都2023年了,不会还有人不知道什么是View吧,不会吧,不会吧。按我以往的面试经验来看,View被问到的概率不比Activity低多少哦,个人感觉View在Android中的重要性也和Activity不相上下,所以这篇文章将介绍下View的基础知识及其工作原理。
一、初识View
什么是View ?
所谓的View,就是Android中所有控件的基类(例如:Button、TextView、LinearLayout等等),甚至是ViewGroup也是继承自View的,然而ViewGroup是一个控件组,里面包含许多控件(也就是一组View),所以这就意味着View本身既可以是单个控件,也可以是由许多控件组成的一个控件,这样看来View其实也没那么玄乎啊。
View的位置参数
首先我们要知道,在Android手机中,坐标系是以手机屏幕的最左上角为原点而建立的,大家可以参考下面的图片理解下。
其次,View的初始位置
由四个属性决定,分别是:top、left、right、bottom。其中top和left为View左上角的纵坐标和横坐标,而bottom和right为View右下角的纵坐标和横坐标。(看官注意:这些坐标全都是相对于View所在的父容器来说的,是一个相对坐标,并不是在手机屏幕中的实际坐标)
同样,在下面放上图示帮助大家理解:
所以,通过上面的一通分析,我们可以得出View的宽高和坐标之间的关系:
width = right - left
height = bottom -top
细心的看官可能发现了,在上面我把”初始位置“四个字给标红了。没错,那四个属性不仅仅是初始位置,而且在你的View不论是发生旋转或者平移时候,他们都不会改变,改变的其实是另外的位置参数。从Android3.0开始,View增加了几个额外的参数,它们分别是x、y、translationX、translationY,其中x和y是View左上角的坐标,而translationX、translationY是View左上角相对于父容器的偏移量,(注意:这几个参数同样是针对父容器而言的)
并且translationX和translationY的默认值是0,它们有以下的换算关系:
x = left + translationX
y = top +translationY
所以不难看出,当View发生位置改变时,改变的其实是x、y、translationX、translationY这四个参数。好了,以上就是View位置参数的全部内容,如果以上内容各位看起来比较轻松的话,那么接下来的内容可能比较费劲,接下来继续发车了。
二、DecorView和MeasureSpec
View的三大流程无非就是Measure、Layout、Draw,但这三大流程都是基于DecorView中呈现的,然而想要呈现出View,还需要知道View的大小,在测量过程中MeasureSpec又是其中的关键,所以接下来我们有必要了解下他们。
初识DecorView
DecorView是Activity里的顶级View,它一般来说是一个竖直方向的LinearLayout(这与Android的版本和主题有关),在这个LinearLayout里面有上下两部分,上面是标题栏,下面是内容栏。我们在Activity的onCreate()方法中通过setContentView所设置的布局文件就被加入到了DecorView的内容栏之中,内容栏的id为content,通过ViewGroup content = findViewById(R.android.id.content)
可以得到content,View层的事件都会先经过DecorView之后才继续向下传递的。同样是一图胜千言:
理解MeasureSpec
第一个问题,什么是MeasureSpec?在《Android开发艺术探索》一书中对它的解释是这样的:MeasureSpec翻译过来是”测量规格“或者”测量说明书“,是一个32位的int值,高2位代表SpecMode,低30位代表SpecSize。而SpecMode指测量模式,SpecSize指在某种测量模式下的规格大小
。
第二个问题,MeasureSpec是干啥的?同样书中的解释是:它在很大程度上决定了一个View的尺寸规格,之所以说是很大程度上是因为这个过程还受父容器的影响,因为父容器影响View的MeasureSpec的创建过程。在测量过程中,系统会将View的LayoutParams根据父容器所施加的规则转换成对应的MeasureSpec,然后再根据这个measureSpec来测量出View的宽高。
可谓是听君一席话,胜似听君一席话,我一开始看也是云里雾里的,接下来我尽量不深入代码,带大家通俗的理解一下:
我回答下第一个问题,MeasureSpec其实就是一串数字,一串长度为32位的数字,里面包含着一些View的测量信息。
第二个问题的答案就是,通过这一串数字可以帮助系统测量出View的宽和高。
这够通俗了吧,这就跟你网购的快递一样的,快递单号就是那一串莫名其妙的数字(MeasureSpec),通过这一串数字能帮助你查询到快递运到哪了,换算到View上,不就是帮助系统知道View的宽和高嘛,这是一个道理,听懂就来点掌声。
那么接下来就要详细了解下系统是怎么通过这串数字(MeasureSpec)来测量出View的宽高的。在上面提到SpecMode和SpecSize。 SpecSize比较简单,通俗理解就是View在父容器下的实际大小或者是可用大小
,有人可能会问为什么还会有个可用大小?这里我解释下:当你在布局文件中给定一个View一个确切的大小时,那么SpecSize就是实际大小,例如:android:layout_width = "x dp"
、android:layout_height = "x dp"
。反之如果这两个属性给的值为”match_parent“
、或者是"wrap_content"
时,此时的SpecSize就是父容器下的可用大小。
SpecMode相对于SpecSize而言削微有那么点抽象,它分为三类,分别如下:
- UNSPECIFIE:父容器不对View有任何限制,要多大就给大多,这一般用于系统内部,表示一种测量的状态,一般来说可忽视。
- EXACTLY:父容器已经检测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所指定的值,它对应于
LayoutParams
中的match_parent
和赋予具体数值
的这两种模式- AT_MOST:父容器指定了一个可用大小即SpecSize,View的大小不能大于这个值,具体是什么值需要看不同View的具体实现。它对应于
LayoutParams
中的wrap_content
通俗的说,你在layout布局文件中给View的”match_parent“
、和"wrap_content"
属性设置不同的值,再根据父容器的SpecMode加以对应,就会得到View实际的SpecMode,具体的对应关系如下:图来!!!(图中的parentSize指的是父容器中目前可使用的大小,表格中的UNSPECIFIED中的Size为 0 表示忽略,在普通的View中是不会出现的,只会在例如DecorView这种系统级别的才会出现)
parentSpecMode /childLayoutParams | EXACTLY | AT_MOST | UNSPECIFIED |
---|---|---|---|
dp/px | EXACTLY (childSize) | EXACTLY (childsize) | EXACTLY (childsize) |
match_parent | EXACTLY (parentSize) | AT_MOST (parentSize) | UNSPECIFIED (0) |
wrap_content | AT_MOST (parentSize) | AT_MOST (parentSize) | UNSPECIFIED (0) |
所以,只要提供了父容器的MeasureSpec和子元素的LayoutParams,就可以确定出子View的MeasureSpec了,从而就可以进一步确定出View测量后的大小了,至此MeasureSpec扫盲结束。让我们喝口水,继续讲述View的三大流程。等等,你刚才说,喝什么???
三、View的三大流程
此为面试热点,面试官一般会从这里引入,然后不断对你进行摸底,各位要跳槽的看官要注意了,View的三大流程是指measure、layout、draw,即测量、布局和绘制。其中measure确定View的测量宽高,layout确定View的最终宽高和四个顶点位置,而draw则是将View绘制到屏幕上。所以我们逐一进行分析,同样我也不深入代码,有想深入了解代码的可以自己查阅相关信息或者参考《Android开发艺术探索》一书。
measure过程
measure过程主要分为两类,一类是单个View的measure,另一类是对于ViewGroup的measure。单个View的measure比较简单,直接通过调用自身的measure方法就完成了测量过程。然而对于ViewGroup而言,除了会完成自身的measure过程外,还会去遍历调用所有子元素的measure方法,各个子元素再递归去执行这个流程。
1. View的measure过程
上文有提到View的measure通过调用自身的measure()
方法就完成了测量过程,然而measure()方法中又会去调用onMeasure()
,具体代码如下:
//View的measure方法
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
...
// measure ourselves, this should set the measured dimension flag back
onMeasure(widthMeasureSpec, heightMeasureSpec);
...
}
//View的onMeasure方法
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(),widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
从上面的代码我们可以知道两点,一是宽和高分别有自己的MeasureSpec,至于什么是MeasureSpec,上文有提到过了。二是宽或高的测量大小是通过getDefaultSize()
方法得到的,至于他是怎么得到的,我们继续往下看:
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;
}
对于MeasureSpec.getMode()
和MeasureSpec.getSize()
方法我详细解释下,这是系统提供的一个解包方法,也可以理解为解密吧(其实也就是二进制的与操作),将一个measureSpec通过解包,从而得到sepcMode和specSize,再往后就是根据specMode返回对应的大小了。
另外,getDefaultSize()
方法还传入了一个getSuggestedMinimumHeight()
或getSuggestedMinimumWidth()
参数,这看名字应该是一个系统推荐的默认值,至于这个默认值怎么来的,我就不带着大家分析代码了,我直接给出结论,有兴趣的看官可以自行了解,我这里以getSuggestedMinimumWidth()
为例给结论就行,因为getSuggestedMinimumHeight()
也是一模一样的。
getSuggestedMinimumWidth()结论: 如果View没有设置背景,那么返回android:minWidth这个属性所指定的值(这个值可以为0),如果View设置了背景,则返回android:minWidth和背景的最小宽度这两者中的最大值.
面试点细节总结
敲黑板了,注意了,此为魔鬼细节和面试问点,各位看官要注意了
面试官:直接继承自View的自定义控件需要注意什么?
我:当然是需要重写onDraw()方法啦!
面试官:…这简直是一句犀利的废话。
直接继承View的自定义View需要重写onMeasure方法并设置wrap_content时的大小,否则在布局中使用wrap_content时就相当于match_parent,导致使用match_parent和使用wrap_content时完全没有区别。
之所以会出现这样的现象是因为View在布局中使用wrap_content时,那么它的specMode是AT_MOST模式,在这种模式下,它的宽、高specSize都是parentSize
(这一部分前面有讲过,可以查阅表格,如果懒得往上翻的朋友,我会在下面再放一次表格),而parentSize代表的是父容器中目前可以使用的大小。所以在这种情况下,View的宽高就会等于父容器可使用空间大小,我们可以再看表格,艾,巧了,当我们使用match_parent时,specSize同样也是parentSize,所以呈现的效果完全一致,这下大家都明白了吧?
parentSpecMode /childLayoutParams | EXACTLY | AT_MOST | UNSPECIFIED |
---|---|---|---|
dp/px | EXACTLY (childSize) | EXACTLY (childsize) | EXACTLY (childsize) |
match_parent | EXACTLY (parentSize) | AT_MOST (parentSize) | UNSPECIFIED (0) |
wrap_content | AT_MOST (parentSize) | AT_MOST (parentSize) | UNSPECIFIED (0) |
呐,还是给大家举个栗子,帮助理解。
大家可以看到我在布局中加了两个控件,一个是普通的View,背景色为黑色(这里我们也可以看成自定义View),另一个是TextView,背景色为白色。这控件的宽高属性全部是wrap_content
,然而我们自定义的View却撑满了整个屏幕,TextView却没有。这是因为TextView中已经重写了onMeasure()方法,在方法中对specMode为AT_MOST时,做了特殊处理,大家感兴趣可以自己查看源码,而View中没有处理。所以出现了上述的问题。
所以当大家在写自定义View时,记得也加入这样的处理,我在下面为大家放上一个解决方案,具体值得大小还需要你们自己去灵活定义:
//代码来源于《Android开发艺术探索》
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(mWidth, mHeight);
} else if (widthMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(mWidth, heightSize);
} else if (heightMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSize, mHeight);
}
}
2. ViewGroup的measure过程
ViewGroup自身是没有重写onMeasure()方法的,而View是有重写的。但是ViewGroup提供了一个measureChild
方法,其作用就是取出子元素的LayoutParams,进一步获得子元素的MeasureSpec,接着将MeasureSpec传递给View的measure方法进行测量,View的measure测量流程已经在上面做了详细分析了。
大家看到这应该也明白了,为什么自定义View不用必须重写onMeasure,而自定义ViewGroup必须重写onMeasure方法的原因了
所以ViewGroup的测量流程简单而言可以分为两块内容,
第一块递归对子View进行measure
,第二块根据每个子View的测量结果,累计加总测量出ViewGroup自身的宽高
。第一块内容在上文详细介绍过,因此我们主要关注第二块内容,接下来以LinearLayout为例子进行介绍,我们先来看下LinearLayout的onMeasure方法:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mOrientation == VERTICAL) {
measureVertical(widthMeasureSpec, heightMeasureSpec);
} else {
measureHorizontal(widthMeasureSpec, heightMeasureSpec);
}
}
上述代码可谓简单明了,我们就只以VERTICAL方向上去看一下,另一个也大同小异,大家可以自行了解,由于measureVertical方法比较长,我就截取部分源码,描述下大概逻辑,首先看代码:
void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {
mTotalLength = 0;
...
// See how tall everyone is. Also remember max width.
//(遍历每个子View的高度,并且记录下总高度,其中mTotalLength就是总高度)
for (int i = 0; i < count; ++i) {
final View child = getVirtualChildAt(i);
...
// Determine how big this child would like to be. If this or
// previous children have given a weight, then we allow it to
// use all available space (and we will shrink things later
// if needed).
final int usedHeight = totalWeight == 0 ? mTotalLength : 0;
measureChildBeforeLayout(child, i, widthMeasureSpec, 0,
heightMeasureSpec, usedHeight);
final int childHeight = child.getMeasuredHeight();
final int totalLength = mTotalLength;
mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin +
lp.bottomMargin + getNextLocationOffset(child));
...
}
//所有子元素遍历结束,开始测量自身大小
// Add in our padding,加顶部和底部的padding统计进总高度
mTotalLength += mPaddingTop + mPaddingBottom;
int heightSize = mTotalLength;
// Check against our minimum height
heightSize = Math.max(heightSize, getSuggestedMinimumHeight());
// Reconcile our calculated size with the heightMeasureSpec
// 根据父容器的大小和自身的MeasureSpec计算出最终高度,因为子元素高度总和是不能超过父元素剩余空间的
int heightSizeAndState = resolveSizeAndState(heightSize, heightMeasureSpec, 0);
...
maxWidth += mPaddingLeft + mPaddingRight;
// Check against our minimum width
maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());
//传入最终测量出的宽高尺寸,从而设置ViewGroup的宽高
setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),
heightSizeAndState);
}
上述代码的主要逻辑就是系统会遍历所有子元素并对他们执行measureChildBeforeLayout
方法,在这个方法内部会调用子元素的measure方法,也就是进入到了第一块内容中(上文已经讲解过),系统还通过mTotalLength来记录LinearLayout在竖直方向的总高度,每测量出一个子元素,mTotalLength就会增加,增加的部分主要包括子元素的高度以及子元素在竖直方向上的margin、padding等。最终设置ViewGroup的测量宽高,至此测量完成!
面试点细节总结
- 自定义ViewGroup,继承ViewGroup后,必须要重写onMeasure方法测量自身和子View,进而重写onLayout,这点与自定义View差别较大,需要特别注意.
- 不论是自定义View还是自定义ViewGroup,他们在measure过程得到的宽高都不是最终宽高,仅仅是测量宽高。最终宽高是在onLayout过程中才真正确定的,所以要获取一个控件的宽高,最好在onLayout方法中去获取。当然大多数情况下,控件的测量宽高和最终宽高是相等的.
- 由于View的measure过程和Activity的生命周期方法是不同步执行的,因此无法保证Activity执行了onCreate、onStart、onResume时某个View已经被测量完毕了,如果此时View还没有测量完毕,我们获取到的宽高值将会是0,要解决这种问题,我们可以采用
view.post(runnable)
方法,通过post将一个runnable投递到消息队列尾部,等View初始化完成后,就会从Looper中调用此runnable,从而拿到测量出的宽高值,代码示例如下:
mView.post(new Runnable() {
@Override
public void run() {
int width = mView.getMeasuredWidth();
int height = mView.getMeasuredHeight();
}
});
layout过程
Layout流程是用于确定View或ViewGroup的位置,因为layout流程相对于measure而言比较简单,我们先看看view的layout大致代码逻辑:
public void layout(int l, int t, int r, int b) {
...
int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;
boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
onLayout(changed, l, t, r, b);
...
}
...
}
在上述代码中,大致流程是,layout
方法首先会通过setFrame方法来设定View的四个顶点位置,即初始化mLeft、mTop、mRight、mBottom这四个值,View的四个顶点一旦确定,View在父容器中的位置也就随之确定了。接下来就会调用onLayout
方法,这个方法的用途是父容器确定子元素位置的,通俗而言就是layout是确定自身的位置,onLayout是确定其子View的位置
,因为单个View没有子元素,ViewGroup类布局的不确定性,所以他们均对onLayout
方法都是空实现,即如下所示:
/**
* Called from layout when this view should
* assign a size and position to each of its children.
*
* Derived classes with children should override
* this method and call layout on each of
* their children.
*/
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
}
因为LinearLayout继承自ViewGroup,所以它必然实现了onLayout方法,所以我们继续以它为例,看看它是如何实现的,代码如下所示:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (mOrientation == VERTICAL) {
layoutVertical(l, t, r, b);
} else {
layoutHorizontal(l, t, r, b);
}
}
啊哈,看来和onMeasure()
中的实现逻辑类似,我们还是以layoutVertical
进行讲解,同样继续给出主要代码逻辑:
void layoutVertical(int left, int top, int right, int bottom) {
...
final int count = getVirtualChildCount();
...
for (int i = 0; i < count; i++) {
final View child = getVirtualChildAt(i);
if (child == null) {
childTop += measureNullChild(i);
} else if (child.getVisibility() != GONE) {
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
final LinearLayout.LayoutParams lp =
(LinearLayout.LayoutParams) child.getLayoutParams();
...
if (hasDividerBeforeChildAt(i)) {
childTop += mDividerHeight;
}
childTop += lp.topMargin;
setChildFrame(child, childLeft, childTop + getLocationOffset(child),
childWidth, childHeight);
childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child);
i += getChildrenSkipCount(child, i);
}
}
}
所以综上所述:
layoutVertical
方法会遍历所有子元素并调用setChildFrame
方法来为子元素指定对应位置,其中childTop记录当前末端子元素的高度位置,childTop会逐渐增大,意味着后面的子元素会被放置在靠下的位置,这正是竖直方向上LinearLayout的特性。
再简单说一下
setChildFrame
方法,它仅仅是调用子元素(这里我们可以看成是儿子元素
)的layout方法,然后当该元素(儿子元素)确定了自己的位置以后又调用onLayout方法安排其子元素(孙子元素
)的位置。好家伙,这简直就是俄罗斯套娃(递归)。
draw过程
Draw过程就更简单了,尤其是对于做了许多自定义View的友友来说。它的作用就是将View绘制到屏幕上面,绘制过程大致如下:
- 绘制背景background.draw(canvas).
- 绘制自己 (onDraw).
- 绘制children (dispatchDraw).
- 绘制装饰 (onDrawScrollBars).
关于绘制过程,是三大流程中最为简单的了,看官可以自行查看源码,这里就不再赘述了,另外ViewGroup一般不用重写onDraw来绘制自己,只需要对子View进行绘制就可以。但明确知道一个ViewGroup需要通过onDraw来绘制内容时,我们需要调用setViewNotDraw(false)
来进行设置;
好了,至此View三大工作流程已经讲解完毕,没错,接下来当然是划重点啦。
四、自定义View相关总结
自定义View的分类
- 继承View:需重写onDraw方法,一般用于实现一些不规则的效果。
- 继承ViewGroup:需重写onMeasure、onLayout方法,即自己定义一种除了像linearLayout、RelativeLayout等,这几种系统布局之外的布局,这种情况比较少
- 继承特定的View:比如继承ImgView等,一般用于拓展某种已有的View的功能。
- 继承特定的ViewGroup: 比如继承LinearLayout,一般也是用于拓展功能。但好处是它不需要自己重写onMeasure和onLayout方法,并且也比较常用。
自定义View的注意事项
- 让View支持wrap_content,这一点在上面
View的measure过程的面试点细节总结
里详细介绍过。 - 在自定义View时,不要在
onDraw()
方法中定义变量和执行循环操作,不然会导致内存溢出和卡顿掉帧的现象。 - 如果View中有线程或者动画,需要及时停止,否则会造成内存泄露的情况。
- View带有滑动嵌套情形时,需要处理好滑动冲突。
- 不建议使用自定义的Handler,而应该使用
view.post(runnable)
方法进行替代。 - 如果有必要,让你的View支持padding。对于直接继承View的控件,如果不在
onDraw()
方法中处理padding,那么padding属性是没有效果的。对于直接继承自ViewGroup的控件,需要在onMeasure()
和onLayout()
中考虑padding和子元素的margin对其造成的影响,否则也会导致padding和子元素的margin失效。
更多Android 面试知识点:https://qr18.cn/CKV8OZ