深入理解 RecyclerView 的缓存机制

13,654 阅读11分钟

使用 ScrollView 的时候,它的所有子 view 都会一次性被加载出来。而正确使用 RecyclerView 可以做到按需加载,按需绑定,并实现复用。本文主要分析 RecyclerView 缓存复用的原理。

从缓存获取 ViewHolder 流程概览

从缓存获取的大致流程如下图所示:

缓存获取流程

说明:

在创建 ViewHolder 之前,RecyclerView 会先从缓存中尝试获取是否有符合要求的 ViewHolder,详见 Recycler#tryGetViewHolderForPositionByDeadline 方法

  • 第一次,尝试从 mChangedScrap 中获取。
    • 只有在 mState.isPreLayout() 为 true 时,也就是预布局阶段,才会做这次尝试。
    • 「预布局」的概念会在介绍。
  • 第二次,getScrapOrHiddenOrCachedHolderForPosition() 获得 ViewHolder。
    • 尝试从 1. mAttachedScrap 2.mHiddenViews 3.mCachedViews 中查找 ViewHolder
      • 其中 mAttachedScrap 和 mCachedViews 都是 Recycler 的成员变量
      • 如果成功获得 ViewHolder 则检验其有效性,
        • 检验失败则将其回收到 RecyclerViewPool 中
        • 检验成功可以直接使用
  • 第三次,如果给 Adapter 设置了 stableId,调用 getScrapOrCachedViewForId 尝试获取 ViewHolder。
    • 跟第二次的区别在于,之前是根据 position 查找,现在是根据 id 查找
  • 第四次,mViewCacheExtension 不为空的话,则调用 ViewCacheExtension#getViewForPositionAndType 方法尝试获取 View
    • 注:ViewCacheExtension 是由开发者设置的,默认情况下为空,一般我们也不会设置。这层缓存大部分情况下可以忽略。
  • 第五次。尝试从 RecyclerViewPool 中获取,相比较于 mCachedViews,从 mRecyclerPool 中成功获取 ViewHolder 对象后并没有做合法性和 item 位置校验,只检验 viewType 是否一致。
    • 从 RecyclerViewPool 中取出来的 ViewHolder 需要重新执行 bind 才能使用。
  • 如果上面五次尝试都失败了,调用 RecyclerView.Adapter#createViewHolder 创建一个新的 ViewHolder
  • 最后根据 ViewHolder 的状态,确定是否需要调用 bindViewHolder 进行数据绑定。

问题

预布局、预测动画是什么?

理解「预布局」需要先了解「预测动画」。考虑这样一个场景:

用户有 A、B、C 三个 item,A,B 刚好显示在屏幕中,这个时候,用户把 B 删除了,那么最终 C 会显示在 B 原来的位置

如果 C 从底部平滑地滑动到之前 B 的位置将会更符合直觉。但是要做到这点实际上没那么简单。因为我们只知道 C 最终的位置,但是不知道 C 的起始位置在哪里,无法确定 C 应该从哪里滑动过来。如果根据最终的状态,就断定 C 应该要从底部滑动过来的话,很可能是有问题的。因为在其他 LayoutManager 中,它可能是从侧面或者是其他地方滑动过来的。

那根据原状态与最终状态之间的差异,能不能得出我们应该执行什么样的切换动画呢?答案依然是 no。因为在原状态中,C 根本就不存在。(这个时候,我们并不知道,B 要被删除了,如果把 C 给加载出来,很可能是一种资源浪费。)

设计 RecyclerView 的工程师是这么解决的。当 Adapter 发生变化的时候,RecyclerView 会让 LayoutManager 进行两次布局。

  • 第一次是预布局。将之前原状态 下的 item 都布局出来。并且根据 Adapter 的 notify 信息,我们知道哪些 item 即将变化了,所以可以加载出另外的 View。在上述例子中,因为知道 B 已经被删除了,所以可以把屏幕之外的 C 也加载出来
  • 第二个,最终的布局,也就是变化完成之后的布局。

这样只要比较前后布局的变化,就能得出应该执行什么动画了。

这种负责执行动画的 view 在原布局或新布局中不存在的动画,就称为预测动画

预布局是实现预测动画的一个步骤。

下面两个动图展示了普通动画与预测动画效果的区别:

普通动画 👇

预测动画 👇

关于预测动画,感兴趣的同学可以进一步阅读这篇文章

关于 Scrap

Scrap 缓存列表(mChangedScrap、mAttachedScrap)是 RecyclerView 最先查找 ViewHolder 地方,它跟 RecyclerViewPool 或者 ViewCache 有很大的区别。

mChangedScrap 和 mAttachedScrap 只在布局阶段使用。其他时候它们是空的。布局完成之后,这两个缓存中的 viewHolder,会移到 mCacheView 或者 RecyclerViewPool 中。

当 LayoutManager 开始布局的时候(预布局或者是最终布局),当前布局中的所有 view,都会被 dump 到 scrap 中(具体实现可见 LinearLayoutManager#onLayoutChildren() 方法中调用了 detachAndScrapAttachedViews() ),然后 LayoutManager 挨个地取回 view,除非 view 发生了什么变化,否则它会马上从 scrap 中回到原来的位置。

img

以上图为例,我们删除掉 b,调用 notifyItemRemove 方法,触发重新布局,这时 a,b,c 都会被 dump 到 scrap 中,然后 LayoutManager 会从 scrap 中取回 a 和 c。

偏个题,这个时候,b 去哪了? RecyclerView 看到 b 没有出现在最终的布局中,会 unscrap 它,让它执行一个消失的动画然后隐藏。动画执行完之后,b 被放到 RecyclerViewPool 中。

为什么 LayoutManager 需要先执行 detach,然后再重新 attach 这些 view,而不是只移除哪些变化的子 view 呢?Scrap 缓存列表的存在,是为了隔离 LayoutManager 和 RecyclerView.Recycler 之间的关注点/职责。LayoutManager 不需要知道哪一个子 view 应该保留 或者是 应该被回收到 pool 亦或者其他什么地方。这是 Recycler 的职责。

除了在布局时不为空外,还有另一个与 scrap 有关的规律:所有 scrap 的 view 都会跟 RecyclerView 分离。ViewGroup 中的 attachView 和 detachView 方法跟 addView 和 removeView 方法很像,但是不会触发请求布局会重绘的事件。它们只是从 ViewGroup 的子 view 列表中删除对应的子 view,并将该子 view 的 parent 设置为 null。detached 状态必须是临时,后面紧随着 attach 或者 remove 事件

如果在计算一个新布局的时候,已经添加了一堆子 view,可以放心的将它们全部 detach ,Recyclerview 就是这么做的。

Attached vs Changed scrap

Recycler 类中,我们可以看到两个单独的 scrap 容器: mAttachedScrap 和 mChangedScrap。为什么需要两个呢?

ViewHolder 只有在满足下面情况才会被添加到 mChangedScrap:当它关联的 item 发生了变化(notifyItemChanged 或者 notifyItemRangeChanged 被调用),并且 ItemAnimator 调用 ViewHolder#canReuseUpdatedViewHolder 方法时,返回了 false。否则,ViewHolder 会被添加到AttachedScrap 中。

canReuseUpdatedViewHolder 返回 “false” 表示我们要执行用一个 view 替换另一个 view 的动画,例如淡入淡出动画。 “true”表示动画在 view 内部发生。

mAttachedScrap 在 整个布局过程中都能使用,但是 changed scrap — 只能在预布局阶段使用。

这是有道理的:在布局后,新的 ViewHolder 应该替换掉“改变了的”视图,因此 AttachedScrap 在布局后是没有用的。 更改动画执行完成后,change scrap 将按预期方式转存到 pool 中

默认的 ItemAnimator 可以在 3 种情况下重用更新的 ViewHolder:

  • 调用了 setSupportsChangeAnimations(false)。
  • 调用了 notifyDataSetChanged 而不是 notifyItemChanged 或 notifyItemRangeChanged 。
  • 提供了这样的更改 payload:adapter.notifyItemChanged(index,anyObject)。

最后一种情况显示了一种很好的方法,当只想更改一些内部元素时,可以避免创建/绑定新的 ViewHolder。

Hidden Views 是什么?

前面提到在第二次尝试获取 ViewHolder 的时候,有一个子步骤会从 hidden view 中搜索,这里的 hidden view 指的是什么?「hidden view」指的是那些正在从 RecyclerView 边界中脱离的 view。为了让这些 view 正确地执行对应的分离动画,它们仍然作为 RecyclerView 的子 view 被保留下来。

站在 LayoutManager 的角度,这些 view 已经不存在了,因此不应该被包含在计算里面。比如 在部分 view 正在执行消失动画的过程中,调用 LayoutManager#getChildAt 方法,这些 view 不算在下标里面。来自 LayoutManager 的所有对 getChildAt()、getChildCount()、addView() 等的方法调用 在应用到实际的可回收view 之前,都要通过 ChildHelper 处理,ChildHelper 的职责是重新计算非隐藏的子 view 列表和完整的子 view 列表之间的索引。

请记住,我们正在搜索要提供给 LayoutManager 的视图,但是 LayoutManager 不应了解隐藏 View

举一个实际的🌰:这种让人费解的“从隐藏的 view 弹跳”(bouncing from hidden views)机制对于处理下面这种情况而言是很有必要的。 考虑这种场景,我们插入一个 item ,然后在插入动画完成之前,马上删除该 item:

img

我们想要看到的是 b 从 c 移除时的位置开始向上平移。 但是在那个时候,b 是一个隐藏的 view! 如果我们忽略了它(“隐藏”的 b),那会导致在现有 b 下面创建一个新的 b。更糟糕的是,这两个 view 会重叠,因为 新的 b 会往上,旧的 b 会往下。 为了避免这种错误,在搜索 ViewHolder 的较早步骤之一中,RecyclerView 会询问 ChildHelper 是否具有合适的 hidden view。 所谓「合适」,表示这个 view 跟我们需要的位置相关联,并具有正确的 view type,并且这个 view 的被隐藏的原因不是为了移除掉它(我们不应该让被移除的 view 复活)

如果有这样的 view ,RecyclerView 会将其返回到 LayoutManager 并将其添加到 preLayout 中以标记应从其进行动画处理的位置(详见 recordAnimationInfoIfBouncedHiddenView 方法)。

什么?在 布局前后 添加内容不应该是 LayoutManager 的职责吗?怎么现在 RecyclerView 也在往 preLayout 中添加view? 是的,这种机制看起来有点职责部分,但这是也说明我们有必要了解它。

Stable Id 的作用是什么?

理解 stable Id 特性的最重要的一个点是,它只会在调用 notifyDataSetChanged 方法之后,影响 RecyclerView 的行为。

如果调用 notifyDataSetChanged 的时候,Adapter 并没有设置 hasStableId,RecyclerView 不知道 发生了什么,哪一些东西变化了,所以,它假设所有的东西都变了,每一个 ViewHolder 都是无效的,因此应该把它们放到 RecyclerViewPool 而不是 scrap 中。

img

如果有 Stable Id,那那将会是像下面这样:

img

ViewHolder 会进入 scrap 而不是 pool 中。然后会通过特定的 Id(Adapter 中的 getItemId 获取到的 id)而不是 postion 到 scrap 中查找 ViewHolder。

好处是什么?

  1. 不会导致 RecyclerViewPool 溢出,因此非必须情况下,不需要创建新的 ViewHolder。之前的 ViewHolder 会重新绑定,因为 Id 没有变化不代表内容没有变化
  2. 最大好处的好处是 支持动画。上面移动 item4 到 item6 的位置。正常情况下,我们需要调用 notifyItemMoved(4,6) 才能得到一个移动动画。但是通过 stable id,调用 notifyDataSetChanged 也能支持这一点。因为 RecyclerView 可以看到特定 id 的 view 在新旧布局的上的位置,
    • 要注意的是,这里的动画只支持简单的动画,预测动画无法支持。 如果我们在新布局中看到一些 ID,而在旧布局中没有,那么我们如何知道它是新插入的 item 还是从某处移入的 item,在后一种情况下它究竟是从哪里来的呢? 通常,这些问题的答案会在预布局中找到,根据适配器的更改,该布局已超出 RecyclerView 的范围,但现在这种情况下, 我们不知道这些更改具体是什么

总体而言,stable id 的使用场景似乎比较有限。 不过,还是有这样一个使用场景:如果是从 ListView 迁移到 RecyclerView,将所有 notifyDataSetChanged 调用,都转换为特定更改的通知可能会很痛苦。 在这种情况下,stable id 可以提供给你提供简单的 RecyclerView 动画。

缓存优化实践

  • 尽量使用 notifyItemXxx 方法进行细粒度的通知更新,而不是 notifyDatasetChanged

    • 如果变更前后是两个数据集,无法确定具体哪一些数据项变化了,可以考虑使用 DiffUtil
    • 如果数据集较大,建议结合使用 AsyncListDiffer 在子线程做 diff 运算。
  • 如果特定 viewType 的 item 只有一个,可以通过 RecyclerView#getRecycledViewPool()#setMaxRecycledViews(viewType,1); 来调整缓存区的大小,减少内存占用

  • 如果特定 viewType 的 item 特别多,但是不得不通过 notifyDataSetChange 方法更新数据,可以通过下面这种方式,在变更前调大缓存,变更完成后,调小缓存。这样布局变化也可以最大程度地复用已有的 ViewHolder。

    mRecyclerView.getRecycledViewPool().setMaxRecycledViews(0, 屏幕显示的item总数+7 );
    mAdapter.notifyDataSetChanged();
    new Handler().post(new Runnable() {
        @Override
        public void run() {
            mRecyclerView.getRecycledViewPool()
                    .setMaxRecycledViews(0, 5);
        }
    });
    
  • 如果 RecyclerView 中的每个 item 都是一个 RecyclerView, 并且子 RecyclerView 的 item type 相同可以通过 RecyclerView#setRecycledViewPool(); 方法,实现缓存池的复用。

参考资料与学习资源推荐

由于本人水平有限,可能出于误解或者笔误难免出错,如果发现有问题或者对文中内容存在疑问请在下面评论区告诉我,谢谢!