天天看點

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

Android 6.0 View行為變更适配

  • 一.相關知識參考
  • 二.異常現象分析
    • (一).正常情況
    • (二).異常情況
    • (三).異常行為分析
      • 1.為何ScrollView滑動不上去了,而RecyclerView可以自己滑動
      • 2.為何RecyclerView的高度發生改變
      • 3.UNSPECIFIED的size如何影響RecyclerView測量
      • 4.UNSPECIFIED的size為何不為0
        • (1)6.0之前的ScrollView
        • (2)6.0之後的ScrollView
        • (3)sUseZeroUnspecifiedMeasureSpec
  • 三.6.0行為變更
    • (一)6.0之前
    • (二)6.0之後
  • 四.适配方案
    • (一)出現情況分析
    • (二)NestedScrollView代替ScrollView

一.相關知識參考

View繪制流程

RecyclerView源碼分析

NestedScrolling嵌套滾動原理分析

二.異常現象分析

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

假如我們有一個頁面如上所示,整個頁面是一個ScrollView,底部是一個高度為WRAP_CONTENT的RecyclerView

(一).正常情況

正常情況下,RecyclerView的高度如果為WRAP_CONTENT,意味着RecyclerView繪制時會繪制所有的item(即沒有滑動功能),全部展示,此時,即使RecyclerView的嵌套滾動功能是預設開啟的,但是因為其不用滑動,是以也不會有嵌套滾動的現象發生,頁面可以正常滑動

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

(二).異常情況

當對項目做了6.0的适配(targetSdkVersion>=23并更新了support包)後,再進入界面發現如下情況:

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

底部的RecyclerView居然沒有辦法往上滑動(如左圖),而是可以嵌套滑動(如右圖),這是什麼鬼?

(三).異常行為分析

出現了異常情況,我們就要一步一步來跟蹤分析,找到問題的來源

1.為何ScrollView滑動不上去了,而RecyclerView可以自己滑動

上面說過,RecyclerView高度設定為WRAP_CONTENT時,會将所有item布局出來,高度也為所有item的高度和,是以ScrollView可以順利的将RecyclerView滑動上來

而此時,ScrollView滑動不上去了,看似到頭了,并且RecyclerView内部可以滑動了,這是不是說明RecyclerView的高度并不是我們預想的WRAP_CONTENT高度,而是螢幕最底下那段我們能看見的高度呢?如果是這樣的話,那麼就可以解釋了:RecyclerView的高度在測量時因為某些原因,導緻高度并不是所有item的高度和而是螢幕剩下的可見高度,以緻RecyclerView自己還可以滑動,而又因為其嵌套滾動功能預設為開啟的,是以ScrollView滑動不上去,RecyclerView可自己滑動

當然,這隻是我們的猜測,任何猜測都需要認證,于是我們在頁面中打斷點,分别調試一下出問題前後的頁面,RecyclerView的高度是不是有差別:

i.适配前

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

ii.适配後

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

可見,果然是适配前後RecyclerView的高度有變化,導緻異常狀況的出現

2.為何RecyclerView的高度發生改變

我們沒有改動代碼,為啥RecyclerView的高度變化了,難道是因為更新了support包,新的RecyclerView的onMeasure政策變化了?于是我們繼續調試,對比适配前後RecyclerView的onMeasure方法的實作發現并沒有什麼的太大的改動,那是怎麼回事呢?還是一步一步調試onMeasure()來看:

i.适配前

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

ii.适配後

Android 6.0 View行為變更适配一.相關知識參考二.異常現象分析三.6.0行為變更四.适配方案

可見,RecyclerView的onMeasure方法的入參heightSpec不一樣,導緻了後面的處理不一樣

那麼heightSpec是什麼呢?簡單複習一下吧:

View的onMeasure()方法的參數MeasureSpec是 父View對自己的限制+View自己的高度設定 共同決定得出的View的寬高尺寸限制,一個MeasureSpec由size+mode組成,mode是View尺寸模式,比如AT_MOST就是最多多大、EXACTLY就是明确的大小、UNSPECIFIED就是沒有規定大小限制,View自己決定;而size就是那個"最多"或"明确"的值,UNSPECIFIED的size為0(此處留意哦)

知道了MeasureSpec的作用,我們通過MeasureSpec.getMode()和getSize()來看下适配前後這兩個heightSpec代表着什麼:

  • 适配前的0,代表着mode是UNSPECIFIED,size是0
  • 适配後的93,代表着mode是UNSPECIFIED,size是93

可以看出,mode都是UNSPECIFIED,size是不一樣的,也就是說是這個size的不同影響了RecyclerView的高度測量,那麼為何會影響呢,我們接着看

3.UNSPECIFIED的size如何影響RecyclerView測量

我們将RecyclerView的高度設定為WRAP_CONTENT,先來看看更新前的RecyclerView的測量過程

//RecyclerView
protected void onMeasure(int widthSpec, int heightSpec) {
	...
    mLayout.setMeasureSpecs(widthSpec, heightSpec);
    dispatchLayoutStep2();
	...
}
//LayoutManager
void setMeasureSpecs(int wSpec, int hSpec) {
    mWidthSpec = wSpec;
    mHeightSpec = hSpec;
}
//dispatchLayoutStep2
private void dispatchLayoutStep2() {
    ...
    mLayout.onLayoutChildren(mRecycler, mState);
	...
}
//LayoutManager.onLayoutChildren
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    ...
    mLayoutState.mInfinite = mOrientationHelper.getMode() == View.MeasureSpec.UNSPECIFIED;//是否是UNSPECIFIED
	...
    updateLayoutStateToFillStart(mAnchorInfo);//更新目前需要填充的資訊,比如還可填充多少、從哪個item開始等
    fill(recycler, mLayoutState, state, false);//填充(getView/measure/add...)
}
public int getMode() {
    return this.mLayoutManager.getHeightMode();
}
public int getHeightMode() {
    return MeasureSpec.getMode(mHeightSpec);
}
//fill
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
        RecyclerView.State state, boolean stopOnFocusable) {
    ...
    while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {//有可用空間或mInfinite,hasMore是是否添加到最後一個item
        ...
    }
	...
}
           

我們可以看到onLayoutChildren方法,當我們的heightSpec的mode是UNSPECIFIED時,mInfinite為true,那麼在fill填充時,就會一直填充item直到所有item都添加完,此時,我們的RecyclerView是正常的高度

對比再看看更新後的RecyclerView的onMeasure方法,整體流程都沒有怎麼變,但是有兩處決定性的變化:

//1.setMeasureSpecs
void setMeasureSpecs(int wSpec, int hSpec) {
    ...width
    this.mHeight = MeasureSpec.getSize(hSpec);
    this.mHeightMode = MeasureSpec.getMode(hSpec);
    if(this.mHeightMode == 0 && !RecyclerView.ALLOW_SIZE_IN_UNSPECIFIED_SPEC) {
        this.mHeight = 0;
    }
}
//2.mInfinite
this.mLayoutState.mInfinite = this.resolveIsInfinite();
boolean resolveIsInfinite() {
    return this.mOrientationHelper.getMode() == 0 && this.mOrientationHelper.getEnd() == 0;
}
public int getEnd() {
    return this.mLayoutManager.getHeight();
}
public int getHeight() {
    return this.mHeight;
}
           

(1)首先是setMeasureSpecs方法,除了儲存MeasureSpec的size和mode外,還做了一個特出處理,就是mode為UNSPECIFIED時,當ALLOW_SIZE_IN_UNSPECIFIED_SPEC為false時,要把size置為0,什麼?據我們通常的了解,UNSPECIFIED的size本來不就是0麼,難道還有可能是别的值?ALLOW_SIZE_IN_UNSPECIFIED_SPEC也有可能是true?答案是是的,我們來看這個變量的指派

static {
    ALLOW_SIZE_IN_UNSPECIFIED_SPEC = VERSION.SDK_INT >= 23;//6.0開始為true
} 
           

代碼可知,該變量在6.0的機子上為true,也就是說如果UNSPECIFIED的size不是0的話,heightSize就為非0的數,那這個會怎麼影響RecyclerView呢?

(2)第二處變化,相應的就是對這個的處理,更新後的RecyclerView,mInfinite不再是隻依賴mode為UNSPECIFIED了,還依賴了size,隻有size為0的情況下才會和之前的處理一樣,而我們适配後的調試看到,heightSpec的size不是0了,是以,這裡的RecyclerView的就沒有按mInfinite=true的情況布局所有item了,而是依賴了這個size(作為可用空間),導緻高度隻有我們看到的那點

4.UNSPECIFIED的size為何不為0

有人會問,即使ALLOW_SIZE_IN_UNSPECIFIED_SPEC為true,他也隻是不将非0值轉為0而已,而正常來講UNSPECIFIED的size就不該是非0啊,那我們繼續追根究底,看看這個MeasureSpec究竟是怎麼回事

要探究RecyclerView的onMeasure方法,就要從其根View探究,是以我們來看看ScrollView的onMeasure()方法

(1)6.0之前的ScrollView

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ...
    for (int i = 0; i < count; i++) {
        final View child = getChildAt(i);
        if (mMeasureAllChildren || child.getVisibility() != GONE) {
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
            ...
        }
    }
	...
}
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    ...
    final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
            lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
public static int makeMeasureSpec(int size, int mode) {
	...
    return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
           

由此可見,ScrollView裡測量唯一的child-LinearLayout時,給予其得heightSpec直接就是UNSPECIFIED+0,這樣在LinearLayout測量RecyclerView時:

protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    ...
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);
    int size = Math.max(0, specSize - padding);
    ...
    // Parent asked to see how big we want to be
    case MeasureSpec.UNSPECIFIED:
        ...if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size.... find out how
            // big it should be
            resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
        break;
    }
    //noinspection ResourceType
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
           

由于size是0,是以MeasureSpec是UNSPECIFIED+0,是以也是沒問題,用6.0以下手機測試也是沒有問題的,再來看看6.0之後的ScrollView

(2)6.0之後的ScrollView

protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    ...
    final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
            Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
            MeasureSpec.UNSPECIFIED);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
public static int makeSafeMeasureSpec(int size, int mode) {
    if (sUseZeroUnspecifiedMeasureSpec && mode == UNSPECIFIED) {
        return 0;
    }
    return makeMeasureSpec(size, mode);
}
           

6.0之後的ScrollView,在測量child時候,改成了通過MeasureSpec的makeSafeMeasureSpec生成heightSpec給child,該方法與原來的makeMeasureSpec方法相比,多了一個判斷直接傳回0的語句,該語句意思是:如果mode是UNSPECIFIED且sUseZeroUnspecifiedMeasureSpec為true時傳回0(UNSPECIFIED+0),否則傳回size+mode,哎?那當mode是UNSPECIFIED且sUseZeroUnspecifiedMeasureSpec不是true時,不就出現了上述的情況麼,于是乎趕緊調試,發現适配前的代碼在6.0的手機上也是沒有問題的,sUseZeroUnspecifiedMeasureSpec還是true,令人失望。。。

(3)sUseZeroUnspecifiedMeasureSpec

既然sUseZeroUnspecifiedMeasureSpec變量是在6.0開始加入的,但是在6.0的手機上和6.0之前表現一樣,而适配後卻有了問題,是以突然想到,會不會是這個變量是做了targetSdkVersion适配?順着這個思路,我們把同樣的代碼在适配後的版本上再調試一下,果然,這個變量為false了!!!

然後要做的就是确認下這個變量是如何指派的

final int targetSdkVersion = context.getApplicationInfo().targetSdkVersion;
sUseZeroUnspecifiedMeasureSpec = targetSdkVersion < Build.VERSION_CODES.M; 
           

哈哈,果然不出所料,是framework層對這個變量做了版本适配,隻要項目的targetSdkVersion小于23,都執行老的行為,即傳回UNSPECIFIED+0,隻要适配到23之後,就會傳回UNSPECIFIED+size

現在,問題終于找到了。。。是因為android對23的MeasureSpec做了改動,将UNSPECIFIED的size行為改變了,導緻依賴了這種MeasureSpec的一些控件(如RecyclerView)行為出現異常,這個适配具體為何物我們接着往下看

三.6.0行為變更

(一)6.0之前

6.0之前,正如我們的普遍了解,View的MeasureSpec的UNSPECIFIED,作用是測量時不限定子View的尺寸,由子View自己決定,是以這個MeasureSpec的size為0,不起什麼作用

最經常用到的View就是ScrollView了,其在測量子View的時候,給予其的heightSpec也都是UNSPECIFIED的(正如我們上面的代碼看到的),原因也很簡單,因為ScrollView是可滑動的,其子View的高度自然是任意的,有多少都行,反正會通過滑動展示出來

(二)6.0之後

  1. 6.0開始,View裡增加了sUseZeroUnspecifiedMeasureSpec變量,在建構UNSPECIFIED的MeasureSpec時,如果該變量為true,那麼size還是0(正如我們通常了解的那樣),如果是false,那麼size就會建構到MeasureSpec中,将UNSPECIFIED的size設定為非0,就是為了用到它,用它幹什麼呢,Google是這麼解釋的:

    In M and newer, our widgets can pass a “hint” value in the size for scrolling containers know what the expected parent size is going to be, so eg list items can size themselves at 1/3 the size of their container. It breaks older apps though, specifically apps that use some popular open source libraries.

    大概就是子View可以通過這個size,來了解其滾動的父View的尺寸,進而來調整自己的大小,也就是說是用來優化調整可滾動控件内部View的大小的

  2. 不過android對該變量做了API級别适配,在targetSdkVersion為23之前,還是會采用老的行為,從23開始,就會采用新的行為,對于一些用到此功能的View來說,可能會造成一些現實的異常,需要我們做适配
  3. RecyclerView更新後就用到了此功能,如果size不為0,認為滾動父控件還有剩餘可見空間,于是就将高度定為這個高度來測量(不太了解為什麼,感覺并沒有什麼用)
  4. 最可恨的是官方文檔并沒有對這一行為變更做出聲明,如果不是測試時發現還被蒙在鼓裡。。。。Google這波可以的。。。。

四.适配方案

以上是問題的發現和分析過程,當然還有抱怨,但是總歸問題還是要解決的,下面來看看如何對這種情況做适配吧

(一)出現情況分析

上述問題其實是因為做了适配和更新後,RecyclerView支援了UNSPECIFIED的size功能,而ScrollView也開啟了這個功能,就導緻ScrollView的heightSpec的size非0,并傳遞到RecyclerView中,導緻測量高度使用了這個size而不是WRAP_CONTENT應有的行為,是以,我們可以從幾個方面來思考能否解決:

  1. 降級targetSdkVersion或support包。。。pass
  2. 讓RecyclerView固定高度,需求不允許,pass
  3. 想辦法讓傳遞到RecyclerView的heightSpec的size為0

貌似隻有(3)方法可以一想了,而由于ScrollView的onMeasure就是這麼處理heightSpec的,是以看似沒有什麼辦法可以制止,不過恰巧,項目中還有其他地方有類似使用,卻沒有發現問題,一對比發現不同處就是外層使用的不是ScrollView而是NestedScrollView,這個給了我們思路,我們就來看下是否可以用NestedScrollView代替ScrollView

(二)NestedScrollView代替ScrollView

通過使用NestedScrollView和ScrollView時,傳遞到RecyclerView的onMeasure方法的heightSpec發現,其值是不一樣的,做了适配後,NestedScrollView傳遞給RecyclerView的heightSpec也是0,是以我們猜想是NestedScrollView和ScrollView的measure過程不一樣,跟蹤源碼:

protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
    ...
    int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(lp.topMargin + lp.bottomMargin, 0);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
} 
           

果然與ScrollView不同,NestedScrollView給予child的heightSpec的size是child的top/bottomMargin,一般我們這兩個屬性都是0,是以導緻MeasureSpec是UNSPECIFIED+0,是以沒有問題

綜上所述,我們可以使用NestedScrollView來代替嵌套了RecyclerView的ScrollView。