读原码长知识 | 就像讲话一样,写代码也要留有余地!?

3,979 阅读11分钟

讲述一个代码随需求而变的过程,曾一度因为既有代码不能满足新的需求而卡壳。在阅读了 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;
            }
            ...
        }
    }
}

虽然源码中还有超多细节不理解,但看到这里可以粗略地得出下面的结论:

  1. View 将绘制背景委托给 Drawable,当为背景设置不同 Drawable 实例时,就实现了背景的多态。
  2. 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包下

推荐阅读

这是读源码长知识系列的第六篇,系列文章目录如下:

  1. 读源码长知识 | 更好的RecyclerView点击监听器
  2. Android自定义控件 | 源码里有宝藏之自动换行控件
  3. Android自定义控件 | 小红点的三种实现(下)
  4. 读源码长知识 | 动态扩展类并绑定生命周期的新方式
  5. 读源码长知识 | Android卡顿真的是因为”掉帧“?
  6. 读原码长知识 | 就像讲话一样,写代码也要留有余地!?