RecyclerView的複用機制

susion發表於2018-12-14

本文是RecyclerView完全解析系列第三篇文章,內容是緊跟前兩篇:RecyclerView基本設計結構RecyclerView重新整理機制

通過前面分析知道LayoutManager在佈局子View時會向Recycler索要一個ViewHolder。但從Recycler中獲取一個ViewHolder的前提是Recycler中要有ViewHolder。那Recycler中是如何有ViewHolder的呢? 本文會分析兩個問題:

  1. RecyclerViewView是在什麼時候放入到Recycler中的。以及在Recycler中是如何儲存的。
  2. LayoutManager在向Recycler獲取ViewHolder時,Recycler尋找ViewHolder的邏輯是什麼。

何時存、怎麼存何時取、怎麼取的問題。何時取已經很明顯了:LayoutManager在佈局子View時會從Recycler中獲取子View。 所以本文要理清的是其他3個問題。在文章繼續之前要知道Recycler管理的基本單元是ViewHolderLayoutManager操作的基本單元是View,即ViewHolderitemview。本文不會分析RecyclerView動畫時view的複用邏輯。

為了接下來的內容更容易理解,先回顧一下Recycler的組成結構:

RecyclerView的複用機制

  • mChangedScrap : 用來儲存RecyclerView做動畫時,被detach的ViewHolder
  • mAttachedScrap : 用來儲存RecyclerView做資料重新整理(notify),被detach的ViewHolder
  • mCacheViews : Recycler的一級ViewHolder快取。
  • RecyclerViewPool : mCacheViews集合中裝滿時,會放到這裡。

先看一下如何從Recycler中取一個ViewHolder來複用。

從Recycler中獲取一個ViewHolder的邏輯

LayoutManager會呼叫Recycler.getViewForPosition(pos)來獲取一個指定位置(這個位置是子View佈局所在的位置)的viewgetViewForPosition()會呼叫tryGetViewHolderForPositionByDeadline(position...), 這個方法是從Recycler中獲取一個View的核心方法。它就是如何從Recycler中獲取一個ViewHolder的邏輯,即怎麼取, 方法太長, 我做了很多裁剪:

ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) {
    ...
    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); //從 attach 和 mCacheViews 中獲取
        if (holder != null) {
            ... //校驗這個holder是否可用
        }
    }
    if (holder == null) {
        ...
        final int type = mAdapter.getItemViewType(offsetPosition); //獲取這個位置的資料的型別。  子Adapter複寫的方法
        // 2) Find from scrap/cache via stable ids, if exists
        if (mAdapter.hasStableIds()) {    //stable id 就是標識一個viewholder的唯一性, 即使它做動畫改變了位置
            holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),  //根據 stable id 從 scrap 和 mCacheViews中獲取
                    type, dryRun);
            ....
        }
        if (holder == null && mViewCacheExtension != null) { // 從使用者自定義的快取集合中獲取
            final View view = mViewCacheExtension
                    .getViewForPositionAndType(this, position, type);  //你返回的View要是RecyclerView.LayoutParams屬性的
            if (view != null) {
                holder = getChildViewHolder(view);  //把它包裝成一個ViewHolder
                ...
            }
        }
        if (holder == null) { // 從 RecyclerViewPool中獲取
            holder = getRecycledViewPool().getRecycledView(type);
            ...
        }
        if (holder == null) { 
            ...
            //實在沒有就會建立
            holder = mAdapter.createViewHolder(RecyclerView.this, type);
            ...
        }
    }
    ...
    boolean bound = false;
    if (mState.isPreLayout() && holder.isBound()) { //動畫時不會想去呼叫 onBindData
        ...
    } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
        ...
        final int offsetPosition = mAdapterHelper.findPositionOffset(position);
        bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);  //呼叫 bindData 方法
    }

    final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
    final LayoutParams rvLayoutParams;
    ...調整LayoutParams
    return holder;
}

複製程式碼

即大致步驟是:

  1. 如果執行了RecyclerView動畫的話,嘗試根據positionmChangedScrap集合中尋找一個ViewHolder
  2. 嘗試根據positionscrap集合hide的view集合mCacheViews(一級快取)中尋找一個ViewHolder
  3. 根據LayoutManagerposition更新到對應的Adapterposition。 (這兩個position在大部分情況下都是相等的,不過在子view刪除或移動時可能產生不對應的情況)
  4. 根據Adapter position,呼叫Adapter.getItemViewType()來獲取ViewType
  5. 根據stable id(用來表示ViewHolder的唯一,即使位置變化了)scrap集合mCacheViews(一級快取)中尋找一個ViewHolder
  6. 根據position和viewType嘗試從使用者自定義的mViewCacheExtension中獲取一個ViewHolder
  7. 根據ViewType嘗試從RecyclerViewPool中獲取一個ViewHolder
  8. 呼叫mAdapter.createViewHolder()來建立一個ViewHolder
  9. 如果需要的話呼叫mAdapter.bindViewHolder來設定ViewHolder
  10. 調整ViewHolder.itemview的佈局引數為Recycler.LayoutPrams,並返回Holder

雖然步驟很多,邏輯還是很簡單的,即從幾個快取集合中獲取ViewHolder,如果實在沒有就建立。但比較疑惑的可能就是上述ViewHolder快取集合中什麼時候會儲存ViewHolder。接下來分幾個RecyclerView的具體情形,來一點一點弄明白這些ViewHolder快取集合的問題。

情形一 : 由無到有

即一開始RecyclerView中沒有任何資料,新增資料來源後adapter.notifyXXX。狀態變化如下圖:

RecyclerView的複用機制

很明顯在這種情形下Recycler中是不會存在任何可複用的ViewHolder。所以所有的ViewHolder都是新建立的。即會呼叫Adapter.createViewHolder()和Adapter.bindViewHolder()。那這些建立的ViewHolder會快取起來嗎?

這時候新建立的這些ViewHolder是不會被快取起來的。 即在這種情形下: Recycler只會通過Adapter建立ViewHolder,並且不會快取這些新建立的ViewHolder

情形二 : 在原有資料的情況下進行整體重新整理

就是下面這種狀態:

RecyclerView的複用機制

其實就是相當於使用者在feed中做了下拉重新整理。實現中的虛擬碼如下:

dataSource.clear()
dataSource.addAll(newList)
adapter.notifyDatasetChanged()
複製程式碼

在這種情形下猜想Recycler肯定複用了老的卡片(卡片的型別不變),那麼問題是 : 在使用者重新整理時舊ViewHolder儲存在哪裡? 如何呼叫舊ViewHolderAdapter.bindViewHolder()來重新設定資料的?

其實在上一篇文章Recycler重新整理機制中,LinearLayoutManager在確定好佈局錨點View之後就會把當前attachRecyclerView上的子View全部設定為scrap狀態:

void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    ...
    onAnchorReady(recycler, state, mAnchorInfo, firstLayoutDirection);  // RecyclerView指定錨點,要準備正式佈局了
    detachAndScrapAttachedViews(recycler);   // 在開始佈局時,把所有的View都設定為 scrap 狀態
    ...
}
複製程式碼

什麼是scrap狀態呢? 在前面的文章其實已經解釋過: ViewHolder被標記為FLAG_TMP_DETACHED狀態,並且其itemviewparent被設定為null

detachAndScrapAttachedViews就是把所有的view儲存到RecyclermAttachedScrap集合中:

public void detachAndScrapAttachedViews(@NonNull Recycler recycler) {
    for (int i = getChildCount() - 1; i >= 0; i--) {
        final View v = getChildAt(i);
        scrapOrRecycleView(recycler, i, v);
    }
}
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
    final ViewHolder viewHolder = getChildViewHolderInt(view);
    ...刪去了一些判斷邏輯
    detachViewAt(index);  //設定RecyclerView這個位置的view的parent為null, 並標記ViewHolder為FLAG_TMP_DETACHED
    recycler.scrapView(view); //新增到mAttachedScrap集合中  
    ...
}
複製程式碼

所以在這種情形下LinearLayoutManager在真正擺放子View之前,會把所有舊的子View按順序儲存到RecyclermAttachedScrap集合

接下來繼續看,LinearLayoutManager在佈局時如何複用mAttachedScrap集合中的ViewHolder

前面已經說了LinearLayoutManager會當前佈局子View的位置向Recycler要一個子View,即呼叫到tryGetViewHolderForPositionByDeadline(position..)。我們上面已經列出了這個方法的邏輯,其實在前面的第二步:

嘗試根據positionscrap集合hide的view集合mCacheViews(一級快取)中尋找一個ViewHolder

即從mAttachedScrap中就可以獲得一個ViewHolder:

ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
    final int scrapCount = mAttachedScrap.size();
    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;
        }
    }
    ...
}
複製程式碼

即如果mAttachedScrap中holder的位置和入參position相等,並且holder是有效的話這個holder就是可以複用的。所以綜上所述,在情形二下所有的ViewHolder幾乎都是複用Recycler中mAttachedScrap集合中的。 並且重新佈局完畢後Recycler中是不存在可複用的ViewHolder的。

情形三 : 滾動複用

這個情形分析是在情形二的基礎上向下滑動時ViewHolder的複用情況以及RecyclerViewHolder的儲存情況, 如下圖:

RecyclerView的複用機制

在這種情況下滾出螢幕的View會優先儲存到mCacheViews, 如果mCacheViews中儲存滿了,就會儲存到RecyclerViewPool中。

在前一篇文章RecyclerView重新整理機制中分析過,RecyclerView在滑動時會呼叫LinearLayoutManager.fill()方法來根據滾動的距離來向RecyclerView填充子View,其實在個方法在填充完子View之後就會把滾動出螢幕的View做回收:

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
    ...
    int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
    ...
    while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
        ...
        layoutChunk(recycler, state, layoutState, layoutChunkResult); //填充一個子View

        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            recycleByLayoutState(recycler, layoutState); //根據滾動的距離來回收View
        }
    }
}
複製程式碼

fill每填充一個子View都會呼叫recycleByLayoutState()來回收一個舊的子View,這個方法在層層呼叫之後會呼叫到Recycler.recycleViewHolderInternal()。這個方法是ViewHolder回收的核心方法,不過邏輯很簡單:

  1. 檢查mCacheViews集合中是否還有空位,如果有空位,則直接放到mCacheViews集合
  2. 如果沒有的話就把mCacheViews集合中最前面的ViewHolder拿出來放到RecyclerViewPool中,然後再把最新的這個ViewHolder放到mCacheViews集合
  3. 如果沒有成功快取到mCacheViews集合中,就直接放到RecyclerViewPool

mCacheViews集合為什麼要這樣快取? 看一下下面這張圖 :

RecyclerView的複用機制

我是這樣認為的,如上圖,往上滑動一段距離,被滑動出去的ViewHolder會被快取在mCacheViews集合,並且位置是被記錄的。如果使用者此時再下滑的話,可以參考文章開頭的從Recycler中獲取ViewHolder的邏輯:

  1. 先按照位置從mCacheViews集合中獲取
  2. 按照viewTypemCacheViews集合中獲取

上面對於mCacheViews集合兩步操作,其實第一步就已經命中了快取的ViewHolder。並且這時候都不需要呼叫Adapter.bindViewHolder()方法的。即是十分高效的。

所以在普通的滾動複用的情況下,ViewHolder的複用主要來自於mCacheViews集合, 舊的ViewHolder會被放到mCacheViews集合, mCacheViews集合擠出來的更老的ViewHolder放到了RecyclerViewPool

到這裡基本的複用情形都覆蓋了,其他的就涉及到RecyclerView動畫了。這些點在下一篇文章繼續看。

歡迎關注我的Android進階計劃。看更多幹貨。

相關文章