该系列的上两篇介绍了如何高效地量化绘制性能,并对 RecyclerView 加载速度做了 4 次优化,使得表项加载耗时从 370 ms 缩减到 170 ms。这一篇再介绍一种终极优化手段,把加载耗时再打对折。
这次性能调优的界面如下:
界面用列表的形式,展示了一个主播排行榜。
不要小看这个简单的界面,在一屏中展示了 17 个表项,并且每个表项 2 个 ImageView 和 5 个 TextView,其中图片还得依赖网络拉取。
若首次加载该列表的同时,界面上还有动画的话,必然会造成动画的掉帧。因为列表的加载很耗时。
回顾下上两篇做的优化:
- 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
- 替换表项根布局,由更简单的
PercentLayout
取代ConstraintLayout
,以缩短 measure + layout 时间。
- 使用协程 + Glide 同步加载方法,以缩减加载图片耗时。
- 将列表首屏显示的表项合并成一个新的表项类型,以缩短填充表项耗时。
关于这四点优化的详细讲解可以点击:
单个表项中控件越多,表项复杂度越高,构建表项消耗的 measure + layout 时间就越长。
于是我有一个大胆的想法:有没有什么办法把单个表项内的多个控件变成一个控件?
大部分场景下,表项都是图文混排,即文字(TextView)+ 图片(ImageView)。
能不能不使用这两个控件,直接把文字和图片绘制在画布上?
绘制文字
先从简单的文字开始,不就是在合适的位置调用Canvas.drawText()
吗!
但仔细一想,没那么简单,drawText()
只能绘制单行文字,若是一长串文字,怎么换行展示?
绘制换行文字
经过一顿搜索,换行绘制文字这些细节StaticLayout
已经帮我们处理好了。
使用StaticLayout
绘制文字的模板代码如下:
// 自定义 View class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { // 文字画笔 private var textPaint: TextPaint? = null // StaticLayout 实例 private var staticLayout: StaticLayout? = null // 待绘制的文字 var text: CharSequence = "" // 字号 var textSize: Float = 0f // 字色 var textColor: Int = Color.parseColor("#ff00ff") // 单行文字宽度(超过这个宽度会自动换行) var textWidth: Int = 0 // 行间距 var spaceAdd: Float = 0f // 行距倍数 var spcaeMult: Float = 1.0f override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 在测量的时候构建画笔 if (textPaint == null) { textPaint = TextPaint().apply { isAntiAlias = true textSize = this@OneViewGroup.textSize color = textColor } } // 在测量的时候构建 StaticLayout 实例 if (staticLayout == null) { staticLayout = StaticLayout.Builder.obtain(text, 0, text.length, textPaint!!, textWidth) .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setLineSpacing(spaceAdd, spcaeMult) .setIncludePad(false) .build() } } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) // 绘制文字 staticLayout?.draw(canvas) } }
自定义控件OneViewGroup
会在自己的左上角绘制text
属性指定的文字。
抽象出可绘制对象
OneViewGroup
应该持有一组text
,以表示在不同位置绘制的多个文字。直接用List<CharSequence>
来表达这样的需求也没不可以,但是考虑到除了文字还有图片,难道再用一个List<Bitmap>
成员来表达?
更好的方案是抽象出一个“可绘制”实体,让上层类OneViewGroup
和这个抽象互动:
// 可绘制实体类 interface Drawable { fun measure(widthMeasureSpec: Int, heightMeasureSpec: Int) fun draw(canvas: Canvas?) } // 可绘制的文字 class Text : Drawable { override fun measure(widthMeasureSpec: Int, heightMeasureSpec: Int) {} override fun draw(canvas: Canvas?) {} } // 可绘制的图片 class Image : Drawable { override fun measure(widthMeasureSpec: Int, heightMeasureSpec: Int) {} override fun draw(canvas: Canvas?) {} }
OneViewGroup
持有一组Drawalbe
实例:
class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { // 持有一组 Drawable 实例 private val drawables = mutableListOf<Drawable>() // 添加 Drawable 实例 fun addDrawable(drawable: Drawable) { drawables.add(drawable) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 将测量委托给 Drawable 实例处理 drawables.forEach { it.measure(widthMeasureSpec, heightMeasureSpec) } } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) // 将绘制委托给 Drawable 实例处理 drawables.forEach { it.draw(canvas) } } }
绘制文字的细节就可以移到子类Text
类中:
class Text : Drawable { var textPaint: TextPaint? = null var staticLayout: StaticLayout? = null var text: CharSequence = "" var textSize: Float = 0f var textColor: Int = Color.parseColor("#ff00ff") var textWidth: Int = 0 var spaceAdd: Float = 0f var spcaeMult: Float = 1.0f override fun measure(widthMeasureSpec: Int, heightMeasureSpec: Int) { if (textPaint == null) { textPaint = TextPaint().apply { isAntiAlias = true textSize = this@Text.textSize color = textColor } } if (staticLayout == null) { staticLayout = StaticLayout.Builder.obtain(text, 0, text.length, textPaint!!, textWidth) .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setLineSpacing(spaceAdd, spcaeMult) .setIncludePad(false) .build() } } override fun draw(canvas: Canvas?) { staticLayout?.draw(canvas) } }
这样的设计符合依赖倒置原则,即上层类OneViewGroup
不依赖下层类Text
,它们都依赖一个抽象Drawalbe
。
这样一来OneViewGroup
就又符合开闭原则了,即新增可绘制类型时不需要修改OneViewGroup
类,只需要新建一个Drawable
的子类即可。
再定义一个扩展方法用于构建Text
对象:
inline fun OneViewGroup.text(init: Text.() -> Unit) = // 构建 Text 实例并应用属性,再加入到 OneViewGroup 中 Text().apply(init).also { addDrawable(it) }
方法被定义为OneViewGroup
的扩展方法,这样的好处是只要在OneViewGroup
上下文环境中就可以轻松的构建Text
实例。
扩展方法传入的参数是一个带接收者的 lambda,它是一种特殊的 lambda,kotlin 中特有的。可以把它理解成“为接收者声明的一个匿名扩展函数”。
带接收者的 lambda 的函数体除了能访问其所在类的成员外,还能访问接收者的所有非私有成员,这个特性使它能够轻松地构建结构。
Text.() -> Unit
的接收者是Text
,意味着,可以在 lambda 函数体中轻松的设置Text
实例的属性。
再配合构造OneViewGroup
的扩展法方法:
// 在 Context 上下文中轻松地构建 OneViewGroup 实例 inline fun Context.OneViewGroup(init: OneViewGroup.() -> Unit): OneViewGroup = return OneViewGroup(this).apply(init)
就可以用声明式的语法来构建布局了:
OneViewGroup { layout_width = match_parent layout_height = match_parent text { text = "title" textSize = 40f textColor = "#ffffff" textWidth = 200 } text { text = "content" textSize = 30f textColor = "#ffffff" textWidth = 300 } }
上述代码会在OneViewGroup
控件的左上角绘制两行文字,不过这两行文字是重叠在一起的,因为还没有指定他们的相对位置。
文字相对布局
staticLayout.draw(canvas)
并没有提供绘制坐标的参数。所以只能通过平移画布来实现在不同位置绘制文字:
class Text : Drawable { var left: Float = 0f var right: Float = 0f override fun draw(canvas: Canvas?) { canvas?.save() // 记忆当前画布位置 canvas?.translate(left, top) // 平移画布到绘制点(left, top) staticLayout?.draw(canvas) // 绘制文字 canvas?.restore() // 还原当初画布位置 } }
然后就可以像这样指定文字的绝对位置:
OneViewGroup { layout_width = match_parent layout_height = match_parent text { text = "title" textSize = 40f textColor = "#ffffff" textWidth = 200 left = 10 // 距离父控件左边 10 像素 top = 20 // 距离父控件顶部 20 像素 } text { text = "content" textSize = 30f textColor = "#ffffff" textWidth = 300 left = 10 // 距离父控件左边 10 像素 top = 50 // 距离父控件顶部 50 像素 } }
用绝对像素值显然不能满足实际项目的要求。像素布局无法解决多屏幕适配的问题,用相对于父控件的绝对位置来布局也不能满足子控件间相对布局的需求。
还记得在RecyclerView 性能优化 | 把加载表项耗时减半 (一)中介绍的PercentLayout
吗?,它是一个自定义ViewGroup
,其中的子控件有一组相对属性来指定相对位置。将这套相对布局方法移植过来。
相对属性不是Text
独有的,应该将它们上提到Drawable
中:
abstract class Drawable { // 用 Int 值作为唯一标识 var id: Int = -1 // 距离父控件左上角的百分比值 var leftPercent: Float = -1f var topPercent: Float = -1f // 相对布局属性 var startToStartOf: Int = -1 var startToEndOf: Int = -1 var endToEndOf: Int = -1 var endToStartOf: Int = -1 var topToTopOf: Int = -1 var topToBottomOf: Int = -1 var bottomToTopOf: Int = -1 var bottomToBottomOf: Int = -1 var centerHorizontalOf: Int = -1 var centerVerticalOf: Int = -1 // 业务层指定的宽高 var width = 0 var height = 0 // 上下左右边距 var topMargin = 0 var bottomMargin = 0 var leftMargin = 0 var rightMargin = 0 // 用于保存测量宽高结果的变量 var measuredWidth = 0 var measuredHeight = 0 // 上下左右用于描述可绘制对象所处矩形 var left = 0 var right = 0 var top = 0 var bottom = 0 // 上下左右内边距 var paddingStart = 0 var paddingEnd = 0 var paddingTop = 0 var paddingBottom = 0 // 如何测量及绘制由子类定义 abstract fun measure(widthMeasureSpec: Int, heightMeasureSpec: Int) abstract fun draw(canvas: Canvas?) // 布局的结果保存在上下左右四个变量组成的矩形中 fun setRect(left: Int, top: Int, right: Int, bottom: Int) { this.left = left this.right = right this.top = top this.bottom = bottom } // 测量的结果保存在 measuredWidth 和 measuredHeight fun setDimension(width: Int, height: Int) { this.measuredWidth = width this.measuredHeight = height } }
为Drawable
新增了很多属性,用于描述它的尺寸及相对位置。还新增了两个方法用于保存测量和布局的结果。因为同时存在抽象和非抽象方法,就把原先的接口重构成了抽象类。
然后重写onLayout()
以定位所有Drawable
对象相对于父控件的位置:
class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { // 容器1:保存 Drawable 及其 id 的对应关系 private val drawableMap = HashMap<Int, Drawable>() // 容器2:按序保存所有 Drawable 实例 private val drawables = mutableListOf<Drawable>() // 向父控件中添加 Drawable 对象,它的引用会同时存储在两种容器中 fun addDrawable(drawable: Drawable) { drawables.add(drawable) drawableMap[drawable.id] = drawable } // 按序测量所有 Drawable override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) drawables.forEach { it.measure(widthMeasureSpec, heightMeasureSpec) } } // 按序布局所有 Drawable override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { val parentWidth = right - left val parentHeight = bottom - top drawables.forEach { // 计算 Drawable 的 left val left = getChildLeft(it, parentWidth) // 计算 Drawable 的 top val top = getChildTop(it, parentHeight) // 确定 Drawable 上下左右四个角 it.setRect(left, top, left + it.measuredWidth, top + it.measuredHeight) } } // 按序绘制所有 Drawable override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) drawables.forEach { it.draw(canvas) } } }
现在OneViewGroup
的代码和自定义控件的代码架子一模一样,都有三个步骤,测量、布局、绘制。只不过现在的对象不是 View,而是自定义的 Drawable。
其中getChildTop()
和getChildTop()
会读取刚才定义一系列属性,并根据属性值计算出Drawable
相对于OneViewGroup
左上角的坐标:
class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { private fun getChildTop(drawable: Drawable, parentHeight: Int): Int { return when { // 若指定了上百分比,则可和父控件高度相乘直接得出 drawable 的 top 值 drawable.topPercent != -1f -> (parentHeight * drawable.topPercent).toInt() // 若指定了垂直对齐某 drawable drawable.centerVerticalOf != -1 -> { if (drawable.centerVerticalOf == parent_id) { (parentHeight - drawable.height) / 2 } else { (drawableMap[drawable.centerVerticalOf]?.let { it.top + (it.bottom - it.top) / 2 } ?: 0) - drawable.measuredHeight / 2 } } // 若指定了在某 drawable 下方 drawable.topToBottomOf != -1 -> { val b = if (drawable.topToBottomOf == parent_id) bottom else drawableMap[drawable.topToBottomOf]?.bottom ?: 0 (b + drawable.topMargin) } // 若指定了和某 drawable 上边对齐 drawable.topToTopOf != -1 -> { val t = if (drawable.topToTopOf == parent_id) top else drawableMap[drawable.topToTopOf]?.top ?: 0 (t + drawable.topMargin) } // 若指定了在某 drawable 上方 drawable.bottomToTopOf != -1 -> { val t = if (drawable.bottomToTopOf == parent_id) top else drawableMap[drawable.bottomToTopOf]?.top ?: 0 (t - drawable.bottomMargin) - drawable.measuredHeight } // 若指定了和某 drawable 底边对齐 drawable.bottomToBottomOf != -1 -> { val b = if (drawable.bottomToBottomOf == parent_id) bottom else drawableMap[drawable.bottomToBottomOf]?.bottom ?: 0 (b - drawable.bottomMargin) - drawable.measuredHeight } else -> 0 } } private fun getChildLeft(drawable: Drawable, parentWidth: Int): Int { return when { // 若指定了左百分比,则可和父控件宽度相乘直接得出 drawable 的 left 值 drawable.leftPercent != -1f -> (parentWidth * drawable.leftPercent).toInt() // 若指定了水平对齐某 drawable drawable.centerHorizontalOf != -1 -> { if (drawable.centerHorizontalOf == parent_id) { (parentWidth - drawable.width) / 2 } else { (drawableMap[drawable.centerHorizontalOf]?.let { it.left + (it.right - it.left) / 2 } ?: 0) - drawable.measuredWidth / 2 } } // 若指定了在某 drawable 右边 drawable.startToEndOf != -1 -> { val r = if (drawable.startToEndOf == parent_id) right else drawableMap[drawable.startToEndOf]?.right ?: 0 (r + drawable.leftMargin) } // 若指定了和某 drawable 左边对齐 drawable.startToStartOf != -1 -> { val l = if (drawable.startToStartOf == parent_id) left else drawableMap[drawable.startToStartOf]?.left ?: 0 (l + drawable.leftMargin) } // 若指定了在某 drawable 左边 drawable.endToStartOf != -1 -> { val l = if (drawable.endToStartOf == parent_id) left else drawableMap[drawable.endToStartOf]?.left ?: 0 (l - drawable.rightMargin) - drawable.measuredWidth } // 若指定了和某 drawable 右边对齐 drawable.endToEndOf != -1 -> { val r = if (drawable.endToEndOf == parent_id) right else drawableMap[drawable.endToEndOf]?.right ?: 0 (r - drawable.rightMargin) - drawable.measuredWidth } else -> 0 } } }
getChildTop()
和getChildTop()
分类讨论了每一种相对布局的情况下,该如何计算 drawable 的 left 和 top 值。
其中被依赖的控件通过drawableMap
获取,这个 Map 结构的目的是可以根据 id 快速获取 Drawable 对象。若只有列表结构的drawables
,则需要遍历,就比较耗时。但遍历 Drawable 进行测量、布局、绘制的时候,使用的是后者,因为 Map 结构是无序的。为了确定一个 Drawable 的位置,必须将它依赖的 Drawable 先完成定位。这要求构建 Drawable 时,被依赖项必须优先定义。定义的顺序被列表结构的drawables
记录。
然后就可以像这样定义具有相对位置的文字:
OneViewGroup { layout_width = match_parent layout_height = match_parent text { id = "title" width = 100 text = "title" textSize = 40f textColor = "#ffffff" leftPercent = 0.2f // 横向 20% topPercent = 0.2f // 纵向 20% } text { id = "content" width = 60 text = "content" textSize = 15f textColor ="#88ffffff" topToBottomOf = "title" // 在 title 的下面 startToStartOf = "title" // 与 title 左边对齐 } }
绘制形状
已经可以绘制文字,并且也可以指定文字间的相对位置了。还有一个常见的需求就是为文字添加圆形背景。在 xml 中对应的是<shape>
标签。
可以直接使用canvas.drawRoundRect()
在绘制文字之前先绘制一个圆形矩形作为背景。
抽象出一个形状类,它包含了绘制需要的参数:
class Shape { var color: String? = null // 颜色 var radius: Float = 0f // 圆角半径 var radii: IntArray? = null // 为四个角单独指定圆角 }
Text
持有一个Shape
实例:
class Text : Drawable() { var shapePaint: Paint? = null // 文字背景画笔 var shape: Shape? = null // 文字背景 set(value) { field = value shapePaint = Paint().apply { isAntiAlias = true style = Paint.Style.FILL color = Color.parseColor(value?.color) } } override fun draw(canvas: Canvas?) { canvas?.save() // 平移画布到文字绘制的左上角, 从这个点开始绘制文字背景 canvas?.translate(left, top) // 绘制背景 drawBackground(canvas) // 继续平移画布到文字的绘制点(文字和背景的距离用 padding 表示) canvas?.translate(paddingStart, paddingTop) // 绘制文字 staticLayout?.draw(canvas) canvas?.restore() } private fun drawBackground(canvas: Canvas?) { // 绘制背景的具体实现 shape?.let { shape -> canvas?.drawRoundRect(0f, 0f, measuredWidth, measuredHeight, shape.radius, shape.radius, shapePaint!!) } } }
为OneViewGroup
新增一个扩展方法,以便用声明式的结构来构建Shape
实例:
fun OneViewGroup.shape(init: OneViewGroup.Shape.() -> Unit): OneViewGroup.Shape = OneViewGroup.Shape().apply(init)
然后就可以像这样为文字添加背景:
OneViewGroup { layout_width = match_parent layout_height = match_parent text { id = "title" width = 100 text = "title" textSize = 40f textColor = "#ffffff" shape = shape { color = "#ff0000" radius = 20f } } }