Android开发的艺术探索第四章

本文详细解析Android中View的工作流程,包括measure、layout和draw三大核心过程,以及MeasureSpec的作用和自定义View的技巧。文章深入浅出地介绍了ViewRoot和DecorView的角色,帮助开发者更好地掌握View的绘制和布局机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

View的工作原理

除了View的三大流程以外,View常见的回调方法也是需要熟练掌握的,比如构造方 法、onAttach、onVisibilityChanged、onDetach等。另外对于一些具有滑动效果的自定义 View,我们还需要处理View的滑动,如果遇到滑动冲突就还需要解决相应的滑动冲突。

4.1 初识ViewRoot和DecorView

在这里插入图片描述
其实DecorView是一个FrameLayout,里面是一个垂直的线性布局,在线性布局中分上下两部分FrameLayout,上面一部分是TitleBar,下面是android.R.id.content,我们平常的setContentView就是将布局加载在android.R.id.content中。
ViewRoot对应于ViewRootImpl类,它是连接WindowManager和DecorView的纽带, View的三大流程均是通过ViewRoot来完成的。在ActivityThread中,当Activity对象被创建 完毕后,会将DecorView添加到Window中,同时会创建ViewRootImpl对象,并将 ViewRootImpl对象和DecorView建立关联。

  root = new ViewRootImpl(view.getContext(),display);  
  root.setView(view,wparams,panelParentView);

View的绘制流程是从ViewRoot的performTraversals方法开始的,它经过measure、 layout和draw三个过程才能最终将一个View绘制出来,其中measure用来测量View的宽和 高,layout用来确定View在父容器中的放置位置,而draw则负责将View绘制在屏幕上。
在这里插入图片描述

4.2 理解MeasureSpec

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

4.2.1 MeasureSpec

在这里插入图片描述
MeasureSpec通过将SpecMode和SpecSize打包成一个int值来避免过多的对象内存分配,为了方便操作,其提供了打包和解包方法。SpecMode和SpecSize也是一个int值,一组SpecMode和SpecSize可以打包为一个MeasureSpec,而一个MeasureSpec可以通过解包的形 式来得出其原始的SpecMode和SpecSize,需要注意的是这里提到的MeasureSpec是指 MeasureSpec所代表的int值,而并非MeasureSpec本身。
SpecMode有三类,每一类都表示特殊的含义,如下所示。
UNSPECIFIED
父容器不对View有任何限制,要多大给多大,这种情况一般用于系统内部,表示一 种测量的状态。
EXACTLY
父容器已经检测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所 指定的值。它对应于LayoutParams中的match_parent和具体的数值这两种模式。
AT_MOST
父容器指定了一个可用大小即SpecSize,View的大小不能大于这个值,具体是什么值 要看不同View的具体实现。它对应于LayoutParams中的wrap_content。

4.2.2 MeasureSpec和LayoutParams的对应关系

MeasureSpec不是唯一 由LayoutParams决定的,LayoutParams需要和父容器一起才能决定View的MeasureSpec,从而进一步决定View的宽/高。另外,对于顶级View(即DecorView)和普通View来说, MeasureSpec的转换过程略有不同。对于DecorView,其MeasureSpec由窗口的尺寸和其自身的LayoutParams来共同确定;对于普通View,其MeasureSpec由父容器的MeasureSpec和 自身的LayoutParams来共同决定,MeasureSpec一旦确定后,onMeasure中就可以确定View 的测量宽/高。
对于DecorView来说,在ViewRootImpl中的measureHierarchy方法中有如下一段代码, 它展示了DecorView的MeasureSpec的创建过程,其中desiredWindowWidth和desiredWindowHeight是屏幕的尺寸:
在这里插入图片描述
在这里插入图片描述
通过上述代码,DecorView的MeasureSpec的产生过程就很明确了,具体来说其遵守如 下规则,根据它的LayoutParams中的宽/高的参数来划分。
LayoutParams.MATCH_PARENT:精确模式,大小就是窗口的大小;
LayoutParams.WRAP_CONTENT:最大模式,大小不定,但是不能超过窗口的大 小;
固定大小(比如100dp):精确模式,大小LayoutParams中指定的大小。

对于普通View来说,先看一下ViewGroup的measureChildWithMargins方法
在这里插入图片描述
上述方法会对子元素进行measure,在调用子元素的measure方法之前会先通过 getChildMeasureSpec方法来得到子元素的MeasureSpec。从代码来看,很显然,子元素的 MeasureSpec的创建与父容器的MeasureSpec和子元素本身的LayoutParams有关,此外还和 View的margin及padding有关。
在这里插入图片描述
对于普通View,其MeasureSpec由父 容器的MeasureSpec和自身的LayoutParams来共同决定,那么针对不同的父容器和View本 身不同的LayoutParams,View就可以有多种MeasureSpec。这里简单说一下,当View采用 固定宽/高的时候,不管父容器的MeasureSpec是什么,View的MeasureSpec都是精确模式 并且其大小遵循Layoutparams中的大小。当View的宽/高是match_parent时,如果父容器的 模式是精准模式,那么View也是精准模式并且其大小是父容器的剩余空间;如果父容器 是最大模式,那么View也是最大模式并且其大小不会超过父容器的剩余空间。当View的 宽/高是wrap_content时,不管父容器的模式是精准还是最大化,View的模式总是最大化并 且大小不能超过父容器的剩余空间。可能读者会发现,在我们的分析中漏掉了 UNSPECIFIED模式,那是因为这个模式主要用于系统内部多次Measure的情形,一般来 说,我们不需要关注此模式。

4.3 View的工作流

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

4.3.1 measure过程
  1. View的measure过程
    在这里插入图片描述在这里插入图片描述
    在这里插入图片描述
    简单地理解,其实getDefaultSize返回的大小就是 measureSpec中的specSize,而这个specSize就是View测量后的大小,这里多次提到测量后 的大小,是因为View最终的大小是在layout阶段确定的,所以这里必须要加以区分,但是 几乎所有情况下View的测量大小和最终大小是相等的。
    至于UNSPECIFIED这种情况,一般用于系统内部的测量过程,在这种情况下,View 的大小为getDefaultSize的第一个参数size,即宽/高分别为getSuggestedMinimumWidth和 getSuggestedMinimumHeight这两个方法的返回值,看一下它们的源码:
    在这里插入图片描述
    从getSuggestedMinimumWidth的代码可以看出,如果View没有设置背景,那么View的宽度为mMinWidth,而mMinWidth对应于android:minWidth这个属性所 指定的值,因此View的宽度即为android:minWidth属性所指定的值。这个属性如果不指 定,那么mMinWidth则默认为0;如果View指定了背景,则View的宽度为 max(mMinWidth,mBackground.getMinimumWidth())。mMinWidth的含义我们已经知道了, 那么mBackground.getMinimumWidth()是什么呢?我们看一下Drawable的getMinimumWidth 方法,如下所示。
    在这里插入图片描述
    可以看出,getMinimumWidth返回的就是Drawable的原始宽度,前提是这个Drawable 有原始宽度,否则就返回0。那么Drawable在什么情况下有原始宽度呢?这里先举个例子 说明一下,ShapeDrawable无原始宽/高,而BitmapDrawable有原始宽/高(图片的尺寸)。
    再总结一下getSuggestedMinimumWidth的逻辑:如果View没有设置背景,那么返 回android:minWidth这个属性所指定的值,这个值可以为0;如果View设置了背景,则返 回android:minWidth和背景的最小宽度这两者中的最大值,getSuggestedMinimumWidth和 getSuggestedMinimumHeight的返回值就是View在UNSPECIFIED情况下的测量宽/高。

  2. ViewGroup的measure过程
    对于ViewGroup来说,除了完成自己的measure过程以外,还会遍历去调用所有子元 素的measure方法,各个子元素再递归去执行这个过程。和View不同的是,ViewGroup是 一个抽象类,因此它没有重写View的onMeasure方法,但是它提供了一个叫 measureChildren的方法,如下所示。
    在这里插入图片描述
    在这里插入图片描述
    很显然,measureChild的思想就是取出子元素的LayoutParams,然后再通过 getChildMeasureSpec来创建子元素的MeasureSpec,接着将MeasureSpec直接传递给View的 measure方法来进行测量。
    我们知道,ViewGroup并没有定义其测量的具体过程,这是因为ViewGroup是一个抽象类,其测量过程的onMeasure方法需要各个子类去具体实现,比如LinearLayout、 RelativeLayout等,为什么ViewGroup不像View一样对其onMeasure方法做统一的实现呢? 那是因为不同的ViewGroup子类有不同的布局特性,这导致它们的测量细节各不相同,比 如LinearLayout和RelativeLayout这两者的布局特性显然不同,因此ViewGroup无法做统一 实现。下面就通过LinearLayout的onMeasure方法来分析ViewGroup的measure过程,其他 Layout类型读者可以自行分析。
    在这里插入图片描述
    系统会遍历子元素并对每个子元素执行measureChildBeforeLayout方法,这个方法内部会调用子元素的measure方法,这样各个子元素就开始依 次进入measure过程,并且系统会通过mTotalLength这个变量来存储LinearLayout在竖直方 向的初步高度。每测量一个子元素,mTotalLength就会增加,增加的部分主要包括了子元 素的高度以及子元素在竖直方向上的margin等。当子元素测量完毕后,LinearLayout会测 量自己的大小。
    View的measure过程是三大流程中最复杂的一个,measure完成以后,通过 getMeasured-Width/Height方法就可以正确地获取到View的测量宽/高。需要注意的是,在 某些极端情况下,系统可能需要多次measure才能确定最终的测量宽/高,在这种情形下, 在onMeasure方法中拿到的测量宽/高很可能是不准确的。一个比较好的习惯是在onLayout 方法中去获取View的测量宽/高或者最终宽/高。

上面已经对View的measure过程进行了详细的分析,现在考虑一种情况,比如我们想 在Activity已启动的时候就做一件任务,但是这一件任务需要获取某个View的宽/高。读者 可能会说,这很简单啊,在onCreate或者onResume里面去获取这个View的宽/高不就行 了?读者可以自行试一下,实际上在onCreate、onStart、onResume中均无法正确得到某个 View的宽/高信息,这是因为View的measure过程和Activity的生命周期方法不是同步执行 的,因此无法保证Activity执行了onCreate、onStart、onResume时某个View已经测量完毕 了,如果View还没有测量完毕,那么获得的宽/高就是0。有没有什么方法能解决这个问题 呢?答案是有的,这里给出四种方法来解决这个问题:
(1)Activity/View#onWindowFocusChanged
onWindowFocusChanged这个方法的含义是:View已经初始化完毕了,宽/高已经准备 好了,这个时候去获取宽/高是没问题的。需要注意的是,onWindowFocusChanged会被调 用多次,当Activity的窗口得到焦点和失去焦点时均会被调用一次。具体来说,当Activity 继续执行和暂停执行时,onWindowFocusChanged均会被调用,如果频繁地进行onResume 和onPause,那么onWindowFocusChanged也会被频繁地调用。
在这里插入图片描述(2)view.post(runnable)
通过post可以将一个runnable投递到消息队列的尾部,然后等待Looper调用此runnable
的时候,View也已经初始化好了。在这里插入图片描述
(3)ViewTreeObserver
使用ViewTreeObserver的众多回调可以完成这个功能,OnGlobalLayoutListener这个接口,当View树的状态发生改变或者View树内部的View的可 见性发现改变时,onGlobalLayout方法将被回调,因此这是获取View的宽/高一个很好的时 机。需要注意的是,伴随着View树的状态改变等,onGlobalLayout会被调用多次。
在这里插入图片描述
(4)view.measure(int widthMeasureSpec,int heightMeasureSpec)

4.3.2 layout过程

Layout的作用是ViewGroup用来确定子元素的位置,当ViewGroup的位置被确定后, 它在onLayout中会遍历所有的子元素并调用其layout方法,在layout方法中onLayout方法又 会被调用。Layout过程和measure过程相比就简单多了,layout方法确定View本身的位置, 而onLayout方法则会确定所有子元素的位置。
在这里插入图片描述
layout方法的大致流程如下:首先会通过setFrame方法来设定View的四个顶点的位 置,即初始化mLeft、mRight、mTop和mBottom这四个值,View的四个顶点一旦确定,那 么View在父容器中的位置也就确定了;接着会调用onLayout方法,这个方法的用途是父容 器确定子元素的位置,和onMeasure方法类似,onLayout的具体实现同样和具体的布局有 关,所以View和ViewGroup均没有真正实现onLayout方法。接下来,我们可以看一下 LinearLayout的onLayout方法,如下所示。
在这里插入图片描述
在这里插入图片描述
这里分析一下layoutVertical的代码逻辑,可以看到,此方法会遍历所有子元素并调用 setChildFrame方法来为子元素指定对应的位置,其中childTop会逐渐增大,这就意味着后 面的子元素会被放置在靠下的位置,这刚好符合竖直方向的LinearLayout的特性。至于 setChildFrame,它仅仅是调用子元素的layout方法而已,这样父元素在layout方法中完成自己的定位以后,就通过onLayout方法去调用子元素的layout方法,子元素又会通过自己的 layout方法来确定自己的位置,这样一层一层地传递下去就完成了整个View树的layout过程。
在这里插入图片描述
下面我们来回答一个在4.3.2节中提到的问题:View的测量宽/高和最终/宽高有什么区 别?这个问题可以具体为:View的getMeasuredWidth和getWidth这两个方法有什么区别, 至于getMeasuredHeight和getHeight的区别和前两者完全一样。为了回答这个问题,首先, 我们看一下getwidth和getHeight这两个方法的具体实现:
:在View 的默认实现中,View的测量宽/高和最终宽/高是相等的,只不过测量宽/高形成于View的 measure过程,而最终宽/高形成于View的layout过程,即两者的赋值时机不同,测量宽/高 的赋值时机稍微早一些。

4.3.3 draw过程

Draw过程就比较简单了,它的作用是将View绘制到屏幕上面。View的绘制过程遵循 如下几步:
(1)绘制背景background.draw(canvas)。
(2)绘制自己(onDraw)。
(3)绘制children(dispatchDraw)。
(4)绘制装饰(onDrawScrollBars)。

4.4 自定义View

4.4.1 自定义View的分类
  1. 继承View重写onDraw方法
    这种方法主要用于实现一些不规则的效果,即这种效果不方便通过布局的组合方式来 达到,往往需要静态或者动态地显示一些不规则的图形。很显然这需要通过绘制的方式来 实现,即重写onDraw方法。采用这种方式需要自己支持wrap_content,并且padding也需要 自己处理。
  2. 继承ViewGroup派生特殊的Layout
    这种方法主要用于实现自定义的布局,即除了LinearLayout、RelativeLayout、 FrameLayout这几种系统的布局之外,我们重新定义一种新布局,当某种效果看起来很像 几种View组合在一起的时候,可以采用这种方法来实现。采用这种方式稍微复杂一些, 需要合适地处理ViewGroup的测量、布局这两个过程,并同时处理子元素的测量和布局过程。
  3. 继承特定的View(比如TextView)
    这种方法比较常见,一般是用于扩展某种已有的View的功能,比如TextView,这种 方法比较容易实现。这种方法不需要自己支持wrap_content和padding等。
  4. 继承特定的ViewGroup(比如LinearLayout)
    这种方法也比较常见,当某种效果看起来很像几种View组合在一起的时候,可以采用这种方法来实现。采用这种方法不需要自己处理ViewGroup的测量和布局这两个过程。 需要注意这种方法和方法2的区别,一般来说方法2能实现的效果方法4也都能实现,两者的主要差别在于方法2更接近View的底层。
4.4.2 自定义View须知
  1. 让View支持wrap_content
    这是因为直接继承View或者ViewGroup的控件,如果不在onMeasure中对wrap_content 做特殊处理,那么当外界在布局中使用wrap_content时就无法达到预期的效果,具体情形 已经在4.3.1节中进行了详细的介绍,这里不再重复了。
  2. 如果有必要,让你的View支持padding
    这是因为直接继承View的控件,如果不在draw方法中处理padding,那么padding属性 是无法起作用的。另外,直接继承自ViewGroup的控件需要在onMeasure和onLayout中考虑 padding和子元素的margin对其造成的影响,不然将导致padding和子元素的margin失效。
  3. 尽量不要在View中使用Handler,没必要
    这是因为View内部本身就提供了post系列的方法,完全可以替代Handler的作用,当 然除非你很明确地要使用Handler来发送消息。
  4. View中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow
    这一条也很好理解,如果有线程或者动画需要停止时,那么onDetachedFromWindow 是一个很好的时机。当包含此View的Activity退出或者当前View被remove时,View的 onDetachedFromWindow方法会被调用,和此方法对应的是onAttachedToWindow,当包含 此View的Activity启动时,View的onAttachedToWindow方法会被调用。同时,当View变得 不可见时我们也需要停止线程和动画,如果不及时处理这种问题,有可能会造成内存泄 漏。
  5. View带有滑动嵌套情形时,需要处理好滑动冲突
    如果有滑动冲突的话,那么要合适地处理滑动冲突,否则将会严重影响View的效 果,具体怎么解决滑动冲突请参看第3章。
4.4.3 自定义View示例
  1. 继承View重写onDraw方法
    这种方法主要用于实现一些不规则的效果,一般需要重写onDraw方法。采用这种方 式需要自己支持wrap_content,并且padding也需要自己处理。
    自定义属性:
    第一步,在values目录下面创建自定义属性的XML,比如attrs.xml,也可以选择类似 于attrs_circle_view.xml等这种以attrs_开头的文件名,当然这个文件名并没有什么限制,可 以随便取名字。
    在这里插入图片描述
    在上面的XML中声明了一个自定义属性集合“CircleView”,在这个集合里面可以有很 多自定义属性,这里只定义了一个格式为“color”的属性“circle_color”,这里的格式color指的是颜色。除了颜色格式,自定义属性还有其他格式,比如reference是指资源id, dimension是指尺寸,而像string、integer和boolean这种是指基本数据类型。
    全部属性值查看及使用

第二步,在View的构造方法中解析自定义属性的值并做相应处理。对于本例来说, 我们需要解析circle_color这个属性的值,代码如下所示。在这里插入图片描述
这看起来很简单,首先加载自定义属性集合CircleView,接着解析CircleView属性集 合中的circle_color属性,它的id为R.styleable.CircleView_circle_color。
第三步,在布局文件中使用自定义属性,
在这里插入图片描述
上面的布局文件中有一点需要注意,首先,为了使用自定义属性,必须在布局文件中 添加schemas声明:xmlns:app=http://schemas.android.com/apk/res-auto。在这个声明中,app 是自定义属性的前缀,当然可以换其他名字,但是CircleView中的自定义属性的前缀必须 和这里的一致,然后就可以在CircleView中使用自定义属性了,比如: app:circle_color="@color/light_green"。另外,也有按照如下方式声明schemas: xmlns:app=http://schemas.android.com/apk/res/com.ryg.chapter_4,这种方式会在apk/res/后面 附加应用的包名。但是这两种方式并没有本质区别,笔者比较喜欢的是 xmlns:app=http://schemas.android.com/apk/res-auto这种声明方式。

  1. 继承ViewGroup派生特殊的Layout
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值