本文干货充足,篇幅较长,建议收藏后阅读,避免迷路。融云公众号后台回复【列表卡顿】,可直接领取本文涉及所有资料。
一、背景
作为一款 IM 软件,会话列表是用户首先接触到的界面,会话列表滑动是否流畅对用户的体验有着很大的影响。随着功能的不断增加,会话列表上要展示的信息也越来越多。我们发现打完 Call 返回到会话列表界面进行滑动,可能出现严重的卡顿。于是我们开始对会话列表卡顿情况进行详细的分析。
二、卡顿的原因
提到卡顿原因,我们都会说是因为在 16ms 内无法完成渲染导致的。那么为什么需要在 16ms 内完成呢?以及在 16ms 以内需要完成什么工作?
2.1 刷新率(RefreshRate)与帧率(FrameRate)
刷新率指的是屏幕每秒刷新的次数,是针对硬件而言的。目前大部分的手机刷新率都在 60Hz(屏幕每秒钟刷新 60 次),有部分高端机采用的 120Hz(比如 iPad Pro)。
帧率是每秒绘制的帧数,是针对软件而言的。通常只要帧率与刷新率保持一致,我们看到的画面就是流畅的。所以帧率在 60FPS 时我们就不会感觉到卡。
如果帧率为每秒钟 60 帧,而屏幕刷新率为 30Hz,那么就会出现屏幕上半部分还停留在上一帧的画面,屏幕的下半部分渲染出来的就是下一帧的画面 —— 这种情况被称为画面撕裂;相反,如果帧率为每秒钟 30 帧,屏幕刷新率为 60Hz,那么就会出现相连两帧显示的是同一画面,这就出现了卡顿。
所以单方面的提升帧率或者刷新率是没有意义的,需要两者同时进行提升。
由于目前大部分 Android 机屏幕都采用的 60Hz 的刷新率,为了使帧率也能达到 60FPS,那么就要求在 16.67ms 内完成一帧的绘制(1000ms/60Frame = 16.666ms / Frame)。
2.2 VSYNC
由于显示器是从最上面一行像素开始,向下逐行刷新,所以从最顶端到最底部的刷新是有时间差的。
如果帧率(FPS)大于刷新率,那么就会出现前文提到的画面撕裂,如果帧率再大一点,那么下一帧的还没来得及显示,下下一帧的数据就覆盖上来了,中间这帧就被跳过了,这种情况被称为跳帧。
为了解决这种帧率大于刷新率的问题,引入了垂直同步的技术,简单来说就是显示器每隔 16ms 发送一个垂直同步信号(VSYNC),系统会等待垂直同步信号的到来,才进行一帧的渲染和缓冲区的更新,这样就把帧率与刷新率锁定。
2.3 系统是如何生成一帧的
在 Android4.0 以前,处理用户输入事件,绘制、栅格化都由 CPU 中应用主线程执行,很容易造成卡顿。主要原因在于主线程的任务太重,要处理很多事件,其次 CPU 中只有少量的 ALU 单元(算术逻辑单元),并不擅长做图形计算。
Android4.0 以后应用默认开启硬件加速。开启硬件加速以后,CPU 不擅长的图像运算就交给了 GPU 来完成,GPU 中包含了大量的 ALU 单元,就是为实现大量数学运算设计的(所以挖矿一般用 GPU)。硬件加速开启后还会将主线程中的渲染工作交给单独的渲染线程(RenderThread),这样当主线程将内容同步到 RenderThread 后,主线程就可以释放出来进行其他工作,渲染线程完成接下来的工作。
那么完整的一帧流程如下:
(1) 首先在第一个 16ms 内,显示器显示了第 0 帧的内容,CPU/GPU 处理完第一帧。
(2) 垂直同步信号到来后,CPU 马上进行第二帧的处理工作,处理完以后交给 GPU。显示器则将第一帧的图像显示出来。
整个流程看似没有什么问题,但是一旦出现帧率(FPS)小于刷新率的情况,画面就会出现卡顿。
图上的 A 和 B 分别代表两个缓冲区。因为 CPU/GPU处理时间超过了 16ms,导致在第二个 16ms 内,显示器本应该显示 B 缓冲区中的内容,现在却不得不重复显示 A 缓冲区中的内容,也就是掉帧了(卡顿)。
由于 A 缓冲区被显示器所占用,B 缓冲区被 GPU 所占用,导致在垂直同步信号 (VSync) 到来时 CPU 没办法开始处理下一帧的内容,所以在第二个 16ms内,CPU 并没有触发绘制工作。
2.4 三缓冲区(Triple Buffer)
为了解决帧率(FPS)小于屏幕刷新率导致的掉帧问题,Android4.1 引入了三级缓冲区。
在双缓冲区的时候,由于 Display 和 GPU 各占用了一个缓冲区,导致在垂直同步信号到来时 CPU 没有办法进行绘制。那么现在新增一个缓冲区,CPU 就能在垂直同步信号到来时进行绘制工作。
在第二个 16ms 内,虽然还是重复显示了一帧,但是在 Display 占用了 A 缓冲区,GPU 占用了 B 缓冲区的情况下,CPU 依然可以使用 C 缓冲区完成绘制工作,这样 CPU 也被充分地利用起来。后续的显示也比较顺畅,有效地避免了 Jank 进一步的加剧。
通过绘制的流程我们知道,出现卡顿是因为掉帧了,而掉帧的原因在于垂直同步信号到来时,还没有准备好数据用于显示。所以我们要处理卡顿,就要尽量缩短 CPU/GPU 绘制的时间,这样就能保证在 16ms 内完成一帧的渲染。
三、问题分析
有了以上的理论基础,我们开始分析会话列表卡顿的问题。由于 Boss 使用的 Pixel5 属于高端机,卡顿并不明显,我们特意从测试同学手中借来了一台中低端机。
优化之前,手机刷新率是多少:
是 60Hz 没问题。
去高通网站上查询一下 SDM450 具体的架构:
可以看该手机的 CPU 是 8 核 A53 Processor。
A53 Processor 一般在大小核架构中当作小核来使用,其主要作用是省电,那些性能要求很低的场景一般由它们负责,比如待机状态、后台执行等,而A53 也确实把功耗做到了极致。
在三星 Galaxy A20s 手机上,全都采用该 Processor,并且没有大核,那么处理速度自然不会很快,这也就要求我们的 APP 优化得更好才行。
在有了对手机大致的了解以后,我们使用工具来查看一下卡顿点。
首先打开系统自带的 GPU 呈现模式分析工具,对会话列表进行查看。
可以看见直方图已经高出了天际。在图中最下面有一条绿色的水平线(代表16ms),超过这条水平线就有可能出现掉帧。
根据 Google 给出的颜色对应表,我们来看看耗时的大概位置。首先我们要明确,虽然该工具叫 GPU 呈现模式分析工具,但是其中显示的大部分操作发生在 CPU 中。
其次根据颜色对照表大家可能也发现了,谷歌给出的颜色跟真机上的颜色对应不上。所以我们只能判断耗时的大概位置。
从我们的截图中可以看见,绿色部分占很大比例,其中一部分是 Vsync 延迟,另外一部分是输入处理+动画+测量/布局。
Vsync 延迟图标中给出的解释为两个连续帧之间的操作所花的时间。其实就是 SurfaceFlinger 在下一次分发 Vsync 的时候,会往 UI 线程的 MessageQueue 中插入一条 Vsync 到来的消息,而该消息并不会马上执行,而是等待前面的消息被执行完毕以后,才会被执行。所以 Vsync 延迟指的就是 Vsync 被放入 MessageQueue 到被执行之间的时间。这部分时间越长说明 UI 线程中进行的处理越多,需要将一些任务分流到其他线程中执行。
四、优化方案及实践
4.1 异步
有了大概的方向以后,我们开始对会话列表进行优化。
在问题分析中,我们发现 Vsync 延迟占比很大,所以我们首先想到的是将主线程中的耗时任务剥离出来,放到工作线程中执行。为了更快地定位主线程方法耗时,可以使用滴滴的 Dokit 或者腾讯的 Matrix 进行慢函数定位。
这部分逻辑在主线程中执行,耗时大概在 80ms 左右,如果会话列表多,数据库表数据变更大,这部分的耗时还会增加。
我们还发现每次进入会话列表时都需要从数据库中获取会话列表数据,加载更多时也会从数据库中读取会话数据。读取到会话数据以后,我们会对获取到的会话进行过滤操作,比如不是同一个组织下的会话则应该过滤掉。过滤完成以后会进行去重,如果该会话已经存在,则更新当前会话;如果不存在,则创建一个新的会话并添加到会话列表,然后还需要对会话列表按一定规则进行排序,最后再通知 UI 进行刷新。
这部分的耗时为 500ms-600ms,并且随着数据量的增大耗时还会增加,所以这部分必须放到子线程中执行。但是这里必须注意线程安全问题,否则会出现数据多次被添加,会话列表上出现多条重复的数据。
4.2 增加缓存
在检查代码的时候,我们发现有很多地方会获取当前用户的信息,而当前用户信息保存在了本地 SP 中(后改为MMKV),并且以 Json 格式存储。那么在获取用户信息的时候会从 SP 中先读取出来(IO 操作),再反序列化为对象(反射)。
每次都这样获取当前用户的信息会非常的耗时。为了解决这个问题,我们将第一次获取的用户信息进行缓存,如果内存中存在当前用户的信息则直接返回,并且在每次修改当前用户信息的时候,更新内存中的对象。
4.3 减少刷新次数
在这个方案里,一方面要减少不合理的刷新,另外一方面要将部分全局刷新改为局部刷新。
将通知页面刷新的代码提取到循环外面,等待数据更新完毕以后刷新一次即可。(详细方案进入融云公众号查阅)
4.4 onCreateViewHolder 优化
在分析 Systrace 报告时,我们发现了这种情况 —— 一次滑动伴随着大量的 CreateView 操作。为什么会出现这种情况呢?我们知道 RecyclerView 本身是存在缓存机制的,滑动中如果新展示的 item 布局跟老的一致,就不会再执行 CreateView,而是复用老的 item,执行 bindView 来设置数据,这样可减少创建 view 时的 IO 和反射耗时。(详细方案进入融云公众号查阅)
4.5 预加载+全局缓存
虽然我们减少了 CreateView 的次数,但是我们在首次进入时第一屏还是需要 CreateView,并且我们发现 CreateView 的耗时也挺长。
这部分时间能不能优化掉?我们首先想到的是在 onCreateViewHolder 时采用异步加载布局的方式,将 IO、反射放在子线程来做,后来这个方案被去掉了,具体原因后文会说。如果不能异步加载,那么我们就考虑将创建 View 的操作提前来执行并且缓存下来。(详细方案进入融云公众号查阅)
4.6 onBindViewHolder 优化
我们在查看 Systrace 报告时,还发现,除了 CreateView 耗时,BindView 竟然也很耗时,而且这个耗时甚至超过了 CreateView。这样在一次滑动过程中,如果有 10 个 item 新展示出来,那么耗时将达到 100 毫秒以上。这是绝对不能接受的,于是我们开始清理 onBindViewHolder 的耗时操作。(详细方案进入融云公众号查阅)
4.7 布局优化
除了减少 BindView 的耗时以外,布局的层级也影响着 onMeasure 和 onLayout 的耗时。我们在使用 GPU 呈现模式分析工具时发现测量和布局花费了大量的时间,所以我们打算减少 item 的布局层级。
除了去掉重复的背景,我们还可以尽量减少使用透明度,Android 系统在绘制透明度时会将同一个区域绘制两次,第一次是原有的内容,第二次是新加的透明度效果。基本上 Android 中的透明度动画都会造成过度绘制,所以可以尽量减少使用透明度动画,在 View 上面也尽量不要使用 alpha 属性。具体原理可以参考谷歌官方视频(视频链接可通过融云微信公众号后台回复【列表卡顿】获取)。
4.8 其他优化
除了上面所说的优化点,还有一些小的优化点:
(1)比如使用高版本的 RecyclerView,会默认开启预取功能。
从上图中我们可以看见,UI 线程完成数据处理交给 Render 线程以后就一直处于空闲状态,需要等待个 Vsync 信号的到来才会进行数据处理,而这空闲时间就被白白浪费了,开启预取以后就能合理地使用这段空闲时间。
(2)将 RecyclerView 的 setHasFixedSize 方法设置为 true。当我们的 item 宽高固定时,使用 Adapter 的 onItemRangeChanged()、onItemRangeInserted()、onItemRangeRemoved()、
onItemRangeMoved() 这几个方法更新 UI,不会重新计算大小。
(3)如果不使用 RecyclerView 的动画,可以通过 ((SimpleItemAnimator) rv.getItemAnimator()).setSupportsChangeAnimations(false) 把默认动画关闭来提升效率。
五、弃用的方案
在做会话列表卡顿优化过程中,我们采用了一些优化方案,但是最终没有采用,这里也列出加以说明。
5.1 异步加载布局
在前文中有提到,我们在减少 CreateView 耗时的过程中,最初打算采用异步加载布局的方式来将 IO、反射放在子线程中执行。我们使用的是谷歌官方的 AsyncLayoutInflater 来异步加载布局,该类会将布局加载完成以后回调通知我们。但是它一般用于 onCreate 方法中。而在 onCreateViewHolder 方法中需要返回 ViewHolder,所以没有办法直接使用。
为了解决这个问题,我们自定义了一个 AsyncFrameLayout 类,该类继承于 FrameLayout,我们会在 onCreateViewHolder 方法中将 AsyncFrameLayout 作为 ViewHolder 的根布局添加进去,并且调用自定义的 inflate 方法,进行异步加载布局,加载成功以后再把加载成功的布局添加到 AsyncFrameLayout 中,作为 AsyncFrameLayout 的子 View。
5.2 DiffUtil
DiffUtil 是谷歌官方提供的一个数据对比工具,它可以对比两组新老数据,找出其中的差异,然后通知 RecyclerView 进行刷新。DiffUtil 使用 Eugene W. Myers 的差分算法(相关资料可通过融云微信公众号后台回复【列表卡顿】获取)来计算将一个列表转换为另一个列表的最少更新次数。但是对比数据时也会耗时,所以也可以采用 AsyncListDiffer 类,把对比操作放在异步线程中执行。
5.3 滑动停止时刷新
为了避免会话列表大量刷新操作,我们将会话列表滑动时的数据更新给记录了下来,等待滑动停止以后再进行刷新。但是在实际测试过程中,停止后的刷新会导致界面卡顿一次,中低端机上比较明显,所以放弃了此策略。
5.4 提前分页加载
由于会话列表数量可能很多,所以我们采用分页的方式来加载数据。为了保证用户感知不到加载等待的时间,我们打算在用户将要滑动到列表结束位置之前获取更多的数据,让用户无痕地下滑。想法是理想的,但是实践过程中也发现在中低端机上会有一瞬间的卡顿,所以该方法也暂时先弃用。
除了以上方案被弃用了,我们在优化过程中发现,其它品牌相似产品的会话列表滑动其实速度并没特别快,如果滑动速度慢的话,那么在一次滑动过程中需要展示的 item 数量就会小,这样一次滑动就不需要渲染过多的数据。这其实也是一个优化点,后面我们可能会考虑降低滑动速度的实践。
六、总结
在开发过程中,随着业务的不断新增,我们的方法和逻辑复杂度也会不断增加,这时候一定要注意方法耗时,耗时严重的尽量提取到子线程中执行。使用 Recyclerview 时千万不要无脑刷新,能局部刷的绝不全局刷,能延迟刷的绝不马上刷。在分析卡顿的时候可以结合工具进行,这样效率会提高很多,通过 Systrace 发现大概的问题和排查方向以后,可以通过 Android Studio 自带的 Profiler 来进行具体代码的定位。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。