View的工作原理

在本章中主要介绍两方面的内容,首先介绍View的工作原理,接着介绍自定义View的实现方式。在Android的知识体系中,View扮演着很重要的角色,简单来理解,View是 Android在视觉上的呈现。在界面上Android提供了一套GUI库,里面有很多控件,但是很多时候我们并不满足于系统提供的控件,因为这样就意味这应用界面的同类化比较严重。 那么怎么才能做出与众不同的效果呢?答案是自定义View,也可以叫自定义控件,通过自定义View我们可以实现各种五花八门的效果。但是自定义View是有一定难度的,尤其是复杂的自定义View,大部分时候我们仅仅了解基本控件的使用方法是无法做出复杂的自定义控件的。为了更好地自定义View,还需要掌握View的底层工作原理,比如View的测量流程、布局流程以及绘制流程,掌握这几个基本流程后,我们就对View的底层更加了解, 这样我们就可以做出一个比较完善的自定义View。

除了View的三大流程以外,View常见的回调方法也是需要熟练掌握的,比如构造方法、onAttach、onVisibilityChanged、onDetach等。另外对于一些具有滑动效果的自定义View,我们还需要处理View的滑动,如果遇到滑动冲突就还需要解决相应的滑动冲突,关于滑动和滑动冲突这一块内容已经在第3章中进行了全面介绍。自定义View的实现看起来很复杂,实际上说简单也简单。总结来说,自定义View是有几种固定类型的,有的直接继承自View和ViewGroup,而有的则选择继承现有的系统控件,这些都可以,关键是要选择最适合当前需要的方式,选对自定义View的实现方式可以起到事半功倍的效果,下面就围绕着这些话题一一展开。

4.1 初识ViewRoot和DecorView

在正式介绍View的三大流程之前,我们必须先介绍一些基本概念,这样才能更好地理解View的measure、layout和draw过程,本节主要介绍ViewRoot和DecorView的概念。

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绘制在屏幕上。 针对performTraversals的大致流程,可用流程图4-1来表示。

在这里插入图片描述
如图4-1所示,performTraversals会依次调用 performMeasure、performLayout 和 performDraw 这三个方法,这三个方法分别完成顶级View的measure、layout和draw这三大流程,其中在performMeasure中会调用measure方法,在measure方法中又会调用onMeasure方法,在onMeasure方法中则会对所有的子元素进行measure过程,这个时候measure流程就从父容器传递到子元素中了,这样就完成了一次measure过程。接着子元素会重复父容器的measure过程,如此反复就完成了整个View树的遍历。同理,performLayout和performDraw的传递流程和performMeasure是类似的,唯一不同的是,performDraw的传递过程是在draw方法中通过dispatchDraw来实现的,不过这并没有本质区别

measure过程决定了View的宽/高,Measure完成以后,可以通过getMeasuredWidth和getMeasuredHeight方法来获取到View测量后的宽/高,在几乎所有的情况下它都等同于 View最终的宽/高,但是特殊情况除外,这点在本章后面会进行说明。

Layout过程决定了View的四个顶点的坐标和实际的View的宽/高,完成以后,可以通过getTop、getBottom、getLeft和getRight来拿到View的四个顶点的位置,并可以通过getWidth和getHeight方法来拿到View的最终宽/高

Draw过程则决定了View的显示,只有draw方法完成以后View的内容才能呈现在屏幕上
在这里插入图片描述
如图4-2所示,DecorView作为顶级View,一般情况下它内部会包含一个竖直方向的LinearLayout,在这个LinearLayout里面有上下两个部分(具体情况和Android版本及主题有关),上面是标题栏,下面是内容栏。在Activity中我们通过setContentView所设置的布局文件其实就是被加到内容栏之中的,而内容栏的id是content,因此可以理解为Activity指定布局的方法不叫setview而叫setContentView,因为我们的布局的确加到了id为content 的FrameLayout中。如何得到content呢?可以这样:ViewGroup content = findViewByld(R.android.id.content)如何得到我们设置的View呢?可以这样:content.getChildAt(0)。同时,通过源码我们可以知道,DecorView其实是一个FrameLayout,View层的事件都先经过DecorView,然后才传递给我们的View

4.2 理解MeasureSpec

为了更好地理解View的测量过程,我们还需要理解MeasureSpec。从名字上来看,MeasureSpec看起来像’'测量规格”或者“测量说明书”,不管怎么翻译,它看起来都好像是或多或少地决定了View的测量过程。通过源码可以发现,MeasureSpec的确参与了View的measure过程。读者可能有疑问,MeasureSpec是干什么的呢?确切来说,MeasureSpec在很大程度上决定了一个View的尺寸规格,之所以说是很大程度上是因为measure这个过程还受父容器的影响,因为父容器影响View的MeasureSpec的创建过程。在测量过程中,系统会将View的LayoutParams根据父容器所施加的规则转换成对应的MeasureSpec,然后再根据这个measureSpec来测量出View的宽/高。上面提到过,这里的宽/高是测量宽/高,不一定等于View的最终宽/高。MeasureSpec看起来有点复杂,其实它的实现是很简单的,下面会详细地分析MeasureSpec。

4.2.1 MeasureSpec

MeasureSpec代表一个32位int值,高2位代表SpecMode,低30位代表SpecSize,SpecMode是指测量模式,而SpecSize是指在某种测量模式下的规格大小。下面先看一下 MeasureSpec内部的一些常量的定义,通过下面的代码,应该不难理解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来进行View的测量,但是正常情况下我们使用View指定MeasureSpec尽管如此,但是我们可以给View设置LayoutParams在View测量的时候,系统会将LayoutParams在父容器的约束下转换成对应的MeasureSpec,然后再根据这个MeasureSpec来确定View测量后的宽/高。需要注意的是,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是屏幕的尺寸
在这里插入图片描述
接着再看一下getRootMeasureSpec方法的实现:
在这里插入图片描述
通过上述代码,DecorView的MeasureSpec的产生过程就很明确了,具体来说其遵守如下规则,根据它的LayoutParams中的宽/高的参数来划分

  • LayoutParams.MATCH_PARENT:精确模式,大小就是窗口的大小;
  • LayoutParams.WRAP_CONTENT:最大模式,大小不定,但是不能超过窗口的大小:
  • 固定大小(比如100dp):精确模式,大小为LayoutParams中指定的大小。

对于普通View来说,这里是指我们布局中的View,View的measure过程由ViewGroup传递而来,先看一下ViewGroup的measureChildWithMargins方法

在这里插入图片描述
上述方法会对子元素进行measure,在调用子元素的measure方法之前会先通过getChildMeasureSpec方法来得到子元素的MeasureSpec。从代码来看,很显然,子元素的 MeasureSpec的创建与父容器的MeasureSpec和子元素本身的LayoutParams有关,此外还和View的margin及padding有关,具体情况可以看一下ViewGroup的getChildMeasureSpec方法,如下所示。

在这里插入图片描述在这里插入图片描述在这里插入图片描述
上述方法不难理解,它的主要作用是根据父容器的MeasureSpec同时结合View本身的LayoutParams来确定子元素的MeasureSpec,参数中的padding是指父容器中已占用的空间大小,因此子元素可用的大小为父容器的尺寸减去padding,具体代码如下所示。

int specSize = MeasureSpec.getSize(spec);
int size = Math.max(0, specSize - padding);

getChildMeasureSpec清楚展示了普通View的MeasureSpec的创建规则,为了更清晰地理解getChildMeasureSpec的逻辑,这里提供一个表,表中对getChildMeasureSpec的工作原理进行了梳理,请看表4-1。注意,表中的parentSize是指父容器中目前可使用的大小
在这里插入图片描述
针对表4-1,这里再做一下说明。前面已经提到,对于普通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-1可以看出,只要提供父容器的MeasureSpec和子元素的LayoutParams,就可以快速地确定出子元素的MeasureSpec了有了MeasureSpec就可以进一步确定出子元素测量后的大小了。需要说明的是,表4-1并非是什么经验总结,它只是getChildMeasureSpec这个方法以表格的方式呈现出来而已。

4.3 View的工作流程

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

4.3.1 measure过程

measure过程要分情况来看,如果只是一个原始的View,那么通过measure方法就完成了其测量过程,如果是一个ViewGroup,除了完成自己的测量过程外,还会遍历去调用所有子元素的measure方法,各个子元素再递归去执行这个流程,下面针对这两种情况分别讨论。

1. View的measure过程

View的measure过程由其measure方法来完成,measure方法是一个final类型的方法,这意味着子类不能重写此方法,在View的measure方法中会去调用View的onMeasure方法,因此只需要看onMeasure的实现即可,View的onMeasure方法如下所示。
在这里插入图片描述
上述代码很简洁,但是简洁并不代表简单,setMeasuredDimension方法会设置View宽/高的测量值,因此我们只需要看getDefaultSize这个方法即可:

在这里插入图片描述
可以看出,getDefaultSize这个方法的逻辑很简单,对于我们来说,我们只需要看AT_MOST和EXACTLY这两种情况。简单地理解,其实getDefaultSize返回的大小就是measureSpec中的specSize,而这个specSize就是View测量后的大小,这里多次提到测量后的大小,是因为View最终的大小是在layout阶段确定的,所以这里必须要加以区分,但是几乎所有情况下View的测量大小和最终大小是相等的

至于UNSPECIFIED这种情况,一般用于系统内部的测量过程,在这种情况下,View的大小为getDefaultSize的第一个参数size,即宽/高分别为getSuggestedMinimumWidth和 getSuggestedMinimumHeight这两个方法的返回值,看一下它们的源码:

在这里插入图片描述
这里只分析 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有原始宽/高(图片的尺寸),详细内容会在第6章进行介绍。

这里再总结一下getSuggestedMinimumWidth的逻辑如果View没有设置背景,那么返回android:minWidth这个属性所指定的值,这个值可以为0;如果View设置了背景,则返回android:minWidth和背景的最小宽度这两者中的最大值getSuggestedMinimumWidth和getSuggestedMinimumHeight的返回值就是View在UNSPECIFIED情况下的测量宽/高

从getDefaultSize方法的实现来看,View的宽/高由specSize决定,所以我们可以得出如下结论直接继承View的自定义控件需要重写onMeasure方法并设置wrap_content时的自身大小,否则在布局中使用wrap_content就相当于使用match_parent。为什么呢?这个原因需要结合上述代码和表4-1才能更好地理解。从上述代码中我们知道,如果View在布局中使用wrap_content,那么它的specMode是AT_MOST模式,在这种模式下,它的宽/高等于specSize;查表4-1可知,这种情况下View的specSize是parentSize,而parentSize是父容器中目前可以使用的大小,也就是父容器当前剩余的空间大小。很显然,View的宽/高就等于父容器当前剩余的空间大小,这种效果和在布局中使用match_parent完全一致。如何解决这个问题呢?也很简单,代码如下所示。

在这里插入图片描述
在上面的代码中,我们只需要给View指定一个默认的内部宽/高(mWidth和mHeight), 并在wrap_content时设置此宽/高即可对于非wrap_content情形,我们沿用系统的测量值即可,至于这个默认的内部宽/高的大小如何指定,这个没有固定的依据,根据需要灵活指定即可。如果查看TextView、ImageView等的源码就可以知道,针对wrap_content情形,它们的onMeasure方法均做了特殊处理,读者可以自行查看它们的源码。

2. ViewGroup的measure过程

对于ViewGroup来说,除了完成自己的measure过程以外,还会遍历去调用所有子元素的measure方法,各个子元素再递归去执行这个过程。和View不同的是,ViewGroup是一个抽象类,因此它没有重写View的onMeasure方法,但是它提供了一个叫measurechildren的方法,如下所示。

在这里插入图片描述在这里插入图片描述
从上述代码来看,ViewGroup在measure时,会对每一个子元素进行measure,measureChild这个方法的实现也很好理解,如下所示。

在这里插入图片描述
很显然,measureChild的思想就是取出子元素的LayoutParams,然后再通过getChildMeasureSpec来创建子元素的MeasureSpec,接着将MeasureSpec直接传递给View的measure方法来进行测量。getChildMeasureSpec的工作过程己经在上面进行了详细分析,通过表4-1可以更清楚地了解它的逻辑。

我们知道,ViewGroup并没有定义其测量的具体过程,这是因为ViewGroup是一个抽象类,其测量过程的onMeasure方法需要各个子类去具体实现,比如LinearLayout、RelativeLayout等,为什么ViewGroup不像View一样对其onMeasure方法做统一的实现呢? 那是因为不同的ViewGroup子类有不同的布局特性,这导致它们的测量细节各不相同,比如LinearLayout和RelativeLayout这两者的布局特性显然不同,因此ViewGroup无法做统一实现。

下面就通过LinearLayout的onMeasure方法来分析ViewGroup的measure过程,其他Layout类型读者可以自行分析。

首先来看LinearLayout的onMeasure方法,如下所示。

在这里插入图片描述

上述代码很简单,我们选择一个来看一下,比如选择查看竖直布局的LinearLayout的测量过程,即measureVertical方法,measureVertical的源码比较长,下面只描述其大概逻辑, 首先看一段代码:

在这里插入图片描述
从上面这段代码可以看出,系统会遍历子元素并对每个子元素执行measureChildBeforeLayout方法,这个方法内部会调用子元素的measure方法,这样各个子元素就开始依 次进入measure过程,并且系统会通过mTotalLength这个变量来存储LinearLayout在竖直方向的初步高度。每测量一个子元素,mTotalLength就会增加,增加的部分主要包括了子元素的高度以及子元素在竖直方向上的margin等。当子元素测量完毕后,LinearLayout会测量自己的大小,源码如下所示。

在这里插入图片描述
这里对上述代码进行说明,当子元素测量完毕后,LinearLayout会根据子元素的情况来测量自己的大小。针对竖直的LinearLayout而言,它在水平方向的测量过程遵循View的测量过程,在竖直方向的测量过程则和View有所不同。具体来说是指,如果它的布局中高度采用的是match_parent或者具体数值,那么它的测量过程和View—致,即高度为specSize;如果它的布局中高度采用的是wrap_content,那么它的高度是所有子元素所占用的高度总和,但是仍然不能超过它的父容器的剩余空间,当然它的最终高度还需要考虑其在竖直方向的padding,这个过程可以进一步参看如下源码:

在这里插入图片描述在这里插入图片描述
View的measure过程是三大流程中最复杂的一个,measure完成以后,通过getMeasuredWidth/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)

通过手动对View进行measure来得到View的宽/高。这种方法比较复杂,这里要分情况处理,根据View的LayoutParams来分

match_parent

直接放弃,无法measure出具体的宽/高。原因很简单,根据View的measure过程,如表4-1所示,构造此种MeasureSpec需要知道parentSize,即父容器的剩余空间,而这个时候我们无法知道parentSize的大小,所以理论上不可能测量出View的大小

具体的数值(dp/px)

比如宽/高都是100px,如下measure:

在这里插入图片描述
wrap_parent

如下measure:

在这里插入图片描述

注意到(1<<30)-1,通过分析MeasureSpec的实现可以知道,View的尺寸使用30位二进制表示,也就是说最大是30个1(即2^30-1),也就是(1<<30)-1,在最大化模式下,
我们用View理论上能支持的最大值去构造MeasureSpec是合理的。

关于View的measure,网络上有两个错误的用法。为什么说是错误的,首先其违背了系统的内部实现规范(因为无法通过错误的MeasureSpec去得出合法的SpecMode,从而导致measure过程出错),其次不能保证一定能measure出正确的结果

第一种错误用法:

在这里插入图片描述

第二种错误用法:

在这里插入图片描述

4.3.2 layout过程

Layout的作用是ViewGroup用来确定子元素的位置,当ViewGroup的位置被确定后,它在onLayout中会遍历所有的子元素并调用其layout方法,在layout方法中onLayout方 法又会被调用。Layout过程和measure过程相比就简单多了,layout方法确定View本身的 位置,而onLayout方法则会确定所有子元素的位置,先看View的layout方法,如下所示。

在这里插入图片描述在这里插入图片描述
layout方法的大致流程如下:首先会通过setFrame方法来设定View的四个顶点的位置,即初始化mLeft、mRight、mTop和mBottom这四个值,View的四个顶点一旦确定,那么View在父容器中的位置也就确定了接着会调用onLayout方法,这个方法的用途是父容器确定子元素的位置,和onMeasure方法类似,onLayout的具体实现同样和具体的布局有关,所以View和ViewGroup均没有真正实现onLayout方法。接下来,我们可以看一下LinearLayout的onLayout 方法,如下所示。

在这里插入图片描述
LinearLayout中onLayout的实现逻辑和onMeasure的实现逻辑类似,这里选择layoutvertical继续讲解,为了更好地理解其逻辑,这里只给出了主要的代码:

在这里插入图片描述
这里分析一下layoutvertical的代码逻辑,可以看到,此方法会遍历所有子元素并调用setChildFrame方法来为子元素指定对应的位置,其中childTop会逐渐增大,这就意味着后面的子元素会被放置在靠下的位置,这刚好符合竖直方向的LinearLayout的特性。至于setChildFrame,它仅仅是调用子元素的layout方法而已,这样父元素在layout方法中完成自己的定位以后,就通过onLayout方法去调用子元素的layout方法,子元素又会通过自己的layout方法来确定自己的位置,这样一层一层地传递下去就完成了整个View树的layout 过程。setChildFrame方法的实现如下所示。

在这里插入图片描述在这里插入图片描述
我们注意到,setChildFrame中的width和height实际上就是子元素的测量宽/高,从下面的代码可以看出这一点:

在这里插入图片描述
而在layout方法中会通过setFrame去设置子元素的四个顶点的位置,在setFrame中有如下几句赋值语句,这样一来子元素的位置就确定了

在这里插入图片描述
下面我们来回答一个在4.3.2节中提到的问题:View的测量宽/高和最终/宽高有什么区别?这个问题可以具体为:View的getMeasuredWidth和getWidth这两个方法有什么区别,至于getMeasuredHeight和getHeight的区别和前两者完全一样。为了回答这个问题,首先,我们看一下getwidth和getHeight这两个方法的具体实现:

在这里插入图片描述
从getWidth和getHeight的源码再结合mLeft、mRight、mTop和mBottom这四个变量的赋值过程来看,getWidth方法的返回值刚好就是View的测量宽度,而getHeight方法的 返回值也刚好就是View的测量高度。经过上述分析,现在我们可以回答这个问题了:在View的默认实现中,View的测量宽/高和最终宽/高是相等的,只不过测量宽/高形成于View的measure过程,而最终宽/高形成于View的layout过程,即两者的值时机不同,测量宽/高的赋值时机稍微早一些。因此,在日常开发中,我们可以认为View的测量宽/高就等于最终宽/高,但是的确存在某些特殊情况会导致两者不一致,下面举例说明。

如果重写View的layout方法,代码如下:

在这里插入图片描述
上述代码会导致在任何情况下View的最终宽/高总是比测量宽/高大100px,虽然这样做会导致View显示不正常并且也没有实际意义,但是这证明了测量宽/高的确可以不等于最终宽/高。另外一种情况是在某些情况下,View需要多次measure才能确定自己的测量宽/高,在前几次的测量过程中,其得出的测量宽/高有可能和最终宽/高不一致,但最终来说,测量宽/高还是和最终宽/高相同

4.3.3 draw过程

Draw过程就比较简单了,它的作用是将View绘制到屏幕上面。View的绘制过程遵循如下几步

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

这一点通过draw方法的源码可以明显看出来,如下所示。

在这里插入图片描述在这里插入图片描述
View绘制过程的传递是通过dispatchDraw来实现的,dispatchDraw会遍历调用所有子元素的draw方法,如此draw事件就一层层地传递了下去View有一个特殊的方法setWillNotDraw,先看一下它的源码,如下所示。

在这里插入图片描述
从setWillNotDraw这个方法的注释中可以看出,如果一个View不需要绘制任何内容,那么设置这个标记位为true以后,系统会进行相应的优化默认情况下,View没有启用这个优化标记位,但是ViewGroup会默认启用这个优化标记位。这个标记位对实际开发的意义是:当我们的自定义控件继承于ViewGroup并且本身不具备绘制功能时,就可以开启这个标记位从而便于系统进行后续的优化。当然,当明确知道一个ViewGroup需要通过onDraw来绘制内容时,我们需要显式地关闭WILL_NOT_DRAW这个标记位

4.4 自定义View

本节将详细介绍自定义View相关的知识。自定义View的作用不用多说,这个读者都应该清楚,如果想要做出绚丽的界面效果仅仅靠系统的控件是远远不够的,这个时候就必 须通过自定义View来实现这些绚丽的效果。自定义View是一个综合的技术体系,它涉及View的层次结构、事件分发机制和View的工作原理等技术细节,而这些技术细节每一项又都是初学者难以掌握的,因此就不难理解为什么初学者都觉得自定义View很难这一现状。考虑到这一点,本书在第3章和第4章的前半部分对自定义View的各种技术细节都做了详细的分析,目的就是为了让读者更好地掌握本节的内容。尽管自定义View很难,甚至面对各种复杂的效果时往往还会觉得有点无章可循。但是,本节将从一定高度来重新审视自定义View,并以综述的形式介绍自定义View的分类和须知,旨在帮助初学者能够透过现象看本质,避免陷入只见树木不见森林的状态之中。同时为了让读者更好地理解自定义View,在本节最后还会针对自定义View的不同类别分别提供一个实际的例子,通过这些例子能够让读者更深入地掌握自定义View。

4.4.1 自定义View的分类

自定义View的分类标准不唯一,而笔者则把自定义View分为4类。

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的底层

上面介绍了自定义View的4种方式,读者可以仔细体会一下,是不是的确可以这么划分?但是这里要说的是,自定义View讲究的是灵活性,一种效果可能多种方法都可以实现,我们需要做的就是找到一种代价最小、最高效的方法去实现,在4.4.2节会列举一些自定义View过程中常见的注意事项。

4.4.2 自定义View须知

本节将介绍自定义View过程中的一些注意事项,这些问题如果处理不好,有些会影响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示例

4.4.1节和4.4.2节分别介绍了自定义View的类别和注意事项,本节将通过几个实际的例子来演示如何自定义一个规范的View,通过本节的例子再结合上面两节的内容,可以让读者更好地掌握自定义View,下面仍然按照自定义View的分类来介绍具体的实现细节。

1. 继承View重写onDraw方法

这种方法主要用于实现一些不规则的效果,一般需要重写onDraw方法。采用这种方式需要自己支持wrap_content,并且padding也需要自己处理。下面通过一个具体的例子来 演示如何实现这种自定义View.

为了更好地展示一些平时不容易注意到的问题,这里选择实现一个很简单的自定义控件,简单到只是绘制一个圆,尽管如此,需要注意的细节还是很多的。为了实现一个规范的控件,在实现过程中必须考虑到wrap_content模式以及padding,同时为了提高便捷性,还要对外提供自定义属性。我们先来看一下最简单的实现,代码如下所示。

在这里插入图片描述在这里插入图片描述
上面的代码实现了一个具有圆形效果的自定义View,它会在自己的中心点以宽/高的最小值为直径绘制一个红色的实心圆,它的实现很简单,并且上面的代码相信大部分初学 者都能写出来,但是不得不说,上面的代码只是一种初级的实现,并不是一个规范的自定义View,为什么这么说呢?我们通过调整布局参数来对比一下。

请看下面的布局:

在这里插入图片描述
在这里插入图片描述再看一下运行的效果,如图4-3中的(1)所示,这是我们预期的效果。接着再调整CircleView的布局参数,为其设置20dp的margin,调整后的布局如下所示。

在这里插入图片描述在这里插入图片描述
运行后看一下效果,如图4-3中的(2)所示,这也是我们预期的效果,这说明margin属性是生效的。这是因为margin属性是由父容器控制的,因此不需要在CircleView中做特 殊处理。再调整CircleView的布局参数,为其设置20dp的padding,如下所示。

在这里插入图片描述
运行后看一下效果,如图4-3中的(3)所示。结果发现padding根本没有生效,这就是我们在前面提到的直接继承自View和ViewGroup的控件,padding是默认无法生效的,需要自己处理。再调整一下CircleView的布局参数,将其宽度设置为wrap_content,如下所示。

在这里插入图片描述
运行后看一下效果,如图4-3中的(4)所示,结果发现wrap_content并没有达到预期的效果。对比下(3)和(4)的效果图,发现宽度使用wrap_content和使用match_parent没有任何区别。的确是这样的,这一点在前面也己经提到过:对于直接继承自View的控件,如果不对wrap_content做特殊处理,那么使用wrap_content就相当于使用match_parent

为了解决上面提到的几种问题,我们需要做如下处理:

首先,针对wrap_content的问题,其解决方法在4.3.1节中已经做了详细的介绍,这里只需要指定一个wrap_content模式的默认宽/高即可,比如选择200px作为默认的宽/高。

其次,针对padding的问题,也很简单,只要在绘制的时候考虑一下padding即可,因此我们需要对onDraw稍微做一下修改,修改后的代码如下所示。

在这里插入图片描述
上面的代码很简单,中心思想就是在绘制的时候考虑到View四周的空白即可,其中圆心和半径都会考虑到View四周的padding,从而做相应的调整。

在这里插入图片描述
针对上面的布局参数,我们再次运行一下,结果如图4-4中的(1)所示,可以发现布局参数中的wrap_content和padding均生效了。

在这里插入图片描述
最后,为了让我们的View更加容易使用,很多情况下我们还需要为其提供自定义属性,像android:layout_width和android:padding这种以android开头的属性是系统自带的属性, 那么如何添加自定义属性呢?这也不是什么难事,遵循如下几步:

第一步,在values目录下面创建自定义属性的XML,比如attrs.xml也可以选择类似于attrs_circle_view.xml等这种以attrs_开头的文件名,当然这个文件名并没有什么限制,可 以随便取名字。针对本例来说,选择创建attrs.xml文件,文件内容如下:

在这里插入图片描述
在上面的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。在这一步骤中,如果在使用时没有指定circle_color这个属性,那么就会选择红色论为默认的颜色值,解析完自定义属性后,通过recycle方法来实现资源,这样CircleView中所做的工作就完成了。

第三步,在布局文件中使用自定义属性,如下所示。

在这里插入图片描述
上面的布局文件中有一点需要注意,首先,为了使用自定义属性,必须在布局文件中添加 schemas 声明:xmlns:app=http://schemas.android.com/apk/res-auto在这个声明中,app 是自定义属性的前缀,当然可以换其他名字,但是CircleView中的自定义属性的前缀必须和这里的一致,然后就可以在 CircleView 中使用自定义属性了,比如:app:circle_color= “@color/light_green”。另外,也有按照如下方式声明 schemas: xmlns:app=http:// schcmas.android.com/apk/res/com.ryg.chapter_4,这种方式会在apk/res/后面附加应用的包名。但是这两种方式并没有本质区别, 笔者比较喜欢的是 xmlns:app=http://schemas.android.com/apk/res-auto这种声明方式。

到这里自定义属性的使用过程就完成了,运行一下程序,效果如图4-4中的(2)所示,很显然,CircleView的自定义属性circle_color生效了。下面给出CircleView的完整代码, 这时的CircleView已经是一个很规范的自定义View了,如下所示。

在这里插入图片描述

2. 继承ViewGroup派生特殊的Layout

这种方法主要用于实现自定义的布局,采用这种方式稍微复杂一些,需要合适地处理ViewGroup的测量、布局这两个过程,并同时处理子元素的测量和布局过程。在第3章的3.5.3节中,我们分析了滑动冲突的两种方式并实现了两个自定义View: HorizontalScrollViewEx 和 StickyLayout。其中 HorizontalScrollViewEx就是通过继承ViewGroup来实现的自定义View,这里会再次分析它的measure和layout过程。

需要说明的是,如果要采用此种方法实现一个很规范的自定义View,是有一定的代价的,这点通过查看LinearLayout等的源码就知道,它们的实现都很复杂。对于HorizontalScrollViewEx来说,这里不打算实现它的方方面面,仅仅是完成主要功能,但是需要规范化的地方会给出说明。

这里再回顾一下HorizontalScrollViewEx的功能,它主要是一个类似于ViewPager的控件,也可以说是一个类似于水平方向的LinearLayout的控件,它内部的子元素可以进行水平滑动并且子元素的内部还可以进行竖直滑动,这显然是存在滑动冲突的,但是HorizontalScrollViewEx内部解决了水平和竖直方向的滑动冲突问题。关于HorizontalScrollViewEx是如何解决滑动冲突的,请参看第3章的相关内容。这里有一个假设,那就是所有子元素的宽/高都是一样的。下面主要看一下它的onMeasure和onLayout方法的实现,先看onMeasure,如下所示。

在这里插入图片描述在这里插入图片描述
这里说明一下上述代码的逻辑,首先会判断是否有子元素,如果没有子元素就直接把自己的宽/高设为0然后就是判断宽和高是不是采用了wrap_content,如果宽采用了 wrap_content,那么HorizontalScrollViewEx的宽度就是所有子元素的宽度之和如果高度采用了wrap_content.那么HorizontalScrollViewEx的高度就是第一个子元素的高度

上述代码不太规范的地方有两点第一点是没有子元素的时候不应该直接把宽/高设为0 ,而应该根据LayoutParams中的宽/高来做相应处理第二点是在测量HorizontalScrollViewEx的宽/高时没有考虑到它的padding以及子元素的margin,因为它的padding以及子元素的margin会影响到HorizontalScrollViewEx的宽/高。这是很好理解的,因为不管是自己的padding还是子元素的margin,占用的都是HorizontalScrollViewEx的空间。

接着再看一下HorizontalScrollViewEx的onLayout方法,如下所示。

在这里插入图片描述
上述代码的逻辑并不复杂,其作用是完成子元素的定位。首先会遍历所有的子元素, 如果这个子元素不是处于GONE这个状态,那么就通过layout方法将其放置在合适的位置上。从代码上来看,这个放置过程是由左向右的,这和水平方向的LinearLayout比较类似。 上述代码的不完美之处仍然在于放置于元素的过程没有考虑到自身的padding以及子元素的margin,而从一个规范的控件的角度来看,这些都是应该考虑的。下面给出HorizontalScrollViewEx的完整代码,如下所示。

public class HorizontalScrollViewEx extends ViewGroup {
    private static final String TAG = "HorizontalScrollViewEx";

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;

    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
    // 分别记录上次滑动的坐标(onInterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;

    public HorizontalScrollViewEx(Context context) {
        super(context);
        init();
    }

    public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public HorizontalScrollViewEx(Context context, AttributeSet attrs,
            int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

    private void init() {
        if (mScroller == null) {
            mScroller = new Scroller(getContext());
            mVelocityTracker = VelocityTracker.obtain();
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            intercepted = false;
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
                intercepted = true;
            }
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int deltaX = x - mLastXIntercept;
            int deltaY = y - mLastYIntercept;
            if (Math.abs(deltaX) > Math.abs(deltaY)) {
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false;
            break;
        }
        default:
            break;
        }

        Log.d(TAG, "intercepted=" + intercepted);
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;

        return intercepted;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
            }
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int deltaX = x - mLastX;
            int deltaY = y - mLastY;
            scrollBy(-deltaX, 0);
            break;
        }
        case MotionEvent.ACTION_UP: {
            int scrollX = getScrollX();
            mVelocityTracker.computeCurrentVelocity(1000);
            float xVelocity = mVelocityTracker.getXVelocity();
            if (Math.abs(xVelocity) >= 50) {
                mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
            } else {
                mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
            }
            mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
            int dx = mChildIndex * mChildWidth - scrollX;
            smoothScrollBy(dx, 0);
            mVelocityTracker.clear();
            break;
        }
        default:
            break;
        }

        mLastX = x;
        mLastY = y;
        return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measuredWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measuredWidth, measuredHeight);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measuredWidth, heightSpaceSize);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;

        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + childWidth,
                        childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    }

    private void smoothScrollBy(int dx, int dy) {
        mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
        invalidate();
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}

继承特定的View(比如TextView)和继承特定的ViewGroup(比如LinearLayout)这两种方式比较简单,这里就不再举例说明了,关于第3章中提到的StickyLayout的具体实现,大家可以参看笔者在Github上的开源项目:https://github.com/singwhatiwanna/PinnedHeaderExpandableListView

4.4.4 自定义View的思想

到这里,自定义View相关的知识都己经介绍完了,可能读者还是觉得有点模糊。前面说过,自定义View是一个综合的技术体系,很多情况下需要灵活地分析从而找出最高效的方法,因此本章不可能去分析一个个具体的自定义View的实现,因为自定义View五花八门,是不可能全部分析一遍的。虽然我们不能把自定义View都分析一遍,但是我们能够提取出一种思想,在面对陌生的自定义View时,运用这个思想去快速地解决问题。这种思想的描述如下:首先要掌握基本功,比如View的弹性滑动、滑动冲突、绘制原理等,这些东西都是自定义View所必须的,尤其是那些看起来很炫的自定义View,它们往往对这些技术点的要求更高;熟练掌握基本功以后,在面对新的自定义View时,要能够对其分类并选择合适的实现思路,自定义View的实现方法的分类在4.4.1节中已经介绍过了;另外平时还需要多积累一些自定义View相关的经验,并逐渐做到融会贯通,通过这种思想慢慢地就可以提高自定义View的水平了。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页