🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
第三章终于进入开发的正题了!本章主要介绍自定义控件: #### **一、Android 控件架构** Android中的每个控件都会在界面中占得一块矩形的区域,而在Android中,控件大致被分为两类,即ViewGroup控件与View控件。ViewGroup控件作为父控件可以包含多个View控件,并管理其包含的View控件。通过ViewGroup,整个界面上的控件形成了一个树形结构,这也就是我们常说的控件树,**上层控件负责下层子控件的测量与绘制,井传递交互事件**。通常在Activity中使用的findViewById()方法,就是在控件树中以树的深度优先遍历来查找对应元素。**在每个控件树的顶部,都有一个ViewParent对象,这就是整棵树的控制核心,所有的交互管理事件都由它来统一调度和分配,从而可以对整个视图进行整体控制**。下图展示一个View视图树。 :-: ![](https://box.kancloud.cn/93ef98c9d5c7f9617a9b750f08db50e3_789x426.jpg) 图1View树结构 Activity包含一个Window对象,通常是由PhoneWindow类来实现的,PhoneWindow对象又将一个DecorView设置为整个应用的根View。DecorView作为窗口界面的顶层视图,封装了一些窗口操作的通用方法,DecorView将要显示的内容呈现在了PhoneWindow上,这里所有View的监听事件都通过WindowManagerService来接收,并通过Activity对象来回调onClickListener。DecorView在显示上分为TitleView和ContentView两部分,如下图所示。可以通过如下代码获得ContentView: ~~~ ViewGroup content=(ViewGroup)findViewById(android.R.id.content); ~~~ :-: ![](https://img.kancloud.cn/f0/27/f027120f562a667c622092a747298cfc_1152x648.jpg) 图2 UI界面架构图 :-: ![](https://img.kancloud.cn/67/c2/67c2547be7c3e6b6dfc51091c143efa3_419x661.png) 图3 标准视图树 Activity的setContentView()方法,其实最终调用PhoneWindow的setContentView()方法,从该方法可以看出**先得到当前的一个窗体**(通过getWindow()方法)即**一个Activity一定会有一个当前的window**,当Activity实例化时,一定会实例化一个并且仅有一个window,**window本身不是显示的视图,只是一个窗户玻璃,窗户玻璃上的窗花(实际上是view)才是真正的视图**,而且window实际上是其唯一的一个实例化的子类PhoneWindow,之后调用phoneWindow的setContentView方法,该方法内部会有一个mContentParent.addView(view, params)方法,来添加view视图,而**这个窗花怎么裁剪和贴到玻璃呢,是通过LayoutInflater()和addView()。** >[info] 补充: > LayoutInflater是一个用来实例化XML布局文件为View对象的类 > LayoutInflater.infalte(R.layout.test,null)用来从指定的XML资源中填充一个新的View **Activity相当于一个工人,该工人来建造一个窗户phoneWindow**,这个窗户phoneWindow有一个viewRoot根视图(view、viewGroup),在根视图上面就要添加一个一个的view,通过 mContentParent.addView(view, params);来达到我们的想要的效果,mContentParent是一个viewGroup(),**当我们点击界面的某一个控件时,实际上windowManagerService接收到这个讯息,来回调Activity的方法,比如onKeyDown()方法**。**Activity是控制单元,window是承载模型,view才是真正的显示视图。** 为什么调用requestWindowFeature()方法一定要在setContentView()方法调用之前?**通过设置`requestWindowFeature(Window.FEATURE_NO_TITLE)`来设置全屏显示,视图树中的布局就只有Content了。** 当程序在onCreate()方法中调用setContentView()方法后,ActivityManagerService会回调onResume()方法,此时系统才会将整个DecorView添加到PhoneWindow中,并让其显示出来,从而完成界面的绘制。 [Android 中window 、view、 Activity的关系](https://www.kancloud.cn/alex_wsc/android/344868) #### **二、View的测量** 在现实生活中,如果我们要去画一个图形,就必须知道它的大小和位置。同样, Android系统在绘制View前,也必须对View进行测量,即告诉系统该画一个多大的View。这个过程在onMeasure()方法中进行。 View的测量在onMeasure中进行,系统提供了MeasureSpec类,是一个32位的int值,其高2位为测量模式,低30位为测量的大小。测量模式有以下三种: * EXACTLY:精确模式,当控件指定精确值(例如android:layout_width="50dp")或者指定为match_parent属性时系统使用该模式。 * AT_MOST:最大值模式,指定wrap_content时系统使用该属性,View类默认只支持EXACTLY,如果想使用wrap_content需自己在onMeasure中实现。控件大小一般随着控件的子控件或内容的变化而变化,此时控件的尺寸只要不超过父控件允许的最大尺寸即可。 * UNSPECIFIED:自定义模式,View想多大就多大,**通常在绘制自定义View的时候才使用**。 View类默认的onMeasure()方法只支持EXACTLY模式,所以如果在自定义控件的时候不重写onMeasure()方法的话,就只能使用EXACTLY模式。控件可以响应你指定的具体宽高值或者是match_parent属性。而如果要让自定义View支持wrap_content属性,那么就必须在写onMeasure()方法来指定wrap_content时的大小。 **通过MeasureSpec这个类,可以获取View的测量模式和View想要绘制的大小**。有了这些信息就可以控制View最后显示的大小。 ①、首先重写onMeasure()方法 ~~~ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure (widthMeasureSpec, heightMeasureSpec); } ~~~ 点击super.onMeasure()方法,进入到View.onMeasure()方法,发现系统最终会调用setMeasuredDimension()方法将测量后的宽高值设置进去,从而完成测量工作。所以在重写onMeasure()方法后,最重要的工作就是把测量后的宽高值作为参数设置给setMeasuredDimension()方法。 **View.onMeasure()** ~~~ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec)); } ~~~ 源码中对于这个方法这样描述: 1. 测量视图View及其内容以确定测量的宽度和测量的高度。 这个方法由measure(int,int)调用,应该被子类覆盖,以提供精确和高效的内容测量。 2. 覆盖此方法时,您必须调用setMeasuredDimension(int,int)来存储此视图的测量的宽度和高度,如果不这样做,则会触发由measure(int,int)引发的IllegalStateException异常, 调用super.onMeasure (widthMeasureSpec, heightMeasureSpec);是一个有效的用法。 3. 度量的基类实现默认为背景大小,除非MeasureSpec允许更大的大小,子类应该覆盖onMeasure(int,int)来提供对其内容的更好的度量。 4. 如果这个方法被覆盖,那么这个子类的责任是确保测量的高度和宽度至少是视图的最小高度和宽度——getSuggestedMinimumHeight()和getSuggestedMinimumWidth()。 >[info] 参数 > widthMeasureSpec:父控件强加的横向空间要求,需求用android.view.View.MeasureSpec进行编码。 > heightMeasureSpec:父控件强加的垂直空间要求,需求用android.view.View.MeasureSpec进行编码。 示例效果如下: ![](https://box.kancloud.cn/715d7bf9a742c5877b34dda58ef1b30c_366x603.jpg) 图4:TeachingView-wrap_content 代码如下所示 [TeachingView](https://github.com/xuyisheng/AndroidHeroes/blob/master/3.Android控件架构/SystemWidget/app/src/main/java/com/imooc/systemwidget/TeachingView.java).onMeasure() ~~~ @Override protected void onMeasure(int widthMeasureSpec,int heightMeasureSpec) { setMeasuredDimension( measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec)); } ~~~ 在onMeasure(int,int)方法中,我们调用自定义的measureWidth()和mcasureHeight()方法,分别对宽高进行重新定义,参数则是宽和高的MeasureSpec对象,MeasureSpec对象中包含了测量的模式和测量值的大小。 **TeachingView.measureWidth()** ~~~ private int measureWidth(int measureSpec) { int result = 0; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); if (specMode == MeasureSpec.EXACTLY) { result = specSize; } else { result = 200; if (specMode == MeasureSpec.AT_MOST) { result = Math.min(result, specSize); //确保view的水平像素大小小于手机分辨率的水平像素大小 } } return result; } ~~~ 下面以measureWidth()方法为例,讲解如何自定义测量值。 * 第一步,从MeasureSpec对象中提取出具体的测量模式和大小 * 接着,通过判断测量的模式(通过view布局文件中view的宽高属性来判定测量模式),给出不同的测量值。当specMode是EXACTLY时,直接使用指定的specSize即可,当specMode为其他两种模式时,需要给它-个默认的大小。特别地,如果指定wrap_content属性,即AT_MOST模式,则需要取出我们指定的大小与specSize中最小的一个来作为最后的测量值,measureWidth()方法的代码如上所示 下面是onMeasure的示例代码: ~~~ //参数widthMeasureSpec和heightMeasureSpec包含了测量值的模式和大小 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec);// 获取宽度模式,参数是宽和高的MeasureSpec对象 int widthSize = MeasureSpec.getSize(widthMeasureSpec);// 获取宽度值 int width = 0; if (widthMode == MeasureSpec.EXACTLY) { width = widthSize; } else { width = 200;// 自定义的默认wrap_content值 if (widthMode == MeasureSpec.AT_MOST) { width = Math.min(widthSize, width); } } int heightMode = MeasureSpec.getMode(heightMeasureSpec);// 获取高度模式 int heightSize = MeasureSpec.getSize(heightMeasureSpec);// 获取高度值 int height = 0; if (heightMode == MeasureSpec.EXACTLY) { height = heightSize; } else { height = 200;// 自定义的默认wrap_content值 if (heightMode == MeasureSpec.AT_MOST) { height = Math.min(heightSize, height); } } setMeasuredDimension(width, height);// 最终将测量的值传入该方法完成测量 } ~~~ 当指定宽高属性为wrap_content属性时, 如果不重写onMeasure()方法,那么系统就不知道该使用默认多大的尺寸。因此,它就会默认填充整个父布局,所以重写onMeasure()方法的目的,就是为了能够给View一个wrap_content属性下的默认大小,程序运行效果如图4所示。 在布局文件中,先指定确定的宽高值400dp,程序运行效果如图5所示。 当指定宽高属性为match_parent属性时,程序运行效果如图6所示。 :-: ![](https://box.kancloud.cn/7a94b2f28a4fac1b5eeba2fc3697e874_359x602.jpg) 图5 TeachingView-400dp :-: ![](https://box.kancloud.cn/d1dde4971f55e384e2b4bbc30c62bfb8_362x607.jpg) 图6 TeachingView-match_parent 可以发现,当指定wrap_content属性时,View就获得了一个默认值200dp(由上面代码可得知该结果), 而不是再填充父布局。 #### **三、View的绘制** 当测量好了一个View之后,我们就可以简单地重写onDraw()方法,并在Canvas 对象上来绘制所需要的图形。 要想在Android的界而中绘制相应的图像,就必须在Canvas上进行绘制。Canvas就像是一个画板,使用Paint就可以在上面作画了。通常需要通过继承View并重写它的onDraw()方法来完成绘图。 View的绘制是通过onDraw方法实现的,具体是通过对onDraw方法中canvas参数操作执行绘图。在其他地方,则需要自己创建canvas对象,创建时需传入一个bitmap对象,`Canvas canvas = new Canvas(bitmap); `bitmap是用来保存Canvas.drawXXX绘制的像素信息的,通过这些绘图操作改变的实际上就是bitmap对象而不是canvas。 **onDraw方法** ~~~ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.drawColor(Color.RED); } ~~~ #### **四、ViewGroup的测量与绘制** * 1、在前面的分析中说了,ViewGroup会去管理其子View,其中一个管理项目就是负责子View的显示大小。当ViewGroup的大小为wrap_content时,它就会遍历所有子View,并调用其Measure方法获得其大小,来决定自身的大小,而在其他模式下则通过指定值来设置自身的大小。 * 2、然后当View测量完毕以后,ViewGroup会执行它的Layout方法,同样是遍历子View并调用其Layout方法来确定子View的布局位置。在自定义ViewGroup时,通常会重写onLayout()方法来控制子View显示位置,同样,若需支持wrap_content还需重写onMeasure()方法。 * 3、**ViewGroup通常不需要绘制,因为它本身没有需要绘制的东西,如果不指定ViewGroup的背景颜色,那么ViewGroup的onDraw方法都不会被调用**。但是,ViewGroup会调用dispatchDraw方法来绘制其子view,其过程同样是通过遍历所有子view并调用子view的绘制方法来完成绘制工作的。 8.本章较为浅显的分析了下事件传递的机制。当ViewGroup接收到事件,通过调用dispatchTouchEvent(),由这个方法再调用onInterceptTouchEvent()方法来判断是否要拦截事件,如果返回true则拦截将事件交给onTouchEvent处理,返回false则继续向下传递。当View在接受到事件时,通过调用dispatchTouchEvent(),由此方法再调用onTouchEvent方法,如果返回true则拦截事件自己处理,如果返回false则将事件向上传递回ViewGroup并且调用其onTouchEvent方法继续做判断。