老大爺都能看懂的RecyclerView動畫原理

冬天的毛毛雨發表於2020-12-25

如何閱讀本篇文章

本文主要講解RecyclerView Layout變化觸發動畫執行的原理。前半部分偏重原理和程式碼的講解,後半部分通過圖文結合場景講解各個階段的執行過程。

建議先粗略閱讀前半部分的原理和程式碼篇,做到心中有概念,帶著理論知識去閱讀後半部分的場景篇。最後結合全文學到的知識,帶著問題去閱讀原始碼,效果會更好。

原理篇

1. Adapter的notify方法

用過RecyclerView的同學大概都應該知道Adapter有幾個notify相關的方法,它們分別是:

  • notifyDataSetChanged()
  • notifyItemChanged(int)
  • notifyItemInserted(int)
  • notifyItemRemoved(int)
  • notifyItemRangeChanged(int, int)
  • notifyItemRangeInserted(int, int)
  • notifyItemRangeRemoved(int, int)
  • notifyItemMoved(int, int)

稍微有點開發經驗的同學都知道,notifyDataSetChanged()方法比其它的幾個方法更重量級一點,它會導致整個列表重新整理,其它幾個方法則不會。有更多開發經驗的同學可能還知道notifyDataSetChanged()方法不會觸發RecyclerView的動畫機制,其它幾個方法則會觸發各種不同型別的動畫。

2. RecyclerView的佈局邏輯

2.1 RecyclerView的dispatchLayout

dispatchLayout顧名思義,當然是把子View佈局(新增並放置到合適的位置)到RecyclerView上面了。開啟它的原始碼我們可以看到這樣一段註釋。

Wrapper around layoutChildren() that handles animating changes caused by layout. Animations work on the assumption that there are five different kinds of items in play:

  1. PERSISTENT: items are visible before and after layout

  2. REMOVED: items were visible before layout and were removed by the app

  3. ADDED: items did not exist before layout and were added by the app

  4. DISAPPEARING: items exist in the data set before/after, but changed from visible to non-visible in the process of layout (they were moved off screen as a side-effect of other changes)

  5. APPEARING: items exist in the data set before/after, but changed from non-visible to visible in the process of layout (they were moved on screen as a side-effect of other changes)

從註釋我們可以知道。dispatchLayout方法不僅有給子View佈局的功能,而且可以處理動畫。動畫主要分為五種:

  1. PERSISTENT:針對佈局前和佈局後都在手機介面上的View所做的動畫
  2. REMOVED:在佈局前對使用者可見,但是資料已經從資料來源中刪除掉了
  3. ADDED:新增資料到資料來源中,並且在佈局後對使用者可見
  4. DISAPPEARING:資料一直都存在於資料來源中,但是佈局後從可見變成不可見狀態
  5. APPEARING:資料一直都存在於資料來源中,但是佈局後從不可見變成可見狀態

到目前為止,我們還不能完全理解這五種型別的動畫有什麼具體的區別,分別在什麼樣的場景下會觸發這些型別的動畫。但是給我們提供了很好的研究思路。目前我們只需要簡單瞭解有這五種動畫,接著往下,我們這裡看下dispatchLayout的原始碼,為了響應文章標題,這裡貼出精簡過的原始碼:

void dispatchLayout(){
  ...
  dispatchLayoutStep1();
  dispatchLayoutStep2();
  dispatchLayoutStep3();
  ...
}

關於dispatchLayoutStepX方法,相信很多人都聽說或者瞭解過,文章後面我會做詳細的介紹,簡單介紹如下:

從dispatchLayout的註釋中,我們注意到before和after兩個單詞,分別表示佈局前和佈局後。這麼說來那就簡單了。dispatchLayoutStep1對應的是before(佈局前),dispatchLayoutStep2的意思是佈局中,dispatchLayoutStep3對應的是after(佈局後)。它們的作用描述如下:

  1. dispatchLayoutStep1
    1. 判斷是否需要開啟動畫功能

    2. 如果開啟動畫,將當前螢幕上的Item相關資訊儲存起來供後續動畫使用

    3. 如果開啟動畫,呼叫mLayout.onLayoutChildren方法預佈局

    4. 預佈局後,與第二步儲存的資訊對比,將新出現的Item資訊儲存到Appeared中

精簡後的程式碼如下:

private void dispatchLayoutStep1() {
  ...
  //第一步 判斷是否需要開啟動畫功能
  processAdapterUpdatesAndSetAnimationFlags();
  ...
  if (mState.mRunSimpleAnimations) {
    ...
    //第二步  將當前螢幕上的Item相關資訊儲存起來供後續動畫使用
    int count = mChildHelper.getChildCount();
    for (int i = 0; i < count; ++i) {
        final ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
        final ItemHolderInfo animationInfo = mItemAnimator
                        .recordPreLayoutInformation(mState, holder,
                                ItemAnimator.buildAdapterChangeFlagsForAnimations(holder),
                                holder.getUnmodifiedPayloads());
        mViewInfoStore.addToPreLayout(holder, animationInfo);
    }
    ...
    if (mState.mRunPredictiveAnimations) {
          saveOldPositions();
          //第三步 呼叫onLayoutChildren方法預佈局
          mLayout.onLayoutChildren(mRecycler, mState);
          mState.mStructureChanged = didStructureChange;

          for (int i = 0; i < mChildHelper.getChildCount(); ++i) {
              final View child = mChildHelper.getChildAt(i);
              final ViewHolder viewHolder = getChildViewHolderInt(child);
              if (viewHolder.shouldIgnore()) {
                  continue;
              }
                        //第四步 預佈局後,對比預佈局前後,哪些item需要放入到Appeared中

              if (!mViewInfoStore.isInPreLayout(viewHolder)) {

                  if (wasHidden) {
                      recordAnimationInfoIfBouncedHiddenView(viewHolder, animationInfo);
                  } else {
                      mViewInfoStore.addToAppearedInPreLayoutHolders(viewHolder, animationInfo);
                  }
              }
          }
          clearOldPositions();
      } else {
          clearOldPositions();
      }
  }

}
  1. dispatchLayoutStep2 根據資料來源中的資料進行佈局,真正展示給使用者看的最終介面
private void dispatchLayoutStep2() {
    ...
    // Step 2: Run layout
    mState.mInPreLayout = false;//此處關閉預佈局模式
    mLayout.onLayoutChildren(mRecycler, mState);
    ...
}
  1. dispatchLayoutStep3 觸發動畫
private void dispatchLayoutStep3() {
    ...
    if (mState.mRunSimpleAnimations) {
        // Step 3: Find out where things are now, and process change animations.
        // traverse list in reverse because we may call animateChange in the loop which may
        // remove the target view holder.
        for (int i = mChildHelper.getChildCount() - 1; i >= 0; i--) {
            ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
            if (holder.shouldIgnore()) {
                continue;
            }
            long key = getChangedHolderKey(holder);
            final ItemHolderInfo animationInfo = mItemAnimator
                    .recordPostLayoutInformation(mState, holder);
            ViewHolder oldChangeViewHolder = mViewInfoStore.getFromOldChangeHolders(key);
            if (oldChangeViewHolder != null && !oldChangeViewHolder.shouldIgnore()) {
                // run a change animation
                ...
            } else {
                mViewInfoStore.addToPostLayout(holder, animationInfo);
            }
        }

        // Step 4: Process view info lists and trigger animations
        //觸發動畫
        mViewInfoStore.process(mViewInfoProcessCallback);
    }

  ...
    }

從程式碼我們可以看出dispatchLayoutStep1和dispatchLayoutStep2方法中呼叫了onLayoutChildren方法,而dispatchLayoutStep3沒有呼叫。

2.2 LinearLayoutManager的onLayoutChildren方法

以垂直方向的RecyclerView為例子,我們填充RecyclerView的方向有兩種,從上往下填充和從下往上填充。開始填充的位置不是固定的,可以從RecyclerView的任意位置處開始填充。該方法的功能我精簡為以下幾個步驟:

  1. 尋找填充的錨點(最終呼叫findReferenceChild方法)
  2. 移除螢幕上的Views(最終呼叫detachAndScrapAttachedViews方法)
  3. 從錨點處從上往下填充(呼叫fill和layoutChunk方法)
  4. 從錨點處從下往上填充(呼叫fill和layoutChunk方法)
  5. 如果還有多餘的空間,繼續填充(呼叫fill和layoutChunk方法)
  6. 非預佈局,將scrapList中多餘的ViewHolder填充(呼叫layoutForPredictiveAnimations)

本文只講解onLayoutChildren的主流程,具體的填充邏輯請參考RecyclerView填充邏輯一文

LinearLayoutManager#onLayoutChildren

  public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    ...
    //1\. 尋找填充的錨點
    updateAnchorInfoForLayout(recycler, state, mAnchorInfo);

    ...
    //2\. 移除螢幕上的Views
    detachAndScrapAttachedViews(recycler);

    ...
    //3\. 從錨點處從上往下填充
    updateLayoutStateToFillEnd(mAnchorInfo);
    mLayoutState.mExtraFillSpace = extraForEnd;
    fill(recycler, mLayoutState, state, false);

    ...
    //4\. 從錨點處從下往上填充
    // fill towards start
    updateLayoutStateToFillStart(mAnchorInfo);
    mLayoutState.mExtraFillSpace = extraForStart;
    mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
    fill(recycler, mLayoutState, state, false);

    ...
    //5\. 如果還有多餘的空間,繼續填充
    if (mLayoutState.mAvailable > 0) {
        extraForEnd = mLayoutState.mAvailable;
        // start could not consume all it should. add more items towards end
        updateLayoutStateToFillEnd(lastElement, endOffset);
        mLayoutState.mExtraFillSpace = extraForEnd;
        fill(recycler, mLayoutState, state, false);
        endOffset = mLayoutState.mOffset;
    }
  }
    ...
    //6\. 非預佈局,將scrapList中多餘的ViewHolder填充
    layoutForPredictiveAnimations(recycler, state, startOffset, endOffset);
    ...

LinearLayoutManager#layoutForPredictiveAnimations

 private void layoutForPredictiveAnimations(RecyclerView.Recycler recycler,
            RecyclerView.State state, int startOffset,
            int endOffset) {
        //判斷是否滿足條件,如果是預佈局直接返回
        if (!state.willRunPredictiveAnimations() ||  getChildCount() == 0 || state.isPreLayout()
                || !supportsPredictiveItemAnimations()) {
            return;
        }
        // 遍歷scrapList,步驟2中螢幕中被移除的View
        int scrapExtraStart = 0, scrapExtraEnd = 0;
        final List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList();
        final int scrapSize = scrapList.size();
        final int firstChildPos = getPosition(getChildAt(0));
        for (int i = 0; i < scrapSize; i++) {
            RecyclerView.ViewHolder scrap = scrapList.get(i);
            //如果被remove掉了,跳過
            if (scrap.isRemoved()) {
                continue;
            }
            //計算額外的控制元件
                scrapExtraEnd += mOrientationHelper.getDecoratedMeasurement(scrap.itemView);

        }

        mLayoutState.mScrapList = scrapList;
        ...
        // 步驟6 繼續填充
        if (scrapExtraEnd > 0) {
            View anchor = getChildClosestToEnd();
            updateLayoutStateToFillEnd(getPosition(anchor), endOffset);
            mLayoutState.mExtraFillSpace = scrapExtraEnd;
            mLayoutState.mAvailable = 0;
            mLayoutState.assignPositionFromScrapList();
            fill(recycler, mLayoutState, state, false);
        }
        mLayoutState.mScrapList = null;
    }

至此,佈局的邏輯已經講解完畢。關於具體的動畫執行邏輯,由於篇幅有限。不在本文中講解

場景篇

1. notifyItemRemoved

我們來測試從螢幕中刪除View,呼叫notifyItemRemoved相關的方法,dispatchLayout是如何重新佈局的。假設初始狀態如下圖,假設Adapter資料有100條,螢幕上有Item1~Item6 6個View,刪除Item1和Item2。

  1. 將Item1 Item2對應的ViewHolder設定為REMOVE狀態
  2. 將所有的Item對應的ViewHolder的mPreLayoutPosition欄位賦值為當前的position

我們回顧以下onLayoutChildren的幾個步驟

  1. 尋找填充的錨點(最終呼叫findReferenceChild方法)
  2. 移除螢幕上的Views(最終呼叫detachAndScrapAttachedViews方法)
  3. 從錨點處從上往下填充(呼叫fill和layoutChunk方法)
  4. 從錨點處從下往上填充(呼叫fill和layoutChunk方法)
  5. 如果還有多餘的空間,繼續填充(呼叫fill和layoutChunk方法)
  6. 非預佈局,將scrapList中多餘的ViewHolder填充(呼叫layoutForPredictiveAnimations)

1.1 dispatchLayoutStep1階段

  1. 尋找填充的錨點,尋找錨點的邏輯是,從上往下,找到第一個非remove狀態的Item。在本Case中,找到Item3

  1. 移除螢幕上的Views,將它們的ViewHolder放入到Recycler的mAttachedScrap快取中,這個快取的好處是如果position對應上了,無需重新繫結,直接拿來用。

  1. 從錨點Item3處往下填充,mAttachedScrap只剩下ViewHolder2和ViewHolder1

  1. 從錨點Item3處往上填充Item2 Item1,因為Item2,Imte1已經被remove掉了,它消耗的空間不會被記錄,那麼到步驟5的時候還可以填充

  1. 還有多餘的空間,繼續填充,把Item7、Item8填充到螢幕中

  1. 因為當前是預佈局,直接返回

至此step1的layout結束

1.2 dispatchLayoutStep2階段

  1. 尋找填充的錨點,尋找錨點的邏輯是,從上往下,找到第一個非remove狀態的Item。在本Case中,找到Item3

  1. 移除螢幕上的Views,將它們的ViewHolder放入到Recycler的mAttachedScrap快取中

  1. 從錨點Item3處往下填充,填充到Item6為止,就沒有足夠的距離了,mAttachedScrap只剩下ViewHolder8,ViewHolder7,ViewHolder2,ViewHolder1

  1. 往上填充,雖然此時還有兩個View的高度,但是此時,上邊沒有資料了,此處不填充

  1. 此時還有兩個View的高度,繼續往下填充

注意此時已經佈局完成但是螢幕上部與第一個有GAP,會修復

 if (getChildCount() > 0) {
            // because layout from end may be changed by scroll to position
            // we re-calculate it.
            // find which side we should check for gaps.
            if (mShouldReverseLayout ^ mStackFromEnd) {
                int fixOffset = fixLayoutEndGap(endOffset, recycler, state, true);
                startOffset += fixOffset;
                endOffset += fixOffset;
                fixOffset = fixLayoutStartGap(startOffset, recycler, state, false);
                startOffset += fixOffset;
                endOffset += fixOffset;
            } else {
                int fixOffset = fixLayoutStartGap(startOffset, recycler, state, true);
                startOffset += fixOffset;
                endOffset += fixOffset;
                fixOffset = fixLayoutEndGap(endOffset, recycler, state, false);
                startOffset += fixOffset;
                endOffset += fixOffset;
            }
        }

修復後效果如下

  1. 當前不是預佈局,但是因為ViewHolder1和ViewHolder2都是被Remove掉的,所以跳過

2. notifyItemInserted

假設在Item1下面插入兩條資料AddItem1,AddItem2

2.1 dispatchLayoutStep1階段

  1. 尋找錨點,找到Item1

2. 移除螢幕上的Views,放入到mAttachedScrap中 [圖片上傳中…(image-180539-1608907165301-6)]

3. 錨點處從上往下填充 [圖片上傳中…(image-e86354-1608907165301-5)]

4. 錨點處從下往上填充,由上圖可知,上面沒有空間了,不填充 5. 判斷是否還有剩餘的空間,如果有在末尾填充,下面沒空間了,不填充 6. 因為當前是預佈局階段,不填充

2.2 dispatchLayoutStep2階段

  1. 尋找錨點,找到Item1

2. 移除螢幕上的Views,放入到mAttachedScrap中

3. 錨點處從上往下填充,此時將變化後的資料填充到螢幕上,addItem1和addItem2被填充到item1下面

4. 錨點處從下往上填充,由圖可知,沒有空間不填充

5. 判斷是否還有剩餘的空間,由圖可知,沒有空間不填充

6. 當前是layoutStep2階段,會將mAttachScrap的內容,填充到螢幕末尾,ViewHolder5和ViewHolder6對應的ItemView被填充

2.3 dispatchLayoutStep3階段

開始動畫,動畫結束後,item5和item6會被回收掉,此時會被回收到mCachedViews快取池中

大家如果還想了解更多Android 相關的更多知識點,可以加入Android粉絲裙:872206502,裡面記錄了許多的Android 知識點文件,等待你來領取。


相關文章