1. 程式人生 > 其它 >深入探索RecyclerView 之抽絲剝繭 LayoutManager原理及使用

深入探索RecyclerView 之抽絲剝繭 LayoutManager原理及使用

前言

LayoutManagerRecyclerView中的重要一環,使用LayoutManager就跟玩捏臉蛋的遊戲一樣,即使好看的五官(好看的子View)都具備了,也不一定能捏出漂亮的臉蛋,好在RecyclerView為我們提供了預設的模板:LinearLayoutManagerGridLayoutManagerStaggeredGridLayoutManager

說來慚愧,如果不是看了GridLayoutManager的原始碼,我還真不知道GridLayoutManager竟然可以這麼使用,圖片來自網路:

不過呢,今天我們講解的原始碼不是來自GridLayoutManager,而是線性佈局LinearLayoutManager

GridLayoutManager也是繼承自LinearLayoutManager),分析完原始碼,我還將給大家帶來實戰,完成以下的效果:

時間軸的效果來自TimeLine,自己稍微處理了一下,現在開始進入正題。

程式碼地址:https://github.com/mCyp/Orient-Ui

目錄

一、原始碼分析

本著認真負責的精神,我把RecyclerView中用到LayoutManager的地方大致看了一遍,發現其負責的主要業務:

  • 回收和複用子View(當然,這會交給Recyler處理)。
  • 測量和佈局子View。
  • 關於滑動的處理。

回收和複用子View顯然不是LayoutManager實際完成的,不過,子View

的新增和刪除都是LayoutManager通知的,除此以外,滑動處理的本質還是對子View進行管理,所以,本文要討論的只有測量和佈局子View的。

測量和佈局子View發生在RecyclerView三大工作流程,又...又回到了最初的起點?這是我們在上篇討論過的,如果不涉及到LayoutManager的知識,我們將一筆帶過即可。

1. 自動測量機制

RecyclerView#onMeasure方法中,LayoutManager是否支援自動測量會走不同的流程:

protected void onMeasure(int widthSpec, int heightSpec) {
    // ...
    if (mLayout.isAutoMeasureEnabled()) {
        final int widthMode = MeasureSpec.getMode(widthSpec);
        final int heightMode = MeasureSpec.getMode(heightSpec);
        // 未複寫的情況下預設呼叫RecyclerView#defaultOnMeasure方法
        mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
        final Boolean measureSpecModeIsExactly =
                            widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY;
        // 長和寬的MeasureSpec都為EXACTLY的情況下會return
        if (measureSpecModeIsExactly || mAdapter == null) {
            return;
        }
        if (mState.mLayoutStep == State.STEP_START) {
            dispatchLayoutStep1();
        }
        // 1\. 計算寬度和長度等
        mLayout.setMeasureSpecs(widthSpec, heightSpec);
        mState.mIsMeasuring = true;
        // 2\. 佈局子View
        dispatchLayoutStep2();
        // 3\. 測量子View的寬和高,並再次測量父佈局
        mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
        if (mLayout.shouldMeasureTwice()) {
            // 再走一遍1,2,3
        }
    } else {
        // ...
        mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
        // ....
    }
}

從程式碼上來看,使用自動測量機制需要具備:

  1. RecyclerView佈局的長和寬的SpecMode不能是MeasureSpec.EXACTLY(大概率指的是佈局中RecyclerView長或寬中有WrapContent)。
  2. RecyclerView設定的LayoutMangerisAutoMeasureEnabled返回為true

當設定自動測量機制的時候,我們的流程如下:

從上圖可以看出,是否使用自動測量機制帶來的差距還是挺明顯的,使用自動測量機制需要經歷那麼多流程,反正都要使用LayoutManager#onMeasure方法,還不如不使用測量機制呢!

顯然,這種想法是不對的,因為官方是這麼說的,如果不使用自動測量機制,需要在自定義LayoutManager過程中複寫LayoutManager#onMeasure方法,所以呢,這個方法應該是包括自動測量機制的全部過程,包括:測量父佈局-佈置子View-重新測量子View-重新測量父佈局,而使用自動測量機制是不需要複寫這個方法的,該方法預設測量父佈局。

需要提及的是,我們平時使用的三大LayoutManager都開啟了自動測量機制。

2. onLayoutChildren

即使RecyclerViewonMeasure方法中逃過了佈局子View,那麼在onLayout中也不可避免,在上一篇部落格中,我們瞭解到RecyclerView通過LayoutManager#onLayoutChildren方法實現給子View佈局,我們以LinearLayoutManager為例,看看其中的奧祕。

在正式開始之前,我們先看看LinearLayoutManager中幾個重要的類:

重要的類 解釋
LinearLayoutManager 這個大家都懂,線性佈局。
AnchorInfo 繪製子View的時候,記錄其位置、偏移量、方向等基礎資訊。
LayoutChunkResult 載入子View結果情況的記錄,比如已經填充的子View的數量。
LayoutState 當前載入的狀態記錄,比如當前繪製的偏移量,螢幕還剩餘多少空間等

直接看最重要的LinearLayoutManager#onLayoutChildren,程式碼被我一刪再刪後如下:

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //... 省略的程式碼為:資料為0的情況下移除所有的子View,將子View加入到快取
    // 第一步:初始化LayoutState 配置LayoutState引數
    ensureLayoutState();
    mLayoutState.mRecycle = false;
    // ... 
    // 第二步:尋找焦點子View
    final View focused = getFocusedChild();
    // ...
    // 第三步:移除介面中已經存在的子View,並放入快取
    detachAndScrapAttachedViews(recycler);
    if (mAnchorInfo.mLayoutFromEnd) {
        // ...
    } else {
        // 第四步:更新LayoutSatete,填充子View
        // 填充也分為兩步:1.從錨點處向結束方向填充 2.從錨點處向開始方向填充

        // fill towards end 往結束方向填充子View
        // 更新LayoutState
        updateLayoutStateToFillEnd(mAnchorInfo);
        fill(recycler, mLayoutState, state, false);
        //...
        // fill towards start 往開始方向填充子View
        // 更新LayoutState等資訊
        updateLayoutStateToFillStart(mAnchorInfo);
        fill(recycler, mLayoutState, state, false);
        if (mLayoutState.mAvailable > 0) {
            // 如果還有剩餘空間
            updateLayoutStateToFillEnd(lastElement, endOffset);
            fill(recycler, mLayoutState, state, false);
            // ...
        }
    }
    // ...
    // 第五步:整理一些引數,以及做一下結束處理
    // 不是預佈局的狀態下結束給子View佈局,否則,重置錨點資訊
    if (!state.isPreLayout()) {
        mOrientationHelper.onLayoutComplete();
    } else {
        mAnchorInfo.reset();
    }
    //...
}

整個onLayoutChildren可以分為如下五個過程:

  • 第一步:建立LayoutState
  • 第二步:獲取焦點子View
  • 第三步:移除檢視中已經存在的View,回收ViewHolder
  • 第四步:填充子View
  • 第五步:填充結束後的處理
2.1 第一步、第二步

第一步是建立LayoutState,第二步是獲取螢幕中的焦點子View,程式碼比較簡單,感興趣的同學們可以自己查詢。

2.2 第三步

在填充子View前,如果當前已經存在子View並將繼續存在的時候,會先從螢幕中暫時移除,將ViewHolder暫存在Recycler的一級快取mAttachedScrap中:

/**
 * Temporarily detach and scrap all currently attached child views. Views will be scrapped
 * into the given Recycler. The Recycler may prefer to reuse scrap views before
 * other views that were previously recycled.
 *
 * @param recycler Recycler to scrap views into
 */
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);
    }
}

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()) {
        // 無效的ViewHolder會被新增進RecyclerPool
        removeViewAt(index);
        recycler.recycleViewHolderInternal(viewHolder);
    } else {
        // 新增進一級快取
        detachViewAt(index);
        recycler.scrapView(view);
        mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
    }
}

上面的英文註釋其實就是我開始所說的,暫時儲存被detachViewHolder,至於Recycler如何儲存,我們在上一篇部落格中已經討論過,這裡不再贅述。

2.3 第四步

最複雜的就是子View的填充過程,回到LinearLayoutManager#onLayoutChildren方法,我們假設mAnchorInfo.mLayoutFromEndfalse,那麼LinearLayoutManager會先從錨點處往下填充,直至填滿,往下填充前,會先更新LayoutState

private void updateLayoutStateToFillEnd(AnchorInfo anchorInfo) {
    updateLayoutStateToFillEnd(anchorInfo.mPosition, anchorInfo.mCoordinate);
}

private void updateLayoutStateToFillEnd(int itemPosition, int offset) {
    // mAvailable:可以填充的距離
    mLayoutState.mAvailable = mOrientationHelper.getEndAfterPadding() - offset;
    // 填充方向
    mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD :
                    LayoutState.ITEM_DIRECTION_TAIL;
    // 當前位置
    mLayoutState.mCurrentPosition = itemPosition;
    mLayoutState.mLayoutDirection = LayoutState.LAYOUT_END;
    // 當前位置的偏移量
    mLayoutState.mOffset = offset;
    mLayoutState.mScrollingOffset = LayoutState.SCROLLING_OFFSET_NaN;
}

更新完LayoutState以後,就是子View的真實填充過程LinearLayoutManager#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) {
        // ...
        // 滑動發生時回收ViewHolder
        recycleByLayoutState(recycler, layoutState);
    }
    int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
    LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
    // 核心載入過程
    while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
        //...
        layoutChunk(recycler, state, layoutState, layoutChunkResult);
        //... 省略的是:載入一個ViewHolder之後處理狀態資訊
    }
    // 返回消費的空間
    return start - layoutState.mAvailable;
}

最核心的就是while迴圈裡面的LinearLayoutManager#layoutChunk,最後來看一下該方法如何實現的:

void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
    // 利用快取策略獲取 與Recycler相關
    View view = layoutState.next(recycler);
    // 新增或者刪除 最後會通知父佈局新增或者移除子View
    if (layoutState.mScrapList == null) {
        if (mShouldReverseLayout == (layoutState.mLayoutDirection
                                    == LayoutState.LAYOUT_START)) {
            addView(view);
        } else {
            addView(view, 0);
        }
    } else {
        if (mShouldReverseLayout == (layoutState.mLayoutDirection
                                    == LayoutState.LAYOUT_START)) {
            addDisappearingView(view);
        } else {
            addDisappearingView(view, 0);
        }
    }
    // 測量子View
    measureChildWithMargins(view, 0, 0);
    // 佈局子View
    layoutDecoratedWithMargins(view, left, top, right, bottom);
    // ... 設定LayoutChunkResult引數
}

首先,View view = layoutState.next(recycler);就是我們在上一節中討論利用快取Recycler去獲取ViewHolder,接著獲取ViewHolder中繫結的子View,給它新增進父佈局RecyclerView,然後給子View測量一下寬高,最後,有了寬高資訊,給它放置到具體的位置就完事了,過程清晰明瞭。

回到上個方法LinearLayoutManager#fill,在While迴圈並且有資料的情況下,不斷的將子View填充至RecyclerView中,直至該方向填滿。

再回到一開始的LinearLayoutManager#onLayoutChildren方法,除了呼叫了我們第四步一開始介紹的LinearLayoutManager#updateLayoutStateToFillEnd,還呼叫了LinearLayoutManager#updateLayoutStateToFillStart,所以從整體上來看,它是先填充錨點至結束的方向,再填充錨點至開始的方向(不絕對),如果用一圖表示,我覺得可以是這樣:

先從錨點向下填充,再從錨點向上填充,不過,也有可能是先向上,再向下,由一些引數決定。

第五步

第五步就是對之前的子View的填充結果做一些處理,不做過多介紹。

二、實戰

看了VivianTimeLine,你可能會這麼吐槽,人家的庫藉助StaggeredGridLayoutManager就可以實現時間軸,為何還要多此一舉,使用我的TwoSideLayoutManager(我給實現的佈局方式起名叫TwoSideLayoutManager)呢?因為使用瀑布流StaggeredGridLayoutManager想要在時間軸上實現子View平均分佈的效果還是比較困難的,但是,使用TwoSideLayoutManager實現起來就簡單多了。

那麼我們如何實現RecyclerView的兩側佈局呢?一張圖來開啟思路:

顯然,TwoSideLayoutManager的佈局實現可以利用LinearLayoutManager的實現方式,僅需要修改新增子View以後的測量邏輯和佈局邏輯即可。

上面我們提到過,新增子View,給子View測量,佈局都在LinearLayoutManager#layoutChunk中實現,那我們完全可以照搬LinearLayoutManager的填充邏輯,稍微改幾處程式碼,限於篇幅,我們就看一下核心方法TwoSideLayoutManager#layoutChunk

private void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
                             LayoutState layoutState, LayoutChunkResult result) {
    View view = layoutState.next(recycler);
    if (view == null) {
        // 沒有更多的資料用來生成子View
        result.mFinished = true;
        return;
    }
    RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
    // 新增進RecyclerView
    if (layoutState.mLayoutDirection != LayoutState.LAYOUT_START) {
        addView(view);
    } else {
        addView(view, 0);
    }
    // 第一遍測量子View
    measureChild(view);
    // 佈局子View
    layoutChild(view, result, params, layoutState, state);
    // Consume the available space if the view is not removed OR changed
    if (params.isItemRemoved() || params.isItemChanged()) {
        result.mIgnoreConsumed = true;
    }
    result.mFocusable = view.hasFocusable();
}

整體邏輯在註釋中已經寫得很清楚了,挨個看一下主要方法。

1. measureChild

測量子View

private void measureChild(View view) {
    final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams();
    int verticalUsed = lp.bottomMargin + lp.topMargin;
    int horizontalUsed = lp.leftMargin + lp.rightMargin;
    // 設定測量的長度為可用空間的一半
    final int availableSpace = (getWidth() - (getPaddingLeft() + getPaddingRight())) / 2;
    int widthSpec = getChildMeasureSpec(availableSpace, View.MeasureSpec.EXACTLY
                    , horizontalUsed, lp.width, true);
    int heightSpec = getChildMeasureSpec(mOrientationHelper.getTotalSpace(), getHeightMode(),
                    verticalUsed, lp.height, true);
    measureChildWithDecorationsAndMargin(view, widthSpec, heightSpec, false);
}

高度的使用方式跟LinearLayoutManager一樣,寬度控制在螢幕可用空間的一半。

2. layoutChild

佈局子View

private void layoutChild(View view, LayoutChunkResult result
            , RecyclerView.LayoutParams params, LayoutState layoutState, RecyclerView.State state) {
    final int size = mOrientationHelper.getDecoratedMeasurement(view);
    final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams();
    result.mConsumed = size;
    int left, top, right, bottom;
    int num = params.getViewAdapterPosition() % 2;
    // 根據位置 奇偶位來進行佈局
    // 如果起始位置為左側,那麼偶數位為左側,奇數位為右側
    if (isLayoutRTL()) {
        if (num == mStartSide) {
            right = (getWidth() - getPaddingRight()) / 2;
            left = right - mOrientationHelper.getDecoratedMeasurementInOther(view);
        } else {
            right = getWidth() - getPaddingRight();
            left = right - mOrientationHelper.getDecoratedMeasurementInOther(view) - (getWidth() - getPaddingRight()) / 2;
        }
    } else {
        if (num == mStartSide) {
            left = getPaddingLeft();
            right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
        } else {
            left = getPaddingLeft() + (getWidth() - getPaddingRight()) / 2;
            right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
        }
    }
    if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
        bottom = layoutState.mOffset;
        top = layoutState.mOffset - result.mConsumed;
    } else {
        top = layoutState.mOffset;
        bottom = layoutState.mOffset + result.mConsumed;
        if (mLayoutState.mCurrentPosition == state.getItemCount() && lastViewOffset != 0) {
            lp.setMargins(lp.leftMargin, lp.topMargin, lp.rightMargin, lp.bottomMargin + lastViewOffset);
            view.setLayoutParams(lp);
            bottom += lastViewOffset;
        }
    }
    layoutDecoratedWithMargins(view, left, top, right, bottom);
}

public void layoutDecoratedWithMargins(@NonNull View child, int left, int top, ![總結.png](https://upload-images.jianshu.io/upload_images/9271486-9440574ea525a11a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
int right, int bottom) {
    RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams)child.getLayoutParams();
    Rect insets = lp.mDecorInsets;
    child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin, right - insets.right - lp.rightMargin, bottom - insets.bottom - lp.bottomMargin);
}

給子View測量完寬高之後,根據奇偶位初始設定的一側mStartSide佈局子View。如果需要顯示時間軸的結束節點,那麼需要在建立TwoSideLayoutManager物件的時候設定lastViewOffset,預留最後位置的空間,不過,需要注意的是,如果設定了時間軸的結束節點,那麼,最後一個子View最好還是不要回收,不然,最後一個子View回收給其他資料使用的時候還得處理Margin。只要在回收的時候稍稍處理就行了,具體的程式碼不再貼出了。

三、總結

寫這個佈局花的時間還挺多的,說明自己需要提升的地方還很多,有的時候程式碼雖然能看懂,自己卻不一定能寫出來。水平有限,難免有誤,歡迎指出喲。

文末

感謝大家關注我,分享Android乾貨,交流Android技術。
對文章有何見解,或者有何技術問題,都可以在評論區一起留言討論,都會看的哦~
Android架構師系統進階學習路線、58萬字學習筆記、教學視訊免費分享地址:我的GitHub