专栏名称: 鸿洋
你好,欢迎关注鸿洋的公众号,每天为您推送高质量文章,让你每天都能涨知识。点击历史消息,查看所有已推送的文章,喜欢可以置顶本公众号。此外,本公众号支持投稿,如果你有原创的文章,希望通过本公众号发布,欢迎投稿。
目录
相关文章推荐
郭霖  ·  一文了解 Gradle 的文件api ·  4 天前  
郭霖  ·  音视频基础能力之 Android ... ·  3 天前  
鸿洋  ·  掌握这17张图,掌握RecyclerView ... ·  3 天前  
51好读  ›  专栏  ›  鸿洋

画了10张图,带你搞定RecyclerView的缓存复用机制

鸿洋  · 公众号  · android  · 2024-12-12 08:35

正文

ViewPager2是在RecyclerView的基础上构建而成的,意味着其可以复用RecyclerView对象的绝大部分特性,比如缓存复用机制等。
作为ViewPager2系列的第一篇,本篇的主要目的是快速普及必要的前置知识,而内容的核心,正是前面所提到的RecyclerView的缓存复用机制。
RecyclerView,顾名思义,它会回收其列表项视图以供重用
具体而言,当一个列表项被移出屏幕后,RecyclerView并不会销毁其视图,而是会缓存起来,以提供给新进入屏幕的列表项重用,这种重用可以:
  • 避免重复创建不必要的视图。
  • 避免重复执行昂贵的findViewById

从而达到的改善性能、提升应用响应能力、降低功耗的效果。而要了解其中的工作原理,我们还得回到RecyclerView是如何构建动态列表的这一步。


1
RecyclerView是如何构建动态列表的?
与RecyclerView构建动态列表相关联的几个重要类中,Adapter与ViewHolder负责配合使用,共同定义RecyclerView列表项数据的展示方式,其中:
  • ViewHolder是一个「包含列表项视图(itemView)的封装容器」,同时也是「RecyclerView缓存复用的主要对象」。
  • Adapter则提供了「数据<->视图」 的“绑定”关系,其包含以下几个关键方法:
    • onCreateViewHolder:负责创建并初始化ViewHolder及其关联的视图,但不会填充视图内容。
    • onBindViewHolder:负责提取适当的数据,填充ViewHolder的视图内容。
然而,这2个方法并非每一个进入屏幕的列表项都会回调,相反,由于视图创建及findViewById执行等动作都主要集中在这2个方法,每次都要回调的话反而效率不佳。因此,我们应该通过对ViewHolder对象积极地缓存复用,来尽量减少对这2个方法的回调频次。
  1. 最优情况是——取得的缓存对象正好是原先的ViewHolder对象,这种情况下既不需要重新创建该对象,也不需要重新绑定数据,即拿即用。
  2. 次优情况是——取得的缓存对象虽然不是原先的ViewHolder对象,但由于二者的列表项类型(itemType)相同,其关联的视图可以复用,因此只需要重新绑定数据即可。
  3. 最后实在没办法了,才需要执行这2个方法的回调,即创建新的ViewHolder对象并绑定数据。

实际上,这也是RecyclerView从缓存中查找最佳匹配ViewHolder对象时所遵循的优先级顺序。而真正负责执行这项查找工作的,则是RecyclerView类中一个被称为「回收者」的内部类——Recycler。


2
Recycler是如何查找ViewHolder对象的?

/**
 * ...
 * When {@link Recycler#getViewForPosition(int)} is called, Recycler checks attached scrap and
 * first level cache to find a matching View. If it cannot find a suitable View, Recycler will
 * call the {@link #getViewForPositionAndType(Recycler, int, int)} before checking
 * {@link RecycledViewPool}.
 * 
 * 当调用getViewForPosition(int)方法时,Recycler会检查attached scrap和一级缓存(指的是mCachedViews)以找到匹配的View。 
 * 如果找不到合适的View,Recycler会先调用ViewCacheExtension的getViewForPositionAndType(RecyclerView.Recycler, int, int)方法,再检查RecycledViewPool对象。
 * ...
 */

public abstract static class ViewCacheExtension {
    ...
}


public final class Recycler {
    ...
    /**
     * Attempts to get the ViewHolder for the given position, either from the Recycler scrap,
     * cache, the RecycledViewPool, or creating it directly.
     * 
     * 尝试通过从Recycler scrap缓存、RecycledViewPool查找或直接创建的形式来获取指定位置的ViewHolder。
     * ...
     */

    @Nullable
    ViewHolder tryGetViewHolderForPositionByDeadline(int position,
            boolean dryRun, long deadlineNs)
 
{
        if (mState.isPreLayout()) {
            // 0 尝试从mChangedScrap中获取ViewHolder对象
            holder = getChangedScrapViewForPosition(position);
            ...
        }
        if (holder == null) {
            // 1.1 尝试根据position从mAttachedScrap或mCachedViews中获取ViewHolder对象
            holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
            ...
        }
        if (holder == null) {
            ...
            final int type = mAdapter.getItemViewType(offsetPosition);
            if (mAdapter.hasStableIds()) {
                // 1.2 尝试根据id从mAttachedScrap或mCachedViews中获取ViewHolder对象
                holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
                        type, dryRun);
                ...
            }
            if (holder == null && mViewCacheExtension != null) {
                // 2 尝试从mViewCacheExtension中获取ViewHolder对象
                final View view = mViewCacheExtension
                        .getViewForPositionAndType(this, position, type);
                if (view != null) {
                    holder = getChildViewHolder(view);
                    ...
                }
            }
            if (holder == null) { // fallback to pool
                // 3 尝试从mRecycledViewPool中获取ViewHolder对象
                holder = getRecycledViewPool().getRecycledView(type);
                ...
            }
            if (holder == null) {
                // 4.1 回调createViewHolder方法创建ViewHolder对象及其关联的视图
                holder = mAdapter.createViewHolder(RecyclerView.this, type);
                ...
            }
        }

        if (mState.isPreLayout() && holder.isBound()) {
            ...
        } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
            ...
            // 4.1 回调bindViewHolder方法提取数据填充ViewHolder的视图内容
            bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
        }

        ...

        return holder;
    }
    ...
}    


结合RecyclerView类中的源码及注释可知,Recycler会依次从mChangedScrap/mAttachedScrapmCachedViewsmViewCacheExtensionmRecyclerPool中尝试获取指定位置或ID的ViewHolder对象以供重用,如果全都获取不到则直接重新创建。这其中涉及的几层缓存结构分别是:

mChangedScrap/mAttachedScrap

mChangedScrap/mAttachedScrap主要用于「临时存放仍在当前屏幕可见、但被标记为「移除」或「重用」的列表项」,其均以ArrayList的形式持有着每个列表项的ViewHolder对象,大小无明确限制,但一般来讲,其最大数就是屏幕内总的可见列表项数。
final ArrayList mAttachedScrap = new ArrayList<>();
ArrayList mChangedScrap = null;


但问题来了,既然是当前屏幕可见的列表项,为什么还需要缓存呢?又是什么时候列表项会被标记为「移除」或「重用」的呢?
这2个缓存结构实际上更多是为了避免出现像「局部刷新」这一类的操作,导致所有的列表项都需要重绘的情形。
区别在于,mChangedScrap主要的使用场景是:
  1. 开启了列表项动画(itemAnimator),并且列表项动画的canReuseUpdatedViewHolder(ViewHolder viewHolder)方法返回false的前提下;
  2. 调用了notifyItemChangednotifyItemRangeChanged这一类方法,通知列表项数据发生变化;
boolean canReuseUpdatedViewHolder(ViewHolder viewHolder) {
    return mItemAnimator == null || mItemAnimator.canReuseUpdatedViewHolder(viewHolder,
            viewHolder.getUnmodifiedPayloads());
}

public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder,
        @NonNull List payloads)
 
{
    return canReuseUpdatedViewHolder(viewHolder);
}

public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder) {
    return true;
}


canReuseUpdatedViewHolder方法的返回值表示的不同含义如下:
  • true,表示可以重用原先的ViewHolder对象。
  • false,表示应该创建该ViewHolder的副本,以便itemAnimator利用两者来实现动画效果(例如交叉淡入淡出效果)。
简单讲就是,mChangedScrap主要是为列表项数据发生变化时的动画效果服务的
mAttachedScrap应对的则是剩下的绝大部分场景,比如:
  • notifyItemMovednotifyItemRemoved这种列表项发生移动,但列表项数据本身没有发生变化的场景。
  • 关闭了列表项动画,或者列表项动画的canReuseUpdatedViewHolder方法返回true,即允许重用原先的ViewHolder对象的场景。
下面以一个简单的notifyItemRemoved(int position)操作为例来演示:
notifyItemRemoved(int position)方法用于通知观察者,先前位于position的列表项已被移除, 其往后的列表项position都将往前移动1位。
为了简化问题、方便演示,我们的范例将会居于以下限制:
  • 列表项总个数没有铺满整个屏幕——意味着不会触发mCachedViewsmRecyclerPool等结构的缓存操作。
  • 去除列表项动画——意味着调用notifyItemRemoved后RecyclerView只会重新布局子视图一次。
recyclerView.itemAnimator = null


理想情况下,调用notifyItemRemoved(int position)方法后,应只有位于position的列表项会被移除,其他的列表项,无论是位于position之前或之后,都最多只会调整position值,而不应发生视图的重新创建或数据的重新绑定,即不应该回调onCreateViewHolderonBindViewHolder这2个方法。
为此,我们就需要将当前屏幕内的可见列表项暂时从当前屏幕剥离,临时缓存到mAttachedScrap这个结构中去。


等到RecyclerView重新开始布局显示其子视图后,再遍历mAttachedScrap找到对应position的ViewHolder对象进行复用。


mCachedViews

mCachedViews主要用于「存放已被移出屏幕、但有可能很快重新进入屏幕的列表项」。其同样是以ArrayList的形式持有着每个列表项的ViewHolder对象,默认大小限制为2。
final ArrayList mCachedViews = new ArrayList();
int mViewCacheMax = DEFAULT_CACHE_SIZE;
static final int DEFAULT_CACHE_SIZE = 2;


比如像朋友圈这种按更新时间的先后顺序展示的Feed流,我们经常会在快速滑动中确定是否有自己感兴趣的内容,当意识到刚才滑走的内容可能比较有趣时,我们往往就会将上一条内容重新滑回来查看。
这种场景下我们追求的自然是上一条内容展示的实时性与完整性,而不应让用户产生“才滑走那么一会儿又要重新加载”的抱怨,也即同样不应发生视图的重新创建或数据的重新绑定。
我们用几张流程示意图来演示这种情况:
同样为了简化问题、方便描述,我们的范例将会居于以下限制:
  • 关闭预拉取——意味着之后向上滑动时,都不会再预拉取「待进入屏幕区域」的一个列表项放入mCachedView了。
recyclerView.layoutManager?.isItemPrefetchEnabled = false


  • 只存在一种类型的列表项,即所有列表项的itemType相同,默认都为0。
我们将图中的列表项分成了3块区域,分别是被滑出屏幕之外的区域、屏幕内的可见区域、随着滑动手势待进入屏幕的区域。


  1. 当position=0的列表项随着向上滑动的手势被移出屏幕后,由于mCachedViews初始容量为0,因此可直接放入;


  1. 当position=1的列表项同样被移出屏幕后,由于未达到mCachedViews的默认容量大小限制,因此也可继续放入;
  1. 此时改为向下滑动,position=1的列表项重新进入屏幕,Recycler就会依次从mAttachedScrapmCachedViews查找可重用于此位置的ViewHolder对象;
  2. mAttachedScrap不是应对这种情况的,自然找不到。而mCachedViews会遍历自身持有的ViewHolder对象,对比ViewHolder对象的position值与待复用位置的position值是否一致,是的话就会将ViewHolder对象从mCachedViews中移除并返回;
  3. 此处拿到的ViewHolder对象即可直接复用,即符合前面所述的「最优情况」。


  1. 另外,随着position=1的列表项重新进入屏幕,position=7的列表项也会被移出屏幕,该位置的列表项同样会进入mCachedViews,即RecyclerView是双向缓存的。


mViewCacheExtension

mViewCacheExtension主要用于提供额外的、可由开发人员自由控制的缓存层级,属于非常规使用的情况,因此这里暂不展开讲。

mRecyclerPool

mRecyclerPool主要用于「按不同的itemType分别存放超出mCachedViews限制的、被移出屏幕的列表项」,其会先以SparseArray区分不同的itemType,然后每种itemType对应的值又以ArrayList的形式持有着每个列表项的ViewHolder对象,每种itemType的ArrayList大小限制默认为5。
public static class RecycledViewPool {
    private static final int DEFAULT_MAX_SCRAP = 5;
    static class ScrapData {
        final ArrayList mScrapHeap = new ArrayList<>();
        int mMaxScrap = DEFAULT_MAX_SCRAP;
        long mCreateRunningAverageNs = 0;
        long mBindRunningAverageNs = 0;
    }
    SparseArray mScrap = new SparseArray<>();
    ...
}


由于mCachedViews默认的大小限制仅为2,因此,当滑出屏幕的列表项超过2个后,就会按照先进先出的顺序,依次将ViewHolder对象从mCachedViews移出,并按itemType放入RecycledViewPool中的不同ArrayList。
这种缓存结构主要考虑的是随着被滑出屏幕列表项的增多,以及被滑出距离的越来越远,重新进入屏幕内的可能性也随之降低。于是Recycler就在时间与空间上做了一个权衡,允许相同itemType的ViewHolder被提取复用,只需要重新绑定数据即可。
这样一来,既可以避免无限增长的ViewHolder对象缓存挤占了原本就紧张的内存空间,又可以减少回调相比较之下执行代价更加昂贵的onCreateViewHolder方法。
同样我们用几张流程示意图来演示这种情况,这些示意图将在前面的mCachedViews示意图基础上继续操作:
  1. 假设目前存在于mCachedViews中的仍是position=0及position=1这两个列表项。
  2. 当我们继续向上滑动时,position=2的列表项会尝试进入mCachedViews,由于超出了mCachedViews的容量限制,position=0的列表项会从mCachedViews中被移出,并放入RecycledViewPool中itemType为0的ArrayList,即图中的情况①。
3.同时,底部的一个新的列表项也将随着滑动手势进入到屏幕内,但由于此时mAttachedScrapmCachedViewsmRecyclerPool均没有合适的ViewHolder对象可以提供给其复用,因此该列表项只能执行onCreateViewHolderonBindViewHolder这2个方法的回调,即图中的情况②。
  1. 等到position=2的列表项被完全移出了屏幕后,也就顺利进入了mCachedViews中。


  1. 我们继续保持向上滑动的手势,此时,由于下一个待进入屏幕的列表项与position=0的列表项的itemType相同,因此我们可以在走到从mRecyclerPool查找合适的ViewHolder对象这一步时,根据itemType找到对应的ArrayList,再取出其中的1个ViewHolder对象进行复用,即图中的情况①。
  2. 由于itemType类型一致,其关联的视图可以复用,因此只需要重新绑定数据即可,即符合前面所述的「次优情况」。
  1. ②③ 情况与前面的一致,此处不再赘余。
最后总结一下,



以上的就是RecyclerView缓存复用机制的核心内容了。




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!