ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android 自定义进度条实战:从 Drawable 到组件化封装

Android 自定义进度条实战:从 Drawable 到组件化封装 1. 项目源起一个下载场景把默认进度条逼到了墙角做 Android 开发这几年自定义进度条几乎是每个项目都会碰到的需求。最近我在做一款工具型 App 的缓存下载页产品的要求很具体横向进度条要有圆角、渐变中间还要显示百分比文字下载过程中最好有一点流动反馈。需求一出来我就知道默认的 ProgressBar 顶不住必须先搞清楚它到底能做什么、不能做什么再决定怎么改。默认的 ProgressBar 不是不能改颜色android:progressTint、android:indeterminateTint这些属性都可以调整主题色但问题在于圆角、渐变、文字覆盖、流畅的动画反馈这些都不是简单换几个颜色能搞定的。而且真实项目里也不会只做一个样式可能下载页一个样式、上传页一个样式、列表加载又是一个样式如果全靠一个个属性去试代码和样式文件会越堆越乱。所以我把“自定义 ProgressBar 进度条”当成一个小型组件来做而不是临时调样式。整篇内容就围绕这个思路展开先用系统自带的 LayerDrawable 把进度条外观换掉再通过组合 View 加文字和动画最后把过程中踩过的坑和排查思路整理出来。这套方案不依赖第三方库也不需要重写 View 的 onDraw熟练以后半天足够搭完一个可复用的进度条组件。1.1 为什么默认 ProgressBar 不够用很多人第一次接触进度条第一反应是直接用android:progressTint改颜色。这个做法在简单场景下确实最快比如只要一个纯色进度条。但一旦需求变成“圆角、渐变、双层进度、文字居中”默认 ProgressBar 就暴露了很多限制默认横向进度条的高度和样式受系统主题控制不同 Android 版本、不同 ROM 上的表现会有差异进度条轨道和进度条本身都只是普通 Drawable不支持直接配置圆角和渐变进度文字需要额外叠加进度条本身没有内置文字的能力如果项目里要给下载进度和缓存进度分别做两层展示默认样式很难区分系统默认的进度条动画观感比较机械开启 indeterminate 时容易显得老气。这些问题不是靠progressTint能解决的。最直接的办法是接管 ProgressBar 的progressDrawable把进度条内部每一层都替换成自定义 Drawable。ProgressBar 的机制本身就不复杂它内部把进度值映射成 Drawable 的 level通过 ClipDrawable 去裁剪显示区域。我们只要按照系统约定好的图层 ID 去组装 Drawable就能完全控制外观。1.2 自定义的两个大方向改 Drawable 还是重写 View刚上手的人容易纠结要不要自定义 View。我的建议是先看需求复杂度。如果只是外观不一样用 LayerDrawable ClipDrawable 就足够了不仅写法简单而且不需要处理测量、绘制、触摸事件这些麻烦问题。如果需求里有复杂动画、特殊交互、自定义属性很多再考虑自定义 View。我之前在项目里做过对比。一个纯展示型进度条用 XML 写 Drawable 和布局前后二十分钟就能搞定而如果冲动去写 CustomView涉及 onMeasure、onDraw、动画线程稍不注意就会出现测量尺寸不对、动画占 CPU、布局预览失效之类的问题。自定义 ProgressBar 的正确姿势应该是“能组合就不重写能 XML 就不写代码”把系统能力榨干之后再动手造轮子。2. 基础版实现用 XML LayerDrawable 替换默认样式既然思路明确我就直接带大家把基础版做出来。这个基础版解决三个问题圆角、渐变、双层进度展示。整个过程不需要写 Java/Kotlin 代码只需要在 res 目录下放几个 XML 文件然后把progressDrawable指过去。2.1 先认识 ProgressBar 的图层协议自定义 progressDrawable 的第一步是先理解系统认哪几个 ID。ProgressBar 在横向模式下会默认把 progressDrawable 当作 LayerDrawable其中三个 item 的 id 是有特殊含义的android:id/background进度条底轨android:id/secondaryProgress缓冲进度或缓存进度层android:id/progress实际进度层。ProgressBar 更新进度时会去 LayerDrawable 里找这些 id 对应的层然后把进度值转换成的 level 设置到 ClipDrawable 上。只有 progress 层做裁剪background 层始终保持完整。所以写 layer-list 时顺序不能乱background 在最下面secondaryProgress 在中间progress 在最上面。2.2 写一个圆角渐变进度条 Drawable很多示例喜欢把所有 Shape 直接塞进 layer-list 的 item 里但我建议把每一层拆成独立文件尤其是渐变色、圆角这些样式以后会被其他页面复用拆开之后管理起来更清晰。先建一个底轨 Drawableres/drawable/pb_bg.xmlshape xmlns:androidhttp://schemas.android.com/apk/res/android android:shaperectangle corners android:radius8dp / solid android:color#EEEEEE / /shape再建一个缓存进度层res/drawable/pb_secondary.xmlshape xmlns:androidhttp://schemas.android.com/apk/res/android android:shaperectangle corners android:radius8dp / solid android:color#CCCCCC / /shape主进度层我用渐变res/drawable/pb_progress.xmlshape xmlns:androidhttp://schemas.android.com/apk/res/android android:shaperectangle corners android:radius8dp / gradient android:startColor#4FC3F7 android:endColor#0288D1 android:angle0 / /shape最后把这些层组合成进度条的 progressDrawableres/drawable/pb_layer_list.xmllayer-list xmlns:androidhttp://schemas.android.com/apk/res/android item android:idandroid:id/background android:drawabledrawable/pb_bg / item android:idandroid:id/secondaryProgress android:drawabledrawable/pb_secondary / item android:idandroid:id/progress clip android:drawabledrawable/pb_progress android:clipOrientationhorizontal android:gravityleft / /item /layer-list这里有几个细节需要特别注意。clip需要设置android:clipOrientationhorizontal并且android:gravityleft表示进度从左往右铺开。如果把 gravity 写成center进度会从中间往两边扩不是我熟悉的横向进度条表现。另外angle0表示渐变从左往右写成90就是从上往下很多人在这一步看效果不对基本都是角度问题。2.3 把 Drawable 挂到 ProgressBar 上Drawable 文件准备好之后布局里就直接用progressDrawable引用。我习惯给 ProgressBar 设置progressBarStyleHorizontal否则有些主题下它可能还是圆形加载样式progressDrawable不会生效。ProgressBar style?android:attr/progressBarStyleHorizontal android:layout_widthmatch_parent android:layout_height16dp android:max100 android:progress30 android:secondaryProgress60 android:progressDrawabledrawable/pb_layer_list android:minHeight12dp android:maxHeight16dp /先解释一下为什么progress要设置成 30、secondaryProgress设置成 60。在下载场景里通常需要展示两层进度底层是已下载缓存大小顶层是当前文件下载进度。这样用户一眼能看出“缓存比实际进度快”体验上更接近真实下载工具。注意layout_height我直接写成了 16dp而不是wrap_content。原因是 LayerDrawable 里的 Shape 本身没有固定的 intrinsic height如果布局高度写成 wrap_content在部分国产 ROM 上会塌成一条细线。最稳妥的做法是给一个明确高度同时配合minHeight和maxHeight。这一步做完一个圆角渐变、带双层进度的基础版就完成了。运行项目调整setProgress和setSecondaryProgress你会看到进度条按照预期裁剪显示。3. 进阶让进度条自带百分比文字和动画基础版能解决外观但实际下载页还需要文字和动画。我用的方案比较朴素在 FrameLayout 里把 ProgressBar 和 TextView 叠在一起通过代码同步进度和文字。这种做法比自定义 View 快也比 Canvas 画文字更容易控制字体、颜色和样式。3.1 一个稳妥做法FrameLayout 套 ProgressBar TextView布局大概长这样FrameLayout android:layout_widthmatch_parent android:layout_heightwrap_content ProgressBar android:idid/download_progress style?android:attr/progressBarStyleHorizontal android:layout_widthmatch_parent android:layout_height16dp android:max100 android:progress0 android:progressDrawabledrawable/pb_layer_list / TextView android:idid/download_percent android:layout_widthwrap_content android:layout_heightwrap_content android:layout_gravitycenter android:gravitycenter android:text0% android:textColor#FFFFFF android:textSize11sp / /FrameLayout文字为什么要放在 FrameLayout 里而不是用自定义 View 去画因为文字居中、字号、颜色这些都是 TextView 自带能力用 XML 就能直接预览。如果放到自定义 View 里去 drawText还要自己计算 baseline、处理换行、适配字体缩放完全是重复劳动。3.2 代码更新进度和文字当进度回调过来时只需要同步更新两个控件fun updateProgress(progress: Int) { binding.downloadProgress.progress progress binding.downloadPercent.text $progress% }如果还有缓存进度层就多更新一个字段binding.downloadProgress.secondaryProgress cachedProgress这里有个经验下载回调的频率通常很高尤其是本地文件拷贝场景可能一秒钟回调几十次。如果每次都直接刷新进度和文字TextView 会频繁请求重绘性能差的机器上会出现肉眼可见的卡顿。所以我在真实项目里会做一个简单的节流只在进度变化超过 1% 或间隔超过 100ms 时才刷新 UI。3.3 动画细节用 ValueAnimator 平滑推进直接 setProgress 的观感是“跳变”尤其进度从 30 跳到 31用户会觉得很生硬。我一般在进度条上套一层 ValueAnimator让进度在几百毫秒内平滑过渡到目标值。fun smoothProgressTo(target: Int, duration: Long 400L) { val start binding.downloadProgress.progress val animator ValueAnimator.ofInt(start, target) animator.addUpdateListener { animation - val value animation.animatedValue as Int binding.downloadProgress.progress value binding.downloadPercent.text $value% } animator.interpolator DecelerateInterpolator() animator.duration duration animator.start() }DecelerateInterpolator的好处是开始快、结束慢下载到最后阶段时不会反复跳动。动画时长不要太长400ms 左右比较自然超过 800ms 用户会觉得进度条在“追进度”反而不真实。注意ValueAnimator 更新的是进度条的 progress 属性不要在这里额外启动线程也不要在回调里再开一个 Handler。动画本身跑在 Choreographer 的帧回调上安安静静更新属性就好。4. 实操中绕不开的坑样式、比例、多线程自定义 ProgressBar 的原理不复杂但实际项目里翻车的地方往往非常低级。我把高频问题集中在这里讲一下都是我自己或者团队同事踩过的坑。4.1 进度条高度不生效表现是无论怎么改layout_height进度条始终只有一条细线或者 Drawable 被拉伸得很难看。这个问题的根源在于 ShapeDrawable 没有 intrinsic height而部分主题又给 ProgressBar 设置了默认的minHeight。解决办法有三步布局高度不要用 wrap_content直接给一个明确 dp 值同时设置android:maxHeight避免系统主题把高度强行压低如果自定义 Drawable 里有文字或装饰给 Drawable 本身加上 padding而不是只调 View 高度。我在项目里经常写android:layout_height16dp、android:minHeight12dp、android:maxHeight16dp三件套基本能避开高度问题。4.2 progressTint 覆盖自定义渐变Android 5.0 之后系统会给 ProgressBar 自动应用主题色尤其是 Material 主题下progressTint会直接叠加在 progressDrawable 上导致精心配的渐变被染成单色。这个坑很隐蔽因为布局预览里看不到真机上运行才会发现颜色不对。解决办法是在 ProgressBar 上显式把 tint 清掉android:progressTintnull android:progressBackgroundTintnull如果你用的是 MaterialComponents 主题还有可能被?attr/colorPrimary影响。自定义 Drawable 之后检查一下主题和控件上有没有继承来的 tint是排查渐变失效的第一步。4.3 多线程更新进度必须回到主线程进度条是 View更新 View 一定要在主线程。很多人从网络库回调里直接progressBar.progress x结果发现有的时候更新无效有的时候直接抛 “Cant create handler inside thread that has not called Looper.prepare()”。最稳妥的做法是始终通过主线程更新runOnUiThread { updateProgress(newProgress) }如果项目里用 Kotlin 协程直接切到主线程调度器withContext(Dispatchers.Main) { updateProgress(newProgress) }还有一个经验不要在 Service 里直接持有 Activity 里的 ProgressBar 引用。下载任务在后台持续运行Activity 可能已经销毁。我一般用回调接口把进度发出去由界面层判断是否还能更新 UI避免内存泄漏。4.4 不确定模式与确定模式混用ProgressBar 有两种模式一种是通过progress更新确定进度另一种是通过indeterminate显示无限旋转动画。很多人会在布局里同时开着indeterminatetrue又设置progress结果发现进度条根本不走。这是因为 indeterminate 模式下ProgressBar 会使用indeterminateDrawable完全忽略我们设置的 progressDrawable 和 progress。下载场景不需要 indeterminate布局里保持默认 false 即可。如果某些 loading 场景确实要转圈建议单独用一个ProgressBar不要和横向进度条共用同一个控件。5. 现场排查实录从“不显示”到“列表错乱”再到“卡顿”这一节是我自己在调试过程中的真实记录。进度条出问题多半不是原理多深奥而是某个 XML 属性写错或者生命周期没考虑好。5.1 进度条整个不显示的三个原因第一种是layer-list里的 item 没有设置系统 id。ProgressBar 内部拿着android:id/progress去 LayerDrawable 里查找找不到就干脆不绘制。第二种是高度归零ShapeDrawable 没有 intrinsic sizelayout_height 又是 wrap_content就会塌掉。第三种是颜色全透明尤其是渐变色的 endColor 写成了透明色远看就像没显示。我排查进度条不显示时有一个固定顺序先看布局高度再看 progressDrawable 的 item id最后看颜色值。这个顺序命中率最高基本上都是这三类问题。5.2 RecyclerView 列表里多个进度条互相串数据列表页如果每个 item 都有进度条最容易翻车的是复用错乱。比如第一个 item 的进度是 80滑出去再滑回来第二个 item 可能也显示 80。原因很简单ViewHolder 复用时旧的 progress 值没有被重置。解决办法是在 bind 的时候先重置再设置holder.progressBar.progress 0 holder.progressBar.secondaryProgress 0 holder.percentText.text 0% if (item.progress 0) { holder.progressBar.progress item.progress holder.percentText.text ${item.progress}% }还有一个细节如果在 bind 里启动了 ValueAnimator一定要在复用前把动画取消掉否则列表快速滑动时一个 item 的动画还会继续更新已经复用的另一个 item。5.3 进度条卡顿、掉帧的排查思路进度条在低端机上卡顿最常见的原因是更新频率太高。网络下载进度的回调常常是每几毫秒一次如果每次都 setProgress除了重绘之外还会触发 layout自然掉帧。我的优化很朴素把更新频率限制在 100ms 左右或者只在进度数值变化超过 1% 时刷新。一个稳定 60fps 的进度条比一个 30fps 但“实时”的进度条体验好太多。还有一个容易被忽略的点渐变 ShapeDrawable 每次重绘都会做一次渐变渲染频繁更新时比纯色耗性能。如果进度条需要长时间高频刷新可以换成纯色加一层渐变蒙版或者降低渐变区域的复杂度。6. 组件化封装与后续扩展基础功能完成后下一步就是把它从“某个页面里的进度条”变成“项目里可复用的组件”。我习惯用自定义属性把颜色、圆角、文字开关这些都暴露出来这样后续换肤、多语言、深浅色模式都不用改结构。6.1 自定义属性attrs.xml先在res/values/attrs.xml里定义一组属性resources declare-styleable nameCustomProgressBar attr nameprogressColor formatcolor / attr nametrackColor formatcolor / attr nameradius formatdimension / attr nameshowText formatboolean / /declare-styleable /resources然后在自定义 View 或自定义组合布局里读取这些属性通过代码动态生成 Drawable。这样调用方只需要写com.example.widget.CustomProgressBar android:layout_widthmatch_parent android:layout_height16dp app:progressColorcolor/brand_blue app:trackColorcolor/bg_gray app:radius8dp app:showTexttrue /封装的价值不是让你所有界面都用同一个组件而是让进度条的视觉输入被收敛到少数几个属性上。以后产品改一个主色只需要改全局颜色或换主题不需要翻遍每一个布局文件去改 drawable。6.2 不同场景的方案选择建议只改颜色用progressTint别写 Drawable圆角 渐变用本文的 LayerDrawable ClipDrawable中间要显示文字用 FrameLayout 组合不写自定义 View需要大量动画和状态管理才考虑自绘 View组件库或 UI 库优先投入自定义属性封装。这套决策顺序帮我省了很多时间。大多数进度条需求都落在第二和第三种方案里真正需要写 onDraw 的场景其实很少。6.3 一点个人建议最后分享几条我在这个项目里总结的经验第一进度条不只是为了展示数字更重要的是给用户一个“事情还在继续”的反馈所以动画要适度别让用户觉得卡第二先把系统 ProgressBar 的图层协议吃透比到处找第三方库更可靠因为第三方库到了大项目里往往也要改第三所有的坑都比想象中更基础排查时从 id、高度、tint 起步效率最高。我自己做完这套组件后后来不管是上传头像、下载缓存还是后台任务进度提示都是直接复用同一套 Drawable 和封装逻辑。自定义 ProgressBar 这件事只要第一遍把基础打扎实后续扩展基本都是水到渠成的事。
返回列表