View工作原理

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

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

初识 ViewRoot DecorView

在正式介绍View的三大流程之前,我们必须先介绍一些基本概念,这样才能更好地理Viewmeasure, layoutdraw过程,本节主要介绍ViewRootDecorView的概念。

ViewRoot 对应于 ViewRootlmpl 类,它是连接 WindowManager DecorView 的纽带, View的三大流程均是通过ViewRoot来完成的。在ActivityThread中,当Activity对象被创建完毕后,会将DecorView添加到Window中,同时会创建ViewRootlmpl对象,并将 ViewRootlmpl对象和DecorView建立关联,这个过程可参看如下源码:

root = new ViewRootlmpl(view.getContext(), display);
root.setview(view, wparams, panelParentView);

View的绘制流程是从ViewRootperformTraversals方法开始的,它经过measure、layoutdraw三个过程才能最终将一个View绘制出来,其中measure用来测量View的宽 和高,layout用来确定View在父容器中的放置位置,而draw则负责将View绘制在屏幕上。 针对performTraversals的大致流程,可用流程图4-1来表示。

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

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

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

                                                                图4-2顶级View DecorView的结构

理解 MeasureSpec

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

MeasureSpec

MeasureSpec代表一个32int值,高2位代表SpecMode,30位代表SpecSize, SpecMode是指测量模式,而SpecSize是指在某种测量模式下的规格大小。下面先看一下 MeasureSpec内部的一些常量的定义,通过下面的代码,应该不难理解MeasureSpec的工作 原理:

private static final int MODE__SHIFT = 30; 
private static final int MODE_MASK = 0x3 << MODE_SHIFT;
public static final int UNSPECIFIED = 0 << MODE_SHIFT;
public static final int EXACTLY = 1 << MODE_SHIFT;
public static final int AT_MOST = 2 << MODE_SHIFT;

public static int makeMeasureSpec(int size, int mode) { 
    if (sUseBrokenMakeMeasureSpec) { 
        return size + mode;
    } else {
        return (size & -MODE MASK) | (mode & MODE MASK);
    }
}

public static int getMode(int measureSpec) { 
    return (measureSpec & MODE_MASK);
}

public static int getSize(int measureSpec) {
    return (measureSpec & ~MODE_MASK);
}
    

MeasureSpec通过将SpecModeSpecSize打包成一个int值来避免过多的对象内存分 配,为了方便操作,其提供了打包和解包方法。SpecModeSpecSize也是一个int值,一 组SpecModeSpecSize可以打包为一个MeasureSpec,而一个MeasureSpec可以通过解包的形式来得出其原始的SpecModeSpecSize,需要注意的是这里提到的MeasureSpec是指 MeasureSpec所代表的int值,而并非MeasureSpec本身。

SpecMode有三类,每一类都表示特殊的含义,如下所示。

UNSPECIFIED

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

EXACTLY

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

AT_MOST

父容器指定了一个可用大小即SpecSize, View的大小不能大于这个值,具体是什么值要看不同View的具体实现。它对应于LayoutParams中的wrap_content。

MeasureSpec LayoutParams 的对应关系

上面提到,系统内部是通过MeasureSpec来进行View的测量,但是正常情况下我们使用View指定MeasureSpec,尽管如此,但是我们可以给View设置LayoutParams,View 测量的时候,系统会将LayoutParams在父容器的约束下转换成对应的MeasureSpec,然后再根据这个MeasureSpec来确定View测量后的宽/高。需要注意的是,MeasureSpec不是唯 一由LayoutParams决定的,LayoutParams需要和父容器一起才能决定ViewMeasureSpec, 从而进一步决定View的宽/高。另外,对于顶级View (即DecorView)和普通View来说, MeasureSpec的转换过程略有不同。对于DecorView,MeasureSpec由窗口的尺寸和其自身的LayoutParams来共同确定;对于普通View,MeasureSpec由父容器的MeasureSpec 和自身的LayoutParams来共同决定,MeasureSpec 一旦确定后,onMeasure中就可以确定 View的测量宽/高。

对于DecorView来说,在ViewRootlmpl中的measureHierarchy方法中有如下一段代码, 它展示了 DecorView MeasureSpec 的创建过程,其中 desiredWindowWidth desiredWindowHeight是屏幕的尺寸:

childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);

接着再看一下getRootMeasureSpec方法的实现:

private static int getRootMeasureSpec(int windowsize, int rootDimension) { 
    int measureSpec;
    switch (rootDimension) {
        case ViewGroup.LayoutParams.MATCH_PARENT:
            // Window can't resize. Force root view to be windowsize. 
            measureSpec = MeasureSpec.makeMeasureSpec(windowsize, MeasureSpec.EXACTLY);
            break;
        case ViewGroup.LayoutParams.WRAP_CONTENT:
            // Window can resize. Set max size for root view.
            measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
            break;
        default:
            // Window wants to be an exact size. Force root view to be that size. 
            measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, Measure- Spec. EXACTLY) ;
            break;
    }
    return measureSpec;
}

通过上述代码,DecorViewMeasureSpec的产生过程就很明确了,具体来说其遵守如下规则,根据它的LayoutParams中的宽/高的参数来划分。

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

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

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

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

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);
    int size = Math.max(0, specSize - padding);
    int resultsize = 0;
    int resultMode = 0;
    switch (specMode) {
        // Parent has imposed an exact size on us
        case MeasureSpec.EXACTLY:
            if (childDimension >= 0) {
                resultsize = childDimension; 
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) { 
                // Child wants to be our size. So be it.
                resultsize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) { 
                // Child wants to determine its own size. It can*t be 
                // bigger than us.
                resultsize = size; 
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent has imposed a maximum size on us
        case MeasureSpec.AT_MOST:
            if (childDimension > 0) {
                // Child wants a specific size... so be it 
                resultsize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) { 
                // Child wants to be our size, but our size is not fixed. 
                // Constrain child to not be bigger than us.
                resultsize = size; 
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) { 
                //Child wants to determine its own size. It can*t be bigger than us.
                resultsize = size; 
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent asked to see how big we want to be
        case MeasureSpec.UNSPECIFIED:
        if (childDimension >= 0) {
            // Child wants a specific size... let him have it 
            resultsize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size... find out how big it should 
            // be
            resultsize = 0;
            resultMode = MeasureSpec.UNSPECIFIED;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size.... find out how
            // big it should be 
            resultsize = 0;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
        break;
    }
    return MeasureSpec.makeMeasureSpec(resultsize, resultMode);
}

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

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

getChildMeasureSpec清楚展示了普通ViewMeasureSpec的创建规则,为了更清晰地 理解getChildMeasureSpec的逻辑,这里提供一个表,表中对getChildMeasureSpec的工作原理进行了梳理,请看表4.1。注意,表中的parentSize是指父容器中目前可使用的大小。

                               表4.1普通ViewMeasureSpec的创建规则

 

针对表4-1,这里再做一下说明。前面已经提到,对于普通View ,MeasureSpec由父容器的MeasureSpec和自身的LayoutParams来共同决定,那么针对不同的父容器和View 本身不同的LayoutParams, View就可以有多种MeasureSpec.这里简单说一下,当View釆用固定宽/高的时候,不管父容器的MeasureSpec是什么,ViewMeasureSpec都是精确模式并且其大小遵循LayoutParams中的大小。当View的宽/高是match_parent时,如果父容器的模式是精准模式,那么View也是精准模式并且其大小是父容器的剩余空间;如果父容器是最大模式,那么View也是最大模式并且其大小不会超过父容器的剩余空间。当View 的宽/高是wrap_content时,不管父容器的模式是精准还是最大化,View的模式总是最大化并且大小不能超过父容器的剩余空间。可能读者会发现,在我们的分析中漏掉了 UNSPECIFIED模式,那是因为这个模式主要用于系统内部多次Measure的情形,一般来说,我们不需要关注此模式。

通过表4-1可以看出,只要提供父容器的MeasureSpec和子元素的LayoutParams,就 可以快速地确定出子元素的MeasureSpec 了,有了 MeasureSpec就可以进一步确定出子元 素测量后的大小了。需要说明的是,表4-1并非是什么经验总结,它只是getChildMeasureSpec 这个方法以表格的方式呈现出来而已。

View的工作流程

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

measure 过程

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

1. View measure 过程

Viewmeasure过程由其measure方法来完成,measure方法是一个final类型的方法,

这意味着子类不能重写此方法,在Viewmeasure方法中会去调用ViewonMeasure方法,因此只需要看onMeasure的实现即可,ViewonMeasure方法如下所示。

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这个方法的逻辑很简单,对于我们来说,我们只需要看 AT_MOSTEXACTLY这两种情况。简单地理解,其实getDefaultSize返回的大小就是 measureSpec中的specSize.而这个specSize就是View测量后的大小,这里多次提到测量后的大小,是因为View最终的大小是在layout阶段确定的,所以这里必须要加以区分,但是几乎所有情况下View的测量大小和最终大小是相等的。

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

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

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

这里只分析 getSuggestedMinimumWidth 方法的实现,getSuggestedMinimumHeight 和它的实现原理是一样的。从getSuggestedMinimumWidth的代码可以看岀,如果View没有设置背景, 那么View的宽度为mMinWidth,mMinWidth对应于android: minWidth这个属性所指定的值, 因此View的宽度即为android:minWidth属性所指定的值。这个属性如果不指定,那么 mMinWidth则默认为0;如果View指定了背景,则View的宽度为max(mMinWidth, mBackground.getMinimumWidth())。mMinWidth 的含义我们已经知道了,那么 mBackground. getMinimumWidth()是什么呢?我们看一下DrawablegetMinimumWidth方法,如下所示。

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

可以看出,getMinimumWidth返回的就是Drawable的原始宽度,前提是这个Drawable 有原始宽度,否则就返回0。那么Drawable在什么情况下有原始宽度呢?这里先举个例子 说明一下,ShapeDrawable无原始宽/高,而BitmapDrawable有原始宽/高(图片的尺寸), 详细内容会在第6章进行介绍。

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

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

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {     
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    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 (heightSpecMode == MeasureSpec.AT_MOST) { 
        setMeasuredDimension(widthSpecSize, mHeight);
    }
}

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

2. ViewGroup measure 过程

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

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

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

protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
    final LayoutParams lp= child.getLayoutParams();
    final int chiIdWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpeC,mPaddingLeft + mPaddingRight, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,mPaddingTop + mPaddingBottom, lp.height);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

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

我们知道,ViewGroup并没有定义其测量的具体过程,这是因为ViewGroup是一个抽象类,其测量过程的onMeasure方法需要各个子类去具体实现,比如LinearLayout、RelativeLayout等,为什么ViewGroup不像View 一样对其onMeasure方法做统一的实现呢? 那是因为不同的ViewGroup子类有不同的布局特性,这导致它们的测量细节各不相同,比 如LinearLayoutRelativeLayout这两者的布局特性显然不同,因此ViewGroup无法做统 一实现。下面就通过LinearLayoutonMeasure方法来分析ViewGroupmeasure过程, 其他Layout类型读者可以自行分析。

首先来看LinearLayoutonMeasure方法,如下所示。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 
    if (mOrientation == VERTICAL) {
        measurevertical(widthMeasureSpec, heightMeasureSpec);
    } else {
        measureHorizontal(widthMeasureSpec, heightMeasureSpec);
    }
}

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

// See how tall everyone is. Also remember max width.
for (int i = 0; i < count; ++i) {
    final View child = getVirtualChildAt(i);
    // Determine how big this child would like to be. If this or 
    // previous children have given a weight, then we allow it to 
    // use all available space (and we will shrink things later if needed).
    measureChildBeforeLayout(child, i, widthMeasureSpec, 0, heightMeasureSpec, totalweight == 0 ? mTotalLength : 0);
    if (oldHeight != Integer.MIN_VALUE) {
        lp.height = oldHeight;
    }

    final int childHeight = child.getMeasuredHeight(); 
    final int totalLength = mTotalLength;
    mTotalLength=Math.max(totalLength,totalLength+childHeight+lp.topMargin + lp.bottomMargin + getNextLocationOffset(child));
}

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

// Add in our padding
mTotalLength += mPaddingTop + mPaddingBottom;
int heightsize = mTotalLength;
// Check against our minimum height
heightsize = Math.max(heightsize, getSuggestedMinimumHeight());
// Reconcile our calculated size with the heightMeasureSpec
int heightSizeAndState=resolveSizeAndState(heightsize, heightMeasureSpec, 0);
heightsize = heightSizeAndState & MEASURED_SIZE_MASK;
...
setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childstate),heightSizeAndState);

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

public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState){
    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: 
            if (specSize < size) {
                result = specSize | MEASURED_STATE_TOO_SMALL;
            } else {
                result = size;
            }
            break;
        case MeasureSpec.EXACTLY: 
            result = specSize;
            break;
    }
    return result | (childMeasuredState&MEASURED_STATE_MASK);
}

View的measure过程是三大流程中最复杂的一个,measure完成以后,通过getMeasuredWidth/Height方法就可以正确地获取到View的测量宽/高。需要注意的是,在某些极端情况 下,系统可能需要多次measure才能确定最终的测量宽/高,在这种情形下,在onMeasure 方法中拿到的测量宽/高很可能是不准确的。一个比较好的习惯是在onLayout方法中去获取 View的测量宽/高或者最终宽/高。

上面已经对Viewmeasure过程进行了详细的分析,现在考虑一种情况,比如我们想在Activity己启动的时候就做一件任务,但是这一件任务需要获取某个View的宽/高。读者可能会说,这很简单啊,在onCreate或者onResume里面去获取这个View的宽/高不就 行了?读者可以自行试一下,实际上在onCreate. onStartonResume中均无法正确得到某个View的宽/高信息,这是因为Viewmeasure过程和Activity的生命周期方法不是同步执行的,因此无法保证Activity执行了 onCreateonStartonResume时某个View己经测量 完毕了,如果View还没有测量完毕,那么获得的宽/高就是0。有没有什么方法能解决这个问题呢?答案是有的,这里给出四种方法来解决这个问题:

  • Activity/View#onWindowFocusChanged。

onWindowFocusChanged这个方法的含义是:View已经初始化完毕了,宽/高己经准备 好了,这个时候去获取宽/高是没问题的。需要注意的是,onWindowFocusChanged会被调用多次,当Activity的窗口得到焦点和失去焦点时均会被调用一次。具体来说,当Activity 继续执行和暂停执行时,onWindowFocusChanged均会被调用,如果频繁地进行onResume onPause,那么onWindowFocusChanged也会被频繁地调用。典型代码如下:

public void onWindowFocusChanged(boolean hasFocus) { 
    super.onWindowFocusChanged(hasFocus);
    if (hasFocus) {
        int width = view.getMeasuredWidth();
        int height = view.getMeasuredHeight();
    }
}
  • view.post(runnable).

通过post可以将一个runnable投递到消息队列的尾部,然后等待Looper调用此runnable 的时候,View也已经初始化好了。典型代码如下:

protected void onStart() {
    super.onStart();
    view.post(new Runnable() {

        @Override 
        public void run() { 
            int width = view.getMeasuredWidth(); 
            int height = view.getMeasuredHeight();

        }
    });
}
  • ViewTreeObserver.

使用ViewTreeObserver的众多回调可以完成这个功能,比如使用 OnGlobalLayoutListener这个接口,当View树的状态发生改变或者View树内部的View的可见性发现改变时,onGlobalLayout方法将被回调,因此这是获取View的宽/高一个很好的时机。需要注意的是,伴随着View树的状态改变等,onGlobalLayout会被调用多次。典型代码如下:

protected void onStart() {
    super.onStart();
    ViewTreeObserver observer = view.getViewTreeObserver();
    observer.addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
        @SuppressWarnings("deprecation") 
        @Override
        public void onGlobalLayout() (
            view.getViewTreeObserver().removeGlobalOnLayoutListener(this); 
            int width = view.getMeasuredWidth();
            int height = view.getMeasuredHeight();
        }
    });
}
  • view.measure(int widthMeasureSpec, int heightMeasureSpec).

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

match_parent

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

具体的数值(dp/px)

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

int widthMeasureSpec = MeasureSpec.makeMeasureSpec(100, MeasureSpec. EXACTLY);
int heightMeasureSpec = MeasureSpec.makeMeasureSpec(100, MeasureSpec. EXACTLY);
view.measure(widthMeasureSpec, heightMeasureSpec);

wrap_content

如下 measure

int widthMeasureSpec = MeasureSpec.makeMeasureSpec((1 << 30) - 1, MeasureSpec.AT_MOST);
int heightMeasureSpec = MeasureSpec.makeMeasureSpec((1 << 30) - 1, MeasureSpec.AT_MOST);
view.measure(widthMeasureSpec, heightMeasureSpec);

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

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

第一种错误用法:

int widthMeasureSpec = MeasureSpec.makeMeasureSpec(-1, MeasureSpec.UNSPECIFIED);
int heightMeasureSpec = MeasureSpec.makeMeasureSpec(-1, MeasureSpec.UNSPECIFIED);
view.measure(widthMeasureSpec, heightMeasureSpec);

第二种错误用法:

view.measure(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);

layout 过程

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

public void layout(int 1, int t, int r, int b) {
    if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEF0RE_LAY0UT) != 0) {     
        onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec); 
        mPrivateFlags3 &= ~PFLAG3__MEASURE_NEEDED_BEFORE_LAYOUT;
    }
    int oldL = mLeft;
    int oldT = mTop;
    int oldB = mBottom;
    int oldR = mRight;
    boolean changed = isLayoutModeOptical(mParent) ?setOpticalFrame(1, t, r, b) : setFrame(1, t, r, b); 
    if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
        onLayout(changed, 1, t, r, b);
        mPrivateFlags &= -PFLAG_LAYOUT_REQUIRED;
        Listenerinfo li = mListenerlnfo;
        if (li != null && li.mOnLayoutChangeListeners != null) {
            ArrayList<OnLayoutChangeListener> listenersCopy = (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
            int numListeners = listenersCopy.size();
            for (int i = 0; i < numListeners; ++i) (         
                listenersCopy.get(i).onLayoutChange(this, 1, t, r, b, oldL, oldT, oldR, oldB);
                mPrivateFlags &= -PFLAG_FORCE_LAYOUT; mPrivateFlags? | = PFLAG3__IS__LAID_OUT;
            }
        }
    }
}    

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

protected void onLayout(boolean changed, int 1, int t, int r, int b) { 
    if (mOrientation == VERTICAL) {
        layoutvertical(1, t, r, b);
    } else {
        layoutHorizontal (1, t, r, b);
    }
}

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

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);
            LinearLayout.LayoutParams lp =(LinearLayout.LayoutParams) child.getLayoutParams();
            if (hasDividerBeforeChildAt(i)) { 
                childTop += mDividerHeight;
                childTop += lp.topMargin;
                setChildFrame(child, childLeft, childTop + getLocationOffset (child),childwidth, childHeight);
                childTop += childHeight + lp.bottomMargin + getNextLocation- Offset(child);
                i += getChildrenSkipCount(child, i);
            }
        }
    }
}

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

private void setChildFrame(View child, int left, int top, int width, int height) {
    child.layout (left, top, left + width, top + height);
}

我们注意到,setChildFrame中的widthheight实际上就是子元素的测量宽/高,从下 面的代码可以看出这一点:

final int childwidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
setChildFrame(child, childLeft, childTop + getLocationOffset(child), childwidth, childHeight);

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

mLeft = left; 
mTop = top; 
mRight = right; 
mBottom = bottom;

下面我们来回答一个在4.3.2节中提到的问题:View的测量宽/高和最终/宽高有什么区 别?这个问题可以具体为:ViewgetMeasuredWidthgetWidth这两个方法有什么区别, 至于getMeasuredHeightgetHeight的区别和前两者完全一样。为了回答这个问题,首先, 我们看一下getWidthgetHeight这两个方法的具体实现:

public final int getWidth() { 
    return mRight - mLeft;
}

public final int getHeight() { 
    return mBottom - mTop;
}

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

public void layout(int 1, int t, int r, int b){
    super.layout(1, t, r + 100, b + 100);
}

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

draw 过程

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

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

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

public void draw(Canvas canvas) (
    final int privateFlags = mPrivateFlags;
    final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK)== PFLAG_DIRTY_OPAQUE &&(mAttachlnfo == null || ImAttachlnfo.mlgnoreDirtyState); 
    mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;

    /*
    Draw traversal performs several drawing steps which must be executed
    1. Draw the background
    2. If necessary, save the canvas1 layers to prepare for fading
    3. Draw view`s content
    4. Draw children
    5. If necessary, draw the fading edges and restore layers
    6. Draw decorations (scrollbars for instance)
    */

    // Step 1, draw the background, if needed
    int saveCount;
    if (!dirtyOpaque) {
        drawBackground(canvas);
    }

    // skip step 2 & 5 if possible (common case)
    final int viewFlags = mViewFlags;
    boolean horizontalEdges = (viewFlags & FADING__EDGE_HORIZONTAL) != 0; 
    boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0; 
    if (!verticalEdges && !horizontalEdges) {
        // Step 3, draw the content
        if (!dirtyOpaque) onDraw(canvas);
        // Step 4, draw the children
        dispatchDraw(canvas);
        // Step 6, draw decorations (scrollbars) 
        onDrawScrollBars(canvas);
        if (mOverlay != null && SmOverlay.isEmpty()) {         
            mOverlay.getOverlayView().dispatchDraw(canvas);
        }
        // we're done...
        return;
    }
}

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

/**
If this view doesn1t do any drawing on its own, set this flag to
allow further optimizations. By default, this flag is not set on
View, but could be set on some View subclasses such as ViewGroup.
Typically, if you override (@link #onDraw(android.graphics.Canvas))you should clear this flag.
@param willNotDraw whether or not this View draw on its own
*/

public void setWillNotDraw(boolean willNotDraw) { 
    setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW__MASK);
}

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

自定义View

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

自定义View的分类

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

1.继承View重写on Draw方法

这种方法主要用于实现一些不规则的效果,即这种效果不方便通过布局的组合方式来 达到,往往需要静态或者动态地显示一些不规则的图形。很显然这需要通过绘制的方式来 实现,即重写onDraw方法。采用这种方式需要自己支持wrap content.并且padding也需 要自己处理。

2.继承ViewGroup派生特殊的Layout

这种方法主要用于实现自定义的布局,即除了 LinearLayoutRelativeLayout> FrameLayout这几种系统的布局之外,我们重新定义一种新布局,当某种效果看起来很像几 种View组合在一起的时候,可以釆用这种方法来实现。釆用这种方式稍微复杂一些,需要 合适地处理ViewGroup的测量、布局这两个过程,并同时处理子元素的测量和布局过程。

3.继承特定的View 比如TextView)

这种方法比较常见,一般是用于扩展某种已有的View的功能,比如TextView,这种 方法比较容易实现。这种方法不需要自己支持wrap contentpadding等。

4.继承特定的 ViewGroup 比如 LinearLayout)

这种方法也比较常见,当某种效果看起来很像几种View组合在一起的时候,可以釆用 这种方法来实现。釆用这种方法不需要自己处理ViewGroup的测量和布局这两个过程。需 要注意这种方法和方法2的区别,一般来说方法2能实现的效果方法4也都能实现,两者 的主要差别在于方法2更接近View的底层。

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

自定义View须知

本节将介绍自定义View过程中的一些注意事项,这些问题如果处理不好,有些会影响 View的正常使用,而有些则会导致内存泄露等,具体的注意事项如下所示。

1.让 View 支持 wrap_content

这是因为直接继承View或者ViewGroup的控件,如果不在onMeasure中对wrapcontent 做特殊处理,那么当外界在布局中使用wrap_content时就无法达到预期的效果,具体情形 已经在4.3.1节中进行了详细的介绍,这里不再重复了。

2.如果有必要,让你的View支持padding

这是因为直接继承View的控件,如果不在draw方法中处理padding.那么padding属 性是无法起作用的。另夕卜,直接继承自ViewGroup的控件需要在onMeasureonLayout 中考虑padding和子元素的margin对其造成的影响,不然将导致padding和子元素的margin 失效。

3.尽量不要在View中使用Handler,没必要

这是因为View内部本身就提供了 post系列的方法,完全可以替代Handler的作用,当然除非你很明确地要使用Handler来发送消息。

4.View中如果有线程或者动画,需要及时停止,参考View#onDetachedFromWindow

这一条也很好理解,如果有线程或者动画需要停止时,那么onDetachedFromWindow 是一个很好的时机。当包含此ViewActivity退出或者当前Viewremove时,ViewonDetachedFromWindow方法会被调用,和此方法对应的是onAttachedToWindow,当包含 此ViewActivity启动时,ViewonAttachedToWindow方法会被调用。同时,当View 变得不可见时我们也需要停止线程和动画,如果不及时处理这种问题,有可能会造成内存泄漏。

5.View带有滑动嵌套情形时,需要处理好滑动冲突

如果有滑动冲突的话,那么要合适地处理滑动冲突,否则将会严重影响View的效果, 具体怎么解决滑动冲突请参看第3章。

自定义View示例

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

1.继承View重写onDraw方法

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

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

public class CircleView extends View {
    private int mColor = Color.RED;
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    public CircleView(Context context) {
        super(context);
        init();
    }

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

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {     
        super(contextz attrs, defStyleAttr);
        init();
    }
    
    private void init() { 
        mPaint.setcolor(mColor);
    }

    @Override
    protected void onDraw(Canvas canvas) { 
        super.onDraw(canvas); 
        int width = getWidth(); 
        int height = getHeight();
        int radius = Math.min(width, height) / 2;
        canvas.drawCircle(width / 2, height / 2, radius, mPaint);
    }
}

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

请看下面的布局:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 
    xmlns:tools="http://schemas.android.com/tools" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent"
    android:background="#ffffff"
    android:orientation="vertical" >
    <com.ryg.chapter_4.ui.Circleview
        android:id="@+id/circleViewl"
        android:layout_width="match_parent"
        android:layout_height="100dp" 
        android:background="#000000"/>

</LinearLayout>

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

<com.ryg.chapter_4.ui.Circleview
    android:id="@+id/circleViewl"
    android:layout_width="match_parent"
    android:layout_height="100dp" 
    android:layout_margin="20dp" 
    android:background="#000000"/>

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

<com.ryg.chapter_4.ui.CircleView
    android:id="@+id/circleViewl"
    android:Layou_width="match_parent"
    android:layout_height="100dp" 
    android:layout_margin="20dp" 
    android:padding="20dp" 
    android:background="#000000"/>

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

<com.ryg.chapter_4.ui.CircleView 
    android:id="@+id/circleViewl" 
    android:layout_width="wrap_content" 
    android:layout_height="100dp" 
    android:layout_margin="20dp" 
    android:padding="20dp" 
    android:background="#000000"/>

运行后看一下效果,如图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作为默认的宽/高。

 

 

                                                                 图4-3 CircleView运行效果图

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

protected void onDraw(Canvas canvas) { 
    super.onDraw(canvas);
    paddingLeft = getPaddingLeft(); 
    paddingRight = getPaddingLeft(); 
    paddingTop = getPaddingLeft(); 
    paddingBottom = getPaddingLeft();
    int width = getWidth() - paddingLeft - paddingRight;
    int height = getHeight() - paddingTop - paddingBottom; 
    int radius = Math.min(width/ height) / 2;
    canvas,drawCircle(paddingLeft + width / 2, paddingTop + height/2, radius.mPaint);
}

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

<com.ryg.chapter_4.ui.Circleview 
    android:id="@+id/circleViewl" 
    android:layout_width="wrap_content" 
    android:layout_height="100dp" 
    android:layout_margin="20dp" 
    android:padding="20dp"
    android:background="#000000"/>

针对上面的布局参数,我们再次运行一下,结果如图4.4中的1)所示,可以发现布 局参数中的wrap contentpadding均生效了。

                                                                图4-4 CircleView运行效果图

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

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

<?xml version="l.0" encoding="utf-8"?> 
<resources>
    <declare-styleable name="CircleView">
        <attr name="circle_color" format="color" /> 
    </declare-styleable>
</resources>

在上面的XML中声明了一个自定义属性集合“CircleView”在这个集合里面可以有 很多自定义属性,这里只定义了一个格式为“color”的属性“circle_color”这里的格式color 指的是颜色。除了颜色格式,自定义属性还有其他格式,比如reference是指资源id, dimension 是指尺寸,而像string, integerboolean这种是指基本数据类型。除了列举的这些还有其他类型,这里就不一一描述了,读者查看一下文档即可,这并没有什么难度。

第二步,在View的构造方法中解析自定义属性的值并做相应处理。对于本例来说,我 们需要解析circle_color这个属性的值,代码如下所示。

public Circleview(Context context. Attributeset attrs, int defStyleAttr) { 
    super(context, attrs, defStyleAttr);
    TypedArray a = context.obtainStyledAttributes(attrs, R.styleable. Circleview);
    mColor = a.getColor(styleable.CircleView_circle_color, Color.RED);
    a.recycle();
    init();
}

这看起来很简单,首先加载自定义属性集合CircleView,接着解析CircleView属性集合中的 circle_color 属性,它的 id R.styleable.CircleView_circle_coIor. 在这一步骤中,如 果在使用时每有指定circle_color这个属性,那么就会选择红色论为默认的颜色值,解析完 自定义属性后,通过recycle方法来实现资源,这样CircleView中所做的工作就完成了。

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

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"     
    xmlns:app="http://schemas.android.com/apk/res-auto" 
    android:layout_width=Mmatch_parent" 
    android:layout_height="match_parent" 
    android:background="#ffffff" 
    android:orientation="vertical" >

    <com.ryg.chapter_4.ui.Circleview
        android:id="@+id/circleViewl" 
        android:layout_width="wrap_content" 
        android:layout_height="100dp" 
        android:layout_margin="20dp" 
        app:circle_color="@color/light_green" 
        android:padding="20dp" 
        android:background="#000000"/>

</LinearLayout>

上面的布局文件中有一点需要注意,首先,为了使用自定义属性,必须在布局文件中添加 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 这种声明方式。

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

public class CircleView extends View {
    private int mColor = Color.RED; 
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    public CircleView(Context context) {
        super(context);
        init ();
    }

    public CircleView(Context context, AttributeSet attrs) { 
        this(context, attrs, 0);
    }

    public CircleView (Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrsz defStyleAttr);
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable. CircleView);
        mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED); 
        a.recycle();
        init();
    }

    private void init () {
        mPaint.setColor(mColor);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {     
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); 
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); 
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); 
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); 
        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, 200);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) { 
            setMeasuredDimension(200, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {         
            setMeasuredDimension(widthSpecSizez 200);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        paddingLeft = getPaddingLeft(); 
        paddingRight = getPaddingLeft(); 
        paddingTop = getPaddingLeft(); 
        paddingBottom = getPaddingLeft();
        int width = getWidth() - paddingLeft - paddingRight;
        int height = getHeight() - paddingTop - paddingBottom; 
        int radius = Math.min(width, height) / 2;
        canvas.drawcircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
   }
}

2.继承ViewGroup派生特殊的Layout

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

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

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

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.getMeasured- Height());
    } else if (widthSpecMode == MeasureSpec.AT_MOST) {
        final View childview = getChildAt(0); 
        measuredWidth = childView.getMeasuredWidth() * childCount;     
        setMeasuredDimension(measuredWidth, heightSpaceSize);
}

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

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

接着再看一下HorizontalScrollViewExonLayout方法,如下所示。

protected void onLayout(boolean changed, int 1, 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;
        }
     }
}



上述代码的逻辑并不复杂,其作用是完成子元素的定位。首先会遍历所有的子元素, 如果这个子元素不是处于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 mChildlndex;
    //分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
    //分别记录上次滑动的坐标(onlnterceptTouchEvent)  
    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, attrsz defStyle); 
        init();
    }

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

    @Override
    public boolean onlnterceptTouchEvent(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 (ImScroller.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:
                Log.d(TAG, Mintercepted=" + 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) {
                    mChildlndex = xVelocity > 0 ? mChildlndex - 1 : mChildlndex + 1; 
                } else {
                    mChildlndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                mChildlndex = Math.max (0/ Math.min(mChildlndex, mChildrenSize - 1)); 
                int dx = mChildlndex * 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 lr 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();
        }
    }

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

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

自定义View的思想

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值