美文网首页
【总结】自定义控件,View的工作原理

【总结】自定义控件,View的工作原理

作者: 械勒的时间 | 来源:发表于2018-03-01 16:00 被阅读0次

    本篇作为自定义View的前置章节

    View的绘制流程

    View的绘制一般分为三步,分别为MeasureLayoutDraw,即测量,布局和绘制。
    View的事件传递从父空间的Measure开始,传递过程如 图1 所示。

    图1 View的绘制流程.png

    PS:事实上,View的绘制流程是从ViewRootperformTraversals方法开始的,Measure,Layout和Draw方法之前还分别有名为performMeasure,performLayout和performDraw的方法,这里从简,不影响逻辑。

    MeasureSpec

    说到View的绘制流程,就不能不提到 MeasureSpec 这个概念,MeasureSpec的字面意义是测量规范,这是View绘制流程中非常重要的一个变量,它影响着View的测量过程。

    MeasureSpec的内容为一个32位的int值,高两位代表SpecMode,测量模式,低30位代表SpecSize,为某种测量模式下的规格大小。SpecMode与SpecSize均可通过对MeasureSpec进行解包获得。

    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    
        ...
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        ...
    }
    
    

    其中,SpecMode有三类,每一种都有其特殊的含义。

    1. UNSPECIFIED,父容器不对View做任何限制,用于系统内部,自己写控件的时候用不到。
    2. EXACTLY,View所需大小为一个精确值,这种情况下,View的最终大小就是其SpecSize的值。对应于布局文件中,match_parent和具体数值这两种情况。
    3. AT_MOST,View的大小被限定在一个范围之内,其最大值为父容器所能提供的最大值,但其具体大小要看View的具体实现。对应于布局文件中,wrap_content这种情况。

    View的MeasureSpec是在父容器的getChildMeasureSpec()方法中生成,之后才会调用View的Measure方法,将其传递给View。
    getChildMeasureSpec()方法作用主要是根据父容器的MeasureSpec,同时结合View本身的LayoutParams来确定View的MeasureSpec,这其中的生成规律如图2,有条件的同学推荐去看看源码。

    图2 MeasureSpec生成规律.png

    UNSPECIFIED模式用不到,不做讨论。
    简单说明下这个表格,横轴标题为父容器测量模式,纵轴标题为子View的layoutparams参数,六种不同情况下,他们会为子View生成不同的MeasureSpec。

    对于这个表格的理解,我们以布局文件中,layout_width属性为例说明。layout_height属性与其类似。
    先看表头:
    EXACTLY对应于LayoutParams中的match_parent或者是一个准确的大小。那么我们可以认为,在布局文件中,父容器的宽属性为100dp。
    AT_MOST对用于LayoutParams中的wrap_content,我们认为,此时父容器的宽属性为wrap_content。

    那么这个表格可以这么理解:

    1. 当子View的宽是一个精确的数值(dp/px),那么不论父容器是那种测量模式,子View的测量模式都会是精确数值(EXACTLY)模式,子View的规格大小都是自身布局文件中所输入的大小(childSize)。因为它自身的数值在其布局文件中已经确定了。
    2. 当子View的宽的大小为填充父容器,子View的宽的大小一定会跟父容器相同(parentSize),但是测量模式为会分两种情况:
      1. 当子View父容器的宽是精确数值(EXACTLY)模式时,测量模式为精确数值(EXACTLY)模式。由于父容器为一个精确数值(上文的假设100dp),那么子View填充父容器,子View的宽也就是确定的,为父容器的宽的数值(100dp),那么它的测量模式自然是精确模式。
      2. 当子控件父容器的宽为限制最大值(AT_MOST)模式时,测量模式同样为限制最大值(AT_MOST)模式。由于子View的宽填充父容器,但父容器的宽还未确定, 只有一个限制最大值的范围,所以子控件的宽也无法确定,也只是有一个最大范围,这个范围与父容器的范围相同。
    3. 这种情况会比较特殊,先要重新明确一个概念,MeasureSpec中,低30位为控件的规格大小,而非实际大小。当子View的宽为wrap_content时,它的大小是不确定的,所以测量模式为限制最大值(AT_MOST)模式,但是此时,它的规格大小为父容器的大小(parentSize)而非是子容器自身的大小,因为此时子容器的测量过程还未开始,无法计算出这个View实际的大小,而在理论上,这个控件宽度最大可以达到父容器的宽度,所以规格大小为父容器的大小(parentSize)。至于其自身到底有多大,会在子View的onMeasure中进行计算。

    PS:上文中,若父容器有内边距,子控件填充时,所得的宽的数值要将其减去。

    View的工作流程

    measure过程

    View的measure过程

    View的measure过程由measure方法调用onMeasure方法来完成,我们来说说View的measure过程的大致流程。View的各个子类与其流程大致相同。

    
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                    getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }
    
    

    setMeasuredDimension这个方法的作用是设置View宽高的测量值,这个不重要,我们需要关心的是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;
    }
    
    

    简单来说,getDefaultSize这个方法,返回的就是measureSpec中的specSize即View的测量大小,UNSPECIFIED的情况不需要在意,我们需要关心的是剩余的两种情况。
    从源码上可以得知,自定义控件直接继承View时,需要重写onMeasure方法,并规定wrap_content时自身的大小。否则在布局中使用wrap_content与使用match_parent是一个效果。这一点结合图2以及以上源码很好理解。
    在实践过程中,我们需要给自定义控件指定一个最小宽高值,当布局文件中使用wrap_content属性时,设置控件为最小宽高,其他情况使用遵循系统测量值即可。如TextView,EdittextView以及其他所有系统控件,他们对wrap_content都做了特殊处理。

    自定义控件时候,是一定要处理wrap_content。

    ViewGroup的measure过程

    对于ViewGroup来说,除了需要measure自身以外,还需要遍历所有子控件的measure方法。
    ViewGroup这个类是一个抽象类,他没有对onMeasure做具体实现(onMeasure的实现交由子类来完成,如Linearlayout)。但重要的是,他有一个叫做measureChildren的方法,会遍历每一个子元素,通过子控件的Layoutparams计算出每一个子控件的的measureSpec,再调用子控件的measure方法来对子控件进行测量。

    
        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);
        }
    
    

    PS:measureSpec生成规则见图2.

    上文提到,ViewGroup是一个抽象类,他没有定义measure的具体过程,这个过程交由他的子类来实现,因为ViewGroup的子类有多种多样如LinearLayout,RelativeLayout,他们的测量过程完全不同,由此无法做统一实现。
    例如,LinearLayout的onMeasure会先对所有子控件进行遍历,得到所有子控件的宽高信息之后,LinearLayout自身的宽高会被测量为所有子控件宽/高的累计的和。RelativeLayout,FrameLayout等均有自己的测量规则。
    View的measure是View三个流程中,最为复杂的一个,measure完成后,就可以通过getMeasureWidth,getMeasureHeigth方法正确的获得View的测量宽高。总要的是,只有当measure完全完成后,获取到的View的宽高才是准确的,而在某些情况中,onMeasure会被执行多次,因此不要在onMeasure获取控件宽高,应在onLayout过程中获取

    layout过程

    layout的作用是ViewGroup用来确定子控件的位置。
    当ViewGroup的位置被确定以后,它会便利所有子元素并且调用其layout方法,在layout中,onLayout方法又会被调用。
    layout方法确定View自身的位置,onLayout方法确定当前View所有子元素的位置。
    layout方法大致的流程如下:假设有三个控件控件A,控件B,控件C。A为B的父控件,B为C的父控件。首先会初始化当前控件(B控件)的mLeft、mRight、mTop、mBottom这四个顶点的位置,将这四个值交给当前控件的父控件(A控件),来确定当前控件(B控件)在其父控件(A控件)中的位置。接着,当前控件(B控件)会调用自身的onLayout方法,来确定当前控件(B控件)的子控件(C控件)的在当前控件(B控件)中的位置。

    因为View的子类(如TextView)没有子控件,所有onLayout方法在View以及他的子类中是一个空函数,并且View的子类大部分情况下,不会对该方法进行实现。同样的,ViewGroup没有对onLayout进行实现,因为不同的布局实现方式完全不同。

    以LinearLayout的Vertical模式为例,onLayout时会遍历所有子控件,先看代码

    
        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();
                    int gravity = lp.gravity;
                    if (gravity < 0) {
                        gravity = minorGravity;
                    }
                    final int layoutDirection = getLayoutDirection();
                    final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
                    switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
                        case Gravity.CENTER_HORIZONTAL:
                            childLeft = paddingLeft + ((childSpace - childWidth) / 2)
                                    + lp.leftMargin - lp.rightMargin;
                            break;
                        case Gravity.RIGHT:
                            childLeft = childRight - childWidth - lp.rightMargin;
                            break;
                        case Gravity.LEFT:
                        default:
                            childLeft = paddingLeft + lp.leftMargin;
                            break;
                    }
                    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);
                }
            }
        }
    
        private void setChildFrame(View child, int left, int top, int width, int height) {        
            child.layout(left, top, left + width, top + height);
        }
    
    

    可以看到,此方法会遍历所有子控件,并为所有控件指定对应位置,其中childTop会逐渐增大,这样的话接下来的子控件就会处于比较靠下的位置,即Vertical模式下,LinearLayout的样子。可以看到,LinearLayout的onLayout方法在确定自身位置后,会通过setChildFrame方法调用自身子控件的layout方法,来确定子控件的位置,通过这样一层层传递,就完成了整个View树的layout过程。

    PS:网上流传一句话,自定义ViewGroup只需重写onMeasure,onLayout。自定义View只需重写onMeasure,onDraw。我认为是不准确的。虽然自定义View没有子控件,不需要调用onLayout来确定子控件的位置,但自定义View若想获取自身测量宽高并且做出一些处理的话,可以通过重写onLayout来完成。

    draw过程

    draw是三个过程中,最简单的一个步骤了,他的作用是将View绘制在屏幕上。
    View的绘制过程是通dispatchDraw方法来实现的,dispatchDraw方法会遍历所有子控件的draw方法,将事件传递下去。
    需要在意的是,View有一个特殊方法setWillNotDraw,若一个View没有需要绘制的内容,那么将这个编辑设置为true之后,系统会对其做相应的优化,View默认不启用,但是ViewGroup会默认将其启用。当我们知道,一个ViewGroup需要通过onDraw来绘制内容的时候,需要主动关闭WILL_NOT_DRAW这个标志位

    一些细节

    在Activity中,得到View已经被测量完毕的回掉

    假设我们需要在Activity启动的时候,获取View的狂傲信息,但因为View的测绘流程与Activity的生命周期是一个异步的过程,所以我们无法通过Activity的生命周期来获取一个View的绘制状态,因此在Activity的任何一个生命周期状态中,我们都不能保证,一定能获取到View的宽高信息。
    所以,我们推荐以下几种方法。

    1. onWindowFocusChanged
      这个方法在View初始化完成之后会被调用,但是这个方法不止会被调用一次,View每次获取焦点,这个方法都会被调用。若频繁的进行onResume,这个方法也会被频繁调用。经典写法如下。
    
        @Override
        public void onWindowFocusChanged(boolean hasFocus) {
            super.onWindowFocusChanged(hasFocus);
            if (hasFocus) {
                    int width = view.getMeasuredWidth();
                    int height = view.getMeasuredHeight();
            }
        }
    
    
    1. view.post(runnable)
      通过post方法,将一个runnable放在消息队列的尾部,当Looper调用次runnable的时候,View肯定已经初始化好了。为防止执行post的时候,view已经加在完毕,所以在onStart方法中添加。
    
        @Override
        protected void onStart() {
            super.onStart();
            view.post(new Runnable() {
    
                @Override
                public void run() {
                    int width = view.getMeasuredWidth();
                    int height = view.getMeasuredHeight();
                }
            });
        }
    
    
    1. ViewTreeObserver

    使用ViewTreeObserver的OnGlobalLayoutListener接口便能很好的解决这个问题,当View树发生改变时,onGlobalLayout会被回调。但是伴随View树的改变,onGlobalLayout会被回调多次。

    
        @Override
        protected void onStart() {
            super.onStart();
            ViewTreeObserver observer = view.getViewTreeObserver();
            observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
                @Override
                public void onGlobalLayout() {
                    view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                    int width = view.getMeasuredWidth();
                    int height = view.getMeasuredHeight();
                }
            });
        }
    
    
    1. view.measure(int widthMeasureSpec, int hightMeasureSpec)
      这个方法太复杂,不推荐使用,此处不表。

    getWight/getHight与getMeasureWight/getMeasureHight的区别

    我们以其中一对的对比来说明。
    在View的默认实现中,getWight与getMeasureWight是相等的,只不过MeasureWight形成于View的measure过程中,而Wight形成于View的layout过程,即,两者的赋值机制不同。在开发过程中,我们尽可以认为两者为相同的。
    当然我们可以在layout函数中强行改变getWight的数值,使其两者不相同,但是这么做对于开发没有意义。

    getX/getY与getRawX/getRawY

    getRawX()、getRawY()返回的是触摸点相对于屏幕左上角的坐标
    而getX()、getY()返回的则是触摸点相对于View左上角的坐标

    附:

    我自己看源码的一个网站:
    http://grepcode.com/project/repository.grepcode.com/java/ext/com.google.android/android/
    不用翻墙,虽然代码老了点,但是用来学习源码没什么问题。
    参考资料:《Android开发艺术探索》


    个人理解,难免有错误纰漏,欢迎指正。转载请注明出处。

    相关文章

      网友评论

          本文标题:【总结】自定义控件,View的工作原理

          本文链接:https://www.haomeiwen.com/subject/ytjjzxtx.html