安卓-Kotlin-高级教程-全-
安卓 Kotlin 高级教程(全)
一、系统
Android 操作系统诞生于 2003 年的 Android Inc .公司,后来于 2005 年被谷歌公司收购。第一款运行安卓系统的设备于 2008 年上市。自那以来,它已经进行了多次更新,2018 年初的最新版本号为 8.1。
自从第一次构建以来,Android 操作系统的市场份额一直在不断增加,据说到 2018 年,它的市场份额超过了 80%。尽管这些数字因你使用的来源不同而不同,但 Android 操作系统的成功是不可否认的。这一胜利部分源于谷歌公司是全球智能手机市场上的一个聪明的参与者,但也源于 Android 操作系统经过精心定制,以满足智能手机和其他手持或类似手持设备的需求。
大多数以前或仍然在 PC 环境中工作的计算机开发人员会在完全忽视手持设备开发的情况下做不好工作,本书的目标是帮助开发人员理解 Android OS 并掌握其程序的开发。这本书还专注于使用 Kotlin 作为一种语言来实现开发需求,但首先我们将看看 Android 操作系统和辅助开发相关系统,让您了解 Android 的内部功能。
安卓操作系统
Android 基于一个特别定制的 Linux 内核。这个内核提供了处理硬件、程序执行环境和低级通信通道所需的所有低级驱动程序。
在内核之上,你会发现 Android 运行时 (ART)和几个用 c 编写的底层库。后者是应用相关库和内核之间的粘合剂。Android 运行时是 Android 程序运行的执行引擎。
作为开发人员,您几乎不需要了解这些底层库和 Android 运行时如何工作的细节,但是您将使用它们来完成基本的编程任务,例如处理音频子系统或数据库。
在底层库和 Android 运行时之上是应用框架,它定义了你为 Android 构建的任何应用的外部结构。它处理活动、GUI 部件、通知、资源等等。虽然理解底层库肯定有助于你编写好的程序,但是了解应用框架对于编写任何 Android 应用都是必不可少的。
最重要的是,你会发现你的用户为他们必须完成的任务而启动的应用。见图 1-1 。
图 1-1
安卓操作系统
作为开发人员,你将使用 Kotlin、Java 或 C++ 作为编程语言,或者它们的组合来创建 Android 应用。您将使用应用框架和库与 Android 操作系统和硬件对话。使用 C++ 作为较低层次的编程语言,解决目标体系结构的特殊性,导致合并了本地开发工具包 (NDK),这是 Android SDK 的可选部分。虽然出于特殊目的,可能有必要使用 NDK,但在大多数情况下,处理另一种语言及其带来的特殊挑战的额外努力并没有得到回报。因此,在本书中,我们将主要讨论 Kotlin,有时也会适当地讨论 Java。
开发系统
运行在手持设备上的操作系统是故事的一部分;作为开发者,你也需要一个创建 Android 应用的系统。后者发生在 PC 或笔记本电脑上,你使用的软件套件是 Android Studio 。
Android Studio 是您用于开发的 IDE,但是在您安装和操作它的同时,软件开发工具包(参见“SDK”)也会被安装,我们将在下面的部分中讨论这两者。我们还将讨论虚拟设备,它为在各种目标设备上测试你的应用提供了宝贵的帮助。
Android Studio
Android Studio IDE 是创建和运行 Android 应用的专用开发环境。图 1-2 显示了它的主窗口和仿真器视图。
Android Studio 提供以下功能:
图 1-2
Android Studio
-
管理 Kotlin、Java 和 C++ (NDK)的程序源
-
管理项目资源
-
在模拟器或连接的真实设备中测试运行应用的能力
-
更多测试工具
-
调试设备
-
性能和内存分析器
-
代码检查
-
用于构建本地或可发布应用的工具
studio 和在线资源中包含的帮助为掌握 Android Studio 提供了足够的信息。在本书中,我们将偶尔在专门的章节中讨论它。
虚拟设备
开发计算机软件总是包括创建一个能够处理所有可能的目标系统的程序的挑战。如今手持设备以如此多的不同形式出现,这一方面变得比以往任何时候都更加重要。你有尺寸在 3.9 英寸到 5.4 英寸及以上的智能手机设备,7 英寸到 14 英寸及以上的平板电脑,可穿戴设备,不同尺寸的电视等等,都运行 Android 操作系统。
当然,作为开发人员,您不可能购买覆盖所有可能尺寸的所有设备。这就是模拟器派上用场的地方。有了模拟器,你不必购买硬件,你仍然可以开发 Android 应用。
Android Studio 让您可以轻松使用仿真器开发和测试应用,使用软件开发套件中的工具,您甚至可以从 Android Studio 外部操作仿真器。
警告
你可以开发应用,而不需要拥有一个真正的设备。但是,不建议这样做。你应该至少有一部上一代的智能手机,如果你买得起的话,也许还有一部平板电脑。原因是与模拟器相比,操作真实设备的感觉不同。物理处理不是 100%相同,性能也不同。
要从 Android Studio 内部管理虚拟设备,请通过工具➤Android➤avd 管理器打开 Android 虚拟设备管理器。在这里,您可以调查、更改、创建、删除和启动虚拟设备。见图 1-3 。
图 1-3
AVD 经理
创建新的虚拟设备时,您将能够从电视、穿戴设备、电话或平板设备中进行选择;您可以选择要使用的 API 级别(并下载新的 API 级别);在设置中,您可以指定如下内容:
-
图形性能
-
相机模式(高级设置)
-
网络速度(高级设置)
-
引导选项(高级设置;设备首次启动后,快速启动可显著提高启动速度)
-
模拟 CPU 的数量(高级设置)
-
内存和存储设置(高级设置)
用于创建虚拟映像的虚拟设备基础映像和皮肤可以在以下位置找到:
SDK_INST/system-images
SDK_INST/skins
安装了应用和用户数据的实际虚拟设备位于以下位置:
∼/.android/avd
警告
虚拟设备不会模拟真实设备支持的所有硬件。即 2018 年第一季度,不支持以下内容:
-
API 等级 25 之前的 WiFi
-
蓝牙
-
国家足球联盟
-
SD 卡弹出和插入
-
连接到设备的耳机
-
通用串行总线
因此,如果您想使用模拟器,您必须在应用中采取预防措施,以避免这些问题的出现。
处理正在运行的虚拟设备也可以通过各种命令行工具来完成;更多信息见第十八章。
SDK
与 Android Studio 相反,软件开发工具包(SDK)是一个松散耦合的工具选择,这些工具要么是 Android 开发的基本工具,因此直接由 Android Studio 使用,要么至少对一些开发任务有帮助。它们都可以从 shell 中启动,有或没有自己的 GUI。
如果你在安装 Android Studio 的时候不知道 SDK 安装在哪里,你可以很容易地问 Android Studio:从菜单中选择文件➤项目结构➤ SDK 位置。
属于 SDK 一部分的命令行工具在第十八章中描述。
二、应用
一个 Android 应用由活动、服务、广播接收器和内容提供者等组件组成,如图 2-1 所示。活动用于与设备用户交互,服务用于在没有专用用户界面的情况下运行的程序部分,广播接收器监听来自其他应用和组件的标准化消息,内容供应器允许其他应用和组件访问由组件提供的一定数量和种类的数据。
图 2-1
Android 操作系统中的一个应用
组件由 Android 运行时启动,如果你喜欢,也可以由执行引擎启动,或者由它自己启动,或者代表其他创建启动触发器的组件启动。组件何时启动取决于它的类型和给它的元信息。在生命周期结束时,所有正在运行的组件都将从进程执行列表中删除,因为它们已经完成了工作,或者因为 Android OS 已经决定可以删除某个组件,因为不再需要该组件,或者因为设备资源短缺而必须删除该组件。
为了让你的应用或组件尽可能稳定地运行,并让你的用户对其可靠性有一个良好的感觉,深入了解 Android 组件的生命周期是有帮助的。在这一章中,我们将着眼于组件的系统特征及其生命周期。
简单的应用和 Android 组件易于构建;只需参考 Android 官方网站上的一个教程,或者网上其他地方成千上万个教程中的一个。不过,一个简单的应用不一定是专业级的稳定应用,因为就应用而言,Android 状态处理与桌面应用不同。这样做的原因是,你的 Android 设备可能会决定关闭你的应用以节省系统资源,特别是当你因为使用一个或多个其他应用一段时间而临时暂停有问题的应用时。
当然,Android 很可能永远不会杀死你目前正在使用的应用,但你必须采取预防措施。任何被 Android 终止的应用都可以在定义的数据和处理状态下重新启动,包括用户当前输入的大多数数据,并尽可能少地干扰用户当前的工作流程。
从文件的角度来看,Android 应用是一个带有后缀.apk
的单个 zip 存档文件。它包含您的完整应用,包括所有元信息,这是在 Android 设备上运行应用所必需的。里面最重要的控制工件是描述应用和应用组成的组件的文件AndroidManifest.xml
。
我们在这里不详细介绍这个归档文件结构,因为在大多数情况下,Android Studio 会为您正确地创建归档文件,所以您通常不需要了解它的内在功能。但是你可以很容易地看到里面。随便打开一个*.apk
文件;例如,以您已经使用 Android Studio 构建的示例应用为例,如下所示:
图 2-2
解压后的 APK 文件
AndroidStudioProject/[YOUR-APP]/release/app-release.apk
然后拉开拉链。APK 文件只是普通的压缩文件。您可能需要临时将后缀改为.zip
,这样您的解压缩程序就可以识别它。图 2-2 显示了一个解压缩的 APK 文件的例子。
这个.dex
文件包含了以 Dalvik 可执行文件格式编译的类,类似于 Java 中的 JAR 文件。
我们将很快讨论与应用相关的工件,但是首先我们将看看任务是什么的更概念性的想法。
任务
一个任务是一组相互交互的活动,最终用户将它们视为应用的元素。用户启动一个应用,看到主活动,在那里做一些工作,打开和关闭子活动,可能切换到另一个应用,返回,并最终关闭应用。
再深入一点,一个任务展示的主要结构是它的后台栈,或者简称为栈,在那里一个应用的活动堆积起来。这个堆栈中简单应用的标准行为是,当你启动一个应用时,第一个活动构建这个堆栈的根,从应用内部启动的下一个活动位于它的顶部,另一个子活动位于两者的顶部,以此类推。每当一个活动因为您向后导航而关闭时(这就是名称 back stack 的来源),该活动就会从堆栈中删除。当根活动被移除时,栈作为一个整体被关闭,你的应用被认为是关闭的。
在AndroidManifest.xml
文件的<application>
元素中,在线文本指南的“应用声明”部分有更详细的描述,我们可以看到几个改变任务堆栈标准行为的设置,我们将在第三章中看到更多。通过这种方式,定制的任务堆栈可以成为帮助您的最终用户理解和流畅使用您的应用的强大手段。请记住,对于开始使用你的应用的用户来说,复杂的堆栈行为可能很难理解,所以你的目标应该是在功能和易用性之间找到一个良好的平衡。
应用清单
你可以在任何 Android 应用中看到的一个重要的中央应用配置文件是文件AndroidManifest.xml
。它描述了应用并声明了应用的所有组件。这种清单文件的大纲可能如下所示:
<manifest xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:tools=
"http://schemas.android.com/tools"
package="de.pspaeth.tinqly">
...
<application
android:allowBackup="true"
android:icon="@mipmap/my_icon"x
android:label="@string/app_name"
android:roundIcon="@mipmap/my_round_icon"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity ... />
</application>
</manifest>
根条目<manifest>
最重要的属性叫做package
。它声明您的应用的 ID,如果您计划发布您的应用,这必须是它的全球唯一 ID。一个好主意是颠倒使用您的域(或您公司的域),然后使用唯一的应用标识符,如前面的代码所示。
表 2-1 描述了<manifest>
所有可能的属性。请注意,对于最简单的应用,您需要的只是一个package
属性和一个<application>
子元素。
表 2-1
清单主要属性
|名字
|
描述
|
| --- | --- |
| android: installLocation
| 定义安装位置。使用internalOnly
仅安装在内部存储器中,使用auto
让操作系统决定使用内部存储器(用户可以稍后在系统设置中切换),或者使用preferExternal
让操作系统决定使用外部存储器。默认为internalOnly
。请注意,为此目的使用外部存储有一些限制;参见<manifest>
的在线文档。对于具有大量空闲内部存储的现代设备,您应该永远不需要在这里指定preferExternal
。 |
| package
| 定义您的应用的全球唯一 ID,是一个类似于abc.def.ghi.[...]
的字符串,其中非点字符可能包含字母 A–Z 和 A–Z、数字 0–9 和下划线。不要在圆点后使用数字!这也是默认的进程名称和默认的任务关联性;请参阅在线文本指南,了解它们的含义。请注意,应用发布后,您将无法在谷歌 Play 商店中更改此包的名称。没有违约;您必须设置该属性。 |
| android: sharedUserId
| 定义分配给应用的 Android 操作系统用户 id 的名称。在 Android 8.0 或 API level 26 之前,你可以为不同的应用分配相同的用户 id,让它们自由交换数据。这些应用必须使用相同的证书进行签名。然而,你通常不需要设置这个属性,但是如果你设置了它,确保你知道你在做什么。 |
| android: sharedUserLabel
| 如果您还设置了sharedUserId
,您可以在这里为共享用户 ID 设置一个用户可读的标签。该值必须是对字符串资源的引用(例如,@string/myUserLabel
)。 |
| android: targetSandboxVersion
| 用作安全级别,为 1 或 2。从 Android 8.0 或 API level 26 开始,你必须将其设置为 2。对于 2,用户 ID 不再能在不同的应用之间共享,并且usesClearTextTraffic
(参见在线文本伴侣)的默认值被设置为 false。 |
| android: versionCode
| 定义应用的内部版本号。这不向用户显示,仅用于比较版本。此处使用整数。这默认为undefined
。 |
| android: versionName
| 定义用户可见的版本字符串。这要么是字符串本身,要么是指向字符串资源的指针("@string/..."
)。这除了通知用户之外,没有其他用途。 |
在线文本指南的“清单顶级条目”一节中列出了所有可能作为<manifest>
元素的子元素的元素。最重要的一个是<application>
,它描述了应用,在在线文本指南的“应用声明”一节中有详细介绍。
三、活动
活动代表应用的用户界面入口点。任何需要以直接方式与用户进行功能性交互的应用,通过让用户输入东西或以图形方式告诉用户应用的功能状态,都会向系统暴露至少一个活动。我之所以说是功能性的,是因为通过祝酒词或状态栏的通知也可以告诉用户事件的发生,而这并不需要活动。
应用可以有零个、一个或多个活动,它们以两种方式开始:
-
在
AndroidManifest.xml
中声明的主活动通过启动应用开始。这有点类似于传统应用的main()
函数。 -
所有活动都可以被配置为由一个显式或隐式的意图启动,就像在
AndroidManifest.xml
中配置的那样。Intents 既是一个类的对象,也是 Android 中的一个新概念。有了明确的意图,通过触发意图,组件指定它需要由专用应用的专用组件来完成某些事情。对于隐式意图,组件只是告诉需要做什么,而没有指定应该由哪个组件来做。Android 操作系统或用户决定哪个应用或组件能够满足这样的隐式请求。
从用户的角度来看,活动表现为可以从应用启动器内部启动的东西,无论是标准启动器还是专门的第三方启动器应用。一旦它们开始运行,它们就会出现在任务堆栈中,当用户使用后退按钮时就会看到它们。
申报活动
要声明一个活动,您可以在AndroidManifest.xml
中编写以下内容,例如:
<?xml version="1.0" encoding="utf-8"?>
<manifest ...
package="com.example.myapp">
<application ... >
<activity android:name=".ExampleActivity" />
...
</application ... >
...
</manifest >
如这个特殊的例子所示,您可以用一个点作为名称的开头,这将导致应用包名称的前置。在这种情况下,活动的全称是com.example.myapp.ExampleActivity
。也可以写全名,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest ... package="com.example.myapp" ...>
<application ... >
<activity android:name=
"com.example.myapp.ExampleActivity" />
...
</application ... >
...
</manifest>
您可以添加到<activity>
元素的所有属性都列在在线文本指南的“活动相关清单条目”一节中。
以下元素可以是activity
元素中的子元素:
-
<意图过滤>
这是一个意图过滤器。有关详细信息,请参阅“与活动相关的清单条目”中的在线文本指南。您可以指定零个、一个或多个意图过滤器。
-
<布局>
从 Android 7.0 开始,您可以在多窗口模式下指定布局属性,如下所示,当然您可以使用自己的数字:
<layout android:defaultHeight="500dp"
android:defaultWidth="600dp"
android:gravity="top|end"
android:minHeight="450dp"
android:minWidth="300dp" />
属性defaultWidth
和defaultHeight
指定默认尺寸,属性gravity
指定活动在自由形式模式下的初始位置,属性minHeight
和maxHeight
表示最小尺寸。
-
<元数据>
这是一个任意的名称值对,形式为
<meta-data android:name="..." android:resource="..." android:value="..." />
。你可以有几个这样的元素,它们被放入一个叫做PackageItemInfo.metaData
的android.os.Bundle
元素中。
警告
编写一个没有任何活动的应用是可能的。该应用仍然可以作为内容供应器提供服务、广播接收器和数据内容。作为应用开发人员,你需要记住的一件事是,用户不一定理解这些没有用户界面的组件实际上是做什么的。在大多数情况下,建议提供一个简单的主活动来提供信息,这样可以改善用户体验。然而,在企业环境中,提供没有活动的应用是可以接受的。
开始活动
活动可以通过两种方式之一启动。首先,如果活动被标记为应用的可启动主活动,则可以从应用启动器启动该活动。要将一个活动声明为可启动的主活动,在AndroidManifest.xml
文件中应该编写以下内容:
<activity android:name=
"com.example.myapp.ExampleActivity">
<intent-filter>
<action android:name=
"android.intent.action.MAIN" />
<category android:name=
"android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
android.intent.action.MAIN
告诉 Android 它是主活动,将转到一个任务的底部,android.intent.category.LAUNCHER
指定它必须列在启动器内部。
第二,一个活动可以由来自同一个 app 或任何其他 app 的意向启动。为此,在清单中声明一个意图过滤器,如下所示:
<activity android:name=
"com.example.myapp.ExampleActivity">
<intent-filter>
<action android:name=
"com.example.myapp.ExampleActivity.START_ME" />
<category android:name=
"android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
处理这个意图过滤器并实际启动活动的相应代码现在如下所示:
val intent = Intent()
intent.action =
"com.example.myapp.ExampleActivity.START_ME"
startActivity(intent)
必须为其他应用的调用设置标志exported="false"
。过滤器中的类别规范android.intent.category.DEFAULT
负责即使在启动代码中没有设置类别也可以启动的活动。
在前面的例子中,我们使用了一个显式的意图来调用一个活动。我们精确地告诉 Android 要调用哪个活动,我们甚至期望只有一个活动,它通过意图过滤器以这种方式被处理。另一种类型的意图被称为隐式意图,它的作用是,与精确地调用一个活动相反,告诉系统我们实际上想做什么,而无需指定使用哪个应用或哪个组件。例如,这样的隐式调用看起来像这样:
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_TEXT, "Give me a Quote")
startActivity(intent)
这个代码片段调用一个活动,该活动能够处理Intent.ACTION_SEND
动作,接收 MIME 类型的文本text/plain
,并传递文本“给我一个报价”然后,Android 操作系统将向用户呈现一个活动列表,这些活动来自这个或其他能够接收这种意图的应用。
活动可以有与之相关的数据。只需使用 intent 类的一个重载的putExtra(...)
方法。
活动和任务
与任务堆栈相关的已启动活动的实际情况由这里列出的属性决定,如<activity>
元素的属性所示:
-
taskAffinity
-
launchMode
-
allowTaskReparenting
-
clearTaskOnLaunch
-
alwaysRetainTaskState
-
finishOnTaskLaunch
和意向调用标志,如下所示:
-
FLAG_ACTIVITY_NEW_TASK
-
FLAG_ACTIVITY_CLEAR_TOP
-
FLAG_ACTIVITY_SINGLE_TOP
您可以指定Intent.flags = Intent.<FLAG>
,其中<FLAG>
是列表中的一个。如果活动属性和调用者标志相矛盾,调用者标志获胜。
返回数据的活动
如果您使用以下命令开始一项活动:
startActivityForResult(intent:Intent, requestCode:Int)
这意味着您希望被调用的活动在返回的同时返回一些东西。您在调用的活动中使用的构造如下所示:
val intent = Intent()
intent.putExtra(...)
intent.putExtra(...)
setResult(Activity.RESULT_OK, intent)
finish()
在.putExtra(...)
方法调用中,您可以添加从活动中返回的任何数据。例如,您可以将这些行添加到onBackPressed()
事件处理程序方法中。
对于setResult()
的第一个参数,您可以使用以下任何一种:
-
Activity.RESULT_OK
如果你想告诉调用者被调用的活动成功完成了它的工作。 -
Activity.RESULT_CANCELED
如果你想告诉调用者被调用的活动没有成功完成它的工作。您仍然可以通过.putExtra(...)
输入额外的信息来指明哪里出错了。 -
Activity.RESULT_FIRST_USER + N
,N
为 0、1、2、...,用于要定义的任何自定义结果代码。N
实际上没有限制(最大值为 2311)。
注意,如果你有一个工具栏,你还需要处理回压事件。一种可能是添加如下的onCreate()
方法行:
setSupportActionBar(toolbar)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
// The navigation button from the toolbar does not
// do the same as the BACK button, more precisely
// it does not call the onBackPressed() method.
// We add a listener to do it ourselves
toolbar.setNavigationOnClickListener { onBackPressed() }
当被调用的意图以前面描述的方式返回时,调用组件需要被告知该事件。这是异步完成的,因为startActivityForResult()
方法会立即返回,而不会等待被调用的活动完成。然而,捕获该事件的方法是重写onActivityResult()
方法,如下所示:
override
fun onActivityResult(requestCode:Int, resultCode:Int,
data:Intent) {
// do something with 'requestCode' and 'resultCode'
// returned data is inside 'data'
}
requestCode
是你在startActivityForResult()
里面设置为requestCode
的东西,resultCode
是你在被调用活动的setResult()
里面作为第一个参数写的东西。
警告
在某些设备上,requestCode
的最高有效位设置为 1,无论之前设置了什么。为了安全起见,您可以在onActivityResult()
中使用 Kotlin 构造,如下所示:val requestCodeFixed = requestCode and 0xFFFF
意图过滤器
意图是告诉 Android 需要做一些事情的对象,如果我们不指定被调用的组件,而是让 Android 决定哪个应用和哪个组件可以响应请求,则意图可以是明确的或隐含的。如果有一些歧义,Android 无法决定调用哪个组件来表达隐含的意图,Android 会询问用户。
为了让隐含意图发挥作用,可能的意图接收者需要声明它能够接收哪些意图。例如,一个活动可能能够显示一个文本文件的内容,而一个呼叫者说“我需要一个可以显示文本文件的活动”可能正好连接到这个活动。现在,意向接收方声明其响应意向请求的能力的方式是在其应用的AndroidManifest.xml
文件中指定一个或多个意向过滤器*。这种声明的语法如下:
<intent-filter android:icon="drawable resource"
android:label="string resource"
android:priority="integer" >
...
</intent-filter>
这里,icon
指向图标的可绘制资源 ID,label
指向标签的字符串资源 ID。如果未指定,将使用父元素的图标或标签。priority
属性是一个介于-999 和 999 之间的数字,对于 intents 指定它处理这种 intent 请求的能力,对于 receiver 指定几个 receiver 的执行顺序。较高的优先级在较低的优先级之前。
警告
应该谨慎使用priority
属性。一个组件不可能知道来自其他应用的其他组件的优先级。因此,你在应用之间引入了某种依赖,这不是设计意图。
这个<intent-filter>
元素可以是下列元素的子元素:
-
<activity>
和<activity-alias>
-
<service>
-
<receiver>
因此,意图可以用来启动活动和服务,并发射广播消息。
元素必须包含子元素,如下所示:
-
<action>
(必须) -
<category>
(可选) -
<data>
(可选)
意图动作
过滤器的<action>
子过滤器(或者多个子过滤器,因为可以有多个子过滤器)指定要执行的动作。语法如下:
<action android:name="string" />
这将是表示诸如查看、选择、编辑、拨号等动作的东西。通用动作的完整列表是由类android.content.Intent
中名称类似ACTION_*
的常量指定的;您可以在联机文本指南的“意图构成部分”一节中找到一个列表。除了这些通用操作之外,您还可以定义自己的操作。
注意
使用任何标准操作并不一定意味着您的设备上有任何应用能够响应相应的意图。
意图类别
过滤器的<category>
子级指定了过滤器的类别。语法如下:
<category android:name="string" />
此属性可用于指定意图应解决的组件类型。您可以指定几个类别,但是该类别并不用于所有目的,您也可以省略它。只有当所有要求的类别都存在时,过滤器才会匹配意图。
当调用方使用一个意图时,您可以通过编写以下代码来添加类别,例如:
val intent:Intent = Intent(...)
intent.addCategory("android.intent.category.ALTERNATIVE")
标准类别对应于名称类似于android.content.Intent
类中的CATEGORY_*
的常量。您可以在联机文本指南的“意图构成部分”一节中找到它们。
警告
对于隐式意图,您必须使用过滤器内的DEFAULT
类别。这是因为方法startActivity()
和startActivityForResult()
默认使用这个类别。
意向数据
过滤器的<data>
子级是过滤器的数据类型规范。语法如下:
<data android:scheme="string"
android:host="string"
android:port="string"
android:path="string"
android:pathPattern="string"
android:pathPrefix="string"
android:mimeType="string" />
您可以指定以下任一项或两项:
-
仅由
mimeType
元素指定的数据类型,例如text/plain
或text/html
。所以,你可以这样写: -
由方案、主机、端口和一些路径规范指定的数据类型:
<scheme>://<host>:<port>[<path>|<pathPrefix>|<pathPattern>]
。这里的<path>
表示完整路径,<pathPrefix>
是路径的起点,<pathPattern>
类似于路径,但带有通配符:X*
是字符 X 的零次或多次出现,.*
是任何字符的零次或多次出现。由于转义规则的原因,写\*表示星号,写\\表示反斜杠。
<data android:mimeType="text/html" />
在调用方,您可以使用setType()
、setData()
和setDataAndType()
来设置任何数据类型组合。
警告
对于隐式意图过滤器,如果调用者在intent.data = <some URI>
中指定了 URI data
部分,那么在过滤器声明中仅指定方案/主机/端口/路径可能是不够的。在这些情况下,您还必须指定 MIME 类型,就像在mimeType="*/*"
中一样。否则,过滤器可能不匹配。这通常发生在内容提供者环境中,因为内容提供者的getType()
方法被指定的 URI 调用,结果被设置为意图的 MIME 类型。
意向额外数据
除了由<data>
子元素指定的数据之外,任何 intent 都可以添加额外的数据来发送数据。
虽然您可以使用各种putExtra(...)
方法中的一种来添加任何种类的额外数据,但是也有一些标准的额外数据字符串是由putExtra(String,Bundle)
发送的。您可以在联机文本指南的“意图构成部分”一节中找到这些关键字。
意图标志
您可以通过调用以下内容来设置特殊意图处理标志:
intent.flags = Intent.<FLAG1> or Intent.<FLAG2> or ...
这些标志中的大部分指定了 Android 操作系统如何处理意图。具体来说,FLAG_ACTIVITY_*
形式的标志是针对Context.startActivity(..)
调用的活动的,类似FLAG_RECEIVER_*
的标志是和Context.sendBroadCast(...)
一起使用的。在线文本指南的“意图构成部分”一节中的表格显示了详细信息。
系统意图过滤器
系统应用(即您购买智能手机时已经安装的应用)具有意图过滤器,您可以使用该过滤器从您的应用中调用它们。不幸的是,猜测如何从系统应用中调用活动并不容易,相关的文档也很难找到。一个解决办法是从他们的 APK 档案中提取这些信息。对于 API 级别 26,这已经为您完成了,其结果在“系统意图过滤器”一节中的在线文本指南中列出
举个例子,假设你想发送一封电子邮件。在网文伴侣里看系统意图表,可以找到很多PrebuiltGmail
的动作。我们用哪一个?首先,通用接口不应该有太多的输入参数。其次,我们还可以查看动作名称,以找到似乎合适的内容。一个有希望的候选者是SEND_TO
行动;显然,它所需要的只是一个mailto:
数据规范。碰巧的是,这是我们真正需要的行动。使用精心设计的mailto:...
URL 允许我们指定更多的收件人、抄送和密件抄送收件人、主题,甚至邮件正文。然而,你也可以只使用“mailto:
master@universe.com”并通过使用额外的字段来添加收件人、正文等等。因此,要发送电子邮件,同时可能让用户在设备上安装的几个电子邮件应用中进行选择,请编写以下内容:
val emailIntent:Intent = Intent(Intent.ACTION_SENDTO,
Uri.fromParts("mailto","abc@gmail.com", null))
emailIntent.putExtra(Intent.EXTRA_SUBJECT, "Subject")
emailIntent.putExtra(Intent.EXTRA_TEXT, "Body")
startActivity(Intent.createChooser(
emailIntent, "Send email..."))
// or startActivity(emailIntent) if you want to use
// the standard chooser (or none, if there is only
// one possible receiver).
警告
如何准确处理意图 URIs 和额外数据由接收应用决定。设计糟糕的电子邮件可能根本不允许您指定电子邮件标题数据。为了安全起见,您可能希望将所有标题数据都添加到mailto:
URI 和中作为额外数据。
活动生命周期
活动有一个生命周期,与传统的桌面应用相反,当 Android 操作系统决定终止活动时,它们会被有意地终止。所以,作为一名开发者,你需要采取特别的预防措施来保证应用的稳定性。更准确地说,活动发现自己处于以下状态之一:
-
关闭:活动不可见,不做任何处理。尽管如此,包含该活动的应用可能还活着,因为它有一些其他组件在运行。
-
已创建:要么该活动是主活动,由用户或其他组件启动,要么它是一个活动,不管它是否是主活动,由其他组件启动,从同一应用或其他应用内部启动(如果安全考虑允许的话)。此外,例如,当你翻转屏幕时,活动创建就会发生,应用需要用不同的屏幕特征来构建。在创建过程中,回调方法
onCreate()
被调用。您必须实现这个方法,因为那里需要构建 GUI。您还可以使用这个回调方法来启动或连接到服务,或者提供内容提供者数据。你可以使用这些 API 让准备播放音乐,操作相机,或者做这个应用为之而生的任何事情。这也是一个初始设置数据库或应用需要的其他数据存储的好地方。 -
已启动:一旦完成创建(以及在停止后重新启动的情况下),活动进入已启动状态。在这里,活动将对用户可见。在启动过程中,回调方法
onStart()
被调用。这是启动广播接收器、启动服务、重建内部状态和活动进入停止状态时退出的进程的好地方。 -
Resumed :在对用户可见之前不久,活动经历了恢复过程。在这个过程中,回调
onResume()
被调用。 -
运行:活动完全可见,用户可以与之交互。这种状态紧跟在恢复过程之后。
-
暂停:活动失去焦点,但至少部分可见。例如,当用户点击后退或最近按钮时,就会失去焦点。活动可能会继续向 UI 发送更新,或者继续发出声音,但是在大多数情况下,活动会进入停止状态。在暂停期间,
onPause()
回调被调用。暂停状态之后是停止状态或恢复状态。 -
停止:活动对用户不可见。它以后可能会被重新启动、销毁,并从活动进程列表中删除。在停止期间,
onStop()
回调被调用。停止之后,要么毁灭,要么开始。在这里你可以,例如,停止你在onStart()
中启动的服务。 -
销毁:活动被移除。回调
onDestroy()
被调用,您应该实现它并在那里做一切事情来释放资源和做其他清理动作。
表 3-1 列出了活动状态之间可能的转换,如图 3-1 所示。
图 3-1
活动状态转换
表 3-1
活动状态转换
|从
|
到
|
描述
|
实施
|
| --- | --- | --- | --- |
| 停工 | 创造 | 一个活动在第一次或销毁后被调用。 | onCreate()
:调用super.onCreate()
,准备 UI,启动服务。 |
| 创造 | 出发 | 活动在创建后开始。 | 您可以在这里启动仅在活动可见时才需要的服务。 |
| 出发 | 重新开始 | 恢复状态自动跟随开始状态。 | 使用onResume
。 |
| 重新开始 | 运转 | 运行状态自动跟随恢复状态。 | 包括 UI 活动在内的活动在这里运行。 |
| 运转 | 暂停 | 该活动失去焦点,因为用户点击了“后退”或“最近”按钮。 | 使用onPause
。 |
| 暂停 | 重新开始 | 该活动尚未停止,用户导航回该活动。 | 使用onResume()
。 |
| 暂停 | 停止 | 该活动对用户是不可见的,例如,因为另一个活动开始了。 | 您可以在这里停止仅在活动可见时才需要的服务。 |
| 停止 | 出发 | 停止的活动再次开始。 | 您可以在这里启动仅在活动可见时才需要的服务。 |
| 停止 | 破坏 | 停止的活动将被删除。 | onDestroy()
:释放所有资源,进行清理,停止onCreate
中启动的服务。 |
在活动中保留状态
我已经强调了你需要采取预防措施,以确保当你的应用被 Android 操作系统强行停止时,它能以良好的方式重新启动。在这里,我给你一些如何做到这一点的建议。
查看活动的生命周期,我们可以看到一个即将被 Android OS 终止的活动调用了方法onStop()
。但是还有两次试镜我们还没有谈到。它们的名字是onSaveInstanceState()
和onRestoreInstanceState()
,每当 Android 决定需要保存或恢复某项活动的数据时,就会调用它们。这与onStart()
和onStop()
不一样,因为有时没有必要保存应用的状态。例如,如果一个活动不会被销毁,而只是被挂起,那么无论如何状态都会被保持,并且onSaveInstanceState()
和onRestoreInstanceState()
不会被调用。
Android 在这里帮助了我们:onSaveInstanceState()
和onRestoreInstanceState()
的默认实现已经保存和恢复了有 id 的 UI 元素。所以,如果这就是你所需要的,你不需要做任何事情。当然,您的活动可能更复杂,可能包含其他需要保留的字段。在这种情况下,您可以覆盖onSaveInstanceState()
和onRestoreInstanceState()
。只要确保你调用了超类的方法;否则,您必须自己处理所有 UI 元素。
override
fun onSaveInstanceState(outState:Bundle?) {
super.onSaveInstanceState(outState)
// add your own data to the Bundle here...
// you can use one of the put* methods here
// or write your own Parcelable types
}
override
fun onRestoreInstanceState(savedInstanceState: Bundle?) {
super.onRestoreInstanceState(savedInstanceState)
// restore your own data from the Bundle here...
// use one of the get* methods here
}
注意,保存的状态也会被onCreate()
回调,所以您可以决定是使用onRestoreInstanceState()
还是onCreate()
方法来恢复状态。
在这种情况下,保存和恢复状态的标准机制可能不适合您的需要。例如,当你停止应用时,它不能保留数据。在这种情况下,不会调用onSaveInstanceState()
方法。如果你需要在这种情况下保存数据,你可以使用onDestroy()
将你的应用的数据保存在数据库中,并在onCreate()
回调时读取数据库。更多信息见第八章。*
四、服务
服务是在没有用户界面的情况下运行的组件,并且在概念上与长期运行的流程密切相关。它们独立于状态栏或祝酒词中的通知。服务可以由应用启动,也可以由应用绑定,或者两者都有。
服务有两种风格:前台服务和后台服务。虽然乍一看,谈论“前台”服务似乎是矛盾的,因为许多人倾向于说“服务在后台运行”,但前台服务确实存在。前台和后台服务之间的区别是至关重要的,因为它们的行为是不同的。
警告
不要将服务误解为运行任何需要在后台计算的东西的构造,换句话说,不要干扰 GUI 活动。如果你需要一个不干扰 GUI 的进程,但是在你的应用不活动的时候没有资格运行,也不能在你的应用之外使用,考虑使用线程。更多信息见第十章。
前台服务
不同 Android 版本的前台服务的内在功能有所不同。Android 8.0 (API level 26)之前的前台服务只是在状态栏中有一个条目的后台服务,对 Android 操作系统如何处理它们没有严格的影响,而在 Android 8.0 (API level 26)中,前台服务遵循一种特殊的符号,并得到 Android 操作系统的更多关注,使它们不太可能因资源短缺而被终止。以下是一些细节:
-
Android 8.0(API 26 级)之前的前台服务是在状态栏中只是呈现一个通知条目的服务。需要使用服务的客户端组件不知道启动的服务是否是前台服务;它只是通过
startService(intent)
启动服务。参见第十二章的。 -
从 Android 8.0 (API 等级 26)开始的前台服务在用户知道它们的情况下运行。他们必须通过状态栏中的通知来干预操作系统。客户端组件通过调用
startForeroundService(intent)
显式启动前台服务,服务本身必须在几秒钟内通过调用startForeground(notificationId, notification)
告诉 Android OS 它想要作为前台服务运行。
前台服务的一个显著的生命周期特征是它不太可能因为可用资源短缺而被 Android 杀死。然而,文件对此并不明确。有时你会读到“不会被杀死”,有时会读到“不太可能被杀死”此外,Android 处理这些事情的方式会随着新版本的 Android 而改变。一般来说,你应该保守,做最坏的打算。在这种情况下,阅读“不太可能被杀死”,并采取预防措施,如果服务在您的应用执行某些工作时停止运行。
后台服务
后台服务在后台运行;也就是说,它们不会在状态栏中显示条目。然而,他们被允许使用祝酒辞向用户发送短通知消息。与前台服务相比,后台服务更脆弱,因为 Android 希望它们与用户活动的联系更松散,因此当资源短缺时,更容易决定终止它们。
从 Android 8.0 (API level 26)开始,如果您以旧的方式实例化后台服务,会有一些限制,并且建议转向使用 JobScheduler 方法。如果以下情况不成立,运行在 Android 8.0 或更新版本上的应用将被视为在后台运行:
-
该应用有一个可见的活动,当前活动或暂停。
-
app 有一个前台服务,换句话说,一个服务在运行过程中调用了
startForegound()
。 -
另一个前台应用连接到它,要么通过使用它的服务之一,要么通过将其用作内容供应器。
一旦一个 Android 8.0 应用作为后台应用开始它的生命,或者被切换到后台应用,它在被认为空闲之前有几分钟的时间。一旦空闲,应用的后台服务就会停止。作为一个例外,一个后台应用将进入白名单,如果它处理用户可见的任务,就被允许执行后台服务。示例包括处理“Firebase Cloud Messaging”消息、接收广播(如 SMS 或 MMS 消息)、执行通知中的PendingIntent
(在原始应用的许可下由不同应用执行的意图)或启动VpnService
。
从 Android 8.0 开始,以前通过执行后台作业完成的大多数事情都被认为有资格由 JobScheduler API 来处理;更多信息见第八章。
声明服务
服务在应用的AndroidManifest.xml
文件中声明如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application ...>
<activity ...>
</activity>
<service
android:name=".MyService"
android:enabled="true"
android:exported="true">
</service>
</application>
</manifest>
可用标志见表 4-1 。
表 4-1
服务的清单标志
|名字
|
描述
|
| --- | --- |
| android:description
| 这是指向服务描述的资源 ID。你应该使用它,因为用户可以杀死服务,但如果你告诉他们你的服务是做什么的,这种情况就不太可能发生。 |
| android:directBootAware
| 这个可以是true
也可以是false
。默认是false
。如果true
,即使重启后设备尚未解锁,服务也可以运行。Android 7.0(API 24 级)引入了直接引导模式。请注意,直接引导感知服务必须将其数据存储在设备的受保护存储中。 |
| android:enabled
| 这个可以是true
也可以是false
。默认是true
。如果false
,服务被有效禁用。对于生产服务,通常不会将它设置为false
。 |
| android:exported
| 这个可以是true
也可以是false
。这指定了其他应用是否可以使用该服务。如果没有意图过滤器,默认为false
,否则为true
。意图过滤器的存在意味着外部使用,因此这种区别。 |
| android:icon
| 这是图标资源 ID。默认为应用的图标。 |
| android:isolatedProcess
| 这个可以是true
也可以是false
。默认是false
。如果true
,服务无法与系统通信,只能通过服务方法。使用这个标志实际上是一个好主意,但是在大多数情况下你的服务需要和系统对话,所以你不得不离开它false
,除非这个服务真的是自包含的。 |
| android:label
| 这是向用户显示的服务标签。默认为应用的标签。 |
| android:name
| 这是服务类的名称。如果您使用一个点作为第一个字符,它会自动加上在manifest
元素中指定的包名。 |
| android:permission
| 这是伴随此服务的权限名称。默认值是application
元素中的permission
属性。如果未指定并且默认值不适用,则服务将不受保护。 |
| android:service
| 这是服务进程的名称。如果指定,服务将在其自己的进程中运行。如果它以冒号(:
)开头,该过程将是应用的私有过程。如果它以小写字母开头,那么产生的进程将是一个全局进程。可能会有安全限制。 |
<service>
元素允许以下子元素:
-
<意图过滤>
这可以是零个、一个或多个意图过滤器。第三章对它们进行了描述。
-
<元数据>
这是一个任意的名称值对,形式为
<meta-data android:name="..." android:resource="..." android:value="..." />
。你可以有几个这样的元素,它们被放入一个叫做PackageItemInfo.metaData
的android.os.Bundle
元素中。
作为一名专业开发人员,理解什么是进程以及 Android 操作系统如何处理它是非常重要的;见过程控制清单中的android:service
标志。这可能很棘手,因为过程内部往往会随着新的 Android 版本而变化,如果你读博客,它们似乎每分钟都在变化。事实上,进程是一个计算单元,由 Android 操作系统启动以执行计算任务。此外,当 Android 决定耗尽系统资源时,它会停止运行。如果你决定停止使用某个特定的应用,并不意味着相应的进程会自动终止。每当您第一次启动一个应用,并且您没有明确地告诉该应用使用另一个应用的进程时,就会创建并启动一个新的进程,并且随着后续计算任务的存在,进程会被使用或新的进程会被启动,这取决于它们的设置以及彼此之间的关系。
除非您在清单文件中明确指定服务特征,否则由应用启动的服务将在应用的进程中运行。这意味着服务可能会随着应用而存在,也不可避免地会消亡。一个进程需要启动才能真正活起来,但是在 app 主进程中运行的时候,app 死了就死了。这意味着服务的资源需求关系到应用的资源需求。在以前资源稀缺的时候,这比现在更强大的设备更重要,但知道这一点还是有好处的。如果一项服务需要大量资源,而资源短缺,那么是否需要杀死整个应用或只是那个贪婪的服务来释放资源就有所不同了。
然而,如果您通过android:service
manifest 条目告诉服务使用它自己的进程,服务的生命周期可以由 Android 操作系统独立处理。你必须做出决定:要么让它使用自己的进程,并接受一个应用可能出现的进程激增,要么让它们在一个进程中运行,并更紧密地耦合生命周期。
让几个计算单元在一个进程中运行还有另一个后果:它们不会并发运行!这对于 GUI 活动和进程来说是至关重要的,因为我们知道 GUI 活动必须很快才能不妨碍用户交互,而且服务在概念上是绑定到长时间运行的计算的。摆脱这种困境的一种方法是使用异步任务或线程。第十章将会更多地讨论并发性。
如果服务需要寻址受设备保护的存储,就像清单中的android:directBootAware
标志触发的直接引导模式一样,它需要访问一个特殊的上下文。
val directBootContext:Context =
appContext.createDeviceProtectedStorageContext()
// For example open a file from there:
val inStream:FileInputStream =
directBootContext.openFileInput(filename)
通常情况下,您不应该使用此上下文,只有在特殊服务需要在引导过程后立即激活时才使用。
服务类别
服务必须扩展下列类或其子类之一:
android.app.Service
如前所述,它们必须在应用的AndroidManifest.xml
文件中声明。
来自android.app.Service
的接口方法在在线文本指南的“意图组成部分”一节中有所描述。
请注意,有两种方法可以停止通过startService()
或startForeroundService
显式启动的服务:服务通过调用stopSelf()
或stopSelfResult()
或从外部调用stopService()
来自行停止。
启动服务
服务可以从任何组件显式启动,这些组件是android.content.Context
的子类或者可以访问Context
。活动、其他服务、广播接收器和内容供应器都是如此。
要显式启动服务,您需要一个适当的意图。我们基本上有两种情况:首先,如果服务与服务的客户端(调用者)在同一个应用中,您可以为从 Android 8.0 (API 级别 26)开始定义的前台服务编写以下代码:
val intent = Intent(this, TheService::class.java)
startService(intent)
对于正常服务,或者
val intent = Intent(this, TheService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
所以,我们可以直接引用服务类。如果您是一名新的 Kotlin 开发人员,乍一看,TheService::class.java
符号可能看起来很奇怪;这就是 Kotlin 提供 Java 类作为参数的方式。(对于 Android 8.0 (API 级别 26)之前的版本,您可以正常启动它。)
注意
因为意图通过使用各种putExtra()
方法之一允许通用的额外属性,所以我们也可以将数据传递给服务。
第二种情况是,如果我们想要启动的服务是另一个应用的一部分,因此是一个外部服务。然后,您必须在服务声明中添加一个意图过滤器。这里有一个例子:
<service
android:name=".MyService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="<PCKG_NAME>.START_SERVICE" />
</intent-filter>
</service>
在这个例子中,<PCKG_NAME>
是 app 的包名,如果你愿意,你可以写一个不同的标识符来代替START_SERVICE
。现在,在服务客户端内部,您可以编写以下代码来启动和停止外部服务,其中在 intent 构造函数内部,您必须编写与服务的 intent filter 声明中相同的字符串:
val intent = Intent("<PCKG_NAME>.START_SERVICE")
intent.setPackage("<PCKG_NAME>")
startService(intent)
// ... do something ...
stopService(intent)
setPackage()
语句在这里很重要(当然您必须替换服务的包名);否则,将应用安全限制,并且您会收到一条错误消息。
绑定到服务
开始一项服务是故事的一部分。另一部分是在它们运行时使用它们。这就是服务的绑定的用途。
要创建可以绑定到同一个应用或从同一个应用绑定的服务,请编写如下代码:
/**
* Class used for binding locally, i.e. in the same App.
*/
class MyBinder(val servc:MyService) : Binder() {
fun getService():MyService {
return servc
}
}
class MyService : Service() {
// Binder given to clients
private val binder: IBinder = MyBinder(this)
// Random number generator
private val generator: Random = Random()
override
fun onBind(intent: Intent):IBinder {
return binder
}
/** method for clients */
fun getRandomNumber():Int {
return generator.nextInt(100)
}
}
要从同一应用内部绑定到此服务,请在使用客户端的服务内部编写以下代码:
val servcConn = object : ServiceConnection {
override
fun onServiceDisconnected(compName: ComponentName?) {
Log.e("LOG","onServiceDisconnected: " + compName)
}
override
fun onServiceConnected(compName: ComponentName?,
binder: IBinder?) {
Log.e("LOG","onServiceConnected: " + compName)
val servc = (binder as MyBinder).getService()
Log.i("LOG", "Next random number from service: " +
servc.getRandomNumber())
}
override
fun onBindingDied(compName:ComponentName) {
Log.e("LOG","onBindingDied: " + compName)
}
}
val intent = Intent(this, MyService::class.java)
val flags = BIND_AUTO_CREATE
bindService(intent, servcConn, flags)
在这里,object: ServiceConnection {...}
构造是 Kotlin 通过创建匿名内部类的对象来实现接口的方式,就像 Java 中的new ServiceConnection(){...}
。这个构造在 Kotlin 中被称为对象表达式。在这种情况下,意图构造函数中的this
指的是一个Context
对象。你可以像这样在活动中使用它。如果变量中有Context
,请在此处使用该变量的名称。
当然,除了日志记录,你应该做更有意义的事情。特别是在onSeviceConnected()
方法中,您可以将绑定器或服务保存在一个变量中以备将来使用。尽管如此,还是要确保对死亡的绑定或死亡的服务连接做出适当的反应。例如,您可以尝试再次绑定服务,告诉用户,或者两者都做。
前面的代码会在您绑定到服务时自动启动该服务,但它还不存在。这是通过以下陈述实现的:
val flags = BIND_AUTO_CREATE
[...]
如果因为确定服务正在运行而不需要,可以省略。然而,在大多数情况下,最好包含该标志。以下是可用于设置绑定特征的其他标志:
-
我们刚刚用了那个。这意味着如果服务还没有启动,它会自动启动。有时你会读到,如果你绑定到一个服务,那么显式地启动它是不必要的,但是只有当你设置了这个标志时,这才是正确的。
-
BIND_DEBUG_UNBIND
:这导致保存下一个unbindService()
的调用栈,以防后续的 unbind 命令出错。如果发生这种情况,将会显示更详细的诊断输出。因为这会造成内存泄漏,所以该特性只应用于调试目的。 -
BIND_NOT_FOREGROUND
:仅当客户端运行在前台进程中,目标服务运行在后台进程中时才适用。使用此标志,绑定过程不会将服务提升到前台调度优先级。 -
BIND_ABOVE_CLIENT
:用这个标志,我们指定服务比客户端(即服务调用者)更重要。在资源短缺的情况下,系统将在调用服务之前终止客户端。 -
这个标志告诉 Android 操作系统,你愿意接受 Android 将绑定视为非关键的,并在内存不足的情况下终止服务。
-
BIND_WAIVE_PRIORITY
:这个标志导致将服务调用的调度留给服务运行的流程。
只需将它们添加到适合您需求的组合中。
注意
从BroadcastReceiver
组件内部绑定是不可能的,除非BroadcastReceiver
已经通过registerReceiver(receiver.intentfilter)
注册。在后一种情况下,接收器的寿命与注册组件相关。但是,您可以从广播接收器传递用于启动(换句话说,不绑定)服务的 intent 内部的指令字符串。
要绑定到外部服务,换句话说,绑定到属于另一个应用的服务,您不能使用与内部服务相同的绑定技术。原因是我们使用的IBinder
接口不能直接访问服务类,因为该类在进程边界上是不可见的。然而,我们可以将在服务和服务客户机之间传输的数据包装到一个android.os.Handler
对象中,并使用该对象将数据从服务客户机发送到服务。为了实现这一点,我们首先需要为服务定义一个用于接收消息的Handler
。这里有一个例子:
internal class InHandler(val ctx: Context) : Handler() {
override
fun handleMessage(msg: Message) {
val s = msg.data.getString("MyString")
Toast.makeText(ctx, s, Toast.LENGTH_SHORT).show()
}
}
[...]
class MyService : Service() {
val myMessg:Messenger = Messenger(InHandler(this))
[...]
}
除了创建一个Toast
消息,当消息到达时,你当然可以做更多有趣的事情。现在在服务的onBind()
方法中,我们返回 messenger 提供的 binder 对象。
override
fun onBind(intent:Intent):IBinder {
return myMessg.binder
}
至于在AndroidManifest.xml
文件中的条目,我们可以写得和启动远程服务时一样。
在服务客户机中,您可以添加一个Messenger
属性和一个ServiceConnection
对象。这里有一个例子:
var remoteSrvc:Messenger? = null
private val myConnection = object : ServiceConnection {
override
fun onServiceConnected(className: ComponentName,
service: IBinder) {
remoteSrvc = Messenger(service)
}
override
fun onServiceDisconnected(className: ComponentName) {
remoteSrvc = null
}
}
要实际执行绑定,您可以像处理内部服务一样进行。例如,在活动的onCreate()
方法中,您可以编写以下代码:
val intent:Intent = Intent("<PCKG_NAME>.START_SERVICE")
intent.setPackage("<PCKG_NAME>")
bindService(intent, myConnection, Context.BIND_AUTO_CREATE)
这里,适当地用服务包的名称代替<PCKG_NAME>
。
现在,要跨越流程边界从客户端向服务发送消息,您可以编写以下代码:
val msg = Message.obtain()
val bundle = Bundle()
bundle.putString("MyString", "A message to be sent")
msg.data = bundle
remoteSrvc?.send(msg)
注意,在这个例子中,您不能将这些行添加到活动的onCreate()
方法中的bindService()
语句之后,因为remoteSrvc
只有在连接启动后才会获得一个值。但是你可以把它添加到ServiceConnection
类的onServiceConnected()
方法中。
注意
在前面的代码中,没有采取任何预防措施来确保连接的完整性。您应该为生产性代码添加健全性检查。此外,在onStop()
方法中解除服务绑定。
服务发送的数据
到目前为止,我们一直在讨论从服务客户端发送到服务的消息。从服务到服务客户机的相反方向发送数据也是可能的;最好是通过在客户端内部使用一个额外的Messenger
、一个广播消息或者一个ResultReceiver
类来实现。
对于第一种方法,在服务客户端中提供另一个Handler
和Messenger
,一旦客户端接收到一个onServiceConnected()
回调,发送一个Message
给服务,第二个Messenger
由replyTo
参数传递。
internal class InHandler(val ctx: Context) : Handler() {
override
fun handleMessage(msg: Message) {
// do something with the message from the service
}
}
class MainActivity : AppCompatActivity() {
private var remoteSrvc:Messenger? = null
private var backData:Messenger? = null
private val myConn = object : ServiceConnection {
override
fun onServiceConnected(className: ComponentName,
service: IBinder) {
remoteSrvc = Messenger(service)
backData = Messenger(
InHandler(this@MainActivity))
// establish backchannel
val msg0 = Message.obtain()
msg0.replyTo = backData
remoteSrvc?.send(msg0)
// handle forward (client -> service)
// connectivity...
}
override
fun onServiceDisconnected(clazz: ComponentName) {
remoteSrvc = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// bind to the service, use ID from the manifest!
val intent = Intent("<PCKG>.START_SERVICE")
intent.setPackage("<PCKG>")
val flags = Context.BIND_AUTO_CREATE
bindService(intent, myConn, flags)
}
}
然后,服务可以使用这个消息,提取replyTo
属性,并使用它向服务客户机发送消息。
internal class IncomingHandler(val ctx: Context) :
Handler() {
override
fun handleMessage(msg: Message) {
val s = msg.data.getString("MyString")
val repl = msg.replyTo
Toast.makeText(ctx, s, Toast.LENGTH_SHORT).show()
Log.e("IncomingHandler", "!!! " + s)
Log.e("IncomingHandler", "!!! replyTo = " + repl)
// If not null, we can now use the 'repl' to send
// messages to the client. Of course we can save
// it elsewhere and use it later as well
if(repl != null) {
val thr = Thread( object : Runnable {
override fun run() {
Thread.sleep(3000)
val msg = Message.obtain()
val bundle = Bundle()
bundle.putString("MyString",
"A reply message to be sent")
msg.data = bundle
repl?.send(msg)
}
} )
thr.start()
}
}
}
其他两种方法,使用广播消息或ResultReceiver
类,在第 5 和 12 章节中处理。
服务子类
到目前为止,我们一直使用android.app.Service
作为我们描述的服务的基类。不过,Android 提供的其他类也可以用作基类,只是语义不同。对于 Android 8.0,有不少于 20 个服务类或基类可以使用。你可以在 Android API 文档的“已知直接子类”部分看到它们。
注意
在写这本书的时候,你可以在 https://developer.android.com/reference/android/app/Service.html
找到这个文档。
最重要的服务类别有以下三种:
-
这是我们到目前为止一直在用的一款。这是最基本的服务类。除非您在中使用多线程,否则服务类或服务被显式配置为在另一个进程中执行,服务将在服务调用者的主线程中运行。如果这是 GUI 线程,并且您不希望服务调用运行得很快,那么强烈建议您将服务活动发送到后台线程。
-
android.app.IntentService
:虽然服务在设计上不会自然地同时处理主线程传入的启动请求,但是IntentService
使用一个专用的工作线程来接收多个启动消息。尽管如此,它只使用一个线程来处理启动请求,所以它们被一个接一个地执行。IntentService
类负责正确地停止服务,所以你自己不需要关心这个。您必须在被覆盖的onHandleIntent()
方法中为每个启动请求提供服务要完成的工作。因为基本上你不需要其他任何东西,所以IntentService
服务很容易实现。注意,从 Android 8.0 (API 级别 26)开始,限制适用于后台进程,因此在适当的情况下,可以考虑使用JobIntentService
类来代替。 -
android.support.v4.app.JobIntentService
:这使用了一个JobScheduler
来对服务执行请求进行排队。从 Android 8.0(API 26 级)开始,考虑使用这个服务基类进行后台服务。要实现这样的服务,基本上必须创建一个JobIntentService
的子类,并覆盖方法onHandleWork(intent: Intent): Unit
来包含服务的工作负载。
服务生命周期
在前面几节中描述了各种服务特征之后,从鸟瞰的角度来看,服务的实际生命周期可能比活动的生命周期更容易。但是,要注意服务可能会在后台运行。此外,因为服务更容易受到 Android 操作系统强制停止的影响,所以在与服务客户端通信时,它们可能需要特别注意。
在您的服务实现中,您可以覆盖这里列出的任何生命周期回调,例如,在开发或调试时记录服务调用信息:
-
onCreate()
-
onStartCommand()
-
onBind()
-
onUnbind()
-
onRebind()
-
onDestroy()
图 4-1 显示了服务生命周期的概述
图 4-1
服务生命周期
更多服务特征
以下是关于服务的更多观察:
-
服务与
AndroidManifest.xml
中的活动一起被声明。一个常见的问题是它们如何相互作用。有人需要调用服务来使用它们,但这也可以通过其他服务、其他活动甚至其他应用来完成。 -
出于性能和稳定性原因,不要在活动的
onResume()
和onPause()
方法期间绑定或解除绑定。如果您只需要在活动可见时与服务交互,请在onStart()
和onStop()
方法中绑定和解除绑定。如果在活动停止时和在后台也需要服务连接,请在onCreate()
和onRestore()
方法中绑定和解除绑定。 -
在远程连接操作中(服务存在于另一个应用中),捕捉并处理
DeadObjectException
异常。 -
如果您覆盖了服务的
onStartCommand(intent: Intent, flags: Int, startId: Int)
方法,首先确保也调用方法super.onStartCommand()
,除非您有充分的理由不这样做。接下来,对传入的flags
参数做出适当的反应,该参数告知这是否是一个自动后续启动请求,因为之前的启动尝试失败了。最终这个方法在离开onStartCommand()
方法后返回一个描述服务状态的整数;有关详细信息,请参见 API 文档。 -
从服务外部调用
stopService()
或从服务内部调用stopSelf()
并不能保证服务立即停止。预计这项服务会持续一段时间,直到 Android 真的停止它。 -
如果服务没有被设计成对绑定请求做出反应,并且您覆盖了服务的
onBind()
方法,那么它应该返回null
。 -
虽然没有明确禁止,但是对于设计用于通过绑定与服务客户端通信的服务,可以考虑禁止由
startService()
启动该服务。在这种情况下,您必须在bindService()
方法调用中提供Context.BIND_AUTO_CREATE
标志。
五、广播
Android 广播是遵循发布-订阅模式的消息。它们通过 Android 操作系统发送,内部被 Android 操作系统隐藏,因此发布者和订阅者只能看到一个简单的异步接口来发送和接收消息。广播可以由 Android 操作系统本身、标准应用以及系统上安装的任何其他应用发布。同样,任何应用都可以配置或编程为接收他们感兴趣的广播消息。像活动一样,广播可以显式或隐式路由,这是广播发送方决定的责任。
广播接收器要么在AndroidManifest.xml
文件中声明,要么以编程方式声明。从 Android 8.0 (API level 26)开始,Android 的开发人员已经放弃了 XML 和广播接收器的编程声明之间通常的对称性,转而使用隐含的意图。原因是对在后台模式下运行的进程(尤其是与广播相关的进程)施加限制的总体想法导致了 Android 操作系统上的高负载,大大降低了设备的速度,并导致了糟糕的用户体验。出于这个原因,AndroidManifest.xml
内部广播接收器的声明现在被限制在一个更小的用例集合中。
注意
你会想要编写可以在 Android 8.0 和更新版本中运行的现代应用。出于这个原因,认真对待这个隐含意图的广播限制,并使你的应用在这个限制内运行。
明确的广播
显式广播是以这样一种方式发布的广播,即只有一个接收方被其寻址。这通常只有在广播发布者和订阅者都是同一个应用的一部分时才有意义,或者如果它们之间有很强的功能依赖性,则是同一个应用集合的一部分。
本地广播和远程广播是有区别的:本地广播接收者必须驻留在同一个 app 中,它们运行速度很快,接收者不能在AndroidManifest.xml
内部声明。相反,本地广播接收机必须使用程序化注册方法。此外,您必须使用以下内容来发送本地广播消息:
// send local broadcast
LocalBroadcastManager.getInstance(Context).
sendBroadcast(...)
远程广播接收机,另一方面,可以驻留在同一个 app 中,它们比较慢,可以用AndroidManifest.xml
来声明。要发送远程广播,请编写以下内容:
// send remote broadcast (this App or other Apps)
sendBroadcast(...)
注意
出于性能原因,本地广播应优先于远程广播。不能使用AndroidManifest.xml
来声明本地接收器的明显缺点并没有太大关系,因为从 Android 8.0 (API level 26)开始,在清单文件中声明广播接收器的用例无论如何都是有限的。
明确的本地广播
要在同一个应用中向本地广播接收器发送本地广播消息,您需要编写以下代码:
val intent = Intent(this, MyReceiver::class.java)
intent.action = "de.pspaeth.simplebroadcast.DO_STH"
intent.putExtra("myExtra", "myExtraVal")
Log.e("LOG", "Sending broadcast")
LocalBroadcastManager.getInstance(this).
sendBroadcast(intent)
Log.e("LOG", "Broadcast sent")
这里,MyReceiver
是接收器类。
class MyReceiver : BroadcastReceiver() {
override
fun onReceive(context: Context?, intent: Intent?) {
Toast.makeText(context, "Intent Detected.",
Toast.LENGTH_LONG).show()
Log.e("LOG", "Received broadcast")
Thread.sleep(3000)
// or real work of course...
Log.e("LOG", "Broadcast done")
}
}
对于本地广播,接收者必须在代码中声明。为了避免资源泄漏,我们在onCreate()
中创建和注册了接收者,并在onDestroy()
中取消注册。
class MainActivity : AppCompatActivity() {
private var bcReceiver:BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
bcReceiver = MyReceiver()
val ifi:IntentFilter =
IntentFilter("de.pspaeth.myapp.DO_STH")
LocalBroadcastManager.getInstance(this).
registerReceiver(bcReceiver, ifi)
}
override fun onDestroy() {
super.onDestroy()
// ...
LocalBroadcastManager.getInstance(this).
unregisterReceiver(bcReceiver)
}
}
显式远程广播
我们已经指出,我们可以将远程类型的广播消息发送到其他应用或接收者所在的同一应用。区别在于数据是如何发送的。对于远程消息,数据通过 IPC 通道传输。现在,要向同一个应用发送这样的远程广播消息,您需要编写以下代码:
val intent = Intent(this, MyReceiver::class.java)
intent.action = "de.pspaeth.myapp.DO_STH"
intent.putExtra("myExtra", "myExtraVal")
sendBroadcast(intent)
在接收端,对于远程消息,必须在清单文件中声明接收者和。
<application ...>
...
<receiver android:name=".MyReceiver">
<intent-filter>
<action android:name=
"de.pspaeth.myapp.DO_STH">
</action>
</intent-filter>
</receiver>
</application>
了解本地和远程广播之间的差异,记住以下几点很有帮助:
-
本地显式广播:
发送方使用显式的接收方类,接收方必须以编程方式声明,发送方和接收方都使用
LocalBroadcastManager
来发送消息和注册接收方。 -
远程显式广播:
发送方使用显式的接收方类,接收方必须在
AndroidManifest.xml
中声明。
对于负责处理接收到的广播的类,与显式本地广播相比没有区别。
class MyReceiver : BroadcastReceiver() {
override
fun onReceive(context: Context?, intent: Intent?) {
// handle incoming broadcasts...
}
}
发送到其他应用的明确广播
显性广播的发送者和接收者可以生活在不同的应用中。为此,您不能再使用我们之前使用的 intent 构造函数。
val intent = Intent(this, MyReceiver::class.java)
intent.action = "de.pspaeth.myapp.DO_STH"
// add other coords...
sendBroadcast(intent)
这是因为接收类(这里是MyReceiver
)不是类路径的一部分。然而,我们可以使用另一种结构来代替。
val intent = Intent()
intent.component = ComponentName("de.pspaeth.xyz",
"de.pspaeth.xyz.MyReceiver")
intent.action = "de.pspaeth.simplebroadcast.DO_STH"
// add other coords...
sendBroadcast(intent)
这里,ComponentName
的第一个参数是接收包的包字符串,第二个参数是类名。
警告
除非你正在向自己构建的应用广播,否则这种发送明确广播的方式只有有限的用途。另一个应用的开发人员可能很容易决定更改类名,然后您使用广播与另一个应用的通信将会中断。
隐式广播
隐式广播是具有不确定数量的可能接收者的广播。对于显式广播,您了解到我们必须通过使用指向接收方组件的构造函数来构建相应的意图:val intent = Intent(this, TheReceiverClass::class.java)
。与此相反,对于隐式广播,我们不再指定接收者,而是提示哪些组件可能对接收它感兴趣。这里有一个例子:
val intent = Intent()
intent.action = "de.pspaeth.myapp.DO_STH"
sendBroadcast(intent)
在这里,我们实际上表达如下:“向所有对动作de.pspaeth.myapp.DO_STH
感兴趣的接收者发送广播消息。”Android OS 确定哪些组件有资格接收这样的广播消息;这可能导致零个、一个或多个实际收件人。
在开始编程隐式广播之前,您必须做出三个决定。
-
我们想听系统广播吗?
Android 存在大量预定义的广播消息类型。在你用 Android Studio 安装的 Android SDK 里面,在
SDK_INST_DIR/platforms/VERSION/data/broadcast_actions.txt
,你可以找到一个系统广播动作的列表。如果我们想要收听这样的消息,我们只需要按照本章后面的描述对适当的广播接收机进行编程。在在线文本指南的“系统广播”部分,您会找到系统广播的完整列表。 -
我们如何对广播消息类型进行分类?
广播发送者和广播接收者通过意图过滤器匹配来连接,就像活动一样。正如在第三章中所讨论的,当描述活动的意图过滤器时,广播的分类是三重的:首先是一个强制的动作说明符,其次是一个类别,第三是一个可以用来定义过滤器的数据和类型说明符。我们将在本章后面描述这个匹配过程。
-
我们是在向本地广播还是远程广播前进?
如果所有的广播都完全发生在你的应用中,你应该使用本地广播来发送和接收消息。对于隐式广播,这种情况可能不会太常见,但对于大型复杂的应用,这是完全可以接受的。如果涉及系统广播或来自其他应用的广播,您必须使用远程广播。后者是大多数例子中的默认情况,所以您会经常看到这种模式。
意图过滤器匹配
广播接收机通过声明动作、类别、数据说明符来表示接受广播。
先说动作。这些只是没有任何语法限制的字符串。更彻底地观察它们,您会看到我们首先有一组或多或少严格定义的预定义动作名称。我们在第三章中列出了它们。此外,您可以定义自己的操作。惯例是使用包名加一个点,然后是一个动作说明符。你不一定要遵循这个惯例,但是强烈建议你这样做,这样你的应用就不会和其他应用混淆。如果不指定任何其他筛选条件,指定您在筛选器中指定的特定操作的发件人将会到达所有匹配的收件人。
-
为了匹配意图过滤器,接收方指定的动作必须与发送方指定的动作相匹配。对于隐式广播,一次广播可以寻址零个、一个或多个接收器。
-
接收者可以指定一个以上的过滤器。如果其中一个过滤器包含指定的动作,这个特定的过滤器将匹配广播。
表 5-1 显示了一些例子。
表 5-1
动作匹配
|接收器
|
发报机
|
比赛
|
| --- | --- | --- |
| 一个过滤器action = "com.xyz.ACTION1"
| action = "com.xyz.ACTION1"
| 是 |
| 一个过滤器action = "com.xyz.ACTION1"
| action = "com.xyz.ACTION2"
| 不 |
| 两个过滤器action = "com.xyz.ACTION1"``action = "com.xyz.ACTION2"
| action = "com.xyz.ACTION1"
| 是 |
| 两个过滤器action = "com.xyz.ACTION1"``action = "com.xyz.ACTION2"
| action = "com.xyz.ACTION3"
| 不 |
除了动作,一个类别说明符可以用来限制一个意图过滤器。我们在第三章中列出了几个预定义的类别,但是你也可以定义自己的类别。就像对于动作一样,对于你自己的类别你应该遵循将你的应用的包名加到你的类别名前面的命名惯例。一旦在意图匹配过程中发现动作中的匹配,发送者声明的所有类别也必须出现在接收者的意图过滤器中,以使匹配更进一步。
- 一旦意图过滤器内的动作匹配广播,并且过滤器也包含类别列表,则只有这样的广播将匹配发送者指定的类别全部包含在接收者的类别列表中的过滤器。
表 5-2 显示了一些例子(只有一个过滤器;如果有几个过滤器,匹配以“或”为基础。
表 5-2
类别匹配
|接收器动作
|
接收者类别
|
发报机
|
比赛
|
| --- | --- | --- | --- |
| com.xyz.ACT1
| com.xyz.cate1
| action = "com.xyz.ACT1"
| 是 |
| com.xyz.ACT1
| | action = "com.xyz.ACT1"``categ = "com.xyz.cate1"
| 不 |
| com.xyz.ACT1
| com.xyz.cate1
| action = "com.xyz.ACT1"``categ = "com.xyz.cate1"
| 是 |
| com.xyz.ACT1
| com.xyz.cate1
| action = "com.xyz.ACT1"``categ = "com.xyz.cate1"``categ = "com.xyz.cate2"
| 不 |
| com.xyz.ACT1
| com.xyz.cate1``com.xyz.cate2
| action = "com.xyz.ACT1"``categ = "com.xyz.cate1"``categ = "com.xyz.cate2"
| 是 |
| com.xyz.ACT1
| com.xyz.cate1``com.xyz.cate2
| action = "com.xyz.ACT1"``categ = "com.xyz.cate1"
| 是 |
| com.xyz.ACT1
| any
| action = "com.xyz.ACT2"``categ = any
| 不 |
第三,数据和类型说明符允许过滤数据类型。这种说明符是下列说明符之一:
-
类型:MIME 类型,例如
"text/html"
或"text/plain"
-
数据:某数据 URI,例如“
http://xyz.com/type1
"
-
数据和类型:两者都有
这里,data
元素允许通配符匹配。
-
假定的 动作 和 类别 匹配:如果发送方指定的 MIME 类型包含在接收方允许的 MIME 类型列表中,则类型过滤器元素匹配。
-
假定的 动作 和 类别 匹配: A 数据过滤元素匹配如果发送方指定的数据 URI 匹配接收方允许的数据 URIs 列表中的任何一个(通配符匹配可能适用)。
-
假定 动作 和 类别 匹配:如果 MIME 类型和数据 URI 匹配,即包含在接收者的指定列表中,则数据和类型过滤元素匹配。
表 5-3 显示了一些例子(只有一个过滤器;如果有几个过滤器,匹配以“或”为基础。
表 5-3
数据匹配
|接收器类型
|
接收者 URI
。* =任何字符串
|
发报机
|
比赛
|
| --- | --- | --- | --- |
| text/html
| | type = "text/html"
| 是 |
| text/html``text/plain
| | type = "text/html"
| 是 |
| text/html``text/plain
| | type = "image/jpeg"
| 不 |
| | http://a.b.c/xyz
| data = "http://a.b.c/xyz"
| 是 |
| | http://a.b.c/xyz
| data = "http://a.b.c/qrs"
| 不 |
| | http://a.b.c/xyz/.*
| data = "http://a.b.c/xyz/3"
| 是 |
| | http://.*/xyz
| data = "http://a.b.c/xyz"
| 是 |
| | http://.*/xyz
| data = "http://a.b.c/qrs"
| 不 |
| text/html
| http://a.b.c/xyz/.*
| type = "text/html"``data = "http://a.b.c/xyz/1"
| 是 |
| text/html
| http://a.b.c/xyz/.*
| type = "image/jpeg"``data = "http://a.b.c/xyz/1"
| 不 |
主动或等待接听
应用必须处于哪个状态才能接收隐式广播?如果我们希望广播接收器只在系统中注册,并且只在匹配的广播到达时按需启动,则必须在应用的清单文件中指定侦听器。然而,对于隐式广播,这不能自由地进行。它仅适用于预定义的系统广播,如在线文本指南的“系统广播”一节所列。
注意
对清单中指定的隐式意图过滤器的这种限制是在 Android 8.0 (API 级别 26)中引入的。在此之前,可以在清单文件中指定任何隐式过滤器。
但是,如果您从应用内部以编程方式启动广播侦听器,并且该应用正在运行,则您可以根据需要定义任意数量的隐式广播侦听器,并且对于广播来自系统、您的应用还是其他应用没有任何限制。同样,对于可用的动作或类别名称也没有限制。
因为监听引导完成事件包含在清单文件中允许的监听器列表中,所以您可以自由地启动应用作为活动或服务,并且在这些应用中,您可以注册任何隐式监听器。但这意味着从 Android 8.0 开始,你可以合法地绕过这些限制。只是要注意,如果出现资源短缺,这类应用可能会被 Android OS 杀死,所以你必须采取适当的预防措施。
发送隐式广播
要准备发送隐式广播,请按如下方式指定操作、类别、数据和额外数据:
val intent = Intent()
intent.action = "de.pspaeth.myapp.DO_STH"
intent.addCategory("de.pspaeth.myapp.CATEG1")
intent.addCategory("de.pspaeth.myapp.CATEG2")
// ... more categories
intent.type = "text/html"
intent.data = Uri.parse("content://myContent")
intent.putExtra("EXTRA_KEY", "extraVal")
intent.flags = ...
只有动作是强制性的;所有其他的都是可选的。现在要发送广播,您需要为远程广播编写以下代码:
sendBroadcast(intent)
对于本地广播,您应该这样写:
LocalBroadcastManager.getInstance(this).
sendBroadcast(intent)
this
必须是Context
或其子类;如果代码来自一个活动或服务类内部,它将完全像这里显示的那样工作。
对于远程消息,还有一种变体,每次向一个适用的接收器发送广播。
...
sendOrderedBroadcast(...)
...
这使得接收者顺序地获得消息,并且每个接收者可以取消,通过使用BroadcastReceiver.abortBroadcast()
将消息转发给队列中的下一个接收者。
接收隐式广播
要接收隐式广播,对于一组有限的广播类型(参见在线文本指南的“系统意图过滤器”一节),您可以在AndroidManifest.xml
中指定一个BroadcastListener
,如下所示:
<application ...>
...
<receiver android:name=".MyReceiver">
<intent-filter>
<action android:name=
"com.xyz.myapp.DO_STH" />
<category android:name=
"android.intent.category.DEFAULT"/>
<category android:name=
"com.xyz.myapp.MY_CATEG"/>
<data android:scheme="http"
android:port="80"
android:host="com.xyz"
android:path="items/7"
android:mimeType="text/html" />
</intent-filter>
</receiver>
</application>
这里显示的<data>
元素只是一个例子;参见第三章了解所有可能性。
与此相反,向代码中添加隐式广播的编程侦听器是不受限制的。
class MainActivity : AppCompatActivity() {
private var bcReceiver:BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
bcReceiver = MyReceiver()
val ifi:IntentFilter =
IntentFilter("de.pspaeth.myapp.DO_STH")
registerReceiver(bcReceiver, ifi)
}
override fun onDestroy() {
super.onDestroy()
// ...
unregisterReceiver(bcReceiver)
}
}
MyReceiver
是类android.content.BroadcastReceiver
的一个实现。
收听系统广播
要收听系统广播,请参阅在线文本指南的“系统广播”一节中的列表。您可以像前面展示的那样使用编程注册。对于它们中的大多数,您不能使用自 Android 8.0 (API 级别 26)以来强加的后台执行限制的清单注册方法。但是,对于其中的一些,您也可以使用清单文件来指定侦听器。
-
ACTION_LOCKED_BOOT_COMPLETED
,ACTION_BOOT_COMPLETED
:应用可能需要这些来安排作业、警报等等。
-
ACTION_USER_INITIALIZE
、"android.intent.action.USER_ADDED"
、"android.intent.action.USER_REMOVED"
:这些受特权权限的保护,所以用例是有限的。
-
"android.intent.action.TIME_SET"
、ACTION_TIMEZONE_CHANGED
、ACTION_NEXT_ALARM_CLOCK_CHANGED
:这些都是时钟 app 需要的。
-
ACTION_LOCALE_CHANGED
:区域设置已更改,发生这种情况时,应用可能需要更新其数据。
-
ACTION_USB_ACCESSORY_ATTACHED
、ACTION_USB_ACCESSORY_DETACHED
、ACTION_USB_DEVICE_ATTACHED
、、、ACTION_USB_DEVICE_DETACHED
:这些都是 USB 相关的事件。
-
ACTION_CONNECTION_STATE_CHANGED
、ACTION_ACL_ CONNECTED
、ACTION_ACL_DISCONNECTED
:这些是蓝牙事件。
-
ACTION_CARRIER_CONFIG_CHANGED
,TelephonyIntents
。ACTION_*_SUBSCRIPTION_CHANGED,"TelephonyIntents. SECRET_CODE_ACTION"
:OEM 电话应用可能需要接收这些广播。
-
LOGIN_ACCOUNTS_CHANGED_ACTION
:一些应用需要这一点来为新帐户和更改的帐户设置预定操作。
-
ACTION_PACKAGE_DATA_CLEARED
:操作系统设置应用清除数据;一个正在运行的应用可能对此感兴趣。
-
ACTION_PACKAGE_FULLY_REMOVED
:如果某些应用被卸载并且其数据被移除,可能需要通知相关应用。
-
ACTION_NEW_OUTGOING_CALL
:这会拦截呼出的电话。
-
ACTION_DEVICE_OWNER_CHANGED
:一些应用可能需要接收此消息,以便知道设备的安全状态已更改。
-
ACTION_EVENT_REMINDER
:这是由日历供应器发送的,用于向日历应用发布事件提醒。
ACTION_MEDIA_MOUNTED
,ACTION_MEDIA_CHECKING
、ACTION_MEDIA_UNMOUNTED
、ACTION_MEDIA_EJECT
、ACTION_MEDIA_UNMOUNTABLE
、ACTION_MEDIA_REMOVED
、ACTION_MEDIA_BAD_REMOVAL
:应用可能需要了解用户与设备的物理交互。
-
SMS_RECEIVED_ACTION
,WAP_PUSH_RECEIVED_ACTION
:这些都是短信接收应用所需要的。
增加广播的安全性
广播消息的安全性由权限系统处理,该系统将在第七章中得到更详细的处理。
在下面几节中,我们将区分显式和隐式广播。
保护明确的广播
对于非本地广播(即不使用LocalBroadcastManager
),权限可以在双方指定,接收者和发送者。对于后者,广播发送方法具有重载版本,包括一个权限说明符:
...
val intent = Intent(this, MyReceiver::class.java)
...
sendBroadcast(intent, "com.xyz.theapp.PERMISSION1")
...
这表示向受com.xyz.theapp.PERMISSION1
保护的接收器发送广播。当然,您应该在这里编写自己的包名,并使用适当的权限名。
相反,在没有许可说明的情况下发送广播可能会针对具有和不具有许可保护的接收者:
...
val intent = Intent(this, MyReceiver::class.java)
...
sendBroadcast(intent)
...
这意味着在发送方指定权限并不意味着告诉接收方发送方受到任何形式的保护。
为了向接收方添加权限,我们首先需要在应用级别的AndroidManifest.xml
中声明使用它。
<manifest ...>
<uses-permission android:name=
"com.xyz.theapp.PERMISSION1"/>
...
<application ...
接下来,我们将它显式地添加到同一个清单文件中的 receiver 元素中。
<receiver android:name=".MyReceiver"
android:permission="com.xyz.theapp.PERMISSION1">
<intent-filter>
<action android:name=
"com.xyz.theapp.DO_STH" />
</intent-filter>
</receiver>
这里,MyReceiver
是android.content.BroadcastReceiver
的一个实现。
第三,由于这是一个自定义权限,您必须在清单文件中声明它自己。
<manifest ...>
<permission android:name=
"com.xyz.theapp.PERMISSION1"/>
...
<permission>
允许更多的属性;请参阅联机文本指南中的“清单顶级条目”一节,了解有关保护级别的更多信息。第七章详细解释了它的细节和含义。
对于非定制权限,不需要使用<permission>
元素。
警告
当您尝试发送广播时,在发送方指定权限而在接收方没有匹配的权限会自动失败。也没有日志记录条目,所以要小心发送端的权限。
如果通过LocalBroadcastManager
使用本地广播,则不能在发送方或接收方指定权限。
保护隐式广播
像非本地显式广播一样,隐式广播中的权限可以在广播发送方和接收方指定。在发送方,您应该编写以下内容:
val intent = Intent()
intent.action = "de.pspaeth.myapp.DO_STH"
// ... more intent coordinates
sendBroadcast(intent, "com.xyz.theapp.PERMISSION1")
这表示向受com.xyz.theapp.PERMISSION1
额外保护的所有匹配接收器发送广播。当然,您应该在这里写下自己的包名,并使用适当的权限名。至于用于隐式广播的通常的发送者-接收者匹配过程,添加许可种类作为附加的匹配标准,因此如果有几个接收者候选查看意图过滤器,为了实际接收该广播,只有那些额外提供该许可标志的将被挑选出来。
隐式广播需要注意的另一件事是在AndroidManifest.xml
中指定权限用法。因此,为了使该发件人能够使用权限,请将以下内容添加到清单文件中:
<uses-permission android:name="com.xyz.theapp.
PERMISSION1"/>
和露骨的广播一样。在没有许可说明的情况下发送广播可以寻址具有和不具有许可保护的接收器。
...
sendBroadcast(intent)
...
这意味着在发送方指定权限不应该告诉接收方发送方受到任何形式的保护。
为了使接收器能够获得这样的广播,必须将许可添加到代码中,如下所示:
private var bcReceiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
bcReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?,
intent: Intent?) {
// do s.th. when receiving...
}
}
val ifi: IntentFilter =
IntentFilter("de.pspaeth.myapp.DO_STH")
registerReceiver(bcReceiver, ifi,
"com.xyz.theapp.PERMISSION1", null)
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(bcReceiver)
}
此外,您必须在接收方的清单文件中定义权限并声明使用它。
...
<uses-permission android:name=
"com.xyz.theapp.PERMISSION1" />
<permission android:name=
"com.xyz.theapp.PERMISSION1" />
...
同样,对于非定制权限,您不需要使用<permission>
元素。有关权限的更多信息,请参见第七章。
注意
作为提高安全性的附加手段,在适用的情况下,您可以使用Intent.setPackage()
来限制可能的接收者。
从命令行发送广播
对于可以通过 Android 调试桥 (ADB)连接的设备,可以在开发 PC 上使用 shell 命令发送广播消息(参见第十八章)。这里有一个向包de.pspaeth.simplebroadcast
的专用接收者MyReceiver
发送动作de.pspaeth.myapp.DO_STH
的例子(这是一个明确的广播消息):
./adb shell am broadcast -a de.pspaeth.myapp.DO_STH \
de.pspaeth.simplebroadcast MyReceiver
要获得以这种方式发送广播的完整概要,可以使用如下的 shell:
./adb shell
am
该命令将向您展示使用该am
命令创建广播消息和做其他事情的所有可能性。
广播随笔
以下是一些关于广播的附加信息:
-
您还可以在回调方法
onPause()
和onResume()
中注册和注销编程管理的接收器。显然,与使用onCreate()
/onDestroy()
对相比,注册和注销会更频繁。 -
一个当前正在执行的
onReceive()
方法会将进程优先级升级到“前台”级别,防止 Android OS 杀死接收进程。只有在极度资源短缺的情况下,这种情况才会发生。 -
如果您在
onReceive()
中有长时间运行的进程,您可能会想到在后台线程上运行它们,提前完成onReceive()
。但是由于完成onReceive()
后进程优先级会恢复到正常水平,所以你的后台进程被杀的可能性比较大,坏了你的 app。你可以通过使用Context.goAsync()
然后启动一个AsyncTask
来防止这种情况(在最后你必须在从goAsync()
获得的PendingResult
对象上调用finish()
来最终释放资源),或者你可以使用一个JobScheduler
。 -
自定义权限,就像我们在“保护隐式广播”一节中使用的一样,在安装应用时注册。因此,定义自定义权限的应用必须在应用使用它们之前安装。
-
小心通过隐式广播发送敏感信息。潜在的恶意应用也可能试图接收它们。至少,您可以通过在发送方指定权限来保护广播。
-
为了清晰起见,也为了不与其他应用混淆,请始终使用名称空间作为广播操作和权限名称。
-
避免从广播开始活动。这违背了 Android 的可用性原则。
六、内容供应器
本章将介绍内容供应器。
内容供应器框架
内容供应器框架允许以下内容:
-
使用其他应用提供的(结构化)数据
-
提供(结构化)数据供其他应用使用
-
将数据从一个应用复制到另一个应用
-
向搜索框架提供数据
-
向与数据相关的特殊 UI 小部件提供数据
-
凭借定义良好的标准化接口完成所有这些工作
传递的数据可以具有严格定义的结构,例如数据库中具有定义的列名和类型的行,但它也可以是没有任何关联语义的文件或字节数组。
如果您的应用关于数据存储的要求不符合前面的任何情况,您就不需要实现内容提供者组件。请改用普通的数据存储选项。
注意
没有严格禁止应用向自己的组件提供数据或使用自己的数据提供者来访问内容;然而,在考虑内容供应器时,您通常会想到应用间的数据交换。但是如果你需要的话,你总是可以把应用内的数据交换模式看作是应用间通信的一个简单的特例。
如果我们想要创建内容感知型应用,无论是提供内容还是消费内容,都需要考虑以下主要问题:
-
app 如何提供内容?
-
应用如何访问其他应用提供的内容?
-
应用如何处理其他应用提供的内容?
-
我们如何保护所提供的数据?
我们将在接下来的章节中探讨这些主题。图 6-1 为轮廓图。
图 6-1
内容供应器框架
提供内容
内容可以由您的应用以及系统应用提供。想想相机拍摄的照片或联系人列表中的联系人。如果我们首先看内容提供方,那么内容提供者框架就更容易理解了。在后面的部分中,我们还将研究消费者和其他主题。
首先,我们需要知道数据存储在哪里。然而,内容提供者框架并不假设数据实际上来自哪里。它可以存在于文件、数据库、内存存储或任何你能想到的地方。这改善了应用的维护。例如,在早期阶段,数据可能来自文件,但后来你可能会转向数据库或云存储,潜在的消费者不必关心这些变化,因为他们不必改变他们访问你的内容的方式。因此,内容提供者框架为您的数据提供了一个抽象层。
您需要实现来提供内容的单一接口是下面的抽象类:
android.content.ContentProvider
在接下来的部分中,我们将从用例的角度来看这个类的实现。
正在初始化提供程序
您必须实现以下方法:
ContentProvider.onCreate()
当内容提供者被实例化时,这个方法被 Android 操作系统调用。您可以在这里初始化内容提供者。但是,您应该避免将耗时的初始化过程放在这里,因为实例化并不一定意味着内容提供者将被实际使用。
如果你在这里没有什么有趣的事情要做,就把它实现为一个空方法。
为了在实例化时找到更多关于内容提供者运行环境的信息,您可以覆盖它的attachInfo()
方法。在那里,您将被告知内容提供者运行的上下文,并获得一个ProviderInfo
对象。只是别忘了也从内部呼叫super.attachInfo()
。
查询数据
对于查询类似数据库的数据集,有一个方法必须实现,另外两个方法可以选择实现。
abstract fun query( // ----- Variant A -----
uri:Uri,
projection:Array<String>,
selection:String,
selectionArgs:Array<String>,
sortOrder:String) : Cursor
// ----- Variant B -----
// You don't have to implement this. The default
// implementation calls variant A, but disregards the
// 'cancellationSignal' argument.
fun query(
uri:Uri,
projection:Array<String>,
selection:String,
selectionArgs:Array<String>,
String sortOrder:String,
cancellationSignal:CancellationSignal) : Cursor
// ----- Variant C -----
// You don't have to implement this. The default
// implementation converts the bundle argument to
// appropriate arguments for calling variant B.
// The bundle keys used are:
// ContentResolver.QUERY_ARG_SQL_SELECTION
// ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS
// ContentResolver.QUERY_ARG_SQL_SORT_ORDER -or-
// ContentResolver.QUERY_ARG_SORT_COLUMNS
// (this being a String array)
fun query(
uri:Uri,
projection:Array<String>,
queryArgs:Bundle,
cancellationSignal:CancellationSignal) : Cursor
这些方法并不用于呈现图像和声音等文件数据。但是,返回文件数据的链接或标识符是可以接受的。
在下面的列表中,我按名称和变量描述了所有参数:
-
uri
:这是一个重要的参数,指定查询在数据空间中的类型坐标。通过适当地指定这个参数,内容消费者将会知道他们对什么样的数据感兴趣。因为 URIs 是如此重要,我们在他们自己的部分描述他们;请参见下面“提供内容”一节中的“设计内容 URIs”。该参数对于变型 A、B 和 c 具有相同的含义。 -
projection
:这将告诉实现请求者对哪些列感兴趣。看看存储数据的 SQL 数据库类型,它列出了应该包含在结果中的列名。然而,对于一对一的映射没有严格的要求。请求者可能会要求一个选择参数X
,而X
的值可能会以你可能想到的任何方式计算出来。如果null
,返回所有字段。该参数对于变型 A、B 和 c 具有相同的含义。 -
selection
:这仅适用于变量 A 和 b。这为要返回的数据指定了一个选择。内容提供者框架没有假设这个选择参数应该是什么样子。这完全取决于实现,内容请求者必须服从实现的定义。然而,在许多情况下,您会有类似于 SQL 选择字符串的东西,比如这里的name = Jean AND age < 45
。如果null
,返回所有数据集。 -
selectionArgs
:选择参数可能包含类似?
的占位符。如果是这样,要为占位符插入的值在该数组中指定。同样,这个框架在这里没有做严格的假设,但是在大多数情况下,?
充当占位符,就像在 SQL 中的name = ? AND age < ?
一样。如果没有选择占位符,这可能是null
。 -
sortOrder
:这仅适用于变量 A 和 b。这指定了要返回的数据的排序顺序。内容提供者框架在这里没有规定语法,但是对于类似 SQL 的访问,这通常类似于name DESC, or ASC
。 -
queryArgs
:这仅适用于变量 c。所有三个选择、选择参数和排序顺序可以使用android.os.Bundle
对象指定,也可以不指定。按照惯例,对于类似 SQL 的查询,包键如下:-
ContentResolver.QUERY_ARG_SQL_SELECTION
-
ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS
-
ContentResolver.QUERY_ARG_SQL_SORT_ORDER
-
-
cancellationSignal
:这仅适用于变体 B 和 c,如果这不是null
,您可以用它来取消当前操作。Android 操作系统会适当地通知请求者。
所有的查询方法都应该返回一个android.database.Cursor
对象。这允许您迭代数据集,约定每个数据集包含一个_id
键控技术 ID。参见下面“提供内容”一节中的“基于 AbstractCursor 的光标类”和“设计内容 URIs ”,了解如何设计合适的光标对象。
修改内容
内容供应器不仅允许你阅读内容,还允许你修改内容。为此,存在以下方法:
abstract fun insert(
Uri uri:Uri,
values:ContentValues) : Uri
// You don't have to overwrite this, the default
// implementation correctly iterates over the input
// array and calls insert(...) on each element.
fun bulkInsert(
uri:Uri,
values:Array<ContentValues>) : Int
abstract fun update(
uri:Uri,
values:ContentValues,
selection:String,
selectionArgs:Array<String>) : Int
abstract fun delete(
uri:Uri,
selection:String,
selectionArgs:Array<String>) : Int
这些参数及其含义如下:
-
uri
:指定数据在数据空间中的类型坐标。通过适当地指定这个参数,内容消费者将会知道他们的目标是哪种类型的数据。注意,对于删除或更新单个数据集,通常假设 URI 包含 URI 路径末端的数据的(技术)ID,例如content://com.android.contacts/contact/42
。 -
values
:这些是要插入或更新的值。您可以使用这个类的各种get*()
方法来访问这些值。 -
selection
:指定选择要更新或删除的数据。内容提供者框架没有假设这个选择参数应该是什么样子。这完全取决于实现,内容请求者必须服从实现的定义。然而,在许多情况下,您会有类似于 SQL 选择字符串的东西,比如这里的name = Jean AND age < 45
。如果选择null
,数据集的所有项目都将被处理。 -
selectionArgs
:选择参数可能包含类似?
的占位符。如果是这样,要为占位符插入的值在该数组中指定。同样,框架在这里没有做严格的假设,但是在大多数情况下,?
充当占位符,就像在name = ? AND age < ?
中一样,就像 SQL 一样。如果没有选择占位符,可能是null
。
insert()
方法应该返回指定插入数据的 URI。这个返回值可能是null
,所以这里没有严格要求返回什么东西。如果它返回一些东西,这应该包含技术 ID。所有的Int
-返回方法都应该返回受影响数据集的数量。
如果您不希望内容提供者能够修改任何数据,那么您可以为所有的插入、更新和删除方法提供空的实现,并让它们返回0
或null
。
完成 ContentProvider 类
为了完成您的ContentProvider
类的实现,除了查询、插入、更新和删除方法之外,您必须再实现一个方法。
abstract getType(uri:Uri) : String
这将任何可用的 URI 映射到适当的 MIME 类型。例如,对于一个可能的实现,您可以按如下方式使用 URIs:
ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd.<name>.<type>"
ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.<name>.< type>"
URIs 分别指可能多项,或至多一项。对于<name>
,使用一个全球唯一的名称,可以是反向公司域名或包名,也可以是其重要部分。对于<type>
,使用定义表名或数据域的标识符。
注册内容供应器
一旦您完成了ContentProvider
实现,您必须将它注册到AndroidManifest.xml
文件中,如下所示:
<provider android:authorities="list"
android:directBootAware=["true" | "false"]
android:enabled=["true" | "false"]
android:exported=["true" | "false"]
android:grantUriPermissions=["true" | "false"]
android:icon="drawable resource"
android:initOrder="integer"
android:label="string resource"
android:multiprocess=["true" | "false"]
android:name="string"
android:permission="string"
android:process="string"
android:readPermission="string"
android:syncable=["true" | "false"]
android:writePermission="string" >
...
</provider>
表 6-1 描述了这些属性。
表 6-1
属性(在每个属性前添加android:
)
|
描述
|
| --- | --- |
| authorities
| 这是以分号分隔的权威列表。在许多情况下,这将只是一个,并且您通常使用 app(包)名称或ContentProvider
实现的完整类名。没有默认值,您必须至少有一个。 |
| directBootAware
| 这指定了内容供应器是否可以在用户解锁设备之前运行。默认为false
。 |
| enabled
| 这指定是否启用内容提供者。默认为true
。 |
| exported
| 这指定了其他应用是否可以访问此处的内容。根据您的应用的架构,访问可能被限制为来自同一应用的组件,但通常您希望其他应用访问内容,因此将其设置为true
。从 API 级开始,默认为false
。在此之前,该标志并不存在,应用的行为是这样设置为true
。 |
| grantUriPermissions
| 这指定是否可以临时授予其他应用通过 URI 访问此供应器的内容的权限。临时授予是指由<permission>
、<readPermission>
或writePermission
定义的许可拒绝,如果内容访问客户端被带有intent.addFlags(Intent.FLAG_GRANT_*_URI_PERMISSION)
的意图调用,则该许可被临时覆盖。如果将该属性设置为false
,仍然可以通过设置一个或多个<grant-uri-permission>
子元素来授予更细粒度的临时权限。默认为false
。 |
| icon
| 这指定了用于提供程序的图标的资源 ID。默认情况下使用父组件的图标。 |
| initOrder
| 在这里,您可以为同一个应用的内容提供者实例化强加一些顺序。较大的数字首先被实例化。请小心使用,因为启动顺序依赖关系可能表明应用设计不好。 |
| label
| 这指定了标签要使用的字符串资源 ID。默认是 app 的label
。 |
| multiprocess
| 如果设置为true
,在多个进程中运行的应用可能会运行多个内容提供者实例。否则,至多存在一个内容提供者的实例。默认为false
。 |
| name
| 这指定了ContentProvider
实现的完全限定类名。 |
| permission
| 这是设置readPermission
和writePermission
的便捷方式。指定后者之一具有优先权。 |
| process
| 如果希望内容提供者在应用本身之外的另一个进程中运行,请指定一个进程名称。如果以冒号(:)开头,则该进程将是应用的私有进程;如果以小写字母开头,将使用全局流程(需要许可)。默认是在应用的进程中运行。 |
| readPermission
| 这是客户端必须拥有的读取内容供应器的内容的许可。借助于grantUriPermissions
属性,您可以让没有该权限客户机仍然访问内容。 |
| syncable
| 这指定了内容供应器的数据是否应该与服务器同步。 |
| readPermission
| 这是客户端必须拥有的对内容供应器的内容进行写入的许可。借助于grantUriPermissions
属性,您可以让没有该权限客户机仍然访问内容。 |
如果您使用grantUriPermissions
将 URI 权限临时授予由隐式意图调用的其他应用的组件,您必须小心地定制这样的意图。首先添加标志Intent.FLAG_GRANT_READ_URI_PERMISSION
,然后在 intent 的data
字段中添加您希望允许访问的 URI。这里有一个例子:
intent.action =
"com.example.app.VIEW" // SET INTENT ACTION
intent.flags =
Intent.FLAG_ACTIVITY_NEW_TASK
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// GRANT TEMPORARY READ PERMISSION
intent.data = Uri.parse("content://<AUTHORITY>/<PATH>")
// USE YOUR OWN!
startActivity(intent)
在被调用组件的意图过滤器中,您必须指定一个<data>
元素,并且它必须包含一个适当的 URI 和一个 MIME 类型。尽管我们没有明确说明,但是必须指定 MIME 类型的原因是,Android 操作系统使用内容供应器的getType(Uri)
方法来自动添加 MIME 类型,同时解析意图。这里有一个例子:
<intent-filter>
<action android:name=
"de.pspaeth.crcons.VIEW"/>
<category android:name=
"android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"
android:scheme="content"
android:host="*"
android:pathPattern=".*"/>
</intent-filter>
然后被调用的组件被授权以指定的方式访问这个 URI。在它完成工作之后,它应该调用revokeUriPermission(String, Uri, Int)
来撤销它已经被给予的临时许可。
revokePermission(getPackageName(), uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
在<provider>
元素中,您可以添加几个子元素,如下所示:
-
meta-data
:<meta-data android:name="string" android:resource="resource specification" android:value="string" />
这是必须指定resource
或value
的地方。如果使用resource
,一个资源 ID 比如@string/someString
会将资源 ID 本身分配给元条目,而使用value
和@string/someString
会将资源的内容分配给元条目。
-
grant-uri-permission
:<grant-uri-permission android:path="string" android:pathPattern="string" android:pathPrefix="string" />
这将授予特定的 URI 权限(使用该子项的零到多个实例)。只有当父节点的属性grantUriPermissions
被设置为false
时,这个子节点才允许访问特定的 URIs。使用这些属性中的一个:path
表示完整的 URI,pathPrefix
表示以该值开始的 URIs,pathPattern
允许通配符(X*
表示任意字符 X 的零到多次重复,.*
表示任意字符的零到多次重复)。
-
path-permission
:<path-permission android:path="string" android:pathPrefix="string" android:pathPattern="string" android:permission="string" android:readPermission="string" android:writePermission="string" />
要定义内容提供者可以提供的数据子集,可以使用该元素指定路径和所需的权限。path
属性指定一个完整的路径,pathPrefix
属性允许匹配路径的初始部分,pathPattern
是一个完整的路径,但是带有通配符(*
匹配前面字符的零到多次出现,.*
匹配任何字符的零到多次出现)。属性permission
指定了读和写权限,属性readPermission
和writePermission
区分了读和写权限。如果指定了后两者之一,它优先于permission
属性。
设计内容 URIs
URIs 描述了内容请求者感兴趣的数据领域。考虑到 SQL,这应该是表名。然而,URIs 可以做得更多。URI 的官方语法如下:
scheme:[//[user[:password]@]host[:port]]
[/path][?query][#fragment]
你可以看到user
、password
和port
部分是可选的,事实上你通常不会在 Android 环境中指定它们。然而,它们并没有被禁止,而且在某些情况下是有意义的。然而,host
部分在最普遍的意义上被解释为提供某种东西,这也正是它在这里的解释方式,“某种东西”就是数据。为了让这个概念更加清晰,Android 的host
部分通常被称为权威。例如,在联系人系统应用中,权限将是com.android.contacts
。(不要用字符串;请改用类常量字段。有关更多信息,请参见“合同”部分。)按照惯例scheme
通常是content
。所以,URI 的一般交往是从下面开始的:
content://com.android.contacts
URI 的path
部分指定了数据域,即 SQL 中的表。例如,联系人内的用户简档数据通过以下方式来处理:
content://com.android.contacts/profile
在这个例子中,path 只有一个元素,但是 In 可以更复杂,比如pathpart1/pathpart2/pathpart3
。
URI 也可以有一个指定选择的查询部分。查看来自类android.content.ContentProvider
的查询方法,我们已经有能力在 API 的基础上指定一个选择,但是它是完全可以接受的,尽管不是强制性的,也允许在 URI 中使用查询参数。如果您需要将几个元素放入查询参数,您可以遵循通常的惯例使用&
作为分隔符,就像在name=John&age=37
中一样。
片段指定了次要资源,并且不经常被内容提供者使用。但是你可以用它,如果它对你有帮助的话。
由于 URI 是如此通用的构造,并且猜测正确的 URIs 来访问某些应用提供的内容几乎是不可能的,所以内容供应器应用通常会提供一个契约类来帮助为手头的任务构建正确的 URIs。
构建内容接口契约
客户端用来访问数据的 URIs 代表了访问内容的接口。因此,有一个中心位置是个好主意,客户可以在这里找到要使用的 URIs。Android 文档建议为此使用内容契约类。这类课程的大纲如下所示:
class MyContentContract {
companion object {
// The authority and the base URI
@JvmField
val AUTHORITY = "com.xyz.whatitis"
@JvmField
val CONTENT_URI = Uri.parse("content://" +
AUTHORITY)
// Selection for ID bases query
@JvmField
val SELECTION_ID_BASED = BaseColumns._ID +
" = ? "
}
// For various tables (or item types) it is
// recommended to use inner classes to specify
// them. This is just an example
class Items {
companion object {
// The name of the item.
@JvmField
val NAME = "item_name"
// The content URI for items
@JvmField
val CONTENT_URI = Uri.withAppendedPath(
MyContentContract.CONTENT_URI, "items")
// The MIME type of a directory of items
@JvmField
val CONTENT_TYPE =
ContentResolver.CURSOR_DIR_BASE_TYPE +
"/vnd." + MyContentContract.AUTHORITY +
".items"
// The mime type of a single item.
@JvmField
val CONTENT_ITEM_TYPE =
ContentResolver.CURSOR_ITEM_BASE_TYPE +
"/vnd." + MyContentContract.AUTHORITY +
".items"
// You could add database column names or
// projection specifications here, or sort
// order specifications, and more
// ...
}
}
}
当然,接口设计的一部分必须是为类、字段名称和字段值使用有意义的名称。
注意
契约类中描述的接口而不是必须对应于实际的数据库表。从概念上将接口从实际实现中分离出来是完全可行和有益的,并且还提供了表连接或其他项目类型,这些类型是以您可能想到的任何方式派生的。
这里有一些关于这个结构的注释:
-
If you can make sure clients will be using only Kotlin as a platform, this can be written in a much shorter way without any boilerplate code.
object MyContentContract2 { val AUTHORITY = "com.xyz.whatitis" val CONTENT_URI = Uri.parse("content://" + AUTHORITY) val SELECTION_ID_BASED = BaseColumns._ID + " = ? " object Items { val NAME = "item_name" val CONTENT_URI = Uri.withAppendedPath( MyContentContract.CONTENT_URI, "items") val CONTENT_TYPE = ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd." + MyContentContract.AUTHORITY + ".items" val CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd." + MyContentContract.AUTHORITY + ".items" } }
然而,如果我们想让 Java 客户机也使用这个接口,我们必须使用所有那些伴随对象和
@JvmObject
声明和修饰符。 -
使用伴随对象和
JvmObject
注释允许编写TheClass.THE_FIELD
,就像 Java 中的静态字段一样。 -
您可以考虑向您的客户提供一个等价的 Java 构造,这样如果他们只使用 Java,就不必学习 Kotlin 语法。
-
Uri.parse()
和Uri.withAppendedPath()
方法调用只是使用Uri
类的两个例子。这个类包含了更多的方法来帮助管理构造正确的 URIs。 -
您还可以在契约类中提供帮助器方法。如果这样做,请确保接口类不依赖于其他类,并在
fun
函数声明中添加一个修饰符JvmStatic
,使其可以从 Java 中调用。
然后,您可以向任何可能使用您的内容供应器应用的客户端公开提供这个契约类(或者多个类,如果您希望使用 Kotlin 和 Java 来记录接口的话)。
基于 AbstractCursor 和相关类的游标类
来自ContentProvider
的所有query*()
方法返回一个android.database.Cursor
对象。从包中你可以看到这是一个以数据库为中心的类,这实际上是 Android 的一个小设计缺陷,因为内容接口应该是访问方法不可知的。
此外,Cursor
接口是一个随机访问接口,供想要浏览结果集的客户机使用。您可以使用游标类的基本实现android.database.AbstractCursor
;它已经实现了几个接口方法。为此,编写class MyCursor : AbstractCursor { ... }
或val myCursor = object : AbstractCursor { ... }
并实现所有抽象方法,并覆盖该类的一些其他方法来做有意义的事情。
-
override fun getCount(): Int
这指定了可用数据集的数量。
-
override fun getColumnNames(): Array<String>
这指定了列名的有序数组。
-
override fun getInt(column: Int): Int
这会得到一个长值(列索引从零开始)。
-
override fun getLong(column: Int): Long
这会得到一个长值(列索引从零开始)。
-
override fun getShort(column: Int): Short
这将获得一个短值(列索引从零开始)。
-
override fun getFloat(column: Int): Float
这将获得一个浮点值(列索引从零开始)。
-
override fun getDouble(column: Int): Double
这会得到一个双精度值(列索引从零开始)。
-
override fun getString(column: Int): String
这将获得一个字符串值(列索引从零开始)。
-
override fun isNull(column: Int): Boolean
这表明该值是否为
null
(列索引从零开始)。 -
override fun getType(column: Int): Int
你可以不覆盖这个,但是如果不覆盖,它总会返回
Cursor.FIELD_TYPE_STRING
,假设getString()
总会返回有意义的东西。对于更细粒度的控制,让它返回FIELD_TYPE_NULL
、FIELD_TYPE_INTEGER
、FIELD_TYPE_FLOAT
、FIELD_TYPE_STRING
或FIELD_TYPE_BLOB
中的一个。列索引是从零开始的。 -
override fun getBlob(column: Int): ByteArray
如果您想要支持 blobs,请覆盖它。否则,将抛出一个
UnsupportedOperationException
。 -
override fun onMove(oldPosition: Int, newPosition: Int): Boolean
虽然没有标为
abstract
,但是你必须覆盖这个。您的实现必须将光标移动到结果集中的相应位置。可能的值范围从-1
(第一个位置之前;不是有效位置)到count
(在最后一个位置之后;不是有效的位置)。如果移动成功,让它返回true
。如果你不覆盖它,什么都不会发生,函数总是返回true
。
AbstractCursor
还提供了一个名为fillWindow(position: Int, window: CursorWindow?): Unit
的方法,可以用来根据查询结果集填充一个android.database.CursorWindow
对象。参见CursorWindow
的在线 API 文档,继续该方法。
除了AbstractCursor
,Cursor
接口还有几个(抽象的)实现可以使用,如表 6-2 中所总结的。
表 6-2
更多游标实现
|里面的名字android.database
|
描述
|
| --- | --- |
| AbstractWindowedCursor
| 它继承自AbstractCursor
并拥有一个保存数据的CursorWindow
对象。子类负责在他们的onMove(Int, Int)
操作中用数据填充光标窗口,必要时分配一个新的光标窗口。与AbstractCursor
相比,它更容易实现,但是你必须给onMove()
增加很多功能。 |
| CrossProcessCursor
| 这是一个游标实现,允许从远程进程使用它。它只是对android.database.Cursor
接口的扩展,包含另外三个方法:fillWindow(Int, CursorWindow)
、getWindow(): CursorWindow
和onMove(Int, Int): Boolean
。它不提供任何自己的实现;你必须覆盖所有在Cursor
中定义的方法。 |
| CrossProcessCursorWrapper
| 这是一个游标实现,允许从远程进程使用它。它实现了CrossProcessCursor
并持有一个Cursor
委托,这个委托也可以是一个CrossProcessCursor
。 |
| CursorWrapper
| 它保存了一个Cursor
委托,所有的方法调用都被转发到这个委托。 |
| MatrixCursor
| 这是一个完整的Cursor
实现,数据在内存中存储为一个Object
数组。你必须使用addRow(...)
来添加数据。内部类MatrixCursor.RowBuilder
可以用来构建供MatrixCursor.addRow(Array<Object>)
使用的行。 |
| MergeCursor
| 使用它透明地合并或连接光标对象。 |
| sqlite.SQLiteCursor
| 这是一个由 SQLite 数据库支持的数据的Cursor
实现。使用其中一个构造函数将游标与 SQLite 数据库对象连接起来。 |
基于光标接口的光标类
一种更低级的实现游标的方法不是依赖于AbstractCursor
,而是自己实现所有的接口方法。
然后,您可以像在class MyCursor : Cursor { ... }
中一样使用子类化,或者像在val myCursor = object : Cursor { ... }
中一样使用匿名对象。在线文本指南中的“光标界面”一节描述了所有界面方法。
在提供者代码中调度 URIs
为了简化传入 URIs 的调度,类android.content.UriMatcher
派上了用场。如果您有与查询相关的 URIs,例如:
people #list all people from a directory
people/37 #inquire a person with ID 37
people/37/phone #get phone info of person with ID 37
并且想要使用一个简单的switch
语句,你可以在你的类或对象中编写如下代码:
val PEOPLE_DIR_AUTHORITY = "directory"
val PEOPLE = 1
val PEOPLE_ID = 2
val PEOPLE_PHONES = 3
val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
init {
uriMatcher.addURI(PEOPLE_DIR_AUTHORITY,
"people", PEOPLE)
uriMatcher.addURI(PEOPLE_DIR_AUTHORITY,
"people/#", PEOPLE_ID)
uriMatcher.addURI(PEOPLE_DIR_AUTHORITY,
"people/#/phone", PEOPLE_PHONES)
}
这里,#
代表任意数字,*
匹配任意字符串。
在您的ContentProvider
实现中,您可以使用下面的构造来分派传入的字符串 URL:
when(uriMatcher.match(url)) {
PEOPLE ->
// incoming path = people, do s.th. with that...
PEOPLE_ID ->
// incoming path = people/#, do s.th. with that...
PEOPLE_PHONES ->
// incoming path = people/#/phone, ...
else ->
// do something else
}
提供内容文件
内容提供者不仅可以访问类似数据库的内容,还可以公开检索类似文件的数据的方法,比如图像或声音文件。为此,提供了以下方法:
-
override fun getStreamTypes(uri:Uri, mimeTypeFilter:String) : Array<String>
如果您的内容供应器提供文件,请覆盖此方法以允许客户端在给定 URI 的情况下确定支持的 MIME 类型。
mimeTypeFilter
不应该是null
,可以用它来过滤输出。它支持通配符,所以如果客户端想要检索所有值,它会在这里写*/*
,您的提供者代码需要正确处理这一点。输出还必须包含所有那些可能是由提供程序执行的适当类型转换的结果的类型。这可能会返回null
来指示空的结果集。例如image/png
或audio/mpeg
。 -
override fun openFile(uri:Uri, mode:String): ParcelFileDescriptor
Override this to handle requests to open a file blob. The parameter
mode
must be one of the following (there is no default):-
r
用于只读访问 -
w
用于只写访问(如果数据已经存在,则首先擦除) -
wa
,类似于w
,但可能附加数据 -
rw
用于阅读和添加文字 -
rwt
,与rw
类似,但会截断现有数据
要了解如何处理返回的
ParcelFileDescriptor
,请参阅列表后的文本。 -
-
override fun openFile(uri:Uri, mode:String, signal:CancellationSignal): ParcelFileDescriptor
这与
openFile(Uri, String)
相同,但是客户端可能会在读取文件的过程中发出取消信号。提供者可以保存signal
对象,并通过定期调用signal
对象上的throwIfCancelled()
来捕捉客户端的取消请求。 -
override fun openAssetFile(uri:Uri, mode:String): AssetFileDescriptor
这类似于
openFile(Uri, String)
,但是它可以由需要能够返回文件的子部分的提供者实现,这些子部分通常是他们的 APK 中的素材。为了实现这一点,您可能想要使用android.content.res.AssetManager
类。您可以在上下文的asset
字段中找到它,所以例如在一个活动中,您可以直接使用asset
来寻址AssetManager
。 -
override fun openAssetFile(uri:Uri, mode:String, signal:CancellationSignal): AssetFileDescriptor
这与
openAssetFile(Uri, String)
相同,但允许从客户端取消。提供者可以保存signal
对象,并通过定期调用signal
对象上的throwIfCancelled()
来捕捉客户端的取消请求。 -
override fun : openTypedAssetFile(uri:Uri, mimeTypeFilter:String, opts:Bundle): AssetFileDescriptor
如果您想让客户端能够读(而不是写!)按 MIME 类型的素材数据。默认实现将
mimeTypeFilter
与它从getType(Uri)
获得的任何东西进行比较,如果它们匹配,它就简单地转发给openAssetFile(...)
。 -
override fun : openTypedAssetFile(uri:Uri, mimeTypeFilter:String, opts:Bundle, signal:CancellationSignal): AssetFileDescriptor
这与
openTypedAssetFile(Uri, String, Bundle)
相同,但允许从客户端取消。提供者可以保存signal
对象,并通过定期调用signal
对象上的throwIfCancelled()
来捕捉客户端的取消请求。 -
override fun <T : Any?> openPipeHelper(uri: Uri?, mimeType: String?, opts: Bundle?, args: T, func: PipeDataWriter<T>?): ParcelFileDescriptor
这是一个实现
openTypedAssetFile(Uri, String, Bundle)
的辅助函数。它创建了一个数据管道和一个后台线程,允许您将生成的数据流回客户端。这个函数返回一个新的ParcelFileDescriptor
。工作完成后,调用者必须关闭它。 -
override fun openFileHelper(uri:Uri, mode:String): ParcelFileDescriptor
对于子类来说,这是一个方便的方法。默认实现打开一个文件,其路径由使用提供的 URI 的
query()
方法的结果给出。对于文件路径,_data
成员从查询结果中提取,结果集计数必须是1
。
那些返回ParcelFileDescriptor
对象的方法可以调用如下适当的构造函数来为文件构建输入和输出流:
val fd = ... // get the ParcelFileDescriptor
val inpStream =
ParcelFileDescriptor.AutoCloseInputStream(fd)
val outpStream =
ParcelFileDescriptor.AutoCloseOutputStream(fd)
一旦工作完成,您必须在流上使用close()
方法。Auto
表示当您关闭流时,ParcelFileDescriptor
会自动为您关闭。
类似地,那些返回AssetFileDescriptor
对象的方法可以调用如下适当的构造函数来为文件构建输入和输出流:
val fd = ... // get the AssetFileDescriptor
val inpStream =
AssetFileDescriptor.AutoCloseInputStream(fd)
val outpStream =
AssetFileDescriptor.AutoCloseOutputStream(fd)
同样,一旦工作完成,您必须在流上使用close()
方法;当您关闭流时,只有AssetFileDescriptor
会自动为您关闭。
通知监听器数据更改
通过其ContentResolver
字段(例如Activity.contentResolver
)寻址内容提供者的客户端可以通过调用以下内容来注册以获得内容变化的通知:
val uri = ... // a content uri
contentResolver.registerContentObserver(uri, true,
object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
// do s.th.
}
override fun onChange(selfChange: Boolean,
uri: Uri?) {
// do s.th.
}
}
)
registerContentObserver()
的第二个参数指定子 URIs(URI 加上任何其他路径元素)是否也会导致通知。ContentObserver
的构造函数参数也可以是一个Handler
对象,用于在不同的线程中接收onChange
消息。
要做到这一点,在内容提供者端,您可能需要注意事件是否被正确发出。例如,在任何数据修改方法中,都应该添加以下内容:
context.contentResolver.notifyChange(uri, null)
此外,为了使更改监听防弹,您可能想要通知由query()
方法返回的任何Cursor
对象。为此,cursor
有一个registerContentObserver()
方法,可以用来收集基于光标的内容观察者。内容供应器然后也可以向那些内容观察者发送消息。
扩展内容提供者
我们已经看到内容供应器允许访问类似数据库的内容和文件。如果您不太喜欢这种方式,或者对内容供应器应该能够做什么有自己的想法,您可以如下实现call()
方法:
override call(method:String, arg:String, extras:Bundle):
Bundle {
super.call(method, arg, extras)
// do your own stuff...
}
这样,您可以设计自己的内容访问框架。当然,您应该告知可能的客户如何使用该接口,例如,在 contract 类中。
警告
没有安全检查适用于调用此方法。您必须自己实现适当的安全检查,例如通过在上下文中使用checkSelfPermission()
。
通过 URI 规范化实现客户端访问一致性
查询结果通常包含 id、列表索引号或其他依赖于一些短期数据库上下文的信息。例如,一个查询可能返回 23、67 或 56 这样的商品 ID,如果您需要获得某个商品的详细信息,您可以使用包含该 ID 的另一个 URI 再次查询,例如content://com.xyz/people/23
。这种 URIs 的问题是,客户通常不会保存它们供以后检索。同时,ID 可能已经改变,因此 URI 不太可靠。
为了克服这个问题,内容供应器可以实现 URI 规范化。为此,您的内容提供者类必须实现这两个方法:
-
canonicalize(url:Uri): Uri:
Let this method return a canonicalized URI, for example, by adding some domain-specific query parameters as follows:
content://com.xyz/people/23 -> content://com.xyz/people? firstName=John& lastName=Bird& Birthday=20010534& SSN=123-99-1624
-
uncanonicalize(url:Uri): Uri:
这与
canonicalize()
正好相反。如果项目丢失并且无法执行取消定位,则让它返回null
。
消费内容
为了消费内容,内容供应器客户端使用一个android.content.ContentResolver
对象。任何包含活动、服务等的Context
对象都提供了一个名为getContentResolver()
的对象,或者用 Kotlin 更简洁地表示,只需编写contentResolver
。
使用内容解析器
要访问类似数据库的内容,您可以使用以下ContentProvider
方法之一:
-
insert(url: Uri, values: ContentValues): Int
这将插入一条记录。
-
delete(url: Uri, where: String, selectionArgs: Array<String>): Int
这将删除记录。
-
update(uri: Uri, values: ContentValues, where: String, selectionArgs: Array<String>): Int
这将更新记录。
-
query(uri: Uri, projection: Array<String>, queryArgs: Bundle, cancellationSignal: CancellationSignal): Cursor
这将根据给定的参数查询内容。
-
query(uri: Uri, projection: Array<String>, selection: String, selectionArgs: Array<String>, sortOrder: String, cancellationSignal: CancellationSignal): Cursor
这将根据给定的参数查询内容。
-
query(uri: Uri, projection: Array<String>, selection: String, selectionArgs: Array<String>, sortOrder: String): Cursor
这将根据给定的参数查询内容。
如前所述,它们的签名和含义与相应的ContentProvider
方法密切相关。另外,看看在线 API 参考。
要改为访问文件内容,您可以使用以下方法之一:
-
openAssetFileDescriptor(uri: Uri, mode: String, cancellationSignal: CancellationSignal): AssetFileDescriptor
这将打开内部(素材)文件。
-
openAssetFileDescriptor(uri: Uri, mode: String): AssetFileDescriptor
这将打开内部(素材)文件,没有取消信号。
-
openTypedAssetFileDescriptor(uri: Uri, mimeType: String, opts: Bundle, cancellationSignal: CancellationSignal): AssetFileDescriptor
这将打开类型化的内部(素材)文件。
-
openTypedAssetFileDescriptor(uri: Uri, mimeType: String, opts: Bundle): AssetFileDescriptor
这将打开类型化的内部(素材)文件,没有取消信号。
-
openFileDescriptor(uri: Uri, mode: String, cancellationSignal: CancellationSignal): ParcelFileDescriptor
这将打开文件。
-
openFileDescriptor(uri: Uri, mode: String): ParcelFileDescriptor
这将打开文件,没有取消信号。
-
openInputStream(uri: Uri): InputStream
这将打开一个输入流。
-
openOutputStream(uri: Uri, mode: String): OutputStream
这会打开一个输出流。
-
openOutputStream(uri: Uri): OutputStream
这将在
w
模式下打开一个输出流。
open*Descriptor()
方法同样与“提供内容”部分中相应的ContentProvider
方法密切相关。另外两个,openInputStream()
和openOutputStream()
,是更容易访问文件(流)数据的便利方法。
如前所述,要注册内容观察者,以便在内容更改时获得异步信号,请使用以下方法之一:
-
registerContentObserver(uri: Uri, notifyForDescendants: Boolean, observer: ContentObserver)
-
unregisterContentObserver(observer: ContentObserver)
要使用通过实现其call()
方法来展示扩展的内容提供者,可以使用内容解析器的相应的call()
方法
call(uri: Uri, method: String, arg: String, extras: Bundle)
访问系统内容供应器
Android 操作系统及其预装的应用提供了几个内容供应器组件。在在线 API 文档中,您可以在“android.provider/Classes”部分找到内容提供者契约类。以下部分总结了它们是什么以及如何访问它们。
BlockedNumberContract
这将显示一个包含被阻止号码的表。只有系统、默认电话应用、默认短信应用和运营商应用可以访问此表,但canCurrentUserBlockNumbers()
除外,它可以由任何应用调用。例如,要使用它,您可以这样写:
val values = ContentValues()
values.put(BlockedNumbers.COLUMN_ORIGINAL_NUMBER,
"1234567890")
Uri uri = contentResolver.insert(
BlockedNumbers.CONTENT_URI, values)
日历合同
这是一个相当复杂的内容提供者,有许多表。例如,我们正在访问日历列表并在此添加一个事件:
val havePermissions =
ContextCompat.checkSelfPermission(this,
Manifest.permission.WRITE_CALENDAR)
== PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
if(!havePermissions) {
// Acquire permissions...
}else{
data class CalEntry(val name: String, val id: String)
val calendars = HashMap<String, CalEntry>()
val uri = CalendarContract.Calendars.CONTENT_URI
val cursor = contentResolver.query(
uri, null, null, null, null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
val calName = cursor.getString(
cursor.getColumnIndex(
CalendarContract.Calendars.NAME))
val calId = cursor.getString(
cursor.getColumnIndex(
CalendarContract.Calendars._ID))
calendars[calName] = CalEntry(calName, calId)
cursor.moveToNext()
}
Log.e("LOG", calendars.toString())
val calId = "4" // You should instead fetch an
// appropriate entry from the map!
val year = 2018
val month = Calendar.AUGUST
val dayInt = 27
val hour = 8
val minute = 30
val beginTime = Calendar.getInstance()
beginTime.set(year, month, dayInt, hour, minute)
val event = ContentValues()
event.put(CalendarContract.Events.CALENDAR_ID,
calId)
event.put(CalendarContract.Events.TITLE,
"MyEvent")
event.put(CalendarContract.Events.DESCRIPTION,
"This is test event")
event.put(CalendarContract.Events.EVENT_LOCATION,
"School")
event.put(CalendarContract.Events.DTSTART,
beginTime.getTimeInMillis())
event.put(CalendarContract.Events.DTEND,
beginTime.getTimeInMillis())
event.put(CalendarContract.Events.ALL_DAY,0)
event.put(CalendarContract.Events.RRULE,
"FREQ=YEARLY")
event.put(CalendarContract.Events.EVENT_TIMEZONE,
"Germany")
val retUri = contentResolver.insert(
CalendarContract.Events.CONTENT_URI, event)
Log.e("LOG", retUri.toString())
}
我们没有实现权限查询;权限在第七章中有详细描述。
呼叫日志
这是一个列出已拨和已接呼叫的表格。下面是一个列表示例:
val havePermissions =
ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_CALL_LOG)
== PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(this,
Manifest.permission.WRITE_CALL_LOG)
== PackageManager.PERMISSION_GRANTED
if(!havePermissions) {
// Acquire permissions...
}else {
val uri = CallLog.Calls.CONTENT_URI
val cursor = contentResolver.query(
uri, null, null, null, null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
Log.e("LOG", "New entry:")
for(name in cursor.columnNames) {
val v = cursor.getString(
cursor.getColumnIndex(name))
Log.e("LOG"," > " + name + " = " + v)
}
cursor.moveToNext()
}
}
我们没有实现权限查询;权限在第七章中有详细描述。表格 6-3 描述了表格的栏目。
表 6-3
调用日志表列
|名字
|
描述
|
| --- | --- |
| date
| 自纪元以来的调用日期,以毫秒为单位。 |
| transcription
| 通话或语音邮件条目的转录。 |
| photo_id
| 关联照片的缓存照片 ID。 |
| subscription_component_ name
| 用于发出或接收呼叫的帐户的组件名称。 |
| type
| 呼叫的类型。(在CallLog.Calls
中的常量名称)之一:INCOMING_TYPE``OUTGOING_TYPE``MISSED_TYPE``VOICEMAIL_TYPE``REJECTED_TYPE``BLOCKED_TYPE``ANSWERED_EXTERNALLY_TYPE
|
| geocoded_location
| 与此呼叫关联的号码的地理编码位置。 |
| presentation
| 由网络设置的号码表示规则。CallLog.Calls
中的常量名称之一:PRESENTATION_ALLOWED``PRESENTATION_RESTRICTED``PRESENTATION_UNKNOWN``PRESENTATION_PAYPHONE
|
| duration
| 以秒为单位的呼叫持续时间。 |
| subscription_id
| 用于拨打或接听电话的帐户的标识符。 |
| is_read
| 该项目是否已被用户阅读或消费(0= false
,1= true
)。 |
| number
| 用户输入的电话号码。 |
| features
| 描述呼叫特征的位掩码,由(常量名称在CallLog.Calls
): FEATURES_HD_CALL
组成:呼叫是 HD。FEATURES_PULLED_EXTERNALLY
:呼叫被外部拔出。FEATURES_VIDEO
:通话有视频。FEATURES_WIFI
:通话是 WIFI 通话。 |
| voicemail_uri
| 语音邮件条目的 URI(如果适用)。 |
| normalized_number
| 电话号码的缓存规范化(E164)版本(如果存在)。 |
| via_number
| 对于来话呼叫,是通过其接收呼叫的辅助线路号码。当 SIM 卡有多个相关的电话号码时,该值表示与 SIM 卡相关的号码被呼叫。 |
| matched_number
| 与此项匹配的联系人的缓存电话号码(如果存在)。 |
| last_modified
| 上次插入、更新行或将其标记为已删除的日期。以毫秒为单位。只读。 |
| new
| 呼叫是否已被确认(0= false
,1= true
)。 |
| numberlabel
| 与电话号码相关联的自定义号码类型的缓存号码标签(如果存在)。 |
| lookup_uri
| 缓存的 URI,用于查找与电话号码关联的联系人(如果存在)。 |
| photo_uri
| 与电话号码关联的图片的缓存照片 URI(如果存在)。 |
| data_usage
| 呼叫的数据使用情况,以字节为单位。 |
| phone_account_address
| 无证。 |
| formatted_number
| 缓存的电话号码,使用基于用户拨打或接听电话时所在国家的规则进行格式化。 |
| add_for_all_users
| 无证。 |
| numbertype
| 与电话号码相关联的缓存号码类型(如果适用)。(在CallLog.Calls
中的常量名称)之一:INCOMING_TYPE``OUTGOING_TYPE``MISSED_TYPE``VOICEMAIL_TYPE``REJECTED_TYPE``BLOCKED_TYPE ANSWERED_EXTERNALLY_TYPE
|
| countryiso
| 用户接听或拨打电话的国家/地区的 ISO 3166-1 双字母国家/地区代码。 |
| name
| 与电话号码相关联的缓存名称(如果存在)。 |
| post_dial_digits
| 已拨号码的后拨部分。 |
| transcription_state_id
| 无证。 |
| | 表条目的(技术)ID。 |
联系人合同
这是一份描述电话联系的复杂合同。联系信息存储在三层数据模型中。
-
ContactsContract.Data
:任何种类的个人数据。
-
ContactsContract.RawContacts
:描述一个人的一组数据。
-
ContactsContract.Contacts
:一个人的聚合视图,可能与
RawContacts
表中的几行相关。由于它的聚合特性,它只能部分写入。
还有更多合同相关的表被描述为ContactsContract
的内部类。为了让您入门,我们没有解释联系人内容提供者的所有可能的用例,而是给出了代码,列出了前面列出的三个主表的内容,显示了单个新联系人在那里写了什么,否则请参考ContactsContract
类的在线文档。要列出三个表的内容,请使用以下内容:
fun showTable(tbl:Uri) {
Log.e("LOG", "##################################")
Log.e("LOG", tbl.toString())
val cursor = contentResolver.query(
tbl, null, null, null, null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
Log.e("LOG", "New entry:")
for(name in cursor.columnNames) {
val v = cursor.getString(
cursor.getColumnIndex(name))
Log.e("LOG"," > " + name + " = " + v)
}
cursor.moveToNext()
}
}
...
showTable(ContactsContract.Contacts.CONTENT_URI)
showTable(ContactsContract.RawContacts.CONTENT_URI)
showTable(ContactsContract.Data.CONTENT_URI)
如果您使用 Android 预装的联系人应用创建了一个新联系人,在Contacts
视图表中,您会发现以下新条目(此处仅显示重要的列):
_id = 1
display_name_alt = Mayer, Hugo
sort_key_alt = Mayer, Hugo
has_phone_number = 1
contact_last_updated_timestamp = 1518451432615
display_name = Hugo Mayer
sort_key = Hugo Mayer
times_contacted = 0
name_raw_contact_id = 1
作为表RawContacts
中的一个关联条目,您会发现以下内容:
_id = 1
account_type = com.google
contact_id = 1
display_name_alt = Mayer, Hugo
sort_key_alt = Mayer, Hugo
account_name = pmspaeth1111@gmail.com
display_name = Hugo Mayer
sort_key = Hugo Mayer
times_contacted = 0
account_type_and_data_set = com.google
显然,您会在前面列出的Contacts
视图中发现许多条目。相关联的是Data
表中的零到多个条目(只显示了最重要的)。
Entry:
_id = 3
mimetype = vnd.android.cursor.item/phone_v2
raw_contact_id = 1
contact_id = 1
data1 = (012) 345-6789
Entry:
_id = 4
mimetype = vnd.android.cursor.item/phone_v2
raw_contact_id = 1
contact_id = 1
data1 = (098) 765-4321
Entry:
_id = 5
mimetype = vnd.android.cursor.item/email_v2
raw_contact_id = 1
contact_id = 1
data1 = null
Entry:
_id = 6
mimetype = vnd.android.cursor.item/name
raw_contact_id = 1
contact_id = 1
data3 = Mayer
data2 = Hugo
data1 = Hugo Mayer
Entry:
_id = 7
mimetype = vnd.android.cursor.item/nickname
raw_contact_id = 1
contact_id = 1
data1 = null
Entry:
_id = 8
mimetype = vnd.android.cursor.item/note
raw_contact_id = 1
contact_id = 1
data1 = null
您可以看到,Data
表中的行对应于 GUI 中的编辑字段。您看到两个电话号码,一个名一个姓,没有昵称,也没有电子邮件地址。
文件合同
与我们在这里看到的其他契约不同,这不是一个内容契约。对应的是android.provider.DocumentsProvider
,是android.content.ContentProvider
的子类。我们将在本章的后面讨论文档提供者。
FontsContract
这是一份处理可下载字体的合同,不对应内容供应器。
媒体库
媒体存储处理内部和外部存储设备上所有媒体相关文件的元数据。这包括音频文件、图像和视频。此外,它以一种与用法无关的方式处理文件。这意味着媒体和非媒体文件与媒体文件相关。根类android.provider.MediaStore
本身不包含特定于内容提供者的素材,但是下面的内部类包含:
-
MediaStore.Audio
音频文件。包含更多的音乐专辑、艺术家、音频文件本身、流派和播放列表的内部类。
-
MediaStore.Images
图像。
-
MediaStore.Videos
视频。
-
MediaStore.Files
一般文件。
您可以通过浏览在线 API 文档来研究任何媒体存储表。对于您自己的实验,您可以从整个表开始,注意常量EXTERNAL_CONTENT_URI
和INTERNAL_CONTENT_URI
,或者方法getContentUri()
,然后通过我们之前已经使用过的相同代码发送它们。
showTable(MediaStore.Audio.Media.getContentUri(
"internal")) // <- other option: "external"
fun showTable(tbl:Uri) {
Log.e("LOG", "#######################################")
Log.e("LOG", tbl.toString())
val cursor = contentResolver.query(
tbl, null, null, null, null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
Log.e("LOG", "New entry:")
for(name in cursor.columnNames) {
val v = cursor.getString(
cursor.getColumnIndex(name))
Log.e("LOG"," > " + name + " = " + v)
}
cursor.moveToNext()
}
}
设置
这是一个处理各种全局和系统级设置的内容提供者。以下是 contract 类中作为常量的主要 URIs:
-
Settings.Global.CONTENT_URI
:Global settings. All entries are triples of the following:
-
_id
-
android.provider.Settings.NameValueTable.NAME
-
android.provider.Settings.NameValueTable.VALUE
-
-
Settings.System.CONTENT_URI
:Global system-level settings. All entries are triples of the following:
-
_id
-
android.provider.Settings.NameValueTable.NAME
-
android.provider.Settings.NameValueTable.VALUE
-
-
Settings.Secure.CONTENT_URI
:
这是一个安全的系统设置。应用不允许改变它。所有条目都是以下内容的三元组:
-
_id
-
android.provider.Settings.NameValueTable.NAME
-
android.provider.Settings.NameValueTable.VALUE
为了研究这些表,请看一下android.provider.Settings
的在线 API 文档。它描述了所有可能的设置。要列出完整的设置,您可以使用与前面的ContactsContract
契约类相同的函数。
showTable(Settings.Global.CONTENT_URI)
showTable(Settings.System.CONTENT_URI)
showTable(Settings.Secure.CONTENT_URI)
...
fun showTable(tbl:Uri) {
Log.e("LOG", "##################################")
Log.e("LOG", tbl.toString())
val cursor = contentResolver.query(
tbl, null, null, null, null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
Log.e("LOG", "New entry:")
for(name in cursor.columnNames) {
val v = cursor.getString(
cursor.getColumnIndex(name))
Log.e("LOG"," > " + name + " = " + v)
}
cursor.moveToNext()
}
}
你的应用不需要特殊权限来读取设置。然而,只可能对Global
和System
表进行写操作,并且您还需要一个特殊的构造来获得权限。
if(!Settings.System.canWrite(this)) {
val intent = Intent(
Settings.ACTION_MANAGE_WRITE_SETTINGS)
intent.data = Uri.parse(
"package:" + getPackageName())
startActivity(intent)
}
通常,您通过调用以下命令来获取权限:
ActivityCompat.requestPermissions(this,
arrayOf(Manifest.permission.WRITE\_SETTINGS), 42)
然而,当设置权限时,这个请求会被当前的 Android 版本立即拒绝。因此,您不能使用它,而是需要调用前面所示的 intent。
要访问某个条目,可以再次使用 contract 类中的常量和方法。
val uri = Settings.System.getUriFor(
Settings.System.HAPTIC_FEEDBACK_ENABLED)
Log.e("LOG", uri.toString())
val feedbackEnabled = Settings.System.getInt(
contentResolver,
Settings.System.HAPTIC_FEEDBACK_ENABLED)
Log.e("LOG", Integer.toString(feedbackEnabled))
Settings.System.putInt(contentResolver,
Settings.System.HAPTIC_FEEDBACK_ENABLED, 0)
警告
虽然可以为某个设置获取单独的 URI,但是您不应该使用ContentResolver.update()
、ContentResolver.insert()
和ContentResolver.delete()
方法来改变值。相反,请使用 contract 类提供的方法。
SyncStateContract
浏览器应用、联系人应用和日历应用使用此合约来帮助将用户数据与外部服务器同步。
用户词典
这是指允许您管理和使用基于词典的预测输入的内容供应器。从 API level 23 开始,用户词典只能在输入法编辑器或拼写检查框架中使用。对于现代应用,你不应该试图从另一个地方使用它。因此,该合同仅起信息作用。
语音邮件合同
该合同允许访问涉及语音邮件供应器的信息。它主要由两个由内部类描述的表组成。
VoicemailContract.Status
一个语音邮件源应用使用这个契约来告诉系统它的状态。
-
VoicemailContract.Voicemails
这包含实际的语音邮件。
您可以列出这些表格的内容。例如,对于Voicemails
表,编写以下内容:
val uri = VoicemailContract.Voicemails.CONTENT_URI.
buildUpon().
appendQueryParameter(
VoicemailContract.PARAM_KEY_SOURCE_PACKAGE,
packageName)
.build()
showTable(uri)
fun showTable(tbl:Uri) {
Log.e("LOG", "####################################")
Log.e("LOG", tbl.toString())
val cursor = contentResolver.query(
tbl, null, null, null, null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
Log.e("LOG", "New entry:")
for(name in cursor.columnNames) {
val v = cursor.getString(
cursor.getColumnIndex(name))
Log.e("LOG"," > " + name + " = " + v)
}
cursor.moveToNext()
}
}
添加VoicemailContract.PARAM_KEY_SOURCE_PACKAGE
URI 参数很重要;否则,您会得到一个安全异常。
批量访问内容数据
android.content.ContentProvider
类允许您的实现使用以下内容:
applyBatch(
operations: ArrayList<ContentProviderOperation>):
Array<ContentProviderResult>
默认实现遍历列表并依次执行每个操作,但是您也可以重写方法以使用自己的逻辑。参数中提供的ContentProviderOperation
对象描述了要执行的操作。它可以是更新、删除和插入之一。
为了方便起见,该类提供了一个生成器,您可以按如下方式使用它:
val oper:ContentProviderOperation =
ContentProviderOperation.newInsert(uri)
.withValue("key1", "val1")
.withValue("key2", 42)
.build()
保护内容
从您在AndroidManifest.xml
中声明一个内容提供者并通过将其exported
属性设置为true
来导出它的那一刻起,其他应用就被允许访问该提供者公开的完整内容。
这可能不是您想要的敏感信息。作为一种补救措施,要对内容或部分内容施加限制,可以向<provider>
元素或其子元素添加与权限相关的属性。
您基本上有以下选择:
-
通过一个标准保护所有内容
To do so, use the
permission
attribute of<provider>
as follows:<provider ... android:permission="PERMISSION-NAME" ... > ... </provider>
Here,
PERMISSION-NAME
is a system permission or a permission you defined in the<permission>
element of the app. If you do it that way, the complete content of the provider is accessible only to such clients that successfully acquired exactly this permission. More precisely, any read or write access requires clients to have this permission. If you need to distinguish between read permission and write permission, you can instead use thereadPermission
andwritePermission
attributes. If you use a mixture, the more specific attributes win.-
permission = A ® writePermission = A, readPermission = A
-
permission = A, readPermission = B ® writePermission = A, readPermission = B
-
permission = A, writePermission = B ® writePermission = B, readPermission = A
-
permission = A, writePermission = B, readPermission = C ® writePermission = B, readPermission = C
-
-
保护特定的 URI 路径
By using the
<path-permission>
subelement of<provider>
, you can impose restrictions on specific URI paths.<path-permission android:path="string" android:pathPrefix="string" android:pathPattern="string" android:permission="string" android:readPermission="string" android:writePermission="string" />
在
*permission
属性中,您指定权限名称和权限范围,就像前面描述的通过一个标准保护所有内容一样。对于路径规范,您可以使用三个可能的属性中的一个:path
用于精确的路径匹配,path- Prefix
用于匹配路径的开头,pathPattern
允许通配符(X*
用于任意字符的零到多次出现,.*
用于任意字符的零到多次出现)。因为可以使用几个<path-permission>
元素,所以可以在内容提供者中构建细粒度的权限结构。 -
许可豁免
By using the
grantUriPermission
attribute of the<provider>
element, you can temporarily grant permissions to components called by intent from the app that owns the content provider. If you setgrantUriPermission
totrue
and the intent for calling the other component gets constructed using the help of this:intent.addFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION) /*or*/ intent.addFlags( Intent.FLAG_GRANT_WRITE_URI_PERMISSION) /*or*/ intent.addFlags( Intent.FLAG_GRANT_WRITE_URI_PERMISSION and Intent.FLAG_GRANT_READ_URI_PERMISSION)
then the called component will have full access to all content of the provider. You can instead set
grantUriPermission
tofalse
and add subelements.<grant-uri-permission android:path="string" android:pathPattern="string" android:pathPrefix="string" />
然后,您以更细粒度的方式控制豁免。为了让这两者都有意义,显然必须让由*permission
属性设置的限制生效;否则,你没有什么可以豁免的。元素属性的规则如前所述:path
用于精确的路径匹配,pathPrefix
用于匹配路径的起点,pathPattern
允许通配符(X*
用于任意字符 X 的零到多次出现,.*
用于任意字符的零到多次出现)。
为搜索框架提供内容
Android 搜索框架为用户提供了一个功能,可以通过任何方式和使用任何数据源来搜索任何可用的数据。我们将在第八章讨论搜索框架;目前,重要的是要知道内容供应器在以下方面发挥的作用:
-
最近的查询建议
-
自定义建议
对于这两者,您提供特殊的内容提供者子类,并将它们作为任何其他内容提供者添加到AndroidManifest.xml
。
文档供应器
文档提供程序是存储访问框架(SAF)的一部分。它允许以文档为中心的数据访问视图,还展示了文档目录的分层超结构。
注意
SAF 包含在 API 级中。截至 2018 年 2 月,超过 90%的活跃 Android 设备都使用该版本。在此之前,您不能将 SAF 用于设备,但是如果您真的需要涵盖剩余的 10%,您仍然可以将文档作为由内容提供者协调的普通内容来提供,并提取出 SAF 和遗留提供者都可以使用的代码。
文档提供者的主要思想是,你的应用提供对文档的访问,无论相应的数据存储在哪里,并且不关心文档和文档结构如何呈现给用户或其他应用。文档提供者数据模型由一个到多个从根节点开始的树组成,子节点或者是文档,或者是跨越子树的目录,还有其他目录和文档。因此,它类似于文件系统中的数据结构。
从文档提供者开始,创建一个实现android.provider.DocumentsProvider
的类,它本身是android.content.ContentProvider
的一个专门化子类。至少,您必须实现这些方法:
override fun onCreate(): Boolean
:
使用它来初始化文档提供程序。因为这是在应用的主线程上运行的,所以你不能在这里执行冗长的操作。但是您可以准备对提供者的数据访问。如果提供者加载成功,则返回true
,否则返回false
。
-
override fun queryRoots(projection: Array<out String>?): Cursor
:This is supposed to query the roots of the provider’s data structure. In many cases, the data will fit into one tree, and you thus need to provide just one root, but you can have as many roots as makes sense for your requirements. The
projection
argument may present a list of columns to be included in the result set. The names are the same as theCOLUMN_*
constants insideDocumentsContract.Root
. It may benull
, which means return all columns. The method must return cursors with at a maximum the following fields (shown are the constant names fromDocumentsContract.Root
):-
COLUMN_AVAILABLE_BYTES
(long):根下可用字节。可选,可以是null
表示unknown
。 -
COLUMN_CAPACITY_BYTES
(long):该根处的树的容量,以字节为单位。想想文件系统的容量。可选,可以是null
表示unknown
。 -
COLUMN_DOCUMENT_ID
:该根对应的目录的 ID(字符串)。必需的。 -
COLUMN_FLAGS
:应用于根的标志(int)。(在DocumentsContract.Root
中的常数)的组合:-
FLAG_LOCAL_ONLY
(设备本地,无网络接入), -
FLAG_SUPPORTS_CREATE
(根下至少有一个文档支持创建内容) -
FLAG_SUPPORTS_RECENTS
(可以查询 root 来显示最近更改的文档) -
FLAG_SUPPORTS_SEARCH
(该树允许搜索文档)
-
-
COLUMN_ICON
(int):根的图标资源 ID。必选。 -
COLUMN_MIME_TYPES
(字符串):支持的 MIME 类型。如果不止一个,使用换行符\n
作为分隔符。可选的,可能是null
来表示支持所有的 MIME 类型。 -
COLUMN_ROOT_ID
(字符串):根的唯一 ID。必选。 -
COLUMN_SUMMARY
(字符串):该根的摘要;可能会显示给用户。可选,可以是null
表示“未知” -
COLUMN_TITLE
(字符串):根的标题,可能会显示给用户。必选。
-
如果这组根改变了,你必须用DocumentsContract.buildRootsUri
调用ContentResolver.notifyChange
来通知系统。
-
override fun queryChildDocuments(parentDocumentId: String?, projection: Array<out String>?, sortOrder: String?): Cursor
:Return the immediate children documents and subdirectories contained in the requested directory. Apps targeting at API level 26 or higher should instead implement
fun queryChildDocuments(parentDocumentId: String?, projection: Array<out String>?, queryArgs: Bundle?): Cursor
and in this method use the following:override fun queryChildDocuments( parentDocumentId: String?, projection: Array<out String>?, sortOrder: String?): Cursor { val bndl = Bundle() bndl.putString( ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder) return queryChildDocuments( parentDocumentId, projection, bndl) }
-
override fun queryChildDocuments(parentDocumentId: String?, projection: Array<out String>?, queryArgs: Bundle?): Cursor
:Return the immediate children documents and subdirectories contained in the requested directory. The bundle argument contains query parameters as keys.
ContentResolver.QUERY_ARG_SQL_SELECTION ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS ContentResolver.QUERY_ARG_SQL_SORT_ORDER -or- ContentResolver.QUERY_ARG_SORT_COLUMNS (this being a String array)
parentDocumentId
是我们想要列出的目录的 ID,在projection
中,您可以指定应该返回的列。使用来自DocumentsContract.Document
的常量COLUMN_*
列表。或者写null
返回所有栏目。结果Cursor
最大返回以下字段(关键字是DocumentsContract.Document
中的常量):
-
COLUMN_DISPLAY_NAME
(字符串):文档的显示名称,用作向用户显示的主标题。必选。 -
COLUMN_DOCUMENT_ID
(字符串):单据的唯一标识。必选。 -
COLUMN_FLAGS
:文档的标志。(来自DocumentsContract.Document
的常量名称)的组合:-
FLAG_SUPPORTS_WRITE
(支持写作) -
FLAG_SUPPORTS_DELETE
(支持删除) -
FLAG_SUPPORTS_THUMBNAIL
(支持缩略图表示) -
FLAG_DIR_PREFERS_GRID
(对于目录,如果它们应该显示为网格) -
FLAG_DIR_PREFERS_LAST_MODIFIED
(对于目录,优先按“最后修改时间”排序) -
FLAG_VIRTUAL_DOCUMENT
(没有 MIME 类型的虚拟文档) -
FLAG_SUPPORTS_COPY
(支持复制) -
FLAG_SUPPORTS_MOVE
(在树内移动,被支撑) -
FLAG_SUPPORTS_REMOVE
(从层级结构中移除,不删除,支持)
-
-
COLUMN_ICON
(int):文档的特定图标资源 ID。可能是null
使用系统默认。 -
COLUMN_LAST_MODIFIED
(long):上次修改文档的时间戳,从 UTC 1970 年 1 月 1 日 00:00:00.0 开始,以毫秒为单位。必需,但如果未定义,可能是null
。 -
COLUMN_MIME_TYPE
(字符串):文档的 MIME 类型。必选。 -
COLUMN_SIZE
(long):文档的大小,以字节为单位,如果未知,则为null
。必选。 -
COLUMN_SUMMARY
(字符串):一个文档的摘要;可以显示给用户。可选,可能是null
。
对于与网络相关的操作,您可以部分返回数据,并在Cursor
上设置DocumentsContract.EXTRA_LOADING
,以表明您仍在获取额外的数据。然后,当网络数据可用时,您可以发送更改通知来触发重新查询并返回完整的内容。为了支持变更通知,您必须用一个相关的 URI 来触发Cursor.setNotificationUri()
,可能是从DocumentsContract.buildChildDocumentsUri()
开始。然后你可以用那个 URI 打电话给ContentResolver.notifyChange()
发送变更通知。
fun openDocument(documentId: String?, mode: String?, signal: CancellationSignal?): ParcelFileDescriptor
:
打开并返回请求的文档。这应该会返回一个可靠的ParcelFileDescriptor
来检测远程调用者何时读或写完了文档。如果您在下载内容时阻止,您应该定期检查CancellationSignal.isCanceled()
以中止放弃的打开请求。对于要返回的文档,参数为documentId
。mode
指定“打开”模式,如r
、w
或rw
。应始终支持模式r
。如果不支持传递模式,提供者应该抛出UnsupportedOperationException
。如果模式是排他的r
或w
,您可以返回一个管道或套接字对,但是像rw
这样的复杂模式意味着磁盘上有一个支持查找的普通文件。如果请求被取消,调用方可以使用signal
。可能是null
。
override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor
:
返回单个请求文档的元数据。参数是用于返回文档 ID 的documentId
和用于放入光标的列列表的projection
。使用来自DocumentsContract.Document
的常量。列表见queryChildDocuments()
方法描述。如果在这里使用null
,所有的列都将被返回。
在文件AndroidManifest.xml
中,您可以像注册任何其他提供者一样注册文档提供者。
<provider
android:name="com.example.YourDocumentProvider"
android:authorities="com.example.documents"
android:exported="true"
android:grantUriPermissions="true"
android:permission=
"android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name=
"android.content.action.DOCUMENTS_PROVIDER"/>
</intent-filter>
</provider>
在前面的查询中,我们已经看到,Cursor
对象返回标志来表示最近的文档和在树中的搜索应该得到支持。为此,您必须在您的DocumentsProvider
实现中再实现一两个方法。
-
override fun queryRecentDocuments(rootId: String, projection: Array<String>): Cursor
:这应该会返回请求的根目录下最近修改的文档。返回的文档要按
COLUMN_LAST_MODIFIED
降序排列,最多显示 64 个条目。最近的文档不支持更改通知。 -
querySearchDocuments(rootId: String, query: String, projection: Array<String>): Cursor
:这应该会返回与所请求的根下的给定查询相匹配的文档。返回的文档应该按照相关性降序排列。对于慢速查询,您可以返回部分数据,并在游标上设置
EXTRA_LOADING
以指示您正在获取额外的数据。然后,当数据可用时,您可以发送更改通知来触发重新查询并返回完整的内容。为了支持变更通知,您必须将setNotificationUri(ContentResolver, Uri)
与相关的Uri
一起使用,可能来自buildSearchDocumentsUri(String, String, String)
。然后您可以用那个Uri
调用方法notifyChange(Uri, android.database.ContentObserver, boolean)
来发送变更通知。
一旦您的文档提供者被配置并运行,客户端组件就可以使用ACTION_OPEN_DOCUMENT
或ACTION_CREATE_DOCUMENT
意图来打开或创建文档。Android 系统选择器将负责向用户呈现适当的文档;您不必为您的文档提供者提供自己的 GUI。
下面是这种客户端访问的一个示例:
// An integer you can use to identify that call when the
// called Intent returns
val READ_REQUEST_CODE = 42
// ACTION_OPEN_DOCUMENT used in this example is the
// intent to choose a document like for example a file
// file via the system's file browser.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
// Filter to only show results that can be "opened", such
// as a file (as opposed to a list of informational items
)
intent.addCategory(Intent.CATEGORY_OPENABLE)
// You can use a filter to for example show only images.
// To search for all documents instead, you can use "*/*"
// here.
intent.type = "image/*"
// The actual Intent call - the system will provide the
// GUI
startActivityForResult(intent, READ_REQUEST_CODE)
一旦从系统选择器中选择了一个项目,为了捕捉意图返回,您应该编写如下内容:
override fun onActivityResult(requestCode:Int,
resultCode:Int,
resultData:Intent) {
// The ACTION_OPEN_DOCUMENT intent was sent with the
// request code READ_REQUEST_CODE. If the request
// code seen here doesn't match, it's the
// response to some other intent, and the code below
// shouldn't run at all.
if (requestCode == READ_REQUEST_CODE
&& resultCode == Activity.RESULT_OK) {
// The document selected shows up in
// intent.getData()
val uri = resultData.data
Log.i("LOG", "Uri: " + uri.toString())
showImage(uri) // Do s.th. with it
}
}
除了打开示例中所示的文件,您还可以对 intent 返回中收到的 URI 做其他事情。例如,您还可以发出一个查询来获取元数据,如前面的查询方法所示。由于DocumentsProvider
继承自ContentProvider
,您可以使用前面描述的方法为文档的字节打开一个流。
七、权限
保护敏感数据是应用开发过程中的一项重要任务。随着越来越多的手持设备上的应用被用于银行等敏感的日常任务,安全性变得越来越重要,并且在未来将继续如此。作为开发人员,你必须采取一切可能的预防措施,负责任地处理你的应用用户的数据。
完全涵盖每个可能的安全方面是一项具有挑战性的任务,它本身将会写满一整本书。幸运的是,有大量的在线资源可以让你了解 Android 操作系统安全问题的最新进展。只是要谨慎过滤掉不合适的信息。Android 操作系统在线资源中的以下安全相关主题是一个很好的起点:
https://developer.android.com/training/best-security.html
https://developer.android.com/training/best-permissions-ids.html
如果这些链接在你阅读这本书时被破坏了,在你最喜欢的搜索引擎中搜索 android 最佳实践安全和 android 最佳实践权限,你会很容易找到这些资源。
话虽如此,我们仍然希望彻底解决 Android 操作系统中的权限系统,因为一旦你的应用处理敏感数据,这绝对是你作为一名开发人员必须有宾至如归的感觉的地方。权限增加了系统数据和功能的安全性;您可以使用预定义的权限,自己定义它们,或者通过在AndroidManifest.xml
中编写适当的条目来声明它们。
权限类型
根据所需的保护级别,权限有几种类型。
-
正常:该级别对应低级别安全敏感信息。系统会自动授予此类权限,无需明确询问用户,但权限列在包描述中,可以使用系统设置 app 明确需求查询。
-
危险:该级别对应高级安全敏感信息。用户将被询问是否允许使用该权限。一旦允许某个应用,将会保存该权限,并且不会再次询问用户,直到重新安装该应用或使用“系统设置”应用明确撤销权限。
-
签名:该级别对应的是极高级别的安全敏感信息。只有使用与定义权限的应用相同的证书签名的应用才能获取它。系统将检查签名是否匹配,然后自动授予权限。这个级别只对同一个开发者开发的应用集合有意义。
-
特殊:对于一些用例,系统仅通过带外采集方法授予对某些系统资源的访问权。也就是说,对于权限
SYSTEM_ALERT_WINDOW
和WRITE_SETTINGS
,您必须在清单中声明它们,并且调用特殊意图来获取它们。你给SYSTEM_ALERT_WINDOW
用的意向动作是Settings.ACTION_MANAGE_OVERLAY_PERMISSION
,给WRITE_SETTINGS
用的意向动作是Settings.ACTION_MANAGE_WRITE_SETTINGS
。只有在绝对必要的情况下,你的应用才应该使用这两个。 -
特权或系统专用:这些用于系统映像应用。你不应该使用它们。
权限集中在权限组中。其思想是,一旦用户接受了来自 G1 组的权限 A 的权限请求,就不需要对同一 G1 组的另一权限 B 进行另一权限查询。从用户体验的角度来看,只有当我们谈论危险类型的权限时,权限组才显示出效果;普通权限的权限组没有影响。
注意
权限到权限组的映射可能会随着 Android 的未来版本而改变。因此你的应用不应该依赖这样的映射。从开发的角度来看,您应该忽略权限组,除非您定义了自己的权限和权限组。
定义权限
Android 操作系统包括许多由各种内置应用或操作系统本身定义的权限。此外,作为开发人员,您可以定义自己的权限来保护应用或部分应用。
至于内置权限,是由系统定义的,如果你的 app 需要其中的一个或几个,你就声明使用它们(参见在线文字伴侣的“权限”部分)。然后,系统将根据保护级别决定如何处理这些权限请求。如果你的应用将敏感信息暴露给其他应用或系统,并且它不是由应用使用的权限处理的,你可以在AndroidManifest.xml
中定义你自己的权限。
<permission android:description="string resource"
android:icon="drawable resource"
android:label="string resource"
android:name="string"
android:permissionGroup="string"
android:protectionLevel=["normal" | "dangerous" |
"signature" | "signatureOrSystem"] />
在线文本指南的“清单顶级条目”一节中介绍了这些属性的含义。最起码,您必须提供name
和protectionLevel
属性,但是添加一个标签、图标和描述来帮助您的用户理解权限的作用无疑是一个好主意。
如果需要对权限进行分组,可以使用两种方法之一。
-
使用
<permission-group>
元素并将permissionGroup
属性添加到<permission>
;请参阅联机文本指南中的“清单顶级条目”一节。 -
使用
<permission-tree>
元素并相应地命名您的权限;请参见联机文本指南中的“清单顶级条目”一节。
如果您随后获得了某个组的权限,则来自同一组的同级权限将隐式包含在授予中。
警告
为了遵守安全准则,并使你的应用设计清晰稳定,请将你自己定义的权限数量保持在最低限度。
使用权限
要使用权限,在您的AndroidManifest.xml
文件中添加一个或多个,如下所示:
<uses-permission android:name="string"
android:maxSdkVersion="integer" />
或者,如果您需要为 API 级别 23 或更高(Android 6.0)指定权限,请使用:
<uses-permission-sdk-23 android:name="string"
android:maxSdkVersion="integer" />
在这两种情况下,name
属性指定了权限名称,而maxSdkVersion
是该权限需求将考虑的最大 API 级别。这个特殊的<uses-permission-sdk23>
元素来自 Android 6.0 权限语义的一个重大变化。如果您不在乎这种区别,就省略maxSdkVersion
属性。
问题是,我们如何知道我们的应用到底需要哪些权限?答案有三个部分。
图 7-1
Android Studio 告诉你一个权限要求
-
Android Studio 告诉你你的应用需要一个权限。例如,如果你写了以下代码,Android Studio 会告诉你需要一定的权限(图 7-1 ):
val uri = CallLog.Calls.CONTENT_URI val cursor = contentResolver.query( uri, null, null, null, null)
-
在开发和测试期间,你的应用崩溃了,在日志中你会看到这样一个条目:
Caused by: java.lang.SecurityException: Permission Denial: opening provider com.android.providers.contacts.CallLogProvider from ProcessRecord{faeda9c 4127:de.pspaeth.cp1/u0a96} (pid=4127, uid=10096) requires android.permission.READ_CALL_LOG or android.permission.WRITE_CALL_LOG
-
系统权限列表告诉您,您需要特定任务的特定权限。见表 7-1 。
一旦在顶层元素<uses-permission>
中声明了权限的用法,它仍然必须连接到应用的组件。如果您想将权限一次连接到所有组件,这可以发生在<application>
元素内部,或者更好的是,基于每个组件。在这两种情况下,都要在permission
属性中声明权限,如下所示:
表 7-1
系统权限
|同意
|
组
|
描述
|
| --- | --- | --- |
| READ_CALENDAR
| CALENDAR
| 允许阅读日历。清单条目:android.permission.READ_CALENDAR
|
| WRITE_CALENDAR
| CALENDAR
| 允许写日历。清单条目:android.permission.READ_CALENDAR
|
| CAMERA
| CAMERA
| 允许访问摄像机。清单条目:android.permission.CAMERA
|
| READ_CONTACTS
| CONTACTS
| 从联系人表中读取。清单条目:android.permission.READ_CONTACTS
|
| WRITE_CONTACTS
| CONTACTS
| 写入联系人表。清单条目:android.permission.WRITE_CONTACTS
|
| GET_ACCOUNTS
| CONTACTS
| 允许从帐户服务中列出帐户。清单条目:android.permission.GET_ACCOUNTS
|
| ACCESS_FINE_LOCATION
| LOCATION
| 允许应用访问细粒度的位置。清单条目:android.permission.ACCESS_FINE_LOCATION
|
| ACCESS_COARSE_LOCATION
| LOCATION
| 允许应用访问大致位置。清单条目:android.permission.ACCESS_COARSE_LOCATION
|
| RECORD_AUDIO
| MICROPHONE
| 允许录制音频。清单条目:android.permission.RECORD_AUDIO
|
| READ_PHONE_STATE
| PHONE
| 允许读取电话状态(设备的电话号码、当前蜂窝网络信息、任何正在进行的呼叫的状态以及设备上注册的任何PhoneAccounts
的列表)。清单条目:android.permission.READ_PHONE_STATE
|
| READ_PHONE_NUMBERS
| PHONE
| 读取访问设备的电话号码。清单条目:android.permission.READ_PHONE_NUMBERS
|
| CALL_PHONE
| PHONE
| 允许应用不通过拨号器用户界面发起电话呼叫。清单条目:android.permission.CALL_PONE
|
| ANSWER_PHONE_CALLS
| PHONE
| 允许应用接听来电。清单条目:android.permission.ANSWER_PHONE_CALLS
|
| READ_CALL_LOG
| PHONE
| 允许从呼叫日志表中读取。清单条目:android.permission.READ_CALL_LOG
|
| WRITE_CALL_LOG
| PHONE
| 允许写入呼叫日志表。清单条目:android.permission.WRITE_CALL_LOG
|
| ADD_VOICEMAIL
| PHONE
| 允许添加语音邮件。清单条目:com.android.voicemail.permission.ADD_VOICEMAIL
|
| USE_SIP
| PHONE
| 允许使用 SIP 服务。清单条目:android.permission.USE_SIP
|
| PROCESS_OUTGOING_CALLS
| PHONE
| 允许应用在拨出呼叫时看到正在拨打的号码,并可以选择将呼叫重定向到不同的号码或中止呼叫。清单条目:android.permission.PROCESS_OUTGOING_CALLS
|
| BODY_SENSORS
| SENSORS
| 允许应用访问来自传感器的数据,用户使用这些数据来测量他们体内发生的情况。清单条目:android.permission.BODY_SENSORS
|
| SEND_SMS
| SMS
| 允许发送短信。清单条目:android.permission.SEND_SMS
|
| RECEIVE_SMS
| SMS
| 允许接收短信。清单条目:android.permission.RECEIVE_SMS
|
| READ_SMS
| SMS
| 允许阅读短信。清单条目:android.permission.READ_SMS
|
| RECEIVE_WAP_PUSH
| SMS
| 允许接收 WAP 服务信息。清单条目:android.permission.RECEIVE_WAP_PUSH
|
| RECEIVE_MMS
| SMS
| 允许接收彩信。清单条目:android.permission.RECEIVE_MMS
|
| READ_EXTERNAL_STORAGE
| STORAGE
| 允许从外部存储器读取。仅当 API 水平低于 19 时才需要。清单条目:android.permission.READ_EXTERNAL_STORAGE
|
| WRITE_EXTERNAL_STORAGE
| STORAGE
| 允许写入外部存储器。仅当 API 水平低于 19 时才需要。清单条目:WRITE_EXTERNAL_STORAGE
|
...
<activity android:name=
"com.example.myapp.ExampleActivity"
android:permission=
"com.eample.myapp.abcPermission"/>
...
获取权限
Android 操作系统处理权限的方式已经改变。在 Android 6.0(API 23 级)之前,询问用户的权限询问发生在安装过程中。从 API 级别 23 开始,发生了一个范式变化:权限查询发生在应用运行时。这使得权限系统更加灵活;你的应用的用户可能永远不会使用它的某些部分,因此请求允许这样做可能会惹恼他们。
这种方法的缺点是需要更多的编程工作。运行时权限查询必须包含在您的代码中。为此,在需要权限之前的任何合适的地方,添加以下内容:
val activity = this
val perm = Manifest.permission.CAMERA
val cameraPermReturnId = 7239 // any suitable constant
val permissionCheck = ContextCompat.checkSelfPermission(
activity, perm)
if (permissionCheck !=
PackageManager.PERMISSION_GRANTED) {
// Should we show an explanation?
if (ActivityCompat.
shouldShowRequestPermissionRationale(
activity, perm)) {
// Show an explanation to the user
// *asynchronously* -- don't block
// this thread waiting for the user's
// response! After the user sees the
// explanation, try again to request
// the permission.
val dialog = AlertDialog.Builder(activity) ...
.create()
dialog.show()
} else {
// No explanation needed, we can request
// the permission.
ActivityCompat.requestPermissions(activity,
arrayOf(perm), cameraPermReturnId)
// cameraPermReturnId is an app-defined
// int constant. The callback method gets
// the result of the request.
}
}
该代码执行以下操作:
-
首先,我们检查权限是否已经被授予。如果之前授予了权限,则不会再次询问用户,除非重新安装应用或明确撤销权限。
-
ActivityCompat.shouldShowRequestPermissionRationale()
方法检查是否应该向用户显示基本原理。这背后的想法是,如果用户拒绝权限查询请求几次,他们可能已经这样做了,因为对权限的需求没有得到很好的理解。在这种情况下,应用有机会告诉用户更多关于权限需求的信息。shouldShowRequestPermissionRationale()
返回true
的频率取决于 Android 操作系统。这里的例子显示了一个对话;您当然可以在这里做任何您想做的事情来通知用户。 -
ActivityCompat.requestPermissions(...)
方法最后执行权限查询。这是异步发生的,所以调用会立即返回。
一旦对ActivityCompat.requestPermissions(...)
的调用发生,用户就会被 Android 操作系统在你的应用之外询问他们是否想要授予权限。其结果将显示在异步回调方法中,如下所示:
override
fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>,
grantResults: IntArray) {
when (requestCode) {
cameraPermReturnId -> {
// If request is canceled, the result
// arrays are empty. Here we know it just
// can be one entry
if ((grantResults.isNotEmpty()
&& grantResults[0] ==
PackageManager.PERMISSION_GRANTED)) {
// permission was granted
// act accordingly...
} else {
// permission denied
// act accordingly...
}
return
}
// Add other 'when' lines to check for other
// permissions this App might request.
else -> {
// Ignore all other requests.
// Or whatever makes sense to you.
}
}
}
这个方法需要在一个android.content.Activity
类中实现。在其他情况下,这是不可能的。
获取特殊权限
在某些情况下使用ActivityCompat.requestPermissions()
不足以获得权限SYSTEM_ALERT_WINDOW
和WRITE_SETTINGS
。对于这两种权限,您需要遵循不同的方法。
API 等级 23 及更高等级的权限WRITE_SETTINGS
必须通过以下特殊方式获得:
val backFromSettingPerm = 6183 // any suitable constant
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val activity = this
if (!Settings.System.canWrite(activity)) {
// This is just a suggestion: present a special
// dialog to the user telling about the special
// permission. Important is the Activity start
AlertDialog dialog =
new AlertDialog.Builder(activity)
.setTitle(...)
.setMessage(...)
.setPositiveButton("OK", { dialog, id ->
val intent = Intent(
Settings.ACTION_MANAGE_WRITE_SETTINGS)
intent.data = Uri.parse("package:" +
getPackageName())
activity.startActivityForResult(intent,
backFromSettingPerm)
}).setNegativeButton("Cancel",
{ dialog, id ->
// ...
})
.create();
dialog.show();
systemWillAsk = true;
}
} else {
// do as with any other permissions...
}
一旦达到这个目的,回调方法onActivityResult()
就可以用来继续 GUI 流。
override
protected fun onActivityResult(requestCode:Int,
resultCode:Int, data:Intent) {
if ((requestCode and 0xFFFF) == backFromSettingPerm) {
if (resultCode == Activity.RESULT_OK) {
// act accordingly...
}
}
}
对于SYSTEM_ALERT_WINDOW
权限,您必须遵循相同的方法,但是使用ACTION_MANAGE_OVERLAY_PERMISSION
来创建内容。
注意
对于这种特殊的SYSTEM_ALERT_WINDOW
权限,如果应用是从 Google Play 商店安装的,并且 API 级别为 23 或更高,Google Play 商店将自动授予该权限。对于本地开发和测试,您必须使用所描述的意图。
功能要求和权限
在第二章中,我们看到通过AndroidManifest.xml
中的<uses-feature>
元素,你可以指定你的应用将使用哪些特性。这些信息对于 Google Play 商店了解您的应用发布后可以在哪些设备上运行非常重要。然而,如果您指定了这个需求,还有另一个重要的方面需要考虑:这些需求将隐含哪些权限,以及如何根据所使用的 API 级别来处理这些权限?
特性常量和 API 级别并不一定严格相关。比如在 API level 8 中加入了android.hardware.bluetooth
功能,但是在 API level 5 中加入了相应的蓝牙 API。正因为如此,一些应用在能够使用<uses-feature>
声明来声明它们需要 API 之前就已经能够使用 API 了。为了弥补这种差异,Google Play 假设某些硬件相关的权限表明默认情况下需要底层硬件功能。例如,使用蓝牙的应用必须请求在<uses-permission>
元素中的BLUETOOTH
权限,对于针对旧 API 级别的应用,Google Play 假设权限声明意味着应用需要底层的android.hardware.bluetooth
功能。表 7-2 列出了隐含这些特性要求的权限。
请注意,<uses-feature>
声明优先于表 7-2 中权限所隐含的特性。对于这些权限中的任何一个,您都可以通过在一个具有android:required=”false”
属性的<uses-feature>
元素中显式声明隐含特性来禁用基于隐含特性的过滤。例如,要禁用任何基于CAMERA
权限的过滤,您可以将它添加到清单文件中:
表 7-2
暗示功能要求的权限
|种类
|
同意...
|
...隐含特征
|
| --- | --- | --- |
| 蓝牙 | BLUETOOTH
| android.hardware.bluetooth
|
| BLUETOOTH_ADMIN
| android.hardware.bluetooth
|
| 照相机 | CAMERA
| android.hardware.camera
和android.hardware.camera.autofocus
|
| 位置 | ACCESS_MOCK_LOCATION
| android.hardware.location
|
| ACCESS_LOCATION_EXTRA_COMMANDS
| android.hardware.location
|
| INSTALL_LOCATION_PROVIDER
| android.hardware.location
|
| ACCESS_COARSE_LOCATION
| android.hardware.locationandroid.hardware.location.network
(API 等级< 21) |
| ACCESS_FINE_LOCATION
| android.hardware.locationandroid.hardware.location.gps
(API 等级< 21) |
| 麦克风 | RECORD_AUDIO
| android.hardware.microphone
|
| 电话 | CALL_PHONE
| android.hardware.telephony
|
| CALL_PRIVILEGED
| android.hardware.telephony
|
| MODIFY_PHONE_STATE
| android.hardware.telephony
|
| PROCESS_OUTGOING_CALLS
| android.hardware.telephony
|
| READ_SMS
| android.hardware.telephony
|
| RECEIVE_SMS
| android.hardware.telephony
|
| RECEIVE_MMS
| android.hardware.telephony
|
| RECEIVE_WAP_PUSH
| android.hardware.telephony
|
| SEND_SMS
| android.hardware.telephony
|
| WRITE_APN_SETTINGS
| android.hardware.telephony
|
| WRITE_SMS
| android.hardware.telephony
|
| 无线网络 | ACCESS_WIFI_STATE
| android.hardware.wifi
|
| CHANGE_WIFI_STATE
| android.hardware.wifi
|
| CHANGE_WIFI_MULTICAST_STATE
| android.hardware.wifi
|
<uses-feature android:name="android.hardware.camera"
android:required="false" />
使用终端的权限处理
要查看您在设备上注册的权限,您可以浏览“系统设置”应用中的应用列表,或者更简单地使用 ADB shell 在终端中获取各种与权限相关的信息。
为此,通过 USB 将硬件设备连接到您的笔记本电脑或 PC,打开终端,cd
到 SDK 安装中的platform-tools
文件夹,在./adb devices
中找到您的设备,然后输入以下内容:
./adb shell -s <DEVICE-NAME>
如果只有一个设备,可以省略-s
开关。
一旦进入 shell,您可以使用几个命令来获取权限信息。首先,您可以列出通过以下方式安装的所有软件包:
cmd package list package
若要显示所有危险的权限,查看特定包的权限状态,或者授予或撤销一个或多个权限,可以使用以下内容:
cmd package list permissions -d -g
dumpsys package <PACKAGE-NAME>
pm [grant|revoke] <PERMISSION-NAME> ...
注意
当前版本的dumpsys
将显示请求的和授予的权限。不要被关于此事的旧博客条目所迷惑。
八、API
本章的主题是介绍 API,它是你的应用的基石。这些 API 包括以下内容:
-
数据库
-
行程安排
-
装载机
-
通知
-
警报管理器
-
联系人
-
搜索框架
-
位置和地图
数据库
Android 为处理数据库提供了两个领域:要么使用 Android OS 中包含的 SQLite 库,要么使用 Room architecture 组件。推荐使用后者,因为它在数据库和客户机之间增加了一个抽象层,简化了 Kotlin 对象和数据库存储对象之间的映射。您可以在在线文档中找到关于 SQLite 的详尽信息,也可以在网上找到很多例子。在本书中,我们讨论空间,因为由抽象引发的关注点分离有助于您编写更好的代码。此外,因为 Room 有助于避免样板代码,所以如果使用 Room 而不是 SQLite,就可以大大缩短数据库代码。
为房间配置您的环境
因为 Room 是一个支持架构组件,所以您必须在 Android Studio 构建脚本中配置它。为此,打开模块的build.gradle
文件(不是项目中的文件!)并在顶层(不在任何花括号内)编写以下内容:
apply plugin: 'kotlin-kapt'
这是支持注释处理的 Kotlin 编译器插件。在dependencies
部分,写下以下内容(三行;删除implementation
和kapt
后的换行符:
// Room
implementation
"android.arch.persistence.room:runtime:1.0.0"
kapt
"android.arch.persistence.room:compiler:1.0.0"
房间建筑
房间的设计考虑了易用性;你基本上处理三种对象。
-
数据库:表示数据库的持有者。用 SQL 语言的习惯用语来说,它包含了几个表。用一种技术不可知的方式来说,一个数据库包含几个实体容器。
-
实体:表示 SQL 世界中的一个表。从技术不可知的角度来说,这是一个以使用为中心的字段集合。例如,公司内部的员工或联系人持有关于如何与他人或合作伙伴通信的信息。
-
数据访问对象(DAO) :包含从数据库中检索数据的访问逻辑。因此,它充当程序逻辑和数据库模型之间的接口。每个实体类通常有一个 DAO,但是对于不同的组合可能有更多的 DAO。例如,您可以为两个雇员和联系人实体设置一个
EmployeeDao
和一个ContactDao
,以及一个组合了雇员和联系人信息的PersonDao
。
数据库
要声明数据库,您需要编写以下代码:
import android.arch.persistence.room.*
@Database(entities =
arrayOf(Employee::class, Contact::class),
version = 1)
abstract class MyDatabase : RoomDatabase() {
abstract fun employeeDao(): EmployeeDao
abstract fun contactDao(): ContactDao
abstract fun personDao(): PersonDao
}
在@Database
注释中,您声明了所有使用的实体类,并且作为抽象函数,您为 DAO 类提供了工厂方法。您不必实现这个抽象数据库类。房间库将根据签名和注释自动为您提供实现!当升级到不同的数据模型版本时,版本号将对您有所帮助;在接下来的几节中,您将了解到更多相关内容。
实体
接下来我们实现实体类,这在 Kotlin 中非常容易。
@Entity
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
var firstName:String,
var lastName:String)
@Entity
data class Contact(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
var emailAddr:String)
您可以看到,我们需要为每个实体设置一个类型为Int
的主键。autoGenerate = true
负责自动使其独一无二。
由这些实体类定义的数据库表中的列名与变量名相匹配。如果你想改变它,你可以添加另外一个注释:@ColumnInfo
。
@Entity
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
@ColumnInfo(name = "first_name") var firstName:String,
@ColumnInfo(name = "last_name") var lastName:String)
这将导致使用first_name
和last_name
作为表的列名。
同样,表名取自实体类名,比如这些例子中的Employee
和Contact
。你也可以改变这一点;只需将参数tableName
添加到@Entity
注释中,如下所示:
@Entity(tableName = "empl")
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
@ColumnInfo(name = "first_name") var firstName:String,
@ColumnInfo(name = "last_name") var lastName:String)
虽然通常使用单个整数值主键是个好主意,但是也可以使用组合键。为此,在@Entity
中有一个额外的注释参数。这里有一个例子:
@Entity(tableName = "empl",
primaryKeys = tableOf("first_name","last_name"))
data class Employee(
@ColumnInfo(name = "first_name") var firstName:String,
@ColumnInfo(name = "last_name") var lastName:String)
实体也可以有不会被持久化的字段。从设计的角度来看,这可能不是一个好主意,但是如果您需要这样一个字段,您可以添加它并使用如下的注释@Ignore
:
@Entity(tableName = "empl")
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
var firstName:String = "",
var lastName:String = "",
@Ignore var salary:Int)
由于 Room 的实现方式,如果添加这样的@Ignore
注释,所有的字段都必须分配默认值,即使没有使用。
关系
房间设计不允许实体之间的直接关系。例如,您不能将一列Contact
实体添加为Employee
实体的类成员。但是,可以声明外键关系,这有助于保持数据的一致性。
为此,添加一个foreignKeys
注释属性,如下面的代码片段所示:
@Entity(
foreignKeys = arrayOf(
ForeignKey(entity = Employee::class,
parentColumns = arrayOf( "uid" ),
childColumns = arrayOf( "employeeId" ),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
deferred = true)),
indices = arrayOf(
Index("employeeId"))
)
@Entity
data class Contact(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
var employeeId:Int,
var emailAddr:String)
以下是关于这一结构的几点说明:
-
在 Java 中,你可以写
@Entity(foreignKeys = @ForeignKey( ... )
。Kotlin 不允许在注释中包含注释。在这种情况下,使用构造函数作为替代,这可以归结为省略内部注释的@
。 -
在 Java 注释中,属性值数组的写法类似于
name = { ..., ... }
。这不能在 Kotlin 中使用,因为花括号不充当数组初始值设定项。相反,使用了arrayOf(...)
库方法。 -
childColumns
属性指向这个实体中的引用键,在本例中为Contact.employeeId
。 -
parentColumns
属性指向被引用的外键实体,在本例中是Employee.uid
。 -
onDelete
属性告诉当父节点被删除时该做什么。值为ForeignKey.CASCADE
意味着也自动删除所有子代,即关联的Contact
实体。可能的值如下:-
CASCADE
:将所有动作传输到父子关系树的根。 -
什么都不要做。这是默认设置,如果关系因为更新或删除操作而中断,它会导致一个异常。
-
RESTRICT
:类似于NO_ACTION
,但是当发生删除或更新时会立即进行检查。 -
SET_NULL
:如果发生父删除或更新,所有子键列都被设置为null
。 -
SET_DEFAULT
:如果发生父删除或更新,所有子键列都被设置为默认值。
-
-
onUpdate
属性告诉当父节点被更新时该做什么。值ForeignKey.CASCADE
意味着也自动更新所有子代,这些子代是关联的Contact
实体。可能的值与onDelete
相同。 -
deferred = true
设置将延迟一致性检查,直到数据库事务被提交。例如,如果父事务和子事务都在同一个事务中创建,这可能很重要。 -
外键必须是相应索引的一部分。这里
Contact.employeeId
获取索引。在接下来的几节中,您将了解更多关于索引的内容。
嵌套对象
尽管除了通过外键手动定义对象间关系之外,不可能定义对象间关系,但是您可以在对象端定义层次对象的嵌套。例如,来自以下雇员实体:
@Entity
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
var firstName:String,
var lastName:String)
您可以将名和姓分离出来,改为写以下内容:
data class Name(var firstName:String, var lastName:String)
@Entity
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
@Embedded var name:Name)
请注意,这对数据模型的数据库端没有任何影响。关联的表仍然有列uid
、firstName
和lastName
。由于这种嵌入对象的数据库标识与其字段的名称相关联,如果您有几个相同嵌入类型的嵌入对象,您必须使用如下的prefix
属性来消除名称的歧义:
data class Name(var firstName:String, var lastName:String)
@Entity
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
@Embedded var name:Name,
@Embedded(prefix="spouse_") var spouseName:Name)
这使得表格具有列uid
、firstName
、lastName
、spouse_firstName
和spouse_lastName
。
如果您愿意,可以在可嵌入的类中使用房间注释。例如,您可以使用@ColumnInfo
注释来指定定制的列名。
data class Name(
@ColumnInfo(name = "first_name") var firstName:String,
@ColumnInfo(name = "last_name") var lastName:String)
@Entity
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
@Embedded var name:Name)
使用索引
为了提高数据库查询性能,您可以声明一个或多个索引,用于某些字段或字段组合。对于唯一键,您不必这样做;这是自动为您完成的。但是对于您想要定义的任何其他索引,请编写如下内容:
@Entity(indices = arrayOf(
Index("employeeId"),
Index(value = arrayOf("country","city"))
)
)
data class Contact(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
var employeeId:Int,
var emailAddr:String,
var country:String,
var city:String)
这增加了一个索引,允许使用外键字段employeeId
进行快速查询,并增加了另一个索引,用于给定国家和城市的快速查询。
如果您将unique = true
作为属性添加到@Index
注释中,Room 将确保该表中不能有两个条目对于该特定索引具有相同的值。例如,我们可以向Employee
添加一个社会保险号(SSN)字段,并为其定义一个唯一的索引,如下所示:
@Entity(indices = arrayOf(
Index(value = arrayOf("ssn"), unique = true)
)
)
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
var ssn:String,
@Embedded var name:Name)
如果您现在试图向数据库中添加两个具有相同 SSN 的雇员,Room 将抛出一个异常。
数据访问:DAOs
数据访问对象(Dao)提供了访问数据库的逻辑。我们已经看到,在数据库声明中,我们必须列出工厂方法中的所有 Dao,如下所示:
@Database(entities =
arrayOf(Employee::class, Contact::class),
version = 1)
abstract class MyDatabase : RoomDatabase() {
abstract fun employeeDao(): EmployeeDao
abstract fun contactDao(): ContactDao
abstract fun personDao(): PersonDao
}
在这个例子中,我们声明了三个 Dao 供 Room 使用。对于实际的实现,我们不需要完全成熟的 DAO 类。声明接口或抽象类就够了,Room 会为我们完成剩下的工作。
例如,以下实体的 DAO 类:
@Entity
data class Employee(
@PrimaryKey(autoGenerate = true) var uid:Int = 0,
@ColumnInfo(name = "first_name") var firstName:String,
@ColumnInfo(name = "last_name") var lastName:String)
可能看起来像这样:
@Dao
interface EmployeeDao {
@Query("SELECT * FROM employee")
fun getAll(): List<Employee>
@Query("SELECT * FROM employee" +
" WHERE uid IN (:uIds)")
fun loadAllByIds(uIds: IntArray): List<Employee>
@Query("SELECT * FROM employee" +
" WHERE last_name LIKE :name")
fun findByLastName(name: String): List<Employee>
@Query("SELECT * FROM employee" +
" WHERE last_name LIKE :lname AND " +
" first_name LIKE :fname LIMIT 1")
fun findByName(lname: String, fname: String): Employee
@Query("SELECT * FROM employee" +
" WHERE uid = :uid")
fun findById(uid: Int): Employee
@Insert
fun insert(vararg employees: Employee): LongArray
@Update
fun update(vararg employees: Employee)
@Delete
fun delete(vararg employees: Employee)
}
您可以看到我们在这里使用了一个接口,这是可能的,因为完整的访问逻辑是由方法签名和注释定义的。同样,对于插入、更新和删除,方法签名是所有房间需要的;它会通过查看签名向数据库发送正确的命令。
对于各种查询方法,我们使用@Query
注释来提供正确的数据库命令。您可以看到,Room 足够智能,可以看出我们是要返回对象列表还是单个对象。此外,我们可以通过使用:name
标识符将方法参数传递给伪 SQL。
@Insert
注释允许添加属性onConflict = "<strategy>"
,在这里您可以指定如果因为违反了惟一或主键约束而发生冲突时该做什么。常量内部给出了<strategy>
的可能值:
-
OnConflictStrategy.ABORT
中止交易 -
OnConflictStrategy.FAIL
交易失败 -
OnConflictStrategy.IGNORE
忽略冲突 -
OnConflictStrategy.REPLACE
仅替换实体,否则继续交易 -
OnConflictStrategy.ROLLBACK
回滚事务
前面使用的示例实体中的其他 Dao 看起来类似。PersonDao
可能会执行外部连接来合并雇员和联系人实体:
@Dao
interface ContactDao {
@Insert
fun insert(vararg contacts: Contact)
@Query("SELECT * FROM Contact WHERE uid = :uId")
fun findById(uId: Int): List<Contact>
@Query("SELECT * FROM Contact WHERE" +
" employeeId = :employeeId")
fun loadByEmployeeId(employeeId: Int): List<Contact>
}
data class Person(@Embedded var name:Name?,
var emailAddr: String?)
@Dao
interface PersonDao {
@Query("SELECT * FROM empl" +
" LEFT OUTER JOIN Contact ON" +
" empl.uid = Contact.employeeId" +
" WHERE empl.uid = :uId")
fun findById(uId: Int): List<Person>
}
可观察的查询
除了在查询发生时使用返回的实体或实体列表或实体数组执行查询之外,还可以检索查询结果加上注册一个观察器,当底层数据发生变化时会调用该观察器。
对于 DAO 类中的方法,实现这一点的构造如下所示:
@Query("SELECT * FROM employee")
fun getAllSync(): LiveData<List<Employee>>
所以,你基本上在结果周围包装了一个LiveData
类,这就是你可以对所有查询做的事情。
然而,只有当您添加相应的架构组件时,这才是可能的。为此,将以下内容添加到模块的build.gradle
文件中:
implementation "android.arch.lifecycle:livedata:1.1.0"
这个LiveData
对象现在允许添加一个观察者,如下所示:
val ld: LiveData<List<Employee>> =
employeeDao.getAllSync()
ld.observeForever { l ->
l?.forEach { empl ->
Log.e("LOG", empl.toString())
// do s.th. else with the employee
}
}
如果您在观察者回调中更新 GUI 组件,这将非常有用。
警告
您的生产代码应该在做正确的内务处理方面做得更好。通过在代码中的适当位置调用ld.removeObserver(...)
,LiveData
对象应该取消观察者的注册。这里没有显示,因为我们只提供了代码片段,而内务处理必须在包含代码片段的代码中完成。
一个LiveData
对象还允许添加一个绑定到生命周期对象的观察者。这是通过以下方式完成的:
val ld: LiveData<List<Employee>> =
employeeDao.getAllSync()
val lcOwn : LifecycleOwner = ...
ld.observe(lcOwn, { l ->
l?.forEach { empl ->
Log.e("LOG", empl.toString())
// do s.th. else with the employee
}
} )
关于生命周期对象的详细信息,请查看android.arch.lifecycle.LiveData
的在线 API 文档。
一种类似但可能更全面的方法是使用 RxJava/RxKotlin,这是 ReactiveX 的 Java/Kotlin 平台实现。这里我们不介绍 ReactiveX 编程,但是将它包含在查询中可以归结为将结果包装到 RxJava 对象中。举例来说,为了让您快速了解如何做到这一点,您可以编写以下代码:
@Query("SELECT * FROM employee" +
" WHERE uid = :uid")
fun findByIdRx(uid: Int): Flowable<Employee> {
[...] // Wrap query results into a Flowable
}
这将返回一个Flowable
,允许观察者对以异步方式对检索到的数据库行做出反应。
要做到这一点,您必须在构建文件中包含 RxJava 支持(删除implementation
后的换行符)。
// RxJava support for Room
Implementation
"android.arch.persistence.room:rxjava2:1.0.0"
有关 RxKotlin 的更多详细信息,请参考关于 react vex 的在线资源或关于 react vex 的 Kotlin 语言绑定的 RxKotlin。
数据库客户端
为了在应用中包含 Room,我们需要知道如何获得数据库和 DAO 对象。为此,我们首先通过以下方式获取对数据库的引用:
fun fetchDb() =
Room.databaseBuilder(
this, MyDatabase::class.java,
"MyDatabase.db")
.build()
val db = fetchDb()
这将创建一个文件备份数据库。字符串参数是保存数据的文件的名称。如果要打开一个基于内存的数据库,比如出于测试目的,或者当应用停止时,您更喜欢速度而不是数据丢失,请使用下面的代码:
fun fetchDb() =
Room.inMemoryDatabaseBuilder(
this, MyDatabase::class.java)
.build()
val db = fetchDb()
构建器允许以流畅的构建器风格进行某些配置活动。有趣的配置选项如表 8-1 所示。你只需在最后一次.build()
呼叫前将它们锁住。在早期开发阶段,您可能经常使用的一个选项是通过使用以下方法放松前台操作限制:
表 8-1
房间生成器选项
|[计]选项
|
描述
|
| --- | --- |
| addCallback(RoomDatabase.Callback)
| 使用它向该数据库添加一个RoomDatabase.Callback
。例如,您可以使用它在创建或打开数据库时执行一些代码。 |
| allowMainThreadQueries()
| 使用它来禁用房间中没有主线程的限制。如果不使用这个,而试图在主线程中执行数据库操作,Room 会抛出异常。“空间”以这种方式运作是有充分理由的。GUI 相关的线程不应该因为冗长的数据库操作而被阻塞。因此,对于您的代码,您不应该调用此方法;只有在实验中避免处理异步性才有意义。 |
| addMigrations(vararg Migration)
| 使用它来添加迁移计划。本章稍后将更详细地介绍迁移。 |
| fallbackToDestructiveMigration()
| 如果缺少匹配的迁移计划(例如,对于从数据库中的数据版本到@Database
注释中指定的版本的必要升级,找不到注册的迁移计划),Room 通常会抛出一个异常。如果您希望清除当前数据库,然后为新版本从头开始构建数据库,请使用此方法。 |
| fallbackToDestructiveMigration(vararg Int)
| 这与fallbackToDestructiveMigration()
相同,但仅限于某些起始版本。对于所有其他版本,如果缺少迁移计划,将会引发异常。 |
fun fetchDb() =
Room.databaseBuilder(
this, MyDatabase::class.java,
"MyDatabase.db")
.allowMainThreadQueries()
.build()
val db = fetchDb()
然后,一旦有了数据库对象,只需以抽象的方式调用我们在数据库类内部定义的任何 DAO 工厂方法,Room 就会自动提供实现。例如,写下以下内容:
val db = ...
val employeeDao = db.employeeDao()
// use the DAO...
处理
房间允许在EXCLUSIVE
模式下进行交易。这意味着,如果事务 A 正在进行,则在事务 A 完成之前,不允许其他进程或线程访问另一个事务 B 中的数据库。更准确地说,事务 B 必须等到 A 完成。
要在 Kotlin 的事务中运行一组数据库操作,可以编写以下代码:
val db = ...
db.runInTransaction { ->
// do DB work...
}
如果闭包内的代码没有抛出任何异常,则事务被标记为“成功”。否则,事务将被回滚。
迁移数据库
要将数据库从应用的一个版本迁移到另一个版本,请在访问数据库时添加迁移计划,如下所示:
val migs = arrayOf(
object : Migration(1,2) {
override fun migrate(db: SupportSQLiteDatabase) {
// code for the 1->2 migration...
// this is already running inside a transaction,
// don't add your own transaction code here!
}
}, object : Migration(2,3) {
override fun migrate(db: SupportSQLiteDatabase) {
// code for the 2->3 migration...
// this is already running inside a transaction,
// don't add your own transaction code here!
}
} // more migrations ...
)
private fun fetchDb() =
Room.databaseBuilder(
this, MyDatabase::class.java,
"MyDatabase.db")
.addMigrations(*migs)
.build()
在这里使用 DAO 类显然是没有意义的,因为这样你就必须管理几个 DAO 变体,每个版本一个。这就是为什么在migrate()
方法中,你需要在一个较低的层次上访问数据库,例如通过执行没有绑定到 Kotlin 对象的 SQL 语句。例如,假设您有一个Employee
表。你从版本 1 升级到版本 2,需要增加一列salary
,然后你从版本 2 升级到版本 3,需要另一列childCount
。在前面代码中的migs
数组内,您接着编写以下代码:
//...
object : Migration(1,2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE components "+
"ADD COLUMN salary INTEGER DEFAULT 0;")
}
}
//...
object : Migration(2,3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE components "+
"ADD COLUMN childCount INTEGER DEFAULT 0;")
}
}
//...
object : Migration(1,3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE components "+
"ADD COLUMN salary INTEGER DEFAULT 0;")
db.execSQL("ALTER TABLE components "+
"ADD COLUMN childCount INTEGER DEFAULT 0;")
}
}
//...
如果您同时提供小步迁移和大步迁移,则后者具有优先权。这意味着如果您有迁移计划 1 → 2、2 → 3 和 1 → 3,而系统要求迁移 1 → 3,则计划 1 → 3 将运行,而不是链 1 → 2 → 3。
行程安排
考虑到用户体验,以异步方式运行任务是一件重要的事情。至关重要的是,不要让冗长的操作扰乱前端流程,给人留下你的应用正在流畅地完成工作的印象。
然而,要编写稳定的应用,让重要的部分在后台运行并不容易。出现这种情况的原因有很多:设备可能会根据需要或因为电池电量低而关机,或者用户可能已经启动了一个更重要的优先级更高的应用,希望以低优先级模式临时运行后台作业。此外,Android 操作系统可能会因为其他原因(如资源短缺或超时条件适用)而决定中断或推迟后台作业。随着 Android 8 的出现,考虑执行后台任务的聪明方法变得更加重要,因为这个版本对程序部分的后台执行施加了严格的限制。
对于以异步方式运行作业,有几种技术,它们都有优缺点。
-
Java 线程
Java 和 Kotlin 线程(记住,两者都针对同一个 Java 虚拟机)是一种在后台运行的底层技术。在 Kotlin 中,您可以使用这样简单的结构在后台线程中处理程序部分:
Thread{-> do s.th.}.start()
这是一种基本的方法,您可以从后台执行任务中获得高性能。然而,您已经完全耗尽了任何 Android OS 组件的生命周期,所以当 Android 进程的生命周期状态改变时,您并不能很好地控制长期运行的后台线程会发生什么。
-
Java 并发类
Java 和 Kotlin 允许使用
java.util.concurrency
包中与并发相关的类。这是一种在后台运行的更高级的方法,具有改进的后台任务管理功能,但它仍然有超出 Android 组件生命周期控制的缺点。 -
报警管理器
这最初是为在特定时间运行任务而设计的,如果您需要在特定时间向用户发送通知,可以使用它。从 API 级开始就有了。从 API level 19 (Android 4.4)开始,系统允许在某些情况下推迟警报。不利的一面是,你无法控制更一般的设备条件;当设备启动时,无论设备上发生了什么,它都会自行触发警报事件。
-
同步适配器
这种方法是在 Android API level 5 中添加的。这对于同步任务特别有用。对于更一般的后台执行任务,您应该使用以下两种方法之一,Firebase JobDispatcher 或 JobScheduler。仅当您需要它提供的附加功能时,才使用其中之一。
-
Firebase JobDispatcher
这是一个通用的多用途作业调度程序库,可以用于从 API 级别 14 开始的 Android 设备,目标是超过 99%的正在使用的 Android 设备。在网上找到 Firebase JobDispatcher 的全面完整的文档有点困难,但是您会找到足够多的例子来帮助您入门。尽管它不是 Android 操作系统核心的一部分,但它需要安装 Google Play 服务和 Google Play 商店。
-
作业调度器
这是一个用于在 Android 操作系统上调度作业的集成库。它可以在任何 API 等级为 21 的设备上运行,这适用于 85%的 Android 设备。强烈推荐使用,除非你真的需要寻址 API 级之前的设备,也就是 Android 4.4 及以上版本。
第十章中介绍了更低级的方法;本节的其余部分是关于 Firebase 作业调度程序、作业调度程序和 AlarmManager 的。
作业调度程序
JobScheduler 是在任何 Android 设备中调度和运行后台任务的专用方法,从 API 级别 21 开始。Android 8 的文档强烈建议使用 JobSchedulers 来克服自 Android 8 以来强加的后台任务执行限制。
注意
如果您的目标 API 级别为 21 或更高(截至 2018 年 2 月,超过 85%的 Android 安装),请使用 JobSchedulers 进行后台任务。
要开始使用 JobScheduler,我们首先实现作业本身。为此,实现类android.app.job.JobService
,如下所示:
class MyJob : JobService() {
var jobThread:Thread? = null
override
fun onStartJob(params: JobParameters) : Boolean {
Log.i("LOG", "MyJob: onStartJob() : " +
params.jobId)
jobThread?.interrupt()
jobThread = Thread {
Log.i("LOG", "started job thread")
// do job work...
jobFinished(params, false) jobThread = null
Log.i("LOG", "finished job thread")
}
jobThread.start()
return true
}
override
fun onStopJob(params:JobParameters) : Boolean {
Log.i("LOG", "MyJob: onStopJob()")
jobThread?.interrupt()
jobThread = null
return true
}
}
实现中最重要的部分是onStartJob()
方法。在那里,您将输入工作实际上应该做的工作。请注意,我们将实际工作推入了一个线程中。这很重要,因为onStartJob()
方法运行在应用的主线程中,因此如果它在里面停留太久,会阻塞潜在的重要的其他工作。相反,启动线程会立即结束。此外,我们返回true
,表明作业继续在后台线程中工作。一旦作业完成,它必须调用jobFinished()
;否则,系统不会知道作业已经完成了它的工作。
被覆盖的onStopJob()
方法是而不是正常作业生命周期的一部分。相反,当系统决定提前完成作业时,它会被调用。我们让它返回true
来告诉系统,如果相应地配置了作业,允许重新调度作业。
为了完成作业实现,我们仍然必须在AndroidManifest.xml
中配置服务类。为此,添加以下内容:
<service android:name=".MyJob"
android:label="MyJob Service"
android:permission=
"android.permission.BIND_JOB_SERVICE" />
这里配置的权限是而不是一个“危险”的权限,所以你不必实现一个过程来获得这个权限。但是,您必须在此处添加此权限;否则,该作业会被忽略。
要实际调度由 JobScheduler 管理的作业,首先需要获得一个JobScheduler
对象作为系统服务。然后可以构建一个JobInfo
对象,最后向 JobScheduler 注册它。
val jsched = getSystemService(JobScheduler::class.java)
val JOB_ID : Int = 7766
val service = ComponentName(this, MyJob::class.java)
val builder = JobInfo.Builder(JOB_ID, service)
.setMinimumLatency((1 * 1000).toLong())
// wait at least 1 sec
.setOverrideDeadline((3 * 1000).toLong())
// maximum delay 3 secs
jsched.schedule(builder.build())
此示例计划作业的启动时间,最早在一秒钟后,最晚在三秒钟后。通过构造,它获得分配的 ID 7766
。这是在作业实现中传递给onStartJob()
的一个值。数字只是一个例子;您可以使用任何唯一的 ID 号。
在构建JobInfo
对象时,可以设置各种作业特性,如表 8-2 所示。
表 8-2
工单信息生成器选项
|方法
|
描述
|
| --- | --- |
| setMinimumLatency(minLatencyMillis: Long)
| 此作业应延迟指定的时间量,或更长时间。 |
| setOverrideDeadline(maxExecutionDelayMillis: Long)
| 这是作业可以延迟的最长时间。 |
| setPeriodic(intervalMillis: Long)
| 这将使作业重复进行,并设置重复间隔。实际间隔可以更高,但不会更低。 |
| setPeriodic(intervalMillis: Long, flexMillis: Long)
| 这将使作业重复进行,并设置重复间隔和灵活性窗口。所以,真正的区间会在intervalMillis``0.5``flexMillis
和intervalMillis
+ 0.5
flexMillis
之间。这两个数字的最低可能值分别被箝位到getMinPeriodMillis()
和MAX( getMinFlexMillis(), 0.05 * intervalMillis )
。 |
| setBackoffCriteria(initialBackoffMillis:Long, backoffPolicy:Int)
| 当您在作业实现内部编写jobFinished(params, true)
时,可能会发生回退。您可以在这里指定在这种情况下会发生什么。backoffPolicy
的可能值由以下常量给出:JobInfo.BACKOFF_POLICY_LINEAR
:回退以initialBackoffMillis retry
number
的间隔发生。 JobInfo.BACKOFF_POLICY_EXPONENTIAL
:回退间隔为initialBackoffMillis
2 重试次数。 |
| setExtras(extras: PersistableBundle)
| 这设置了可选的附加功能。这些额外的东西在作业实现中被传递给onStartJob()
。 |
| setTransientExtras(extras: Bundle)
| 这只适用于 API 等级 26 及以上。这将设置可选的未持久化的额外内容。这些额外的东西在作业实现中被传递给onStartJob()
。 |
| setPersisted(isPersisted: Boolean)
| 这将设置作业是否在设备重新启动后保持不变。它需要许可android.Manifest.permission.RECEIVE_BOOT_COMPLETED
。 |
| setRequiredNetworkType(networkType: Int)
| 这指定了运行作业需要满足的附加条件。这些是可能的参数值:JobInfo.NETWORK_TYPE_NONE``JobInfo.NETWORK_TYPE_ANY``JobInfo.NETWORK_TYPE_UNMETERED``JobInfo.NETWORK_TYPE_NOT_ROAMING``JobInfo.NETWORK_TYPE_METERED
|
| setRequiresBatteryNotLow(batteryNotLow: Boolean)
| 这只适用于 API 等级 26 及以上。这指定了运行作业需要满足的附加条件,即电池电量不能过低。false
重置为不关心。 |
| setRequiresCharging(requiresCharging: Boolean)
| 指定设备必须插上电源,这是作业运行所需满足的附加条件。false
重置为不关心。 |
| setRequiresDeviceIdle(requiresDeviceIdle: Boolean)
| 将设备必须处于空闲状态指定为运行作业所需满足的附加条件。false
重置为不关心。 |
| setRequiresStorageNotLow(storageNotLow: Boolean)
| 这只适用于 API 等级 26 及以上。这指定了运行作业所需满足的附加条件,即设备内存不能太低。false
重置为不关心。 |
| addTriggerContentUri(uri: JobInfo.TriggerContentUri)
| 这只适用于 API 等级 24 及以上。这将添加一个内容 URI,对其更改进行监控。如果发生变化,作业就会被执行。 |
| setTriggerContentUpdateDelay(durationMs: Long)
| 这只适用于 API 等级 24 及以上。这将设置从检测到内容更改到安排作业之间的最小延迟时间(毫秒)。 |
| setTriggerContentMaxDelay(durationMs: Long)
| 这只适用于 API 等级 24 及以上。这将设置从第一次检测到内容更改到安排作业之间允许的最大总延迟(以毫秒为单位)。 |
| setClipData(clip:ClipData, grantFlags:Int)
| 这只适用于 API 等级 26 及以上。这将设置一个与该任务相关的ClipData
。grantFlags
的可能值如下:FLAG_GRANT_READ_URI_PERMISSION``FLAG_GRANT_WRITE_URI_PERMISSION``FLAG_GRANT_PREFIX_URI_PERMISSION
(所有常量都在类Intent
内。) |
Firebase 作业调度程序
Firebase JobDispatcher 是 JobScheduler 的替代产品,适用于 Android API 之前的版本。
警告
Firebase JobDispatcher 库要求安装 Google Play 服务和 Google Play 商店。如果您的目标不是低于 21 的 API 级别,建议您使用 JobScheduler。
要使用 Firebase JobDispatcher,首先必须安装它。为此,将以下内容添加到您的模块的build.gradle
文件中的dependencies
部分:
implementation 'com.firebase:firebase-jobdispatcher:0.8.5'
作为第一步,实现一个作业类,如下所示:
import com.firebase.jobdispatcher.*
class MyJobService : JobService() {
var jobThread:Thread? = null
override fun onStopJob(job: JobParameters?): Boolean {
Log.e("LOG", "onStopJob()")
jobThread?.interrupt()
jobThread = null
return false // this job should not be retried
}
override fun onStartJob(job: JobParameters): Boolean {
Log.e("LOG", "onStartJob()")
jobThread?.interrupt()
jobThread = Thread {
Log.i("LOG", "started job thread")
// do job work...
jobFinished(job, false)
// instead use true to signal a retry
jobThread = null
Log.i("LOG", "finished job thread")
}
jobThread?.start()
return true // work is going on in the background
}
}
然后在清单文件AndroidManifest.xml
中注册作业,如下所示:
<service
android:exported="false"
android:name=".MyJobService">
<intent-filter>
<action android:name=
"com.firebase.jobdispatcher.ACTION_EXECUTE"
/>
</intent-filter>
</service>
要包括可用性检查,您必须执行以下步骤:
-
Google Play 服务需要添加到 SDK 安装中。在 Android Studio 中,进入工具➤ Android ➤ SDK 管理器。在菜单中选择外观与行为➤系统设置➤ Android SDK。在 SDK 工具选项卡上,选择 Google Play 服务,然后单击确定按钮。
-
右键单击项目,选择打开模块设置,然后在菜单中选择您的应用模块。转到 Dependencies 选项卡,通过单击+按钮添加库
com.google.android.gms:play-services
。
要在您的应用中实际安排作业,您可以获取服务,创建作业,然后使用以下命令注册该作业:
val gps = GoogleApiAvailability.getInstance().
isGooglePlayServicesAvailable(this)
if(gps == ConnectionResult.SUCCESS) {
// Create a new dispatcher using the Google Play
// driver.
val dispatcher = FirebaseJobDispatcher(
GooglePlayDriver(this))
val myJob = dispatcher.newJobBuilder()
.setService(MyJobService::class.java)
// the JobService that will be called
.setTag("my-unique-tag")
// uniquely identifies the job
.build()
dispatcher.mustSchedule(myJob)
} else {
Log.e("LOG", "GooglePlayServices not available: " +
GoogleApiAvailability.getInstance().
getErrorString(gps))
}
此示例调度了一个具有基本作业调度特征的作业。对于更复杂的需求,作业生成器允许更多选项,如表 8-3 所示。只是在.build()
方法之前链接它们。
表 8-3
作业调度程序选项
|方法
|
描述
|
| --- | --- |
| setService(Class)
| 这是 job 类(在 Kotlin 中你必须写MyService::class.java
)。 |
| setTag(String)
| 这是作业的唯一标识。 |
| setRecurring(Boolean)
| 这将设置这是否是一个重复作业。 |
| setLifetime(Int)
| 这将设置作业的生命周期。可能的值是Lifetime.FOREVER
和Lifetime.UNTIL_NEXT_BOOT
。使用FOREVER
,即使在设备重启后,作业也会持续。 |
| setTrigger(Trigger)
| 这将设置何时触发作业。可能的值如下:Trigger.NOW
:立即开始工作Trigger.executionWindow(windowStart: Int, windowEnd: Int)
:设置执行窗口(秒)Trigger.contentUriTrigger(uris: List<ObservedUri>)
:手表内容 URIs |
| setReplaceCurrent(Boolean)
| 这指定是否替换现有作业,前提是该作业具有相同的标签。 |
| setRetryStrategy( RetryStrategy)
| 这将设置重试策略。可能的值如下:RetryStrategy.DEFAULT_EXPONENTIAL
:指数型,如 30 秒、1 分钟、2 分钟、4 分钟、8 分钟等。RetryStrategy.DEFAULT_LINEAR
:线性,如 30 秒、60 秒、90 秒、120 秒等。 |
| setConstraints(vararg Int)
| 在这里,您可以设置作业运行所需满足的约束。可能的值如下:Constraint.ON_ANY_NETWORK
:仅在网络可用时运行。Constraint.ON_UNMETERED_NETWORK
:仅在无计量网络可用时运行。Constraint.DEVICE_CHARGING
:仅当设备接通电源时运行。Constraint.DEVICE_IDLE
:仅在设备空闲时运行。 |
| setExtras(Bundle)
| 用它来设置额外的数据。这些将被传递给作业服务类中的onStartJob()
。 |
警报管理器
如果您需要在特定时间执行操作,不管相关组件是否正在运行,报警管理器是您可以用于此类任务的系统服务。
关于报警管理器,您的设备处于以下状态之一:
-
设备唤醒
设备正在运行。通常这也意味着屏幕是开着的,但是不能保证如果屏幕是关着的,设备就不再处于唤醒状态。虽然通常如果屏幕被关闭,设备会很快离开唤醒状态。细节取决于硬件和设备的软件配置。如果设备处于唤醒状态,报警管理器可以工作,但对于报警管理器触发事件来说,并不需要处于唤醒状态。
-
设备锁定
设备被锁定,用户需要解锁后才能再次操作。锁定的设备可能导致设备进入睡眠状态;然而,锁定本身是一种安全措施,对报警管理器的功能没有主要影响。
-
设备休眠
屏幕关闭,设备以低功耗模式运行。报警管理器触发的事件将能够唤醒设备,然后触发事件,但这需要明确指定。
-
设备关闭
报警管理器停止工作,仅在下次打开设备时恢复工作。当设备关闭时,警报事件会丢失;这里没有类似重试的功能。
警报事件是下列事件之一:
-
一个未决事件被解雇。由于未决事件可能以服务、活动或广播为目标,所以警报事件可能启动活动或服务或发送广播。
-
一个处理程序被调用。这是向发出警报的同一个组件发送警报事件的直接版本。
要安排报警,首先需要将报警管理器作为系统服务,如下所示:
val alrm = getSystemService(AlarmManager::class.java)
// or, if API level below 23:
// val alrm = getSystemService(Context.ALARM_SERVICE)
// as AlarmManager
然后你可以通过各种方法发出警报,如表 8-4 所示。如果对于 API 级别 24 或更高,您选择让监听器接收报警事件,关于如何使用相关处理程序的细节将在第十章中介绍。相反,如果您的目标是意图,那么所有相应的方法都有一个带有以下可能值的type:Int
参数:
表 8-4
发出警报
|方法
|
描述
|
| --- | --- |
| set(type: Int, triggerAtMillis: Long, operation: PendingIntent): Unit
| 这将安排一个警报。根据类型调用和触发一个意图,并提供时间参数。从 API 级别 19 开始,警报事件传递对于优化系统资源的使用可能是不精确的。如果您需要精确的交付,请使用setExact
方法之一。 |
| set(type: Int, triggerAtMillis: Long, tag: String, listener: AlarmManager.OnAlarmListener, targetHandler: Handler): Unit
| 这需要 API 等级 24 或更高。是set(Int, Long, PendingIntent)
的直接回调版本。Handler
参数可以是null
来调用应用主 looper 上的监听器。否则,侦听器的调用将从提供的处理程序内部执行。 |
| setAlarmClock(info: AlarmManager.AlarmClockInfo, operation: PendingIntent): Unit
| 这需要 API 等级 21 或更高。这将安排一个由闹钟代表的闹铃。闹钟信息对象允许添加一个能够描述触发器的意图。系统可以选择向用户显示关于该警报的相关信息。除此之外,这个方法类似于setExact(Int, Long, PendingIntent)
,但是隐含了RTC_WAKEUP
触发器类型。 |
| setAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent): Unit
| 这需要 API 等级 23 或更高。与set(Int, Long, PendingIntent)
类似,但即使系统处于低功耗空闲模式,该警报也将被允许执行。 |
| setExact(type: Int, triggerAtMillis: Long, operation: PendingIntent): Unit
| 这需要 API 等级 19 或更高。这将安排在规定的时间准确发送警报。 |
| setExact(type: Int, triggerAtMillis: Long, tag: String, listener: AlarmManager.OnAlarmListener, targetHandler: Handler): Unit
| 这需要 API 等级 24 或更高。setExact(Int, Long, PendingIntent)
的直接回调版本。Handler
参数可以是null
来调用应用主 looper 上的监听器。否则,侦听器的调用将从提供的处理程序内部执行。 |
| setExactAndAllowWhileIdle( type: Int, triggerAtMillis: Long, operation: PendingIntent): Unit
| 这需要 API 等级 23 或更高。与setExact(Int, Long, PendingIntent)
类似,但即使系统处于低功耗空闲模式,该警报也将被允许执行。 |
| setInexactRepeating(type: Int, triggerAtMillis: Long, intervalMillis: Long, operation: PendingIntent): Unit
| 这安排了一个重复的警报,它有不精确的触发时间要求;例如,每小时重复一次的警报,但不一定是在每小时的顶部。 |
| setRepeating(type: Int, triggerAtMillis: Long, intervalMillis: Long, operation: PendingIntent): Unit
| 这将安排重复报警。从 API 级开始,这个和setInexactRepeating()
一样。 |
| setWindow(type: Int, windowStartMillis: Long, windowLengthMillis: Long, operation: PendingIntent): Unit
| 这将安排在给定的时间窗口内发送警报。 |
| setWindow(int type: Int, windowStartMillis: Long, windowLengthMillis: Long, tag: String, listener: AlarmManager.OnAlarmListener, targetHandler: Handler) : Unit
| 这需要 API 等级 24 或更高。这是setWindow(int, long, long, PendingIntent)
的直接回调版本。Handler
参数可以是null
来调用应用主 looper 上的监听器。否则,侦听器的调用将从提供的处理程序内部执行。 |
-
AlarmManager.RTC_WAKEUP
time 参数是以 UTC 表示的挂钟时间(自 1970 年 1 月 1 日 00:00:00 开始的毫秒数);如有必要,设备将被唤醒。
-
AlarmManager.RTC
time 参数是以 UTC 表示的挂钟时间(自 1970 年 1 月 1 日 00:00:00 开始的毫秒数)。如果设备处于睡眠状态,事件将被丢弃,并且不会触发警报。
-
AlarmManager.ELAPSED_REALTIME_WAKEUP
time 参数是自上次启动以来的时间,以毫秒为单位,包括睡眠时间。如果需要,设备将被唤醒
-
AlarmManager.ELAPSED_REALTIME
time 参数是自上次启动以来的时间,以毫秒为单位,包括睡眠时间。如果设备处于睡眠状态,事件将被丢弃,并且不会触发警报。
报警管理器还有几个辅助方法,如表 8-5 所述。
表 8-5
辅助报警管理器方法
|方法
|
描述
|
| --- | --- |
| cancel(operation: PendingIntent) : Unit
| 这将删除任何具有匹配意图的警报。 |
| cancel(listener: AlarmManager.OnAlarmListener): Unit
| 这将删除任何预定发送给给定AlarmManager.OnAlarmListener
的警报。 |
| getNextAlarmClock() : AlarmManager.AlarmClockInfo
| 这将获得当前预定的下一个闹钟的信息。 |
| setTime(long millis): Unit
| 这将设置系统挂钟时间 UTC(自 1970 年 1 月 1 日 00:00:00 起的毫秒数)。 |
| setTimeZone(String timeZone): Unit
| 这将设置系统的持续默认时区。 |
装载机
加载器用于在后台加载数据。主要使用模式如下:
-
需要在一个可能很耗时的过程中加载数据,例如,在单击按钮后的 UI 线程中,或者从代码中的任何其他位置。因为加载预计需要一些时间,所以您希望加载在后台进行,例如,不要干扰 UI。
-
你从上下文中得到
LoaderManager
。在 Kotlin 的 activities 中,您只需使用伪 getterloaderManager
。 -
您实现并提供了一个
LoaderManager.LoaderCallbacks
的子类。这个类的主要职责是构造一个android.content.Loader
并提供加载状态回调函数。 -
您在
LoaderManager
上调用init(...)
,并传递回调的实现。 -
您对回调事件做出反应。
查看加载器框架的在线 API 文档,有两点值得一提。
-
几乎所有的描述和所有的例子(以及几乎所有你能在网上找到的例子)都建议使用加载器框架类的兼容性库。这是为了向后兼容。事实是,你不必那样做。加载器框架已经存在很长时间了,确切地说是从 API 11 级开始,并且由于您可能不关心 API 11 以下的不到 1%的使用中版本,所以使用加载器框架类的兼容性库的需求可能不会太高。
-
阅读文档,似乎有必要将加载器与片段结合使用。事实是,加载器框架与片段本身无关;如果你愿意,你可以使用片段,但不是必须的。因此,您也可以将加载器用于标准活动。
在下面的段落中,我们给出了一个使用加载器框架的基本例子。尝试它,并根据您的需要扩展它。
如上所述,在一个活动中,我们已经有了一个LoaderManager
;只需使用loaderManager
,科特林将其内部转录为getLoaderManager()
。
接下来我们提供一个LoaderManager.LoaderCallbacks
的实现。您可以使用自己的类,但是为了简单起见,您可以直接在您的活动上实现它,如下所示:
class MainActivity : AppCompatActivity(),
LoaderManager.LoaderCallbacks<MyData> {
val LOADER_ID = 42
var loaded:MyData? = null
// other fields and methods...
override fun onCreateLoader(id: Int, args: Bundle?):
Loader<MyData>? {
Log.e("LOG", "onCreateLoader()")
return makeLoader()
}
override fun onLoadFinished(loader: Loader<MyData>?,
data: MyData?) {
Log.e("LOG", "Load finished: " + data)
loaded = data
// show on UI or other actions...
}
override fun onLoaderReset(loader: Loader<MyData>?) {
Log.e("LOG", "onLoaderReset()")
loaded = null
// remove from UI or other actions...
}
}
我们这里有以下内容:
-
LOADER_ID
是加载器的唯一 ID。一个应用可能有几个加载器在工作,所以加载器框架需要能够区分不同的加载器。 -
var loaded:MyData? = null
稍后将从加载过程中接收结果。请注意,没有必要保留对Loader
本身的引用,实际上您也不应该这样做,因为加载器框架将负责加载器的生命周期。 -
方法
onCreateLoader()
、onLoadFinished()
、onLoadReset()
描述了LoaderManager.LoaderCallbacks
的实现。注意,后两个是侦听器,而第一个(其名称有点混乱)是用于创建加载器的工厂方法。该框架将负责仅在需要构建加载程序时调用的onCreateLoader()
。如果某个 ID 的加载器存在并且没有被放弃,那么它将被重用,并且这个方法不会被调用。
在我们的活动中,我们放置了方法makeLoader()
来构建一个加载器。android.content.Loader
需要被子类化才能有可用的加载器。提供了两种实现:android.content.AsyncTaskLoader
和android.content.CursorLoader
。加载器CursorLoader
可用于从内容供应器处加载类似表格的内容,而AsyncTaskLoader
更通用,将从AsyncTask
内部加载数据。对于此处所示的示例,我们使用后一种方法:
fun makeLoader():Loader<MyData> {
val res =
@SuppressLint("StaticFieldLeak")
object : AsyncTaskLoader<MyData>(this@MainActivity) {
val myData: MutableList<String> =
ArrayList<String>()
var initLoaded = false
override fun loadInBackground(): MyData {
Log.e("LOG",
"AsyncTaskLoader.loadInBackground()")
Log.e("LOG", "Thread: " +
Thread.currentThread().toString())
for (i in 0..9) {
Log.e("LOG", i.toString())
myData.add("Item " + i.toString())
Thread.sleep(1000)
if (isLoadInBackgroundCanceled)
throw OperationCanceledException(
"Canceled")
}
return MyData(myData)
}
override fun onStartLoading() {
Log.e("LOG",
"AsyncTaskLoader.onStartLoading()")
super.onStartLoading()
if (!initLoaded)
forceLoad()
initLoaded = true
}
}
return res
}
以下是一些注意事项:
-
@SuppressLint(”StaticFieldLeak”)
将抑制 Android Studio 内部给出的关于可能内存泄漏的警告。加载器的生命周期由加载器框架控制,makeLoader()
将返回一个可重用的加载器,因此可能的泄漏被减轻了。相反,通过将它移动到一个静态字段,这在 Kotlin 中意味着将其作为一个对象提供,在这里并不容易做到,因为我们需要一个对构造AsycTaskLoader
的活动的引用。 -
我们提供了布尔型的
initLoaded
字段,以确保加载将通过第一次调用forceLoad()
来实际启动。 -
按照设计,
loadInBackground()
方法在后台线程中被调用。这是装载实际发生的地方。在这个例子中,我们只是从 0 数到 9。在真实世界的场景中,你当然会在这里做更多有趣的事情。 -
为了帮助框架保持一个正确的加载器状态,在
loadInBackground()
内部你应该定期检查isLoadInBackgroundCanceled
并采取相应的行动。在这个例子中,我们抛出了一个OperationCanceledException
,它不会破坏你的应用,但是会被加载器框架处理。事实上,它将被向上传输,并最终调用onLoaderReset()
回调方法。 -
方法
onStartLoading()
被框架调用;你不必亲自去做。
现在唯一缺少的就是开始或者停止装载。如果在 UI 中使用两个按钮,相应的方法如下所示:
fun go(view: View) {
loaderManager.initLoader(LOADER_ID,null,this)
}
fun dismiss(view: View) {
loaderManager.getLoader<MyData>(LOADER_ID)?.
cancelLoad()
loaderManager.destroyLoader(LOADER_ID)
}
cancelLoad()
方法是告诉加载器取消其加载操作所必需的,而destroyLoader()
方法将从加载器框架中注销加载器。
通知
通知是应用可以在其正常 GUI 流之外呈现给用户的消息。通知显示在屏幕的特殊区域,最突出的是屏幕顶部的状态栏和通知抽屉内,特殊对话框中,锁定屏幕上,配对的 Android Wear 设备上,或应用图标徽章上。智能手机示例见图 8-1 和 8-2 。在用户展开通知抽屉后,您可以看到通知图标和通知内容。
图 8-2
通知内容
图 8-1
智能手机通知
通知还允许操作,比如点击时调用自定义活动,或者它们可以包含特殊的操作按钮,甚至用户可以填写的编辑字段。同样,虽然通知主要是为了显示简短的文本片段,但在当前的 Android 版本中,也可以显示更长的文本。
在线 API 文档建议使用支持库中的 NotificationCompat API。使用这个兼容层允许旧版本在后来才可用的特性上呈现相似的或无操作的变体,从而简化了开发。虽然使用这个兼容层消除了开发人员在代码中呈现许多分支以照顾不同 Android API 级别的负担,但必须注意不要因为过度依赖最新的通知 API 功能而使应用不可用。
为了确保兼容 API 在 Android Studio 中对您的项目可用,请在dependencies
部分检查您的模块的build.gradle
设置(只有一行;删除implementation
后的换行符。
implementation
"com.android.support:support-compat:27.0.2"
以下部分概述了通知 API。随着该 API 在过去几年中的显著发展,请参考在线文档以获得所有通知功能的更详细描述。
创建和显示通知
要创建和显示通知,您需要为点击和附加操作按钮准备操作意图,使用通知构建器来构造通知,注册通知通道,最后让框架显示通知。一个例子是这样的:
val NOTIFICATION_CHANNEL_ID = "1"
val NOTIFICATION_ID = 1
// Make sure this Activity exists
val intent = Intent(this, AlertDetails::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
//or Intent.FLAG_ACTIVITY_CLEAR_TASK
val tapIntent = PendingIntent.getActivity(this, 0,
intent, 0)
// Make sure this broadcast receiver exists and can
// be called by explicit Intent like this
val actionIntent = Intent(this, MyReceiver::class.java)
actionIntent.action = "com.xyz.MAIN"
actionIntent.putExtra(EXTRA_NOTIFICATION_ID, 0)
val actionPendingIntent =
PendingIntent.getBroadcast(this, 0, actionIntent, 0)
val builder = NotificationCompat.Builder(this,
NOTIFICATION_CHANNEL_ID)
.setSmallIcon( ... an icon resource id... )
.setContentTitle("Title")
.setContentText("Content Content Content Content ...")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
// add the default tap action
.setContentIntent(tapIntent)
.setAutoCancel(true)
// add a custom action button
.addAction( ... an icon resource id ...,
"Go",
actionPendingIntent)
buildChannel(NOTIFICATION_CHANNEL_ID)
val notificationManager =
NotificationManagerCompat.from(this)
notificationManager.notify(
NOTIFICATION_ID, builder.build())
Android API 级别 26 及以上(Android 8.0)需要函数buildChannel()
。其内容如下:
fun buildChannel(channelId:String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create the NotificationChannel, but only
// on API 26+ only after that it is needed
val channel = if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.O) {
NotificationChannel(channelId,
"Channel Name",
NotificationManager.IMPORTANCE_DEFAULT)
} else {
throw RuntimeException("Internal error")
}
channel.description = "Description"
// Register the channel with the system
val notificationManager =
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.M) {
getSystemService(
NotificationManager::class.java)
} else {
throw RuntimeException("Internal error")
}
notificationManager.
createNotificationChannel(channel)
}
}
下面是对其他代码的解释:
-
通知本身需要一个唯一的 ID;我们把它保存在
NOTIFICATION_ID
里面。 -
此处用于发送广播的操作按钮仅用于示例。允许没有动作按钮。
-
setAutoCancel(true)
将导致用户点击通知后自动取消通知。只有在同时使用了setContentIntent()
的情况下才有效。 -
只有 API 级别 26 或更高(Android 8.0)才需要创建通知通道。为了让 Android Studio 不抱怨兼容性问题,外层
if
内部多余的检查是必要的。 -
对于所有字符串,您应该在可行的情况下使用资源 id;否则,请使用更符合您需求的文本。
添加直接回复
从 API level 24 (Android 7.0)开始,您可以允许用户输入文本作为对通知消息的回复。这方面的一个主要用例当然是来自消息传递系统的通知消息,比如聊天客户端或电子邮件。示例见图 8-3 。
图 8-3
回复通知
因为低于 24 的 API 等级不能提供这个功能,所以你的应用不应该依赖这个功能。通常这很容易实现。对于 API 级别 23 或更低的应用,通过点击通知调用的活动当然可以包含一个在需要时进行回复的工具。
发出具有回复功能的通知的方法可能如下所示:
fun directReply(view:View) {
// Key for the string that's delivered in the
// action's intent.
val KEY_TEXT_REPLY = "key_text_reply"
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply label")
.build()
// Make sure this broadcast receiver exists
val CONVERSATION_ID = 1
val messageReplyIntent =
Intent(this, MyReceiver2::class.java)
messageReplyIntent.action = "com.xyz2.MAIN"
messageReplyIntent.putExtra("conversationId",
CONVERSATION_ID)
// Build a PendingIntent for the reply
// action to trigger.
val replyPendingIntent = PendingIntent.
getBroadcast(applicationContext,
CONVERSATION_ID,
messageReplyIntent,
PendingIntent.FLAG_UPDATE_CURRENT)
// Create the reply action and add the remote input.
val action = NotificationCompat.Action.Builder(
... a resource id for an icon ...,
"Reply", replyPendingIntent)
.addRemoteInput(remoteInput)
.build()
val builder = NotificationCompat.Builder(this,
NOTIFICATION_CHANNEL_ID)
.setSmallIcon(... a resource id for an icon ...)
.setContentTitle("Title")
.setContentText("Content Content Content ...")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
// add a reply action button
.addAction(action)
buildChannel(NOTIFICATION_CHANNEL_ID)
val notificationManager =
NotificationManagerCompat.from(this)
notificationManager.notify(
NOTIFICATION_ID, builder.build())
}
以下是关于这段代码的一些注释:
-
KEY_TEXT_REPLY
用于标识意向接收方的回复文本。 -
CONVERSATION_ID
用于标识会话链;在这里,通知和接收回复的意图必须知道它们相互引用。 -
像往常一样,确保在生产代码中使用字符串资源和适当的文本。
当通知出现时,它将包含一个回复按钮,当用户单击它时,系统将提示输入一些回复文本,然后这些文本将被发送给意向接收方(本例中为messageReplyIntent
)。
回复文本的目的接收方可能会有一个如下所示的接收回调:
override fun onReceive(context: Context,
intent: Intent) {
Log.e("LOG", intent.toString())
val KEY_TEXT_REPLY = "key_text_reply"
val remoteInput = RemoteInput.
getResultsFromIntent(intent)
val txt = remoteInput?.
getCharSequence(KEY_TEXT_REPLY)?:"undefined"
val conversationId =
intent.getIntExtra("conversationId",0)
Log.e("LOG","reply text = " + txt)
// Do s.th. with the reply...
// Build a new notification, which informs the user
// that the system handled their interaction with
// the previous notification.
val NOTIFICATION_CHANNEL_ID = "1"
val repliedNotification =
NotificationCompat.Builder(context,
NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentText("Replied")
.build()
buildChannel(NOTIFICATION_CHANNEL_ID)
// Issue the new notification.
val notificationManager =
NotificationManagerCompat.from(context)
notificationManager.notify(conversationId,
repliedNotification)
}
以下是关于这种方法的一些注意事项:
-
使用与回复输入相同的键,通过使用
RemoteInput.getResultsFromIntent()
获取回复文本 -
获取我们作为额外值添加到 intent 中的对话 ID
-
做任何适当的事情来处理回复
-
通过设置另一个通知对回复进行回复
通知进度条
要将进度条添加到通知中,请将以下内容添加到生成器中,并将PROGRESS_MAX
作为最大整数值,并将PROGRESS_CURRENT 0
放在开头:
.setProgress(PROGRESS_MAX, PROGRESS_CURRENT, false)
或者,如果您想要一个不确定的进度条,您可以使用以下命令:
.setProgress(0, 0, true)
在执行该工作的后台线程中,您可以通过使用新的currentProgress
值定期执行以下操作来更新确定的进度条:
builder.setProgress(PROGRESS_MAX, currentProgress, false)
notificationManager.notify(
NOTIFICATION_ID, builder.build())
要完成一个确定的或不确定的进度条,您可以编写以下代码:
builder.setContentText("Download complete")
.setProgress(0,0,false)
notificationManager.notify(
NOTIFICATION_ID, builder.build())
可扩展通知
通知不必只包含短消息;使用可扩展的特征,可以向用户显示更多的信息。
有关如何操作的详细信息,请参考在线文档。例如,在您喜欢的搜索引擎中输入Android create expandable notification,即可找到相应的页面。
纠正活动导航
为了改善用户体验,可以添加从通知内部启动的活动的预期任务行为。例如,如果您单击 back 按钮,堆栈中下面的活动将被调用。为此,您必须在AndroidManifest.xml
中定义一个活动层次结构,例如,如下所示:
<activity
android:name=".MainActivity"
... >
</activity>
<!-- MainActivity is the parent for ResultActivity -->
<activity
android:name=".ResultActivity"
android:parentActivityName=".MainActivity" />
...
</activity>
然后,您可以使用一个TaskStackBuilder
来为调用的 intent 扩充任务堆栈。
// Create an Intent for the Activity you want to start
val resultIntent =
Intent(this, ResultActivity::class.java)
// Create the TaskStackBuilder
val stackBuilder = TaskStackBuilder.create(this)
stackBuilder.
addNextIntentWithParentStack(resultIntent)
// Get the PendingIntent containing the back stack
val resultPendingIntent =
stackBuilder.getPendingIntent(
0, PendingIntent.FLAG_UPDATE_CURRENT)
// -> this can go to .setContentIntent() inside
// the builder
有关活动和任务管理的更多详细信息,请参见第三章。
分组通知
从 API level 24 (Android 7.0)开始,可以对通知进行分组,以改进以某种方式相关的几个通知的表示。要创建这样一个组,您只需将以下内容添加到构建器链中,其中GROUP_KEY
是您选择的字符串:
.setGroup(GROUP_KEY)
如果需要自定义排序,默认是按进货日期排序;您可以使用构建器中的方法setSortKey()
。然后根据给定的键进行字典排序。通知抽屉内的分组可能如图 8-4 所示。
图 8-4
通知组
对于低于 24 的 API 级别,如果没有某种 Android 管理的组自动摘要,您可以添加一个通知摘要。为此,只需像创建任何其他通知一样创建一个通知,但是在构建器链中另外调用.setGroupSummary(true)
。确保来自群组和摘要的所有通知使用相同的setGroup(GROUP_KEY)
。
警告
因为至少在 API 级别 27 中有一个错误,你必须为要启用的分组添加一个摘要通知。因此,建议是,无论你的目标是什么 API 级别,都要添加一个通知摘要。
对于摘要,您可能希望定制显示样式,以显示适当数量的摘要项目。为此,您可以在构建器链中使用如下结构:
.setStyle(NotificationCompat.InboxStyle()
.addLine("MasterOfTheUniverse Go play PacMan")
.addLine("Silvia Cheng Party tonite")
.setBigContentTitle("2 new messages")
.setSummaryText("xyz@example.com"))
通知渠道
从 Android 8.0 (API level 26)开始,引入了另一种通过通知通道对通知进行分组的方式。这样做的目的是让设备用户能够更好地控制系统如何对通知进行分类和区分优先级,以及通知呈现给用户的方式。
要创建通知通道,您需要编写下面的代码,我们已经在前面的章节中看到了:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create the NotificationChannel, but only
// on API 26+ only after that it is needed
val channel = if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.O) {
NotificationChannel(channelId,
"Channel Name",
NotificationManager.IMPORTANCE_DEFAULT)
} else {
throw RuntimeException("Internal error")
}
channel.description = "Description"
// Register the channel with the system
val notificationManager =
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.M) {
getSystemService(
NotificationManager::class.java)
} else {
throw RuntimeException("Internal error")
}
notificationManager.
createNotificationChannel(channel)
}
说到 Kotlin 语言样式,这看起来有点笨拙。引入多余的if
结构是为了让 Android Studio 不会抱怨兼容性问题。根据您的需要修改通道 ID、通道名称和通道构造函数中的重要性,就像描述文本一样。
对了,最后一行的createNotificationChannel()
方法是幂等的。如果具有相同特征的通道已经存在,则不会发生任何事情。
在NotificationChannel
构造函数中,可能的重要性级别有:IMPORTANCE_HIGH
表示声音和提醒通知,IMPORTANCE_DEFAULT
表示声音,IMPORTANCE_LOW
表示没有声音,IMPORTANCE_MIN
表示既没有声音也没有状态栏。
话虽如此,如何处理通知通道取决于用户。在您的代码中,您仍然可以读取用户通过使用通过getNotificationChannel()
或getNotificationChannels()
从管理器获得的NotificationChannel
对象的get*()
方法之一所做的设置。有关详细信息,请参考在线 API 文档。
还有一个通知通道设置用户界面,您可以使用它来调用:
val intent = Intent(
Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra(Settings.EXTRA_APP_PACKAGE,
getPackageName())
intent.putExtra(Settings.EXTRA_CHANNEL_ID,
myNotificationChannel.getId())
startActivity(intent)
您可以通过分组的方式进一步组织通知渠道,例如,将与工作相关的渠道和私人类型的渠道分开。要创建一个组,您需要编写以下代码:
val groupId = "my_group"
// The user-visible name of the group.
val groupName = "Group Name"
val notificationMngr =
getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
notificationMngr.createNotificationChannelGroup(
NotificationChannelGroup(groupId, groupName))
然后,您可以通过使用其setGroup()
方法将该组添加到每个通知通道。
通知徽章
从 Android 8.0 (API level 26)开始,一旦通知到达系统,应用的图标上会出现一个通知标记。例如参见图 8-5 。
图 8-5
通知徽章
您可以使用表 8-6 中列出的NotificationChannel
方法之一来控制该徽章。
表 8-6
通知徽章
|方法
|
描述
|
| --- | --- |
| setShowBadge(Boolean)
| 这指定是否显示工卡。 |
| setNumber(Int)
| 长按带有徽章的应用图标将显示已收到的通知数量。您可以使用这种方法根据自己的需要定制这个数字。 |
| setBadgeIconType(Int)
| 长按带有徽章的应用图标将显示与通知关联的图标。您可以使用此方法定制图标的大小。可能的值在NotificationCompat
: BADGE_ICON_NONE
、BADGE_ICON_SMALL
和BADGE_ICON_LARGE
类中作为常数给出。 |
联系人
管理和使用联系人是手持设备必须真正擅长的任务之一。毕竟,手持设备,尤其是智能手机,通常习惯于与他人交流,而联系人是抽象的实体,代表着人、团体、公司或其他“事物”,您可以用它们作为交流需求的地址点。
随着联系人如此重要,内置联系人框架在 Android 的历史上变得相当复杂。幸运的是,如果我们限制自己只看后端部分,忽略本书其他章节中描述的用户界面特性,复杂性可以有所降低。下面是对联系人框架的描述:
-
查看内部,尤其是使用的数据库模型
-
了解如何读取联系人数据
-
了解如何写入联系人数据
-
调用系统活动来处理单个联系人
-
同步联系人
-
使用快速联系徽章
联系人框架内部
与内容框架通信的基类是android.content.ContentResolver
类。这很有意义,因为联系数据非常适合内容供应器处理的内容。因此,您经常使用内容供应器操作来处理联系人数据。更多信息参见第六章。
数据模型由三个主表组成:Contacts
、Raw Contacts
、Data
。此外,还存在一些用于管理任务的辅助表。您通常不必处理任何类型的直接表访问,但是如果您感兴趣,可以看看在线 contacts 框架文档和ContactsContract
类的文档,它们详细描述了 contacts 的内容提供者契约。
如果您想直接查看 contacts 表,使用虚拟或根设备的 ADB,您可以在终端中使用cd SDK_INST/platform-tools ; ./adb root ; ./adb shell
创建对您的设备的 shell 访问;参见第十八章了解更多信息,并从那里研究如下表格:
cd /data
find . -name 'contacts*.db'
# <- this is to locate the contacts DB
cd <folder-for-contacts-db>
sqlite3 <name-of-contacts-db-file>
例如,输入.header on
打开表头输出,.tables
列出所有表名,select * from raw_contacts;
列出Raw Contacts
表。
阅读联系人
为了根据某种标准读取大量联系人,您应该按照“加载器”一节中的描述创建一个加载器。为了稍微提高代码质量,我们将加载责任放在我们自己的类上,并编写以下代码:
import android.app.Activity
import android.app.LoaderManager
import android.content.CursorLoader
import android.content.Loader
import android.database.Cursor
import android.os.Bundle
import android.provider.ContactsContract
import android.util.Log
import android.net.Uri.withAppendedPath
class ContactsLoader(val actv: Activity?,
val search:String):
LoaderManager.LoaderCallbacks<Cursor> {
override fun onCreateLoader(id: Int, args: Bundle?):
Loader<Cursor>? {
Log.e("LOG", "onCreateLoader()")
val PROJECTION = arrayOf(
ContactsContract.Contacts._ID,
ContactsContract.Contacts.LOOKUP_KEY,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
val SELECTION =
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY
+ " LIKE ?"
val selectionArgs = arrayOf("%" + search + "%")
val contentUri =
ContactsContract.Contacts.CONTENT_URI
Log.e("LOG", contentUri.toString())
// Starts the query
return CursorLoader(
actv,
contentUri,
PROJECTION,
SELECTION,
selectionArgs,
null
)
}
override fun onLoadFinished(loader: Loader<Cursor>,
data: Cursor) {
Log.e("LOG", "Load finished: " + data)
if(data.moveToFirst()) {
do {
Log.e("LOG", "Entry:")
data.columnNames.forEachIndexed { i, s ->
Log.e("LOG", " -> " + s + " -> "
+ data.getString(i))
}
} while (data.moveToNext())
}
// show on UI or other actions...
}
override fun onLoaderReset(loader: Loader<Cursor>?) {
Log.e("LOG", "onLoaderReset()")
// remove from UI or other actions...
}
}
由于我们在这里将ContactsContract.Contacts.CONTENT_URI
用作 URI,这将在Contacts
表中进行搜索,返回基本的联系人数据。
要初始化和启动加载程序,剩下要做的事情就是,例如,在您的活动中:
val searchStr = "" // or whatever
val ldr = ContactsLoader(this, searchStr)
loaderManager.initLoader(0, null, ldr)
相反,如果您想在包含电话号码、电子邮件地址等信息的Data
表中进行搜索,您可以在ContactsLoader.onCreateLoader()
中编写以下内容:
...
val PROJECTION = arrayOf(
ContactsContract.Data._ID,
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
ContactsContract.CommonDataKinds.Email.ADDRESS)
val SELECTION =
ContactsContract.CommonDataKinds.Email.ADDRESS
+ " LIKE ? " + "AND "
+ ContactsContract.Data.MIMETYPE + " = '"
+ ContactsContract.
CommonDataKinds.Email.CONTENT_ITEM_TYPE
+ "'"
val selectionArgs = arrayOf("%" + search + "%")
val contentUri = ContactsContract.Data.CONTENT_URI
Log.e("LOG", contentUri.toString())
...
你也可以使用特殊的 URIs。例如,要通过电子邮件地址查找联系人,您可以使用内容 URI ContactsContract.CommonDataKinds.Email.CONTENT_URI
。
作为第三种可能性,ContactsContract.Contacts.CONTENT_FILTER_URI
给出的 URI 允许在 URI 中添加搜索条件,而不是在CursorLoader
构造函数中指定它们。
...
val PROJECTION : Array<String>? = null
val SELECTION : String? = null
val selectionArgs : Array<String>? = null
val contentUri = Uri.withAppendedPath(
ContactsContract.Contacts.CONTENT_FILTER_URI,
Uri.encode(search))
Log.e("LOG", contentUri.toString())
...
注意,在这种情况下,不允许传递空字符串(""
)作为搜索标准。
书写联系人
插入或更新联系人最好在批处理模式下进行。您从项目类型ContentProviderOperation
的列表开始,并用如下操作填充它:
import android.content.Context
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.provider.ContactsContract
import android.content.ContentValues.TAG
import android.util.Log
import android.widget.Toast
class ContactsWriter(val ctx:Context, val contentResolver:
ContentResolver) {
val opList = mutableListOf<ContentProviderOperation>()
fun addContact(accountType:String, accountName:String,
firstName:String, lastName:String,
emailAddr:String, phone:String) {
val firstOperationIndex = opList.size
在这个方法中,我们首先创建一个新的联系人。Contacts
表将自动填充;无论如何,直接访问是不可能的。需要设备的用户帐户和帐户类型;否则,操作将会失败!
// Creates a new raw contact.
var op = ContentProviderOperation.newInsert(
ContactsContract.RawContacts.CONTENT_URI)
.withValue(
ContactsContract.RawContacts.ACCOUNT_TYPE,
accountType)
.withValue(
ContactsContract.RawContacts.ACCOUNT_NAME,
accountName)
opList.add(op.build())
接下来,仍然在该方法中,我们为新行创建一个显示名称。这是表StructuredName
中的一行。
// Creates the display name for the new row
op = ContentProviderOperation.newInsert(
ContactsContract.Data.CONTENT_URI)
// withValueBackReference will make sure the
// foreign key relations will be set
// correctly
.withValueBackReference(
ContactsContract.Data.RAW_CONTACT_ID,
firstOperationIndex)
// The data row's MIME type is StructuredName
.withValue(ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.
StructuredName.CONTENT_ITEM_TYPE)
// The row's display name is the name in the UI.
.withValue(ContactsContract.CommonDataKinds.
StructuredName.DISPLAY_NAME,
firstName + " " + lastName)
opList.add(op.build())
同样,我们添加电话号码和电子邮件地址。
// The specified phone number
op = ContentProviderOperation.newInsert(
ContactsContract.Data.CONTENT_URI)
// Fix foreign key relation
.withValueBackReference(
ContactsContract.Data.RAW_CONTACT_ID,
firstOperationIndex)
// Sets the data row's MIME type to Phone
.withValue(ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.
Phone.CONTENT_ITEM_TYPE)
// Phone number and type
.withValue(ContactsContract.CommonDataKinds.
Phone.NUMBER, phone)
.withValue(ContactsContract.CommonDataKinds.
Phone.TYPE,
android.provider.ContactsContract.
CommonDataKinds.Phone.TYPE_HOME)
opList.add(op.build())
// Inserts the email
op = ContentProviderOperation.newInsert(
ContactsContract.Data.CONTENT_URI)
// Fix the foreign key relation
.withValueBackReference(
ContactsContract.Data.RAW_CONTACT_ID,
firstOperationIndex)
// Sets the data row's MIME type to Email
.withValue(ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.
Email.CONTENT_ITEM_TYPE)
// Email address and type
.withValue(ContactsContract.CommonDataKinds.
Email.ADDRESS, emailAddr)
.withValue(ContactsContract.CommonDataKinds.
Email.TYPE,
android.provider.ContactsContract.
CommonDataKinds.Email.TYPE_HOME)
最后,在结束方法之前,我们添加一个屈服点。这没有功能上的影响,但是引入了一个中断,因此系统可以做其他工作来提高可用性。以下代码片段也包含该类的其余部分:
// Add a yield point.
op.withYieldAllowed(true)
opList.add(op.build())
}
fun reset() {
opList.clear()
}
fun doAll() {
try {
contentResolver.applyBatch(
ContactsContract.AUTHORITY,
opList as ArrayList)
} catch (e: Exception) {
// Display a warning
val duration = Toast.LENGTH_SHORT
val toast = Toast.makeText(ctx,
"Something went wrong", duration)
toast.show()
// Log exception
Log.e("LOG", "Exception encountered "+
"while inserting contact: " + e, e)
}
}
}
这使用了一个固定的电话类型和一个固定的电子邮件类型,但是我想你明白了。此外,确保在生产代码中使用资源字符串,而不是硬编码的字符串,如下所示。要使用该类,您必须在活动内部完成以下工作:
val cwr = ContactsWriter(this, contentResolver)
cwr.addContact("com.google","user@gmail.com",
"Peter","Kappa",
"post@kappa.com","0123456789")
cwr.addContact("com.google","user@gmail.com",
"Hilda","Kappa",
"post2@kappa.com","0123456789")
cwr.doAll()
为了更新联系人条目,我们在ContactsWriter
类中引入了另一个函数。
fun updateContact(id:String, firstName:String?,
lastName:String?, emailAddr:String?, phone:String?) {
var op : ContentProviderOperation.Builder? = null
if(firstName != null && lastName != null) {
op = ContentProviderOperation.newUpdate(
ContactsContract.Data.CONTENT_URI)
.withSelection(ContactsContract.Data.CONTACT_ID +
" = ? AND " + ContactsContract.Data.MIMETYPE +
" = ?",
arrayOf(id, ContactsContract.CommonDataKinds.
StructuredName.CONTENT_ITEM_TYPE))
.withValue(ContactsContract.Contacts.DISPLAY_NAME,
firstName + " " + lastName)
opList.add(op.build())
}
if(emailAddr != null) {
op = ContentProviderOperation.newUpdate(
ContactsContract.Data.CONTENT_URI)
.withSelection(ContactsContract.Data.CONTACT_ID +
" = ? AND " + ContactsContract.Data.MIMETYPE +
" = ?",
arrayOf(id, ContactsContract.CommonDataKinds.
Email.CONTENT_ITEM_TYPE))
.withValue(ContactsContract.CommonDataKinds.Email.
ADDRESS, emailAddr)
opList.add(op.build())
}
if(phone != null) {
op = ContentProviderOperation.newUpdate(
ContactsContract.Data.CONTENT_URI)
.withSelection(ContactsContract.Data.CONTACT_ID +
" = ? AND " + ContactsContract.Data.MIMETYPE +
" = ?",
arrayOf(id, ContactsContract.CommonDataKinds.
Phone.CONTENT_ITEM_TYPE))
.withValue(ContactsContract.CommonDataKinds.Phone.
NUMBER, phone)
opList.add(op.build())
}
}
作为输入,需要来自Raw Contacts
表的 ID 键;任何不是null
的函数参数都会被更新。例如,您可以在活动中编写以下内容:
val rawId = ...
val cwr = ContactsWriter(this, contentResolver)
cwr.updateContact(rawId, null, null,
"postXXX@kappa.com", null)
cwr.doAll()
作为最后一个功能,我们添加了删除联系人的可能性,也是基于 ID。
fun delete(id:String) {
var op = ContentProviderOperation.newDelete(
ContactsContract.RawContacts.CONTENT_URI)
.withSelection(ContactsContract.RawContacts.
CONTACT_ID + " = ?",
arrayOf(id))
opList.add(op.build())
}
使用它类似于更新。
val rawId = ...
val cwr = ContactsWriter(this, contentResolver)
cwr.delete(rawId)
cwr.doAll()
使用联系人系统活动
要读取或更新单个联系人,您可以避免编写自己的用户界面。只需使用系统活动来访问联系人。创建单个联系人的合适意向调用如下所示:
val intent = Intent(Intents.Insert.ACTION)
intent.setType(ContactsContract.RawContacts.CONTENT_TYPE)
intent.putExtra(Intents.Insert.EMAIL, emailAddress)
.putExtra(Intents.Insert.EMAIL_TYPE,
CommonDataKinds.Email.TYPE_WORK)
.putExtra(Intents.Insert.PHONE, phoneNumber)
.PutExtra(Intents.Insert.PHONE_TYPE, Phone.
TYPE_WORK)
startActivity(intent)
这将打开用于创建新联系人的联系人屏幕,并预先填写给定的字段。
相反,要编辑现有的联系人,在获得查找关键字和原始联系人 ID 后,如前所示,编写以下内容:
val uri = Contacts.getLookupUri(id, lookupKey)
val intent = Intent(Intent.ACTION_EDIT)
// the following must be done in _one_ call, do not
// chain .setData() and .setType(), because they
// overwrite each other!
intent.setDataAndType(uri, Contacts.CONTENT_ITEM_TYPE)
// starting at API level 15, this is needed:
intent.putExtra("finishActivityOnSaveCompleted", true)
// now put any data to update, for example
intent.putExtra(Intents.Insert.EMAIL, newEmail)
...
startActivity(intent)
同步联系人
在这里,我们提供了一个简短的大纲,如果你想写一个你的设备和服务器之间的联系人同步应用该怎么做。
-
构建
android.app.Application
的子类,在文件AndroidManifest.xml
的<application>
标签内注册为name
。在它的onCreate()
回调中,实例化一个SyncAdapter
并提供一个SyncAdapter
服务可以获取这个实例的方法。 -
构建系统可用于同步的可绑定服务组件。
-
实现
SyncAdapter
,例如通过子类化AbstractThreadedSyncAdapter
。 -
提供一个 XML 文件来告诉系统关于适配器的信息。该程序在
AbstractThreadedSyncAdapter
的在线 API 文档中有描述。 -
可选地提供用于认证的服务。
AccountManager
启动该服务以开始认证过程。当系统想要验证应用同步适配器的用户帐户时,它调用服务的onBind()
方法来获取验证者的IBinder
。 -
可选地提供一个子类
AbstractAccountAuthenticator
,它处理认证请求。
使用快速联系徽章
快速联系徽章允许你使用一个 GUI 部件,你的用户可以点击它来查看联系人的详细信息,并从那里采取任何合适的行动,如发送电子邮件、发出呼叫或任何有意义的事情。该详细信息屏幕由系统显示;你不必在你的应用中实现它。见图 8-6 。
图 8-6
快速联系徽章
要生成这样的快速联系人徽章,您必须在布局文件中添加以下内容:
<QuickContactBadge
android:id="@+id/quickBadge"
android:layout_width="60dp"
android:layout_height="60dp"
android:scaleType="centerCrop"/>
在您的代码中,您必须将徽章连接到从联系人提供程序获得的以下信息:原始联系人 ID、查找关键字和缩略图 URI。相应的代码可能如下所示:
val id = row[ContactsContract.Contacts._ID]
val lookup = row[ContactsContract.Contacts.
LOOKUP_KEY]
val photo = row[ContactsContract.Contacts.
PHOTO_THUMBNAIL_URI]
例如,这里的row
是从 contacts 内容供应器查询中获得的地图。在这种情况下,Raw Contact
中的一个查询就足够了;你不需要也查询Data
表。
在这里,我们按如下方式配置徽章,例如,在您通过用户界面活动加载联系人信息之后:
val contactUri = ContactsContract.Contacts.getLookupUri(
id?.toLong()?:0,
lookup)
quickBadge.assignContactUri(contactUri)
val thumbnail =
loadContactPhotoThumbnail(photo.toString())
quickBadge.setImageBitmap(thumbnail)
这里,loadContactPhotoThumbnail()
功能加载缩略图图像数据。
private fun loadContactPhotoThumbnail(photoData: String):
Bitmap? {
var afd: AssetFileDescriptor? = null
try {
val thumbUri = Uri.parse(photoData)
afd = contentResolver.
openAssetFileDescriptor(thumbUri, "r")
afd?.apply {
fileDescriptor?.apply {
return BitmapFactory.decodeFileDescriptor(
this, null, null)
}
}
} catch (e: FileNotFoundException) {
// Handle file not found errors ...
} finally {
afd?.close()
}
return null
}
搜索框架
搜索框架允许您无缝地将搜索功能添加到您的应用中,并将您的应用注册为 Android 操作系统中的可搜索项目供应器。
谈到用户界面,您有两种选择。
-
打开搜索对话框
-
通过
SearchView
向用户界面添加搜索小工具
更准确地说,要在你的应用中包含搜索功能,你必须这样做:
-
以 XML 文件的形式提供可搜索的配置。
-
提供一个活动,该活动(a)能够接收搜索查询,(b)在应用数据内执行搜索,以及(c)显示搜索结果。
-
提供一个对话框或搜索小部件。
本节的其余部分将介绍这些要求。
可搜索的配置
可搜索的配置是一个名为searchable.xml
的文件,它位于项目的文件夹/res/xml
中。该文件最基本的内容如下:
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android=
"http://schemas.android.com/apk/res/android"
android:label="@string/app_label"
android:hint="@string/search_hint" >
</searchable>
@string/...
指向本地化的字符串资源。@string/app_label
指向一个标签,应该等于<application>
元素的label
属性的名称。另一个是@string/search_hint
,如果还没有输入任何内容,它将显示在搜索字段中。这是推荐的,应该显示类似于Search <content>
的内容,而<content>
是针对你的应用提供的数据的。还有更多可能的属性和一些可选的子元素;我们将在下面的章节中提到一些。有关完整列表,请参见“可搜索配置”部分的在线文档。
可搜索的活动
对于在应用中处理搜索相关问题的活动,从它在AndroidManifest.xml
中的声明开始。活动需要有一个特殊的签名,如下所示:
<activity android:name=".SearchableActivity" >
<intent-filter>
<action android:name=
"android.intent.action.SEARCH" />
</intent-filter>
<meta-data android:name="android.app.searchable"
android:resource="@xml/searchable"/>
</activity>
活动名称由你决定;所有其他标签和属性必须如这里所示。
接下来,我们让这个活动接收搜索请求。这是在它的onCreate()
回调中完成的,如下所示:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_searchable)
// This is the standard way a system search dialog
// or the search widget communicates the query
// string:
if (Intent.ACTION_SEARCH == intent.action) {
val query =
intent.getStringExtra(SearchManager.QUERY)
doMySearch(query)
}
// More initialization if necessary...
}
doMySearch()
函数应该执行搜索并在SearchableActivity
中显示结果。这样做的方式完全取决于应用;这可能是一个数据库搜索或使用内容供应器或任何你想要的搜索。
搜索对话框
对于任何打开系统搜索对话框并让它将输入的查询传递给SearchableActivity
的活动,您在AndroidManifest.xml
中编写以下内容:
<activity android:name=".SearchableActivity" >
<!-- same as above -->
</activity>
<activity android:name=".MainActivity"
android:label="Main">
<!-- ... -->
<!-- Enable the search dialog and let it send -->
<!-- the queries to SearchableActivity -->
<meta-data android:name=
"android.app.default_searchable"
android:value=
".SearchableActivity" />
</activity>
这个例子允许MainActivity
打开系统的搜索对话框。事实上,你可以在你的应用中使用任何合适的活动来达到这个目的。
要在搜索活动中打开搜索对话框,请编写以下内容:
onSearchRequested()
注意
通常直接执行以on...
开头的明显回调函数有一股臭味。你通常这样做是为了半合法的捷径。我们之所以要在这里做,是因为你的设备可能有一个专用的搜索按钮。在这种情况下,onSearchRequested()
被系统调用,它是一个真正的回调方法。因为这样的按钮是可选的,但是,有必要在你的应用中提供一个搜索启动器。
图 8-7 显示了基于对话框的搜索流程。
图 8-7
基于对话的搜索流程
搜索小部件
您可以在 UI 中放置一个<SearchView>
小部件,而不是打开系统搜索对话框。虽然原则上你可以把它放在任何你喜欢的地方,但是建议你把它放在动作栏里。为此,如果您已经设置了一个动作栏并在那里定义了一个菜单,那么在 XKML 菜单定义中您可以编写以下代码:
<menu xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:app=
"http://schemas.android.com/apk/res-auto">
<!-- Usually you have Settings in any menu -->
<item android:id="@+id/action_settings"
android:title="Settings"
app:showAsAction="never"/>
<item android:id="@+id/action_search"
android:title="Search"
app:showAsAction="ifRoom|collapseActionView"
app:actionViewClass=
"android.support.v7.widget.SearchView"
android:icon=
"@android:drawable/ic_menu_search"/>
<!-- more items ... ->
</menu>
接下来需要在你的应用中做什么来连接小部件和搜索框架,如下所示:
// Set the searchable configuration
val searchManager = getSystemService(SEARCH_SERVICE)
as SearchManager
val searchView = menu.findItem(R.id.action_search).
actionView as SearchView
searchView.setSearchableInfo(
searchManager.getSearchableInfo(componentName))
// Do not iconify the widget; expand it by default:
searchView.setIconifiedByDefault(false)
就是这样!流程如图 8-8 所示(您可以点击操作栏中的搜索图标)。
图 8-8
基于小部件的搜索流
搜索建议
有两种方法可以帮助用户输入搜索查询字符串。你可以让系统记忆下一次使用搜索时的查询,你也可以让你的应用提供完全可定制的建议。
最近的查询和建议
对于最近的查询建议,您实现了一个内容提供者子类SearchRecentSuggestionsProvider
,并像其他内容提供者一样将其添加到AndroidManifest.xml
。一个基本但已经完全实现的内容提供者如下所示:
class RecentsProvider :
SearchRecentSuggestionsProvider {
val AUTHORITY = "com.example.RecentsProvider"
val MODE = DATABASE_MODE_QUERIES
init {
setupSuggestions(AUTHORITY, MODE)
}
}
在/res/xml/searchable.xml
中注册如下:
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android=
"http://schemas.android.com/apk/res/android"
android:label="@string/app_label"
android:hint="@string/search_hint"
android:searchSuggestAuthority=
"com.example.RecentsProvider"
android:searchSuggestSelection=
" ?">
</searchable>
New 是最后两个属性。这里的android:searchSuggestAuthority
属性绘制了一个到提供者的连接。
内容供应器仍必须在AndroidManifest.xml
注册。例如,内容如下:
<provider
android:name=".RecentsProvider"
android:authorities="com.example.RecentsProvider"
android:enabled="true"
android:exported="true">
</provider>
这将从自动生成的数据库中读取以前的查询。剩下要做的就是添加搜索查询。为此,在SearchableActivity
类中编写以下代码:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_searchable)
// This is the standard way a system search dialog
// or the search widget communicates the query
// string:
if (Intent.ACTION_SEARCH == intent.action) {
val query =
intent.getStringExtra(SearchManager.QUERY)
// Add it to the recents suggestion database
val suggestions = SearchRecentSuggestions(this,
RecentsProvider.AUTHORITY, RecentsProvider.MODE)
suggestions.saveRecentQuery(q, null)
doMySearch(query)
}
// More initialization if necessary...
}
saveRecentQuery()
方法的第二个参数可以是用于注释目的的第二行。要做到这一点,您必须使用RecentsProvider
中的val MODE = DATABASE_MODE_QUERIES
或DATABASE_MODE_2LINES
,并想办法在SearchableActivity
类中检索注释文本。
自定义建议
与最近的建议相比,自定义建议更有效。它们可以完全特定于应用或领域,您可以为用户提供智能建议,为当前用户操作量身定制。与最近的建议相比,你可以通过遵守某些规则来实现和注册一个ContentProvider
。
-
The Android OS will fire
ContentProvider.query(uri, projection, selection, selectionArgs, sortOrder
calls with URIs like the following:content://your.authority/ optional.suggest.path/ SUGGEST_URI_PATH_QUERY/ <query>
这里,
your.authority
是内容提供者权限,/optional.suggest.path
可以由搜索配置添加用于消除歧义,SUGGEST_URI_PATH_QUERY
是常量SearchManager.SUGGEST_URI_PATH_QUERY
的值。<query>
包含要搜索的字符串。只有在搜索配置中正确配置后,selection
和selectionArgs
参数才会被填充。 -
结果
Cursor
必须返回以下字段(显示的是常量名称):这是您必须提供的(技术上)唯一 ID。这是搜索建议。(可选)这是第二个不太重要的字符串,表示注释文本。这是与意向一起发送的意向数据成员。这是要附加到 intentdata
成员的字符串。这是有目的地发送的额外数据。这是原始查询字符串。-
BaseColumns._ID
This is a (technically) unique ID you must provide.
-
SearchManager.SUGGEST_COLUMN_TEXT_1
This is the search suggestion.
-
SearchManager.SUGGEST_COLUMN_TEXT_2
(optional) This is a second, less important string representing an annotation text.
-
SearchManager.SUGGEST_COLUMN_ICON_1
(可选)这是要在左侧显示的图标的可绘制资源 ID、内容或文件 URI 字符串。
-
SearchManager.SUGGEST_COLUMN_ICON_2
(可选)这是要在右侧显示的图标的可绘制资源 ID、内容或文件 URI 字符串。
-
SearchManager.SUGGEST_COLUMN_INTENT_ACTION
(可选)这是一个 intent 操作字符串,用于在建议被单击时调用 intent。
-
SearchManager.SUGGEST_COLUMN_INTENT_DATA
This is an intent data member to be sent with the intent.
-
SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID
This is a string to be appended to the intent
-
SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA
This is extra data to be sent with the intent.
-
SearchManager.SUGGEST_COLUMN_QUERY
This is the original query string.
-
SearchManager.SUGGEST_COLUMN_SHORTCUT_ID
这在为快速搜索框提供建议时使用。它指示是否应该将搜索建议存储为快捷方式,以及是否应该对其进行验证。
-
SearchManager.SUGGEST_COLUMN_SPINNER_WHILE_ REFRESHING
这在为快速搜索框提供建议时使用;当此建议的快捷方式在快速搜索框中刷新时,应该显示一个微调器,而不是来自
SUGGEST_COLUMN_ICON_2
的图标。
-
让我们尝试为自定义建议构建一个有效的示例。我们从前面描述的最近建议提供者的工作示例开始。使用对话框还是小部件方法并不重要。请注意,不同之处在于内容提供者和搜索配置。
对于由文件/res/xml/searchable.xml
定义的搜索配置,输入以下内容:
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android=
"http://schemas.android.com/apk/res/android"
android:label=
"@string/app_label"
android:hint=
"@string/search_hint"
android:searchSuggestAuthority=
"com.example.CustomProvider"
android:searchSuggestIntentAction=
"android.intent.action.VIEW">
</searchable>
接下来,我们定义一个新的内容供应器。
class CustomProvider : ContentProvider() {
override fun query(uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?): Cursor? {
Log.e("LOG", "query(): " + uri +
" - projection=" +
Arrays.toString(projection) +
" - selection=" + selection +
" - selectionArgs=" +
Arrays.toString(selectionArgs) +
" - sortOrder=" + sortOrder)
return null
}
override fun delete(uri: Uri, selection: String?,
selectionArgs: Array<String>?): Int {
throw UnsupportedOperationException(
"Not yet implemented")
}
override fun getType(uri: Uri): String? {
throw UnsupportedOperationException(
"Not yet implemented")
}
override fun insert(uri: Uri, values: ContentValues?):
Uri? {
throw UnsupportedOperationException(
"Not yet implemented")
}
override fun onCreate(): Boolean {
return false
}
override fun update(uri: Uri, values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?): Int {
throw UnsupportedOperationException(
"Not yet implemented")
}
}
在AndroidManifest.xml
中注册。
<provider
android:name=".CustomProvider"
android:authorities="com.example.CustomProvider"
android:enabled="true"
android:exported="true">
</provider>
这是目前为止当用户开始搜索时发生的情况:
-
每当用户输入或删除一个字符时,系统将通过查看它需要的
searchSuggestAuthority
属性来进入它看到的搜索配置,因此它可以用分配给它的这个权限来寻址内容供应器。 -
通过查看
AndroidManifest.xml
,它看到这个授权连接到提供者类CustomProvider
。 -
它调用这个提供者上的一个
query()
,并期望一个Cursor
返回定制的建议。 -
如果用户点击一个建议,凭借设置为
android.intent.action.VIEW
的searchSuggestIntentAction
属性,SearchableActivity
的onCreate()
将看到带有VIEW
动作的输入意图。
到目前为止,我们让query()
方法返回null
,这相当于没有建议,但是我们添加了一个日志记录语句,所以我们可以看到query()
方法得到了什么。例如,当用户输入sp
时,到目前为止的参数将如下所示:
query(): content://com.example.CustomProvider/
search_suggest_query/sp?limit=50
projection=null
selection=null
selectionArgs=null
sortOrder=null
搜索框架发送给query()
方法的参数可以通过各种搜索配置属性进行大量定制。然而,现在我们建议您参考在线文档,并继续从第一个uri
参数中提取信息。如何构建Cursor
对象在第六章中有描述;对于这个例子,我们使用一个MatrixCursor
,而不是返回null
,例如,我们可以从SearchableActivity
内部返回以下内容:
override fun query(uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?): Cursor? {
Log.e("LOG", "query(): " + uri +
" - projection=" +
Arrays.toString(projection) +
" - selection=" + selection +
" - selectionArgs=" +
Arrays.toString(selectionArgs) +
" - sortOrder=" + sortOrder)
val lps = uri.lastPathSegment // the query
val qr = uri.encodedQuery // e.g. "limit=50"
val curs = MatrixCursor(arrayOf(
BaseColumns._ID,
SearchManager.SUGGEST_COLUMN_TEXT_1,
SearchManager.SUGGEST_COLUMN_INTENT_DATA
))
curs.addRow(arrayOf(1, lps + "-Suggestion 1",
lps + "-Suggestion 1"))
curs.addRow(arrayOf(2, lps + "-Suggestion 2",
lps + "-Suggestion 2"))
return curs
}
这个例子只提供了一些愚蠢的建议;你可以给MatrixCursor
写一些更巧妙的东西。
作为最后一项修改,您可以将您的搜索建议提供给系统的quick 搜索框。你所要做的就是将android:includeInGlobalSearch = true
添加到你的搜索配置中。用户必须允许此连接的设置生效。
位置和地图
手持设备可以跟踪它们的地理位置,并且它们可以与地图服务交互,以图形方式干扰用户的位置需求。地理位置不仅仅是纬度和经度的数字,还包括街道地址和兴趣点。虽然这肯定是一个重要的伦理问题,应用可以在多大程度上跟踪用户的个人生活,但有趣的应用和游戏的可能性是无限的。在本节中,我们将讨论技术上的可能性。只是要小心你的用户的数据,并对他们的行为保持透明。
Android OS 本身包含一个位置框架,在包android.location
中有类。然而,谷歌的官方立场是倾向于 Google Play 服务位置 API,因为它更精细,使用起来更简单。我们遵循这个建议,在下面的段落中讨论服务 API。位置是指找出设备的地理位置,作为一个经纬度对,并找出街道名称、门牌号和给定地理位置的其他感兴趣的点。
要使服务位置 API 对您的应用可用,请将以下内容作为依赖项添加到您的应用模块的build.gradle
文件中(只有两行;删除implementation
后的换行符):
implementation
'com.google.android.gms:play-services-location:11.8.0'
implementation
'com.google.android.gms:play-services-maps:11.8.0'
最后已知位置
获取设备位置的最简单方法是获取最后已知的位置。为此,请在您的应用请求权限中添加以下内容:
<uses-permission android:name=
"android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name=
"android.permission.ACCESS_FINE_LOCATION"/>
小于 10 码的 GPS 分辨率需要FINE
位置许可,而大约 100 码的更粗的基于网络的分辨率需要COARSE
位置许可。将两者都添加到清单文件中给了我们最多的选择,但是根据您的需要,您可以继续使用粗略的选项。
然后,在您的组件内部(例如,在onCreate()
内部),您如下构造一个FusedLocationProviderClient
:
var fusedLocationClient: FusedLocationProviderClient? =
null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
fusedLocationClient = LocationServices.
getFusedLocationProviderClient(this)
}
在应用中的任何需要的地方,您都可以使用它来获取最近的已知位置。
if (checkPermission(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION)) {
fusedLocationClient?.lastLocation?.
addOnSuccessListener(this,
{location : Location? ->
// Got last known location. In some rare
// situations this can be null.
if(location == null) {
// TODO, handle it
} else location.apply {
// Handle location object
Log.e("LOG", location.toString())
}
})
}
这里,checkPermission()
按照第七章中的描述检查并可能获取所需的权限。例如,这可能是以下内容:
val PERMISSION_ID = 42
private fun checkPermission(vararg perm:String) :
Boolean {
val havePermissions = perm.toList().all {
ContextCompat.checkSelfPermission(this,it) ==
PackageManager.PERMISSION_GRANTED
}
if (!havePermissions) {
if(perm.toList().any {
ActivityCompat.
shouldShowRequestPermissionRationale(this, it)}
) {
val dialog = AlertDialog.Builder(this)
.setTitle("Permission")
.setMessage("Permission needed!")
.setPositiveButton("OK",{
id, v ->
ActivityCompat.requestPermissions(
this, perm, PERMISSION_ID)
})
.setNegativeButton("No",{
id, v ->
})
.create()
dialog.show()
} else {
ActivityCompat.requestPermissions(this, perm,
PERMISSION_ID)
}
return false
}
return true
}
为了简单起见,我使用字符串作为按钮标签和消息。对于生产代码,请确保使用资源!如有必要,函数checkPermission()
试图从系统活动中获取许可。无论用户是否授予权限,从该活动返回后,您的应用可能会相应地对结果做出反应。
override
fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<String>,
grantResults: IntArray) {
when (requestCode) {
PERMISSION_ID -> {
...
}
...
}
}
警告
“最后已知地点”的概念有些模糊。例如,在仿真设备中,通过所提供的设备控制来设置位置以改变最后已知的位置是不够的。只有在像 Google Maps 这样的应用使用位置更新机制之后,这里描述的代码才会返回正确的值。以下部分描述的机制更复杂,但也更可靠。
跟踪位置更新
如果您的应用需要跟踪位置变化的更新,您可以采用不同的方法。首先,所需的权限与前面提到的最后一个已知位置的权限相同,因此没有任何变化。不同之处在于向融合位置供应器请求定期更新。为此,我们需要定义一个位置设置对象。令人困惑的是,对应的类被称为LocationRequest
(如果称为LocationRequestSettings
或其他什么会更好)。要创建一个,请编写以下内容:
val reqSetting = LocationRequest.create().apply {
fastestInterval = 10000
interval = 10000
priority = LocationRequest.PRIORITY_HIGH_ACCURACY
smallestDisplacement = 1.0f
}
.apply
构造让我们可以更快地配置对象。例如,fastestInterval = 10000
在内部被翻译成reqSetting.setFastestInterval(10000)
。各个设置的含义如下:
-
fastestInterval
位置提供程序的最快总更新时间间隔(毫秒)。
-
interval
以毫秒为单位的请求间隔。这个设置只是近似的。
-
priority
The requested accuracy. This setting influences the battery usage. The following are possible values (constants from
LocationRequest
):-
只有当其他请求者主动请求更新时,才获取被动更新
-
PRIORITY_LOW_POWER
:仅在“城市”级别更新 -
PRIORITY_BALANCED_POWER_ACCURACY
:仅在“城市街道”级别更新 -
PRIORITY_HIGH_ACCURACY
:使用可能的最高精度
该值将根据可用权限进行内部调整。
-
-
smallestDisplacement
这是触发更新消息所需的最小位移,以米为单位。
通过定位请求设置,我们检查系统是否能够满足我们的请求。这发生在以下代码片段中:
val REQUEST_CHECK_STATE = 12300 // any suitable ID
val builder = LocationSettingsRequest.Builder()
.addLocationRequest(reqSetting)
val client = LocationServices.getSettingsClient(this) client.checkLocationSettings(builder.build()).
addOnCompleteListener { task ->
try {
val state: LocationSettingsStates = task.result.
locationSettingsStates
Log.e("LOG", "LocationSettings: \n" +
" BLE present: ${state.isBlePresent} \n" +
" BLE usable: ${state.isBleUsable} \n" +
" GPS present: ${state.isGpsPresent} \n" +
" GPS usable: ${state.isGpsUsable} \n" +
" Location present: " +
"${state.isLocationPresent} \n" +
" Location usable: " +
"${state.isLocationUsable} \n" +
" Network Location present: " +
"${state.isNetworkLocationPresent} \n" +
" Network Location usable: " +
"${state.isNetworkLocationUsable} \n"
)
} catch (e: RuntimeExecutionException) {
if (e.cause is ResolvableApiException)
(e.cause as ResolvableApiException).
startResolutionForResult(
this@MainActivity,
REQUEST_CHECK_STATE)
}
}
这将异步执行检查。如果要求高精度,而设备的设置不允许基于 GPS 数据进行更新,则会调用相应的系统设置对话框。后一种情况在异常捕捉中发生得有些尴尬。相应的系统意图调用的结果如下:
override fun onActivityResult(requestCode: Int,
resultCode: Int, data: Intent) {
if (requestCode and 0xFFFF == REQUEST_CHECK_STATE) {
Log.e("LOG", "Back from REQUEST_CHECK_STATE")
...
}
}
设置正确且拥有足够的权限后,我们现在可以通过以下方式注册位置更新:
val locationUpdates = object : LocationCallback() {
override fun onLocationResult(lr: LocationResult) {
Log.e("LOG", lr.toString())
Log.e("LOG", "Newest Location: " + lr.locations.
last())
// do something with the new location...
}
}
fusedLocationClient?.requestLocationUpdates(reqSetting,
locationUpdates,
null /* Looper */)
要停止位置更新,您可以将locationUpdates
移动到一个类字段,并通过以下方式响应停止请求:
fun stopPeriodic(view:View) {
fusedLocationClient?.
removeLocationUpdates(locationUpdates)
}
地理编码
Geocoder
类允许您确定给定地址的地理坐标(经度、纬度),或者相反,给定地理坐标的可能地址。这些过程被称为正向和反向地理编码。Geocoder
类在内部使用一个在线 Google 服务,但是细节隐藏在实现内部。作为开发人员,你可以使用Geocoder
类,而不需要了解数据来自哪里。
本节讲的是反向地理编码。我们使用一个带有经度和纬度的Location
对象来查找附近的街道名称。首先,我们必须决定如何处理可能需要长时间运行的操作。为了进行查找,网络操作是必要的,并且在线服务需要查找巨大的数据库。一个IntentService
将为我们完成这项工作,我们从返回值的方法中选择一个由 intent extras 传递的ResultReceiver
。首先,我们在服务和服务客户之间定义了一种包含一些常量的类的契约。
class GeocoderConstants {
companion object Constants {
val SUCCESS_RESULT = 0
val FAILURE_RESULT = 1
val PACKAGE_NAME = "<put your package name here>"
val RECEIVER = "$PACKAGE_NAME.RECEIVER"
val RESULT_DATA_KEY =
"$PACKAGE_NAME.RESULT_DATA_KEY"
val LOCATION_DATA_EXTRA =
"$PACKAGE_NAME.LOCATION_DATA_EXTRA"
}
}
现在完整的服务类如下所示。
class FetchAddressService :
IntentService("FetchAddressService") {
override
fun onHandleIntent(intent: Intent?) {
val geocoder = Geocoder(this, Locale.getDefault())
var errorMessage = ""
// Get the location passed to this service through
// an extra.
val location = intent?.getParcelableExtra(
GeocoderConstants.LOCATION_DATA_EXTRA)
as Location
// Get the Intent result receiver
val receiver = intent.getParcelableExtra(
GeocoderConstants.RECEIVER) as ResultReceiver
var addresses: List<Address>? = null
try {
addresses = geocoder.getFromLocation(
location.getLatitude(),
location.getLongitude(),
1) // Get just a single address!
} catch (e: IOException) {
// Catch network or other I/O problems.
errorMessage = "service_not_available"
Log.e("LOG", errorMessage, e)
} catch (e: IllegalArgumentException) {
// Catch invalid latitude or longitude values.
errorMessage = "invalid_lat_long_used"
Log.e("LOG", errorMessage + ". " +
"Latitude = " + location.getLatitude() +
", Longitude = " +
location.getLongitude(), e)
}
if (addresses == null || addresses.size == 0) {
// No address was found.
if (errorMessage.isEmpty()) {
errorMessage = "no_address_found"
Log.e("LOG", errorMessage)
}
deliverResultToReceiver(
receiver,
GeocoderConstants.FAILURE_RESULT,
errorMessage)
} else {
val address = addresses[0]
val addressFragments =
(0..address.maxAddressLineIndex).
map { i -> address.getAddressLine(i) }
val addressStr = addressFragments.joinToString(
separator =
System.getProperty("line.separator"))
Log.i("LOG", "address_found")
deliverResultToReceiver(
receiver,
GeocoderConstants.SUCCESS_RESULT,
addressStr)
}
}
private fun deliverResultToReceiver(
receiver:ResultReceiver,
resultCode: Int,
message: String) {
val bundle = Bundle()
bundle.putString(GeocoderConstants.RESULT_DATA_KEY,
message)
receiver.send(resultCode, bundle)
}
}
同样,对于生产性代码,您应该使用资源字符串而不是文字,如下例所示。服务必须在AndroidManifest.xml
中注册。
<service android:name=".FetchAddressService"
android:exported="false"/>
为了使用这个服务,我们首先构建一个ResultReceiver
类并检查权限。例如,我们使用最后已知的位置来调用服务。
class AddressResultReceiver(handler: Handler?) :
ResultReceiver(handler) {
override
fun onReceiveResult(resultCode: Int,
resultData: Bundle) {
val addressOutput =
resultData.getString(
GeocoderConstants.RESULT_DATA_KEY)
Log.e("LOG", "address result = " +
addressOutput.toString())
...
}
}
val resultReceiver = AddressResultReceiver(null)
fun startFetchAddress(view:View) {
if (checkPermission(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION))
{
fusedLocationClient?.lastLocation?.
addOnSuccessListener(this, {
location: Location? ->
if (location == null) {
// TODO
} else location.apply {
Log.e("LOG", toString())
val intent = Intent(
this@MainActivity,
FetchAddressService::class.java)
intent.putExtra(
GeocoderConstants.RECEIVER,
resultReceiver)
intent.putExtra(
GeocoderConstants.LOCATION_DATA_EXTRA,
this)
startService(intent)
}
})
}
}
你可以看到我们使用了明确的意图。这就是为什么我们在AndroidManifest.xml
的服务声明中不需要意图过滤器。
使用 ADB 获取位置信息
出于开发和调试目的,您可以使用 ADB 获取连接到您的 PC 或笔记本电脑的设备的位置信息。
./adb shell dumpsys location
有关 CLI 命令的更多信息,请参见第十八章。
地图
将地图添加到与位置相关的应用可以极大地提高用户的可用性。要添加 Google API 地图,最简单的方法就是使用 Android Studio 提供的向导。请遵循以下步骤:
-
添加地图活动:右键单击您的模块,在菜单中选择新➤活动➤画廊,并从画廊选择谷歌地图活动。单击下一步。在随后的屏幕上,根据您的需要输入活动参数。然而,选择一个合适的活动名称基本上是你所需要的。缺省值在大多数情况下是有意义的。
-
你需要一个 API 密匙来使用谷歌地图。为此,在文件
/res/values/google_maps_api.xml
中,找到注释中的链接;它可能看起来像https://console.developers.google.com/flows/enableapi?
...
。在浏览器中打开此链接,并按照其中的说明进行操作。最后,输入在线生成的密钥,作为该文件中<string name = "google_maps_key" ... >
元素的文本。
我们现在有一个为我们准备的 activity 类,一个可以包含在我们的应用中的片段布局文件,以及一个允许我们从 Google 服务器获取地图数据的注册 API 键。
为了包含由/res/layout/activity_maps.xml
定义的片段,您在布局中编写以下内容,其大小根据您的需要进行调整:
<include
android:layout_width="fill_parent"
android:layout_height="250dp"
layout="@layout/activity_maps" />
在代码中,我们首先添加一个代码片段来从服务器获取地图。您可以在您的onCreate()
回调中这样做,如下所示:
override fun onCreate(savedInstanceState: Bundle?) {
...
val mapFragment = supportFragmentManager
.findFragmentById(R.id.map)
as SupportMapFragment
mapFragment.getMapAsync(this)
}
这里,R.id.map
指向/res/layout/activity_maps
里面的地图 ID。xml
。
接下来,我们添加一个回调函数,当地图被加载并准备好接收命令时会调用这个回调函数。为此,我们扩展了处理地图的活动,以实现接口com.google.android.gms.maps.OnMapReadyCallback
。
class MainActivity : AppCompatActivity(),
OnMapReadyCallback { ... }
添加回调实现。这里有一个例子:
/**
* Use this to save and manipulate the map once
* available.
*/
override fun onMapReady(map: GoogleMap) {
// Add a marker in Austin, TX and move the camera
val austin = LatLng(30.284935, -97.735464)
map.addMarker(MarkerOptions().position(austin).
title("Marker in Austin"))
map.moveCamera(CameraUpdateFactory.
newLatLng(austin))
}
如果设备上没有安装 Google Play 服务,系统会自动提示用户安装。当然,地图对象可以保存为一个类对象字段,您可以用它做许多有趣的事情,包括添加标记、线条、缩放、移动等等。在com.google.android.gms.maps.GoogleMap
的在线 API 文档中描述了这些可能性。
偏好;喜好;优先;参数选择
首选项允许用户更改应用执行某些功能的方式。与用户在应用的功能工作流程中给出的输入相反,偏好设置不太可能被更改,因此对偏好设置的访问通常由应用菜单中的单个偏好设置条目提供。
作为开发人员,您可能会决定从头开始为首选项开发特殊的活动,但您不必这样做。事实上,Android 操作系统提供的首选项 API 非常通用,允许您以标准方式呈现首选项或设置对话框,让您的应用看起来更专业。此外,您不必自己实现 GUI。
要从一个示例首选项工作流开始,请创建一个类,如下所示:
class MySettingsFragment : PreferenceFragment(),
SharedPreferences.OnSharedPreferenceChangeListener {
companion object {
val DELETE_LIMIT = "pref_key_delete_limit"
val LIST = "pref_key_list"
val RINGTONE = "pref_key_ringtone"
}
override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences?,
key: String?) {
sharedPreferences?.run {
when(key) {
DELETE_LIMIT -> {
findPreference(key).summary =
getString(key, "") ?: "10"
}
LIST -> {
findPreference(key).summary =
(findPreference(key) as ListPreference).
entry
}
RINGTONE -> {
val uriStr = getString(key, "") ?: ""
findPreference(key).summary =
getRingtoneName(Uri.parse(uriStr))
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.preferences)
val sharedPref = PreferenceManager.
getDefaultSharedPreferences(activity)
sharedPref.registerOnSharedPreferenceChangeListener(
this)
with(sharedPref) {
findPreference(DELETE_LIMIT).summary =
getString(DELETE_LIMIT, "10")
findPreference(LIST).summary =
(findPreference(LIST) as ListPreference).let {
val ind = Math.max(0, it.findIndexOfValue(
it.value))
resources.getStringArray(listentries)[ind]
}
findPreference(RINGTONE).summary =
getRingtoneName(
Uri.parse(getString(RINGTONE, "") ?: ""))
}
}
fun getRingtoneName(uri:Uri):String {
return activity.contentResolver.
query(uri, null, null, null, null)?.let {
it.moveToFirst()
val res = it.getString(
it.getColumnIndex(
MediaStore.MediaColumns.TITLE))
it.close()
res
} ?: ""
}
}
这定义了一个具有以下参数的片段:
-
在创建时,它设置首选项资源,稍后将对此进行描述。
-
在创建时,它使用
PreferenceManager
访问首选项 API。我们用它来设置一个首选项更改监听器,这样我们就可以在首选项更改时更新 UI。此外,我们获取几个首选项来准备首选项 UI,并设置在首选项中显示的“摘要”行。 -
监听器
onSharedPreferenceChanged()
用于更新适用首选项的摘要行。
实际的首选项布局在res/xml/
中定义,在本例中,作为一个资源文件preferences.xml
,包含以下内容:
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android=
"http://schemas.android.com/apk/res/android">
<CheckBoxPreference
android:key="pref_key_auto_delete"
android:summary="Auto delete"
android:title="Auto delete"
android:defaultValue="false" />
<EditTextPreference
android:key="pref_key_delete_limit"
android:dependency="pref_key_auto_delete"
android:summary="Delete Limit"
android:title="Delete Limit"
android:defaultValue="10" />
<ListPreference android:key="pref_key_list"
android:summary="A List"
android:title="A List"
android:entries="@array/listentries"
android:entryValues="@array/listvalues"
android:defaultValue="1" />
<MultiSelectListPreference
android:key="pref_key_mslist"
android:summary="A Multiselect List"
android:title="A Multiselect List"
android:entries="@array/listentries"
android:entryValues="@array/listvalues"
android:defaultValue="@array/multiselectdefaults"/>
<SwitchPreference
android:key="pref_key_switch"
android:summary="A Switch"
android:title="A Switch"
android:defaultValue="false" />
<RingtonePreference
android:key="pref_key_ringtone"
android:summary="A Ringtone"
android:title="A ringtone"
/>
</PreferenceScreen>
这里使用的键必须与前面定义的片段中使用的键字符串相匹配。请注意,为了简单起见,我在该文件中使用了普通字符串。对于生产环境,您当然应该参考资源字符串。
此外,在res/values
中添加一个文件arrays.xml
,并在其中为我们在示例中使用的各种数组编写以下内容:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="listentries">
<item>List item 1</item>
<item>List item 2</item>
<item>List item 3</item>
</string-array>
<string-array name="listvalues">
<item>1</item>
<item>2</item>
<item>3</item>
</string-array>
<string-array name="multiselectdefaults">
<item>1</item>
<item>3</item>
</string-array>
</resources>
剩下的就是在你的应用中插入偏好设置的地方。例如,您可以在适当的位置添加以下内容:
<FrameLayout
android:id="@+id/prefsFragm"
android:layout_width="match_parent"
android:layout_height="match_parent" />
在应用调用中,启动偏好设置工作流程。
fragmentManager.beginTransaction()
.replace(prefsFragm.id, MySettingsFragment())
.commit()
为了使我们在preferences.xml
中定义的各种默认值生效,您必须在应用中首先访问首选项的任何地方调用以下内容:
PreferenceManager.setDefaultValues(
this, preferences.id, false)
这可以在活动的onCreate()
回调中完成。图 8-9 显示了该偏好示例。
图 8-9
首选项屏幕
对于更多的设置,到目前为止设计的设置屏幕可能有点难以阅读。要添加一些结构,您可以将设置项目分组并添加组标题。这在preferences.xml
文件中完成,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android=
"http://schemas.android.com/apk/res/android">
...
<PreferenceCategory
android:title="Category Title">
<SwitchPreference
android:key="pref_key_switch"
android:summary="A Switch"
android:title="A Switch"
/>
<RingtonePreference
android:key="pref_key_ringtone"
android:summary="A Ringtone"
android:title="A Ringtone"
/>
</PreferenceCategory>
...
相反,您可以让条目打开一个首选项子屏幕。为此,再次在preferences.xml
中,你写下以下内容:
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android=
"http://schemas.android.com/apk/res/android">
...
<PreferenceScreen
android:title="Subscreen Title"
android:persistent="false">
<SwitchPreference
android:key="pref_key_switch"
android:summary="A Switch"
android:title="A Switch"
/>
<RingtonePreference
android:key="pref_key_ringtone"
android:summary="A Ringtone"
android:title="A Ringtone"
/>
</PreferenceScreen>
...
高级首选项功能包括标准化的标题内容方式来呈现首选项,覆盖设置项 UI,创建您自己的首选项 UI,以及调整首选项数据位置。有关详细信息,请参考设置 API 的在线文档。
九、用户界面
用户界面当然是任何终端用户应用最重要的部分。对于企业使用,没有用户界面的应用是可能的,但即使这样,在大多数情况下,你也会有一些基本的用户界面,如果没有其他原因,只是为了避免 Android 操作系统在资源管理任务中太容易杀死你的应用。
在这一章中,我们不会讨论 Android 用户界面开发的基础知识。相反,你应该已经阅读了一些入门书籍或者阅读了官方教程(或者你可以在网上找到的其他教程)。我们在这里做的是覆盖几个重要的 UI 相关的问题,帮助你创建更稳定的应用或者有特殊需求的应用。
后台任务
Android 依赖于单线程执行模型。这意味着当一个应用启动时,默认情况下它只启动一个线程,称为主线程,在这个线程中所有的动作都会运行,除非你明确地使用后台线程来完成某些任务。这自然意味着,如果您有长时间运行的任务会中断流畅的 UI 工作流,您必须采取特殊的预防措施。对于现代应用来说,在用户点击一个按钮后用户界面冻结是不可接受的,因为这个动作会导致一个过程运行几秒钟或更长时间。因此,将长期运行的任务放到后台是至关重要的。
一种完成将事物放入背景的方法是使用IntentService
物体,如第四章所述。然而,根据具体情况,将所有后台工作放入服务中可能会破坏你的应用设计;此外,在一台设备上运行太多服务将无助于保持较低的资源使用率。另一种选择是使用第八章所述的装载机。然而,对于低级任务,最好使用更低级的方法。这里有几个选项,我们将在下面几节中介绍。
Java 并发
在底层,您可以使用 Java 线程和来自java.util.concurrent
包的类来处理后台作业。从 Java 7 开始,这些类变得非常强大,但是您需要完全理解所有选项和含义。
你会经常读到从 Android 操作系统内部直接处理线程并不是一个好主意,因为线程对于系统资源来说是非常昂贵的。虽然这对于旧设备和旧版本的 Android 来说是正确的,但现在情况已经不同了。对我来说,在摩托罗拉 Moto G4 上进行一个简单的测试,启动 1000 个线程,等待所有线程运行,每个线程大约需要 0.0006 秒。因此,如果您习惯于 Java 线程,并且线程启动时间少于一毫秒对您来说是好事,那么不使用 Java 线程就没有性能上的理由。然而,你必须考虑到线程运行在任何 Android 组件生命周期之外,所以如果你使用线程,你必须手动处理生命周期问题。
在 Kotlin 中,线程很容易定义和启动。
val t = Thread{ ->
// do background work...
}.also { it.start() }
注意,Android 不允许从线程内部访问 UI。您必须按如下方式操作:
val t = Thread{ ->
// do background work...
runOnUiThread {
// use the UI...
}
}.also { it.start() }
AsyncTask 类
一个AsyncTask
对象是一个中级助手类,用于在后台运行一些代码。您覆盖它的doInBackground()
方法来做一些后台工作,如果您需要与 UI 通信,您还实现onProgressUpdate()
来做通信,并从后台工作内部触发publishProgress()
来触发它。
注意
如果你创建类似于val at = object : AsyncTask< Int, Int, Int >() { ... }
的AsyncTask
对象,Android Studio 将会警告你可能的内存泄漏。这是因为在内部将保存对背景代码的静态引用。可以通过用@SuppressLint("StaticFieldLeak")
注释该方法来取消警告。
警告
多个N AsyncTask
任务不会导致所有N
任务的并行执行。相反,它们都在的一个后台线程中顺序运行。
经理人
Handler
是维护消息队列的对象,用于异步处理消息或Runnable
对象。您可以将Handler
用于异步流程,如下所示:
var handlerThread : HandlerThread? = null
// or: lateinit var handlerThread : HandlerThread
...
fun doSomething() {
handlerThread = handlerThread ?:
HandlerThread("MyHandlerThread").apply {
start()
}
val handler = Handler(handlerThread?.looper)
handler.post {
// do s.th. in background
}
}
如果您创建一个HandlerThread
,如下面的代码片段所示,所有发布的内容都会在后台运行,但是它会在后台按顺序执行。这意味着handler.post{}; handler.post{}
将连续运行帖子。但是,您可以创建更多的HandlerThread
对象来并行处理帖子。对于真正的并行性,每次执行都必须使用一个HandlerThread
。
注意
在 Java 7 中推出新的java.util.concurrent
包之前很久,Android 中就引入了处理程序。如今,对于您自己的代码,您可能会决定在不遗漏任何东西的情况下优先选择泛型 Java 类而不是Handler
。然而,处理程序经常出现在 Android 的库中。
装载机
加载器也在后台工作。它们主要用于从外部源加载数据。第章和第章对装载机进行了描述。
支持多种设备
设备兼容性是应用的一个重要问题。当你创建一个应用时,你的目标当然是解决尽可能多的设备配置,并确保在某个设备上安装你的应用的用户可以实际使用它。兼容性归结为以下几点:
-
寻找一种方法,让您的应用可以在不同的屏幕功能下运行,包括像素宽度、像素高度、像素密度、色彩空间和屏幕形状
-
找到一种方法让你的应用可以运行不同的 API 版本
-
找到一种方法,让你的应用可以运行不同的硬件功能,包括传感器和键盘
-
找到一种方法来过滤你的应用在 Google Play 商店中的可见性
-
可能为一个应用提供不同的 apk,具体取决于设备特性
在这一章中,我们讨论与 UI 相关的兼容性问题;我们关注屏幕和用户输入能力。
屏幕尺寸
要让您的应用在不同的屏幕尺寸上看起来都很漂亮,您可以执行以下操作:
-
使用灵活的布局
避免指定绝对位置和绝对宽度。取而代之的是,使用允许“在右边”或“使用一半的可用空间”或类似规格的布局。
-
使用替代布局
使用替代布局是提供不同屏幕尺寸的有力手段。布局 XML 文件可以放在不同的目录中,其名称包含大小过滤器。例如,您可以将一个布局放入文件
res/layout/main_activity.xml
中,将另一个放入res/layout-sw480dp/main_activity.xml
中,表示“最小宽度”为 480dp(适用于 5 英寸或更大的手机屏幕)。Android 开发人员文档中的“提供资源”和“提供替代资源”文档对命名模式进行了广泛的在线描述。然后,Android 操作系统会在用户设备上运行时自动选择最匹配的布局。 -
使用可拉伸图像
您可以为 UI 元素提供九片位图。在这样的图像中,你提供一个 1 像素宽的边框,告诉图像的哪些部分可以重复以拉伸图像,哪些部分可以用于内部内容。这种九补丁图像是带有后缀
.9.png
的 PNG 文件。Android Studio 允许将常规 png 转换为九补丁 png;为此,请使用上下文菜单。
像素密度
设备具有不同的像素密度。为了让你的应用尽可能的独立于设备,只要你需要像素大小,就使用独立于密度的像素大小,而不是像素大小。与密度无关的像素大小使用 dp 作为单位,而像素使用 px。
此外,您还可以根据不同的密度提供不同的布局文件。该分离类似于前面描述的不同屏幕尺寸的分离。
声明受限屏幕支持
在某些情况下,你想要通过说某些屏幕特征不能被使用来限制你的应用。很明显,你想避免这样的情况,但是万一不可避免,你可以在AndroidManifest.xml
里这么做。
-
告诉应用,某些活动可以在 API level 24 (Android 7.0)或更高版本上可用的多窗口模式下运行。为此,使用属性
android:resizeableActivity
并将其设置为true
或false
。 -
告诉某些活动,它们应该在特定的宽高比之上用字母框起来(加上适当的边距)。为此,使用属性
android:maxAspectRatio
并将纵横比指定为一个值。对于 Android 7.1 和更早的版本,在<application>
元素中重复这个设置,就像在<meta-data android:name = "android.max_aspect" android:value = "s.th." />
中一样。 -
通过在一个
<supports-screens>
元素中使用largestWidthLimitDp
属性,告诉某些活动它们不应该超过某个限制。 -
使用更多的
<supports-screens>
和<compatible-screens>
元素和属性,如第二章所述。
检测设备功能
从应用内部,您可以检查某些功能,如下所示:
if(packageManager.
hasSystemFeature(PackageManager.FEATURE_...)) {
// ...
}
这里,FEATURE_...
是来自PackageManager
内部的各种常量之一。
特征信息的另一个来源是Configuration
。在 Kotlin 中,从活动内部,您可以通过以下方式获得配置对象:
val conf = resources.configuration
从那里,获得有关正在使用的颜色模式、可用键盘、屏幕方向和触摸屏功能的信息。要获得屏幕的大小,您可以编写以下代码:
val size = Point()
windowManager.defaultDisplay.getSize(size)
// or (getSystemService(Context.WINDOW_SERVICE)
// as WindowManager).defaultDisplay.getSize(size)
val (width,height) = Pair(size.x, size.y)
要获得解决方案,您可以使用以下方法:
val metrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(metrics)
val density = metrics.density
编程式用户界面设计
通常 UI 设计是通过在一个或多个 XML 布局文件中声明 UI 对象(View
对象)和容器(ViewGroup
对象)来实现的。虽然这是设计 UI 的建议方式,而且大多数人可能会告诉你不应该做任何其他事情,但是有理由从 XML 中去掉布局设计,用程序布局来代替。
-
你需要更多的动态布局。例如,您的应用通过用户操作添加、移除或移动布局元素。或者您想创建一个游戏,其中的游戏元素由动态移动、出现和消失的
View
对象表示。 -
您的布局是在服务器上定义的。尤其是在公司环境中,定义服务器上的布局是有意义的。每当应用的布局发生变化时,您不需要在所有设备上安装新版本。相反,只需要更新中央布局引擎。
-
您定义了一个布局构建器,与 XML 相比,它允许以一种更有表现力和更简洁的方式在 Kotlin 中指定布局。例如,看看下面哪个是有效的 Kotlin 语法:
LinearLayout(orientation="vertical") { TextView(id="tv1", width="match_parent", height="wrap_content") Button(id="btn1", text="Go", onClick = { btn1Clicked() }) }
-
您需要 XML 中没有定义的特殊结构。虽然标准的方法是尽可能用 XML 定义所有内容,其余的用代码完成,但是您可能更喜欢单一技术的解决方案,在这种情况下,您必须在代码内部完成所有工作。
请注意,如果您放弃通过 XML 文件的描述性布局,而使用编程性布局,您必须手动处理不同的屏幕尺寸、屏幕密度和其他硬件特征。虽然这总是可能的,但在某些情况下,这可能是一个复杂且容易出错的过程。某些特征,比如 UI 元素的大小,用 XML 表达比用代码表达要容易得多。
从编程式 UI 设计开始,如果在 XML 中定义一个容器并在代码中使用它,这是最容易的。我这样说是因为布局对于如何以及何时放置它们的子元素有自己的想法,所以如果您的代码对于如何以及何时放置视图有另外的想法,您可能会陷入时间、布局和剪辑问题的噩梦中。一个好的候选人是FrameLayout
,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android =
"http://schemas.android.com/apk/res/android"
android:id="@+id/fl"
android:layout_width="match_parent"
android:layout_height="match_parent">
</FrameLayout>
将此用作布局 XML 文件,比如说/res/layout/activity_main.xml
,并编写以下示例活动:
class MainActivity : AppCompatActivity() {
var tv:TextView? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// For example add a text at a certain position
tv = TextView(this).apply {
text = "Dynamic"
x = 37.0f
y = 100.0f
}
fl.addView(tv)
}
}
要添加一个按钮来移动前一个示例中的文本,您可以编写以下代码:
val WRAP = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT)
fl.addView(
Button(this).apply {
text = "Go"
setOnClickListener { v ->
v?.run {
x += 30.0f *
(-0.5f + Math.random().toFloat())
y += 30.0f *
(-0.5f + Math.random().toFloat())
}
}
}, WRAP
)
如果你不需要完全的控制,并且想要一个布局对象按照它被设计的方式来做它的子元素的定位和大小调整工作,例如,添加子元素到其他布局类型,比如一个LinearLayout
,这在代码内部是可能的。不需要通过setX()
或setY()
明确设定位置,只需使用addView()
方法即可。在某些情况下,你必须使用layoutObject.invalidate()
来触发重新布局。后者必须从 UI 线程内部或runOnUiThread{ ... }
内部完成。
适配器和列表控件
经常需要显示具有可变数量项目的列表,尤其是在公司环境中。虽然具有各种子类的AdapterView
和Adapter
对象已经存在了一段时间,但我们还是专注于相对较新且性能较高的回收器视图。您将看到,使用 Kotlin 的简明性,实现回收器视图以优雅和全面的方式进行。
基本的 ide 如下:你有一个数组或一个列表或另一个数据项的集合,可能来自一个数据库,你想把它们发送到一个单一的 UI 元素来完成所有的表示,包括呈现所有可见的项目,并在必要时提供滚动功能。每个项目的表示要么应该依赖于一个项目 XML 布局文件,要么从代码内部动态生成。从每个数据项的成员到该项的 UI 表示中相应的视图元素的映射将由一个适配器对象来处理。
对于回收器视图,这一切都以一种简单的方式发生,但是首先我们必须包含一个支持库,因为回收器视图不是框架的一部分。为此,在您的模块的build.gradle
文件中,添加以下内容:
implementation
'com.android.support:recyclerview-v7:26.1.0'
将此内容添加到dependencies{ ... }
部分(在一行上;删除implementation
后的换行符。
要告诉应用我们希望使用回收器视图,请在您的活动布局文件中添加以下内容:
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:scrollbars="vertical"
... />
指定其他View
的布局选项。
对于列表中某个项目的布局,在res/layout
中创建另一个布局文件,比如说item.xml
,其示例内容如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/
apk/res/android"
android:layout_width="fill_parent"
android:layout_height="?android:attr/
listPreferredItemHeight"
android:padding="8dip" >
<ImageView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="fill_parent"
android:layout_alignParentBottom="true"
android:layout_alignParentTop="true"
android:layout_marginRight="8dip"
android:contentDescription="TODO"
android:src="@android:drawable/star_big_on" />
<TextView
android:id="@+id/secondLine"
android:layout_width="fill_parent"
android:layout_height="26dip"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:layout_toRightOf="@id/icon"
android:singleLine="true"
android:text="Description"
android:textSize="12sp" />
<TextView
android:id="@+id/firstLine"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_above="@id/secondLine"
android:layout_alignParentRight="true"
android:layout_alignParentTop="true"
android:layout_alignWithParentIfMissing="true"
android:layout_toRightOf="@id/icon"
android:gravity="center_vertical"
android:text="Example application"
android:textSize="16sp" />
</RelativeLayout>
如前所述,您也可以省略这一步,只从代码内部定义一个项目的布局!接下来,我们提供一个适配器。在科特林,这可能很简单,如下所示:
class MyAdapter(val myDataset:Array<String>) :
RecyclerView.Adapter
<MyAdapter.Companion.ViewHolder>() {
companion object {
class ViewHolder(val v:RelativeLayout) :
RecyclerView.ViewHolder(v)
}
override
fun onCreateViewHolder(parent:ViewGroup,
viewType:Int) : ViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.item, parent, false)
as RelativeLayout
return ViewHolder(v)
}
override
fun onBindViewHolder(holder:ViewHolder,
position:Int) {
// replace the contents of the view with
// the element at this position
holder.v.findViewById<TextView>(
R.id.firstLine).text =
myDataset[position]
}
override
fun getItemCount() : Int = myDataset.size
}
下面是该列表的一些注释:
-
“同伴对象”内部的类是 Kotlin 声明静态内部类的方式。它将每个数据项的引用指定为一个 UI 元素。更准确地说,回收器视图将只在内部保存表示可见的项目所需的视图持有者。
-
只有在真正需要时,才会调用函数
onCreateViewHolder()
来创建视图保持器。更准确地说,它的调用频率或多或少取决于向用户显示项目的需要。 -
函数
onBindViewHolder()
将一个可视视图持有者与某个数据项连接起来。这里我们必须替换视图持有者视图的内容。
在活动内部,定义回收器视图所需的全部内容如下:
with(recycler_view) {
// use this setting to improve performance if you know
// that changes in content do not change the layout
// size of the RecyclerView
setHasFixedSize(true)
// use for example a linear layout manager
layoutManager = LinearLayoutManager(this@MainActivity)
// specify the adapter, use some sample data
val dataset = (1..21).map { "Itm" + it }.toTypedArray()
adapter = MyAdapter(dataset)
}
这将看起来如图 9-1 所示。以下是该程序的有用扩展:
-
向所有项目添加点击监听器
-
使项目可选
-
使物件或物件部件可编辑
-
自动对底层数据的变化做出反应
-
定制图形过渡效果
对于所有这些,我参考了回收视图的在线文档。然而,这里给出的代码应该给你一个好的起点。
图 9-1
recycler view(回收视图)
样式和主题
Android 应用默认使用的预定义样式已经为专业外观的应用提供了一个良好的起点。然而,如果你想应用你公司的风格指南,或者创建一个视觉上杰出的应用,创造你自己的风格是值得努力的。更好的是,创建你自己的主题,它是应用于 UI 元素组的样式集合。
样式和主题作为 XML 文件在res/values/
中创建。要创建一个新的主题,您可以使用或创建一个名为themes.xml
的文件,并编写如下内容:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="MyTheme" parent="Theme.AppCompat">
<item name="colorPrimary">
@color/colorPrimary</item>
<item name="colorPrimaryDark">
@color/colorPrimaryDark</item>
<item name="colorAccent">
@color/colorAccent</item>
<item name="android:textColor">
#FF0000</item>
<item name="android:textSize"> 22sp</item>
</style>
</resources>
这里有一些关于这方面的注意事项:
-
parent
属性很重要。它表明我们想要创建一个主题,覆盖兼容性库中的部分主题。 -
因为命名模式 Theme + DOT + AppCompat,我们可以推断主题
Theme.AppCompat
继承了主题Theme
。这种点诱导的遗传可能有更多的元素。 -
我们可以使用它的一个子主题来代替父主题
Theme.AppCompat
。你可以看到它们的列表;在 Android Studio 中单击 AppCompat 部分,然后按 Ctrl+b。Android Studio 将打开一个文件,其中包含所有子主题的列表,例如Theme.AppCompat.CompactMenu
、Theme.AppCompat.Light
等等。 -
在示例中,我们看到了两种覆盖样式的方法。那些以
android:
开头的引用了为 UI 元素定义的样式设置,就像我们想在一个布局文件中设置样式一样。您可以在所有视图的在线 API 文档中找到它们。然而,更好的做法是,在开头使用不带android:
的标识符,因为这些标识符指的是实际构成主题的抽象样式标识符。如果在在线文档中搜索 R.styleable.Theme ,你会得到一个可能的项目名称列表。 -
这些年来,造型系统变得越来越复杂。如果你够勇敢并且有一些时间,你可以通过在父母上重复按 Ctrl+B 来浏览 Android Studio 中的所有文件。
-
@color/...
指的是res/values/colors.xml
文件中的条目。你应该采用这种方法,在你的应用模块的res/values/colors.xml
文件中定义新的颜色。 -
<item>
元素的值可以通过@style/...
引用样式。例如,使用项目<item name="buttonStyle">@style/Widget.AppCompat.Button</item>
。您也可以覆盖这些项目;在styles.xml
中定义自己的风格并参考就可以了。
要立即为整个应用使用新主题,您需要在清单文件AndroidManifest.xml
中编写以下内容:
<manifest ... >
<application android:theme="@style/MyTheme" ... >
</application>
</manifest>
注意
您不必使用完整的主题来覆盖样式。相反,您可以覆盖或创建单个样式,然后应用于单个小部件。然而,使用主题可以极大地提高应用的设计一致性。
您可以将样式分配给不同的 API 级别。为此,例如,创建一个名为res/values-v21/
的文件夹或任何适合你的级别编号。如果当前的 API 等级大于或等于这个数字的话,那么文件夹中的样式就会被另外应用和。
XML 中的字体
从 API 级别 26 开始的 Android 版本(Android 8.0),以及使用支持库 26 的早期版本,允许您以 TTF 或 OTF 格式添加自己的字体。
注意
要使用这个支持库,在你的模块的build.gradle
文件中,在dependencies
部分添加implementation 'com.android.support:appcompat-v7:26.1.0'
。
添加字体文件,创建字体资源目录:选择新➤ Android 资源目录,输入字体作为目录名,输入字体作为资源类型,点击确定。将字体文件复制到新资源目录中,但是首先将所有文件名转换为只包含后缀前允许的字符( a 到 z ,0 到 9,_)。
要应用新字体,使用如下的android:fontFamily
属性:
<TextView ...
android:fontFamily="@font/<FONT_NAME>"
/>
这里,<FONT_NAME>
是不带后缀的字体文件名。
要添加不同字体样式的字体,假设您在font
资源文件夹中有字体myfont_regular.ttf
、myfont_bold.ttf
、myfont_italic.ttf
和myfont_bold_italic.ttf
。通过选择新的➤字体资源文件添加文件myfont.xml
。在该文件中写入以下内容:
<?xml version="1.0" encoding="utf-8"?>
<font-family
xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:app=
"http://schemas.android.com/apk/res-auto">
<font
android:fontStyle="normal"
app:fontStyle="normal"
android:fontWeight="400"
app:fontWeight="400"
android:font="@font/myfont_regular"
app:font="@font/myfont_regular"/>
<font
android:fontStyle="normal"
app:fontStyle="normal"
android:fontWeight="700"
app:fontWeight="700"
android:font="@font/myfont_bold"
app:font="@font/myfont_bold"/>
<font
android:fontStyle="italic"
app:fontStyle="italic"
android:fontWeight="400"
app:fontWeight="400"
android:font="@font/myfont_italic"
app:font="@font/myfont_italic"/>
<font
android:fontStyle="italic"
app:fontStyle="italic"
android:fontWeight="700"
app:fontWeight="700"
android:font="@font/myfont_bold_italic"
app:font="@font/myfont_bold_italic"/>
</font-family>
忽略 Android Studio 的版本警告;为了兼容,所有属性都使用一个标准命名空间和一个兼容命名空间。
然后,您可以使用这个 XML 文件的名称,不带后缀,用于android:fontFamily
属性内的 UI 视图。
<TextView ...
android:fontFamily="@font/myfont"
android:textStyle="normal"
/>
作为textStyle
,你现在也可以使用italic
或bold
或bold|italic
。
二维动画
动画让你的应用看起来更有趣,虽然太多的动画可能看起来很古怪,但是适量的动画可以帮助你的用户理解你的应用是做什么的。
Android 操作系统提供了几种你可以使用的动画技术,我们将在下面的章节中描述它们。
自动动画布局
添加动画的一个简单方法是使用内置的布局自动动画。你所要做的就是将android:animateLayoutChanges="true"
添加到布局声明中。这里有一个例子:
<LinearLayout
...
android:animateLayoutChanges="true"
...
/>
动画位图
你可以通过提供不同版本的位图并让 Android 在它们之间切换来给位图添加动画。首先将所有图像添加到res/drawable
,例如img1.png
、img2.png
,...,img9.png
。然后在同一个文件夹中创建一个文件,例如,anim.xml
,并在文件中写入以下内容:
<?xml version="1.0" encoding="utf-8"?>
<animation-list
xmlns:android=
"http://schemas.android.com/apk/res/android"
android:oneshot="false">
<item android:drawable="@drawable/img1"
android:duration="250" />
<item android:drawable="@drawable/img2"
android:duration="250" />
...
<item android:drawable="@drawable/img9"
android:duration="250" />
</animation-list>
这里,每个位图幻灯片的持续时间以毫秒为单位。要使其不重复,请设置android:oneshot="true"
。将图像作为ImageView
添加到布局中,如下所示:
<ImageView
android:id="@+id/img"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/anim1" />
这将准备动画,但需要从程序内部启动,如下所示:
img.setBackgroundResource(R.drawable.anim1)
img.setOnClickListener{
val anim = img.background as AnimationDrawable
anim.start()
}
这里,当用户单击图像时,动画开始。
属性动画
属性动画框架允许你制作任何你能想到的动画。使用类android.animation.ValueAnimator
,您可以指定以下内容:
-
持续时间和重复模式
-
插值的类型
-
动画期间的时间插值
-
值更新的监听器
然而,大多数时候你会使用android.animation.ObjectAnimator
类,因为它已经面向对象和它们的属性,所以你不必实现监听器。这个类有各种静态工厂方法来创建ObjectAnimator
的实例。在参数中,您可以指定要制作动画的对象、要使用的该对象的属性的名称以及在动画制作过程中要使用的值。然后你可以在对象上设置一个特定的插值器(默认为AccelerateDecelerateInterpolator
,缓慢开始和结束,在加速和减速之间),并添加一个值更新监听器(在onAnimationUpdate()
中设置),例如,如果一个View
类型的目标对象需要被告知它必须更新自己(通过调用invalidate()
)。
例如,我们在一个FrameLayout
中定义了一个TextView
对象,并以加速的方式将它从x=0
移动到x=500
。布局文件包含以下内容:
<FrameLayout
android:id="@+id/fl"
android:layout_width="match_parent"
android:layout_height="400dp">
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="XXX"/>
</FrameLayout>
例如,在单击按钮后,在代码中编写以下内容:
val anim = ObjectAnimator.ofFloat(tv, "x", 0.0f, 500.0f)
.apply {
duration = 1000 // default is 300ms
interpolator = AccelerateInterpolator()
}
anim.start()
注意
这只有在所讨论的对象(这里是一个TextView
)具有指定属性(这里是一个x
)的 setter 方法时才起作用。更准确地说,对象需要一个setX(Float)
,然而这是所有View
对象的情况。
警告
根据 UI 对象所处的布局,您的收获会有很大的不同。毕竟,布局对象可能有自己的想法如何定位对象,阻碍动画。A FrameLayout
这里挺帅的,但是你要做代码里面所有的布局。
使用android.animation.AnimatorSet
,你也可以设计一组动画。在线 API 文档会告诉你更多关于如何使用它的信息。
查看属性动画
通过施加某些约束,一些与View
相关的属性也可以使用android.view.ViewPropertyAnimator
来制作动画。与一般的属性动画相比,这似乎不太具有侵入性,但是只有少量的属性可以被动画化,并且只有绘图受到动画的影响。如果视图移开,点击监听器的位置会变得孤立。
此外,您可以使用它来平移视图、缩放视图、旋转视图以及淡入或淡出视图。有关详细信息,请参见在线文档。
作为内置视图属性动画的扩展,您可能想看看 Fling 动画。这种类型的动画对移动的对象施加摩擦力,让动画看起来更自然。要找到关于 Fling 动画的信息,请在您最喜欢的搜索引擎中搜索 android fling 动画。
春季物理学
将弹簧物理学添加到动画中可以使移动更加真实,从而改善用户体验。要添加 spring physics,需要包含相应的支持库。在你的模块的build.gradle
文件中,在dependencies
元素中添加implementation 'com.android.support:support-dynamic-animation:27.1.0'
。
详情请参考android.support.animation.SpringAnimation
类的在线 API 文档。以下是最重要的设置:
-
在构造函数内,设置属性。可用的属性有 alpha、平移、旋转、滚动值和缩放。
-
添加侦听器;使用
addUpdateListener()
和/或addEndListener()
。 -
使用
setStartVelocity()
以初始速度开始动画(默认为 0.0f)。 -
使用
getSpring().setDampingRatio()
或 Kotlin 中的.spring.dampingRatio = ...
设置阻尼系数。 -
使用
getSpring().setStiffness()
或 Kotlin 中的.spring.stiffness = ...
设置弹簧刚度。
使用start()
或animateToFinalPosition()
启动动画。这必须发生在 GUI 线程或runOnUiThread { ... }
内部。
以下是按钮按下后的弹簧动画示例:
val springAnim = SpringAnimation(tv, DynamicAnimation.
TRANSLATION_X, 500.0f).apply {
setStartVelocity(1.0f)
spring.stiffness =
SpringForce.STIFFNESS_LOW
spring.dampingRatio =
SpringForce.DAMPING_RATIO_LOW_BOUNCY
}
springAnim.start()
过渡
过渡框架允许您在不同的布局之间应用动画过渡。你基本上从开始布局和结束布局创建android.transition.Scene
对象,然后让TransitionManager
动作。这里有一个例子:
val sceneRoot:ViewGroup = ...
// Obtain the view hierarchy to add as a child of
// the scene root when this scene is entered
val startViewHierarchy:ViewGroup = ...
// Same for the end scene
val endViewHierarchy:ViewGroup = ...
// Create the scenes
val startScene = Scene(sceneRoot, startViewHierarchy)
val endScene = Scene(sceneRoot, endViewHierarchy)
val fadeTransition = Fade()
TransitionManager.go(endScene, fadeTransition)
例如,sceneRoot
可以是一个FrameLayout
布局,其中的过渡应该发生在内部。然后你可以在开始的时候在里面添加起始布局(startViewHierarchy
)。前面的代码将建立到结束布局的过渡(endViewHierarchy
),所有这些都发生在sceneRoot
的中的。
这种转换可以在代码内部指定,也可以作为特殊的 XML 文件指定。有关详细信息,请参见在线文档。
警告
某些限制适用。不是所有的View
类型都会正确地参与到这样的布局转换中。然而,可以通过使用removeTarget()
方法从转换中排除元素。
使用转场开始活动
当一个活动被另一个活动替换时,可以为第一个活动指定退出转换,为第二个活动指定进入转换,并允许普通视图元素的平滑转换。这种转换可以由特殊的 XML 文件指定,也可以从代码内部指定。我们以 XML 的方式简要描述了后者;有关更多详细信息,请参考在线文档。
这种切换适用于 API 级别 21 (Android 5.0)及以上。为了确保您的代码能与之前的版本一起工作,我们在下面的代码片段中编写了一个检查:
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.LOLLIPOP) ...
要设置一个退出和一个进入转换,请在两个活动中使用以下代码片段:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.LOLLIPOP) {
with(window) {
requestFeature(
Window.FEATURE_CONTENT_TRANSITIONS)
exitTransition = Explode()
// if inside the CALLED transition,
// instead use:
// enterTransition = Explode()
// use this in the CALLED transition to
// primordially start the enter transition:
// allowEnterTransitionOverlap = true
}
} else {
// Go without transition - this can be empty
}
...
}
在这里,你可以选择Slide()
、Fade()
或AutoTransition()
来代替Explode()
。Slide()
和Fade()
做显而易见的事情;AutoTransition
淡出不常用的元素,移动常用元素并调整大小,然后淡入新元素。
注意
requestFeature()
方法必须发生在onCreate()
的开头。
仅当您通过以下方式开始新活动时,转换才会被激活:
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.LOLLIPOP) {
startActivity(intent,
ActivityOptions.
makeSceneTransitionAnimation(this).toBundle())
}else{
startActivity(intent)
}
当名为的活动存在时,您可以使用Activity.finishAfterTransition()
而不是通常的finish()
来更好地处理反向转换。同样,你必须把它放进一张if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
支票里。
为了改善用户体验,您可以识别两个活动共有的元素,并让转换框架以特殊的方式处理它们。为此,您需要做两件事。
-
Give the common UI elements a special transition name.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { img.transitionName = "imgTrans" // for a certain "img" UI element }
该名称必须是唯一的,并且在调用和被调用活动中,该设置都应该发生在
onCreate()
内部。 -
将
ActivityOptions.makeSceneTransitionAnimation(this)
替换为以下内容:ActivityOptions. makeSceneTransitionAnimation( this@MainActivity, UPair.create(img,"imgTrans"), ...more pairs... )
这里,UPair
是避免名称冲突的重要别名:import android.util.Pair as UPair
。
然后,您可以使用AutoTransition()
转换在动画过程中以特殊方式处理这些常见的 UI 元素。
快速图形 OpenGL ES
对于 Android 应用,您可以使用行业标准的 OpenGL ES 来渲染 2D 和 3D 的高性能图形。用户界面的开发与标准的 Android 操作系统有很大的不同,你必须花一些时间来学习如何使用 OpenGL ES。然而,最终你可能会得到出色的图形,这是值得的。
注意
如果 Android 操作系统用户界面提供相同的功能并且性能不是问题,那么使用 OpenGL ES 就没有意义。
OpenGL ES 有不同的版本:1。 x ,2.0,3。 x 。而 1。 x 和后来的版本,2.0 和 3 的区别。 x 没那么大。但是设置 3。 x 作为一个严格的要求,你会错过大约三分之一的潜在用户(到 2018 年初),所以建议是这样的:
- 为 OpenGL ES 2.0 开发,只有当你真的需要它时才添加 3。 x 特征。如果用 3。 x ,你最好为不会说话的设备提供后备 3。 x 。
在下面的章节中,我们将讨论 2.0 版。
注意
框架和本地开发工具包(NDK)都支持 OpenGL ES for Android。在这本书里,我们将把更多的重量放在框架类上。
OpenGL ES 是非常通用的,使用模式可能是无穷无尽的。涵盖 OpenGL ES 提供的所有内容超出了本书的范围,但是我将给出以下场景来帮助您入门:
-
配置活动以使用 OpenGL ES
-
提供一个自定义的
GLSurfaceView
类来保存 OpenGL 场景 -
提供两个图形图元:一个使用顶点缓冲区和着色器程序的三角形,一个使用顶点缓冲区、索引缓冲区和着色器程序的四边形
-
提供渲染器来绘制图形
-
简要概述如何引入视图投影
-
简要概述如何添加运动
-
简要概述如何添加光线
-
简要概述如何对用户输入做出反应
在活动中显示 OpenGL 图面
您可以通过以下方式使自定义 OpenGL 视图元素成为在活动中显示的唯一 UI 元素:
class MyActivity : AppCompatActivity() {
var glView:GLSurfaceView? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create a GLSurfaceView instance and set it
// as the ContentView for this Activity.
glView = MyGLSurfaceView(this)
setContentView(glView)
}
}
这里,MyGLSurfaceView
是我们将要定义的自定义GLSurfaceView
。
或者,您可以使用一个普通的 XML 布局文件,并在其中添加定制的GL
视图,例如,编写以下代码:
<com.example.opengl.glapp.MyGLSurfaceView
android:layout_width="400dp"
android:layout_height="400dp"/>
这里,您必须指定自定义GL
视图类的完整类路径。
创建自定义 OpenGL 视图元素
定制 OpenGL 视图元素可以像子类化android.opengl.GLSurfaceView
一样简单,为图形数据指定一个渲染器,一个渲染模式,可能还有用户交互的监听器。然而,我们想更进一步,包括 OpenGL ES 版本检查,以便您可以决定是否包括 OpenGL ES 3。 x 构造是可能的。代码内容如下:
import android.app.ActivityManager
import android.content.Context
import android.opengl.GLSurfaceView
import android.util.Log
import javax.microedition.khronos.egl.EGL10
class MyGLSurfaceView(context: Context) :
GLSurfaceView(context) {
val renderer: MyGLRenderer
var supports3x = false
var minVers = 0
init {
fetchVersion()
// Create an OpenGL ES 2.0 context
setEGLContextClientVersion(2)
// We set the 2.x context factory to use for
// the view
setEGLContextFactory()
// We set the renderer for drawing the graphics
renderer = MyGLRenderer()
setRenderer(renderer)
// This setting prevents the GLSurfaceView frame
// from being redrawn until you call
// requestRender()
renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
}
private fun fetchVersion() {
val activityManager =
context.getSystemService(
Context.ACTIVITY_SERVICE)
as ActivityManager
val configurationInfo =
activityManager.deviceConfigurationInfo
val vers = configurationInfo.glEsVersion
// e.g. "2.0"
supports3x = vers.split(".")[0] == "3"
minVers = vers.split(".")[1].toInt()
Log.i("LOG", "Supports OpenGL 3.x = " +
supports3x)
Log.i("LOG", "OpenGL minor version = " +
minVers)
}
private fun setEGLContextFactory() {
val EGL_CONTEXT_CLIENT_VERSION = 0x3098
// from egl.h c-source
class ContextFactory :
GLSurfaceView.EGLContextFactory {
override fun createContext(egl: EGL10,
display: javax.microedition.khronos.
egl.EGLDisplay?,
eglConfig: javax.microedition.khronos.
egl.EGLConfig?)
:javax.microedition.khronos.egl.EGLContext? {
val attrib_list =
intArrayOf(EGL_CONTEXT_CLIENT_VERSION,
2, EGL10.EGL_NONE)
val ectx = egl.eglCreateContext(display,
eglConfig,
EGL10.EGL_NO_CONTEXT,
attrib_list)
return ectx
}
override fun destroyContext(egl: EGL10,
display: javax.microedition.khronos.
egl.EGLDisplay?,
context: javax.microedition.khronos.
egl.EGLContext?) {
egl.eglDestroyContext(display, context)
}
}
setEGLContextFactory(ContextFactory())
}
}
然后可以用.supports3x
看看 OpenGL ES 3。支持 x ,如果需要,使用.minVers
作为次要版本号。这个类使用的渲染器马上就会被定义。另外,请注意,由于RENDERMODE_WHEN_DIRTY
,重绘只在需要时发生。如果您需要完全动态的更改,那么就把那一行注释掉。
带有顶点缓冲区的三角形
一个负责绘制图元的类,在本例中是一个简单的三角形,如下所示:
class Triangle {
val vertexShaderCode = """
attribute vec4 vPosition;
void main() {
gl_Position = vPosition;
}
""".trimIndent()
val fragmentShaderCode = """
precision mediump float;
uniform vec4 vColor;
void main() {
gl_FragColor = vColor;
}
""".trimIndent()
var program:Int? = 0
val vertexBuffer: FloatBuffer
var color = floatArrayOf(0.6f, 0.77f, 0.22f, 1.0f)
var positionHandle: Int? = 0
var colorHandle: Int? = 0
val vertexCount =
triangleCoords.size / COORDS_PER_VERTEX
val vertexStride = COORDS_PER_VERTEX * 4
// 4 bytes per vertex
companion object {
// number of coordinates per vertex
internal val COORDS_PER_VERTEX = 3
internal var triangleCoords =
floatArrayOf( // in counterclockwise order:
0.0f, 0.6f, 0.0f, // top
-0.5f, -0.3f, 0.0f, // bottom left
0.5f, -0.3f, 0.0f // bottom right
)
}
在类的init
块中,着色器被加载和初始化,顶点缓冲区被准备好。
init {
val vertexShader = MyGLRenderer.loadShader(
GLES20.GL_VERTEX_SHADER,
vertexShaderCode)
val fragmentShader = MyGLRenderer.loadShader(
GLES20.GL_FRAGMENT_SHADER,
fragmentShaderCode)
// create empty OpenGL ES Program
program = GLES20.glCreateProgram()
// add the vertex shader to program
GLES20.glAttachShader(program!!, vertexShader)
// add the fragment shader to program
GLES20.glAttachShader(program!!, fragmentShader)
// creates OpenGL ES program executables
GLES20.glLinkProgram(program!!)
// initialize vertex byte buffer for shape
// coordinates
val bb = ByteBuffer.allocateDirect(
// (4 bytes per float)
triangleCoords.size * 4)
// use the device hardware's native byte order
bb.order(ByteOrder.nativeOrder())
// create a floating point buffer from bb
vertexBuffer = bb.asFloatBuffer()
// add the coordinates to the buffer
vertexBuffer.put(triangleCoords)
// set the buffer to start at 0
vertexBuffer.position(0)
}
draw()
方法执行渲染工作。注意,通常在 OpenGL 渲染中,这个方法必须运行得非常快。在这里,我们只是移动参考:
fun draw() {
// Add program to OpenGL ES environment
GLES20.glUseProgram(program!!)
// get handle to vertex shader's vPosition member
positionHandle = GLES20.glGetAttribLocation(
program!!, "vPosition")
// Enable a handle to the triangle vertices
GLES20.glEnableVertexAttribArray(
positionHandle!!)
// Prepare the triangle coordinate data
GLES20.glVertexAttribPointer(positionHandle!!,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
vertexStride, vertexBuffer)
// get handle to fragment shader's vColor member
colorHandle = GLES20.glGetUniformLocation(
program!!, "vColor")
// Set color for drawing the triangle
GLES20.glUniform4fv(colorHandle!!, 1, color, 0)
// Draw the triangle
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0,
vertexCount)
// Disable vertex array
GLES20.glDisableVertexAttribArray(
positionHandle!!)
}
}
要了解如何从渲染器内部使用这个三角形类,请参见下一节。
具有顶点缓冲区和索引缓冲区的四边形
在 OpenGL 中,多于三个顶点的多边形最好被描述为将尽可能多的三角形粘合在一起。所以,对于一个四边形,我们需要两个三角形。显然,一些顶点出现了几次:如果我们有一个四边形 A-B-C-D,我们需要声明三角形 A-B-C 和 A-C-D,因此顶点 A 和 C 各使用两次。
多次将顶点上传到图形硬件不是一个好的解决方案,这就是为什么有索引列表的原因。我们上传顶点 A、B、C 和 D,以及一个列表 0、1、3 和 2,将指向顶点列表,并将这两个三角形描述为一个三角形带(第一个是 0-1-3,第二个是 1-3-2)。四元菜单的相应代码如下所示:
class Quad {
val vertexBuffer: FloatBuffer
val drawListBuffer: ShortBuffer
val vertexShaderCode = """
attribute vec4 vPosition;
void main() {
gl_Position = vPosition;
}
""".trimIndent()
val fragmentShaderCode = """
precision mediump float;
uniform vec4 vColor;
void main() {
gl_FragColor = vColor;
}
""".trimIndent()
// The shader program
var program:Int? = 0
var color = floatArrayOf(0.94f, 0.67f, 0.22f, 1.0f)
val vbo = IntArray(1) // one vertex buffer
val ibo = IntArray(1) // one index buffer
var positionHandle: Int? = 0
var colorHandle: Int? = 0
companion object {
val BYTES_PER_FLOAT = 4
val BYTES_PER_SHORT = 2
val COORDS_PER_VERTEX = 3
val VERTEX_STRIDE = COORDS_PER_VERTEX *
BYTES_PER_FLOAT
var quadCoords = floatArrayOf(
-0.5f, 0.2f, 0.0f, // top left
-0.5f, -0.5f, 0.0f, // bottom left
0.2f, -0.5f, 0.0f, // bottom right
0.2f, 0.2f, 0.0f) // top right
val drawOrder = shortArrayOf(0, 1, 3, 2)
// order to draw vertices
}
对于前面的三角形,我们初始化了init
块中的着色器和缓冲区。
init {
val vertexShader = MyGLRenderer.loadShader(
GLES20.GL_VERTEX_SHADER,
vertexShaderCode)
val fragmentShader = MyGLRenderer.loadShader(
GLES20.GL_FRAGMENT_SHADER,
fragmentShaderCode)
program = GLES20.glCreateProgram().apply {
// add the vertex shader to program
GLES20.glAttachShader(this, vertexShader)
// add the fragment shader to program
GLES20.glAttachShader(this, fragmentShader)
// creates OpenGL ES program executables
GLES20.glLinkProgram(this)
}
// initialize vertex byte buffer for shape coords
vertexBuffer = ByteBuffer.allocateDirect(
quadCoords.size * BYTES_PER_FLOAT).apply{
order(ByteOrder.nativeOrder())
}.asFloatBuffer().apply {
put(quadCoords)
position(0)
}
// initialize byte buffer for the draw list
drawListBuffer = ByteBuffer.allocateDirect(
drawOrder.size * BYTES_PER_SHORT).apply {
order(ByteOrder.nativeOrder())
}.asShortBuffer().apply {
put(drawOrder)
position(0)
}
GLES20.glGenBuffers(1, vbo, 0);
GLES20.glGenBuffers(1, ibo, 0);
if (vbo[0] > 0 && ibo[0] > 0) {
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,
vbo[0])
GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER,
vertexBuffer.capacity() * BYTES_PER_FLOAT,
vertexBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, ibo[0])
GLES20.glBufferData(
GLES20.GL_ELEMENT_ARRAY_BUFFER,
drawListBuffer.capacity() *
BYTES_PER_SHORT,
drawListBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(
GLES20.GL_ARRAY_BUFFER, 0);
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
} else {
//TODO: some error handling
}
}
draw()
方法执行渲染,就像我们之前描述的三角形类一样。
fun draw() {
// Add program to OpenGL ES environment
GLES20.glUseProgram(program!!)
// Get handle to fragment shader's vColor member
colorHandle = GLES20.glGetUniformLocation(
program!!, "vColor")
// Set color for drawing the quad
GLES20.glUniform4fv(colorHandle!!, 1, color, 0)
// Get handle to vertex shader's vPosition member
positionHandle = GLES20.glGetAttribLocation(
program!!, "vPosition")
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(
positionHandle!!)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(positionHandle!!,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, vertexBuffer)
// Draw the quad
GLES20.glBindBuffer(
GLES20.GL_ARRAY_BUFFER, vbo[0]);
// Bind Attributes
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, ibo[0])
GLES20.glDrawElements(GLES20.GL_TRIANGLE_STRIP,
drawListBuffer.capacity(),
GLES20.GL_UNSIGNED_SHORT, 0)
GLES20.glBindBuffer(
GLES20.GL_ARRAY_BUFFER, 0)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
// Disable vertex array
GLES20.glDisableVertexAttribArray(
positionHandle!!)
}
}
在着色器程序的构造器中,顶点缓冲区和索引缓冲区被上传到图形硬件。在可能经常被调用的draw()
方法中,只使用指向上传缓冲区的指针。这个Quad
类的用法将在下一节中描述。
创建和使用渲染器
渲染器负责绘制图形对象。因为我们使用的是android.opengl.GLSurfaceView
的子类,所以渲染器必须是GLSurfaceView.Renderer
的子类。由于类Triangle
和Quad
有它们自己的着色器,除了一些样板代码,渲染器需要做的就是实例化一个四边形和一个三角形,并使用它们的draw()
方法。
class MyGLRenderer : GLSurfaceView.Renderer {
companion object {
fun loadShader(type: Int, shaderCode: String)
: Int {
// create a vertex shader type
// (GLES20.GL_VERTEX_SHADER)
// or a fragment shader type
// (GLES20.GL_FRAGMENT_SHADER)
val shader = GLES20.glCreateShader(type)
// add the source code to the shader and
// compile it
GLES20.glShaderSource(shader, shaderCode)
GLES20.glCompileShader(shader)
return shader
}
}
var triangle:Triangle? = null
var quad:Quad? = null
// Called once to set up the view's OpenGL ES
// environment.
override
fun onSurfaceCreated(gl: GL10?, config:
javax.microedition.khronos.egl.EGLConfig?) {
// enable face culling feature
GLES20.glEnable(GL10.GL_CULL_FACE)
// specify which faces to not draw
GLES20.glCullFace(GL10.GL_BACK)
// Set the background frame color
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
}
// Called for each redraw of the view.
// If renderMode =
// GLSurfaceView.RENDERMODE_WHEN_DIRTY
// (see MyGLSurfaceView)
// this will not be called every frame
override
fun onDrawFrame(unused: GL10) {
// Redraw background color
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
triangle = triangle ?: Triangle()
triangle?.draw()
quad = quad ?: Quad()
quad?.draw()
}
override
fun onSurfaceChanged(unused: GL10, width: Int,
height: Int) {
GLES20.glViewport(0, 0, width, height)
}
}
规划
一旦我们开始使用第三维,我们需要谈论投影。投影描述了三维顶点如何映射到二维屏幕坐标。到目前为止,我们构建的Triangle
和Quad
图形图元都使用它们自己的着色器程序。虽然这给了我们最大的灵活性,但为了避免着色器程序的激增,最好提取着色器程序并在渲染器中只使用一个。此外,投影计算只需要在一个地方完成。
此外,我们让渲染器准备N
乘以两个缓冲区对象,每个对象一对顶点和索引缓冲区,并为每个图形图元构造函数提供相应的句柄。新的Square
类如下所示:
class Square(val program: Int?,
val vertBuf:Int, val idxBuf:Int) {
val vertexBuffer: FloatBuffer
val drawListBuffer: ShortBuffer
var color = floatArrayOf(0.94f, 0.67f, 0.22f, 1.0f)
companion object {
val BYTES_PER_FLOAT = 4
val BYTES_PER_SHORT = 2
val COORDS_PER_VERTEX = 3
val VERTEX_STRIDE = COORDS_PER_VERTEX *
BYTES_PER_FLOAT
var coords = floatArrayOf(
-0.5f, 0.2f, 0.0f, // top left
-0.5f, -0.5f, 0.0f, // bottom left
0.2f, -0.5f, 0.0f, // bottom right
0.2f, 0.2f, 0.0f) // top right
val drawOrder = shortArrayOf(0, 1, 3, 2)
// order to draw vertices
}
该类不再包含着色器代码,所以留给init
块的是准备缓冲区以使用以下代码:
init {
// initialize vertex byte buffer for shape
// coordinates
vertexBuffer = ByteBuffer.allocateDirect(
coords.size * BYTES_PER_FLOAT).apply{
order(ByteOrder.nativeOrder())
}.asFloatBuffer().apply {
put(coords)
position(0)
}
// initialize byte buffer for the draw list
drawListBuffer = ByteBuffer.allocateDirect(
drawOrder.size * BYTES_PER_SHORT).apply {
order(ByteOrder.nativeOrder())
}.asShortBuffer().apply {
put(drawOrder)
position(0)
}
if (vertBuf > 0 && idxBuf > 0) {
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,
vertBuf)
GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER,
vertexBuffer.capacity() *
BYTES_PER_FLOAT,
vertexBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, idxBuf)
GLES20.glBufferData(
GLES20.GL_ELEMENT_ARRAY_BUFFER,
drawListBuffer.capacity() *
BYTES_PER_SHORT,
drawListBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(
GLES20.GL_ARRAY_BUFFER, 0)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
} else {
//TODO: error handling
}
}
draw()
方法与以前没有实质性的不同。这次我们使用构造函数中提供的着色器程序。同样,这个方法运行速度很快,因为它只在引用周围移动。
fun draw() {
// Add program to OpenGL ES environment
GLES20.glUseProgram(program!!)
// get handle to fragment shader's vColor member
val colorHandle = GLES20.glGetUniformLocation(
program!!, "vColor")
// Set color for drawing the square
GLES20.glUniform4fv(colorHandle!!, 1, color, 0)
// get handle to vertex shader's vPosition member
val positionHandle = GLES20.glGetAttribLocation(
program!!, "vPosition")
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(
positionHandle!!)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(positionHandle!!,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, vertexBuffer)
// Draw the square
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,
vertBuf)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, idxBuf)
GLES20.glDrawElements(GLES20.GL_TRIANGLE_STRIP,
drawListBuffer.capacity(),
GLES20.GL_UNSIGNED_SHORT, 0)
GLES20.glBindBuffer(
GLES20.GL_ARRAY_BUFFER, 0)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
// Disable vertex array
GLES20.glDisableVertexAttribArray(
positionHandle!!)
}
}
这里,在构造函数中,我们获取程序的句柄,一个顶点缓冲区名称(一个整数)和一个索引缓冲区名称(另一个整数),此外,我们准备并上传顶点和索引缓冲区到图形硬件。让一个新的Triangle
类使用相同的方法是留给你的一个练习。
新的渲染器类现在包含着色器程序,并为缓冲区准备句柄。但是我们更进一步,还增加了投影矩阵。
class MyGLRenderer : GLSurfaceView.Renderer {
companion object {
fun loadShader(type: Int, shaderCode: String)
: Int {
// create a vertex shader type
// (GLES20.GL_VERTEX_SHADER)
// or a fragment shader type
// (GLES20.GL_FRAGMENT_SHADER)
val shader = GLES20.glCreateShader(type)
// add the source code to the shader and
// compile it
GLES20.glShaderSource(shader, shaderCode)
GLES20.glCompileShader(shader)
return shader
}
}
val vertexShaderCode = """
attribute vec4 vPosition;
uniform mat4 uMVPMatrix;
void main() {
gl_Position = uMVPMatrix * vPosition;
}
""".trimIndent()
val fragmentShaderCode = """
precision mediump float;
uniform vec4 vColor;
void main() {
gl_FragColor = vColor;
}
""".trimIndent()
var triangle:Triangle? = null
var square:Square? = null
var program:Int? = 0
val vbo = IntArray(2) // vertex buffers
val ibo = IntArray(2) // index buffers
val vMatrix:FloatArray = FloatArray(16)
val projMatrix:FloatArray = FloatArray(16)
val mvpMatrix:FloatArray = FloatArray(16)
当 OpenGL 渲染准备就绪时,系统只调用一次方法onSurfaceCreated()
。我们用它来设置一些渲染标志和初始化着色器。
// Called once to set up the view's
// OpenGL ES environment.
override fun onSurfaceCreated(gl: GL10?, config:
javax.microedition.khronos.egl.EGLConfig?) {
// enable face culling feature
GLES20.glEnable(GL10.GL_CULL_FACE)
// specify which faces to not draw
GLES20.glCullFace(GL10.GL_BACK)
// Set the background frame color
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
val vertexShader = loadShader(
GLES20.GL_VERTEX_SHADER,
vertexShaderCode)
val fragmentShader = loadShader(
GLES20.GL_FRAGMENT_SHADER,
fragmentShaderCode)
// create empty OpenGL ES Program
program = GLES20.glCreateProgram()
// add the vertex shader to program
GLES20.glAttachShader(program!!, vertexShader)
// add the fragment shader to program
GLES20.glAttachShader(program!!, fragmentShader)
// creates OpenGL ES program executables
GLES20.glLinkProgram(program!!)
GLES20.glGenBuffers(2, vbo, 0) // just buffer names
GLES20.glGenBuffers(2, ibo, 0)
// Create a camera view and an orthogonal projection
// matrix
Matrix.setLookAtM(vMatrix, 0, 0f, 0f, 3.0f, 0f,
0f, 0f, 0f, 1.0f, 0.0f)
Matrix.orthoM(projMatrix,0,-1.0f,1.0f, -1.0f, 1.0f,
100.0f, -100.0f)
}
视图的每次重绘都会调用回调方法onDrawFrame()
。如果renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
,参见MyGLSurfaceView
类。然而,只有在检测到变化时,才不会在每一帧都调用这个函数。下面的代码片段也关闭了该类:
override fun onDrawFrame(unused: GL10) {
// Redraw background color
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program!!)
val muMVPMatrixHandle = GLES20.glGetUniformLocation
(
program!!, "uMVPMatrix");
Matrix.multiplyMM(mvpMatrix, 0,
projMatrix, 0, vMatrix, 0)
// Apply the combined projection and camera view
// transformations
GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1,
false, mvpMatrix, 0);
triangle = triangle ?:
Triangle(program,vbo[0],ibo[0])
triangle?.draw()
square = square ?:
Square(program,vbo[1],ibo[1])
square?.draw()
}
override
fun onSurfaceChanged(unused: GL10, width: Int,
height: Int) {
GLES20.glViewport(0, 0, width, height)
}
}
你可以看到投影矩阵是在 Kotlin 代码中计算的,但它们是作为 shader uniform 变量上传的,并在顶点着色器中使用。
将投影应用于三维对象更有意义。作为这种 3D 对象的一般假设,我们期望如下:
-
四维齐次坐标中的顶点坐标
-
指定给顶点的 RGBA 颜色
-
指定给顶点的面法线
使用四个坐标值而不是通常的三个(x,y,z)有助于透视投影。分配给顶点的颜色可以从着色器代码内部使用,以应用着色方案。但是它也可以被忽略或者用于非着色目的。这完全取决于着色器代码。法线有助于真实的照明。
渲染器只获取新的着色器代码,如下所示:
val vertexShaderCode = """
attribute vec4 vPosition;
attribute vec4 vNorm;
attribute vec4 vColor;
varying vec4 fColor;
varying vec4 fNorm;
uniform mat4 uMVPMatrix;
void main() {
gl_Position = uMVPMatrix * vPosition;
fColor = vColor;
fNorm = vNorm;
}
""".trimIndent()
val fragmentShaderCode = """
precision mediump float;
varying vec4 fColor;
varying vec4 fNorm;
void main() {
gl_FragColor = fColor;
}
""".trimIndent()
作为一个示例 3D 对象,我根据顶点颜色和目前忽略的法线,用插值着色呈现了一个立方体。
class Cube(val program: Int?, val vertBuf:Int,
val idxBuf:Int) {
val vertexBuffer: FloatBuffer
val drawListBuffer: ShortBuffer
companion 对象包含立方体所需的所有坐标和索引。
companion object {
val BYTES_PER_FLOAT = 4
val BYTES_PER_SHORT = 2
val COORDS_PER_VERTEX = 4
val NORMS_PER_VERTEX = 4
val COLORS_PER_VERTEX = 4
val VERTEX_STRIDE = (COORDS_PER_VERTEX +
NORMS_PER_VERTEX +
COLORS_PER_VERTEX) * BYTES_PER_FLOAT
var coords = floatArrayOf(
// positions + normals + colors
// --- front
-0.2f, -0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.2f, -0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.2f, 0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f,
-0.2f, 0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f,
// --- back
-0.2f, -0.2f, -0.2f, 1.0f,
0.0f, 0.0f, -1.0f, 0.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.2f, -0.2f, -0.2f, 1.0f,
0.0f, 0.0f, -1.0f, 0.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.2f, 0.2f, -0.2f, 1.0f,
0.0f, 0.0f, -1.0f, 0.0f,
0.0f, 1.0f, 0.0f, 1.0f,
-0.2f, 0.2f, -0.2f, 1.0f,
0.0f, 0.0f, -1.0f, 0.0f,
0.0f, 1.0f, 0.0f, 1.0f,
// --- bottom
-0.2f, -0.2f, 0.2f, 1.0f,
0.0f, -1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.2f, -0.2f, 0.2f, 1.0f,
0.0f, -1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.2f, -0.2f, -0.2f, 1.0f,
0.0f, -1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 1.0f,
-0.2f, -0.2f, -0.2f, 1.0f,
0.0f, -1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 1.0f,
// --- top
-0.2f, 0.2f, 0.2f, 1.0f,
0.0f, 1.0f, 0.0f, 0.0f,
1.0f, 0.0f, 1.0f, 1.0f,
0.2f, 0.2f, 0.2f, 1.0f,
0.0f, 1.0f, 0.0f, 0.0f,
1.0f, 0.0f, 1.0f, 1.0f,
0.2f, 0.2f, -0.2f, 1.0f,
0.0f, 1.0f, 0.0f, 0.0f,
1.0f, 0.0f, 1.0f, 1.0f,
-0.2f, 0.2f, -0.2f, 1.0f,
0.0f, 1.0f, 0.0f, 0.0f,
1.0f, 0.0f, 1.0f, 1.0f,
// --- right
0.2f, -0.2f, 0.2f, 1.0f,
1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 1.0f, 1.0f,
0.2f, 0.2f, 0.2f, 1.0f,
1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 1.0f, 1.0f,
0.2f, 0.2f, -0.2f, 1.0f,
1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 1.0f, 1.0f,
0.2f, -0.2f, -0.2f, 1.0f,
1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 1.0f, 1.0f,
// --- left
-0.2f, -0.2f, 0.2f, 1.0f,
-1.0f, 0.0f, 0.0f, 0.0f,
1.0f, 1.0f, 0.0f, 1.0f,
-0.2f, 0.2f, 0.2f, 1.0f,
-1.0f, 0.0f, 0.0f, 0.0f,
1.0f, 1.0f, 0.0f, 1.0f,
-0.2f, 0.2f, -0.2f, 1.0f,
-1.0f, 0.0f, 0.0f, 0.0f,
1.0f, 1.0f, 0.0f, 1.0f,
-0.2f, -0.2f, -0.2f, 1.0f,
-1.0f, 0.0f, 0.0f, 0.0f,
1.0f, 1.0f, 0.0f, 1.0f
)
val drawOrder = shortArrayOf( // vertices order
0, 1, 2, 0, 2, 3, // front
4, 6, 5, 4, 7, 6, // back
8, 10, 9, 8, 11, 10, // bottom
12, 13, 14 12, 14, 15, // top
16, 18, 17 16, 19, 18, // right
20, 21, 22 20, 22, 23, // left
)
}
和前面的清单一样,我们使用init
块来准备和初始化着色器所需的缓冲区。
init {
// initialize vertex byte buffer for shape
// coordinates, normals and colors
vertexBuffer = ByteBuffer.allocateDirect(
coords.size * BYTES_PER_FLOAT).apply{
order(ByteOrder.nativeOrder())
}.asFloatBuffer().apply {
put(coords)
position(0)
}
// initialize byte buffer for the draw list
drawListBuffer = ByteBuffer.allocateDirect(
drawOrder.size * BYTES_PER_SHORT).apply {
order(ByteOrder.nativeOrder())
}.asShortBuffer().apply {
put(drawOrder)
position(0)
}
if (vertBuf > 0 && idxBuf > 0) {
GLES20.glBindBuffer(
GLES20.GL_ARRAY_BUFFER, vertBuf)
GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER,
vertexBuffer.capacity() *
BYTES_PER_FLOAT,
vertexBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, idxBuf)
GLES20.glBufferData(
GLES20.GL_ELEMENT_ARRAY_BUFFER,
drawListBuffer.capacity() *
BYTES_PER_SHORT,
drawListBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(
GLES20.GL_ARRAY_BUFFER, 0)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
} else {
// TODO: error handling
}
}
这次渲染图形的draw()
方法如下:
fun draw() {
// Add program to OpenGL ES environment
GLES20.glUseProgram(program!!)
// get handle to vertex shader's vPosition member
val positionHandle =
GLES20.glGetAttribLocation(program,
"vPosition")
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(positionHandle)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(
positionHandle, COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, vertexBuffer)
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// Buffer offsets are a little bit strange in the
// Java binding - for the normals and colors we
// create new views and then reset the vertex
// array
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// get handle to vertex shader's vPosition member
vertexBuffer.position(COORDS_PER_VERTEX)
val normBuffer = vertexBuffer.slice()
// create a new view
vertexBuffer.rewind()
// ... and rewind the original buffer
val normHandle = GLES20.glGetAttribLocation(
program, "vNorm")
if(normHandle >= 0) {
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(normHandle)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(normHandle,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, normBuffer)
}
// get handle to vertex shader's vColor member
vertexBuffer.position(COORDS_PER_VERTEX +
NORMS_PER_VERTEX)
val colorBuffer = vertexBuffer.slice()
// create a new view
vertexBuffer.rewind()
// ... and rewind the original buffer
val colorHandle = GLES20.glGetAttribLocation(
program, "vColor")
if(colorHandle >= 0) {
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(colorHandle)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(colorHandle,
COLORS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, colorBuffer)
}
// Draw the cube
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,
vertBuf)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, idxBuf)
GLES20.glDrawElements(GLES20.GL_TRIANGLES,
drawListBuffer.capacity(),
GLES20.GL_UNSIGNED_SHORT, 0)
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,0)
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER,0)
// Disable attribute arrays
GLES20.glDisableVertexAttribArray(positionHandle)
if(normHandle >= 0)
GLES20.glDisableVertexAttribArray(normHandle)
if(colorHandle >= 0)
GLES20.glDisableVertexAttribArray(colorHandle)
}
}
移动
到目前为止,我们的对象是静态的,由于类MyGLSurfaceView
中的renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
,重绘只在需要时发生。如果你使用GLSurfaceView.RENDERMODE_CONTINUOUSLY
,每一帧都会重新绘制。
请注意,您仍然可以并且应该使用顶点和索引缓冲区来填充着色器。你可以通过调整 Kotlin 中的矩阵或直接编辑着色器代码来轻松引入运动,例如在添加更多的统一变量之后。
光
可以在片段着色器代码中添加光照。这一次我们将不得不使用法向量,因为它们决定了光线如何在表面元素上反射。如果我们引入光,我们需要告诉它的位置在哪里。为此,在渲染器的配套对象中添加以下内容:
val lightPos = floatArrayOf(0.0f, 0.0f, 4.0f, 0.0f)
随着着色器代码变得越来越复杂,我们应该找出如何获得错误消息。为此,您可以在渲染器的loadShader()
函数中添加以下内容:
val statusShader = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS,
IntBuffer.wrap(statusShader))
if (statusShader[0] == GLES20.GL_FALSE) {
val s = GLES20.glGetShaderInfoLog(shader)
Log.e("LOG", "Shader compilation: " + s)
}
同样,在程序链接后,添加以下代码片段:
val statusShader = IntArray(1)
GLES20.glGetShaderiv(program!!, GLES20.GL_LINK_STATUS,
IntBuffer.wrap(statusShader))
if (statusShader[0] == GLES20.GL_FALSE) {
val s = GLES20.glGetShaderInfoLog(program!!)
Log.e("LOG", "Shader linking: " + s)
}
新的顶点着色器根据旋转和缩放传输一个变换的法向量,而不是包括平移(这就是为什么法向量的第四个分量为 0.0),以及一个变换的位置向量,这次包括平移。
val vertexShaderCode = """
attribute vec4 vPosition;
attribute vec4 vNorm;
attribute vec4 vColor;
varying vec4 fColor;
varying vec3 N;
varying vec3 v;
uniform mat4 uVMatrix;
uniform mat4 uMVPMatrix;
void main() {
gl_Position = uMVPMatrix * vPosition;
fColor = vColor;
v = vec3(uVMatrix * vPosition);
N = normalize(vec3(uVMatrix * vNorm));
}
""".trimIndent()
注意,我们也传输顶点颜色,尽管我们不再使用它们。但是,您可以合并颜色信息,这样我们就不会删除它。下面的代码忽略顶点颜色。
新的片段着色器从顶点着色器获取插值位置和法线,为灯光位置添加统一变量,在我们的示例中,使用 Phong 着色模型来应用灯光。
val fragmentShaderCode = """
precision mediump float;
varying vec4 fColor;
varying vec3 N;
varying vec3 v;
uniform vec4 lightPos;
void main() {
vec3 L = normalize(lightPos.xyz - v);
vec3 E = normalize(-v); // eye coordinates!
vec3 R = normalize(-reflect(L,N));
//calculate Ambient Term:
vec4 Iamb = vec4(0.0, 0.1, 0.1, 1.0);
//calculate Diffuse Term:
vec4 Idiff = vec4(0.0, 0.0, 1.0, 1.0) *
max(dot(N,L), 0.0);
Idiff = clamp(Idiff, 0.0, 1.0);
// calculate Specular Term:
vec4 Ispec = vec4(1.0, 1.0, 0.5, 1.0) *
pow(max(dot(R,E),0.0),
/*shininess=*/5.0);
Ispec = clamp(Ispec, 0.0, 1.0);
// write Total Color:
gl_FragColor = Iamb + Idiff + Ispec;
//gl_FragColor = fColor; // use vertex color instead
}
""".trimIndent()
不要忘记在渲染器的onDrawFrame()
函数中添加一个灯光位置的句柄。
// The light position
val lightPosHandle = GLES20.glGetUniformLocation(
program!!, "lightPos");
GLES20.glUniform4f(lightPosHandle,
lightPos[0],lightPos[1],lightPos[2],lightPos[3])
Phong 着色算法中使用的矢量如图 9-2 所示。你会发现在着色器代码中使用相同的向量名称。
图 9-2
Phong 着色向量
您可以通过使用统一的颜色组件来增加灯光的动态效果。为了简单起见,我将它们硬编码在片段着色器中(所有那些vec4(...)
构造函数)。一个发光的立方体看起来像图 9-3 。
图 9-3
发光的立方体
口感
OpenGL 中的图像由纹理处理,这些纹理是上传到图形硬件的位图数据,通常跨越由纹理定义的表面。为了允许纹理,我们在前面几节中介绍的渲染器的伙伴对象获得了另一个函数,从 Android resources 文件夹加载可纹理化的图像。
companion object {
...
fun loadTexture(context: Context, resourceId: Int):
Int {
val textureHandle = IntArray(1)
GLES20.glGenTextures(1, textureHandle, 0)
if (textureHandle[0] != 0) {
val options = BitmapFactory.Options().apply {
inScaled = false // No pre-scaling
}
// Read in the resource
val bitmap = BitmapFactory.decodeResource(
context.getResources(),
resourceId, options)
// Bind to the texture in OpenGL
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,
textureHandle[0])
// Set filtering
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_MIN_FILTER,
GLES20.GL_NEAREST)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_MAG_FILTER,
GLES20.GL_NEAREST)
// Load the bitmap into the bound texture.
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0,
bitmap, 0)
// The bitmap is no longer needed.
bitmap.recycle()
}else{
// TODO: handle error
}
return textureHandle[0]
}
此外,渲染器为顶点和片段着色器获取新代码。
val vertexShaderCode = """
attribute vec4 vPosition;
attribute vec2 vTexture;
attribute vec4 vColor;
varying vec2 textureCoords;
varying vec4 fColor;
uniform mat4 uMVPMatrix;
void main() {
gl_Position = uMVPMatrix * vPosition;
textureCoords = vTexture;
fColor = vColor;
}
""".trimIndent()
val fragmentShaderCode = """
precision mediump float;
uniform sampler2D texture; // The input texture.
varying vec2 textureCoords;
varying vec4 fColor;
void main() {
gl_FragColor = texture2D(texture, textureCoords);
// use vertex color instead:
// gl_FragColor = fColor;
}
""".trimIndent()
属性vTexture
对应于对象顶点定义中的新数据段。uniform sampler2D texture;
描述与 Kotlin 代码中定义的纹理对象的连接。
作为一个样本对象,我们定义了一个平面,它类似于我们之前定义的Cube
类中的一个面,除了额外提供纹理。
class Plane(val program: Int?, val vertBuf:Int,
val idxBuf:Int, val context: Context) {
val vertexBuffer: FloatBuffer
val drawListBuffer: ShortBuffer
// Used to pass in the texture.
var textureUniformHandle: Int = 0
// A handle to our texture data
var textureDataHandle: Int = 0
companion 对象用于定义坐标和一些常数。
companion object {
val BYTES_PER_FLOAT = 4
val BYTES_PER_SHORT = 2
val COORDS_PER_VERTEX = 4
val TEXTURE_PER_VERTEX = 2
val NORMS_PER_VERTEX = 4
val COLORS_PER_VERTEX = 4
val VERTEX_STRIDE = (COORDS_PER_VERTEX +
TEXTURE_PER_VERTEX +
NORMS_PER_VERTEX +
COLORS_PER_VERTEX) * BYTES_PER_FLOAT
var coords = floatArrayOf(
// positions, normals, texture, colors
-0.2f, -0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.2f, -0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.2f, 0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f,
-0.2f, 0.2f, 0.2f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f
)
val drawOrder = shortArrayOf( // vertices order
0, 1, 2, 0, 2, 3
)
}
在init
块中,缓冲区被定义和初始化,我们还加载了一个纹理图像。
init {
// initialize vertex byte buffer for shape
// coordinates
vertexBuffer = ByteBuffer.allocateDirect(
coords.size * BYTES_PER_FLOAT).apply{
order(ByteOrder.nativeOrder())
}.asFloatBuffer().apply {
put(coords)
position(0)
}
// initialize byte buffer for the draw list
drawListBuffer = ByteBuffer.allocateDirect(
drawOrder.size * BYTES_PER_SHORT).apply {
order(ByteOrder.nativeOrder())
}.asShortBuffer().apply {
put(drawOrder)
position(0)
}
if (vertBuf > 0 && idxBuf > 0) {
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,
vertBuf)
GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER,
vertexBuffer.capacity() * BYTES_PER_FLOAT,
vertexBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, idxBuf)
GLES20.glBufferData(
GLES20.GL_ELEMENT_ARRAY_BUFFER,
drawListBuffer.capacity() *
BYTES_PER_SHORT,
drawListBuffer, GLES20.GL_STATIC_DRAW)
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
} else {
// TODO: handle error
}
// Load the texture
textureDataHandle =
MyGLRenderer.loadTexture(context,
R.drawable.myImage)
}
draw()
回调用于绘制包括纹理在内的缓冲区。在下面的代码片段中,我们还关闭了该类。
fun draw() {
// Add program to OpenGL ES environment
GLES20.glUseProgram(program!!)
// get handle to vertex shader's vPosition member
val positionHandle =
GLES20.glGetAttribLocation(program,
"vPosition")
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(positionHandle)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(positionHandle,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, vertexBuffer)
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// Buffer offsets are a little bit strange in the
// Java binding - For the other arrays we create
// a new view and then reset the vertex array
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// get handle to vertex shader's vNorm member
vertexBuffer.position(COORDS_PER_VERTEX)
val normBuffer = vertexBuffer.slice()
// create a new view
vertexBuffer.rewind()
// ... and rewind the original buffer
val normHandle =
GLES20.glGetAttribLocation(program, "vNorm")
if(normHandle >= 0) {
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(normHandle)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(normHandle,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, normBuffer)
}
// get handle to vertex shader's textureCoords
vertexBuffer.position(COORDS_PER_VERTEX +
NORMS_PER_VERTEX)
val textureBuffer = vertexBuffer.slice()
// create a new view
vertexBuffer.rewind()
// ... and rewind the original buffer
val textureHandle =
GLES20.glGetAttribLocation(program,
"vTexture")
if(textureHandle >= 0) {
// Enable a handle to the texture coords
GLES20.glEnableVertexAttribArray(
textureHandle)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(textureHandle,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, textureBuffer)
}
// get handle to vertex shader's vColor member
vertexBuffer.position(COORDS_PER_VERTEX +
NORMS_PER_VERTEX + TEXTURE_PER_VERTEX)
val colorBuffer = vertexBuffer.slice()
// create a new view
vertexBuffer.rewind()
// ... and rewind the original buffer
val colorHandle =
GLES20.glGetAttribLocation(program, "vColor")
if(colorHandle >= 0) {
// Enable a handle to the vertices
GLES20.glEnableVertexAttribArray(colorHandle)
// Prepare the coordinate data
GLES20.glVertexAttribPointer(colorHandle,
COLORS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRIDE, colorBuffer)
}
textureUniformHandle =
GLES20.glGetUniformLocation(program,
"texture")
if(textureHandle >= 0) {
// Set the active texture unit to
// texture unit 0.
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
// Tell the texture uniform sampler to use
// this texture in the shader by binding to
// texture unit 0.
GLES20.glUniform1i(textureUniformHandle, 0)
}
// Draw the plane
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,
vertBuf)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, idxBuf)
GLES20.glDrawElements(GLES20.GL_TRIANGLES,
drawListBuffer.capacity(),
GLES20.GL_UNSIGNED_SHORT, 0)
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0)
GLES20.glBindBuffer(
GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
// Disable vertex array
GLES20.glDisableVertexAttribArray(
positionHandle)
if(normHandle >= 0)
GLES20.glDisableVertexAttribArray(
normHandle)
if(textureHandle >= 0)
GLES20.glDisableVertexAttribArray(
textureHandle)
if(colorHandle >= 0)
GLES20.glDisableVertexAttribArray(
colorHandle)
}
}
用户输入
为了响应用户触摸事件,你所要做的就是覆盖函数onTouchEvent( e: MotionEvent ) : Boolean { ... }
。你接收到的MotionEvent
能够告诉你很多事情。
-
触摸事件:向下触摸和向上触摸
-
移动事件:触摸时移动
-
指针事件:第二,第三,...手指触摸
除此之外,还注册了几个事件;参见MotionEvent
的在线文档。
如果我们想要监听触摸和移动事件,这种监听器的最小实现如下所示:
override
fun onTouchEvent(event: MotionEvent): Boolean {
var handled = true
when(event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
Log.e("LOG","Action: ACTION_DOWN " +
event.toString())
}
MotionEvent.ACTION_UP -> {
Log.e("LOG","Action: ACTION_UP " +
event.toString())
}
MotionEvent.ACTION_MOVE -> {
Log.e("LOG","Action: MOVE " +
event.toString())
}
else -> handled = false
}
return handled || super.onTouchEvent(event)
}
您可以看到,当我们的侦听器处理事件时,我们返回true
。如果我们收到一个ACTION_DOWN
事件,返回true
是很重要的;否则,移动和上移操作都将被忽略。
注意
我们检查事件对象的actionMasked
访问器,而不是通常建议的action
访问器。这样做的原因是,如果发生多点触摸事件,action
访问器可能包含额外的位。被掩盖的变体更可靠。
带有可移动项目的用户界面设计
如果你的应用需要可移动的 UI 元素,你应该使用FrameLayout
类或者它的子类。我们想自己处理 UI 元素的位置,不希望布局类干扰它。FrameLayout
类不动态定位它的元素,所以使用它是这种定位的好选择。
如果您希望您的视图是可移动的,您可以创建一个子类并覆盖它的onTouchEvent()
方法。例如,假设您有一个ImageView
并希望它是可移动的。为此,创建一个子类,如下所示:
class MyImageView : ImageView {
constructor(context: Context)
: super(context)
constructor(context: Context, attrs: AttributeSet)
: super(context, attrs)
var dx : Float = 0.0f
var dy : Float = 0.0f
override
fun onTouchEvent(event: MotionEvent): Boolean {
var handled = true
when(event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
//Log.e("LOG","Action: ACTION_DOWN " +
//event.toString())
dx = x - event.rawX
dy = y - event.rawY
}
MotionEvent.ACTION_UP -> {
//Log.e("LOG","Action: ACTION_UP " +
//event.toString())
}
MotionEvent.ACTION_MOVE -> {
//Log.e("LOG","Action: MOVE " +
//event.toString())
x = event.rawX + dx
y = event.rawY + dy
}
else -> handled = false
}
return handled || super.onTouchEvent(event)
}
}
相反,如果您希望通过代码中的一个点来处理移动,您可以做两件事情中的一件。
-
您可以为可移动视图创建一个基类,并让所有 UI 元素都继承它。
-
您可以将触摸事件侦听器添加到布局容器中。但是,您必须提供一些逻辑,通过检查像素坐标边界来找到被触摸的 UI 元素。
菜单和操作栏
如果你想给你的用户提供一个可选择的列表,菜单和操作栏是重要的用户界面元素。在下面的章节中,我们将介绍不同种类的菜单,并解释何时以及如何使用它们。
选项菜单
选项菜单显示在应用栏内。Android Studio 将通过应用栏帮助你开始开发应用。如果您想自己做,在您的布局中添加以下内容:
<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/AppTheme.AppBarOverlay">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/AppTheme.PopupOverlay"/>
</android.support.design.widget.AppBarLayout>
在 activity 的onCreate(...)
方法中,我们需要告诉 Android OS 我们正在使用一个应用栏。为此,请通过以下方式注册应用栏:
setSupportActionBar(toolbar)
同样,在活动中,覆盖onCreateOptionsMenu(...)
来创建菜单,覆盖onOptionsItemSelected(...)
来监听菜单点击事件。
override
fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.getItemId()) {
menu_item1 -> {
Toast.makeText(this,"Item 1",
Toast.LENGTH_LONG).show()
return true
}
menu_item2 -> {
Toast.makeText(this,"Item 2",
Toast.LENGTH_LONG).show()
return true
}
else -> return
super.onOptionsItemSelected(item)
}
}
override
fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater = menuInflater
inflater inflate(R.menu.my_menu, menu)
return true
}
剩下的就是菜单本身的定义了。在res/menu
中,添加一个名为my_menu.xml
的 XML 文件,内容如下:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android=
"http://schemas.android.com/apk/res/android">
<item android:id="@+id/menu_item1"
android:title="@string/title_item1"/>
<item android:id="@+id/menu_item2"
android:title="@string/title_item2"/>
</menu>
这里,<item>
元素接受更多的属性;更多信息参见在线文档(搜索 Android 菜单资源)。特别有趣的是android:showAsAction
属性。将它设置为ifRoom
允许将菜单项作为一个单独的动作元素突出地放在动作栏中。
上下文菜单
在注册的视图上长时间点击后会出现一个上下文菜单。要进行注册,在活动内部调用registerForContextMenu()
并提供视图作为参数。
override fun onCreate(savedInstanceState: Bundle?) {
...
registerForContextMenu(myViewId)
}
如果您希望上下文菜单在几个视图中显示,可以多次这样做。
一旦注册了上下文菜单,就可以通过覆盖活动中的onCreateContextMenu(...)
来定义它,此外还可以覆盖onContextItemSelected
来监听菜单选择事件。这里有一个例子:
override
fun onCreateContextMenu(menu: ContextMenu, v: View,
menuInfo: ContextMenuInfo?) {
super.onCreateContextMenu(menu, v, menuInfo)
val inflater = menuInflater
inflater.inflate(R.menu.context_menu, menu)
}
override
fun onContextItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
ctxmenu_item1 -> {
Toast.makeText(this,"CTX Item 1",
Toast.LENGTH_LONG).show()
}
ctxmenu_item2 -> {
Toast.makeText(this,"CTX Item 2",
Toast.LENGTH_LONG).show()
}
else -> return
super.onContextItemSelected(item)
}
return true
}
XML 定义进入标准菜单 XML 文件,例如res/context_menu.xml
。
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android=
"http://schemas.android.com/apk/res/android">
<item android:id="@+id/ctxmenu_item1"
android:title="@string/ctxtitle_item1"/>
<item android:id="@+id/ctxmenu_item2"
android:title="@string/ctxtitle_item2"/>
</menu>
也可以通过在活动中使用openContextMenu( someView )
以编程方式打开上下文菜单。
上下文动作模式
上下文动作模式负责上下文相关的应用栏。虽然应用栏对应用是静态的,但上下文操作模式是视图特定的。对于这种上下文菜单,您必须执行以下操作:
-
实现
ActionMode.Callback
接口。-
在
onCreateActionMode(...)
中创建菜单,类似于前面显示的标准上下文菜单的onCreateContextMenu()
。 -
在
onPrepareActionMode(...)
里面返回false
,除非你需要特殊的准备步骤。 -
实现
onActionItemClicked(...)
监听触摸事件。
-
-
为标准上下文菜单创建一个菜单 XML 资源文件。
-
在您的代码中,通过调用
startActionMode( theActionModeCallback )
打开上下文动作模式。
弹出式菜单
虽然与上下文相关的菜单主要用于不经常更改的设置,但属于前端工作流的菜单最好实现为弹出菜单。弹出菜单通常是用户与特定视图交互的结果,因此弹出菜单被分配给 UI 元素。
在一些用户操作之后显示视图的弹出菜单就像调用一个函数一样简单。
fun showPopup(v: View) {
PopupMenu(this, v).run {
setOnMenuItemClickListener { menuItem ->
Toast.makeText(this@TheActivity,
menuItem.toString(),
Toast.LENGTH_LONG).show()
true
}
menuInflater.inflate(popup, menu)
show()
}
}
像往常一样,您还需要在res/menu
资源中定义菜单。例如,它是一个名为popup.xml
的文件(如inflate(...)
的第一个参数所示)。
进度条
对于需要几秒钟时间的任务,显示进度条是改善用户体验的好方法。要实现进度条,在你的布局中添加一个ProgressBar
视图。无论是在 XML 布局文件中还是在 Kotlin 程序中,都没有关系。在 XML 中,您可以编写以下内容:
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>
对于不确定的进度条(如果您不能将进度表示为百分比值)或下面的确定的进度条(在更新进度条时您知道百分比)。
<ProgressBar
android:id="@+id/progressBar"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:progress="0"
/>
在代码中,您可以使用以下命令来切换不确定进度条的可见性:
progressBar.visibility = View.INVISIBLE
// or .. = View.VISIBLE
要为确定的进度条设置进度值,请参见以下示例:
with(progressBar) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
min = 0
max = 100
}
var value = 0
Thread {
while(value < 100) {
value += 1
Thread.sleep(200)
runOnUiThread {
progressBar.progress = value
}
}
progressBar.visibility = View.INVISIBLE
}.start()
这里,我们使用一个后台线程来模拟长时间运行的任务。在现实世界的应用中,后台会发生一些更合理的事情。也许你用一个AsyncTask
来代替一个线程。
使用片段
一个片段是一个活动的可重用的、模块化的部分。如果你开发一个没有片段的应用,你会有很多活动互相调用。问题是,在大屏幕设备上,如果活动彼此之间保持紧密联系,这可能不是最佳解决方案。例如,考虑一个活动中一些项目的列表和另一个活动中一个选定项目的详细视图。在小型设备上,一旦用户单击列表活动中的一项,就启动细节活动是完全可以接受的。然而,在更大的屏幕上,如果两个视图,列表和细节,同时显示,可能会改善用户体验。
这就是碎片派上用场的地方。您不用在活动之间切换,而是创建几个片段,它们是同一个活动的一部分。然后,根据设备的不同,您可以选择单窗格视图或双窗格视图。
如果您从一个只包含活动的应用中执行转换,那么开发片段是很容易的。片段和活动一样有生命周期,生命周期回调和活动的生命周期回调是相似的。这就是故事变得有点复杂的地方,因为容器活动的生命周期和所包含的片段的生命周期是相互联系的,而且片段还表现出专用的调用堆栈行为。片段的在线文档为您提供了所有片段相关问题的详细参考;在这一节中,我们将调查限制在创建和使用片段的基本方面。
创建片段
要创建片段,您有两个选择:要么在布局文件中指定Fragment
元素,要么从 Kotlin 代码中以编程方式添加片段。
要使用 XML 方式将片段添加到您的应用中,您需要在布局文件中确定添加片段的适当位置,并编写以下内容:
<fragment android:name=
"com.example.android.fragments.TheFragment"
android:id="@+id/fragment_id1"
android:layout_weight="..."
android:layout_width="..."
android:layout_height="..." />
布局参数将根据您的应用的布局需求进行选择。对于不同的设备,更确切地说是不同的屏幕尺寸,您可以提供几个不同的布局文件,在不同的位置包含不同数量的片段。
对于由 XML 文件中的name
属性指定的片段类,从如下所示的最小片段开始:
import android.support.v4.app.Fragment
...
class MyFragment : Fragment() {
override
fun onCreateView(inflater: LayoutInflater?,
container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater!!.inflate(
my_fragment, container, false)
}
}
将名为my_fragment.xml
的布局文件添加到res/layout
资源文件夹中。
要从 Kotlin 代码内部添加片段,您需要标识一个布局容器或ViewGroup
来放置片段,然后使用片段管理器来执行插入。
with(supportFragmentManager.beginTransaction()) {
val fragment = MyFragment()
add(fragm_container.id, fragment, "fragmTag")
val fragmentId = fragment.id // can use that later...
commit()
}
例如,这可能发生在活动的onCreate()
回调中,或者更动态地发生在任何其他合适的位置。例如,fragm_container
是布局 XML 中的一个<FrameLayout>
元素。
注意
当将片段添加到事务中时,片段从片段管理器获取其 ID。在此之前不能使用它,如果在 Kotlin 代码中添加片段,也不能提供自己的 ID。
处理来自活动的片段
处理来自内部活动的片段包括以下内容:
-
添加片段,如前面的“创建片段”一节所示
-
给定 ID 或标签,获取对片段的引用
-
处理后堆栈
-
为生命周期事件注册监听器
对于所有这些需求,您可以使用getSupportFragmentManager()
,它为您提供了能够完成所有这些工作的片段管理器,或者在 Kotlin 中,只需使用下面的访问器来获取引用:
supportFragmentManager
注意
还有一个名字里没有“支持”的fragmentManager
。这一点指向框架的片段管理器,而不是支持库片段管理器。然而,使用支持片段管理器提高了与旧 API 级别的兼容性。
与碎片交流
活动可以通过使用片段管理器和基于它们的 ID 或标签查找片段来与它们的片段进行通信。
val fragm = supportFragmentManager.
findFragmentByTag("fragmTag")
// or val fragm = supportFragmentManager.
// findFragmentById(fragmId)
但是碎片也能和它们的活动对话。这可能意味着糟糕的应用设计,因为片段应该是自包含的实体。如果您仍然需要它,您可以在 fragment 类中使用getActivity()
,或者在 Kotlin 中简单地使用这个来访问它:
activity
从那里,一个片段甚至可以获取对其他片段的引用。
应用小部件
应用小部件是一种专用类型的应用,在其他应用中显示信息性消息和/或控制器,尤其是主屏幕。应用小部件被实现为特殊的广播接收器,因此,一旦回调方法完成了它们的工作,几秒钟过去了,它们就会被 Android 操作系统杀死。如果您需要运行更长的流程,可以考虑从应用小部件内部启动服务。
要开始创建一个应用小部件,在AndroidManifest.xml
中编写以下内容作为<application>
的子元素:
<receiver android:name=".ExampleAppWidgetProvider" >
<intent-filter>
<action android:name="android.appwidget.action.
APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider"
android:resource="@xml/
example_appwidget_info" />
</receiver>
接下来,在资源中创建元数据。在res/xml
中创建一个名为example_appwidget_info.xml
的新文件,并在其中写入以下内容:
<appwidget-provider xmlns:android=
"http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>
剩下的工作是创建广播侦听器类。为了这个目的,从类android.appwidget.AppWidgetProvider
继承是最容易的。例如,编写以下内容:
class ExampleAppWidgetProvider : AppWidgetProvider() {
override
fun onUpdate(context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds:IntArray) {
// Perform this loop procedure for each App
// Widget that belongs to this provider
for(appWidgetId in appWidgetIds) {
// This is just an example, you can do other
// stuff here...
// Create an Intent to launch MainActivity
val intent = Intent(context,
MainActivity::class.java)
val pendingIntent = PendingIntent.
getActivity(context, 0, intent, 0)
// Attach listener to the button
val views =
RemoteViews(context.getPackageName(),
R.layout.appwidget_provider_layout)
views.setOnClickPendingIntent(
R.id.button, pendingIntent)
// Tell the AppWidgetManager to perform an
// update on the app widget
appWidgetManager.updateAppWidget(
appWidgetId, views)
}
}
}
因为一个应用小部件提供者可以提供几个应用小部件,所以我们必须在设置中经历一个循环。由于这需要一个布局文件ciappwidget_provider_layout.xml
,我们在res/layout
中创建这个文件,并编写如下内容:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Go"/>
</RelativeLayout>
注意
并非所有布局容器和视图都允许出现在应用小部件的布局文件中。您可以使用其中的一种:FrameLayout
、LinearLayout
、RelativeLayout
、GridLayout
、AnalogClock
、Button
、Chronometer
、ImageButton
、ImageView
、ProgressBar
、TextView
、ViewFlipper
、ListView
、GridView
、StackView
和AdapterViewFlipper
。
注意
用户仍然必须决定通过长时间点击应用图标来激活应用小部件,然后将其放在主屏幕上。因为不是所有用户都知道这一点,所以应用的功能不应该取决于它是否在主屏幕上被设置为应用小部件。
应用小部件可以附加一个配置活动。一旦用户试图将应用小部件放在主屏幕上,就会调用这个特殊的活动。然后可以要求用户进行一些关于外观或功能的设置。要安装这样的活动,您需要在AndroidManifest.xml
中编写以下内容:
<activity android:name=".ExampleAppWidgetConfigure">
<intent-filter>
<action android:name=
"android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
这里显示的意图过滤器很重要;它告诉系统活动的这种特殊性质。
然后,必须将 app widget 配置活动添加到 XML 配置文件example_appwidget_info.xml
中。使用配置器类的全限定名称,添加以下内容作为附加属性:
android:configure=
"full.class.name.ExampleAppWidgetConfigure"
要求配置活动本身返回应用小部件 ID,如下所示:
class ExampleAppWidgetConfigure : AppCompatActivity() {
var awi:Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_conf)
awi = intent.extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID)
Toast.makeText(this,"" + awi, Toast.LENGTH_LONG).
show()
// do more configuration stuff...
}
fun goBack(view: View) {
// just an example...
val data = Intent()
data.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
awi)
setResult(RESULT_OK, data)
finish()
}
}
注意
第一次配置活动退出时,app 小部件的onUpdate()
方法没有被调用。如果需要,相应地调用应用小部件管理器上的updateAppWidget()
是配置活动的责任。
拖放
Android 支持任何类型的 UI 元素和布局的拖放。
-
该应用定义了一个手势,该手势定义了拖动操作的开始。你完全可以自由定义这个手势;通常的候选操作是触摸或触摸并移动操作,但是您也可以通过编程启动拖动操作。
-
如果拖放指定了某种数据传输,您可以将类型为
android.content.ClipData
的数据片段分配给拖动操作。 -
参与这个拖放操作的视图(这包括任何拖动源和所有可能的拖放目标)得到一个定制的
View.OnDragListener
对象。 -
在拖动过程中,被拖动的目标在视觉上由移动的阴影对象表示。你可以自由定义它。它可以是一个星形、一个方框或任何类似拖动源的图形。你只需要告诉系统如何绘制它。拖动操作过程中的定位由 Android 操作系统自动处理。因为拖动源在拖动操作期间保持在原位,所以布局始终保持静态,并且拖动不会妨碍布局管理器的布局操作。
-
当拖动阴影进入可能的拖放目标区域时,监听器会得到通知,您可以做出反应,例如,更改拖放候选对象的视觉外观。
-
因为所有拖动源和可能的拖放目标都知道各种不同的拖动状态,所以您可以通过使用不同的视图外观自由地直观地表达出来。
-
一旦发生丢弃,监听器会得到通知,您可以自由地对这种丢弃事件做出反应。
也可以不使用专用的拖放侦听器,而是覆盖参与拖放操作的视图的某些特定方法。这样做的缺点是,您必须在布局描述中使用定制的视图,这使得它的可读性有点差。此外,从体系结构的角度来看,视图知道太多发生在它们之间的事情,这是一个外部问题,因此最好由外部对象来处理。因此,我们遵循监听器方法,并在接下来的部分中描述这种方法到底要做什么。
定义拖动数据
如果您的拖放操作定义了某种从一个视图表示的对象到另一个对象的数据传输,您可以定义一个ClipData
对象,例如,如下所示:
val item = ClipData.Item(myView.tag.toString())
val dragData = ClipData(myView.tag.toString(),
arrayOf(MIMETYPE_TEXT_PLAIN), item)
构造函数的第一个参数是剪辑数据的用户可读标签,第二个参数通过分配适当的 MIME 类型来描述内容的类型,item
参数表示要传输的数据,这里是视图的tag
属性给出的字符串。其他项类型是意图和 URIs,在ClipData.Item
的构造函数中指定。
定义拖动阴影
拖动阴影是在拖动过程中绘制在手指下方的可见元素。您可以按如下方式定义对象中的阴影:
class DragShadow(val resources: Resources, val resId:Int,
view: ImageView) : View.DragShadowBuilder(view) {
val rect = Rect()
// Defines a callback that sends the drag shadow
// dimensions and touch point back to the
// system.
override
fun onProvideShadowMetrics(size: Point, touch: Point) {
val width = view.width
val height = view.height
rect.set(0, 0, width, height)
// Back to the system through the size parameter.
size.set(width, height)
// The touch point's position in the middle
touch.set(width / 2, height / 2)
}
// Defines a callback that draws the drag shadow in a
// Canvas
override
fun onDrawShadow(canvas: Canvas) {
canvas.drawBitmap(
BitmapFactory.decodeResource(
resources, resId),
null, rect, null)
}
}
这个例子画了一个位图资源,但是你可以在这里做任何你想做的事情。
开始拖动
要开始拖动,您可以调用 API 级别为 24 或更高的startDragAndDrop()
,或者调用作为拖动源的视图对象上的startDrag()
。这里有一个例子:
theView.setOnTouchListener { view, event ->
if(event.action == MotionEvent.ACTION_DOWN) {
val shadow = DragShadow(resources,
R.the_dragging_image, theView)
val item = ClipData.Item(frog.tag.toString())
val dragData = ClipData(frog.tag.toString(),
arrayOf(MIMETYPE_TEXT_PLAIN), item)
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.N) {
theView.startDragAndDrop(dragData, shadow,
null, 0)
} else {
theView.startDrag(dragData, shadow,
null, 0)
}
}
true
}
如果拖动操作是而不是与数据类型相关联的,你也可以让dragData
是null
。在这种情况下,您不必构建一个ClipData
对象。
监听拖动事件
拖放操作过程中发生的一整套事件由拖放侦听器控制。这里有一个例子:
class MyDragListener : View.OnDragListener {
override
fun onDrag(v: View, event: DragEvent): Boolean {
var res = true
when(event.action) {
DragEvent.ACTION_DRAG_STARTED -> {
when(v.tag) {
"DragSource" -> { res = false
/*not a drop receiver*/ }
"OneTarget" -> {
// could visibly change
// possible drop receivers
}
}
}
DragEvent.ACTION_DRAG_ENDED -> {
when(v.tag) {
"OneTarget" -> {
// could visibly change
// possible drop receivers
}
}
}
DragEvent.ACTION_DROP -> {
when(v.tag) {
"OneTarget" -> {
// visually revert drop
// receiver ...
}
}
Toast.makeText(v.context, "dropped!",
Toast.LENGTH_LONG).show()
}
DragEvent.ACTION_DRAG_ENTERED -> {
when(v.tag) {
"OneTarget" -> {
// could visibly change
// possible drop receivers
}
}
}
DragEvent.ACTION_DRAG_EXITED -> {
when(v.tag) {
"OneTarget" -> {
// visually revert drop
// receiver ...
}
}
}
}
return res
}
}
您可以看到,我们正在监听拖动开始和结束事件、阴影进入或退出可能的拖放区域以及拖放事件。正如所指出的,你如何应对所有这些事件完全取决于你自己。
注意
在这个例子中,我们使用一个分配给视图的tag
属性来标识作为拖放操作一部分的视图。事实上,你也可以使用 ID 或者任何你能想到的方法。
剩下的在活动的onCreate()
回调里面;您向参与拖放操作的所有视图注册侦听器。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
theView.setOnTouchListener ...
val dragListener = MyDragListener()
theView.setOnDragListener(dragListener)
otherView.setOnDragListener(dragListener)
...
}
多点触摸
Android 操作系统中的多点触摸事件非常容易处理。你所需要做的就是覆盖一个View
或者一个ViewGroup
元素的onTouchEvent()
方法。在onTouchEvent()
中,你获取被屏蔽的动作并对其进行操作。
frog.setOnTouchListener { view, event ->
true
}
注意
在旧版本的 Android 中,你通常像在event.action
中那样调度动作。对于多点触摸手势,最好在maskedAction
上操作。
在监听器内部,您通过event.actionMasked
获得屏蔽的动作,并将其传递给一个when(){ .. }
语句。
神奇之处在于这个监听器被所有手指(这里称为指针)连续调用。要找出当前有多少手指被注册,您可以使用event.pointerCount
,如果您想知道该事件属于您的哪个手指,可以使用val index = event.actionIndex
。因此,起点如下:
theView.setOnTouchListener { view,event ->
fun actionToString(action:Int) : String = mapOf(
MotionEvent.ACTION_DOWN to "Down",
MotionEvent.ACTION_MOVE to "Move",
MotionEvent.ACTION_POINTER_DOWN to "Pointer Down",
MotionEvent.ACTION_UP to "Up",
MotionEvent.ACTION_POINTER_UP to "Pointer Up",
MotionEvent.ACTION_OUTSIDE to "Outside",
MotionEvent.ACTION_CANCEL to "Cancel").
getOrDefault(action,"")
val action = event.actionMasked
val index = event.actionIndex
var xPos = -1
var yPos = -1
Log.d("LOG", "The action is " +
actionToString(action))
if (event.pointerCount > 1) {
Log.d("LOG", "Multitouch event")
// The coordinates of the current screen contact,
// relative to the responding View or Activity.
xPos = event.getX(index).toInt()
yPos = event.getY(index).toInt()
} else {
// Single touch event
Log.d("LOG", "Single touch event")
xPos = event.getX(index).toInt()
yPos = event.getY(index).toInt()
}
// do more things...
true
}
画中画模式
从 Android 8.0 (API 级别 26)开始,存在一种画中画模式,其中活动被缩小并固定在屏幕的边缘。如果活动播放视频,并且您希望视频在另一个活动出现时继续播放,这将非常有用。
要启用画中画模式,在AndroidManifest.xml
内将以下属性添加到<activity>
:
android:resizeableActivity="true"
android:supportsPictureInPicture="true"
android:configChanges=
"screenSize|smallestScreenSize|
screenLayout|orientation"
然后,在你的应用中,只要可行,你就可以使用以下命令启动画中画模式:
enterPictureInPictureMode()
如果进入或退出画中画模式,您可能需要更改布局,然后再恢复。为此,覆盖onPictureInPictureModeChanged( isInPictureInPictureMode : Boolean, newConfig : Configuration )
并做出相应反应。
文本到语音
文本到语音框架允许将文本转换为音频,或者直接发送到音频硬件,或者发送到文件。使用相应的TextToSpeech
类很容易,但是您应该确保所有必要的资源都已加载。为了这个目的,一个带有动作的意图TextToSpeech.Engine.ACTION_CHECK_TTS_DATA
应该被激发。有望回归TextToSpeech.Engine.CHECK_VOICE_DATA_PASS
;如果没有,用动作TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA
调用另一个意图,让用户将文本安装到语音数据中。
执行以下所有操作的示例活动:
class MainActivity : AppCompatActivity() {
companion object {
val MY_DATA_CHECK_CODE = 42
}
var tts: TextToSpeech? = null
override
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val checkIntent = Intent()
checkIntent.action = TextToSpeech.Engine.
ACTION_CHECK_TTS_DATA
startActivityForResult(checkIntent,
MY_DATA_CHECK_CODE)
}
fun go(view: View) {
tts?.run {
language = Locale.US
val myText1 = "Did you sleep well?"
val myText2 = "It's time to wake up."
speak(myText1, TextToSpeech.QUEUE_FLUSH, null)
speak(myText2, TextToSpeech.QUEUE_ADD, null)
}
}
override
fun onActivityResult(requestCode: Int,
resultCode: Int, data: Intent) {
if (requestCode == MY_DATA_CHECK_CODE) {
if (resultCode ==
TextToSpeech.Engine.CHECK_VOICE_DATA_PASS) {
// success, create the TTS instance
tts = TextToSpeech(this, { status ->
// do s.th. if you like
})
} else {
// data are missing, install it
val installIntent = Intent()
installIntent.action =
TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA
startActivity(installIntent)
}
}
}
}
这个例子还包含了一个go()
方法,例如可以通过按下按钮来触发。我会准备一些演讲稿,并立即发送到扩音器上。
如果你想把音频写到一个文件,使用tts.synthesizeToFile()
方法。您可以在TextToSpeech
的在线文档中找到更多详细信息。
十、开发
与前几章相比,本章涉及的问题更接近于开发问题。我们将在这里讨论的主题与特定的 Android 操作系统 API 没有太紧密的联系。我们更关心的是如何使用 Kotlin 方法最好地完成技术需求。
这一章还有一节介绍了如何将 Kotlin 代码转换成可以服务于WebView
小部件的 JavaScript 代码。
用 Kotlin 编写可重用库
你在网上找到的大多数教程都是关于活动、服务、广播接收器和内容供应器的。这些组件是可重用的,因为您可以或多或少地从一个项目中提取它们,并将它们复制到另一个项目中。Android 操作系统中的封装已经达到了一个复杂的阶段,这使得重用成为可能。然而,在较低的层面上,在某些情况下,Android 中提供的库或 API 可能不适合您的所有需求,因此您可能会尝试自己开发这样的库,然后在可行的情况下将源代码从一个项目复制到另一个项目。
当然,这种在源代码层面上的复制并不适合可重用库的现代方法;只需考虑引入大量样板工作的维护和版本控制问题。最好的事情是将这样的可重用库设计为专用的开发工件。然后,它们可以很容易地从不同的项目中重用。
在接下来的部分中,我们将开发一个基本的正则表达式库,作为您自己的库项目的概念基础。
启动库模块
库项目是包含一个或多个模块的项目。在 Android Studio 打开的情况下,创建一个新项目,并确保它启用了 Kotlin 支持。然后,在新项目中进入新➤新模块,选择 Android 库。
注意
Android 库不仅仅是类的集合。它还可能包含资源和配置文件。出于我们的目的,我们将只看类。从开发的角度来看,这些额外的可能性没有坏处,你可以忽略它们。然而,对于使用 Android 库类型的项目,与只使用 JAR 文件相比,这为将来的扩展提供了更多的可能性。
创建库
在库模块中,创建一个新的 Kotlin 类,并在其中编写以下内容:
package com.example.regularexpressionlib
infix operator fun String.div(re:String) :
Array<MatchResult> =
Regex(re).findAll(this).toList().toTypedArray()
infix operator fun String.rem(re:String) :
MatchResult? =
Regex(re).findAll(this).toList().firstOrNull()
operator fun MatchResult.get(i:Int) =
this.groupValues[i]
fun String.onMatch(re:String, func: (String)-> Unit)
: Boolean =
this.matches(Regex(re)).also { if(it) func(this) }
这四个操作符和函数的作用不亚于允许我们编写searchString/regExpString
来搜索正则表达式匹配和searchString % regExpString
来搜索第一个匹配。此外,我们可以使用searchString.onMatch()
让一些块只有在匹配时才执行。
这个列表不同于我们在本书中看到的所有列表。首先,你可以看到我们这里没有任何类。这是可能的,因为 Kotlin 知道文件工件的概念。在幕后,它根据包名生成一个隐藏的类。通过import com.example.regularexpressionlib.*
导入的库的任何客户端都可以像在 Java 中执行所有这些函数的静态导入一样工作。
infix operator fun String.div( re:String )
定义字符串的除法运算符。这样的划分在标准中是不可能的,所以与 Kotlin 内置运算符没有冲突。它使用 Kotlin 库中的Regex
类来查找搜索字符串中正则表达式的所有匹配项,并将其转换为数组,因此我们可以稍后使用[ ]
操作符通过索引来访问结果。infix operator fun String.rem( re:String )
做了几乎相同的事情,但是它为字符串定义了%
操作符,执行正则表达式搜索,并且只获取第一个结果,如果没有结果,则返回null
。
operator fun MatchResult.get(i:Int) = ...
是前面运算符返回的MatchResult
的扩展。它允许通过索引访问匹配的组。比方说,如果你搜索 (el)【内侧" Hello Nelo" ,你可以写("Hello Nelo" / "(e.)") [0][1]
来获得第一组的第一场比赛,在这种情况下 el 来自 Hello
测试库
我们需要一种在开发库的同时测试它的方法。不幸的是,Android Studio 3.0 不允许类似于main()
的功能。我们唯一能做的就是创建一个单元测试,对于我们的例子,这样的单元测试可以如下所示:
import org.junit.Assert.*
import org.junit.Test
...
class RegularExpressionTest {
@Test
fun proof_of_concept() {
assertEquals(1, ("Hello Nelo" / ".*el.*").size)
assertEquals(2, ("Hello Nelo" / ".*?el.*?").size)
var s1:String = ""
("Hello Nelo" / "e[lX]").forEach {
s1 += it.groupValues
}
assertEquals("[el][el]", s1)
var s2: String = ""
("Hello Nelo" / ".*el.*").firstOrNull()?.run {
s2 += this[0]
}
assertEquals("Hello Nelo", s2)
assertEquals("el",
("Hello Nelo" % ".*(el).*")?.let{ it[1] } )
assertEquals("el",
("Hello Nelo" / ".*(el).*")[0][1])
var match1: Boolean = false
"Hello".onMatch(".*el.*") {
match1 = true
}
assertTrue(match1)
}
}
然后,您可以使用 Android Studio 的上下文菜单像运行任何其他单元测试一样运行这个测试。注意,在开发的早期阶段,您可以向测试添加println()
语句,以便在测试运行时在测试控制台上打印一些信息。
使用图书馆
一旦你调用构建➤重建项目,你可以在模块的这个文件夹中找到 Android 库。
build/outputs/aar
要从客户端使用它,请通过“新建➤新模块”在客户端项目中创建新模块,然后选择“导入”。JAR/。AAR 包,并导航到库项目生成的.aar
文件。
警告
该程序复制.aar
文件。如果您有一个新版本的库,您可以删除客户端项目中的库项目并再次导入它,或者手动将.aar
文件从库项目复制到客户端项目。
要在客户机中使用这个库,您只需将import com.example.regularexpressionlib.*
添加到头部,此后您就可以应用新的匹配结构,如前面的测试所示。
发布图书馆
到目前为止,我们一直在本地使用这个库,这意味着您在开发机器上的某个地方有这个库项目,并且可以从同一台机器上的其他项目中使用它。您还可以发布库,这意味着如果您手头有一个企业存储库,就可以让企业环境中的其他开发人员使用它们,或者让它们对您想要提供给社区的库真正公开。
不幸的是,发布库的过程相当复杂,包括在几个地方修改构建文件,以及使用第三方插件和存储库网站。这使得发布库的过程变得复杂而脆弱,当您阅读本书时,对一个可能的发布过程的详细描述可能很容易过时。因此,我要求你自己做研究。在你最喜欢的搜索引擎中输入publishing Android libraries,你会很容易地找到对你有帮助的在线资源。如果您发现几个过程可能适合您的需要,一般的经验法则是使用一个有大的支持社区并且尽可能简单的过程。
此外,对于公司项目,如果您想使用公共存储库,请确保您有权限使用它们。如果您不能使用公共存储库,安装一个公司存储库并不是一个过于复杂的任务。要建立公司的 Maven 资源库,您可以使用软件套件 Artifactory。
使用 Kotlin 的高级监听器
无论你在为 Android 开发什么样的应用,在某个地方或者更常见的地方,你都必须为 API 函数调用提供监听器。在 Java 中,您必须创建实现监听器接口的类或匿名内部类,而在 Kotlin 中,您可以更优雅地做到这一点。
如果你有一个单一的抽象方法(SAM)类或接口,这很容易。例如,如果你想给一个按钮添加一个点击监听器,这意味着你必须提供一个接口View.OnClickListener
的实现,用 Java 的方式来做就是这样的:
btn.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) {
// do s.th.
}
})
然而,由于这个接口只有一个方法,您可以更简洁地编写它,就像这样:
btn.setOnClickListener {
// do s.th.
}
你可以让编译器找出接口方法应该如何实现。
如果侦听器不是 SAM,这意味着如果它有不止一个方法,这种简短的符号就不再可能。例如,如果您有一个EditText
视图,并且想要添加一个文本更改监听器,那么即使您只对onTextChanged()
回调方法感兴趣,您也必须编写下面的代码。
val et = ... // the EditText view
et.addTextChangedListener( object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
// ...
}
override fun beforeTextChanged(s: CharSequence?,
start: Int, count: Int, after: Int) {
// ...
}
override fun onTextChanged(s: CharSequence?,
start: Int, before: Int, count: Int) {
// ...
}
})
然而,您可以做的是在一个实用程序文件中扩展EditText
类,并增加提供一个简化的文本更改监听器的可能性。为此,从这样一个文件开始,例如,com.example
包内的utility.kt
,当然也可以是你的应用的任何包。添加以下内容:
fun EditText.addTextChangedListener(l:
(CharSequence?, Int, Int, Int) -> Unit) {
this.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?,
start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?,
start: Int, before: Int, count: Int) {
l(s, start, before, count)
}
})
}
这将所需的方法动态添加到该类中。
现在,您可以在任何需要的地方使用import com.example.utility.*
,然后编写下面的代码,与最初的构造相比,它看起来要简洁得多:
val et = ... // the EditText view
et.addTextChangedListener({ s: CharSequence?,
start: Int, before: Int, count: Int ->
// do s.th.
})
多线程操作
我们已经在第九章中讨论了多线程。在这一节中,我们只是指出 Kotlin 在语言层面上可以简化多线程。
Kotlin 在其标准库中包含了几个实用函数。与使用 Java API 相比,它们有助于更容易地启动线程和计时器;见表 10-1 。
表 10-1
科特林并发
|名字
|
因素
|
返回
|
描述
|
| --- | --- | --- | --- |
| fixedRate-Timer
| name: String?``daemon: Boolean``initialDelay: Long``period: Long``action: TimerTask.() -> Unit
| Timer
| 为固定速率调度创建并启动计时器对象。period
和initialDelay
参数以毫秒为单位。 |
| fixedRate-Timer
| name: String?``daemon: Boolean``startAt: Date``period: Long``action: TimerTask.() -> Unit
| Timer
| 为固定速率调度创建并启动计时器对象。period 参数以毫秒为单位。 |
| timer
| name: String?``daemon: Boolean``initialDelay: Long``period: Long``action: TimerTask.() -> Unit
| Timer
| 为固定速率调度创建并启动计时器对象。period 参数是上一个任务结束和下一个任务开始之间的时间,以毫秒为单位。 |
| timer
| name: String?``daemon: Boolean``startAt: Date``period: Long``action: TimerTask.() -> Unit
| Timer
| 为固定速率调度创建并启动计时器对象。period 参数是上一个任务结束和下一个任务开始之间的时间,以毫秒为单位。 |
| thread
| start: Boolean``isDaemon: Boolean``contextClassLoader: ClassLoader?``name: String?``priority: Int``block: () -> Unit
| Thread
| 创建并可能启动一个线程,执行它的block
。优先级较高的线程优先于优先级较低的线程执行。 |
对于定时器函数,action
参数是一个闭包,其中this
是对应的TimerTask
对象。例如,使用它,你可以从它的执行块中取消定时器。将daemon
或isDaemon
设置为true
的线程或定时器不会阻止 JVM 在所有非守护线程退出后关闭。
凭借其通用的功能,Kotlin 在帮助我们处理并发性方面做得很好;在java.util.concurrent
中,许多处理并行执行的类都将Runnable
或Callable
作为参数,在 Kotlin 中,你总是可以通过直接的{ ... }
lambda 构造来替换这样的 SAM 构造。这里有一个例子:
val es = Executors.newFixedThreadPool(10)
// ...
val future = es.submit({
Thread.sleep(2000L)
println("executor over")
10
} as ()->Int)
val res:Int = future.get()
因此,您不必像在 Java 中那样编写以下代码:
ExecutorService es = Executors.newFixedThreadPool(10);
// ...
Callable<Integer> c = new Callable<>() {
public Integer call() {
try {
Thread.sleep(2000L);
} catch(InterruptedException e { }
System.out.println("executor over");
return 10;
};
Future<Integer> f = es.submit(c);
int res = f.get();
注意,在 Kotlin 代码中,()->Int
的造型是必要的,即使 Android Studio 抱怨这是多余的。这样做的原因是,如果我们不这样做,另一个带有参数Runnable
的方法会被调用,而执行器无法返回值。
兼容性库
框架 API 和兼容性库之间有一个重要的区别,但在开始时并不容易理解。如果你开始开发 Android 应用,你会经常看到相同名称的类出现在不同的包中。或者更糟的是,您会看到不同包中不同名称的类似乎在做同样的事情。
我们来看一个突出的例子。要创建活动,您可以子类化android.app.Activity
或者子类化android.support.v7.app.AppCompatActivity
。看看你在网上找到的例子和教程,在用法上似乎没有明显的区别。实际上,AppCompatActivity
继承了Activity
,所以哪里需要Activity
,就可以用AppCompatActivity
代替,它就会编译。那么,功能上有区别吗?看情况。如果你查看文档或代码,你会发现AppCompatActivity
允许添加android.support.v7.app.ActionBar
,而android.app.Activity
不允许。相反,android.app.Activity
允许添加android.app.ActionBar
。而且这次android.support.v7.app.ActionBar
没有继承android.app.ActionBar
,所以不能把android.support.v7.app.ActionBar
加到android.app.Activity
上。
这基本上是说,如果你偏爱android.support.v7.app.ActionBar
胜过android.app.ActionBar
,你必须为一个活动使用AppCompatActivity
。为什么要用android.support.v7.app.ActionBar
而不是android.app.ActionBar
?答案很简单:后者相当古老;从 API 级开始就有了。较新版本的android.app.ActionBar
不能破坏 API 以保持与旧设备的兼容性。但是android.support.v7.app.ActionBar
可以增加新的功能;它要新得多,从 API 级就存在了。
魔法现在是这样工作的:如果你使用一个说 API 等级 24 或更高的设备,你可以使用android.support.v7.app.AppCompatActivity
并添加android.support.v7.app.ActionBar
。你也可以使用android.app.Activity
,但是你不能添加android.support.v7.app.ActionBar
,而是必须使用android.app.ActionBar
。因此,对于新设备,如果支持库动作栏比框架动作栏更适合您的需求,那么使用android.support.v7.app.AppCompatActivity
进行活动是有意义的。
老设备呢?您仍然可以使用android.support.v7.app.AppCompatActivity
,因为它是作为添加到应用中的库提供的。因此,你也可以使用现代的android.support.v7.app.ActionBar
作为一个动作条,并且比老式的android.app.ActionBar
拥有更多的功能。这实际上是魔术的过程。通过使用支持库,即使旧设备也可以利用后来添加的新功能!support 类的实现在内部检查设备版本,并提供合理的回退功能,以尽可能地模仿现代设备。
需要注意的是,作为开发人员,您必须同时生活在两个世界中。如果没有其他选择,您必须显式或隐式地使用框架类,并且如果您希望确保与旧设备的最大兼容性,您必须考虑使用支持库类(如果可用的话)。因此,在使用一个类之前,检查是否也有匹配的支持库类是至关重要的。你可能不喜欢 Android 中使用的这种两个世界的方法,这也意味着构建应用需要更多的思考,但这就是 Android 处理向后兼容性的方式。
如果你在你最喜欢的搜索引擎中输入 android 支持库,你将很容易找到关于支持库的详细信息。
支持库与您的应用捆绑在一起,因此它们必须在构建文件中声明为依赖项。如果你在 Android Studio 中启动一个新项目,默认情况下,它会写入模块的build.gradle
文件。
dependencies {
...
implementation 'com.android.support:appcompat-v7
:26.1.0'
implementation 'com.android.support.constraint:
constraint-layout:1.0.2'
...
}
您可以看到默认情况下支持库版本 7 是可用的,因此您可以从一开始就使用它。
科特林最佳实践
开发不仅仅是解决与 IT 相关的问题或实现需求;你也想写出“好”的软件。然而,“好”在这个上下文中的确切含义有点模糊。很多方面在这里起作用:开发快,执行性能高,程序短,程序可读,程序稳定性高,等等。所有这些都有其优点,夸大其中任何一个都会阻碍其他方面。
事实上,你应该把它们都记在心里,但是我的经验告诉我要把重点放在以下几个方面:
-
让程序变得全面(或有表现力)。一个没有人理解的超级优雅的解决方案可能会让你高兴,但是请记住,以后可能其他人需要理解你的软件。
-
保持程序简单。过于复杂的解决方案容易出现不稳定性。当然,你不会某天早上醒来说,“好,今天我要写一个简单程序来解决 XYZ 需求。”编写能够可靠解决问题的简单程序是一个经验问题,需要多年的实践。但是你可以在编写简单的程序时不断尝试变得更好。一个好的起点是经常问自己,“难道不应该有一个更简单的解决方案吗?”对于软件的任何部分,在某些情况下,通过查看 API 文档和编程语言参考,您会发现更容易的解决方案与您当前拥有的解决方案一样。
-
不要重复自己。这个原则,通常被称为干编程,怎么强调都不为过。每当你发现自己在使用 Ctrl+C 和 Ctrl+V 来复制一些程序段落时,可以考虑使用一个函数或一个 lambda 表达式来提供一个完成事情的地方。
-
做预期的事情。你可以在 Kotlin 中覆盖类方法和操作符,你可以动态地添加函数到现有的类中,甚至是像
String
这样的基本类。在任何情况下,通过查看它们的名字来确保这样的扩展如预期的那样工作,因为如果它们不工作,程序就很难理解。
Kotlin 在所有这些方面都有所帮助,并且经常比古老的 Java 做得更好。在接下来的部分中,我们指出了几个 Kotlin 概念,你可以用它们来使你的程序变得简短、简单和有表现力。请注意,这些概念的总和远远不是 Kotlin 的完整文档。因此,要了解更多细节,请参阅在线文档。
函数式编程
虽然函数式编程作为一种开发范式在版本 8 中进入了 Java,但 Kotlin 从一开始就支持函数式编程风格。在函数式编程中,你更喜欢不变的值而不是变量,避免状态机,并允许函数作为函数的参数。另外,lambda 演算允许传递没有名字的函数。科特林为我们提供了这一切。
在 Java 中,你可以用final
修饰符来表示一个变量在第一次初始化后不会被改变。虽然大多数 Java 开发人员使用final
修饰符作为常量;我很少看到开发人员在编码中使用它们。
public class Constants {
public final static int CALCULATION_PRECISION = 10;
public final static int MAX_ITERATIONS = 1000;
...
}
这是一个遗憾,因为它提高了可读性和稳定性。为了节省几个按键而省略它的诱惑实在太大了。科特林的故事是不同的;您使用val
来表示数据对象在其生命周期内保持不变,如果您需要一个实变量,则使用var
,如下所示:
fun getMaxFactorial():Int = 13
fun fact(n:Int):Int {
val maxFactorial = getMaxFactorial()
if(n > maxFactorial)
throw RuntimeException("Too big")
var x = 1
for( i in 1.. (n) ) {
x *= i
}
return x
}
val x = fact(12)
System.out.println("12! = ${x}")
这个简短的代码片段使用maxFactorial
作为val
,意思是“这是不可更改的。”然而,x
是一个var
,它在初始化后被改变。
我们甚至可以在阶乘计算的代码片段中避免使用var x
,用一个函数构造来代替它。这是另一个函数命令:比起一个语句或一串语句,更喜欢表达式。为此,我们使用递归并编写以下代码:
fun fact(n:Int):Int = if(n>getMaxFactorial())
throw RuntimeException("Too big") else
if(n > 1) n * fact(n-1) else 1
val x = fact(10)
System.out.println("10! = ${x}")
这个小阶乘计算器只是一个简短的例子。有了收藏,故事变得更加有趣。Kotlin 标准库包括许多函数构造,您可以使用它们来编写优雅的代码。为了让您对所有的可能性有所了解,我们再次重写阶乘计算器,并使用 collections 包中的fold
函数。
fun fact(n:Int) = (1..n).fold(1, { acc,i -> acc * i })
System.out.println("10! = ${fact(10)}")
为了简单起见,我删除了范围检查;如果您愿意,可以将前面的if...
检查添加到{...}
中的 lambda 表达式。你看我们连一个val
都没有留下;不过在内部,i
和acc
被当作vals
来处理。这甚至可以再缩短一步。因为我们使用的只是类型Int
的“时间”功能,所以我们可以直接引用它并编写以下代码:
fun fact(n:Int) = (1..n).fold(1, Int::times)
System.out.println("10! = ${fact(10)}")
使用 collections 包中的其他函数构造,您可以对集合、列表和映射执行更有趣的转换。但是函数式编程也是将函数作为对象在代码中传递。在 Kotlin 中,您可以将功能分配给vals
或vars
,如下所示:
val factEngine: (acc:Int,i:Int) -> Int =
{ acc,i -> acc * i }
fun fact(n:Int) = (1..n).fold(1, factEngine)
System.out.println("10! = ${fact(10)}")
或者如下,这甚至更短,因为科特林在某些情况下可以推断类型:
val factEngine = { acc:Int, i:Int -> acc * i }
fun fact(n:Int) = (1..n).fold(1, factEngine)
System.out.println("10! = ${fact(10)}")
在本书中,我们尽可能使用函数构造来提高全面性和简明性。
顶级函数和数据
虽然在 Java 世界中,拥有太多全局可用的函数和数据被认为是不好的风格,例如在一些实用程序类中使用静态范围的定义,但在 Kotlin 中,这已经经历了一次复兴,看起来也更加自然。这是因为您可以在任何类之外的文件中声明函数和变量/值。不过,要使用它们,您必须导入类似于import com.example.global.*
中的元素,其中包com/example.global
中的任意名称的文件不包含类,而只包含fun
、var
和val
元素。
例如,在com/example/app/util
中编写一个名为common.kt
的文件,并在其中添加以下内容:
package com.example.app.util
val PI_SQUARED = Math.PI * Math.PI
fun logObj(o:Any?) =
o?.let { "(" + o::class.toString() + ") " +
o.toString() } ?: "<null>"
然后添加更多的实用函数和常量。要使用它们,请编写以下内容:
import com.example.app.util.*
...
val ps = PI_SQUARED
logObj(ps)
但是,您应该谨慎使用该功能;过度使用它很容易导致结构混乱。完全避免将可变变量放在这样的范围内!您可以也应该将实用函数和全局常量放在这样的全局文件中。
类别扩展
与 Java 语言不同,Kotlin 允许动态地向类中添加方法。为此,请编写以下内容:
fun TheClass.newFun(...){ ... }
操作符也是如此,它允许你创建像"Some Text" % "magic"
(这是你的想象)这样的扩展到像String
这样的普通类。您应该像这样实现这个特殊的扩展:
infix operator fun String.rem(s:String){ ... }
只要确保不要无意中覆盖现有的类方法和操作符。这使得你的程序不可读,因为它做了意想不到的事情。注意,像Double.times()
这样的大多数标准操作符无论如何都不能被覆盖,因为它们在内部被标记为 final。
表 10-2 描述了你可以通过operator fun TheClass.<OPER- ATOR>
定义的操作符。
表 10-2
科特林算子
|标志
|
转化为
|
中缀
|
默认功能
|
| --- | --- | --- | --- |
| +a
| a.unaryPlus()
| | 通常什么都不做。 |
| -a
| a.unaryMinus()
| | 对一个数字求反。 |
| !a
| a.not()
| | 对布尔表达式求反。 |
| a++
| a.inc()
| | 增加一个数字。 |
| a- -
| a.dec()
| | 减少一个数字。 |
| a + b
| a.plus(b)
| x | 加法。 |
| a - b
| a.minus(b)
| x | 减法 |
| a * b
| a.times(b)
| x | 乘法。 |
| a / b
| a.div(b)
| x | 组织。 |
| a % b
| a.rem(b)
| x | 除法后的余数。 |
| a . . b
| a.rangeTo(b)
| x | 定义一个范围。 |
| a in b
| b.contains(a)
| x | 密封检查。 |
| a !in b
| !b.contains(a)
| x | 非包容检查。 |
| a[i]
| a.get(i)
| | 索引访问。 |
| a[i,j,...]
| a.get(i,j,...)
| | 索引访问,通常不使用。 |
| a[i] = b
| a.set(i,b)
| | 索引设置访问。 |
| a[i,j,...] = b
| a.set(i,j,...,b)
| | 索引设置访问,通常不使用。 |
| a()
| a.invoke()
| | 祈祷。 |
| a(b)
| a.invoke(b)
| | 祈祷。 |
| a(b,c,...)
| a.invoke(b,c,...)
| | 祈祷。 |
| a += b
| a.plusAssign(b)
| x | 添加到a
。不得返回值;而是必须修改this
。 |
| a -= b
| a.minusAssign(b)
| x | 从a
中减去。不得返回值;而是必须修改this
。 |
| a *= b
| a.timesAssign()
| x | 乘以a
。不得返回值;而是必须修改this
。 |
| a /= b
| a.divAssign(b)
| x | 将a
除以b
然后赋值。不得返回值;相反,你必须修改this
。 |
| a %= b
| a.remAssign(b)
| x | 将除法的余数除以b
,然后赋值。不得返回值;而是必须修改this
。 |
| a == b
| a?.equals(b) ?: (b === null)
| x | 检查相等性。 |
| a != b
| !(a?.equals(b) ?: (b === null))
| x | 检查不平等。 |
| a > b
| a.compareTo(b) > 0
| x | 对比。 |
| a < b
| a.compareTo(b) < 0
| x | 对比。 |
| a >= b
| a.compareTo(b) >= 0
| x | 比较。 |
| a <= b
| a.compareTo(b) <= 0
| x | 对比。 |
要定义扩展,对于类型为Infix
的表中的任何操作符,您需要编写以下代码:
infix operator fun TheClass.<OPERATOR>( ... ){ ... }
这里,函数参数是第二个和任何后续操作数,函数体内的this
是指第一个操作数。对于非类型Infix
的操作符,只需省略infix
。
为自己的类定义操作符当然是个好主意。通过操作符修改标准 Java 或 Kotlin 库类也可以提高代码的可读性。
命名参数
通过使用如下命名参数:
fun person(fName:String = "", lName:String = "",
age:Int=0) {
val p = Person().apply { ... }
return p
}
你可以像这样打更有表现力的电话:
val p = person(age = 27, lName = "Smith")
使用参数名意味着您不必关心参数顺序,并且在许多情况下,您可以避免为各种参数组合重载构造函数。
范围函数
作用域函数允许你以一种不同于使用类和方法的方式来构建你的代码。例如,考虑以下代码:
val person = Person()
person.lastName = "Smith"
person.firstName = "John"
person.birthDay = "2011-01-23"
val company = Company("ACME")
虽然这是有效的代码,但重复的person.
令人讨厌。况且前四行是在构造一个人,下一行和一个人无关。如果这能直观的表达出来就好了,也可以避免重复。这是 Kotlin 中的一个构造,内容如下:
val person = Person().apply {
lastName = "Smith"
firstName = "John"
birthDay = "2011-01-23"
}
company = Company("ACME")
与原始代码相比,这看起来更有表现力。上面明明说构造一个人,用它做点什么,然后再做点别的。有五个这样的结构,尽管相似,但它们在含义和用法上不同:also
、apply
、let
、run
和with
。表 10-3 描述了它们。
表 10-3
范围函数
|句法
|
什么是this
|
这是什么
|
返回
|
使用
|
| --- | --- | --- | --- | --- |
| a.also {``... }
| this
外部语境 | a
| a
| 用于一些横切关注点,例如添加日志记录。 |
| a.apply {``... }
| a
| -
| a
| 用于后期构造对象成形。 |
| a.let {``... }
| this
外部语境 | a
| 最后一个表达式 | 用于转换。 |
| a.run {``... }
| a
| - | 最后一个表达式 | 用一个对象做一些计算,只有副作用。为了 c 更清晰,不要使用它返回的内容。 |
| with(a) {``... }
| a
| - | 最后一个表达式 | 对一个对象进行分组操作。为了更清楚,不要使用它返回的内容。 |
使用作用域函数极大地提高了代码的表达能力。我在这本书里经常用到它们。
可空性
Kotlin 在语言层面上解决了可空性问题,以避免烦人的NullPointerException
抛出。对于任何变量或常量,默认情况下不允许赋值null
值;您必须通过在末尾添加一个?
来显式声明可空性,如下所示:
var name:String? = null
然后,编译器知道示例中的name
可以是null
,并采取各种预防措施来避免NullPointerException
s。例如,您不能编写name.toUpperCase()
,但您必须使用name?.toUpperCase()
来代替,它仅在name
不是null
时进行大写,否则返回null
本身。
使用我们之前描述的作用域函数,有一种优雅的方法可以避免像if( x != null ) { ... }
这样的构造。您可以改为编写以下内容:
x?.run {
...
}
这样做是一样的,但是更有表现力;凭借?.
,只有当x
不是null
时,才会执行run{}
。
elvis
操作符?:
也非常有用,因为它处理只有当接收变量是null
时才需要计算表达式的情况,如下所示:
var x:String? = ...
...
var y:String = x ?: "default"
这和 Java 里的String y = (x != null) ? x : "default");
是一样的。
数据类别
数据类是负责承载结构化数据的类。实际上,对数据类中的数据做一些事情通常是不必要的,或者至少是不重要的。
在 Kotlin 中声明数据类很容易;你所要做的就是写下以下内容:
data class Person(
val fName:String,
val lName:String,
val age:Int)
或者,如果您想对某些参数使用默认值,请使用:
data class Person(
val fName:String="",
val lName:String,
val age:Int=0)
这个简单的声明已经定义了一个构造函数、一个合适的用于比较的equals()
方法、一个默认的toString()
实现,以及成为析构的一部分的能力。要创建一个对象,您只需编写以下代码:
val pers = Person("John","Smith",37)
或者写一个更有表现力的版本,如下所示:
val pers = Person(fName="John", lName="Smith", age=37)
在这种情况下,如果参数声明了默认值,也可以省略参数。
这一点以及您还可以在函数中声明类和函数的事实,使得定义特定的复杂函数返回类型变得很容易,如下所示:
fun someFun() {
...
data class Person(
val fName:String,
val lName:String,
val age:Int)
fun innerFun():Person = ...
...
val p1:Person = innerFun()
val fName1 = p1.fName
...
解构
析构声明允许你多重赋值或变量。假设您有一个数据类Person
,如前一节所定义。然后,您可以编写以下内容:
val p:Person = ...
val (fName,lName,age) = p
这给了你三个不同的值。数据类的顺序是由类的成员声明的顺序定义的。一般来说,任何具有component1()
、component2()
、...访问器可以参与析构,所以您也可以对自己的类使用析构。例如,这是默认情况下为映射条目指定的,因此您可以编写以下内容:
val m = mapOf( 1 to "John", 2 to "Greg", ... )
for( (k,v) in m) { ... }
这里,to
是一个中缀操作符,它创建了一个Pair
类,该类又定义了fun component1()
和fun component2()
。
作为析构声明的一个附加特性,可以对未使用的部分使用 _ 通配符,如下所示:
val p:Person = ...
val (fName,lName,_) = p
多行字符串文字
Java 中的多行字符串定义起来总是有点笨拙。
String s = "First line\n" +
"Second line";
在 Kotlin 中,可以如下定义多行字符串文字:
val s = """
First line
Second Line"""
您甚至可以通过添加.trimIndent()
来去掉前面的缩进空格,如下所示:
val s = """
First line
Second Line""".trimIndent()
这将删除每一行开头的前导换行符和公共空格。
内部函数和类
在 Kotlin 中,函数和类也可以在其他函数中声明,这进一步有助于构建代码。
fun someFun() {
...
class InnerClass { ... }
fun innerFun() = ...
...
}
这种内部构造的范围当然仅限于声明它们的函数。
字符串插值
在 Kotlin 中,您可以将值传递给字符串,如下所示:
val i = 7
val s = "And the value of 'i' is ${i}"
这是从 Groovy 语言借用的,您可以将它用于所有类型,因为所有类型都有一个toString()
成员。唯一的要求是${}
的内容计算为一个表达式,因此您甚至可以编写以下代码:
val i1 = 7
val i2 = 8
val s = "The sum is: ${i1+i2}"
或者使用方法调用和 lambda 函数编写更复杂的结构:
val s = "8 + 1 is: ${ { i: Int -> i + 1 }(8) }"
限定“这个”
如果this
不是您想要的,而是您想要从外部上下文中引用this
,那么在 Kotlin 中,您可以使用如下的@
限定符:
class A {
val b = 7
init {
val p = arrayOf(8,9).apply {
this[0] += this@A.b
}
...
}
}
授权
Kotlin 允许轻松地遵循委托模式。这里有一个例子:
interface Printer {
fun print()
}
class PrinterImpl(val x: Int) : Printer {
override fun print() { print(x) }
}
class Derived(b: Printer) : Printer by b
在这里,类Derived
是类型Printer
的,并将其所有方法调用委托给b
对象。所以,你可以这样写:
val pi = PrinterImpl(7)
Derived(pi).print()
您可以随意覆盖方法调用,因此您可以调整委托以使用新的功能。
class Derived(val b: Printer) : Printer by b {
override fun print() {
print("Printing:")
b.print()
}
}
重命名的导入
在某些情况下,导入的类可能会使用长名称,但是您经常使用它们,所以您希望它们有较短的名称。例如,假设您经常在代码中使用SimpleDateFormat
类,但不想一直写完整的类名。为了帮助我们简化这一点,您可以引入导入别名并编写以下代码:
import java.text.SimpleDateFormat as SDF
此后,您可以使用SDF
代替SimpleDateFormat
,如下所示:
val dateStr = SDF("yyyy-MM-dd").format(Date())
但是,不要过度使用这个特性,因为否则你的开发伙伴需要记住太多的新名字,这会使你的代码难以阅读。
JavaScript 上的 Kotlin
如果你把 Android 和 Kotlin 放在一起听,很明显你会认为 Kotlin 是 Java 的替代品,解决了 Android 运行时和 Android APIs 的问题。但还有另一种可能性,虽然不那么明显,但却开启了有趣的可能性。如果只看 Kotlin,您会发现它可以创建在 Java 虚拟机上运行的字节码,或者在 Android 的情况下在有点像 Java 的 Dalvik 虚拟机上运行。或者它可以生成在浏览器中使用的 JavaScript。问题是,我们能在 Android 中也使用它吗?答案是肯定的,在接下来的部分中,我将向您展示如何做到这一点。
创建 JavaScript 模块
我们从包含 Kotlin 文件的 JavaScript 模块开始,这些文件被编译成 JavaScript 文件。当您启动一个新模块时,没有什么像 JavaScript 模块向导一样可用,但我们可以轻松地从一个标准的智能手机应用模块开始,并转换它以满足我们的需求。
在 Android Studio 项目中,选择新➤新模块,然后选择手机和平板电脑模块。给它一个像样的名字,暂时说kotlinjsSample
。生成模块后,删除以下文件夹和文件,因为我们不需要它们:
src/test
src/androidTest
src/main/java
src/main/res
src/main/AndroidManifest.xml
注意
如果你想从 Android Studio 中移除,你必须首先将视图类型从 Android 切换到 Project。
相反,添加两个文件夹。
src/main/kotlinjs
src/main/web
现在将模块的build.gradle
文件的内容替换如下:
buildscript {
ext.kotlin_version = '1.2.31'
repositories {
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:" +
"kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'kotlin2js'
sourceSets {
main.kotlin.srcDirs += 'src/main/kotlinjs'
}
task prepareForExport(type: Jar) {
baseName = project.name + '-all'
from {
configurations.compile.collect {
it.isDirectory() ? it : zipTree(it) } +
'src/main/web'
}
with jar
}
repositories {
mavenCentral()
}
dependencies {
implementation "org.jetbrains.kotlin:" +
"kotlin-stdlib-js:$kotlin_version"
}
这个构建文件启用了 Kotlin ➤ JavaScript 编译器,并引入了一个新的导出任务。
你现在可以打开 Android Studio 窗口右侧的 Gradle 视图,在那里的others
下,你会找到任务prepareForExport
。要运行它,请双击它。之后,在build/libs
里面你会发现一个新的文件kotlinjsSample-all.jar
。这个文件代表 JavaScript 模块,供其他应用或模块使用。
在src/main/kotlinjs
中创建文件Main.kt
,并向其中添加内容,如下所示:
import kotlin.browser.document
fun main(args: Array<String>) {
val message = "Hello JavaScript!"
document.getElementById("cont")!!.innerHTML = message
}
最后,我们将针对一个网站,所以我们需要第一个 HTML 页面。将它作为标准的登陆页面index.html
,在src/main/web
中创建它,并输入以下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kotlin-JavaScript</title>
</head>
<body>
<span id="cont"></span>
<script type="text/javascript"
src="kotlin.js"></script>
<script type="text/javascript"
src="kotlinjsSample.js"></script>
</body>
</html>
再次执行任务prepareForExport
,让模块输出工件反映我们刚才所做的更改。
使用 JavaScript 模块
要使用我们在上一节中构建的 JavaScript 模块,请在应用的build.gradle
文件中添加几行代码,如下所示:
task syncKotlinJs(type: Copy) {
from zipTree('../kotlinjsSample/build/libs/' +
'kotlinjsSample-all.jar')
into 'src/main/assets/kotlinjs'
}
preBuild.dependsOn(syncKotlinJs)
这将导入 JavaScript 模块的输出文件,并将其提取到应用的assets
文件夹中。借助于dependsOn()
声明,这个额外的构建任务会在正常构建过程中自动执行。
现在,在布局文件中放置一个WebView
元素,可能如下所示:
<WebView
android:id="@+id/wv"
android:layout_width="match_parent"
android:layout_height="match_parent">
</WebView>
要用主活动的onCreate()
回调中的网页填充视图,编写以下代码:
wv.webChromeClient = WebChromeClient()
wv.settings.javaScriptEnabled = true
wv.loadUrl("file:///android_asset/kotlinjs/index.html")
这将启用对WebView
小部件的 JavaScript 支持,并从 JavaScript 模块加载主 HTML 页面。
作为扩展,您可能希望将网页中的 JavaScript 连接到应用中的 Kotlin 代码(而不是 JavaScript 模块)。这并不过分复杂;您只需添加以下内容:
class JsObject {
@JavascriptInterface
override fun toString(): String {
return "Hi from injectedObject"
}
}
wv.addJavascriptInterface(JsObject(), "injectedObject")
此后,您可以使用 JavaScript 模块中的injectedObject
,如下所示:
val message = "Hello JavaScript! injected=" +
window["injectedObject"]
使用这些技术,你可以使用 HTML、CSS、Kotlin 转换成 JavaScript,以及一些访问器对象来处理 Android APIs,从而设计出完整的应用。
十一、构建
在这一章中,我们将讨论应用的构建过程。尽管使用终端和 Android Studio IDE 的图形界面都可以构建带有源文件的应用,但这不是 Android Studio 的介绍,也不是代码参考。对于这种类型的深入指导,请参考包括的帮助或其他书籍和在线资源。
我们在这一章要做的是看看与构建相关的概念和方法,让构建过程适应你的需要。
与构建相关的文件
在 Android Studio 中创建新项目后,您将看到以下与构建相关的文件:
-
build.gradle
这是与项目相关的顶级构建文件。它包含项目包含的所有模块共有的存储库和依赖项的声明。对于简单的应用,通常不需要编辑这个文件。
-
gradle.properties
这包含与 Gradle 构建相关的技术设置。通常不需要编辑这个文件。
-
gradlew 和 gradlew.bat
这些是包装器脚本,因此您可以使用终端而不是 Android Studio IDE 来运行构建。
-
local.properties
它保存了与您的 Android Studio 安装相关的生成的技术属性。您不应编辑此文件。
-
设置.等级
这将告诉您哪些模块是项目的一部分。如果你添加了新的模块,Android Studio 会处理这个文件。
-
app/build.gradle
这是一个与模块相关的构建文件。这是配置模块的重要依赖项和构建过程的地方。Android Studio 将为您创建第一个名为
app
的模块,包括相应的构建文件,但将app
作为名称只是一种约定。额外的模块会有你随意选择的不同名字,它们都有自己的构建文件。如果你愿意,甚至可以将app
改名为一个更适合你需要的名字。
模块配置
项目的每个模块都包含自己的构建文件build.gradle
。如果您让 Android Studio 为您创建一个新项目或模块,它也会为您创建一个初始构建文件。具有 Kotlin 支持的模块的基本构建文件如下所示(忽略¬
和后面的换行符):
apply plugin: "com.android.application"
apply plugin: "kotlin-android"
apply plugin: "kotlin-android-extensions"
android {
compileSdkVersion 26
defaultConfig {
applicationId "de.pspaeth.xyz"
minSdkVersion 16
targetSdkVersion 26
versionCode 1
versionName "1.0"
testInstrumentationRunner ¬
"android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles ¬
getDefaultProguardFile( ¬
"proguard-android.txt"), ¬
"proguard-rules.pro"
}
}
}
dependencies {
implementation fileTree(dir: 'libs', ¬
include: ['*.jar'])
implementation ¬
"org.jetbrains.kotlin:kotlin-stdlib-jre7: ¬
$kotlin_version"
implementation ¬
"com.android.support:appcompat-v7:26.1.0"
implementation ¬
"com.android.support.constraint: ¬
constraint-layout:1.0.2"
testImplementation "junit:junit:4.12"
androidTestImplementation ¬
"com.android.support.test:runner:1.0.1"
androidTestImplementation ¬
"com.android.support.test.espresso: ¬
espresso-core:3.0.1"
}
注意 Gradle 中的""
字符串可以包含${}
占位符,而’ ’
字符串则不能。除此之外,它们是可以互换的。
其内容如下:
-
apply plugin:
行加载并应用 Android 和 Kotlin 开发所需的 Gradle 插件。 -
元素指定了 Android 插件的设置。
-
元素描述了模块的依赖关系。
implementation
关键字意味着编译模块和运行模块都需要依赖关系。后者意味着依赖关系包含在 APK 文件中。像xyzImplementation
这样的标识符指的是构建类型或者源集xyz
。您可以看到,对于位于src/test
的单元测试,添加了 jUnit 库,而对于src/androidTest
,测试运行器和espresso
都被使用。如果您更喜欢构建类型或产品风格,您可以用构建类型名称或产品风格名称替换xyz
。如果您想要引用一个 variant,它是一个构建类型和产品风格的组合,您还必须在一个configurations { }
元素中声明它。这里有一个例子:configurations { // flavor = "free", type = "debug" freeDebugCompile {} }
-
关于
defaultConfig { }
和buildTypes { }
,请参见以下章节。
dependencies {...}
部分中的其他关键字包括:
-
实施
我们讨论过这个。它表明编译和运行应用都需要依赖关系。
-
API
这与
implementation
相同,但除此之外,它还允许依赖关系泄漏到应用的客户端。 -
编译
这是
api
的旧别名。不要用。 -
完全地
编译需要依赖项,但不会包含在应用中。对于像源代码预处理程序之类的纯源代码库来说,这种情况经常发生。
-
运行时仅
编译时不需要依赖项,但会包含在应用中。
模块通用配置
模块的build.gradle
文件中的defaultConfig { ... }
元素指定了构建的配置设置,与所选择的变量无关(见下一节)。可以在 Android Gradle DSL 参考中查找可能的设置,但常见的设置如下所示:
defaultConfig {
// Uniquely identifies the package for publishing.
applicationId 'com.example.myapp'
// The minimum API level required to run the app.
minSdkVersion 24
// The API level used to test the app.
targetSdkVersion 26
// The version number of your app.
versionCode 42
// A user-friendly version name for your app.
versionName "2.5"
}
模块构建变体
构建变体对应于构建过程生成的不同的.apk
文件。构建变体的数量由以下公式给出:
Number of Build Variants =
(Number of Build Types) x (Number of Product Flavors)
在 Android Studio 中,你可以通过构建➤在菜单中选择构建变体来选择构建变体。在接下来的部分中,我们将描述什么是构建类型和产品风格。
构建类型
构建类型对应于应用开发的不同阶段。如果你开始一个项目,Android Studio 会为你设置两种构建类型:开发和发布。如果你打开模块的build.gradle
文件,你可以看到里面的android { ... }
(忽略¬
,包括下面的换行符)。
buildTypes {
release {
minifyEnabled false
proguardFiles ¬
getDefaultProguardFile('proguard-android.txt'), ¬
'proguard-rules.pro'
}
}
尽管你在这里看不到一个debug
类型,但它确实存在。它没有出现的事实仅仅意味着debug
类型使用它的默认设置。如果您需要更改默认值,只需添加一个debug
部分,如下所示:
buildTypes {
release {
...
}
debug {
...
}
}
您并不局限于使用预定义的构建类型之一。您可以定义其他构建类型,例如:
buildTypes {
release {
...
}
debug {
...
}
integration {
initWith debug
manifestPlaceholders = ¬
[hostName:"internal.mycompany.com"]
applicationIdSuffix ".integration"
}
}
这定义了一个名为integration
的新构建类型,它通过initWith
从debug
继承而来,另外还添加了一个定制的 app 文件后缀,并提供了一个占位符用于清单文件。您可以在那里指定的设置相当多。如果你在你最喜欢的搜索引擎中输入 android gradle 插件 dsl 参考,你就可以找到它们。
另一个我们还没有谈到的标识符是proguardFiles
标识符。一个用于过滤和/或模糊文件,这些文件将在分发应用之前包含在应用中。如果你用它来过滤,请首先权衡利弊。有了现代设备,现在省几兆也起不了多大作用。如果你想用它来混淆,注意如果反射被你的代码或者被引用的库使用,这可能会带来麻烦。而且模糊处理并不能真正防止劫机者在反编译后使用你的代码;只会让事情变得更难。所以,仔细考虑一下使用proguard
的好处。如果您认为它符合您的需要,您可以在在线文档中找到有关如何使用它的详细信息。
产品风味
产品风格允许区分不同的功能集或不同的设备需求,但您可以在最适合自己的地方进行区分。
默认情况下,Android Studio 不会为一个新项目或模块准备不同的产品风格。如果您需要它们,您必须在文件build.gradle
的android { ... }
元素中添加一个productFlavors { ... }
部分。这里有一个例子:
buildTypes {...}
flavorDimensions "monetary"
productFlavors {
free {
dimension "monetary"
applicationIdSuffix ".free"
versionNameSuffix "-free"
}
paid {
dimension "monetary"
applicationIdSuffix ".paid"
versionNameSuffix "-paid"
}
}
在这里,你可以看看 Android Gradle DSL 参考中可能的设置。这将导致以下形式的 apk:
app-free-debug.apk
app-paid-debug.apk
app-free-release.apk
app-paid-release.apk
你甚至可以扩展维度。如果在flavorDimensions
行中加入更多元素,比如flavorDimensions ”monetary”, ”apilevel”
,就可以加入更多的味道。
flavorDimensions "monetary", "apilevel"
productFlavors {
free {
dimension "monetary" ... }
paid {
dimension "monetary" ... }
sinceapi21 {
dimension "apilevel"
versionNameSuffix "-api21" ... }
sinceapi24 {
dimension "apilevel"
versionNameSuffix "-api24" ... }
}
这最终将为您提供以下一组 APK 文件:
app-free-api21-debug.apk
app-paid-api21-debug.apk
app-free-api21-release.apk
app-paid-api21-release.apk
app-free-api24-debug.apk
app-paid-api24-debug.apk
app-free-api24-release.apk
app-paid-api24-release.apk
为了过滤掉某些可能的变体,在构建文件中添加一个variantFilter
元素,并编写以下代码:
variantFilter { variant ->
def names = variant.flavors*.name // this is an array
// To filter out variants, make a check here and then
// do a "setIgnore(true)" if you don't need a variant.
// This is just an example:
if (names.contains("sinceapi24") &&
names.contains("free")) {
setIgnore(true)
}
}
源集
如果在 Android Studio 中创建一个项目,切换到项目视图,可以看到在src
文件夹里面有一个main
文件夹。这对应于main
源集,它是默认配置和使用的单个默认源集。见图 11-1 。
图 11-1
主源集
您可以有更多的集合,它们对应于构建类型、产品风格和构建变体。一旦您添加了更多的源集,一个构建将导致合并当前的构建变体、它所包含的构建类型、它所包含的产品风格,最后是主源集。要查看构建中将包含哪些源集,请打开窗口右侧的 Gradle 视图,并运行sourceSets
任务。这将产生一个很长的列表,您可以看到如下条目:
main
Java sources: [app/src/main/java]
debug
Java sources: [app/src/debug/java]
free
Java sources: [app/src/free/java]
freeSinceapi21
Java sources: [app/src/freeSinceapi21/java]
freeSinceapi21Debug
Java sources: [app/src/freeSinceapi21Debug/java]
freeSinceapi21Release
Java sources: [app/src/freeSinceapi21Release/java]
paid
Java sources: [app/src/paid/java]
paidSinceapi21
Java sources: [app/src/paidSinceapi21/java]
release
Java sources: [app/src/release/java]
sinceapi21
Java sources: [app/src/sinceapi21/java]
这将告诉您,如果您选择一个名为freeSinceapi21Debug
的构建变体,构建过程将在这些文件夹中查找类:
app/src/freeSinceapi21Debug/java
app/src/freeSinceapi21/java
app/src/free/java
app/src/sinceapi21/java
app/src/debug/java
app/src/main/java
同样,它会在相应的文件夹中查找资源、素材和AndroidManifest.xml
文件。虽然 Java 或 Kotlin 类不能在这样的构建链中重复,但是清单文件以及资源和素材文件将被构建过程合并。
在文件build.gradle
的dependencies { ... }
部分中,您可以根据构建变体分派依赖项。只需在任何设置前添加一个骆驼大小写版本的源集。例如,如果对于freeSinceapi21
变体,您想要包含一个:mylib
的编译依赖项,请编写以下代码:
freeSinceapi21Compile ':mylib'
从控制台运行构建
你不必使用 Android Studio 来构建应用。虽然使用 Android Studio 引导应用项目是一个好主意,但在此之后,您可以使用终端构建应用。这就是 Gradle 包装脚本gradlew
和gradlew.bat
的用途。第一个是 Linux 的,第二个是 Windows 的。在下面的段落中,我们将看一下 Linux 的一些命令行命令;如果你有 Windows 开发机器,就用BAT
脚本代替。
在前面的章节中,我们已经看到了每个构建的基本构建块由一个或多个在构建期间执行的任务组成。所以,我们首先想知道哪些任务实际存在。为此,要列出所有可用的任务,请输入以下内容:
./gradlew tasks
这将为您提供一个详细的列表和每个任务的一些描述。在接下来的几节中,我们将看看其中的一些任务。
要为构建类型debug
或release
构建应用 APK 文件,请输入以下内容之一:
./gradlew assembleDebug
./gradlew assembleRelease
这将在<PROJECT>/<MODULE>/build/outputs
中创建一个 APK 文件。当然,您也可以指定您在build.gradle
中定义的任何定制构建类型。
要构建调试类型 APK,然后将其安装在连接的设备或仿真器上,请输入以下内容:
./gradlew installDebug
这里,对于参数中的Debug
部分,您可以使用变量的大小写名称替换任何构建变量。这将在连接的设备上安装应用。但是,它不会自动运行它;你必须手动操作!要安装和运行 app,请参见第十八章。
如果您想找出您的应用的任何模块有哪些依赖关系,请查看依赖关系树,并输入以下 or 并用app
替换有问题的模块名称:
./gradlew dependencies :app:dependencies
这提供了一个相当长的清单,所以您可能希望将它通过管道传输到一个文件中,然后在编辑器中研究结果。
./gradlew dependencies :app:dependencies > deps.txt
签署
每个应用的 APK 文件都需要签名,然后才能在设备上运行。对于debug
构建类型,会自动为你选择一个合适的签名配置,所以对于调试开发阶段,你不需要关心签名。
然而,一个发布的 APK 需要一个正确的签名配置。如果您使用 Android Studio 的构建➤生成签名的 APK 菜单项,Android Studio 将帮助您创建和/或使用适当的密钥。但是您也可以在模块的build.gradle
文件中指定签名配置。为此,添加一个signingConfigs { ... }
部分,如下所示:
android {
...
defaultConfig {...}
signingConfigs {
release {
storeFile file("myrelease.keystore")
storePassword "passwd"
keyAlias "MyReleaseKey"
keyPassword "passwd"
}
}
buildTypes {
release {
...
signingConfig signingConfigs.release
}
}
}
此外,从发布构建类型内部,引用列表中的signingConfig
所示的签名配置。您需要提供的密钥库是一个标准的 Java 密钥库;请参阅 Java 的在线文档,了解如何构建一个。或者,您可以让 Android Studio 帮助您创建一个密钥库,使用在菜单中选择构建➤生成签名的 APK 时弹出的对话框。
十二、通信
通信是指通过组件、应用或设备边界发送数据。一个或多个应用的组件相互通信的标准方式是使用广播,这在第五章中讨论过。
在一个设备上进行应用间通信的另一种可能性是使用ResultReceiver
对象,这些对象通过意图传递。不管它们的名字是什么,它们不仅可以在被调用的组件完成它的工作时,而且可以在它还活着的任何时候将数据发送回调用者。我们在这本书的几个地方使用了它们,但是在这一章中,我们将复习如何使用它们来实现所有的交流方式。
对于通过设备边界的通信,选项是众多的,如果我们可以使用基于云的通信平台,则更是如此。我们将讨论使用基于云的服务和通过互联网直接通信的应用间通信。
结果接收器类
通过将一个ResultReceiver
对象分配给一个 intent,它可以从任何一个组件传递到另一个组件,因此您可以使用它在任何类型的组件之间发送数据,只要它们位于相同的设备上。
我们首先子类化一个ResultReceiver
,它稍后将从一个被调用的组件接收消息,并编写以下代码:
class MyResultReceiver : ResultReceiver(null) {
companion object {
val INTENT_KEY = "my.result.receiver"
val DATA_KEY = "data.key"
}
override fun onReceiveResult(resultCode: Int,
resultData: Bundle?) {
super.onReceiveResult(resultCode, resultData)
val d = resultData?.get(DATA_KEY) as String
Log.e("LOG", "Received: " + d)
}
}
当然,你可以在它的onReceiveResult()
函数内部写更多有意义的东西。
为了将一个MyResultReceiver
的实例传递给一个被调用的组件,我们现在可以编写以下或任何其他方法来调用另一个组件:
Intent(this, CalledActivity::class.java).apply {
putExtra(MyResultReceiver.INTENT_KEY,
MyResultReceiver())
}.run{ startActivity(this) }
在被调用的组件内部,您现在可以在任何合适的位置通过类似下面的方式向调用组件发送数据:
var myReceiver:ResultReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_called)
...
myReceiver = intent.
getParcelableExtra<ResultReceiver>(
MyResultReceiver.INTENT_KEY)
}
fun go(v: View) {
val bndl = Bundle().apply {
putString(MyResultReceiver.DATA_KEY,
"Hello from called component")
}
myReceiver?.send(42, bndl) ?:
throw IllegalStateException("myReceiver is null")
}
在生产环境中,您还需要检查接收者是否还活着。为了简洁起见,我省略了这个检查。还要注意,在发送端,实际上不需要对ResultReceiver
实现的引用;如果您通过应用边界进行交流,您可以只写以下内容:
...
val INTENT_KEY = "my.result.receiver"
val DATA_KEY = "data.key"
...
val myReceiver = intent.
getParcelableExtra<ResultReceiver>(
INTENT_KEY)
...
val bndl = Bundle().apply {
putString(DATA_KEY,
"Hello from called component")
}
myReceiver?.send(42, bndl)
Firebase 云消息传递
Firebase Cloud Messaging (FCM)是一个基于云的消息代理,可以用来发送和接收来自各种设备的消息,包括其他操作系统,如 Apple iOS。想法如下:你在 Firebase 控制台中注册一个应用,从此可以在连接的设备上接收和发送消息,包括在其他设备上安装你的应用。
注意
Firebase 云消息是 Google 云消息(GCM)的继任者。文档说你应该更喜欢 FCM 而不是 GCM。在本书中,我们讨论 FCM 如果你需要关于 GCM 的信息,请参考在线资源。
要从 Android Studio 内部启动 FCM,从您打开的项目进入工具➤ Firebase 的各种向导。选择云消息,然后设置 Firebase 云消息。如果您遵循那里的说明,您将最终使用两个服务。
FirebaseInstanceIdService
的子类,您将在其中接收消息令牌。这个类基本上是这样的:
class MyFirebaseInstanceIdService :
FirebaseInstanceIdService() {
override
fun onTokenRefresh() {
// Get updated InstanceID token.
val refreshedToken =
FirebaseInstanceId.getInstance().token
Log.d(TAG, "Refreshed token: " +
refreshedToken!!)
}
}
它在AndroidManifest.xml
中有一个对应的条目。
<service
android:name=".MyFirebaseInstanceIdService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name=
"com.google.firebase.INSTANCE_ID_EVENT"/>
</intent-filter>
</service>
当您第一次启动连接到 Firebase 的应用时,您在此收到的令牌非常重要;您需要它来使用基于 Firebase 的通信通道。该令牌不经常自动更新,因此您需要找到一种方法,以便在收到该服务中的令牌时可靠地存储该令牌。帮自己一个忙:除非您实现了存储令牌的方法,否则一定要将收到的令牌保存在日志中,因为恢复丢失的令牌会导致烦人的管理工作。
另一个服务负责接收基于 FCM 的消息。其内容如下:
class MyFirebaseMessagingService :
FirebaseMessagingService() {
override
fun onMessageReceived(remoteMessage:
RemoteMessage) {
// ...
// Check if message contains a data payload.
if (remoteMessage.data.size > 0) {
Log.d(TAG, "Message data payload: " +
remoteMessage.data)
// Implement a logic:
// For long-running tasks (10 seconds or more)
// use Firebase Job Dispatcher.
scheduleJob()
// ...or else handle message within 10 seconds
// handleNow()
}
// Message contains a notification payload?
remoteMessage.notification?.run {
Log.d(TAG, "Message Notification Body: " +
body)
}
}
private fun handleNow() {
Log.e("LOG","handleNow()")
}
private fun scheduleJob() {
Log.e("LOG","scheduleJob()")
}
}
这在AndroidManifest.xml
中也有相应的条目。
<service
android:name=".MyFirebaseMessagingService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name=
"com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
要做到这一点,你需要在你的谷歌账户中激活 Firebase。有几种选择,对于高流量的信息服务,你需要购买一个计划。然而,免费版本(截至 2018 年 3 月)将为您提供足够的开发和测试能力。
如果一切设置正确,您可以使用基于 web 的 Firebase 控制台来测试向您正在运行的应用发送消息,并在日志中查看到达那里的消息。
注意
Firebase 不仅仅是消息传递;请参考您在 Firebase 控制台中找到的在线文档和信息,以及 Android Studio 给您的信息,以了解还可以做些什么。
对于发送消息,建议的解决方案是以应用服务器的形式建立一个可信环境。这超出了本书的范围,但是在线 Firebase 文档为您提供了各种提示。
与后端的通信
如前所述,使用像 Firebase 这样的基于云的供应器将你的应用连接到其他设备上的其他应用,当然有不同的优点。您有一个可靠的消息代理,它有消息备份工具、分析工具等等。
但是使用云也有它的缺点。你的数据,无论是否加密,都将离开你的房子,甚至对于企业应用来说也是如此,你不能 100%确定供应器不会在未来某个时候改变 API,迫使你改变你的应用。所以,如果你需要更多的控制,你可以放弃云,转而使用直接联网。
对于直接使用网络协议与设备或应用服务器通信,您基本上有两种选择。
-
使用 javax . net . SSL . http surlcconnection
这提供了一个低级的连接,但是包括 TLS、流功能、超时和连接池。从类名可以看出,它是标准 Java API 的一部分,所以你可以在网上找到很多关于它的信息。尽管如此,我们还是在下一节给出了描述。
-
使用 Android 附带的凌空 API
这是基本网络功能的高级包装。使用凌空大大简化了基于网络的开发,因此它通常是在 Android 中使用网络的首选。
在这两种情况下,您都需要在AndroidManifest.xml
中添加适当的权限。
<uses-permission android:name=
"android.permission.INTERNET" />
<uses-permission android:name=
"android.permission.ACCESS_NETWORK_STATE" />
与 HttpsURLConnection 的通信
在使用网络通信 API 之前,我们需要确保网络操作发生在后台;现代的 Android 版本甚至不允许你在 UI 线程中执行联网。但是即使没有这种限制,也强烈建议总是在后台任务中执行联网。我们在第九章中谈到了后台操作。您想看的第一种方法是在AsyncTask
中运行网络操作,但是您也可以自由选择其他方法。下面几节假设这里展示的代码片段在后台运行。
使用基于类HttpsURLConnection
的通信可以归结为以下几点:
fun convertStreamToString(istr: InputStream): String {
val s = Scanner(istr).useDelimiter("\\A")
return if (s.hasNext()) s.next() else ""
}
// This is a convention for emulated devices
// addressing the host (development PC)
val HOST_IP = "10.0.2.2"
val url = "https://${HOST_IP}:6699/test/person"
var stream: InputStream? = null
var connection: HttpsURLConnection? = null
var result: String? = null
try {
connection = (URL(uri.toString()).openConnection()
as HttpsURLConnection).apply {
// ! ONLY FOR TESTING ! No SSL hostname verification
class TrustAllHostNameVerifier : HostnameVerifier {
override
fun verify(hostname: String, session: SSLSession):
Boolean = true
}
hostnameVerifier = TrustAllHostNameVerifier()
// Timeout for reading InputStream set to 3000ms
readTimeout = 3000
// Timeout for connect() set to 3000ms.
connectTimeout = 3000
// For this use case, set HTTP method to GET.
requestMethod = "GET"
// Already true by default, just telling. Needs to
// be true since this request is carrying an input
// (response) body.
doInput = true
// Open communication link
connect()
responseCode.takeIf {
it != HttpsURLConnection.HTTP_OK }?.run {
throw IOException("HTTP error code: $this")
}
// Retrieve the response body
stream = inputStream?.also {
result = it.let { convertStreamToString(it) }
}
}
} finally {
stream?.close()
connection?.disconnect()
}
Log.e("LOG", result)
这个例子试图访问一个针对您的开发 PC 的 GET URLhttps://10.0.2.2:6699/test/person
,并在日志中打印结果。
注意,如果您的服务器碰巧持有 SSL 的自签名证书,您必须在初始化位置,比如在onCreate()
回调中,添加以下内容:
val trustAllCerts =
arrayOf<TrustManager>(object : X509TrustManager {
override
fun getAcceptedIssuers():
Array<java.security.cert.X509Certificate>? = null
override
fun checkClientTrusted(
certs: Array<java.security.cert.X509Certificate>,
authType: String) {
}
override
fun checkServerTrusted(
certs: Array<java.security.cert.X509Certificate>,
authType: String) {
}
})
SSLContext.getInstance("SSL").apply {
init(null, trustAllCerts, java.security.SecureRandom())
}.apply {
HttpsURLConnection.setDefaultSSLSocketFactory(
socketFactory)
}
否则,前面的代码将会报错并失败。当然,与生产代码中的自签名证书相比,您更喜欢官方签名证书。
与凌空联网
凌空是一个网络库,简化了 Android 的网络。第一,凌空自己把作品发到后台;你不用管这个。凌空提供的其他好处如下:
-
调度机制
-
并行处理多个请求
-
JSON 请求和响应的处理
-
贮藏
-
诊断工具
要开始使用凌空开发,将依赖项添加到模块的build.gradle
文件中,如下所示:
dependencies {
...
implementation 'com.android.volley:volley:1.1.0'
}
接下来要做的事情是设置一个RequestQueue
,凌空使用它在后台处理请求。最简单的方法是在活动中写下以下内容:
val queue = Volley.newRequestQueue(this)
但是您也可以定制一个RequestQueue
的创建,改为编写以下代码:
val CACHE_CAPACITY = 1024 * 1024 // 1MB
val cache = DiskBasedCache(cacheDir, CACHE_CAPACITY)
// ... or a different implementation
val network = BasicNetwork(HurlStack())
// ... or a different implementation
val requestQueue = RequestQueue(cache, network).apply {
start()
}
问题是,在哪个范围下定义请求队列最好?我们可以在活动的范围内创建并运行请求队列,这意味着每次活动重新创建自己时,都需要重新创建队列。这是一个有效的选项,但是文档建议使用应用范围来减少缓存的重新创建。推荐的方法是使用Singleton
模式,其结果如下:
class RequestQueueSingleton
constructor (context: Context) {
companion object {
@Volatile
private var INSTANCE: RequestQueueSingleton? = null
fun getInstance(context: Context) =
INSTANCE ?: synchronized(this) {
INSTANCE ?: RequestQueueSingleton(context)
}
}
val requestQueue: RequestQueue by lazy {
val alwaysTrusting = object : HurlStack() {
override
fun createConnection(url: URL): HttpURLConnection {
fun getHostnameVerifier():HostnameVerifier {
return object : HostnameVerifier {
override
fun verify(hostname:String,
session:SSLSession):Boolean = true
}
}
return (super.createConnection(url) as
HttpsURLConnection).apply {
hostnameVerifier = getHostnameVerifier()
}
}
}
// Using the Application context is important.
// This is for testing:
Volley.newRequestQueue(context.applicationContext,
alwaysTrusting)
// ... for production use:
// Volley.newRequestQueue(context.applicationContext)
}
}
出于开发和测试目的,添加了一个 accept-all SSL 主机名验证器。
因此,不像前面那样写val queue = Volley.newRequestQueue(this)
或val requestQueue = RequestQueue(...)
,而是使用下面的代码:
val queue = RequestQueueSingleton(this).requestQueue
现在,要发送一个字符串请求,您必须编写以下内容:
// This is a convention for emulated devices
// addressing the host (development PC)
val HOST_IP = "10.0.2.2"
val stringRequest =
StringRequest(Request.Method.GET,
"https://${HOST_IP}:6699/test/person",
Response.Listener<String> { response ->
val shortened =
response.substring(0,
Math.min(response.length, 500))
tv.text = "Response is: ${shortened}"
},
Response.ErrorListener { err ->
Log.e("LOG", err.toString())
tv.text = "That didn't work!"
})
queue.add(stringRequest)
这里,tv
指向一个TextView
UI 元素。为此,你需要一个响应https://localhost:6699/test/person
的服务器。请注意,响应侦听器自动运行在 UI 线程上,因此您不必亲自处理。
要取消单个请求,在请求对象的任何地方使用cancel()
。您也可以取消一组请求。像在val stringRequest = ... .apply {tag = "TheTag"}
中那样给每个有问题的请求添加一个标签,然后写下queue?.cancelAll( "TheTag" )
。一旦请求被取消,凌空确保响应监听器永远不会被调用。
要请求 JSON 对象或 JSON 数组,您必须替换以下内容:
val request =
JsonArrayRequest(Request.Method.GET, ...)
或者下面是我们之前使用的StringRequest
:
val request =
JsonObjectRequest(Request.Method.GET, ...)
例如,对于 JSON 请求和 POST 方法,您可以编写以下代码:
val reqObj:JSONObject =
JSONObject("""{"a":7, "b":"Hello"}""")
val json1 = JsonObjectRequest(Request.Method.POST,
"https://${HOST_IP}:6699/test/json",
reqObj,
Response.Listener<JSONObject> { response ->
Log.e("LOG", "Response: ${response}")
},
Response.ErrorListener{ err ->
Log.e("LOG", "Error: ${err}")
})
凌空可以为你做更多;您可以使用其他 HTTP 方法,如PUT
,也可以编写定制的请求处理并返回其他数据类型。有关更多详细信息,请参阅凌空的在线文档或其 API 文档。
设置测试服务器
这不是一个真正的 Android 主题,甚至也不是任何与 Kotlin 有关的东西,但是为了测试通信,您需要运行某种 web 服务器。为了简单起见,我通常基于 Groovy 和 Spark(不是 Apache Spark,而是来自 http://sparkjava.com
/
的 Java Spark)配置一个简单而强大的服务器。
要在 Eclipse 中使用它,首先要安装 Groovy 插件。然后创建一个 Maven 项目并添加依赖项,如下所示:
<dependency>
<groupId>com.sparkjava</groupId>
<artifactId>spark-core</artifactId>
<version>2.7.2</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.25</version>
<scope>test</scope>
</dependency>
之后,创建一个 Java keystore 文件,编写一个 Groovy 脚本,并启动它。
import static spark.Spark.*
def keystoreFilePath = "keystore.jks"
def keystorePassword = "passw7%d"
def truststoreFilePath = null
def truststorePassword = null
secure(keystoreFilePath, keystorePassword,
truststoreFilePath, truststorePassword)
port(6699)
get("/test/person", { req, res -> "Hello World" })
post("/test/json", { req, res ->
println(req.body())
'{ "msg":"Hello World", "val":7 }'
})
警告
为了避免 Servlet API 版本冲突,请在 Groovy 设置对话框中删除对 Servlet API 的依赖,该对话框是通过右键单击项目中的 Groovy 库并选择 Properties 打开的。
要在 Linux 下创建一个 keystore 文件,可以使用如下 Bash 脚本,并修改 Java 路径:
#!/bin/bash
export JAVA_HOME=/opt/jdk
$JAVA_HOME/bin/keytool -genkey -keyalg RSA \
-alias selfsigned -keystore keystore.jks \
-storepass passw7%d -validity 360 -keysize 2048
安卓和 NFC
NFC 用于在支持 NFC 的设备之间传输小数据包的短程无线连接。通信伙伴之间的范围被限制在几厘米。这些是典型的使用案例:
-
连接,然后读取或写入 NFC 标签
-
连接并与其他支持 NFC 的设备通信(点对点模式)
-
通过连接并与 NFC 读卡器和写卡器通信来模拟 NFC 卡
要开始开发一款支持 NFC 的应用,你需要在AndroidManifest.xml
内部获得许可。
<uses-permission android:name="android.permission.NFC" />
要限制 Google Play 商店中的可见性,请将以下内容添加到同一个文件中:
<uses-feature android:name="android.hardware.nfc"
android:required="true" />
与 NFC 标签对话
一旦启用 NFC 的设备在附近发现 NFC 标签,它会尝试根据某种算法来调度标签。如果系统确定了一个 NDEF 数据并找到了一个能够处理 NDEF 的意图过滤器,那么相应的组件就会被调用。如果标签没有展示 NDEF 数据,但是通过提供关于技术和/或有效载荷的信息来识别自身,则这组数据被映射到“技术”记录,并且系统试图找到能够处理它的组件。如果两者都失败,则发现信息仅限于发现了 NFC 标签这一事实。在这种情况下,系统试图找到一个组件,该组件可以在没有 NDEF 和没有“技术”类型数据的情况下处理 NFC 标签。
基于在 NFC 标签上找到的信息,Android 还创建了一个 URI 和一个 MIME 类型,您可以将其用于意图过滤器。Android 在线开发者文档的“NFC 基础知识”页面上有更详细的描述;在你最喜欢的搜索引擎中输入 android develop nfc basics 即可找到。
要编写适当的意图过滤器,请参见第三章,此外,对于“技术”风格的发现,您需要在<activity>
中添加特定的<meta-data>
元素,如下所示:
<meta-data android:name="android.nfc.action.
TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
这指向了在res/xml
中的一个名为nfc_tech_filter.xml
的文件,包含以下内容或其子集:
<resources xmlns:xliff=
"urn:oasis:names:tc:xliff:document:1.2">
<tech-list>
<tech>android.nfc.tech.IsoDep</tech>
<tech>android.nfc.tech.NfcA</tech>
<tech>android.nfc.tech.NfcB</tech>
<tech>android.nfc.tech.NfcF</tech>
<tech>android.nfc.tech.NfcV</tech>
<tech>android.nfc.tech.Ndef</tech>
<tech>android.nfc.tech.NdefFormatable</tech>
<tech>android.nfc.tech.MifareClassic</tech>
<tech>android.nfc.tech.MifareUltralight</tech>
</tech-list>
</resources>
您需要添加到意图过滤器中以有助于 NFC 分派过程的操作如下:
-
对于 NDEF 探索风格,使用以下:
<intent-filter> <action android:name= "android.nfc.action.NDEF_DISCOVERED"/> ...more filter specs... </intent-filter>
-
对于技术探索风格,使用以下:
<intent-filter> <action android:name= "android.nfc.action.TECH_DISCOVERED"/> </intent-filter> <meta-data android:name= "android.nfc.action.TECH_DISCOVERED" android:resource="@xml/nfc_tech_filter" />
-
对于故障回复发现方式,请使用以下命令:
<intent-filter> <action android:name= "android.nfc.action.TAG_DISCOVERED"/> ...more filter specs... </intent-filter>
一旦与 NFC 相关的意图被分派,匹配活动就可以从意图中提取 NFC 信息。为此,请通过以下一种或多种方法获取额外的意向数据:
-
NfcAdapter.EXTRA_TAG
。必需的;返回一个android.nfc.Tag
对象。 -
NfcAdapter.EXTRA_NDEF_MESSAGES
。可选;来自标签的 NDEF 消息。您可以通过以下方式检索它们:val rawMessages : Parcelable[] = intent.getParcelableArrayExtra( NfcAdapter.EXTRA_NDEF_MESSAGES)
-
NfcAdapter.EXTRA_ID
。可选;标签的低级 ID。
如果你想写 NFC 标签,在 Android 在线开发者文档的“NFC 基础知识”一页中有描述。
点对点 NFC 数据交换
Android 允许两个 Android 设备通过其光束技术进行 NFC 通信。过程如下:让支持 NFC 的设备的活动扩展CreateNdefMessageCallback
并实现方法createNdefMessage( event : NfcEvent ) : NdefMessage
。在这个方法中,创建并返回一个NdefMessage
,如下所示:
val text = "A NFC message at " +
System.currentTimeMillis().toString()
val msg = NdefMessage( arrayOf(
NdefRecord.createMime(
"application/vnd.com.example.android.beam",
text.toByteArray() )
) )
/*
* When a device receives an NFC message with an Android
* Application Record (AAR) added, the application
* specified in the AAR is guaranteed to run. The AAR
* thus overrides the tag dispatch system.
*/
//val msg = NdefMessage( arrayOf(
// NdefRecord.createMime(
// "application/vnd.com.example.android.beam",
// text.toByteArray() ),
// NdefRecord.createApplicationRecord(
// "com.example.android.beam")
//) )
return msg
然后,NFC 数据接收应用可以在其onResume()
回调中检测它是否由 NFC 发现动作发起。
override
fun onResume() {
super.onResume()
// Check to see that the Activity started due to an
// Android Beam event
if (NfcAdapter.ACTION_NDEF_DISCOVERED ==
intent.action) {
processIntent(intent)
}
}
NFC 卡仿真
让 Android 设备像带有 NFC 芯片的智能卡一样工作需要复杂的设置和编程任务。如果您考虑安全性,这尤其有意义;一些 Android 设备可能包含一个安全元件,它在硬件基础上执行与读卡器的通信。一些其他设备可能应用基于主机的卡仿真来让设备 CPU 执行通信。NFC 卡仿真的所有细节的详尽描述超出了本书的范围,但是如果您打开 Android 在线开发人员指南中的“基于主机的卡仿真”页面,您可以在网上找到相关信息。
也就是说,我们从一个基于主机的卡仿真开始描述基本的工件。该示例基于 Android 开发人员指南提供的 HCE 示例,但它被转换为 Kotlin,并归结为仅与 NFC 相关的重要方面(该示例在 Apache 许可下运行; www.apache.org/licenses/LICENSE-2.0
见)。代码内容如下:
/**
* This is a sample APDU Service which demonstrates how
* to interface with the card emulation support added
* in Android 4.4, KitKat.
*
* This sample replies to any requests sent with the
* string "Hello World". In real-world situations, you
* would need to modify this code to implement your
* desired communication protocol.
*
* This sample will be invoked for any terminals
* selecting AIDs of 0xF11111111, 0xF22222222, or
* 0xF33333333\. See src/main/res/xml/aid:list.xml for
* more details.
*
* Note: This is a low-level interface. Unlike the
* NdefMessage many developers are familiar with for
* implementing Android Beam in apps, card emulation
* only provides a byte-array based communication
* channel. It is left to developers to implement
* higher level protocol support as needed.
*/
class CardService : HostApduService() {
如果与 NFC 卡的连接丢失,则调用onDeactivated()
回调,让应用知道连接断开的原因(或者是丢失的链接,或者是读卡器选择的另一个辅助设备)。
/**
* Called if the connection to the NFC card is lost.
* @param reason Either DEACTIVATION_LINK_LOSS or
* DEACTIVATION_DESELECTED
*/
override fun onDeactivated(reason: Int) {}
当收到 APDU 命令时,将调用processCommandApdu()
方法。在这个方法中,可以通过返回一个字节数组来直接提供响应 APDU。一般来说,响应 APDUs 必须尽快发送,因为当调用此方法时,用户很可能将设备放在 NFC 读取器上。如果有多个服务在其元数据条目中注册了相同的辅助,则只有当用户明确选择了您的服务时,您才会被调用,无论是作为默认服务还是仅用于下一次点击。该方法运行在应用的主线程上。如果您不能立即返回一个响应 APDU,返回null
并稍后使用sendResponseApdu()
方法。
/**
* This method will be called when a command APDU has
* been received from a remote device.
*
* @param commandApdu The APDU that received from the
* remote device
* @param extras A bundle containing extra data. May
* be null.
* @return a byte-array containing the response APDU,
* or null if no response APDU can be sent
* at this point.
*/
override
fun processCommandApdu(commandApdu: ByteArray,
extras: Bundle): ByteArray {
Log.i(TAG, "Received APDU: " +
byteArrayToHexString(commandApdu))
// If the APDU matches the SELECT AID command for
// this service, send the loyalty card account
// number, followed by a SELECT_OK status trailer
// (0x9000).
if (Arrays.equals(SELECT_APDU, commandApdu)) {
val account = AccountStorage.getAccount(this)
val accountBytes = account!!.toByteArray()
Log.i(TAG, "Sending account number: $account")
return concatArrays(accountBytes, SELECT_OK_SW)
} else {
return UNKNOWN_CMD_SW
}
}
companion 对象包含几个常量和实用函数。
companion object {
private val TAG = "CardService"
// AID for our loyalty card service.
private val SAMPLE_LOYALTY_CARD_AID = "F222222222"
// ISO-DEP command HEADER for selecting an AID.
// Format: [Class | Instruction | Parameter 1 |
// Parameter 2]
private val SELECT_APDU_HEADER = "00A40400"
// "OK" status word sent in response to SELECT AID
// command (0x9000)
private val SELECT_OK_SW =
hexStringToByteArray("9000")
// "UNKNOWN" status word sent in response to
// invalid APDU command (0x0000)
private val UNKNOWN_CMD_SW =
hexStringToByteArray("0000")
private val SELECT_APDU =
buildSelectApdu(SAMPLE_LOYALTY_CARD_AID)
/**
* Build APDU for SELECT AID command. This command
* indicates which service a reader is
* interested in communicating with. See
* ISO 7816-4.
*
* @param aid Application ID (AID) to select
* @return APDU for SELECT AID command
*/
fun buildSelectApdu(aid: String): ByteArray {
// Format: [CLASS | INSTRUCTION |
// PARAMETER 1 | PARAMETER 2 |
// LENGTH | DATA]
return hexStringToByteArray(
SELECT_APDU_HEADER +
String.format("%02X",
aid.length / 2) +
aid)
}
/**
* Utility method to convert a byte array to a
* hexadecimal string.
*/
fun byteArrayToHexString(bytes: ByteArray):
String {
val hexArray = charArrayOf('0', '1', '2', '3',
'4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F')
val hexChars = CharArray(bytes.size * 2)
var v: Int
for (j in bytes.indices) {
v = bytes[j].toInt() and 0xFF
// Cast bytes[j] to int, treating as
// unsigned value
hexChars[j * 2] = hexArray[v.ushr(4)]
// Select hex character from upper nibble
hexChars[j * 2 + 1] = hexArray[v and 0x0F]
// Select hex character from lower nibble
}
return String(hexChars)
}
/**
* Utility method to convert a hexadecimal string
* to a byte string.
*
* Behavior with input strings containing
* non-hexadecimal characters is undefined.
*/
fun hexStringToByteArray(s: String): ByteArray {
val len = s.length
if (len % 2 == 1) {
// TODO, throw exception
}
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
// Convert each character into a integer
// (base-16), then bit-shift into place
data[i / 2] =
((Character.digit(s[i], 16) shl 4) +
Character.digit(s[i + 1], 16)).
toByte()
i += 2
}
return data
}
/**
* Utility method to concatenate two byte arrays.
*/
fun concatArrays(first: ByteArray,
vararg rest: ByteArray): ByteArray {
var totalLength = first.size
for (array in rest) {
totalLength += array.size
}
val result =
Arrays.copyOf(first, totalLength)
var offset = first.size
for (array in rest) {
System.arraycopy(array, 0,
result, offset, array.size)
offset += array.size
}
return result
}
}
}
AndroidManifest.xml
中相应的服务声明如下:
<service android:name=".CardService"
android:exported="true"
android:permission=
"android.permission.BIND_NFC_SERVICE">
<!-- Intent filter indicating that we support
card emulation. -->
<intent-filter>
<action android:name=
"android.nfc.cardemulation.action.
HOST_APDU_SERVICE"/>
<category android:name=
"android.intent.category.DEFAULT"/>
</intent-filter>
<!-- Required XML configuration file, listing the
AIDs that we are emulating cards
for. This defines what protocols our card
emulation service supports. -->
<meta-data android:name=
"android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/aid:list"/>
</service>
并且我们需要在res/xml
里面有一个名为aid:list.xml
的文件。
<?xml version="1.0" encoding="utf-8"?>
<!-- This file defines which AIDs this application
should emulate cards for.
Vendor-specific AIDs should always start with an "F",
according to the ISO 7816 spec. We recommended
vendor-specific AIDs be at least 6 characters long,
to provide sufficient uniqueness. Note, however, that
longer AIDs may impose a burden on non-Android NFC
terminals. AIDs may not exceed 32 characters
(16 bytes).
Additionally, AIDs must always contain an even number
of characters, in hexadecimal format.
In order to avoid prompting the user to select which
service they want to use when the device is scanned,
this app must be selected as the default handler for
an AID group by the user, or the terminal must
select *all* AIDs defined in the category
simultaneously ("exact match").
-->
<host-apdu-service
xmlns:android=
"http://schemas.android.com/apk/res/android"
android:description="@string/service_name"
android:requireDeviceUnlock="false">
<!--
If category="payment" is used for any aid-groups, you
must also add an android:apduServiceBanner attribute
above, like so:
android:apduServiceBanner="@drawable/settings_banner"
apduServiceBanner should be 260x96 dp. In pixels,
that works out to...
- drawable-xxhdpi: 780x288 px
- drawable-xhdpi: 520x192 px
- drawable-hdpi: 390x144 px
- drawable-mdpi: 260x96 px
The apduServiceBanner is displayed in the "Tap & Pay"
menu in the system Settings app, and is only displayed
for apps which implement the "payment" AID category.
Since this sample is implementing a non-standard card
type (a loyalty card, specifically), we do not need
to define a banner.
Important: category="payment" should only be used for
industry-standard payment cards. If you are
implementing a closed-loop payment system (e.g.
stored value cards for a specific merchant or
transit system), use category="other". This is
because only one "payment" card may be active at
a time, whereas all "other" cards are active
simultaneously (subject to AID dispatch).
-->
<aid-group android:description=
"@string/card_title" android:category="other">
<aid-filter android:name="F222222222"/>
</aid-group>
</host-apdu-service>
服务类别还依赖于对象AccountStorage
,例如,如下所示:
/**
* Utility class for persisting account numbers to disk.
*
* The default SharedPreferences instance is used as
* the backing storage. Values are cached in memory for
* performance.
*/
object AccountStorage {
private val PREF_ACCOUNT_NUMBER = "account_number"
private val DEFAULT_ACCOUNT_NUMBER = "00000000"
private val TAG = "AccountStorage"
private var sAccount: String? = null
private val sAccountLock = Any()
fun setAccount(c: Context, s: String) {
synchronized(sAccountLock) {
Log.i(TAG, "Setting account number: $s")
val prefs = PreferenceManager.
getDefaultSharedPreferences(c)
prefs.edit().
putString(PREF_ACCOUNT_NUMBER, s).
commit()
sAccount = s
}
}
fun getAccount(c: Context): String? {
synchronized(sAccountLock) {
if (sAccount == null) {
val prefs = PreferenceManager.
getDefaultSharedPreferences(c)
val account = prefs.getString(
PREF_ACCOUNT_NUMBER,
DEFAULT_ACCOUNT_NUMBER)
sAccount = account
}
return sAccount
}
}
}
安卓和蓝牙
Android 允许你添加自己的蓝牙功能。详尽地描述蓝牙的所有需求超出了本书的范围,但要了解如何做到以下几点,请参阅 Android 中蓝牙的在线文档:
-
扫描可用的本地蓝牙设备(如果您有多个设备)
-
扫描配对的远程蓝牙设备
-
扫描远程设备提供的服务
-
建立通信渠道
-
在本地和远程设备之间传输数据
-
使用配置文件
-
在您的 Android 设备上添加蓝牙服务器
我们将在这里描述 RfComm 通道的实现,以在您的智能手机和外部蓝牙服务之间传输串行数据。通过这个用例,您已经拥有了一个强大的蓝牙通信工具。例如,你可以用它来控制机器人或智能家居设备。
蓝牙 RfComm 服务器
令人惊讶的是,在网上很难找到关于设置蓝牙服务器的有价值的信息。然而,对于开发来说,有必要实现一个蓝牙服务器,这样您就可以测试 Android 应用。这样的测试服务器也可以作为您可能想到的真实场景的基础。
BlueCove 是蓝牙服务器技术的一个很好的候选者,它是一个开源项目。它的一部分是在 Apache License V2.0 下许可的,其他部分是在 GPL 下许可的,所以虽然它很容易合并到您自己的项目中,但是您需要检查对于商业项目,该许可是否适合您的需要。在下面的段落中,我将描述如何使用 BlueCove 和 Groovy 在 Linux 上设置 RfComm 蓝牙服务器。对于 Windows,您必须修改启动脚本并使用 DLL 库来代替。
从下载和安装 Groovy 开始。任何现代版本都可以。接下来,下载 BlueCove。我测试的版本是 2.1.0,但是您也可以尝试更新的版本。你需要文件bluecove-2.1.0.jar
、bluecove-emu-2.1.0.jar
和bluecove-gpl-2.1.0.jar
。暂时将 jar 文件解压为 zip 文件,并创建一个文件夹结构,如下所示:
libbluecove.jnilib
startRfComm.sh
libbluecove.so
libbluecove_x64.so
libs/
bluecove-2.1.0.jar
bluecove-emu-2.1.0.jar
bluecove-gpl-2.1.0.jar
scripts/
rfcomm.groovy
注意
根据您使用的 Linux 发行版,您可能需要添加一个符号链接,如下所示:
cd /usr/lib/x86_64-linux-gnu/
ln -s libbluetooth.so.3 libbluetooth.so
您必须以 root 用户身份执行此操作。
注意
此外,仍然以 root 用户身份执行以下操作:
mkdir /var/run/sdb
chmod 777 /var/run/sdp
注意
此外,为了解决兼容性问题,您必须调整蓝牙服务器流程,如下所示:
cd/etc/systemd/system/bluetooth.target.wants/
里面的变化bluetooth.service
像这样:
ExecStart=/usr/lib/bluetooth/bluetoothd →
ExecStart=/usr/lib/bluetooth/bluetoothd -C
然后在终端中输入以下内容
systemctl daemon-reload
和 systemctl restart bluetooth
文件startRfComm.sh
是启动脚本。创建它,并在里面编写以下内容,相应地固定路径:
#!/bin/bash
export JAVA_HOME=/opt/jdk8
export GROOVY_HOME=/opt/groovy
$GROOVY_HOME/bin/groovy \
-cp libs/bluecove-2.1.0.jar:libs/bluecove-emu-2.1.0.jar
:libs/bluecove-gpl-2.1.0.jar \
-Dbluecove.debug=true \
-Djava.library.path=. \
scripts/rfcomm.groovy
服务器代码位于scripts/rfcomm.groovy
中。创建它并插入以下内容:
import javax.bluetooth.*
import javax.obex.*
import javax.microedition.io.*
import groovy.transform.Canonical
// Run server as root!
// setup the server to listen for connection
// retrieve the local Bluetooth device object
LocalDevice local = LocalDevice.getLocalDevice()
local.setDiscoverable(DiscoveryAgent.GIAC)
UUID uuid = new UUID(80087355)
String url = "btspp://localhost:" + uuid.toString() +
";name=RemoteBluetooth"
println("URI: " + url)
StreamConnectionNotifier notifier = Connector.open(url)
// waiting for connection
while(true) {
println("waiting for connection...")
StreamConnection connection = notifier.acceptAndOpen()
InputStream inputStream = connection.openInputStream()
println("waiting for input")
while (true) {
int command = inputStream.read()
if(command == -1) break
println("Command: " + command)
}
}
服务器必须以 root 用户身份启动。在安装了蓝牙适配器的系统上调用sudo ./startRfComm.sh
后,去掉时间戳的输出应该如下所示:
Java 1.4+ detected: 1.8.0_60; Java HotSpot(TM) 64-Bit
Server VM; Oracle Corporation
...
localDeviceID 0
...
BlueCove version 2.1.0 on bluez
URI: btspp://localhost:04c6093b00001000800000805f9b34fb;
name=RemoteBluetooth
open using BlueCove javax.microedition.io.Connector
...
connecting btspp://localhost:04
c6093b00001000800000805f9b34fb;name=RemoteBluetooth
...
created SDPSession 139982379587968
...
BlueZ major verion 4 detected
...
function sdp_extract_pdu of bluez major version 4 is called
...
waiting for connection...
Android RfComm 客户端
随着前面小节中的 RfComm 蓝牙服务器进程的运行,我们现在将为 Android 平台开发客户机。它应该执行以下操作:
-
提供一个活动来选择要连接的远程蓝牙设备
-
提供另一个活动来启动连接,并向 Bluetooth RfConn 服务器发送消息
从一个新项目开始,不要忘记添加 Kotlin 支持。将文件AndroidManifest.xml
修改如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android=
"http://schemas.android.com/apk/res/android"
package="de.pspaeth.bluetooth">
<uses-permission android:name=
"android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name=
"android.permission.BLUETOOTH"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name=
"android.intent.action.MAIN"/>
<category android:name=
"android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".DeviceListActivity"
android:configChanges=
"orientation|keyboardHidden"
android:label="Select Device"
android:theme=
"@android:style/Theme.Holo.Dialog"/>
</application>
</manifest>
接下来在res/layout
中创建三个布局文件。第一个是activity_main.xml
,包含一个状态行和两个按钮。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="State: " />
<TextView
android:id="@+id/state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scan Devices"
android:onClick="scanDevices"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="RfComm"
android:onClick="rfComm"/>
</LinearLayout>
注意
为了简单起见,我添加了文本作为文字。在生产环境中,您当然应该使用字符串资源。
下一个布局文件device_list.xml
用于远程设备选择器活动:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/title_paired_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#666"
android:paddingLeft="5dp"
android:text="Paired Devices"
android:textColor="#fff"
android:visibility="gone"
/>
<ListView
android:id="@+id/paired_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:stackFromBottom="true"
/>
<TextView
android:id="@+id/title_new_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#666"
android:paddingLeft="5dp"
android:text="Other Devices"
android:textColor="#fff"
android:visibility="gone"
/>
<ListView
android:id="@+id/new_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="2"
android:stackFromBottom="true"
/>
<Button
android:id="@+id/button_scan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scan"
/>
</LinearLayout>
最后一个是device_name.xml
,用于布置设备列表器活动中的列表项:
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="5dp"
android:textSize="18sp" />
DeviceListActvity
类是 Android 开发人员文档中蓝牙聊天示例的 device lister 活动的改编版本。
/**
* This Activity appears as a dialog. It lists any
* paired devices and devices detected in the area after
* discovery. When a device is chosen by the user, the
* MAC address of the device is sent back to the parent
* Activity in the result Intent.
*/
class DeviceListActivity : Activity() {
companion object {
private val TAG = "DeviceListActivity"
var EXTRA_DEVICE_ADDRESS = "device_address"
}
private var mBtAdapter: BluetoothAdapter? = null
private var mNewDevicesArrayAdapter:
ArrayAdapter<String>? = null
OnItemClickListener
是在 Kotlin 中实现一个单一方法接口的例子。
private val mDeviceClickListener =
AdapterView.OnItemClickListener {
av, v, arg2, arg3 ->
// Cancel discovery because it's costly and we're
// about to connect
mBtAdapter!!.cancelDiscovery()
// Get the device MAC address, which is the last
// 17 chars in the View
val info = (v as TextView).text.toString()
val address = info.substring(info.length - 17)
// Create the result Intent and include the MAC
// address
val intent = Intent()
intent.putExtra(EXTRA_DEVICE_ADDRESS, address)
// Set result and finish this Activity
setResult(Activity.RESULT_OK, intent)
finish()
}
BroadcastReceiver
监听发现的设备,并在发现完成时更改标题。
/**
* Listening for discovered devices.
*/
private val mReceiver = object : BroadcastReceiver() {
override
fun onReceive(context: Context, intent: Intent) {
val action = intent.action
// When discovery finds a device
if (BluetoothDevice.ACTION_FOUND == action) {
// Get the BluetoothDevice object from
// the Intent
val device = intent.
getParcelableExtra<BluetoothDevice>(
BluetoothDevice.EXTRA_DEVICE)
// If it's already paired, skip it,
// because it's been listed already
if (device.bondState !=
BluetoothDevice.BOND_BONDED) {
mNewDevicesArrayAdapter!!.add(
device.name + "\n" +
device.address)
}
// When discovery is finished, change the
// Activity title
} else if (BluetoothAdapter.
ACTION_DISCOVERY_FINISHED == action) {
setProgressBarIndeterminateVisibility(
false)
setTitle("Select Device")
if (mNewDevicesArrayAdapter!!.count
== 0) {
val noDevices = "No device"
mNewDevicesArrayAdapter!!.add(
noDevices)
}
}
}
}
像往常一样,onCreate()
回调方法设置用户界面。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Setup the window
requestWindowFeature(Window.
FEATURE_INDETERMINATE_PROGRESS)
setContentView(R.layout.activity_device_list)
// Set result CANCELED in case the user backs out
setResult(Activity.RESULT_CANCELED)
// Initialize the button to perform device
// discovery
button_scan.setOnClickListener { v ->
doDiscovery()
v.visibility = View.GONE
}
// Initialize array adapters. One for already
// paired devices and one for newly discovered
// devices
val pairedDevicesArrayAdapter =
ArrayAdapter<String>(this,
R.layout.device_name)
mNewDevicesArrayAdapter =
ArrayAdapter(this,
R.layout.device_name)
// Find and set up the ListView for paired devices
val pairedListView = paired_devices as ListView
pairedListView.adapter = pairedDevicesArrayAdapter
pairedListView.onItemClickListener =
mDeviceClickListener
// Find and set up the ListView for newly
// discovered devices
val newDevicesListView = new_devices as ListView
newDevicesListView.adapter =
mNewDevicesArrayAdapter
newDevicesListView.onItemClickListener =
mDeviceClickListener
// Register for broadcasts when a device is
// discovered
var filter =
IntentFilter(BluetoothDevice.ACTION_FOUND)
this.registerReceiver(mReceiver, filter)
// Register for broadcasts when discovery has
// finished
filter = IntentFilter(BluetoothAdapter.
ACTION_DISCOVERY_FINISHED)
this.registerReceiver(mReceiver, filter)
// Get the local Bluetooth adapter
mBtAdapter = BluetoothAdapter.getDefaultAdapter()
// Get a set of currently paired devices
val pairedDevices = mBtAdapter!!.bondedDevices
// If there are paired devices, add each one to
// the ArrayAdapter
if (pairedDevices.size > 0) {
title_paired_devices.visibility = View.VISIBLE
for (device in pairedDevices) {
pairedDevicesArrayAdapter.add(
device.name + "\n" + device.address)
}
} else {
val noDevices = "No devices"
pairedDevicesArrayAdapter.add(noDevices)
}
}
onDestroy()
回调方法用于清理东西。最后,doDiscovery()
方法执行实际的发现工作。
override fun onDestroy() {
super.onDestroy()
// Make sure we're not doing discovery anymore
if (mBtAdapter != null) {
mBtAdapter!!.cancelDiscovery()
}
// Unregister broadcast listeners
this.unregisterReceiver(mReceiver)
}
/**
* Start device discover with the BluetoothAdapter
*/
private fun doDiscovery() {
Log.d(TAG, "doDiscovery()")
// Indicate scanning in the title
setProgressBarIndeterminateVisibility(true)
setTitle("Scanning")
// Turn on sub-title for new devices
title_new_devices.visibility = View.VISIBLE
// If we're already discovering, stop it
if (mBtAdapter!!.isDiscovering) {
mBtAdapter!!.cancelDiscovery()
}
// Request discover from BluetoothAdapter
mBtAdapter!!.startDiscovery()
}
}
MainActivity
类负责检查和获取权限,并构造一个BluetoothCommandService
,我们将在后面描述。
class MainActivity : AppCompatActivity() {
companion object {
val REQUEST_ENABLE_BT = 42
val REQUEST_QUERY_DEVICES = 142
}
var mBluetoothAdapter: BluetoothAdapter? = null
var mCommandService:BluetoothCommandService? = null
活动中的onCreate()
回调用于设置用户界面和注册蓝牙适配器。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val permission1 = ContextCompat.
checkSelfPermission(
this, Manifest.permission.BLUETOOTH)
val permission2 = ContextCompat.
checkSelfPermission(
this, Manifest.permission.BLUETOOTH_ADMIN)
if (permission1 !=
PackageManager.PERMISSION_GRANTED ||
permission2 !=
PackageManager.PERMISSION_GRANTED)
{
ActivityCompat.requestPermissions(this,
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN),
642)
}
mBluetoothAdapter =
BluetoothAdapter.getDefaultAdapter()
if (mBluetoothAdapter == null) {
Toast.makeText(this,
"Bluetooth is not supported",
Toast.LENGTH_LONG).show()
finish()
}
if (!mBluetoothAdapter!!.isEnabled()) {
val enableIntent = Intent(
BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(
enableIntent, REQUEST_ENABLE_BT)
}
}
scanDevices()
方法用于调用系统的蓝牙设备扫描器。
/**
* Launch the DeviceListActivity to see devices and
* do scan
*/
fun scanDevices(v:View) {
val serverIntent = Intent(
this, DeviceListActivity::class.java)
startActivityForResult(serverIntent,
REQUEST_QUERY_DEVICES)
}
方法rfComm
和sendMessage()
处理蓝牙消息的发送。
fun rfComm(v: View) {
sendMessage("The message")
}
/**
* Sends a message.
*
* @param message A string of text to send.
*/
private fun sendMessage(message: String) {
if (mCommandService?.mState !==
BluetoothCommandService.Companion.
State.CONNECTED)
{
Toast.makeText(this, "Not connected",
Toast.LENGTH_SHORT).show()
return
}
// Check that there's actually something to send
if (message.length > 0) {
val send = message.toByteArray()
mCommandService?.write(send)
}
}
到设备的实际连接是从方法connectDevice()
内部完成的。
private
fun connectDevice(data: Intent, secure: Boolean) {
val macAddress = data.extras!!
.getString(
DeviceListActivity.EXTRA_DEVICE_ADDRESS)
mBluetoothAdapter?.
getRemoteDevice(macAddress)?.run {
val device = this
mCommandService =
BluetoothCommandService(
this@MainActivity, macAddress).apply {
addStateChangeListener { statex ->
runOnUiThread {
state.text = statex.toString()
}
}
connect(device)
}
}
}
private fun fetchUuids(device: BluetoothDevice) {
device.fetchUuidsWithSdp()
}
回调方法onActivityResult()
处理从系统设备选择器的返回。在这里,我们只需连接到所选的设备。
override
fun onActivityResult(requestCode: Int,
resultCode: Int, data: Intent) {
when (requestCode) {
REQUEST_QUERY_DEVICES -> {
if (resultCode == Activity.RESULT_OK) {
connectDevice(data, false)
}
}
}
}
}
Class BluetoothCommandService
虽然名不副实,但却不是 Android 服务。它处理与蓝牙服务器的通信,读取内容如下:
class BluetoothCommandService(context: Context,
val macAddress:String) {
companion object {
// Unique UUID for this application
private val MY_UUID_INSECURE = UUID.fromString(
"04c6093b-0000-1000-8000-00805f9b34fb")
// Constants that indicate the current connection
// state
enum class State {
NONE, // we're doing nothing
LISTEN, // listening for incoming conns
CONNECTING, // initiating an outgoing conn
CONNECTED // connected to a remote device
}
}
private val mAdapter: BluetoothAdapter
private var createSocket: CreateSocketThread? = null
private var readWrite: SocketReadWrite? = null
var mState: State = State.NONE
private var stateChangeListeners =
mutableListOf<(State)->Unit>()
fun addStateChangeListener(l:(State)->Unit) {
stateChangeListeners.add(l)
}
init {
mAdapter = BluetoothAdapter.getDefaultAdapter()
changeState(State.NONE)
}
它的公共方法用于连接、断开和写入数据。
/**
* Initiate a connection to a remote device.
*
* @param device The BluetoothDevice to connect
*/
fun connect(device: BluetoothDevice) {
stopThreads()
// Start the thread to connect with the given
// device
createSocket = CreateSocketThread(device).apply {
start()
}
}
/**
* Stop all threads
*/
fun stop() {
stopThreads()
changeState(State.NONE)
}
/**
* Write to the ConnectedThread in an unsynchronized
* manner
*
* @param out The bytes to write
* @see ConnectedThread.write
*/
fun write(out: ByteArray) {
if (mState != State.CONNECTED) return
readWrite?.run { write(out) }
}
它的私有方法处理连接线程。
/////////////////////////////////////////////////////
/////////////////////////////////////////////////////
/**
* Start the ConnectedThread to begin managing a
* Bluetooth connection
*
* @param socket The BluetoothSocket on which the
* connection was made
* @param device The BluetoothDevice that has been
* connected
*/
private fun connected(socket: BluetoothSocket,
device: BluetoothDevice) {
stopThreads()
// Start the thread to perform transmissions
readWrite = SocketReadWrite(socket).apply {
start()
}
}
private fun stopThreads() {
createSocket?.run {
cancel()
createSocket = null
}
readWrite?.run {
cancel()
readWrite = null
}
}
/**
* Indicate that the connection attempt failed.
*/
private fun connectionFailed() {
changeState(State.NONE)
}
/**
* Indicate that the connection was lost.
*/
private fun connectionLost() {
changeState(State.NONE)
}
连接套接字处理线程本身是一个专用的Thread
实现。
/**
* This thread runs while attempting to make an
* outgoing connection with a device. It runs straight
* through; the connection either succeeds or fails.
*/
private inner
class CreateSocketThread(
private val mmDevice: BluetoothDevice) :
Thread() {
private val mmSocket: BluetoothSocket?
init {
// Get a BluetoothSocket for a connection
// with the given BluetoothDevice
mmSocket = mmDevice.
createInsecureRfcommSocketToServiceRecord(
MY_UUID_INSECURE)
changeState(Companion.State.CONNECTING)
}
override fun run() {
name = "CreateSocketThread"
// Always cancel discovery because it will
// slow down a connection
mAdapter.cancelDiscovery()
// Make a connection to the BluetoothSocket
try {
// This is a blocking call and will only
// return on a successful connection or an
// exception
mmSocket!!.connect()
} catch (e: IOException) {
Log.e("LOG","Connection failed", e)
Log.e("LOG", "Maybe device does not " +
" expose service " + MY_UUID_INSECURE)
// Close the socket
mmSocket!!.close()
connectionFailed()
return
}
// Reset the thread because we're done
createSocket = null
// Start the connected thread
connected(mmSocket, mmDevice)
}
fun cancel() {
mmSocket!!.close()
}
}
为了从连接套接字读写数据,我们使用另一个线程。
/**
* This thread runs during a connection with a
* remote device. It handles all incoming and outgoing
* transmissions.
*/
private inner
class SocketReadWrite(val mmSocket: BluetoothSocket) :
Thread() {
private val mmInStream: InputStream?
private val mmOutStream: OutputStream?
init {
mmInStream = mmSocket.inputStream
mmOutStream = mmSocket.outputStream
changeState(Companion.State.CONNECTED)
}
override fun run() {
val buffer = ByteArray(1024)
var bytex: Int
// Keep listening to the InputStream while
// connected
while (mState ==
Companion.State.CONNECTED) {
try {
// Read from the InputStream
bytex = mmInStream!!.read(buffer)
} catch (e: IOException) {
connectionLost()
break
}
}
}
/**
* Write to the connected OutStream.
*
* @param buffer The bytes to write
*/
fun write(buffer: ByteArray) {
mmOutStream!!.write(buffer)
}
fun cancel() {
mmSocket.close()
}
}
最后,我们提供了一种方法来告知相关方套接字连接状态何时发生变化。这里,它还发出一个日志记录语句。对于生产代码,您可以删除它,或者以其他方式向用户提供这些信息。
private fun changeState(newState:State) {
Log.e("LOG",
"changing state: ${mState} -> ${newState}")
mState = newState
stateChangeListeners.forEach { it(newState) }
}
}
注意
伴随对象的 UUID 必须与您在服务器启动日志中看到的 UUID 相匹配。
这个类的作用如下:
-
一旦它的
connect(...)
方法被调用,它就开始连接尝试。 -
如果连接成功,另一个使用 connection 对象初始化输入和输出流的线程就会启动。注意,在这个例子中没有使用输入流;此处仅供参考。
-
凭借它的
mState
成员,客户端可以检查连接状态。 -
如果已连接,可以调用方法
write(...)
通过连接通道发送数据。
要测试连接,请按 UI 上的 RFCOMM 按钮。然后,服务器应用应该记录以下内容:
Command: 84
Command: 104
Command: 101
Command: 32
Command: 109
Command: 101
Command: 115
Command: 115
Command: 97
Command: 103
Command: 101
这是消息“消息”的数字表示
十三、硬件
Android 能做的不仅仅是在智能手机上呈现 GUI。Android 还与可穿戴设备有关,与适当配置的电视机和汽车中的信息娱乐系统有关。智能手机也有摄像头、NFC 和蓝牙适配器,以及位置、移动、方向和指纹传感器。是的,智能手机也可以打电话。本章介绍了 Android 操作系统如何在智能手机以外的设备上运行,以及如何与设备的硬件进行交互。
使用可穿戴设备编程
Google Wear 是关于你穿在身上的小设备。虽然目前这种设备仅限于你买的戴在手腕上的智能手表,但未来的设备可能包括你的眼镜、衣服或任何你能想到的东西。目前,使用 Google Wear 意味着随身携带一部智能手机,并通过某种配对机制将其连接到 Google Wear 设备,但现代设备也可能以独立的方式运行。这意味着他们不再需要配对的智能手机,他们自己可以通过 Wi-Fi、蓝牙或蜂窝适配器连接到互联网、蜂窝网络或本地网络。
如果你碰巧使用配对的智能手机来运行 Google Wear 应用,这不再局限于只运行 Android,所以你可以将 Google Wear 设备与 Android 智能手机或苹果 iOS 手机配对。Android Wear OS 适用于运行 Android 4.4 或更高版本和 iOS 9.3 或更高版本的配对手机。
谷歌对智能手机应用的设计准则(更准确地说,是对简单而富于表现力的用户界面的需求)对穿戴应用更为重要。由于有限的空间和输入能力,将 UI 元素和前端工作流减少到最低限度对于与穿戴相关的开发来说是绝对重要的。否则,你的应用的可用性和可接受性就有显著降低的风险。
以下是 Google Wear 应用的常见使用案例:
-
设计自己的表盘(时间和日期显示)
-
添加复杂面(自定义面元素)
-
显示通知
-
信息发送
-
语音交互
-
谷歌助手
-
播放音乐
-
拨打和接听电话
-
警告
-
具有简单用户界面的应用
-
智能手机和平板电脑应用的配套应用
-
传感器应用
-
基于位置的服务
-
付费应用
在接下来的章节中,我们将关注 Google Wear 应用的开发事宜。
可穿戴设备的发展
虽然开发穿戴应用时,你可以使用与智能手机或平板电脑应用开发相同的工具和技术,但你必须记住智能手表的空间有限,以及用户与手表的交互方式与其他设备不同。
尽管如此,开始 Wear 开发的主要地方是 Android Studio,在这一节中,我们将描述如何设置您的 IDE 来开始 Wear 开发,以及如何将设备连接到 Android Studio。
开发穿戴类 app,首先要指出有两种操作模式。
-
将 可穿戴设备与智能手机配对
由于技术限制,无法将虚拟智能手表与虚拟智能手机配对。所以,你必须使用真实的手机来配对虚拟智能手表。
-
单机模式
Wear 应用可以独立运行,无需与智能手机配对。强烈建议现代应用在独立模式下也能做明智的事情。
在这两种情况下,创建一个新的 Android Studio 项目,并在 Target Android Devices 部分中,只选择 Wear box。作为最低 API 级别,选择 API 24。在随后的屏幕上,选择以下选项之一:
-
不添加活动
继续操作,不添加活动。您将不得不稍后手动执行该操作。
-
空白磨损活动
添加以下内容作为布局:
<android.support.wear.widget.BoxInsetLayout ...>
<FrameLayout ...>
</FrameLayout>
</android.support.wear.widget.BoxInsetLayout>
添加以下内容作为活动类:
-
谷歌地图佩戴活动
添加以下内容作为布局:
class MainActivity : WearableActivity() {
override
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setAmbientEnabled() // Enables Always-on
}
}
<android.support.wear.widget.
SwipeDismissFrameLayout ...>
<FrameLayout ...>
<fragment android:id="@+id/map" android:name=
"com.google.android.gms.maps.MapFragment"
... />
</FrameLayout>
</android.support.wear.widget.
SwipeDismissFrameLayout>
添加以下内容作为活动类:
-
看脸
此选项不创建活动;相反,它构建了一个定义手表外观所需的服务类。
class MapsActivity : WearableActivity(),
OnMapReadyCallback {
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
setAmbientEnabled() // Enables always on
setContentView(R.layout.activity_maps)
// Enables the Swipe-To-Dismiss Gesture
...
// Adjusts margins
...
}
override
fun onMapReady(googleMap: GoogleMap) {
...
}
}
这个选择对应于你选择的开发范式。您将创建以下内容之一:
-
一个类似智能手机的应用,需要在手表上明确启动才能运行。这包括一款用于穿戴的谷歌地图应用。
-
手表表面。这或多或少是一个图形设计问题;表盘是手表表面时间和日期的视觉外观。
-
面部并发症。这是添加到面部的特征。
我们将在接下来的章节中讨论不同的发展路径。
接下来,打开工具➤ AVD 管理器并创建一个新的虚拟穿戴设备。你现在可以在虚拟穿戴设备上启动你的应用了。除非您选择不添加任何活动,否则模拟器应该已经在界面上显示了一个启动 UI。
要将虚拟手表与智能手机配对,请使用 USB 电缆将智能手机连接到开发 PC,使其成为开发设备(在系统设置底部的内部版本号上点击七次),然后使用谷歌应用在智能手机上安装 Wear OS。在您的开发 PC 上,通过以下方式设置通信:
./adb -d forward tcp:5601 tcp:5601
启动应用,并从菜单中选择连接到模拟器。
如果你想使用真正的智能手表进行开发,并需要调试功能,在线资源“调试 Wear OS 应用”显示了有关如何设置智能手表调试过程的更多信息。
可穿戴设备应用用户界面
在开始为 Wear 应用创建用户界面之前,请考虑使用一种内置机制,即通知或面部复杂功能,如以下章节所述。然而,如果你认为你的穿戴应用有必要展示自己的布局,不要只是复制智能手机应用的布局,并将其用于穿戴。相反,要构建真正的 Wear 用户界面,请使用 Wear 支持库提供的特殊 UI 元素。要使用它们,确保模块的build.gradle
文件包含以下内容:
dependencies {
...
implementation 'com.android.support:wear:26.0.0'
}
这个库包含各种类,帮助您构建一个 UI,其中包含专门为 Wear 开发定制的元素。在线 API 文档中的“android.support.wear.widget”页面包含有关如何使用这些类的详细信息。
可穿戴面孔
如果您想要创建一个显示特定自定义设计中的时间和日期的磨损表面,请使用“观察表面”选项,从前面描述的项目创建向导开始。
警告
Android Studio 3.1 中提供的手表脸示例包含一个 bug。它试图启动一个默认的活动,该活动对于一个只显示人脸的应用来说是不存在的。要解决这个问题,请在“运行”菜单中打开“编辑配置”,在“启动选项”中将“启动”更改为“无”。
生成的向导服务类提供了一个非常精致的手表表面示例,您可以将它用作自己的表面的起点。
添加面部并发症
面部并发症是面部数据片段的占位符。复杂数据提供者与复杂数据呈现器是严格分开的,所以当你面对时,你不会说你想显示某些复杂数据。相反,您可以指定显示复杂情况的位置,还可以指定可能的复杂数据类型,但是您可以让用户决定在哪里显示哪些复杂情况。
在本节中,我们将讨论如何增强您的面部以显示并发症数据。为此,我提出了一种最小侵入性的方法来更新你的面部实现,以便你更容易实现自己的想法。如前所述,有一个运行面是本节的一个要求。
我们从AndroidManifest.xml
中的条目开始。
- 我们需要一种方式来告诉 Android,我们将为复杂的 UI 元素进行配置活动。这是通过在
service
元素中添加这个来实现的(移除由¬
指示的换行符):
<meta-data
android:name=
"com.google.android.wearable. ¬
watchface.wearableConfigurationAction"
android:value=
"com.example.xyz88.CONFIG_COMPLICATION"/>
这表明 Android 存在复杂的管理活动。我们将它映射到下一个描述的新活动。
- 我们添加关于权限查询活动和配置活动的信息,如下所示:
<activity android:name=
"android.support.wearable. ¬
complications. ¬
ComplicationHelperActivity"/>
<activity
android:name=
".ComplicationConfigActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name=
"com.example.xyz88\. ¬
CONFIG_COMPLICATION"/>
<category android:name=
"com.google.android. ¬
wearable.watchface.category. ¬
WEARABLE_CONFIGURATION"/>
<category android:name=
"android.intent.category. ¬
DEFAULT"/>
</intent-filter>
</activity>
接下来,我们在Face
类中任何合适的地方添加以下内容:
lateinit var compl : MyComplications
private fun initializeComplications() {
compl = MyComplications()
compl.init(this@MyWatchFace, this)
}
override
fun onComplicationDataUpdate(
complicationId: Int,
complicationData: ComplicationData)
{
compl.onComplicationDataUpdate(
complicationId,complicationData)
}
private fun drawComplications(
canvas: Canvas, drawWhen: Long) {
compl.drawComplications(canvas, drawWhen)
}
// Fires PendingIntent associated with
// complication (if it has one).
private fun onComplicationTap(
complicationId:Int) {
Log.d("LOG", "onComplicationTap()")
compl.onComplicationTap(complicationId)
}
在同一文件中,将以下内容添加到ci.onCreate(...)
:
initializeComplications()
在onSurfaceChanged(...)
的末尾,添加以下内容:
compl.updateComplicationBounds(width, height)
在onTapCommand(...)
功能内,按如下方式替换相应的where
块分支:
WatchFaceService.TAP_TYPE_TAP -> {
// The user has completed the tap gesture.
// Toast.makeText(applicationContext, R.string.message,
Toast.LENGTH_SHORT)
// .show()
compl.getTappedComplicationId(x, y)?.run {
onComplicationTap(this)
}
}
这将判断用户是否点击了显示的复杂功能之一,如果是这样,则将事件转发给我们定义的新功能之一。最后,在onDraw(...)
中,写下以下内容:
...
drawBackground(canvas)
drawComplications(canvas, now)
drawWatchFace(canvas)
...
为了处理这种复杂性,创建一个名为MyComplications
的新类,其内容如下:
class MyComplications {
我们首先在 companion 对象中定义几个常量和实用方法。
companion object {
fun getComplicationId(
pos: ComplicationConfigActivity.
ComplicationLocation): Int {
// Add supported locations here
return when(pos) {
ComplicationConfigActivity.
ComplicationLocation.LEFT ->
LEFT_COMPLICATION_ID
ComplicationConfigActivity.
ComplicationLocation.RIGHT ->
RIGHT_COMPLICATION_ID
else -> -1
}
}
fun getSupportedComplicationTypes(
complicationLocation:
ComplicationConfigActivity.
ComplicationLocation): IntArray? {
return when(complicationLocation) {
ComplicationConfigActivity.
ComplicationLocation.LEFT ->
COMPLICATION_SUPPORTED_TYPES[0]
ComplicationConfigActivity.
ComplicationLocation.RIGHT ->
COMPLICATION_SUPPORTED_TYPES[1]
else -> IntArray(0)
}
}
private val LEFT_COMPLICATION_ID = 0
private val RIGHT_COMPLICATION_ID = 1
val COMPLICATION_IDS = intArrayOf(
LEFT_COMPLICATION_ID, RIGHT_COMPLICATION_ID)
private val complicationDrawables =
SparseArray<ComplicationDrawable>()
private val complicationDat =
SparseArray<ComplicationData>()
// Left and right dial supported types.
private val COMPLICATION_SUPPORTED_TYPES =
arrayOf(
intArrayOf(ComplicationData.TYPE_RANGED_VALUE,
ComplicationData.TYPE_ICON,
ComplicationData.TYPE_SHORT_TEXT,
ComplicationData.TYPE_SMALL_IMAGE),
intArrayOf(ComplicationData.TYPE_RANGED_VALUE,
ComplicationData.TYPE_ICON,
ComplicationData.TYPE_SHORT_TEXT,
ComplicationData.TYPE_SMALL_IMAGE)
)
}
private lateinit var ctx:CanvasWatchFaceService
private lateinit var engine:MyWatchFace.Engine
在一个init()
方法中,我们注册了要绘制的复杂内容。方法onComplicationDataUpdate()
用于处理并发症数据更新,方法updateComplicationBounds()
对并发症大小变化做出反应。
fun init(ctx:CanvasWatchFaceService,
engine: MyWatchFace.Engine) {
this.ctx = ctx
this.engine = engine
// A ComplicationDrawable for each location
val leftComplicationDrawable =
ctx.getDrawable(custom_complication_styles)
as ComplicationDrawable
leftComplicationDrawable.setContext(
ctx.applicationContext)
val rightComplicationDrawable =
ctx.getDrawable(custom_complication_styles)
as ComplicationDrawable
rightComplicationDrawable.setContext(
ctx.applicationContext)
complicationDrawables[LEFT_COMPLICATION_ID] =
leftComplicationDrawable
complicationDrawables[RIGHT_COMPLICATION_ID] =
rightComplicationDrawable
engine.setActiveComplications(*COMPLICATION_IDS)
}
fun onComplicationDataUpdate(
complicationId: Int,
complicationData: ComplicationData) {
Log.d("LOG", "onComplicationDataUpdate() id: " +
complicationId);
complicationDat[complicationId] = complicationData
complicationDrawables[complicationId].
setComplicationData(complicationData)
engine.invalidate()
}
fun updateComplicationBounds(width: Int,
height: Int) {
// For most Wear devices width and height
// are the same
val sizeOfComplication = width / 4
val midpointOfScreen = width / 2
val horizontalOffset =
(midpointOfScreen - sizeOfComplication) / 2
val verticalOffset =
midpointOfScreen - sizeOfComplication / 2
complicationDrawables.get(LEFT_COMPLICATION_ID).
bounds =
// Left, Top, Right, Bottom
Rect(
horizontalOffset,
verticalOffset,
horizontalOffset + sizeOfComplication,
verticalOffset + sizeOfComplication)
complicationDrawables.get(RIGHT_COMPLICATION_ID).
bounds =
// Left, Top, Right, Bottom
Rect(
midpointOfScreen + horizontalOffset,
verticalOffset,
midpointOfScreen + horizontalOffset +
sizeOfComplication,
verticalOffset + sizeOfComplication)
}
方法drawComplications()
实际上是画了复杂。为此,我们扫描了我们在init
块中登记的并发症。
fun drawComplications(canvas: Canvas, drawWhen: Long) {
COMPLICATION_IDS.forEach {
complicationDrawables[it].
draw(canvas, drawWhen)
}
}
我们需要有能力发现我们的一个复杂问题是否被利用了。方法getTappedComplicationId()
对此负责。最后,一个方法onComplicationTap()
对此类事件做出反应。
// Determines if tap happened inside a complication
// area, or else returns null.
fun getTappedComplicationId(x:Int, y:Int):Int? {
val currentTimeMillis = System.currentTimeMillis()
for(complicationId in
MyComplications.COMPLICATION_IDS) {
val res =
complicationDat[complicationId]?.run {
var res2 = -1
if(isActive(currentTimeMillis)
&& (getType() !=
ComplicationData.TYPE_NOT_CONFIGURED)
&& (getType() !=
ComplicationData.TYPE_EMPTY))
{
val complicationDrawable =
complicationDrawables[complicationId]
val complicationBoundingRect =
complicationDrawable.bounds
if (complicationBoundingRect.width()
> 0) {
if (complicationBoundingRect.
contains(x, y)) {
res2 = complicationId
}
} else {
Log.e("LOG",
"Unrecognized complication id.")
}
}
res2
} ?: -1
if(res != -1) return res
}
return null
}
// The user tapped on a complication
fun onComplicationTap(complicationId:Int) {
Log.d("LOG", "onComplicationTap()")
val complicationData =
complicationDat[complicationId]
if (complicationData != null) {
if (complicationData.getTapAction()
!= null) {
try {
complicationData.getTapAction().send()
} catch (e: Exception ) {
Log.e("LOG",
"onComplicationTap() tap error: " +
e);
}
} else if (complicationData.getType() ==
ComplicationData.TYPE_NO_PERMISSION) {
// Launch permission request.
val componentName = ComponentName(
ctx.applicationContext,
MyComplications::class.java)
val permissionRequestIntent =
ComplicationHelperActivity.
createPermissionRequestHelperIntent(
ctx.applicationContext,
componentName)
ctx.startActivity(permissionRequestIntent)
}
} else {
Log.d("LOG",
"No PendingIntent for complication " +
complicationId + ".")
}
}
}
剩下要做的是编写配置活动。为此,创建一个名为ComplicationConfigActivity
的新 Kotlin 类,其内容如下:
class ComplicationConfigActivity :
Activity(), View.OnClickListener {
companion object {
val TAG = "LOG"
val COMPLICATION_CONFIG_REQUEST_CODE = 1001
}
var mLeftComplicationId: Int = 0
var mRightComplicationId: Int = 0
var mSelectedComplicationId: Int = 0
// Used to identify a specific service that renders
// the watch face.
var mWatchFaceComponentName: ComponentName? = null
// Required to retrieve complication data from watch
// face for preview.
var mProviderInfoRetriever:
ProviderInfoRetriever? = null
var mLeftComplicationBackground: ImageView? = null
var mRightComplicationBackground: ImageView? = null
var mLeftComplication: ImageButton? = null
var mRightComplication: ImageButton? = null
var mDefaultAddComplicationDrawable: Drawable? = null
enum class ComplicationLocation {
LEFT,
RIGHT
}
像往常一样,我们使用onCreate()
和onDestroy()
回调来设置或清理我们的用户界面。另外,onCreate()
使用方法retrieveInitialComplicationsData()
来初始化复杂情况。
override
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_config)
mDefaultAddComplicationDrawable =
getDrawable(R.drawable.add_complication)
mSelectedComplicationId = -1
mLeftComplicationId =
MyComplications.getComplicationId(
ComplicationLocation.LEFT)
mRightComplicationId =
MyComplications.getComplicationId(
ComplicationLocation.RIGHT)
mWatchFaceComponentName =
ComponentName(applicationContext,
MyWatchFace::class.java!!)
// Sets up left complication preview.
mLeftComplicationBackground =
left_complication_background
mLeftComplication = left_complication
mLeftComplication!!.setOnClickListener(this)
// Sets default as "Add Complication" icon.
mLeftComplication!!.setImageDrawable(
mDefaultAddComplicationDrawable)
mLeftComplicationBackground!!.setVisibility(
View.INVISIBLE)
// Sets up right complication preview.
mRightComplicationBackground =
right_complication_background
mRightComplication = right_complication
mRightComplication!!.setOnClickListener(this)
// Sets default as "Add Complication" icon.
mRightComplication!!.setImageDrawable(
mDefaultAddComplicationDrawable)
mRightComplicationBackground!!.setVisibility(
View.INVISIBLE)
mProviderInfoRetriever =
ProviderInfoRetriever(applicationContext,
Executors.newCachedThreadPool())
mProviderInfoRetriever!!.init()
retrieveInitialComplicationsData()
}
override fun onDestroy() {
super.onDestroy()
mProviderInfoRetriever!!.release()
}
fun retrieveInitialComplicationsData() {
val complicationIds =
MyComplications.COMPLICATION_IDS
mProviderInfoRetriever!!.retrieveProviderInfo(
object : ProviderInfoRetriever.
OnProviderInfoReceivedCallback() {
override fun onProviderInfoReceived(
watchFaceComplicationId:
Int,
complicationProviderInfo:
ComplicationProviderInfo?)
{
Log.d(TAG,
"onProviderInfoReceived: " +
complicationProviderInfo)
updateComplicationViews(
watchFaceComplicationId,
complicationProviderInfo)
}
},
mWatchFaceComponentName,
*complicationIds)
}
方法onClick()
和launchComplicationHelperActivity()
用于处理复杂攻丝。
override
fun onClick(view: View) {
if (view.equals(mLeftComplication)) {
Log.d(TAG, "Left Complication click()")
launchComplicationHelperActivity(
ComplicationLocation.LEFT)
} else if (view.equals(mRightComplication)) {
Log.d(TAG, "Right Complication click()")
launchComplicationHelperActivity(
ComplicationLocation.RIGHT)
}
}
fun launchComplicationHelperActivity(
complicationLocation: ComplicationLocation) {
mSelectedComplicationId =
MyComplications.getComplicationId(
complicationLocation)
if (mSelectedComplicationId >= 0) {
val supportedTypes = MyComplications.
getSupportedComplicationTypes(
complicationLocation)!!
startActivityForResult(
ComplicationHelperActivity.
createProviderChooserHelperIntent(
applicationContext,
mWatchFaceComponentName,
mSelectedComplicationId,
*supportedTypes),
ComplicationConfigActivity.
COMPLICATION_CONFIG_REQUEST_CODE)
} else {
Log.d(TAG,
"Complication not supported by watch face.")
}
}
为了处理 Android 操作系统发出的更新信号,我们提供了方法updateComplicationViews()
和onActivityResult()
。
fun updateComplicationViews(
watchFaceComplicationId:
Int,
complicationProviderInfo:
ComplicationProviderInfo?)
{
Log.d(TAG, "updateComplicationViews(): id: "+
watchFaceComplicationId)
Log.d(TAG, "\tinfo: " + complicationProviderInfo)
if (watchFaceComplicationId ==
mLeftComplicationId) {
if (complicationProviderInfo != null) {
mLeftComplication!!.setImageIcon(
complicationProviderInfo.providerIcon)
mLeftComplicationBackground!!.
setVisibility(View.VISIBLE)
} else {
mLeftComplication!!.setImageDrawable(
mDefaultAddComplicationDrawable)
mLeftComplicationBackground!!.
setVisibility(View.INVISIBLE)
}
} else if (watchFaceComplicationId ==
mRightComplicationId) {
if (complicationProviderInfo != null) {
mRightComplication!!.
setImageIcon(
complicationProviderInfo.providerIcon)
mRightComplicationBackground!!.
setVisibility(View.VISIBLE)
} else {
mRightComplication!!.setImageDrawable(
mDefaultAddComplicationDrawable)
mRightComplicationBackground!!.
setVisibility(View.INVISIBLE)
}
}
}
override
fun onActivityResult(requestCode: Int,
resultCode: Int, data: Intent) {
if (requestCode ==
COMPLICATION_CONFIG_REQUEST_CODE
&& resultCode == Activity.RESULT_OK) {
// Retrieves information for selected
// Complication provider.
val complicationProviderInfo =
data.getParcelableExtra<
ComplicationProviderInfo>(
ProviderChooserIntent.
EXTRA_PROVIDER_INFO)
Log.d(TAG, "Provider: " +
complicationProviderInfo)
if (mSelectedComplicationId >= 0) {
updateComplicationViews(
mSelectedComplicationId,
complicationProviderInfo)
}
}
}
}
请注意,我们添加了几个日志记录语句,您可能希望删除这些语句以用于生产代码。相应的布局可以如下所示:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:id="@+id/watch_face_background"
android:layout_width="180dp"
android:layout_height="180dp"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background=
"@drawable/settings_face_preview_background"/>
<View
android:id="@+id/watch_face_highlight"
android:layout_width="180dp"
android:layout_height="180dp"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background=
"@drawable/settings_face_preview_highlight"/>
<View
android:id="@+id/watch_face_arms_and_ticks"
android:layout_width="180dp"
android:layout_height="180dp"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background=
"@drawable/settings_face_preview_arms_n_ticks"/>
<ImageView
android:id="@+id/left_complication_background"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/added_complication"
style="?android:borderlessButtonStyle"
android:background="@android:color/transparent"
android:layout_centerVertical="true"
android:layout_alignStart=
"@+id/watch_face_background"/>
<ImageButton
android:id="@+id/left_complication"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?android:borderlessButtonStyle"
android:background="@android:color/transparent"
android:layout_alignTop=
"@+id/left_complication_background"
android:layout_alignStart=
"@+id/watch_face_background"/>
<ImageView
android:id="@+id/right_complication_background"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/added_complication"
style="?android:borderlessButtonStyle"
android:background="@android:color/transparent"
android:layout_alignTop=
"@+id/left_complication_background"
android:layout_alignStart=
"@+id/right_complication"/>
<ImageButton
android:id="@+id/right_complication"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?android:borderlessButtonStyle"
android:background="@android:color/transparent"
android:layout_alignTop=
"@+id/right_complication_background"
android:layout_alignEnd=
"@+id/watch_face_background"/>
</RelativeLayout>
通过所有这些添加,该表面提供了两种可能的用户需求增加的复杂性。更多的复杂位置是可能的;只需重写代码的适当部分。
注意
输入这里显示的代码,Android Studio 会抱怨缺少资源,尤其是 drawables。要运行这里给出的代码,您必须提供缺少的资源。你通常可以通过查看名称来了解它们的用途。
提供并发症数据
默认情况下,Google Wear 设备包括几个复杂数据提供程序,因此用户可以在其中进行选择,以填充面部的复杂占位符。
如果您想创建自己的复杂数据提供者,请准备一个新的服务,如AndroidManifest.xml
中所述。
<service
android:name=".CustomComplicationProviderService"
android:icon="@drawable/ic_watch_white"
android:label="Service label"
android:permission="com.google.android.wearable. ¬
permission.BIND_COMPLICATION_PROVIDER">
<intent-filter>
<action android:name="android.support.wearable. ¬
complications. ¬
ACTION_COMPLICATION_UPDATE_REQUEST"/>
</intent-filter>
<meta-data
android:name="android.support.wearable.¬
complications.SUPPORTED_TYPES"
android:value=
"SHORT_TEXT,LONG_TEXT,RANGED_VALUE"/>
<!--
UPDATE_PERIOD_SECONDS specifies how
often you want the system to check for updates
to the data. A zero value means you will
instead manually trigger updates.
If not zero, set the interval in the order
of minutes. The actual update may however
differ - the system might have its own idea.
-->
<meta-data
android:name="android.support.wearable.¬
complications.UPDATE_PERIOD_SECONDS"
android:value="0"/>
</service>
从服务等级CustomComplicationProviderService
开始,如下所示:
class CustomComplicationProviderService :
ComplicationProviderService() {
// This method is for any one-time per complication set
-up.
override
fun onComplicationActivated(
complicationId: Int, dataType: Int,
complicationManager: ComplicationManager?) {
Log.d(TAG,
"onComplicationActivated(): $complicationId")
}
// The complication needs updated data from your
// provider. Could happen because of one of:
// 1\. An active watch face complication is changed
// to use this provider
// 2\. A complication using this provider becomes
// active
// 3\. The UPDATE_PERIOD_SECONDS (manifest) has
// elapsed
// 4\. Manually: an update via
// ProviderUpdateRequester.requestUpdate()
override fun onComplicationUpdate(
complicationId: Int, dataType: Int,
complicationManager: ComplicationManager) {
Log.d(TAG,
"onComplicationUpdate() $complicationId")
// ... add code for data generation ...
var complicationData: ComplicationData? = null
when (dataType) {
ComplicationData.TYPE_SHORT_TEXT ->
complicationData = ComplicationData.
Builder(ComplicationData.TYPE_SHORT_TEXT)
. ... create datum ...
.build()
ComplicationData.TYPE_LONG_TEXT ->
complicationData = ComplicationData.
Builder(ComplicationData.TYPE_LONG_TEXT)
...
ComplicationData.TYPE_RANGED_VALUE ->
complicationData = ComplicationData.
Builder(ComplicationData.
TYPE_RANGED_VALUE)
...
else ->
Log.w("LOG",
"Unexpected complication type $dataType")
}
if (complicationData != null) {
complicationManager.updateComplicationData(
complicationId, complicationData)
} else {
// Even if no data is sent, we inform the
// ComplicationManager
complicationManager.noUpdateRequired(
complicationId)
}
}
override
fun onComplicationDeactivated(complicationId: Int) {
Log.d("LOG",
"onComplicationDeactivated(): $complicationId")
}
}
要手动触发系统查询新的复杂数据的请求,可以使用如下的ProviderUpdateRequester
类:
val compName =
ComponentName(applicationContext,
MyService::class.java)
val providerUpdateRequester =
ProviderUpdateRequester(
applicationContext, componentName)
providerUpdateRequester.requestUpdate(
complicationId)
// To instead all complications, instead use
// providerUpdateRequester.requestUpdateAll()
关于可穿戴设备的通知
可穿戴设备上的通知可以在桥接模式和独立模式下运行。在桥接模式下,通知会自动与配对的智能手机同步;在独立模式下,穿戴设备独立显示通知。
要开始创建您自己的通知,请使用项目设置向导中的空白磨损活动从磨损项目开始。然后,在模块的build.gradle
文件中,更新依赖关系,如下所示(删除¬
处的换行符):
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar
'])
implementation "org.jetbrains.kotlin: ¬
kotlin-stdlib-jre7:$kotlin_version"
implementation ¬
'com.google.android.support:wearable:2.3.0'
implementation 'com.google.android.gms: ¬
play-services-wearable:12.0.1'
implementation ¬
'com.android.support:percent:27.1.1'
implementation ¬
'com.android.support:support-v13:27.1.1'
implementation ¬
'com.android.support:recyclerview-v7:27.1.1'
implementation ¬
'com.android.support:wear:27.1.1'
compileOnly ¬
'com.google.android.wearable:wearable:2.3.0'
}
更改布局文件以添加用于创建通知的按钮,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<android.support.wear.widget.BoxInsetLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/dark_grey"
android:padding="@dimen/box_inset_layout_padding"
tools:context=".MainActivity"
tools:deviceIds="wear">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding=
"@dimen/inner_frame_layout_padding"
app:boxedEdges="all"
android:orientation="vertical">
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Go" android:onClick="go"/>
</LinearLayout>
</android.support.wear.widget.BoxInsetLayout>
活动获取一个函数来对按钮的按下做出反应。在内部,我们创建并发送通知。
class MainActivity : WearableActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(activity_main)
setAmbientEnabled() // Enables Always-on
}
fun go(v: View) {
val notificationId = 1
// The channel ID of the notification.
val id = "my_channel_01"
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.O) {
// Create the NotificationChannel
val name = "My channel"
val description = "Channel description"
val importance =
NotificationManager.IMPORTANCE_DEFAULT
val mChannel = NotificationChannel(
id, name, importance)
mChannel.description = description
// Register the channel with the system
val notificationManager = getSystemService(
Context.NOTIFICATION_SERVICE)
as NotificationManager
notificationManager.
createNotificationChannel(mChannel)
}
// Notification channel ID is ignored for Android
// 7.1.1 (API level 25) and lower.
val notificationBuilder =
NotificationCompat.Builder(this, id)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("Title")
.setContentText("Content Text")
// Get NotificationManager service
val notificationManager =
NotificationManagerCompat.from(this)
// Issue the notification
notificationManager.notify(
notificationId, notificationBuilder.build())
}
}
如果你启动这个应用,它会显示一个简单的用户界面,有一个文本和一个按钮。按下按钮会导致短时间显示通知图标,在我们的示例中是一个“播放”矩形。使用后退按钮并向上滑动,通知会显示标题和内容。此外,您的用户使用的面孔可能会添加通知预览。见图 13-1 。
图 13-1
磨损通知
您还可以在代码中添加一个PendingIntent
,并在构建器中用setContentIntent(...)
注册它,以便在用户单击一个出现的通知时发送一个意图。此外,在构建器中,您可以使用addAction(...)
或addActions(...)
添加动作图标。
可以通过构造一个NotificationCompat.WearableExtender
对象并调用构建器上的extent(...)
来传递这个扩展器对象,从而将特定于可穿戴设备的功能添加到通知中。注意,通过向WearbleExtender
对象而不是构建器添加动作,可以确保动作只在可穿戴设备上显示。
要使用预定义的文本响应和在桥接模式下使用的特殊功能为佩戴通知添加语音功能,请参阅佩戴通知的在线文档。
控制可穿戴设备上的应用可见性
自 Android 5.1 以来,穿戴操作系统设备允许在前台运行穿戴应用,即使在省电或环境模式下。处理环境模式有两种选择。
-
使用
AmbientModeSupport
类。 -
使用
WearableActivity
类。
要使用AmbientModeSupport
类,实现Activity
的一个子类,实现AmbientCallbackProvider
接口,声明并保存AmbientController
,如下所示:
class MainActivity : FragmentActivity(),
AmbientModeSupport.AmbientCallbackProvider {
override
fun getAmbientCallback():
AmbientModeSupport.AmbientCallback
{
...
}
lateinit
var mAmbientController:
AmbientModeSupport.AmbientController
override
fun onCreate(savedInstanceState:Bundle?) {
super.onCreate(savedInstanceState)
...
mAmbientController =
AmbientModeSupport.attach(this)
}
}
在getAmbientCallback()
函数中,创建并返回AmbientModeSupport.AmbientCallback
的子类。这个回调负责标准模式和环境模式之间的切换。作为开发人员,环境模式实际上做什么取决于您,但是您应该采用节能措施,例如暗显和黑白图形、增加更新间隔等等。
允许环境模式的第二种可能性是让你的活动从类WearableActivity
继承,在onCreate(...)
回调中调用setAmbientEnabled()
,并覆盖onEnterAmbient()
和onExitAmbient()
。如果你也覆盖了onUpdateAmbient()
,你可以把你的屏幕更新逻辑放在那里,让系统决定在环境模式下使用哪个更新频率。
穿着认证
随着穿戴应用能够以独立模式运行,身份验证对于穿戴应用变得更加重要。描述这方面的适当程序超出了本书的范围,但是在线文档中的“穿戴认证”页面为您提供了有关穿戴认证的详细信息。
穿着时的语音功能
为穿戴式设备添加语音功能非常有意义,因为其他用户输入方法因设备尺寸较小而受到限制。您有两种选择:将您的应用连接到一个或多个系统提供的语音操作,或者定义您自己的操作。
警告
磨损模拟器无法处理语音命令;你必须使用真实的设备来测试它。
将系统语音事件与应用提供的活动联系起来非常简单。您所要做的就是将意图过滤器添加到您的活动中,如下所示:
<intent-filter>
<action android:name=
"android.intent.action.SEND" />
<category android:name=
"com.google.android.voicesearch.SELF_NOTE" />
</intent-filter>
表 13-1 列出了可能的语音键。
表 13-1
系统语音命令
|命令
|
显示
|
关键附加功能
|
| --- | --- | --- |
| “好的,谷歌,帮我叫辆出租车”“好的,谷歌,帮我叫辆车” | com.google.android.gms.actions.RESERVE_TAXI_RESERVATION
| |
| “好的,谷歌,记下来”“好吧,谷歌,自我提醒” | android.intent.action.SEND
类别:com.android.voicesearch.SELF_NOTE
| android.content.Intent.EXTRA_TEXT
:带音符体的字符串 |
| "好吧,谷歌,设置一个早上 8 点的闹钟."“好的,谷歌,明天早上 6 点叫醒我” | android.intent.action.SET_ALARM
| android.provider.AlarmClock.EXTRA_HOUR
:一个整数,表示闹铃的时间android.provider.AlarmClock.EXTRA_MINUTES
:报警分钟的整数 |
| “好的谷歌,设置一个 10 分钟的计时器” | android.intent.action.SET_TIMER
| android.provider.AlarmClock.EXTRA_LENGTH
:1 到 86400(24 小时内的秒数)范围内的整数,表示计时器的长度 |
| “好吧谷歌,开始秒表” | com.google.android.wearable.action.STOPWATCH
| |
| “好的谷歌,开始骑自行车”“好的,谷歌,开始我的骑行”“好了谷歌,别骑自行车了” | vnd.google.fitness.TRACK
MIME 类型:vnd.google.fitness.activity/biking
| actionStatus
:启动时值为ActiveActionStatus
,停止时值为CompletedActionStatus
的字符串 |
| “好的,谷歌,追踪我的跑步记录”“好的,谷歌,开始运行”“好了谷歌,别跑了” | vnd.google.fitness.TRACK
MIME 类型:vnd.google.fitness.activity/running
| actionStatus
:启动时值为ActiveActionStatus
,停止时值为CompletedActionStatus
的字符串 |
| “好的,谷歌,开始锻炼”“好吧,谷歌,跟踪我的训练”“好吧谷歌,停止锻炼” | vnd.google.fitness.TRACK
MIME 类型:vnd.google.fitness.activity/other
| actionStatus
:启动时值为ActiveActionStatus
,停止时值为CompletedActionStatus
的字符串 |
| “好的谷歌,我的心率是多少?”“好吧谷歌,我的 bpm 是什么?” | vnd.google.fitness.VIEW
MIME 类型:vnd.google.fitness.data_type/com.google.heart_rate.bpm
| |
| “好的谷歌,我走了多少步?”"好的,谷歌,我的步数是多少?" | vnd.google.fitness.VIEW
MIME 类型:vnd.google.fitness.data_type/com.google.step_count.cumulative
| |
额外的数据可以像往常一样通过各种Intent.get*Extra(...)
方法之一从输入的意图中提取出来。
您还可以提供应用定义的语音操作,以启动自定义活动。为此,在AndroidManifest.xml
中,将每个有问题的<action>
元素定义如下:
<activity android:name="MyActivity" android:label="
MyRunningApp">
<intent-filter>
<action android:name="android.intent.action.MAIN"
/>
<category android:name="android.intent.category.
LAUNCHER" />
</intent-filter>
</activity>
借助于label
属性,您可以说“Start MyRunningApp”来启动活动。
您也可以让语音识别填写编辑字段。为此,请编写以下内容:
val SPEECH_REQUEST_CODE = 42
val intent = Intent(
RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
}.run {
startActivityForResult(this, SPEECH_REQUEST_CODE)
}
然后在被覆盖的onActivityResult(...)
回调中获取结果。
fun onActivityResult(requestCode:Int, resultCode:Int,
data:Intent) {
if (requestCode and 0xFFFF == SPEECH_REQUEST_CODE
&& resultCode == RESULT_OK) {
val results = data.getStringArrayListExtra(
RecognizerIntent.EXTRA_RESULTS)
String spokenText = results[0]
// ... do something with spoken text
}
super.onActivityResult(
requestCode, resultCode, data)
}
可穿戴设备上的扬声器
如果你想使用连接到 Wear 设备的扬声器来播放一些音频,你首先要检查 Wear 应用是否可以连接扬声器。
fun hasSpeakers(): Boolean {
val packageManager = context.getPackageManager()
val audioManager =
context.getSystemService(
Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Check FEATURE_AUDIO_OUTPUT to guard against
// false positives.
if (!packageManager.hasSystemFeature(
PackageManager.FEATURE_AUDIO_OUTPUT)) {
return false
}
val devices =
audioManager.getDevices(
AudioManager.GET_DEVICES_OUTPUTS)
for (device in devices) {
if (device.type ==
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
return true
}
}
}
return false
}
然后,您可以像在任何其他设备上播放任何其他应用一样播放声音。这在“播放音频”一节中有详细描述
磨损位置
要在穿戴设备中使用位置检测,您必须首先检查位置数据是否可用。
fun hasGps():Boolean {
return packageManager.hasSystemFeature(
PackageManager.FEATURE_LOCATION_GPS);
}
如果可穿戴设备没有自己的位置传感器,您必须不断检查可穿戴设备是否连接。您可以通过如下方式处理回调:
var wearableConnected = false
fun onCreate(savedInstanceState: Bundle?) {
...
Wearable.getNodeClient(this@MainActivity).
connectedNodes.addOnSuccessListener {
wearableConnected = it.any {
it.isNearby
}
}.addOnCompleteListener {
}.addOnFailureListener {
...
}
}
从这里开始,您可以使用融合的位置提供器处理位置检测,如第八章所述。
穿着中的数据通信
Wear OS 中的数据通信有两种方式。
-
直接网络通信:这是针对能够自己连接到网络的穿戴设备,希望与非配对设备通话。
-
使用可穿戴数据层 API :用于与配对的手持设备通信。
对于直接网络通信,使用类 android.net
.ConnectivityManager
来检查诸如带宽之类的功能和请求诸如增加带宽之类的新功能。有关详细信息,请参见在线 API 文档中的类。要实际执行网络通信,使用包 android.net
中的类和接口。
本节的其余部分将描述用于与配对手持设备通信的可穿戴数据层 API 。
要访问可穿戴数据层 API,请从活动内部通过以下方式检索一个DataClient
或MessageClient
:
val dataClient = Wearable.getDataClient(this)
val msgClient = Wearable.getMessageClient(this)
你可以经常这样做,因为这两个电话都不贵。消息客户端最适合用于负载较小的数据;对于更大的有效负载,请使用数据客户端。此外,数据客户端是在穿戴设备和手持设备之间同步数据的可靠方式,而消息客户端使用一劳永逸的模式。因此,消息客户端不知道发送的数据是否实际到达。
对于使用数据客户端发送数据项,创建一个PutDataMapRequest
对象,对其调用getDataMap()
,并使用各种put...()
方法之一添加数据。最后,调用asPutDataRequest()
,并用其结果调用DataClient.putDataItem(...)
。后者启动与其他设备的同步,并返回一个com.google.android.gms.tasks.Task
对象,您可以向其中添加监听器来监视通信。
在接收方,您可以通过使用DataClient.OnDataChangedListener
扩展您的活动并实现fun onDataChanged(dataEvents:DataEventBuffer)
函数来观察数据同步。
对于像图像这样的大型二进制数据集,您可以使用一个Asset
作为要通过数据客户端发送的数据类型,如下所示:
fun createAssetFromBitmap(bitmap: Bitmap): Asset {
val byteStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100,
byteStream)
return Asset.createFromBytes(byteStream.
toByteArray())
}
val bitmap = BitmapFactory.decodeResource(
getResources(), android.R.drawable.ic_media_play)
val asset = createAssetFromBitmap(bitmap)
val dataMap = PutDataMapRequest.create("/image")
dataMap.getDataMap().putAsset("profileImage", asset)
val request = dataMap.asPutDataRequest()
val putTask: Task<DataItem> =
Wearable.getDataClient(this).putDataItem(request)
要使用消息客户端,我们首先需要找到合适的消息接收者。为此,您首先要为合适的手持应用分配功能。这可以通过向res/values
添加一个文件wear.xml
来实现,该文件包含以下内容:
<resources>
<string-array name="android_wear_capabilities">
<item>my_capability1</item>
<item>my_capability2</item>
...
</string-array>
</resources>
要找到具有合适功能的手持设备(或网络节点),然后向其发送消息,您需要编写以下代码:
val capabilityInfo = Tasks.await(
Wearable.getCapabilityClient(this).getCapability(
"my_capability1",
CapabilityClient.FILTER_REACHABLE))
capabilityInfo.nodes.find {
it.isNearby
}?.run {
msgClient.sendMessage(
this.id,"/msg/path","Hello".toByteArray())
}
除此之外,您还可以直接向客户端添加一个CapabilityClient.OnCapabilityChangedListener
监听器,如下所示:
Wearable.getCapabilityClient(this).addListener({
it.nodes.find {
it.isNearby
}?.run {
msgClient.sendMessage(
this.id,"/msg/path","Hello".toByteArray())
}
}, "my_capability1")
要接收这样的消息,在手持设备上安装的应用中的任何位置,通过以下方式注册消息事件监听器:
Wearable.getMessageClient(this).addListener {
messageEvent ->
// do s.th. with the message event
}
使用 Android 电视编程
针对 Android 电视设备的应用开发与智能手机上的开发没有本质区别。然而,由于几十年来电视消费带来的用户期望,与智能手机相比,惯例更加严格。幸运的是,Android Studio 的项目构建器向导可以帮助您入门。在这一部分,我们也将讨论 Android TV 开发的重要方面。
安卓电视使用案例
以下是安卓电视应用的典型使用案例:
-
播放视频和音乐数据流和文件
-
帮助用户查找内容的目录
-
可以在 Android 电视上玩的游戏(无触摸屏)
-
用内容呈现频道
启动 Android TV 工作室项目
如果你在 Android Studio 中开始一个新的 Android TV 项目,以下是突出的兴趣点:
- 在清单文件中,这些项目将确保应用也可以在带触摸屏的智能手机上运行,并且包含 Android TV 所需的向后倾斜的用户界面。
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false"/>
<uses-feature
android:name="android.software.leanback"
android:required="true"/>
- 仍然在清单文件中,您将看到 start 活动具有如下所示的意图过滤器:
<intent-filter>
<action android:name=
"android.intent.action.MAIN"/>
<category android:name=
"android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
这里显示的类别很重要;否则,Android TV 将无法正确识别该应用。该活动还需要有一个android:banner
属性,该属性指向 Android TV 用户界面上突出显示的横幅。
- 在模块的
build.gradle
文件中,向后倾斜支持库被添加到dependencies
部分中。
implementation 'com.android.support:leanback-
v17:27.1.1'
对于开发,您既可以使用虚拟设备,也可以使用真实设备。虚拟设备通过工具菜单中的 AVD 管理器进行安装。对于真实设备,在设置➤设备➤关于中点击七次内部版本号。然后在“设置”中,转到“首选项”并在“开发人员选项”中启用调试。
Android 电视硬件功能
要了解某个应用是否正在 Android 电视上运行,您可以按如下方式使用UiModeManager
:
val isRunnigOnTv =
(getSystemService(Context.UI_MODE_SERVICE)
as UiModeManager).currentModeType ==
Configuration.UI_MODE_TYPE_TELEVISION
此外,可用功能因设备而异。如果您的应用需要某些硬件功能,您可以按如下方式检查可用性:
getPackageManager().
hasSystemFeature(PackageManager.FEATURE_*)
有关所有可能的特性,请参见PackageManager
的 API 文档。
Android 电视设备上的用户输入通常通过 D-pad 控制器进行。为了构建稳定的应用,您应该对 D-pad 控制器可用性的变化做出反应。因此,在AndroidManifest.xml
文件中,添加android: configChanges = "keyboard|keyboardHidden|navigation"
作为活动属性。然后,应用通过被覆盖的回调函数fun onConfigurationChanged( newConfig : Configuration )
获得配置更改的通知。
Android 电视的用户界面开发
对于 Android TV 开发,建议使用倾斜主题。为此,将AndroidManifest.xml
文件的<application>
元素中的theme
属性替换为:
android:theme="@style/Theme.Leanback"
这意味着不使用动作栏,这是有意义的,因为 Android TV 不支持动作栏。此外,活动不得延长AppCompatActivity
;而是延长android.support.v4.app.FragmentActivity
。
Android TV 应用的另一个特点是偶尔可能会发生过扫描。根据像素大小和纵横比,Android TV 可能会裁剪掉部分屏幕。为了避免布局被破坏,建议在主容器上增加 48dp × 27dp 的边距,如下所示:
<RelativeLayout xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="27dp"
android:layout_marginBottom="27dp"
android:layout_marginLeft="48dp"
android:layout_marginRight="48dp">
<!-- Screen elements ... -->
</RelativeLayout>
此外,对于 Android 电视,建议开发 1920 × 1080 像素。对于其他硬件像素大小,Android 将在必要时自动缩小布局元素。
由于用户无法通过点击式 UI 元素进行导航,而是使用 D-pad 进行导航,因此 Android TV 需要一种在 UI 元素之间切换的替代方法。这可以通过向 UI 元素添加nextFocusUp
、nextFocusDown
、nextFocusLeft
和nextFocusRight
属性来轻松实现。参数是导航到元素的 ID 规范,如在@+id/xyzElement
中。
对于电视播放组件,向后倾斜库提供了几个方便使用的类和概念,如下所示:
-
对于媒体浏览器,让你的片段扩展
android.support.v17.leanback.app.BrowseSupportFragment
。project builder 向导创建了一个废弃的BrowseFragment
,但是您可以浏览一下BrowseSupportFragment
的 API 文档来学习新的方法。 -
要在媒体浏览器中呈现的实际媒体项目由卡片视图控制。对应要覆盖的类是
android.support.v17.leanback.widget.Presenter
。 -
要显示所选媒体项目的细节,请扩展类
android.support.v17.leanback.app.DetailsSupportFragment
。该向导创建了不推荐使用的DetailFragment
,但是它们的用法是相似的,您可以查看 API 文档了解更多细节。 -
对于显示视频播放的 UI 元素,使用
android.support.v17.leanback.app.PlaybackFragment
或android.support.v17.leanback.app.VideoFragment
中的一个。 -
使用类
android.media.session.MediaSession
来配置一个“正在播放”的卡。 -
类
android.media.tv.TvInputService
支持将视频流直接呈现到 UI 元素上。调用onTune(...)
将开始渲染直接视频流。 -
如果你的应用需要一个使用几个步骤的指南,例如向用户展示一个购买工作流程,你可以使用类
android.support.v17.leanback.app.GuidedStepSupportFragment
。 -
要以非交互方式向首次用户展示应用,请使用类
android.support.v17.leanback.app.OnboardingSupportFragment
。
内容搜索的推荐渠道
展示给用户的推荐有两种形式:在 Android 8.0 之前作为推荐行,从 Android 8.0 开始作为推荐通道(API 级)。为了不遗漏用户,您的应用应该在一个交换机中同时提供这两种服务,如下所示:
if (android.os.Build.VERSION.SDK_INT >=
Build.VERSION_CODES.O) {
// Recommendation channels API ...
} else {
// Recommendations row API ...
}
对于 Android 8.0 和更高版本,Android TV 主屏幕在频道列表顶部显示一个全局播放下一行,以及多个频道,每个频道都属于某个应用。“播放下一行”以外的频道不能属于多个应用。每个应用都可以定义一个默认频道,它会自动显示在频道视图中。对于应用可能定义的所有其他频道,用户必须首先批准它们,然后频道才会显示在主屏幕上。
应用需要具有以下权限才能管理频道:
<uses-permission android:name=
"com.android.providers.tv.permission.READ_EPG_DATA"
/>
<uses-permission android:name=
"com.android.providers.tv.permission.WRITE_EPG_DATA"
/>
因此,将它们添加到文件AndroidManifest.xml
中。
此外,在模块的build.gradle
文件中,将以下内容添加到dependencies
部分(在一行中):
implementation
'com.android.support:support-tv-provider:27.1.1'
要创建一个频道,添加一个频道徽标,可能使其成为默认频道,并编写以下内容:
val builder = Channel.Builder()
// Intent to execute when the app link gets tapped.
val appLink = Intent(...).toUri(Intent.URI_INTENT_SCHEME)
// You must use type `TYPE_PREVIEW`
builder.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName("Channel Name")
.setAppLinkIntentUri(Uri.parse(appLink))
val channel = builder.build()
val channelUri = contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues())
val channelId = ContentUris.parseId(channelUri)
// Choose one or the other
ChannelLogoUtils.storeChannelLogo(this, channelId,
/*Uri*/ logoUri)
ChannelLogoUtils.storeChannelLogo(this, channelId,
/*Bitmap*/ logoBitmap)
// optional, make it the default channel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
TvContractCompat.requestChannelBrowsable(this,
channelId)
要更新或删除通道,您可以使用从通道创建步骤中收集的通道 ID,然后编写以下内容:
// to update:
contentResolver.update(
TvContractCompat.buildChannelUri(channelId),
channel.toContentValues(), null, null)
// to delete:
contentResolver.delete(
TvContractCompat.buildChannelUri(channelId),
null, null)
要添加程序,请使用以下命令:
val pbuilder = PreviewProgram.Builder()
// Intent to launch when a program gets selected
val progLink = Intent().toUri(Intent.URI_INTENT_SCHEME)
pbuilder.setChannelId(channelId)
.setType(TvContractCompat.PreviewPrograms.TYPE_CLIP)
.setTitle("Title")
.setDescription("Program description")
.setPosterArtUri(largePosterArtUri)
.setIntentUri(Uri.parse(progLink))
.setInternalProviderId(appProgramId)
val previewProgram = pbuilder.build()
val programUri = contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
previewProgram.toContentValues())
val programId = ContentUris.parseId(programUri)
相反,要将一个节目添加到 Play Next 行,您可以使用WatchNextProgram.Builder
并编写以下代码:
val wnbuilder = WatchNextProgram.Builder()
val watchNextType = TvContractCompat.
WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
wnbuilder.setType(
TvContractCompat.WatchNextPrograms.TYPE_CLIP)
.setWatchNextType(watchNextType)
.setLastEngagementTimeUtcMillis(time)
.setTitle("Title")
.setDescription("Program description")
.setPosterArtUri(largePosterArtUri)
.setIntentUri(Uri.parse(progLink))
.setInternalProviderId(appProgramId)
val watchNextProgram = wnbuilder.build()
val watchNextProgramUri = contentResolver
.insert(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
watchNextProgram.toContentValues())
val watchnextProgramId =
ContentUris.parseId(watchNextProgramUri)
对于watchNextType
,您可以使用TvContractCompat.WatchNextPrograms
中的下列常量之一:
-
WATCH_NEXT_TYPE_CONTINUE
:用户在观看内容时停止,可以在这里继续。 -
WATCH_NEXT_TYPE_NEXT
:系列中的下一个可用节目可用。 -
系列中的下一个可用节目是最新可用的。
-
WATCH_NEXT_TYPE_WATCHLIST
:用户保存程序时,由系统或 app 插入。
要更新或删除程序,请使用您在程序生成时记忆的程序 ID。
// to update:
contentResolver.update(
TvContractCompat.
buildPreviewProgramUri(programId),
watchNextProgram.toContentValues(), null, null)
// to delete:
contentResolver.delete(
TvContractCompat.
buildPreviewProgramUri(programId),
null, null)
内容搜索的推荐行
对于 Android 7.1(API 级别 25)之前的版本,推荐由一个特殊的推荐行处理。任何更高版本都不能使用建议行。
对于参与 8.0 之前 Android 版本的推荐行的应用,我们首先创建一个新的推荐服务,如下所示:
class UpdateRecommendationsService :
IntentService("RecommendationService") {
companion object {
private val TAG = "UpdateRecommendationsService"
private val MAX_RECOMMENDATIONS = 3
}
override fun onHandleIntent(intent: Intent?) {
Log.d("LOG", "Updating recommendation cards")
val recommendations:List<Movie> =
ArrayList<Movie>()
// TODO: fill recommendation movie list...
var count = 0
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
val notificationId = 42
for (movie in recommendations) {
Log.d("LOG", "Recommendation - " +
movie.title!!)
val builder = RecommendationBuilder(
context = applicationContext,
smallIcon = R.drawable.video_by_icon,
id = count+1,
priority = MAX_RECOMMENDATIONS - count,
title = movie.title ?: "",
description = "Description",
image = getBitmapFromURL(
movie.cardImageUrl ?:""),
intent = buildPendingIntent(movie))
val notification = builder.build()
notificationManager.notify(
notificationId, notification)
if (++count >= MAX_RECOMMENDATIONS) {
break
}
}
}
private fun getBitmapFromURL(src: String): Bitmap {
val url = URL(src)
return (url.openConnection() as HttpURLConnection).
apply {
doInput = true
}.let {
it.connect()
BitmapFactory.decodeStream(it.inputStream)
}
}
private fun buildPendingIntent(movie: Movie):
PendingIntent {
val detailsIntent =
Intent(this, DetailsActivity::class.java)
detailsIntent.putExtra("Movie", movie)
val stackBuilder = TaskStackBuilder.create(this)
stackBuilder.addParentStack(
DetailsActivity::class.java)
stackBuilder.addNextIntent(detailsIntent)
// Ensure a unique PendingIntents, otherwise all
// recommendations end up with the same
// PendingIntent
detailsIntent.action = movie.id.toString()
return stackBuilder.getPendingIntent(
0, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
AndroidManifest.xml
中的相应条目如下:
<service
android:name=".UpdateRecommendationsService"
android:enabled="true" />
代码中的RecommendationBuilder
指的是通知生成器周围的包装类。
class RecommendationBuilder(
val id:Int = 0,
val context:Context,
val title:String,
val description:String,
var priority:Int = 0,
val image: Bitmap,
val smallIcon: Int = 0,
val intent: PendingIntent,
val extras:Bundle? = null
) {
fun build(): Notification {
val notification:Notification =
NotificationCompat.BigPictureStyle(
NotificationCompat.Builder(context)
.setContentTitle(title)
.setContentText(description)
.setPriority(priority)
.setLocalOnly(true)
.setOngoing(true)
.setColor(...)
.setCategory(
Notification.CATEGORY_RECOMMENDATION)
.setLargeIcon(image)
.setSmallIcon(smallIcon)
.setContentIntent(intent)
.setExtras(extras))
.build()
return notification
}
}
我们需要它,因为创建和传递通知是向系统告知建议的方式。
剩下的是一个组件,它在系统启动时启动,然后定期发送建议。一个例子是使用广播接收器和警报器进行定期更新。
class RecommendationBootup : BroadcastReceiver() {
companion object {
private val TAG = "BootupActivity"
private val INITIAL_DELAY: Long = 5000
}
override
fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "BootupActivity initiated")
if (intent.action!!.endsWith(
Intent.ACTION_BOOT_COMPLETED)) {
scheduleRecommendationUpdate(context)
}
}
private
fun scheduleRecommendationUpdate(context: Context) {
Log.d(TAG, "Scheduling recommendations update")
val alarmManager =
context.getSystemService(
Context.ALARM_SERVICE) as AlarmManager
val recommendationIntent = Intent(context,
UpdateRecommendationsService::class.java)
val alarmIntent =
PendingIntent.getService(
context, 0, recommendationIntent, 0)
alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
INITIAL_DELAY,
AlarmManager.INTERVAL_HALF_HOUR,
alarmIntent)
}
}
以下是AndroidManifest.xml
中的相应条目:
<receiver android:name=".RecommendationBootup"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name=
"android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
为此,您需要以下权限:
<uses-permission android:name=
"android.permission.RECEIVE_BOOT_COMPLETED"/>
Android 电视内容搜索
您的 Android TV 应用可能有助于 Android 搜索框架。我们在第八章中描述过。在本节中,我们指出了在电视应用中使用搜索的特点。
表 13-2 描述了对电视搜索建议很重要的搜索项字段;左栏列出了来自SearchManager
类的常量名称。您可以在您的数据库中使用它们,或者至少您必须在应用中提供一个映射机制。
表 13-2
电视搜索栏
|田
|
描述
|
| --- | --- |
| SUGGEST_COLUMN_TEXT_1
| 必选。您的内容的名称。 |
| SUGGEST_COLUMN_TEXT_2
| 内容的文本描述。 |
| SUGGEST_COLUMN_RESULT_CARD_IMAGE
| 内容的图片/海报/封面。 |
| SUGGEST_COLUMN_CONTENT_TYPE
| 必选。媒体的 MIME 类型。 |
| SUGGEST_COLUMN_VIDEO_WIDTH
| 媒体的宽度。 |
| SUGGEST_COLUMN_VIDEO_HEIGHT
| 媒体的高度。 |
| SUGGEST_COLUMN_PRODUCTION_YEAR
| 必选。生产年份。 |
| SUGGEST_COLUMN_DURATION
| 必需的。以毫秒为单位的持续时间。 |
对于任何其他搜索提供者,在你的应用中为搜索建议创建一个内容提供者。
一旦用户提交搜索对话框,实际上执行一个搜索查询,搜索框架就会用动作SEARCH
触发一个意图,因此您可以用适当的意图过滤器编写一个活动,如下所示:
<activity
android:name=".DetailsActivity"
android:exported="true">
<!-- Receives the search request. -->
<intent-filter>
<action android:name=
"android.intent.action.SEARCH" />
</intent-filter>
<!-- Points to searchable meta data. -->
<meta-data android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
安卓电视游戏
虽然游戏开发最初看起来很吸引人,因为显示器很大,但记住以下几点很重要:
-
电视总是处于横向模式,所以请确保您的应用善于使用横向模式。
-
对于多人游戏来说,通常不可能对用户隐藏什么,例如在纸牌游戏中。你可以将电视应用连接到智能手机上运行的配套应用,以解决这一问题。
-
你的电视游戏应该支持游戏手柄,并且应该突出地告诉用户如何使用它们。在
AndroidManifest.xml
文件中,你最好声明<uses-feature android:name = "android.hardware.gamepad" android:required = "false"/>
。如果你写的是required = "true"
,你的应用对没有游戏手柄的用户来说是不可卸载的。
Android 电视频道
直播内容的处理,即连续的、基于频道的内容的呈现,由电视输入框架和com.android.tv
、com.android.providers.tv
和android.media.tv
包中的各种类控制。它主要面向 OEM 制造商,帮助他们将 Android 的电视系统连接到直播流数据。详情请看看这些包的 API 文档或者在你喜欢的搜索引擎中输入Android Building TV Channels。
使用 Android Auto 编程
Android Auto 用于将 Android 操作系统转移到兼容的汽车用户界面。到 2018 年,数十家汽车制造商已经或计划在他们的至少一些车型中包含 Android Auto,因此扩展您的应用以包含 Android Auto 功能为您提供了分发应用和改善用户体验的新可能性。作为一种替代的操作模式,Android Auto 应用也可以在智能手机或平板电脑上运行,这使得它可以用于任何类型的汽车,无论是否有兼容的用户界面。
Android Auto 目前仅限于在汽车中使用 Android OS 的以下功能:
-
播放音频
-
信息发送
Android Auto 适用于从 Android 5.0 (API 级别 21)开始的设备。此外,您必须在res/xml
文件夹中提供一个名为automotive_app_desc.xml
的文件,该文件包含以下内容(或其中一些行):
<automotiveApp>
<uses name="media" />
<uses name="notification" />
</automotiveApp>
此外,在AndroidManifest.xml
中,在<application>
元素中添加以下内容:
<meta-data android:name=
"com.google.android.gms.car.application"
android:resource=
"@xml/automotive_app_desc"/>
为 Android Auto 开发
要为 Android Auto 开发应用,请像使用任何其他 Android 应用一样使用 Android Studio。确保您的目标是 API 级别 21 或更高,并且您已经将 v4 支持库添加到模块的build.gradle
文件的dependencies
部分(在一行上)。
implementation
'com.android.support:support-v4:27.1.1'
在手机屏幕上测试 Android Auto
要在您的手持设备上测试运行 Android Auto,您必须从 Google Play 安装 Android Auto 应用。然后,在菜单中,点击信息,然后在活动标题上点击十次或更多次(注意,没有反馈!)直到出现启用开发者模式的 toast 通知。现在,点击新的菜单项“开发者设置”并选择“未知来源”选项。重启 Android Auto。在设备上,通过在设置➤关于屏幕中点击内部版本号七次来启用 USB 调试。之后,在设置➤开发者选项中,启用 USB 调试。
为汽车屏幕测试 Android Auto
您可以在桌面主机(DHU)工具中测试自动应用。这在你的手持设备上模拟了一个汽车用户界面。要安装它,首先在设备上启用 USB 调试,方法是在“设置”“➤”“关于”屏幕中点击内部版本号七次。之后,在设置➤开发者选项中,启用 USB 调试。之后,在你的掌上电脑上安装 Android Auto 应用。
在 Android Studio 中,打开工具菜单中的 SDK 管理器,下载并安装 Android Auto 桌面主机仿真器。你可以在 Android SDK ➤ SDK 工具中找到这个选项。
要在 Linux 上运行 DHU,必须安装以下软件包:libsdl2-2.0-0
、libsdl2-ttf-2.0-0
、libportaudio2
和libpng12-0
。在 Android Auto 中,启用“测试 Android Auto 的手机屏幕”一节中描述的开发人员选项
除非它已经在运行,否则在 Android Auto 应用的菜单中,选择“启动主机服务器”在“设置”中,点击“已连接的汽车”并确保“向 Android Auto 添加新车”选项已启用。
通过 USB 线将手持设备连接到开发机器,打开终端,进入 Android SDK 文件夹,进入platform-tools
文件夹,发出以下命令:
./adb forward tcp:5277 tcp:5277
现在,您可以通过输入以下内容来启动 DHU 工具:
cd <sdk>/extras/google/auto
./desktop-head-unit
# or ./desktop-head-unit -i controller
# for rotary control
DHU 工具现在应该出现在你的开发机器的屏幕上,如图 13-2 所示。
图 13-2
DHU 屏幕
此外,最后一个命令打开了 DHU 工具的外壳,因此可以向它输入和发送命令。下面是一些有趣的 shell 用例:
-
日间和夜间模式
在控制台中输入
daynight
。单击 DHU 屏幕以获得焦点,然后按键盘上的 N 键在白天和夜晚之间切换。 -
模拟麦克风输入
输入
mic play /path/to/sound/file/file.wav
发送声音文件作为模拟麦克风输入。常见的语音命令可以在<sdk>/extras/google/auto/voice/
中找到。 -
睡眠
输入
sleep <N>
使系统休眠N
秒。 -
轻点
输入
tap <X> <Y>
在一些坐标上模拟一个点击事件(对测试脚本有用)。
如果您已经启用了旋转控制器模式,输入dpad
和以下任一选项将模拟一个旋转控制动作:
-
up
、down
、left
或right
:模拟移动。这与箭头键相同。 -
soft left
或soft right
:模拟按下侧面按钮(仅在部分设备上)。这与使用 Shift 和箭头键是一样的。 -
click
:模拟按压控制器。这与按回车键是一样的。 -
back
:模拟按后退键(仅在部分设备上)。这与按退格键是一样的。 -
rotate left
或rotate right
:模拟控制器旋转。这与按 1 或 2 是一样的。 -
flick left
或flick right
:模拟控制器的快速旋转。这与按 Shift+1 或 Shift+2 相同。
开发自动音频播放
如果您的应用向 Android Auto 提供音频服务,您可以在文件AndroidManifest.xml
中定义一个媒体浏览器服务,如下所示:
<service android:name=".MyMediaBrowserService"
android:exported="true">
<intent-filter>
<action android:name=
"android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
要在<application>
中为您的应用额外定义一个通知图标,请编写以下内容:
<meta-data android:name=
"com.google.android.gms.car.notification.SmallIcon"
android:resource=
"@drawable/ic_notification" />
我们将很快关注媒体浏览器服务的实现,但首先我们将讨论一些状态查询方法。首先,如果你的应用需要发现一个 Android Auto 用户是否连接到你的应用,你可以添加一个带有意图过滤器的广播接收器com.google.android.gms.car.media.STATUS
。receiver 类的onReceive(...)
方法将获得一个由media_connection_status
键入的额外值。这个额外的字段的值例如可以读作media_connected
以指示连接事件。
此外,应用可以通过使用以下查询来确定它是否在汽车模式下运行:
fun isCarUiMode(c:Context):Boolean {
val uiModeManager =
c.getSystemService(Context.UI_MODE_SERVICE) as
UiModeManager
return uiModeManager.getCurrentModeType() ==
Configuration.UI_MODE_TYPE_CAR
}
现在让我们回到媒体浏览器的实现。最重要的事情是让服务实现抽象类MediaBrowserServiceCompat
。在其被覆盖的onCreate(...)
方法中,您创建并注册了一个MediaSessionCompat
对象。
public void onCreate() {
super.onCreate()
...
// Start a MediaSession
val mSession = MediaSessionCompat(
this, "my session tag")
val token:MediaSessionCompat.Token =
mSession.sessionToken
// Set a callback object to handle play
/control requests
mSession.setCallback(
object : MediaSessionCompat.Callback() {
// overwrite methods here for
// playback controls...
})
...
}
在此服务中,您必须实现以下方法:
-
onGetRoot(...)
这应该会返回内容层次结构的顶层节点。
-
无子女(-我...。)
在这里,您返回层次结构中节点的子节点。
为了最大限度地减少汽车司机的注意力分散,你的应用应该能够听到语音命令。要启用“播放 XYZ 或 APP_NAME”等语音命令,只需将以下内容添加到文件AndroidManifest.xml
:
<activity>
<intent-filter>
<action android:name=
"android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name=
"android.intent.category.DEFAULT" />
</intent-filter>
</activity>
这将让框架从您添加到会话的MediaSessionCompat.Callback
监听器调用onPlayFromSearch(...)
回调。传入的第二个参数可能作为一个Bundle
包含额外的搜索选择信息,您可以使用它来过滤您想要在应用中返回的结果。使用MediaStore.EXTRA_*
常量之一从Bundle
参数中检索值。
要允许回放语音控制动作,如“下一首歌曲”或“恢复音乐”,请将以下内容作为标志添加到媒体会话对象:
mSession.setFlags(
MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
在汽车上开发信息
您的汽车应用可能有助于 Android 自动消息传递。更准确地说,您可以执行以下一项或多项操作:
-
向 Android Auto 发布通知。通知由消息本身和用于对消息进行分组的会话 ID 组成。您不必自己进行分组,但是为通知分配一个会话 ID 是很重要的,因此框架可以让用户知道通知属于与一个专用通信伙伴的会话。
-
当用户听到消息时,框架将触发一个“消息阅读”意图,你的应用可以捕捉到。
-
用户可以使用自动框架发送回复。这伴随着另一个由框架触发的“消息回复”意图,并被你的应用捕获。
为了捕捉“消息读取”和“消息回复”事件,您按照AndroidManifest.xml
中的以下条目编写接收者:
<application>
...
<receiver android:name=".MyMessageReadReceiver"
android:exported="false">
<intent-filter>
<action android:name=
"com.myapp.auto.MY_ACTION_MESSAGE_READ"/>
</intent-filter>
</receiver>
<receiver android:name=".MyMessageReplyReceiver"
android:exported="false">
<intent-filter>
<action android:name=
"com.myapp.auto.MY_ACTION_MESSAGE_REPLY"/>
</intent-filter>
</receiver>
...
</application>
您必须告诉 Auto 您希望接收此类事件。为此,您需要准备适当的PendingIntent
对象,如下所示:
val msgReadIntent = Intent().apply {
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
setAction("com.myapp.auto.MY_ACTION_MESSAGE_READ")
putExtra("conversation_id", thisConversationId)
setPackage("com.myapp.auto")
}.let {
PendingIntent.getBroadcast(applicationContext,
thisConversationId,
it,
PendingIntent.FLAG_UPDATE_CURRENT)
}
val msgReplyIntent = Intent().apply {
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
setAction("com.myapp.auto.MY_ACTION_MESSAGE_REPLY")
putExtra("conversation_id", thisConversationId)
setPackage("com.myapp.auto")
}.let {
PendingIntent.getBroadcast(applicationContext,
thisConversationId,
it,
PendingIntent.FLAG_UPDATE_CURRENT)
}
这里,com.myapp.auto
是与您的应用相关联的包名。你必须适当地替换它。
为了进一步处理与 Android Auto 的交互,我们需要一个可以如下生成的UnreadConversation
对象:
// Build a RemoteInput for receiving voice input
// in a Car Notification
val remoteInput =
RemoteInput.Builder(MY_VOICE_REPLY_KEY)
.setLabel("The label")
.build()
val unreadConvBuilder =
UnreadConversation.Builder(conversationName)
.setReadPendingIntent(msgReadIntent)
.setReplyAction(msgReplyIntent, remoteInput)
这里,conversationName
是显示给自动用户的对话的名称。如果对话涉及多个用户,这也可以是逗号分隔的标识符列表。
构建器还没有准备好。我们首先添加如下消息:
unreadConvBuilder.addMessage(messageString)
.setLatestTimestamp(currentTimestamp)
接下来我们准备一个NotificationCompat.Builder
对象。我们向这个构建器添加前面的unreadConvBuilder
构建器,从系统中获取一个NotificationManager
,最后发送消息。
val notificationBuilder =
NotificationCompat.Builder(applicationContext)
.setSmallIcon(smallIconResourceID)
.setLargeIcon(largeIconBitmap)
notificationBuilder.extend(CarExtender()
.setUnreadConversation(unreadConvBuilder.build())
NotificationManagerCompat.from(/*context*/this).run {
notify(notificationTag,
notificationId,
notificationBuilder.build())
剩下的就是处理“消息阅读”和“消息回复”事件,如果你注册了它们的话。为此,您编写相应的BroadcastReceiver
类,如AndroidManifest.xml
中的条目所示。请注意,对于“消息回复”操作,您需要使用某种方式来获取消息。
val remoteInput =
RemoteInput.getResultsFromIntent(intent)?.let {
it.getCharSequence(MY_VOICE_REPLY_KEY)
} ?: ""
播放和录制声音
在 Android 中播放声音意味着一件或两件事,或者两件都意味着:
-
简短的声音片段:您通常会播放它们作为对用户界面操作的反馈,比如按下按钮或在编辑字段中输入内容。另一个用例是游戏,其中某些事件可以映射到短音频片段。特别是对于用户界面的反应,确保你不会惹恼用户,并提供一个随时静音音频输出的可能性。
-
音乐播放:您想要播放持续时间超过几秒钟的音乐。
对于简短的音频片段,您使用一个SoundPool
;对于音乐作品,你用一个MediaPlayer
。我们将在下面的章节中讨论它们和录音。
简短的声音片段
对于简短的声音片段,您可以使用SoundPool
并在初始化期间预加载声音。使用SoundPool.load(...)
方法之一加载声音片段后,您不能立即使用它们。相反,你必须等到所有的声音都加载完毕。建议的方法是不要等待一段时间,因为你经常可以在一些博客中读到。取而代之的是,监听声音加载事件并计算完成的片段。您可以让自定义类来完成这项工作,如下所示:
class SoundLoadManager(val ctx:Context) {
var scheduled = 0
var loaded = 0
val sndPool:SoundPool
val soundPoolMap = mutableMapOf<Int,Int>()
init {
sndPool =
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.LOLLIPOP) {
SoundPool.Builder()
.setMaxStreams(4)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(
AudioAttributes.USAGE_MEDIA)
.setContentType(
AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
).build()
} else {
SoundPool(4,
AudioManager.STREAM_MUSIC,
100)
}
sndPool.setOnLoadCompleteListener({
sndPool, sampleId, status ->
if(status != 0) {
Log.e("LOG",
"Sound could not be loaded")
} else {
Log.i("LOG", "Loaded sample " +
sampleId + ", status = " +
status)
}
loaded++
})
}
fun load(resourceId:Int) {
scheduled++
soundPoolMap[resourceId] =
sndPool.load(ctx, resourceId, 1)
}
fun allLoaded() = scheduled == loaded
fun play(rsrcId: Int, loop: Boolean):Int {
return soundPoolMap[rsrcId]?.run {
val audioManager = ctx.getSystemService(
Context.AUDIO_SERVICE) as AudioManager
val curVolume = audioManager.
getStreamVolume(
AudioManager.STREAM_MUSIC)
val maxVolume = audioManager.
getStreamMaxVolume(
AudioManager.STREAM_MUSIC)
val leftVolume = 1f * curVolume / maxVolume
val rightVolume = 1f * curVolume / maxVolume
val priority = 1
val noLoop = if(loop) -1 else 0
val normalPlaybackRate = 1f
sndPool.play(this, leftVolume, rightVolume,
priority, noLoop, normalPlaybackRate)
} ?: -1
}
}
请注意该类的以下内容:
-
加载并保存
SoundPool
的实例。该构造函数已被弃用,这就是我们根据 Android API 级别使用不同的初始化方法的原因。此处显示的参数可根据您的需要进行调整;请参见SoundPool
、SoundPool.Builder
和AudioAttributes.Builder
的 API 文档。 -
提供一个将资源 ID 作为参数的
load()
方法。例如,这可能是一个位于res/raw
文件夹中的 WAV 文件。 -
提供了一个
allLoaded()
方法,你可以用它来检查是否所有的声音都被加载了。 -
提供了一个
play()
方法,您可以使用它来播放加载的声音。如果声音还没有加载,这将不起任何作用。如果声音被实际播放,这将返回流 ID,否则返回-1
。
若要使用类,请创建一个具有实例的字段。例如,在初始化时,在活动的onCreate(...)
方法中,加载声音并调用play()
开始播放。
...
lateinit var soundLoadManager:SoundLoadManager
...
override
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
...
soundLoadManager = SoundLoadManager(this)
with(soundLoadManager) {
load(R.raw.click)
// more ...
}
}
fun go(v: View) {
Log.e("LOG", "All sounds loaded = " +
soundLoadManager.allLoaded())
val strmId = soundLoadManager.play(
R.raw.click, false)
Log.e("LOG", "Stream ID = " + strmId.toString())
}
SoundPool
类也允许停止和恢复声音。如果需要的话,您可以适当地扩展SoundLoadManager
类来考虑这个问题。
播放媒体
注册和播放任意长度和任意来源的音乐剪辑只需要这个类MediaPlayer
。它是一个状态引擎,因此不太容易处理,但是我们首先讨论操作媒体播放器可能需要的权限。
- 如果您的应用需要播放来自互联网的媒体,您必须通过向文件
AndroidManifest.xml
添加以下内容来允许互联网访问:
<uses-permission android:name=
"android.permission.INTERNET" />
- 如果您想要防止播放被进入睡眠状态的设备中断,您需要获取唤醒锁。我们稍后会对此进行更多的讨论,但是为了使这一切成为可能,您需要向
AndroidManifest.xml
添加以下权限:
<uses-permission android:name=
"android.permission.WAKE_LOCK" />
要进一步了解如何获得代码中的权限,请参考第七章。
设置好必要的权限后,我们现在可以处理MediaPlayer
类了。如前所述,它的一个实例创建了一个状态机,从一个状态到另一个状态的转换对应于不同的回放状态。更详细地说,对象可以处于以下状态之一:
-
空闲
一旦被默认的构造函数构造或者在
reset()
之后,播放器就处于空闲状态。 -
已初始化
一旦通过
setDataSource(...)
设置了数据源,播放器就处于初始化的状态。除非你首先使用一个reset()
,否则再次调用setDataSource(...)
会导致错误。 -
准备完毕
准备转换准备一些资源和数据流用于回放。因为这可能需要一些时间,特别是对于来自互联网上的数据源的流资源,有两种可能来参与该转换:
prepare()
方法执行该步骤并阻塞程序流直到它完成,而prepareAsync()
方法将准备发送到后台。在后一种情况下,您必须通过setOnPreparedListener(...)
注册一个监听器,以查明准备步骤实际上是何时完成的。初始化后必须做好启动前的准备工作,在一个stop()
方法后必须再做一次才能再次启动回放。 -
开始
准备成功后,可以通过调用
start()
开始播放。 -
暂停
在
start()
之后,您可以通过调用pause
来暂停播放。再次调用start
将在当前播放位置恢复播放。 -
停止了
您可以通过调用
stop()
来停止播放,无论是正在播放还是暂停播放。一旦停止,就不允许再次开始,除非先重复准备步骤。 -
已完成
一旦回放完成且无循环活动,则进入完成状态。你可以从这里停下来,也可以重新开始。
注意,各种静态create(...)
工厂方法收集了几个转换。有关详细信息,请参见 API 文档。
举个例子,一个基本的播放器 UI 界面,用于播放来自assets
文件夹中的音乐文件,利用同步准备,带有一个开始/暂停按钮和一个停止按钮,如下所示:
var mPlayer: MediaPlayer? = null
fun btnText(playing:Boolean) {
startBtn.text = if(playing) "Pause" else "Play"
}
fun goStart(v:View) {
mPlayer = mPlayer?.run {
btnText(!isPlaying)
if(isPlaying)
pause()
else
start()
this
} ?: MediaPlayer().apply {
setOnCompletionListener {
btnText(false)
release()
mPlayer = null
}
val fd: AssetFileDescriptor =
assets.openFd("tune1.mp3")
setDataSource(fd.fileDescriptor)
prepare() // synchronous
start()
btnText(true)
}
}
fun goStop(v:View) {
mPlayer?.run {
stop()
prepare()
btnText(false)
}
}
代码基本上是不言自明的。一旦按钮被按下,就会调用goStart()
和goStop()
方法,而btnText(...)
用于指示状态变化。这里使用的构造可能看起来很奇怪,但是它所做的就是:如果mPlayer
对象不是null
,那么执行(A)
,最后对它自己执行一个 void 赋值。否则,构造它,然后对它应用(B)
。
mPlayer = mPlayer?.run {
(A)
this
} ?: MediaPlayer().apply {
(B)
}
为了让这个例子工作,你的布局中必须有 id 为startBtn
和stopBtn
的按钮,通过android:onclick="goStop"
和android:onclick="goStart"
连接它们,并且在你的assets/
文件夹中有一个名为tune1.mp3
的文件。该示例在“播放”和“暂停”标签之间切换按钮文本;当然,你也可以在这里使用ImageButton
视图,按下后改变图标。
要使用任何其他数据源,包括来自互联网的在线流,请应用各种setDataSource(...)
替代方法之一或使用静态create(...)
方法之一。为了监控各种状态转换,通过setOn...Listener(...)
添加适当的监听器。进一步建议,一旦你使用完一个MediaPlayer
对象,立即调用它的release()
来释放不再使用的系统资源。
一些音乐的回放也可以在后台处理,例如使用服务而不是活动。在这种情况下,如果您希望避免设备因决定进入睡眠模式而中断播放,您可以通过以下方式获取唤醒锁,以避免 CPU 进入睡眠状态:
mPlayer.setWakeMode(applicationContext,
PowerManager.PARTIAL_WAKE_LOCK)
这避免了网络连接被中断:
val wifiLock = (applicationContext.getSystemService(
Context.WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL, "
myWifilock")
.run {
acquire()
this
}
... later:
wifiLock.release()
录制音频
为了录制音频,您可以使用类MediaRecorder
。使用它相当简单,如下所示:
val mRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setOutputFile(mFileName)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
}
mRecorder.prepare()
mRecorder.start()
... later:
mRecorder.stop()
关于输入、媒体格式和输出的其他选项,请参见类MediaRecorder
的 API 文档。
使用相机
向用户展示事物的应用一直是计算机的主要应用领域。先是文字,后来是图片,甚至后来是电影。只有在过去的几十年里,相反的,让用户展示东西,获得了相当大的关注。随着手持设备配备质量越来越高的相机,对能够处理相机数据的应用的需求已经出现。Android 在这里帮助很大;一个应用可以告诉 Android 操作系统拍照或录制电影并将其保存在某个地方,或者它可以完全控制相机硬件并持续监控相机数据,并根据需要更改变焦、曝光和对焦。
我们将在接下来的章节中讨论所有这些内容。如果您需要这里没有描述的特性或设置,API 文档可以作为进一步研究的起点。
拍照
与相机硬件通信的高级方法是这个命令的 IT 对应物:“拍照并保存在我告诉你的某个地方。”为了实现这一点,假设手持设备实际上有一个摄像头,并且您有使用它的权限,您调用某个 intent 来告诉路径名在哪里保存图像。在意图结果检索时,您可以访问图像数据,既可以直接访问低分辨率缩略图,也可以访问所请求位置的完整图像数据。
我们首先告诉 Android 我们的应用需要一个摄像头。这是通过文件AndroidManifest.xml
中的<uses-feature>
元素实现的。
<uses-feature android:name="android.hardware.camera"
android:required="true" />
在您的应用中,您将进行运行时检查并采取相应的行动。
if (!packageManager.hasSystemFeature(
PackageManager.FEATURE_CAMERA)) {
...
}
要声明必要的权限,您需要在清单文件AndroidManifrest.xml
的<manifest>
元素中编写。
<uses-permission android:name=
"android.permission.CAMERA" />
检查该许可,如果获得许可,请参见第七章。如果你想将图片保存到一个公开可用的商店,以便其他应用可以看到它,你还需要以同样的方式声明和获取权限android.permission.WRITE_EXTERNAL_STORAGE
。相反,要将图片数据保存到应用的私有空间,您需要声明一个稍微不同的权限,如下所示:
<uses-permission android:name=
"android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="18"/>
这个声明只有在 Android 4.4 (API 级别 18)以下才有必要。
我们需要做一些额外的工作来访问图像数据存储。除了我们刚刚描述的许可之外,我们还需要在内容供应器安全级别上访问存储。这意味着,在AndroidManifest.xml
的<application>
元素中,添加以下内容:
<provider
android:name=
"android.support.v4.content.FileProvider"
android:authorities=
"com.example.autho.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name=
"android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths">
</meta-data>
</provider>
在文件res/xml/file_paths.xml
中,写下以下内容:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android=
"http://schemas.android.com/apk/res/android">
<external-path name="my_images" path=
"Android/data/com.example.pckg.name/files/Pictures"
/>
</paths>
path 属性中的值取决于我们是将图片保存在公共存储中还是应用的私有数据空间中。
-
如果您想将图像保存到应用的私有数据空间,请使用
Android/data/com.example.
package.name/files/Pictures
。 -
如果您想将图像保存到公共数据空间,使用
Pictures
。
注意
如果您使用该应用的私人数据空间,则在卸载该应用时,所有图片都将被删除。
要启动系统的摄像头,首先创建一个空文件来写入拍摄的照片,然后创建并启动一个意图,如下所示:
val REQUEST_TAKE_PHOTO = 42
var photoFile:File? = null
fun dispatchTakePictureIntent() {
fun createImageFile():File {
val timeStamp =
SimpleDateFormat("yyyyMMdd_HHmmss_SSS",
Locale.US).format(Date())
val imageFileName = "JPEG_" + timeStamp + "_"
val storageDir =
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_PICTURES)
// To instead take the App's private space:
// val storageDir =
// getExternalFilesDir(
// Environment.DIRECTORY_PICTURES)
val image = File.createTempFile(
imageFileName,
".jpg",
storageDir)
return image
}
val takePictureIntent =
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
val canHandleIntent = takePictureIntent.
resolveActivity(packageManager) != null
if (canHandleIntent) {
photoFile = createImageFile()
Log.e("LOG","Photo output File: ${photoFile}")
val photoURI = FileProvider.getUriForFile(this,
"com.example.autho.fileprovider",
photoFile!!)
Log.e("LOG","Photo output URI: ${photoURI}")
takePictureIntent.putExtra(
MediaStore.EXTRA_OUTPUT, photoURI)
startActivityForResult(takePictureIntent,
REQUEST_TAKE_PHOTO)
}
}
dispatchTakePictureIntent()
注意,FileProvider.getUriForFile()
中的第二个参数指定了权限,因此也必须出现在文件AndroidManifest.xml
的<provider>
元素中,如前所示。
照片拍摄完成后,应用的onActivityResult()
可用于获取图像数据。
override
fun onActivityResult(requestCode: Int, resultCode: Int,
data: Intent) {
if ((requestCode and 0xFFFF) == REQUEST_TAKE_PHOTO
&& resultCode == Activity.RESULT_OK) {
val bmOptions = BitmapFactory.Options()
BitmapFactory.decodeFile(
photoFile?.getAbsolutePath(), bmOptions)?.run {
imgView.setImageBitmap(this)
}
}
}
这里,imgView
指向 UI 布局内部的一个ImageView
元素。
警告
尽管 API 文档中暗示了这一点,但返回的 intent 并不可靠地在其data
字段中包含缩略图。有些设备可以做到这一点,但有些则不行。
因为我们使用photoFile
字段来传输图像文件的名称,所以我们必须注意它能够在活动重启后继续存在。要确保它被持久化,请编写以下代码:
override
fun onSaveInstanceState(outState: Bundle?) {
super.onSaveInstanceState(outState)
photoFile?.run{
outState?.putString("imgFile", absolutePath)
}
}
并在onCreate(...)
内添加:
savedInstanceState?.run {
photoFile = getString("imgFile")?.let {File(it)}
}
只有当您使用公共可用空间来存储图片时,您才能将图像公布到系统的媒体扫描仪。为此,请编写以下内容:
val mediaScanIntent =
Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
val contentUri = Uri.fromFile(photoFile)
mediaScanIntent.setData(contentUri)
sendBroadcast(mediaScanIntent)
录制视频
如前所述,使用该系统的应用录制视频与拍照并无实质区别。本节的其余部分假设您已经完成了该部分。
首先,我们需要文件res/xml/file_paths.xml
中的一个不同的条目。由于我们现在正在处理视频部分,请编写以下内容:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android=
"http://schemas.android.com/apk/res/android">
<external-path name="my_videos"
path="Android/data/de.pspaeth.camera/
files/Movies" />
</paths>
要将视频保存在应用的私有数据空间中,或者改为使用所有应用都可用的公共数据空间,请使用:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android=
"http://schemas.android.com/apk/res/android">
<external-path name="my_videos"
path="Movies" />
</paths>
然后,要告诉 Android 操作系统开始录制视频并将数据保存到我们选择的文件中,请编写以下内容:
var videoFile:File? = null
val REQUEST_VIDEO_CAPTURE = 43
fun dispatchRecordVideoIntent() {
fun createVideoFile(): File {
val timeStamp =
SimpleDateFormat("yyyyMMdd_HHmmss_SSS",
Locale.US).format(Date())
val imageFileName = "MP4_" + timeStamp + "_"
val storageDir =
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_MOVIES)
// To instead tke the App's private space:
// val storageDir = getExternalFilesDir(
// Environment.DIRECTORY_MOVIES)
val image = File.createTempFile(
imageFileName,
".mp4",
storageDir)
return image
}
val takeVideoIntent =
Intent(MediaStore.ACTION_VIDEO_CAPTURE)
if (takeVideoIntent.resolveActivity(packageManager)
!= null) {
videoFile = createVideoFile()
val videoURI = FileProvider.getUriForFile(this,
"com.example.autho.fileprovider",
videoFile!!)
Log.e("LOG","Video output URI: ${videoURI}")
takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT,
videoURI)
startActivityForResult(
takeVideoIntent, REQUEST_VIDEO_CAPTURE)
}
}
dispatchRecordVideoIntent()
为了在录制完成后最终获取视频数据,将以下内容添加到onActivityResult(...)
:
if((requestCode == REQUEST_VIDEO_CAPTURE and 0xFFFF) &&
resultCode == Activity.RESULT_OK) {
videoView.setVideoPath(videoFile!!.absolutePath)
videoView.start()
}
这里,videoView
指向布局文件中的一个VideoView
。
此外,因为我们需要确保videoFile
成员在活动重启后仍然存在,所以将它添加到onSaveInstanceState(...)
和onCreate()
中,如前面针对photoFile
字段所示。
编写自己的相机应用
使用意图来告诉 Android 操作系统为我们拍照或录制视频对于许多用例来说可能是好的。但是,一旦您需要对摄像机或 GUI 进行更多的控制,您就需要使用摄像机 API 编写自己的摄像机访问代码。在这一节中,我将向您展示一个可以做到这两点的应用——向您展示预览并让您拍摄静态图像。
注意
在本书中,我们主要考虑了 Android 或更高版本。在当前部分,我们稍微偏离了这个策略。API 21 级(Android 5.0)之前被弃用的相机 API 与 21 级以来的新相机 API 有很大不同。这就是我们选择使用新 API 的原因,到 2018 年年中,该 API 将覆盖 85%或更多的设备。对于旧的 API,请参见在线文档。
我们从三个实用程序类开始。第一个类是一个TextureView
的扩展。我们使用一个TextureView
,因为它允许相机硬件和屏幕之间更快速的连接,我们扩展了它,使它更好地适应相机的固定比例输出。清单内容如下:
/**
* A custom TextureView which is able to automatically
* crop its size according to an aspect ratio set
*/
class AutoFitTextureView : TextureView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) :
super(context, attrs)
constructor(context: Context, attrs: AttributeSet?,
attributeSetId: Int) :
super(context, attrs, attributeSetId)
var mRatioWidth = 0
var mRatioHeight = 0
/**
* Sets the aspect ratio for this view. The size of
* the view will be measured based on the ratio
* calculated from the parameters. Note that the
* actual sizes of parameters don't matter, that
* is, calling setAspectRatio(2, 3) and
* setAspectRatio(4, 6) make the same result.
*
* @param width Relative horizontal size
* @param height Relative vertical size
*/
fun setAspectRatio(width:Int, height:Int) {
if (width < 0 || height < 0) {
throw IllegalArgumentException(
"Size cannot be negative.");
}
mRatioWidth = width;
mRatioHeight = height;
requestLayout()
}
override
fun onMeasure(widthMeasureSpec:Int,
heightMeasureSpec:Int) {
super.onMeasure(
widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
if (0 == mRatioWidth || 0 == mRatioHeight) {
setMeasuredDimension(width, height)
} else {
val ratio = 1.0 * mRatioWidth / mRatioHeight
if (width < height * ratio) {
setMeasuredDimension(
width, (width / ratio).toInt())
} else {
setMeasuredDimension(
(height * ratio).toInt(), height)
}
}
}
}
下一个实用程序类在系统中查询背面摄像头,一旦找到就存储其特征。其内容如下:
/**
* Find a backface camera
*/
class BackfaceCamera(context:Context) {
var cameraId: String? = null
var characteristics: CameraCharacteristics? = null
init {
val manager = context.getSystemService(
Context.CAMERA_SERVICE) as CameraManager
try {
manager.cameraIdList.find {
manager.getCameraCharacteristics(it).
get(CameraCharacteristics.LENS_FACING) ==
CameraCharacteristics.LENS_FACING_BACK
}.run {
cameraId = this
characteristics = manager.
getCameraCharacteristics(this)
}
} catch (e: CameraAccessException) {
Log.e("LOG", "Cannot access camera", e)
}
}
}
第三个实用程序类执行一些计算,帮助我们适当地将相机输出尺寸映射到纹理视图尺寸。其内容如下:
/**
* Calculates and holds preview dimensions
*/
class PreviewDimension {
companion object {
val LOG_KEY = "PreviewDimension"
// Max preview width guaranteed by Camera2 API
val MAX_PREVIEW_WIDTH = 1920
// Max preview height guaranteed by Camera2 API
val MAX_PREVIEW_HEIGHT = 1080
val ORIENTATIONS = SparseIntArray().apply {
append(Surface.ROTATION_0, 90);
append(Surface.ROTATION_90, 0);
append(Surface.ROTATION_180, 270);
append(Surface.ROTATION_270, 180);
}
作为配套功能,我们需要一种方法,在给定相机支持的大小的情况下,选择最小的一个,该最小的一个至少与相应的纹理视图大小一样大,最多与相应的最大大小一样大,并且其纵横比与指定值匹配。如果这样的尺寸不存在,它选择最大的一个,该最大的一个至多与相应的最大尺寸一样大,并且其纵横比与指定值匹配。
/**
* Calculate the optimal size.
*
* @param choices The list of sizes
* that the camera supports for the intended
* output class
* @param textureViewWidth The width of the
* texture view relative to sensor coordinate
* @param textureViewHeight The height of the
* texture view relative to sensor coordinate
* @param maxWidth The maximum width
* that can be chosen
* @param maxHeight The maximum height
* that can be chosen
* @param aspectRatio The aspect ratio
* @return The optimal size, or an arbitrary one
* if none were big enough
*/
fun chooseOptimalSize(choices: Array<Size>?,
textureViewWidth: Int,
textureViewHeight: Int,
maxWidth: Int, maxHeight: Int,
aspectRatio: Size): Size {
// Collect the supported resolutions that are
// at least as big as the preview Surface
val bigEnough = ArrayList<Size>()
// Collect the supported resolutions that are
// smaller than the preview Surface
val notBigEnough = ArrayList<Size>()
val w = aspectRatio.width
val h = aspectRatio.height
choices?.forEach { option ->
if (option.width <= maxWidth &&
option.height <= maxHeight &&
option.height ==
option.width * h / w) {
if (option.width >= textureViewWidth
&& option.height >=
textureViewHeight) {
bigEnough.add(option)
} else {
notBigEnough.add(option)
}
}
}
// Pick the smallest of those big enough. If
// there is no one big enough, pick the
// largest of those not big enough.
if (bigEnough.size > 0) {
return Collections.min(bigEnough,
CompareSizesByArea())
} else if (notBigEnough.size > 0) {
return Collections.max(notBigEnough,
CompareSizesByArea())
} else {
Log.e(LOG_KEY,
"Couldn't find any suitable size")
return Size(textureViewWidth,
textureViewHeight)
}
}
/**
* Compares two sizes based on their areas.
*/
class CompareSizesByArea : Comparator<Size> {
override
fun compare(lhs: Size, rhs: Size): Int {
// We cast here to ensure the
// multiplications won't overflow
return Long.signum(lhs.width.toLong() *
lhs.height -
rhs.width.toLong() * rhs.height)
}
}
}
internal var rotatedPreviewWidth: Int = 0
internal var rotatedPreviewHeight: Int = 0
internal var maxPreviewWidth: Int = 0
internal var maxPreviewHeight: Int = 0
internal var sensorOrientation: Int = 0
internal var previewSize: Size? = null
我们需要一种方法来计算预览维度,包括传感器方向。方法calcPreviewDimension()
就是这么做的。
fun calcPreviewDimension(width: Int, height: Int,
activity: Activity, bc: BackfaceCamera) {
// Find out if we need to swap dimension to get
// the preview size relative to sensor coordinate.
val displayRotation =
activity.windowManager.defaultDisplay.rotation
sensorOrientation = bc.characteristics!!.
get(CameraCharacteristics.SENSOR_ORIENTATION)
var swappedDimensions = false
when (displayRotation) {
Surface.ROTATION_0, Surface.ROTATION_180 ->
if (sensorOrientation == 90 ||
sensorOrientation == 270) {
swappedDimensions = true
}
Surface.ROTATION_90, Surface.ROTATION_270 ->
if (sensorOrientation == 0 ||
sensorOrientation == 180) {
swappedDimensions = true
}
else -> Log.e("LOG",
"Display rotation is invalid: " +
displayRotation)
}
val displaySize = Point()
activity.windowManager.defaultDisplay.
getSize(displaySize)
rotatedPreviewWidth = width
rotatedPreviewHeight = height
maxPreviewWidth = displaySize.x
maxPreviewHeight = displaySize.y
if (swappedDimensions) {
rotatedPreviewWidth = height
rotatedPreviewHeight = width
maxPreviewWidth = displaySize.y
maxPreviewHeight = displaySize.x
}
if (maxPreviewWidth > MAX_PREVIEW_WIDTH) {
maxPreviewWidth = MAX_PREVIEW_WIDTH
}
if (maxPreviewHeight > MAX_PREVIEW_HEIGHT) {
maxPreviewHeight = MAX_PREVIEW_HEIGHT
}
}
/**
* Retrieves the JPEG orientation from the specified
* screen rotation.
*
* @param rotation The screen rotation.
* @return The JPEG orientation
* (one of 0, 90, 270, and 360)
*/
fun getOrientation(rotation: Int): Int {
// Sensor orientation is 90 for most devices, or
// 270 for some devices (eg. Nexus 5X). We have
// to take that into account and rotate JPEG
// properly. For devices with orientation of 90,
// we simply return our mapping from ORIENTATIONS.
// For devices with orientation of 270, we need
// to rotate the JPEG 180 degrees.
return (ORIENTATIONS.get(rotation) +
sensorOrientation + 270) % 360
}
为了实现正确的预览图像显示,我们使用了getTransformationMatrix()
方法,如下所示:
fun getTransformationMatrix(activity: Activity,
viewWidth: Int, viewHeight: Int): Matrix {
val matrix = Matrix()
val rotation = activity.windowManager.
defaultDisplay.rotation
val viewRect = RectF(0f, 0f,
viewWidth.toFloat(), viewHeight.toFloat())
val bufferRect = RectF(0f, 0f,
previewSize!!.height.toFloat(),
previewSize!!.width.toFloat())
val centerX = viewRect.centerX()
val centerY = viewRect.centerY()
if (Surface.ROTATION_90 == rotation
|| Surface.ROTATION_270 == rotation) {
bufferRect.offset(
centerX - bufferRect.centerX(),
centerY - bufferRect.centerY())
matrix.setRectToRect(viewRect, bufferRect,
Matrix.ScaleToFit.FILL)
val scale = Math.max(
viewHeight.toFloat() / previewSize!!.height,
viewWidth.toFloat() / previewSize!!.width)
matrix.postScale(
scale, scale, centerX, centerY)
matrix.postRotate(
(90 * (rotation - 2)).toFloat(),
centerX, centerY)
} else if (Surface.ROTATION_180 == rotation) {
matrix.postRotate(180f, centerX, centerY)
}
return matrix
}
}
与前面的部分一样,我们需要确保能够获得必要的权限。为此,在AndroidManifest.xml
中添加以下内容:
<uses-permission android:name=
"android.permission.CAMERA"/>
接下来,我们编写一个活动,该活动检查并可能获取必要的权限,打开一个Camera
对象,稍后我们将定义该对象的类,添加一个静态图像捕获按钮和一个捕获的静态图像消费者回调,并处理一个转换矩阵以使TextureView
对象显示正确大小的预览图片。它看起来会像这样:
class MainActivity : AppCompatActivity() {
companion object {
val LOG_KEY = "main"
val PERM_REQUEST_CAMERA = 642
}
lateinit var previewDim:PreviewDimension
lateinit var camera:Camera
override
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val permission1 =
ContextCompat.checkSelfPermission(
this, Manifest.permission.CAMERA)
if (permission1 !=
PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
arrayOf(Manifest.permission.CAMERA),
PERM_REQUEST_CAMERA)
}else{
start()
}
}
override fun onDestroy() {
super.onDestroy()
camera.close()
}
fun go(v: View) {
camera.takePicture()
}
方法start()
用于正确处理相机对象并设置预览画布。注意,当屏幕关闭再打开时,SurfaceTexture
已经可用,onSurfaceTextureAvailable
不会被调用。在这种情况下,我们可以打开一个摄像头,从这里开始预览。否则,我们在SurfaceTextureListener
中等到表面准备好。
private fun start() {
previewDim = PreviewDimension()
camera = Camera(
this, previewDim, cameraTexture).apply {
addPreviewSizeListener { w,h ->
Log.e(LOG_KEY,
"Preview size by PreviewSizeListener:
${w} ${h}")
cameraTexture.setAspectRatio(w,h)
}
addStillImageConsumer(::dataArrived)
}
// Correctly handle the screen turned off and
// turned back on.
if (cameraTexture.isAvailable()) {
camera.openCamera(cameraTexture.width,
cameraTexture.height)
configureTransform(cameraTexture.width,
cameraTexture.height)
} else {
cameraTexture.surfaceTextureListener = object :
TextureView.SurfaceTextureListener {
override
fun onSurfaceTextureSizeChanged(
surface: SurfaceTexture?,
width: Int, height: Int) {
configureTransform(width, height)
}
override
fun onSurfaceTextureUpdated(
surface: SurfaceTexture?) {
}
override
fun onSurfaceTextureDestroyed(
surface: SurfaceTexture?): Boolean {
return true
}
override
fun onSurfaceTextureAvailable(
surface: SurfaceTexture?,
width: Int, height: Int) {
camera.openCamera(width, height)
configureTransform(width, height)
}
}
}
}
private fun dataArrived(it: ByteArray) {
Log.e(LOG_KEY, "Data arrived: " + it.size)
// do more with the picture...
}
private fun configureTransform(
viewWidth: Int, viewHeight: Int) {
val matrix =
previewDim.getTransformationMatrix(
this, viewWidth, viewHeight)
cameraTexture.setTransform(matrix)
}
onRequestPermissionsResult()
回调用于在权限检查从相应的系统调用返回后开始预览。
override
fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode,
permissions, grantResults)
when (requestCode) {
PERM_REQUEST_CAMERA -> {
if(grantResults[0] ==
PackageManager.PERMISSION_GRANTED) {
start()
}
}
}
}
}
带有“拍照”按钮和自定义ciTextureView
UI 元素的相应布局文件如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
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:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Go"
android:onClick="go"/>
<de.pspaeth.camera2.AutoFitTextureView
android:id="@+id/cameraTexture"
android:layout_width="400dp"
android:layout_height="200dp"
android:layout_marginTop="8dp"
/>
</LinearLayout>
在这里,您必须使用自己的类的完全限定路径,而不是de.pspaeth.camera2.AutoFitTextureView
。
Camera
类确保我们将重要的活动放入背景中,准备一个可以放置静态图像捕获数据的空间,并构建一个 camera session 对象。它还解决了几个规模问题。
/**
* A camera with a preview sent to a TextureView
*/
class Camera(val activity: Activity,
val previewDim:PreviewDimension,
val textureView:TextureView) {
companion object {
val LOG_KEY = "camera"
val STILL_IMAGE_FORMAT = ImageFormat.JPEG
val STILL_IMAGE_MIN_WIDTH = 480
val STILL_IMAGE_MIN_HEIGHT = 480
}
private val previewSizeListeners =
mutableListOf<(Int,Int) -> Unit>()
fun addPreviewSizeListener(
l: (Int,Int) -> Unit ) {
previewSizeListeners.add(l)
}
private val stillImageConsumers =
mutableListOf<(ByteArray) -> Unit>()
fun addStillImageConsumer(
l: (ByteArray) -> Unit) {
stillImageConsumers.add(l)
}
/**
* An additional thread and handler for running
* tasks that shouldn't block the UI.
*/
private var mBackgroundThread: HandlerThread? = null
private var mBackgroundHandler: Handler? = null
private var cameraDevice: CameraDevice? = null
private val backfaceCamera =
BackfaceCamera(activity)
// Holds the backface camera's ID
/**
* A [Semaphore] to prevent the app from exiting
* before closing the camera.
*/
private val cameraOpenCloseLock = Semaphore(1)
private var imageReader:ImageReader? = null
private var paused = false
private var flashSupported = false
private var activeArraySize: Rect? = null
private var cameraSession:CameraSession? = null
private var stillImageBytes:ByteArray? = null
openCamera()
方法检查权限,连接到摄像机数据输出,并启动与摄像机的连接。
fun openCamera(width: Int, height: Int) {
startBackgroundThread()
val permission1 =
ContextCompat.checkSelfPermission(
activity, Manifest.permission.CAMERA)
if (permission1 !=
PackageManager.PERMISSION_GRANTED) {
Log.e(LOG_KEY,
"Internal error: "+
"Camera permission missing")
}
setUpCameraOutputs(width, height)
val manager = activity.getSystemService(
Context.CAMERA_SERVICE)
as CameraManager
try {
if (!cameraOpenCloseLock.tryAcquire(
2500, TimeUnit.MILLISECONDS)) {
throw RuntimeException(
"Time out waiting.")
}
val mStateCallback = object :
CameraDevice.StateCallback() {
override
fun onOpened(cameraDev: CameraDevice) {
// This method is called when the
// camera is opened. We start camera
// preview here.
cameraOpenCloseLock.release()
cameraDevice = cameraDev
createCameraSession()
}
override
fun onDisconnected(
cameraDev: CameraDevice) {
cameraOpenCloseLock.release()
cameraDevice?.close()
cameraDevice = null
}
override
fun onError(cameraDev: CameraDevice,
error: Int) {
Log.e(LOG_KEY,
"Camera on error callback: "
+ error);
cameraOpenCloseLock.release()
cameraDevice?.close()
cameraDevice = null
}
}
manager.openCamera(
backfaceCamera.cameraId,
mStateCallback,
mBackgroundHandler)
} catch (e: CameraAccessException) {
Log.e(LOG_KEY,"Could not access camera", e)
} catch (e: InterruptedException) {
Log.e(LOG_KEY,
"Interrupted while camera opening.", e)
}
}
/**
* Initiate a still image capture.
*/
fun takePicture() {
cameraSession?.takePicture()
}
fun close() {
stopBackgroundThread()
cameraSession?.run {
close()
}
imageReader?.run {
surface.release()
close()
imageReader = null
}
}
以下是处理后台线程和摄像机会话的几个私有方法:
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
/**
* Starts a background thread and its [Handler].
*/
private fun startBackgroundThread() {
mBackgroundThread =
HandlerThread("CameraBackground")
mBackgroundThread?.start()
mBackgroundHandler = Handler(
mBackgroundThread!!.getLooper())
}
/**
* Stops the background thread and its [Handler].
*/
private fun stopBackgroundThread() {
mBackgroundThread?.run {
quitSafely()
try {
join()
mBackgroundThread = null
mBackgroundHandler = null
} catch (e: InterruptedException) {
}
}
}
private fun createCameraSession() {
cameraSession = CameraSession(mBackgroundHandler!!,
cameraOpenCloseLock,
backfaceCamera.characteristics,
textureView,
imageReader!!,
cameraDevice!!,
previewDim,
activity.windowManager.defaultDisplay.
rotation,
activeArraySize!!,
1.0).apply {
createCameraSession()
addStillImageTakenConsumer {
//Log.e(LOG_KEY, "!!! PICTURE TAKEN!!!")
for (cons in stillImageConsumers) {
mBackgroundHandler?.post(
Runnable {
stillImageBytes?.run{
cons(this)
}
})
}
}
}
}
setUpCameraOutputs()
方法执行连接到摄像机数据输出的艰苦工作。
/**
* Sets up member variables related to camera:
* activeArraySize, imageReader, previewDim,
* flashSupported
*
* @param width The width of available size for
* camera preview
* @param height The height of available size for
* camera preview
*/
private fun setUpCameraOutputs(
width: Int, height: Int) {
activeArraySize = backfaceCamera.
characteristics?.
get(CameraCharacteristics.
SENSOR_INFO_ACTIVE_ARRAY_SIZE)
val map =
backfaceCamera.characteristics!!.get(
CameraCharacteristics.
SCALER_STREAM_CONFIGURATION_MAP)
val stillSize = calcStillImageSize(map)
imageReader =
ImageReader.newInstance(
stillSize.width,
stillSize.height,
STILL_IMAGE_FORMAT, 3).apply {
setOnImageAvailableListener(
ImageReader.OnImageAvailableListener {
reader ->
if (paused)
return@OnImageAvailableListener
val img = reader.acquireNextImage()
val buffer = img.planes[0].buffer
stillImageBytes =
ByteArray(buffer.remaining())
buffer.get(stillImageBytes)
img.close()
}, mBackgroundHandler)
}
previewDim.calcPreviewDimension(width, height,
activity, backfaceCamera)
val texOutputSizes =
map?.getOutputSizes(
SurfaceTexture::class.java)
val optimalSize =
PreviewDimension.chooseOptimalSize(
texOutputSizes,
previewDim.rotatedPreviewWidth,
previewDim.rotatedPreviewHeight,
previewDim.maxPreviewWidth,
previewDim.maxPreviewHeight,
stillSize)
previewDim.previewSize = optimalSize
// We fit the aspect ratio of TextureView
// to the size of preview we picked.
val orientation =
activity.resources.configuration.
orientation
if (orientation ==
Configuration.ORIENTATION_LANDSCAPE) {
previewSizeListeners.forEach{
it(optimalSize.width,
optimalSize.height) }
} else {
previewSizeListeners.forEach{
it(optimalSize.height,
optimalSize.width) }
}
// Check if the flash is supported.
val available =
backfaceCamera.characteristics?.
get(CameraCharacteristics.
FLASH_INFO_AVAILABLE)
flashSupported = available ?: false
}
最后一个私有方法是计算静止图像的大小。一旦触发器被按下或触发器被模拟按下,这就起作用。
private fun calcStillImageSize(
map: StreamConfigurationMap): Size {
// For still image captures, we use the smallest
// one at least some width x height
val jpegSizes =
map.getOutputSizes(ImageFormat.JPEG)
var stillSize: Size? = null
for (s in jpegSizes) {
if (s.height >= STILL_IMAGE_MIN_HEIGHT
&& s.width >= STILL_IMAGE_MIN_WIDTH) {
if (stillSize == null) {
stillSize = s
} else {
val f =
(s.width * s.height).toFloat()
val still =
(stillSize.width *
stillSize.height).toFloat()
if (f < still) {
stillSize = s
}
}
}
}
return stillSize ?: Size(100,100)
}
}
我们需要的最后一个也可能是最复杂的类是CameraSession
。它是一个状态机,处理各种相机状态,包括自动对焦和自动曝光,并提供两种数据消耗:预览纹理和捕获的静态图像存储。在解释这里使用的几个结构之前,我先给出清单:
/**
* A camera session class.
*/
class CameraSession(val handler: Handler,
val cameraOpenCloseLock:Semaphore,
val cameraCharacteristics:CameraCharacteristics?,
val textureView: TextureView,
val imageReader: ImageReader,
val cameraDevice: CameraDevice,
val previewDim: PreviewDimension,
val rotation:Int,
val activeArraySize: Rect,
val zoom: Double = 1.0) {
companion object {
val LOG_KEY = "Session"
enum class State {
STATE_PREVIEW,
// Showing camera preview.
STATE_WAITING_LOCK,
// Waiting for the focus to be locked.
STATE_WAITING_PRECAPTURE,
// Waiting for the exposure to be
// precapture state.
STATE_WAITING_NON_PRECAPTURE,
// Waiting for the exposure state to
// be something other than precapture
STATE_PICTURE_TAKEN
// Picture was taken.
}
}
var mState:State = State.STATE_PREVIEW
内部类MyCaptureCallback
负责处理两种情况,预览和静态图像捕获。然而,对于预览版,状态转换仅限于on
和off
。
inner class MyCaptureCallback :
CameraCaptureSession.CaptureCallback() {
private fun process(result: CaptureResult) {
if(captSess == null)
return
when (mState) {
State.STATE_PREVIEW -> {
// We have nothing to do when the
// camera preview is working normally.
}
State.STATE_WAITING_LOCK -> {
val afState = result.get(
CaptureResult.CONTROL_AF_STATE)
if (CaptureResult.
CONTROL_AF_STATE_FOCUSED_LOCKED
== afState
|| CaptureResult.
CONTROL_AF_STATE_NOT_FOCUSED_LOCKED
== afState
|| CaptureResult.
CONTROL_AF_STATE_PASSIVE_FOCUSED
== afState) {
if(cameraHasAutoExposure) {
mState =
State.STATE_WAITING_PRECAPTURE
runPrecaptureSequence()
} else {
mState =
State.STATE_PICTURE_TAKEN
captureStillPicture()
}
}
}
State.STATE_WAITING_PRECAPTURE -> {
val aeState = result.get(
CaptureResult.CONTROL_AE_STATE)
if (aeState == null ||
aeState == CaptureResult.
CONTROL_AE_STATE_PRECAPTURE
||
aeState == CaptureRequest.
CONTROL_AE_STATE_FLASH_REQUIRED) {
mState =
State.STATE_WAITING_NON_PRECAPTURE
}
}
State.STATE_WAITING_NON_PRECAPTURE -> {
val aeState = result.get(
CaptureResult.CONTROL_AE_STATE)
if (aeState == null ||
aeState != CaptureResult.
CONTROL_AE_STATE_PRECAPTURE) {
mState = State.STATE_PICTURE_TAKEN
captureStillPicture()
}
}
else -> {}
}
}
override
fun onCaptureProgressed(
session: CameraCaptureSession,
request: CaptureRequest,
partialResult: CaptureResult) {
//...
}
override
fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult) {
process(result)
}
}
var captSess: CameraCaptureSession? = null
var cameraHasAutoFocus = false
var cameraHasAutoExposure = false
val captureCallback = MyCaptureCallback()
private val stillImageTakenConsumers =
mutableListOf<() -> Unit>()
fun addStillImageTakenConsumer(l: () -> Unit) {
stillImageTakenConsumers.add(l)
}
自动对焦动作仅限于支持它的相机设备。这是在createCameraSession()
开始时检查的。同样,自动曝光动作仅限于适当的设备。
/**
* Creates a new [CameraCaptureSession] for camera
* preview and taking pictures.
*/
fun createCameraSession() {
//Log.e(LOG_KEY,"Starting preview session")
cameraHasAutoFocus = cameraCharacteristics?.
get(CameraCharacteristics.
CONTROL_AF_AVAILABLE_MODES)?.let {
it.any{ it ==
CameraMetadata.CONTROL_AF_MODE_AUTO }
} ?: false
cameraHasAutoExposure = cameraCharacteristics?.
get(CameraCharacteristics.
CONTROL_AE_AVAILABLE_MODES)?.let {
it.any{ it == CameraMetadata.
CONTROL_AE_MODE_ON ||
it == CameraMetadata.
CONTROL_AE_MODE_ON_ALWAYS_FLASH ||
it == CameraMetadata.
CONTROL_AE_MODE_ON_AUTO_FLASH ||
it == CameraMetadata.
CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE }
} ?: false
try {
val texture = textureView.getSurfaceTexture()
// We configure the size of default buffer
// to be the size of camera preview we want.
texture.setDefaultBufferSize(
previewDim.previewSize!!.width,
previewDim!!.previewSize!!.height)
// This is the output Surface we need to start
// preview.
val previewSurface = Surface(texture)
val takePictureSurface = imageReader.surface
有两个相机输出消费者:用于预览的纹理和用于静态图像捕获的图像读取器。两者都是构造函数参数,都用于创建会话对象;参见cameraDevice.createCaptureSession(...)
。
// Here, we create a CameraCaptureSession for
// both camera preview and taking a picture
cameraDevice.
createCaptureSession(Arrays.asList(
previewSurface, takePictureSurface),
object : CameraCaptureSession.
StateCallback() {
override
fun onConfigured(cameraCaptureSession:
CameraCaptureSession) {
// When the session is ready, we
// start displaying the preview.
captSess = cameraCaptureSession
try {
val captReq =
buildPreviewCaptureRequest()
captSess?.
setRepeatingRequest(captReq,
captureCallback,
handler)
} catch (e: Exception) {
Log.e(LOG_KEY,
"Cannot access camera "+
"in onConfigured()", e)
}
}
override fun onConfigureFailed(
cameraCaptureSession:
CameraCaptureSession) {
Log.e(LOG_KEY,
"Camera Configuration Failed")
}
override fun onActive(
sess: CameraCaptureSession) {
}
override fun onCaptureQueueEmpty(
sess: CameraCaptureSession) {
}
override fun onClosed(
sess: CameraCaptureSession) {
}
override fun onReady(
sess: CameraCaptureSession) {
}
override fun onSurfacePrepared(
sess: CameraCaptureSession, surface: Surface) {
}
}, handler
)
} catch (e: Exception) {
Log.e(LOG_KEY, "Camera access failed", e)
}
}
/**
* Initiate a still image capture.
*/
fun takePicture() {
lockFocusOrTakePicture()
}
fun close() {
try {
cameraOpenCloseLock.acquire()
captSess?.run {
stopRepeating()
abortCaptures()
close()
captSess = null
}
cameraDevice.run {
close()
}
} catch (e: InterruptedException) {
Log.e(LOG_KEY,
"Interrupted while trying to lock " +
"camera closing.", e)
} catch (e: CameraAccessException) {
Log.e(LOG_KEY, "Camera access exception " +
"while closing.", e)
} finally {
cameraOpenCloseLock.release()
}
}
以下是私有方法。各种各样的build*CaptureRequest()
方法展示了如何准备一个请求,然后发送到摄像机硬件。
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
private fun buildPreviewCaptureRequest():
CaptureRequest {
val texture = textureView.getSurfaceTexture()
val surface = Surface(texture)
// We set up a CaptureRequest.Builder with the
// preview output Surface.
val reqBuilder = cameraDevice.
createCaptureRequest(
CameraDevice.TEMPLATE_PREVIEW)
reqBuilder.addTarget(surface)
// Zooming
val cropRect = calcCropRect()
reqBuilder.set(
CaptureRequest.SCALER_CROP_REGION,
cropRect)
// Flash off
reqBuilder.set(CaptureRequest.FLASH_MODE,
CameraMetadata.FLASH_MODE_OFF)
// Continuous autofocus
reqBuilder.set(CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.
CONTROL_AF_MODE_CONTINUOUS_PICTURE)
return reqBuilder.build()
}
private fun buildTakePictureCaptureRequest() :
CaptureRequest {
// This is the CaptureRequest.Builder that we use
// to take a picture.
val captureBuilder =
cameraDevice.createCaptureRequest(
CameraDevice.TEMPLATE_STILL_CAPTURE)
captureBuilder.addTarget(imageReader.getSurface())
// Autofocus mode
captureBuilder.set(CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.
CONTROL_AF_MODE_CONTINUOUS_PICTURE)
// Flash auto
captureBuilder.set(CaptureRequest.CONTROL_AE_MODE,
CaptureRequest.
CONTROL_AE_MODE_ON_AUTO_FLASH)
// captureBuilder.set(CaptureRequest.FLASH_MODE,
// CameraMetadata.FLASH_MODE_OFF)
// Zoom
val cropRect = calcCropRect()
captureBuilder.set(CaptureRequest.
SCALER_CROP_REGION, cropRect)
// Orientation
captureBuilder.set(CaptureRequest.
JPEG_ORIENTATION,
previewDim.getOrientation(rotation))
return captureBuilder.build()
}
private fun buildPreCaptureRequest() :
CaptureRequest {
val surface = imageReader.surface
val reqBuilder =
cameraDevice.createCaptureRequest(
CameraDevice.TEMPLATE_STILL_CAPTURE)
reqBuilder.addTarget(surface)
reqBuilder.set(CaptureRequest.
CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest. CONTROL_AE_PRECAPTURE_TRIGGER_START)
return reqBuilder.build()
}
private fun buildLockFocusRequest() :
CaptureRequest {
val surface = imageReader.surface
val reqBuilder =
cameraDevice.createCaptureRequest(
CameraDevice.TEMPLATE_STILL_CAPTURE)
reqBuilder.addTarget(surface)
reqBuilder.set(CaptureRequest.
CONTROL_AF_TRIGGER,
CameraMetadata.CONTROL_AF_TRIGGER_START)
return reqBuilder.build()
}
private fun buildCancelTriggerRequest() :
CaptureRequest {
val texture = textureView.getSurfaceTexture()
val surface = Surface(texture)
val reqBuilder =
cameraDevice.createCaptureRequest(
CameraDevice.TEMPLATE_PREVIEW)
reqBuilder.addTarget(surface)
reqBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER,
CameraMetadata.CONTROL_AF_TRIGGER_CANCEL)
return reqBuilder.build()
}
捕捉静态图片由方法captureStillPicture()
处理。注意,像许多其他与相机相关的功能一样,适当的任务被发送到后台,回调处理后台处理结果。
private fun captureStillPicture() {
val captureRequest =
buildTakePictureCaptureRequest()
if (captSess != null) {
try {
val captureCallback = object :
CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult) {
//Util.showToast(activity,
//"Acquired still image")
stillImageTakenConsumers.forEach {
it() }
unlockFocusAndBackToPreview()
}
}
captSess?.run {
stopRepeating()
capture(captureRequest,
captureCallback, null)
}
} catch (e: Exception) {
Log.e(LOG_KEY,
"Cannot capture picture", e)
}
}
}
private fun lockFocusOrTakePicture() {
if(cameraHasAutoFocus) {
captSess?.run {
try {
val captureRequest =
buildLockFocusRequest()
mState = State.STATE_WAITING_LOCK
capture(captureRequest,
captureCallback,
handler)
} catch (e: Exception) {
Log.e(LOG_KEY,
"Cannot lock focus", e)
}
}
} else {
if(cameraHasAutoExposure) {
mState = State.STATE_WAITING_PRECAPTURE
runPrecaptureSequence()
} else {
mState = State.STATE_PICTURE_TAKEN
captureStillPicture()
}
}
}
/**
* Unlock the focus. This method should be called when
* still image capture sequence is finished.
*/
private fun unlockFocusAndBackToPreview() {
captSess?.run {
try {
mState = State.STATE_PREVIEW
val cancelAfTriggerRequest =
buildCancelTriggerRequest()
val previewRequest =
buildPreviewCaptureRequest()
capture(cancelAfTriggerRequest,
captureCallback,
handler)
setRepeatingRequest(previewRequest,
captureCallback,
handler)
} catch (e: Exception) {
Log.e(LOG_KEY,
"Cannot go back to preview mode", e)
}
}
}
运行捕捉静止图像的预捕捉序列由方法runPrecaptureSequence()
执行。当我们在captureCallback
中从方法lockFocusThenTakePicture()
得到响应时,应该调用这个方法。
/**
* Run the precapture sequence for capturing a still
* image.
*/
private fun runPrecaptureSequence() {
try {
captSess?.run {
val captureRequest = buildPreCaptureRequest()
mState = State.STATE_WAITING_PRECAPTURE
capture(captureRequest, captureCallback,
handler)
}
} catch (e: Exception) {
Log.e(LOG_KEY, "Cannot access camera", e)
}
}
private fun calcCropRect(): Rect {
with(activeArraySize) {
val cropW = width() / zoom
val cropH = height() / zoom
val top = centerY() - (cropH / 2f).toInt()
val left = centerX() - (cropW / 2f).toInt()
val right = centerX() + (cropW / 2f).toInt()
val bottom = centerY() + (cropH / 2f).toInt()
return Rect(left, top, right, bottom)
}
}
}
这里有一些关于CameraSession
类的注释:
-
模拟器不具备自动对焦功能。守则会照顾到这一点。
-
术语预捕捉只是自动曝光的另一个名称。
-
使用闪光灯是这门课的一个
todo
。要启用 flash,请查看代码中提到 flash 的地方。 -
借助于从
CameraSession
开始的监听器链,静止图像捕获数据最终到达MainActivity
的dataArrived(...)
方法。在那里,您可以开始编写进一步的处理算法,如保存、发送、转换、读取等。
安卓和 NFC
如果 Android 设备有 NFC 适配器,NFC 适配器允许与其他支持 NFC 的设备或 NFC 标签进行短程无线通信。我们在第十二章谈到了 NFC。
安卓和蓝牙
大多数(如果不是全部的话)现代安卓设备都内置了蓝牙。通过蓝牙,他们可以与其他蓝牙设备进行无线通信。详情请参见第十二章。
安卓传感器
Android 设备向应用提供关于其环境的各种信息,如下所示:
-
由罗盘或陀螺仪确定的方向
-
由加速力引起的运动
-
重力
-
气温、气压、湿度
-
照明
-
接近度,例如找出到用户耳朵的距离
传感器检测不到设备的确切地理空间位置。关于使用 GPS 检测位置坐标,请参见第八章。
检索传感器功能
从 Android 4.0 (API level 14)开始,Android 设备应该提供各种android
定义的所有传感器类型。hardware
。Sensor.TYPE_*
常数。要查看所有传感器的列表,包括关于它们的各种信息,请使用以下代码片段:
val sensorManager = getSystemService(
Context.SENSOR_SERVICE) as SensorManager
val deviceSensors =
sensorManager.getSensorList(Sensor.TYPE_ALL)
deviceSensors.forEach { sensor ->
Log.e("LOG", "+++" + sensor.toString())
}
要获取某个传感器,请使用以下命令:
val magneticFieldSensor = sensorManager.getDefaultSensor(
Sensor.TYPE_MAGNETIC_FIELD)
一旦你有了一个Sensor
对象,你就可以获得关于它的各种信息。详见android.hardware.Sensor
的 API 文档。要找出传感器值,请参见下一节。
监听传感器事件
Android 支持以下两种传感器事件监听器:
-
传感器精度的变化
-
传感器值的变化
要注册一个监听器,获取传感器管理器和传感器,如前一节所述,然后在活动中使用如下内容:
val sensorManager = getSystemService(
Context.SENSOR_SERVICE) as SensorManager
val magneticFieldSensor = sensorManager.getDefaultSensor(
Sensor.TYPE_MAGNETIC_FIELD)
sensorManager.registerListener(this,
magneticFieldSensor,
SensorManager.SENSOR_DELAY_NORMAL)
对于时间分辨率,您也可以使用其他延迟规格之一:SensorManager.SENSOR_DELAY_*
。
然后,活动必须覆盖android.hardware.SensorEventListener
并实现它。
class MainActivity : AppCompatActivity(),
SensorEventListener {
private lateinit var sensorManager:SensorManager
private lateinit var magneticFieldSensor:Sensor
override fun onCreate(savedInstanceState: Bundle?) {
...
sensorManager =
getSystemService(Context.SENSOR_SERVICE)
as SensorManager
magneticFieldSensor =
sensorManager.getDefaultSensor(
Sensor.TYPE_MAGNETIC_FIELD)
}
override
fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
// Do something here if sensor accuracy changes.
}
override
fun onSensorChanged(event: SensorEvent) {
Log.e("LOG", Arrays.toString(event.values))
// Do something with this sensor value.
}
override
fun onResume() {
super.onResume()
sensorManager.registerListener(this,
magneticFieldSensor,
SensorManager.SENSOR_DELAY_NORMAL)
}
override
fun onPause() {
super.onPause()
sensorManager.unregisterListener(this)
}
}
如此例所示,当不再需要传感器事件侦听器时,将其注销是很重要的,因为传感器可能会耗尽电池电量。
注意
顾名思义,onSensorChanged
事件可能会被触发,即使传感器值并没有真正发生变化。
你从onSensorChanged()
内的SensorEvent.values
获得的所有可能的传感器值都列在表 13-3 中。
表 13-3
传感器事件值
|类型
|
价值观念
|
| --- | --- |
| TYPE_ACCELEROMETER
| 矢量 3:沿 x-y-z 轴的加速度,单位为米 / 秒 秒。包括重力。 |
| TYPE_AMBIENT_TEMPERATURE
| 标量:环境空气温度,单位为摄氏度。 |
| TYPE_GRAVITY
| 矢量 3:沿 x-y-z 轴的重力在 m / s 2 方向。 |
| TYPE_GYROSCOPE
| 向量 3:绕 x-y-z 轴的旋转速率,单位为拉德 / 秒。 |
| TYPE_LIGHT
| 标量:照度,单位为 lx 。 |
| TYPE_LINEAR_ACCELERATION
| 矢量 3:沿 x-y-z 轴的加速度,单位为米 / 秒 秒。没有重力。 |
| TYPE_MAGNETIC_FIELD
| Vector3:地磁场的强度,单位为 μT 。 |
| TYPE_ORIENTATION
| 向量 3:方位角,俯仰角,滚动角度。 |
| TYPE_PRESSURE
| 标量:以 hP a 为单位的环境空气压力。 |
| TYPE_PROXIMITY
| 标量:距离物体的距离,单位为厘米。 |
| TYPE_RELATIVE_HUMIDITY
| 标量:以%表示的环境相对湿度。 |
| TYPE_ROTATION_VECTOR
| 向量 4:四元数形式的旋转向量。 |
| TYPE_SIGNIFICAT_MOTION
| 每次检测到重大运动时,都会触发该事件。要参加此次活动,您必须通过SensorManager.requestTriggerSensor(...)
注册。 |
| TYPE_STEP_COUNTER
| 标量:自重新启动和传感器激活后的累计步数。 |
| TYPE_STEP_DETECTOR
| 每次检测到一个步骤时都会触发该事件。 |
| TYPE_TEMPERATURE
| 已弃用。标量:设备的温度,单位为摄氏度。 |
一些传感器具有未校准版本,这意味着它们显示变化更准确,但与固定点相关的精度较低:
-
TYPE_ACCELEROMETER_UNCALIBRATED
-
TYPE_GYROSCOPE_UNCALIBRATED
-
TYPE_MAGNETIC_FIELD_UNCALIBRATED.
除了TYPE_ROTATION_VECTOR
传感器,您还可以使用以下传感器之一:
-
TYPE_GAME_ROTATION_VECTOR
-
TYPE_GEOMAGNETIC_ROTATION_VECTOR
第一种不使用陀螺仪,对于探测变化更精确,但是对于找出北在哪里不是很精确。第二种使用磁场而不是陀螺仪;它不太准确,但也需要较少的电池电量。
与电话互动
Android 允许多种方式来与呼入或呼出电话以及拨号过程进行交互。以下是您的应用可能为电话实现的最突出的用例:
-
监控电话的状态变化,如通知来电和去电
-
启动拨号过程以开始拨出电话
-
为管理呼叫提供自己的用户界面
您可以在包android.telecom
和android.telephony
及其子包中找到电话相关的类和接口。
监控电话状态变化
要监控电话状态变化,请将以下权限添加到AndroidManifest.xml
:
<uses-permission android:name=
"android.permission.READ_PHONE_STATE" />
<uses-permission android:name=
"android.permission.PROCESS_OUTGOING_CALLS"/>
READ_PHONE_STATE
权限允许您检测正在进行的通话的状态。PROCESS_OUTGOING_CALLS
权限允许你的应用查看呼出电话的数量,甚至使用不同的号码或取消通话。
要了解如何从您的应用中获取权限,请参见第七章。
为了收听与电话相关的事件,您可以在AndroidManifest.xml
中添加一个广播接收器。
<application>
...
<receiver android:name=".CallMonitor">
<intent-filter>
<action android:name=
"android.intent.action.PHONE_STATE" />
</intent-filter>
<intent-filter>
<action android:name=
"android.intent.action.NEW_OUTGOING_CALL" />
</intent-filter>
</receiver>
</application>
例如,您可以按如下方式实现它:
package ...
import android.telephony.TelephonyManager as TM
import ...
class CallMonitor : BroadcastReceiver() {
companion object {
private var lastState = TM.CALL_STATE_IDLE
private var callStartTime: Date? = null
private var isIncoming: Boolean = false
private var savedNumber: String? = null
}
onReceive()
回调处理传入的广播,这次是传入或传出的呼叫。
override
fun onReceive(context: Context, intent: Intent) {
if (intent.action ==
Intent.ACTION_NEW_OUTGOING_CALL) {
savedNumber = intent.extras!!.
getString(Intent.EXTRA_PHONE_NUMBER)
} else {
val stateStr = intent.extras!!.
getString(TM.EXTRA_STATE)
val number = intent.extras!!.
getString(TM.EXTRA_INCOMING_NUMBER)
val state = when(stateStr) {
TM.EXTRA_STATE_IDLE ->
TM.CALL_STATE_IDLE
TM.EXTRA_STATE_OFFHOOK ->
TM.CALL_STATE_OFFHOOK
TM.EXTRA_STATE_RINGING ->
TM.CALL_STATE_RINGING
else -> 0
}
callStateChanged(context, state, number)
}
}
protected fun onIncomingCallReceived(
ctx: Context, number: String?, start: Date){
Log.e("LOG",
"IncomingCallReceived ${number} ${start}")
}
protected fun onIncomingCallAnswered(
ctx: Context, number: String?, start: Date) {
Log.e("LOG",
"IncomingCallAnswered ${number} ${start}")
}
protected fun onIncomingCallEnded(
ctx: Context, number: String?,
start: Date?, end: Date) {
Log.e("LOG",
"IncomingCallEnded ${number} ${start}")
}
protected fun onOutgoingCallStarted(
ctx: Context, number: String?, start: Date) {
Log.e("LOG",
"OutgoingCallStarted ${number} ${start}")
}
protected fun onOutgoingCallEnded(
ctx: Context, number: String?,
start: Date?, end: Date) {
Log.e("LOG",
"OutgoingCallEnded ${number} ${start}")
}
protected fun onMissedCall(
ctx: Context, number: String?, start: Date?) {
Log.e("LOG",
"MissedCall ${number} ${start}")
}
私有方法callStateChanged()
对与电话呼叫相对应的各种状态变化做出反应。
/**
* Incoming call:
* IDLE -> RINGING when it rings,
* -> OFFHOOK when it's answered,
* -> IDLE when its hung up
* Outgoing call:
* IDLE -> OFFHOOK when it dials out,
* -> IDLE when hung up
* */
private fun callStateChanged(
context: Context, state: Int, number: String?) {
if (lastState == state) {
return // no change in state
}
when (state) {
TM.CALL_STATE_RINGING -> {
isIncoming = true
callStartTime = Date()
savedNumber = number
onIncomingCallReceived(
context, number, callStartTime!!)
}
TM.CALL_STATE_OFFHOOK ->
if (lastState != TM.CALL_STATE_RINGING) {
isIncoming = false
callStartTime = Date()
onOutgoingCallStarted(context,
savedNumber, callStartTime!!)
} else {
isIncoming = true
callStartTime = Date()
onIncomingCallAnswered(context,
savedNumber, callStartTime!!)
}
TM.CALL_STATE_IDLE ->
if (lastState == TM.CALL_STATE_RINGING) {
//Ring but no pickup- a miss
onMissedCall(context,
savedNumber, callStartTime)
} else if (isIncoming) {
onIncomingCallEnded(context,
savedNumber, callStartTime,
Date())
} else {
onOutgoingCallEnded(context,
savedNumber, callStartTime,
Date())
}
}
lastState = state
}
}
使用这样的监听器,您可以收集有关电话使用的统计信息,创建优先电话号码列表,或者做其他有趣的事情。要连接带有联系信息的电话,请参见第八章。
Initiate a Dialing Process
要从您的应用中启动拨号过程,您基本上有两种选择。
-
开始拨号过程;用户可以看到并更改被叫号码。
-
开始拨号过程;用户不能改变被叫号码。
对于第一种情况,向用户显示号码并让他们更改,您不需要任何特殊权限。只需写下以下内容:
val num = "+34111222333"
val intent = Intent(Intent.ACTION_DIAL,
Uri.fromParts("tel", num, null))
startActivity(intent)
要用指定的号码开始拨号,您需要以下权限作为附加权限:
<uses-permission android:name=
"android.permission.CALL_PHONE" />
要了解如何获得它,请参见第七章。然后,可以通过以下方式启动呼叫过程:
val num = "+34111222333"
val intent = Intent(Intent.ACTION_CALL,
Uri.fromParts("tel", num, null))
startActivity(intent)
创建电话呼叫自定义用户界面
创建你自己的电话呼叫活动,包括它自己的 UI,在 Android 文档的在线页面“构建一个呼叫应用”中有描述。
指纹认证
指纹认证随着 Android 6.0 版(API 级)进入 Android 框架。在此之前,您必须使用特定于供应商的 API。以下假设你针对的是 Android 6.0 或更新版本。
只有当用户的设备有指纹扫描仪时,使用指纹扫描仪才有意义。要检查是否是这种情况,请使用以下代码片段:
val useFingerprint =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
(getSystemService(Context.FINGERPRINT_SERVICE)
as FingerprintManager).let {
it.isHardwareDetected &&
it.hasEnrolledFingerprints()
}
} else false
这在 Android P 中已被否决。从 Android P 开始的替代方法是尝试执行一个认证并捕捉适当的错误消息。
现在要开始一个指纹认证过程,你首先必须决定是使用现在已经废弃的FingerPrintManager
类还是从 Android P 开始的新的FingerprintDialog
。
要使用不赞成使用的FingerPrintManager
类进行身份验证,您可以提供一个回调,然后在其上调用authenticate(...)
方法。
val mngr = getSystemService(Context.FINGERPRINT_SERVICE)
as FingerprintManager
val cb = object :
FingerprintManager.AuthenticationCallback() {
override
fun onAuthenticationSucceeded(
result: FingerprintManager.AuthenticationResult) {
...
}
override
fun onAuthenticationFailed() {
...
}
}
val cs = CancellationSignal()
mngr.authenticate(null, cs, 0, cb, null)
要使用FingerprintDialog
,您可以类似地启动一个认证过程,调用authenticate()
并对认证结果做出适当的反应。
注意
截至 2018 年 4 月,只存在一个FingerprintDialog
的开发者预览版。
十四、测试
关于信息技术中的测试已经说了很多。在过去的几十年中,测试获得了广泛的关注,这有三个原因。
-
测试是开发者和用户之间的接口。
-
测试在某种程度上是可以被设计的。
-
测试有助于增加利润。
开发人员往往对他们的软件有偏见。这么说并没有冒犯的意思。很自然,如果你在某个主题上花了很多时间,你可能会失去预测新用户脑子里在想什么的能力。因此,强烈建议定期走出你的开发人员角色,问自己这样一个问题,“假设我对应用一无所知,如果我进入这个 GUI 工作流,它是否有意义,它是否容易遵循,是否很难犯不可恢复的错误?”测试对此有所帮助。它迫使开发人员扮演最终用户的角色,并问这个问题。
发展远不是一门工业工程科学。这是好消息也是坏消息。如果它有一个强大的工程路径,那么遵循一致同意的开发模式会更容易,其他开发人员也会更容易理解你在做什么。另一方面,不那么精确的可工程化也为更多的创造力打开了领域,并允许开发成为一门艺术。如今的测试倾向于优先考虑可工程性。这源于这样一个事实,你可以精确地说出软件应该做什么,完全不知道一行代码。因此,部分测试并不关心事情是如何在编码层面上完成的,从而消除了开发需求如何得到满足的过多可能性。这对于低级别的单元测试来说是不正确的,但是即使对于那些单元测试,你也可以看到软件工件契约和测试方法的强烈重叠。因此,测试可操作性的等级比单纯的开发要高一些。然而,因为测试只是开发过程的一个方面,作为一名开发人员,仍然有可能拥有一份有趣的工作,同时生活在两个世界中。你可以在开发代码时成为艺术家,在编写测试时成为工程师。
在开发链的另一端,根据您的意图,您可能希望让最终用户为您的应用花费一些钱。测试显然有助于避免因为你没有预料到的错误而产生的挫折感,让公众更容易购买你的应用。
关于 Android 的测试已经说了很多,你可以在 Android 官方文档中找到很好的信息和入门或高级视频。本章的其余部分应该被看作是关于测试问题的建议和经验知识的集合。我不打算介绍测试,涵盖它的每一个方面,但是我希望我可以给你一个你自己更深入研究的起点。
单元测试
单元测试针对类级别,测试应用的底层功能。我所说的“功能性”是指单元测试通常检查方法调用的输入和输出之间的确定性关系,可能但不一定以确定、直接的方式包括类实例的状态变量。
标准单元测试
在 Android 环境中,标准单元测试的运行不依赖于设备硬件或任何 Android 框架类,因此可以在开发机器上执行。
它们通常对库有用,而不是 GUI 相关的功能,这就是为什么这种单元测试的适用性在某种程度上对大多数 Android 应用是有限的。
然而,如果您的应用的类包含方法调用,并且您可以在给定各种输入集的情况下预测调用结果,那么使用标准单元测试是有意义的。将单元测试添加到您的应用中很容易。事实上,如果你使用 Android Studio 开始一个新项目,单元测试已经为你设置好了,你甚至会得到一个样本测试类,如图 14-1 所示。
图 14-1
初始单元测试设置
因此,您可以立即开始使用该测试类作为例子来编写单元测试;只需在源代码的test
部分添加更多的测试类。
注意
虽然在技术上没有必要,但是一个常见的惯例是对测试类使用与被测试类相同的名称,加上Test
。所以,com.example.myapp.TheClass
的测试类应该叫做com.example.myapp.TheClassTest
。
要在 Android Studio 中运行单元测试,右键单击test
部分并选择 Run Tests in 或 Debug Tests in。
使用存根 Android 框架的单元测试
默认情况下,用于执行单元测试的 Gradle 插件包含一个 Android 框架的存根版本,每当调用 Android 类时都会抛出异常。
您可以通过将以下内容添加到应用的build.gradle
文件来更改此行为:
android {
...
testOptions {
unitTests.returnDefaultValues = true
}
}
任何对 Android 类的方法的调用都不做任何事情,并根据需要返回null
。
模拟 Android 框架的单元测试
如果您需要从单元测试内部访问 Android 类,并期望它们做真实的事情,使用社区支持的 Robolectric 框架作为单元测试实现是一个有效的选择。使用 Robolectric,您可以模拟点击按钮、读写文本以及许多其他与 GUI 相关的活动。尽管如此,所有这些都在您的开发机器上运行,这大大加快了测试速度。
要允许您的项目使用 Robolectric,请将以下内容添加到您的应用的build.gradle
文件中:
android {
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
...
//testImplementation 'junit:junit:4.12'
testImplementation "org.robolectric:robolectric:3.8"
}
例如,一个测试类模拟点击一个Button
,然后检查点击动作是否更新了一个TextView
,如下所示:
package com.example.robolectric
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowApplication
import android.content.Intent
import android.widget.Button
import android.widget.TextView
import org.junit.Test
import org.robolectric.Robolectric
import org.junit.Assert.*
@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
@Test
fun clickingGo_shouldWriteToTextView() {
val activity = Robolectric.setupActivity(
MainActivity::class.java!!)
activity.findViewById<Button>(R.id.go).
performClick()
assertEquals("Clicked",
activity.findViewById<TextView>(
R.id.tv).text)
}
}
通过右键单击test
部分并选择 Run Tests in 或 Debug Tests in,您可以像任何普通的单元测试一样开始那个测试。
更多测试选项和详情,请参见 Robolectric 主页 www.robolectric.org
。
模拟单元测试
模仿意味着您让测试挂钩到 Android OS 函数的调用,并通过模仿它们的功能来模拟它们的执行。
如果你想在单元测试中包含模仿,Android 开发者文档建议你使用 Mockito 测试库。我建议更进一步,使用 PowerMock,它位于 Mockito 之上,但增加了更多功能,比如模仿静态或最终类。
要启用 PowerMock,请将以下内容添加到您的应用的build.gradle
文件中(删除powermock:
后的换行符):
android {
...
testOptions {
unitTests.returnDefaultValues = true
}
}
dependencies {
...
testImplementation ('org.powermock:
powermock-mockito-release-full:1.6.1') {
exclude module: 'hamcrest-core'
exclude module: 'objenesis'
}
testImplementation 'org.reflections:reflections:0.9.11'
}
}
不要删除或注释掉dependencies
部分中的testImplementation 'junit:junit:4.12'
行,因为仍然需要它。unitTests.returnDefaultValues = true
条目负责单元测试的存根 Android 实现,以防万一,不抛出异常。reflections
包用于扫描包来搜索测试类。
作为一个重要的例子,我给出了一个向数据库写入条目的活动。我们将模拟实际的数据库实现,但仍然希望确保创建必要的表并执行insert
语句。该活动如下所示:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
fun save(v: View) {
saveInDb(et.text.toString())
}
fun count(v: View) {
val db = openOrCreateDatabase("MyDb",
MODE_PRIVATE, null)
with(db) {
val resultSet = rawQuery(
"Select * from MyItems", null)
val cnt = resultSet.count
Toast.makeText(this@MainActivity,
"Count: ${cnt}", Toast.LENGTH_LONG).
show()
}
db.close()
}
private fun saveInDb(item:String) {
val tm = System.currentTimeMillis() / 1000
val db = openOrCreateDatabase("MyDb",
MODE_PRIVATE, null)
with(db) {
execSQL("CREATE TABLE IF NOT EXISTS " +
"MyItems(Item VARCHAR,timestamp INT);")
execSQL("INSERT INTO MyItems VALUES(?,?);",
arrayOf(item, tm))
}
db.close()
}
}
相应的布局文件如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.powermock.MainActivity"
android:orientation="vertical">
<EditText
android:id="@+id/et"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""/>
<Button
android:id="@+id/btnSave"
android:text="Save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="save"/>
<Button
android:id="@+id/btnCount"
android:text="Count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="count"/>
</LinearLayout>
它包含一个 ID 为et
的EditText
视图元素和两个调用活动的方法save()
和count()
的按钮。
对于测试本身,在源代码的test
部分创建一个类MainActivityTest
。内容如下:
import android.database.sqlite.SQLiteDatabase
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatcher
import org.powermock.core.classloader.annotations.
PrepareForTest
import org.powermock.modules.junit4.PowerMockRunner
import org.mockito.BDDMockito.*
import org.mockito.Matchers
import org.powermock.reflect.Whitebox
@RunWith(PowerMockRunner::class)
@PrepareForTest(MainActivity::class)
class MainActivityTest {
@Test
fun table_created() {
val activity = MainActivity()
val activitySpy = spy(activity)
val db = mock(SQLiteDatabase::class.java)
// given
given(activitySpy.openOrCreateDatabase(
anyString(), anyInt(), any())).willReturn(db)
// when
Whitebox.invokeMethod<Unit>(
activitySpy,"saveInDb","hello")
// then
verify(db).execSQL(Matchers.argThat(
object : ArgumentMatcher<String>() {
override
fun matches(arg:Any):Boolean {
return arg.toString().matches(
Regex("(?i)create table.*\\bMyItems\\b.*"))
}
}))
}
@Test
fun item_inserted() {
val activity = MainActivity()
val activitySpy = spy(activity)
val db = mock(SQLiteDatabase::class.java)
// given
given(activitySpy.openOrCreateDatabase(
anyString(), anyInt(), any())).willReturn(db)
// when
Whitebox.invokeMethod<Unit>(
activitySpy,"saveInDb","hello")
// then
verify(db).execSQL(Matchers.argThat(
object : ArgumentMatcher<String>() {
override
fun matches(arg:Any):Boolean {
return arg.toString().matches(
Regex("(?i)insert into MyItems\\b.*"))
}
}), Matchers.argThat(
object : ArgumentMatcher<Array<Any>>() {
override
fun matches(arg:Any):Boolean {
val arr = arg as Array<Any>
return arr[0] == "hello" &&
arr[1] is Number
}
}))
}
}
@RunWith(PowerMockRunner::class)
将确保 PowerMock 被用作单元测试运行程序,并且@PrepareForTest(MainActivity::class)
准备了MainActivity
类,所以即使它被标记为final
(这是 Kotlin 默认的做法),它也可以被模拟。
第一个函数table_created()
应该确保在必要时创建表。它的作用如下:
-
我们实例化
MainActivity
,这是可能的,因为实例化不调用 Android 框架类。 -
我们将
MainActivity
实例包装成一个间谍。这允许我们挂钩方法调用来模拟实际的实现。 -
我们创建了一个对
SQLiteDatabase
的模拟,这样我们就可以在不实际使用真实数据库的情况下挂钩数据库操作。 -
接下来的
//given
、//when
和//then
部分遵循 BDD 开发风格。 -
在
//given
部分中,我们模拟出活动的openOrCreateDatabase()
调用,并让它返回我们的模拟数据库。 -
在
//when
内部,我们称活动类的private
方法为saveInDb()
。在测试开发中调用私有方法是不被允许的,但是在这里我们没有其他的机会,因为我们不能使用save()
方法并让它在没有更复杂工作的情况下访问EditText
视图。由于所有的模拟准备,这个调用到达了真实的 activity 类,但是将使用模拟的数据库而不是真实的数据库。 -
在
//then
部分,我们可以检查saveInDb()
的调用是否调用了适当的数据库操作来创建必要的表。为此,我们使用了一个ArgMatcher
,它允许我们检查适当的方法调用参数。
测试函数item_inserted()
做了几乎相同的事情,但是检查是否有合适的insert
语句被发送到数据库。
在 Android Studio 中使用 PowerMock 作为 Kotlin 的单元测试运行程序有一个缺点:通常你可以使用包的上下文菜单来运行所有的单元测试,但是由于某些原因,这对于 PowerMock 和 Kotlin 来说并不适用。作为一种变通方法,我将一个单独的测试类作为一个套件,调用它在包中可以找到的所有测试类。这就是我们在 Gradle 构建文件中添加testImplementation 'org.reflections:reflections:0.9.11'
的原因。
@RunWith(TestAll.TestAllRunner::class)
class TestAll {
class TestAllRunner(klass: Class<*>?,
runners0: List<Runner>) :
ParentRunner<Runner>(klass) {
private val runners: List<Runner>
constructor(clazz: Class<*>) : t
his(clazz, listOf<Runner>()) {
}
init {
val classLoadersList = arrayOf(
ClasspathHelper.contextClassLoader(),
ClasspathHelper.staticClassLoader())
val reflections = Reflections(
ConfigurationBuilder()
.setScanners(SubTypesScanner(false),
TypeAnnotationsScanner())
.setUrls(ClasspathHelper.
forClassLoader(
*classLoadersList))
.filterInputsBy(FilterBuilder().
include(FilterBuilder.
prefix(
javaClass.`package`.name))))
runners = reflections.getTypesAnnotatedWith(
RunWith::class.java).filter {
clazz ->
clazz.getAnnotation(RunWith::class.java).
value.toString().
contains(".PowerMockRunner")
}.map { PowerMockRunner(it) }
}
override fun getChildren(): List<Runner> = runners
override fun describeChild(child: Runner):
Description = child.description
override fun runChild(runner: Runner,
notifier: RunNotifier) {
runner.run(notifier)
}
}
}
这个类提供了自己的测试运行器实现,它使用init ...
块中的reflections
库来扫描包中的测试类。您现在可以在这个TestAll
类上运行测试,它将依次运行它在包中找到的所有测试类。
集成测试
集成测试介于在开发机器上进行细粒度测试的单元测试和在真实或虚拟设备上运行的成熟的用户界面测试之间。集成测试也在设备上运行,但它们并不测试整个应用,而是在一个隔离的执行环境中测试选定的组件。
集成测试发生在源代码的androidTest
部分。你还需要在应用的build.gradle
文件中添加几个包,如下所示(去掉androidTestImplementation
后的换行符):
dependencies {
...
androidTestImplementation
'com.android.support:support-annotations:27.1.1'
androidTestImplementation
'com.android.support.test:runner:1.0.2'
androidTestImplementation
'com.android.support.test:rules:1.0.2'
}
测试服务
要测试具有绑定的服务,请编写如下代码:
@RunWith(AndroidJUnit4::class)
class ServiceTest {
// A @Rule wraps around the test invocation - here we
// use the 'ServiceTestRule' which makes sure the
// service gets started and stopped correctly.
@Rule @JvmField
val mServiceRule = ServiceTestRule()
@Test
fun testWithBoundService() {
val serviceIntent = Intent(
InstrumentationRegistry.getTargetContext(),
MyService::class.java
).apply {
// If needed, data can be passed to the
// service via the Intent.
putExtra("IN_VAL", 42L)
}
// Bind the service and grab a reference to the
// binder.
val binder: IBinder = mServiceRule.
bindService(serviceIntent)
// Get the reference to the service
val service: MyService =
(binder as MyService.MyBinder).getService()
// Verify that the service is working correctly.
assertThat(service.add(11,27), `is`(38))
}
}
这用一个add(Int, Int)
服务方法测试了一个名为MyService
的简单服务。
class MyService : Service() {
class MyBinder(val servc:MyService) : Binder() {
fun getService():MyService {
return servc
}
}
private val binder: IBinder = MyBinder(this)
override fun onBind(intent: Intent): IBinder = binder
fun add(a:Int, b:Int) = a + b
}
要运行集成测试,右键单击源代码的androidTest
部分,并选择 Run Tests in。这将创建并上传一个 APK 文件,通过InstrumentationRegistry.getTargetContext()
创建一个集成测试上下文,然后在设备上运行测试。
测试意向服务
除了官方文档声明,基于IntentService
类的服务也可以进行集成测试。您不能使用@Rule ServiceTestRule
来处理服务生命周期,因为意向服务有自己的何时开始和停止的想法。但是你可以自己处理生命周期。作为一个例子,我给出了一个简单意向服务的测试,该服务工作 10 秒钟,并通过一个ResultReceiver
不断发回数据。
服务本身如下所示:
class MyIntentService() :
IntentService("MyIntentService") {
class MyResultReceiver(val cb: (Double) -> Unit) :
ResultReceiver(null) {
companion object {
val RESULT_CODE = 42
val INTENT_KEY = "my.result.receiver"
val DATA_KEY = "data.key"
}
override
fun onReceiveResult(resultCode: Int,
resultData: Bundle?) {
super.onReceiveResult(resultCode, resultData)
val d = resultData?.get(DATA_KEY) as Double
cb(d)
}
}
var status = 0.0
override fun onHandleIntent(intent: Intent) {
val myReceiver = intent.
getParcelableExtra<ResultReceiver>(
MyResultReceiver.INTENT_KEY)
for (i in 0..100) {
Thread.sleep(100)
val bndl = Bundle().apply {
putDouble(MyResultReceiver.DATA_KEY,
i * 0.01)
}
myReceiver.send(MyResultReceiver.RESULT_CODE, bndl)
}
}
}
这里是测试类,也在源代码的androidTest
部分:
@RunWith(AndroidJUnit4::class)
class MyIntentServiceTest {
@Test
fun testIntentService() {
var serviceVal = 0.0
val ctx = InstrumentationRegistry.
getTargetContext()
val serviceIntent = Intent(ctx,
MyIntentService::class.java
).apply {
`package`= ctx.packageName
putExtra(
MyIntentService.MyResultReceiver.
INTENT_KEY,
MyIntentService.MyResultReceiver( { d->
serviceVal = d
}))
}
ctx.startService(serviceIntent)
val tm0 = System.currentTimeMillis() / 1000
var ok = false
while(System.currentTimeMillis() / 1000 - tm0
< 20) {
if(serviceVal == 1.0) {
ok = true
break
}
Thread.sleep(1000)
}
assertThat(ok, `is`(true))
}
}
这个测试调用服务,监听一会儿它的结果,当它检测到服务按预期完成了它的工作时,让测试通过。
测试内容供应器
为了测试内容供应器,Android 提供了一个名为ProviderTestCase2
的特殊类,它启动一个隔离的临时环境,因此测试不会干扰用户的数据。例如,一个测试用例如下所示:
@RunWith(AndroidJUnit4::class)
class MyContentProviderTest :
ProviderTestCase2<MyContentProvider>(
MyContentProvider::class.java,
"com.example.database.provider.MyContentProvider") {
@Before public override // "public" necessary!
fun setUp() {
context = InstrumentationRegistry.
getTargetContext()
super.setUp()
val mockRslv: ContentResolver = mockContentResolver
mockRslv.delete(MyContentProvider.CONTENT_URI,
"1=1", arrayOf())
}
@Test
fun test_inserted() {
val mockCtx: Context = mockContext
val mockRslv: ContentResolver = mockContentResolver
// add an entry
val cv = ContentValues()
cv.put(MyContentProvider.COLUMN_PRODUCTNAME,
"Milk")
cv.put(MyContentProvider.COLUMN_QUANTITY,
27)
val newItem = mockRslv.insert(
MyContentProvider.CONTENT_URI, cv)
// query all
val cursor = mockRslv.query(
MyContentProvider.CONTENT_URI,
null, null, null)
assertThat(cursor.count, `is`(1))
cursor.moveToFirst()
val ind = cursor.getColumnIndex(
MyContentProvider.COLUMN_PRODUCTNAME)
assertThat(cursor.getString(ind), `is`("Milk"))
}
}
内容供应器对使用的列名、权限和 URI 有偏见。对于测试用例来说,重要的是使用模拟的内容解析器与内容提供者对话。
注意
注意setUp()
中前两行的顺序。这和你在 2018 年 5 月起的安卓开发者文档里能读到的不一样。这里的医生是错的。
测试广播接收器
对于测试广播接收机,Android 测试框架并不特别关注。被测广播接收机实际做什么也很重要。假设它会产生某种副作用,例如向数据库中写入一些东西,那么您可以使用我们之前为内容提供者使用的相同测试上下文来模拟该数据库操作。
例如,如果您从androidTest
源部分中查看下面的测试用例:
import android.support.test.InstrumentationRegistry
import android.support.test.runner.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.hamcrest.Matchers.*
import android.content.Intent
@RunWith(AndroidJUnit4::class)
class BroadcastTest {
@Test
fun testBroadcastReceiver() {
val context = InstrumentationRegistry.
getTargetContext()
val intent = Intent(context,
MyReceiver::class.java)
intent.putExtra("data", "Hello World!")
context.sendBroadcast(intent)
// Give the receiver some time to do its work
Thread.sleep(5000)
// Check the DB for the entry added
// by the broadcast receiver
val db = MyDBHandler(context)
val p = db.findProduct("Milk")
assertThat(p, isA(Product::class.java))
assertThat(p!!.productName, `is`("Milk"))
}
}
你可以看到我们使用了由InstrumentationRegistry.getTargetContext()
提供的上下文。这将确保广播接收机和稍后的测试所使用的数据库为其数据使用临时空间。
您可以像启动任何其他集成测试一样启动此测试,方法是右键单击它或它所在的包,然后选择 Run 或 Run Tests in。
用户界面测试
进行用户界面测试,你可以研究用户故事,看看你的应用作为一个整体是否像预期的那样运行。这是两个框架:
-
表达
使用 Espresso 编写针对您的应用的测试,忽略任何 interapp 活动。使用 Espresso,您可以做一些事情,例如当某个
View
(Button
、TextView
、EditText
等)出现时,做一些事情(输入文本,执行点击),然后您可以检查是否出现一些后置条件。 -
UI 自动机
使用 UI Automator 编写跨多个应用的测试。使用 UI Automator,您可以检查布局以找出活动的 UI 结构,模拟活动的动作,并检查 UI 元素。
有关如何使用它们的详细信息,请参考在线文档。例如,在您最喜欢的搜索引擎中输入 android 自动化 ui 测试来查找资源。
十五、故障排除
在前一章,我们讨论了测试你的应用的方法。如果测试失败,日志通常会告诉你到底发生了什么,如果这还不够,你可以扩展应用的日志来查看哪里出错了。
但是,即使有最好的测试概念,你的应用仍然有可能不像预期的那样运行。首先,从功能角度来看,它有时可能做不了正确的事情。其次,从非功能的角度来看,它可能表现不佳,这意味着随着时间的推移,它会耗尽内存资源,或者在速度方面表现不佳。
在这一章中,我们将讨论修复你的应用可能暴露的问题的技术。我们将讨论日志记录、调试和监控,以及 Android Studio 和 SDK 中的工具在这些主题方面对我们的帮助。
记录
登录 Android 很容易;您只需导入android.util.Log
,并在代码中编写类似Log.e("LOG", "Message")
的语句来发布日志消息。Android Studio 随后会帮助您收集、过滤和分析日志记录。
虽然使用这种日志功能进行开发非常方便,但是在发布应用时,就会出现问题。您不希望影响应用的性能,文档建议删除所有日志记录,这基本上等于否定了您在日志记录中所做的所有工作。如果您的用户后来报告了问题,您可以添加日志记录语句进行故障排除,在修复完成后再删除它们,以此类推。
为了纠正这个过程,我建议从一开始就在日志记录周围添加一个简单的包装器。
class Log {
companion object {
fun v(tag: String, msg: String) {
randroid.util.Log.v(tag, msg)
}
fun v(tag: String, msg: String, tr: Throwable) {
android.util.Log.v(tag, msg, tr)
}
fun d(tag: String, msg: String) {
android.util.Log.d(tag, msg)
}
fun d(tag: String, msg: String, tr: Throwable) {
android.util.Log.d(tag, msg, tr)
}
fun i(tag: String, msg: String) {
android.util.Log.i(tag, msg)
}
fun i(tag: String, msg: String, tr: Throwable) {
android.util.Log.i(tag, msg, tr)
}
fun w(tag: String, msg: String) {
android.util.Log.w(tag, msg)
}
fun w(tag: String, msg: String, tr: Throwable) {
android.util.Log.w(tag, msg, tr)
}
fun w(tag: String, tr: Throwable) {
android.util.Log.w(tag, tr)
}
fun e(tag: String, msg: String) {
android.util.Log.e(tag, msg)
}
fun e(tag: String, msg: String, tr: Throwable) {
android.util.Log.e(tag, msg, tr)
}
}
}
然后,您可以使用与 Android 标准相同的简单日志记录符号,但是以后您可以自由地更改日志记录实现,而不需要修改其余的代码。例如,您可以添加一个简单的开关,如下所示:
class Log {
companion object {
val ENABLED = true
fun v(tag: String, msg: String) {
if(!ENABLED) return
// <- add this to all the other statements
android.util.Log.v(tag, msg)
}
...
}
}
或者,您可以只为虚拟设备启用日志记录。不幸的是,没有简单可靠的方法来确定你的应用是否运行在虚拟设备上。博客中介绍的所有解决方案都有其优点和缺点,并且可能会因新的 Android 版本而发生变化。相反,您可以做的是将构建变量传输到您的应用。为此,在您的应用的build.gradle
文件中添加以下内容:
buildTypes {
release {
...
buildConfigField "boolean", "LOG", "false"
}
debug {
...
buildConfigField "boolean", "LOG", "true"
}
}
然后,在日志实现中,您只需替换以下内容:
val ENABLED = BuildConfig.LOG
它为调试 apk 打开日志记录,否则关闭日志记录。
使用完全不同的日志实现也是可能的。例如,要将日志切换到 Log4j,请在应用的build.gradle
文件的dependencies
部分添加以下内容(删除implementation
后的换行符):
implementation
'de.mindpipe.android:android-logging-log4j:1.0.3'
implementation
'log4j:log4j:1.2.17'
要实际配置日志记录,请在您的自定义Log
类中添加以下内容:
companion object {
...
private val mLogConfigrator = LogConfigurator().apply {
fileName = Environment.
getExternalStorageDirectory().toString() +
"/" + "log4j.log"
maxFileSize = (1024 * 1024).toLong()
filePattern = "%d - [%c] - %p : %m%n"
maxBackupSize = 10
isUseLogCatAppender = true
configure()
}
private var ENABLED = true // or, see above
// private var ENABLED = BuildConfig.LOG
fun v(tag: String, msg: String) {
if(!ENABLED) return
Logger.getLogger(tag).trace(msg)
// <- add similar lines to all the other
// statements
}
...
}
这个例子将日志写到由Environment.getExternalStorageDirectory()
返回的目录中,这个目录在设备上通常映射到/sdcard
。你也可以在其他地方这样做。如果您使用这里显示的外部存储器,不要忘记检查并可能获得适当的写权限!更准确地说,您需要在您的AndroidManifest.xml
文件中包含以下内容:
<uses-permission android:name=
"android.permission.WRITE_EXTERNAL_STORAGE"/>
一旦你的应用开始记录到设备内部的文件,你就可以通过使用文件浏览器从 Android Studio 内部轻松访问日志文件。通过查看➤工具 Windows ➤设备文件资源管理器启动它。然后双击打开日志文件,如图 15-1 所示。
图 15-1
访问设备上的日志文件
提高性能的最后一个方法是使用 lambdas 进行日志记录活动。为此,在您的自定义记录器中使用如下记录方法:
fun v(tag: String, msg: ()->String) {
if(!ENABLED) return
Logger.getLogger(tag).trace(msg.invoke())
}
... similar for the other statements
然后,在代码中发出如下日志消息:
Log.v("LOG",
{-> "Number of items added = " + calculate()})
这种方法的优点是,如果未启用日志记录,则不会计算日志记录消息,从而为应用的生产版本增加了一些性能提升。
排除故障
从 Android Studio 内部调试就不多说了;它只是像预期的那样工作。
您在代码中设置断点,一旦程序流到达断点,您就可以单步执行程序的其余部分,并观察程序做什么以及变量如何更改它们的值。
性能监控
Android Studio 有一个非常强大的性能监视器,可以让您分析到方法级别的性能问题。要使用它,您必须首先找到一种方法,在循环中运行易受性能问题影响的那部分代码。您可以尝试使用测试来实现这一点,但是在代码中临时添加人工循环也是可行的。
然后,随着循环的运行,在 Android Studio 中打开视图➤工具 Windows ➤ Android Profiler。分析器首先抱怨高级分析没有启用,如图 15-2 所示。
图 15-2
高级分析警报
通过单击蓝色的运行配置链接来启用它。确保选中该框,如图 15-3 所示,然后点击确定。
图 15-3
高级分析设置
轮廓监视器随即出现,如图 15-4 所示。除了 CPU 分析,它还包含内存使用分析和网络监视器。
图 15-4
剖面仪通道
在那里,单击 CPU 通道将视图缩小到您在图 15-5 中看到的性能监视器图。
图 15-5
CPU 分析部分
滚动下方窗格中的线程,然后可以尝试查找可疑的线程。对于我在这里运行的示例,您可以看到 Thread-4 做了相当多的工作。将其重命名为 PiCalcThread(app 计算 pi)然后点击会显示一条信息,提示还没有捕获到数据,如图 15-6 所示。
图 15-6
CPU 分析线程
在窗格的顶部,可以看到捕捉控件,如图 15-7 所示。
图 15-7
CPU 分析捕获控件
对于我们即将开始的捕获,您可以从以下选项中进行选择:
-
Sampled (Java) :选择此项以定期捕获应用的调用堆栈。这是侵入性最小的捕捉方式,你通常会选择这个。
-
Instrumented (Java): 选择此项为应用中的每个方法调用的收集数据。这本身将引入高性能影响,并将收集大量数据。如果样本变量不能提供足够的信息,请选择此项。
-
Sampled (Native) :这个只能在 Android 8 (API 等级 26)开始的设备上使用。它将对本地调用进行采样。这深入到了 Android 的内部,你通常只会用这个进行深度分析。
一旦你选择了你的捕捉模式,点击红色的球开始捕捉。让捕获运行一段时间,然后结束它并开始分析收集的数据。Android Studio 为每个线程提供了不同的收集数据视图,并且各有千秋。一张火焰图见图 15-8 ,一张俯视图见图 15-9 。
图 15-9
自上而下的图表
图 15-8
火焰图
对于这个例子,浏览图表可以看到,在BigDecimal.divide()
中消耗了相当多的 CPU 能力。为了提高这个例子的性能,您可以尽量避免过于频繁地调用这个方法,或者您可以尝试找到一个替代方法。
作为分析的额外辅助,您可以打开过滤器。点击控制器面板右侧的过滤符号,如图 15-10 所示。Android Studio 随后会高亮显示图表中匹配的条目,如图 15-11 所示。
图 15-11
分析过滤器已打开
图 15-10
分析过滤器
有关性能监控的更多信息和细节,请参见 Android Studio 的文档。
内存使用监控
除了分析应用的性能,如前一章所示,Android Studio 的分析器还可以帮助您找到内存泄漏或与内存管理不善相关的问题。同样,将有问题的代码部分放入一个循环中并启动它。通过查看➤工具 Windows ➤ Android Profiler 打开 profiler。选择内存通道,分析器会立即显示内存使用图,如图 15-12 所示。
图 15-12
内存监视器
运行一段时间后,您可以看到内存使用率上升。这是因为在示例应用中,我添加了一个人为的内存泄漏。参见图 15-13 。
图 15-13
更长时间的内存分析
要开始分析,请使用鼠标选择适当的区域。然后视图立即切换到使用统计视图,如图 15-14 所示。
图 15-14
内存分析,已选择
要找到漏洞,请从“按类排列”模式切换到“按调用堆栈排列”通过单击、双击和/或按 Enter 键进入树。最终结果可能如图 15-15 所示。
图 15-15
内存分析,详细信息
几乎有 40,000 个分配的橙色线属于run() (com.example.perfmonitor.A$go$1
,这正是我放置内存泄漏的点。
class A {
fun go(l:MutableList<String>) {
Thread {
while (true) {
l.add("" + System.currentTimeMillis())
Thread.sleep(1)
}
}.start()
}
}
如果这还不足以解决内存问题,您可以获取一个堆转储。为此,单击 profiler 窗口标题中的堆转储符号,如图 15-16 所示。
图 15-16
进行堆转储
然后,您可以使用前面介绍的相同技术,或者将堆导出为 HPROF 文件,并使用其他工具来分析转储。要执行这样的导出,单击堆转储视图左上角的导出图标,如图 15-17 所示。
图 15-17
保存堆转储
注意
这样的堆转储允许您确定对象引用关系——这超出了 Android Studio 的内存分析。这使您能够最大限度地了解内存结构,但是需要一些时间来熟悉堆转储分析工具并找到正确的答案。
十六、分发应用
如果你已经完成了你的应用,你需要找到一种方法来分发它。实现这一目的的主要途径是 Google Play 商店,但如果你能说服你的用户允许从“其他来源”安装应用,也可以使用其他发布渠道我在这里没有给出发行渠道的列表,也没有给出使用 Google Play 商店的详细说明。有太多的选择取决于你的目标市场。此外,这本书并不打算成为应用营销的一般介绍。
你自己的应用商店
现在,设备允许用户从 Google Play 商店之外的来源安装应用,APK 文件可以从任何服务器上呈现,包括你自己的公司服务器。请注意,根据使用的 Android 版本,流程会有所不同。
-
直到 Android 7 (API 级别 25),在“安全”部分有一个系统范围的设置,允许从 Google Play 以外的其他来源安装应用。
-
从 Android 8 (API 级别 26)开始,从其他来源安装应用的权限是基于每个应用进行处理的,例如浏览器中的设置。
无论您选择哪种分销渠道,您都必须首先通过构建➤生成签名的 APK 来生成签名的 APK。然后,将其复制到服务器,并确保该文件被分配了 MIME 类型application/vnd.android.package-archive
。
注意
虽然 Android Studio 会自动将应用的调试版本上传到虚拟设备或通过 USB 连接的设备,但对于虚拟设备,您也可以测试签名的 APK 安装程序。如果您在本地开发机器上运行服务器,在虚拟设备内部使用 IP 10.0.2.2 连接到开发机器。最好先卸载开发构建过程中安装的版本。
然后,您可以使用设备的浏览器下载并安装 APK 文件。
谷歌 Play 商店
尽管这不是对如何使用 Google Play 商店的介绍,但这里有几个关于发行技术方面的附加要点:
-
如前所述,你必须在你的应用发布到 Google Play 之前给它签名。
-
在线文档建议在分发应用之前,删除应用内部的所有日志记录语句。作为破坏性较小的替代方案,遵循第十五章的说明,创建一个自定义记录器。
-
如果你的应用使用数据库,当数据库模式改变时提供更新机制。见
SQLiteOpenHelper
类。如果你忘记了这一点,从一个版本到另一个版本更新应用和升级数据库会变得非常麻烦。 -
可以为不同的设备分发不同的 apk。这一功能在本书中被忽略了,因为如今在现代设备中,一个应用的大小不再起重要作用,你通常可以将所有内容放入一个 APK 文件中。如果您仍然想提供多个 apk,请查阅在线文档。使用您最喜欢的搜索引擎搜索 android multiple apk 或类似产品。
-
如果你在一个真实的设备上测试你的应用,如果你在你的设备上使用一个不同于你用来分发应用的谷歌账户,事情会变得简单一些。否则谷歌不会让你使用 Play store 安装你的应用。尽早这样做,因为以后更改设备的 Google 帐户可能会很复杂。
-
本地化所有显示给用户的文本!在本书中,出于简洁的原因,没有使用本地化,但是您绝对应该为您的应用使用本地化。Android Studio 附带的 LINT checker 有助于发现本地化缺陷,使用模拟器附带的自定义区域设置切换器也可以让您进行检查。
-
虽然只为智能手机外形(屏幕尺寸、分辨率)开发有点诱人,但你应该检查你的设计是否有其他外形。各种模拟器可以帮助你做到这一点。你至少应该在平板电脑上测试你的应用。
十七、即时应用
即时应用允许设备用户使用应用,而无需实际安装它们。在 Google Play 商店上,你有时会看到的“尝试”按钮会启动这样一个即时应用。
开发即时应用
要开发即时应用,在创建 Android 应用时可以使用一个开关,如图 17-1 所示。
图 17-1
添加即时应用功能
比方说你给项目取名instantapp
;该向导创建了四个模块,如下所示:
-
基地
包含普通可安装应用变体和即时应用的基础。与许多博客所建议的相反,你不需要在这里放任何重要的东西。Android Studio 在这个模块中只创建了两个文件:一个是包含标记符
baseFeature true
的build.gradle
文件,另一个是添加组件和代码时不需要调整的非常基本的AndroidManifest.xml
文件。由于构建文件,基本模块依赖于可安装应用和即时应用变体。
注意:对于干净的设计纯粹主义者来说,可安装应用和即时应用都依次依赖于基础模块,这听起来像是一种循环依赖。但是,这种依赖性不能理解为 Java 包依赖性!
-
app
包含可安装应用的构建说明。这不包含代码,因为可安装应用和即时应用共享相同的代码基础,这将进入
feature
模块。 -
即时应用
包含即时应用的构建说明。这也不包含代码。
-
功能
可安装应用和即时应用的共享代码在这里。
截至 2018 年 5 月,向导的输出与 Google Play 商店的预期不匹配。为了避免以后推出即时应用时出现问题,请更改feature
模块的AndroidManifest.xml
文件,并为http
方案添加另一个<data>
元素,如下所示:
<data
android:host="myfirstinstantapp.your server.com"
android:pathPattern="/instapp"
android:scheme="https"/>
<data
android:scheme="http"/>
此外,意图过滤器必须添加属性android:autoVerify = "true"
。Play store 会检查它,如果它丢失了,就会投诉。
其余的开发与正常的 Android 应用开发没有实质性的不同。只是运行即时 app 和你所知道的不一样。我们将在接下来的章节中讨论这一点。
在模拟器上测试即时应用
即时应用可以在模拟器上测试。为此,确保选择的运行配置显示instantapp
,如图 17-2 所示。
图 17-2
运行配置
此外,如果您从按下灰色小三角时弹出的菜单中打开编辑配置,您应该会看到选择了 URL 启动方法,如图 17-3 所示。
图 17-3
发射方法
对于在仿真设备上运行,输入的 URL 是否存在并不重要,但它必须与来自模块feature
的AndroidManifest.xml
的意图过滤器内的主机规范相匹配。否则,配置屏幕会报错。
警告
对于开发来说,添加一个android:port
属性会导致问题。根据你的具体情况,当你想推出你的应用时,你可能需要一个,但是在开发期间不要使用它,或者把它注释掉!
构建部署工件
在推出即时应用之前,你必须为可安装应用和即时应用创建签名的 apk。
警告
两个变体和基本模块都需要有相同的版本信息,如它们的build.gradle
文件所示。
为了创建部署工件,对app
和instantapp
进行两次构建➤生成签名的 APK。
可安装应用的部署工件通常是一个.apk
文件,而即时应用是一个 zip 文件。
准备深层链接
深层链接是显示在网页或应用中的 URL,并链接到即时应用的功能。每当用户点击或点击一个 URL 时,借助于推出的即时应用的意图过滤器,相应的功能会立即下载并启动,而无需安装。
对于生产应用,连接到即时应用的 URL 必须存在,并且域的根必须有一个名为.well-known/assetlinks.json
的文件。顺便说一句,谷歌验证你所指的域名存在,是你的。这个文件的结构在在线文档中有解释,但是 Android Studio 也有一个向导:进入工具➤应用链接助手。
如果手动生成文件assetlinks.json
,则需要输入证书指纹。除非您已经拥有它,否则您可以通过以下方式获得它:
keytool -list -v -keystore my-release-key.keystore
这种文件的一个示例如下,其中指纹被裁剪:
[{
"relation":
["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example",
"sha256_cert_fingerprints":
["14:6D:E9:83:C5:73:06...50"]
}
}]
推出即时应用
要使用 Google Play 控制台推出即时应用,您必须创建一个新应用,然后推出可安装应用和即时应用。
在此过程中,Play console 将执行各种检查,以查看您的应用中的所有配置是否正确,它还将检查您的服务器是否如前一部分所述设置正确。
十八、命令行界面
在这一章中,我们总结了可以用来构建、管理和维护在 Android Studio 之外运行的任务的命令行工具。
注意
-
这些工具在某种程度上是“半”官方的;您可能会在 SDK 文件夹中稍微不同的位置找到它们,并且在线文档不一定是最新的。
-
这里显示的工具是 Linux 发行版的一部分。对于其他操作系统,提供了相应的版本,它们的用法将是相似的。
-
该清单并不详尽;如果您安装了更少或更多的 SDK 包,它可能会与您的安装有所不同。
SDK 工具
表 18-1 描述了该文件夹中提供的与平台无关的工具:
表 18-1
SDK 工具
|命令
|
描述
|
| --- | --- |
| apkanalyzer
| 使用这个来分析你能找到的 APK 文件,例如,在PROJECT-DIR/PROJECT/release
文件夹中(PROJECT
经常读为app
)。调用不带参数的命令,如下所示,将显示用法信息:./apkanalyzer
|
| archquery
| 这是一个查询操作系统架构的简单工具。./archquery
例如,这会输出x86_64
。 |
| avdmanager
| 用这个来管理虚拟设备(AVD = Android 虚拟设备)。您可以创建、移动和删除设备,还可以列出设备、目标和 avd。调用不带参数的命令,如下所示,将显示用法信息:./avdmanager
您可以在∼/.android/avd
中找到该命令处理的虚拟设备的数据文件。用于创建设备的系统映像在SDK_INST/system-images
中。 |
| jobb
| 使用它来管理不透明二进制 Blob (OBB)文件。这些是 APK 扩展文件,存放在外部存储器上,例如 SD 卡,只能从你的应用内部访问。调用不带参数的命令,如下所示,将显示用法信息:./job
|
| lint
| 这是用于代码检查的 LINT 工具。调用不带参数的命令,如下所示,会显示用法信息。./lint
|
| monkeyrunner
| 这是一个强大的测试工具,通过在你的电脑上使用 Python 脚本来控制 Android 应用。调用以下命令会显示使用信息:./monkeyrunner
不带参数启动它会启动一个 Jython shell。你可以在第十四章中找到更多关于monkeyrunner
的细节。 |
| screenshot2
| 使用它从设备或模拟器中截取屏幕截图。调用不带参数的命令,如下所示,将显示用法信息:./screenshot2
|
| sdkmanager
| 这个工具帮助你管理 Android SDK 的包。您可以安装、卸载或更新 SDK 软件包,并且可以使用它来列出已安装和可用的软件包。援引./sdkmanager ––help
显示详细的使用信息。例如,要列出已安装和可用的包,包括构建工具、平台、文档、源代码、系统映像和更多 SDK 组件,请调用:./sdkmanager ––list
要安装新组件,该工具需要下载它们。存在几个标志;查看–help
的输出,了解如何指定代理或禁用 HTTPS。 |
| uiautomatorviewer
| 这将打开 UI Automator GUI。./uiautomatorviewer
更多信息见第十四章。 |
SDK_INST/tools/bin
这些工具侧重于虚拟设备、SDK 本身以及各种测试和工件管理任务的管理。
在父目录中,如下所示:
SDK_INST/tools
您会发现更多的工具。对它们的总结见表 18-2 。
表 18-2
更多 SDK 工具
|命令
|
描述
|
| --- | --- |
| android
| 已弃用。不带参数地调用它来查看概要。 |
| emulator
| 模拟器管理工具。我们在第一章谈到了模拟器。引起./emulator –help
获取此命令的用法信息。 |
| emulator-check
| 用于主机系统的诊断工具。查看的输出./emulator-check –h
为了一个概要。 |
| mksdcard
| 创建用作模拟器映像的 FAT32 映像。调用它时不带用法信息参数,如下所示:./mksdcard
|
| monitor
| 启动图形设备监视器。这与从 Android Studio 的工具➤ Android ➤ Android 设备监视器中调用的是同一个设备监视器。注意,如果您在 Android Studio 的一个实例正在运行时运行这个命令,您可能会得到一个错误消息。 |
| proguard
| Proguard 程序驻留在这个目录中。使用 Proguard,您可以通过忽略文件、类、字段和方法来缩小 APK 文件。在在线文档中找到“压缩您的代码和资源”,了解 Proguard 是如何工作的。 |
SDK 构建工具
表 18-3 列出了该文件夹中提供的构建工具:
表 18-3
SDK 工具
|命令
|
描述
|
| --- | --- |
| aapt
| 这是 Android 素材打包工具。调用不带参数的命令,如下所示,将显示用法信息:./aapt
该工具能够列出 APK 文件的内容,并从中提取信息。此外,它能够打包素材,添加和删除 APK 文件中的元素。该工具还负责创建 R 类,它将资源映射到代码内部可用的资源 id(Android Studio 会自动为您完成这项工作)。 |
| aapt2
| 这是前面描述的aapt
工具的后继者。调用不带参数的命令,如下所示,会显示一些基本的用法信息:./aapt2
使用CMD
调用compile
、link
、dump
、diff
、optimize
或version
中的任意一个./aapt2 CMD -h
,会给出更详细的信息。在aapt
命令的帮助下进行交叉检查提供了额外的帮助。 |
| aarch64-linux-android-ld
| Android 对象文件的特殊链接器,针对 64 位 ARM 架构的设备。调用该命令会显示详细的使用信息,如下所示:./aarch64-linux-android-ld ––help
通常,如果你使用 Android Studio,你不必直接调用这个工具,因为它会为你处理编译和链接。 |
| aidl
| AIDL 是 Android 接口定义语言,处理不同应用的绑定服务类之间的低级进程间通信。aidl
工具可用于将*.aidl
接口定义文件编译成定义接口的 Java 语言接口文件。调用不带参数的命令会显示用法信息。./aidl
|
| apksigner
| 管理 APK 文件签名。APK 文件需要签名才能发布。Android Studio 可以帮助你完成这个过程(参见构建➤生成签名的 APK ),但是你也可以使用这个工具。有关用法信息,请按如下方式调用它:./apksigner –h
|
| arm-linux-androideabi-ld
| Android 目标文件的特殊链接器,目标是 32 位 ARM 架构的设备和 ABI 编译器生成的目标文件。调用该命令会显示详细的使用信息。./arm-linux-androideabi-ld ––help
如果你使用 Android Studio,通常你不需要直接调用这个工具,因为它会为你处理编译和链接。 |
| bcc_compat
| Android 用于renderscript
的一个 BCC 编译器。按如下方式调用命令会显示用法信息:./bcc_compat ––help
|
| dexdump
| 一个工具,用于调查 APK 文件中包含类的 DEX 文件。如下所示,不带参数调用会显示用法信息:./dexdump
|
| dx
| 一个管理 DEX 文件的工具。例如,您可以创建 DEX 文件或转储其内容。调用以下命令获取用法信息:./dx ––help
|
| i686-linux-android-ld
| Android 对象文件的链接器,面向 x86 架构的设备。如此处所示,调用命令会显示详细的使用信息:./i686-linux-android-ld ––help
通常,如果你使用 Android Studio,你不必直接调用这个工具,因为它会为你处理编译和链接。 |
| llvm-rs-cc
| renderscript 源代码编译器(脱机模式)。引起./llvm-rs-cc ––help
查看一些使用信息。 |
| mainDexClasses
| 这是为了遗留应用希望允许命令dx
上的–multi-dex
,并使用com.android.multidex.installer
库加载多个文件。mainDexClasses
脚本将在–main-dex-list
中提供给dx
的文件内容。 |
| mipsel-linux-android-ld
| 一个 Android 对象文件的链接器,目标是具有 MIPS 架构的设备。调用命令./mipsel-linux-android-ld ––help
显示详细的使用信息。通常,如果你使用 Android Studio,你不必直接调用这个工具,因为它会为你处理编译和链接。 |
| split-select
| 在给定目标设备配置的情况下,允许生成用于选择拆分 APK 的逻辑。调用命令./split-select ––help
显示一些使用信息。 |
| x86_64-linux-android-ld
| Android 对象文件的链接器,面向具有 x86 64 位架构的设备。调用命令./x86_64-linux-android-ld --help
显示详细的使用信息。通常,如果你使用 Android Studio,你不必直接调用这个工具,因为它会为你处理编译和链接。 |
| zipalign
| ZIP 对齐实用程序。开发人员不一定习惯的事实是,操作系统可能依赖于以某种方式对齐的归档文件的元素,例如,条目总是从 32 位边界开始。该工具可用于相应地修改 ZIP 文件。援引./zipalign –h
显示使用信息。 |
SDK_INST/build-tools/[VERSION]
其中包括连接器、编译器、APK 文件工具和一个 Android 界面定义语言(AIDL)管理工具。
SDK 平台工具
表 18-4 描述了该文件夹中提供的平台相关工具:
表 18-4
SDK 平台工具
|命令
|
描述
|
| --- | --- |
| adb
| Android 调试桥。关于adb
命令的描述,见表后的文字。 |
| dmtracedump
| 从跟踪转储创建图形化的调用堆栈图。跟踪文件必须是用android.os.Debug
类获取的。不带参数地调用它,如下所示,以获取有关该命令的信息:./dmstracedump
|
| e2fsdroid
| 挂载一个映像文件。目前已损坏。 |
| etc1tool
| 使用这个在 PNG 和 ETC1 图像格式之间转换。引起./etc1tool ––help
查看使用信息。 |
| fastboot
| 这是您可以用来修改设备固件的快速启动程序。引起./fastboot ––help
获取使用信息。 |
| hprof-conv
| 使用它将从 Android OS 工具获得的 HPROF 堆文件转换成标准的 HPROF 格式。不带参数地调用它,如下所示,以获取用法信息:./hprof-conv
|
| make_f2fs
| 用来在某个设备上创建一个 F2FS 文件系统。如下所示,不带参数调用它以获取用法信息:./make_f2fs
|
| mke2fs
| 生成 Linux 第二个扩展文件系统。不带参数调用它,如下所示,以查看选项:./mke2fs
|
| sload_f2fs
| 用于将文件加载到 F2FS 设备中。不带参数调用它,如下所示,以查看选项:./sload_f2fs
|
| sqlite3
| 启动 SQLite 管理工具。如下所示调用它以获取用法信息:./sqlite3 –help
|
| systrace/ systrace.py
| 研究 Android 系统的图形 Systrace 实用程序。工具adb
所在的路径必须是PATH
环境变量的一部分,并且必须安装 Python。然后你就可以跑了python systrace/systrace.py –h
获取命令概要。 |
SDK_INST/platform-tools
由adb
命令调用的 Android Debug Bridge (ADB)是一个多功能工具,可以将您的开发 PC 连接到正在运行的仿真器和通过 USB 或 Wi-Fi 连接的设备。它由开发 PC 上的客户端和透明服务器进程以及设备上运行的守护程序组成。您可以使用adb
进行以下操作:
-
查询可访问设备
-
安装和卸载应用(APK 文件)
-
将文件复制到设备或从设备复制文件
-
执行备份和恢复
-
连接到应用的日志输出
-
在设备上启用 root 访问权限
-
在设备上启动 shell(例如,查看和研究应用的文件)
-
开始和停止活动和服务
-
发布广播
-
启动和停止分析会话
-
转储堆
-
访问设备上的软件包管理器
-
截图录视频
-
重启设备。
有关更多详细信息,请在在线文档中找到“Android 调试桥”页面。通过以下方式调用它,以显示该命令提供的帮助:
./adb
例如,使用此列表列出连接的设备:
./adb devices
要打开设备上的 shell,请使用下面的命令,参数DEVICE_NAME
是设备列表中第一列的条目之一:
./adb -s DEVICE_NAME shell
如果只有一个设备,在前面的命令中,您可以省略-s
标志和设备名称。
注意
您必须在真实设备上启用调试,ADB 才能成功连接到这些设备。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)