构建 Android App 界面时,RecyclerView 出场率很高。它的加载性能影响着用户体检。本篇分享一次完整的 RecyclerView 性能优化过程:从用工具定位问题,再不断尝试各种优化方案,最终达成 50% 的性能优化。
这次性能调优的界面如下:
界面用列表的形式,展示了一个主播排行榜。
这个排行榜嵌套在一个 ViewPager 中。最初发现性能问题是因为滑动到该界面时,ViewPager 指示器的平移动画卡了一下,掉帧了。
虽然卡顿是肉眼可见的,但若不能量化卡顿,就无法量化优化程度。
第一个想到的工具是GPU 呈现模式分析。开启它的路径如下:打开手机设置 — 开发者选项 — GPU 呈现模式分析 — 在屏幕上显示为条形图:
开启后,绘制性能就会图形化展示如下:
果然有很大的性能问题,柱子都快冲出屏幕了。
虽然图形化很直观,但量化地还不够细致,绘制耗时最好能精确到毫秒。所以转战到另一种方式“在 adb shell dumpsys gfxinfo 中”。选中后,打开排行榜界面,然后输入命令adb shell dumpsys gfxinfo <包名>,最近 n 针的渲染时长就会罗列如下:
每一行代表一帧渲染中各个阶段的耗时。
用另一个命令还可以得到更加精确的数据:adb shell dumpsys gfxinfo <包名> framestats,该命令会从应用生成的最近 120 个帧中输出带有纳秒时间戳的帧时间信息:
原生输出信息没有可读性,但它们遵守 csv 格式,复制粘贴到 wps 表格中,选中 数据 — 分列,用“逗号”分割:
数据就以表格的形式展示:
每一行表示一帧绘制的时间信息,一共有 16 列,每一列表示一个关键节点的时间戳,比如PerformTraversalsStart表示绘制遍历的开始时间点,DrawStart表示onDraw()开始的时间点,前者减去后者表示measure + layout的耗时。
利用表格的求差功能可以计算出一排表征性能的耗时。
虽然得到了量化数据,但是这么折腾着实有点辛苦。
一顿搜索之后,终于找到了下面这个高效的方法:
Window.addOnFrameMetricsAvailableListener()方法可以监听最近 120 帧的绘制耗时,它的数据源和上面 adb 命令是一样的。
我把自己感兴趣的耗时都打印了出来,分别是 measure + layout、延迟、动画、触摸、绘制、总耗时。
然后打开了排行榜界面,得到了如下数据:
有一帧绘制耗时高达 435 ms,其中 measure + layout 占了 370 ms。(此数值在不同手机上差异较大)
然后我关闭了 log 过滤,发现了更多信息:
紧接着耗时最长的那一帧,有一条警告,它是由Choreographer打印的,表示此刻发生掉帧,而且掉了整整 23 帧。。。(关于 Choreographer 详细的源码解析可以点击读源码长知识 | Android卡顿真的是因为”掉帧“?)
首先想到的一个方案是:“弃用 xml”
onCreateViewHolder()执行在主线程,如果它执行耗时,势必会影响到也运行在主线程的绘制性能。
demo 中排行榜一共有两类 item:表头和表体,其中构建表头布局的代码如下:
原本这些逻辑应该写在RecyclerView.Adapter中,把它单独抽象到一个 proxy 类中,是为了解耦,以便更容易地为列表添加不同类型的表项:
调用addProxy()就动态地添加一种新表项类型(关于代理模式的实战应用可以点击代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂)。
在onCreateViewHolder()中通过解析布局文件的方式来构建表项 item。
但解析布局文件需要进行 IO 操作将布局文件读到内存中,再解析 xml 根据标签 new 出对应的控件实例,最后 addView() 到容器中。这个过程是耗时的。
如果能使用 kotlin 代码直接完成布局的构建,则可以加速这个过程。但这样的构建代码可读性很差,后期想要更改控件的某个属性很难定位。
利用 kotlin 的DSL来改善构建代码的可读性,甚至超越 xml:
关于如何使用 DSL 简化布局构建可以点击Android性能优化 | 把构建布局用时缩短 20 倍(下)
将表头和表体 item 都用DSL重构了一番,运行 demo 看看数据:
measure + layout 时间从 370 ms 缩短到 330 ms,可喜可贺~~
想到的第二个优化方案是:“替换表项的根布局”
表头和表头 item 的布局都是用了ConstraintLayout,是不是因为它太复杂了,所以导致measure + layout耗时过长?
带着怀疑,我把所有的ConstraintLayout换成了FrameLayout,界面就变成了这样:
所有子控件都聚拢在一点,再瞄一眼性能日志:
令人惊喜的是measure + layout时间从 330 ms 缩短到了 272 ms。
看来表项根布局的复杂程度的确可以影响到列表的加载性能,而且列表会放大这个性能差距,因为 n 个表项就会进行 n 次measure + layout
那就用最最简单的FrameLayout来布局吧~。通过leftMargin和topMargin来定位表项中的每一个子控件。我对着 UI 设计图,读取了每个子控件相对于父控件的左边距和上边距,然后用FrameLayout重写了表头 item。
但当我把 demo 在不同手机上运行之后,发现这个方案有缺陷,虽然已经使用了 dp 而不是像素值,但依然无法很好地解决多屏幕适配的问题:
“粉丝数”根据左边距和上边距来确定相对于父控件的位置,不同的手机屏宽度不同,所以适配效果很差。
可能这就是相对布局存在的原因,但RelativeLayout也不是省油的灯。有没有别的更简单的方法?
我想到了百分比布局,还是基于左边距和上边距,但这次不使用 dp 值,而是用相对于父控件的百分比,不就能完美解决这个问题吗?
立马搜索了一下,遗憾的发现PercentFrameLayout已经被弃用。。。
那就自己手写一个:
百分比布局的编码很简单,只需要两步:先测量所有子控件,然后按需要定位所有子控件。其中测量孩子使用ViewGroup.measureChildren()就完成了。布局孩子得先计算出父控件的宽高,然后与子控件的百分比相乘就得到了相对于父控件的位置,最后调用View.layout()来定位子控件。
运行一下 demo,效果理想~~
运用相同的思路重构了一下表体 item 。过程中发现了一个问题:并不是所有控件都可以相对于父控件来布局。
比如下面这个场景:
表项数据是服务器返回的,文字长度是可变的,“等烟雨来,就是不来”后面与它垂直对齐的图片就无法相对于父控件布局。
所以PrecentLayout不得不也引入相对布局的概念,但也不需要像ConstraintLayout那样复杂,一个简化版的百分比相对布局如下:
- PercentLayout使用了SparseArray来存储子控件 id 和子控件引用的对应关系。其实只要拿到了View就可以拿到它的 id,为啥还要特意将这些信息存储在一个 map 结构中?因为想用空间换一点时间,否则每次都得遍历所有子控件。使用SparseArray而不是HashMap也是出于节约内存的考虑,相对而言,它有更好的内存效率,详细分析可以点击内存优化:充满矛盾的SparseArray。
- 为PercentLayout新增了一系列相对布局属性,这些属性的语义和ConstraintLayout中的一样。但有两个比较特殊的:centerHorizontalOf表示相对于某个控件水平对齐,centerVerticalOf表示相对于某个控件垂直对齐。
- 这一系列相对布局属性存在互斥关系,他们分为两组,一组横向,一组纵向(详见代码注释)。一个控件只能拥有一个横向属性和一个纵向属性。getChildLeft()和getChildTop分别遍历所有的横向和纵向属性,根据不同的相对位置采取不同的计算方法,以确定子控件相对于父控件的 left 和 top。
然后就可以像这样构建表体 item 的布局:
把 demo 跑起来,measure + layout 的耗时如下:
measure + layout用了 288 ms,虽然相对于FrameLayout多了十几毫秒,但是和ConstraintLayout的 330 ms 相比还是有不小的提升。
measure + layout耗时从最开始的 370 ms 经过两次优化,分别是弃用 xml和替换表项根布局,缩减到 288 ms,有了 22% 的性能提升。但是离“耗时减半”还有点距离。限于篇幅原因,后续的优化详解放到下一篇继续讲解。欢迎关注我,以及时获取博客更新。