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

16,876 阅读22分钟

构建 Android App 界面时,RecyclerView 出场率很高。它的加载性能影响着用户体检。本篇分享一次完整的 RecyclerView 性能优化过程:从用工具定位问题,再不断尝试各种优化方案,最终达成 50% 的性能优化。

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

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

预优化,先量化

这个排行榜嵌套在一个 ViewPager 中。最初发现性能问题是因为滑动到该界面时,ViewPager 指示器的平移动画卡了一下,掉帧了。

虽然卡顿是肉眼可见的,但若不能量化卡顿,就无法量化优化程度。

第一个想到的工具是GPU 呈现模式分析。开启它的路径如下:打开手机设置 --- 开发者选项 --- GPU 呈现模式分析 --- 在屏幕上显示为条形图:

微信图片_20210313112022.jpg

开启后,绘制性能就会图形化展示如下:

微信图片_20210311200132.png 果然有很大的性能问题,柱子都快冲出屏幕了。

虽然图形化很直观,但量化地还不够细致,绘制耗时最好能精确到毫秒。所以转战到另一种方式“在 adb shell dumpsys gfxinfo 中”。选中后,打开排行榜界面,然后输入命令adb shell dumpsys gfxinfo <包名>,最近 n 针的渲染时长就会罗列如下:

        Draw    Prepare Process Execute
        50.00   0.23    6.82    1.28
        50.00   0.26    1.49    1.13
        7.01    0.24    1.58    0.76
        6.41    0.52    7.42    1.34
        13.13   0.18    2.01    0.76
        4.38    0.15    1.72    0.39
        4.37    0.15    1.13    0.33
        4.36    0.15    1.23    0.38
        4.34    0.35    1.15    0.31
        4.36    0.15    1.16    8.42
        4.32    0.14    1.11    0.31
        4.32    0.15    1.10    0.32

每一行代表一帧渲染中各个阶段的耗时。

用另一个命令还可以得到更加精确的数据:adb shell dumpsys gfxinfo <包名> framestats,该命令会从应用生成的最近 120 个帧中输出带有纳秒时间戳的帧时间信息:

Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssueDrawCommandsStart,SwapBuffers,FrameCompleted,DequeueBufferDuration,QueueBufferDuration,
1,299873182486990,299873215820322,9223372036854775807,0,299873227699771,299873227750761,299873228134563,299873242278000,299873243236959,299873243432011,299873243482063,299873244517375,299873245505396,62000,670000,
0,299873232346485,299873249013151,9223372036854775807,0,299873253133625,299873253191177,299873253443990,299873418812375,299873433404406,299873433753313,299873434022167,299873435099667,299873435955448,71000,453000,
0,299873448312229,299873448312229,9223372036854775807,0,299873448760344,299873448798573,299873449290656,299873449438469,299873449500969,299873449733261,299873449909979,299873450770344,299873451478625,65000,264000,
0,299873464924749,299873464924749,9223372036854775807,0,299873465493625,299873465550292,299873466377896,299873466594511,299873466643417,299873466932115,299873468812427,299873475972792,299873476852011,145000,198000,
0,299873481537390,299873481537390,9223372036854775807,0,299873481932896,299873481972688,299873482590188,299873482741333,299873482772688,299873483047375,299873483263886,299873483856958,299873484308886,72000,96000,
0,299873498151975,299873498151975,9223372036854775807,0,299873498582427,299873498633417,299873499218833,299873499442271,299873499483781,299873499850761,299873500327427,299873501493886,299873502170708,126000,186000,
0,299873514764506,299873514764506,9223372036854775807,0,299873515260031,299873515314667,299873515966646,299873516205656,299873516254667,299873516538052,299873516896229,299873518042063,299873518653990,141000,149000,
0,299873531376920,299873531376920,9223372036854775807,0,299873531891646,299873531951906,299873532798261,299873533022792,299873533072219,299873533390292,299873533748886,299873534813729,299873535444771,118000,160000,
0,299873547989162,299873547989162,9223372036854775807,0,299873548571750,299873548638990,299873549306594,299873549534042,299873549590969,299873549916958,299873550342167,299873551852063,299873552613886,128000,200000,
0,299873564601513,299873564601513,9223372036854775807,0,299873565070500,299873565126281,299873565700031,299873565924302,299873565977479,299873566281073,299873566635865,299873567892375,299873568616386,143000,167000,
0,299873581213965,299873581213965,9223372036854775807,0,299873581577948,299873581621073,299873583047271,299873583253833,299873583293729,299873583680031,299873584854875,299873592375136,299873593527688,222000,308000,

原生输出信息没有可读性,但它们遵守 csv 格式,复制粘贴到 wps 表格中,选中 数据 --- 分列,用“逗号”分割:

微信截图_20210311203039.png

数据就以表格的形式展示:

微信截图_20210311203249.png

每一行表示一帧绘制的时间信息,一共有 16 列,每一列表示一个关键节点的时间戳,比如PerformTraversalsStart表示绘制遍历的开始时间点,DrawStart表示onDraw()开始的时间点,前者减去后者表示measure + layout的耗时。

利用表格的求差功能可以计算出一排表征性能的耗时。

虽然得到了量化数据,但是这么折腾着实有点辛苦。

一顿搜索之后,终于找到了下面这个高效的方法:

class TestActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        window?.addOnFrameMetricsAvailableListener(Window.OnFrameMetricsAvailableListener { window, frameMetrics, dropCountSinceLastInvocation ->
           Log.v("test","measure + layout=${frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)/1000000}, " +
                   "    delay=${frameMetrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)/1000000}, " +
                   "    anim=${frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION)/1000000}," +
                   "    touch=${frameMetrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)/1000000}, " +
                   "    draw=${frameMetrics.getMetric(FrameMetrics.DRAW_DURATION)/1000000}, " +
                   "    total=${frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)/1000000}")
        }, Handler())
    }
}

Window.addOnFrameMetricsAvailableListener()方法可以监听最近 120 帧的绘制耗时,它的数据源和上面 adb 命令是一样的。

我把自己感兴趣的耗时都打印了出来,分别是 measure + layout、延迟、动画、触摸、绘制、总耗时。

然后打开了排行榜界面,得到了如下数据:

measure + layout=370,     delay=35,     anim=0,    touch=0,     draw=21,     total=435
measure + layout=0,     delay=451,     anim=3,    touch=0,     draw=0,     total=467
measure + layout=22,     delay=6,     anim=0,    touch=0,     draw=3,     total=34
measure + layout=0,     delay=17,     anim=0,    touch=0,     draw=0,     total=41

有一帧绘制耗时高达 435 ms,其中 measure + layout 占了 370 ms。(此数值在不同手机上差异较大)

然后我关闭了 log 过滤,发现了更多信息:

measure + layout=370,     delay=35,     anim=0,    touch=0,     draw=21,     total=435
Skipped 23 frames!  The application may be doing too much work on its main thread.
measure + layout=0,     delay=451,     anim=3,    touch=0,     draw=0,     total=467
measure + layout=22,     delay=6,     anim=0,    touch=0,     draw=3,     total=34
measure + layout=0,     delay=17,     anim=0,    touch=0,     draw=0,     total=41

紧接着耗时最长的那一帧,有一条警告,它是由Choreographer打印的,表示此刻发生掉帧,而且掉了整整 23 帧。。。(关于 Choreographer 详细的源码解析可以点击读源码长知识 | Android卡顿真的是因为”掉帧“?

动态构建布局,弃用 xml

首先想到的一个方案是:“弃用 xml”

onCreateViewHolder()执行在主线程,如果它执行耗时,势必会影响到也运行在主线程的绘制性能。

demo 中排行榜一共有两类 item:表头和表体,其中构建表头布局的代码如下:

// 表头 item 代理类, 描述如何构建 item 及为它绑定数据
class HeaderProxy:VarietyAdapter.Proxy<Header, HeaderViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        // 构建 item
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.header_layout,parent,false)
        return HeaderProxy(itemView)
    }

    override fun onBindViewHolder(holder: HeaderViewHolder, data: Header, index: Int, action: ((Any?) -> Unit)?) {
        // 为 item 绑定数据
        holder.tvCount?.text = data.count
        holder.tvName?.text = data.name
        holder.tvRank?.text = data.rank
        holder.tvTitle?.text = data.title
    }
}

// 表头实体类
data class Header(
    val rank: String,// 排名
    val name: String,// 主播
    val count: String,// 粉丝数
    val title: String// 表头
)
// 用于保存表项控件引用的 ViewHolder
class HeaderViewHolder(itemView:View):RecyclerView.ViewHolder(itemView){
    val tvCount = itemView.findViewById<TextView>(R.id.tvCount)
    val tvName = itemView.findViewById<TextView>(R.id.tvName)
    val tvRank = itemView.findViewById<TextView>(R.id.tvRank)
    val tvTitle = itemView.findViewById<TextView>(R.id.tvtitle)
}

原本这些逻辑应该写在RecyclerView.Adapter中,把它单独抽象到一个 proxy 类中,是为了解耦,以便更容易地为列表添加不同类型的表项:

private val adapter = VarietyAdapter().apply {
    addProxy(HeaderProxy())
    addProxy(RankProxy())
}

调用addProxy()就动态地添加一种新表项类型(关于代理模式的实战应用可以点击代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂)。

onCreateViewHolder()中通过解析布局文件的方式来构建表项 item。

但解析布局文件需要进行 IO 操作将布局文件读到内存中,再解析 xml 根据标签 new 出对应的控件实例,最后 addView() 到容器中。这个过程是耗时的。

如果能使用 kotlin 代码直接完成布局的构建,则可以加速这个过程。但这样的构建代码可读性很差,后期想要更改控件的某个属性很难定位。

利用 kotlin 的DSL来改善构建代码的可读性,甚至超越 xml:

class HeaderProxy : VarietyAdapter.Proxy<Header, HeaderViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val itemView = parent.context.run {
            LinearLayout { // 构建 LinearLayout
                layout_width = match_parent
                layout_height = wrap_content
                orientation = vertical
                padding_top = 10
                padding_horizontal = 10
                shape = shape {
                    corner_radii = intArrayOf(20, 20, 20, 20, 0, 0, 0, 0)
                    solid_color = "#ffffff"
                }
                
                TextView { // 构建 TextView
                    layout_id = "tvTitle"
                    layout_width = wrap_content
                    layout_height = wrap_content
                    textSize = 16f
                    textColor = "#3F4658"
                    textStyle = bold
                    margin_bottom = 3
                }

                ConstraintLayout { // 构建 ConstraintLayout
                    layout_width = match_parent
                    layout_height = wrap_content
                    margin_top = 16
                    
                    TextView { // 构建 TextView
                        layout_id = "tvRank"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 11f
                        textColor = "#9DA4AD"
                        start_toStartOf = parent_id
                        center_vertical = true
                    }
                    
                    TextView { // 构建 TextView
                        layout_id = "tvName"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 11f
                        textColor = "#9DA4AD"
                        align_vertical_to = "tvRank"
                        start_toEndOf = "tvRank"
                        margin_start = 19
                    }

                    TextView { // 构建 TextView
                        layout_id = "tvCount"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 11f
                        textColor = "#9DA4AD"
                        align_vertical_to = "tvRank"
                        end_toEndOf = parent_id
                    }
                }
            }
        }
        return HeaderViewHolder(itemView)
    }
    
    override fun onBindViewHolder(holder: HeaderViewHolder, data: Header, index: Int, action: ((Any?) -> Unit)?) {
        holder.tvCount?.text = data.count
        holder.tvName?.text = data.name
        holder.tvRank?.text = data.rank
        holder.tvTitle?.text = data.title
    }
}

// 表头实体类
data class Header(
    val rank: String,// 排名
    val name: String,// 主播
    val count: String,// 粉丝数
    val title: String// 表头
)

// 用于记录表项控件引用的 ViewHolder
class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val tvRank = itemView.find<TextView>("tvRank")
    val tvName = itemView.find<TextView>("tvName")
    val tvCount = itemView.find<TextView>("tvCount")
    val tvTitle = itemView.find<TextView>("tvTitle")
}

关于如何使用 DSL 简化布局构建可以点击Android性能优化 | 把构建布局用时缩短 20 倍(下)

将表头和表体 item 都用DSL重构了一番,运行 demo 看看数据:

measure + layout=330,     delay=47,     anim=0,    touch=0,     draw=21,     total=402
measure + layout=0,     delay=357,     anim=2,    touch=0,     draw=0,     total=362
measure + layout=19,     delay=4,     anim=0,    touch=0,     draw=3,     total=39
measure + layout=0,     delay=16,     anim=0,    touch=0,     draw=0,     total=40

measure + layout 时间从 370 ms 缩短到 330 ms,可喜可贺~~

不同的 ViewGroup,不同的 measure + layout 耗时

想到的第二个优化方案是:“替换表项的根布局”

表头和表头 item 的布局都是用了ConstraintLayout是不是因为它太复杂了,所以导致measure + layout耗时过长?

带着怀疑,我把所有的ConstraintLayout换成了FrameLayout,界面就变成了这样:

微信截图_20210313120209.png

所有子控件都聚拢在一点,再瞄一眼性能日志:

measure + layout=272,     delay=40,     anim=0,    touch=0,     draw=15,     total=312
measure + layout=0,     delay=300,     anim=2,    touch=0,     draw=0,     total=332
measure + layout=9,     delay=7,     anim=0,    touch=0,     draw=2,     total=21
measure + layout=7,     delay=4,     anim=0,    touch=0,     draw=1,     total=55
measure + layout=0,     delay=0,     anim=0,    touch=0,     draw=0,     total=41

令人惊喜的是measure + layout时间从 330 ms 缩短到了 272 ms。

看来表项根布局的复杂程度的确可以影响到列表的加载性能,而且列表会放大这个性能差距,因为 n 个表项就会进行 n 次measure + layout

那就用最最简单的FrameLayout来布局吧~。通过leftMargintopMargin来定位表项中的每一个子控件。我对着 UI 设计图,读取了每个子控件相对于父控件的左边距和上边距,然后用FrameLayout重写了表头 item。

但当我把 demo 在不同手机上运行之后,发现这个方案有缺陷,虽然已经使用了 dp 而不是像素值,但依然无法很好地解决多屏幕适配的问题:

微信图片_20210313122139.jpg

微信图片_20210313122136.jpg

“粉丝数”根据左边距和上边距来确定相对于父控件的位置,不同的手机屏宽度不同,所以适配效果很差。

可能这就是相对布局存在的原因,但RelativeLayout也不是省油的灯。有没有别的更简单的方法?

我想到了百分比布局,还是基于左边距和上边距,但这次不使用 dp 值,而是用相对于父控件的百分比,不就能完美解决这个问题吗?

立马搜索了一下,遗憾的发现PercentFrameLayout已经被弃用。。。

那就自己手写一个:

// 自定义百分比布局
class PercentLayout
@JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0) 
: ViewGroup(context, attrs, defStyleAttr, defStyleRes) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 测量所有子控件
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        // 父控件宽
        val parentWidth = right - left
        // 父控件高
        val parentHeight = bottom - top
        // 遍历子控件逐个定位它们
        (0 until childCount).map { getChildAt(it) }.forEach { child ->
            val lp = child.layoutParams as LayoutParam
            // 计算子控件 left 值(相对于父控件宽)
            val childLeft = (parentWidth * lp.leftPercent).toInt()
            // 计算子控件 top 值(相对于父控件高)
            val childTop = (parentHeight * lp.topPercent).toInt()
            // 定位子控件
            child.layout(childLeft, childTop, childLeft + child.measuredWidth, childTop + child.measuredHeight)
        }
    }

    // 自定义的布局参数, 新增了两个属性分别是左百分比和上百分比
    class LayoutParam(source: ViewGroup.LayoutParams?) : ViewGroup.LayoutParams(source) {
        var leftPercent: Float = 0f
        var topPercent: Float = 0f
    }


}

百分比布局的编码很简单,只需要两步:先测量所有子控件,然后按需要定位所有子控件。其中测量孩子使用ViewGroup.measureChildren()就完成了。布局孩子得先计算出父控件的宽高,然后与子控件的百分比相乘就得到了相对于父控件的位置,最后调用View.layout()来定位子控件。

运行一下 demo,效果理想~~

运用相同的思路重构了一下表体 item 。过程中发现了一个问题:并不是所有控件都可以相对于父控件来布局。

比如下面这个场景:

微信截图_20210313124558.png

表项数据是服务器返回的,文字长度是可变的,“等烟雨来,就是不来”后面与它垂直对齐的图片就无法相对于父控件布局。

所以PrecentLayout不得不也引入相对布局的概念,但也不需要像ConstraintLayout那样复杂,一个简化版的百分比相对布局如下:

// 自定义百分比相对布局
class PercentLayout
@JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0) 
: ViewGroup(context, attrs, defStyleAttr, defStyleRes) {
    // 记录所有子控件及它 id 的 map
    private val childMap = SparseArray<View>()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 测量所有子控件
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        val parentWidth = right - left
        val parentHeight = bottom - top
        (0 until childCount).map { getChildAt(it) }.forEach { child ->
            val lp = child.layoutParams as LayoutParam
            // 计算子控件 left 值
            val childLeft = getChildLeft(lp, parentWidth, child)
            // 计算子控件 top 值
            val childTop = getChildTop(lp, parentHeight, child)
            // 布局子控件
            child.layout(childLeft, childTop, childLeft + child.measuredWidth, childTop + child.measuredHeight)
        }
    }

    // 根据不同的情况计算相对于父控件上边的距离
    private fun getChildTop(lp: LayoutParam, parentHeight: Int, child: View): Int {
        val parentId = parent_id.toLayoutId()
        return when {
            lp.topPercent != -1f -> (parentHeight * lp.topPercent).toInt()
            lp.centerVerticalOf != -1 -> {
                if (lp.centerVerticalOf == parentId) {
                    (parentHeight - child.measuredHeight) / 2
                } else {
                    (childMap.get(lp.centerVerticalOf)?.let { it.top + (it.bottom - it.top) / 2 } ?: 0) - child.measuredHeight / 2
                }
            }
            lp.topToBottomOf != -1 -> {
                val b = if (lp.topToBottomOf == parentId) bottom else childMap.get(lp.topToBottomOf)?.bottom ?: 0
                (b + lp.topMargin)
            }
            lp.topToTopOf != -1 -> {
                val t = if (lp.topToTopOf == parentId) top else childMap.get(lp.topToTopOf)?.top ?: 0
                (t + lp.topMargin)
            }
            lp.bottomToTopOf != -1 -> {
                val t = if (lp.bottomToTopOf == parentId) top else childMap.get(lp.bottomToTopOf)?.top ?: 0
                (t - lp.bottomMargin) - child.measuredHeight
            }
            lp.bottomToBottomOf != -1 -> {
                val b = if (lp.bottomToBottomOf == parentId) bottom else childMap.get(lp.bottomToBottomOf)?.bottom ?: 0
                (b - lp.bottomMargin) - child.measuredHeight
            }
            else -> 0
        }
    }

    // 根据不同的情况计算相对于父控件左边的距离
    private fun getChildLeft(lp: LayoutParam, parentWidth: Int, child: View): Int {
        val parentId = parent_id.toLayoutId()
        return when {
            lp.leftPercent != -1f -> (parentWidth * lp.leftPercent).toInt()
            lp.centerHorizontalOf != -1 -> {
                if (lp.centerHorizontalOf == parentId) {
                    (parentWidth - child.measuredWidth) / 2
                } else {
                    (childMap.get(lp.centerHorizontalOf)?.let { it.left + (it.right - it.left) / 2 } ?: 0) - child.measuredWidth / 2
                }
            }
            lp.startToEndOf != -1 -> {
                val r = if (lp.startToEndOf == parentId) right else childMap.get(lp.startToEndOf)?.right ?: 0
                (r + lp.marginStart)
            }
            lp.startToStartOf != -1 -> {
                val l = if (lp.startToStartOf == parentId) left else childMap.get(lp.startToStartOf)?.left ?: 0
                (l + lp.marginStart)
            }
            lp.endToStartOf != -1 -> {
                val l = if (lp.endToStartOf == parentId) left else childMap.get(lp.endToStartOf)?.left ?: 0
                (l - lp.marginEnd) - child.measuredWidth
            }
            lp.endToEndOf != -1 -> {
                val r = if (lp.endToEndOf == parentId) right else childMap.get(lp.endToEndOf)?.right ?: 0
                (r - lp.marginEnd) - child.measuredWidth
            }
            else -> 0
        }
    }

    // 当有新子控件加入时,将其 id 和引用存入 map
    override fun onViewAdded(child: View?) {
        super.onViewAdded(child)
        child?.let { childMap.put(it.id, it) }
    }
    // 当有新子控件被移出时,将其 id 和引用移出 map
    override fun onViewRemoved(child: View?) {
        super.onViewRemoved(child)
        child?.let { childMap.remove(it.id) }
    }

    // 自定义布局参数
    class LayoutParam(source: ViewGroup.LayoutParams?) : MarginLayoutParams(source) {
        // 横向属性
        var leftPercent: Float = -1f
        var startToStartOf: Int = -1
        var startToEndOf: Int = -1
        var endToEndOf: Int = -1
        var endToStartOf: Int = -1
        var centerHorizontalOf: Int = -1
        // 纵向属性
        var topPercent: Float = -1f
        var topToTopOf: Int = -1
        var topToBottomOf: Int = -1
        var bottomToTopOf: Int = -1
        var bottomToBottomOf: Int = -1
        var centerVerticalOf: Int = -1
    }
}
  • PercentLayout使用了SparseArray来存储子控件 id 和子控件引用的对应关系。其实只要拿到了View就可以拿到它的 id,为啥还要特意将这些信息存储在一个 map 结构中?因为想用空间换一点时间,否则每次都得遍历所有子控件。使用SparseArray而不是HashMap也是出于节约内存的考虑,相对而言,它有更好的内存效率,详细分析可以点击内存优化:充满矛盾的SparseArray

  • PercentLayout新增了一系列相对布局属性,这些属性的语义和ConstraintLayout中的一样。但有两个比较特殊的:centerHorizontalOf表示相对于某个控件水平对齐,centerVerticalOf表示相对于某个控件垂直对齐。

  • 这一系列相对布局属性存在互斥关系,他们分为两组,一组横向,一组纵向(详见代码注释)。一个控件只能拥有一个横向属性和一个纵向属性。getChildLeft()getChildTop分别遍历所有的横向和纵向属性,根据不同的相对位置采取不同的计算方法,以确定子控件相对于父控件的 left 和 top。

然后就可以像这样构建表体 item 的布局:

PercentLayout {
    layout_width = match_parent
    layout_height = 35
    background_color = "#ffffff"

    TextView {
        layout_id = "tvRank"
        layout_width = 18
        layout_height = wrap_content
        textSize = 14f
        textColor = "#9DA4AD"
        left_percent = 0.08f // 相对于父控件左边的百分比
        center_vertical_of_percent = parent_id //相对于父控件垂直居中
    }

    ImageView {
        layout_id = "ivAvatar"
        layout_width = 20
        layout_height = 20
        scaleType = scale_center_crop
        center_vertical_of_percent = parent_id // 相对于父控件垂直居中
        left_percent = 0.15f // 相对于父控件左边的百分比
    }

    TextView {
        layout_id = "tvName"
        layout_width = wrap_content
        layout_height = wrap_content
        textSize = 11f
        textColor = "#3F4658"
        gravity = gravity_center
        maxLines = 1
        includeFontPadding = false
        start_to_end_of_percent = "ivAvatar" // 位于 ivAvatar 控件的右边
        top_to_top_of_percent = "ivAvatar" // 与 ivAvatar 控件上边对齐
        margin_start = 5
    }

    TextView {
        layout_id = "tvTag"
        layout_width = wrap_content
        layout_height = wrap_content
        textSize = 8f
        textColor = "#ffffff"
        text = "save"
        gravity = gravity_center
        padding_vertical = 1
        includeFontPadding = false
        padding_horizontal = 2
        shape = shape {
            corner_radius = 4
            solid_color = "#8cc8c8c8"
        }
        start_to_start_of_percent = "tvName" // 与 tvName 控件左边对齐
        top_to_bottom_of_percent = "tvName" // 在 tvName 控件的下面
    }

    ImageView {
        layout_id = "ivLevel"
        layout_width = 10
        layout_height = 10
        scaleType = scale_fit_xy
        center_vertical_of_percent = "tvName" // 与 tvName 控件垂直对齐
        start_to_end_of_percent = "tvName" // 在 tvName 控件的后面
        margin_start = 5
    }

    TextView {
        layout_id = "tvLevel"
        layout_width = wrap_content
        layout_height = wrap_content
        textSize = 7f
        textColor = "#ffffff"
        gravity = gravity_center
        padding_horizontal = 2
        shape = shape {
            gradient_colors = listOf("#FFC39E", "#FFC39E")
            orientation = gradient_left_right
            corner_radius = 20
        }
        center_vertical_of_percent = "tvName" // 与 tvName 控件垂直对齐
        start_to_end_of_percent = "ivLevel" // 在 ivLevel 控件后面
        margin_start = 5
    }

    TextView {
        layout_id = "tvCount"
        layout_width = wrap_content
        layout_height = wrap_content
        textSize = 14f
        textColor = "#3F4658"
        gravity = gravity_center
        center_vertical_of_percent = parent_id // 相对于父控件居中
        end_to_end_of_percent = parent_id // 在父控件尾部
        margin_end = 20
    }
}

把 demo 跑起来,measure + layout 的耗时如下:

measure + layout=288,     delay=39,     anim=0,    touch=0,     draw=20,     total=350
measure + layout=0,     delay=307,     anim=4,    touch=0,     draw=0,     total=314
measure + layout=15,     delay=9,     anim=0,    touch=0,     draw=4,     total=31
measure + layout=0,     delay=14,     anim=0,    touch=0,     draw=0,     total=27

measure + layout用了 288 ms,虽然相对于FrameLayout多了十几毫秒,但是和ConstraintLayout的 330 ms 相比还是有不小的提升。

measure + layout耗时从最开始的 370 ms 经过两次优化,分别是弃用 xml替换表项根布局,缩减到 288 ms,有了 22% 的性能提升。但是离“耗时减半”还有点距离。限于篇幅原因,后续的优化详解放到下一篇继续讲解。欢迎关注我,以及时获取博客更新。

Talk is cheap, show me the code

相对百分比布局 + layout DSL 代码地址 代理模式解耦RecyclerView代码地址

推荐阅读

RecyclerView 系列文章目录如下:

  1. RecyclerView 缓存机制 | 如何复用表项?

  2. RecyclerView 缓存机制 | 回收些什么?

  3. RecyclerView 缓存机制 | 回收到哪去?

  4. RecyclerView缓存机制 | scrap view 的生命周期

  5. 读源码长知识 | 更好的RecyclerView点击监听器

  6. 代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂

  7. 更好的 RecyclerView 表项子控件点击监听器

  8. 更高效地刷新 RecyclerView | DiffUtil二次封装

  9. 换一个思路,超简单的RecyclerView预加载

  10. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)

  11. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

  12. RecyclerView 动画原理 | 如何存储并应用动画属性值?

  13. RecyclerView 面试题 | 列表滚动时,表项是如何被填充或回收的?

  14. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?

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

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

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

  18. RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势

  19. RecyclerView 的滚动时怎么实现的?(二)| Fling

  20. RecyclerView 刷新列表数据的 notifyDataSetChanged() 为什么是昂贵的?