继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

RecyclerView 性能优化 | 把加载表项耗时减半 (三)

九州编程
关注TA
已关注
手记 475
粉丝 42
获赞 203

该系列的上两篇介绍了如何高效地量化绘制性能,并对 RecyclerView 加载速度做了 4 次优化,使得表项加载耗时从 370 ms 缩减到 170 ms。这一篇再介绍一种终极优化手段,把加载耗时再打对折。

这次性能调优的界面如下:

微信截图_20210311194655.png 界面用列表的形式,展示了一个主播排行榜。

不要小看这个简单的界面,在一屏中展示了 17 个表项,并且每个表项 2 个 ImageView 和 5 个 TextView,其中图片还得依赖网络拉取。

若首次加载该列表的同时,界面上还有动画的话,必然会造成动画的掉帧。因为列表的加载很耗时。

回顾下上两篇做的优化:

  1. 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
  2. 替换表项根布局,由更简单的PercentLayout取代ConstraintLayout,以缩短 measure + layout 时间。
  3. 使用协程 + Glide 同步加载方法,以缩减加载图片耗时。
  4. 将列表首屏显示的表项合并成一个新的表项类型,以缩短填充表项耗时。

关于这四点优化的详细讲解可以点击:

单个表项中控件越多,表项复杂度越高,构建表项消耗的 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
        }
    }
}
复制代码

如果需要绘制这样的效果咋办?

微信图片_20210416174604.jpg

即左上角和右上角是圆角,其余的不是。

drawRoundRect()做不到这个效果,只能用canvas.drawPath()

抽象一个Corners类来表示四个角的圆角程度:

class Corners(
    var leftTopRx: Float = 0f,
    var leftTopRy: Float = 0f,
    var leftBottomRx: Float = 0f,
    var LeftBottomRy: Float = 0f,
    var rightTopRx: Float = 0f,
    var rightTopRy: Float = 0f,
    var rightBottomRx: Float = 0f,
    var rightBottomRy: Float = 0f
) {
    // 将 8 个表示圆角的属性,按照 Android api 需要的顺序组织成数组
    val radii: FloatArray
        get() = floatArrayOf(
            leftTopRx, 
            leftTopRy, 
            rightTopRx, 
            rightTopRy, 
            rightBottomRx, 
            rightBottomRy, 
            leftBottomRx, 
            LeftBottomRy
        )
}
复制代码

其中一共有 8 个属性,分为四对分别表示左上,右上,左下,右下四个角。

Shape会持有一个Corners实例:

class Shape {
    var color: String? = null
    var radius: Float = 0f
    // 绘制的路径
    internal var path: Path? = null
    var corners: Corners? = null
        set(value) {
            field = value
            // 当 corners 被赋值时构建 Path 实例
            path = Path()
        }
}
复制代码

再需要改写一下Text中绘制背景的方法:

class Text : OneViewGroup.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)
            }
        }
        
    private fun drawBackground(canvas: Canvas?) {
        if (shape == null) return
        val _shape = shape!!
        // 如果设置了 radius 表示四个角都是圆角
        if (_shape.radius != 0f) {
            canvas?.drawRoundRect(0f, 0f, measuredWidth, measuredHeight, _shape.radius, _shape.radius, shapePaint!!)
        } 
        // 如果设置了 corners 表示有些角是圆角
        else if (_shape.corners != null) {
            // 根据 radii 属性构建 path
            _shape.path!!.apply {
                addRoundRect(
                    RectF(0f, 0f, measuredWidth, measuredHeight),
                    _shape.corners!!.radii,
                    Path.Direction.CCW
                )
            }
            // 绘制 path
            canvas?.drawPath(_shape.path!!, shapePaint!!)
        }
    }
}
复制代码

还是借助于 Kotlin 的语法糖,让构建Corners变得更加可读:

fun Shape.corners(init: Shape.Corners.() -> Unit): Corners 
    = Corners().apply(init)
复制代码

然后就可以像这样构建上面截图中的形状了:

OneViewGroup {
    layout_width = match_parent
    layout_height = match_parent

    text {
        id = "title"
        width = 100
        text = "title"
        textSize = 40f
        textColor = "#000000"
        shape = shape {
            color = "#ffffff"
            corners = corners{
                leftTopRx = 30f
                leftTopRy = 30f
                rightTopRx = 30f
                rightTopRy = 30f
            }
        }
    }
}
复制代码

绘制图片

图片的加载就要复杂很多。如何异步获取图片?如何绘制图片?即使解决了这两个问题,如果没有办法做到局部刷新,那当图片显示时,布局中的文字也会跟着闪一下。(欢迎有思路的小伙伴留言)

又没看过ImageView的源码,自己也很难较好地处理这些问题。那就先退一步,图片依然采用ImageView控件展示。但这样的话就产生了一个新的问题:如何确定 ImageView 控件和 OneViewGroup 控件中绘制文字的相对位置?

控件与控件之间的相对位置很好确定,但如何确定一个控件和另一个控件中绘制内容的相对位置?

OneViewGroup中的绘制内容被抽象为一个Drawable对象,该对象用一组属性来标识和另一个Drawable对象的相对位置。如果ImageView也是一个Drawable对象,那就能很方便的确定它和绘制文字的相对位置了!

怎么把一个类装扮成另一个类?—— 多重继承

但 Kotlin 不支持多重继承,所以只能把抽象类Drawable重构成接口:

interface Drawable {
    // 测量后的宽高
    var layoutMeasuredWidth: Int
    var layoutMeasuredHeight: Int
    // 布局后的上下左右边框
    var layoutLeft: Int
    var layoutRight: Int
    var layoutTop: Int
    var layoutBottom: Int
    // 唯一标识 id
    var layoutId: Int
    // 相对布局属性
    var leftPercent: Float
    var topPercent: Float
    var startToStartOf: Int
    var startToEndOf: Int
    var endToEndOf: Int
    var endToStartOf: Int
    var topToTopOf: Int
    var topToBottomOf: Int
    var bottomToTopOf: Int
    var bottomToBottomOf: Int
    var centerHorizontalOf: Int
    var centerVerticalOf: Int
    // 记录业务层设置的宽高
    var layoutWidth: Int
    var layoutHeight: Int
    // 内边距
    var layoutPaddingStart: Int
    var layoutPaddingEnd: Int
    var layoutPaddingTop: Int
    var layoutPaddingBottom: Int
    // 外边距
    var layoutTopMargin: Int
    var layoutBottomMargin: Int
    var layoutLeftMargin: Int
    var layoutRightMargin: Int
    // 布局的终点:确定上下左右
    fun setRect(left: Int, top: Int, right: Int, bottom: Int) {
        this.layoutLeft = left
        this.layoutRight = right
        this.layoutTop = top
        this.layoutBottom = bottom
    }
    // 测量的终点:确定宽高
    fun setDimension(width: Int, height: Int) {
        this.layoutMeasuredWidth = width
        this.layoutMeasuredHeight = height
    }
    // 抽象的 测量 布局 绘制 , 供子类实现多态
    fun doMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)
    fun doLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int)
    fun doDraw(canvas: Canvas?)
}
复制代码

然后新建一个类,即继承了ImageView又实现了Drawable接口:

class ImageDrawable
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) 
    : AppCompatImageView(context, attrs, defStyleAttr), OneViewGroup.Drawable {
    
    override var leftPercent: Float = -1f
    override var topPercent: Float = -1f
    override var startToStartOf: Int = -1
    override var startToEndOf: Int = -1
    override var endToEndOf: Int = -1
    override var endToStartOf: Int = -1
    override var topToTopOf: Int = -1
    override var topToBottomOf: Int = -1
    override var bottomToTopOf: Int = -1
    override var bottomToBottomOf: Int = -1
    override var centerHorizontalOf: Int = -1
    override var centerVerticalOf: Int = -1
    override var layoutWidth: Int = 0
    override var layoutHeight: Int = 0
    override var layoutMeasuredWidth: Int = 0
        get() = measuredWidth
    override var layoutMeasuredHeight: Int = 0
        get() = measuredHeight
    override var layoutLeft: Int = 0
        get() = left
    override var layoutRight: Int = 0
        get() = right
    override var layoutTop: Int = 0
        get() = top
    override var layoutBottom: Int = 0
        get() = bottom
    override var layoutId: Int = 0
        get() = id
    override var layoutPaddingStart: Int = 0
        get() = paddingStart
    override var layoutPaddingEnd: Int = 0
        get() = paddingEnd
    override var layoutPaddingTop: Int = 0
        get() = paddingTop
    override var layoutPaddingBottom: Int = 0
        get() = paddingBottom
    override var layoutTopMargin: Int = 0
        get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0
    override var layoutBottomMargin: Int = 0
        get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 0
    override var layoutLeftMargin: Int = 0
        get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.leftMargin ?: 0
    override var layoutRightMargin: Int = 0
        get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.rightMargin ?: 0

    override fun doMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 在 View 体系中测量
    }

    override fun doLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        // 在 View 体系中布局
        layout(left, top, right, bottom)
    }

    override fun doDraw(canvas: Canvas?) {
        // 在 View 体系中绘制
    }
}
复制代码

接口中的属性都是抽象的,在子类中如果不给它指定一个初始值,就要添加set()get()方法。

ImageDrawable的测量宽高,上下左右,内外边距的获取都委托给了View体系中的值,并且在布局自己的时候调用了View.layout(),以确定自己和其他Drawable的相对位置。相对位置的计算在OneViewGroup.onLayout()中完成:

class OneViewGroup 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) 
    : ViewGroup(context, attrs, defStyleAttr) {// 重构为 ViewGroup
    
    private val drawables = mutableListOf<Drawable>()

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        val parentWidth = right - left
        val parentHeight = bottom - top
        // 依次计算每个 Drawable 的相对位置
        drawables.forEach {
            val left = getChildLeft(it, parentWidth)
            val top = getChildTop(it, parentHeight)
            it.doLayout(changed, left, top, left + it.layoutMeasuredWidth, top + it.layoutMeasuredHeight)
        }
    }
}
复制代码

为了让OneViewGroup除了容纳Drawable之外,还能容纳View,所以不得不将其继承自ViewGroup

OneViewGroup必须得测量自己的孩子ImageDrawable,否则孩子就没有宽高数据:

class OneViewGroup 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) 
    : ViewGroup(context, attrs, defStyleAttr) {

    private val drawables = mutableListOf<Drawable>()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 测量 ImageDrawable
        measureChildren(widthMeasureSpec,heightMeasureSpec) 
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // 测量其他 Drawable
        drawables.forEach { it.doMeasure(widthMeasureSpec, heightMeasureSpec) }
    }
}
复制代码

用 Kotlin 语法糖构建 ImageDrawable:

inline fun OneViewGroup.image(init: ImageDrawable.() -> Unit) 
    = ImageDrawable(context).apply(init).also {
        addView(it) // 添加为 OneViewGroup 的子控件
        addDrawable(it) // 添加为 OneViewGroup 的子 Drawable
    }
复制代码

就像多重继承的语义一样,ImageDrawable有双重身份,它既是OneViewGroup的子控件,又是OneViewGroup的子Drawable

ImageDrawable的测量、布局、绘制都依赖于 View 体系,唯独布局的参数依赖于其他的Drawable

然后就可以像这样图文混排了:

OneViewGroup {
    layout_width = match_parent
    layout_height = match_parent

    text {
        id = "title"
        width = 100
        text = "title"
        textSize = 40f
        textColor = "#000000"
    }
    
    image {
        id = "avatar"
        layout_width = 40
        layout_height = 40
        scaleType = fit_xy
        startToEndOf = "title" // 位于 title 的后面
        centerVerticalOf = "title" // 和 title 垂直居中
    }
}
复制代码

因为没有将 ImageView 去掉,所以这是一个曲线救国的方案。但从另一个角度看,这也是将OneViewGroup和任何其他控件组合使用的通用方案。

点击事件

原先可以通过View.setOnClickListener()分别为子控件设置点击事件。OneViewGroup把子控件抽象为Drawable后该如何处理点击事件?

更好的 RecyclerView 表项子控件点击监听器中提到一种解决方案,即判断触点坐标是否和子控件有交集。可以沿用到OneViewGroup上:

先为Drawable新增一个表示其矩形区域的属性rect

interface Drawable {
    var layoutLeft: Int
    var layoutRight: Int
    var layoutTop: Int
    var layoutBottom: Int
    // 用上下左右构建矩形对象
    val rect: Rect
        get() = Rect(layoutLeft, layoutTop, layoutRight, layoutBottom)
    ...
}
复制代码

再在OneViewGroup中拦截触摸事件:

class OneViewGroup 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) 
    : ViewGroup(context, attrs, defStyleAttr) {

    private val drawables = mutableListOf<Drawable>()
    // 手势监听器, 用于将触摸事件解析成点击事件
    private var gestureDetector: GestureDetector? = null

    // 设置 Drawable 点击监听器
    fun setOnItemClickListener(onItemClickListener: (String) -> Unit) {
        // 构造手势监听器
        gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
            override fun onShowPress(e: MotionEvent?) {
            }

            // 当单击事件发生时
            override fun onSingleTapUp(e: MotionEvent?): Boolean {
                e?.let {
                    // 若在触摸点找到对应 Drawable 则回调点击事件
                    findDrawableUnder(e.x, e.y)?.let { onItemClickListener.invoke(it.layoutIdString) }
                }
                return true
            }

            // 必须返回 true 表示处理 ACTION_DOWN 事件, 否则后续事件不会传递到 OneViewGroup
            override fun onDown(e: MotionEvent?): Boolean {
                return true
            }

            override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
                return false
            }

            override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
                return false
            }

            override fun onLongPress(e: MotionEvent?) {
            }
        })
    }

    // 根据坐标查找 Drawable 对象
    private fun findDrawableUnder(x: Float, y: Float): Drawable? {
        // 遍历所有 Drawable ,返回矩形区域内包含触点的 Drawable
        drawables.forEach {
            if (it.rect.contains(x.toInt(), y.toInt())) {
                return it
            }
        }
        return null
    }
    
    // 将触摸事件传递给手势监听器
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return gestureDetector?.onTouchEvent(event) ?: super.onTouchEvent(event)
    }
}
复制代码

然后就可以像这样为OneViewGroup设置子 Drawable 的点击事件了:

OneViewGroup {
    layout_width = match_parent
    layout_height = match_parent

    text {
        id = "title"
        width = 100
        text = "title"
        textSize = 40f
        textColor = "#000000"
    }
    
    setOnItemClickListener { id->
        when (id) {
            "title" -> {
                Log.v("test", "title is clicked")
            }
        }
    }
}

复制代码

Talk is cheap, show me the code

  • OneViewGroup源码可以点击这里
  • Demo 源码地址可以点击这里 (其中的RecyclerViewPerformanceActivity

最后附上,文章开头截图布局用OneViewGroup的重构版本(经重构, 其中和 OneViewGroup 有关的属性都以 drawable 开头):

class OneRankProxy : VarietyAdapter2.Proxy<BetterRank, OneRankViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val itemView = parent.context.run {
            LinearLayout {
                layout_id = "container"
                layout_width = match_parent
                layout_height = wrap_content
                orientation = vertical
                margin_start = 20
                margin_end = 20
                padding_bottom = 16
                shape = shape {
                    corner_radius = 20
                    solid_color = "#ffffff"
                }

                OneViewGroup { // 用 OneViewGroup 重构的表头
                    layout_id = "one"
                    layout_width = match_parent
                    layout_height = 60
                    shape = shape {
                        corner_radii = intArrayOf(20, 20, 20, 20, 0, 0, 0, 0)
                        solid_color = "#ffffff"
                    }

                    text {
                        drawable_layout_id = "tvTitle"
                        drawable_max_width = 60
                        drawable_text_size = 16f
                        drawable_text_color = "#3F4658"
                        drawable_start_to_start_of = parent_id
                        drawable_left_margin = 20
                        topPercent = 0.23f
                    }

                    text {
                        drawable_layout_id = "tvRank"
                        drawable_max_width = 60
                        drawable_text_size = 11f
                        drawable_text_color = "#9DA4AD"
                        leftPercent = 0.06f
                        topPercent = 0.78f
                    }

                    text {
                        drawable_layout_id = "tvName"
                        drawable_max_width = 60
                        drawable_text_size = 11f
                        drawable_text_color = "#9DA4AD"
                        leftPercent = 0.18f
                        topPercent = 0.78f
                    }

                    text {
                        drawable_layout_id = "tvCount"
                        drawable_max_width = 100
                        drawable_text_size = 11f
                        drawable_text_color = "#9DA4AD"
                        drawable_end_to_end_of = parent_id
                        drawable_right_margin = 20
                        topPercent = 0.78f
                    }

                }
            }

        }
        return OneRankViewHolder(itemView)
    }


    override fun onBindViewHolder(holder: OneRankViewHolder, data: BetterRank, index: Int, action: ((Any?) -> Unit)?) {
        holder.tvTitle?.text = data.title
        holder.tvCount?.text = data.countColumn
        holder.tvRank?.text = data.rankColumn
        holder.tvName?.text = data.nameColumn

        holder.container?.apply {
            // 遍历主播数据, 动态构建每个主播的布局
            data.ranks.forEachIndexed { index, rank ->
                OneViewGroup {
                    layout_width = match_parent
                    layout_height = 35
                    background_color = "#ffffff"

                    text {
                        drawable_layout_id = "tvRank"
                        drawable_layout_width = 18
                        drawable_text_size = 14f
                        drawable_text_color = "#9DA4AD"
                        leftPercent = 0.08f
                        drawable_center_vertical_of = parent_id
                        text = rank.rank.toString()
                    }

                    image {
                        layout_id = "ivAvatar"
                        layout_width = 20
                        layout_height = 20
                        scaleType = scale_center_crop
                        drawable_center_vertical_of = parent_id
                        leftPercent = 0.15f
                        load(rank.avatarUrl) 
                    }

                    text {
                        drawable_layout_id = "tvName"
                        drawable_max_width = 200
                        drawable_text_size = 11f
                        drawable_text_color = "#3F4658"
                        drawable_gravity = gravity_center
                        drawable_max_lines = 1
                        drawable_start_to_end_of = "ivAvatar"
                        drawable_top_to_top_of = "ivAvatar"
                        drawable_left_margin = 5
                        drawable_text = rank.name
                    }

                    text {
                        drawable_layout_id = "tvTag"
                        drawable_max_width = 100
                        drawable_text_size = 8f
                        drawable_text_color = "#ffffff"
                        drawable_gravity = gravity_center
                        drawable_padding_top = 1
                        drawable_padding_bottom = 1
                        drawable_padding_start = 2
                        drawable_padding_end = 2
                        drawable_text = "save"
                        drawable_shape = drawableShape {
                            radius = 4f
                            color = "#8cc8c8c8"
                        }
                        drawable_start_to_start_of = "tvName"
                        drawable_top_to_bottom_of = "tvName"
                    }

                    image {
                        layout_id = "ivLevel"
                        layout_width = 10
                        layout_height = 10
                        scaleType = scale_fit_xy
                        drawable_center_vertical_of = "tvName"
                        drawable_start_to_end_of = "tvName"
                        drawable_left_margin = 5
                        load(rank.levelUrl)
                    }

                    text {
                        drawable_layout_id = "tvLevel"
                        drawable_max_width = 200
                        drawable_text_size = 7f
                        drawable_text_color = "#ffffff"
                        drawable_gravity = gravity_center
                        drawable_padding_start = 2
                        drawable_padding_end = 2
                        drawable_shape = drawableShape {
                            color = "#FFC39E"
                            radius = 20f
                        }
                        drawable_center_vertical_of = "tvName"
                        drawable_start_to_end_of = "ivLevel"
                        drawable_left_margin = 5
                        drawable_text = rank.level.toString()
                    }

                    text {
                        drawable_layout_id = "tvCount"
                        drawable_max_width = 200
                        drawable_text_size = 14f
                        drawable_text_color ="#3F4658"
                        drawable_gravity = gravity_center
                        drawable_center_vertical_of = parent_id
                        drawable_end_to_end_of = parent_id
                        drawable_right_margin = 20
                        drawable_text = rank.count.formatNums()
                    }
                }
            }
        }
    }
}

class OneRankViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val oneViewGroup = itemView.find<OneViewGroup>("one")
    val container = itemView.find<LinearLayout>("container")
    val tvTitle = oneViewGroup?.findDrawable<Text>("tvTitle")
    val tvRank = oneViewGroup?.findDrawable<Text>("tvRank")
    val tvName = oneViewGroup?.findDrawable<Text>("tvName")
    val tvCount = oneViewGroup?.findDrawable<Text>("tvCount")
}
复制代码

代码中沿用了上一篇中提到的将首屏的多个表项合并成一个表项的方案,动态地为每个主播构建表项并添加到表项容器中。

其中OneViewGroup.findDrawable()的作用类似于View.findViewById()

class OneViewGroup 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) 
    : ViewGroup(context, attrs, defStyleAttr) {

    // 保存所有 Drawable 和其 id 对应关系的 map
    private val drawableMap = HashMap<Int, Drawable>()
    // 按插入顺序保存所有 Drawable
    private val drawables = mutableListOf<Drawable>()

    // 插入 Drawable 到 OneViewGroup
    fun addDrawable(drawable: Drawable) {
        drawables.add(drawable)
        drawableMap[drawable.layoutId] = drawable
    }

    // 按 id 查找 Drawable (将 String 类型 id 转换成 Int 并在 map 中查找)
    fun <T> findDrawable(id: String):T? = drawableMap[id.toLayoutId()] as? T 
}
复制代码

重构完毕,运行 Demo 看下耗时:

measure + layout=75,     unknown delay=33,     anim=0,    touch=0,     draw=9, total=121
measure + layout=0,     unknown delay=0,     anim=0,    touch=0,     draw=0,  total=3
measure + layout=0,     unknown delay=0,     anim=0,    touch=0,     draw=0,  total=7
复制代码

再援引此次优化前的数据做对比:

measure + layout=170,     unknown delay=41,     anim=0,    touch=0,     draw=18, total= 200
measure + layout=0,     unknown delay=250,     anim=1,    touch=0,     draw=0,   total=289
measure + layout=4,     unknown delay=4,     anim=0,    touch=0,     draw=2,    total=13
measure + layout=4,     unknown delay=0,     anim=0,    touch=0,     draw=1,    total=13
复制代码

measure + layout 从 170 ms 骤降到 75 ms。

虽然绘制图片还需要借助于 ImageView,但光凭将所有的 TextView 汇聚成一个控件展示就有如此大的性能提升。后续文章还会继续优化,将图片加载补上!

经过了 5 次优化,布局加载时间从原先的 370 ms 降到 75 ms,优化手段总结如下:

  1. 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
  2. 替换表项根布局,由更简单的PercentLayout取代ConstraintLayout,以缩短 measure + layout 时间。
  3. 使用协程 + Glide 同步加载方法,以缩减加载图片耗时。
  4. 将列表首屏显示的表项合并成一个新的表项类型,以缩短填充表项耗时。
  5. OneViewGroup代替PercentLayout,大幅减少表项的控件数量。

作者:唐子玄
链接:https://juejin.cn/post/6954892589539524615
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP