如何掌握自定义View

0.902字数 8491阅读 838

国内自定义View的文章汗牛充栋,但是,即便是你全部看完也未必掌握这一知识(实际上,我也看了很多,但是一旦涉及自定义View,依然无从下手)。为什么,一言以蔽之,你是得其术,不明其道(本文不打算讲自定义View的属性和事件分发,太多文章已经很详细讲解了)。

一、自定义View你真的掌握了吗?

如果说你已经掌握了自定义View,那么请尝试回答下列问题

  • Goolge提出View这个概念的目的是什么?
  • View这个概念和Activity、Fragment、Drawable之间是一种什么样的关系?
  • View能够感知Activity的生命周期事件吗?为什么?

What?如果你说这些问题太抽象?那么请继续回答如下问题:

  • View的声明周期是什么?
  • 当View所在的Activity进入stop状态后,View去哪儿了?如果在一个后台线程中持有一个View引用,我们此时能够改变他的状态吗?为什么?
  • View能够与其他的View交叉重叠吗?重叠区域发生的点击事件交给谁去处理?可不可以重叠的两个View都处理?
  • View控制一个Drawable的方法途径有哪些?Drawable能不能与View通信?如果能,如何通信?
  • 假如View所在的ViewGroup中的子View减少了,View因此获得了更大的空间,View如何及时有效地利用这些空间,改变自己的绘制?
  • 假如我要在View中动态地注册与接触广播接收器,应该在那里完成呢?
  • 假如我的手机带键盘(自带或外接),你的自定义View应该如何响应键盘事件。
  • AnimationDrawable 作为View的背景,会自动进行动画,View在其中扮演了怎样的角色?

假如以上问题你能准确的回答出来,那么,恭喜你!我觉得你的自定义View应学到家了,如果有那么几个问题你还搞不清楚,或者不是很确定,那么,请上终南山,闭关三个月,继续参悟自定义View的内在玄机。

为什么看了那么多文章,还是无法愉快地与自定义View玩耍呢?是那些文章写的不好吗?非也!是你没有掌握学习自定义View的正确方式。你看那些作者,轻轻松松整出一个漂亮的 自定义View,你依葫芦画瓢也整出一个,就觉得自己好像也会了,年轻人,你太傲娇了!你想过没有这些写文章的人怎么掌握自定义View的?请把这个问题默念三遍。以后读任何文章,都问自己这样的问题,相信不久的将来,你也会称为Android大牛的,至少也是小壮牛一头!!!,因为,你已经从学习别人的知识,进入到学习别人的方法境界了,功力自然大增!

好了,说了那么多,到底怎样才能学好自定义View呢?其实只需要掌握三个问题,就可以轻松搞定它:

  • 问题一:从Android系统设计者的的角度,View这个概念究竟是做什么的?
  • 问题二:Android系统中那个View类,它有哪些默认功能和行为,能干什么,不能干什么(知彼知己,才好自定义!)
  • 问题三:我要改变这个View的行为、外观,肯定要覆写View类中的方法,但是怎么覆写,覆写哪些方法能够改变行为?

以上三个问题,从抽象到具体,我觉得适用于学习任何技术知识,只是每个问题的问法可能因具体的技术而有所调整,总体上就是从概念上、从默认实现上、从自己定制上去提问,比如你学习RecycleView,也可以问以上三个问题,按照这三个问题的顺序一个一个搞懂了,也就完全掌握了这一知识。

从Android系统设计者的角度,View这个概念究竟是做什么的?

关于这个问题最权威的当然是官方文档,如下:

This class represents the basic building block for user interface components. A View occupies a rectangular area on the screen and is responsible for drawing and event handling. View is the base class for
widgets, which are used to create interactive UI components (buttons, text fields, etc.). The ViewGroup
subclass is the base class for layouts, which are invisible containers that hold other Views (or other ViewGroups) and define their layout properties.

译:View是用户接口组件的基本构建块,它在屏幕上占据一块矩形区域,负责绘制和事件处理。View是小组件的基类,用于创建交互式UI组件(TextView、Button等);ViewGroup是布局类的基类,是一个容纳其他视图并定义布局属性的不可见容器。

这句话言简意赅,高屋建瓴,一针见血,力透纸背,入木三分,令人销魂佩服!需要我们认真体会,它包含三层含义:

  • View是用户接口组件的基本构建块。通俗讲,在Android中,一个用户与一个应用的交互,其实就是与这个应用中的许许多多的View的交互,这些View既可以是简单的View,也可以是若干View组合而成的一个复合View。由此我们可以明白,所谓View是基本构件块,原因就在于它是复合View(就是ViewGroup)的基本组成单元。这层含义,就是告诉你,View就是用来与用户交互的,那么很自然地,我们要问,我们用户在哪里与View交互,以及怎样与View交互呢?
  • View在屏幕上占据一个矩形区域。这是说,既然View是用户与应用交互的基本构建块,而用户使用Android设备时,主要是通过一个触摸屏来交互的,相应的,Andorid的设计者们,就让一个View就在屏幕上占据一个矩形区域,用户在这个区域中发生的交互动作(点击、滑动、拖动等),就是与这个View的交互。什么?为什么不让View占据一个圆形区域或者五角星区域呢?当然是为了简单。这就解决了在哪里与View交互的问题。很自然地,我们又想问,View在屏幕上占据一个矩形区域,这个区域的大小、位置怎么确定,它们会不会变化,谁来决定这个变化呢?如果这个变化不是由View自己来决定的,而是其他外界因素决定的,View又要怎样响应这种变化呢?不要急,后面都会有答案。
  • View通过绘制自己与事件处理两种方式与用户交互。这是解决了如何交互的问题。简单讲,View与用户交互就两个办法,一个是改变自己的模样,也就是通过绘制自己与用户交互,比如,当用户点击自己时,就改变自己的背景颜色,以此来告诉用户:“本View已经响应你的点击了!”第二个方式就是事件处理,比如,当用户点击View时,就完成一定的任务,然后弹出一个Toast,告诉用户该View完成了什么任务,这样,用户也就知道这次交互结果如何。

看到没,这就是官方文档的魅力,短短一句话,胜君读千篇水文。现在我们明白了,设计View,主要是为了让应用能够与用户交互,要想完成交互,这个View就要在屏幕上占据一个矩形区域,然后利用这块屏幕区域与用户交互,交互的方式就两种,绘制自己与事件处理。

View类,有哪些默认的功能和行为,能干什么,不能干什么?

对于上面的解释,想必大家有很多疑问,我们想知道:

  • View 是怎样被显示到屏幕上的?
  • View 在屏幕上的位置是怎样决定的?
  • View 所占的矩形大小是怎样决定的?
  • 屏幕上肯定不止一个View,View之间相互知道吗?他们能协作吗?
  • View 完成与用户的交互后,能够自动隐藏吗,在需要交互的时候,能重新显示在屏幕上吗?
    .....

现在我们就一点点来讲,学习的同时,最好能用心体会Google工程师设计的思路,这样学习效果最好。

首先,一个用户界面,上面有许多View,既有基本的View,也有复合的View,把他们组织起来还让他们很好的协作确实是一个难题,Google的解决方案是:首先,一套完整的用户界面用一个Window来表示,Window这个概念和我们在计算机上所说的Window很相似。Window负责管理所有的View,怎么管理?很简单,借鉴复合View的思路,Window首先加载一个超级复合View,用它包含住所有的其他View,这个超级复合View就叫做DecorView。但是这个DecorView除了包含我们的用户界面上的那些View,还包含了作为一个Window特有的View,叫做TitleBar,这个我们就不细说了。

这样,在Window中的所有View被组织起来了,一个巨大的ViewGroup(以后我们不再用复合View这个说法,用ViewGroup取而代之,二者是一回事),下面有若干ViewGroup和若干View,每个ViewGroup下面又有若干ViewGroup和若干View,很像数据结构中的树,叶子结点就是基本View。

好了,这些View已经被组织起来了,DecorView已经能够完全控制它们了,同时,DecorView掌握着能够分配给这些View的屏幕区域,包括区域的大小和位置。我们知道,屏幕的大小是有限的,一个Window的DecorView能控制的屏幕区域更加有限,AndroidN中引入多Window机制后,DecorView能够掌控的屏幕区域更加小了,因为屏幕上有多个Window将成为常态。这些有限的区域还要被Window特有的View(TitleBar)占去一小部分,剩下的才是留给用户界面上的View分的。如果你是DecorView,你肯定为难了,如何将这些有限的屏幕区域分给这些View们?分给他们还得为每个View排好在屏幕上的位置,难上加难。

停下来,想一想,如果是你,怎么解决这个问题?

首先,不同的View是为了完成特定的交付任务的。比如,Button就是用来点击的,TextView就是用来显示字符的,等等。DecorView知道,不同的View,为了完成自己的交互任务所需要的屏幕区域大小是不同的,所以DecorView在确定给每个View分配的屏幕区域大小时,是允许View参与进来的,与它一起商量的。但是每个View在屏幕区域位置就不能让View自己来决定了,而是有DecorView一手操办,这个比较简单,我们先看看DecorView是怎样决定每个View的位置的吧。

1、确定每个View的位置

我们在Activity中,调用了setContentView(View view),实际上就是将用户界面所有的View交给了DecorView中的一个FrameLayout,这个FrameLayout代表着可以分配给用户界面使用的区域。而用户界面View既可以是一个简单的View,也可以是一个ViewGroup,如果是一个简单的View,比如就是一个TextView,那么这个TextView就会占据整个FrameLayout的屏幕区域,也就是说,此时用户在FrameLayout的屏幕区域内的所有交互是与这个TextView交互。但是更常见的情况是,我们的用户界面是一个ViewGroup,里面包含着其他的ViewGroup和View。这个时候,首先这个ViewGroup就会占据FrameLayout所代表的屏幕区域,剩下的任务,就是这个ViewGroup给它内部的小弟们分配区域了。至于怎么分,不同的ViewGroup有不同的分法,总体来看,可说是有总有分。所谓总,举例来讲,就像LinearLayout的vertical,他按照自己 小弟的数量,把自己竖向裁成不同的区域,如下图所示:


LinearLayout-sample

虽然View无法决定自己在ViewGroup中的位置,但是开发者在使用View时,可以向ViewGroup表达自己所用的View要放在哪里,以LinearLayout vertical 为例,开发者书写布局文件时,子View在LinearLayout中的出现顺序将决定他们在屏幕上的上下顺序,同时还可以借助layout_margin,layout_gravity等配置进一步调整子View分给自己的矩形区域中的位置。到这里,我么可以理解,layout_*之类的配置虽然在书写上与View的属性在一起,但他们并不是View的属性,他们只是使用该View的使用者来细化调整该View在ViewGroup中的位置的,同时,这些值在Inflate时,是由ViewGroup读取,然后生成一个ViewGroup特定的LayoutParams对象,再把这个对象存入子View中的,这样,ViewGroup在为该子View安排位置时,就可以参考这个LayoutParams中的信息了。

进一步思考,我们发现,调用inflate时,除了输入布局文件的id外,一般要求传入parent ViewGroup,传入这个参数的目的,就是为了读取布局文件中的layout配置信息,如果没有传入,这些信息将会丢失,感兴趣的同学可以自己实验验证下,这里就不展开了。

不同的ViewGroup拥有不同的LayoutParam内部类,这是因为,他们所允许的子view微微调整自己的位置的方式是不一样的,具体讲究就是配置子View时,允许使用的layout_*是不一样的,比如,RelativeLayout就允许layout_toRightOf等配置,其他的ViewGroup就没有这些配置。

这些确定View的位置的过程,被包装在View的Layout方法中,这样我们也很容易理解,对于基本View而言,这个方法是没有用的,所有都是空的,你可以查看下ImageView、TextView等的源代码,验证下这一点。对于ViewGroup而言,他们会用该方法为自己的子View安排位置。

2、确定View的大小

下面就要确定View的大小了,这是一个开发者、View与ViewGroup三方相互商量的过程。(这里的讲解可能与一般的文章不同,是我个人的理解,一搬的文章都不会说三反商量,二十直接说View与ViewGroup两方的商量)

第一步,开发者在书写布局文件时,会为一个View写上android:layout_width="..." android:layout_height="..."两个配置,这是开发者向ViewGroup表达的,我这个View需要的大小是多少。...的取值有三种:

  • 具体值,如50dp,很简单,不多讲
  • match_parent ,表示开发者向ViewGroup说,把你所有的屏幕区域都给这个View吧。
  • wrap_parent,表示开发者向ViewGroup说,只要给这个View够他展示自己的空间就行,至于到底给多少,你直接跟View沟通吧,看它怎么说。

第二步:ViewGroup收到了开发者对View大小的说明,然后ViewGroup会综合考虑自己的空间大小以及开发者的请求,然后生成两个MeasureSpace对象(width与height)传给View,这两个对象是ViewGroup向子View提出的要求,就像相当于告诉子View:“我已经与你的使用者(开发者)商量过了,现在把我们商量确定的结果告诉你,你的宽度不能违反width MeasureSpec对象的要求,你的高度不能违反height MeasureSpec对象的要求,现在,你赶紧根据这个要求确定下自己要多大空间,只许少,不许多哦。”

然后,这两个对象将会传到子View的protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法中。子View能怎么办呢?它肯定是要先看看ViewGroup的要求是什么吧,于是,它从传入的两个对象中解译出如下信息:

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize =  MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize =  MeasureSpec.getSize(heightMeasureSpec);

Mode与Size一起,准确表达出了ViewGroup的要求。下面我们举例说明,假设Size是100dp,
Mode的取值有三种,它们代表了ViewGroup的总体态度:

  • 1、EXACTLY 表示,ViewGroup对View说,你只能用100dp,原因是多样的,可能是你的使用者说要你完全占据我的空间,而我只有100dp。也可能这是你的使用者的要求,他需要你占这么大的空间,而我恰好也有这么多的空间,你的使用者让你占这么大的空间,肯定有他自己的考虑,你不能不理不顾,不然你达不到他的要求,他可能就不用你了。
  • 2、AT_MOST表示,你最多只能用100dp,这是因为你的使用者说让你占据wrap_content的大小,让我跟你商量,我又不知道你到底要占多大区域,但是我告诉你,我只有100dp,你最多也只能用这么多哈。(这里,可以看出,当使用者在布局文件中要求一个View是wrap_content时,此时,View的大小决定权就交给View自己了,默认的View类中的实现,比较粗暴,就是将此时ViewGroup提供的空间全占据,完全没有真正根据自己的内容来确定大小,为什么这么粗暴?因为View是一个基类,所有的组件都是它的子类,每个子类的content都各不相同,View怎么可能知道content的大小呢,所以,它把wrap_content情况下,自己尺寸大小的决定权下放给了不同的子组件,让它们自己根据自己的内容去决定自己的大小,同样,我们自定义View时,也要考虑这一点)
  • 3、UNSPECIFIED表示,你自己看着办,把你最理想的大小告诉我,我考虑考虑。

第三步:好了,子View已经清楚第理解了ViewGroup和它的使用者对它的大小的期望和要求了。下步就要在该要求下来确定自己的大小并告诉ViewGroup了。(废话,不告诉ViewGroup大小,它怎么给你安排位置(layout),无法给你layout,你也就占据不了一块屏幕区域,占不了屏幕区域,你就无法与用户交互,无法与用户交互,要你何用啊!)

关于子View怎么确定自己的大小,不同的View有不同的态度,但是有几点基本的规矩是要遵守的:
规矩一就是,不要违反ViewGroup的规定,最后设置的尺寸一定要在ViewGroup要求的范围内(不论是宽度还是高度),但是你说,假如我就是想要更大的空间,难道就没有办法了吗,我能不能遵守要求的情况下,同时告诉ViewGroup,虽然我告诉你的我要求的尺寸是遵照你的旨意来的,但实际上我是委屈求全的,我真实想要的大小不是这样的,你能不能再考虑一下。答案是:有。那就是如下调用:

    esolveSizeAndState((int)(wantedWidth), widthMeasureSpec, 0),    
    resolveSizeAndState((int) (wantedHeight), heightMeasureSpec, 0);

View可以把自己想要的宽和高进行一个resolveSizeAndState处理,就可以达到上述目的。即如果想要的大小没超过要求,一切都Ok,如果超过了,在该方法内部,就会把尺寸调整成符合ViewGroup要求的,但是也会在尺寸中设置一个标记,告诉ViewGroup,这个大小是子View委屈求全的结果。至于ViewGroup会不会理会这一标记,要看不同的ViewGroup了。如果你实现自己的ViewGroup,最好还是关注下这个标记,毕竟作为大哥的你,最主要的职责就是把自己的小弟(子View)安排好,让它们都满意嘛。(这一点,我没有看到任何一篇讲解自定义View的文章提到过!)
什么?好奇的你想看看究竟是怎样设置标记的?来来来,满足你:

public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {  
      final int specMode = MeasureSpec.getMode(measureSpec);  
      final int specSize = MeasureSpec.getSize(measureSpec);  
      final int result;  
      switch (specMode) {     
         case MeasureSpec.AT_MOST:         
             if (specSize < size) {            
                  result = specSize | MEASURED_STATE_TOO_SMALL;         
             } else {            
                  result = size;      
             }         
             break;      
         case MeasureSpec.EXACTLY:          
              result = specSize;      
              break;       
         case MeasureSpec.UNSPECIFIED:   
         default:        
              result = size;   
       }   
       return result | (childMeasuredState & MEASURED_STATE_MASK);
}

上面的代码中的MEASURED_STATE_TOO_SMALL就是在子View想要的空间太大时设置的标记了。

规矩二就是要在该方法中调整自己的绘制参数,这一点很好理解,毕竟ViewGroup提出了尺寸要求,要及时根据这一要求调整自己的绘制,比如,如果自己的背景图片太大,那就算算要缩放多少才合适,并且设置一个合理的缩放值。
规矩三就是一定要设置自己考虑后的尺寸,如果不设置就相当于没有告诉ViewGroup自己想要的大小,这会导致ViewGroup无法正常工作,设置的办法就是在onMeasure方法的最后,调用
setMeasuredDimension方法。为什么调用这个方法就可以了呢?这只是一个约定,没有必要深究了。

关于View的绘制,非常简单,就是一个方法onDraw,后面的自定义View实战部分会细说,这里先略过了。

以上,View的三个基本知识点,我们都了解了,即View 的位置如何确定,大小如何确定以及如何绘制自己。这都是默认的View类中为我们准备好的。

四、我要改变这个View的行为,外观,肯定是覆写View类中的方法,但是怎么覆写,覆写哪些方法能够改变哪些行为?

好了,View的位置和大小怎么确定我们都清楚了,现在,是时候开始自定义View了。
首先,关于View所要具备的一般功能,View类中都有了基本的实现,比如确定位置,它有layout方法,当然,这个只适用于ViewGroup,实现自己的ViewGroup时,才需要修改该方法。确定大小,它有onMeasure方法,如果你不满意默认的确认大小的方法,也可以自己定义。改变默认的绘制,就覆写onDraw方法。下面,我们通过一张图,来看看,自定义View时,我们最可能需要修改的方法是哪些:

image.png

把这些方法都搞明白了,你也就理解了View的生命周期了。

比如View被inflated出来后,系统会回调该View的onFinishInflate方法,你的View可以在这个方法中,做一些准备工作。

如果你的View所属的Window可见性发生了变化,系统会回调该View的onWindowVisibilityChanged方法,你也可以根据需要,在该方法中完成一定的工作,比如,当Window显示时,注册一个监听器,根据监听到的广播事件改变自己的绘制,当Window不可见时,解除注册,因为此时改变自己的绘制已经没有意义了,自己也要跟着Window变成不可见了。

当ViewGroup中的子View数量增加或者减少,导致ViewGroup给自己分配的屏幕区域大小发生变化时,系统会回调View的onSizeChanged方法,该方法中,View可以获取自己最新的尺寸,然后根据这个尺寸相应调整自己的绘制。

当用户在View所占据的屏幕区域发生了触摸交互,系统会将用户的交互动作分解成如DOWN、MOVE、UP等一系列的MotionEvent,并且把这些事件传递给View的onTouchEvent方法,View可以在这个方法中进行与用户的交互处理。当然这个是基本的流程,实际的流程会稍复杂些,你可以阅读我的另一篇文章,是专门讲解事件分发的,文章非常经典,你读了一定不后悔。

除了这些方法,View还实现了三个接口,如下


View继承类关系图.jpg

三个接口是:
Drawable.Callback
KeyEvent.Callback
AccessibilityEventSource

每个接口都有自己的作用。

KeyEvent回调接口,是用来处理键盘事件的,这与onTouchEvent用来处理触摸事件是相对的。

Drawable回调接口是用来让View中的Drawable能够与View通信的,尤其是AnimationDrawable,更是必须依赖该回调才能实现动画效果,关于这一点,我深入地研究了FrameWork的源码,对AnimationDrawable如何实现动画,有了深入彻底的掌握,我也在考虑要不要就此写一篇文章,看大家需要吧,如果本文赞数过百,我就写,绝不食言。

第三个回调接口,我没有细致研究,不便多说。

写到这里你应该发现,我们的第三个问题,自定义View,应该覆写哪些方法,能够实现哪些功能也已经解决了。


休息一刻,如侵权,删除

五、光说不练假把式,实战自定义View

说了这么多,不自定一个View,怎么对的起你辛苦读到这里呢。好,我们现在就来自定义一个钟表,而且可以自己走的。如下图所示:

Demo图

这个时钟可是能够走动的哈。下面我们就开始吧。首先,准备三张图片资源,如下:

clock_dial.png
clock_hand_hour.png
clock_hand_minute.png

聪明如你,一看就应该知道这是做什么用的了。准备图片时,使用了一个小技巧,就是时针和分针,你所看到的图像只是图片的一半,在图像的下方,还有同样大小的空白,这个是做什么用的呢?主要是为了绘制图片时的方便,待会儿就可以明白了。

材料齐全,开工!

public class AnalogClock extends View {   

      private Time mCalendar;    //用来记录当前时间

      //用来存放三张图片资源
      private Drawable mHourHand;  
      private Drawable mMinuteHand; 
      private Drawable mDial;   

    //用来记录表盘图片的宽和高,
    //以便帮助我们在onMeasure中确定View的大
    //小,毕竟,我们的View中最大的一个Drawable就是它了。
       private int mDialWidth; 
       private int mDialHeight;   


//用来记录View是否被加入到了Window中,我们在View attached到
//Window时注册监听器,监听时间的变更,并根据时间的变更,改变自己
//的绘制,在View从Window中剥离时,解除注册,因为我们不需要再监听
//时间变更了,没人能看得到我们的View了。
       private boolean mAttached;    

//看名字
        private float mMinutes;    
        private float mHour;    

//用来跟踪我们的View 的尺寸的变化,
//当发生尺寸变化时,我们在绘制自己
//时要进行适当的缩放。
        private boolean mChanged;
...
}

下面,我们来确定自定义View 的构造方法,查看View类,我们知道,View类有四个构造方法,我们相应地,也写四个构造方法,并且初始化相关变量:

/第一个构造方法
public AnalogClock(Context context) {   
     this(context, null);
}
//第二个构造方法
public AnalogClock(Context context, AttributeSet attrs) {  
      this(context, attrs, 0);
}
//第三个构造方法
public AnalogClock(Context context, AttributeSet attrs, int defStyleAttr) { 
      this(context, attrs, defStyleAttr, 0);
}
//第四个构造方法
public AnalogClock(Context context, AttributeSet attrs, 
int defStyleAttr, int defStyleRes) {    

    super(context, attrs, defStyleAttr, defStyleRes);    
    final Resources r = context.getResources();  
    if (mDial == null) {    
          mDial = context.getDrawable(R.drawable.clock_dial);  
    }  
    if (mHourHand == null) {        
        mHourHand = context.getDrawable(R.drawable.clock_hand_hour);   
    }     
    if (mMinuteHand == null) {      
          mMinuteHand = 
                context.getDrawable(R.drawable.clock_hand_minute);   
     }  

     mCalendar = new Time(); 

    mDialWidth = mDial.getIntrinsicWidth();   
    mDialHeight = mDial.getIntrinsicHeight();
}

请注意,以上为自定义View设置的构造方法是适用性最广的一种写法,这样写,可以确保我们的自定义View能够被最大多数的开发者使用,是一种最佳实践。

接下来,确定我们的自定义View 的大小,也就是改写onMeasure方法:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {   

         int widthMode = MeasureSpec.getMode(widthMeasureSpec);  
         int widthSize =  MeasureSpec.getSize(widthMeasureSpec);  

         int heightMode = MeasureSpec.getMode(heightMeasureSpec);  
         int heightSize =  MeasureSpec.getSize(heightMeasureSpec); 

         float hScale = 1.0f;  
         float vScale = 1.0f;   

         if (widthMode != MeasureSpec.UNSPECIFIED && widthSize < mDialWidth) {       
             hScale = (float) widthSize / (float) mDialWidth;   
         }   
         if (heightMode != MeasureSpec.UNSPECIFIED && heightSize < mDialHeight) {       
             vScale = (float )heightSize / (float) mDialHeight;  
          }    
         float scale = Math.min(hScale, vScale);    
        setMeasuredDimension(
              resolveSizeAndState((int) (mDialWidth * scale), widthMeasureSpec, 0),           
             resolveSizeAndState((int) (mDialHeight * scale), heightMeasureSpec, 0)
        );
}

在该方法中,我们的View想要的尺寸当然就是与表盘一样大的尺寸,这样可以保证我们的View有最佳的展示,可是如果ViewGroup给的尺寸比较小,我们就根据表盘图片的尺寸,进行适当的按比例缩放。注意,这里我们没有直接使用ViewGroup给我们的较小的尺寸,而是对我们的表盘图片的宽高进行相同比例的缩放后,设置的尺寸,这样的好处是,可以防止表盘图片绘制时的拉伸或者挤压变形。

确定了大小,是不是就可以绘制了,先不着急,我们先要处理两件事,一件就是让我们的自定义View能够感知自己尺寸的变化,这样每次绘制时,可以先判断下尺寸是否发生了变化,如果有变化,就及时调整我们的绘制策略。代码如下:

protected void onSizeChanged(int w, int h, int oldw, int oldh) {    
       super.onSizeChanged(w, h, oldw, oldh);   
       mChanged = true;
}

我们会在onDraw使用mChanged变量的。

第二件事就是让我们的View能够监听时间变化,并及时更新该View中的mCalendar变量,然后根据它来更新自身的绘制。为此,我们先写一个更新时间的方法,代码如下:

private void onTimeChanged() {    
        mCalendar.setToNow();  

        int hour = mCalendar.hour;   
        int minute = mCalendar.minute;  
        int second = mCalendar.second;   
        /*这里我们为什么不直接把minute设置给mMinutes,而是要加上
            second /60.0f呢,这个值不是应该一直为0吗?
            这里又涉及到Calendar的 一个知识点,
            也就是它可以是Linient模式,
            此模式下,second和minute是可能超过60和24的,具体这里就不展开了,
            如果不是很清楚,建议看看Google的官方文档中讲Calendar的部分*/
         mMinutes = minute + second / 60.0f;    
         mHour = hour + mMinutes / 60.0f;   
         mChanged = true;
}

然后我们还要实现一个广播接收器,接收系统发出的时间变化广播,然后更新该View的mCalendar,如下:

private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {   
       @Override  
        public void onReceive(Context context, Intent intent) {    
            //这个if判断主要是用来在时区发生变化时,更新mCalendar的时区的,这
            //样,我们的自定义View在全球都可以使用了。
            if (intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) {            
                  String tz = intent.getStringExtra("time-zone");         
                   mCalendar = new Time(TimeZone.getTimeZone(tz).getID());    
            }     
          //进行时间的更新  
             onTimeChanged();     
          //invalidate当然是用来引发重绘了。
           invalidate();   
         }
};

现在,我们要给我们的View动态地注册广播接收器,没错,我们就是要在
onAttachedToWindow和onDetachedFromWindow中完成这一功能。代码如下:

@Override
protected void onAttachedToWindow() {   
       super.onAttachedToWindow();    
      if (!mAttached) {      
          mAttached = true;      
          IntentFilter filter = new IntentFilter();        
        //这里确定我们要监听的三种系统广播
          filter.addAction(Intent.ACTION_TIME_TICK);   
          filter.addAction(Intent.ACTION_TIME_CHANGED);        
          filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);        
          getContext().registerReceiver(mIntentReceiver,   filter); 
       }   

        mCalendar = new Time();   
        onTimeChanged();
}

@Override
protected void onDetachedFromWindow() {    
          super.onDetachedFromWindow();  
          if (mAttached) {     
               getContext().unregisterReceiver(mIntentReceiver);     
               mAttached = false;   
           }
}

万事具备,只欠东风,开始绘制我们的View吧。代码如下:

@Override
protected void onDraw(Canvas canvas) {   
         super.onDraw(canvas);  

      //View尺寸变化后,我们用changed变量记录下来,
    //同时,恢复mChanged为false,以便继续监听View的尺寸变化。
          boolean changed = mChanged;   
          if (changed) {      
                mChanged = false;   
           }   
        /* 请注意,这里的availableWidth和availableHeight,
           每次绘制时是可能变化的,
           我们可以从mChanged变量的值判断它是否发生了变化,
           如果变化了,说明View的尺寸发生了变化,
           那么就需要重新为时针、分针设置Bounds,
           因为我们需要时针,分针始终在View的中心。*/
           int availableWidth = super.getRight() - super.getLeft();   
           int availableHeight = super.getBottom() - super.getTop();  


        /* 这里的x和y就是View的中心点的坐标,
          注意这个坐标是以View的左上角为0点,向右x,向下y的坐标系来计算的。
          这个坐标系主要是用来为View中的每一个Drawable确定位置。
          就像View的坐标是用parent的左上角为0点的坐标系计算得来的一样。
          简单来讲,就是ViewGroup用自己左上角为0点的坐标系为
          各个子View安排位置,
          View同样用自己左上角为0点的坐标系
          为它里面的Drawable安排位置。
          注意不要搞混了。*/

           int x = availableWidth / 2;    
           int y = availableHeight / 2;   

           final Drawable dial = mDial;  
           int w = dial.getIntrinsicWidth();   
           int h = dial.getIntrinsicHeight();   
            boolean scaled = false;   

        /*如果可用的宽高小于表盘图片的宽高,
           就要进行缩放,不过这里,我们是通过坐标系的缩放来实现的。
          而且,这个缩放效果影响是全局的,
          也就是下面绘制的表盘、时针、分针都会受到缩放的影响。*/
           if (availableWidth < w || availableHeight < h) {     
                 scaled = true;      
                  float scale = Math.min((float) availableWidth / (float) w,   
                              (float) availableHeight / (float) h);     
                 canvas.save();    
                 canvas.scale(scale, scale, x, y);  
             }    

         /*如果尺寸发生变化,我们要重新为表盘设置Bounds。
           这里的Bounds就相当于是为Drawable在View中确定位置,
           只是确定的方式更直接,直接在View中框出一个与Drawable大小
           相同的矩形,
           Drawable就在这个矩形里绘制自己。
           这里框出的矩形,是以(x,y)为中心的,宽高等于表盘图片的宽高的一个矩形,
           不用担心表盘图片太大绘制不完整,
            因为我们已经提前进行了缩放了。*/
          if (changed) {       
                 dial.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); 
           }    
          dial.draw(canvas);    

          canvas.save();   
          /*根据小时数,以点(x,y)为中心旋转坐标系。
            如果你对来回旋转的坐标系感到头晕,摸不着头脑,
            建议你看一下**徐宜生**《安卓群英传》中讲解2D绘图部分中的Canvas一节。*/

           canvas.rotate(mHour / 12.0f * 360.0f, x, y);  
           final Drawable hourHand = mHourHand;   

          //同样,根据变化重新设置时针的Bounds
           if (changed) {     
                   w = hourHand.getIntrinsicWidth();    
                   h = hourHand.getIntrinsicHeight();      

            /* 仔细体会这里设置的Bounds,我们所画出的矩形,
                同样是以(x,y)为中心的
                矩形,时针图片放入该矩形后,时针的根部刚好在点(x,y)处,
                因为我们之前做时针图片时,
                已经让图片中的时针根部在图片的中心位置了,
                虽然,看起来浪费了一部分图片空间(就是时针下半部分是空白的),
                但却换来了建模的简单性,还是很值的。*/
                  hourHand.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2));  
             }    
              hourHand.draw(canvas);  
              canvas.restore();  

              canvas.save();    
            //根据分针旋转坐标系
              canvas.rotate(mMinutes / 60.0f * 360.0f, x, y);   
              final Drawable minuteHand = mMinuteHand;   

              if (changed) {     
                       w = minuteHand.getIntrinsicWidth();    
                       h = minuteHand.getIntrinsicHeight();    
                       minuteHand.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); 
               }   
               minuteHand.draw(canvas);    
                canvas.restore();    
            //最后,我们把缩放的坐标系复原。
              if (scaled) {      
                   canvas.restore();   
              }

}

大功告成,现在我们的时钟终于完成了,任何开发者都可以使用我们的View,获得一个不断走动的模拟时钟。该View的完整代码已经上传到Github,猛戳https://github.com/like4hub/CustomViewForClock。(注:该时钟的实现,主要参考了AOSP中模拟时钟)

关于本文前面提出的问题,简单回答一下:

Q1:google提出view概念的目的是给android app提供用户交互的机制。
Q2、Q3、Q7:android framework采用的是层次架构:从上到下是:Activity、Fragment
View
Drawable
上层知道下层,下层却不知道上层。上层可以直接使用支配下层,下层却无法支配使用上层,下层与上层的通信主要靠回调。所以View处于Activity、Fragment与Drawable中间,意味着View不能够感知Activity的生命周期,但是View可以完全控制Drawable,控制的手段定义在Drawable中,凡是Drawable提供的方法,都是View控制Drawable的手段,最典型的,在本文中也使用了的就是setBounds方法。正如View无法感知Activity的声明周期一样,Drawable同样无法感知View的生命周期。但是View实现了Drawable.Callback接口,Drawable可以通过这个接口与View通信。本文中有说明
Q4:View的生命周期请见本文View-Method-For-Override一图,这张图来自google官方文档,如果看不懂,可以查看文档获得相关说明,如果还是看不懂,欢迎留言讨论。

Q5:Activity进入stop状态后,它的窗口会被最新呈现的窗口挡住,窗口中的view也因此无法被我们看见,如果此时在后台线程中更新一个view是可以的,前提是要提交到UI线程中,但通常意义不大,因为此时用户无法看到view的改变,而且,当这个Activity从stop状态中进入resume时,一般都会重新更新view,以便继续与用户交互,所以,在stop状态下对view的更新没有什么意义。
Q6:View直接是可以重叠,重叠区域的点击事件由谁处理取决于它们的parent 在dispatch这个点击事件时,先dispatch给谁。能不能都处理呢?一般情况下是不可以的,但是在最新的CoordinateLayout中,可以通过behavior实现这一需求。具体内容太多,请自行搜索。

Q8:View利用这些空间的方法很简单啊,就是在onSizeChanged方法中在新的宽高下绘制自己 。新的宽高由其parent ViewGroup在其他子View被移除后,重新layout时确定。本文的案例中就利用了这个方法。

参考文章
步步为营

推荐阅读更多精彩内容