RecyclerView的新机制:预取(Prefetch)

英文原文:RecyclerView Prefetch 。

当我还是小孩的时候,妈妈企图治愈我顽固不化的拖延症,说如果你现在就打扫房间,就不必拖到以后。但是我从未把她的话信以为真,我觉得尽量拖延是最好的。首先,如果我现在就打扫,它还是会再次变脏,然后我还得重复那个恶心的工作;再者,如果我拖的时间足够长,她可能就把那事给忘了。

拖延对我总是有用。跟我的朋友RecyclerView不同,我不需要去处理帧率连贯性的问题。

问题所在

在滚动和滑动的时候,RecyclerView需要显示进入屏幕的新item,这些item需要被绑定数据(如果缓存中没有类似的item很可能还需要创建),然后把它们放入布局并绘制。当所有这些工作慢吞吞进行的时候,UI线程会慢慢停下来等待其完成,然后渲染才能进行,滚动才能继续。

1-X9E34oKRhAJbG-uSrhv-TA.png

滚动期间RecyclerView能满足绝大多数帧的需求,因为没有新的内容。在这些帧期间,UI线程处理输入(input),动画,布局,并记录绘制指令。然后把这些绘制信息同步给RenderThread(自Lollipop以来就是如此,之前的版本这些都是在UI线程中做的 ),RenderThread把这些指令发送给GPU。

1-DIr64fruHL5lp72Ji-b7rw.png

但当一个新的item进入屏幕,在input这一步就需要更多的工作来绑定,甚至是创建了。这就推迟了UI线程,以及RenderThread的后续工作,如果在帧边界之内不能完成就会发生卡顿。

1-R0vg4lvbNilR1xB5Qrawmw.png

当新item进入的时候,仔细检查input阶段的调用栈可以看到大部分时间都花在了视图的创建和绑定上。

与其在准备item的时候推迟其所有其它工作,换个地方做这些工作不是更好吗?

1-2XWNdvsSwW8-L_DQwYxLxw.png

这是 Chris Craik(安卓UI工具团队的图形工程师)在用Systraces观察RecyclerView滚动时得到的结果。他看到在需要一个新的item时,我们花了太多时间去准备这个item,但同时UI线程却早早的完成了前一帧的任务,休眠了大量时间。

解决办法

显然是时候认真研究时间这个问题了。Chris重新安排了这些工作在RecyclerView布局中发生的方式。在空闲的阶段预先取出即将显示的这些item,避免稍后出现大家都在等待的情况。

1-_qCP_uaM8nMSlgqU6L1CxA.png

现在这些工作毫不费力的就完成了。因为我们可以把空闲的时间利用起来完成这些工作,UI线程在帧间隙没有做任何工作,同时因为最难的部分已经完成,也让后续的帧更加流畅。

细节,细节

这个机制运行的原理是在RecyclerView开始一个滚动操作的时候启动一个Runnable。这个Runnable执行即将显示的item的预取操作,具体是哪些item取决于滚动的方向以及layout manager。预取不限于一个item,也可以一次预取多个item,比如GridLayoutManager中即将显示的一行item。在版本v25.1中,预取操作被分割为单个的创建/绑定操作,这比对一组item整个执行要更容易适合UI线程的间隙。

一个有趣的事情是系统需要预测此操作需要花费的时间,以及能不能在时间间隙之内完成。毕竟如果预取操作让那一帧超过了自己的时间期限,我们仍然会感受到跳过这一帧造成的卡顿,跟不使用预取相比,只是卡顿的位置不同罢了。系统对于这个细节的处理方式是跟踪每个view type创建和邦定的平均时间,预测未来创建和邦定的所需时间。

对于嵌套的RecyclerView而言就稍微复杂点了,因为邦定内部RecyclerView的时候并不会分配任何子view,只有在被装载与卸载的时候RecyclerView才会去获取子view。在内部RecyclerView中,预取机制仍然能准备好子view,不过必须知道需要预取多少。这就是为什么在25.1版本中为LinearLayoutManager新增了APIsetInitialItemPrefetchCount(),它告诉系统当RecyclerView即将在屏幕上滚动的时候该提前获取多少个item来填充RecyclerView。

译者注:从这个方法的名称就能看出它的作用了,initalitem就是初始item的意思。这样就很好理解上面那段话了。

警告

有几个值得注意的问题:

  • Pre-fetching可能会造成不必要的工作。因为在预取一个view的时候,可能会过于激进,而RecyclerView不能到达这个item。这意味着我们的预取工作被浪费了。(由于是并行发生的,这并不是什么大问题。而且这种情况不常见,因为我们都是在很接近需要之前的时间去取,这两帧之间不太可能会出现滚动停止和反向的情况)。

  • RenderThread:RenderThread是萝莉炮(Lollipop)才引入的特性,为了把渲染任务放在一个不同的线程上,提高性能,比如运行一些动画(比如ripples , circular reveals)就是完全在RenderThread上。这就意味着Lollipop之前的设备是不能享受这个优化的。

哪里可以得到?

预取机制在 Support Library v25 被引入,在v25.1.0得到了进一步的强化。因此第一步就是获得最新版本的支持库。

如果你使用的是RecyclerView默认的布局管理器,你自动的就得到了这些优化。但是如果你使用嵌套的RecyclerView,或者你自己写布局管理器,则需要改变一下代码才能利用这个特性。

对于嵌套RecyclerViews要得到最佳性能,在内层LayoutManager上调用LinearLayoutManager的新方法setInitialItemPrefetchCount()(v25.1可用)。比如,如果一个垂直列表最少能显示3个以上的item,调用setInitialItemPrefetchCount(4)。

如果你自己实现LayoutManager的话,需要重写 LayoutManager.collectAdjacentPrefetchPositions(), 因为prefetch开启的时候RecyclerView会调用这个方法,而它默认的实现什么也没做。同样当RecyclerView是嵌套在另一个RecyclerView中的时候,要想它的LayoutManager发生预取行为,也要实现 LayoutManager.collectInitialPrefetchPositions()

跟往常一样,在创建和绑定这两步仍然值得去优化,尽可能做更少的工作。虽然framework可以通过预取来并行工作,但是这仍是有时间开销的,item创建开销巨大的话仍然会卡顿。一个最简化的视图结构总是比更复杂的要节省一些。绑定工作也要尽可能简单快速,只调用setters是最好的。即使你能在一帧之内跑完你的代码,进一步优化对低端设备用户也是有好处的,甚至对那些高端设备也能节约电池的消耗。

如果你想看看优化的实际效果,可以调用 LayoutManager.setItemPrefetchEnabled(boolean enabled) 来切换这个功能,然后比较。你可以明显的看到优化效果是显著的,尤其是那些item的创建和绑定花费时间比较多的列表。如果你想看看底层发生了什么,可以分别在prefetch启用和关闭的情况下运行Systrace, 或者 启用 GPU profiling

1-gmuFD82uYJmGVVEPFxs6ag.png

结尾

去下载最新的 Support Library吧,感受下新的RecyclerView机制,而我呢继续保持不打扫房间的习惯...