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

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

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


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


image.png


界面用列表的形式,展示了一个主播排行榜。


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


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


回顾下上两篇做的优化:


  1. 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。


  1. 替换表项根布局,由更简单的PercentLayout取代ConstraintLayout,以缩短 measure + layout 时间。


  1. 使用协程 + Glide 同步加载方法,以缩减加载图片耗时。


  1. 将列表首屏显示的表项合并成一个新的表项类型,以缩短填充表项耗时。


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


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


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


单个表项中控件越多,表项复杂度越高,构建表项消耗的 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
        }
    }
}
目录
相关文章
|
8月前
|
缓存 Java 测试技术
RecyclerView 优化—滑动性能提升
RecyclerView 优化—滑动性能提升
134 0
|
4月前
|
缓存 前端开发 JavaScript
SPA首屏加载速度慢怎么解决
SPA(单页面应用)的首屏加载速度慢可能是由于以下几个方面造成的:
35 0
|
4月前
|
缓存 前端开发 JavaScript
面试官 : 首屏加载速度慢怎么优化?
面试官 : 首屏加载速度慢怎么优化?
|
23天前
|
缓存 前端开发
SPA首屏加载速度慢的怎么解决?
SPA首屏加载速度慢的怎么解决?
40 2
|
12月前
|
XML 存储 缓存
RecyclerView 性能优化 | 把加载表项耗时减半 (一)
RecyclerView 性能优化 | 把加载表项耗时减半 (一)
10920 0
|
12月前
|
XML 存储 缓存
RecyclerView 性能优化 | 把加载表项耗时减半 (二)
RecyclerView 性能优化 | 把加载表项耗时减半 (二)
170 0
|
12月前
|
XML 存储 缓存
RecyclerView 性能优化 | 把加载表项耗时减半 (三)(下)
RecyclerView 性能优化 | 把加载表项耗时减半 (三)
109 0
|
11月前
|
前端开发 JavaScript Java
移动端性能优化:减少应用的加载时间和内存占用
移动应用的性能对用户体验至关重要。在移动设备上,加载时间和内存占用是两个主要的性能指标。本文将介绍一些有效的技术和策略,帮助开发人员优化移动应用的加载时间并减少内存占用,以提升应用的性能和响应速度。
183 0
|
12月前
|
存储 缓存 Java
RecyclerView 性能优化 | 是什么在破坏缓存机制?
RecyclerView 性能优化 | 是什么在破坏缓存机制?
141 0
|
缓存 算法 API
使用优化 | RecyclerView中可优化的点
使用优化 | RecyclerView中可优化的点
使用优化 | RecyclerView中可优化的点