真正帶你搞懂RecyclerView的快取機制

寒鴉飛盡發表於2019-08-27

whatsapp-interface-1660652_1280

本文已授權微信公眾號【玉剛說】獨家原創釋出

前言

RecyclerView大概是Android開發者接觸最多的一個控制元件了,官方對其做了很好的封裝抽象,使得它既靈活又好用,但是你真的瞭解它麼?在它簡單的使用方式之下著實是不簡單,首先我們看一下官方對它的介紹:

A flexible view for providing a limited window into a large data set.

很簡單,就一句話「為大量資料集提供一個有限展示視窗的靈活檢視」怎麼展示大量的資料是個技術活,這些資料伴隨著滾動逐漸展示在我們眼前,但是展示過的滾走的檢視呢?它們是否還存在?我想大家肯定知道它們是要被回收的,否者來個幾百上千條資料那還不OOM了。那麼我們今天就圍繞RecyclerView的檢視回收機制來談一談,到底RecyclerView的回收機制是怎樣的。

快取層級

我們先了解下Recycler的快取結構是怎樣的,先了解兩個專業詞彙:

  • Scrap (view):在佈局期間進入臨時分離狀態的子檢視。廢棄檢視可以重複使用,而不會與父級RecyclerView完全分離,如果不需要重新繫結,則不進行修改,如果檢視被視為髒,則由介面卡修改。(這裡的髒怎麼理解呢?就是指那些在展示之前必須重新繫結的檢視,比如一個檢視原來展示的是“張三”,之後需要展示“李四”了,那麼這個檢視就是髒檢視,需要重新繫結資料後再展示的。)
  • Recycle (view):先前用於顯示介面卡特定位置的資料的檢視可以放置在快取記憶體中以供稍後重用再次顯示相同型別的資料。這可以通過跳過初始佈局或構造來顯著提高效能。

RecyclerView的快取型別呢基本也就是上面的兩種,這時可能有同學要站出來說我不對了,胡說,RecyclerView明明有四級快取,怎麼就兩種了,騷年稍安勿躁,且聽我來慢慢分解。首先我們先看一個RV(RecyclerView在後文簡稱RV)的內部類Recycler。

    public final class Recycler {
        final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
        ArrayList<ViewHolder> mChangedScrap = null;

        final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
        RecycledViewPool mRecyclerPool;
        private ViewCacheExtension mViewCacheExtension;
        …… 省略 ……
    }
複製程式碼

就是介個類掌握著RV的快取大權,從上面的程式碼片段我們可以看到這個類宣告瞭五個成員變數。我們一個個的來說一下:

  1. mAttachedScrap:我們可以看到這個變數是個存放ViewHolder物件的ArrayList,這一級快取是沒有容量限制的,只要符合條件的我來者不拒,全收了。前面講兩個專業術語的時候提到了Scrap,這個就屬於Scrap中的一種,這裡的資料是不做修改的,不會重新走Adapter的繫結方法。
  2. mChangedScrap:這個變數和上邊的mAttachedScrap是一樣的,唯一不同的從名字也可以看出來,它存放的是發生了變化的ViewHolder,如果使用到了這裡的快取的ViewHolder是要重新走Adapter的繫結方法的。
  3. mCachedViews:這個變數同樣是一個存放ViewHolder物件的ArrayList,但是這個不同於上面的兩個裡面存放的是dettach掉的檢視,它裡面存放的是已經remove掉的檢視,已經和RV分離的關係的檢視,但是它裡面的ViewHolder依然儲存著之前的資訊,比如position、和繫結的資料等等。這一級快取是有容量限制的,預設是2(不同版本API可能會有差異,本文基於API26.1.0)。
  4. mRecyclerPool:這個變數呢本身是一個類,跟上面三個都不一樣。這裡面儲存的ViewHolder不僅僅是removed掉的檢視,而且是恢復了出廠設定的檢視,任何繫結過的痕跡都沒有了,想用這裡快取的ViewHolder那是鐵定要重新走Adapter的繫結方法了。而且我們知道RV支援多佈局,所以這裡的快取是按照itemType來分開儲存的,我們來大致的看一下它的結構:
    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;
            …… 省略 ……
        }
        SparseArray<ScrapData> mScrap = new SparseArray<>();
        …… 省略後面程式碼 ……
    }
複製程式碼
  • 首先我們看到一個常量‘DEFAULT_MAX_SCRAP’,這個就是快取池定義的一個預設的快取數,當然這個快取數我們是可以自己設定的。而且這個快取數量不是指整個快取池只能快取這麼多,而是每個不同itemType的ViewHolder的快取數量。
  • 接著往下看,我們看到一個靜態內部類ScrapData,這裡我們只看跟快取相關的兩個變數,先說mMaxScrap,前面的常量賦值給了它,這也就印證了我們前面說的這個快取數量是對應每一種型別的ViewHolder的。再來看這個mScrapHeap變數,熟悉的一幕又來了,同樣是一個快取ViewHolder物件的ArrayList,它的容量預設是5.
  • 最後我們看到mScrap這個變數,它是一個儲存我們上面提到的ScrapData類的物件的SparseArray,這樣我們這個RecyclerPool就把不同itemType的ViewHolder按型別分類快取了起來。
  1. mViewCacheExtension:這一級快取是留給開發者自由發揮的,官方並沒有預設實現,它本身是null。
    waste-separation-502952_1280
    垃圾桶講完了,哦不,是快取層級講完了。這裡提一句,其實還有一層沒有提到,因為它不在Recycler這個類中,它在ChildHelper類中,其中有個mHiddenViews,是個快取被隱藏的ViewHolder的ArrayList。到這裡我想大家對這幾層快取心裡已經有個數了,但是還遠遠不夠,這麼多層快取是怎麼工作的?什麼時候用什麼快取?各個快取之間有沒有什麼PY交易?如果讓你自己寫一個LayoutManager你能處理好快取問題麼?就好比垃圾分類後,我們知道每種垃圾桶的定義和功能,但是面對大媽的靈魂拷問我依然分不清自己是什麼垃圾,我太難了~相比之下,RV的幾個垃圾桶簡單多了,下面我們一起來看看,這些個快取都咋用。

各快取的使用

上面我們介紹了RV的各快取層級,但是它們是怎麼工作的呢?為什麼要設計這些層級呢?別急,我們去原始碼中找找答案。一葉落而知天下秋,我們就從官方自帶的最簡單的佈局管理者LinearLayoutManager入手,來看看到底如何使用這幾級快取寫出一個合格的佈局管理者。

RV從無到有的載入過程

首先我們看一下RV從無到有是怎麼顯示出資料來的。大家因該知道一個檢視的顯示要經過onMeasure、onLayout、onDraw三個方法,那麼我們就先從第一個方法onMeasure入手,來看看裡面做了什麼。

    @Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        if (mLayout == null) {
            defaultOnMeasure(widthSpec, heightSpec);
            return;
        }
        if (mLayout.mAutoMeasure) {
            if (mState.mLayoutStep == State.STEP_START) {
                dispatchLayoutStep1();
            }
            dispatchLayoutStep2();
        }
    }
複製程式碼

上面程式碼省略了一些無關程式碼,我們只看我們關心的,dispatchLayoutStep1和2方法,1方法中如果mState.mRunPredictiveAnimations為true會呼叫mLayout.onLayoutChildren(mRecycler, mState)這個方法,但是一般RV的預測動畫都為false,所以我們看一下2方法,方法中同樣呼叫了mLayout.onLayoutChildren(mRecycler, mState)方法,來看一下:

    //已省略無關程式碼
    private void dispatchLayoutStep2() {
        eatRequestLayout();
        onEnterLayoutOrScroll();

        // Step 2: Run layout
        mState.mInPreLayout = false;
        mLayout.onLayoutChildren(mRecycler, mState);
        
        mState.mLayoutStep = State.STEP_ANIMATIONS;
        onExitLayoutOrScroll();
        resumeRequestLayout(false);
    }
複製程式碼

這裡onLayoutChildren方法是必走的,而mLayout是RV的成員變數,也就是LayoutManager,接下來我們去LinearLayoutManager裡看看onLayoutChildren方法做了什麼。

    //已省略無關程式碼
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        
        detachAndScrapAttachedViews(recycler);
       
        if (mAnchorInfo.mLayoutFromEnd) {
            // fill towards start
            fill(recycler, mLayoutState, state, false);
            
            // fill towards end
            fill(recycler, mLayoutState, state, false);
            endOffset = mLayoutState.mOffset;
        } else {
            // fill towards end
            fill(recycler, mLayoutState, state, false);
            
            // fill towards start
            fill(recycler, mLayoutState, state, false);
            startOffset = mLayoutState.mOffset;
        }
    }
複製程式碼

這個方法挺長的,我們只看最關心的,來看下detachAndScrapAttachedViews(recycler)方法中做了什麼。

    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);
            }
    }
複製程式碼

如果有子view呼叫了scrapOrRecycleView(recycler, i, v)方法,繼續追蹤。

   private void scrapOrRecycleView(Recycler recycler, int index, View view) {
       final ViewHolder viewHolder = getChildViewHolderInt(view);
       if (viewHolder.isInvalid() && !viewHolder.isRemoved()
               && !mRecyclerView.mAdapter.hasStableIds()) {
           removeViewAt(index);
           recycler.recycleViewHolderInternal(viewHolder);
       } else {
           detachViewAt(index);
           recycler.scrapView(view);
           mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
       }
   }
複製程式碼

正常開始佈局的時候會進入else分支,首先是呼叫detachViewAt(index)來分離檢視,然後呼叫了recycler.scrapView(view)方法。前面我們說過Recycler是RV的內部類,是管理RV快取的核心類,然後我們繼續追蹤這個srapView方法,看看裡面做了什麼。

    void scrapView(View view) {
        final ViewHolder holder = getChildViewHolderInt(view);
        if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
                || !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
            if (holder.isInvalid() && !holder.isRemoved() && !mAdapter.hasStableIds()) {
                throw new IllegalArgumentException("……");
            }
            holder.setScrapContainer(this, false);
            mAttachedScrap.add(holder);
        }
    }
複製程式碼

這裡我們看到了熟悉的身影,「mAttachedScrap」,到此為止我們知道了,onLayoutChildren方法中呼叫detachAndScrapAttachedViews方法把存在的子view先分離然後快取到了AttachedScrap中。我們回到onLayoutChildren方法中看看接下來做了什麼,我們發現它先判斷了方向,因為LinearLayoutManager有橫縱兩個方向,無論哪個方向最後都是呼叫fill方法,見名知意,這是個填充佈局的方法,fill方法中又呼叫了layoutChunk這個方法,我們看一眼這個方法。

    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
        View view = layoutState.next(recycler);
        if (view == null) {
            return;
        }
        if (layoutState.mScrapList == null) {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                addView(view);
            } else {
                addView(view, 0);
            }
        } 
    }
複製程式碼

該方法中我們看到通過layoutState.next(recycler)方法來拿到檢視,如果這個檢視為null那麼方法終止,否則就會呼叫addView方法將檢視新增或者重新attach回來,這個我們不關心,我們看看是怎麼拿到檢視的。

    View next(RecyclerView.Recycler recycler) {
        if (mScrapList != null) {
            return nextViewFromScrapList();
        }
        final View view = recycler.getViewForPosition(mCurrentPosition);
        mCurrentPosition += mItemDirection;
        return view;
    }
複製程式碼

首先我們看到如果mScrapList不為空會去其中取檢視,mScrapList是什麼呢?實際上它就是mAttachedScrap,但是它是隻讀的,而且只有在開啟預測動畫時才會被賦值,所以我們忽略它即可。重點關注下recycler.getViewForPosition(mCurrentPosition)方法,這個方法經過層層呼叫,最終是呼叫的Recycler類中的「tryGetViewHolderForPositionByDeadline(int position,boolean dryRun,long deadlineNs)」方法,接下來看一下這個方法做了哪些事。

    @Nullable
    ViewHolder tryGetViewHolderForPositionByDeadline(int position,
            boolean dryRun, long deadlineNs) {
        boolean fromScrapOrHiddenOrCache = false;
        ViewHolder holder = null;
        // 0) If there is a changed scrap, try to find from there
        if (mState.isPreLayout()) {
            holder = getChangedScrapViewForPosition(position);
            fromScrapOrHiddenOrCache = holder != null;
        }
        // 1) Find by position from scrap/hidden list/cache
        if (holder == null) {
            holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
        }
        if (holder == null) {
            // 2) Find from scrap/cache via stable ids, if exists
            if (holder == null && mViewCacheExtension != null) {
                final View view = mViewCacheExtension
                        .getViewForPositionAndType(this, position, type);
            }
            if (holder == null) {
                holder = getRecycledViewPool().getRecycledView(type);
            }
            if (holder == null) {
                holder = mAdapter.createViewHolder(RecyclerView.this, type);
            }
        }
        return holder;
    }
複製程式碼

這段程式碼著實做了不少事情,獲取View和繫結View都是在這個方法中完成的,當然關於繫結和其它的無關程式碼這裡就不貼了。我們一步步的看一下:

  1. 第一步先從getChangedScrapViewForPosition(position)方法中找需要的檢視,但是有個條件mState.isPreLayout()要為true,這個一般在我們呼叫adapter的notifyItemChanged等方法時為true,其實也很好理解,資料發生了變化,viewholder被detach掉後快取在mChangedScrap之中,在這裡拿到的viewHolder後續需要重新繫結。
  2. 第二步,如果沒有找到檢視則從getScrapOrHiddenOrCachedHolderForPosition這個方法中繼續找。這個方法的程式碼就不貼了,簡單說下這裡的查詢順序。
  • 首先從mAttachedScrap中查詢
  • 再次從前面略過的ChildHelper類中的mHiddenViews中查詢
  • 最後是從mCachedViews中查詢的
  1. 第三步, mViewCacheExtension中查詢,我們說過這個物件預設是null的,是由我們開發者自定義快取策略的一層,所以如果你沒有定義過,這裡是找不到View的。
  2. 第四步,從RecyclerPool中查詢,前面我們介紹過RecyclerPool,先通過itemType從SparseArray型別的mscrap中拿到ScrapData,不為空繼續拿到scrapHeap這個ArrayList,然後取到檢視,這裡拿到的檢視需要重新繫結。
  3. 第五步,如果前面幾步都沒有拿到檢視,那麼呼叫了mAdapter.createViewHolder(RecyclerView.this, type)方法,這個方法內部呼叫了一個抽象方法onCreateViewHolder,是不是很熟悉,沒錯,就是我們自己寫一個Adapter要實現的方法之一。

到此為止我們獲取一個檢視的流程就講完了,獲取到檢視之後就是怎麼擺放檢視並新增到RV之中,然後最終展示到我們面前。細心的小夥伴可能發現這個流程貌似有點問題啊?第一次進入onLayoutChildren時還沒有任何子view,在fill方法前等於沒有快取子view,所有的子View都是第五步onCreateViewHolder建立而來的。實際上這裡的設計是有道理的,除了一些特殊情況onLayoutChildren方法會被多次呼叫外,一個View從無到有展示在我們面前要至少經過兩次onMeasure,一次onLayout,一次onDraw方法(為什麼是這樣的呢,感興趣的小夥伴可以去ViewRootImpl中找找答案)。所以這裡需要做個快取,而不至於每次都重新建立新的檢視。整個過程大致如圖:

1566893166006
這裡提一下,在RV展示成功後,Scrap這層的快取就為空了,在從Scrap中取檢視的同時就被移出了快取。在onLayout這裡最終會呼叫到dispatchLayoutStep3方法,沒錯,除了1和2還有3,在3中,如果Scrap還有快取,那麼快取會被清空,清空的快取會被新增到mCachedViews或者RecyclerPool中。

RV滑動時的快取過程

RV是可以通過滾動來展示大量資料的控制元件,那麼由當前螢幕滾動而出的View去哪了?滾動而入的View哪來的?同樣的,我們去原始碼中找找答案。

scrollHorizontallyBy,scrollVerticallyBy

  • 一個LayoutManager如果可以滑動,那麼上面的兩個方法要返回非0值,分別代表可以橫向滾動和縱向滾動。最終兩個方法都會呼叫scrollBy方法,然後scrollby方法呼叫了fill方法,這個fill我們已經見過了,現在再看一下。
    int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
        final int start = layoutState.mAvailable;
        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            // TODO ugly bug fix. should not happen
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            recycleByLayoutState(recycler, layoutState);
        }
    }
複製程式碼

這這段程式碼中判斷了當前是否是滾動觸發的fill方法,如果是呼叫recycleByLayoutState(recycler, layoutState)方法。這個方法幾經週轉會呼叫到removeAndRecycleViewAt方法:

    public void removeAndRecycleViewAt(int index, Recycler recycler) {
        final View view = getChildAt(index);
        removeViewAt(index);
        recycler.recycleView(view);
    }
複製程式碼

這裡注意先把檢視remove掉了,而不是detach掉。然後呼叫Recycler中的recycleView方法,這個方法最後會呼叫recycleViewHolderInternal方法,方法如下:

    void recycleViewHolderInternal(ViewHolder holder) {

            if (forceRecycle || holder.isRecyclable()) {
                if (省略) {
                    int cachedViewSize = mCachedViews.size();
                    if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
                        recycleCachedViewAt(0);
                        cachedViewSize--;
                    }
                    mCachedViews.add(targetCacheIndex, holder);
                    cached = true;
                }
                if (!cached) {
                    addViewHolderToRecycledViewPool(holder, true);
                    recycled = true;
                }
            } 
    }
複製程式碼

刪除不相關程式碼後邏輯很清晰。前面我們說過mCachedViews是有容量限制的,預設為2。那麼如果符合放到mCachedViews中的條件,首先會判斷mCachedViews是否已經滿了,如果滿了會通過recycleCachedViewAt(0)方法把最老得那個快取放進RecyclerPool,然後在把新的檢視放進mCachedViews中。如果這個檢視不符合條件會直接被放進RecyclerPool中。我們注意到,在快取進mCachedViews之前,我們的檢視只是被remove掉了,繫結的資料等資訊都還在,這意味著從mCachedViews取出的檢視如果符合需要的目標檢視是可以直接展示的,而不需要重新繫結。而放進RecyclerPool最終是要呼叫putRecycledView方法的。

    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;
        }
        scrap.resetInternal();
        scrapHeap.add(scrap);
    }
複製程式碼

這個方法中同樣對容量做了判斷,跟mCachedViews不一樣,如果容量滿了,就不再繼續快取了。在快取之前先呼叫了scrap.resetInternal()方法,這個方法顧名思義是個重置的方法,快取之前把檢視的資訊都清除掉了,這也是為什麼這裡快取滿了之後就不再繼續快取了,而不是把老的快取替換掉,因為它們重置後都一樣了(這裡指具有同種itemType的是一樣的)。這就是滑動快取的全過程,至此我們知道了滾動出去的檢視去哪了,那麼滾動進來的檢視哪來的呢?

  • 和從無到有的過程一樣,最後滾動也呼叫了fill方法,那最後必然是要走到前面分析的獲取檢視的5個流程。前面說過在佈局完成之後,Scrap層的快取就是空的了,那就只能從mCachedViews或者RecyclerPool中取了,都取不到最後就會走onCreateViewHolder建立檢視。到這裡滑動時的快取以及取快取就講完了。

資料更新時的快取過程

這塊我就簡單說一下結論,感興趣的同學可以自行檢視原始碼。為什麼我們在有資料重新整理的時候推薦大家使用notifyItemChanged等方法而不使用notifyDataSetChanged方法呢?

  • 在呼叫notifyDataSetChanged方法後,所有的子view會被標記,這個標記導致它們最後都被快取到RecyclerPool中,然後重新繫結資料。並且由於RecyclerPool有容量限制,如果不夠最後就要重新建立新的檢視了。
  • 但是使用notifyItemChanged等方法會將檢視快取到mChangedScrap和mAttachedScrap中,這兩個快取是沒有容量限制的,所以基本不會重新建立新的檢視,只是mChangedScrap中的檢視需要重新繫結一下。

總結

我們從快取的幾個型別以及佈局、滾動、重新整理幾個方面全方位的剖析了RV的快取機制。

這麼多層快取是怎麼工作的?什麼時候用什麼快取?各個快取之間有沒有什麼PY交易?如果讓你自己寫一個LayoutManager你能處理好快取問題麼?

我相信你已經有了自己的答案。後續會推出一篇關於自定義LayoutManager的文章,敬請期待。

相關文章