【进阶】RecyclerView源码解析(三)——深度解析缓存机制

本系列博客基于com.android.support:recyclerview-v7:26.1.0
1.【进阶】RecyclerView源码解析(一)——绘制流程
2.【进阶】RecyclerView源码解析(二)——缓存机制
3.【进阶】RecyclerView源码解析(三)——深度解析缓存机制
4.【进阶】RecyclerView源码解析(四)——RecyclerView进阶优化使用
5.【框架】基于AOP的RecyclerView复杂楼层样式的开发框架,楼层打通,支持组件化,支持MVP(不用每次再写Adapter了~)

上一篇博客从源码角度分析了RecyclerView读取缓存的步骤,让我们对于RecyclerView的缓存有了一个初步的理解,但对于RecyclerView的缓存的原理还是不能理解。本篇博客将从实际项目角度来理解RecyclerView的缓存原理。

项目的截图如下:
Demo

其中可以看到,这里是一个我们经常使用RecycleView实现列表。右侧输出面板展示了ScrapView的最大数量,CacheView的数量和内容,Pool中存在的内容。左侧面板展示了onBindViewHolder和onCreateViewHolder的过程。(Demo是基于一篇博客的Demo的拓展:手摸手第二弹,可视化 RecyclerView 缓存机制)
Demo地址:RecyclerViewStudy感兴趣的可以顺手点个star~

1.ScrapViews

起初,我对于这个缓存的概念一直很模糊,我尝试过很多方法想要将这个缓存中的View读取出来看看里面的内容,但是发现这个缓存的大小总是为0,这个就让我很疑惑一个大
小总是为0的缓存还有什么作用?
无意中读到了一篇博客,这篇博客对于RecyclerView提出了Detach和Remove的概念的区别,对于RecycleView的ScrapView进行了讲解。

1.1 Detach和Remove

所以我们需要区分两个概念,DetachRemove

detach: 在ViewGroup中的实现很简单,只是将ChildView从ParentView的ChildView数组中移除,ChildView的mParent设置为null, 可以理解为轻量级的临时remove, 因
为View此时和View树还是藕断丝连, 这个函数被经常用来改变ChildView在ChildView数组中的次序。View被detach一般是临时的,在后面会被重新attach。
remove: 真正的移除,不光被从ChildView数组中除名,其他和View树各项联系也会被彻底斩断(不考虑Animation/LayoutTransition这种特殊情况), 比如焦点被清除,从TouchTarget中被移除等。

1.2 缓存作用

首先我们要了解,任何一个ViewGroup都会经历两次onLayout的过程,对应的childView就会经历detach和attach的过程,而在这个过程中,ScrapViews就起了缓存的作用,这样就不需要重复创建childView和bind。
所以ScrapView主要用于对于屏幕内的ChildView的缓存,缓存中的ViewHolder不需要重新Bind,缓存时机是在onLayout的过程中,并且用完即清空

1.3 Demo验证

我们可以看一下demo验证一下我们的想法。
首先我们重写了RecylclerView的onLayout方法。

@Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        onLayoutListener.beforeLayout();
        super.onLayout(changed, l, t, r, b);
        onLayoutListener.afterLayout();
    }

在beforLayout时设置通过反射将RecyclerView内部的mAttachedScrap替换成我们自己重写的数据结构。

public void setAllCache() {
        try {
            Field mRecycler =
                    Class.forName("android.support.v7.widget.RecyclerView").getDeclaredField("mRecycler");
            mRecycler.setAccessible(true);
            RecyclerView.Recycler recyclerInstance =
                    (RecyclerView.Recycler) mRecycler.get(this);

            Class<?> recyclerClass = Class.forName(mRecycler.getType().getName());
            Field mAttachedScrap = recyclerClass.getDeclaredField("mAttachedScrap");
            mAttachedScrap.setAccessible(true);
            mAttachedScrap.set(recyclerInstance, mAttachedRecord);
            Field mCacheViews = recyclerClass.getDeclaredField("mCachedViews");
            mCacheViews.setAccessible(true);
            mCacheViews.set(recyclerInstance, mCachedRecord);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

为什么要这样做哪?这里利用了Hook的思想。这样的话,RecyclerView内部在对mAttachedScrap进行操作的时候,比如RecyclerView内部对于mAttachedScrap的添加是使用add(T t)这个方法,这样我们设置的子类只要重写这个add(T t)的方法,在添加的时候就会调用我们子类重写的add方法。

    @Override
    public boolean add(T t) {
        RecyclerView.ViewHolder vh = (RecyclerView.ViewHolder) t;
        RcyLog.log(key + "添加---【position=" + vh.getAdapterPosition() + "】");
        if (canReset) {
            if (size() + 1 > lastSize) {
                maxSize = size() + 1;
            }
        }
        return super.add(t);
    }

    @Override
    public T remove(int index) {
        RecyclerView.ViewHolder vh = (RecyclerView.ViewHolder) get(index);
        RcyLog.log(key + "移除---【position=" + vh.getAdapterPosition() + "】");
        return super.remove(index);
    }

可以看到这里,当RecyclerView内部对mAttachedScrap进行add和remove的时候,我们都会进行打印log。并且记录一下maxSize。按照我们的猜想,RecyclerView会在onLayout的过程中对mAttachedScrap进行添加和移除操作,执行完后,mAttachedScrap的大小为0。

第一次进入应用

Log截图

可以看到我们打开应用Demo的这个操作,没有做其他任何操作,仅仅是打开,mAttachedScrap经历了添加屏幕内9个ChildView的过程,并将9个ChildView移除的过程。而mAttachedScrap的大小刚好为屏幕内可以显示的Item的数量。
为什么说不需要重写Bind哪?通过上篇博客,我们从源码角度对RecyclerView的缓存有了一个初步的了解:

//先从scrap中寻找
        for (int i = 0; i < scrapCount; i++) {
            final ViewHolder holder = mAttachedScrap.get(i);
            if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
                    && !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
                holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                return holder;
            }
        }
        
        
         boolean bound = false;
        if (mState.isPreLayout() && holder.isBound()) {
            // do not update unless we absolutely have to.
            holder.mPreLayoutPosition = position;
        } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
            //如果FLAG是ViewHolder.FLAG_UPDATE | ViewHolder.FLAG_INVALID,则需要调bind
            if (DEBUG && holder.isRemoved()) {
                throw new IllegalStateException("Removed holder should be bound and it should"
                        + " come here only in pre-layout. Holder: " + holder
                        + exceptionLabel());
            }
            final int offsetPosition = mAdapterHelper.findPositionOffset(position);
            bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
        }

可以看到,我们在Scrap中寻找的时候,是有一个判断!holder.isInvalid(),而对于需要bind的时候判断是否需要bind有一个判断holder.isInvalid()。所以两个条件是互斥的。

2.CacheViews

CacheViews其实就是和我们平常使用过程中息息相关的一个缓存。CacheViews缓存的特点是CacheViews内的缓存在复用的时候不需要调用bind,也就是在滑动的过程中,免去了bind的过程,提高滑动的效率。

2.1 缓存源码

首先来看一下对于CacheViews内缓存的获取的源码:

/ /Search in our first-level recycled view cache.
           final int cacheSize = mCachedViews.size();
           for (int i = 0; i < cacheSize; i++) {
               final ViewHolder holder = mCachedViews.get(i);
               // invalid view holders may be in cache if adapter has stable ids as they can be
               // retrieved via getScrapOrCachedViewForId
               if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
                   if (!dryRun) {
                       mCachedViews.remove(i);
                   }
                   if (DEBUG) {
                       Log.d(TAG, "getScrapOrHiddenOrCachedHolderForPosition(" + position
                               + ") found match in cache: " + holder);
                   }
                   return holder;
               }
           }

首先我们通过源码可以知道CacheViews是一个ArrayList,可以看到获取的时候是遍历CacheViews,当缓存的ViewHolder和所需要的position相同的并且有效才可以复用。
和上面分析的一样,可以知道这个缓存的ViewHolder是有效的才可以复用,所以在判断是否需要bind的时候,就不需要重新bind了。
接着来看一下缓存的源码:
既然是缓存,那肯定是滑动过程中的比较直观:

@Override
   public boolean onTouchEvent(MotionEvent e) {
           case MotionEvent.ACTION_MOVE: {
       .........
                   if (scrollByInternal(
                           canScrollHorizontally ? dx : 0,
                           canScrollVertically ? dy : 0,
                           vtev)) {
                       getParent().requestDisallowInterceptTouchEvent(true);
                   }
              ........
       return true;
   }
   
   
   boolean scrollByInternal(int x, int y, MotionEvent ev) {
       ......
           if (x != 0) {
               consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
               unconsumedX = x - consumedX;
           }
           if (y != 0) {
               consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
               unconsumedY = y - consumedY;
           }
          .......
       return consumedX != 0 || consumedY != 0;
   }

可以看到这里省略了部分代码,在onTouchEvent的ACTION_MOVE事件中,可以看到,这里对canScrollVertically方法进行了判断,并最终将偏移量传给了scrollByInternal方法,而在scrollByInternal方法中,调用了LayoutManager的scrollVerticallyBy方法。而scrollVerticallyBy最后调用了scrollBy方法。

int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
       ......
       //调用了fill方法
       final int consumed = mLayoutState.mScrollingOffset
               + fill(recycler, mLayoutState, state, false);
       ......
       return scrolled;
   }

可以看到fill方法又调回了前一篇博客分析的fill()方法,这样就很明显了。而缓存的源码其实上面博客上面提到过一个方法onLayoutChild()方法里面有个detachAndScrapAttachedViews方法。

public void detachAndScrapAttachedViews(Recycler recycler) {
        final int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            final View v = getChildAt(i);
            scrapOrRecycleView(recycler, i, v);
        }
    }
    
    /**
     * 1.Recycle操作对应的是removeView, View被remove后调用Recycler的recycleViewHolderInternal回收其ViewHolder
     2.Scrap操作对应的是detachView,View被detach后调用Reccyler的scrapView暂存其ViewHolder
     * @param recycler
     * @param index
     * @param view
     */
    private void scrapOrRecycleView(Recycler recycler, int index, View view) {
        final ViewHolder viewHolder = getChildViewHolderInt(view);
        if (viewHolder.shouldIgnore()) {
            if (DEBUG) {
                Log.d(TAG, "ignoring view " + viewHolder);
            }
            return;
        }
        if (viewHolder.isInvalid() && !viewHolder.isRemoved()
                && !mRecyclerView.mAdapter.hasStableIds()) {
            //注意这里是remove
            removeViewAt(index);
            //往cacheview和pool中
            recycler.recycleViewHolderInternal(viewHolder);
        } else {
            //注意这里是detach
            detachViewAt(index);
            //存到scrap中
            recycler.scrapView(view);
            mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
        }
    }

这里就可以看到前面所说的Remove和Detach的区别,如果是remove,会执行recycleViewHolderInternal(viewHolder);方法,而这个方法最终会将ViewHolder加入CacheView和Pool中,而当是Detach,会将View加入到ScrapViews中,注意View和ViewHolder的区别,前面提到过,ScrapViews是对View的复用,而CacheView和Pool是对ViewHolder的复用。
既然是看CacheViews,那么就看一下recycleViewHolderInternal方法。

void recycleViewHolderInternal(ViewHolder holder) {
        ......
        if (forceRecycle || holder.isRecyclable()) {
            if (mViewCacheMax > 0
                    && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
                    | ViewHolder.FLAG_REMOVED
                    | ViewHolder.FLAG_UPDATE
                    | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {
                // Retire oldest cached view
                int cachedViewSize = mCachedViews.size();
                //如果超过默认大小,则删除第一个
                if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
                //从CacheViews中删除第一个,并加入到Pool中
                    recycleCachedViewAt(0);
                    cachedViewSize--;
                }
        ......
                //加入缓存
                mCachedViews.add(targetCacheIndex, holder);
                cached = true;
            }
            if (!cached) {
                //不然直接加入Pool中
                addViewHolderToRecycledViewPool(holder, true);
                recycled = true;
            }
        .......
    }

可以看到几个关键逻辑:

1.如果超过默认大小,则会移除CacheViews中的第一个,并加入到Pool中,然后在将需要加入缓存的ViweHolder加入到CacheView中。
2.如果不能加入到CacheViews中,则加入到Pool中。

2.2 Demo验证

(1)进入应用
我们首先进入应用会发现当前CacheViews的大小是0,也就是说进入应用时没有滑动,是没有任何ViewHolder回收的,这不需要解释吧。。。,而且Bind也只走了页面渲染的0-8。

进入应用

(2)向下滑动一个,第一个移除
这时我们向下滑动,加载出第9个
滑动一个

可以看到这时候除了加载了页面的position=9,还提前加载出了position=10,执行了onBind,而这时,由于第一个移出界面,所以position=0也就被加入到了CacheViews中。
(3)向上滑动,再显示第一个
回到顶部

这时候我们会发现几个特别的点:

1.onBind的面板没有新的Log,说明新出来的position=0没有走onBind方法。
2.CacheViews中由刚才保存的position=0position=10,变成了position=10position=9
由此可见:
CacheViews中缓存的ViewHolder当被复用的时候是不会走Bind流程的

3.RecycledViewPool

其实根据前一节的讲解,我们已经对RecycleView的缓存有了一个很具体的了解了,RecyclerPool其实是RecyclerView区分ListView的一个亮点。利用这级缓存我们可以实现多个RecyclerView之间的ViewHolder的复用。(关于这一点的利用我准备在下一篇博客对RecycleView使用的技巧进行举例讲解)

3.1 缓存源码

首先我们看一下ReyclerPool的结构。

public static class RecycledViewPool {
    private static final int DEFAULT_MAX_SCRAP = 5;
    static class ScrapData {
        ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
        int mMaxScrap = DEFAULT_MAX_SCRAP;
        long mCreateRunningAverageNs = 0;
        long mBindRunningAverageNs = 0;
    }
    SparseArray<ScrapData> mScrap = new SparseArray<>();
    }

可以看到RecyclerPool内部其实是一个SparseArray,可想而知,key就是我们的ViewType,而Value是ArrayList<ViewHolder>。
我们来看一下RecyclerPool的put方法。

public void putRecycledView(ViewHolder scrap) {
        final int viewType = scrap.getItemViewType();
        final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
        if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
            return;
        }
        if (DEBUG && scrapHeap.contains(scrap)) {
            throw new IllegalArgumentException("this scrap item already exists");
        }
        //重置ViewHolder
        scrap.resetInternal();
        scrapHeap.add(scrap);
    }

其中resetInternal方法值得我们注意。

void resetInternal() {
        mFlags = 0;
        mPosition = NO_POSITION;
        mOldPosition = NO_POSITION;
        mItemId = NO_ID;
        mPreLayoutPosition = NO_POSITION;
        mIsRecyclableCount = 0;
        mShadowedHolder = null;
        mShadowingHolder = null;
        clearPayload();
        mWasImportantForAccessibilityBeforeHidden = ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO;
        mPendingAccessibilityState = PENDING_ACCESSIBILITY_STATE_NOT_SET;
        clearNestedRecyclerViewIfNotNested(this);
    }

可以看到所有被put进入RecyclerPool中的ViewHolder都会被重置,这也就意味着RecyclerPool中的ViewHolder再被复用的时候是需要重新Bind的。这一点就可以区分和CacheViews中缓存的区别。

总结

还是那篇Bugly博客中的图片吧(都怪我太懒了。。。)

缓存总结

看过上面的分析,这张图片就很好理解了。

最后

给大家分享几篇我认为不错的RecyclerView源码分析的博客吧,我的分析其中有些地方就是从这些博客中学习来的。

1.Bugly分析ListView和RecyclerView的区别的,建议深入了解后再看
2.CSDN的一个大神的分析,分了有6篇博客,值得一读
3.一篇很好的RecyclerView的源码分析博客,适合深入阅读
4.可视化RecyclerView缓存机制,也就是本篇博客Demo的参考
5.一篇将RecyclerView的缓存讲的通俗易懂的博客,源码不是比较深入,但是很好理解
。。。还有一些就不上了,以上5篇是我认为很值得反复阅读学习的。

下篇博客可能是RecyclerView分析系列的结尾篇了,可能从实际使用角度分析一些我所了解的RecyclerView的一些进阶知识

相关

基于AOP的RecyclerView复杂楼层样式的开发框架,楼层打通,支持组件化,支持MVP(不用每次再写Adapter了~)-EMvp
Star👏支持一下~
欢迎提issues讨论~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342