一.相关知识参考
二.异常现象分析
假如我们有一个页面如上所示,整个页面是一个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。