面试官:谈一谈你对View的认识和View的工作流程

都2022年了,不会还有人不知道什么是View吧,不会吧,不会吧。按我以往的面试经验来看,View被问到的概率不比Activity低多少哦,个人感觉View在Android中的重要性也和Activity不相上下,所以这篇文章将介绍下View的基础知识及其工作原理,如有帮助到你,可以点个赞表示鼓励,谢谢各位!

image.png

一、初识View

什么是View ?

所谓的View,就是Android中所有控件的基类(例如:Button、TextView、LinearLayout等等),甚至是ViewGroup也是继承自View的,然而ViewGroup是一个控件组,里面包含许多控件(也就是一组View),所以这就意味着View本身既可以是单个控件,也可以是由许多控件组成的一个控件,这样看来View其实也没那么玄乎啊。

View的位置参数

首先我们要知道,在Android手机中,坐标系是以手机屏幕的最左上角为原点而建立的,大家可以参考下面的图片理解下。

image.png


其次,View的初始位置由四个属性决定,分别是:top、left、right、bottom。其中top和left为View左上角的纵坐标和横坐标,而bottom和right为View右下角的纵坐标和横坐标。(看官注意:这些坐标全都是相对于View所在的父容器来说的,是一个相对坐标,并不是在手机屏幕中的实际坐标)同样,在下面放上图示帮助大家理解:

image.png

所以,通过上面的一通分析,我们可以得出View的宽高和坐标之间的关系:

width = right - left
height = bottom -top
复制代码

image.png

细心的看官可能发现了,在上面我把”初始位置“四个字给标红了。没错,那四个属性不仅仅是初始位置,而且在你的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位置参数的全部内容,如果以上内容各位看起来比较轻松的话,那么接下来的内容可能比较费劲,接下来继续发车了。

image.png

二、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之后才继续向下传递的。同样是一图胜千言:

image.png

理解MeasureSpec

第一个问题,什么是MeasureSpec?在《Android开发艺术探索》一书中对它的解释是这样的:MeasureSpec翻译过来是”测量规格“或者”测量说明书“,是一个32位的int值,高2位代表SpecMode,低30位代表SpecSize。而SpecMode指测量模式,SpecSize指在某种测量模式下的规格大小

第二个问题,MeasureSpec是干啥的?同样书中的解释是:它在很大程度上决定了一个View的尺寸规格,之所以说是很大程度上是因为这个过程还受父容器的影响,因为父容器影响View的MeasureSpec的创建过程。在测量过程中,系统会将View的LayoutParams根据父容器所施加的规则转换成对应的MeasureSpec,然后再根据这个measureSpec来测量出View的宽高。

可谓是听君一席话,胜似听君一席话,我一开始看也是云里雾里的,接下来我尽量不深入代码,带大家通俗的理解一下:

image.png

我回答下第一个问题,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而言削微有那么点抽象,它分为三类,分别如下:

  1. UNSPECIFIE:父容器不对View有任何限制,要多大就给大多,这一般用于系统内部,表示一种测量的状态,一般来说可忽视。
  2. EXACTLY:父容器已经检测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所指定的值,它对应于LayoutParams中的match_parent赋予具体数值的这两种模式
  3. 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的三大流程。等等,你刚才说,喝什么???

image.png

三、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)

呐,还是给大家举个栗子,帮助理解。 来啊,上栗子!! image.png

image.png

大家可以看到我在布局中加了两个控件,一个是普通的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的测量宽高,至此测量完成!

面试点细节总结

  1. 自定义ViewGroup,继承ViewGroup后,必须要重写onMeasure方法测量自身和子View,进而重写onLayout,这点与自定义View差别较大,需要特别注意.
  1. 不论是自定义View还是自定义ViewGroup,他们在measure过程得到的宽高都不是最终宽高,仅仅是测量宽高。最终宽高是在onLayout过程中才真正确定的,所以要获取一个控件的宽高,最好在onLayout方法中去获取。当然大多数情况下,控件的测量宽高和最终宽高是相等的.
  1. 由于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绘制到屏幕上面,绘制过程大致如下:

  1. 绘制背景background.draw(canvas).
  2. 绘制自己 (onDraw).
  3. 绘制children (dispatchDraw).
  4. 绘制装饰 (onDrawScrollBars).

关于绘制过程,是三大流程中最为简单的了,看官可以自行查看源码,这里就不再赘述了,另外ViewGroup一般不用重写onDraw来绘制自己,只需要对子View进行绘制就可以。但明确知道一个ViewGroup需要通过onDraw来绘制内容时,我们需要调用setViewNotDraw(false)来进行设置;

好了,至此View三大工作流程已经讲解完毕,没错,接下来当然是划重点啦。

四、自定义View相关总结

自定义View的分类

  1. 继承View:需重写onDraw方法,一般用于实现一些不规则的效果。
  2. 继承ViewGroup:需重写onMeasure、onLayout方法,即自己定义一种除了像linearLayout、RelativeLayout等,这几种系统布局之外的布局,这种情况比较少,但感兴趣的朋友可以参考下面这篇文章,个人感觉非常不错。 ViewGroup实战Demo
  3. 继承特定的View:比如继承ImgView等,一般用于拓展某种已有的View的功能。
  4. 继承特定的ViewGroup: 比如继承LinearLayout,一般也是用于拓展功能。但好处是它不需要自己重写onMeasure和onLayout方法,并且也比较常用。

自定义View的注意事项

  1. 让View支持wrap_content,这一点在上面View的measure过程的面试点细节总结里详细介绍过。
  2. 在自定义View时,不要在onDraw()方法中定义变量和执行循环操作,不然会导致内存溢出和卡顿掉帧的现象。
  3. 如果View中有线程或者动画,需要及时停止,否则会造成内存泄露的情况。
  4. View带有滑动嵌套情形时,需要处理好滑动冲突。
  5. 不要在View中使用Handler,可以使用view.post(runnable)方法进行替代。
  6. 如果有必要,让你的View支持padding。对于直接继承View的控件,如果不在onDraw()方法中处理padding,那么padding属性是没有效果的。对于直接继承自ViewGroup的控件,需要在onMeasure()onLayout()中考虑padding和子元素的margin对其造成的影响,否则也会导致padding和子元素的margin失效。

好了,所有内容介绍完毕,完结撒花!还是那句话,如果该文章对你有所帮助,还请点赞表示鼓励,祝大家薪资早日翻倍!此文章为原创,其中参考了《Android开发艺术探索》,转载请注明出处,谢谢!

A9EF9F02FBE53AE7F5B8C3A465456E14.gif

Guess you like

Origin juejin.im/post/7055255933450092558