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嵌套滾動原理分析
二.異常現象分析
假如我們有一個頁面如上所示,整個頁面是一個ScrollView,底部是一個高度為WRAP_CONTENT的RecyclerView
(一).正常情況
正常情況下,RecyclerView的高度如果為WRAP_CONTENT,意味着RecyclerView繪制時會繪制所有的item(即沒有滑動功能),全部展示,此時,即使RecyclerView的嵌套滾動功能是預設開啟的,但是因為其不用滑動,是以也不會有嵌套滾動的現象發生,頁面可以正常滑動
(二).異常情況
當對項目做了6.0的适配(targetSdkVersion>=23并更新了support包)後,再進入界面發現如下情況:
底部的RecyclerView居然沒有辦法往上滑動(如左圖),而是可以嵌套滑動(如右圖),這是什麼鬼?
(三).異常行為分析
出現了異常情況,我們就要一步一步來跟蹤分析,找到問題的來源
1.為何ScrollView滑動不上去了,而RecyclerView可以自己滑動
上面說過,RecyclerView高度設定為WRAP_CONTENT時,會将所有item布局出來,高度也為所有item的高度和,是以ScrollView可以順利的将RecyclerView滑動上來
而此時,ScrollView滑動不上去了,看似到頭了,并且RecyclerView内部可以滑動了,這是不是說明RecyclerView的高度并不是我們預想的WRAP_CONTENT高度,而是螢幕最底下那段我們能看見的高度呢?如果是這樣的話,那麼就可以解釋了:RecyclerView的高度在測量時因為某些原因,導緻高度并不是所有item的高度和而是螢幕剩下的可見高度,以緻RecyclerView自己還可以滑動,而又因為其嵌套滾動功能預設為開啟的,是以ScrollView滑動不上去,RecyclerView可自己滑動
當然,這隻是我們的猜測,任何猜測都需要認證,于是我們在頁面中打斷點,分别調試一下出問題前後的頁面,RecyclerView的高度是不是有差別:
i.适配前
ii.适配後
可見,果然是适配前後RecyclerView的高度有變化,導緻異常狀況的出現
2.為何RecyclerView的高度發生改變
我們沒有改動代碼,為啥RecyclerView的高度變化了,難道是因為更新了support包,新的RecyclerView的onMeasure政策變化了?于是我們繼續調試,對比适配前後RecyclerView的onMeasure方法的實作發現并沒有什麼的太大的改動,那是怎麼回事呢?還是一步一步調試onMeasure()來看:
i.适配前
ii.适配後
可見,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之後
-
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的大小的
- 不過android對該變量做了API級别适配,在targetSdkVersion為23之前,還是會采用老的行為,從23開始,就會采用新的行為,對于一些用到此功能的View來說,可能會造成一些現實的異常,需要我們做适配
- RecyclerView更新後就用到了此功能,如果size不為0,認為滾動父控件還有剩餘可見空間,于是就将高度定為這個高度來測量(不太了解為什麼,感覺并沒有什麼用)
- 最可恨的是官方文檔并沒有對這一行為變更做出聲明,如果不是測試時發現還被蒙在鼓裡。。。。Google這波可以的。。。。
四.适配方案
以上是問題的發現和分析過程,當然還有抱怨,但是總歸問題還是要解決的,下面來看看如何對這種情況做适配吧
(一)出現情況分析
上述問題其實是因為做了适配和更新後,RecyclerView支援了UNSPECIFIED的size功能,而ScrollView也開啟了這個功能,就導緻ScrollView的heightSpec的size非0,并傳遞到RecyclerView中,導緻測量高度使用了這個size而不是WRAP_CONTENT應有的行為,是以,我們可以從幾個方面來思考能否解決:
- 降級targetSdkVersion或support包。。。pass
- 讓RecyclerView固定高度,需求不允許,pass
- 想辦法讓傳遞到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。