大家都知道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(主要作用在于展示更丰富的图形样式,而不在于布局)比较实用,以上代码计算大小的步骤:
-
先取View的期望的最小宽/高,这个最小值由View的内容和设置决定。
什么是期望的最小宽/高?
View的大小的应该至少满足内容的显示需求,比如要显示一个10个汉字的View,那么这个View的期望最小宽/高就是“当前文字样式下10个汉字的宽/高 + padding”。
-
根据MeasureSpec的模式,确定最终的宽/高。具体逻辑是:
- MeasureSpec.EXACTLY:以MeasureSpec的size为准。
- MeasureSpec.AT_MOST:取期望和MeasureSpec的size的最小值。
- MeasureSpec.UNSPECIFIED:取期望值。
MeasureSpec的三种模式,下面还会专门说明。所以暂时先不要纠结上面逻辑的理由。
调用
setMeasuredDimension()
方法保存结果。
总结
自定义View的Measure过程通用处理方法:首先要确定View需要的(显示内容)最小/合适大小,然后根据MeasureSpec的三种模式确定最终的measured尺寸。
MeasureSpec
之所以没有开始就讲这个类,是因为在讲之前我希望大家先对自定义View的Measure过程有个印象。
定义:MeasureSpec封装了父View对子View的布局需求。所以这个类表示了一种需求,需求,需求。
MeasureSpec由mode和size两个部分组成,这两个部分通过位计算储存到一个int类型中,(怎么个结构这里就不细说了,看源码吧),通过getMode()和getSize()获取。这两个方法加上构造方法基本就是MeasureSpec的全部API了。
size很好理解,下面把三种mode翻译成普通话:(以下“我”代表父View,“你”代表子View,“size”表示MeasureSpec的size)
- MeasureSpec.EXACTLY:我需要你的大小和size一样。
- MeasureSpec.AT_MOST:你可以是(根据内容确定的或是)任意大小,但是不能超过size。
- 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的取值类型一致,共有三种:
- MATCH_PARENT (-1): 表示子View希望自己和父控件的width/height一致。一般情况下,会导致onMeasure方法中得到mode为EXACTLY,size为父View宽/高的MeasureSpec。
- WRAP_CONTENT (-2): 表示子View希望自己的width和height由自己的内容决定。一般情况下,会导致onMeasure方法中得到mode为AT_MOST,size为父View宽/高的MeasureSpec。
- 任意非负整数: 表示子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过程大部分情况下不需要关心。因为:
- 如果自定义ViewGroup的目的是为了自定义自View的布局规则,那么请直接继承ViewGroup类。
- 如果自定义ViewGroup的目的是为了包装业务,那么不需要涉及布局规则的定义,就不需要关心Measure和Layout过程了。
- 如果两者都有,那么参见第一条。
这里我们讨论直接继承自ViewGroup的情况。根据刚才的结论,自定义ViewGroup类,自然是要干涉子View的布局逻辑。比如:按比例布局、按某种图形布局、自动折行等等。
自定义ViewGroup就是上面讨论的“父View”,所以它需要:
-
通过调用子View的
View.measure(int, int)
方法向子View传递自己的合适的需求。
这句话是第6次出现了,这很重要。
你至少应该从中得到以下信息:(以下VG表示“自定义ViewGroup”)
- 自定义ViewGroup要在onMeasure方法中调用所有需要布局的子View(有些View,比如不需要显示,可以不测量)的measure方法。
- 自定义ViewGroup要向子View传递Measure需求。
- 自定义ViewGroup向子View传递的MeasureSpec是代表自己对子View的Measure需求,可以并且一般也都和onMeasure方法的参数(“爷爷View的需求”)的MeasureSpec不同。
- 自定义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步骤
对每个View进行measure,调用ViewGroup.measureChildWithMargin方法。这里传入的MeasureSpec是使用ViewGroup的默认实现计算(注1)得到的。同时记录所有子View的width/height的最大值。
取子View的最大width/height,考虑自己的minHeight/minWidth、Foreground和Padding,得到新的最大值,作为自己的暂时measuredWidth/measuredHeight。
结合onMeasure的参数MeasureSpec(父ViewGroup的measure需求),得到最终measuredWidth、measuredHeight,调用setMeasuredDimension方法。(measuredState见注2)
判断:如果onMeasure参数中有非EXACTLY mode的MeasureSpec(某个方向或者某两个方向尺寸不确定),并且子View的LayoutParams中,有MATCH_PARENT的值。如果不满足,结束onMeasure;否则继续。
-
对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
不知道大家有没有做过这种列表:
每个列表项分为标题和内容,标题和内容都是左对齐,并且所有内容都要左对齐。但你遇到这种列表你会想到怎么做呢?
如果你能想到通过自定义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,就讲到这里吧,希望对大家有帮助。