在Android体系中View作为视觉上的呈现,扮演着非常重要的角色。尽管Android提供了一套包含很多控件的GUI库。但是在大多数情况下,因为交互或展现的定制化要求,我们不是不能直接拿来使用的。怎么解决这一问题呢?
为了防止Android应用界面的同类化严重,我们需要通过自定义View来实现更友好的用户体验和更美观的呈现效果。
前言
为了更好的自定义View,我们应该掌握View的基本流程,如:View的测量流程、布局流程及绘制流程;
除了三大流程之外,View常见的回调方法也是需要熟练掌握的。
- 构造方法:
基本属性和自定义属性的初始化和赋值; - onAttach
- onVisibilityChanged
- onDetach
View的工作原理
ViewRoot对应于ViewRootImpl类,它是连接WindowManger和DecorView的纽带,View的三大流程均是通过ViewRoot来完成的。
在ActivityThread中,当Activity对象创建完毕后,会将DecorView添加到Window中,同时创建ViewRootImpl对象,并将ViewRootImpl对象和DecorView建立关联。参看下面源码(位于android-25/android/app/ActivityThread.java
):
//该代码位于handleLaunchActivity->handleResumeActivity方法中
ActivityClientRecord r = mActivities.get(token);
...
r.window = r.activity.getWindow();
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
if (r.mPreserveWindow) {
a.mWindowAdded = true;
r.mPreserveWindow = false;
// Normally the ViewRoot sets up callbacks with the Activity
// in addView->ViewRootImpl#setView. If we are instead reusing
// the decor view we have to notify the view root that the
// callbacks may have changed.
ViewRootImpl impl = decor.getViewRootImpl();
if (impl != null) {
impl.notifyChildRebuilt();
}
}
View的绘制流程是从ViewRoot的performTraversals方法开始的,经过measure、layout和draw三个过程才最终将一个View绘制出来。其中:
- measure:用来测量View的宽和高;
- layout:用来View在父容器放置的位置;
- draw:负责将View绘制在屏幕上;
如图所示,performTraversals会依次调用performMeasure、performLayout和performDraw三个方法,这三个方法分别完成顶级View的measure、layout和draw这三大流程;其中performMeasure中会调用measure方法,measure方法又会调用onMeasure方法,在onMeasure方法则会对所有的子View进行measure过程,这时measure流程就从父容器传递到子元素中去。然后子元素重复父容器的measure过程,如此反复就完成了整个View树的遍历,这样就完成了依次measure过程。另外两个过程是类似的,不赘述。
Measure过程
measure过程决定了View的宽高,measure完成以后可以通过getMeasureWidth和getMeasureHeight方法来获取View测量后的宽高,几乎所有情况下这两个值都等同于View的最终宽高值,特殊情况下除外(onLayout时强制指定宽高)。
Layout过程
layout过程决定了View的四个顶点的坐标和实际的View宽高。方法完成后,可以通过getTop、getBottom、getLeft、getRight来拿到View四个顶点的位置,并可以通过getWidth、getHeight方法来拿到View最终宽高。
Draw过程
draw过程决定了View的显示内容,只有draw方法完成之后View的内容才能显示在屏幕上。
DecorView层级组成
DecorView作为顶级的View,一般情况下它内部会包含一个竖直方向的LinearLayout,这个LinearLayout里面有上下两部分(具体情况和系统版本及主题有关),上面是标题栏,下面是内容区。
Activity通过setContentView所设置的布局其实是添加到DecorView的内容区里面了。而内容区的id是content,因此可以理解制定布局的方式不叫setView而叫setContentView;确切的说,设置的布局是加在id为content的FrameLayout的布局中。
如图:
理解MeasureSpec
MeasureSpec通过将高2位的SpecMode和低30位的SpecSize打包成一个32位的int里面来避免过多的内存分配。为了方便操作,其同时提供了打包和解包方法。
SpecMode
UNSPECIFIED(未指明模式)
父容器不对View做任何限制,要多大给多大,这种情况下一般用于系统内部,表示一种测量的状态。EXACTLY(准确模式)
父容器已经检测出View所需要的精确大小,这时View的最终大小就是低30位的SpecSize指定的值。这种情况对应于LayoutParams中的match_parent和具体的数值这两种模式。AT_MOST(最大模式)
父容器指定了一个可用大小即就是低30位的SpecSize的值,最终View的值不能大于这个值。它对应于LayoutParmas中的wrap_content。
MeasureSpec和LayoutParams的对应关系
Android系统内部是通过MeasureSpec来进行View测量的,但正常情况下我们使用View指定MeasureSpec;同时我们也可以给View设置LayoutParams。在View的测量时,系统会将LayoutParams在父容器的约束下转换成对应的MeasureSpec,然后在根据MeasureSpec来确定View测量后的宽高。
- MeasureSpec不是仅由LayoutParams决定的,其需要和父容器一起才能决定View的MeasureSpec,从而进一步决定View的宽高。
- 顶级View(DecorView)的MeasureSepc由窗口的尺寸和其自身的LayoutParams来共同决定。
- 对于普通View,它的MeasureSpec由父容器的MeasureSpec和其自身的LayoutParams来共同决定。
- MeasureSpec一旦确定onMeasure中就可以确定View的测量宽、高。
DecorView中对应关系
- LayoutParams.MATCH_PARENT:精确模式大小就是窗口的大小。
- LayoutParams.WRAP_CONTENT:最大模式,大小不确定,但不能超过窗口的大小
- 固定模式(如,100dp):精确模式,大小为LayoutParams指定的大小。
普通View中的对应关系
- 当View采用固定模式宽高的时候,不管父容器的MeasureSpec是什么,View的MeasureSpec是精确模式且其大小为当前View要求的大小。
- 当View宽高采用match_parent时,那么View的MeasureSpec是精确模式且其大小为父容器剩余的空间大小;如果父容器为最大模式,那么View的MeasureSpec是最大模式且其大小不超过父容器剩余的空间大小。
- 当View的宽高采用wrap_content时,不论父容器的模式是精确模式还是最大模式,View的MeasureSpec是最大模式且其大小不超过父容器剩余的空间大小。
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);
int resultSize = 0;
int resultMode = 0;
switch (specMode) {
// Parent has imposed an exact size on us
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// Parent has imposed a maximum size on us
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
// Child wants a specific size... so be it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed.
// Constrain child to not be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// Parent asked to see how big we want to be
case MeasureSpec.UNSPECIFIED:
if (childDimension >= 0) {
// Child wants a specific size... let him have it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size... find out how big it should
// be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
} else 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);
}
View的工作流程
View的工作流程主要是指measure(测量)、layout(布局)、draw(绘制)这三大流程。
其中measure确定了View的测量宽高,layout确定了View的最终宽高和四个顶点的位置,draw则将View绘制到屏幕上。
measure过程
measure过程要分两种情况来分析。如果是原始的View,那么通过measure方法就完成了View的测量过程;如果是一个ViewGroup除了完成自己的测量过程外,还要遍历调用所有子元素的measure方法,各个子元素再递归的执行这个流程。
原始View的measure过程
View的measure过程使用measure方法完成的,measure方法是一个final修饰的方法,意味着子类不能重写此方法,在measure方法中会调用其onMeasure方法。onMeasure方法源码如下:
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) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
onMeasure方法很简洁,setMeasuredDimension方法会设置View的宽高的测量值。我们同时需要关注getDefaultSize这个方法,这个方法逻辑很简单在AT_MOST和EXACTLY这两种情形下,getDefaultSize大小就是MeasureSpec的specSize的值。
而在UNSPECIFIED(位指明模式)的情形下,一般用于系统内部的测量过程,getSuggestedMinimumWidth、getSuggestedMinimumHeight这两个方法的返回值。源码如下:
protected int getSuggestedMinimumHeight() {
return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());
}
protected int getSuggestedMinimumWidth() {
return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}
这种情形下,可以得出如下结论:
如果View没有设置背景,那么返回android:minWidth这个属性所指定的值,这个值可以为0;如果View设置了背景,则返回minWidth和背景的最小宽度、高度之间的最大值。
结论
直接继承View的自定义空间需要重写onMeasure方法,并设置wrap_content时的自身大小,否则在布局中使用wrap_content就相当于使用了match_parent。
View在布局中使用wrap_content,那么他的specMode为AT_MOST,这种模式下宽高等于specSize,根据之前的表格可以得知,specSize就是parentSize即父容器当前剩余的空间大小;很显然这种效果等同于布局中使用match_parent。
如何解决这个问题?很简单,只需要给View指定一个默认的内部宽高,并在wrap_content是设置此宽高即可。代码如下:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
heightMeasureSpec);
if (widthSpecMode == MeasureSpec.AT_ MOST && heightSpecMode == MeasureSpec.AT_ MOST){
setMeasuredDimension(mWidth, mHeight);
} else if (widthSpecMode == MeasureSpec.AT_ MOST){
setMeasuredDimension(mWidth, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_ MOST){
setMeasuredDimension(widthSpecSize, mHeight);
}
}
查看TextView、ImageView等的源码可以知道,针对wrap_content的情形,在onMeasure方法中都做了特殊处理,可以自行阅读查看。
ViewGroup的measure过程
和View不同的是ViewGroup是一个抽象类,因此它没有重写onMeasure方法,而是提供了一个叫做measureChildren的方法,如下所示。
protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
final int size = mChildrenCount;
final View[] children = mChildren;
for (int i = 0; i < size; ++i) {
final View child = children[i];
if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}
}
protected void measureChild(View child, int parentWidthMeasureSpec,
int parentHeightMeasureSpec) {
final LayoutParams lp = child.getLayoutParams();
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom, lp.height);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
上述代码可以看出ViewGroup在measure时,会对每一个子元素进行measure,measureChild方法的实现就是取出子元素的LayoutParams,然后再通过getChildMeasureSpec来获取子元素的MeasureSpec,接着讲MeasureSpec传递给依次调用子元素的measure方法进行测量。
总结
ViewGroup作为一个抽象类并没有定义其测量的具体过程,代表测量过程的onMeasure方法需要各个子类进行具体的实现,比如LinearLayout、RelativeLayout等。ViewGroup的实现类有不同的布局特性,导致系统无法统一实现,因此才决定交给扩展类区实现。
View的测量过程是三大流程中最复杂的一个,measure完成以后,通过getMeasureWidth/Height方法就可以正确获取到View的测量宽、高。需要注意的是,极端的情况下,系统可能需要多次调用measure才能最终确定测量宽、高,这种情形下onMeasure拿到的测量宽高可能并不准确。一个比较好的习惯是在onLayout方法中获取View的测量宽高,或者最终宽高。