Android View的Measure过程

大家都知道Android View绘制过程包含Measure、Layout、Draw三个主要的过程,这个过程看似简单,但是在应用的时候,很多同学还是不能很好的运用。我希望这篇文章可以把其中的一部分——Measure——讲的更加清晰一点。

Measure过程是对View大小的测量过程,相比其他两个过程,Measure的逻辑更加复杂。Measure过程是RootView调用performTraversals()方法时执行的。我们只关心“看的到”的部分。Measure的过程由View树上的View在onMeasure方法中调用子View的measure方法完成的。有点绕,不过,对于自定义View或者自定义ViewGroup来说,我们需要关心下面的内容:

  • 自定义View:覆写onMeasure方法,计算合适的大小,并将结果通过setMeasuredDimension()方法保存结果。
  • 自定义ViewGroup:除了完成上面所说的工作外,还需要调用子View的measure方法,确保每个子View都正确的测量。

自定义View

先贴一个算是通用的自定义View onMeasure方法实现:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            setMeasuredDimension(measure(widthMeasureSpec, true), measure(heightMeasureSpec, false));
    }

    protected int measure(int measureSpec, boolean WOH) {
        int size = MeasureSpec.getSize(measureSpec);
        int mode = MeasureSpec.getMode(measureSpec);

        int measured;
        if (mode == MeasureSpec.EXACTLY) {
            measured = size;
        } else {
            int measureMinimum = WOH ? getMinimumMeasureWidth() : getMinimumMeasureHeight();

          // 根据内容计算最小值
          // measureMinimum = Math.max(measureMinimum, MIN_CONTENT_SIZE);

            if (WOH) {
                measureMinimum = Math.max(measureMinimum, measureMinimum + getPaddingLeft() + getPaddingRight());
            } else {
                measureMinimum = Math.max(measureMinimum, measureMinimum + getPaddingTop() + getPaddingBottom());
            }
            measured = measureMinimum;
            if (mode == MeasureSpec.AT_MOST) {
                measured = Math.min(measured, size);
            }
        }
        return measured;
    }

上面的代码对于绘制类的自定义View(主要作用在于展示更丰富的图形样式,而不在于布局)比较实用,以上代码计算大小的步骤:

  1. 先取View的期望的最小宽/高,这个最小值由View的内容和设置决定。

    什么是期望的最小宽/高?

    View的大小的应该至少满足内容的显示需求,比如要显示一个10个汉字的View,那么这个View的期望最小宽/高就是“当前文字样式下10个汉字的宽/高 + padding”。

  2. 根据MeasureSpec的模式,确定最终的宽/高。具体逻辑是:

    1. MeasureSpec.EXACTLY:以MeasureSpec的size为准。
    2. MeasureSpec.AT_MOST:取期望和MeasureSpec的size的最小值。
    3. MeasureSpec.UNSPECIFIED:取期望值。

    MeasureSpec的三种模式,下面还会专门说明。所以暂时先不要纠结上面逻辑的理由。

  3. 调用setMeasuredDimension()方法保存结果。

总结

自定义View的Measure过程通用处理方法:首先要确定View需要的(显示内容)最小/合适大小,然后根据MeasureSpec的三种模式确定最终的measured尺寸。

MeasureSpec

之所以没有开始就讲这个类,是因为在讲之前我希望大家先对自定义View的Measure过程有个印象。

定义:MeasureSpec封装了父View对子View的布局需求。所以这个类表示了一种需求需求需求

MeasureSpec由modesize两个部分组成,这两个部分通过位计算储存到一个int类型中,(怎么个结构这里就不细说了,看源码吧),通过getMode()getSize()获取。这两个方法加上构造方法基本就是MeasureSpec的全部API了。

size很好理解,下面把三种mode翻译成普通话:(以下“我”代表父View,“你”代表子View,“size”表示MeasureSpec的size)

  1. MeasureSpec.EXACTLY:我需要你的大小和size一样。
  2. MeasureSpec.AT_MOST:你可以是(根据内容确定的或是)任意大小,但是不能超过size。
  3. MeasureSpec.UNSPECIFIED:你可以是(根据内容确定的或是)任意大小。

以上“我”代表父View,“你”代表子View,“size”表示MeasureSpec的size。

对于子View来说,在onMeasure方法中拿到MeasureSpec之后,就要根据自己的期望和MeasureSpec的需求确定最终大小。而且一般情况下,对于绘制类的自定义View,通过第一节的方法都可以完成Measure过程。

对于父View来说,首先它也是“爷爷View”的子View,所以也是要在onMeasure方法中处理,拿到MeasureSpec之后,不仅要通过自己的期望和“爷爷View”的需求确定大小,还要负责子View的measure过程,它需要(在自己的onMeasure方法中

  • 通过调用子View的View.measure(int, int)方法向子View传递自己的合适的需求。
  • 通过调用子View的View.measure(int, int)方法向子View传递自己的合适的需求
  • 通过调用子View的View.measure(int, int)方法向子View传递自己的合适的需求。
  • 通过调用子View的View.measure(int, int)方法向子View传递自己的合适的需求。

具体父View应该怎么做,请继续往下看。

总结

MeasureSpec表是父View对子View的measure需求。对于自定义View来说,需要在onMeasure中考虑MeasureSpec的值,从而确定最终measured尺寸;对于自定义ViewGroup而言,还需要通过调用子View的View.measure(int, int)方法向子View传递自己的合适的需求

LayoutParams

在进入自定义ViewGroup的Measure过程之前,还需要考虑一个因素。LayoutParams,直译过来就是“布局参数”。上面讲了MeasureSpec是父View传递给子View的需求,而LayoutParams,是子View的布局参数。作用是什么呢?向父View传递需求。两个需求是有差别的,一般对于子View来说,只需要关心MeasureSpec,而LayoutParams是ViewGroup需要考虑的因素。这也是为什么在这里讨论LayoutParams的原因。

LayoutParams(这里指ViewGroup.LayoutParams)相比MeasureSpec更加简单,封装了两个值,width和height。这两个值是开发者对子View大小的约束,对ViewGroup来说,这两个值表示“子View希望ViewGroup如何Measure自己”。觉得晕没关系,继续往下看。

width和height的取值类型一致,共有三种:

  1. MATCH_PARENT (-1): 表示子View希望自己和父控件的width/height一致。一般情况下,会导致onMeasure方法中得到mode为EXACTLY,size为父View宽/高的MeasureSpec。
  2. WRAP_CONTENT (-2): 表示子View希望自己的width和height由自己的内容决定。一般情况下,会导致onMeasure方法中得到mode为AT_MOST,size为父View宽/高的MeasureSpec。
  3. 任意非负整数: 表示子View希望自己的width和height是确切的这个值。一般情况下,会导致onMeasure方法中得到mode为EXACTLY,size为该值的MeasureSpec。

对于ViewGroup来说,LayoutParams的取值表达了子View对自己width和height的期望。

Q: Android View的size不是View的onMeasure确定的吗?为什么要向父View传递期望?

A: 第一节有提到,View的onMeasure方法要根据onMeasure的参数(两个MeasureSpec)最终确定。而在ViewGroup知道View的类型之前,是不知道如何向子View传递MeasureSpec的(ViewGroup也是很讲道理的,MeasureSpec表达了ViewGroup对子View的measure期望,但也不能随便传啊。)。LayoutParams就是ViewGroup确定向子View传递怎样的MeasureSpec的确定因素之一。

ViewGroup根据LayoutParams的width/height和自己的设计(每个特定的ViewGroup类型,比如LinearLayout、FrameLayout)来确定向子类传递的MeasureSpec。

注意:这里说的是【LayoutParams的width/height】而不是【LayoutParams】,因为特定的ViewGroup是可以自己定义属于自己的LayoutParams的,比如LinearLayout.LayoutParams定义了gravity,RelativeLayout定义了toLeftOf、above等特定的布局参数,这些参数在ViewGroup的onMeasure方法内也都会考虑到,但总的来说还是width/height在起作用,尤其是对于大部分自定义ViewGroup来说。举个例子:

RelatIveLayout里面的子View,即便将LayoutParams.width设置为WRAP_CONTENT,但是如果同时将这个子View的alignParentLeft、alignParentRight设置为true的话,子View在onMeasure里面拿到的widthMeasureSpec的mode依然是EXACTLY。

因为RelatIveLayout根据以上两个alignParentLeft/Right属性判断,这个子View是希望MATCH_PARENT的。

总结

LayoutParams表示子View对自己布局(包含measure和layout)的期望,ViewGroup.LayoutParams仅包含width和height两个值。ViewGroup在确定自己对某个子View的MeasureSpec时,一般需要考虑这个子View的LayoutParams参数。

自定义ViewGroup

如果自定义ViewGroup是继承自Framework内的几个Layout类,那么Measure过程大部分情况下不需要关心。因为:

  1. 如果自定义ViewGroup的目的是为了自定义自View的布局规则,那么请直接继承ViewGroup类。
  2. 如果自定义ViewGroup的目的是为了包装业务,那么不需要涉及布局规则的定义,就不需要关心Measure和Layout过程了。
  3. 如果两者都有,那么参见第一条。

这里我们讨论直接继承自ViewGroup的情况。根据刚才的结论,自定义ViewGroup类,自然是要干涉子View的布局逻辑。比如:按比例布局、按某种图形布局、自动折行等等。

自定义ViewGroup就是上面讨论的“父View”,所以它需要:

  • 通过调用子View的View.measure(int, int)方法向子View传递自己的合适的需求

这句话是第6次出现了,这很重要。

你至少应该从中得到以下信息:(以下VG表示“自定义ViewGroup”)

  1. 自定义ViewGroup要在onMeasure方法中调用所有需要布局的子View(有些View,比如不需要显示,可以不测量)的measure方法。
  2. 自定义ViewGroup要向子View传递Measure需求。
  3. 自定义ViewGroup向子View传递的MeasureSpec是代表自己对子View的Measure需求,可以并且一般也都和onMeasure方法的参数(“爷爷View的需求”)的MeasureSpec不同。
  4. 自定义ViewGroup在确定MeasureSpec时,要考虑到子View的LayoutParams参数,从而确定合适的MeasureSpec。

所以自定义ViewGroup的Measure过程的关键就是向子View传递合适的需求,就是对每个子View构建合适的MeasureSpec。

以FrameLayout为例

还是很抽象对吗?让我们来看一下Framework内置的Layout是怎么做的。这里讨论FrameLayout,因为FrameLayout的measure过程相对简单,不至于跑题。当然,这个过程也是可以覆盖刚才我们讨论的整个过程和原理的(事实上,上面讨论的原理是普适性的)。如果你有兴趣可以再继续研究下其他Layout的measure过程,我认同 Read the ** source code. 是最有效最基础的学习方法。

源码就不贴了,太占地方,下面要和大家一起分析的是版本号为23的SDK中的源码,可以打开AndroidStudio对照着看。

onMeasure步骤

  1. 对每个View进行measure,调用ViewGroup.measureChildWithMargin方法。这里传入的MeasureSpec是使用ViewGroup的默认实现计算(注1)得到的。同时记录所有子View的width/height的最大值。

  2. 取子View的最大width/height,考虑自己的minHeight/minWidth、Foreground和Padding,得到新的最大值,作为自己的暂时measuredWidth/measuredHeight。

  3. 结合onMeasure的参数MeasureSpec(父ViewGroup的measure需求),得到最终measuredWidth、measuredHeight,调用setMeasuredDimension方法。(measuredState见注2)

  4. 判断:如果onMeasure参数中有非EXACTLY mode的MeasureSpec(某个方向或者某两个方向尺寸不确定),并且子View的LayoutParams中,有MATCH_PARENT的值。如果不满足,结束onMeasure;否则继续。

  5. 对LayoutParams中有MATCH_PARENT的值的View重新measure。对设置了MATCH_PARENT值的这个方向,使用经第1、2、3步骤处理后最终确定的自己(FrameLayout)的width/height作为size,EXACTLY作为mode的MeasureSpec。

    因为第1步measure子View的时候,没有考虑到1、2、3步骤之后最终确定的自己的大小,所以对于设置了MATCH_PARENT的View,无法给出确切的值,所以要再次调用子View的measure方法,传入正确的值。

注1:ViewGroup.getChildMeasureSpec方法。根据从父ViewGroup获取到的MeasureSpec和子View的LayoutParams,得到合适的MeasureSpec。

注2:关于measuredState,目前应用很狭窄,暂时可以忽略。

它的场景只有一种,涉及到的值常量也只有一个:MEASURED_STATE_TOO_SMALL。表示measure过程中最终确定的size小于measure过程中计算得到的需要的(内容)size。

在上面的步骤中,上述第3步中会调用FrameLayout的View.resolveSizeAndState方法,如果暂时的measuredWidth/measuredHeight小于父ViewGroup提供的MeasureSpec的size并且MeasureSpec的mode为AT_MOST的话,将在最终得到的measuredSize的高8位保存MEASURED_STATE_TOO_SMALL(0x01000000)。

举个例子:

RelativeLayout > FrameLayout > View三层布局,FrameLayout的LayoutParams为WRAP_CONTENT,里面View的宽高设为超过RelativeLayout的值,FrameLayout的measureState就包含MEASURED_STATE_TOO_SMALL。

总结

自定义ViewGroup的measure过程除了要确定自身的measuredSize;同时要向子View传递合适的MeasureSpec,保证子View正确measure,在确定MeasureSpec时,通常要考虑到每个子View的LayoutParams。

实例分析

理论是要结合实践的,下面通过两个实例,来分别分析下自定义View和自定义ViewGroup的measure。

自定义View——FixRatioImageView

FixRatioImageView继承自Image,作用是根据image source的比例确定View的大小,要求有一边为EXCATLY(MATCH_PARENT或固定数值)。开发中会遇到需要固定比例显示的图片资源,有些时候是需要有固定的布局需求的。ImageView其实已经设计了属性adjustViewBounds但是在第三方系统上的兼容性并不好,所以我们通过干涉ImageView的onMeasure方法,实现这个需求。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mRatio == 0) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            return;
        }
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int width = widthSize, height = heightSize;
        if (widthMode != MeasureSpec.EXACTLY && heightMode != MeasureSpec.EXACTLY) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            return;
        } else if (widthMode == MeasureSpec.EXACTLY) {
            height = (int) (width / mRatio + 0.5f);
        } else if (heightMode == MeasureSpec.EXACTLY) {
            width = (int) (height * mRatio + 0.5f);
        }

        setMeasuredDimension(width, height);
    }

mRatio表示固定的宽高比:width/height

上面的代码,先判断是否有宽或者高为EXACTLY,如果都不是,那么使用ImageView的measure逻辑,否则根据mRatio的值,计算另一边的值。有一边为EXCATLY这个前提不能适用所有情况,但是大部分需求都能满足了。

使用方法如下:

<com.kyleduo.androidcustomview.view.FixRatioImageView
            android:id="@+id/fix_ratio"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:scaleType="centerCrop"
            android:src="@drawable/fixratio"
            app:fr_rate="10.56338"/>

自定义ViewGroup——内容左对齐的KeyValueItem

不知道大家有没有做过这种列表:

measure_custom_vg_list

每个列表项分为标题和内容,标题和内容都是左对齐,并且所有内容都要左对齐。但你遇到这种列表你会想到怎么做呢?

如果你能想到通过自定义ViewGroup实现,那一定是极好的。很明显,对于这类需要有特殊布局要求的开发需求,自定义ViewGroup应该是首先想到的方法。

思路是这样的,自定义KeyValueItem,使用静态变量储存左侧Title的最大宽度,然后measure右侧Message的时候,减去左侧宽度,保证正确测量。onLayout里面,右侧Message layout的左边缘在Title最大宽度右侧。

是不是很清晰?嗯,还有个问题需要考虑,因为这个ViewGroup肯定是要复用的,而最大宽度通过静态变量保存,那么多个页面进行复用的时候,就会出现宽度被污染的问题。分析需求,这种列表是在同一个ViewGroup下布局的,也就是说他们有一个公共的父View,那么我们就可以用父View作为Key,保存这个最大宽度,当Key变化时对最大宽度进行清空。直接引用父View当然不行,我们取父View对象的hashCode()作为key。

如果你向我一样这个列表每个Activity中只出现一次,那么直接用Context的hashCode也可以。

这里贴出onMeasure和onLayout的源码:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        if (widthMode != MeasureSpec.EXACTLY) {
            throw new IllegalArgumentException("width must be exactly");
        }

        if (getParent() != null) {
            int parentHash = getParent().hashCode();
            if (parentHash != sMaxKey) {
                sMaxKey = parentHash;
                sMaxTitleWidth = 0;
            }
        }

        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int maxTitleWidth = widthSize / 2 - space * 2;
        int maxChildHeight = 0;
        for (int i = 0; i < getChildCount(); i++) {
            int childWidth = widthSize;

            View child = getChildAt(i);
            if (child == mTitleTv) {
                childWidth = maxTitleWidth;
            } else if (child == mContentTv) {
                childWidth = widthSize - sMaxTitleWidth - space * 3; // |-[title]-space-[content]-space|
            }

            child.measure(MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.AT_MOST), heightMeasureSpec);
            if (child == mTitleTv) {
                int width = child.getMeasuredWidth();
                if (width > sMaxTitleWidth) {
                    sMaxTitleWidth = width;
                }
            }

            int height = child.getMeasuredHeight();
            if (height > maxChildHeight) {
                maxChildHeight = height;
            }
        }

        setMeasuredDimension(widthSize, Math.max(maxChildHeight + space * 2, mMinHeight));
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int centerY = getMeasuredHeight() / 2;
        int left = getPaddingLeft() + space;
        int right = getMeasuredWidth() - space;
        if (mTitleTv != null) {
            mTitleTv.layout(left, centerY - mTitleTv.getMeasuredHeight() / 2, left + mTitleTv.getMeasuredWidth(), centerY + mTitleTv.getMeasuredHeight() / 2);
            left += sMaxTitleWidth + space;
        }
        if (mContentTv != null) {
            mContentTv.layout(left, centerY - mContentTv.getMeasuredHeight() / 2, right, centerY + mContentTv.getMeasuredHeight() / 2);
        }
    }

Q: 代码里并没有使用for循环之类的语句遍历子View?

A: KeyValueListItem并不是一个通用的Layout控件,里面只有两个子View并且是确定的两个子View,而且他们的Layout结构也是确定的,所以可以直接针对这两个对象进行Layout。

如果是自定义类似标签云这种包含平等子View的ViewGroup,那么遍历是必然的。

最终的效果是这样的:

如果查看LayoutBounds,可以看到也是非常干净。

总结

Measure过程是对View尺寸的测量过程,View通过onMeasure方法确定自己的尺寸,ViewGroup在确定自己尺寸的同时,要正确调用子View的measure()方法,让子View正确测量。自定义View和ViewGroup的时候,也是通过onMeasure方法完成measure过程。

这篇文章分别讨论了自定义View、自定义ViewGroup的measure方法,也解释了MeasureSpec和LayoutParams的含义以及他们是如何在View的measure过程中提起作用的;然后分析了FrameLayout的onMeasure方法实现;最后通过两个实例分析在场景用应用了measure过程的技术要点。

关于Android View的measure,就讲到这里吧,希望对大家有帮助。

推荐阅读更多精彩内容