天天看点

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。