讲述一个代码随需求而变的过程,曾一度因为既有代码不能满足新的需求而卡壳。在阅读了 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
包下
推荐阅读
这是读源码长知识系列的第六篇,系列文章目录如下: