Android5-高级教程-全-

Android5 高级教程(全)

原文:Pro Android 5

协议:CC BY-NC-SA 4.0

零、前言

早在 2008 年,我得到了我的第一台 Android 设备。这就是梦,也被称为 G1,我立即开始修补它。毕竟,这是一部有数千种应用前景的智能手机,谁知道会有多少种可能的手机。那说明当时我懂的有多深!我真的应该考虑数百万个应用和数万个设备,因为这是 Android 今天的发展方向。

亲爱的读者,无论是传统的手机、平板电脑、汽车、飞机上的娱乐系统、机器人,还是其他各种各样的 Android 设备,让它们变得伟大的是像你这样的人编写的应用!每天,Android 开发人员都在推动应用和 Android 能够做什么的可能性,正是这种能量将我吸引到社区,并以我自己的方式帮助像 Pro Android 这样的书籍。

我所听到的关于技术和创新的最好的观察之一是,当你创造一些东西并与另一个人分享时,创新就发生了,然后他们以完全意想不到的方式适应和使用这些东西。所以让我本着这种精神向你推荐这本书。享受 Pro Android 为您提供的一切,并利用它来创造完全意想不到的东西!不管是什么,我们都会第一个去尝试。

—格兰特·艾伦
纽约【2015 年 5 月

介绍

欢迎来到安卓的奇妙世界。在这个世界里,只需一点知识和努力,你也可以编写 Android 应用。然而,要编写好的应用,你需要更深入地挖掘,了解 Android 架构的基础,了解应用如何协同工作,了解移动应用与所有以前的编程形式有何不同。Android 上的在线文档还不错,但还不够。您可以阅读源代码,但这一点也不容易。

这本书是关于 Android 的七年研究、开发、测试、提炼和写作的高潮。我们已经阅读了所有的在线文档,搜索了源代码,探索了互联网的广阔领域,并编写了这本书。我们已经填补了空白,预计到了您的问题,并提供了答案。一路走来,我们看到 API 来了又去,被修订。我们已经看到了应用构造方式的重大变化。起初我们都使用活动,但是当平板电脑出现时,我们开始使用片段。我们把我们所学的一切都用在了这本书上,用实用的指导来使用最新的 Android APIs 来编写有趣的应用。

你仍然可以找到开始主题的覆盖范围,以帮助新的学习者开始开发 Android。您还会发现对更高级主题的覆盖,例如 Google Maps Android API v2,它与 v1 有很大不同。我们已经用可用 API 的最新信息更新了这个版本。您将发现对意图、服务、广播接收器、通信、片段、小部件、传感器、动画、安全、Google Cloud 消息、音频和视频等的深入报道。对于每个主题,都有示例程序以有意义的方式说明每个 API。所有源代码都是可下载的,所以您可以将其复制并粘贴到您的应用中,以获得良好的开端。

一、你好安卓

欢迎来到这本书,欢迎来到 Android 开发的世界。在不到十年的时间里,Android 帮助改变了现代移动计算和电话的面貌,并在应用的开发方式和开发人员方面发起了一场革命。有了这本书在你的手中,你现在是伟大的 Android 爆炸的一部分!我们假设你想直接使用 Android,所以我们不会用关于 Android 的历史、主要人物、赞誉或任何其他散文的炉边聊天来烦你。我们要直奔主题了!

在本章中,您将首先了解使用 Android 软件开发工具包(SDK) 构建应用所需的东西,并设置您选择的开发环境。接下来,你走过一个“你好,世界!”应用。然后本章解释了 Android 应用的生命周期,最后讨论了如何在 Android 虚拟设备(avd)和真实设备上运行应用。所以让我们开始吧。

Android 开发的先决条件

要为 Android 构建应用,您需要 Java SE 开发工具包(JDK) 、Android SDK 和一个开发环境。严格地说,您可以使用简单的文本编辑器和一些像 Ant 这样的命令行工具来开发您的应用。出于本书的目的,我们将使用普遍可用的 Eclipse IDE,尽管您可以自由采用 Android Studio 及其 IntelliJ 基础——我们甚至将为那些没有见过它的人介绍 Android Studio。除了几个附加工具之外,我们在书中分享的例子在这两种 ide 之间同样适用。

Android SDK 需要 JDK 6 或 7(完整的 JDK,而不仅仅是 Java 运行时环境[JRE])和可选的支持 IDE。目前,Google 直接支持两种可选的 ide,提供了一些选择。历史上,Eclipse 是 Google 支持的第一个用于 Android 开发的 IDE,为 Android 4.4 KitKat 或 5.0 Lollipop 开发需要 Eclipse 3.6.2 或更高版本(本书使用 Eclipse 4.2 或 4.4,也分别称为 Juno 和 Luna,以及其他版本)。Google 为 Android 发布并支持的替代环境现在被称为 Android Studio。这是 IDEA IntelliJ 的打包版本,内置 Android SDK 和开发者工具。

注意在撰写本文时,Java 8 已经推出,但 Android SDK 尚不支持。在以前版本的 Android SDK 中,也支持 Java 5,但现在情况不同了。最新版本的 Eclipse (4.4,也就是 Juno)也可以使用,但 Android 在最新版本的 Eclipse 上一直不太可靠。在这里查看系统需求找到最新:【http://developer.android.com/sdk/index.html】T3T5。

Android SDK 兼容 Windows (Windows XP、Windows Vista 和 Windows 7)、Mac OS X(仅限英特尔)和 Linux(仅限英特尔)。硬件方面,你需要一台 Intel 的机器,越强大越好。

为了让您的生活更轻松,如果您选择 Eclipse 作为您的 IDE,您将希望使用 Android 开发工具(ADT) 。ADT 是一个 Eclipse 插件,支持使用 Eclipse IDE 构建 Android 应用。

Android SDK 由两个主要部分组成:工具和包。当你第一次安装 SDK 时,你得到的只是基础工具。这些是帮助您开发应用的可执行文件和支持文件。这些包是特定于特定版本的 Android(称为平台)或特定平台插件的文件。平台包括 Android 1.5 到 4.4.2。这些附加软件包括谷歌地图应用编程接口、市场许可证验证器,甚至还有供应商提供的软件,如三星的 Galaxy Tab 附加软件。安装 SDK 后,您可以使用其中一个工具来下载并设置平台和附加组件。

请记住,您只需要设置和配置 Eclipse 或 Android Studio 中的一个。如果你愿意,你可以两者都用,但这肯定不是必须的。我们开始吧!

设置您的 Eclipse 环境

在本节中,您将逐步下载 JDK 6 、Eclipse IDE、Android SDK(工具和软件包)以及 ADT。您还可以配置 Eclipse 来构建 Android 应用。谷歌提供了一个页面来描述安装过程(【http://developer.android.com/sdk/installing.html】)但是省略了一些关键步骤,你将会看到。

下载 JDK

你首先需要的是 JDK。Android SDK 要求 JDK 6 或更高版本;我们使用 JDK 6 和 7 开发了我们的示例,这取决于所使用的 Eclipse 或 Android Studio 的版本。对于 Windows 和 Mac OS X,从甲骨文网站(downloads/index . html)下载 JDK 7 并安装。你只需要 JDK,不需要捆绑。要安装 Linux 版 JDK,请打开一个终端窗口,并指示您的软件包管理员安装它。例如,在 Debian 或 Ubuntu 中,尝试以下操作:

sudo apt-get install sun-java7-jdk

这将安装 JDK 以及任何依赖项,比如 JRE。如果没有,这可能意味着您需要添加一个新的软件源,然后再次尝试该命令。网页help.ubuntu.com/community/Repositories/Ubuntu解释软件来源以及如何添加到第三方软件的连接。根据您使用的 Linux 版本的不同,这个过程会有所不同。完成后,重试该命令。

随着 Ubuntu 10.04 (Lucid Lynx)的推出,Ubuntu 建议使用 OpenJDK,而不是 Oracle/Sun JDK。要安装 OpenJDK,请尝试以下操作:

sudo apt-get install openjdk-7-jdk

如果找不到,请按照前面所述设置第三方软件,然后再次运行该命令。JDK 依赖的所有包都会自动添加。可以同时安装 OpenJDK 和 Oracle/Sun JDK。要在 Ubuntu 上安装的 Java 版本之间切换活动 Java,请在 shell 提示符下运行以下命令

sudo update-alternatives --config java

然后选择您想要的默认 Java。

现在您已经安装了 Java JDK,是时候设置 JAVA_HOME 环境变量指向 JDK 安装文件夹了。要在 Windows XP 机器上执行此操作,请选择开始image我的电脑,右键单击,选择属性,选择高级选项卡,然后单击环境变量。单击“新建”添加变量,如果变量已经存在,则单击“编辑”进行修改。 JAVA_HOME 的值类似于 C:\ Program Files \ JAVA \ JDK 1 . 7 . 0 _ 79。

对于 Windows Vista 和 Windows 7,进入环境变量屏幕的步骤略有不同。选择开始image计算机,右键单击,选择属性,单击高级系统设置的链接,然后单击环境变量。之后,按照与 Windows XP 相同的说明更改 JAVA_HOME 环境变量。

对于 Mac OS X,你在中设置 JAVA_HOME 。bashrc 文件在您的主目录中。编辑或创建。bashrc 文件,并添加如下所示的一行

export JAVA_HOME=path_to_JDK_directory

其中路径 _ 到 _ JDK _ 目录大概是/库/Java/Home 。对于 Linux,编辑你的。bashrc 文件并添加一行类似于 Mac OS X 的代码,除了您的 Java 路径可能是类似于 /usr/lib/jvm/java-6-sun 或/usr/lib/JVM/Java-6-open JDK 的代码。

下载 Eclipse

安装 JDK 后,您可以下载面向 Java 开发人员的 Eclipse IDE。(不需要 Java EE 的版本;它可以工作,但是它要大得多,并且包含了你在这本书里不需要的东西。)本书中的例子使用的是 Eclipse 4.2 或 4.4 (在 Linux 和 Windows 环境下均可)。你可以从 www.eclipse.org/downloads/下载所有版本的 Eclipse。

注意除了这里介绍的各个步骤,您还可以从 Android 开发者网站下载 ADT 捆绑包。这包括带有内置开发工具的 Eclipse 和一个软件包中的 Android SDK。这是一个快速入门的好方法,但是如果您有一个现有的环境,或者只是想知道所有的组件是如何连接在一起的,那么遵循一步一步的说明是正确的方法。

Eclipse 发行版是一个。可以在任何地方提取的 zip 文件。在 Windows 上,最简单的解压位置是 C:\ ,这会产生一个 C:\eclipse 文件夹,在那里可以找到 eclipse.exe。根据您的安全配置,从 C:\运行时,Windows 可能会坚持实施 UAC。对于 Mac OS X,你可以解压到应用。对于 Linux,您可以解压到您的主目录,或者让您的管理员将 Eclipse 放在一个您可以访问它的公共位置。对于所有平台,Eclipse 可执行文件都在 eclipse 文件夹中。您还可以使用 Linux 的软件中心找到并安装 Eclipse,以添加新的应用,尽管这可能不会为您提供最新的版本。

当您第一次启动 Eclipse 时,它会询问您工作区的位置。为了简单起见,您可以选择一个简单的位置,如 C:\android 或您的主目录下的一个目录。如果您与其他人共享计算机,您应该将工作区文件夹放在您的个人目录下的某个位置。

下载 Android SDK

要为 Android 构建应用,您需要 Android SDK。如前所述,SDK 附带了基础工具;然后您下载您需要和/或想要使用的包部件。SDK 的工具部分包括一个模拟器,所以你不需要一个装有 Android 操作系统的移动设备来开发 Android 应用。它还有一个安装工具,允许您安装想要下载的软件包。

可以从developer.android.com/sdk下载 Android SDK。它以的名字发货。zip 文件,类似于 Eclipse 的分发方式,所以您需要将其解压缩到一个适当的位置。对于 Windows,将文件解压缩到一个方便的位置(我们使用了 C: 驱动器),之后你应该有一个类似于 C:\android-sdk-windows 的文件夹,其中包含如图图 1-1 所示的文件。对于 Mac OS X 和 Linux,您可以将文件解压缩到您的主目录。请注意,Mac OS X 和 Linux 没有 SDK 管理器可执行文件;Mac OS X 和 Linux 中的 SDK 管理器相当于运行工具/android 程序。

9781430246800_Fig01-01.jpg

图 1-1 。Android SDK 的基本内容

另一种方法(仅适用于 Windows)是下载安装程序 EXE 而不是 zip 文件,然后运行安装程序可执行文件。这个可执行文件检查 Java JDK,为您解包嵌入的文件,并运行 SDK 管理程序来帮助您设置其余的下载。

无论是通过使用 Windows installer 还是通过执行 SDK 管理器,接下来都应该安装一些软件包。当您第一次安装 Android SDK 时,它没有任何平台版本(即 Android 版本)。安装平台非常容易。在你启动了 SDK 管理器之后,你会看到已经安装了什么和可以安装什么,如图 1-2 所示。您必须添加 Android SDK 工具和平台工具,以便您的环境能够工作。因为你用的时间短,所以至少添加 Android 1.6 SDK 平台,以及你的安装程序中显示的最新平台。

9781430246800_Fig01-02.jpg

图 1-2 。向 Android SDK 添加软件包

单击安装按钮。您需要为正在安装的每个项目点按“接受”(或“全部接受”),然后点按“安装”。Android 然后下载你的包和平台让你可以使用。Google APIs 是使用 Google Maps 开发应用的附加组件。你可以随时回来添加更多的软件包。

更新 PATH 环境变量

Android SDK 附带了一个工具目录,您希望它位于您的路径中。在您的路径中还需要刚刚安装的平台工具目录。让我们现在添加它们,或者,如果您正在升级,请确保它们是正确的。当你在那里时,你也可以添加一个 JDK bin 目录,这将使以后的生活更容易。

对于 Windows,返回到环境变量窗口。编辑路径变量并添加分号(;)放在最后,后面是 Android SDK tools 文件夹的路径,再后面是一个分号,再后面是 Android SDK platform-tools 文件夹的路径,再后面是一个分号,然后是 %JAVA_HOME%\bin 。完成后,点按“好”。对于 Mac OS X 和 Linux,编辑你的。bashrc 文件,并将 Android SDK 工具目录路径添加到您的 path 变量,以及 Android SDK 平台工具目录和 $JAVA_HOME/bin 目录。类似下面的内容适用于 Linux:

export PATH=$PATH:$HOME/android-sdk-linux_x86/tools:$HOME/android-sdk-linux_x86/platform-tools:$JAVA_HOME/bin

只要确保指向 Android SDK 工具目录的 PATH 组件对于您的特定设置是正确的。

工具窗口

在本书的后面,有时您需要执行命令行工具。这些程序是 JDK 或 Android SDK 的一部分。通过将这些目录放在您的路径中,您不需要指定完整的路径名来执行它们,但是您需要启动一个工具窗口 来运行它们(后面的章节参考这个工具窗口)。在 Windows 中创建工具窗口最简单的方法是选择开始image运行,键入 cmd ,点击确定。对于 Mac OS X,从 Finder 中的应用文件夹中选择“终端”,或者从 Dock 中选择“终端”(如果有)。对于 Linux,运行您最喜欢的终端。

稍后您可能需要知道工作站的 IP 地址。要在 Windows 中找到它,启动工具窗口并输入命令 ipconfig 。结果包含一个 IPv4(或类似的东西)条目,旁边列出了您的 IP 地址。IP 地址看起来像这样:192.168.1.25。对于 Mac OS X 和 Linux,启动工具窗口并使用命令 ifconfig 。你可以在标签 inet addr 旁找到你的 IP 地址。

您可能会看到名为 localhost 或 lo 的网络连接;此网络连接的 IP 地址是 127.0.0.1。这是操作系统使用的特殊网络连接,与您工作站的 IP 地址不同。为您的工作站 IP 地址寻找一个不同的数字。

安装 ADT

现在你需要安装 ADT(最近更名为 GDT,谷歌开发者工具),这是一个帮助你构建 Android 应用的 Eclipse 插件。具体来说,ADT 与 Eclipse 集成,为您创建、测试和调试 Android 应用提供了便利。您需要使用 Eclipse 中的 Install New Software 工具来执行安装。(本节稍后将介绍升级 ADT 的说明。)要开始,启动 Eclipse IDE 并遵循以下步骤:

  1. 选择帮助image安装新软件。

  2. Select the Work With field, type in

    [`dl-ssl.google.com/android/eclipse/`](https://dl-ssl.google.com/android/eclipse/),
    

    然后按回车键。Eclipse 联系站点并填充列表,如图 1-3 所示。

    9781430246800_Fig01-03.jpg

    图 1-3 。使用 Eclipse 中的安装新软件特性安装 ADT

  3. 您应该看到一个名为 Developer Tools 的条目,它有四个子节点:Android DDMS、Android 开发工具、Android Hierarchy Viewer 和 Android Traceview。就在出版这本书之前,谷歌更新了 ADT,使其成为更通用的 Eclipse 或 GDT 谷歌开发者工具插件的一部分。在 GDT 寻找同样的选择。选择父节点 Developer Tools,确保也选择了子节点,然后单击 Next 按钮。你看到的版本可能比这些新,没关系。您可能还会看到其他工具。这些工具将在第十一章中进一步解释。

  4. Eclipse 要求您验证要安装的工具。单击下一步。

  5. 您需要查看 ADT 以及安装 ADT 所需工具的许可证。查看许可证,单击“我接受”,然后单击“完成”按钮。

Eclipse 下载开发人员工具并安装它们。您需要重新启动 Eclipse,新的插件才会出现在 IDE 中。

如果您在 Eclipse 中已经有了一个旧版本的 ADT,请转到 Eclipse Help 菜单并选择 Check for Updates。您应该看到 ADT 的新版本,并能够按照安装说明进行操作,从步骤 3 开始。

注意如果您正在升级 ADT,您可能在要升级的工具列表中看不到这些工具。如果你没有看到它们,那么在你升级了 ADT 的其余部分之后,去安装新的软件,并从 Works With 菜单中选择【https://dl-ssl.google.com/android/eclipse/】。中间的窗口应该显示可以安装的其他工具。

让 ADT 在 Eclipse 中发挥作用的最后一步是将它指向 Android SDK。在 Eclipse 中,选择窗口image首选项。(在 Mac OS X 上,偏好设置位于 Eclipse 菜单下。)在首选项对话框中,选择 Android 节点,并将 SDK 位置字段设置为 Android SDK 的路径(参见图 1-4 ),然后单击应用按钮。请注意,您可能会看到一个对话框,询问您是否要向 Google 发送有关 Android SDK 的使用统计数据;这个决定取决于你。

9781430246800_Fig01-04.jpg

图 1-4 。将 ADT 指向 Android SDK

您可能想在 Android image构建页面上再做一次偏好更改。如果你想让你的文件保存得更快,跳过打包选项应该被选中。默认情况下,ADT 会在每次构建应用时为启动做好准备。通过选中此选项,只有在真正需要时才会进行打包和索引。

从 Eclipse 中,您可以启动 SDK 管理器。为此,选择窗口image Android SDK 管理器。您应该会看到与图 1-2 中相同的窗口。

如果您已经选择 Eclipse 作为您的 IDE,那么您几乎已经为您的第一个 Android 应用做好了准备——您可以跳过 Android Studio 的以下部分,直接进入“学习 Android 的基本组件”部分。

设置您的 Android Studio 环境

2013 年,谷歌推出了第二个支持的开发环境,称为 Android Studio (或推出时的 Android Developer Studio)。这是基于一个流行的 Java IDE: IDEA IntelliJ。关于 Android Studio 最重要的一点是,它仍然是一个正在进行中的工作。截至本书写作时,最新版本是 1.2。任何熟悉版本号变幻莫测的人都知道,从低版本号开始通常意味着“小心!”

第二个需要记住的最重要的事情是,Android Studio 目前假设的是 64 位开发环境。这意味着像 Java 这样的依赖也需要是 64 位的。

接下来的章节简要介绍了 Android Studio 的设置,供那些有兴趣或者有足够热情的人使用。请注意,本书的其余部分主要展示了使用 Eclipse 的示例和选项。

Android Studio 的 Java 需求

像 Eclipse 一样,Android Studio 依赖于一个有效的 Java 安装。Android Studio 会在安装过程中尝试自动发现您的 Java 环境,因此安装和配置 Java 是值得的。

对于 Java 安装,记住 Android Studio 是 64 位的。在所有其他方面,你可以遵循前面题为“下载 JDK”的部分——为了节省一些树木,我们不会在这里逐字重复。确保您遵循了那里的所有说明,包括设置 JAVA_HOME 环境变量,因为这是 Android Studio 安装程序用来查找您的 JAVA 安装的主要指示器。

下载和安装 Android Studio

谷歌在主要的 Android 开发网站上提供 Android Studio,目前位于 URL【http://developer.android.com/sdk/installing/studio.html】。这可能会随时改变,但在 developer.android.com 网站上快速搜索一下应该就能找到。Android Studio 被打包成一个整体包,几乎包含了你需要的所有组件。Java SDK 是个例外——我们很快会谈到这一点。从前面的 URL 下载的软件包将被命名为类似于 android-studio-bundle-132.893413-windows.exe 的名称(对于 windows ),或者类似的名称(对于 OS X 和 Linux 具有不同的扩展名),包括以下内容:

  • IntelliJ IDEA 的 Android Studio 捆绑包的当前最新版本
  • 内置 Android SDK
  • 所有相关的 Android 构建工具
  • Android 虚拟设备映像

我们将在后面的章节中详细讨论这些组件。对于 Windows 安装,运行可执行文件,按照提示选择安装路径,并决定 Android Studio 是对 Windows 机器上的所有用户开放,还是只对当前用户开放。对于 OS X,打开。dmg 文件,并将 Android Studio 条目复制到您的应用文件夹。在 Linux 下,提取的内容。tgz 文件到你想要的位置。

安装完成后,可以从提示时选择的开始菜单文件夹启动 Windows 下的 Android Studio 在应用文件夹的 OS X 下;而在 Linux 下通过运行。安装目录下的/Android-studio/bin/studio . sh 文件。不管是什么操作系统,你都应该看到 Android Studio 主屏幕,如图图 1-5 所示。

9781430246800_Fig01-05.jpg

图 1-5 。 Android Studio 首次推出时

学习 Android 的基本组件

每个应用框架都有一些关键组件,开发人员在开始编写基于框架的应用之前需要了解这些组件。例如,为了编写 Java 2 平台企业版(J2EE)应用,您需要理解 JavaServer Pages (JSP)和 servlets。类似地,当您为 Android 构建应用时,您需要理解视图、活动、片段、意图、内容提供者、服务和 AndroidManifest.xml 文件。您将在此简要介绍这些基本概念,并在整本书中更详细地探讨它们。

视角

视图 是构成用户界面基本构件的用户界面(UI)元素。视图可以是按钮、标签、文本字段或许多其他 UI 元素。如果你熟悉 J2EE 和 Swing 中的视图,那么你就会理解 Android 中的视图。视图也被用作视图的容器,这意味着 UI 中通常有视图的层次结构。最后,你看到的一切都是一种风景。

活动

一个活动 是一个 UI 概念,通常代表应用中的一个屏幕。它通常包含一个或多个视图,但这不是必须的。活动听起来很像——帮助用户做一件事的东西,可以是查看数据、创建数据或编辑数据。大多数 Android 应用内部都有几个活动。

片段

当屏幕很大时,很难在单个活动中管理它的所有功能。片段 就像子活动,一个活动可以同时在屏幕上显示一个或多个片段。当屏幕很小时,一个活动很可能只包含一个片段,这个片段可以是在更大的屏幕中使用的同一个片段。

目的

意图 一般定义做一些工作的“意图”。意图包含了几个概念,所以理解它们的最好方法是查看它们的使用示例。您可以使用意图来执行以下任务:

  • 广播消息
  • 启动服务
  • 发起一项活动
  • 显示网页或联系人列表
  • 拨打电话号码或接听电话

意图并不总是由你的应用发起——它们也被系统用来通知你的应用特定的事件(比如一个文本消息的到达)。

意图可以是明确的,也可以是隐含的。如果你只是简单地说你想显示一个 URL,系统会决定用什么组件来实现这个意图。您还可以提供关于应该如何处理意图的具体信息。意图松散地耦合动作和动作处理器。

内容供应器

设备上的移动应用之间的数据共享很常见。因此,Android 为应用定义了一个标准机制来共享数据(如联系人列表),而不暴露底层存储、结构和实现。通过内容供应器,您可以公开您的数据,并让您的应用使用来自其他应用的数据。

服务

Android 中的服务 类似于你在 Windows 或其他平台中看到的服务——它们是后台进程,可能会运行很长时间。Android 定义了两种类型的服务:本地服务和远程服务。本地服务是只能由托管服务的应用访问的组件。相反,远程服务是指设备上运行的其他应用可以远程访问的服务。

电子邮件应用用来轮询新邮件的组件就是服务的一个例子。如果设备上运行的其他应用不使用这种服务,则这种服务可以是本地服务。如果几个应用使用该服务,那么它就被实现为一个远程服务。

AndroidManifest.xml

AndroidManifest.xml ,类似于 J2EE 世界的 web.xml 文件,定义了你的应用的内容和行为。例如,它列出了应用的活动和服务,以及应用运行所需的权限和功能。

AVD

AVD 允许开发者测试他们的应用,而无需连接实际的 Android 设备(通常是手机或平板电脑)。avd 可以以各种配置创建,以模拟不同类型的真实设备。

你好世界!

现在,您已经准备好构建您的第一个 Android 应用了。您首先构建一个简单的“Hello World!”程序。按照以下步骤创建应用的框架:

  1. 启动 Eclipse,并选择 File image New image Project。在新建项目对话框中,选择 Android 应用项目,然后单击下一步。你看到新的 Android 项目对话框,如图图 1-6 所示。(Eclipse 可能在新菜单中添加了 Android Project,如果有就可以用。)工具栏上还有一个新的 Android 项目按钮。

    9781430246800_Fig01-06.jpg

    图 1-6 .使用新建项目向导创建机器人应用

  2. 如图 1-6 所示,输入 HelloAndroid 作为项目名称。您需要将这个项目与您在 Eclipse 中创建的其他项目区分开来,所以当您在 Eclipse 环境中查看所有项目时,选择一个对您有意义的名称。您还将看到可用的构建目标。选择安卓 2.2。这是您用作应用基础的 Android 版本。你可以在更高版本的 Android 上运行你的应用,比如 4.3 和 4.4;但是 Android 2.2 有这个例子需要的所有功能,所以选择它作为你的目标。一般来说,最好尽可能选择最低的版本号,因为这样可以最大限度地增加运行应用的设备数量。

  3. 让项目名称根据您的应用名称自动完成。

  4. 使用 com.androidbook.hello 作为包名。像所有 Java 应用一样,您的应用必须有一个基本包名,就是这个。此包名将用作应用的标识符,并且在所有应用中必须是唯一的。因此,最好以您自己的域名作为包名的开头。如果您没有自己的包名,请创造性地确保您的包名不会被其他任何人使用。单击下一步。

  5. 下一个窗口提供了客户启动器图标的选项、存储源代码和其他文件的工作区的实际目录,以及其他几个选项。将所有这些都保留为默认值,然后单击 Next。

  6. 下一个窗口显示配置启动器图标选项和设置,如图图 1-7 所示。尽管您所做的任何更改都是修饰性的,会影响应用部署时启动器图标的外观,而不是它的实际逻辑,但是您可以随意使用这里的选项。准备就绪后,单击下一步。

    9781430246800_Fig01-07.jpg

    图 1-7 .新机器人项目的机器人启动器配置选项

  7. 接下来,您将看到创建活动屏幕。选择“空白活动”作为活动类型,然后单击“下一步”移至向导的最后一个屏幕。

  8. 新 Android 应用向导的最后一个屏幕将是空白的活动详情页面。键入 HelloActivity 作为活动名称。你告诉 Android 这个活动是当你的应用启动时启动的。您的应用中可能还有其他活动,但这是用户看到的第一个活动。允许布局名称自动填充值 activity_hello 。

  9. 单击 Finish 按钮,这会告诉 ADT 为您生成项目框架。现在,打开 src 文件夹下的 HelloActivity.java 文件,修改 onCreate() 方法如下:

    /** Called when the activity is first created. */
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            /** create a TextView and write Hello World! */
            TextView tv = new TextView(this);
            tv.setText("Hello World!");
            /** set the content view to the TextView */
            setContentView(tv);
        }
    

您将需要添加一个导入 Android . widget . textview;用其他导入文件顶部的语句来摆脱 Eclipse 报告的错误。保存 HelloActivity.java 的文件。

要运行这个应用,您需要创建一个 Eclipse 启动配置,并且您需要一个运行它的虚拟设备。我们将快速浏览这些步骤,稍后再回来讨论 AVDs 的更多细节。按照以下步骤创建 Eclipse 启动配置:

  1. 选择运行image运行配置。

  2. 在运行配置对话框中,双击左窗格中的 Android 应用。向导将插入一个名为“新配置”的新配置。

  3. 重命名配置 RunHelloWorld

  4. 单击 Browse 按钮,并选择 HelloAndroid 项目。

  5. 将启动操作设置为启动默认活动。该对话框应如图 1-8 所示。

    9781430246800_Fig01-08.jpg

    图 1-8 .配置一个黯然失色运行配置来运行“你好世界!”应用

  6. 单击应用,然后单击运行。你就快到了!Eclipse 已经准备好运行您的应用,但是它需要一个运行它的设备。如图 1-9 所示,警告您没有找到兼容的目标,并询问您是否要创建一个。单击是。

    9781430246800_Fig01-09.jpg

    图 1-9 .警告目标并要求新主动脉瓣疾病的错误信息

  7. 你会看到一个显示现有 avd 的窗口(见图 1-10 )。您需要添加一个适合您新应用的 AVD。单击新建按钮。

    9781430246800_Fig01-10.jpg

    图 1-10 .现有的 avd

  8. 填写如图图 1-11 所示的创建 AVD 表格。将 Name 设置为 KitKat,选择 Android 4.4 - API Level 19(或其他版本)作为目标,将 SD 卡大小设置为 64MB),并选择其他值,如图所示。单击创建 AVD。经理可能会确认您的 AVD 创建成功。通过单击右上角的 X 关闭 AVD 管理器窗口。

    9781430246800_Fig01-11.jpg

    图 1-11 .配置 AVD

    T12T14注意你正在为你的主动脉瓣疾病选择一个较新版本的 SDK,但是你的应用也可以在一个旧版本上运行。这是可以的,因为具有较新软件开发工具包(Software Development Kit)的主动脉瓣疾病可以运行需要较旧软件开发工具包(Software Development Kit)的应用。当然,相反的情况是不正确的:一个需要新软件开发工具包(Software Development Kit)特性的应用不能在一个有旧软件开发工具包(Software Development Kit)的主动脉瓣疾病上运行.

  9. 从底部列表中选择您的新 AVD。请注意,您可能需要单击刷新按钮,以使任何新的 avd 显示在列表中。单击确定按钮。

  10. Eclipse 用您的第一个 Android 应用启动模拟器(见图 1-12 )!

9781430246800_Fig01-12.jpg

图 1-12 。 HelloAndroidApp 在模拟器中运行

注意仿真器可能需要一段时间来仿真设备启动过程。一旦启动过程完成,您通常会看到一个锁定的屏幕。单击菜单按钮或拖动解锁图像来解锁 AVD。解锁后,你应该会看到 HelloAndroidApp 在模拟器中运行,如图图 1-11 所示。请注意,模拟器在启动过程中会在后台启动其他应用,因此您可能会不时看到警告或错误消息。如果这样做了,通常可以关闭它,让模拟器进入启动过程的下一步。例如,如果您运行模拟器并看到类似“应用 abc 没有响应”的消息,您可以等待应用启动,或者直接要求模拟器强制关闭应用。通常,您应该等待,让模拟器干净地启动。

现在您知道了如何创建一个新的 Android 应用,并在模拟器中运行它。接下来,我们将更深入地了解 AVDs,以及如何部署到真实设备上。

AVD

AVD 代表一个设备及其配置。例如,你可以用一个 AVD 来代表一个运行 1.5 版本 SDK 和 32MB SD 卡的非常旧的 Android 设备。这个想法是你创建你要支持的 avd,然后在开发和测试你的应用时,将仿真器指向这些 avd 中的一个。指定(和更改)使用哪种 AVD 非常容易,并且可以轻松测试各种配置。前面,您看到了如何使用 Eclipse 创建 AVD。你可以通过选择窗口image Android 虚拟设备管理器在 Eclipse 中制作更多的 avd。您还可以使用命令行创建 avd,在工具目录下使用名为 android 的工具(例如 c:\ Android-SDK-windows \ tools )。 android 允许你创建一个新的 AVD 并管理现有的 AVD。例如,您可以通过使用“avd”选项调用 android 来查看现有的 avd、移动 avd 等等。运行 android -help 可以看到使用 android 的可用选项。现在,让我们创建一个 AVD。

在真实设备上运行

测试 Android 应用的最佳方式是在真实设备上运行它。任何商业 Android 设备在连接到您的工作站时都应该可以工作,但您可能需要做一点工作来设置它。如果你有一台 Mac,除了用 USB 线把它插上,你不需要做任何事情。然后,在设备本身上,选择设置image应用image开发(尽管这可能因手机和版本而异)并启用 USB 调试。在 Linux 上,你可能需要创建或者修改这个文件:/etc/udev/rules . d/51-Android . rules。我们将此文件的副本与项目文件一起放在我们的网站上;将其复制到正确的目录,并根据您的计算机修改相应的用户名和组值。然后,当你插入一个 Android 设备时,它会被识别。接下来,在设备上启用 USB 调试。

对于 Windows,你必须处理 USB 驱动程序。谷歌提供了一些 Android 包,这些包放在 Android SDK 目录的 usb_driver 子目录下。其他设备供应商为您提供了驱动程序,所以请在他们的网站上查找。你也可以访问 forum.xda-developers.com 的 XDA 论坛,那里讨论了为各种手机和设备寻找和配置驱动程序的建议。当您设置好驱动程序后,在设备上启用 USB 调试,您就准备好了。

现在,您的设备已连接到您的工作站,当您尝试启动应用时,它会直接在设备上启动,或者(如果您运行了仿真器或连接了其他设备)会打开一个窗口,您可以在其中选择要启动的设备或仿真器。如果没有,请尝试编辑您的运行配置来手动选择目标。

探索 Android 应用的结构

虽然 Android 应用的大小和复杂性可能有很大的不同,但它们的结构是相似的。图 1-13 显示了“Hello World!”您刚刚构建的应用。

9781430246800_Fig01-13.jpg

图 1-13 。“你好,世界!”的结构应用

Android 应用有些工件是必需的,有些是可选的。表 1-1 总结了一个 Android 应用的元素。

表 1-1 。安卓应用的神器

|

假象

|

描述

|

必需的?

|
| --- | --- | --- |
| AndroidManifest.xml | Android 应用描述符文件。这个文件定义了应用的活动、内容提供者、服务和意图接收者。您还可以使用该文件以声明方式定义应用所需的权限,以及检测和测试选项。 | 是 |
| src | 包含应用所有源代码的文件夹。 | 是 |
| 资产 | 文件夹和文件的任意集合。 | 不 |
| 无 | 包含应用资源的文件夹。这是可绘制、动画师、布局、菜单、值、 xml 和 raw 的父文件夹。 | 是 |
| 可拉伸 | 包含应用使用的图像或图像描述符文件的文件夹。 | 不 |
| 动画师 | 包含 XML 描述符文件的文件夹,这些文件描述了应用使用的动画。 | 不 |
| 布局 | 包含应用视图的文件夹。 | 不 |
| 菜单 | 包含应用中菜单的 XML 描述符文件的文件夹。 | 不 |
| 值 | 包含应用使用的其他资源的文件夹。该文件夹中的资源示例包括字符串、数组、样式和颜色。 | 不 |
| xml | 包含应用使用的附加 XML 文件的文件夹。 | 不 |
| 生 | 包含应用所需的附加数据(可能是非 XML 数据)的文件夹。 | 不 |

正如你从表 1-1 中看到的,一个 Android 应用主要由三部分组成:应用描述符、各种资源的集合和应用的源代码。如果你暂时抛开 AndroidManifest.xml 文件,你可以用这种简单的方式来看待一个 Android 应用:你有一些用代码实现的业务逻辑,其他的都是资源。

Android 也采用了通过 XML 标记定义视图的方法。您从这种方法中受益,因为您不必对应用的视图进行硬编码;您可以通过编辑标记来修改应用的外观。

还值得注意的是资源方面的一些限制。首先,Android 只支持在 res 下的预定义文件夹中的单级文件列表。例如,资产文件夹和 res 下的 raw 文件夹有一些相似之处。两个文件夹都可以包含原始文件,但是在 raw 中的文件被认为是资源,而在 assets 中的文件不是。所以 raw 中的文件是本地化的,可以通过资源 id 访问,等等。但是 assets 文件夹的内容被认为是通用内容,可以在没有资源限制和支持的情况下使用。请注意,因为 assets 文件夹的内容不被视为资源,所以您可以在该文件夹中放置任意层次的文件夹和文件。(第三章讲了很多关于资源的东西。)

注意你可能已经注意到 XML 在 Android 中被大量使用。您知道 XML 可能是一种臃肿的数据格式,那么当您知道您的目标是资源有限的设备时,依赖 XML 有意义吗?事实证明,您在开发过程中创建的 XML 实际上是使用 Android 资产打包工具(AAPT)编译成二进制的。因此,当您的应用安装在设备上时,设备上的文件以二进制形式存储。当运行时需要该文件时,该文件以二进制形式读取,而不是转换回 XML。这给了您两个世界的好处——您可以使用 XML,而不必担心占用设备上的宝贵资源。

检查应用的生命周期

Android 应用的生命周期由系统根据用户需求、可用资源等进行严格管理。例如,用户可能想要启动 web 浏览器,但是系统最终决定是否启动该应用。尽管系统是最终的管理者,但它遵循一些定义好的逻辑准则来决定应用是否可以被加载、暂停或停止。如果用户当前正在进行某项活动,系统会给予该应用较高的优先级。相反,如果某个活动不可见,并且系统确定某个应用必须关闭以释放资源,则它会关闭优先级较低的应用。

应用生命周期的概念是合乎逻辑的,但是 Android 应用的一个基本方面使事情变得复杂。具体来说,Android 应用架构是面向组件和集成的。这允许丰富的用户体验、无缝的重用和简单的应用集成,但是给应用生命周期管理人员带来了复杂的任务。

让我们考虑一个典型的场景。用户正在通过电话与某人交谈,需要打开一封电子邮件来回答问题。用户转到主屏幕,打开邮件应用,打开电子邮件消息,单击电子邮件中的链接,并通过阅读网页上的股票报价来回答朋友的问题。这个场景需要四个应用:家庭应用、通话应用、电子邮件应用和浏览器应用。当用户从一个应用导航到下一个应用时,体验是无缝的。然而,在后台,系统正在保存并恢复应用状态。例如,当用户单击电子邮件消息中的链接时,系统在启动浏览器应用活动以启动 URL 之前保存正在运行的电子邮件消息活动的元数据。事实上,系统在开始另一个活动之前保存任何活动的元数据,以便它可以返回到该活动(例如,当用户回溯时)。如果内存成为问题,系统必须关闭运行活动的进程,并在必要时恢复它。

Android 对应用及其组件的生命周期非常敏感。因此,您需要理解和处理生命周期事件,以便构建一个稳定的应用。运行 Android 应用及其组件的进程会经历各种生命周期事件,Android 提供了回调,您可以实现这些回调来处理状态变化。首先,你应该熟悉一个活动的各种生命周期回调(见清单 1-1 )。

清单 1-1 。 一种活动的生命周期方法

protected void onCreate(Bundle savedInstanceState);
protected void onStart();
protected void onRestart();
protected void onResume();
protected void onPause();
protected void onStop();
protected void onDestroy();

清单 1-1 显示了 Android 在活动生命周期中调用的生命周期方法列表。为了确保实现一个稳定的应用,理解系统何时调用每个方法是很重要的。请注意,您不需要对所有这些方法做出反应。但是,如果你这样做了,一定要调用超类版本。图 1-14 显示了状态之间的转换。

9781430246800_Fig01-14.jpg

图 1-14 。活动的状态转换

系统可以根据正在发生的事情来启动和停止您的活动。Android 在刚创建活动时调用 onCreate() 方法。 onCreate() 后面总是跟随着对 onStart() 的调用,但是 onStart() 前面并不总是跟随着对 onCreate() 的调用,因为如果您的应用被停止,就可以调用 onStart() 。当调用 onStart() 时,用户看不到您的活动,但很快就会看到了。 onResume() 在 onStart() 之后调用,正好是活动在前台,用户可以访问的时候。此时,用户可以与您的活动进行交互。

当用户决定移动到另一个活动时,系统调用您的活动的 onPause() 方法。从 onPause() 中,您可以期待调用 onResume() 或 onStop() 。 onResume() 被调用,比如用户把你的活动带回前台。如果用户看不到您的活动,将调用 onStop() 。如果您的活动在调用 onStop() 后被带回到前台,那么 onRestart() 将被调用。如果您的活动位于活动堆栈上,但对用户不可见,并且系统决定终止您的活动,则调用 onDestroy() 。

作为一名开发人员,您不必处理每一种可能的情况;你主要处理 onCreate() 、 onResume() 、 onPause() 。您处理 onCreate() 来为您的活动创建用户界面。在这种方法中,您将数据绑定到小部件,并为 UI 组件连接任何事件处理器。在 onPause() 中,您希望将关键数据保存到应用的数据存储中:这是在系统终止应用之前调用的最后一个安全方法。 onStop() 和 onDestroy() 不保证被调用,所以不要依赖这些方法进行临界逻辑。

这场讨论的要点是什么?系统管理您的应用,它可以随时启动、停止或恢复应用组件。尽管系统控制着你的组件,但是它们并不完全独立于你的应用运行。换句话说,如果系统在您的应用中启动一个活动,您可以在您的活动中依赖应用上下文。

简单调试

Android SDK 包括许多工具,您可以使用它们进行调试。这些工具与 Eclipse IDE 集成在一起(参见图 1-15 中的一个小例子)。

9781430246800_Fig01-15.jpg

图 1-15 。调试工具,你可以在构建 Android 应用时使用

你在 Android 开发中使用的工具之一是 LogCat。该工具显示您使用 android.util.Log 、异常、 System.out.println 等发出的日志消息。虽然 System.out.println 工作正常,并且消息出现在 LogCat 窗口中,但是要记录来自应用的消息,您应该使用 android.util.Log 类。该类定义了常见的信息、警告和错误方法,您可以在 LogCat 窗口中过滤这些方法,以查看您想要查看的内容。下面是一个示例 Log 命令:

Log.v("string TAG", "This is my verbose message to write to the log");

这个例子展示了日志类的静态 v() 方法,但是还有其他针对不同严重性级别的方法。最好对您想要记录的消息使用适当的调用级别,通常在您想要部署到生产的应用中留下冗长的调用并不是一个好主意。请记住,日志记录会占用内存和 CPU 资源。

LogCat 的特别之处在于,当您在模拟器中运行应用时,您可以查看日志消息,但是当您将真实设备连接到工作站并处于调试模式时,您也可以查看日志消息。事实上,日志消息是这样存储的,您甚至可以从记录日志消息时断开连接的设备中检索最新的消息。当您将设备连接到工作站并打开 LogCat 视图时,您会看到最后几百条消息。

启动模拟器

前面您已经看到了如何在 Eclipse 中从项目启动模拟器。在大多数情况下,您希望首先启动模拟器,然后在运行的模拟器中部署和测试您的应用。要在任何时候启动模拟器,首先从 Android SDK 的工具目录或从 Eclipse 的窗口菜单运行带有 avd 选项的 Android 程序进入 AVD 管理器。在管理器中,从列表中选择所需的 AVD,然后单击开始。

当你点击开始按钮时,启动选项对话框打开(见图 1-16 )。这允许您缩放模拟器窗口的大小以适合您的显示器,并更改启动和关闭选项。缩放结果有时会出乎意料地大或小,因此请根据您的屏幕尺寸和屏幕密度选择适合您的值。

9781430246800_Fig01-16.jpg

图 1-16 。启动选项对话框

您也可以在启动选项对话框中使用快照。当您退出模拟器时,保存到快照会导致较长的延迟。顾名思义,您正在将模拟器的当前状态写到一个快照映像文件中,下次启动时可以使用它来避免经历整个 Android 启动序列。如果有快照,启动速度会快得多,因此节省时间的延迟是值得的—您基本上是从您停止的地方开始。

如果你想从头开始,你可以选择清除用户数据。您也可以取消选择从快照启动以保留用户数据并完成启动序列。或者,您可以创建您喜欢的快照,并仅启用从快照启动选项;这样可以反复使用快照,所以启动和关闭都很快,因为每次退出时不会创建新的快照映像文件。快照图像文件存储在与其余 AVD 图像文件相同的目录中。如果在创建 AVD 时没有启用快照,您可以随时编辑 AVD 并在那里启用它们。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

本章涵盖了以下主题,帮助您为 Android 开发做好准备:

  • 下载并安装 JDK、Eclipse 或 Android Studio 以及 Android SDK
  • 如何修改路径变量并启动工具窗口
  • 安装和升级视图、活动、片段、意图、内容提供者、服务和 AndroidManifest.xml 文件的 ADT 基本概念
  • Android 虚拟设备(AVDs),当你没有设备(或者你想要测试的特定设备)时,它可以用来测试应用
  • 建立一个“你好的世界!”应用并将其部署到仿真器
  • 初始化任何应用的基本要求(项目名称、Android 目标、应用名称、包名称、主要活动、最低 SDK 版本)
  • 运行配置在哪里以及如何更改它们
  • 将真实设备连接到您的工作站,并在其上运行您的新应用
  • Android 应用的内部结构,以及活动的生命周期
  • LogCat,以及在哪里寻找来自应用的内部消息
  • 启动模拟器时可用的选项,如快照和调整屏幕显示大小

二、Android 应用架构介绍

第一章介绍了开发 Android 应用所需的环境和工具。本章将对 Android 的应用架构进行广泛的介绍。我们将通过做三件事来做到这一点。首先,我们将通过构建一个 Android 应用来展示其架构。然后,我们将介绍 Android 架构的基本组件,即活动、资源、意图、活动生命周期和保存状态。我们将以一个学习路线图来结束这一章,学习如何使用本书的其余部分来创建简单到复杂的移动应用。

在本章的第一节中,一个一页的计算器应用将让你鸟瞰使用 Android SDK 编写应用。创建这个应用将演示如何创建 UI,编写 Java 代码来控制 UI,以及构建和部署应用。

除了演示用户界面,这个计算器应用将向您介绍活动、资源和意图。这些概念是 Android 应用架构的核心。我们将在本章的第二节中详细介绍这些主题,以便为您理解 Android SDK 的其余部分打下坚实的基础。我们还将讨论活动的生命周期,并简要概述应用的持久性选项。

第三部分中,我们将给出本书剩余部分的路线图,解决构建 Android 应用的基本和高级方面。这最后一节将这些章节分成一组学习轨道。这一节是对整套 Android APIs 的广泛介绍。

此外,在这一章中你将会找到以下问题的答案:我怎样才能创建一个拥有丰富控件的 UI?如何持久存储状态?如何读取应用输入的静态文件?我怎样才能接触到网络,从网络上阅读或向网络上写东西?Android 还提供了哪些 API 让我的应用功能丰富?

事不宜迟,让我们进入简单的计算器应用,打开 Android 的世界。

探索简单的 Android 应用

本章我们想要演示的计算器应用如图 2-1 中的所示。

9781430246800_Fig02-01.jpg

图 2-1 。一款计算器应用

图 2-1 中的显示在 Android 中称为活动。这个活动在顶部有两个编辑控件,代表两个数字。您可以在这些编辑框中输入数字,并使用图形底部的运算符按钮来执行算术运算。操作的结果将显示在顶部的编辑控件中。这两个编辑框标记为操作数 1 和操作数 2。要使用 Android SDK 创建这种类型的计算器应用,您需要执行以下步骤:

  1. 在文本/xml 文件(Android 中称为布局或布局文件)中创建用户界面(UI) 定义。
  2. 在 Java 文件中编写编程逻辑(通常在扩展基本活动类的类中)。
  3. 创建一个描述您的应用的配置文件(这个文件总是被称为 AndroidManifest )。xml)。
  4. 创建一个项目和一个目录结构来放置步骤 1、2 和 3 中的文件。
  5. 使用步骤 4 中的项目构建一个可部署的包(它被称为)。apk 文件)。

通过浏览这些步骤的细节,你会对 Android 应用的制作有所了解。我们现在将经历这些步骤。

通过布局文件定义用户界面

Android 应用在许多方面类似于 web 应用。在 web 应用中,UI 就是你的网页。网页的用户界面是通过 HTML 定义的。一个 HTML 网页是一系列的控件,如段落、分区、表格、按钮等。Android 中的 UI 构造类似。Android 中的布局文件就像一个 HTML 页面,尽管控件来自 Android SDK 而不是 HTML。在 Android 中,这个文件被称为布局文件。清单 2-1 显示了产生图 2-1 的用户界面的布局文件。

清单 2-1 。 定义活动 UI 的 Android 布局文件

<?xml version="1.0" encoding="utf-8"?>
<!--
*********************************************
* calculator_layout.xml
* corresponding activity: CalculatorMainActivity.java
* prefix: cl_ (Used for prefixing unique identifiers)
*
* Use:
*    Demonstrate a simple calculator
*    Demonstrate text views, edit text, buttons, business logic
*********************************************
-->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="match_parent" android:layout_height="match_parent"
    android:layout_margin="5dp" android:padding="5dp"
    android:background="@android:color/darker_gray"
    >
    <!--  Operand 1 -->
    <TextView android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Operand 1, (And Result)"
        />
    <EditText android:layout_width="match_parent" android:layout_height="wrap_content"
        android:id="@+id/editText1"  android:text="0"
        android:inputType="numberDecimal"/>
    <!--  Operand 2 -->
    <TextView android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Operand 2"
        android:layout_marginTop="10dp"
        />
    <EditText android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="0"
       android:id="@+id/editText2"
       android:inputType="numberDecimal">
    </EditText>
    <!--  Buttons for Various Operators -->
    <TextView android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Operand1 = Operand1 Operator Operand2"
        android:layout_marginTop="10dp"
        />
<LinearLayout
    android:orientation="horizontal"
    android:layout_marginTop="10dp"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    <Button android:text="+" android:id="@+id/plusButton"
       android:layout_weight="1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content">
    </Button>
    <Button android:text="-" android:id="@+id/minusButton"
       android:layout_weight="1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content">
    </Button>
    <Button android:text="*" android:id="@+id/multiplyButton"
       android:layout_weight="1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content">
    </Button>
    <Button android:text="/" android:id="@+id/divideButton"
       android:layout_weight="1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content">
    </Button>
</LinearLayout>
</LinearLayout>

让我们逐行检查清单 2-1 的计算器 XML 布局文件。与图 2-1 中的相比,这个文件看起来很复杂。是的,它很冗长,但是您很快就会看到它的架构很简单。

在布局文件中指定注释

作为一个好的实践,清单 2-1 中的布局 XML 文件顶部的注释指出了这个文件名是什么,什么 UI 活动将被用来显示这个文件,这个文件的目的是什么,以及这个布局文件中有什么简单的控件。

在布局文件中添加视图和视图组

布局文件中的每个 XML 节点表示一个 UI 控件。这些控件可以是视图,也可以是其他视图的容器。其他视图的容器称为视图组 。例如,按钮就是一个视图。清单 2-1 中的 LinearLayout 是一个视图组,它将所有子视图垂直向下或水平交叉放置。因此, LinearLayout 就像一个 HTML div 一样,它可以横向或纵向布局其子元素。

在布局文件中指定控件属性

计算器布局文件中的 UI 控件有 LinearLayout 、 TextView 、 EditText 和一个按钮。当在屏幕上绘制时,这些控件中的每一个都代表一个 Java 对象。作为一个对象,每个控件都有属性。如果控件属于核心 Android SDK,它们的属性会以 "android:" 作为前缀,如 "android:orientation" 用于 LinearLayout 控件。你通常在应用中使用的大部分(如果不是全部)控件都来自核心的 Android SDK。当您编写自己的控件时,它们被称为自定义控件。这些自定义控件允许您定义自定义属性。有关自定义控件的更多信息,请参见本章的“路线图”部分。

指示视图组属性

一些控件属性被标记为“Android:layout _”、如 android:layout_width 。虽然在给定的 XML 节点中提到了这些属性,比如一个按钮,但是它们被父节点读取并使用,比如 LinearLayout 来放置子节点。父节点是视图组,如 LinearLayout 。你可以在清单 2-1 的布局文件中的第一个 LinearLayout 节点如何定义填充和边距中看到这种差异。在本例中,属性 padding 属于最顶端的 LinearLayout 对象,而同一个最顶端的 LinearLayout 的边距属性 layout_margin 属性属于 LinearLayout 的父对象,后者是 Android 框架提供的隐式视图组。所以对于填充,你说 android:padding ,对于边距,你说 android:layout_margin 。注意有无“布局 _”前缀。如果您想知道一个对象(或控件)支持什么属性,您可以使用 eclipse 中的 Ctrl-Space 来查看该对象属性的一组建议。根据您的开发环境,您可以很容易地找到一组等效的组合键来做同样的事情。

控制控件的宽度和高度

控件的两个常用属性是其布局宽度和布局高度。控件的布局父级管理这些值。这些属性的值通常是匹配 _ 父项和包装 _ 内容。如果您说您的 TextView 的宽度设置为 match_parent ,控件的宽度与父宽度匹配。当一个 TextView 被设置为它的高度 wrap_content 时,那么它的高度将刚好足以包含它在垂直方向上的所有文本。当然,这两个属性可用于布局的所有子控件,而不仅仅是文本控件。这两个布局控件属性 match_parent 和 wrap_content 也适用于控件的高度。

介绍资源和背景

虽然我们正在解释布局文件中的控件,但这是介绍资源的好地方。布局文件是资源,并且由资源组成。在计算器布局文件中,我们通过在根 LinearLayout 控件上设置背景来设置整个视图的背景。该指令如下所示:

android:background="@android:color/darker_gray"

Android 中的每个视图或控件都支持背景属性。背景通常被认为是资源。在这个例子中,背景指向一个资源,来自 Android 包,类型为颜色,引用值为深灰色。

在 Android 中,应用的许多输入被表示为资源。一些示例资源是图像文件、整个布局文件、颜色、字符串、XML 文件、菜单和 Android SDK 中列出的许多其他东西。例如,我们讨论的整个计算器布局文件本身就是一个资源。

从计算器布局文件中可以看出,资源有不同的类型。在 Android 中,它们被进一步宽泛地分为“基于价值”或“基于文件”作为值的资源的例子有字符串和颜色。文件资源的例子有图像或布局文件。清单 2-2 展示了一个创建基于价值的资源的例子,这些资源是字符串和颜色。

清单 2-2 。 基于价值的资源的例子

<?xml version="1.0" encoding="utf-8"?>
<!-- this file will be in /res/values subdirectory  -->
<resources>
    <string name="hello">Hello World, CalculatorMainActivity!</string>
    <string name="app_name">A Demo Calculator</string>
    <color name="red">#FF0000</color>
    <color name="blue">#0000FF</color>
</resources>

您可以拥有任意数量的基于值的文件,只要它们都在 /res/values 子目录下。每个文件将以资源根节点开始。您可以使用 Ctrl-Space 来发现其他可能的基于价值的可用资源。

转到基于文件的资源,清单 2-3 显示了一个将许多基于文件的资源放在它们各自的资源子目录下的例子。

清单 2-3 。 基于文件的资源示例

/res/layout/page1_layout.xml (A layout file for say page 1)
/res/drawable/page1_background.jpg (An example image file)
/res/drawable-hdpi/page1_background.jpg (Same image file for a different density)
/res/xml/some_preferences.xml (example of an input file for your app)

任何这些资源,无论是基于文件还是基于值,都可以使用“ @ ”资源引用语法在布局文件中引用。例如,在清单 2-1 中的计算器布局文件中,背景可以被设置为引号之间的颜色值,如 "#FFFFFF ,"或指向一个资源引用(由一个已经被定义为颜色资源的开始@ color/red】指示)(如清单 2-2 中的所示)。在以“@”开头的语法中,引用资源的类型是“颜色”其他类型资源的一些关键字是 string (用于字符串)、d rawable (用于图像)等。

在清单 2-1 中,读取 LinearLayout 的背景属性的值,即@ android:color/darker _ gray 的方法如下:使用 Android 核心框架中标识为 darker_gray 的资源的值,该资源的资源类型为 color 。有了这些资源引用语法的知识,再看一下计算器布局文件清单,您将能够阅读它,其中每个控件都有属性,每个属性都有一个直接指定的值,或者引用资源文件中其他地方定义的资源。

定义为资源引用的控件属性值的间接性具有优势。资源可以针对语言、设备密度变化和各种因素进行定制,而无需更改编译后的 Java 源代码。例如,当你提供背景图片时,你可以把这些图片放在不同的目录中,并用 Android 指定的惯例来命名它们。然后 Android 知道如何根据你的应用运行的设备来定位正确的图片。

在布局文件中使用文本控件

在计算器布局示例中,我们使用了两个基于文本的控件。一个是 TextView 控件,作为标签使用;另一个是 EditText 控件,用于获取输入文本。我们已经向您展示了如何使用以“ layout_ ”开头的属性来设置任何视图的宽度和高度。每个基于文本的控件也有一个名为 text 的属性。在我们的示例中,我们已经直接将文本指定为该属性的值。建议使用资源引用。例如:

android:text="Literal text"  //what we did for clarity
or
android:text="@string/LiteralTextId" //doing it properly

后一个资源 ID, LiteralTextId ,可以在 /res/values 子目录下的文件中定义,非常类似于清单 2-2 中的内容。

计算器布局中的 EditText 控件有一个属性 inputType 来提供必要的约束和验证,这些约束和验证需要在数据被键入可编辑字段时发生。请参考文档以查看可编辑字段可用的大量约束。或者,您可以使用 eclipse ADT 在编码期间动态地发现可用的输入类型。

为控件使用自动生成的 id

为了操作清单 2-1 的计算器布局中的控件,我们需要一种方法将它们转换成 Java 对象。这是通过使用活动的当前加载的布局文件中的唯一 ID 来定位这些控件来实现的。让我们看看布局文件中的一个例子,其中一个编辑文本控件被赋予一个 ID 编辑文本 2 ,如下所示:

android:id="@+id/editText2"

这种格式告诉 Android 这个 EditText 控件的 ID 是一个类型为 ID 的资源,它的整数值在 Java 中应该被称为 editText2 。+便于为 editText2 分配一个新的唯一整数。如果您没有 + 符号,那么 Android 会寻找一个用名为 editText2 的 id 定义的整数值资源。借助 + 的便利,我们可以避免先单独定义一个资源,然后再使用它。在某些情况下,您可能需要一个由多段代码共享的众所周知的 ID,在这种情况下,您将删除 + ,并采取多个步骤,首先定义 ID,然后在多个地方使用它的名称。您将在编程逻辑部分(稍后)看到如何使用这些控件 id 来定位和操作控件。

实现编程逻辑

要查看设备屏幕上的计算器布局,您需要一个从 Android SDKs 类 activity 派生的 Java 类。这样一个活动代表了移动应用中的一个窗口。所以你需要通过扩展 Android 基础的一个活动类来创建一个计算器活动,如清单 2-4 所示。

清单 2-4 。 编程逻辑:实现一个活动类

/**
 * Activity name: CalculatorMainActivity
 * Layout file: calculator_layout.xml
 * Layout shortcut prefix for ids: cl_
 * Menu file: none
 * Purpose and Logic
 * ******************
 * 1\. Demonstrate business logic for a simple calculator
 * 2\. Load the calculator_layout.xml as layout
 * 3\. Setup button callbacks
 * 4\. Respond to button clicks
 * 5\. Read values from edit text controls
 * 6\. Perform operation and update result edit control
 */
public class CalculatorMainActivity extends Activity
implements OnClickListener
{
    private EditText number1EditText;
    private EditText number2EditText;

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.calculator_layout);
        gatherControls();
        setupButtons();
    }
    private void gatherControls()   {
        number1EditText = (EditText)this.findViewById(R.id.editText1);
        number2EditText = (EditText)this.findViewById(R.id.editText2);
        number2EditText.requestFocus();
    }
    private void setupButtons()    {
        Button b = (Button)this.findViewById(R.id.plusButton);
        b.setOnClickListener(this);

        b = (Button)this.findViewById(R.id.minusButton);
        b.setOnClickListener(this);

        b = (Button)this.findViewById(R.id.multiplyButton);
        b.setOnClickListener(this);

        b = (Button)this.findViewById(R.id.divideButton);
        b.setOnClickListener(this);
    }
    @Override
    public void onClick(View v)     {
        String sNum1 = number1EditText.getText().toString();
        String sNum2 = number2EditText.getText().toString();
        double num1 = getDouble(sNum1);
        double num2 = getDouble(sNum2);
        Button b = (Button)v;

        double value = 0;
        if (b.getId() == R.id.plusButton)   {
            value = plus(num1, num2);
        }
        else if (b.getId() == R.id.minusButton)   {
            value = minus(num1, num2);
        }
        else if (b.getId() == R.id.multiplyButton)   {
            value = multiply(num1, num2);
        }
        else if (b.getId() == R.id.divideButton)   {
            value = divide(num1, num2);
        }
        number1EditText.setText(Double.toString(value));
    }

    private double plus(double n1, double n2)    {
        return n1 + n2;
    }
    private double minus(double n1, double n2)    {
        return n1 - n2;
    }
    private double multiply(double n1, double n2)    {
        return n1 * n2;
    }
    private double divide(double n1, double n2)    {
        if (n2 == 0)    {
            return 0;
        }
        return n1 / n2;
    }
    private double getDouble(String s)    {
        if (validString(s))        {
            return Double.parseDouble(s);
        }
        return 0;
    }
    private boolean invalidString(String s)    {
        return !validString(s);
    }
    private boolean validString(String s)    {
        if (s == null)     {
            return false;
        }
        if (s.trim().equalsIgnoreCase(""))    {
            return false;
        }
        return true;
    }
}

在这个清单中,计算器活动被称为 CalculatorMainActivity 。一旦你有了这个活动,你可以加载计算器布局到其中,以便看到图 2-1 的计算器屏幕。

让我们了解一下 Android 中的一项活动。程序员不需要直接实例化一个活动。Android 框架可以基于用户的动作实例化一个活动。从这个意义上说,活动是由 Android 管理的一个“托管组件”。

当另一个具有更高优先级的 UI 位于某个活动之上时,该活动可以部分隐藏或完全隐藏(例如,由于一个电话)。或者,由于内存限制,后台中的活动可以被临时移除。在这些情况下,当用户再次访问应用时,活动可以自动恢复。

将布局文件装入活动

因为活动是事件驱动的,所以活动依赖于回调。第一个重要的回调是 onCreate() 回调。在清单 2-4 中给出的计算器活动中,你可以很容易地找到这个方法。这是我们将计算器布局加载到计算器活动中的地方。这是通过方法 setContentView() 完成的。该方法的输入是计算器布局文件的标识符。

Android 的一个很好的特性是它对包括布局文件在内的各种资源的处理。它自动生成一个名为 R.java 的 java 类,在这里它为所有资源定义整数 id,不管它们是基于值的还是基于文件的。在清单 2-4 给出的活动中,变量 r . layout . calculator _ layout 指向计算器布局文件(它本身在清单 2-1 中)。

当你尝试 Android 框架时,另一个神秘的东西是的。由于 Android 框架可能会停止和重启(甚至重新创建)活动,它需要一种方法将活动的最后状态传递给 onCreate() 方法。这就是保存实例捆绑包的功能。它是保存活动先前状态的键值对的集合。你将在本章后面更详细地了解状态管理的这一方面,也将在第九章中了解,在那里我们将介绍当设备旋转时会发生什么。对于计算器示例的实现,我们简单地调用超类的方法来传递状态包。

收集控件

接下来的两个方法, gatherControls() 和 setupButtons() ,为计算器建立交互模型。在 gatherecontrols()方法中,您获取需要操作(读取或写入)的编辑控件的 java 引用,并将它们本地保存在 calculator 活动类中。您可以通过在基本活动类上使用 findViewById() 方法来实现这一点。 findViewById() 方法将布局文件中控件的 Id 作为输入。这里,Android 也自动生成这些 id,并将它们放入 R.java 类。在您的 eclipse 项目中,您可以在/ gen 子目录中看到这个文件。清单 2-5 显示了为这个计算器项目生成的 R.java 文件。(如果您自己尝试这个项目,这些 id 可能会有所不同。所以使用这个清单主要是为了理解概念。)

清单 2-5 。自动生成的资源标识:R.java

public final class R {
    public static final class attr {
    }
    public static final class drawable {
        public static final int background=0x7f020000;
        public static final int icon=0x7f020001;
    }
    public static final class id {
        public static final int divideButton=0x7f050005;
        public static final int editText1=0x7f050000;
        public static final int editText2=0x7f050001;
        public static final int minusButton=0x7f050003;
        public static final int multiplyButton=0x7f050004;
        public static final int plusButton=0x7f050002;
    }
    public static final class layout {
        public static final int calculator_layout=0x7f030000;
    }
    public static final class string {
        public static final int app_name=0x7f040001;
        public static final int hello=0x7f040000;
    }
}

注意 R.java 如何为每种资源类型使用不同的类前缀。这允许 eclipse 中的程序员根据 id 的类型快速地将它们分开。所以,比如所有布局文件的 id 都以 R.layout 为前缀,所有图像 id 都以 R.drawable 为前缀,所有字符串都以 R.string 为前缀,等等。然而,在使用这些 id 时有一个注意事项。即使您有十个布局文件,所有控件的 id 都生成到一个名称空间中,如 R.id.*(其中“id”是一个资源类型的示例)。因此,您可能希望养成在布局文件中用一些前缀来命名控件的习惯,以表明它们属于哪个布局文件。

设置按钮

清单 2-1 的计算器布局中的一些控件是计算器按钮。分别是代表操作员的按钮: +、- 、 x 、 / 。我们需要在按下这些按钮时调用代码。方法是在按钮控件上注册一个回调对象。这些回调对象必须实现视图。OnClickListener 接口。计算器活动除了扩展活动类之外,还实现了视图。OnClickListener 接口,允许我们将活动注册为每个按钮被按下时需要被回调的活动。正如您在活动代码中看到的(清单 2-4 ),这是通过调用每个按钮上的 setOnClickListener 来完成的。

对按钮点击的响应:将所有这些联系在一起

当点击任何操作按钮时,清单 2-4 中的给出的计算器活动中的 onClick() 方法被调用。在这个方法中,我们将调查回调的视图的 ID。这个调用视图应该是按钮之一。在这个方法中,我们将从两个编辑文本控件中读取值(操作数值),然后调用一个特定于每个操作符的方法。运算符方法将计算结果并更新标记为 result 的编辑文本。

更新机器人清单。可扩展置标语言

到目前为止,我们已经有了 UI(根据布局文件)和根据计算器活动的业务逻辑。每个 Android 应用都必须有自己的配置文件。这个文件叫做 AndroidManifest.xml 。这可以在项目的根目录中找到。清单 2-6 显示了该项目的 AndroidManifest.xml 。

清单 2-6 。?? 应用配置文件:AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      package="com.androidbook.calculator"
      android:versionCode="1"
      android:versionName="1.0">
    <uses-sdk android:minSdkVersion="14" />
    <application android:icon="@drawable/icon" android:label="@string/app_name">
        <activity android:name=".CalculatorMainActivity"
            android:theme="@android:style/Theme.Light"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>
</manifest>

这个清单文件的 package 属性遵循类似于 java 名称空间的命名结构。在计算器 app 中,包设置为 com . androidbook . calculator。这就像给你的应用一个名字和一个唯一的标识符。一旦您签署了此应用并将其安装在谷歌 Play 商店等应用发布者上,只有您才能更新它或发布它的后续版本。 uses-sdk 指令表示该应用向后兼容的 API。应用节点有许多属性,包括它的标签和一个将显示在 Android 设备应用菜单中的图标。在应用节点内部,我们需要定义组成这个应用的所有活动。每个活动由其各自的 java 类名来标识。如果活动类名不是完全限定的,那么 java 包就被认为与所标识的应用包相同。活动的主题表示属于该活动的视图将继承的一组属性。这就像在 HTML UI 上设置 CSS 样式一样。Android 有一些默认的风格。选择浅色主题有利于截图时的对比(如图图 2-1 )。第七章专门讨论在你的应用中使用风格和主题。

在 Android 应用清单文件中,活动可以指定一系列意图过滤器。意图是 Android 独有的编程概念。Android 非常依赖这些意图。Android 使用意图对象来调用包括活动在内的应用组件。一个意图对象可以包含一个显式活动类名,这样当您调用该意图时,您最终会调用该活动。或者,除了有一个显式的类名之外,intent 还可以指示一个通用的动作,比如查看网页的 VIEW。当你用一个普通的动作调用这样一个意图时,Android 将会呈现所有可能的活动来满足这个动作。活动通过 manifest 文件向 Android 注册,它们可以通过意图过滤器来响应一些动作。清单 2-7 展示了如何通过一个意图对象来调用一个活动。

清单 2-7 。 使用意图对象调用活动

//currentActivity refers to the activity in which this code runs
Intent i = new Intent(currentActivity,SomeTargetActivity.class);
currentActivity.startActivity(i); //start the target activity

尽管我们使用 currentActivity 作为第一个参数的值来创建一个 intent,但它所需要的只是一个名为 Context 的基类引用。ccontext 引用代表了应用上下文,像活动这样的组件在其中运行。回到 intent 对象,它有许多标志和额外的数据元素,可以用来控制 intent 正在调用的目标活动的行为。清单 2-8 显示了一个例子。

清单 2-8 。 在一个意向对象上使用临时演员

//currentActivity refers to the activity in which this code runs
Intent intent = new Intent(currentActivity,SomeTargetActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
                | Intent.FLAG_ACTIVITY_SINGLE_TOP);
intent.putExtra("some-key", "some-value");
currentActivity.startActivity(intent);

在本例中,我们希望将目标活动放在窗口或活动堆栈的顶部,并关闭之前在它上面的任何其他活动。当一个人从其他活动中调用活动时,这些活动相互重叠。这个堆栈允许后退按钮导航回堆栈中的上一个活动。当您返回时,当前顶层活动结束,前一个活动显示在前台。清单 2-8 中的代码就像回到目标活动的最后一个位置,使它成为顶层实例,并删除/完成它上面所有最近的活动。 Extras on intent 是一组键值对,您可以将它们从源活动传递给目标活动。各种活动相互之间相当孤立。它们之间不共享局部变量。相反,它们应该通过可以序列化和反序列化的对象来传递数据。Android 使用了一个类似于 Serializable 的界面,叫做 Parcelable ,它允许更大的灵活性和效率。

最终,每个活动几乎总是由意图对象启动。您可以通过调用 getIntent() 在目标活动中的任何地方获取 intent 对象。一旦获得了 intent 对象,就可以获得它的额外内容,并查看是否有您需要的相关数据。

意图及其变体的完整研究是一个大课题。在结束对计算器应用的讨论后,我们将在本章的后面继续讨论更多的意图。我们还在这一章的末尾提供了一个链接,可以找到我们之前版本中关于意图的免费章节。

将文件放置在 Android 项目中

让我们回到我们的主线,计算器 app。至此,您已经拥有了创建计算器应用所需的三个文件。使用你在第一章中学到的知识创建一个空的 Android 项目,并调整该项目来放置这三个文件。清单 2-9 中给出了这些文件及其父目录。

清单 2-9 。计算器应用文件的 位置

/res/layout/calculator_layout.xml
/src/com/androidbook/calculator/CalculatorActivity.java
/AndroidManifest.xml

图 2-2 显示了你的 Android 项目在 eclipse 中的结构。你可以看到清单 2-9 中文件的相对位置。

9781430246800_Fig02-02.jpg

图 2-2 。一款计算器 app 目录结构

图 2-1 中的目录结构也显示了其他资源如图像和字符串的位置。您还可以在图 2-2 中看到设备相关图像文件的目录结构。这就是 Android 如何通过使用不同的资源子目录后缀来解决本地化多语言支持。当你阅读这本书的时候,你会学到一个 Android 项目的其他子目录。

在真实设备上测试计算器应用

现在剩下的就是构建 APK 文件,签署它,并准备好部署。测试您的项目的最简单的方法是让 eclipse 将 APK 部署到模拟器并测试它。在设备上测试此文件(签名后)的最简单方法是通过电子邮件将其发送给您自己,然后在您的设备上打开电子邮件。设备上有一个安全设置,允许来自未验证来源的 apk。只要这是允许的,你就可以安装 APK 文件并在你的设备上运行。或者您也可以将设备连接到 USB 端口,让 eclipse 将 APK 直接部署到设备上。您甚至可以通过 eclipse 在设备上调试它。您也可以将 APK 文件从您的 PC 或 Mac 复制到设备 SD 卡,并从那里进行安装。

这就结束了我们关于计算器应用的部分,它展示了 Android 应用的本质。现在我们将转到本章的第二部分,在这里我们将更深入地讨论活动,并重新审视资源、意图和保存状态。先说活动。

Android 活动生命周期

Android 活动是 Android 应用的独立组件,可以根据各种事件(包括用户发起的和系统发起的事件)来启动、停止、暂停、重启或回收。因此,通过查看活动的所有回调来审查活动生命周期的架构是非常重要的。图 2-3 通过记录回调的顺序和执行回调的环境,展示了活动的生命周期。让我们逐一考虑这些回调方法。

9781430246800_Fig02-03.jpg

图 2-3 。注释安卓活动生命周期

void onCreate (捆绑储蓄罐)

活动的生命周期从这个方法开始。在这种方法中,您应该通过将布局加载到活动的内容视图中来加载视图层次结构。您还可以初始化任何可能在活动的生命周期中使用的活动级别变量。像许多回调一样,你也首先调用父类的 onCreate() 方法。

当调用 onCreate 时,活动可能处于三种状态之一。该活动可能是第一次开始其生命的全新活动。或者它可以是由于配置改变而自动重启的活动,例如设备从一个方向旋转到另一个方向。或者,它是一个在上次由于内存不足而关闭进程后重新启动的活动,并且在后台运行。在 onCreate 回调中,如果您在每个场景中需要做的事情不同,您应该考虑这些场景。

现在我们可以理解涉及 savedInstanceBundle 的这个方法的参数了。您可以使用这个包来查看活动的先前状态。这个包最初可能用于保存配置更改期间的活动状态,或者当活动及其流程由于内存不足而关闭时。保存到这个 bundle 参数中的状态通常被称为活动的实例状态。实例状态本质上是临时的;具体来说,在这个调用过程中,它被绑定到应用的这个实例。这种类型的状态不会像文件一样被写入永久存储。当应用恢复时,如果这种状态恢复到初始状态,用户将不会太不安。在回调中我们将很快解释叫做【on pause()你可以把必须持久化的状态保存到长期存储器中。如果发生这种情况,您可以使用 onCreate() 方法来加载该状态以及启动的一部分。

这种方法还需要考虑另一个因素。当由于方向改变而重新启动或重新创建一个活动时,旧的活动被销毁,并在其位置创建一个新的活动。这意味着新的活动在内存中有了新的引用。旧的活动参考不再有效。让一个外部线程或全局对象抓住旧的活动不放是错误的。因此,当活动被重新创建时,需要有一种机制来告诉外部对象有一个新的活动引用。为此,重新创建的活动需要知道外部对象的引用。这个外部对象引用被称为“非配置实例引用”有一个回调方法叫做 onRetainNonConfigurationInstance()可以返回对这个外部对象的引用;我们很快会谈到这一点。Android SDK 随后保留这个引用,并通过一个名为 getlastonconfigurationinstance()的方法使其可用于重新创建的活动。请注意,在第八章中,我们将向您展示如何通过所谓的无头保留片段更好地做到这一点。我们将在关于 AsyncTask 的第十五章中回到这个话题。

onCreate 方法还有另一个细微差别。你可能想要确保在布局中你有正确的视图和片段(你将在第八章中学习)来匹配状态被保存的时间。因为随后的 onRestoreInstanceState()(在 onStart() 之后调用)假设所有的视图和片段层次结构都存在以恢复它们各自的状态,仅仅存在先前的状态不会重新创建视图。因此,由这个方法来加载要显示的正确布局。如果您在与活动交互的过程中没有删除或添加视图,这通常不是问题。

void onStart()

创建后,此方法将活动推入可见状态。换句话说,这个方法启动了活动的“可见生命周期”。这个方法在 onCreate() 之后被调用。这个方法假设视图层次结构已经从 onCreate() 加载并可用。你通常不需要重写这个方法,如果你这样做了,确保你首先调用父节点的 onStart() 。在图 2-2 中,注意这个方法也可以从另一个叫做 onRestart 的回调中调用。

你必须知道 onRestoreInstanceState 方法是在这个方法之后调用的。因此,您不应该对这种方法中的视图状态做出假设。所以尽量不要在这个方法中操纵视图的状态。在随后的 on restorestancestate 或 onResume 方法中进行细化。因为这是 onStop() 的对应物,所以如果您已经在 onStop() 或 onPause() 中停止了某个东西,请执行相反的操作。如果你看到一些事情正在用这种方法来做,要谨慎地看待它,确保它是你想要的。还要知道,在活动的整个当前周期中,开始和停止周期可能会发生多次。

当活动先隐藏后显示时,也可以调用此方法,因为另一个活动已经到了可见性堆栈的顶部。在那些情况下,这个方法在 onRestart() 之后被调用,它本身在 onStop() 之后被触发。所以这个方法有两条路径:要么是 onCreate() 要么是 onRestart() 。在这两种情况下,视图层次结构都应该在回调之前建立并可用。

参见 onrestoreinstallationstate(bundle savedinstancestate)

如果用户合法地关闭一个活动,那么用户愿意放弃的状态就是实例状态。例如,当用户选择一个后退按钮时,他/她就在通知 Android 他/她对这个活动不再感兴趣,这个活动可以被关闭,放弃所有尚未保存的状态。因此,这种状态是短暂的,并且只在活动存在于内存中时才有效,这就是实例状态。

如果系统选择关闭活动,因为方向发生了变化,那么当活动重新开始时,用户将会期望返回到临时(实例)状态。为了方便起见,Android 调用这个 onRestoreInstanceState 方法,其中包含保存的实例状态。(参见 onSavedInstanceState 方法解释。)

与实例状态相反,活动的持久状态是用户希望看到的,即使在活动结束并且不再运行之后。这种持久性状态可能是在活动过程中创建的,甚至可能是在活动创建之前就存在了。这种类型的状态,尤其是在活动的帮助下创建的状态,必须像文件一样显式保存到外部持久性存储中。如果活动没有使用显式的“保存”按钮来满足这种需求,那么就需要使用“ onPause 方法来保存这种隐式的持久状态。这是因为在内存不足的情况下,不能保证调用之后的任何方法。如果信息太重要而不能丢失,就不应该依赖实例状态。

参见 onResume()

Resume 上的回调方法是活动完全可见的前身。这也是活动前台周期的开始。在这个前台循环中,活动可以在 onResume() 和 onPause() 之间移动多次,因为其他更紧急的活动、通知或对话会优先进行。

当这个方法被调用时,我们可以期待视图和它们的状态被完全恢复。您可以借此机会调整最终状态更改。由于这个方法没有捆绑包,如果需要的话,你需要依靠来自 onCreate 或 onRestoreInstanceState 方法的信息来微调状态。

如果您在期间因为停止了任何计数器或动画,您可以在此重新启动它们。您还可以跟踪视图是否真的被破坏的情况,方法是遵循前面的回调方法(无论 onResume 是由 onCreate 、 onRestart 还是 onPause )并尽可能最小地调整视图状态。通常情况下,您不会在这里进行状态管理,而只是那些需要根据可见性打开或关闭的任务。

参见 on case()

该回调表示活动即将进入后台。当活动完全可见时,您应该停止任何正在运行的计数器或动画。活动可能会在结束时进行到或者在结束时进行到。前往的结果将把活动带到前台。转到 onStop 将使活动进入后台状态。

根据 SDK,它也是在活动和流程被完全回收之前保证被调用的最后一个方法。所以这是开发人员将任何非实例和持久数据保存到文件中的最后机会。

Android SDK 也在使前台活动完全活动之前等待该方法返回。所以你想在这个方法中简洁。还要注意,这个方法没有传递任何包。这表明该方法用于存储持久数据,并且也存储在诸如文件或网络的外部存储介质中。

您还可以使用此方法停止后台任务的任何计数器、动画或状态显示。您可以在 onResume 中继续。

void onStop()

回调方法 onStop() 将活动从部分可见状态转移到后台状态,同时保持所有视图层次结构不变。这是 onStart 的翻版。通过调用 onStart 可以将活动带回到可视循环。在同一个活动生命周期中,从 onStop 到 onStart 的状态转换通过 onRestart() 方法完成。

调用后,活动不再可见。但是请记住,在低内存条件下,这可能不会在之后调用,因为。由于这种不确定性,不要使用此方法来启动或停止此进程之外的服务。因为而在做那件事,结果在继续。但是,您可以使用此方法来控制流程内部的服务或工作。这是因为,只要进程是活动的,这个方法就会被调用。如果整个过程都停止了,那么那些相关的任务或全局变量无论如何都会消失。

void onSaveInstanceState(Bundle saveStateBundle)

如果进程仍在内存中,则控制转到 onDestroy() 从 onStop 出来。然而,如果 Android 意识到活动在没有用户预期的情况下被关闭,那么它会在调用 onDestroy() 之前调用 onSaveInstanceState() 。方向改变是一个非常具体的例子。SDK 警告说 onSaveInstanceState() 的时序是在 onStop() 之前还是之后是不可预测的。

此方法的默认实现已经保存了视图的状态。但是,如果有一些视图不知道的显式状态,您需要将它保存在 bundle 对象中,并在 onRestoreInstanceState 方法中检索它。您确实需要首先调用父视图的 onSaveInstanceState() 方法,以便视图有机会自己保存它们的状态。视图保存状态有一些限制和规则。关于 UI 控件的章节(第三章、第四章和第五章)和配置更改的章节(第九章)更详细地介绍了这个主题。

参见 onRestart()

当活动从后台状态过渡到部分可见状态时,即从 onStop 到 onStart 时,调用该方法。如果你想基于是重新开始还是重启来优化代码,你可以在 onStart 中使用这些知识。当它重新启动时,视图和它们的状态相当完整。

你可以用这种方法做一些在 onStart 中已经完成的事情,但是当活动不可见时进行优化,但是在 resume 中重复做太昂贵了。

对象 on retain no configuration instance()??]

这个回调方法用于处理由于配置更改而导致的活动重新创建。该方法返回进程内存中的一个对象引用,该对象引用需要在活动重新创建后重新分配给活动。我们之前在描述 onCreate 方法时已经详细解释过了。

当重新创建活动时,从该方法返回的对象通过方法 getlastonconfigurationinstance()变得可用。现在在 onCreate() 中,新的活动可以使用先前建立的资源和对象引用。重要的是,如果那些先前的资源保持旧的活动引用,那么资源可以被告知使用新的。

这种困境的存在是因为在方向改变时,Android 不会终止进程,而是丢弃旧的活动,在新的方向上重新创建活动,并期望程序员提供新的布局,等等。,以适应新的配置。所以工作对象仍然在那里保持一个旧的活动。这是与它的“get”对应物相关联的克服这一障碍的方法。

当你阅读第八章时,你将会了解到这种方法已被废弃,你将会在它的位置使用所谓的无头保留片段。这些无头的保留片段的额外好处是能够跟踪活动的生命周期,而不仅仅是对活动的引用。

void ondstroy()

onDestroy() 是 onCreate() 的对应物。活动将在晚会结束后结束。一个活动可能因为两个主要原因而结束。

一个是明确的结束。当用户通过单击用于指示用户已完成的按钮,或者通过使用后退按钮离开活动转到上一个活动,而明确地使活动完成时,就会发生这种情况。在这种情况下,除非用户再次选择该活动,否则系统不会恢复该活动。在这个场景中,活动生命周期以 onDestroy 方法结束。

活动可以结束的第二个原因是非自愿的。当设备的方向改变时,Android SDK 将强制关闭活动并调用 onDestroy 方法,然后重新创建活动并再次调用 onCreate 。

当一个活动在后台时,如果系统需要内存,Android 可能会关闭该进程,并且可能没有机会调用 onDestroy 方法。由于这种不确定性,就像 onStop 一样,不要使用这种方法来控制活动运行的流程之外的任务或服务。但是,如果进程仍然在内存中,onDestroy 将作为生命周期的一部分被调用,只要代码属于该进程,您就可以将清理代码放在 onDestroy 中。

活动回访的一般说明

使用图 2-3 来指导您了解这些回调的顺序以及如何最好地使用它们。如果您要覆盖回调,您需要回调父方法。SDK 文档明确指出需要哪些派生方法来回调它们的父对等方法。还可以参考 SDK 文档来了解在哪些回调过程中,系统不会因为内存不足而终止进程。还要注意,只有少数回调带有实例状态包。

更多关于资源的信息

我们想告诉你更多关于 Android 应用如何使用资源的信息。在计算器布局文件中,您已经看到了一些使用的资源,如字符串、图像、id 等。

其他不太明显的资源包括维度、drawables、字符串数组、复数语言术语、xml 文件和所有类型的输入文件。在 Android 中,某些东西被视为资源 a)如果它是程序的输入,并且是 apk 文件的一部分,b)如果输入的值或内容可以基于语言、地区或设备的方向而具有不同的值,通常称为配置更改。

资源的目录结构

Android 中的所有资源都放在你的应用包根目录的 /res 子目录下。清单 2-10 展示了一个 /res 可能的样子:

清单 2-10 。Android 资源和资产目录结构

/res/values/strings.xml
           /colors.xml
           /dimens.xml
           /attrs.xml
           /styles.xml
    /drawable/*.png
             /*.jpg
             /*.gif
             /*.9.png
             /*.xml
    /anim/*.xml`
    /layout/*.xml
    /raw/*.*
    /xml/*.xml
/assets/*.*/*.*

我们将在第七章的中介绍 attrs.xml 和 styles.xml 。 anim 子目录中的 xml 文件定义了可应用于各种视图的动画。我们将在动画章节(第十八章)中介绍这些动画相关的资源。xml 子目录中的 xml 文件被编译成二进制文件,可以使用它们的资源 id 来读取它们。我们将很快展示一个这样的例子。/ raw 子目录保存被放置的文件,它们没有被转换成任何二进制格式。

/assets 目录是 /res 的同级,它不是资源层次结构的一部分。这意味着该子目录中的文件不会因语言或区域设置而改变。Android 不会为这些文件生成任何 id。这个目录更像是任何用作输入的文件的静态本地存储,比如应用的配置文件。

除了资产目录之外, /res 子目录中的所有其他工件最终都会在 R.* 名称空间中生成一个 ID,就像您之前看到的那样。每个不同的资源类型在 R.* 下都有自己的名称空间,如 R.id 、 R.string 或 R.drawable 等。

从 Java 代码中读取资源

在布局文件中,正如您在计算器布局中看到的,一个资源可以引用其他资源。例如,计算器布局资源文件引用了字符串和颜色引用。这种方法很常见。或者,您也可以使用 Java API 通过方法 activity . get resources()来检索资源值。该方法返回对 Android SDK java 类资源的引用。您可以使用这个类上的方法来获取本地 R.* 名称空间中标识的每个资源的值。清单 2-11 展示了这种方法的一个例子:

清单 2-11 。读取 Java 代码中的资源值

Resources res = activity.getResources();
//Retrieving a color resource
int somecolor  = res.getColor(R.color.main_back_ground_color);
// Using a drawable resource
ColorDrawable redDrawable=(ColorDrawable)res.getDrawable(R.drawable.red_rectangle);

可绘制资源的运行时行为

drawable 目录是一个有趣的案例,值得报道,以展示 Android 架构的流畅性。如前所述,这个目录可以包含可以设置为背景的图像。这个目录还允许 XML 文件知道如何被转换成可绘制的 java 对象,这些对象可以在运行时被用作背景。清单 2-12 显示了一个这样的例子:

清单 2-12 。 一个形状可绘制的 XML 资源文件的例子

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:shape="rectangle">
    <solid android:color="#f0600000"/>
    <stroke android:width="3dp" android:color="#ffff8080"/>
    <corners android:radius="13dp" />
    <padding android:left="10dp" android:top="10dp"
        android:right="10dp" android:bottom="10dp" />
</shape>

如果将这样的文件放在 drawable 子目录中,并将其命名为 background1.xml ,就会产生一个名为 R.drawable.background1 的 ID。然后,您可以使用该 ID,就像它是用矩形边框绘制的任何视图的背景图像一样。其他可能的形状有椭圆形、直线形和环形。

与 shape xml 文件类似,drawing able 目录中的每个允许的 xml 文件都定义了一个 drawing able,它定义了一种特定的绘制方式。这些可绘制项的示例包括可以用某些行为来装饰的位图、或者可以从一个图像过渡到另一个图像的图像、作为其他可绘制项的集合的分层可绘制项、可以基于输入参数来选择的可绘制项、可以通过显示多个图像来响应进度的可绘制项、可以剪辑其他可绘制项的可绘制项等...有关使用这些运行时可绘制对象可以执行的许多复杂操作,请参见以下 URL:

[`androidbook.com/item/4236`](http://androidbook.com/item/4236)

使用任意 XML 文件作为资源

Android 还允许将任意 XML 文件用作资源,然后可以针对每个设备进行本地化或调整。清单 2-13 是从 /res/xml 子目录中读取和处理基于 XML 的资源文件的例子。

清单 2-13 。读取 XML 资源文件

private String readAnXMLFile(Activity activity) throws XmlPullParserException, IOException {
   StringBuffer sb = new StringBuffer();
   Resources res = activity.getResources();
   XmlResourceParser xpp = res.getXml(R.xml.test);

   xpp.next();
   int eventType = xpp.getEventType();
    while (eventType != XmlPullParser.END_DOCUMENT) {
        if(eventType == XmlPullParser.START_DOCUMENT) {
           sb.append("******Start document");
        }
        else if(eventType == XmlPullParser.START_TAG)  {
           sb.append("\nStart tag "+xpp.getName());
        }
        else if(eventType == XmlPullParser.END_TAG) {
           sb.append("\nEnd tag "+xpp.getName());
        }
        else if(eventType == XmlPullParser.TEXT) {
           sb.append("\nText "+xpp.getText());
        }
        eventType = xpp.next();
    }//eof-while
    sb.append("\n******End document");
    return sb.toString();
}//eof-function

使用原始资源文件

Android 也允许任何类型的非编译文件作为资源。清单 2-14 是一个读取放置在 /res/raw 子目录中的文件的例子。作为一种资源,甚至这个目录中的原始文件也可以针对语言或设备配置进行定制。Android 也为这些文件自动生成 id,因为它们和其他资源一样是资源。

清单 2-14 。?? 读取原始资源文件

String getStringFromRawFile(Activity activity) throws IOException {
      Resources r = activity.getResources();
      InputStream is = r.openRawResource(R.raw.test);
      //assuming you have a function to convert a stream to a string
      String myText = convertStreamToString(is);
      is.close(); //take care of exceptions etc.
      return myText;
}

从资产目录中读取文件

尽管通常与资源放在一起, /assets 目录有点不同。这个目录不在 /res 路径下,所以这个目录中的文件的行为不像资源文件。Android 不会在 R.* 名称空间中为这些文件生成资源 id。这些文件不能基于区域设置或设备配置进行自定义。清单 2-15 显示了一个读取放置在 /assets 子目录中的文件的例子。

清单 2-15 。?? 从资产目录中读取文件

String getStringFromAssetFile(Activity activity) {
    AssetManager am = activity.getAssets();
    InputStream is = am.open("test.txt");
    String s = convertStreamToString(is);
    is.close();
    return s;
}

到目前为止,我们已经使用了一个活动引用来获取资源或一个资产管理器对象,如清单 2-15 所示。实际上,我们需要的只是活动的基类,即上下文对象。

阅读没有活动参考的资源和资产

有时,您可能需要从源代码内部读取 XML 资源文件或资产文件,在这种情况下传递活动引用会造成干扰。对于这些情况,您可以使用下面的方法来获取应用上下文,然后使用该引用来获取资产和资源。

当 Android 加载您的应用时(为了调用它的任何组件),它实例化并调用一个应用对象来通知应用可以初始化自己。这个应用类名在 Android 清单文件中指定。如果 MyApplication.java 是你的应用 java 类,那么它可以在 Android 清单文件中指定,如清单 2-16 所示。

清单 2-16 。 在清单文件中指定应用类

<application android:name=".MyApplication"
        android:icon="@drawable/icon" .../>

清单 2-17 展示了我们如何编写我的应用,也展示了我们如何在一个全局变量中捕获应用上下文。

清单 2-17 。 捕获应用上下文的应用示例代码

public class MyApplication extends Application {
   //Make sure to check for null for this variable
   public static volatile Context s_appContext = null;

   @Override
   public void onConfigurationChanged(Configuration newConfig) {
      super.onConfigurationChanged(newConfig);
   }
   @Override
   public void onCreate() {
      super.onCreate();
      MyApplication.s_appContext = this.getApplicationContext();
   }
   @Override
   public void onLowMemory() {
      super.onLowMemory();
   }
   @Override
   public void onTerminate() {
      super.onTerminate();
   }
}

有了在全局变量中捕获的应用上下文,我们现在可以访问资产管理器来读取我们的资产,如清单 2-18 中的所示。

清单 2-18 。 使用应用对象来获取应用资产文件

AssetManager am = MyApplication.s_appContext.getAssets();
InputStream is = am.open(filename);

了解资源目录、语言和区域设置

让我们总结一下 Android resources 的概念,指出资源目录是如何根据语言、地区或设备的配置变化(比如方位)来加载资源的。查看清单 2-19 中的如何将一个同名的布局文件放置在多个布局目录中,这些布局目录以相同的布局前缀开始,但具有不同的限定符,如用于纵向的“端口”和用于横向的“陆地”。SDK 文档中有大量这样的限定符。我们还将在第九章中讨论这些方面(配置变更)。清单 2-19 显示了一个如何通过纵向或横向配置排列布局文件的例子:

清单 2-19 。 演示资源限定符

\res\layout\main_layout.xml
\res\layout-port\main_layout.xml
\res\layout-land\main_layout.xml

更多关于意图

我们已经讨论了如何使用意图来调用活动。我们现在想涵盖更多的基本方面的意图。清单 2-20 显示了 intents 是如何被用来调用一些预先构建的 Google 应用的。

清单 2-20 。 示例代码使用意图

public class IntentsUtils {
    public static void invokeWebBrowser(Activity activity)    {
       Intent intent = new Intent(Intent.ACTION_VIEW);
       intent.setData(Uri.parse("[`www.google.com`](http://www.google.com)"));
       activity.startActivity(intent);
    }
    public static void invokeWebSearch(Activity activity)    {
       Intent intent = new Intent(Intent.ACTION_WEB_SEARCH);
       intent.setData(Uri.parse("[`www.google.com`](http://www.google.com)"));
       activity.startActivity(intent);
    }
    public static void dial(Activity activity)    {
       Intent intent = new Intent(Intent.ACTION_DIAL);
       activity.startActivity(intent);
    }
    public static void call(Activity activity)    {
       Intent intent = new Intent(Intent.ACTION_CALL);
       intent.setData(Uri.parse("tel:555–555–5555"));
       activity.startActivity(intent);
    }
    public static void showMapAtLatLong(Activity activity)     {
       Intent intent = new Intent(Intent.ACTION_VIEW);
       //geo:lat,long?z=zoomlevel&q=question-string
       intent.setData(Uri.parse("geo:0,0?z=4&q=business+near+city"));
       activity.startActivity(intent);
    }
}

请注意,这些意图不是通过类名调用特定的活动,而是使用合适活动的目标质量。例如,调用浏览器来查看网页,意图简单地说动作是 ACTION_VIEW ,并且意图的数据部分被设置为网址。Android 然后环顾四周,查看所有知道如何在数据属性中显示所请求的数据类型的活动。然后,它将为用户提供一个选项,用户希望选择哪个活动来打开 URL。这些不指定要调用的组件的类名的意图被称为隐式意图。我们稍后会对此进行更详细的介绍。

开始结果活动

清单 2-21 显示了一个活动的例子,其中它的一个方法正在调用一个目标活动,以便在该目标活动完成时获得一个结果。这是通过清单 2-21 中的 invokePick() 方法完成的。

清单 2-21 。 利用意图从活动中获得结果

public class SomeActivity extends Activity {
.....
//Call this method to start a target activity that knows how to pick a note
//Use a data URI that tells the target activity which list of notes to show
public static void invokePick(Activity activity) {
  Intent pickIntent = new Intent(Intent.ACTION_PICK);
  int requestCode = 1;
  pickIntent.setData(Uri.parse(
     "content://com.google.provider.NotePad/notes"));
  activity.startActivityForResult(pickIntent, requestCode);
}

//the following method will be called when the target activity finishes
//Notice the outputIntent object that is passed back which could
//contain additional information

@Override
protected void
onActivityResult(int requestCode,int resultCode, Intent outputIntent) {
   super.onActivityResult(requestCode, resultCode, outputIntent);
   parseResult(this, requestCode, resultCode, outputIntent);
}
public static void parseResult(Activity activity
    , int requestCode, int resultCode , Intent outputIntent)
{
    if (requestCode != 1)  {
     Log.d("Test", "Someone else called this. not us");
     return;
    }
    if (resultCode != Activity.RESULT_OK)  {
      Log.d("Test", "Result code is not ok:" + resultCode);
               return;
    }
    Log.d("Test", "Result code is ok:" + resultCode);
    Uri selectedUri = outputIntent.getData();
    Log.d("Test", "The output uri:" + selectedUri.toString());

    //Proceed to display the note
    outputIntent.setAction(Intent.ACTION_VIEW);
    startActivity(outputIntent);
}

常量 RESULT_OK 、RESULT _ cancelled 和 RESULT_FIRST_USER 都在 activity 类中定义。常量 RESULT_FIRST_USER 用作用户定义的“活动结果”的起始编号。这些常数的数值如列表 2-22 所示:

清单 2-22 。 返回活动的结果值

RESULT_OK = -1;
RESULT_CANCELED = 0;
RESULT_FIRST_USER = 1;

为了使 PICK 功能起作用,正在响应的实现或目标活动应该有明确满足 ACTION_PICK 需求的代码。让我们看一个例子,看看在 Google sample NotePad 应用中是如何做到这一点的。(参见参考资料部分,在那里可以找到这个应用。)当项目列表中的项目被选中时,调用目标活动的意图被检查以查看它是否是一个 ACTION_PICK 意图。如果是,则所选注释项的数据 URI 被设置为新的 intent,并通过 setResult() 返回,如清单 2-23 所示。然后,调用活动可以调查返回的意图,看看其中有什么数据。参见清单 2-21 中的方法 parseResult() 。

清单 2-23 。 目标活动通过数据 URI 返回结果

@Override
protected void onListItemClick(ListView l, View v, int position, long id) {
    Uri uri = ContentUris.withAppendedId(getIntent().getData(), id);

    String action = getIntent().getAction();
    if (Intent.ACTION_PICK.equals(action) ||
              Intent.ACTION_GET_CONTENT.equals(action))    {
        // The caller is waiting for us to return a note selected by
        // the user.  They have clicked on one, so return it now.
        setResult(RESULT_OK, new Intent().setData(uri));
        finish();
    }
    ...other ways of how this activity may have been invoked
}

执行 GET_CONTENT 操作

ACTION_GET_CONTENT 类似于 ACTION_PICK 。在 ACTION_PICK 的情况下,您指定了一个数据 URI,它指向一个项目集合,就像一个类似记事本的应用中的笔记列表。您将期望 intent 操作选取其中一个音符并将其返回给调用者。在 ACTION_GET_CONTENT 的情况下,你向 Android 表明你需要一个特定 MIME 类型的项目。Android 搜索可以创建这些项目之一的活动,或者从满足该类型的现有项目集中进行选择的活动。

使用 ACTION_GET_CONTENT ,您可以使用清单 2-24 中所示的代码从记事本应用支持的笔记集合中选择一个笔记:

清单 2-24 。 调用活动创建内容

public static void invokeGetContent(Activity activity) {
      Intent pickIntent = new Intent(Intent.ACTION_GET_CONTENT);
      int requestCode = 2;
      pickIntent.setType("vnd.android.cursor.item/vnd.google.note");
      activity.startActivityForResult(pickIntent, requestCode);
}

注意意图类型是如何被设置为单个注释的 MIME 类型的。与此形成对比的是 ACTION_PICK 代码,它明确指出了一个指向笔记集合的 URL(就像一个可以检索一页数据的 web URL)。

对于响应 ACTION_GET_CONTENT 的活动,该活动必须注册一个意图过滤器,表明该活动可以提供一个 MIME 类型的项目。清单 2-25 展示了 SDK 的记事本应用是如何完成的:

清单 2-25 。 活动过滤器获取内容

<activity android:name="NotesList" android:label="@string/title_notes_list">
......
<intent-filter>
    <action android:name="android.intent.action.GET_CONTENT" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="vnd.android.cursor.item/vnd.google.note" />
      </intent-filter>
......
</activity>

响应 onActivityResult() 的其余代码与前面的 ACTION_PICK 示例相同。如果有多个活动可以返回相同的 MIME 类型,Android 会显示选择器对话框让你选择一个活动。

相关意图和活动

intent 不仅用于启动活动,还用于启动其他组件,如服务或广播接收器。这些组件将在后面的章节中介绍。您可以看到这些组件具有某些属性。组件的一个属性可以是该组件所属的类别。另一个属性可以是该组件可以查看、编辑、更新或删除什么类型的数据。另一个属性可以是组件可以响应什么类型的动作。如果您将这些组件视为数据库中的实体,那么它们的属性可以被视为列。那么意图可以被看作是一个 where 子句,它指定了选择一个组件(如要启动的活动)的所有或部分特征。清单 2-26 是一个展示如何查询所有被归类为 CATEGORY_LAUNCHER 的活动的例子。

清单 2-26 。 查询符合意向的活动

Intent mainIntent = new Intent(Intent.ACTION_MAIN, null);
mainIntent.addCategory(Intent.CATEGORY_LAUNCHER);
PackageManager pm = getPackageManager();
List<ResolveInfo> list = pm.queryIntentActivities(mainIntent, 0);

PackageManager 是一个关键类,它允许您在不调用活动的情况下发现符合特定意图的活动。基于 ResolveInfo API,您可以遍历收到的活动,并在您认为合适的时候调用它们。清单 2-27 是前面代码的扩展,它遍历活动列表,如果匹配某个名称就调用其中一个活动。在代码中,我们使用了一个任意的名称来测试它:

清单 2-27 。 遍历匹配的活动列表,寻找意向

for(ResolveInfo ri: list) {
    //ri.activityInfo.
    Log.d("test",ri.toString());
    String packagename = ri.activityInfo.packageName;
    String classname = ri.activityInfo.name;
    Log.d("test", packagename + ":" + classname);
    if (classname.equals("com.ai.androidbook.resources.TestActivity")) {
        Intent ni = new Intent();
        ni.setClassName(packagename,classname);
        activity.startActivity(ni);
    }
}

理解显性和隐性意图

当您在一个意图中指定一个显式的活动名称(或一个组件名称,如服务或广播接收器)时,这样的意图被称为显式意图。当这个意图用于启动一个活动时,该活动被调用,而不管该意图中还有什么,比如它的类别或数据。

正如您所看到的,一个意图不一定要有一个明确指定的活动来调用它。意图可以依赖于活动的动作属性、类别属性或数据属性。这些省略了显式活动或组件类的意图被称为隐式意图。当您使用隐式意图来调用活动时,活动必须将 CATEGORY_DEFAULT 作为其类别之一,这一点非常重要。如果您希望您的活动明确地由一个意图开始,那么您根本不需要为该活动指定任何类别。清单 2-28 展示了一个在 Android 清单文件中最小化注册一个活动的例子,这样它就可以被一个明确的意图调用。

清单 2-28 。 最小活动定义

<activity android:name="com.androidbook.asynctask.TestProgressBarDriverActivity"
      android:label="Test Progress bars"/>

如果你想通过一个隐含的意图来调用这个活动,而不指定它的类名,比如通过一个动作,那么你需要添加下面的意图过滤器,一个用于动作,一个用于所需的默认强制类别,如清单 2-29 所示。

清单 2-29 。 一个带有过滤器的活动定义

<activity android:name="com.androidbook.asynctask.TestProgressBarDriverActivity"
       android:label="Test Progress bars">
      <intent-filter>
           <action android:name="com.androidbook.intent.action.ME" />
           <category android:name="android.intent.category.DEFAULT" />
     </intent-filter>
</activity>

在 Android 中保存状态

当您查看计算器应用时,您的下一个需求可能是如何存储 Android 应用的数据。让我们简单介绍一下可用的选项。Android 中存储数据的方式有五种:1)共享首选项,2)内部文件,3)外部文件,4) SQLlite ,5)云端网络存储。

Shared preferences API 是 Android SDK 中一个复杂的 API,用于保存、显示和操作应用的首选项。尽管这个特性是为偏好设计和定制的,但它也可以用来保存应用的任意状态。共享偏好设置是应用和设备内部的。Android 不会将这些数据提供给其他应用。用户不希望通过安装到 USB 端口上来直接操作这些数据。当应用被删除时,该数据被自动删除。这些共享偏好在第十一章中有详细介绍。

虽然共享首选项数据是结构化的键/值对数据,并遵循一些其他强加的语义,但内部文件是独立的文件,您可以在没有预定义结构的情况下写入。我们还没有发现使用内部文件优于共享首选项或其他方式的令人信服的优势,特别是对于中小型国家。因此,对于大多数应用,你可以选择其中之一。

与存储在设备内部存储器上的内部文件不同,外部文件存储在 SD 卡上。这些成为公共文件,其他应用包括用户可以在你的应用环境之外看到。外部文件可用于存储即使在应用之外也有意义的数据,如图像文件或视频文件。对于严格意义上的应用内部状态,内部文件是更好的选择。

如果状态非常大,达到几十兆字节,外部文件也是一个选项。通常,当这种情况发生时,您无论如何都不希望将状态保存为一个整体文件,而是选择更细粒度的存储,如 SQLlite 这样的关系数据库。

我们将在第二十五章中给出一个关于如何使用偏好、内部文件和外部文件来存储你的应用状态的快速概述和简短代码示例。技巧之一是直接使用 JSON 和 GSOn 持久化 java 对象树,同时考虑这种粒度级别是否合适。如果您不熟悉 JSON,它是基于 JavaScript 的对象的对象传输和存储格式。它通常也适用于任何对象结构,包括 java 对象,最近经常这样使用。GSON 是一个 Google 库,它将 Java 对象与 JSON 字符串相互转换。

SQLlite 是一个非常好的选项,推荐用来存储应用的状态。缺点是保存和读取数据的逻辑变得冗长和麻烦。您也许可以使用 O/R 映射库来克服 java 对象及其关系表示之间的这种不匹配。SQLlite 还经常用于存储需要由多个应用通过称为内容提供者的概念共享的数据。这是第二十五章的中心话题。

最后,基于云的网络存储也开始崭露头角。例如,parse.com 等许多 MBAAS(移动后端即服务)平台支持将移动数据直接存储在云中,供在线和离线使用。随着你开始在多个设备上为同一个用户提供应用,或者能够与其他用户合作,这种模式将变得越来越重要。这个话题在我们的伙伴书《Android 专家》中有详细的介绍。

很多时候,对于你的应用来说,GSON 选项将应用状态存储在一个内部文件中确实是最快、最实用的方法。当然,您确实想分析解决方案的粒度,看看这种更简单的方法是否不会成为计算能力或电池寿命的负担。如果你的应用很受欢迎,你可能想通过优化存储速度来使用 SQLlite 的第二个版本,或者使用云存储,如果这更适合那个版本的话。

学习 Android 的路线图和本书的其余部分

让我们快速回顾一下到目前为止我们已经学过的内容。在这个单页应用中,您已经看到了 UI 是如何组装的,业务逻辑是如何用 Java 编写的,以及如何使用 Android manifest 文件将应用定义到 Android sdk。我们解释了什么是资源,它们如何相互引用,它们如何在布局文件中被引用,甚至如何将输入文件作为资源读取。我们已经向您展示了什么是意图,它们的复杂性,以及如何使用它们来调用或发现活动。我们已经涵盖了活动的生命周期,这对理解 Android 架构非常重要。我们还简要介绍了如何保存应用的状态。这是计划和编写简单应用的良好基础。

现在,我们希望通过成为 Android 平台上的专业应用开发人员的路线图来跟进这个 Android 应用的鸟瞰图。该路线图将本书的章节分为以下六个主要学习方向:

  • 途径 Android 应用的用户界面基础
  • 路线 2:保存状态
  • 路线 3:准备/将您的应用带到 Google Play
  • 路线 4:使您的应用健壮
  • 路线 5:为你的应用带来技巧
  • 路线 6:与其他设备和云集成

在这六个方面中,前三个是你必须了解的基本方面,以编写对你和更大的社区有用的 Android 应用。路线 4、5 和 6 旨在使您的应用变得更好,并在后续版本中提供丰富的功能。我们将讨论每个专题由哪些章节组成,以及您期望从该专题中获得什么。

途径 Android 应用的 UI 要点

Android 有许多现成的 UI 控件和布局来编写功能丰富的应用。例如按钮、各种文本视图、编辑文本控件、复选框、单选按钮、日期和时间控件、列表控件、显示模拟和数字时钟的控件、显示图像和视频的控件、选择数字的控件等。我们将在第三章 的 中讨论其中的一些。在那一章中,我们还将介绍从这些控件中组合 UI 所需的基本布局。

一旦你能够使用基本的控件来构建你的 UI,你的应用中绝对需要的一个控件就是列表控件。我们没有把列表控件作为一个基本控件来介绍,因为它有点复杂。此外,Android 有许多功能和方法来处理基于列表的应用。因此,我们为列表控件和填充这些列表控件所必需的数据适配器专门写了一章。这些方面都涵盖在 第四章 中。

一旦你掌握了基本的控件、基本的布局和列表控件,你将开始寻找更复杂的布局,比如网格和表格布局。这些将在“使用高级布局”下的第五章 中介绍

菜单见第六章。Android 的菜单基础设施包括上下文菜单、弹出菜单、动作栏中的选项图标等。

你的移动应用如果不通过样式化来提炼就不是真正完整的,就像 CSS 一样。 第七章 讲述了 Android 中的风格和主题是如何工作的。

对话框在任何用户界面中都是必不可少的。Android 中的对话框有点复杂。要理解 Android 中的对话框,你必须首先理解片段的概念。对话的架构只是片段的一个方面。片段现在是 Android UI 的核心。 第八章 解释了什么是片段并在 第十章 我们盖对话框,盖在第八章之上。

在移动应用中,如果不了解设备方向改变时应用会发生什么变化,就无法编写应用。在 Android 中,为改变方向而正确编程并不容易。如何为方向和其他器件配置更改编程在第九章的和章节中有所介绍。

对于任何合理有用的应用,您可能需要了解所有这些 UI 要素。所以轨道 1 是一个重要的轨道。

路线 2:保存状态

一旦你知道了如何构造你的应用的 UI,下一步你需要做的就是保存你的应用的状态。请参考前面关于保存状态的部分,以了解哪些选项可用以及在哪些章节中介绍了这些选项。路线 2 也是一个重要的路线,因为你应该知道如何保存状态。

路线 3:准备/将您的应用推向市场

通过完成途径 1 和 2,您可以构建一个非常合理的应用,并将其部署到市场上。 第三十章 向你展示如何将你的应用带到 Google Play 商店。

路线 4:让您的应用变得健壮

Track 4 是深入 Android 内部的高级课程。你需要浏览本专题的章节,巩固你对 Android 工作原理的理解。我们从兼容性库上的第十二章 的 开始这个曲目。本章讲述了如何让你的应用在旧版本上运行良好,同时使用仅在新平台上可用的功能。

Android 允许你在你的应用中运行代码,即使你并没有在前台使用这个应用。它可能是你在后台播放的音乐,也可能是你把图片备份到云端,等等。这种类型的代码在 Android 中被称为服务。第十三章 中的 介绍了服务工作。这些服务可以通过直接的用户动作或通过警报或广播事件来触发。警报管理器包含在第十七章 中。****

当您使用意图来调用组件(如活动或服务)时,您的目标是单个组件。Android 还支持发布-订阅协议,在该协议中,可以使用一个意图来调用同时注册的多个组件。这些组件被称为接收器或广播接收器。广播接收器是您的应用中的一段代码,即使您的应用在事件发生时尚未启动或处于休眠状态,它也会执行以响应广播事件。如何使用广播接收器在第十六章 的 中介绍。

随着你开始使用 Android 越来越多的功能,如服务、广播接收器和内容供应器,你需要了解 Android 如何使用单个主线程来运行这些组件中的代码。这个线程模型在第十四章 的 中有详细介绍。了解这一点将有助于您编写健壮的代码。在本期节目中,你还将了解到非常有用的 AsyncTask ,它用于简化从主线程卸载工作。这个 API 经常在 UI 中使用,用来从 web 上读取消息或检查电子邮件等。 AsyncTask 包含在第十五章** 中。**

路线 5:为你的应用带来技巧

为了让你的应用看起来吸引人,你可以做的第一件事就是添加一点或很多动画。这在 第十八章 中有所涉及。基于触摸的界面现在是标准。通过拖放操作你的环境更加自然。你想利用传感器来编写与外部世界更好地集成的应用。这些触摸屏、拖放和传感器分别在 第二十二章、第二十三章和第二十四章 中介绍。

主屏幕部件是提取应用片段并使其在您选择的任何主屏幕上可用的绝佳方式。这种个性化功能,当以创新的方式使用时,会使与设备的交互变得简单而愉快。第二十一章 中的 小部件。

基于地图和位置的应用是为移动设备开发的。这个话题在 第十九章 中有所涉及。

你可以非常容易地将音频和视频整合到你的 Android 应用中。这个 API 包含在第二十章的 中。

路线 6:与其他设备和云集成

你可以使用谷歌云消息来联系你的移动应用的用户。谷歌云消息在 第二十九章 中有所涉及。通过 Android 中的 NFC 和蓝牙功能,您可以开始在应用中与物理环境进行交互。我们希望在这本书的在线伙伴上发布一些关于这些主题的材料。

最终曲目:获得专家机器人的帮助

现在,我们要谈论几个本书没有涉及的话题。如果您发现这些主题与您的需求相关,您可以考虑一下。其中大部分都是基于我们在 2014 年初通过 Apress 出版的关于 Android 专家的书的研究。

Android 有一个公共 API 来编写自定义组件,这些组件可以以不同于开箱即用的方式工作和行为。您可以编写自定义视图,在其中您可以控制绘制什么和如何绘制,然后可以与其他现成的控件(如按钮或文本控件)共存。您还可以将多个现有控件组合成一个复合控件,该复合控件可以像独立控件一样工作。您还可以设计符合您的显示需求的新布局。要很好地创建这些定制组件,需要很多技巧。你必须了解核心的 Android 视图架构。这一材料在来自 Apress 的专家 Android 书中有三章 100 页的内容。

如果你的应用是基于表单的,你需要写很多代码来验证表单输入。你真的需要一个框架来处理这个问题。专家 Android 有一章是关于创建一个小的表单处理框架的,这个框架非常有用,可以减少错误和你需要写的代码量。

MBAAS,移动后端即服务,是移动应用所需要的技术,现在已经非常普及。MBAAS 提供的功能包括用户登录、社交登录、用户管理、代表云中的用户保存数据、与用户通信、用户之间的协作等。在专家 Android 中,我们有多个章节致力于一个名为 Parse 的 MBAAS 平台。

OpenGL 在 Android 上已经取得了很大的进步,现在已经对新一代可编程 GPU 提供了实质性的支持。Android 支持 ES 2.0 已经有一段时间了。在专家 Android 中,我们有超过 100 页关于 OpenGL 的内容。我们从头开始解释所有的概念,而不需要参考外部书籍,尽管我们给出了关于 OpenGL 的大量参考书目。我们很好地介绍了 ES 2.0,并提供了结合 OpenGL 和常规视图的指导,为 3D 组件铺平了道路。

Android 的联合搜索协议非常强大,因为你可以用很多富有想象力的方式来使用它。专家 Android 全面探索了它的基本原理以及一些优化使用它的替代方法。

Android 为调试提供了越来越多的功能。这些话题在专家 Android 中有所涉及。手机最终是一个通话设备,尽管它的使用频率越来越低。我们也有一章是关于在专家 Android 中使用电话 API 的。

现在我们把这本书的其余部分留给你

最后,你可能想知道为什么你应该成为一名移动开发者。我们可以举出两个强有力的论据,其中一个以前从未有过。最常见的一种方式是成为 IT 组织的一员,从事移动编程工作。IT 机会正在增加,但还没有完全实现,这与 Web 编程范例出现时的情况不同。然而,我们预计这种需求会逐渐增加。

另一方面,迫在眉睫的令人兴奋的机会是你成为一个独立的应用发行商。你写的应用有销售渠道,这在软件行业是独一无二的。不是我们每个人都会成为 IT 组织中的明日之星。独立开发者之路为你提供了一条按照你自己的速度和方向成长的途径。运气和耐心甚至会让你变得富有。至少你可以在满足自己需求的同时给社会增加价值。

如果你决定进入 Android 移动编程领域,你应该准备好合适的硬件,让这种体验变得可以忍受。如果你要买一台 Windows 笔记本电脑,看看你能否买到一台至少 8G 内存、固态硬盘和相当快的处理器的笔记本电脑。预计花费约 1000 至 1500 美元。如果你买的是 Mac 笔记本电脑,类似的配置可能要花 2500 美元左右。一个好的快速配置对 Android 开发很重要。如果你是一个经验丰富的 Java 程序员,考虑到这笔投资和手中的这本书,如果你遵循这里列出的轨道,你可以在大约六个月内成为一个称职的移动 Android 应用开发人员。

参考

以下是本章所讨论主题的附加资源。

摘要

本章列出了使用 Android SDK 创建移动应用所需了解的一切。您已经看到了 UI 是如何构造的。你知道什么是活动。你知道活动生命周期的复杂性。你了解资源和意图。你知道如何拯救国家。最后,通过阅读总结本书其余部分的学习资料,您可以看到 Android SDK 的广度。我们希望前两章给你一个 Android SDK 开发工作的开端。

三、构建基本用户界面和使用控件

前一章向您介绍了 Android 中一些可用的 UI 元素的速成课程,以及如何快速地将它们组合起来创建计算器应用。虽然这很有趣,但我们希望你开始思考除了第二章中介绍的文本视图、编辑文本和按钮控件之外,Android 中还有哪些 UI 小部件。

在这一章中,我们将详细讨论用户界面和控件。我们将从讨论 Android 中 UI 开发的一般原理开始,然后我们将描述 Android SDK 附带的许多 UI 控件。这些是您将创建的界面的构建块。在接下来的章节中,我们还将讨论视图适配器和布局管理器,您将看到它们是如何建立在我们在本章中介绍的基本控件之上的。

到本章结束时,你将对 Android 工具集中的许多 UI 控件有一个坚实的理解,以及如何将 UI 控件布局到屏幕上并用数据填充它们。

Android 中的用户界面开发

Android 中的 UI 开发很有趣。好玩是因为相对容易。有了 Android,我们有了一个简单易懂的框架和有限的开箱即用控件。可用的屏幕区域通常是有限的——如果不是在平板电脑上,也是在手机上——这指导了 Android 控件的“简单电源”的基本哲学。Android 也承担了许多通常与设计和构建高质量 ui 相关的繁重工作。这一点,再加上用户通常想做一个特定的动作,让我们可以轻松地构建一个好的 UI 来交付一个好的用户体验。

Android SDK 附带了许多控件,您可以使用它们来为您的应用构建 ui。与其他 SDK 类似,Android SDK 提供了文本字段、按钮、列表、网格等等。此外,Android 提供了一组适用于移动设备的控件。

常见控件的核心是两个类: android.view.View 和 Android . view . view group。正如第一个类的名字所暗示的那样,视图类代表一个通用的视图对象。Android 中的常用控件最终扩展了视图类。视图组也是一个视图,但是它也包含其他视图。视图组是布局类列表的基类。Android 和 Swing 一样,使用布局的概念来管理控件在容器视图中的布局。正如我们将要看到的,使用布局可以让我们很容易地控制 ui 中控件的位置和方向。

你可以从多种方法中选择在 Android 中构建 ui。您可以完全用代码来构造 ui。也可以用 XML 定义 ui。您甚至可以将两者结合起来——用 XML 定义 UI,然后在代码中引用它并修改它。为了证明这一点,本章我们将使用这三种方法中的每一种来构建一个简单的 UI。

在我们开始之前,让我们定义一些术语。在本书和其他 Android 文献中,你会在关于 UI 开发的讨论中发现术语视图控件小部件容器布局。如果您是 Android 编程或 UI 开发的新手,您可能不熟悉这些术语。在我们开始之前,我们将简要描述它们(见表 3-1 )。

表 3-1 。 UI 术语表

|

学期

|

描述

|
| --- | --- |
| 视图、小部件、控件 | 每一个都代表一个 UI 元素。示例包括按钮、网格、列表、窗口、对话框等。术语视图小部件控件在本章中可以互换使用。 |
| 容器 | 这是一个用于包含其他视图的视图。例如,网格可以被视为一个容器,因为它包含单元格,每个单元格都是一个视图。 |
| 布局 | 这是容器和视图的可视化排列,可以包括其他布局。我们将在本章中讨论布局,并在第五章中全面探讨 Android 的布局特性。 |

图 3-1 显示了我们将要构建的应用的屏幕截图。屏幕截图旁边是应用中控件和容器的布局层次结构。

9781430246800_Fig03-01.jpg

图 3-1 。活动的用户界面和布局

在讨论示例程序时,我们将参考这个布局层次结构。现在,知道应用有一个活动。活动的 UI 由三个容器组成:包含人名的容器、包含地址的容器以及子容器的外部父容器。

完全用代码构建 UI

第一个例子,清单 3-1 ,演示了如何完全用代码构建 UI。为了尝试这一点,使用项目名控件,包名 com.androidbook.controls ,以及名为 MainActivity 的活动,创建一个新的 Android 应用项目,然后将清单 3-1 中的代码复制到您的 MainActivity 类中。

注意我们会在本章末尾给你一个 URL,你可以用它来下载本章的项目。这将允许您将这些项目直接导入 Eclipse,而不是复制和粘贴代码。

清单 3-1 。 完全用代码创建一个简单的用户界面

package com.androidbook.controls;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup.LayoutParams;
import android.widget.LinearLayout;
import android.widget.TextView;
public class MainActivity extends Activity
{
    private LinearLayout nameContainer;

    private LinearLayout addressContainer;

    private LinearLayout parentContainer;

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);

        createNameContainer();

        createAddressContainer();

        createParentContainer();

        setContentView(parentContainer);
    }

    private void createNameContainer()
    {
        nameContainer = new LinearLayout(this);

        nameContainer.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT,
                LayoutParams.WRAP_CONTENT));
        nameContainer.setOrientation(LinearLayout.HORIZONTAL);

        TextView nameLbl = new TextView(this);
        nameLbl.setText("Name: ");

        TextView nameValue = new TextView(this);
        nameValue.setText("John Doe");

        nameContainer.addView(nameLbl);
        nameContainer.addView(nameValue);
    }

    private void createAddressContainer()
    {
        addressContainer = new LinearLayout(this);

        addressContainer.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT,
                LayoutParams.WRAP_CONTENT));
        addressContainer.setOrientation(LinearLayout.VERTICAL);

        TextView addrLbl = new TextView(this);
        addrLbl.setText("Address:");

        TextView addrValue = new TextView(this);
        addrValue.setText("911 Hollywood Blvd");

        addressContainer.addView(addrLbl);
        addressContainer.addView(addrValue);
    }

    private void createParentContainer()
    {
        parentContainer = new LinearLayout(this);

        parentContainer.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT,
                LayoutParams.FILL_PARENT));
        parentContainer.setOrientation(LinearLayout.VERTICAL);

        parentContainer.addView(nameContainer);
        parentContainer.addView(addressContainer);
    }
}

如清单 3-1 所示,该活动包含三个 LinearLayout 对象。我们将在第五章中更深入地讨论布局,但是有一个小的先有鸡还是先有蛋的问题,需要知道一点关于布局的知识,这样你就可以学习许多基本的控制。现在,知道布局对象包含在屏幕的一部分中定位对象的逻辑就足够了。例如, LinearLayout 知道如何垂直或水平布局控件。布局对象可以包含任何类型的视图,甚至是其他布局。

nameContainer 对象包含两个 TextView 控件:一个用于标签名称:,另一个用于保存名称的实际文本(如 John Doe)。 addressContainer 还包含两个 TextView 控件。这两个容器的区别在于名称容器是水平布局的,而地址容器是垂直布局的。这两个容器都位于 parentContainer 中,这是活动的根视图。构建完容器后,活动通过调用 setContentView(parent container)将视图的内容设置为根视图。当需要呈现活动的 UI 时,根视图被调用来呈现自身。然后,根视图调用其子视图来呈现它们自己,子控件调用它们的子控件,依此类推,直到呈现整个 UI。

如清单 3-1 所示,我们有几个线性布局控件。其中两个垂直布置,一个水平布置。名称容器横向布局。这意味着两个 TextView 控件水平并排出现。 addressContainer 垂直布局,这意味着两个 TextView 控件一个叠在另一个上面。 parentContainer 也是垂直布局的,这就是为什么 nameContainer 出现在 addressContainer 的上方。请注意两个垂直布局的容器之间的细微差别, addressContainer 和 parentContainer。parentContainer 被设置为占据整个屏幕的宽度和高度:

parentContainer.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT,
        LayoutParams.FILL_PARENT));

并且 addressContainer 垂直包装其内容:

addressContainer.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT,
        LayoutParams.WRAP_CONTENT));

换句话说, WRAP_CONTENT 意味着视图应该只占用它在那个维度中需要的空间,不要超过包含它的视图所允许的空间。对于 addressContainer ,这意味着容器将垂直占据两行,因为这是我们提供的虚拟地址所需要的。

完全用 XML 构建用户界面

现在让我们用 XML 构建相同的 UI(参见清单 3-2 )。XML 布局文件存储在资源( /res/ )目录下一个名为布局的文件夹中。要尝试这个例子,在 Eclipse 中创建一个新的 Android 项目。默认情况下,您将获得一个名为 activity_main.xml 的 XML 布局文件,它位于 res/layout 文件夹下。双击 activity_main.xml 查看内容。Eclipse 将为您的布局文件显示一个可视化编辑器。您可能在视图的顶部有一个字符串,写着“Hello World,MainActivity!”或者类似的东西。单击视图底部的 activity_main.xml 选项卡,查看 activity_main.xml 文件的 xml。这显示了一个 LinearLayout 和一个 TextView 控件。使用布局或 activity_main.xml 选项卡,或同时使用两者,在 activity_main.xml 文件中重新创建清单 3-2 。省省吧。

清单 3-2 。 完全用 XML 创建用户界面

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical" android:layout_width="fill_parent"
    android:layout_height="fill_parent">
    <!-- NAME CONTAINER -->
    <LinearLayout        android:orientation="horizontal" android:layout_width="fill_parent"
        android:layout_height="wrap_content">

            <TextView  android:layout_width="wrap_content"
        android:layout_height="wrap_content" android:text="Name:" />

            <TextView android:layout_width="wrap_content"
        android:layout_height="wrap_content" android:text="John Doe" />

    </LinearLayout>

    <!-- ADDRESS CONTAINER -->
    <LinearLayout        android:orientation="vertical" android:layout_width="fill_parent"
        android:layout_height="wrap_content">

            <TextView android:layout_width="fill_parent"
        android:layout_height="wrap_content" android:text="Address:" />

            <TextView android:layout_width="fill_parent"
        android:layout_height="wrap_content" android:text="911 Hollywood Blvd" />
    </LinearLayout>

</LinearLayout>

在您的新项目的 src 目录下,有一个。包含一个活动类定义的 java 文件。双击该文件以查看其内容。注意语句 setContentView(r . layout . activity _ main)。清单 3-2 中显示的 XML 片段,结合对 setContentView(r . layout . activity _ main)的调用,将呈现与之前完全用代码生成时相同的 UI。XML 文件是不言自明的,但是请注意,我们定义了三个容器视图。第一个 LinearLayout 相当于我们的父容器。这个容器通过如下设置相应的属性将其方向设置为垂直:Android:orientation = " vertical "。父容器包含两个 LinearLayout 容器,分别代表 nameContainer 和 addressContainer 。

运行这个应用将产生与我们前面的示例应用相同的 UI。标签和值将如图 3-1 所示显示。

用代码构建 XML 格式的用户界面

清单 3-2 是一个人为的例子。在 XML 布局中硬编码 TextView 控件的值没有任何意义。理想情况下,我们应该用 XML 设计 ui,然后从代码中引用控件。这种方法使我们能够将动态数据绑定到设计时定义的控件。事实上,这是推荐的方法。用 XML 构建布局并使用代码填充动态数据是相当容易的。

清单 3-3 显示了相同的用户界面,但 XML 略有不同。这个 XML 将 id 分配给 TextView 控件,这样我们就可以在代码中引用它们。

清单 3-3 。 用带有标识的 XML 创建用户界面

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical" android:layout_width="fill_parent"
    android:layout_height="fill_parent">
    <!-- NAME CONTAINER -->
    <LinearLayout        android:orientation="horizontal" android:layout_width="fill_parent"
        android:layout_height="wrap_content">

            <TextView android:layout_width="wrap_content"
        android:layout_height="wrap_content" android:text="@string/name_text" />

            <TextView android:id="@+id/nameValue"
        android:layout_width="wrap_content" android:layout_height="wrap_content" />

    </LinearLayout>

    <!-- ADDRESS CONTAINER -->
    <LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:orientation="vertical" android:layout_width="fill_parent"
        android:layout_height="wrap_content">

            <TextView android:layout_width="fill_parent"
        android:layout_height="wrap_content" android:text="@string/addr_text" />

            <TextView android:id="@+id/addrValue"
        android:layout_width="fill_parent" android:layout_height="wrap_content" />
    </LinearLayout>

</LinearLayout>

除了将 id 添加到我们希望从代码中填充的 TextView 控件之外,我们还有标签 TextView 控件,我们正在用字符串资源文件中的文本填充这些控件。这些是没有 id 的文本视图有一个 android:text 属性。这些 TextView 的实际字符串将来自于 /res/values 文件夹中的 strings.xml 文件。清单 3-4 展示了我们的 strings.xml 文件可能的样子。

清单 3-4 。 strings.xml 文件为清单 3-3

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Common Controls</string>
    <string name="name_text">Name:</string>
    <string name="addr_text">Address:</string>
</resources>

清单 3-5 中的代码演示了如何获取 XML 中定义的控件的引用来设置它们的属性。您可以将它放入活动的 onCreate() 方法中。

清单 3-5 。 泛指在运行时控制资源

setContentView(R.layout.activity_main);
TextView nameValue = (TextView)findViewById(R.id.nameValue);
nameValue.setText("John Doe");
TextView addrValue = (TextView)findViewById(R.id.addrValue);
addrValue.setText("911 Hollywood Blvd.");

清单 3-5 中的代码很简单,但是请注意,在调用 findViewById() 之前,我们通过调用 setContentView(r . layout . activity _ main)来加载资源——如果视图还没有被加载,我们就不能获取对视图的引用。

Android 的开发者已经做了很好的工作,使得控件的每个方面都可以通过 XML 或代码来设置。在 XML 布局文件中设置控件的属性通常比使用代码更好。但是,有很多时候需要使用代码,比如设置要显示给用户的值。

FILL_PARENT vs. MATCH_PARENT

常量 FILL_PARENT 在 Android 2.2 中被弃用,取而代之的是 MATCH_PARENT 。不过,严格来说,这是一次更名。该常量的值仍然是–1。类似地,对于 XML 布局, fill_parent 被替换为 match_parent 。那么你用什么值呢?代替 FILL_PARENT 或 MATCH_PARENT ,您可以简单地使用值–1,这样就可以了。然而,这不是很容易阅读,并且您没有一个等价的未命名值用于您的 XML 布局。有更好的方法。

根据您需要在应用中使用的 Android APIs,您可以根据 2.2 之前的 Android 版本构建您的应用并依靠向前兼容性,或者根据 2.2 或更高版本的 Android 构建您的应用并将 minSdkVersion 设置为您的应用将运行的最低版本的 Android。例如,如果你只需要 Android 1.6 中已经存在的 API,那么在 Android 1.6 上构建并使用 FILL_PARENT 和 fill_parent 。您的应用在 Android 的所有更高版本(包括 2.2 及更高版本)中运行应该没有问题。如果您需要 Android 2.2 或更高版本的 API,请继续构建该版本的 Android,使用 MATCH_PARENT 和 match_parent ,并将 minSdkVersion 设置为更老的版本:例如, 4 (对于 Android 1.6)。您仍然可以将 Android 2.2 中构建的 Android 应用部署到旧版本的 Android 上,但是您必须小心早期版本的 Android SDK 中没有的类和/或方法。有一些方法可以解决这个问题,比如使用反射或创建包装类来处理 Android 版本之间的差异。我们将在后面的章节中讨论这些高级主题。

了解 Android 的常用控件

我们现在将开始讨论 Android SDK 中的常见控件。我们将从文本控件开始,然后是按钮、复选框、单选按钮、列表、网格、日期和时间控件以及一个地图视图控件。这些将与我们将在第四章中介绍的布局控件一起出现。

文本控件

文本控件可能是你在 Android 中使用的第一种控件。Android 有一套完整但并不强大的文本控件。在本节中,我们将讨论文本视图、编辑文本、自动完成文本视图和多自动完成文本视图控件。图 3-2 显示了操作中的控制。

9781430246800_Fig03-02.jpg

图 3-2 。Android 中的文本控件

文本视图

你已经看到了清单 3-3 中文本视图控件的简单 XML 规范,以及如何在清单 3-4 中的代码中处理文本视图 s。注意我们如何在 XML 中指定文本的 ID、宽度、高度和值,以及如何使用 setText() 方法设置值。 TextView 控件知道如何显示文本,但不允许编辑。这可能会让您认为该控件本质上是一个虚拟标签。不是真的。 TextView 控件有一些有趣的属性,使它非常方便。例如,如果您知道 TextView 的内容将包含一个 web URL 或电子邮件地址,您可以将 autoLink 属性设置为 email|web ,该控件将查找并高亮显示任何电子邮件地址和 URL。此外,当用户单击这些突出显示的项目之一时,系统将负责启动带有电子邮件地址的电子邮件应用或带有 URL 的浏览器。在 XML 中,这个属性应该在文本视图标签中,看起来像这样:

<TextView   ...     android:autoLink="email|web"    ...    />

您指定一组由管道分隔的值,包括 web 、 email 、 phone 或 map ,或者使用 none (默认)或 all 。如果要在代码中设置自动链接行为,而不是使用 XML,对应的方法调用是 setAutoLinkMask() 。你可以给它传递一个类似于之前的表示值组合的 int,比如 Linkify。电子邮件地址|Linkify。WEB _ URLS。为了实现这个功能, TextView 使用了 Android . text . util . link ify 类。清单 3-6 显示了一个代码自动链接的例子。

清单 3-6 。 在文本视图中的文本上使用 Linkify

TextView tv =(TextView)this.findViewById(R.id.tv);
tv.setAutoLinkMask(Linkify.ALL);
tv.setText("Please visit my website, [`www.androidbook.com`](http://www.androidbook.com)
or email me at davemac327@gmail.com.");

请注意,在设置文本之前,我们在 TextView 上设置了自动链接选项。这很重要,因为在设置文本后设置自动链接选项不会影响现有文本。因为我们使用代码将超链接添加到我们的文本中,所以我们为清单 3-6 中的文本视图编写的 XML 不需要任何特殊属性,看起来就像这样简单:

<TextView android:id="@+id/tv" android:layout_width="wrap_content"
  android:layout_height="wrap_content"/>

如果你愿意,你可以调用 Linkify 类的静态 addLinks() 方法,按需查找并添加到任何 TextView 或任何 Spannable 的内容的链接。不使用 setAutoLinkMask() ,我们可以在设置文本后执行以下:

Linkify.addLinks(tv, Linkify.ALL);

单击一个链接将导致调用该动作的默认意图。例如,单击一个 web URL 将启动带有该 URL 的浏览器。点击一个电话号码将启动电话拨号器,等等。 Linkify 类可以立即执行这项工作。

Linkify 还可以检测你想要寻找的自定义模式,决定它们是否与你认为需要点击的东西相匹配,并设置如何激发一个意图,使点击变成某种行动。这里不赘述那些细节,但是知道这些事情是可以做到的。

从字体属性到最小线和最大线等等,还有更多文本视图的特性可以探索。这些都是显而易见的,我们鼓励您进行实验,看看如何使用它们。尽管你应该记住 TextView 类中的一些功能不适用于只读字段,但是 TextView 的子类也有这些功能,其中一个我们将在下面介绍。

编辑文本

编辑文本 控件是文本视图的子类。顾名思义, EditText 控件允许文本编辑。 EditText 不如你在互联网上找到的文本编辑控件强大,但基于 Android 设备的用户可能不会直接在 EditText 控件中键入文档——他们最多会键入几个段落,或者使用功能更全的基于 HTML 的页面。因此,该类具有有限但适当的功能,甚至可能会让您感到惊讶。例如,编辑文本最重要的属性之一是输入类型。您可以将 inputType 属性设置为 textAutoCorrect 以使控件更正常见的拼写错误。您可以将其设置为 textCapWords 以使控件将单词大写。其他选项只需要电话号码或密码。

指定大写、多行文字和其他功能的方法比较古老,但现在已被废弃。如果在没有 inputType 属性的情况下指定它们,它们可以被读取;但是如果指定了输入类型,这些旧的属性将被忽略。

EditText 控件的旧默认行为是在一行上显示文本,并根据需要扩展。换句话说,如果用户键入的内容超过了第一行,就会出现另一行,依此类推。然而,您可以通过将 singleLine 属性设置为 true 来强制用户使用一行。在这种情况下,用户将不得不继续在同一行上键入。对于 inputType ,如果不指定 textMultiLine ,则 EditText 将默认为单行。因此,如果您想要旧的多行输入的默认行为,您需要用 textMultiLine 指定 inputType 。

EditText 的一个很好的特性是你可以指定提示文本。一旦用户开始键入文本,该文本将显示为轻微褪色并消失。该提示的目的是让用户知道该字段中的内容,而无需用户选择和删除默认文本。在 XML 中,这个属性是 Android:hint = " your hint text here "或 Android:hint = " @ string/your _ hint _ name ",其中 your_hint_name 是在 /res/values/strings.xml 中找到的字符串的资源名。在代码中,您可以用一个 CharSequence 或一个资源 ID 来调用 setHint() 方法。

自动完成文本视图

AutoCompleteTextView 控件是一个具有自动完成功能的 TextView 。换句话说,当用户在文本视图中输入时,控件可以显示选择建议。清单 3-7 展示了带有 XML 和相应代码的 AutoCompleteTextView 控件。

清单 3-7 。 使用 AutoCompleteTextView 控件

<AutoCompleteTextView android:id="@+id/actv"
    android:layout_width="fill_parent"  android:layout_height="wrap_content" />
AutoCompleteTextView actv = (AutoCompleteTextView) this.findViewById(R.id.actv);

ArrayAdapter<String> aa = new ArrayAdapter<String>(this,
                android.R.layout.simple_dropdown_item_1line,
                new String[] {"English", "Hebrew", "Hindi", "Spanish",
                "German", "Greek" });

actv.setAdapter(aa);

在清单 3-7 中显示的 AutoCompleteTextView 控件向用户建议一种语言。例如,如果用户键入 en ,控件会提示英语。如果用户键入 gr ,控件推荐希腊语,依此类推。

如果您使用过建议控件或类似的自动完成控件,您会知道像这样的控件有两个部分:文本视图控件和显示建议的控件。这是总的概念。要像这样使用控件,您必须创建控件,创建建议列表,告诉控件建议列表,并可能告诉控件如何显示建议。或者,您可以为建议创建第二个控件,然后将这两个控件关联起来。

Android 让这变得简单了,从清单 3-7 中可以明显看出。要使用 AutoCompleteTextView ,可以在布局文件中定义控件,并在活动中引用它。然后创建一个保存建议的适配器类,并定义将显示建议的控件的 ID(在本例中,是一个简单的列表项)。在清单 3-7 中, ArrayAdapter 的第二个参数告诉适配器使用一个简单的列表项来显示建议。最后一步是将适配器与 AutoCompleteTextView 相关联,这可以使用 setAdapter() 方法来完成。暂时不要担心适配器;我们将在本章的后面讨论这些。

多自动完成文本视图

如果您玩过 AutoCompleteTextView 控件,您会知道该控件只为文本视图中的整个文本提供建议。换句话说,如果你键入一个句子,你不会得到每个单词的建议。这就是多自动完成文本视图的用武之地。您可以使用 MultiAutoCompleteTextView 在用户键入时提供建议。例如,图 3-2 显示用户键入单词英文后跟逗号,然后是 Ge ,此时控件提示德文。如果用户继续,控件将提供额外的建议。

使用多自动完成文本视图就像使用自动完成文本视图。不同的是,你必须告诉控件从哪里开始再次建议。例如,在图 3-2 中,你可以看到控件可以在句首和逗号后提供建议。MultiAutoCompleteTextView 控件要求您给它一个标记器,它可以解析句子并告诉它是否再次开始建议。清单 3-8 演示了如何使用 MultiAutoCompleteTextView 控件和 XML 以及 Java 代码。

清单 3-8 。 使用 MultiAutoCompleteTextView 控件

<MultiAutoCompleteTextView android:id="@+id/mactv"
    android:layout_width="fill_parent"  android:layout_height="wrap_content" />

MultiAutoCompleteTextView mactv = (MultiAutoCompleteTextView) this
                .findViewById(R.id.mactv);
ArrayAdapter<String> aa2 = new ArrayAdapter<String>(this,
                android.R.layout.simple_dropdown_item_1line,
new String[] {"English", "Hebrew", "Hindi", "Spanish", "German", "Greek" });

mactv.setAdapter(aa2);

mactv.setTokenizer(new MultiAutoCompleteTextView.CommaTokenizer());

清单 3-7 和清单 3-8 之间唯一显著的区别是使用了多自动完成文本视图和对 setTokenizer() 方法的调用。由于本例中的 CommaTokenizer ,在 EditText 字段中键入逗号后,该字段将再次使用字符串数组提出建议。键入的任何其他字符都不会触发该字段提出建议。因此,即使您键入法语 Spani ,部分单词 Spani 也不会触发建议,因为它后面没有逗号。Android 为电子邮件地址提供了另一个标记器,名为 Rfc822Tokenizer 。如果你愿意,你可以创建你自己的记号赋予器。

按钮控件

按钮在任何 widget 工具包中都很常见,Android 也不例外。Android 提供了一组典型的按钮以及一些额外的功能。在这一节中,我们将讨论三种类型的按钮控件:基本按钮、图像按钮和切换按钮。图 3-3 显示了带有这些控件的用户界面。最上面的按钮是基本按钮,中间的按钮是图像按钮,最后一个是切换按钮。

9781430246800_Fig03-03.jpg

图 3-3 。安卓按钮控件

让我们从基本按钮开始。

按钮控件

Android 中的基本按钮类是 android.widget.Button 。除了如何使用它来处理点击事件之外,这种类型的按钮没什么特别的。清单 3-9 显示了按钮控件的 XML 布局片段,加上一些我们可能在活动的 onCreate() 方法中设置的 Java。我们的基本按钮看起来像图 3-3 中的顶部按钮。

清单 3-9 。 处理按钮上的点击事件

<Button android:id="@+id/button1"
    android:text="@string/basicBtnLabel"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content" />

Button button1 = (Button)this.findViewById(R.id.button1);
button1.setOnClickListener(new OnClickListener()
{
     public void onClick(View v)
     {
        Intent intent = new Intent(Intent.ACTION_VIEW,
                                Uri.parse("[`www.androidbook.com`](http://www.androidbook.com)"));
        startActivity(intent);
     }
});

清单 3-9 展示了如何注册一个按钮点击事件。通过用一个 OnClickListener 调用 setOnClickListener() 方法来注册点击事件。在清单 3-9 中,动态创建了一个匿名监听器来处理按钮 1 的点击事件。当点击按钮时,监听器的 onClick() 方法被调用,在这种情况下,启动浏览器到我们的网站。

从 Android SDK 1.6 开始,有一种更简单的方法来为你的按钮设置一个点击处理器。清单 3-10 显示了一个按钮的 XML,其中你为处理器指定了一个属性,加上作为点击处理器的 Java 代码。

清单 3-10 。 为按钮设置点击处理器

<Button   ...    android:onClick="myClickHandler"    ...  />
    public void myClickHandler(View target) {
        switch(target.getId()) {
        case R.id.button1:
        ...

将调用 handler 方法,将 target 设置为代表被单击按钮的视图对象。注意点击处理器方法中的开关语句如何使用按钮的资源 id 来选择要运行的逻辑。使用这种方法意味着您不必在代码中显式创建每个按钮对象,并且您可以跨多个按钮重用同一方法。这使得事情更容易理解和维护。这也适用于其他按钮类型。

ImageButton 控件

Android 通过 Android . widget . imagebutton 提供一个图片按钮。使用图像按钮类似于使用基本按钮(见清单 3-11 )。我们的图像按钮看起来像图 3-3 中的中间按钮。

清单 3-11 。?? 使用 ImageButton

<ImageButton android:id="@+id/imageButton2"
    android:layout_width="wrap_content" android:layout_height="wrap_content"
    android:onClick="myClickHandler"
    android:src="@drawable/icon"  />

ImageButton imageButton2 = (ImageButton)this.findViewById(R.id.imageButton2);
imageButton2.setImageResource(R.drawable.icon);

在这里,我们用 XML 创建了图像按钮,并从一个 drawable 资源中设置了按钮的图像。按钮的图像文件必须存在于 /res/drawable 下。在我们的例子中,我们只是简单地重用了按钮的 Android 图标。我们还在清单 3-11 中展示了如何通过调用按钮上的 setImageResource() 方法并传递给它一个资源 ID 来动态设置按钮的图像。请注意,您只需要做其中一项。您不需要在 XML 文件和代码中都指定按钮图像。

图像按钮的一个很好的特性是你可以为按钮指定一个透明的背景。结果将是一个可点击的图像,它的行为就像一个按钮,但可以是你想要的任何样子。只需为图片按钮设置 Android:background = " @ null "即可。

因为您的图像可能与标准按钮非常不同,所以您可以自定义按钮在 UI 中使用时的其他两种状态。除了正常显示外,按钮可以有焦点,并且可以被按下。拥有焦点仅仅意味着按钮当前是事件将要发生的地方。例如,您可以使用键盘或 D-pad 上的箭头键将焦点指向某个按钮。按下表示按钮的外观在被按下时但在用户放开之前发生变化。为了告诉 Android 我们按钮的三个图像是什么,哪一个是哪一个,我们设置了一个选择器。这是一个简单的 XML 文件, imagebuttonselector ,它位于我们项目的 /res/drawable 文件夹中。这有点违反直觉,因为这是一个 XML 文件,而不是图像文件,然而选择器文件必须放在那里。选择器文件的内容将类似于清单 3-12 中的。

清单 3-12 。 使用带有 ImageButton 的选择器

<?xml version="1.0" encoding="utf-8"?>
    <selector xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)">
     <item android:state_pressed="true"
           android:drawable="@drawable/button_pressed" /> <!-- pressed -->
     <item android:state_focused="true"
           android:drawable="@drawable/button_focused" /> <!-- focused -->
     <item android:drawable="@drawable/icon" /> <!-- default -->
    </selector>

关于选择器文件,有几点需要注意。首先,不要像在 values XML 文件中那样指定一个 < resources > 标记。其次,按钮图像的顺序很重要。Android 将依次测试选择器中的每一项,看是否匹配。因此,您希望将正常图像放在最后,以便仅在按钮未被按下且按钮没有焦点时使用。如果首先列出的是普通图像,那么即使按钮被按下或有焦点,它也总是匹配并被选中。当然,你所指的 drawables 必须存在于 /res/drawables 文件夹中。在布局 XML 文件中按钮的定义中,您希望将选择器 XML 文件的 android:src 属性设置为常规的 drawable,如下所示:

<Button   ...    android:src="@drawable/imagebuttonselector"    ...   />

ToggleButton 控件

与复选框或单选按钮一样, ToggleButton 控件是一个双态按钮。该按钮可以处于开或关状态。如图图 3-3 所示,切换按钮的默认行为是在打开状态时显示一个彩色条,在关闭状态时显示一个灰条。此外,默认行为还会在按钮处于打开状态时将按钮文本设置为打开,在按钮处于关闭状态时将按钮文本设置为关闭。如果开/关不适合您的应用,您可以修改切换按钮的文本。例如,如果您有一个想要通过 ToggleButton 启动和停止的后台进程,您可以通过使用 android:textOn 和 android:textOff 属性将按钮的文本设置为停止和运行。

清单 3-13 显示了一个例子。我们的切换按钮是图 3-3 中最底部的按钮,它处于 on 位置,所以按钮上的标签写着 Stop。

清单 3-13 。?? 安卓切换按钮

<ToggleButton android:id="@+id/cctglBtn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Toggle Button"
        android:textOn="Stop"
        android:textOff="Run"/>

因为 ToggleButton s 有 on 和 off 文本作为单独的属性,所以 ToggleButton 的 android:text 属性并没有真正被使用。它是可用的,因为它已经被继承了(从 TextView ),但是在这种情况下,你不需要使用它。

复选框控件

复选框控件是另一个双态按钮,允许用户切换其状态。不同之处在于,在许多情况下,用户不会将它视为一个调用即时操作的按钮。然而,从 Android 的角度来看,它是一个按钮,你可以用复选框做任何你可以用按钮做的事情。

在 Android 中,可以通过创建 android.widget.CheckBox 的实例来创建复选框。参见清单 3-14 和图 3-4 。

清单 3-14 。 创建复选框

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

<CheckBox android:id="@+id/chickenCB"
    android:text="Chicken"
    android:checked="true"
    android:layout_width=""wrap_content"
    android:layout_height="wrap_content" />

<CheckBox android:id="@+id/fishCB"
    android:text="Fish"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

<CheckBox android:id="@+id/steakCB"
    android:text="Steak"
    android:checked="true"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

</LinearLayout>

9781430246800_Fig03-04.jpg

图 3-4 。使用复选框 控制

您可以通过调用 setChecked() 或 toggle() 来管理复选框的状态。可以通过调用 isChecked() 获得状态。

如果您需要在复选框被选中或取消选中时实现特定的逻辑,您可以通过调用 setOnCheckedChangeListener()和 CompoundButton 的实现来注册 on-checked 事件。OnCheckedChangeListener 接口。然后,您必须实现 onCheckedChanged() 方法,该方法将在复选框被选中或取消选中时被调用。清单 3-15 显示了一些处理复选框的代码。

清单 3-15 。 在代码中使用复选框

public class CheckBoxActivity extends Activity {
        /** Called when the activity is first created. */
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.checkbox);

            CheckBox fishCB = (CheckBox)findViewById(R.id.fishCB);

            if(fishCB.isChecked())
                fishCB.toggle();     // flips the checkbox to unchecked if it was checked

            fishCB.setOnCheckedChangeListener(
                        new CompoundButton.OnCheckedChangeListener() {

                @Override
                public void onCheckedChanged(CompoundButton arg0, boolean isChecked) {
                    Log.v("CheckBoxActivity", "The fish checkbox is now "
                            + (isChecked?"checked":"not checked"));
                }});
        }
}

设置 OnCheckedChangeListener 的好处在于,您被传递了复选框按钮的新状态。你可以使用 OnClickListener 技术,就像我们使用基本按钮一样。当调用 onClick() 方法时,您需要确定按钮的新状态,方法是对其进行适当的转换,然后对其调用 isChecked() 。清单 3-16 显示了如果我们将 Android:onClick = " myClickHandler "添加到我们的复选框按钮的 XML 定义中,这段代码可能会是什么样子。

清单 3-16 。 在代码中使用复选框 android:onClick

public void myClickHandler(View view) {
    switch(view.getId()) {
    case R.id.steakCB:
        Log.v("CheckBoxActivity", "The steak checkbox is now " +
                (((CheckBox)view).isChecked()?"checked":"not checked"));
    }
}

开关控制

在 Android 4.0 中引入了开关小部件,它提供了与复选框非常相似的行为。事实上,这两个小部件是如此的相似,当你回顾一个开关对象的代码时,你几乎肯定会有一种似曾相识的感觉。许多人(包括本书的一些作者)认为引入开关更多的是出于审美原因。在过去几年中,UI 设计的趋势是朝着看起来像真实世界事物的微件的扭曲理想发展,并且开关是真实世界中的具体选择器——毕竟没有多少厨房电器有复选框。

开关与复选框控件的相似之处扩展到检查和更改状态的常用方法。这种方法的模仿包括 setChecked() 打开开关, isChecked() 测试当前状态,等等。由 Switch 小部件提供的一个美学差异是能够在状态之间改变相关的文本。还可以使用其他方法来控制此文本:

  • getTextOn() :返回开关打开时显示的文本。
  • getTextOff() :返回开关关闭时显示的文本。
  • setTextOn() :设置开关打开时显示的文本。虽然好的设计通常意味着不会更改文本,但在某些情况下,实时更新交换机文本中的某些指标会有所帮助。
  • setTextOff() :设置开关关闭时显示的文本。

在清单 3-17 中显示了包括开关的示例布局。

清单 3-17 。 使用开关创建布局

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <Switch
        android:id="@+id/switchdemo"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="This switch is: off" />

</LinearLayout>

注意记住“switch”是 Java 中的保留字。所以我们用一个不冲突的 ID。

在清单 3-18 中,我们的示例开关的代码应该会激起我们提到的关于复选框控件的似曾相识的感觉。

清单 3-18 。 用代码控制开关行为

public class SwitchDemo extends Activity
  implements CompoundButton.OnCheckedChangeListener {
  Switch sw;

  @Override
  public void onCreate(Bundle icicle) {
    super.onCreate(icicle);
    setContentView(R.layout.main);

    sw=(Switch)findViewById(R.id.switchdemo);
    sw.setOnCheckedChangeListener(this);
  }

  public void onCheckedChanged(CompoundButton buttonView,
                                 boolean isChecked) {
    if (isChecked) {
      sw.setTextOn("This switch is: on");
    }
    else {
      sw.setTextOff("This switch is: off");
    }
  }
}

我们切换工作的结果显示在图 3-5 中。

9781430246800_Fig03-05.jpg

图 3-5 。使用开关 控制

单选按钮控件

RadioButton 控件是任何 UI 工具包不可或缺的一部分。单选按钮给用户几个选择,并强迫他们选择一个项目。为了实施这种单一选择模型,单选按钮通常属于一个组,每个组一次只能选择一个项目。

要在 Android 中创建一组单选按钮,首先创建一个 RadioGroup ,然后用单选按钮填充该组。清单 3-19 和图 3-6 显示了一个例子。

清单 3-19 。 使用安卓单选按钮 小工具

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
   android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <RadioGroup
        android:id="@+id/rBtnGrp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical" >

        <RadioButton
            android:id="@+id/chRBtn"
            android:text="Chicken"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

        <RadioButton
            android:id="@+id/fishRBtn"
            android:text="Fish"
            android:checked="true"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

        <RadioButton
            android:id="@+id/stkRBtn"
            android:text="Steak"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

    </RadioGroup>

</LinearLayout>

9781430246800_Fig03-06.jpg

图 3-6 。使用单选按钮

在 Android 中,使用 Android . widget . radio group 实现一个单选按钮组,使用 Android . widget . radio button 实现一个单选按钮。

请注意,单选按钮组中的单选按钮默认情况下是未选中的,尽管您可以在 XML 定义中设置一个为选中,就像我们在清单 3-19 中对 Fish 所做的那样。要以编程方式将其中一个单选按钮设置为选中状态,可以获取对该单选按钮的引用并调用 setChecked() :

RadioButton steakBtn = (RadioButton)this.findViewById(R.id.stkRBtn);
steakBtn.setChecked(true);

您还可以使用 toggle() 方法来切换单选按钮的状态。与复选框控件一样,如果您使用 OnCheckedChangeListener 接口的实现调用 setOnCheckedChangeListener(),您将被通知已选中或未选中事件。不过,这里有一点小小的不同。这是一个与以前不同的班级。这一次,技术上来说是 RadioGroup。OnCheckedChangeListener 类代表 RadioGroup,而之前它是 CompoundButton。OnCheckedChangeListener 类。

单选按钮组也可以包含除单选按钮之外的视图。例如,清单 3-20 在最后一个单选按钮后添加了一个文本视图。还要注意,第一个单选按钮( anotherRadBtn )位于单选按钮组之外。

清单 3-20 。?? 一个单选按钮组而不仅仅是单选按钮

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:orientation="vertical"  android:layout_width="fill_parent"
        android:layout_height="fill_parent">

    <RadioButton android:id="@+id/anotherRadBtn"
        android:text="Outside"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <RadioGroup android:id="@+id/radGrp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

        <RadioButton android:id="@+id/chRBtn"
            android:text="Chicken"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

          <RadioButton android:id="@+id/fishRBtn"
            android:text="Fish"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

        <RadioButton android:id="@+id/stkRBtn"
            android:text="Steak"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

        <TextView android:text="My Favorite"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

    </RadioGroup>
</LinearLayout>

清单 3-20 展示了在一个单选按钮组中可以有非单选按钮控件。您还应该知道,单选按钮组只能在它自己的容器中的单选按钮上执行单选操作。也就是说,ID 为 anotherRadBtn 的单选按钮不会受到清单 3-20 中所示单选按钮组的影响,因为它不是该组的子组之一。

您可以通过编程来操作单选按钮组。例如,您可以获取对单选按钮组的引用,并添加单选按钮(或其他类型的控件)。清单 3-21 展示了这个概念。

清单 3-21 。?? 在代码中添加一个单选按钮到一个单选按钮组

RadioGroup radGrp = (RadioGroup)findViewById(R.id.radGrp);
RadioButton newRadioBtn = new RadioButton(this);
newRadioBtn.setText("Pork");
radGrp.addView(newRadioBtn);

一旦用户选中了单选按钮组中的单选按钮,用户就不能通过再次单击来取消选中它。清除单选按钮组中所有单选按钮的唯一方法是以编程方式调用单选按钮组上的 clearCheck() 方法。

当然,你想用 RadioGroup 做一些有趣的事情。你可能不想轮询每个单选按钮来确定它是否被选中。幸运的是,电台组有几种方法可以帮你。我们用清单 3-22 中的来演示这些。这段代码的 XML 在清单 3-20 中。

清单 3-22 。 以编程方式使用单选按钮组

public class RadioGroupActivity extends Activity {
        protected static final String TAG = "RadioGroupActivity";

        /** Called when the activity is first created. */
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.radiogroup);

            RadioGroup radGrp = (RadioGroup)findViewById(R.id.radGrp);

            int checkedRadioButtonId = radGrp.getCheckedRadioButtonId();

            radGrp.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
                @Override
                public void onCheckedChanged(RadioGroup arg0, int id) {
                    switch(id) {
                    case -1:
                        Log.v(TAG, "Choices cleared!");
                        break;
                    case R.id.chRBtn:
                        Log.v(TAG, "Chose Chicken");
                        break;
                    case R.id.fishRBtn:
                        Log.v(TAG, "Chose Fish");
                        break;
                    case R.id.stkRBtn:
                        Log.v(TAG, "Chose Steak");
                        break;
                    default:
                        Log.v(TAG, "Huh?");
                        break;
                    }
                }});
        }
}

我们总是可以使用 getCheckedRadioButtonId()来获取当前选中的 RadioButton ,这将返回选中项的资源 Id,或者如果没有选中任何内容,则返回–1(如果没有默认设置,并且用户还没有选择选项,则有可能)。我们之前在我们的 onCreate() 方法中展示了这一点,但是实际上,您可能希望在适当的时候使用它来读取用户当前的选择。我们还可以设置一个监听器,当用户选择其中一个单选按钮时,监听器会立即得到通知。请注意, onCheckedChanged() 方法采用了一个 RadioGroup 参数,允许您对多个 RadioGroup 使用相同的 OnCheckedChangeListener。您可能已经注意到了–1 的开关选项。如果使用 clearCheck() 通过代码清除了 RadioGroup ,也会发生这种情况。

ImageView 控件

我们还没有涉及的一个基本控件是 ImageView 控件。这用于显示图像,其中图像可以来自文件、内容提供者或资源(如 drawable)。你甚至可以指定一种颜色,然后 ImageView 就会显示这种颜色。清单 3-23 显示了一些 ImageView s 的 XML 示例,后面是一些显示如何创建 ImageView 的代码。

清单 3-23 。XML 和代码中的 ImageView

<ImageView android:id="@+id/image1"
  android:layout_width="wrap_content"  android:layout_height="wrap_content"
  android:src="@drawable/icon" />

<ImageView android:id="@+id/image2"
  android:layout_width="125dip"  android:layout_height="25dip"
  android:src="#555555" />

<ImageView android:id="@+id/image3"
  android:layout_width="wrap_content"  android:layout_height="wrap_content" />

<ImageView android:id="@+id/image4"
  android:layout_width="wrap_content"  android:layout_height="wrap_content"
  android:src="@drawable/manatee02"
  android:scaleType="centerInside"
  android:maxWidth="35dip"  android:maxHeight="50dip"
  />

  ImageView imgView = (ImageView)findViewById(R.id.image3);

  imgView.setImageResource( R.drawable.icon );

  imgView.setImageBitmap(BitmapFactory.decodeResource(
              this.getResources(), R.drawable.manatee14) );

  imgView.setImageDrawable(
              Drawable.createFromPath("/mnt/sdcard/dave2.jpg") );

  imgView.setImageURI(Uri.parse("file://mnt/sdcard/dave2.jpg"));

在这个例子中,我们用 XML 定义了四个图像。第一个只是我们应用的图标。第二个是一个比它高还宽的灰色条。第三个定义没有在 XML 中指定图像源,但是我们将一个 ID 与这个 ID(image3)相关联,我们可以在代码中使用它来设置图像。第四个图像是我们的另一个可绘制图像文件,我们不仅指定图像文件的来源,还设置图像在屏幕上的最大尺寸,并定义如果图像大于我们的最大尺寸该怎么办。在这种情况下,我们告诉 ImageView 居中并缩放图像,使其符合我们指定的大小。

在清单 3-23 的 Java 代码中,我们展示了几种设置图片 3 的方法。当然,我们首先必须通过使用资源 ID 找到对 ImageView 的引用。第一个 setter 方法, setImageResource() ,简单地使用图像的资源 ID 来定位图像文件,以便为我们的 ImageView 提供图像。第二个 setter 使用 BitmapFactory 将图像资源读入一个位图对象,然后将 ImageView 设置为那个位图。注意,在将位图应用到我们的 ImageView 之前,我们可以对位图做一些修改,但是在我们的例子中,我们按原样使用它。此外, BitmapFactory 有几种创建位图的方法,包括从一个字节数组和一个 InputStream 。您可以使用 InputStream 方法从 web 服务器读取图像,创建位图图像,然后从那里设置 ImageView 。

第三个设置使用一个 Drawable 作为我们的图像源。在本例中,我们显示的是来自 SD 卡的图像来源。你需要在 SD 卡上放一些有合适名字的图像文件。类似于 bitmap factory,Drawable 类有几种不同的方法来构造 Drawable s,包括从 XML 流中构造。

最后一个 setter 方法获取图像文件的 URI,并将其用作图像源。对于这最后一个调用,不要认为你可以使用任何图像 URI 作为来源。这个方法实际上只适用于设备上的本地图像,而不适用于通过 HTTP 找到的图像。要使用基于互联网的图像作为你的 ImageView 的来源,你最有可能使用 BitmapFactory 和 InputStream 。

日期和时间控件

日期和时间控件在许多小部件工具包中很常见。Android 提供了几个基于日期和时间的控件,其中一些我们将在本节讨论。具体来说,我们将介绍日期选择器、时间选择器、数字时钟和模拟时钟控件。

日期选择器和时间选择器控件

顾名思义,使用日期选择器控件选择日期,使用时间选择器控件选择时间。清单 3-24 和图 3-7 显示了这些控件的例子。

清单 3-24 。XML 中的日期选择器和时间选择器控件

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:orientation="vertical"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent">

  <TextView android:id="@+id/dateDefault"
    android:layout_width="fill_parent" android:layout_height="wrap_content" />

  <DatePicker android:id="@+id/datePicker"
    android:layout_width="wrap_content" android:layout_height="wrap_content" />

  <TextView android:id="@+id/timeDefault"
    android:layout_width="fill_parent" android:layout_height="wrap_content" />

  <TimePicker android:id="@+id/timePicker"
    android:layout_width="wrap_content" android:layout_height="wrap_content" />

</LinearLayout>

9781430246800_Fig03-07.jpg

图 3-7 。日期选择器和时间选择器 ui

如果查看 XML 布局,您会发现定义这些控件很容易。与 Android toolkit 中的任何其他控件一样,您可以通过编程方式访问这些控件来初始化它们或从中检索数据。例如,你可以初始化这些控件,如清单 3-23 所示。

清单 3-25 。 分别用日期和时间初始化日期选择器和时间选择器

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.datetimepicker);

    TextView dateDefault = (TextView)findViewById(R.id.dateDefault);
    TextView timeDefault = (TextView)findViewById(R.id.timeDefault);

    DatePicker dp = (DatePicker)this.findViewById(R.id.datePicker);
    // The month, and just the month, is zero-based. Add 1 for display.
    dateDefault.setText("Date defaulted to " + (dp.getMonth() + 1) + "/" +
            dp.getDayOfMonth() + "/" + dp.getYear());
    // And here, subtract 1 from December (12) to set it to December
    dp.init(2008, 11, 10, null);

    TimePicker tp = (TimePicker)this.findViewById(R.id.timePicker);

    java.util.Formatter timeF = new java.util.Formatter();
    timeF.format("Time defaulted to %d:%02d", tp.getCurrentHour(),
                    tp.getCurrentMinute());
    timeDefault.setText(timeF.toString());

    tp.setIs24HourView(true);
    tp.setCurrentHour(new Integer(10));
    tp.setCurrentMinute(new Integer(10));
}

清单 3-25 将日期选择器上的日期设置为 2008 年 12 月 10 日。请注意,对于月份,内部值是从零开始的,这意味着一月是 0,十二月是 11。对于时间选择器,小时数和分钟数设置为 10。另请注意,该控件支持 24 小时视图。如果不设置这些控件的值,默认值将是设备已知的当前日期和时间。

最后,注意 Android 提供了这些控件的模态窗口版本,比如 DatePickerDialog 和 TimePickerDialog 。如果要向用户显示控件并强制用户做出选择,这些控件非常有用。我们将在第八章的中更详细地讨论对话。

textblock 和 analogclock 控件

Android 还提供文本时钟和模拟时钟控件(见图 3-8 )。

9781430246800_Fig03-08.jpg

图 3-8 。使用模拟时钟和数字时钟

如图所示,除了小时和分钟之外,文本时钟还支持秒。Android 中的模拟时钟是一个双手时钟,一只手用于小时指示器,另一只手用于分钟指示器。要将这些添加到你的布局中,使用如清单 3-26 所示的 XML。

清单 3-26 。?? 在 XML 中添加数字时钟或模拟时钟

<TextClock
  android:layout_width="wrap_content" android:layout_height="wrap_content"
  android:format12Hour="hh:mm:ss aa" android:format24Hour="kk:mm:ss" />

<AnalogClock
  android:layout_width="wrap_content" android:layout_height="wrap_content" />

这两个控件实际上只是用于显示当前时间,因为它们不允许您修改日期或时间。换句话说,它们是唯一能够显示当前时间的控件。因此,如果你想改变日期或时间,你需要坚持使用日期选择器 / 时间选择器或日期选择器对话框 / 时间选择器对话框。不过,这两个时钟的好处在于,它们会自动更新,而无需你做任何事情。也就是说,秒针在文本时钟中滴答走,指针在模拟时钟上移动,而不需要我们做任何额外的事情。

MapView 控件

随着 Google Play 服务的推出,Android 显示地图数据的方式发生了一些变化。然而,绝大多数开发人员仍然喜欢原始的 MapView 控件,原因有很多——向后兼容性、简单性等等。顾名思义,com . Google . Android . maps . mapview 控件可以显示地图。您可以通过 XML 布局或代码实例化这个控件,但是使用它的活动必须扩展 MapActivity。MapActivity 负责加载地图、执行缓存等多线程请求。

注意严格来说,MapView 是 Google API 的一部分,而不是 Android API 的一部分。以便测试代码等。对于 MapView,请确保您的仿真器是根据包含 Google APIs 的 SDK 版本创建的。

清单 3-27 显示了一个 MapView 的实例化。

清单 3-27 。 通过 XML 布局创建 MapView 控件

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:orientation="vertical" android:layout_width="fill_parent"
        android:layout_height="fill_parent">

    <com.google.android.maps.MapView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:enabled="true"
        android:clickable="true"
        android:apiKey="myAPIKey"
        />

</LinearLayout>

我们将在第十九章中详细讨论基于位置的服务。这也是您学习如何获得自己的映射 API 密钥的地方。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

  • 【http://www.androidbook.com/proandroid5/projects】:与本书相关的可下载项目列表。在本章中,寻找一个名为 pro Android 5 _ Ch03 _ controls . ZIP 的 ZIP 文件。这个 ZIP 文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何从这些 ZIP 文件之一将项目导入 Eclipse。
  • :几篇“布局招数”——非常值得一读的类型化技术文章。他们开始研究在 Android 中设计和构建 ui 的性能方面。在这个列表中寻找其他与构建 ui 相关的文章。

摘要

让我们通过快速列举你所学到的关于构建用户界面的知识来结束这一章:

  • XML 资源如何定义 UI 外观,以及代码如何填充数据
  • Android 中所有的基本用户界面控件
  • 第四章中的列表视图和第五章中的布局的提示。

四、适配器和列表控件

在第三章中,我们介绍了一系列基本的用户界面控件,你可以用它们来构建 Android 应用。如果您还记得 TextView 上的例子,我们探索的控件类型之一是 AutoCompleteTextView,当它与数据源(适配器)结合时,能够提示用户一系列预定的值。在这一章中,我们将进一步探索适配器,以及更广泛的列表控件的主题,这些控件支持构建更精细和复杂的屏幕设计。

了解适配器

在我们进入 Android 的列表控件的细节之前,我们需要谈论一下适配器。列表控件用于显示数据集合。但是,Android 没有使用单一类型的控件来管理显示和数据,而是将这两项职责分为列表控件和适配器。列表控件是扩展 Android . widget . adapter view 的类,包括 ListView 、 GridView 、 Spinner 和 Gallery (见图 4-1 )。

9781430246800_Fig04-01.jpg

图 4-1 。 AdapterView 类层次

AdapterView 本身扩展了 android.widget.ViewGroup ,也就是说 ListView 、 GridView 等等都是容器控件。换句话说,列表控件包含子视图的集合。适配器的目的是管理适配器视图的数据,并为其提供子视图。让我们通过检查 SimpleCursorAdapter 来看看这是如何工作的。

了解 SimpleCursorAdapter

简单光标适配器如图图 4-2 所示。

9781430246800_Fig04-02.jpg

图 4-2 。简单光标适配器

这是一张非常重要的理解图。左边是适配器视图;在这个例子中,它是一个由文本视图子视图组成的列表视图。右边是数据;在本例中,它被表示为来自针对内容提供者的查询的数据行的结果集。

为了将数据行映射到 ListView,SimpleCursorAdapter 需要一个子布局资源 ID。子布局必须描述应该显示在左侧的右侧的每个数据元素的布局。这种情况下的布局就像我们为活动所做的布局一样,但是它只需要指定我们的列表视图的单行布局。例如,如果您有一个来自 Contacts 内容提供者的信息结果集,并且您只想在您的 ListView 中显示每个联系人的名字,那么您需要提供一个布局来描述 name 字段应该是什么样子。如果您想在 ListView 的每一行显示结果集中的名称和图像,您的布局必须说明如何显示名称和图像。

这并不意味着您必须为结果集中的每个字段提供一个布局规范,也不意味着您必须在结果集中为您想要包含在 ListView 的每一行中的所有内容提供一段数据。例如,我们将向您展示如何在您的 ListView 中设置复选框来选择行,并且这些复选框不需要从结果集中的数据中设置。我们还将向您展示如何获取结果集中不属于列表视图的数据。尽管我们刚刚讨论了列表视图、文本视图和结果集,但是请记住,适配器的概念比这更普遍。左侧可以是一个画廊,右侧可以是一个简单的图像数组。但是现在让我们保持事情相当简单,更详细地看一下 SimpleCursorAdapter 。

SimpleCursorAdapter 最简单的构造函数如下:

SimpleCursorAdapter(Context context, int childLayout, Cursor c, String[] from, int[] to)

此适配器将光标中的行转换为容器控件的子视图。子视图的定义是在 XML 资源中定义的( childLayout 参数)。注意,因为光标中的一行可能有许多列,所以通过指定一组列名(使用来自参数的,告诉 SimpleCursorAdapter 您想要从该行中选择哪些列。

类似地,因为您选择的每个列必须映射到布局中的一个视图,所以您必须在到参数中指定 id。您选择的列和显示列中数据的视图之间存在一对一的映射,因此从和到的参数数组必须具有相同数量的元素。正如我们之前提到的,子视图可以包含其他类型的视图;它们不一定是文本视图,例如,你可以使用图像视图。

在 ListView 和我们的适配器之间有一个谨慎的合作。当 ListView 想要显示一行数据时,它调用适配器的 getView() 方法,传入位置来指定要显示的数据行。适配器通过使用在适配器的构造函数中设置的布局构建适当的子视图,并从结果集中的适当记录中提取数据来做出响应。因此, ListView 不需要处理数据如何存在于适配器端;它只需要根据需要调用子视图。这是一个关键点,因为这意味着我们的 ListView 不一定需要为每个数据行创建每个子视图。它实际上只需要有显示窗口中可见的所需数量的子视图。如果只显示 10 行,从技术上讲,ListView 只需要实例化 10 个子布局,即使我们的结果集中有数百条记录。实际上,有超过 10 个子布局被实例化,因为 Android 通常会保留额外的子布局,以便更快地将新行显示出来。您应该得出的结论是,由 ListView 管理的子视图可以被回收。我们稍后会详细讨论这一点。

图 4-2 揭示了使用适配器的一些灵活性。因为列表控件使用适配器,所以可以根据数据和子视图替换各种类型的适配器。例如,如果您不打算从内容供应器或数据库填充一个 AdapterView ,您就不必使用 SimpleCursorAdapter 。您可以选择更“简单”的适配器——array adapter。

了解 ArrayAdapter

ArrayAdapter 是 Android 中最简单的适配器。它专门针对列表控件,并假设 TextView 控件代表列表项(子视图)。创建一个新的数组适配器看起来就像这样简单:

ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
                android.R.layout.simple_list_item_1,
                new String[]{"Dave","Satya","Dylan"});

我们仍然传递上下文( this )和一个 childLayout 资源 ID。但是我们不是从数据字段规范的数组中传递一个,而是传递一个字符串数组作为实际数据。我们不会将光标或传递给视图资源 id 的数组。这里的假设是,我们的子布局由一个单独的 TextView 组成,这就是 ArrayAdapter 将用作我们的数据数组中的字符串的目的地。

现在我们将为子布局资源 ID 引入一个很好的快捷方式。我们可以利用 Android 中预定义的布局,而不是为列表项创建自己的布局文件。注意,子布局资源 ID 的资源前缀是 android。Android 在自己的目录中查找,而不是在我们本地的 /res 目录中查找。您可以通过导航到 Android SDK 文件夹并在平台/<Android-版本>/数据/资源/布局下查找来浏览该文件夹。在那里你会找到 simple_list_item_1.xml ,并且可以看到它定义了一个简单的 TextView 。那个 TextView 是我们的 ArrayAdapter 将用来创建一个视图(在它的 getView() 方法中)以提供给 ListView 。请随意浏览这些文件夹,找到各种用途的预定义布局。我们以后会用到更多这样的东西。

ArrayAdapter 有其他的构造函数。如果 childLayout 不是简单的 TextView ,可以传入行布局资源 ID 加上 TextView 的资源 ID 来接收数据。当没有现成的字符串数组可以传入时,可以使用 createFromResource() 方法。清单 4-1 、 4-2 和 4-3 展示了一个例子,其中我们为一个旋转器创建了一个 ArrayAdapter 。

清单 4-1 。 清单片段,用于从字符串资源文件创建一个 ArrayAdapter

<Spinner android:id="@+id/spinner"
    android:layout_width="wrap_content"  android:layout_height="wrap_content" />

清单 4-2 。 用于从字符串资源文件创建 ArrayAdapter 的代码片段

Spinner spinner = (Spinner) findViewById(R.id.spinner);

ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this,
        R.array.planets, android.R.layout.simple_spinner_item);

adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);

spinner.setAdapter(adapter);

清单 4-3 。 实际字符串-资源文件

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/values/planets.xml -->
<resources>
  <string-array name="planets">
    <item>Mercury</item>
    <item>Venus</item>
    <item>Earth</item>
    <item>Mars</item>
    <item>Jupiter</item>
    <item>Saturn</item>
    <item>Uranus</item>
    <item>Neptune</item>
  </string-array>
</resources>

第一个清单是 spinner 的 XML 布局。中的第二个 Java 清单展示了如何创建一个 ArrayAdapter ,它的数据源是在一个字符串资源文件中定义的。使用这种方法,您不仅可以将列表内容具体化为 XML 文件,还可以使用本地化版本。稍后我们将讨论微调器,但是现在,我们知道微调器有一个视图来显示当前选择的值,还有一个列表视图来显示可以从中选择的值。基本上就是下拉菜单。清单 4-3 是名为 /res/values/planets.xml 的 XML 资源文件,读入该文件是为了初始化 ArrayAdapter 。

值得一提的是, ArrayAdapter 允许对底层数据进行动态修改。例如, add() 方法会在数组末尾追加一个新值。方法将在数组中的指定位置添加一个新值。并且 remove() 从数组中取出一个对象。也可以调用 sort() 对数组重新排序。当然,一旦你这样做了,数据数组就与 ListView 不同步了,所以这时你调用适配器的 notifyDataSetChanged() 方法。该方法将使列表视图与适配器重新同步。

以下列表总结了 Android 提供的适配器:

  • ArrayAdapter < T > :这是一个位于任意对象的通用数组之上的适配器。这意味着与一个列表视图一起使用。
  • CursorAdapter :这个适配器也打算在 ListView 中使用,通过光标向列表提供数据。
  • SimpleAdapter :顾名思义,这个适配器是一个简单的适配器。它通常用于用静态数据填充列表(可能来自参考资料)。
  • ResourceCursorAdapter :这个适配器扩展了 CursorAdapter ,并且知道如何从资源中创建视图。
  • SimpleCursorAdapter :这个适配器扩展了 ResourceCursorAdapter 并从光标中的列创建了 TextView/ImageView 视图。视图在参考资料中定义。

我们已经介绍了足够多的适配器,现在开始向您展示一些使用适配器和列表控件的真实例子(也称为 AdapterView s)。我们开始吧。

将适配器与 AdapterViews 一起使用

既然已经向您介绍了适配器,是时候让它们为我们工作了,为列表控件提供数据。在这一节中,我们将首先介绍基本的列表控件,即列表视图。然后,我们将描述如何创建您自己的定制适配器,最后,我们将描述其他类型的列表控件: GridView s,spinners 和 gallery。

基本列表控件:ListView

ListView 控件垂直显示项目列表。也就是说,如果我们有一个要查看的项目列表,并且项目的数量超出了我们当前在显示中可以看到的范围,我们可以滚动来查看其余的项目。你一般通过编写一个扩展 android.app.ListActivity 的新活动来使用 ListView ,ListActivity 包含一个 ListView ,你通过调用 setListAdapter() 方法来为 ListView 设置数据。

如前所述,适配器将列表控件链接到数据,并帮助准备列表控件的子视图。在列表视图中的项目可以被点击以立即采取行动,或者被选择以稍后对所选择的项目集采取行动。我们将从非常简单的开始,然后逐步增加功能。

在列表视图中显示值

图 4-3 显示了一个最简单形式的列表视图控件。

9781430246800_Fig04-03.jpg

图 4-3 。使用列表视图控件

在这个练习中,我们将把一个 ListView 放入一个默认的 Android 布局中,没有任何特殊的调整或更改,因此您可以看到它们如何适合一个典型的主布局 XML 文件。清单 4-4 显示了我们活动的 Java 代码。

清单 4-4 。 添加项目到列表视图

public class MainActivity extends Activity {
    private ListView listView1;
    private ArrayAdapter<String> listAdapter1;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        listView1 = (ListView) findViewById(R.id.listView1);

        String[] someColors = new String[] { "Red", "Orange", "Yellow",
                "Green", "Blue", "Indigo", "Violet", "Black", "White"};
        ArrayList<String> colorArrayList = new ArrayList<String>();
        colorArrayList.addAll( Arrays.asList(someColors) );

        listAdapter1 = new ArrayAdapter<String>(this, android.R.id.text1,
                colorArrayList);

        listView1.setAdapter( listAdapter1 );
    }
...
}

清单 4-2 创建了一个 ListView 控件,其中填充了我们在数组中指定的颜色列表, someColors 。在我们的例子中,我们获取数组的内容并将字符串颜色名称映射到一个 TextView 控件( android)。R.id.text1 )。之后,我们创建一个数组适配器并设置列表的适配器。适配器类很聪明,可以从您提供的任何数据源中获取行来填充 UI。

我们可以利用非常基本的 ListActivity 来提供主布局,因为没有其他 UI 元素或复杂性需要考虑。然而,我们选择在一个典型的新项目中部署 ListView,并利用基本活动。我们还为我们的子视图使用了 Android 提供的布局(资源 ID 。R.layout.simple_list_item_1 ,其中包含一个 Android 提供的 TextView (资源 ID android。R.id.text1 )。总而言之,设置非常简单。

我们可以通过展示如何用我们自己的设计替换 Android 为子视图提供的布局来扩展这个例子和您的理解。在项目的 res/layout 文件夹中创建一个新的空文件,并将其命名为 simple_list_row.xml 。清单 4-5 显示了一个简单的文本视图的 XML 布局,用来表示我们的列表视图中要呈现的每一行(或者引用这个简单列表行布局的任何其他布局)。

清单 4-5 。 为列表渲染创建自定义的 TextView 子视图

<TextView xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
 android:id="@+id/rowTextView"
 android:layout_width="fill_parent"
 android:layout_height="wrap_content"
 android:padding="12dp"
 android:textSize="24sp" >
</TextView>

在我们的代码中,我们只需要更改绑定 ListView 中使用的所选布局的引用,以使用新的 simple_list_row 布局,如下所示:

listAdapter1 = new ArrayAdapter<String>(this, R.layout.simple_list_row,
        colorArrayList);

请注意,当我们以这种方式引用我们自己的自定义布局时,我们去掉了前面的“android”引用。我们现在可以运行这个例子来看看完整的效果,如图 4-4 所示。

9781430246800_Fig04-04.jpg

图 4-4 。ListView 示例的运行

列表视图中可点击的项目

当然,当您运行这个示例时,您会看到您可以上下滚动列表来查看所有的颜色名称,但仅此而已。如果我们想在这个例子中做一些更有趣的事情,比如让应用在用户点击我们的列表视图中的一个项目时做出响应,该怎么办?清单 4-6 显示了我们的例子接受用户输入的修改。

清单 4-6 。?? 在列表视图上接受用户输入

public class MainActivity extends Activity {

    private ListView listView1;
    private ArrayAdapter<String> listAdapter1;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        listView1 = (ListView) findViewById(R.id.listView1);

        String[] someColors = new String[] { "Red", "Orange", "Yellow",
                "Green", "Blue", "Indigo", "Violet", "Black", "White"};
        ArrayList<String> colorArrayList = new ArrayList<String>();
        colorArrayList.addAll( Arrays.asList(someColors) );

        listAdapter1 = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1,
                colorArrayList);

        listView1.setAdapter( listAdapter1 );

        listView1.setOnItemClickListener(new OnItemClickListener() {

          @Override
          public void onItemClick(AdapterView<?> parent, View view, int position
              , long id) {
            String itemValue = (String) listView1.getItemAtPosition(position);
            Toast.makeText(getApplicationContext(), itemValue,
                Toast.LENGTH_LONG).show();
          }
        });
    }
...
}

我们的活动现在正在实现 OnItemClickListener 接口,这意味着当用户点击我们的 ListView 中的某些内容时,我们将收到一个回调。正如您可以通过我们的 onItemClick() 方法看到的,我们获得了许多关于被点击内容的信息,包括接收点击的视图、被点击项目在 ListView 中的位置,以及根据我们的适配器的项目 ID。在调用 makeText() 方法来处理颜色名称之前,我们进行了相应的转换。位置值表示该项在列表视图中相对于整个项目列表的位置,并且是从零开始的。因此,列表中的第一项位于位置 0。

ID 值完全取决于适配器和数据源。在我们的例子中,我们碰巧在一个数组中查询带有颜色名称的字符串,所以根据这个适配器的 ID 是来自内容提供者的条目在数组中的位置。但是在其他情况下,您的数据源可能不像这样简单,所以您不应该认为您可以像我们在这个示例中所做的那样,总是能够提前知道诸如订购之类的事情。如果我们使用一个从系统的联系人数据库中读取其值的 SimpleCursorAdapter ,那么提供给我们的 ID 将是记录的底层 _ID ,它可以是任何值,这取决于系统中联系人的年龄。

当我们之前讨论过 ArrayAdapter s 时,我们提到了 notifydatascethanged()方法让适配器在数据改变时更新 ListView 。有些适配器,如 SimpleCursorAdapter ,能够感知底层数据源(如 Contacts content provider)发生的更新,并将根据变化动态地为您更新 ListView 内容。然而,使用 ArrayAdapter s,您将需要自己调用 notifyDataSetChanged() 方法。

这很容易做到。我们生成了自己的颜色名称列表视图,通过点击一种颜色,我们向用户显示了一条消息。但是如果我们想先选择一堆名字,然后对这些人的子集做些什么呢?对于下一个示例应用,我们将修改列表项的布局以包含复选框,并且我们将向 UI 添加一个按钮,然后对选定项的子集进行操作。

用 ListView 添加其他控件

如果您想在主布局中添加更多的控件,您可以提供自己的布局 XML 文件,放入一个 ListView ,并添加其他想要的控件。例如,你可以在 UI 中的 ListView 下面添加一个按钮,提交对所选项目的操作,如图图 4-5 所示。

9781430246800_Fig04-05.jpg

图 4-5 。一个额外的按钮,让用户提交选择的项目

这个例子的主布局在清单 4-7 中,它包含了活动的 UI 定义—列表视图和按钮。

清单 4-7 。 覆盖 ListView 被引用我们的 活动

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    xmlns:tools="[`schemas.android.com/tools`](http://schemas.android.com/tools)"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.artifexdigital.android.listviewdemo3.MainActivity" >

    <ListView
        android:id="@+id/listView1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="1" />

    <Button
        android:id="@+id/button1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="doClick"
        android:text="Submit selection" />

</LinearLayout>

注意我们必须在 LinearLayout 中指定 ListView 的高度和重量。我们希望我们的按钮一直出现在屏幕上,不管我们的列表视图中有多少项目,我们不希望一直滚动到页面底部才找到按钮。为了实现这一点,我们将 layout_height 设置为 wrap_content ,然后使用 layout_weight 来说明该控件应该占用父容器的所有可用空间。这个技巧为按钮留出了空间,并保留了我们滚动列表视图的能力。我们将在本章的后面更多地讨论布局和权重。

活动的实现看起来就像清单 4-8 中的。

清单 4-8 。 从 ListActivity 读取用户输入

public class MainActivity extends Activity {

    private ListView listView1;
    private ArrayAdapter<String> listAdapter1;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        listView1 = (ListView) findViewById(R.id.listView1);

        String[] someColors = new String[] { "Red", "Orange", "Yellow",
                "Green", "Blue", "Indigo", "Violet", "Black", "White"};
        ArrayList<String> colorArrayList = new ArrayList<String>();
        colorArrayList.addAll( Arrays.asList(someColors) );

        listAdapter1 = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_checked,
                colorArrayList);

        listView1.setAdapter( listAdapter1 );

        listView1.setChoiceMode(listView1.CHOICE_MODE_MULTIPLE);

        listView1.setOnItemClickListener(new OnItemClickListener() {

          @Override
          public void onItemClick(AdapterView<?> parent, View view, int position
              , long id) {
            String itemValue = (String) listView1.getItemAtPosition(position);
            Toast.makeText(getApplicationContext(), itemValue,
                Toast.LENGTH_LONG).show();
          }
        });
    }

    public void doClick(View view) {
      int count=listView1.getCount();
      SparseBooleanArray viewItems = listView1.getCheckedItemPositions();
      for(int i=0; i<count; i++) {
        if(viewItems.get(i)) {
          String selectedColor = (String) listView1.getItemAtPosition(i);
          Log.v("ListViewDemo", selectedColor + " is checked at position " + i);
        }
      }
    }
}

在适配器的设置中,我们为一个 ListView 行项目( android)传递另一个 Android 提供的视图。r . layout . simple _ list _ item _ checked,这导致每一行都有一个文本视图和一个复选框。如果你查看这个布局文件,你会看到 TextView 的另一个子类,这个叫做 CheckedTextView 。这种特殊类型的文本视图旨在与列表视图一起使用。看,我们告诉过你在 Android 布局文件夹中有一些有趣的东西!您将看到 CheckedTextView 的 ID 是 text1 ,这是我们需要在视图数组中传递给 SimpleCursorAdapter 的构造函数的内容。

因为我们希望用户能够选择我们的行,所以我们将选择模式设置为 CHOICE_MODE_MULTIPLE 。默认情况下,选择模式为选择模式无。另一个可能的值是选择 _ 模式 _ 单一。如果你想在这个例子中使用选择模式,你会想使用不同的布局,最有可能是 android。r . layout . simple _ list _ item _ single _ choice。

在这个例子中,我们实现了一个基本按钮,它调用活动的 doClick() 方法。为了简单起见,我们只想写出用户检查过的条目的名称。好消息是解决方案非常简单;坏消息是 Android 已经进化了,所以最好的解决方案取决于你的目标 Android 版本。我们在这里展示的 ListView 解决方案从 Android 1 就开始工作了(尽管我们在按钮回调上采用了 Android 1.6 的快捷方式)。也就是说,getCheckedItemPositions()方法是旧的,但是仍然有效。返回值是一个数组,它可以告诉你一个项是否被检查过。所以,我们遍历数组。如果我们的 ListView 中的相应行被选中,viewItems.get(i) 将返回 true。使用列表视图的 getItemAtPosition() 方法,可以直接从列表视图中访问我们的数据。在我们的例子中,从 getItemAtPosition() 返回的对象是一个字符串对象。正如我们之前所说的,在其他情况下,当与一些特定的内容提供者(如本书后面讨论的联系人提供者)一起工作时,我们可能会得到一些其他类型的对象,如光标和包装器。您必须了解您的数据源和适配器,才能知道会发生什么。

如果我们点击图 4-5 中的提交选择按钮,我们可以在 Eclipse 或 Android Studio 中看到 log cat 窗口,它从我们的选择中发出数据,如在 doClick() 方法中实现的。如图 4-6 中的所示。

9781430246800_Fig04-06.jpg

图 4-6 。使用列表视图中的用户输入进行进一步处理

从列表视图中读取选择的另一种方法

Android 1.6 引入了另一种从列表视图 : getCheckItemIds() 中检索选中行列表的方法。然后在 Android 2.2 中,这个方法被弃用,取而代之的是 getCheckedItemIds() 。这是一个微妙的名称变化,但你使用方法的方式基本上是一样的。清单 4-9 显示了我们对 Java 代码所做的修改,以反映处理列表中选中条目的这种演变。对于 list.xml 的 XML 布局,我们可以继续使用清单 4-7 中的文件。

清单 4-9 。 从 ListActivity 中读取用户输入的另一种方式

public class MainActivity extends Activity {
    private ListView listView1;
    private ArrayAdapter<String> listAdapter1;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        listView1 = (ListView) findViewById(R.id.listView1);

        String[] someColors = new String[] { "Red", "Orange", "Yellow",
                "Green", "Blue", "Indigo", "Violet", "Black", "White"};
        ArrayList<String> colorArrayList = new ArrayList<String>();
        colorArrayList.addAll( Arrays.asList(someColors) );

        listAdapter1 = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_checked,
                colorArrayList);

        listView1.setAdapter( listAdapter1 );

        listView1.setChoiceMode(listView1.CHOICE_MODE_MULTIPLE);

        listView1.setOnItemClickListener(new OnItemClickListener() {

          @Override
          public void onItemClick(AdapterView<?> parent, View view, int position
              , long id) {
            String itemValue = (String) listView1.getItemAtPosition(position);
            Toast.makeText(getApplicationContext(), itemValue,
                Toast.LENGTH_LONG).show();
          }
        });
    }

...

    public void doClick(View view) {
        if(!listAdapter1.hasStableIds()) {
            Log.v(TAG, "Data is not stable");
            return;
        }
        long[] viewItems = listView1.getCheckedItemIds();
        for(int i=0; i<viewItems.length; i++) {
          String selectedColor = (String) listView1.getItemAtPosition(i);
          Log.v("ListViewDemo", selectedColor + " is checked at position " + i);
        }
      }
    }
}

在这个示例应用中,当我们单击按钮时,我们的回调调用方法 getCheckedItemIds() 。在我们的上一个例子中,我们在 ListView 中获得了被检查条目的位置数组,而这次我们从适配器中获得了在 ListView 中被检查的记录的 id 数组。我们现在可以绕过 ListView 和光标,因为 id 可以用来驱动我们想要的任何动作。

我们已经向您展示了如何在各种场景中使用 ListView s。我们已经展示了适配器做了很多工作来支持一个列表视图。接下来,我们将讨论其他类型的列表控件,从 GridView 开始。

GridView 控件

大多数小部件工具包都提供一个或多个基于网格的控件。Android 有一个 GridView 控件,可以以网格的形式显示数据。注意,虽然我们在这里使用术语数据,但是网格的内容可以是文本、图像等等。

GridView 控件在网格中显示信息。 GridView 的使用模式是在 XML 布局中定义网格(参见清单 4-10 和 4-11 ),然后使用 Android . widget . list adapter 将数据绑定到网格。

清单 4-10 。XML 布局中 GridView 的定义

<RelativeLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    xmlns:tools="[`schemas.android.com/tools`](http://schemas.android.com/tools)"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.artifexdigital.android.gridviewdemo.MainActivity" >

    <GridView
        android:id="@+id/gridView1"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:padding="10dp"
        android:verticalSpacing="10dp"
        android:horizontalSpacing="10dp"
        android:numColumns="auto_fit"
        android:columnWidth="100dp"
        android:stretchMode="columnWidth"
        android:gravity="center" />

</RelativeLayout>

清单 4-11 。 Java 实现为 GridView

public class MainActivity extends Activity {
    private GridView gridView1;
    private ArrayAdapter<String> listAdapter1;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        gridView1 = (GridView) findViewById(R.id.gridView1);

        String[] someColors = new String[] { "Red", "Orange", "Yellow",
                "Green", "Blue", "Indigo", "Violet", "Black", "White"};

        ArrayList<String> colorArrayList = new ArrayList<String>();
        colorArrayList.addAll( Arrays.asList(someColors) );

        listAdapter1 = new ArrayAdapter<String>(this,
                android.R.layout.simple_list_item_1, colorArrayList);

        gridView1.setAdapter( listAdapter1 );

    }

}

清单 4-10 在 XML 布局中定义了一个简单的 GridView 。然后,网格被加载到活动的内容视图中。生成的界面如图图 4-7 所示。

9781430246800_Fig04-07.jpg

图 4-7 。一个用颜色填充的 GridView

图 4-7 中的网格显示了我们数组中颜色的名称。我们已经决定显示一个带有颜色名称的文本视图,但是你可以很容易地生成一个充满图像或其他控件的网格。我们再次利用了 Android 中预定义的布局。事实上,这个例子看起来非常像的清单 4-7 ,除了一些重要的区别。我们必须调用 setContentView() 来为我们的 GridView 设置布局;没有默认视图可以依赖。为了设置适配器,我们在 GridView 对象上调用 setAdapter() ,而不是在活动上调用 setListAdapter() 。

毫无疑问,您已经注意到网格使用的适配器是一个 ListAdapter 。列表通常是一维的,而网格是二维的。我们可以得出结论,网格实际上显示的是面向列表的数据。结果是列表是按行显示的。也就是说,列表遍历第一行,然后遍历第二行,依此类推。

和以前一样,我们有一个列表控件,它和一个适配器一起处理数据管理和子视图的生成。我们之前使用的相同技术应该可以很好地处理 GridView s。一个例外与选择有关:没有办法在 GridView 中指定多个选择,正如我们在清单 4-7 中所做的那样。

微调控制按钮

微调器控件就像一个下拉菜单。它通常用于从相对较短的选项列表中进行选择。如果选择列表太长而无法显示,则会自动为您添加滚动条。您可以通过 XML 布局实例化一个微调器,就像下面这样简单:

<Spinner
    android:id="@+id/spinner"  android:prompt="@string/spinnerprompt"
    android:layout_width="wrap_content"  android:layout_height="wrap_content" />

尽管 spinner 在技术上是一个列表控件,但它看起来更像一个简单的 TextView 控件。换句话说,当微调器静止时,只显示一个值。微调器的目的是允许用户从一组预先确定的值中进行选择:当用户单击小箭头时,会显示一个列表,用户需要选择一个新值。填充该列表的方式与填充其他列表控件的方式相同:使用适配器。

因为微调按钮经常像下拉菜单一样使用,所以经常可以看到适配器从资源文件中获取列表选项。清单 4-12 中显示了一个使用资源文件设置旋转器的例子。请注意名为 android:prompt 的新属性,用于在列表顶部设置一个提示以供选择。微调器提示的实际文本在我们的 /res/values/strings.xml 文件中。正如您所料, Spinner 类也有一个在代码中设置提示的方法。

清单 4-12 。 代码从资源文件中创建一个微调器

public class SpinnerActivity extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.spinner);

        Spinner spinner = (Spinner)findViewById(R.id.spinner);

        ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this,
                R.array.planets, android.R.layout.simple_spinner_item);

        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);

        spinner.setAdapter(adapter);
    }
}

您可能还记得在清单 4-1 中看到的 planets.xml 文件。我们在这个例子中展示了如何创建一个微调控件;设置适配器,然后将其关联到微调器。参见图 4-8 中的了解这在实际操作中的效果。

9781430246800_Fig04-08.jpg

图 4-8 。选择行星的旋转器

与我们以前的列表控件的一个不同之处是,当使用微调器时,我们有一个额外的布局要处理。图 4-8 的左侧显示了微调器的正常模式,其中显示了当前的选择。在这种情况下,当前选择是土星。单词旁边有一个向下的箭头,表示该控件是一个微调器,可用于弹出一个列表来选择不同的值。第一个布局作为参数提供给 array adapter . createfromresource()方法,定义了微调器在正常模式下的外观。在图 4-8 的右侧,我们以弹出列表的方式显示微调器,等待用户选择新的值。使用 setDropDownViewResource()方法设置该列表的布局。同样在这个例子中,我们使用 Android 提供的布局来满足这两个需求,所以如果你想检查这些布局的定义,你可以访问 Android res/layout 文件夹。当然,您可以为其中任何一个指定您自己的布局定义,以获得您想要的效果。

图库控制

Gallery 控件是一个可水平滚动的列表控件,总是聚焦在列表的中心。该控件通常在触摸模式下用作照片库。您可以通过 XML 布局或代码实例化一个图库:

<Gallery
    android:id="@+id/gallery"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
/>

Gallery 控件通常用于显示图像,所以您的适配器很可能是图像专用的。我们将在下一节自定义适配器中向您展示自定义图像适配器。视觉上,一个画廊看起来像图 4-9 。

9781430246800_Fig04-09.jpg

图 4-9 。展示海牛图像的画廊

摘要

在本章中,我们通过以下方式扩展了您对 UI 组件的理解和熟练程度:

  • Android 中可用的主要列表控件
  • 如何使用适配器填充列表控件中的数据

五、构建更高级的用户界面布局

在前几章中,我们回顾了 Android 提供的许多标准布局,涵盖了各种可能的 UI 方法。当 Android 提供的股票布局不完全符合你的要求时,你会转向哪里?在这一章中,我们将快速探索 Android 如何为您提供构建自己的定制布局和管理相关适配器以填充有用数据的能力。

创建自定义适配器

Android 中的标准适配器很容易使用,但有一些限制。为了解决这个问题,Android 提供了一个名为 BaseAdapter 的抽象类,如果你需要一个定制的适配器,你可以扩展它。如果您有特殊的数据管理需求,或者如果您希望对如何显示子视图有更多的控制,您可以使用自定义适配器。您还可以使用自定义适配器,通过使用缓存技术来提高性能。接下来,我们将向您展示如何构建自定义适配器。

清单 5-1 显示了定制适配器的 XML 布局和 Java 代码。对于下一个例子,我们的适配器将处理海牛的图像,所以我们称它为 ManateeAdapter 。我们也将在活动中创建它。

清单 5-1 。 本店 自定义适配器: ManateeAdapter

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is at /res/layout/gridviewcustom.xml -->
<GridView xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:id="@+id/gridview"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:padding="10dip"
    android:verticalSpacing="10dip"
    android:horizontalSpacing="10dip"
    android:numColumns="auto_fit"
    android:gravity="center"
    />

Java 实现

public class GridViewCustomAdapter extends Activity
{
    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.gridviewcustom);
        GridView gv = (GridView)findViewById(R.id.gridview);
        ManateeAdapter adapter = new ManateeAdapter(this);
        gv.setAdapter(adapter);
    }

    public static class ManateeAdapter extends BaseAdapter {
        private static final String TAG = "ManateeAdapter";
        private static int convertViewCounter = 0;
        private Context mContext;
        private LayoutInflater mInflater;
        static class ViewHolder {
            ImageView image;
        }

        private int[] manatees = {
                R.drawable.manatee00, R.drawable.manatee01, R.drawable.manatee02,
// ... many more manatees here - see the sample code folder
                R.drawable.manatee32, R.drawable.manatee33 };

        private Bitmap[] manateeImages = new Bitmap[manatees.length];
        private Bitmap[] manateeThumbs = new Bitmap[manatees.length];

        public ManateeAdapter(Context context) {
            Log.v(TAG, "Constructing ManateeAdapter");
            this.mContext = context;
            mInflater = LayoutInflater.from(context);

            for(int i=0; i<manatees.length; i++) {
                manateeImages[i] = BitmapFactory.decodeResource(
                        context.getResources(), manatees[i]);
                manateeThumbs[i] = Bitmap.createScaledBitmap(manateeImages[i],
                        100, 100, false);
            }
        }

        @Override
        public int getCount() {
            Log.v(TAG, "in getCount()");
            return manatees.length;
        }

        public int getViewTypeCount() {
            Log.v(TAG, "in getViewTypeCount()");
            return 1;
        }

        public int getItemViewType(int position) {
            Log.v(TAG, "in getItemViewType() for position " + position);
            return 0;
        }

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            ViewHolder holder;

            Log.v(TAG, "in getView for position " + position +
                    ", convertView is " +
                    ((convertView == null)?"null":"being recycled"));

            if (convertView == null) {
                convertView = mInflater.inflate(R.layout.gridimage, null);
                convertViewCounter++;
                Log.v(TAG, convertViewCounter + " convertViews have been created");
                holder = new ViewHolder();
                holder.image = (ImageView) convertView.findViewById(R.id.gridImageView);
                convertView.setTag(holder);
            } else {
                holder = (ViewHolder) convertView.getTag();
            }

            holder.image.setImageBitmap( manateeThumbs[position] );

            return convertView;
        }

        @Override
        public Object getItem(int position) {
            Log.v(TAG, "in getItem() for position " + position);
            return manateeImages[position];
        }

        @Override
        public long getItemId(int position) {
            Log.v(TAG, "in getItemId() for position " + position);
            return position;
        }
    }
}

当您运行这个应用时,您应该会看到类似于图 5-1 的显示。

9781430246800_Fig05-01.jpg

图 5-1 。一个带有海牛图像的网格视图

在这个例子中有很多东西需要解释,尽管它看起来相对简单。我们将从我们的 Activity 类开始,它看起来很像我们在本章的这一节一直在使用的那些。有一个来自 gridviewcustom.xml 的主布局,它只包含一个 GridView 定义。我们需要从布局内部获取对 GridView 的引用,因此我们定义并设置 gv 。我们实例化我们的 ManateeAdapter ,将我们的上下文传递给它,并在我们的 GridView 上设置适配器。到目前为止,这是相当标准的东西,尽管您无疑已经注意到,我们的定制适配器在创建时使用的参数并不像预定义适配器那么多。这主要是因为我们完全控制了这个特定的适配器,并且我们只在这个应用中使用它。如果我们让这个适配器更通用,我们很可能会设置更多的参数。但是让我们继续。

我们在适配器中的工作是管理数据向 Android 视图对象的传递。列表控件将使用视图对象(在本例中是一个 GridView )。数据来自某个数据源。在前面的例子中,数据是通过传递给适配器的游标对象获得的。在我们的定制案例中,我们的适配器知道所有的数据以及数据来自哪里。列表控件会询问一些事情,这样它就知道如何构建 UI。当它有一个不再需要的视图时,它也会好心地传递视图以供回收。认为我们的适配器必须知道如何构造视图似乎有点奇怪,但最终,这一切都是有意义的。

当我们实例化我们的定制适配器 ManateeAdapter 时,习惯上是传入上下文并让适配器持有它。在需要的时候让它可用通常是非常有用的。我们想在适配器中做的第二件事是挂在充气机上。当我们需要创建一个新视图来返回列表控件时,这将有助于提高性能。适配器中第三件典型的事情是创建一个视图持有者对象,包含我们管理的数据的视图对象。采用这种方法也起到了性能优化的作用,使我们不必重复查找视图。对于这个例子,我们只是存储了一个 ImageView ,但是如果我们有额外的字段要处理,我们会将它们添加到 ViewHolder 的定义中。例如,如果我们有一个 ListView ,其中每行包含一个 ImageView 和两个 TextView ,我们的 ViewHolder 将有一个 ImageView 和两个 TextView

因为我们在这个适配器中处理的是海牛的图像,所以我们设置了一个它们的资源 id 数组,以便在创建位图的过程中使用。我们还定义了一个位图数组作为我们的数据列表。

正如您可以从我们的 ManateeAdapter 构造函数中看到的,我们保存上下文,创建并挂起一个 inflater,然后我们遍历图像资源 id 并构建一个位图数组。这个位图数组将是我们的数据。

正如您之前了解到的,设置适配器将导致我们的 GridView 调用适配器上的方法来设置自身以显示数据。例如,我们的 GridView gv 将调用适配器的 getCount() 方法来确定有多少对象要显示。它还将调用 getViewTypeCount() 方法来确定在 GridView 中可以显示多少种不同类型的视图。出于本例的目的,我们将其设置为 1。然而,如果我们有一个 ListView 并且想要在常规数据行之间放置分隔符,我们将有两种类型的视图,并且需要从 getViewTypeCount() 返回 2。您可以拥有任意多的不同视图类型,只要您适当地从该方法返回正确的计数。与此方法相关的是 getItemViewType() 。我们刚刚说过,我们可以从适配器返回多种类型的视图,但是为了简单起见, getItemViewType() 只需要返回一个整数值来指示哪种视图类型位于数据中的特定位置。因此,如果我们有两种类型的视图要返回, getItemViewType() 将需要返回 0 或 1 来指示哪种类型。如果我们有三种类型的视图,这个方法需要返回 0、1 或 2。

如果我们的适配器正在处理 ListView 中的分隔符,它必须将分隔符视为数据。这意味着数据中有一个位置被分隔符占用。当列表控件调用 getView() 来检索该位置的适当视图时, getView() 将需要返回一个分隔符作为视图,而不是常规数据作为视图。当在 getItemViewType() 中询问该位置的视图类型时,我们需要返回我们认为匹配该视图类型的适当整数值。如果使用分隔符,您应该做的另一件事是实现 isEnabled() 方法。这对于列表项应该返回 true,对于分隔符应该返回 false,因为分隔符不应该是可选择或可点击的。

ManateeAdapter 中最有趣的方法是 getView() 方法调用。一旦 GridView 确定了有多少项可用,它就开始请求数据。现在,我们可以谈谈循环利用的观点。列表控件只能在显示屏上显示尽可能多的子视图。这意味着没有必要为适配器中的每条数据调用 get view();调用 getView() 来显示尽可能多的项目是有意义的。当 gv 从适配器获取子视图时,它将决定有多少子视图适合显示。当显示全是子视图时, gv 可以停止调用 getView() 。

如果您在启动这个示例应用后查看 LogCat,您将会看到各种调用,但是您还会看到在请求所有图像之前, getView() 停止被调用。如果你开始上下滚动 GridView ,你会在 LogCat 中看到更多对 getView() 的调用,你会注意到,一旦我们创建了一定数量的子视图, getView() 被调用,而 convertView 被设置为某个值,而不是 null。这意味着我们现在正在回收子视图,这对性能非常有利。

如果我们从 getView() 中的 gv 得到一个非空的 convertView 值,这意味着 gv 正在回收那个视图。通过重用传入的视图,我们避免了必须膨胀 XML 布局,并且我们避免了必须找到 ImageView 。通过将一个 ViewHolder 对象链接到我们返回的视图,我们可以在下次视图返回时更快地回收视图。我们在 getView() 中所要做的就是重新获取视图持有者并将正确的数据分配到视图中。

在这个例子中,我们想展示放入视图中的数据不一定就是数据中存在的内容。 createScaledBitmap() 方法创建一个较小版本的数据用于显示。重点是我们的列表控件没有调用 getItem() 方法。如果用户操作列表控件,这个方法将被我们的其他代码调用,这些代码想要对数据做一些事情。同样,对于任何适配器,理解它在做什么是非常重要的。您不一定想要依赖来自列表控件的视图中的数据,正如适配器中的 getView() 所创建的那样。有时,您需要调用适配器的 getItem() 方法来获取要操作的实际数据。有时,就像我们在前面的 ListView 例子中所做的那样,你会想要找到数据的光标。这完全取决于适配器和数据最终来自哪里。尽管我们在示例中使用了 createScaledBitmap() 方法,但 Android 2.2 引入了另一个可能会有所帮助的类: ThumbnailUtils 。这个类有一些从位图和视频生成缩略图的静态方法。

这个例子最后要指出的是对 getItemId() 方法的调用。在我们之前的例子中,列表视图和联系人的条目 ID 是来自内容供应器的 _ID 值。对于这个例子,除了 position 之外,我们不需要为商品 ID 使用任何东西。项目 id 的目的是提供一种机制来独立于其位置引用数据。当数据离开这个适配器时尤其如此,我们的联系人就是这种情况。当我们对数据有了这种直接控制,就像我们对海牛图像的控制一样,并且我们知道如何在应用中获得实际数据时,简单地使用位置作为项目 ID 是一种常见的捷径。在我们的例子中尤其如此,因为我们甚至不允许添加或删除数据。

Android 中的其他控件

Android 中有很多很多控件可以使用。到目前为止,我们已经讨论了相当多,更多将在后面的章节中讨论(例如第十九章中的 MapView 和第二十章中的 VideoView 和 MediaController )。你会发现其他控件,因为它们都是从视图派生出来的,与我们在这里讨论的有很多共同点。现在,我们将只提到几个您可能想自己进一步探索的控件。

ScrollView 是一个设置带有垂直滚动条的视图容器的控件。当你在一个屏幕上显示太多内容时,这很有用。

进度条 和分级条 控件类似于滑块。第一个图标直观地显示了某项操作的进度(可能是文件下载或音乐播放),第二个图标显示了星级评定。

计时器 控制是一个计时的计时器。如果你想帮助你显示一个倒计时器,有一个 CountDownTimer 类,但它不是一个 View 类。

在 Android 4.0 中引入了 Switch 控件,其功能类似于 ToggleButton ,但在视觉上有一个侧到侧的呈现,以及 Space 视图,这是一个轻量级视图,可以在布局中使用,以便更容易地在其他视图之间创建空间。

WebView 是一个非常特殊的显示 HTML 的视图。它能做的远不止这些,包括处理 cookies 和 JavaScript,以及链接到应用中的 Java 代码。但是在您开始在应用中实现 web 浏览器之前,您应该仔细考虑调用设备上的 web 浏览器来完成所有这些繁重的工作。

这就完成了本章中对控件的介绍。我们现在将继续讨论修改控件外观和感觉的样式和主题,然后讨论在屏幕上排列控件的布局。

样式和主题

Android 提供了几种方法来改变应用中的视图风格。我们将首先介绍在字符串中使用标记标签,然后介绍如何使用 Spannables 来改变文本的特定视觉属性。但是,如果您想对几个视图或整个活动或应用使用一个通用的规范来控制事物的外观,该怎么办呢?我们将讨论 Android 风格和主题来告诉你怎么做。

使用样式

有时,您想要突出显示视图内容的一部分或设置其样式。您可以静态或动态地做到这一点。静态地,您可以将标记直接应用于字符串资源中的字符串,如下所示:

<string name="styledText"><i>Static</i> style in a <b>TextView</b>.</string>

然后,您可以在 XML 或代码中引用它。注意,可以对字符串资源使用以下 HTML 标记: < i > 、 < b > 、 < u > 分别用于斜体、粗体和下划线,以及 < sup > (上标)、 < sub > (下标)、 < strike > 【删除线】、你甚至可以嵌套它们来得到,例如,小的上标。这不仅适用于文本视图,也适用于其他视图,比如按钮。图 5-2 展示了样式化和主题化的文本,使用了本节中的许多例子。

9781430246800_Fig05-02.jpg

图 5-2 。风格和主题示例

以编程方式设计一个 TextView 控件的内容需要一点额外的工作,但是允许更多的灵活性(见清单 5-2 ),因为你可以在运行时设计它。不过,这种灵活性只能应用于 Spannable ,这就是 EditText 通常管理内部文本的方式,而 TextView 通常不使用 Spannable 。span able 基本上是一个字符串,您可以对其应用样式。要让 TextView 将文本存储为 span able,可以这样调用 setText() :

tv.setText("This text is stored in a Spannable", TextView.BufferType.SPANNABLE);

然后,当你调用 tv.getText() 时,你会得到一个 Spannable 。

如清单 5-2 中的所示,您可以获取 EditText 的内容(作为一个 Spannable 对象),然后为部分文本设置样式。清单中的代码将文本样式设置为粗体和斜体,并将背景设置为红色。您可以使用所有的样式选项,就像我们之前描述的 HTML 标签一样,还可以使用其他选项。

清单 5-2 。编辑文本 的内容动态应用样式

EditText et =(EditText)this.findViewById(R.id.et);
et.setText("Styling the content of an EditText dynamically");
Spannable spn = (Spannable) et.getText();
spn.setSpan(new BackgroundColorSpan(Color.RED), 0, 7,
             Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spn.setSpan(new StyleSpan(android.graphics.Typeface.BOLD_ITALIC),
             0, 7, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

这两种样式化技术只对它们所应用的一个视图有效。Android 提供了一种样式机制来定义跨视图重用的通用样式,还提供了一种主题机制,它基本上将一种样式应用于整个活动或整个应用。首先,我们需要谈谈风格。

一个样式 是一个视图属性的集合,它有一个名称,所以你可以通过它的名称来引用这个集合,并通过名称将那个样式分配给视图。例如,清单 5-3 显示了一个资源 XML 文件,保存在 /res/values 中,我们可以将它用于所有的错误消息。

清单 5-3 。 定义在多个视图中使用的样式

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="ErrorText">
        <item name="android:layout_width">fill_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:textColor">#FF0000</item>
        <item name="android:typeface">monospace</item>
    </style>
</resources>

定义了视图的大小以及字体颜色(红色)和字样。注意项目标记的 name 属性是我们在布局 XML 文件中使用的 XML 属性名,并且项目标记的值不再需要双引号。我们现在可以对一个错误使用这种风格文本视图,如清单 5-4 所示。

清单 5-4 。 在视图中使用样式

<TextView  android:id="@+id/errorText"
    style="@style/ErrorText"
    android:text="No errors at this time"
    />

需要注意的是,在这个视图定义中,样式的属性名称不是以 android: 开头的。注意这一点,因为除了风格之外,所有东西似乎都使用 android: 。当您的应用中有许多共享一种样式的视图时,在一个地方改变该样式就简单多了;您只需要在一个资源文件中修改样式的属性。当然,您可以为各种控件创建许多不同的样式。按钮可以共享一个共同的样式,例如,不同于菜单中文本的共同样式。

样式的一个非常好的方面是你可以设置它们的层次结构。我们可以为非常糟糕的错误消息定义一种新的样式,并以 ErrorText 的样式为基础。清单 5-5 展示了这可能是什么样子。

清单 5-5 。 从 父样式 中定义样式

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="ErrorText.Danger" >
        <item name="android:textStyle">bold</item>
    </style>
</resources>

这个例子表明,我们可以简单地使用父样式作为新样式名的前缀来命名我们的子样式。因此, ErrorText。危险是错误文本的子元素,继承了父元素的样式属性。然后,它为文本样式添加了一个新属性。这可以一次又一次地重复,以创建一个完整的风格树。

和适配器布局一样,Android 提供了大量我们可以使用的样式。要指定 Android 提供的样式,请使用如下语法:

style="@android:style/TextAppearance"

这个样式设置了 Android 中文本的默认样式。要找到主 Android styles.xml 文件,请访问 Android SDK/platforms//data/RES/values/文件夹。在这个文件中,您会发现许多现成的样式供您使用或扩展。这里有一个关于扩展 Android 提供的样式的警告:以前使用前缀的方法不适用于 Android 提供的样式。相反,您必须使用样式标签的父属性,如下所示:

<style name="CustomTextAppearance" parent="@android:style/TextAppearance">
    <item  ... your extensions go here ...    />
</style>

你不必总是在你的视图中引入一个完整的样式。你可以选择借用这种风格的一部分。例如,如果您想将文本视图中的文本颜色设置为系统样式颜色,您可以执行以下操作:

<EditText android:id="@+id/et2"
    android:layout_width="fill_parent"  android:layout_height="wrap_content"
    android:textColor="?android:textColorSecondary"
    android:text="@string/hello_world" />

注意,在这个例子中, textColor 属性值的名称以开始。字符代替了 @ 字符。?使用了字符,所以 Android 知道在当前主题中寻找一个样式值。因为我们看到?android ,我们在 android 系统主题中寻找这个样式值。

使用主题

样式的一个问题是,您需要添加一个属性规范 style="@style/... "应用到您希望它应用到的每个视图定义。如果你想在整个活动或者整个应用中应用一些样式元素,你应该使用主题来代替。一个主题实际上只是一种被广泛应用的风格;但就定义主题而言,它就像一种风格。事实上,主题和样式是可以互换的:你可以将主题扩展成样式,也可以将样式称为主题。通常,只有名称给出了一个提示,说明一个样式是用作样式还是主题。

要为活动或应用指定主题,请为项目的 AndroidManifest.xml 文件中的 <活动> 或 <应用> 标签添加一个属性。代码可能如下所示:

<activity android:theme="@style/MyActivityTheme">
<application android:theme="@style/MyApplicationTheme">
<application android:theme="@android:style/Theme.NoTitleBar">

您可以在 Android 提供的样式所在的文件夹中找到 Android 提供的主题,主题位于一个名为 themes.xml 的文件中。当您查看主题文件时,您会看到一大组定义的样式,它们的名称都以主题开头。你还会注意到,在 Android 提供的主题和风格中,有很多扩展,这就是为什么你最终使用了名为主题的风格。比如 Dialog.AppError 。

我们对 Android 控件集的讨论到此结束。正如我们在本章开始时提到的,在 Android 中构建 ui 需要你掌握两件事:控件集和布局管理器。在下一部分,我们将讨论 Android 布局管理器。

了解布局管理器

Android 提供了一组视图类,作为视图的容器。这些容器类被称为布局(或布局管理器),每个容器类都实现一个特定的策略来管理其子容器的大小和位置。例如, LinearLayout 类一个接一个地水平或垂直布局其子元素。所有的布局管理器都是从视图类中派生出来的,因此你可以将布局管理器相互嵌套。

Android SDK 附带的布局管理器包括表 5-1 中定义的常用管理器。

表 5-1 。 Android 布局管理器

|

布局管理器

|

描述

|
| --- | --- |
| 线性布局 | 水平或垂直组织其子节点 |
| 表格布局 | 以表格形式组织其子节点 |
| 相对布局 | 相对于彼此或相对于父节点组织其子节点 |
| 帧布局 | 允许您动态更改布局中的控件 |
| 网格布局 | 在网格排列中组织其子节点 |

我们将在接下来的章节中讨论这些布局管理器。名为 AbsoluteLayout 的布局管理器已被弃用,不在本书讨论范围内。

线性布局布局管理器

LinearLayout 布局管理器是最基本的。该布局管理器根据方向属性的值水平或垂直组织其子节点。到目前为止,我们已经在几个例子中使用了线性布局。清单 5-6 显示了水平配置的线性布局。

清单 5-6 。 线形布局带 横向配置

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="horizontal"
    android:layout_width="fill_parent"  android:layout_height="wrap_content">

    <!-- add children here-->

</LinearLayout>

通过将方向设置为垂直,可以创建一个垂直方向的线性布局。因为布局管理器可以嵌套,所以您可以构建一个包含水平布局管理器的垂直布局管理器来创建一个填充表单,其中每一行在一个 EditText 控件旁边都有一个标签。每一行都有自己的水平布局,但是集合中的行是垂直组织的。

了解重量和重力

方向属性是线性布局布局管理器识别的第一个重要属性。影响子控件大小和位置的其他重要属性是重量和重力。

您使用 weight 来指定一个控件相对于容器中其他控件的大小重要性。假设一个容器有三个控件:一个权重为 1,而其他的权重为 0。在这种情况下,权重等于 1 的控件将占用容器中的空白空间。引力本质上是对齐的。例如,如果您想将标签的文本向右对齐,您可以将其重力设置为右。重力的可能值有很多,包括左、中、右、上、下、中 _ 垂直、夹 _ 水平等。参见 developer.android.com 了解这些和其他重力值的细节。

注意布局管理器扩展了 android.widget.ViewGroup ,许多基于控件的容器类也是如此,比如 ListView 。尽管布局管理器和基于控件的容器扩展了相同的类,但按照惯例(如果不是严格的要求),布局管理器类处理控件的大小和位置,而不是用户与子控件的交互。

现在让我们看一个涉及重量和重力属性的例子(见图 5-3 )。

9781430246800_Fig05-03.jpg

图 5-3 。使用线性布局布局管理器

图 5-3 显示了三个使用线性布局的用户界面,具有不同的重量和重力设置。左边的用户界面使用重量和重力的默认设置。第一个用户界面的 XML 布局如清单 5-7 所示。

清单 5-7 。 三个 文本字段 以线性布局垂直排列,使用默认的重量和重力值

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical" android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <EditText android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="one"/>
    <EditText android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="two"/>
    <EditText android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="three"/>
</LinearLayout>

图 5-3 中间的 UI 使用默认的 weight 值,但是将容器中控件的 android:gravity 分别设置为左侧、中间和右侧。最后一个示例将中心组件的 android:layout_weight 属性设置为 1.0,并将其他属性保留为默认值 0.0(参见清单 5-8 )。通过将中间组件的权重属性设置为 1.0,并将其他两个组件的权重属性设置为 0.0,我们指定中间组件应该占据容器中所有剩余的空白,而其他两个组件应该保持其理想大小。

类似地,如果您希望容器中的三个控件中的两个共享它们之间剩余的空白,您可以将这两个控件的权重设置为 1.0,将第三个控件的权重设置为 0.0。最后,如果希望三个组件平均共享空间,可以将它们的权重值都设置为 1.0。这样做可以均等地扩展每个文本字段。

清单 5-8 。 线型布局搭配 权重配置

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical" android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <EditText android:layout_width="fill_parent" android:layout_weight="0.0"
    android:layout_height="wrap_content" android:text="one"
    android:gravity="left"/>

    <EditText android:layout_width="fill_parent" android:layout_weight="1.0"
    android:layout_height="wrap_content" android:text="two"
    android:gravity="center"/>

    <EditText android:layout_width="fill_parent" android:layout_weight="0.0"
    android:layout_height="wrap_content" android:text="three"
    android:gravity="right"
    />
</LinearLayout>

安卓:重力 vs 安卓:布局 _ 重力

注意,Android 定义了两个相似的重力属性: android:gravity 和 android:layout_gravity 。区别在于: android:gravity 是视图使用的设置,而 android:layout_gravity 是容器( android.view.ViewGroup )使用的设置。例如,您可以将 android:gravity 设置为 center 以使 EditText 中的文本在控件内居中。同样,你可以通过设置 Android:layout _ gravity = " right "将 EditText 对齐到 LinearLayout (容器)的最右边。参见图 5-4 和清单 5-9 。

9781430246800_Fig05-04.jpg

图 5-4 。应用重力设置

清单 5-9 。 了解安卓:重力和安卓:布局 _ 重力 的区别

<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical" android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <EditText android:layout_width="wrap_content" android:gravity="center"
    android:layout_height="wrap_content" android:text="one"
 android:layout_gravity="right"/>
</LinearLayout>

如图 5-4 所示,文本在编辑文本中居中,与线型布局右侧对齐。

表布局布局管理器

表格布局布局管理器是线性布局的扩展。该布局管理器将其子控件组织成行和列。清单 5-10 显示了一个例子。

清单 5-10 。一个简单的表格布局

<?xml version="1.0" encoding="utf-8"?>
<TableLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:layout_width="fill_parent"  android:layout_height="fill_parent">

  <TableRow>
    <TextView android:text="First Name:"
        android:layout_width="wrap_content"  android:layout_height="wrap_content" />

    <EditText android:text="Edgar"
        android:layout_width="wrap_content"  android:layout_height="wrap_content" />
  </TableRow>

  <TableRow>
    <TextView android:text="Last Name:"
        android:layout_width="wrap_content"  android:layout_height="wrap_content" />

    <EditText android:text="Poe"
        android:layout_width="wrap_content"  android:layout_height="wrap_content" />
  </TableRow>

</TableLayout>

要使用这个布局管理器,您需要创建一个 TableLayout 的实例,并在其中放置 TableRow 元素。这些 TableRow 元素包含了表格的控件。清单 5-10 的用户界面如图 5-5 所示。

9781430246800_Fig05-05.jpg

图 5-5 。表格布局 布局管理器

使用 TableLayout 可以实现许多更复杂的布局,包括嵌套、不对称的行和列等等。我们在图书网站上有一个关于桌面布局、【www.androidbook.com】的更多选项的奖励部分。

相对布局布局管理器

另一个有趣的布局管理器是 RelativeLayout 。顾名思义,这个布局管理器实现了一个策略,其中容器中的控件相对于容器或容器中的另一个控件进行布局。清单 5-11 和图 5-6 显示了一个例子。

清单 5-11 。?? 使用相对布局布局管理器

<RelativeLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content">

<TextView android:id="@+id/userNameLbl"
        android:layout_width="fill_parent"  android:layout_height="wrap_content"
        android:text="Username: "
        android:layout_alignParentTop="true" />

<EditText android:id="@+id/userNameText"
        android:layout_width="fill_parent"  android:layout_height="wrap_content"
        android:layout_toRightOf="@id/userNameLbl" />

<TextView android:id="@+id/pwdLbl"
        android:layout_width="wrap_content"  android:layout_height="wrap_content"
        android:layout_below="@id/userNameText"
        android:text="Password: " />

<EditText android:id="@+id/pwdText"
        android:layout_width="fill_parent"  android:layout_height="wrap_content"
        android:layout_toRightOf="@id/pwdLbl"
        android:layout_below="@id/userNameText" />

<TextView android:id="@+id/pwdCriteria"
        android:layout_width="fill_parent"  android:layout_height="wrap_content"
        android:layout_below="@id/pwdText"
        android:text="Password Criteria... " />

<TextView android:id="@+id/disclaimerLbl"
        android:layout_width="fill_parent"  android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:text="Use at your own risk... " />

</RelativeLayout>

9781430246800_Fig05-06.jpg

图 5-6 。使用相对布局 布局管理器布局的用户界面

如图所示,UI 看起来像一个简单的登录表单。用户名标签被固定在容器的顶部,因为我们将 Android:layout _ alignParentTop 设置为 true 。类似地,用户名输入字段位于用户名标签下方,因为我们设置了 android:layout_below 。密码标签出现在用户名标签下方,密码输入字段出现在密码标签下方。免责声明标签被固定在容器的底部,因为我们将 Android:layout _ alignParentBottom 设置为 true 。

除了这三个布局属性,你还可以指定 layout_above 、 layout_toRightOf 、 layout_toLeftOf 、 layout_centerInParent 等等。使用 RelativeLayout 很有趣,因为它很简单。事实上,一旦你开始使用它,它将成为你最喜欢的布局管理器——你会发现自己一遍又一遍地回到它身边。

框架布局布局管理器

到目前为止,我们讨论的布局管理器实现了各种布局策略。换句话说,每一个都有特定的方式在屏幕上定位和定向它的孩子。有了这些布局管理器,你可以同时在屏幕上有许多控件,每个控件占据屏幕的一部分。Android 还提供了一个布局管理器,主要用来显示单个项目: FrameLayout 。您主要使用这个工具布局类来动态显示单个视图,但是您可以用许多项目填充它,将一个项目设置为可见,而将其他项目设置为不可见。清单 5-12 演示了如何使用框架布局。

清单 5-12 。 填充框架布局

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:id="@+id/frmLayout"
    android:layout_width="fill_parent"  android:layout_height="fill_parent">

     <ImageView
        android:id="@+id/oneImgView" android:src="@drawable/one"
        android:scaleType="fitCenter"
        android:layout_width="fill_parent"  android:layout_height="fill_parent"/>
    <ImageView
        android:id="@+id/twoImgView" android:src="@drawable/two"
        android:scaleType="fitCenter"
        android:layout_width="fill_parent"  android:layout_height="fill_parent"
        android:visibility="gone" />

</FrameLayout>

public class FrameLayoutActivity extends Activity{
    private ImageView one = null;
    private ImageView two = null;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.listing6_48);

        one = (ImageView)findViewById(R.id.oneImgView);
        two = (ImageView)findViewById(R.id.twoImgView);

        one.setOnClickListener(new OnClickListener(){

            public void onClick(View view) {
                two.setVisibility(View.VISIBLE);

                view.setVisibility(View.GONE);
            }});

        two.setOnClickListener(new OnClickListener(){

            public void onClick(View view) {
                one.setVisibility(View.VISIBLE);

                view.setVisibility(View.GONE);
            }});
    }
}

清单 5-12 显示了布局文件以及活动的 onCreate() 方法。演示的想法是在 FrameLayout 中加载两个 ImageView 对象,一次只能看到 ImageView 对象中的一个。在 UI 中,当用户单击可见图像时,我们隐藏一个图像并显示另一个图像。

现在仔细看看清单 5-12 ,从布局开始。你可以看到我们用两个 ImageView 对象定义了一个 FrameLayout (一个 ImageView 是一个知道如何显示图像的控件)。注意,第二个 ImageView 的可见性被设置为消失,使得控件不可见。现在,看看 onCreate() 方法。在 onCreate() 方法中,我们注册侦听器来点击 ImageView 对象上的事件。在点击处理器中,我们隐藏一个 ImageView 并显示另一个。

如前所述,当您需要动态地将视图的内容设置为单个控件时,通常使用 FrameLayout 。虽然这是一般的做法,但控件将接受许多子控件,正如我们所演示的那样。清单 5-12 在布局中添加了两个控件,但每次只能看到其中一个。然而,FrameLayout 并不强迫你一次只能看到一个控件。如果你在布局中添加了很多控件, FrameLayout 会简单地堆叠控件,一个在另一个上面,最后一个在上面。这可以创建一个有趣的 UI。例如,图 5-7 显示了一个 FrameLayout 控件,带有两个可见的 ImageView 对象。您可以看到控件堆叠在一起,顶部的控件部分覆盖了它后面的图像。

9781430246800_Fig05-07.jpg

图 5-7 。 框架布局 带有两个 ImageView 对象

FrameLayout 的另一个有趣的方面是,如果你向布局中添加多个控件,布局的大小将被计算为容器中最大项目的大小。在图 5-7 中,顶部的图像实际上比它后面的图像小得多,但是因为布局的尺寸是根据最大的控件计算的,所以顶部的图像被拉伸了。

还要注意的是,如果你在一个 FrameLayout 中放置了许多控件,其中一个或多个是不可见的,你可能要考虑在你的 FrameLayout 中使用 setMeasureAllChildren(true)。因为最大的子元素决定了布局的大小,如果最大的子元素一开始是不可见的,那么就会有问题:当它变得可见时,它只是部分可见。为了确保所有项目都能正确呈现,调用 setMeasureAllChildren()并向其传递值 true 。 FrameLayout 的等价 XML 属性是 Android:measure all children = " true "。

GridLayout 布局管理器

Android 4.0 带来了一个新的布局管理器,叫做 GridLayout 。正如您所料,它以行和列的网格模式布局视图,有点像 TableLayout 。不过比 TableLayout 好用。使用 GridLayout ,您可以为视图指定行和列值,这就是它在网格中的位置。这意味着您不需要为每个单元格指定一个视图,只需要为那些您想要保存视图的单元格指定一个视图。视图可以跨越多个网格单元。您甚至可以在同一个网格单元中放置多个视图。

布局视图时,不能使用 weight 属性,因为它在 GridLayout 的子视图中不起作用。你可以使用布局 _ 重力属性来代替。您可以在 GridLayout 子视图中使用的其他有趣属性包括 layout_column 和 layout_columnSpan 来分别指定视图最左侧的列和列数。同样,还有 layout_row 和 layout_rowSpan 属性。有趣的是,您不需要为 GridLayout 子视图指定 layout_height 和 layout _ width;它们默认为 WRAP_CONTENT 。

为各种设备配置定制布局

到目前为止,您已经非常了解 Android 提供了大量的布局管理器来帮助您构建 ui。如果您使用过我们讨论过的布局管理器,您会知道您可以用各种方式组合布局管理器来获得您想要的外观和感觉。但是,即使有了所有的布局管理器,构建 ui——并使它们正确——也可能是一个挑战。对于移动设备来说尤其如此。移动设备的用户和制造商变得越来越复杂,这使得开发人员的工作更具挑战性。

挑战之一是为应用构建一个可以在各种屏幕配置中显示的 UI。例如,如果你的应用以纵向和横向模式显示,你的用户界面会是什么样子?如果你还没有遇到这种情况,你现在可能正在思考如何处理这种常见的情况。有趣且幸运的是,Android 为这个用例提供了一些支持。

它是这样工作的:当建立一个布局时,Android 会根据设备的配置从特定的文件夹中找到并加载布局。设备可以有三种配置:纵向、横向或方形(方形很少见)。要为各种配置提供不同的布局,您必须为每个配置创建特定的文件夹,Android 将从这些文件夹中加载适当的布局。如你所知,默认布局文件夹位于 res/layout 。为了支持纵向显示,创建一个名为 res/layout-port 的文件夹。对于风景,创建一个名为 res/layout-land 的文件夹。对于正方形,创建一个名为 res/layout-square 的正方形。

此时一个很好的问题是,“有了这三个文件夹,我还需要默认的布局文件夹( res/layout )吗?”一般来说,是的。Android 的资源解析逻辑首先查看特定于配置的目录。如果 Android 在那里找不到资源,它会转到默认的布局目录。因此,您应该将默认布局定义放在 res/layout 中,并将定制版本放在特定于配置的文件夹中。

另一个技巧是在布局文件中使用 < include / > 标签。这允许您创建布局代码的公共块(例如,在默认的布局目录中)并将它们包含在布局端口和布局端口中定义的布局中。一个包含标签可能看起来像这样:

<include layout="@layout/common_chunk1" />

如果你对包含的概念感兴趣,你还应该看看 Android API 中的 < merge / > 标签和 ViewStub 类。这些给你更多的灵活性,当组织布局,没有重复的观点。

请注意,Android SDK 没有提供任何 API 让您以编程方式指定加载哪个配置——系统只是根据设备的配置选择文件夹。但是,您可以在代码中设置设备的方向,例如,使用以下代码:

import android.content.pm.ActivityInfo;
...
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);

这将强制您的应用以横向模式出现在设备上。请在您的早期项目中尝试一下。将代码添加到 activity 的 onCreate() 方法中,在模拟器中运行它,然后查看您的应用。

摘要

让我们通过快速列举你所学到的关于构建用户界面的知识来结束这一章:

  • 布局的主要类型以及何时使用每种布局
  • Android 支持的视图以及如何用 XML 和代码定义它们
  • 您可以使用样式和主题从一组公共资源中管理应用的外观和感觉

六、使用菜单和操作栏

Android SDK 支持常规菜单、子菜单、上下文菜单、图标菜单和二级菜单。Android 3.0 引入了动作栏,与菜单很好的融合在一起。我们将在本章中讨论菜单和动作栏。

像本书中的许多其他章节一样,我们将展示一些基本的代码片段,您可以用它们来处理菜单和动作栏。这些代码片段的完整代码上下文可以在专门为本章开发的可下载应用中找到。这些可下载项目的链接位于本章末尾的“参考资料”部分。

通过 XML 文件使用菜单

在 Android 中,使用菜单最简单的方法是通过 XML 菜单资源文件。这种创建菜单的 XML 方法有几个优点,比如命名菜单、自动排序菜单和分配 id 的能力。由于 XML 菜单是资源,您还可以获得菜单文本和图标的本地化支持。

创建 XML 菜单资源文件

清单 6-1 给出了一个示例菜单 XML 文件。在这个清单中,您可以看到一系列的菜单项组合在一个组 XML 节点下。您可以使用 @+id 资源引用方法为组指定一个 ID。您可以在 java 代码中使用这个 ID 来访问菜单组,并在需要时管理它。分组是可选的,可以省略 group XML 节点。

每个菜单 XML 文件都有一系列菜单项,它们的菜单项 id 与符号名相关联。标题表示菜单标题, orderInCategory 表示菜单项在菜单中出现的顺序。您可以参考 Android SDK 文档,了解这些 XML 标签的所有可能属性。本章的“参考资料”部分提供了参考 URL。

清单 6-1 。带有菜单定义的菜单 XML 资源文件

<menu xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)">
    <group android:id="@+id/menuGroup_Main">
        <item android:id="@+id/menu_item1"
            android:orderInCategory="1"
            android:title="item1 text" />
        <item android:id="@+id/menu_item2"
            android:orderInCategory="2"
            android:enabled="true"
            android:icon="@drawable/some-file"
            android:title="item2 text" />
        <item android:id="@+id/menu_item3"
            android:orderInCategory="3"
            android:title="item3 text" />
    </group>
</menu>

清单 6-1 中的所有子菜单项都根据它们在 XML 文件中的名称(例如: menu_item1 )分配了菜单项 id。现在让我们看看如何获取这个菜单 XML 文件并将其与一个活动相关联。

从菜单 XML 文件填充活动菜单

假设菜单 XML 文件的名称是 my_menu.xml 。你需要把这个文件放在 /res/menu 子目录中。将文件放在 /res/menu 中会自动生成一个名为 R.menu.my_menu 的资源 ID。

Android 菜单支持中的关键类是 android.view.Menu 。Android 中的每个活动都与一个这种类型的菜单对象相关联。在活动的生命周期中,Android 调用一个名为 onCreateOptionsMenu()的方法来填充这个菜单对象。在这个方法中,我们将 XML 菜单文件加载到菜单对象中。这显示在清单 6-2 中。

清单 6-2 。使用菜单充气器

//This callback method is available on every activity class
@Override
public boolean onCreateOptionsMenu(Menu menu) {
   super.onCreateOptionsMenu(menu);
   MenuInflater inflater = getMenuInflater(); //from activity
   inflater.inflate(R.menu.my_menu, menu);

   //It is important to return true to see the menu
   return true;

}

一旦菜单项被填充,代码应该返回 true 以使菜单可见。如果这个方法返回假,菜单是不可见的。

响应基于 XML 的菜单项

您在 onOptionsItemSelected()回调方法中响应菜单项。Android 不仅为 XML 菜单文件生成一个资源 id(如在清单 6-2 中使用的),而且还生成必要的菜单项 ID 来帮助你区分菜单项。清单 6-3 中的代码说明了如何响应菜单项。

清单 6-3 。响应 XML 菜单资源文件中的菜单项

@Override
public void onOptionsItemSelected (MenuItem item){
   if (item.getItemId() == R.id.menu_item1){
        //do something
        //for items handled
        return true;
   }
   else if (item.getItemId() == R.id.menu_item2){
        //do something
        return true;
   }
   //for the rest
   ...return super.onOptionsItemSelected(item);
}

注意 XML 菜单资源文件中的菜单项名称是如何在 R.id 空间中自动生成菜单项 id 的。

从 SDK 3.0 开始,您还可以使用菜单项的 android:onClick 属性来直接指示附加到该菜单的活动中的方法名称。然后使用菜单项对象作为唯一的输入来调用这个活动方法。该功能仅在 3.0 及以上版本中可用。清单 6-4 显示了一个例子。

清单 6-4 。在 XML 菜单资源文件中指定菜单回调方法

<item android:id="... "
        android:onClick="a-method-name-in-your-activity"
       ...
</item>

在 Android 中使用菜单项就是这么简单。现在让我们探索一下菜单的 Java API。

在 Java 代码中使用菜单

如前所述,Android 菜单支持中的关键类是 android.view.Menu 。Android 中的每个活动都与一个这种类型的菜单对象相关联。然后,菜单对象包含许多菜单项和子菜单。菜单项由 android.view.MenuItem 表示。子菜单以 android.view.SubMenu 为代表。

在 SDK 3.0 之前, onCreateOptionsMenu() 在第一次访问活动的选项菜单时被调用。从 3.0 开始,该方法作为活动创建的一部分被调用。另请注意,此方法在活动的生命周期中只调用一次。如果你想动态添加菜单,你需要使用 onPrepareOptionsMenu()方法,稍后会讲到。清单 6-5 中的代码显示了如何使用一个单独的组 ID 以及递增的菜单项 ID 和订单 ID 来添加三个菜单项。

清单 6-5 。添加菜单项

@Override
public boolean onCreateOptionsMenu(Menu menu){
   super.onCreateOptionsMenu(menu);
   menu.add(0           // Group
         ,1             // item id
         ,0             //order
         ,"item1");     // title

   menu.add(0,2,1,"item2");
   menu.add(0,3,2,"item3");
   //It is important to return true to see the menu
   return true;
}

还应该调用该方法的基类实现,让系统有机会用系统菜单项填充菜单(到目前为止还没有定义系统菜单项)。

清单 6-5 中的解释了创建菜单项的参数。最后一个参数是菜单项的名称或标题。代替自由文本,你可以通过 R.java 的常量文件使用一个字符串资源。组、菜单项和订单 id 都是可选的;你可以使用菜单。无如果你不想指定它们中的任何一个。如果菜单。没有为组指定,则项目不属于任何组。如果菜单。没有为项目指定,那么这可能是一个子菜单或分隔符。如果菜单。没有为顺序指定,Android 将选择一些机制来排序它们。

使用菜单组

现在,让我们看看如何使用菜单组。清单 6-6 展示了如何添加两组菜单:第一组和第二组。

清单 6-6 。使用组 id 创建菜单组

@Override
public boolean onCreateOptionsMenu(Menu menu) {
   //Group 1
   int group1 = 1;
   menu.add(group1,1,1,"g1.item1");
   menu.add(group1,2,2,"g1.item2");

   //Group 2
   int group2 = 2;
   menu.add(group2,3,3,"g2.item1");
   menu.add(group2,4,4,"g2.item2");

   return true; // it is important to return true
}

Android 在 android.view.Menu 类上提供了一组基于组 id 的方法。您可以使用清单 6-7 中所示的方法操作一个组的菜单项:

清单 6-7 。菜单组相关方法

removeGroup(id)
setGroupCheckable(id, checkable, exclusive)
setGroupEnabled(id,enabled)
setGroupVisible(id,visible)

removeGroup() 给定组 ID,从该组中删除所有菜单项。您可以使用 setGroupEnabled 方法 () 来启用或禁用给定组中的菜单项。同样,您可以使用 setgroup visible()来控制一组菜单项的可见性。

setGroupCheckable()有意思。当菜单项被选中时,可以使用此方法在该菜单项上显示复选标记。当应用于一个组时,它为该组中的所有菜单项启用此功能。如果设置了该方法的独占标志,则该组中只有一个菜单项被允许进入选中状态。其他菜单项保持未选中状态。

现在您知道了如何用一组菜单项填充活动的主菜单,并根据它们的性质对它们进行分组。除了菜单项 id 由程序员显式控制之外,您对这些菜单项的响应方式与您对它们的 XML 对应项的响应方式是相同的。

通过监听器响应菜单项

你通常通过覆盖 onOptionsItemSelected() 来响应菜单;菜单项还允许您注册一个可以用作回调的侦听器。这种方法分两步走。第一步,实现菜单项。OnMenuItemClickListener 接口。然后,获取该实现的一个实例,并将其传递给菜单项。当菜单项被点击时,菜单项调用 MenuItem 的 onMenuItemClick() 方法。OnMenuItemClickListener 接口(参见清单 6-8 )。

清单 6-8 。使用监听器作为菜单项点击的回调

//Step 1
public class MyResponse implements MenuItem.OnMenuItemClickListener{
   public MyResponse(...someargs...){} //a constructor
   @override
   public boolean OnMenuItemClick(MenuItem item) {
      //do your thing
      return true;
   }
}

//Step 2
MyResponse myResponse = new MyResponse(..your args..);//supply your args
menuItem.setOnMenuItemClickListener(myResponse);
...

当菜单项被调用时,调用 onMenuItemClick() 方法。菜单项一被单击,甚至在调用 onOptionsItemSelected() 方法之前,这段代码就会执行。如果 onMenuItemClick() 返回 true ,则不执行其他回调,包括 onOptionsItemSelected() 回调方法。这意味着监听器代码优先于 onOptionsItemSelected()方法。

使用意图来响应菜单项

您也可以通过使用 MenuItem 的方法 setIntent(intent) 将菜单项与意图相关联。当一个意图与一个菜单项相关联,并且没有其他东西处理该菜单项时,那么默认行为是使用 startActivity(intent) 调用该意图。为此,所有处理器——尤其是 onOptionsItemSelected() 方法——都应该为那些未被处理的菜单项调用父类的 onOptionsItemSelected() 方法。

了解扩展菜单

如果一个应用的菜单项比它在主屏幕上显示的要多,Android 会多显示个菜单项,让用户可以看到剩下的部分。这个菜单被称为扩展菜单,当有限的空间内显示太多菜单项时,它会自动出现。

使用图标菜单

Android 不仅支持文本,还支持图像或图标作为其菜单的一部分。创建图标菜单项很简单。像以前一样创建一个常规的基于文本的菜单项,然后使用 MenuItem 类上的 setIcon() 方法来设置图像。您需要使用图像的资源 ID,所以您必须首先通过将图像或图标放在 /res/drawable 目录中来生成它。例如,如果图标的文件名是气球,那么资源 ID 就是 R.drawable .气球。清单 6-9 演示了如何给一个菜单项添加一个图标。

清单 6-9 。将图标附加到菜单项

//add a menu item and remember it so that you can use it
//subsequently to set the icon on it.
MenuItem item = menu.add(...);//supply the menu item details
item.setIcon(R.drawable.balloons);

只要菜单项显示在主应用屏幕上,图标就会显示。如果它显示为扩展菜单的一部分,则不显示图标,只显示文本。在 XML 菜单资源文件中,还有一个图标标签可以用来指示图标。在某些情况下,Android 可能会选择不显示图标,并建议始终提供文本。

使用子菜单

一个菜单对象可以有多个子菜单对象。通过调用 Menu.addSubMenu() 方法,将每个子菜单对象添加到菜单对象中(参见清单 6-10 )。向子菜单添加菜单项的方式与向菜单添加菜单项的方式相同。这是因为子菜单 也是从菜单对象中派生出来的。但是,您不能向子菜单添加额外的子菜单。

清单 6-10 。添加子菜单

private void addSubMenu(Menu menu){
   //Secondary items are shown just like everything else
   int base=Menu.FIRST + 100;
   SubMenu sm = menu.addSubMenu(base,base+1,Menu.NONE,"submenu");
   sm.add(base,base+2,base+2,"sub item1");
   sm.add(base,base+3,base+3,"sub item2");
   sm.add(base,base+4,base+4,"sub item3");

   //the following is ok
   sm.setIcon(R.drawable.icon48x48_1);

   //This will result in runtime exception
   //sm.addSubMenu("try this");
}

注意 子菜单,作为菜单对象的子类,继续携带 addSubMenu() 方法。如果你把一个子菜单添加到另一个子菜单,编译器不会报错,但是如果你试图这样做,你会得到一个运行时异常。

Android SDK 文档也建议子菜单不支持图标菜单项。当您将图标添加到菜单项,然后将该菜单项添加到子菜单时,菜单项会忽略该图标,即使您没有看到编译时或运行时错误。但是,子菜单本身可以有图标。

使用上下文菜单 s

Android 通过一个叫做长点击的动作来支持上下文菜单的想法。在任何 Android 视图中,长点击是指长时间按住鼠标,时间比平时稍长。活动拥有常规的选项菜单,而视图拥有上下文菜单。这是意料之中的,因为激活上下文菜单的长时间点击适用于被点击的视图。因此,一个活动只能有一个选项菜单,但可以有许多上下文菜单。

注册上下文菜单的视图

实现上下文菜单的第一步是在活动的 onCreate() 方法中注册上下文菜单的视图。您可以使用清单 6-11 中的代码为上下文菜单注册一个文本视图。首先找到文本视图,然后使用文本视图作为参数,对活动调用 registerForContextMenu() 。这为上下文菜单设置了文本视图。

清单 6-11 。为上下文菜单注册一个文本视图

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    TextView tv = (TextView)this.findViewById(R.id.textViewId);
    registerForContextMenu(tv);
}

填充上下文菜单

一旦像本例中的 TextView 这样的视图被注册为上下文菜单,Android 就调用 onCreateContextMenu() 方法,将该视图作为参数。这是您可以填充该上下文菜单的上下文菜单项的地方。 onCreateContextMenu() 回调方法提供了三个参数。清单 6-12 演示了 onCreateContextMenu() 方法。

清单 6-12 。onCreateContextMenu()方法

@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo){
        menu.setHeaderTitle("Sample Context Menu");
        menu.add(200, 200, 200, "item1");
}

第一个参数是预构造的 ContextMenu 对象,第二个是生成回调的视图(如 TextView ),第三个是 ContextMenuInfo 类。对于许多简单的情况,您可以忽略 ContextMenuInfo 对象。但是,某些视图可能会通过该对象传递额外的信息。在这些情况下,您需要将 ContextMenuInfo 类转换为一个子类,然后使用附加方法来检索附加信息。

从 ContextMenuInfo 派生的类的一些例子包括 AdapterContextMenuInfo 和 ExpandableListContextMenuInfo。作为 AdapterViews 的视图,比如 Android 中的 ListView 使用 AdapterContextMenuInfo 类来传递显示上下文菜单的视图中的行 id。从某种意义上说,您可以使用这个类来进一步澄清触摸或点击下面的对象,甚至是在给定的复合视图中。

响应上下文菜单项

Android 提供了一个类似于 onOptionsItemSelected() 的回调方法叫做 onContextItemSelected()。清单 6-13 演示了 onContextItemSelected()。

清单 6-13 。响应上下文菜单

//This method is available for all activities @Override
 public boolean onContextItemSelected(MenuItem item) {
     if (item.getItemId() == some-menu-item-id)     {
        //handle this menu item
        return true;
     }
... other exception processing
}

整合动态菜单

到目前为止,我们已经讨论了静态菜单——你设置了一次,它们不会根据屏幕上的内容动态变化。如果你想创建动态菜单,使用 Android 在活动类上提供的 onPrepareOptionsMenu() 方法。这个方法类似于 onCreateOptionsMenu() ,除了它在每次显示菜单之前被调用。如果你的菜单有动态菜单选项,你应该使用 onPrepareOptionsMenu() 和 onCreateOptionsMenu() 来有效地管理你的菜单。 onPrepareOptionMenu() 是根据您正在显示的内容启用或禁用某些菜单项或菜单组的地方。对于 3.0 及更高版本,当您想要更改菜单时,因为像操作栏这样的菜单相关组件总是显示,所以您必须显式调用一个名为 activity . invalidateoptions menu()的新预配方法,该方法又调用 onCreateOptionsMenu() 并重新绘制菜单,从而也导致在显示之前调用 onPrepareOptionsMenu() 。只要应用状态发生变化,需要更改菜单,就可以调用此方法。

使用弹出式菜单

Android 3.0 引入了另一种类型的菜单,称为弹出菜单。SDK 4.0 通过在 PopupMenu 类中添加几个实用方法(例如, PopupMenu.inflate )稍微增强了这一点。(参见弹出菜单 API 文档以了解这些方法。清单 6-14 也引起了对这种差异的注意。)

可以针对任何视图调用弹出菜单来响应任何 UI 事件。UI 事件的一个例子是按钮点击或图像视图上的点击。图 6-1 显示了一个针对视图调用的弹出菜单。

9781430246800_Fig06-01.jpg

图 6-1 。附加到文本视图的弹出菜单

要创建一个类似于图 6-1 中的弹出菜单,从一个常规的 XML 菜单文件开始,使用清单 6-14 中的 Java 代码加载这个菜单 XML 作为弹出菜单。如果您想查看完整的实现,请参阅本章的可下载项目。

清单 6-14 。使用弹出式菜单

//Other activity code goes here...
//Invoke the following method to show a popup menu
private void showPopupMenu() {
    //Get hold of a view to anchor the popup
    TextView tv = findViewById(R.id.SOME_TEXT_VIEW_ID);

    //instantiate a popup menu. var "this" stands for activity
    PopupMenu popup = new PopupMenu(this, tv);

    //the following code for 3.0 sdk
    //popup.getMenuInflater().inflate(R.menu.popup_menu, popup.getMenu());
    //Or in sdk 4.0 and above
    popup.inflate(R.menu.popup_menu);
    popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
       public boolean onMenuItemClick(MenuItem item) {
             //do something here
             return true; } } );
    popup.show();
}

如您所见,弹出菜单的行为很像选项菜单。主要区别如下:

  • 弹出菜单按需使用,而选项菜单总是可用的。
  • 弹出菜单锚定到一个视图,而选项菜单属于整个活动。
  • 弹出菜单使用自己的菜单项回调,而选项菜单使用活动上的 onOptionsItemSelected() 回调。

探索动作栏

在 Android 3.0 中引入并在 Android 4.0 中扩展的 ActionBar 将菜单的范围扩展到了活动的标题栏。这允许用户容易地获得频繁使用的动作,而无需搜索选项菜单或上下文菜单。除了图标和菜单项,动作栏可以容纳其他视图,如标签、列表或搜索框,以帮助导航。图 6-2 显示了选项卡导航模式下的动作栏。

9781430246800_Fig06-02.jpg

图 6-2 。带有选项卡式操作栏的活动

您可以在这里看到动作栏的各个部分。操作栏左上角的图标称为主页图标。点击这个 Home 图标会向菜单 ID 为 android 的选项菜单发送一个回调。R.id.home 主页图标后面是此活动的标题区域。然后您会看到一组选项卡(或者一个下拉列表,如果这是一个基于列表的动作栏的话)。在中间,你可以看到搜索视图。快结束时,你会看到一组动作图标。该操作栏的最后一部分是一条垂直虚线,代表该活动的菜单。当你点击该图标时,会出现一个标准的下拉菜单(参见图 6-3 )。

你在图 6-1 中看到的动作条是一个选项卡式动作条。动作栏的另外两种模式是标准模式和列表模式。在列表操作栏中,选项卡由下拉列表代替。在标准操作栏中,没有为列表或选项卡留出区域。现在,让我们向您展示如何实现一个简单的标准动作栏。

实现标准动作栏

清单 6-15 展示了为一个活动实现一个标准导航动作栏的示例源代码。

清单 6-15 。标准导航操作栏活动

public class StandardNavigationActionBarActivity extends Activity {
     //    ..... other code
    @Override
    public void onCreate(Bundle savedInstanceState)     {
        super.onCreate(savedInstanceState);

        ActionBar bar = this.getActionBar();
        bar.setTitle("Some title of your choosing");
        bar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
    }
    public boolean onCreateOptionsMenu(Menu mainMenu) {
       //load the menu xml file into the mainMenu object as usual here
       return true;
    }
}

从清单 6-15 中可以看出,使用动作栏很容易。注意在清单中我们如何使用 getActionBar() 来访问动作栏对象,然后设置它的标题和导航模式。您在 onCreateOptionsMenu() 中设置的任何菜单都可以直接从操作栏中调用,如图图 6-3 所示。(然而,当菜单以这种方式从动作栏呈现时,由于空间限制,系统可能不显示图标和菜单文本。)

9781430246800_Fig06-03.jpg

图 6-3 。带有操作栏和扩展菜单的活动

随着动作栏的引入,菜单 XML 文件通过新的属性得到了增强,以指示一些菜单项在动作栏中直接显示为图标。(您可以在图 6-3 中展开菜单上方的操作栏中看到这些图标)。清单 6-16 中的 XML 菜单文件示例演示了如何将一个菜单项直接指定为动作栏上的一个图标。

清单 6-16 。此项目的菜单 XML 文件

<!-- /res/menu/menu.xml -->
<menu
xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)">
    <!-- This group uses the default category. -->
    <group android:id="@+id/menuGroup_Main">
        <!-- a regular menu item -->
        <item android:id="@+id/menu_da_clear"
            android:title="clear" />
        <!--item to be shown directly on the action bar-->
        <item android:id="@+id/menu_action_icon1"
            android:title="Action Icon1"
            android:icon="@drawable/creep001"
            android:showAsAction="ifRoom"/>
        <!-- ..other menu items-->
    </group>
</menu>

要在动作栏上显示的菜单项用标签 showAsAction 表示。在前面的代码中,该属性被设置为“ ifRoom ”。这个 XML 标签的其他可能值如下:总是,从不,带文本, collapseActionView 。您也可以使用在 MenuItem 类上可用的 Java API 来实现相同的效果。选项始终表示“在动作栏中将此项显示为按钮”选项从不表示“从不显示该项目”选项 withText 表示“显示此项目及其文本标签和图标”选项 collapseActionView 的意思是“当没有被选中时,折叠这个动作菜单项的动作视图所占用的空间。”因为这些动作仅仅是菜单项,所以它们的行为也是如此,并调用 activity 类的 onOptionsItemSelected()回调方法。

实现选项卡式操作栏

清单 6-17 展示了如何设置一个选项卡式动作栏。

清单 6-17 。选项卡-启用导航的操作栏活动

//Activity Source code
public class TabNavigationActionBarActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState)   {
        super.onCreate(savedInstanceState);
        workwithTabbedActionBar();
    }
    public void workwithTabbedActionBar()    {
        ActionBar bar = this.getActionBar();
        bar.setTitle(tag);
        bar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
        TestTabListener tl = new TestTabListener();
        Tab tab1 = bar.newTab();
        tab1.setText("Tab1");  tab1.setTabListener(tl);   bar.addTab(tab1);
        Tab tab2 = bar.newTab();
        tab2.setText("Tab2");   tab2.setTabListener(tl);   bar.addTab(tab2);
    }
}//eof-class

选项卡式操作栏,顾名思义,有多个选项卡。在清单 6-17 中,你可以看到有一些额外的方法和类被用来处理标签动作栏。与标准动作栏不同,选项卡式动作栏需要每个选项卡都有一个选项卡监听器。这个监听器需要实现 TabListener 接口。在清单 6-18 中,类 TestTabListener 实现了 TabListener 接口。如果您忘记在添加到操作栏的选项卡上调用 setta listener()方法,您会得到一个运行时错误,指示需要一个侦听器。清单 6-18 显示了测试列表器类的代码。

清单 6-18 。响应选项卡操作的选项卡监听器

public class TestTabListener implements ActionBar.TabListener {
   // constructor code
    public TestTabListener(){}
   // callbacks
    public void onTabReselected(Tab tab, FragmentTransaction ft)  {
       //apply necessary logic here
    }
    public void onTabSelected(Tab tab, FragmentTransaction ft)   {
       //apply necessary logic here
    }
    public void onTabUnselected(Tab tab, FragmentTransaction ft)   {
       //apply necessary logic here
    }
}

当标签被选中和取消选中时,清单 6-18 中的回调方法将被调用。动作栏是活动的属性,不跨越活动边界。换句话说,我们不能使用一个动作栏来控制或影响多个活动。每个活动必须提供自己的动作栏。动作栏之间动作的任何共性都留给程序员来编排。

在清单 6-17 中,一旦我们获得了活动的动作栏,我们就将其导航模式设置为动作栏。导航 _ 模式 _ 标签页。另外两种可能的动作栏导航模式是导航 _ 模式 _ 列表和导航 _ 模式 _ 标准。现在让我们看看如何实现一个基于列表的动作栏。

实现基于列表的动作栏

为了能够用列表导航模式初始化操作栏,您需要以下两样东西:

  • 可用于填充导航选项下拉列表的微调器适配器。
  • 一个列表导航监听器,当一个列表项被选中时,你可以得到一个回调。

清单 6-19 展示了实现 SpinnerAdapter 接口的 simplespinnerrayadapter。如前所述,这个类的目标是给出要显示的项目列表。

清单 6-19 。为列表导航创建微调器适配器

public class SimpleSpinnerArrayAdapter extends ArrayAdapter<String>
implements SpinnerAdapter {
    public SimpleSpinnerArrayAdapter(Context ctx)    {
        super(ctx,
          android.R.layout.simple_spinner_item,
          new String[]{"one","two"});

        this.setDropDownViewResource(
          android.R.layout.simple_spinner_dropdown_item);
    }
    public View getDropDownView(int position, View convertView, ViewGroup parent) {
        return super.getDropDownView(
          position, convertView, parent);
    }
}

没有直接实现列表导航所需的 SpinnerAdapter 接口的 SDK 类。因此,您从一个 ArrayAdapter 中派生出这个类,并为 SpinnerAdapter 提供一个简单的实现。在本章的最后是一个关于 spinner 适配器的参考 URL,供进一步阅读。现在让我们转到列表导航监听器。这是一个实现 ActionBar 的简单类。OnNavigationListener 。清单 6-20 显示了这个类的代码。

清单 6-20 。为列表导航创建列表侦听器

public class ListListener implements ActionBar.OnNavigationListener {
    //simple constructor...
    public ListListener(){}
    //needed callback to respond to actions
    public boolean onNavigationItemSelected(int itemPosition, long itemId)  {
        //respond and return true
        return true;
    }
}

现在,您已经具备了设置列表导航操作栏所需的条件。使用基于列表的动作栏所需的源代码如清单 6-21 所示。

清单 6-21 。列出导航操作栏活动

//Activity Source code
public class TabNavigationActionBarActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState)   {
        super.onCreate(savedInstanceState);
        workwithTabbedActionBar();
    }
   public void workwithListActionBar()  {
       ActionBar bar = this.getActionBar();
       bar.setTitle("title");
       bar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
       bar.setListNavigationCallbacks(
          new SimpleSpinnerArrayAdapter(this),
          new ListListener());
   }
}//eof-class

图 6-4 显示了一个列表条动作条展开后的样子。

9781430246800_Fig06-04.jpg

图 6-4 。打开导航列表的活动

这就是我们如何将动作栏用于常规菜单、选项卡式导航和基于列表的导航的结论。现在让我们看看如何嵌入一个搜索视图,如图 6-2 所示。

浏览操作栏和搜索视图

本节展示了如何在操作栏中使用搜索小部件。要在操作栏中使用搜索,您需要具备以下条件:

  1. 在指向 SDK 提供的搜索视图类的菜单 XML 文件中定义菜单项。您还需要一个活动来加载这个菜单。这通常称为搜索调用者活动
  2. 创建另一个活动,该活动可以从步骤 1 中的搜索视图中获取查询并提供结果。这通常称为搜索结果活动
  3. 创建一个 XML 文件,允许您在操作栏中自定义搜索视图。这个文件通常被称为 searchable.xml ,位于 res/xml 子目录中。
  4. 在清单文件中声明搜索结果活动。这个定义需要指向步骤 3 中定义的 XML 文件。
  5. 在搜索调用者活动的菜单设置中,指出搜索视图需要以步骤 2 中的搜索结果活动为目标。

让我们从搜索视图小部件开始。

将搜索视图小部件定义为菜单项

要定义一个出现在活动操作栏中的搜索视图,您需要在一个菜单 XML 文件中定义一个菜单项,如清单 6-22 中的所示。

清单 6-22 。搜索视图菜单项定义

<item android:id="@+id/menu_search"
    android:title="Search"
    android:showAsAction="ifRoom"
    android:actionViewClass="android.widget.SearchView"
    />

清单 6-22 中的关键元素是指向 Android . widget . search view 的 actionViewClass 属性。在本章前面,当您声明您的普通菜单项在动作栏中显示为动作图标时,您已经看到了其他属性。

创建搜索结果活动

要在应用中启用搜索,您需要一个能够响应搜索查询的活动。这可以像任何其他活动。清单 6-23 中显示了一个例子。

清单 6-23 。搜索结果活动

public class SearchResultsActivity extends Activity {
    public static String tag = "SearchResultsActivity ";
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        final Intent queryIntent = getIntent();
        doSearchQuery(queryIntent);
    }
    @Override
    public void onNewIntent(final Intent newIntent) {
        super.onNewIntent(newIntent);
        final Intent queryIntent = getIntent();
        doSearchQuery(queryIntent);
    }
    private void doSearchQuery(final Intent queryIntent) {
        final String queryAction = queryIntent.getAction();
        if (!(Intent.ACTION_SEARCH.equals(queryAction)))    {
            Log.d(tag,"intent NOT for search");
            return;
        }
        final String queryString = queryIntent.getStringExtra(SearchManager.QUERY);
        Log.d(tag, queryString);
    }
}//eof-class

在清单 6-23 中,活动检查调用它的动作是否是由搜索发起的。或者,这个活动可能是新创建的,或者只是放在顶部,在这种情况下,它需要做一些与它的 onNewIntent() 方法中的 onCreate() 方法相同的事情。另一方面,如果这个活动由 search 调用,它将使用一个名为 SearchManager 的额外参数来检索查询字符串。查询 。然后,活动记录该字符串是什么。在真实的场景中,您将使用该字符串来绘制匹配的结果。

指定可搜索的 XML 文件

正如前面的步骤所指出的,让我们看看定制搜索小部件所需的 XML 文件;参见清单 6-24 。

清单 6-24 。可搜索的 XML 文件

<!-- /res/xml/searchable.xml -->
<searchable xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:label="@string/search_label"
    android:hint="@string/search_hint"
/>

提示属性将作为提示出现在搜索视图小部件上,当您开始键入时,该提示将消失。标签在动作栏中并不起重要作用。但是,当您在搜索对话框中使用相同的搜索结果活动时,该对话框会在此处定义标签。您可以通过以下 URL 了解有关可搜索 XML 属性的更多信息:

[`developer.android.com/guide/topics/search/searchable-config.html`](http://developer.android.com/guide/topics/search/searchable-config.html)

在清单文件中定义搜索结果活动

现在让我们看看如何将这个 XML 文件与搜索结果活动联系起来。这是在清单文件中完成的,作为定义搜索结果活动的一部分:参见清单 6-25 。注意指向可搜索的 XML 文件资源的元数据定义。

清单 6-25 。将活动绑定到它的 Searchable.xml

<activity android:name=".SearchResultsActivity"
   android:label="Search Results">
     <intent-filter>
        <action android:name="android.intent.action.SEARCH"/>
     </intent-filter>
     <meta-data android:name="android.app.searchable"
                android:resource="@xml/searchable"/>
</activity>

标识搜索视图小部件的搜索目标

到目前为止,您的操作栏中有 search 视图,并且您有可以响应 search 的活动。您需要使用 Java 代码将这两部分结合在一起。作为设置菜单的一部分,您可以在搜索调用活动的 onCreateOptions() 回调中这样做。清单 6-26 中的函数可以从 onCreateOptions() 中调用,以链接搜索视图小部件和搜索结果活动。

清单 6-26 。将搜索视图小部件绑定到搜索结果活动

private void setupSearchView(Menu menu) {
  //Step1: Locate the search view widget
  SearchView searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
  //report error and return if searchView is null

  //Step2: get SearchManager and searchableInfo
  SearchManager
  searchManager = (SearchManager)getSystemService(Context.SEARCH_SERVICE);
  ComponentName cn = new ComponentName(this,SearchResultsActivity.class);
  SearchableInfo info = searchManager.getSearchableInfo(cn);
  //report error and return if searchable info is null

  //Step3: set searchableInfo on the searchview widget
  searchView.setSearchableInfo(info);
  // Do not iconify the widget; expand it by default
   searchView.setIconifiedByDefault(false);
}

让我们看看清单 6-26 中发生了什么。这段代码的目标是告诉搜索视图在哪里可以找到定义搜索行为的 searchable.xml 。为此,第一步是获取对 SearchView 的引用。这是通过菜单对象完成的。第二步是询问系统范围的搜索管理器什么可搜索的 XML 文件与活动 SearchResultsActivity 相关联。这是通过调用 SearchManager 系统服务上的方法 getSearchableInfo来完成的。一旦我们有了代表 XML 文件的 SearchableInfo 对象,我们就将该信息传递给 SearchView 对象。有了所有这些,现在如果您在搜索框中键入一些内容,这些信息将被传递给搜索结果活动,它将显示结果。

Android Search API 是一个很大的 API,有很多细微差别,由于篇幅原因,我们没有包括在本书中。有三点建议。我们在“参考资料”部分提供了一个 URL,指向一系列关于 Google search API 的文章和注释。我们也有一个关于搜索的大章节,可以在网上找到。这个链接也在“参考资料”一节中。我们还更新了上一版本的搜索资料,并将这些内容添加到了来自 Apress 的专家 Android 版本中。

资源

当您了解并使用 Android 菜单和操作栏时,您可能希望将以下 URL 放在手边:

  • :谷歌描述如何使用菜单的主要文档。
  • developer . Android . com/guide/topics/resources/menu-resource . html:关于菜单资源中可以使用的各种 XML 标签的信息。
  • developer . Android . com/reference/Android/app/ActionBar . html:用于 ActionBar 类的 API URL。
  • :我们对动作栏的研究,包括进一步的参考资料列表、样本代码、例子的链接,以及代表各种动作栏模式的 UI 图。
  • :要设置列表导航模式,你需要了解下拉列表和微调器是如何工作的。这篇简短的文章展示了一些关于如何在 Android 中使用 spinners 的示例和参考链接。
  • :解释搜索如何工作,帮助你最大限度地利用动作栏。
  • 【http://www.androidicons.com】:本章中用到的几个图标都是从这个网站借来的。这些图标受知识共享许可 3.0 的保护。
  • www.androidbook.com/item/3302:“讨好安卓布局。”简单布局的一些注释和示例代码。
  • :你可以在这里找到上一版搜索章节的免费拷贝。这为 Android 搜索提供了广泛的覆盖面。
  • 【http://androidbook.com/proandroid5/projects】:本书项目下载网址。本章可下载的项目 ZIP 文件是 pro Android 5 _ ch06 _ test menus . ZIP 和 pro Android 5 _ ch06 _ testactionbar . ZIP。

摘要

菜单和动作栏是编写移动应用不可或缺的一部分。本章包括常规菜单、上下文菜单、弹出菜单、标准动作栏、选项卡式动作栏和基于列表的动作栏。本章还介绍了如何在操作栏中嵌入搜索视图小部件的基础知识。

七、样式和主题

到目前为止,我们已经介绍了 Android 用户界面(UI)的一些基础知识。在这一章中,我们将讨论样式和主题,它们有助于封装控件外观属性,以便于设置和维护。Android 提供了几种改变应用中视图风格的方法,包括 XML 和代码。我们将首先介绍在字符串中使用标记标签,然后介绍如何使用 spannables 来改变文本的特定视觉属性。但是,如果您想对几个视图或整个活动或应用使用一个通用的规范来控制事物的外观,该怎么办呢?我们将讨论 Android 风格和主题,向您展示如何操作。

使用样式

有时,您想要突出显示视图内容的一部分或设置其样式。您可以静态或动态地做到这一点。静态地,你可以将标记直接应用到你的字符串资源中的字符串,如下所示:

<string name="styledText"><i>Static</i> style in a <b>TextView</b>.</string>

然后,您可以在 XML 或代码中引用它。注意,可以对字符串资源使用以下 HTML 标记: < i > 、 < b > 和 < u > ,分别用于斜体、粗体和下划线,以及 < sup > (上标)、 < sub > (下标)、 < strike > (删除线)、你甚至可以嵌套它们来得到,例如,小的上标。这不仅适用于文本视图,也适用于其他视图,比如按钮。图 7-1 展示了样式化和主题化的文本,使用了本节中的许多例子。

9781430246800_Fig07-01.jpg

图 7-1 。风格和主题示例

以编程方式设计一个 TextView 控件的内容需要一点额外的工作,但是允许更多的灵活性(见清单 7-1 ),因为你可以在运行时设计它。不过,这种灵活性只能应用于 spannable,这就是 EditText 通常管理内部文本的方式,而 TextView 通常不使用 Spannable 。span able 基本上是一个可以应用样式的字符串。要让 TextView 将文本存储为 spannable,可以这样调用 setText :

tv.setText("This text is stored in a Spannable", TextView.BufferType.SPANNABLE);

然后,当你调用 tv.getText 时,你会得到一个 spannable。

如清单 7-1 中的所示,您可以获取 EditText 的内容(作为一个 Spannable 对象),然后为部分文本设置样式。清单中的代码将文本样式设置为粗体和斜体,并将背景设置为红色。您可以使用所有的样式选项,就像我们之前描述的 HTML 标签一样,还可以使用其他选项。

清单 7-1 。将样式动态应用于编辑文本的内容

EditText et =(EditText)this.findViewById(R.id.et);
et.setText("Styling the content of an EditText dynamically");
Spannable spn = (Spannable) et.getText();
spn.setSpan(new BackgroundColorSpan(Color.RED), 0, 7,
             Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spn.setSpan(new StyleSpan(android.graphics.Typeface.BOLD_ITALIC),
             0, 7, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

这两种样式化技术只对它们所应用的一个视图有效。Android 提供了一种样式机制来定义跨视图重用的通用样式,还提供了一种主题机制,它基本上将一种样式应用于整个活动或整个应用。首先,我们需要谈谈风格。

一个样式是一个有名称的视图 属性的集合,所以你可以通过它的名称来引用这个集合,并通过名称将那个样式分配给视图。例如,清单 7-2 显示了一个资源 XML 文件,保存在 /res/values 中,我们可以将它用于所有的错误消息。

清单 7-2 。定义在多个视图中使用的样式

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="ErrorText">
        <item name="android:layout_width">fill_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:textColor">#FF0000</item>
        <item name="android:typeface">monospace</item>
    </style>
</resources>

定义了视图的大小以及字体颜色(红色)和字样。注意项目标签的名称属性(例如 android:layout_width )是我们在前面章节的布局 XML 文件中使用的 XML 属性名称,并且项目标签的值不再需要双引号。我们现在可以对一个错误使用这种样式 TextView ,如清单 7-3 所示。

清单 7-3 。在视图中使用样式

<TextView  android:id="@+id/errorText"
    style="@style/ErrorText"
    android:text="No errors at this time"
    />

需要注意的是,在这个视图定义中,样式的属性名不是以 android: 开头的。注意这一点,因为除了风格,所有东西似乎都用 android: 。当您的应用中有许多共享一种样式的视图时,在一个地方改变该样式就简单多了;您只需要在一个资源文件中修改样式的属性。

当然,您可以为各种控件创建许多不同的样式。例如,按钮可以共享不同于菜单中文本的通用样式的通用样式。常见的是用样式管理文本属性,包括 android:textColor、android:textStyle、android:textSize。样式使用的其他常见属性包括填充值、android:background 和颜色。

样式的一个非常好的方面是你可以设置它们的层次结构。我们可以为非常糟糕的错误消息定义一种新的样式,并以 ErrorText 的样式为基础。清单 7-4 展示了这可能是什么样子。

清单 7-4 。从父样式定义样式

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="ErrorText.Danger" >
        <item name="android:textStyle">bold</item>
    </style>
</resources>

这个例子表明,我们可以简单地使用父样式作为新样式名的前缀来命名我们的子样式。因此, ErrorText。危险 是 ErrorText 的子节点,继承了父节点的样式属性。然后为 textStyle 添加一个新属性。这可以一次又一次地重复,以创建一个完整的风格树。

和适配器布局一样,Android 提供了大量我们可以使用的样式。要指定 Android 提供的样式,使用如下语法:

style="@android:style/TextAppearance"

这个样式设置了 Android 中文本的默认样式。要找到主 Android styles.xml 文件,请访问安装 Android SDK 的 Android SDK/platforms//data/RES/values/文件夹;是您想要查看样式的 Android 的特定版本。在这个文件中,您会发现许多现成的样式供您使用或扩展。关于@ Android:style/text appearance 的一个快速说明:这种样式没有设置 android:layout_height 或 android:layout_width ,因此一个视图规范需要比这种样式更多的样式才能正确编译。

这里有一个关于扩展 Android 提供的样式的警告:以前使用前缀的方法不适用于 Android 提供的样式。相反,您必须使用样式标签的父属性,如下所示:

<style name="CustomTextAppearance" parent="@android:style/TextAppearance">
    <item  ... your extensions go here ...    />
</style>

你不必总是在你的视图中引入一个完整的样式。你可以选择借用这种风格的一部分。例如,如果您想将文本视图中的文本颜色设置为系统样式颜色,您可以执行以下操作:

<TextView android:id="@+id/tv2"
    android:layout_width="fill_parent"  android:layout_height="wrap_content"
    android:textColor="?android:textColorSecondary"
    android:text="@string/hello_world" />

注意,在这个例子中, textColor 属性值的名称以开始。字符代替了 @ 字符。?使用了字符,所以 Android 知道在当前主题中寻找一个样式值。因为我们看到?android ,我们在 android 系统主题中寻找这个样式值。

使用主题

样式的一个问题是,您需要添加一个属性规范 style="@style/... "应用到您希望它应用到的每个视图定义。如果您希望在整个活动或整个应用中应用一些样式元素,您应该使用主题。一个主题实际上只是一种被广泛应用的风格;但就定义主题而言,它就像一种风格。事实上,主题和样式是可以互换的:你可以将主题扩展成样式,也可以将样式称为主题。通常,只有名称给出了一个提示,说明一个样式是用作样式还是主题。

要为活动或应用指定一个主题,您需要为项目的 AndroidManifest.xml 文件中的 <活动> 或 <应用> 标签添加一个属性。代码可能类似于以下代码之一:

<activity android:theme="@style/MyActivityTheme">
<application android:theme="@style/MyApplicationTheme">
<application android:theme="@android:style/Theme.NoTitleBar">

您可以在 Android 提供的样式所在的文件夹中找到 Android 提供的主题,主题位于一个名为 themes.xml 的文件中。当您查看主题文件时,您会看到一大组定义的样式,它们的名称都以主题开头。把最后一句读几遍可能会有好处。换句话说,所有的样式和主题都是类型样式,即使样式名称中有“主题”。你还会注意到,在 Android 提供的主题和样式中,有很多扩展,这就是为什么你最终会有被称为主题的样式。比如 Dialog.AppError 。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

  • :与本书相关的可下载项目列表。对于这一章,寻找一个名为 pro Android 5 _ Ch07 _ styles . ZIP 的 ZIP 文件。这个 ZIP 文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何将项目从这些 ZIP 文件之一导入到您的 IDE 中。
  • :Android 风格和主题指南。

摘要

让我们通过快速列举你所学到的风格和主题来结束这一章:

  • 样式只是视图属性的集合,便于在视图、活动和应用之间重用。
  • 您可以创建自己的样式、使用预定义的样式或扩展现有样式。
  • 当主题被应用到一个活动或应用时,你称之为风格。

八、片段

到目前为止,我们已经探索了 Android 应用的一些片段,并且您已经运行了一些为智能手机大小的屏幕定制的简单应用。您所要考虑的就是如何在屏幕上为一个活动安排 UI 控件,以及一个活动如何流向下一个活动,等等。对于 Android 的前两个主要版本,小屏幕是它。然后是安卓平板电脑:屏幕尺寸为 10 英寸的设备。这让事情变得复杂。为什么?因为现在屏幕空间太大了,以至于一个简单的活动很难在保持单一功能的同时填满整个屏幕。让一个电子邮件应用在一个活动中只显示标题(占据一个大屏幕),而在一个单独的活动中显示一封电子邮件(也占据一个大屏幕),这已经没有意义了。有了这么大的空间,应用可以在屏幕的左侧显示电子邮件标题列表,在屏幕的右侧显示选定的电子邮件内容。它能在一个布局单一的活动中完成吗?是的,但是你不能在任何小屏幕设备上重复使用这个活动或布局。

Android 3.0 中引入的核心类之一是 Fragment 类,它是专门为帮助开发人员管理应用功能而设计的,因此它将提供出色的可用性以及大量的重用。本章将向你介绍片段,它是什么,它如何适应应用的架构,以及如何使用它。片段让很多以前很难的有趣的事情成为可能。几乎在同一时间,谷歌发布了一个片段 SDK,可以在旧的 Androids 上工作。因此,即使你对编写平板电脑应用不感兴趣,你也可能会发现片段让你在非平板设备上的生活更加轻松。现在,为智能手机、平板电脑甚至电视和其他设备编写优秀的应用比以往任何时候都更容易。

让我们从 Android 片段开始。

什么是片段?

第一部分将解释什么是片段以及它的作用。但是首先,让我们来看看为什么我们需要片段。正如您之前了解到的,小屏幕设备上的 Android 应用使用活动向用户显示数据和功能,每个活动都有一个相当简单、定义明确的目的。例如,一个活动可能向用户显示他们地址簿中的联系人列表。另一个活动可能允许用户键入电子邮件。Android 应用是将这些活动组合在一起以实现更大目的的一系列活动,例如通过阅读和发送消息来管理电子邮件帐户。这对于小屏幕设备来说很好,但是当用户的屏幕非常大(10 英寸或更大)时,屏幕上的空间可以做不止一件简单的事情。应用可能希望让用户查看收件箱中的电子邮件列表,同时在列表旁边显示当前选择的电子邮件文本。或者应用可能希望显示联系人列表,同时在详细视图中显示当前选择的联系人。

作为一名 Android 开发人员,你知道这个功能可以通过用列表视图和布局以及各种其他视图为 xlarge 屏幕定义另一种布局来实现。我们所说的“另一种布局”是指除了那些你可能已经为小屏幕定义的布局之外的布局。当然,你会希望有单独的布局为纵向案件以及横向案件。对于 xlarge 屏幕的大小,这可能意味着所有标签、字段和图像等的视图非常多,您需要对其进行布局,然后提供代码。如果有一种方法可以将这些视图对象组合在一起并整合它们的逻辑,以便应用的大部分可以跨屏幕大小和设备重用,从而最大限度地减少开发人员维护应用的工作量,那该多好。这就是为什么我们有片段。

将片段视为子活动的一种方式。事实上,片段的语义很像一个活动。一个片段可以有一个与之关联的视图层次结构,并且它有一个生命周期,就像一个活动的生命周期一样。片段甚至可以像活动一样响应后退按钮。如果你在想,“要是我能在平板电脑的屏幕上同时进行多项活动就好了”,那么你就对了。但是因为在平板电脑屏幕上同时激活一个应用的多个活动太麻烦了,所以创建了片段来实现这个想法。这意味着片段包含在活动中。片段只能存在于活动的上下文中;没有活动就不能使用片段。片段可以与活动的其他元素共存,这意味着您不需要转换活动的整个用户界面来使用片段。您可以像以前一样创建一个活动的布局,并且只对用户界面的一部分使用一个片段。

然而,当涉及到保存状态和稍后恢复它时,片段不像活动。片段框架提供了几个特性,使得保存和恢复片段比您需要在活动上做的工作简单得多。

如何决定何时使用片段取决于几个考虑因素,这些将在下面讨论。

何时使用片段

使用片段的一个主要原因是,你可以跨设备和屏幕尺寸重用用户界面和功能。对于平板电脑来说尤其如此。想想当屏幕和平板电脑一样大时会发生什么。它更像一个桌面而不是电话,你的许多桌面应用都有一个多面板用户界面。如前所述,您可以在屏幕上同时拥有所选项目的列表和详细视图。这很容易在横向中描述,列表在左边,细节在右边。但是,如果用户将设备旋转到纵向模式,这样屏幕的高度就比宽度大了,会怎么样呢?也许您现在希望列表在屏幕的顶部,详细信息在底部。但是,如果这个应用运行在一个小屏幕上,屏幕上没有空间同时显示这两个部分,该怎么办呢?难道您不希望列表和细节的独立活动能够在大屏幕上共享您构建到这些部分中的逻辑吗?我们希望你的回答是肯定的。片段能帮上忙。图 8-1 让这一点更加清晰。

9781430246800_Fig08-01.jpg

图 8-1 。用于平板电脑用户界面和智能手机用户界面的片段

在横向模式下,两个片段可以很好地并排放置。在肖像模式下,我们可以把一个片段放在另一个上面。但是如果我们试图在一个较小屏幕的设备上运行相同的应用,我们可能需要显示片段 1 或片段 2,但不能同时显示两者。如果我们试图用布局来管理所有这些场景,我们会创建相当多的布局,这意味着很难在许多不同的布局中保持一切正确。当使用片段时,我们的布局保持简单;每个活动布局将片段作为容器来处理,并且活动布局不需要指定每个片段的内部结构。每个片段都有自己的内部结构布局,并且可以在许多配置中重用。

让我们回到旋转方向的例子。如果您必须为活动的方向更改编写代码,您就会知道保存活动的当前状态并在活动被重新创建后恢复状态是一件非常痛苦的事情。如果您的活动有块可以在方向改变时容易地保留,这样您就可以避免每次方向改变时的所有拆除和重新创建,这不是很好吗?当然会。片段能帮上忙。

现在想象一个用户在你的活动中,他们已经做了一些工作。想象一下,在同一个活动中,用户界面发生了变化,用户想要后退一步、两步或三步。在旧式活动中,按下 Back 按钮将使用户完全退出活动。使用片段,Back 按钮可以在一堆片段中后退,同时保持在当前活动中。

接下来,当一大块内容改变时,考虑活动的用户界面;你想让过渡看起来平滑,就像一个完美的应用。片段也能做到。

现在你对什么是片段以及为什么要使用片段有了一些概念,让我们更深入地研究一下片段的结构。

片段的结构

如前所述,片段就像一个子活动:它有一个相当具体的目的,并且几乎总是显示一个用户界面。但是在活动从上下文被子类化的地方,片段从包 android.app 中的对象被扩展。片段是而不是活动的扩展。然而,像活动一样,您将总是扩展片段(或者它的一个子类),以便您可以覆盖它的行为。

一个片段可以有一个视图层次结构来吸引用户。这个视图层次结构与任何其他视图层次结构相似,因为它可以从 XML layout 规范创建(展开),也可以用代码创建。如果要让用户看到视图层次结构,就需要将它附加到周围活动的视图层次结构上,我们很快就会看到。组成片段视图层次结构的视图对象与 Android 中其他地方使用的视图类型相同。所以你所知道的关于视图的一切也适用于片段。

除了视图层次之外,一个片段还有一个 bundle 作为它的初始化参数。类似于一个活动,一个片段可以被保存,以后由系统自动恢复。当系统恢复一个片段时,它调用默认的构造函数(不带参数),然后将这个参数包恢复到新创建的片段。片段上的后续回调可以访问这些参数,并可以使用它们将片段恢复到以前的状态。为此,您必须

  • 确保片段类有一个默认的构造函数。
  • 创建一个新的片段后,立即添加一组参数,以便这些后续方法可以正确地设置您的片段,并且系统可以在必要时正确地恢复您的片段。

一个活动可以同时拥有多个片段;并且如果一个片段已经被另一个片段换出,则片段交换事务可以被保存在后栈上。后台堆栈由绑定到活动的片段管理器管理。back 堆栈是管理 Back 按钮行为的方式。片段管理器将在本章后面讨论。这里您需要知道的是,一个片段知道它被绑定到哪个活动,并且从那里它可以到达它的片段管理器。片段也可以通过它的活动获得活动的资源。

同样类似于活动,当片段被重新创建时,片段可以将状态保存到 bundle 对象中,并且这个 bundle 对象被返回给片段的 onCreate() 回调。这个保存的包也被传递给 onInflate() 、 onCreateView() 和 onActivityCreated() 。请注意,这与作为初始化参数附加的包不同。在这个包中,您可能会存储片段的当前状态,而不是应该用来初始化它的值。

片段的生命周期

在开始在示例应用中使用片段之前,您需要了解片段的生命周期。为什么?片段的生命周期比活动的生命周期更复杂,理解什么时候可以用片段做事情非常重要。图 8-2 显示了一个片段的生命周期。

9781430246800_Fig08-02.jpg

图 8-2 。片段的生命周期

如果你将它与图 2-3(一个活动的生命周期)相比较,你会注意到几个不同之处,主要是由于活动和片段之间需要的交互。片段非常依赖于它所在的活动,当它的活动经历一个步骤时,它可以经历多个步骤。

在最开始,一个片段被实例化。它现在作为一个对象存在于内存中。第一件可能发生的事情是初始化参数将被添加到片段对象中。在系统从一个保存的状态重新创建你的片段的情况下,这肯定是正确的。当系统从保存的状态中恢复一个片段时,默认的构造函数被调用,随后是初始化参数包的附件。如果你在代码中创建片段,一个很好的使用模式是清单 8-1 中的模式,它显示了在 MyFragment 类定义中实例化器的工厂类型。

清单 8-1 。使用静态工厂方法实例化片段

public static MyFragment newInstance(int index) {
    MyFragment f = new MyFragment();
    Bundle args = new Bundle();
    args.putInt("index", index);
    f.setArguments(args);
    return f;
}

从客户端的角度来看,他们通过用单个参数调用静态 newInstance() 方法来获得一个新实例。它们取回实例化的对象,并且在 arguments 包中的这个片段上设置了初始化参数。如果这个片段被保存并在以后重新构造,系统将经历一个非常相似的过程,调用默认的构造函数,然后重新附加初始化参数。对于您的特殊情况,您将定义您的 newInstance() 方法(或方法)的签名,以获取适当数量和类型的参数,然后适当地构建参数包。这就是您想要您的 newInstance() 方法做的所有事情。随后的回调将负责您的片段的其余设置。

onInflate()回调

接下来发生的是布局视图膨胀。如果您的片段由布局中的 <片段> 标签定义,您的片段的 onInflate() 回调将被调用。这将传入一个对周围活动的引用、一个带有来自 <片段> 标签的属性的属性集和一个保存的包。保存的包中有保存的状态值,如果这个片段以前存在并且正在被重新创建,则由 onSaveInstanceState() 放在那里。 onInflate() 的期望是读取属性值并保存它们以备后用。在这个阶段,对用户界面做任何事情都为时过早。该片段甚至还没有与其活动相关联。但那是你的片段的下一个事件。

onAttach()回调

在您的片段与其活动相关联之后,调用 onAttach() 回调。如果您想使用活动参考,它会传递给您。您至少可以使用活动来确定有关封闭活动的信息。您还可以使用活动作为上下文来执行其他操作。需要注意的一点是,片段类有一个 getActivity() 方法,如果你需要的话,它总是会为你的片段返回附加的活动。请记住,在整个生命周期中,可以从片段的 getArguments() 方法中获得初始化参数包。但是,一旦片段被附加到它的 activity,就不能再调用 setArguments() 了。因此,除了在最开始的时候,你不能添加初始化参数。

onCreate()回调

接下来是 onCreate() 回调。虽然这与活动的 onCreate() 相似,但是不同之处在于,您不应该在这里放置依赖于活动视图层次结构的代码。您的片段现在可能已经关联到它的活动了,但是您还没有得到通知,活动的 onCreate() 已经完成。这就来了。这个回调获取传入的保存的状态包(如果有的话)。这个回调尽可能早地创建一个后台线程来获取这个片段需要的数据。您的片段代码正在 UI 线程上运行,并且您不想在 UI 线程上进行磁盘输入/输出(I/O)或网络访问。事实上,启动一个后台线程来做好准备是很有意义的。你的后台线程应该在阻塞调用的地方。稍后您将需要与数据挂钩,可能使用处理器或其他技术。

注意在后台线程中加载数据的方法之一是使用加载器类。这将在第二十八章中讲述。

onCreateView()回调

下一个回调是 onCreateView() 。这里的期望是你将为这个片段返回一个视图层次结构。传递给这个回调函数的参数包括一个 LayoutInflater (您可以用它来扩展这个片段的布局)、一个 ViewGroup 父对象(在清单 8-2 中称为容器)和一个保存的包(如果存在的话)。注意不要将视图层次附加到传入的视图组父视图,这一点非常重要。这种关联将在以后自动发生。如果您在这个回调中将片段的视图层次结构附加到父级,您很可能会得到异常——或者至少是奇怪和意外的应用行为。

清单 8-2 。在 onCreateView() 中创建片段视图层次结构

@Override
public View onCreateView(LayoutInflater inflater,
                  ViewGroup container, Bundle savedInstanceState) {
        if(container == null)
            return null;

        View v = inflater.inflate(R.layout.details, container, false);
        TextView text1 = (TextView) v.findViewById(R.id.text1);
        text1.setText(myDataSet[ getPosition() ] );
        return v;
}

提供了父类,因此您可以将它与 LayoutInflater 的 inflate() 方法一起使用。如果父容器值为 null,这意味着这个特定的片段不会被查看,因为没有视图层次结构可供它附加。在这种情况下,您可以简单地从这里返回 null。请记住,在您的应用中可能有一些没有显示出来的片段。清单 8-2 展示了你可能想在这个方法中做什么的一个例子。

在这里,您可以看到如何访问这个片段的一个布局 XML 文件,并将其展开为一个视图,然后返回给调用者。这种方法有几个优点。您总是可以用代码构建视图层次结构,但是通过膨胀一个布局 XML 文件,您可以利用系统的资源查找逻辑。根据设备的配置,或者您使用的设备,将选择适当的布局 XML 文件。然后,您可以访问布局中的特定视图——在本例中为 text1 TextView 字段——来执行您想要的操作。重复非常重要的一点:不要在这个回调中将片段的视图附加到容器父级。你可以在清单 8-2 中看到,你在对 inflate() 的调用中使用了一个容器,但是你也为 attachToRoot 参数传递了 false 。

onViewCreated()回调

这个在 onCreateView() 之后调用,但是在任何保存的状态被放入 UI 之前。传入的视图对象与从 onCreateView() 返回的视图对象相同。

onActivityCreated()回调

您现在已经接近用户可以与您的片段进行交互的点了。下一个回调是 onActivityCreated() 。这是在活动完成其 onCreate() 回调之后调用的。现在,您可以相信活动的视图层次结构(包括您自己的视图层次结构,如果您之前返回了一个视图层次结构的话)已经准备好并且可用了。在用户看到之前,您可以在这里对用户界面进行最后的调整。您还可以在这里确定该活动的任何其他片段是否已经附加到您的活动。

onViewStateRestored()回调

这是一个相对较新的版本,是在 JellyBean 4.2 中引入的。当这个片段的视图层次结构恢复了所有状态(如果适用)时,您的片段将调用这个回调。以前,您必须在 onActivityCreated() 中做出关于调整 UI 以恢复片段的决定。现在,您可以将该逻辑放入回调中,明确知道这个片段正在从保存的状态中恢复。

onStart()回调

片段生命周期中的下一个回调是 onStart() 。现在用户可以看到您的片段了。但是你还没有开始与用户互动。这个回调被绑定到活动的 onStart() 。因此,以前您可能将您的逻辑放在活动的 onStart() 中,现在您更可能将您的逻辑放在片段的 onStart() 中,因为这也是用户界面组件所在的位置。

onResume()回调

用户可以与您的片段交互之前的最后一个回调是 onResume() 。这个回调被绑定到活动的 onResume() 。当这个回调返回时,用户可以自由地与这个片段进行交互。例如,如果您的片段中有一个相机预览,您可能会在片段的 onResume() 中启用它。

所以现在你已经达到了应用忙于让用户开心的程度。然后用户决定退出你的应用,要么退出,要么按 Home 键,要么启动其他应用。下一个序列,类似于活动中发生的,与为交互设置片段的方向相反。

onPause()回调

片段上的第一个撤销回调是 onPause() 。这个回调被绑定到活动的 on pause();就像一个活动一样,如果您的片段或其他共享对象中有一个媒体播放器,您可以通过您的 onPause() 方法暂停、停止或返回它。同样的好公民规则也适用于此:如果用户正在接电话,你不希望播放音频。

onSaveInstanceState()回调

与活动类似,片段有机会保存状态以便以后重建。这个回调函数为这个片段传入一个 Bundle 对象,作为您想要保存的任何状态信息的容器。这是传递给前面提到的回调的保存状态包。为了防止内存问题,要小心保存到这个包中的内容。只保存你需要的。如果您需要保存对另一个片段的引用,不要试图保存或放置另一个片段,而是保存另一个片段的标识符,比如它的标签或 ID。当这个片段运行 onViewStateRestored() 时,您可以重新建立到这个片段所依赖的其他片段的连接。

虽然您可能会看到这个方法通常在 onPause() 之后被调用,但是这个片段所属的活动在认为片段的状态应该被保存时会调用它。这可能发生在 ondestory()之前的任何时候。

onStop()回调

下一个撤销回调是 onStop() 。这个与活动的 onStop() 绑定在一起,其作用类似于活动的 onStop() 。已经停止的片段可以直接返回到 onStart() 回调,然后导致 onResume() 。

onDestroyView()回调

如果你的片段正在被删除或保存,撤销方向的下一个回调是 onDestroyView() 。这将在您之前在 onCreateView() 回调中创建的视图层次结构从您的片段中分离出来之后被调用。

onDestroy()回调

接下来是 onDestroy() 。当片段不再被使用时,就调用这个函数。请注意,它仍然连接到活动,仍然可以找到,但它不能做太多。

onDetach()回调

片段生命周期中的最后一次回调是 onDetach() 。一旦这个被调用,这个片段就不再绑定到它的活动,它不再有视图层次结构,并且它的所有资源都应该被释放。

使用 setRetainInstance()

你可能已经注意到了图 8-2 中的虚线。片段的一个很酷的特性是,如果活动被重新创建,您可以指定不希望片段被完全销毁,因此您的片段也将被恢复。所以,片段自带了一个叫 setRetainInstance() 的方法,用一个布尔参数告诉它“Yes 我希望你在我的活动重新开始时留下来”或“不;走开,我会从头创建一个新的片段。”调用 setRetainInstance() 的一个好地方是在片段的 onCreate() 回调中,但是在 onCreateView() 中工作,就像 onActivityCreated() 一样。

如果参数为真,这意味着您希望将片段对象保存在内存中,而不是从头开始。但是,如果您的活动正在离开并被重新创建,您必须将您的片段从这个活动中分离出来,并将其附加到新的活动中。底线是,如果保留实例值为 true ,您实际上不会销毁您的片段实例,因此您不需要在另一端创建一个新的。图中的虚线表示您将在退出时跳过 onDestroy() 回调,当您的片段被重新附加到您的新活动时,您将跳过 onCreate() 回调,所有其他回调都将被触发。因为活动很可能是因为配置更改而重新创建的,所以您的片段回调应该假设配置已经更改,因此应该采取适当的操作。例如,这将包括扩展布局以在 onCreateView() 中创建新的视图层次。清单 8-2 中提供的代码会在编写时处理好这个问题。如果您选择使用 retain-instance 特性,您可能会决定不将一些初始化逻辑放在 onCreate() 中,因为它不会像其他回调那样总是被调用。

展示生命周期的示例片段应用

没有什么比看到一个真实的例子更能理解一个概念了。您将使用一个经过测试的示例应用,这样您就可以看到所有这些回调的运行情况。您将使用一个示例应用,它在一个片段中使用了一系列莎士比亚的标题;当用户点击其中一个标题时,该剧的一些文本将出现在一个单独的片段中。这个示例应用可以在平板电脑上以横向和纵向模式运行。然后将它配置为在较小的屏幕上运行,这样您就可以看到如何将文本片段分成一个活动。您将从清单 8-3 中的横向模式下活动的 XML 布局开始,当它运行时,看起来像图 8-3 中的。

清单 8-3 。用于横向模式的活动布局 XML

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout-land/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <fragment class="com.androidbook.fragments.bard.TitlesFragment"
            android:id="@+id/titles" android:layout_weight="1"
            android:layout_width="0px"
            android:layout_height="match_parent" />
    <FrameLayout
            android:id="@+id/details" android:layout_weight="2"
            android:layout_width="0px"
            android:layout_height="match_parent" />

</LinearLayout>

9781430246800_Fig08-03.jpg

图 8-3 。您的示例片段应用的用户界面

注意:本章末尾的是您可以用来下载本章中的项目的 URL。这将允许您将这些项目直接导入到您的 IDE(比如 Eclipse 或 Android Studio)中。

这种布局看起来像你在整本书中看到的许多其他布局,水平地从左到右有两个主要对象。不过有一个特殊的新标签,叫做 <片段> ,这个标签有一个新属性叫做类。请记住,片段不是视图,因此片段的布局 XML 与其他所有内容的布局稍有不同。另一件要记住的事情是, <片段> 标签只是这个布局中的一个占位符。在布局 XML 文件中,不应将子标签放在 <片段> 下。

片段的其他属性看起来很熟悉,其用途类似于视图。片段标签的类属性为应用的标题指定了扩展类。也就是说,你必须扩展一个 Android 片段类来实现你的逻辑, <片段> 标签必须知道你扩展类的名字。一个片段有它自己的视图层次结构,这个层次结构将由片段自己创建。下一个标签是一个框架布局—而不是另一个 <片段> 标签。为什么会这样?我们稍后会更详细地解释,但是现在,你应该意识到你将要在文本上做一些转换,用一个片段替换另一个片段。您使用 FrameLayout 作为视图容器来保存当前的文本片段。对于标题片段,您有一个——也是唯一一个——需要担心的片段:没有交换和过渡。对于显示莎士比亚文本的区域,您将有几个片段。

MainActivity Java 代码在清单 8-4 中。实际上,清单只显示了有趣的代码。该代码带有日志消息,因此您可以通过 LogCat 看到发生了什么。请查看从网站上下载的莎士比亚乐器的源代码文件。

清单 8-4 。来自 MainActivity 的有趣源代码

public boolean isMultiPane() {
    return getResources().getConfiguration().orientation
            == Configuration.ORIENTATION_LANDSCAPE;
}

/**
 * Helper function to show the details of a selected item, either by
 * displaying a fragment in-place in the current UI, or starting a
 * whole new activity in which it is displayed.
 */
public void showDetails(int index) {
    Log.v(TAG, "in MainActivity showDetails(" + index + ")");

    if (isMultiPane()) {
        // Check what fragment is shown, replace if needed.
        DetailsFragment details = (DetailsFragment)
                getFragmentManager().findFragmentById(R.id.details);
        if ( (details == null) ||
             (details.getShownIndex() != index) ) {
            // Make new fragment to show this selection.
            details = DetailsFragment.newInstance(index);

            // Execute a transaction, replacing any existing
            // fragment with this one inside the frame.
            Log.v(TAG, "about to run FragmentTransaction...");
            FragmentTransaction ft
                    = getFragmentManager().beginTransaction();
            ft.setTransition(
                    FragmentTransaction.TRANSIT_FRAGMENT_FADE);
            //ft.addToBackStack("details");
            ft.replace(R.id.details, details);
            ft.commit();
        }

    } else {
        // Otherwise you need to launch a new activity to display
        // the dialog fragment with selected text.
        Intent intent = new Intent();
        intent.setClass(this, DetailsActivity.class);
        intent.putExtra("index", index);
        startActivity(intent);
    }
}

这是一个非常简单的活动。要确定 multipane 模式(即是否需要并排使用片段),只需使用设备的方向。如果你在风景模式下,你是多窗格;如果你在肖像模式下,你不是。helper 方法 showDetails() 是用来计算当标题被选中时如何显示文本的。索引是标题在标题列表中的位置。如果您处于多窗格模式,您将使用一个片段来显示文本。您将这个片段称为 DetailsFragment ,并使用工厂类型的方法创建一个带有索引的片段。 DetailsFragment 类的有趣代码如清单 8-5 所示(减去所有的日志代码)。正如我们之前在 TitlesFragment 中所做的那样, DetailsFragment 的各种回调都添加了日志记录,因此我们可以通过 LogCat 观察发生了什么。稍后您将回到您的 showDetails() 方法。

清单 8-5 。细节片段的源代码

public class DetailsFragment extends Fragment {

    private int mIndex = 0;

    public static DetailsFragment newInstance(int index) {
        Log.v(MainActivity.TAG, "in DetailsFragment newInstance(" +
                                 index + ")");

        DetailsFragment df = new DetailsFragment();

        // Supply index input as an argument.
        Bundle args = new Bundle();
        args.putInt("index", index);
        df.setArguments(args);
        return df;
    }

    public static DetailsFragment newInstance(Bundle bundle) {
        int index = bundle.getInt("index", 0);
        return newInstance(index);
    }

    @Override
    public void onCreate(Bundle myBundle) {
        Log.v(MainActivity.TAG,
                "in DetailsFragment onCreate. Bundle contains:");
        if(myBundle != null) {
            for(String key : myBundle.keySet()) {
                Log.v(MainActivity.TAG, "    " + key);
            }
        }
        else {
            Log.v(MainActivity.TAG, "    myBundle is null");
        }
        super.onCreate(myBundle);

        mIndex = getArguments().getInt("index", 0);
    }

    public int getShownIndex() {
        return mIndex;
    }

    @Override
    public View onCreateView(LayoutInflater inflater,
            ViewGroup container, Bundle savedInstanceState) {
        Log.v(MainActivity.TAG,
                "in DetailsFragment onCreateView. container = " +
                container);

        // Don't tie this fragment to anything through the inflater.
        // Android takes care of attaching fragments for us. The
        // container is only passed in so you can know about the
        // container where this View hierarchy is going to go.
        View v = inflater.inflate(R.layout.details, container, false);
        TextView text1 = (TextView) v.findViewById(R.id.text1);
        text1.setText(Shakespeare.DIALOGUE[ mIndex ] );
        return v;
    }
}

DetailsFragment 类实际上也相当简单。现在您可以看到如何实例化这个片段。需要指出的是,您正在代码中实例化这个片段,因为您的布局定义了您的细节片段将要进入的视图组容器(一个框架布局)。因为片段本身不是在活动的布局 XML 中定义的,与 titles 片段不同,您需要在代码中实例化您的 details 片段。

要创建一个新的细节片段,您可以使用您的 newInstance() 方法。如前所述,这个工厂方法调用默认构造函数,然后用索引的值设置参数包。一旦 newInstance() 已经运行,您的细节片段就可以通过 getArguments() 引用参数包,在它的任何回调中检索索引的值。为了方便起见,在 onCreate() 中,您可以将 arguments 包中的索引值保存到 DetailsFragment 类的成员字段中。

您可能想知道为什么不简单地在 newInstance() 中设置 mIndex 值。原因是 Android 将在幕后使用默认的构造函数重新创建您的片段。然后,它将参数包设置为之前的样子。Android 不会使用您的 newInstance() 方法,因此确保设置 mIndex 的唯一可靠方法是从 arguments bundle 中读取值,并在 onCreate() 中设置它。便利的方法 getShownIndex() 检索该索引的值。现在细节片段中剩下的唯一要描述的方法是 onCreateView() 。这也很简单。

onCreateView() 的目的是返回片段的视图层次结构。请记住,基于您的配置,您可能希望这个片段有各种不同的布局。因此,最常见的做法是为您的片段使用一个布局 XML 文件。在您的示例应用中,您使用资源 R.layout.details 将片段的布局指定为 details.xml 。 details.xml 的 XML 在清单 8-6 中。

清单 8-6 。细节片段的 details.xml 布局文件

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/details.xml -->
<LinearLayout
  xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
  android:layout_width="match_parent"
  android:layout_height="match_parent">
  <ScrollView android:id="@+id/scroller"
      android:layout_width="match_parent"
      android:layout_height="match_parent">
    <TextView android:id="@+id/text1"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
  </ScrollView>
</LinearLayout>

对于您的示例应用,无论是在横向模式还是纵向模式下,您都可以使用完全相同的布局文件来获取详细信息。这个布局不是为活动设计的,它只是让你的片段显示文本。因为它可以被认为是默认布局,所以你可以将它存储在 /res/layout 目录中,即使你处于横向模式,也可以找到并使用它。当 Android 寻找细节 XML 文件时,它会尝试与设备配置紧密匹配的特定目录,但如果它在任何其他地方都找不到 details.xml 文件,它将最终出现在 /res/layout 目录中。当然,如果你想让你的片段在风景模式下有一个不同的布局,你可以定义一个单独的 details.xml 布局文件,并把它保存在 /res/layout-land 下。随意试验不同的 details.xml 文件。

当您的细节片段的 onCreateView() 被调用时,您只需获取适当的 details.xml 布局文件,对其进行膨胀,并将文本设置为来自 Shakespeare 类的文本。莎士比亚的全部 Java 代码在这里没有显示,但是有一部分在清单 8-7 中,所以你可以理解它是如何完成的。如需完整的源代码,请访问项目下载文件,如本章末尾的“参考资料”部分所述。

清单 8-7 。Shakespeare.java 的源代码

public class Shakespeare {
    public static String TITLES[] = {
            "Henry IV (1)",
            "Henry V",
            "Henry VIII",
            "Romeo and Juliet",
            "Hamlet",
            "The Merchant of Venice",
            "Othello"
    };
    public static String DIALOGUE[] = {
        "So shaken as we are, so wan with care,\n...
*... and so on ...*

现在,您的细节片段视图层次结构包含了所选标题的文本。你的细节片段已经准备好了。并且可以返回 MainActivity 的 showDetails() 方法来谈 FragmentTransactions 。

FragmentTransactions 和片段回栈

showDetails() 中的代码获取新的细节片段(在清单 8-8 中再次部分显示)看起来相当简单,但是这里有很多事情要做。花一些时间来解释正在发生的事情及其原因是值得的。如果您的活动处于 multipane 模式,您希望在标题列表旁边的片段中显示详细信息。您可能已经显示了细节,这意味着您可能有一个对用户可见的细节片段。不管怎样,资源 ID R.id.details 是为您的活动的框架布局准备的,如清单 8-3 所示。如果布局中有一个细节片段,因为您没有为它分配任何其他 ID,那么它将拥有这个 ID。因此,要找出布局中是否有细节片段,可以使用 findFragmentById()询问片段管理器。如果框架布局为空,这将返回 null,或者将给出当前的细节片段。然后,您可以决定是否需要在布局中放置一个新的细节片段,因为布局是空的,或者因为有其他标题的细节片段。一旦您决定创建并使用一个新的细节片段,您就可以调用工厂方法来创建一个细节片段的新实例。现在,您可以将这个新的片段放到用户可以看到的地方。

清单 8-8 。片段事务示例

public void showDetails(int index) {
    Log.v(TAG, "in MainActivity showDetails(" + index + ")");

    if (isMultiPane()) {
        // Check what fragment is shown, replace if needed.
        DetailsFragment details = (DetailsFragment)
                getFragmentManager().findFragmentById(R.id.details);
        if (details == null || details.getShownIndex() != index) {
            // Make new fragment to show this selection.
            details = DetailsFragment.newInstance(index);

            // Execute a transaction, replacing any existing
            // fragment with this one inside the frame.
            Log.v(TAG, "about to run FragmentTransaction...");
            FragmentTransaction ft
                    = getFragmentManager().beginTransaction();
            ft.setTransition(
                    FragmentTransaction.TRANSIT_FRAGMENT_FADE);
            //ft.addToBackStack("details");
            ft.replace(R.id.details, details);
            ft.commit();
        }
            // The rest was left out to save space.
}

需要理解的一个关键概念是片段必须位于视图容器中,也称为视图组。视图组类包括布局和它们的派生类。 FrameLayout 是一个很好的选择,可以作为活动的 main.xml 布局文件中的细节片段的容器。一个框架布局很简单,你所需要的只是一个简单的容器来存放你的片段,没有其他类型布局带来的额外负担。框架布局是你的细节片段将要去的地方。如果您在活动的布局文件中指定了另一个 <片段> 标签,而不是一个框架布局 ,您将无法用一个新片段替换当前片段(即交换片段)。

FragmentTransaction 是你用来做交换的。您告诉片段事务,您希望用新的细节片段替换框架布局中的任何内容。你可以通过定位细节文本视图的资源 ID 并设置它的文本为新的莎士比亚标题的新文本来避免这一切。但是片段的另一面解释了为什么你使用片段事务。

正如您所知道的,活动被安排在一个堆栈中,随着您越来越深入地了解一个应用,同时进行几个活动的堆栈并不罕见。当您按下返回按钮时,最上面的活动消失,您将返回到下面的活动,该活动将为您恢复。这可以持续到你再次回到主屏幕。

当一个活动只有一个目的时,这很好,但现在一个活动可以同时有几个片段,因为你可以在不离开最上面的活动的情况下更深入地进入你的应用,Android 真的需要扩展后退按钮栈的概念,以包括片段。事实上,片段更需要这一点。当在一个活动中有几个片段同时相互交互,并且有一次跨越几个片段到新内容的转换时,按 Back 按钮应该会导致每个片段一起回滚一步。为了确保每个片段正确地参与回滚,创建了一个 FragmentTransaction 并对其进行管理以执行该协调。

*请注意,在活动中不需要片段的后台堆栈。您可以对应用进行编码,让 Back 按钮在活动级别工作,而不是在片段级别工作。如果你的片段没有后台栈,按 back 按钮会弹出当前的活动栈,让用户回到底层。如果你选择利用片段的后栈,你将需要在清单 8-8 中取消注释 ft . addtobackstack(" details ")这一行。对于这个特殊的例子,您已经将标记参数硬编码为字符串“细节”。这个标记应该是一个适当的字符串名称,代表事务时片段的状态。标签不一定是特定片段的名称,而是片段事务和事务中所有片段的名称。您将能够使用标记值在代码中查询后台堆栈来删除条目,以及弹出条目。您将希望这些交易上有意义的标记能够在以后找到合适的交易。

片段事务转换和动画

片段事务的一个非常好的地方是,您可以使用转换和动画来执行从旧片段到新片段的转换。这些不像后面的动画,在第十八章。这些要简单得多,不需要深入的图形知识。当您用新的细节片段替换旧的细节片段时,让我们使用片段事务转换来添加特殊效果。这可以为您的应用增添光彩,使从旧片段到新片段的切换看起来更加平滑。

完成这个的一个方法是 setTransition() ,如清单 8-8 所示。但是,有一些不同的转换可用。您在示例中使用了淡入淡出,但是您也可以使用 setCustomAnimations() 方法来描述其他特殊效果,例如当一个片段从左边滑入时,将另一个片段滑入右边。自定义动画使用新的对象动画定义,而不是旧的定义。旧的动画 XML 文件使用标签如 <翻译> ,而新的 XML 文件使用 <对象动画> 。旧的标准 XML 文件位于适当的 Android SDK 平台目录下的 /data/res/anim 目录中(比如蜂巢的平台/android-11 )。这里的 /data/res/animator 目录中也有一些新的 XML 文件。您的代码可能类似于

ft.setCustomAnimations(android.R.animator.fade_in, android.R.animator.fade_out);

这将导致新片段在旧片段淡出时淡入。第一个参数适用于进入的片段,第二个参数适用于退出的片段。请随意浏览 Android animator 目录以获取更多的库存动画。如果你想创建你自己的,在第十八章中有关于物体动画的章节可以帮助你。您需要的另一个非常重要的知识是,转换调用需要在替换()调用之前进行;否则,它们将不起作用。

使用 object animator 对片段进行特效处理是一种有趣的过渡方式。关于 FragmentTransaction 还有两个方法你应该知道: hide() 和 show() 。这两种方法都将一个片段作为参数,它们完全按照您的预期工作。对于与视图容器相关联的片段管理器中的片段,这些方法只是在用户界面中隐藏或显示片段。在这个过程中,片段不会从片段管理器中删除,但是为了影响它的可见性,它肯定会被绑定到一个视图容器中。如果一个片段没有视图层次,或者如果它的视图层次没有绑定到显示的视图层次,那么这些方法不会做任何事情。

一旦你为你的片段事务指定了特效,你必须告诉它你想要完成的主要工作。在您的例子中,您用新的细节片段替换了框架布局中的任何内容。这就是 replace() 方法的用武之地。这相当于为已经在框架布局中的任何片段调用 remove() ,然后为新的细节片段调用 add() ,这意味着您可以根据需要调用 remove() 或 add() 。

使用片段事务时,您必须采取的最后一个动作是提交它。 commit() 方法不会让事情立即发生,而是将工作安排在 UI 线程准备好的时候。

现在你应该明白了,为什么要大费周章的去改变一个简单片段里的内容。不仅仅是你想改文字;在过渡过程中,您可能需要特殊的图形效果。您可能还希望将转换详细信息保存在一个片段事务中,以便以后可以撤销。最后一点可能会令人困惑,所以我们要澄清一下。

这不是真正意义上的交易。当您从后台堆栈中弹出片段事务时,您并没有撤消可能已经发生的所有数据更改。如果活动中的数据发生了更改,例如,在后台堆栈上创建片段事务时,按 back 按钮不会导致活动数据更改恢复到以前的值。您仅仅是按照您进来的方式在用户界面视图中后退,就像您处理活动一样,但是在这种情况下,它是针对片段的。由于保存和恢复片段的方式,从保存状态恢复的片段的内部状态将取决于与片段一起保存的值以及如何恢复它们。因此,您的片段可能看起来与以前一样,但您的活动不会,除非您在恢复片段时采取措施恢复活动状态。

在您的示例中,您只使用了一个视图容器,并引入了一个细节片段。如果您的用户界面更复杂,您可以在片段事务中操作其他片段。您实际做的是开始事务,用新的细节片段替换细节框架布局中的任何现有片段,指定淡入动画,并提交事务。您注释掉了将该事务添加到后台堆栈的部分,但是您当然可以取消对它的注释以加入后台堆栈。

片段管理器

FragmentManager 是一个组件,负责管理属于活动的片段。这包括后面堆栈上的片段和可能只是挂在周围的片段。我们会解释的。

片段应该只在活动的上下文中创建。这可以通过扩展活动的布局 XML 来实现,也可以通过使用类似于清单 8-1 中的代码直接实例化来实现。当通过代码实例化时,片段通常使用片段事务附加到活动。在这两种情况下, FragmentManager 类用于访问和管理活动的这些片段。

您可以在活动或附加片段上使用 getFragmentManager() 方法来检索片段管理器。您在清单 8-8 中看到,片段管理器是获取片段事务的地方。除了获取片段事务之外,您还可以使用片段的 ID、标签或者包和键的组合来获取片段。如果片段是从 XML 中膨胀出来的,那么片段的 ID 将是片段的资源 ID,如果片段是使用片段事务放入视图中的,那么它将是容器的资源 ID。片段的标签是一个字符串,可以在片段的 XML 定义中分配,或者在片段通过片段事务被放置在视图中时分配。检索片段的 bundle 和 key 方法只适用于使用 putFragment() 方法持久化的片段。

获取一个片段,getter 方法有 findFragmentById() 、 findFragmentByTag() 和 getFragment() 。 getFragment() 方法将与 putFragment() 结合使用,后者也接受一个包、一个密钥和要放置的片段。这个包很可能是 savedState 包,而 putFragment() 将在 onSaveInstanceState() 回调中使用,以保存当前活动(或另一个片段)的状态。 getFragment() 方法可能会在 onCreate() 中被调用,以对应于 putFragment() ,尽管对于一个片段来说,这个包可用于其他回调方法,如前所述。

显然,您不能在还没有附加到活动的片段上使用 getFragmentManager() 方法。但是,您也可以将一个片段附加到一个活动,而不使它对用户可见。如果你这样做,你应该关联一个字符串标签到这个片段,这样你就可以在将来访问它。您最有可能使用这种 FragmentTransaction 方法来实现:

public FragmentTransaction add (Fragment fragment, String tag)

事实上,您可以拥有一个不展示视图层次结构的片段。这样做可能是为了将某些逻辑封装在一起,这样它就可以附加到一个活动中,但仍然保留一些来自活动生命周期和其他片段的自主权。当一个活动由于设备配置的改变而经历一个重新创建的周期时,这个非 UI 片段可能在活动离开并再次回来的时候基本上保持不变。对于 setRetainInstance() 选项来说,这是一个很好的选择。

片段后栈也是片段管理器的领域。片段事务用于将片段放入后台堆栈,而片段管理器可以从后台堆栈中取出片段。这通常是使用片段的 ID 或标签来完成的,但也可以基于在后台堆栈中的位置来完成,或者只是弹出最顶层的片段。

最后,片段管理器有一些调试特性的方法,比如使用 enableDebugLogging() 打开调试消息到 LogCat,或者使用 dump() 将片段管理器的当前状态转储到流中。请注意,您在清单 8-4 中的活动的 onCreate() 方法中打开了片段管理器调试。

引用片段时要小心

是时候重温一下之前关于片段生命周期、参数和保存状态包的讨论了。Android 可以在不同的时间保存你的一个片段。这意味着当您的应用想要检索该片段时,它可能不在内存中。出于这个原因,我们提醒你不要认为一个片段的变量引用将在很长一段时间内保持有效。如果使用片段事务在容器视图中替换片段,任何对旧片段的引用现在都指向可能在后台堆栈上的片段。或者,在应用配置更改(如屏幕旋转)期间,片段可能会脱离活动的视图层次结构。小心点。

如果你要保留一个对片段的引用,要知道它什么时候可以被保存;当您需要再次找到它时,使用片段管理器的 getter 方法之一。如果您想保留一个片段引用,比如当一个活动正在经历一个配置变更时,您可以使用带有适当包的 putFragment() 方法。在活动和片段的情况下,合适的包是在 onSaveInstanceState() 中使用的 savedState 包,它在 onCreate() 中重新出现(或者,在片段的情况下,是片段生命周期的其他早期回调)。您可能永远不会将直接的片段引用存储到片段的参数包中;如果你想这么做,请先仔细考虑一下。

获得特定片段的另一种方法是使用已知的标签或 ID 查询它。前面描述的 getter 方法将允许以这种方式从片段管理器中检索片段,这意味着您可以选择只记住片段的标签或 ID,以便您可以使用这些值之一从片段管理器中检索它,而不是使用 putFragment() 和 getFragment() 。

保存片段状态

Android 3.2 引入了另一个有趣的类:片段。保存的状态。使用 FragmentManager 的 saveFragmentInstanceState()方法,你可以给这个方法传递一个片段,它会返回一个表示该片段状态的对象。然后,您可以在初始化片段时使用该对象,使用片段的 setInitialSavedState() 方法。第九章对此进行了更详细的讨论。

列表片段和

为了使您的示例应用完整,还需要做一些事情。第一个是 TitlesFragment 类。这是通过主活动的 main.xml 文件创建的。 <片段> 标签作为该片段的占位符,并不定义该片段的视图层次结构。你的 TitlesFragment 的有趣代码在清单 8-9 中。对于所有的代码,请参考源代码文件。标题片段显示应用的标题列表。

清单 8-9 。 TitlesFragment Java 代码

public class TitlesFragment extends ListFragment {
    private MainActivity myActivity = null;
    int mCurCheckPosition = 0;

    @Override
    public void onAttach(Activity myActivity) {
        Log.v(MainActivity.TAG,
            "in TitlesFragment onAttach; activity is: " + myActivity);
        super.onAttach(myActivity);
        this.myActivity = (MainActivity)myActivity;
    }

    @Override
    public void onActivityCreated(Bundle savedState) {
        Log.v(MainActivity.TAG,
            "in TitlesFragment onActivityCreated. savedState contains:");
        if(savedState != null) {
            for(String key : savedState.keySet()) {
                Log.v(MainActivity.TAG, "    " + key);
            }
        }
        else {
            Log.v(MainActivity.TAG, "    savedState is null");
        }
        super.onActivityCreated(savedState);

        // Populate list with your static array of titles.
        setListAdapter(new ArrayAdapter<String>(getActivity(),
                android.R.layout.simple_list_item_1,
                Shakespeare.TITLES));

        if (savedState != null) {
            // Restore last state for checked position.
            mCurCheckPosition = savedState.getInt("curChoice", 0);
        }

        // Get your ListFragment's ListView and update it
        ListView lv = getListView();
        lv.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
        lv.setSelection(mCurCheckPosition);

        // Activity is created, fragments are available
        // Go ahead and populate the details fragment
        myActivity.showDetails(mCurCheckPosition);
    }
    @Override
    public void onSaveInstanceState(Bundle outState) {
        Log.v(MainActivity.TAG, "in TitlesFragment onSaveInstanceState");
        super.onSaveInstanceState(outState);
        outState.putInt("curChoice", mCurCheckPosition);
    }

    @Override
    public void onListItemClick(ListView l, View v, int pos, long id) {
        Log.v(MainActivity.TAG,
            "in TitlesFragment onListItemClick. pos = "
            + pos);
        myActivity.showDetails(pos);
        mCurCheckPosition = pos;
    }

    @Override
    public void onDetach() {
        Log.v(MainActivity.TAG, "in TitlesFragment onDetach");
        super.onDetach();
        myActivity = null;
    }
}

与 DetailsFragment 不同,对于这个片段,您不需要在 onCreateView() 回调中做任何事情。这是因为您扩展了 ListFragment 类,其中已经包含了一个 ListView 。一个 ListFragment 的默认 onCreateView() 为你创建这个 ListView 并返回它。直到 onActivityCreated() 你才做任何真正的应用逻辑。到目前为止,在您的应用中,您可以确定活动的视图层次结构以及这个片段的视图层次结构已经创建。列表视图的资源 ID 是 android。R.id.list1 ,但是如果你需要获取对它的引用,你可以随时调用 getListView() ,你可以在 onActivityCreated() 中这样做。因为列表片段管理列表视图,所以不要将适配器直接连接到列表视图。您必须使用 ListFragment 的 setListAdapter() 方法。活动的视图层次现在已经设置好了,所以您可以安全地返回到活动中进行 showDetails() 调用。

在示例活动的这一阶段,您已经向列表视图添加了一个列表适配器,您已经恢复了当前位置(如果您是从恢复中恢复过来的,可能是由于配置更改),并且您已经要求活动(在 showDetails() 中)将文本设置为与所选的莎士比亚的标题相对应。

您的 TitlesFragment 类在列表上也有一个监听器,因此当用户单击另一个标题时,调用 onListItemClick() 回调,您切换文本以对应于该标题,再次使用 showDetails() 方法。

这个片段与之前的细节片段的另一个区别是,当这个片段被销毁和重新创建时,您将状态保存在一个 bundle 中(列表中当前位置的值),并在 onCreate() 中读回它。不像在你的活动布局上的框架布局中交换的细节片段,只需要考虑一个标题片段。因此,当配置发生变化,并且您的标题片段正在经历保存和恢复操作时,您需要记住您在哪里。使用细节片段,您可以重新创建它们,而不必记住以前的状态。

需要时调用单独的活动

有一段代码我们还没有谈到,那就是在 showDetails() 中,当你处于纵向模式时,细节片段将无法与标题片段正确地放在同一页面上。如果屏幕空间不允许对一个片段进行可行的查看,否则该片段将与其他片段一起显示,您将需要启动一个单独的活动来显示该片段的用户界面。对于您的示例应用,您实现了一个细节活动;代码在清单 8-10 中。

清单 8-10 。当片段不适合时显示新的活动

public class DetailsActivity extends Activity {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        Log.v(MainActivity.TAG, "in DetailsActivity onCreate");
        super.onCreate(savedInstanceState);

        if (getResources().getConfiguration().orientation
                == Configuration.ORIENTATION_LANDSCAPE) {
            // If the screen is now in landscape mode, it means
            // that your MainActivity is being shown with both
            // the titles and the text, so this activity is
            // no longer needed. Bail out and let the MainActivity
            // do all the work.
            finish();
            return;
        }

        if(getIntent() != null) {
            // This is another way to instantiate a details
            // fragment.
            DetailsFragment details =
                DetailsFragment.newInstance(getIntent().getExtras());

            getFragmentManager().beginTransaction()
                .add(android.R.id.content, details)
                .commit();
        }
    }
}

这段代码有几个有趣的方面。首先,它很容易实现。您可以简单地确定设备的方向,只要您处于纵向模式,就可以在这个细节活动中设置一个新的细节片段。如果您处于横向模式,您的 MainActivity 能够显示标题片段和细节片段,所以根本没有理由显示这个活动。你可能想知道如果你在横向模式下,为什么你会发起这个活动,答案是,你不会。但是,一旦此活动以纵向模式启动,如果用户将设备旋转到横向模式,此详细信息活动将由于配置更改而重新启动。现在活动开始了,而且是在横向模式下。在那一刻,结束这个活动并让主活动 接管并完成所有工作是有意义的。

关于这个细节活动的另一个有趣的方面是,您从不使用 setContentView() 来设置根内容视图。那么用户界面是如何创建的呢?如果您仔细观察片段事务上的 add() 方法调用,您会看到添加片段的视图容器被指定为资源 android。R.id.content 这是活动的顶级视图容器,因此当您将片段视图层次结构附加到该容器时,您的片段视图层次结构将成为活动的唯一视图层次结构。您使用与之前完全相同的 DetailsFragment 类和另一个 newInstance() 方法来创建片段(将 bundle 作为参数的方法),然后您简单地将它附加到活动视图层次结构的顶部。这将导致片段显示在这个新的活动中。

从用户的角度来看,他们现在看到的只是细节片段视图,这是莎士比亚戏剧中的文本。如果用户想要选择一个不同的标题,他们按下 Back 按钮,弹出这个活动来显示你的主活动(只有标题片段)。用户的另一个选择是旋转设备以回到风景模式。然后您的细节活动将调用 finish() 并离开,显示下面同样旋转的主活动。

当设备处于纵向模式时,如果你没有在主活动中显示细节片段,你应该有一个单独的 main.xml 布局文件用于纵向模式,就像清单 8-11 中的那个。

清单 8-11 。肖像主活动的布局

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <fragment class="com.androidbook.fragments.bard.TitlesFragment"
            android:id="@+id/titles"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
</LinearLayout>

当然,您可以将这种布局设计成您想要的样子。出于这里的目的,您只需让它单独显示标题片段。您的 titles fragment 类不需要包含太多代码来处理设备重新配置,这非常好。

请花点时间查看这个应用的清单文件。你可以在其中找到一个类别为 LAUNCHER 的主活动,这样它就会出现在设备的应用列表中。然后您有单独的细节活动,类别为默认。这允许您从代码启动详细信息活动,但不会将详细信息活动显示为应用列表中的一个应用。

片段的持久性

当您使用这个示例应用时,请确保旋转设备(按 Ctrl+F11 在模拟器中旋转设备)。你会看到设备旋转,片段也随之旋转。如果您观察 LogCat 消息,您会看到这个应用的许多消息。特别是,在设备轮换期间,要特别注意有关片段的消息;不仅活动被破坏和重新创建,而且片段也被破坏和重新创建。

到目前为止,您只在 titles 片段上编写了一小段代码来记住重启后 titles 列表中的当前位置。您没有在细节片段代码中做任何事情来处理重新配置,这是因为您不需要这样做。Android 会保留片段管理器中的片段,保存它们,然后在重新创建活动时恢复它们。您应该意识到,在重新配置完成后,您获得的片段很可能与您之前在内存中获得的片段不同。这些片段已经为你复原了。Android 保存了 arguments bundle 和它是哪种类型的片段的知识,并且它为每个片段存储了保存状态 bundle,其中包含关于该片段的保存状态信息,用于在另一端恢复它。

LogCat 消息向您展示了片段与活动同步地经历它们的生命周期。您将看到您的细节片段被重新创建,但是您的 newInstance() 方法没有被再次调用。相反,Android 使用默认的构造函数,将 arguments 包附加到它上面,然后开始调用片段上的回调。这就是为什么在 newInstance() 方法中不做任何花哨的事情是如此重要:当片段被重新创建时,它不会通过 newInstance() 来完成。

到目前为止,您还应该意识到,您已经能够在一些不同的地方重用您的片段。titles 片段在两种不同的布局中使用,但是如果您查看 titles 片段代码,它并不担心每个布局的属性。您可以使布局彼此大相径庭,而标题片段代码看起来是一样的。细节片段也是如此。它被用于你的主要景观布局和细节活动中。同样,细节片段的布局在两者之间可能有很大的不同,而细节片段的代码可能是相同的。细节活动的代码也非常简单。

到目前为止,您已经探索了两种片段类型:基本片段类和列表片段子类。片段还有其他子类: DialogFragment 、 PreferenceFragment 和 WebViewFragment 。我们将在第十章和第十一章中分别介绍对话片段和偏好片段。

片段通信

因为片段管理器知道附加到当前活动的所有片段,所以活动或该活动中的任何片段可以使用前面描述的 getter 方法请求任何其他片段。一旦获得了片段引用,活动或片段就可以适当地转换引用,然后直接在该活动或片段上调用方法。这将导致您的片段比通常所期望的拥有更多的关于其他片段的知识,但是不要忘记您是在移动设备上运行这个应用,所以抄近路有时是合理的。清单 8-12 中提供了一个代码片段,展示了一个片段如何与另一个片段直接通信。该片段将是你的扩展片段类的一部分,而片段是一个不同的扩展片段类。

清单 8-12 。直接片段到片段通信

FragmentOther fragOther =
        (FragmentOther)getFragmentManager().findFragmentByTag("other");
fragOther.callCustomMethod( arg1, arg2 );

在清单 8-12 中,当前片段直接知道另一个片段的类,以及该类中存在哪些方法。这可能没问题,因为这些片段是一个应用的一部分,简单地接受一些片段将知道其他片段的事实可能更容易。我们将在第十章的对话片段示例应用中向您展示一种更简洁的片段间通信方式。

使用 startActivity()和 setTargetFragment()

片段的一个非常类似于活动的特性是片段启动活动的能力。片段有一个 startActivity() 方法和 startActivityForResult() 方法。这些工作就像那些活动;当一个结果被传回时,它将导致 onActivityResult() 回调在启动活动的片段上触发。

你应该知道另一种沟通机制。当一个片段想要启动另一个片段时,有一个特性可以让调用片段设置它与被调用片段的身份。清单 8-13 展示了一个可能的例子。

清单 8-13 。片段到目标片段设置

mCalledFragment = new CalledFragment();
mCalledFragment.setTargetFragment(this, 0);
fm.beginTransaction().add(mCalledFragment, "work").commit();

通过这几行代码,您已经创建了一个新的 CalledFragment 对象,将被调用片段上的目标片段设置为当前片段,并使用片段事务将被调用片段添加到片段管理器和活动中。当被调用的片段开始运行时,它将能够调用 getTargetFragment() ,这将返回对调用片段的引用。有了这个引用,被调用的片段可以调用调用片段上的方法,甚至直接访问视图组件。例如,在清单 8-14 中,被调用的片段可以直接在调用片段的 UI 中设置文本。

清单 8-14 。目标片段对片段通信

TextView tv = (TextView)
    getTargetFragment().getView().findViewById(R.id.text1);
tv.setText("Set from the called fragment");

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

本章介绍了片段类及其相关的管理器类、事务类和子类。这是本章内容的总结:

  • 片段类,它做什么,以及如何使用它。
  • 为什么片段不被附加到一个且只有一个活动就不能被使用。
  • 虽然片段可以用静态工厂方法实例化,比如 newInstance() ,但是您必须始终拥有一个默认的构造函数,并且有一种方法可以将初始化值保存到初始化参数包中。
  • 片段的生命周期,以及它如何与拥有该片段的活动的生命周期交织在一起。
  • FragmentManager 及其特性。
  • 使用片段管理设备配置。
  • 将片段合并到一个活动中,或者将它们拆分到多个活动中。
  • 使用片段事务来改变向用户显示的内容,并使用酷炫的效果来制作这些转换的动画。
  • 使用片段时后退按钮可能出现的新行为。
  • 在布局中使用 <片段> 标签。
  • 当你想使用过渡时,使用一个框架布局作为片段的占位符。
  • ListFragment 以及如何使用适配器来填充数据(非常类似于 ListView )。
  • 当一个片段不能适应当前屏幕时启动一个新的活动,以及当一个配置改变使得可以再次看到多个片段时如何调整。
  • 片段之间以及片段与其活动之间的通信。*

九、响应配置更改

到目前为止,我们已经介绍了相当多的内容,现在似乎是介绍配置更改的好时机了。当应用在设备上运行时,如果设备的配置发生变化(例如旋转 90 度),应用需要做出相应的响应。新配置看起来很可能与以前的配置不同。例如,从纵向模式切换到横向模式意味着屏幕从又高又窄变成了又短又宽。UI 元素(按钮、文本、列表等等)需要重新排列、调整大小,甚至删除以适应新的配置。

在 Android 中,默认情况下,配置更改会导致当前活动消失并被重新创建。应用本身继续运行,但是它有机会改变活动的显示方式以响应配置的改变。在极少数情况下,您需要在不破坏和重新创建活动的情况下处理配置更改,Android 也提供了一种处理方式。

请注意,配置更改可以采取多种形式,而不仅仅是设备旋转。如果设备连接到 dock,这也是一种配置更改。改变设备的语言也是如此。无论新的配置是什么,只要你已经为该配置设计了你的活动,Android 会处理大部分事情来过渡到它,给用户一个无缝的体验。

本章将从活动和片段两个角度带您了解配置变更的过程。我们将向您展示如何为这些转换设计您的应用,以及如何避免可能导致您的应用崩溃或行为不当的陷阱。

默认配置更改流程

Android 操作系统会跟踪运行它的设备的当前配置。配置包括许多因素,而且新的因素一直在增加。例如,如果设备被插入扩展坞,这表示设备配置发生了变化。当 Android 检测到配置更改时,运行中的应用会调用回调来告诉它们正在发生更改,因此应用可以正确地对更改做出响应。我们稍后将讨论这些回调,但是现在让我们刷新一下关于资源的记忆。

Android 的一个伟大特性是根据设备的当前配置为你的活动选择资源。您不需要编写代码来确定哪个配置是活动的;你只需按名称访问资源,Android 就会为你获取相应的资源。如果设备处于纵向模式,并且您的应用请求布局,您将获得纵向布局。如果设备处于横向模式,您将获得横向布局。代码只是请求一个布局,而没有指定应该获得哪个布局。这是非常强大的,因为当引入新的配置因子或配置因子的新值时,代码保持不变。开发人员需要做的就是决定是否需要创建新的资源,并根据新配置的需要创建它们。然后,当应用经历配置更改时,Android 向应用提供新的资源,一切继续按预期运行。

由于非常希望事情简单,当配置改变时,Android 破坏当前的活动,并在它的位置创建一个新的活动。这可能看起来相当苛刻,但事实并非如此。更大的挑战是采取一个运行的活动,并找出哪些部分会保持不变,哪些不会,然后只处理需要改变的部分。

一个即将被销毁的活动首先被适当地通知,给你一个机会去保存任何需要保存的东西。当新活动被创建时,它有机会使用前一个活动的数据来恢复状态。为了获得良好的用户体验,显然您不希望这种保存和恢复花费很长时间。

保存你需要保存的任何数据,然后让 Android 扔掉其余的并重新开始,这是相当容易的,只要应用及其活动的设计不包含大量非 UI 的东西,这些东西需要很长时间来重新创建。成功的配置变更设计的秘密就在于此:不要把“东西”放在一个在配置变更期间不容易被重新创建的活动中。

请记住,我们的应用并没有被销毁,所以应用上下文中的任何东西,不属于我们当前活动的一部分,都将在新活动中继续存在。Singletons 仍然是可用的,任何后台线程也是可用的,这些线程可能是我们为了应用而分离出来的。我们合作过的任何数据库或内容供应器也将继续存在。利用这些优势可以快速、轻松地更改配置。如果可能的话,将数据和业务逻辑放在活动之外。

活动和片段之间的配置变更过程有些相似。当一个活动被销毁和重新创建时,该活动中的片段被销毁和重新创建。我们需要担心的是关于我们的片段和活动的状态信息,比如当前显示给用户的数据,或者我们想要保留的内部值。我们将保存我们想要保留的东西,并在片段和活动被重新创建时在另一边再次拾起它。您会希望通过不让数据在默认的配置更改过程中被破坏来保护不容易重新创建的数据。

活动的破坏/创建周期

在处理活动中的默认配置更改时,需要注意三个回调:

  • onSaveInstanceState()
  • onCreate()
  • onRestoreInstanceState()

第一个是 Android 在检测到配置发生变化时调用的回调。在配置更改结束时创建新活动时,活动有机会保存它想要恢复的状态。在调用 onStop() 之前,将调用 onSaveInstanceState() 回调。任何存在的状态都可以被访问并保存到一个 Bundle 对象中。当活动被重新创建时,这个对象将被传递给其他两个回调函数( onCreate() 和 onRestoreInstanceState())。您只需要在其中一个中放入逻辑来恢复活动的状态。

默认的 onSaveInstanceState() 回调为您做了一些好事。例如,它遍历当前活动的视图层次结构,并保存每个具有 android:id 的视图的值。这意味着如果您的 EditText 视图接收到一些用户输入,那么该输入将在活动销毁/创建周期的另一端可用,以在用户取回控制权之前填充 EditText 。您不需要亲自经历并保存这个状态。如果您确实覆盖了 onSaveInstanceState() ,请确保使用 bundle 对象调用 super . onSaveInstanceState(),以便它可以为您处理此事。保存的不是视图,而是它们状态的属性,这些属性应该在销毁/创建边界上保持不变。

要将数据保存在 bundle 对象中,可以对整数使用 putInt() ,对字符串使用 putString() 等方法。 android.os.Bundle 类有不少方法;不限于整数和字符串。例如, putParcelable() 可以用来保存复杂对象。每个 put 都与一个字符串键一起使用,稍后您将使用与放入该值相同的键来检索该值。示例 onSaveInstanceState() 可能类似于清单 9-1 。

清单 9-1 。样本 onSaveInstanceState()

@Override
public void onSaveInstanceState(Bundle icicle) {
    super.onSaveInstanceState(icicle);
    icicle.putInt("counter", 1);
}

有时这个包被称为冰柱,因为它代表了一项活动的一小块冻结部分。在这个示例中,您只保存了一个值,它有一个计数器的键。您可以通过简单地向这个回调函数添加更多的 put 语句来保存更多的值。本例中的计数器值是临时的,因为如果应用被完全破坏,当前值将会丢失。例如,如果用户关闭了他们的设备,就会发生这种情况。在第十一章中,你会学到更持久地保存价值的方法。这个实例状态只意味着在应用运行时保留值。对于需要长期保存的重要状态,不要使用这种机制。

为了恢复活动状态,您可以访问 bundle 对象来检索您认为存在的值。同样,您使用 Bundle 类的方法,比如 getInt() 和 getString() ,并传递适当的键来告诉您想要返回哪个值。如果键在包中不存在,则传回值 0 或 null (取决于被请求对象的类型)。或者您可以在适当的 getter 方法中提供默认值。清单 9-2 显示了一个示例 onRestoreInstanceState()回调。

清单 9-2 。样本 onRestoreInstanceState()

@Override
public void onRestoreInstanceState(Bundle icicle) {
    super.onRestoreInstanceState(icicle);
    int someInt = icicle.getInt("counter", -1);
    // Now go do something with someInt to restore the
    // state of the activity. -1 is the default if no
    // value was found.
}

是在 onCreate() 还是在 onRestoreInstanceState() 恢复状态,由你决定。许多应用将在 onCreate() 中恢复状态,因为许多初始化工作都是在那里完成的。将两者分开的一个原因是,如果您正在创建一个可以扩展的 activity 类。进行扩展的开发人员可能会发现,与重写所有的 onCreate() 相比,用代码重写 onRestoreInstanceState() 来恢复状态会更容易。

这里需要注意的非常重要的一点是,当当前活动被完全销毁时,您需要非常关注对活动和视图以及其他需要进行垃圾收集的对象的引用。如果在保存的包中放入了引用被销毁的活动的内容,那么该活动就不能被垃圾收集。这很可能是一种内存泄漏,这种泄漏会越来越严重,直到您的应用崩溃。束中要避免的对象包括 Drawable s、 Adapter s、 View s,以及任何与活动上下文相关的东西。不要把一个 Drawable 放到包中,而是序列化位图并保存它。或者更好的是,在活动和片段之外管理位图,而不是在内部。将位图的某种引用添加到包中。当需要为新片段重新创建任何 Drawable 时,使用引用访问外部位图来重新生成您的 Drawable s。

片段的破坏/创建循环

片段的销毁/创建周期与活动的周期非常相似。处于销毁和重新创建过程中的片段将调用其 onSaveInstanceState() 回调,允许该片段将值保存在 Bundle 对象中以备后用。一个区别是,当一个片段被重新创建时,六个片段回调接收这个捆绑对象: onInflate() , onCreate() , onCreateView() , onActivityCreated() , onViewCreated() 和 onViewStateRestored() 。最后两次回调是最近的,分别来自 Honeycomb 3.2 和 JellyBean 4.2。这给了我们很多机会从先前的状态重建我们的重建片段的内部状态。

Android 只保证在 onDestroy() 之前的某个时候会为片段调用 onSaveInstanceState() 。这意味着当调用 onSaveInstanceState() 时,可能会也可能不会附加视图层次。因此,不要指望遍历 onSaveInstanceState() 中的视图层次。例如,如果片段在片段后栈上,就不会显示 UI,所以就不会存在视图层次结构。这当然是可以的,因为如果没有 UI 显示,就没有必要试图捕获视图的当前值来保存它们。在试图保存其当前值之前,您需要检查视图是否存在,如果视图不存在,不要认为这是一个错误。

与 activities 一样,注意不要在 bundle 对象中包含引用一个 activity 或一个片段的条目,当这个片段被重新创建时,这个片段可能不存在。保持包的大小尽可能小,尽可能在活动和片段之外存储持久的数据,并简单地从您的活动和片段中引用它。那么您的销毁/创建周期将会更快,您产生内存泄漏的可能性会更小,并且您的活动和片段代码应该更容易维护。

使用 FragmentManager 保存片段状态

除了 Android 通知片段应该保存它们的状态之外,片段还有另一种保存状态的方式。在 Honeycomb 3.2 中, FragmentManager 类得到了一个 saveFragmentInstanceState()方法,可以调用该方法来生成类 Fragment 的对象。保存的状态。前面提到的保存状态的方法是在 Android 内部完成的。虽然我们知道状态正在被保存,但我们无法直接访问它。这种保存状态的方法为您提供了一个对象,该对象表示一个片段的保存状态,并允许您控制是否以及何时从该状态创建一个片段。

使用片段的方法。SavedState 对象恢复一个片段是通过片段类的 setInitialSavedState() 方法实现的。在第八章的中,您了解到最好使用静态工厂方法(例如, newInstance() )来创建新的片段。在这个方法中,您看到了如何调用默认构造函数,然后附加一个参数包。您可以调用 setInitialSavedState() 方法来设置它以恢复到以前的状态。

关于这种保存片段状态的方法,您应该知道一些注意事项:

  • 要保存的片段当前必须连接到片段管理器。
  • 使用此保存状态创建的新片段必须与创建它的片段具有相同的类类型。
  • 保存的状态不能包含对其他片段的依赖。当重新创建保存的片段时,其他片段可能不存在。

在片段上使用 setRetainInstance

片段可以避免在配置改变时被破坏和重新创建。如果使用参数 true 调用 setRetainInstance() 方法,当其活动被销毁和重新创建时,该片段将保留在应用中。不会调用该片段的 onDestroy() 回调,也不会调用 onCreate() 。将调用 onDetach() 回调,因为该片段必须从即将离开的活动中分离,并且将调用 onAttach() 和 onActivityCreated() ,因为该片段附加到一个新的活动。这只适用于不在后台堆栈上的片段。这对于没有 UI 的片段尤其有用。

这个特性非常强大,因为您可以使用一个非 UI 片段来处理对数据对象和后台线程的引用,并在这个片段上调用 setRetainInstance(true) ,这样它就不会在配置更改时被破坏和重新创建。额外的好处是,在正常的配置更改过程中,非 UI 片段回调 onDetach() 和 onAttach() 会将活动引用从旧的切换到新的。

不推荐使用的配置更改方法

关于活动的几个方法已经被否决,所以您不应该再使用它们:

  • getastnonconfiguration instance()
  • on retaining no configuration instance()

这些方法以前允许您保存正在被销毁的活动中的任意对象,并将其传递给正在创建的活动的下一个实例。尽管这些方法很有用,但是您现在应该使用前面描述的方法来管理销毁/创建周期中活动实例之间的数据。

自己处理配置更改

到目前为止,您已经看到了 Android 如何为您处理配置更改。它负责销毁和重新创建活动和片段,为新配置引入最佳资源,保留任何用户输入的数据,并让您有机会在一些回调中执行一些额外的逻辑。这通常是你的最佳选择。但如果不是这样,当你不得不自己处理配置变更时,Android 提供了一条出路。不建议这样做,因为这完全取决于您来确定由于这种变化需要改变什么,然后由您负责做出所有的变化。如前所述,除了方向变化之外,还有许多配置变化。幸运的是,您不必亲自处理所有的配置更改。

自己处理配置更改的第一步是在 AndroidManifest.xml 文件的 < activity > 标签中声明您将使用 android:configChanges 属性处理的更改。Android 将使用前面描述的方法处理其他配置更改。您可以根据需要指定任意多的配置更改类型,方法是将它们与“|”符号进行“或”运算,如下所示:

<activity  ...   android:configChanges="orientation|keyboardHidden" ... >

配置更改类型的完整列表可以在 R.attr 的参考页面上找到。请注意,如果您的目标是 API 13 或更高版本,并且您需要处理方向,您还需要处理屏幕尺寸。

配置更改的默认过程是调用回调来销毁和重新创建活动或片段。当您已经声明您将处理特定的配置更改时,流程会发生变化,因此只有 onConfigurationChanged() 回调会在活动及其片段上被调用。Android 传入一个配置对象,因此回调知道新的配置是什么。由回调来确定可能发生了什么变化;然而,由于您自己可能只处理少量的配置更改,所以找出这一点应该不会太难。

当您可以跳过销毁和重新创建的时候,您真的只想自己处理配置更改。例如,如果纵向和横向的活动布局是相同的布局,并且所有图像资源都是相同的,则破坏和重新创建活动实际上并没有完成任何事情。在这种情况下,声明您将处理方向配置更改是相当安全的。在活动的方向改变期间,活动将保持不变,并使用现有资源(如布局、图像、字符串等)简单地以新的方向重新呈现自己。但是如果可以的话,让 Android 来处理这些事情真的没什么大不了的。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

让我们通过快速列举您所学到的关于处理配置更改的内容来结束本章:

  • 默认情况下,活动会在配置更改期间被销毁和重新创建。片段也是。
  • 避免将大量数据和逻辑放入活动中,以便快速进行配置更改。
  • 让 Android 提供适当的资源。
  • 使用单例来保存活动之外的数据,以便在配置更改期间更容易销毁和重新创建活动。
  • 利用默认的 onSaveInstanceState() 回调来保存带有 android:id s 的视图的 UI 状态
  • 如果一个片段在活动销毁和创建周期中没有问题,使用 setRetainInstance() 告诉 Android 它不需要销毁和创建这个片段。

十、使用对话框

Android SDK 为对话框提供了广泛的支持。对话框是在当前窗口前面弹出的一个较小的窗口,用来显示紧急消息,提示用户输入内容,或者显示某种状态,如下载进度。通常期望用户与对话框交互,然后返回到下面的窗口继续应用。从技术上来说,Android 允许在一个活动的布局中嵌入一个对话片段,我们也会谈到这一点。

Android 中明确支持的对话框包括警告、提示、选择列表、单选、多选、进度、时间选择器和日期选择器对话框。(该列表可能因 Android 版本而异。)Android 还支持其他需求的自定义对话框。这一章的主要目的不是涵盖所有的对话框,而是通过一个示例应用来涵盖 Android 对话框的底层架构。从那里你应该可以使用任何 Android 对话框。

需要注意的是,Android 3.0 增加了基于片段的对话框。来自 Google 的期望是开发者只会使用片段对话框,即使是在 Android 之前的版本中。这可以通过片段兼容性库来完成。为此,本章重点介绍对话片段 。

使用 Android 中的对话框

Android 中的对话框是异步的,这提供了灵活性。然而,如果您习惯于对话框主要是同步的编程框架(比如 Microsoft Windows,或者网页中的 JavaScript 对话框),您可能会发现异步对话框有点不直观。对于同步对话框,对话框显示后的代码行直到对话框关闭后才运行。这意味着下一行代码可以询问按下了哪个按钮,或者在对话框中输入了什么文本。然而在 Android 中,对话框是异步的。对话框一显示出来,下一行代码就会运行,即使用户还没有接触到对话框。您的应用必须通过从对话框实现回调来处理这一事实,以允许应用被通知用户与对话框的交互。

这也意味着您的应用能够从代码中消除对话框,这是非常强大的。如果对话框因为你的应用正在做一些事情而显示一条繁忙的消息,一旦你的应用完成了那个任务,它就可以从代码中关闭对话框。

理解对话片段

在本节中,您将学习如何使用对话框片段来呈现一个简单的警报对话框和一个用于收集提示文本的自定义对话框。

基本对话片段

在我们向您展示提示对话框和警告对话框的工作示例之前,我们想先介绍一下对话框片段的高级概念。与对话框相关的功能使用一个名为 DialogFragment 的类。一个 DialogFragment 从类 Fragment 派生而来,其行为很像一个片段。然后,您将使用 DialogFragment 作为您的对话框的基类。一旦你有了一个从这个类派生的对话框,比如

public class MyDialogFragment extends DialogFragment { ... }

然后,您可以使用片段事务将这个对话框片段 MyDialogFragment 显示为一个对话框。清单 10-1 展示了一个代码片段。

清单 10-1 。显示对话片段

public class SomeActivity extends Activity
{
    //....other activity functions
    public void showDialog()
    {
        //construct MyDialogFragment
        MyDialogFragment mdf = MyDialogFragment.newInstance(arg1,arg2);
        FragmentManager fm = getFragmentManager();
        FragmentTransaction ft = fm.beginTransaction();
        mdf.show(ft,"my-dialog-tag");
    }
    //....other activity functions
}

注意我们在本章末尾的“参考资料”部分提供了一个可下载项目的链接。您可以使用该下载来试验本章中介绍的代码和概念。

从清单 10-1 开始,显示对话片段的步骤如下:

  1. 创建一个对话框片段。
  2. 获取片段交易。
  3. 使用步骤 2 中的片段事务显示对话框。

让我们来谈谈其中的每一个步骤。

构造一个对话片段

当构建一个对话框片段时,规则与构建任何其他类型的片段时是一样的。推荐的模式是像以前一样使用工厂方法,比如 newInstance() 。在 new instance()方法中,您使用对话框片段的默认构造函数,然后添加一个包含传入参数的 arguments 包。你不想在这个方法里面做其他的工作,因为你必须确保你在这里做的和 Android 从一个保存的状态恢复你的对话框片段时做的是一样的。Android 所做的就是调用默认的构造函数,并在其上重新创建参数包。

覆盖 onCreateView

当您从对话框片段继承时,您需要重写两个方法中的一个来为您的对话框提供视图层次结构。第一个选项是覆盖 onCreateView() 并返回一个视图。第二个选项是覆盖 onCreateDialog() 并返回一个对话框(类似于由 AlertDialog 构造的对话框。构建器,我们稍后会谈到)。

清单 10-2 展示了一个覆盖 onCreateView() 的例子。

清单 10-2 。覆盖 DialogFragment 的 onCreateView()

public class MyDialogFragment extends DialogFragment
    implements View.OnClickListener
{
    .....other functions
    public View onCreateView(LayoutInflater inflater,
            ViewGroup container, Bundle savedInstanceState)
    {
        //Create a view by inflating desired layout
        View v =
 inflater.inflate(R.layout.prompt_dialog, container, false);

        //you can locate a view and set values
        TextView tv = (TextView)v.findViewById(R.id.promptmessage);
        tv.setText(this.getPrompt());

        //You can set callbacks on buttons
        Button dismissBtn = (Button)v.findViewById(R.id.btn_dismiss);
        dismissBtn.setOnClickListener(this);

        Button saveBtn = (Button)v.findViewById(R.id.btn_save);
        saveBtn.setOnClickListener(this);
        return v;
    }
    .....other functions
}

在清单 10-2 中,你正在加载一个由布局标识的视图。然后寻找两个按钮,并在它们上面设置回调。这与你在《??》第八章中创建细节片段的方式非常相似。然而,与前面的片段不同,对话框片段有另一种方式来创建视图层次结构。

「突出显示」对话方块「??」

作为在 onCreateView() 中提供视图的替代方法,您可以覆盖 onCreateDialog() 并提供一个对话框实例。清单 10-3 提供了这种方法的示例代码。

清单 10-3 。覆盖对话框片段的 onCreateDialog()

public class MyDialogFragment extends DialogFragment
    implements DialogInterface.OnClickListener
{
    .....other functions
    @Override
    public Dialog onCreateDialog(Bundle icicle)
    {
        AlertDialog.Builder b = new AlertDialog.Builder(getActivity())
          .setTitle("My Dialog Title")
          .setPositiveButton("Ok", this)
          .setNegativeButton("Cancel", this)
          .setMessage(this.getMessage());
        return b.create();
    }
    .....other functions
}

在此示例中,您使用警报对话框生成器来创建要返回的对话框对象。这对于简单的对话框很有效。覆盖 onCreateView() 的第一个选项同样简单,并且提供了更大的灵活性。

警报对话框。Builder 实际上是 3.0 之前 Android 的遗留物。这是创建对话框的老方法之一,您仍然可以在 DialogFragment s 中创建对话框。正如您所看到的,通过调用各种可用的方法来创建对话框相当容易,就像我们在这里所做的那样。

显示对话片段

一旦构建了一个对话片段,就需要一个片段事务来显示它。像所有其他片段一样,对对话片段的操作是通过片段事务进行的。

对话片段上的 show() 方法将片段事务作为输入。你可以在清单 10-1 中看到这一点。 show() 方法使用片段事务将这个对话框添加到活动中,然后提交片段事务。但是, show() 方法不会将事务添加到后台堆栈中。如果要这样做,需要先把这个事务添加到后台栈,然后传递给 show() 方法。对话框片段的 show() 方法具有以下签名:

public int show(FragmentTransaction transaction, String tag)
public int show(FragmentManager manager, String tag)

第一个 show() 方法通过将这个片段添加到带有指定标记的传入事务中来显示对话框。然后,该方法返回已提交事务的标识符。

第二个 show() 方法自动从事务管理器获取事务。这是一个快捷的方法。然而,当您使用第二种方法时,您没有将事务添加到后台堆栈的选项。如果您想要该控件,您需要使用第一种方法。如果您只想显示对话框,并且当时没有其他理由使用片段事务,那么可以使用第二种方法。

对话作为片段的一个好处是底层的片段管理器完成了基本的状态管理。例如,即使设备在显示对话框时旋转,对话框也会在您不执行任何状态管理的情况下再现。

对话框片段还提供了控制显示对话框视图的框架的方法,例如框架的标题和外观。参考 DialogFragment 类文档以查看这些选项的更多信息;本章末尾提供了该 URL。

消除对话片段

有两种方法可以消除对话框片段。第一种是显式调用对话框片段上的 dismisse()方法来响应对话框视图上的按钮或某些动作,如清单 10-4 所示。

清单 10-4 。调用解除()

if (someview.getId() == R.id.btn_dismiss)
{
    //use some callbacks to advise clients
    //of this dialog that it is being dismissed
    //and call dismiss
    dismiss();
    return;
}

对话框片段的 dissolve()方法从片段管理器中移除该片段,然后提交该事务。如果这个对话片段有一个后台堆栈,那么 dissolve()将当前对话弹出事务堆栈,并呈现前一个片段的事务状态。不管有没有回栈,调用 disass()都会导致调用标准的对话框片段销毁回调,包括 onDismiss() 。

需要注意的一点是,你不能依靠 onDismiss() 来断定你的代码已经调用了一个 disass()。这是因为当设备配置改变时 onDismiss() 也被调用,因此不能很好地指示用户对对话框本身做了什么。如果用户旋转设备时对话框正在显示,即使用户没有按下对话框中的按钮,对话框片段也会看到 onDismiss() 被调用。相反,你应该总是依赖于对话框视图上的显式按钮点击。

如果用户在对话框片段显示时按下后退按钮,这将导致对话框片段触发 onCancel() 回调。默认情况下,Android 让对话框片段消失,所以你不需要自己对片段调用 dismisse()。但是如果您想让调用活动得到对话框被取消的通知,您需要从 onCancel() 内部调用逻辑来实现这一点。这就是 onCancel() 和 onDismiss() 对于对话片段的区别。对于 onDismiss() ,您不能确定到底发生了什么导致了 onDismiss() 回调的触发。您可能还注意到,一个对话框片段没有 cancel() 方法,只有 dissolve();但是正如我们所说的,当一个对话框片段被按下返回按钮取消时,Android 会帮你取消/消除它。

消除对话片段的另一种方法是呈现另一个对话片段。关闭当前对话框并显示新对话框的方式与关闭当前对话框略有不同。清单 10-5 显示了一个例子。

清单 10-5 。为后台堆栈设置对话框

if (someview.getId() == R.id.btn_invoke_another_dialog)
{
    Activity act = getActivity();
    FragmentManager fm = act.getFragmentManager();
    FragmentTransaction ft = fm.beginTransaction();
    ft.remove(this);

    ft.addToBackStack(null);
    //null represents no name for the back stack transaction

    HelpDialogFragment hdf =
        HelpDialogFragment.newInstance(R.string.helptext);
    hdf.show(ft, "HELP");
    return;
}

在单个事务中,您将删除当前的对话片段并添加新的对话片段。这具有使当前对话框在视觉上消失并使新对话框出现的效果。如果用户按下 Back 按钮,因为您已经在 back stack 上保存了这个事务,新的对话框被关闭,并显示上一个对话框。例如,这是显示帮助对话框的一种便捷方式。

对话框解除的含义

当您向片段管理器添加任何片段时,片段管理器会对该片段进行状态管理。这意味着当设备配置发生变化时(例如,设备旋转),活动会重新启动,片段也会重新启动。在第八章的中,您在运行莎士比亚示例应用时旋转了设备。

设备配置的改变不会影响对话框,因为它们也由片段管理器管理。但是 show() 和 disass()的隐含行为意味着如果你不小心的话,你会很容易忘记一个对话片段。 show() 方法自动将片段添加到片段管理器中;方法自动从片段管理器中删除片段。在开始显示对话片段之前,您可能有一个指向该片段的直接指针。但是你不能将这个片段添加到片段管理器中,之后再调用 show() ,因为一个片段只能添加一次到片段管理器中。您可以计划通过恢复活动来检索该指针。但是,如果您显示并关闭这个对话框,这个片段将被隐式地从片段管理器中删除,从而拒绝该片段被恢复和重新打印的能力(因为片段管理器在它被删除后不知道这个片段的存在)。

如果你想在对话框关闭后保持它的状态,你需要在父活动中或者在一个非对话框片段中保持对话框外的状态。

对话片段示例应用

在本节中,您将回顾一个示例应用,它演示了对话框片段的这些概念。您还将检查片段和包含它的活动之间的通信。要实现这一切,您需要五个 Java 文件:

  • MainActivity.java:你申请的主要活动。它显示了一个简单的视图,其中包含帮助文本和一个菜单,可以从该菜单启动对话框。
  • PromptDialogFragment.java:一个对话框片段的例子,它用 XML 定义了自己的布局,并允许用户输入。它有三个按钮:保存、消除(取消)和帮助。
  • AlertDialogFragment.java:一个对话框片段的例子,它使用 AlertBuilder 类在这个片段中创建一个对话框。这是创建对话的传统方式。
  • HelpDialogFragment.java:一个非常简单的片段,显示了来自应用资源的帮助消息。特定的帮助消息是在创建帮助对话框对象时标识的。这个帮助片段可以在主活动和提示对话框片段中显示。
  • OnDialogDoneListener.java:一个接口,您需要您的活动来实现它,以便从片段中获取消息。使用接口意味着你的片段不需要知道太多关于调用活动的信息,除非它已经实现了这个接口。这有助于封装功能。从活动的角度来看,它有一个通用的方法来接收来自片段的信息,而不需要了解太多。

这个应用有三种布局:主活动、提示对话框片段和帮助对话框片段。请注意,您不需要警告对话框片段的布局,因为 AlertBuilder 会在内部为您处理该布局。当你完成后,应用看起来像图 10-1 。

9781430246800_Fig10-01.jpg

图 10-1 。对话框片段示例应用的用户界面

对话示例:主活动

让我们来看看源代码,您可以从本书的网站上下载(参见“参考资料”一节)。我们将使用 DialogFragmentDemo 项目。在我们继续之前,开放 MainActivity.java 的源代码。

主活动的代码非常简单。您显示一个简单的文本页面并设置一个菜单。每个菜单项调用一个活动方法,每个方法基本上做同样的事情:获取一个片段事务,创建一个新的片段,并显示该片段。注意,每个片段都有一个用于片段事务的唯一标签。这个标记与片段管理器中的片段相关联,因此您可以在以后通过标记名来定位这些片段。该片段还可以用片段上的 getTag() 方法确定自己的标签值。

主活动中的最后一个方法定义是 onDialogDone() ,它是一个回调,是您的活动正在实现的 OnDialogDoneListener 接口的一部分。正如您所看到的,回调提供了一个调用您的片段的标签、一个指示对话框片段是否被取消的布尔值和一条消息。就您的目的而言,您只想将信息记录到 LogCat 您还可以使用吐司向用户展示它。吐司将在本章后面介绍。

样本对话框:ondialogdonelistener

为了让您能够知道对话框何时消失,请创建一个由对话框调用者实现的侦听器接口。接口的代码在 OnDialogDoneListener.java。

如你所见,这是一个非常简单的界面。您只能为此接口选择一个回调,活动必须实现该回调。您的片段不需要知道调用活动的细节,只需要知道调用活动必须实现 OnDialogDoneListener 接口;因此,片段可以调用这个回调来与调用活动通信。根据片段正在做的事情,接口中可能有多个回调。对于这个示例应用,您将显示与片段类定义分开的接口。为了更容易管理代码,您可以将片段侦听器接口嵌入片段类定义本身,从而更容易保持侦听器和片段之间的同步。

对话框示例:PromptDialogFragment

现在让我们来看看你的第一个片段, PromptDialogFragment ,它的布局在/res/layout/prompt_dialog.xml 中,Java 代码在 PromptDialogFragment.java 的/src 下。

这个提示对话框布局看起来像你以前见过的许多。有一个 TextView 作为提示;一个 EditText 接受用户的输入;以及用于保存输入、消除(取消)对话片段和弹出帮助对话框的三个按钮。

Java 代码开始看起来就像你之前的片段。您有一个 newInstance() 静态方法来创建新对象,在这个方法中,您调用默认的构造函数,构建一个参数包,并将其附加到您的新对象。接下来,您在 onAttach() 回调中有了新的东西。您希望确保您刚刚附加到的活动已经实现了 OnDialogDoneListener 接口。为了测试这一点,您将传递的活动转换为 OnDialogDoneListener 接口。下面是代码:

try {
    OnDialogDoneListener test = (OnDialogDoneListener)act;
}
catch(ClassCastException cce) {
    // Here is where we fail gracefully.
    Log.e(MainActivity.LOGTAG, "Activity is not listening");
}

如果活动没有实现这个接口,就会抛出一个 ClassCastException 。您可以更优雅地处理这个异常,但是这个示例尽可能保持代码简单。

接下来是 onCreate() 回调。与片段一样,您不需要在这里构建您的用户界面,但是您可以设置对话框样式。这是对话片段所特有的。您可以自己设置样式和主题,也可以只设置样式并使用主题值零(0)让系统为您选择合适的主题。下面是代码:

int style = DialogFragment.STYLE_NORMAL, theme = 0;
setStyle(style,theme);

在 onCreateView() 中,您为您的对话框片段创建视图层次结构。就像其他片段一样,您没有将视图层次结构附加到传入的视图容器中(也就是说,通过将 attachToRoot 参数设置为 false )。然后继续设置按钮回调,并将对话框提示文本设置为最初传递给 newInstance() 的提示。

没有显示 onCancel() 和 onDismiss() 回调,因为它们所做的只是记录日志;您将能够看到这些回调在片段的生命周期中何时触发。

提示对话框片段中的最后一个回调是针对按钮的。再一次,您获取对您的封闭活动的引用,并将其转换为您期望该活动已经实现的接口。如果用户按下了 Save 按钮,您获取输入的文本并调用接口的回调函数 onDialogDone() 。这个回调函数接受这个片段的标记名、一个指示这个对话框片段是否被取消的布尔值和一条消息,在这个例子中,这条消息是用户输入的文本。这是来自的主要活动:

public void onDialogDone(String tag, boolean cancelled,
                         CharSequence message) {
    String s = tag + " responds with: " + message;
    if(cancelled)
        s = tag + " was cancelled by the user";
    Toast.makeText(this, s, Toast.LENGTH_LONG).show();
    Log.v(LOGTAG, s);
}

为了完成对 Save 按钮的点击操作,您可以调用 dissolve()来删除对话框片段。记住 dismisse()不仅使片段在视觉上消失,而且还将片段从片段管理器中弹出,因此它不再对您可用。

如果按下的按钮是 dissolve,那么再次调用接口回调,这次没有消息,然后调用 dissolve()。最后,如果用户按下了帮助按钮,您不想丢失提示对话框片段,所以您做了一些稍微不同的事情。我们之前描述过。为了记住提示对话框片段,以便以后可以回到它,您需要创建一个片段事务来删除提示对话框片段,并使用 show() 方法添加帮助对话框片段;这个需要放到后面的堆栈里。还要注意,帮助对话框片段是如何通过引用资源 ID 来创建的。这意味着您的帮助对话框片段可以与您的应用可用的任何帮助文本一起使用。

对话框示例:HelpDialogFragment

您创建了一个片段事务,从提示对话框片段转到帮助对话框片段,并将该片段事务放在后台堆栈上。这具有使提示对话框片段从视图中消失的效果,但是仍然可以通过片段管理器和后台堆栈访问它。新的帮助对话框片段出现在它的位置,并允许用户阅读帮助文本。当用户消除帮助对话框片段时,弹出片段后栈条目,帮助对话框片段的效果被消除(从视觉上和从片段管理器中),并且提示对话框片段恢复到视图。这是实现这一切的一个非常简单的方法。它非常简单却非常强大;如果用户在显示这些对话框时旋转设备,它甚至可以工作。

看一下 HelpDialogFragment.java 文件的源代码及其布局( help_dialog.xml )。这个对话框片段的目的是显示帮助文本。布局是一个文本视图和一个关闭按钮。您应该开始对 Java 代码感到熟悉了。有一个 newInstance() 方法创建新的帮助对话框片段,一个 onCreate() 方法设置样式和主题,一个 onCreateView() 方法构建视图层次结构。在这个特殊的例子中,您希望找到一个字符串资源来填充 TextView ,因此您通过活动访问资源,并选择传递给 newInstance() 的资源 ID。最后, onCreateView() 设置一个按钮点击处理器来捕获关闭按钮的点击。这种情况下,解散的时候不需要做什么有趣的事情。

这个片段有两种调用方式:从活动调用和从提示对话框片段调用。当这个帮助对话框片段显示在主活动中时,消除它只是将片段从顶部弹出,并显示下面的主活动。当该帮助对话框片段从提示对话框片段中显示时,因为帮助对话框片段是后台堆栈上片段事务的一部分,所以消除它会导致片段事务回滚,这将弹出帮助对话框片段,但会恢复提示对话框片段。用户看到提示对话框片段再次出现。

对话框示例:AlertDialogFragment

在这个示例应用中,我们还有最后一个对话框片段要向您展示:警报对话框片段。虽然您可以以类似于帮助对话框片段的方式创建一个警告对话框片段,但是您也可以使用旧的 AlertBuilder 框架来创建一个对话框片段,该框架已经在许多 Android 版本中运行。看看 AlertDialogFragment.java 的源代码。

你不需要为它设计一个布局,因为 AlertBuilder 会为你做好准备。注意,这个对话框片段开始时和其他的一样,但是不是一个 onCreateView() 回调,而是一个 onCreateDialog() 回调。您可以实现 onCreateView() 或 onCreateDialog() ,但不能同时实现两者。从 onCreateDialog() 返回的不是视图;这是一个对话。这里感兴趣的是,要获得对话框的参数,应该访问 arguments 包。在这个示例应用中,您只对警报消息这样做,但是您也可以通过 arguments bundle 访问其他参数。

还要注意,对于这种类型的对话框片段,您需要片段类来实现 DialogInterface。OnClickListener ,这意味着您的对话框片段必须实现 onClick() 回调。当用户操作嵌入的对话框时,这个回调被触发。同样,您会得到一个对触发的对话框的引用,以及按下了哪个按钮的指示。和以前一样,您应该小心不要依赖于 onDismiss() ,因为当设备配置发生变化时,这可能会触发。

对话框示例:嵌入式对话框

您可能已经注意到了 DialogFragment 的另一个特性。在应用的主布局中,文本下面是一个框架布局,它可以用来保存一个对话框。在应用的菜单中,最后一项导致片段事务将一个 PromptDialogFragment 的新实例添加到主屏幕。无需任何修改,对话框片段可以嵌入在主布局中显示,并且它的功能与您预期的一样。

这项技术的一个不同之处是,显示嵌入式对话框的代码与显示弹出对话框的代码不同。嵌入的对话框代码如下所示:

ft.add(R.id.embeddedDialog, pdf, EMBED_DIALOG_TAG);
ft.commit();

这看起来和第八章第一节中的一样,当我们在一个框架布局中显示一个片段的时候。但是,这一次,您要确保传入一个标记名,当对话框片段通知您用户输入的活动时会用到这个标记名。

对话示例:观察

当您运行这个示例应用时,请确保在设备的不同方向上尝试所有的菜单选项。显示对话片段时旋转设备。您应该很高兴看到对话框与旋转一起出现;您不需要担心大量的代码来管理由于配置更改而导致的片段的保存和恢复。

我们希望你欣赏的另一件事是你可以在片段和活动之间轻松交流。当然,活动有对所有可用片段的引用,或者可以获得对所有可用片段的引用,因此它可以访问由片段本身公开的方法。这不是片段和活动之间唯一的交流方式。您总是可以使用片段管理器上的 getter 方法来检索托管片段的实例,然后适当地转换该引用并直接调用该片段上的方法。您甚至可以在另一个片段中这样做。您使用接口和通过活动将片段相互隔离的程度,或者使用片段到片段的通信构建依赖关系的程度,取决于您的应用有多复杂,以及您想要实现多少重用。

使用吐司工作

一个 Toast 就像一个迷你警告对话框,有一条消息,显示一段时间,然后自动消失。它没有任何按钮。所以可以说是一个瞬态的告警信息。它被称为吐司,因为它像吐司一样从烤面包机里蹦出来。

清单 10-10 展示了一个如何使用 Toast 显示消息的例子。

清单 10-10 。使用 Toast 进行调试

//Create a function to wrap a message as a toast
//show the toast
public void reportToast(String message)
{
    String s = MainActivity.LOGTAG + ":" + message;
    Toast.makeText(activity, s, Toast.LENGTH_SHORT).show();
}

清单 10-10 中的 makeText() 方法不仅可以接受活动,还可以接受任何上下文对象,比如传递给广播接收器或服务的对象。这扩展了吐司在活动之外的用途。

参考

摘要

本章讨论了异步对话框以及如何使用对话框片段,包括以下主题:

  • 什么是对话框,为什么要使用对话框
  • Android 中对话框的异步特性
  • 让对话框显示在屏幕上的三个步骤
  • 创建一个片段
  • 对话框片段如何创建视图层次结构的两种方法
  • 片段事务如何参与显示对话片段,以及如何获得一个片段事务
  • 当用户在查看对话片段时按下后退按钮会发生什么
  • 后台堆栈和管理对话片段
  • 当一个对话框片段上的按钮被点击时会发生什么,你如何处理它
  • 一种从对话片段与调用活动通信的干净方式
  • 一个对话片段如何调用另一个对话片段并仍然返回到前一个对话片段
  • 这个 Toast 类以及它如何被用作一个简单的警告弹出窗口

十一、使用首选项和保存状态

Android 提供了一个强大而灵活的框架来处理设置,也称为偏好。所谓设置,我们指的是用户为定制他们喜欢的应用而做出并保存的那些特性选择。(在本章中,术语“设置”和“首选项”将互换使用。)例如,如果用户想要经由铃声或振动的通知或者根本不想要,则这是用户保存的偏好;应用会记住这个选择,直到用户改变它。Android 提供了简单的 API,这些 API 隐藏了首选项的管理和持久化。它还提供了预构建的用户界面,您可以使用这些界面让用户进行偏好选择。由于 Android preferences 框架内置了强大的功能,我们还可以使用 preferences 来更通用地存储应用状态,以允许我们的应用从它停止的地方重新开始,如果我们的应用离开并稍后回来的话。作为另一个例子,一个游戏的高分可以存储为首选项,尽管您希望使用自己的 UI 来显示它们。

本章涵盖了如何为您的应用实现您自己的设置屏幕,如何与 Android 系统设置交互,以及如何使用设置来秘密保存应用状态,它还提供了最佳实践指导。你将会发现如何让你的设置在小屏幕和大屏幕上看起来都不错,比如平板电脑。

探索偏好框架

Android 的首选项框架从单个设置选项构建到包含设置选项的屏幕层次结构。设置可以是二进制设置,如开/关、文本输入或数值,也可以是选项列表中的一个选项。Android 使用一个 PreferenceManager 向应用提供设置值。该框架负责进行和保持更改,并在设置更改或即将更改时通知应用。虽然设置保存在文件中,但应用并不直接处理文件。文件被藏了起来,你很快就会看到它们在哪里。

与第三章中的视图一样,可以用 XML 或通过编写代码来指定首选项。在本章中,您将使用一个示例应用来演示不同类型的选择。XML 是指定首选项的首选方式,所以应用就是这样编写的。XML 指定了最低级别的设置,以及如何将设置分组到类别和屏幕中。作为参考,本章的示例应用给出了如下设置,如图图 11-1T5 所示。

9781430246800_Fig11-01.jpg

图 11-1 。示例应用首选项 UI 中的主要设置。由于屏幕的高度,它被显示为顶部在左边,底部在右边。请注意两幅图像之间的重叠部分

Android 提供了一个端到端的偏好框架。这意味着框架允许您定义首选项,向用户显示设置,并将用户的选择保存到数据存储中。您可以在 XML 中的 /res/xml/ 下定义您的首选项。为了向用户显示首选项,您需要编写一个 activity 类来扩展一个预定义的 Android 类,名为 Android . preference . preference activity,并使用片段来处理首选项的屏幕。框架负责剩下的事情(显示和持久化)。在您的应用中,您的代码将获得对特定首选项的引用。有了首选项引用,您可以获得首选项的当前值。

为了在用户会话中保存首选项,当前值必须保存在某个位置。Android 框架负责将首选项保存在设备上应用的 /data/data 目录下的 XML 文件中(参见图 11-2 )。

9781430246800_Fig11-02.jpg

图 11-2 。应用保存偏好的路径

注意您将只能在模拟器中检查共享的偏好设置文件。在真实的设备上,由于 Android 的安全性,共享的首选项文件是不可读的(当然,除非你有 root 权限)。

应用的默认首选项文件路径是 /data/data/ 【包名】 /shared_prefs/ 【包名】 _preferences.xml,其中【包名】是应用的包。清单 11-1 显示了本例的 com . Android book . preferences . main _ preferences . XML 数据文件。

清单 11-1 。为我们的示例保存的首选项

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<boolean name="notification_switch" value="true" />
<string name="package_name_preference">com.androidbook.win</string>
<boolean name="potato_selection_pref" value="true" />
<boolean name="show_airline_column_pref" value="true" />
<string name="flight_sort_option">2</string>
<boolean name="alert_email" value="false" />
<set name="pizza_toppings">
<string>pepperoni</string>
<string>cheese</string>
<string>olive</string>
</set>
<string name="alert_email_address">davemac327@gmail.com</string>
</map>

如您所见,值存储在一个映射中,首选项作为数据值的名称。有些值看起来很神秘,与显示给用户的内容不匹配。例如,航班排序选项的值是 2。Android 不会将显示的文本存储为首选项的值;相反,它存储了一个用户看不到的值,你可以独立于用户看到的内容来使用它。您希望能够根据用户的语言自由地更改显示的文本,还希望能够调整显示的文本,同时保持首选项文件中存储的值不变。如果值是一个整数而不是一些显示字符串,您甚至可以对首选项进行更简单的处理。您不必担心的是解析这个数据文件。Android 首选项框架提供了一个很好的 API 来处理首选项,这将在本章后面更详细地描述。

如果您将清单 11-1 中的偏好映射与图 11-1 中的截图进行比较,您会注意到并非所有的偏好都在偏好 XML 数据文件中列出了值。这是因为首选项数据文件不会自动为您存储默认值。您将很快看到如何处理默认值。

既然您已经看到了保存值的位置,那么您需要了解如何定义向用户显示的屏幕,以便他们可以进行选择。在您看到如何将偏好设置收集到屏幕中之前,您将了解您可以使用的不同类型的偏好设置,然后您将看到如何将它们收集到屏幕中。/data/data XML 文件中的每个持久值都来自特定的首选项。因此,让我们来了解一下其中每一项的含义。

了解复选框首选项和开关首选项

最简单的首选项是复选框首选项和开关首选项。这些共享一个公共的父类( TwoStatePreference ),或者打开(值为真)或者关闭(值为假)。对于示例应用,创建了一个带有五个复选框首选项的屏幕,如图 11-3 中的所示。清单 11-2 显示了 CheckBoxPreference 的 XML 外观。

9781430246800_Fig11-03.jpg

图 11-3 。复选框首选项的用户界面

清单 11-2 。使用复选框首选项

<CheckBoxPreference
        android:key="show_airline_column_pref"
        android:title="Airline"
        android:summary="Show Airline column" />

注意我们会在本章末尾给你一个 URL,你可以用它来下载本章的项目。这将允许您将这些项目直接导入到 IDE 中。主示例应用名为 PrefDemo。您应该参考该项目,直到到达保存状态部分。

此示例显示了指定首选项所需的最低要求。关键字是首选项的引用或名称,标题是为首选项显示的标题,摘要是对首选项内容或当前设置状态的描述。回头看看清单 11-1 中保存的值,您会看到一个用于“show_airline_column_pref”(关键字)的 <布尔> 标记,它有一个属性值 true,这表明首选项已被选中。

使用复选框首选项,当用户设置状态时,保存首选项的状态。换句话说,当用户选中或取消选中首选项控件时,它的状态会被立即保存。

除了视觉显示不同之外, SwitchPreference 非常相似。用户看到的不是用户界面中的复选框,而是一个开关,如图图 11-1 中“通知是”旁边所示。

CheckBoxPreference 和 SwitchPreference 的另一个有用的特性是,你可以根据它是否被选中来设置不同的摘要文本。XML 属性是 summaryOn 和 summaryOff 。如果您在 main.xml 文件中查找名为“potato_selection_pref”的复选框 Preference ,您会看到一个这样的例子。

在学习其他首选项类型之前,现在是了解如何访问该首选项以读取其值并执行其他操作的好时机。

在代码中访问首选项值

现在您已经定义了一个首选项,您需要知道如何在代码中访问该首选项,以便可以读取值。清单 11-3 显示了访问 Android 中 SharedPreferences 对象的代码,该对象中存在首选项。这段代码来自 setOptionText() 方法中的【MainActivity.java】文件。

清单 11-3 。访问复选框首选项

    SharedPreferences prefs =
            PreferenceManager.getDefaultSharedPreferences(this);
//  This is the other way to get to the shared preferences:
//  SharedPreferences prefs = getSharedPreferences(
//          "com.androidbook.preferences.main_preferences", 0);
    boolean showAirline = prefs.getBoolean("show_airline_column_pref", false);

使用对首选项的引用,可以直接读取 show_airline_column_pref 首选项的当前值。如清单 11-3 所示,有两种方法可以获得首选项。所示的第一种方法是获取当前上下文的默认首选项。在这种情况下,上下文是我们应用的主活动的上下文。第二种情况是注释掉的,使用包名检索首选项。如果您需要在不同的文件中存储不同的首选项集,您可以使用您想要的任何包名。

一旦有了对首选项的引用,就可以用首选项的键和默认值调用适当的 getter 方法。由于 show_airline_column_pref 是一个 TwoStatePreference ,所以返回值是一个布尔值。show_airline_column_pref 的默认值在这里被硬编码为 false。如果这个偏好还没有被设置,硬编码值( false )将被分配给 showAirline 。但是,这本身并不会将首选项保持为 false 以备将来使用,也不会考虑 XML 规范中为该首选项设置的任何默认值。如果 XML 规范使用资源值来指定默认值,则可以在代码中引用相同的资源来设置默认值,如以下不同首选项所示:

String flight_option = prefs.getString(
        resources.getString(R.string.flight_sort_option),
        resources.getString(R.string.flight_sort_option_default_value));

注意这里首选项的键也使用了一个字符串资源值(r . string . flight _ sort _ option)。这可能是一个明智的选择,因为它减少了打字错误的可能性。如果资源名称输入错误,您很可能会得到一个构建错误。如果您只使用简单的字符串,除了您的首选项不起作用之外,输入错误可能会被忽略。

我们展示了一种在代码中读取首选项默认值的方法。Android 提供了另一种更优雅的方式。在 onCreate() 中,您可以改为执行以下操作:

PreferenceManager.setDefaultValues(this, R.xml.main, false);

然后,在 setOptionText() 中,您应该这样做来读取选项值:

String option = prefs.getString(
    resources.getString(R.string.flight_sort_option), null);

第一个调用将使用 main.xml 来查找默认值,并使用默认值为我们生成首选项 xml 数据文件。如果我们在内存中已经有了一个 SharedPreferences 对象的实例,它也会更新这个实例。然后,第二个调用将为 flight_sort_option 找到一个值,因为我们首先负责加载默认值。

第一次运行这段代码后,如果您查看 shared_prefs 文件夹,您将会看到 preferences XML 文件,即使 preferences 屏幕尚未被调用。您还会看到另一个名为 _ has _ set _ default _ values . XML 的文件。这个文件告诉您的应用,已经使用默认值创建了首选项 XML 文件。的第三个参数 setDefaultValues()—即 false—表示您希望在 preferences XML 文件中设置默认值,前提是以前没有这样做过。Android 通过这个新 XML 文件的存在记住了这些信息。然而,即使你升级你的应用并添加具有新默认值的新设置,Android 也会记住,这意味着这个技巧不会设置那些新默认值。您的最佳选择是始终使用资源作为默认值,并在获取首选项的当前值时始终提供该资源作为默认值。

理解 ListPreference

列表首选项包含每个选项的单选按钮,默认(或当前)选项是预先选定的。期望用户选择一个且仅一个选项。当用户选择一个选项时,对话框会立即关闭,选择会保存在 preferences XML 文件中。图 11-4 显示了的样子。

9781430246800_Fig11-04.jpg

图 11-4 。list preference 的用户界面

清单 11-4 包含一个 XML 片段,表示航班选项偏好设置。这一次,该文件包含对字符串和数组的引用,这是指定这些内容而不是对字符串进行硬编码的更常见方式。如前所述,存储在 /data/data/{package} 目录下的 XML 数据文件中的列表首选项的值与用户在用户界面中看到的不同。密钥的名称与用户看不到的隐藏值一起存储在数据文件中。因此,要让 ListPreference 工作,需要两个数组:显示给用户的值和用作键值的字符串。这是你容易犯错的地方。entries 数组保存向用户显示的字符串,而 entryValues 数组保存将存储在首选项数据 XML 文件中的字符串。

清单 11-4 。在 XML 中指定 ListPreference

<ListPreference
  android:key="@string/flight_sort_option"
  android:title="@string/listTitle"
  android:summary="@string/listSummary"
  android:entries="@array/flight_sort_options"
  android:entryValues="@array/flight_sort_options_values"
  android:dialogTitle="@string/dialogTitle"
  android:defaultValue="@string/flight_sort_option_default_value" />

两个阵列之间的元素在位置上彼此对应。也就是说, entryValues 数组中的第三个元素对应于 entries 数组中的第三个元素。用 0,1,2 等很有诱惑力。,因为 entryValues 但这不是必需的,而且当以后必须修改数组时,这可能会导致问题。如果我们的选项本质上是数字(例如,一个倒数计时器的起始值),那么我们可以使用 60、120、300 等值。只要对开发人员有意义,这些值根本不需要是数字;用户看不到这些值,除非您选择公开它们。用户只能看到第一个字符串数组 flight_sort_options 中的文本。本章的示例应用展示了这两种方式。

这里需要注意的是:因为 preferences XML 数据文件只存储值而不存储文本,所以如果您升级了应用并更改了选项的文本或者向字符串数组添加了项,那么在升级之后,preferences XML 数据文件中存储的任何值都应该与适当的文本对齐。在应用升级期间,会保留首选项 XML 数据文件。如果 preferences XML 数据文件中有 a "1" ,并且这意味着升级前的" # of Stops ",那么它在升级后仍然意味着" # of Stops "。

由于最终用户看不到 entryValues 数组,所以最好在应用中只存储一次。因此,创建一个且只有一个/RES/values/prefvaluearrays . XML 文件来包含这些数组。对于不同的语言或不同的设备配置,每个应用很可能会多次创建条目数组。因此,为您需要的每一种变化制作单独的 predisplayarrays . XML 文件。例如,如果您的应用将使用英语和法语,则英语和法语将有单独的 prefdisplayarrays.xml 文件。您不希望在这些其他文件中包含 entryValues 数组。不过,在 entryValues 和 entries 数组之间必须有相同数量的数组元素。元素必须对齐。当你做出改变时,要注意保持一切都在一条线上。清单 11-5 包含示例的 ListPreference 文件的源。

清单 11-5 。我们示例中的其他 ListPreference 文件

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/values/prefvaluearrays.xml -->
<resources>
<string-array name="flight_sort_options_values">
    <item>0</item>
    <item>1</item>
    <item>2</item>
</string-array>
<string-array name="pizza_toppings_values">
    <item>cheese</item>
    <item>pepperoni</item>
    <item>onion</item>
    <item>mushroom</item>
    <item>olive</item>
    <item>ham</item>
    <item>pineapple</item>
</string-array>
<string-array name="default_pizza_toppings">
    <item>cheese</item>
    <item>pepperoni</item>
</string-array>
</resources>

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/values/prefdisplayarrays.xml -->
<resources>
<string-array name="flight_sort_options">
    <item>Total Cost</item>
    <item># of Stops</item>
    <item>Airline</item>
</string-array>
<string-array name="pizza_toppings">
    <item>Cheese</item>
    <item>Pepperoni</item>
    <item>Onions</item>
    <item>Portobello Mushrooms</item>
    <item>Black Olives</item>
    <item>Smoked Ham</item>
    <item>Pineapple</item>
</string-array>
</resources>

另外,不要忘记 XML 源文件中指定的默认值必须与 prefvaluearrays.xml 的数组中的 entryValue 相匹配。

对于 ListPreference,首选项的值是一个字符串。如果你使用数字串(例如,0,1,1138)作为的 entryValues ,你可以把它们转换成整数或者你在代码中需要的任何东西,就像在 flight_sort_options_values 数组中使用的那样。

您的代码可能想要显示来自首选项的条目数组的用户友好文本。这个例子采用了一个捷径,因为数组索引被用于 flight _ sort _ options _ values 中的元素。通过简单地将值转换成一个 int ,你就知道从 flight_sort_options 中读取哪个字符串。如果您为 flight _ sort _ options _ values 使用了其他值集,您将需要确定您偏好的元素的索引,然后使用该索引从 flight_sort_options 中获取您偏好的文本。 ListPreference 的 helper 方法 findindexoffvalue()可以对此有所帮助,它将索引提供给 values 数组,这样您就可以轻松地从 entries 数组中获取相应的显示文本。

现在回到清单 11-4 ,有几个字符串用于标题、摘要等等。名为的字符串 flight _ sort _ option _ default _ value 将默认值设置为 1 ,以表示示例中的“# of Stops”。为每个选项选择一个默认值通常是一个好主意。如果您没有选择默认值,也没有选择任何值,那么返回选项值的方法将返回空值。在这种情况下,您的代码必须处理空值。

了解编辑文本首选项

首选项框架还提供了一个名为 EditTextPreference 的自由格式文本首选项。该首选项允许您捕获原始文本,而不是要求用户做出选择。为了演示这一点,让我们假设您有一个为用户生成 Java 代码的应用。这个应用的首选项设置之一可能是用于生成的类的默认包名。在这里,您希望向用户显示一个文本字段,以便为生成的类设置包名。图 11-5 显示了 UI,清单 11-6 显示了 XML。

9781430246800_Fig11-05.jpg

图 11-5 。使用编辑文本首选项

清单 11-6 。一个 EditTextPreference 的例子

<EditTextPreference
        android:key="package_name_preference"
        android:title="Set Package Name"
        android:summary="Set the package name for generated code"
        android:dialogTitle="Package Name" />

当选择“设置包名”时,会向用户显示一个对话框来输入包名。单击“确定”按钮时,首选项会保存到首选项存储中。

与其他首选项一样,您可以通过调用适当的 getter 方法来获取首选项的值,在本例中是 getString() 。

了解 MultiSelectListPreference

最后,Android 3.0 中引入了一个名为 multiselectlistposition 的偏好设置。这个概念有点类似于 ListPreference ,但是用户不是只能在列表中选择一个项目,而是可以选择几个或者一个都不选。在清单 11-1 中,multiselectlist preference 在 preferences XML 数据文件中存储一个标签,而不是单个值。与 multiselectlist preference 的另一个显著区别是,默认值是一个数组,就像 entryValues 数组一样。也就是说,对于该首选项,默认值数组必须包含零个或多个来自 entryValues 数组的元素。这也可以在本章的示例应用中看到;只需查看 /res/xml 目录中 main.xml 文件的结尾即可。

要获得一个 multiselectlist preference 的当前值,使用 SharedPreferences 的 getStringSet() 方法。要从 entries 数组中检索显示字符串,您需要遍历作为该首选项的值的字符串集,确定字符串的索引,并使用该索引从 entries 数组中访问适当的显示字符串。

更新 AndroidManifest.xml

因为示例应用中有两个活动,所以我们需要在 AndroidManifest.xml 中有两个活动标记。第一个是类别 LAUNCHER 的标准活动。第二个是针对一个 PreferenceActivity 的,所以根据意图的约定设置动作名称,并将类别设置为 PREFERENCE ,如清单 11-7 所示。你可能不希望 PreferenceActivity 出现在我们所有其他应用的 Android 页面上,这就是为什么你不使用 LAUNCHER 的原因。如果要添加其他偏好活动,您需要对 AndroidManifest.xml 进行类似的更改。

清单 11-7 。androidmanifest . XML 中的 PreferenceActivity 条目

        <activity android:name=".MainPreferenceActivity"
                  android:label="@string/prefTitle">
            <intent-filter>
                <action android:name=
 "com.androidbook.preferences.main.intent.action.MainPreferences" />
                <category
                    android:name="android.intent.category.PREFERENCE" />
            </intent-filter>
        </activity>

使用偏好类别

首选项框架支持您将首选项组织成类别。例如,如果您有很多偏好,您可以使用 PreferenceCategory ,它将偏好分组在一个分隔符标签下。图 11-6 显示了这可能是什么样子。注意名为“肉和“蔬菜的分隔符您可以在 /res/xml/main.xml 中找到这些的规范。

9781430246800_Fig11-06.jpg

图 11-6 。使用偏好类别组织偏好

创建具有依赖关系的子首选项

另一种组织首选项的方法是使用首选项依赖关系。这在偏好之间创建了父子关系。例如,您可能有一个打开提醒的偏好设置;如果警报打开,可能会有几个其他与警报相关的首选项可供选择。如果主提醒首选项关闭,则其他首选项不相关,应被禁用。清单 11-8 显示了 XML,而图 11-7 显示了它的样子。

清单 11-8 。XML 中的首选项依赖

<PreferenceScreen>
    <PreferenceCategory
            android:title="Alerts">

        <CheckBoxPreference
                android:key="alert_email"
                android:title="Send email?" />

        <EditTextPreference
                android:key="alert_email_address"
                android:layout="?android:attr/preferenceLayoutChild"
                android:title="Email Address"
                android:dependency="alert_email" />

    </PreferenceCategory>
</PreferenceScreen>

9781430246800_Fig11-07.jpg

图 11-7 。偏好依赖

带标题的首选项

Android 3.0 引入了一种新的方式来组织偏好。你可以在平板电脑的主设置应用下看到这个。因为平板电脑的屏幕空间比智能手机大得多,所以同时显示更多的偏好信息是有意义的。为此,您可以使用首选项标题。看一下图 11-8 。

9781430246800_Fig11-08.jpg

图 11-8 。带有首选项标题的主设置页面

请注意,标题出现在左侧下方,就像一个垂直的标签栏。当您单击左侧的每个项目时,右侧的屏幕会显示该项目的首选项。在图 11-8 中,选择了声音,声音首选项显示在右侧。右边是一个 PreferenceScreen 对象,这个设置使用了片段。显然,我们需要做一些不同于本章所讨论的事情。

Android 3.0 最大的变化是在 PreferenceActivity 中添加了标题。这也意味着在 PreferenceActivity 中使用一个新的回调函数来设置标题。现在,当您扩展 PreferenceActivity 时,您会想要实现这个方法:

public void onBuildHeaders(List<Header> target) {
    loadHeadersFromResource(R.xml.preferences, target);
}

完整的源代码请参考 PrefDemo 示例应用。 preferences.xml 文件包含一些新标签,如下所示:

<preference-headers
        xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)">
    <header android:fragment="com.example.PrefActivity$Prefs1Fragment"
            android:icon="@drawable/ic_settings_sound"
            android:title="Sound"
            android:summary="Your sound preferences" />
    ...

每个 header 标签指向一个扩展了 PreferenceFragment 的类。在刚刚给出的例子中,XML 指定了图标、标题和摘要文本(类似于副标题)。 Prefs1Fragment 是 PreferenceActivity 的内部类,看起来可能是这样的:

public static class Prefs1Fragment extends PreferenceFragment {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.sound_preferences);
    }
}

这个内部类需要做的就是拉入适当的首选项 XML 文件,如图所示。这个 preferences XML 文件包含了我们前面提到的首选项规范的类型,比如 ListPreference 、 CheckBoxPreference 、 PreferenceCategory 等等。非常好的是,当屏幕配置改变时,当偏好显示在小屏幕上时,Android 会注意做正确的事情。当屏幕太小而无法同时显示标题和右侧的偏好设置屏幕时,标题的行为类似于旧的偏好设置。也就是说,您只能看到标题;当您单击标题时,您只能看到相应的首选项屏幕。

首选屏幕

首选项的顶层容器是一个首选项屏幕。在平板电脑和 PreferenceFragment s 之前,你可以嵌套 PreferenceScreen s,当用户点击嵌套的 PreferenceScreen 项目时,新的 PreferenceScreen 将替换当前显示的 PreferenceScreen 。这在小屏幕上工作得很好,但是在平板电脑上看起来就不那么好了,尤其是如果你从标题和片段开始。您可能希望新的 PreferenceScreen 出现在当前片段所在的位置。

为了让 PreferenceScreen 在片段中工作,您需要做的就是为那个 PreferenceScreen 指定一个片段类名。清单 11-9 展示了样本应用中的 XML。

清单 11-9 。通过 PreferenceFragment 调用 PreferenceScreen

<PreferenceScreen
    android:title="Launch a new screen into a fragment"
    android:fragment="com.androidbook.preferences.main.BasicFrag" />

当用户点击此项时,当前片段被替换为 BasicFrag ,然后加载一个新的 XML 布局给 PreferenceScreen ,如 nested _ screen _ basic frag . XML 中所指定。在这种情况下,我们选择不使 BasicFrag 类成为 MainPreferenceActivity 类的内部类,主要是因为不需要来自外部类的共享,并向您展示如果您愿意,您可以这样做。

动态首选项摘要文本

您可能见过首选项摘要包含当前值的首选项。这实际上比你想象的要难实现一些。为了完成这一任务,您创建了一个侦听器回调函数,该函数检测首选项值何时会发生变化,然后相应地更新首选项摘要。第一步是让您的 PreferenceFragment 实现 OnPreferenceChangeListener 接口。然后您需要实现 onPreferenceChange() 回调。清单 11-10 显示了一个例子。回调中的 pkgPref 对象被提前设置为 onCreate() 方法中的首选项。

清单 11-10 。设置首选项监听器

public boolean onPreferenceChange(Preference preference,
                                      Object newValue) {
    final String key = preference.getKey();
    if ("package_name_preference".equals(key)) {
        pkgPref.setSummary(newValue.toString());
    }
    ...
    return true;
}

您必须使用 setOnPreferenceChangeListener(this)在 onResume() 中将该片段注册为一个监听器,并在 onPause() 中通过使用 null 再次调用它来取消注册。现在,每当您注册的首选项有一个待定的更改时,这个回调将被调用,并传入首选项和潜在的新值。回调返回一个布尔值,指示是否继续将首选项设置为新值(真)或不设置(假)。假设您将返回 true 以允许新的设置,这也是您可以更新汇总值的地方。您也可以验证新值并拒绝更改。也许您希望一个 multiselectlistproperty 具有最大数量的选中项。您可以在回调中计算所选项目的数量,如果数量过多,则拒绝更改。

保存带有首选项的状态

首选项对于允许用户根据自己的喜好定制应用非常有用,但是我们可以使用 Android 首选项框架来做更多的事情。当您的应用需要跟踪应用调用之间的一些数据时,即使用户在首选项屏幕中看不到数据,首选项也是完成任务的一种方式。请查找名为 SavingStateDemo 的示例应用以及完整的源代码。

活动类有一个 getPreferences(int mode) 方法。实际上,这只是调用 getSharedPreferences() ,用活动的类名作为标签,加上传入的模式。结果是一个特定于活动的共享首选项文件,您可以用它来跨调用存储有关该活动的数据。清单 11-11 中显示了一个如何使用它的简单例子。

清单 11-11 。使用首选项保存活动的状态

    final String INITIALIZED = "initialized";
    private String someString;

[ ... ]

    SharedPreferences myPrefs = getPreferences(MODE_PRIVATE);

    boolean hasPreferences = myPrefs.getBoolean(INITIALIZED, false);
    if(hasPreferences) {
        Log.v("Preferences", "We've been called before");
        // Read other values as desired from preferences file...
        someString = myPrefs.getString("someString", "");
    }
    else {
        Log.v("Preferences", "First time ever being called");
        // Set up initial values for what will end up
        // in the preferences file
        someString = "some default value";
    }

[ ... ]

    // Later when ready to write out values
    Editor editor = myPrefs.edit();
    editor.putBoolean(INITIALIZED, true);
    editor.putString("someString", someString);
    // Write other values as desired
    editor.commit();

这段代码所做的是为我们的 activity 类获取一个对 preferences 的引用,并检查一个名为 initialized 的布尔“preference”是否存在。我们将“preference”写在双引号中,因为这个值不是用户将要看到或设置的;它只是一个值,我们希望存储在一个共享的首选项文件中,供下次使用。如果我们得到一个值,那么共享的首选项文件就存在,所以这个应用以前一定被调用过。然后,您可以从共享首选项文件中读取其他值。例如,someString 可以是一个活动变量,它应该在上次运行该活动时设置,或者如果这是第一次,则设置为默认值。

要将值写入共享首选项文件,您必须首先获得一个首选项编辑器。然后,您可以将值放入首选项,并在完成后提交这些更改。注意,在幕后,Android 正在管理一个真正共享的 SharedPreferences 对象。理想情况下,一次不会有超过一个编辑处于活动状态。但是调用 commit() 方法非常重要,这样才能更新 SharedPreferences 对象和共享首选项 XML 文件。在这个例子中, someString 的值被写出来,以便在下次运行这个活动时使用。

您可以随时访问、写入和提交值到您的首选项文件。可能的用途包括写出游戏的高分或记录应用最后一次运行的时间。您还可以使用不同名称的 getSharedPreferences() 调用来管理不同的首选项集,所有这些都在同一个应用甚至同一个活动中。

到目前为止,在我们的示例中,MODE_PRIVATE 用于模式。因为共享的首选项文件总是存储在您的应用的/数据/数据/{包} 目录中,因此其他应用无法访问,您只需使用模式 _ 私有。

使用对话框首选项

到目前为止,您已经看到了如何使用 preferences 框架的开箱即用功能,但是如果您想要创建一个定制的首选项呢?如果你想要一个类似于屏幕设置下亮度偏好设置的滑块的东西呢?这就是 DialogPreference 的用武之地。 DialogPreference 是 EditTextPreference 和 ListPreference 的父类。该行为是一个弹出的对话框,向用户显示选项,并通过按钮或后退按钮关闭。但是您可以扩展 DialogPreference 来设置您自己的自定义首选项。在您的扩展类中,您在 onDialogClosed() 中提供了自己的布局、自己的点击处理器和自定义代码,以便将您的首选项数据写入共享的首选项文件。

参考

以下是对您可能希望进一步探索的主题的有用参考:

摘要

本章讲述了在 Android 中管理偏好设置:

  • 可用的首选项类型
  • 将首选项的当前值读入您的应用
  • 从嵌入式代码中设置默认值,并将默认值从 XML 文件写入保存的首选项文件
  • 将首选项组织成组,并定义首选项之间的依赖关系
  • 对首选项进行回调,以验证更改并设置动态摘要文本
  • 使用首选项框架跨调用保存和恢复活动信息
  • 创建自定义首选项

十二、为旧设备使用兼容性库

自从几年前首次推出以来,Android 平台已经经历了令人印象深刻的演变。虽然 Android 的意图一直是支持许多不同类型的设备,但它从一开始就不是为了满足这个目标而设计的。取而代之的是,谷歌的工程师们增加、删除和改变了 API 以提供新的特性。最大的变化之一是创建片段,以便处理更大的屏幕尺寸,如平板电脑和电视。但是还有其他的变化,比如动作栏和菜单。

新的 API 给开发人员带来了一个难题,他们希望自己的应用可以在带有新 API 的新设备上运行,也可以在没有这些 API 的旧设备上运行。许多老款设备无法升级安卓系统。即使谷歌将新的 API 添加到旧的 Android 操作系统的版本中,旧的设备也不会得到新的版本,因为设备制造商和移动运营商都需要测试和支持。谷歌想出的解决方案是创建兼容库,可以链接到应用中,这样它就可以利用新的 API 功能,但仍然可以在旧版本的 Android 上运行。该库指出了如何使用旧的 API 来实现新的特性。如果相同的应用运行在已经有这些新特性的新版 Android 上,兼容性库调用新版 Android 中的底层 API。

本章将深入探讨兼容性库,并解释如何使用它们以及需要注意什么。如果你没有为旧版本的 Android 开发应用,你可以安全地跳过这一章,因为你不需要这些库。只有当你想在一个没有新 API 的旧版本 Android 上运行的应用中包含新 API 的功能时,这些库才是有用的。

这一切都始于平板电脑

在支持平板电脑之前,Android 操作系统运行良好。应用的基本构件是活动,意味着为用户执行一个单一的任务,并填满设备的屏幕。但是平板电脑提供了更多的空间,因此用户可以在一个屏幕上同时看到和做一些事情。于是有了蜂巢(Android 3.0),谷歌引入了片段。这是一个全新的概念,它改变了开发人员创建 ui 的方式以及 ui 背后的逻辑。这本来很好,除了仍然有大量的 Android 设备(例如,智能手机)不支持片段。Google 发现可以编写一个兼容性库来提供片段的类似实现,等等。,它使用旧版本 Android 中的现有 API。如果一个应用链接到兼容性库中,它就可以使用片段,即使旧版本的 Android 在操作系统中不支持片段。

然后,谷歌工程师研究了新 Android 中的其他功能和 API,并提供了兼容库功能和 API 来匹配,以便这些功能也可以用于旧版本的 Android,而不必发布旧版本 Android 的更新。除了对片段的支持,兼容性库还提供对加载器、RenderScript、ActionBar 等的支持。

兼容性库并不总是让新旧版本完全相同。例如,新的 Activity 类知道片段。要使用兼容库,必须扩展 fragmentation Activity 类,而不是 Activity;正是 fragmentation activity 类在旧的 Android 版本中处理片段。

当您使用兼容性库时,您将为您的应用使用这些类,而不管它将在哪个版本的 Android 上运行。换句话说,你只需要在你的应用中使用 fragmentation activity,它将在所有版本的 Android 中做正确的事情,包括 Android 3.0 和更高版本。你不会试图在同一个应用中同时包含安卓 3.0 以上版本的活动和安卓 3.0 以下版本的片段活动。当 fragmentation Activity 在 Android 3.0 和更高版本上执行时,它几乎可以直接调用底层的 Activity 类。在最近的 Android 版本上使用兼容性库并没有真正的损失。

将库添加到项目中

在撰写本文时,有四个兼容性库;该集合一起被称为 Android 支持库,修订版 22.1.1:

  • v4—包含 fragmentation activity, Fragment , Loader ,以及 Android 3.0 之后引入的相当多的其他类。数字 4 代表 Android API 版本 4(即 Donut 1.6)。这意味着这个库可以用于运行在 Android API 版本 4 及以上的应用。
  • V7—使 ActionBar 、CardView、GridLayout、MediaRouter 、Palette 和 RecyclerView 类可用。该库可用于 Android API 版本 7(即艾克蕾尔 2.1)及以上版本。这里实际上有六个库:appcompat、cardview、gridlayout、mediarouter、palette 和 recyclerview
  • V8—将 RenderScipt 功能添加到 Android API 版本 8(即 Froyo 2.2)及更高版本。RenderScript 允许跨设备处理器(CPU 内核、GPU、DSP)的工作并行化,并在 Android API 版本 11(即 Honeycomb 3.0)中引入。
  • V13——为选项卡式和页面式界面添加了一些特殊的片段功能。这个库还包含许多来自 v4 的类,因此它可以包含在您的应用中,而不需要其他库。
  • v17—添加了与 Android 电视应用相关的向后倾斜功能

有关按版本号列出的所有兼容性功能的完整列表,请参见本章末尾的参考资料。

要将 Android 支持库下载到您的计算机上,请使用 Android SDK 管理器,并在 Extras 下的列表底部找到它。如果你使用的是 Android Studio,下载 Android 支持库。否则,请下载 Android 支持库。这些文件将放在您的 Android SDK 目录下。android 支持库可以在 extras/android/support/ 中找到,Android 支持库可以在 extras/Android/m2 Repository 中找到。

正如你在前面的列表中看到的,并不是所有 Android 支持库的特性都可以在所有旧版本的 Android 上使用。因此,您必须在 AndroidManifest.xml 文件中正确设置 android:minSdkVersion 。如果你用的是 v7 的兼容库功能, android:minSdkVersion 应该不低于 7。

包括 v7 支持库

您很少会想要包含 v4 库而不包含 v7 库。因为 v7 库要求也包含 v4 库,以便为 v7 正常运行提供必要的类,所以您希望两者都包含。如果您使用的是 Eclipse,ADT 插件会让这一切变得非常简单。当您在 Eclipse 中创建新的 Android 项目时,您需要指定它将运行的 Android 的最低版本。如果 ADT 认为您可能希望包含兼容性库,它会自动包含它。

例如,如果您指定目标 SDK 为 16 (JellyBean 4.1),但最小 SDK 为 8 (Froyo 2.2),ADT 将自动设置一个 appcompat v7 库项目,将该库项目包括在您的新应用中,并且还将 v4 库包括在您的应用中。因此,v7 库中的资源可供您的应用使用,而无需您做额外的工作。然而,如果您想使用另外两个 v7 库(gridlayout 和/或 mediarouter)中的任何一个,就需要做一些额外的工作,下面将会解释。通过创建一个库项目并将其包含在您的应用中,它将包含您的应用将需要的兼容性库资源。

您将手动执行与 ADT 类似的操作,将 v7 appcompat 库自动包含到您的项目中。首先,您将选择文件image导入,然后将现有 Android 代码导入工作区,然后导航到工作站上 Android SDK 所在的 extras 文件夹。找到 v7 gridlayout 或 mediarouter 文件夹并选择它。参见图 12-1 。

9781430246800_Fig12-01.jpg

图 12-1 。导入 v7 mediarouter 兼容性库

单击 Finish,您将获得一个新的库项目。如果您选择为 v7 mediarouter 创建一个库项目,您会发现它缺少一些功能,因此有错误。您需要添加 v7 appcompat 库来消除这种情况。在 Eclipse 中右键单击 mediarouter 库项目,然后选择 Properties。在左边的列表中选择 Android。现在,单击库部分中的添加…按钮。参见图 12-2 。

9781430246800_Fig12-02.jpg

图 12-2 。将 appcompat_v7 添加到 v7 mediarouter 兼容性库中

选择 appcompat_v7 库,然后单击确定。这应该可以清除 mediarouter 中的错误。现在,当您想要将 mediarouter 包含在您的应用项目中时,只需遵循相同的过程,但右键单击您的应用项目,当您单击“库”的“添加…”按钮时,选择 mediarouter 库。

有了 Android Studio,添加 v7 兼容库也一样简单。默认情况下,如果您创建的新项目的最小 SDK 值小于您的目标 SDK,您很可能会自动添加 v7 appcompat 库。您可以通过在应用的 build.gradle 配置文件的 dependencies 部分中查找以下行来检查这一点:

编译' com . Android . support:app compat-V7:22 . 0 . 0 '

因此,要添加另一个 v7 库,需要在 dependencies 部分插入另一个类似的编译行,但是要使用适当的名称,比如 cardview 或 mediarouter。

包括 v8 支持库

如果您想要使用 v8 renderscript 兼容性库,并且您使用 Eclipse 进行开发,那么您只需将以下三行添加到应用项目的 project.properties 文件中,而不管您的应用的目标版本如何:

renderscript.target=22
renderscript.support.mode=true
sdk.buildtools=22.1.1

在撰写本文时,在线 Android 文档说您应该使用 18 的目标和 18.1.0 的构建工具。但是,使用旧值会产生一个错误,要求使用 buildtools 的新版本。如果您在 Eclipse 控制台中看到关于版本号的错误,请尝试使用错误所指示的更高版本。

如果您使用 Android Studio 进行开发,要包含 v8 renderscript,您需要编辑应用的 build.gradle 文件,并在 defaultConfig 部分添加以下行:

renderscriptTargetApi 22
renderscriptSupportModeEnabled true

在您的代码中,确保您从 Android . support . v8 . renderscript 而不是 android.renderscript 导入。如果您正在修改 V8 库的现有 render script 应用,请确保清理您的项目。从您的生成的 Java 文件。rs 文件需要重新生成才能使用 v8 库。您现在可以像往常一样使用 RenderScript,并将您的应用部署到旧版本的 Android 上。

包括 v13 支持库

要使用 Eclipse 将 v13 兼容性库包含到您的应用中,请导航到 SDK extras 目录并找到 v13 jar 文件。将该文件复制到应用项目的/libs 目录中。一旦 v13 jar 文件就位,右键单击它以弹出菜单,然后选择构建路径image Add to Build Path。由于 ADT 的帮助,您的应用中很可能已经有了 v4 和 v7 appcompat 库。如果您不需要其中任何一个的功能,您可以选择去掉它们。例如,如果您的应用的最低 SDK 是 v11,您可以使用本机 ActionBar 类,而不需要 v7 appcompat 支持库。

v13 jar 文件包含许多与 v4 相同的类,所以您不希望因为在两个版本中包含相同的类而导致任何问题。如果您要在应用中使用所有三个库(即 v4、v7 和 v13),那么至少要确保 v13 在 v4 之前排序。这可以在“配置构建路径”对话框中完成。

如果您使用的是 Android Studio,只需确保 SDK 管理器已经下载了支持库,然后将以下编译行添加到应用的 build.gradle 文件中,就像您对 v7 库所做的那样:

编译“com . Android . support:support-v 13:22 . 0 . 0”

包括 v17 支持库

最后,包含 v17 兼容性库的方式与包含 v13 支持库的方式相同。

仅包括 v4 支持库

如果您真的必须有 v4 支持库,而没有其他库,那么您将遵循与 v13 库相同的过程。

用 Android 支持库改造应用

为了更好地了解这一切是如何工作的,你将带回你在第八章中开发的一个片段应用,并使它适用于不支持片段的旧版本 Android。

使用文件image导入,选择常规,然后将现有项目导入工作区。从第八章的中导航到莎士比亚乐器项目并选择它。在点击“完成”之前,选中“将项目复制到工作区”。

现在,您将对这个应用进行改进,以便在低于 API 版本 11 的 Android 版本上工作。当您不需要兼容性库中的资源时,下面的代码可以工作,因为它只关心 JAR 文件中的复制。

  1. 右键单击您的项目并选择 Android 工具image添加支持库....接受许可,然后单击确定。
  2. 现在进入 MainActivity.java,将基类从活动更改为片段活动。您需要修复从 android.app.Activity 到 Android . support . v4 . app . fragmentation activity 的导入行。还要修复片段、片段管理器和片段事务的导入,以使用来自支持库的导入。
  3. 找到对 getFragmentManager() 的方法调用,并将其更改为 getSupportFragmentManager()。对 DetailsActivity.java 也这样做。
  4. 对于,将片段的导入改为支持库片段的导入(即 Android . support . v4 . app . Fragment)。
  5. 在 TitlesFragment.java,将 ListFragment 的导入改为支持库 ListFragment 的导入(即 Android . support . v4 . app . list fragment)。

新版本的 Android 使用与旧版本不同的动画制作人员。您可能需要修复 showDetails() 方法中 MainActivity.java 的动画。选择一个注释掉的调用 setCustomAnimations(),然后播放输入和输出动画。任何依赖于 ObjectAnimator 类的东西都不能在旧设备上工作,因为这个类是在 API 版本 11(即 Honeycomb 3.0)中引入的。它会编译,但是由于这个类没有在旧的 Android 中实现,也没有包含在兼容性库中,你会得到一个运行时异常。换句话说,避免使用 R.animator,尝试使用 R.anim。你可以把你想用的动画资源文件复制到你的项目中,或者你可以试着参考一下 android。R.anim 文件。

现在你可以进入 AndroidManifest.xml 并将 minSdkVersion 从 11 改为 8。那应该是你需要做的全部。尝试在 Froyo 设备或模拟器上运行此应用。如果一切顺利,您现在应该会看到一个基于片段的应用运行在 Android 3.0 之前的操作系统上。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

让我们通过快速列举你对 Android 兼容性库的了解来结束本章:

  • 要让您的应用在最广泛的设备上工作,请使用它们的 API 的兼容性库和代码,而不是最新和最好的 API。
  • v7 支持库附带了一些资源,这些资源必须包含在您的应用中,API 才能正常工作。

十三、探索包、进程、线程和处理器

在本书中,我们已经关注了如何为 Android 平台编程的要点。在这一章中,我们想深入探讨一下 Android 程序的进程和线程模型。这个讨论将引导我们对包进行签名,在包之间共享数据,使用编译时库,Android 组件的性质以及它们如何使用线程,最后是对处理器的需求以及如何编写处理器。

当你阅读这一章时,请记住“包”这个词已经用得太多了。有时它指的是 Java 语言包,有时它指的是 Android 应用部署为的 APK 文件。

了解包和过程

我们将从 Android 包和流程模型开始。当你在 Android 中开发一个应用时,你最终会得到一个。apk 文件。你签这个。apk 文件并将其部署到设备上。每个。apk 文件由一个惟一的 java 语言风格的包名惟一标识,如清单 13-1 中的清单文件所示。

清单 13-1 。在清单文件中提供包名

<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      package="com.androidbook.testapp"
      ...>
      ...rest of the xml nodes
</manifest>

如果您是这个包的开发者,那么一旦这个应用被部署,除了您之外,没有人能够更新它。Android 应用包名称是为您保留的。当你在不同的应用发行商那里注册你的应用时,就会发生这种捆绑。因此,选择这个 Android 应用包的名称非常类似于 Java 包的命名方式。这需要是世界上独一无二的。一旦发布了应用,就不能更改此包名称,因为它定义了应用的身份。

Android 使用包名作为运行这个包的组件的进程名。Android 还为这个进程分配了一个唯一的用户 id。这个用户 ID 实际上是底层 Linux 操作系统的用户 ID。由于此用户 ID 是在特定设备上安装时确定的,因此在安装应用的每个设备上都会有所不同。您可以通过 Android 模拟器中的开发人员工具查看已安装软件包的详细信息来发现这些信息。例如,已安装浏览器应用的包详细信息屏幕看起来像图 13-1 。(请注意,在不同的版本中,您查找的图像或工具可能会有所不同。图 13-1 中的图片取自 Android 模拟器上的开发者工具应用。)

9781430246800_Fig13-01.jpg

图 13-1 。安卓包详情

图 13-1 显示了由清单文件中的 Java 包名和分配给该包的唯一用户 ID 所指示的进程名。这个进程或包创建的任何资源都将在这个 Linux 用户 ID 下得到保护。该屏幕还列出了该包中的组件。组件的例子有活动、服务和广播接收器。请注意,此图像可能会因 Android 版本而异。通过设备或模拟器的设置,您还可以卸载软件包,以便可以删除它。

因为进程与包名相关联,而包名与其签名相关联,所以签名在保护属于包的数据方面起着一定的作用。包通常用自签名 PKI(公钥基础设施)证书签名。证书识别包的作者是谁。这些证书不需要由证书颁发机构颁发。这意味着证书中的信息未经任何权威机构批准或验证。这意味着人们可以创建一个证书,表明他们的名字是谷歌。唯一的保证是,如果之前没有人在市场上要求该包名称,则该包名称将保留给该用户,并且对该包的任何后续更新将仅提供给该用户(由该证书标识)。

通过该包安装或创建的所有资产都属于其 ID 被分配给该包的用户。如果您的意图是允许一组依赖于一组公共数据的协作应用,那么您可以选择显式地指定一个对您来说是唯一的并且对您的需求来说是公共的用户 ID。这个共享用户 ID 也在清单文件中定义,类似于包名的定义。清单 13-2 显示了一个例子。

清单 13-2 。共享用户 ID 声明

<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      package="com.androidbook.somepackage"
      sharedUserId="com.androidbook.mysharedusrid"
      ...
>
...the rest of the xml nodes
</manifest>

如果多个应用共享相同的签名(用相同的 PKI 证书签名),则它们可以指定相同的共享用户 ID。拥有一个共享的用户 ID 允许多个应用共享数据,甚至在同一个进程中运行。为了避免共享用户 id 的重复,请使用类似于命名 Java 类的约定。以下是 Android 系统中共享用户 id 的一些例子:

"android.uid.system"
"android.uid.phone"

注意共享 ID 必须指定为原始字符串,而不是字符串资源。

注意,如果您计划使用共享用户 id,建议从一开始就使用它们。否则,当您将应用从非共享用户 ID 升级到共享用户 ID 时,它们就不能很好地工作。其中一个被引用的原因是,由于用户 id 的改变,Android 将不会在旧资源上运行 chown 。

用于共享数据的代码模式

本节探讨两个应用通过使用共享用户 ID 来共享资源和数据的机会。在运行时,每个包的资源和数据由该包的上下文拥有和保护。您需要访问想要从中共享资源或数据的包的上下文。

您可以在任何现有的上下文对象(比如您的活动)上使用 createPackageContext()API 来获取对您想要与之交互的目标上下文的引用。清单 13-3 提供了一个例子。

清单 13-3 。使用 createPackageContext()API

//Use the appropriate try/catch to detect errors
//Identify package you want to use
String targetPackageName="com.androidbook.samplepackage1";

//Decide on an appropriate context flag
int flag=Context.CONTEXT_RESTRICTED;

//Get the target context through one of your activities
//Need to catch NameNotFoundException
Activity myContext = ......;
Context targetContext =
         myContext.createPackageContext(targetPackageName, flag);

//Use context to resolve file paths
Resources res = targetContext.getResources();
File path = targetContext.getFilesDir();

请注意我们如何能够获得对给定包名的上下文的引用,比如 com . androidbook . sample package 1。清单 13-3 中的目标上下文与应用启动时传递给目标应用的上下文相同。正如方法名所示(在其“create”前缀中),每个调用都返回一个新的上下文对象。然而,文档向我们保证,这个返回的上下文对象被设计成轻量级的,这意味着它不会消耗很多内存,并且被优化为引用目标包的资源、资产和代码。

无论两个上下文是否共享一个用户 ID,该 API 都适用。如果您共享用户 ID,那当然很好。如果不共享用户 ID,目标应用将需要声明外部用户可以访问它的资源。

CONTEXT_RESTRICTED 标志表示您感兴趣的只是加载资源和资产,而不是代码。因此,使用这个标志允许系统检测布局是否包含对回调代码的引用。回调的一个例子是布局中的一个按钮,该按钮引用一个将被调用的方法。此回调代码存在于源上下文中。因此,您可能希望系统抛出一个异常,这样您就可以检测到该情况或者忽略该特定的 XML 标记。本质上,您是在告诉系统,您在受限的意义上使用上下文,并且目标上下文可以基于该标志自由地做出合适的假设。底线似乎是,如果您不想使用来自目标上下文的代码,就使用这个标志。

CONTEXT_INCLUDE_ 代码 允许你在运行时将 Java 类从目标上下文加载到你的进程中,并调用该代码。文档表明,如果加载代码不安全,您可能会收到安全异常。但是,不清楚在什么情况下代码被认为是不安全的。一个有根据的猜测是,目标上下文没有与源上下文共享的用户 ID。您可以通过同时指定 CONTEXT_IGNOR_SECURITY 和 CONTEXT_INCLUDE_CODE 来克服这个限制。这两个标志一起始终将目标上下文代码加载到源上下文代码中,即使目标上下文属于不同的用户,也会被忽略。尽管代码是借用的,并在客户端进程中运行,但它对目标上下文数据没有权限。因此,请确保代码在您的数据上释放时会做什么。这种方法通常用于可以共享的工具代码。

了解图书馆项目

当我们谈论共享代码和资源时,一个值得问的问题是,“库”项目的想法有帮助吗?从 ADT 0.9.7 Eclipse 插件开始,Android 支持库项目的思想。从那时起,构建库的方法已经有了一些改变,而中心思想仍然保留在所有最近的版本中。

库项目是 Java 代码和资源的集合,看起来像一个常规的 Android 项目,但绝不会以结束。apk 文件本身。相反,一个库项目的代码和资源成为另一个项目的一部分,并被编译到主项目的中。apk 文件。由于库纯粹是一个编译时概念,每个开发工具可能会以不同的方式创建这个工具。

以下是关于这些图书馆项目的一些其他事实:

  • 一个库项目可以有自己的不同于主应用的包名。
  • 库项目可以使用其他 JAR 文件。
  • Eclipse ADT 将把库 Java 源文件编译成一个 JAR 文件,然后用应用项目编译这个 JAR 文件。
  • 除了 Java 文件(它变成了一个 jar 文件)之外,属于一个库项目的其余文件(比如资源)都保存在库项目中。为了编译包含库作为依赖项的应用项目,库项目是必需的。
  • 从 SDK Tools 15.0 开始,为库项目在其各自的 R.java 文件中生成的资源 id 不是最终的。(这将在本章后面解释。)
  • 库项目和主项目都可以通过各自的 R.java 文件访问库项目中的资源。这意味着 ID 名称是重复的,并且在两个 R.java 文件中都可用。
  • 如果您想区分两个项目(library 和 main)之间的资源 id,您可以使用不同的资源前缀,例如库项目资源的 lib_ 。
  • 主项目可以引用任意数量的库项目。
  • 需要在目标主项目清单文件中定义库的组件,如活动。完成后,库包中的组件名必须用库包名完全限定。
  • 没有必要在库清单文件中定义组件,尽管快速了解它支持哪些组件可能是一个好的做法。
  • 创建一个库项目首先要创建一个常规的 Android 项目,然后在其属性窗口中选择 Is Library 标志。
  • 您也可以通过项目属性屏幕设置主项目的从属库项目。
  • 显然,作为一个库项目,任何数量的主项目都可以包含一个库项目。
  • 从发行版(Android 4.4、API 19、SDK Tools 19、ADT 22.3)开始,一个库项目不能引用另一个库项目,尽管似乎希望在未来的发行版中能够这样做。

要创建一个库项目,首先要创建一个常规的 Android 项目。建立项目后,右键单击项目名称,然后单击属性上下文菜单,显示库项目的属性对话框。该对话框如图 13-2 中的所示。(此图中可用的构建目标可能因您的 Android SDK 版本而异。)只需从该对话框中选择 Is Library,将该项目设置为库项目。

9781430246800_Fig13-02.jpg

图 13-2 。将项目指定为库项目

你可以使用下面的项目属性对话框(见图 13-3 )来表明一个主项目依赖于先前创建的库项目。

9781430246800_Fig13-03.jpg

图 13-3 。声明库项目依赖关系

请注意对话框中的“添加”按钮。你可以用这个来添加图 13-3 中的库作为参考。你不需要做任何其他事情。

一旦库项目被设置为主应用项目的依赖项,库项目就会作为编译后的 JAR 文件出现在应用项目的节点 Android Dependencies 下。

Android 不会将库中的 R.class 文件打包到各自的 jar 文件中。相反,它依赖于源文件【R.java】,该文件被重新创建并在每个库的主应用项目中可用。这意味着在主项目的 gen 子目录中,每个库都有一个 R.java 文件。

为了避免库的编译源代码中出现硬编码的常量,Android 创建了库 R.java 文件,使得该文件中的所有常量都是非最终的。在主项目的最终编译过程中,会分配新的常数值,以便这些常数值在所有库和主项目中是唯一的。如果我们在库编译期间给定了最终的常量值,那么这些数字可能会在库之间发生冲突。给定名称集的唯一 id 分配必须一次性完成。一旦这些数字在主项目的编译期间被分配给 IDs,它们就可以成为该主项目中的最终数字。

库的 R.java 文件中的 id 不是最终的,这一事实暗示了这一点。通常使用开关语句来响应基于菜单项 ID 的菜单项。如果 id 不是最终的,那么在库代码中完成时,这个语言构造将在编译时失败。这是因为 switch 子句中的 case 语句必须是一个数字常量。

所以,清单 13-4 中的 switch 语句不会编译,除非 id(如 R.id.menu_item_1 )是实际的文字数字或静态的终结符。

清单 13-4 。示例 switch 语句演示非最终变量

switch(menuItem.getItemId()) {
   case R.id.menu_item_1:
        Statement1;
        break;
   case 0x7778888: // as an example for R.id.menu_item_2:
       statement;
       statement;
       break;
   default:
       statement;
       statement;
}

因为 id 被定义为非最终的库项目,我们被迫使用 if/else 语句,而不是 switch/case 子句。因为从库的 R.java 文件中重新创建的相同常量是最终的,所以你可以在你的最终项目中自由使用开关子句。

如您所见,库项目是编译时构造。显然,任何属于这个库的资源都会被吸收并合并到主项目中。不存在运行时共享的问题,因为只有一个包文件与主包同名。简而言之,库提供了一种在编译时在相关项目之间共享资源的方式。

了解组件和线程

我们从这一章开始,确定每个包在它自己的进程中运行。我们现在将解释这个过程中线程的组织。这将引导我们为什么我们需要处理器来卸载主线程的工作,并与主线程通信。

Android 应用中的大多数代码都运行在组件的上下文中,比如一个活动或一个服务。大多数时候,一个 Android 进程中只有一个线程在运行,称为主线程。我们将讨论在不同组件之间共享这个主线的含义。首先,这可能导致应用不响应(ANR)消息(“A”代表“应用”,而不是“烦人”)。我们将向您展示当需要长时间运行的操作时,如何使用处理器、消息和线程来打破对主线程的依赖。

Android 流程有四种主要的组件类型:活动、服务、内容提供者和广播接收者。您在 Android 应用中编写的大多数代码都是这些组件之一的一部分,或者由这些组件之一调用。在 Android 项目清单文件中的应用节点规范下,每个组件都有自己的 XML 节点。回想一下,清单 13-5 中的这些节点是:

清单 13-5 。如何在清单文件中声明组件

<manifest...>
  <application>
     <activity/>
     <service/>
     <receiver/>
     <provider/>
  </application>
</manifest>

除了一些例外(比如对内容提供者的外部进程调用),Android 使用相同的线程来处理(或运行)这些组件中的代码。这个线程被称为应用的主线程。当调用这些组件时,调用可以是同步调用,例如当您调用内容提供者获取数据时,也可以是通过消息队列的延迟调用,例如当您通过调用启动服务或显示对话框来调用功能时。

图 13-4 描述了螺纹和这四个部件之间的关系。这张图展示了线程是如何在 Android 框架及其组件中交织的。该图没有指出线可能穿过各种部件的顺序。该图仅仅示出了处理以连续的方式从一个组件继续到另一个组件。

9781430246800_Fig13-04.jpg

图 13-4 。 Android 组件和线程框架

如图 13-4 所示,主螺纹做重提升。它通过使用消息队列贯穿所有组件。当您在设备屏幕上选择菜单或按钮时,设备会将这些操作转换为消息,并将其放在处于焦点的流程的主队列中。主线程位于一个循环中,处理每条消息。如果任何消息超过 5 秒左右,Android 就会抛出一条 ANR 消息。

同样,在响应菜单项时,如果您要调用广播消息,Android 会再次在包进程的主队列中删除一条消息,注册的接收者将从该包进程中被调用。主线程将在以后调用该消息来调用接收方。主线程也为广播接收器工作。如果主线程忙于响应菜单动作,广播接收器将不得不等待,直到主线程被释放。

服务也是如此。当您从一个菜单项用 activity . start service 启动一个本地服务时,一条消息被放到主队列中,主线程将通过服务代码来处理它。

对本地内容供应器的调用略有不同。对于本地调用,内容提供者仍然在主线程上运行,但是对它的调用是同步的,并且不使用消息队列。

你可能会问,“为什么 Android 应用中的大部分代码运行在主线程或其他线程上很重要?”这很重要,因为主线程有责任回到它的队列,以便 UI 事件得到响应。因此,您不应该阻塞主线程。如果有些事情要花五秒以上的时间,你应该在一个单独的线程中完成它,或者通过请求主线程在它从其他处理中释放出来时返回来推迟它。

当流程之外的外部客户端或组件调用内容提供者获取数据时,会从线程池中为该调用分配一个线程。连接到服务的外部客户端也是如此。

让我们在下一节看看什么是处理器以及它们是如何工作的。

了解处理器

我们简单地提到了如果需要的话,推迟一个主线程的工作的想法。这是通过处理器完成的。处理器在 Android 中被广泛使用,所以主 UI 线程不会被阻塞。它们还在与其他派生工作线程中的主线程进行通信时发挥作用。

处理器是一种在主队列(更准确地说,是附加到处理器在其上被实例化的线程的队列)上丢弃消息的机制,以便该消息可以在稍后的时间点被循环线程处理。被丢弃的消息有一个指向丢弃它的处理器的内部引用。

当主线程开始处理该消息时,它通过 handler 对象上的回调方法调用丢弃该消息的处理器。这个回调方法叫做 handleMessage 。图 13-5 展示了处理器、消息和主线程之间的关系。

9781430246800_Fig13-05.jpg

图 13-5 。处理器、消息、消息队列关系

图 13-5 展示了当我们谈论处理器时,一起工作的关键角色:主线程、主线程队列、处理器和消息。在这四种方法中,我们没有直接暴露给主线程或队列。我们主要处理 h 和 l 对象和 m 消息对象。即使在这两者之间,h 和 ler 对象协调大部分工作。

虽然处理器允许我们将消息放到队列中,但实际上是消息对象保存了对处理器的引用。m 消息对象也保存了一个可以传递回处理器的数据结构。

通过一个例子可以更好地理解处理器和消息。例如,我们将有一个调用一个函数的菜单项,该函数依次以一秒的间隔执行一个动作五次,并且每次都向调用活动报告。

如果我们不介意阻塞主线程,我们可以像清单 13-6 中的伪代码一样编写这个场景。

清单 13-6 。 用睡眠法压着主线程

public class SomeActivity  {
    ....other methods
    void respondToMenuItem()    {
        //Prove that we are on the main thread
        Utils.logThreadSignature();
        //simulate an operation that takes longer than 5 seconds
        for (int i=0;i<6;i++)      {
            sleepFor(1000);// put main thread to sleep for 1 sec
            dosomething();
            SomeTextView.setText("did something. Counter:" + Integer.toString(i));
         }
   }
}

这将满足用例的需求。然而,如果我们这样做了,我们就抓住了主线,我们保证会有一个 ANR。我们可以使用一个处理器来避免前面例子中的 ANR。通过一个处理器来做这件事的伪代码将看起来像清单 13-7 中的。

清单 13-7 。从主线程实例化一个处理器

void respondToMenuItem(){
    SomeHandlerDerivedFromHandler myHandler =
                 new SomeHandlerDerivedFromHandler();
    myHandler.doDeferredWork(); //invoke a function in 1 sec intervals
    //note that doDeferredWork() is not part of the SDK
    //we will show you the code for this shortly
}

现在,调用 respontomenuitem()将允许主线程返回到它的循环中。实例化的处理器知道它是在主线程上被调用的,并将自己挂接到队列上。方法 doDeferredWork() 将调度工作,以便主线程一旦空闲就可以回到这个工作。

为了研究这个协议,让我们来看看正确的处理器的实际源代码。下一节清单 13-8 中的代码演示了这个处理器,它被称为 DeferWorkHandler 。在前面清单 13-7 的伪代码中,指示的处理器 SomeHandlerDerivedFromHandler 相当于这个 DeferWorkHandler 。类似地,在清单 13-8 中的 DeferWorkHandler 上实现了(清单 13-7 中的 doDeferredWork() )所示的方法。

清单 13-8 。DeferWorkHandler 源代码

public class DeferWorkHandler extends Handler  {
    //Keep track of how many times we sent the message
    private int count = 0;

    //A parent driver activity we can use to inform of status.
    private TestHandlersDriverActivity parentActivity = null;

    //During construction we take in the parent driver activity.
    public DeferWorkHandler(TestHandlersDriverActivity inParentActivity){
        parentActivity = inParentActivity;
    }
    //Callback method that gets called by the main thread
    @Override
    public void handleMessage(Message msg)     {
        //Use the message object to get to its data
        String pm = new String("message called:" + count + ":" +
                msg.getData().getString("message"));
        //you can access the parent activity and invoke UI calls on it here
        parentActivity.someControl.somemethod(); //example only

        //logic to invoke itself multiple times if needed
        if (count > 5)      {
            return;
        }
        count++; //increment count
        sendTestMessage(1); //reinvoke again by sending a message
    }
    //method called by the client
    public void doDeferredWork()    {
        count = 0;
        sendTestMessage(1);
    }
    //Preparing and sending the message
    public void sendTestMessage(long interval)    {
        Message m = this.obtainMessage();
        prepareMessage(m);
        this.sendMessageDelayed(m, interval * 1000);
    }
    public void prepareMessage(Message m)    {
        Bundle b = new Bundle();
        b.putString("message", "Hello World");
        m.setData(b);
        return ;
    }
}

让我们来看看这段源代码的关键方面。第一个是处理器是从基类 h 和 ler 派生的。在处理器的构造函数中,我们使用一个指向父活动的指针,这样我们就可以使用活动的 UI 控件来报告需要报告的内容或需要采取的行动。然后我们编写一个方法( doDeferredWork )来封装这个处理器要为我们做什么。请注意, doDeferredWork ()不是一个被覆盖的方法,您可以随意调用这个方法。正是在这种方法中,您使用消息来最终调用被覆盖的 handleMessage ()。此外,正是在这个 handleMessage ()中,您实际上放置了最初从主线程推迟的真正代码。

基本处理器提供了一系列方法来将消息发送到队列,以便稍后进行响应。这些方法在 doDeferredWork ()中使用。 sendMessage() 和 sendMessageDelayed() 是这些发送方法的两个例子。我们在示例中使用的 sendMessageDelayed() ,允许我们以给定的时间延迟在主队列中丢弃一条消息。相反,当主线程找到消息时,sendMessage() 会立即丢弃该消息进行处理。

当您调用 sendMessage() 或 sendMessageDelayed() 时,您将需要 mmessage 对象的一个实例。最好是你让处理器给你,因为当处理器返回 m 消息对象时,它把自己藏在 m 消息的肚子里。这样,当主线程出现时,它就知道只根据消息调用哪个处理器。在清单 13-8 中,使用以下代码获得消息:

Message m = this.obtainMessage();

这个引用的变量是 handler 对象实例。顾名思义,该方法不创建新消息,而是从全局消息池中获取一个消息。稍后,一旦该消息被处理,它将被回收。方法获得消息() 的变化如清单 13-9 所示。

清单 13-9 。通过处理器构造消息

obtainMessage();
obtainMessage(int what);
obtainMessage(int what, Object object);
obtainMessage(int what, int arg1, int arg2)
obtainMessage(int what, int arg1, int arg2, Object object);

每个方法变体都在消息对象上设置相应的字段。当消息跨越流程边界时,对 object 参数有一些限制。在这种情况下,需要打包。在这种情况下,在消息对象上显式使用 setData() 方法要安全得多,也更兼容,该方法需要一个包。在清单 13-8 中,我们使用了 setData() 。如果您打算传递的是可以用整数值容纳的简单指示符,鼓励您使用 arg1 或 arg2 来代替。

参数 what (在清单 13-9 中)允许您将消息出队或查询队列中是否有这种类型的消息。详见 h 和 ler 类的操作。

一旦我们从处理器获得消息,我们就可以有选择地修改该消息的数据内容。在我们的例子中,我们使用了 setData() 函数,将它传递给一个 Bundle 对象。在我们对消息的数据进行分类或识别后,我们可以通过 sendMessage() 或 sendMessageDelayed() 将消息发送到队列中。当调用这些方法时,主线程将返回到队列中。

一旦消息被发送到队列,处理器就会等待(形象地说)直到主线程检索到这些消息并调用处理器的 handleMessage() 。

如果您想更清楚地看到这个处理器和主线程的交互,您可以在发送消息时和在 handleMessage() 回调中编写一个 logcat 消息。您会注意到时间戳有所不同,因为主线程会多花几毫秒的时间返回到 handleMessage() 方法。

在我们的示例中,每个 handleMessage() 在处理完一条消息后,将另一条消息发送到队列,以便可以再次调用它。它这样做五次,当计数器达到 5 时,它停止向队列发送消息。这是将工作分成多个块的一种方法,尽管有更好的方法来完成这个任务,要么通过一个工人线程,要么通过一个类 AsyncTask 。基本的 异步任务将在下一章讨论。现在让我们简要地介绍一下显式工作线程选项。

使用工作线程

当我们使用上一节中的处理器时,代码仍然在主线程上执行。对 handleMessage() 的每个调用仍然应该在主线程规定的时间内返回(换句话说,每个消息调用应该在五秒钟内完成,以避免应用不响应)。如果您的目标是进一步延长执行时间,您将需要启动一个单独的线程,保持该线程运行,直到它完成工作,并允许该子线程向运行在主线程上的主活动报告。这种类型的子线程通常称为工作线程。

在响应菜单项时启动一个单独的线程是很容易的。然而,巧妙的方法是允许工作线程向主线程的队列发送一条消息,告知正在发生某件事情,并且主线程应该在到达该消息时查看该消息。在非 UI 线程上调用 UI 方法也是错误的。因此,您将需要这个绑定到主线程的处理器来从工作线程调用 UI 方法。

涉及工作线程的合理解决方案如下:

  1. 响应菜单项时,在主线程中创建一个处理器。放在一边。
  2. 创建一个单独的线程(工作线程)来完成实际的工作。将步骤 1 中的处理器传递给工作线程。该处理器允许工作线程与主线程通信。
  3. 工作线程代码现在可以在超过五秒的时间内完成实际工作,并且在完成工作的同时,可以调用处理器来发送状态消息,以便与主线程进行通信。
  4. 这些状态消息现在由主线程处理,因为处理器属于主线程。主线程可以在工作线程工作的同时处理这些消息。

您可以在本章的可下载项目中看到这个交互的示例代码。从工作线程与 UI 线程通信的另一种可能更直接的方式是获取活动指针并调用方法 activity . runonuithread(Runnable action)。当然,您需要创建一个可运行的对象来进行协调。

参考

以下是一些有用的链接,可以进一步加深你对本章的理解:

摘要

本章简要介绍了 Android 应用中的包、进程、组件和线程是如何交互的。本章还记录了库对在多个应用之间共享资源的支持。本章还介绍了处理器,这是 Android SDK 中的一个关键概念。在下一章中,我们将详细介绍 AsyncTask ,它将工作线程和处理器组合成一个更简单的编程抽象来使用。

十四、构建和消费服务

Android 平台提供了完整的软件堆栈。这意味着你得到一个操作系统和中间件,以及工作应用(如电话拨号器)。除此之外,您还有一个 SDK,可以用来为该平台编写应用。到目前为止,我们已经看到,我们可以构建通过用户界面直接与用户交互的应用。然而,我们还没有讨论后台服务或者构建在后台运行的组件的可能性。

在这一章中,我们将关注在 Android 中构建和消费服务。首先我们将讨论使用 HTTP 服务,然后我们将介绍一种完成简单后台任务的好方法,最后我们将讨论进程间通信——即同一设备上的应用之间的通信。

消费 HTTP 服务

Android 应用和移动应用通常都是具有大量功能的小应用。移动应用在如此小的设备上提供如此丰富的功能的方式之一是它们从各种来源获取信息。例如,大多数 Android 智能手机都带有地图应用,它提供了复杂的地图功能。然而,我们知道该应用集成了 Google Maps API 和其他服务,提供了大部分的复杂性。

也就是说,您编写的应用很可能也会利用来自其他应用和 API 的信息。一种常见的集成策略是使用 HTTP。例如,您可能在互联网上有一个 Java servlet,它提供了您希望从一个 Android 应用中利用的服务。你如何用 Android 做到这一点?有趣的是,Android SDK 附带了 Apache 的 http client(【http://hc.apache.org/httpcomponents-client-ga/】)的一个变种,这个版本被普遍使用。Android 版本已经针对 Android 进行了修改,但是 API 与 Apache 版本中的 API 非常相似。

Apache HttpClient 是一个全面的 HTTP 客户端。它提供了对 HTTP 协议的全面支持。在这一节中,我们将讨论使用 HttpClient 来进行 HTTP GET 和 HTTP POST 调用。如果您正在使用 RESTful 服务,您可能还会使用其他 HTTP 操作(PUT、DELETE 等。).

对 HTTP GET 请求使用 HttpClient

下面是使用 HttpClient 的一般模式之一:

  1. 创建一个 HttpClient (或者获取一个现有的引用)。
  2. 实例化一个新的 HTTP 方法,比如 PostMethod 或者 GetMethod 。
  3. 设置 HTTP 参数名称/值。
  4. 使用 HttpClient 执行 HTTP 调用。
  5. 处理 HTTP 响应。

清单 14-1 展示了如何使用 HttpClient 执行 HTTP GET。

注意我们在本章末尾给了你一个 URL,你可以从本章下载项目。这将允许您将这些项目直接导入到 IDE 中。此外,因为代码试图使用互联网,当使用 HttpClient 进行 HTTP 调用时,您需要将 Android . permission . Internet 添加到您的清单文件中。

还要注意,在下面的例子中,所有的 web 服务调用都应该放在后台线程中,以免阻塞主 UI 线程。参见本章后面的内容,以及第十五章的,了解如何做到这一点。出于本章的目的,排除这些细节是为了帮助理解服务。

清单 14-1使用 HttpClient 和 http get:

public class HttpGetDemo extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        BufferedReader in = null;
        try {

            HttpClient client = new DefaultHttpClient();
            HttpGet request = new HttpGet("[`code.google.com/android/`](http://code.google.com/android/)");
            HttpResponse response = client.execute(request);

            in = new BufferedReader(
                    new InputStreamReader(
                        response.getEntity().getContent()));

            StringBuffer sb = new StringBuffer("");
            String line = "";
            String NL = System.getProperty("line.separator");
            while ((line = in.readLine()) != null) {
                sb.append(line + NL);
            }
            in.close();

            String page = sb.toString();
            System.out.println(page);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

HttpClient 能够使用各种 HTTP 请求类型,比如 HttpGet 、 HttpPost 等等。清单 14-1 使用 HttpClient 获取code.google.com/android/URL 的内容。实际的 HTTP 请求通过调用 client.execute() 来执行。执行请求后,代码将整个响应读入一个 string 对象。注意, BufferedReader 在 finally 块中是关闭的,这也关闭了底层的 HTTP 连接。

对于我们的例子,我们将 HTTP 逻辑嵌入到活动中,但是我们不需要在活动的上下文中使用 HttpClient 。您可以在任何 Android 组件的上下文中使用它,或者将其作为独立类的一部分使用。事实上,您不应该在活动中直接使用 HttpClient,因为 web 调用可能需要一段时间才能完成,并导致应用没有响应(ANR) 弹出窗口。我们将在本章的后面讨论这个话题。现在我们要稍微作弊一下,这样我们就可以专注于如何进行 HttpClient 调用。

清单 14-1 中的代码执行一个 HTTP 请求,而不向服务器传递任何 HTTP 参数。通过将名称/值对附加到 URL,您可以将名称/值参数作为请求的一部分传递,如清单 14-2 所示。

清单 14-2 参数 添加到 HTTP GET 请求

HttpGet request =
    new HttpGet("[`somehost/Upload.aspx?one=value1&two=value2`](http://somehost/Upload.aspx?one=value1&two=value2)");
client.execute(request);

当执行 HTTP GET 时,请求的参数(名称和值)作为 URL 的一部分传递。以这种方式传递参数有一些限制。也就是说,URL 的长度应该保持在 2048 个字符以下。如果要提交的数据超过这个数量,应该使用 HTTP POST。POST 方法更加灵活,它将参数作为请求体的一部分传递。

将 HttpClient 用于 HTTP POST 请求(一个多部分示例)

进行 HTTP POST 调用与进行 HTTP GET 调用非常相似(参见清单 14-3 )。这个例子叫做 SimpleHTTPPost。

清单 14-3用 HttpClient 制作一个 HTTP POST 请求

HttpClient client = new DefaultHttpClient();
HttpPost request = new HttpPost(
        "[`www.androidbook.com/akc/display`](http://www.androidbook.com/akc/display)");
List<NameValuePair> postParameters = new ArrayList<NameValuePair>();
postParameters.add(new BasicNameValuePair("url", "DisplayNoteIMPURL"));
postParameters.add(new BasicNameValuePair("reportId", "4788"));
postParameters.add(new BasicNameValuePair("ownerUserId", "android"));
postParameters.add(new BasicNameValuePair("aspire_output_format", "embedded-xml"));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(
        postParameters);
request.setEntity(formEntity);
HttpResponse response = client.execute(request);

清单 14-3 中的代码将替换清单 14-1 中的三行代码,其中使用了 HttpGet 。其他一切都可以保持不变。要使用 HttpClient 进行 HTTP POST 调用,必须使用 HttpPost 的实例调用 HttpClient 的 execute() 方法。当进行 HTTP POST 调用时,通常将 URL 编码的名称/值表单参数作为 HTTP 请求的一部分进行传递。要使用 HttpClient 来实现这一点,您必须创建一个包含 NameValuePair 对象实例的列表,然后用一个 urlencodeformentity 对象包装该列表。 NameValuePair 包装了一个名称/值组合,UrlEncodedFormEntity 类知道如何编码一个适合 HTTP 调用(一般是 POST 调用)的 NameValuePair 对象列表。在您创建了一个 urlencodeformentity 之后,您可以将 HttpPost 的实体类型设置为 urlencodeformentity 然后执行请求。

在清单 14-3 中,我们创建了一个 HttpClient ,然后用 HTTP 端点的 URL 实例化了 HttpPost 。接下来,我们创建了一个由 NameValuePair 对象组成的列表,并用几个名称/值参数填充它。然后,我们创建了一个 urlencodeformentity 实例,将 NameValuePair 对象的列表传递给它的构造函数。最后,我们调用 POST 请求的 setEntity() 方法 ,然后使用 HttpClient 实例执行请求。

HTTP POST 其实比这个厉害多了。通过 HTTP POST,我们可以传递简单的名称/值参数,如清单 14-3 所示,也可以传递复杂的参数,如文件。HTTP POST 支持另一种称为多部分 POST 的请求正文格式。使用这种类型的 POST,您可以像以前一样发送名称/值参数以及任意文件。不幸的是,Android 自带的 HttpClient 版本不直接支持多部分 POST。为了在过去实现这个目标,我们建议您获取另外三个库:Apache Commons IO、Mime4j 和 HttpMime。

现在我们建议您下载 Ion 库,它有两个依赖项。这三个 jar 文件都可以在以下两个站点找到:

清单 14-4 展示了一个使用 Android 的多部分帖子。这个例子叫做 MultipartHTTPPost。

清单 14-4制作一个 多部分帖子调用

public class TestMultipartPost extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        try {
            Ion.with(this, "[`www.androidbook.com/akc/update/PublicUploadTest`](http://www.androidbook.com/akc/update/PublicUploadTest)")
            .setMultipartParameter("field1", "This is field number 1")
            .setMultipartParameter("field2", "Field 2 is shorter")
            .setMultipartFile("datafile",
                    new File(Environment.getExternalStorageDirectory()+"/testfile.txt"))
            .asString()
            .setCallback(new FutureCallback<String>() {
                   @Override
                    public void onCompleted(Exception e, String result) {
                       System.out.println(result);
                    }});

        } catch(Exception e) {
            // Do something about exceptions
            System.out.println("Got exception: " + e);
        }
    }
}

注意多部分示例使用了几个。不包含在 Android 运行时中的 jar 文件。确保。jar 文件将被打包成您的的一部分。apk 文件,你需要将它们添加为外部。Eclipse 中的 jar 文件。为此,在 Eclipse 中右键单击您的项目,选择 Properties,选择 Java Build Path,选择 Libraries 选项卡,然后选择 Add External JARs。

遵循这些步骤将使。jar 文件在编译时和运行时都可用。

要使用 Ion 库执行多部分 POST,只需将适当的调用放在一起构建 URL、添加参数、定义返回类型并设置回调方法。这将异步运行,一旦从 web 服务器收到响应,将在 UI 线程上调用回调。在该示例中,结果字符串被写入 LogCat。您的应用可能会接收回一个 JsonObject,然后回调函数会对其进行处理。但是要意识到,来自 web 服务器的响应已经被转换为 JsonObject,这使得回调中的处理变得更加容易。清单 14-4 给请求添加了三个部分:两个字符串部分和一个文本文件。要自己运行这个示例,您需要将 testfile.txt 文件放到设备或仿真器的外部存储区域。

最后,如果您正在构建一个需要向 web 资源传递多部分 POST 的应用,您可能需要在本地工作站上使用服务的虚拟实现来调试解决方案。当您在本地工作站上运行应用时,通常您可以通过使用 localhost 或 IP 地址 127.0.0.1 来访问本地机器。然而,对于 Android 应用,你将无法使用 localhost (或 127.0.0.1 ),因为设备或仿真器将是它自己的 localhost 。您不想将此客户端指向 Android 设备上的服务;你想指向你的工作站。要从设备或仿真器中运行的应用引用您的开发工作站,您必须在 URL 中使用工作站的 IP 地址。

SOAP、JSON 和 XML 解析器

肥皂呢?互联网上有很多基于 SOAP 的 web 服务,但是到目前为止,Google 还没有在 Android 中提供对调用 SOAP web 服务的直接支持。相反,谷歌更喜欢类似 REST 的网络服务,似乎是为了减少客户端设备所需的计算量。然而,代价是开发人员必须做更多的工作来发送数据和解析返回的数据。理想情况下,对于如何与 web 服务交互,您将有一些选择。一些开发人员已经使用 kSOAP2 开发工具包来为 Android 构建 SOAP 客户端。我们不会讨论这种方法,但是如果您感兴趣,它就在那里。

原 kSOAP2 源位于此:。开源社区已经(谢天谢地!)贡献了一个安卓版的 kSOAP2,可以在这里了解更多:code.google.com/p/ksoap2-android/

一种已经成功使用的方法是在互联网上实现您自己的服务,它可以与目的地服务进行 SOAP(或其他)对话。然后你的 Android 应用只需要和你的服务对话,你现在就有了完全的控制权。如果目标服务发生了变化,您也许能够处理它,而不必更新和发布应用的新版本。你只需要更新服务器上的服务。这种方法的另一个好处是,您可以更容易地为您的应用实现付费订阅模型。如果用户让他们的订阅失效,您可以在您的服务器上关闭它们。

Android是否支持 JavaScript 对象符号(JSON) 。这是在 web 服务器和客户端之间打包数据的一种相当常见的方法。JSON 解析类使得从响应中解包数据变得非常容易,因此您的应用可以对其进行操作。或者更深入地研究本章前面提到的 Gson 包。Gson 是 Google 的一个 JSON Java 库,它的主要好处是很容易将 JSON 输入解析成 Java 对象,反之亦然。也很快。

*Android 也有一些 XML 解析器,可以用来解释 HTTP 调用的响应;推荐的是 XMLPullParser 。

处理异常

处理异常是任何程序的一部分,但是使用外部服务(比如 HTTP 服务)的软件必须额外注意异常,因为出错的可能性被放大了。在使用 HTTP 服务时,您可能会遇到几种类型的异常。这些是传输异常、协议异常和超时。您应该了解这些异常可能发生的时间。

传输异常可能因多种原因而发生,但移动设备最有可能出现的情况是网络连接不良。协议异常(例如,ClientProtocolException)是 HTTP 协议层的异常。这些错误包括身份验证错误、无效的 cookies 等等。例如,如果您必须提供登录凭证作为 HTTP 请求的一部分,但却没有这样做,那么您可能会看到协议异常。关于 HTTP 调用的超时,有两种类型:连接超时和套接字超时。如果 HttpClient 无法连接到 HTTP 服务器,例如,如果服务器不可用,则可能发生连接超时(例如,ConnectTimeoutException)。如果 HttpClient 未能在定义的时间段内接收到响应,则会发生套接字超时(例如 SocketTimeoutException)。换句话说, HttpClient 能够连接到服务器,但是服务器无法在分配的时间限制内返回响应。

现在您已经了解了可能发生的异常类型,那么您如何处理它们呢?幸运的是, HttpClient 是一个健壮的框架,可以帮您卸下大部分负担。事实上,您唯一需要担心的异常类型是那些您能够轻松管理的异常类型。 HttpClient 通过检测传输问题和重试请求来处理传输异常(这对于这种类型的异常非常有效)。协议异常是通常可以在开发过程中清除的异常。超时是您必须处理的最有可能的异常。处理这两种超时(连接超时和套接字超时)的一种简单而有效的方法是用一个 try / catch 包装 HTTP 请求的 execute() 方法,然后在失败时重试。

当使用 HttpClient 作为现实世界应用的一部分时,您需要注意可能出现的多线程问题。现在就来深究这些吧。

解决多线程问题

到目前为止,我们展示的例子为每个请求创建了一个新的 HttpClient 。然而,实际上,您可以为整个应用创建一个 HttpClient ,并将其用于所有的 HTTP 通信。可以将连接池与这个 HttpClient 相关联,您现在将看到这一点。用一个 HttpClient 服务所有的 HTTP 请求,您应该注意多线程问题,如果您通过同一个 HttpClient 同时发出请求,可能会出现多线程问题。幸运的是, HttpClient 提供了使这变得容易的工具——您所要做的就是使用 ThreadSafeClientConnManager 创建 DefaultHttpClient ,如清单 14-5 所示。这个示例项目是 HttpSingleton。

清单 14-5为多线程创建一个 http client:CustomHttpClient.java

public class CustomHttpClient {
    private static HttpClient customHttpClient;

    /** A private Constructor prevents instantiation */
    private CustomHttpClient() {
    }

    public static synchronized HttpClient getHttpClient() {
        if (customHttpClient == null) {
            HttpParams params = new BasicHttpParams();
            HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
            HttpProtocolParams.setContentCharset(params,
                    HTTP.DEFAULT_CONTENT_CHARSET);
            HttpProtocolParams.setUseExpectContinue(params, true);
            HttpProtocolParams.setUserAgent(params,
                    System.getProperty("http.agent")
                    // Could also have used the following which is browser-oriented as opposed to
                    // device-oriented:
                    // new WebView(getApplicationContext()).getSettings().getUserAgentString()
            );

            ConnManagerParams.setTimeout(params, 1000);

            HttpConnectionParams.setConnectionTimeout(params, 5000);
            HttpConnectionParams.setSoTimeout(params, 10000);

            SchemeRegistry schReg = new SchemeRegistry();
            schReg.register(new Scheme("http",
                            PlainSocketFactory.getSocketFactory(), 80));
            schReg.register(new Scheme("https",
                            SSLSocketFactory.getSocketFactory(), 443));
            ClientConnectionManager conMgr = new
                            ThreadSafeClientConnManager(params,schReg);

            customHttpClient = new DefaultHttpClient(conMgr, params);
        }
        return customHttpClient;
    }

    public Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException();
    }
}

如果您的应用需要进行多次 HTTP 调用,那么您应该创建一个 HttpClient 来服务您所有的 HTTP 请求。最简单的方法是创建一个可以从应用的任何地方访问的单例类,就像我们在这里展示的那样。这是一个相当标准的 Java 模式,在这种模式中,我们同步对 getter 方法的访问,getter 方法为单例对象返回唯一的 HttpClient 对象,在必要时第一次创建它。

现在,看看 CustomHttpClient 的 getHttpClient() 方法 。这个方法负责创建我们的单体 HttpClient 。我们设置一些基本参数,一些超时值,以及我们的 HttpClient 将支持的方案(即 HTTP 和 HTTPS)。注意,当我们实例化 DefaultHttpClient() 时,我们传入了一个 ClientConnectionManager。ClientConnectionManager 负责管理 HttpClient 的 HTTP 连接。因为我们想对所有 HTTP 请求使用一个单独的 HttpClient (如果我们使用线程,请求可能会重叠),所以我们创建了一个 ThreadSafeClientConnManager。

我们还向您展示了一种从 HTTP 请求中收集响应的更简单的方法,使用一个 BasicResponseHandler 。使用我们的 CustomHttpClient 的活动代码在清单 14-6 中。

清单 14-6使用我们的 custom http client:HttpActivity.java

public class HttpActivity extends Activity
{
    private HttpClient httpClient;
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        httpClient = CustomHttpClient.getHttpClient();
        getHttpContent();
    }

    public void getHttpContent()
    {
        try {
            HttpGet request = new HttpGet("[`www.google.com/`](http://www.google.com/)");
            String page = httpClient.execute(request,
                    new BasicResponseHandler());
            System.out.println(page);
        } catch (IOException e) {
            // covers:
            //      ClientProtocolException
            //      ConnectTimeoutException
            //      ConnectionPoolTimeoutException
            //      SocketTimeoutException
            e.printStackTrace();
        }
    }
}

对于这个示例应用,我们对 Google 主页进行了简单的 HTTP get。我们还使用一个 BasicResponseHandler 对象来将页面呈现为一个大的字符串,然后我们将它写到 LogCat 中。如您所见,向 execute() 方法添加一个 BasicResponseHandler 非常容易。

每个 Android 应用都有一个关联的应用对象,您可能会想利用这一事实。默认情况下,如果不定义自定义应用对象,Android 使用 android.app.Application 。关于 application 对象有一件有趣的事情:对于您的应用,始终只有一个 application 对象,并且您的所有组件都可以访问它(使用全局上下文对象)。可以扩展应用类并添加功能,比如我们的 CustomHttpClient 。然而,在我们的例子中,实际上没有理由在应用类本身中这样做,当您可以简单地创建一个单独的单例类来处理这种类型的需求时,您最好不要弄乱应用类。

超时的乐趣

为我们的应用设置一个单独的 HttpClient 还有其他非常好的优势。我们可以在一个地方修改它的属性,每个人都可以利用它。例如,如果我们想为我们的 HTTP 调用设置公共超时值,我们可以在创建我们的 HttpClient 时,通过对我们的 HttpParams 对象调用适当的 setter 函数来实现。请参考清单 14-5 和 getHttpClient() 方法。请注意,我们可以使用三种暂停。第一个是连接管理器的超时,它定义了从连接管理器管理的连接池中获取连接需要等待多长时间。在我们的例子中,我们将其设置为 1 秒。我们唯一可能需要等待的时候是池中的所有连接都在使用中。第二个超时值定义了我们应该等待多长时间才能通过网络连接到另一端的服务器。这里,我们使用了 2 秒的值。最后,我们将套接字超时值设置为 4 秒,以定义我们应该等待多长时间来获取请求的数据。

对应于前面描述的三个超时,我们可以得到这三个异常:ConnectionPoolTimeoutException、 ConnectTimeoutException 或 SocketTimeoutException 。所有这三个异常都是 IOException 的子类,我们在 HttpActivity 中使用了它,而不是单独捕获每个子类异常。

如果您研究我们在 getHttpClient() 中使用的每个参数设置类,您可能会发现更多有用的参数。

我们已经为您描述了如何建立一个带有连接池的 HttpClient,以便在您的应用中使用。这意味着,每当您需要使用连接时,各种设置将适用于您的特定需求。但是,如果您希望对特定的消息进行不同的设置,该怎么办呢?谢天谢地,有一个简单的方法可以做到这一点。我们向您展示了如何使用一个 HttpGet 或一个 HttpPost 对象来描述通过网络发出的请求。以类似于我们在 HttpClient 上设置 HttpParams 的方式,您可以在 HttpGet 和 HttpPost 对象上设置 HttpParams 。您在消息级别应用的设置将覆盖 HttpClient 级别的设置,而不会更改 HttpClient 的设置。清单 14-7 显示了如果我们想让一个特定请求的套接字超时为 1 分钟而不是 4 秒钟,这可能会是什么样子。您可以使用这些行来代替清单 14-6 中 getHttpContent() 的 try 块中的行。

清单 14-7在请求级别覆盖套接字超时

HttpGet request = new HttpGet("[`www.google.com/`](http://www.google.com/)");
HttpParams params = request.getParams();
HttpConnectionParams.setSoTimeout(params, 60000);   // 1 minute
request.setParams(params);
String page = httpClient.execute(request,
                    new BasicResponseHandler());
System.out.println(page);

使用 HttpURLConnection

Android 提供了另一种处理 HTTP 服务的方式,那就是使用 Java . net . httpurlconnection 类。这与我们刚刚讨论过的 HttpClient 类没有什么不同,但是 HttpURLConnection 倾向于需要更多的语句来完成任务。HttpURLConnection 也不是线程安全的。另一方面,这个类比 HttpClient 小得多,也轻得多,所以您可以简单地创建您需要的类。从 Gingerbread 版本开始,它也相当稳定,所以当您只需要基本的 HTTP 功能并且想要一个紧凑的应用时,您应该考虑将其用于更新设备上的应用。

使用 AndroidHttpClient

Android 2.2 引入了 HttpClient 的一个新子类,叫做 AndroidHttpClient 。这个类背后的想法是通过提供适用于 Android 应用的默认值和逻辑,使 Android 应用的开发变得更加容易。例如,连接和套接字(即操作)的超时值都默认为 20 秒。连接管理器默认为线程安全客户端连接管理器。在很大程度上,它可以与我们在前面的例子中使用的 HttpClient 互换。但是,您应该知道一些不同之处:

  • 为了创建一个 AndroidHttpClient ,您调用 AndroidHttpClient 类的静态 newInstance() 方法,就像这样:

    AndroidHttpClient httpClient = AndroidHttpClient.newInstance("my-http-agent-string");
    
  • Notice that the parameter to the newInstance() method is an HTTP agent string. You most likely don’t want to hardcode this, so you have two options as follows, which unfortunately can return different strings. The second one is probably the one you want to use as it looks more like what a browser would send (at least in our experiments).

    // The first option is a device-level agent string
    String httpAgent = System.getProperty("http.agent");
    // This second option looks like a browser’s agent string
    httpAgent = new WebView(context).getSettings().getUserAgentString();
    

    当然,你也可以使用任何你的应用可用的东西来构建你自己的代理字符串;服务器将解析它以更好地理解设备,如果你控制服务器,你可以使用你从应用发送的任何值。

  • 当在这个客户端上调用 execute() 时,您必须在一个独立于主 UI 线程的线程中。这意味着如果你试图用一个和一个来替换我们之前的 HttpClient ,你会得到一个异常。从主 UI 线程进行 HTTP 调用是不好的做法,所以 AndroidHttpClient 不会让你这样做。我们将在下一节讨论线程问题。

  • 当您完成时,必须在 AndroidHttpClient 实例上调用 close() 。这样可以适当地释放内存。

  • 有一些方便的静态方法来处理来自服务器的压缩响应,包括

    • modifyrequesttoacceptgziprense(http request)
    • get comprehensity(字节[]日期, 内容解析器 【解析器】
    • getUngzippedContent(HttpEntity 实体)

一旦获得了 AndroidHttpClient 的实例,就不能修改其中的任何参数设置,也不能向其中添加任何参数设置(例如 HTTP 协议版本)。您的选择是覆盖前面所示的 HttpGet 对象中的设置,或者不使用 AndroidHttpClient 。

这就结束了我们对通过 HttpClient 使用 HTTP 服务的讨论。要获得关于使用 HttpClient 和这些其他概念的精彩教程,请访问 Apache 网站HC . Apache . org/httpcomponents-client-ga/tutorial/html/

我们已经向您展示了如何操作基于 HTTP 的服务。但是,如果我们想要运行一些持续时间超过一小段时间的后台处理,或者如果我们想要调用另一个 Android 应用中存在的一些非 UI 功能,该怎么办呢?针对这些需求,Android 提供了服务。我们接下来将讨论它们。

使用安卓服务

Android 支持服务的概念。服务是在后台运行的组件,没有用户界面。您可以将这些组件视为类似于 Windows 服务或 Unix 守护程序。与这些类型的服务类似,Android 服务可以一直可用,但不必主动做些什么。更重要的是,Android 服务可以拥有独立于活动的生命周期。当一个活动暂停、停止或被销毁时,您可能希望继续进行一些处理。服务业对此也有好处。

Android 支持两种类型的服务:本地服务和远程服务。一个本地服务 是一个只能被托管它的应用访问的服务,它不能被设备上运行的其他应用访问。通常,这些类型的服务只是支持托管服务的应用。除了托管服务的应用之外,还可以从设备上的其他应用访问远程服务 。远程服务使用 Android 接口定义语言(AIDL) 向客户端定义自己。我们将讨论这两种类型的服务,尽管在接下来的几章中,我们将深入讨论本地服务。因此,我们将在这里介绍它们,但不会花太多时间。我们将在本章中更详细地讨论远程服务。

了解 Android 中的服务

Android Service 类是一种具有类似服务行为的代码包装器。然而,服务对象不会自动创建自己的线程。对于一个使用线程的服务对象,开发者必须让它发生。这意味着在没有给服务添加线程的情况下,服务的代码将在主线程上运行。如果我们的服务正在执行的操作会很快完成,这就不是问题。如果我们的服务可能会运行一段时间,我们肯定希望包含线程。请记住,在服务中使用 AsyncTask s 进行线程处理没有任何问题。

Android 支持服务的概念有两个原因:

  • 首先,允许您轻松实现后台任务。
  • 第二,允许您在同一设备上运行的应用之间进行进程间通信。

这两个原因对应了 Android 支持的两类服务:本地服务和远程服务。第一种情况的例子可能是作为电子邮件应用的一部分实现的本地服务。该服务可以处理向电子邮件服务器发送新电子邮件,包括附件和重试。因为这可能需要一段时间才能完成,所以服务是包装该功能的一种很好的方式,这样主线程就可以启动它并返回给用户。此外,如果电子邮件活动停止,您仍然希望发送的电子邮件被传递。第二种情况的一个例子是语言翻译应用,我们将在后面看到。假设您有几个应用在一个设备上运行,您需要一个服务来接受需要从一种语言翻译成另一种语言的文本。您可以编写一个远程翻译服务,让应用与服务对话,而不是在每个应用中重复逻辑。

本地服务由使用 bindService() 绑定到它的客户端初始化,或者由使用 startService() 启动它的客户端初始化。远程服务通常总是用 bindService() 初始化。绑定的服务在第一个客户端绑定到它时被实例化,在最后一个客户端解除绑定时被销毁。当客户端进出前台时,它们可以根据需要绑定和解除绑定,以确保服务不会不必要地运行。这有助于延长电池寿命。但是,在 onResume()中绑定而在 onPause()中取消绑定是不明智的,因为这可能会导致大量不必要的服务启动和停止。最好在 onCreate()和 onDestroy()中绑定和解除绑定,或者在 onStart()和 onStop()中绑定和解除绑定。仅允许从应用上下文、活动、另一个服务或内容提供者进行绑定。这意味着不是来自片段,也不是来自广播接收器。

相反,当使用 startService()启动服务时,它将一直运行,直到被客户端或告诉自己停止而停止。对于希望在后台执行工作的本地服务,可以考虑用 startService()实例化它,这样即使启动它的活动消失,它也可以保持运行。从技术上讲,广播接收器可以使用 startService()启动服务,因为一旦短暂的广播接收器终止,服务就可以继续存在。如果您确实创建了一个即使在活动已经消失的情况下也将在后台运行的服务,那么您可能希望实现 onBind(),以便用户能够重新获得对该服务的控制。一个新的活动可以绑定到现有的服务,然后调用它的服务方法。

有不创建后台线程的本地服务的例子,但是这在实践中可能不是很有用。服务本身并不创建任何线程,因此默认情况下,服务的代码将在主 UI 线程上运行。将这些代码包装在服务中可能没有任何真正的好处,因为您可以只调用类的方法来执行该逻辑。更常见的是,本地服务有自己的执行线程,这些线程可以在第一个客户端绑定到它时启动,也可以因为 startService()命令而启动。

现在,我们可以开始详细检查这两种类型的服务。我们将从讨论本地服务开始,然后讨论远程服务。如前所述,本地服务是仅由承载它们的应用调用的服务。远程服务是支持远程过程调用(RPC)机制 的服务。这些服务允许同一设备上的外部客户端连接到服务并使用其设施。调用远程服务有两种主要方式:使用 AIDL 接口和使用信使。两者都包括在内。

注意Android 中的第二种服务有几个名字:远程服务、AIDL 支持服务、AIDL 服务、外部服务和 RPC 服务。这些术语都是指同一种类型的服务——一种可以被设备上运行的其他应用远程访问的服务。

了解本地服务

本地服务是通常通过 Context.startService() 启动的服务。一旦启动,这些类型的服务将继续运行,直到客户端调用服务上的 Context.stopService() 或者服务本身调用 stopSelf() 。注意,当 Context.startService() 被调用,服务还没有被创建时,系统会实例化服务,并调用服务的 onStartCommand() 方法 。请记住,在服务启动后(即当它存在时)调用 Context.startService() 不会导致服务的另一个实例,但会重新调用正在运行的服务的 onStartCommand() 方法。这里有几个本地服务的例子:

  • 一种服务,用于监控来自设备的传感器数据并进行分析,如果达到某个条件,就会发出警报。该服务可能会持续运行。
  • 一个任务执行程序服务,允许您的应用的活动提交作业并将它们排队等待处理。此服务可能仅在提交作业的操作期间运行。

清单 14-8 通过实现一个执行后台任务的服务来演示一个本地服务。我们最终将得到创建和消费服务所需的四个构件:【BackgroundService.java】(服务本身)【main . XML】(活动的布局文件)【MainActivity.java】(调用服务的活动类),以及 AndroidManifest.xml 。清单 14-8 仅包含 BackgroundService.java。我们将首先剖析这段代码,然后再看其他三段。

清单 14-8实现本地服务:

public class BackgroundService extends Service
{
    private static final String TAG = "BackgroundService";
    private NotificationManager notificationMgr;
    private ThreadGroup myThreads = new ThreadGroup("ServiceWorker");

    @Override
    public void onCreate() {
        super.onCreate();

        Log.v(TAG, "in onCreate()");
        notificationMgr =(NotificationManager)getSystemService(
               NOTIFICATION_SERVICE);
        displayNotificationMessage("Background Service is running");
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        super.onStartCommand(intent, flags, startId);

        int counter = intent.getExtras().getInt("counter");
        Log.v(TAG, "in onStartCommand(), counter = " + counter +
                ", startId = " + startId);

        new Thread(myThreads, new ServiceWorker(counter),
            "BackgroundService")
                .start();

        return START_STICKY;
    }

    class ServiceWorker implements Runnable
    {
        private int counter = -1;
        public ServiceWorker(int counter) {
            this.counter = counter;
        }

        public void run() {
            final String TAG2 = "ServiceWorker:" +
                Thread.currentThread().getId();
            // do background processing here... we'll just sleep...
            try {
                Log.v(TAG2, "sleeping for 10 seconds. counter = " +
                    counter);
                Thread.sleep(10000);
                Log.v(TAG2, "... waking up");
            } catch (InterruptedException e) {
                Log.v(TAG2, "... sleep interrupted");
            }
        }
    }

    @Override
    public void onDestroy()
    {
        Log.v(TAG, "in onDestroy(). Interrupting threads and cancelling notifications");
        myThreads.interrupt();
        notificationMgr.cancelAll();
        super.onDestroy();
    }

    @Override
    public IBinder onBind(Intent intent) {
        Log.v(TAG, "in onBind()");
        return null;
    }

    private void displayNotificationMessage(String message)
    {

        PendingIntent contentIntent =
            PendingIntent.getActivity(this, 0,
                new Intent(this, MainActivity.class), 0);

        Notification notification = new NotificationCompat.Builder(this)
            .setContentTitle(message)
            .setContentText("Touch to turn off service")
            .setSmallIcon(R.drawable.emo_im_winking)
            .setTicker("Starting up!!!")
            // .setLargeIcon(aBitmap)
            .setContentIntent(contentIntent)
            .setOngoing(true)
            .build();

        notificationMgr.notify(0, notification);
    }
}

一个服务对象的结构有点类似于一个活动。有一个 onCreate() 方法可以用来进行初始化,还有一个 onDestroy() 方法可以用来进行清理。服务不像活动那样暂停或恢复,所以我们不使用 onPause() 或 onResume() 方法。在这个例子中,我们不会绑定到本地服务,但是因为服务需要实现 onBind() 方法 ,所以我们提供了一个返回 null 的服务。值得一提的是,您可以有一个实现 onBind()而不使用 onStartCommand()的本地服务。

回到我们的 onCreate() 方法,除了通知用户这个服务已经创建,我们不需要做太多事情。我们使用通知管理器来完成这项工作。你可能已经注意到了 Android 屏幕左上角的通知栏。通过拉下这个按钮,用户可以查看重要的消息,通过触摸通知可以对通知进行操作,这通常意味着返回到与通知相关的一些活动。对于服务,因为它们可以在后台运行,或者至少存在于后台,而没有可见的活动,所以必须有某种方法让用户重新接触到服务,也许是关闭它。因此,我们创建一个通知对象,用一个 pending content 填充它,这将使我们返回到我们的控件活动,并发布它。这一切都发生在 displayNotificationMessage()方法中。请注意,只要我们的服务存在,我们的通知对象就需要存在,因此我们使用 setOngoing(true) 将它保留在通知列表中,直到我们自己从服务的 onDestroy() 方法中将其清除。我们在 onDestroy() 中使用的清除通知的方法是 NotificationManager 上的 cancelAll() 。

这个例子还需要另外一个东西。您需要创建一个名为 emo_im_winking 的 drawable,并将其放在项目的 drawable 文件夹中。出于演示目的,一个很好的 drawables 来源是查看 Android 平台文件夹下的 AndroidSDK/platforms//data/RES/drawable,其中 < version > 是您感兴趣的版本。不幸的是,你不能从你的代码中可靠地引用 Android 系统的 drawables,所以你需要把你想要的复制到你的项目的 drawables 文件夹中。如果您为您的示例选择了不同的 drawable 文件,只需在通知的构造函数中重命名资源 ID。

当使用 startService() 将意图发送到我们的服务中时,如果需要,将调用 onCreate() ,并调用我们的 onStartCommand() 方法来接收调用者的意图。在我们的例子中,我们不打算对它做任何特别的事情,除了打开计数器并用它来启动一个后台线程。在真实世界的服务中,我们希望任何数据都通过 intent 传递给我们,例如,这可能包括 URIs。注意在创建线程时使用了线程组 。这将被证明是有用的,当我们想摆脱我们的背景线程。还要注意 startId 参数。这是由 Android 为我们设置的,是自该服务启动以来服务调用的唯一标识符。

我们的 ServiceWorker class 是一个典型的 runnable,是我们服务的工作发生的地方。在我们的特殊情况下,我们只是记录一些消息和睡眠。我们也会捕捉任何干扰并记录下来。我们没有做的一件事是操纵用户界面。例如,我们不会更新任何视图。因为我们不再在主线程上,所以我们不能直接接触 UI。我们的服务人员有很多方法可以改变用户界面,我们将在接下来的几章中详细介绍这些方法。

我们 BackgroundService 中最后要注意的一项是 onDestroy() 方法 。这是我们进行清理的地方。在我们的例子中,我们想要去掉我们之前创建的线程,如果有的话。如果我们不这样做,它们可能只是四处游荡,占用内存。第二,我们想摆脱我们的通知消息。因为我们的服务正在消失,用户不再需要通过活动来摆脱它。然而,在实际应用中,我们可能希望让我们的员工继续工作。如果我们的服务是发送电子邮件,我们当然不想简单地杀死线程。我们的例子过于简单,因为我们暗示通过使用 interrupt() 方法 可以很容易地杀死后台线程。然而实际上,你最多只能打断一下。不过,这不一定会杀死一个线程。有一些不推荐使用的方法来终止线程,但是您不应该使用这些方法。它们会给你和你的用户带来内存和稳定性问题。在我们的例子中,打断是有效的,因为我们在睡觉,这是可以被打断的。

看一下 ThreadGroup 类是值得的,因为它提供了访问线程的方法。我们在服务中创建了一个单独的线程组对象,然后在创建我们自己的线程时使用它。在我们的服务的 onDestroy() 方法中,我们简单地对线程组执行中断(),它向线程组中的每个线程发出一个中断。

这就是一个简单的本地服务的构成。在我们展示我们活动的代码之前,清单 14-9 展示了我们用户界面的 XML 布局文件。

清单 14-9实现本地服务: main.xml

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/layout/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    >
<Button  android:id="@+id/startBtn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Start Service"  android:onClick="doClick" />
<Button  android:id="@+id/stopBtn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Stop Service"  android:onClick="doClick" />
</LinearLayout>

我们将在用户界面上显示两个按钮,一个执行 startService() ,另一个执行 stopService() 。我们可以选择使用一个 ToggleButton,但是这样你就不能连续多次调用 startService() 。这是很重要的一点。 startService() 和 stopService() 之间不是一一对应的关系。当调用 stopService() 时,服务对象将被销毁,所有 startService() 调用创建的所有线程也将消失。现在,让我们看看清单 14-10 中的活动代码。

清单 14-10实现本地服务:

public class MainActivity extends Activity
{
    private static final String TAG = "MainActivity";
    private int counter = 1;

    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }

    public void doClick(View view) {
        switch(view.getId()) {
        case R.id.startBtn:
            Log.v(TAG, "Starting service... counter = " + counter);
            Intent intent = new Intent(MainActivity.this,
                    BackgroundService.class);
            intent.putExtra("counter", counter++);
            startService(intent);
            break;
        case R.id.stopBtn:
            stopService();
        }
    }

    private void stopService() {
        Log.v(TAG, "Stopping service...");
        if(stopService(new Intent(MainActivity.this,
                    BackgroundService.class)))
            Log.v(TAG, "stopService was successful");
        else
            Log.v(TAG, "stopService was unsuccessful");
    }

    @Override
    public void onDestroy()
    {
        stopService();
        super.onDestroy();
    }
}

我们的主要活动看起来很像你见过的其他活动。有一个简单的 onCreate() 来从 main.xml 布局文件设置我们的用户界面。有一个 doClick() 方法来处理按钮回调。在我们的示例中,当按下启动服务按钮时,我们调用 startService() ,当按下停止服务按钮时,我们调用 stopService() 。当我们启动服务时,我们希望传入一些数据,这是通过 intent 实现的。我们选择在 Extras 包中传递数据,但是如果我们有一个 URI,我们可以使用 setData() 来添加它。当我们停止服务时,我们检查返回结果。它通常应该是真的,但是如果服务没有运行,我们可能会得到假的返回。最后,当我们的活动终止时,我们想要停止服务,所以我们也在我们的 onDestroy() 方法中停止服务。还有一项需要讨论,那就是 AndroidManifest.xml 文件,我们在清单 14-11 中展示了它。

清单 14-11实现本地服务:Android manifest . XML

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      package="com.androidbook.services.simplelocal"
      android:versionCode="1"
      android:versionName="1.0">
    <uses-sdk android:minSdkVersion="8" />
    <application android:icon="@drawable/icon"
             android:label="@string/app_name">
        <activity android:name=".MainActivity"
                android:label="@string/app_name"
                android:launchMode="singleTop" >
           <intent-filter>
             <action android:name="android.intent.action.MAIN" />
             <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
        </activity>
        <service android:name="BackgroundService"/>
    </application>

</manifest>

除了我们清单文件中常规的 <活动> 标签之外,我们现在还有一个 <服务> 标签。因为这是一个我们使用类名显式调用的本地服务,所以我们不需要在 <服务> 标签中放太多内容。所需要的只是我们服务的名称。但是关于这个清单文件还有一点需要指出。我们的服务会创建一个通知,以便用户可以返回到我们的 MainActivity ,例如,如果用户在不停止服务的情况下按下了 MainActivity 上的 Home 键。

主活动仍然存在;只是看不出来而已。返回到主活动的一种方法是单击我们的服务创建的通知。通知管理器将我们的意图传递回我们的应用,通常会导致一个新的主活动实例来处理新的意图。为了防止这种情况发生,我们在清单文件中为名为 android:launchMode 的 MainActivity 设置了一个属性,并将其设置为 singleTop 。这将有助于确保现有的不可见的 MainActivity 将被前移并显示,而不是创建另一个 MainActivity 。

当您运行这个应用时,您会看到我们的两个按钮。通过单击 Start Service 按钮,您将实例化服务并调用 onStartCommand() 。我们的代码将几条消息记录到 LogCat 中,因此您可以跟着做。继续,连续几次点击“启动服务”,甚至更快。您将看到为处理每个请求而创建的线程。您还会注意到,计数器的值被传递给每个 ServiceWorker 线程。当您按下停止服务按钮时,我们的服务将会消失,您将会看到来自我们的 MainActivity 的 stopService() 方法的日志消息,来自我们的 BackgroundService 的 onDestroy() 方法的日志消息,如果线程被中断,还可能会看到来自 ServiceWorker 线程的日志消息。

您还应该注意到服务启动时的通知消息。随着服务的运行,在我们的 MainActivity 中点击 Back 按钮,注意通知消息消失了。这意味着我们的服务也消失了。要重启我们的主活动,点击 Start Service 让服务再次运行。现在,按下主页按钮。我们的主活动从视图中消失了,但是通知仍然存在,这意味着我们的服务仍然存在。继续点击通知,您将再次看到我们的主活动。

请注意,我们的示例使用活动与服务进行交互,但是您的应用中的任何组件都可以使用该服务。这包括其他服务、活动、泛型类等等。还要注意,我们的服务不会自行停止;它依赖于活动来完成。有一些方法可供服务使用,允许服务自行停止,即 stopSelf() 和 stopSelfResult() 。显然,如果这个服务有多个客户端,我们不希望其中一个客户端停止服务,而其他客户端仍在使用它。对于一个有多个客户端的已启动服务,您更有可能在服务本身中加入逻辑来决定服务何时能够或者应该停止,并且服务将使用 stop*()方法 之一来完成这一任务。

我们的 BackgroundService 是托管服务的应用组件所使用的服务的典型例子。换句话说,运行服务的应用也是唯一的消费者。因为该服务不支持来自其进程之外的客户端,所以该服务是本地服务。本地服务的关键方法有 onCreate() 、 onStartCommand() 、onBind()、 stop*() 和 onDestroy() 。

本地服务还有另一种选择,那就是只有一个服务实例和一个后台线程。在这种情况下,在 BackgroundService 的 onCreate() 方法中,我们可以创建一个线程来完成服务的繁重工作。我们可以在 onCreate() 而不是 onStartCommand() 中创建并启动线程。我们可以这样做,因为 onCreate() 只被调用一次,并且我们希望线程在服务的生命周期中只被创建一次。然而,我们在 onCreate() 中没有的一件事是 startService() 传递的意图的内容。如果我们需要,我们也可以使用前面描述的模式,我们只需要知道 onStartCommand() 应该只被调用一次。

Android 还有另一种方法来实现自动包含后台线程的本地服务:IntentService。服务的子类 IntentService 接收来自 startService()调用的传入意图,为您创建一个后台(worker)线程,并调用回调 onHandleIntent(Intent intent)。如果在工作线程完成前一个意图之前将另一个意图传递给此服务,则新的意图将处于等待状态,直到处理完前一个意图,此时队列中的下一个意图将被传递给 onHandleIntent()方法。当来自入站队列的所有意图都完成处理后,服务将自行停止(不需要您这样做)。

我们对本地服务的介绍到此结束。请记住,我们将在后续章节中深入了解本地服务的更多细节。让我们转到 AIDL 服务——一种更复杂的服务。

了解 AIDL 服务

在上一节中,我们向您展示了如何编写一个 Android 服务,由托管该服务的应用使用。现在,我们将向您展示如何通过远程过程调用(RPC) 构建一个可以被其他流程使用的服务。与许多其他基于 RPC 的解决方案一样,在 Android 中,您需要一个接口定义语言(IDL)来定义将向客户端公开的接口。在 Android 世界,这个 IDL 被称为 Android 接口定义语言(AIDL) 。要构建远程服务,您需要执行以下操作:

  1. 编写一个 AIDL 文件,定义你与客户的接口。AIDL 文件使用 Java 语法,并有一个。aidl 扩展。在你的 AIDL 文件中使用和你的 Android 项目相同的包名。
  2. 将 AIDL 文件添加到 Eclipse 项目的 src 目录下。Android Eclipse 插件将调用 AIDL 编译器从 AIDL 文件生成 Java 接口(AIDL 编译器作为构建过程的一部分被调用)。
  3. 实现一个服务,从 onBind() 方法返回接口。
  4. 将服务配置添加到您的 AndroidManifest.xml 文件中。接下来的部分向您展示了如何执行每个步骤。

在 AIDL 定义服务接口

为了演示一个远程服务的例子,我们将编写一个股票报价服务。该服务将提供一个方法,该方法获取股票代码并返回股票价值。要在 Android 中编写远程服务,第一步是在 AIDL 文件中定义服务接口定义。清单 14-12 显示了 IStockQuoteService 的 AIDL 定义。对于您的 StockQuoteService 项目,该文件与常规 Java 文件放在同一个位置。

清单 14-12AIDL 定义的 服务

// This file is IStockQuoteService.aidl
package com.androidbook.services.stockquoteservice;
interface IStockQuoteService
{
        double getQuote(String ticker);
}

IStockQuoteService 以字符串形式接受股票代码,并以双精度形式返回当前股票价值。当您创建 AIDL 文件时,Android Eclipse 插件运行 AIDL 编译器来处理您的 AIDL 文件(作为构建过程的一部分)。如果您的 AIDL 文件编译成功,编译器会生成一个适合 RPC 通信的 Java 接口。注意,生成的文件将位于您的 AIDL 文件中命名的包中,在本例中为 com . androidbook . services . stock quote service。

清单 14-13 显示了为我们的 IStockQuoteService 接口生成的 Java 文件。生成的文件将被放到我们的 Eclipse 项目的 gen 文件夹中。

清单 14-13编译器生成的 Java 文件

 /*
 * This file is auto-generated.  DO NOT MODIFY.
 * Original file: C:\\android\\StockQuoteService\\src\\com\\androidbook\\![shift-enter.jpg](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/pro-andr5/img/image00893.jpeg)
services\\stockquoteservice\\IStockQuoteService.aidl
 */
package com.androidbook.services.stockquoteservice;
import java.lang.String;
import android.os.RemoteException;
import android.os.IBinder;
import android.os.IInterface;
import android.os.Binder;
import android.os.Parcel;
public interface IStockQuoteService extends android.os.IInterface
{
/** Local-side IPC implementation stub class. */
public static abstract class Stub extends android.os.Binder implements![shift-enter.jpg](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/pro-andr5/img/image00893.jpeg)
com.androidbook.services.stockquoteservice.IStockQuoteService
{
private static final java.lang.String DESCRIPTOR =  ![shift-enter.jpg](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/pro-andr5/img/image00893.jpeg)
"com.androidbook.services.stockquoteservice.IStockQuoteService";
/** Construct the stub at attach it to the interface. */
public Stub()
{
this.attachInterface(this, DESCRIPTOR);
}
/**
 * Cast an IBinder object into an IStockQuoteService interface,
 * generating a proxy if needed.
 */
public static com.androidbook.services.stockquoteservice.IStockQuoteService![shift-enter.jpg](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/pro-andr5/img/image00893.jpeg)
asInterface(android.os.IBinder obj)
{
if ((obj==null)) {
return null;
}
android.os.IInterface iin = (android.os.IInterface)obj.queryLocalInterface(DESCRIPTOR);
if (((iin!=null)&&(iin instanceof com.androidbook.services.stockquoteservice.IStockQuoteService))) {
return ((com.androidbook.services.stockquoteservice.IStockQuoteService)iin);
}
return new com.androidbook.services.stockquoteservice.IStockQuoteService.Stub.Proxy(obj);
}
public android.os.IBinder asBinder()
{
return this;
}
@Override public boolean onTransact(int code, android.os.Parcel data,![shift-enter.jpg](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/pro-andr5/img/image00893.jpeg)
     android.os.Parcel reply, int flags) throws android.os.RemoteException
{
switch (code)
{
case INTERFACE_TRANSACTION:
{
reply.writeString(DESCRIPTOR);
return true;
}
case TRANSACTION_getQuote:
{
data.enforceInterface(DESCRIPTOR);
java.lang.String _arg0;
_arg0 = data.readString();
double _result = this.getQuote(_arg0);
reply.writeNoException();
reply.writeDouble(_result);
return true;
}
}
return super.onTransact(code, data, reply, flags);
}
private static class Proxy implements
        com.androidbook.services.stockquoteservice.IStockQuoteService
{
private android.os.IBinder mRemote;
Proxy(android.os.IBinder remote)
{
mRemote = remote;
}
public android.os.IBinder asBinder()
{
return mRemote;
}
public java.lang.String getInterfaceDescriptor()
{
return DESCRIPTOR;
}
public double getQuote(java.lang.String ticker) throws android.os.RemoteException
{
android.os.Parcel _data = android.os.Parcel.obtain();
android.os.Parcel _reply = android.os.Parcel.obtain();
double _result;
try {
_data.writeInterfaceToken(DESCRIPTOR);
_data.writeString(ticker);
mRemote.transact(Stub.TRANSACTION_getQuote, _data, _reply, 0);
_reply.readException();
_result = _reply.readDouble();
}
finally {
_reply.recycle();
_data.recycle();
}
return _result;
}
}
static final int TRANSACTION_getQuote = (IBinder.FIRST_CALL_TRANSACTION + 0);
}
public double getQuote(java.lang.String ticker) throws android.os.RemoteException;
}

关于生成的类,请注意以下要点:

  • 我们在 AIDL 文件中定义的接口在生成的代码中实现为一个接口(即有一个名为 IStockQuoteService 的接口)。
  • 一个名为存根的静态抽象类扩展了 android.os.Binder 并实现了 IStockQuoteService 。请注意,该类是一个抽象类。
  • 名为 Proxy 的内部类实现了代理存根类的 IStockQuoteService 。
  • AIDL 文件必须位于生成的文件所在的包中(如 AIDL 文件的包声明中所指定的)。

现在,让我们继续,在服务类中实现 AIDL 接口。

实现 AIDL 接口

在上一节中,我们为股票报价机服务定义了一个 AIDL 文件,并生成了绑定文件。现在,我们将提供该服务的实现。为了实现服务的接口,我们需要编写一个类来扩展 android.app.Service 并实现 IStockQuoteService 接口。我们要写的类我们称之为股票报价服务。为了向客户端公开服务,我们的 StockQuoteService 将需要提供 onBind() 方法的实现,并且我们需要向 AndroidManifest.xml 文件添加一些配置信息。清单 14-14 显示了 IStockQuoteService 接口的实现。该文件也放入 StockQuoteService 项目的 src 文件夹中。

清单 14-14IStockQuoteService**服务 实现

public class StockQuoteService extends Service
{
    private static final String TAG = "StockQuoteService";
    public class StockQuoteServiceImpl extends IStockQuoteService.Stub
    {
        @Override
        public double getQuote(String ticker) throws RemoteException
        {
            Log.v(TAG, "getQuote() called for " + ticker);
            return 20.0;
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();
        Log.v(TAG, "onCreate() called");
    }

    @Override
    public void onDestroy()
    {
        super.onDestroy();
        Log.v(TAG, "onDestroy() called");
    }

    @Override
    public IBinder onBind(Intent intent)
    {
        Log.v(TAG, "onBind() called");
        return new StockQuoteServiceImpl();
    }
}

清单 14-14 中的 StockQuoteService.java 类类似于我们之前创建的本地后台服务,但是没有通知管理器。重要的区别是我们现在实现了 onBind() 方法。回想一下,从 AIDL 文件生成的存根类是一个抽象类,它实现了 IStockQuoteService 接口。在我们的服务实现中,我们有一个内部类,它扩展了名为 StockQuoteServiceImpl 的存根类。这个类充当远程服务实现,这个类的一个实例从 onBind() 方法返回。这样,我们就有了一个功能性的 AIDL 服务,尽管外部客户端还不能连接到它。

为了向客户端公开服务,我们需要在 AndroidManifest.xml 文件中添加一个服务声明,这一次,我们需要一个意图过滤器来公开服务。清单 14-15 显示了股票报价服务的服务声明。 <服务> 标签是 <应用> 标签的子标签。

清单 14-15清单声明 为 IStockQuoteService

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      package="com.androidbook.services.stockquoteservice"
      android:versionCode="1"
      android:versionName="1.0">
    <application android:icon="@drawable/icon"
        android:label="@string/app_name">
      <service android:name="StockQuoteService">
        <intent-filter>
                <action android:name=
                            "com.androidbook.services.stockquoteservice.IStockQuoteService" />
        </intent-filter>
      </service>
    </application>
    <uses-sdk android:minSdkVersion="4" />
</manifest>

与所有服务一样,我们用一个 <服务> 标签定义我们想要公开的服务。对于一个 AIDL 服务,我们还需要为我们想要公开的服务接口添加一个带有 <动作> 条目的<>。

有了这些,我们就拥有了部署服务所需的一切。当您准备好从 Eclipse 部署服务应用时,只需像对任何其他应用一样选择 Run。Eclipse 将在控制台中注释这个应用没有启动程序,但它无论如何都会部署这个应用,这就是我们想要的。现在让我们看看如何从另一个应用调用服务(当然是在同一个设备上)。

从客户端应用调用服务

当客户端与服务对话时,两者之间必须有一个协议或契约。对于 Android,合同在我们的 AIDL 文件中。因此,使用服务的第一步是获取服务的 AIDL 文件,并将其复制到您的客户端项目中。当您将 AIDL 文件复制到客户端项目时,AIDL 编译器会创建与服务实现时创建的相同的接口定义文件(在服务实现项目中)。这向客户端公开了服务上的所有方法、参数和返回类型。让我们创建一个新项目并复制 AIDL 文件:

  1. 创建一个新的 Android 项目,命名为 StockQuoteClient 。使用不同的包名,比如 com . androidbook . stock quote client。将主活动用于创建活动字段。
  2. 在这个项目中,在 src 目录下创建一个名为 com . androidbook . services . stock quote service 的新 Java 包。
  3. 将 StockQuoteService 项目中的 IStockQuoteService.aidl 文件复制到这个新包中。请注意,在您将文件复制到项目中之后,AIDL 编译器将会生成相关的 Java 文件。

您重新生成的服务接口充当客户端和服务之间的契约。下一步是获取对服务的引用,这样我们就可以调用 getQuote() 方法。对于远程服务,我们必须调用 bindService() 方法,而不是 startService() 方法。清单 14-16 显示了一个活动类,它作为 IStockQuoteService 服务的客户端。清单 14-17 包含了活动的布局文件。

清单 14-16 显示了我们的【MainActivity.java】文件的文件。认识到客户端活动的包名并不重要——您可以将活动放在任何您喜欢的包中。然而,您创建的 AIDL 工件是包敏感的,因为 AIDL 编译器从 AIDL 文件的内容生成代码。

清单 14-16istock quote Service 服务的客户端

public class MainActivity extends Activity {
    private static final String TAG = "StockQuoteClient";
    private IStockQuoteService stockService = null;
    private ToggleButton bindBtn;
    private Button callBtn;

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        bindBtn = (ToggleButton)findViewById(R.id.bindBtn);
        callBtn = (Button)findViewById(R.id.callBtn);
    }

    public void doClick(View view) {
        switch(view.getId()) {
        case R.id.bindBtn:
            if(((ToggleButton) view).isChecked()) {
                bindService(new Intent(
                    IStockQuoteService.class.getName()),
                    serConn, Context.BIND_AUTO_CREATE);
            }
            else {
                unbindService(serConn);
                callBtn.setEnabled(false);
            }
            break;
        case R.id.callBtn:
            callService();
            break;
        }
    }

    private void callService() {
        try {
            double val = stockService.getQuote("ANDROID");
            Toast.makeText(MainActivity.this,
                    "Value from service is " + val,
                    Toast.LENGTH_SHORT).show();
        } catch (RemoteException ee) {
            Log.e("MainActivity", ee.getMessage(), ee);
        }
    }

    private ServiceConnection serConn = new ServiceConnection() {

        @Override
        public void onServiceConnected(ComponentName name,
            IBinder service)
        {
            Log.v(TAG, "onServiceConnected() called");
            stockService = IStockQuoteService.Stub.asInterface(service);
            bindBtn.setChecked(true);
            callBtn.setEnabled(true);
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            Log.v(TAG, "onServiceDisconnected() called");
            bindBtn.setChecked(false);
            callBtn.setEnabled(false);
            stockService = null;
        }
    };

    protected void onDestroy() {
        Log.v(TAG, "onDestroy() called");
        if(callBtn.isEnabled())
            unbindService(serConn);
        super.onDestroy();
    }
}

该活动显示了我们的布局并获取了对 Call Service 按钮的引用,因此我们可以在服务运行时正确地启用它,并在服务停止时禁用它。当用户点击 Bind 按钮时,活动调用 bindService() 方法 。同样,当用户点击 UnBind 时,活动调用 unbindService() 方法 。注意,有三个参数被传递给了 bindService() 方法:一个带有 AIDL 服务名称的 Intent ,一个 ServiceConnection 实例,以及一个自动创建服务的标志。

清单 14-17istock quote Service 服务客户端布局

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/layout/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" >

<ToggleButton android:id="@+id/bindBtn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textOff="Bind"  android:textOn="Unbind"
    android:onClick="doClick" />

<Button android:id="@+id/callBtn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Call Service"  android:enabled="false"
    android:onClick="doClick" />
</LinearLayout>

对于绑定服务,比如 AIDL 服务,您需要提供一个 ServiceConnection 接口的实现。这个接口定义了两个方法:一个由系统在建立到服务的连接时调用,另一个在到服务的连接被破坏时调用。在我们的活动实现中,我们为 IStockQuoteService 定义了 ServiceConnection 。当我们调用 bindService() 方法时,我们传入对此方法的引用(即 serConn )。当建立到服务的连接时,调用 onServiceConnected() 回调,然后我们使用存根获取对 IStockQuoteService 的引用,并启用调用服务按钮。

注意, bindService() 调用是一个异步调用。它是异步的,因为流程或服务可能没有运行,因此可能必须创建或启动。我们不能在主线程上等待服务启动。因为 bindService() 是异步的,平台提供了 ServiceConnection 回调,所以我们知道什么时候服务已经启动,什么时候服务不再可用。这些 ServiceConnection 回调将在主线程上运行,所以如果需要的话,它们可以访问 UI 组件。

请注意 onServiceDisconnected() 回调。当我们从服务中解除绑定时,这个不会被调用。如果服务崩溃或者 Android 决定终止服务,例如内存不足,就会调用这个函数。如果这个回调触发,我们不应该认为我们仍然是连接的,我们可能需要重新调用 bindService() 调用。这就是为什么当这个回调被调用时,我们要改变 UI 中按钮的状态。但是请注意,我们说过“我们可能需要重新调用 bindService() 调用。”Android 可以为我们重启服务,并调用我们的 onServiceConnected() 回调。您可以自己尝试一下,运行客户端,绑定到服务,并使用 DDMS 在股票报价服务应用上停留一下。

当您运行这个示例时,请观察 LogCat 中的日志消息,了解一下幕后发生了什么。

到目前为止,在我们的服务示例中,我们已经严格处理了简单 Java 原语类型的传递。Android 服务实际上也支持传递复杂类型。这非常有用,尤其是对于 AIDL 服务,因为您可能需要向服务传递不限数量的参数,而将它们都作为简单的原语传递是不合理的。更有意义的做法是将它们打包成复杂类型,然后传递给服务。

让我们看看如何将复杂类型传递给服务。

将复杂类型传递给服务

与传递 Java 基本类型相比,在服务之间传递复杂类型需要更多的工作。在开始这项工作之前,您应该了解一下 AIDL 对非原始类型的支持:

  • AIDL 支持串和串。
  • AIDL 允许您传递其他 AIDL 接口,但是您需要为您引用的每个 AIDL 接口拥有一个 import 语句(即使被引用的 AIDL 接口在同一个包中)。
  • AIDL 允许你传递实现 Android . OS . parcelable 接口的复杂类型。对于这些类型,在您的 AIDL 文件中需要有一个 import 语句。
  • AIDL 支持 java.util.List 和 java.util.Map ,有一些限制。集合中项目允许的数据类型包括 Java 原语、字符串、 CharSequence 和 android.os.Parcelable 。对于列表或映射,您不需要导入语句,但是对于 Parcelable s,您需要它们。
  • 除了字符串之外的非基本类型需要一个方向指示器。方向指示灯包括 in 、 out 和 inout。in 表示值由客户端设置, out 表示值由服务设置, inout 表示值由客户端和服务共同设置。如果值没有按照指示的方向流动,Android 会避免序列化这些值,这有助于整体性能。

Parcelable 接口告诉 Android 运行时如何在编组和解组过程中序列化和反序列化对象。清单 14-18 显示了一个 Person 类,它实现了 Parcelable 接口。

清单 14-18实现**Parcelable 接口

// This file is Person.java
package com.androidbook.services.stock2;
import android.os.Parcel;
import android.os.Parcelable;

public class Person implements Parcelable {
    private int age;
    private String name;
    public static final Parcelable.Creator<Person> CREATOR =
        new Parcelable.Creator<Person>()
    {
        public Person createFromParcel(Parcel in) {
            return new Person(in);
        }

        public Person[] newArray(int size) {
            return new Person[size];
        }
    };

    public Person() {
    }

    private Person(Parcel in) {
        readFromParcel(in);
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel out, int flags) {
        out.writeInt(age);
        out.writeString(name);
    }

    public void readFromParcel(Parcel in) {
        age = in.readInt();
        name = in.readString();
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

要开始实现这个,在 Eclipse 中创建一个名为 StockQuoteService2 的新 Android 项目。对于创建活动,使用名称 MainActivity ,使用包 com . androidbook . services . stock 2。然后将清单 14-18 中的 Person.java 文件添加到我们新项目的 com . androidbook . services . stock 2 包中。

Parcelable 接口定义了对象编组和解组的契约。在 Parcelable 接口的底层是包裹容器对象。package 类是一种快速序列化/反序列化机制,专门为 Android 中的进程间通信而设计。类提供了一些方法,您可以使用这些方法将成员展开到容器中,以及将成员从容器中展开回来。为了正确地实现进程间通信的对象,我们必须执行以下操作:

  1. 实现 Parcelable 接口。这意味着您实现了 writeToParcel() 和 readfrompacelle()。write 方法将把对象写到包裹中,read 方法将从包裹中读取对象。请注意,写入属性的顺序必须与读取属性的顺序相同。
  2. 向名为创建者的类添加一个静态最终属性。属性需要实现 Android . OS . parcelable . creator接口。
  3. 为包提供一个构造函数,它知道如何从包中创建对象。
  4. 在中定义一个可打包的类。与匹配的 aidl 文件。包含复杂类型的 java 文件。AIDL 编译器在编译你的 AIDL 文件时会寻找这个文件。一个 Person.aidl 文件的例子如清单 14-19 所示。这个文件应该和 Person.java 在同一个地方。

注意看到 Parcelable 可能会引发一个问题,为什么 Android 不使用内置的 Java 序列化机制?事实证明,Android 团队得出的结论是,Java 中的序列化速度太慢,无法满足 Android 的进程间通信需求。因此,团队构建了可打包解决方案。 Parcelable 方法要求你显式地序列化你的类的成员,但是最终,你得到了一个更快的对象序列化。

还要认识到,Android 提供了两种机制,允许您将数据传递给另一个进程。第一个是使用 intent 将包传递给活动,第二个是将 Parcelable 传递给服务。这两种机制不可互换,也不应混淆。也就是说,可打包的不应该被传递给活动。如果您想启动一个活动并向其传递一些数据,请使用包。Parcelable 仅用于 AIDL 定义的一部分。

清单 14-19一个person . aidl文件 的例子

// This file is Person.aidl
package com.androidbook.services.stock2;
parcelable Person;

你将需要一个。项目中每个包的 aidl 文件。在这种情况下,我们只有一个包装,这就是人。您可能会注意到,您没有在 gen 文件夹中创建一个 Person.java 文件。这是可以预料的。我们在之前创建该文件时就已经有了它。

现在,让我们在远程服务中使用 Person 类。为了简单起见,我们将修改我们的 IStockQuoteService 来接受一个 Person 类型的输入参数。这个想法是,客户将传递一个人给服务,告诉服务谁在请求报价。新的 istockquoteservice . aidl 看起来像清单 14-20 中的。

清单 14-20将包裹传递给服务

// This file is IStockQuoteService.aidl
package com.androidbook.services.stock2;
import com.androidbook.services.stock2.Person;

interface IStockQuoteService
{
    String getQuote(in String ticker,in Person requester);
}

getQuote() 方法现在接受两个参数:股票的股票代码和一个 Person 对象来指定是谁发出的请求。注意,我们在参数上有方向指示器,因为参数包括非主类型,并且我们有一个用于 Person 类的 import 语句。 Person 类也和服务定义(com . androidbook . services . stock 2)在同一个包中。

服务实现现在看起来像清单 14-21 中的,主要活动布局在清单 14-22 中的中。

清单 14-21stockquoteservice 2实现**

package com.androidbook.services.stock2;
// This file is StockQuoteService2.java

import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.os.RemoteException;

public class StockQuoteService2 extends Service
{
    private NotificationManager notificationMgr;

    public class StockQuoteServiceImpl extends IStockQuoteService.Stub
    {
        public String getQuote(String ticker, Person requester)
                throws RemoteException {
            return "Hello " + requester.getName() +
                "! Quote for " + ticker + " is 20.0";
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();

        notificationMgr =
          (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

        displayNotificationMessage(
              "onCreate() called in StockQuoteService2");
    }

    @Override
    public void onDestroy()
    {
        displayNotificationMessage(
              "onDestroy() called in StockQuoteService2");
        // Clear all notifications from this service
        notificationMgr.cancelAll();
        super.onDestroy();
    }

    @Override
    public IBinder onBind(Intent intent)
    {
        displayNotificationMessage(
              "onBind() called in StockQuoteService2");
        return new StockQuoteServiceImpl();
    }

    private void displayNotificationMessage(String message)
    {
        PendingIntent contentIntent =
                PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), 0);

        Notification notification = new NotificationCompat.Builder(this)
            .setContentTitle("StockQuoteService2")
            .setContentText(message)
            .setSmallIcon(R.drawable.emo_im_happy)
            .setTicker(message)
            // .setLargeIcon(aBitmap)
            .setContentIntent(contentIntent)
            .setOngoing(true)
            .build();

        notificationMgr.notify(R.id.app_notification_id, notification);
    }
}

清单 14-22stock quote service2布局**

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/layout/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" >
<TextView
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:text="This is where the service could ask for help." />
</LinearLayout>

这个实现与前一个实现的不同之处在于,我们带回了通知,现在我们以字符串而不是双精度值的形式返回股票值。返回给用户的字符串包含来自 Person 对象的请求者的名字,这表明我们读取了从客户端发送的值,并且 Person 对象被正确地传递给了服务。

要实现这一点,还需要做一些其他的事情:

  1. 从 AndroidSDK/platforms/Android-19/data/RES/drawable-mdpi 下找到 emo_im_happy.png 镜像文件,复制到我们项目的 /res/drawable 目录下。或者在代码中更改资源的名称,然后将您想要的任何图像放入 drawables 文件夹中。
  2. 在 /res/values/strings.xml 文件中添加一个新的 <项目 type = " id " name = " app _ notification _ id "/>标签。
  3. 我们需要修改 AndroidManifest.xml 文件中的应用,如清单 14-23 所示。

清单 14-23修改<应用>中的androidmanifest . XML文件 为 StockQuoteService2

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      package="com.androidbook.services.stock2"
      android:versionCode="1"
      android:versionName="1.0">
    <uses-sdk android:minSdkVersion="8" />
    <application android:icon="@drawable/icon"
            android:label="@string/app_name">
        <activity android:name=".MainActivity"
                android:label="@string/app_name"
                android:launchMode="singleTop" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
            </intent-filter>
        </activity>
      <service android:name="StockQuoteService2">
        <intent-filter>
            <action android:name="com.androidbook.services.stock2.IStockQuoteService" />
        </intent-filter>
      </service>
    </application>

</manifest>

虽然我们的 android:name= " "可以使用点符号。MainActivity" 属性,在服务的 < intent-filter > 标签内的 < action > 标签内不能使用点符号。我们需要把它拼出来;否则,我们的客户将找不到服务规范。

最后,我们将使用默认的 MainActivity.java 文件,该文件简单地显示了一个带有简单消息的基本布局。我们之前向您展示了如何从通知启动活动。这个活动在现实生活中也可以达到这个目的,但是在这个例子中,我们将保持这个部分简单。现在我们已经有了服务实现,让我们创建一个名为 StockQuoteClient2 的新 Android 项目。使用 com.dave 作为包,使用 MainActivity 作为活动名称。要实现将 Person 对象传递给服务的客户机,我们需要将客户机需要的所有东西从服务项目复制到客户机项目。需要一个名为 com . Android book . services . stock 2 的新 src 包来接收这些复制的文件。在我们之前的例子中,我们所需要的就是文件 istockquoteservice . aidl。我们还需要复制 Person.java 和 Person.aidl 文件,因为 Person 对象现在是接口的一部分。将这三个文件复制到客户端项目的 com . androidbook . services . stock 2 src 包后,根据清单 14-24 修改 main.xml ,根据清单 14-25 修改 MainActivity.java。或者简单地从我们网站上的源代码导入这个项目。

清单 14-24更新main . XML为 StockQuoteClient2

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/layout/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" >

<ToggleButton android:id="@+id/bindBtn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textOff="Bind"  android:textOn="Unbind"
    android:onClick="doClick" />
<Button android:id="@+id/callBtn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Call Service" android:enabled="false"
    android:onClick="doClick" />
</LinearLayout>

清单 14-25调用 服务

package com.dave;
// This file is MainActivity.java
import com.androidbook.services.stock2.IStockQuoteService;
import com.androidbook.services.stock2.Person;

public class MainActivity extends Activity {

    protected static final String TAG = "StockQuoteClient2";
    private IStockQuoteService stockService = null;
    private ToggleButton bindBtn;
    private Button callBtn;

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        bindBtn = (ToggleButton)findViewById(R.id.bindBtn);
        callBtn = (Button)findViewById(R.id.callBtn);
    }

    public void doClick(View view) {
        switch(view.getId()) {
        case R.id.bindBtn:
            if(((ToggleButton) view).isChecked()) {
                bindService(new Intent(
                    IStockQuoteService.class.getName()),
                    serConn, Context.BIND_AUTO_CREATE);
            }
            else {
                unbindService(serConn);
                callBtn.setEnabled(false);
            }
            break;
        case R.id.callBtn:
            callService();
            break;
        }
    }

    private void callService() {
        try {
            Person person = new Person();
            person.setAge(47);
            person.setName("Dave");
            String response = stockService.getQuote("ANDROID", person);
            Toast.makeText(MainActivity.this,
                        "Value from service is "+response,
                        Toast.LENGTH_SHORT).show();
        } catch (RemoteException ee) {
            Log.e("MainActivity", ee.getMessage(), ee);
        }
    }

    private ServiceConnection serConn = new ServiceConnection() {

        @Override
        public void onServiceConnected(ComponentName name,
            IBinder service)
        {
            Log.v(TAG, "onServiceConnected() called");
            stockService = IStockQuoteService.Stub.asInterface(service);
            bindBtn.setChecked(true);
            callBtn.setEnabled(true);
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            Log.v(TAG, "onServiceDisconnected() called");
            bindBtn.setChecked(false);
            callBtn.setEnabled(false);
            stockService = null;
        }
    };

    protected void onDestroy() {
        if(callBtn.isEnabled())
            unbindService(serConn);
        super.onDestroy();
    }
}

现在可以运行了。记住,在发送客户端运行之前,要将服务发送到设备或模拟器。用户界面应该看起来像图 14-1 。

9781430246800_Fig14-01.jpg

图 14-1 。股票行情 2 用户界面

让我们看看我们有什么。像以前一样,我们绑定到我们的服务,然后我们可以调用服务方法。在 onServiceConnected() 方法中,我们被告知我们的服务正在运行,因此我们可以启用 Call Service 按钮,这样该按钮就可以调用 callService() 方法。如图所示,我们创建一个新的人物对象,并设置其年龄和姓名属性。然后,我们执行服务并显示服务调用的结果。结果看起来像图 14-2 。

9781430246800_Fig14-02.jpg

图 14-2 。用包调用服务的结果

请注意,当调用该服务时,您会在状态栏中收到一个通知。这是来自服务本身。我们在前面简要地谈到了通知是服务与用户交流的一种方式。通常,服务在后台,不显示任何类型的 UI。但是如果一个服务需要与用户交互呢?虽然很容易认为服务可以调用活动,但是服务应该永远不要直接调用活动。相反,服务应该创建一个通知,通知应该是用户如何获得所需的活动。这在我们上次的练习中已经展示过了。我们为服务定义了一个简单的布局和活动实现。当我们在服务中创建通知时,我们在通知中设置活动。用户可以触摸通知,它会将用户带到我们的活动,这是该服务的一部分。这将允许用户与服务进行交互。

通知被保存,以便您可以通过从状态栏下拉来查看它们。请注意,我们对每条消息都重复使用相同的 ID。这意味着我们每次都更新唯一的通知,而不是创建新的通知条目。所以如果你在 Android 中点击绑定后进入通知屏幕,再次调用,几次解除绑定,你只会在通知中看到一条消息,而且会是 StockQuoteService2 发送的最后一条。如果我们使用不同的 id,我们可以有多个通知消息,我们可以分别更新每一个。通知还可以设置附加的用户“提示”,如声音、灯光和/或振动。

查看服务项目和调用它的客户端的工件也是有用的(参见图 14-3 )。

9781430246800_Fig14-03.jpg

图 14-3 。服务和客户端的工件

图 14-3 显示了服务(左)和客户端(右)的 Eclipse 项目工件。注意,客户机和服务之间的契约由双方交换的 AIDL 工件和可打包的对象组成。这就是我们在两边看到【Person.java】、 IStockQuoteService.aidl 、 Person.aidl 的原因。因为 AIDL 编译器从 AIDL 工件生成 Java 接口、存根、代理等等,所以当我们将契约工件复制到客户端项目时,构建过程会在客户端创建 IStockQuoteService.java 文件。

现在您知道了如何在服务和客户端之间交换复杂类型。让我们简单地谈谈调用服务的另一个重要方面:同步和异步服务调用。

您对服务进行的所有调用都是同步的。这就带来了一个明显的问题:您需要在一个工作线程中实现所有的服务调用吗?不一定。在大多数其他平台上,客户端使用完全黑盒子的服务是很常见的,因此客户端在进行服务调用时必须采取适当的预防措施。使用 Android,您可能会知道服务中有什么(通常是因为您自己编写了服务),因此您可以做出明智的决定。如果您知道您正在调用的方法正在做大量繁重的工作,那么您应该考虑使用一个辅助线程来进行调用。如果您确定该方法没有任何瓶颈,就可以安全地在 UI 线程上进行调用。如果您认为最好在工作线程中进行服务调用,那么您可以创建线程,然后调用服务。然后,您可以将结果传递给 UI 线程。

信使和处理者

在 Android 中,还有一种与服务通信的方式,那就是使用信使和处理器。这种机制是建立在 AIDL 服务之上的,但你不必看到或处理 AIDL。与 AIDL 服务一样,当服务在独立于客户端的进程中时,可以使用它。客户端和服务都将实现 Messenger 和 Handler,并继续来回发送消息。您不需要指定任何。aidl 文件;所有东西都编码在 Java 类中。这是在 Android 上进行进程间服务调用的一种相当常见的方式,并且比自己处理 AIDL 要容易得多。

这里有一个它如何工作的快速概述。客户端绑定到服务,并设置一个信使和处理器来接收来自服务的响应。处理器中的回调处理服务发回的消息。客户端还创建一个 Messenger 来向服务发送消息。在服务端,有一个类似的 Messenger 和处理器来接收来自客户端的传入消息。来自客户端的消息包括用于回复该客户端的信使。因此,服务只创建一个 Messenger,而客户端创建两个。客户端是异步的,服务响应在后面。服务调用的问题会生成一个 RemoteException,客户端可以捕获并处理它。

让我们来看一个例子。这个示例应用有两个部分:MessengerClient 和 MessengerService。它们将在设备上作为独立的进程运行。客户端将使用非 UI 片段来包含服务客户端连接。这意味着客户端活动可能会由于配置更改而消失并被重新创建,而底层服务连接仍然存在。这是从活动连接到服务的首选方式,因为您不希望仅仅因为设备发生了旋转就必须重新构建服务客户端连接。清单 14-26 显示了来自 MessengerService.java 的设置处理器和信使的重要代码。有关完整的列表,请参考本章的 MessengerService 源项目。

清单 14-26基于的 服务代码

public class MessengerService extends Service {
    NotificationManager mNM;
    ArrayList<Messenger> mClients = new ArrayList<Messenger>();
    int mValue = 0;
    public static final int MSG_REGISTER_CLIENT = 1;
    public static final int MSG_UNREGISTER_CLIENT = 2;
    public static final int MSG_SET_SIMPLE_VALUE = 3;
    public static final int MSG_SET_COMPLEX_VALUE = 4;
    public static final String TAG = "MessengerService";
    /**
     * Handler of incoming messages from clients.
     */
    class IncomingHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_REGISTER_CLIENT:
                    mClients.add(msg.replyTo);
                    Log.v(TAG, "Registering client");
                    break;
                case MSG_UNREGISTER_CLIENT:
                    mClients.remove(msg.replyTo);
                    Log.v(TAG, "Unregistering client");
                    break;
                case MSG_SET_SIMPLE_VALUE:
                    mValue = msg.arg1;
                    Log.v(TAG, "Receiving arg1: " + mValue);
                    showNotification("Received arg1: " + mValue);
                    for (int i=mClients.size()-1; i>=0; i--) {
                        try {
                            mClients.get(i).send(Message.obtain(null,
                                    MSG_SET_SIMPLE_VALUE, mValue, 0));
                        } catch (RemoteException e) {
                            // The client is dead.  Remove it from the list;
                            // we are going through the list from back to front
                            // so this is safe to do inside the loop.
                            mClients.remove(i);
                        }
                    }
                    break;
                case MSG_SET_COMPLEX_VALUE:
                    Bundle mBundle = msg.getData();
                    Log.v(TAG, "Receiving bundle: ");
                    if(mBundle != null) {
                        showNotification("Got complex msg: myDouble = "
                                + mBundle.getDouble("myDouble"));
                        for(String key : mBundle.keySet()) {
                            Log.v(TAG, "    " + key);
                        }
                    }
                    break;
                default:
                    Log.v(TAG, "Got some other message: " + msg.what);
                    super.handleMessage(msg);
            }
        }
    }

    // Target for clients to send messages to IncomingHandler.
    final Messenger mMessenger = new Messenger(new IncomingHandler());

    @Override
    public void onCreate() {
        mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

        // Display a notification about us starting.
        Log.v(TAG, "Service is starting");
        showNotification(getText(R.string.remote_service_started));
    }

    @Override
    public void onDestroy() {
        // Cancel the persistent notification.
        mNM.cancel(R.string.remote_service_started);

        // Tell the user we stopped.
        Toast.makeText(this, R.string.remote_service_stopped, Toast.LENGTH_SHORT).show();
    }

    /**
     * When binding to the service, we return an interface to our messenger
     * for sending messages to the service.
     */
    @Override
    public IBinder onBind(Intent intent) {
        return mMessenger.getBinder();
    }

    /**
     * Show a notification while this service is running. Note that
     * we don't include an intent since we're just a service here. The
     * service stops when the client tells it to.
     */
    private void showNotification(CharSequence text) {
        Notification notification = new NotificationCompat.Builder(this)
            .setContentTitle("MessengerService")
            .setContentText(text)
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .setTicker(text)
            .setOngoing(true)
            .build();

        mNM.notify(R.string.remote_service_started, notification);
    }

}

在此示例中,客户端向服务注册、向服务注销、发送简单消息或发送复杂消息。当客户端注册时,服务通过将传入的客户端 Messenger (即 msg.replyTo )保存在 mClients 中来记住它。如果收到一条简单的消息,服务会将收到的参数值的副本发送给所有已知的客户端。注意如何使用来自每个客户端的 mClients 中的 Messenger s 向每个客户端发送回复。消息的 what 字段只是一个 int,用来指示正在调用什么服务操作。基于 what 操作,服务将提取适当的参数。因为一个消息对象有两个可用的 int 参数,所以简单的例子只使用其中一个消息字段。当必须发送更复杂的数据时,会创建、填充 Bundle 对象,并将其附加到消息中,以便传输到服务。

请注意,对于所有正在进行的服务调用,服务有 1MB 的缓冲区用于传递数据(传入和传出),因此您希望将消息数据保持在最低限度。如果有许多并发的服务调用,您可能会超出缓冲区并获得 TransactionTooLargeException。

在客户端,有一个 MainActivity 和一个 ClientFrag(非 UI 片段)。为了简单起见,活动向用户提供 UI,而不使用 UI 片段。清单 14-27 显示了主活动。有关该项目的完整列表,请参见本章的 MessengerClient 项目。

清单 14-27基于信使/处理器的 客户端活动代码

public class MainActivity extends FragmentActivity implements ISampleServiceClient {

    protected static final String TAG = "MessengerClient";
    private TextView mCallbackText;
    private ClientFrag clientFrag;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mCallbackText = (TextView)findViewById(R.id.callback);

        // Get a non-UI fragment to handle the service interface.
        // If our activity gets destroyed and recreated, the fragment
        // will still be around and we just need to re-fetch it.
        if((clientFrag = (ClientFrag) getSupportFragmentManager()
                .findFragmentByTag("clientFrag")) == null) {
            updateStatus("Creating a clientFrag. No service yet.");
            clientFrag = ClientFrag.getInstance();
            getSupportFragmentManager().beginTransaction()
                .add(clientFrag, "clientFrag")
                .commit();
        }
        else {
            updateStatus("Found existing clientFrag, will use it");
        }
    }

    public void doClick(View view) {
        switch(view.getId()) {
        case R.id.startBtn:
            clientFrag.doBindService();
            break;
        case R.id.stopBtn:
            clientFrag.doUnbindService();
            break;
        case R.id.simpleBtn:
            clientFrag.doSendSimple();
            break;
        case R.id.complexBtn:
            clientFrag.doSendComplex();
            break;
        }
    }

    @Override
    public void updateStatus(String status) {
        mCallbackText.setText(status);
    }
}

注意这个活动中没有提到服务,只有一个文本视图、按钮和一个客户端片段。updateStatus()方法是此活动实现的 ISampleServiceClient 接口的结果,它所要做的就是将 UI 中的文本设置为传入的文本。按钮只是调用客户端片段的一个方法。在实际的应用中,在这个活动中或者在与服务调用分离的其他片段中会有更多的业务和 UI 逻辑。

客户端片段是有趣的地方。清单 14-28 显示了来自客户端片段的代码。

列表 14-28基于信使/处理器的 客户端片段代码

public class ClientFrag extends Fragment {
    private static final String TAG = "MessengerClientFrag";
    static private ClientFrag mClientFrag = null;
    // application context will be used to bind to the service because
    // fragments can't bind and activities can go away.
    private Context appContext = null;

    // Messenger for sending to service.
    Messenger mService = null;
    // Flag indicating whether we have called bind on the service.
    boolean mIsBound;

    // Instantiation method for the client fragment. We just want one
    // and we use setRetainInstance(true) so it hangs around during
    // configuration changes.
    public static ClientFrag getInstance() {
        if(mClientFrag == null) {
            mClientFrag = new ClientFrag();
            mClientFrag.setRetainInstance(true);
        }
        return mClientFrag;
    }

    // Handler for response messages from the service
    class IncomingHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MessengerService.MSG_SET_SIMPLE_VALUE:
                    updateStatus("Received from service: " + msg.arg1);
                    break;
                default:
                    break;
            }
            super.handleMessage(msg);
        }
    }

    // Need a Messenger to receive responses. Send this with the
    // Messages to the service.
    final Messenger mMessenger = new Messenger(new IncomingHandler());

    private ServiceConnection mConnection = new ServiceConnection() {
        public void onServiceConnected(ComponentName className,
                IBinder service) {
            // This is called when the connection with the service has been
            // established, giving us the service object we can use to
            // interact with the service.  We are communicating with our
            // service through a Messenger, so get a client-side
            // representation of that from the raw service object.
            mService = new Messenger(service);
            updateStatus("Attached.");

            // We want to monitor the service for as long as we are
            // connected to it. This is not strictly necessary. You
            // do not need to register with the service before using
            // it. But if this failed you'd have an early warning.
            try {
                Message msg = Message.obtain(null,
                        MessengerService.MSG_REGISTER_CLIENT);
                msg.replyTo = mMessenger;
                mService.send(msg);
            } catch (RemoteException e) {
                // In this case the service has crashed before we could even
                // do anything with it; we can count on soon being
                // disconnected (and then reconnected if it can be restarted)
                // so there is no need to do anything here.
                Log.e(TAG, "Could not establish a connection to the service: " + e);
            }
        }

        public void onServiceDisconnected(ComponentName className) {
            // This is called when the connection with the service has been
            // unexpectedly disconnected -- that is, its process crashed.
            mService = null;
            updateStatus("Disconnected.");
        }
    };

    public void doBindService() {
        // Establish a connection with the service. We use the String name
        // of the service since it exists in a separate process and we do
        // not want to require the service jar in the client. We also grab
        // the application context and bind the service to that since the
        // activity context could go away on a configuration change but the
        // application context will always be there.
        appContext = getActivity().getApplicationContext();
        if(mIsBound = appContext.bindService(
            new Intent("com.androidbook.messengerservice.MessengerService"),
                    mConnection, Context.BIND_AUTO_CREATE)
            ) {
            updateStatus("Bound to service.");
        }
        else {
            updateStatus("Bind attempt failed.");
        }
    }

    public void doUnbindService() {
        if (mIsBound) {
            // If we have received the service, and hence registered with
            // it, then now is the time to unregister. Note that the
            // replyTo value is only used by the service to unregister
            // this client. No response message will come back to the client.
            if (mService != null) {
                try {
                    Message msg = Message.obtain(null,
                            MessengerService.MSG_UNREGISTER_CLIENT);
                    msg.replyTo = mMessenger;
                    mService.send(msg);
                } catch (RemoteException e) {
                    // There is nothing special we need to do if the service
                    // has crashed.
                }
            }

            // Detach our existing connection.
            appContext.unbindService(mConnection);
            mIsBound = false;
            updateStatus("Unbound.");
        }
    }

    // If you can simplify and send only one or two integers, this
    // is the easy way to do it.
    public void doSendSimple() {
        try {
            Message msg = Message.obtain(null,
                MessengerService.MSG_SET_SIMPLE_VALUE, this.hashCode(), 0);
            mService.send(msg);
            updateStatus("Sending simple message.");
        } catch (RemoteException e) {
            Log.e(TAG, "Could not send a simple message to the service: " + e);
        }
    }

    // If you have more complex data, throw it into a Bundle and
    // add it to the Message. Can also pass Parcelables if you like.
    public void doSendComplex() {
        try {
            Message msg = Message.obtain(null,
                MessengerService.MSG_SET_COMPLEX_VALUE);
            Bundle mBundle = new Bundle();
            mBundle.putString("stringArg", "This is a string to pass");
            mBundle.putDouble("myDouble", 1138L);
            mBundle.putInt("myInt", 42);
            msg.setData(mBundle);
            mService.send(msg);
            updateStatus("Sending complex message.");
        } catch (RemoteException e) {
            Log.e(TAG, "Could not send a complex message to the service: " + e);
        }
    }

    private void updateStatus(String status) {
        // Make sure the latest status is updated in the GUI, which
        // is handled by the parent activity.
        ISampleServiceClient uiContext = (ISampleServiceClient) getActivity();
        if(uiContext != null) {
            uiContext.updateStatus(status);
        }
    }
}

客户端片段代码相当简单。当用户单击 Bind Service 按钮时,客户端片段绑定到远程服务并设置 ServiceConnection。绑定是从应用上下文中完成的。这是首选,因为片段不能绑定服务,但活动和应用可以。但是,因为活动可能在配置更改期间消失,所以最好绑定到始终存在的应用。当 ServiceConnection 被连接时,一个传出的 Messenger 被设置为向服务发送 MSG_REGISTER_CLIENT 注册客户端消息。客户端不等待服务的回复,而是返回等待用户的下一次交互。这可以防止可怕的 ANR 弹出窗口。按发送简单信息创建一条简单信息并发送。

对于一个简单的消息,服务会回复消息,由客户端的处理器接收并处理。客户端处理器所做的只是用从服务接收的值更新 TextView。请注意,客户端片段使用父活动的 ISampleServiceClient 接口来调用适当的方法来更新 UI。这是因为客户端片段是非 UI 的,我们不希望在其中嵌入 UI 逻辑。接口将客户端片段与活动分离开来,使得活动在配置更改期间离开和返回变得容易。按 Send Complex 创建一个消息,该消息带有一个包含几个不同值的包,该包被发送到服务。服务将在通知中使用 double 值来证明该值已从客户端正确传输到服务。该服务不会为复杂消息发送回复消息。

对于这种进程间服务调用机制,需要注意的一点是:服务的处理器是从传入消息队列开始工作的,因此默认情况下是单线程的。除非您自己创建一些线程,否则不会有多线程来处理传入的服务消息。因为客户端不会阻止来自服务的回复,所以如果服务需要一段时间来响应消息,客户端应用不会崩溃。然而,如果你的服务有多个客户端的话,你需要记住这一点。AIDL 服务可以更容易地同时处理请求,因此如果您需要更可预测的响应时间,它可能是更好的选择。

如果您的客户端向多个服务发送消息,您可以使用一个 Messenger/Handler 对来处理来自这些服务的回复消息。您只需将相同的 Messenger 放入每个出站消息中,每个服务就会回复。

另一件要注意的事情是,客户端不能保证服务会永远响应。信使/处理器交互没有固有的超时。如果服务终止,您将通过 onServiceDisconnected() 得到通知,但如果服务挂起或运行时间过长,您将不会得到通知。因此,为了确保服务及时响应,客户端可以选择设置一个计时器,或者设置一个闹钟来再次唤醒它。如果在计时器/闹钟响起之前回复回来,客户端处理器可以清除它。如果计时器/闹钟唤醒了客户端,这意味着服务花费了太长时间,客户端可以采取适当的措施。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

这一章是关于服务的,特别是:

  • 我们讨论了使用 Apache HttpClient 消费外部 HTTP 服务。
  • 关于使用 HttpClient ,我们向您展示了如何进行 HTTP GET 调用和 HTTP POST 调用。
  • 我们还向您展示了如何进行多部分发布。
  • 您了解了 SOAP 可以在 Android 上实现,但是它不是调用 web 服务的首选方式。
  • 我们讨论了如何设置一个 Internet 代理来代表您的应用从某个服务器管理 SOAP 服务,这样您的应用就可以对您的代理使用 RESTful 服务,并使应用更简单。
  • 然后,我们讨论了异常处理和应用可能遇到的异常类型(主要是超时)。
  • 您看到了如何使用 ThreadSafeClientConnManager 在您的应用中共享一个公共的 HttpClient 。
  • 您学习了如何检查和设置网络连接的超时值。
  • 我们讨论了连接 web 服务的几个选项,包括 HttpURLConnection 和 AndroidHttpClient 。
  • 我们解释了本地服务和远程服务之间的区别。本地服务是在与服务相同的流程中由组件(如活动)消费的服务。远程服务是其客户端在托管服务的进程之外的服务。
  • 您了解了即使一个服务应该在一个单独的线程上,它仍然由开发人员来创建和管理与服务相关联的后台线程。
  • 您了解了如何启动和停止本地服务,以及如何创建和绑定到远程服务。
  • 您看到了如何使用 NotificationManager 来跟踪正在运行的服务。
  • 我们讲述了如何使用复杂类型的 Parcelables 将数据传递给服务。
  • 您了解了如何使用信使和处理器来调用远程服务。*******

十五、高级异步任务和进度对话框

在第十三章中,我们介绍了处理器和工作线程来运行长时间运行的任务,同时主线程保持 UI 的有序。Android SDK 已经认识到这是一种模式,并将处理器和线程细节抽象成一个名为 AsyncTask 的工具类。您可以使用 AsyncTask 在 UI 环境中运行耗时超过 5 秒的任务。(我们将在第十六章的“长时间运行的接收器和服务”中讲述如何运行真正长时间运行的任务,从几分钟到几小时不等。)

本章将从一个异步任务 的基础开始,转到显示进度对话框和进度条所需的代码,这些对话框和进度条能正确显示一个异步任务的状态,即使设备改变了它的配置。让我们通过清单 15-1 中的伪代码来介绍异步任务。

清单 15-1 。活动对异步任务的使用模式

public class MyActivity  {
    void respondToMenuItem()    { //menu handler
       performALongTask();
    }
    void performALongTask()    { //using an AsyncTask
        //Derive from an AsyncTask, and Instantiate this AsyncTask
        MyLongTask myLongTask = new MyLongTask(...CallBackObjects...);
        myLongTask.execute(...someargs...); //start the work on a worker thread
        //have the main thread get back to its UI business
    }

    //Hear back from the AsyncTask
    void someCallBackFromAsyncTask(SomeParameterizedType x)    {
        //Although invoked by the AsyncTask this code runs on the main thread.
        //report back to the user of the progress
    }
}

使用一个 AsyncTask 首先从扩展 AsyncTask 开始,就像清单 15-1 中的 MyLongTask 一样。一旦实例化了 AsyncTask 对象,就可以对该对象调用 execute() 方法。 execute() 方法在内部启动一个单独的线程来完成实际的工作。 AsyncTask 实现将依次调用多个回调来报告任务的开始、任务的进度和任务的结束。清单 15-2 显示了扩展一个 AsyncTask 的伪代码以及需要被覆盖的方法。(请注意,这是伪代码,不打算编译。添加@override 注释是为了显式声明它们是从基类中重写的。)

清单 15-2 。扩展 AsyncTask:示例

public class MyLongTask extends AsyncTask<String,Integer,Integer> {
    //... constructors stuff
    //Calling execute() will result in calling all of these methods
    @Override
    void onPreExecute(){} //Runs on the main thread

    //This is where you do all the work and runs on the worker thread
    @Override
    Integer doInBackground(String... params){}

    //Runs on the main thread again once it finishes
    @Override
    void onPostExecute(Integer result){}

    //Runs on the main thread
    @Override
    void onProgressUpdate(Integer... progressValuesArray){}
    //....other methods
}

在主线程上调用清单 15-1 中的 execute() 方法。这个调用将触发清单 15-2 中的一系列方法,从 onPreExecute() 开始。在主线程上也调用了 onPreExecute() 。您可以使用此方法来设置执行任务的环境。您还可以使用此方法来设置一个对话框或启动一个进度条,以向用户指示工作已经开始。在完成 onPreExecute() 之后, execute() 方法将返回,活动的主线程继续其 UI 职责。到那时, execute() 将会产生一个新的工作线程,因此 doInBackground() 方法被调度在该工作线程上执行。在这个 doInBackground() 方法中,你将完成所有繁重的工作。因为这个方法运行在一个工作线程上,所以主线程不受影响,您也不会得到“应用没有响应”的消息。从 doInBackground() 方法中,您可以调用 onprogress update()来报告进度。这个 onProgressUpdate() 方法在主线程上运行,这样您就可以影响主线程上的 UI。

简单 AsyncTask 的要点

让我们进入扩展 AsyncTask 的细节。 AsyncTask 类使用泛型为其方法提供类型安全,包括被覆盖的方法。当您查看 AsyncTask 类的部分定义(清单 15-3 )时,您可以看到这些泛型。(请注意清单 15-3 是 AsyncTask 类的一个极其精简的版本。它实际上只是客户端代码最常用的接口元素。)

清单 15-3 。快速浏览一下 AsyncTask 类的定义

public class AsyncTask<Params, Progress, Result> {
    //A client will call this method
    AsyncTask<Params, Progress, Result>    execute(Params... params);

    //Do your work here. Frequently triggers onProgressUpdate()
    Result doInBackGround(Params... params);

    //Callback: After the work is complete
    void onPostExecute(Result result);

    //Callback: As the work is progressing
    void onProgressUpdate(Progress... progressValuesArray);
}

研究清单 15-3 ,可以看到 AsyncTask (通过泛型)在扩展时需要以下三个参数化类型( Params 、 Progress 和 Result )。让我们简单解释一下这些类型:

  • Params(execute()方法的参数类型):当扩展 AsyncTask 时,您需要指出您将传递给 execute() 方法的参数类型。如果您说您的 Params 类型是字符串,那么 execute() 方法将期望在它的调用中有任意数量的由逗号分隔的字符串,例如 execute(s1,s2,s3) 或 execute(s1,s2,s3,s4,s5) 。
  • Progress (进度回调方法的参数类型):该类型指示在通过回调 onProgressUpdate(Progress)报告进度时传递回调用者的值的数组...。传递进度值数组的能力允许对任务的多个方面进行监控和报告。例如,如果一个 AsyncTask 正在处理多个子任务,就可以使用这个特性。
  • Result (用于通过 onPostExecute() 方法报告结果的类型):该类型表示通过回调 onpost execute(Result final Result)作为执行的最终结果返回的返回值的类型。

现在知道了一个 AsyncTask 所需的泛型类型,假设我们为我们特定的 AsyncTask 决定了以下参数:Params :一个字符串,结果:一个 int,Progress :一个整数。然后,我们可以声明一个扩展的 AsyncTask 类,如清单 15-4 所示。

清单 15-4 。扩展通用 AsyncTask 到具体类型

public class MyLongTask
extends AsyncTask<String,Integer,Integer>
{
    //...other constructors stuff
    //...other methods
    //Concrete methods based on the parameterized types
    protected Integer doInBackground(String... params);
    protected void onPostExecute(Integer result);
    protected void onProgressUpdate(Integer... progressValuesArray);

    //....other methods
}

请注意清单 15-4 、 MyLongTask 中的这个具体类是如何消除类型名的歧义并得到类型安全的函数签名的。

实现您的第一个异步任务

现在让我们来看看一个简单但完整的 MyLongTask 的实现。我们已经充分注释了清单 15-5 中的代码,指出哪些方法运行在哪个线程上。还要注意 MyLongTask 的构造函数,它接收调用上下文(通常是一个活动)的对象引用,以及一个特定的简单接口,如 IReportBack 来记录进度消息。

IReportBack 接口对于您的理解并不重要,因为它只是一个日志的包装器。对于工具类也是如此。你可以在本章的两个可下载项目中看到这些额外的类。可下载项目的 URL 位于本章末尾的参考资料部分。清单 15-5 显示了 MyLongTask 的完整代码。

清单 15-5 。实现 AsyncTask 的完整源代码

//The following code is in MyLongTask.java (ProAndroid5_Ch15_TestAsyncTask.zip)
//Use menu item: Test Async1 to invoke this code
public class MyLongTask extends AsyncTask<String,Integer,Integer>
{
    IReportBack r; // an interface to report back log messages
    Context ctx;   //The activity to start a dialog
    public String tag = null;  //Debug tag
    ProgressDialog pd = null;  //To start, report, and stop a progress dialog

    //Constructor now
    MyLongTask(IReportBack inr, Context inCtx, String inTag)   {
        r = inr;  ctx = inCtx;  tag = inTag;
    }
    //Runs on the main ui thread
    protected void onPreExecute()    {
        Utils.logThreadSignature(this.tag);
        pd = ProgressDialog.show(ctx, "title", "In Progress...",true);
    }
    //Runs on the main ui thread. Triggered by publishProgress called multiple times
    protected void onProgressUpdate(Integer... progress)  {
        Utils.logThreadSignature(this.tag);
        Integer i = progress[0];
        r.reportBack(tag, "Progress:" + i.toString());
    }
    protected void onPostExecute(Integer result)     {
        //Runs on the main ui thread
        Utils.logThreadSignature(this.tag);
        r.reportBack(tag, "onPostExecute result:" + result);
        pd.cancel();
    }
    //Runs on a worker thread. May even be a pool if there are more tasks.
    protected Integer doInBackground(String...strings)    {
        Utils.logThreadSignature(this.tag);
        for(String s :strings)        {
            Log.d(tag, "Processing:" + s);
        }
        for (int i=0;i<3;i++)        {
            Utils.sleepForInSecs(2);
            publishProgress(i); //this calls onProgressUpdate
        }
        return 1; //this value is then passed to the onPostExecute as input
    }
}

在简要介绍了客户端如何使用(或调用) MyLongTask 之后,我们将深入研究清单 15-5 中强调的每一个方法的细节。

调用异步任务

一旦我们实现了类 MyLongTask ,客户端将会使用这个类,如清单 15-6 所示。

清单 15-6 。调用异步任务

//You will find this class AsyncTester.java(ProAndroid5_Ch15_TestAsyncTask.zip)
//Use menu item: Test Async1 to invoke this code
void respondToMenuItem() {
   //An interface to log some messages back to the activity
   //See downloadable project if you need the details.
   IReportBack reportBackObject = this;
   Context ctx = this;   //activity
   String tag = "Task1"; //debug tag

   //Instantiate and execute the long task
   MyLongTask mlt = new MyLongTask(reportBackObject,ctx,tag);
   mlt.execute("String1","String2","String3");
}

注意 execute() 方法是如何在清单 15-6 中被调用的。因为我们已经将其中一个泛型类型指定为一个字符串,并且 execute() 方法接受该类型的可变数量的参数,所以我们可以将任意数量的字符串传递给 execute() 方法。在清单 15-6 的例子中,我们传递了三个字符串参数。你可以根据需要或多或少地通过。

一旦我们在 AsyncTask 上调用了 execute() 方法,这将导致调用 onPreExecute() 方法,然后调用 doInBackground() 方法。一旦 doInBackground() 方法完成,系统也将调用 onPostExecute() 回调。关于这些方法是如何实现的,请参考清单 15-5 。

了解 onPreExecute()回调和进度对话框

回到 MyLongTask 实现在清单 15-5 中,在 onPreExecute() 方法中,我们启动了一个进度对话框来指示任务正在进行中。图 15-1 显示了该对话框的图像。(使用菜单项 Test Async1 从项目下载 pro Android 5 _ Ch15 _ testasync task . zip 调用该视图。)

9781430246800_Fig15-01.jpg

图 15-1 。与异步任务交互的简单进度对话框

显示进度对话框的代码段(取自清单 15-5 )在清单 15-7 中重现。

清单 15-7 。显示不确定的进度对话框

pd = ProgressDialog.show(ctx, "title", "In Progress...",true);

变量 pd 已经在构造函数中声明了(见清单 15-5 )。清单 15-7 中的这个调用将创建一个进度对话框,并显示如图 15-1 所示。清单 15-7 中 show() 方法的最后一个参数表示对话框是否不确定(对话框是否可以预先估计有多少工作量)。我们将在后面的章节中讨论确定性的情况。

注意可靠地显示异步任务的进度是相当复杂的。这是因为一个活动可以来来去去,要么是因为配置改变,要么是因为另一个 UI 优先。我们将在本章的后面讨论这个基本需求和解决方案。

了解 doInBackground()方法

由 AsyncTask 执行的所有后台工作都在 doInBackground() 方法中完成。这个方法由 AsyncTask 编排,在一个工作线程上运行。因此,这项工作被允许花费超过五秒钟,不像在主线程上完成的工作。

在清单 15-5 的例子中,在 doInBackground() 方法中,我们简单地检索任务的每个输入字符串,就好像它们是一个数组。在这个方法定义中,我们没有定义一个显式的字符串数组。然而,这个函数的单个参数被定义为变长参数,如清单 15-8 所示。

清单 15-8 。 doInBackground() 方法签名

protected Integer doInBackground(String...strings)

然后,Java 将参数视为函数内部的数组。因此,在我们的代码中的 doInBackground() 方法中,我们读取每个字符串并记录它们,以表明我们知道它们是什么。然后,我们等待足够长的时间来模拟长时间运行的操作。因为这个方法运行在一个工作线程中,所以我们不能从这个工作线程访问 Android 的 UI 功能。例如,你不能直接更新任何视图,即使你可以从这个线程访问它们。你甚至不能在这里发祝酒辞。接下来的两种方法可以让我们克服这个问题。

通过 publishProgress()触发 onProgressUpdate()

在 doInBackground() 方法中,可以通过调用 publishProgress() 方法来触发 onProgressUpdate() 。被触发的 onProgressUpdate() 方法然后在主线程上运行。这允许 onProgressUpdate() 方法适当地更新 UI 元素,比如视图。也可以从这里发祝酒辞。在清单 15-5 中,我们简单地记录了一条消息。一旦所有的工作都完成了,我们从 doInBackground() 方法返回一个结果代码。

了解 onPostExecute()方法

来自 doInBackground() 方法的结果代码然后被传递给 onPostExecute() 回调方法。这个回调也在主线程上执行。在这个方法中,我们告诉进度对话框关闭。在主线程上,您可以不受限制地访问该方法中的任何 UI 元素。

升级到确定性进度对话框

在清单 15-5 的的前一个例子中,我们使用了一个进度对话框(图 15-1 ),它并没有告诉我们工作的哪一部分已经完成。这个进度对话框称为不确定进度对话框。如果您在这个进度对话框中将不确定属性设置为 false ,您将看到一个进度对话框,它会逐步跟踪进度。如图 15-2 中的所示。(使用菜单项“Test Async2”从项目下载中调用该视图 pro Android 5 _ Ch15 _ testasynctask . zip .)

9781430246800_Fig15-02.jpg

图 15-2 。显示明确进度的进度对话框,与异步任务交互

清单 15-9 显示了来自清单 15-5 的前一个任务,它被重写以将进度对话框的行为改变为确定性进度对话框。我们还添加了一个 onCancelListener 来查看我们是否需要在取消对话框时取消任务。用户可以点击图 15-2 中的后退按钮取消对话框。代码的关键部分在清单 15-9 中给出(完整代码见下载文件 pro Android 5 _ Ch15 _ testasynctask . zip)。

清单 15-9 。利用确定性进度对话框的长任务

//Following code is in MyLongTask1.java(ProAndroid5_Ch15_TestAsyncTask.zip)
//Use menu item: Test Async2 to invoke this code
public class MyLongTask1 extends AsyncTask<String,Integer,Integer>
implements OnCancelListener
{
    //..other code taken from Listing 15-5
    //Also refer to the java class MyLongTask1.java in the downloadable project
    //for full code listing.
    protected void onPreExecute()    {
         //....other code
        pd = new ProgressDialog(ctx);
        pd.setTitle("title");
        pd.setMessage("In Progress...");
        pd.setCancelable(true);
        pd.setOnCancelListener(this);
        pd.setIndeterminate(false);
        pd.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
        pd.setMax(5);
        pd.show();
    }
    public void onCancel(DialogInterface d)    {
        r.reportBack(tag,"Cancel Called");
        this.cancel(true);
    }
    //..other code taken from Listing 15-5
}

注意我们是如何准备清单 15-9 中的进度对话框的。在这种情况下,我们没有使用静态方法 show() ,这与我们在进度对话框的清单 15-5 中所做的相反。相反,我们显式地实例化了进度对话框。变量 ctx 代表 UI 进度对话框运行的上下文(或活动)。然后我们单独设置对话框的属性,包括它的确定性或不确定性行为。方法 setMax() 表示进度对话框有多少步。当取消被触发时,我们还将自身引用( AsyncTask 本身)作为监听器传递。在取消回调中,我们在 AsyncTask 上显式发出一个取消。如果我们用布尔参数 false 调用 cancel() 方法,它将尝试停止工作线程。布尔参数真将强制停止工作线程。

异步任务和线程池

考虑清单 15-10 中的代码,其中一个菜单项一个接一个地调用两个 AsyncTasks 。

清单 15-10 。调用两个长期运行的任务

void respondToMenuItem() {
    MyLongTask mlt = new MyLongTask(this.mReportTo,this.mContext,"Task1");
    mlt.execute("String1","String2","String3");

    MyLongTask mlt1 = new MyLongTask(this.mReportTo,this.mContext,"Task2");
    mlt1.execute("String1","String2","String3");
}

这里我们在主线程上执行两个任务。你可能会认为这两项任务开始的时间很接近。但是,默认行为是,这些任务使用从线程池中抽出的单个线程按顺序运行。如果想要并行执行,可以在 AsyncTask 上使用 executeOnExecutor() 方法。有关此方法的详细信息,请参见 SDK 文档。同样根据 SDK 文档,在单个 AsyncTask 上多次调用 execute() 方法是无效的。如果您想要这种行为,您必须实例化一个新任务并再次调用 execute() 方法。

正确显示异步任务进度的问题和解决方案

如果你学习这一章的主要目标是学习 AsyncTask 的基本知识,那么我们到目前为止所学的已经足够了。然而,当一个 AsyncTask 与一个进度对话框配对时,会出现一些问题,如前面的清单所示。其中一个问题是当设备旋转时, AsyncTask 将丢失正确的活动参考,从而也丢失了它对进度对话框的参考。另一个问题是,我们在前面代码中使用的进度对话框不是托管对话框。现在让我们来理解这些问题。

处理活动指针和设备轮换

当由于配置改变而重新创建活动时,由 AsyncTask 持有的活动指针变得陈旧。这是因为 Android 创建了一个新的活动,旧的活动不再显示在屏幕上。因此,抓住旧的活动及其对应的对话框不放是不好的,原因有两个。首先,用户看不到异步任务试图更新的活动或对话。第二个原因是旧的活动需要进行垃圾收集,而您正在阻止它进行垃圾收集,因为 AsyncTask 正在保留它的引用。如果您聪明地对旧活动使用 Java 弱引用,那么您不会泄漏内存,但会得到一个空指针异常。陈旧指针不仅适用于活动指针,也适用于间接指向活动的任何其他指针。

有两种方法可以解决过时的活动引用问题。推荐的方法是使用无头保留片段。(片段在第八章有所涉及。保留片段是由于配置更改而重新创建活动时保留下来的片段。这些片段也被称为无头的,因为它们不一定要保存任何 UI。)解决陈旧活动指针的另一种方法是使用来自活动的保留对象回调。我们将介绍这两种解决陈旧活动指针问题的方法。

处理托管对话

即使我们能够解决过时的活动引用问题并重新建立与当前活动的连接,本章迄今为止使用进度对话框的方式仍存在缺陷。我们已经直接实例化了一个进度对话框。以这种方式创建的 ProgressDialog 不是一个“受管理的”对话框。如果它不是托管对话,则当设备经历旋转或任何其他配置更改时,该活动将不会重新创建该对话。因此,当设备旋转时,异步任务仍然不间断地运行,但是对话框不会显示。也有几种方法可以解决这个问题。建议不要使用进度对话框,而是在活动本身中使用嵌入式 UI 控件,比如进度条。因为进度条是活动视图层次结构的一部分,所以希望它能被重新创建。虽然进度条听起来不错,但有时模式进度对话框更有意义。例如,如果您不希望用户在 AsyncTask 运行时与活动的任何其他部分进行交互,就会出现这种情况。在这些情况下,我们发现使用片段对话框代替进度条并没有什么矛盾。

现在是我们进入解决方案来处理活动引用问题和托管对话框问题的时候了。我们将提出三种不同的解决方案。第一种使用保留对象和片段对话框。第二种使用了无头保留片段和片段对话框。第三种解决方案使用无头的保留片段和进度条。

行为良好的进度对话框的测试场景

在本章的三个解决方案中,无论您使用哪一个来正确显示 AsyncTask 的进度对话框,该解决方案都应该在以下所有测试场景中工作:

  1. 如果不改变方向,进度对话框必须开始,显示其进度,结束,并清除对 AsyncTask 的引用。这必须重复工作,以显示没有从以前的运行留下痕迹。
  2. 解决方案应该在任务执行过程中处理方向变化。旋转应该会重新创建对话框,并在它停止的地方显示进度。该对话框应正确结束并清除异步任务参考。这必须反复进行,以显示没有留下任何痕迹。
  3. 任务开始运行时,应禁用背面。
  4. 即使任务正在执行中,也应该允许回家。
  5. 回家重访活动要显示对话框,正确反映当前进度,进度绝对不能小于之前的进度。
  6. 当任务在返回之前完成时,回家并重新进行活动也应该有效。应该正确关闭该对话框,并删除 AsyncTask 引用。

这组测试用例应该总是为所有处理异步任务的活动执行。既然我们已经列出了每个解决方案应该如何满足,让我们从第一个解决方案开始,这个解决方案使用保留对象和片段对话框。

使用保留对象和片段对话框

在第一个解决方案中,让我们向您展示如何使用保留的对象和片段对话框来正确显示 AsyncTask 的进度。该解决方案包括以下步骤:

  1. 活动必须通过其 onRetainNonConfigurationInstance()回调来跟踪外部对象。这个外部对象必须保留下来,并且在活动关闭并返回时验证其引用。这就是这个对象被称为保留对象的原因。这个被保留的对象既可以是 AsyncTask 对象本身,也可以是一个保存对 AsyncTask 的引用的中间对象。让我们称之为根保留的活动相关对象(或根 RADO)。它被称为“根”是因为 onRetainNonConfigurationInstance()只能使用一个保留的对象引用。
  2. 然后,根 RADO 将有一个指向异步任务的指针,并且可以随着活动的到来和结束,设置和重置异步任务上的活动指针。因此,这个根 RADO 充当活动和 AsyncTask 之间的中介。
  3. 然后, AsyncTask 将实例化一个片段进度对话框,而不是一个普通的非托管进度对话框。 AsyncTask 将使用由根 RADO 设置的活动指针来完成这一任务,因为您将需要一个活动来创建一个包含片段对话框的片段。
  4. 活动将在对话片段旋转时重新创建对话片段,并适当地保持其状态,因为对话片段是受管理的。只要活动被设置并且可用, AsyncTask 就可以在片段对话框上增加进度。请注意,这个对话片段本身不是保留片段。它作为活动生命周期的一部分被重新创建。
  5. 片段对话框可以进一步禁止取消,这样当 AsyncTask 正在进行时,用户不能从对话框返回到活动。
  6. 然而,用户可以通过点击 Home 并使用其他应用来回家。这将把我们的活动以及与之相关的对话推到后台。这件事必须处理。当用户返回到活动或应用时,对话框可以继续显示进度。如果任务在活动隐藏时完成,AsyncTask 必须知道如何关闭片段对话框。作为一个片段对话框,如果活动不在前台,关闭此对话框将引发无效状态异常。因此,AsyncTask 必须等到活动重新打开并处于正确的状态时才能关闭对话框。

探索相应的关键代码片段

我们现在将展示用于实现所概述的方法的关键代码片段。其余的实现可以在本章的可下载项目 proandroid 5 _ Ch15 _ testasynctaskswithconfigchanges . zip 中找到。由于这个问题的所有解决方案都要求对话框是一个片段对话框,以便可以管理这个对话框,清单 15-11 首先给出了这个片段对话框的源代码。

清单 15-11。将 ProgressDialog 封装在 DialogFragment 中

//The following code is in ProgressDialogFragment.java
//(ProAndroid5_Ch15_TestAsyncTaskWithConfigChanges.zip)
/**
 * A DialogFragment that encapsulates a ProgressDialog.
 * This is not expected to be a retained fragment dialog.
 * Gets re-created as activity rotates following any fragment protocol.
 */
public class ProgressDialogFragment extends DialogFragment  {
    private static String tag = "ProgressDialogFragment";
    ProgressDialog pd; //Will be set by onCreateDialog

    //This gets called from ADOs such as retained fragments
    //typically done when activity is attached back to the AsyncTask
    private IFragmentDialogCallbacks fdc;
    public void setDialogFragmentCallbacks(IFragmentDialogCallbacks infdc)  {
        Log.d(tag, "attaching dialog callbacks");
        fdc = infdc;
    }

    //This is a default constructor. Called by the framework all the time
    //for reintroduction.
    public ProgressDialogFragment()    {
        //Should be safe for me to set cancelable as false;
        //wonder if that is carried through rebirth?
        this.setCancelable(false);
    }
    //One way for the client to attach in the beginning when the fragment is reborn.
    //The reattachment is done through setFragmentDialogCallbacks
    //This is a shortcut. Your compiler if enabled for lint may throw an error.
    //You can use the newInstance pattern and setbundle (see the fragments chapter)
    public ProgressDialogFragment(IFragmentDialogCallbacks infdc)    {
        this.fdc = infdc;
        this.setCancelable(false);
    }
    /**
     * This can get called multiple times each time the fragment is
     * re-created. So storing the dialog reference in a local variable should be safe
     */
    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState)    {
        Log.d(tag,"In onCreateDialog");
        pd = new ProgressDialog(getActivity());
        pd.setTitle("title");
        pd.setMessage("In Progress...");
        pd.setIndeterminate(false);
        pd.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
        pd.setMax(15);
        return pd;
    }
    //Called when the dialog is dismissed.I should tell my corresponding task
    //to close or do the right thing! This is done through call back to fdc
    //fdc: fragment dialog callbacks could be the Task, or Activity or the rootRADO
    //See Listing 15-12 to see how FDC is implemented by the task
    @Override
    public void onDismiss(DialogInterface dialog)    {
        super.onDismiss(dialog);
        Log.d(tag,"Dialog dismissed");
        if (fdc != null)        {
            fdc.onDismiss(this, dialog);
        }
    }
    @Override
    public void onCancel(DialogInterface dialog)    {
        super.onDismiss(dialog);
        Log.d(tag,"Dialog cancelled");
        if (fdc != null)    {
            fdc.onCancel(this, dialog);
        }
    }
    //will be called by a client like the task
    public void setProgress(int value)    {
        pd.setProgress(value);
    }
}

清单 15-11 中的代码展示了如何将一个常规的非托管 ProgressDialog 包装在一个托管片段对话框中。我们扩展一个 DialogFragment 并覆盖它的 onCreateDialog() 以返回 ProgressDialog 对象。除了这个基本特性之外,我们还增加了监视进度对话框何时关闭或取消的功能。我们还在包装的类上提供了一个 setProgress() 方法来调用内部 ProgressDialog 上的 setProgress() 。您可以在可下载的项目(proandroid 5 _ Ch15 _ testasynctaskwithconfigchanges . zip)中看到 IFragmentDialogCallbacks 的源代码,因为它对于理解这个片段进度对话框并不重要。

现在让我们看看 AsyncTask 如何创建和控制这个片段进度对话框。为了帮助理解,清单 15-12 给出了异步任务的伪代码。有关完整的源代码,请参考可下载的项目。

清单 15-12 。使用片段进度对话框的 AsyncTask 的伪代码

//The following code is in MyLongTaskWithRADO.java
//(ProAndroid5_Ch15_TestAsyncTaskWithConfigChanges.zip)
//You can start this task through menu item: Flip Dialog with ADOs
public class MyLongTaskWithRADO extends AsyncTask<String,Integer,Integer>
implements IRetainedADO, IFragmentDialogCallbacks
{
    //....other code
    @Override public void onPreExecute()    {
        //....other code
        //get the activity as it would have been set by the root RADO
        Activity act = this.getActivity();

        //Create the progress diaolg
        ProgressDialogFragment pdf = new ProgressDialogFragment();
        //the show method will add and commit the fragment dialog
        pdf.show(act.getFragmentManager(), this.PROGRESS_DIALOG_FRAGMENT_TAG_NAME);
    }
    @Override public void onProgressUpdate()    {
        //if activity is available, get the fragment dialog from it
        //call setProgress() on it
        //otherwise ignore the progress
    }
    @Override public void onPostExecute()    {
        //if activity is in a good state
        //dismiss the dialog and tell the root RADO to drop the pointer to the AsyncTask
        //if not remember it through a flag to close it when you come back
    }
    @Override public void attach()    {
        //called when the activity is back
        //check to see if you are done
        //if so dismiss the dialog and remove yourself from the RADO
        //if not continue to update the progress
    }
}

因为这个 AsyncTask 实现了保留的活动相关对象( IRetainedADO )的思想,它知道活动何时可用,何时不可用。它还知道活动的状态,比如 UI 是否准备好了。尽管实现活动相关对象(ado)需要一些代码,但这并不是一个难懂的概念。出于篇幅考虑,我们将这个问题留给您来研究可下载的项目 pro Android 5 _ Ch15 _ testasynktaskwithconfigchanges。zip 看看这是怎么做到的。

这个清单 15-12 中的 AsyncTask 也接管了它的片段对话框的管理,这样它就像一个内聚的单元,从而不会因为这个 AsyncTask 的细节而污染主活动。清单 15-12 中的另一个关键细节是当 AsyncTask 结束时对话框关闭时会发生什么。此时,如果活动是隐藏的,或者由于旋转而不存在,那么在重新创建活动时关闭对话框是很重要的。为此, onPostExecute() 会记住 AsyncTask 的最后状态,无论它是已完成还是正在进行。这个 AsyncTask 然后等待 attach() 方法,当 UI 就绪活动被重新附加到这个 ADO 时,该方法被调用。一旦进入 attach() 方法, AsyncTask 就可以关闭片段对话框。

你可以下载名为 pro Android 5 _ Ch15 _ testasynctaskswithconfigchanges . zip 的项目,看看清单 15-12 中呈现的交互是如何完全实现的。

与使用保留片段相比,这种使用保留对象的特殊方法有点复杂。但它的优雅之处在于,使用 ADOs 的思想以更通用的形式解决了这个问题,不管它们是片段还是其他。我们在参考资料部分提供了概述这一想法并提供背景的链接。至此,让我们把注意力转向我们的第二个解决方案中推荐的保留片段的想法。

使用保留片段和片段对话框

在第二个解决方案中,我们将坚持使用片段对话框,但是我们将使用无头保留片段,而不是简单的保留对象。Android 不赞成保留对象,而支持保留片段。在 Android 中,保留的对象只是一个对象,没有跟踪活动状态的内置功能。(这就是为什么我们必须在顶层发明 ADOs 的框架。)随着 Android 后续版本中片段的引入,这一缺陷不复存在。尽管片段紧密地编织在 UI 的结构中,但是它们也可以在没有 UI 的情况下存在。这些被称为无头片段。除了能够跟踪活动的状态,片段也可以被保留,就像保留的对象一样。

概述保留片段的方法

这个解决方案中的方法是使用一个无头的保留片段作为锚,在活动和异步任务之间进行通信。这种方法的主要方面如下:

  1. 继续使用片段进度对话框,就像之前的解决方案一样。
  2. 让活动创建一个无头保留片段,该片段保存一个指向 AsyncTask 的指针。这个无头的保留片段取代了前面解决方案中的保留对象。作为一个保留的片段,当用一个新的指针重新创建活动时,片段对象仍然存在。然后,AsyncTask 总是依赖保留的片段来检索最新的活动指针。
  3. AsyncTask 依赖于被告知活动状态的无头保留片段来完成前面解决方案中指出的所有测试用例。

探索相应的关键代码片段

在之前的解决方案中,我们已经向您展示了片段对话框的代码。由于我们在这个解决方案中继续使用同一个对象,我们将关注保留片段,以及 AsyncTask 如何通过保留片段使用片段对话框。

在我们在下载中提供的示例程序(proandroid 5 _ Ch15 _ testasynctaskwithconfigchanges . zip)中,我们将保留片段称为 AsyncTesterFragment 。清单 15-13 显示了这个类的伪代码,它展示了,除了别的以外,是什么使这个类成为一个无头片段。

清单 15-13 。无头片段的伪代码

//The following code is in AsyncTesterFragment.java
//(ProAndroid5_Ch15_TestAsyncTaskWithConfigChanges.zip)
//You can start this task through menu item: Flip Dialog with Fragment
public class AsyncTesterFragment

扩展片段(或从片段派生的另一个对象){

   //No need to override the key onCreateView() method
   //which otherwise would have returned a view loaded from a layout.
   //Thus having no View makes this fragment a headless fragment

   //Use this name to register with the activity
   public static String FRAGMENT_NAME="AsyncTesterRetainedFragment";

   //Local variable for the asynctask. You can use a menu to start work on this task
   //Nullify this reference when the asynctask finishes
   MyLongTaskWithFragmentDialog taskReference;

   //Have an init method to help with inheritance
   public void init(arg1, arge2, etc) {
       super.init(arg1,...); //if there is one
       setArguments(....); //or pass the bundle to the super init
   }
   public static AsyncTesterFragment newInstance(arg1, arg2, ...){
       AsyncTesterFragment f = new AsyncTesterFragment();
       f.init(arg1,arg2,...);
   }
   //have more static methods to create the fragment, locate the fragment etc.
}

清单 15-13 中的代码有三点值得一提。由于没有覆盖 onCreateView() ,这个片段变成了一个无头片段。因为片段是使用默认构造函数重新创建的,所以我们遵循了 newInstance() 模式,并扩展了该模式以使用 init() 方法,这些方法可以是虚拟的,也可以是继承的。如果您在更深层次中扩展片段类,后一种方法是有用的。

清单 15-14 显示了这个 AsyncTesterFragment 对象上的一个静态方法,它可以创建这个片段,让它保持它的状态,然后用活动注册它。

清单 15-14 。将片段注册为保留片段

//The following code is in AsyncTesterFragment.java
//(ProAndroid5_Ch15_TestAsyncTaskWithConfigChanges.zip)
//You can start this task through menu item: Flip Dialog with Fragment
public static AsyncTesterFragment createRetainedAsyncTesterFragment(Activity act) {
    AsyncTesterFragment frag = AsyncTesterFragment.newInstance();
    frag.setRetainInstance(true);
    FragmentManager fm = act.getFragmentManager();
    FragmentTransaction ft = fm.beginTransaction();
    ft.add(frag, AsyncTesterFragment.FRAGMENT_TAG);
    ft.commit();
    return frag;
}

一旦这个保留的片段在活动中可用,它可以在任何时候被检索,并被要求启动一个 AsyncTask 。清单 15-15 显示了 AsyncTask 的伪代码,它能够与这个保留的片段交互来控制片段对话框

清单 15-15 。通过保留片段使用片段对话的异步任务

//The following code is in MyLongTaskWithFragment.java
//(ProAndroid5_Ch15_TestAsyncTaskWithConfigChanges.zip)
//You can start this task through menu item: Flip Dialog with Fragment
public class MyLongTaskWithFragment extends AsyncTask<String,Integer,Integer> {
    //...other code
    //The following reference passed in and set from the constructor
    AsyncTesterFragment retainedFragment;

    //....other code
    @Override protected void onPreExecute()    {
        ....other code
        //get the activity from the retained fragment
        Activity act = retainedFragment.getActivity();
        //Create the progress dialog
        ProgressDialogFragment pdf = new ProgressDialogFragment();
        //the show method will add and commit the fragment dialog
        pdf.show(act.getFragmentManager(), this.PROGRESS_DIALOG_FRAGMENT_TAG_NAME);
    }
    @Override protected void onProgressUpdate()    {
        //if activity is available, get the fragment dialog from it, call setProgress() on it
        //otherwise ignore the progress
    }
    @Override protected void onPostExecute()    {
        //if activity is in a good state
        //dismiss the dialog and tell the root RADO to drop the pointer to the AsyncTask
        //if not remember it through a flag to close it when you come back
    }
    @Override public void attach()    {
        //called when the activity is back. check to see if this task is done
        //if so dismiss the dialog and remove yourself from the retained fragment
        //if not continue to update the progress
    }
    @Override protected Integer doInBackground(String...strings)
    {
       //Do the actual work here which occurs on a separate thread
    }
}

清单 15-15 中的 AsyncTask 的行为很像使用保留对象的 AsyncTask。一旦这个任务知道了如何从保留的片段中访问进度对话框片段,设置进度就非常简单了。和以前一样,这个任务也需要知道活动何时被重新附加,以防任务提前完成。如果发生这种情况, AsyncTask 需要记住这一点,并在重新挂接时关闭对话框。清单 15-15 中的伪代码满足了前面列出的所有测试条件。

这就结束了我们的第二个解决方案。现在让我们转到第三个解决方案,我们将使用进度条而不是进度对话框来显示一个 AsyncTask 的进度。

使用保留的片段和进度条

关于 progress dialog()的 Android SDK 文档建议我们在许多场景中使用 ProgressBar 作为更好的实践。据称的原因是进度条不太打扰,因为它允许与活动的其他区域进行交互。像进度对话框一样,进度条的持续时间可以是不确定的,也可以是固定的。也可以是连续旋转的圆,也可以是单杠。您可以通过查找进度条的文档找到这些模式。清单 15-16 给出了一个布局文件中进度条样式的快速纲要。

清单 15-16 。在布局文件中设置进度条样式的不同方法

//The following code is in spb_show_progressbars_activity_layout.xml
//(ProAndroid5_Ch15_TestAsyncTaskWithConfigChanges.zip)
//You can see these progress bars through menu item: Show Progress bars
<!--  A regular progress bar - A large spinning circle -->
<ProgressBar
    android:id="@+id/tpb_progressBar1"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@android:color/background_light"/>

<!--  Small spinning circle -->
<ProgressBar
    android:id="@+id/tpb_progressBar4"
    style="?android:attr/progressBarStyleSmall"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@android:color/background_light"/>

<!--  Horizontal indefinite Progress bar: a line -->
<ProgressBar
    android:id="@+id/tpb_progressBar3"
    style="?android:attr/progressBarStyleHorizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:indeterminate="true"
    />

 <!--  Horizontal fixed duration Progress bar: a line -->
 <ProgressBar
    android:id="@+id/tpb_progressBar3"
    style="?android:attr/progressBarStyleHorizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:indeterminate="false"
    android:max="50"
    android:progress="10"
    />

图 15-3 显示了清单 15-16 中显示的进度条布局在加载到活动中时的样子。每种类型的进度条都有标签来指示其模式或行为。(使用菜单项显示进度条从项目下载 proandroid 5 _ Ch15 _ testasynktaskswithconfigchanges . zip .调用该视图)

9781430246800_Fig15-03.jpg

图 15-3 。安卓进度条示例

概述 ProgressBar 方法

通过进度条报告异步任务进度的方法类似于之前使用保留的无头片段和片段进度对话框的方法。

  1. 与前面的解决方案一样,让活动创建一个无头保留片段,该片段保存一个指向 AsyncTask 的指针。
  2. 在活动布局中嵌入进度条。 AsyncTask 会通过无头保留片段到达这个进度条。
  3. AsyncTask 依赖于被告知活动状态的无头保留片段来完成前面指出的所有测试用例。

遍历相应的关键代码片段

让我们浏览一下让这个解决方案工作所需的关键代码片段。让我们从局部变量开始, AsyncTask 持有这些变量来与保留的片段和活动进行交互(清单 15-17 )。

清单 15-17。AsyncTask 的局部变量使用 ProgressBar

//The following code is in MyLongTaskWithProgressBar.java
//(ProAndroid5_Ch15_TestAsyncTaskWithConfigChanges.zip)
//You can start this task through menu item: Test ProgressBar
public class MyLongTaskWithProgressBar
extends AsyncTask<String,Integer,Integer>
implements IWorkerObject
{
    public String tag = null;    //Debug tag
    private MonitoredFragment retainedFragment; //Reference to the retained fragment
    int curProgress = 0; //To track current progress
....

清单 15-18 显示了 AsyncTask 启动时如何初始化进度条。

清单 15-18 。初始化进度条

//Part of MyLongTaskWithProgressBar.java
private void showProgressBar()    {
    Activity act = retainedFragment.getActivity();
    ProgressBar pb = (ProgressBar) act.findViewById(R.id.tpb_progressBar1);
    pb.setProgress(0);
    pb.setMax(15);
    pb.setVisibility(View.VISIBLE);
}

清单 15-19 显示了 AsyncTask 在定位后如何在进度条上设置进度。

清单 15-19 。在进度条上设置进度

//Part of MyLongTaskWithProgressBar.java
private void setProgressOnProgressBar(int i) {
    this.curProgress = i;
    ProgressBar pbar = getProgressBar();
    if (pbar == null)   {
        Log.d(tag, "Activity is not available to set progress");
        return;
    }
    pbar.setProgress(i);
}

定位活动的方法 getProgressBar() 相当简单;您只需使用 find() 方法来定位进度条视图。如果活动由于设备旋转而不可用,则进度条引用将为空,我们将忽略设置进度。清单 15-20 显示了 AsyncTask 如何关闭进度条。

清单 15-20 。异步任务完成时关闭进度条

//Part of MyLongTaskWithProgressBar.java
private void closeProgressBar(){
    ProgressBar pbar = getProgressBar();
    if (pbar == null)    {
        Log.d(tag, "Sorry progress bar is null to close it!");
        return;
    }
    //Dismiss the dialog
    pbar.setVisibility(View.GONE);
    detachFromParent();
}

一旦 ProgresBar 从视图中移除,清单 15-20 中的代码通知保留的片段,它可以释放 AsyncTask 指针,如果它持有它的话。根据保留的片段如何保存这个指针,这个步骤可能需要,也可能不需要。但是告诉父类它不再需要保留它不再需要的引用是一个好的做法。因此,清单 15-21 展示了 AsyncTask 如何通知父进程它不再需要持有一个指向 AsyncTask 的指针。

清单 15-21 。像保留片段一样,通知客户端 AsyncTask 的完成

//To tell the called object that I, the AsyncTask, have finished
//The Activity or retained fragment can act as a client to this AsyncTask
//AsyncTask is imagined to be a WorkerObject and hence understands the IWorkerObjectClient

//MyLongTaskWithProgressBar implements IWorkerObject
//AsyncTesterFragment implements the IWorkerObjectClient

//Code below is taken from MyLongTaskWithProgressBar.java
//This implements the IWorkerObject contract
IWorkerObjectClient client = null;
int workerObjectPassbackIdentifier = -1;

public void registerClient(IWorkerObjectClient woc,
     int inWorkerObjectPassbackIdentifier) {
    client = woc;
    this.workerObjectPassbackIdentifier = inWorkerObjectPassbackIdentifier;
}
private void detachFromParent()    {
    if (client == null)        {
        Log.e(tag,"You have failed to register a client.");
        return;
    }
    //client is available
    client.done(this,workerObjectPassbackIdentifier);
}

利用 ProgressBar 解决方案解决关键差异

当我们使用进度条而不是进度对话框时,有一些意想不到的差异你必须知道。

最初,在布局文件中,进度条的可见性被设置为消失,从而表示异步任务甚至还没有开始的状态。一旦 AsyncTask 开始,它会将可见性设置为可见,并随后设置进度。然而,当重新创建活动时,活动的状态管理要求从 onCreate() 方法出来的控件是可见的。因为在布局中可见性被设置为消失,所以该活动不会恢复进度条状态,并且当设备旋转时您将看不到进度条。因此, AsyncTask 需要接管这个进度条状态管理的控制权,并在活动被重新附加时正确地重新初始化它。清单 15-22 展示了我们如何在 AsyncTask 代码中实现这一点。

清单 15-22 。从 AsyncTask 管理 ProgressBar 状态

//Taken from MyLongTaskWithProgressBar.java
//On activity start
public void onStart(Activity act) {
    //dismiss dialog if needed
    if (bDoneFlag == true)        {
        Log.d(tag,"On my start I notice I was done earlier");
        closeProgressBar();
        return;
    }
    Log.d(tag,"I am reattached. I am not done");
    setProgressBarRightOnReattach();
}
private void setProgressBarRightOnReattach()    {
    ProgressBar pb = getProgressBar();
    pb.setMax(15);
    pb.setProgress(curProgress);
    pb.setVisibility(View.VISIBLE);
}

清单 15-22 中的 onStart() 方法由 AsyncTask 上的保留片段调用,此时活动被重新附加到保留片段,并且该片段检测到活动的 UI 已经准备好被使用。

使用进度条的另一个区别是后退按钮的行为。与进度对话框不同,对于活动,您可能希望允许后退按钮。由于“后退”按钮完全删除了该活动,您可能希望借此机会取消该任务。清单 15-23 中的 releaseResources() 方法被保留的片段调用,当它通过监视 onDestroy() 方法中的 isFinishing() 标志检测到活动不会返回时。

清单 15-23 。取消活动返回的异步任务

//Taken from MyLongTaskWithProgressBar.java
public void releaseResources()    {
    cancel(true); //cancel the task
    detachFromParent(); //remove myself
}

本章后半部分概述的所有三种解决方案都将正确显示一个 AsyncTask 的进度。SDK 推荐的方法是使用进度条作为正确的 UI 组件来显示进度。我们对于只需要一两秒钟的快速任务的偏好是使用进度条。对于一个需要更长时间的任务——并且你不希望用户扰乱 UI 的状态——那么使用 ProgressDialog 和一个 headless retained 片段。当您的解决方案需要深层次的对象时,无论您是通过保留的片段还是通过保留的对象来使用 ADO 框架,使用 ADO 框架都会很方便。您可以在可下载的项目 pro Android 5 _ Ch15 _ testasynctaskswithconfigchanges . zip 中看到这里概述的所有解决方案的完整实现。

如果 AsyncTask 正在更新和改变状态,还需要进一步考虑。如果是这种情况,您可能希望使用后台服务,以便在进程被回收并在以后重新启动时可以重新启动它。这里介绍的方法对于快速到中等水平的阅读是足够的,因为您希望用户等待。但是,对于较长时间的读取或写入,您可能希望采用基于服务的解决方案。

参考

以下参考资料将帮助您了解本章中讨论的主题的更多信息:

  • developer . Android . com/reference/Android/OS/async task . html:明确记录 AsyncTask 行为的关键资源。
  • :再看一个乖巧的 AsyncTask 。
  • :我们在准备本章时收集的关于 AsyncTask 的研究笔记。
  • :Android 在其 API 中经常使用 Java 泛型。这个 URL 记录了 Java 泛型的一些基础知识,可以帮助您入门。
  • :正如本章所展示的,要权威性地使用 AsyncTask 你需要了解很多关于活动生命周期、片段、它们的生命周期、无头片段、配置变更、片段对话框、AsyncTask、ADOs 等等。这个网址上有许多关注这些领域的文章。
  • :ADO 是一种抽象,我们的一位作者支持它作为处理配置变更的便捷工具。这个 URL 记录了 ado 是什么以及如何使用它们,并且还提供了一个初步的实现。
  • :这个 URL 记录了使用 ProgressBar 的背景、有用的 URL、代码片段和有用的提示。
  • :这个 URL 对配置发生变化时的活动生命周期有很好的研究。
  • :要写出在设备旋转时运行良好的程序是相当困难的。这个 URL 概述了一些基本的测试案例,您必须成功运行这些案例来验证 AsyncTask。
  • 【http://www.androidbook.com/item/4673】:这个 URL 建议使用一种增强的模式来构造继承片段。
  • :了解一个片段,包括一个留存的片段,最好的方法就是用心研究它的回调。这个 URL 提供了片段的所有重要回调的文档样本代码。
  • :了解一个活动生命周期的最好方法是努力研究它的回调。这个 URL 提供了所有重要活动回调的文档样本代码。
  • :这个 URL 概述了我们对片段对话框的研究。
  • :这本书的可下载项目列表在这个 URL。对于这一章,寻找一个名为 proandroid 5 _ Ch15 _ testasynctask . zip 和 proandroid 5 _ Ch15 _ testasynctaskswithconfigchanges 的 zip 文件。后一个 zip 文件实现了行为良好的 AsyncTask 的三个解决方案。

摘要

在这一章中,除了介绍 AsyncTask 之外,我们还介绍了进度对话框、进度条、无头保留片段和 ado。阅读这一章,你不仅理解了 AsyncTask ,还能运用你对活动生命周期的理解和对片段的深刻理解。我们还记录了一组关键的测试案例,一个表现良好的 Android 应用必须满足这些案例。**

十六、广播接收器和长期服务

除了活动、内容提供者和服务,广播接收器是 Android 流程中的另一个组件。广播接收器是可以响应客户端发送的广播消息的组件。该消息被建模为意图。此外,广播消息(intent)可以由一个以上的接收器来响应。

活动或服务等客户端组件使用上下文类中可用的 sendBroadcast(intent) 方法来发送广播。广播意图的接收组件将需要从 Android SDK 中可用的 BroadcastReceiver 类继承。这些广播接收器需要通过一个 receiver 组件标签在清单文件中注册,以表明接收器对响应某种类型的广播意图感兴趣。

发送广播

清单 16-1 显示了发送广播事件的示例代码。这段代码创建了一个具有唯一意图动作字符串的意图,在其上放置了一个名为消息的额外字段,并调用 sendBroadcast() 方法。将额外的放在目标上是可选的。

清单 16-1 。传播意图

//This code is in class: TestBCRActivity.java
//Project: TestBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
private void testSendBroadcast(Activity activity) {
    //Create an intent with a unique action string
    String uniqueActionString = "com.androidbook.intents.testbc";
    Intent broadcastIntent = new Intent(uniqueActionString);

    //Allow stand alone cross-processes that have broadcast receivers
    //in them to be started even though they are in stopped state.
    broadcastIntent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES);

    broadcastIntent.putExtra("message", "Hello world");
    activity.sendBroadcast(broadcastIntent);
}

在清单 16-1 中,动作是一个满足您需求的任意标识符。为了使这个动作字符串唯一,您可能希望使用一个类似于 Java 包的名称空间。此外,我们将在本章后面的“进程外接收者”一节中讨论跨进程 FLAG _ INCLUDE _ STOPPED _ PACKAGES

编码一个简单的接收器

清单 16-2 显示了一个广播接收器,它可以响应来自清单 16-1 的广播意图。

清单 16-2 。示例广播接收器代码

//This class is in TestBroadcastReceiver project in the download
//The download for this chapter is: ProAndroid5_Ch16_TestReceivers.zip
public class TestReceiver extends BroadcastReceiver {
    private static final String tag = "TestReceiver";
    @Override
    public void onReceive(Context context, Intent intent)     {
        Log.d("TestReceiver", "intent=" + intent);
        String message = intent.getStringExtra("message");
        Log.d(tag, message);
    }
}

创建一个广播接收器非常简单。扩展 BroadcastReceiver 类并覆盖 onReceive() 方法。我们能够看到接收者的意图并从中提取信息。接下来,我们需要在清单文件中将广播接收器注册为接收器。

在清单文件中注册接收方

清单 16-3 展示了如何将一个接收者声明为意图的接收者,其动作是 com . androidbook . intents . testbc。

清单 16-3 。清单文件中的接收器定义

<!--
In filename: AndroidManifest.xml
Project: TestBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
-->
<manifest>
<application>
...
<activity>...</activity>
...
<receiver android:name=".TestReceiver">
    <intent-filter>
        <action android:name="com.androidbook.intents.testbc"/>
    </intent-filter>
</receiver>
...
</application>
</manifest>

与活动等其他组件节点一样, receiver 元素是应用元素的子节点。

有了接收者(清单 16-2 )及其在清单文件(清单 16-3 )中的注册,您可以使用清单 16-1 中的客户端代码来调用接收者。我们已经在本章末尾提供了本章的可下载 ZIP 文件 pro Android 5 _ Ch16 _ test receivers . ZIP 的参考。这个 ZIP 文件有两个项目。到目前为止引用的代码在项目 TestBroadcastReceiver 中。

容纳多个接收器

广播的概念是可以有一个以上的接收器。让我们将 TestReceiver (参见清单 16-2 )复制为 TestReceiver2 ,看看两者是否能够响应相同的广播消息。 TestReceiver2 的代码在清单 16-4 中给出。

清单 16-4 。测试接收 2 的源代码

//Filename: TestReceiver2.java
//Project: TestBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
public class TestReceiver2 extends BroadcastReceiver {
    private static final String tag = "TestReceiver2";
    @Override
    public void onReceive(Context context, Intent intent)     {
        Log.d(tag, "intent=" + intent);
        String message = intent.getStringExtra("message");
        Log.d(tag, message);
    }
}

将这个接收者添加到您的清单文件中,如清单 16-5 所示。

清单 16-5 。清单文件中的 TestReceiver2 定义

<!--
In filename: AndroidManifest.xml
Project: TestBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
-->
<receiver android:name=".TestReceiver2">
    <intent-filter>
        <action android:name="com.androidbook.intents.testbc"/>
    </intent-filter>
</receiver>

现在,如果你像清单 16-1 中的那样启动事件,两个接收者都将被调用。

我们已经在第十三章中指出,主线程运行所有属于单个进程的广播接收器。您可以通过打印出每个接收器中的线程签名来证明这一点,包括主线调用代码。您将看到相同的线程按顺序运行这段代码。 sendBroadcast() 对广播消息进行排队,并让主线程返回其队列。接收者对这个排队消息的响应是由同一个主线程按顺序执行的。当有多个接收者时,依赖于执行顺序来决定首先调用哪个接收者并不是一个好的设计。

使用进程外接收器

广播的意图更可能是响应它的进程是未知的,并且与客户端进程分离。你可以通过复制一个你目前展示的接收器并创建一个单独的来证明这一点。apk 文件。然后,当您从清单 16-1 中触发事件时,您将会看到两个进程内接收者(那些在同一个项目或中的接收者)。apk 文件)和进程外接收器(那些在单独的中的)。apk 文件)被调用。您还将通过 LogCat 消息看到进程内和进程外接收器在各自的主线程中运行。

然而,在 API 12 (Android 3.1)之后,外部进程中的广播接收器出现了一些问题。这是由于 SDK 出于安全考虑而采用的发布模式。您可以在本章提供的参考链接中了解更多信息。随着这一变化,应用在安装时将处于停止状态。可以启动组件的意图现在可以指定只针对那些处于启动状态的应用。默认情况下,旧的行为仍然存在。但是,对于广播意图,系统会自动添加一个标记来排除处于停止状态的应用。为了克服前一点,可以在广播意图上明确地设置意图标志,以将那些停止的应用作为有效目标包括在内。这就是你在清单 16-1 的代码中看到的。

我们在本章的可下载 ZIP 文件 proandroid 5 _ Ch16 _ test receivers . ZIP 中包含了一个额外的独立项目,名为 standalone broadcast receiver 来测试这个概念。要尝试它,您必须在模拟器上部署调用项目 TestBroadcastReceiver 和独立接收方的项目 StandloneBroadcastReceiver。然后,您可以使用 TestBroadcastReceiver 项目发送广播事件,并监视来自 standalone broadcastreceiver 的接收器响应的 LogCat。

使用来自接收者的通知

广播接收器通常需要向用户传达已发生的事情或状态。这通常是通过系统通知栏中的通知图标提醒用户来实现的。我们现在将向您展示如何从广播接收器创建通知、发送通知以及通过通知管理器查看通知。

通过通知管理器监控通知

Android 在通知区域显示通知图标作为提醒。通知区域位于装置顶部,呈一条带状,看起来像图 16-1 中的。通知区域的外观和位置可能会根据设备是平板电脑还是手机而变化,有时也会根据 Android 版本而变化。

9781430246800_Fig16-01.jpg

图 16-1 。安卓通知图标状态栏

图 16-1 中所示的通知区域称为状态栏 。它包含电池电量、信号强度等系统指标。当我们发送通知时,通知会以图标的形式出现在图 16-1 所示的区域。通知图标如图 16-2 所示。

9781430246800_Fig16-02.jpg

图 16-2 。状态栏显示通知图标

通知图标是向用户指示需要观察某些情况的指示器。要查看完整的通知,您必须用手指按住图标,像拉窗帘一样向下拖动图 16-2 中的标题栏。这将扩大通知区域,如图图 16-3 所示。

9781430246800_Fig16-03.jpg

图 16-3 。扩展通知视图

在图 16-3 的通知的放大视图中,您可以看到通知的详细信息。您可以单击通知详细信息来激发显示通知所属的完整应用的意图。您可以使用此视图来清除通知。此外,根据设备和版本,可能有打开通知的替代方式。现在让我们看看如何生成如图图 16-2 和图 16-3 所示的通知图标。

发送通知

当您创建一个通知对象时,它需要有以下元素:

  • 要显示的图标
  • 像“你好,世界”这样的文字
  • 交付的时间

一旦构造了通知对象,就可以通过询问名为 Context 的系统服务的上下文来获得通知管理器引用。通知 _ 服务。然后,您要求通知管理器发送通知。清单 16-6 有发送通知的广播接收器的源代码,如图图 16-2 和图 16-2 所示。

清单 16-6 。发送通知的接收者

//Filename: NotificationReceiver.java
//Project: StandaloneBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
public class NotificationReceiver extends BroadcastReceiver {
    private static final String tag = "Notification Receiver";
    @Override
    public void onReceive(Context context, Intent intent) {
        Log.d(tag, "intent=" + intent);
        String message = intent.getStringExtra("message");
        Log.d(tag, message);
        this.sendNotification(context, message);
    }
    private void sendNotification(Context ctx, String message)   {
        //Get the notification manager
        String ns = Context.NOTIFICATION_SERVICE;
        NotificationManager nm =
            (NotificationManager)ctx.getSystemService(ns);
        //Prepare Notification Object Details
        int icon = R.drawable.robot;
        CharSequence tickerText = "Hello";
        long when = System.currentTimeMillis();
        //Get the intent to fire when the notification is selected
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setData(Uri.parse("[`www.google.com`](http://www.google.com)"));
        PendingIntent pi = PendingIntent.getActivity(ctx, 0, intent, 0);
        //Create the notification object through the builder
        Notification notification =
            new Notification.Builder(ctx)
                .setContentTitle("title")
                .setContentText(tickerText)
                .setSmallIcon(icon)
                .setWhen(when)
                .setContentIntent(pi)
                .setContentInfo("Addtional Information:Content Info")
                .build();
        //Send notification
        //The first argument is a unique id for this notification.
        //This id allows you to cancel the notification later
        //This id also allows you to update your notification
        //by creating a new notification and resending it against that id
        //This id is unique with in this application
        nm.notify(1, notification);
    }
}

展开通知时,会显示通知的内容视图。这就是你在图 16-2 中看到的。内容视图需要是一个 RemoteViews 对象。然而,我们不直接传递内容视图。根据传递给 Builder 对象的参数,Builder 对象创建一个适当的 RemoteViews 对象,并在通知上设置它。如果需要的话,构建器界面还有一个方法可以直接将内容视图设置为一个整体。

直接使用远程视图查看通知内容的步骤如下:

  1. 创建布局文件。
  2. 使用包名和布局文件 ID 创建一个 RemoteViews 对象。
  3. 在通知上调用 setContent() 。Builder 对象,然后调用 build()方法创建通知对象,然后将通知对象发送给通知管理器。

请记住,只有有限的一组控件可以参与远程视图,例如框架布局、线性布局、相对布局、模拟时钟、按钮、计时器、图像按钮、图像视图、进度条、文本视图。

清单 16-6 中的代码使用 Builder 对象创建一个通知来设置隐式内容视图(通过标题和文本)和触发意图(在我们的例子中,这个意图是浏览器意图)。可以创建一个新的通知,通过通知管理器重新发送,以便使用通知的唯一 ID 更新其先前的实例。通知的 ID 在清单 16-6 中设置为 1,在这个应用上下文中是惟一的。这种唯一性允许我们不断地更新通知发生了什么,并在需要时取消它。

您可能还想在创建通知时查看各种可用的标志,例如 FLAG_NO_CLEAR 和 FLAG _ understand _ EVENT,以控制这些通知的持久性。您可以使用以下 URL 来检查这些标志:

[`developer.android.com/reference/android/app/Notification.html`](http://developer.android.com/reference/android/app/Notification.html)

在广播接收器中启动活动

虽然我们建议你在需要通知用户时使用通知管理器,但 Android 确实允许你明确地产生一个活动。您可以通过使用常用的 startActivity() 方法来实现这一点,但是要将以下标志添加到用作 startActivity()参数的 intent 中:

  • 意图。FLAG_ACTIVITY_NEW_TASK
  • 意图。背景标志
  • 意图。FLAG_ACTIVITY_SINGLE_TOP

探索长期运行的接收器和服务

到目前为止,我们已经介绍了广播接收器的快乐之路,其中广播接收器的执行不太可能超过 10 秒。如果我们想执行超过 10 秒的任务,问题空间就有点复杂。

要了解原因,让我们回顾一下关于广播接收器的一些事实:

  • 与 Android 进程的其他组件一样,广播接收器运行在主线程上。因此,在广播接收器中拦截代码将会拦截主线程,并导致 ANR。广播接收器的时间限制是 10 秒,而活动的时间限制是 5 秒。这是一点缓刑,但不太多。
  • 承载广播接收器的进程将随着广播接收器的执行而开始和终止。因此,在广播接收方的 onReceive() 方法返回后,该过程将不再继续。当然,这是假设进程只包含广播接收器。如果流程包含其他已经在运行的组件,比如活动或服务,那么流程的生命周期也会考虑这些组件的生命周期。
  • 与服务进程不同,广播接收器进程不会重新启动。
  • 如果广播接收器要启动一个单独的线程并返回到主线程,Android 将假设工作已经完成,即使有线程在运行,也会关闭进程,使这些线程突然停止。
  • Android 在调用广播服务时自动获取部分唤醒锁,并在主线程中从服务返回时释放它。唤醒锁是 SDK 中可用的一种机制和 API 类,用于防止设备进入睡眠状态,或者在设备已经睡眠时将其唤醒。

给定这些谓词,我们如何执行长时间运行的代码来响应广播事件呢?

了解长期运行的广播接收器协议

答案在于解决以下问题:

  • 我们显然需要一个单独的线程,以便主线程可以返回并避免 ANR 消息。
  • 为了阻止 Android 杀死进程和工作线程,我们需要告诉 Android 这个进程包含一个组件,比如一个有生命周期的服务。因此,我们需要创建或启动该服务。服务本身不能直接完成超过 5 秒钟的工作,因为这发生在主线程上,所以服务需要启动一个工作线程并让主线程离开。
  • 在工作线程执行期间,我们需要保持部分唤醒锁,这样设备就不会进入睡眠状态。部分唤醒锁将允许设备在不打开屏幕等情况下运行代码,从而延长电池寿命。
  • 必须在接收器的主线代码中获得部分唤醒锁;否则就太晚了。例如,您不能在服务中这样做,因为在广播接收器发出的 startService() 和开始执行的服务的 onStartCommand() 之间可能太晚了。
  • 因为我们正在创建一个服务,所以服务本身可能会因为内存不足而关闭和重新启动。如果发生这种情况,我们需要再次获取唤醒锁。
  • 当服务的 onStartCommand() 方法启动的工作线程完成工作时,它需要告诉服务停止,这样它就可以被放在床上,而不是被 Android 起死回生。
  • 也有可能发生不止一个广播事件。考虑到这一点,我们需要小心我们需要产生多少工作线程。

鉴于这些事实,延长广播接收器寿命的推荐协议如下:

  1. 在广播接收器的 onReceive() 方法中获取(静态)部分唤醒锁。部分唤醒锁需要是静态的,以允许广播接收器和服务之间的通信。没有其他方法将唤醒锁的引用传递给服务,因为服务是通过不带参数的默认构造函数调用的。
  2. 启动一个本地服务,这样进程就不会被终止。
  3. 在服务中,启动一个工作线程来完成这项工作。不要在服务的 onStart() 方法中工作。如果你这样做了,你基本上是再次阻碍了主线。
  4. 当工作线程完成时,告诉服务直接或通过处理器停止自己。
  5. 让服务关闭静态唤醒锁。

了解 IntentService

认识到服务不阻塞主线程的需要,Android 提供了一个名为 IntentService 的工具本地服务实现,将工作卸载到工作线程,以便在将工作调度到子线程后释放主线程。在这种方案下,当你在一个 IntentService 上调用 startService() 时, IntentService 将使用一个循环和一个处理器将该请求排队到一个子线程,以便调用 IntentService 的一个派生方法在单个工作线程上完成实际工作。

下面是针对 IntentService 的 API 文档:

IntentService 是按需处理异步请求(表示为意图)的服务的基类。客户端通过 startService(Intent)调用发送请求;该服务根据需要启动,使用工作线程依次处理每个意图,并在工作耗尽时自行停止。这种“工作队列处理器”模式通常用于从应用的主线程中卸载任务。IntentService 类的存在是为了简化这种模式,并处理其中的机制。若要使用它,请扩展 IntentService 并实现 onhandleinent(Intent)。IntentService 将接收意图,启动一个工作线程,并在适当的时候停止服务。所有请求都在单个工作线程上处理—它们可能需要多长时间(并且不会阻塞应用的主循环),但是一次只会处理一个请求。

这个想法用清单 16-7 中的一个简单例子来演示。您扩展了 IntentService ,并在 onHandleIntent() 方法中提供了您想要做的事情。

清单 16-7 。使用 IntentService

//You can see file Test30SecBCRService.java for example
//Project: StandaloneBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
public class MyService extends IntentService {
    public MyService()
    { super("some-java-package-like-name-used-for-debugging"); }
    protected void onHandleIntent(Intent intent)  {
        //log thread signature if you want to see that it is running on a separate thread
        //Ex: Utils.logThreadSignature("MyService");
        //do the work in this subthread
        //and return
    }
}

一旦有了这样的服务,就可以在清单文件中注册这个服务,并使用客户端代码调用这个服务作为 context . start service(new Intent(context,MyService.class)) 。这将导致调用清单 16-7 中的 onhandleinentent()。您会注意到,如果您在实际代码中使用清单 16-7 中注释掉的方法 utils . logthreadsignature(),它将打印工作线程而不是主线程的 ID。您可以在项目中看到 Utils 类,并下载在清单 16-7 的注释部分列出的引用。

为广播接收器扩展 IntentService

从广播接收器的角度来看,一个 IntentService 是一件美好的事情。它让我们在不阻塞主线程的情况下执行长时间运行的代码。不仅如此,作为一个服务, IntentService 提供了一个在广播代码返回时保持运行的进程。那么我们可以使用 IntentService 来满足长期运行操作的需求吗?是也不是。

是的,因为 IntentService 做两件事:首先,它保持流程运行,因为它是一个服务。第二,它让主线程离开并避免相关的 ANR 消息。

要理解“不”的答案,你需要多理解一点唤醒锁。当通过警报管理器调用广播接收器时,设备可能没有打开。因此,警报管理器通过调用电源管理器并请求唤醒锁来部分打开设备(仅足以在没有任何 UI 的情况下运行代码)。广播接收器一返回,唤醒锁就被释放。

这使得 IntentService 调用没有唤醒锁,因此设备可能会在实际代码运行之前进入睡眠状态。然而, IntentService ,作为服务的通用扩展,它不获取唤醒锁。所以我们需要在 IntentService 之上的进一步支持。我们需要一个抽象。

马克·墨菲创建了一个名为的 IntentService 的变体,它保留了使用 IntentService 的语义,但也获取了唤醒锁,并在各种条件下正确释放它。你可以在github.com/commonsguy/cwac-wakeful看看他的实现。

探索长期运行的广播服务抽象

wakeflintentservice 是一个很好的抽象。然而,我们想更进一步,使我们的抽象与扩展 IntentService 的方法并行,如清单 16-7 所示,做 IntentService 所做的一切,但也提供了一些好处:

  • 将传递给广播接收器的原始意图传递给被覆盖的方法 on handle identity。这允许我们在很大程度上隐藏广播接收器,模拟响应广播消息而启动服务的编程体验。这确实是这个抽象的目标,同时也有一些额外的东西。
  • 获取和释放唤醒锁(类似于唤醒服务)。
  • 处理正在重新启动的服务。
  • 允许在同一进程中以统一的方式处理多个接收器和多个服务的唤醒锁。

我们将这个抽象类称为 alongningnonstickybroadcastservice。顾名思义,我们希望这个服务允许长时间运行的工作。它也将专门为广播接收器而建造。这个服务也是非粘性的(我们将在本章后面解释这个概念,但简单来说,这表明如果队列中没有消息,Android 将不会启动该服务)。为了允许一个 IntentService 的行为,它将扩展 IntentService 并覆盖 on hand lenent 方法。

结合这些想法,抽象的 alongningnonstickybroadcastservice 服务将具有类似于清单 16-8 的签名。

清单 16-8 。长期运行的服务抽象概念

public abstract class ALongRunningNonStickyBroadcastService extends IntentService {
//...other implementation details
//the following method will be called by the onHandleIntent of IntentService
//this is where the actual work happens in this derived abstract class
protected abstract void handleBroadcastIntent(Intent broadcastIntent);
//...other implementation details

}

这个 alongningnonstickybroadcastservice 的实现细节有点复杂,在我们解释了为什么要追求这种类型的服务之后,我们将很快介绍它们。我们想首先展示它的实用性和简单性。

一旦我们有了清单 16-8 中的这个抽象类,清单 16-7 中的 MyService 示例就可以重写为清单 16-9 中的示例。

清单 16-9 。长期运行的服务示例用法

public class MyService extends ALongRunningNonStickyBroadcastService {
    //..other implementation details
    protected void handleBroadcastIntent(Intent broadcastIntent)  {
        //You can use the following method to see which thread runs this code
        //Utils.logThreadSignature("MyService");
        //do the work here
        //and return
    }
    //..other implementation details
}

清单 16-9 的简单之处在于,一旦客户端发出广播意图,这个代码就会被调用。尤其是事实上,你是直接接收,未经修改,同样的意图,调用广播接收器。好像广播接收器已经从解决方案中消失了。

正如你所看到的,你可以扩展这个新的长期运行的服务类(就像 IntentService 和 WakefulIntentService )并覆盖一个单独的方法,在广播接收器中做很少的事情甚至什么都不做。你的工作将在一个工作线程中完成(多亏了 IntentService ),不会阻塞主线程。

清单 16-9 是一个演示这个概念的简单例子。让我们来看一个更完整的实现,它实现了一个长时间运行的服务,可以运行 60 秒来响应一个广播事件(证明我们可以运行超过 10 秒并避免 ANR 消息)。我们将这个服务恰当地称为 Test60SecBCRService (BCR 代表广播接收器),其实现如清单 16-10 所示。

清单 16-10 。test60 secbcrservice 的源代码

//Filename: Test30SecBCRService.java
//Project: StandaloneBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
public class Test60SecBCRService extends ALongRunningNonStickyBroadcastService {
   public static String tag = "Test60SecBCRService";
   //Required by IntentService to pass the classname for debug needs
   public Test60SecBCRService(){
      super("com.androidbook.service.Test60SecBCRService");
   }
   /* Perform long-running operations in this method.
    * This is executed in a separate thread.
    */
   @Override
   protected void handleBroadcastIntent(Intent broadcastIntent)  {
      //Utils class is in the download project mentioned
      Utils.logThreadSignature(tag);
      Log.d(tag,"Sleeping for 60 secs");
      //Use the thread to sleep for 60 seconds
      Utils.sleepForInSecs(60);
      String message =
         broadcastIntent.getStringExtra("message");
      Log.d(tag,"Job completed");
      Log.d(tag,message);
   }
}

正如您所看到的,这段代码成功地模拟了工作 60 秒,并且仍然避免了 ANR 消息。清单 16-10 中的实用方法是不言自明的,可以在本章的下载项目中找到。项目名和下载文件名在清单 16-10 中代码的注释部分。

设计长期运行的接收器

一旦我们有了清单 16-10 中的长期运行的服务,我们需要能够从广播接收器调用服务。同样,我们追求的是尽可能隐藏广播接收器的抽象。

长时间运行的广播接收器的第一个目标是将工作委托给长时间运行的服务。为此,长时间运行的接收者需要长时间运行的服务的类名来调用它。第二个目标是获得唤醒锁。第三个目标是将调用广播接收器的初衷传递给服务。我们将通过将原始意图作为一个可打包粘贴到意图附加中来实现这一点。我们将使用原意作为这个额外的名称。然后,长时间运行的服务提取 original_intent 并将其传递给长时间运行的服务的被覆盖的方法(稍后您将在长时间运行的服务的实现中看到这一点)。因此,这种设备给人的印象是,长时间运行的服务实际上是广播接收器的延伸。

让我们把这三样东西抽象出来,提供一个基类。这个长期运行的接收器抽象需要的唯一一点信息是通过一个名为 getLRSClass() 的抽象方法得到的长期运行的服务类的名称( LRSClass )。

将这些需求放在一起,实现抽象类的源代码在清单 16-11 中。

清单 16-11 。 单独运行接收方抽象

//Filename: ALongRunningReceiver.java
//Project: StandaloneBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
public abstract class  ALongRunningReceiver extends BroadcastReceiver  {
    private static final String tag = "ALongRunningReceiver";
    @Override
    public void onReceive(Context context, Intent intent)  {
       Log.d(tag,"Receiver started");
       //LightedGreenRoom abstracts the Android WakeLock
       //to keep the device partially on.
       //In short this is equivalent to turning on
       //or acquiring the wake lock.
       LightedGreenRoom.setup(context);
       startService(context,intent);
       Log.d(tag,"Receiver finished");
    }
    private void startService(Context context, Intent intent)  {
       Intent serviceIntent = new Intent(context,getLRSClass());
       serviceIntent.putExtra("original_intent", intent);
       context.startService(serviceIntent);
    }
    /*
     * Override this method to return the
     * "class" object belonging to the
     * nonsticky service class.
     */
    public abstract Class getLRSClass();
}

在前面的广播接收器代码中,您可以看到对名为 LightedGreenRoom 的类的引用。这是一个静态唤醒锁的包装器。除了作为一个唤醒锁,这个类试图迎合多接收器,多服务等工作。,以便所有的 waki-ness 得到适当的协调。出于理解的目的,你可以把它当作一个静态的唤醒锁。这种抽象被称为 LightedGreenRoom,因为它旨在像各种“绿色”运动一样为设备节能。此外,它之所以被称为“点亮”,是因为它一开始就被“点亮”,因为广播接收器一启动它就打开它。最后一个使用它的服务将关闭它。

一旦接收器抽象可用,您将需要一个与清单 16-11 中的 60 秒长时间运行的服务协同工作的接收器。在清单 16-12 中提供了这样一个接收器。

清单 16-12 。一个长期运行的广播接收器示例, Test60SecBCR

//Filename: Test60SecBCR.java
//Project: StandaloneBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
public class Test60SecBCR extends ALongRunningReceiver {
   @Override
   public Class getLRSClass() {
      Utils.logThreadSignature("Test60SecBCR");
      return Test60SecBCRService.class;
   }
}

就像清单 16-10 和清单 16-11 中的服务抽象一样,清单 16-12 中的代码使用了广播接收器的抽象。接收者抽象启动由 getLRSClass() 方法返回的服务类所指示的服务。

到目前为止,我们已经展示了为什么我们需要两个重要的抽象来实现由广播接收器调用的长期运行的服务:

  • ALongRunningNonStickyBroadcastService(清单 16-8 )
  • ALongRunningReceiver(清单 16-11

用明亮的温室提取唤醒锁

如前所述, LightedGreenRoom 抽象的主要目的是简化与唤醒锁的交互,唤醒锁用于在后台处理期间保持设备开启。您真的不需要 LightedGreenRoom 的实现细节,而只需要它的接口和针对它的调用。请记住,它只是 Android SDK 唤醒锁的一个薄薄的包装。在其最简单的实现中,它可以像打开(获取)和关闭(释放)唤醒锁一样简单。清单 16-13 展示了唤醒锁是如何被使用的,如 SDK 中所述。

清单 16-13 。使用唤醒锁 API 的伪代码

//Get access to the power manager service
PowerManager pm =
   (PowerManager)inCtx.getSystemService(Context.POWER_SERVICE);

//Get hold of a wake lock
PowerManager.WakeLock wl =
   pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag);

//Acquire the wake lock
wl.acquire();

//do some work
//while this work is being done the device will be on partially

//release the Wakelock
wl.release();

考虑到这种交互,广播接收器应该获得锁,当长时间运行的服务结束时,它需要释放锁。如前所述,没有好的方法将唤醒锁变量从广播接收器传递给服务。服务知道这个唤醒锁的唯一方式是使用静态或应用级变量。

获取和释放唤醒锁的另一个困难是引用计数。由于广播接收器被多次调用,如果调用重叠,将会有多次调用来获取唤醒锁。类似地,将有多个调用要释放。如果获取和释放调用的数量不匹配,我们最终会得到一个唤醒锁,在最坏的情况下,它会让设备保持比需要的时间长得多的时间。此外,当不再需要该服务并且垃圾收集运行时,如果唤醒锁计数不匹配,LogCat 中将出现运行时异常。这些问题促使我们尽最大努力将唤醒锁抽象为一个亮室以确保正确使用。每个进程都有一个这样的对象来保持一个唤醒锁,并确保它被正确地打开和关闭。包含的项目有这个类的实现。如果您发现代码由于要考虑的条件太多而太复杂,您可以从一个简单的静态变量开始,在服务启动和关闭时打开和关闭它,并对其进行优化以适应您的特定条件。

广播接收器和服务相互通信的合理方法是通过静态变量。我们没有将唤醒锁设为静态,而是将整个灯光温室设为静态实例。然而,灯光温室中的其他变量都是局部的,不稳定的。

为了方便起见, LightedGreenRoom 的每个公共方法也被公开为静态方法。我们使用了以“s_”开始命名这些方法的惯例。相反,您可以选择摆脱静态方法,直接调用 LightedGreenRoom 的单个对象实例。

实现长期运行的服务

为了呈现长期运行的服务抽象,我们必须再绕一圈来解释服务的生命周期以及它如何与 onStartCommand 的实现相关联。这是最终负责启动工作线程和服务语义的方法。

当一个服务通过 startService 启动时,该服务首先被创建,其 onStartCommand 方法被调用。Android 规定将这个过程保存在内存中,这样即使在服务多个传入的客户端请求时,服务也可以完成。但是,在内存要求苛刻的情况下,Android 可能会选择回收进程,并调用服务的 onDestroy() 方法。

注意当服务没有执行其 onCreate() 、 onStart() 或 onDestroy() 方法时,或者换句话说,当服务空闲时,Android 试图为服务调用 onDestroy() 方法来回收其资源。

然而,与被关闭的活动不同,如果队列中有未决的 startService 意图,则服务被调度为在资源可用时再次重启。服务将被唤醒,下一个意图将通过 onStartCommand() 传递给它。当然,服务带回时会调用 onCreate() 。

因为如果服务没有被显式停止,它们会自动重启,所以有理由认为,与活动和其他组件不同,服务组件从根本上来说是一个粘性组件。

了解非粘性服务

如果客户端明确调用 stopService ,服务不会自动重启。根据仍然连接的客户端数量,这个 stopService 方法可以将服务转移到停止状态,此时服务的 onDestroy 方法被调用,服务生命周期结束。一旦服务被它的最后一个客户端像这样停止,该服务将不会恢复。

当所有事情都按照设计发生时,这个协议工作得很好,其中 start 和 stop 方法被依次调用和执行,没有遗漏。在 Android 2.0 之前,即使没有工作要做,设备也会看到许多服务在周围徘徊并占用资源,这意味着即使队列中没有消息,Android 也会将服务带回内存。当停止服务因为异常或者因为在 onStartCommand 和停止服务之间的流程被取消而没有被调用时,就会发生这种情况。

Android 2.0 引入了一个解决方案,这样我们可以向系统表明,如果没有未决的意图,它不应该麻烦重启服务。这是通过返回非粘性标志(服务来完成的。来自的 START _ NOT _ STICKYonstart command。

然而,不粘并不是真的不粘。即使我们将这项服务标记为非粘性的,如果有悬而未决的意图,Android 将使这项服务起死回生。此设置仅适用于没有待定意向的情况。

理解粘性服务

那么一个服务真正的粘性是什么意思呢?粘旗(服务。START_STICKY 意味着 Android 应该重启服务,即使没有未决的意图。当服务重新启动时,调用 o nCreate 和 onStartCommand 的空意图。这将给服务一个机会,如果需要的话,调用 stopSelf 如果合适的话。这意味着粘性服务需要在重启时处理无效意图。

了解重新交付意图选项

尤其是本地服务遵循一种模式,即成对调用 onStart 和 stopSelf 。一个客户端调用 onStart 。当服务完成这项工作时,它调用 stopSelf 。如果一个服务需要 30 分钟来完成一个任务,那么它在 30 分钟内不会调用 stopSelf 。同时,由于低内存条件和更高优先级的作业,服务被回收。如果我们使用非粘性标志,服务将不会被唤醒,我们也不会调用 stopSelf 。

很多时候,这样是可以的。但是,如果你想确定这两个调用是否确实发生,你可以告诉 Android 在调用 stopSelf 之前不要 unqueue 这个 start 事件。这确保了当服务被回收时,除非调用了 stopSelf ,否则总会有一个挂起的事件。这被称为重新交付模式,可以通过返回服务来回复 onStartCommand 方法。START _ rede deliver _ INTENT 标志。

为长期运行的服务编码

现在您已经有了关于 IntentService 的背景、服务启动标志和亮着灯的绿色房间,我们准备看看清单 16-14 中的长期运行的服务。

清单 16-14 。长期运行的服务抽象

//Filename: ALongRunningNonStickyBroadcastService.java
//Project: StandaloneBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
public abstract class ALongRunningNonStickyBroadcastService
extends IntentService  {
    public static String tag = "ALongRunningBroadcastService";
    //This is what you override to do your work
    protected abstract void
    handleBroadcastIntent(Intent broadcastIntent);

    public ALongRunningNonStickyBroadcastService(String name){
        super(name);
    }
    /*
     * This method can be invoked under two circumstances
     * 1\. When a broadcast receiver issues a "startService"
     * 2\. when android restarts this service due to pending "startService" intents.
     *
     * In case 1, the broadcast receiver has already
     * set up the "lightedgreenroom" and thereby gotten the wake lock
     *
     * In case 2, we need to do the same.
     */
    @Override
    public void onCreate()   {
        super.onCreate();

        //Set up the green room
        //The setup is capable of getting called multiple times.
        LightedGreenRoom.setup(this.getApplicationContext());

        //It is possible that more than one service of this type is running.
        //Knowing the number will allow us to clean up the wake locks in ondestroy.
        LightedGreenRoom.s_registerClient();
    }
    @Override
    public int onStartCommand(Intent intent, int flag, int startId)   {
        //Call the IntentService "onstart"
        super.onStart(intent, startId);

        //Tell the green room there is a visitor
        LightedGreenRoom.s_enter();

        //mark this as nonsticky
        //Means: Don't restart the service if there are no
        //pending intents.
        return Service.START_NOT_STICKY;
    }
    /*
     * Note that this method call runs in a secondary thread setup by the IntentService.
     *
     * Override this method from IntentService.
     * Retrieve the original broadcast intent.
     * Call the derived class to handle the broadcast intent.
     * finally tell the lighted room that you are leaving.
     * if this is the last visitor then the lock
     * will be released.
     */
    @Override
    final protected void onHandleIntent(Intent intent)    {
        try {
            Intent broadcastIntent
            = intent.getParcelableExtra("original_intent");
            handleBroadcastIntent(broadcastIntent);
        }
        finally {
            //release the wake lock if you are the last one
            LightedGreenRoom.s_leave();
        }
    }
    /* If Android reclaims this process, this method will release the lock
     * irrespective of how many visitors there are.
     */
    @Override
    public void onDestroy() {
        super.onDestroy();
        //Do any cleanup, if needed, when a service no longer needs a wake lock
        LightedGreenRoom.s_unRegisterClient();
    }
}

这个类扩展了 IntentService ,并获得了由 IntentService 设置的工作线程的所有好处。此外,它进一步特殊化了 IntentService ,使其成为一个非粘性服务。从开发人员的角度来看,主要关注的方法是抽象的 handleBroadcastIntent()方法。清单 16-15 展示了如何在清单文件中设置接收者和相应的服务。

清单 16-15 。长期运行的接收者和服务定义

<!--
In filename: AndroidManifest.xml
Project: StandaloneBroadcastReceiver, Download: ProAndroid5_Ch16_TestReceivers.zip
-->
<manifest...>
......
<application....>
<receiver android:name=".Test60SecBCR">
    <intent-filter>
       <action android:name="com.androidbook.intents.testbc"/>
    </intent-filter>
</receiver>
<service android:name=".Test60SecBCRService"/>
</application>
.....
<uses-permission android:name="android.permission.WAKE_LOCK"/>
</manifest>

请注意,您将需要 wake lock 权限来运行这个长期运行的接收器抽象。本章的可下载项目中提供了所有接收器和长期运行服务的完整源代码。清单 16-15 展示了广播接收器调用的长期运行服务的本质。这个抽象声明你写几行代码来创建一个类似于 Test60SecBCR ( 清单 16-12 )的接收器,然后写一个类似于代码 Test60SecBCRService ( 清单 16-10 )中的 java 方法。给定接收者和想要长时间运行的 java 方法,您可以执行该方法来响应广播事件。这种抽象确保了该方法可以运行尽可能长的时间,而不会产生 ARM。该抽象负责 a)保持流程活动,b)调用服务,c)负责唤醒锁,以及 d)将广播意图传递给服务。最后,这种抽象模拟了从广播事件中“调用一个可以无限制执行的方法”。

广播接收器中的附加主题

由于篇幅所限,我们无法在本书中涵盖广播接收器的所有方面。我们还没有涉及的一个话题是限制发送和接收广播的安全机会。您可以在接收方上使用 export 属性来决定是否可以从外部进程调用它。您还可以通过清单文件或以编程方式启用或禁用接收器。我们还没有介绍一个叫做 sendOrderBroadcast 的方法,它可以帮助我们按照包括链接在内的顺序调用广播接收器。你可以从 BroadcastReceiver 类的主要 API 文档中了解这些方面。

此外,在 Android 支持库 SDK 的版本 4 中,有一个名为 LocalBroadcastManager 的类,用于优化对严格本地的广播接收器的调用。由于是本地的,所以不需要考虑所有的安全限制。根据 SDK,当使用这个类时,还有系统级的优化。

同样在 Android 支持库 SDK 的版本 4 中,有一个名为 WakefulBroadcastReceiver 的类,它封装了一些我们为长期运行的服务需求所涵盖的相同概念。

参考

以下是本章所涵盖主题的有用参考:

摘要

在这一章中,我们已经讨论了广播接收器、通知管理器以及服务抽象在最大限度地利用广播接收器中的作用。我们也给出了一个实用的抽象来模拟广播接收器长期运行的广播服务。

十七、探索警报管理器

在 Android 中,intent 对象用于启动 UI 活动、后台服务或广播接收器。通常这些意图是由用户动作触发的。在 Android 中,你也可以使用闹钟来触发广播意图,请注意,只是广播意图。然后,被调用的广播接收器可以选择开始一项活动或一项服务。

在本章中,您将了解警报管理器 API。警报管理器 API 用于安排广播意图在特定时间启动。我们将这种在特定时间安排广播意图的过程称为设置警报。

我们还将向您展示如何安排定期重复的闹铃。我们将向您展示如何取消已经设置的警报。

当意图对象被存储以供以后使用时,它被称为待定意图。由于警报管理器一直在使用未决意图,你也将在本章中看到未决意图的用法和复杂性。

设置简单的警报

我们将从在特定时间设置闹钟并让它呼叫广播接收器开始这一章。一旦广播接收器被调用,你可以使用来自第十六章的信息在广播接收器中执行简单的和长时间运行的操作。

访问警报管理器很简单,如清单 17-1 所示。

清单 17-1 。访问警报管理器

//In filename: SendAlarmOnceTester.java
AlarmManager am =
    (AlarmManager)
         anyContextObject.getSystemService(Context.ALARM_SERVICE);

变量 anyContextObject 指的是一个上下文对象。例如,如果您从活动菜单中调用此代码,上下文变量将是活动。为了设置特定日期和时间的闹钟,您需要一个由 Java 日历对象标识的 time 实例。清单 17-2 显示了一个实用函数,它为当前时间之后的某个特定时刻提供了一个日历对象。

清单 17-2 。一些有用的日历工具

//In filename: Utils.java
public class Utils {
    public static Calendar getTimeAfterInSecs(int secs) {
        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.SECOND,secs);
        return cal;
    }
}

在本章的可下载项目中,您将看到更多基于日历的工具,它们可以通过多种方式到达时间实例。现在,我们需要一个接收器来设置我们计划设置的警报。清单 17-3 中显示了一个简单的接收器。

清单 17-3 。 TestReceiver 用于测试警报广播

//In filename: TestReceiver.java
public class TestReceiver extends BroadcastReceiver  {
    private static final String tag = "TestReceiver";
    @Override
    public void onReceive(Context context, Intent intent)  {
        Log.d (tag, "intent=" + intent);
        String message = intent.getStringExtra("message");
        Log.d(tag, message);
    }
}

您需要使用 < receiver > 标签在清单文件中注册这个接收者,如清单 17-4 所示。接收器在第十六章中有详细介绍。

清单 17-4 。注册广播接收器

<!-- In filename: AndroidManifest.xml -->
<receiver android:name=".TestReceiver"/>

在 Android 中,警报实际上是一种广播意图,被安排在稍后的时间。这个意图应该调用的接收方组件在意图中明确指定(通过它的类名)。清单 17-5 显示了一个意图,可以用来调用我们在清单 17-3 中的广播接收器。

清单 17-5 。创建一个指向 TestReceiver 的意图

//In filename: SendAlarmOnceTester.java
Intent intent = new Intent(mContext, TestReceiver.class);
intent.putExtra("message", "Single Shot Alarm");

我们也有机会在创建意图时加载“额外内容”。因为警报管理器存储了一个意图供以后使用,我们需要从清单 17-5 的意图中创建一个待定意图。清单 17-6 显示了如何从一个标准意向创建一个待定意向。

清单 17-6 。创建待定意向

//In filename: SendAlarmOnceTester.java
PendingIntent pendingIntent =
    PendingIntent.getBroadcast(
      mContext,    //context, or activity, or service
      1,           //request id, used for disambiguating this intent
      intent,      //intent to be delivered
      0);          //pending intent flags

注意,我们已经要求 pending content 类构造一个显式适用于广播的待定 Intent。创建待定意向的其他变化在清单 17-7 中列出:

清单 17-7 。用于创建待定意向的多个 API

//useful to start an activity
PendingIntent activityPendingIntent = PendingIntent.getActivity(..args..);
//useful to start a service
PendingIntent servicePendingIntent = PendingIntent.getService(..args..);

在清单 17-7 中,方法 getActivity() 和 getService() 的参数类似于清单 17-6 中 getBroadcast() 方法的参数。请注意,警报需要广播待定意图,而不是活动待定意图或服务待定意图。

我们将在本章后面更详细地讨论请求 id 参数,我们在清单 17-6 中将它设置为 1。简而言之,它用于分离两个在所有其他方面都等于的意图对象。

待定意向标志对警报管理器影响很小或没有影响。建议不使用任何标志,并使用 0 作为它们的值。这些意向标志通常有助于控制待定意向的生存期。但是,在这种情况下,生命周期由警报管理器维护。例如,要取消一个待定的意向,您要求警报管理器取消它。

一旦我们有了作为日历对象的以毫秒为单位的时间实例和指向接收者的待定意向,我们就可以通过调用警报管理器的 set() 方法来设置一个警报。这显示在清单 17-8 中。

清单 17-8 。使用警报管理器的 set() 方法

//In filename: SendAlarmOnceTester.java
Calendar cal = Utils.getTimeAfterInSecs(30);
//...other code that gets the pendingintent etc
am.set(AlarmManager.RTC_WAKEUP,
        cal.getTimeInMillis(),
        pendingIntent);

set() 方法的第一个参数表示闹铃的唤醒性质,以及我们将用于闹铃的参考时钟。该参数的可能值为 AlarmManager。RTC_WAKEUP ,警报管理器。RTC ,警报管理器。ELAPSED_REALTIME , AlarmManager。ELAPSED_REALTIME_WAKEUP

这些常量中的 elapsed 字指的是自设备最近启动以来的时间,以毫秒为单位。因此,它指的是设备时钟。 RTC 时间是指当你在设备上查看自己的时钟时,你在设备上看到的人类时钟/时间。这些常量中的 WAKEUP 一词指的是警报的性质,比如警报是应该唤醒设备还是只是在设备最终唤醒的第一时间传递。综上所述, RTC_WAKEUP 表示使用实时时钟,器件应该被唤醒。常量 ELAPSED_REALTIME 表示使用设备时钟,不唤醒设备;相反,要在第一时间发出警报。

当调用清单 17-8 中的方法时,警报管理器将调用清单 17-3 中的测试接收器,在该方法被调用的日历时间之后 30 秒,如果设备处于睡眠状态,警报管理器也会唤醒设备。

反复触发警报

现在让我们考虑如何设置一个重复的闹钟;见清单 17-9 。

清单 17-9 。设置重复闹钟

public void sendRepeatingAlarm() {
    Calendar cal = Utils.getTimeAfterInSecs(30);

    //Get an intent to invoke the receiver
    Intent intent = new Intent(this.mContext, TestReceiver.class);
    intent.putExtra("message", "Repeating Alarm");

    int requestid = 2;
    PendingIntent pi = this.getDistinctPendingIntent(intent, requestid);
    // Schedule the alarm!
    AlarmManager am =
        (AlarmManager)
            this.mContext.getSystemService(Context.ALARM_SERVICE);

    am.setRepeating(AlarmManager.RTC_WAKEUP,
            cal.getTimeInMillis(),
            5*1000, //5 secs repeat
            pi);
}

protected PendingIntent getDistinctPendingIntent(Intent intent, int requestId) {
    PendingIntent pi =
        PendingIntent.getBroadcast(
          mContext,     //context, or activity
          requestId,    //request id
          intent,       //intent to be delivered
          0);
    return pi;
}

清单 17-9 中代码的关键元素被突出显示。通过调用警报管理器对象上的 setRepeating() 方法来设置重复警报。此方法的主要输入是指向接收者的挂起意图。我们使用了在清单 17-5 中创建的相同意图,指向 TestReceiver 的意图。然而,当我们用清单 17-5 中的意图制作一个待定意图时,我们将唯一请求代码的值改为 2。如果我们不这样做,我们会看到一点奇怪的行为,我们现在解释。假设我们打算通过两个不同的警报调用同一个接收器:一个警报只响一次,另一个警报重复响。因为两个警报都指向同一个接收者,所以它们需要使用指向同一个接收者的意图。指向同一个接收者的两个意图之间没有任何其他区别,被认为是相同的意图。因此,当我们告诉警报管理器将 intent 1 上的警报设置为一次性警报,然后将 intent 2 上的警报设置为重复警报时,我们可能会认为它们是两个不同的警报。但是,在内部,两个警报指向相同的 intent 值,因为 intent 1 和 intent 2 的值相同。这就是为什么警报实际上与其设置目的相同(特别是通过值)。因此,如果意图相同,后一个警报会覆盖第一个警报。

同样,如果两个意图具有相同的动作、类型、数据、类别或类,则它们被认为是相同的。在计算意图的唯一性时,额外的因素不包括在内。此外,如果两个待定意图的底层意图相同并且请求 id 匹配,则它们被认为是相同的。因为我们可以使用请求 ID 来区分两个待定的意图,所以清单 17-8 中的代码通过使用请求 id 参数克服了源意图的相似性。这个请求 id 到待定内容 API 的参数将在所有其他内容匹配时将一个待定意图与另一个待定意图分开。

如果您将未决意图(通过值而不是通过其 Java 对象引用)本身视为您设置不同时间的警报,这一切都应该是有意义的。

取消警报

清单 17-10 中的代码用于取消警报。

清单 17-10 。取消重复警报

public void cancelRepeatingAlarm() {
    //Get an intent that was originally
    //used to invoke TestReceiver class
    Intent intent = new Intent(this.mContext, TestReceiver.class);

    //To cancel, extra is not necessary to be filled in
    //intent.putExtra("message", "Repeating Alarm");

    PendingIntent pi = this.getDistinctPendingIntent(intent, 2);

    // Cancel the alarm!
    AlarmManager am =
        (AlarmManager)
           this.mContext.getSystemService(Context.ALARM_SERVICE);
    am.cancel(pi);
}

要取消一个警报,我们必须首先构造一个挂起的意图,然后将其作为参数传递给警报管理器的 cancel() 方法。但是,您必须注意确保待定内容在设置警报时以完全相同的方式构建,包括请求代码和目标接收器。

在构建取消意图时,可以忽略原始意图中的额外意图(清单 17-10 ),因为额外意图在意图的唯一性中不起作用,因此取消该意图。

了解警报的准确性

在 API 19 之前,Android 会在尽可能接近指定时间的时候触发警报。从 API 19 开始,为了电池寿命,彼此靠近的警报被捆绑。如果你需要更老的行为,有一个版本的 set() 方法叫做 setExact() 。还有一种叫做 setWindow() 的方法,它允许有效率的空间,也允许有保证的窗口。类似地,方法 setRepeating() 现在是不精确的。与 setExact() 方法不同, setRepeating() 没有确切的版本。如果你有这样的需求,你就得用 setExact() 自己重复多次。

了解警报的持续性

关于警报的另一个注意事项是,它们不会在设备重启后保存。这意味着您需要将警报设置和待定意图保存在持久性存储中,并根据设备重启广播动作和可能的时变广播动作(例如,意图)重新注册它们。动作 _ 启动 _ 完成,意图。ACTION_TIME_CHANGED ,意图。动作 _ 时区 _ 改变。

参考

以下参考资料将帮助您了解本章中讨论的主题的更多信息:

摘要

本章探讨了用于设置和取消警报的警报管理器 API。本章向您展示了如何将警报连接到广播服务。本章还向您展示了警报是如何与意图紧密相关的。

十八、探索 2D 动画

动画允许屏幕上的对象随时间改变其颜色、位置、大小或方向。Android 中的动画功能实用、有趣且简单。它们在应用中经常使用。

Android 2.3 及之前的版本支持三种类型的动画 :逐帧动画(frame-by-frame animation),以固定的间隔一个接一个地绘制一系列帧时出现;布局动画,您可以在容器(如列表和表格)中动画显示视图的布局;和视图动画,其中任何视图都可以被动画化。在布局动画中,焦点不是任何给定的视图,而是视图集合起来形成复合布局的方式。Android 3.0 通过将其扩展到任何 Java 属性(包括 UI 元素的属性)来增强动画。我们将首先介绍 2.3 之前的特性,然后介绍 3.0 之后的特性。根据您的使用情况,这两种功能都适用。

探索逐帧动画

逐帧动画是一系列图像以快速间隔连续显示,最终效果是物体移动或变化。图 18-1 显示了一组圆,每个圆的不同位置都有一个球。有了这些图像中的一些(它们是帧),你可以使用动画来让球绕着圆圈运动。

9781430246800_Fig18-01.jpg

图 18-1 。动画的示例图像帧

图 18-1 中的每个圆都是一个独立的图像。给图像一个基本名称 colored_ball ,并将其中八个图像存储在 /res/drawable 子目录中,这样您就可以使用它们的资源 id 来访问它们。每个图像的名称将具有图案 coloured-ballN,其中 N 是代表图像编号的数字。我们正在计划的动画活动将看起来像图 18-2 。

9781430246800_Fig18-02.jpg

图 18-2 。逐帧动画测试装具

图 18-2 中的主要控制是动画视图,显示球被放置在一个椭圆/圆上。顶部的按钮用于开始和停止动画。顶部有一个调试便笺簿,用于记录事件。清单 18-1 显示了用于创建图 18-2 中的活动的布局。

清单 18-1 。帧动画示例的 XML 布局文件

<?xml version="1.0" encoding="utf-8"?>
<!--
filename: /res/layout/frame_animations_layout.xml
Download: ProAndroid5_ch18_TestFrameAnimation.zip
-->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent" android:layout_height="fill_parent">
<TextView android:id="@+id/textViewId1"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content" android:text="Debug Scratch Pad"/>
<Button
    android:id="@+id/startFAButtonId"
    android:layout_width="fill_parent" android:layout_height="wrap_content"
    android:text="Start Animation"/>
<ImageView
    android:id="@+id/animationImage"
    android:layout_width="fill_parent" android:layout_height="wrap_content"/>
</LinearLayout>

第一个控件是 debug-scratch 文本控件,它是一个简单的 TextView 。然后添加一个按钮来开始和停止动画。最后一个视图是 ImageView ,用于播放动画。

在 Android 中,逐帧动画是通过类 AnimationDrawable 实现的。这个类是一个可提取的。这些对象通常用作视图的背景。 AnimationDrawable 除了是一个 Drawable 之外,还可以获取其他 Drawable 资源的列表(比如图片)并以指定的间隔渲染。要使用这个 AnimationDrawable 类,首先要有一组 Drawable 资源(例如,一组图像)放在 /res/drawable 子目录中。然后,您将使用这些图像的列表构建一个定义了 AnimationDrawable 的 XML 文件(参见清单 18-2 )。这个 XML 文件也需要放在 /res/drawable 子目录中。

清单 18-2 。定义要制作动画的帧列表的 XML 文件

<!--
filename: /res/drawable/frame_animation.xml
Download: ProAndroid5_ch18_TestFrameAnimation.zip
-->
<animation-list xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
android:oneshot="false">
   <item android:drawable="@drawable/colored_ball1" android:duration="50" />
   <item android:drawable="@drawable/colored_ball2" android:duration="50" />
   <item android:drawable="@drawable/colored_ball3" android:duration="50" />
   <item android:drawable="@drawable/colored_ball4" android:duration="50" />
   <item android:drawable="@drawable/colored_ball5" android:duration="50" />
   <item android:drawable="@drawable/colored_ball6" android:duration="50" />
   <item android:drawable="@drawable/colored_ball7" android:duration="50" />
   <item android:drawable="@drawable/colored_ball8" android:duration="50" />
</animation-list>

每个框架都指向您通过资源 id 收集的一个彩球图像。动画列表标签被转换成代表图像集合的动画绘制对象。然后,您需要将这个 AnimationDrawable 设置为活动布局中的 ImageView 控件的背景资源。假设这个 XML 文件的文件名是 frame_animation.xml 并且它位于 /res/drawable 子目录中,您可以使用下面的代码将 AnimationDrawable 设置为 ImageView 的背景:

view.setBackgroundResource(R.drawable.frame_animation); //See Listing 18-3

通过这段代码,Android 意识到资源 IDr . drawable . frame _ animation 是一个 XML 资源,并相应地为其构造了一个合适的 AnimationDrawable Java 对象,然后将其设置为背景。设置好之后,你可以通过对视图对象执行获取来访问这个动画绘制对象,如下所示:

Object  backgroundObject = view.getBackground();
AnimationDrawable ad = (AnimationDrawable)backgroundObject;

一旦有了 AnimationDrawable 对象,就可以使用它的 start() 和 stop() 方法来开始和停止动画。下面是这个对象的另外两个重要方法:

setOneShot(boolean);
addFrame(drawable, duration);

setOneShot(true) 方法运行动画一次,然后停止。 addFrame() 方法使用 Drawable 对象添加一个新帧,并设置其显示持续时间。 addFrame() 方法的功能类似于 清单 18-2 中的 XML 标签 android:drawable 。将这些放在一起,就可以得到图 18-1 中我们的逐帧动画活动的完整代码。

清单 18-3 。逐帧动画测试工具的完整代码

// filename: FrameAnimationActivity.java
// Download: ProAndroid5_ch18_TestFrameAnimation.zip
public class FrameAnimationActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState)   {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.frame_animations_layout);
        this.setupButton();
    }
    private void setupButton(){
       Button b = (Button)this.findViewById(R.id.startFAButtonId);
       b.setOnClickListener(
             new Button.OnClickListener(){
                public void onClick(View v) {animate();}
             });
    }
    private void animate()  {
        ImageView imgView = (ImageView)findViewById(R.id.animationImage);
        imgView.setVisibility(ImageView.VISIBLE);
        imgView.setBackgroundResource(R.drawable.frame_animation);

        AnimationDrawable frameAnimation = (AnimationDrawable)imgView.getBackground();
        if (frameAnimation.isRunning()) {
            frameAnimation.stop();
        }
        else {
            frameAnimation.stop();
            frameAnimation.start();
        }
    }
}//eof-class

清单 18-3 中的方法 animate() 在活动中定位 ImageView ,并将其背景设置为由资源 r . drawable . frame _ animation 标识的 AnimationDrawable 。这个动画资源 ID 指向清单 18-3 中早先的动画定义。该方法中的其余代码检索这个 AnimationDrawable 对象,并在该对象上调用动画方法。在同一个清单 18-3 中,设置了开始/停止按钮,如果动画正在运行,该按钮可以停止动画;如果动画没有运行,该按钮可以启动它。如果将清单 18-2 中动画定义的 oneshot 属性设置为 true ,动画会在一次后停止。

探索布局动画

LayoutAnimation 用于动画显示 Android 布局中的视图。你可以使用这种类型的动画,例如像列表视图和网格视图这样的普通布局控件。与逐帧动画不同,布局动画不是通过重复帧而是通过改变视图的变换矩阵来实现的。Android 中的每个视图都有一个将视图映射到屏幕的转换矩阵。通过更改这个矩阵,您可以实现视图的缩放、旋转和移动(平移)。这种依赖于更改属性和重绘图像的动画称为补间动画。基本上 LayoutAnimation 是布局中视图的变换矩阵的补间动画。在布局上指定的布局动画应用于该布局中的所有视图。

这些是可以应用于布局的补间动画类型:

  • 缩放动画:用于沿 x 轴、y 轴或两者放大或缩小视图。您还可以指定希望动画围绕其发生的轴心点。
  • 旋转动画:用于将视图围绕枢轴点旋转一定的角度。
  • 平移动画:用于沿 x 轴或 y 轴移动视图。
  • Alpha 动画:用于改变视图的透明度。

这些动画在 /res/anim 子目录中被定义为 XML 文件。清单 18-4 显示了一个在 XML 文件中声明的比例动画。

清单 18-4 。在位于 /res/anim/scale.xml 的 XML 文件中定义的缩放动画

<set xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
android:interpolator="@android:anim/accelerate_interpolator">
   <scale
         android:fromXScale="1"
         android:toXScale="1"
         android:fromYScale="0.1"
         android:toYScale="1.0"
         android:duration="500"
         android:pivotX="50%"
         android:pivotY="50%"
         android:startOffset="100" />
</set>

动画 XML 中的参数具有“from”和“to”风格,以指示该属性的开始和结束值。动画的其他属性还包括动画持续时间和时间插值器。插值器决定动画参数的变化率,如清单 18-4 中的比例。我们将很快介绍插值器。清单 18-4 中的 XML 文件可以与一个布局相关联,以动画显示该布局的组成视图。

注意像清单 18-4 中的比例动画在 android.view.animation 包中被表示为 Java 类。这些类的 Java 文档不仅描述了 Java 方法,还描述了每种类型的动画所允许的 XML 参数。

我们可以使用图 18-3 中的列表视图来测试一些布局动画。当您运行本章的示例项目时,您会看到这个活动。proandroid 5 _ ch18 _ testlayoutanimation . zip

9781430246800_Fig18-03.jpg

图 18-3 。要制作动画的列表视图

该活动的布局在清单 18-5 中。

清单 18-5 。ListView XML 布局文件

<?xml version="1.0" encoding="utf-8"?>
<!--
filename: /res/layout/list_layout.xml
project: ProAndroid5_ch18_TestLayoutAnimation.zip
-->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">
    <ListView
        android:id="@+id/list_view_id"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"/>
</LinearLayout>

清单 18-5 显示了一个简单的线性布局,其中有一个列表视图。将布局从 18-5 显示为图 18-3 的活动代码在清单 18-6 中。

清单 18-6 。布局-动画活动代码

//filename: LayoutAnimationActivity.java
//project: ProAndroid5_ch18_TestLayoutAnimation.zip
public class LayoutAnimationActivity extends Activity  {
    @Override
    public void onCreate(Bundle savedInstanceState)  {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.list_layout);
        setupListView();
    }
    private void setupListView()  {
      String[] listItems = new String[] {
             "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6",
      };
      ArrayAdapter<String> listItemAdapter =
       new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, listItems);
      ListView lv = (ListView)this.findViewById(R.id.list_view_id);
      lv.setAdapter(listItemAdapter);
    }
}

现在让我们看看如何将清单 18-4 中的缩放动画应用到这个列表视图中。列表视图需要另一个 XML 文件,作为它自己和清单 18-4 中的比例动画之间的中介。这是因为清单 18-4 中定义的动画是通用的,适用于任何视图。另一方面,布局是视图的集合。因此清单 18-7 中的中介布局动画 XML 文件重用了通用动画 XML 文件,并指定了适用于视图集合的附加属性。这个中介布局动画 XML 文件如清单 18-9 所示。

清单 18-7 。布局控制器 XML 文件

<?xml version="1.0" encoding="utf-8"?>
<!--
filename: /res/anim/list_layout_controller.xml (ProAndroid5_ch18_TestLayoutAnimation.zip)
-->
<layoutAnimation xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
        android:delay="100%"
        android:animationOrder="reverse"
        android:animation="@anim/scale" />

这个 XML 文件需要在 /res/anim 子目录中。这个 XML 文件指定列表中的动画应该反向进行,每个项目的动画应该以相对于总动画持续时间 100%的延迟开始。100%的持续时间确保一个项目的动画在下一个项目的动画开始之前完成。您可以更改该百分比以适应动画的需要。任何小于 100%的值都将导致项目的重叠动画。这个 mediator XML 文件还通过资源引用 @anim/scale 引用单个动画文件 scale.xml ( 清单 18-4 )。清单 18-8 展示了如何通过清单 18-7 的中介将清单 18-4 的动画附加到清单 18-5 的活动布局上。

清单 18-8 。 list_layout.xml 文件的更新代码

<?xml version="1.0" encoding="utf-8"?>
<!--
filename: /res/layout/list_layout.xml(ProAndroid5_ch18_TestLayoutAnimation.zip)
-->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent" android:layout_height="fill_parent">
    <ListView android:id="@+id/list_view_id"
        android:persistentDrawingCache="animation|scrolling"
        android:layout_width="fill_parent" android:layout_height="fill_parent"
        android:layoutAnimation="@anim/list_layout_controller" />
</LinearLayout>

在清单 18-8 中,android:layoutAnimation 是指向清单 18-7 的中介 XML 文件的标签,该文件又指向清单 18-5 的 scale.xml 。在清单 18-8 中,Android SDK 文档建议在列表视图上设置 persistentDrawingCache 标签,以优化动画和滚动。如果您要运行应用 pro Android 5 _ ch18 _ testlayoutanimation . zip,您将会看到当加载活动时,缩放动画在单个列表项上生效。我们已经将动画持续时间设置为 500 ms,以便在绘制每个列表项时可以观察到比例变化。

使用这个示例程序,您可以尝试不同的动画类型。你可以用清单 18-9 中的代码尝试 alpha 动画。

清单 18-9 。测试 alpha 动画的 alpha.xml 文件

<?xml version="1.0" encoding="utf-8"?>
<!-- file: /res/anim/alpha.xml(ProAndroid5_ch18_TestLayoutAnimation.zip) -->
<alpha xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
       android:interpolator="@android:anim/accelerate_interpolator"
       android:fromAlpha="0.0" android:toAlpha="1.0" android:duration="1000" />

Alpha 动画控制(颜色的)褪色。在清单 18-9 中,阿尔法动画的颜色在 1 秒钟内从不可见变为全亮度。如果你打算使用相同的中介文件,不要忘记改变中介 XML 文件(见清单 18-7 )指向新的动画文件。

清单 18-10 显示了一个结合了位置变化和颜色渐变的动画。

清单 18-10 。通过动画集组合平移和 Alpha 动画

<?xml version="1.0" encoding="utf-8"?>
<!-- file:/res/anim/alpha_translate.xml(ProAndroid5_ch18_TestLayoutAnimation.zip)-->
<set xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
  android:interpolator="@android:anim/accelerate_interpolator">
  <translate android:fromYDelta="-100%" android:toYDelta="0"android:duration="500"/>
  <alpha android:fromAlpha="0.0" android:toAlpha="1.0" android:duration="500"/>
</set>

注意清单 18-10 的动画集中有两个动画。翻译动画将在当前分配的显示空间中从上到下移动文本。当文本项下降到它的槽中时,Alpha 动画将把颜色渐变从不可见变为可见。要查看这个动画,请参考文件名@ anim/alpha _ translate . XML 更改 layout animationmediator XML 文件。清单 18-11 显示了旋转动画的定义。

清单 18-11 。旋转动画 XML 文件

<!-- file: /res/anim/rotate.xml(ProAndroid5_ch18_TestLayoutAnimation.zip) -->
<rotate xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      android:interpolator="@android:anim/accelerate_interpolator"
      android:fromDegrees="0.0" android:toDegrees="360"
      android:pivotX="50%"  android:pivotY="50%"
      android:duration="500" />

清单 18-11 将列表中的每个文本项围绕文本项的中点旋转一整圈。让我们来谈谈你在动画 XML 文件中看到的插值器。

理解插值器

插值器 告诉属性如何随时间从其起始值变化到结束值。变化是线性的还是指数的?变化会很快开始,到最后会慢下来吗?

清单 18-9 中的阿尔法动画将插补器标识为加速 _ 插补器。有一个相应的 Java 对象定义了这个插值器的行为。由于我们已经在清单 18-9 中将这个内插器指定为一个资源引用,所以必须有一个对应于@ anim/accelerate _ interpolator 的文件来描述这个 Java 对象是什么以及它可能需要什么附加参数。清单 8-12 显示了资源引用@ Android:anim/accelerate _ interpolator 指向的资源 XML 文件定义:

清单 18-12 。作为 XML 资源的插值器定义

<accelerateInterpolator
  xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
  factor="1" />

你可以在根 Android SDK 包的子目录/RES/anim/accelerate _ interpolator . XML 中看到这个 XML 文件。(注意:根据版本的不同,该文件可能会有所不同。)XML 标签 accelerateInterpolator 对应 Java 类 Android . view . animation . accelerate interpolator。您可以查阅相应的 Java 文档来了解哪些 XML 标签是可用的。该插值器的目标是在给定基于双曲线的时间间隔的情况下提供倍增因子。清单 18-13 中的源代码片段说明了这一点。(注意:根据 Android 版本的不同,此代码可能会有所不同。)

清单 18-13 。核心 Android SDK 中 AccelerateInterpolator 的示例代码

public float getInterpolation(float input) {
   if (mFactor == 1.0f)   {
      return (float)(input * input);
   }
   else   {
      return (float)Math.pow(input, 2 * mFactor);
   }
}

每个插值器实现 getInterpolation 方法的方式不同。在加速插值器的情况下,如果插值器在资源文件中设置为因子 1.0 ,它将在每个间隔返回输入的平方。否则,它将返回输入的乘方,该乘方将按因子数量进一步缩放。如果因子是 1.5 ,你会看到一个三次函数,而不是平方函数。

支持的插值器包括加速减速插值器、加速插值器、周期插值器、减速插值器、线性插值器、预测插值器、预测过冲插值器、反弹插值器和过冲插值器。

要了解这些插值器有多灵活,快速查看一下清单 18-14 中的反弹插值器,它在动画周期结束时反弹对象(即来回移动它):

清单 18-14 。核心 Android SDK 中的 BounceInterpolator 实现

public class BounceInterpolator implements Interpolator {
     private static float bounce(float t) {
         return t * t * 8.0f;
     }
     public float getInterpolation(float t) {
         t *= 1.1226f;
         if (t < 0.3535f) return bounce(t);
         else if (t < 0.7408f) return bounce(t - 0.54719f) + 0.7f;
         else if (t < 0.9644f) return bounce(t - 0.8526f) + 0.9f;
         else return bounce(t - 1.0435f) + 0.95f;
     }
 }

您可以在以下 URL 找到这些不同插值器的行为描述:

[`developer.android.com/reference/android/view/animation/package-summary.html`](http://developer.android.com/reference/android/view/animation/package-summary.html)

这些类的 Java 文档还指出了可用于控制它们的 XML 标记。

探索视图动画

通过视图动画,您可以通过操纵视图的变换矩阵来制作视图动画。变换矩阵就像一个透镜,将视图投射到显示器上。变换矩阵会影响投影视图的比例、大小、位置和颜色。

恒等式变换矩阵保留原始视图。从单位矩阵开始,应用一系列数学变换,包括大小、位置和方向。然后,将最终矩阵设置为要转换的视图的转换矩阵。

Android 通过允许向视图注册动画对象来公开视图的转换矩阵。动画对象将被传递给变换矩阵。

考虑将图 18-4 作为视图动画的演示。“开始动画”按钮使列表视图从屏幕中间的小区域开始,逐渐充满整个空间。清单 18-15 显示了用于该活动的 XML 布局文件。

9781430246800_Fig18-04.jpg

图 18-4 。查看动画活动

清单 18-15 。视图动画活动的 XML 布局文件

<?xml version="1.0" encoding="utf-8"?>
<!-- filen: at /res/layout/list_layout.xml(ProAndroid5_ch18_TestViewAnimation.zip) -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">
<Button
   android:id="@+id/btn_animate"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:text="Start Animation"/>
<ListView
     android:id="@+id/list_view_id"
     android:persistentDrawingCache="animation|scrolling"
     android:layout_width="fill_parent"
     android:layout_height="fill_parent"/>
</LinearLayout>

清单 18-16 显示了加载该布局的活动代码。

清单 18-16 。动画前视图动画活动的代码

//filename: ViewAnimationActivity.java(ProAndroid5_ch18_TestViewAnimation.zip)
public class ViewAnimationActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState)    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.list_layout);
        setupListView();
        this.setupButton();
    }
    private void setupListView()    {
      String[] listItems = new String[] {
            "Item 1", "Item 2", "Item 3","Item 4", "Item 5", "Item 6",
      };
      ArrayAdapter<String> listItemAdapter =
        new ArrayAdapter<String>(this,android.R.layout.simple_list_item_1,listItems);
      ListView lv = (ListView)this.findViewById(R.id.list_view_id);
      lv.setAdapter(listItemAdapter);
    }
    private void setupButton()    {
       Button b = (Button)this.findViewById(R.id.btn_animate);
       b.setOnClickListener(
           new Button.OnClickListener(){
             public void onClick(View v)   {
                //animateListView();
             }
           });
    }
}

有了这段代码,你会看到用户界面如图 18-4 所示。为了给图 18-4 所示的列表视图添加动画,我们需要一个从 Android . view . animation . animation 派生的类。清单 18-17 显示了这个类。

清单 18-17 。用于视图动画类的代码

//filename: ViewAnimation.java project: ProAndroid5_ch18_TestViewAnimation.zip
public class ViewAnimation extends Animation {
 @Override
 public void initialize(int width, int height, int parentWidth, int parentHeight){
        super.initialize(width, height, parentWidth, parentHeight);
        setDuration(2500); setFillAfter(true);
        setInterpolator(new LinearInterpolator());
  }
  @Override
  protected void applyTransformation(float interpolatedTime, Transformation t) {
        final Matrix matrix = t.getMatrix();
        matrix.setScale(interpolatedTime, interpolatedTime);
  }
}

在清单 18-7 中 初始化方法是一个带有视图维度的回调方法。动画参数可以在这里初始化。这里动画持续时间被设置为 2.5 秒。通过将 FillAfter 设置为 true ,我们已经将动画效果设置为在动画完成后保持不变。我们设置了一个线性插值器。所有这些属性都来自基本的 Android . view . animation . animation 类。

动画的主要部分发生在应用转换方法中。Android SDK 反复调用这个方法来模拟动画。Android 每次调用该方法,interpoled time 都有不同的值。该值从 0 到 1 变化,取决于动画在初始化期间设置的 2.5 秒持续时间内的位置。当插补时间为 1 时,动画结束。我们在这个方法中的目标是通过名为 t 的转换对象来改变可用的转换矩阵。首先得到矩阵,并改变它的一些东西。当视图被绘制时,新的矩阵将生效。矩阵对象上可用的方法记录在 SDK 中,网址为

[`developer.android.com/reference/android/graphics/Matrix.html`](http://developer.android.com/reference/android/graphics/Matrix.html)

在清单 18-17 中,改变矩阵的代码是

matrix.setScale(interpolatedTime, interpolatedTime);

setScale 方法取两个参数:x 方向的缩放因子和 y 方向的缩放因子。因为插值时间在 0 和 1 之间,您可以直接使用该值作为比例因子。在动画开始时,x 和 y 方向的比例因子都是 0 。动画进行到一半时,该值在 x 和 y 方向上都将为 0.5 。在动画结束时,视图将处于其最大尺寸,因为在 x 和 y 方向的缩放因子都是 1 。这个动画的最终结果是列表视图开始很小,然后变大。清单 18-18 显示了您需要在清单 18-15 中添加活动类并从按钮点击中调用它的函数。

清单 18-18 。视图动画活动的代码,包括动画

private void animateListView()   {
   ListView lv = (ListView)this.findViewById(R.id.list_view_id);
   lv.startAnimation(new ViewAnimation());
}

注意在关于视图动画的这一节中,我们将建议清单 18-18 中的视图动画类的替代实现。在提供的项目中,该类有多种版本,如 ViewAnimation 、 ViewAnimation1 、 ViewAnimation2 和 ViewAnimation3 。后续讨论中的代码片段将在注释中指出这些类中的哪些类持有该代码。示例项目中只有一个用于动画的菜单项。为了测试每一个变化,你必须用相应的版本替换清单 18-18 中的 ViewAnimation() 类,并重新运行程序来查看改变后的动画。

当你用清单 18-17 中的 ViewAnimation 类运行代码时,你会注意到一些奇怪的事情。列表视图不是从屏幕中间均匀变大,而是从左上角变大。这是因为矩阵运算的原点在左上角。为了获得想要的效果,你首先必须移动整个视图,使视图的中心与动画中心(左上角)相匹配。然后,应用矩阵,并将视图移回先前的中心。清单 18-16 中的重写代码如清单 18-19 所示。

清单 18-19 。使用预翻译和后翻译 查看动画

//filename: ViewAnimation1.java project: ProAndroid5_ch18_TestViewAnimation.zip
public class ViewAnimation extends Animation {
    float centerX, centerY;
    public ViewAnimation(){}

    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
        super.initialize(width, height, parentWidth, parentHeight);
        centerX = width/2.0f;  centerY = height/2.0f;
        setDuration(2500);  setFillAfter(true);
        setInterpolator(new LinearInterpolator());
    }
    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        final Matrix matrix = t.getMatrix();
        matrix.setScale(interpolatedTime, interpolatedTime);
        matrix.preTranslate(-centerX, -centerY); matrix.postTranslate(centerX, centerY);
    }
}

预转换和后转换方法在缩放操作之前和之后设置矩阵。这相当于一前一后进行三次矩阵变换。考虑一下清单 18-20 中的代码

清单 18-20 。转换矩阵前后转换的标准模式

matrix.setScale(interpolatedTime, interpolatedTime);
matrix.preTranslate(-centerX, -centerY);
matrix.postTranslate(centerX, centerY);

清单 18-20 中的代码相当于

move to a different center
scale it
move to the original center

你会看到这种前后后的模式经常被应用。你也可以在矩阵类上使用其他方法来实现这个结果,但是这种技术很普通,因为它很简洁。

矩阵类不仅允许你缩放一个视图,还可以通过平移方法移动它,或者通过旋转方法改变它的方向。您可以尝试这些方法,并查看生成的动画。前面“布局动画”一节中介绍的动画都是使用这个 Matrix 类上的方法在内部实现的。

在 2D 使用照相机提供深度感知

Android 中的图形包通过 Camera 类提供了另一个与变换矩阵相关的特性。这个课程提供了对 2D 视角的深度感知。你可以拿我们的 ListView 为例,将它从屏幕上沿 z 轴向后移动 10 个像素,并绕 y 轴旋转 30 度。清单 18-21 是一个使用摄像机操作变换矩阵的例子。

清单 18-21 。使用相机对象

//filename: ViewAnimation2.java project: ProAndroid5_ch18_TestViewAnimation.zip
public class ViewAnimation extends Animation {
    float centerX, centerY;
    Camera camera = new Camera();
    public ViewAnimation(float cx, float cy){
        centerX = cx;  centerY = cy;
    }
    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
        super.initialize(width, height, parentWidth, parentHeight);
        setDuration(2500); setFillAfter(true);
        setInterpolator(new LinearInterpolator());
    }
    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        final Matrix matrix = t.getMatrix();
        camera.save();
        camera.translate(0.0f, 0.0f, (1300 - 1300.0f * interpolatedTime));
        camera.rotateY(360 * interpolatedTime);
        camera.getMatrix(matrix);

        matrix.preTranslate(-centerX, -centerY);
        matrix.postTranslate(centerX, centerY);
        camera.restore();
    }
}

这段代码通过首先将视图 1300 个像素放回 z 轴,然后将它放回 z 坐标为 0 的平面,来为列表视图制作动画。执行此操作时,代码还会围绕 y 轴将视图从 0 旋转到 360 度。相机。清单 18-21 中的 translate(x,y,z) 方法告诉 camera 对象平移视图,这样当 interpolatedTime 为 0 (动画开始时),z 值将为 1300 。随着动画的进行,z 值会越来越小,直到最后,当插补时间变为 1 并且 z 值变为 0 时。

方法 camera . rotatey(360 * interpolated time)利用了相机绕轴的 3D 旋转。在动画开始时,该值将为 0 。动画最后会是 360 。

方法 camera.getMatrix(matrix )获取目前为止在相机上执行的操作,并将这些操作应用于传入的矩阵。一旦代码这样做了,矩阵就有了它所需要的翻译,以获得拥有一个摄像机的最终效果。现在不再需要相机对象,因为矩阵中嵌入了所有操作。然后,在矩阵上做前和后移动中心并将其带回来。最后,您将相机设置为之前保存的原始状态。使用清单 18-21 中的代码,你会看到列表视图以旋转的方式从视图中心到达屏幕前方。由于这个版本的 ViewAnimation 需要额外的构造参数,清单 18-22 显示了如何调用这个版本的 AnimationView :

清单 18-22 。使用预翻译和后翻译 查看动画

//filename: ViewAnimationActivity.java
//project: ProAndroid5_ch18_TestViewAnimation.zip
ListView lv = (ListView)this.findViewById(R.id.list_view_id);
float cx = (float)(lv.getWidth()/2.0);
float cy = (float)(lv.getHeight()/2.0);
lv.startAnimation(new ViewAnimation(cx, cy));

作为我们关于视图动画讨论的一部分,我们向您展示了如何通过扩展一个 Animation 类并将其应用于一个视图来制作任何视图的动画。除了让你操作矩阵(通过相机类直接和间接操作)之外,动画类还让你检测动画中的不同阶段。我们将在接下来讨论这个问题。

探索 AnimationListener 类

Android SDK 有一个监听器接口, AnimationListener ,用于监控动画事件。清单 18-23 通过实现 AnimationListener 接口演示了这些动画事件。

清单 18-23 。动画监听器接口的实现

//filename: ViewAnimationListener.java
//project: ProAndroid5_ch18_TestViewAnimation.zip
public class ViewAnimationListener implements Animation.AnimationListener {
    public ViewAnimationListener(){}
    public void onAnimationStart(Animation animation) {
        Log.d("Animation Example", "onAnimationStart");
    }
    public void onAnimationEnd(Animation animation) {
        Log.d("Animation Example", "onAnimationEnd");
    }
    public void onAnimationRepeat(Animation animation) {
        Log.d("Animation Example", "onAnimationRepeat");
    }
}

在清单 18-23 中的 viewoanimationlistener 类只是记录消息。清单 18-24 中的代码展示了如何将一个动画监听器附加到一个动画对象。

清单 18-24 。将 AnimationListener 附加到动画对象

private void animateListView(){
   ListView lv = (ListView)this.findViewById(R.id.list_view_id);
   //Init width,height and assuming ViewAnimation from Listing 18-21
   ViewAnimation animation = new ViewAnimation(width,height);
   animation.setAnimationListener(new ViewAnimationListener());
   lv.startAnimation(animation);
}

关于变换矩阵的注记

正如你在本章中看到的,矩阵是转换视图和动画的关键。让我们探索一下矩阵类的一些关键方法。

  • Matrix.reset() :将矩阵重置为单位矩阵,这不会导致应用时视图发生变化
  • Matrix.setScale(...一个参数名..):改变尺寸
  • Matrix.setTranslate(...一个参数名..):改变位置模拟移动
  • Matrix.setRotate(...一个参数名..):改变方向
  • Matrix.setSkew(...一个参数名..):扭曲视图

最后四个方法有输入参数。

您可以将矩阵相乘,以复合单个变换的效果。在清单 18-25 中,考虑三个矩阵, m1 、 m2 和 m3 ,它们是单位矩阵:

清单 18-25 。使用预翻译和后翻译 查看动画

m1.setScale(..scale args..);
m2.setTranslate(..translate args..)
m3.setConcat(m1,m2)

用 m1 变换一个视图,然后用 m2 变换结果视图,相当于用 m3 变换同一个视图。注意 m3.setConcat(m1,m2) 不同于 m3.setConcat(m2,m1)。setConcat(matrix1,matrix2) 按照给定的顺序将两个矩阵相乘。

你已经看到了预翻译和后翻译方法用来影响矩阵转换的模式。事实上, pre 和 post 方法并不是 translate 所独有的,对于每一个 set 变换方法,你都有 pre 和 post 的版本。最终,一个预转换如 m1.preTranslate(m2 )相当于

m1.setConcat(m2,m1)

以类似的方式,方法 m1.postTranslate(m2) 等价于

m1.setConcat(m1,m2)

考虑清单 18-26 中的代码

清单 18-26 。翻译前后模式

matrix.setScale(interpolatedTime, interpolatedTime);
matrix.preTranslate(-centerX, -centerY);
matrix.postTranslate(centerX, centerY);

这个清单 18-26 中的代码等同于清单 18-27 中的代码

清单 18-27 。翻译前后模式的等效性

Matrix matrixPreTranslate = new Matrix();
matrixPreTranslate.setTranslate(-centerX, -centerY);

Matrix matrixPostTranslate = new Matrix();
matrixPostTranslate.setTranslate(centerX, centerY);

matrix.setConcat(matrixPreTranslate,matrix);
matrix.setConcat(matrix,matrixPostTranslate);

探索属性动画:新的动画 API

Android 的 3.0 和 4.0 对动画 API 进行了大修。这种新的动画方法被称为属性动画。属性 animation API 范围很广,差异很大,足以将以前的 animation API(3 . x 之前的版本)称为传统 API,即使以前的方法仍然有效且没有被弃用。旧的动画 API 在 android.view.animation 包里。新的动画 API 在 android.animation 包中。新属性动画 API 中的关键概念是:

  • 鼓舞者
  • 价值动画师
  • 对象动画师
  • 动画师集合
  • 动画制作者
  • 动画听众
  • 财产价值持有者
  • 类型评估者
  • 查看属性动画
  • 布局转换
  • XML 文件中定义的动画

我们将在这一章的其余部分讨论这些概念。

了解属性动画

属性动画方法随时间改变属性的值。该属性可以是任何东西,例如独立的整数、浮点数或对象(如视图)的特定属性。例如,通过使用一个名为 ValueAnimator 的动画类,你可以在 5 秒内将一个 int 值从 10 改变到 200(参见清单 18-28 )。

清单 18-28 。一个简单的值动画师

//file: TestBasicValueEvaluator.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
//Define an animator to change an int value from 10 to 200
ValueAnimator anim = ValueAnimator.ofInt(10, 200);

//set the duration for the animation
anim.setDuration(5000); //5 seconds, default 300 ms

//Provide a callback to monitor the changing value
anim.addUpdateListener(
    new ValueAnimator.AnimatorUpdateListener()  {
        public void onAnimationUpdate(ValueAnimator animation) {
            Integer value = (Integer) animation.getAnimatedValue();
            // this code gets called many many times for 5 seconds.
            // The value will range from 10 to 200
        }
    }
);
anim.start();

这个想法很容易理解。一个 ValueAnimator 是一个每 10 毫秒做一件事的机制(这是默认的帧率)。虽然这是默认的帧速率,但是根据系统负载的不同,你可能不会被调用那么多次。对于给定的例子,我们可以预期在 5 秒内被调用 500 次。在模拟器上,我们的测试显示它可能只有 10 倍。然而,最后一次呼叫将接近 5 秒的持续时间。

在为每一帧(每 10 毫秒)调用的相应回调中,您可以选择更新视图或任何其他方面来影响动画。除了 onAnimationUpdate 之外,通用 Animator 上还有其他有用的回调函数。来自 Android SDK 的 AnimatorListener 接口(清单 18-28 ),可以通过其基类 Animator 附加到 ValueAnimator 。所以在一个 ValueAnimator 上,你可以做 addListener(Animator。。参见清单 18-29 。

清单 18-29 。 AnimatorListener 回调接口

public static interface Animator.AnimatorListener {
  abstract void onAnimationStart(Animator animation);
  abstract void onAnimationRepeat(Animator animation);
  abstract void onAnimationCancel(Animator animation);
  abstract void onAnimationEnd(Animator animation);
}

您可以使用清单 18-29 中的这些回调函数在动画期间或之后进一步作用于感兴趣的对象。

属性动画依赖于启动动画的线程上的 android.os.Looper 的可用性。这通常是 UI 线程的情况。当动画线程是主线程时,回调也发生在 UI 线程上。

当你使用 ValueAnimators 和它们的监听器时,请记住这些对象的生命周期。即使你让一个价值动画师的引用离开你的本地范围,价值动画师将继续存在,直到它完成动画。如果你要添加一个监听器,那么监听器持有的所有引用在 ValueAnimator 的生命周期内也是有效的。

为属性动画设计一个测试平台

从价值动画器的基本思想开始,Android 提供了许多派生的方法来制作任意对象的动画,尤其是视图。为了演示这些机制,我们将采用线性布局的简单文本视图,并对其 alpha 属性(模拟透明度动画)以及 x 和 y 位置(模拟移动)进行动画处理。我们将使用图 18-5 作为锚来解释属性动画概念。

9781430246800_Fig18-05.jpg

图 18-5 。展示房产动画的活动

图 18-5 中的每个按钮使用一个独立的机制来激活图底部的文本视图。我们将演示的机制如下:

  • 按钮 1:使用对象动画,在一个视图中淡出和淡入交替点击一个按钮。
  • 按钮 2:使用动画师设置 ,依次运行淡出动画和淡入动画。
  • 按钮 3:使用一个 AnimatiorSetBuilder 对象将多个动画以“之前”、“之后”或“与”的关系捆绑在一起。使用此方法运行与按钮 2 相同的动画。
  • 按钮 4:为按钮 2 的序列动画定义一个 XML 文件,并将其附加到相同动画效果的文本视图中。
  • 按钮 5:使用一个 PropertyValuesHolder 对象,在同一个动画中动画显示文本视图的多个属性。我们将更改 x 和 y 值,将文本视图从右下方移动到左上方。
  • 按钮 6:使用 view property animator 将文本视图从右下角移动到左上角(与按钮 5 动画相同)。
  • 按钮 7:在自定义点对象上使用 TypeEvaluator 将文本视图从右下角移动到左上角(与按钮 5 的动画相同)。
  • 按钮 8:使用关键帧来影响文本视图上的移动和 alpha 变化(与按钮 5 的动画相同,但是是交错的)。

构建图 18-5 中的活动非常简单。您可以在下载项目文件 pro Android 5 _ ch18 _ testpropertyaanimation . zip 中看到该活动的布局和活动代码。让我们从第一个按钮开始。

带有对象动画器的动画视图

图 18-5 (Fadeout: Animator)中的第一个按钮调用清单 18-30 中的 toggleAnimation(View) 方法。

清单 18-30 。使用对象动画制作工具的基本视图动画

//file:TestPropertyAnimationActivity.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public void toggleAnimation(View btnView) {
    Button tButton = (Button)btnView; //The button we have pressed
    //m_tv: is the pointer to the text view
    //Animate the alpha from current value to 0 this will make it invisible
    if (m_tv.getAlpha() != 0)  {
        ObjectAnimator fadeOut = ObjectAnimator.ofFloat(m_tv, "alpha", 0f);
        fadeOut.setDuration(5000);
        fadeOut.start();
        tButton.setText("Fade In");
    }
    //Animate the alpha from current value to 1 this will make it visible
    else   {
        ObjectAnimator fadeIn = ObjectAnimator.ofFloat(m_tv, "alpha", 1f);
        fadeIn.setDuration(5000);
        fadeIn.start();
        tButton.setText("Fade out");
    }
}

清单 18-30 中的代码首先检查文本视图的 alpha 值。如果这个值大于 0 ,那么代码假设文本视图是可见的,并运行一个淡出动画。淡出动画结束时,文本视图将不可见。如果文本视图的 alpha 值为 0 ,那么代码假定文本视图不可见,并运行一个淡入动画使文本视图再次可见。

清单 18-30 中的 ObjectAnimator 代码非常简单。使用静态方法 ofFloat() 在文本视图(m_tv)上获得一个 ObjectAnimator 。这个方法的第一个参数是一个对象(m_tv)。第二个参数是您希望 ObjectAnimator 修改或制作动画的对象的属性名。在文本视图 m_tv 的情况下,该属性名为 alpha 。目标对象需要有一个公共方法来匹配这个名称。对于名为 alpha 的属性,对应的视图对象需要有下面的 set 方法:

view.setAlpha(float f);

第三个参数是动画结束时属性的值。如果指定第四个参数,则第三个参数是起始值,第四个参数是目标值。您可以传递更多的参数,只要它们都是 float s。动画将使用这些值作为动画过程中的中间值。

如果您只指定了“到”值,那么“从”值将通过使用

view.getAlpha();

当你播放这个动画的时候,文字视图会先逐渐消失。清单 18-30 中的代码将按钮重命名为“淡入”现在,如果你再次点击按钮,现在称为“淡入”,运行清单 18-30 中的第二个动画,文本视图将在 5 秒内逐渐出现。

用 AnimatorSet 实现顺序动画

图 18-5 中的按钮 2 一个接一个地运行两个动画:一个淡出,接着是一个淡入。我们可以使用动画监听器回调来等待第一个动画结束,然后开始第二个动画。有一种自动化的方式通过类 AnimatorSet 来运行动画,以获得相同的效果。在清单 18-31 中,按钮 2 演示了这一点。

清单 18-31 。通过动画师设置 的连续动画

//file:TestPropertyAnimationActivity.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public void sequentialAnimation(View bView) {
    ObjectAnimator fadeOut = ObjectAnimator.ofFloat(m_tv, "alpha", 0f);
    ObjectAnimator fadeIn = ObjectAnimator.ofFloat(m_tv, "alpha", 1f);
    AnimatorSet as = new AnimatorSet();
    as.playSequentially(fadeOut,fadeIn);
    as.setDuration(5000); //5 secs
    as.start();
}

在清单 18-31 中,我们创建了两个动画制作人:一个淡出动画制作人和一个淡入动画制作人。然后,我们创建了一个动画师集,并告诉它按顺序播放这两个动画。

您还可以通过调用方法 playTogether() 来选择使用 animator 集合一起播放动画。这两种方法, playSequentially() 和 playTogether() ,都可以接受数量可变的动画师对象。

当你播放这个动画时,文本视图会逐渐消失,然后重新出现,很像你之前看到的动画。

用 AnimatorSet 设置动画关系。建设者

AnimatorSet 还提供了一种更加精细的方式,通过一个叫做 AnimatorSet 的工具类来链接动画。建造者。清单 18-32 展示了这一点。

清单 18-32 。使用 AnimatorSetBuilder

//filename: TestPropertyAnimationActivity.java (ProAndroid5_ch18_TestPropertyAnimation.zip)
public void testAnimationBuilder(View v) {
    ObjectAnimator fadeOut = ObjectAnimator.ofFloat(m_tv, "alpha", 0f);
    ObjectAnimator fadeIn = ObjectAnimator.ofFloat(m_tv, "alpha", 1f);
    AnimatorSet as = new AnimatorSet();
    //play() returns the nested class: AnimatorSet.Builder
    as.play(fadeOut).before(fadeIn);
    as.setDuration(5000); //5 secs
    as.start();
}

一个动画师集上的 play 方法返回一个名为动画师集的类。建造者。这纯粹是一个工具类。这个类上的方法有 after(动画师)、before(动画师)、和 with(动画师)。这个类由你通过 play 方法提供的第一个动画师初始化。对这个对象的每一个其他调用都与这个原始动画师有关。考虑清单 18-33 中的:

清单 18-33 。使用动画集。建设者

AnimatorSet.Builder builder = someSet.play(main_animator).before(animator1);

用这个代码动画师 1 将在主 _ 动画师之后播放。当我们说 builder.after(animator2) 时,animator2 的动画会在 main_animator 之前播放。方法与(animator) 一起播放动画。

使用 AnimationBuilder 的关键点在于,通过 before() 、 after() 和 with() 建立的关系不是链式的,而只是绑定到从 play ()方法获得的原始 animator。此外,动画 start() 方法不在构建器对象上,而是在原始 animator 集上。当您通过 Button3 播放这个动画时,文本视图将逐渐消失,然后重新出现,就像上一个动画一样。

使用 XML 加载动画

Android SDK 允许在 XML 资源文件中描述动画制作人,这是唯一可以期待的。Android SDK 有一个新的资源类型叫做 R.animator 来区分 animator 资源文件。这些 XML 文件存储在 /res/animator 子目录中。清单 18-34 是一个在 XML 文件中定义的动画集的例子。

清单 18-34 。一个 Animator XML 资源文件

<?xml version="1.0" encoding="utf-8" ?>
<!-- file: /res/animator/fadein.xml (ProAndroid5_ch18_TestPropertyAnimation.zip) -->
<set xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:ordering="sequentially">
<objectAnimator
    android:interpolator="@android:interpolator/accelerate_cubic"
    android:valueFrom="1"  android:valueTo="0"
    android:valueType="floatType"  android:propertyName="alpha"
    android:duration="5000" />
<objectAnimator
    android:interpolator="@android:interpolator/accelerate_cubic"
    android:valueFrom="0"  android:valueTo="1"
    android:valueType="floatType" android:propertyName="alpha"
    android:duration="5000" />
</set>

您自然会想知道有什么 XML 节点可以用来定义这些动画。从 4.0 开始,允许的 XML 标签如下:

  • 动画师:绑定到值动画师
  • objectAnimator :绑定到 ObjectAnimator
  • 设置:绑定到动画师设置

您可以在下面的 Android SDK URL 中看到关于这些标签的基本讨论:

[`developer.android.com/guide/topics/graphics/prop-animation.html#declaring-xml`](http://developer.android.com/guide/topics/graphics/prop-animation.html#declaring-xml)

动画标签的完整 XML 参考可在以下 URL 找到:

[`developer.android.com/guide/topics/resources/animation-resource.html#Property`](http://developer.android.com/guide/topics/resources/animation-resource.html#Property)

一旦你有了这个 XML 文件,你就可以使用清单 18-35 所示的方法来播放这个动画。

清单 18-35 。加载 Animator XML 资源文件

//file: TestPropertyAnimationActivity.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public void sequentialAnimationXML(View bView) {
  AnimatorSet set = (AnimatorSet)AnimatorInflater.loadAnimator(this, R.animator.fadein);
  set.setTarget(m_tv);
  set.start();
}

请注意,首先加载动画 XML 文件,然后显式地将对象设置为动画是多么必要。在我们的例子中,要制作动画的对象是由 m_tv 表示的文本视图。清单 18-35 中的方法被按钮 4 调用(FadeOut/FadeIn XML)。当这个动画运行时,文本视图将首先淡出,然后通过淡入重新出现,就像以前的 alpha 动画一样。

使用 PropertyValuesHolder

到目前为止,我们已经看到了如何在单个动画中制作单个值的动画。类 PropertyValuesHolder 让我们在动画周期中制作多个值的动画。清单 18-36 演示了 PropertyValuesHolder 类的使用。

清单 18-36 。使用 PropertyValueHolder 类

//file: TestPropertyAnimationActivity.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public void testPropertiesHolder(View v) {
    //Get the current coordinates of the text view.
    //This allows us to know starting and ending positions to animate
    float h = m_tv.getHeight(); float w = m_tv.getWidth();
    float x = m_tv.getX(); float y = m_tv.getY();

    //Set the view to the bottom right as a starting point
    m_tv.setX(w); m_tv.setY(h);

    //from the right bottom animate "x" to its original position: top left
    PropertyValuesHolder pvhX = PropertyValuesHolder.ofFloat("x", x);

    //from the right bottom animate "y" to its original position
    PropertyValuesHolder pvhY = PropertyValuesHolder.ofFloat("y", y);

    //when you do not specify the from position, the animation will take the current position
    //as the from position.

    //Tell the object animator to consider both
    //"x" and "y" properties to animate to their respective target values.
    ObjectAnimator oa = ObjectAnimator.ofPropertyValuesHolder(m_tv, pvhX, pvhY);

    //set the duration
    oa.setDuration(5000); //5 secs

    //here is a way to set an interpolator on any animator
    oa.setInterpolator(new  AccelerateDecelerateInterpolator());
    oa.start();
}

一个 PropertyValuesHolder 类保存一个属性名及其目标值。然后你可以定义许多这些 PropertyValuesHolder 用它们自己的属性来制作动画。您可以将这组 PropertyValuesHolder 提供给对象动画师。然后,对象动画制作者会将这些属性设置为它们在目标对象上各自的值。随着动画的每次刷新,来自每个 PropertyValuesHolder 的所有值将一次全部应用。这比并行应用多个动画更有效。

图 18-5 中的按钮 5 运行清单 18-36 中的代码。当这个动画运行时,文本视图将从右下角出现,并在 5 秒钟内迁移到左上角。

了解视图属性动画

Android SDK 有一个优化的方法来激活视图的各种属性。这是通过一个名为 viewpropertyimator 的类来完成的。清单 18-37 使用这个类将文本视图从右下角移动到左上角。

清单 18-37 。使用视图属性动画器

//file: TestPropertyAnimationActivity.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public void testViewAnimator(View v) {
    //Remember current boundaries
    float h = m_tv.getHeight(); float w = m_tv.getWidth();
    float x = m_tv.getX(); float y = m_tv.getY();

    //Position the view at bottom right
    m_tv.setX(w);  m_tv.setY(h);

    //Get a ViewPropertyAnimator from the text view
    ViewPropertyAnimator vpa = m_tv.animate();

    //Set as many target values you want to set
    vpa.x(x);  vpa.y(y);

    //Set duration and interpolators
    vpa.setDuration(5000); //2 secs
    vpa.setInterpolator(new  AccelerateDecelerateInterpolator());

    //The animation automatically starts when the UI thread gets to it.
    //No need to explicitly call the start method.
    //vpa.start();
}

使用 viewpropertyimator 的步骤如下:

  1. 通过调用视图上的 animate() 方法来获得 viewpropertyimator。
  2. 使用 viewpropertyianimator 对象来设置该视图的各种最终属性,如 x 、 y 、 scale 、 alpha 等等。
  3. 让 UI 线程通过从函数返回来继续。动画将自动开始。

该动画由按钮 6 调用。当此动画运行时,文本视图将从右下方迁移到左上方。

了解类型评估者

正如我们所见,对象动画师在每个动画周期中直接在目标对象上设置特定值。到目前为止,这些值都是单点值,例如 float s, int s,等等。如果你的目标对象有一个本身就是对象的属性,会发生什么?这就是类型赋值器发挥作用的地方。

为了说明这一点,考虑一个视图,我们想要在其上设置两个值,比如' x 和' y '。清单 18-35 展示了我们如何封装一个常规视图,我们知道如何改变 x 和 y 。封装将允许动画通过 Android 图形包中可用的 PointF 抽象为 x 和 y 调用一次。我们将提供一个 setPoint(PointF) 方法,然后在该方法中解析出 x 和 y 并在视图上设置它们。看一下清单 18-38 。

清单 18-38 。通过类型评估器 制作视图动画

//file: AnimatableView.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public class MyAnimatableView {
    PointF curPoint = null; View m_v = null;
    public MyAnimatableView(View v)    {
        curPoint = new PointF(v.getX(),v.getY());
        m_v = v;
    }
    public PointF getPoint()    {
        return curPoint;
    }
    public void setPoint(PointF p) {
        curPoint = p;
        m_v.setX(p.x);
        m_v.setY(p.y);
    }
}

在代码清单 18-38 中,TypeEvaluator 是一个助手对象,它知道如何在动画周期中设置一个复合值,比如一个二维或三维点。在涉及复合字段(表示为一个对象)的场景中, ObjectAnimator 将获取起始复合值(类似于 x 和 y 的复合的 PointF 对象)、结束复合值,并将它们传递给 TypeEvaluator 辅助对象以获取中间对象值。然后在目标对象上设置这个复合值。清单 18-39 显示了 TypeEvlautor 如何通过它的 evaluate 方法计算这个中间值。

清单 18-39 。编码一个类型评估器

//file: MyPointEvaluator.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public class MyPointEvaluator implements TypeEvaluator<PointF> {
  public PointF evaluate(float fraction, PointF startValue, PointF endValue) {
        PointF startPoint = (PointF) startValue;
        PointF endPoint = (PointF) endValue;
        return new PointF(
            startPoint.x + fraction * (endPoint.x - startPoint.x),
            startPoint.y + fraction * (endPoint.y - startPoint.y));
    }
}

从清单 18-39 中可以看出,您需要从 TypeEvaluator 接口继承并实现 evaluate() 方法。在这种方法中,您将获得动画总进度的一部分。您可以使用该分数来调整中间复合值,并将其作为类型化值返回。

清单 18-40 展示了一个 ObjectAnimator 如何使用 MyAnimatableView 和 MyPointEvaluator 来为一个视图的复合值制作动画。

清单 18-40 。使用型评估器

//file: TestPropertyAnimationActivity.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public void testTypeEvaluator(View v) {
    float h = m_tv.getHeight(); float w = m_tv.getWidth();
    float x = m_tv.getX(); float y = m_tv.getY();

    PointF startingPoint = new PointF(w,h);
    PointF endingPoint = new PointF(x,y);

    //m_atv: You will need this code in your activity earlier as a local variable:
    MyAnimatableView m_atv = new MyAnimatableView(m_tv);

    ObjectAnimator viewCompositeValueAnimator =
        ObjectAnimator.ofObject(m_atv
            ,"point", new MyPointEvaluator()
            ,startingPoint, endingPoint);

    viewCompositeValueAnimator.setDuration(5000);
    viewCompositeValueAnimator.start();
}

注意清单 18-40 中的注意到对象动画制作人正在使用方法 ofObject() 而不是 ofFloat() 或 ofInt() 。还要注意,动画的起始值和结束值是由类 PointF 表示的复合值。对象动画器的目标现在是为 PointF 提供一个中间值,然后将其传递给自定义类 MyAnimatableView 上的方法 setPoint(PointF) 。类 MyAnimatableView 可以相应地在包含的文本视图上设置各自的属性。使用类型评估器的清单 18-40 中的动画由按钮 7 调用。当此动画运行时,视图将从右下方迁移到左上方。

理解关键帧

在动画周期中,关键帧是放置关键时间标记(重要的时间实例)的有用位置。关键帧指定在给定时刻某个属性的特定值。关键标记的时间介于 0(动画开始)和 1(动画结束)之间。一旦你收集了这些关键帧值,你就将它们与一个特定的属性相对应,比如 alpha 、 x 或者 y 。关键帧与其各自属性的关联是通过 PropertyValuesHolder 类完成的。然后,您告诉 ObjectAnimator 为生成的 PropertyValuesHolder 制作动画。清单 18-41 演示了关键帧动画。

清单 18-41 。使用关键帧制作视图动画

//file:TestPropertyAnimationActivity.java(ProAndroid5_ch18_TestPropertyAnimation.zip)
public void testKeyFrames(View v) {
    float h = m_tv.getHeight();   float w = m_tv.getWidth();
    float x = m_tv.getX();  float y = m_tv.getY();

    //Start frame : 0.2, alpha: 0.8
    Keyframe kf0 = Keyframe.ofFloat(0.2f, 0.8f);

    //Middle frame: 0.5, alpha: 0.2
    Keyframe kf1 = Keyframe.ofFloat(.5f, 0.2f);

    //end frame: 0.8, alpha: 0.8
    Keyframe kf2 = Keyframe.ofFloat(0.8f, 0.8f);

    PropertyValuesHolder pvhAlpha =
        PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1, kf2);
    PropertyValuesHolder pvhX =
        PropertyValuesHolder.ofFloat("x", w, x);

    //end frame
    ObjectAnimator anim =
        ObjectAnimator.ofPropertyValuesHolder(m_tv, pvhAlpha,pvhX);
    anim.setDuration(5000);
    anim.start();
}

清单 18-41 中的动画由按钮 8 调用。当此动画运行时,您将看到文本从右向左移动。当 20%的时间过去后,α会变为 80%。 alpha 值将在中途达到 20%,并在动画时间的第 80 百分位变回 80%。

了解布局转换

属性动画 API 还通过 LayoutTransition 类提供基于布局的动画。这个类作为标准 API Java 文档的一部分在下面的 URL 中有很好的记录。

[`developer.android.com/reference/android/animation/LayoutTransition.html`](http://developer.android.com/reference/android/animation/LayoutTransition.html)

这里我们将只总结布局转换的关键点。要在一个视图组上启用布局转换(大多数布局都是视图组),您需要使用清单 18-42 中的代码。

清单 18-42 。设置布局转换

viewgroup.setLayoutTransition(
  new LayoutTransition()
);

使用清单 18-42 中的代码,布局容器(视图组)将在添加和删除视图时显示默认转换。LayoutTransition 对象有四种不同的默认动画,涵盖以下每种情况:

  • 添加视图(由于添加或放映而出现的视图的动画)
  • 变化出现(布局中其余项目的动画,因为它们可能会由于添加新项目而改变其大小或外观)
  • 移除视图(因移除或隐藏而消失的视图的动画)
  • 变化消失(布局中剩余项目的动画,因为它们可能由于项目被移除而改变大小或外观)

如果您想要为每种情况定制动画,您可以在 LayoutTransition 对象上设置它们。在清单 18-43 中有一个例子。

清单 18-43 。布局过渡方法

//Here is how you get a new layout transition
LayoutTransition lt = new LayoutTransition();

//You can set this layout transition on a layout
someLayout.setLayoutTransition(lt);

//obtain a default animator if you need to remember
Animator defaultAppearAnimator = lt.getAnimator(APPEARING);

//create a new animator
ObjectAnimator someNewObjectAnimator1, someOtherObjectAnimator2;

//set it as your custom animator for the allowed set of animators
lt.setAnimator(APPEARING, someNewObjectAnimator1);
lt.setAnimator(CHANGE_APPEARING, someNewObjectAnimator1);
lt.setAnimator(DISAPPEARING, someNewObjectAnimator1);
lt.setAnimator(CHANGE_DISAPPEARING, someOtherObjectAnimator2);

因为您提供给布局过渡的动画应用于每个视图,所以动画在应用于每个视图之前会在内部克隆。

资源

当你使用 Android 动画 API 时,这里有一些有用的链接:

摘要

在这一章中,我们已经介绍了逐帧动画、布局动画、视图动画、插值器、变换矩阵、摄像机以及使用新属性动画 API 的各种方法。所有的概念都有可用的代码片段,并有可用的可下载项目支持。

十九、探索地图和基于位置的服务

在本章中,我们将讨论地图和基于位置的服务。基于位置的服务是 Android SDK 最令人兴奋的部分之一。SDK 的这一部分提供了 API,让应用开发人员可以显示和操作地图,获取实时设备位置信息,并利用其他令人兴奋的功能。当谷歌推出了地图片段和谷歌地图 API 的第二版时,地图的使用发生了巨大的变化。本章将详细介绍创建和操作地图的新方法。

Android 中基于位置的服务设施基于两大支柱:地图和基于位置的 API。Android 中的地图 API 提供了显示和操作地图的工具。比如可以缩放、平移;您可以更改地图模式(例如,从卫星视图到交通视图);您可以向地图添加标记和自定义数据;诸如此类。另一端是全球定位系统(GPS)数据和位置信息,这两者都由位置包处理。

这些 API 通常通过 Google Play Services (设备上的本地 uber 应用)跨越互联网从谷歌服务器调用服务。因此,你通常需要有互联网连接,这些工作。此外,谷歌有一些服务条款,你必须同意这些条款,然后才能使用这些谷歌服务开发应用。仔细阅读条款;谷歌对你可以用服务数据做什么设置了一些限制。例如,您可以将位置信息用于用户的个人用途,但某些商业用途受到限制,例如涉及车辆自动控制的应用。当您注册 Maps API 密钥时,将会看到这些条款。

在本章中,我们将逐一介绍这些软件包。我们将从地图 API 开始,向您展示如何在您的应用中使用地图。正如你将看到的,除了与谷歌地图集成的映射 API 之外,Android 中的映射归结为使用 MapFragment 类。我们还将向您展示如何在您显示的地图上放置自定义数据,以及如何在地图上显示设备的当前位置。在讨论了地图之后,我们将深入研究基于位置的服务,它扩展了地图的概念。我们将向您展示如何使用 Android 地理编码器类和位置服务服务。我们还将触及使用这些 API 时出现的线程问题。

了解制图包

正如我们提到的,地图 API 是 Android 基于位置服务的组件之一。制图包几乎包含了在屏幕上显示地图、处理用户与地图的交互(如缩放)、在地图顶部显示自定义数据等所需的一切。在旧版本的 Android Maps 中,您的应用将直接与谷歌地图服务进行对话,处理所有与地图相关的事情。在新版本中,您的应用必须与 Google Play 服务对话,这是设备上的一个本地应用,作为操作系统的一部分提供。您的应用仍然可以通过互联网拨打电话获取数据,但如果设备上没有本地 Google Play 服务,您的地图将无法使用。如果你需要在没有 Google Play 服务的设备上使用地图,你需要探索一个适用于 Android 的其他地图包(例如 MapQuest)。

为了让您的应用与 Google Play 服务对话,您需要将 Google Play 服务库包含到您的应用中。Android Studio 的做法与 Eclipse 的 ADT 有所不同。请参阅下面的参考资料部分,获取在线说明的链接,了解最新的方法。在您的应用中包含 Google Play 服务库之前,您必须首先通过 SDK 管理器下载它。你会在附加项目下找到它。

您可能已经注意到,除了 Android SDK 平台之外,您的 Android SDK 管理器还显示了 Google API 包。以前,为了使用地图,你必须让你的应用基于 Google APIs 包,但现在不再是这样了。相反,地图 API 集成到 Google Play 服务中,因此您的应用可以基于常规的 Android 包。然而,要在模拟器中测试一个基于地图的应用,你需要将你的模拟器的 Android 虚拟设备(AVD) 基于一个 Google APIs 包。稍后更多关于测试应用的内容。

使用地图包的第一步是显示地图。为此,您将使用 MapFragment (或者 SupportMapFragment ,如果您想要向后兼容 API 12 之前的 Android 版本,也就是 Honeycomb 3.1)。然而,使用这个类需要一些准备工作。具体来说,在使用谷歌地图服务之前,你需要从谷歌获得一个地图 API 密钥。地图 API 键使 Android 能够与谷歌地图服务交互以获取地图数据。下一节将解释如何获取 Maps API 密钥。

从谷歌获取地图 API 密钥

谷歌希望能够识别连接到地图服务的应用。它使用应用包和用于签署应用的证书的组合来生成 Maps API 密钥,应用必须使用该密钥来请求服务。Maps API 密匙可以跨多对包和证书使用。这意味着您可以在开发和生产中使用相同的 Maps API 密钥;包是一样的,但是证书可能是不同的。理论上,您可以在多个应用中使用同一个密钥,但是不鼓励这样做。你无论如何都不想这么做,因为谷歌对地图 API 的使用有一定的限制,而且通过与多个应用共享一个地图 API 密钥,你可以更容易地超过限制。

要获取 Maps API 密钥,您需要用于对应用进行签名的证书(如果是应用的开发版本,则需要调试证书)。您将获得证书的 SHA-1 指纹,然后将它与您的应用包一起输入到 Google 的网站上,以生成一个相关的 Maps API 密钥。

首先,您必须找到由 Eclipse 生成和维护的调试证书。您可以使用 Eclipse IDE 找到确切的位置。如果您使用的是 Eclipse 之外的 IDE,那么您只需要找到保存证书的 keystore 文件。从 Eclipse 的 Preferences 菜单,进入 Android image Build。调试证书的位置将显示在默认调试密钥库字段中,如图图 19-1 所示。

9781430246800_Fig19-01.jpg

图 19-1 。调试证书的位置

要提取 SHA-1 的指纹,您可以使用–列表选项运行 keytool ,如下所示:

keytool -list -alias androiddebugkey -keystore
"FULL PATH OF YOUR debug.keystore FILE" -storepass android -keypass android

注意,您想要从调试存储中获得的别名是 androiddebugkey 。同样,密钥库密码是 android ,私钥密码也是 android 。运行该命令时, keytool 提供指纹(参见图 19-2 )。

9781430246800_Fig19-02.jpg

图 19-2 。列表选项的 keytool 输出

你会注意到 keytool 命令显示的指纹与图 19-1 所示的首选项屏幕中显示的指纹是一样的,所以你可以从那个屏幕中获取指纹。但是现在您知道了为您的应用提取 SHA-1 指纹的两种方法。当您使用 keytool 提取生产证书的 SHA-1 指纹时,您将使用为生产证书设置的密钥库文件、别名和密码。

下一步是转到 Google 的开发者控制台添加您的应用,然后启用 Maps API。结果将是要包含在应用中的地图 API 密钥。开发者控制台就在这里,你需要一个谷歌账号才能进入:

[`console.developers.google.com`](https://console.developers.google.com)

您需要创建一个新项目。作为创建新项目的一部分,您需要提供项目名称和项目 ID。项目 ID 将预先填充一些奇怪的东西。你可以在这里放任何你想要的值,只要它是唯一的。但是,项目 ID 只是供 Google 开发者控制台使用;它与您的应用的源代码无关。请记住,您正在基于本章示例项目的代码创建一个示例项目,这样您就可以获得一个 Maps API 密钥来查看它的工作情况。

通读服务条款。如果您同意这些条款,请单击“创建”创建您的新项目。这与谷歌建立了一个项目的基本模板。接下来,您将启用您想要的 API。对于地图应用,您将选择谷歌地图 Android API v2。对于本章的示例应用,您还希望包含地理编码 API。你可能会看到一个名为“为你的应用名>配置安卓密钥”的弹出窗口。如果没有弹出窗口,可以在开发人员控制台中导航到项目的 API&authimageCredentials 部分,并在那里生成一个 API 密匙。在这里,您需要复制并粘贴应用签名证书的 SHA-1 指纹和应用的包名,用分号分隔。包名是源代码中的包名。请注意,您可以在多行中复制,因此如果您有来自生产应用签名证书的 SHA-1 指纹(它通常不同于开发中使用的 androiddebugkey),您可以为生产应用添加第二行。

一旦你按下这个屏幕上的创建按钮,你就会得到一个 API 密匙。这是您将包含在应用的 AndroidManifest.xml 文件中的内容。API 键立即激活,因此您可以开始使用它从 Google 获取地图数据。

将地图 API 密钥添加到您的应用中

要查看如何将 Maps API 键添加到清单文件中,请参见清单 19-1 的底部。

清单 19-1 。简单地图应用的 AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    package="com.androidbook.maps.whereami"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="10" android:targetSdkVersion="19" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-feature
        android:glEsVersion="0x00020000" android:required="true"/>

    <application
        android:allowBackup="true" android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name="com.androidbook.maps.whereami.MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <meta-data android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />
        <meta-data
            android:name="com.google.android.maps.v2.API_KEY"
            android:value="AIzaSyBDs1ZQgu9X2A4TG1a7fPl-Ge_MKlyviKM"/>
    </application>
</manifest>

正如您无疑已经注意到的那样,清单文件中还有其他一些元素必须存在,地图应用才能正常工作。Maps API 键上方的 <元数据> 标记是必需的,靠近顶部的权限也是必需的。从技术上讲,显示地图不需要 ACCESS_FINE_LOCATION 权限;它在那里,所以定位功能(例如,GPS)将工作。GPS 通常用于地图应用。 ACCESS_NETWORK_STATE 和 INTERNET 权限,因此地图应用可以下载地图切片数据(即地图图形)并了解应用的网络连接类型和状态。有了 WRITE_EXTERNAL_STORAGE 权限,地图应用可以在设备的本地存储空间上创建地图切片文件的本地缓存。如果没有缓存,地图应用可能会花费大量时间反复下载地图切片,这不仅对您的应用来说效率低下,而且会给 Google 服务器带来不必要的负担,并且可能会消耗用户数据计划的很大一部分。最后, glEsVersion 特性之所以存在,是因为在屏幕上渲染地图使用了 OpenGL,因此通过要求该特性,应用可以避免安装在无法显示地图的设备上。

现在,让我们开始玩地图。

了解地图片段

地图应用的基础构件是地图片段。这是在 Honeycomb (Android 3.1)中引入的,取代了的 MapView 和的 MapActivity 功能。现在你可以在一个常规的 Android 活动中嵌入一个 MapFragment 。如果您希望您的应用在运行旧版本 Android 的设备上运行,您可以使用 SupportMapFragment 并将其嵌入到 fragmentation activity 中。 MapFragment 包含显示地图的地图视图,它处理用户手势来操作地图,并且它管理与 Google 服务对话以检索地图数据的后台线程。

MapFragment 是一个非常好的功能包,但它并不是你在设备上使用地图所需的全部。幸运的是,与 Google Play 服务的集成全部为您处理;您所需要做的就是在您的应用的 AndroidManifest.xml 文件中创建一个特殊的条目,您可以在上一节中看到它。

本章的第一个示例应用将简单地向用户显示地图,并让用户探索地图。

注意注意我们在本章末尾给了你一个 URL,你可以从本章下载项目。这将允许您将这些项目直接导入到 IDE 中。还要注意,如果您想用 Android 模拟器测试这些示例,请确保 Android 虚拟设备(AVD)是用 Google APIs 构建的。

请参考名为 WhereAmI 的示例项目。该应用由一个非常基本的 fragmentation activity,一个非常简单的布局,和一个 SupportMapFragment 组成。该示例使用了兼容性类,这意味着它可以在 Gingerbread 设备和最新型号的设备上运行。如果你的应用只需要在比 Honeycomb 3.0 更新的设备上运行,你可以使用一个常规活动和一个 MapFragment 来代替。

清单 19-2 显示了活动。所有需要做的就是设置布局,如果需要,创建 MapFragment 并将其插入到布局的容器中(一个框架布局)。

清单 19-2 。 显示地图的基本分片功能

public class MainActivity extends FragmentActivity {
    private static final String MAPFRAGTAG = "MAPFRAGTAG";
    private MyMapFragment myMapFrag;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if ((myMapFrag = (MyMapFragment) getSupportFragmentManager()
                .findFragmentByTag(MAPFRAGTAG)) == null) {
            myMapFrag = MyMapFragment.newInstance();
            getSupportFragmentManager().beginTransaction()
                .add(R.id.container, myMapFrag, MAPFRAGTAG).commit();
        }
    }
}

例如,如果由于方向改变而重新创建活动,地图片段将仍然可用,并由 Android 自动附加到新活动。如果找不到地图片段,则表示这是第一次使用,或者地图片段已被破坏,因此创建一个新的地图片段并附加它。没有比这更简单的了。布局源如清单 19-3 所示。它只是一个框架布局,带有一个“容器的 id ,填充了可用的屏幕空间。

清单 19-3 。简单地图显示布局(activity_main.xml)

<FrameLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    xmlns:tools="[`schemas.android.com/tools`](http://schemas.android.com/tools)"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.androidbook.maps.whereami.MainActivity"
    tools:ignore="MergeRootFrame" />

如果您将地图片段与其他项目一起包含在您的用户界面中,您可以简单地在您希望地图片段出现的地方使用框架布局,嵌入在其他布局中。唯一剩下的代码是 MapFragment 的代码,如清单 19-4 所示。图 19-3 显示了用户看到的内容。

清单 19-4 。地图片段的代码

public class MyMapFragment extends SupportMapFragment
    implements OnMapReadyCallback {
    private GoogleMap mMap = null;

    public static MyMapFragment newInstance() {
        MyMapFragment myMF = new MyMapFragment();
        return myMF;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getMapAsync(this);
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        setRetainInstance(true);
    }

    @Override
    public void onResume() {
        super.onResume();
        doWhenMapIsReady();
    }

    @Override
    public void onPause() {
        super.onPause();
        if(mMap != null)
            mMap.setMyLocationEnabled(false);
    }

    @Override
    public void onMapReady(GoogleMap arg0) {
        mMap = arg0;
        doWhenMapIsReady();
    }

    /* We have a race condition where the fragment could resume
     * before or after the map is ready. So we put all our logic
     * for initializing the map into a common method that is
     * called when the fragment is resumed or resuming and the
     * map is ready.
     */
    void doWhenMapIsReady() {
        if(mMap != null && isResumed())
            mMap.setMyLocationEnabled(true);
    }
}

9781430246800_Fig19-03.jpg

图 19-3 。显示您位置的基本地图片段

地图应用编程接口的最新发展(2014 年 12 月)是使用回调来让应用知道地图何时可以被操作。使用 getMapAsync() 设置回调,当应用可以使用地图时,调用 onMapReady() 回调。在调用 getMapAsync() 和调用 onMapReady() 回调之间,Android 正在设置通信、线程等。,供图。这意味着当调用 onResume() 时,地图可能准备好了,也可能没有准备好,这告诉片段 UI 正在被显示。因此,应用需要一个单独的方法来处理地图,并且需要由 onResume() 和 onMapReady() 调用。对于这个示例应用, doWhenMapIsReady() 方法充当了这个角色。

应用希望向用户显示设备的当前位置,因此在 doWhenMapIsReady() 中调用了 setMyLocationEnabled() 方法。但是 doWhenMapIsReady() 需要检查映射是否存在以及片段是否正在恢复或者已经恢复。我们不知道哪一个会先发生,但在我们启用位置更新之前,两者都必须是真的。当片段离开视图时,当前位置更新被禁用(参见 onPause() )。另一个需要注意的代码行是 setRetainInstance() 方法调用。由于不需要为活动的配置更改而销毁和重新创建映射,因此保留片段并重用它以及线程和瓦片等是有意义的。您应该记住,配置更改将导致在配置更改期间调用 onPause() 和 onResume() 。这将正确禁用位置更新,并在 onResume() 期间重新启用它们。

地图控件:我的位置、缩放、平移

用户界面上有几个工件值得注意。首先是右上角的 MyLocation 按钮。当您第一次启动示例应用时,您将看到一个非常高级的世界视图。要显示当前位置,请轻按“我的位置”按钮。这将把地图重新定位到当前位置并放大。第二个是蓝点。蓝点代表应用认为你在哪里,圆圈代表它认为这个位置有多准确。该圆可以随着位置信息的改变而增大或缩小。

用户可以使用捏手势(即,将两个手指分开或合在一起)来放大或缩小。用户在地图上可以做的手势更多。通过滑动,用户可以平移地图;也就是说,他们可以移动地图来查看附近的区域。使用两个手指和一个旋转移动,用户可以旋转地图。简单地创建一个 MapFragment 就可以自动实现很多功能。

这些地图控件和更多控件包含在一个 UiSettings 类的对象中。您可以通过调用 GoogleMap 对象上的 getUiSettings() 来获得地图的 UiSettings (即示例应用中的 mMap )。然后,您可以通过编程方式修改这些设置。例如,您可以使指南针显示在地图上,或者您可以启用/禁用缩放加/减控件,使其显示或不显示。缩放加/减控件出现在右下角,允许用户通过分别点击加或减按钮来放大或缩小。

地图类型

默认地图类型为 MAP_TYPE _NORMAL 。这是在图 19-3 的中使用的类型。它显示了道路与土地的基本特征,如水在哪里,绿地在哪里,以及一些地方和建筑物。MAP_TYPE_SATELLITE 显示地面的摄影卫星视图,因此用户能够看到真实的建筑物、汽车,甚至人。 MAP_TYPE_HYBRID 是这两者的结合;MAP_TYPE_TERRAIN 类似于普通地图,但添加了地形特征,如山脉和峡谷。要真正看到 MAP_TYPE_TERRAIN 的效果,放大科罗拉多州博尔德这样的地方,将地图设置为地形。

您使用一个 GoogleMap 的 setMapType() 方法来改变类型。

添加流量图层

在之前版本的安卓地图中,交通就像地图的卫星和普通模式一样。在 API v2 中,流量是使用 GoogleMap 的 setTrafficEnabled() 方法单独启用的。

地图切片

当您的应用显示地图时,了解正在发生的事情会很有帮助。谷歌已经创建了数以百万计的底图来代表地球表面。在最低缩放级别(即,零),有一个平铺显示整个世界。在缩放级别 1,有四个 2x2 配置的单幅图块。在缩放级别 2,4x4 配置中有 16 个单幅图块。依此类推直到缩放级别 21。根据您想要显示世界的哪个部分以及缩放级别,GoogleMap 对象将获取并缓存适当的图块。平移到侧面,将获取并显示任何附加的图块。平移回您所在的位置,您的应用可以从缓存中检索地图切片,而不是往返于服务器之间。

有趣的是,普通类型地图的底图切片不是图像。谷歌想出了一种压缩的方式来描述瓷砖的形状和颜色,而不是只发送每个瓷砖的图像。因此,普通地图切片在缓存空间和网络带宽方面非常有效。另一方面,卫星图像块没有被压缩,因为它们是图像。

现在你可以理解为什么有时一个地图应用会显示一个灰色的网格图案,看起来可以工作,但不会显示街道和其他项目。GoogleMap 对象已经被实例化,它知道缩放级别以及应该在哪里显示地图,但是它无法检索并向用户呈现图块。这通常是由于无效的地图 API 键,或者 API 键设置不正确。但这也意味着很难到达谷歌地图服务器。但是,如果地图切片已经被缓存,即使 Google 的切片服务器不可达,这些切片也可以呈现给用户。地图切片缓存有两个不幸之处。首先,没有 API 调用来管理地图切片缓存,无论是强制缓存地图切片,还是更改缓存大小,或者从缓存中移除切片。你只需要相信谷歌会做正确的事情。第二个是每个应用都会缓存地图切片。因此,仅仅因为 Google Maps 应用可能缓存了切片,您的应用就无法访问这些切片。您的应用只能看到其缓存的缓存切片。

向地图添加标记

通常你会想要在地图上确定感兴趣的点,这是使用标记来完成的。这些点可以是静止的物体,如地址、地标或停车位。但它们也可能是移动的物体,如汽车、飞机、人、宠物、风暴等。您可以选择标记的外观以及它在地图上的位置。你可以同时拥有许多标记。我们将修改上面的示例程序,使其包含几个标记。您将看到如何放置它们,然后如何操作视图以确保用户看到标记。

现在使用名为 WhereAmIMarkers 的示例程序。您需要像以前一样修改 AndroidManifest.xml 文件,以使用您的 Maps API 键。MyMapFragment.java 的的源代码已经被修改,如清单 19-5 所示。屏幕将出现类似于图 19-4 的画面。

清单 19-5 。显示标记的地图片段代码

public class MyMapFragment extends SupportMapFragment
    implements OnMapReadyCallback {

    public static MyMapFragment newInstance() {
        MyMapFragment myMF = new MyMapFragment();
        return myMF;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getMapAsync(this);
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        setRetainInstance(true);
    }

    @Override
    public void onMapReady(GoogleMap myMap) {
        LatLng disneyMagicKingdom = new LatLng(28.418971, -81.581436);
        LatLng disneySevenLagoon = new LatLng(28.410067, -81.583699);

        // Add a marker
        MarkerOptions markerOpt = new MarkerOptions()
                .draggable(false)
                .flat(false)
                .position(disneyMagicKingdom)
                .title("Magic Kingdom")
                .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE));
        myMap.addMarker(markerOpt);

        markerOpt.position(disneySevenLagoon)
                 .title("Seven Seas Lagoon");
        myMap.addMarker(markerOpt);

        // Derive a bounding box around the markers
        LatLngBounds latLngBox = LatLngBounds.builder()
                .include(disneyMagicKingdom)
                .include(disneySevenLagoon)
                .build();

        // Move the camera to zoom in on our locations
        myMap.moveCamera(CameraUpdateFactory.newLatLngBounds(latLngBox, 200, 200, 0));
    }
}

9781430246800_Fig19-04.jpg

图 19-4 。地图上的标记

再一次,一切从从 MapFragment 获取 GoogleMap 对象开始。一旦地图可用,您就可以创建标记,在这种情况下,标记是基于几个固定的锁定对象。不过你会注意到,你没有直接实例化一个标记对象。相反,您使用一个 MarkerOptions 对象来指定应该如何创建标记。在标记选项对象中,您可以决定位置、标题、标记形状、颜色等。虽然您可以实例化一个 Marker 对象,然后调用您想要的每个 setter,但是 MarkerOptions 使事情变得容易得多,尤其是如果您需要创建共享公共特性的多个标记。该示例仅使用了一些标记选项功能;请参阅参考文档以了解所有可用的选项。

接下来你可能要做的是向用户展示地图,这样所有的标记都可以同时看到。这需要做两件事:将地图放在标记的中间,将缩放级别设置得尽可能高,但不要太近,以至于无法将所有标记都放入视图中。幸运的是,有一个助手类可用于此目的。通过向其传递所有应该在视图内的倾斜点来创建倾斜对象,并计算包含所有这些点的最小盒子。在这个示例中,两个点都是同时传入的。您也可以使用一个循环来传递所有点,然后调用 build() 方法来返回边界框。

一旦你有了一个边界框,你需要调整地图的相机。在旧版本的谷歌地图中,只有地图的俯视图,就好像你在地图上方俯视一样。在 Maps API 第 2 版中,有一个摄像头的概念,它可以直接向下看,但也可以从一个角度看。如果你同时使用两个手指,从上到下滑动屏幕,你会看到视角的变化。您实际上已经旋转了摄像机,因此您不再直视下方。当相机倾斜时,它还可以向东、向南或任何其他方向看。你也可以转动两个手指来旋转地图。

所有这些摄像机角度、缩放级别等等都是使用地图的 animateCamera() 或 moveCamera() 方法来控制的。这些方法以一个 CameraUpdate 对象作为指令,而 CameraUpdateFactory 类生成这些指令。在该示例中,边界框被传递给 CameraUpdateFactory ,它返回一个适当的 CameraUpdate ,这样摄像机将被定位在最佳位置以查看所有标记。 CameraUpdateFactory 还有其他几种方法来适应其他定位摄像机的方式。你可以简单的用 zoomIn() 和 zoomOut() 来举例。您也可以创建一个 CameraPosition 对象并使用它。

总而言之,你会同意在地图上放置标记再简单不过了。或者可能吗?我们没有纬度/经度对的数据库,但是我们猜测我们将需要以某种方式使用真实地址创建一个或多个锁存对象。这时你可以使用地理编码器类,它是我们接下来要讨论的位置包的一部分。

了解位置包

android.location 包为基于位置的服务提供了便利。在这一节中,我们将讨论这个包的两个重要部分:地理编码器类和位置管理器服务。我们将从地理编码器开始。

使用 Android 进行地理编码

如果你要用地图做任何实际的事情,你可能必须将一个地址(或位置)转换成一个纬度/经度对。这个概念被称为地理编码,而 Android . location . geocoder 类提供了这个功能。事实上, Geocoder 类提供了向前和向后转换——它可以接受一个地址并返回一个纬度/经度对,它可以将一个纬度/经度对转换成一个地址列表。 该类提供了以下方法:

  • 列表<地址> getFromLocation(双纬度,双经度,int maxResults)
  • list
    getfromlocation name(string location name,int maxResults,double lowerLeftLatitude,double lowerleftlongitude,double upperRightLatitude,double upper right length)
  • list
    getfromlocation name(string location name,int maxResults)

事实证明,计算地址并不是一门精确的科学,因为描述位置的方式多种多样。例如, getFromLocationName() 方法可以接受一个地点的名称、物理地址、机场代码,或者只是一个众所周知的地点名称。因此,这些方法返回地址列表,而不是单个地址。因为这些方法会返回一个列表,这个列表可能会很长(并且需要很长时间才能返回),所以建议您通过为 maxResults 提供一个范围在 1 和 5 之间的值来限制结果集。现在,让我们考虑一个例子。

清单 19-6 显示了图 19-5 中显示的活动和地图片段的 XML 布局和相应代码。要运行该示例,您需要用自己的 Maps API 密钥更新清单。

清单 19-6 。 使用 Android 地理编码器类

<!-- This is activity_main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    xmlns:tools="[`schemas.android.com/tools`](http://schemas.android.com/tools)"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.androidbook.maps.whereami.MainActivity"
    tools:ignore="MergeRootFrame" >

    <EditText android:id="@+id/locationName"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Enter location name"
        android:inputType="text"
        android:imeOptions="actionGo" />

    <FrameLayout android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

/**
  * This is from MainActivity.java
 **/
public class MainActivity extends FragmentActivity {

    private static final String MAPFRAGTAG = "MAPFRAGTAG";
    MyMapFragment myMapFrag = null;
    private Geocoder geocoder;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if ((myMapFrag = (MyMapFragment) getSupportFragmentManager()
                         .findFragmentByTag(MAPFRAGTAG)) == null) {
            myMapFrag = MyMapFragment.newInstance();
            getSupportFragmentManager().beginTransaction()
                    .add(R.id.container, myMapFrag, MAPFRAGTAG).commit();
        }
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD
                        && !Geocoder.isPresent()) {
            Toast.makeText(this, "Geocoder is not available on this device",
                       Toast.LENGTH_LONG).show();
            finish();
        }
        geocoder = new Geocoder(this);
        EditText loc = (EditText)findViewById(R.id.locationName);
        loc.setOnEditorActionListener(new OnEditorActionListener() {
            @Override
            public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
                if (actionId == EditorInfo.IME_ACTION_GO) {
                    String locationName = v.getText().toString();

                    try {
                        List<Address> addressList =
                           geocoder.getFromLocationName(locationName, 5);
                        if(addressList!=null && addressList.size()>0)
                        {
//                       Log.v(TAG, "Address: " + addressList.get(0).toString());
                            myMapFrag.gotoLocation(new LatLng(
                                addressList.get(0).getLatitude(),
                                addressList.get(0).getLongitude()),
                                locationName);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                return false;
            }
        });
    }
}

public class MyMapFragment extends SupportMapFragment
    implements OnMapReadyCallback {
    private GoogleMap mMap = null;

    public static MyMapFragment newInstance() {
        MyMapFragment myMF = new MyMapFragment();
        return myMF;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getMapAsync(this);
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        setRetainInstance(true);
    }

    public void gotoLocation(LatLng latlng, String locString) {
        if(mMap == null)
            return;
        // Add a marker for the given location
        MarkerOptions markerOpt = new MarkerOptions()
                .draggable(false)
                .flat(false)
                .position(latlng)
                .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE))
                .title("You chose:")
                .snippet(locString);
        // See the onMarkerClicked callback for why we do this
        mMap.addMarker(markerOpt);

        // Move the camera to zoom in on our location
        mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latlng, 15));
    }

    @Override
    public void onMapReady(GoogleMap arg0) {
        mMap = arg0;
    }
}

9781430246800_Fig19-05.jpg

图 19-5 。地理编码到一个给定位置名称的点

要演示 Android 中地理编码的使用,请在编辑文本字段中键入一个位置的名称或地址,然后点击键盘上的 Go 按钮。为了找到一个位置的地址,我们调用地理编码器的 getfromclocationname()方法。该位置可以是地址或众所周知的名称,如“白宫”地理编码可能是一项耗时的操作,因此我们建议您将结果限制为五个,正如 Android 文档所建议的那样。

对 getFromLocationName() 的调用返回一个地址列表。示例应用获取地址列表,并处理找到的第一个地址。每个地址都有一个纬度和经度,您可以用它来创建一个标签。然后调用我们的 gotoLocation() 方法来导航到这个点。地图片段中的这个新方法创建了一个新标记,将其添加到地图中,并将相机移动到缩放级别为 15 的标记处。缩放级别可以设置为 1 到 21 之间的浮点数,包括 1 和 21。当您从 1 向 21 移动 1 时,缩放级别会增加 2 倍。如果我们愿意,我们可以显示一个对话框来显示多个找到的位置,但是现在,我们只显示返回给我们的第一个位置。

在我们的示例应用中,我们只读取返回的地址的纬度和经度。事实上,可以有大量关于地址 es 的数据返回给我们,包括该地点的通用名称、街道、城市、州、邮政编码、国家,甚至电话号码和网站 URL。

您应该了解与地理编码相关的几点:

  • 虽然地理编码器类可能存在,但服务可能没有实现。如果设备是 Gingerbread 或更高版本,在尝试在您的应用中进行地理编码之前,您应该检查一下 Geocoder.isPresent() 。
  • 返回的地址并不总是准确的地址。显然,因为返回的地址列表取决于输入的准确性,所以您需要尽一切努力向地理编码器提供准确的位置名称。
  • 尽可能将 maxResults 参数设置为介于 1 和 5 之间的值。
  • 您应该认真考虑在不同于 UI 线程的线程中执行地理编码操作。这有两个原因。第一个是显而易见的:操作很耗时,您不希望在进行地理编码时 UI 挂起,导致 Android 终止您的活动。第二个原因是,对于移动设备,您总是需要假设网络连接可能会丢失,并且连接很弱。因此,您需要适当地处理输入/输出(I/O)异常和超时。计算完地址后,您可以将结果发送到 UI 线程。请参阅附带的名为 WhereAmIGeocoder2 的示例应用,了解如何做到这一点。

了解定位服务

定位服务提供了两个主要功能:一个是让您获得设备地理位置的机制,另一个是在设备进入或退出指定地理位置时通知您(通过意向)的工具。后一种操作被称为地理围栏。

在本节中,您将学习如何找到设备的当前位置。要使用该服务,您必须首先获得对它的引用。清单 19-7 显示了服务的一个简单用法。这个示例项目叫做 wheremilocationapi。

清单 19-7 。使用位置提供者 API

public class MyMapFragment extends SupportMapFragment
    implements GoogleApiClient.ConnectionCallbacks,
               GoogleApiClient.OnConnectionFailedListener,
               OnMapReadyCallback {
    private Context mContext = null;
    private GoogleMap mMap = null;
    private GoogleApiClient mClient = null;
    private LatLng mLatLng = null;

    public static MyMapFragment newInstance() {
        MyMapFragment myMF = new MyMapFragment();
        return myMF;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getMapAsync(this);
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        if(mClient == null) { // first time in, set up this fragment
            setRetainInstance(true);

            mContext = getActivity().getApplication();
            mClient = new GoogleApiClient.Builder(mContext, this, this)
                .addApi(LocationServices.API)
                .build();
            mClient.connect();
        }
    }

    @Override
    public void onConnectionFailed(ConnectionResult arg0) {
        Toast.makeText(mContext, "Connection failed", Toast.LENGTH_LONG).show();
    }

    @Override
    public void onConnected(Bundle arg0) {
        // Figure out where we are (lat, long) as best as we can
        // based on the user's selections for Location Settings
        FusedLocationProviderApi locator = LocationServices.FusedLocationApi;
        Location myLocation = locator.getLastLocation(mClient);
        // if the services are not available, could get a null location
        if(myLocation == null)
            return;
        double lat = myLocation.getLatitude();
        double lng = myLocation.getLongitude();
        mLatLng = new LatLng(lat, lng);
        doWhenEverythingIsReady();
    }

    @Override
    public void onConnectionSuspended(int arg0) {
        Toast.makeText(mContext, "Connection suspended", Toast.LENGTH_LONG).show();
    }

    @Override
    public void onMapReady(GoogleMap arg0) {
        mMap = arg0;
        doWhenEverythingIsReady();
    }

    private void doWhenEverythingIsReady() {
        if(mMap == null || mLatLng == null)
            return;
        // Add a marker
        MarkerOptions markerOpt = new MarkerOptions()
                .draggable(false)
                .flat(true)
                .position(mLatLng)
                .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE));
        mMap.addMarker(markerOpt);

        // Move the camera to zoom in on our location
        mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(mLatLng, 15));
    }
}

要获得位置服务,首先需要创建一个 Google API 客户端对象,它使您可以使用 Google Play 服务中的服务。这相对容易做到,一旦有了客户端对象,就需要调用它的 connect() 方法。这将在稍后异步调用 onConnected() 回调,让您的应用知道客户端已经连接,现在可以使用了。或者您的应用可能会得到 onConnectionFailed() 回调,在这种情况下,您应该采取适当的措施。对于这个示例,我们只是在连接尝试失败时显示一条 Toast 消息。稍后,您将看到如何更可靠地处理失败的连接。

当调用 onConnected() 回调时,现在您可以使用位置提供者 API 了。回想一下,在本章开始时,您在清单文件中设置了访问位置信息的权限。精确定位使用 GPS,而粗略定位使用手机信号塔和 WiFi 热点。使用融合的位置提供者 API 意味着您的应用不必担心启用了什么或设置了什么权限。API 调用是相同的。你只需询问位置,就能得到当时可用的最佳位置信息。

对于这个示例,我们调用 getLastLocation() 方法。运气好的话,返回的位置非常当前;但是,请注意,最后一次定位可能是在几分钟或几小时前。 Location 对象可以通过 getTime() 方法告诉您何时获得了这个定位。在决定使用它之前,您可以检查一下它是否足够新。从技术上讲, getLastLocation() 可能会返回 null,因此您也应该为这种情况做好准备。如果在“设置”中禁用了定位服务,就会发生这种情况。

您将很快看到如何更新位置。目前,该示例获取最后一个位置,并从中创建一个地图标记以显示给用户。您应该认识本章前面部分创建标记的代码。

如何启用定位服务

如果应用运行时没有打开定位服务,您可能会认为有一个简单的 API 来启用定位服务。不幸的是,事实并非如此。要打开定位服务,用户必须在设备的设置屏幕中打开。您的应用可以通过启动特定的设置屏幕来简化用户的操作。location settings source 屏幕实际上只是一个活动,这个活动是为了响应一个意图而设置的。

在刚刚介绍的示例应用中,您将在活动的 onCreate() 回调中看到来自清单 19-8 的代码。

清单 19-8检查定位服务是否开启

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    if ((myMapFrag = (MyMapFragment) getSupportFragmentManager()
            .findFragmentByTag(MAPFRAGTAG)) == null) {
        myMapFrag = MyMapFragment.newInstance();
        getSupportFragmentManager().beginTransaction()
            .add(R.id.container, myMapFrag, MAPFRAGTAG).commit();
    }

    if(!isLocationEnabled(this)) {
        // no location service providers are enabled
        Toast.makeText(context, "Location Services appear to be turned off." +
            " This app can't work without them. Please turn them on.",
            Toast.LENGTH_LONG).show();
        startActivityForResult(new Intent(
            android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS), 0);
    }
}

@SuppressWarnings("deprecation")
public boolean isLocationEnabled(Context context) {
    int locationMode = Settings.Secure.LOCATION_MODE_OFF;
    String locationProviders;

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT){
        try {
            locationMode = Settings.Secure.getInt(
                context.getContentResolver(),
                Settings.Secure.LOCATION_MODE);
        } catch (SettingNotFoundException e) {
            e.printStackTrace();
        }
        return locationMode != Settings.Secure.LOCATION_MODE_OFF;
    }else{
        locationProviders = Settings.Secure.getString(
            context.getContentResolver(),
            Settings.Secure.LOCATION_PROVIDERS_ALLOWED);
        return !TextUtils.isEmpty(locationProviders);
    }
}

Android 19 (KitKat)发生了一个变化,在静态设置中加入了新的设置值。安全级。这使得判断定位服务是否打开以及哪些服务打开变得更加容易,但用户仍然需要做一些工作来启用这些服务。这段代码中有两种方法来检查服务:使用其中一个新值,或者获取可用的位置提供者。清单 19-8 的第一部分检查 Android 版本是否是 KitKat 或更高版本,如果是,它寻找位置模式的新设置值。代码的第二部分(如果 Android 版本比 KitKat 旧)对允许的位置提供者进行获取。如果定位模式未关闭,或者至少有一个定位供应器可用,则定位服务正在运行。如果没有,此代码将启动位置设置屏幕。此时,在设置活动运行时,此活动将会暂停。设置活动完成后,我们的活动将继续。

如果您想要处理来自设置活动的响应(即,当该活动完成时得到通知,并且可能已经进行了设置更改),您必须在您的活动中实现 onActivityResult() 回调。还要记住,虽然你希望用户打开定位服务,但他们可能不会。您需要再次检查用户是否启用了定位服务,并根据结果采取适当的措施。我们将在后面的小节中向您展示如何完成所有这些工作。

位置供应器

您已经看到了 FusedLocationApi,但是您还应该知道更老的替代位置提供者。硬件就在设备上,用于获取位置信息,位置提供者会将它提供给你的应用。您将很快看到 FusedLocationApi 如何在比这些提供者更高的层次上处理您的位置需求。但是如果您需要深入了解细节,例如检查可用 GPS 卫星的状态,您会很高兴知道这些供应器的存在。谷歌建议大家改用 FusedLocationApi 但由于它依赖于 Google Play 服务,这意味着使用 FusedLocationApi 的应用将无法在非谷歌 Android 设备上运行。

location manager 服务是一个系统级服务。系统级服务是使用服务名从上下文中获得的服务;你不能直接实例化它们。 android.app.Activity 类提供了一个名为 getSystemService() 的实用方法,您可以使用它来获得系统级服务。您调用 getSystemService() 并传入您想要的服务的名称,在本例中是 Context。位置 _ 服务。你很快会在清单 19-9 中看到这一点。

LocationManager 服务通过使用位置提供者来提供地理位置细节。目前,有三种类型的位置供应器:

  • GPS 供应商使用全球定位系统来获取位置信息。
  • 网络供应器使用手机信号塔或 WiFi 网络来获取位置信息。
  • 被动提供者就像一个位置更新嗅探器,它将其他应用请求的位置更新传递给你的应用,而你的应用不需要特别请求任何位置更新。当然,如果没有其他人要求位置更新,你也不会得到任何。

类似于 FusedLocationApi, LocationManager 类可以提供设备的最后已知位置,这次是通过 getLastKnownLocation() 方法。位置信息是从提供者处获得的,因此该方法将您想要使用的提供者的名称作为参数。提供者名称的有效值是 LocationManager。GPS_PROVIDER , LocationManager。网络供应器和位置管理器。被动 _ 提供者。请注意,对于融合提供者没有选项,因为这是一个单独的定位功能。

为了让您的应用成功获取位置信息,它必须在 AndroidManifest.xml 文件中拥有适当的权限。Android . permission . access _ FINE _ LOCATION 是 GPS 和被动供应器所必需的,而 Android . permission . access _ COARSE _ LOCATION 或 Android . permission . access _ FINE _ LOCATION 可用于网络供应器,具体取决于您的需求。例如,假设您的应用将使用 GPS 或网络数据进行位置更新。因为您需要 ACCESS_FINE_LOCATION 用于 GPS,您也已经满足了网络访问的权限,所以您不需要同时指定 ACCESS_COARSE_LOCATION 。如果您只打算使用网络提供者,那么您可以只使用清单文件中的 ACCESS_COARSE_LOCATION 来解决问题。

调用 getLastKnownLocation() 返回一个 Android . location . location 实例,如果没有可用的位置,则返回 null 。 Location 类提供位置的纬度和经度、计算位置的时间,还可能提供设备的高度、速度和方位。一个 Location 对象还可以使用 getProvider() 告诉你它来自哪个提供者,这个提供者可能是 GPS_PROVIDER 或 NETWORK_PROVIDER 。如果你通过 PASSIVE_PROVIDER 获取位置更新,请记住,你实际上只是嗅探位置更新,所以所有更新最终都来自 GPS 或网络。

因为 LocationManager 操作提供者,所以该类提供 API 来获取提供者。例如,您可以通过调用 getAllProviders() 获得所有已知的提供者。您可以通过调用 getProvider() ,将提供者的名称作为参数传递(例如 LocationManager)来获得特定的提供者。GPS_PROVIDER )。需要注意的一点是 getAllProviders() 将返回您可能无法访问或当前被禁用的提供程序。幸运的是,您可以使用其他方法来确定提供者的状态,例如 isProviderEnabled(String providerName)或 get providers(boolean enabled only),您可以使用值 true 来调用这些方法,以便只获取您可以立即使用的提供者。

还有另一种方法来获得合适的提供者,那就是使用 LocationManager 的 get providers(Criteria Criteria,boolean enabledOnly) 方法。通过指定位置更新的标准,并通过将 enabledOnly 设置为 true 以便您获得已启用并准备就绪的供应器,您可以获得返回给您的供应器名称列表,而不必知道您获得了哪个供应器的具体信息。这可能更便于携带,因为一个设备可能有一个自定义的 LocationProvider 来满足您的需求,而无需您事先了解它。标准对象可以设置参数,包括精度水平和对速度、方位、高度、成本和功率要求等信息的需求。如果没有供应器符合您的标准,将返回一个空列表,允许您退出或放宽标准并重试。

向您的应用发送位置更新

在进行开发测试时,您的应用需要位置信息,而模拟器无法访问 GPS 或手机信号塔。为了在模拟器中测试您的应用,您可以从 Eclipse 手动发送位置更新。清单 19-9 展示了一个简单的例子来说明如何做到这一点。这里我们将坚持使用 LocationManager 方法,稍后将展示 FusedLocationApi 方法。

清单 19-9 。注册位置更新

public class LocationUpdateDemoActivity extends Activity
{
    LocationManager locMgr = null;
    LocationListener locListener = null;

    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);

        locMgr = (LocationManager)
            getSystemService(Context.LOCATION_SERVICE);

        locListener = new LocationListener()
        {
            public void  onLocationChanged(Location location)
            {
                if (location != null)
                {
                    Toast.makeText(getBaseContext(),
                        "New location latitude [" +
                        location.getLatitude() +
                        "] longitude [" +
                        location.getLongitude()+"]",
                        Toast.LENGTH_SHORT).show();
                }
            }

            public void  onProviderDisabled(String provider)
            {
            }

            public void  onProviderEnabled(String provider)
            {
            }

            public void  onStatusChanged(String provider,
                            int status, Bundle extras)
            {
            }        };
    }

    @Override
    public void onResume() {
        super.onResume();

        locMgr.requestLocationUpdates(
            LocationManager.GPS_PROVIDER,
            0,                // minTime in ms
            0,                // minDistance in meters
            locListener);
    }

    @Override
    public void onPause() {
        super.onPause();
        locMgr.removeUpdates(locListener);
    }
}

我们没有为这个例子显示用户界面,所以标准的初始布局 XML 文件就可以了,还有一个常规的活动。

服务的主要用途之一是接收设备位置的通知。清单 19-9 展示了如何注册一个监听器来接收位置更新事件。要注册一个侦听器,您需要调用 requestLocationUpdates() 方法,将提供者类型作为参数之一传递。当位置改变时, LocationManager 用新的位置调用监听器的 onLocationChanged() 方法。在适当的时候删除任何位置更新的注册非常重要。在我们的示例中,我们在 onResume() 中进行注册,并在 onPause() 中删除该注册。如果我们不能在位置更新上做任何事情,我们应该告诉供应器不要发送它们。我们的活动也有可能被破坏(例如,如果用户旋转他们的设备,我们的活动重新启动),在这种情况下,我们的旧活动可能仍然存在,接收更新,用 Toast 显示它们,并占用内存。

在我们的例子中,我们将最小时间和最小距离设置为零。这告诉 LocationManager 尽可能频繁地给我们发送更新。这些并不是您的生产应用或真实设备所需要的设置,但是我们在这里使用它们来使演示在模拟器中更好地运行。(在现实生活中,你不会希望硬件如此频繁地试图找出我们的当前位置,因为这会耗尽电池。)根据情况适当地设置这些值,尽量减少您真正需要得到位置变化通知的频率。谷歌通常推荐不小于 20 秒的值。

用模拟器测试定位应用

让我们在模拟器中测试这一点,使用 Eclipse 的 ADT 插件附带的 Dalvik Debug Monitor Service (DDMS)透视图。DDMS 用户界面提供了一个屏幕,让你将仿真器发送到一个新的位置(见图 19-6 )。

9781430246800_Fig19-06.jpg

图 19-6 。使用 Eclipse 中的 DDMS UI 向模拟器发送位置数据

要在月食中到达 DDMS,使用窗口image打开视角image DDMS。模拟器控件视图应该已经为你准备好了,但是如果没有,使用窗口image显示视图image其他image Android image模拟器控件使它在这个透视图中可见。您可能需要在模拟器控件中向下滚动,以找到位置控件。如图图 19-6 所示,DDMS 用户界面中的手动选项卡允许你发送一个新的 GPS 位置(纬度/经度对)到模拟器。发送一个新位置将触发监听器上的 onLocationChanged() 方法,这将导致向用户发送一条传达新位置的消息。

你可以使用其他几种技术向模拟器发送位置数据,如 DDMS 用户界面所示(见图 19-6 )。例如,DDMS 界面允许您提交 GPS 交换格式(GPX)文件或锁眼标记语言(KML)文件。您可以从以下站点获得 GPX 文件示例:

同样,您可以使用以下 KML 资源来获取或创建 KML 文件:

注意一些网站提供 KMZ 文件。这些是压缩的 KML 文件,所以只需解压它们就可以得到 KML 文件。一些 KML 文件需要修改它们的 XML 名称空间值才能在 DDMS 正常播放。如果您在使用某个 KML 文件时遇到问题,请确保该文件包含以下内容:

<KML font name 1 ">earth.google.com/kml/2.x>。

您可以上传一个 GPX 或 KML 文件到模拟器,并设置模拟器回放文件的速度(见图 19-7 )。然后,模拟器将根据配置的速度向您的应用发送位置更新。如图图 19-7 所示,一个 GPX 文件包含点(显示在顶部)和路径(显示在底部)。你不能播放一个点,但是当你点击一个点时,它会被发送到模拟器。你点击一个路径,然后播放按钮将被激活,这样你就可以播放点。

9781430246800_Fig19-07.jpg

图 19-7 。将 GPX 和 KML 的文件上传到模拟器进行回放

注意有报道称,并非所有的 GPX 文件都能被模拟器控件理解。如果您尝试加载 GPX 文件,但没有任何反应,请尝试从不同的源加载不同的文件。

清单 19-9 包括一些我们还没有提到的 LocationListener 的附加方法。分别是回调 onProviderDisabled() 、回调 onProviderEnabled() 和回调 onStatusChanged() 。对于我们的示例,我们没有做任何事情,但是在您的应用中,当用户禁用或启用某个位置提供者(如 gps )时,或者当某个位置提供者的状态发生变化时,您会收到通知。状态包括停用、暂时不可用和可用。即使启用了提供者,也不意味着它将发送任何位置更新,您可以使用状态来判断这一点。注意,如果为一个被禁用的提供者调用了 requestLocationUpdates(),那么 onProviderDisabled() 将被立即调用。

从模拟器控制台发送位置更新

Eclipse 有一些易于使用的工具可以将位置更新发送到您的应用,但是还有另外一种方法。您可以从工具窗口中使用以下命令启动模拟器控制台:

telnet localhost emulator_port_number

其中 emulator_port_number 是与已经运行的 AVD 实例相关联的编号,显示在模拟器窗口的标题栏中。如果您的工作站没有 telnet,您可能需要安装它。一旦你连接上了,你可以使用 geo fix 命令发送位置更新。要发送纬度/经度坐标和高度(高度是可选的),请使用以下形式的命令:

geo fix lon lat [ altitude ]

例如,以下命令将把佛罗里达州杰克逊维尔的位置发送到您的应用,该位置的海拔高度为 120 米。

geo fix  -81.5625  30.334954  120

请仔细注意 geo fix 命令的参数顺序。经度是第一个自变量,纬度是第二个。

你能用一个位置做什么?

如前所述,位置 s 可以告诉你纬度和经度,何时计算出位置,计算这个位置的提供者,以及可选的高度、速度、方位和精度水平。根据位置来自的提供者,也可能有额外的信息。例如,如果位置来自 GPS 供应器,则有一个额外的捆绑包会告诉您使用了多少颗卫星来计算位置。可选值可能存在,也可能不存在,具体取决于提供程序。为了知道一个位置是否具有这些值之一,位置类提供了一组具有...()返回一个布尔值的方法,例如 hasAccuracy() 。在依赖 getAccuracy() 的返回值之前,先调用 hasAccuracy() 会比较明智。

Location 类还有一些其他有用的方法,包括一个静态方法 distanceBetween() ,它将返回两个 Location 之间的最短距离。另一个与距离相关的方法是 distanceTo() ,它将返回当前 Location 对象和传递给该方法的 Location 对象之间的最短距离。请注意,距离以米为单位,距离计算考虑了地球的曲率。但是也要知道,距离并不是以你必须开车去的距离来提供的。

如果你想得到驾驶方向或驾驶距离,你将需要有你的开始和结束地点 ??,但是为了进行计算,你可能需要使用谷歌方向 API。方向 API 将允许您的应用显示如何从起点到达终点。这是您可以为您的应用启用的另一个 Google API 客户端 API。

设置 Google Play 服务的位置更新

您已经看到了如何使用 LocationManager 获取位置更新,但是让我们返回到 FusedLocationProviderApi,看看如何从中获取位置更新。本节的示例项目是 FusedLocationApiUpdates。这一个有点棘手,因为我们正在处理 Google Play 服务,一个运行在设备上的独立服务。因此,您不能总是确保您具有有效的客户端连接,并且在请求位置更新时需要小心。因此,您的应用需要考虑状态。

在早期的示例程序(WhereAmILocationAPI)中,您检查了定位服务是否打开,但是代码假定 Google Play 服务可用并且准备好了。现在您将看到如何检查 Google Play 服务的存在,以及 GooglePlayServicesUtil 类如何帮助您。基本流程是检查每个位置更新发生的依赖关系,如果有纠正问题的方法,就帮助用户修复它。如果用户没有或者不能解决问题,应用就会退出。如果用户一直在解决问题,直到一切正常,然后位置更新被请求,应用通过 Toast 消息显示位置更新。

清单 19-10 显示了我们尝试连接的主要方法。您将在该方法中看到与前面的 WhereAmILocationAPI 示例应用相同的位置服务检查。将从活动的 onResume() 回调中调用 tryToConnect() 方法,以便每次恢复该活动时,都将建立一个新的客户端连接到 Google Play 服务。我们不想假设一个老客户仍然是有效的和活跃的。

清单 19-10 。检查进行位置更新的能力

private void tryToConnect() {
    // Check that Google Play services is available
    int resultCode = GooglePlayServicesUtil
                    .isGooglePlayServicesAvailable(this);
    // If Google Play services is available, then we're good
    if (resultCode == ConnectionResult.SUCCESS) {
        Log.d(TAG, "Google Play services is available.");
        if(!isLocationEnabled(this)) {
            if(lastFix == FIX.LOCATION_SETTINGS) {
                // Since we're coming through again, it means
                // recovery didn't happen. Time to bail out.
                Log.e(TAG, "Location settings didn't work");
                finish();
            }
            else {
                // no location service providers are enabled
                Toast.makeText(this, "Location Services are off. " +
                    "Can't work without them. Please turn them on.",
                    Toast.LENGTH_LONG).show();
                Log.i(TAG, "Location Services need to be on. " +
                    "Launching the Settings screen");
                startActivityForResult(new Intent(
                    android.provider.Settings
                        .ACTION_LOCATION_SOURCE_SETTINGS),
                    LOCATION_SETTINGS_REQUEST);
                lastFix = FIX.LOCATION_SETTINGS;
            }
        }
        else {
            client.connect();
            Log.v(TAG, "Connecting to GoogleApiClient...");
        }
    }
    // Google Play services was not available for some reason
    // See if the user can do something about it
    else if(GooglePlayServicesUtil
                .isUserRecoverableError(resultCode)) {
        if(lastFix == FIX.PLAY_SERVICES) {
            // Since we're coming through again, it means
            // recovery didn't happen. Time to bail out.
            Log.e(TAG, "Recovery doesn't seem to work");
            finish();
        }
        else {
            Log.d(TAG, "Google Play services may be available. " +
                "Asking user for help");
            // This form of the dialog call will result in either a
            // callback to onActivityResult, or a dialog onCancel.
            GooglePlayServicesUtil.showErrorDialogFragment(resultCode,
                this, PLAY_SERVICES_RECOVERY_REQUEST, this);
            lastFix = FIX.PLAY_SERVICES;
        }
    } else {
        // No hope left.
        Log.e(TAG, "Google Play Services is/are not available." +
              " No point in continuing");
        finish();
    }
}

GooglePlayServicesUtil 类有几个静态方法来帮助设置位置更新。第一个方法是 isGooglePlayServicesAvailable(),需要一个上下文。结果是一个整数值,该整数值或者是成功,或者是其他几个值中的一个,这些值可以指示例如服务缺失或者版本不合适。在大多数情况下,您真的不需要关心返回的其他值,您将会看到。

如果 Google Play 服务可用,您将检查位置服务(如前所述),如果它们正常,您可以在 GoogleApiClient 客户端上调用 connect() 方法。 connect() 调用是异步的,一个单独的回调将处理 connect 调用的结果。和以前一样,如果位置服务没有打开,您将启动位置设置活动,以便用户可以打开它们。在这个示例中,我们只是使用一条 Toast 消息来告诉用户为什么他们会被重定向到设置屏幕。在生产应用中,您可能希望在重定向到设置屏幕之前显示一个带有“确定”和“取消”按钮的警告对话框。

如果 Google Play 服务不可用,下一步检查是看用户是否可以使用 isUserRecoverableError() 方法解决问题。在这里,您传入先前检查的结果代码,它应该是除了成功之外的代码。这就是为什么您不需要关心返回了什么其他值。这个方法为您决定用户是否可以做一些事情。如果用户不能纠正这种情况(例如, isUserRecoverableError() 返回 false ),那么你真的没有别的办法,你可能会想退出。在这个示例应用中,会写入一条日志消息,然后活动结束。你可能想让你的退场更优雅些。

如果用户可以对 Google Play 服务的问题做些什么,那么 GooglePlayServicesUtil 类还有另一个静态方法可以使用:showerrodialogfragment()。这将向用户显示一个对话框,指出问题是什么以及他们可以做些什么。这个调用有一些变化,示例使用的是在监听对话框取消时弹出一个对话框片段的调用。对话框片段可以启动另一个活动,这将导致我们的 onActivityResult() 被调用。出于这个原因,您希望传入一个请求值(即 PLAY _ SERVICES _ RECOVERY _ REQUEST),该值稍后将被传递给 onActivityResult() 。这个方法也是异步的,您的应用将看到稍后调用的 onActivityResult() ,或者对话框的 onCancel() 。showerrodialogfragment()的第二个参数是上下文,最后一个参数是对话框的监听器。因为我们将“this”作为最后一个参数传递,为了表示此活动,示例活动必须实现 DialogInterface。OnCancelListener 并有一个 onCancel() 回调。

您很快就会看到 onActivityResult() 的代码,但是您应该知道,当一个结果被传递回您的活动时,您将不得不通过调用 tryToConnect() 再次进行这些检查。这就是为什么这个方法设置了一个 lastFix 值,来跟踪哪个问题正在被处理。如果同样的问题在用户有机会解决之后仍然存在,我们可以假设用户对解决问题不感兴趣,或者系统不能解决问题。我们不希望出现某种用户无法摆脱的无限循环。对于这个示例活动,如果 tryToConnect() 连续两次遇到相同的问题,它将退出,活动结束。您的应用可能希望采取替代措施,为用户提供更多选项来继续使用该应用。

回顾一下在 tryToConnect() 中发生的事情,您检查了 Google Play 服务和定位服务的存在和准备情况。如果一切看起来都很好,就在 GoogleClientApi 客户端上进行连接调用。如果用户能够纠正任何事情,就会激发一个合适的意图来启动一个活动来处理它。如果情况没有希望,活动就结束了。现在让我们看看这些操作可能导致的各种回调。

如果连接请求成功,将会触发 onConnected() 回调。清单 19-11 展示了这个样子。

清单 19-11 。客户端已连接,因此请求位置更新

@Override
public void onConnected(Bundle arg0) {
    // Set up location updates
    Log.v(TAG, "Connected!");
    lastFix = FIX.NO_FAIL;
    locator.requestLocationUpdates(client, locReq, this);
    Log.v(TAG, "Requesting location updates (onConnected)...");
}

这个很简单。如果我们与 Google Play 服务连接良好,就开始向 FusedLocationProviderApi (定位器)请求位置更新。稍后您将看到更多关于 locReq 的内容,但是现在只知道它是一个 LocationRequest 对象,其参数定义了您的应用需要哪种位置更新。该方法还重置了一个状态变量(lastFix ),这将很快变得更有意义。

如果连接请求不成功,将触发 onConnectionFailed() 回调。清单 19-12 显示了这个回调。

清单 19-12 。处理失败的连接尝试

@Override
public void onConnectionFailed(ConnectionResult connectionResult) {
    /*
     * Google Play services can resolve some errors it detects.
     * If the error has a resolution, try sending an Intent to
     * start a Google Play services activity that can resolve
     * the error.
     */
    if (connectionResult.hasResolution()) {
        Log.i(TAG, "Connection failed, trying to resolve it...");
        if(lastFix == FIX.CONNECTION) {
            // Since we're coming through again, it means
            // recovery didn't happen. Time to bail out.
            Log.e(TAG, "Connection retry didn't work");
            finish();
        }
        try {
            // Start an activity that tries to resolve the error
            lastFix = FIX.CONNECTION;
            connectionResult.startResolutionForResult(
                    this,
                    CONNECTION_FAILURE_RESOLUTION_REQUEST);
        } catch (IntentSender.SendIntentException e) {
            // Log the error
            Log.e(TAG, "Could not resolve connection failure");
            e.printStackTrace();
            finish();
        }
    } else {
        /*
         * If no resolution is available, display error to the
         * user.
         */
        Log.e(TAG, "Connection failed, no resolutions available, "+
                GooglePlayServicesUtil.getErrorString(
                        connectionResult.getErrorCode() ));
        Toast.makeText(this, "Connection failed. Cannot continue",
                Toast.LENGTH_LONG).show();
        finish();
    }
}

如果连接请求失败,仍然有可能纠正这种情况。同样,有一种方法可以判断是否有解决问题的方法。ConnectionResult 对象包含一个指示符(如果有解决方案),以及尝试解决这种情况的触发意图。在这种情况下,应用调用 startResolutionForResult()。与之前类似,一个意图将被触发,一些活动将被启动,您的应用将在 onActivityResult() 中返回一个结果。注意,这里的请求标签是连接失败解决请求。如果什么也做不了,显示一个错误并退出。

可能有几个启动的意图,每一个都应该触发你的 onActivityResult() 回调。清单 19-13 显示了这个回调的样子。请记住,可能有三个独立的意图来处理问题,所以这个回调必须期望三个中的任何一个。还要记住,意图导致活动运行,这意味着您的活动暂停了,它将在触发 onActivityResult() 之后立即恢复。这就是为什么 tryToConnect() 方法(如清单 19-10 所示)只能从活动的 onResume() 回调中调用的主要原因。每当恢复此活动时,它都会尝试建立到 Google Play 服务的新连接,并设置位置更新。当此活动暂停时,它会断开与 Google Play 服务的连接。重新连接比在不需要的时候试图抓住一个连接更容易。

清单 19-13 。从已发布的意向中获取消息

@Override
protected void onActivityResult(
        int requestCode, int resultCode, Intent data) {
    /* Decide what to do based on the original request code.
     * Note that our activity got paused to launch the other
     * activity, so after this callback runs, our activity's
     * onResume() will run.
     */
    switch (requestCode) {
    case PLAY_SERVICES_RECOVERY_REQUEST :
        Log.v(TAG, "Got a result for Play Services Recovery");
        break;
    case LOCATION_SETTINGS_REQUEST :
        Log.v(TAG, "Got a result for Location Settings");
        break;
    case CONNECTION_FAILURE_RESOLUTION_REQUEST :
        Log.v(TAG, "Got a result for connection failure");
        break;
    }
    Log.v(TAG, "resultCode was " + resultCode);
    Log.v(TAG, "End of onActivityResult");
}

因为 onActivityResult() 可能因为许多意图而被调用,所以 switch 语句被用来判断哪个意图被响应。通过将结果代码设置为活动,Google Play 服务纠正操作可能会说它成功了。结果 _OK 。这不一定意味着用户修复了问题,但它告诉你没有什么失败。如果对 Google Play 服务纠正措施的响应是活动。结果取消了,这可能意味着出现了某种故障。不管用户是否修复了问题,您都将从这个回调中返回,然后 onResume() 将运行,其中 tryToConnect() 将被再次调用。所以 resultCode 是什么真的不重要。实际上,即使正确设置了位置更新,您也可以看到 resultCode 设置为 RESULT _ cancelled。类似地,如果有对其他修复的响应,记录下来并继续,因为 onResume() 无论如何都会运行。

最后,回头参考清单 19-11 中的 onConnected() 回调,该回调调用 locator . requestlocationupdates(client,locReq,this) 。在这里 FusedLocationProviderApi 将被要求向该活动发回位置更新。Google Play 服务已启动并运行,位置服务也已正确设置。

一旦请求了位置更新,任何新的位置更新都将被发送到 onLocationChanged() 回调。在这个示例应用中,所发生的只是位置信息显示在 Toast 消息中。下一节将更详细地介绍如何请求位置更新。

到目前为止,活动中还有一些其他方法没有描述。 onPause() 回调在停止位置更新后断开客户端。您应该注意到,在调用方法之前,会检查客户端的连通性。 GoogleApiClient 类有一个名为 isConnected() 的方法,您将使用它来确保只有在有连接的客户端时才请求或删除位置更新。否则会得到一个 IllegalStateException 。设置菜单的两种方法是基本的菜单回调。该菜单用于允许用户在各种优先级值之间切换。当用户选择一个菜单项时,位置请求对象被更新并传回以改变位置更新过程。可以从弹出的错误对话框中调用 onCancel() 回调,如 tryToConnect 所示(参见清单 19-10 )。如果用户简单地关闭错误片段对话框,我们推断用户不想获得更新,应用退出。

使用 FusedLocationProviderApi 更新位置

使用 LocationManager ,您必须与特定的位置供应器打交道(即 GPS 或手机/WiFi)。使用 FusedLocationProviderApi ,您提交一个 LocationRequest ,Api 将为您选择哪一个提供者是最好的,不仅是最初,而且随着时间的推移。一般来说,在获取位置更新时,要在功耗和精度之间进行权衡。GPS 通常更精确,但耗电最多。另一方面,在室内时,GPS 可能不如手机/WiFi 精确,您可能希望自动切换到更精确的位置,同时消耗最少的电能。FusedLocationProviderApi 还可以利用陀螺仪或指南针等机载传感器。这个 API 向您隐藏了定位的复杂性。

您应该编写自己的代码,以便只在有意义的时候请求位置更新。如果您在地图上显示当前位置,并且地图不可见,则不需要请求更新。有些情况下,即使不显示当前位置,您也可能希望不断获得更新,我们将在下一节中讨论这一点。关键是位置更新会消耗大量的电池电量,所以只有在你真正需要的时候才请求更新。你不应该假设用户会“马上回来”,并因此不断获得更新。如果他们放下设备,一段时间内不会再看它,你最好不要耗尽电池。

清单 19-14 显示了示例应用如何设置 LocationRequest 对象来请求 FusedLocationProviderApi 的位置更新。这是在活动的 onCreate() 回调中完成的。

清单 19-14 。设置 LocationRequest 对象

locReq = LocationRequest.create()
        .setPriority(
            LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY)
        .setInterval(10000)
        .setFastestInterval(5000);

使用静态的 create() 方法,然后调用适当的 setters 来填充请求对象。该对象将被传递给 FusedLocationProviderApi 的 requestLocationUpdates() 方法。与处理旧的位置提供者的一个很大的区别是,这个请求对象不引用任何特定的位置提供者。类似于寻找提供者的标准方法,这个请求对象最终选择更新的频率和功耗。

您可以使用 setInterval() 和 setFastestInterval() 指定所需的位置更新频率;两者都有一个表示毫秒数的长参数。前者是说你想要定期得到位置更新,每隔几毫秒。如果可以的话,系统会尽力做到这一点,但不能保证。您可以比预期的更频繁地获得更新,甚至更频繁。这就是第二种方法的用武之地。您可以指定接收位置更新的最快时间间隔。稍后会有更多的介绍。

请求的功率部分由 setPriority() 设置器处理。该参数目前有四个选项:

  • PRIORITY_NO_POWER
  • PRIORITY_LOW_POWER
  • 优先级 _ 平衡 _ 功率 _ 精度
  • 优先级 _ 高 _ 准确度

NO_POWER 选项相当于说您的应用将使用前面描述的被动提供者。不消耗任何功率的唯一方法是搭载另一个应用的位置更新。因此,位置的准确性可能不是非常准确或频繁;这完全取决于其他应用的请求。您刚刚了解到,您可以使用 setInterval() 和 setFastestInterval() 请求更新频率。如果您正在使用另一个应用,并且该应用每 5 秒接收一次位置更新,但是您不希望更新速度超过 20 秒,那么您应该使用 setFastestInterval(20000)这样您的应用就不会被更新淹没。同时,您可以使用 setInterval(60000) 请求每分钟更新一次的间隔。如果设备上发生的其他位置更新很少,你就不必担心将频率从 5 秒减少到 20 秒,但同时你可能也不会每分钟更新一次。您需要使用这两个设置器来表明您的应用想要什么,但这并不意味着您一定能得到您想要的。

低功率优先级通常意味着位置更新将仅通过蜂窝塔三角测量和 WiFi 热点位置信息来获得。这些都是确定位置的低功耗方法,精度会相应降低。您可以很容易地找到仅精确到 1500 米以内或更差的位置,但随后您可以获得精确到 10 米的位置。所有优先级都将利用被动提供者,因此,如果某个其他应用请求精确的位置更新,即使您的优先级设置为低功耗,您的应用也可以获得它。

平衡的优先级将尝试做一件体面的工作,以较低的功耗换取精度。它将考虑使用除 GPS 之外的所有可用的定位方法。

高精度优先级将潜在地使用所有可用的位置信息来源,包括 GPS。由于 GPS 无线电,这种优先权会消耗大量电池。

位置更新还取决于设备的位置模式。正如您之前看到的,KitKat 中的位置设置发生了变化,允许用户为他们的设备指定位置更新的模式。现在参考设置。安全类,定位模式设定值如下:

  • 位置 _ 模式 _ 关闭
  • 位置 _ 模式 _ 电池 _ 节能
  • 位置 _ 模式 _ 高精度
  • 位置 _ 模式 _ 传感器 _ 仅限于

使用清单 19-8 中的代码可以检索当前值。该模式由用户为整个器件设置,而不是由应用设置。但是,您的应用有机会请求一个优先级来补充用户所做的模式选择。如果设备的模式是高精度,而你的应用选择的优先级是低功耗,你的应用将不会耗尽电池,但仍然可以获得不错的位置更新。

然而,这种模式可能对你不利。如果用户选择了 SENSORS_ONLY 的模式,并且优先级被设置为 NO_POWER 、 LOW_POWER 甚至 BALANCED ,则位置更新将很少,无论您在位置请求中用 setInterval() 设置了什么。最有用的位置更新的首选模式是 HIGH_ACCURACY ,因为该模式将结合所有可能的位置信息来源,并提供最准确的结果。您的应用将能够在需要时获得高精度(希望这是一种罕见的需要),并在其余时间获得良好的精度。您的应用可以在需要时将优先级更改为高精度,但在其他时候平衡或低功耗。

使用 LocationRequest 的其他一些有趣的选项包括设置要接收的位置更新的具体数量,或者指定位置更新应该停止的时间限制。您还可以设置应用不希望更新的最小距离(以米为单位)。这是一种地理围栏,你告诉定位服务,如果设备从当前位置移动了一定距离,你只需要位置更新。这实际上是在当前位置周围建立地理围栏圈。稍后将详细介绍 geofences。

获取位置更新的替代方法

您已经看到了如何使用 LocationManager 的 requestLocationUpdates() 方法和 FusedLocationProviderApi 将位置更新发送到您的活动。这个方法实际上有几种不同的签名,包括使用 pending content 的签名。这使您能够将位置更新定向到服务或广播接收器。您还可以将位置更新指向其他 Looper 线程而不是主线程,为您的应用提供了很多灵活性,尽管其中一些方法仅在 Android 2.3 之后才可用。

使用邻近警报和地理围栏

地理围栏是移动应用的普遍要求。这意味着您的应用应该根据它的位置来改变它的行为。一个典型的使用案例是防止设备在特定位置之外工作。例如,当患者不在医院时,医院应用可以限制对患者数据的访问。或者,当设备在工作场所时,您的应用可能希望静音通知。LocationManager 有一个名为 proximity alerts 的机制,最近还有一个类似的 API,名为 GeofencingApi,用于更新的位置服务。我们将简要讨论第一个,然后详细讨论第二个。

我们之前提到过, LocationManager 可以在设备进入指定的地理位置时通知你。设置这个的方法是来自 LocationManager 类的 addProximityAlert() 。基本上,你告诉 LocationManager 你想要一个 Intent 在设备的位置进入或离开一个以纬度/经度位置为中心的特定半径的圆时被触发。意图可以触发 BroadcastReceiver 或者 Service 被调用,或者 Activity 被启动。警报还有一个可选的时间限制,所以它可以在意图开火之前超时。

在内部,该方法的代码为 GPS 和网络供应器注册监听器,并设置每秒一次的位置更新和 1 米的距离。您无法覆盖这种行为或设置参数。因此,如果您让它长时间运行,您可能会很快耗尽电池。如果屏幕进入睡眠状态,接近警报将每四分钟检查一次,但是同样,您无法控制持续时间。出于这些原因,我们在示例应用中包含了一个名为 ProximityAlertDemo 的演示应用,但我们不会深入讨论细节。相反,我们将把注意力转向位置服务方法,使用另一个名为 GeofencingApi 的示例应用。请注意,GeofencingApi 示例应用看起来与 FusedLocationProviderApi 示例应用相似,因为两者都共享 GoogleClientApi 的激活机制。

GeofencingApi API

在撰写本文时,地理围栏是一个具有纬度/经度中心的圆形区域,外加一些时间参数。在未来的某个时刻,这个区域可能不是圆形的,但现在是。一旦地理围栏构建完成,就可以将其传递给 GeofenceApi 进行监控。您的应用甚至可以消失,您的地理围栏也可以处于活动状态。除了一个地理围栏或一组地理围栏之外,您的应用还将传递一个 PendingIntent,目的是在地理围栏周围发生有趣的事情时被触发。三个当前事件是进入、退出和停留。进入和退出简单易懂;如果设备进入或离开圆形区域,将触发意图。停留事件在设备停留在圆形区域内一段时间后触发意图。这个待机延迟以毫秒为单位。这就是全部了。

请参见名为 GeofencingApiDemo 的示例应用。它设置了两个名为 home 和 work 的地理围栏,连接到位置服务,并注册了一个服务意图,当设备进入、退出或停留在这些地理围栏中的任何一个时,将被触发。触发时,该服务会为每个事件生成一个通知,以便您更容易看到结果。地理围栏通常在后台使用,因此服务在这里很有意义。也就是说,应用不需要在前台就可以拥有 geofences。事实上,地理围栏的基本思想是,如果设备进入或离开特定的地理区域,您希望您的应用被唤醒。

之前用于确保 Google Play 服务和位置服务可用且准备就绪的设置代码已从该示例应用中删除,以便于理解,但是您可能希望将该代码包含在生产应用中。清单 19-15 显示了主活动的 onCreate() 方法,其中创建了地理围栏和 pending content。

清单 19-15 。设置地理围栏

private GoogleApiClient mClient = null;
private List<Geofence> mGeofences = new ArrayList<Geofence>();
private PendingIntent pIntent = null;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    final float radius = 0.5f * 1609.0f; // half mile times 1609 meters per mile

    Geofence.Builder gb = new Geofence.Builder();
    // Make a half mile geofence around your home
    Geofence home = gb.setCircularRegion(28.993818, -81.383816, radius)
            .setTransitionTypes(
                Geofence.GEOFENCE_TRANSITION_ENTER |
                Geofence.GEOFENCE_TRANSITION_EXIT |
                Geofence.GEOFENCE_TRANSITION_DWELL )
            .setExpirationDuration(
                12 * 60 * 60 * 1000)  // 12 hours
            .setLoiteringDelay(300000)   // 5 minutes
            .setRequestId("home")
            .setNotificationResponsiveness(5000) // 5 secs
            .build();
    mGeofences.add(home);

    // Make another geofence around your work
    Geofence work = gb.setCircularRegion(28.36631, -81.52120, radius)
            .setRequestId("work")
            .build();
    mGeofences.add(work);
    Intent intent = new Intent(this, ReceiveTransitionsIntentService.class);

    pIntent = PendingIntent.getService(getApplicationContext(), 0, intent,
            PendingIntent.FLAG_UPDATE_CURRENT);

    mClient = new GoogleApiClient.Builder(this, this, this)
            .addApi(LocationServices.API)
            .build();

    Log.v(TAG, "Activity, client are created");
}

了解如何将地理围栏创建为围绕纬度/经度的圆圈,其中包含感兴趣的事件(在本例中为所有事件)和一些时间参数。在这个例子中,地理围栏将被激活 12 个小时,或者直到它们被移除(见 onDestroy() )。也可以将地理围栏设置为永不过期。5 分钟的游荡延迟意味着如果设备在地理围栏内停留至少 5 分钟,停留事件将触发。请求 ID 将与意向一起传回您的应用,以便您可以确定意向是针对哪个 geofence。5 秒的通知响应意味着 GeofencingApi 将尝试在事件发生后的 5 秒内发送意图。然而,没有人能保证这种意图会很快实现。这个值越大,对电池寿命越有利,因为 API 可以睡得更多,检查的次数更少。另一方面,如果该值很长,例如几分钟,那么如果设备快速通过地理围栏,您甚至可能会错过事件。通知响应的选择将取决于您的 geofences 有多大以及您希望您的应用如何运行。

类似于前面的示例应用,从 onResume() 尝试连接,并且清单 19-16 显示了当连接成功时运行的内容。

清单 19-16 。向 API 注册 Geofences

@Override
public void onConnected(Bundle arg0) {
    // Set up geofences
    Log.v(TAG, "Setting up geofences (onConnected)...");
    PendingResult<Status> pResult = mFencer.addGeofences(mClient,
            mGeofences, pIntent);
    pResult.setResultCallback(this);  // ResultCallback<Status> interface
}

@Override
public void onResult(Status status) {
    Log.v(TAG, "Got a result from addGeofences("
        + status.getStatusCode() + "): "
        + status.getStatus().getStatusMessage());
}

GeofencingApi 通过 Api 客户端句柄、geofences 列表和 PendingIntent。返回是一个待定结果。如果想知道结果最终是否成功,需要使用 setResultCallback() 设置一个回调接收方。该活动已经实现了 result callback接口,因此将使用 addGeofences() 方法调用的结果来调用 onResult() 回调。对于这个示例,结果被简单地记录下来,但是如果结果不成功,您当然希望采取措施。这就是活动所做的一切。接下来是当有趣的事件发生时接收意图的服务。

清单 19-17 展示了 receivetransitionsinntentservice 的有趣回调和方法,这是该应用的一个 IntentService 。它主要报告收到的信息,无论是错误还是地理围栏事件。使用通知显示事件。这是为了您的安全,因为预计您将在家里启动该应用,然后开车去工作。我们不希望您在旅途中观看设备屏幕。相反,当您安全停止时,您将能够查看每个事件的所有通知。

清单 19-17 。从 GeofencingApi 接收意向

public void onCreate() {
    super.onCreate();
    notificationMgr = (NotificationManager)getSystemService(
            NOTIFICATION_SERVICE);
}

@Override
protected void onHandleIntent(Intent intent) {
    GeofencingEvent gfEvent = GeofencingEvent.fromIntent(intent);
    // First check for errors
    if (gfEvent.hasError()) {
        // Get the error code with a static method
        int errorCode = gfEvent.getErrorCode();
        // Log the error
        Log.e(TAG, "Location Services error: " +
                Integer.toString(errorCode));
    /*
     * If there's no error, get the transition type and the IDs
     * of the geofence or geofences that triggered the transition
     */
    } else {
        // Get the type of transition (entry or exit)
        int transitionType =
                gfEvent.getGeofenceTransition();
        String tranTypeStr = "UNKNOWN(" + transitionType + ")";
        switch(transitionType) {
        case Geofence.GEOFENCE_TRANSITION_ENTER:
            tranTypeStr = "ENTER";
            break;
        case Geofence.GEOFENCE_TRANSITION_EXIT:
            tranTypeStr = "EXIT";
            break;
        case Geofence.GEOFENCE_TRANSITION_DWELL:
            tranTypeStr = "DWELL";
            break;
        }
        Log.v(TAG, "transitionType reported: " + tranTypeStr);
        Location triggerLoc = gfEvent.getTriggeringLocation();
        Log.v(TAG, "triggering location is " + triggerLoc);

        List <Geofence> triggerList =
                   gfEvent.getTriggeringGeofences();

        String[] triggerIds = new String[triggerList.size()];

        for (int i = 0; i < triggerIds.length; i++) {
            // Grab the Id of each geofence
            triggerIds[i] = triggerList.get(i).getRequestId();
            String msg = tranTypeStr + ": " + triggerLoc.getLatitude() +
                ", " + triggerLoc.getLongitude();
            String title = triggerIds[i];
            displayNotificationMessage(title, msg);
        }
    }
}

private void displayNotificationMessage(String title, String message)
{
    int notif_id = (int) (System.currentTimeMillis() & 0xFFL);

    Notification notification = new NotificationCompat.Builder(this)
    .setContentTitle(title)
    .setContentText(message)
    .setSmallIcon(android.R.drawable.ic_menu_compass)
    .setOngoing(false)
    .build();

    notificationMgr.notify(notif_id, notification);
}

当你在这个应用中替换家庭和工作的纬度和经度时,你在一个真实的设备上运行它,然后你移动这个设备,你将会看到如图图 19-8 中的通知。

9781430246800_Fig19-08.jpg

图 19-8 。来自 GeofencingApi 事件的通知

第一个事件发生在下午 6:40,因为当应用启动时,设备已经在家庭区域内。下午 6:45 的第二个事件是停留事件,因为在 5 分钟的游荡延迟之后,设备仍然在归属区域内。如果设备在截屏被捕获之前离开了家庭区域,将会有离开家庭的事件。请注意,通知中的纬度和经度是设备的实际位置,不一定是该区域的中心。

参考

这里有一些有用的参考资料,您可能希望进一步研究。

  • 。与本书相关的可下载项目列表。对于这一章,寻找一个名为 pro Android 5 _ Ch19 _ maps . zip 的 zip 文件。这个 zip 文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何将项目从这些 zip 文件之一导入到 IDE 中。这里有一些额外的示例应用,包括 WhereAmI4,它包含标记的自定义信息窗口。
  • developer . Android . com/guide/topics/location/index . html。Android 位置和地图开发者指南。
  • developer . Android . com/Google/play-services/index . html。Google Play 服务文档,包括 FusedLocationProviderApi、GeofencingApi 和 GoogleMap。
  • developer . Android . com/Google/play-services/setup . html。将 Google Play 服务库纳入您的应用的说明。注意下拉菜单允许在 Android Studio 和 Eclipse with ADT 之间进行选择。
  • 。地图 API 文档独立于其他在线 Android 文档。

摘要

让我们通过快速列举到目前为止您已经了解的地图来结束本章:

  • 如何从谷歌获取自己的地图 API 密钥?
  • MapFragment ,所有地图的主要组件。
  • 您需要对您的 AndroidManifest.xml 文件进行修改,以使地图应用工作。
  • 定义包含地图的布局,以及如何实例化地图。
  • 放大和缩小,平移和显示当前位置。
  • 包括卫星、交通等不同模式。
  • 如何使用地图框渲染地图。
  • 向地图添加标记。
  • 地图相机和设置适应特定标记集的缩放级别的方法。
  • 地理编码器,以及它如何从地址转换为纬度/经度,或者从纬度/经度转换为地址和感兴趣的地点。
  • 将地理编码器放入后台线程,以避免讨厌的应用不响应(ANR)弹出窗口。
  • LocationServices 服务,使用 GPS 和/或网络塔来精确定位设备的位置。
  • 选择位置提供者,以及如果期望的位置服务或提供者未被启用时该做什么。
  • 使用模拟器的功能将位置事件发送到您的应用进行测试。这包括使用记录整个系列位置事件的特殊文件。
  • 例如,使用 Location 类的方法来计算点之间的距离。
  • 如何进行所有的检查和纠正措施来设置 Google Play 位置更新服务。
  • 接近时发出警报—即设置一个接近度,当设备进入或离开该接近度时发出警报。
  • 设置 geofences 来处理一个或多个区域的进入、退出和停留事件,同时节省电池寿命。

二十、了解媒体框架

现在我们将探索 Android SDK 的一个非常有趣的部分:媒体框架。我们将向您展示如何播放各种来源的音频和视频。我们还将在在线伴侣部分介绍如何用相机拍照以及录制音频和视频。

使用媒体 API

Android 支持播放 android.media 包下的音视频内容。在这一章中,我们将从这个包中探索媒体 API。

android.media 包的核心是 ?? Android . media . media player 类 ??。MediaPlayer 类负责播放音频和视频内容。本课程的内容可以来自以下来源:

  • Web :您可以通过 URL 播放来自 Web 的内容。
  • apk 文件:你可以播放打包成你一部分的内容。apk 文件。您可以将媒体内容打包为资源或资产(在资产文件夹中)。
  • Android KitKat 4.4 新增的存储访问框架,它提供了对存储在一系列供应器和互联网服务中的媒体文件的访问。
  • SD 卡:您可以播放设备 SD 卡或模拟本地存储器上的内容。

MediaPlayer 能够解码多种不同的内容格式,包括第三代合作伙伴计划(3GPP、 .3gp )、MP3 ( .mp3 )、MIDI ( )。mid 等)、Ogg Vorbis ( )。ogg 、PCM/WAVE ( )。wav ),以及 MPEG-4 ( .mp4 )。RTSP、HTTP/HTTPS 直播和 M3U 播放列表也受支持,尽管包含 URL 的播放列表不受支持,至少在撰写本文时是如此。有关支持的媒体格式的完整列表,请访问developer . Android . com/guide/appendix/media-formats . html

SD 卡何去何从?

在我们深入媒体框架的核心之前,我们应该快速解决可移动存储的话题,尤其是 SD 卡。Android 设备的最新趋势是,一些制造商将它们从设备中删除,而其他制造商则继续包含它们。谷歌自己通过混淆 Android 中的底层文件系统,模糊了什么是移动存储,什么不是移动存储的界限。

不管你作为开发者的个人偏好如何,你的一些用户可能仍然拥有支持 SD 卡的设备,并且想要使用它们。我们将在这里讨论的许多例子同样适用于从 SD 卡获取媒体文件。然而,为了节省空间,并避免不必要的重复,我们在本书的网站上放置了一些额外的例子,这些例子涉及 SD 卡的细节和支持材料。一定要在www.androidbook.com检查一下。

播放媒体

首先,我们将向你展示如何构建一个简单的应用来播放网络上的 MP3 文件(见图 20-1 )。之后,我们将讨论如何使用 MediaPlayer 类的 setDataSource() 方法来播放来自的内容。apk 文件。 MediaPlayer 并不是播放音频的唯一方式,因此我们还将介绍 SoundPool 类,以及 JetPlayer 、 AsyncPlayer ,以及用于处理音频的最低级别的 AudioTrack 类。之后,我们将讨论 MediaPlayer 类的一些不足之处。最后,我们将了解如何播放视频内容。

播放音频内容

图 20-1 显示了我们第一个例子的用户界面。这个应用将演示 MediaPlayer 类的一些基本用法,比如启动、暂停、重启和停止媒体文件。查看应用用户界面的布局。

9781430246800_Fig20-01.jpg

图 20-1 。媒体应用的用户界面

用户界面由一个带有四个按钮的 RelativeLayout 组成:一个启动播放器,一个暂停播放器,一个重启播放器,一个停止播放器。我们本可以使这变得简单,只需将我们的例子与做同样事情的媒体控制器小部件结合起来,但是我们想向您展示自己控制事物的内部工作原理。应用的代码和布局文件如清单 20-1 所示。对于这个示例,我们将假设您正在构建 Android 2.2 或更高版本,因为我们使用了环境类的 getexternalsragepublicdirectory()方法。如果你想在一个旧版本的 Android 上构建它,只需使用 getExternalStorageDirectory()并调整你放置媒体文件的位置,这样你的应用就能找到它们。

注意参见本章末尾的“参考”部分,从中可以直接将这些项目导入 Eclipse,而不是复制和粘贴代码。

清单 20-1 。媒体应用的布局和代码

<RelativeLayout xmlns:android="[`schemas.android.com/apk/res/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"
    tools:context=".MainActivity"
    android:orientation="vertical" >

  <Button android:id="@+id/startPlayerBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Start Playing Audio"
    android:onClick="doClick" />

  <Button android:id="@+id/pausePlayerBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Pause Player"
    android:layout_below="@+id/startPlayerBtn"
    android:onClick="doClick" />

  <Button android:id="@+id/restartPlayerBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Restart Player"
    android:layout_below="@+id/pausePlayerBtn"
    android:onClick="doClick" />

  <Button android:id="@+id/stopPlayerBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Stop Player"
    android:layout_below="@+id/restartPlayerBtn"
    android:onClick="doClick" />

</RelativeLayout>

// This file is MainActivity.java
import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnPreparedListener;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.View;

public class MainActivity extends Activity implements OnPreparedListener
{
    static final String AUDIO_PATH =
     "[`www.androidbook.com/akc/filestorage/android/documentfiles/3389/play.mp3`](http://www.androidbook.com/akc/filestorage/android/documentfiles/3389/play.mp3)";

    private MediaPlayer mediaPlayer;
    private int playbackPosition=0;

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }

    public void doClick(View view) {
        switch(view.getId()) {
        case R.id.startPlayerBtn:
            try {
            // Only have one of these play methods uncommented
                playAudio(AUDIO_PATH);
//              playLocalAudio();
//              playLocalAudio_UsingDescriptor();
            } catch (Exception e) {
                e.printStackTrace();
            }
            break;
        case R.id.pausePlayerBtn:
            if(mediaPlayer != null && mediaPlayer.isPlaying()) {
                playbackPosition = mediaPlayer.getCurrentPosition();
                mediaPlayer.pause();
            }
            break;
        case R.id.restartPlayerBtn:
            if(mediaPlayer != null && !mediaPlayer.isPlaying()) {
                mediaPlayer.seekTo(playbackPosition);
                mediaPlayer.start();
            }
            break;
        case R.id.stopPlayerBtn:
            if(mediaPlayer != null) {
                mediaPlayer.stop();
                playbackPosition = 0;
            }
            break;
        }
    }

    private void playAudio(String url) throws Exception
    {
        killMediaPlayer();

        mediaPlayer = new MediaPlayer();
        mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
        mediaPlayer.setDataSource(url);
        mediaPlayer.setOnPreparedListener(this);
        mediaPlayer.prepareAsync();
    }

    private void playLocalAudio() throws Exception
    {
        mediaPlayer = MediaPlayer.create(this, R.raw.music_file);
        mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
        // calling prepare() is not required in this case
        mediaPlayer.start();
    }

    private void playLocalAudio_UsingDescriptor() throws Exception {

        AssetFileDescriptor fileDesc = getResources().openRawResourceFd(
                    R.raw.music_file);
        if (fileDesc != null) {

            mediaPlayer = new MediaPlayer();
            mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
            mediaPlayer.setDataSource(fileDesc.getFileDescriptor(),
                    fileDesc.getStartOffset(), fileDesc.getLength());

            fileDesc.close();

            mediaPlayer.prepare();
            mediaPlayer.start();
        }
    }

    // This is called when the MediaPlayer is ready to start
    public void onPrepared(MediaPlayer mp) {
        mp.start();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        killMediaPlayer();
    }

    private void killMediaPlayer() {
        if(mediaPlayer!=null) {
            try {
                mediaPlayer.release();
            }
            catch(Exception e) {
                e.printStackTrace();
            }
        }
    }
}

在第一个场景中,您正在从一个网址播放 MP3 文件。因此,您需要将 Android . permission . internet 添加到您的清单文件中。清单 20-1 显示了 MainActivity 类包含三个成员:一个指向 MP3 文件 URL 的 final 字符串,一个 MediaPlayer 实例,以及一个名为 playbackPosition 的整数成员。我们的 onCreate() 方法只是从我们的布局 XML 文件中设置用户界面。在按钮点击处理器中,当按下开始播放音频按钮时,调用 playAudio() 方法。在 playAudio() 方法中,创建了一个 MediaPlayer 的新实例,播放器的数据源设置为 MP3 文件的 URL。

然后调用播放器的 prepareAsync() 方法来准备 MediaPlayer 进行播放。我们在活动的主 UI 线程中,所以我们不想花太多时间来准备 MediaPlayer。在 MediaPlayer 上有一个 prepare() 方法,但是在准备完成之前它会阻塞。如果这需要很长时间,或者如果服务器需要一段时间来响应,用户可能会认为应用卡住了,或者更糟,得到一个错误消息。像进度对话框这样的东西可以帮助你的用户理解正在发生的事情。 prepareAsync() 方法立即返回,但是设置一个后台线程来处理 MediaPlayer 的 prepare() 方法。当准备完成时,我们的活动的 onPrepared() 回调被调用。这是我们最终开始播放媒体播放器的地方。我们必须告诉 MediaPlayer 谁是 onPrepared() 回调的侦听器,这就是为什么我们在调用 prepareAsync() 之前调用 setOnPreparedListener()。您不必将当前活动用作侦听器;我们在这里这样做是因为这对于本演示来说更简单。

现在看看暂停播放器和重启播放器按钮的代码。可以看到当暂停播放器按钮被选中时,通过调用 getCurrentPosition() 得到播放器的当前位置。然后通过调用 pause() 来暂停播放器。当播放器必须重启时,调用 seekTo() ,传入之前从 getCurrentPosition() 获得的位置,然后调用 start() 。

MediaPlayer 类还包含一个 stop() 方法。注意,如果您通过调用 stop() 来停止播放器,您需要在再次调用 start() 之前再次准备 MediaPlayer 。反过来,如果调用 pause() ,可以不用准备播放器,再次调用 start() 。此外,在使用完媒体播放器后,一定要调用它的 release() 方法。在本例中,您将此作为 killMediaPlayer() 方法的一部分。

在示例应用源代码中有第二个 URL 用于音频源,但它不是 MP3 文件,而是一个流音频提要(Radio-Mozart)。这也适用于 MediaPlayer,并再次显示了为什么您需要调用 prepareAsync() 而不是 prepare() 。准备用于回放的音频流可能需要一段时间,具体取决于服务器、网络流量等。

清单 20-1 展示了如何播放网络上的音频文件。 MediaPlayer 类也支持播放你的本地的媒体。apk 文件。清单 20-2 展示了如何从的 /res/raw 文件夹中引用并回放一个文件。apk 文件。继续添加 raw 文件夹到 /res 下,如果 Eclipse 项目中还没有的话。然后,将您选择的 MP3 文件复制到 /res/raw 中,文件名为 music_file.mp3 。还要注意原始代码中的注释,取消对 playLocalAudio() 的调用的注释,并注释掉 playAudio()。

清单 20-2 。 使用 MediaPlayer 播放应用本地文件

private void playLocalAudio()throws Exception
{
    mediaPlayer = MediaPlayer.create(this, R.raw.music_file);
    mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);        // calling prepare() is not required in this case
    mediaPlayer.start();
}

如果您需要在应用中包含音频或视频文件,您应该将该文件放在 /res/raw 文件夹中。然后,您可以通过传入媒体文件的资源 ID 来获得资源的 MediaPlayer 实例。你可以通过调用静态的 create() 方法来实现,如清单 20-2 所示。请注意, MediaPlayer 类提供了一些其他静态的 create() 方法,您可以使用这些方法来获取 MediaPlayer 而不是自己实例化一个。在清单 20-2 中, create() 方法相当于调用构造函数 MediaPlayer(Context context,int resourceId) 后跟调用 prepare() 。只有当媒体源位于设备本地时,才应该使用 create() 方法,因为它总是使用 prepare() 而不是 prepareAsync() 。

了解 setDataSource 方法

在清单 20-2 中,我们调用了 create() 方法从原始资源加载音频文件。使用这种方法,您不需要调用 setDataSource() 。或者,如果您使用默认构造函数自己实例化了 MediaPlayer ,或者如果您的媒体内容不能通过资源 ID 或 URI 访问,您将需要调用 setDataSource() 。

setDataSource() 方法有重载版本,您可以根据自己的特定需求定制数据源。例如,清单 20-3 展示了如何使用 FileDescriptor 从原始资源中加载一个音频文件。

清单 20-3 。 使用文件描述符设置媒体播放器的数据源

private void playLocalAudio_UsingDescriptor() throws Exception {
    AssetFileDescriptor fileDesc = getResources().openRawResourceFd(
            R.raw.music_file);
    if (fileDesc != null) {

        mediaPlayer = new MediaPlayer();
        mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
        mediaPlayer.setDataSource(fileDesc.getFileDescriptor(),
                fileDesc.getStartOffset(), fileDesc.getLength());

        fileDesc.close();

        mediaPlayer.prepare();
        mediaPlayer.start();
    }
}

清单 20-3 假设它在一个活动的上下文中。如图所示,调用 getResources() 方法获取应用的资源,然后使用 openRawResourceFd() 方法获取 /res/raw 文件夹中音频文件的文件描述符。然后使用 AssetFileDescriptor 调用 setDataSource() 方法,开始回放的起始位置和结束位置。如果您想回放音频文件的特定部分,也可以使用这个版本的 setDataSource() 。如果总是想播放整个文件,可以调用更简单的版本 set data source(file descriptor desc),不需要初始偏移量和长度。

在这种情况下,我们选择使用 prepare() 后跟 start() ,只是为了向您展示它可能的样子。我们应该能够逃脱,因为音频资源是本地的,但像以前一样使用 prepareAsync() 也无妨。

我们还有一个音频内容的来源可以谈论:SD 卡。有关处理 SD 卡及其文件系统内容的基础知识,请参考在线指南章节。在我们的例子中,我们使用 setDataSource() 通过传入一个 MP3 文件的 URL 来访问互联网上的内容。如果你在 SD 卡上有一个音频文件,你可以使用同样的 setDataSource() 方法,但是把你在 SD 卡上的音频文件的路径传给它。例如,音乐目录中名为 music_file.mp3 的文件可以用 AUDIO_PATH 变量来播放,如下所示:

static final String AUDIO_PATH =
Environment.getExternalStoragePublicDirectory(
    Environment.DIRECTORY_MUSIC) +
    "/music_file.mp3";

您可能已经注意到,在我们的示例中,我们没有实现 onResume() 和 onPause() 。这意味着当我们的活动进入后台时,它会继续播放音频——至少,直到活动被终止,或者直到对音频源的访问被关闭。例如,如果我们不持有唤醒锁,CPU 可能会关闭,从而结束音乐的播放。许多人选择在服务中管理媒体播放来帮助解决这些问题。在我们当前的例子中,其他问题包括 MediaPlayer 是否正在通过 Wi-Fi 播放音频流,如果我们的活动没有锁定 Wi-Fi,Wi-Fi 可能会被关闭,我们将失去与该流的连接。 MediaPlayer 有一个名为 setWakeMode() 的方法,它允许我们设置一个 PARTIAL_WAKE_LOCK 来在播放时保持 CPU 活动。然而,为了锁定 Wi-Fi,我们需要分别通过 WifiManager 和 WifiManager 来实现。WifiLock 。

继续在后台播放音频的另一个方面是,我们需要知道什么时候不应该这样做,可能是因为有来电,或者是因为闹钟响了。Android 有一个 AudioManager 来帮助解决这个问题。调用的方法有 requestAudioFocus() 和 abandonAudioFocus() ,在接口 AudioManager 中有一个回调方法叫做 onAudioFocusChange() 。OnAudioFocusChangeListener 。有关更多信息,请参见 Android 开发人员指南中的媒体页面。

使用 SoundPool 进行同步音轨播放

MediaPlayer 是我们媒体工具箱中的一个重要工具,但它一次只能处理一个音频或视频文件。如果我们想同时播放多个音轨呢?一种方法是创建多个媒体播放器并同时使用它们。如果你只有少量的音频要播放,并且你想要快速的性能,Android 有 SoundPool 类来帮助你。在幕后, SoundPool 使用 MediaPlayer ,但是我们无法访问 MediaPlayer API,只能访问 SoundPool API。

MediaPlayer 和 SoundPool 的另一个区别是 SoundPool 被设计成只处理本地媒体文件。也就是说,您可以从资源文件、使用文件描述符的其他文件或使用路径名的文件载入音频。 SoundPool 还提供了其他几个不错的功能,比如循环播放音轨、暂停和恢复单个音轨,或者暂停和恢复所有音轨。

然而, SoundPool 也有一些缺点。对于所有的音轨, SoundPool 在内存中有一个总的音频缓冲区,只有 1MB。当您查看只有几千字节大小的 MP3 文件时,这可能看起来很大。但是 SoundPool 扩展了内存中的音频,使播放变得快速简单。内存中音频文件的大小取决于比特率、声道数(立体声与单声道)、采样速率和音频长度。如果你无法将声音加载到 SoundPool 中,你可以尝试使用源音频文件的这些参数来使音频在内存中变小。

我们的示例应用将加载并播放动物的声音。其中一种声音是蟋蟀的叫声,它不断地在背景中播放。其他声音以不同的时间间隔播放。有时候你听到的只有蟋蟀的叫声;其他时候,你会同时听到几种动物的声音。我们还将在用户界面中放置一个按钮,允许暂停和恢复。清单 20-4 显示了我们的布局 XML 文件和活动的 Java 代码。你最好的办法是从我们的网站上下载,以便获得声音文件和代码。有关如何找到可下载源代码的信息,请参见本章末尾的“参考”部分。

清单 20-4 。 用 SoundPool 播放音频

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"  android:layout_height="fill_parent"
    >
<ToggleButton android:id="@+id/button"
    android:textOn="Pause"  android:textOff="Resume"
    android:layout_width="wrap_content"  android:layout_height="wrap_content"
    android:onClick="doClick" android:checked="true" />
</LinearLayout>

// This file is MainActivity.java
import java.io.IOException;
import android.app.Activity;
import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.media.AudioManager;
import android.media.SoundPool;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.view.View;
import android.widget.ToggleButton;

public class MainActivity extends Activity implements SoundPool.OnLoadCompleteListener {
    private static final int SRC_QUALITY = 0;
    private static final int PRIORITY = 1;
    private SoundPool soundPool = null;
    private AudioManager aMgr;

    private int sid_background;
    private int sid_roar;
    private int sid_bark;
    private int sid_chimp;
    private int sid_rooster;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }

    @Override
    protected void onResume() {
        soundPool = new SoundPool(5, AudioManager.STREAM_MUSIC,
                SRC_QUALITY);
        soundPool.setOnLoadCompleteListener(this);

        aMgr =
            (AudioManager)this.getSystemService(Context.AUDIO_SERVICE);

        sid_background = soundPool.load(this, R.raw.crickets, PRIORITY);

        sid_chimp = soundPool.load(this, R.raw.chimp, PRIORITY);
        sid_rooster = soundPool.load(this, R.raw.rooster, PRIORITY);
        sid_roar = soundPool.load(this, R.raw.roar, PRIORITY);

        try {
            AssetFileDescriptor afd =
                    this.getAssets().openFd("dogbark.mp3");
            sid_bark = soundPool.load(afd.getFileDescriptor(),
                                0, afd.getLength(), PRIORITY);
            afd.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        //sid_bark = soundPool.load("/mnt/sdcard/dogbark.mp3", PRIORITY);

        super.onResume();
    }

    public void doClick(View view) {
        switch(view.getId()) {
        case R.id.button:
            if(((ToggleButton)view).isChecked()) {
                soundPool.autoResume();
            }
            else {
                soundPool.autoPause();
            }
            break;
        }
    }

    @Override
    protected void onPause() {
        soundPool.release();
        soundPool = null;
        super.onPause();
    }

    @Override
    public void onLoadComplete(SoundPool sPool, int sid, int status) {
        Log.v("soundPool", "sid " + sid + " loaded with status " +
                status);

        final float currentVolume =
            ((float)aMgr.getStreamVolume(AudioManager.STREAM_MUSIC)) /
            ((float)aMgr.getStreamMaxVolume(AudioManager.STREAM_MUSIC));

        if(status != 0)
            return;
        if(sid == sid_background) {
            if(sPool.play(sid, currentVolume, currentVolume,
                    PRIORITY, -1, 1.0f) == 0)
                Log.v("soundPool", "Failed to start sound");
        } else if(sid == sid_chimp) {
            queueSound(sid, 5000, currentVolume);
        } else if(sid == sid_rooster) {
            queueSound(sid, 6000, currentVolume);
        } else if(sid == sid_roar) {
            queueSound(sid, 12000, currentVolume);
        } else if(sid == sid_bark) {
            queueSound(sid, 7000, currentVolume);
        }
    }

    private void queueSound(final int sid, final long delay,
        final float volume)
    {
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                if(soundPool == null) return;
                if(soundPool.play(sid, volume, volume,
                        PRIORITY, 0, 1.0f) == 0)
                    Log.v("soundPool", "Failed to start sound (" + sid +
                          ")");
                queueSound(sid, delay, volume);
            }}, delay);
    }
}

这个例子的结构相当简单。我们有一个用户界面,上面只有一个切换按钮。我们将用它来暂停和恢复活动的音频流。当我们的应用启动时,我们创建我们的音池,并加载音频样本。当样本被正确加载后,我们开始播放它们。蟋蟀的声音无休止地循环播放;其他样本在延迟后播放,然后在相同的延迟后将自己设置为再次播放。通过选择不同的延迟,我们可以在声音上获得一种随机的效果。

创建一个声音池需要三个参数:

  • 第一个是 SoundPool 将同时播放的最大样本数。这不是音池能容纳的样本数量。
  • 第二个参数是样本将在哪个音频流上播放。典型值是 AudioManager。STREAM_MUSIC ,但是 SoundPool 可以用于闹铃或者铃声。请参见 AudioManager 参考页,了解完整的音频流列表。
  • 在创建音池时 SRC_QUALITY 值应该设置为 0。

代码演示了 SoundPool 的几种不同的 load() 方法。最基本的是从 /res/raw 加载一个音频文件作为资源。我们对前四个音频文件使用这种方法。然后我们展示如何从应用的 /assets 目录中加载一个音频文件。这个 load() 方法还接受指定要加载的音频的偏移量和长度的参数。这将允许我们使用一个包含多个音频样本的文件,从中提取出我们想要使用的内容。最后,我们在注释中展示了如何从 SD 卡中访问音频文件。一直到 Android 4.0,优先级参数应该只是 1。

对于我们的例子,我们选择使用 Android 2.2 中引入的一些特性,特别是我们活动的 onLoadCompleteListener 接口,以及按钮回调中的 autoPause() 和 autoResume() 方法。

当将声音样本加载到 SoundPool 中时,我们必须等到它们被正确加载后才能开始播放它们。在我们的 onLoadComplete() 回调中,我们检查加载的状态,然后根据它是哪种声音,我们设置它进行播放。如果是蟋蟀的声音,我们会打开循环播放(第五个参数的值为 -1 )。对于其他人,我们会在一段时间后播放声音。时间值以毫秒为单位。注意音量的设置。Android 提供了 AudioManager 让我们知道当前的音量设置。我们还从 AudioManager 获得最大音量设置,因此我们可以计算出介于 0 和 1 之间的 play() 的音量值(作为一个浮点数)。 play() 方法实际上为左右声道取一个单独的音量值,但我们只是将两者都设置为当前音量。同样,优先级应该设置为 1。 play() 方法的最后一个参数用于设置回放速率。该值应介于 0.5 和 2.0 之间,1.0 为正常值。

我们的 queueSound() 方法使用一个处理器来基本上建立一个未来的事件。我们的 Runnable 将在延迟期过去后运行。我们检查以确保我们仍然有一个 SoundPool 可以播放,然后我们播放一次声音,并安排相同的声音在与之前相同的时间间隔后再次播放。因为我们用不同的声音 id 和不同的延迟来调用 queueSound() ,所以效果有点像动物声音的随机播放。

当您运行这个示例时,您将听到蟋蟀、黑猩猩、公鸡、狗和吼声(我们认为是熊)。当其他动物来来去去的时候,蟋蟀在不停地鸣叫。关于 SoundPool 的一个好处是它让我们可以同时播放多种声音,而不需要我们做任何实际的工作。此外,我们不会让设备负担太重,因为声音是在加载时解码的,我们只需要将声音比特馈送给硬件。

如果您点按该按钮,蟋蟀会停止鸣叫,当前播放的任何其他动物声音也会停止。然而, autoPause() 方法并不能阻止新声音的播放。你会在几秒钟内再次听到动物的声音(除了蟋蟀的叫声)。因为我们已经把声音排到了未来,我们仍然会听到那些声音。事实上, SoundPool 没有办法阻止现在和未来的所有声音。你需要自己停下来。只有我们再次点击按钮恢复声音,蟋蟀才会回来。但即使这样,我们也可能会失去蟋蟀,因为如果达到同时播放样本的最大数量,SoundPool 将丢弃最老的声音,为新的声音腾出空间。

使用 JetPlayer 播放声音

SoundPool 是一款不错的播放器,但内存限制可能会让它难以完成工作。当你需要播放同步声音时,另一个选择是 JetPlayer 。为游戏定制的 JetPlayer 是一个非常灵活的工具,可以播放大量的声音,并协调这些声音与用户动作。使用乐器数字接口(MIDI) 定义声音。

JetPlayer 的声音是用一种特殊的 JETCreator 工具制作的。这个工具是在 Android SDK 工具目录下提供的,尽管你也需要安装 Python 才能使用它,而且它仅限于 Mac OSX 和 Windows SDK 包。生成的 JET 文件可以读入您的应用,并设置声音进行播放。整个过程有些复杂,超出了本书的范围,所以我们将在本章末尾的“参考资料”部分为您提供更多信息。

用异步播放器播放背景声音

如果你想要的只是播放一些音频,并且不想占用当前线程,那么 AsyncPlayer 可能就是你要找的。音频源作为 URI 传递给该类,因此音频文件可以是本地的,也可以是网络上的远程文件。这个类自动创建一个后台线程来处理获取音频和开始播放。因为是异步的,所以你不会确切知道音频什么时候开始。你也不知道它什么时候结束,甚至不知道它是否还在播放。但是,您可以调用 stop() 来停止播放音频。如果您在之前的音频播放完毕之前再次调用 play() ,之前的音频将立即停止,新的音频将在未来的某个时间开始播放,此时一切都已设置好并已获取。这是一个非常简单的类,提供了一个自动后台线程。清单 20-5 显示了你的代码应该如何实现这一点。

清单 20-5 。?? 用 AsyncPlayer 播放音频

private static final String TAG = "AsyncPlayerDemo";
private AsyncPlayer mAsync = null;

[ ... ]

    mAsync = new AsyncPlayer(TAG);
    mAsync.play(this, Uri.parse("file://” + “/perry_ringtone.mp3"),
            false, AudioManager.STREAM_MUSIC);

[ ... ]

@Override
protected void onPause() {
    mAsync.stop();
    super.onPause();
}

使用 AudioTrack 的低级音频回放

到目前为止,我们一直在处理来自文件的音频,无论是本地文件还是远程文件。如果您想深入到一个较低的层次,也许是从一个流中播放音频,您需要研究一下 AudioTrack 类。除了像 play() 和 pause() , AudioTrack 这样的常用方法,还提供了向音频硬件写入字节的方法。这个类给了你对音频回放的最大控制权,但是它比本章到目前为止讨论的音频类要复杂得多。我们的一个在线伙伴示例应用使用了 AudioRecord 类。 AudioRecord 类与 AudioTrack 类非常相似,因此为了更好地理解 AudioTrack 类,请参考后面的 AudioRecord 示例。

关于 MediaPlayer 的更多信息

一般来说,MediaPlayer 是非常系统化的,所以您需要以特定的顺序调用操作来正确地初始化 MediaPlayer 并为播放做准备。下面的列表总结了使用媒体 API 时应该知道的一些其他细节:

  • 一旦设置了 MediaPlayer 的数据源,就不能轻易将其更改为另一个——您必须创建一个新的 MediaPlayer 或调用 reset() 方法来重新初始化播放器的状态。
  • 调用 prepare() 后,可以调用 getCurrentPosition() 、 getDuration() 、 isPlaying() 来获取玩家的当前状态。也可以在调用 prepare() 后调用 setLooping() 和 setVolume() 方法。如果您使用了 prepareAsync() ,那么您应该等到 onPrepared() 被调用后再使用任何其他方法。
  • 调用 start() 后,可以调用 pause() 、 stop() 、 seekTo() 。
  • 你创建的每一个媒体播放器都会使用大量的资源,所以当你使用完媒体播放器时,一定要调用 release() 方法。在视频播放的情况下, VideoView 会处理这一点,但是如果你决定使用 MediaPlayer 而不是 VideoView ,你就必须手动处理。在接下来的章节中会有更多关于 VideoView 的内容。
  • MediaPlayer 与几个监听器配合使用,您可以使用它们对用户体验进行额外的控制,包括 OnCompletionListener 、 OnErrorListener 和 OnInfoListener 。例如,如果您正在管理一个音频播放列表,当一个片段完成时,将调用 OnCompletionListener ,以便您可以将下一个片段排队。

我们关于播放音频内容的讨论到此结束。现在我们将注意力转向播放视频。正如您将看到的,引用视频内容类似于引用音频内容。

播放视频内容

在本节中,我们将讨论使用 Android SDK 播放视频。具体来说,我们将讨论从 web 服务器播放视频和从 SD 卡播放视频。可以想象,视频回放比音频回放要复杂一些。幸运的是,Android SDK 提供了一些额外的抽象来完成大部分繁重的工作。

注意在模拟器中回放视频不是很可靠。如果成功了,那太好了。但是如果不能,试着在一个设备上运行。因为模拟器必须只使用软件来运行视频,所以它可能很难跟上视频,并且您可能会得到意想不到的结果。

播放视频比播放音频需要更多的努力,因为除了音频之外,还有一个视觉组件要处理。为了消除一些痛苦,Android 提供了一个名为 android.widget.VideoView 的专门视图控件,它封装了 MediaPlayer 的创建和初始化。要播放视频,您需要在用户界面中创建一个 VideoView 小部件。然后设置视频的路径或 URI,并触发 start() 方法。清单 20-6 演示了 Android 中的视频播放。

清单 20-6 。 使用媒体 API 播放视频

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/layout/main.xml -->
<LinearLayout
 android:layout_width="fill_parent" android:layout_height="fill_parent"
 xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)">

    <VideoView  android:id="@+id/videoView"
        android:layout_width="200px"  android:layout_height="200px" />

</LinearLayout>

// This file is MainActivity.java
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.widget.MediaController;
import android.widget.VideoView;

public class MainActivity extends Activity {
    /** Called when the activity is first created. */
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.setContentView(R.layout.main);

        VideoView videoView =
               (VideoView)this.findViewById(R.id.videoView);
        MediaController mc = new MediaController(this);
        videoView.setMediaController(mc);
        videoView.setVideoURI(Uri.parse(
              "[`www.androidbook.com/akc/filestorage/android/`](http://www.androidbook.com/akc/filestorage/android/)" +
               "documentfiles/3389/movie.mp4"));
 /* videoView.setVideoPath(
    Environment.getExternalStoragePublicDirectory(
    Environment.DIRECTORY_MOVIES) +
    "/movie.mp4");
 */
        videoView.requestFocus();
        videoView.start();
    }
}

清单 20-6 演示了位于www . Android book . com/AKC/filestorage/Android/document files/3389/movie . MP4的文件的视频回放,这意味着运行代码的应用需要请求 Android . permission . internet 权限。所有回放功能都隐藏在 VideoView 类之后。事实上,你所要做的就是将视频内容传送给视频播放器。应用的用户界面如图 20-2 所示。

9781430246800_Fig20-02.jpg

图 20-2 。启用了媒体控制的视频播放 UI

当这个应用运行时,您会在屏幕底部看到按钮控件大约三秒钟,然后它们会消失。您可以通过单击视频帧中的任意位置来找回它们。当我们回放音频内容时,我们只需要显示按钮控件来开始、暂停和重启音频。我们不需要音频本身的视图组件。当然,对于视频,我们需要按钮控件以及观看视频的东西。对于这个例子,我们使用一个 VideoView 组件来显示视频内容。但是我们没有创建自己的按钮控件(如果我们愿意,我们仍然可以这样做),而是创建了一个媒体控制器来为我们提供按钮。如图 20-2 和清单 20-6 所示,您可以通过调用 setMediaController() 来设置 VideoView 的媒体控制器,以启用播放、暂停和查找控件。如果您想用自己的按钮以编程方式操作视频,可以调用 start() 、 pause() 、 stopPlayback() 和 seekTo() 方法。

请记住,在这个例子中,我们仍然使用了一个 media player——只是我们没有看到它。事实上,你可以直接在 MediaPlayer 中“播放”视频。如果你回到清单 20-1 中的例子,在你的 SD 卡上放一个电影文件,并在 AUDIO_PATH 中插入电影的文件路径,你会发现即使你看不到视频,它也能很好地播放音频。

鉴于 MediaPlayer 有一个 setDataSource() 方法, VideoView 没有。 VideoView 改为使用 setVideoPath() 或 setVideoURI() 方法。假设您将一个电影文件放在您的 SD 卡上,您修改清单 20-6 中的代码,注释掉 setVideoURI() 调用,取消注释 setVideoPath() 调用,根据需要调整电影文件的路径。当您再次运行该应用时,您将在视频视图中听到并看到视频。从技术上讲,我们可以用下面的代码调用 setVideoURI() 来获得与 setVideoPath() 相同的效果:

videoView.setVideoURI(Uri.parse("file://" +
    Environment.getExternalStoragePublicDirectory(
    Environment.DIRECTORY_MOVIES) + "/movie.mp4"));

你可能已经注意到 VideoView 没有像 MediaPlayer 那样从文件描述符中读取数据的方法。你可能也注意到了 MediaPlayer 有几个方法可以将 SurfaceHolder 添加到 MediaPlayer (一个 SurfaceHolder 就像一个图像或视频的视窗)。 MediaPlayer 方法之一是 create(Context context,Uri uri,surface holder),另一个是 set display(surface holder)。

关于录音和高级媒体的额外在线章节

现在,您已经掌握了媒体播放的许多方面,包括在应用中构建自己的音频和视频功能的各种方法,还有一些领域可以探索,这些领域本身几乎就是一本书的内容。因此,我们将它们放在另一个在线奖金章节中,探讨以下内容:

  • 用 MediaRecorder 、 AudioRecord 等技术录音
  • 从头开始录像
  • 用于视频录制的相机和摄像机配置文件
  • 使用 intents 和 MediaStore 类让其他应用帮你完成所有的录制工作!

看一下音频和视频记录奖励章节的在线材料。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

  • :与本书相关的可下载项目列表。对于本章中的项目,寻找一个名为 proandroid 5 _ Ch20 _ media . zip 的 zip 文件。这个 zip 文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何从这些 zip 文件之一将项目导入 Eclipse。
  • developer . Android . com/guide/topics/media/jet/jet creator _ manual . html:jet creator 工具的用户手册。您可以使用它来创建一个 JET 声音文件,以便使用 JetPlayer 播放。JETCreator 仅适用于 Windows 和 Mac OS。要查看实际运行的 JetPlayer,请将 JetBoy 示例项目从 Android SDK 加载到 Eclipse 中,构建并运行它。请注意,发射按钮是中间的方向键。

摘要

以下是本媒体章节中关于音频和视频的主题摘要:

  • 通过媒体播放器播放音频
  • 为 MediaPlayer 提供音频的几种方式,从本地应用资源到文件,再到网络流
  • 使用 MediaPlayer 正确播放音频的步骤
  • SoundPool 及其同时播放多种声音的能力
  • SoundPool 在处理音频量方面的限制
  • AsyncPlayer 非常有用,因为声音通常需要在后台处理
  • AudioTrack ,使用 VideoView 提供对音频播放视频的低级访问

二十一、主屏幕小部件

Android 中的主屏幕小部件在 Android 的主屏幕上呈现频繁变化的信息。主屏幕小部件是显示在主屏幕上的断开连接的视图。这些视图的数据内容由后台进程定期更新,或者仅作为静态视图保存。

例如,一个电子邮件主屏幕小部件可能会提醒您要阅读的未处理电子邮件的数量。小工具可能只显示电子邮件的数量,而不是邮件本身。单击电子邮件计数可能会将您带到显示实际电子邮件的活动。这些甚至可以是外部电子邮件源,如 Yahoo、Gmail 和 Hotmail,只要设备能够通过 HTTP 或其他连接机制访问计数。

在 Android SDK 中,小部件是声明式定义的。小部件定义包含以下内容:

  • 要在主屏幕上显示的视图布局,以及它应该适合主页的大小。
  • 指定更新频率的计时器。
  • 一个名为 widget provider 的广播接收器 Java 类,它可以响应计时器更新,以便通过填充数据以某种方式改变视图。
  • 一个 activity 类,负责收集进一步配置要显示的小部件所需的输入。

定时器、接收器和配置活动是可选的。一旦定义了小部件并提供了 Java 类,用户就可以将小部件拖到主页上。视图和相应的 Java 类是以这样一种方式构建的,它们相互之间是断开的。例如,任何 Android 服务或活动都可以使用其布局 id 检索视图,用数据填充视图(就像填充模板一样),并将其发送到主屏幕。一旦视图被发送到主屏幕,它就会从底层 Java 代码中移除。

在我们向您展示如何实现一个小部件之前,我们将首先向您概述一个最终用户是如何使用小部件的。

主屏幕小工具的用户体验

Android 中的 Home screen widget 功能允许你选择一个预编程的 widget 放在主屏幕上。放置后,如果需要,小部件将允许您使用活动(定义为小部件包的一部分)来配置它。在真正研究小部件是如何实现的细节之前,理解这种交互是很重要的。

我们将带您浏览我们为本章创建的名为“生日”的小部件。我们将在本章的后面给出它的源代码。首先,我们将使用这个小部件作为我们演练的示例。由于随后会有源代码,我们需要你仔细阅读图片,而不是在你的屏幕上寻找这个小部件。如果您遵循提供的图形和解释,您将了解生日小部件的性质和行为,这将在我们随后编写代码时使事情变得清楚。

让我们通过定位我们想要的小部件并在主屏幕上创建它的一个实例来开始这个旅程。访问可用小部件列表的方式因 Android 版本而异。不过,通常情况下,小工具列表与设备上可用的应用列表放在一起。图 21-1 中有一个来自 API 16(或 Android 的 Jellybean 版本)的例子。

9781430246800_Fig21-01.jpg

图 21-1 。主屏幕小工具选择列表

在图 21-1 的小部件列表中,生日小部件 就是为这一章设计的。如果你选择这个小工具,Android 允许你把它拖到主屏幕的一个页面上。Android 将在主屏幕上创建一个相应的 widget 实例,看起来像图 21-2 中的生日 Widget 示例。

9781430246800_Fig21-02.jpg

图 21-2 。生日小工具示例

图 21-2 中的生日小工具会在它的头部显示这个人的名字,这个人的生日还有几天,今年的出生日期是什么时候,以及一个购买礼物的链接。您可能想知道人名和出生日期是如何配置的。如果您想要这个小部件的两个实例,每个实例包含不同人的姓名和出生日期,该怎么办?这就是小部件配置活动发挥作用的地方,也是我们接下来要讨论的主题。

了解小部件配置活动

小部件定义可选地包括称为小部件配置活动的活动的规范。当您从主页小部件选择列表中选择一个小部件来创建小部件实例时,Android 会调用相应的小部件配置活动(如果为它定义了一个活动的话)。这个活动是你需要编码的东西。

如果是我们的 BirthdayWidget ,这个配置活动会提示你输入人名和即将到来的生日,如图图 21-3 所示。配置活动的责任是将该信息保存在一个持久的位置,以便当在窗口小部件提供者上调用更新时,窗口小部件提供者将能够定位该信息并更新离生日的天数。

9781430246800_Fig21-03.jpg

图 21-3 。生日小工具配置活动

注意当用户选择在主屏幕上创建两个生日小部件实例时,配置活动将被调用两次(每个小部件实例调用一次)。

在内部,Android 通过分配唯一的 id 来跟踪小部件实例。这个惟一的小部件实例 ID 被传递给 Java 回调函数和 configurator Java 类,以便初始配置和更新可以指向主页上正确的小部件实例。在图 21-2 中,在字符串 satya:3 的后面部分, 3 是 widget 实例 ID。

了解小部件的生命周期

小部件的生命周期有以下几个阶段:

  1. 小部件定义
  2. 小部件实例创建
  3. onUpdate() (时间间隔到期时)
  4. 对点击的响应(在主屏幕的小部件视图上)
  5. 小工具删除(从主屏幕)
  6. 卸载

我们现在将详细介绍这些阶段。

理解小部件定义阶段

小部件定义从 Android 清单文件中的小部件提供者类的定义开始。清单 21-1 显示了我们为清单文件中的本章 BDayWidgetProvider 设计的 AppWidgetProviderT4 的定义。

清单 21-1 。Android 清单文件中的小部件定义

<!-- filename: AndroidManifest.xml, project: ProAndroid5_ch21_TestWidgets.zip -->
<manifest..>
<application>
....
   <receiver android:name=".BDayWidgetProvider">
      <meta-data android:name="android.appwidget.provider"
             android:resource="@xml/bday_appwidget_provider" />
      <intent-filter>
           <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
      </intent-filter>
   </receiver>
   ...
   <activity>
      .....
   </activity>
</application>
</manifest>

这个定义表明有一个名为 BDayWidgetProvider 的广播接收器 Java 类,它接收应用小部件广播更新消息。清单 21-1 中的 widget 类定义还指向一个 XML 文件@ XML/bday _ app widget _ provider,它是/RES/XML/bday _ app widget _ provider . XML。这个 XML 文件在清单 21-2 中。这个小部件定义文件有许多关于这个小部件的东西,比如它的布局资源文件、更新频率等等。

清单 21-2 。小部件提供者信息 XML 文件中的小部件视图定义

<!-- /res/xml/bday_appwidget_provider.xml(ProAndroid5_ch21_TestWidgets.zip) -->
<appwidget-provider xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:minWidth="150dp"
    android:minHeight="120dp"
    android:updatePeriodMillis="43200000"
    android:initialLayout="@layout/bday_widget"
    android:configure="com.androidbook.BDayWidget.ConfigureBDayWidgetActivity"
    android:resizeMode="horizontal|vertical"
    android:previewImage="@drawable/some_preview_image_icon"
    >
</appwidget-provider>

这个 XML 文件称为应用小部件提供者信息文件。在内部,这被翻译成 Java 类 AppWidgetProviderInfo。该文件确定布局的宽度和高度分别为 150dp 和 120dp 。该定义文件还指示更新频率为 12 小时转换为毫秒。小部件定义还通过 initialLayout 属性指向一个布局文件。这个布局文件(见未来的清单 21-6 )产生了如图图 21-2 所示的部件外观。

了解调整大小模式属性

从 SDK 3.1 开始,用户能够调整放置在他们的一个图像上的小部件的大小。当用户长按小部件时,他们会看到调整大小手柄,然后可以使用这些手柄来调整大小。这种调整大小可以是水平的、垂直的或无。您可以组合水平和垂直来在两个维度上调整小部件的大小,如清单 21-2 所示。然而,为了利用这一点,您的小部件控件应该以这样一种方式进行布局,即它们可以使用它们的布局参数来扩展和收缩。没有回调来告诉你你的部件有多大。

了解预览图像属性

清单 21-2 中的预览图像属性指出了什么图像或图标用于显示可用部件列表中的部件。如果省略它,默认行为是显示应用包的主图标,这在清单文件中有所指示。

了解小部件布局:initialLayout 属性

小部件视图的布局被限制为只包含某些类型的视图元素。小部件布局中允许的视图通过一个名为 RemoteViews 的接口公开,并且只有某些视图可以组成这个布局。清单 21-3 中显示了一些允许的视图元素。注意它们的子类不被支持——只支持那些包含在清单 21-3 中的子类。

清单 21-3 。 RemoteViews 中允许的视图控件

FrameLayout
LinearLayout
RelativeLayout
GridLayout
AnalogClock
Button
Chronometer
ImageButton
ImageView
ProgressBar
TextView
ViewFlipper
ListView
GridView
StackView
AdapterViewFlipper

这个列表可能会随着每个版本的发布而增加。限制远程视图中允许的内容的主要原因是,这些视图与实际控制它们的进程是断开的。这些窗口小部件视图由 Home 应用这样的应用托管。这些视图的控制器是由计时器调用的后台进程。因此,这些视图被称为远程视图。有一个相应的 Java 类叫做 RemoteViews ,允许访问这些视图。换句话说,程序员不能直接访问这些视图来调用它们的方法。您只能通过远程视图访问这些视图(就像看门人一样)。

当我们在下一个主要部分探索这个例子时,我们将会涉及到一个 RemoteViews 类的相关方法。现在,记住在小部件布局文件中只允许使用清单 21-3 中有限的一组视图。

了解配置属性

小部件定义(清单 21-2 )使用配置属性来指定用户创建小部件实例时需要调用的配置活动。清单 21-2 中的指定的配置活动是 ConfigureBDayWidgetActivity。这个活动(图 21-3 )和任何其他 Android 活动一样。此活动的表单字段用于收集小部件实例所需的信息。

了解小部件实例创建阶段

当用户选择一个小部件来创建一个小部件实例时,Android 调用配置活动(图 21-3 ),如果它是在小部件的配置 XML 文件中定义的。如果未定义此配置活动,则跳过此阶段,小部件直接显示在主页上。调用此配置活动时,它会执行以下操作:

  1. 从启动配置活动的调用意图接收小部件实例 ID。
  2. 通过表单字段提示用户收集特定于小部件实例的信息。
  3. 持久化小部件实例信息,以便对 AppWidgetProvider 的 onUpdate 方法的后续调用可以访问这些信息。
  4. 通过检索小部件视图布局准备第一次显示小部件视图,并用它创建一个 RemoteViews 对象。
  5. 调用 RemoteViews 对象上的方法来设置单个视图对象的值,比如文本和图像。
  6. 还使用 RemoteViews 对象来注册小部件的任何子视图上的任何 onClick 事件。
  7. 告诉 AppWidgetManager 使用小部件的实例 ID 在主屏幕上绘制 RemoteViews 。
  8. 返回小部件 ID,并关闭。

注意,在这种情况下,小部件的第一次填充是由配置活动完成的,而不是由 AppWidgetProvider 的 onUpdate() 方法完成的。

注意配置活动是可选的。如果没有指定配置活动,调用将直接转到 AppWidgetProvider 的 onUpdate() 方法。由 onUpdate() 来更新视图。

Android 将为用户创建的每个小部件实例执行这个过程。除了调用配置活动,Android 还调用 AppWidgetProvider 的 onEnabled 回调。让我们通过查看我们的 BDayWidgetProvider 的外壳来简要考虑一下 AppWidgetProvider 类上的回调(参见清单 21-4 )。我们将在后面的清单 21-10 中检查这个文件的完整清单。

清单 21-4 。小部件提供者外壳

// filename: BDayWidgetProvider.java(ProAndroid5_ch21_TestWidgets.zip)
public class BDayWidgetProvider extends AppWidgetProvider {
    public void onUpdate(Context context, AppWidgetManager appWidgetManager,
                        int[] appWidgetIds){}
    public void onDeleted(Context context, int[] appWidgetIds){}
    public void onEnabled(Context context){}
    public void onDisabled(Context context) {}
}

one enabled()回调方法表示主屏幕上至少有一个小部件实例正在运行。这意味着用户必须至少将小部件放到主页上一次。在这个调用中,您将需要为这个广播接收器组件启用接收消息(您将在清单 21-10 中看到这一点)。SDK 基类 AppWidgetProvider 具有启用或禁用接收广播消息的功能。

当用户将小部件实例视图拖到垃圾桶时,调用 onDeleted() 回调方法。在这里,您需要删除为该小部件实例保存的任何持久值。

从主屏幕上移除最后一个小部件实例后,调用 onDisabled() 回调方法。当用户将小部件的最后一个实例拖到垃圾箱时,就会发生这种情况。您应该使用这个方法来取消对接收任何针对这个组件的广播消息的兴趣(您将在清单 21-9 中看到这个)。

每当清单 21-2 中的指定的定时器到期时,就会调用 onUpdate() 回调方法。如果没有配置活动,在第一次创建小部件实例时也会调用这个方法。如果存在配置活动,则在创建小部件实例时不会调用此方法。当计时器以指示的频率到期时,将随后调用此方法。

了解 onUpdate 阶段

一旦小部件实例出现在主屏幕上,下一个重要事件就是计时器到期。Android 将调用 onUpdate() 来响应那个定时器。因为 onUpdate() 是通过广播接收器调用的,所以相应的 Java 进程将被加载并保持活动状态,直到该调用结束。一旦调用返回,该进程将准备好被删除。

一旦有了更新 onUpdate() 方法中的小部件所需的数据,就可以调用 AppWidgetManager 来绘制远程视图。这表明 AppWidgetProvider 类是无状态的,甚至可能无法在调用之间维护静态变量。这是因为包含这个广播接收器类的 Java 进程可能会在两次调用之间被关闭和重建,从而导致静态变量的重新初始化。

因此,如果需要的话,您需要想出一个记住状态的方案。您可以将 widget 实例的状态保存在持久性存储中,如文件、共享首选项或 SQLite 数据库。在本章的例子中,我们使用共享参数作为持久性 API。

注意为了省电,谷歌建议更新的持续时间超过一个小时,这样设备就不会太频繁地被唤醒。从 2.0 API 开始,更新超时限制为 30 分钟或更长时间。

对于更短的持续时间,比如只有几秒钟,您需要使用 AlarmManager 类中的工具自己调用这个 onUpdate() 方法。当您使用 AlarmManager 时,您还可以选择不调用 onUpdate() ,而是在警报回调中执行 onUpdate() 的工作。参考第十七章中的使用警报管理器。

这是您在 onUpdate() 方法中通常需要做的事情:

  1. 确保配置器已经完成工作;否则,就返回。在 2.0 及更高版本中,这应该不是问题,因为预计持续时间会更长。否则,根据更新间隔(当它太小时),有可能在用户完成配置器中的小部件配置之前调用 onUpdate() 。
  2. 检索该小部件实例的持久化数据。
  3. 检索小部件视图布局,并用它创建一个 RemoteViews 对象。
  4. 调用 RemoteViews 上的方法来设置单个视图对象的值,比如文本和图像。
  5. 通过使用挂起的意图,在任何视图上注册任何 onClick 事件。
  6. 告诉 AppWidgetManager 使用实例 ID 绘制更新后的 RemoteViews 。

正如你所看到的,配置器最初做的和 onUpdate() 方法做的有很多重叠。您可能希望在两个地方之间重用该功能。

了解小部件视图鼠标点击事件回调

如上所述, onUpdate() 方法使小部件视图保持最新。小部件视图和该视图中的子元素可以为鼠标点击注册回调。通常, onUpdate() 方法使用一个挂起的意图来注册一个事件动作,比如鼠标点击。这个动作可以启动一个服务或者启动一个活动,比如打开一个浏览器。

如果需要,这个被调用的服务或活动可以使用小部件实例 ID 和 AppWidgetManager 与视图进行通信。因此,重要的是,挂起的意图带有小部件实例 ID。

删除小部件实例

小部件实例可能发生的另一个不同事件是它可能被删除。为此,用户必须长按主屏幕上的小工具。这将使垃圾桶显示在主屏幕上。然后,用户可以将小部件实例拖到垃圾桶,以便从屏幕上删除小部件实例。

这样做将调用小部件提供者的 onDelete() 方法。如果您保存了这个小部件实例的任何状态信息,您将需要在这个 onDelete 方法中删除该数据。

如果刚刚被删除的小部件实例是这种类型的最后一个小部件实例,Android 也会调用 onDisable() 。您将使用这个回调来清除为所有小部件实例存储的任何持久性属性,并从小部件 onUpdate() 广播中注销回调。

卸载 Widget 包

如果你打算卸载并安装你的的新版本,有必要清理小部件。包含这些小部件的 apk 文件。

建议您在尝试卸载软件包之前移除或删除所有小部件实例。按照“删除小部件实例”一节中的说明删除每个小部件实例,直到一个都没有。

然后,您可以卸载并安装新版本。如果您使用 Eclipse ADT 来开发小部件,这一点尤其重要,因为在开发期间,每次运行应用时,ADT 都会尝试这样做。因此,在两次运行之间,一定要删除小部件实例。

实现一个示例小部件应用

到目前为止,我们已经介绍了小部件背后的理论和方法。让我们创建一个样例小部件,它的行为被用作解释小部件架构的例子。我们将开发、测试和部署这个生日小部件。

每个生日小部件实例将显示一个名字、下一个生日的日期,以及从今天到生日还有多少天。它还会创建一个 onClick 区域,你可以在那里点击购买礼物。这个点击会打开一个浏览器,带你去 www.google.com 的。

完成的小部件的布局应该看起来像图 21-4 。

9781430246800_Fig21-04.jpg

图 21-4 。生日小工具的外观

这个小部件的实现由以下与小部件相关的文件组成。整个项目也可以从本章“参考资料”一节中提到的 URL 下载。

基本档案有

  • AndroidManifest.xml :定义 AppWidgetProvider 的地方(参见清单 21-5 )
  • RES/XML/bday _ app Widget _ provider . XML:Widget 尺寸和布局(见清单 21-2 )
  • RES/layout/bday _ widget . XML:小部件布局(参见清单 21-6 )
  • res/drawable/box1.xml :为部件布局的部分提供框(参见清单 21-7 )
  • src/.../bdaywidgetprovider . Java:AppWidgetProvider 类的实现(参见清单 21-10 )

这些文件实现了小部件配置活动:

  • src/.../configurebdaywidgetactivity . Java:配置活动(参见清单 21-8 )
  • Layout/edit _ bday _ widget . XML:取名字和生日的布局(见清单 21-9 )

这些文件使用首选项存储/检索小部件实例的状态:

  • src/.../iwidgetmodelsavecontract . Java:用于保存和检索小部件数据的契约(参见可下载项目中的)
  • src/.../APrefWidgetModel.java :抽象的基于首选项的小部件模型,将小部件数据保存在首选项中(参见可下载项目中的)
  • src/.../BDayWidgetModel.java :保存小部件视图数据的小部件模型(参见可下载项目中的)
  • src/.../Utils.java :一些工具类(参见可下载项目)

我们将浏览一些关键文件,并解释任何需要进一步考虑的其他概念。您可以从本章的可下载项目中获得其余的文件。

定义小部件提供者

对于生日小部件项目,清单文件在清单 21-5 中。它具有作为广播接收器的小部件提供者 BDayAppWidgetProvider 的声明,以及配置活动 ConfigureBDayWidgetActivity 的定义。注意小部件提供者定义是如何指向小部件定义 XML 文件@ XML/bday _ app widget _ provider。

清单 21-5 。 BDayWidget 示例应用的 Android 清单文件

<?xml version="1.0" encoding="utf-8"?>
<!-- file: AndroidManifest.xml(ProAndroid5_ch21_TestWidgets.zip) -->
<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
      package="com.androidbook.BDayWidget"
      android:versionCode="1"
      android:versionName="1.0.0">
<application android:icon="@drawable/icon"
             android:label="Birthday Widget">
<!--
**********************************************************************
*  Birthday Widget Provider Receiver
**********************************************************************
 -->
   <receiver android:name=".BDayWidgetProvider">
      <meta-data android:name="android.appwidget.provider"
             android:resource="@xml/bday_appwidget_provider"/>
      <intent-filter>
           <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
      </intent-filter>
   </receiver>
<!--
**********************************************************************
*  Birthday Provider Configuration activity
**********************************************************************
 -->
   <activity android:name=".ConfigureBDayWidgetActivity"
             android:label="Configure Birthday Widget">
       <intent-filter>
           <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
       </intent-filter>
   </activity>

    </application>
    <uses-sdk android:minSdkVersion="3"/>
</manifest>

由下面一行中的“生日小工具”标识的应用标签

<application android:icon="@drawable/icon" android:label="Birthday Widget">

显示在主页的小部件选择列表中(见图 21-2 )。您还可以在小部件定义 XML 文件(清单 21-2 )中指定一个当小部件被列出时显示的替代图标(也称为预览)。配置活动定义类似于任何其他普通活动,只是它需要声明自己能够响应 Android . app widget . action . app widget _ CONFIGURE 操作。

参考清单 21-2 中的小部件定义文件@ XML/bday _ app widget _ provider,查看小部件大小和布局文件的路径是如何指定的。这个布局文件就像 Android 中任何其他视图的布局文件一样。清单 21-6 显示了我们用来生成图 21-4 所示的小部件布局的布局文件。

清单 21-6 。 BDayWidget 的 Widget 视图布局定义

<?xml version="1.0" encoding="utf-8"?>
<!-- res/layout/bday_widget.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="fill_parent"  android:layout_height="fill_parent"
    android:background="@drawable/box1">
<TextView
    android:id="@+id/bdw_w_name"
    android:layout_width="fill_parent" android:layout_height="40sp"
    android:text="Anonymous"  android:background="@drawable/box1"
    android:gravity="center"  android:layout_weight="0"/>
<LinearLayout
    android:orientation="horizontal"
    android:layout_width="fill_parent" android:layout_height="fill_parent"
    android:layout_weight="1">
       <TextView
           android:id="@+id/bdw_w_days"
           android:layout_width="wrap_content" android:layout_height="fill_parent"
           android:gravity="center" android:layout_weight="50"
                   android:text="0" android:textSize="30sp" />
       <TextView
           android:id="@+id/bdw_w_button_buy"
           android:layout_width="wrap_content"  android:layout_height="fill_parent"
           android:layout_weight="50"  android:gravity="center"
           android:textSize="20sp"  android:text="Buy"
           android:background="#FF6633"/>
</LinearLayout>
<TextView
    android:id="@+id/bdw_w_date"
    android:layout_width="fill_parent"  android:layout_height="40sp"
    android:gravity="center" android:layout_weight="0"
    android:text="1/1/2000" android:background="@drawable/box1"/>
</LinearLayout>

一些控件还使用一个名为 box1.xml 的形状定义文件来定义边框。形状定义文件的代码如清单 21-7 所示。

清单 21-7 。边界框形状定义

<!-- res/drawable/box1.xml -->
<shape xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)">
    <stroke android:width="4dp" android:color="#888888"/>
    <padding android:left="2dp" android:top="2dp"
            android:right="2dp" android:bottom="2dp"/>
    <corners android:radius="4dp"/>
</shape>

实施小部件配置活动

以生日小部件为例,小部件职责的配置在 ConfigureBDayWidgetActivity 中实现。这个类的源代码在清单 21-8 中。

清单 21-8 。实施配置活动

// file: ConfigureBDayWidgetActivity.java(ProAndroid5_ch21_TestWidgets.zip)
public class ConfigureBDayWidgetActivity extends Activity
{
   private static String tag = "ConfigureBDayWidgetActivity";
   private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.edit_bday_widget);
        setupButton(); //setup the save button

        //Get the widget instanceid from the intent extra
        Intent intent = getIntent();
        Bundle extras = intent.getExtras();
        if (extras != null) {
            mAppWidgetId = extras.getInt(
                    AppWidgetManager.EXTRA_APPWIDGET_ID,
                    AppWidgetManager.INVALID_APPWIDGET_ID);
        }
    }
    private void setupButton(){
       Button b = (Button)this.findViewById(R.id.bdw_button_update_bday_widget);
       b.setOnClickListener(
             new Button.OnClickListener(){
                public void onClick(View v)  {
                   saveConfiguration(v);
                }
             });
    }
    //Read name and date.
    //Call updateAppWidgetLocal to save the values for this instance
    //in that method also send the view to the homepage.
    //Return the result of the configuration activity to the SDK
    //finish the activity.
    private void saveConfiguration(View v){
       String name = this.getName();
       String date = this.getDate();
       if (Utils.validateDate(date) == false){
          this.setDate("wrong date:" + date);
          return;
       }
       if (this.mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID){
          return;
       }
       updateAppWidgetLocal(name,date);
       Intent resultValue = new Intent();
       resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
       setResult(RESULT_OK, resultValue);
       finish();
    }
    private String getName(){
        EditText nameEdit =
            (EditText)this.findViewById(R.id.bdw_bday_name_id);
        String name = nameEdit.getText().toString();
        return name;
    }
    private String getDate(){
        EditText dateEdit = (EditText)this.findViewById(R.id.bdw_bday_date_id);
        String dateString = dateEdit.getText().toString();
        return dateString;
    }
    private void setDate(String errorDate){
        EditText dateEdit = (EditText)this.findViewById(R.id.bdw_bday_date_id);
        dateEdit.setText("error");
        dateEdit.requestFocus();
    }
    private void updateAppWidgetLocal(String name, String dob){
       //Create an object to hold the data: widgetid, name, and dob
       BDayWidgetModel m = new BDayWidgetModel(mAppWidgetId,name,dob);
       //Create the view and send it to the home screen
       updateAppWidget(this,AppWidgetManager.getInstance(this),m);
       //Use the data model object to save the id, name, and dob in prefs
       m.savePreferences(this);
    }
    //A key method where a lot of magic happens
    public static void updateAppWidget(Context context,
            AppWidgetManager appWidgetManager,
            BDayWidgetModel widgetModel)
    {
      //Construct a RemoteViews Object from the widget layout file
      RemoteViews views = new RemoteViews(context.getPackageName(),
                    R.layout.bday_widget);

      //Use the control ids in the layout to set values on them.
      //Notice that these methods are limited and available on the
      //on the RemoteViews object. In other words we are not using the
      //TextView directly to set these values.
      views.setTextViewText(R.id.bdw_w_name
         , widgetModel.getName() + ":" + widgetModel.iid);

      views.setTextViewText(R.id.bdw_w_date
            , widgetModel.getBday());

      //update the name
      views.setTextViewText(R.id.bdw_w_days,
                           Long.toString(widgetModel.howManyDays()));

      //Set intents to invoke other activities when widget is clicked on
      Intent defineIntent = new Intent(Intent.ACTION_VIEW,
              Uri.parse("[`www.google.com`](http://www.google.com)"));
      PendingIntent pendingIntent =
           PendingIntent.getActivity(context,
                    0 /* no requestCode */,
                    defineIntent,
                    0 /* no flags */);
       views.setOnClickPendingIntent(R.id.bdw_w_button_buy, pendingIntent);

      // Tell the widget manager to paint the remote view
      appWidgetManager.updateAppWidget(widgetModel.iid, views);
   }
}

在我们介绍这段代码做什么之前,这个小部件配置活动使用的布局在清单 21-9 中。这种布局很简单。你也可以在图 21-3 中直观地看到这一点。

清单 21-9 。配置活动的布局定义

<?xml version="1.0" encoding="utf-8"?>
<!-- res/layout/edit_bday_widget.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
   android:id="@+id/root_layout_id"  android:orientation="vertical"
   android:layout_width="fill_parent"  android:layout_height="fill_parent">
<TextView
    android:id="@+id/bdw_text1"  android:layout_width="fill_parent"
    android:layout_height="wrap_content" android:text="Name:" />
<EditText
    android:id="@+id/bdw_bday_name_id" android:layout_width="fill_parent"
    android:layout_height="wrap_content" android:text="Anonymous" />
<TextView
    android:id="@+id/bdw_text2" android:layout_width="fill_parent"
    android:layout_height="wrap_content" android:text="Birthday (9/1/2001):" />
<EditText
    android:id="@+id/bdw_bday_date_id" android:layout_width="fill_parent"
    android:layout_height="wrap_content"  android:text="ex: 10/1/2009" />
<Button
    android:id="@+id/bdw_button_update_bday_widget" android:layout_width="fill_parent"
    android:layout_height="wrap_content" android:text="update"/>
</LinearLayout>

回到清单 21-8 中的配置活动代码,它完成以下任务:

  • 从调用意图中读取小部件实例 ID
  • 使用表单字段收集姓名和出生日期
  • 通过加载 widget 布局文件获取 RemoteViews
  • 在远程视图上设置文本值
  • 通过 RemoteViews 注册待定意向
  • 调用 AppWidgetManager 将 RemoteViews 发送给小部件
  • 根据这个小部件实例 ID 在首选项中保存姓名和出生日期。这是通过类 BDayWidgetModel 完成的。我们很快会谈到这一点。
  • 最后返回一个结果。

注意静态函数 udpateAppWidget 只要知道 widget ID 就可以从任何地方调用。这意味着您可以从设备上的任何地方和任何进程(可视和非可视)更新小部件。

注意我们是如何将小部件 ID 传递回这个配置活动的调用者的。这就是 AppWidgetManager 知道小部件实例的配置活动已经完成的方式。

让我们来谈谈通过清单 21-8 中的 BDayWidgetModel 对象来保存和检索 widget 实例状态。 BDayWidgetModel 对象的作用是存储和检索三个值:小部件实例 ID(主键)、名称和出生日期。这个类使用 preferences API 来保存和读回这些值。或者,您可以使用任何持久性机制来满足这一需求。我们不包括这个类的源代码,因为它实现起来非常简单。在本章的可下载项目中,我们有这个类的一个更广泛的实现,其中我们编写了一个可重用的框架来存储首选项中任何 java 对象的值。我们已经充分记录了源代码,这样您就可以按原样使用它来满足其他需求,或者进一步调整它并使用反射来进一步简化。最终,您将拥有一个非常可扩展的模型框架。由于这不是本章的主要目标,我们在这里没有深入讨论这些细节。对于本章来说,重要的是保存和检索这三个值,即实例 ID、名称和 dob。您可以根据 BDayWidgetModel 上的名称作为指南。

实现小部件提供者

现在让我们通过检查小部件提供者类来看看我们将如何响应小部件的生命周期事件。清单 21-10 实现了小部件提供者类。

清单 21-10 。样例小部件提供程序的源代码:BDayWidgetProvider

// file: BDayWidgetProvider.java(ProAndroid5_ch21_TestWidgets.zip)
public class BDayWidgetProvider extends AppWidgetProvider  {
    private static final String tag = "BDayWidgetProvider";
    public void onUpdate(Context context, AppWidgetManager appWidgetManager,
                        int[] appWidgetIds)  {
        final int N = appWidgetIds.length;
        for (int i=0; i<N; i++)  {
            int appWidgetId = appWidgetIds[i];
            updateAppWidget(context, appWidgetManager, appWidgetId);
        }
    }
    public void onDeleted(Context context, int[] appWidgetIds) {
        final int N = appWidgetIds.length;
        for (int i=0; i<N; i++) {
               BDayWidgetModel bwm = BDayWidgetModel.retrieveModel(context, appWidgetIds[i]);
               bwm.removePrefs(context);
        }
    }
    public void onEnabled(Context context) {
        BDayWidgetModel.clearAllPreferences(context);
        PackageManager pm = context.getPackageManager();
        pm.setComponentEnabledSetting(
                new ComponentName("com.androidbook.BDayWidget",
                       ".BDayWidgetProvider"),
                PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
                PackageManager.DONT_KILL_APP);
    }

    public void onDisabled(Context context) {
        BDayWidgetModel.clearAllPreferences(context);
        PackageManager pm = context.getPackageManager();
        pm.setComponentEnabledSetting(
                new ComponentName("com.androidbook.BDayWidget",
                       ".BDayWidgetProvider"),
                PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
                PackageManager.DONT_KILL_APP);
    }
    private void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
                          int appWidgetId) {
       BDayWidgetModel bwm = BDayWidgetModel.retrieveModel(context, appWidgetId);
       if (bwm == null) {return;}
       ConfigureBDayWidgetActivity.updateAppWidget(context, appWidgetManager, bwm);
   }
}

在“小部件的生命周期”一节中,我们讨论了这些方法的职责。对于生日小部件,所有这些方法都利用 BDayWidgetModel 来检索与小部件实例相关联的数据,回调函数被调用。在 BDayWidgetModel 上的这些方法有 removePrefs() 、 retrievePrefs() 和 clearAllPreferences() 。

为该小部件类型的所有小部件实例调用更新回调方法。这个方法必须更新所有的小部件实例。小部件实例作为 id 数组传入。对于每个 id,on update()方法将定位相应的小部件实例模型,并调用配置活动使用的相同方法(参见清单 21-8 )来显示检索到的小部件模型。

在 onDeleted() 方法中,我们实例化了一个 BDayWidgetModel ,然后要求它将自己从首选项持久性存储中删除。

在 one enabled()方法中,因为它在第一个实例出现时只被调用一次,所以我们已经清除了小部件模型的所有持久性,这样我们就可以从头开始了。我们在 onDisabled() 方法中也做了同样的事情,这样就不存在小部件实例的内存。

在 one enabled()方法中,我们启用小部件提供者组件,以便它可以接收广播消息。在 onDisabled() 方法中,我们禁用了组件,这样它就不会寻找任何广播消息。

基于集合的小部件

从 SDK 3.0 开始,Android 已经扩展了小部件,以包括基于集合的小部件。我们在这本书的印刷本中没有空间。我们将把上一版中关于收集部件的章节放在我们的在线网站上以供下载。

资源

以下是本章所涵盖主题的有用参考:

摘要

在 Android 中,小部件经常和你的应用一起使用。本章涵盖了创建和配置小部件所需的基本要素。在线提供了关于列表小部件的补充章节。

二十二、触摸屏

许多 Android 设备都集成了触摸屏。当设备没有物理键盘时,大部分用户输入必须通过触摸屏。因此,您的应用通常需要能够处理来自用户的触摸输入。您很可能已经见过当用户需要输入文本时,屏幕上显示的虚拟键盘。我们在第十九章中使用了 touch 和地图应用。到目前为止,触摸屏界面的实现对您是隐藏的,但是现在我们将向您展示如何利用触摸屏。

本章由三个主要部分组成。第一部分将处理 MotionEvent 对象,这是 Android 如何告诉应用用户正在触摸触摸屏。我们还将介绍速度跟踪器。第二部分将处理多点触摸 ,用户可以在触摸屏上一次使用多个手指。最后,我们将包括一个关于手势的部分,这是一种特殊类型的功能,触摸序列可以被解释为命令。

理解运动事件

在这一节,我们将介绍 Android 如何告诉应用用户的触摸事件。现在,我们将只关注一次一个手指触摸屏幕(我们将在后面的章节中讨论多点触摸)。

在硬件层面,触摸屏由特殊材料制成,可以拾取压力并将其转换为屏幕坐标。关于触摸的信息被转换成数据,这些数据被传递给软件进行处理。

运动事件对象

当用户触摸 Android 设备的触摸屏时,会创建一个 MotionEvent 对象。运动事件包含关于触摸发生的时间和地点的信息,以及触摸事件的其他细节。运动事件对象被传递给应用中的一个适当的方法。这可能是一个视图对象的 onTouchEvent() 方法。记住视图类是 Android 中相当多类的父类,包括布局 s、按钮 s、列表 s、时钟 s 等等。这意味着我们可以使用触摸事件与所有这些不同类型的视图对象进行交互。当该方法被调用时,它可以检查 MotionEvent 对象来决定做什么。例如, GoogleMap 可以使用触摸事件来横向移动地图,以允许用户将地图平移到其他感兴趣的点。虚拟键盘对象可以接收触摸事件来激活虚拟键,以向用户界面(UI)的某个其他部分提供文本输入。

接收 MotionEvent 对象

运动事件对象是与用户触摸相关的一系列事件之一。该序列从用户第一次触摸触摸屏时开始,通过手指在触摸屏表面上的任何移动继续,并在手指从触摸屏抬起时结束。手指的初始触摸(一个动作 _ 下动作)、侧向移动(动作 _ 移动动作)、上事件(一个动作 _ 上动作)都创建运动事件对象。在您接收到最终的 ACTION_UP 事件 之前,随着手指在表面上移动,您可能会接收到相当多的 ACTION_MOVE 事件。每个 MotionEvent 对象包含关于正在执行什么动作、触摸发生在哪里、施加了多少压力、触摸有多大、动作何时发生以及初始 ACTION_DOWN 何时发生的信息。还有第四种可能的动作,就是动作 _ 取消。该动作用于指示触摸序列在没有实际做任何事情的情况下结束。最后,还有 ACTION_OUTSIDE ,它设置在一个特殊的情况下,触摸发生在我们的窗口之外,但我们仍然可以发现它。

还有另一种方法来接收触摸事件,那就是为一个视图对象上的触摸事件注册一个回调处理器。接收事件的类必须实现视图。OnTouchListener 接口,必须调用视图对象的 setOnTouchListener() 方法 来设置该视图的处理器。视图的实现类。OnTouchListener 必须实现 onTouch() 方法。而 onTouchEvent() 方法 只取一个运动事件对象作为参数, onTouch() 同时取一个视图和一个运动事件对象作为参数。这是因为 OnTouchListener 可以接收多个视图的 MotionEvent 对象。这将在我们的下一个示例应用中变得更加清晰。

如果一个 MotionEvent 处理器(通过 onTouchEvent() 或 onTouch() 方法)使用了该事件,而其他人不需要知道它,该方法应该返回 true 。这告诉 Android 事件不需要传递给任何其他视图。如果视图对象对该事件或任何与该触摸序列相关的未来事件不感兴趣,则返回假。基类视图的 onTouchEvent() 方法不做任何事情,返回 false 。视图的子类可能会也可能不会做同样的事情。例如,按钮对象会消耗一个触摸事件,因为触摸相当于点击,因此从 onTouchEvent() 方法返回 true 。当接收到一个 ACTION_DOWN 事件 时,按钮会改变颜色,表示正在被点击。按钮也希望接收 ACTION_UP 事件,以了解用户何时放开,从而可以启动单击按钮的逻辑。如果一个按钮对象从 onTouchEvent() 返回了 false ,那么当用户从触摸屏上抬起手指时,它将不再接收任何 MotionEvent 对象来告诉它。

当我们希望触摸事件对特定的视图对象做一些新的事情时,我们可以扩展该类,覆盖 onTouchEvent() 方法,并将我们的逻辑放在那里。我们也可以贯彻的观点。OnTouchListener 接口,并在视图对象上设置一个回调处理器。通过用 onTouch() 设置一个回调处理器, MotionEvent s 将在它们转到视图的 onTouchEvent() 方法之前首先被传递到那里。只有当 onTouch() 方法返回 false 时,我们的视图的 onTouchEvent() 方法才会被调用。让我们来看看我们的示例应用,在这里应该更容易看到这一点。

注意我们会在本章末尾给你一个 URL,你可以用它来下载本章的项目。这将允许您将这些项目直接导入到 IDE 中。

设置示例应用

清单 22-1 显示了一个布局文件的 XML。从这个布局开始创建一个新的 Android 项目。

清单 22-1 。TouchDemo1 的 XML 布局文件 ??

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

  <RelativeLayout  android:id="@+id/layout1"
    android:tag="trueLayoutTop"  android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_weight="1" >

    <com.androidbook.touch.demo1.TrueButton android:text="Returns True"
    android:id="@+id/trueBtn1"  android:tag="trueBtnTop"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

    <com.androidbook.touch.demo1.FalseButton android:text="Returns False"
    android:id="@+id/falseBtn1"  android:tag="falseBtnTop"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_below="@id/trueBtn1" />

  </RelativeLayout>
  <RelativeLayout  android:id="@+id/layout2"
    android:tag="falseLayoutBottom"  android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_weight="1"  android:background="#FF00FF" >

    <com.androidbook.touch.demo1.TrueButton android:text="Returns True"
    android:id="@+id/trueBtn2"  android:tag="trueBtnBottom"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

    <com.androidbook.touch.demo1.FalseButton android:text="Returns False"
    android:id="@+id/falseBtn2"  android:tag="falseBtnBottom"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_below="@id/trueBtn2" />

  </RelativeLayout>
</LinearLayout>

关于这种布局,有几点需要指出。我们在 UI 对象上加入了标签,当事件发生时,我们将能够在代码中引用这些标签。我们使用了定制对象(真按钮和假按钮)。你会在 Java 代码中看到这些是从按钮类扩展而来的类。因为这些是按钮,所以我们可以使用我们在其他按钮上使用的所有 XML 属性。图 22-1 显示了这个布局,清单 22-2 显示了我们的按钮 Java 代码。

9781430246800_Fig22-01.jpg

图 22-1 。我们的 TouchDemo1 应用的 UI

清单 22-2 。TouchDemo1 的按钮类 的 Java 代码

// This file is BooleanButton.java
public abstract class BooleanButton extends Button {
    protected boolean myValue() {
        return false;
    }

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

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        String myTag = this.getTag().toString();
        Log.v(myTag, "-----------------------------------");
        Log.v(myTag, MainActivity.describeEvent(this, event));
        Log.v(myTag, "super onTouchEvent() returns " +
                super.onTouchEvent(event));
        Log.v(myTag, "and I'm returning " + myValue());
        return(myValue());
    }
}

// This file is TrueButton.java
public class TrueButton extends BooleanButton {
    protected boolean myValue() {
        return true;
    }

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

// This file is FalseButton.java
public class FalseButton extends BooleanButton {

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

构建了 BooleanButton 类,以便我们可以重用 onTouchEvent() 方法,我们已经通过添加日志记录对其进行了定制。然后,我们创建了 TrueButton 和 FalseButton ,它们将对传递给它们的 MotionEvent s 做出不同的响应。当您查看主活动代码时,这将变得更加清晰,如清单 22-3 所示。

清单 22-3 。我们主要活动的 Java 代码

// This file is MainActivity.java
import android.view.MotionEvent;
import android.view.View.OnTouchListener;
public class MainActivity extends Activity implements OnTouchListener {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        RelativeLayout layout1 =
                (RelativeLayout) findViewById(R.id.layout1);
        layout1.setOnTouchListener(this);
        Button trueBtn1 = (Button)findViewById(R.id.trueBtn1);
        trueBtn1.setOnTouchListener(this);
        Button falseBtn1 = (Button)findViewById(R.id.falseBtn1);
        falseBtn1.setOnTouchListener(this);

        RelativeLayout layout2 =
                (RelativeLayout) findViewById(R.id.layout2);
        layout2.setOnTouchListener(this);
        Button trueBtn2 = (Button)findViewById(R.id.trueBtn2);
        trueBtn2.setOnTouchListener(this);
        Button falseBtn2 = (Button)findViewById(R.id.falseBtn2);
        falseBtn2.setOnTouchListener(this);
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        String myTag = v.getTag().toString();
        Log.v(myTag, "-----------------------------");
        Log.v(myTag, "Got view " + myTag + " in onTouch");
        Log.v(myTag, describeEvent(v, event));
        if( "true".equals(myTag.substring(0, 4))) {
        /*  Log.v(myTag, "*** calling my onTouchEvent() method ***");
            v.onTouchEvent(event);
            Log.v(myTag, "*** back from onTouchEvent() method ***"); */
            Log.v(myTag, "and I'm returning true");
            return true;
        }
        else {
            Log.v(myTag, "and I'm returning false");
            return false;
        }
    }

    protected static String describeEvent(View view, MotionEvent event) {
        StringBuilder result = new StringBuilder(300);
        result.append("Action: ").append(event.getAction()).append("\n");
        result.append("Location: ").append(event.getX()).append(" x ")
                 .append(event.getY()).append("\n");
        if(    event.getX() < 0 || event.getX() > view.getWidth() ||
               event.getY() < 0 || event.getY() > view.getHeight()) {
            result.append(">>> Touch has left the view <<<\n");
        }
        result.append("Edge flags: ").append(event.getEdgeFlags());
        result.append("\n");
        result.append("Pressure: ").append(event.getPressure());
        result.append("   ").append("Size: ").append(event.getSize());
        result.append("\n").append("Down time: ");
        result.append(event.getDownTime()).append("ms\n");
        result.append("Event time: ").append(event.getEventTime());
        result.append("ms").append("  Elapsed: ");
        result.append(event.getEventTime()-event.getDownTime());
        result.append(" ms\n");
        return result.toString();
    }
}

我们的主活动代码在按钮和布局上设置了回调,这样我们就可以处理 UI 中所有的触摸事件( MotionEvent 对象)。我们增加了许多日志记录,所以你将能够准确地告诉触摸事件发生时发生了什么。另一个好主意是将以下标记添加到您的清单文件中,这样谷歌 Play 商店将知道您的应用需要触摸屏才能工作:。例如,谷歌电视没有触摸屏,所以试图在那里运行这个应用没有意义。当您编译并运行这个应用时,您应该会看到一个类似于图 22-1 的屏幕。

运行示例应用

为了充分利用这个应用,你需要在你的 IDE (Eclipse 或 Android Studio)中打开 LogCat,在你触摸触摸屏的时候观察信息的飞逝。这在模拟器和真实设备上都有效。我们还建议您最大化 LogCat 窗口,这样您可以更容易地上下滚动来查看该应用生成的所有事件。要最大化窗口,只需双击 LogCat 选项卡。现在,转到应用 UI,触摸并释放最上面标有 Returns True 的按钮(如果您正在使用模拟器,请使用鼠标单击并释放按钮)。您应该看到 LogCat 中至少记录了两个事件。这些消息被标记为来自 trueBtnTop ,并从主活动中的 onTouch() 方法记录下来。关于 onTouch() 方法的代码,请参见 MainActivity.java。在查看 LogCat 输出时,可以看到哪些方法调用产生了这些值。例如,Action 之后显示的值来自于 getAction() 方法。清单 22-4 显示了您可能在示例应用的 LogCat 中看到的示例。

清单 22-4 。来自 TouchDemo1 的示例 LogCat 消息

trueBtnTop        -----------------------------
trueBtnTop        Got view trueBtnTop in onTouch
trueBtnTop        Action: 0
trueBtnTop        Location: 42.8374 x 25.293747
trueBtnTop        Edge flags: 0
trueBtnTop        Pressure: 0.05490196   Size: 0.2
trueBtnTop        Down time: 24959412ms
trueBtnTop        Event time: 24959412ms  Elapsed: 0 ms
trueBtnTop        and I'm returning true
trueBtnTop        -----------------------------
trueBtnTop        Got view trueBtnTop in onTouch
trueBtnTop        Action: 2
trueBtnTop        Location: 42.8374 x 25.293747
trueBtnTop        Edge flags: 0
trueBtnTop        Pressure: 0.05490196   Size: 0.2
trueBtnTop        Down time: 24959412ms
trueBtnTop        Event time: 24959530ms  Elapsed: 118 ms
trueBtnTop        and I'm returning true
trueBtnTop        -----------------------------
trueBtnTop        Got view trueBtnTop in onTouch
trueBtnTop        Action: 1
trueBtnTop        Location: 42.8374 x 25.293747
trueBtnTop        Edge flags: 0
trueBtnTop        Pressure: 0.05490196   Size: 0.2
trueBtnTop        Down time: 24959412ms
trueBtnTop        Event time: 24959567ms  Elapsed: 155 ms
trueBtnTop        and I'm returning true

了解运动事件内容

第一个事件的动作为 0,即动作 _ 下降。最后一个事件的 action 为 1,即 ACTION_ UP 。如果您使用真实设备,您可能会看到两个以上的事件。在 ACTION_DOWN 和 ACTION_UP 之间的任何事件最有可能有一个 ACTION 2,即 ACTION _MOVE。另一种可能是 3 的一个动作,也就是动作 _ 取消 ,或者 4,也就是动作 _ 在 之外。当在真实的触摸屏上使用真实的手指时,你不能总是在表面没有轻微移动的情况下触摸和释放,所以期待一些 ACTION_MOVE 事件。

请注意位置值。一个运动事件的位置有一个 x 和 y 分量,其中 x 代表从视图对象的左侧到被触摸点的距离,y 代表从视图对象的顶部到被触摸点的距离。

在模拟器中,压力可能是 1.0,大小可能是 0.0。对于真实的设备,压力代表手指按下的力度,大小代表触摸的力度。如果用小指尖轻轻触摸,压力和大小的值会很小。如果用拇指使劲按,压力和大小都会大一些。用拇指轻轻按压会产生一个较小的压力值,但会产生一个较大的尺寸值。文档说压力和大小的值将在 0 和 1 之间。然而,由于硬件的差异,在您的应用中很难使用任何绝对数字来决定压力和尺寸。当运动事件在您的应用中发生时,比较它们之间的压力和大小是很好的,但是如果您决定压力必须超过诸如 0.8 之类的值才能被认为是硬按压,那么您可能会遇到麻烦。在那个特定的设备上,你可能永远得不到高于 0.8 的值。你可能得不到高于 0.2 的值。

停机时间和事件时间值在模拟器和真实设备之间以相同的方式运行,唯一的区别是真实设备具有大得多的值。经过的时间是一样的。

边缘标志用于检测触摸何时到达物理屏幕的边缘。Android SDK 文档称,标志被设置为指示触摸已经与显示器的边缘相交(顶部、底部、左侧或右侧)。然而, getEdgeFlags() 方法可能总是返回零,这取决于它在什么设备或仿真器上使用。对于一些硬件来说,实际检测显示器边缘的触摸太困难了,所以 Android 应该将位置固定到边缘,并为您设置适当的边缘标志。这种情况并不总是发生,所以您不应该依赖于正确设置的边缘标志。 MotionEvent 类提供了一个 setEdgeFlags() 方法,这样你就可以自己设置标志了。

最后要注意的是,我们的 onTouch() 方法返回 true ,因为我们的 TrueButton 被编码为返回 true 。返回 true 告诉 Androidmotion event 对象已经被消费,没有理由再给别人。它还告诉 Android 继续从这个触摸序列向这个方法发送触摸事件。这就是为什么我们得到了 ACTION_UP 事件,以及真实设备情况下的 ACTION_MOVE 事件。

现在触摸屏幕顶部附近的退货错误按钮 。清单 22-5 显示了退货误触的示例 LogCat 输出。

清单 22-5 。触摸顶部返回假按钮的示例 LogCat

falseBtnTop        -----------------------------
falseBtnTop        Got view falseBtnTop in onTouch
falseBtnTop        Action: 0
falseBtnTop        Location: 61.309372 x 44.281494
falseBtnTop        Edge flags: 0
falseBtnTop        Pressure: 0.0627451   Size: 0.26666668
falseBtnTop        Down time: 28612178ms
falseBtnTop        Event time: 28612178ms  Elapsed: 0 ms
falseBtnTop        and I'm returning false
falseBtnTop        -----------------------------------
falseBtnTop        Action: 0
falseBtnTop        Location: 61.309372 x 44.281494
falseBtnTop        Edge flags: 0
falseBtnTop        Pressure: 0.0627451   Size: 0.26666668
falseBtnTop        Down time: 28612178ms
falseBtnTop        Event time: 28612178ms  Elapsed: 0 ms
falseBtnTop        super onTouchEvent() returns true
falseBtnTop        and I'm returning false
trueLayoutTop        -----------------------------
trueLayoutTop        Got view trueLayoutTop in onTouch
trueLayoutTop        Action: 0
trueLayoutTop        Location: 61.309372 x 116.281494
trueLayoutTop        Edge flags: 0
trueLayoutTop        Pressure: 0.0627451   Size: 0.26666668
trueLayoutTop        Down time: 28612178ms
trueLayoutTop        Event time: 28612178ms  Elapsed: 0 ms
trueLayoutTop        and I'm returning true
trueLayoutTop        -----------------------------
trueLayoutTop        Got view trueLayoutTop in onTouch
trueLayoutTop        Action: 2
trueLayoutTop        Location: 61.309372 x 111.90039
trueLayoutTop        Edge flags: 0
trueLayoutTop        Pressure: 0.0627451   Size: 0.26666668
trueLayoutTop        Down time: 28612178ms
trueLayoutTop        Event time: 28612217ms  Elapsed: 39 ms
trueLayoutTop        and I'm returning true
trueLayoutTop        -----------------------------
trueLayoutTop        Got view trueLayoutTop in onTouch
trueLayoutTop        Action: 1
trueLayoutTop        Location: 55.08958 x 115.30792
trueLayoutTop        Edge flags: 0
trueLayoutTop        Pressure: 0.0627451   Size: 0.26666668
trueLayoutTop        Down time: 28612178ms
trueLayoutTop        Event time: 28612361ms  Elapsed: 183 ms
trueLayoutTop        and I'm returning true

现在你看到了非常不同的行为,所以我们将解释发生了什么。Android 在一个 MotionEvent 对象中接收 ACTION_DOWN 事件,并将其传递给我们在 MainActivity 类中的 onTouch() 方法。我们的 onTouch() 方法在 LogCat 中记录信息,并返回 false 。这告诉 Android 我们的 onTouch() 方法没有使用事件,所以 Android 期待调用下一个方法,在我们的例子中是我们的 FalseButton 类的被覆盖的 onTouchEvent() 方法。因为 FalseButton 是 BooleanButton 类的扩展,请参考 BooleanButton.java 中的 onTouchEvent() 方法查看代码。在 onTouchEvent() 方法中,我们再次向 LogCat 写入信息,我们调用父类的 onTouchEvent() 方法,然后我们也返回 false 。请注意,LogCat 中的位置信息与之前完全相同。这应该是意料之中的,因为我们仍然在同一个视图对象中,即假按钮。我们看到我们的父类想要从 onTouchEvent() 返回 true ,我们可以看出为什么。如果您在 UI 中查看按钮,它应该与 Returns True 按钮的颜色不同。我们的退货错误按钮现在看起来像是被按了一半。也就是说,它看起来就像一个按钮被按下但没有释放时的样子。我们的自定义方法返回了假而不是真。因为我们再次告诉 Android 我们没有消费这个事件,通过返回 false ,Android 从不发送 ACTION_UP 事件给我们的按钮,所以我们的按钮不知道手指曾经离开触摸屏。因此,我们的按钮仍然处于按下状态。如果我们像父母希望的那样返回了 true ,我们最终会收到 ACTION_UP 事件,因此我们可以将颜色改回正常的按钮颜色。概括一下,每次我们从一个 UI 对象为接收到的 MotionEvent 对象返回 false 时,Android 停止向那个 UI 对象发送 MotionEvent 对象,Android 继续寻找另一个 UI 对象来消费我们的 MotionEvent 对象。

您可能已经意识到,当我们触摸 Returns True 按钮时,按钮的颜色没有变化。为什么会这样?嗯,我们的 onTouch() 方法是在任何实际的按钮方法被调用之前被调用的,并且 onTouch() 返回 true ,所以 Android 从不费心调用 Returns True 按钮的 onTouchEvent() 方法。如果添加一个 v.onTouchEvent(事件);行到 onTouch() 方法就在返回 true 之前,你会看到按钮改变颜色。您还会在 LogCat 中看到更多的日志行,因为我们的 onTouchEvent() 方法也在向 LogCat 写入信息。

让我们继续检查 LogCat 的输出。现在 Android 已经两次尝试为 ACTION_DOWN 事件寻找消费者,但都失败了,它转到应用中可能接收事件的下一个视图,在我们的例子中是按钮下面的布局。我们调用了我们的顶部布局 trueLayoutTop ,我们可以看到它接收了 ACTION_DOWN 事件。

注意,我们的 onTouch() 方法再次被调用,尽管现在是在布局视图中而不是在按钮视图中。传递给 onTouch() 用于 trueLayoutTop 的 MotionEvent 对象的所有内容都与之前相同,包括时间,除了位置的 y 坐标。按钮的 y 坐标从 44.281494 更改为布局的 116.281494。这是有意义的,因为 Returns False 按钮不在布局的左上角,而是在 Returns True 按钮的下面。因此,触摸相对于布局的 y 坐标大于相同触摸相对于按钮的 y 坐标;触摸距离布局的上边缘比距离按钮的上边缘更远。因为 trueLayoutTop 的 onTouch() 返回 true ,Android 将其余的触摸事件发送到布局,我们看到的日志记录对应于 ACTION _MOVE 和 ACTION_UP 事件。继续操作,再次触摸顶部的 Returns False 按钮,您会注意到出现了一组相同的日志记录。即 false bttop 调用 onTouch() ,其余事件 trueLayoutTop 调用 false bttop 调用 onTouchEvent() 。Android 每次只停止向按钮发送一个触摸序列的事件。对于一个新的触摸事件序列,Android 将发送到按钮,除非它从被调用的方法获得另一个返回 false ,在我们的示例应用中仍然是这样。

现在用手指触摸顶部布局,但不要触摸任何按钮,然后拖动手指一点,并将其抬离触摸屏(如果您使用的是仿真器,只需使用鼠标做出类似的动作)。请注意 LogCat 中的日志消息流,其中第一条记录有一个动作 ACTION_DOWN ,然后许多 ACTION_MOVE 事件之后是一个 ACTION_UP 事件。

现在,触摸顶部的 Returns True 按钮,在从按钮上抬起手指之前,在屏幕上拖动手指,然后抬起。清单 22-6 显示了 LogCat 中的一些新信息。

清单 22-6 。LogCat 记录 显示我们视野之外的触摸

[ ... log messages of an ACTION_DOWN event followed by some ACTION_MOVE events ... ]

trueBtnTop        Got view trueBtnTop in onTouch
trueBtnTop        Action: 2
trueBtnTop        Location: 150.41768 x 22.628128
trueBtnTop        >>> Touch has left the view <<<
trueBtnTop        Edge flags: 0
trueBtnTop        Pressure: 0.047058824   Size: 0.13333334
trueBtnTop        Down time: 31690859ms
trueBtnTop        Event time: 31691344ms  Elapsed: 485 ms
trueBtnTop        and I'm returning true

[ ... more ACTION_MOVE events logged ... ]

trueBtnTop        Got view trueBtnTop in onTouch
trueBtnTop        Action: 1
trueBtnTop        Location: 291.5864 x 223.43854
trueBtnTop        >>> Touch has left the view <<<
trueBtnTop        Edge flags: 0
trueBtnTop        Pressure: 0.047058824   Size: 0.13333334
trueBtnTop        Down time: 31690859ms
trueBtnTop        Event time: 31692493ms  Elapsed: 1634 ms
trueBtnTop        and I'm returning true

即使在您的手指离开按钮后,我们仍会收到与按钮相关的触摸事件通知。清单 22-6 中的第一条记录显示了一条我们不再点击按钮的事件记录。在这种情况下,触摸事件的 x 坐标位于按钮对象边缘的右侧。然而,我们一直被 MotionEvent 对象调用,直到我们得到一个 ACTION_UP 事件,因为我们继续从 onTouch() 方法返回 true 。即使当你最终将手指从触摸屏上移开,即使你的手指没有放在按钮上,我们的 onTouch() 方法仍然被调用来给我们 ACTION_UP 事件,因为我们一直返回 true 。这是在处理 MotionEvent s 时要记住的事情。当手指离开视图时,我们可以决定取消可能已经执行的任何操作,并从 onTouch() 方法返回 false ,这样我们就不会得到进一步事件的通知。或者我们可以选择继续接收事件(通过从 onTouch() 方法返回 true ,并且只有当手指在抬起之前返回到我们的视图时才执行逻辑。

当我们从 onTouch() 返回 true 时,事件的触摸序列与顶部的返回 True 按钮相关联。这告诉 Android,它可以停止寻找一个对象来接收运动事件对象,而只是将这个触摸序列的所有未来运动事件对象发送给我们。即使我们在拖动手指时遇到了另一个视图,我们仍然被绑定到该序列的原始视图。

练习示例应用的下半部分

让我们看看应用的下半部分会发生什么。继续,触摸下半部分的返回真按钮。我们看到顶部的 Returns True 按钮发生了同样的事情。因为 onTouch() 返回 true ,Android 向我们发送触摸序列中的其余事件,直到手指从触摸屏上抬起。现在,触摸底部返回错误按钮。再次, onTouch() 方法和 onTouchEvent() 方法返回 false (都与 falseBtnBottom 视图对象相关联)。但这次,下一个接收 MotionEvent 对象的视图是 falseLayoutBottom 对象,它也返回 false 。现在,我们结束了。

因为 onTouchEvent() 方法调用了 super 的 onTouchEvent() 方法,所以按钮已经改变了颜色,表示它已经被按下了一半。同样,按钮将保持这种状态,因为我们从未在这个触摸序列中获得 ACTION_UP 事件,因为我们的方法总是返回 false 。不像以前,连版面都对这个活动不感兴趣。如果您触摸底部的 Returns False 按钮并按住它,然后在显示屏上拖动手指,您将不会在 LogCat 中看到任何额外的记录,因为不再有 MotionEvent 对象发送给我们。我们返回了 false ,所以 Android 不会再用这个触摸序列的任何事件来打扰我们。同样,如果我们开始一个新的触摸序列,我们可以看到新的 LogCat 记录出现。如果您在底部布局中而不是在按钮上启动触摸序列,您将在 LogCat 中看到一个针对 falseLayoutBottom 的事件,该事件返回 false ,然后再无其他事件(直到您启动新的触摸序列)。

到目前为止,我们已经使用按钮从触摸屏上向您展示了 MotionEvent 事件的效果。值得指出的是,通常情况下,您会使用 onClick() 方法在按钮上实现逻辑。我们在这个示例应用中使用了按钮,因为它们易于创建,并且是视图的子类,因此可以像任何其他视图一样接收触摸事件。记住,这些技术适用于应用中的任何视图对象,无论是标准的还是定制的视图类。

回收运动事件

你可能已经注意到了 Android 参考文档中的 MotionEvent 类的 recycle() 方法 。你很想回收在 onTouch() 或 onTouchEvent() 中收到的 MotionEvent s,但是不要这样做。如果你的回调方法没有使用 MotionEvent 对象,并且你返回了 false ,那么 MotionEvent 对象可能会被传递给其他方法或视图或我们的活动,所以你还不想让 Android 回收它。即使你消费了事件并返回了 true ,事件对象也不属于你,所以你不应该回收它。

如果你看一下 MotionEvent 文档,你会看到一个叫做获取() 的方法的一些变体。这要么是创建一个运动事件的副本,要么是一个全新的运动事件。你的副本,或者说你全新的事件对象,是你用完后应该回收的事件对象。比如你要挂一个通过回调传递给你的事件对象,你要用获取()做一个副本,因为一旦你从回调返回,那个事件对象就会被 Android 回收,继续使用可能会得到奇怪的结果。当您使用完您的副本时,您对它调用 recycle() 。

使用速度跟踪器

Android 提供了一个类来帮助处理触摸屏序列,这个类就是 velocity tracker。当手指在触摸屏上移动时,知道它在表面上移动的速度可能会很好。例如,如果用户在屏幕上拖动一个对象,然后放开,应用可能希望相应地显示该对象在屏幕上飞行。Android 提供了 VelocityTracker 来帮助解决相关的数学问题。

要使用 VelocityTracker ,首先要通过调用静态方法 VelocityTracker.obtain() 获得一个 VelocityTracker 的实例。然后,您可以使用 add movement(motion event ev)方法向其添加 MotionEvent 对象。您可以在接收 MotionEvent 对象的处理器中调用该方法,从一个处理器方法如 onTouch() 或从一个视图的 onTouchEvent() 调用该方法。 VelocityTracker 使用 MotionEvent 对象来判断用户的触摸顺序。一旦 VelocityTracker 中至少有两个 MotionEvent 对象,我们可以使用其他方法来找出发生了什么。

两个 VelocityTracker 方法——getx velocity()和 getYVelocity()——分别返回手指在 x 和 y 方向的相应速度。这两种方法返回的值将表示每个时间段的像素。这可以是每毫秒或每秒的像素,或者任何你想要的东西。为了告诉 VelocityTracker 使用什么时间段,在调用这两个 getter 方法之前,需要调用 VelocityTracker 的 computeCurrentVelocity(int units)方法。单位的值代表测量速度的时间周期为多少毫秒。如果你想要每毫秒像素,使用一个单位值为 1;如果你想要每秒像素,使用单位值 1000。如果速度向右(对于 x)或向下(对于 y),那么由 getXVelocity() 和 getYVelocity() 方法返回的值将是正的。如果速度向左(对于 x)或向上(对于 y),返回值将是负的。

当你完成了用获取()方法得到的 VelocityTracker 对象后,调用 VelocityTracker 对象的 recycle() 方法。清单 22-7 显示了一个活动的示例 onTouchEvent() 处理器。原来,一个活动有一个 onTouchEvent() 回调,每当没有视图处理触摸事件时就会调用这个回调。因为我们使用的是普通的空布局,所以没有视图消耗我们的触摸事件。

清单 22-7 。使用 VelocityTracker 的示例活动

import android.view.MotionEvent;
import android.view.VelocityTracker;

public class MainActivity extends Activity {
    private static final String TAG = "VelocityTracker";

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }

    private VelocityTracker vTracker = null;

    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();
        switch(action) {
            case MotionEvent.ACTION_DOWN:
                if(vTracker == null) {
                    vTracker = VelocityTracker.obtain();
                }
                else {
                    vTracker.clear();
                }
                vTracker.addMovement(event);
                break;
            case MotionEvent.ACTION_MOVE:
                vTracker.addMovement(event);
                vTracker.computeCurrentVelocity(1000);
                Log.v(TAG, "X velocity is " + vTracker.getXVelocity() +
                       " pixels per second");
                Log.v(TAG, "Y velocity is " + vTracker.getYVelocity() +
                       " pixels per second");
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                Log.v(TAG, "Final X velocity is " + vTracker.getXVelocity() +
                       " pixels per second");
                Log.v(TAG, "Final Y velocity is " + vTracker.getYVelocity() +
                       " pixels per second");
                vTracker.recycle();
                vTracker = null;
                break;
        }
        return true;
    }
}

显然,当你只给一个速度跟踪器(ACTION _ DOWN 事件)添加了一个运动事件时,速度不能被计算为零以外的任何值。但是我们需要添加起点,以便随后的 ACTION_MOVE 事件可以计算速度。

就性能而言,VelocityTracker 有点昂贵,所以要谨慎使用。此外,确保你用完后尽快回收。安卓系统中可以有多个 VelocityTracker 在使用,但它们会占用大量内存,所以如果你不打算继续使用它,就把你的还给我。在清单 22-7 中,如果我们开始一个新的触摸序列(也就是说,如果我们得到一个 ACTION_DOWN 事件并且我们的 VelocityTracker 对象已经存在),我们也使用 clear() 方法,而不是回收这个对象并获得一个新的。

多点触摸

现在,您已经看到了单点触摸的实际操作,让我们继续多点触摸。自从 2006 年的 TED 大会上 Jeff Han 演示了用于计算机用户界面的多点触摸表面以来,多点触摸就引起了人们的极大兴趣。在屏幕上使用多个手指为操作屏幕上的内容提供了很多可能性。例如,将两个手指放在一幅图像上并分开,可以放大图像。通过将多个手指放在图像上并顺时针旋转,可以旋转屏幕上的图像。例如,这些是谷歌地图中的标准触摸操作。

但是,如果你仔细想想,这并没有什么魔力。如果屏幕硬件可以检测到在屏幕上开始的多点触摸,在这些触摸在屏幕表面上移动时通知您的应用,并在这些触摸离开屏幕时通知您,您的应用就可以知道用户试图对这些触摸做什么。虽然不是魔术,但也不容易。在这一部分,我们将帮助您理解多点触控。

多点触摸的基础

多点触摸的基本原理与单点触摸完全相同。 MotionEvent 对象被创建用于触摸,这些 MotionEvent 对象像以前一样被传递给你的方法。您的代码可以读取关于触摸的数据,并决定做什么。基本上,运动事件的方法是相同的;也就是我们调用 getAction() 、get down()、 getX() 等等。然而,当不止一个手指触摸屏幕时, MotionEvent 对象必须包括来自所有手指的信息,但有一些注意事项。来自 getAction() 的动作值是针对一个手指,而不是所有手指。“按下时间”值是指第一个手指按下时的时间,只要至少有一个手指按下,它就会测量时间。位置值 getX() 和 getY() ,以及 getPressure() 和 getSize() ,可以为手指取一个实参;因此,您需要使用指针索引值来请求您感兴趣的手指的信息。我们之前使用的一些方法调用没有使用任何参数来指定一个指针(例如, getX() , getY() ),那么如果我们使用这些方法,这些值将用于哪个指针呢?你可以弄清楚,但这需要一些工作。因此,如果你不总是考虑多个手指,你可能会得到一些奇怪的结果。让我们深入研究一下,看看该怎么办。

多点触控需要了解的第一个 MotionEvent 方法是 getPointerCount() 。这告诉你有多少手指出现在 MotionEvent 对象中,但不一定告诉你有多少手指实际上在触摸屏幕;这取决于硬件和 Android 在硬件上的实现。您可能会发现,在某些设备上, getPointerCount() 不会报告所有正在触摸的手指,只是一些手指。但是让我们继续努力。一旦在 MotionEvent 对象中报告了多个手指,就需要开始处理指针索引和指针 id。

MotionEvent 对象包含从索引 0 开始直到该对象中报告的手指数量的指针信息。指针索引总是从 0 开始;如果报告三个指针,指针索引将为 0、1 和 2。对诸如 getX() 之类的方法的调用必须包含您想要了解的手指的指针索引。指针 id 是表示哪个手指正在被跟踪的整数值。第一个手指按下时,指针 id 从 0 开始,但一旦手指在屏幕上出现和消失,指针 id 并不总是从 0 开始。把一个指针 ID 想象成被 Android 追踪的手指的名字。例如,想象两个手指的一对触摸序列,从手指 1 向下开始,然后是手指 2 向下、手指 1 向上和手指 2 向上。第一个手指按下将获得指针 ID 0。第二个手指向下将得到指针 ID 1。一旦第一个手指向上,第二个手指仍将是指针 ID 1。此时,第二手指的指针索引变为 0,因为指针索引总是从 0 开始。在这个例子中,第二个手指(指针 ID 1)在第一次触下时开始为指针索引 1,然后在第一个手指离开屏幕时转变为指针索引 0。即使第二手指是屏幕上唯一的手指,它仍然是指针 ID 1。您的应用将使用指针 id 将与特定手指相关的事件链接在一起,即使涉及到其他手指。让我们看一个例子。

清单 22-8 展示了我们新的 XML 布局和多点触摸应用的 Java 代码。这就是名为 MultiTouchDemo1 的应用。图 22-2 显示了它应该是什么样子。

清单 22-8 。多点触摸演示的 XML 布局 和 Java

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/layout/main.xml -->
<RelativeLayout  xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:id="@+id/layout1"
    android:tag="trueLayout"  android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_weight="1"
    >

    <TextView android:text="Touch fingers on the screen and look at LogCat"
    android:id="@+id/message"
    android:tag="trueText"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignParentBottom="true" />

</RelativeLayout>

// This file is MainActivity.java
import android.view.MotionEvent;
import android.view.View.OnTouchListener;

public class MainActivity extends Activity implements OnTouchListener {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        RelativeLayout layout1 =
                (RelativeLayout) findViewById(R.id.layout1);
        layout1.setOnTouchListener(this);
    }

    public boolean onTouch(View v, MotionEvent event) {
        String myTag = v.getTag().toString();
        Log.v(myTag, "-----------------------------");
        Log.v(myTag, "Got view " + myTag + " in onTouch");
        Log.v(myTag, describeEvent(event));
        logAction(event);
        if( "true".equals(myTag.substring(0, 4))) {
            return true;
        }
        else {
            return false;
        }
    }

    protected static String describeEvent(MotionEvent event) {
        StringBuilder result = new StringBuilder(500);
        result.append("Action: ").append(event.getAction()).append("\n");
        int numPointers = event.getPointerCount();
        result.append("Number of pointers: ");
        result.append(numPointers).append("\n");
        int ptrIdx = 0;
        while (ptrIdx < numPointers) {
            int ptrId = event.getPointerId(ptrIdx);
            result.append("Pointer Index: ").append(ptrIdx);
            result.append(", Pointer Id: ").append(ptrId).append("\n");
            result.append("   Location: ").append(event.getX(ptrIdx));
            result.append(" x ").append(event.getY(ptrIdx)).append("\n");
            result.append("   Pressure: ");
            result.append(event.getPressure(ptrIdx));
            result.append("   Size: ").append(event.getSize(ptrIdx));
            result.append("\n");

            ptrIdx++;
        }
        result.append("Down time: ").append(event.getDownTime());
        result.append("ms\n").append("Event time: ");
        result.append(event.getEventTime()).append("ms");
        result.append("  Elapsed: ");
        result.append(event.getEventTime()-event.getDownTime());
        result.append(" ms\n");
        return result.toString();
    }

    private void logAction(MotionEvent event) {
        int action = event.getActionMasked();
        int ptrIndex = event.getActionIndex();
        int ptrId = event.getPointerId(ptrIndex);

        if(action == 5 || action == 6)
            action = action - 5;

        Log.v("Action", "Pointer index: " + ptrIndex);
        Log.v("Action", "Pointer Id: " + ptrId);
        Log.v("Action", "True action value: " + action);
    }
}

9781430246800_Fig22-02.jpg

图 22-2 。我们的多点触摸演示应用

如果你只有模拟器,这个应用仍然可以工作,但是你不能在屏幕上同时显示多个手指。您将看到类似于我们在前面的应用中看到的输出。清单 22-9 显示了我们之前描述的触摸序列的示例 LogCat 消息 。也就是第一个手指按在屏幕上,然后第二个手指按,第一个手指离开屏幕,第二个手指离开屏幕。

清单 22-9 。多点触摸应用的示例 LogCat 输出

trueLayout       -----------------------------
trueLayout       Got view trueLayout in onTouch
trueLayout       Action: 0
trueLayout       Number of pointers: 1
trueLayout       Pointer Index: 0, Pointer Id: 0
trueLayout          Location: 114.88211 x 499.77502
trueLayout          Pressure: 0.047058824   Size: 0.13333334
trueLayout       Down time: 33733650ms
trueLayout       Event time: 33733650ms  Elapsed: 0 ms
Action           Pointer index: 0
Action           Pointer Id: 0
Action           True Action value: 0
trueLayout       -----------------------------
trueLayout       Got view trueLayout in onTouch
trueLayout       Action: 2
trueLayout       Number of pointers: 1
trueLayout       Pointer Index: 0, Pointer Id: 0
trueLayout          Location: 114.88211 x 499.77502
trueLayout          Pressure: 0.05882353   Size: 0.13333334
trueLayout       Down time: 33733650ms
trueLayout       Event time: 33733740ms  Elapsed: 90 ms
Action           Pointer index: 0
Action           Pointer Id: 0
Action           True Action value: 2
trueLayout       -----------------------------
trueLayout       Got view trueLayout in onTouch
trueLayout       Action: 261
trueLayout       Number of pointers: 2
trueLayout       Pointer Index: 0, Pointer Id: 0
trueLayout          Location: 114.88211 x 499.77502
trueLayout          Pressure: 0.05882353   Size: 0.13333334
trueLayout       Pointer Index: 1, Pointer Id: 1
trueLayout          Location: 320.30692 x 189.67395
trueLayout          Pressure: 0.050980393   Size: 0.13333334
trueLayout       Down time: 33733650ms
trueLayout       Event time: 33733962ms  Elapsed: 312 ms
Action           Pointer index: 1
Action           Pointer Id: 1
Action           True Action value: 0
trueLayout       -----------------------------
trueLayout       Got view trueLayout in onTouch
trueLayout       Action: 2
trueLayout       Number of pointers: 2
trueLayout       Pointer Index: 0, Pointer Id: 0
trueLayout          Location: 111.474594 x 499.77502
trueLayout          Pressure: 0.05882353   Size: 0.13333334
trueLayout       Pointer Index: 1, Pointer Id: 1
trueLayout          Location: 320.30692 x 189.67395
trueLayout          Pressure: 0.050980393   Size: 0.13333334
trueLayout       Down time: 33733650ms
trueLayout       Event time: 33734189ms  Elapsed: 539 ms
Action           Pointer index: 0
Action           Pointer Id: 0
Action           True Action value: 2
trueLayout       -----------------------------
trueLayout       Got view trueLayout in onTouch
trueLayout       Action: 6
trueLayout       Number of pointers: 2
trueLayout       Pointer Index: 0, Pointer Id: 0
trueLayout          Location: 111.474594 x 499.77502
trueLayout          Pressure: 0.05882353   Size: 0.13333334
trueLayout       Pointer Index: 1, Pointer Id: 1
trueLayout          Location: 320.30692 x 189.67395
trueLayout          Pressure: 0.050980393   Size: 0.13333334
trueLayout       Down time: 33733650ms
trueLayout       Event time: 33734228ms  Elapsed: 578 ms
Action           Pointer index: 0
Action           Pointer Id: 0
Action           True Action value: 1
trueLayout       -----------------------------
trueLayout       Got view trueLayout in onTouch
trueLayout       Action: 2
trueLayout       Number of pointers: 1
trueLayout       Pointer Index: 0, Pointer Id: 1
trueLayout          Location: 318.84656 x 191.45105
trueLayout          Pressure: 0.050980393   Size: 0.13333334
trueLayout       Down time: 33733650ms
trueLayout       Event time: 33734240ms  Elapsed: 590 ms
Action           Pointer index: 0
Action           Pointer Id: 1
Action           True Action value: 2
trueLayout       -----------------------------
trueLayout       Got view trueLayout in onTouch
trueLayout       Action: 1
trueLayout       Number of pointers: 1
trueLayout       Pointer Index: 0, Pointer Id: 1
trueLayout          Location: 314.95224 x 190.5625
trueLayout          Pressure: 0.050980393   Size: 0.13333334
trueLayout       Down time: 33733650ms
trueLayout       Event time: 33734549ms  Elapsed: 899 ms
Action           Pointer index: 0
Action           Pointer Id: 1
Action           True Action value: 1

了解多点触摸内容

我们现在将讨论这个应用是怎么回事。我们看到的第一个事件是第一个手指的 ACTION_DOWN (动作值为 0)。我们使用 getAction() 方法 来了解这一点。请参考 MainActivity.java 中的 describeEvent() 方法来了解哪些方法产生了哪些输出。我们得到一个索引为 0、指针 ID 为 0 的指针。之后,你可能会看到这个第一个手指的几个动作 _ 移动事件(动作值为 2),尽管我们在清单 22-9 中只显示了其中一个。我们仍然只有一个指针,索引和 ID 仍然都是 0。

过了一会儿,我们用第二手指触摸屏幕。该动作现在是十进制值 261。这是什么意思?动作值实际上由两部分组成:一个指示动作是针对哪个指针的,以及这个指针正在做什么动作。将十进制 261 转换为十六进制,我们得到 0x00000105。动作是最小的字节(本例中为 5),指针索引是下一个字节(本例中为 1)。注意,这告诉我们的是指针索引,而不是指针 ID。如果您在屏幕上按下第三个手指,动作将是 0x00000205(或十进制 517)。第四个指针是 0x00000305(或十进制 773),依此类推。你还没有看到一个动作值为 5,但它被称为 ACTION_POINTER_DOWN 。它就像 ACTION_DOWN 一样,只是它用在多点触摸的情况下。

现在,看看清单 22-9 中来自 LogCat 的下一对记录。第一条记录是针对一个动作 _ 移动事件 ??(动作值为 2)。请记住,很难阻止手指在真实屏幕上移动。我们只显示了一个 ACTION_MOVE 事件,但是当您亲自尝试时,您可能会看到几个。当第一个手指离开屏幕时,我们得到一个动作值 0x00000006(或十进制 6)。像以前一样,我们有指针索引 0 和动作值 ACTION_POINTER_UP (类似于 ACTION_UP ,但用于多点触摸情况)。如果在多点触摸的情况下抬起第二个手指,我们将获得 0x00000106(或十进制 262)的动作值。请注意,当我们为其中一个手指获取 ACTION_UP 时,我们仍然有两个手指的信息。

清单 22-9 中的最后一对记录显示了第二手指的另一个动作 _ 移动事件,随后是第二手指的动作 _ 上升。这一次,我们看到一个动作值为 1 ( ACTION_UP )。我们没有得到 262 的动作值,但是我们将在下面解释。另外,请注意,一旦第一个手指离开屏幕,第二个手指的指针索引已经从 1 变为 0,但是指针 ID 仍然是 1。

ACTION_MOVE 事件不会告诉你哪个手指动了。无论有多少根手指向下或哪根手指在移动,你的动作值总是为 2。所有向下手指的位置在运动事件对象中都是可用的,所以你需要读取这些位置,然后把事情弄清楚。如果屏幕上只剩下一个手指,指针 ID 会告诉你哪个手指还在动,因为它是唯一剩下的手指。在清单 22-9 中,当屏幕上只剩下第二手指时, ACTION_MOVE 事件的指针索引为 0,指针 ID 为 1,所以我们知道是第二手指在移动。

一个 MotionEvent 对象不仅可以包含多个手指的移动事件,还可以包含每个手指的多个移动事件。它使用对象中包含的历史值来实现这一点。Android 应该报告自最后一个运动事件对象以来的所有历史。参见 getHistoricalSize() 和另一个 getHistorical...()方法。

回到清单 22-9 的开头,第一个按下的手指是指针索引 0,指针 ID 0,那么为什么在第一个手指先于其他任何手指按到屏幕上时,我们得不到动作值 0x00000005(或者十进制 5)?很遗憾,这个问题没有一个满意的答案。我们可以在下面的场景中得到一个动作值 5:将第一个手指按向屏幕,然后按第二个手指,得到动作值 0 和 261(暂时忽略 ACTION_MOVE 事件)。现在,抬起第一个手指(动作值为 6),并在屏幕上向下按压。第二手指的指针 ID 保持为 1。当第一个手指在空中时,我们的应用只知道指针 ID 1。一旦第一个手指再次触摸屏幕,Android 会将指针 ID 0 重新分配给第一个手指,并赋予其指针索引 0。因为现在我们知道涉及到多个手指,所以我们得到的动作值为 5(指针索引为 0,动作值为 5)。因此,问题的答案是向后兼容,但这不是一个令人满意的答案。动作值 0 和 1 是预多点触摸。

当只有一个手指留在屏幕上时,Android 将其视为单点触控外壳。所以我们得到旧的 ACTION_UP 值 1,而不是多点触摸 ACTION_UP 值 6。我们的代码需要仔细考虑这些情况。指针索引为 0 可能导致 ACTION_DOWN 值为 0 或 5,这取决于哪个指针在起作用。最后一个向上的指针将获得为 1 的 ACTION_UP 值 ,而不管它具有哪个指针 ID。

还有一个动作我们至今没有提到: ACTION_SCROLL (值为 8),在 Android 3.1 中引入。这来自鼠标之类的输入设备,而不是触摸屏。事实上,从 MotionEvent 中的方法可以看出,这些对象可以用于触摸屏触摸之外的许多事情。我们不会在本书中讨论这些其他的输入设备。

手势

手势是一种特殊类型的触摸屏事件。术语手势在 Android 中用于各种各样的事情,从简单的触摸序列,如扔或捏,到正式的手势类。投掷、挤压、长按和滚动都有带有预期触发的预期行为。也就是说,对大多数人来说很清楚,一个手指触摸屏幕,向一个方向快速拖动,然后抬起的手势。例如,当有人在 Gallery 应用(以从左到右的链显示图像的应用)中使用 fling 时,图像将向旁边移动,以向用户显示新的图像。

在接下来的几节中,您将学习如何实现挤压手势,从中您可以轻松地实现其他常见的手势。正式手势类是指用户在触摸屏上绘制的手势,以便应用可以对这些手势做出反应。典型的例子包括绘制应用可以理解为字母的字母表中的字母。正式的手势课程不在本书的讨论范围之内。让我们学会掐!

捏手势

多点触摸的一个很酷的应用是挤压手势,用于缩放。这个想法是,如果你把两个手指放在屏幕上并分开,应用应该通过放大来响应。如果你的手指并拢,应用应该会缩小。该应用通常显示图像,可能是地图。

在我们开始挤压手势的 本地支持之前,我们首先需要介绍一个从一开始就存在的类——手势检测器。

手势检测器和 OnGestureListeners

第一个帮助我们做手势的类是 GestureDetector ,它从 Android 最开始就存在了。它在生活中的目的是接收运动事件对象,并告诉我们一系列事件何时看起来像一个普通的手势。我们从回调中将所有的事件对象传递给手势检测器,当它识别出一个手势时,就会调用其他回调函数,比如一个轻击或长按。我们需要为来自 GestureDetector 的回调注册一个监听器,这是我们放置逻辑的地方,它告诉我们如果用户执行了这些常见的手势中的一个,该做什么。不幸的是,这个类没有告诉我们一个捏手势是否正在发生;为此,我们需要使用一个新的类,我们很快就会用到它。

有几种方法可以构建侦听器端。您的第一个选择是编写一个新的类来实现适当的手势监听器接口:例如,手势检测器。OnGestureListener 接口。对于每个可能的回调,都必须实现几个抽象方法。

第二种选择是选择一个简单的侦听器实现,并覆盖您所关心的适当的回调方法。比如手势检测器。SimpleOnGestureListener 类已经实现了所有的抽象方法,不做任何事情并返回 false 。您所要做的就是扩展该类,并覆盖您需要对您所关心的几个手势进行操作的几个方法。其他方法有它们默认的实现。即使您决定覆盖所有回调方法,选择第二个选项也更符合未来,因为如果 Android 的未来版本向接口添加另一个抽象回调方法,简单的实现将提供一个默认回调方法,因此您可以放心。

我们将探索 ScaleGestureDetector ,加上相应的 listener 类,看看如何使用收缩手势来调整图像的大小。在这个例子中,我们扩展了简单的实现( ScaleGestureDetector)。SimpleOnScaleGestureListener)为我们的监听器。清单 22-10 给出了我们的主活动的 XML 布局和 Java 代码。

清单 22-10 。使用 ScaleGestureDetector 的挤压手势的布局和 Java 代码

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:id="@+id/layout"  android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

  <TextView  android:text=
        "Use the pinch gesture to change the image size"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />

  <ImageView android:id="@+id/image"  android:src="@drawable/icon"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:scaleType="matrix" />

</LinearLayout>

// This file is MainActivity.java
public class MainActivity extends Activity {
    private static final String TAG = "ScaleDetector";
    private ImageView image;
    private ScaleGestureDetector mScaleDetector;
    private float mScaleFactor = 1f;
    private Matrix mMatrix = new Matrix();
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        image = (ImageView)findViewById(R.id.image);
        mScaleDetector = new ScaleGestureDetector(this,
                new ScaleListener());
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        Log.v(TAG, "in onTouchEvent");
        // Give all events to ScaleGestureDetector
        mScaleDetector.onTouchEvent(ev);

        return true;
    }

    private class ScaleListener extends
            ScaleGestureDetector.SimpleOnScaleGestureListener {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            mScaleFactor *= detector.getScaleFactor();

            // Make sure we don't get too small or too big
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));

            Log.v(TAG, "in onScale, scale factor = " + mScaleFactor);
            mMatrix.setScale(mScaleFactor, mScaleFactor);

            image.setImageMatrix(mMatrix);
            image.invalidate();
            return true;
        }
    }
}

我们的布局很简单。我们有一个简单的文本视图和我们的消息来使用捏手势,我们有我们的图像视图和标准的 Android 图标。我们将使用收缩手势来调整图标图像的大小。当然,你可以随意用你自己的图像文件来代替图标。只需将您的图像文件复制到一个 drawable 文件夹中,并确保更改布局文件中的 android:src 属性。注意我们图像的 XML 布局中的 android:scaleType 属性。这告诉 Android 我们将使用图形矩阵对图像进行缩放操作。虽然图形矩阵也可以在布局中移动我们的图像,但我们现在只关注缩放。还要注意,我们将 ImageView 的尺寸设置得尽可能大。当我们缩放图像时,我们不希望它被 ImageView 的边界所剪切。

代码也很简单。在 onCreate() 中,我们获取对我们图像的引用,并创建我们的 ScaleGestureDetector 。在我们的 onTouchEvent() 回调中,我们所做的就是将我们获得的每个事件对象传递给 ScaleGestureDetector 的 onTouchEvent() 方法,并返回 true ,这样我们就能不断获得新的事件。这允许 ScaleGestureDetector 看到所有事件,并决定何时通知我们手势。

ScaleListener 是缩放发生的地方。监听器类中实际上有三个回调: onScaleBegin() 、 onScale() 和 onScaleEnd() 。我们不需要对 begin 和 end 方法做任何特殊的处理,所以我们没有在这里实现它们。

在 onScale() 中,传入的检测器可以用来找出大量关于缩放操作的信息。比例因子是一个徘徊在 1 左右的值。也就是说,随着手指捏得越来越近,该值略低于 1;当手指分开时,该值略大于 1。我们的 mScaleFactor 成员从 1 开始,所以随着手指的靠拢或分开,它逐渐变得小于或大于 1。如果 mScaleFactor 等于 1,我们的图像将是正常大小。否则,当 mScaleFactor 移动到 1 以下或以上时,我们的图像将会比正常图像小或大。我们用优雅的最小/最大函数组合为 mScaleFactor 设置了一些界限。这可以防止我们的图像变得太小或太大。然后,我们使用 mScaleFactor 缩放图形矩阵,并将新缩放的矩阵应用于我们的图像。调用 invalidate() 强制在屏幕上重新绘制图像。

为了使用 OnGestureListener 接口,你要做的事情与我们在这里使用 ScaleListener 所做的事情非常相似,除了回调将针对不同的常见手势,如单击、双击、长按和投掷。

参考

这里有一些对您可能希望进一步探索的主题有帮助的参考。

摘要

让我们通过快速列举到目前为止您已经了解的触摸屏知识来结束本章:

  • 作为触摸处理基础的运动事件
  • 不同的回调处理一个视图对象上的触摸事件,并通过一个 OnTouchListener
  • 触摸序列中发生的不同类型的事件
  • 触摸事件如何在整个视图层次中传播,除非沿途处理
  • 一个运动事件对象包含的关于触摸的信息,包括多个手指
  • 何时回收一个运动事件对象,何时不回收
  • 确定手指在屏幕上拖动的速度
  • 多点触控的奇妙世界及其工作原理的内部细节
  • 实现挤压手势以及其他常见手势

二十三、实现拖放

在上一章中,我们介绍了触摸屏、 MotionEvent 类和手势。您学习了如何使用触摸在您的应用中实现一些事情。我们没有涉及的一个领域是拖放。从表面上看,拖放似乎相当简单:触摸屏幕上的一个对象,在屏幕上拖动它(通常在其他对象上),然后放开,应用应该采取适当的动作。在许多计算机操作系统中,这是从桌面上删除文件的常用方法;你只要把文件的图标拖到垃圾桶图标上,文件就被删除了。在 Android 中,你可能已经看到如何通过将图标拖到新位置或垃圾桶来重新排列主屏幕上的图标。

这一章将深入探讨拖放。在 Android 3.0 之前,当涉及到拖放时,开发人员只能靠自己。但是因为仍然有相当多的手机运行 Android 2.3,我们将向您展示如何在这些手机上进行拖放。我们将在本章的第一节向您展示旧的方法,然后在第二部分向您展示新的方法。

探索拖放

在下一个示例应用中,我们将选取一个白点,并将其拖动到用户界面中的新位置。我们还将在用户界面中放置三个计数器,如果用户将白点拖到其中一个计数器上,该计数器将递增,该点将返回到其起始位置。如果这个点被拖到屏幕上的其他地方,我们就把它留在那里。

注意参见本章末尾的“参考”一节,获取可以将这些项目直接导入 IDE 的 URL。我们将只在文本中显示代码来解释概念。您需要下载代码来创建一个工作示例应用。

本章的第一个示例应用叫做 TouchDragDemo 。在这一部分中,我们要讨论两个关键文件:

  • /res/layout/main.xml
  • /src/com/Android book/touch/drag demo/dot . Java

main.xml 文件包含我们的拖放演示的布局。如清单 23-1 所示。我们希望您注意的一些关键概念是使用一个 FrameLayout 作为顶层布局,其中有一个 LinearLayout ,包含 TextView s 和一个名为 Dot 的自定义 View 类。因为 LinearLayout 和 Dot 共存于 FrameLayout 中,它们的位置和大小实际上不会相互影响,但是它们将共享屏幕空间,一个在另一个之上。该应用的用户界面如图 23-1 所示。

清单 23-1 。我们的拖动示例的示例布局 XML

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/main.xml -->
<FrameLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#0000ff" >

  <LinearLayout android:id="@+id/counters"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <TextView android:id="@+id/top" android:text="0"
      android:background="#111111"
      android:layout_height="wrap_content"
      android:layout_width="60dp"
      android:layout_gravity="right"
      android:layout_marginTop="30dp"
      android:layout_marginBottom="30dp"
      android:padding="10dp" />

    <TextView android:id="@+id/middle" android:text="0"
      android:background="#111111"
      android:layout_height="wrap_content"
      android:layout_width="60dp"
      android:layout_gravity="right"
      android:layout_marginBottom="30dp"
      android:padding="10dp" />

    <TextView android:id="@+id/bottom" android:text="0"
      android:background="#111111"
      android:layout_height="wrap_content"
      android:layout_width="60dp"
      android:layout_gravity="right"
      android:padding="10dp" />
  </LinearLayout>

  <com.androidbook.touch.dragdemo.Dot android:id="@+id/dot"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

</FrameLayout>

9781430246800_Fig23-01.jpg

图 23-1 。触摸屏演示的用户界面

注意,布局 XML 文件中的包名必须与应用中使用的包名相匹配。如上所述,点的布局与线状布局是分开的。这是因为我们想要在屏幕上自由移动圆点,这也是我们选择【match _ parent】的 layout_width 和 layout_height 的原因。当我们在屏幕上画点时,我们希望它是可见的,如果我们将我们的点的视图的大小压缩到点的直径,当我们将它从我们的起始位置拖开时,我们将看不到它。

注意从技术上来说,我们可以在 FrameLayout 标签中将 android:clipChildren 设置为 true ,并将点的布局宽度和高度设置为 wrap_content ,但是这样感觉不太干净。

对于每个计数器,我们简单地用背景、填充、边距和重力来布置它们,使它们显示在屏幕的右侧。我们从零开始,但是你很快就会看到,当点被拖到它们上面时,我们会增加这些值。虽然在这个例子中我们选择使用文本视图的,但是你可以使用任何视图对象作为拖放目标。现在我们来看看清单 23-2 中点类的 Java 代码。

清单 23-2 。我们的点类的 Java 代码

public class Dot extends View {
    private static final String TAG = "TouchDrag";
    private float left = 0;
    private float top = 0;
    private float radius = 20;
    private float offsetX;
    private float offsetY;
    private Paint myPaint;
    private Context myContext;

    public Dot(Context context, AttributeSet attrs) {
        super(context, attrs);

        // Save the context (the activity)
        myContext = context;

        myPaint = new Paint();
        myPaint.setColor(Color.WHITE);
        myPaint.setAntiAlias(true);
    }

    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();
        float eventX = event.getX();
        float eventY = event.getY();
        switch(action) {
        case MotionEvent.ACTION_DOWN:
            // First make sure the touch is on our dot,
            // since the size of the dot's view is
            // technically the whole layout. If the
            // touch is *not* within, then return false
            // indicating we don't want any more events.
            if( !(left-20 < eventX && eventX < left+radius*2+20 &&
                top-20 < eventY && eventY < top+radius*2+20))
                return false;

            // Remember the offset of the touch as compared
            // to our left and top edges.
            offsetX = eventX - left;
            offsetY = eventY - top;
            break;
        case MotionEvent.ACTION_MOVE:
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            left = eventX - offsetX;
            top = eventY - offsetY;
            if(action == MotionEvent.ACTION_UP) {
                checkDrop(eventX, eventY);
            }
            break;
        }
        invalidate();
        return true;
    }

    private void checkDrop(float x, float y) {
        // See if the x,y of our drop location is near to
        // one of our counters. If so, increment it, and
        // reset the dot back to its starting position
        Log.v(TAG, "checking drop target for " + x + ", " + y);

        int viewCount = ((MainActivity)myContext).counterLayout
                          .getChildCount();

        for(int i = 0; i<viewCount; i++) {
            View view = ((MainActivity)myContext).counterLayout
                          .getChildAt(i);
            if(view.getClass() == TextView.class){
                Log.v(TAG, "Is the drop to the right of " +
                            (view.getLeft()-20));
                Log.v(TAG, "  and vertically between " +
                          (view.getTop()-20) +
                          " and " + (view.getBottom()+20) + "?");
                if(x > view.getLeft()-20 &&
                        view.getTop()-20 < y &&
                        y < view.getBottom()+20) {
                    Log.v(TAG, "     Yes. Yes it is.");

                    // Increase the count value in the TextView by one
                    int count =
                        Integer.parseInt(
                            ((TextView)view).getText().toString());
                    ((TextView)view).setText(String.valueOf( ++count ));

                    // Reset the dot back to starting position
                    left = top = 0;
                    break;
                }
            }
        }
    }

    public void draw(Canvas canvas) {
        canvas.drawCircle(left + radius, top + radius, radius, myPaint);
    }
}

当你运行这个应用时,你会看到一个蓝底白点。你可以触摸这个点并在屏幕上拖动它。当您抬起手指时,圆点会停留在原来的位置,直到您再次触摸它并将它拖到其他地方。 draw() 方法 将圆点放在其当前的左上方位置,并根据圆点的半径进行调整。通过在 onTouchEvent() 方法中接收 MotionEvent 对象,我们可以通过触摸的移动来修改左边和上边的值。

因为用户不会总是触摸对象的精确中心,所以触摸坐标不会与对象的位置坐标相同。这就是偏移值的目的:让我们从触摸的位置回到点的左边和上边。但是,即使在我们开始拖动操作之前,我们也希望确保用户的触摸被认为离点足够近才有效。如果用户触摸屏幕远离点,这在技术上是在点的视图布局内,我们不希望这开始一个拖动序列。这就是为什么我们要观察触摸是否在白点本身之内;如果不是,我们简单地返回假,这阻止在该触摸序列中接收任何更多的触摸事件。

当你的手指开始在屏幕上移动时,我们根据得到的运动事件来调整对象在 x 和 y 方向的位置。当你停止移动( ACTION_UP ,我们使用你触摸的最后坐标来确定我们的位置。在这个例子中,我们不必担心滚动条,滚动条会使我们的对象在屏幕上的位置计算变得复杂。但是基本原理还是一样的。通过知道要移动的对象的起始位置并跟踪从动作 _ 向下到动作 _ 向上的触摸的增量值,我们可以调整对象在屏幕上的位置。

将一个物体放到屏幕上的另一个物体上,与触摸的关系要小得多,与了解物体在屏幕上的位置关系更大。当我们在屏幕上拖动一个物体时,我们知道它相对于一个或多个参考点的位置。我们还可以询问屏幕上物体的位置和大小。然后我们可以确定我们拖动的对象是否在另一个对象的“上方”。确定被拖动对象的拖放目标的典型过程是遍历可以拖放的可用对象,并确定我们的当前位置是否与该对象重叠。每个物体的大小和位置(有时是形状)可以用来做这个决定。如果我们得到一个 ACTION_UP 事件,这意味着用户已经放开了我们拖动的对象,并且该对象位于我们可以拖放的对象之上,我们可以触发逻辑来处理拖放操作。

我们在示例应用中使用了这种方法。当检测到 ACTION_UP 动作时,我们接着查看 LinearLayout 的子视图,对于找到的每个 TextView ,我们将触摸的位置与 TextView 的边缘进行比较(加上一点额外的)。如果触摸在那个文本视图内,我们获取文本视图的当前数值,递增 1,并写回。如果发生这种情况,点的位置将被重置回其开始位置(left = 0,top = 0 ),以便进行下一次拖动。

我们的例子向您展示了在 3.0 之前的 Android 中进行拖放的基本方法。这样你就可以在你的应用中实现拖放功能。这可能是将某个对象拖到垃圾桶的动作,在垃圾桶中被拖动的对象应该被删除,也可能是将文件拖到文件夹中以便移动或复制它。为了美化您的应用,您可以预先确定哪些视图是潜在的拖放目标,并在拖动开始时使它们在视觉上发生变化。如果你希望被拖动的对象在被放下时从屏幕上消失,你总是可以通过编程从布局中移除它(参见视图组中的各种 removeView 方法)。

既然你已经看到了拖放的艰难过程,我们将向你展示 Android 3.0 中添加的拖放支持。

3.0+中拖放的基础知识

在 Android 3.0 之前,没有直接支持拖拽。在本章的第一节中,你学习了如何在屏幕上拖动一个视图;您还了解了可以使用被拖动对象的当前位置来确定下面是否有拖放目标。当接收到竖起手指事件的 MotionEvent 时,您的代码可以判断这是否意味着发生了跌落。尽管这是可行的,但肯定不如在 Android 中直接支持拖放操作那么容易。你现在得到了直接支持。

在最基本的情况下,拖放操作从一个声明拖动已经开始的视图开始;然后,所有相关方都会看到拖动的发生,直到 drop 事件被触发。如果一个视图捕捉到了拖放事件并想要接收它,那么拖放就发生了。如果没有视图接收放置,或者接收放置的视图不想要放置,那么就不会发生放置。拖动是通过使用一个 DragEvent 对象来传递的,该对象被传递给所有可用的拖动监听器。

在 DragEvent 对象中有大量信息的描述符,这取决于拖动序列的发起者。例如, DragEvent 可以包含对发起者本身的对象引用、状态信息、文本数据、URIs,或者任何您想通过拖动序列传递的东西。

可以传递导致视图到视图动态通信的信息;然而,当 DragEvent 被创建时, DragEvent 对象中的发起者数据被设置,并且此后保持不变。除了这些数据之外,拖拽事件还有一个动作值,指示拖拽序列正在进行什么,以及位置信息,指示拖拽在屏幕上的位置。

一个拖拽事件有六种可能的动作:

  • ACTION _ DRAG _ STARTED表示一个新的拖动序列已经开始。
  • ACTION _ DRAG _ enter表示被拖动的对象已经被拖动到特定视图的边界内。
  • ACTION _ DRAG _ LOCATION表示被拖动的对象已经在屏幕上被拖动到一个新的位置。
  • ACTION _ DRAG _ EXITED 表示被拖动的对象已经被拖动到特定视图的边界之外。
  • ACTION_DROP 表示用户已经放开了被拖动的对象。由该事件的接收者来确定这是否真正意味着发生了丢弃。
  • ACTION _ DRAG _ ENDED 告诉所有的拖拽监听器,之前的拖拽序列已经结束。 DragEvent.getResult() 方法表示成功丢弃或失败。

您可能认为您需要在系统中参与拖动序列的每个视图上设置一个拖动监听器;但是,事实上,您可以在应用中的任何内容上定义一个拖动监听器,它将接收系统中所有视图的所有拖动事件。这可能会使事情变得有点混乱,因为拖动侦听器不需要与被拖动的对象或放置目标相关联。监听器可以管理所有的拖放协调。

事实上,如果您检查 Android SDK 附带的拖放示例项目,您会看到它在一个与实际拖放无关的 TextView 上设置了一个侦听器。接下来的示例项目使用绑定到特定视图的拖动侦听器。这些拖动监听器每个都接收一个拖动事件对象,用于拖动序列中发生的拖动事件。这意味着一个视图可能会接收到一个可以被忽略的 DragEvent 对象,因为它实际上是关于一个不同的视图。这也意味着拖动监听器必须在代码中做出决定,并且在 DragEvent 对象中必须有足够的信息让拖动监听器知道该做什么。

如果一个拖动监听器得到一个 DragEvent 对象,它只是说有一个未知的对象正在被拖动,并且它在坐标(15,57)处,那么拖动监听器对它没有什么作用。获得一个 DragEvent 对象会更有帮助,它表示一个特定的对象正在被拖动,它位于坐标(15,57),这是一个复制操作,数据是一个特定的 URI。当这个值下降时,就有足够的信息来启动拷贝操作。

我们实际上看到了两种不同的拖拽方式。在我们的第一个示例应用中,我们将一个视图拖过一个框架布局,我们可以放开它,视图将停留在原来的位置。当我们把视图放到其他东西上面时,我们只有拖放行为。支持的拖放形式与此不同。现在,当您将视图作为拖放序列的一部分进行拖动时,被拖动的视图根本不会移动。我们得到了拖动视图的阴影图像,它在屏幕上移动,但是如果我们放开它,阴影视图就消失了。这意味着,在 Android 3.0+应用中,你可能仍然有机会使用本章开头的技术,在屏幕上移动图像,而不需要拖放。

拖放示例应用

对于您的下一个示例应用,您将使用 3.0 版本的 staple,即片段。这将证明拖拽可以跨越片段边界。您将在左侧创建一个点调色板,在右侧创建一个方形目标。当长时间点击一个点时,你将改变调色板中该点的颜色,Android 将在你拖动时显示该点的阴影。当拖动的点到达正方形目标时,目标将开始发光。如果您将圆点放在方形目标上,将会出现一条信息,提示您刚刚向滴数中添加了一滴,发光将会停止,原来的圆点将恢复到原来的颜色。

文件列表

这个应用建立在我们在本书中讨论的概念之上。我们将只在文本中包含有趣的文件。至于其他的,你可以在空闲的时候在你的 IDE 里看看。以下是我们包含在文本中的内容:

  • palette.xml 是左侧圆点的片段布局(见清单 23-3 )。
  • dropzone.xml 是右边正方形目标的片段布局,加上 drop-count 消息(见清单 23-4 )。
  • DropZone.java 膨胀 dropzone.xml 片段布局文件,然后实现拖放目标的拖动监听器(见清单 23-5 )。
  • 是你要拖动的对象的自定义视图类。它处理拖动序列的开始,观察拖动事件,并画出点(见清单 23-6 )。

布置示例拖放应用

在我们进入代码之前,图 23-2 展示了应用的样子。

9781430246800_Fig23-02.jpg

图 23-2 。拖放片段示例应用用户界面

主布局文件有一个简单的水平线性布局和两个片段规范。第一个片段将用于点调色板,第二个片段将用于 dropzone。

调色板片段布局文件(清单 23-3 )变得更有趣了。虽然这个布局表示一个片段,但是您不需要在这个布局中包含一个片段标记。这个布局将被放大,成为您的面板片段的视图层次结构。这些点被指定为自定义点,其中有两个垂直排列。注意,在 dots 的定义中有几个定制的 XML 属性( dot:color 和 dot:radius) 。如您所见,这些属性指定了点的颜色和半径。您可能还注意到,布局的宽度和高度是 wrap_content ,而不是本章前面的示例应用中的 match_parent 。新的拖放支持使事情变得更加容易。

清单 23-3 。点 s 的 palette.xml 布局文件

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/palette.xml -->
<LinearLayout
  xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
  xmlns:dot=
    "[`schemas.android.com/apk/res/com.androidbook.drag.drop.demo`](http://schemas.android.com/apk/res/com.androidbook.drag.drop.demo)"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

  <com.androidbook.drag.drop.demo.Dot android:id="@+id/dot1"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:padding="30dp"
    android:tag="Blue dot"
    dot:color="#ff1111ff"
    dot:radius="20dp"  />

  <com.androidbook.drag.drop.demo.Dot android:id="@+id/dot2"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:padding="10dp"
    android:tag="White dot"
    dot:color="#ffffffff"
    dot:radius="40dp"  />

</LinearLayout>

清单 23-4 中的 dropzone 片段布局文件也很容易理解。有一个绿色方块和一条横向排列的短信。这将是您将要拖动的点的拖放区。文本消息将用于显示液滴的运行计数。

清单 23-4 。 dropzone.xml 布局文件

<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/dropzone.xml -->
<LinearLayout
  xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="horizontal" >

  <View android:id="@+id/droptarget"
    android:layout_width="75dp"
    android:layout_height="75dp"
    android:layout_gravity="center_vertical"
    android:background="#00ff00" />

  <TextView android:id="@+id/dropmessage"
    android:text="0 drops"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center_vertical"
    android:paddingLeft="50dp"
    android:textSize="17sp" />

</LinearLayout>

在空投区回应翁德拉格

现在您已经有了主应用布局,让我们通过检查清单 23-5 来看看需要如何组织放置目标。

清单 23-5 。DropZone.java 文件

public class DropZone extends Fragment {

    private View dropTarget;
    private TextView dropMessage;

    @Override
    public View onCreateView(LayoutInflater inflater,
            ViewGroup container, Bundle icicle)
    {
        View v = inflater.inflate(R.layout.dropzone, container, false);

        dropMessage = (TextView)v.findViewById(R.id.dropmessage);

        dropTarget = (View)v.findViewById(R.id.droptarget);
        dropTarget.setOnDragListener(new View.OnDragListener() {
            private static final String DROPTAG = "DropTarget";
            private int dropCount = 0;
            private ObjectAnimator anim;

            public boolean onDrag(View v, DragEvent event) {
                int action = event.getAction();
                boolean result = true;
                switch(action) {
                case DragEvent.ACTION_DRAG_STARTED:
                    Log.v(DROPTAG, "drag started in dropTarget");
                    break;
                case DragEvent.ACTION_DRAG_ENTERED:
                    Log.v(DROPTAG, "drag entered dropTarget");
                    anim = ObjectAnimator.ofFloat(
                                (Object)v, "alpha", 1f, 0.5f);
                    anim.setInterpolator(new CycleInterpolator(40));
                    anim.setDuration(30*1000); // 30 seconds
                    anim.start();
                    break;
                case DragEvent.ACTION_DRAG_EXITED:
                    Log.v(DROPTAG, "drag exited dropTarget");
                    if(anim != null) {
                        anim.end();
                        anim = null;
                    }
                    break;
                case DragEvent.ACTION_DRAG_LOCATION:
                    Log.v(DROPTAG, "drag proceeding in dropTarget: " +
                            event.getX() + ", " + event.getY());
                    break;
                case DragEvent.ACTION_DROP:
                    Log.v(DROPTAG, "drag drop in dropTarget");
                    if(anim != null) {
                        anim.end();
                        anim = null;
                    }

                    ClipData data = event.getClipData();
                    Log.v(DROPTAG, "Item data is " +
                          data.getItemAt(0).getText());

                    dropCount++;
                    String message = dropCount + " drop";
                    if(dropCount > 1)
                        message += "s";
                    dropMessage.setText(message);
                    break;
                case DragEvent.ACTION_DRAG_ENDED:
                    Log.v(DROPTAG, "drag ended in dropTarget");
                    if(anim != null) {
                        anim.end();
                        anim = null;
                    }
                    break;
                default:
                    Log.v(DROPTAG, "other action in dropzone: " +
                                   action);
                    result = false;
                }
                return result;
            }
        });
        return v;
    }
}

现在你开始进入有趣的代码。对于 dropzone,您需要创建要在其上拖动点的目标。正如您前面看到的,布局在屏幕上指定了一个绿色方块,旁边有一条文本消息。因为 dropzone 也是一个片段,所以您覆盖了 DropZone 的 onCreateView() 方法 。首先要做的是膨胀 dropzone 布局,然后提取方形目标( dropTarget )和文本消息( dropMessage )的视图引用。然后,您需要在目标上设置一个拖动监听器,这样它就会知道拖动何时开始。

拖放目标拖动监听器中有一个回调方法: onDrag() 。这个回调将接收一个视图引用以及一个 DragEvent 对象。视图引用与拖拽事件相关的视图相关。如前所述,拖动侦听器不一定连接到将与拖动事件交互的视图,因此这个回调必须标识发生拖动事件的视图。

在任何 onDrag() 回调中,您可能要做的第一件事就是从 DragEvent 对象中读取动作。这会告诉你发生了什么。在大多数情况下,您在这个回调中唯一想做的事情就是记录一个拖动事件正在发生的事实。例如,你不需要为 ACTION_DRAG_LOCATION 做任何事情。但是,当对象被拖动到您的边界内时,您确实希望有一些特殊的逻辑(ACTION _ DRAG _ enter),当对象被拖动到您的边界外时( ACTION_DRAG_EXITED )或者当对象被放下时( ACTION_DROP ),这些逻辑将被关闭。

你正在使用在第十八章中介绍的 ObjectAnimator 类 ,只是在这里你在代码中使用它来指定一个修改目标 alpha 的循环插值器。这将具有使绿色目标方块的透明度脉动的效果,这将是目标愿意接受物体掉落到其上的视觉指示。因为您打开了动画,所以您必须确保在对象离开或被放下,或者拖放结束时也关闭动画。理论上,你不需要在 ACTION_DRAG_ENDED 上停止动画,但无论如何这样做是明智的。

对于这个特定的拖动监听器,只有当被拖动的对象与您关联的视图交互时,您才会得到 ACTION _ DRAG _ enter 和 ACTION_DRAG_EXITED 。正如您将看到的, ACTION_DRAG_LOCATION 事件只有在被拖动的对象在您的目标视图中时才会发生。

另一个有趣的条件是 ACTION_DROP 本身(注意 DRAG_ 不是这个动作名称的一部分)。如果你的视图上出现了一个点,这意味着用户已经放开了绿色方块上的点。因为您希望这个对象被放到绿色方块上,所以您可以直接从第一个项目中读取数据,然后将其记录到 LogCat 中。在生产应用中,您可能会更加关注包含在拖动事件本身中的 ClipData 对象。通过检查它的属性,您可以决定是否接受这个拖放操作。

这是在这个 onDrag() 回调方法中指出结果布尔值的好时机。根据事情的进展,你想让 Android 知道你处理了拖动事件(通过返回真)或者你没有处理(通过返回假)。如果您在拖动事件对象中没有看到您想要看到的内容,您当然可以从这个回调中返回 false,这将告诉 Android 这个拖放操作没有被处理。

一旦在 LogCat 中记录了来自拖动事件的信息,就会增加接收到的 drops 的计数;这是在用户界面中更新的,关于 DropZone 也就这些了。

如果你看一下这个类,它真的很简单。这里实际上没有任何处理 MotionEvents 的代码,也不需要自己判断是否有拖拽在进行。随着拖动序列的展开,您只需获得适当的回调调用。

设置拖动源视图

现在让我们考虑一下对应于一个拖动源的视图是如何组织的,从查看清单 23-6 开始。

清单 23-6 。自定义视图的 Java:点

public class Dot extends View
    implements View.OnDragListener
{
    private static final int DEFAULT_RADIUS = 20;
    private static final int DEFAULT_COLOR = Color.WHITE;
    private static final int SELECTED_COLOR = Color.MAGENTA;
    protected static final String DOTTAG = "DragDot";
    private Paint mNormalPaint;
    private Paint mDraggingPaint;
    private int mColor = DEFAULT_COLOR;
    private int mRadius = DEFAULT_RADIUS;
    private boolean inDrag;

    public Dot(Context context, AttributeSet attrs) {
        super(context, attrs);

        // Apply attribute settings from the layout file.
        // Note: these could change on a reconfiguration
        // such as a screen rotation.
        TypedArray myAttrs = context.obtainStyledAttributes(attrs,
                R.styleable.Dot);

        final int numAttrs = myAttrs.getIndexCount();
        for (int i = 0; i < numAttrs; i++) {
            int attr = myAttrs.getIndex(i);
            switch (attr) {
            case R.styleable.Dot_radius:
                mRadius = myAttrs.getDimensionPixelSize(attr,
                          DEFAULT_RADIUS);
                break;
            case R.styleable.Dot_color:
                mColor = myAttrs.getColor(attr, DEFAULT_COLOR);
                break;
            }
        }
        myAttrs.recycle();

        // Setup paint colors
        mNormalPaint = new Paint();
        mNormalPaint.setColor(mColor);
        mNormalPaint.setAntiAlias(true);

        mDraggingPaint = new Paint();
        mDraggingPaint.setColor(SELECTED_COLOR);
        mDraggingPaint.setAntiAlias(true);

        // Start a drag on a long click on the dot
        setOnLongClickListener(lcListener);
        setOnDragListener(this);
    }

    private static View.OnLongClickListener lcListener =
        new View.OnLongClickListener() {
        private boolean mDragInProgress;

        public boolean onLongClick(View v) {
            ClipData data =
            ClipData.newPlainText("DragData", (String)v.getTag());

            mDragInProgress =
            v.startDrag(data, new View.DragShadowBuilder(v),
                    (Object)v, 0);

            Log.v((String) v.getTag(),
              "starting drag? " + mDragInProgress);

            return true;
        }
    };

    @Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        int size = 2*mRadius + getPaddingLeft() + getPaddingRight();
        setMeasuredDimension(size, size);
    }

    // The dragging functionality
    public boolean onDrag(View v, DragEvent event) {
        String dotTAG = (String) getTag();
        // Only worry about drag events if this is us being dragged
        if(event.getLocalState() != this) {
            Log.v(dotTAG, "This drag event is not for us");
            return false;
        }
        boolean result = true;

        // get event values to work with
        int action = event.getAction();
        float x = event.getX();
        float y = event.getY();

        switch(action) {
        case DragEvent.ACTION_DRAG_STARTED:
            Log.v(dotTAG, "drag started. X: " + x + ", Y: " + y);
            inDrag = true; // used in draw() below to change color
            break;
        case DragEvent.ACTION_DRAG_LOCATION:
            Log.v(dotTAG, "drag proceeding… At: " + x + ", " + y);
            break;
        case DragEvent.ACTION_DRAG_ENTERED:
            Log.v(dotTAG, "drag entered. At: " + x + ", " + y);
            break;
        case DragEvent.ACTION_DRAG_EXITED:
            Log.v(dotTAG, "drag exited. At: " + x + ", " + y);
            break;
        case DragEvent.ACTION_DROP:
            Log.v(dotTAG, "drag dropped. At: " + x + ", " + y);
            // Return false because we don't accept the drop in Dot.
            result = false;
            break;
        case DragEvent.ACTION_DRAG_ENDED:
            Log.v(dotTAG, "drag ended. Success? " + event.getResult());
            inDrag = false; // change color of original dot back
            break;
        default:
            Log.v(dotTAG, "some other drag action: " + action);
            result = false;
            break;
        }
        return result;
    }

    // Here is where you draw our dot, and where you change the color if
    // you're in the process of being dragged. Note: the color change
    // affects the original dot only, not the shadow.
    public void draw(Canvas canvas) {
        float cx = this.getWidth()/2 + getLeftPaddingOffset();
        float cy = this.getHeight()/2 + getTopPaddingOffset();
        Paint paint = mNormalPaint;
        if(inDrag)
            paint = mDraggingPaint;
        canvas.drawCircle(cx, cy, mRadius, paint);
        invalidate();
    }
}

点代码看起来有点类似于 DropZone 的代码。这在一定程度上是因为在这个类中也接收到了拖动事件。一个点的构造器计算出属性以设置正确的半径和颜色,然后它设置两个监听器:一个用于长点击,另一个用于拖动事件。

这两种颜料将被用来画你的圆圈。当圆点就在那里的时候,你使用普通的颜料。但是,当拖动点时,您希望通过将原件的颜色更改为洋红色来表明这一点。

长点击监听器是您启动拖动序列的地方。让用户开始拖动点的唯一方式是用户点击并按住点。当长点击监听器触发时,您使用一个字符串和点的标签创建一个新的 ClipData 对象。您碰巧知道标记是 XML 布局文件中指定的点的名称。还有其他几种方法可以将数据指定到一个 ClipData 对象中,所以请随意阅读参考文档中关于在一个 ClipData 对象中存储数据的其他方法。

接下来的语句是关键的一个: startDrag() 。这是 Android 将接管并开始拖动过程的地方。注意,第一个参数是之前的 ClipData 对象;然后是拖动阴影对象,然后是本地状态对象,最后是数字零。

拖动阴影对象是拖动过程中显示的图像。在您的情况下,这不会替换屏幕上的原始点图像,但在拖动时,除了屏幕上的原始点之外,还会显示一个点的阴影。默认的 dragshaodbuilder 行为是创建一个看起来非常像原始的阴影,所以对于你的目的,你仅仅调用它并在你的视图中传递。在这里,你可以随心所欲地创建任何你想要的阴影视图,但是如果你要覆盖这个类,你需要实现一些方法来使它工作。

这里的 onMeasure() 方法 为 Android 提供您在这里使用的自定义视图的维度信息。你必须告诉 Android 你的视图有多大,这样它才知道如何把它和其他东西放在一起。这是自定义视图的标准做法。

最后,还有一个 onDrag() 回调。如上所述,每个拖动监听器都可以接收拖动事件。比如他们都得到动作 _ 拖动 _ 开始和动作 _ 拖动 _ 结束。因此,当事件发生时,你必须小心处理这些信息。因为在这个示例应用中有两个点,所以无论何时使用这些点做什么,都必须小心不要影响到正确的点。

当两个点都接收到 ACTION_DRAG_STARTED 动作时,只有一个点应该将自己的颜色设置为洋红色。要弄清楚哪一个是正确的,请将传入的本地状态对象与您自己进行比较。如果您回顾设置本地状态对象的位置,您将当前视图传递给了。现在,当您接收到本地状态对象时,您将它与您自己进行比较,看看您是否是启动拖动序列的视图。

如果您不同意这种观点,您可以向 LogCat 写一条日志消息,说明这不适合您,然后返回 false 说明您没有处理这条消息。

如果您是应该接收这个拖动事件的视图,那么您从拖动事件中收集一些值,然后您只需将该事件记录到 LogCat 中。第一个例外是 ACTION_DRAG_STARTED 。如果你得到了这个动作,并且是给你的,那么你就知道你的点已经开始了一个拖动序列。因此,您设置了 inDrag boolean,以便稍后的 draw() 方法会做正确的事情并显示不同颜色的点。这种不同的颜色只持续到收到 ACTION_DRAG_ENDED 为止,这时你就恢复了点的原始颜色。

如果一个点得到了 ACTION_DROP 动作,这意味着用户试图将一个点放到一个点上——甚至可能是原来的点。这不应该做任何事情,所以你只要从这个回调函数中返回 false 就可以了。

最后,您的自定义视图的 draw() 方法计算出您的圆(点)的中心点的位置,然后用适当的颜料画出来。 invalidate() 方法 告诉 Android 你已经修改了视图,Android 应该重新绘制用户界面。通过调用 invalidate() ,您可以确保用户界面会很快被新内容更新。

现在,您已经拥有了编译和部署这个示例拖放应用所需的所有文件和背景。

测试示例拖放应用

下面是我们运行这个示例应用时 LogCat 的一些示例输出。注意日志消息如何使用蓝点 来表示来自蓝点的消息,白点 表示来自白点的消息, DropTarget 表示允许放置的视图。

White dot:  starting drag? true
Blue dot:   This drag event is not for us
White dot:  drag started. X: 53.0, Y: 206.0
DropTarget: drag started in dropTarget
DropTarget: drag entered dropTarget
DropTarget: drag proceeding in dropTarget: 29.0, 36.0
DropTarget: drag proceeding in dropTarget: 48.0, 39.0
DropTarget: drag proceeding in dropTarget: 45.0, 39.0
DropTarget: drag proceeding in dropTarget: 41.0, 39.0
DropTarget: drag proceeding in dropTarget: 40.0, 39.0
DropTarget: drag drop in dropTarget
DropTarget: Item data is White dot
ViewRoot:   Reporting drop result: true
White dot:  drag ended. Success? true
Blue dot:   This drag event is not for us
DropTarget: drag ended in dropTarget

在这个特殊的例子中,拖动是从白点开始的。一旦长点击触发了拖动序列的开始,我们就会得到开始拖动的消息。

请注意,接下来的三行都表示在三个不同的视图中收到了一个 ACTION_DRAG_STARTED 动作。蓝点确定回调不适合它。这也不是为了的 DropTarget 。

接下来,注意拖动进行消息如何显示通过 DropTarget 发生的拖动,从 ACTION _ DRAG _ enter 动作开始。这意味着圆点被拖到了绿色方块的顶部。拖动事件对象中报告的 x 和 y 坐标是拖动点相对于视图左上角的坐标。因此,在示例应用中,拖放目标中的第一条拖动记录位于(x,y) = (29,36),拖放发生在(40,39)。看看 drop target 是如何从事件的 ClipData 中提取白点的标签名并将其写入 LogCat 的。

同样,看看所有的拖动监听器是如何接收到 ACTION_DRAG_ENDED 动作的。只有白点确定可以使用 getResult() 显示结果。

请随意试验这个示例应用。将一个点拖到另一个点,甚至拖到它本身。继续添加另一个点到 palette.xml 。请注意,当拖动的圆点离开绿色方块时,会有一条消息提示拖动已退出。还要注意的是,如果你把一个点放到绿色方块以外的地方,那么这个点就被认为是失败的。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

  • :与本书相关的可下载项目列表。对于这一章,寻找一个名为 pro Android 5 _ Ch23 _ dragndrop . zip 的 zip 文件。这个 zip 文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何将项目从这些 zip 文件之一导入到您的 IDE 中。
  • 【http://developer.android.com/guide/topics/ui/drag-drop.html】:Android 开发者拖拽指南。

摘要

让我们总结一下本章涉及的主题:

  • Android 3.0 中的拖放支持,并在 3.0 之前使用其他方法实现它
  • 遍历可能的放置目标,查看是否发生了放置(即手指在拖动后离开屏幕)
  • 计算跟踪拖动对象的位置以及它是否在放置目标上的难度
  • Android 3.0+中的拖放支持,这要好得多,因为它消除了许多猜测
  • 拖动侦听器,它可以是任何对象,不需要是可拖动对象或拖放目标视图
  • 拖动可以发生在片段之间的事实
  • DragEvent 对象,它可以包含大量关于什么被拖动以及为什么被拖动的信息
  • Android 如何利用数学来确定视图顶部是否发生了拖放

二十四、使用传感器

Android 设备通常带有内置的硬件传感器,Android 提供了一个与这些传感器一起工作的框架。传感器很有趣。测量外部世界并在设备的软件中使用它是非常酷的。这种编程体验是你在桌子上或服务器机房里的普通电脑上无法获得的。使用传感器的新应用的可能性是巨大的,我们希望你受到启发来实现它们。

在这一章中,我们将探索 Android 传感器框架。我们将解释什么是传感器以及我们如何获得传感器数据,然后讨论我们可以从传感器获得的数据类型的一些细节以及我们可以用这些数据做什么。虽然 Android 已经定义了几种传感器类型,但毫无疑问,Android 的未来会有更多的传感器,我们预计未来的传感器将被纳入传感器框架。

什么是传感器?

在 Android 中, 传感器是来自物理世界的数据事件的来源。这通常是一个已经连接到设备中的硬件,但 Android 也提供了一些逻辑传感器,可以将来自多个物理传感器的数据结合起来。反过来,应用使用传感器数据来通知用户关于物理世界的信息,控制游戏,进行增强现实,或者为在现实世界中工作提供有用的工具。传感器只在一个方向上工作;它们是只读的。这使得使用它们相当简单。您设置了一个侦听器来接收传感器数据,然后在数据到达时对其进行处理。GPS 硬件就像我们在本章中讨论的传感器。在第十九章的中,我们为 GPS 位置更新设置了监听器,当这些位置更新进来时,我们就进行处理。虽然 GPS 类似于传感器,但它不是 Android 提供的传感器框架的一部分。

Android 设备中可能出现的一些传感器类型包括

  • 光敏感元件
  • 近程传感器
  • 温度传感器
  • 压力传感器
  • 陀螺仪传感器
  • 加速计
  • 磁场传感器
  • 重力传感器
  • 线性加速度传感器
  • 旋转矢量传感器
  • 相对湿度传感器

检测传感器

请不要假设,然而,所有的 Android 设备都有所有这些传感器。事实上,许多设备只有其中一些传感器。例如,Android 模拟器只有一个加速度计。那么,如何知道设备上有哪些传感器可用呢?有两种方式,一种直接,一种间接。

第一种方法是向 SensorManager 请求可用传感器的列表。它将响应一个传感器对象列表,然后您可以为其设置监听器并从中获取数据。我们将在本章的稍后部分向您展示如何操作。这种方法假设用户已经将您的应用安装到一个设备上,但是如果这个设备没有您的应用需要的传感器呢?

这就是第二种方法出现的原因。在 AndroidManifest.xml 文件中,您可以指定设备必须具备的特性,以便正确支持您的应用。如果您的应用需要一个近程传感器,您可以在清单文件中用如下所示的行来指定:

<uses-feature android:name="android.hardware.sensor.proximity" />

谷歌 Play 商店只会将您的应用安装在具有接近传感器的设备上,因此当您的应用运行时,您会知道它就在那里。对于所有其他 Android 应用商店来说,情况就不一样了。也就是说,一些 Android 应用商店不会进行这种检查,以确保你的应用只能安装在支持你指定的传感器的设备上。

关于传感器我们能知道什么?

虽然在清单文件中使用 uses-feature 标记可以让您知道您的应用需要的传感器存在于设备上,但它并不能告诉您您可能想知道的关于实际传感器的所有信息。让我们构建一个简单的应用,向设备查询传感器信息。清单 24-1 显示了我们的主活动的 Java 代码。

注意

你可以下载本章的项目。我们会在本章末尾给你网址。这将允许您将这些项目直接导入到 IDE 中。

清单 24-1 。??【传感器列表 App】Java

public class MainActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        TextView text = (TextView)findViewById(R.id.text);

        SensorManager mgr =
            (SensorManager) this.getSystemService(SENSOR_SERVICE);

        List<Sensor> sensors = mgr.getSensorList(Sensor.TYPE_ALL);

        StringBuilder message = new StringBuilder(2048);
        message.append("The sensors on this device are:\n");

        for(Sensor sensor : sensors) {
            message.append(sensor.getName() + "\n");
            message.append("  Type: " +
                    sensorTypes.get(sensor.getType()) + "\n");
            message.append("  Vendor: " +
                    sensor.getVendor() + "\n");
            message.append("  Version: " +
                    sensor.getVersion() + "\n");
            try {
                message.append("  Min Delay: " +
                    sensor.getMinDelay() + "\n");
            } catch(NoSuchMethodError e) {} // ignore if not found
            try {
                message.append("  FIFO Max Event Count: " +
                    sensor.getFifoMaxEventCount() + "\n");
            } catch(NoSuchMethodError e) {} // ignore if not found
            message.append("  Resolution: " +
                    sensor.getResolution() + "\n");
            message.append("  Max Range: " +
                    sensor.getMaximumRange() + "\n");
            message.append("  Power: " +
                    sensor.getPower() + " mA\n");
        }
        text.setText(message);
    }

    private HashMap<Integer, String> sensorTypes =
                      new HashMap<Integer, String>();

    {
      sensorTypes.put(Sensor.TYPE_ACCELEROMETER, "TYPE_ACCELEROMETER");
      sensorTypes.put(Sensor.TYPE_AMBIENT_TEMPERATURE,
                            "TYPE_AMBIENT_TEMPERATURE");
      /* ... the rest is omitted to save space ... */
    }
}

在我们的 onCreate() 方法中,我们首先获取对 SensorManager 的引用。其中只能有一个,所以我们将其作为系统服务进行检索。然后我们调用它的 getSensorList() 方法来获取传感器列表。对于每一个传感器,我们写下它的信息。输出将类似于图 24-1 。

9781430246800_Fig24-01.jpg

图 24-1 。从我们的传感器列表应用输出

关于此传感器信息,需要了解一些事情。类型值告诉您传感器的基本类型,但没有具体说明。光传感器就是光传感器,但不同设备的光传感器可能会有所不同。例如,一个设备上的光传感器的分辨率可能与另一个设备上的不同。当你在 < uses-feature > 标签中指定你的应用需要一个光传感器时,你并不知道你将会得到什么类型的光传感器。如果这对您的应用有影响,您将需要查询设备来找出并相应地调整您的代码。

您获得的分辨率和最大范围值将采用该传感器的相应单位。功率测量以毫安(mA)为单位,代表传感器从设备电池中汲取的电流;越小越好。

现在我们知道了我们可以使用哪些传感器,我们如何从它们那里获取数据呢?正如我们前面所解释的,我们设置了一个监听器来获取发送给我们的传感器数据。现在让我们来探索一下。

获取传感器事件

一旦我们注册了一个侦听器来接收数据,传感器就会向我们的应用提供数据。当我们的听众不听时,传感器可以关闭,节省电池寿命,所以请确保您只在真正需要的时候听。设置传感器监听器很容易做到。假设我们想要测量光传感器的亮度。清单 24-2 显示了实现这一功能的示例应用的 Java 代码。

清单 24-2 。 一款光传感器监控 App 的 Java 代码

public class MainActivity extends Activity implements SensorEventListener {
    private SensorManager mgr;
    private Sensor light;
    private TextView text;
    private StringBuilder msg = new StringBuilder(2048);

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        mgr = (SensorManager) this.getSystemService(SENSOR_SERVICE);
        light = mgr.getDefaultSensor(Sensor.TYPE_LIGHT);
        text = (TextView) findViewById(R.id.text);
    }

    @Override
    protected void onResume() {
        mgr.registerListener(this, light,
                SensorManager.SENSOR_DELAY_NORMAL);
        super.onResume();
    }

    @Override
    protected void onPause() {
        mgr.unregisterListener(this, light);
        super.onPause();
    }

    public void onAccuracyChanged(Sensor sensor, int accuracy) {
        msg.insert(0, sensor.getName() + " accuracy changed: " +
            accuracy + (accuracy==1?" (LOW)":(accuracy==2?" (MED)":
            " (HIGH)")) + "\n");
        text.setText(msg);
        text.invalidate();
    }

    public void onSensorChanged(SensorEvent event) {
        msg.insert(0, "Got a sensor event: " + event.values[0] +
        " SI lux units\n");
        text.setText(msg);
        text.invalidate();
    }
}

在这个示例应用中,我们再次获得对 SensorManager 的引用,但是我们没有获得传感器列表,而是专门查询光传感器。然后,我们在活动的 onResume() 方法中设置了一个监听器,并在 onPause() 方法中注销了这个监听器。当我们的应用不在前台时,我们不想担心光线水平。

对于 registerListener() 方法,我们传入一个值,表示我们希望多久收到一次传感器值变化的通知。该参数可以是

  • 传感器 _ 延迟 _ 正常(代表 200,000 微秒延迟)
  • SENSOR_DELAY_UI(代表 60000 微秒延迟)
  • 传感器 _ 延迟 _ 游戏(代表 20000 微秒延迟)
  • 传感器 _ 延迟 _ 最快(代表尽可能快)

您还可以使用其他 registerListener 方法之一指定特定的微秒延迟,只要它大于 3 微秒;然而,任何低于 20,000 英镑的都不太可能兑现。为该参数选择合适的值很重要。有些传感器非常敏感,会在短时间内产生大量事件。如果您选择 SENSOR _ DELAY _ fast,您甚至可能会超出应用的处理能力。根据您的应用对每个传感器事件的处理,您可能会在内存中创建和销毁太多对象,以至于垃圾收集会导致设备明显变慢和中断。另一方面,某些传感器非常需要尽可能频繁地被读取;这尤其适用于旋转矢量传感器。另外,不要依赖这个参数来生成精确定时的事件。事件可能会来得快一点或慢一点。

因为我们的活动实现了 SensorEventListener 接口,所以我们有两个针对传感器事件的回调: onAccuracyChanged() 和 onSensorChanged() 。第一种方法将让我们知道我们的传感器(或多个传感器,因为它可以被调用多个传感器)的精度是否改变。精度参数的值将为传感器状态不可靠、传感器状态精度低、传感器状态精度中或传感器状态精度高。精度不可靠不代表设备坏了;这通常意味着传感器需要校准。第二个回调方法告诉我们光线水平何时改变,我们得到一个 SensorEvent 对象来告诉我们来自传感器的新值的细节。

一个 SensorEvent 对象有几个成员,其中一个是一个由 float 值组成的数组。对于光传感器事件,只有第一个 float 值有意义,它是传感器检测到的光的 SI lux 值。对于我们的示例应用,我们通过在旧消息的顶部插入新消息来构建消息字符串,然后我们在一个 TextView 中显示这批消息。我们最新的传感器值将始终显示在屏幕顶部。

当您运行这个应用时(当然是在真实的设备上,因为模拟器没有光传感器),您可能会注意到最初什么也没有显示。只需更改设备左上角的指示灯。这很可能是你的光传感器所在的位置。如果你仔细看,你可能会看到屏幕后面的那个点,那就是光传感器。如果你用手指盖住这个点,光级很可能会变成一个很小的值(虽然可能不会达到零)。信息应该显示在屏幕上,告诉你光线水平的变化。

注意

您可能还会注意到,当光传感器被盖住时,您的按钮会亮起(如果您的设备带有发光按钮)。这是因为 Android 已经检测到了黑暗,并点亮了按钮,使设备在“黑暗中”更容易使用。

获取传感器数据的问题

Android 传感器框架有一些你需要注意的问题。这是不好玩的部分。在某些情况下,我们有办法解决问题;在其他地方我们没有,或者很难。

无法直接访问传感器值

您可能已经注意到,没有直接的方法来查询传感器的当前值。从传感器获取数据的唯一方法是通过监听器。有两种传感器:流式传感器和非流式传感器。流式传感器 s 将定期发送数值,例如加速度计。方法调用 getMinDelay() 将为流式传感器返回一个非零值,告诉您传感器用来感知环境的最小微秒数。对于非流式传感器来说,返回值是零,所以即使你设置了监听器,也不能保证你会在设定的时间内得到新的数据。至少回调是异步的,所以不会阻塞 UI 线程等待来自传感器的数据。然而,您的应用必须适应这样一个事实,即传感器数据可能在您需要的时候不可用。重温图 24-1 ,你会注意到光传感器是不流动的。因此,只有当光线水平发生变化时,您的应用才会获得一个事件。对于所示的其他传感器,事件之间的延迟最小为 20 毫秒,但也可能更长。

可以使用本机代码和 Android 的 JNI 功能直接访问传感器。您需要了解感兴趣的传感器驱动程序的底层本地 API 调用,并且能够设置回 Android 的接口。所以可以做到,但是不容易。

传感器值发送不够快

即使在 SENSOR _ DELAY _ fast 下,您可能也不会每隔 20 ms 获得一次新值(这取决于设备和传感器)。如果您需要比使用 SENSOR _ DELAY _ fast 的速率设置所能获得的更快的传感器数据,可以使用本地代码和 JNI 来更快地获得传感器数据,但与前面的情况类似,这并不容易。

传感器随屏幕关闭

Android 2.x 中出现了传感器更新在屏幕关闭时关闭的问题。显然有人认为,如果屏幕关闭,不发送传感器更新是一个好主意,即使你的应用(很可能使用服务)有一个唤醒锁。基本上,当屏幕关闭时,你的监听器会被注销。

这个问题有几种解决方法。有关此问题以及可能的解决方案和变通办法的更多信息,请参考 Android 问题 11028:

[`code.google.com/p/android/issues/detail?id=11028`](http://code.google.com/p/android/issues/detail?id=11028)

现在你知道了如何从传感器获取数据,你能用这些数据做什么?如前所述,根据从哪个传感器获取数据,values 数组中返回的值有不同的含义。下一节将探讨每种传感器类型及其值的含义。

解释传感器数据

现在,您已经了解了如何从传感器获取数据,您将希望对这些数据做一些有意义的事情。然而,您获得的数据将取决于您从哪个传感器获得数据。有些传感器比其他传感器简单。在接下来的部分中,我们将描述您将从我们目前了解的传感器中获得的数据。随着新设备的出现,新的传感器无疑也会被引入。传感器框架很可能保持不变,因此我们在这里展示的技术应该同样适用于新的传感器。

光传感器

光传感器 是设备上最简单的传感器之一,也是本章第一个示例应用中使用的传感器。传感器给出由设备的光传感器检测到的光水平的读数。随着光线水平的变化,传感器读数也会变化。数据的单位是 SI lux 单位。要了解这意味着什么,请参阅本章末尾的“参考资料”部分,获取更多信息的链接。

对于 SensorEvent 对象中的值数组,光线传感器只使用第一个元素 values[0] 。该值是一个浮动值,从技术上讲,范围从 0 到特定传感器的最大值。我们说在技术上是,因为传感器在没有光线的时候可能只发送非常小的值,而从来不会实际发送值 0 。

还要记住,传感器可以告诉我们它可以返回的最大值,不同的传感器可以有不同的最大值。出于这个原因,在 SensorManager 类中考虑与光线相关的常数可能没有用。比如 SensorManager 有一个常量叫做 LIGHT_SUNLIGHT_MAX ,是一个 float 值 12 万;然而,当我们之前查询我们的设备时,返回的最大值是 10,240,显然比这个常量值小得多。还有一个叫 LIGHT_SHADE 在 20000,也在我们测试的设备最大值以上。因此,在编写使用光传感器数据的代码时,请记住这一点。

接近传感器

接近传感器 要么测量某个物体距离设备的距离(以厘米为单位),要么代表一个标志来表示物体是近还是远。一些接近传感器将给出范围从 0.0 到最大值的增量值,而其他传感器返回 0.0 或仅返回最大值。如果接近传感器的最大范围等于传感器的分辨率,那么你知道它是只返回 0.0 或最大值的传感器之一。有最大值为 1.0 的设备,也有最大值为 6.0 的设备。不幸的是,在应用安装和运行之前,没有办法知道你将得到哪个接近传感器。即使您在接近传感器的 AndroidManifest.xml 文件中放置了一个 < uses-feature > 标签,您也可以获得任何一种标签。除非您绝对需要更细粒度的接近传感器,否则您的应用应该很好地适应这两种类型。

关于接近传感器有一个有趣的事实:接近传感器有时和光传感器是同一个硬件。然而,Android 仍然将它们视为逻辑上独立的传感器,所以如果您需要来自两者的数据,您将需要为每一个设置一个监听器。这里有另一个有趣的事实:接近传感器通常用于手机应用,以检测设备旁边是否有人的头部。如果头部离触摸屏如此之近,触摸屏将被禁用,因此当人在打电话时,耳朵或脸颊不会意外按下任何按键。

本章的源代码项目包括一个简单的近程传感器监视器应用,它基本上是光传感器监视器应用,修改后使用近程传感器而不是光传感器。我们不会在本章中包含代码,但是您可以自己尝试。

温度传感器

旧的不推荐使用的温度传感器 (TYPE_TEMPERATURE)提供温度读数,并且在值【0】中只返回一个值。该传感器通常读取内部温度,例如电池的温度。有一种新型温度传感器叫做 TYPE _ AMBIENT _ TEMPERATURE。新值表示设备外部的温度,单位为摄氏度。

温度传感器的位置取决于器件,温度读数可能会受到器件本身产生的热量的影响。本章的项目包括一个名为温度传感器的温度传感器。它负责根据运行的 Android 版本调用正确的温度传感器。

压力传感器 s

该传感器测量大气压力,例如可以检测海拔高度或用于天气预测。该传感器不应与触摸屏产生具有压力值(触摸压力)的运动事件的能力相混淆。我们在第二十二章中讨论过这种触摸类型的压力感应。触摸屏压力感测不使用 Android 传感器框架。

压力传感器的测量单位为大气压力,单位为 hPa(毫巴),该测量值以值[0] 表示。

陀螺仪传感器

陀螺仪 是非常酷的组件,可以测量设备相对于参考系的扭曲度。换句话说,陀螺仪测量绕轴旋转的速率。当设备不旋转时,传感器值将为零。当有任何方向的旋转时,你会从陀螺仪得到非零值。陀螺仪常用于导航。但是陀螺仪本身并不能告诉你导航所需要知道的一切。不幸的是,随着时间的推移,错误会慢慢出现。但是再加上加速度计,就可以确定设备的运动路径。

卡尔曼滤波器可用于将来自两个传感器的数据联系在一起。加速度计在短期内不是非常精确,陀螺仪在长期内也不是非常精确,所以结合起来,它们可以一直相当精确。虽然卡尔曼滤波器非常复杂,但有一种替代方法叫做互补滤波器 ,它更容易在代码中实现,并且产生非常好的结果。这些概念超出了本书的范围。

陀螺仪传感器返回 x、y 和 z 轴的值数组中的三个值。单位是弧度/秒,数值代表绕每个轴的旋转速率。处理这些值的一种方法是对它们进行时间积分,以计算角度变化。这种计算类似于将线速度与时间相结合来计算距离。

加速计

加速度计可能是设备上使用最多的传感器。使用这些传感器,您的应用可以确定设备在空间中相对于重力直接下拉的物理方向,并了解作用在设备上的力。提供这些信息允许应用做各种有趣的事情,从玩游戏到增强现实。当然,加速度计会告诉 Android 何时将用户界面的方向从纵向切换到横向,然后再切换回来。

加速度计坐标系是这样工作的:加速度计的 x 轴从设备的左下角开始,穿过底部到右边。y 轴也从左下角开始,沿着显示屏的左侧向上。z 轴从左下角开始,在远离设备的空间中向上延伸。图 24-2 显示了这意味着什么。

9781430246800_Fig24-02.jpg

图 24-2 。加速度计坐标系

这个坐标系不同于布局和 2D 图形中使用的坐标系。在这个坐标系中,原点(0,0)在左上角,y 在屏幕下方是正的。在处理不同参照系的坐标系时很容易混淆,所以要小心。

我们还没有说加速度计的值意味着什么,那么它们意味着什么呢?加速度的单位是米每秒平方(米/秒 2 )。正常的地球重力是 9.81 米/秒 2 ,向地心拉下。从加速度计的角度来看,重力测量值为–9.81。如果您的设备完全静止(不动)并且位于一个非常平坦的表面上,x 和 y 读数将为 0,z 读数将为+9.81。实际上,由于加速度计的灵敏度和精度,这些值不会完全相同,但它们会很接近。当设备静止时,重力是唯一作用在设备上的力,因为重力直接向下拉动,如果我们的设备完全平坦,它对 x 轴和 y 轴的影响为零。在 z 轴上,加速度计测量设备上的力减去重力。因此,0 减–9.81 等于+9.81,这就是 z 值(在 SensorEvent 对象中又称为 values【2】)。

加速度计发送给应用的值始终代表设备上的力减去重力的总和。如果你拿起一个非常平的设备,把它垂直向上举起,z 值一开始会增加,因为你增加了向上(z)方向的力。一旦你的提升力停止,总的力就会回到重力状态。如果该设备被摔落(假设——请不要这样做),它将朝着地面加速,这将使重力为零,因此加速度计将读取 0 力。

让我们从图 24-2 中取出设备,并向上旋转,使其处于纵向模式和垂直状态。x 轴也是一样,从左指向右。我们的 y 轴现在是直上直下的,z 轴指向屏幕外面直对着我们。y 值将为+9.81,x 和 z 都将为 0。

当你将设备旋转到横向模式并继续垂直拿着它,屏幕就在你的面前时会发生什么?如果你猜 y 和 z 现在是 0,x 是+9.81,那你就对了。图 24-3 显示了它可能的样子。

9781430246800_Fig24-03.jpg

图 24-3 。横向垂直加速计数值

当设备不移动或以恒定速度移动时,加速度计仅测量重力。在每个轴上,加速度计的值是重力在那个轴上的分量。因此,使用一些三角学,你可以计算出角度,并知道设备相对于重力的方向。也就是说,你可以知道设备是处于纵向模式还是横向模式,或者处于某种倾斜模式。事实上,这正是 Android 所做的,以确定使用哪种显示模式(纵向或横向)。然而,请注意,加速度计并没有说明设备如何相对于磁北定向。因此,虽然你可以知道设备是在横向模式下垂直放置的,但你不知道你是面向东还是面向西或者两者之间的任何地方。这就是磁场传感器的用武之地,我们将在后面的章节中讨论。

加速度计和显示方向

设备中的加速度计是硬件,它们牢固地连接在一起,因此相对于设备具有特定的方向,不会随着设备的转动而改变。加速度计发送到 Android 的值当然会随着设备的移动而改变,但加速度计的坐标系相对于物理设备保持不变。然而,当用户从纵向到横向再返回时,显示器的坐标系会改变。事实上,根据屏幕转动的方式,纵向可以是正面朝上,也可以是 180 度倒置。类似地,风景可以是相隔 180 度的两个不同旋转之一。

当您的应用正在读取加速度计数据并希望正确影响用户界面时,您的应用必须知道显示器发生了多大的旋转,才能进行适当的补偿。当您的屏幕从纵向重新定向为横向时,屏幕的坐标系相对于加速度计的坐标系发生了旋转。要处理这一点,您的应用必须使用方法 display . get rotation()。返回值是一个简单的整数,但不是旋转的实际度数。该值将是面中的一个。、面旋转 _0。旋转 _90 ,面。ROTATION_180 或面。旋转 _270 。这些常量的值分别为 0 、 1 、 2 和 3 。这个返回值告诉你显示器从设备的“正常”方向旋转了多少。因为不是所有的 Android 设备都正常处于人像模式,所以你不能假设人像处于 ROTATION_0 。

加速度计和重力

到目前为止,我们只是简单地谈到了当设备移动时加速度计值会发生什么变化。让我们进一步探讨这个问题。加速度计将检测到作用在设备上的所有力。如果抬起设备,初始升力在 z 方向为正,您会得到一个大于+9.81 的 z 值。如果你把设备推到左边,你会在 x 方向得到一个初始的负读数。

你想要做的是,把重力和作用在设备上的其他力分开。有一种相当简单的方法可以做到这一点,它被称为低通滤波器 。除了重力之外,作用在该装置上的力通常不是渐进的。换句话说,如果用户正在摇动设备,则摇动力会迅速反映在加速度计值中。低通滤波器将有效地去除振动力,只留下稳定的力,即重力。让我们用一个示例应用来说明这个概念。它叫做重力演示。清单 24-3 显示了 Java 代码。

清单 24-3 。 用加速度计测量重力

// This file is MainActivity.java
public class MainActivity extends Activity implements SensorEventListener {
    private SensorManager mgr;
    private Sensor accelerometer;
    private TextView text;
    private float[] gravity = new float[3];
    private float[] motion = new float[3];
    private double ratio;
    private double mAngle;
    private int counter = 0;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        mgr = (SensorManager) this.getSystemService(SENSOR_SERVICE);
        accelerometer = mgr.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
        text = (TextView) findViewById(R.id.text);
    }

    @Override
    protected void onResume() {
        mgr.registerListener(this, accelerometer,
        SensorManager.SENSOR_DELAY_UI);
        super.onResume();
    }

    @Override
    protected void onPause() {
        mgr.unregisterListener(this, accelerometer);
        super.onPause();
    }

    public void onAccuracyChanged(Sensor sensor, int accuracy) {
        // ignore
    }

    public void onSensorChanged(SensorEvent event) {
        // Use a low-pass filter to get gravity.
        // Motion is what's left over
        for(int i=0; i<3; i++) {
            gravity [i] = (float) (0.1 * event.values[i] +
                                   0.9 * gravity[i]);
            motion[i] = event.values[i] - gravity[i];
        }

        // ratio is gravity on the Y axis compared to full gravity
        // should be no more than 1, no less than -1
        ratio = gravity[1]/SensorManager.GRAVITY_EARTH;
        if(ratio > 1.0) ratio = 1.0;
        if(ratio < -1.0) ratio = -1.0;

        // convert radians to degrees, make negative if facing up
        mAngle = Math.toDegrees(Math.acos(ratio));
        if(gravity[2] < 0) {
            mAngle = -mAngle;
        }

        // Display every 10th value
        if(counter++ % 10 == 0) {
            String msg = String.format(
                "Raw values\nX: %8.4f\nY: %8.4f\nZ: %8.4f\n" +
                "Gravity\nX: %8.4f\nY: %8.4f\nZ: %8.4f\n" +
                "Motion\nX: %8.4f\nY: %8.4f\nZ: %8.4f\nAngle: %8.1f",
                event.values[0], event.values[1], event.values[2],
                gravity[0], gravity[1], gravity[2],
                motion[0], motion[1], motion[2],
                mAngle);
            text.setText(msg);
            text.invalidate();
            counter=1;
        }
    }
}

运行该应用的结果是一个类似于图 24-4 的显示。这张截图是在设备平放在桌子上时拍摄的。

9781430246800_Fig24-04.jpg

图 24-4 。重力、运动和角度值

这个示例应用的大部分与之前的 Accel 传感器应用相同。不同之处在于 onSensorChanged() 方法。我们试图跟踪重力和运动,而不是简单地显示事件数组中的值。通过只使用事件数组中的一小部分新值和重力数组中以前值的大部分来获得重力。所用的两部分之和必须是 1.0。我们用了 0.9 和 0.1。您也可以尝试其他值,例如 0.8 和 0.2。我们的重力阵列不可能像实际传感器值那样快速变化。但这更接近现实。这就是低通滤波器的作用。只有当力导致设备移动时,事件数组值才会改变,并且您不希望将这些力作为重力的一部分来测量。你只想在你的重力数组中记录重力本身。这里的数学并不意味着您神奇地只记录重力,但是您计算的值将会比事件数组中的原始值更接近。

还要注意代码中的运动数组。通过跟踪原始事件数组值和计算出的重力值之间的差异,您基本上是在测量运动数组中设备上的主动力、非重力。如果运动数组中的值为零或非常接近零,这意味着设备可能没有移动。这是有用的信息。从技术上讲,以恒定速度移动的设备在运动数组中也具有接近于零的值,但是实际情况是,如果用户正在移动设备,运动值将稍微大于零。用户不可能以完美的恒定速度移动设备。

最后,请注意这个例子没有产生需要垃圾收集的新对象。在处理传感器事件时,不创建新对象是非常重要的;否则,您的应用将花费太多时间暂停垃圾收集周期。

使用加速度计测量设备的角度

在我们继续之前,我们想再向您展示一个关于加速度计的东西。如果我们回顾三角学的课程,我们会记得角的余弦是近边和斜边的比值。如果我们考虑 y 轴和重力本身之间的角度,我们可以测量重力在 y 轴上的力,然后取反余弦来确定角度。我们在这段代码中也做到了这一点,尽管这里我们不得不再次处理 Android 中一些混乱的传感器。在 SensorManager 中有不同重力常数的常数,包括地球的。但是您的实际测量值可能会超过定义的常数。接下来我们会解释这是什么意思。

理论上,你的设备在静止状态下会测得一个与恒定值相等的重力值,但这种情况很少发生。静止时,加速度计传感器很可能会给我们一个大于或小于常数的重力值。因此,我们的比率最终可能会大于 1,或者小于负 1。这会让 acos() 方法抱怨,所以我们将比率值固定为不大于 1 且不小于-1。相应的角度范围从 0 度到 180 度。这很好,除了这样我们不会得到从 0 到–180 的负角度。为了获得负角度,我们使用重力数组中的另一个值,即 z 值。如果重力的 z 值为负,则意味着设备的正面朝下。对于所有那些设备面朝下的值,我们也使我们的角度为负,结果是我们的角度从–180 到+180,正如我们所预期的。

继续尝试这个示例应用。请注意,当设备平放时,角度值为 90 °,当设备在我们面前上下垂直放置时,角度值为零(或接近于 90°)。如果我们继续向下旋转越过 flat,我们会看到角度值超过 90°。如果我们将设备从 0°位置向上倾斜更多,角度值将变为负值,直到我们将设备举过头顶,角度值为–90°。最后,您可能已经注意到了我们的计数器,它控制显示更新的频率。因为传感器事件可能会非常频繁地出现,所以我们决定每十次获取值时才显示一次。

磁场传感器

磁场传感器测量 x、y 和 z 轴上的环境磁场。该坐标系就像加速度计一样对齐,因此 x、y 和 z 如图 24-2 中的所示。磁场传感器的单位是微特斯拉(uT)。这个传感器可以探测地球磁场,从而告诉我们北极在哪里。这个传感器也被称为指南针 ,实际上 < uses-feature > 标签使用 Android . hardware . sensor . compass 作为这个传感器的名称。由于这种传感器非常微小和敏感,它会受到设备附近物体产生的磁场的影响,甚至在某种程度上影响设备内的组件。因此,磁场传感器的准确性有时会受到怀疑。

我们在网站的下载部分包含了一个简单的 CompassSensor 应用,所以可以随意导入并使用它。如果您在此应用运行时将金属物体放在设备附近,您可能会注意到相应的数值变化。当然,如果你把一块磁铁靠近设备,你会看到数值发生变化。事实上,谷歌 Cardboard“设备”在一个物理按钮下使用了一块磁铁,当按钮被按下时,手机会检测到磁场的变化。

你可能会问,我可以用指南针传感器作为指南针来检测北在哪里吗?答案是:它本身不会。虽然指南针传感器可以检测设备周围的磁场,但如果设备没有完全平放在地球表面,您将无法正确解读指南针传感器的值。但是你有加速度计可以告诉你设备相对于地球表面的方向!因此,您可以从指南针传感器创建一个指南针,但您也需要加速度计的帮助。让我们来看看如何做到这一点。

一起使用加速度计和磁场传感器

SensorManager 提供了一些方法,允许我们结合指南针传感器和加速度计来计算方向。正如我们刚刚讨论的,您不能只使用指南针传感器来完成这项工作。因此 SensorManager 提供了一个名为 getRotationMatrix() 的方法,该方法从加速度计和指南针获取值,并返回一个可用于确定方向的矩阵。

另一个 SensorManager 方法, getOrientation() ,采用前一步的旋转矩阵,给出一个方向矩阵。方向矩阵中的值告诉您设备相对于地球磁北极的旋转,以及设备相对于地面的俯仰和滚动。

磁偏角和地磁场

关于方向和设备,我们还想讨论另一个话题。指南针传感器会告诉你磁北在哪里,但不会告诉你真北在哪里(也叫地理北)。想象你正站在磁北极和地理北极的中点。他们会相差 180 度。离两个北极越远,这个角度差就越小。磁北与真北的角度差称为磁偏角 。而且这个值只能相对于行星表面上的一个点来计算。也就是说,你必须知道你站在哪里,才能知道地理北极相对于磁北极的位置。幸运的是,Android 有一种方法可以帮助你,那就是地磁领域 ?? 类。

为了实例化一个 GeomagneticField 类的对象,您需要传入一个纬度和经度。因此,为了得到磁偏角,你需要知道参考点在哪里。您还需要知道您想要该值的时间。磁北极随时间漂移。一旦实例化,您只需调用此方法来获取偏角(以度为单位):

float declinationAngle = geoMagField.getDeclination();

如果磁北在地理北的东面,则下倾角的值将为正。

重力传感器

这个传感器不是一个独立的硬件。这是一个基于加速度计的虚拟传感器。事实上,这种传感器使用的逻辑类似于我们之前描述的加速度计,以产生作用于设备的力的重力分量。然而,我们不能访问这个逻辑,所以无论在重力传感器类中使用什么因素和逻辑,我们都必须接受。不过,虚拟传感器可能会利用陀螺仪等其他硬件来帮助它更准确地计算重力。该传感器的值数组报告重力,就像加速计传感器报告其值一样。

线性加速度传感器

与重力传感器类似,线性加速度传感器 是一种虚拟传感器,代表加速度计力减去重力。同样,我们之前对加速度计传感器值进行了自己的计算,以去除重力,从而获得这些线性加速度力值。这个传感器让你更方便。它可以利用陀螺仪等其他硬件来帮助它更精确地计算线性加速度。值数组报告线性加速度,就像加速度计传感器报告其值一样。

旋转矢量传感器

旋转矢量传感器 代表设备在空间中的方位,相对于硬件加速度计的参考框架有一定角度(见图 24-2 )。该传感器返回一组表示单位四元数的最后三个分量的值。四元数是一个可以写满一本书的主题,所以我们不会在这里深入讨论。

谢天谢地,谷歌在 SensorManager 中提供了一些方法来帮助这个传感器。getquaternionfromvvector()方法将旋转矢量传感器输出转换为归一化四元数。getrotationmatrix from vector()方法将旋转矢量传感器输出转换为旋转矩阵,可与 getOrientation() 一起使用。但是,当将旋转矢量传感器输出转换为方向矢量时,您需要意识到它的范围是从–180 度到+180 度。

本章示例应用的 ZIP 文件包括一个版本的 VirtualJax ,它显示了正在使用的旋转矢量。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

  • :与本书相关的可下载项目列表。对于这一章,寻找一个名为 pro Android 5 _ Ch24 _ sensors . ZIP 的 ZIP 文件。该文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何将项目从这些 ZIP 文件之一导入到 IDE 中。
  • :维基百科词条勒克司,光的度量单位。
  • :来自 NOAA 的地磁信息。
  • :大卫·萨克斯关于加速度计、陀螺仪、指南针和 Android 开发的谷歌技术演讲。
  • stack overflow . com/questions/1586658/combine-gyroscope-and-accelerometer-data:stackoverflow.com 上一篇不错的帖子,谈到了组合陀螺仪和加速度计传感器数据以供应用。
  • en . Wikipedia . org/wiki/Quaternions _ and _ spatial _ rotation:关于四元数及其如何用于表示空间旋转的维基百科页面,例如 Android 设备。

摘要

在本章中,我们讨论了以下主题:

  • 安卓有哪些传感器。
  • 找出设备上的传感器。
  • 指定应用加载到 Android 设备之前所需的传感器。
  • 确定设备上传感器的属性。
  • 如何获取传感器事件?
  • 事实上,只要传感器值发生变化,事件就会发生,因此了解在获得第一个值之前可能会有一个滞后是很重要的。
  • 传感器更新的不同速度以及何时使用每种速度。
  • SensorEvent 的详细信息以及这些信息如何用于各种传感器类型。
  • 虚拟传感器,由来自其他传感器的数据组成。旋转矢量传感器就是其中之一。
  • 使用传感器确定设备的角度,并告知设备面向哪个方向。

二十五、探索 Android 持久性和内容供应器

在 Android SDK 中有许多保存状态的方法。其中包括 1)共享首选项,2)内部文件,3)外部文件,4) SQLite,5)内容供应器,6) O/R 映射工具,以及 7)云中的网络存储。我们将首先简要介绍每个状态保存选项,然后详细介绍使用 SQLite 和内容提供者管理应用状态。

使用共享偏好设置保存状态

我们已经在第十一章中介绍了共享偏好。共享首选项是应用拥有的基于键/值的 XML 文件。Android 在这个通用的持久化机制之上有一个框架来显示/更新/检索偏好,而无需编写大量代码。后一个方面是第十一章的主要话题。

第十一章还简要介绍了应用如何使用 XML 文件中的共享偏好 API 存储任何类型的数据。在这种方法中,数据首先被转换为字符串表示,然后存储在首选项键/值存储中。这种方法可以用来存储应用的任意状态,只要它是小到中等大小。

共享首选项 XML 文件是设备上应用的内部文件。其他应用不能直接使用这些数据。最终用户不能通过安装到 USB 端口上来直接操作这些数据。当应用被删除时,该数据被自动删除。

从简单到中等程度的应用持久性需求,您可以通过将各种 Java 对象树直接存储在共享首选项文件中来利用共享首选项。在给定的首选项文件中,可以有一个指向序列化 Java 对象树的关键点。您还可以为多个 Java 对象树使用多个首选项文件。我们已经使用 google 的 JSON/GSON 库非常有效地完成了从 Java 对象到它们的等效 JSON 字符串值的转换。在这种方法中,使用 google GSON 库将 Java 对象树作为 JSON 字符串进行流式传输。然后,该树作为一个值存储在首选项文件的键/值对中。请记住,Java 对象的 GSON 和 JSON 转换可能有一些限制。阅读 GSON/JSON 文档,了解 Java 对象可以变得多么复杂,才能使这种方法发挥作用。我们相当确信,对于大多数基于数据的 Java 对象来说,这是可行的。

清单 25-1 给出了一些使用 GSON/JSON 和共享参数保存 Java 树的示例代码。

清单 25-1 。使用 JSON 在共享首选项 XML 文件中保存 Java 对象树

//Implementation of storeJSON for storing any object
public void storeJSON(Context context, Object anyObject) {

    //Get a GSON instance
    Gson gson = new Gson();

    //Convert Java object to a JSON string
    String jsonString = gson.toJson(anyObject);

    //See Chapter 11 for more details on how to get a shared preferences reference
    String filename = "somefilename.xml";
    int mode = Context.MODE_PRIVATE;
    SharedPreferences sp = context.getSharedPreferences(filename,mode);

    //Save the JSON string in the shared preferences
    SharedPreferences.Editor spe = sp.edit();
    spe.putString("json", jsonString);
    spe.commit();
}
//This code can then be used by a client like this:
//Create any data object with reasonable complexity
//Ex:  MainObject mo = MainObject.createTestMainObject();
//You can then call storeJSON(some-activity, mo) below

清单 25-2 展示了一些使用 GSON/JSON 和共享参数检索 Java 树的示例代码。

清单 25-2 。使用 JSON 从共享首选项 XML 文件中读取 Java 对象树

public Object retrieveJSON(Context context, String filename, Class classRef) {
    int mode = Context.MODE_PRIVATE;
    SharedPreferences sp = context.getSharedPreferences(filename,mode);
    String jsonString = sp.getString("json", null);
    if (jsonString == null)    {
        throw new RuntimeException("Not able to read the preference");
    }
    Gson gson = new Gson();
    return gson.fromJson(jsonString, classRef);
}

//You can then do this in the client code
MainObject mo = (MainObject)retrieveJSON(context,"somefilename.xml", MainObject.class);
String compareResult = MainObject.checkTestMainObject(mo);
if (compareResult != null)    {
       throw new RuntimeException("Something is wrong. Objects don't match");
}

这段代码要求您将 GSON Java 库添加到项目中。这种基于 GSON 的方法在我们的姊妹书《来自 Apress 的专家 Android》中有详细介绍。这个在网上也有简要记载在。

使用内部文件保存状态

在 Android 中,你也可以使用内部文件来存储你的应用的状态。这些内部文件是设备上应用的内部文件。其他应用不能直接使用这些数据。最终用户不能通过安装到 USB 端口上来直接操作这些数据。当应用被删除时,该数据被自动删除。

清单 25-3 显示了如何使用 GSON/JSON 和内部文件保存 Java 树的示例代码。

清单 25-3 。从/向 Android 内部文件读取/写入 JSON 字符串

private Object readFromInternalFile(Context appContext, String filename, Class classRef)
throws Exception
{
    FileInputStream fis = null;
    try {
        fis = appContext.openFileInput(filename);
        //Read the following string from the filestream fis
        String jsonString;

        Gson gson = new Gson();
        return gson.fromJson(jsonString, classRef);
}
    finally {
        // write code to closeStreamSilently(fis);
    }
}
private void saveToInternalFile(Context appContext, String filename, Object anyObject){
    Gson gson = new Gson();
    String jsonString = gson.toJson(anyObject);

    FileOutputStream fos = null;
    try {
        fos = appContext.openFileOutput(filename
                                ,Context.MODE_PRIVATE);
        fos.write(jsonString.getBytes());
    }
    finally    {
        // closeStreamSilently(fos);
    }
}

这种基于内部文件和 GSON 的方法在我们的姊妹书《来自 Apress 的专家 Android(【http://www.apress.com/9781430249504】)中有详细介绍。这在网上androidbook.com/item/4439也有简要记载。

使用外部文件保存状态

在 Android 中,外部文件要么存储在 SD 卡上,要么存储在设备上。这些成为公共文件,其他应用(包括用户)可以在应用环境之外看到和读取。对于许多想要管理内部状态的应用来说,这些外部文件会不必要地污染公共空间。

因为您表示为 JSON 的数据通常是特定于您的应用的,所以将其作为外部存储是没有意义的,外部存储通常用于音乐文件、视频文件或其他应用可以理解的格式的文件。

因为诸如 SD 卡的外部存储器可以处于各种状态(可用、未安装、已满等。),当数据足够小时,为简单的应用编写这样的程序会更困难。因此,我们现在还不能很好地将应用状态保存在外部存储上。

如果应用需要音乐和照片,而这些可以放在外部存储中,同时将核心状态数据保存在 JSON 和 internal 中,那么混合方法可能是有意义的。

android.os.Environment 类和 android.content.Context 类有许多读写外部文件和目录的方法。我们没有包括代码示例,因为一旦您通过和 id.content.Context 访问这些文件,这种方法与内部文件非常相似。

使用 SQLite 保存状态

Android 应用可以使用 SQLite 数据库来存储它们的状态。SQLite 很好地集成到了 Android 的结构中。如果你想存储应用的内部状态,那么这可能是最好的方法。然而,使用包括 SQLite 在内的任何关系数据库都有许多细微差别。我们将在本章稍后介绍在 Android 上使用 SQLite 的要点和细微差别。

使用 O/R 映射库保存状态

O/R 映射代表对象到关系的映射。用 Java 这样的编程语言在关系数据库中存储状态的一个关键困难是 Java 对象结构和数据库的关系结构之间的不匹配。我们需要在名称、类型和字段关系之间进行映射,因为它们在 Java 空间和数据库空间中是等价的。这种映射容易出错。当我们稍后详细介绍 SQLite 时,您会看到这一点。

需要简化 Java 和 SQL 之间的数据映射。这个空间在业内被称为 O/R 映射。现在有一些工具可以在 Android 中解决这个问题。涵盖这些 O/R 映射工具的要点超出了本书的范围。但是我们现在将命名其中的几个工具并给出它们的在线参考。

这方面的两个关键工具是绿岛( 、http://greendao-orm.com/()和奥姆利特( 、http://ormlite.com/)。每年都有更多的出现。所以经常检查看看新的是更快还是更容易。GreenDAO 使用基于模式定义的代码生成方法。据说比 OrmLite 快三到四倍。OrmLite 通过注释将模式定义与 Java 类融合在一起。后一种方法在编程上更容易。OrmLite 在任何 Java 平台上也是一样的。然而,可能由于在运行时使用反射,它可能会慢一些,但我怀疑对于大多数应用来说已经足够快了。

我们预测,使用这些 O/R 映射库之一是将您的应用更快推向市场的关键需求。我们建议您隔离持久性服务,从 OrmLite 开始,然后如果您的应用获得足够的牵引力或从您的原型转移到生产,则转移到 GreenDAO。

使用内容提供者 保存状态

Android 在基于 URIs 的数据存储之上提供了更高层次的抽象。使用这种方法,任何应用都可以像 URIs 一样使用 REST 读取或存储数据。这种抽象还允许应用通过基于 URI 字符串的 API 共享数据。在这种方法中,提交 URI 将返回数据库游标中的行和列的集合。如果被授予权限,URI 还可以接受一组键/值对,并将它们保存在目标数据库中。这是 Android 应用之间数据互操作性的通用机制。我们将在本章后面更详细地讨论这一点。如果您的应用拥有可供其他应用共享、创建或操作的宝贵数据,这是一种首选机制。例如,许多处理笔记、文档、音频或视频的应用将它们的数据实现为内容提供者。Android 的大部分核心数据相关服务也是如此。

使用网络存储保存状态

当一个应用创建或使用的数据需要由同一平台或不同平台(如协作应用)上的其他用户通过网络共享时,网络存储就发挥了作用。移动应用使用的这种后端服务设施被称为 MBaaS(移动后端即服务)。Parse.com 是 MBAAS 的一个例子,它提供后端服务,如用户管理、用户登录、安全、社交、公共网络存储、服务器端业务逻辑和通知。

Android 本身也使用一种叫做同步适配器的概念来在设备和网络服务器之间传输数据。你可以在developer . Android . com/training/sync-adapters/index . html了解更多关于同步适配器的信息。这是一个使用异步回调的框架,通过在最合适的时机调度和执行它来有效地优化任意数量数据的传输。框架苦于细节,开发人员只需提供转移代码。

以上总结了为 Android 移动应用保存状态的各种方法。我们现在将详细讨论其中的两种方法:SQLite 和内容提供者。我们将从 Android SQLite API 开始。

使用 SQLite 直接存储数据

在本节中,我们将详细探讨如何有效地使用 SQLite 来管理 Android 应用状态。你会明白 Android 对 SQLite 的支持程度。我们将向您展示重要的代码片段。我们将向您展示在 Android 上使用 SQLite 的最佳实践。我们将向您展示如何最好地加载 DDL 来创建您的数据库。我们将向您展示抽象持久性服务的更清晰的架构模式。我们将展示如何通过动态代理应用事务。这一节是对在 Android 上使用 SQLite 的一个健壮的处理。我们还有一个示例程序,您可以下载来查看完整的工作实现。让我们先快速概述一下 Android 中的 SQLite 包和类。

总结关键的 SQLite 包和类

Android 通过其 Java 包 android.database.sqlite 支持 SQLite。为了有效地使用 Android SQLite API,你需要理解的一些关键类在清单 25-4 中列出。注意,有些类在 android.database.sqlite 包之外。

清单 25-4 。Android SDK 中的关键 SQLite Java 类

android.database.sqlite.SQLiteDatabase
android.database.sqlite.SQLiteCursor
android.database.sqlite.SQLiteQueryBuilder
android.content.ContentValues
android.database.Cursor
android.database.SQLException
android.database.sqlite.SQLiteOpenHelper

让我们简单地讨论一下这些包和类。

SQLiteDatabase:SQLiteDatabase 是一个 Java 类,表示数据库通常指的是一个。文件系统上的 db 文件。使用该对象,您可以查询、插入、更新或删除数据库中的给定表。您还可以执行一条任意的 SQL 语句。您可以应用事务。您还可以使用该对象通过 DDLs(数据定义语言)来定义表。DDL 是允许您创建数据库实体(如表、视图、索引等)的语句。通常,在您的应用中有一个表示数据库的该对象的实例。

SQLiteCursor :这个 Java 类表示从 SQLiteDatabase 返回的行的集合。它还实现了 android.database.Cursor 接口。这个对象有一些方法,像向前的数据库游标一样一次导航一行,并只在需要时检索行。如果需要,这个对象还可以像随机光标一样通过实现窗口特性向前或向后跳转。这也是您将用来读取任何当前行的列值的对象。

SQLiteQueryBuilder :这是一个 helper Java 类,通过递增地指定表名、列名、where 子句等来构造 SQLite 查询字符串。,作为单独的字段。这个类有许多 set 方法来逐步构建查询,而不是将整个 SQL 查询指定为一个字符串。如果您的查询很简单,您也可以直接在 SQLiteDatabase 类上使用查询方法。

ContentValues :这个类的 Java 对象包含一组键/值对,许多 SQLite 类使用它们来插入或更新一行数据。

SQLException :大部分 Android SQLite 数据库 API 在有错误时抛出这个异常。

SQLiteOpenHelper :这个助手 Java 对象通过检查一些事情来提供对一个 SQLiteDatabase 的访问:给定一个数据库的文件名,这个对象检查该数据库是否已经安装并且可用。如果可用,它会检查版本是否相同。如果版本也相同,它提供对代表该数据库的 SQLiteDatabase 的引用。如果版本不同,它会在提供对数据库的有效引用之前提供一个回调来迁移数据库。如果数据库文件不存在,那么它提供一个回调来创建和填充数据库。您将扩展这个基类,并为这些不同的回调提供实现。您将很快在提供的代码片段中看到这一点。

这是您用来在 SQLite 数据库中保存应用状态的关键类的快速总结。现在让我们转向使用 SQLite 管理应用状态的关键概念。让我们从创建数据库开始。

创建 SQLite 数据库

Android 中数据库的创建是通过 SQLiteOpenHelper 类来控制的。对于应用中的每个数据库,您将拥有一个 Java 数据库对象,它是该类的一个实例。这个 SQLiteOpenHelper 对象有一对 get 方法来获取对读优化(配置为)或写优化(配置为) SQLiteDatabase 对象的引用。创建或获取对 SQLite 数据库对象的访问包括以下内容:

  1. 扩展 SQLiteOpenHelper 并将数据库名称和版本提供给这个派生类的构造函数,以便这些值可以传递给基类
  2. 从 SQLiteOpenHelper 中重写 onCreate() 、 onUpgrade() 和 onDowngrade() 方法。如果这个数据库不存在,您将调用 onCreate() 。如果数据库版本较新,则调用 onUpgrade() ,如果数据库版本比设备上的版本旧,则调用 onDowngrade()。您将在这些方法中使用 execute DDL 语句来创建或调整数据库。如果您的数据库不是新的或者版本相同,那么这两个回调都不会被调用。
  3. 对此派生对象有一个静态引用。对此对象调用 get 方法以获取对可读或可写数据库副本的引用。使用这些数据库引用来执行 CRUD 操作和事务。

清单 25-5 是一个代码片段,展示了如何实现这些步骤来创建一个名为“booksqlite.db”的数据库,这个数据库保存了一个图书及其详细信息的表格。

清单 25-5 。使用 SQLiteOpenHelper

// File reference in project: DirectAccessBookDBHelper.Java
/**
* A complete example of SQLiteOpenHelper demonstrating
* 1\. How to create a databases
* 2\. How to migrate a database
* 3\. How to hold a static reference
* 4\. How to give out read and write database references
*
* This class also can act as a DatabaseContext.IFactory to produce read and write
* database references. This aspect is not critical to understanding but included
* for advanced readers and for some material later in the chapter.
*/
public class DirectAccessBookDBHelper extends SQLiteOpenHelper
implements DatabaseContext.IFactory
{
    //there is one and only one of these database helpers
    //for this database for this entire application
    public static DirectAccessBookDBHelper m_self =
       new DirectAccessBookDBHelper(MyApplication.m_appContext);

    //Name of the database on the device
    private static final String DATABASE_NAME = "bookSQLite.db";

    //Name of the DDL file you want to load while creating a database
    private static final String CREATE_DATABASE_FILENAME = "create-book-db.sql";

    //Current version number of the database for the App to work
    private static final int DATABASE_VERSION = 1;

    //Just a logging tag
    private static final String TAG = "DirectAccessBookDBHelper";

    //Pass the database name and version to the base class
    //This is a non public constructor
    //Clients can just use m_self and not construct this object at all directly
    DirectAccessBookDBHelper(Context context) {
        super(context,DATABASE_NAME,null,DATABASE_VERSION);
        //Initialize anything else in your system that may need a
        //reference to this object.
        //Example: DatabaseContext.initialize(this);
    }
    @Override
    public void onCreate(SQLiteDatabase db)  {
       try {
          //No database exists. Load DDL from a file in the assets directory
          loadSQLFrom(this.CREATE_DATABASE_FILENAME,db);
       }
       catch(Throwable t)       {
          //Problem creating database
          throw new RuntimeException(t);
       }
    }
    //A function to load one SQL statement at a time using execSQL method
    private void loadSQLFrom(String assetFilename, SQLiteDatabase db)    {
       List<String> statements = getDDLStatementsFrom(assetFilename);
       for(String stmt: statements){
          Log.d(TAG,"Executing Statement:" + stmt);
          db.execSQL(stmt);
       }
    }
    //Optimize this function for robustness.
    //For now it assumes there are no comments in the file
    //the statements are separated by a semicolon
    private List<String> getDDLStatementsFrom(String assetFilename)  {
       ArrayList<String> l = new ArrayList<String>();
       String s = getStringFromAssetFile(assetFilename);
       for (String stmt: s.split(";"))   {
          //Add the stmt if it is a valid statement
          if (isValid(stmt)) {
             l.add(stmt);
          }
       }
       return l;
    }
    private boolean isValid(String s)    {
        //write logic here to see if it is null, empty etc.
        return true; //for now
    }
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)   {
       //Use old and new version numbers to run DDL statements
       //to upgrade the database
    }
    //Using your specific application object to remember the application context
    //Then using that application context to read assets
    private String getStringFromAssetFile(String filename)   {
       Context ctx = MyApplication.m_appContext;
       if ( ctx == null)   {
          throw new RuntimeException("Sorry your app context is null");
       }
       try   {
         AssetManager am = ctx.getAssets();
         InputStream is = am.open(filename);
         String s = convertStreamToString(is);
         is.close();
         return s;
       }
       catch (IOException x)    {
          throw new RuntimeException("Sorry not able to read filename:" + filename,x);
       }
    }
    //Optimize later. This may not be an efficient read
    private String convertStreamToString(InputStream is)  throws IOException   {
       ByteArrayOutputStream baos = new ByteArrayOutputStream();
       int i = is.read();
       while (i != -1)  {
          baos.write(i);
          i = is.read();
       }
       return baos.toString();
    }
   //Here are some examples of how to get access to readable and
   //writable databases. These methods will make sense once we get through the
   //the transactions applied through dynamic proxies
   /*
   public ReadDatabaseContext createReadableDatabase() {
      return new ReadDatabaseContext(this.getReadableDatabase());
   }
   public WriteDatabaseContext createWritableDatabase() {
      return new WriteDatabaseContext(this.getWritableDatabase());
   }
   */
}//eof-class DatabaseHelper
//Here is the code for MyApplication to remember the context
public class MyApplication extends Application {
   public final static String tag="MyApplication";
   public static volatile Context m_appContext = null;

   @Override
   public void onCreate() {
       super.onCreate();
          MyApplication.m_appContext = this.getApplicationContext();
   }
}
//assets/create-book-db.sql
CREATE TABLE t_books (id INTEGER PRIMARY KEY,
                name TEXT,  isbn TEXT, author TEXT,
                created_on INTEGER, created_by TEXT,
                last_updated_on INTEGER, last_updated_by TEXT
);

通过 DDLs 定义数据库

在清单 25-5 中,DirectAccessBookDBHelper 是 SQLiteOpenHelper 的一个派生类,它允许我们检查一个现有的数据库,看看它是否需要被创建或者仅仅是基于它的版本被迁移。

只有当设备上不存在该数据库时,才会调用方法 onCreate() 。如果没有 SQLiteOpenHelper ,我们将不得不检查这个文件的物理位置,看看它是否存在。换句话说, SQLiteOpenHelper 实际上是一个瘦包装器,它为我们节省了许多“if-else”子句来检查数据库并进行必要的初始化:无论是创建数据库还是迁移数据库。

许多在互联网上创建 Android 数据库的例子使用 Java 代码中嵌入的 DDL 字符串来创建所需的表。作为 DDL 语句,Java 代码中的字符串难以阅读并且容易出错。更好的方法是将这些数据库创建脚本放在 assets 目录中的一个文本文件中。清单 25-5 中的示例代码演示了如何从应用的资产目录中读取一个文本文件,并使用 SQLiteDatabase 上可用的 execSQL() 函数来初始化数据库。

execSQL() 的一个限制是它一次只能执行一条 SQL 语句。这就是为什么清单 25-5 中的代码读取脚本文件并使用简单的语法将其解析成一系列语句。您可能希望搜索互联网,查看更好的解析工具,以获得更好的脚本文件支持。如果适合您的情况,另一种替代方法是拥有一个 schema 类,它的唯一目的是包含 DDL 的静态公共字符串,因为它减少了解析文件的需要。在本章末尾提供的在线参考资料中,我们有这些基于 Java 的库的链接。特别是,使用 ANTLR 的基于 Java 的工具对于复杂的数据库设置有很大的前景。

onCreate() 函数也将其执行包装在一个事务中,以便执行的数据库是一致的。

如果您有许多脚本,也可以创建整个数据库,并将其保存在 assets 文件夹中。在部署过程中,如果数据库不存在,您可以将文件复制到目标位置。

迁移数据库

如上所述, SQLiteOpenHelper 识别版本号,并适当地调用 onUpgrade() 方法来升级数据库。在这里,您可能还想在 assets 文件夹中保存一系列脚本,这些脚本可以根据版本号的不同适当地改变数据库。请记住,设备上的版本号可能小于或大于您的目标版本。因此,您可能需要一组对于每个转换序列都是唯一的脚本:从 V1 到 V3,或者从 V2 到 V3,或者从 V3 到 V1。后退可能需要警告或动态下载服务器端转换到旧版本,因为旧版本应用的源代码不太可能具有从未来版本退出所需的工具。

插入行

其核心是,将一行及其列值插入到 SQLiteDatabase 中仅仅是调用 SQLiteDatabase 对象上与插入相关的方法。解释这一点的伪代码如清单 25-6 所示。

清单 25-6 。使用 SQLiteDatabase 插入行的基础知识

//Get a reference to the database object
//Depending on the framework you have there could be many ways of doing this
SQLiteDatatabase db = DirectAccessBookDBHelper.m_self.getReadableDatabase();
String tablename; //which table you want to insert the row into

//populate a structure with the needed columns and their values
ContentValues cv = new ContentValues();
cv.put(columnName1, column1Value); //etc.

//A column that could be null if 'cv' is empty if an empty row is needed
//Provide null if that behavior is not needed
String nullColumnName = null;

//Insert the row
long rowId = db.insertOrThrow(tablename, nullColumnName, cv);

这段代码非常简单。使用这段代码插入任何 Java 对象仅仅是读取其属性,并将这些值放入 ContentValues 数据集中,然后插入。就 Android 的 SQLite insert 功能而言,你只需要知道这些。

如何最好地构造 Java 对象以及如何将这些值转换成内容值取决于您的框架。这是一个冗长乏味的过程。但是,这个细节对于 insert 的基本理解来说并不重要。大多数应用都需要这种严格程度。如果你认为这很复杂,你可以跳过,但是我们把它放在这里,因为我们觉得你的大多数应用都需要这种严格程度。

因此,为插入行获取正确的列名和值需要一些工作,您通常需要以下内容(不管您使用什么框架):

  1. 一个 Java 对象,通常代表数据库中的行,例如一个 Book 对象
  2. 保存一组帐册的表名
  3. Books 表中可用列的字符串名称
  4. 最后,调用 insert 方法将 Book 对象持久化为 Books 表中的一行

我们将给出这些需求的代码片段(有些是伪代码)。对于实际的代码,你可以下载本章的项目。下面是清单 25-7 中的几个类,它们用 Java 代码表示一个图书对象。

清单 25-7 。确保域对象和持久性之间的最小依赖

// File reference in project: BaseEntity.Java
public class BaseEntity {
   private int id; //database identifier

   private String ownedAccount = null; //Multi-tenant if needed
   private String createdBy;
   private Date createdOn;
   private String lastUpdatedBy;
   private Date lastUpdatedOn;

   public BaseEntity(String ownedAccount, String createdBy, Date createdOn,
         String lastUpdatedBy, Date lastUpdatedOn, int id) {
      super();
      this.ownedAccount = ownedAccount;
      this.createdBy = createdBy;
      this.createdOn = createdOn;
      this.lastUpdatedBy = lastUpdatedBy;
      this.lastUpdatedOn = lastUpdatedOn;
      this.id = id;
   }
   //For persistence
   public BaseEntity(){}

   //Usual generated get/set methods
   //eliminated here for space. See the downloads
}
// File reference in project: Book.Java
public class Book extends BaseEntity
{
   //Key data fields
   //*************************************
   private String name;
   private String author;
   private String isbn;
   //*************************************

   public Book(String ownedAccount, String createdBy, Date createdOn,
         String lastUpdatedBy, Date lastUpdatedOn, String name,
         String author, String isbn) {
      super(ownedAccount, createdBy, createdOn, lastUpdatedBy, lastUpdatedOn,-1);
      this.name = name;
      this.author = author;
      this.isbn = isbn;
   }
   //To help with persistence
   public Book() {}
   //Generated methods get and set methods...
   //....
   //The following method is here for testing purposes
   //and also to see how a book object is typically created
   public static Book createAMockBook()  {
      String ownedAccount = "Account1";
      String createdBy = "satya";
      Date createdOn = Calendar.getInstance().getTime();
      String lastUpdatedBy = "satya";
      Date lastUpdatedOn = Calendar.getInstance().getTime();

      //See how many books I have and increment it by one
      //The following method returns a collection of books in the database
      //This is not essential for your understanding here
      //You will see this clarified when you read the section of transactions
      List<Book> books = Services.PersistenceServices.bookps.getAllBooks();
      int i = books.size();
      String name = String.format("Book %s",i);
      String author = "satya";
      String isbn   = "isbn-12344-" + i;

      return new Book(ownedAccount,createdBy,createdOn,
            lastUpdatedBy,lastUpdatedOn,
            name,author,isbn);
   }
}

这个清单有两个 Java 类:一个 BaseEntity 和一个扩展了 BaseEntity 的 Book 。看起来像清单 25-7 中的书的对象被称为域对象。这些是纯 Java 对象,它们可以在程序的 Java 空间中移动,而不会受到与持久性相关的行为的影响。然而,谁创建了这些对象,它们是何时创建的,这些属性被封装在 BaseEntity 中,这样所有的域对象都有这些基本信息。

因为 SQLite 数据库方法需要这些对象的显式列名,所以该方面是在一组单独的类中定义的,这些类描述了这些对象的元数据。这些支持类在清单 25-8 中给出。

清单 25-8 。为域对象定义元数据

// File reference in project: BaseEntitySQLiteSQLiteMetaData.Java
public class BaseEntitySQLiteSQLiteMetaData  {
   static public final String OWNED_ACCOUNT_COLNAME = "owned_account";
   static public final String CREATED_BY_COLNAME = "created_by";
   static public final String CREATED_ON_COLNAME = "created_on";
   static public final String LAST_UPDATED_ON = "last_updated_on";
   static public final String LAST_UPDATED_BY = "last_updated_by";
   static public final String ID_COLNAME = "id";
}
// File reference in project: BookSQLiteSQLiteMetaData.Java
public class BookSQLiteSQLiteMetaData extends BaseEntitySQLiteSQLiteMetaData {
   static public final String TABLE_NAME = "t_books";
   static public final String NAME = "name";
   static public final String AUTHOR = "author";
   static public final String ISBN = "isbn";
}

这两个类平行于它们各自的 BaseEntity 和 Book 对象类。您必须注意列名与数据库中的列名相匹配。所以这种需求基本上是容易出错的。除非您使用 O/R 映射库并创建自己的库,否则这个问题会一直存在,您必须做好测试。在我们前面讨论的 O/R 映射工具中,由程序员明确定义这些类是不必要的。

现在我们有了一个 Java 类来表示一本书及其元数据定义,它告诉我们表名和字段,我们可以继续编写 Java 代码来将一个 book 对象保存在数据库中,如清单 25-9 所示(注意这仍然是伪代码,使用下载查看任何遗漏的细节)。

清单 25-9 。使用 Android SQLite APIs 插入一行

// File reference in project: BookPSSQLite.Java
private long createBook(Book book) {
   //Get access to a read database
   SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getWritableDatabase();

   //Fill fields from the book object into the content values
   ContentValues bcv = new ContentValues();
   //.... fill other fields example
   bcv.put(BookSQLiteSQLiteMetaData.NAME, book.getName());
   bcv.put(BookSQLiteSQLiteMetaData.ISBN, book.getIsbn());
   bcv.put(BookSQLiteSQLiteMetaData.AUTHOR, book.getAuthor());
   //.... fill other fields

   //if bcv is an empty set, then an empty row can possibly be inserted.
   //It is not the case for our book table. If it were though, the empty bcv
   //will result in an insert statement with no column names in it.
   //At least one column name is needed by SQL insert syntax.
   //It is one of these column names that goes below. For us this is not case so a null
   String nullColumnName = null;

    long rowId = db.insertOrThrow(BookSQLiteSQLiteMetaData.TABLE_NAME,
                      nullColumnName,
                      bcv);
    return rowId;
}

清单 25-9 中的逻辑非常简单。获取对我们想要保存的书对象的引用。将书中的字段值复制到一个 ContentValues 键/值对对象中。使用元数据类来正确定义字段名称。使用填充的 ContentValues 对象并调用 insert 方法。如果我们什么都不做,插入会被封装在自动提交中。我们将很快讨论如何进行交易,因为它的理论有点复杂,尽管代码写起来很简单。insert 方法返回该表新插入的主键 ID。这种返回表主键的约定来自底层 SQLite 产品文档,并不特定于 Android。

nullColumnName 与 SQL insert 语句的语法有关。如果该行有十列,但只有两列,并且指示了它们的非空值,则插入带有这两列的新行,并且预计剩余的八列将允许空值。如果您想要一个每列都为 null 的行,可以发出一个完全没有列名的 insert 语句,匹配空的内容值集。但是,不允许使用没有列名的 insert 语句。因此,这个参数 nullColumnName 可以包含一个可以为 null 的列名,以便满足 insert 语句的语法要求。当插入该行时,数据库会在内部将其余的列设置为 null。通常这个列名作为 null 传入,因为我们很少想要插入一个每一列都为空或 null 的行。

更新行

清单 25-10 是一个示例伪代码片段(完整代码见下载项目),展示了如何更新数据库中的一行。注意 Book object 和 BookSQLiteMetaData 类是如何被用来最小化指定表名和列名的错误的。该方法类似于插入方法。

清单 25-10 。更新记录的 Android SQLite API

// File reference in project: BookPSSQLite.Java
public void updateBook(Book book) {
   if (book.getId() < 0) {
      throw new SQLException("Book id is less than 0");
   }
   //Get access to a read database
   SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getWritableDatabase();

   //Fill fields from the book object into the content values
   ContentValues bcv = new ContentValues();
   //.... fill other fields
   bcv.put(BookSQLiteSQLiteMetaData.NAME, book.getName());
   bcv.put(BookSQLiteSQLiteMetaData.ISBN, book.getIsbn());
   bcv.put(BookSQLiteSQLiteMetaData.AUTHOR, book.getAuthor());
   //.... fill other fields

   //You can do this
   String whereClause = String.format("%s = %s",BookSQLiteSQLiteMetaData.ID_COLNAME,book.getId());
   String whereClauseArgs = null;
   //Or the next 4 lines (this is preferred)
   String whereClause2 = BookSQLiteSQLiteMetaData.ID_COLNAME + " = ?";
   String[] whereClause2Args = new String[1];
   whereClause2Args[1] = Integer.toString(book.getId());

   int count = db.update(BookSQLiteSQLiteMetaData.TABLE_NAME, bcv, whereClause2, whereClause2Args);
   if (count == 0)   {
      throw new SQLException(
            String.format("Failed to update book for book id:%s",book.getId()));
   }
}

删除行

清单 25-11 是如何从数据库中删除一行的例子。

清单 25-11 。Android SQLite API 删除记录

// File reference in project: BookPSSQLite.Java
public void deleteBook(int bookid){
   //Get access to a writable database
   SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getWritableDatabase();

   String tname = BookSQLiteSQLiteMetaData.TABLE_NAME;
   String whereClause =
      String.format("%s = %s;",
         BookSQLiteSQLiteMetaData.ID_COLNAME,
         bookid);
   String[] whereClauseargs = null;
   int i = db.delete(tname,whereClause, whereClauseargs);
   if (i != 1)   {
      throw new RuntimeException("The number of deleted books is not 1 but:" + i);
   }
}

读取行

清单 25-12 显示了使用 SQLiteDatabase.query() 方法从 SQLite 中读取的伪代码片段(完整代码参见下载项目)。这个方法返回一个光标对象,您可以用它来检索每一行。

清单 25-12 。Android SQLite API 读取记录

// File reference in project: BookPSSQLite.Java
public List<Book> getAllBooks()   {
   //Get access to a read database
   SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getReadableDatabase();

   String tname = BookSQLiteSQLiteMetaData.TABLE_NAME;
   //Get column name array from the metadata class
   //(See the download how the column names are gathered)
   //(at the end of the day it is just a set of column names
   String[] colnames = BookSQLiteSQLiteMetaData.s_self.getColumnNames();

   //Selection
   String selection = null;       //all rows. Usually a where clause. exclude where part
   String[] selectionArgs = null; //use ?s if you need it

   String groupBy = null;     //sql group by clause: exclude group by part
   String having = null;      //similar
   String orderby = null;
   String limitClause = null; //max number of rows
   //db.query(tname, colnames)
   Cursor c = null;

   try {
      c = db.query(tname,colnames,selection,selectionArgs,groupBy,having,orderby,limitClause);
      //This may not be the optimal way to read data through a list
      //Directly pass the cursor back if your intent is to read these one row at a time
      List<Book> bookList = new ArrayList<Book>();
      for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
         Log.d(tag,"There are books");
         Book b = new Book();

         //..fill base fields the same way
         b.setName(c.getString(c.getColumnIndex(BookSQLiteMetaData.NAME)));
         b.setAuthor(c.getString(c.getColumnIndex(BookSQLiteMetaData.AUTHOR)));
         b.setIsbn(c.getString(c.getColumnIndex(BookSQLiteMetaData.ISBN)));
         //..fill other fields

         //Or you could delegate this work to the BookSQLiteMetaData object
         //as we have done in the sample downloadable project
         //Ex: BookSQLiteSQLiteMetaData.s_self.fillFields(c,b);

         bookList.add(b);
      }
      return bookList;
   }
   finally {
      if (c!= null) c.close();
   }
}

以下是一些关于 Android 光标对象的事实:

  • 游标是行的集合。
  • 在读取任何数据之前,您需要使用 moveToFirst() ,因为光标从第一行之前开始定位。
  • 您需要知道列名。
  • 您需要知道列的类型。
  • 所有的字段访问方法都是基于列号的,所以必须先将列名转换成列号。注意,这个查找可以被优化。如果您希望获取值,然后在游标上使用显式常量索引,那么按顺序填充列名数组会更有效。
  • 光标是随机的(可以前后移动,可以跳跃)。
  • 因为游标是随机的,所以可以向它询问行数。

应用交易

Android 上的 SQLite 库支持事务。事务方法在 SQLiteDatabase 类中可用。这些方法如清单 25-13 中的伪代码片段所示(完整代码参见下载项目)。

清单 25-13 。用于事务的 SQLite API

// File reference in project: DBServicesProxyHandler.Java
public void doSomeUpdates() {
  SQLiteDatabase db;  //Get a reference to this database through helper
  db.beginTransaction();
  try {
    //...call a number of database methods
    db.setTransactionSuccessful();
  }
  finally {
    db.endTransaction();
  }
}

总结 SQLite

如果你是一个有几年经验的 Java 程序员,我们到目前为止所介绍的内容足以理解 Android 中的 SQLite API。到目前为止,已经学习了这些内容,您知道了如何检查数据库、通过 DDL 创建数据库、插入行、更新行、删除行或使用数据库游标进行读取。我们还向您展示了事务的基本 API。然而,如果您不是 Java 方面的老手,数据库事务很难正确有效地实现。下一节将告诉您一个使用 Java 动态代理的基于 API 的模式。

通过动态代理进行交易

您可以将您的移动应用想象成两块砖的集合:一个 API 砖和一个 UI 砖。API 块将有一系列的无状态方法,为 UI 块提供逻辑和数据。在这种情况下,清单 25-13dosome updates()中的方法被 UI 的许多部分或其他 API 认为是可重用的 API。因为它是一个可重用的 API,所以客户端决定在该事务中是否应该提交某些东西。这意味着 API 大部分时间不应该处理事务。它非常像关系数据库中的存储过程。存储过程很少直接处理事务。存储过程的容器决定在存储过程外部提交或不提交。逻辑是这样的:如果存储过程被自己调用,那么它的输出将在存储过程级别提交。如果存储过程被另一个存储过程调用,提交将一直等到主调用存储过程完成。

最好在应用中对这些 API 使用相同的策略,以降低实现 API 的复杂性。这是通过拦截对所有 API 的调用来完成的,以确定这是一个直接调用还是由另一个已经被事务监视的 API 调用。有许多方法可以拦截需要拦截的 API 调用。这有时也被称为面向方面编程或 AOP。AOP 需要复杂的工具来完成。Java 通过动态代理提供了一种不太复杂但很简单的方法。动态代理是 Java 中的一种工具,基于 Java 反射,允许您在对象不知道的情况下拦截对底层对象的调用。当客户机通过这个代理调用对象时,客户机认为它是在直接与对象对话。然而,代理可以选择应用其他方面(如安全性、日志、事务等。)在发送对真实对象的调用之前。本章包含的项目提供了一个动态代理的完整实现,它可以自动应用事务方面。

我们将首先向您展示一旦动态代理就位,您的 API 实现是什么样子的。这将首先让您了解这种事务处理方法的简单性。然后你可以看看你是否想走这条路,并使用动态代理。当我们展示下面的代码时,请注意我们将只包括代码片段或示例,而不是整个代码。使用可下载的项目了解全部细节。我们对下载项目做了很多注释,以帮助您理解。考虑到这一点,请考虑使用 API 来处理基于 Book 的对象。

。基于 API 的接口,用于处理 Book 域对象

// File reference in project: IBookPS.Java
public interface IBookPS {
   public int saveBook(Book book);
   public Book getBook(int bookid);
   public void updateBook(Book book);
   public void deleteBook(int bookid);
   public List<Book> getAllBooks();
}

该接口使用基于 Java 的对象定义操作。 IBookPS 服务末尾的字母“PS”表示这是一本书的持久性服务 API。清单 25-15 显示了 IBookPS 的 SQLite 实现

清单 25-15 。使用 SQLite 实现图书 API

// File reference in project: BookPSSQLite.Java
// The missing classes in this code are in the download and not essential for
// exploring the idea.
// ASQLitePS is a class that contains reusable common methods like getting access
// to the read and write databases using the singleton database helper.
public class BookPSSQLite extends ASQLitePS implements IBookPS {
   private static String tag = "BookPSSQLite";
   @Override public int saveBook(Book book)   {
      //get the database
      //case: id does not exist in the book object
      if (book.getId() == -1)  {
         //id of the book doesn't exist so create it
         return (int)createBook(book);
      }
      //case: id exists in book object
      updateBook(book);
      return book.getId();
   }
   @Override public void deleteBook(int bookid){
      SQLiteDatabase db = getWriteDb();
      String tname = BookSQLiteSQLiteMetaData.TABLE_NAME;
      String whereClause =
         String.format("%s = %s;",
            BookSQLiteSQLiteMetaData.ID_COLNAME,
            bookid);
      String[] whereClauseargs = null;
      int i = db.delete(tname,whereClause, whereClauseargs);
      if (i != 1)  {
         throw new RuntimeException("The number of deleted books is not 1 but:" + i);
      }
   }
   private long createBook(Book book)   {
      //book doesn't exist
      //create it
      SQLiteDatabase db = getWriteDb();

      ContentValues bcv = this.getBookAsContentValuesForCreate(book);

      //I don't need to insert an empty row
      //usually any nullable column name goes here if I want to insert an empty row.
      String nullColumnNameHack = null;
      //Construct values from the Book object. SQLException is a runtime exception
      long rowId = db.insertOrThrow(BookSQLiteMetaData.TABLE_NAME, nullColumnNameHack, bcv);
      return rowId;
   }
   @Override  public void updateBook(Book book) {
      if (book.getId() < 0) {
         throw new SQLException("Book id is less than 0");
      }
      SQLiteDatabase db = getWriteDb();
      ContentValues bcv = this.getBookAsContentValuesForUpdate(book);
      String whereClause = String.format("%s = %s",BookSQLiteMetaData.ID_COLNAME,book.getId());
      whereArgs[0] = BookSQLiteMetaData.ID_COLNAME;
      whereArgs[1] = Integer.toString(book.getId());

      int count = db.update(BookSQLiteMetaData.TABLE_NAME, bcv, whereClause, null);
      if (count == 0)   {
         throw new SQLException(
               String.format("Failed to update book for book id:%s",book.getId()));
      }
   }
    private ContentValues getBookAsContentValuesForUpdate(Book book)  {
      ContentValues cv = new ContentValues();
      //Following code loads column values from book object to the cv
      //See the downloadable project for the mechanics of it
      BookSQLiteMetaData.s_self.fillUpdatableColumnValues(cv, book);
      return cv;
    }
    private ContentValues getBookAsContentValuesForCreate(Book book)  {
      ContentValues cv = new ContentValues();
      BookSQLiteMetaData.s_self.fillAllColumnValues(cv, book);
      return cv;
    }
    @Override   public List<Book> getAllBooks() {
      SQLiteDatabase db = getReadDb();
      String tname = BookSQLiteMetaData.TABLE_NAME;
      String[] colnames = BookSQLiteMetaData.s_self.getColumnNames();

      //Selection
      String selection = null; //all rows. Usually a where clause. exclude where part
      String[] selectionArgs = null; //use ?s if you need it

      String groupBy = null; //sql group by clause: exclude group by part
      String having = null; //similar
      String orderby = null;
      String limitClause = null; //max number of rows
      //db.query(tname, colnames)
      Cursor c = null;

      try {
         c = db.query(tname,colnames,selection,selectionArgs,groupBy,having,orderby,limitClause);
         //This may not be the optimal way to read data through a list
         //Directly pass the cursor back if your intent is to read these one row at a time
         List<Book> bookList = new ArrayList<Book>();
         for(c.moveToFirst();!c.isAfterLast();c.moveToNext())
         {
            Log.d(tag,"There are books");
            Book b = new Book();
            BookSQLiteMetaData.s_self.fillFields(c,b);
            bookList.add(b);
         }
         return bookList;
      }
      finally {
         if (c!= null) c.close();
      }
   }
   @Override   public Book getBook(int bookid) {
      SQLiteDatabase db = getReadDb();
      String tname = BookSQLiteMetaData.TABLE_NAME;
      String[] colnames = BookSQLiteMetaData.s_self.getColumnNames();

      //Selection
      String selection =
         String.format("%s = %s",
               BookSQLiteMetaData.ID_COLNAME,
               bookid);
      //all rows. Usually a where clause. exclude where part
      String[] selectionArgs = null; //use ?s if you need it

      String groupBy = null; //sql group by clause: exclude group by part
      String having = null; //similar
      String orderby = null;
      String limitClause = null; //max number of rows
      //db.query(tname, colnames)
      Cursor c = db.query(tname,colnames,selection,
                      selectionArgs,groupBy,having,orderby,limitClause);
      try    {
         if (c.isAfterLast()) {
            Log.d(tag,"No rows for id" + bookid);
            return null;
         }
         Book b = new Book();
         BookSQLiteMetaData.s_self.fillFields(c, b);
         return b;
      }
      finally {
         c.close();
      }
   }
}//eof-class

注意 Book persistence API 的实现并不直接处理这些方法的事务方面。相反,事务是由 Java 动态代理处理的,我们将很快展示这一点。清单 25-16 展示了客户端如何看到这些 API 并间接调用这些持久性 API(同样,请参考下载项目中的类,这些类在这段代码中被引用,但没有在这里列出,因为它们对于理解并不重要)。

清单 25-16 。客户端对基于 API 的服务的访问

// File reference in project: SQLitePersistenceTester.Java
// BaseTester is just a helper class to provider common functionality
// it implements some logging and report back methods to the UI activity
public class SQLitePersistenceTester extends BaseTester {
   private static String tag = "SQLitePersistenceTester";
   //Services is a static class that provides access to persistence services
   //Services class provides visibility to the implementer of the IBookPS
   //It demonstrates how a client gets access to the namespace of services
   //You will shortly see what this class is. Understand the intent first.
   private IBookPS bookPersistenceService = Services.PersistenceServices.bookps;
   //IReportBack is a logging interface to report loggable events back to the UI
   //UI will then choose to log those events and also show on the activity screen.
   SQLitePersistenceTester(Context ctx, IReportBack target) {
      super(ctx, target,tag);
   }

   //Add a book whose id is one larger than the books
   //in the database
   public void addBook()  {
      Book book = Book.createAMockBook();
      int bookid = bookPersistenceService.saveBook(book);
      reportString(String.format("Inserted a book %s whose generated id now is %s"
            ,book.getName()
            ,bookid));
   }
   //Delete the last book
   public void removeBook()  {
      List<Book> bookList = bookPersistenceService.getAllBooks();
      if( bookList.size() <= 0)
      {
         reportString("There are no books that can be deleted");
         return;
      }
      reportString(String.format("There are %s books. First one will be deleted", bookList.size()));

      Book b = bookList.get(0);
      bookPersistenceService.deleteBook(b.getId());
      reportString(String.format("Book with id:%s successfully deleted", b.getId()));
   }

   //write the list of books so far to the screen
   public void showBooks()  {
      List<Book> bookList = bookPersistenceService.getAllBooks();
      reportString(String.format("Number of books:%s", bookList.size()));
      for(Book b: bookList) {
         reportString(String.format("id:%s name:%s author:%s isbn:%s"
               ,b.getId()
               ,b.getName()
               ,b.getAuthor()
               ,b.getIsbn()));
      }
   }

   //Count the number of books in the database
   private int getCount()  {
      List<Book> bookList = bookPersistenceService.getAllBooks();
      return bookList.size();
   }
}

在清单 25-16 中,注意通过静态类服务访问 API 是多么简单。当然,我们还没有向您展示服务的实现,以及静态类服务持有的动态代理。清单 25-17 显示了静态服务类的源代码,以便让您了解这个方案是如何工作的。本章中许多(如果不是全部)列表的目的是帮助您理解。对于完整的可编译源代码,我们恳请您参考本章的可下载项目。

清单 25-17 。通过服务名称空间向客户端公开 API

// File reference in project: Services.Java
/**
 * Allow a namespace for clients to discover various services
 * Usage: Services.persistenceServices.bookps.addBook(); etc.
 * Dynamic proxy will take care of transactions.
 * Dynamic proxy will take care of mock data.
 * Dynamic Proxy will allow more than one interface
 *   to apply the above aspects.
 */
public class Services {
   public static String tag = "Services";
   public static class PersistenceServices   {
      ////se this pointer during initialization
      public static IBookPS bookps = null;
      static {
         Services.init();
      }
   }
   //Although this method is empty, calling it
   //will trigger all static initialization code for this class
   public static void init() {}
   private static Object mainProxy;
   static    {
      //A utility class to compile all database-related initializations so far
      //Gets the database helper going.
      //See the download project how it uses the concepts presented so far to do this
      Database.initialize();

      //set up bookps
      ClassLoader cl = IBookPS.class.getClassLoader();
      //Add more interfaces as available
      Class[] variousServiceInterfaces = new Class[] { IBookPS.class };

      //Create a big object that can proxy all the related interfaces
      //for which similar common aspects are applied
      //In this cases it is android SQLite transactions
      mainProxy = Proxy.newProxyInstance(cl,
               variousServiceInterfaces, new DBServicesProxyHandler());

      //Preset the namespace for easy discovery
      PersistenceServices.bookps = (IBookPS)mainProxy;
   }
}

注意 DBServicesProxyHandler 是如何代理实现 IBookPS 的。当被客户端调用时,DBServicesProxyHandler 然后调用 IBookPS 的实际实现。 IBookPS 的实际实现如清单 25-15 所示。让我们转向清单 25-18 中的动态代理的实现。清单 25-18 中引用的一些代码和类只在可下载的项目中可用。然而,这不应该妨碍对动态代理体系结构的一般理解。

清单 25-18 。Java 动态代理来包装 SQLite API 实现

// File reference in project: DBServicesProxyHandler.Java
/**
 * DBServicesProxyHandler: A class to externalize SQLite Transactions.
 * It is a dynamic proxy. See Services.Java to see how a reference to this is used.
 *
 * This proxy is capable of hosting multiple persistence interfaces.
 * Each interface may represent persistence aspects of a particular entity or a domain object
 * like a Book. Or the interface can be a composite interface dealing with multiple entities.
 *
 * It also uses ThreadLocals to pass the DatabaseContext
 * DatabaseContext holds a reference to the database that is on this thread
 * It also knows how to apply transactions to that database
 * It also knows if the current thread also has a running transaction
 * @See DatabaseContext
 *
 * DatabaseContext provides the SQLiteDatabase reference to
 * the implementation classes.
 *
 * Related classes
 * ****************
 * Services.Java : Client access to interfaces
 * IBookPS: Client interface to deal with persisting a Book
 * BookPSSQLite: SQLite Implementation of IBookPS
 *
 * DBServicesProxyHandler: This class that is a dynamic proxy
 * DatabaseContext: Holds a db reference for BookPSSQLite implementation
 * DirectAccessBookDBHelper: Android DBHelper to construct the database
 *
 */
public class DBServicesProxyHandler implements InvocationHandler {
   private BookPSSQLite bookServiceImpl = new BookPSSQLite();
   private static String tag = "DBServicesProxyHandler";
   DBServicesProxyHandler(){}
   public Object invoke(Object proxy, Method method, Object[] args)
         throws Throwable {
      logMethodSignature(method);
      String mname = method.getName();
      if (mname.startsWith("get")){
         return this.invokeForReads(method, args);
      }
      else {
         return this.invokeForWrites(method, args);
      }
   }
   private void logMethodSignature(Method method){
      String interfaceName = method.getDeclaringClass().getName();
      String mname = method.getName();
      Log.d(tag,String.format("%s : %s", interfaceName, mname));
   }
   private Object callDelegatedMethod(Method method, Object[] args)
   throws Throwable{
      return method.invoke(bookServiceImpl, args);
   }
   private Object invokeForReads(Method method, Object[] args) throws Throwable {
      //See comments above about DatabaseContext
      if (DatabaseContext.isItAlreadyInsideATransaction() == true){
         //It is already bound
         return invokeForReadsWithoutATransactionalWrap(method, args);
      }
      else {
         //A new transaction
         return invokeForReadsWithATransactionalWrap(method, args);
      }

   }
   private Object invokeForReadsWithATransactionalWrap(Method method, Object[] args)
   throws Throwable {
      try   {
         DatabaseContext.setReadableDatabaseContext();
         return callDelegatedMethod(method, args);
      }
      finally   {
         DatabaseContext.reset();
      }
   }
   private Object invokeForReadsWithoutATransactionalWrap(Method method, Object[] args)
   throws Throwable {
      return callDelegatedMethod(method, args);
   }
   private Object invokeForWrites(Method method, Object[] args) throws Throwable   {
      if (DatabaseContext.isItAlreadyInsideATransaction() == true) {
         //It is already bound
         return invokeForWritesWithoutATransactionalWrap(method, args);
      }
      else   {
         //A new transaction
         return invokeForWritesWithATransactionalWrap(method, args);
      }
   }
   private Object invokeForWritesWithATransactionalWrap(Method method, Object[] args)
   throws Throwable   {
      try   {
         DatabaseContext.setWritableDatabaseContext();
         DatabaseContext.beginTransaction();
         Object rtnObject = callDelegatedMethod(method, args);
         DatabaseContext.setTransactionSuccessful();
         return rtnObject;
      }
      finally   {
         try {
            DatabaseContext.endTransaction();
         }
         finally {
            DatabaseContext.reset();
         }
      }
   }
   private Object invokeForWritesWithoutATransactionalWrap(Method method, Object[] args)
   throws Throwable   {
      return callDelegatedMethod(method, args);
   }
}//eof-class

清单 25-18 中的代码是动态代理实现。我们没有包括所有的细节,但是这里有足够的细节来理解这个动态代理如何以自动化的面向方面的方式执行事务。它通过反射检查被调用的方法名,看方法名是否以“get”开头,如果是,那么它假设方法不需要事务上下文。否则,它将当前线程标记为事务上下文。在方法返回时,它成功完成事务。如果在它们之间调用了其他方法,动态代理从线程中知道有一个事务在适当的位置,因此从事务的角度来看忽略了那个方法。

现在,根据你的需要,你可能想根据注解或者接口的其他方面来改变这个协议,但是你明白了。这种将 API 从 UI 中分离出来的方法是很好的设计,您可以使用任意数量的持久性存储,而无需更改您的客户端 UI 代码。我们强烈建议您采用这种方法,而不管您使用的持久性机制,包括 O/R 映射工具。

浏览模拟器和可用设备上的数据库

当您直接或通过内容提供者(下一节)使用 SQLite 作为持久性机制时,您可能希望在设备上检查生成的数据库文件,以便进行调试。

SQLite API 创建的数据库文件保存在以下目录中:

/data/data//数据库

您可以使用 Eclipse Android file explorer 来定位目录并将文件复制到您的本地驱动器,并直接使用 SQLite 提供的本地 SQLite 工具来查看和操作该数据库。

您还可以使用 Android 和 SQLite 提供的工具来检查这些数据库。这些工具中有许多位于<Android-SDK-install-directory>\ tools 子目录中;其他的在 \ <安卓-SDK-安装-目录>\平台-工具。

这些目录中一些有用的命令是

android list avd: To see a list of AVDs or emulators
emulator.exe @avdname: To start an emulator with a given name
adb.exe devices: To see the devices or emulators
adb shell: To open a shell on the emulator or device

您可以从“adb shell”中使用以下命令这些可以在模拟器上运行,但是在真实设备上你需要 root 访问权限。

ls /system/bin : To see available commands
ls -l /: Root level directories
ls /data/data/com.android.providers.contacts/databases: an example
ls -R /data/data/*/databases: To see all databases on the device or emulator

如果在附带的 Android Unix shell 中有一个 find 命令,你可以查看所有的 *。db 文件。但是仅仅用 ls 是没有好办法做到这一点的。你能做的最接近的事情是:

ls -R /data/data/*/databases

使用这个命令,您会注意到 Android 发行版具有清单 25-19 中所示的数据库(根据您的版本,这个列表可能会有所不同):

清单 25-19 。几个示例数据库

alarms.db
contacts.db
downloads.db
internal.db
settings.db
mmssms.db
telephony.db

您可以在 adb shell 中调用这些数据库中的一个数据库上的 sqlite3 ,方法是键入以下命令:

sqlite3 /data/data/com.android.providers.contacts/databases/contacts.db

您可以通过键入以下命令退出 sqlite3 :

sqlite>.exit

注意 adb 的提示是 # , sqlite3 的提示是 sqlite > 。这些提示可能因设备而异。你可以通过访问www.sqlite.org/sqlite.html来了解各种 sqlite3 命令。但是,我们将在这里列出一些重要的命令,这样您就不必去访问 Web 了。您可以通过键入以下命令来查看表格列表

sqlite> .tables

该命令是查询 sqlite_master 表的快捷方式,如清单 25-20 所示(结果输出的格式和结构可能有所不同)。

清单 25-20 。使用 SQLite sqlite_master 表

SELECT name FROM sqlite_master
WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'
UNION ALL
SELECT name FROM sqlite_temp_master
WHERE type IN ('table','view')
ORDER BY 1

表 sqlite_master 是一个主表,它跟踪 sqlite 数据库中的表和视图。下面的命令行为 contacts.db 中的一个名为 people 的表显示一个 create 语句(假设这个数据库存在于您的设备上):

.schema people

这是在 SQLite 中获取表的列名的一种方法。这也将显示列数据类型。使用内容供应器时,您应该注意这些列类型,因为访问方法依赖于它们。还要注意,这可能不是查看这些数据库的实用方法,因为您可能无法在真实设备上访问它们。在这种情况下,您必须依赖内容供应器提供的文档。

您可以在操作系统命令提示符下发出以下命令,将 contacts.db 文件下载到本地文件系统:

adb pull /data/data/com.android.providers.contacts/databases/contacts.db É
c:/somelocaldir/contacts.db

清单 25-21 中的示例 SQL 语句可以帮助您快速浏览 SQLite 数据库(或者您可以使用任何第三方 SQLite 浏览器工具):

清单 25-21 。SQLite 的示例 SQL 代码

--Set the column headers to show in the tool
sqlite>.headers on

--select all rows from a table
select * from table1;

--count the number of rows in a table
select count(*) from table1;

--select a specific set of columns
select col1, col2 from table1;

--Select distinct values in a column
select distinct col1 from table1;

--counting the distinct values
select count(col1) from (select distinct col1 from table1);

--group by
select count(*), col1 from table1 group by col1;

--regular inner join
select * from table1 t1, table2 t2
where t1.col1 = t2.col1;

--left outer join
--Give me everything in t1 even though there are no rows in t2
select * from table t1 left outer join table2 t2
on t1.col1 = t2.col1
where ....

探索内容供应器

在本章的前面,我们谈到了内容提供者在应用之间共享数据。如上所述,内容提供者是数据存储的包装者。数据存储可以是本地的或远程的。数据存储通常是本地设备上的 SQLite 数据库。

要从内容提供者检索数据或将数据保存到内容提供者,您将使用一组类似 REST 的 URIs。例如,如果您要从一个内容供应器那里检索一套图书,该内容供应器是图书数据库的封装,您可能需要使用如下的 URI:

content://com.android.book.BookProvider/books

要从图书数据库中检索特定的图书(比如说图书 23),您可以使用如下的 URI:

content://com.android.book.BookProvider/books/23

在本章中,您将看到这些 URIs 如何转化为底层的数据库访问机制。在设备上具有适当访问权限的任何应用都可以利用这些 URIs 来访问和操作数据。

探索 Android 的内置供应器

Android 带有许多内置的内容提供者,这些内容提供者记录在 SDK 的 android.provider Java 包中。您可以在此处查看这些供应器的列表:

[`developer.android.com/reference/android/provider/package-summary.html`](http://developer.android.com/reference/android/provider/package-summary.html)

供应器包括例如联系人和媒体商店。这些 SQLite 数据库通常有一个扩展名。db 和只能从实现包中访问。该包之外的任何访问都必须通过内容提供者接口。您可以使用上一节“浏览模拟器和可用设备上的数据库”来浏览模拟器上内置提供程序创建的数据库文件。在真实的设备上,这是不可行的,除非您在设备上有 root 访问权限。

了解内容供应器 URIs 的结构

设备上的每个内容提供者都在 Android manifest 文件中注册,就像一个网站,带有一个名为 authority(类似于域名)的字符串标识符。清单 25-22 有两个注册的例子:

清单 25-22 。注册供应器的示例

<!-- File reference in project: AndroidManifest.xml -->
<provider android:name="SomeProviderJavaClass"
        android:authorities="com.your-company.SomeProvider" />

<provider android:name="BookProvider"
   android:authorities="com.androidbook.provider.BookProvider"
/>

唯一的授权字符串形成了该内容供应器提供的一组 URIs 的基础。Android 内容 URI 具有以下结构:

content://<authority-name>/<path-segment1>/<path-segment2>/etc...

下面是一个 URI 的例子,它在图书数据库中找到一本编号为 23 的书:

content:// com.androidbook.provider.BookProvider/books/23

在内容:之后,URI 包含授权,用于在提供者注册中心定位提供者。在前面的例子中,com . Android book . provider . book provider 是 URI 的权威部分。

/books/23 是特定于每个提供者的 URI 的路径部分。书和 23 部分的路段称为路段。记录和解释 URIs 的路段和路段是供应器的责任。因此,内容供应器提供这些类似 REST 的 URL 来检索或操作数据。对于前面的注册,在图书数据库中标识目录或图书集合的 URI 是

content:// com.androidbook.provider.BookProvider/books

URI 确定的一个具体注意事项是

content:// com.androidbook.provider.BookProvider/books/#

其中 # 是特定音符的 id 。清单 25-23 显示了一些 Android 上的数据供应器接受的 URIs 的其他例子:

清单 25-23 。几个示例 Android 内容 URL

content://media/internal/images
content://media/external/images
content://contacts/people/
content://contacts/people/23

请注意,这些提供者的媒体(内容://媒体)和联系人(内容://联系人)没有完全限定的授权名称。这是因为 Android 提供的提供者可能没有完全合格的授权名称。

给定这些内容 URIs,提供者需要检索 URIs 表示的行。还期望提供者使用任何状态改变方法在这个 URI 改变内容:插入、更新或删除。

实施内容供应器

让我们通过实现和使用一个来充分理解内容提供者。要编写一个内容提供者,你得扩展 Android . content . content provider 并实现以下关键方法: query() 、 insert() 、 update() 、 delete() 、 getType() 。

您需要设置一些东西来实现这些方法。实现内容提供者需要以下步骤:

  1. 规划数据库、URIs、列名等,并创建一个元数据类,为所有这些元数据元素定义常量。
  2. 扩展抽象类 ContentProvider。
  3. 实现这些方法:查询、插入、更新、删除和获取类型。
  4. 在清单文件中注册提供程序。
  5. 使用内容供应器。

规划数据库

为了探究这个主题,我们将创建一个数据库,它类似于我们在图书收藏中使用的数据库,用于直接演示在 SQLite 中存储数据。请注意,为了防止数据库相互冲突,一些名称可能会不同。

图书数据库只包含一个名为图书的表,它的列是名称、 isbn 和作者。这些列名属于元数据。您将在 Java 类中定义这种相关的元数据。这个承载元数据的 Java 类 BookProviderMetaData 如清单 25-24 所示。

清单 25-24 。为数据库定义元数据

// File reference in project: BookProviderMetaData.Java
public class BookProviderMetaData {
    public static final String AUTHORITY = "com.androidbook.provider.BookProvider";

    public static final String DATABASE_NAME = "book.db";
    public static final int DATABASE_VERSION = 1;
    public static final String BOOKS_TABLE_NAME = "books";

    private BookProviderMetaData() {}

    //inner class describing BookTable
    public static final class BookTableMetaData implements BaseColumns  {
        private BookTableMetaData() {}
        public static final String TABLE_NAME = "books";

        //uri and MIME type definitions
        public static final Uri CONTENT_URI =
                          Uri.parse("content://" + AUTHORITY + "/books");
        public static final String CONTENT_TYPE =
                         "vnd.android.cursor.dir/vnd.androidbook.book";
        public static final String CONTENT_ITEM_TYPE =
                        "vnd.android.cursor.item/vnd.androidbook.book";

        public static final String DEFAULT_SORT_ORDER = "modified DESC";

        //Additional Columns start here.
        //string type
        public static final String BOOK_NAME = "name";
        //string type
        public static final String BOOK_ISBN = "isbn";
        //string type
        public static final String BOOK_AUTHOR = "author";
        //Integer from System.currentTimeMillis()
        public static final String CREATED_DATE = "created";
        //Integer from System.currentTimeMillis()
        public static final String MODIFIED_DATE = "modified";
    }
}

这个 BookProviderMetaData 类首先将其权限定义为 com . androidbook . provider . book provider。

然后,这个类继续将其一个表( books )定义为内部的 BookTableMetaData 类。然后, BookTableMetaData 类定义了一个 URI 来标识图书集合。给定上一段中的权限,URI 的藏书将是这样的:

content://com.androidbook.provider.BookProvider/books

这个 URI 由常数表示

BookProviderMetaData.BookTableMetaData.CONTENT_URI

然后, BookTableMetaData 类继续为一组书籍和一本书籍定义 MIME 类型。提供者实现将使用这些常量来返回传入 URIs 的 MIME 类型。MIME 类型类似于 HTTP 定义的 MIME 类型。作为指南,通过 Android 光标返回的项目集合的主要 MIME 类型应该始终是 vnd.android.cursor.dir ,通过 Android 光标检索的单个项目的主要 MIME 类型应该是 vnd.android.cursor.item 。当涉及到子类型时,您有更多的回旋余地,如清单 25-24 中的 vnd.androidbook.book 。

BookTableMetaData 然后定义图书表的列集合:名称, isbn ,作者,创建(创建日期),以及修改(最后更新日期)。

元数据类 BookTableMetaData 也继承了提供标准 _id 字段的 BaseColumns 类,该字段表示行 id。有了这些元数据定义,我们就可以开始处理提供者实现了。

扩展 ContentProvider

实现 BookProvider 包括扩展 ContentProvider 类并覆盖 onCreate() 来创建数据库,然后实现查询、插入、更新、删除和获取类型方法。

查询方法需要它需要返回的一组列。这类似于一个 select 子句,该子句需要列名及其对应的作为(有时称为同义词)。按照惯例,Android SDK 使用一个称为投影映射的映射对象来表示这些列名及其同义词。我们将需要设置这个映射,以便稍后在查询方法实现中使用它。在提供者实现的代码中(见清单 25-26 ,你会看到这是作为投影贴图设置的一部分提前完成的。

我们将为内容供应器契约实现的大多数方法都将 URI 作为输入。清单 25-25 显示了图书供应商 URI 的例子:

清单 25-25 。图书供应器内容 URIs 示例

Uri1: content://com.androidbook.provider.BookProvider/books
Uri2: content://com.androidbook.provider.BookProvider/books/12

图书供应商需要区分这些 URIs 中的每一个。 BookProvider 是一个简单的案例。如果我们的图书供应商除了图书之外还存放了更多的对象,那么就会有更多的 URIs 来识别这些额外的对象。

提供者实现需要一种机制来区分 fromAndroid 为此使用了一个名为 UriMatcher 的类。所以我们需要用所有的 URI 变体来设置这个对象。在我们定义了投影图之后,你会在清单 25-26 中看到这段代码。我们将在“使用 UriMatcher 计算 URIs”一节中进一步解释 UriMatcher 类

清单 25-26 中的代码覆盖了 onCreate() 方法以方便数据库的创建。数据库的创建与我们在直接使用 SQLite 来满足内部持久性需求的过程中介绍的数据库创建是相同的。

清单 25-26 中的源代码实现了 insert() 、 query() 、 update() 、 getType() 和 delete() 方法。所有这些的代码都在清单 25-26 中给出,但是我们将在单独的小节中解释每个方面。

清单 25-26 。实现 BookProvider 内容提供者

// File reference in project: BookProvider.Java
public class BookProvider extends ContentProvider
{
    //Logging helper tag. No significance to providers.
    private static final String TAG = "BookProvider";

    //Setup projection Map
    //Projection maps are similar to "as" (column alias) construct
    //in an sql statement where by you can rename the
    //columns.
    private static HashMap<String, String> sBooksProjectionMap;
    static
    {
        sBooksProjectionMap = new HashMap<String, String>();
        sBooksProjectionMap.put(BookTableMetaData._ID,
                                BookTableMetaData._ID);

        //name, isbn, author
        sBooksProjectionMap.put(BookTableMetaData.BOOK_NAME,
                                BookTableMetaData.BOOK_NAME);
        sBooksProjectionMap.put(BookTableMetaData.BOOK_ISBN,
                                BookTableMetaData.BOOK_ISBN);
        sBooksProjectionMap.put(BookTableMetaData.BOOK_AUTHOR,
                                BookTableMetaData.BOOK_AUTHOR);

        //created date, modified date
        sBooksProjectionMap.put(BookTableMetaData.CREATED_DATE,
                                BookTableMetaData.CREATED_DATE);
        sBooksProjectionMap.put(BookTableMetaData.MODIFIED_DATE,
                                BookTableMetaData.MODIFIED_DATE);
    }

    //Provide a mechanism to identify all the incoming uri patterns.
    private static final UriMatcher sUriMatcher;
    private static final int INCOMING_BOOK_COLLECTION_URI_INDICATOR = 1;
    private static final int INCOMING_SINGLE_BOOK_URI_INDICATOR = 2;
    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(BookProviderMetaData.AUTHORITY, "books",
                          INCOMING_BOOK_COLLECTION_URI_INDICATOR);
        sUriMatcher.addURI(BookProviderMetaData.AUTHORITY, "books/#",
                          INCOMING_SINGLE_BOOK_URI_INDICATOR);

    }
    // Setup/Create Database to use for the implementation
    private static class DatabaseHelper extends SQLiteOpenHelper {
        DatabaseHelper(Context context) {
            super(context,
                BookProviderMetaData.DATABASE_NAME,
                null,
                BookProviderMetaData.DATABASE_VERSION);
        }
        @Override
        public void onCreate(SQLiteDatabase db)     {
            Log.d(TAG,"inner oncreate called");
            db.execSQL("CREATE TABLE " + BookTableMetaData.TABLE_NAME + " ("
                    + BookTableMetaData._ID + " INTEGER PRIMARY KEY,"
                    + BookTableMetaData.BOOK_NAME + " TEXT,"
                    + BookTableMetaData.BOOK_ISBN + " TEXT,"
                    + BookTableMetaData.BOOK_AUTHOR + " TEXT,"
                    + BookTableMetaData.CREATED_DATE + " INTEGER,"
                    + BookTableMetaData.MODIFIED_DATE + " INTEGER"
                    + ");");
        }
        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            Log.d(TAG,"inner onupgrade called");
            Log.w(TAG, "Upgrading database from version "
                    + oldVersion + " to "
                    + newVersion + ", which will destroy all old data");
            db.execSQL("DROP TABLE IF EXISTS " +
                     BookTableMetaData.TABLE_NAME);
            onCreate(db);
        }
    }//eof-inner DatabaseHelper class
    //This is initialized in the onCreate() method
    private DatabaseHelper mOpenHelper;

    //Component creation callback
    @Override
    public boolean onCreate()   {
        Log.d(TAG,"main onCreate called");
        mOpenHelper = new DatabaseHelper(getContext());
        return true;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
            String[] selectionArgs,  String sortOrder)   {
        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();

        switch (sUriMatcher.match(uri)) {
        case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
            qb.setTables(BookTableMetaData.TABLE_NAME);
            qb.setProjectionMap(sBooksProjectionMap);
            break;

        case INCOMING_SINGLE_BOOK_URI_INDICATOR:
            qb.setTables(BookTableMetaData.TABLE_NAME);
            qb.setProjectionMap(sBooksProjectionMap);
            qb.appendWhere(BookTableMetaData._ID + "="
                        + uri.getPathSegments().get(1));
            break;

        default:
            throw new IllegalArgumentException("Unknown URI " + uri);
        }

        // If no sort order is specified use the default
        String orderBy;
        if (TextUtils.isEmpty(sortOrder)) {
            orderBy = BookTableMetaData.DEFAULT_SORT_ORDER;
        } else {
            orderBy = sortOrder;
        }

        // Get the database and run the query
        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
        Cursor c = qb.query(db, projection, selection,
                   selectionArgs, null, null, orderBy);

        //example of getting a count
        int i = c.getCount();

        // Tell the cursor what uri to watch,
        // so it knows when its source data changes
        c.setNotificationUri(getContext().getContentResolver(), uri);
        return c;
    }
    @Override
    public String getType(Uri uri)    {
        switch (sUriMatcher.match(uri)) {
        case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
            return BookTableMetaData.CONTENT_TYPE;
        case INCOMING_SINGLE_BOOK_URI_INDICATOR:
            return BookTableMetaData.CONTENT_ITEM_TYPE;
        default:
            throw new IllegalArgumentException("Unknown URI " + uri);
        }
    }
    @Override
    public Uri insert(Uri uri, ContentValues initialValues)  {
        // Validate the requested uri
        if (sUriMatcher.match(uri)
                != INCOMING_BOOK_COLLECTION_URI_INDICATOR) {
            throw new IllegalArgumentException("Unknown URI " + uri);
        }
        ContentValues values;
        if (initialValues != null) {
            values = new ContentValues(initialValues);
        } else {
            values = new ContentValues();
        }
        Long now = Long.valueOf(System.currentTimeMillis());
        // Make sure that the fields are all set
        if (values.containsKey(BookTableMetaData.CREATED_DATE) == false){
            values.put(BookTableMetaData.CREATED_DATE, now);
        }
        if (values.containsKey(BookTableMetaData.MODIFIED_DATE) == false) {
            values.put(BookTableMetaData.MODIFIED_DATE, now);
        }
        if (values.containsKey(BookTableMetaData.BOOK_NAME) == false) {
            throw new SQLException(
               "Failed to insert row because Book Name is needed " + uri);
        }
        if (values.containsKey(BookTableMetaData.BOOK_ISBN) == false) {
            values.put(BookTableMetaData.BOOK_ISBN, "Unknown ISBN");
        }
        if (values.containsKey(BookTableMetaData.BOOK_AUTHOR) == false) {
            values.put(BookTableMetaData.BOOK_ISBN, "Unknown Author");
        }

        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        long rowId = db.insert(BookTableMetaData.TABLE_NAME,
                BookTableMetaData.BOOK_NAME, values);
        if (rowId > 0) {
            Uri insertedBookUri =
                ContentUris.withAppendedId(
                        BookTableMetaData.CONTENT_URI, rowId);
            getContext()
               .getContentResolver()
                    .notifyChange(insertedBookUri, null);

            return insertedBookUri;
        }
        throw new SQLException("Failed to insert row into " + uri);
    }
    @Override
    public int delete(Uri uri, String where, String[] whereArgs) {
        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        int count;
        switch (sUriMatcher.match(uri)) {
        case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
            count = db.delete(BookTableMetaData.TABLE_NAME,
                    where, whereArgs);
            break;
        case INCOMING_SINGLE_BOOK_URI_INDICATOR:
            String rowId = uri.getPathSegments().get(1);
            count = db.delete(BookTableMetaData.TABLE_NAME,
                    BookTableMetaData._ID + "=" + rowId
                    + (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""),
                    whereArgs);
            break;
        default:
            throw new IllegalArgumentException("Unknown URI " + uri);
        }

        getContext().getContentResolver().notifyChange(uri, null);
        return count;
    }
    @Override
    public int update(Uri uri, ContentValues values,
            String where, String[] whereArgs)  {
        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        int count;
        switch (sUriMatcher.match(uri)) {
        case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
            count = db.update(BookTableMetaData.TABLE_NAME,
                    values, where, whereArgs);
            break;

        case INCOMING_SINGLE_BOOK_URI_INDICATOR:
            String rowId = uri.getPathSegments().get(1);
            count = db.update(BookTableMetaData.TABLE_NAME,
                    values, BookTableMetaData._ID + "=" + rowId
                    + (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""),
                    whereArgs);
            break;

        default:
            throw new IllegalArgumentException("Unknown URI " + uri);
        }

        getContext().getContentResolver().notifyChange(uri, null);
        return count;
    }
}

现在,让我们一段一段地分析这段代码。

使用 UriMatcher 计算出 URIs

我们已经多次提到了 UriMatcher 类;让我们调查一下。内容提供者中的几乎所有方法相对于 URI 都是重载的。例如,无论您想要检索单本书还是一系列书,都会调用相同的 query() 方法。由方法决定是否知道正在请求哪种类型的 URI。Android 的 UriMatcher 工具类帮助你识别 URI 类型。

它是这样工作的。您告诉一个 UriMatcher 的实例,在它的初始化过程中会出现什么样的 URI 模式。您还将为每个模式关联一个唯一的编号。一旦这些模式被注册,您就可以询问 UriMatcher 传入的 URI 是否匹配某个模式。

正如我们提到的,我们的 book providercontent provider 有两种 URI 模式:一种针对一系列书籍,另一种针对单本书籍。清单 25-26 中的代码使用 UriMatcher 注册了这两种模式。它为图书集分配 1 ,为单本书分配 2(URI 模式本身在图书表的元数据中定义)。您可以在清单 25-26 中的变量 sUriMatcher 的静态初始化中看到这一点。然后,您可以看到 UriMatcher 如何在 query() 方法实现中发挥作用,使用常数区分每种类型 URI 的 URIs。

使用投影地图

内容提供者就像数据库中抽象列集和真实列集之间的中介,但是这些列集可能不同。在构造查询时,您必须在客户端指定的 where 子句列和实际的数据库列之间进行映射。您在 SQLiteQueryBuilder 类的帮助下建立了这个投影地图。您可以看到这个投影映射变量 sBooksProjectionMap 是如何为清单 25-26 中的 BookProvider 设置的。您还可以在清单中看到这个变量 sBooksProjectionMap 如何被 SQLite QueryBuilder 对象使用。

履行模拟合同

让我们从清单 25-26 中的 getType () 方法开始。此方法返回给定 URI 的 MIME 类型。像内容供应器的许多其他方法一样,这种方法对输入的 URI 很敏感。因此, getType() 方法的首要职责就是区分 URI 的类型。是藏书还是单本?代码使用了 UriMatcher 来破译这种 URI 类型。根据这个 URI, BookTableMetaData 类定义了为每个 URI 返回的 MIME 类型常量。

实现查询方法

像其他方法一样,查询方法使用 UriMatcher 来识别 URI 类型。如果 URI 类型是单项类型,该方法通过查看由 getPathSegments() 返回的第一个段,从传入的 URI 中检索图书 ID。

然后,查询方法使用我们在清单 25-26 中预先创建的投影来标识返回列。最后,查询将光标返回给调用者。在整个过程中,查询方法使用 SQLiteQueryBuilder 对象来制定和执行查询。

读取数据时,可以使用 URI 或通过作为输入传递给查询方法的显式 where 子句参数来约束返回的行。在清单 25-26 的 BookProvider 实现中,我们使用了使用 URI 段检索图书 ID 的方法来返回该书的值。

相反,您可以使用 query() 方法的 selection 参数和 selectionArgs 参数来显式传递 where 子句参数。这些参数的工作方式就像清单 25-12 中的 SQLiteDatabase.query() 参数,其中“?”用作在 selectionArgs 数组中传递的值的占位符。

实现插入方法

内容提供者中的 insert 方法负责将记录插入底层数据库,然后返回指向新创建记录的 URI。

像其他方法一样, insert 使用 UriMatcher 来识别 URI 类型。代码首先检查 URI 是否指示正确的集合类型 URI。否则,代码会抛出异常。

然后,代码验证可选和强制的列参数。如果某些列缺少默认值,该代码可以替换它们。

接下来,代码使用一个 SQLiteDatabase 对象来插入新记录,并返回新插入的 ID。最后,代码使用数据库返回的 ID 构造新的 URI。

实施更新方法

内容提供者中的 update 方法负责根据传入的列值以及传入的 where 子句更新记录。然后, update 方法返回流程中更新的行数。

像其他方法一样, update 使用 UriMatcher 来识别 URI 类型。如果 URI 类型是一个集合,那么 where 子句被传递,因此它可以影响尽可能多的记录。如果 URI 类型是单记录类型,那么从 URI 中提取图书 ID,并指定为附加的 where 子句。最后,代码返回更新的记录数。还要注意这个 notifyChange 方法如何让您向全世界宣布 URI 的数据已经更改。潜在地,你可以在 insert 方法中做同样的事情,比如说 URI 的图书数据集合".../books 在插入记录时已经改变。

实现删除方法

内容提供者中的 delete 方法负责根据传入的 where 子句删除一个(或多个)记录。然后, delete 方法返回流程中删除的行数。

像其他方法一样, delete 使用 UriMatcher 来识别 URI 类型。如果 URI 类型是集合类型,那么 where 子句将被传递,这样您就可以删除尽可能多的记录。如果 where 子句为 null ,所有记录将被删除。如果 URI 类型是单记录类型,则从 URI 中提取图书 ID,并指定为附加的 where 子句。最后,代码返回删除的记录数。

注册供应器

最后,您必须在 Android 中注册内容供应器。使用清单 25-27 中的标签结构的 Manifest.xml 文件。提供者是一个组件,因此是其他组件(如活动和接收者)的兄弟。所以它是 Android 清单文件中其他活动的兄弟节点。

清单 25-27 。注册供应器

<provider android:name=".BookProvider"
   android:authorities="com.androidbook.provider.BookProvider"/>

行使图书供应商

现在我们有了一个图书提供者,我们将向您展示使用该提供者的示例代码。示例代码包括添加一本书、删除一本书、计算书的数量,最后显示所有的书。

请记住,这些是从示例项目中提取的代码,不会被编译,因为它们需要额外的依赖文件。但是,我们认为这个示例代码足以展示我们所探索的概念。

在本章的最后,我们包含了一个到可下载的示例项目的链接,您可以在您的 Eclipse 环境中使用它来编译和测试。

添加图书

清单 25-28 中的代码将一本新书插入图书数据库。

清单 25-28 。练习提供者插入

// File reference in project:ProviderTester.Java
public void addBook(Context context) {
    String tag = "Exercise BookProvider";
    Log.d(tag,"Adding a book");
    ContentValues cv = new ContentValues();
    cv.put(BookProviderMetaData.BookTableMetaData.BOOK_NAME, "book1");
    cv.put(BookProviderMetaData.BookTableMetaData.BOOK_ISBN, "isbn-1");
    cv.put(BookProviderMetaData.BookTableMetaData.BOOK_AUTHOR, "author-1");

    ContentResolver cr = context.getContentResolver();
    Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
    Log.d(tag,"book insert uri:" + uri);
    Uri insertedUri = cr.insert(uri, cv);
    Log.d(tag,"inserted uri:" + insertedUri);
}

移除一本书

清单 25-29 中的代码从图书数据库中删除最后一条记录。

清单 25-29 。执行提供者删除

// File reference in project:ProviderTester.Java
public void removeBook() {
   int firstBookId = this.getFirstBookId();
   if (firstBookId == -1) throw new SQLException("Book id is less than 0");
   ContentResolver cr = this.mContext.getContentResolver();
   Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
   Uri delUri = Uri.withAppendedPath(uri, Integer.toString(firstBookId));
   reportString("Del Uri:" + delUri);
   cr.delete(delUri, null, null);
   this.reportString("Number of Books after the delete:" + getCount());
}

private int getFirstBookId() {
   Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
   Activity a = (Activity)this.mContext;
   Cursor c = null;
   try   {
      c = a.getContentResolver().query(uri,
            null, //projection
            null, //selection string
            null, //selection args array of strings
            null); //sort order
      int numberOfRecords = c.getCount();
      if (numberOfRecords == 0) {
         return -1;
      }
      c.moveToFirst();
      int id = c.getInt(1); //id column
      return id;
   }
   finally   {
      if (c!= null) c.close();
   }
}

显示图书列表

清单 25-30 中的代码检索图书数据库中的所有记录。

清单 25-30 。显示图书列表

// File reference in project:ProviderTester.Java
public void showBooks() {
   Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
   Activity a = (Activity)this.mContext;
   Cursor c = null;
   try  {
      c = a.getContentResolver().query(uri,
            null, //projection
            null, //selection string
            null, //selection args array of strings
            null); //sort order
      int iid = c.getColumnIndex(BookProviderMetaData.BookTableMetaData._ID);
      int iname = c.getColumnIndex(BookProviderMetaData.BookTableMetaData.BOOK_NAME);
      int iisbn = c.getColumnIndex(BookProviderMetaData.BookTableMetaData.BOOK_ISBN);
      int iauthor = c.getColumnIndex(BookProviderMetaData.BookTableMetaData.BOOK_AUTHOR);

      //Report your indexes
      Log.d(tag, "name,isbn,author:" + iname + iisbn + iauthor);

      //walk through the rows based on indexes
      for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
         //Gather values
         String id = c.getString(iid);
         String name = c.getString(iname);
         String isbn = c.getString(iisbn);
         String author = c.getString(iauthor);

         //Report or log the row
         StringBuffer cbuf = new StringBuffer(id);
         cbuf.append(",").append(name);
         cbuf.append(",").append(isbn);
         cbuf.append(",").append(author);
         Log.d(tag,cbuf.toString());
      }

      //Report how many rows have been read
      int numberOfRecords = c.getCount();
      Log.d(tag,"Num of Records:" + numberOfRecords);
   }
   finally   {
      if (c!= null) c.close();
   }
}

请注意,从内容供应器处检索图书的方法与从 SQLite 数据库中检索数据非常相似。在清单 25-30 中,我们使用了来自 ContentResolver 对象的 query() 方法。使用光标对象后,我们关闭了光标。

相反,如果您将这个光标对象传递给一个位于活动中的 UI 组件,那么这个光标对象需要被管理,因为活动遵循它的生命周期。在 Honeycomb 之前,在活动上有一个名为 managedQuery() 的方法来自动完成这项工作,但该方法已经被弃用,取而代之的是 CursorLoader。

当通过 managedQuery() 管理一个查询时,活动可以调用游标上的方法,将其置于适当的状态。例如,活动在停止时会调用光标上的 deactivate() ,稍后在开始时会调用 requery() 。当活动被销毁时,光标将被关闭。如果您想自己控制光标的行为,可以选择对该光标调用 stopManagingCursor() 。因为活动关闭了游标,所以不要关闭托管游标。如果您打算一次读取所有行并关闭游标,则使用 ContentResolver 的 query() 方法,而不是 activity . managed query()方法并显式关闭游标。

自 Honeycomb 以来,游标读取被打包成一种更通用的方法,称为“加载器”,它允许您通过暴露给片段或活动的回调在异步线程中读取数据。这是推荐和首选的方法。我们将在下一章关于加载器的第二十六章中介绍这种方法。

您已经看到了我们如何在内容供应器上使用更新 API。如果通过内容供应器一个接一个地进行,这些更新操作可能是低效的。在第二十七章中,我们将介绍如何将这些单独的更新操作批量发送给内容供应器,以提高效率。

资源

以下是一些额外的 Android 资源,可以帮助您了解本章涵盖的主题:

摘要

本章涵盖了应用的一个重要需求的许多方面:持久性。我们已经为您提供了 Android 中过多的持久性选项,以及如何选择合适的选项。我们已经非常详细地介绍了如何使用 SQLite 来满足内部持久性需求。我们向您展示了使用 SQLite 实现持久性的工业级 API 模式,该模式可以扩展到任何持久性实现。重要的是,该模式向您展示了如何将事务外部化,以保持您的持久性代码简单。然后,我们讨论了什么是内容提供者,内容 URIs 的本质,MIME 类型,如何使用 SQLite 来构造响应 URIs 的提供者,如何编写新的内容提供者,以及如何访问现有的内容提供者。

二十六、了解加载器

本章着眼于通过推荐的加载器机制从数据源加载数据。加载器的 API 被设计用来处理通过活动和片段加载数据的两个问题。

首先是活动的不确定性,活动可能部分或全部隐藏,由于设备轮换而重新启动,或者由于内存不足而在后台从内存中删除。这些事件称为活动生命周期事件。任何检索数据的代码都必须与活动生命周期事件协调工作。在 3.0 (API 11)中引入加载器之前,这是通过管理的游标来处理的。这种机制现已停产,取而代之的是加载器。

在活动和片段中加载数据的第二个问题是,数据访问在主线程上可能需要更长时间,从而导致应用不响应(ANR)消息。加载器通过在工作线程上完成工作,并提供对活动和片段的回调来响应数据获取的异步特性,从而解决了这个问题。

了解加载器的架构

加载器使得异步加载活动或片段中的数据变得容易。多个加载器,每个都有自己的数据集,可以与一个活动或一个片段相关联。加载器还监控其数据源,并在数据内容发生变化时提供新的结果。配置更改后重新创建时,加载程序会自动重新连接到先前检索的数据结构,就像光标 。由于前一个游标未被销毁,因此不会重新查询数据。

当我们在本章中讨论加载器时,加载器的所有方面都适用于活动和片段,除非我们从现在开始另外指明。

每个活动都使用一个单独的 LoaderManager 对象来管理与该活动相关的加载器。一旦加载器向加载器管理器注册, LoaderManager 将促进必要的回调,以 a)创建并初始化加载器,b)当加载器完成加载数据时读取数据,以及 c)当加载器由于不再需要活动而即将被销毁时关闭资源。 LoaderManager 对你是隐藏的,你通过回调和 LoaderManager 公共 API 来使用它。LoaderManager 的创建由活动控制。LoaderManager 几乎就像是活动本身不可或缺的一部分。

已注册的加载器负责使用其数据源,并且还负责使用加载器管理器读取数据并将结果发送回加载器管理器。然后, LoaderManager 将调用数据准备好的活动的回调。加载器还负责暂停数据访问或监控数据更改,或者与加载器管理器一起工作,以了解活动生命周期事件并对其做出反应。

虽然您可以通过扩展加载器 API 为您的特定数据需求从头开始编写加载器,但是您通常使用已经在 SDK 中实现的加载器。大多数加载器都扩展了 AsyncTaskLoader ,它提供了在工作线程上完成工作的基本能力,从而释放了主线程。当工作线程返回数据时, LoaderManager 将调用活动的主回调,表明数据已在主线程上准备好。

这些预构建加载器中最常用的是光标加载器 。随着光标加载器的出现,使用加载器变得非常非常简单,只需要几行代码。这是因为所有的细节都隐藏在 LoaderManager 、 Loader 、 AsyncTaskLoader 和 CursorLoader 之后。

列出基本加载器 API 类

清单 26-1 列出了加载器 API 中涉及的关键类。

清单 26-1 。Android Loader API 关键参与类

LoaderManager
LoaderManager.LoaderCallbacks
Loader
AsyncTaskLoader
CursorLoader

最常用的 API 是 LoaderManager。LoaderCallbacks 和 CursorLoader 。但是,让我们简单介绍一下这些类。

每个活动有一个 LoaderManager 对象。这个对象定义了加载器应该如何工作的协议。因此 LoaderManager 是与活动相关的加载器的协调器。 LoaderManager 与活动的交互是通过 LoaderManager 实现的。LoaderCallbacks 。在这些加载器回调中,加载器通过 LoaderManager 向您提供数据,并期望您与活动进行交互。

Loader 类定义了如果想设计自己的加载器必须遵守的协议。 AsyncTaskLoader 就是一个例子,它在工作线程上以异步方式实现加载器协议。通常,AsyncTaskLoader 是实现大多数加载器的基类。 CursorLoader 是这个 AsyncTaskLoader 的一个实现,它知道如何从内容供应器那里加载光标。如果一个人正在实现自己的加载器,那么理解来自 LoaderManager 的与加载器的所有交互都发生在主线程上是很重要的。甚至由活动实现的 LoaderManager 回调也发生在主线程上。

展示加载器

我们现在将通过实现一个简单的单页应用(图 26-1 )向您展示如何使用加载器,该应用从 Android 设备上的联系人提供者数据库中加载联系人。这个应用是开发 Android 活动的典型方式。您甚至可以将这个示例项目用作起始应用模板。

9781430246800_Fig26-01.jpg

图 26-1 。通过加载器加载的联系人过滤列表

我们希望图 26-1 中的活动展现出以下特征 : 1)它应该显示设备上的所有联系人;b)它应该异步检索数据;c)在检索数据时,活动应该显示进度条视图,而不是列表视图;d)在成功检索数据时,代码应该用填充的列表视图替换进度视图;e)活动应提供一种搜索机制,以筛选必要的联系人;f)当旋转设备时,它应该再次显示联系人,而无需向联系人内容提供者进行重新查询;g)代码应该允许我们看到回调的顺序以及活动生命周期回调。

我们将首先展示活动的源代码,然后解释每个部分。到本章结束时,你会清楚地了解加载器是如何工作的,以及如何在你的代码中使用它们。至此,清单 26-2 显示了图 26-1 的活动代码。请注意清单 26-2 中的代码依赖于这里提供的一些资源。其中一些字符串资源你可以在图 26-1 中看到,但是对于其他的和这里没有包括的代码,请查看可下载的项目。和往常一样,这里给出的代码对于当前的主题来说已经足够了。

清单 26-2 。用加载器加载数据的活动

public class TestLoadersActivity
extends      MonitoredListActivity //very simple class to log activity callbacks
implements   LoaderManager.LoaderCallbacks<Cursor> //Loader Manager callbacks
             ,OnQueryTextListener //Search text callback to filter contacts
{
   private static final String tag = "TestLoadersActivity";

   //Adapter for displaying the list's data
   //Initialized to null cursor in onCreate and set on the list
   //Use it in later callbacks to swap cursor
   //This is reinitialized to null cursor when rotation occurs
    SimpleCursorAdapter mAdapter;

    //Search filter working with OnQueryTextListener
    String mCurFilter;

    //Contacts columns that we will retrieve
    static final String[] PROJECTION = new String[] {ContactsContract.Data._ID,
            ContactsContract.Data.DISPLAY_NAME};

    //select criteria for the contacts URI
    static final String SELECTION = "((" +
            ContactsContract.Data.DISPLAY_NAME + " NOTNULL) AND (" +
            ContactsContract.Data.DISPLAY_NAME + " != '' ))";

    public TestLoadersActivity()  {
       super(tag);
    }
    @Override
    protected void onCreate(Bundle savedInstanceState)  {
       super.onCreate(savedInstanceState);
       this.setContentView(R.layout.test_loaders_activity_layout);

       //Initialize the adapter
       this.mAdapter = createEmptyAdapter();
       this.setListAdapter(mAdapter);

       //Hide the listview and show the progress bar
       this.showProgressbar();

       //Initialize a loader for an id of 0
       getLoaderManager().initLoader(0, null, this);
    }
    //Create a simple list adapter with a null cursor
    //The good cursor will come later in the loader callback
    private SimpleCursorAdapter createEmptyAdapter() {
       // For the cursor adapter, specify which columns go into which views
        String[] fromColumns = {ContactsContract.Data.DISPLAY_NAME};
        int[] toViews = {android.R.id.text1}; // The TextView in simple_list_item_1
        //Return the cursor
       return new SimpleCursorAdapter(this,
             android.R.layout.simple_list_item_1,
             null, //cursor
             fromColumns,
             toViews);
    }
   //This is a LoaderManager callback. Return a properly constructed CursorLoader
   //This gets called only if the loader does not previously exist.
   //This means this method will not be called on rotation because
   //a previous loader with this ID is already available and initialized.
   //This also gets called when the loader is "restarted" by calling
   //LoaderManager.restartLoader()
   @Override
   public Loader<Cursor> onCreateLoader(int id, Bundle args) {
      Log.d(tag,"onCreateLoader for loader id:" + id);
      Uri baseUri;
      if (mCurFilter != null) {
          baseUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_FILTER_URI,
                 Uri.encode(mCurFilter));
      } else {
          baseUri = Contacts.CONTENT_URI;
      }
      String[] selectionArgs = null;
      String sortOrder = null;
      return new CursorLoader(this, baseUri,
            PROJECTION, SELECTION, selectionArgs, sortOrder);
   }
   //This is a LoaderManager callback. Use the data here.
   //This gets called when he loader finishes. Called on the main thread.
   //Can be called multiple times as the data changes underneath.
   //Also gets called after rotation with out requerying the cursor.
   @Override
   public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
       Log.d(tag,"onLoadFinished for loader id:" + loader.getId());
       Log.d(tag,"Number of contacts found:" + cursor.getCount());
        this.hideProgressbar();
        this.mAdapter.swapCursor(cursor);
   }
   //This is a LoaderManager callback. Remove any references to this data.
   //This gets called when the loader is destroyed like when activity is done.
   //FYI - this does NOT get called because of loader "restart"
   //This can be seen as a "destructor" for the loader.
   @Override
   public void onLoaderReset(Loader<Cursor> loader) {
      Log.d(tag,"onLoaderReset for loader id:" + loader.getId());
      this.showProgressbar();
      this.mAdapter.swapCursor(null);
   }
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Place an action bar item for searching.
        MenuItem item = menu.add("Search");
        item.setIcon(android.R.drawable.ic_menu_search);
        item.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
        SearchView sv = new SearchView(this);
        sv.setOnQueryTextListener(this);
        item.setActionView(sv);
        return true;
    }
    //This is a Searchview callback. Restart the loader.
    //This gets called when user enters new search text.
    //Call LoaderManager.restartLoader to trigger the onCreateLoader
    @Override
    public boolean onQueryTextChange(String newText) {
        // Called when the action bar search text has changed.  Update
        // the search filter, and restart the loader to do a new query
        // with this filter.
        mCurFilter = !TextUtils.isEmpty(newText) ? newText : null;
        Log.d(tag,"Restarting the loader");
        getLoaderManager().restartLoader(0, null, this);
        return true;
    }
    @Override
    public boolean onQueryTextSubmit(String query) {
        return true;
    }
    private void showProgressbar() {
       //show progress bar
       View pbar = this.getProgressbar();
       pbar.setVisibility(View.VISIBLE);
       //hide listview
       this.getListView().setVisibility(View.GONE);
       findViewById(android.R.id.empty).setVisibility(View.GONE);
    }
    private void hideProgressbar()  {
       //show progress bar
       View pbar = this.getProgressbar();
       pbar.setVisibility(View.GONE);
       //hide listview
       this.getListView().setVisibility(View.VISIBLE);
    }
    private View getProgressbar()  {
       return findViewById(R.id.tla_pbar);
    }
}//eof-class

在我们向您展示了清单 26-3 中活动代码的支持布局之后,我们将解释清单 26-2 中的每一部分。清单 26-3 中的布局应该能阐明图 26-1 中的视图(请注意,这里没有包括一些资源,但是可以在 apress.com/9781430246800 的可下载文件中找到)。

清单 26-3 。加载器的典型列表活动布局

<?xml version="1.0" encoding="utf-8"?>
<!--
*********************************************
* /res/layout/test_loaders_activity_layout.xml
* corresponding activity: TestLoadersActicity.java
* prefix: tla_ (Used for prefixing unique identifiers)
*
* Use:
*    Demonstrate loading a cursor using loaders
* Structure:
*    Header message: text view (tla_header)
*    ListView Heading, ListView (fixed)
*    ProgressBar (To show when data is being fetched)
*    Empty View (To show when the list is empty): ProgressBar
*    Footer: text view (tla_footer)
************************************************
-->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    android:orientation="vertical"
    android:layout_width="match_parent"  android:layout_height="match_parent"
    android:paddingLeft="2dp"  android:paddingRight="2dp">
    <!--  Header and Main documentation text -->
    <TextView android:id="@+id/tla_header"
        android:layout_width="match_parent"  android:layout_height="wrap_content"
        android:background="@drawable/box2"
        android:layout_marginTop="4dp" android:padding="8dp"
        android:text="@string/tla_header"/>
    <!--  Heading for the list view -->
    <TextView android:id="@+id/tla_listview_heading"
        android:layout_width="match_parent"    android:layout_height="wrap_content"
        android:background="@color/gray"
        android:layout_marginTop="4dp"  android:padding="8dp"
        android:textColor="@color/black" style="@android:style/TextAppearance.Medium"
        android:text="List of Contacts"/>
    <!--  ListView used by the ListActivity. Uses a standard id needed by a list view -->
    <!--  Fix the height of the listview in a production setting -->
    <ListView android:id="@android:id/list"
        android:layout_width="match_parent"  android:layout_height="wrap_content"
        android:background="@drawable/box2"
        android:layout_marginTop="4dp" android:layout_marginBottom="4dp"
        android:drawSelectorOnTop="false"/>
    <!--  ProgressBar: To show and hide the progress bar as loaders load data -->
    <ProgressBar android:id="@+id/tla_pbar"
        android:layout_width="match_parent" android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:indeterminate="true"/>
     <!--  Empty List: Uses a standard id needed by a list view -->
     <TextView android:id="@android:id/empty"
        android:layout_width="match_parent" android:layout_height="wrap_content"
        android:visibility="gone"
        android:layout_marginTop="4dp"  android:layout_marginBottom="4dp"
        android:padding="8dp"
        android:text="No Contacts to Match the Criteria"/>
     <!--  Footer: Additional documentation text and the footer-->
     <TextView android:id="@+id/tla_footer"
        android:layout_width="match_parent"  android:layout_height="wrap_content"
        android:background="@drawable/box2"  android:padding="8dp"
        android:text="@string/tla_footer"/>
</LinearLayout>

现在让我们来理解清单 26-2 中的代码。我们将通过一系列步骤来解释这段代码,您可以按照这些步骤使用加载器进行编码。让我们从步骤 1 开始,这里需要扩展活动以支持 LoaderManager 回调。

步骤 1:准备加载数据的活动

使用加载器加载数据所需的代码非常少,因为大部分工作是由光标加载器完成的。您需要做的第一件事是让您的活动扩展 LoaderManager。Loader 回调<光标> 并实现所需的三个方法: onCreateLoader() 、 onLoadFinished() 和 onLoaderReset() 。你可以在清单 26-2 中看到。通过实现这个接口,您已经使活动能够通过这三个回调成为 LoaderManager 事件的接收者。

步骤 2:初始化加载程序

接下来,您必须告诉活动您想要一个加载器对象来加载数据。这是通过在活动的 onCreate() 方法期间注册并初始化一个加载器来完成的,如清单 26-4 所示。你也可以在清单 26-3 的 onCreate() 中,在整个代码的上下文中看到这一点。

清单 26-4 。初始化加载程序

int loaderid = 0; Bundle argBundle = null;
LoaderCallbacks<Cursor> loaderCallbacks = this; //this activity itself
getLoaderManager().initLoader(loaderid, argBundle, loaderCallbacks);

loaderid 参数是开发人员在该活动的上下文中分配的唯一编号,用于将该加载程序与在该活动中注册的其他加载程序进行唯一标识。注意,在这里的例子中,我们只使用了一个加载器。

第二个 argsBundle 参数 用于在需要时将附加参数传递给 onCreateLoader() 回调。这种“参数捆绑”方法遵循 Android 中许多托管组件中不同工厂对象构造的常见模式。活动、片段和加载器都是这种模式的例子。

第三个参数, loaderCallbacks ,是对 LoaderManager 所需回调的实现的引用。在清单 26-2 和清单 26-4 中,活动本身正在扮演这个角色,所以我们将引用活动的这个变量作为参数值传递给。

一旦加载器被注册和初始化,加载器管理器将在必要时调度对 onCreateLoader() 回调的调用。如果先前对 onCreateLoader() 进行了调用,并且对应于该加载器 ID 的加载器对象可用,则不会触发方法 onCreateLoader() 。如前所述,例外情况是开发人员通过调用 loader manager . restart loader()来覆盖此行为。稍后,当我们谈到提供基于搜索的过滤功能来定位联系人的子选择时,您将看到对这个调用的解释。

探究列表活动的结构

图 26-1 中的 ListActivity 正在通过 setContentView() 扩展一个带有自定义布局的内容视图的列表活动。这给了我们更多的灵活性,可以在活动上放置除列表视图之外的其他控件。例如,我们提供了一个页眉视图、一个页脚视图以及一个进度条来显示我们正在获取数据。由 ListActivity 设置的唯一约束是使用保留的@ Android:id/listview 来命名控件,以标识列表活动将使用的列表视图。除了 listview ID 之外,如果列表为空,我们还可以提供列表活动使用的视图。该视图由预定义的 ID@ Android:ID/empty 标识。

使用数据的异步加载

加载器异步加载数据。因此,我们在 activity . oncreate()中增加了一个责任,隐藏列表视图并显示进度指示器,直到列表数据准备好。为此,我们在清单 26-3 的布局中有一个 ProgressBar 组件。在 Activity.onCreate() 方法中,我们设置了布局的初始状态,以便隐藏列表视图并显示进度条。该功能编码在清单 26-2 中的 showProgressbar() 方法中。在同一个清单 26-2 中,当数据准备好时,我们调用 hideProgressbar() 来隐藏进度条并显示已填充的列表视图,如果没有数据,则显示空列表视图。

步骤 3:实现 onCreateLoader()

onCreateLoader() 由加载程序的初始化触发。你可以在清单 26-2 中看到这个方法的签名和实现。该方法为相应的加载器 ID 构造一个加载器对象,该加载器 ID 是通过调用 Loader manager . init Loader()进行初始化而传入的。该方法还接收在加载器初始化期间为该加载器 ID 提供的参数包。

该方法将一个正确的类型化的(通过 Java 泛型)加载器对象返回给加载器管理器。在我们的例子中,这个类型是加载器<光标> 。 LoaderManager 缓存 Loader 对象并将重用它。这很有用,因为当设备旋转并且加载器由于 Activity.onCreate() , LoaderManager 识别加载器 ID 和现有加载器的存在。然后 LoaderManager 将不会触发对 onCreateLoader() 的重复调用。但是,如果活动要意识到加载器的输入数据已经改变,活动代码可以调用 loader manager . restart loader(),这将再次触发对 onLoaderCreate() 的调用。在这种情况下, LoaderManager 将首先销毁旧的加载程序,并使用 onLoaderCreate() 返回的新加载程序。 LoaderManager 确实保证了旧的加载程序会一直存在,直到新的加载程序被创建并可用。

onCreateLoader() 方法可以完全访问活动的局部变量。因此它可以以任何可以想到的方式使用它们来构造所需的装载器。在光标加载器的情况下,这种构造仅限于光标加载器的构造器可用的参数,该构造器是专门为允许来自 Android 内容供应器的光标而构建的。

在我们的例子中,我们使用了 contacts 内容供应器提供的内容 URIs。关于如何使用内容 URIs 从内容供应器数据源中检索光标,请参考第二十五章。这非常简单:只需指出您想要从中获取数据的 URI,按照 contacts 内容提供者可用的文档,在该 URI 上将过滤器字符串作为参数或路径段提供,指定您想要的列,将 where 子句指定为字符串,并构造 CursorLoader 。

步骤 4:实现 onLoadFinished()

一旦光标加载器返回到加载器管理器中,光标加载器将被指示在工作线程上开始工作,主线程将继续 UI 杂务。稍后,当数据准备好时,调用这个方法 onLoadFinished() 。

这个方法可以被多次调用。当来自内容供应器的数据发生变化时,因为光标加载器已经向数据源注册了自己,所以它将被警告。 CursorLoader 然后会再次触发 onLoadFinished() 。

在 onLoadFinished() 方法中,您需要做的就是交换列表适配器持有的数据光标。列表适配器最初是用空游标初始化的。与填充的光标交换将在列表视图中显示新数据。由于我们在 Activity.onCreate() 中隐藏了 listview,我们需要显示 listview 并隐藏进度条。随后,当数据发生变化时,我们可以继续用新游标替换旧游标。这些更改将自动反映在列表视图中。

当设备旋转时,会发生一些事情。将再次调用 Activity.onCreate() 。这将把列表光标设置为空,并隐藏列表视图。 Activity.onCreate() 中的代码也会再次初始化加载器。对 LoaderManager 进行编程,这样重复初始化是无害的。onCreateLoader () 不会被调用。将不会重新查询光标。然而, onLoadFinished() 再次被调用,这是我们需要打破的难题:首先将数据初始化为 null,并想知道如果我们不重新查询,它将如何以及何时被填充。随着 onLoadFinished() 在循环中再次被调用,我们能够移除进度条,显示列表视图,并从空光标中交换有效光标。所有作品。是的,这是偷偷摸摸和迂回,但它的工作。

步骤 5:实现 onLoaderReset()

当以前注册的加载程序不再需要并因此被销毁时,调用这个回调函数。当一个活动由于后退按钮而被销毁或者被代码明确指示完成时,就会发生这种情况。在这种情况下,这个回调允许关闭不再需要的资源或引用。然而,重要的是不要关闭光标,因为它们由相应的加载器管理,并且将由框架为您关闭。这可能意味着 loader manager . restart loader()可能导致调用 onLoaderReset() ,因为旧加载器的参数不再有效。但是测试表明事实并非如此。方法 loader manager . restart loader()不会触发对方法 onLoaderReset() 的调用。 onLoaderReset() 方法仅在加载程序被不再需要的活动主动销毁时调用。您也可以通过调用 loader manager . destroy loader(loader id)显式指示 LoaderManager 销毁加载程序。

使用加载器搜索

我们将在示例应用中使用 search 来演示加载器的动态特性。我们在菜单上附加了一个搜索视图。您可以在清单 26-2 中的方法 onCreateOptionsMenu()中看到这一点。这里,我们将一个 SearchView 附加到菜单上,并在 SearchView 中提供新文本时,将该活动作为对 SearchView 的回调。在清单 26-2 的方法 onQueryTextchange()中处理 SearchView 回调。

在 onQueryTextChange() 方法中,我们获取新的搜索文本并设置局部变量 mCurFilter 。然后我们调用 loader manager . restart loader(),使用与 loader manager . initialize loader()相同的参数。这将再次触发 onCreateLoader() ,然后它将使用 mCurFilter 来改变 CursorLoader 的参数,从而产生一个新的光标。这个新的光标将替换 onLoadFinished() 方法中的旧光标。

了解 LoaderManager 回调的顺序

因为 Android 编程很大程度上是基于事件的,所以知道事件回调的顺序很重要。为了帮助您理解 LoaderManager 回调的时间,我们在示例程序中添加了日志消息。以下是一些显示回调顺序的结果。

清单 26-5 显示了第一次创建活动时的调用顺序。

清单 26-5 。活动创建时的加载程序回调

Application.onCreate()
Activity.onCreate()
  LoaderManager.LoaderCallbacks.onCreateLoader()
  Activity.onStart()
  Activity.onResume()
  LoaderManager.LoaderCallbacks.onLoadFinished()

当搜索视图通过回调触发一个新的搜索标准时,回调的顺序如清单 26-6 所示。

清单 26-6 。由 RestartLoader 触发的新搜索标准的加载程序回调

RestartLoader //log message from onQueryTextChange
LoaderManager.LoaderCallbacks.onCreateLoader()
LoaderManager.LoaderCallbacks.onLoadFinished()
//Notice, no call to onLoaderReset()

清单 26-7 显示了配置变更的调用顺序。

清单 26-7 。配置更改时的加载程序回调

Application:config changed
Activity: onCreate
  Activity.onStart
  [No call to the onCreateLoader]
  LoaderManager.LoaderCallbacks.onLoadFinished
  [optionally if searchview has text in it]
    SearchView.onQueryChangeText
    RestartLoader //just a log message
    LoaderManager.LoaderCallbacks.onCreateLoader
    LoaderManager.LoaderCallbacks.onLoadFinished

清单 26-8 显示了当活动中的那些动作结果被销毁时,导航返回或导航回家的回调顺序。

清单 26-8 。当活动被销毁时加载器回调

ActivityonStop()
Activity.onDestroy()
LoaderManager.LoaderCallbacks.onLoaderReset() //Notice this method is called

编写自定义加载程序

正如您在游标加载器、加载器中看到的,它们是特定于数据源的。因此,您可能需要编写自己的加载程序。很可能你需要从 AsyncTaskLoader 中派生出来,并使用 Loader 协议规定的原则和契约对其进行专门化。参见 SDK 文档中的加载器类以获得更多细节。您也可以使用 CursorLoader 源代码作为编写自己的加载器的指南。源代码可以从网上的多个来源获得(你可以谷歌一下),或者作为 Android 源代码下载的一部分。

资源

以下是本章所涵盖主题的附加资源:

摘要

从时间的角度以及处理活动和片段的托管生命周期的能力来看,加载器对于从数据源加载数据是必不可少的。在本章中,您已经看到了使用加载器从内容供应器加载数据是多么容易。生成的代码响应迅速,能够处理配置更改,并且简单。

二十七、探索联系人 API

在第二十五章和第二十六章中,我们讨论了内容供应器和他们的近亲,加载者。我们列出了通过内容提供者抽象公开数据的好处。在内容提供者抽象中,数据被公开为一系列 URL。这些数据 URL 可用于读取、查询、更新、插入和删除。这些 URL 及其对应的光标成为该内容提供者的 API。

Contacts API 就是这样一个用于处理联系人数据的内容提供者 API。Android 中的联系人保存在一个数据库中,并通过一个内容供应器公开,该供应器的权威来源于

content://com.android.contacts

Android SDK 使用一组植根于 Java 包的 Java 接口和类来记录各种 URL 及其返回的数据

android.provider.ContactsContract

您将看到许多父上下文为 ContactsContract 的类;这些在查询、读取、更新和向内容数据库插入联系人时非常有用。使用联系人 API 的主要文档可在 Android 网站上获得,网址为

[`developer.android.com/guide/topics/providers/contacts-provider.html`](https://developer.android.com/guide/topics/providers/contacts-provider.html)

主 API 入口点 ContactsContract 被恰当地命名,因为该类定义了联系人的客户端与联系人数据库的提供者和保护者之间的契约。

本章对这个契约进行了相当详细的探讨,但并没有涵盖每一个细节。Contacts API 很大,触角很远。然而,当您使用 Contacts API 时,需要几周的研究才能意识到它的底层结构很简单。这是我们想贡献最多的地方,在阅读本章的时间里解释这些基础知识。

Android 4.0 扩展了联系人的概念,加入了用户资料,类似于社交网络中的用户资料。用户配置文件是代表设备所有者的专用联系人。大多数基于接触的一般概念保持不变。我们将介绍如何扩展 Contacts API 来支持用户配置文件。

了解账户

Android 中的所有联系人都在一个帐户的上下文中工作。什么是账户?嗯,举个例子,如果你的电子邮件是通过谷歌发的,那么你就有了一个谷歌账户。如果你把自己设置成脸书的用户,你就拥有了脸书的账户。您可以通过设备上的“帐户与同步”设置选项来设置这些帐户。请参阅 Android 用户指南,了解有关帐户以及如何设置帐户的更多详细信息。

您管理的联系人与特定帐户相关联。一个帐户拥有它的一组联系人,或者说一个帐户是一个联系人的父代。帐户由两个字符串标识:帐户名和帐户类型。在谷歌的情况下,你的账户名是你在 Gmail 的电子邮件用户名,你的账户类型是 com.google 。帐户类型在设备中必须是唯一的。您的帐户名称在该帐户类型中是唯一的。帐户类型和帐户名称一起构成了一个帐户,只有在帐户形成后,才能为该帐户插入一组联系人。

枚举帐户

Contacts API 主要处理存在于各种帐户中的联系人。创建帐户的机制不在 Contacts API 的范围之内,所以解释编写自己的帐户提供程序的能力以及如何在这些帐户中同步联系人不在本章的讨论范围之内。你可以理解并受益于这一章,而不必深入到如何建立账户的细节中。但是,当您想要添加联系人或联系人列表时,您确实需要知道设备上存在哪些帐户。您可以使用清单 27-1 中的代码来枚举帐户及其属性(帐户名和类型)。清单 27-1 中的代码列出了给定上下文变量(如活动)的账户名称和类型。

清单 27-1 。显示帐户列表的代码

public void listAccounts(Context ctx) {
    AccountManager am = AccountManager.get(ctx);
    Account[] accounts = am.getAccounts();
    for(Account ac: accounts) {
        String account_name=ac.name;
        String account_type = ac.type;
        Log.d("accountInfo", account_name + ":" + account_type);
    }
}

要运行清单 27-1 中的代码,清单文件需要使用清单 27-2 中的行请求许可。

清单 27-2 。读取帐户的权限

<uses-permission android:name="android.permission.GET_ACCOUNTS"/>

来自清单 27-1 的代码将打印出如下内容:

Your-email-at-gmail:com.google

这假设您只配置了一个帐户(Google)。如果您有多个帐户,所有这些帐户将以类似的方式列出。

使用设备上的“联系人”应用,您可以添加、编辑和删除任何现有帐户的联系人。

了解联系人

客户拥有的联系人称为原始联系人。原始联系人有一组可变的数据元素(例如,电子邮件地址、电话号码、姓名和邮政地址)。Android 通过只列出一次任何似乎匹配的原始联系人来呈现原始联系人的聚合视图。这些汇总的联系人构成了您在打开“联系人”应用时看到的一组联系人。

我们现在将研究联系人及其相关数据是如何存储在各种表中的。理解这些联系人表及其相关视图是理解联系人 API 的关键。

检查联系人 SQLite 数据库

理解和检查联系人数据库表的一种方法是从设备或模拟器下载联系人数据库,并使用 SQLite explorer 工具之一打开它。

要下载联系人数据库,请使用图 30-17 所示的文件资源管理器,并导航到仿真器上的以下目录:

/data/data/com.android.providers.contacts/databases

根据版本的不同,数据库文件名可能会略有不同,但应该称为 contacts.db 、 contacts2.db 或类似的名称。在 4.0 中,联系人提供程序使用一个结构相似但独立的数据库文件,名为 profile.db ,用于保存与个人资料相关的联系人。

了解原始联系人

您在联系人应用中看到的联系人称为聚合联系人。在每个聚集的联系人下面是一组称为原始联系人的联系人。聚合联系人是一组相似的原始联系人的视图。

属于一个帐户的一组联系人称为原始联系人。每个原始联系人指向该帐户上下文中一个人的详细信息。这与聚合联系人相反,聚合联系人跨越帐户边界,并作为一个整体属于设备。帐户与其原始联系人集之间的这种关系在原始联系人表中维护。清单 27-3 显示了联系人数据库中原始联系人表的结构。

清单 27-3 。原始联系表定义

CREATE TABLE raw_contacts
(_id INTEGER PRIMARY KEY AUTOINCREMENT,
is_restricted        INTEGER DEFAULT 0,
account_name         STRING DEFAULT NULL,
account_type         STRING DEFAULT NULL,
sourceid             TEXT,
version              INTEGER NOT NULL DEFAULT 1,
dirty                INTEGER NOT NULL DEFAULT 0,
deleted              INTEGER NOT NULL DEFAULT 0,
contact_id           INTEGER REFERENCES contacts(_id),
aggregation_mode     INTEGER NOT NULL DEFAULT 0,
aggregation_needed   INTEGER NOT NULL DEFAULT 1,
custom_ringtone      TEXT
send_to_voicemail    INTEGER NOT NULL DEFAULT 0,
times_contacted      INTEGER NOT NULL DEFAULT 0,
last_time_contacted  INTEGER,
starred              INTEGER NOT NULL DEFAULT 0,
display_name         TEXT,
display_name_alt     TEXT,
display_name_source  INTEGER NOT NULL DEFAULT 0,
phonetic_name        TEXT,
phonetic_name_style  TEXT,
sort_key             TEXT COLLATE PHONEBOOK,
sort_key_alt         TEXT COLLATE PHONEBOOK,
name_verified        INTEGER NOT NULL DEFAULT 0,
contact_in_visible_group  INTEGER NOT NULL DEFAULT 0,
sync1 TEXT, sync2         TEXT, sync3 TEXT, sync4 TEXT )

与大多数 Android 表一样,原始联系人表具有唯一标识原始联系人的 _ID 列。该字段的 account_name 和 account_type 一起识别该联系人(具体地说,原始联系人)所属的账户。 sourceid 字段指示如何在帐户中唯一标识该原始联系人。

字段 contact_id 指的是该原始联系人所属的聚合联系人。聚合联系人指向一个或多个相似的联系人,这些联系人实质上是在多个帐户中设置的同一个人。

字段显示名称指向联系人的显示名称。这主要是一个只读字段。它是由触发器根据添加到该原始联系人的数据表(将在下一小节中介绍)中的数据行设置的。

帐户使用同步字段来同步设备和服务器端帐户(如 Google mail)之间的联系人。

尽管我们使用了 SQLite 工具来探索这些领域,但是发现这些领域的方法不止一种。推荐的方法是遵循在 ContactsContract API 中声明的类定义。要浏览属于原始联系人的列,可以查看 ContactsContract 的类文档。原始联系人。

这种方法有优点也有缺点。一个显著的优势是,您可以了解 Android SDK 发布和认可的领域。可以在不改变公共接口的情况下添加或删除数据库列。因此,如果您直接使用数据库列,它们可能存在,也可能不存在。相反,如果您对这些列使用公共定义,那么您在两个版本之间是安全的。

然而,一个缺点是,类文档中有许多其他的常数散布在列名中;我们有点迷失在搞清楚什么是什么的过程中。这些众多的类定义给人一种 API 很复杂的印象,而实际上,Contacts API 的 80%的类文档都是为这些列定义常量,并为访问这些行定义 URIs。

当我们在后面的小节中练习 Contacts API 时,我们将使用基于类文档的常量,而不是直接的列名。但是,我们认为直接浏览表是帮助您理解 Contacts API 的最快方法。

接下来让我们讨论一下与联系人相关的数据(如电子邮件和电话号码)是如何存储的。

了解联系人数据表

从原始联系人表定义可以看出,原始联系人(从虎头蛇尾的意义上来说)只是一个 ID,表示它属于哪个帐户。与联系人相关的数据不在原始联系人表中,而是保存在数据表中。每个数据元素,比如电子邮件和电话号码,都作为单独的行存储在数据表中,由原始联系人 ID 绑定。数据表的定义如清单 27-4 所示,包含 16 个通用列,可以存储任何类型的数据元素,如电子邮件。

清单 27-4 。接触数据表定义

CREATE TABLE data
(_id              INTEGER PRIMARY KEY AUTOINCREMENT,
package_id        INTEGER REFERENCES package(_id),
mimetype_id       INTEGER REFERENCES mimetype(_id) NOT NULL,
raw_contact_id    INTEGER REFERENCES raw_contacts(_id) NOT NULL,
is_primary        INTEGER NOT NULL DEFAULT 0,
is_super_primary  INTEGER NOT NULL DEFAULT 0,
data_version      INTEGER NOT NULL DEFAULT 0,
data1 TEXT,data2 TEXT,data3 TEXT,data4 TEXT,data5 TEXT,
data6 TEXT,data7 TEXT,data8 TEXT,data9 TEXT,data10 TEXT,
data11 TEXT,data12 TEXT,data13 TEXT,data14 TEXT,data15 TEXT,
data_sync1 TEXT, data_sync2 TEXT, data_sync3 TEXT, data_sync4 TEXT )

raw_contact_id 指向该数据行所属的原始联系人。 mimetype_id 指向 MIME 类型条目,指示在清单 27-4 中的联系人数据类型中标识的类型之一。列 data1 到 data15 是通用的基于字符串的表,可以根据 MIME 类型存储任何必要的内容。同步字段支持联系人同步。解析 MIME 类型 id 的表格在清单 27-5 中。

清单 27-5 。联系人 MIME 类型查找表定义

CREATE TABLE mimetypes
(_id INTEGER PRIMARY KEY AUTOINCREMENT,
mimetype TEXT NOT NULL)

与原始 contacts 表一样,您可以通过 ContactsContract 的 helper 类文档来发现数据表列。数据。虽然您可以从这个类定义中找出列,但是您不会知道从数据 1 到数据 15 的每个通用列中存储了什么。要了解这一点,您需要查看名称空间 ContactsContract 下许多类的类定义。常用数据种类。

这些类别的一些示例如下:

  • 联系人联系人。CommonDataKinds.Email
  • 联系人联系人。CommonDataKinds.Phone

事实上,您将看到每个预定义 MIME 类型都有一个类。这些类如下:邮件、事件、群组成员、身份、 Im 、昵称、备注、组织、电话、照片、关系、 SipAddress 、结构名称最终, CommonDataKinds 类所做的就是指出哪些通用数据字段( data1 到 data15 )正在使用以及用途。

了解汇总联系人

最终,联系人及其相关数据明确地存储在原始联系人表和数据表中。另一方面,聚合联系是启发式的,可能是不明确的。

当多个帐户之间有相同的联系人时,您可能希望看到一个姓名,而不是看到相同或相似的姓名在每个帐户中重复出现一次。Android 通过将联系人聚集到一个只读视图中来解决这个问题。Android 将这些聚集的联系人存储在一个名为 contacts 的表中。Android 在原始联系人表和数据表上使用许多触发器来填充或更改这个聚合联系人表。

在解释聚合背后的逻辑之前,让我们给你看一下联系表的定义(见清单 27-6 )。

清单 27-6 。聚合联系人表定义

CREATE TABLE contacts
(_id                  INTEGER PRIMARY KEY AUTOINCREMENT,
name_raw_contact_id   INTEGER REFERENCES raw_contacts(_id),
photo_id              INTEGER REFERENCES data(_id),
custom_ringtone       TEXT,
send_to_voicemail     INTEGER NOT NULL DEFAULT 0,
times_contacted       INTEGER NOT NULL DEFAULT 0,
last_time_contacted   INTEGER,
starred               INTEGER NOT NULL DEFAULT 0,
in_visible_group      INTEGER NOT NULL DEFAULT 1,
has_phone_number      INTEGER NOT NULL DEFAULT 0,
lookup                TEXT,
status_update_id      INTEGER REFERENCES data(_id),
single_is_restricted  INTEGER NOT NULL DEFAULT 0)

没有客户端直接更新该表。当添加一个原始联系人及其详细信息时,Android 会搜索其他原始联系人,以查看是否有类似的原始联系人。如果有,它将使用该原始联系人的聚合联系人 ID 作为新的原始联系人的聚合联系人 ID。聚合联系人表中没有条目。如果没有找到,它将创建一个聚合联系人,并将该聚合联系人用作该原始联系人的联系人 ID。

Android 使用以下算法来确定哪些原始联系人是相似的:

  1. 这两个原始联系人具有匹配的姓名。
  2. 名称中的单词是相同的,但顺序不同:“first last”或“first,last”或“last,first”
  3. 姓名的较短版本匹配,例如“Bob”代表“Robert”
  4. 如果其中一个原始联系人只有名字或姓氏,这将触发对其他属性的搜索,如电话号码或电子邮件,如果其他属性匹配,该联系人将被聚合。
  5. 如果其中一个原始联系人完全丢失了姓名,这也将触发对其他属性的搜索,如步骤 4 所示。

因为这些规则是启发式的,所以一些联系人可能会被无意地聚集。在这种情况下,客户端应用需要提供一种机制来分离联系人。如果你参考 Android 用户指南,你会看到默认的联系人应用允许你分离无意中合并的联系人。

您还可以通过在插入原始联系人时设置聚合模式来阻止聚合。聚集模式如清单 27-7 所示。

清单 27-7 。聚合模式常数

AGGREGATION_MODE_DEFAULT
AGGREGATION_MODE_DISABLED
AGGREGATION_MODE_SUSPENDED

第一种选择是显而易见的;这就是聚合的工作方式。

第二个选项( disabled )将这个原始联系人排除在聚合之外。即使它已经被聚合,Android 也会将其从聚合中取出,并为该原始联系人分配一个新的聚合联系人 id。

第三个选项( suspended )表示即使联系人的属性可能改变,这将使其不能聚合到该批联系人中,也应该保持与该聚合联系人的联系。

最后一点引出了聚合联系人的可变维度。假设您有一个包含名字和姓氏的唯一原始联系人。现在,它不匹配任何其他原始联系人,因此这个唯一的原始联系人获得它自己的聚合联系人分配。聚合的联系人 ID 将存储在原始联系人表中,与原始联系人行相对应。

但是,您可以更改这个原始联系人的姓氏,使其与另一组聚合的联系人相匹配。在这种情况下,Android 将从这个聚合联系人中删除原始联系人,并将其移动到另一个,自己放弃这个单个聚合联系人。在这种情况下,聚合联系人的 ID 完全被放弃,因为它在将来不会匹配任何内容,因为它只是一个没有底层原始联系人的 ID。

所以聚集接触是不稳定的。随着时间的推移,保持这个聚集的联系人 ID 没有重要的价值。

Android 通过在聚合联系人表中提供一个名为 lookup 的字段来缓解这种困境。此查找字段是帐户和该帐户中每个原始联系人的唯一 ID 的聚合(串联)。该信息被进一步编码,以便可以作为 URL 参数传递,从而检索最新的聚合联系人 ID。Android 查看查找关键字,并查看该查找关键字有哪些基础的原始联系人 id。然后,它使用最佳算法返回一个合适的(或者可能是新的)聚合联系人 ID。

当我们明确地检查联系人数据库时,让我们考虑几个有用的与联系人相关的数据库视图。

浏览视图 _ 联系人

这些视图的第一个是视图 _ 联系人 。虽然有一个保存聚合联系人的表(contacts 表),但是 API 并不直接公开 contacts 表。相反,它使用 view_contacts 作为读取聚合联系人的目标。当您基于 URI ContactsContract 进行查询时。Contacts.CONTENT_URI ,返回的列基于这个视图 view_contacts 。视图 _ 联系人视图的定义如清单 27-8 所示。

清单 27-8 。读取聚合联系人的视图

CREATE VIEW view_contacts AS

SELECT contacts._id AS _id,
contacts.custom_ringtone                AS custom_ringtone,
name_raw_contact.display_name_source    AS display_name_source,
name_raw_contact.display_name           AS display_name,
name_raw_contact.display_name_alt       AS display_name_alt,
name_raw_contact.phonetic_name          AS phonetic_name,
name_raw_contact.phonetic_name_style    AS phonetic_name_style,
name_raw_contact.sort_key               AS sort_key,
name_raw_contact.sort_key_alt           AS sort_key_alt,
name_raw_contact.contact_in_visible_group AS in_visible_group,
has_phone_number,
lookup,
photo_id,
contacts.last_time_contacted           AS last_time_contacted,
contacts.send_to_voicemail             AS send_to_voicemail,
contacts.starred                       AS starred,
contacts.times_contacted               AS times_contacted, status_update_id

FROM contacts JOIN raw_contacts AS name_raw_contact
ON(name_raw_contact_id=name_raw_contact._id)

请注意,视图 _contacts 视图根据聚合的联系人 ID 将 contacts 表与原始 contact 表组合在一起。

探索联系人 _ 实体 _ 视图

另一个有用的视图是 contact _ entities _ view,它将原始的 contacts 表与数据表结合在一起。这个视图允许我们一次检索给定原始联系人的所有数据元素,甚至是属于同一个聚合联系人的多个原始联系人的数据元素。清单 27-9 给出了基于联系实体的视图的定义。

清单 27-9 。联系人实体视图

CREATE VIEW contact_entities_view AS

SELECT raw_contacts.account_name    AS account_name,
raw_contacts.account_type           AS account_type,
raw_contacts.sourceid               AS sourceid,
raw_contacts.version                AS version,
raw_contacts.dirty                  AS dirty,
raw_contacts.deleted                AS deleted,
raw_contacts.name_verified          AS name_verified,
package                             AS res_package,
contact_id,
raw_contacts.sync1                  AS sync1,
raw_contacts.sync2                  AS sync2,
raw_contacts.sync3                  AS sync3,
raw_contacts.sync4                  AS sync4,
mimetype, data1, data2, data3, data4, data5, data6, data7, data8,
data9, data10, data11, data12, data13, data14, data15,
data_sync1, data_sync2, data_sync3, data_sync4,

raw_contacts._id                    AS _id,

is_primary, is_super_primary,
data_version,
data._id                            AS data_id,
raw_contacts.starred                AS starred,
raw_contacts.is_restricted          AS is_restricted,
groups.sourceid                     AS group_sourceid

FROM raw_contacts LEFT OUTER JOIN data
   ON (data.raw_contact_id=raw_contacts._id)
LEFT OUTER JOIN packages
  ON (data.package_id=packages._id)
LEFT OUTER JOIN mimetypes
  ON (data.mimetype_id=mimetypes._id)
LEFT OUTER JOIN groups
  ON (mimetypes.mimetype='vnd.android.cursor.item/group_membership'
    AND groups._id=data.data1)

访问该视图所需的 URIs 在 类 ContactsContract 中可用。RawContactsEntity 。

使用联系人 API

到目前为止,我们已经通过研究 Contacts API 的表和视图探索了它背后的基本思想。我们现在将展示一些可用于探索联系人的代码片段。这些片段摘自为支持本章而开发的示例应用。尽管这些片段来自示例应用,但它们足以帮助理解 Contacts API 是如何工作的。您可以使用本章末尾的项目下载 URL 下载完整的示例程序。

探索帐户

我们将通过编写一个可以打印出帐户列表的程序来开始我们的练习。我们已经给出了获取帐户列表所需的代码片段。考虑清单 27-10 中的类 AccountsFunctionTester。

清单 27-10 。 AccountsFunctionTester 打印可用账户

//Java class: AccountsFunctionTester.java
//Menu to invoke this: Accounts
//BaseTester is a supporting base class holding the parent activity
// and some reused common variables. See the source code if you are more curious.
public class AccountsFunctionTester extends BaseTester {
    private static String tag = "tc>";

    //IReportBack is a simple logging interface that writes log messages
    //to the main activity and also to the log.
    public AccountsFunctionTester(Context ctx, IReportBack target) {
        super(ctx, target);
    }
    public void testAccounts() {
        AccountManager am = AccountManager.get(this.mContext);
        Account[] accounts = am.getAccounts();
        for(Account ac: accounts) {
            String acname=ac.name;
            String actype = ac.type;
            this.mReportTo.reportBack(tag,acname + ":" + actype);
        }
    }
}

注意在我们展示和探索使用联系人所需的 Java 代码时,您会看到在展示的源代码中重复使用了三个变量:

mContext :一个指向活动的变量

mReportTo :一个实现日志接口的变量(ireport back—你可以在可下载的项目中看到这个 Java 文件),它可以用来将消息记录到本章使用的测试活动中

Utils :封装了非常简单的实用方法的静态类

我们选择不在这里列出这些类,因为它们会分散您对 Contacts API 核心功能的理解。您可以在可下载的项目中检查这些类。

本章中的所有代码都使用针对内容提供者的非托管查询。这是通过调用 Activity.getContentResolver()来完成的。查询()。这是因为我们只是读取数据并立即打印出结果。如果你的目标是使用 UI(通过活动或片段)作为显示你的联系人的目标,那么请阅读第二十七章关于加载器的内容。加载器显示了显示来自任何内容提供者的光标的正确方式。

当您运行您可以为本章下载的示例程序时,您将看到一个主活动,它带有许多菜单选项。菜单选项“帐户”将打印设备上可用的帐户列表。

探索聚合联系人

让我们看看如何通过代码片段探索聚合联系人。若要读取联系人,您需要在清单文件中请求以下权限:

android.permission.READ_CONTACTS

由于我们测试的功能涉及内容提供者、URIs 和光标,让我们来看看清单 27-11 中的一些有用的代码片段。(这些代码片段可以在 utils.java 的或者从本章的可下载项目中的 BaseTester 派生的一些基类中获得。)

清单 27-11 。给定一个 URI 和一个 where 子句,获取一个游标

//Utils.java
//Retrieve a column from a cursor
public static String getColumnValue(Cursor cc, String cname) {
   int i = cc.getColumnIndex(cname);
   return cc.getString(i);
}
//See what columns are there  in a cursor
protected static String getCursorColumnNames(Cursor c) {
   int count = c.getColumnCount();
   StringBuffer cnamesBuffer = new StringBuffer();
    for (int i=0;i<count;i++) {
       String cname = c.getColumnName(i);
       cnamesBuffer.append(cname).append(';');
    }
    return cnamesBuffer.toString();
}

//From URIFunctionTester.java, baseclass of some of the other testers
//Given a URI and a where clause return a cursor
protected Cursor getACursor(Uri uri,String clause) {
   Activity a = (Activity)this.mContext; //mContext coming from BaseTester
   return a.getContentResolver().query(uri, null, clause, null, null);
}

在本节中,我们主要探索由聚合联系人 URIs 返回的游标。由产生的联系人光标返回的每一行将有许多字段。对于我们的例子,我们对所有的领域都不感兴趣,只对少数领域感兴趣。您可以将其抽象为另一个名为 AggregatedContact 的类。清单 27-12 展示了这个类。

清单 27-12 。聚合联系人的几个字段的对象定义

//AggregatedContact.java
public class AggregatedContact {
    public String id;
    public String lookupUri;
    public String lookupKey;
    public String displayName;
    public void fillinFrom(Cursor c) {
        id = Utils.getColumnValue(c,"_ID");
        lookupKey = Utils.getColumnValue(c,ContactsContract.Contacts.LOOKUP_KEY);
        lookupUri = ContactsContract.Contacts.CONTENT_LOOKUP_URI + "/" + lookupKey;
        displayName = Utils.getColumnValue(c,ContactsContract.Contacts.DISPLAY_NAME);
    }
}

在清单 27-12 中,我们使用光标来加载我们感兴趣的字段。

获取聚集联系人光标

清单 27-13 展示了如何检索一个聚集联系人集合的光标。

清单 27-13 。获取所有聚合联系人的光标

//Get a cursor of all contacts. Specify the where clause as null to indicate all rows.
//Java class: AggregatedContactFunctionTester.java
//Menu item to invoke: Contacts Cursor
private Cursor getContacts() {
    Uri uri = ContactsContract.Contacts.CONTENT_URI;
    //Specify ascending or descending way to sort names
    String sortOrder = ContactsContract.Contacts.DISPLAY_NAME
                         + " COLLATE LOCALIZED ASC";
    Activity a = (Activity)this.mContext; //Local variable pointing to an activity
    return a.getContentResolver().query(uri, null, null, null, sortOrder);
}

用于读取所有联系人的 URI 是 contacts contact。联系人.内容 _URI 。您可以将这个 URI 传递给 q uery() 函数来检索光标。您可以传递 null 作为列投影来接收所有列。虽然在实践中不建议这样做,但在我们的例子中,这样做是有意义的,因为我们想知道它返回的所有列。我们还使用联系人的显示名称作为排序顺序。再次注意我们是如何使用 ContactContract 的。联系人获取联系人的列名,显示 名称。如果你要从这个光标打印字段名,你会看到返回的字段,如清单 27-14 所示。根据版本的不同,顺序可能会有所不同,并且可能会添加更多的列。显式指定查询子句的投影是一种好的做法;这样,您的代码将跨版本工作。

清单 27-14 。汇总联系人内容 URI 光标列

times_contacted; contact_status; custom_ringtone; has_phone_number; phonetic_name;
phonetic_name_style; contact_status_label; lookup; contact_status_icon; last_time_contacted;
display_name; sort_key_alt; in_visible_group; _id; starred; sort_key; display_name_alt;
contact_presence; display_name_source; contact_status_res_package; contact_status_ts;
photo_id; send_to_voicemail;

读取汇总的联系人详细信息

现在我们已经研究了联系人内容 URI 中可用的列,让我们挑选几列,看看有哪些联系人行可用。我们对联系人光标的以下几列感兴趣:显示名称、查找关键字和查找 URI。我们之所以考虑这些字段,是因为我们希望根据本章理论部分的内容来了解查找关键字和查找关键字 URI 的情况。具体来说,我们感兴趣的是启动查找 URI,看看它返回什么类型的游标。

清单 27-15 中的函数 listContacts() 获取一个联系人光标,并为光标的每一行打印这三列。请注意,这个清单来自一个类,该类包含一个名为 mContext 的局部变量来指示活动,还包含一个名为 mReportTo 的局部变量来记录活动的任何消息。

清单 27-15 。打印汇总联系人的查找关键字

//Java class: AggregatedContactFunctionTester.java
//Menu item to invoke: Contacts
public void listContacts() {
    Cursor c = null;
    try {
        c = getContacts();
        int i = c.getColumnCount();
        this.mReportTo.reportBack(tag, "Number of columns:" + i);
        this.printLookupKeys(c);
    }
   finally { if (c!= null) c.close(); }
}
private void printLookupKeys(Cursor c) {
    for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
        String name=this.getContactName(c);
        String lookupKey = this.getLookupKey(c);
        String luri = this.getLookupUri(lookupKey);
        this.mReportTo.reportBack(tag, name + ":" + lookupKey); //log
        this.mReportTo.reportBack(tag, name + ":" + luri); //log
    }
}
private String getLookupKey(Cursor cc) {
    int lookupkeyIndex = cc.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY);
    return cc.getString(lookupkeyIndex);
}
private String getContactName(Cursor cc){
    return Utils.getColumnValue(cc,ContactsContract.Contacts.DISPLAY_NAME);
}
private String getLookupUri(String lookupkey) {
    String luri = ContactsContract.Contacts.CONTENT_LOOKUP_URI + "/" + lookupkey;
    return luri;
}

探索查找 URI 光标

既然我们知道了如何为给定的聚合联系人提取查找 URIs,那么让我们来看看我们可以用查找 URI 做些什么。

清单 27-16 中的函数 listlookupricolumns()将从所有联系人列表中取出第一个联系人,然后为该联系人制定一个查找 URI,并启动 URI,通过打印该光标的列名来查看它返回哪种光标。

清单 27-16 。探索查找 URI 光标

//Class: AggregatedContactFunctionTester.java, Menu item to invoke: Single Contact Cursor
public void listLookupUriColumns() {
    Cursor c = null;
    try {
        c = getContacts();
        String firstContactLookupUri = getFirstLookupUri(c);
        printLookupUriColumns(firstContactLookupUri);
    }
    finally { if (c!= null) c.close(); }
}
private String getFirstLookupUri(Cursor c) {
    c.moveToFirst();
    if (c.isAfterLast()) {
        Log.d(tag,"No rows to get the first contact");
        return null;
    }
    String lookupKey = this.getLookupKey(c);
    return  this.getLookupUri(lookupKey);
}
public void printLookupUriColumns(String lookupuri) {
    Cursor c = null;
    try {
        c = getASingleContact(lookupuri);
        int i = c.getColumnCount();
        this.mReportTo.reportBack(tag, "Number of columns:" + i);
        int j = c.getCount();
        this.mReportTo.reportBack(tag, "Number of rows:" + j);
        this.printCursorColumnNames(c);
    }
    finally { if (c!=null)c.close(); }
}
// Use the lookup uri, retrieve a single aggregated contact
private Cursor getASingleContact(String lookupUri) {
    Activity a = (Activity)this.mContext;
    return a.getContentResolver().query(Uri.parse(lookupUri), null, null, null, null);
}

事实证明,它只是返回一个游标(如清单 27-14 中的)与清单 27-13 中的中的聚合联系人游标在列上是相同的,除了它只有一行指向查找关键字的联系人。另请注意,我们使用了以下代码 URI 定义:

ContactsContract.Contacts.CONTENT_LOOKUP_URI

从对联系人查找 URIs 的讨论中可以看出,每个查找 URI 都代表一个已连接的原始联系人标识的集合。既然如此,您可能希望查找 URI 返回一系列匹配的原始联系人。然而,清单 27-16 中的测试表明,它返回的不是原始联系人的光标,而是联系人的光标。

注意基于联系人查找 URI 的查找返回汇总联系人,而不是原始联系人。

另一个趣闻是,基于查找 URI 的聚集联系人的查找过程不是线性的或精确的。这意味着 Android 不会寻找查找键的精确匹配。相反,Android 将查找关键字解析为其组成的原始联系人,然后找到与大多数原始联系人记录匹配的聚合联系人 id,并返回该聚合联系人记录。

这样做的一个后果是,没有公共机制可以从查找键转到它的原始联系人。相反,您必须找到该查找关键字的联系人 ID,然后为该联系人 ID 生成一个原始联系人 URI,以检索相应的原始联系人。

下面是另一个代码片段,显示了从游标返回的对象,而不是一组列。清单 27-17 中的代码将第一个聚集的联系人作为 一个 对象返回。

清单 27-17 。代码测试聚合联系人

//Java class: AggregatedContactFunctionTester.java
protected AggregatedContact getFirstContact() {
    Cursor c=null;
    try {
        c = getContacts(); c.moveToFirst();
        if (c.isAfterLast()) {
            Log.d(tag,"No contacts");
            return null;
        }
        AggregatedContact firstcontact = new AggregatedContact();
        firstcontact.fillinFrom(c);
        return firstcontact;
    }
    finally { if (c!=null) c.close(); }
}

探索原始联系人

在清单 27-18 中,文件【RawContact.java 从原始联系人表光标中捕获了几个重要字段。(与本章中的所有其他代码片段一样,这个文件可以在本章的可下载项目中找到。)

清单 27-18 。源代码为 RawContact.java

//Class: RawContact.java
public class RawContact  {
    public String rawContactId;
    public String aggregatedContactId;
    public String accountName;
    public String accountType;
    public String displayName;

    public void fillinFrom(Cursor c) {
        rawContactId = Utils.getColumnValue(c,"_ID");
        accountName = Utils.getColumnValue(c,ContactsContract.RawContacts.ACCOUNT_NAME);
        accountType = Utils.getColumnValue(c,ContactsContract.RawContacts.ACCOUNT_TYPE);
        aggregatedContactId = Utils.getColumnValue(c,
                                        ContactsContract.RawContacts.CONTACT_ID);
        displayName = Utils.getColumnValue(c,"display_name");
    }
    public String toString() { //..prints the public fields. See the download project for details }
}//eof-class

显示原始联系人光标

与聚合联系人 URIs 一样,让我们首先检查原始联系人 URI 的性质及其返回的内容。原始联系人 URI 的签名定义如下:

ContactsContract.RawContacts.CONTENT_URI

清单 27-19 中的函数 showRawContactsCursor()打印原始联系人 URI 的光标列。

清单 27-19 。浏览原始联系人光标

//Java class: RawContactFunctionTester.java; Menu item: Raw Contacts Cursor
public void showRawContactsCursor() {
    Cursor c = null;
    try {
        c = this.getACursor(ContactsContract.RawContacts.CONTENT_URI,null);
        this.printCursorColumnNames(c);
    }
    finally { if (c!=null) c.close(); }
}

清单 27-19 中的代码将显示原始接触光标具有清单 27-20 中所示的字段(该列表似乎因设备不同而有所不同)。

清单 27-20 。原始联系人光标字段

times_contacted; phonetic_name; phonetic_name_style; contact_id;version; last_time_contacted;
aggregation_mode; _id; name_verified; display_name_source; dirty; send_to_voicemail; account_type; custom_ringtone; sync4;sync3;sync2;sync1; deleted; account_name; display_name;
sort_key_alt; starred; sort_key; display_name_alt; sourceid;

查看原始联系人光标返回的数据

清单 27-21 显示了方法 showAllRawContacts() ,它打印原始联系人光标中的所有行。

清单 27-21 。显示原始联系人

//Java class: RawContactFunctionTester.java; Menu item: All Raw Contacts
public void showAllRawContacts(){
    Cursor c = null;
    try {
        c = this.getACursor(getRawContactsUri(), null);
        this.printRawContacts(c);
    }
    finally { if (c!=null) c.close(); }
}
private void printRawContacts(Cursor c) {
    for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
        RawContact rc = new RawContact();
        rc.fillinFrom(c);
        this.mReportTo.reportBack(tag, rc.toString()); //log
    }
}

用一组对应的聚集联系人约束原始联系人

使用清单 27-20 中的光标列,让我们看看是否可以细化我们的查询,以检索给定聚合联系人 ID 的联系人。清单 27-22 中的代码将查找第一个聚合联系人,然后发出一个带有 where 子句的原始联系人 URI,该子句为 contact_id 列指定一个值。

清单 27-22 。获取聚合联系人的原始联系人

//Java class: RawContactFunctionTester.java; Menu item: Raw Contacts
public void showRawContactsForFirstAggregatedContact(){
    AggregatedContact ac = getFirstContact();
    Cursor c = null;
    try {
        c = this.getACursor(getRawContactsUri(), getClause(ac.id));
        this.printRawContacts(c);
    }
    finally { if (c!=null) c.close(); }
}
private String getClause(String contactId) {
    return "contact_id = " + contactId;
}

探索原始联系人数据

因为属于原始联系人的数据行包含许多字段,所以我们创建了一个名为 ContactData.java 的 Java 类,如清单 27-23 所示,来捕获联系人数据的代表性集合,而不是所有字段。

清单 27-23 。源代码为 ContactData.java

//ContactData.java
public class ContactData {
    public String rawContactId;
    public String aggregatedContactId;
    public String dataId;
    public String accountName;
    public String accountType;
    public String mimetype;
    public String data1;

    public void fillinFrom(Cursor c) {
        rawContactId = Utils.getColumnValue(c,"_ID");
        accountName = Utils.getColumnValue(c,ContactsContract.RawContacts.ACCOUNT_NAME);
        accountType = Utils.getColumnValue(c,ContactsContract.RawContacts.ACCOUNT_TYPE);
        aggregatedContactId =
               Utils.getColumnValue(c,ContactsContract.RawContacts.CONTACT_ID);
        mimetype = Utils.getColumnValue(c,ContactsContract.RawContactsEntity.MIMETYPE);
        data1 = Utils.getColumnValue(c,ContactsContract.RawContactsEntity.DATA1);
        dataId = Utils.getColumnValue(c,ContactsContract.RawContactsEntity.DATA_ID);
    }
    public String toString()   {//just a concatenation of fields for logging }
}

Android 使用一个名为 RawContactEntity 视图的视图来从原始联系人表和相应的数据表中检索数据,如本章“contact_entities_view”一节所述。访问这个视图的 URI 在清单 27-24 中。

清单 27-24 。原始实体含量 URI

ContactsContract.RawContactsEntity.CONTENT_URI

让我们看看如何使用这个 URI 来发现这个 URI 返回的字段名称:

//Java class: ContactDataFunctionTester.java; Menu item: Contact Entity Cursor
public void showRawContactsEntityCursor(){
    Cursor c = null;
    try {
        Uri uri = ContactsContract.RawContactsEntity.CONTENT_URI;
        c = this.getACursor(uri,null);
        this.printCursorColumnNames(c);
    }
    finally { if (c!=null) c.close(); }
}

清单 27-24 中的代码打印出清单 27-25 中所示的列列表。因此,清单 27-25 中的列是由原始联系人实体光标返回的列。根据供应商特定的实现,可能还有其他列。

清单 27-25 。联系人实体光标列

data_version; contact_id; version; data12;data11;data10; mimetype; res_package;
_id; data15;data14;data13; name_verified; is_restricted; is_super_primary; data_sync1;dirty;data_sync3;data_sync2; data_sync4;account_type;data1;sync4;sync3;
data4;sync2;data5;sync1; data2;data3;data8;data9; deleted; group_sourceid; data6;data7;
account_name; data_id; starred; sourceid; is_primary;

一旦知道了这组列,就可以通过制定适当的 where 子句来过滤这个游标的结果集。然而,您想要使用 ContactsContract Java 类来使用这些列名的定义。例如,在清单 27-26 中,我们检索与联系人 IDs 3、4 和 5 相关的数据元素。

清单 27-26 。显示来自 RawContactsEntity 的数据元素

//Java class: ContactDataFunctionTester.java; Menu item: Contact Data
public void showRawContactsData(){
    Cursor c = null;
    try {
        Uri uri = ContactsContract.RawContactsEntity.CONTENT_URI;
        c = this.getACursor(uri,"contact_id in (3,4,5)");
        this.printRawContactsData(c);
    }
    finally { if (c!=null) c.close(); }
}
protected void printRawContactsData(Cursor c) {
    for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
        ContactData dataRecord = new ContactData();
        dataRecord.fillinFrom(c);
        this.mReportTo.reportBack(tag, dataRecord.toString());
    }
}

清单 27-26 中的代码将打印姓名、电子邮件地址和 MIME 类型,如清单 27-23 中的 ContactData 对象所定义的那样。

添加联系人及其详细信息

让我们来看一个添加联系人姓名、电子邮件和电话号码的代码片段。要写入联系人,您需要清单文件中的以下权限:

android.permission.WRITE_CONTACTS

清单 27-27 中的代码添加了一个原始联系人,然后为该联系人添加了两个数据行(姓名和电话号码)。

清单 27-27 。添加联系人

//Java class: AddContactFunctionTester.java; Menu item: Add Contact
public void addContact(){
    long rawContactId = insertRawContact();
    this.mReportTo.reportBack(tag, "RawcontactId:" + rawContactId);
    insertName(rawContactId);
    insertPhoneNumber(rawContactId);
    showRawContactsDataForRawContact(rawContactId);
}
private long insertRawContact(){
    ContentValues cv = new ContentValues();
    cv.put(RawContacts.ACCOUNT_TYPE, "com.google");
    cv.put(RawContacts.ACCOUNT_NAME, "--use your gmail id -- ");
    Uri rawContactUri =
        this.mContext.getContentResolver()
             .insert(RawContacts.CONTENT_URI, cv);
    long rawContactId = ContentUris.parseId(rawContactUri);
    return rawContactId;
}
private void insertName(long rawContactId) {
    ContentValues cv = new ContentValues();
    cv.put(Data.RAW_CONTACT_ID, rawContactId);
    cv.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
    cv.put(StructuredName.DISPLAY_NAME,"John Doe_" + rawContactId);
    this.mContext.getContentResolver().insert(Data.CONTENT_URI, cv);
}
private void insertPhoneNumber(long rawContactId) {
    ContentValues cv = new ContentValues();
    cv.put(Data.RAW_CONTACT_ID, rawContactId);
    cv.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
    cv.put(Phone.NUMBER,"123 123 " + rawContactId);
    cv.put(Phone.TYPE,Phone.TYPE_HOME);
    this.mContext.getContentResolver().insert(Data.CONTENT_URI, cv);
}
private void showRawContactsDataForRawContact(long rawContactId) {
    Cursor c = null;
    try {
        Uri uri = ContactsContract.RawContactsEntity.CONTENT_URI;
        c = this.getACursor(uri,"_id = " + rawContactId);
        this.printRawContactsData(c);
    }
    finally { if (c!=null) c.close(); }
}

清单 27-27 中的代码执行以下操作:

  1. 使用帐户的名称和类型为预定义帐户添加新的原始联系人,由方法 insertRawContact() 表示。注意它是如何使用 URI RawContact 的。内容 _URI 。
  2. 从步骤 1 中获取原始联系人 ID,并使用 insertName() 方法在数据表中插入姓名记录。注意它是如何使用 URI 数据的。内容 _URI 。
  3. 从步骤 1 中获取原始联系人 ID,并在数据表中使用 insertPhoneNumber() 方法插入一个电话号码记录。作为数据行,它使用数据。内容 _URI 为 URI。

清单 27-27 还展示了插入记录时使用的列别名。注意像电话这样的常量。键入和的电话。编号指向通用数据表的列名 data1 和 data2 。

控制联系人的聚合

更新或插入联系人的客户端不会显式更改联系人表。联系人表由查看原始联系人表和原始联系人数据表的触发器更新。

添加或更改的原始联系人反过来会影响 contacts 表中的聚合联系人。但是,您可能不希望聚合两个联系人。

通过在创建合同时设置聚合模式,可以控制原始联系人的聚合行为。从清单 27-20 中的原始联系表列可以看出,原始联系表包含一个名为 aggregation_mode 的字段。聚集模式的值在清单 27-7 中显示,并在“聚集联系人”一节中解释

您还可以通过向名为 agg_exceptions 的表中插入行来保持两个联系人始终分开。需要插入到这个表中的 URIs 在 Java 类 ContactsContract 中定义。聚合异常。 agg_exceptions 的表结构如清单 27-28 所示。

清单 27-28 。聚集例外表定义

CREATE TABLE agg_exceptions
(_id INTEGER PRIMARY KEY AUTOINCREMENT,
type INTEGER NOT NULL,
raw_contact_id1 INTEGER REFERENCES raw_contacts(_id),
raw_contact_id2 INTEGER REFERENCES raw_contacts(_id))

清单 27-28 中的类型列保存了清单 27-29 中的一个整数常量。

清单 27-29 。聚集例外表中的聚集类型

ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER
ContactsContract.AggregationExceptions.TYPE_KEEP_SEPARATE
ContactsContract.AggregationExceptions.TYPE_AUTOMATIC

TYPE_KEEP_TOGETHER 表示这两个原始接触永远不应该分开。 TYPE_KEEP_SEPARATE 表示这些原始的联系永远不应该被连接。 TYPE_AUTOMATIC 表示使用默认算法来聚合联系人。

您将用来插入、读取和更新该表的 URI 被定义为

ContactsContract.AggregationExceptions.CONTENT_URI

Java 类 ContactsContract 中也提供了用于该表的字段定义的常量。聚合异常。

了解个人资料

API 14 中引入的个人资料类似于联系人,只是只有一个个人资料联系人。这就是你,在你的设备上。

然而,作为一个实现细节,与单个个人资料联系人相关的所有数据都保存在一个名为 profile.db 的单独数据库中。我们的研究表明,该数据库具有与联系人 2.db 相同的结构。这意味着您已经知道可用的相关表以及每个表的列。

作为单个联系人,聚合非常简单。被添加到个人简档中的每个原始联系人都被期望属于单个聚集联系人。如果不存在,则创建一个新的聚合联系人,并将其放在新的原始联系人中。如果存在,该联系人 ID 将用作原始联系人的聚合联系人 ID。

Android SDK 使用相同的基类 ContactsContract 来定义必要的 URIs,以读取/更新/删除/添加原始联系人到个人资料。这些 URIs 与它们的对应者相似,但是在它们的某个地方有一根弦【轮廓】。清单 27-30 展示了其中的一些 URIs。

清单 27-30 。4.0 中引入的基于配置文件的 URIs

//Relates to profile aggregated contact
ContactsContract.Profile.CONTENT_URI

//Relates to profile based raw contact
ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI

//Relates to profile based raw contact + profile based data table
ContactsContract.RawContactsEntity.PROFILE_CONTENT_URI

清单 27-30 显示了在处理聚集联系和原始联系时,我们有单独的 URIs。然而,对于数据表,没有相应的个人简介 URI。同样的数据 URI,数据。内容 _URI ,既适用于常规联系人数据,也适用于个人资料联系人数据。

还要注意,同一个内容供应器同时满足个人简档和常规联系人的需求。在内部,该内容供应器基于原始联系人 ID 知道数据 URI 属于简档数据还是常规联系人数据。

接下来让我们看看读取联系人数据并将其添加到个人资料中的代码片段。您将需要清单 27-31 中的权限来读写概要文件数据。

清单 27-31 。读取/写入个人资料数据的权限

<uses-permission android:name="android.permission.READ_PROFILE"/>
<uses-permission android:name="android.permission.WRITE_PROFILE"/>

读取档案原始联系人

让我们使用下面的 URI 来读取属于个人资料的原始联系人:

ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI

清单 27-32 显示了如何读取档案原始联系人条目。

清单 27-32 。显示所有简档原始联系人

//Java class: ProfileRawContactFunctionTester.java; Menu item: PRaw Contacts
//In the download this method is named showAllRawContacts
//It is expanded here for clarity.
public void showAllRawProfileContacts() {
    Cursor c = null;
    try {
        String whereClause = null;
        c = this.getACursor(ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI,
            whereClause);
        this.printRawContacts(c);
    }
    finally { if (c!=null) c.close(); }
}
//In the download this method is named printRawContacts
//It is expanded here for clarity.
private void printRawProfileContacts(Cursor c) {
    for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
        RawContact rc = new RawContact();
        rc.fillinFrom(c);
        this.mReportTo.reportBack(tag, rc.toString());
    }
}

请注意,一旦我们检索到光标,它包含的数据将与我们之前为常规原始联系人定义的 RawContact 相匹配。

读取个人资料联系人数据

让我们使用下面的 URI 来读取属于个人配置文件的原始联系人的各种数据元素(比如电子邮件、MIME 类型等等):

ContactsContract.RawContactsEntity.PROFILE_CONTENT_URI

请注意我们是如何使用与常规联系人相似的视图的。 RawContactEntity 是原始联系人和属于该原始联系人的数据行之间的连接。我们将看到每个数据元素占一行,比如姓名、电子邮件、MIME 类型等等。

清单 27-33 显示了读取 profile 原始联系人条目的代码片段。

清单 27-33 。显示个人资料联系人的数据元素

//Java class: ProfileContactDataFunctionTester.java; Menu item: all p raw contacts
public void showProfileRawContactsData() {
    Cursor c = null;
    try {
        Uri uri = ContactsContract.RawContactsEntity.PROFILE_CONTENT_URI;
        String whereClause = null;
        c = this.getACursor(uri,whereClause);
        this.printProfileRawContactsData(c);
    }
    finally { if (c!=null) c.close(); }
}
protected void printProfileRawContactsData(Cursor c) {
    for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
        ContactData dataRecord = new ContactData();
        dataRecord.fillinFrom(c);
        this.mReportTo.reportBack(tag, dataRecord.toString());
    }
}

请注意,一旦我们检索到光标,它包含的数据就与我们之前为常规原始联系人数据元素定义的 ContactData 对象(清单 27-23 )相匹配。

向个人档案添加数据

让我们使用以下 URI 将原始联系人添加到个人资料中:

ContactsContract.RawContactsEntity.PROFILE_CONTENT_URI

我们还将向该原始联系人添加一些数据元素,如电话号码和昵称,以便它们出现在设备上您个人资料的详细信息中。清单 27-34 显示了代码片段。

清单 27-34 。添加简档原始联系人

//Java class: AddProfileContactFunctionTester.java; Menu item: all p raw contacts
//In the source code you won't see the word "profile" in the following method names
//It is added here to add clarity as the whole class is not included
public void addProfileContact() {
    long rawContactId = insertProfileRawContact();
    this.mReportTo.reportBack(tag, "RawcontactId:" + rawContactId);
    insertProfileNickName(rawContactId);
    insertProfilePhoneNumber(rawContactId);
    showProfileRawContactsDataForRawContact(rawContactId);
}
private void insertProfileNickName(long rawContactId) {
    ContentValues cv = new ContentValues();
    cv.put(Data.RAW_CONTACT_ID, rawContactId);
    //cv.put(Data.IS_USER_PROFILE, "1");
    cv.put(Data.MIMETYPE, CommonDataKinds.Nickname.CONTENT_ITEM_TYPE);
    cv.put(CommonDataKinds.Nickname.NAME,"PJohn Nickname_" + rawContactId);
    this.mContext.getContentResolver().insert(Data.CONTENT_URI, cv);
}
private void insertProfilePhoneNumber(long rawContactId) {
    ContentValues cv = new ContentValues();
    cv.put(Data.RAW_CONTACT_ID, rawContactId);
    cv.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
    cv.put(Phone.NUMBER,"P123 123 " + rawContactId);
    cv.put(Phone.TYPE,Phone.TYPE_HOME);
    this.mContext.getContentResolver().insert(Data.CONTENT_URI, cv);
}
private long insertProfileRawContact() {
    ContentValues cv = new ContentValues();
    cv.put(RawContacts.ACCOUNT_TYPE, "com.google");
    cv.put(RawContacts.ACCOUNT_NAME, "--use your gmail id --");
    Uri rawContactUri =
        this.mContext.getContentResolver()
             .insert(ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI, cv);
    long rawContactId = ContentUris.parseId(rawContactUri);
    return rawContactId;
}
private void showProfileRawContactsDataForRawContact(long rawContactId) {
    Cursor c = null;
    try {
        Uri uri = ContactsContract.RawContactsEntity.PROFILE_CONTENT_URI;
        c = this.getACursor(uri,"_id = " + rawContactId);
        this.printRawContactsData(c);
    }
    finally { if (c!=null) c.close(); }
}

清单 27-34 中的代码与我们用来添加常规联系人及其详细信息的代码相似(清单 27-27 )。尽管我们使用了特定于概要文件的 URI 来添加原始联系人,但是我们使用了相同的数据。内容 _URI 添加单个数据元素。

注意清单 27-34 中的注释掉的代码:

//cv.put(Data.IS_USER_PROFILE, "1");

因为数据。CONTENT_URI 不特定于简档,底层内容供应器如何知道是将该数据插入常规原始联系人还是个人简档原始联系人?我们认为指定一个名为的列会对内容供应器有所帮助。显然不是。这个新列主要用于读取目的。如果在插入过程中指定此项,插入将会失败。唯一的结论是,内容供应器依靠原始联系人 ID 来查看该原始联系人是来自 profile.db 还是 contacts2.db 。

同步适配器的作用

到目前为止,我们主要讨论了如何操作设备上的联系人。然而,Android 上的帐户及其联系人与基于服务器的联系人密切相关。例如,如果你已经在安卓手机上创建了一个谷歌账户,谷歌账户将提取你的 Gmail 联系人,并使他们在设备上可用。为了做到这一点,Android 提供了一个同步框架,只要你编写一个符合标准的同步适配器,它就可以完成大部分的基础工作。Android 的同步框架负责网络可用性、可选认证和调度。

实现同步适配器包括通过扩展 SDK 类 AbstractThreadedSyncAdapter 来实现服务,并在方法 onperformatsync()中完成工作。该方法涉及的工作是从服务器加载数据,并使用本章中讨论的 Contacts API 更新联系人。然后,需要在设备上创建同步适配器资源文件(XML ),该文件将描述该服务如何与需要同步的帐户相关联。

除了这个基本的理解之外,由于篇幅的限制,我们在本书的这个版本中没有涉及同步 API。Android SDK 文档有一些文档和示例。

联系人的同步对删除设备上的联系人有影响。当您使用汇总联系人 URI 删除联系人时,将删除其所有对应的原始联系人以及每个原始联系人的数据元素。然而,Android 只会在设备上将它们标记为已删除,并期望后台同步实际上与服务器同步,然后从设备上永久删除联系人。这种删除的级联也发生在原始联系人级别,其中该原始联系人的相应数据元素被删除。

使用批处理操作优化 ContentProvider 更新

当在第二十六章中讨论内容提供者时,我们指出我们将在本章中讨论批处理操作。

重新思考一下在本章前面如何创建原始联系人及其关联的数据元素。请注意,我们需要向 contacts 提供者发送多个命令来插入一个原始联系人。首先,我们必须插入原始接触。然后使用该 ID 插入属于该原始联系人的多个数据元素。这些插入中的每一个都是独立发送给内容供应器的单独命令。

当我们顺序发送这多个命令时,有两个问题。第一个问题是内容提供者不知道它们属于单个提交单元。第二个问题是更新内容供应器数据库需要更长的时间,因为每个事务都是自己提交的。

这两个问题由可用于任何内容提供者(包括联系提供者)的批量更新 API 来解决。

批量更新内容供应器的想法

在批处理方法中,每个内容提供者更新操作都封装在一个名为“ContentProviderOperation”的对象中,还有 URI 和执行该操作所需的所有键/值对。然后将这些操作收集到一个列表对象中。然后告诉内容解析器同时将整批命令或命令列表发送给内容提供者。因为内容提供者知道这些命令是成批的,所以它会根据提示在最后或经常适当地应用事务。

如果一个操作指示事务可以在该操作结束时应用,那么到目前为止完成的操作将被提交。这允许您将许多行的长时间更新分成更小的子行集。您还可以在操作中指示要更新的列之一需要使用由索引的先前操作返回的键。我们现在将展示一些展示这些想法如何工作的样本代码。

清单 27-35 显示了一个创建列表对象来保存操作列表的例子。

清单 27-35 。用于内容供应器操作的容器

ArrayList<ContentProviderOperation> ops =  new ArrayList<ContentProviderOperation>();

现在让我们看看如何构造添加到清单 27-36 中的单个操作。

清单 27-36 。批处理 ContentProviderOperations

ContentProviderOperation.Builder op = ContentProviderOperation.newInsert(a content URI);
op.withValue(key, value);
//...more of these
ContentProviderOperation op1 = op.build();
ops.add(op1);

关键类是 ContentProviderOperation 及其对应的 Builder 对象。在这里的例子中,我们使用插入操作。对于其余的方法,请参见类参考。一旦我们有了一个构建器及其相关的内容 URI,我们告诉构建器添加一组与内容 URI 一起的键/值对。一旦添加完所有的键/值对,我们就从构建器中生成 ContentProviderOperation ,并将其添加到列表中。然后我们要求内容解析器使用清单 27-37 中的代码来应用批量操作。

清单 27-37 。使用内容解析来应用批量操作

activity.getContentResolver().applyBatch(contentProviderAuthority, ops);

在清单 27-37 中,参数 contentProviderAuthority 是指向内容供应器的授权字符串,参数 ops 是应该批量应用于该内容供应器的操作列表。这是一个将一系列更新操作作为单个事务添加的示例。现在让我们看看如何向提供提交提示,以便可以在给定批处理的较小子集上完成提交操作。

批处理通过让步提交

将大量命令作为单个事务提交的一个问题是,这项工作可能会阻塞数据库上的其他操作。为了有助于这一点,也为了有助于在单个事务中提交太多的工作,您可以指示一个操作放弃。当内容提供者识别出某个操作的 yield 参数时,它会提交已完成的工作并暂停,以便让出其他流程来运行。

注意在清单 27-38 的代码中,一个操作是如何被设置为允许 yield 的。

清单 27-38 。在 ContentProviderOperation 中使用 Yield

ContentProviderOperation.Builder operationBuilder =
      ContentProviderOperation.newInsert(a content URI);
operationBuilder.withValue(key, value);
//...more of these key/value pairs when you have them
ContentProviderOperation op1 = operationBuilder.build();

//... Add More operations

//Mark the next operation as yield allowed
operationBuilder = ContentProviderOperation.newInsert(a content URI);
operationBuilder.withValue(key, value);
operationBuilder.withYieldAllowed(true); //it is ok to commit
ContentProviderOperation operationWithYield = operationBuilder.build();
ops.add(operationWithYield);

//... Add More operations and yield points as needed

//Finally apply the list of operations
activity.getContentResolver().apply(contentProviderAuthority, ops);

使用反向引用

对于上面的一个操作,你可以使用一个反向引用,如清单 27-39 所示。

清单 27-39 。在 ContentProviderOperation 中使用反向引用

//Take the key coming out of op1 and add it as the value
int indexOfTheOperationWhoseKeyYouNeed = 0;
op.withValueBackReference(mykey, indexOfTheOperationWhoseKeyYouNeed);

清单 27-39 中的代码要求内容提供者运行由列表索引 indexOfTheOperationWhoseKeyYouNeed 指示的操作,并获取其生成的主键,并将其用作在目标操作上设置的列的值。这就是如何从原始联系人获取插入,并使用其主键作为属于该原始联系人的数据项的键值。

乐观锁定

在乐观锁定中,您首先在不锁定底层存储库的情况下应用事务,并查看自从您知道它的值之后是否进行了任何更新。如果是,请取消交易并重试。

为了在批处理模式下实现这一点,API 提供了一种称为断言查询的操作。在这种类型的操作中,内容提供者进行查询,并比较检索到的光标的值,以获得计数或某些键的值。如果它们不匹配,它将回滚事务并引发一个中断代码流的异常。请看清单 27-40 中的代码演示。

清单 27-40 。通过 newAssertQuery 使用乐观锁定

try {
  //Read a raw contact for a particular raw contact id
  ContentProviderOperation.Builder assertOpBuilder =
              ContentProviderOperation.newAssertQuery(rawContactUri);
  //Make sure there is only one raw contact with that details
  assertOpBuilder.withExpectedCount(1);
  //Make sure the version column matches with you started with
  //If not throw an exception. We chose to compare the version number
  //column (field) in the raw contacts table to assert.
  assertOpBuilder.withValue(SyncColumns.VERSION, mVersion);
  //get this operation and add it to the operations list at the end
  //Apply the batch ...
  activityInstance.getContentResolver().applyBatch(...);
}
//for this or other exceptions
catch (OperationApplicationException e) {
  //The batch is already cancelled
  //Tell the user the update failed
  //Show the user the new details and repeat the process
}

重用联系人提供者用户界面

Android 中的联系人提供者功能还定义了一组意图,可用于重用联系人应用中可用的 UI。

有三种意图。联系提供者基于内容提供者 UI 应用中发生的事件触发一组意图。例如,当用户在联系人应用中点击联系人上的“邀请到网络”按钮时,触发 intent INVITE_CONTACT。一个应用可以注册这个事件并读取联系信息。

当联系人提供者充当您的定制活动的搜索提供者时,会用到另一组意图。使用此功能,您可以通过搜索建议在自定义应用中搜索联系人。

外部应用可以使用另一组意图来重用联系人应用提供的 UI。您可以使用这些意图从联系人列表、电话号码列表、地址列表或电子邮件列表中进行选择。您还可以使用这些意图来更新联系人,或者使用 Android 应用提供的 UI 来创建联系人。

这些意图记录在 ContactsContract 的类引用中。意图。

使用组功能

联系人 API 提供了清单 27-41 中的所示的契约来处理联系人的群组特性

清单 27-41 。集团联系合同

ContactsContract.Groups
ContactsContract.CommonDataKinds.GroupMembership

groups 表保存诸如组的名称、关于该组的注释以及成员的一些组级别计数。原始联系人所属的组保存在数据表中。

使用照片功能

您可以使用清单 27-42 中显示的类契约来探索联系人的照片相关信息。

清单 27-42 。联系照片合同

ContactsContract.Contacts.Photo
ContactsContract.RawContacts.DisplayPhoto

这些协定的类文档包含描述如何使用这些功能的示例代码。

参考

以下是本章所涵盖主题的附加资源:

摘要

在本章中,我们介绍了以下内容:联系人 API 的性质,探索联系人数据库,探索联系人 API URIs 及其光标,读取和添加联系人,聚合原始联系人,个人资料和联系人之间的关系,以及读取和添加联系人到个人资料。我们还简要介绍了批处理提供者操作,使用联系人提供者作为联系人的搜索提供者。

二十八、探索安全性和权限

不讨论安全性,对现代开发平台或操作系统的探索就不完整。在 Android 中,安全性跨越了应用生命周期的所有阶段——从设计时策略考虑到运行时边界检查。在这一章中,你将学习 Android 的安全架构,并理解如何设计安全的应用。

让我们从 Android 安全模型开始。

了解 Android 安全模型

让我们深入讨论任何 Android 应用的部署和执行过程中的安全性。要部署 Android 应用,您必须使用数字证书对其进行签名,以便将其安装到设备上。关于执行,Android 在一个单独的进程中运行每个应用,其中每个进程都有一个唯一且永久的用户 id(在安装时分配)。这在进程周围设置了一个边界,防止一个应用直接访问另一个应用的数据。此外,Android 定义了声明式权限模型,保护敏感特性(如联系人列表)。

在接下来的几节中,我们将讨论这些主题。但是在我们开始之前,让我们先概述一下我们稍后将会提到的一些安全概念。

安全概念概述

Android 要求应用使用数字证书签名。此要求的好处之一是,应用不能用不是由原始作者或签名证书持有人发布的版本进行更新。例如,如果我们发布了一个应用,那么您不能用您的版本更新我们的应用(当然,除非您以某种方式获得了我们的证书)。也就是说,应用被签名意味着什么?还有签申请的流程是怎样的?

您使用数字证书对应用进行签名。一个数字证书 是一个包含你的信息的工件,比如你的公司名称、地址等等。数字证书的一些重要属性包括它的签名和公钥/私钥。公钥/私钥也称为密钥对 。注意,虽然这里用数字证书来签名。apk 文件,您也可以将它们用于其他目的(比如加密通信、签署文档等等)。您可以从可信的证书颁发机构(CA)获得数字证书,也可以使用诸如 keytool 之类的工具自己生成一个数字证书,我们稍后将对此进行讨论。数字证书存储在密钥库中。一个 keystore 包含一个数字证书列表,每个证书都有一个别名,您可以用它在 keystore 中引用它。

签署一个 Android 应用需要三样东西:一个数字证书。您希望签名的应用的 apk 文件,以及知道如何将数字签名应用到的工具。apk 文件。我们使用一个免费的工具,它是 Java 开发工具包(JDK)发行版的一部分,名为 jarsigner 。这个工具是一个命令行工具,它知道如何签署一个。jar 文件和一个。apk 文件实际上只是一个 zip 格式的文件,收集在一起。jar 文件和其他一些资源。还有其他签名工具可用,因此您可以自由选择最适合您的工具。

现在,让我们继续讨论如何签署。带有数字证书的 apk 文件。

为部署签署应用

要在设备上安装 Android 应用,首先需要签署 Android 包()。apk 文件)使用数字证书。然而,证书可以是自签名的——你不需要从认证机构如 VeriSign 购买证书。请注意,自签名证书通常被认为不太可信,在某些环境中被认为是不安全的。

对应用进行部署签名包括三个步骤。第一步是使用 keytool (或类似的工具)生成一个证书。第二步涉及使用 jarsigner 工具对进行签名。apk 文件和生成的证书。第三步是在内存边界上对齐应用的各个部分,以便在设备上运行时更有效地使用内存。注意,在开发过程中,Eclipse 的 ADT 插件和 Android Developer Studio 都会为您处理一切:签署您的。apk 文件并进行内存对齐,然后部署到仿真器或设备上。

使用 Keytool 生成自签名证书

keytool 工具管理一个私有密钥及其对应的 X.509 证书(数字证书的标准)的数据库。这个工具随 JDK 一起提供,驻留在 JDK bin 目录下。如果你按照第二章中关于改变路径的说明,JDK bin 目录应该已经在你的路径中了。在这一节中,我们将向您展示如何生成一个只有一个条目的密钥库,稍后您将使用它来签署一个 Android 。apk 文件。要生成密钥库条目,请执行以下操作:

  1. 创建一个文件夹来保存密钥库,比如 c:\android\release\ 。或者 /opt/android/release (取决于你的操作系统)。
  2. 打开一个 shell 或命令窗口,用清单 28-1 中显示的参数执行 keytool 工具。

清单 28-1 。 使用 keytool 工具生成密钥库条目

keytool -genkey -v -keystore "c:\android\release\release.keystore"
-alias androidbook -keyalg RSA
-validity 14000

传递给 keytool 的所有参数汇总在表 28-1 中。

表 28-1 。传递给 keytool 工具的参数

|

争吵

|

描述

|
| --- | --- |
| 键 | 告诉 keytool 生成公钥/私钥对。 |
| v | 告诉 keytool 在密钥生成期间发出详细输出。 |
| 密钥库 | 密钥库数据库的路径(在本例中是一个文件)。如有必要,将创建该文件。 |
| 别名 | 密钥库条目的唯一名称。稍后将使用此别名来引用密钥库条目。 |
| 键藻 | 算法。 |
| 有效期 | 有效期。 |

keytool 将在创建密钥库和您正在创建的条目时提示您输入两个密码。提示的第一个密码是密钥库本身的密码,它控制对您将存储的所有密钥材料的访问。这也可以使用 storepass 参数来指定。第二个密码是您正在创建的私钥和相关证书的密码,也可以通过 keypass 参数获得。您应该习惯于而不是将这些作为参数包含在命令行中,而是更喜欢让 keytool 提示您,这是一种良好的通用安全实践。

请注意,如果您确实使用了 keytool 的密码参数,那么任何能够访问您的 shell 或命令行历史的人都可以看到密码,就像任何能够在 keytool 运行时列出您的机器上正在运行的进程的人一样。清单 28-1 中的命令将在您的 keystore 文件夹中生成一个 keystore 数据库文件。数据库将是一个名为 release.keystore 的文件。参赛作品的有效期将是 14000 天(或大约 38 年),这是一段很长的时间。你应该明白这其中的原因。Android 文档建议您指定一个足够长的有效期,以超过应用的整个生命周期,这将包括应用的许多更新。建议有效期至少为 25 年。如果您计划在 Google Play 上发布应用,您的证书至少需要在 2033 年 10 月 22 日之前有效。Google Play 会在上传时检查每个应用,以确保它至少在此之前有效。

注意因为您在任何应用更新中的证书必须与您第一次使用的证书相匹配,所以请确保您保护好您的密钥材料。确保您的密钥库文件或密钥对(如果您选择导出它们)的安全!如果您失去了对 keystore 或底层密钥的访问,并且无法重新创建它,那么您将无法更新您的应用,而必须发布一个全新的应用。

回到 keytool ,参数别名是赋予密钥库数据库中条目的唯一名称;稍后您将使用这个名称来引用该条目。当您运行清单 28-1 中的 keytool 命令时, keytool 会问您几个问题(参见图 28-1 ),然后生成密钥库数据库和条目。

9781430246800_Fig28-01.jpg

图 28-1 。由 keytool 提出的附加问题

一旦有了生产证书的密钥库文件,就可以重用这个文件来添加更多的证书。只需再次使用 keytool ,并指定您现有的 keystore 文件。

调试密钥库和开发证书

我们提到过 Eclipse 的 ADT 插件和 Android Developer Studio 都负责为您设置开发密钥库。但是,开发期间用于签名的默认证书不能用于实际设备上的生产部署。这部分是因为自动生成的开发证书只有 365 天的有效期,这显然不会让您超过 2033 年 10 月 22 日。那么在发育的第三百六十六天会发生什么呢?您将得到一个构建错误。您现有的应用应该仍然可以运行,但是要构建应用的新版本,您需要生成新的证书。最简单的方法是删除现有的 debug.keystore 文件,一旦再次需要它,ADT(例如)将生成一个新文件和证书,有效期为 365 天。

要找到您的 debug.keystore 文件,假设您正在使用 Eclipse 和 ADT,打开 Eclipse 的 Preferences 屏幕并进入 Android image Build。调试证书的位置将显示在默认的调试密钥库字段中,如图图 28-2 所示(如果找不到首选项菜单,请参见第二章)。

9781430246800_Fig28-02.jpg

图 28-2 。调试证书的位置

当然,现在您已经获得了新的开发证书,您不能在 Android 虚拟设备(AVDs)或使用新开发证书的设备上更新您现有的应用。Eclipse 将在控制台中提供消息,告诉您首先使用 adb 卸载现有的应用,您当然可以这样做。如果您在 AVD 上安装了许多应用,您可能会觉得简单地重新创建 AVD 更容易,因此它不包含任何应用,您可以从头开始。为了在一年后避免这个问题,您可以生成自己的 debug.keystore 文件,它具有您想要的任何有效期。显然,它需要与 ADT 创建的文件具有相同的文件名,并且位于相同的目录中。证书别名为 androiddebugkey ,而 storepass 和 keypass 都是“Android”。ADT 将证书上的名和姓设置为“Android Debug”,组织单位设置为“Android”,双字母国家代码设置为“US”。您可以将组织、城市和州的值保留为“未知”。

如果您使用旧的调试证书从 Google 获得了一个 map-api 密钥,您将需要获得一个新的 map-api 密钥来匹配新的调试证书。我们在第十九章的中介绍了地图 api 键。

现在您有了一个数字证书,可以用来签署您的作品。apk 文件,您需要使用 jarsigner 工具来进行签名。以下是如何做到这一点。

使用 Jarsigner 工具对。apk 文件

上一节描述的 keytool 工具创建了一个数字证书,这是 jarsigner 工具的参数之一。jarsigner 的另一个参数是实际要签名的 Android 包。要生成 Android 包,您需要使用 Eclipse 的 ADT 插件中的导出未签名的应用包工具(或 Android Developer Studio 中的等效功能)。您可以通过在 Eclipse 中右键单击一个 Android 项目,选择 Android Tools,然后选择 Export Unsigned Application Package 来访问该工具。运行导出未签名的应用包工具将生成一个。不会用调试证书签名的 apk 文件。

要了解这是如何工作的,在您的一个 Android 项目上运行 Export Unsigned Application Package 工具,并存储生成的。apk 文件在某处。对于这个例子,我们将使用我们之前创建的 keystore 文件夹,并生成一个。apk 文件名为 c:\ Android \ release \ myapp raw . apk。

同。apk 文件和密钥库条目,运行 jarsigner 工具对进行签名。apk 文件(见清单 28-2 )。使用您的密钥库文件和的完整路径名。apk 文件。

清单 28-2 。?? 使用 jarsigner 对进行签名。apk 文件

jarsigner -keystore "PATH TO YOUR release.keystore FILE" -storepass paxxword -keypass paxxword "PATH TO YOUR RAW APK FILE" androidbook

签了。apk 文件,您传递密钥库的位置、密钥库密码、私钥密码、到的路径。apk 文件,以及密钥库条目的别名。然后签名人会在上签名。apk 文件,其中包含来自 keystore 条目的数字证书。要运行 jarsigner 工具,您需要打开一个工具窗口(如第二章中所述)或打开一个命令或终端窗口,然后导航到 JDK bin 目录或确保您的 JDK bin 目录在系统路径上。出于安全原因,更安全的做法是不使用命令的密码参数,只让 jarsigner 在需要时提示您输入密码。图 28-3 显示了 jarsigner 工具调用的样子。你可能已经注意到 jarsigner 在图 28-3 中只提示了一个密码。当 storepass 和 keypass 相同时,Jarsigner 发现不要询问 keypass 密码。严格来说,清单 28-2 中的 jarsigner 命令只需要–key pass,如果它的密码与–store pass 不同。

9781430246800_Fig28-03.jpg

图 28-3 。使用 jarsigner

正如我们前面指出的,Android 要求应用用数字签名进行签名,以防止恶意程序员用他们的版本更新您的应用。为了做到这一点,Android 要求对应用的更新必须使用与原始签名相同的签名。如果你用不同的签名给应用签名,Android 会把它们当作两个不同的应用。因此,我们再次提醒您,要小心您的密钥库文件,以便在以后需要为您的应用提供更新时可以使用它。

使用 zipalign 调整您的应用

您希望应用在设备上运行时尽可能节省内存。如果您的应用在运行时包含未压缩的数据(可能是某些图像类型或数据文件),Android 可以使用 mmap() 调用将这些数据直接映射到内存中。不过,要做到这一点,数据必须在 4 字节的内存边界上对齐。Android 设备中的 CPU 是 32 位处理器,32 位等于 4 个字节。这个 mmap() 调用在你的中产生数据。apk 文件看起来像内存,但是如果数据没有在 4 字节边界上对齐,它就不能这样做,在运行时必须进行额外的数据复制。位于 Android SDK build 或 build-tools/目录中的 zipalign 工具,可以浏览您的应用,并将任何尚未位于 4 字节内存边界的未压缩数据稍微移动到 4 字节内存边界。您可能会看到应用的文件大小略有增加,但并不显著。在您的上执行校准。apk 文件,在工具窗口中使用该命令(参见图 28-4 ):

zipalign –v 4 infile.apk outfile.apk

9781430246800_Fig28-04.jpg

图 28-4 。使用 zipalign

注意 zipalign 不修改输入文件,所以这就是为什么我们在从 Eclipse 导出时选择使用“raw”作为文件名的一部分。现在,我们的输出文件有了一个合适的部署名称。如果需要覆盖现有的 outfile.apk 文件,可以使用–f 选项。还要注意,当您创建对齐的文件时, zipalign 会执行对齐验证。要验证现有文件是否正确对齐,请按以下方式使用 zipalign :

zipalign –c –v 4 filename.apk

请务必在签名后对齐;否则,签署可能导致事情回到不一致的状态。这并不意味着您的应用会崩溃,但它可能会使用比它需要的更多的内存。

使用导出向导

在 Eclipse 中,您可能已经注意到了 Android Tools 下的一个菜单选项,称为导出签名的应用包。这将启动所谓的导出向导 ,它会为您执行前面的所有步骤,只提示您输入密钥库文件的路径、密钥别名、密码和输出的名称。apk 文件。如果需要,它甚至会创建一个新的密钥库或新的密钥。您可能会发现使用该向导更容易,或者您可能更喜欢自己编写脚本来操作导出的未签名应用包。现在你已经知道了每一种的工作原理,你可以决定哪一种更适合你。

手动安装应用

一旦您签署并校准了。apk 文件,您可以使用 adb 工具将其手动安装到虚拟设备上。作为一个练习,从 AVD 管理器启动虚拟设备,这将在不从 Eclipse 复制任何开发项目的情况下启动。现在,打开一个工具窗口,用安装命令运行 adb 工具:

adb install "PATH TO APK FILE GOES HERE"

失败的原因可能有几个,但最有可能的原因是应用的调试版本已经安装在仿真器上,这给了您一个证书错误,或者应用的发布版本已经安装在仿真器上,这给了您一个" INSTALL _ FAILED _ ALREADY _ EXISTS "错误。在第一种情况下,您可以使用以下命令卸载调试应用:

adb uninstall packagename

注意,卸载的参数是应用的包名,而不是。apk 文件名。包名在安装的应用的 AndroidManifest.xml 文件中定义。

对于第二种情况,您可以使用这个命令,其中–r 表示重新安装应用,同时将其数据保留在设备(或仿真器)上:

adb install –r "PATH TO APK FILE GOES HERE"

现在,让我们看看签名如何影响应用的更新过程。

将更新安装到应用并签名

之前,我们提到过证书有一个截止日期,Google 建议你将截止日期设置在很远的将来,以考虑到大量的应用更新。也就是说,如果证书过期了会发生什么?Android 还会运行这个应用吗?幸运的是,是的——Android 只在安装时测试证书的有效期。一旦安装了应用,即使证书过期,它也将继续运行。

但是更新呢?不幸的是,一旦证书过期,您将无法更新应用。换句话说,正如 Google 所建议的,您需要确保证书的生命周期足够长,以支持应用的整个生命周期。如果证书过期,Android 将不会安装应用的更新。剩下的唯一选择就是创建另一个应用——一个具有不同包名的应用——并用新证书对其进行签名。因此,正如您所看到的,在生成证书时考虑证书的到期日期是至关重要的。

既然你已经理解了关于部署和安装的安全性,让我们继续讨论 Android 的运行时安全性。

执行运行时安全检查

Android 中的运行时安全性发生在进程和操作级别。在进程级别,Android 阻止一个应用直接访问另一个应用的数据。它通过在不同的进程中运行每个应用,并使用一个唯一且永久的用户 ID 来实现这一点。在操作层面,Android 定义了一系列受保护的功能和资源。为了让您的应用访问这些信息,您必须向您的 AndroidManifest.xml 文件添加一个或多个权限请求。您还可以使用您的应用定义自定义权限。

在接下来的部分中,我们将讨论进程边界安全性以及如何声明和使用预定义的权限。我们还将讨论创建自定义权限并在您的应用中实施它们。让我们从剖析 Android 在进程边界的安全性开始。

了解流程边界的安全性

与桌面环境不同,在桌面环境中,大多数应用运行在同一个用户 id 下,每个 Android 应用通常运行在自己唯一的 ID 下。通过在不同的 ID 下运行每个应用,Android 在每个进程周围创建了一个隔离边界。这可以防止一个应用直接访问另一个应用的数据。

尽管每个进程都有一个边界,应用之间的数据共享显然是可能的,但必须是显式的。换句话说,要从另一个应用获取数据,您必须遍历该应用的组件。例如,你可以查询另一个应用的内容提供者,你可以调用另一个应用中的活动,或者——你将在第十五章中看到——你可以与另一个应用的服务进行通信。所有这些工具都为您提供了在应用之间共享信息的方法,但它们都是以显式的方式进行的,因为您不直接访问底层数据库、文件等。

Android 在进程边界的安全性清晰而简单。当我们开始谈论保护资源(如联系人数据)、功能(如设备的摄像头)和我们自己的组件时,事情变得有趣了。为了提供这种保护,Android 定义了一个权限方案。现在我们来分析一下。

声明和使用权限

Android 定义了一个权限方案,旨在保护设备上的资源和功能。例如,默认情况下,应用不能访问联系人列表、打电话等等。为了保护用户免受恶意应用的攻击,Android 要求应用在需要使用受保护的功能或资源时请求权限。从 Android Kit Kat 的引入,到 Android Lollipop 的延续,当呈现给最终用户时,权限现在被聚集成组,以解决它们不断增长的数量和复杂性。正如你将观察到的,这种分组带来了一些妥协。

正如我们将很快介绍的,权限请求放在清单文件中。在安装时,APK 安装程序根据的签名授予或拒绝所请求的权限。apk 文件和/或来自用户的反馈。如果未授予权限,任何执行或访问相关功能的尝试都将导致权限失败。

表 28-2 显示了一些常用的功能及其所需的权限。尽管您还不熟悉列出的所有特性,但您将在以后了解它们(无论是在本章还是在后续章节中)。

表 28-2 。功能和资源及其所需的权限

|

功能/资源

|

所需许可

|

描述

|
| --- | --- | --- |
| 照相机 | Android . permission . camera | 使您能够访问设备的摄像头。 |
| 互联网 | Android . permission . internet | 使您能够建立网络连接。 |
| 用户的联系数据 | Android . permission . read _ CONTACTSAndroid . permission . write _ CONTACTS | 使您能够读取或写入用户的联系人数据。 |
| 用户的日历数据 | Android . permission . read _ CALENDARAndroid . permission . write _ CALENDAR | 允许您读取或写入用户的日历数据。 |
| 录制音频 | Android . permission . record _ AUDIO | 使您能够录制音频。 |
| Wi-Fi 位置信息 | Android . permission . access _ COARSE _ LOCATION | 使您能够从 Wi-Fi 和手机信号塔访问粗粒度的位置信息。 |
| GPS 位置信息 | Android . permission . access _ FINE _ LOCATION | 使您能够访问精细的位置信息。这包括 GPS 位置信息。对于 Wi-Fi 和手机信号塔也足够了。 |
| 电池信息 | Android . permission . battery _ STATS | 使您能够获取电池状态信息。 |
| 蓝牙 | android。权限。蓝牙 | 使您能够连接到配对的蓝牙设备。 |

有关权限的完整列表,请参见以下 URL:

[`developer.android.com/reference/android/Manifest.permission.html`](http://developer.android.com/reference/android/Manifest.permission.html)

应用开发人员可以通过向 AndroidManifest.xml 文件添加条目来请求权限。例如,清单 28-3 要求访问设备上的摄像头,读取联系人列表,并读取日历。

清单 28-3 。中的权限 AndroidManifest.xml

<manifest ...  >
    <application>
         ...
    </application>
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.READ_CONTACTS"/>
    <uses-permission android:name="android.permission.READ_CALENDAR" />
</manifest>

请注意,您可以在 AndroidManifest.xml 文件中硬编码权限,也可以使用清单编辑器。当您打开(双击)清单文件时,清单编辑器就会启动。清单编辑器包含一个下拉列表,其中预加载了所有权限,以防止您出错。如图图 28-5 所示,您可以通过选择清单编辑器中的权限选项卡来访问权限列表。

9781430246800_Fig28-05.jpg

图 28-5 。Eclipse 中的 Android 清单编辑器工具

你现在知道 Android 定义了一组权限来保护一组特性和资源。类似地,您可以使用您的应用定义和实施自定义权限。让我们看看它是如何工作的。

了解和使用 URI 权限

内容供应器(在第四章中讨论)通常需要在比全部或没有更精细的层次上控制访问。幸运的是,Android 为此提供了一种机制。想想电子邮件附件。附件可能需要由另一个活动读取才能显示。但是其他活动不应该访问所有的电子邮件数据,甚至不需要访问所有的附件。这就是 URI 权限发挥作用的地方。

有意通过 URI 权限

当调用另一个活动并传递一个 URI 时,您的应用可以指定它正在向被传递的 URI 授予权限。但是在您的应用能够做到这一点之前,它本身需要对 URI 的许可,并且 URI 内容提供者必须合作并允许对另一个活动授予许可。调用授予权限的活动的代码看起来像清单 28-4 中的,它实际上来自 Android 电子邮件程序,在那里它启动一个活动来查看电子邮件附件。

清单 28-4 。 授权发起活动的代码

try {
    Intent intent = new Intent(Intent.ACTION_VIEW);
    intent.setData(contentUri);
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    startActivity(intent);
} catch (ActivityNotFoundException e) {
    mHandler.attachmentViewError();
    // TODO: Add a proper warning message (and lots of upstream cleanup to prevent
    // it from happening) in the next release.
}

附件由 contentUri 指定。注意意图是如何用动作意图创建的。ACTION_VIEW ,使用 setData() 设置数据。该标志被设置为将附件的读取权限授予与意图匹配的任何活动。这就是内容供应器发挥作用的地方。仅仅因为一个活动拥有对内容的读取权限,并不意味着它可以将该权限传递给其他还没有该权限的活动。内容供应器也必须允许它。当 Android 在一个活动上找到一个匹配的意图过滤器时,它会咨询内容供应器以确保可以授予权限。实质上,内容供应器被要求允许访问由 URI 指定的内容的这个新活动。如果内容供应器拒绝,则抛出安全异常,操作失败。在清单 28-4 中,这个特定的应用没有检查安全异常,因为开发者不期望任何拒绝授予许可的情况。这是因为附件内容提供程序是电子邮件应用的一部分!尽管有可能找不到处理附件的活动,但这是唯一被监视的异常。

如果被调用来处理 URI 的活动已经有了访问该 URI 的权限,内容提供者就不能拒绝访问。也就是说,调用活动可以授予权限,如果意向接收端的活动已经拥有了对 contentURI 的必要权限,那么被调用的活动将被允许顺利进行。

除了意图。FLAG _ GRANT _ READ _ URI _ PERMISSION,有一个写权限的标志: Intent。标志 _ 授予 _ 写入 _ URI _ 许可。可以在意图中指定两者。同样,这些标志可以应用于服务和广播接收者以及活动,因为它们也可以接收意图。

在内容供应器中指定 URI 权限

那么,内容供应器如何指定 URI 权限呢?它在 AndroidManifest.xml 文件中以两种方式之一实现:

  • 在 <提供者> 标签中,Android:granturipmissions 属性可以设置为真或假。如果为真,来自该内容供应器的任何内容都可以被授权。如果为假,指定 URI 权限的第二种方式可以发生,或者内容供应器可以决定不让任何其他人授予权限。
  • 用 <提供者> 的子标签指定权限。子标签是,在 < provider >内可以有多个。有三个可能的属性:
    • 使用 android:path 属性,您可以指定一个完整的路径,该路径将具有可授予的权限。
    • 类似地, android:pathPrefix 指定了 URI 路径的开始。
    • android:pathPattern 允许通配符(星号、 * 、字符)指定路径。

如前所述,在被允许将内容授予其他实体之前,授予实体还必须对内容拥有适当的权限。通过 <提供者> 标签的 android:readPermission 属性、 android:writePermission 属性和 android:permission 属性(一种用一个权限字符串值指定读写权限的便捷方式),内容提供者有额外的方法来控制对其内容的访问。这三个属性中任何一个的值都是一个字符串,它表示调用者必须拥有的权限,以便读取或写入这个内容提供者。在某个活动可以授予内容 URI 读权限之前,该活动必须首先拥有读权限,这由 android:readPermission 属性或 android:permission 属性指定。想要这些权限的实体将在它们的清单文件中用标签来声明它们。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

本安全章节涵盖了以下主题:

  • 独特的应用用户 id,有助于将应用相互分离,以保护处理和数据
  • 数字证书及其在 Android 应用签名中的使用
  • 应用只有在更新用与原始应用相同的数字证书签名时才能被更新
  • 使用 keytool 管理密钥库中的证书
  • 运行 jarsigner 将证书应用到应用。apk 文件
  • zipalign 和内存边界
  • Eclipse 插件向导负责为您生成 apk、应用证书和 zipalign
  • 手动将应用安装到设备和模拟器上
  • 应用可以声明和使用的权限
  • URI 权限以及内容供应器如何使用它们

二十九、在 Android 上使用谷歌云消息

当我们接近这本书的结尾时,当谈到处理设备外服务时,您将已经对 Android 中可用的许多通信协议和架构选项有了很好的理解和欣赏。在这一章中,我们将探索 Google 的云消息传递(或 GCM)平台,以及如何使用它来满足应用的远程通信和服务交互需求。

什么是谷歌云消息?

GCM 是 Google 提供的一项服务,它使你能够在不同的平台上编写多个应用,这些应用可以交换消息以增强它们的功能。多个应用的主要示例是与远程服务器应用交换消息的 Android 客户端应用。

作为开发人员,实际发送的消息及其目的取决于您。它可能是来自远程服务器的一条消息,让您的客户端应用知道(一条“下游”消息)新闻提要、音乐服务或类似订阅的新更新可用。从客户端到服务器的消息(“上游”消息)可能是发送聊天消息、图片缩略图或用户在客户端捕获或生成的其他新数据。这些只是例子——您可以自由想象在 GCM 中交换的消息的任何用途和任何负载。

了解 GCM 的关键构件

阅读了 GCM 简介之后,您已经知道了任何完整的 GCM 配置中的两个关键组件。完成这个画面的最后一部分是由 Google 托管的 GCM 服务器(实际上是服务器),它执行消息队列、转发等等。

概括来说,GCM 的三个关键组成部分如下:

  • 客户端应用—您编写的应用,例如 Android 应用,它发送和/或接收来自远程服务器的消息以帮助实现功能。
  • GCM Connection Servers—Google 的消息传递基础设施,管理所有消息传递流量、传递延迟时的消息队列、最终传递保证等。
  • 远程应用服务器—您编写的作为服务器的应用,以可访问互联网的方式托管,负责发送和/或接收来自客户端应用的消息。

我们也可以用视觉形式来表现这种架构。图 29-1 显示了一个完整的 GCM 设置中的组件和消息流。

9781430246800_Fig29-01.jpg

图 29-1 。 GCM 架构概述

准备在应用中使用 GCM

以前我们在构建示例应用时直接跳到了 Java 代码、布局 XML 等等,对于基于 GCM 的开发,我们需要采取一些准备步骤,以便让 Google 的服务器接受来自我们的客户机和服务器的流量。

在 Google 开发者控制台中创建或确认你的 GCM 项目

要使用谷歌的任何在线服务和 API,包括 GCM,你需要在谷歌开发者控制台中创建一个 API 项目,在 cloud.google.com/console.你可能已经有了一个可以重用的项目,但是让我们假设你是第一次创建一个项目。导航到控制台 URL,然后单击“创建项目”按钮。按照提示输入账户和账单信息等。,你应该会得到一个新的项目(或者确认一个现有的项目),如图 29-2 所示。

9781430246800_Fig29-02.jpg

图 29-2 。您的 Google 开发人员控制台已经安装了 API 项目

为您的项目激活 GCM APIs

API 项目就绪后,您现在需要激活特定的 GCM APIs。Google 支持几十种独立的 API,所有这些 API 在默认情况下都是禁用的,以确保您不会意外触发行为或招致您意想不到的成本。点击你的 api 项目(在我们的例子中,api-project-589435632025 ),在左侧的 APIs & Auth 部分,选择 API 并滚动,直到你看到 Google Cloud Messagingfor Android。使用“启用 API”按钮打开它。当按钮从启用 API 变为禁用 API 时,你就知道你已经成功启用了 GCM API ,如图图 29-3 所示。

9781430246800_Fig29-03.jpg

图 29-3 。启用了 GCM 的 Google 开发者控制台 API 项目

生成您的 API 密钥

与其他 Google APIs 一样,访问项目的 GCM API 需要您的密钥。这有助于确保从流量分离到计费、分析等各个方面,您的 GCM 消息不会无意中与其他应用的消息混合在一起。

要生成您的密钥,请选择 APIs & Auth 下的凭据选项。选择“创建新密钥”选项,当提示输入密钥属性时,选择“服务器”作为密钥类型。如果您知道目标服务器的公共 IP 地址,您可以在配置部分使用它,否则您可以使用 0.0.0.0/0 进行测试。然后选择“创建”来生成密钥。当您返回到控制台时,您应该在 Credentials 子菜单下看到您的 API 密钥。记下键值,因为您很快就会用到它。

认证 GCM 通信

API 密钥并不是用于在 GCM 环境中认证和授权消息传输的唯一标识信息。你可以在 developer.google.com 网站上找到更多关于 GCM 令牌和密钥使用的细节。简而言之,在 GCM 应用中使用了以下四种令牌类型:

  • 发送者 ID 可从谷歌开发者控制台获得的项目 ID 代码。您的服务器应用将使用它作为向 Google 的 GCM 服务器注册过程的一部分,以使它能够向 Android 客户端应用和您的用户发送消息。
  • Sender Auth Token 您的 API 密钥,在发送到 GCM 服务器的每条消息中使用,以证明消息的真实性及其来自您的服务器应用。
  • 对于您的 Android 应用,这是完全限定的 Java 包名,例如 com.androidbook.gcm 。因为这在所有 Android 应用中是唯一的,所以它允许 GCM 生态系统知道哪些应用接收哪些类型的消息。
  • 注册 ID 当您的客户端 Android 应用向 GCM 服务器注册消息传递时,分配给它的 ID。注册 ID 是敏感信息,应安全存储,不得泄露。

所有这些项目结合起来允许客户机和服务器应用向 GCM 注册并被它识别,还可以唯一地识别应用及其消息。

构建支持 Android GCM 的应用

构建一个有意义的基于 GCM 的 Android 应用和支持服务器端的第三方服务是一项大工程——大到我们几乎可以就这个主题写一本小书。下面我们将介绍构建应用时需要考虑的主要配置和编码要点,您可以查看图书网站,获得关于 GCM 的更深入的讨论和完整的示例。

为 GCM 编写客户端组件代码

客户端 Android 应用需要考虑三大领域。首先,正确设置您的开发环境。其次,配置 Android 项目,使其包含正确的依赖项和特权。最后,将 GCM 注册方法和消息处理方法写入活动的 Java 代码中。

为您的项目配置项目依赖关系

在我们可以为我们想要的基于 GCM 的应用编写实际的 Java 代码和任何相关的 XML 布局之前,我们需要配置我们的项目,使必要的 API 可用,并调用您的 IDE 的构建工具(例如 gradle)和必要的依赖项,以确保成功的构建。

你的开发环境(Android Studio,Eclipse 等。)需要安装 Google Play 服务 SDK。在 IDE 或命令提示符下用 SDK 管理器仔细检查这一点。

接下来,您的项目需要配置为使用 Google Play 服务 SDK 提供的 GoogleCloudMessaging API。例如,要将它添加到一个 Android Studio 项目中,打开你的项目的 build.gradle 文件,并确保该 API 作为一个依赖项被包含进来,如清单 29-1 所示。

清单 29-1 。 build.gradle 文件片段显示播放服务依赖关系

dependencies {
    // your other dependencies here
    compile "com.google.android.gms:play-services:3.1.+"
}

对于 Eclipse 用户,等效的任务是从 Google Play 服务库集合中添加 google-play-services.jar 作为项目的外部库依赖项。最后,任何 GCM 应用必须运行在 Android 2.2(安装了 Play Store)或更高版本上。更新你的清单的使用-sdk 元素来设置 android:minSdkVersion 至少为 8 。

为 GCM 设置清单属性

除了 GCM 所需的最低 SDK 版本之外,您的应用还需要特定的权限来执行以下操作:

  • 使用 com . Google . Android . c2dm . permission . receive 权限向 GCM 服务器注册以接收消息。
  • 使用设备的互联网连接发送信息,使用 Android . permission . internet 权限。
  • 专门保留给该应用的消息,并防止其他应用为它们注册。这使用了带有应用名称的自定义 C2D 消息权限块。
  • 您还将为接收者定义特定于 GCM 的权限,以便允许 GCM 服务器向您的应用发送消息。这使用 com . Google . Android . c2dm . permission . receive 设置。

注意GCM 的早期版本被称为 C2DM,即云到设备的消息传递。因此,参考 C2DM 和 C2D 的早期名称。

您定义的接收者应该声明它的 intent-filter 作用于 com . Google . Android . c2dm . intent . receive 并使用应用包名称作为它的类别。

仔细阅读一个示例 AndroidManifest.xml 文件的片段来查看所有这些设置通常会更好。清单 29-2 显示了 GCM 应用需要的四个关键权限的示例设置。

清单 29-2 。GCM Android 应用的示例 AndroidManifest.xml 条目

<manifest xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
    package="com.androidbook.gcm">

    ...

    <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="21"/>
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.GET_ACCOUNTS" />
    <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />

    ...

    <permission android:name="com.androidbook.gcm.permission.C2D_MESSAGE"
        android:protectionLevel="signature" />
    <uses-permission android:name="com.androidbook.gcm.permission.C2D_MESSAGE" />

    ...

   <application ...>
        <receiver
            android:name=".GcmBroadcastReceiver"
            android:permission="com.google.android.c2dm.permission.SEND" >
            <intent-filter>
                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
                <category android:name="com.androidbook.gcm" />
            </intent-filter>
        </receiver>
        <service android:name=".GcmIntentService" />
    </application>
...
</manifest>

编码您的主要活动以注册 GCM

在您的应用可以从 GCM 服务器(以及发送消息的服务器端应用)接收消息之前,在它可以通过 GCM 发送回自己的消息之前,您的应用必须向 GCM 服务器注册。这是为了让 GCM 基础设施知道如何路由您的消息,防止流量混淆,等等。清单 29-3 显示了一个在 onCreate() 覆盖中启动注册的示例活动片段。这个示例代码是仿照谷歌在 developer.android.com 发布的 github 示例 GCM 项目。

清单 29-3 。 从 Java 向 GCM 注册

package com.google.android.gcm.demo.app;
// imports from a default activity, and the GCM specific libraries

public class GCMExampleActivity extends Activity {

    public static final String EXTRA_MESSAGE = "message";
    public static final String PROPERTY_REG_ID = "registration_id";
    private static final int PLAY_SERVICES_RESOLUTION_REQUEST = 9000;

    String SENDER_ID = "a123b456c789d012"; // Remember to use your ID

    TextView myMessageDisplay;
    GoogleCloudMessaging gcm;
    AtomicInteger messageID = new AtomicInteger();
    Context context;
    String registrationID;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.main);
        myMessageDisplay = (TextView) findViewById(R.id.display);
        context = getApplicationContext();

        // Register with GCM servers
        gcm = GoogleCloudMessaging.getInstance(this);
        final SharedPreferences myAppPrefs = getGcmPreferences(context);
        registrationID = myAppPrefs.getString(PROPERTY_REG_ID, "");
        // you could also perform version and other checks if desired

        if (registrationID.isEmpty()) {
            // Not registered, do so async so as not to block main thread
            try {
                registerAppInBackground();
            } catch (NameNotFoundException e)  {
                // log details here, as something failed during registration
            }
        }
    }

    private void registerAppInBackground() {
        new AsyncTask<Void, Void, String>() {
            @Override
            protected String doInBackground(Void... params) {
                String regStatus = "Unregistered";
                try {
                    if (gcm == null) {
                        gcm = GoogleCloudMessaging.getInstance(context);
                    }
                    registrationID = gcm.register(SENDER_ID);
                    regStatus = "Registered with ID: " + registrationID;
                    // add your call to securely store the registrationID for later reuse
                    // ...
                } catch (IOException ex) {
                    // perform your error handling here, e.g. retry
                }
                return regStatus;
            }

        }.execute(null, null, null);
    }
...

这是一个非常简化的例子,因此您可以专注于绝对强制的组件,并从那里开始构建。我们的 onCreate() 方法首先实例化一个 gcm 对象和一个 SharedPreferences 对象。然后,它从首选项中检索 registration_id 。此时,您的应用可能尚未注册,这意味着首选项的返回值将为空。我们测试这个空值,当检测到空的 registration_id 时,我们通过调用私有方法 registerAppInBackground()启动注册过程。

registerAppInBackground()的实现遵循 Google 的建议,异步执行首次注册。我们这样做是因为我们不想在等待握手和注册过程完成时阻塞主线程。该过程可能需要几秒钟或更长时间。您可以通过添加一系列中间状态更新、错误检查等等来增强应用。

一旦我们有了一个注册的应用,我们就可以执行消息交换,然后基于消息传递方面或由消息传递方面驱动,执行您可能希望您的应用拥有的所有其他逻辑。清单 29-4 展示了一个基于 Bundle 对象发送消息的示例方法,该对象用于保存消息细节。

清单 29-4 。 示例从您的 Android 客户端发送消息

private void sendMessage(Bundle messagePayload) {
    new AsyncTask<Void, Void, String>() {
        @Override
        protected String doInBackground(Void... params) {
            String status = "";
            try {
                String id = Integer.toString(messageID.incrementAndGet());
                gcm.send(SENDER_ID + "@gcm.googleapis.com", id, messagePayload);
                status = "message sent";
            } catch (IOException ex) {
                // your error handling here
                // set status string
            }
            return status;
        }
    }
}

sendMessage() 方法的逻辑完全与发送你在 messagePayload 参数中构造的内容有关。这个 Bundle 对象留给你自己去想象,但是它可以是即时消息、照片、语音消息,或者你的应用实际上在帮助用户的其他内容。

我们再次使用 AsyncTask 来确保我们不会阻塞消息传递。这是处理任何类型的消息总线或消息传递服务时的通用设计模式。在异步逻辑中,我们使用 messageid . incrementandget()方法生成唯一的消息标识符,然后调用 gcm.send() 方法,向其传递唯一的 ID 和我们的消息有效负载。

此时很容易添加错误捕获和重试逻辑。如果您打算对消息 ID 采用更高的值(通常不推荐),最好将重试逻辑放在 sendMessage() 方法中,以便能够在消息 ID 超出方法返回的范围之前重用它。

为 GCM 编写服务器组件代码

您的第三方服务基本上可以用任何语言编写,只要它可以调用 GCM 云端点并支持我们在本章前面部分描述的授权消息协议。

因为这样的服务不是严格意义上的 Android 产品或代码,我们将从书中保留一些珍贵的页面,并向您指出 Google 提供的优秀示例,以给你编写后端服务的灵感。

您可以在 developer.android.com/google/gcm/server.html 查看这项非 Android 第三方服务的选项和方法。

超越 GCM 简介

这么短的一章无法涵盖基于 GCM 的应用的巨大可能性和细微差别。关于什么是可能的更多细节,请查看 Android Stack Exhange 站点、【android.stackechange.com】【developer.android.com】站点。

三十、部署您的应用:谷歌 Play 商店及其他

创建一个人们会喜欢的伟大的应用是一回事,但是你还需要一个容易的方法让人们找到并下载它。谷歌为此创建了 Play Store。用户可以通过设备上的图标直接点击 Play Store 来浏览、搜索、查看和下载应用。用户也可以通过互联网访问 Play Store 来做同样的事情,尽管下载不是在电脑上,而是直接将应用发送到用户的设备上。很多应用都是免费的;对于那些不是这样的人,Play Store 提供了方便购买的支付机制。

Play Store 甚至可以从应用内部访问,这使得应用可以很容易地访问 Play Store,以指导用户获得他们需要的东西,使您的应用获得成功。例如,当您的应用有了新版本时,您可以让用户轻松地直接进入 Play Store 页面来获取或购买新版本。然而,谷歌 Play 商店并不是让应用进入设备的唯一途径;其他渠道遍布互联网。

谷歌 Play 商店应用在模拟器中是不可用的(尽管黑客可以使它可用)。这对开发人员来说有点困难。理想情况下,你会有一个自己的设备,你可以用谷歌 Play 商店。在这一章中,我们将探索如何让你设置发布应用到 Play Store 如何准备通过 Play Store 销售您的应用;如何保护自己免受盗版侵害;用户将如何找到、下载和使用你的应用;最后,让您的应用可用的替代方法。

成为出版商

在你上传一个应用到谷歌 Play 商店之前,你需要成为一个发布者。为此,您必须创建一个 Google Play 发布者帐户。一旦完成,你就可以将你的应用上传到 Play Store,这样用户就可以找到并下载它们了。如果你要为你的应用收费,或者接受应用内购买,你还需要设置一个谷歌钱包商户账户。谷歌已经使获得这些账户的过程变得相对容易,价格也合理。

从这个页面开始比较好:developer . Android . com/distribute/Google play/start . html。从这里,您可以单击大的开始按钮开始该过程。如果您还没有 Google 帐户,系统会提示您创建一个。要成为发布者,您还需要提供开发人员姓名、电子邮件地址、网站地址以及可以联系到您的电话号码。一旦您的帐户设置完成,您将能够在以后更改这些值。你还需要支付注册费。这是通过谷歌钱包完成的。为了完成支付交易,您需要使用您的 Google 帐户。

在付款过程中,您可以选择“对我的电子邮件地址保密”这是指您和谷歌 Play 商店之间当前购买 publisher 访问权限的交易。如果您选择“是”,您将对谷歌 Play 商店保密您的电子邮件地址。这与向应用的购买者保密您的电子邮件地址没有任何关系。买家能否看到你的电子邮件地址与此选项无关。稍后会详细介绍。

接下来是 Google Play 开发者分发协议(GPDDA) 。这是谷歌和你之间的法律合同。它阐明了分发应用、收取费用、批准退款、反馈、评级、用户权利、开发者权利等等的规则。在本章的“遵守规则”一节中有更多的内容。

接受协议后,您将在被带到一个通常称为开发者控制台的页面。

遵守规则

GPDDA 阐明了许多规则。你可能希望法律顾问在同意合同之前先审查一下,这取决于你计划在谷歌 Play 商店开展业务的认真程度。本节介绍了您可能感兴趣的一些亮点:

  • 你必须是一个信誉良好的开发者才能使用谷歌 Play 商店。这意味着您必须按照描述的过程进行注册,您必须接受协议,并且您必须遵守协议中的规则。违反规则可能会让你被禁止,你的产品也会从 Play Store 中删除。
  • 你可以免费或有偿分发产品。该协议适用于任何一种情况。付款必须通过授权的谷歌 Play 商店支付处理器收取。这包括 Google Checkout(信用卡、借记卡、Google Play 礼品卡)、运营商计费(例如威瑞森、美国电话电报公司)和 PayPal。
  • 付费应用将产生交易费,可能还会从设备运营商那里收取费用,这些费用将从售价中扣除。截至 2015 年 3 月,交易费用为 30%,因此,如果销售价格为 10 美元,谷歌收取 3 美元,你得到 7 美元(假设没有运营商费用)。
  • 对于欧盟国家,谷歌需要为您汇出税款。在欧盟之外,您有责任向您的税务机关缴纳适当的税款。对于一些非欧盟国家,你可以选择让谷歌为你汇税。当您设置您的商家帐户时,您指定适用于购买的适当税率。Google Checkout 将根据您设置 Google Checkout 的方式收取相应的税款。如果谷歌没有为你汇款,这笔钱将提供给你,你必须适当地汇款。关于美国销售税的其他信息,可以试试biztaxlaw . about . com/od/business taxes/f/onlinesalestax . htmwww.thestc.com
  • 你可以免费发布你的应用的演示版本,也可以选择付费来解锁应用的全部功能;但是,您必须通过授权的谷歌 Play 商店支付处理器收取款项。你不允许将你的免费应用的用户重定向到一些其他的支付处理器来收取升级费用。你可以这样想:如果你通过谷歌 Play 商店赚钱,谷歌想要它的份额。
  • 应用内计费允许应用对应用内使用的数字商品或资产收费。数字资产可以是虚拟武器、游戏的新关卡、音乐或图形文件。结帐过程与购买应用相同。
  • 如果您的应用要求用户登录某个 web 服务器,并且该 web 服务器向用户收取订阅费,那么该 web 服务器可以以任何方式收取订阅费。这样,你就切断了订阅费与应用的联系,谷歌就可以让应用在谷歌 Play 商店可用——只要你的免费应用不把用户导向网站。一些人只是决定从与服务相同的 web 服务器上分发他们的免费 Android 应用,但这确实需要用户能够安装来自未知来源的应用,这可能会阻止一些用户安装。
  • 你似乎可以使用替代支付处理器来接受免费应用用户的捐款,但你不能在你的应用中创建激励机制来鼓励这些捐款。
  • 虽然 GPDDA 表示可以在购买后 48 小时内申请退款,但截至 2015 年 3 月,用户可以在购买后 2 小时内申请自动退款。在下载前可以预览产品的用户不会获得退款。这包括铃声和壁纸。
  • 然而,Google Checkout 确实允许开发者在退款窗口已经过去的情况下进行退款。用户可以进入他们的 Google Play 活动历史记录,并在最初的 2 小时后申请退款。如果购买后不到 48 小时,退款可能会自动进行。否则,要不要退钱由开发商决定。
  • 您需要为您的产品提供足够的支持。如果没有提供足够的支持,用户可以通过谷歌要求退款,这些费用将被退还给您,可能包括手续费。
  • 用户可以无限制地重新安装从谷歌 Play 商店下载的应用。如果用户对其设备进行了出厂重置,该功能允许他们取回所有应用,而无需重新购买。
  • 开发者同意保护用户的隐私和合法权利。这包括保护在使用应用的过程中可能收集到的任何数据。可以更改有关用户数据保护的规则,但只能通过显示并让用户接受您与该用户之间的单独协议来实现。
  • 你的申请不得与谷歌 Play 商店竞争。谷歌不希望谷歌 Play 商店国内的应用在谷歌 Play 商店以外销售安卓产品,从而绕过其支付处理器。这并不意味着你不能通过其他渠道销售你的应用,但是你在谷歌 Play 商店的应用本身不能在谷歌 Play 商店之外销售 Android 产品。
  • 谷歌会给你的产品打分。评级可以基于用户反馈、安装率、卸载率、退款率和/或开发者综合得分。开发者综合得分可能由 Google 使用应用的过去历史来计算,这可能会影响新应用的评级。出于这个原因,发布与你相关的高质量应用是很重要的,即使是免费的。还不清楚开发者综合得分是否存在,但如果存在,就没办法看到你的了。
  • 通过谷歌 Play 商店销售您的应用,您就授予了用户“在设备上执行、显示和使用产品的非排他性、全球性、永久性许可”但是,您完全可以编写一份单独的最终用户许可协议(EULA)来取代本声明。在您的网站上提供此 EULA,或者为购物者和用户提供其他阅读方式。
  • 谷歌要求你遵守 Android 的品牌规则。这些措施包括限制使用单词 Android ,以及使用机器人图形、标志和定制字体。更多详情请前往developer . Android . com/distribute/tools/promote/brand . html

开发者控制台

开发者控制台是您在谷歌 Play 商店控制应用的登陆页面。从开发人员控制台,您可以在 Google Checkout 中设置一个商家帐户(这样您就可以对您的应用收费),上传应用,并获得有关您上传的应用的信息。您还可以编辑您的帐户详细信息,包括开发人员姓名、电子邮件地址、网址和电话号码。图 30-1 显示了开发者控制台。

9781430246800_Fig30-01.jpg

图 30-1 。谷歌 Play 商店开发者控制台

如果您没有设置商家帐户,您将无法在谷歌 Play 商店为您的产品付款。建立一个商家账户并不难。单击开发人员控制台中的链接,填写应用,同意服务条款,一切就绪。您需要提供一个美国联邦税号(EIN),一个信用卡号加上一个美国社会保险号(SSN),或者只是一个信用卡号。税务信息用于验证您的信用状况,以确保及时存款。当您的 Google Checkout 帐户中资金不足时,信用卡信息将用于处理因买方争议而导致的退款。您还可以提供银行帐户信息,以便从销售收入中进行电子资金转账。

请注意,谷歌结账不仅仅是谷歌 Play 商店的一项服务。因此,不要被非谷歌 Play 商店销售的 Google Checkout 的交易费信息所迷惑。前面提到的 30%是谷歌 Play 商店的交易费。对于非谷歌 Play 商店销售,还有额外的 Google Checkout 交易费信息,这些信息不适用于谷歌 Play 商店。

上传和监控你的应用可能是你将使用的开发者控制台的主要功能,尽管控制台也是你可以注册访问 Google APIs 和游戏服务并链接到你的 AdWords 帐户的地方。

对于监控,Play Store 提供了工具来查看您的应用在总下载量方面的表现以及有多少用户仍然安装了它。您可以查看您的应用的总体评分(0 到 5 颗星),以及有多少人提交了评分。开发人员控制台中有各种报告、图表和图形,因此您可以看到您的应用在不同版本的 Android、不同设备、不同国家和不同语言中的运行情况。

除了对您的应用进行评级之外,用户还可以提交评论。为了快速解决任何问题,阅读评论对你最有利。评论中包含用户对您的应用的评价、用户输入的用户名以及提交评论的日期。您可以回复特定的评论,该用户将会收到一封电子邮件通知他们。您只能对每个评论留下一个回复,但您可以在以后编辑您的回复。在极端情况下,如果某个评论特别有害或不恰当,您可以通过帮助中心网站联系谷歌支持部门:【https://support.google.com/googleplay/android-developer/】

开发人员控制台允许您重新发布应用(例如,为了升级)或取消发布应用。取消发布不会将它从设备上移除,甚至也不一定会将应用从谷歌服务器上移除,尤其是如果它是一个付费应用的话。已经为您的应用付费并卸载了它,但没有要求退款的用户可以在以后重新安装它,即使您已经取消发布它。用户真正无法使用它的唯一方式是,如果谷歌因违反规则而取消它。

您还可以查看由您的应用生成的错误,并看到应用冻结和崩溃。图 30-2 显示了崩溃的& ANRs 屏幕。

9781430246800_Fig30-02.jpg

图 30-2 。崩溃& ANRs 屏幕

深入到崩溃报告的细节,您可以看到崩溃的堆栈跟踪,以及运行应用的设备类型和崩溃的时间。不幸的是,您无法与遇到问题的用户联系以获得更多详细信息或帮助他们解决问题。你必须希望受影响的用户会通过评论、电子邮件或你的网站与你联系。否则,您只能从崩溃报告中找出问题所在,并尝试修复它。

如果你真的想知道用户是如何崩溃的,你需要在你的应用中实现一个移动分析包。当用户逐步通过您的应用时,这些将生成事件记录,并且还将报告崩溃。面包屑(事件记录)将让您知道用户在崩溃之前采取的步骤。然而,这种能力是独立于谷歌 Play 商店的。

开发人员控制台还有一个您可能需要使用的特性:网站的帮助部分。帮助按钮在右上角。单击它会显示一些内联帮助,还会有一个指向帮助中心网站的链接。还有提交电子邮件或在线聊天(在工作时间)的链接。

我们已经向您介绍了开发人员控制台的一些不错的功能,但是您可能想了解最有用的部分,即将您的应用放入谷歌 Play 商店,以便用户可以找到并下载它们。但在此之前,我们先来看看如何准备您的应用进行上传和销售。

准备销售您的应用

将一个应用从代码完成到谷歌 Play 商店,有很多事情要考虑和做。本节将帮助您完成这些项目。

不同设备的测试

随着越来越多的 Android 设备变得可用,并且每个设备都可能具有一些新的硬件配置,测试您想要支持的各种设备非常重要。您可以购买一些您想要支持的设备,但您可能无法全部购买。有一些在线服务可以让真实的设备在互联网上使用。您的另一个选择是为每种类型的设备配置 Android 虚拟设备(AVD ),指定适当的硬件配置,然后用仿真器和每个 AVD 进行测试。一些设备制造商提供了特定于他们设备的 Android 模拟器包,所以请查看他们的网站以获得下载选项。

Android SDK 提供了各种帮助测试的类,以及 UI/应用练习器 Monkey。这些工具将帮助您进行自动化测试,这样您就不会永远手动测试您的应用。有关更多详细信息,请访问以下网页:

[`developer.android.com/tools/testing/index.html`](https://developer.android.com/tools/testing/index.html)
[`developer.android.com/tools/testing-support-library/index.html`](https://developer.android.com/tools/testing-support-library/index.html)

在您开始测试之前,您可能想要从您的代码中移除任何您不再需要的开发工件,以及来自 /res 的任何开发工件。您希望您的应用尽可能小,并以最少的内存尽可能快地运行。最后,确保从您的应用中禁用或删除任何您不希望发布到生产环境中的调试功能。

支持不同的屏幕尺寸

Android 支持多种屏幕尺寸。为了以最小的尺寸运行,您必须在 AndroidManifest.xml 文件中设置一个特定的 < supports-screens > 元素作为 < manifest > 的子元素。如果没有此标签指定您的应用支持小屏幕尺寸,您的应用在 Play Store 中对于小屏幕设备将是不可见的。

为了支持不同的屏幕尺寸,你可能需要在 /res 下创建备用资源文件。比如对于 /res/layout 中的文件,你可能需要在 /res/layout-small 中创建相应的文件来支持小屏幕。这并不意味着您还必须在 /res/layout-large 和 /res/layout-normal 中创建相应的文件,因为如果 Android 在更具体的资源目录(如 /res/layout-large )中找不到所需的内容,它会在 /res/layout 中查找。还要记住,这些资源文件可以有限定符的组合;例如, /res/layout-small-land 将包含横向模式下小屏幕的布局。支持小屏幕可能意味着也要创建可绘制的替代版本,比如图标。对于 drawables,考虑到屏幕分辨率和屏幕大小,您可能需要创建备用资源目录。

平板电脑当然在屏幕尺寸方面走了相反的方向,使用了标签 xlarge 。与之前相同的 < supports-screens > 标记用于指定您的应用是否将在超大屏幕上运行,在该标记内使用的属性是 android:xlargeScreens 。在某些情况下,您可能有一个仅适用于平板电脑的应用,在这种情况下,您需要明确指出对于其他尺寸,属性值为 false 。

正在准备 AndroidManifest.xml 以便上载

你的 AndroidManifest.xml 文件在你上传到谷歌 Play 商店之前可能需要稍微调整一下。ADT 通常将 android:icon 属性放在 <应用> 标签中,而不放在 <活动> 标签中。如果你有不止一个可以启动的活动,你需要为每个活动指定单独的图标,这样用户就可以更容易的区分它们。但是您仍然需要一个在 <应用> 中指定的图标,它也作为任何没有指定自己图标的活动的默认活动图标。只有在 < activity > 标签中指定了 android:icon 的情况下,你的应用才能在设备上和模拟器中正常工作,但是当谷歌 Play 商店检查你的应用的标签时。apk 文件上传时,在 <应用> 标签中寻找图标信息。

如果您使用的软件包名称以 com.google 、 com.android 、 android 或 com.example 开头,谷歌 Play 商店会阻止上传您的应用,但我们希望您没有在您的应用中使用这些名称。

对于您的应用,还有许多其他兼容性需要考虑。一些设备有摄像头,一些没有物理键盘,一些有轨迹球而不是方向垫。根据需要在您的 AndroidManifest.xml 文件中使用和 < uses-feature > 标签来定义您的应用有哪些硬件/平台需求。谷歌 Play 商店将强制执行这一点,不会让用户在不支持你的应用的设备上看到你的应用。请注意,这些标签与 AndroidManifest.xml 文件的标签是不同且独立的。在大多数情况下,您会在您的 AndroidManifest.xml 文件中使用两个标签,一个用于指定需要一个摄像头,另一个用于指定需要使用摄像头的权限。但是并不是所有的功能都需要权限,所以指定您需要的功能对您最有利。

和 < uses-feature > 还有一个很大的区别: < uses-feature > 标签可以说你的应用需要那个特性或者你的应用没有它也能运行。也就是有一个属性叫做 android:required 可以设置为 true 或者 false;默认情况下是真。如果某个特性有权限,但是您没有提供相应的 < uses-feature > 标签,那么默认情况下,就好像您指定了 < uses-feature > 并且该特性是必需的。例如,如果蓝牙可用,您的应用可能会利用它,但如果它不可用,也能正常工作。因此,在清单文件中,除了 Bluetooth permission 元素之外,还有如下内容:

<uses-feature android:name="android.hardware.bluetooth" android:required="false" />

在您的应用代码中,您应该调用 PackageManager 来确定蓝牙是否可用,您可以通过以下方式来完成:

boolean hasBluetooth = getPackageManager().hasSystemFeature(
                PackageManager.FEATURE_BLUETOOTH);

如果没有蓝牙,就在应用中采取适当的措施。Android 文档在这方面可能会令人困惑。如果您查看 < uses-feature > 的开发者指南页面,您将不会看到 PackageManager 参考页面上描述的那么多特性,该页面为每个可用特性定义了一个 FEATURE_* 常量。

<用途-配置> 标签略有不同。它规定了设备必须具备的键盘、触摸屏和/或导航控件的种类。但是你可以将配置选项的组合放到你的应用需要的地方,而不是像 <使用功能> 那样的独立选项。例如,如果您的应用需要一个五向导航控件(即,一个 D-pad 或一个轨迹球)和一个触摸屏(使用手写笔或手指),您可以指定如下两个标记:

<uses-configuration android:reqFiveWayNav="true" android:reqTouchScreen="stylus" />
<uses-configuration android:reqFiveWayNav="true" android:reqTouchScreen="finger" />

本地化应用

如果您的应用将在其他国家使用,您可能需要考虑将其本地化。这在技术上相对容易做到。找人做本地化是另一回事。从技术角度来看,您只需在 /res 下创建另一个文件夹——例如, /res/values-fr 来存放法语版的 strings.xml 。获取现有的 strings.xml 文件,将字符串值翻译成新的语言,并使用与原始文件相同的文件名将新翻译的文件保存在新的资源文件夹下。在运行时,如果设备的语言设置为法语,Android 将查找放置在 /res/values-fr 下的字符串。如果它不能从那里找到字符串,那么它将从 /res/values 中寻找字符串。

同样的技术也适用于其他类型的资源文件——例如,可绘制文件和菜单。如果不同的国家或文化有不同的图像和颜色,对你的用户来说可能会更好。因此,最好不要对颜色资源名称使用真彩色名称。在颜色的在线文档中,经常会看到这样的内容:

<color name="solid_red">#f00</color>

这意味着在您的代码或其他资源文件中,您通过颜色的实际名称来引用颜色,在本例中是 solid_red 。为了将颜色本地化为更适合其他国家或文化的颜色,最好使用一个颜色名称,如 accent_color1 或 alert_color 。在英语中,红色可能是合适的颜色值,而在西班牙语中,使用黄色可能更好。因为像 alert_color 这样的颜色名称并不显示您正在使用的实际颜色,所以当您想要将实际颜色值更改为其他值时,就不会那么混乱了。与此同时,你可以设计一个令人愉悦的配色方案,包括基色和强调色,并且更加确信你在正确的地方使用了正确的颜色。

在不同的国家,菜单选项可能需要更改,使用更少或更多的菜单项,或者以不同的方式组织,这取决于应用在哪里使用。菜单通常存储在 /res/menu 下。如果您面临这种情况,您可能最好将所有字符串文本放入 strings.xml 或位于 /res/values 目录下的其他文件中,并在其他适当的资源文件中使用字符串 id。这使得您不太可能错过翻译某个晦涩的资源文件中的字符串值。然后,您的语言翻译工作仅限于 /res/values 下的文件。

准备应用图标

下载后,购物者和你的用户将会在谷歌 Play 商店和他们的设备上看到你的应用图标和标签。请特别注意为您的应用及其活动创建良好的图标和标签。根据需要或期望对它们进行本地化。请记住,对于不同的屏幕尺寸,您的图标可能需要调整,以便看起来更好。看看其他开发者对他们的图标做了什么,尤其是那些和你的应用在同一个类别的应用。你想让你的应用引起注意,所以最好不要和其他应用混在一起。同时,你希望你的图标和标签在一个设备上工作得很好,当它被许多其他的应用图标围绕着的时候。你不希望用户对你的应用的功能感到困惑,所以让图标代表你的应用的功能。

在为你的应用创建任何图像时,尤其是你的图标,你需要考虑目标设备的屏幕密度。密度指每英寸的像素数。不要认为小屏幕是低密度,大屏幕是高密度——你可以看到任何大小和密度的组合。对于高密度屏,你很可能会选择 72 × 72 像素的图标。中等密度图标的尺寸通常为 48 × 48 像素。而对于超高密度,则是 96 × 96 像素。对于低密度屏幕,使图标看起来大小合适意味着使图标具有更少的像素,通常为 36 × 36。Android 在低密度的情况下会帮助你,因为它会自动将你的 HDPI 图标缩小一半,所以你不需要自己提供一个低密度的图标。一般来说,你会发现最容易担心的是图像的密度,比如图标。定义布局时,您会担心屏幕尺寸。

将用户引导回 Play 商店

Android 有一个 URI 计划来帮助在谷歌 Play 商店寻找应用: market:// 。谷歌 Play 商店以前叫做安卓市场。]例如,如果您想引导您的用户到 Play Store 找到所需的组件,或者追加销售一个可以解锁您应用中的功能的附加应用,您可以执行如下所示的操作,其中 MY_PACKAGE_NAME 将被替换为您的真实软件包名称:

Intent intent = new Intent(Intent.ACTION_VIEW,
          Uri.parse("market://search?q=pname:MY_PACKAGE_NAME"));
startActivity(intent);

这将在设备上启动 Play Store 应用,并将用户带到该包名称。然后,用户可以选择下载或购买该应用。请注意,这种方案在普通的 web 浏览器中不起作用。除了使用包名( pname )进行搜索,还可以使用 market://search 按开发者名称进行搜索?q=pub:"Fname Lname" 或在谷歌 Play 商店使用 market://search?q= < querystring > 。

Android 授权服务

不幸的是,Android 应用的构建方式使它们成为盗版的目标。制作 Android 应用的副本,然后分发给其他设备是可能的。那么如何保证没有购买你的应用的用户不能运行它呢?Android 团队已经创建了一个叫做许可证验证库(LVL)的东西来满足这个需求。它是这样工作的。

如果你的应用是通过谷歌 Play 商店下载的,那么设备上一定有谷歌 Play 商店应用的副本。此外,谷歌 Play 商店应用具有更高的权限,能够从设备中读取值,如用户的谷歌帐户名称、IMSI 和其他信息。谷歌 Play 商店应用将响应来自应用的许可证验证请求。您从自己的应用调用 LVL,LVL 与谷歌 Play 商店应用通信,谷歌 Play 商店应用与谷歌服务器通信,您的应用得到一个回答,表明该设备上的用户是否有权使用您的应用。这意味着应用必须是通过谷歌 Play 商店购买的;否则谷歌服务器不会知道。如果网络不可用,您可以控制一些设置来决定如何操作。实现 LVL 的过程完整描述可以在developer . Android . com/Google/play/licensing/index . html找到。

不过,需要注意的一点是,LVL 机制容易受到黑客攻击。如果有人可以访问您的应用的。apk 文件,他们可以反汇编应用,然后打补丁,如果他们知道在哪里寻找 LVL 调用的返回值。如果您在从 LVL 获得响应后使用明显的 switch 语句模式,根据返回代码分支到适当的逻辑,黑客可以简单地强制成功返回代码值,他们就拥有了您的应用。出于这个原因,Android 团队强烈建议您对应用进行模糊处理,以隐藏应用中检查 LVL 返回代码的部分。可以想象,这变得相当复杂。

使用 ProGuard 进行优化,打击盗版

Google 以 ProGuard 特性的形式为混淆提供了一些支持。ProGuard 不是谷歌的产品,但已经集成到 ADT 和 Android Studio 中,所以很容易使用。ProGuard 不仅仅是为打击盗版提供混淆;它也使你的应用更小更快。它通过剥离调试信息、删除永远不会运行的代码,以及将(类、方法等的)名称改为无意义的字符串来完成所有这些工作。永远不会运行的代码示例包括永远不会被调用的库类和方法,以及依赖于您设置为 false (用于生产)的常量的日志记录。它还可以识别优化,例如将值向左二进制移位一位,而不是乘以 2。通过去掉调试信息并更改名称,最终编译成。apk 文件不会透露变量名、类名、方法等等,所以要弄清楚代码做了什么,以及如何窃取它、修改它,并把它作为别的东西发布,变得极其困难。

当您创建您的应用时,它应该会自动获得一个 proguard-project.txt 文件。默认文件看起来类似于清单 30-1 。

清单 30-1 。样本 proguard-project.txt 文件

# To enable ProGuard in your project, edit project.properties
# to define the proguard.config property as described in that file.
#
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in ${sdk.dir}/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the ProGuard
# include property in project.properties.
#
# For more details, see
#   [`developer.android.com/guide/developing/tools/proguard.html`](http://developer.android.com/guide/developing/tools/proguard.html)

# Add any project specific keep options here:

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
#   public *;
#}

您还需要将应用的 project.properties 文件中的 proguard.config 属性取消注释到 proguard-project.txt 文件的位置。该行如下所示:

proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt

如您所见,Android SDK 的 tools/proguard 目录下的一个文件提供了一组常用的 ProGuard 配置。然后,您可以在 proguard-project.txt 文件中增加 ProGuard 配置,作为您的应用项目的一部分。请注意,所提供的配置实际上并不支持优化,因为这些需要更多的测试来确保您的应用仍然能够正常工作。如果你想尝试优化,将 project.properties 文件中的引用改为$ { SDK . dir }/tools/proguard/proguard-Android-optimize . txt。

如前所述,ProGuard 通过剥离内容来完成工作。有时它去掉了太多,这就是为什么你会看到在 proguard-android.txt 文件中指定的 -keep 选项。当你制作一个的时候。apk 文件,你需要测试一下,确保 ProGuard 没有拿出太多。如果您发现由于缺少类或方法而导致的错误,您可以编辑 proguard-project.txt 文件,为您缺少的项包含另一个 -keep 选项。重建你的。apk 文件,并再次测试。我们建议使用 Eclipse 中 Android Tools 菜单选项下的 Export Signed Application Package 选项,因为它会在构建时为您调用 ProGuard。apk 文件。下一节将介绍导出。

如果您使用 Ant 进行构建,也可以配置 Ant 使用 ProGuard 进行模糊处理。

当 ProGuard 完成它的工作时,你会得到一个名为 mapping.txt 的文件以及你的。apk 文件。请保留这个文件,因为您将需要它来消除应用中的堆栈跟踪。如果你用 Eclipse 来导出你的。apk 文件,您将看到在您的 Eclipse 项目中创建了一个新的 proguard 目录。 mapping.txt 文件会在那里。要使用的命令是 retrace ,它位于 tools/proguard/bin 下的 Android SDK 目录中。要追溯的参数包括 mapping.txt 文件和 stacktrace 文件,但是要注意,您需要指定每个文件的完整路径名。此外,您应该跟踪应用的哪个版本与哪个 mapping.txt 文件相匹配。

关于测试应用的另一个注意事项。Android KitKat 引入了一个名为 Android RunTime (ART) 的实验性运行时引擎,在 Lollipop 中它成为了唯一的运行时引擎。你应该用这两者来测试你的应用,尤其是当你使用 ProGuard 并进行优化的时候。

准备你的。用于上传的 apk 文件

让您的测试应用准备好上传——也就是说,创建。 apk 文件 上传—您需要创建一个您的应用的签名导出。这可以通过多种方式实现,但最简单的方式是使用内置的 IDE 特性。对于 Eclipse,您可以右键单击项目名称并选择 Android ToolsimageExport Signed Application Package....对于 Android Studio,您可以选择项目名称并选择构建菜单image生成签名的 APK……按照对话框选择合适的签名证书密钥并创建您的生产 APK。

上传您的申请

上传很容易,但需要一些准备。在你开始上传之前,你需要准备好一些东西,并做出一些决定。本节将介绍这些准备工作和决策。然后,当你得到你需要的一切,到开发者控制台,选择+添加新的应用。您将被提示提供大量关于您的应用的信息,Play Store 将对您的应用和信息进行一些处理,然后您的应用将准备好发布到 Play Store。

上一节介绍了如何准备您的应用。用于上传的 apk 文件。让你的应用对购物者有吸引力需要你做一些市场营销。你需要好的描述它是什么和做什么,你需要好的图片,这样购物者就能明白他们可能会下载什么。

谷歌 Play 商店明白您可以在不同的国家销售您的应用。因此,只需一个应用,您就可以为不同国家提供本地化的文本和图形。

图形

您将被要求上传应用的截图。捕捉应用屏幕截图最简单的方法是使用 DDMS。启动 Eclipse,在模拟器或真实设备上启动您的应用,然后将 Eclipse 透视图切换到 DDMS 和设备视图。在设备视图中,选择运行应用的设备,然后单击屏幕捕获按钮(它看起来像右上角的一个小相机)或从视图菜单中选择它。如果保存时可以选择,请选择 24 位颜色。Android Device Monitor 与 DDMS 非常相似,是一个独立的工具(称为 Monitor ),位于 SDK tools 目录下,或者位于 Android Studio 的 tools 菜单中。

谷歌 Play 商店会把你的截图转换成压缩的 JPEG 从 24 位开始比从 8 位颜色开始会产生更好的结果。选择能让你的应用脱颖而出,同时也能展示重要功能的截图。你必须提供至少两个截图,你最多可以提供八个。请注意,您可以为其他语言的应用上传截图。如果您的应用已经针对另一个国家/地区和/或语言进行了本地化,您会希望屏幕截图与之相对应。

接下来是一个高分辨率的应用图标。这可能与你的应用图标设计完全相同,但谷歌 Play 商店想要一个 512 × 512 像素的图标图像。这是必需的。

特征图形是必需的,并且是 1024 × 500 像素的大尺寸。这个图形用在谷歌 Play 商店的特色部分,所以你想让这个看起来真的很好。

你也可以提供一个宣传图片,但是它的尺寸要比截图小。虽然这个图形是可选的,但是包含它是一个好主意。你永远不知道图形什么时候会显示出来;如果没有,你不知道在它的位置上会显示什么,如果有的话。在谷歌 Play 商店,宣传画会出现在你的申请详情页面的顶部。

当你读到这里的时候,你可能还有其他的图片可以上传。例如,谷歌现在接受在电视上观看的应用的电视横幅图形。

与你的申请相关的最后一点图片是一个可选的视频,你可以把它放在 YouTube 上,并从你的谷歌 Play 商店页面链接到它。

列表详细信息

谷歌 Play 商店要求向购物者显示有关您的应用的文本信息,包括标题、简短描述(以前称为促销文本)和完整描述。

有一个只有 80 个字符的简短描述字段,并且是强制性的。当你的应用出现在谷歌 Play 商店列表的顶部时,显示的是推广图片和简短描述。

完整描述也是强制性的,最多允许 4000 个字符。如果您为用户编写了单独的 EULA,请在完整的描述文本中提供一个链接,以便购物者在下载您的应用之前查看。考虑到购物者可能会使用搜索来定位应用,所以一定要在文本中输入适当的词语,以最大限度地提高与应用功能相关的搜索命中率。值得在文本中添加一个简短的注释,说明如果用户遇到问题,就给你发电子邮件。如果没有这个简单的提示,人们更有可能留下负面评论,而与受影响的用户进行电子邮件交流相比,负面评论确实会限制您排查和解决问题的能力。

前面描述的用户注释机制的一个缺点是,它不能向用户区分应用的具体版本。如果收到了对版本 1 的负面评论,而您发布了版本 2,并且修复了所有问题,那么来自版本 1 的评论仍然存在,购物者可能没有意识到这些评论不适用于新版本。当发布应用的新版本时,应用评级(星级数)也不会重置。部分由于这个原因,谷歌开始提供一个最近的变化文本字段,你可以描述这个版本中的新内容。在这里,您可以指出某个问题已经解决,或者告诉我们有什么新特性。Play Store 还提供了仅查看最新版本的评论/评论的能力,但默认情况下,会显示所有版本的评论和评论。

在为应用编写文本时,您的职责之一是公开所需的权限。这些权限与应用中的 AndroidManifest.xml 文件的标记中设置的权限相同。当用户将你的应用下载到他们的设备上时,Android 将检查 AndroidManifest.xml 文件,并在完成安装之前询问用户关于所有使用许可的要求。所以你最好提前透露一下。否则,用户会对你的应用要求一些他们不准备授予的权限感到惊讶,更不用说退款了,这也会影响你的开发者综合得分。与权限类似,如果你的应用需要某种类型的屏幕、摄像头或其他设备功能,这应该在你的应用的文本描述中披露。作为最佳实践,您不仅应该公开您的应用需要哪些权限和功能,还应该公开您的应用将如何使用它们。你要提前回答用户的问题:为什么这个 app 需要 X

上传申请时,您需要选择申请类型和类别。由于这些值会随着时间的推移而变化,我们不会在这里列出它们,但是很容易转到 Add new application 屏幕来查看它们是什么。

发布选项

您必须选择两个内容分级。这个想法是让消费者了解某个应用对特定年龄组的适用性。第一个(较老的)内容分级的范围包括高、中、低成熟度,以及每个人。选择正确的级别取决于应用中的内容以及内容的数量。谷歌有关于位置感知和张贴或发布位置的规则。最好在这里自己看一下规则:support . Google . com/Google play/Android-developer/answer/188189。第二个内容评级是在您完成调查问卷后得出的。实际上,根据您对调查问卷的回答,您将获得多个国家的内容评级。问卷调查消除了内容评级的一些主观性。

接下来,您设置应用的价格。默认情况下,价格是免费的,如果您想对您的应用收费,您必须事先在 Google Checkout 中设置一个商业帐户。为一个应用设定合适的价格是一件棘手的事情,除非你有一些复杂的市场研究能力,即便如此,这仍然是一件棘手的事情。价格定得太高可能会让人们失去兴趣,如果人们觉得这个价格不值得,你就要承担退款的风险。价格定得太低也会让人们失去兴趣,因为他们可能会认为这是一个廉价的应用。

上传应用之前要做的最后一个决定是选择应用可见的位置和运营商。选择“全部”,您的应用将随处可用。但是,您可能希望按地理位置或运营商限制分发。根据您的应用中的功能,您可能需要根据位置进行限制,以符合美国出口法。如果您的应用与某些运营商的设备或政策存在兼容性问题,您可以选择按运营商限制您的应用。要查看运营商,请单击国家旁边的显示选项链接,将显示该国家可用的运营商,允许您选择您想要的运营商。选择全部也意味着谷歌添加的任何新位置或运营商将自动看到你的应用,无需你的干预。

除了国家和运营商选择,谷歌 Play 商店还允许您将应用限制在某些设备上。默认情况下,设备列表是根据您的清单文件进行筛选的,在清单文件中,您指定了应用需要的功能等。上传屏幕的这一部分允许您进一步限制其他设备。如果某个特定设备存在已知问题,导致您的应用无法在该设备上运行(尽管它应该运行),那么您可能只希望这样做。

Android 还提供为同一个应用上传多个 apk 的选项。它可以让你在谷歌 Play 商店上有一个入口,但对手机和平板电脑有不同的版本。参见Android-developers . blogspot . com/2011/07/multiple-apk-support-in-Android-market . htmldeveloper . Android . com/Google/play/publishing/multiple-apks . html

联系信息

即使您的开发人员档案包含您的联系信息,您也可以在上传每个应用时设置不同的信息。Play Store 要求提供网站、电子邮件地址和电话号码作为与该应用相关的联系信息。你必须提供其中至少一个,这样买家才能获得支持,但你不需要提供所有三个。在这里,而不是使用你的个人电子邮件地址是个好主意,因为你可能不会真的想要给出你的个人电话号码。当你通过销售你的应用赚了数百万美元时,你会希望让别人接收和处理来自用户的电子邮件。通过预先设置应用支持类型的电子邮件地址,您可以轻松地将支持电子邮件与个人电子邮件分开。当然,如果您需要/想要的话,您可以随时更改这些值。

同意

做出所有这些决定后,你必须证明你的应用遵守 Android 的内容指南(基本上没有令人讨厌的东西),并再次证明该软件可以从美国出口。美国出口法律适用,因为 Google 的服务器位于美国境内,即使您不在美国,即使您和您的客户都不在美国。请记住,您可以随时选择通过其他渠道分发您的应用。当你所有的信息和图片都上传后,点击保存按钮。这将为您的应用做好一切准备。

然后,您可以通过单击“发布”按钮发布您的应用。谷歌 Play 商店将对您的应用执行一些检查——例如,检查您的应用证书的到期日期。如果一切顺利,您的应用将很快可供下载。恭喜你!

谷歌 Play 商店的用户体验

Play Store 应用已经在设备上提供了一段时间,并且可以通过互联网获得。开发者对谷歌 Play 商店的工作方式没有任何控制权,除了为他们的应用在 Play Store 中的列表提供良好的文本和图形。因此,用户体验很大程度上取决于谷歌。在设备上,用户可以通过关键字进行搜索;看看下载量排名靠前的应用(免费和付费都有),特色应用,或者新应用;或者按类别浏览。一旦他们找到他们想要的应用,他们只需选择它,就会弹出一个项目详细信息屏幕,允许他们安装或购买它。购买会将用户带到谷歌收银台进行交易的金融部分。下载后,新应用会与所有其他应用一起显示。

从谷歌 Play 商店( 、https://play.google.com)的互联网网站来看,用户界面看起来差不多,尽管比大多数设备屏幕大得多。一个不同之处是,基于网络的谷歌 Play 商店希望用户登录他们的谷歌账户来使用 Play Store。这使得谷歌可以将你在谷歌 Play 商店的网络体验与你的实际设备连接起来。这意味着两件事:当使用网站时,谷歌 Play 商店知道你的设备上已经安装了哪些应用;当你在谷歌 Play 商店网站上购物时,下载可以发送到你的设备上,而不是你正在浏览的任何一台电脑上。

谷歌 Play 商店可以选择在我的应用中查看下载的应用。此区域包含所有已安装的应用和您已购买的任何应用,即使您已移除它们(可能您移除它们只是为了给其他应用腾出空间)。这意味着你可以从手机上删除一个付费应用,然后重新安装,而不必重新购买。当然,如果你选择退款,该应用将不会显示在我的应用。

“我的应用”中的应用列表与您在所有设备上使用的 Google 帐户相关联。这意味着你可以切换到一个新的物理设备,仍然可以访问你已经付费的所有应用。但是要小心。由于您可能在 Google 上有多个身份,因此您必须使用与之前完全相同的身份才能在新设备上获得您的应用。在“我的应用”中查看应用时,任何有可用升级的应用都会指出这一点,并允许您获得升级。

用户可用的谷歌 Play 商店过滤器应用。它以多种方式做到这一点。一些国家的用户只能看到免费的应用,因为谷歌在该国涉及商业法律。谷歌正在努力克服商业障碍,这样所有的付费应用将随处可见。在那之前,一些国家的用户将无法使用付费应用。使用旧版本 Android 设备的用户将无法看到需要新版本 Android SDK 的应用。设备配置与应用需求不兼容的用户(通过 AndroidManifest.xml 文件中的 < uses-feature > 标签表示)将无法看到这些应用。例如,在谷歌 Play 商店,用户在小屏幕设备上无法看到不支持小屏幕的应用。这种过滤主要是为了防止用户下载不适合他们设备的应用。

如果您在谷歌 Play 商店从其他国家购买应用,您的交易可能需要进行货币兑换,这也可能会产生额外的费用,除非卖家指定了以您的当地货币计价。你真的是在使用卖家所在国家的谷歌收银台购物。谷歌 Play 商店将显示一个大概的金额,但实际费用可能会有所不同,这取决于交易的时间和支付处理器。买家可能会注意到他们的账户中有一笔小额交易(例如 1 美元)。谷歌这样做是为了确保提供的支付信息是正确的,这笔未决费用实际上不会通过。

有几个网站可以反映谷歌 Play 商店的应用列表。购物者可以在没有设备的情况下,通过互联网搜索、浏览类别和了解谷歌 Play 商店的应用。这绕过了谷歌 Play 商店根据你的设备配置和位置所做的过滤。但是,这不会将应用下载到您的设备上。这些镜像站点的例子有 、www.androlib.com、www.appszoom.com

超越谷歌 Play 商店

谷歌的 Play Store 并不是镇上唯一的游戏。你根本不会被迫使用谷歌 Play 商店。你应该考虑利用其他分销渠道,不仅要让更多国家的更多人可以使用你的应用,还要利用其他支付处理器和赚钱的机会。

有一些完全独立于谷歌 Play 商店的安卓应用商店,其中最大的可能是亚马逊。安卓应用商店的其他例子还有 http://mall.soc.io/appsslideme.orgwww.getjar.comf-droid.org/。在这些网站上,你可以通过设备或网络浏览器搜索、浏览、查找应用,还可以下载应用。这些网站不必遵守谷歌的规则,包括付费应用的交易费用和支付方式。PayPal 和其他支付处理器可以用来在这些独立的网站上购买应用。这些网站也不一定受位置或设备配置的限制。其中一些提供可以安装的 Android 客户端,或者在某些情况下可以预装在设备上。用户只需在他们的设备上启动浏览器,通过网站找到他们想要下载的应用;当文件保存到设备上时,Android 知道如何处理它。也就是说一个下载下来的。apk 文件被视为 Android 应用。如果您在浏览器的下载历史中点击它(不要与我的应用混淆,前面已经介绍过),系统会提示您是否要安装它。这种自由意味着你可以设置你自己的下载 Android 应用的方法,甚至可以从你自己的网站和你自己的支付方式下载。你仍然必须处理收集任何必要的销售税,并将其汇给有关当局。

虽然不受谷歌规则的限制,但这些应用分发的替代方法可能无法提供与谷歌 Play 商店相同的买家保护。通过替代市场购买在购买者的设备上不工作的应用是可能的。买家在替代市场上可能面临更大的恶意软件风险。购买者还可能负责创建备份,以防他们从他们的设备中丢失应用,或者如果他们切换到新设备,则负责转移应用。

这些其他市场让你可以从每个应用的销售中赚钱。你也有能力在这些其他市场实施替代支付机制,或实施广告,并以这种方式赚钱。

请记住,谷歌并不限制开发者在通过谷歌 Play 商店销售应用的同时,在多个市场销售他们的应用。所以考虑你所有的选择,充分利用你的努力。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

您现在已经准备好使用您的 Android 应用与世界交流了!以下是我们在本章中涉及的主题的概要:

  • 如何成为一名谷歌 Play 商店出版商(也就是开发者),这样你就可以在谷歌 Play 商店出版了。
  • Google Play 开发者分发协议中规定的规则。
  • 如果你通过谷歌 Play 商店销售,把你的收入分成给谷歌。我们还讨论了谷歌如何不希望看到来自 Play Store 内部的竞争。
  • 你有责任为你的应用的收入纳税。
  • 谷歌 Play 商店退款政策,包括公布的和真实的。
  • 用户如何在未来的任何时候获得你的应用的拷贝,只要他们曾经支付过费用。
  • Android 品牌规则。请确保您没有侵犯任何与 Android、图像或字体相关的版权。
  • 开发人员控制台及其功能。开发人员控制台从用户那里收集用户反馈和错误报告。
  • 为生产准备您的应用,包括测试、LVL 和 ProGuard 以打击盗版,以及使用资源变化和 AndroidManifest.xml 中的标签来过滤您的应用将可用于哪些设备。
  • 关于按语言和/或文化本地化应用的建议。
  • 设备和互联网/Web 上的谷歌 Play 商店用户界面。
  • 事实上,谷歌 Play 商店不是唯一的游戏,你可以在互联网上的其他地方出售你的应用,所有这一切都在同一时间。
posted @ 2024-08-13 14:02  绝不原创的飞龙  阅读(57)  评论(0)    收藏  举报