RecyclerView 源码分析2-缓存机制图解

汪汪猿 2020/7/29 23:03:51

在使用RecyclerView时会思考它展示这么多数据是怎么做到流畅的滑动,它内部是怎么做的缓存,缓存了什么东西等问题,本文就缓存机制这一块就行探讨,将问题列个目录来说RecyclerView有几级缓存,以及每一级缓存的作用? adapter.setHasStableIds(true)作用是什么? notify…

在使用RecyclerView时会思考它展示这么多数据是怎么做到流畅的滑动,它内部是怎么做的缓存,缓存了什么东西等问题,本文就缓存机制这一块就行探讨,将问题列个目录来说

  • RecyclerView有几级缓存,以及每一级缓存的作用?
  • adapter.setHasStableIds(true)作用是什么?
  • notify系列函数是怎么进行更新数据的?

1. 概述

在之前的文章RecyclerView 源码分析1-绘制流程中已经找到了获取ItemView的入口,,简单回顾下,以LinearLayoutManager为例,调用onLayoutChildren()-> fill()->layoutChunk()->View view = layoutState.next(recycler) 其中layoutChunk()被循环调用,直到按照某个方向填满屏幕,其内部的next()就是获取ItemView的入口,不过我们的Adapter创建的是ViewHolderRecyclerView缓存的也是ViewHolderItemView只是ViewHolder的一个成员变量,跟踪next()方法其实是调用到tryGetViewHolderForPositionByDeadline()来获取一个ViewHolder,所以tryGetViewHolderForPositionByDeadline()就是使用缓存的入口,我们分析该方法

2. 四级缓存

我将RecyclerView的缓存分为四级,也有人分为三级,这个主要看个人的看法了,我们只要知道各级缓存的具体作用就行,这里先将四级缓存都列出来,并简单的描述他们的作用,如果不理解的话,下文还有更详细的描述。

RecyclerViewRecycler这个内部类负责缓存这一部分,直接来看一下它的成员变量

// 这两个表示一级缓存,布局时使用
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
ArrayList<ViewHolder> mChangedScrap = null;

//二级缓存,不需要重新绑定,主要在滑动时使用
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();

//三级缓存,抽象类 需要我们自己实现getViewForPositionAndType(),返回一个View
private ViewCacheExtension mViewCacheExtension;

//四级缓存,需要重新绑定,以上都找不到的时候使用
RecycledViewPool mRecyclerPool;
复制代码

以上提到的是否重新绑定的意思就是是否调用Adapter.onBindViewHolder()方法,前两级缓存就是用ArrayList来保存ViewHolder,第三级缓存需要我们自己实现,不经常用到,暂不讨论,第四级缓存根据itemViewType不同保存不同的ViewHolder,每种itemViewType默认保存5个ViewHolder,如图

RecycledViewPool

我们在tryGetViewHolderForPositionByDeadline()中查看这四级缓存是如何起作用的

ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
    ViewHolder holder = null;
    // 如果是预布局先从mChangedScrap中查找,分别根据position和id查找
    if (mState.isPreLayout()) {
        holder = getChangedScrapViewForPosition(position);
        fromScrapOrHiddenOrCache = holder != null;
    }
    // 根据 position获取
    if (holder == null) {
        holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
    }
    if (holder == null) {
    // 根据id获取
    if (mAdapter.hasStableIds()) {
        holder =getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),type, dryRun);
    }
    //从自定义缓存中获取
    if (holder == null && mViewCacheExtension != null) {
        final View view = mViewCacheExtension.getViewForPositionAndType(this, position, type);
    }
    //从RecycledViewPool中获取
    if (holder == null) {
        holder = getRecycledViewPool().getRecycledView(type);
    }
    //缓存里面没有,调用onCreateViewHolder创建一个新的ViewHolder
    if (holder == null) {
        holder = mAdapter.createViewHolder(RecyclerView.this, type);
    }
    //如果需要绑定的话,调用onBindViewHolder
    if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
        bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
    }
}

复制代码

以上代码不难理解,就是从各级缓存中获取,当前缓存找不到就到下一级缓存,详细的逻辑看一下源码就清楚了。其中需要注意的有

  • 参数deadlineNs,跟滑动时的预取机制有关
  • 上面还涉及到一个从mHiddenViews获取缓存,mHiddenViews只在动画期间才会有元素,当动画结束了,自然就清空了(该结论是查看其它博客得出的,所以先不讨论mHiddenViews
  • 除了根据position之外,还根据id来查找缓存的ViewHolder,这个跟adapter.setHasStableIds(true)有直接关系,下边在详细讨论

以上是获取缓存的逻辑,下面我们来查看下什么时候将ViewHolder添加到缓存中以及各级缓存的详细介绍,不过在这之前需要先对ViewHolder做一个简单的介绍

3. ViewHolder

实现RecyclerView.Adapter时,我们需要重写onCreateViewHolder()onBindViewHolder() ,而缓存也是以ViewHolder为单位的,所以需要看一下ViewHolder里面都保存了写什么信息,其中比较重要的有(只列举了一部分)

  1. itemView 我们的布局view
  2. mPositionAdapter中的位置
  3. mItemId 重写Adapter.getItemId(int position)的值,可以作为ViewHolder的唯一标识
  4. mItemViewType itemView的类型
  5. mFlags 表示ViewHolder的当前状态

mFlags有以下几个重要的标志(只列举了一部分)

状态 描述
FLAG_BOUND 已绑定,就是已经调用了onBindViewHolder
FLAG_UPDATE 有更新
FLAG_INVALID 已经无效了
FLAG_REMOVED 被删除了

4.一级缓存 mAttachedScrap/mChangedScrap

一级缓存的作用是数据发生更新请求重新布局时对屏幕上存在的元素进行缓存,上面获取缓存的流程中已经看到过这两位的身影了,现在再来看一下什么时候往这两兄弟中添加ViewHolder,全局搜索添加的ViewHolder到他两之中的只有scrapView(View view)方法

void scrapView(View view) {
    final ViewHolder holder = getChildViewHolderInt(view);
    if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
        || !holder.isUpdated()
        || canReuseUpdatedViwHolder(holder)) {
        // 标记为无效或者删除或者没有更新的ViweHolder加入到mAttachedScrap
        holder.setScrapContainer(this, false);
        mAttachedScrap.add(holder);
    } else {
        if (mChangedScrap == null) {
            mChangedScrap = new ArrayList<ViewHolder>();
        }
        //有更新的ViweHolder加入到mChangedScrap
        holder.setScrapContainer(this, true);
        mChangedScrap.add(holder);
    }
}
复制代码

canReuseUpdatedViwHolder(holder)mItemAnimator有关,默认情况相当于调用 holder.isInvalid(),详细代码可以跟进去看一下

所以有如下情况

  • FLAG_REMOVED 或者 FLAG_INVALID 或者 没有改动 -> mAttachedScrap
  • FLAG_UPDATE -> mChangedScrap

继续跟踪scrapView()被调用的地方,有两个

  • 在上边获取缓存时从mHiddenViews获取到的ViewHolder后调用scrapView(),这不是普遍情况,涉及mHiddenViews先不讨论
  • scrapOrRecycleView()

scrapOrRecycleView也是一个很重要的方法,它决定ViewHolder是进入一级缓存还是二级缓存

private void scrapOrRecycleView(Recycler recycler, int index, View view) {
            final ViewHolder viewHolder = getChildViewHolderInt(view);
    if (viewHolder.shouldIgnore()) {
        return;
    }
    if (viewHolder.isInvalid() && !viewHolder.isRemoved()
            && !mRecyclerView.mAdapter.hasStableIds()) {
        removeViewAt(index);
        //被标记为无效、没有被删除、没有id 三条件都符合进入二级缓存 mCachedViews
        recycler.recycleViewHolderInternal(viewHolder);
    } else {
        //进入一级缓存 mAttachedScrap/mChangedScrap
        detachViewAt(index);
        recycler.scrapView(view);
        mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
    }
}
复制代码

再找一下scrapOrRecycleView被谁调用,发现如果用户不手动调用相关回收方法的话(一般也不调用)最终调用源头是detachAndScrapAttachedViews(recycler),在LinearLayoutManager.onLayoutChildren()之方法中,在fill()之前调用,说明在布局之前需要把屏幕上的ViewViewHolder的形式保存到缓存中,根据以下具体情况来分析

对于缓存回收路径来说,ViewHolder不同的状态会进入不同的缓存,我们需要弄清楚这三个方法,下面的例子会多次提到:

detachAndScrapAttachedViews(recycler)->scrapOrRecycleView() -> scrapView(View view)

大概记一下三个方法功能如下:

  1. detachAndScrapAttachedViews requestLayout之后,将屏幕上的数据回收到缓存的入口
  2. scrapOrRecycleView() 回收到一级还是二级缓存
  3. scrapView(View view) 回收到一级缓存中的mAttachedScrap还是mChangedScrap

下面详细的例子中涉及到一些图,对图中的元素解释一下

  • notifyItemInserted

notifyItemInsertednotifyItemRangeChanged原理都是一样的,我们说一个就行。如图我们将"a"插入到index==1的位置,然后调用adapter.notifyItemInserted(1)。对比添加数据前后,A是一点也没变,而B、C、D只是位置发生了变化,其他的都没变,所以我们可以直接算出来他们变化后的位置,也就是将他们的ViewHolder.mPosition + 1,这些可以在源码offsetPositionRecordsForInsert找到

回收时根据我们上面提到的缓存回收路径分析,A、B、C、D它们的ViewHolder.mFlags都没有任何变化,在scrapOrRecycleView()中不满足viewHolder.isInvalid(),所以将缓存放入一级缓存中,在一级缓存判断方法scrapView(View view)中A、B、C、D都没有变化,所以放入到mAttachedScrap

缓存复用时A、B、C都可以拿出来就直接使用,不需要绑定,因为他们的内容没有发生任何变化,而"a"的话,肯定不在二级缓存mCachedViews中,因为二级缓存都是即将进去屏幕或者刚离开屏幕的缓存,所以是从Pool池中获取或者创建一个新的,都是必须重新通过onBindViewHolder来绑定

总结: notifyItemInserted将屏幕上的内容缓存到了一级缓存mAttachedScrap,并且可以直接复用,不需绑定,只有新进入屏幕的元素才需要绑定

  • notifyItemRemoved

调用adapter.notifyItemRemoved(1)将B删掉,跟上面的添加一个元素很相似,A在删除前后一点变化没有,而C、D只是位置发生了变化,其他的都没变,所以只需改变让ViewHolder.mPosition - 1即可,不同的是B被打上了FLAG_REMOVED标签

回收时根据我们上面提到的缓存回收路径分析,A、B、C、D都属于有效的,只是B打上了FLAG_REMOVED标签,所以都放入一级缓存,在一级缓存的判断中A、C、D的ViewHolder.mFlags没变,放入mAttachedScrap中,B有FLAG_REMOVED标签所以也会加入到mAttachedScrap

缓存复用时A、C、D都可以拿出来就直接使用,不需要绑定,而E可能是从二级缓存mCachedViews来的,因为它本来就是即将要进入屏幕的元素,这种情况也不需要绑定,E也有可能是从Pool池中获取或者创建一个新的,都是必须重新通过onBindViewHolder来绑定

总结: notifyItemRemoved将屏幕上的内容缓存到了一级缓存mAttachedScrap,并且可以直接复用,不需绑定,新进入屏幕的元素可能需要绑定也可能不需要

  • notifyDataSetChanged

删除第一个元素,如上图,notifyDataSetChanged调用之后会调用到markKnownViewsInvalid(),这个方法给屏幕上所有的ViewHolder添加FLAG_UPDATEFLAG_INVALID,表示所有内容都有更改并且都失效了,除此之外还会将 二级缓存mCachedViews中的数据清空并挪动到RecycledViewPool 中。

回收时根据我们上面提到的缓存回收路径分析,在scrapOrRecycleView()viewHolder.isInvalid() && !viewHolder.isRemoved() && !mRecyclerView.mAdapter.hasStableIds()这三个条件都满足,将ViewHolder放入到二级缓存中,不过二级缓存也不是什么都要的,对于无效的直接扔到RecycledViewPool之中。所以此时缓存的状态是一级、二级缓存都没数据,三级缓存我们没实现,所有的缓存都在RecycledViewPool,需要重新绑定才能显示在页面上

上图中我们删除第一个元素,不过删除后屏幕上还是四个元素,所以RecycledViewPool中肯定是够四个缓存的,所以复用时取出来重新绑定一下就行,不需要onCreateViewHolder来创建

总结: notifyDataSetChanged将屏幕上的内容标为无效,缓存到四级缓存RecycledViewPool,屏幕上的元素都需要重新绑定,顺便说一下,当多次调用setAdapterswapAdapter缓存的处理情况和notifyDataSetChanged很相似

  • notifyDataSetChanged -- setHasStableIds(true)

同样是删除第一个元素,并且调用notifyDataSetChanged,在adapter.setHasStableIds(true)并且重写了getItemId(int position)返回唯一id之后,回收时情况跟上边的不一样了,根据我们上面提到的缓存回收路径分析,在scrapOrRecycleView()

    if (viewHolder.isInvalid() && !viewHolder.isRemoved()
            && !mRecyclerView.mAdapter.hasStableIds()) {
        removeViewAt(index);
        //进入二级缓存 mCachedViews
        recycler.recycleViewHolderInternal(viewHolder);
    } else {
        //进入一级缓存 mAttachedScrap/mChangedScrap
        detachViewAt(index);
        recycler.scrapView(view);
        mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
    }

复制代码

由于第一个if不满足 !mRecyclerView.mAdapter.hasStableIds() 这个条件,所以会将屏幕上的ViewHolder缓存到一级缓存,在一级缓存的判断中又因为每个ViewHolder都是invalid状态,所以放入到mAttachedScrap

缓存复用时,B、C、D可以都通过Id的方式从缓存中取出来,只不过因为是inValid状态所以需要onBindViewHolder来绑定,对于新进入屏幕的E来说,二级缓存都没数据,只有从pool池中取或者新建一个出来,也必定需要绑定。

总结: 对比默认情况来说,setHasStableIds(true)虽然将屏幕上的内容保存到了一级缓存,但是还是需要走绑定的流程,比默认情况加载速度有提升,但是提升并不大,我认为setHasStableIds(true)更大的作用是使得 notifyDataSetChanged有动画效果 ,具体情况大家可以尝试一下,其中判断逻辑在processAdapterUpdatesAndSetAnimationFlags()之中,

 mState.mRunSimpleAnimations = mFirstLayoutComplete
                && mItemAnimator != null
                && (mDataSetHasChangedAfterLayout
                || animationTypeSupported
                || mLayout.mRequestedSimpleAnimations)
                && (!mDataSetHasChangedAfterLayout
                || mAdapter.hasStableIds());
复制代码

看到最后一个条件就是,具体效果大家尝试一下就知道了,就不再细说了

  • notifyItemChanged

我们分析了上面几种情况,发现一级缓存中的mChangedScrap根本没有用到,既然这里提到了它,那不必多说,肯定是在这种情况下使用它了,如图

如图我们将第一个元素A改为a,图中我用两种颜色区分了mAttachedScrapmChangedScrap

回收时根据我们上面提到的缓存回收路径分析,A、B、C、D都会放入一级缓存中,在一级缓存判断中对于B、C、D来说它们没有变化,放入到一级缓存mAttachedScrap中,而A被添加了FLAG_UPDATE,在scrapView(View view)中不满足!holder.isUpdated()所以会被放入到mChangedScrap

缓存复用时B、C、D都可以直接使用,A因为被修改了所以需要重新绑定一下

总结: notifyItemChanged 将屏幕上的元素保存到一级缓存中,有更改的保存到mChangedScrap中并且需要重新绑定,没有变化的保存到mAttachedScrap中。

另外还有一个注意的点是notifyItemChanged(int position, @Nullable Object payload)这个两个参数的方法可能不常用,但是如果遇到刷新闪烁的问题,就可能回用到这个方法,具体可参考这篇文章Android RecyclerView 局部刷新原理

从上面的分析来看,一级缓存的使用场景只有在数据发生了改变,需要重新布局时,对屏幕上已存在的元素进行缓存处理,而在布局的最后阶段dispatchLayoutStep3中,调用了mLayout.removeAndRecycleScrapInt(mRecycler)mAttachedScrapmChangedScrap的内容清空

二级缓存 mCachedViews

查找mCachedViews添加回收ViewHolder的地方,发现只有在recycleViewHolderInternal()中,代码如下

void recycleViewHolderInternal(ViewHolder holder) {
    ...
    if (forceRecycle || holder.isRecyclable()) {
        if (mViewCacheMax > 0
                && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
                        | ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_UPDATE)){
            int cachedViewSize = mCachedViews.size();
            if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
                //如果mCachedViews满了,将最先回收的放到Pool中
                recycleCachedViewAt(0);
                cachedViewSize--;
            }
            mCachedViews.add(targetCacheIndex, holder);
        }
    ...
}
复制代码

查找所有调用该方法的地方,发现mCachedViews回收的路径有很多,有从一级缓存positon或者type对不上放进来的,有从动画结束后放进来等等,我觉得比较重要的是滑动过程中进行回收,我们来看一下这种情况

二级缓存不好用图来表示,上图中黄色虚线框的内容表示缓存在二级缓存mCachedViews

如图,我们手指向上滑动,最左边的状态表示还没有开始滑动,中间状态表示滑动了一点点,如果LayoutManager开启预取机制(默认开启)并且获取成功(图中的E表示预取元素,通过四级缓存获取,获取不到就通过onCreateViewHolder创建),会将下一个将要显示在屏幕上的元素放进二级缓存mCachedViews中,(预取在源码中的入口为RecyclerViewonTouchEvent中的mGapWorker.postFromTraversal(this, dx, dy))。最右边的状态E进入屏幕时是不需要绑定的,因为之前已经将E放入到了mCachedViews中。此时A刚刚划出屏幕,会将A放入到mCachedViews中,源码中可在fill()中的recycleByLayoutState(recycler, layoutState)查看,这时如果再往下滑动,让A重新再显示在屏幕上,A就会从缓存mCachedViews中获取并且不用绑定

总结: mCachedViews在滑动时会缓存屏幕外的元素,达到上限时会将最先存入的元素放入到Pool池中,如果我们一直朝同一个方向滑动,CacheView其实并没有在效率上产生帮助,它只是不断地在把后面滑过的ViewHolder进行了缓存;而如果我们经常上下来回滑动,那么CacheView中的缓存就会得到很好的利用,毕竟复用CacheView中的缓存的时候,不需要经过创建和绑定的消耗。

三级缓存 ViewCacheExtension

这个需要我们自己来实现getViewForPositionAndType(@NonNull Recycler recycler, int position,int type),需要注意的是当调用该方法时,返回的是一个View,并且我们应该提前创建好View,在调用时直接返回,而不是在调用该方法时才创建View。用的不多就不讨论了。

四级缓存 RecycledViewPool

RecycledViewPool的结构前面已经介绍了,不同的ItemViewType分别缓存5个,也可以修改默认大小,前两级缓存不要的元素都缓存在这了,进入Pool池中的元素的特点就是,不能直接使用,需要调用onBindViewHolder()重新绑定,RecycledViewPool还有一个特性就是多个RecyclerView可以共享同一个Pool

总结

经过上面的分析,我们可以知道RecyclerView有四级缓存

  1. mAttachedScrapmChangedScrap只会在布局时用到, 只有在数据更新时,会对屏幕上的数据进行缓存,布局完成后就清空了,这里面的缓存并不是其他文章说的那样不需要绑定可直接使用,而是分情况的,对于adapter.setHasStableIds(true)mChangedScrap这两种特定情况来说还是需要绑定。滑动时RecyclerView内部是通过scrollTo实现的,并不需要布局,所以也不需要一级缓存。
  2. mCachedViews 主要在滑动时对屏幕外的元素进行进行回收和复用,这里的缓存的东西是可以直接使用的,不需要绑定
  3. ViewCacheExtension 自定义缓存,使用的不多
  4. RecycledViewPool 缓存的ViewHolder需要重新绑定才能使用

对于数据更新方法notify系列函数来说:

  1. notifyItemXXX() 会以最少的变动来实现需求,充分利用缓存,并且还自带动画效果,所以我们平常可以尽量使用这种方式来进行数据更新。
  2. notifyDataSetChanged() 会使屏幕上的元素都失效,每一个元素都需要重新绑定才能显示在屏幕上,是一个重量级的更新。

adapter.setHasStableIds(true)的作用

  1. 缓存中增加了一层,可以通过Id来查找ViewHolder,增加缓存的命中率,提升性能(不过根据上文notifyDataSetChanged()分析来看,提升并不明显,也许还有其他情况来使用Id来查找缓存,不过我暂时还不了解)
  2. notifyDataSetChanged()有了动画效果

小技巧:RecyclerView是可以打断点调试的,遇到不理解多打断点尝试一下

最后,由于作者水平有限,如果以上分析有出错的地方,欢迎提出,我及时进行改正,以免误导其他人

参考资料

  1. Android RecyclerView 局部刷新原理
  2. RecyclerView(一):预取机制
随时随地学软件编程-关注百度小程序和微信小程序
关于找一找教程网

本站文章仅代表作者观点,不代表本站立场,所有文章非营利性免费分享。
本站提供了软件编程、网站开发技术、服务器运维、人工智能等等IT技术文章,希望广大程序员努力学习,让我们用科技改变世界。
[RecyclerView 源码分析2-缓存机制图解]http://www.zyiz.net/tech/detail-145367.html

上一篇:Kotlin Jetpack 实战 | Kotlin 高阶函数写 HTML 是一种什么体验?

下一篇:retrofit网络请求框架的简单介绍

赞(0)

共有 条评论 网友评论

验证码: 看不清楚?
    关注微信小程序
    程序员编程王-随时随地学编程

    扫描二维码或查找【程序员编程王】

    可以随时随地学编程啦!

    技术文章导航 更多>
    扫一扫关注最新编程教程