Android UI绘制之View绘制的工作原理

这是AndroidUI绘制流程分析的第二篇文章,主要分析界面中View是如何绘制到界面上的具体过程。

1、ViewRoot和DecorView

ViewRoot对应于ViewRootImpl类,它是连接WindowManagerDecorView的纽带,View的三大流程均是通过ViewRoot来完成的。在ActivityThread中,当Activity对象被创建完毕后,会将DecorView添加到Window中,同时会创建ViewRootImpl对象,并将ViewRootImpl对象和DecorView建立关联。

View的绘制流程是从ViewRootperformTraversals方法开始的,它经过measurelayoutdraw三个过程才最终将一个View绘制出来。

measure过程决定了View的宽/高,Measure完成以后,可以通过getMeasuredWidthgetMeasuredHeight方法来获取View测量后的宽/高,在几乎所有的情况下,它等同于View的最终的宽/高,但是特殊情况除外。Layout过程决定了View的四个顶点的坐标和实际的宽/高,完成以后,可以通过getTop、getBottom、getLeftgetRight来拿到View的四个顶点的位置,可以通过getWidthgetHeight方法拿到View的最终宽/高。Draw过程决定了View的显示,只有draw方法完成后View的内容才能呈现在屏幕上。

DecorView作为顶级View,一般情况下,它内部会包含一个竖直方向的LinearLayout,在这个LinearLayout里面有上下两个部分,上面是标题栏,下面是内容栏。在Activity中,我们通过setContentView所设置的布局文件其实就是被加到内容栏中的,而内容栏id为content。可以通过下面方法得到content:ViewGroup content = findViewById(R.android.id.content)。通过content.getChildAt(0)可以得到设置的viewDecorView其实是一个FrameLayout,View层的事件都先经过DecorView,然后才传递给我们的View

MeasureSpec

MeasureSpec代表一个32位的int值,高2位代表SpecMode,低30位代表SpecSize,SpecMode是指测量模式,而SpecSize是指在某种测量模式下的规格大小。
SpecMode有三类,如下所示:

UNSPECIFIED

父容器不对View有任何限制,要多大给多大,这种情况一般用于系统内部。

EXACTLY

父容器已经检测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所指定的值,对应于LayoutParams中的match_parent和具体的数值这两种模式。

AT_MOST

父容器指定一个可用大小即SpecSizeView的大小不能大于这个值,对应于LayoutParams中的wrap_content

LayoutParams需要和父容器一起才能决定View的MeasureSpec,从而进一步决定View的宽/高。

对于顶级View,即DecorView和普通View来说,MeasureSpec的转换过程略有不同。对于DecorView,其MeasureSpec由窗口的尺寸和其自身的LayoutParams共同确定;


1645852708(1).png

对于普通View,其MeasureSpec由父容器的MeasureSpec和自身的Layoutparams共同决定;


783729886c5041d381369b10ba2bcd2b.png

MeasureSpec一旦确定,onMeasure就可以确定View的测量宽/高。

小结一下

当子 View 采用具体的宽/高时,不管父容器的MeasureSpec 是什么,ViewMeasureSpec 都是精确模式+子ViewLayoutParams 中指定的具体的大小。

当子 View的宽/高采用match_parent 时,这时候父容器的 MeasureSpec 会发挥作用,子 View 的模式总是跟父容器的模式一样:

  • 如果父容器的模式是精确模式(EXACTLY),那么子 ViewMeasureSpec 就是精确模式+父容器的剩余空间(或者说父容器的可用空间);
  • 如果父容器的模式是最大模式(AT_MOST),那么子 ViewMeasureSpec 就是最大模式+父容器的剩余空间(或者说父容器的可用空间);
  • 如果父容器的模式是未指定模式(UNSPECIFIED),那么子 ViewMeasureSpec 就是未指定模式+父容器的剩余空间或者0

当子 View 的宽高采用 wrap_content时,这时候父容器的 MeasureSpec 同样会发挥作用:

  • 如果父容器的模式是精确模式(EXACTLY),那么子ViewMeasureSpec 就是最大模式+父容器的剩余空间(或者说父容器的可用空间);
  • 如果父容器的模式是最大模式(AT_MOST),那么子ViewMeasureSpec 就是最大模式+父容器的剩余空间(或者说父容器的可用空间);
  • 如果父容器的模式是未指定模式(UNSPECIFIED),那么子 ViewMeasureSpec 就是未指定模式+父容器的剩余空间或者0。

当子 View 的宽高采用 wrap_content 时,不管父容器的模式是精确模式还是最大模式,子 View的模式总是最大模式+父容器的剩余空间。

View的工作流程

View的工作流程主要是指measurelayoutdraw三大流程,即测量、布局、绘制。其中measure确定View的测量宽/高,layout确定view的最终宽/高和四个顶点的位置,而draw则将View绘制在屏幕上。

measure过程

measure过程要分情况,如果只是一个原始的view,则通过measure方法就完成了其测量过程,如果是一个ViewGroup,除了完成自己的测量过程外,还会遍历调用所有子元素的measure方法,各个子元素再递归去执行这个流程。

View的measure过程

如果是一个原始的 View,那么通过 measure 方法就完成了测量过程,在 measure 方法中会去调用 View 的 onMeasure 方法,View 类里面定义了 onMeasure 方法的默认实现:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // 先获取建议的最小宽度和高度
    int suggestedMinimumWidth = getSuggestedMinimumWidth();
    int suggestedMinimumHeight = getSuggestedMinimumHeight();
    // 再通过 getDefaultSize 方法获取宽度和高度的测量值
    int measuredWidth = getDefaultSize(suggestedMinimumWidth, widthMeasureSpec);
    int measuredHeight = getDefaultSize(suggestedMinimumHeight, heightMeasureSpec);
    // 最后调用 setMeasuredDimension 方法设置 View 宽度和高度的测量值。
    setMeasuredDimension(measuredWidth, measuredHeight);
}

先看一下 getSuggestedMinimumWidthgetSuggestedMinimumHeight 方法的源码:

protected int getSuggestedMinimumWidth() {
    return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}
protected int getSuggestedMinimumHeight() {
    return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());
}

我们只看 getSuggestedMinimumWidth 方法:如果 View 的背景 mBackground == null 成立,即View 没有设置背景,那么 View 的建议最小宽度就是 mMinWidthmMinWidth 对应于 android:minWidth 这个属性所指定的值);如果 View 设置了背景,那么 View 的建议最小宽度为 mMinWidthmBackground.getMinimumWidth() 中较大的那个。mBackground是一个 Drawable 对象,所以去看Drawable 类下的 getMinimumWidth 方法:

public int getMinimumWidth() {
    final int intrinsicWidth = getIntrinsicWidth();
    return intrinsicWidth > 0 ? intrinsicWidth : 0;
}

可以看到,getMinimumWidth 方法获取的是 Drawable 的原始宽度。如果存在原始宽度(即满足 intrinsicWidth > 0),那么直接返回原始宽度即可;如果不存在原始宽度(即不满足intrinsicWidth > 0),那么就返回 0。

ShapeDrawable 没有原始宽度和高度,而BitmapDrawable有原始宽度和高度。

接着看最重要的 getDefaultSize 方法:

// 这是一个 static 修饰的方法,所以是一个工具方法
/**
 * Utility to return a default size. Uses the supplied size if the
 * MeasureSpec imposed no constraints. Will get larger if allowed
 * by the MeasureSpec.
 *
 * @param size Default size for this view
 * @param measureSpec Constraints imposed by the parent
 * @return The size this view should be.
 */
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;
}

如果specModeMeasureSpec.UNSPECIFIED即未指定模式,那么返回由方法参数传递过来的尺寸作为 View 的测量宽度和高度;
如果specMode不是MeasureSpec.UNSPECIFIED 即是最大模式或者精确模式,那么返回从 measureSpec 中取出的specSize作为 View 测量后的宽度和高度。

看一下刚才的表格:


specModeEXACTLY 或者 AT_MOST 时,View 的布局参数为 wrap_content 或者 match_parent 时,给 ViewspecSize 都是 parentSize。这会比建议的最小宽高要大。这是不符合我们的预期的。因为我们给View设置wrap_content是希望View的大小刚好可以包裹它的内容。

因此:

直接继承 View 的自定义控件需要重写 onMeasure 方法并设置 wrap_content 时的自身大小(给 View 指定一个默认的内部宽高)。
@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
  
        if(widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode==MeasureSpec.AT_MOST){
        setMeasuredDimension(mWidth, mHeight);
}else if(widthSpecMode == MeasureSpec.AT_MOST){
        setMeasuredDimension(mWidth, heightSpecSize);
}else if(heightSpecSize == MeasureSpec.AT_MOST){
         setMeasuredDimension(widthSpecSize, mHeight);
}
}
ViewGroup的measure过程

如果是一个 ViewGroup,除了完成自己的 measure 过程以外,还会遍历去调用所有子元素的 measure 方法,各个子元素再递归去执行 measure 过程。

ViewGroup 并没有重写 View 的 onMeasure 方法,但是它提供了 measureChildren、measureChild、measureChildWithMargins 这几个方法专门用于测量子元素。

// 遍历所有的子元素,并使用 measureChild 方法来对每一个子元素进行 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];
        // 只有当子元素的可见性不是 GONE 时,才对它进行测量。这一点是个细节。
        if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
        }
    }
}
// 测量每一个子元素:最重要的逻辑是通过 getChildMeasureSpec 方法来获取测量子元素所需要的宽度 MeasureSpec 和 高度 MeasureSpec。
// getChildMeasureSpec(int spec, int padding, int childDimension) 方法需要的参数必须搞清楚:
// spec 是 ViewGroup 从 onMeasure 方法接收到的 MeasureSpec,
// padding 是 ViewGroup 已使用的空间,childDimension 是子元素的布局参数的宽和高。
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);
}

protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

为什么ViewGroup 类没有像 View 一样对其 onMeasure 方法做统一的实现,实际上 ViewGroup 继承了 View 类对 onMeasure 方法的实现?这是因为不同的 ViewGroup 子类有不同的布局特性,这导致它们的测量细节各不相同,比如 LinearLayout 和 RelativeLayout 这两者的布局特性显然不同,因此 ViewGroup 无法统一实现。

为什么说在 onLayout 方法中去获取 View 的测量宽/高是一个好的习惯?

正常情况下,View 的 measure 过程完成以后,通过 getMeasuredWidth/getMeasuredHeight 方法就可以正确地获取到 View 的测量宽/高;但是,在某些极端情况下,系统可能需要多次 measure 才能确定最终的测量宽/高,在这种情形下,在 onMeasure 方法中拿到的测量宽/高很可能是不准确的。

在 Activity 启动时,如何获取某个 View 的宽/高?
方式 解释
Activity/View.onWindowFocusChanged onWindowFocusChanged 回调时,表示 View 已经初始化完毕了,宽/高已经准备好了,所以这时去获取宽/高是没有问题的。但是,onWindowFocusChanged会被多次调用,当 Activity 的窗口得到焦点和失去焦点时均会被调用一次。
View.post(Runnable runnable) 通过 post 可以将一个 Runnable 对象放到消息队列的尾部,然后等到 Looper 调用此 Runnable的时候,View 也已经初始化好了。注意:是 View.post方法而不是 Handler.post 方法
ViewTreeObserver 的多个回调,如 OnGlobalLayoutListener 接口。 OnGlobalLayoutListener 接口:当 View 树的状态发生改变或者 View 树内部的 View 的可见性发生改变时,它的 onGlobalLayout 方法将被回调。需要注意的是,伴随着 View 树的状态改变等,onGlobalLayout 会被调用多次。使用完接口后,记得移除。
使用 View.measure(int widthMeasureSpec, int heightMeasureSpec)方法进行手动测量 这种方法比较复杂。当 ViewLayoutParamsmatch_parent 时,这种方式不能测量出具体的宽/高;当 ViewLayoutParams 为具体的数值(如 100 dp)时,这种方式可以测量出具体的宽/高;当 View 的 LayoutParamswrap_content 时, 这种方式不能测量出具体的宽/高。

Layout

如果是 View 的话,那么在它的 layout 方法中就确定了自身的位置(具体来说是通过 setFrame 方法来设定 View 的四个顶点的位置,即初始化 mLeftmRightmTopmBottom 这四个值),layout 过程就结束了。

如果是ViewGroup 的话,那么在它的layout 方法中只是确定了 ViewGroup 自身的位置,要确定子元素的位置,就需要重写 onLayout方法;在 onLayout 方法中,会调用子元素的 layout 方法,子元素在它的layout 方法中确定自己的位置,这样一层一层地传递下去完成整个 View 树的 layout 过程。

layout 方法和 onLayout 方法的区别是什么?

layout 方法的作用是确定View本身的位置,即设定View 的四个顶点的位置,这样就确定了 View 在父容器中的位置;
onLayout 方法的作用是父容器确定子元素的位置,这个方法在 View 中是空实现,因为 View 没有子元素了,在 ViewGroup 中则进行抽象化,它的子类必须实现这个方法。

View 的 getMeasuredWidth 和 getWidth 这两个方法有什么区别?
  • getMeasuredWidth 获取的是测量宽度,定义了一个 View 想要在父 View 里的尺寸,getWidth 获取的是宽度,有时也被称为绘制宽度,定义了绘制时或者布局后屏幕上的 View 的实际尺寸。

  • 两者的赋值时机不同,测量宽/高的赋值时机要早于最终宽/高。具体来说,View 的测量宽/高形成于 Viewmeasure 过程,而View 的最终宽/高形成于 Viewlayout 过程。

  • 两者的值多数情况下是相等的,但在某些特殊情况下会不一致。例如有两种特殊情况:

  • 重写 View 的 layout 方法:手动修改传入 layout 方法的参数值;
  • View 需要多次 measure 才能确定自己的测量宽/高,在前几次的测量过程中,其得出的测量宽/高有可能和最终宽/高不一致,但最终来看,测量宽/高还是会和最终宽/高相同。

Draw

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

dispatchDraw 方法的调用是在 onDraw 方法之后,也就是说,总是先绘制自己再绘制子 View

对于 View 类来说,dispatchDraw 方法是空实现的,对于 ViewGroup 类来说,dispatchDraw 方法是有具体实现的。

ViewGroup 的 draw 过程是如何传递的

通过 dispatchDraw来传递的。dispatchDraw 会遍历调用子元素的 draw 方法,如此 draw 事件就一层一层传递了下去。dispatchDraw 在 View 类中是空实现的,在 ViewGroup 类中是真正实现的。

View 类中的 setWillNotDraw 方法的含义及其开发意义是什么?

如果一个 View 不需要绘制任何内容,那么就设置这个标记为 true,系统会进行进一步的优化。

当创建的自定义控件继承于 ViewGroup 并且不具备绘制功能时,就可以开启这个标记,便于系统进行后续的优化;当明确知道一个 ViewGroup 需要通过 onDraw 绘制内容时,需要关闭这个标记。

查看 LinearLayout 对这个方法的调用:setWillNotDraw(divider == null);,在有 divider 时才会关闭这个标记,否则是打开的;ScrollView 直接使用 setWillNotDraw(false);;ViewStub 类则使用 setWillNotDraw(true);。

自定义View

自定义View的分类
分类 用途 特点
1.继承 View 重写 onDraw 方法 用于实现一些不规则的效果,不方便通过布局的组合方式来达到 需要通过绘制的方式来实现,即重写 onDraw 方法;需要自己支持 wrap_content,处理 padding
2.继承 ViewGroup 派生特殊的 Layout 用于实现自定义的布局 稍微复杂一些,需要合适地处理 ViewGroup 的测量、布局这两个过程,并同时处理好子元素的测量和布局过程
3.继承特定的 View 用于扩展已有的 View 的功能 不需要自己支持 wrap_content 和 padding 等
4.继承特定的 ViewGroup 用于实现几种 View 组合在一起的效果 不要自己处理 ViewGroup 的测量和布局这两个过程,方法2能实现的效果一般方法4也可以实现,但方法2更接近 View 底层
自定义 View 的注意事项
  • 对于直接继承 View 或者ViewGroup的控件,要在 onMeasure 方法中对 wrap_content 做特殊处理;
  • 必要时,让自定义View支持 padding
  • 尽量不要在自定义 View 中使用 Handler,用 View 自己的 post 方法就行;
  • 及时关闭自定义 View 中的线程或者动画,比如在 onDetachedFromWindow 方法中;
  • 自定义 View 带有嵌套滑动时,要处理好滑动冲突;
  • 有自定义布局属性的,在构造方法中取得属性后应及时调用 recycle方法回收资源;
  • onDrawonTouchEvent方法中都应避免创建对象,过多操作会造成卡顿;
  • 自定义 ViewGroup 要重载关于 LayoutParams 的几个方法;
  • 必要时添加对 View 的状态存储与恢复的支持;
  • 对于自定义 ViewGroup,需要绘制内容但是又没有在布局中设置background的话,会画不出来,这时候需要调用setWillNotDraw方法,并设置为false。

参考:《Android开发艺术探索》

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