【ViewPager2避坑系列】瞬間暴增數個Fragment

前言

最近我在關注ViewPager2的使用,期間一直基於官方的Demo調試android-viewpager2,今天遇到一個奇葩的問題,捉摸了半天最終找到緣由,原來是Demo中佈局的問題,過後感受有必要分享一下這個過程,一來能夠鞏固View測量的知識,二來但願你們能避開這個坑;android

閱讀指南git

入坑現場

爲了觀察Fragment的生命週期,我事先在CardFragment類中,對生命週期方法進行埋點Log;github

異常發生的操做步驟:bash

橫屏進入CardFragmentActivity或者CardFragmentActivity豎屏切到橫屏,控制檯瞬間打印多個Fragment的生命週期Log,場面讓人驚呆;ide

CardFragmentActivity橫屏下佈局佈局

控制檯Log輸出ui

因爲Log太長,一屏根本截不完,反正就是不少個Fragment經歷了onCreate->onDestory的全部過程;google

操做前,只有Fragment2建立並顯示,理論上旋轉屏幕以後,只有Fragment2銷燬並重建,不會調用其餘Fragment;如今問題發生在了,旋轉以後有一堆Fragment建立而且銷燬,最終保留的也只有Fragment2,這確定是個Bug,雖然發生在一行代碼都沒有改的官方Demo上;spa

初步緣由MATCH_PARENT計算失效

ViewPager2目前只支持ItemView的佈局參數是MATCH_PARENT,就是填充父佈局的效果;因爲ViewPager2是基於RecyclerView,理論上每一個ItemView必定會是MATCH_PARENT,控制一屏只加載一個Item,可是一旦MATCH_PARENT計算失效,那麼ViewPager2基本上就是RecyclerView的效果,瞬間多個Fragment是能夠解釋通的;調試

ViewPager2測量流程

ViewPager2

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    //測量mRecyclerView
    measureChild(mRecyclerView, widthMeasureSpec, heightMeasureSpec);
    int width = mRecyclerView.getMeasuredWidth();
    int height = mRecyclerView.getMeasuredHeight();
    int childState = mRecyclerView.getMeasuredState();
    //寬高計算
    width += getPaddingLeft() + getPaddingRight();
    height += getPaddingTop() + getPaddingBottom();
    //寬高約束
    width = Math.max(width, getSuggestedMinimumWidth());
    height = Math.max(height, getSuggestedMinimumHeight());
    //設置自身高度
    setMeasuredDimension(resolveSizeAndState(width, widthMeasureSpec, childState),
            resolveSizeAndState(height, heightMeasureSpec,
                    childState << MEASURED_HEIGHT_STATE_SHIFT));
}
複製代碼

ViewPager2.onMeasure()優先計算mRecyclerView的尺寸,因此關注的重點轉移到RecyclerView.onMeasure()上,RecyclerView子View的計算和佈局邏輯在LayoutManager中,因此本例子重要看LinearLayoutManager,LayoutManager對子View計算的方法是measureChildWithMargins(),下面看一下measureChildWithMargins()方法的調用棧;

主要分析measureChildWithMargins()代碼:

RecyclerView.LayoutManager

public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    //獲取當前View的Decor(傳統理解的分割線)尺寸
    final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
    widthUsed += insets.left + insets.right;
    heightUsed += insets.top + insets.bottom;
    //獲取寬測量信息
    final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
            getPaddingLeft() + getPaddingRight()
                    + lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
            canScrollHorizontally());
    //獲取高測量信息
    final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
            getPaddingTop() + getPaddingBottom()
                    + lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
           canScrollVertically());
    //若是須要測量,調用child的測量方法
    if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
        child.measure(widthSpec, heightSpec);
    }
}
複製代碼

獲取寬高測量信息的代碼:

public static int getChildMeasureSpec(int parentSize, int parentMode, int padding,
        int childDimension, boolean canScroll) {
    int size = Math.max(0, parentSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    if (canScroll) {
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            switch (parentMode) {
                case MeasureSpec.AT_MOST:
                case MeasureSpec.EXACTLY:
                    resultSize = size;
                    resultMode = parentMode;
                    break;
                case MeasureSpec.UNSPECIFIED:
                    resultSize = 0;
                    resultMode = MeasureSpec.UNSPECIFIED;
                    break;
            }
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            resultSize = 0;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
    } else {
       //省略
    }
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
複製代碼

分析getChildMeasureSpec()方法,因爲ViewPager2強制設置MATCH_PARENT,因此childDimension確定是MATCH_PARENT,那麼resultMode是什麼呢,經過斷點打印輸出,這裏的parentModeMeasureSpec.UNSPECIFIEDMeasureSpec.EXACTLY交替出現;

剛開始一直在關注子View計算流程,發現MeasureSpecMode異常,老是出現MeasureSpec.UNSPECIFIEDMeasureSpec.EXACTLY交替,最後直接打印RecyclerViewonMeasure輸出;

RecyclerView.onMeasure輸出日誌

在豎屏時,widthMeasureMode一直都是1073741824(MATCH_PARENT),可是橫屏狀態下,widthMeasureMode在0(UNSPECIFIED)和MATCH_PARENT中徘徊;對比差異就是MeasureMode = UNSPECIFIED,因此問題應該出在MeasureMode = UNSPECIFIED上;

如何產生的UNSPECIFIED

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:background="#FFFFFF">
    <include layout="@layout/controls" />
    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/view_pager"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />

</LinearLayout>
複製代碼

總體佈局是LinearLayout,在佈局裏面,ViewPager2 layout_width="0dp" layout_weight="1",多是width=0dp && weight=1形成,扒一扒LinearLayout測量代碼邏輯;

LinearLayout

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (mOrientation == VERTICAL) {
        measureVertical(widthMeasureSpec, heightMeasureSpec);
    } else {
        measureHorizontal(widthMeasureSpec, heightMeasureSpec);
    }
}
複製代碼

LinearLayoutonMeasure()方法分爲豎直方向和水平方向,咱們這裏選擇measureHorizontal()入手;

measureHorizontal()方法中經過判斷lp.width == 0 && lp.weight > 0判定是否須要過渡加載useExcessSpace,下面的過渡加載就是採用UNSPECIFIED方式測量;

爲什麼還要執行一次MATCH_PARENT測量

這是因爲LinearLayoutmeasureHorizontal()針對過渡加載useExcessSpace的佈局,會進行兩次測量,第二次就會傳遞實際的測量模式;

爲什麼UNSPECIFIED模式下,MATCH_PARENT會失效

咱們暫時只討論FrameLayout的狀況,若是FrameLayout的父佈局給該FrameLayout的測量模式是UNSPECIFIED,尺寸是自身的具體寬高,並且該FrameLayoutLayoutParamsMATCH_PARENT,試問FrameLayout能測量出準確的MATCH_PARENT尺寸嗎?

FrameLayout

FrameLayout會測量全部可見View的尺寸,而後算出最大的尺寸maxWidthmaxHeight,自身尺寸的測量調用setMeasuredDimension()方法,每一個Dimension的設置調用resolveSizeAndState(maxWidth, widthMeasureSpec, childState)方法;

resolveSizeAndState()

public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
    //來自父佈局建議的模式和尺寸
    final int specMode = MeasureSpec.getMode(measureSpec);
    final int specSize = MeasureSpec.getSize(measureSpec);
    final int result;
    switch (specMode) {//父佈局建議的模式
        case MeasureSpec.AT_MOST:
            if (specSize < size) {
                result = specSize | MEASURED_STATE_TOO_SMALL;
            } else {
                result = size;
            }
            break;
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        case MeasureSpec.UNSPECIFIED://在這裏
        default:
            result = size;//這個size就是傳入的size
    }
    return result | (childMeasuredState & MEASURED_STATE_MASK);
}
複製代碼

分析resolveSizeAndState(),若是measureSpecspecMode=UNSPECIFIED,結果返回傳入的size,在FrameLayout中是maxWidthmaxHeight,而並非parent給予的specSize;

爲什麼總體會測量兩遍

這是因爲FrameLayout針對MATCH_PARENT的佈局,會進行二次測量,第一次測量爲了找到最大尺寸maxsize,二次測量把用maxsize重新計算MATCH_PARENT的子View;

避免入坑

上訴講解就是爲了說明,UNSPECIFIED會影響MATCH_PARENT的測量,至少在FrameLayout上是影響的,FrameLayout會採起子View的最大尺寸,一旦失去MATCH_PARENT的意義,ViewPager2就失去了ItemView一屏顯示一個的特性,因此會出現開頭說的瞬間暴增多個Fragment現象;

因爲ViewPager2配合Fragment使用時,根佈局是FrameLayout這個沒法改變,解決辦法就是不容許出現跟滑動方向相同的維度測量上,出現UNSPECIFIED;

若是父佈局是LinearLayout,橫向滑動時要避免layout_width="0dp"和layout_weight="1",縱向滑動時要避免layout_height="0dp"和layout_weight="1",代碼的解決方案很簡單,去掉layout_weight="1",吧layout_width設置成match_parent;

總結

注意ViewPager2配合Fragment使用時,一旦發現Fragment瞬間暴增的狀況,多是Item尺寸測量的不對,形成這個緣由要優先想到UNSPECIFIED,·若是用的LinearLayout多是layout_weight="1"的緣由,同理,RecyclerView+PagerSnapHelper+match_parent實現一屏一個Item的方案,也存在這個風險;

相關文章
相關標籤/搜索