书名:Android开发进阶:从小工到专家
ISBN:978-7-115-41591-2
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
• 著 何红辉
责任编辑 张 涛
• 人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
异步社区网址 http://www.epubit.com.cn/book/details/4312
• 读者服务热线:(010)81055410
反盗版热线:(010)81055315
何红辉 : 前友盟(阿里巴巴集团)Android工程师,CSDN博客专家,活跃于国内各大技术社区,热爱开源,热爱技术,热爱分享。Android事件总线开源库(AndroidEventBus)、Colorful作者,开发技术前线( www.devtf.cn )站长。
本书特色
阿里巴巴高级工程师、CSDN博客专家、畅销书作者撰写,百度、腾讯等专家推荐的精品图书
一本只有干货,摒弃了以前图书教程式的写法好书
讲解知识点不再是大水满灌形式,本书主要是结合作者多年开发经验的总结,把作者以前开发走过的坑和陷阱讲解出来,读者看了以后可以少走很多弯路,提升自己的开发能力很快,抓住读者的痛点和需求讲解内容,使读者阅读后很有成就感。
除了全面讲解了Android开发知识外,还对单元测试、代码规范、版本控制、重构、架构等重要知识点进行了讲解,使得读者在深入技术的同时开阔眼界,能够以更专业的方式设计应用软件,完成从只会实现功能的“码农”到软件工程师、设计师的过渡。
本书是一本专门介绍Android开发的图书。书中首先对Android开发的核心知识点进行深入讲解,然后介绍单元测试、代码规范、版本控制、重构、架构等重要的知识,使得读者在深入掌握技术的同时也帮助他们开阔眼界,且能够以更专业的方式设计应用软件,完成从只会实现功能的初级程序员到软件工程师、设计师的转变。
本书的主要内容为:构成Android系统基石的四大组件、创造出丰富多彩的UI设计的控件、保证App流畅的多线程开发、必知必会的HTTP网络请求应用、数据存储的核心SQLite数据库、让程序更优秀的性能优化、让程序更整洁的代码规范、管理程序的好帮手Git版本控制,以及需要掌握的高级技术,如单元测试、六大原则与设计模式、重构和综合实战等。
本书适合Android开发初学者、程序员学习,也适合作为大中专院校相关专业的师生用书和培训学校的教材。
写这本书的念头由来已久了。也许是从我打算写《Android源码设计模式解析与实战》那时起就萌生了这个念头,因为设计模式属于仅次于架构之下的局部战术,阅读这类书籍能够让具备一定工作经验的开发人员提升自己的设计能力,构建更灵活的软件。但是,对于初、中级工程师而言,最重要的还是在于基础知识以及知识广度的掌握上。因此,在《Android源码设计模式解析与实战》交稿之后,我就立即开始了本书的写作之旅。
从单位的面试经历和与开发群中网友的交流中,我发现很多有一定工作经验的开发人员对于Android的基础知识都还只停留在“会用”的阶段,而对于其基本原理一概不知,以至于工作多年之后依旧停留在很表面的层次。这样的知识结构的程序员往往是一旦开发的系统出现问题或者需要优化时就不能应对了。因此,仔细阅读一本深入讲述Android核心开发知识点的书是很有必要的。
目前,图书市场上关于Android的入门书籍大多是覆盖整个Android开发知识体系,这类书籍的特点是讲解的知识面多,也正是这个原因使得这类书籍缺乏深度,往往只是点到即止。例如,关于网络请求的技术,通常只讲解如何发送一个GET请求,但是,对于HTTP原理不会涉及,这使得很多读者在定制一些请求时根本无从下手,如上传图片、参数格式为Json等。
另一个问题就是,很多开发人员即使从业多年,可能都不知道什么是单元测试,不知道重构、面向对象基本原则,这使得他们的代码耦合度可能很高,难以测试和维护,这样带来的后果就是质量没法保证,随着时间的推移系统逐渐“腐化”。因此,读一本讲述设计软件的书也是必要的。
本书的目的就是解决上述两个问题,首先对Android开发的核心知识点进行深入讲解,然后介绍单元测试、代码规范、版本控制、重构、架构等重要知识点,使得读者在深入技术的同时开阔眼界,能够以更专业的方式设计应用软件,帮助读者完成从只会实现功能的“码农”到软件工程师、设计师的过渡。
本书主要分为3部分,第一部分是前6章,在第一部分中深入讲解了Android开发过程中的核心知识点,包括View与动画、多线程、网络、数据库、性能优化,使得读者深入了解开发中最为重要的知识;第二部分是第7~11章,涵盖的内容包括代码规范、单元测试、版本控制、OOP与模式、重构等内容,从代码规范化、专业化的角度着手,开阔读者的眼界,使读者具备构建低耦合、灵活性强的应用软件的基本能力;最后一部分是第12章,在第12章中通过一个完整的示例,演示了如何把一个充满问题的应用软件逐步演化为低耦合、清晰、可测试的实现过程,在其中展示了常见的重构手法、测试手段,使读者从真实的示例中汲取知识与经验,提升技术与设计能力,绕过编程中的诸多陷阱。
当然,书中的知识点很多都只是做了部分讲解,起到一个抛砖引玉的作用,因此,如果需要更深入地了解各领域的知识,希望读者阅读其他专业书籍。
本书面向的读者为初、中、高级Android工程师。本书的定位是学习Android开发的第二本书,因此,阅读的前提是读者需要有一定的Android开发知识。在阅读完本书之后,读者还可以选择《Android群英传》《Android开发艺术探索》《Android源码设计模式解析与实战》等书进行更深入地学习,从更深、更高的层次提升自己,完成从“码农”到专家的蜕变。
本书从整体结构上分为3部分,分别为Android核心开发知识、规范化与专业化开发基本知识、实战示例。初、中级工程师建议阅读全书,高级工程师可以选择自己感兴趣的部分进行阅读。实战示例部分需要第二部分的知识,因此,在阅读最后一章时,如果你学习了第二部分的知识,那么理解效果会更好。判定你是否需要阅读某个章节的标准是,当你看到标题时是否对这个知识点了然于心,如果答案是否定的,那么阅读该章节还是很有必要的。当然,通读全书自然是最好的选择。
“纸上得来终觉浅,绝知此事要躬行”,这放到任何一本书中都适用。因此,阅读本书时建议重新完成书中的示例,然后进行思考,从中体会为什么要这样做,这样做得到的好处是什么。读书、实践、思考结合起来,才会让你在技术道路上跑得更快、更远!
最后需要说明的是,任何一本书籍都难免会有一些错误的地方,因此,我很乐意听到读者关于本书的意见或建议,希望与大家共同进步。读者可以通过发邮件(邮箱地址:simplecoder.h@gmail.com)的方式进行反馈,在这里致以诚挚的谢意。编辑联系邮箱:zhangtao@ptpress.com.cn。
本书中的示例代码都托管在Github,地址为https://github.com/bboyfeiyu/android_jtm_sourcecode ,读者可以通过Git进行下载。另外,本书的勘误地址为: https://github.com/bboyfeiyu/android-jtm-issues。如果读者发现了书中的错误,也可以提交到该项目中。
何红辉
于北京
在本书的出版过程中得到了很多朋友的帮助,首先要感谢佳星、毕老师、凯子、小雨、文辉等好友的审稿,他们的付出使书中的大部分文字错误都在早期被修正。最重要的是要感谢张涛编辑的信任,在上一本书《Android源码设计模式解析与实战》的写作过程中,张涛编辑给了我很大的自由空间,对于我的各种问题也是耐心解答,也正是这些原因使我毫不犹豫地再度与张涛编辑合作。最后要感谢我的家人,在我写作的时候给我建议、校稿,在整个过程中给予我很大的支持。
何红辉
于北京
Android 源码设计模式解析与实战
业界第一本讲解Android源码设计模式的书。
CSDN社区专家精心撰写、业界专家邓凡平、郭霖、任玉刚、徐宜生等鼎力推荐。
本书不仅分析了Android源码的设计模式,更结合实例演示了如何使用这些设计模式。看这本书,既能学到如何分析、学习Android源码,又能提高自己的架构设计水平。
书中的主人公小民就是那些不断追求技术进步,从而得以不断成长的IT技术人的代表,小民的成长过程基本上反映了我们现在程序员的成长经历,他的成功很值得我们学习和借鉴。
深入解析Android 5.0系统
广受读者好评的讲解Android系统的畅销书。
小米电视系统软件部总监、原Motorola软件总监、德信无线软件部经理等专家鼎力推荐。
学得懂、用得上的、国内第一部真正来自于Android开发专家的Android系统分析权威指南,讲解了最新版本的热点技术,解决系统开发中的困惑与问题。
书中尽可能详细地给出了主要模块的架构、原理和主干实现,很多模块前后能相互印证用以帮助读者学习,希望通过本书帮助读者快速理解内核的设计思想、获得对Android系统进行二次开发的能力。
由于本书的目标读者是有一定Android基础的开发人员,因此,本章不再介绍Android系统的架构、历史等知识,而是直接切入主题,从讲解Android的四大组件开始,然后一步一步深入学习开发中的重要知识点,使得我们能够从基本原理层面掌握Android开发基础知识。
Android中最重要的是四大组件,即Activity、Service、ContentProvider和Broadcast。这4个组件分工明确,共同构成了可重用、灵活、低耦合的Android系统。Activity负责UI元素的加载与页面之间的跳转,代表了一个页面单元;Service负责与UI无关的工作,如在后台执行耗时操作等;ContentProvider负责存储、共享数据,使得数据可以在多个应用之间共享;Broadcast则是在各个组件、应用之间进行通信,简化了Android开发中的通信问题。
下面就来简单学习一下这四大开发组件。
Activity在应用中的表现就是一个用户界面,它会加载指定的布局文件来显示各种UI元素,例如TextView、Button、ImageView、ListView等,并且为这些UI元素设置事件处理函数,使得用户可以与这些UI进行交互。同时,Activity还可以在不同的Activity之间跳转,将不同的页面串连在一起,共同完成特定的操作流程。每个应用都是由一个或者多个Activity组成,它是Android应用程序中不可缺少的部分。
应用启动时会加载一个默认的Activity,这个Activity在AndroidManifest.xml中会被设置为如下intent-filter:
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
每个Activity都有生命周期,在不同的阶段会回调不同的生命周期函数,Activity的生命周期函数有如下几个。
相信这是开发者见过次数最多的函数,我们在创建继承自Activity的类时都会默认生成这个函数。它会在Activity第一次被创建时调用,通常会在这个函数中完成Activity的初始化操作,如设置布局、初始化视图、绑定事件等。
这个函数在Activity的onCreate函数调用之后被调用,此时的Activity还处在不可见状态,它的下一个状态就是Activity变得可见的时候,也就是这个函数在Activity可见之前被调用。
这个函数在Activity变为可见时被调用,执行完onResume之后,Activity就会请求AMS渲染它所管理的视图。此时的Activity一定位于返回栈的栈顶,并且处于运行状态。
这个函数在系统准备去启动或者恢复另一个Activity时调用,也就是在Activity即将从可见状态变为不可见时。我们通常会在这个函数中将一些消耗CPU的资源释放掉,以及保存一些关键数据。
这个函数在Activity完全不可见时调用。它和onPause()函数的主要区别在于,如果新启动的Activity是一个对话框式的Activity,那么onPause()函数会得到执行,而onStop() 函数并不会执行。
这个函数在Activity被销毁之前调用,之后Activity的状态将变为销毁状态。
这个函数在Activity由停止状态重新变为运行状态之前调用,也就是Activity被重新启动了。
从onCreate()函数到onDestroy()函数运行的时期就是一个Activity的完整生命周期。一般情况下。我们会在一个Activity的onCreate()函数中完成各种初始化操作,而在onDestroy()函数中完成释放内存的操作。然而并不是各个时期Activity都是可见的,只有onResume()函数和onStop()函数之间的Activity是可见的,在Activity可见期内,用户可以与Activity进行交互,完成所需的功能。
为了帮助读者能够更好地理解,Android 官方提供了一个Activity生命周期的示意图,如图1-1所示。
▲图1-1 Activity的生命周期
Activity的构成并不是一个Activity对象再加上一个布局文件那么简单,在Activity和开发人员设置的视图之间还隔着两层。实际上视图会被设置给一个Window类,这个Window中含有一个DecorView,这个DecorView才是整个窗口的顶级视图。开发人员设置的布局会被设置到这个DecorView的mContentParent布局中。也就是说Android中实际上内置了一些系统布局文件xml,我们在xml中定义的视图最终会被设置到这些系统布局的特定节点之下,这样就形成了整个DecorView。结构如图1-2所示。
从图1-2中可以看到,我们的Activity之下有一个PhoneWindow,这个PhoneWindow是Window的实现类,然后Window之下包含一个DecorView,DecorView实际上是页面的顶级视图,它从一些系统布局中加载,并且在运行时将开发人员设置给Activity的布局资源添加到系统布局的mContentParent中。这样一来,用户界面就被添加到系统布局中了,而系统布局会为我们设置好标题栏区域等。
▲图1-2 Activity结构
下面就是一个名为screen_title的系统布局xml文件:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:fitsSystemWindows="true">
<!-- Popout bar for action modes -->
<ViewStub android:id="@+id/action_mode_bar_stub"
android:inflatedId="@+id/action_mode_bar"
android:layout="@layout/action_mode_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="?attr/actionBarTheme" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="?android:attr/windowTitleSize"
style="?android:attr/windowTitleBackgroundStyle">
<TextView android:id="@android:id/title"
style="?android:attr/windowTitleStyle"
android:background="@null"
android:fadingEdge="horizontal"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
<!—这里就是开发人员设置的布局所填充的位置-->
<FrameLayout android:id="@android:id/content"
android:layout_width="match_parent"
android:layout_height="0dip"
android:layout_weight="1"
android:foregroundGravity="fill_horizontal|top"
android:foreground="?android:attr/windowContentOverlay" />
</LinearLayout>
上述xml文件中包含了actionbar和标题栏区域,下面就是开发人员设置给Activity的布局区域,这个区域被添加到名为content的布局中,而这整个screen_title.xml又是DecorView的子视图,因此,最终用户界面会显示为标题栏、开发人员设置的界面。例如我们的Activity布局代码如下:
<RelativeLayout 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:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:gravity="center"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context=".MainActivity">
<TextView android:text="@string/hello_world" android:layout_width="wrap_content"
android:textSize="30sp"
android:gravity="center"
android:layout_height="wrap_content" />
</RelativeLayout>
该布局的根视图为RelativeLayout,其中只有一个居中的TextView。运行后的界面如图1-3所示。
▲图1-3 用户界面
jtm_chap01显示的区域就是id为title的TextView,而Hello World就是content布局下的一个子视图。当Activity的onResume函数被调用之后,用户界面就显示在我们面前了。
每个应用程序都是由一个或者多个Activity组成,因此,Android内部使用通过回退栈来管理Activity实例。栈是一种后进先出的集合,对于Android来说,当前显示的Activity就在栈顶,当用户点击后退键或者点击应用上的返回按钮,系统就会将栈顶的Activity出栈,此时原来栈顶下的Activity就会变为栈顶显示到设备上。
然而事情可能并不是那么简单,在一些特殊情况下我们可能需要对Activity实例做一些特殊的处理,例如,为了避免重复创建Activity,我们要求一个Activity只有一个实例。好在Android系统为我们提供了这些功能,也就是我们本节要说的Activity的4个启动模式。用户可以在AndroidManifext.xml注册Activity时设置它的启动模式,例如:
<activity
android:name=".MyActivity"
android:launchMode=" singleTask"
android:label="@string/app_name" >
</activity>
Activity的启动模式有4个,分别为standard、singleTop、singleTask、singleInstance,下面我们逐个介绍它们。
这是Activity的标准启动模式,也是Activity的默认启动模式。在这种模式下启动的Activity可以被多次实例化,即在同一个任务栈中可以存在多个Activity实例,每个实例都会处理一个Intent对象。如果ActivityA的启动模式为standard,并且已经有一个ActivityA被启动,在该ActivityA中调用startActivity时会启动一个新的ActivityA实例。栈的变化如图1-4所示。
▲图1-4 栈中有多个ActivityA实例
如果ActivityA是一个非常耗资源的类,那么将会使它所依附的应用消耗更多的系统资源。
如果一个以singleTop模式启动的Activity的实例已经存在于任务栈的栈顶,那么再启动这个Activity时,不会创建新的实例,而是重用位于栈顶的那个实例,并且会调用该实例的onNewIntent()函数将Intent对象传递到这个实例中。例如,ActivityA的启动模式为singleTop,并且ActivityA的一个实例已经存在于栈顶中。那么再调用startActivity启动另一个ActivityA时,不会再次创建ActivityA的实例,而是重用原来的实例,并且调用原来实例的onNewIntent()函数。此时任务桟中还是这一个ActivityA的实例。栈内变化如图1-5所示。
▲图1-5 栈顶的ActivityA被重用
如果以singleTop模式启动的Activity的一个实例已经存在于任务桟中,但是不在桟顶,那么它的行为和standard模式相同也会创建一个新的实例。栈内变化如图1-6所示。
▲图1-6 不在栈顶,重新创建一个ActivityA
singleTask模式是常用的启动模式,如果一个Activity设置了该启动模式,那么在一个任务栈中只能有一个该Activity的实例。如果任务栈中还没有该Activity,会新创建一个实例并放在栈顶。但是,如果已经存在Activity,系统会销毁处在该 Activity上的所有Activity,最终让该 Activity实例处于栈顶。最终让该 Activity实例处于栈顶,同时回调该Activity的onNewIntent()函数。栈内变化如图1-7所示。
▲图1-7 处在ActivityA上的Activity被销毁
设置了singleInstance模式的Activity会在一个独立的任务中开启,并且这个新的任务中有且只有这一个实例,也就是说被该实例启动的其他Activity会自动运行于另一个任务中。当再次启动该Activity实例时,会重用已存在的任务和实例。并且会调用该实例的onNewIntent()函数,将Intent实例传递到该实例中。
和singleTask不同的是,同一时刻在系统中只会存在一个这样的Activity实例,而singleTask模式的Activity是可以有多个实例的,只要这些Activity在不同的任务栈中即可,例如,应用A启动了一个启动模式为singleTask的ActivityA,应用B又通过Intent想要启动一个ActivityA,此时由于应用A和应用B都有自己的任务栈,因此,在这两个任务栈中分别都有一个ActivityA示例。而singleInstance能够保证Activity在系统中只有一个实例,不管多少应用要启动该Activity,这个Activity有且只有一个,如图1-8所示。
▲图1-8 singleInstance的Activity独占一个任务栈
为了更好地运用越来越大的屏幕控件,Android在3.0版本引入了Fragment,它可以像Activity一样包含布局。不同的是Fragment是被嵌套在Activity中使用,它作为一个更大粒度的UI单元。如果需要兼容低于Android 3.0的系统,那么开发人员需要引用android-support-v4的jar包才能使用Fragment功能。
假如有这样的场景:我们的新闻应用含有两个Activity,第一个Activity是显示新闻标题、概要信息的列表,当用户点击这些标题时进入该新闻的详情页面进行阅读。
这是一个再普通不过的场景,但是这样做真的合适吗?我们是否能够简化用户的操作?
答案是:必须的!
Fragment就是为了应对这种情况而出现的,我们可以使用两个Fragment,Fragment1包含了一个新闻标题的列表,每行显示一个新闻的标题;Fragment2则展示这条新闻的详细内容。如果现在程序运行在竖屏模式的平板电脑或手机上,Fragment 1可能嵌入在一个Activity中,而Fragment 2可能嵌入在另一个Activity中,如图1-9所示。
▲图1-9 每个Activity包含一个Fragment
而如果现在程序运行在横屏模式的平板电脑上,两个Fragment就可以嵌入在同一个Activity中,如图1-10所示。
▲图1-10 横屏模式下包含两个Fragment的Activity
就目前开发来说,使用Fragment已经成为流行的开发方式,尽管在它的support v4中存在各种各样的Bug,以至于Square这样的公司举起了声讨Fragment的大旗,但是也不能阻止Fragment“驰骋”在大屏幕手机盛行的时代。
Service是Android中实现程序后台运行的解决方案,它非常适合用于去执行那些不需要和用户交互而且还要求长期运行的任务。但不要被“后台”二字所迷惑,Service默认并不会运行在子线程中,它也不运行在一个独立的进程中,它同样执行在UI线程中,因此,不要在Service中执行耗时的操作,除非你在Service中创建了子线程来完成耗时操作。
Service的运行不依赖于任何用户界面,即使程序被切换到后台或者用户打开了另外一个应用程序,Service仍然能够保持正常运行,这也正是Service的使用场景。当某个应用程序进程被杀掉时,所有依赖于该进程的Service也会停止运行。
Service的生命周期相对Activity来说简单得多,只有3个,分别为onCreate、onStartCommand和onDestory。一旦在项目的任何位置调用了Context 的startService()函数,相应的服务就会启动起来,首次创建时会调用onCreate函数,然后回调onStartCommand()函数。服务启动了之后会一直保持运行状态,直到stopService()或stopSelf()函数被调用。虽然每调用一次startService()函数,onStartCommand()就会执行一次,但实际上每个服务都只会存在一个实例。所以不管你调用了多少次startService()函数, 只需调用一个stopService()或stopSelf()函数,服务就会被停止。
通常的Service大致如下:
public class MyService extends Service {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
doMyJob(intent);
return super.onStartCommand(intent, flags, startId);
}
private void doMyJob(Intent intent){
// 从Intent中获取数据
// 执行相关操作
new Thread(){
@Override
public void run() {
// 耗时操作
}
}.start();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
与Activity一样,Service也需要在AndroidManifest.xml中进行注册,示例如下:
<service android:name=".service.MyService" />
上述示例表示注册一个在应用包service目录下的MyService服务,注册之后,当用户调用startService(new Intent(mContext,MyService.class)) 时会调用onStartCommand函数,我们在该函数中调用doMyJob,而在doMyJob中我们创建了一个线程来执行耗时操作,以避免阻塞UI线程。当我们的Service完成使命时,需要调用stopService来停止该服务。
完成一个简单的后台任务需要这么麻烦,Android显然早就“洞察”了这一点。因此,提供了一个IntentService来完成这样的操作,IntentService将用户的请求执行在一个子线程中,用户只需要覆写onHandleIntent函数,并且在该函数中完成自己的耗时操作即可。需要注意的是,在任务执行完毕之后IntentService会调用stopSelf自我销毁,因此,它适用于完成一些短期的耗时任务。示例如下:
public class MyIntentService extends IntentService {
MyIntentService(){
super(MyIntentService.class.getName());
}
@Override
protected void onHandleIntent(Intent intent) {
// 这里执行耗时操作
}
}
Service默认是运行在后台的,因此,它的优先级相对比较低,当系统出现内存不足的情况时,它就有可能会被回收掉。如果希望Service可以一直保持运行状态,而不会由于系统内存不足被回收,可以将Service运行在前台。前台服务不仅不会被系统无情地回收,它还会在通知栏显示一条消息,下拉状态栏后可以看到更加详细的信息。例如,墨迹天气在前台运行了一个Service,并且在Service中定时更新通知栏上的天气信息,如图1-11所示。
▲图1-11 墨迹天气界面
下面我们就来实现一个类似于如图1-11所示的效果,首先我们定义一个服务,代码如下:
public class WeatherService extends Service {
private static final int NOTIFY_ID = 123;
@Override
public void onCreate() {
super.onCreate();
showNotification();
}
/**
* 在通知栏显示天气信息
*/
private void showNotification() {
NotificationCompat.Builder mBuilder =
new NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.weather)
.setContentTitle(getText(R.string.the_day))
.setContentText(getText(R.string.weather));
// 创建通知被点击时触发的Intent
Intent resultIntent = new Intent(this, MainActivity.class);
// 创建任务栈Builder
TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);
stackBuilder.addParentStack(MainActivity.class);
stackBuilder.addNextIntent(resultIntent);
PendingIntent resultPendingIntent =
stackBuilder.getPendingIntent(
0, PendingIntent.FLAG_UPDATE_CURRENT);
mBuilder.setContentIntent(resultPendingIntent);
NotificationManager mNotifyMgr =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
// 构建通知
final Notification notification = mBuilder.build() ;
// 显示通知
mNotifyMgr.notify(NOTIFY_ID, notification);
// 启动为前台服务
startForeground(NOTIFY_ID, notification);
}
}
我们在onCreate函数中调用了showNotification函数显示通知,并且在最后调用startForeground将服务设置为前台服务。在AndroidManifest.xml注册之后我们就可以启动该Service了。效果如图1-12所示。
▲图1-12 WeatherService效果
AIDL(Android接口描述语言)是一种接口描述语言,通常用于进程间通信。编译器根据AIDL文件生成一个系列对应的Java类,通过预先定义的接口以及Binder机制达到进程间通信的目的。说白了,AIDL就是定义一个接口,客户端(调用端)通过bindService来与远程服务端建立一个连接,在该连接建立时会返回一个IBinder对象,该对象是服务端Binder的BinderProxy,在建立连接时,客户端通过asInterface函数将该BinderProxy对象包装成本地的Proxy,并将远程服务端的BinderProxy对象赋值给Proxy类的mRemote字段,就是通过mRemote执行远程函数调用。
在客户端新建一个AIDL文件,如图1-13所示。
▲图1-13 新建AIDL文件
在SsoAuth.aidl文件中会默认有一个basicTypes函数,我们在程序后面添加一个ssoAuth的函数用于SSO授权。代码如下:
interface SsoAuth {
void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat,
double aDouble, String aString);
/**
* 实现SSO授权
*/
void ssoAuth(String userName, String pwd);
}
因为客户端是调用端,因此,只需要定义AIDL文件,此时Rebuild一下工程就会生成一个SsoAuth.java类,该类根据SsoAuth.aidl文件生成,包含了我们在AIDL文件中定义的函数。因为AIDL通常用于进程间通信,因此,我们新建一个被调用端的工程,我们命名为aidl_server,然后将客户端的AIDL文件夹复制到aidl_server的app/src/main目录下,结构如图1-14所示。
▲图1-14 server中的AIDL
此时相当于在客户端和被调用端都有同一份SsoAuth.aidl文件,它们的包名、类名完全一致,生成的SsoAuth.java类也完全一致,这样在远程调用时它们就能够拥有一致的类型。Rebuild被调用端工程之后就会生成SsoAuth.java文件,该文件中有一个Stub类实现了SsoAuth接口。我们首先需要定义一个Service子类,然后再定义一个继承自Stub的子类,并且在Service的onBind函数中返回这个Stub子类的对象。示例代码如下:
public class SinaSsoAuthService extends Service {
SinaSsoImpl mBinder = new SinaSsoImpl();
@Override
public void onCreate() {
super.onCreate();
Log.e("","### sso auth created") ;
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
// 继承自Stub类,在这里实现ssoAuth函数
class SinaSsoImpl extends SsoAuth.Stub {
@Override
public void ssoAuth(String userName, String pwd) throws RemoteException {
Log.e("", "这里是新浪客户端, 执行SSO登录啦,用户名 : "
+ userName + ", 密码 : " + pwd) ;
}
@Override
public void basicTypes(int anInt, long aLong,
boolean aBoolean, float aFloat,
double aDouble, String aString) throws RemoteException {
}
}
}
从上述代码中我们看到,实际上完成功能的是继承自Stub的SinaSsoImpl类,Service只提供了一个让SinaSsoImpl依附的外壳。完成SinaSsoAuthService之后我们需要将它注册在被调用端应用的Manifest中,注册代码如下:
<service
android:name=".service.SinaSsoAuthService"
android:exported="true"
android:process=":remote"
android:label="@string/app_name">
<intent-filter>
<action android:name="book.aidl_server.service.SinaSsoAuthService"/>
</intent-filter>
</service>
然后先运行被调用端(也就是Server端)应用,并且在客户端中完成调用Server的代码。客户端Activity的代码如下:
public class MainActivity extends AppCompatActivity {
SsoAuth mSsoAuth ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 执行操作
findViewById(R.id.sso_btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if ( mSsoAuth == null ) {
// 绑定远程服务,并且进行登录
bindSsoAuthService();
} else {
doSsoAuth();
}
}
});
}
private void bindSsoAuthService() {
Intent intent = new Intent("book.aidl_server.service.SinaSsoAuthService") ;
bindService(intent, mConnection, Context.BIND_AUTO_CREATE) ;
}
ServiceConnection mConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
// 建立连接之后将Binder转换为mSsoAuth
mSsoAuth = SsoAuth.Stub.asInterface(iBinder) ;
doSsoAuth();
}
@Override
public void onServiceDisconnected(ComponentName componentName) {
mSsoAuth = null;
}
} ;
private void doSsoAuth() {
try {
// 执行登录,实际上调用的是Server端的ssoAuth函数
mSsoAuth.ssoAuth("Mr.Simple", "pwd123");
} catch (RemoteException e) {
e.printStackTrace();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
unbindService(mConnection);
}
}
在上述Activity程序中,运行程序后点击登录按钮时会向Server端发起连接Service请求,在建立连接之后会将Binder对象转换为SsoAuth对象,然后调用SsoAuth对象的ssoAuth函数。此时的ssoAuth函数实际上调用的就是Server端中SinaSsoImpl类的实现。运行程序后点击登录按钮,如图1-15所示。
▲图1-15 客户端的登录界面
输出结果,如图1-16所示。
▲图1-16 AIDL远程调用
这一切的核心都是通过AIDL文件生成的Stub类以及其背后的Binder机制。首先我们看看生成的SsoAuth.java,Stub类就是该文件中的内部类。代码如下:
// 根据SsoAuth.aidl生成的接口
public interface SsoAuth extends android.os.IInterface{
/** Stub类继承自Binder,并且实现了SsoAuth接口 */
public static abstract class Stub extends android.os.Binder
implements book.jtm_chap01.SsoAuth {
private static final java.lang.String DESCRIPTOR = "book.jtm_chap01.SsoAuth";
public Stub(){
this.attachInterface(this, DESCRIPTOR);
}
/**
* 将Binder转换为 book.jtm_chap01.SsoAuth接口或者包装为一个Proxy
*/
public static book.jtm_chap01.SsoAuth asInterface(android.os.IBinder obj){
if ((obj==null)) {
return null;
}
android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
if (((iin!=null)&&(iin instanceof book.jtm_chap01.SsoAuth))) {
return ((book.jtm_chap01.SsoAuth)iin);
}
return new book.jtm_chap01.SsoAuth.Stub.Proxy(obj);
}
@Override public android.os.IBinder asBinder(){
return this;
}
@Override public boolean onTransact(int code,
android.os.Parcel data, android.os.Parcel reply,
int flags) throws android.os.RemoteException{
switch (code){
case INTERFACE_TRANSACTION:{
reply.writeString(DESCRIPTOR);
return true;
}
case TRANSACTION_basicTypes:{
data.enforceInterface(DESCRIPTOR);
// 代码省略
return true;
}
case TRANSACTION_ssoAuth: // 执行ssoAuth函数时提交给Binder的数据
{
data.enforceInterface(DESCRIPTOR);
java.lang.String _arg0;
_arg0 = data.readString();
java.lang.String _arg1;
_arg1 = data.readString();
this.ssoAuth(_arg0, _arg1);
reply.writeNoException();
return true;
}
}
return super.onTransact(code, data, reply, flags);
}
// 本地代理,通过Binder与服务端的对象进行交互
private static class Proxy implements book.jtm_chap01.SsoAuth{
private android.os.IBinder mRemote;
Proxy(android.os.IBinder remote){
mRemote = remote;
}
@Override public android.os.IBinder asBinder(){
return mRemote;
}
// 代码省略
/**
* 实现SSO授权
*/
@Override public void ssoAuth(java.lang.String userName,
java.lang.String pwd) throws android.os.RemoteException{
android.os.Parcel _data = android.os.Parcel.obtain();
android.os.Parcel _reply = android.os.Parcel.obtain();
try {
_data.writeInterfaceToken(DESCRIPTOR);
_data.writeString(userName);
_data.writeString(pwd);
mRemote.transact(Stub.TRANSACTION_ssoAuth, _data, _reply, 0);
_reply.readException();
}
finally {
_reply.recycle();
_data.recycle();
}
}
}
static final int TRANSACTION_basicTypes
= (android.os.IBinder.FIRST_CALL_TRANSACTION + 0);
static final int TRANSACTION_ssoAuth
= (android.os.IBinder.FIRST_CALL_TRANSACTION + 1);
}
public void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat, double
aDouble, java.lang.String aString) throws android.os.RemoteException;
// 实现SSO授权
public void ssoAuth(java.lang.String userName, java.lang.String pwd)
throws android.os.RemoteException;
}
在SsoAuth.java中自动生成了SsoAuth接口,该接口中有一个ssoAuth函数。但最,重要的是生成了一个Stub类,该类继承自Binder类,并且实现了SsoAuth接口。Stub里面最重要的就是asInterface()这个函数,在这个函数中会判断obj参数的类型,如果该obj是本地的接口类型,则认为不是进程间调用,此时将该obj转换成SsoAuth类型;否则会通过自动生成的另一个内部类Proxy来包装obj,将其赋值给Proxy中的mRemote字段。Proxy类也实现了SsoAuth接口,不同的是它是通过Binder机制来与远程进程进行交互,例如,在ssoAuth ()函数中,Proxy将通过Binder机制向服务端传递请求和数据,它请求的类型为TRANSACTION_ssoAuth,参数分别是String类型的userName和pwd。
对于服务端代码来说,它也有同一份SsoAuth.aidli以及SsoAuth.java,但不同的是服务端是指令的接收端,客户端的调用会通过Binder机制传递到服务端,最终调用Stub类中的onTransact函数。可以看到在case TRANSACTION_ssoAuth处执行了this.ssoAuth()函数,意思是当接收到客户端的TRANSACTION_ssoAuth请求时,执行this.ssoAuth()函数,通过客户端的分析我们知道,当我们调用ssoAuth()时实际上就是通过mRemote向服务端提交了一个TRANSACTION_ssoAuth请求,因此,这两端通过Binder机制就对接上了,我们可以简单地理解为C/S模式。
而在客户端调用bindService之后,如果绑定成功则会调用onServiceConnected(ComponentName name,IBinder service),这里的Service对象是BinderProxy类型,经过asInterface转换后被包装成了Proxy类型,但是调用的时候,执行的是服务端SinaSsoImpl中的ssoAuth()函数。因此, SinaSsoImpl实例mBinder被服务端包装成BinderProxy类型,再经过客户端的Proxy进行包装,通过Binder机制进行数据传输,实现进程间调用。
它们的调用时序图如图1-17所示。
▲图1-17 调用时序图
打个比方说,有两个公司打算进行合作需要进行业务磋商,并且这次合作已经签署了合同,只剩下一些细节没有最终确定。但是由于大BOSS比较忙,因此各自都派了一个代表进行沟通。由于两家公司相距较远,双方代表都通过电话进行沟通。BOSS-A跟代表-A交代说,这次合作对方支付的酬劳不能低于十块钱,于是代表-A通过电话与代表B进行沟通,代表-B得到消息之后跑到BOSS-B的办公室请示,BOSS-B确认之后又由代表-B回复代表-A,代表-A最终反馈给BOSS-A。这个例子中的两个BOSS分别对应客户端和服务端,合同就对应了SsoAuth接口,而两个代表则对应了两端的Proxy,代表的通信方式则是电话,而代码的通信方式是Binder。
总体来说,使用AIDL并不是一件困难的事,但是理解AIDL的机制确实有一定的难度。也正是如此,Android通过AIDL这个机制将一些复杂的概念与逻辑通过自动生成类型的方式屏蔽掉,使得开发人员能够更简单地进行进程间通信。
Broadcast是一种广泛运用的、在应用程序之间传输信息的机制,Android中的广播与传统意义上的电台广播类似,一个广播可以有任意个接收者。广播机制是一个典型的发布—订阅模式,也就是我们所说的观察者模式。广播机制最大的特点就是发送方并不关心接收方是否接到数据,也不关心接收方是如何处理数据的,通过这样的形式来达到接、收双方的完全解耦合。
Android广播机制包含 3 个基本要素,分别是用于发送广播的Broadcast、接收广播的BroadcastReceiver以及用于传递信息的Intent。Android广播可分为普通广播、有序广播、本地广播和Sticky广播。
普通广播是完全异步的,通过Context的sendBroadcast()函数来发送,消息传递的效率比较高,但所有的receivers(接收器)的执行顺序不确定。缺点是:接收者不能将处理结果传递给下一个接收者,并且无法终止广播Intent的传播,直到没有与之匹配的广播接收器为止。
首先我们需要定义一个广播接收器,示例如下:
public class HelloBroadcastReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Toast.makeText(context, "hello", Toast.LENGTH_LONG).show();
}
}
第二步是注册广播,形式与Activity、Service的注册类似,可以通过AndroidManifest.xml或者代码进行注册,分别是静态注册和动态注册。xml注册示例如下:
<receiver android:name=".broadcast.HelloBroadcastReceiver" >
<intent-filter>
<action android:name="hello"/>
</intent-filter>
</receiver>
动态注册的示例代码为:
private void registerHelloBroadcast() {
registerReceiver(new HelloBroadcastReceiver(),
new IntentFilter(HELLO_ACTON)) ;
}
如果是在Activity或者Fragment中动态测试,那么不要忘了在执行onDestory时注销该广播。注册之后就可以发送广播了,代码如下所示:
private void sendNormalBroadcast() {
sendBroadcast(new Intent(HELLO_ACTON));
}
然后就会调用HelloBroadcastReceiver的onReceive函数,在该函数中执行相关操作即可。
有序广播通过Context.sendOrderedBroadcast()来发送,所有的广播接收器按照优先级依次执行,广播接收器的优先级通过receiver的intent-filter中的android:priority属性来设置,数值越大优先级越高。当广播接收器接收到广播后,可以使用setResult()函数来结果传给下一个广播接收器接,然后通过getResult()函数来取得上个广播接收器接返回的结果,并可以用abortBroadcast()函数来让系统丢弃该广播,使该广播不再传送到别的广播接收器接。
设置广播的优先级示例:
<receiver android:name=".broadcast.HelloBroadcastReceiver" >
<intent-filter android:priority="100">
<action android:name="hello"/>
</intent-filter>
</receiver>
发送有序广播:
private void sendOrderBroadcast() {
sendOrderedBroadcast(new Intent(HELLO_ACTON), null);
}
在21版的Support v4包中新增本地广播,也就是LocalBroadcastManager。之前广播都是全局的,所有应用程序都可以接收到,这样就会带来安全隐患。但是,有的时候我们并不需要把自己应用内的信息广播给所有应用,而只是进程内使用,现在使用Support v4包中的LocalBroadcastManager就能够实现限于应用内的广播。
它的用法很简单,只需要把调用context的sendBroadcast、registerReceiver 、unregisterReceiver 的地方替换为LocalBroadcastManager getInstance (Context context)中对应的函数即可,如图1-1所示。
表1-1 函数的作用
函 数 |
作 用 |
---|---|
LocalBroadcastManager.getInstance(context). registerReceiver(receiver,intentFilter) |
注册Receiver |
LocalBroadcastManager.getInstance(context). unregisterReceiver(receiver) |
注销Receiver |
LocalBroadcastManager.getInstance(context). sendBroadcast(new Intent(HELLO_ACTON)); |
发送异步广播 |
LocalBroadcastManager.getInstance(context). sendBroadcastSync(new Intent()); |
发送同步广播 |
本地广播与普通广播只是操作的类不一样,其他的接口基本上都类似,因此,替换为本地广播的成本相对较低。为了程序的安全性,建议在不需要其他进程接收广播的情况下使用本地广播。
sticky广播通过Context.sendStickyBroadcast()函数来发送,用此函数发送的广播会一直滞留,当有匹配此广播的广播接收器被注册后,该广播接收器就会收到此条广播。使用此函数发送广播时,需要获得BROADCAST_STICKY权限:
<uses-permission android:name="android.permission.BROADCAST_STICKY"/>
sendStickyBroadcast只保留最后一条广播,并且一直保留下去,这样即使已经有广播接收器处理了该广播,当再有匹配的广播接收器被注册时,此广播仍会被接收。如果你只想处理一遍该广播,可以通过removeStickyBroadcast()函数实现。
ContentProvider在android中的作用是对外共享数据,也就是说可以通过ContentProvider把应用中的数据共享给其他应用访问,其他应用可以通过ContentProvider对应用中的数据进行添、删、改、查。使用ContentProvider对外共享数据的好处是,统一了数据的访问方式,它实际上是对SQliteOpenHelper的进一步封装,通过Uri映射来判断选择需要操作数据库中的哪个表,并且进行增、删、改、查处理。
首先我们先来学习Uri,Uri代表了要操作的数据表的绝对路径,Uri主要包含了两部分信息,一是需要操作的ContentProvider,二是对ContentProvider中的哪个表进行操作。对于ContentProvider来说,一个Uri由以下几部分组成,如图1-18所示。
▲图1-18 Uri格式
ContentProvider的scheme已经由Android固定设置为content://,Authority用于唯一标识这个ContentProvider,外部调用者可以根据这个标识来找到它。这里的path就是要操作的数据库表,最后的id关键字是可选字段,例如,我们要操作特定的数据项时就会指定一个查询条件,如所有联系人的Uri:content://contacts/people,某个联系人的Uri: content://contacts/people/5,这个5就是联系人的id,也就对应了这里的查询关键字。
如果要把一个字符串转换成Uri,可以使用Uri类中的parse()函数,如下:
Uri uri = Uri.parse("content://contacts /people");
Android系统根据Uri来定位注册到系统的ContentProvider中,找到ContentProvider之后会通过ContentResolver来操作对应的数据库。实现ContentProvider的第一步就是需要覆写ContentProvider的insert、query、upate、delete、getType函数。下面我们要创建一个ContentProvider,该ContentProvider存储了一些服务行业人员的电话信息,如一些系统中就存储了快递人员、肯德基订餐电话等信息。我们要做的就是实现类似的功能,首先定义一个ContentProvider,代码如下:
public class UserInfoProvider extends ContentProvider {
private static final String CONTENT = "content://";
public static final String AUTHORIY = "com.book.jtm.info";
/**
* 该ContentProvider所返回的数据类型定义、数据集合
*/
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd." + AUTHORIY;
/**
* 单项数据
*/
public static final String CONTENT_TYPE_ITEM = "vnd.android.cursor.item/vnd." + AUTHORIY;
/**
* 数据集合操作时的Uri
*/
public static final Uri POSTCODE_URI = Uri.parse(CONTENT + AUTHORIY + "/" +
UserInfoDbHelper.TABLE_USER_INFO);
/**
* 数据集合操作时的Uri
*/
public static final Uri COMPANY_URI = Uri.parse(CONTENT + AUTHORIY + "/" +
UserInfoDbHelper.TABLE_COMPANY);
private SQLiteDatabase mDatabase;
static final int USER_INFOS = 1;
static final int USER_INFO_ITEM = 2;
static final int COMPANY = 3;
static final int COMPANY_ITEM = 4;
static UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
uriMatcher.addURI(AUTHORIY, "userinfo", USER_INFOS);
uriMatcher.addURI(AUTHORIY, "userinfo/*", USER_INFO_ITEM);
uriMatcher.addURI(AUTHORIY, "company", COMPANY);
uriMatcher.addURI(AUTHORIY, "company/#",COMPANY_ITEM);
}
@Override
public String getType(Uri uri) {
switch (uriMatcher.match(uri)) {
case USER_INFOS:
case COMPANY:
return CONTENT_TYPE;
case USER_INFO_ITEM:
case COMPANY_ITEM:
return CONTENT_TYPE_ITEM;
default:
throw new RuntimeException("错误的Uri");
}
}
// 删除、更新的代码省略
}
我们需要在ContentProvider中根据Uri建立关系映射,通过UriMatcher管理不同Uri对应的Type类型,这个类型会在getType中被返回。当在ContentProvider中进行增、删、改、查操作时,就会根据这个类型选择对应的数据表。在这个例子中,我们通过UriMatcher映射了4种Uri类型, Uri 的格式主要有两种,以表名结尾就表示期望访问该表中所有的数据,以id结尾就表示期望访问该表中拥有相应 id 的数据。我们可以使用通配符的方式来分别匹配这两种格式的内容Uri,“*”表示匹配任意长度的任意字符,“#”表示匹配任意长度的数字。因此,content://com.book.jtm.info /userinfo表示要查询userinfo表中的所有数据,而content://com.book.jtm.info /userinfo/#表示要根据一个数字id查询一个用户。
下面我们就完成数据库操作的相关代码:
@Override
public boolean onCreate() {
mDatabase = new UserInfoDbHelper(getContext()).getWritableDatabase();
return true;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
long newId = 0 ;
Uri newUri = null;
switch (uriMatcher.match(uri)) {
case USER_INFOS:
newId = mDatabase.insert(UserInfoDbHelper.TABLE_USER_INFO, null, values);
newUri = Uri.parse(CONTENT + AUTHORIY + "/"
+ UserInfoDbHelper.TABLE_USER_INFO + "/" + newId);
break;
case COMPANY:
newId = mDatabase.insert(UserInfoDbHelper.TABLE_COMPANY, null, values);
newUri = Uri.parse(CONTENT + AUTHORIY + "/"
+ UserInfoDbHelper.TABLE_COMPANY + "/" + newId);
break;
}
if (newId > 0) {
return newUri;
}
throw new IllegalArgumentException("Failed to insert row into" + uri);
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[]
selectionArgs, String sortOrder) {
Cursor cursor = null;
switch (uriMatcher.match(uri)) {
case USER_INFOS:
cursor = mDatabase.query(UserInfoDbHelper.TABLE_USER_INFO, projection,
selection, selectionArgs, null, null, sortOrder);
break;
case USER_INFO_ITEM:
String tel = uri.getPathSegments().get(1);
cursor = mDatabase.query(UserInfoDbHelper.TABLE_USER_INFO, projection,
"tel_num = ?", new String[]{tel}, null, null, sortOrder);
break;
case COMPANY:
cursor = mDatabase.query(UserInfoDbHelper.TABLE_COMPANY, projection,
selection, selectionArgs, null, null, sortOrder);
break;
case COMPANY_ITEM:
String cid = uri.getPathSegments().get(1);
cursor = mDatabase.query(UserInfoDbHelper.TABLE_COMPANY, projection,
"id = ?", new String[]{cid}, null, null, sortOrder);
break;
}
return cursor;
}
// 删除、更新的代码省略
上述代码中,我们把传递进来的Uri通过UriMatcher进行解析,得到type之后,再根据type来判断要操作哪个表,根据它的数据类型是所有数据还是单个数据,然后执行对应的数据库操作。对数据库的操作通过UserInfoDbHelper类实现。代码如下:
public class UserInfoDbHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "userinfo.db";
private static final int DB_VERSION = 1;
public static final String TABLE_USER_INFO = "userinfo";
public static final String TABLE_COMPANY = "company";
public static final String TEL_COLUMN = "tel_num";
public static final String DESC_COLUMN = "desc";
public static final String COMP_ID_COLUMN = "comp_id";
public static final String ID_COLUMN = "id";
public static final String BUSINESS_COLUMN = "business";
public static final String ADDR_COLUMN = "addr";
private static final String POSTCODE_TABLE_SQL = "CREATE TABLE " + TABLE_USER_INFO + " ("
+ TEL_COLUMN + " TEXT ,"
+ COMP_ID_COLUMN + " TEXT , "
+ DESC_COLUMN + " TEXT "
+ ")";
private static final String COMPANY_TABLE_SQL = "CREATE TABLE " + TABLE_COMPANY + " ("
+ ID_COLUMN + " TEXT PRIMARY KEY , "
+ BUSINESS_COLUMN + " TEXT , "
+ ADDR_COLUMN + " TEXT"
+ ")";
public UserInfoDbHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(POSTCODE_TABLE_SQL);
db.execSQL(COMPANY_TABLE_SQL);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
}
}
在UserInfoDbHelper中我们建立了两个表,分别为userinfo和company,表结构表1-2和表1-3所示。
表1-2 userinfo
字 段 名 |
类 型 |
意 义 |
---|---|---|
Tel_num |
TEXT |
电话号码 |
Desc |
TEXT |
描述 |
comp_id |
INTEGER |
公司id |
表1-3 company
字 段 名 |
类 型 |
意 义 |
---|---|---|
Id |
INTEGER |
公司的id |
Business |
TEXT |
公司业务 |
Addr |
TEXT |
公司位置 |
完成ContentProvider代码之后,第二步需要在AndroidManifest.xml中使用<provider>对该ContentProvider进行配置,为了能让其他应用找到该ContentProvider ,ContentProvider采用了authorities对它进行唯一标识,示例如下:
<providerandroid:name=".provider.UserInfoProvider"
android:authorities="com.book.jtm.info" />
此时,我们就可以使用该ContentProvider了。
我们新建一个ProviderActivity,在该Activity中存储、查询用户信息,代码如下所示:
public class ProviderActivity extends Activity {
EditText mUserDescEdittext; // 用户描述信息
EditText mUserTelEdittext; // 电话号码
EditText mUserCompIdEdittext ; // 用户所属的公司id
Button mSubmitBtn; // 提交按钮
EditText mCompIdEdittext; // 公司id
EditText mCompBusinessEdittext; // 公司业务
EditText mCompAddrEdittext; // 公司地址
Button mCompSubmitBtn; // 提交按钮
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_provider);
initWidgets();
}
private void initWidgets() {
// 用户信息相关的View
mUserDescEdittext = (EditText) findViewById(R.id.desc_edit);
mUserTelEdittext = (EditText) findViewById(R.id.tel_edit);
mUserCompIdEdittext = (EditText) findViewById(R.id.comp_edit);
mSubmitBtn = (Button) findViewById(R.id.submit_btn);
mSubmitBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
saveUserInfoRecord();
mSubmitBtn.postDelayed(new Runnable() {
@Override
public void run() {
queryPostCode();
}
}, 1000) ;
}
});
// 公司信息相关的View
mCompAddrEdittext = (EditText) findViewById(R.id.comp_addr_edit);
mCompIdEdittext = (EditText) findViewById(R.id.comp_id_edit);
mCompBusinessEdittext = (EditText) findViewById(R.id.comp_business_edit);
mCompSubmitBtn = (Button) findViewById(R.id.submit_comp_btn);
mCompSubmitBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
saveCompanyRecord();
}
});
}
/**
* 存储用户信息到ContentProvider
*/
private void saveUserInfoRecord() {
ContentValues newRecord = new ContentValues();
newRecord.put(UserInfoDbHelper.DESC_COLUMN,
mUserDescEdittext.getText().toString());
newRecord.put(UserInfoDbHelper.TEL_COLUMN,
mUserTelEdittext.getText().toString());
newRecord.put(UserInfoDbHelper.COMP_ID_COLUMN,
mCompIdEdittext.getText().toString());
getContentResolver().insert(UserInfoProvider.USERINFO_URI, newRecord);
}
/**
* 存储公司信息到ContentProvider中
*/
private void saveCompanyRecord() {
ContentValues newRecord = new ContentValues();
newRecord.put(UserInfoDbHelper.ADDR_COLUMN,
mCompAddrEdittext.getText().toString());
newRecord.put(UserInfoDbHelper.BUSINESS_COLUMN,
mCompBusinessEdittext.getText().toString());
newRecord.put(UserInfoDbHelper.ID_COLUMN, mCompIdEdittext.getText().toString());
getContentResolver().insert(UserInfoProvider.COMPANY_URI, newRecord);
}
/**
* 通过电话号码查询相关信息
*/
private void queryPostCode() {
Uri queryUri = Uri.parse("content://com.book.jtm.info/userinfo/123456");
Cursor cursor = getContentResolver().query(queryUri, null, null, null, null);
if (cursor.moveToFirst()) {
Toast.makeText(this, "电话来自 : "
+ cursor.getString(2), Toast.LENGTH_SHORT).show();
}
}
}
上述Activity中我们可以将用户信息和公司信息根据不同的Uri插入到同一个ContentProvider中,UserInfoProvider会根据Uri选择对应的表进行插入,查询也是同样的道理。首先我们插入一条用户数据,该用户的电话为123456,描述为顺风快递员吴老二,公司id为11,插入之后我们可通过电话号码123456到ContentProvider中查询该用户的信息,此时就会得到该用户的描述信息,如图1-19所示。
▲图1-19
然后我们再插入该公司的信息,此时该ConentProvider的数据库中就有了数据,我们将数据库导出到PC中,结果如图1-20和图1-21所示。
▲图1-20 userinfo表的数据
▲图1-21 company表的数据
正如本章前文所说,ContentProvider不过是对SQLiteOpenHelper的二次封装,通过UriMatcher将数据库中的表与Uri建立关联,当用户通过Uri操作ContentProvider时,ContentProvider会根据Uri选择对应的数据库表进行增、删、改、查操作。通过ContentProvider机制,使得数据可以在各应用之间共享,并且为用户提供了统一的API接口,降低了用户的使用成本。
Android之父Andy Rubin在被采访时说过,在设计Android之初他就希望Android能像Facebook那样可以使用不同的应用中的功能模块,通过现有的模块像搭积木一样方便地构建一个应用。
正是基于这种理念,Android被设计为高度组件化、可复用的系统。通常来说,一旦在系统中存在了某个功能模块,你就能够通过Intent复用它,而不必重复实现它,如图片浏览。开发者需要依照Android规范,编写一个个独立的组件,然后通过配置文件对每个组件的需求和能力进行描述,Android系统会统一管理这些组件。这就意味着,应用中的某一项功能,可能是由来自很多应用的若干组件共同完成的,各个Android应用,只有明确的组件边界,而不再有明确的应用边界。
正是由于这种组件化的设计思想,使得Android变得极为灵活,Activity提供UI界面的管理、Service提供与UI无关的服务、ContentProvider用于共享数据、Broadcast用于跨进程数据传输,而Intent就是这些组件的粘合剂将它们联结在一起,但彼此之间却几乎没有耦合。对于开发者而言,需要充分理解基于组件化的应用设计模式,并利用组件化带来的灵活性和可复用性,在此基础上再完成具有应用特性的组件即可打造丰富多彩的应用。
在第一章中,我们说到Android的用户界面构成,实际上就是Activity由一个搭载着视图树的Window构成。作为与用户直接交互的元素,UI控件变得尤为重要。本章将介绍部分常用且重要的控件、自定义控件、动画等内容,使我们进一步认识View,进入更丰富多彩的视图世界。
通常来说用户界面都是由Activity组成,Activity中关联了一个PhoneWindow创建,在这个窗口下则管理了一颗视图树。这颗视图树的顶级视图就是一个ViewGroup类型的DecorView,DecorView下就是各个视图控件。这样一来就组成了Android丰富多彩的UI元素。如图2-1所示。
▲图2-1 用户界面组成
本章我们并不会从TextView、Button等最基本控件谈起,正如我们前文所说的,会介绍部分重要的UI控件以及它们的基本原理,本章的重点是掌握自定义View以及动画。通过了解重要控件的基本原理以及自定义View,使我们能够深入了解View系统,并且能够有能力创建出自己所需的View。
对于用Android开发来说,最重要的控件应该非ListView莫属。它以列表的形式展示具体内容,并且能够根据数据的长度自适应显示。如图2-2和图2-3所示。
▲图2-2 列表视图
▲图2-3 Gmail邮件列表
列表数据的显示需要4个元素,分别为:
(1)用来展示列表的ListView;
(2)用来把数据映射到ListView上的Adapter;
(3)需要展示的数据集;
(4)数据展示的View模板。
首先自然是ListView控件,但是该控件只负责加载、管理视图(每项数据称为Item View),至于有多少项数据、每一项数据是如何显示的它并不关心。而这一切都是交给Adapter类实现,通过Adapter模式,用户只需覆写特定的几个函数就可以将ListView的每项数据构建出来。需要实现的Adapter函数为:
(1)getCount()函数——获取数据的个数;
(2)getItem(int)函数——获取position位置的数据;
(3)getItemId(int)函数——获取position位置的数据id,一般直接返回position即可;
(4)getView(int, View,ViewGroup)函数——获取position位置上的Item View视图。
因为Adapter中含有要显示的数据集合,数据集合中的元素个数也就是要展示的Item View的个数,通过Adapter的getCount()函数返回;而每个数据的获取则通过Adapter的getItem(int)函数实现,根据索引直接访问集合中的元素即可;每个Item View则是通过getView函数实现,在这个函数中用户必须构建Item View,然后将该position位置上数据绑定到Item View上。这样一来,数据就和视图结合在一起了。
当ListView加载时会根据数据的个数来创建Item View,然后根据该View的索引从数据集合中获取数据,调用getView获取具体的视图,并且与数据绑定。但是,并不是有多少数据项就会产生多少Item View,Android采用了视图复用的形式来避免创建过多的Item View,这样能够非常有效地提升性能和降低内存占用率。具体的设计如图2-4所示。
在处理数据量较大时,ListView会构建铺满屏幕所需的Item View个数,当屏幕向下滚动时,第一项数据将会滚出屏幕的可见范围之内,并且进入ListView的一个Recycler中,Recycler会将该视图缓存,如图2-4中的item 1。而此时第8项也需要加载,ListView首先会从Recycler中获取视图,如果视图存在,那么用户可以直接使用该缓存视图,或者重新创建新的视图。当然,这些步骤都是在Adapter中完成的,一个典型的getView函数大致如下:
▲图2-4 ListView的Item View复用机制
public View getView(int position, View convertView, ViewGroup parent) {
View view = null;
// 有视图缓存,复用属兔
if (convertView != null) {
view = convertView;
} else {
// 重新加载视图
}
// 进行数据绑定
// 返回Item View
return view;
}
getView函数的position就表示该视图是第几项数据,convertView就表示缓存的Item View,parent表示该Item View的父视图,对于ListView来说这个parent就代表ListView本身。这里最重要的就是convertView参数,如果有缓存那么该参数不为空,此时直接复用该视图;否则需要重新创建一个新的视图,最后绑定数据并且将该Item View返回。
我们说到ListView只会展示有限数量的Item View,例如8个Item View就能够铺满屏幕,那么即使数据项有1000个,通过复用机制Item View可以只产生8个,这样既节约内存又能很大程度上提高运行效率。复用Item View机制也是优化ListView等集合组件最重要的手段。
那么当某个数据源发生变化之后如何更新ListView呢?
我们知道ListView运用了Adapter模式,但是,在Adapter类中却还运用了观察者模式,Adapter内部有一个可观察者类,ListView则作为它的其中一个观察者。在将Adapter设置给ListView时,ListView会被注册到这个观察者对象中。代码如下:
@Override
public void setAdapter(ListAdapter adapter) {
resetList();
// 清空视图缓存mRecycler
mRecycler.clear();
if (mAdapter != null) {
mDataSetObserver = new AdapterDataSetObserver();
// 将mDataSetObserver注册到adapter中
mAdapter.registerDataSetObserver(mDataSetObserver);
} else {
// 代码省略
}
requestLayout();
}
从以上程序中我们看到,设置Adapter时创建了一个AdapterDataSetObserver对象,然后注册到mAdapter中。刚才不是说ListView是观察者吗?这会儿怎么成了AdapterDataSetObserver对象?我们先放下这个疑问,继续往下看。
首先看我们常用的Adapter基类-BaseAdapter,部分代码如下:
public abstract class BaseAdapter implements ListAdapter, SpinnerAdapter {
private final DataSetObservable mDataSetObservable = new DataSetObservable();
public void registerDataSetObserver(DataSetObserver observer) {
mDataSetObservable.registerObserver(observer);
}
public void notifyDataSetChanged() {
mDataSetObservable.notifyChanged();
}
// 代码省略
}
从以上程序中可以看到,注册观察者实际上是调用了DataSetObservable对应的函数。DataSetObservable拥有一个观察者集合,当可观察者发生变更时,就会通知观察者做出相应的处理。代码如下:
public abstract class Observable<T> {
// 观察者列表
protected final ArrayList<T> mObservers = new ArrayList<T>();
public void registerObserver(T observer) {
if (observer == null) {
throw new IllegalArgumentException("The observer is null.");
}
synchronized(mObservers) {
// 代码省略
// 注册观察者
mObservers.add(observer);
}
}
}
当Adapter的数据源发生变化时,我们会调用Adapter的notifyDataSetChanged函数,在该函数中又会调用DataSetObservable对象的notifyChanged函数通知所有观察者数据发生了变化,使观察者进行相应的操作。代码如下:
public class DataSetObservable extends Observable<DataSetObserver> {
public void notifyChanged() {
synchronized(mObservers) {
for (int i = mObservers.size() - 1; i >= 0; i--) {
// 调用观察者的onChanged函数
mObservers.get(i).onChanged();
}
}
}
}
对于ListView来说,这个观察者就是AdapterDataSetObserver对象,该类声明在AdapterView类中,也是ListView中的一个父类。AdapterDataSetObserver的代码如下:
// AdapterView的内部类AdapterDataSetObserver中
class AdapterDataSetObserver extends DataSetObserver {
private Parcelable mInstanceState = null;
@Override
public void onChanged() {
mDataChanged = true;
mOldItemCount = mItemCount;
// 获取元素个数
mItemCount = getAdapter().getCount();
// 代码省略
checkFocus();
// 重新布局
requestLayout();
}
// 代码省略
}
从以上程序中可以看到,在AdapterDataSetObserver的onChanged函数中会调用ViewGroup的requestLayout()函数进行重新策略、布局、绘制整个ListView的Item View,执行完这个过程之后ListView的元素就发生了变化,因此,此时会根据新的数据来加载Item View。
现在我们回到上面提到的问题,也就是ListView并不是观察者,而AdapterDataSetObserver对象才是观察者的问题。在AdapterData SetObserver的onChanged函数中,实际上调用的却是AdapterView或者ViewGroup类中的属性或者函数完成功能,因此,AdapterDataSet Observer只是在外层做了一下包装,真正核心的功能应该是ListView,确切地说应该是AdapterView。ListView就是通过Adapter模式、观察者模式、Item View复用机制实现了高效的列表显示。
与ListView 相似,GridView 同样继承自 AbsListView,而AbsListView 又是 AdapterView 的子类。GridView因此同样集成了AbsListView的Adapter模式、观察者模式、Item View复用机制等特性,它与ListView不同的就是布局方式。ListView以列表形式展示,而GridView与它的名字一样则是通过网格布局形式展示,如图2-5所示。
▲图2-5 列数为3的GridView
本是同根生使得ListView和GridView拥有了很好的兼容性,同一个Adapter可以设置给ListView或者GridView,不需要半点修改。当然也可以同时设置给这两个视图,这样一来,两个视图都作为该Adapter的观察者,数据会同时显示到这两个视图上。我想这就是为什么要运用观察者模式的缘由吧。
观察者模式、Adapter模式赋予了ListView、GridView等视图良好的可扩展性,但是从另一个角度看,它们似乎太过于相似了,以至于让我们不禁思考,这样做真的是最好的吗?
从上文中我们知道,ListView、GridView基本上只有布局方式不一样而已,其他的机制基本一致。那么有没有更好的实现方式呢?答案是肯定的。
RecyclerView就是作为ListView、GridView的替代者出现的。它的设计与ListView、GridView类似,也使用了Adapter,不过该Adapter并不是ListView中的Adapter,而是RecyclerView的一个静态内部类。该Adapter有一个泛型参数VH,代表的就是ViewHolder。RecyclerView还封装了一个ViewHolder类型,该类型中有一个itemView字段,代表的就是每一项数据的根视图,需要在构造函数中传递给ViewHolder对象。RecyclerView这么设计相当于Android团队将ListView的Adapter进行了再次封装,把getView函数中判断是否含有缓存的代码段封装到RecyclerView内部,使这部分逻辑对用户不可见。用户只需要告诉RecyclerView每项数据是怎么样的以及将数据绑定到每项数据上,分别对应的函数为onCreateViewHolder函数、onBindViewHolder函数,当然还需要通过getItemCount告诉RecyclerView有多少项数据,以往适用于ListView的 Adapter中的getView函数中的逻辑就不需要用户来处理了。一个RecyclerView的Adapter大致如下:
public class RecyclerAdapter extends Adapter<RecyclerViewHolder> {
List<String> mDataSet = new ArrayList<String>() ;
@Override
public int getItemCount() {
return mDataSet.size();
}
@Override
public void onBindViewHolder(RecyclerViewHolder viewHolder, int position) {
// 绑定数据
viewHolder.nameTv.setText(mDataSet.get(position));
}
@Override
public RecyclerViewHolder onCreateViewHolder(ViewGroup parant, int viewType) {
// 创建ViewHolder
return new RecyclerViewHolder(new TextView(parant.getContext()));
}
// 自定义的ViewHolder
static class RecyclerViewHolder extends ViewHolder {
TextView nameTv ;
public RecyclerViewHolder(View itemView) {
super(itemView) ;
nameTv = (TextView)itemView.findViewById(R.id.username_tv);
}
}
}
从RecyclerAdapter中可以看到代码量比ListView的Adapter要少了很多,尤其是不需要用户判断是否使用Item View缓存,用户只需要完成具体的ViewHolder构造以及数据绑定即可。
光这点改进还不足以让RecyclerView如此光芒四射,它的另一大特点就是将布局方式抽象为LayoutManager,默认提供了LinearLayoutManager、GridLayoutManager、StaggeredGridLayoutManager 3种布局,对应为线性布局、网格布局、交错网格布局,如果这些都无法满足你的需求,你还可以定制布局管理器实现特定的布局方式。如图2-6所示分别为线性布局、网格布局、自定义布局。
▲图2-6 Recycler的布局方式
RecyclerView通过桥接的方式将布局职责抽离出去,使得RecyclerView变得更灵活。例如,如果用户只需要修改RecyclerView的布局方式,只需要修改LayoutManager即可,而不需要操作复杂的RecyclerView类型。而ListView、GridView正好是相反的,它们只是布局方式不一样,但却是两个类型,它们覆写了基类AbsListView的layoutChildren函数来实现不同的布局。显然,通过组合的形式要好于通过继承,因此,RecyclerView在设计上也要好于AbsListView类族。
除此之外,RecyclerView对于Item View的控制也更为精细,可以通过ItemDecotation为Item View添加装饰,也就是在Item View上进行二次加工;又可以用过ItemAnimator为Item View添加动画。职责分明、结构清晰使得RecyclerView具有了非常好的扩展性,这也是它成为未来几年最重要控件的重要原因。
一个应用中通常都会有页面导航,用户根据页面导航进入到不同的功能界面,这几乎是每个应用的必备功能。由于Android设备都是触摸屏,因此,通过滑动来进行页面导航再适合不过。ViewPager就是为这种场景而生的,尤其是它与Fragment结合在一起使用时简直可称为“黑白双煞”,Android也深知其的重要性,因此,提供了几个适用于Fragment的Adapter。
没错,又是Adapter!通常来说,定制含有Item View类型的控件都应该使用Adapter模式,因为你不知道用户的Item View是怎样的,你只能通过一个Adapter来进行抽象,让用户将具体的视图、数据通过Adapter进行操作。例如,通过getItem获取某个数据、通过getView获取每个Item View,这样一来变化的部分就交给用户来实现,控件只需关注自身的逻辑,然后通过Adapter的getView来获取每个Item View即可。
ViewPager内部同样也是维护了一个视图集合,这些视图集合横向布局,用户可以通过左右滑动来进行页面切换,如图2-7所示。
▲图2-7 ViewPager示例
如前文所说,ViewPager通常都用于显示Fragment,而ViewPager与Fragment组合时通常会有一个指示器(ViewPagerIndicator)表明当前显示的是哪个页面,如图2-8所示。
▲图2-8 ViewPager和ViewIndicator
指示器与ViewPager实际上是两个视图,指示器根据ViewPager的页面数量以及提供的数据生成特定的指示器项,当ViewPager进行滑动时,指示器上的当前页面标识会随之变化。
图2-8的视图布局程序如下:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<com.viewpagerindicator.TabPageIndicator
android:id="@+id/indicator"
android:layout_height="wrap_content"
android:layout_width="fill_parent"
/>
<android.support.v4.view.ViewPager
android:id="@+id/pager"
android:layout_width="fill_parent"
android:layout_height="0dp"
android:layout_weight="1"
/>
</LinearLayout>
在代码中我们需要进行设置,示例如下:
public class MainActivity extends FragmentActivity {
// Tab标题
private static final String[] TITLE = new String[] { "页面1", "页面2", "页面3"};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//ViewPager的adapter
FragmentPagerAdapter adapter
= new TabPageIndicatorAdapter(getSupportFragmentManager());
ViewPager pager = (ViewPager)findViewById(R.id.pager);
// 将Adapter设置给ViewPager
pager.setAdapter(adapter);
//实例化TabPageIndicator后与ViewPager进行关联
TabPageIndicator indicator = (TabPageIndicator)findViewById(R.id.indicator);
indicator.setViewPager(pager);
}
// ViewPager适配器
class TabPageIndicatorAdapter extends FragmentPagerAdapter {
public TabPageIndicatorAdapter(FragmentManager fm) {
super(fm);
}
@Override
public Fragment getItem(int position) {
//新建一个Fragment来展示ViewPager item的内容,并传递参数
Fragment fragment = new ItemFragment();
Bundle args = new Bundle();
args.putString("arg", TITLE[position]);
fragment.setArguments(args);
return fragment;
}
@Override
public CharSequence getPageTitle(int position) {
return TITLE[position % TITLE.length];
}
@Override
public int getCount() {
return TITLE.length;
}
}
}
ViewPagerIndicator会与ViewPager进行管理,并且通过ViewPager的Adapter获取到页面数量、每个页面的标题等信息,然后绘制出指示器视图。当ViewPager滚动时,指示器视图也会发生相应的变化,以此达到指示页面切换的效果。
虽然Android已经自带了很多强大的UI控件,但是依旧不能满足所有开发人员的需求。通常开发人员需要实现设计师精心设计的视觉效果,这种情况下可能现有的控件就不能满足需求或者说使用现有的控件实现起来成本很高,此时我们只能寻找是否有类似的开源库,如果没有人实现过类似的效果,我们只能通过自定义View实现。因此,自定义View就成了开发人员必须掌握的最重要技能之一。
自定义View也有几种实现类型,分别为继承自View完全自定义、继承自现有控件(如ImageView)实现特定效果、继承自ViewGroup实现布局类,在这其中比较重要的知识点是View的测量与布局、View的绘制、处理触摸事件、动画等,也就是本章我们要学习的重要知识点。
继承自View完全实现自定义控件是最为自由的一种实现,也是相对来说比较复杂的一种。因为你通常需要正确地测量View的尺寸,并且需要手动绘制各种视觉效果,因此,它的工作量相对来说比较大,但是,能够自由地控制整个View的实现。
下面我们就继承View来实现一个简单的ImageView,它能够根据用户设置的大小将图片缩放,使得图片在任何尺寸下都能够正确显示。
对于继承自View类的自定义控件来说,核心的步骤分别为尺寸测量与绘制,对应的函数是onMeasure、onDraw。因为View类型的子类也是视图树的叶子节点,因此,它只负责绘制好自身内容即可,而这两步就是完成它职责的所有工作。
下面我们来简单实现一个显示图片的ImageView,第一版控件的核心代码如下:
/**
* 简单的ImageView,用于显示图片
*/
public class SimpleImageView extends View {
// 画笔
private Paint mBitmapPaint;
// 图片drawable
private Drawable mDrawable;
// View的宽度
private int mWidth;
// View的高度
private int mHeight;
public SimpleImageView(Context context) {
this(context, null);
}
public SimpleImageView(Context context, AttributeSet attrs) {
super(context, attrs);
// 根据属性初始化
initAttrs(attrs);
// 初始化画笔
mBitmapPaint = new Paint();
mBitmapPaint.setAntiAlias(true);
}
private void initAttrs(AttributeSet attrs) {
if (attrs != null) {
TypedArray array = null;
try {
array =
getContext().obtainStyledAttributes(attrs, R.styleable.SimpleImageView);
// 根据图片id获取到Drawable对象
mDrawable = array.getDrawable(R.styleable.SimpleImageView_src);
// 测量Drawable对象的宽、高
measureDrawable();
} finally {
if (array != null) {
array.recycle();
}
}
}
}
// 代码省略
}
首先我们创建了一个继承自View的SimpleImageView类,在含有构造函数中我们会获取该控件的属性,并且进行初始化要绘制的图片及画笔。在values/attr.xml中定义了这个View的属性,为了便于后续的圆形ImageView使用,我们命名为CircleImageView,attr.xml中的内容如下:
<resources>
<declare-styleable name="SimpleImageView">
<attr name="src" format="integer" />
</declare-styleable>
</resources>
该属性集的名字为SimpleImageView,里面只有一个名为src的整型属性。我们通过这个属性为SimpleImageView设置图片的资源id。代码如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns: img = "http://schemas.android.com/apk/res/com.book.jtm"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<com.book.jtm.chap02.SimpleImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
img:src="@drawable/icon_400" />
</LinearLayout>
注意,在使用自定义的属性时,我们需要将该属性所在的命名空间引入到xml文件中,命名空间实际上就是该工程的应用包名,如上述代码中的加粗部分。因为自定义的属性集最终会编译为R类,R类的完整路径是应用的包名.“R”,我们的示例应用包名为com.book.jtm,因此,我们引入了一个名为img的命名控件,它的格式为 :
xmlns:名字="http://schemas.android.com/apk/res/应用包名"
此时我们在xml文件中定义了一个SimpleImageView,并且指定它的图片资源为drawable目录下的icon_400,这是values/drawable目录下的一张图片。当应用启动时会从这个xml布局中解析SimpleImageView的属性,例如宽度、高度都为wrap_content,src属性为drawable目录下的icon_400。进入SimpleImageView构造函数后会调用initAttrs函数进行初始化。
在initAttrs函数中,我们首先读取CircleImageView的属性集TypedArray;再从该对象中读取SimpleImageView_src属性值,该属性是一个drawable的资源id值;然后我们根据这个id从该TypedArray对象中获取到该id对应的Drawable;最后我们调用measureDrawable函数测量该图片Drawable的大小。代码如下:
private void measureDrawable() {
if (mDrawable == null) {
throw new RuntimeException("drawable不能为空!");
}
mWidth = mDrawable.getIntrinsicWidth();
mHeight = mDrawable.getIntrinsicHeight();
}
我们在SimpleImageView中定义了两个字段mWidth、mHeight,分别表示该视图的宽度、高度。在measureDrawable函数中,我们通过在xml文件中指定。资源id对应的Drawable得到图片的高度和高度,并且把它们当作SimpleImageView的宽和高,也就是说图片多大,SimpleImageView就多大。在SimpleImageView被加载时,首先会调用onMeasure函数测量SimpleImageView的大小,然后再将图片绘制出来。代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 设置View的宽和高为图片的宽和高
setMeasuredDimension(mWidth, mHeight);
}
@Override
protected void onDraw(Canvas canvas) {
if (mDrawable == null) {
return;
}
// 绘制图片
canvas.drawBitmap(ImageUtils.drawableToBitamp (mDrawable),
getLeft(), getTop(), mBitmapPaint);
}
运行示例,结果如图2-9所示。
▲图2-9 SimpleImageView显示图片
我们总结一下这个过程:
(1)继承自View创建自定义控件;
(2)如有需要自定义View属性,也就是在values/attrs.xml中定义属性集;
(3)在xml中引入命名控件,设置属性;
(4)在代码中读取xml中的属性,初始化视图;
(5)测量视图大小;
(6)绘制视图内容。
实现起来并不难,但是,这只是最简单的ImageView而已。SimpleImageView的宽、高设置为match_parent会怎么样,设置为指定大小的值又会正常显示吗?
我们都知道Android的视图数在创建时会调用根视图的measure、layout、draw三个函数,分别对应尺寸测量、视图布局、绘制内容。但是,对于非ViewGroup类型来说,layout这个步骤是不需要的,因为它并不是一个视图容器。它需要做的工作只是测量尺寸与绘制自身内容,上述SimpleImageView就是这样的例子。
但是,SimpleImageView的尺寸测量只能够根据图片的大小进行设置,如果用户想支持match_parent和具体的宽高值则不会生效,SimpleImageView的宽高还是图片的宽高。因此,我们需要根据用户设置的宽高模式来计算SimpleImageView的尺寸,而不是一概地使用图片的宽高值作为视图的宽高。
在视图树渲染时View系统的绘制流程会从ViewRoot的performTraversals()方法中开始,在其内部调用View的measure()方法。measure()方法接收两个参数:widthMeasureSpec和heightMeasureSpec,这两个值分别用于确定视图的宽度、高度的规格和大小。MeasureSpec的值由specSize和specMode共同组成,其中specSize记录的是大小,specMode记录的是规格。在支持match_parent、具体宽高值之前,我们需要了解specMode的3种类型,如表2-1所示。
表2-1 SpecMode类型
模 式 类 型 |
说 明 |
---|---|
EXACTLY |
表示父视图希望子视图的大小应该是由specSize的值来决定的,系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。match_parent、具体的数值(如100dp)对应的都是这个模式 |
AT_MOST |
表示子视图最多只能是specSize中指定的大小,开发人员应该尽可能小地去设置这个视图,并且保证不会超过specSize。系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。一般来说wrap_content对应这种模式 |
UNSPECIFIED |
表示开发人员可以将视图按照自己的意愿设置成任意的大小,没有任何限制。这种情况比较少见,不太会用到 |
那么这两个MeasureSpec又是从哪里来的呢?其实这是从整个视图树的控制类ViewRootImpl创建的,在ViewRootImpl的measureHierarchy函数中会调用如下代码获取MeasureSpec:
if (!goodMeasure) {
// 获取MeasureSpec
childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
// 执行测量过程
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
if (mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight()) {
windowSizeMayChange = true;
}
}
从上述程序中可以看到,这里调用了getRootMeasureSpec()方法来获取widthMeasureSpec和heightMeasureSpec的值。注意,方法中传入的参数,参数1为窗口的宽度或者高度,而lp.width和lp.height在创建ViewGroup实例时就被赋值了,它们都等于MATCH_PARENT。然后看一下getRootMeasureSpec()方法中的代码,如下所示:
private int getRootMeasureSpec(int windowSize, int rootDimension) {
int measureSpec;
switch (rootDimension) {
case ViewGroup.LayoutParams.MATCH_PARENT:
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
break;
case ViewGroup.LayoutParams.WRAP_CONTENT:
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
break;
default:
measureSpec =
MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
break;
}
return measureSpec;
}
从上述程序中可以看到,这里使用了MeasureSpec.makeMeasureSpec()方法来组装一个MeasureSpec,当rootDimension参数等于MATCH_PARENT时,MeasureSpec的specMode就等于EXACTLY,当rootDimension等于WRAP_CONTENT时,MeasureSpec的specMode就等于AT_MOST;并且MATCH_PARENT和WRAP_CONTENT的specSize都是等于windowSize的,也就意味着根视图总是会充满全屏的。
当构建完根视图的MeasureSpec之后就会执行performMeasure函数从根视图开始一层一层测量视图的大小。最终会调用每个View的onMeasure函数,在该函数中用户需要根据MeasureSpec测量View的大小,最终调用setMeasuredDimension函数设置该视图的大小。下面我们看看SimpleImageView根据MeasureSpec设置大小的实现,修改的部分只有测量视图的部分,代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 获取宽度的模式与大小
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
// 高度的模式与大小
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
// 设置View的宽高
setMeasuredDimension(measureWidth(widthMode, width),
measureHeight(heightMode, height));
}
private int measureWidth(int mode, int width) {
switch (mode) {
case MeasureSpec.UNSPECIFIED:
case MeasureSpec.AT_MOST:
break;
case MeasureSpec.EXACTLY:
mWidth = width;
break;
}
return mWidth;
}
private int measureHeight(int mode, int height) {
switch (mode) {
case MeasureSpec.UNSPECIFIED:
case MeasureSpec.AT_MOST:
break;
case MeasureSpec.EXACTLY:
mHeight = height;
break;
}
return mHeight;
}
@Override
protected void onDraw(Canvas canvas) {
if (mBitmap == null) {
mBitmap = Bitmap.createScaledBitmap(
ImageUtils.drawableToBitamp(mDrawable),getMeasuredWidth(),
getMeasuredHeight(), true);
}
// 绘制图片
canvas.drawBitmap(mBitmap,
getLeft(), getTop(), mBitmapPaint);
}
在onMeasure函数中我们获取宽、高的模式与大小,然后分别调用measureWidth、measureHeight函数根据MeasureSpec的mode与大小计算View的具体大小。在MeasureSpec.UNSPECIFIED与MeasureSpec.AT_MOST类型中,我们都将View的宽高设置为图片的宽高,而用户指定了具体的大小或match_parent时,它的模式则为EXACTLY,它的值就是MeasureSpec中的值。最后在绘制图片时,会根据View的大小重新创建一个图片,得到一个与View大小一致的Bitmap,然后绘制到View上。
图2-10、图2-11和图2-12分别为宽高设置为wrap_content、match_parent、具体值的显示效果。
▲图2-10 wrap_content
▲图2-11 match_parent
▲图2-12 具体值120dp*180dp
View的测量是自定义View中最为重要的一步,如果不能正确地测量视图的大小,那么将会导致视图显示不完整等情况,这将严重影响View的显示效果。因此,理解MeasureSpec以及正确的测量方法对于开发人员来说是必不可少的。
在上一节中我们自定义了一个SimpleImageView,该视图的作用就是用于显示一张图片。图片并不是自动显示在SimpleImageView上的,而是我们在onDraw函数中通过Canvas和Paint绘制到视图上的,这就引入了Canvas和Paint这两个概念。
对于Android来说,整个View就是一张画布,也就是Canvas。开发人员可以通过画笔Paint在这张画布上绘制各种各样的图形、元素,例如矩形、圆形、椭圆、文字、圆弧、图片等,通过修改画笔的属性则可以将同一个元素绘制出不同的效果,例如设置画笔的颜色为红色,那么通过该画笔绘制一个矩形时,该矩形的颜色则为红色。
Canvas和Paint的重要函数如表2-2和表2-3所示。
表2-2 Canvas部分函数
函 数 名 |
作 用 |
---|---|
drawRect(RectF rect, Paint paint) |
绘制一个矩形,参数一为RectF一个区域 |
drawBitmap (Bitmap bitmap, float left, float top, Paint paint) |
绘制一张图片,left做左边起点,top为上边起点 |
drawPath(Path path, Paint paint) |
绘制一个路径,参数一为Path路径对象 |
drawLine(float startX, float startY, float stopX, float stopY, Paint paint) |
绘制线段 |
drawText(String text, float x, float y, Paint paint) |
绘制文本 |
drawOval(RectF oval, Paint paint) |
绘制椭圆 |
drawCircle(float cx, float cy, float radius,Paint paint) |
绘制圆形,参数一是中心点的x轴,参数二是中心点的y轴,参数三是半径,参数四是paint对象 |
drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint) |
画扇形或者弧形。圆形进度条就是使用这个函数不断地绘制扇形或者弧形实现 |
clipRect (float left, float top, float right, float bottom) |
裁剪画布上的一个区域,使得后续的操作只在这个区域上有效 |
save () |
存储当前矩阵和裁剪状态到一个私有的栈中。随后调用translate,scale,rotate,skew,concat or clipRect,clipPath等函数还是会正常执行,但是调用了restore()之后,这些调用产生的效果就会失效,在save之前的Canvas状态就会被恢复 |
void restore () |
恢复到save之前的状态 |
表2-3 Paint部分函数
函 数 名 |
作 用 |
---|---|
setARGB(int a,int r,int g,int b); |
设置绘制的颜色,a代表透明度,r、g、b代表颜色值 |
setColor(int color); |
设置绘制的颜色,使用颜色值来表示,该颜色值包括透明度和RGB颜色 |
setAntiAlias(boolean aa); |
设置是否使用抗锯齿功能,会消耗较大资源,绘制图形速度会变慢 |
setShader(Shader shader); |
设置图像效果,使用Shader可以绘制出各种渐变效果 |
setShadowLayer(float radius ,float dx,float dy,int color); |
在图形下面设置阴影层,产生阴影效果,radius为阴影的角度,dx和dy为阴影在x轴和y轴上的距离,color为阴影的颜色 |
setStyle(Paint.Style style); |
设置画笔的样式,为FILL、FILL_OR_STROKE或STROKE Style.FILL: 实心,STROKE:空心 FILL_OR_STROKE:同时实心与空心 |
setStrokeCap(Paint.Cap cap); |
当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式,如圆形样式Cap.ROUND,或方形样式Cap.SQUARE |
setStrokeWidth(float width); |
当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的粗细度 |
setXfermode(Xfermode xfermode); |
设置图形重叠时的处理模式,如合并、取交集或并集,经常用来制作橡皮的擦除效果 |
setTextSize(float textSize); |
设置绘制文字的字号大小 |
Canvas和Paint的函数较多,但理解起来都比较简单,因此我们不过多赘述。在onDraw方法里我们经常会看到调用Canvas的save和restore方法,这两个函数很重要,那么它们的作用是什么呢?
有的时候我们需要使用Canvas来绘制一些特殊的效果,在做一些特殊效果之前,我们希望不保存原来的Canvas状态,此时需要调用Canvas的save函数。执行save之后,可以调用Canvas的平移、放缩、旋转、skew(倾斜)、裁剪等操作,然后再进行其他的绘制操作。当绘制完毕之后,我们需要调用restore函数来恢复Canvas之前保存的状态。save和restore要配对使用,但需要注意的是,restore函数的调用次数可以比save函数少,不能多,否则会引发异常。
例如,需要在SimpleImageView中绘制一个竖向的文本,我们知道 drawText函数默认是横向绘制的,如果直接在onDraw函数中绘制文本,那么得到的效果如图2-13所示。
▲图2-13 默认的文字绘制
实现代码如下:
@Override
protected void onDraw(Canvas canvas) {
if (mBitmap == null) {
mBitmap =
Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
getMeasuredWidth(), getMeasuredHeight(), true);
}
// 绘制图片
canvas.drawBitmap(mBitmap,
getLeft(), getTop(), mBitmapPaint);
// 绘制文字
mBitmapPaint.setColor(Color.YELLOW);
mBitmapPaint.setTextSize(30);
canvas.drawText("AngelaBaby", getLeft() + 50, getTop() - 50, mBitmapPaint);
}
但是我们的需求是将文字竖向显示,那么如何实现呢?
通常的思路是在绘制文本之前将画布旋转一定的角度,使得画布的角度发生变化,此时再在画布上绘制文字,得到的效果就是文字被绘制为竖向的。实现代码如下:
@Override
protected void onDraw(Canvas canvas) {
if (mBitmap == null) {
mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
getMeasuredWidth(), getMeasuredHeight(), true);
}
// 绘制图片
canvas.drawBitmap(mBitmap,
getLeft(), getTop(), mBitmapPaint);
// 保存画布状态
canvas.save();
// 旋转90***°***
canvas.rotate(90);
mBitmapPaint.setColor(Color.YELLOW);
mBitmapPaint.setTextSize(30);
// 绘制文本
canvas.drawText("AngelaBaby", getLeft() + 50, getTop() - 50, mBitmapPaint);
// 恢复原来的状态
canvas.restore();
}
得到的效果如图2-14所示。
实现思路是在绘制文本之前将画布旋转90°,即顺时针方向旋转90°,然后再在画布上绘制文字,最后将画布restore到save之前的状态。整个过程如图2-15所示。
▲图2-14 竖向的文字
▲图2-15 绘制竖向文本的过程
首先将画布选择90°之后画布大致如图2-16所示的第二幅图,此时原点到了左下角,向右的方向x递增,向下则为y轴递增。此时我们在该画布上绘制文本,假设SimpleImageView的left和top都为0,那么绘制文本的起始坐标为(50,−50),x越大越靠右,y值越小越向上偏移。绘制完文本之后将画布再还原,此时得到的效果就是文本被竖向显示了。
▲图2-16 红色区域为屏幕
自定义ViewGroup是另一种重要的自定义View形式,当我们需要自定义子视图的排列方式时,通常需要通过这种形式实现。例如,最常用的下拉刷新组件,实现下拉刷新、上拉加载更多的原理就是自定义一个ViewGroup,将Header View、Content View、Footer View从上到下依次布局,如图2-16所示(红色区域为屏幕的显示区域运行时可看到色彩)。然后在初始时通过Scroller滚动使得该组件在y轴方向上滚动HeaderView的高度,这样当依赖该ViewGroup显示在用户眼前时HeaderView就被隐藏掉了,如图2-17所示。而Content View的宽度和高度都是match_parent的,因此,此时屏幕上只显示Content View,HeaderView和FooterView都被隐藏在屏幕之外。当Content View被滚动到顶部,此时如果用户继续下拉,那么该下拉刷新组件将拦截触摸事件,然后根据用户的触摸事件获取到手指滑动的y轴距离,并通过Scroller将该下拉刷新组件在y轴上滚动手指滑动的距离,实现HeaderView显示与隐藏,从而到达下拉的效果,如图2-18所示。当用户滑动到最底部时会触发加载更多的操作,此时会通过Scroller滚动该下拉刷新组件,将Footer View显示出来,实现加载更多的效果。
▲图2-17 向下滚动Header的高度
▲图2-18 下拉
通过使用Scroller使得整个滚动效果更加平滑,使用Margin来实现则需要自己来计算滚动时间和margin值,滚动效果不是很流畅,且频繁地修改布局参数效率也不高。使用Scroller只是滚动位置,而没有修改布局参数,因此,使用Scroller是最好的选择。
为了更好地理解下拉刷新的实现,我们先要了解Scroller的作用以及如何使用。这里我们将做一个简单的示例来说明。
Scroller是一个帮助View滚动的辅助类,在使用它之前,用户需要通过startScroll来设置滚动的参数,即起始点坐标和(x,y)轴上要滚动的距离。Scroller它封装了滚动时间、要滚动的目标x轴和y轴,以及在每个时间内View应该滚动到的(x,y)轴的坐标点,这样用户就可以在有效的滚动周期内通过Scroller的getCurX()和getCurY()来获取当前时刻View应该滚动的位置,然后通过调用View的scrollTo或者ScrollBy方法进行滚动。那么如何判断滚动是否结束呢? 我们只需要覆写View类的computeScroll方法,该方法会在View绘制时被调用,在里面调用Scroller的computeScrollOffset来判断滚动是否完成,如果返回true表明滚动未完成,否则滚动完成。上述说的scrollTo或者ScrollBy的调用就是在computeScrollOffset为true的情况下调用,并且最后还要调用目标View的postInvalidate()或者invalidate()以实现View的重绘。View的重绘又会导致computeScroll方法被调用,从而继续整个滚动过程,直至computeScrollOffset返回false, 即滚动结束。整个过程有点绕,我们看一个实例:
public class ScrollLayout extends FrameLayout {
private String TAG = ScrollLayout.class.getSimpleName();
Scroller mScroller ;
public ScrollLayout(Context context) {
super(context);
mScroller = new Scroller(context) ;
}
// 该函数会在View重绘之时被调用
@Override
public void computeScroll() {
if ( mScroller.computeScrollOffset() ) {
// 滚动到此,View应该滚动到的x,y坐标上
this.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
// 请求重绘该View,从而又会导致computeScroll被调用,然后继续滚动,
// 直到computeScrollOffset返回false
this.postInvalidate();
}
}
// 调用这个方法进行滚动,这里我们只滚动竖直方向
public void scrollTo(int y) {
// 参数1和参数2分别为滚动的起始点水平、竖直方向的滚动偏移量
// 参数3和参数4为在水平和竖直方向上滚动的距离
mScroller.startScroll(getScrollX(), getScrollY(), 0, y);
this.invalidate();
}
}
滚动该视图的代码:
ScrollLayout scrollView = new ScrollLayout(getContext()) ;
scrollView.scrollTo(100);
通过上面这段代码会让scrollView在y轴上向下滚动100个像素点。我们结合代码来分析一下。首先调用scrollTo(inty)方法,然后在该方法中通过mScroller.startScroll()方法来设置滚动的参数,再调用invalidate()方法使得该View重绘。重绘时会调用computeScroll方法,在该方法中通过mScroller.computeScrollOffset()判断滚动是否完成,如果返回true,代表没有滚动完成,此时把该View滚动到此刻View应该滚动到的x、 y位置,这个位置通过mScroller的getCurrX和 getCurrY获得。然后继续调用重绘方法,继续执行滚动过程,直至滚动完成。
了解了Scroller原理后,我们继续看通用的下拉刷新组件的实现吧。
代码量不算多,但是也挺有用的,我们这里只拿出重要的点来分析,完整的源码请访问github(地址为https://github。Com/bboyfeiyu/android my pull refresh viewltreel masterlsvc/coml uit/pull refresh/ scroller)获取。以下是重要的代码段:
// 下拉刷新组件抽象基类,泛型参数T为中间内容视图的类型
public abstract class RefreshLayoutBase<T extends View>
extends ViewGroup implementsOnScrollListener {
// 滚动控制器
protected Scroller mScroller;
//下拉刷新时显示的header View
protected View mHeaderView;
//上拉加载更多时显示的footer View
protected View mFooterView;
//本次触摸滑动y坐标上的偏移量
protected int mYOffset;
// 内容视图, 即用户触摸导致下拉刷新、上拉加载的主视图,如ListView、 GridView等
protected T mContentView;
//最初的滚动位置,第一次布局时滚动header高度的距离
protected int mInitScrollY = 0;
// 最后一次触摸事件的y轴坐标
protected int mLastY = 0;
// 空闲状态
public static final int STATUS_IDLE = 0;
// 下拉或者上拉状态, 还没有到达可刷新的状态
public static final int STATUS_PULL_TO_REFRESH = 1;
// 下拉或者上拉状态
public static final int STATUS_RELEASE_TO_REFRESH = 2;
// 刷新中
public static final int STATUS_REFRESHING = 3;
// Loading中
public static final int STATUS_LOADING = 4;
//当前状态
protected int mCurrentStatus = STATUS_IDLE;
// header中的箭头图标
private ImageView mArrowImageView;
// 箭头是否向上
private boolean isArrowUp;
// header 中的文本标签
private TextView mTipsTextView;
// header中的时间标签
private TextView mTimeTextView;
// header中的进度条
private ProgressBar mProgressBar;
// 屏幕的高度
private int mScreenHeight;
// header的高度
private int mHeaderHeight;
// 下拉刷新回调
protected OnRefreshListener mOnRefreshListener;
// 加载更多的回调
protected OnLoadListener mLoadListener;
public RefreshLayoutBase(Context context) {
this(context, null);
}
public RefreshLayoutBase(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public RefreshLayoutBase(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs);
// 初始化Scroller对象
mScroller = new Scroller(context);
// 获取屏幕高度
mScreenHeight = context.getResources().
getDisplayMetrics().heightPixels;
// header 的高度为屏幕高度的 1/4
mHeaderHeight = mScreenHeight / 4;
// 初始化整个布局
initLayout(context);
}
// 初始化整个布局,从上到下分别为header、内容视图、footer
private final void initLayout(Context context) {
// 设置header view
setupHeaderView(context);
// 设置内容视图
setupContentView(context);
// 设置布局参数
setDefaultContentLayoutParams();
//添加内容视图,如ListView、GridView等
addView(mContentView);
// footer view
setupFooterView(context);
}
// 代码省略
}
在构造函数中首先调用initLayout函数初始化整个布局,从上到下分别为Header View、内容视图、Footer View,我们先看看这3部分的相关函数:
//初始化 header view
protected void setupHeaderView(Context context) {
mHeaderView = LayoutInflater.from(context).inflate(
R.layout.pull_to_refresh_header, this,false);
mHeaderView.setLayoutParams(newViewGroup.LayoutParams(
LayoutParams.MATCH_PARENT, mHeaderHeight));
mHeaderView.setBackgroundColor(Color.RED);
// header的高度为1/4的屏幕高度,但是,它只有100px是有效的显示区域
//取余为paddingTop,这样是为了达到下拉的效果
mHeaderView.setPadding(0, mHeaderHeight - 100, 0, 0);
addView(mHeaderView);
// 初始化header view中的子视图
mArrowImageView = (ImageView)
mHeaderView.findViewById(R.id.pull_to_arrow_image);
mTipsTextView = (TextView)
mHeaderView.findViewById(R.id.pull_to_refresh_text);
mTimeTextView = (TextView)
mHeaderView.findViewById(R.id.pull_to_refresh_updated_at);
mProgressBar = (ProgressBar)
mHeaderView.findViewById(R.id.pull_to_refresh_progress);
}
//初始化Content View, 子类覆写
protected abstract void setupContentView(Context context);
// 初始化footer view
protected void setupFooterView(Context context) {
mFooterView = LayoutInflater.from(context).inflate(
R.layout.pull_to_refresh_footer,this, false);
addView(mFooterView);
}
其中header view和footer view都是从默认的布局中加载,因此,它们是固定的。但是,最中间的内容视图是可变的,例如,我们显示内容的控件可能是ListView、GridView、TextView等,因此,这部分是未知的,所以setContentView留给子类去具体化。还有另外两个抽象函数,分别为判断是否下拉到顶部以及上拉到底部的函数,因为不同内容视图判断是否滚动到顶部、底部的实现代码也是不一样的,因此,也需要抽象化。函数定义如下:
//是否已经到了最顶部,子类需覆写该方法,使得mContentView滑动到最顶端时返回true
//如果到达最顶端用户继续下拉则拦截事件
protected abstract boolean isTop();
//是否已经到了最底部,子类需覆写该方法,使得mContentView滑动到最底端时返回true
//从而触发自动加载更多的操作
protected abstract boolean isBottom();
初始化这3部分视图之后,接下来的第一个关键步骤就是视图测量与布局,也就是我们自定义ViewGroup中必备的两个步骤。上文我们已经说过,header view、内容视图、footer是纵向布局的,因此,需要将它们从上到下布局。在布局之前还需要测量各个子视图的尺寸以及该下拉刷新组件自身的尺寸。代码如下:
/*
* 丈量视图的宽、高。宽度为用户设置的宽度,高度则为header、 content view、 footer这三个子控件的高度之和
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// MeasureSpec中的宽度值
int width = MeasureSpec.getSize(widthMeasureSpec);
// 子视图的个数
int childCount = getChildCount();
// 最终的高度
int finalHeight = 0;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 测量每个子视图的尺寸
measureChild(child, widthMeasureSpec, heightMeasureSpec);
// 所有子视图的高度和就是该下拉刷新组件的总高度
finalHeight += child.getMeasuredHeight();
}
// 设置该下拉刷新组件的尺寸
setMeasuredDimension(width, finalHeight);
}
/*
* 布局函数,将header、 content view、footer这3个View从上到下布局。
*布局完成后通过Scroller滚动到header的底部,即滚动距离为header的高度 +本视图的paddingTop,从而达到隐藏header的效果
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = getPaddingLeft();
int top = getPaddingTop();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
child.layout(left, top,
child.getMeasuredWidth(), child.getMeasuredHeight() + top);
top += child.getMeasuredHeight();
}
// 计算初始化滑动的y轴距离
mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop();
// 滑动到header view高度的位置, 从而达到隐藏header view的效果
scrollTo(0, mInitScrollY);
}
在onMeasure中我们测量了该组件自身的大小以及所有子视图的大小,并且将该控件的高度设置为所有子视图的高度之和,在这里也就是header、content view、footer的高度之和,这样在布局时我们才有足够的空间竖向放置子视图。
在onLayout时,会将Header View、内容视图、Footer View从上到下布局,即Header View实际上显示在该ViewGroup的最上面,如前文的图2-17所示。而在onLayout的最后,我们通过Scroller将该ViewGroup向上滚动了Header View的高度,使得Header View变得不可见,如上文的图2-18所示。当用户向下拉时,该组件判断内容视图滑到了顶部,此时又通过Scroller将该组件向下滚动,使得Header View慢慢显示出来。实现这些功能就需要我们处理该控件的触摸事件,通过内容视图滚动到了顶部或者底部来判断是否需要拦截触摸事件。相关代码如下:
/*
* 在适当的时候拦截触摸事件,这里指的适当的时候是当mContentView滑动到顶部,并且是下拉时拦截触摸事件,否则不拦截,交给其child、view 来处理
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 获取触摸事件的类型
final int action = MotionEventCompat.getActionMasked(ev);
// 取消事件和抬起事件则直接返回false
if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
return false;
}
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastY = (int) ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
mYOffset = (int) ev.getRawY() - mLastY;
// 如果拉到了顶部, 并且是下拉,则拦截触摸事件
// 从而转到onTouchEvent来处理下拉刷新事件
if (isTop() && mYOffset > 0) {
return true;
}
break;
}
// 默认不拦截触摸事件,使得该控件的子视图能够得到处理机会
return false;
}
onInterceptTouchEvent是ViewGroup中对触摸事件进行拦截的函数,当返回true时后续的触摸事件就会被该ViewGroup拦截,此时子视图将不会再获得触摸事件。相应地,返回false则表示不进行拦截。例如在上述onInterceptTouchEvent函数中,我们在ACTION_DOWN事件(手指第一次按下)时记录了y轴的坐标,当用户的手指在屏幕上滑动时就会产生ACTION_MOVE事件,此时我们获取y轴坐标,并且与最初ACTION_DOWN事件的y轴相减。如果mYOffset大于0,那么表示用户的手指是从上到下滑动,如果此时内容视图已经是到了顶部,例如,ListView的第一个可见元素就是第一项,那么则返回true,也就是将后续的触摸事件拦截。此时,后续的ACTION_MOVE、ACTION_UP等事件就会有该组件进行处理,处理函数为onTouchEvent函数,代码如下:
/*
* 在这里处理触摸事件以达到下拉刷新或者上拉自动加载的问题
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
// 滑动事件
case MotionEvent.ACTION_MOVE:
// 获取手指触摸的当前y坐标
int currentY = (int) event.getRawY();
// 当前坐标减去按下时的y坐标得到y轴上的偏移量
mYOffset = currentY - mLastY;
if (mCurrentStatus != STATUS_LOADING) {
// 在y轴方向上滚动该控件
changeScrollY(mYOffset);
}
// 旋转Header 中的箭头图标
rotateHeaderArrow();
// 修改Header中的文本信息
changeTips();
// mLastY 设置为这次的y轴坐标
mLastY = currentY;
break;
case MotionEvent.ACTION_UP:
// 下拉刷新的具体操作
doRefresh();
break;
default:
break;
}
return true; // 返回true,消费该事件,不再传递
}
在onTouchEvent函数中,我们会判断触摸事件的类型,如果还是ACTION_MOVE事件,那么计算当前触摸事件的y坐标与ACTION_DOWN时的y坐标的差值,然后调用changeScrollY函数在y轴上滚动该控件。如果用户一直向下滑动手指,那么mYOffset值将不断增大,那么此时该控件将不断地往上滚动,Header View的可见高度也就越来越大。我们看看changeScrollY函数的实现
/**
* 修改y轴上的滚动值,从而实现Header被下拉的效果
* @param distance 这次触摸事件的y轴与上一次的y轴的差值
* @return
*/
private void changeScrollY(int distance) {
// 最大值为 scrollY(header 隐藏), 最小值为0 ( Header 完全显示)
int curY = getScrollY();
// 下拉
if (distance > 0 && curY - distance >getPaddingTop()) {
scrollBy(0, -distance);
} else if (distance < 0 && curY - distance <= mInitScrollY) {
// 上拉过程
scrollBy(0, -distance);
}
curY = getScrollY();
int slop = mInitScrollY / 2;
if (curY > 0 && curY < slop) {
mCurrentStatus = STATUS_RELEASE_TO_REFRESH;
} else if (curY > 0 && curY > slop) {
mCurrentStatus = STATUS_PULL_TO_REFRESH;
}
}
从上述程序中可以看到,changeScrollY函数实际上就是根据这一次与上一次y轴的差值来滚动当前控件,由于两次触摸事件的差值较小,因此,滚动起来相对比较流畅。当distance小于0时,则是向上滚动,此时Header View的可见范围越来越小,最后完全隐藏;当distance大于0时则是向下滚动,此时Header View的可见范围越来越大,这样一来也就实现了下拉时显示Header View的效果。当然在下拉过程中,我们也会修改Header View布局中的一些控件状态,例如箭头ImageView、文本信息等。
Header View显示之后,当我们的手指离开屏幕时,如果在y轴上的滚动高度大于Header View有效区域高度的二分之一,那么就会触发刷新操作,否则就会通过Scroller将Header View再次隐藏起来。相关代码为ACTION_UP触摸事件中调用的doRefresh函数:
// 执行下拉刷新
private void doRefresh() {
changeHeaderViewStaus();
// 执行刷新操作
if (mCurrentStatus == STATUS_REFRESHING &&mOnRefreshListener != null) {
mOnRefreshListener.onRefresh();
}
}
/**
* 手指抬起时,根据用户下拉的高度来判断是否是有效的下拉刷新操作
如果下拉的距离超过Header View的1/2
*那么则认为是有效的下拉刷新操作,否则恢复原来的视图状态
*/
private void changeHeaderViewStaus() {
int curScrollY = getScrollY();
// 超过1/2则认为是有效的下拉刷新, 否则还原
if (curScrollY < mInitScrollY / 2) {
// 滚动到能够正常显示Header的位置
mScroller.startScroll(getScrollX(), curScrollY,
0, mHeaderView.getPaddingTop() - curScrollY);
mCurrentStatus = STATUS_REFRESHING;
mTipsTextView.setText(R.string.pull_to_refresh_refreshing_label);
mArrowImageView.clearAnimation();
mArrowImageView.setVisibility(View.GONE);
mProgressBar.setVisibility(View.VISIBLE);
} else {
mScroller.startScroll(getScrollX(), curScrollY,
0, mInitScrollY - curScrollY);
mCurrentStatus = STATUS_IDLE;
}
invalidate();
}
在changeHeaderViewStaus函数中,当判断为满足下拉刷新的条件时,就会设置当前组件的状态为STATUS_REFRESHING状态,并且设置正好显示Header View区域,最后调用OnRefreshListener实现用户设定的下拉刷新操作。刷新操作执行完成之后,用户需要调用refreshComplete函数告知当前控件刷新完毕,此时当前控件会将Header View隐藏。相关代码如下:
/**
* 刷新结束,恢复状态
*/
public void refreshComplete() {
mCurrentStatus = STATUS_IDLE;
// 隐藏Header View
mScroller.startScroll(getScrollX(), getScrollY(),
0, mInitScrollY - getScrollY());
invalidate();
updateHeaderTimeStamp();
// 200毫秒后处理arrow和progressbar,免得太突兀
this.postDelayed(new Runnable() {
@Override
public void run() {
mArrowImageView.setVisibility(View.VISIBLE);
mProgressBar.setVisibility(View.GONE);
}
}, 100);
}
在refreshComplete中将重置控件的状态,并且将Header View滚动到屏幕之外。此时,整个下拉刷新操作就完成了。滚动到底部时加载更多比下拉刷新要简单一些,只需要判断是否滚动到底部,如果已经到底部那么直接触发加载更多,因此,当前控件需要监听内容视图的滚动事件:
/*
* 滚动监听,当滚动到最底部,且用户设置了加载更多的监听器时触发加载更多操作
*/
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
int totalItemCount) {
// 用户设置了加载更多监听器,且到了最底部,并且是上拉操作,那么执行加载更多
if (mLoadListener != null && isBottom()
&& mScroller.getCurrY() <= mInitScrollY
&& mYOffset <= 0
&& mCurrentStatus == STATUS_IDLE) {
// 显示Footer View
showFooterView();
// 调用加载更多
doLoadMore();
}
}
// 显示footer view
private void showFooterView() {
startScroll(mFooterView.getMeasuredHeight());
mCurrentStatus = STATUS_LOADING;
}
// 执行下拉(自动)加载更多的操作
private void doLoadMore() {
if (mLoadListener != null) {
mLoadListener.onLoadMore();
}
}
在onScroll中监听内容视图的滚动事件,当内容视图滚动到底部时显示Footer View,并且调用OnLoadListener回调执行加载更多的操作。当操作执行完毕后用户需要调用loadCompelte函数告知当前控件加载完毕,下拉刷新组件此时隐藏Footer View并且设置为STATUS_IDLE状态。
这就是整个RefreshLayoutBase类的核心逻辑,下面我们看看具体实现类,例如内容视图是ListView的实现:
public class RefreshListView extends RefreshAdaterView<ListView> {
// 构造函数省略
// 设置内容视图为ListView,并且设置mContentView的滚动监听器为当前对象
@Override
protected void setupContentView(Context context) {
mContentView = new ListView(context);
// 设置滚动监听器
mContentView.setOnScrollListener(this);
}
@Override
protected boolean isTop() {
// 当第一个可见项是第一项时表示到了顶部
return mContentView.getFirstVisiblePosition() == 0
&&getScrollY() <= mHeaderView.getMeasuredHeight();
}
@Override
protected boolean isBottom() {
// 最后一个可见项是最后一项时表示滚动到了底部
return mContentView != null && mContentView.getAdapter() != null
&& mContentView.getLastVisiblePosition() ==
mContentView.getAdapter().getCount() - 1;
}
}
RefreshListView覆写了RefreshLayoutBase的3个函数,分别为设置内容视图、判断是否是滚动到顶部、判断是否是滚动到底部。需要注意的是,在setContentView函数中,我们将mContentView(在这里也就是ListView)的onScrollListener设置为this,这是因为需要监听ListView的滚动状态,当滚动到最后一项时触发加载更多操作。因为RefreshLayoutBase实现了onScrollListener接口,而判断是否调用加载更多的代码被封装在了RefreshLayoutBase类中,因此,在这里直接调用mContentView对象的setOnScrollListener(this)即可。使用示例代码如下:
final RefreshListView refreshLayout = new RefreshListView(this);
String[] dataStrings = new String[20];
for (int i = 0; i < dataStrings.length; i++) {
dataStrings[i] = "item - " + i;
}
// 获取ListView, 这里的listview就是Content view
refreshLayout.setAdapter(new ArrayAdapter<String>(this,
android.R.layout.simple_list_item_1, dataStrings));
// 设置下拉刷新监听器
refreshLayout.setOnRefreshListener(new OnRefreshListener() {
@Override
public void onRefresh() {
Toast.makeText(getApplicationContext(), "refreshing",
Toast.LENGTH_SHORT).show();
refreshLayout.postDelayed(new Runnable() {
@Override
public void run() {
refreshLayout.refreshComplete();
}
}, 1500);
}
});
// 不设置的话到底部不会自动加载
refreshLayout.setOnLoadListener(new OnLoadListener() {
@Override
public void onLoadMore() {
Toast.makeText(getApplicationContext(), "loading",
Toast.LENGTH_SHORT).show();
refreshLayout.postDelayed(new Runnable() {
@Override
public void run() {
refreshLayout.loadCompelte();
}
}, 1500);
}
});
效果如图2-19、图2-20和图2-21所示。
▲图2-19 下拉刷新ListView
▲图2-20 下拉刷新GridView
▲图2-21 下拉刷新TextView
效果图中含有下拉刷新的ListView、GridView、TextView,而扩展一个支持下拉刷新的控件也非常简单,只需要继承自RefreshLayoutBase类并且覆写setContentView、isTop、isBottom函数即可。通过这种形式,使得下拉刷新组件具有良好的可扩展性。完整代码参见:https://github.com/ bboyfeiyu/android_my_pull_refresh_view/tree/master/src/com/uit/pullrefresh/scroller 。
为了使用户的交互更为流畅、自然,动画已经成为一款应用中不可缺少的部分。在Android中,动画的分类较多,有最早的帧动画、补间动画,从Android 3.0之后添加了属性动画,而在Android 5.0中又增加了VectorDrawable,使得Android的动画多种多样,能够满足用户的各种需求。
动画实际上就是在指定的时间段内持续地修改某个属性的值,使得该值在指定取值范围之内平滑的过渡。如图2-22所示是一个执行时长为40毫秒、将x从0平滑过渡为40的动画。
▲图2-22 执行时间为40毫秒的动画
从图2-22可以看出,动画就是在某个时间点根据一定的计算方式计算出属性的取值,并且设置给目标对象。在动画的执行周期内持续执行这个过程,形成动画的效果。
帧动画也就是我们说的Frame动画。Frame动画是一系列图片按照一定的顺序展示的过程,和放电影的机制很相似,它的原理是在一定的时间段内切换多张有细微差异的图片从而达到动画的效果。
Frame动画可以被定义在xml文件中,也可以完全编码实现。如果被定义在xml文件中,可以放置在/res下的anim或drawable目录中,文件名可以作为资源id在代码中引用;如果完全由编码实现,需要使用到AnimationDrawable对象。需要注意的是,当我们在xml文件中定义帧动画时,<animation-list>元素必须要作为根元素,它可以包含一或多个<item>元素。android:onshot如果定义为true的话,此动画只会执行一次,如果为false则一直循环。<item>元素代表一帧动画,android:drawable指定此帧动画所对应的图片资源,android:druation代表此帧持续的时间,单位为毫秒。
下面用一个简单的示例演示一下帧动画的使用。
下面为res/drawable中的5张类似的图片,图片的名字从ic_heart_0~ic_heart_4,每张图片都有些差异,将这几张图片作为帧动画时就能够看到类似gif图片的进度条效果,如图2-23所示。
▲图2-23 gif图片的效果
我们定义一个名为heart_anim.xml的帧动画存放在res/drawable目录下,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="true">
<item
android:duration="500"
android:drawable="@drawable/ic_heart_0"/>
<item
android:duration="500"
android:drawable="@drawable/ic_heart_1"/>
<item
android:duration="500"
android:drawable="@drawable/ic_heart_2"/>
<item
android:duration="500"
android:drawable="@drawable/ic_heart_3"/>
<item
android:duration="500"
android:drawable="@drawable/ic_heart_4"/>
</animation-list>
定义好之后,我们还需要将动画设置给某个View,例如,将该动画设置为某个ImageView的背景,代码如下:
<ImageView
android:id="@+id/imageview_anim "
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/heart_anim "
/>
但是,此时动画并不会在ImageView显示时启动,我们还需要通过Java代码启动该动画。代码如下:
ImageView mImageView = (ImageView) findViewById(R.id.imageview_anima);
((AnimationDrawable) mImageView.getBackground()).start();
这样,帧动画就会启动了,ImageView的背景就会在指定的时间间隔之内切换。
当然,也可以通过Java代码来构建帧动画,示例如下:
AnimationDrawable anim = new AnimationDrawable();
for (int i = 0; i <= 4; i++) {
// 获取图片的资源id
int id = getResources().getIdentifier("ic_heart_" + i, "drawable",
getPackageName());
Drawable drawable = getResources().getDrawable(id);
// 将Drawable添加到帧动画中
anim.addFrame(drawable, 300);
}
anim.setOneShot(false);
// 将动画设置为ImageView的背景
mImageView.setBackgroundDrawable(anim);
anim.start();
通过xml还是Java代码来设置帧动画完全取决于个人意愿,当然,推荐的方式自然是xml。因为它将动画的代码从复杂的Java代码逻辑中隔离,使得动画的定义更易于维护。
tween动画是操作某个控件让其展现出旋转、渐变、移动、缩放的一种转换过程,这称成为补间动画。同样的,我们可以以xml形式定义动画,也可以编码实现。
如果以xml形式定义一个动画,我们按照动画的定义语法完成xml,并放置于/res/anim目录下,文件名可以作为资源id被引用;如果由编码实现,需要使用到Animation对象。
下面是一个补间动画集合与补间动画的格式,也就是说该集合里面包含了多个自动化,在执行该动画集合时,它们将一起执行:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@[package:]anim/interpolator_resource"
android:shareInterpolator=["true" | "false"] >
<alpha
android:fromAlpha="float"
android:toAlpha="float" />
<scale
android:fromXScale="float"
android:toXScale="float"
android:fromYScale="float"
android:toYScale="float"
android:pivotX="float"
android:pivotY="float" />
<translate
android:fromX="float"
android:toX="float"
android:fromY="float"
android:toY="float" />
<rotate
android:fromDegrees="float"
android:toDegrees="float"
android:pivotX="float"
android:pivotY="float" />
</set>
xml文件中必须有一个根元素,可以是<alpha>、<scale>、<translate>、<rotate>中的任意一个,也可以是<set>来管理一个由前面几个元素组成的动画集合。
<set>是一个动画容器,管理多个动画的群组,与之相对应的Java对象是AnimationSet。它有两个属性,android:interpolator代表一个插值器资源,可以引用系统自带插值器资源,如表2-4所示。当然你也可以用自定义插值器资源,默认值是匀速插值器。android:shareInterpolator代表<set>里面的多个动画是否要共享插值器,默认值为true,即共享插值器,如果设置为false,那么<set>的插值器就不再起作用,我们要在每个动画中加入插值器。
表2-4 插值器
Interpolator对象 | 资源id | 功 能 作 用 |
---|---|---|
AccelerateDecelerateInterpolator | @android:anim/accelerate_decelerate_interpolator | 先加速再减速 |
AccelerateInterpolator | @android:anim/accelerate_interpolator | 加速 |
AnticipateInterpolator | @android:anim/anticipate_interpolator | 先回退一小步然后加速前进 |
AnticipateOvershootInterpolator | @android:anim/anticipate_overshoot_interpolator | 在上一个基础上超出终点一小步再回到终点 |
BounceInterpolator | @android:anim/bounce_interpolator | 最后阶段弹球效果 |
CycleInterpolator | @android:anim/cycle_interpolator | 周期运动 |
DecelerateInterpolator | @android:anim/decelerate_interpolator | 减速 |
LinearInterpolator | @android:anim/linear_interpolator | 匀速 |
OvershootInterpolator | @android:anim/overshoot_interpolator | 快速到达终点并超出一小步,最后回到终点 |
<alpha>是透明度的渐变动画,可以实现淡入、淡出的效果,与之对应的Java对象是AlphaAnimation。android:fromAlpha属性代表起始alpha值、浮点值,范围在0.0和1.0之间,分别代表透明和完全不透明,android:toAlpha属性代表结尾alpha值、浮点值,范围也在0.0和1.0之间。
<scale>是缩放动画,可以实现动态调整控件尺寸的效果,与之对应的Java对象是ScaleAnimation。android:fromXScale属性代表起始的X方向上相对自身的缩放比例,浮点值,比如1.0代表自身无变化,0.5代表起始时缩小一倍,2.0代表放大一倍;android:toXScale属性代表结尾的X方向上相对自身的缩放比例,浮点值;android:fromYScale属性代表起始的Y方向上相对自身的缩放比例,浮点值;android:toYScale属性代表结尾的Y方向上相对自身的缩放比例,浮点值;android:pivotX属性代表缩放的中轴点X坐标,浮点值;android:pivotY属性代表缩放的中轴点Y坐标,浮点值,对于这两个属性,如果我们想表示中轴点为图像的中心,可以把两个属性值定义成0.5或者50%。
<translate>是位移动画,代表一个水平、垂直的位移。与之对应的Java对象是TranslateAnimation。android:fromXDelta属性代表起始X方向的位置,android:toXDelta代表结尾X方向上的位置,android:fromYScale属性代表起始Y方向上的位置,android:toYDelta属性代表结尾Y方向上的位置,以上4个属性都支持3种表示方式:浮点数、num%、num%p。如果以浮点数字表示,代表相对自身原始位置的像素值;如果以num%表示,代表相对于自己的百分比,比如toXDelta定义为100%就表示在X方向上移动自己的1倍距离;如果以num%p表示,代表相对于父类组件的百分比。
<rotate>是旋转动画,与之对应的Java对象是RotateAnimation。android:fromDegrees属性代表起始角度,浮点值,单位:度;android:toDegrees属性代表结尾角度,浮点值,单位:度;android:pivotX属性代表旋转中心的X坐标值,android:pivotY属性代表旋转中心的Y坐标值,这两个属性也有3种表示方式,数字方式代表相对于自身左边缘的像素值,num%方式代表相对于自身左边缘或顶边缘的百分比,num%p方式代表相对于父容器的左边缘或顶边缘的百分比。
补间动画只能运用在View对象之上,并且功能相对来说较为局限。例如旋转动画只能够在x、y轴进行,而不能在z轴方向进行旋转。因此,补间动画通常用于执行一些比较简单的动画。由于比较简单,我们在此不过多赘述。
在Android 3.0之后,Android推出了新的动画包,也就是属性动画。属性动画机制不再是针对View来设计的,也不限定于只能实现移动、缩放、旋转和淡入、淡出这几种简单的动画操作,同时也不再只是一种视觉上的动画效果。它实际上是一种在一定时间段内不断修改某个对象的某个属性值的机制。所以我们仍然可以通过属性动画将一个View进行移动或者缩放,但同时也可以对View的其他属性进行动画操作。我们只需要告诉系统动画要操作的属性、动画时长、需要执行哪种类型的动画,以及动画的初始值和结束值,剩下的工作就可以全部交给系统去完成了。
ValueAnimator是整个属性动画机制当中最核心的一个类,它的作用就是在一定的时间段内不断地修改对象的某个属性值。前文我们已经说过,属性动画的基本原理就是通过不断地修改对象的属性值来实现。ValueAnimator的内部使用一种时间循环的机制来计算值与值之间的动画过渡,我们只需要将属性的取值范围、运行时长提供给ValueAnimator,那么它就会自动帮我们计算属性值在各个动画运行时段的取值,这些值会按照一定的计算方式来实现平滑过渡。除此之外,ValueAnimator还负责管理动画的播放次数、播放模式,以及对动画设置监听器等,这使得它成为属性动画中最核心的类型。
ValueAnimator不仅功能强大,它的API也设计得非常简单。通常我们都是通过ofFloat、ofInt等静态工厂函数构建ValueAnimator。例如下面是我们将数值从0.0过渡到1.0的动画:
private void startValueAnimation() {
ValueAnimator animator = ValueAnimator.ofFloat(0.0f, 1.0f);
animator.setDuration(1000);
animator.addUpdateListener(mAnimationListener);
animator.start();
}
ValueAnimator.AnimatorUpdateListener mAnimationListener = new
ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float newValue = (Float) animation.getAnimatedValue();
Log.e("", "### 新的属性值 : " + newValue);
}
};
当然,我们也可以在res/anim目录下的xml文件中定义该动画,实现如下:
<?xml version="1.0" encoding="utf-8"?>
<animator xmlns:android="http://schemas.android.com/apk/res/android"
android:valueFrom="0.0"
android:valueTo="1.0"
android:valueType="floatType" />
然后在Java代码中加载该动画:
ValueAnimator animator = (ValueAnimator) AnimatorInflater.loadAnimator(
getApplicationContext(),R.anim.value_animator);
启动动画之后,每次更新属性值时就会调用onAnimationUpdate函数,在这里可以获取新的属性值。当然,在这里我们并没有将这个值运用到具体的对象上。但它是非常灵活的实现,它只操作属性值本身,这个值不属于某个具体的对象,但它却能运用于任意对象之上。例如,通过ValueAnimator动画将某个View的透明度从0.0过渡到1.0,那么可以对onAnimationUpdate做如下修改:
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float newValue = (Float) animation.getAnimatedValue();
Log.e("", "### 新的属性值 : " + newValue);
// 将数值设置给具体的对象
myView.setAlpha(newValue);
}
这样一来,我们就将ValueAnimator与具体的对象结合在一起,通过这种形式就能更自由地控制动画,完成各种各样的动画效果。
ValueAnimator功能强大、自由度高,但是,这也意味着开发人员需要做更多的工作来实现动画需求,这在效率致上的软件开发领域来说并不是一个很好的选择。我们开发中运用更多的应该是ObjectAnimator,因为ValueAnimator只是对值进行了一个平滑的动画过渡,但实际开发中需要做的通常是对某个对象的某个属性值进行修改,也就是对某个对象执行动画,当然用得最多的就是View的动画,而ObjectAnimator就是可以直接对任意对象的任意属性进行动画操作的类。
ObjectAnimator继承自ValueAnimator,因此,的动画实现机制也与ValueAnimator一致,所以,前文才说ValueAnimator是属性动画中最核心的类。ObjectAnimator最常用的形式也是通过ofFloat、ofInt等静态工厂形式构建Animator对象,例如下述代码就是在2秒之内将myView的alpha属性从1.0过渡到0.3,再从0.3过渡到0.7:
ObjectAnimator animator = ObjectAnimator.ofFloat(myView, "alpha", 1.0f, 0.3f, 0.7f);
animator.setDuration(2000);
animator.start();
ofXxx这样的静态工厂函数通常至少含有4参数,例如,这里的ofFloat函数,参数1就是要操作的对象,参数2是要操作该对象的哪个属性,我们这里要操作的是alpha属性。剩下的参数就是可变参数,也就是说它可以是0到多个。很多情况我们都是传递2个值,即起始值和目标值。如果是多个数值,那么在动画过程中将会逐个过渡到各个值。
ObjectAnimator极为强大,它能够操作任意对象中的任意属性,因此,它突破了补间动画只运用于View的限制,使得任意对象类型都可以使用属性动画。它的原理是在初始时设置目标对象、目标属性以及要经历的属性值,然后通过内部的计算方式计算出在各个时间段该属性的取值,在动画运行期间通过目标对象属性的setter函数更新该属性值,如果该属性没有setter函数,那么将会通过反射的形式更新目标属性值。在运行周期内不断地计算、更新新的属性值,从而达到对象的属性动画效果。
独立的动画能够实现的视觉效果毕竟是相当有限的,例如,要实现一个View在平移过程中同时在y轴方向进行旋转,这种情况就需要使用AnimatorSet将多个动画组合在一起执行。AnimatorSet类提供了一个play()方法,如果我们向这个方法中传入一个Animator对象将会返回一个AnimatorSet.Builder的实例,AnimatorSet.Builder中包括以下5个核心方法,如表2-5所示。
表2-5 5个核心方法
函 数 |
作 用 |
---|---|
after(Animator anim) |
在anim动画执行完之后再执行调用after函数的动画 |
after(long delay) |
将调用after的动画延迟指定毫秒后执行 |
before(Animator anim) |
在anim动画执行完之前再执行调用after函数的动画 |
with(Animator anim) |
将现有动画和传入的动画同时执行 |
playTogether(Animator… anims) |
将多个动画一起执行 |
有了这5个方法,我们就可以将各种各样的动画组合在一起执行,使得动画效果更加丰富多彩。示例如下:
// 动画集,假设anim1~anim3已经初始化
AnimatorSet animSet = new AnimatorSet();
// 在anim3执行之后同时执行anim1和anim2
animSet.play(anim1).with(anim2).after(anim3);
animSet.setDuration(2000);
animSet.start();
假设anim1~anim3是我们已经初始化好的动画,然后我们创建一个AnimatorSet对象,并将这3个动画通过play、with、after进行组合,最终使得anim3首先执行,在anim3执行完成之后同时执行anim1和anim2。当然,我们也可以通过playTogether函数将3个动画一起执行,代码如下:
animatorSet.playTogether(anim1,anim2, anim3); // 将3个动画一起执行
通过AnimatorSet,我们可以将多个动画进行自由组合、排序,使得不同类型的动画最终可以一起实现复杂的效果,满足各种各样的交互应用。
前文多次说到,动画的原理就是在一定时间内不断地修改某个值。那么在某个时间点这个属性的值如何确定呢?
答案就是通过TypeEvaluator计算得到。TypeEvaluator的中文翻译为类型估值器,它的作用是根据当前动画已执行时间占总时间的百分比来计算新的属性值。TypeEvaluator只有一个evaluate函数,该函数的职责就是计算出新的属性值。函数声明如下:
public abstract T evaluate (float fraction, T startValue, T endValue)
该函数的参数1为已执行时间占总时间的百分比,取值为0.0到1.0。参数2为属性的起始值,参数3为属性的最终值。通常,属性的计算公式为:
T newValue = startValue + (T)( fraction * ( endValue – startValue ) ) ;
也就是已执行时间的百分比乘以两个取值范围的差值再加上起始值。例如某个动画的总时间为1秒,动画的功能是将View的x坐标从0移到100的位置,当已执行时间为300毫秒时,已执行时间的百分比则为30%,对应时float值为0.3,那么此时计算得到的属性值x则为30。它的计算公式为:
int newValue = 0 + (int) ( 0.3 * ( 100 – 0 ) ) ;
因此一个完整的Type代码如下所示:
public class TranslateXEvaluator implements TypeEvaluator<Integer> {
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
// 计算新的属性值
int newValue = startValue + (int) (fraction * (endValue - startValue));
Log.d("", "### fraction = " + fraction + ", start = " + startValue + ", end = "
+ endValue + ", new Value = " + newValue);
return newValue;
}
}
使用代码如下:
private void useCustomEvaluator() {
ObjectAnimator animator = ObjectAnimator.ofObject(mView, "x",
new TranslateXEvaluator(), 0, 200);
animator.setDuration(500);
animator.start();
}
动画运行之后可以看到输出了如下所示的Log:
fraction = 0.0, start = 0, end = 200, new Value = 0
fraction = 0.50942427, start = 0, end = 200, new Value = 101
fraction = 0.8039651, start = 0, end = 200, new Value = 160
fraction = 0.8791809, start = 0, end = 200, new Value = 175
fraction = 0.9117664, start = 0, end = 200, new Value = 182
fraction = 0.9625386, start = 0, end = 200, new Value = 192
fraction = 0.9801469, start = 0, end = 200, new Value = 196
fraction = 0.99228215, start = 0, end = 200, new Value = 198
fraction = 0.9985795, start = 0, end = 200, new Value = 199
fraction = 1.0, start = 0, end = 200, new Value = 200
fraction从0逐渐增加到1.0,在这个过程中属性值也从0慢慢线性增加到200。线性变化也就是说属性的变化范围基本上比较平均,在同一个时间间隔之内属性的变化范围基本没有大的变化。但是,问题是有的时候为了使动画更动感,我们需要动画产生一些非线性的效果,例如动画开始前比较慢,随着时间的推移动画越来越快,直到结束。要实现这种功能就需要TimeInterpolator。
TimeInterpolator中文译为时间插值器,它的作用是修改动画已执行时间与总时间的百分比,也就是修改fraction参数值。系统预置的有匀速的线性插值LinearInterpolator、加速插值器AccelerateInterpolator、减速插值器DecelerateInterpolator和加速减速插值器AccelerateDecelerate Interpolator等。它的作用是在获得已执行时间百分比之后,通过调用TimeInterpolator的getInterpolation函数来对该百分比做出修改,并且返回。
例如上述的加速插值器,它的实现原理是使fraction参数在动画前面部分变化范围小,越往后变化范围越大。还是以1秒内从x轴的坐标0变化到200,如果使用加速动画,在同一个时间段内得到的效果大致如图2-24所示。
▲图2-24 加速插值器
如图2-27所示,在300秒两个关键节点的x、fraction之间的差值不断递增,0到300毫秒的x差值为40、fraction为0.2,300毫秒到600毫秒的x差值为60、fraction为0.3,依次类推,fraction变化频率不断增大,使得x的变化也逐渐增大,也就造成了动画加速的效果。
那么如何控制fraction来实现这种效果呢?
那就是TimeInterpolator的任务,在动画执行时,会调用TimeInterpolator的getInterpolation函数使得开发人员有机会参与到fraction的设定,这样开发人员就可以通过不同的TimeInterpolator实现各种各样与动画频率相关的效果。所谓插值器,也就是在动画执行中“插入一脚”,影响动画的执行。getInterpolation函数的声明如下:
public float getInterpolation (float input)
参数就是fraction本身,返回值则是修改后的fraction值。例如线性插值器是匀速执行的,因此,它没有修改fraction值,LinearInterpolator代码如下:
public class LinearInterpolator implements Interpolator, NativeInterpolatorFactory {
// 代码省略
public float getInterpolation(float input) {
return input;
}
}
下面就来实现一个加速插值器。它的原理就是对getInterpolation的fraction参数进行乘方,因为fraction是float型,且取值在0.0~1.0,对于小数而言,因此值越小乘方之后的值就更小,当fraction慢慢变大时,乘方后的fraction值变化范围就越来越大,也就是说通过逐渐增大统一时段内的fraction值变化范围,即可改变动画的执行效果。实现代码如下:
public class CustomInterpolator implements TimeInterpolator {
@Override
public float getInterpolation(float input) {
return input * input;
}
}
代码很简单,在getInterpolation中将input参数相乘之后返回即可。对于上述的x变化动画,当执行时间t为100毫秒时,此时fraction为0.1,经过乘方之后得到的值为0.01,那么在x轴坐标上移动的距离为1,此时在100毫秒内x的变化范围是1;当t为200、fraction为0.2时,它与t为100毫秒的x差值为3;当t为300、fraction为0.3时,它与t为300毫秒的x差值为5。可见x的变化范围按照1、3、5的规律在慢慢变大。我们看看具体的执行效果,代码如下:
private void useCustomEvaluator() {
ObjectAnimator animator = ObjectAnimator.ofObject(mColorImageView, "x",
new TranslateXEvaluator(), 0, 200);
// 使用自定义的插值器
animator.setInterpolator(new CustomInterpolator());
animator.setDuration(500);
animator.start();
}
执行结果如下:
fraction = 0.0, start = 0, end = 200, new Value = 0
fraction = 0.010404, start = 0, end = 200, new Value = 2
// 省略
fraction = 0.25603598, start = 0, end = 200, new Value = 51
fraction = 0.29160002, start = 0, end = 200, new Value = 58
// 省略
fraction = 0.891136, start = 0, end = 200, new Value = 178
fraction = 0.95648396, start = 0, end = 200, new Value = 191
fraction = 1.0, start = 0, end = 200, new Value = 200
从上述程序中可以看到,正如我们分析的,fraction的变化范围逐渐增大,x轴的变化范围也随之增大。在同一时间段内,动画前期x的变化较小,使得动画看起来较慢,越往后x的差值越大,动画看起来就像是加速效果。通过插值器,我们就可以很方便地在执行速率上控制动画的执行了。
本章学习了Android开发中最为重要的两个知识点,即自定义View与动画。通过自定义View,可以创造出丰富多彩的UI元素,但是由于篇幅有限,一些很重要的知识点并没有覆盖,例如Xfermode,在做一些特殊效果时灵活运用Xfermode将获得意想不到的效果。而对于Canvas以及Paint的细节也没有过多介绍。对于动画而言,我们了解了帧动画、补间动画、属性动画,而较新的VectorDrawable以及SVG等内容并没有覆盖,这些内容大家可以在学习完本章之后自行扩展。