Android-RecyclerView的缓存机制

使用ScrollView时,它的所有子View会一次性加载出来。RecyclerView可以做到按需加载、按需绑定,并实现复用。这里主要分析RecyclerView缓存复用的原理。

从缓存获取ViewHolder流程概览

说明:

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

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

问题

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

理解“预布局”就需要理解“预测动画”。例如:

用户有A、B、C三个item,A、B正好显示在屏幕中,这时,用户把B删除了,最终C会显示在原先B的位置。

如果C从底部平滑地滑动到B的位置会更加合适,但是要想实现,并不简单。因为知道C的最终位置,但是不知道C的起始位置,就无法确定C应该从哪里滑动过来。有可能是底部,也可能是侧边。

根据原状态和最终状态之间的差异,是无法得出应该执行怎样的动画的。

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

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

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

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

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

下面两个动图展示了普通动画和预测动画的区别。

普通动画:

预测动画:

关于预测动画,可以阅读文章

关于Scrap

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

mChangedScrapmAttchedScrap只在布局阶段使用。其他时候它们为空。布局完成之后,这两个缓存中的ViewHolder,会移到mCacheViewRecyclerViewPool中。

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

以上图为例,删除B,调用notifyItemRemove()方法,触发重新布局,这时,A、B、C都会被dump到scrap中,然后LayoutManager会从scrap中取回A和C。

此时,B去哪里了?RecyclerView看到B没有出现在最终布局中,会unscrap它,让他它执行一个消失的动画,然后隐藏。动画执行结束后,B会放到RecyclerViewPool中。

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

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

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

Attached VS Changed scrap

Recycler中,可以看到两个单独的scrap容器:mAttachedScrapmChangedScrap。为什么需要两个呢?

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

canReuseUpdatedViewHolder返回false表示要执行用一个view替换另一个view的动画,true表示动画在内部发生。

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

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

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

  • 调用setSupportsChangeAnimation(false)
  • 调用notifyDataSetChanged()而不是notifyItemChanged()notifyItemRangeChanged()
  • 提供这样的更改playload: adapter.notifyItemChanged(index, anyObject)

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

Hidden View 是什么?

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

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

注意,正在搜索要提供给LayoutManager的视图,但是LayoutManager不应了解隐藏View

举一个实际的例子:这种“从隐藏的view弹跳”(bounching from hidden views)机制对于处理下面这种情况很有必要。现在要插入一个item,然后在插入动画完成之前,马上删除该item:

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

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

在布局前后添加内容的不应该是LayoutManager的职责吗?怎么现在RecyclerView也往preLayout中添加view?这有必要了解。

Stable Id的作用?

Stable Id只会在调用notifyDataSetChanged()方法之后,影响RecyclerView的行为。

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

如果有Stable Id,就会如下:

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

好处:

  • 1、不会导致RecyclerViewPool溢出,因此非必须情况下,不需要创建新的ViewHoler。之前的ViewHolder会重新绑定,因为Id没有变化不代表内容没有变化
  • 2、最大的好处是支持动画。上面移动item4到item6的位置。通常,需要调用notifyItemMoved(4, 6)才能得到一个移动动画。但是通过stable id,调用notifyDataSetChanged()也可以实现。因为RecyclerView可以看到特定id的view在新旧布局的位置。
    • 注意,这里的动画只支持简单的动画,预测动画无法支持。如果在新布局中看到一些id,而旧布局中没有,那么如何知道它是新插入的item还是从某处移入的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特别多,但是不得不通过notifyDataSetChanged()方法更新数据,可以通过下面这种方式,在变更前调大缓存,变更完成后,调小缓存。这样布局变化也可以最大程度地复用已有的ViewHolder

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

参考资料

RecyclerView caching mechanism ( multiplexing?)

RecyclerView缓存原理,有图有真相

RecyclerView缓存机制(咋复用?)

  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信