讲述一个代码随需求而变的过程,曾一度因为既有代码不能满足新的需求而卡壳。在阅读了 Android 源码后茅塞顿开,立马一顿重构。但重构完成之后,我陷入了沉思。。。。
纯色进度条
最开始,需求是展示如下进度条:
用自定义 View 画两个圆角矩形就能实现:
class ProgressBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) :View(context, attrs, defStyleAttr) { // 背景色 var backgroundColor: String = "#00ff00" set(value) { field = value barPaint.color = Color.parseColor(value) } // 进度条色 var progressColor: String = "#0000ff" set(value) { field = value progressPaint.color = Color.parseColor(value) } // 内边距 var paddingStart: Float = 0f set(value) { field = value.dp } var paddingEnd: Float = 0f set(value) { field = value.dp } var paddingTop: Float = 0f set(value) { field = value.dp } var paddingBottom: Float = 0f set(value) { field = value.dp } var padding: Float = 0f set(value) { field = value.dp paddingStart = value paddingEnd = value paddingTop = value paddingBottom = value } // 背景圆角 var backgroundRx: Float = 0f set(value) { field = value.dp } var backgroundRy: Float = 0f set(value) { field = value.dp } // 进度条圆角 var progressRx: Float = 0f set(value) { field = value.dp } var progressRy: Float = 0f set(value) { field = value.dp } // 进度(0-100) var percentage: Int = 0 // 背景画笔 private var barPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(backgroundColor) style = Paint.Style.FILL } // 进度条画笔 var progressPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(progressColor) style = Paint.Style.FILL } // 进度条区域 var progressRectF = RectF() // 背景区域 private var backgroundRectF = RectF() override fun onDraw(canvas: Canvas?) { // 背景撑满整个控件 backgroundRectF.set(0f, 0f, width.toFloat(), height.toFloat()) // 画背景圆角矩形 canvas?.drawRoundRect(backgroundRectF, backgroundRx, backgroundRy, barPaint) // 画进度条圆角矩形 val foregroundWidth = width * percentage/100F val foregroundTop = paddingTop val foregroundRight = foregroundWidth - paddingEnd val foregroundBottom = height.toFloat() - paddingBottom val foregroundLeft = paddingStart progressRectF.set(foregroundLeft, foregroundTop, foregroundRight, foregroundBottom) canvas?.drawRoundRect(progressRectF, progressRx, progressRy, progressPaint) } }
然后就可以像这样构建一个30%的进度条:
ProgressBar(context).apply { percentage = 30 backgroundRx = 20f backgroundRy = 20f backgroundColor = "#e9e9e9" progressColor = "#ff00ff" progressRx = 15f progressRy = 15f padding = 2f }
渐变进度条
新的需求是渐变色的进度条。只需在绘制圆角矩形时为画笔加上渐变 Shader 即可:
class ProgressBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) :View(context, attrs, defStyleAttr) { // 背景色 var backgroundColor: String = "#00ff00" set(value) { field = value barPaint.color = Color.parseColor(value) } // 进度条色 var progressColor: String = "#0000ff" set(value) { field = value progressPaint.color = Color.parseColor(value) } // 渐变色(String数组) var progressColors = emptyArray<String>() set(value) { field = value // 将 string 色值转换成 int _colors = value.map { Color.parseColor(it) }.toIntArray() } // 渐变色(int数组) private var _colors = intArrayOf() ... override fun onDraw(canvas: Canvas?) { // 画背景圆角矩形 backgroundRectF.set(0f, 0f, width.toFloat(), height.toFloat()) canvas?.drawRoundRect(backgroundRectF, backgroundRx, backgroundRy, barPaint) // 画进度条圆角矩形 val foregroundWidth = width * percentage/100F val foregroundTop = paddingTop val foregroundRight = foregroundWidth - paddingEnd val foregroundBottom = height.toFloat() - paddingBottom val foregroundLeft = paddingStart progressRectF.set(foregroundLeft, foregroundTop, foregroundRight, foregroundBottom) // 如果没有渐变色值就用纯色背景,否则构建渐变 Shader progressPaint.shader = if (progressColors.isEmpty()) null else LinearGradient(0f, 0f, width * progress, height.toFloat(), _colors, null, Shader.TileMode.CLAMP) canvas?.drawRoundRect(progressRectF, progressRx, progressRy, progressPaint) } }
为ProgressBar新增了一个属性progressColors,它是一个 String 数组,用于存储渐变色。然后就可以像这样构建渐变色进度条:
ProgressBar(context).apply { percentage = 30 backgroundRx = 20f backgroundRy = 20f backgroundColor = "#e9e9e9" progressColors = arrayOf("#ff00ff", "#00ff00") progressRx = 15f progressRy = 15f padding = 2f }
需求实现完了,但总感觉代码有些奇怪。
原先用一个属性表达了“进度条颜色”这个语义,现在用两个属性互斥地表达了“进度条颜色”这个语义。这种互斥行为是通过if-else在自定义控件内部实现的:
progressPaint.shader = if (progressColors.isEmpty()) null else LinearGradient(0f, 0f, width * progress, height.toFloat(), _colors, null, Shader.TileMode.CLAMP)
这无疑是一个使用ProgressBar的潜规则,但对于使用者也不难理解。暂时也没有进度条的新需求,就先维持现状吧。
多状态渐变色进度条
一次新的迭代打破了现状。这次进度条的渐变色得和进度值关联,效果如下:
进度条颜色从浅绿色逐渐变深,然后过渡到浅红,最后深红。
ProgressBar已有的两个描述“进度条颜色”的属性都不能表达这个新的语义,即“一组状态对应于一组颜色”,难道还得新增一个Map类型的属性?
直觉告诉我这样做很不好。。。
那有什么更好的方案吗?突然想到View.setBackground(Drawable background),背景不仅可以是纯色、渐变色,还可以和一组状态联动:
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <!-- 控件有效 --> <item android:state_enable="true" android:drawable="@drawable/pic1" /> <!-- 控件无效 --> <item android:state_disable="false" android:drawable="@drawable/pic2" /> </selector>
xml 中定义了有效和无效这两种控件状态并关联了两个 drawable,它可以作为控件的背景。
这是怎么做到的?
源码中得到启发
public class View { private Drawable mBackground;// 背景Drawable public void setBackgroundDrawable(Drawable background) { ... mBackground = background; ... } }
调用setBackgroundDrawable()后,背景 Drawable 会被存储在mBackground变量中。这个变量什么时候会被用到?
public class View { // 绘制 View public void draw(Canvas canvas) { // 绘制背景 if (!dirtyOpaque) { drawBackground(canvas); } ... } // 绘制背景 private void drawBackground(Canvas canvas) { final Drawable background = mBackground; // 将绘制委托给 mBackground 对象 background.draw(canvas); ... } // view 状态变更 public void setEnabled(boolean enabled) { ... // 触发重绘 invalidate(true); ... } }
当 View 状态发生变化时,会触发自身重绘,第一步绘制的是背景。但 View 好像并不关心绘制背景的具体实现,而是把它委托给了mBackground这个 Drawable,并将控件画布canvas传递给它:
public abstract class Drawable { // 在指定 Canvas 上绘制当前 Drawable public abstract void draw(@NonNull Canvas canvas); }
Drawable.draw()是一个抽象方法,具体绘制啥交由子类实现:
// 多状态 Drawable public class StateListDrawable extends DrawableContainer {} public class DrawableContainer extends Drawable { // 当前 Drawable private Drawable mCurrDrawable; @Override public void draw(Canvas canvas) { // 绘制当前 Drawable if (mCurrDrawable != null) { mCurrDrawable.draw(canvas); } ... }
StateListDrawable就是上面 xml 中定义的多状态 Drawable,它的绘制逻辑在父类DrawableContainer中,当draw()执行时,仅绘制当前的mCurrDrawable,它是在哪里被赋值的?
public class DrawableContainer extends Drawable { // Drawable 容器 private DrawableContainerState mDrawableContainerState; // 根据索引值选择 Drawable public boolean selectDrawable(int index) { ... // 从 Drawable 容器中根据索引值挑选 Drawable final Drawable d = mDrawableContainerState.getChild(index); // 将选中的 Drawable 赋值给 mCurrDrawable mCurrDrawable = d; } } public class StateListDrawable extends DrawableContainer { // 当 Drawable 状态变化时回调此方法 @Override protected boolean onStateChange(int[] stateSet) { ... // 挑选 Drawable return selectDrawable(idx) || changed; } }
当StateListDrawable状态发生变化时,会从DrawableContainerState中根据索引挑选一张 Drawable,它就作为下一次draw()执行时被绘制的对象。那 Drawable 是存放在什么样的容器中?
public class DrawableContainer extends Drawable { // Drawable容器 public abstract static class DrawableContainerState extends ConstantState { // Drawable数组 Drawable[] mDrawables; // 根据索引获取 Drawable public final Drawable getChild(int index) { final Drawable result = mDrawables[index]; if (result != null) { return result; } ... } } }
虽然源码中还有超多细节不理解,但看到这里可以粗略地得出下面的结论:
- View 将绘制背景委托给 Drawable,当为背景设置不同 Drawable 实例时,就实现了背景的多态。
- StateListDrawable 是一个特殊的 Drawable,它持有一组状态和与之对应的 Drawable 实例。状态变化时,它挑选合适的 Drawable 进行绘制。
这两个结论已经够用了,将它们沿用到ProgressBar。
更有余地的进度条
先模仿 Drawable,抽象出一个Progress接口:
//进度接口 interface Progress { // 绘制进度 fun draw(canvas: Canvas?, progressBar: ProgressBar) // 进度百分比变化回调(并不是每个进度实例都关心百分比变化,所以留了一个空实现) fun onPercentageChange(old: Int, new: Int) {} }
然后让ProgressBar持有Progress实例:
class ProgressBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { // 进度实例 var progress: Progress? = null // 进度条百分比,当百分比变化时先回调接口再触发重绘 var percentage: Int by Delegates.observable(0) { _, oldValue, newValue -> progress?.onPercentageChange(oldValue, newValue) postInvalidate() } override fun onDraw(canvas: Canvas?) { // 绘制进度条背景 backgroundRectF.set(0f, 0f, width.toFloat(), height.toFloat()) canvas?.drawRoundRect(backgroundRectF, backgroundRx, backgroundRy, barPaint) // 计算进度条绘制区域 val foregroundWidth = width * percentage/100F val foregroundTop = paddingTop val foregroundRight = foregroundWidth - paddingEnd val foregroundBottom = height.toFloat() - paddingBottom val foregroundLeft = paddingStart progressRectF.set(foregroundLeft, foregroundTop, foregroundRight, foregroundBottom) // 将绘制任务委托给进度实例 progress?.draw(canvas, this) } }
经过一层抽象,ProgressBar没有具体的进度绘制逻辑,它的功能已经退化为“先绘制背景,再绘制进度前景”。
接着通过实现Progress接口来实现进度条样式多态,纯色进度条定义如下:
class SolidColorProgress(var solidColor: String) : Progress { // 纯色画笔 private var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(solidColor) style = Paint.Style.FILL } // 绘制纯色矩形 override fun draw(canvas: Canvas?, progressBar: ProgressBar) { progressBar.run { canvas?.drawRoundRect(progressRectF, progressRx, progressRy, paint) } } }
然后就可以像这样构建纯色进度条:
ProgressBar(context).apply { percentage = 30 backgroundColor = "#e9e9e9" progress = SolidColorProgress("#ff00ff") }
多状态渐变进度条定义如下:
// 多状态渐变进度条(构造时需传入状态与渐变色的键值对) class StateGradientProgress(var stateMap: Map<IntRange, IntArray>) : Progress { // 当前应该绘制的渐变色值 private var currentColors: IntArray? = null private var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } // 默认渐变色值 private val DEFAULT_COLORS = intArrayOf(0xFFFF00FF.toInt(), 0xFF0000FF.toInt()) override fun draw(canvas: Canvas?, progressBar: ProgressBar) { // 构建线性渐变 Shader 并绘制渐变圆角矩形 progressBar.run { paint.shader = LinearGradient( progressRectF.left, progressRectF.top, progressRectF.right, progressRectF.bottom, currentColors, null, Shader.TileMode.CLAMP ) canvas?.drawRoundRect(progressRectF, progressRx, progressRy, paint) } } // 当进度百分比变化时,选择合适的颜色值进行绘制 override fun onPercentageChange(old: Int, new: Int) { currentColors = stateMap.find { new in it.key } ?: DEFAULT_COLORS } }
对于StateGradientProgress来说:
- 状态是一个百分比区间,用 Int 类型表示时,它的范围是 0-100,对应的 Kotlin 类型就是
IntRange。 - 与每个状态对应的是一组色值,用于传递给
Shader绘制渐变,对应的 Kotlin 类型就是IntArray。
当百分比发生变化时,遍历百分比色值键值对,找到百分比落在哪个区间,也就找到了对应的渐变色值。Map.find()是一个新增的扩展方法:
// 遍历键值对,当键满足条件时,返回对应的键 inline fun <K, V> Map<K, V>.find(predicate: (Map.Entry<K, V>) -> Boolean): V? { forEach { entry -> if (predicate(entry)) return entry.value } return null }
然后就可以像这样构建多状态渐变进度条了:
ProgressBar(context).apply { backgroundColor = "#F5F5F5" progress = stateListOf( 0..19 to arrayOf(0x8000FFE5, 0x80E7FFAA), 20..59 to arrayOf(0xFF00FFE5, 0xFFE7FFAA), 60..79 to arrayOf(0xCCFE579B, 0xCCF9FF19), 80..100 to arrayOf(0xFFFE579B, 0xFFF9FF19) ) }
其中stateListOf()是一个顶层方法,用于构建StateGradientProgress实例:
// 将一组 Pair 转换成 Map 传入 StateGradientProgress 实例 fun stateListOf(vararg states: Pair<IntRange, Array<Long>>) = StateGradientProgress( mutableMapOf<IntRange, IntArray>().apply { // 将 Long 数组转换成 Int 数组 states.forEach { state -> put(state.first, state.second.toIntArray()) } } )
这样做的目的是简化构建代码,否则代码就会变成这样:
ProgressBar(context).apply { backgroundColor = "#F5F5F5" progress = stateListOf( 0..19 to intArrayOf(0x8000FFE5.toInt(), 0x80E7FFAA.toInt()), 20..59 to intArrayOf(0xFF00FFE5.toInt(), 0xFFE7FFAA.toInt()), 60..79 to intArrayOf(0xCCFE579B.toInt(), 0xCCF9FF19.toInt()), 80..100 to intArrayOf(0xFFFE579B.toInt(), 0xFFF9FF19.toInt()) ) }
0xARGB在 Kotlin 中的类型是Long,而LinearGradient的构造方法接收的色值是 int 数组。所以只能在stateListOf()中将 Long 数组转换成 Int 数组:
// 遍历 Long 数组,并强转每个元素为 Int fun Array<out Long>.toIntArray(): IntArray { return IntArray(size) { index -> this[index].toInt() } }
沉思
最开始,代码的语义是“画一条纯色进度条”,
接下来,代码的语义是“画一条纯色或者渐变色进度条”,
重构后,代码的语义是“画一条进度条”。
就像说话一样,代码写得越具体,就越没有余地留给扩展性。
重构为代码增加了扩展性,但代价是什么?
新增了一个抽象层次(接口),新增了若干个实现接口的类。这无疑增加了代码的复杂度。
引入的复杂度配得上它提供的扩展性吗?
当前的迭代周期需要这种扩展性吗?
增加扩展性会破坏既有代码吗?
增加扩展性会影响项目进度吗?
这些问题困扰了我许久。。。
就像讲话一样,如果句句留有余地,不免给人感觉谨小慎微。若代码也处处留有余地,除了工作量增加外,也不免增加了代码的理解成本,甚至可能让人觉得这是“过度设计式地卖弄”。
如果进度条需求从此不再迭代新增样式,这波重构就显得有点过度设计。
如果在进度条新样式到来之前还未进行这波重构,自定义进度条就显得没有扩展性。
辨别出“善变的”与“不变的”逻辑,在合适的场合留有余地,是一项值得不断斟酌的技能。
就像说话一样,编程中的有些东西不是科学,它更像艺术。没有银弹般的公式可以精准衡量每个问题的对错。
最后,想下一个抛砖引玉的结论:
过早的优化对于项目来说是奢侈的,而持续渐进的重构是值得尝试的,当原有设计越来越难应付新变化时,顺手重构一波也是不迟的。
Talk is cheap, show me the code
完整代码在这个repo中的test.taylor.com.taylorcode.ui.custom_view.progress_view包下
推荐阅读
这是读源码长知识系列的第六篇,系列文章目录如下:
- 读源码长知识 | 更好的RecyclerView点击监听器
- Android自定义控件 | 源码里有宝藏之自动换行控件
- Android自定义控件 | 小红点的三种实现(下)
- 读源码长知识 | 动态扩展类并绑定生命周期的新方式
- 读源码长知识 | Android卡顿真的是因为”掉帧“?
- 读原码长知识 | 就像讲话一样,写代码也要留有余地!?
版权声明:
本文来源网络,所有图片文章版权属于原作者,如有侵权,联系删除。
本文网址:https://www.mushiming.com/mjyfx/16342.html