【View系列】震惊!!MeasureSpec.UNSPECIFIED是这么用的?!

2,542 阅读5分钟

前言

上一篇文章我们探索了一下View测量流程的源码,但是整篇文章都没提MeasureSpec.UNSPECIFIED,然后我在文章末给大家留了一个问题,不知道大家是否有自己去尝试过,评论里面也没有人和我互动聊聊...所以我今天索性就把答案和大家分享一下,顺便我们也把现象及原因也给捋一遍。

回顾

先给大家回忆一下留下的问题,给出两段代码:

场景①

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

   <View
       android:layout_width="360dp"
       android:layout_height="400dp"
       android:layout_gravity="center"
       android:background="@color/red" />

</ScrollView>

场景②

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

   <FrameLayout
       android:layout_width="wrap_content"
       android:layout_height="wrap_content">
      
      <View
          android:layout_width="360dp"
          android:layout_height="400dp"
          android:layout_gravity="center"
          android:background="@color/red" />
      
   </FrameLayout>
   
</ScrollView>

场景①展示效果如下:

single_view.png

场景②展示效果如下:

linearlayout_view.png

是的,你没看错,在场景①的时候,咱们给View设置的400dp没起作用,显示的空白页面,在场景②的时候就正常显示了。那到底肿么回事呢?找问题的原因最快的途径是从源码中找答案。

先看一下ScrollView这个类:

ScrollView

//ScrollView.java
//1
public class ScrollView extends FrameLayout {
		...
    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //2
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
      	...
    }
}

标注1处我们可以知道ScrollView继承自FrameLayout,

标注2处的onMeasure()方法也是调用的FrameLayout的onMeasure()方法。

从我写的上篇文章我们知道:FrameLayout会先遍历测量每个child,并传入了widthMeasureSpec和heightMeasureSpec,测量方法如下:

protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

  	//1
    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);
    //2
    final int childHeightMeasureSpec=getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

标注1和标注2出调的都是方法getChildMeasureSpec(),再次帮大家回顾下**getChildMeasureSpec()**方法计算子view的MeasureSpec的规则:

工作簿1.png

由上面的规则可知,如果FrameLayout里面包含一个View 宽高都是固定值的话,那么计算出的child的MeasureSpec应分别是widthMeasureSpec(size=360dp,mode=EXACTLY)heightMeasureSpec(size=400dp,mode=EXACTLY),然后再将这俩measureSpec传递给View的measure方法,理应得出来View测量结果是宽高分别为360dp和400dp呀,那为什么没显示出来呢?

别着急,我们可以在Android Studio里面查看一下ScrollView的所有方法,瞅瞅还有啥猫腻没有:

image-20210423153612204.png

我去。。这是什么,ScrollView竟然重写了measureChildWithMargins方法,点进去瞅瞅:

@Override
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

    //1
    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);
    final int usedTotal = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin + heightUsed;
    //2
    final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
            Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
            MeasureSpec.UNSPECIFIED);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
  • 标注1出没啥特别的,正常构建childWidthMeasureSpec
  • 重要是标注2处,在构建childHeightMeasureSpec的时候,ScrollView将mode指定成了MeasureSpec.UNSPECIFIED

标记成MeasureSpec.UNSPECIFIED这个模式的时候再传给子view,子view在这种mode下是如何计算自己的size呢:

//View.java

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);

    switch (specMode) {
      //1  
      case MeasureSpec.UNSPECIFIED:
        result = size;
        break;
      ...
    }
    return result;
}

标注1处说明heightMeasureSpec在MeasureSpec.UNSPECIFIED模式下,取得是size,那size是什么呢:

protected int getSuggestedMinimumHeight() {
    return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());

}
//Drawable.java
public int getMinimumHeight() {
    final int intrinsicHeight = getIntrinsicHeight();
    return intrinsicHeight > 0 ? intrinsicHeight : 0;
}

上述代码就是size的取值:取的是XML设置的min_heightbackground Drawable的IntrinsicHeight的较大值。因为我们XML没设置相关属性 所以 getSuggestedMinimumHeight()方法返回的是0,所以我们看到场景① View的高度实际是0,显示空白。那场景②显示正常的原因应该很好找了,直接查看FramLayout相关源码:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		...
		//1
    setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState), resolveSizeAndState(maxHeight, heightMeasureSpec,
                    childState << MEASURED_HEIGHT_STATE_SHIFT));
}

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) {
       ...
       //2
       case MeasureSpec.UNSPECIFIED:
       default:
         result = size;
     }
     return result | (childMeasuredState & MEASURED_STATE_MASK);
}                   
  • 标注1处在设置FrameLayout大小的时候调用resolveSizeAndState方法
  • resolveSizeAndState方法在标注2处返回了出入的size,这个size是maxHeight,maxHeight是FrameLayout在测量完所有子view/ViewGroup后取得最大高度,所以这个maxHeight就是400dp, 所以场景②能显示正常。

上面的几段代码把场景① 和场景②出现的缘由介绍清楚了,那么ScrollView/NestedScrollView为什么这么做呢?

根本原因

查阅ScrollView/NestedScrollView相关源码后我个人理解的原因是:

ScrollView的滑动是通过scrollBy实现的,但是滑动总得有个范围,范围的计算规则是拿到子view的真实高度后减去ScrollView的高度,就能拿到ScrollY的范围了。正常测量模式下,子view最多和父view大小一致,我们根本拿不到真实宽高,这个时候MeasureSpec.UNSPECIFIED测量模式作用就显现出来了,android提供的很多控件FrameLayout、LinearLayout等都能在这个测量模式下能测量出真实高度,在这些ViewGroup设置宽高也就是调用setMeasuredDimension方法时候,都是通过resolveSizeAndState方法来计算返回measureSpec的,个人认为算是一种共识,也算是给开发者自定义View提供另外一种可能性吧。

NestedScrollView包裹RecyclerView的坑

聊完根本原因,这里给大家个避坑指南:之前我见过挺多同学在做稍微复杂点的列表布局,会用NestedScrollView包裹RecyclerView实现一些上面添个头布局之类的,从上面的原因分析上我们可以知道RecyclerView这种情况下也会直接测量出真实高度,这会导致RecyclerView的所有item直接全部走了onBindViewholder()方法,RecyclerView的复用机制失效,如果数据量大的时候页面会很卡。。。有兴趣的同学可以自行打印日志查看或者查阅RecyclerView相关源码。

结论

尽量不要使用NestedScrollView包裹RecyclerView尽量不要使用NestedScrollView包裹RecyclerView尽量不要使用NestedScrollView包裹RecyclerView 重要的话说三遍,如果列表复杂的话,推荐使用RecyclerView的多ItemType或者MergeAdapter之类,没方案的你来找我,我给你提供更多方案,哈哈哈😂...

最后的最后又到了推荐环节欢迎大家使用我写的库 超好用的高亮引导库 Github地址如下:github.com/hyy920109/H…

补充

有个小伙伴来找我,最后打印出来日志,是NestedScrollView嵌套RecyclerView有问题,ScrollView嵌套RecyclerView是没有问题的(但是有滑动冲突方面的问题),最后查了一下原因:NestedScrollView和 ScrollView虽然都是重写了measureChildWithMargins,但是这俩个类重写规则略微有点不一样,不过整体来说还是不推荐嵌套方案。这个话题到此就打住了,我的结论只是作为一个参考,主要是想让大家熟悉下测量流程,自己可以多试试写写viewGroup,会更加深印象的。