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

本篇作为自定义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开发艺术探索》


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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容