精通-Kotlin-安卓开发-全-

精通 Kotlin 安卓开发(全)

原文:zh.annas-archive.org/md5/5ADF07BDE12AEC5E67245035E25F68A5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Android 是移动设备最流行的平台。每年都有越来越多的开发人员参与 Android 开发。Android 框架使得可以为手机、平板电脑、电视等开发应用成为可能!到目前为止,所有的开发都是用 Java 完成的。最近,Google 宣布 Kotlin 作为开发人员可以使用的第二种语言。因此,鉴于 Kotlin 日益增长的受欢迎程度,我们决定介绍使用 Kotlin 作为其主要开发编程语言的 Android。

有了 Kotlin,你可以做任何你用 Java 做的事情,但更加愉快和有趣!我们将向你展示如何在 Android 和 Kotlin 中玩耍,以及如何创造令人惊叹的东西!多亏了 Kotlin,可以肯定 Android 平台会进一步发展。在不久的将来,Kotlin 有可能成为该平台的主要开发语言。坐稳,准备开始一段伟大的旅程吧!

本书涵盖的内容

第一章,“开始 Android”,教你如何使用 Kotlin 开始 Android 开发,以及如何设置你的工作环境。

第二章,“构建和运行”,向你展示如何构建和运行你的项目。它将演示如何记录和调试应用程序。

第三章,“屏幕”,从 UI 开始。在这一章中,我们将为我们的应用程序创建第一个屏幕。

第四章,“连接屏幕流”,解释了如何连接屏幕流并定义与 UI 的基本用户交互。

第五章,“外观和感觉”,涵盖了 UI 的主题。我们将向你介绍 Android 主题的基本概念。

第六章,“权限”,解释了为了利用某些系统功能,需要获取适当的系统权限,这将在本章中讨论。

第七章,“使用数据库”,向你展示如何使用 SQLite 作为应用程序的存储。你将创建一个数据库来存储和共享数据。

第八章,“Android 偏好设置”,指出并非所有数据都应存储在数据库中;一些信息可以存储在共享偏好设置中。我们将解释原因和方法。

第九章,“Android 中的并发”,解释了如果你熟悉编程中的并发,那么你会知道在软件中许多事情是同时发生的。Android 也不例外!

第十章,“Android 服务”,介绍了 Android 服务以及如何使用它们。

第十一章,“消息”,说在 Android 中,你的应用程序可以监听各种事件。如何做到这一点将在本章中得到解答。

第十二章,“后端和 API”,连接到远程后端实例以获取数据。

第十三章,“为高性能进行调优”,是一个完美的章节,当你不确定你的应用程序是否足够快时,它会给你答案。

第十四章,“测试”,提到在发布任何东西之前,我们必须对其进行测试。在这里,我们将解释如何为你的应用程序编写测试。

第十五章,“迁移到 Kotlin”,指导你如果计划将现有的 Java 代码库迁移到 Kotlin。

第十六章,“部署你的应用程序”,指导你完成部署过程。我们将发布本书中开发的所有内容。

本书所需内容

对于本书,需要运行 Microsoft Windows、Linux 或 macOS 的现代计算机。您需要安装 Java JDK、Git 版本控制系统和 Android Studio。

为了运行所有代码示例和您编写的代码,您需要一部运行 Android 操作系统版本>= 5 的 Android 手机。

本书适合对象

本书旨在希望以简单有效的方式构建令人惊叹的 Android 应用程序的开发人员。假定具有 Kotlin 的基本知识,但不熟悉 Android 开发。

约定

在本书中,您将找到一些区分不同类型信息的文本样式。以下是这些样式的一些示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL 和用户输入显示如下:“我们将为Application类的每个生命周期事件和我们创建的屏幕(活动)添加适当的日志消息。”

代码块设置如下:

    override fun onCreate(savedInstanceState: Bundle?) { 
      super.onCreate(savedInstanceState) 
      setContentView(R.layout.activity_main) 
      Log.v(tag, "[ ON CREATE 1 ]") 
    } 

任何命令行输入或输出都是这样写的。输入命令可能会被分成几行以增加可读性,但需要作为一个连续的行输入到提示符中:

sudo apt-get install libc6:i386 libncurse
libstdc++6:i386 lib32z1 libbz2-1.0:i386

新术语重要单词以粗体显示。例如,屏幕上看到的单词,例如菜单或对话框中的单词,会出现在文本中,如下所示:“选择工具|Android|AVDManager或单击工具栏中的 AVDManager 图标。”

警告或重要说明会出现在这样。

提示和技巧会出现在这样。

第一章:从 Android 开始

Kotlin已被 Google 正式宣布为 Android 的一流编程语言。了解为什么 Kotlin 是新手的最佳工具,以及为什么高级 Android 开发人员首先采用 Kotlin。

在本章中,您将学习如何设置工作环境。您将安装和运行 Android Studio,并设置 Android SDK 和 Kotlin。在这里,您还将介绍一些重要和有用的工具,如Android 调试桥adb)。

由于您尚未拥有项目,您将设置它。您将初始化一个 Git 存储库以跟踪代码中的更改,并创建一个空项目。您将使其支持 Kotlin,并添加我们将使用的其他库的支持。

在我们初始化了存储库和项目之后,我们将浏览项目结构并解释 IDE 生成的每个文件。最后,您将创建您的第一个屏幕并查看它。

本章将涵盖以下要点:

  • 为 Git 和 Gradle 基础开发环境设置

  • 使用 Android 清单

  • Android 模拟器

  • Android 工具

为什么选择 Kotlin?

在我们开始我们的旅程之前,我们将回答章节标题中的问题--为什么选择 Kotlin?Kotlin 是由 JetBrains 开发的一种新的编程语言,该公司开发了 IntelliJ IDEA。Kotlin 简洁易懂,与 Java 一样将所有内容编译为字节码。它还可以编译为 JavaScript 或本机代码!

Kotlin 来自行业专业人士,并解决程序员每天面临的问题。它易于开始和采用!IntelliJ 配备了一个 Java 到 Kotlin 转换器工具。您可以逐个文件转换 Java 代码文件,一切仍将无缝运行。

它是可互操作的,并且可以使用任何现有的 Java 框架或库。可互操作性无可挑剔,不需要包装器或适配器层。Kotlin 支持构建系统,如 Gradle、Maven、Kobalt、Ant 和 Griffon,并提供外部支持。

对我们来说,关于 Kotlin 最重要的是它与 Android 完美配合。

一些最令人印象深刻的 Kotlin 功能如下:

  • 空安全

  • 异常是未经检查的

  • 类型推断在任何地方都适用

  • 一行函数占一行

  • 开箱即用生成的 getter 和 setter

  • 我们可以在类外定义函数

  • 数据类

  • 函数式编程支持

  • 扩展函数

  • Kotlin 使用 Markdown 而不是 HTML 来编写 API 文档! Dokka 工具是 Javadoc 的替代品,可以读取 Kotlin 和 Java 源代码并生成组合文档

  • Kotlin 比 Java 有更好的泛型支持

  • 可靠且高性能的并发编程

  • 字符串模式

  • 命名方法参数

Kotlin for Android - 官方

2017 年 5 月 17 日,Google 宣布将 Kotlin 作为 Java 虚拟机的一种静态类型编程语言,成为编写 Android 应用程序的一流语言。

下一个版本的 Android Studio(3.0,当前版本为 2.3.3)将直接支持 Kotlin。Google 将致力于 Kotlin 的未来。

重要的是要注意,这只是一种附加语言,而不是现有 Java 和 C++支持的替代品(目前)。

下载和配置 Android Studio

为了开发我们的应用程序,我们将需要一些工具。首先,我们需要一个集成开发环境。为此,我们将使用 Android Studio。Android Studio 提供了在各种类型的 Android 设备上构建应用程序的最快速工具。

Android Studio 提供专业的代码编辑、调试和性能工具。这是一个灵活的构建系统,可以让您专注于构建高质量的应用程序。

设置 Android Studio 只需点击几下。在我们继续之前,您需要为您的操作系统下载以下版本:

developer.android.com/studio/index.html

以下是 macOS、Linux 和 Windows 的说明:

macOS

要在 macOS 上安装它,请按照以下步骤操作:

  1. 启动 Android Studio 的 DMG 文件。

  2. 将 Android Studio 拖放到“应用程序”文件夹中。

  3. 启动 Android Studio。

  4. 选择是否要导入以前的 Android Studio 设置。

  5. 单击确定。

  6. 按照说明进行,直到 Android Studio 准备就绪。

Linux:要在 Linux 上安装它,请按照以下步骤进行:

  1. 将下载的存档解压到适合您的应用程序的位置。

  2. 导航到bin/directory/

  3. 执行/studio.sh

  4. 选择是否要导入以前的 Android Studio 设置。

  5. 单击确定。

  6. 按照说明进行,直到 Android Studio 准备就绪。

  7. 可选地,从菜单栏中选择工具|创建桌面条目。

如果您正在运行 Ubuntu 的 64 位版本,则需要使用以下命令安装一些 32 位库:

使用sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2-1.0:i386命令安装所需的 32 位库。

如果您正在运行 64 位的 Fedora,则命令如下:

**sudo yum install zlib.i686 ncurses-libs.i686 bzip2-libs.i686**

Windows:要在 Windows 上安装它,请按照以下步骤进行:

  1. 执行您下载的.exe文件。

  2. 按照说明进行,直到 Android Studio 准备就绪。

设置 Android 模拟器

Android SDK 带有能够运行我们开发的应用程序的模拟器。我们需要它来进行我们的项目!模拟器的目的是模拟设备并在计算机上显示其所有活动。我们可以用它做什么?我们可以进行原型设计、开发和测试——所有这些都不需要硬件设备。您可以模拟手机、平板电脑、可穿戴设备和电视设备。您可以创建自己的设备定义,或者您可以使用预定义的模拟器。

模拟器的好处是速度快。在许多情况下,运行应用程序的模拟器实例所需的时间比在真实硬件设备上运行要少。

使用模拟器与真实硬件设备一样容易。对于手势,您可以使用鼠标,对于输入,您可以使用键盘。

模拟器可以做任何真实手机可以做的事情!您可以轻松发送来电和短信!您可以指定设备的位置,发送指纹扫描,调整网络速度和状态,甚至模拟电池属性。模拟器可以有一个虚拟 SD 卡和内部数据存储,您可以使用它们来发送真实文件到该空间。

Android 虚拟设备AVD)配置用于定义模拟器。每个 AVD 实例都作为一个完全独立的设备!为了创建和管理 AVD,我们使用 AVD Manager。AVD 定义包含硬件配置文件、系统映像、存储区域、外观和其他重要属性。

让我们来玩一下!要运行 AVD Manager,请执行以下操作之一:

选择工具|Android|AVDManager或单击工具栏中的AVDManager图标:

它显示您已经定义的所有 AVD。正如您所看到的,我们还没有任何 AVD!

我们在这里可以做什么?我们可以做以下事情:

  • 创建一个新的 AVD

  • 编辑现有的 AVD

  • 删除现有的 AVD

  • 创建硬件配置文件

  • 编辑现有的硬件配置文件

  • 删除现有的硬件配置文件

  • 导入/导出定义

  • 启动或停止 AVD

  • 清除数据并重置 AVD

  • 访问文件系统上的 AVD.ini.img文件

  • 查看 AVD 配置详细信息

要获取 AVD 实例,您可以从头开始创建一个新的 AVD,也可以复制现有的 AVD 并根据需要进行修改。

创建一个新的 AVD 实例

从 AVD Manager 的您的虚拟设备中,单击创建虚拟设备(您可以在 Android Studio 中运行应用程序时执行相同操作,方法是单击运行图标,然后在选择部署目标对话框中选择创建新模拟器)。请参考以下截图:

选择一个硬件配置文件,然后单击下一步,如前面的截图所示。

如果您注意到系统映像旁边的下载链接,则必须单击它。下载过程开始,如下屏幕截图所示:

我们必须注意目标设备的 API 级别非常重要!您的应用程序无法在其 API 级别低于应用程序所需级别的系统映像上运行。该属性在您的 Gradle 配置中指定。稍后我们将详细介绍 Gradle。

最后,出现“验证配置”:

如有需要,请更改 AVD 属性,然后单击“完成”以完成向导。新创建的 AVD 将显示在“您的虚拟设备”列表或“选择部署目标”对话框中,具体取决于您从何处访问向导。

如果您需要创建现有 AVD 的副本,请按照以下说明进行操作:

  1. 打开 AVD 管理器,右键单击 AVD 实例,然后选择“复制”。

  2. 按照向导的指示,在您修改所需内容后,单击“完成”。

  3. 我们的 AVD 列表中出现了一个新的修改版本。

我们将通过从头开始创建一个新的硬件配置文件来演示处理硬件配置文件。要创建新的硬件配置文件,请按照以下说明进行操作。在“选择硬件”中,单击“新硬件配置文件”。请参考以下屏幕截图:

配置硬件配置文件出现。根据需要调整硬件配置文件属性。单击“完成”。您新创建的硬件配置文件将显示。

通过复制现有的 AVD 并根据需要进行修改

如果您需要基于现有硬件配置文件的硬件配置文件,请按照以下说明进行操作:

  1. 选择现有的硬件配置文件,然后单击“克隆设备”。

  2. 根据您的需求更新硬件配置文件属性。要完成向导,请单击“完成”。

  3. 您的配置文件将显示在硬件配置文件列表中。

让我们回到 AVD 列表。在这里,您可以对任何现有的 AVD 执行以下操作:

  • 单击“编辑”进行编辑

  • 通过右键单击并选择删除来删除

  • 通过右键单击 AVD 实例并选择在磁盘上显示来访问磁盘上的.ini 和.img 文件

  • 要查看 AVD 配置详细信息,请右键单击 AVD 实例,然后选择“查看详细信息”

既然我们已经涵盖了这一点,让我们回到硬件配置文件列表。在这里,我们可以执行以下操作:

  • 通过选择它并选择编辑设备来编辑硬件配置文件

  • 通过右键单击并选择删除来删除硬件配置文件

您无法编辑或删除预定义的硬件配置文件!

然后,我们可以运行或停止模拟器,或者清除其数据,如下所示:

  • 要运行使用 AVD 的模拟器,请双击 AVD 或只需选择“启动”

  • 右键单击它并选择停止以停止它

  • 要清除模拟器的数据,并将其返回到首次定义时的状态,请右键单击 AVD 并选择“擦除数据”

我们将继续介绍与* -一起使用的命令行功能,您可以使用这些功能。

要启动模拟器,请使用模拟器命令。我们将向您展示一些从终端启动虚拟设备的基本命令行语法:

emulator -avd avd_name [ {-option [value]} ... ]

另一个命令行语法如下:

emulator @avd_name [ {-option [value]} ... ]

让我们看一下以下示例:

$ /Users/vasic/Library/Android/sdk/tools/emulator -avd Nexus_5X_API_23 -netdelay none -netspeed full

您可以在启动模拟器时指定启动选项;稍后,您无法设置这些选项。

如果您需要可用 AVD 的列表,请使用此命令:

emulator -list-avds

结果是从 Android 主目录中列出 AVD 名称。您可以通过设置ANDROID_SDK_HOME环境变量来覆盖默认主目录。

停止模拟器很简单-只需关闭其窗口。

重要的是要注意,我们也可以从 Android Studio UI 运行 AVD!

Android 调试桥

要访问设备,您将使用从终端执行的adb命令。我们将研究常见情况。

列出所有设备:

adb devices

控制台输出:

List of devices attached
emulator-5554 attached
emulator-5555 attached

获取设备的 shell 访问:

adb shell

访问特定设备实例:

adb -s emulator-5554 shell

其中-s代表设备来源。

从设备复制文件:

adb pull /sdcard/images ~/images
adb push ~/images /sdcard/images

卸载应用程序:

adb uninstall <package.name>  

adb最大的特点之一是你可以通过 telnet 访问它。使用telnet localhost 5554连接到你的模拟器设备。使用quitexit命令终止你的会话。

让我们玩玩adb

  • 连接到设备:
        telnet localhost 5554
  • 改变电源等级:
        power status full
        power status charging
  • 或模拟一个电话:
        gsm call 223344556677
  • 发送短信:
        sms send 223344556677 Android rocks
  • 设置地理位置:
        geo fix 22 22  

使用adb,你还可以拍摄屏幕截图或录制视频!

其他重要工具

我们将介绍一些你在日常 Android 开发中需要的其他工具。

让我们从以下开始:

  • adb dumpsys:要获取系统和运行应用程序的信息,使用adb dumpsys命令。要获取内存状态,执行以下命令--adb shell dumpsys meminfo <package.name>

下一个重要的工具如下:

  • adb shell procrankadb shell procrank按照它们的内存消耗顺序列出了所有的应用程序。这个命令在实时设备上不起作用;你只能连接模拟器。为了达到同样的目的,你可以使用--adb shell dumpsys meminfo

  • 对于电池消耗,你可以使用--adb shell dumpsys batterystats--charged <package-name>

  • 下一个重要的工具是Systrace。为了分析你的应用程序的性能,通过捕获和显示执行时间,你将使用这个命令。

当你遇到应用程序故障问题时,Systrace 工具将成为一个强大的盟友!

它不适用于低于 20 的 Android SDK 工具!要使用它,你必须安装和配置 Python。

让我们试试吧!

要从 UI 访问它,打开 Android Studio 中的 Android Device Monitor,然后选择 Monitor:

有时,从终端(命令行)访问它可能更容易:

Systrace 工具有不同的命令行选项,取决于你设备上运行的 Android 版本。

让我们看一些例子:

一般用法:

$ python systrace.py [options] [category1] [category2] ... [categoryN]
  • Android 4.3 及更高版本:
        $ python systrace.py --time=15 -o my_trace_001.html 
        sched gfx  view wm
  • Android 4.2 及更低版本的选项:
        $ python systrace.py --set-tags gfx,view,wm
        $ adb shell stop
        $ adb shell start
        $ python systrace.py --disk --time=15 -o my_trace_001.html

我们要介绍的最后一个重要工具是sdkmanager。它允许你查看、安装、更新和卸载 Android SDK 的包。它位于android_sdk/tools/bin/中。

让我们看一些常见的使用示例:

列出已安装和可用的包:

sdkmanager --list [options]
  • 安装包:
        sdkmanager packages [options]

你可以发送从--list命令得到的包。

  • 卸载:
        sdkmanager --uninstall packages [options]
  • 更新:
        sdkmanager --update [options]

在 Android 中还有一些其他工具可以使用,但我们只展示了最重要的工具。

初始化一个 Git 仓库

我们已经安装了 Android Studio 并介绍了一些重要的 SDK 工具。我们还学会了如何处理将运行我们的代码的模拟设备。现在是时候开始着手我们的项目了。我们将开发一个用于笔记和待办事项的小应用程序。这是每个人都需要的工具。我们将给它起一个名字--Journaler,它将是一个能够创建带有提醒的笔记和待办事项并与我们的后端同步的应用程序。

开发的第一步是初始化一个 Git 仓库。Git 将是我们的代码版本控制系统。你可以决定是否使用 GitHub、BitBucket 或其他远程 Git 实例。创建你的远程仓库并准备好它的 URL 以及你的凭据。那么,让我们开始吧!

进入包含项目的目录:

Execute: git init .

控制台输出将会是这样的:

Initialized empty Git repository in <directory_you_choose/.git>

我们初始化了仓库。

让我们添加第一个文件--vi notes.txt

填充notes.txt并保存一些内容。

执行git add .来添加所有相关文件。

  • 然后:git commit -m "Journaler: First commit"

控制台输出将会是这样的:

[master (root-commit) 5e98ea4]  Journaler: First commit
1 file changed, 1 insertion(+)
create mode 100644 notes.txt

你记得,你准备好了带有凭据的远程 Git 仓库url。将url复制到剪贴板中。现在,执行以下操作:

git remote add origin <repository_url> 

这将设置新的远程。

  • 然后:git remote -v

这将验证新的远程 URL。

  • 最后,将我们所有的东西推送到远程:git push -u origin master

如果要求输入凭据,请输入并按Enter确认。

创建 Android 项目

我们初始化了我们的代码仓库。现在是创建项目的时候了。启动 Android Studio 并选择以下内容:

开始一个新的 Android Studio 项目或文件 | 新建 | 新项目。

创建新项目,会出现一个窗口。

填写应用信息:

然后,点击下一步。

勾选手机和平板选项,然后选择 Android 5.0 作为最低 Android 版本,如下所示:

再次点击下一步。

选择添加无活动,然后点击完成,如下所示:

等待项目创建完成。

你会注意到一个关于检测到未注册的 VCS 根的消息。点击添加根或转到首选项 | 版本控制 | ,然后从列表中选择我们的 Git 仓库,点击+图标,如下面的截图所示:

要确认一切,点击应用和确定。

在提交和推送之前,更新你的.gitignore文件。.gitignore文件的目的是允许你忽略文件,比如编辑器备份文件、构建产品或本地配置覆盖,你永远不想提交到仓库中。如果不符合.gitignore规则,这些文件将出现在 Git 状态输出的未跟踪文件部分中。

打开位于项目root目录的.gitignore并编辑它。要访问它,点击 Android Studio 左侧的项目,然后从下拉菜单中选择项目,如下面的截图所示:

让我们添加一些行:

.idea
.gradle
build/
gradle*
!gradle-plugins*
gradle-app.setting
!gradle-wrapper.jar
.gradletasknamecache
local.properties
gen

然后,编辑位于app模块目录中的.gitignore

*.class
.mtj.tmp/

*.jar
*.war
*.ear
 hs_err_pid*
.idea/*
.DS_Store
.idea/shelf
/android.tests.dependencies
/confluence/target
/dependencies
/dist
/gh-pages
/ideaSDK
/android-studio/sdk
out
tmp
workspace.xml
*.versionsBackup
/idea/testData/debugger/tinyApp/classes*
/jps-plugin/testData/kannotator
ultimate/.DS_Store
ultimate/.idea/shelf
ultimate/dependencies
ultimate/ideaSDK
ultimate/out
ultimate/tmp
ultimate/workspace.xml
ultimate/*.versionsBackup
.idea/workspace.xml
.idea/tasks.xml
.idea/dataSources.ids
.idea/dataSources.xml
.idea/dataSources.local.xml
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml
.idea/gradle.xml
.idea/libraries
.idea/mongoSettings.xml
*.iws
/out/
.idea_modules/
atlassian-ide-plugin.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
!/.mvn/wrapper/maven-wrapper.jar
samples/*
build/*
.gradle/*
!libs/*.jar
!Releases/*.jar

credentials*.gradle
gen

你可以使用前面的.gitignore配置。现在我们可以提交和推送,在 macOS 上按cmd + 9,在 Windows/Linux 上按ctrl + 9(View | Tool Windows | Version Control 的快捷键)。展开未版本化的文件,选择它们,右键单击添加到 VCS。

Cmd + K(或 Windows/Linux 上的Ctrl + K),勾选所有文件,输入提交消息,然后从提交下拉菜单中选择提交和推送。如果出现换行符警告,选择修复并提交。推送提交窗口将出现。勾选推送标签,选择当前分支,然后推送。

设置 Gradle

Gradle 是一个构建系统。你可以在没有它的情况下构建你的 Android 应用程序,但在那种情况下,你必须自己使用几个 SDK 工具。这并不简单!这是你需要 Gradle 和 Android Gradle 插件的部分。

Gradle 接收所有源文件并通过我们提到的工具处理它们。然后,它将所有内容打包成一个带有.apk扩展名的压缩文件。APK 可以解压缩。如果你将它的扩展名改为.zip,你可以提取内容。

每个构建系统都有自己的约定。最重要的约定是将源代码和资产放在具有适当结构的适当目录中。

Gradle 是基于 JVM 的构建系统,这意味着你可以用 Java、Groovy、Kotlin 等编写自己的脚本。此外,它是一个基于插件的系统,易于扩展。一个很好的例子是谷歌的 Android 插件。你可能在项目中注意到了build.gradle文件。它们都是用 Groovy 编写的,所以你写的任何 Groovy 代码都会被执行。我们将定义我们的 Gradle 脚本来自动化构建过程。让我们开始构建吧!打开settings.gradle并查看它:

include ":App" 

这个指令告诉 Gradle 它将构建一个名为App的模块。App模块位于我们项目的app目录中。

现在打开项目root中的build.gradle并添加以下行:

    buildscript { 
      repositories { 
        jcenter() 
        mavenCentral() 
      } 
      dependencies { 
        classpath 'com.android.tools.build:gradle:2.3.3' 
        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.1.3' 
      } 
    } 

    repositories { 
      jcenter() 
      mavenCentral() 
    } 

我们定义了我们的构建脚本将从 JCenter 和 Maven Central 仓库解析其依赖项。相同的仓库将用于解析项目依赖项。主要依赖项被添加到目标,以便针对我们将拥有的每个模块:

  • Android Gradle 插件

  • Kotlin Gradle 插件

在更新了主build.gradle配置之后,打开位于App 模块目录中的build.gradle并添加以下行:

    apply plugin: "com.android.application" 
    apply plugin: "kotlin-android" 
    apply plugin: "kotlin-android-extensions" 
    android { 
      compileSdkVersion 26 
      buildToolsVersion "25.0.3" 
      defaultConfig { 
        applicationId "com.journaler" 
        minSdkVersion 19 
        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'    
         }
       }    
       sourceSets {   
         main.java.srcDirs += 'src/main/kotlin'  
       }}
       repositories { 
         jcenter()  
         mavenCentral()
       }dependencies {
          compile "org.jetbrains.kotlin:kotlin-stdlib:1.1.3"  
          compile 'com.android.support:design:26+'  
          compile 'com.android.support:appcompat-v7:26+'}

我们设置的配置使 Kotlin 成为项目和 Gradle 脚本的开发语言。然后,它定义了应用程序所需的最小和目标 sdk 版本。在我们的情况下,最小值是19,目标是26。重要的是要注意,在默认配置部分,我们还设置了应用程序 ID 和版本参数。依赖项部分为 Kotlin 本身和一些稍后将解释的 Android UI 组件设置了依赖项。

解释目录结构

Android Studio 包含构建应用程序所需的一切。它包含源代码和资产。所有目录都是由我们用来创建项目的向导创建的。要查看它,请在 IDE 的左侧打开项目窗口(单击查看 | 工具窗口 | 项目),如下截图所示:

项目模块代表一组源文件、资产和构建设置,将项目分成离散的功能部分。模块的最小数量是一个。您的项目可以拥有的模块的最大数量没有实际限制。模块可以独立构建、测试或调试。正如您所看到的,我们定义了 Journaler 项目,只有一个名为 app 的模块。

要添加新模块,请按照以下步骤进行:

转到文件 | 新建 | 新建模块。

可以创建以下模块

  • Android 应用程序模块代表应用程序源代码、资源和设置的容器。默认模块名称是 app,就像我们创建的示例中一样。

  • 手机和平板电脑模块。

  • Android Wear 模块。

  • 玻璃模块。

  • Android 电视模块。

  • Library模块代表可重用代码的容器--一个库。该模块可以作为其他应用程序模块的依赖项使用,或者导入其他项目。构建时,该模块具有 AAR 扩展名--Android 存档,而不是 APK 扩展名。

创建新模块窗口提供以下选项:

  • Android 库:在 Android 项目中支持所有类型。此库的构建结果是Android 存档AAR)。

  • Java 库:仅支持纯 Java。此库的构建结果是Java 存档JAR)。

  • Google Cloud 模块:定义了 Google Cloud 后端代码的容器。

重要的是要理解,Gradle 将模块称为单独的项目。如果您的应用程序代码依赖于名为Logger的 Android 库的代码,那么在build.config中,您必须包含以下指令:

    dependencies { 
      compile project(':logger') 
    } 

让我们浏览项目结构。Android Studio 默认使用的视图来显示项目文件是 Android 视图。它不代表磁盘上的实际文件层次结构。它隐藏了一些不经常使用的文件或目录。

Android 视图呈现如下内容:

  • 所有与构建相关的配置文件

  • 所有清单文件

  • 所有其他资源文件都在一个组中

在每个应用程序中,模块内容分为以下组:

  • 清单和AndroidManifest.xml文件。

  • 应用程序和测试的 Java 和 Kotlin 源代码。

  • res和 Android UI 资源。

  • 要查看项目的实际文件结构,请选择项目视图。要执行此操作,请单击 Android 视图,然后从下拉菜单中选择项目。

通过这样做,您将看到更多的文件和目录。其中最重要的是:

  • module-name/:这是模块的名称

  • build/:这是构建输出的保存位置

  • libs/:这保存私有库

  • src/:这保存模块的所有代码和资源文件,组织在以下子目录中:

  • main:这保存main源集文件——所有构建变体共享的源代码和资源(我们稍后会解释构建变体)

  • AndroidManifest.xml:这定义了我们的应用程序及其各个组件的性质

  • java:这保存 Java 源代码

  • kotlin:这保存 Kotlin 源代码

  • jni:这保存使用Java Native InterfaceJNI)的本机代码

  • gen:这保存 Android Studio 生成的 Java 文件

  • res:这保存应用程序资源,例如drawable文件、布局文件、字符串等

  • assets:这保存应该编译成.apk文件的文件,不进行修改

  • test:这保存测试源代码

  • build.gradle:这是模块级别的构建配置

  • build.gradle:这是项目级别的构建配置

选择文件|项目结构以更改以下屏幕截图中项目的设置:

它包含以下部分:

  • SDK 位置:这设置项目使用的 JDK、Android SDK 和 Android NDK 的位置。

  • 项目:这设置 Gradle 和 Android Gradle 插件版本

  • 模块:这编辑特定于模块的构建配置

模块部分分为以下选项卡:

  • 属性:这设置模块构建所需的 SDK 和构建工具的版本

  • 签名:这设置 APK 签名的证书

  • 口味:这为模块定义口味

  • 构建类型:这为模块定义构建类型

  • 依赖项:这设置模块所需的依赖项

请参考以下屏幕截图:

定义构建类型和口味

我们正在接近项目的重要阶段——为我们的应用程序定义构建变体。构建变体代表 Android 应用程序的唯一版本。

它们是独特的,因为它们覆盖了一些应用程序属性或资源。

每个构建变体都是在模块级别配置的。

让我们扩展我们的build.gradle!将以下代码放入build.gradle文件的android部分:

    android { 
      ... 
      buildTypes { 
        debug { 
          applicationIdSuffix ".dev" 
        } 
        staging { 
          debuggable true 
          applicationIdSuffix ".sta" 
        } 
        preproduction { 
          applicationIdSuffix ".pre" 
        } 
           release {} 
        } 
       ... 
    }  

我们为我们的应用程序定义了以下buildTypes——debugreleasestagingpreproduction

产品口味的创建方式与buildTypes类似。您需要将它们添加到productFlavors并配置所需的设置。以下代码片段演示了这一点:

    android { 
      ... 
      defaultConfig {...} 
      buildTypes {...} 
      productFlavors { 
        demo { 
          applicationIdSuffix ".demo" 
          versionNameSuffix "-demo" 
        } 
        complete { 
          applicationIdSuffix ".complete" 
          versionNameSuffix "-complete" 
        } 
        special { 
          applicationIdSuffix ".special" 
          versionNameSuffix "-special" 
        } 
       } 
    } 

创建和配置productFlavors后,单击通知栏中的立即同步。

您需要等待一段时间才能完成该过程。构建变体的名称是通过<product-flavor><Build-Type>约定形成的。以下是一些示例:

    demoDebug 
    demoRelease 
    completeDebug 
    completeRelease 

您可以将构建变体更改为要构建和运行的构建变体。转到 Build,选择 Build Variant,然后从下拉菜单中选择completeDebug

Main/source集在您的应用程序的所有构建变体之间共享。如果您需要创建新的源集,可以为特定的构建类型、产品口味及其组合进行操作。

所有源集文件和目录必须以特定方式组织,类似于Main/Source集。特定于您的debug构建类型的 Kotlin 类文件必须位于src/debug/kotlin/directory中。

为了学习如何组织您的文件,打开终端窗口(View | ToolWindows | Terminal)并执行以下命令行:

./gradlew sourceSets

仔细查看输出。报告是可以理解和自解释的。Android Studio 不会创建sourceSets目录。这是您必须完成的工作。

如果需要,可以使用sourceSets块更改 Gradle 查找源集的位置。让我们更新我们的构建配置。我们将更新以下预期的源代码路径:

    android { 
      ... 
      sourceSets { 
       main { 
       java.srcDirs = [ 
                'src/main/kotlin', 
                'src/common/kotlin', 
                'src/debug/kotlin', 
                'src/release/kotlin', 
                'src/staging/kotlin', 
                'src/preproduction/kotlin', 
                'src/debug/java', 
                'src/release/java', 
                'src/staging/java', 
                'src/preproduction/java', 
                'src/androidTest/java', 
                'src/androidTest/kotlin' 
        ] 
        ... 
     } 

您希望仅与某些配置一起打包的代码和资源,可以存储在sourceSets目录中。这里提供了使用demoDebug构建变体的示例;此构建变体是demo产品风味和debug构建类型的产物。在 Gradle 中,对它们给予以下优先级:

    src/demoDebug/ (build variant source set) 
    src/debug/ (build type source set) 
    src/demo/ (product flavor source set) 
    src/main/ (main source set) 

这是 Gradle 在构建过程中使用的优先顺序,并在应用以下构建规则时考虑它:

  • 它将java/kotlin/目录中的源代码一起编译

  • 它将清单合并到一个单一的清单中

  • 它合并了values/目录中的文件

  • 它合并了res/asset/目录中的资源

资源和清单与库模块依赖项一起包含的优先级最低。

附加库

我们配置了构建类型和风味,现在我们需要一些第三方库。我们将使用并添加对 Retrofit、OkHttp 和 Gson 的支持。以下是它们的说明:

  • Retrofit 是 Square, Inc.为 Android 和 Java 开发的一种类型安全的 HTTP 客户端。Retrofit 是 Android 最受欢迎的 HTTP 客户端库之一,因为它与其他库相比,简单易用且性能出色。

  • OkHttp是一个默认情况下高效的 HTTP 客户端--HTTP/2 支持允许所有请求与同一主机共享套接字。

  • Gson 是一个 Java 库,可用于将 Java 对象转换为其 JSON 表示。它还可以用于将 JSON 字符串转换为等效的 Java 对象。Gson 可以处理包括您没有源代码的现有对象在内的任意 Java 对象。

有一些开源项目可以将 Java 对象转换为 JSON。在本书的后面,我们将添加 Kotson 以为 Kotlin 提供 Gson 绑定。

让我们通过添加 Retrofit 和 Gson 的依赖项来扩展build.gradle

    dependencies { 
      ... 
      compile 'com.google.code.gson:gson:2.8.0' 
      compile 'com.squareup.retrofit2:retrofit:2.2.0' 
      compile 'com.squareup.retrofit2:converter-gson:2.0.2' 
      compile 'com.squareup.okhttp3:okhttp:3.6.0' 
      compile 'com.squareup.okhttp3:logging-interceptor:3.6.0' 
      ... 
    } 

在更新 Gradle 配置后,当要求时再次同步它!

熟悉 Android 清单

每个应用程序必须有一个AndroidManifest.xml文件,文件必须具有确切的名称。它的位置在其root目录中,在每个模块中,它包含有关应用程序的基本信息。manifest文件负责定义以下内容:

  • 为应用程序命名一个包

  • 描述应用程序的组件--活动(屏幕)、服务、广播接收器(消息)和内容提供程序(数据库访问)

  • 应用程序必须具有的权限,以便访问 Android API 的受保护部分

  • 其他应用程序必须具有的权限,以便与应用程序的组件进行交互,如内容提供程序

以下代码片段显示了manifest文件的一般结构和它可以包含的元素:

    <?xml version="1.0" encoding="utf-8"?> 
    <manifest> 
      <uses-permission /> 
      <permission /> 
      <permission-tree /> 
      <permission-group /> 
      <instrumentation /> 
      <uses-sdk /> 
      <uses-configuration />   
      <uses-feature />   
      <supports-screens />   
      <compatible-screens />   
      <supports-gl-texture />   

      <application> 
        <activity> 
          <intent-filter> 
            <action /> 
              <category /> 
                <data /> 
            </intent-filter> 
            <meta-data /> 
        </activity> 

        <activity-alias> 
          <intent-filter> . . . </intent-filter> 
          <meta-data /> 
        </activity-alias> 

        <service> 
          <intent-filter> . . . </intent-filter> 
          <meta-data/> 
        </service> 

        <receiver> 
          <intent-filter> . . . </intent-filter> 
          <meta-data /> 
        </receiver> 
        <provider> 
          <grant-uri-permission /> 
          <meta-data /> 
          <path-permission /> 
        </provider> 

        <uses-library /> 
      </application> 
    </manifest> 

主应用程序类

每个 Android 应用程序都定义了其主要的Application类。Android 中的Application类是 Android 应用程序中包含所有其他组件(如activitiesservices)的基类。Application类或Application类的任何子类在创建应用程序/包的进程时都会首先实例化。

我们将为 Journaler 创建一个Application类。找到主要源目录。展开它,如果没有 Kotlin 源目录,请创建它。然后,创建package com和子包 journaler;为此,请右键单击 Kotlin 目录,然后选择New | Package。创建包结构后,右键单击journaler包,然后选择 New | KotlinFile/Class。命名为Journaler。创建了Journaler.kt

每个Application类必须扩展 Android Application 类,就像我们的示例中所示的那样:

    package com.journaler 

    import android.app.Application 
    import android.content.Context 

    class Journaler : Application() { 

      companion object { 
        var ctx: Context? = null 
      } 

      override fun onCreate() { 
        super.onCreate() 
        ctx = applicationContext 
      } 

    } 

目前,我们的主Application类将为我们提供对应用程序上下文的静态访问。这个上下文将在以后解释。但是,Android 在清单中提到它之前不会使用这个类。打开app模块android 清单并添加以下代码块:

    <manifest http://www.w3.org/1999/xhtml" class="koboSpan" id="kobo.49.1">    res/android" package="com.journaler"> 

    <application 
        android:name=".Journaler" 
        android:allowBackup="false" 
        android:icon="@mipmap/ic_launcher" 
        android:label="@string/app_name" 
        android:roundIcon="@mipmap/ic_launcher_round" 
        android:supportsRtl="true" 
        android:theme="@style/AppTheme"> 

    </application> 
    </manifest> 

通过android:name=".Journaler",我们告诉 Android 要使用哪个类。

你的第一个屏幕

我们创建了一个没有屏幕的应用程序。我们不会浪费时间,我们会创建一个!创建一个名为activity的新包,其中将定义所有我们的屏幕类,并创建您的第一个Activity类,名为MainActivity.kt。我们将从一个简单的类开始:

    package com.journaler.activity 

    import android.os.Bundle 
    import android.os.PersistableBundle 
    import android.support.v7.app.AppCompatActivity 
    import com.journaler.R 

    class MainActivity : AppCompatActivity() { 
      override fun onCreate(savedInstanceState: Bundle?,
      persistentState: PersistableBundle?) { 
        super.onCreate(savedInstanceState, persistentState) 
        setContentView(R.layout.activity_main) 
      } 
    } 

很快,我们将解释所有这些行的含义。现在,重要的是要注意setContentView(R.layout.activity_main)将 UI 资源分配给我们的屏幕,activity_main是定义它的 XML 的名称。由于我们还没有它,我们将创建它。在main目录下找到res目录。如果那里没有布局文件夹,请创建一个,然后通过右键单击布局目录并选择新建|布局资源文件来创建一个名为activity_main的新布局。将activity_main指定为其名称,LinearLayout指定为其根元素。文件的内容应该类似于这样:

    <?xml version="1.0" encoding="utf-8"?> 
    <LinearLayout http://www.w3.org/1999/xhtml" class="koboSpan" id="kobo.34.1">     apk/res/android" 
      android:orientation="vertical" 
      android:layout_width="match_parent" 
      android:layout_height="match_parent"> 

   </LinearLayout> 

在我们准备运行应用程序之前,还有一件事要做:我们必须告诉清单关于这个屏幕。打开主清单文件并添加以下代码:

    <application ... > 
      <activity 
        android:name=".activity.MainActivity" 
        android:configChanges="orientation" 
        android:screenOrientation="portrait"> 
        <intent-filter> 
          <action android:name="android.intent.action.MAIN" /> 
          <category android:name="android.intent.category.LAUNCHER" /> 
        </intent-filter> 
      </activity> 
    </application> 

我们很快会解释所有这些属性;现在你需要知道的是你的应用程序已经准备好运行了。但是,在此之前,提交并推送你的工作。你不想丢失它!

总结

在本章中,我们介绍了 Android 的基础知识,并展示了 Kotlin 的一瞥。我们配置了一个工作环境,并制作了我们应用程序的第一个屏幕。

在下一章中,我们将深入探讨 Android 的问题。您将学习如何构建您的应用程序并自定义不同的变体。我们还将介绍运行应用程序的不同方式。

第二章:构建和运行

在这一点上,您已成功创建了一个包含一个屏幕的 Android 项目。在上一章中,您还学会了如何设置您的工作环境。我们向您展示了使用 Android 工具是多么简单。您还定义了一些风味和构建类型。让我们控制它!现在是时候进行您的第一个构建并在设备或模拟器上运行它了。您将尝试使用所有构建类型和风味组合。

本章将涵盖以下内容:

  • 在模拟器和/或实际硬件设备上运行您的应用程序

  • Logcat 简介

  • Gradle 工具

运行您的第一个 Android 应用程序

我们制作了我们的第一个屏幕,并为应用程序本身定义了一些具体内容。为了确保我们迄今为止所做的一切都没问题,我们将构建并运行我们的应用程序。我们将运行 completeDebug 构建变体。如果您忘记了如何切换到这个构建变体,我们会提醒您。打开 Android Studio 和Journaler项目。通过单击 Android Studio 窗口左侧的 Build Variants 窗格或选择 View | Tool Windows | Build Variants 来打开 Build Variants 窗格。Build Variants 窗格将出现。选择下拉列表中的 completeDebug,如屏幕截图所示:

我们将使用这个构建变体作为我们的主要构建变体进行尝试执行,对于生产构建,我们将使用 completeDebug 构建变体。在我们从下拉列表中选择构建变体之后,Gradle 需要一些时间来构建所选择的变体。

我们现在将运行我们的应用程序。我们将首先在模拟器上运行,然后在实际设备上运行。通过打开 AVD Manager 来启动您的模拟器实例。单击 AVD Manager 图标来打开它。这是最快的打开方式。双击 AVD 实例。直到您的模拟器准备就绪,这需要一些时间。模拟器执行 Android 系统引导,然后加载默认应用程序启动器。

您的模拟器已启动并准备运行应用程序。为了运行应用程序,单击运行图标或导航到 Run | Run 'app'。

有一个键盘快捷键;在 macOS 上,它是Ctrl + R

当应用程序运行时,会出现“选择部署目标”对话框。如果您有多个实例可以运行应用程序,您可以选择其中一个,如下面的屏幕截图所示:

选择您的部署目标并单击“确定”。如果您想记住您的选择,请勾选“将来启动时使用相同的选择”。应用程序需要一些时间来运行,但几秒钟后,您的应用程序就会出现!

了解 Logcat

Logcat 是日常开发的重要组成部分。它的目的是显示来自您设备的所有日志。它显示来自模拟器或连接的实际设备的日志。Android 有几个级别的日志消息:

  • 断言

  • 冗长的

  • 调试

  • 信息

  • 警告

  • 错误

您可以通过这些日志级别(例如,当您需要仅查看错误--应用程序崩溃堆栈跟踪时)或日志标签(我们稍后会解释)或关键字、正则表达式或应用程序包来过滤日志消息。在应用任何过滤器之前,我们将配置 Android Studio,以便日志消息以不同的颜色显示。

选择 Android Studio | Preferences。在搜索字段中输入Logcat。Logcat 着色首选项将出现,如下面的屏幕截图所示:

要编辑颜色,您必须保存当前颜色主题的副本。从下拉列表中选择您的主题,然后选择“另存为”。选择一个合适的名称并确认:

从列表中选择断言,并取消选中使用继承的属性以覆盖颜色。确保前景选项被选中,并点击位于复选框右侧的颜色来选择日志文本的新颜色。我们将选择一些粉色的色调,如下面的截图所示:

对于断言级别,你可以手动输入十六进制代码:FF6B68。为了最大的可读性,我们建议以下颜色:

  • 断言:#FF6B68

  • 冗长:#BBBBBB

  • 调试:#F4F4F4

  • 信息:#6D82E3

  • 警告:#E57E15

  • 错误:#FF1A11

要应用更改,点击应用,然后点击确定。

打开 Android Monitor(View | Tool Windows | Android Monitor)并查看在 Logcat 窗格中打印的消息。它们以不同的色调着色,每个日志级别都不同,如下所示:

现在我们将定义我们自己的日志消息,这也是一个与 Android 生命周期一起工作的好机会。我们将为我们创建的Application类和屏幕(活动)的每个生命周期事件放置适当的日志消息。

打开你的主Application类,Journaler.kt。扩展代码如下:

    class Journaler : Application() { 

      companion object { 
        val tag = "Journaler" 
        var ctx: Context? = null 
      } 

      override fun onCreate() { 
        super.onCreate() 
        ctx = applicationContext 
        Log.v(tag, "[ ON CREATE ]") 
      } 

      override fun onLowMemory() { 
        super.onLowMemory() 
        Log.w(tag, "[ ON LOW MEMORY ]") 
      } 

      override fun onTrimMemory(level: Int) { 
        super.onTrimMemory(level) 
        Log.d(tag, "[ ON TRIM MEMORY ]: $level") 
     } 
    } 

在这里,我们引入了一些重要的更改。我们重写了onCreate()应用程序的主要生命周期事件。我们还重写了另外两个方法:onLowMemory(),在内存紧张的情况下触发(正在运行的进程应该减少内存使用),以及onTrimMemory(),当内存被修剪时。

为了记录我们应用程序中的事件,我们使用Log类的静态方法,每个方法都暴露了适当的日志级别。基于此,我们有以下方法暴露:

  • 对于冗长级别:
        v(String tag, String msg) 
        v(String tag, String msg, Throwable tr) 
  • 对于调试级别:
        d(String tag, String msg) 
        d(String tag, String msg, Throwable tr) 
  • 对于信息级别:
        i(String tag, String msg) 
        i(String tag, String msg, Throwable tr) 
  • 对于警告级别:
        w(String tag, String msg) 
        w(String tag, String msg, Throwable tr) 
  • 对于错误级别:
        e(String tag, String msg) 
        e(String tag, String msg, Throwable tr) 

方法接受以下参数:

  • 标签:用于标识日志消息的来源

  • message: 这是我们想要记录的消息

  • throwable: 这代表要记录的异常

除了这些日志方法,还有一些其他方法可以使用:

  • wtf(String tag, String msg)

  • wtf(String tag, Throwable tr)

  • wtf(String tag, String msg, Throwable tr)

Wtf代表What a Terrible FailureWtf用于报告不应该发生的异常!

我们将继续使用Log类。打开到目前为止创建的唯一屏幕,并使用以下更改更新MainActivity类:

    class MainActivity : AppCompatActivity() { 
      private val tag = Journaler.tag 

      override fun onCreate( 
        savedInstanceState: Bundle?,  
        persistentState: PersistableBundle? 
       ) { 
          super.onCreate(savedInstanceState, persistentState) 
          setContentView(R.layout.activity_main) 
          Log.v(tag, "[ ON CREATE ]") 
         } 

       override fun onPostCreate(savedInstanceState: Bundle?) { 
         super.onPostCreate(savedInstanceState) 
         Log.v(tag, "[ ON POST CREATE ]") 
       } 

       override fun onRestart() { 
         super.onRestart() 
         Log.v(tag, "[ ON RESTART ]") 
       } 

       override fun onStart() { 
         super.onStart() 
         Log.v(tag, "[ ON START ]") 
       } 

       override fun onResume() { 
         super.onResume() 
         Log.v(tag, "[ ON RESUME ]") 
       } 

       override fun onPostResume() { 
         super.onPostResume() 
         Log.v(tag, "[ ON POST RESUME ]") 
       } 

       override fun onPause() { 
        super.onPause() 
        Log.v(tag, "[ ON PAUSE ]") 
      } 

      override fun onStop() { 
        super.onStop() 
        Log.v(tag, "[ ON STOP ]") 
      } 

      override fun onDestroy() { 
        super.onDestroy() 
        Log.v(tag, "[ ON DESTROY ]") 
      } 
    } 

我们按照活动生命周期中它们执行的顺序重写了所有重要的生命周期方法。对于每个事件,我们打印适当的日志消息。让我们解释生命周期的目的和每个重要事件。

在这里,你可以看到来自 Android 开发者网站的官方图表,解释了活动的生命周期:

你可以在developer.android.com/images/activity_lifecycle.png找到这张图片:

  • onCreate(): 当活动第一次创建时执行。这通常是我们初始化主要 UI 元素的地方。

  • onRestart(): 如果你的活动在某个时刻停止然后恢复,这将被执行。例如,你关闭手机屏幕(锁定它),然后再次解锁。

  • onStart(): 当屏幕对应用程序用户可见时执行。

  • onResume(): 当用户开始与活动交互时执行。

  • onPause(): 在我们恢复之前的活动之前,这个方法在当前活动上执行。这是一个保存所有你在下次恢复时需要的信息的好地方。如果有任何未保存的更改,你应该在这里保存它们。

  • onStop(): 当活动对应用程序用户不再可见时执行。

  • onDestroy():这是在 Android 销毁活动之前执行的。例如,如果有人执行了Activity类的finish()方法,就会发生这种情况。要知道活动是否在特定时刻结束,Android 提供了一个检查的方法:isFinishing()。如果活动正在结束,该方法将返回布尔值true

现在,当我们使用 Android 生命周期编写了一些代码并放置了适当的日志消息后,我们将执行两个用例,并查看 Logcat 打印出的日志。

第一种情况

运行您的应用程序。然后只需返回并离开。关闭应用程序。打开 Android Monitor,并从设备下拉列表中选择您的设备实例(模拟器或真实设备)。从下一个下拉列表中,选择 Journaler 应用程序包。观察以下 Logcat 输出:

您会注意到我们在源代码中放置的日志消息。

让我们检查一下在我们与应用程序交互期间我们进入onCreate()onDestroy()方法的次数。将光标放在搜索字段上,然后键入on create。观察内容的变化--我们预期会有两个条目,但只有一个:一个是主Application类的条目,另一个是主活动的条目。为什么会发生这种情况?我们稍后会找出原因:

我们的输出包含什么?它包含以下内容:

06-27:这是事件发生的日期。

11:37:59.914:这是事件发生的时间。

6713-6713/?:这是带有包的进程和线程标识符。如果应用程序只有一个线程,进程和线程标识符是相同的。

V/Journaler:这是日志级别和标记。

[ ON CREATE ]:这是日志消息。

将过滤器更改为on destroy。内容更改为以下内容:

**06-27 11:38:07.317 6713-6713/com.journaler.complete.dev V/Journaler: [ ON DESTROY ]**

在你的情况下,你会有不同的日期、时间和 pid/tid 值。

从下拉列表中,将过滤器从 Verbose 更改为 Warn。保持过滤器的值!您会注意到您的 Logcat 现在是空的。这是因为没有警告消息包含on destroy的消息文本。删除过滤器文本并返回到 Verbose 级别。

运行您的应用程序。锁定屏幕并连续解锁几次。然后,关闭并终止 Journaler 应用程序。观察以下 Logcat 输出:

正如您所看到的,它明显地进入了暂停和恢复的生命周期状态。最后,我们终止了我们的应用程序,并触发了一个onDestroy()事件。您可以在 Logcat 中看到它。

如果对您来说更容易,您可以从终端使用 Logcat。打开终端并执行以下命令行:

adb logcat

使用 Gradle 构建工具

在我们的开发过程中,我们需要构建不同的构建变体或运行测试。如果需要,这些测试可以仅针对某些构建变体执行,或者针对所有构建变体执行。

在以下示例中,我们将涵盖一些最常见的 Gradle 用例。我们将从清理和构建开始。

正如您记得的,Journaler 应用程序定义了以下构建类型:

  • 调试

  • 发布

  • 暂存

  • 预生产

Journaler 应用程序中还定义了以下构建风味:

  • 演示

  • 完成

  • 特殊

打开终端。要删除到目前为止构建的所有内容和所有临时构建派生物,请执行以下命令行:

./gradlew clean

清理需要一些时间。然后执行以下命令行:

./gradlew assemble.

这将组装所有--我们应用程序中拥有的所有构建变体。想象一下,如果我们正在处理一个非常庞大的项目,它可能会产生什么时间影响。因此,我们将隔离构建命令。要仅构建调试构建类型,请执行以下命令行:

./gradlew assembleDebug 

这将比上一个例子执行得快得多!这为调试构建类型构建了所有的 flavor。为了更有效,我们将指示 Gradle 我们只对调试构建类型的完整构建 flavor 感兴趣。执行这个:

./gradlew assembleCompleteDebug

这将执行得更快。在这里,我们将提到几个更重要的 Gradle 命令:

要运行所有单元测试,请执行:

./gradlew test 

如果你想为特定的构建变体运行单元测试,请执行以下命令:

./gradlew testCompleteDebug

在 Android 中,我们可以在真实设备实例或模拟器上运行测试。通常,这些测试可以访问一些 Android 组件。要执行这些(仪器)测试,你可以使用以下示例中显示的命令:

./gradlew connectedCompleteDebug

你将在本书的最后章节中找到更多关于测试和测试 Android 应用程序的内容。

调试你的应用程序

现在,我们知道如何记录重要的应用程序消息。在开发过程中,当分析应用程序行为或调查 bug 时,仅仅记录消息是不够的。

对我们来说,能够在真实的 Android 设备或模拟器上执行应用程序代码时进行调试是很重要的。所以,让我们来调试一些东西!

打开主Application类,并在我们记录onCreate()方法的行上设置断点,如下所示:

正如你所看到的,我们在第 18 行设置了断点。我们将添加更多断点。让我们在我们的主(也是唯一的)活动中添加。在我们执行日志记录的行上的每个生命周期事件中放置一个断点。

我们在第 18、23、28、33、38 行设置了断点。通过点击调试图标或选择运行|调试应用程序,在调试模式下运行应用程序。应用程序以调试模式启动。稍等一会儿,调试器很快就会进入我们设置的第一个断点。

以下的截图说明了这一点:

正如你所看到的,Application类的onCreate()方法是我们进入的第一个方法。让我们检查一下我们的应用程序是否按预期进入了生命周期方法。点击调试器窗格中的恢复程序图标。你可能会注意到,我们没有进入主活动的onCreate()方法!我们在主Application类的onCreate()方法之后进入了onStart()。恭喜你!你刚刚发现了你的第一个 Android bug!为什么会发生这种情况呢?我们使用了错误的onCreate()方法版本,而不是使用以下代码行:

    void onCreate(@Nullable Bundle savedInstanceState) 

我们不小心重写了这个:

     onCreate(Bundle savedInstanceState, PersistableBundle 
     persistentState) 

多亏了调试,我们发现了这个!通过点击调试器窗格中的停止图标来停止调试器并修复代码。将代码行更改为这样:

    override fun onCreate(savedInstanceState: Bundle?) { 
      super.onCreate(savedInstanceState) 
      setContentView(R.layout.activity_main) 
      Log.v(tag, "[ ON CREATE 1 ]") 
    } 

    override fun onCreate(savedInstanceState: Bundle?, 
    persistentState: PersistableBundle?) { 
      super.onCreate(savedInstanceState, persistentState) 
      Log.v(tag, "[ ON CREATE 2 ]") 
    } 

我们更新了我们的日志消息,这样我们就可以跟踪进入onCreate()方法的两个版本。保存你的更改,并以调试模式重新启动应用程序。不要忘记为两个onCreate()方法重写设置断点!逐个通过断点。现在我们按预期的顺序进入了所有断点。

要查看所有断点,请点击查看断点图标。断点窗口会出现,如下所示:

双击断点,你将定位到设置断点的行。停止调试器。

想象一下,您可以继续开发您的应用程序两年。您的应用程序变得非常庞大,并且还执行一些昂贵的操作。直接在调试模式下运行它可能非常困难和耗时。直到它进入我们感兴趣的断点之前,我们将浪费大量时间。我们能做些什么呢?在调试模式下运行的应用程序速度较慢,而我们的应用程序又又大又慢。如何跳过我们正在浪费宝贵时间的部分?我们将进行演示。通过单击运行图标或选择运行|运行'app'来运行您的应用程序。应用程序在我们的部署目标(真实设备或模拟器)上执行并启动。通过单击附加调试器到 Android 进程图标或选择运行|附加调试器到 Android 来将调试器附加到您的应用程序。选择出现的进程窗口:

通过双击其包名称来选择我们的应用程序过程。调试器窗格出现。从您的应用程序中,尝试返回。调试器进入主活动的onPause()方法。停止调试器。

总结

在这一章中,您学会了如何从 Android Studio IDE 或直接从终端构建和运行应用程序。我们还分析了一些来自模拟器和真实设备的日志。最后,我们进行了一些调试。

在下一章中,我们将熟悉一些 UI 组件--屏幕,更准确地说。我们将向您展示如何创建新屏幕以及如何为它们添加一些时尚细节。我们还将讨论按钮和图像的复杂布局。

第三章:屏幕

一个只有简单用户界面的屏幕一点也不令人兴奋。然而,在你进行眼睛糖样式和效果之前,你需要创建更多包含专业开发应用程序必须具有的所有元素的屏幕。你在日常生活中使用的现代应用程序中都可以看到这一点。在上一章中,我们构建并运行了我们的项目。这种技能很重要,这样我们才能继续我们的进展。现在你将在你的应用程序中添加一个 UI!

在本章中,我们将涵盖以下主题:

  • 分析模拟

  • 定义应用程序活动

  • Android 布局

  • Android 上下文

  • 片段、片段管理器和堆栈

  • 视图翻页器

  • 事务、对话框片段和通知

  • 其他重要的 UI 组件

分析模拟计划

事情正在变得有趣!我们准备开始一些严肃的开发!我们将为我们的应用程序创建所有的屏幕。然而,在我们创建它们之前,我们将创建并分析一个模拟,这样我们就知道我们将创建什么。模拟将代表基本的应用程序线框,没有设计。它只是屏幕和它们之间的关系的布局。要创建一个带有线框的好模拟,你需要一个工具。任何能够画线的工具都可以胜任。为了绘制我们的模拟,我们使用了Pencil。Pencil 是一个提供 GUI 原型的免费开源应用程序。

让我们来看看我们的模拟:

正如你所看到的,模拟呈现了一个相对简单的应用程序,具有一些屏幕。这些屏幕将包含不同的组件,我们将在每个屏幕中解释这些组件。让我们来看看模拟。

第一个屏幕,标题为登陆界面,将是我们的主要应用程序屏幕。每次进入应用程序时,都会出现此屏幕。我们已经定义了MainActivity类。这个活动将代表这个屏幕。很快,我们将扩展代码,使活动完全按照模拟进行。

屏幕的中心部分将是包含我们创建的所有项目的列表。每个项目将包含基本属性,如标题或日期和时间。我们将能够按类型过滤项目。我们将只能过滤笔记或 TODO。笔记和 TODO 之间的区别在于 TODO 将代表具有分配的日期时间的任务。我们还将支持一些功能,如长按事件。在每个项目上的长按事件将呈现一个弹出菜单,其中包含编辑、删除或完成选项。点击编辑将打开更新屏幕。

在右下角,我们将有一个+按钮。该按钮的目的是打开选项对话框,用户可以选择他们想要创建笔记还是 TODO 任务。根据选项,用户可以选择出现的屏幕之一--添加笔记屏幕或添加 TODO 屏幕。

登陆界面还包含位于左上角的滑动菜单按钮。点击该按钮将打开滑动菜单,其中包含以下项目:

  • 一个带有应用程序标题和版本的应用程序图标

  • 一个今天的按钮,用于仅过滤分配给当前日期的 TODO 项目

  • 一个下一个 7 天的按钮,用于过滤分配给下一个 7 天的 TODO 项目,包括当前的日期

  • 一个 TODO 按钮仅过滤 TODO 项目

  • 笔记按钮将仅过滤笔记项目

应用一些过滤器将影响我们通过点击登陆界面右上角获得的弹出菜单中的复选框。此外,选中和取消选中这些复选框将修改当前应用的过滤器。

滑动菜单中的最后一项是立即同步。点击此按钮将触发同步,并将所有未同步的项目与后端进行同步。

现在我们将解释两个负责创建(或编辑)笔记和 TODO 的屏幕:

  • 添加/编辑笔记屏幕:用于创建新的笔记或更新现有内容。当编辑文本字段聚焦时,键盘将打开。由于我们计划立即应用我们所做的所有更改,因此没有保存或更新按钮。在此屏幕上,左上角和右上角的按钮也被禁用。

  • 添加/编辑 TODO 屏幕:用于创建新的 TODO 应用程序或更新现有内容。键盘将像前面的示例一样打开。也没有像前面的示例中显示的保存或更新按钮。左上角和右上角的按钮也被禁用。在标题视图之后,我们有按钮来选择日期和时间。默认情况下,它们将设置为当前日期和时间。打开键盘将推动这些按钮。

我们已经涵盖了基本的 UI 和通过分析这个模型我们想要实现的内容。现在是时候创建一些新的屏幕了。

定义应用程序活动

总之,我们将有三个活动:

  • 登陆活动(MainActivty.kt

  • 添加/编辑笔记屏幕

  • 添加/编辑 TODO 屏幕

在 Android 开发中,通常会创建一个活动,作为所有其他活动的父类,因为这样,我们将减少代码库,并同时与多个活动共享。在大多数情况下,Android 开发人员称之为BaseActivity。我们将定义我们自己的BaseActivity版本。创建一个名为BaseActivity的新类;创建BaseActivity.kt文件。确保新创建的类位于项目的Activity包下。

BaseActivity类必须扩展 Android SDK 的FragmentActivity类。我们将扩展FragmentActivity,因为我们计划在MainActivity类中使用片段。片段将与 ViewPager 一起使用,以在不同的过滤器之间导航(今天,接下来的 7 天等)。我们计划当用户从我们的侧滑菜单中点击其中一个时,ViewPager 会自动切换到包含由所选条件过滤的数据的片段的位置。我们将从包android.support.v4.app.FragmentActivity扩展FragmentActivity

Android 提供了支持多个 API 版本的方法。因为我们计划这样做,我们将使用支持库中的FragmentActivity版本。这样,我们最大化了兼容性!要为 Android 支持库添加支持,请在build.gradle配置中包含以下指令:

    compile 'com.android.support:appcompat-v7:26+' 

您可能还记得,我们已经这样做了!

让我们继续!由于我们正在为所有活动引入一个基类,我们现在必须对我们现有的唯一活动进行一些小的重构。我们将tag字段从MainActivity移动到BaseActivity。由于它必须对BaseActivity的子类可访问,我们将更新其可见性为protected

我们希望每个Activity类都有其独特的标签。我们将使用活动具体化来选择其标签的值。因此,tag字段变为abstract,没有分配默认值:

    protected abstract val tag : String 

此外,所有活动中还有一些共同的东西。每个活动都将有一个布局。布局在 Android 中由整数类型的 ID 标识。在BaseActivity类中,我们将创建一个abstract方法,如下:

    protected abstract fun getLayout(): Int 

为了优化代码,我们将把onCreateMainActivity移动到BaseActivity。我们将不再直接传递 Android 生成的资源中布局的 ID,而是传递getLayout()方法的结果值。我们也会移动所有其他生命周期方法的覆盖。

根据这些更改更新您的类,并按以下方式构建和运行应用程序:

    BasicActivity.kt:
    package com.journaler.activity 
    import android.os.Bundle 
    import android.support.v4.app.FragmentActivity 
    import android.util.Log 

    abstract class BaseActivity : FragmentActivity() { 
      protected abstract val tag : String 
      protected abstract fun getLayout(): Int 

      override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        setContentView(getLayout()) 
        Log.v(tag, "[ ON CREATE ]") 
      } 

      override fun onPostCreate(savedInstanceState: Bundle?) { 
        super.onPostCreate(savedInstanceState) 
        Log.v(tag, "[ ON POST CREATE ]") 
      } 

      override fun onRestart() { 
        super.onRestart() 
        Log.v(tag, "[ ON RESTART ]") 
      } 

      override fun onStart() { 
        super.onStart() 
        Log.v(tag, "[ ON START ]") 
      } 

      override fun onResume() { 
        super.onResume() 
        Log.v(tag, "[ ON RESUME ]") 
      } 

      override fun onPostResume() { 
        super.onPostResume() 
        Log.v(tag, "[ ON POST RESUME ]") 
      } 

      override fun onPause() { 
        super.onPause() 
        Log.v(tag, "[ ON PAUSE ]") 
      } 

      override fun onStop() { 
        super.onStop() 
        Log.v(tag, "[ ON STOP ]") 
      } 

      override fun onDestroy() { 
        super.onDestroy() 
        Log.v(tag, "[ ON DESTROY ]") 
      } 

    } 
    MainActivity.kt:
    package com.journaler.activity 
    import com.journaler.R 

    class MainActivity : BaseActivity() { 
      override val tag = "Main activity" 
      override fun getLayout() = R.layout.activity_main 
    }

现在,我们准备定义其余的屏幕。我们必须创建一个用于添加和编辑笔记的屏幕,以及一个用于 TODO 的相同功能的屏幕。这些屏幕之间有很多共同之处。目前唯一的区别是 TODO 屏幕有日期和时间的按钮。我们将为这些屏幕共享的所有内容创建一个通用类。每个具体化都将扩展它。创建一个名为ItemActivity的类。确保它位于Activity包中。再创建两个类--NoteActivityTodoActivityItemActivity扩展我们的BaseActivity类,NoteActivityTodoActivity活动类扩展ItemActivity类。您将被要求覆盖成员。请这样做。为我们在日志中使用的标签赋予一些有意义的值。要分配适当的布局 ID,首先我们必须创建它!

找到我们为主屏幕创建的布局。现在,使用相同的原则,创建另外两个布局:

  • activity_note.xml,如果被问到,让它成为LinearLayout类。

  • activity_todo.xml,如果被问到,让它成为LinearLayout

在 Android 中,任何布局或布局成员都会在构建过程中由 Android 生成的R类中获得唯一的 ID 作为integer表示。我们应用程序的R类如下:

    com.journaler.R 

要访问布局,请使用以下代码行:

    R.layout.layout_you_are_interested_in 

我们使用静态访问。因此,让我们更新我们的类具体化以访问布局 ID。类现在看起来像这样:

    ItemActivity.kt:
    abstract class ItemActivity : BaseActivity()
    For now, this class is short and simple.
    NoteActivity.kt:
    package com.journaler.activity
    import com.journaler.R
    class NoteActivity : ItemActivity(){
      override val tag = "Note activity"
      override fun getLayout() = R.layout.activity_note 
    }
    Pay attention on import for R class!
    TodoActivity.kt: 
    package com.journaler.activity 
    import com.journaler.Rclass TodoActivity : ItemActivity(){
      override val tag = "Todo activity" 
      override fun getLayout() = R.layout.activity_todo
    }

最后一步是在view groups中注册我们的屏幕(活动)。打开manifest文件并添加以下内容:

    <activity 
      android:name=".activity.NoteActivity" 
      android:configChanges="orientation" 
      android:screenOrientation="portrait" /> 

      <activity 
        android:name=".activity.TodoActivity" 
        android:configChanges="orientation" 
        android:screenOrientation="portrait" /> 

两个活动都锁定为“竖屏”方向。

我们取得了进展!我们定义了我们的应用程序屏幕。在下一节中,我们将用 UI 组件填充屏幕。

Android 布局

我们将继续通过定义每个屏幕的布局来继续我们的工作。在 Android 中,布局是用 XML 定义的。我们将提到最常用的布局类型,并用常用的布局组件填充它们。

每个布局文件都有一个布局类型作为其顶级容器。布局可以包含其他具有 UI 组件等的布局。我们可以嵌套它。让我们提到最常用的布局类型:

  • 线性布局:这将以线性顺序垂直或水平对齐 UI 组件

  • 相对布局:这些 UI 组件相对地对齐

  • 列表视图布局:所有项目都以列表形式组织

  • 网格视图布局:所有项目都以网格形式组织

  • 滚动视图布局:当其内容变得高于屏幕的实际高度时,用于启用滚动

我们刚刚提到的布局元素是view groups。每个视图组包含其他视图。View groups扩展了ViewGroup类。在顶部,一切都是View类。扩展View类但不扩展ViewGroup的类(视图)不能包含其他元素(子元素)。这样的例子是ButtonImageButtonImageView和类似的类。因此,例如,可以定义一个包含LinearLayoutRelativeLayout,该LinearLayout包含垂直或水平对齐的其他多个视图等。

我们现在将突出显示一些常用的视图:

  • Button:这是一个与我们定义的onClick操作相关联的Base类按钮

  • ImageButton:这是一个带有图像作为其视觉表示的按钮

  • ImageView:这是一个显示从不同来源加载的图像的视图

  • TextView:这是一个包含单行或多行不可编辑文本的视图

  • EditText:这是一个包含单行或多行可编辑文本的视图

  • WebView:这是一个呈现从不同来源加载的渲染 HTML 页面的视图

  • CheckBox:这是一个主要的两状态选择视图

每个ViewViewGroup都支持杂项 XML 属性。一些属性仅适用于特定的视图类型。还有一些属性对所有视图都是相同的。我们将在本章后面的屏幕示例中突出显示最常用的视图属性。

为了通过代码或其他布局成员访问视图,必须定义一个唯一标识符。要为视图分配 ID,请使用以下示例中的语法:

    android:id="@+id/my_button" 

在这个例子中,我们为一个视图分配了my_button ID。要从代码中访问它,我们将使用以下方法:

    R.id.my_button 

R是一个生成的类,为我们提供对资源的访问。要创建按钮的实例,我们将使用 Android Activity类中定义的findViewById()方法:

    val x = findViewById(R.id.my_button) as Button 

由于我们使用了 Kotlin,我们可以直接访问它,如本例所示:

    my_button.setOnClickListener { ... } 

IDE 会询问您有关适当的导入。请记住,其他布局资源文件可能具有相同名称的 ID 定义。在这种情况下,可能会发生错误的导入!如果发生这种情况,您的应用程序将崩溃。

字符串开头的@符号表示 XML 解析器应解析并扩展 ID 字符串的其余部分,并将其标识为 ID 资源。+符号表示这是一个新的资源名称。当引用 Android 资源 ID 时,不需要+符号,如本例所示:

    <ImageView 
      android:id="@+id/flowers" 
      android:layout_width="fill_parent" 
      android:layout_height="fill_parent" 
      android:layout_above="@id/my_button" 
    /> 

让我们为主应用程序屏幕构建我们的 UI!我们将从一些先决条件开始。在值资源目录中,创建dimens.xml来定义我们将使用的一些尺寸:

    <?xml version="1.0" encoding="utf-8"?> 
    <resources> 
      <dimen name="button_margin">20dp</dimen> 
      <dimen name="header_height">50dp</dimen> 
    </resources> 

Android 以以下单位定义尺寸:

  • 像素(pixels):这对应于屏幕上的实际像素

  • 英寸(inches):这是基于屏幕的物理尺寸,即 1 英寸=2.54 厘米

  • 毫米(millimeters):这是基于屏幕的物理尺寸

  • 点(points):这是基于屏幕的物理尺寸的 1/72

对我们来说最重要的是以下内容:

  • dp(密度无关像素):这代表一个基于屏幕物理密度的抽象单位。它们相对于 160 DPI 的屏幕。一个 dp 在 160 DPI 屏幕上等于一个像素。dp 到像素的比率会随着屏幕密度的变化而改变,但不一定成正比。

  • sp(可伸缩像素):这类似于 dp 单位,通常用于字体大小。

我们必须定义一个将包含在所有屏幕上的页眉布局。创建activity_header.xml文件并像这样定义它:

    <?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="@dimen/header_height"> 
    <Button 
      android:id="@+id/sliding_menu" 
      android:layout_width="@dimen/header_height" 
      android:layout_height="match_parent" 
      android:layout_alignParentStart="true" /> 

    <TextView 
      android:layout_centerInParent="true" 
      android:id="@+id/activity_title" 
      android:layout_width="wrap_content" 
      android:layout_height="wrap_content" /> 

    <Button 
      android:id="@+id/filter_menu" 
      android:layout_width="@dimen/header_height" 
      android:layout_height="match_parent" 
      android:layout_alignParentEnd="true" /> 

    </RelativeLayout> 

让我们解释其中最重要的部分。首先,我们将RelativeLayout定义为我们的主容器。由于所有元素都相对于父元素和彼此定位,我们将使用一些特殊属性来表达这些关系。

对于每个视图,我们必须有宽度和高度属性。其值可以如下:

  • 在尺寸资源文件中定义的尺寸,例如:
        android:layout_height="@dimen/header_height" 
  • 直接定义的尺寸值,例如:
        android:layout_height="50dp" 
  • 匹配父级的大小(match_parent

  • 或者包装视图的内容(wrap_content

然后,我们将使用子视图填充布局。我们有三个子视图。我们将定义两个按钮和一个文本视图。文本视图对齐到布局的中心。按钮对齐到布局的边缘——一个在左边,另一个在右边。为了实现文本视图的中心对齐,我们使用了layout_centerInParent属性。传递给它的值是布尔值 true。为了将按钮对齐到布局的左边缘,我们使用了layout_alignParentStart属性。对于右边缘,我们使用了layout_alignParentEnd属性。每个子视图都有一个适当的 ID 分配。我们将在MainActivity中包含这个:

    <?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"> 

    <include layout="@layout/activity_header" /> 

    <RelativeLayout 
        android:layout_width="match_parent" 
        android:layout_height="match_parent"> 
     <ListView 
        android:id="@+id/items" 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" 
        android:background="@android:color/darker_gray" /> 

     <android.support.design.widget.FloatingActionButton 
        android:id="@+id/new_item" 
        android:layout_width="wrap_content" 
        android:layout_height="wrap_content" 
        android:layout_alignParentBottom="true" 
        android:layout_alignParentEnd="true" 
        android:layout_margin="@dimen/button_margin" /> 

    </RelativeLayout> 
    </LinearLayout> 

Main activity的主容器是LinearLayoutLinearLayout的方向属性是强制性的:

    android:orientation="vertical" 

可以分配给它的值是垂直和水平。作为Main activity的第一个子元素,我们包含了activity_header布局。然后我们定义了RelativeLayout,它填充了屏幕的其余部分。

RelativeLayout有两个成员,ListView将呈现所有的项目。我们为它分配了一个背景。我们没有在颜色资源文件中定义自己的颜色,而是使用了 Android 中预定义的颜色。我们在这里的最后一个视图是FloatingActionButton,和你在 Gmail Android 应用程序中看到的一样。按钮将被定位在屏幕底部对齐右侧的项目列表上。我们还设置了一个边距,将从四面包围按钮。看一下我们使用的属性。

在我们再次运行应用程序之前,我们将做一些更改。打开BaseActivity并更新其代码如下:

    ... 
    protected abstract fun getActivityTitle(): Int 

    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        setContentView(getLayout()) 
        activity_title.setText(getActivityTitle()) 
        Log.v(tag, "[ ON CREATE ]") 
    } 
    ... 

我们引入了一个abstract方法,它将为每个活动提供一个适当的标题。我们将accessactivity_header.xml中定义的activity_title视图,它包含在我们的活动中,并赋予我们执行该方法得到的值。

打开MainActivity并重写以下方法:

    override fun getActivityTitle() = R.string.app_name

ItemActivity中添加相同的行。最后,运行应用程序。你的主屏幕应该是这样的:

让我们为其余的屏幕定义布局。对于笔记、添加/编辑笔记屏幕,我们将定义以下布局:

    <?xml version="1.0" encoding="utf-8"?> 
    <ScrollView xmlns:android=
     "http://schemas.android.com/apk/res/android" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
    android:fillViewport="true" > 

    <LinearLayout 
      android:layout_width="match_parent" 
      android:layout_height="wrap_content" 
      android:orientation="vertical"> 

      <include layout="@layout/activity_header" /> 

      <EditText 
        android:id="@+id/note_title" 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:hint="@string/title" 
        android:padding="@dimen/form_padding" /> 

      <EditText 
        android:id="@+id/note_content" 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" 
        android:gravity="top" 
        android:hint="@string/your_note_content_goes_here" 
        android:padding="@dimen/form_padding" /> 

    </LinearLayout> 
    </ScrollView> 

有一些重要的事情我们必须强调。我们会逐一解释它们。我们将ScrollView作为我们布局的顶级容器。由于我们将填充多行注释,它的内容可能会超出屏幕的物理限制。如果发生这种情况,我们将能够滚动内容。我们使用了一个非常重要的属性--fillViewport。这个属性告诉容器要拉伸到整个屏幕。所有子元素都使用这个空间。

使用 EditText 视图

我们引入了EditText视图来输入可编辑的文本内容。你可以在这里看到一些新的属性:

  • hint:这定义了将呈现给用户的默认字符串值

  • padding:这是视图本身和其内容之间的空间

  • gravity:这定义了内容的方向;在我们的例子中,所有的文本都将粘在父视图的顶部

请注意,对于所有的字符串和尺寸,我们在strings.xml文件和dimens.xml文件中定义了适当的条目。

现在字符串资源文件看起来是这样的:

    <resources> 
      <string name="app_name">Journaler</string> 
      <string name="title">Title</string> 
      <string name="your_note_content_goes_here">Your note content goes 
      here.</string> 
    </resources> 
    Todos screen will be very similar to this: 
    <?xml version="1.0" encoding="utf-8"?> 
    <ScrollView xmlns:android=
    "http://schemas.android.com/apk/res/android" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
    android:fillViewport="true"> 

    <LinearLayout 
      android:layout_width="match_parent" 
      android:layout_height="wrap_content" 
      android:orientation="vertical"> 

    <include layout="@layout/activity_header" /> 

    <EditText 
      android:id="@+id/todo_title" 
      android:layout_width="match_parent" 
      android:layout_height="wrap_content" 
      android:hint="@string/title" 
      android:padding="@dimen/form_padding" /> 

    <LinearLayout 
      android:layout_width="match_parent" 
      android:layout_height="wrap_content" 
      android:orientation="horizontal" 
      android:weightSum="1"> 

   <Button 
      android:id="@+id/pick_date" 
      android:text="@string/pick_a_date" 
      android:layout_width="0dp" 
      android:layout_height="wrap_content" 
      android:layout_weight="0.5" /> 

   <Button 
      android:id="@+id/pick_time" 
      android:text="@string/pick_time" 
      android:layout_width="0dp" 
      android:layout_height="wrap_content" 
      android:layout_weight="0.5" /> 

   </LinearLayout> 

   <EditText 
      android:id="@+id/todo_content" 
      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:gravity="top" 
      android:hint="@string/your_note_content_goes_here" 
      android:padding="@dimen/form_padding" />  
   </LinearLayout> 
   </ScrollView> 

再次,顶级容器是ScrollView。与之前的屏幕相比,我们引入了一些不同之处。我们添加了一个容器来容纳日期和时间选择的按钮。方向是水平的。我们设置了父容器属性weightSum,以定义可以被子视图分割的权重值,这样每个子视图都可以占据其自己权重定义的空间量。所以,weightSum是 1。第一个按钮的layout_weight0.5。它将占据水平空间的 50%。第二个按钮也是相同的值。我们实现了视图分割成两半。定位到 XML 的底部,点击 Design 切换到 Design 视图。你的按钮应该是这样的:

我们为我们的屏幕定义了布局。为了表达这些屏幕应该是什么样子,我们依赖于许多不同的属性。这只是我们可以使用的可用属性的一小部分。为了使这一部分完整,我们将向您介绍一些其他重要的属性,这些属性在日常开发中会用到。

边距属性

边距接受维度资源或直接维度值,支持以下支持的单位之一:

  • layout_margin

  • layout_marginTop

  • layout_marginBottom

  • layout_marginStart

  • layout_marginEnd

填充属性

填充接受维度资源或直接维度值,支持以下支持的单位之一:

  • padding

  • paddingTop

  • paddingBottom

  • paddingStart

  • paddingEnd

检查重力属性

视图重力:

  • 重力(视图内内容的方向):这接受以下内容--topleftrightstartendcentercenter_horizontalcenter_vertical,以及许多其他

  • layout_gravity(视图父级内内容的方向):这接受以下内容--topleftrightleftstartendcentercenter_horizontalcenter_vertical,以及许多其他

可以将重力的值组合如下:

    android:gravity="top|center_horizontal" 

查看其他属性

我们刚刚看到了我们将使用的最重要的属性。现在是时候看看其他你可能会发现方便的属性了。其他属性如下:

  • src:这是要使用的资源:
        android:src="img/icon" 
  • background:视图的背景,十六进制颜色或颜色资源如下:
        android:background="#ddff00" 
        android:background="@color/colorAccent" 
  • onClick:这是当用户点击视图(通常是按钮)时要调用的方法

  • visibility:这是视图的可见性,接受以下参数--gone(不可见且不占用任何布局空间),invisible(不可见但占用布局空间),visible

  • hint:这是视图的提示文本,它接受一个字符串值或字符串资源

  • text:这是视图的文本,它接受一个字符串值或字符串资源

  • textColor:这是文本的颜色,十六进制颜色或颜色资源

  • textSize:这是支持单位的文本大小--直接单位值或尺寸资源

  • textStyle:这是定义要分配给视图的属性的样式资源,如下:

        style="@style/my_theme" 
        ...

在这一部分,我们介绍了使用属性。没有它们,我们无法开发我们的 UI。在本章的其余部分,我们将向您介绍安卓上下文。

理解安卓上下文

我们所有的主屏幕现在都有了它们的布局定义。现在我们将解释安卓上下文,因为我们刚刚创建的每个屏幕都代表一个Context实例。如果您查看类定义并遵循类扩展,您将意识到我们创建的每个活动都扩展了Context类。

Context代表应用程序或对象的当前状态。它用于访问应用程序的特定类和资源。例如,考虑以下代码行:

    resources.getDimension(R.dimen.header_height) 
    getString(R.string.app_name) 

我们展示的访问是由Context类提供的,它显示了我们的活动是如何扩展的。当我们需要启动另一个活动、启动服务或发送广播消息时,需要Context。当时机合适时,我们将展示这些方法的使用。我们已经提到,安卓应用的每个屏幕(Activity)都代表一个Context实例。活动并不是唯一代表上下文的类。除了活动,我们还有服务上下文类型。

安卓上下文有以下目的:

  • 显示对话框

  • 启动活动

  • 充气布局

  • 启动服务

  • 绑定到服务

  • 发送广播消息

  • 注册广播消息

  • 而且,就像我们在前面的例子中已经展示的那样,加载资源

Context是安卓的重要组成部分,也是框架中最常用的类之一。在本书的后面,您将遇到其他Context类。然而,在那之前,我们将专注于片段及其解释。

理解片段

我们已经提到,我们的主屏幕的中心部分将包含一个经过筛选的项目列表。我们希望有几个页面应用不同的筛选集。用户将能够向左或向右滑动以更改筛选内容并浏览以下页面:

  • 所有显示的

  • 今天的事项

  • 未来 7 天的事项

  • 只有笔记

  • 只有待办事项

为了实现这个功能,我们需要定义片段。片段是什么,它们的目的是什么?

片段是Activity实例界面的一部分。您可以使用片段创建多平面屏幕或具有视图分页的屏幕,就像我们的情况一样。

就像活动一样,片段也有自己的生命周期。片段生命周期在以下图表中呈现:

有一些活动没有的额外方法:

  • onAttach(): 当片段与活动关联时执行。

  • onCreateView(): 这实例化并返回片段的视图实例。

  • onActivityCreated(): 当活动的onCreate()被执行时执行。

  • onDestroyView(): 当视图被销毁时执行;当需要进行一些清理时很方便。

  • onDetach(): 当片段与活动解除关联时执行。为了演示片段的使用,我们将MainActivity的中心部分放入一个单独的片段中。稍后,我们将把它移到ViewPager并添加更多页面。

创建一个名为fragment的新包。然后,创建一个名为BaseFragment的新类。根据此示例更新您的BaseFragment类:

    package com.journaler.fragment 

    import android.os.Bundle 
    import android.support.v4.app.Fragment 
    import android.util.Log 
    import android.view.LayoutInflater 
    import android.view.View 
    import android.view.ViewGroup 

    abstract class BaseFragment : Fragment() { 
      protected abstract val logTag : String 
      protected abstract fun getLayout(): Int 

    override fun onCreateView( 
      inflater: LayoutInflater?, container: ViewGroup?,
      savedInstanceState: Bundle? 
      ): View? { 
        Log.d(logTag, "[ ON CREATE VIEW ]") 
        return inflater?.inflate(getLayout(), container, false) 
     } 

     override fun onPause() { 
        super.onPause() 
        Log.v(logTag, "[ ON PAUSE ]") 
     } 

     override fun onResume() { 
        super.onResume() 
        Log.v(logTag, "[ ON RESUME ]") 
     } 

     override fun onDestroy() { 
        super.onDestroy() 
        Log.d(logTag, "[ ON DESTROY ]") 
     } 

    } 

注意导入:

    import android.support.v4.app.Fragment 

我们希望最大限度地提高兼容性,因此我们正在从 Android 支持库中导入片段。

正如您所看到的,我们做了与BaseActivity相似的事情。创建一个新的片段,一个名为ItemsFragment的类。根据此示例更新其代码:

    package com.journaler.fragment 
    import com.journaler.R 

    class ItemsFragment : BaseFragment() { 
      override val logTag = "Items fragment" 
      override fun getLayout(): Int { 
        return R.layout.fragment_items 
      } 
    } 

我们引入了一个实际包含我们在activity_main中的列表视图的新布局。创建一个名为fragment_items的新布局资源:

    <?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"> 

    <ListView 
      android:id="@+id/items" 
      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:background="@android:color/darker_gray" /> 

    <android.support.design.widget.FloatingActionButton 
      android:id="@+id/new_item" 
      android:layout_width="wrap_content" 
      android:layout_height="wrap_content" 
      android:layout_alignParentBottom="true" 
      android:layout_alignParentEnd="true" 
      android:layout_margin="@dimen/button_margin" /> 

    </RelativeLayout> 

您已经看到了这个。这只是我们从activity_main布局中提取出来的一部分。除此之外,我们将以下内容放入activity_main布局中:

    <?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"> 
    <include layout="@layout/activity_header" /> 

    <FrameLayout 
       android:id="@+id/fragment_container" 
       android:layout_width="match_parent" 
       android:layout_height="match_parent" /> 
    </LinearLayout> 

FrameLayout将是我们的fragment容器。要在fragment_container``FrameLayout中显示新片段,请按照以下方式更新MainActivity的代码:

    class MainActivity : BaseActivity() { 

      override val tag = "Main activity" 
      override fun getLayout() = R.layout.activity_main 
      override fun getActivityTitle() = R.string.app_name 

      override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        val fragment = ItemsFragment() 
        supportFragmentManager 
                .beginTransaction() 
                .add(R.id.fragment_container, fragment) 
                .commit() 
     } 
    } 

我们访问了supportFragmentManager。如果我们选择不使用 Android 支持库,我们将使用fragmentManager。然后,我们开始片段事务,我们添加一个新的片段实例,该实例将与fragment_container FrameLayout相关联。commit方法执行此事务。如果我们现在运行我们的应用程序,我们不会注意到任何不同,但是,如果我们查看日志,我们可能会注意到片段生命周期已被执行:

    V/Journaler: [ ON CREATE ] 
    V/Main activity: [ ON CREATE ] 
    D/Items fragment: [ ON CREATE VIEW ] 
    V/Main activity: [ ON START ] 
    V/Main activity: [ ON POST CREATE ] 
    V/Main activity: [ ON RESUME ] 
    V/Items fragment: [ ON RESUME ] 
    V/Main activity: [ ON POST RESUME ] 

我们在界面中添加了一个简单的片段。在下一节中,您将了解有关片段管理器及其目的的更多信息。然后,我们将做一些非常有趣的事情--我们将创建一个ViewPager

片段管理器

负责与当前活动中的片段进行交互的组件是片段管理器。我们可以使用两种不同导入形式的FragmentManager

  • android.app.FragmentManager

  • android.support.v4.app.Fragment

建议从 Android 支持库导入。

使用beginTransaction()方法开始片段事务以执行一系列编辑操作。它将返回一个事务实例。要添加一个片段(通常是第一个),请使用add方法,就像我们的示例中一样。该方法接受相同的参数,但如果已经添加,则替换当前片段。如果我们计划通过片段向后导航,需要使用addToBackStack方法将事务添加到返回堆栈。它接受一个名称参数,如果我们不想分配名称,则为 null。

最后,我们通过执行commit()来安排事务。这不是瞬时操作。它安排在应用程序的主线程上执行操作。当主线程准备好时,事务将被执行。在规划和实施代码时,请考虑这一点!

碎片堆栈

为了说明片段和返回堆栈的示例,我们将进一步扩展我们的应用程序。我们将创建一个片段来显示包含文本Lorem ipsum的用户手册。首先,我们需要创建一个新的片段。创建一个名为fragment_manual的新布局。根据此示例更新布局:

    <?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:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:layout_margin="10dp" 
      android:text="@string/lorem_ipsum_sit_dolore" 
      android:textSize="14sp" /> 
    </LinearLayout> 

这是一个简单的布局,包含了跨越整个父视图的文本视图。将使用这个布局的片段将被称为ManualFragment。为片段创建一个类,并确保它具有以下内容:

     package com.journaler.fragment 
     import com.journaler.R 

     class ManualFragment : BaseFragment() { 
      override val logTag = "Manual Fragment" 
      override fun getLayout() = R.layout.fragment_manual 
    } 

最后,让我们将其添加到片段返回堆栈。更新MainActivityonCreate()方法如下:

    override fun onCreate(savedInstanceState: Bundle?) { 
      super.onCreate(savedInstanceState) 
      val fragment = ItemsFragment() 
      supportFragmentManager 
                .beginTransaction() 
                .add(R.id.fragment_container, fragment) 
                .commit() 
      filter_menu.setText("H") 
      filter_menu.setOnClickListener { 
        val userManualFrg = ManualFragment() 
        supportFragmentManager 
                    .beginTransaction() 
                    .replace(R.id.fragment_container, userManualFrg) 
                    .addToBackStack("User manual") 
                    .commit() 
        } 
    } 

构建并运行应用程序。右上角的标题按钮将标签为H;点击它。包含Lorem ipsum文本的片段填充视图。点击返回按钮,片段消失。这意味着你成功地将片段添加到返回堆栈并移除了它。

我们还需要尝试一件事--连续两到三次点击同一个按钮。点击返回按钮。然后再次。再次。你将通过返回堆栈直到达到第一个片段。如果你再次点击返回按钮,你将离开应用程序。观察你的 Logcat。

你还记得生命周期方法执行的顺序吗?你可以认识到每次一个新的片段被添加到顶部时,下面的片段会暂停。当我们按下返回按钮开始后退时,顶部的片段暂停,下面的片段恢复。从返回堆栈中移除的片段最终进入onDestroy()方法。

创建 View Pager

正如我们提到的,我们希望我们的项目显示在可以滑动的几个页面上。为此,我们需要ViewPagerViewPager使得在片段集合的一部分之间进行滑动成为可能。我们将对我们的代码进行一些更改。打开activity_main布局并像这样更新它:

    <?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"> 
    <android.support.v4.view.ViewPager  xmlns:android=
    "http://schemas.android.com/apk/res/android" 
        android:id="@+id/pager" 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" /> 

    </LinearLayout> 

我们将FrameLayout替换为ViewPager视图。然后,打开MainActivity类,并像这样更新它:

    class MainActivity : BaseActivity() { 
      override val tag = "Main activity" 
      override fun getLayout() = R.layout.activity_main 
      override fun getActivityTitle() = R.string.app_name 

      override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        pager.adapter = ViewPagerAdapter(supportFragmentManager) 
    } 

    private class ViewPagerAdapter(manager: FragmentManager) :  
    FragmentStatePagerAdapter(manager) { 
      override fun getItem(position: Int): Fragment { 
        return ItemsFragment() 
      } 

      override fun getCount(): Int { 
        return 5 
      } 
     } 
    } 

我们工作的主要部分是为分页器定义adapter类。我们必须扩展FragmentStatePagerAdapter类;它的构造函数接受将处理片段事务的片段管理器。为了正确完成工作,重写getItem()方法,返回片段的实例和getCount()返回预期片段的总数。其余的代码非常清晰--我们访问分页器(我们分配的ViewPager的 ID)并将其分配给适配器的新实例。

运行你的应用程序,尝试左右滑动。在你滑动时,观察 Logcat 和生命周期日志。

使用过渡制作动画

为了在片段之间制作动画过渡,需要为事务实例分配一些动画资源。正如你记得的,当我们开始片段事务后,我们得到一个事务实例。然后我们可以访问这个实例并执行以下方法:

  • setCustomAnimations (int enter, int exit, int popEnter, int popExit)

或者,我们可以使用这个方法:

  • setCustomAnimations (int enter, int exit)

这里,每个参数代表此事务中使用的动画。我们可以定义自己的动画资源,或者使用预定义的动画之一:

对话框片段

如果你需要显示任何浮动在应用程序 UI 上方的片段,那么DialogFragment就非常适合你。你所需要做的就是定义片段,非常类似于我们到目前为止所做的。定义一个扩展DialogFragment的类。重写onCreateView()方法,这样你就可以定义布局。你也可以重写onCreate()。你所需要做的最后一件事就是按照以下方式显示它:

    val dialog = MyDialogFragment() 
    dialog.show(supportFragmentManager, "dialog") 

在这个例子中,我们向片段管理器传递了实例和事务的名称。

通知

如果您计划呈现给最终用户的内容很短,那么,您应该尝试通知而不是对话框。我们可以以许多不同的方式自定义通知。在这里,我们将介绍一些基本的自定义。创建和显示通知很容易。这需要比我们迄今为止学到的更多关于 Android 的知识。不要担心;我们会尽力解释。您将在以后的章节中遇到许多这些类。

我们将演示如何使用通知如下:

  1. 定义一个notificationBuilder,并传递一个小图标、内容标题和内容文本如下:
        val notificationBuilder = NotificationCompat.Builder(context) 
                .setSmallIcon(R.drawable.icon) 
                .setContentTitle("Hello!") 
                .setContentText("We love Android!") 
  1. 为应用程序的活动定义Intent。(关于意图的更多内容将在下一章中讨论):
        val result = Intent(context, MyActivity::class.java)
  1. 现在定义包含活动后退堆栈的堆栈构建器对象如下:
        val builder = TaskStackBuilder.create(context) 
  1. 为意图添加后退堆栈:
        builder.addParentStack(MyActivity::class.java) 
  1. 在堆栈顶部添加意图:
        builder.addNextIntent(result) 
        val resultPendingIntent = builder.getPendingIntent( 
          0, 
          PendingIntent.FLAG_UPDATE_CURRENT )Define ID for the   
          notification and notify:
        val id = 0 
        notificationBuilder.setContentIntent(resultPendingIntent) 
        val manager = getSystemService(NOTIFICATION_SERVICE) as
        NotificationManager 
        manager.notify(id, notificationBuilder.build()) 

其他重要的 UI 组件

Android 框架庞大而强大。到目前为止,我们已经涵盖了最常用的View类。然而,还有很多View类我们没有涵盖。其中一些将在以后涵盖,但一些不太常用的将只是提及。无论如何,知道这些视图存在并且是进一步学习的好起点是很好的。让我们举一些例子来给你一个概念:

  • ConstraintLayout:这种视图以灵活的方式放置和定位子元素

  • CoordinatorLayout:这是 FrameLayout 的一个非常高级的版本

  • SurfaceView:这是一个用于绘图的视图(特别是在需要高性能时)

  • VideoView:这是设置播放视频内容的

摘要

在本章中,您学会了如何创建分成部分的屏幕,现在您可以创建包含按钮和图像的基本和复杂布局。您还学会了如何创建对话框和通知。在接下来的章节中,您将连接所有屏幕和导航操作。

第四章:连接屏幕流

你好,亲爱的读者!我们已经来到了我们应用程序开发中的一个重要点——连接我们的屏幕。正如你所知,我们在上一章中创建了屏幕,在本章中,我们将使用 Android 强大的框架来连接它们。我们将继续我们的工作,并且,通过 Android,我们将在 UI 方面做更严肃的事情。准备好自己,专注于本章的每个方面。这将非常有趣!我们保证!

在本章中,我们将涵盖以下主题:

  • 创建应用程序栏

  • 使用抽屉导航

  • Android 意图

  • 在活动和片段之间传递信息

创建应用程序栏

我们正在继续我们的 Android 应用程序开发之旅。到目前为止,我们已经为我们的应用程序创建了一个基础,为 UI 定义了基础,并创建了主要屏幕;然而,这些屏幕并没有连接。在本章中,我们将连接它们并进行精彩的交互。

由于一切都始于我们的MainActivity类,所以在我们设置一些操作来触发其他屏幕之前,我们将进行一些改进。我们必须用应用程序栏包装它。什么是应用程序栏?它是用于访问应用程序的其他部分并提供具有交互元素的视觉结构的 UI 部分。我们已经有一个,但它不是通常的 Android 应用程序栏。在这一点上,我们的应用程序有一个修改过的应用程序栏,我们希望它有一个标准的 Android 应用程序栏。

在这里,我们将向您展示如何创建一个。

首先,将顶级活动扩展替换为AppCompatActivity。我们需要访问应用程序栏所需的功能。AppCompatActivity将为标准的FragmentActivity添加这些额外的功能。您的BaseActivity定义现在应该如下所示:

    abstract class BaseActivity : AppCompatActivity() {   
    ... 

然后更新所使用的主题应用程序,以便可以使用应用程序栏。打开 Android 清单并设置一个新主题如下:

    ... 
    <application 
      android:name=".Journaler" 
      android:allowBackup="false" 
      android:icon="@mipmap/ic_launcher" 
      android:label="@string/app_name" 
      android:roundIcon="@mipmap/ic_launcher_round" 
      android:supportsRtl="true" 
      android:theme="@style/Theme.AppCompat.Light.NoActionBar"> 
    ... 

现在打开你的activity_main布局。删除包含的页眉指令并添加Toolbar

    <?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"> 

    <android.support.v7.widget.Toolbar 
      android:id="@+id/toolbar" 
      android:layout_width="match_parent" 
      android:layout_height="50dp" 
      android:background="@color/colorPrimary" 
      android:elevation="4dp" /> 

    <android.support.v4.view.ViewPager  
      android:id="@+id/pager" 
      android:layout_width="match_parent" 
      android:layout_height="match_parent" /> 

    </LinearLayout> 

对所有布局应用相同的更改。完成后,更新您的BaseActivity代码以使用新的Toolbar。您的onCreate()方法现在应该如下所示:

    override fun onCreate(savedInstanceState: Bundle?) { 
      super.onCreate(savedInstanceState) 
      setContentView(getLayout()) 
      setSupportActionBar(toolbar)        
    Log.v(tag, "[ ON CREATE ]") 
    } 

通过调用setSupportActionBar()方法并传递布局中工具栏的 ID,我们分配了一个应用程序栏。如果您运行应用程序,它将看起来像这样:

我们失去了我们在页眉中拥有的按钮!别担心,我们会把它们拿回来的!我们将创建一个菜单来处理操作,而不是按钮。在 Android 中,菜单是用于管理项目的接口,您可以定义自己的菜单资源。在/res目录中,创建一个menu文件夹。右键单击menu文件夹,然后选择 New | New menu resource file。将其命名为 main。一个新的 XML 文件将打开。根据这个示例更新它的内容:

    <?xml version="1.0" encoding="utf-8"?> 
    <menu  
    > 

    <item 
      app:showAsAction="ifRoom" 
      android:orderInCategory="1" 
      android:id="@+id/drawing_menu" 
      android:icon="@android:drawable/ic_dialog_dialer" 
      android:title="@string/mnu" /> 

    <item 
      app:showAsAction="ifRoom" 
      android:orderInCategory="2" 
      android:id="@+id/options_menu" 
      android:icon="@android:drawable/arrow_down_float" 
      android:title="@string/mnu" /> 
    </menu>

我们设置了常见属性、图标和顺序。为了确保您的图标可见,请使用以下内容:

    app:showAsAction="ifRoom" 

通过这样做,如果有空间可用,菜单中的项目将被展开;否则,它们将通过上下文菜单访问。您可以选择的 Android 中的其他间距选项如下:

  • 始终:此按钮始终放在应用程序栏中

  • 从不:此按钮永远不会放在应用程序栏中

  • collapseAction View:此按钮可以显示为小部件

  • withText:此按钮显示为文本

要将菜单分配给应用程序栏,请在BaseActivity中添加以下内容:

    override fun onCreateOptionsMenu(menu: Menu): Boolean { 
      menuInflater.inflate(R.menu.main, menu) 

      return true 
    } 

最后,通过添加以下代码来将操作连接到菜单项并扩展MainActivity

    override fun onOptionsItemSelected(item: MenuItem): Boolean { 
      when (item.itemId) { 
        R.id.drawing_menu -> { 
          Log.v(tag, "Main menu.") 
          return true 
        } 
        R.id.options_menu -> { 
          Log.v(tag, "Options menu.") 
          return true 
        } 
        else -> return super.onOptionsItemSelected(item) 

     } 

    } 

在这里,我们重写了onOptionsItemSelected()方法,并处理了菜单项 ID 的情况。在每次选择时,我们都添加了一个日志消息。现在运行你的应用程序。你应该会看到这些菜单项:

点击每个项目几次并观察 Logcat。你应该看到类似于这样的日志:

    V/Main activity: Main menu. 
    V/Main activity: Options menu. 
    V/Main activity: Options menu. 
    V/Main activity: Options menu. 

    V/Main activity: Main menu. 

    V/Main activity: Main menu. 

我们成功地将我们的标题切换到应用程序栏。这与应用程序线框中的标题非常不同。这一点目前并不重要,因为我们将在接下来的章节中进行一些重要的样式设置。我们的应用程序栏将看起来不同。

在接下来的部分,我们将处理导航抽屉,并开始组装我们应用程序的导航。

使用导航抽屉

你可能还记得,在我们的模型中,我们已经提出将有链接到过滤数据(笔记和待办事项)的功能。我们将使用导航抽屉来进行过滤。每个现代应用程序都使用导航抽屉。这是一个显示应用程序导航选项的 UI 部分。要定义抽屉,我们必须在布局中放置DrawerLayout视图。打开activity_main并应用以下修改:

    <?xml version="1.0" encoding="utf-8"?> 
    <android.support.v4.widget.DrawerLayout    xmlns:android=
    "http://schemas.android.com/apk/res/android" 
     android:id="@+id/drawer_layout" 
     android:layout_width="match_parent" 
     android:layout_height="match_parent"> 

    <LinearLayout 
      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:orientation="vertical"> 

    <android.support.v7.widget.Toolbar 
      android:id="@+id/toolbar" 
      android:layout_width="match_parent" 
      android:layout_height="50dp" 
      android:background="@color/colorPrimary" 
      android:elevation="4dp" /> 

    <android.support.v4.view.ViewPager xmlns:android=
    "http://schemas.android.com/apk/res/android" 
      android:id="@+id/pager" 
      android:layout_width="match_parent" 
      android:layout_height="match_parent" /> 

    </LinearLayout> 

    <ListView 
       android:id="@+id/left_drawer" 
       android:layout_width="240dp" 
       android:layout_height="match_parent" 
       android:layout_gravity="start" 
       android:background="@android:color/darker_gray" 
       android:choiceMode="singleChoice" 
       android:divider="@android:color/transparent" 
       android:dividerHeight="1dp" /> 
    </android.support.v4.widget.DrawerLayout>  

屏幕的主要内容必须是DrawerLayout的第一个子项。导航抽屉使用第二个子项作为抽屉的内容。在我们的情况下,它是ListView。要告诉导航抽屉导航是否应该位于左侧还是右侧,使用layout_gravity属性。如果我们计划使用导航抽屉位于右侧,我们应该将属性值设置为end

现在我们有一个空的导航抽屉,我们必须用一些按钮填充它。为每个导航项创建一个新的布局文件。将其命名为adapter_navigation_drawer。将其定义为一个只有一个按钮的简单线性布局:

    <?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"> 

    <Button 
      android:id="@+id/drawer_item" 
      android:layout_width="match_parent" 
      android:layout_height="wrap_content" /> 

    </LinearLayout> 

然后,创建一个名为navigation的新包。在这个包中,创建一个新的 Kotlindata类,就像这样:

    package com.journaler.navigation 
    data class NavigationDrawerItem( 
      val title: String,        
      val onClick: Runnable 
    ) 

我们定义了一个抽屉项实体。现在再创建一个类:

    class NavigationDrawerAdapter( 
        val ctx: Context, 
        val items: List<NavigationDrawerItem> 
    ) : BaseAdapter() { 

    override fun getView(position: Int, v: View?, group: ViewGroup?):   
    View { 
      val inflater = LayoutInflater.from(ctx) 
      var view = v 
      if (view == null) { 
        view = inflater.inflate( 
          R.layout.adapter_navigation_drawer, null 
        ) as LinearLayout 
      } 

      val item = items[position] 
      val title = view.findViewById<Button>(R.id.drawer_item) 
      title.text = item.title 
      title.setOnClickListener { 
        item.onClick.run() 
      } 

      return view 
     } 

     override fun getItem(position: Int): Any { 
       return items[position] 
      } 

     override fun getItemId(position: Int): Long { 
       return 0L 
     } 

     override fun getCount(): Int {     
     return items.size 
     } 

    } 

这个类在这里扩展了 Android 的BaseAdapter并重写了适配器提供视图实例所需的方法。适配器创建的所有视图都将分配给我们导航抽屉中的ListView

最后,我们将分配这个适配器。为此,我们需要通过执行以下代码更新我们的MainActivity类:

    class MainActivity : BaseActivity() { 
    ... 
    override fun onCreate(savedInstanceState: Bundle?) { 
      super.onCreate(savedInstanceState) 
      pager.adapter = ViewPagerAdapter(supportFragmentManager) 

      val menuItems = mutableListOf<NavigationDrawerItem>() 
      val today = NavigationDrawerItem( 
        getString(R.string.today), 
          Runnable { 
            pager.setCurrentItem(0, true) 
          } 
        ) 

        val next7Days = NavigationDrawerItem( 
           getString(R.string.next_seven_days), 
             Runnable { 
               pager.setCurrentItem(1, true) 
             } 
         ) 

         val todos = NavigationDrawerItem( 
           getString(R.string.todos), 
             Runnable { 
               pager.setCurrentItem(2, true) 
             } 
         ) 

         val notes = NavigationDrawerItem( 
           getString(R.string.notes), 
             Runnable { 
               pager.setCurrentItem(3, true) 
             } 
        ) 

        menuItems.add(today) 
        menuItems.add(next7Days) 
        menuItems.add(todos) 
        menuItems.add(notes) 

        val navgationDraweAdapter = 
          NavigationDrawerAdapter(this, menuItems) 
        left_drawer.adapter = navgationDraweAdapter 
      } 
      override fun onOptionsItemSelected(item: MenuItem): Boolean { 
        when (item.itemId) { 
          R.id.drawing_menu -> { 
            drawer_layout.openDrawer(GravityCompat.START) 
            return true 
          } 
          R.id.options_menu -> { 
             Log.v(tag, "Options menu.") 
             return true 
          } 
          else -> return super.onOptionsItemSelected(item) 
        }      
      }  
    }  

在这个代码示例中,我们实例化了几个NavigationDrawerItem实例,然后,我们为按钮和我们将执行的Runnable操作分配了一个标题。每个Runnable将跳转到我们视图页面的特定页面。我们将所有实例作为一个单一的可变列表传递给适配器。您可能还注意到,我们更改了drawing_menu项的行。通过点击它,我们将展开我们的导航抽屉。请按照以下步骤操作:

  1. 构建你的应用程序并运行它。

  2. 点击主屏幕右上方的菜单按钮或通过从屏幕的最左侧向右滑动来展开导航抽屉。

  3. 点击按钮。

  4. 你会注意到视图页面在导航抽屉下方的页面位置正在进行动画。

连接活动

如你所记得的,除了MainActivity之外,我们还有一些其他活动。在我们的应用程序中,我们创建了用于创建/编辑笔记和待办事项的活动。我们的计划是将它们连接到按钮点击事件,然后,当用户点击按钮时,适当的屏幕将打开。我们将首先定义一个代表在打开的活动中执行的操作的enum。当我们打开它时,我们可以查看、创建或更新笔记或待办事项。创建一个名为modelenum的新包,名称为MODE。确保你有以下enum值:

    enum class MODE(val mode: Int) { 
      CREATE(0), 
      EDIT(1), 
      VIEW(2); 

      companion object { 
        val EXTRAS_KEY = "MODE" 

        fun getByValue(value: Int): MODE { 
          values().forEach { 
            item -> 

            if (item.mode == value) { 
              return item 
            } 
          } 
          return VIEW 
        } 
      }  
    } 

我们在这里添加了一些附加内容。在enum的伴随对象中,我们定义了额外键的定义。很快,你会需要它,并且你会理解它的目的。我们还创建了一个方法,它将根据其值给我们一个enum

你可能还记得,用于处理笔记和待办事项的两个活动共享相同的类。打开ItemActivity并按以下方式扩展它:

     abstract class ItemActivity : BaseActivity() { 
       protected var mode = MODE.VIEW 
       override fun getActivityTitle() = R.string.app_name 
       override fun onCreate(savedInstanceState: Bundle?) { 
         super.onCreate(savedInstanceState) 
         val modeToSet = intent.getIntExtra(MODE.EXTRAS_KEY, 
         MODE.VIEW.mode) 
         mode = MODE.getByValue(modeToSet) 
         Log.v(tag, "Mode [ $mode ]") 
       } 
     }  

我们引入了一个刚定义的类型字段,它将告诉我们是否正在查看、创建或编辑一个 Note 或 Todo 项目。然后,我们重写了onCreate()方法。这很重要!当我们单击按钮并打开活动时,我们将向其传递一些值。此代码片段检索我们传递的值。为了实现这一点,我们访问Intent实例(在下一节中,我们将解释“意图”)和称为MODE的整数字段(MODE.EXTRAS_KEY的值)。给我们这个值的方法叫做getIntExtra()。对于每种类型都有一个方法的版本。如果没有值,将返回MODE.VIEW.mode。最后,我们将模式设置为我们通过从整数值获取MODE实例获得的值。

拼图的最后一块是触发活动打开。打开ItemsFragment并扩展如下:

    class ItemsFragment : BaseFragment() { 
      ... 
      override fun onCreateView( 
        inflater: LayoutInflater?, 
        container: ViewGroup?, 
        savedInstanceState: Bundle? 
      ): View? {         
          val view = inflater?.inflate(getLayout(), container, false) 
          val btn = view?.findViewById<FloatingActionButton>
          (R.id.new_item) 
          btn?.setOnClickListener { 
            val items = arrayOf( 
              getString(R.string.todos), 
              getString(R.string.notes) 
            ) 
            val builder = 
            AlertDialog.Builder(this@ItemsFragment.context) 
            .setTitle(R.string.choose_a_type) 
            .setItems( 
              items, 
              { _, which -> 
               when (which) { 
               0 -> { 
                 openCreateTodo() 
               } 
               1 -> { 
                 openCreateNote() 
               } 
               else -> Log.e(logTag, "Unknown option selected 
               [ $which ]") 
                } 
               } 
             ) 

            builder.show() 
          } 

          return view 
       } 

      private fun openCreateNote() { 
        val intent = Intent(context, NoteActivity::class.java) 
        intent.putExtra(MODE.EXTRAS_KEY, MODE.CREATE.mode) 
        startActivity(intent) 
      } 

      private fun openCreateTodo() { 
        val intent = Intent(context, TodoActivity::class.java) 
        intent.putExtra(MODE.EXTRAS_KEY, MODE.CREATE.mode) 
        startActivity(intent) 

      } 

     } 

我们访问了FloatingActionButton实例并分配了一个点击侦听器。单击时,我们将创建一个带有两个选项的对话框。这些选项中的每一个都将触发适当的活动打开方法。这两种方法的实现非常相似。例如,我们将专注于openCreateNote()

我们将创建一个新的Intent实例。在 Android 中,Intent表示我们要做某事的意图。要启动一个活动,我们必须传递上下文和我们想要启动的活动的类。我们还必须为其分配一些值。这些值将传递给一个活动实例。在我们的情况下,我们正在传递MODE.CREATE的整数值。startActivity()方法将执行意图,屏幕将出现。

运行应用程序,单击屏幕右下角的圆形按钮,并从对话框中选择一个选项,如下面的屏幕截图所示:

这将带您到这个屏幕:

这将进一步带您添加您自己的数据与日期和时间:

深入了解 Android 意图

在 Android 中,您计划执行的大多数操作都是通过Intent类定义的。Intent可用于启动活动,启动服务(在后台运行的进程)或发送广播消息。

Intent通常接受我们想要传递给某个类的操作和数据。我们可以设置的操作属性包括ACTION_VIEWACTION_EDITACTION_MAIN等。

除了操作和数据,我们还可以为意图设置一个类别。类别为我们设置的操作提供了额外的信息。我们还可以为意图设置类型和组件,该组件代表我们将使用的显式组件类名。

有两种类型的“意图”:

  • 显式意图

  • 隐式意图

显式意图设置了一个显式组件,提供了一个要运行的显式类。隐式意图没有显式组件,但系统根据我们分配的数据和属性决定如何处理它。意图解析过程负责处理这样的“意图”。

这些参数的组合是无穷无尽的。我们将给出一些例子,这样你就可以更好地理解“意图”的目的:

  • 打开网页:
         val intent = Intent(Intent.ACTION_VIEW,
         Uri.parse("http://google.com")) 
         startActivity(intent) 
         Sharing: 
         val intent = Intent(Intent.ACTION_SEND) 
         intent.type = "text/plain" 
         intent.putExtra(Intent.EXTRA_TEXT, "Check out this cool app!") 
         startActivity(intent)  
  • 从相机中捕获图像:
        val takePicture = Intent(MediaStore.ACTION_IMAGE_CAPTURE) 
        if (takePicture.resolveActivity(packageManager) != null) { 
         startActivityForResult(takePicture, REQUEST_CAPTURE_PHOTO +
         position) 
        } else { 
          logger.e(tag, "Can't take picture.") 
       }  
  • 从图库中选择图像:
        val pickPhoto = Intent( 
         Intent.ACTION_PICK, 
         MediaStore.Images.Media.EXTERNAL_CONTENT_URI 
        ) 
        startActivityForResult(pickPhoto, REQUEST_PICK_PHOTO + 
       position) 

正如你所看到的,“意图”是 Android 框架的一个关键部分。在下一节中,我们将扩展我们的代码,以更多地利用“意图”。

在活动和片段之间传递信息

为了在我们的活动之间传递信息,我们将使用 Android Bundle。Bundle 可以包含不同类型的多个值。我们将通过扩展我们的代码来说明 Bundle 的使用。打开ItemsFragemnt并更新如下:

    private fun openCreateNote() { 
      val intent = Intent(context, NoteActivity::class.java) 
      val data = Bundle() 
      data.putInt(MODE.EXTRAS_KEY, MODE.CREATE.mode) 
      intent.putExtras(data) 
      startActivityForResult(intent, NOTE_REQUEST) 
    } 
    private fun openCreateTodo() { 
       val date = Date(System.currentTimeMillis()) 
       val dateFormat = SimpleDateFormat("MMM dd YYYY", Locale.ENGLISH) 
       val timeFormat = SimpleDateFormat("MM:HH", Locale.ENGLISH) 

       val intent = Intent(context, TodoActivity::class.java) 
       val data = Bundle() 
       data.putInt(MODE.EXTRAS_KEY, MODE.CREATE.mode) 
       data.putString(TodoActivity.EXTRA_DATE, dateFormat.format(date)) 
       data.putString(TodoActivity.EXTRA_TIME, 
       timeFormat.format(date)) 
       intent.putExtras(data) 
       startActivityForResult(intent, TODO_REQUEST) 
    } 

    override fun onActivityResult(requestCode: Int, resultCode: Int, 
    data: Intent?) { 
      super.onActivityResult(requestCode, resultCode, data) 
      when (requestCode) { 
         TODO_REQUEST -> { 
           if (resultCode == Activity.RESULT_OK) { 
             Log.i(logTag, "We created new TODO.") 
           } else { 
             Log.w(logTag, "We didn't created new TODO.") 
           } 
          } 
          NOTE_REQUEST -> { 
            if (resultCode == Activity.RESULT_OK) { 
              Log.i(logTag, "We created new note.") 
            } else { 
              Log.w(logTag, "We didn't created new note.") 
              } 
           } 
         } 
      } 

在这里,我们引入了一些重要的更改。首先,我们将我们的 Note 和 Todo 活动作为子活动启动。这意味着我们的MainActivity类取决于这些活动的工作结果。在启动子活动时,我们使用了startActivityForResult()方法,而不是startActivity()方法。我们传递的参数是意图和请求编号。为了获得执行结果,我们重写了onActivityResult()方法。如您所见,我们检查了哪个活动完成了,以及该执行是否产生了成功的结果。

我们还改变了传递信息的方式。我们创建了Bundle实例并分配了多个值,就像 Todo 活动的情况一样。我们添加了模式、日期和时间。使用putExtras()方法将 Bundle 分配给意图。为了使用这些额外值,我们也更新了我们的活动。打开ItemsActivity并应用更改,就像这样:

     abstract class ItemActivity : BaseActivity() { 
       protected var mode = MODE.VIEW 
       protected var success = Activity.RESULT_CANCELED 
       override fun getActivityTitle() = R.string.app_name 

       override fun onCreate(savedInstanceState: Bundle?) { 
         super.onCreate(savedInstanceState) 
         val data = intent.extras 
         data?.let{ 
           val modeToSet = data.getInt(MODE.EXTRAS_KEY, MODE.VIEW.mode) 
           mode = MODE.getByValue(modeToSet) 
         } 
         Log.v(tag, "Mode [ $mode ]") 
       } 

       override fun onDestroy() { 
         super.onDestroy() 
         setResult(success) 
      } 

    } 

在这里,我们介绍了保存活动工作结果的字段。我们还更新了处理传递信息的方式。如您所见,如果有任何额外值可用,我们将获得一个整数值作为模式。最后,onDestroy()方法设置了将可用于父活动的工作结果。

打开TodoActivity并应用以下更改:

     class TodoActivity : ItemActivity() { 

     companion object { 
       val EXTRA_DATE = "EXTRA_DATE" 
       val EXTRA_TIME = "EXTRA_TIME" 
     } 

     override val tag = "Todo activity" 

     override fun getLayout() = R.layout.activity_todo 

     override fun onCreate(savedInstanceState: Bundle?) { 
       super.onCreate(savedInstanceState) 
       val data = intent.extras 
       data?.let { 
         val date = data.getString(EXTRA_DATE, "") 
         val time = data.getString(EXTRA_TIME, "") 
         pick_date.text = date 
         pick_time.text = time 
       } 
     } 

    }  

我们已经获得了日期和时间额外值,并将它们设置为日期/时间选择器按钮。运行您的应用程序并打开 Todo 活动。您的 Todo 屏幕应该是这样的:

当您离开 Todo 活动并返回到主屏幕时,请观察您的 Logcat。将会有一个包含以下内容的日志:

W/Items fragment--我们没有创建新的 TODO。

由于我们尚未创建任何 Todo 项目,因此我们传递了适当的结果。我们通过返回到主屏幕取消了创建过程。在以后的章节和随后的章节中,我们将成功创建笔记和待办事项。

摘要

我们使用本章来连接我们的界面并建立真正的应用程序流程。我们通过为 UI 元素设置适当的操作来建立屏幕之间的连接。我们将数据从一个点传递到另一个点。所有这些都非常简单!我们有一个可以工作的东西,但它看起来很丑。在下一章中,我们将确保它看起来漂亮!我们将为其添加样式和一些漂亮的视觉效果。准备好迎接 Android 强大的 UI API。

第五章:外观

现在,应用程序具有令人惊叹的视觉外观。这是使您的应用程序独特和原创的东西。令人愉悦的外观将使您的应用程序在类似应用程序的领域中脱颖而出,但它也将强烈吸引您的用户,他们更有可能在其设备上安装和保留您的应用程序。在本章中,我们将向您展示如何使您的应用程序变得美观。我们将向您介绍 Android UI 主题的秘密!我们的重点只会放在 Android 应用程序的视觉方面。

在本章中,我们将涵盖以下主题:

  • Android 中的主题和样式

  • 使用资产

  • 自定义字体和着色

  • 按钮设计

  • 动画和动画集

Android 框架中的主题

在上一章中,我们建立了主要 UI 元素之间的连接。我们的应用程序在获得一些颜色之前并不像一个。要获得颜色,我们将从主应用程序主题开始。我们将扩展现有的 Android 主题之一,并用我们喜欢的颜色进行覆盖。

打开styles.xml。在这里,您将为我们应用程序的需求设置默认主题。我们还将覆盖几种颜色。但是,我们将更改parent主题,并根据我们的意愿进行自定义。我们将根据以下示例更新主题:

    <resources> 

      <style name="AppTheme" 
        parent="Theme.AppCompat.Light.NoActionBar"> 
        <item name="android:colorPrimary">@color/colorPrimary</item> 
        <item name="android:statusBarColor">@color/colorPrimary</item> 
        <item name="android:colorPrimaryDark">
         @color/colorPrimaryDark</item> 
        <item name="android:colorAccent">@color/colorAccent</item> 
        <item name="android:textColor">@android:color/black</item> 
      </style> 

    </resources> 

我们定义了一个从AppCompat主题继承的主题。主要颜色代表应用程序品牌的颜色。颜色的较暗变体是colorPrimaryDark,而将着色的 UI 控件颜色为colorAccent。我们还将主要文本颜色设置为黑色。状态栏也将使用我们的主要品牌颜色。

打开colors.xml文件,并定义我们将在主题中使用的颜色如下:

    <?xml version="1.0" encoding="utf-8"?> 
    <resources> 
      <color name="colorPrimary">#ff6600</color> 
      <color name="colorPrimaryDark">#197734</color> 
      <color name="colorAccent">#ffae00</color> 
    </resources> 

在运行应用程序查看主题之前,请确保主题实际应用。使用以下代码更新manifest文件:

    <application 
    android:theme="@style/AppTheme" 

还要更新fragment_items的浮动操作按钮的颜色如下:

    <android.support.design.widget.FloatingActionButton 
        android:backgroundTint="@color/colorPrimary" 
        android:id="@+id/new_item" 
        android:layout_width="wrap_content" 
        android:layout_height="wrap_content" 
        android:layout_alignParentBottom="true" 
        android:layout_alignParentEnd="true" 
        android:layout_margin="@dimen/button_margin" /> 

背景色属性将确保按钮与状态栏具有相同的颜色。构建并运行应用程序。恭喜,您已成功将应用程序品牌定为橙色!

Android 中的样式

我们刚刚定义的主题代表样式。所有样式都在styles.xml文件中定义。我们将创建几种样式,以演示您创建样式的简易性和它们的强大性。您可以为按钮、文本或任何其他视图定义样式。您也可以继承样式。

为了进行样式设置,我们将定义应用程序中要使用的颜色调色板。打开您的colors.xml文件并扩展如下:

    <color name="green">#11c403</color> 
    <color name="green_dark">#0e8c05</color> 
    <color name="white">#ffffff</color> 
    <color name="white_transparent_40">#64ffffff</color> 
    <color name="black">#000000</color> 
    <color name="black_transparent_40">#64000000</color> 
    <color name="grey_disabled">#d5d5d5</color> 
    <color name="grey_text">#444d57</color> 
    <color name="grey_text_transparent_40">#64444d57</color> 
    <color name="grey_text_middle">#6d6d6d</color> 
    <color name="grey_text_light">#b9b9b9</color> 
    <color name="grey_thin_separator">#f1f1f1</color> 
    <color name="grey_thin_separator_settings">#eeeeee</color> 
    <color name="vermilion">#f3494c</color> 
    <color name="vermilion_dark">#c64145</color> 
    <color name="vermilion_transparent_40">#64f3494c</color> 
    <color name="plum">#121e2a</color> 

注意透明颜色!观察白色颜色的情况。纯白色颜色的代码为#ffffff,而40%透明的白色的代码为#64ffffff。要实现透明度,您可以使用以下值:

0% = #00

10% = #16

20% = #32

30% = #48

40% = #64

50% = #80

60% = #96

70% = #112

80% = #128

90% = #144

现在我们已经定义了颜色调色板,我们将创建我们的第一个样式。打开styles.xml并扩展它:

     <style name="simple_button"> 
        <item name="android:textSize">16sp</item> 
        <item name="android:textAllCaps">false</item> 
        <item name="android:textColor">@color/white</item> 
     </style> 

     <style name="simple_button_green" parent="simple_button"> 
        <item name="android:background">
        @drawable/selector_button_green</item> 
    </style> 

我们定义了两种样式。第一种定义了简单的按钮。它具有白色文本,字体大小为16sp。第二个扩展了第一个,并添加了背景属性。我们将创建一个选择器,以便演示我们定义的样式。由于我们还没有这个资源,请在drawable resource文件夹中创建selector_button_green xml

     <?xml version="1.0" encoding="utf-8"?> 
     <selector xmlns:android=
      "http://schemas.android.com/apk/res/android"> 

      <item android:drawable="@color/grey_disabled" 
       android:state_enabled="false" /> 
      <item android:drawable="@color/green_dark"
       android:state_selected="true" /> 
      <item android:drawable="@color/green_dark"
       android:state_pressed="true" /> 
      <item android:drawable="@color/green" /> 

     </selector> 

我们定义了一个选择器。选择器是描述视觉行为或不同状态的 XML。我们为按钮的禁用状态添加了不同的颜色,当按钮被按下、释放或我们没有与其进行任何交互时,我们也为其添加了颜色。

查看按钮的外观,打开activity_todo布局,并为每个按钮设置样式:

    style="@style/simple_button_green"  

然后,运行应用程序并打开Todo屏幕。您的屏幕应该是这样的:

如果您按下按钮,您会注意到颜色已经变成了深绿色。在接下来的部分,我们将通过添加圆角边缘来进一步改进这些按钮,但在此之前,让我们创建一些更多的样式:

  • 为输入字段和导航抽屉在您的styles.xml中添加样式:
        <style name="simple_button_grey" parent="simple_button"> 
         <item name="android:background">
          @drawable/selector_button_grey</item> 
        </style> 

        <style name="edit_text_transparent"> 
          <item name="android:textSize">14sp</item> 
          <item name="android:padding">19dp</item> 
          <item name="android:textColor">@color/white</item> 
          <item name="android:textColorHint">@color/white</item> 
          <item name="android:background">
          @color/black_transparent_40</item> 
        </style> 

       <style name="edit_text_gery_text"
         parent="edit_text_transparent"> 
         <item name="android:textAlignment">textStart</item> 
         <item name="android:textColor">@color/white</item> 
         <item name="android:background">@color/grey_text_light</item> 
       </style> 
  • 对于输入字段,我们定义了提示的颜色。同时,我们引入了一个名为selector_button_grey的选择器可绘制对象:
        <?xml version="1.0" encoding="utf-8"?> 
        <selector xmlns:android=
         "http://schemas.android.com/apk/res/android"> 

         <item android:drawable="@color/grey_disabled"  
         android:state_enabled="false" /> 
         <item android:drawable="@color/grey_text_middle"  
         android:state_selected="true" /> 
         <item android:drawable="@color/grey_text_middle"
         android:state_pressed="true" /> 
         <item android:drawable="@color/grey_text" /> 
        </selector> 
  • 对于两个屏幕(笔记和待办事项)上的note_title,添加样式:
        style="@style/edit_text_transparent" 
  • 对于note_content添加:
        style="@style/edit_text_gery_text"  
  • 对于adapter_navigation_drawer布局,将样式应用于按钮:
        style="@style/simple_button_grey" 

就是这样!您已经为您的应用程序添加了样式!现在运行它并查看所有屏幕和导航抽屉:

您觉得呢?UI 现在看起来更好了吗?也观察下一个屏幕截图:

应用程序现在看起来很不错。随意根据您的愿望调整属性和颜色。我们还没有完成。我们需要一些字体来应用!在接下来的部分,我们将处理这个问题。

使用资源文件

现在是时候让您的应用程序使用原始资源了。一个很好的例子就是字体。我们使用的每个字体应用都将是一个存储在assets文件夹中的单独文件。assets文件夹是main目录或代表构建变体的目录的子目录。除了字体之外,通常还会在这里存储 txt 文件,mp3,waw,mid 等。您不能将这些类型的文件存储在res目录中。

使用自定义字体

字体是资源。因此,为了为您的应用程序提供一些字体,我们首先需要复制它们。有很多好的免费字体资源。例如,Google Fonts。下载一些字体并将它们复制到您的assets目录中。如果没有assets目录,请创建一个。我们将把我们的字体放在assets/fonts目录中。

在我们的示例中,我们将使用ExoExo带有以下font文件:

  • Exo2-Black.ttf

  • Exo2-BlackItalic.ttf

  • Exo2-Bold.ttf

  • Exo2-BoldItalic.ttf

  • Exo2-ExtraBold.ttf

  • Exo2-ExtraBoldItalic.ttf

  • Exo2-ExtraLight.ttf

  • Exo2-ExtraLightItalic.ttf

  • Exo2-Italic.ttf

  • Exo2-Light.ttf

  • Exo2-LightItalic.ttf

  • Exo2-Medium.ttf

  • Exo2-MediumItalic.ttf

  • Exo2-Regular.ttf

  • Exo2-SemiBold.ttf

  • Exo2-SemiBoldItalic.ttf

  • Exo2-Thin.ttf

  • Exo2-ThinItalic.ttf

font文件复制到assets目录不会直接为我们提供对这些字体的支持。我们需要通过代码来使用它们。我们将创建一个代码,它将为我们应用字体。

打开BaseActivity并扩展它:

    abstract class BaseActivity : AppCompatActivity() { 
    companion object { 
      private var fontExoBold: Typeface? = null 
      private var fontExoRegular: Typeface? = null 

      fun applyFonts(view: View, ctx: Context) { 
        var vTag = "" 
        if (view.tag is String) { 
          vTag = view.tag as String 
        } 
        when (view) { 
          is ViewGroup -> { 
            for (x in 0..view.childCount - 1) { 
              applyFonts(view.getChildAt(x), ctx) 
            } 
          } 
          is Button -> { 
            when (vTag) { 
              ctx.getString(R.string.tag_font_bold) -> { 
                view.typeface = fontExoBold 
              } 
              else -> { 
                view.typeface = fontExoRegular 
              } 
             } 
            } 
            is TextView -> { 
              when (vTag) { 
                ctx.getString(R.string.tag_font_bold) -> { 
                view.typeface = fontExoBold 
                } 
                 else -> { 
                   view.typeface = fontExoRegular 
                 } 
                } 
              } 
              is EditText -> { 
                when (vTag) { 
                  ctx.getString(R.string.tag_font_bold) -> { 
                    view.typeface = fontExoBold 
                  } 
                 else -> { 
                   view.typeface = fontExoRegular 
                 } 
               } 
             } 
           } 
        } 
     } 
    ... 
    override fun onPostCreate(savedInstanceState: Bundle?) { 
        super.onPostCreate(savedInstanceState) 
        Log.v(tag, "[ ON POST CREATE ]") 
        applyFonts() 
    } 
    ... 
    protected fun applyFonts() { 
        initFonts() 
        Log.v(tag, "Applying fonts [ START ]") 
        val rootView = findViewById(android.R.id.content) 
        applyFonts(rootView, this) 
        Log.v(tag, "Applying fonts [ END ]") 
    } 

    private fun initFonts() { 
        if (fontExoBold == null) { 
            Log.v(tag, "Initializing font [ Exo2-Bold ]") 
            fontExoBold = Typeface.createFromAsset(assets, "fonts/Exo2-
            Bold.ttf") 
        } 
        if (fontExoRegular == null) { 
            Log.v(tag, "Initializing font [ Exo2-Regular ]") 
            fontExoRegular = Typeface.createFromAsset(assets,
            "fonts/Exo2-Regular.ttf") 
        } 
     }   
    } 

我们扩展了我们的基本活动以处理字体。当活动进入onPostCreate()时,applyFonts()方法将被调用。然后,applyFonts()执行以下操作:

  • 调用initFonts()方法,该方法从资源文件创建TypeFace实例。TypeFace用作字体及其视觉属性的表示。我们为ExoBoldExoRegular实例化了字体。

  • 接下来发生的是,我们正在获取当前活动的root视图,并将其传递给伴随对象的applyFonts()方法。如果视图是一个view group,我们会遍历其子项,直到达到普通视图。视图有一个名为typeface的属性,我们将其设置为我们的typeface实例。您还会注意到,我们正在从每个视图中检索名为tag的类属性。在 Android 中,我们可以为视图设置标签。标签可以是任何类的实例。在我们的情况下,我们正在检查标签是否是具有名称tag_font_bold的字符串资源的String

要设置标签,创建一个名为tags的新xml文件,并将其放入values目录中,并填充以下内容:

    <?xml version="1.0" encoding="utf-8"?> 
    <resources> 
      <string name="tag_font_regular">FONT_REGULAR</string> 
      <string name="tag_font_bold">FONT_BOLD</string> 
    </resources> 
    To apply it open styles.xml and add tag to simple_button style: 
    <item name="android:tag">@string/tag_font_bold</item> 

现在所有应用程序的按钮都将应用粗体字体版本。现在构建您的应用程序并运行它。您会注意到字体已经改变了!

应用颜色

我们为我们的应用程序定义了颜色调色板。我们通过访问其资源应用了每种颜色。有时我们没有特定的颜色资源可用。可能发生的情况是,我们通过后端(作为对某些 API 调用的响应)动态获得颜色,或者由于其他原因,我们希望从代码中定义颜色。

当你需要在代码中处理颜色时,Android 非常强大。我们将涵盖一些示例,并向您展示您可以做什么。

要从现有资源中获取颜色,您可以执行以下操作:

    val color = ContextCompat.getColor(contex, R.color.plum) 

以前我们用来做这个:

     val color = resources.getColor(R.color.plum) 

但它已经在 Android 6 版本中被弃用。

当您获得颜色后,您可以将其应用于某个视图:

    pick_date.setTextColor(color) 

另一种获取颜色的方法是访问Color类的静态方法。让我们从解析一些颜色字符串开始:

    val color = Color.parseColor("#ff0000")  

我们必须注意,已经有一定数量的预定义颜色可用:

     val color = Color.RED 

所以我们不需要解析#ff0000。还有一些其他颜色:

    public static final int BLACK 
    public static final int BLUE 
    public static final int CYAN 
    public static final int DKGRAY 
    public static final int GRAY 
    public static final int GREEN 
    public static final int LTGRAY 
    public static final int MAGENTA 
    public static final int RED 
    public static final int TRANSPARENT 
    public static final int WHITE 
    public static final int YELLOW

有时,您只会有关于红色,绿色或蓝色的参数,然后基于此创建颜色:

     Color red = Color.valueOf(1.0f, 0.0f, 0.0f); 

我们必须注意,此方法从 API 版本 26 开始可用!

如果 RGB 不是您想要的颜色空间,那么您可以将其作为参数传递:

    val colorSpace = ColorSpace.get(ColorSpace.Named.NTSC_1953) 
    val color = Color.valueOf(1f, 1f, 1f, 1f, colorSpace) 

正如您所看到的,当您处理颜色时有很多可能性。如果标准颜色资源不足以管理您的颜色,您可以以一种高级方式来处理它。我们鼓励您尝试并在一些用户界面上尝试。

例如,如果您正在使用AppCompat库,一旦您获得Color实例,您可以像以下示例中那样使用它:

    counter.setTextColor( 
      ContextCompat.getColor(context, R.color.vermilion) 
    ) 

考虑以下截图:

让你的按钮看起来漂亮

我们给我们的按钮上色并为它们定义了状态。我们为每个状态着色。我们有禁用状态的颜色,启用状态和按下状态的颜色。现在我们将更进一步。我们将使我们的按钮变圆,并用渐变颜色而不是纯色来着色。我们将为新的按钮样式准备一个布局。打开activity_todo布局并修改按钮容器:

    <LinearLayout 
      android:background="@color/grey_text_light" 
      android:layout_width="match_parent" 
      android:layout_height="wrap_content" 
      android:orientation="horizontal" 
      android:weightSum="1"> 

      ... 

     </LinearLayout> 

我们将背景设置为与我们用于编辑文本字段相同的背景。按钮将被圆角,所以我们希望它们与屏幕的其余部分在相同的背景上。现在,让我们定义一些额外的尺寸和我们将使用的颜色。我们需要定义具有圆角边角的按钮的半径:

     <dimen name="button_corner">10dp</dimen> 

由于我们计划使用渐变颜色,我们必须为渐变添加第二种颜色。将这些颜色添加到您的colors.xml中:

     <color name="green2">#208c18</color> 
     <color name="green_dark2">#0b5505</color>  

现在我们已经定义了这一点,我们需要更新绿色按钮的样式:

     <style name="simple_button_green" parent="simple_button"> 
        <item name="android:layout_margin">5dp</item> 
        <item name="android:background">
        @drawable/selector_button_green</item> 
     </style> 

我们添加了一个边距,以便按钮彼此分开。我们现在需要矩形圆角可绘制资源。创建三个可绘制资源rect_rounded_greenrect_rounded_green_darkrect_rounded_grey_disabled。确保它们定义如下:

  • rect_rounded_green
         <shape xmlns:android=
           "http://schemas.android.com/apk/res/android"> 
            <gradient 
            android:angle="270" 
            android:endColor="@color/green2" 
            android:startColor="@color/green" /> 

           <corners android:radius="@dimen/button_corner" /> 
         </shape>  
  • rect_rounded_green_dark:
     <shape > 
       <gradient 
       android:angle="270" 
       android:endColor="@color/green_dark2" 
       android:startColor="@color/green_dark" /> 

      <corners android:radius="@dimen/button_corner" /> 
     </shape> 
  • rect_rounded_grey_disabled
         <shape xmlns:android=
         "http://schemas.android.com/apk/res/android"> 

         <solid android:color="@color/grey_disabled" /> 
         <corners android:radius="@dimen/button_corner" /> 
         </shape> 
  • 我们定义了包含以下属性的渐变:

  • 渐变角度(270 度)

  • 起始颜色(我们使用了我们的颜色资源)

  • 结束颜色(我们也使用了我们的颜色资源)

此外,每个可绘制资源都有其角半径的值。最后一步是更新我们的选择器。打开selector_button_green并更新它:

       <?xml version="1.0" encoding="utf-8"?> 
       <selector xmlns:android=
       "http://schemas.android.com/apk/res/android"> 

       <item  
       android:drawable="@drawable/rect_rounded_grey_disabled"  
       android:state_enabled="false" /> 

       <item  
       android:drawable="@drawable/rect_rounded_green_dark"  
       android:state_selected="true" /> 

       <item  
       android:drawable="@drawable/rect_rounded_green_dark"  
       android:state_pressed="true" /> 

       <item  
       android:drawable="@drawable/rect_rounded_green" /> 

     </selector> 

构建您的应用程序并运行它。打开Todo屏幕并看一看。按钮现在有了平滑的圆角边缘,看起来更漂亮。按钮之间通过边距分开,如果您在按钮上按下手指,您将看到我们定义的较深绿色的辅助渐变:

设置动画

我们认为我们的布局很好看。它很漂亮。但它可以更有趣吗?当然可以!如果我们使我们的布局更具互动性,我们将实现更好的用户体验,并吸引用户使用它。我们将通过添加一些动画来实现这一点。动画可以通过代码或通过动画视图属性来定义。我们将通过添加简单而有效的开场动画来改进每个屏幕。

作为资源定义的动画位于anim资源目录中。我们将需要一些动画资源--fade_infade_outbottom_to_toptop_to_bottomhide_to_tophide_to_bottom。创建它们并根据这些示例定义它们:

  • fade_in
        <?xml version="1.0" encoding="utf-8"?> 
        <alpha xmlns:android=
        "http://schemas.android.com/apk/res/android" 
        android:duration="300" 
        android:fromAlpha="0.0" 
        android:interpolator="@android:anim/accelerate_interpolator" 
        android:toAlpha="1.0" /> 
  • fade_out
         <?xml version="1.0" encoding="utf-8"?> 
         <alpha xmlns:android=
         "http://schemas.android.com/apk/res/android" 
         android:duration="300" 
         android:fillAfter="true" 
         android:fromAlpha="1.0" 
         android:interpolator="@android:anim/accelerate_interpolator" 
         android:toAlpha="0.0" /> 
         -  bottom_to_top: 
         <set xmlns:android=
          "http://schemas.android.com/apk/res/android" 
         android:fillAfter="true" 
         android:fillEnabled="true" 
         android:shareInterpolator="false"> 

         <translate 
         android:duration="900" 
         android:fromXDelta="0%" 
         android:fromYDelta="100%" 
         android:toXDelta="0%" 
         android:toYDelta="0%" /> 

         </set> 
  • top_to_bottom
     <set  
     android:fillAfter="true" 
     android:fillEnabled="true" 
     android:shareInterpolator="false"> 
     <translate 
      android:duration="900" 
      android:fromXDelta="0%" 
      android:fromYDelta="-100%" 
      android:toXDelta="0%" 
      android:toYDelta="0%" /> 
    </set> 
  • hide_to_top
     <set  
      android:fillAfter="true" 
      android:fillEnabled="true" 
      android:shareInterpolator="false"> 

    <translate 
      android:duration="900" 
      android:fromXDelta="0%" 
      android:fromYDelta="0%" 
      android:toXDelta="0%" 
      android:toYDelta="-100%" /> 

   </set> 
  • hide_to_bottom
         <set xmlns:android=
          "http://schemas.android.com/apk/res/android" 
           android:fillAfter="true" 
           android:fillEnabled="true" 
           android:shareInterpolator="false"> 

        <translate 
          android:duration="900" 
          android:fromXDelta="0%" 
          android:fromYDelta="0%" 
          android:toXDelta="0%" 
          android:toYDelta="100%" /> 

       </set> 

看看这个例子和你可以定义的属性。在淡入淡出动画示例中,我们为视图的alpha属性进行了动画处理。我们设置了动画持续时间,从和到 alpha 值以及我们将用于动画的插值器。在 Android 中,对于你的动画,你可以选择这些插值器之一:

  • accelerate_interpolator

  • accelerate_decelerate_interpolator

  • bounce_interpolator

  • cycle_interpolator

  • anticipate_interpolator

  • anticipate_overshot_interpolator

  • 以及其他许多动画,都定义在@android:anim/...

对于其他动画,我们使用fromto参数定义了平移。

在使用这些动画之前,我们将调整一些背景,以便在动画开始之前我们的布局中没有间隙。对于activity_main,添加工具栏父视图的背景:

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

对于activity_noteactivity_todo,将工具栏嵌套在一个更多的父级中,以便最终颜色与工具栏下方标题字段的颜色相同:

     <LinearLayout 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:background="@color/black_transparent_40" 
        android:orientation="vertical"> 

      <LinearLayout 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:background="@color/black_transparent_40" 
        android:orientation="vertical"> 

      <android.support.v7.widget.Toolbar 
        android:id="@+id/toolbar" 
        android:layout_width="match_parent" 
        android:layout_height="50dp" 
        android:background="@color/colorPrimary" 
        android:elevation="4dp" /> 

最后,我们将应用我们的动画。我们将为我们的屏幕打开和关闭使用淡入和淡出动画。打开BaseActivity并修改它如下:

     override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        overridePendingTransition(R.anim.fade_in, R.anim.fade_out) 
        setContentView(getLayout()) 
        setSupportActionBar(toolbar) 
        Log.v(tag, "[ ON CREATE ]") 

     } 

我们使用overridePendingTransition()方法覆盖了过渡效果,该方法将进入和退出动画作为参数。

也更新你的onResume()onPause()方法:

    override fun onResume() { 
        super.onResume() 
        Log.v(tag, "[ ON RESUME ]") 
        val animation = getAnimation(R.anim.top_to_bottom) 
        findViewById(R.id.toolbar).startAnimation(animation) 
    } 

    override fun onPause() { 
        super.onPause() 
        Log.v(tag, "[ ON PAUSE ]") 
        val animation = getAnimation(R.anim.hide_to_top) 
        findViewById(R.id.toolbar).startAnimation(animation) 

    } 

我们创建了一个动画实例,并使用startAnimation()方法将其应用于视图。getAnimation()方法是我们自己定义的。因此,将实现添加到BaseActivity

     protected fun getAnimation(animation: Int): Animation =
     AnimationUtils.loadAnimation(this, animation) 

由于我们使用的是 Kotlin,为了使其对所有活动都可用,而不仅仅是扩展BaseActivity的活动,将方法更改为扩展函数,如下所示:

     fun Activity.getAnimation(animation: Int): Animation =
     AnimationUtils.loadAnimation(this, animation) 

再次构建并运行应用程序。多次打开和关闭屏幕,看看我们的动画是如何工作的。

Android 中的动画集

在之前的部分中,我们使用了在 XML 中定义的资源的动画。在本节中,我们将使用各种视图属性和动画集。我们将通过简单而有效的示例来说明目的和用途。

让我们演示代码中的第一个动画。打开ItemsFragment。添加以下方法:

     private fun animate(btn: FloatingActionButton, expand: Boolean =
     true) { 
        btn.animate() 
                .setInterpolator(BounceInterpolator()) 
                .scaleX(if(expand){ 1.5f } else { 1.0f }) 
                .scaleY(if(expand){ 1.5f } else { 1.0f }) 
                .setDuration(2000) 
                .start() 
      } 

这个方法会做什么?这个方法将使用弹跳插值对按钮进行缩放动画。如果扩展参数为true,我们将放大,否则我们将缩小。

将其应用到我们的浮动操作按钮。扩展按钮点击监听器:

    btn?.setOnClickListener { 

    animate(btn) 

    ... 

    } 

并将主对话框设置为可取消,并设置取消操作:

    val builder = AlertDialog.Builder(this@ItemsFragment.context) 
                    .setTitle(R.string.choose_a_type) 
                    .setCancelable(true) 
                    .setOnCancelListener { 
                        animate(btn, false) 
                    } 

    .setItems( ... ) 

    ... 

    builder.show() 

构建并运行应用程序。单击“添加项目”按钮,然后通过在其外部轻击来关闭对话框。我们有一个精彩的缩放动画!

为了使浮动操作按钮完整,添加加号的 PNG 资源并将其应用到按钮上:

     <android.support.design.widget.FloatingActionButton 
     ... 
     android:src="img/add" 
     android:scaleType="centerInside" 
     ... 
     /> 

通过将图标添加到按钮,动画看起来完美!让我们使它更加完美!我们将创建一个包含多个动画的动画集!

     private fun animate(btn: FloatingActionButton, expand: Boolean =
     true) { 
        val animation1 = ObjectAnimator.ofFloat(btn, "scaleX",
        if(expand){ 1.5f } else { 1.0f }) 
        animation1.duration = 2000 
        animation1.interpolator = BounceInterpolator() 

        val animation2 = ObjectAnimator.ofFloat(btn, "scaleY",
        if(expand){ 1.5f } else { 1.0f }) 
        animation2.duration = 2000 
        animation2.interpolator = BounceInterpolator() 

        val animation3 = ObjectAnimator.ofFloat(btn, "alpha",
        if(expand){ 0.3f } else { 1.0f }) 
        animation3.duration = 500 
        animation3.interpolator = AccelerateInterpolator() 

        val set = AnimatorSet() 
        set.play(animation1).with(animation2).before(animation3) 
        set.start() 
      } 

AnimatorSet类使我们能够创建复杂的动画。在这种情况下,我们定义了沿着x轴和y轴的缩放动画。这两个动画将同时进行动画处理,给我们带来了在两个方向上缩放的效果。在我们缩放视图之后,我们将减少(或增加)视图的容量。正如你所看到的,我们可以链接或按顺序执行动画。

构建你的项目并运行。你可以看到新的动画行为。

总结

本章是一个相当互动的章节。首先,我们向您展示了如何在 Android 中添加、定义、更改和调整主题。然后我们深入研究了 Android 的样式和资源。在本章中,我们还采用了一些自定义字体和着色。最后,我们制作了一些非常漂亮的按钮和快速动画。在下一章中,您将开始学习 Android 框架的系统部分。我们将从权限开始。

第六章:权限

你好!你能相信这本书的一个重要部分已经在我们身后了吗?我们已经完成了用户界面,现在,我们正在进入这本书更复杂的部分——系统。

在本章以及接下来的章节中,我们将深入了解 Android 系统的结构。您将学习有关权限、数据库处理、首选项、并发、服务、消息传递、后端、API 和高性能的知识。

然而,不要被愚弄;这本书及其内容并未涵盖整个框架。那是不可能的;Android 是一个如此庞大的框架,完全掌握它可能需要数年时间。在这里,我们只是深入了解 Android 和 Kotlin 的世界。

然而,不要灰心!在这本书中,我们将为您提供掌握 Kotlin 和 Android 所需的知识和技能。在本章中,我们将讨论 Android 中的权限。您将学习权限是什么,它们用于什么,最重要的是,为什么我们需要(强调需要)使用它们。

在本章中,我们将涵盖以下主题:

  • 来自 Android 清单的权限

  • 请求权限

  • 以 Kotlin 方式处理权限

来自 Android 清单的权限

Android 应用在它们自己的进程中运行,并且与操作系统的其余部分分离。因此,为了执行一些特定于系统的操作,需要请求它们。这样的权限请求的一个例子是请求使用蓝牙、检索当前 GPS 位置、发送短信,或者读取或写入文件系统。权限授予对各种设备功能的访问。处理权限有几种方法。我们将从使用清单开始。

首先,我们必须确定需要哪些权限。在安装过程中,用户可能决定不安装应用程序,因为权限太多。例如,用户可能会问为什么一个应用程序需要发送短信功能,当应用程序本身只是一个简单的图库应用程序。

对于我们在本书中开发的 Journaler 应用程序,我们将需要以下权限:

  • 读取 GPS 坐标,因为我们希望我们创建的每个笔记都有相关联的坐标

  • 我们需要访问互联网,这样我们就可以稍后执行 API 调用

  • 启动完成事件,我们需要它,这样应用程序服务可以在每次重新启动手机时与后端进行同步

  • 读取和写入外部存储,以便我们可以读取数据或存储数据

  • 访问网络状态,以便我们知道是否有可用的互联网连接

  • 使用振动,这样我们就可以在从后端接收到东西时振动

打开 AndroidManifest.xml 文件,并使用以下权限进行更新:

    <manifest xmlns:android=
     "http://schemas.android.com/apk/res/android" 
     package="com.journaler"> 

      <uses-permission android:name="android.permission.INTERNET" /> 
      <uses-permission android:name=
       "android.permission.RECEIVE_BOOT_COMPLETED" /> 
      <uses-permission android:name=
       "android.permission.READ_EXTERNAL_STORAGE" /> 
      <uses-permission android:name=
       "android.permission.WRITE_EXTERNAL_STORAGE" /> 
      <uses-permission android:name=
       "android.permission.ACCESS_NETWORK_STATE" /> 
      <uses-permission android:name=
       "android.permission.ACCESS_FINE_LOCATION" /> 
      <uses-permission android:name=
       "android.permission.ACCESS_COARSE_LOCATION" /> 
      <uses-permission android:name="android.permission.VIBRATE" /> 
       <application ... > 
         ... 
       </application 

       ... 

     </manifest>  

我们刚刚请求的权限的名称基本上是不言自明的,并且它们涵盖了我们提到的所有要点。除了这些权限,您还可以请求一些其他权限。看一下每个权限的名称,您会惊讶于您实际上可以请求到什么:

     <uses-permission android:name=
     "android.permission.ACCESS_CHECKIN_PROPERTIES" /> 
     <uses-permission  android:name=
     "android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" /> 
     <uses-permission android:name=
     "android.permission.ACCESS_MOCK_LOCATION" /> 
     <uses-permission android:name=
     "android.permission.ACCESS_SURFACE_FLINGER" /> 
     <uses-permission android:name=
     "android.permission.ACCESS_WIFI_STATE" /> 
     <uses-permission android:name=
     "android.permission.ACCOUNT_MANAGER" /> 
     <uses-permission android:name=
     "android.permission.AUTHENTICATE_ACCOUNTS" /> 
     <uses-permission android:name=
     "android.permission.BATTERY_STATS" /> 
     <uses-permission android:name=
     "android.permission.BIND_APPWIDGET" /> 
     <uses-permission android:name=
     "android.permission.BIND_DEVICE_ADMIN" /> 
     <uses-permission android:name=
     "android.permission.BIND_INPUT_METHOD" /> 
     <uses-permission android:name=
     "android.permission.BIND_REMOTEVIEWS" /> 
     <uses-permission android:name=
     "android.permission.BIND_WALLPAPER" /> 
     <uses-permission android:name=
     "android.permission.BLUETOOTH" /> 
     <uses-permission android:name=
     "android.permission.BLUETOOTH_ADMIN" /> 
     <uses-permission android:name=
     "android.permission.BRICK" /> 
     <uses-permission android:name=
     "android.permission.BROADCAST_PACKAGE_REMOVED" /> 
     <uses-permission android:name=
     "android.permission.BROADCAST_SMS" /> 
     <uses-permission android:name=
     "android.permission.BROADCAST_STICKY" /> 
     <uses-permission android:name=
      "android.permission.BROADCAST_WAP_PUSH" /> 
     <uses-permission android:name=
      "android.permission.CALL_PHONE"/> 
     <uses-permission android:name=
      "android.permission.CALL_PRIVILEGED" /> 
     <uses-permission android:name=
      "android.permission.CAMERA"/> 
     <uses-permission android:name=
      "android.permission.CHANGE_COMPONENT_ENABLED_STATE" /> 
     <uses-permission android:name=
     "android.permission.CHANGE_CONFIGURATION" /> 
     <uses-permission android:name=
     "android.permission.CHANGE_NETWORK_STATE" /> 
     <uses-permission android:name=
     "android.permission.CHANGE_WIFI_MULTICAST_STATE" /> 
     <uses-permission android:name=
     "android.permission.CHANGE_WIFI_STATE" /> 
     <uses-permission android:name=
     "android.permission.CLEAR_APP_CACHE" /> 
     <uses-permission android:name=
     "android.permission.CLEAR_APP_USER_DATA" /> 
     <uses-permission android:name=
     "android.permission.CONTROL_LOCATION_UPDATES" /> 
     <uses-permission android:name=
     "android.permission.DELETE_CACHE_FILES" /> 
     <uses-permission android:name=
     "android.permission.DELETE_PACKAGES" /> 
     <uses-permission android:name=
     "android.permission.DEVICE_POWER" /> 
     <uses-permission android:name=
     "android.permission.DIAGNOSTIC" /> 
     <uses-permission android:name=
     "android.permission.DISABLE_KEYGUARD" /> 
     <uses-permission android:name=
     "android.permission.DUMP" /> 
     <uses-permission android:name=
     "android.permission.EXPAND_STATUS_BAR" /> 
     <uses-permission android:name="
     android.permission.FACTORY_TEST" /> 
     <uses-permission android:name=
     "android.permission.FLASHLIGHT" /> 
     <uses-permission android:name=
     "android.permission.FORCE_BACK" /> 
     <uses-permission android:name=
     "android.permission.GET_ACCOUNTS" /> 
     <uses-permission android:name=
     "android.permission.GET_PACKAGE_SIZE" /> 
     <uses-permission android:name=
     "android.permission.GET_TASKS" /> 
     <uses-permission android:name=
     "android.permission.GLOBAL_SEARCH" /> 
     <uses-permission android:name=
     "android.permission.HARDWARE_TEST" /> 
     <uses-permission android:name=
     "android.permission.INJECT_EVENTS" /> 
     <uses-permission android:name=
     "android.permission.INSTALL_LOCATION_PROVIDER" /> 
     <uses-permission android:name=
     "android.permission.INSTALL_PACKAGES" /> 
     <uses-permission android:name=
     "android.permission.INTERNAL_SYSTEM_WINDOW" /> 
     <uses-permission android:name=
     "android.permission.KILL_BACKGROUND_PROCESSES" /> 
     <uses-permission android:name=
     "android.permission.MANAGE_ACCOUNTS" /> 
     <uses-permission android:name=
     "android.permission.MANAGE_APP_TOKENS" /> 
     <uses-permission android:name=
     "android.permission.MASTER_CLEAR" /> 
     <uses-permission android:name=
     "android.permission.MODIFY_AUDIO_SETTINGS" /> 
     <uses-permission android:name=
     "android.permission.MODIFY_PHONE_STATE" /> 
     <uses-permission android:name=
     "android.permission.MOUNT_FORMAT_FILESYSTEMS" /> 
     <uses-permission android:name=
     "android.permission.MOUNT_UNMOUNT_FILESYSTEMS" /> 
     <uses-permission android:name=
     "android.permission.NFC" /> 
     <uses-permission android:name=
     "android.permission.PROCESS_OUTGOING_CALLS" /> 
     <uses-permission android:name=
     "android.permission.READ_CALENDAR" /> 
    <uses-permission android:name=
     "android.permission.READ_CONTACTS" /> 
    <uses-permission android:name=
    "android.permission.READ_FRAME_BUFFER" /> 
    <uses-permission android:name=
    "android.permission.READ_HISTORY_BOOKMARKS" /> 
    <uses-permission android:name=
    "android.permission.READ_INPUT_STATE" /> 
    <uses-permission android:name=
    "android.permission.READ_LOGS" /> 
    <uses-permission android:name=
    "android.permission.READ_PHONE_STATE" /> 
    <uses-permission android:name=
    "android.permission.READ_SMS" /> 
    <uses-permission android:name=
    "android.permission.READ_SYNC_SETTINGS" /> 
    <uses-permission android:name=
    "android.permission.READ_SYNC_STATS" /> 
    <uses-permission android:name=
    "android.permission.REBOOT" /> 
    <uses-permission android:name=
    "android.permission.RECEIVE_MMS" /> 
    <uses-permission android:name=
    "android.permission.RECEIVE_SMS" /> 
    <uses-permission android:name=
    "android.permission.RECEIVE_WAP_PUSH" /> 
    <uses-permission android:name=
    "android.permission.RECORD_AUDIO" /> 
    <uses-permission android:name=
    "android.permission.REORDER_TASKS" /> 
    <uses-permission android:name=
    "android.permission.RESTART_PACKAGES" /> 
    <uses-permission android:name=
    "android.permission.SEND_SMS" /> 
    <uses-permission android:name=
    "android.permission.SET_ACTIVITY_WATCHER" /> 
    <uses-permission android:name=
     "android.permission.SET_ALARM" /> 
    <uses-permission android:name=
     "android.permission.SET_ALWAYS_FINISH" /> 
    <uses-permission android:name=
     "android.permission.SET_ANIMATION_SCALE" /> 
    <uses-permission android:name=
     "android.permission.SET_DEBUG_APP" /> 
    <uses-permission android:name=
     "android.permission.SET_ORIENTATION" /> 
    <uses-permission android:name=
     "android.permission.SET_POINTER_SPEED" /> 
    <uses-permission android:name=
     "android.permission.SET_PROCESS_LIMIT" /> 
    <uses-permission android:name=
     "android.permission.SET_TIME" /> 
    <uses-permission android:name=
     "android.permission.SET_TIME_ZONE" /> 
    <uses-permission android:name=
     "android.permission.SET_WALLPAPER" /> 
    <uses-permission android:name=
     "android.permission.SET_WALLPAPER_HINTS" /> 
    <uses-permission android:name=
     "android.permission.SIGNAL_PERSISTENT_PROCESSES" /> 
    <uses-permission android:name=
     "android.permission.STATUS_BAR" /> 
    <uses-permission android:name=
     "android.permission.SUBSCRIBED_FEEDS_READ" /> 
    <uses-permission android:name=
     "android.permission.SUBSCRIBED_FEEDS_WRITE" /> 
    <uses-permission android:name=
     "android.permission.SYSTEM_ALERT_WINDOW" /> 
    <uses-permission android:name=
     "android.permission.UPDATE_DEVICE_STATS" /> 
    <uses-permission android:name=
     "android.permission.USE_CREDENTIALS" /> 
    <uses-permission android:name=
     "android.permission.USE_SIP" /> 
    <uses-permission android:name=
     "android.permission.WAKE_LOCK" /> 
    <uses-permission android:name=
     "android.permission.WRITE_APN_SETTINGS" /> 
    <uses-permission android:name=
     "android.permission.WRITE_CALENDAR" /> 
    <uses-permission android:name=
     "android.permission.WRITE_CONTACTS" /> 
    <uses-permission android:name=
     "android.permission.WRITE_GSERVICES" /> 
    <uses-permission android:name=
     "android.permission.WRITE_HISTORY_BOOKMARKS" /> 
    <uses-permission android:name=
     "android.permission.WRITE_SECURE_SETTINGS" /> 
    <uses-permission android:name=
     "android.permission.WRITE_SETTINGS" /> 
    <uses-permission android:name=
     "android.permission.WRITE_SMS" /> 
    <uses-permission android:name=
     "android.permission.WRITE_SYNC_SETTINGS" /> 
    <uses-permission android:name=
     "android.permission.BIND_ACCESSIBILITY_SERVICE"/> 
    <uses-permission android:name=
     "android.permission.BIND_TEXT_SERVICE"/> 
    <uses-permission android:name=
     "android.permission.BIND_VPN_SERVICE"/> 
    <uses-permission android:name=
     "android.permission.PERSISTENT_ACTIVITY"/> 
    <uses-permission android:name=
     "android.permission.READ_CALL_LOG"/> 
    <uses-permission android:name=
     "com.android.browser.permission.READ_HISTORY_BOOKMARKS"/> 
    <uses-permission android:name=
     "android.permission.READ_PROFILE"/> 
    <uses-permission android:name=
     "android.permission.READ_SOCIAL_STREAM"/> 
    <uses-permission android:name=
     "android.permission.READ_USER_DICTIONARY"/> 
    <uses-permission android:name=
     "com.android.alarm.permission.SET_ALARM"/> 
    <uses-permission android:name=
     "android.permission.SET_PREFERRED_APPLICATIONS"/> 
    <uses-permission android:name=
     "android.permission.WRITE_CALL_LOG"/> 
    <uses-permission android:name=
     "com.android.browser.permission.WRITE_HISTORY_BOOKMARKS"/> 
    <uses-permission android:name=
     "android.permission.WRITE_PROFILE"/> 
    <uses-permission android:name=
     "android.permission.WRITE_SOCIAL_STREAM"/> 
    <uses-permission android:name=
     "android.permission.WRITE_USER_DICTIONARY"/>  

请求权限

在 Android SDK 版本 23 之后,需要在运行时请求权限(并非所有权限)。这意味着我们也需要从代码中请求它们。我们将演示如何从我们的应用程序中执行此操作。我们将在用户打开应用程序时请求获取 GPS 位置所需的权限。如果没有获得批准,用户将收到一个对话框以批准权限。打开您的 BaseActivity 类,并将其扩展如下:

    abstract class BaseActivity : AppCompatActivity() {
      companion object { 
      val REQUEST_GPS = 0 
      ... }
      ... 
      override fun onCreate(savedInstanceState: Bundle?) {   
        super.onCreate(savedInstanceState)
        ...
        requestGpsPermissions() } 
     ...
     private fun requestGpsPermissions() {   
       ActivityCompat.requestPermissions( 
         this@BaseActivity,
         arrayOf( 
           Manifest.permission.ACCESS_FINE_LOCATION,
           Manifest.permission.ACCESS_COARSE_LOCATION ),
           REQUEST_GPS ) }
            ... 
      override fun onRequestPermissionsResult(
        requestCode:
         Int, permissions: Array<String>, grantResults: IntArray ) {
           if (requestCode == REQUEST_GPS) { 
            for (grantResult in grantResults) 
            { if (grantResult == PackageManager.PERMISSION_GRANTED)
             { Log.i( tag, String.format( Locale.ENGLISH, "Permission 
              granted [ %d ]", requestCode ) ) 
             } 
             else {
               Log.e( tag, String.format( Locale.ENGLISH, "Permission
               not granted [ %d ]", requestCode ) )
             } } } } }

那么这段代码到底是在做什么呢?我们将从上到下解释所有行。

companion对象中,我们定义了我们请求的 ID。我们将等待该 ID 的结果。在onCreate()方法中,我们调用了requestGpsPermissions()方法,实际上是在我们定义的 ID 下进行权限请求。权限请求的结果将在onRequestPermissionsResult()重写方法中可用。如你所见,我们正在记录权限请求的结果。应用现在可以检索 GPS 数据。

对于所有其他安卓权限,原则是相同的。构建你的应用并运行它。将会询问你权限,如下截图所示:

用 Kotlin 的方式来做

如果我们的应用程序需要通过代码处理很多权限,会发生什么?这意味着我们有很多处理不同权限请求的代码。幸运的是,我们正在使用 Kotlin。Kotlin 将是我们简化事情的工具!

创建一个名为permission的新包。然后创建两个新的 Kotlin 文件如下:

PermissionCompatActivityPermissionRequestCallback

让我们定义权限请求回调如下:

     package com.journaler.permission 

     interface PermissionRequestCallback { 
       fun onPermissionGranted(permissions: List<String>) 
       fun onPermissionDenied(permissions: List<String>) 
     } 

这将是在解决权限时触发的callback。然后,定义我们的权限compat活动:

     package com.journaler.permission 

     import android.content.pm.PackageManager 
     import android.support.v4.app.ActivityCompat 
     import android.support.v7.app.AppCompatActivity 
     import android.util.Log 
     import java.util.concurrent.ConcurrentHashMap 
     import java.util.concurrent.atomic.AtomicInteger 

     abstract class PermissionCompatActivity : AppCompatActivity() { 

       private val tag = "Permissions extension" 
       private val latestPermissionRequest = AtomicInteger() 
       private val permissionRequests = ConcurrentHashMap<Int,
       List<String>>() 
       private val permissionCallbacks =  
        ConcurrentHashMap<List<String>, PermissionRequestCallback>() 

       private val defaultPermissionCallback = object :  
       PermissionRequestCallback { 
         override fun onPermissionGranted(permissions: List<String>) { 
            Log.i(tag, "Permission granted [ $permissions ]") 
         } 
         override fun onPermissionDenied(permissions: List<String>) { 
            Log.e(tag, "Permission denied [ $permissions ]") 
         } 
      } 

     fun requestPermissions( 
        vararg permissions: String,  
        callback: PermissionRequestCallback = defaultPermissionCallback 
     ) { 
        val id = latestPermissionRequest.incrementAndGet() 
        val items = mutableListOf<String>() 
        items.addAll(permissions) 
        permissionRequests[id] = items 
        permissionCallbacks[items] = callback 
        ActivityCompat.requestPermissions(this, permissions, id) 
     } 

     override fun onRequestPermissionsResult( 
        requestCode: Int,  
        permissions: Array<String>,  
        grantResults: IntArray 
     ) { 
        val items = permissionRequests[requestCode] 
        items?.let { 
           val callback = permissionCallbacks[items] 
           callback?.let { 
             var success = true 
              for (x in 0..grantResults.lastIndex) { 
                  val result = grantResults[x] 
                  if (result != PackageManager.PERMISSION_GRANTED) { 
                      success = false 
                      break 
                  } 
              } 
              if (success) { 
                 callback.onPermissionGranted(items) 
              } else { 
                  callback.onPermissionDenied(items) 
              } 
             } 
           } 
         } 
     }

这个类的理念是--我们向终端用户公开了requestPermissions()方法,该方法接受表示我们感兴趣的权限的可变数量的参数。我们可以传递(我们刚刚定义的)可选的callback(接口)。如果我们不传递自己的callback,将使用默认的callback。在权限解决后,我们触发callback。只有当所有权限都被授予时,我们才认为权限解决成功。

让我们更新我们的BaseActivity类如下:

     abstract class BaseActivity : PermissionCompatActivity() { 
     ... 
     override fun onCreate(savedInstanceState: Bundle?) { 
         ... 
         requestPermissions( 
            Manifest.permission.ACCESS_FINE_LOCATION, 
            Manifest.permission.ACCESS_COARSE_LOCATION 
         ) 
     } 
     ... 
    } 

如你所见,我们从BaseActivity类中删除了所有先前与权限相关的代码,并用一个requestPermission()调用替换了它。

总结

本章可能很短,但你学到的信息非常宝贵。每个安卓应用都需要权限。它们存在是为了保护用户和开发者。正如你所见,根据你的需求,有很多不同的权限可以使用。

在下一章中,我们将继续讲解系统部分,你将学习数据库处理。

第七章:使用数据库

在上一章中,我们获得了访问 Android 系统功能所需的关键权限。在我们的情况下,我们获得了位置权限。在本章中,我们将通过向数据库插入数据来继续。我们将插入来自 Android 位置提供程序的位置数据。为此,我们将定义适当的数据库模式和管理类。我们还将定义用于访问位置提供程序以获取位置数据的类。

在本章中,我们将涵盖以下主题:

  • SQLite 简介

  • 描述数据库

  • CRUD 操作

SQLite 简介

为了保存我们应用程序的数据,我们将需要一个数据库。在 Android 中,可以使用 SQLite 进行离线数据存储。

SQLite 是开箱即用的,这意味着它已经包含在 Android 框架中。

好处

SQLite 的好处是它功能强大、快速、可靠,并且有一个庞大的社区在使用它。如果您遇到任何问题,很容易找到解决方案,因为社区中的某人很可能已经解决了这些问题。SQLite 是一个独立的、嵌入式的、功能齐全的、公共领域的 SQL 数据库引擎。

我们将使用 SQLite 来存储所有我们的 Todos 和 Notes。为此,我们将定义我们的数据库、访问它的机制以及数据管理。我们不会直接暴露一个裸的数据库实例,而是会适当地包装它,以便轻松地插入、更新、查询或删除数据。

描述我们的数据库

我们将首先通过定义其表和列以及适当的数据类型来描述我们的数据库。我们还将定义简单的模型来表示我们的数据。为此,请创建一个名为database的新包:

     com.journaler.database 

然后,创建一个名为DbModel的新的 Kotlin 类。DbModel类将表示我们应用程序的所有数据库模型的矩阵,并且只包含 ID,因为 ID 是一个必填字段,并且将用作主键。确保您的DbModel类看起来像这样:

    package com.journaler.database 

    abstract class DbModel { 
      abstract var id: Long 
    } 

现在,当我们定义了我们的起点后,我们将定义实际包含数据的数据类。在我们现有的名为model的包中,创建新的类--DbEntryNoteTodoNoteTodo将扩展Entry,而Entry又扩展了DbModel类。

Entry类的代码如下:

    package com.journaler.model 

    import android.location.Location 
    import com.journaler.database.DbModel 

    abstract class Entry( 
      var title: String, 
      var message: String, 
      var location: Location 
    ) : DbModel() 
    Note class: 
    package com.journaler.model 

    import android.location.Location 

    class Note( 
      title: String, 
      message: String, 
      location: Location 
    ) : Entry( 
        title, 
        message, 
        location 
        ) { 
         override var id = 0L 
        } 

您将注意到我们将当前地理位置作为存储在我们的笔记中的信息,以及title和笔记message内容。我们还重写了 ID。由于新实例化的note尚未存储到数据库中,因此其 ID 将为零。存储后,它将更新为从数据库获取的 ID 值。

Todo类:

    package com.journaler.model 

    import android.location.Location 

    class Todo( 
      title: String, 
      message: String, 
      location: Location, 
      var scheduledFor: Long 
    ) : Entry( 
        title, 
        message, 
        location 
    ) { 
    override var id = 0L 
    } 

Todo类将比Note类多一个字段--用于安排todotimestamp

现在,在我们定义了数据模型之后,我们将描述我们的数据库。我们必须定义负责数据库初始化的数据库助手类。数据库助手类必须扩展 Android 的SQLiteOpenHelper类。创建DbHelper类,并确保它扩展了SQLiteOpenHelper类:

    package com.journaler.database 

    import android.database.sqlite.SQLiteDatabase 
    import android.database.sqlite.SQLiteOpenHelper 
    import android.util.Log 
    import com.journaler.Journaler 

    class DbHelper(val dbName: String, val version: Int) :   
    SQLiteOpenHelper( 
      Journaler.ctx, dbName, null, version 
    ) { 

      companion object { 
        val ID: String = "_id" 
        val TABLE_TODOS = "todos" 
        val TABLE_NOTES = "notes" 
        val COLUMN_TITLE: String = "title" 
        val COLUMN_MESSAGE: String = "message" 
        val COLUMN_SCHEDULED: String = "scheduled" 
        val COLUMN_LOCATION_LATITUDE: String = "latitude" 
        val COLUMN_LOCATION_LONGITUDE: String = "longitude" 
      } 

      private val tag = "DbHelper" 

      private val createTableNotes =  """ 
        CREATE TABLE if not exists $TABLE_NOTES 
           ( 
             $ID integer PRIMARY KEY autoincrement, 
             $COLUMN_TITLE text, 
             $COLUMN_MESSAGE text, 
             $COLUMN_LOCATION_LATITUDE real, 
             $COLUMN_LOCATION_LONGITUDE real 
           ) 
          """ 

      private val createTableTodos =  """ 
        CREATE TABLE if not exists $TABLE_TODOS 
           ( 
              $ID integer PRIMARY KEY autoincrement, 
              $COLUMN_TITLE text, 
              $COLUMN_MESSAGE text, 
              $COLUMN_SCHEDULED integer, 
              $COLUMN_LOCATION_LATITUDE real, 
              $COLUMN_LOCATION_LONGITUDE real 
           ) 
         """ 

       override fun onCreate(db: SQLiteDatabase) { 
        Log.d(tag, "Database [ CREATING ]") 
        db.execSQL(createTableNotes) 
        db.execSQL(createTableTodos) 
        Log.d(tag, "Database [ CREATED ]") 
       } 

      override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int,
      newVersion: Int) { 
        // Ignore for now. 
      } 

    } 

我们的companion对象包含了表和列名称的定义。我们还定义了用于创建表的 SQL。最后,SQL 在onCreate()方法中执行。在下一节中,我们将进一步进行数据库管理,并最终插入一些数据。

CRUD 操作

CRUD 操作是用于创建、更新、选择或删除数据的操作。它们是用一个名为Crud的接口定义的,并且它将是通用的。在database包中创建一个新的接口。确保它涵盖所有 CRUD 操作:

     interface Crud<T> where T : DbModel { 

       companion object { 
        val BROADCAST_ACTION = "com.journaler.broadcast.crud" 
        val BROADCAST_EXTRAS_KEY_CRUD_OPERATION_RESULT = "crud_result" 
       } 

      /** 
       * Returns the ID of inserted item. 
       */ 
      fun insert(what: T): Long 

      /** 
       * Returns the list of inserted IDs. 
       */ 
      fun insert(what: Collection<T>): List<Long> 

      /** 
      * Returns the number of updated items. 
      */ 
      fun update(what: T): Int 

      /** 
      * Returns the number of updated items. 
      */ 
      fun update(what: Collection<T>): Int 

      /** 
      * Returns the number of deleted items. 
      */ 
      fun delete(what: T): Int 

      /** 
      * Returns the number of deleted items. 
      */ 
      fun delete(what: Collection<T>): Int 

      /** 
      * Returns the list of items. 
      */ 
      fun select(args: Pair<String, String>): List<T> 

      /** 
      * Returns the list of items. 
      */ 
      fun select(args: Collection<Pair<String, String>>): List<T> 

      /** 
      * Returns the list of items. 
      */ 
      fun selectAll(): List<T> 

    } 

要执行 CRUD 操作,有两种方法版本。第一个版本是接受实例集合的版本,第二个版本是接受单个项目的版本。让我们通过创建一个名为Db的 Kotlin 对象来创建 CRUD 具体化。创建一个对象使我们的具体化成为一个完美的单例。Db对象必须实现Crud接口:

     package com.journaler.database 

     import android.content.ContentValues 
     import android.location.Location 
     import android.util.Log 
     import com.journaler.model.Note 
     import com.journaler.model.Todo 

     object Db { 

      private val tag = "Db" 
      private val version = 1 
      private val name = "students" 

      val NOTE = object : Crud<Note> { 
        // Crud implementations 
      } 

      val TODO = object : Crud<NoteTodo { 
         // Crud implementations 
      } 
    }  

插入 CRUD 操作

插入操作将新数据添加到数据库中。其实现如下:

    val NOTE = object : Crud<Note> { 
      ... 
      override fun insert(what: Note): Long { 
        val inserted = insert(listOf(what)) 
        if (!inserted.isEmpty()) return inserted[0] 
        return 0 
      } 

     override fun insert(what: Collection<Note>): List<Long> { 
       val db = DbHelper(name, version).writableDatabase 
       db.beginTransaction() 
       var inserted = 0 
       val items = mutableListOf<Long>() 
       what.forEach { item -> 
         val values = ContentValues() 
         val table = DbHelper.TABLE_NOTES 
         values.put(DbHelper.COLUMN_TITLE, item.title) 
         values.put(DbHelper.COLUMN_MESSAGE, item.message) 
         values.put(DbHelper.COLUMN_LOCATION_LATITUDE,
           item.location.latitude) 
         values.put(DbHelper.COLUMN_LOCATION_LONGITUDE,
           item.location.longitude) 
         val id = db.insert(table, null, values) 
           if (id > 0) { 
             items.add(id) 
             Log.v(tag, "Entry ID assigned [ $id ]") 
               inserted++ 
             } 
           } 
           val success = inserted == what.size 
           if (success) { 
                db.setTransactionSuccessful() 
           } else { 
                items.clear() 
           } 
            db.endTransaction() 
            db.close() 
            return items 
          } 
          ... 
    } 
    ... 
    val TODO = object : Crud<Todo> { 
      ... 
      override fun insert(what: Todo): Long { 
        val inserted = insert(listOf(what)) 
        if (!inserted.isEmpty()) return inserted[0] 
        return 0 
      } 

      override fun insert(what: Collection<Todo>): List<Long> { 
        val db = DbHelper(name, version).writableDatabase 
        db.beginTransaction() 
        var inserted = 0 
        val items = mutableListOf<Long>() 
        what.forEach { item -> 
          val table = DbHelper.TABLE_TODOS 
          val values = ContentValues() 
          values.put(DbHelper.COLUMN_TITLE, item.title) 
          values.put(DbHelper.COLUMN_MESSAGE, item.message) 
          values.put(DbHelper.COLUMN_LOCATION_LATITUDE,
          item.location.latitude) 
          values.put(DbHelper.COLUMN_LOCATION_LONGITUDE,
          item.location.longitude) 
          values.put(DbHelper.COLUMN_SCHEDULED, item.scheduledFor) 
            val id = db.insert(table, null, values) 
            if (id > 0) { 
              item.id = id 
              Log.v(tag, "Entry ID assigned [ $id ]") 
              inserted++ 
            } 
           } 
           val success = inserted == what.size 
           if (success) { 
                db.setTransactionSuccessful() 
           } else { 
               items.clear() 
           } 
           db.endTransaction() 
           db.close() 
           return items 
          } 
         ... 
     } 
     ... 

更新 CRUD 操作

更新操作将更新我们数据库中的现有数据。其实现如下:

    val NOTE = object : Crud<Note> { 
       ... 
       override fun update(what: Note) = update(listOf(what)) 

       override fun update(what: Collection<Note>): Int { 
         val db = DbHelper(name, version).writableDatabase 
         db.beginTransaction() 
         var updated = 0 
         what.forEach { item -> 
           val values = ContentValues() 
           val table = DbHelper.TABLE_NOTES 
           values.put(DbHelper.COLUMN_TITLE, item.title) 
           values.put(DbHelper.COLUMN_MESSAGE, item.message) 
           values.put(DbHelper.COLUMN_LOCATION_LATITUDE,
           item.location.latitude) 
           values.put(DbHelper.COLUMN_LOCATION_LONGITUDE,
           item.location.longitude) 
           db.update(table, values, "_id = ?", 
           arrayOf(item.id.toString())) 
                updated++ 
           } 
           val result = updated == what.size 
           if (result) { 
             db.setTransactionSuccessful() 
           } else { 
             updated = 0 
           } 
           db.endTransaction() 
           db.close() 
           return updated 
          } 
          ... 
        } 
        ... 
      val TODO = object : Crud<Todo> { 
        ... 
        override fun update(what: Todo) = update(listOf(what)) 

        override fun update(what: Collection<Todo>): Int { 
          val db = DbHelper(name, version).writableDatabase 
          db.beginTransaction() 
          var updated = 0 
          what.forEach { item -> 
             val table = DbHelper.TABLE_TODOS 
             val values = ContentValues() 
             values.put(DbHelper.COLUMN_TITLE, item.title) 
             values.put(DbHelper.COLUMN_MESSAGE, item.message) 
             values.put(DbHelper.COLUMN_LOCATION_LATITUDE,
             item.location.latitude) 
            values.put(DbHelper.COLUMN_LOCATION_LONGITUDE,
            item.location.longitude) 
            values.put(DbHelper.COLUMN_SCHEDULED, item.scheduledFor) 
            db.update(table, values, "_id = ?",  
            arrayOf(item.id.toString())) 
               updated++ 
            } 
            val result = updated == what.size 
            if (result) { 
              db.setTransactionSuccessful() 
            } else { 
              updated = 0 
            } 
            db.endTransaction() 
            db.close() 
            return updated 
            } 
           ... 
      } 
     ...  

删除 CRUD 操作

删除操作将从数据库中删除现有数据。其实现如下:

    val NOTE = object : Crud<Note> { 
      ... 
      override fun delete(what: Note): Int = delete(listOf(what)) 
         override fun delete(what: Collection<Note>): Int { 
         val db = DbHelper(name, version).writableDatabase 
         db.beginTransaction() 
         val ids = StringBuilder() 
         what.forEachIndexed { index, item -> 
         ids.append(item.id.toString()) 
           if (index < what.size - 1) { 
              ids.append(", ") 
           } 
         } 
         val table = DbHelper.TABLE_NOTES 
         val statement = db.compileStatement( 
           "DELETE FROM $table WHERE ${DbHelper.ID} IN ($ids);" 
         ) 
         val count = statement.executeUpdateDelete() 
         val success = count > 0 
         if (success) { 
           db.setTransactionSuccessful() 
           Log.i(tag, "Delete [ SUCCESS ][ $count ][ $statement ]") 
         } else { 
            Log.w(tag, "Delete [ FAILED ][ $statement ]") 
         } 
          db.endTransaction() 
          db.close() 
          return count 
        } 
        ... 
     } 
     ... 
     val TODO = object : Crud<Todo> { 
       ... 
       override fun delete(what: Todo): Int = delete(listOf(what)) 
       override fun delete(what: Collection<Todo>): Int { 
         val db = DbHelper(name, version).writableDatabase 
         db.beginTransaction() 
         val ids = StringBuilder() 
         what.forEachIndexed { index, item -> 
         ids.append(item.id.toString()) 
            if (index < what.size - 1) { 
                ids.append(", ") 
            } 
        } 
        val table = DbHelper.TABLE_TODOS 
        val statement = db.compileStatement( 
          "DELETE FROM $table WHERE ${DbHelper.ID} IN ($ids);" 
        ) 
        val count = statement.executeUpdateDelete() 
        val success = count > 0 
        if (success) { 
           db.setTransactionSuccessful() 
           Log.i(tag, "Delete [ SUCCESS ][ $count ][ $statement ]") 
        } else { 
           Log.w(tag, "Delete [ FAILED ][ $statement ]") 
        } 
         db.endTransaction() 
         db.close() 
         return count 
        } 
        ... 
    } 
    ...  

选择 CRUD 操作

选择操作将从数据库中读取并返回数据。其实现如下:

     val NOTE = object : Crud<Note> { 
        ... 
        override fun select( 
            args: Pair<String, String> 
        ): List<Note> = select(listOf(args)) 

        override fun select(args: Collection<Pair<String, String>>):
        List<Note> { 
          val db = DbHelper(name, version).writableDatabase 
          val selection = StringBuilder() 
          val selectionArgs = mutableListOf<String>() 
          args.forEach { arg -> 
              selection.append("${arg.first} == ?") 
              selectionArgs.add(arg.second) 
          } 
          val result = mutableListOf<Note>() 
          val cursor = db.query( 
              true, 
              DbHelper.TABLE_NOTES, 
              null, 
              selection.toString(), 
              selectionArgs.toTypedArray(), 
              null, null, null, null 
          ) 
          while (cursor.moveToNext()) { 
          val id = cursor.getLong(cursor.getColumnIndexOrThrow
          (DbHelper.ID)) 
          val titleIdx = cursor.getColumnIndexOrThrow
          (DbHelper.COLUMN_TITLE) 
          val title = cursor.getString(titleIdx) 
          val messageIdx = cursor.getColumnIndexOrThrow
          (DbHelper.COLUMN_MESSAGE) 
          val message = cursor.getString(messageIdx) 
          val latitudeIdx = cursor.getColumnIndexOrThrow( 
             DbHelper.COLUMN_LOCATION_LATITUDE 
          ) 
          val latitude = cursor.getDouble(latitudeIdx) 
          val longitudeIdx = cursor.getColumnIndexOrThrow( 
             DbHelper.COLUMN_LOCATION_LONGITUDE 
          ) 
          val longitude = cursor.getDouble(longitudeIdx) 
          val location = Location("") 
          location.latitude = latitude 
          location.longitude = longitude 
          val note = Note(title, message, location) 
          note.id = id 
          result.add(note) 
        } 
          cursor.close() 
          return result 
       } 

       override fun selectAll(): List<Note> { 
         val db = DbHelper(name, version).writableDatabase 
         val result = mutableListOf<Note>() 
         val cursor = db.query( 
            true, 
            DbHelper.TABLE_NOTES, 
            null, null, null, null, null, null, null 
         ) 
         while (cursor.moveToNext()) { 
                val id = cursor.getLong(cursor.getColumnIndexOrThrow
               (DbHelper.ID)) 
                val titleIdx = cursor.getColumnIndexOrThrow
                (DbHelper.COLUMN_TITLE) 
                val title = cursor.getString(titleIdx) 
                val messageIdx = cursor.getColumnIndexOrThrow
                (DbHelper.COLUMN_MESSAGE) 
                val message = cursor.getString(messageIdx) 
                val latitudeIdx = cursor.getColumnIndexOrThrow( 
                  DbHelper.COLUMN_LOCATION_LATITUDE 
                ) 
                val latitude = cursor.getDouble(latitudeIdx) 
                val longitudeIdx = cursor.getColumnIndexOrThrow( 
                   DbHelper.COLUMN_LOCATION_LONGITUDE 
                ) 
                val longitude = cursor.getDouble(longitudeIdx) 
                val location = Location("") 
                location.latitude = latitude 
                location.longitude = longitude 
                val note = Note(title, message, location) 
                note.id = id 
                result.add(note) 
              } 
             cursor.close() 
             return result 
            } 
            ... 
          } 
          ... 
       val TODO = object : Crud<Todo> { 
        ... 
        override fun select(args: Pair<String, String>): List<Todo> =
        select(listOf(args)) 

        override fun select(args: Collection<Pair<String, String>>): 
        List<Todo> { 
          val db = DbHelper(name, version).writableDatabase 
          val selection = StringBuilder() 
          val selectionArgs = mutableListOf<String>() 
          args.forEach { arg -> 
             selection.append("${arg.first} == ?") 
             selectionArgs.add(arg.second) 
          } 
          val result = mutableListOf<Todo>() 
          val cursor = db.query( 
             true, 
             DbHelper.TABLE_NOTES, 
             null, 
             selection.toString(), 
             selectionArgs.toTypedArray(), 
             null, null, null, null 
            ) 
            while (cursor.moveToNext()) { 
                val id = cursor.getLong(cursor.getColumnIndexOrThrow
                (DbHelper.ID)) 
                val titleIdx = cursor.getColumnIndexOrThrow
                (DbHelper.COLUMN_TITLE) 
                val title = cursor.getString(titleIdx) 
                val messageIdx = cursor.getColumnIndexOrThrow
                (DbHelper.COLUMN_MESSAGE) 
                val message = cursor.getString(messageIdx) 
                val latitudeIdx = cursor.getColumnIndexOrThrow( 
                    DbHelper.COLUMN_LOCATION_LATITUDE 
                ) 
                val latitude = cursor.getDouble(latitudeIdx) 
                val longitudeIdx = cursor.getColumnIndexOrThrow( 
                    DbHelper.COLUMN_LOCATION_LONGITUDE 
                ) 
                val longitude = cursor.getDouble(longitudeIdx) 
                val location = Location("") 
                val scheduledForIdx = cursor.getColumnIndexOrThrow( 
                    DbHelper.COLUMN_SCHEDULED 
                ) 
                val scheduledFor = cursor.getLong(scheduledForIdx) 
                location.latitude = latitude 
                location.longitude = longitude 
                val todo = Todo(title, message, location, scheduledFor) 
                todo.id = id 
                result.add(todo) 
               } 
              cursor.close() 
              return result 
            } 

            override fun selectAll(): List<Todo> { 
            val db = DbHelper(name, version).writableDatabase 
            val result = mutableListOf<Todo>() 
            val cursor = db.query( 
              true, 
              DbHelper.TABLE_NOTES, 
              null, null, null, null, null, null, null 
            ) 
            while (cursor.moveToNext()) { 
                val id = cursor.getLong(cursor.getColumnIndexOrThrow
                (DbHelper.ID)) 
                val titleIdx = cursor.getColumnIndexOrThrow
                (DbHelper.COLUMN_TITLE) 
                val title = cursor.getString(titleIdx) 
                val messageIdx = cursor.getColumnIndexOrThrow
                (DbHelper.COLUMN_MESSAGE) 
                val message = cursor.getString(messageIdx) 
                val latitudeIdx = cursor.getColumnIndexOrThrow( 
                    DbHelper.COLUMN_LOCATION_LATITUDE 
                ) 
                val latitude = cursor.getDouble(latitudeIdx) 
                val longitudeIdx = cursor.getColumnIndexOrThrow( 
                    DbHelper.COLUMN_LOCATION_LONGITUDE 
                ) 
                val longitude = cursor.getDouble(longitudeIdx) 
                val location = Location("") 
                val scheduledForIdx = cursor.getColumnIndexOrThrow( 
                    DbHelper.COLUMN_SCHEDULED 
                ) 
                val scheduledFor = cursor.getLong(scheduledForIdx) 
                location.latitude = latitude 
                location.longitude = longitude 
                val todo = Todo(title, message, location, scheduledFor) 
                todo.id = id 
                result.add(todo) 
              } 
              cursor.close() 
               return result 
             } 
             ... 
        } 
        ... 

每个 CRUD 操作都将使用我们的DbHelper类获取数据库实例。我们不会直接暴露它,而是通过我们的 CRUD 机制来利用它。每次操作后,数据库都将被关闭。我们只能通过访问writableDatabase来获取可读数据库或者像我们的情况一样获取WritableDatabase实例。每个 CRUD 操作都作为一个 SQL 事务执行。这意味着我们将通过在数据库实例上调用beginTransaction()来开始它。通过调用endTransaction()来完成事务。如果我们在之前没有调用setTransactionSuccessful(),则不会应用任何更改。正如我们已经提到的,每个 CRUD 操作都有两个版本--一个包含主要实现,另一个只是将实例传递给另一个。要执行对数据库的插入,重要的是要注意我们将在数据库实例上使用insert()方法,该方法接受我们要插入的表名和代表数据的内容值(ContentValues类)。updatedelete操作类似。我们使用update()delete()方法。在我们的情况下,对于数据删除,我们使用了包含删除 SQL 查询的compileStatement()

我们在这里提供的代码有点复杂。我们直接指向了与数据库相关的事项。所以,请耐心阅读代码,慢慢来,花时间来研究它。我们鼓励您利用我们已经提到的 Android 数据库类,以您自己的方式创建自己的数据库管理类。

将事物联系在一起

我们还有一步!那就是实际使用我们的数据库类并执行 CRUD 操作。我们将扩展应用程序以创建笔记,并专注于插入。

在我们向数据库中插入任何内容之前,我们必须提供一种机制来获取当前用户位置,因为这对于notestodos都是必需的。创建一个名为LocationProvider的新类,并将其定位在location包中,如下所示:

     object LocationProvider { 
       private val tag = "Location provider" 
       private val listeners =   CopyOnWriteArrayList
       <WeakReference<LocationListener>>() 

       private val locationListener = object : LocationListener { 
       ... 
       } 

      fun subscribe(subscriber: LocationListener): Boolean { 
        val result = doSubscribe(subscriber) 
        turnOnLocationListening() 
        return result 
      } 

      fun unsubscribe(subscriber: LocationListener): Boolean { 
        val result = doUnsubscribe(subscriber) 
        if (listeners.isEmpty()) { 
            turnOffLocationListening() 
        } 
        return result 
      } 

      private fun turnOnLocationListening() { 
      ... 
      } 

      private fun turnOffLocationListening() { 
      ... 
      } 

      private fun doSubscribe(listener: LocationListener): Boolean { 
      ... 
      } 

      private fun doUnsubscribe(listener: LocationListener): Boolean { 
       ... 
      } 
    } 

我们公开了LocationProvider对象的主要结构。让我们来看看其余的实现:

locationListener实例代码如下:

     private val locationListener = object : LocationListener { 
        override fun onLocationChanged(location: Location) { 
            Log.i( 
                    tag, 
                    String.format( 
                            Locale.ENGLISH, 
                            "Location [ lat: %s ][ long: %s ]",
                            location.latitude, location.longitude 
                    ) 
            ) 
            val iterator = listeners.iterator() 
            while (iterator.hasNext()) { 
                val reference = iterator.next() 
                val listener = reference.get() 
                listener?.onLocationChanged(location) 
            } 
         } 

        override fun onStatusChanged(provider: String, status: Int,
        extras: Bundle) { 
            Log.d( 
                    tag, 
                    String.format(Locale.ENGLISH, "Status changed [ %s
                    ][ %d ]", provider, status) 
            ) 
            val iterator = listeners.iterator() 
            while (iterator.hasNext()) { 
                val reference = iterator.next() 
                val listener = reference.get() 
                listener?.onStatusChanged(provider, status, extras) 
            } 
        } 

        override fun onProviderEnabled(provider: String) { 
            Log.i(tag, String.format("Provider [ %s ][ ENABLED ]",
            provider)) 
            val iterator = listeners.iterator() 
            while (iterator.hasNext()) { 
                val reference = iterator.next() 
                val listener = reference.get() 
                listener?.onProviderEnabled(provider) 
            } 
        } 

        override fun onProviderDisabled(provider: String) { 
            Log.i(tag, String.format("Provider [ %s ][ ENABLED ]",
            provider)) 
            val iterator = listeners.iterator() 
            while (iterator.hasNext()) { 
                val reference = iterator.next() 
                val listener = reference.get() 
                listener?.onProviderDisabled(provider) 
            } 
          } 
         } 

LocationListener是 Android 的接口,其目的是在location事件上执行。我们创建了我们的具体化,基本上会通知所有订阅方有关这些事件的信息。对我们来说最重要的是onLocationChanged()

    turnOnLocationListening(): 

    private fun turnOnLocationListening() { 
       Log.v(tag, "We are about to turn on location listening.") 
       val ctx = Journaler.ctx 
       if (ctx != null) { 
            Log.v(tag, "We are about to check location permissions.") 

            val permissionsOk = 
            ActivityCompat.checkSelfPermission(ctx,
            Manifest.permission.ACCESS_FINE_LOCATION) ==  
            PackageManager.PERMISSION_GRANTED  
            &&  
            ActivityCompat.checkSelfPermission(ctx, 
            Manifest.permission.ACCESS_COARSE_LOCATION) ==
            PackageManager.PERMISSION_GRANTED 

            if (!permissionsOk) { 
                throw IllegalStateException( 
                "Permissions required [ ACCESS_FINE_LOCATION ]
                 [ ACCESS_COARSE_LOCATION ]" 
                ) 
            } 
            Log.v(tag, "Location permissions are ok. 
            We are about to request location changes.") 
            val locationManager =
            ctx.getSystemService(Context.LOCATION_SERVICE)
            as LocationManager 

            val criteria = Criteria() 
            criteria.accuracy = Criteria.ACCURACY_FINE 
            criteria.powerRequirement = Criteria.POWER_HIGH 
            criteria.isAltitudeRequired = false 
            criteria.isBearingRequired = false 
            criteria.isSpeedRequired = false 
            criteria.isCostAllowed = true 

            locationManager.requestLocationUpdates( 
                    1000, 1F, criteria, locationListener, 
                    Looper.getMainLooper() 
            ) 
            } else { 
             Log.e(tag, "No application context available.") 
          } 
        } 

要打开位置监听,我们必须检查权限是否得到了正确的满足。如果是这样,那么我们将获取 Android 的LocationManager并为位置更新定义Criteria。我们将我们的标准定义为非常精确和准确。最后,我们通过传递以下参数来请求位置更新:

  • long minTime

  • float minDistance

  • Criteria criteria

  • LocationListener listener

  • Looper looper

正如您所看到的,我们传递了我们的LocationListener具体化,它将通知所有订阅的第三方有关location事件的信息:

     turnOffLocationListening():private fun turnOffLocationListening() 
     { 
       Log.v(tag, "We are about to turn off location listening.") 
       val ctx = Journaler.ctx 
       if (ctx != null) { 
         val locationManager =  
         ctx.getSystemService(Context.LOCATION_SERVICE)
         as LocationManager 

         locationManager.removeUpdates(locationListener) 
        } else { 
            Log.e(tag, "No application context available.") 
        } 
     } 
  • 我们通过简单地移除我们的监听器instance.doSubscribe()来停止监听位置。
      private fun doSubscribe(listener: LocationListener): Boolean { 
        val iterator = listeners.iterator() 
        while (iterator.hasNext()) { 
          val reference = iterator.next() 
          val refListener = reference.get() 
          if (refListener != null && refListener === listener) { 
                Log.v(tag, "Already subscribed: " + listener) 
                return false 
            } 
         } 
         listeners.add(WeakReference(listener)) 
         Log.v(tag, "Subscribed, subscribers count: " + listeners.size) 
         return true 
      }  
  • doUnsubscribe()方法代码如下:
      private fun doUnsubscribe(listener: LocationListener): Boolean { 
        var result = true 
        val iterator = listeners.iterator() 
        while (iterator.hasNext()) { 
            val reference = iterator.next() 
            val refListener = reference.get() 
            if (refListener != null && refListener === listener) { 
                val success = listeners.remove(reference) 
                if (!success) { 
                    Log.w(tag, "Couldn't un subscribe, subscribers
                    count: " + listeners.size) 
                } else { 
                    Log.v(tag, "Un subscribed, subscribers count: " +
                    listeners.size) 
                } 
                if (result) { 
                    result = success 
                } 
               } 
             } 
            return result 
        } 

这两种方法负责订阅和取消订阅位置更新给感兴趣的第三方。

我们已经拥有了所需的一切。打开NoteActivity类并扩展如下:

     class NoteActivity : ItemActivity() { 
       private var note: Note? = null 
       override val tag = "Note activity" 
       private var location: Location? = null 
       override fun getLayout() = R.layout.activity_note 

      private val textWatcher = object : TextWatcher { 
        override fun afterTextChanged(p0: Editable?) { 
            updateNote() 
        } 

        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2:
        Int, p3: Int) {} 
        override fun onTextChanged(p0: CharSequence?, p1: Int, p2:
        Int, p3: Int) {} 
      } 

      private val locationListener = object : LocationListener { 
        override fun onLocationChanged(p0: Location?) { 
            p0?.let { 
                LocationProvider.unsubscribe(this) 
                location = p0 
                val title = getNoteTitle() 
                val content = getNoteContent() 
                note = Note(title, content, p0) 
                val task = object : AsyncTask<Note, Void, Boolean>() { 
                    override fun doInBackground(vararg params: Note?):
                    Boolean { 
                        if (!params.isEmpty()) { 
                            val param = params[0] 
                            param?.let { 
                                return Db.NOTE.insert(param) > 0 
                            } 
                        } 
                        return false 
                    } 

                    override fun onPostExecute(result: Boolean?) { 
                        result?.let { 
                            if (result) { 
                                Log.i(tag, "Note inserted.") 
                            } else { 
                                Log.e(tag, "Note not inserted.") 
                            } 
                        } 
                     } 
                  } 
                task.execute(note) 
              } 
          } 

         override fun onStatusChanged(p0: String?, p1: Int, p2:
         Bundle?) {} 
         override fun onProviderEnabled(p0: String?) {} 
         override fun onProviderDisabled(p0: String?) {} 
        } 

        override fun onCreate(savedInstanceState: Bundle?) { 
          super.onCreate(savedInstanceState) 
          note_title.addTextChangedListener(textWatcher) 
          note_content.addTextChangedListener(textWatcher) 
        } 

       private fun updateNote() { 
         if (note == null) { 
          if (!TextUtils.isEmpty(getNoteTitle()) &&
          !TextUtils.isEmpty(getNoteContent())) { 
             LocationProvider.subscribe(locationListener) 
          } 
         } else { 
            note?.title = getNoteTitle() 
            note?.message = getNoteContent() 
            val task = object : AsyncTask<Note, Void, Boolean>() { 
                override fun doInBackground(vararg params: Note?):
            Boolean { 
              if (!params.isEmpty()) { 
                 val param = params[0] 
                 param?.let { 
                   return Db.NOTE.update(param) > 0 
                  } 
                } 
                  return false 
              } 

              override fun onPostExecute(result: Boolean?) { 
                result?.let { 
                   if (result) { 
                       Log.i(tag, "Note updated.") 
                   } else { 
                       Log.e(tag, "Note not updated.") 
                   } 
                 } 
               } 
            } 
            task.execute(note) 
          } 
       } 

       private fun getNoteContent(): String { 
         return note_content.text.toString() 
       } 

       private fun getNoteTitle(): String { 
         return note_title.text.toString() 
       } 

     } 

我们在这里做了什么?让我们从上到下解释一切!我们添加了两个字段——一个包含我们正在编辑的当前Note实例,另一个包含当前用户位置信息。然后,我们定义了一个TextWatcher实例。TextWatcher是一个监听器,我们将分配给我们的EditText视图,每次更改时,适当的更新方法将被触发。该方法将创建一个新的note类,并将其持久化到数据库中(如果不存在),或者如果存在,则执行数据更新。

由于在没有位置数据可用之前,我们不会插入笔记,因此我们定义了我们的locationListener将接收到的位置放入位置字段中,并取消订阅自身。然后,我们将获取note标题和其主要内容的当前值,并创建一个新的note实例。由于数据库操作可能需要一些时间,我们将以异步方式执行它们。为此,我们将使用AsyncTask类。AsyncTask类是 Android 的类,旨在用于大多数异步操作。该类定义了输入类型、进度类型和结果类型。在我们的情况下,输入类型是Note。我们没有进度类型,但我们有一个结果类型Boolean,即操作是否成功。

主要工作是在doInBackground()具体化中完成的,而结果在onPostExecute()中处理。正如你所看到的,我们正在使用我们最近为数据库管理定义的类在后台执行插入操作。

如果你继续查看,我们接下来要做的事情是将textWatcher分配给onCreate()方法中的EditText视图。然后,我们定义了我们最重要的方法——updateNote()。它将更新现有的笔记,如果不存在,则插入一个新的笔记。同样,我们使用AsyncTask在后台执行操作。

构建你的应用程序并运行它。尝试插入note。观察你的 Logcat。你会注意到与数据库相关的日志,如下所示:

    I/Note activity: Note inserted. 
    I/Note activity: Note updated. 
    I/Note activity: Note updated. 
    I/Note activity: Note updated. 

如果你能看到这些日志,那么你已经成功在 Android 中实现了你的第一个数据库。我们鼓励你扩展代码以支持其他 CRUD 操作。确保NoteActivity支持selectdelete操作。

总结

在本章中,我们演示了如何在 Android 中持久化复杂数据。数据库是每个应用程序的核心,所以 Journaler 也不例外。我们涵盖了在 SQLite 数据库上执行的所有 CRUD 操作,并为每个操作提供了适当的实现。在我们的下一章中,我们将演示另一种持久化机制,用于较不复杂的数据。我们将处理 Android 共享首选项,并将使用它们来保存我们应用程序的简单小数据。

第八章:Android 偏好设置

在上一章中,我们处理了存储在 SQLite 数据库中的复杂数据。这一次,我们将处理一种更简单的数据形式。我们将涵盖一个特定的用例,以演示 Android 共享偏好设置的使用。

假设我们想要记住我们的ViewPager类的最后一页位置,并在每次启动应用程序时打开它。我们将使用共享偏好设置来记住它,并在每次视图页面位置更改时持久化该信息,并在需要时检索它。

在这个相当简短的章节中,我们将涵盖以下主题:

  • Android 的偏好设置是什么,你如何使用它们?

  • 定义自己的偏好设置管理器

Android 的偏好设置是什么?

我们应用程序的偏好设置是由 Android 的共享偏好设置机制持久化和检索的。共享偏好设置本身代表 Android 及其 API 访问和修改的 XML 数据。Android 处理有关检索和保存偏好设置的所有工作。它还提供了这些偏好设置为私有的机制,隐藏在公共访问之外。Android SDK 具有一套用于偏好设置管理的优秀类。还有可用的抽象,因此您不仅限于默认的 XML,而可以创建自己的持久化层。

你如何使用它们?

要使用共享偏好设置,您必须从当前上下文获取SharedPreferences实例:

    val prefs = ctx.getSharedPreferences(key, mode) 

在这里,key表示将命名此共享偏好设置实例的String。系统中的 XML 文件也将具有该名称。这些是可以从Context 类获得的模式(操作模式):

  • MODE_PRIVATE:这是默认模式,创建的文件只能被我们的调用应用程序访问

  • MODE_WORLD_READABLE:这已被弃用

  • MODE_WORLD_WRITEABLE:这已被弃用

然后,我们可以存储值或按以下方式检索它们:

    val value = prefs.getString("key", "default value")  

所有常见数据类型都有类似的getter方法。

编辑(存储)偏好设置

我们将通过提供偏好设置编辑的示例来开始本节:

    preferences.edit().putString("key", "balue").commit() 

commit()方法立即执行操作,而apply()方法在后台执行操作。

如果使用commit()方法,永远不要从应用程序的主线程获取或操作共享偏好设置。

确保所有写入和读取都在后台执行。您可以使用AsyncTask来实现这一目的,或者使用apply()而不是commit()

删除偏好设置

删除偏好设置,有一个remove方法可用,如下所示:

    prefs.edit().remove("key").commit() 

不要通过用空数据覆盖它们来删除您的偏好设置。例如,用 null 覆盖整数或用空字符串覆盖字符串。

定义自己的偏好设置管理器

为了实现本章开头的任务,我们将创建一个适当的机制来获取共享偏好设置。

创建一个名为preferences的新包。我们将把所有与preferences相关的代码放在该包中。对于共享偏好设置管理,我们将需要以下三个类:

  • PreferencesProviderAbstract:这是提供对 SharedPreferences 的访问的基本抽象

  • PreferencesProvider:这是PreferencesProviderAbstract的实现

  • PreferencesConfiguration:这个类负责描述我们尝试实例化的偏好设置

使用这种方法的好处是在我们的应用程序中统一访问共享偏好设置的方法。

让我们定义每个类如下:

  • PreferencesProviderAbstract类代码如下:
         package com.journaler.perferences 

         import android.content.Context 
         import android.content.SharedPreferences 

         abstract class PreferencesProviderAbstract { 
           abstract fun obtain(configuration: PreferencesConfiguration,
           ctx: Context): SharedPreferences 
         } 
  • PreferencesConfiguration类代码如下:
         package com.journaler.perferences 
         data class PreferencesConfiguration
         (val key: String, val mode: Int) 
  • PreferencesProvider类代码如下:
        package com.journaler.perferences 

        import android.content.Context 
        import android.content.SharedPreferences 

        class PreferencesProvider : PreferencesProviderAbstract() { 
          override fun obtain(configuration: PreferencesConfiguration,
          ctx: Context): SharedPreferences { 
            return ctx.getSharedPreferences(configuration.key,
            configuration.mode) 
          } 
        } 

正如你所看到的,我们创建了一个简单的机制来获取共享偏好设置。我们将加以整合。打开MainActivity类,并根据以下代码进行扩展:

     class MainActivity : BaseActivity() { 
       ... 
       private val keyPagePosition = "keyPagePosition" 
       ... 

       override fun onCreate(savedInstanceState: Bundle?) { 
         super.onCreate(savedInstanceState) 

         val provider = PreferencesProvider() 
         val config = PreferencesConfiguration("journaler_prefs",
         Context.MODE_PRIVATE) 
         val preferences = provider.obtain(config, this) 

         pager.adapter = ViewPagerAdapter(supportFragmentManager) 
         pager.addOnPageChangeListener(object :
         ViewPager.OnPageChangeListener { 
            override fun onPageScrollStateChanged(state: Int) { 
                // Ignore 
         } 

         override fun onPageScrolled(position: Int, positionOffset:
         Float, positionOffsetPixels: Int) { 
                // Ignore 
         } 

         override fun onPageSelected(position: Int) { 
           Log.v(tag, "Page [ $position ]") 
           preferences.edit().putInt(keyPagePosition, position).apply() 
         } 
       }) 

       val pagerPosition = preferences.getInt(keyPagePosition, 0) 
       pager.setCurrentItem(pagerPosition, true) 
       ... 
      } 
      ... 
     } 

我们创建了preferences实例,用于持久化和读取视图页面位置。构建并运行您的应用程序;滑动到其中一个页面,然后关闭您的应用程序并再次运行。如果您查看 Logcat,您将看到类似以下内容的信息(通过Page进行过滤):

     V/Main activity: Page [ 1 ] 
     V/Main activity: Page [ 2 ] 
     V/Main activity: Page [ 3 ] 
     After we restarted the application: 
     V/Main activity: Page [ 3 ] 
     V/Main activity: Page [ 2 ] 
     V/Main activity: Page [ 1 ] 
     V/Main activity: Page [ 0 ] 

我们在关闭后再次打开应用程序,并滑动回索引为0的页面。

总结

在本章中,您学习了如何使用 Android 共享偏好机制来持久化应用程序偏好设置。正如您所看到的,创建应用程序偏好设置并在应用程序中使用它们非常容易。在下一章中,我们将专注于 Android 中的并发性。我们将学习 Android 提供的机制,并举例说明如何使用它们。

第九章:Android 中的并发

在本章中,我们将解释 Android 中的并发。我们将给出例子和建议,并将并发应用到我们的 Journaler 应用程序中。我们已经通过演示AsyncTask类的使用来介绍了一些基础知识,但现在我们将深入探讨。

在本章中,我们将涵盖以下主题:

  • 处理程序和线程

  • AsyncTask

  • Android Looper

  • 延迟执行

介绍 Android 并发

我们的应用程序的默认执行是在主应用程序线程上执行的。这个执行必须是高效的!如果发生某些操作花费太长时间,那么我们会得到 ANR--Android 应用程序无响应的消息。为了避免 ANR,我们在后台运行我们的代码。Android 提供了机制,让我们可以高效地这样做。异步运行操作不仅可以提供良好的性能,还可以提供良好的用户体验。

主线程

所有用户界面更新都是从一个线程执行的。这就是主线程。所有事件都被收集在一个队列中,并由Looper类实例处理。

以下图片解释了涉及的类之间的关系:

重要的是要注意,主线程更新是你看到的所有 UI。但它也可以从其他线程执行。直接从其他线程执行这些操作会导致异常,你的应用程序可能会崩溃。为了避免这种情况,通过从当前活动上下文调用runOnUiThread()方法在主线程上执行所有与线程相关的代码。

处理程序和线程

在 Android 中,可以通过使用线程来执行线程。不建议只是随意启动线程而没有任何控制。因此,为此目的,可以使用ThreadPoolsExecutor类。

为了演示这一点,我们将更新我们的应用程序。创建一个名为execution的新包,并在其中创建一个名为TaskExecutor的类。确保它看起来像这样:

     package com.journaler.execution 

     import java.util.concurrent.BlockingQueue 
     import java.util.concurrent.LinkedBlockingQueue 
     import java.util.concurrent.ThreadPoolExecutor 
     import java.util.concurrent.TimeUnit 

     class TaskExecutor private constructor( 
        corePoolSize: Int, 
        maximumPoolSize: Int, 
        workQueue: BlockingQueue<Runnable>? 

    ) : ThreadPoolExecutor( 
        corePoolSize, 
        maximumPoolSize, 
        0L, 
        TimeUnit.MILLISECONDS, 
        workQueue 
    ) { 

    companion object { 
        fun getInstance(capacity: Int): TaskExecutor { 
            return TaskExecutor( 
                    capacity, 
                    capacity * 2, 
                    LinkedBlockingQueue<Runnable>() 
            ) 
        } 
    } }

我们扩展了ThreadPoolExecutor类和companion对象,并为执行器实例化添加了成员方法。让我们将其应用到我们现有的代码中。我们将从我们使用的AsyncTask类切换到TaskExecutor。打开NoteActivity类并按照以下方式更新它:

     class NoteActivity : ItemActivity() { 
       ... 
       private val executor = TaskExecutor.getInstance(1) 
       ... 
       private val locationListener = object : LocationListener { 
         override fun onLocationChanged(p0: Location?) { 
            p0?.let { 
                LocationProvider.unsubscribe(this) 
                location = p0 
                val title = getNoteTitle() 
                val content = getNoteContent() 
                note = Note(title, content, p0) 
                executor.execute { 
                  val param = note 
                  var result = false 
                  param?.let { 
                      result = Db.insert(param) 
                  } 
                  if (result) { 
                      Log.i(tag, "Note inserted.") 
                  } else { 
                      Log.e(tag, "Note not inserted.") 
                  } 
               } 

            } 
         } 

        override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?)
        {} 
        override fun onProviderEnabled(p0: String?) {} 
        override fun onProviderDisabled(p0: String?) {} 
      } 
         ... 
      private fun updateNote() { 
       if (note == null) { 
         if (!TextUtils.isEmpty(getNoteTitle()) &&
         !TextUtils.isEmpty(getNoteContent())) { 
            LocationProvider.subscribe(locationListener) 
          } 
        } else { 
           note?.title = getNoteTitle() 
           note?.message = getNoteContent() 
           executor.execute { 
             val param = note 
             var result = false 
             param?.let { 
                result = Db.update(param) 
             } 
             if (result) { 
                Log.i(tag, "Note updated.") 
             } else { 
                Log.e(tag, "Note not updated.") 
             } 
           } 
        } 
       } 
  ... }

如你所见,我们用执行器替换了AsyncTask。我们的执行器一次只处理一个线程。

除了标准的线程方法,Android 还提供了处理程序作为开发人员的选择之一。处理程序不是线程的替代品,而是一种补充!处理程序实例会在其父线程中注册自己。它代表了向特定线程发送数据的机制。我们可以发送MessageRunnable类的实例。让我们通过一个例子来说明它的用法。我们将使用指示器更新笔记屏幕,如果一切都执行正确,指示器将是绿色。如果数据库持久化失败,它将是红色。它的默认颜色将是灰色。打开activity_note.xml文件并扩展它以包含指示器。指示器将是普通视图,如下所示:

     <?xml version="1.0" encoding="utf-8"?> 
     <ScrollView xmlns:android=
      "http://schemas.android.com/apk/res/android" 
     android:layout_width="match_parent" 
     android:layout_height="match_parent" 
     android:fillViewport="true"> 

     <LinearLayout 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:background="@color/black_transparent_40" 
        android:orientation="vertical"> 

        ... 

        <RelativeLayout 
            android:layout_width="match_parent" 
            android:layout_height="wrap_content"> 

            <View 
                android:id="@+id/indicator" 
                android:layout_width="40dp" 
                android:layout_height="40dp" 
                android:layout_alignParentEnd="true" 
                android:layout_centerVertical="true" 
                android:layout_margin="10dp" 
                android:background="@android:color/darker_gray" /> 

            <EditText 
                android:id="@+id/note_title" 
                style="@style/edit_text_transparent" 
                android:layout_width="match_parent" 
                android:layout_height="wrap_content" 
                android:hint="@string/title" 
                android:padding="@dimen/form_padding" /> 

        </RelativeLayout>         
         ...      
      </LinearLayout> 

    </ScrollView> 

现在,当我们添加指示器时,它将根据数据库插入结果改变颜色。像这样更新你的NoteActivity类源代码:

     class NoteActivity : ItemActivity() { 
      ... 
      private var handler: Handler? = null 
      .... 
      override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        handler = Handler(Looper.getMainLooper()) 
        ... 
      } 
      ... 
      private val locationListener = object : LocationListener { 
        override fun onLocationChanged(p0: Location?) { 
            p0?.let { 
                ... 
                executor.execute { 
                    ... 
                    handler?.post { 
                        var color = R.color.vermilion 
                        if (result) { 
                            color = R.color.green 
                        } 
                        indicator.setBackgroundColor( 
                                ContextCompat.getColor( 
                                        this@NoteActivity, 
                                        color 
                                ) 
                        ) 
                    } 
                } 
            } 
        } 

        override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?)
        {} 
        override fun onProviderEnabled(p0: String?) {} 
        override fun onProviderDisabled(p0: String?) {} 
      } 
     ... 
     private fun updateNote() { 
        if (note == null) { 
            ... 
        } else { 
            ... 
            executor.execute { 
                ... 
                handler?.post { 
                    var color = R.color.vermilion 
                    if (result) { 
                        color = R.color.green 
                    } 
                    indicator.setBackgroundColor
                    (ContextCompat.getColor( 
                        this@NoteActivity, 
                        color 
                    )) 
                 } 
               } 
            } 
        } }

构建你的应用程序并运行它。创建一个新的笔记。你会注意到,在输入标题和消息内容后,指示器的颜色变成了绿色。

我们将进行一些更改,并对Message类实例执行相同的操作。根据这个示例更新你的代码:

     class NoteActivity : ItemActivity() { 
      ... 
      override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        handler = object : Handler(Looper.getMainLooper()) { 
            override fun handleMessage(msg: Message?) { 
                msg?.let { 
                    var color = R.color.vermilion 
                    if (msg.arg1 > 0) { 
                        color = R.color.green 
                    } 
                    indicator.setBackgroundColor
                    (ContextCompat.getColor( 
                       this@NoteActivity, 
                       color 
                    )) 
                  } 
                 super.handleMessage(msg) 
               } 
             } 
            ... 
          } 
        ... 
        private val locationListener = object : LocationListener { 
        override fun onLocationChanged(p0: Location?) { 
            p0?.let { 
                ... 
                executor.execute { 
                    ... 
                    sendMessage(result) 
                } 
            } 
        } 

        override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?)
        {} 
        override fun onProviderEnabled(p0: String?) {} 
        override fun onProviderDisabled(p0: String?) {} 
      } 
      ... 
      private fun updateNote() { 
        if (note == null) { 
            ... 
        } else { 
            ... 
            executor.execute { 
                ... 
                sendMessage(result) 
            } 
        } 
      } 
     ... 
     private fun sendMessage(result: Boolean) { 
        val msg = handler?.obtainMessage() 
        if (result) { 
            msg?.arg1 = 1 
        } else { 
            msg?.arg1 = 0 
        } 
        handler?.sendMessage(msg) 
     } 
     ... 
    } 

注意处理程序的实例化和sendMessage()方法。我们使用Handler类的obtainMessage()方法获取了Message实例。作为消息参数,我们传递了一个整数数据类型。根据它的值,我们将更新指示器的颜色。

AsyncTask

你可能已经注意到,我们已经在我们的应用程序中使用了AsyncTask类。现在,我们将进一步运用它--我们将在执行器上运行它。为什么我们要这样做呢?

首先,默认情况下,所有的AsyncTasks都是按顺序在 Android 中执行的。要并行执行它,我们需要在执行器上执行它。

等等!现在,当我们并行执行任务时,想象一下你执行了一些任务。比如说我们从两个开始。这很好。它们将执行它们的操作并在完成时向我们报告。然后,想象一下我们同时运行了四个任务。它们也会工作,在大多数情况下,如果它们执行的操作不太繁重。然而,在某些时候,我们同时运行了五十个AsyncTasks

然后,你的应用程序变慢了!一切都会变慢,因为对任务的执行没有控制。我们必须管理任务,以保持性能。所以,让我们来做吧!我们将继续更新到目前为止更新的同一个类。按照以下方式更改你的NoteActivity

    class NoteActivity : ItemActivity() { 
      ... 
      private val threadPoolExecutor = ThreadPoolExecutor( 
            3, 3, 1, TimeUnit.SECONDS, LinkedBlockingQueue<Runnable>() 
    ) 

    private class TryAsync(val identifier: String) : AsyncTask<Unit,
    Int, Unit>() { 
        private val tag = "TryAsync" 

        override fun onPreExecute() { 
            Log.i(tag, "onPreExecute [ $identifier ]") 
            super.onPreExecute() 
      } 

      override fun doInBackground(vararg p0: Unit?): Unit { 
         Log.i(tag, "doInBackground [ $identifier ][ START ]") 
         Thread.sleep(5000) 
         Log.i(tag, "doInBackground [ $identifier ][ END ]") 
         return Unit 
       } 

       override fun onCancelled(result: Unit?) { 
         Log.i(tag, "onCancelled [ $identifier ][ END ]") 
         super.onCancelled(result) 
        } 

       override fun onProgressUpdate(vararg values: Int?) { 
         val progress = values.first() 
         progress?.let { 
           Log.i(tag, "onProgressUpdate [ $identifier ][ $progress ]") 
         } 
          super.onProgressUpdate(*values) 
        } 

        override fun onPostExecute(result: Unit?) { 
          Log.i(tag, "onPostExecute [ $identifier ]") 
          super.onPostExecute(result) 
        } 
      } 
      ... 
      private val textWatcher = object : TextWatcher { 
        override fun afterTextChanged(p0: Editable?) { 
            ... 
        } 

      override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2:
      Int, p3: Int) {} 

      override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int,
      p3: Int) { 
            p0?.let {  
                tryAsync(p0.toString())  
            } 
        } 
     } 
     ... 
     private fun tryAsync(identifier: String) { 
        val tryAsync = TryAsync(identifier) 
        tryAsync.executeOnExecutor(threadPoolExecutor) 
     } 
    } 

由于这实际上不是我们将在 Journaler 应用程序中保留的内容,请不要提交此代码。如果你愿意,可以将其创建为一个单独的分支。我们创建了一个ThreadPoolExecutor的新实例。构造函数接受几个参数,如下所示:

  • corePoolSize:这代表了池中保持的最小线程数。

  • maximumPoolSize:这代表了池中允许的最大线程数。

  • keepAliveTime:如果线程数大于核心数,非核心线程将等待新任务,如果在这个参数定义的时间内没有得到新任务,它们将终止。

  • Unit:这代表了keepAliveTime的时间单位。

  • WorkQueue:这代表了将用于保存任务的队列实例。

  • 我们将在这个执行器上运行我们的任务。AsyncTask具体化将记录其生命周期中的所有事件。在main方法中,我们将等待 5 秒。运行应用程序,尝试添加一个标题为Android的新笔记。观察你的 Logcat 输出:

08-04 14:56:59.283 21953-21953 ... I/TryAsync: onPreExecute [ A ] 
08-04 14:56:59.284 21953-23233 ... I/TryAsync: doInBackground [ A ][ START ] 
08-04 14:57:00.202 21953-21953 ... I/TryAsync: onPreExecute [ An ] 
08-04 14:57:00.204 21953-23250 ... I/TryAsync: doInBackground [ An ][ START ] 
08-04 14:57:00.783 21953-21953 ... I/TryAsync: onPreExecute [ And ] 
08-04 14:57:00.784 21953-23281 ... I/TryAsync: doInBackground [ And ][ START ] 
08-04 14:57:01.001 21953-21953 ... I/TryAsync: onPreExecute [ Andr ] 
08-04 14:57:01.669 21953-21953 ... I/TryAsync: onPreExecute [ Andro ] 
08-04 14:57:01.934 21953-21953 ... I/TryAsync: onPreExecute [ Androi ] 
08-04 14:57:02.314 21953-2195 ... I/TryAsync: onPreExecute [ Android ] 
08-04 14:57:04.285 21953-23233 ... I/TryAsync: doInBackground [ A ][ END ] 
08-04 14:57:04.286 21953-23233 ... I/TryAsync: doInBackground [ Andr ][ START ] 
08-04 14:57:04.286 21953-21953 ... I/TryAsync: onPostExecute [ A ] 
08-04 14:57:05.204 21953-23250 ... I/TryAsync: doInBackground [ An ][ END ] 
08-04 14:57:05.204 21953-21953 ... I/TryAsync: onPostExecute [ An ] 
08-04 14:57:05.205 21953-23250 ... I/TryAsync: doInBackground [ Andro ][ START ] 
08-04 14:57:05.784 21953-23281 ... I/TryAsync: doInBackground [ And ][ END ] 
08-04 14:57:05.785 21953-23281 ... I/TryAsync: doInBackground [ Androi ][ START ] 
08-04 14:57:05.786 21953-21953 ... I/TryAsync: onPostExecute [ And ] 
08-04 14:57:09.286 21953-23233 ... I/TryAsync: doInBackground [ Andr ][ END ] 
08-04 14:57:09.287 21953-21953 ... I/TryAsync: onPostExecute [ Andr ] 
08-04 14:57:09.287 21953-23233 ... I/TryAsync: doInBackground [ Android ][ START ] 
08-04 14:57:10.205 21953-23250 ... I/TryAsync: doInBackground [ Andro ][ END ] 
08-04 14:57:10.206 21953-21953 ... I/TryAsync: onPostExecute [ Andro ] 
08-04 14:57:10.786 21953-23281 ... I/TryAsync: doInBackground [ Androi ][ END ] 
08-04 14:57:10.787 21953-2195 ... I/TryAsync: onPostExecute [ Androi ] 
08-04 14:57:14.288 21953-23233 ... I/TryAsync: doInBackground [ Android ][ END ] 
08-04 14:57:14.290 21953-2195 ... I/TryAsync: onPostExecute [ Android ] 

让我们通过我们在任务中执行的方法来过滤日志。首先让我们看一下onPreExecute方法的过滤器:

08-04 14:56:59.283 21953-21953 ... I/TryAsync: onPreExecute [ A ] 
08-04 14:57:00.202 21953-21953 ... I/TryAsync: onPreExecute [ An ] 
08-04 14:57:00.783 21953-21953 ... I/TryAsync: onPreExecute [ And ] 
08-04 14:57:01.001 21953-21953 ... I/TryAsync: onPreExecute [ Andr ] 
08-04 14:57:01.669 21953-21953 ... I/TryAsync: onPreExecute [ Andro ] 
08-04 14:57:01.934 21953-21953 ... I/TryAsync: onPreExecute [ Androi ] 
08-04 14:57:02.314 21953-21953 ... I/TryAsync: onPreExecute [ Android ] 

对每个方法都做同样的事情,并关注方法执行的时间。为了给你的代码更多的挑战,将doInBackground()方法的实现改为做一些更严肃和密集的工作。然后,通过输入一个更长的标题来触发更多的任务,例如整个句子。过滤和分析你的日志。

理解 Android Looper

让我们解释一下Looper类。我们在之前的例子中用过它,但我们没有详细解释过它。

Looper代表了一个用于在队列中执行messagesrunnable实例的类。普通线程没有像Looper类那样的队列。

我们在哪里可以使用Looper类?对于执行多个messagesrunnable实例,需要Looper!一个使用的例子可以是在添加新任务到队列的同时,任务处理操作正在运行。

准备 Looper

要使用Looper类,我们必须首先调用prepare()方法。当Looper准备好后,我们可以使用loop()方法。这个方法用于在当前线程中创建一个message循环。我们将给你一个简短的例子:

    class LooperHandler : Handler() { 
      override fun handleMessage(message: Message) { 
            ... 
      } 
    } 

    class LooperThread : Thread() { 
      var handler: Handler? = null 

      override fun run() { 
         Looper.prepare() 
         handler = LooperHandler() 
         Looper.loop() 
      } 
    } 

在这个例子中,我们演示了编程Looper类的基本步骤。不要忘记prepare()你的Looper类,否则你会得到一个异常,你的应用程序可能会崩溃!

延迟执行

本章还有一件重要的事情要向你展示。我们将向你展示在 Android 中的延迟执行。我们将给你一些延迟操作应用到我们的 UI 的例子。打开你的ItemsFragment并做出以下更改:

     class ItemsFragment : BaseFragment() { 
      ... 
       override fun onResume() { 
         super.onResume() 
         ... 
         val items = view?.findViewById<ListView>(R.id.items) 
         items?.let { 
            items.postDelayed({ 
              if (!activity.isFinishing) { 
                items.setBackgroundColor(R.color.grey_text_middle) 
              } 
            }, 3000) 
         } 
      } 
       ... 
     } 

三秒后,如果我们不关闭这个屏幕,背景颜色将变成稍微深一点的灰色。运行你的应用程序,亲自看看。现在,让我们用另一种方式做同样的事情:

     class ItemsFragment : BaseFragment() { 
      ... 
      override fun onResume() { 
        super.onResume() 
        ... 
        val items = view?.findViewById<ListView>(R.id.items) 
        items?.let { 
            Handler().postDelayed({ 
                if (!activity.isFinishing) { 
                    items.setBackgroundColor(R.color.grey_text_middle) 
                } 
            }, 3000) 
         } 
        } 
       } 
       ...
     }

这一次,我们使用了Handler类来执行延迟修改。

总结

在本章中,我们向您介绍了 Android 并发性。我们为每个部分进行了解释,并为您提供了示例。在深入了解 Android 服务之前,这是一个很好的介绍。Android 服务是 Android 提供的最强大的并发特性,正如您将看到的,它可以被用作应用程序的大脑。

第十章:Android 服务

在上一章中,我们开始使用 Android 中的并发机制。我们取得了很大的进展。然而,我们对 Android 并发机制的旅程还没有结束。我们必须介绍 Android 框架中可能是最重要的部分--Android 服务。在本章中,我们将解释服务是什么,何时以及如何使用它们。

在本章中,我们将涵盖以下主题:

  • 服务分类

  • Android 服务的基础知识

  • 定义主要应用程序服务

  • 定义意图服务

服务分类

在我们定义 Android 服务分类并深入研究每种类型之前,我们必须回答 Android 服务到底是什么。嗯,Android 服务是 Android 框架提供的一种机制,通过它我们可以将长时间运行的任务移至后台。Android 服务提供了一些很好的附加功能,可以使开发人员的工作更加灵活和简单。为了解释它如何使我们的开发更容易,我们将通过扩展我们的 Journaler 应用程序来创建一个服务。

Android 服务是一个没有任何 UI 的应用程序组件。它可以被任何 Android 应用程序组件启动,并在需要时继续运行,即使我们离开我们的应用程序或杀死它。

Android 服务有三种主要类型:

  • 前台

  • 背景

  • 绑定

前台 Android 服务

前台服务执行的任务对最终用户是可见的。这些服务必须显示状态栏图标。即使没有与应用程序的交互,它们也会继续运行。

后台 Android 服务

与前台服务不同,后台服务执行的任务不会被最终用户注意到。例如,我们将与后端实例进行同步。用户不需要知道我们的进度。我们决定不去打扰用户。一切都将在我们应用程序的后台默默执行。

绑定 Android 服务

我们的应用程序组件可以绑定到一个服务并触发不同的任务来执行。在 Android 中与服务交互非常简单。只要有至少一个这样的组件,服务就会继续运行。当没有组件绑定到服务时,服务就会被销毁。

可以创建一个在后台运行并具有绑定能力的后台服务。

Android 服务基础知识

要定义 Android 服务,您必须扩展Service类。我们必须重写以下一些方法,以便服务能够正常运行:

  • onStartCommand(): 当startService()方法被某个 Android 组件触发时执行此方法。方法执行后,Android 服务就会启动并可以在后台无限期运行。要停止这个服务,必须执行stopService()方法,它与startService()方法的功能相反。

  • onBind(): 要从另一个 Android 组件绑定到服务,请使用bindService()方法。绑定后,将执行onBind()方法。在此方法的服务实现中,您必须提供一个接口,客户端通过返回一个Ibinder类实例与服务通信。实现此方法是不可选的,但如果您不打算绑定到服务,只需返回null即可。

  • onCreate(): 当服务被创建时执行此方法。如果服务已经在运行,则不会执行此方法。

  • onDestroy(): 当服务被销毁时执行此方法。重写此方法并在此处执行所有清理任务。

  • onUnbind(): 当我们从服务解绑时执行此方法。

声明您的服务

要声明您的服务,您需要将其类添加到 Android 清单中。以下代码片段解释了 Android 清单中服务定义应该是什么样子的:

    <manifest xmlns:android=
     "http://schemas.android.com/apk/res/android"   
      package="com.journaler"> 
      ... 
      <application ... > 
        <service 
          android:name=".service.MainService" 
          android:exported="false" /> 
          ... 

      </application> 
     </manifest>

正如你所看到的,我们定义了扩展Service类的MainService类,并且它位于service包下。导出标志设置为false,这意味着service将在与我们的应用程序相同的进程中运行。要在一个单独的进程中运行你的service,将这个标志设置为true

重要的是要注意,Service类不是你唯一可以扩展的类。IntentService类也是可用的。那么,当我们扩展它时,我们会得到什么?IntentService代表从Service类派生的类。IntentService使用工作线程逐个处理请求。我们必须实现onHandleIntent()方法来实现这个目的。当IntentService类被扩展时,它看起来像这样:

     public class MainIntentService extends IntentService { 
       /** 
       * A constructor is mandatory! 
       */ 
       public MainIntentService() { 
         super("MainIntentService"); 
       } 

       /** 
       * All important work is performed here. 
       */ 
       @Override 
       protected void onHandleIntent(Intent intent) { 
         // Your implementation for handling received intents. 

       } 
     } 

让我们回到扩展Service类并专注于它。我们将重写onStartCommand()方法,使其看起来像这样:

    override fun onStartCommand(intent: Intent?, flags: Int, startId:  
    Int): Int { 

      return Service.START_STICKY 
    }

那么,START_STICKY返回的结果是什么意思?如果我们的服务被系统杀死,或者我们杀死了服务所属的应用程序,它将重新启动。相反的是START_NOT_STICKY;在这种情况下,服务将不会被重新创建和重新启动。

启动服务

要启动服务,我们需要定义代表它的意图。这是服务可以启动的一个例子:

    val startServiceIntent = Intent(ctx, MainService::class.java) 
    ctx.startService(startServiceIntent) 

这里,ctx代表 Android Context类的任何有效实例。

停止服务

要停止服务,执行 Android Context类的stopService()方法,就像这样:

     val stopServiceIntent = Intent(ctx, MainService::class.java)
     ctx.stopService(startServiceIntent) 

绑定到 Android 服务

绑定服务是允许 Android 组件绑定到它的服务。要执行绑定,我们必须调用bindService()方法。当你想要从活动或其他 Android 组件与服务交互时,服务绑定是必要的。为了使绑定工作,你必须实现onBind()方法并返回一个IBinder实例。如果没有人感兴趣了,所有人都解绑了,Android 就会销毁服务。对于这种类型的服务,你不需要执行停止例程。

停止服务

我们已经提到stopService将停止我们的服务。无论如何,我们可以通过在我们的服务实现中调用stopSelf()来实现相同的效果。

服务生命周期

我们涵盖并解释了在 Android 服务的生命周期中执行的所有重要方法。服务像所有其他 Android 组件一样有自己的生命周期。到目前为止我们提到的一切都在下面的截图中表示出来:

现在,我们对 Android 服务有了基本的了解,我们将创建我们自己的服务并扩展 Journaler 应用程序。这个服务将在后面的章节中被重复扩展更多的代码。所以,请注意每一行,因为它可能是至关重要的。

定义主应用程序服务

正如你已经知道的,我们的应用程序处理笔记和待办事项。当前的应用程序实现将我们的数据保存在 SQLite 数据库中。这些数据将与运行在某个远程服务器上的后端实例进行同步。所有与同步相关的操作将在我们应用程序的后台默默执行。所有的责任将交给我们将要定义的服务。创建一个名为service的新包和一个名为MainService的新类,它将扩展 Android service类。确保你的实现看起来像这样:

    class MainService : Service(), DataSynchronization { 

      private val tag = "Main service" 
      private var binder = getServiceBinder() 
      private var executor = TaskExecutor.getInstance(1) 

      override fun onCreate() { 
        super.onCreate() 
        Log.v(tag, "[ ON CREATE ]") 
      } 

      override fun onStartCommand(intent: Intent?, flags: Int, startId:
      Int): Int { 
        Log.v(tag, "[ ON START COMMAND ]") 
        synchronize() 
        return Service.START_STICKY 
      } 

      override fun onBind(p0: Intent?): IBinder { 
        Log.v(tag, "[ ON BIND ]") 
        return binder 
      } 

      override fun onUnbind(intent: Intent?): Boolean { 
        val result = super.onUnbind(intent) 
        Log.v(tag, "[ ON UNBIND ]") 
        return result 
      } 

      override fun onDestroy() { 
        synchronize() 
        super.onDestroy() 
        Log.v(tag, "[ ON DESTROY ]") 
      } 

      override fun onLowMemory() { 
        super.onLowMemory() 
        Log.w(tag, "[ ON LOW MEMORY ]") 
      } 

      override fun synchronize() { 
        executor.execute { 
            Log.i(tag, "Synchronizing data [ START ]") 
            // For now we will only simulate this operation! 
            Thread.sleep(3000) 
            Log.i(tag, "Synchronizing data [ END ]") 
        } 
      } 

      private fun getServiceBinder(): MainServiceBinder = 
      MainServiceBinder() 

      inner class MainServiceBinder : Binder() { 
        fun getService(): MainService = this@MainService 
      } 
    }

让我们解释一下我们的主要服务。正如你们已经知道的,我们将扩展 Android 的Service类以获得所有的服务功能。我们还实现了DataSynchronization接口,它将描述我们服务的主要功能,即同步。请参考以下代码:

    package com.journaler.service 
    interface DataSynchronization { 

     fun synchronize() 
    }

所以,我们定义了synchronize()方法的实现,它实际上将模拟真正的同步。稍后,我们将更新这段代码以执行真正的后端通信。

所有重要的生命周期方法都被重写。注意bind()方法!此方法将通过调用getServiceBinder()方法返回一个由MainServiceBinder类生成的绑定器实例。由于MainServiceBinder类,我们将向最终用户公开我们的service实例,最终用户将能够在需要时触发同步机制。

同步不仅仅是由最终用户触发的,还会被服务自动触发。当服务启动和销毁时,我们会触发同步。

对我们来说,MainService的启动和停止是下一个重要的点。打开代表您的应用程序的Journaler类,并应用此更新:

     class Journaler : Application() { 

       companion object { 
         val tag = "Journaler" 
         var ctx: Context? = null 
       } 

       override fun onCreate() { 
         super.onCreate() 
         ctx = applicationContext 
         Log.v(tag, "[ ON CREATE ]") 
         startService() 
       } 

       override fun onLowMemory() { 
         super.onLowMemory() 
         Log.w(tag, "[ ON LOW MEMORY ]") 
         // If we get low on memory we will stop service if running. 
         stopService() 
       } 

       override fun onTrimMemory(level: Int) { 
         super.onTrimMemory(level) 
         Log.d(tag, "[ ON TRIM MEMORY ]: $level") 
       } 

       private fun startService() { 
         val serviceIntent = Intent(this, MainService::class.java) 
         startService(serviceIntent) 
       } 

       private fun stopService() { 
        val serviceIntent = Intent(this, MainService::class.java) 
        stopService(serviceIntent) 
       } 

     } 

当 Journaler 应用程序被创建时,MainService将被启动。我们还将添加一个小的优化。如果我们的应用程序内存不足,我们将停止我们的MainService类。由于服务是粘性启动的,如果我们明确杀死我们的应用程序,服务将重新启动。

到目前为止,我们已经涵盖了服务的启动和停止以及其实现。您可能还记得我们的模拟,在我们的应用程序抽屉底部,我们计划放置一个额外的项目。我们计划放置同步按钮。触发此按钮将与后端进行同步。

我们将添加该菜单项并将其与我们的服务连接起来。首先让我们做一些准备工作。打开NavigationDrawerItem类并按以下方式更新它:

    data class NavigationDrawerItem( 
      val title: String, 
      val onClick: Runnable, 
      var enabled: Boolean = true 
    ) 

我们引入了enabled参数。这样,如果需要,我们的应用程序抽屉中的一些项目可以被禁用。我们的同步按钮将默认禁用,并在绑定到main服务时启用。这些更改也必须影响NavigationDrawerAdapter。请参考以下代码:

    class NavigationDrawerAdapter( 
      val ctx: Context, 
      val items: List<NavigationDrawerItem> 
      ) : BaseAdapter() { 

        private val tag = "Nav. drw. adptr." 

        override fun getView(position: Int, v: View?, group: 
        ViewGroup?): View { 
          ... 
          val item = items[position] 
          val title = view.findViewById<Button>(R.id.drawer_item) 
          ... 
          title.setOnClickListener { 
            if (item.enabled) { 
                item.onClick.run() 
            } else { 
                Log.w(tag, "Item is disabled: $item") 
            } 
          } 

          return view 
       } 
        ... 
    }

最后,我们将更新我们的MainActivity类如下,以便同步按钮可以触发同步:

    class MainActivity : BaseActivity() { 
      ... 
      private var service: MainService? = null 

      private val synchronize: NavigationDrawerItem by lazy { 
        NavigationDrawerItem( 
          getString(R.string.synchronize), 
          Runnable { service?.synchronize() }, 
          false 
        ) 
     } 

     private val serviceConnection = object : ServiceConnection { 
        override fun onServiceDisconnected(p0: ComponentName?) { 
            service = null 
            synchronize.enabled = false 
        } 

        override fun onServiceConnected(p0: ComponentName?, binder: 
        IBinder?) { 
          if (binder is MainService.MainServiceBinder) { 
            service = binder.getService() 
            service?.let { 
              synchronize.enabled = true 
            } 
           } 
        } 
     } 

      override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        ... 
        val menuItems = mutableListOf<NavigationDrawerItem>() 
        ... 
        menuItems.add(synchronize) 
        ... 
      } 

      override fun onResume() { 
        super.onResume() 
        val intent = Intent(this, MainService::class.java) 
        bindService(intent, serviceConnection, 
        android.content.Context.BIND_AUTO_CREATE) 
     } 

     override fun onPause() { 
        super.onPause() 
        unbindService(serviceConnection) 
     } 

     ... 
    } 

我们将根据我们的主活动状态是否活动来绑定或解绑main服务。为了执行绑定,我们需要ServiceConnection实现,因为它将根据绑定状态启用或禁用同步按钮。此外,我们将根据绑定状态维护main服务实例。同步按钮将访问service实例,并在点击时触发synchronize()方法。

定义intent服务

我们的main服务正在运行并且责任已定义。现在,我们将通过引入另一个服务来对我们的应用程序进行更多改进。这一次,我们将定义intent服务。intent服务将接管数据库 CRUD 操作的执行责任。基本上,我们将定义我们的intent服务并对我们已有的代码进行重构。

首先,我们将在service包内创建一个名为DatabaseService的新类。在我们放置整个实现之前,我们将在 Android 清单中注册它如下:

    <manifest xmlns:android=
      "http://schemas.android.com/apk/res/android" 
       package="com.journaler"> 
       ... 
      <application ... > 
      <service 
        android:name=".service.MainService" 
        android:exported="false" /> 

      <service 
        android:name=".service.DatabaseService" 
        android:exported="false" /> 
        ... 
      </application> 
    </manifest> 

    Define DatabaseService like this: 
    class DatabaseService :
     IntentService("DatabaseService") { 

       companion object { 
         val EXTRA_ENTRY = "entry" 
         val EXTRA_OPERATION = "operation" 
       } 

       private val tag = "Database service" 

       override fun onCreate() { 
         super.onCreate() 
         Log.v(tag, "[ ON CREATE ]") 
       } 

       override fun onLowMemory() { 
         super.onLowMemory() 
         Log.w(tag, "[ ON LOW MEMORY ]") 
       } 

       override fun onDestroy() { 
         super.onDestroy() 
         Log.v(tag, "[ ON DESTROY ]") 
       } 

       override fun onHandleIntent(p0: Intent?) { 
         p0?.let { 
            val note = p0.getParcelableExtra<Note>(EXTRA_ENTRY) 
            note?.let { 
               val operation = p0.getIntExtra(EXTRA_OPERATION, -1) 
               when (operation) { 
                 MODE.CREATE.mode -> { 
                   val result = Db.insert(note) 
                   if (result) { 
                      Log.i(tag, "Note inserted.") 
                   } else { 
                      Log.e(tag, "Note not inserted.") 
                      } 
                   } 
                   MODE.EDIT.mode -> { 
                     val result = Db.update(note) 
                     if (result) { 
                       Log.i(tag, "Note updated.") 
                     } else { 
                       Log.e(tag, "Note not updated.") 
                      } 
                    } 
                    else -> { 
                        Log.w(tag, "Unknown mode [ $operation ]") 
                    } 

                  } 

                } 

             } 

         } 

     } 

服务将接收意图,获取操作,并从中获取实例。根据操作,将触发适当的 CRUD 操作。为了将Note实例传递给Intent,我们必须实现Parcelable,以便数据传递效率高。例如,与Serializable相比,Parcelable要快得多。为此目的,代码已经进行了大量优化。我们将执行显式序列化,而不使用反射。打开您的Note类并按以下方式更新它:

    package com.journaler.model 
    import android.location.Location 
    import android.os.Parcel 
    import android.os.Parcelable 

    class Note( 
      title: String, 
      message: String, 
      location: Location 
    ) : Entry( 
      title, 
      message, 
      location 
    ), Parcelable { 

      override var id = 0L 

      constructor(parcel: Parcel) : this( 
        parcel.readString(), 
        parcel.readString(), 
        parcel.readParcelable(Location::class.java.classLoader) 
      ) { 
         id = parcel.readLong() 
        } 

       override fun writeToParcel(parcel: Parcel, flags: Int) { 
         parcel.writeString(title) 
         parcel.writeString(message) 
         parcel.writeParcelable(location, 0) 
         parcel.writeLong(id) 
       } 

       override fun describeContents(): Int { 
         return 0 
       } 

       companion object CREATOR : Parcelable.Creator<Note> { 
         override fun createFromParcel(parcel: Parcel): Note { 
            return Note(parcel) 
        } 

         override fun newArray(size: Int): Array<Note?> { 
            return arrayOfNulls(size) 
        } 
      } 

    } 

当通过intent传递到DatabaseService时,Note类将被高效地序列化和反序列化。

最后一块拼图是更改当前执行 CRUD 操作的代码。我们将创建intent并将其发送,以便我们的服务为我们处理其余工作。打开NoteActivity类并按以下方式更新代码:

    class NoteActivity : ItemActivity() { 
      ... 
      private val locationListener = object : LocationListener { 
        override fun onLocationChanged(p0: Location?) { 
          p0?.let { 
            LocationProvider.unsubscribe(this) 
            location = p0 
            val title = getNoteTitle() 
            val content = getNoteContent() 
            note = Note(title, content, p0) 

            // Switching to intent service. 
            val dbIntent = Intent(this@NoteActivity, 
            DatabaseService::class.java) 
            dbIntent.putExtra(DatabaseService.EXTRA_ENTRY, note) 
            dbIntent.putExtra(DatabaseService.EXTRA_OPERATION, 
            MODE.CREATE.mode) 
            startService(dbIntent) 
            sendMessage(true) 
          } 
      } 

     override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?) {} 
     override fun onProviderEnabled(p0: String?) {} 
     override fun onProviderDisabled(p0: String?) {} 
   } 
    ... 
    private fun updateNote() { 
      if (note == null) { 
        if (!TextUtils.isEmpty(getNoteTitle()) && 
        !TextUtils.isEmpty(getNoteContent())) { 
           LocationProvider.subscribe(locationListener) 
        } 
        } else { 
            note?.title = getNoteTitle() 
            note?.message = getNoteContent() 

            // Switching to intent service. 
            val dbIntent = Intent(this@NoteActivity, 
            DatabaseService::class.java) 
            dbIntent.putExtra(DatabaseService.EXTRA_ENTRY, note) 
            dbIntent.putExtra(DatabaseService.EXTRA_OPERATION,
            MODE.EDIT.mode) 
            startService(dbIntent) 
            sendMessage(true) 
        } 
      } 
      ... 
    } 

正如你所看到的,改变真的很简单。构建你的应用程序并运行它。当你创建或更新你的Note类时,你会注意到我们执行的数据库操作的日志。此外,你还会注意到DatabaseService的生命周期方法被记录下来。

总结

恭喜!你掌握了 Android 服务并显著改进了应用程序!在本章中,我们解释了什么是 Android 服务。我们还解释了每种类型的 Android 服务,并举例说明了它们的用途。现在,当你完成这些实现时,我们鼓励你至少考虑一个可以接管应用程序的某个现有部分或引入全新内容的服务。玩转这些服务,并尝试思考它们能给你带来的好处。

第十一章:消息

在本章中,我们将使用 Android 广播,并将其用作接收和发送消息的机制。我们将分步理解它。首先,我们将解释底层机制以及如何使用 Android 广播消息。然后,我们将监听一些最常见的消息。因为仅仅监听是不够的,我们将创建新的消息并进行广播。最后,我们将了解启动、关闭和网络广播消息,以便我们的应用程序意识到这一重要的系统事件。

在本章中,我们将涵盖以下主题:

  • Android 广播

  • 监听广播

  • 创建广播

  • 监听网络事件

理解 Android 广播

Android 应用程序可以发送或接收消息。消息可以是系统相关事件,也可以是我们定义的自定义事件。感兴趣的各方通过定义适当的意图过滤器和广播接收器来注册特定的消息。当广播消息时,所有感兴趣的各方都会收到通知。重要的是要注意,一旦你订阅了广播消息(特别是从Activity类),你必须在某个时候取消订阅。我们什么时候可以使用广播消息?我们在应用程序需要跨应用程序的消息系统时使用广播消息。例如,想象一下你在后台启动了一个长时间运行的进程。在某个时候,你想通知多个上下文处理结果。广播消息是这个问题的完美解决方案。

系统广播

系统广播是在发生各种系统事件时由 Android 系统发送的广播。我们发送和最终接收的每条消息都包装在包含有关特定事件信息的Intent类中。每个Intent必须设置适当的操作。例如--android.intent.action.ACTION_POWER_CONNECTED。有关事件的信息用捆绑的额外数据表示。例如,我们可能捆绑了一个额外的字符串字段,表示与我们感兴趣的事件相关的特定数据。让我们考虑一个充电和电池信息的例子。每当电池状态发生变化时,感兴趣的各方将收到通知,并接收包含电池电量信息的广播消息:

    val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) 
    val batteryStatus = registerReceiver(null, intentFilter) 

    val status = batteryStatus.getIntExtra(BatteryManager.
      EXTRA_STATUS, -1) 

    val isCharging = 
                status == BatteryManager.BATTERY_STATUS_CHARGING || 
                status == BatteryManager.BATTERY_STATUS_FULL 

    val chargePlug =    batteryStatus.getIntExtra(BatteryManager.
      EXTRA_PLUGGED, -1) 
    val usbCharge = chargePlug == BatteryManager.
      BATTERY_PLUGGED_USB 
    val acCharge = chargePlug == BatteryManager.BATTERY_PLUGGED_AC

在这个例子中,我们注册了电池信息的意图过滤器。然而,我们没有传递广播接收器的实例。为什么?因为电池数据是粘性的。粘性意图是在广播执行后一段时间内保持存在的意图。注册到这些数据将立即返回包含最新数据的意图。我们也可以传递广播接收器的实例。让我们来做一下:

    val receiver = object : BroadcastReceiver() { 
      override fun onReceive(p0: Context?, batteryStatus: Intent?) { 
      val status = batteryStatus?.getIntExtra
      (BatteryManager.EXTRA_STATUS, -1) 
                 val isCharging = 
                        status == 
                        BatteryManager.BATTERY_STATUS_CHARGING || 
                        status == BatteryManager.BATTERY_STATUS_FULL 
                        val chargePlug = batteryStatus?.getIntExtra
                       (BatteryManager.EXTRA_PLUGGED, -1) 
                        val usbCharge = chargePlug ==
                       BatteryManager.BATTERY_PLUGGED_USB 
                       val acCharge = chargePlug == 
                       BatteryManager.BATTERY_PLUGGED_AC 
        } 
    } 

    val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) 
    registerReceiver(receiver, intentFilter)

每当电池信息发生变化时,接收器将执行我们在其实现中定义的代码;我们也可以在 Android 清单中定义我们的接收器:

    <receiver android:name=".OurPowerReceiver"> 
      <intent-filter> 
        <action android:name="android.intent.action.
        ACTION_POWER_CONNECTED"/> 
        <action android:name="android.intent.action.
        ACTION_POWER_DISCONNECTED"/> 
      </intent-filter> 
    </receiver>

监听广播

如前面的例子所示,我们可以以以下两种方式之一接收广播:

  • 通过 Android 清单注册广播接收器

  • 使用registerBroadcast()方法在上下文中注册广播

通过清单声明需要以下内容:

  • 带有android:nameandroid:exported参数的<receiver>元素。

  • 接收器必须包含我们订阅的操作的intent过滤器。看看下面的例子:

        <receiver android:name=".OurBootReceiver"
          android:exported="true"> 
          <intent-filter> 
            <action android:name=
            "android.intent.action.BOOT_COMPLETED"/> 
            ... 
            <action android:name="..."/> 
            <action android:name="..."/> 
            <action android:name="..."/> 

         </intent-filter> 

       </receiver> 

如你所见,name属性代表我们广播接收器类的名称。导出意味着应用程序可以或不能接收来自接收器应用程序外部的消息。

如果你子类化BroadcastReceiver,它应该像这个例子一样:

     val receiver = object : BroadcastReceiver() { 
        override fun onReceive(ctx: Context?, intent: Intent?) { 
            // Handle your received code. 

        } 
    }

注意,在onReceive()方法实现中执行的操作不应该花费太多时间。否则会发生 ANR!

从上下文注册

现在我们将向您展示一个从 Android 上下文注册广播接收器的示例。要注册接收器,你需要它的一个实例。假设我们的实例是myReceiver

    val myReceiver = object : BroadcastReceiver(){ 

      ... 

     }We need intent filter prepared: 
     val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
     registerReceiver(myReceiver, filter)

这个例子将注册一个接收者,用于监听连接信息。由于此接收者是从上下文中注册的,只要我们注册的上下文有效,它就是有效的。您还可以使用LocalBroadcastManager类。LocalBroadcastManager的目的是注册并向您进程中的本地对象发送意图的广播。这是例子:

    LocalBroadcastManager 
      .getInstance(applicationContext) 
      .registerReceiver(myReceiver, intentFilter)

要取消注册,请执行以下代码片段:

    LocalBroadcastManager 
      .getInstance(applicationContext) 
      .unregisterReceiver(myReceiver)

对于上下文订阅的接收者,重点要注意取消注册。例如,如果我们在活动的onCreate()方法中注册接收者,我们必须在onDestroy()方法中取消注册。如果我们不这样做,就会出现接收者泄漏!同样,如果我们在活动的onResume()中注册,我们必须在onPause()中取消注册。如果我们不这样做,就会多次注册!

接收者执行

我们在onReceive()实现中执行的代码被视为前台进程。广播接收器在我们从该方法返回之前是活动的。系统将始终运行您在实现中定义的代码,除非发生极端的内存压力。正如我们提到的,您应该只执行短操作!否则,可能会发生 ANR!当接收到消息时执行长时间运行的操作的一个很好的例子是启动AsyncTask并在那里执行所有工作。接下来,我们将向您展示一个演示这一点的例子:

     class AsyncReceiver : BroadcastReceiver() { 
       override fun onReceive(p0: Context?, p1: Intent?) { 
         val pending = goAsync() 
         val async = object : AsyncTask<Unit, Unit, Unit>() { 
           override fun doInBackground(vararg p0: Unit?) { 
             // Do some intensive work here... 
             pending.finish() 
           } 
       } 
       async.execute() 
      }
    } 

在这个例子中,我们介绍了goAsync()方法的使用。它是做什么的?该方法返回一个PendingResult类型的对象,表示调用API方法的待定结果。Android 系统认为接收者在调用此实例的finish()方法之前是活动的。使用这种机制,可以在广播接收器中进行异步处理。在完成繁重的工作后,我们调用finish()来指示 Android 系统可以回收此组件。

发送广播

Android 有以下三种发送广播消息的方式:

  • 使用sendOrderedBroadcast(Intent, String)方法一次向一个接收者发送消息。由于接收者按顺序执行,可以将结果传播给下一个接收者。此外,还可以中止广播,使其不会传递给其他接收者。我们可以控制接收者执行的顺序。我们可以使用匹配意图过滤器的android:priority属性来设置优先级。

  • 使用sendBroadcast(Intent)方法向所有接收者发送广播消息。发送是无序的。

  • 使用LocalBroadcastManager.sendBroadcast(Intent)方法向与发送方相同应用程序中的接收者发送广播。

让我们看一个向所有感兴趣的方发送广播消息的例子:

    val intent = Intent() 
    intent.action = "com.journaler.broadcast.TODO_CREATED" 
    intent.putExtra("title", "Go, buy some lunch.") 
    intent.putExtra("message", "For lunch we have chicken.")
    sendBroadcast(intent)

我们创建了一个包含有关我们创建的note(标题和消息)的额外数据的广播消息。所有感兴趣的方都需要一个适当的IntentFilter实例来执行操作:

    com.journaler.broadcast.TODO_CREATED

不要混淆启动活动和发送广播消息。Intent类只是用作我们信息的包装器。这两个操作完全不同!您可以使用本地广播机制来实现相同的功能:

    val ctx = ... 
    val broadcastManager = LocalBroadcastManager.getInstance(ctx) 
    val intent = Intent() 
    intent.action = "com.journaler.broadcast.TODO_CREATED" 
    intent.putExtra("title", "Go, buy some lunch.") 
    intent.putExtra("message", "For lunch we have chicken.") 
    broadcastManager.sendBroadcast(intent)

现在,当我们向您展示了广播消息的最重要方面后,我们将继续扩展我们的应用程序。Journaler 将发送和接收包含数据的自定义广播消息,并与系统广播进行交互,例如系统启动、关闭和网络。

创建自己的广播消息

正如你可能记得的,我们对NoteActivity类进行了代码重构。让我们展示我们在重要部分的最后状态,以便进行进一步的演示:

     class NoteActivity : ItemActivity() { 
       ... 
       private val locationListener = object : LocationListener { 
         override fun onLocationChanged(p0: Location?) { 
           p0?.let { 
                LocationProvider.unsubscribe(this) 
                location = p0 
                val title = getNoteTitle() 
                val content = getNoteContent() 
                note = Note(title, content, p0) 

                // Switching to intent service. 
                val dbIntent = Intent(this@NoteActivity,
                DatabaseService::class.java) 
                dbIntent.putExtra(DatabaseService.EXTRA_ENTRY, note) 
                dbIntent.putExtra(DatabaseService.EXTRA_OPERATION,
                MODE.CREATE.mode) 
                startService(dbIntent) 
                sendMessage(true) 
            } 
        } 

        override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?)
        {} 
        override fun onProviderEnabled(p0: String?) {} 
        override fun onProviderDisabled(p0: String?) {} 
      } 
      ... 
      private fun updateNote() { 
        if (note == null) { 
            if (!TextUtils.isEmpty(getNoteTitle()) &&
             !TextUtils.isEmpty(getNoteContent())) { 
               LocationProvider.subscribe(locationListener) 
            } 
        } else { 
            note?.title = getNoteTitle() 
            note?.message = getNoteContent() 

            // Switching to intent service. 
            val dbIntent = Intent(this@NoteActivity,
            DatabaseService::class.java) 
            dbIntent.putExtra(DatabaseService.EXTRA_ENTRY, note) 
            dbIntent.putExtra(DatabaseService.EXTRA_OPERATION,
            MODE.EDIT.mode) 
            startService(dbIntent) 
            sendMessage(true) 
        } 
      } 
      ... 
    }

如果您再次查看这个,您会注意到我们在执行时向我们的服务发送了intent,但由于我们没有得到返回值,我们只是用布尔值true作为其参数执行了sendMessage()方法。在这里,我们期望一个代表 CRUD 操作结果的值,即成功或失败。我们将使用广播消息将我们的服务与NoteActivity连接起来。每次插入或更新note广播时,都会触发一个消息。我们在NoteActivity中定义的监听器将对此消息做出响应并触发sendMessage()方法。让我们更新我们的代码!打开Crud接口并使用包含操作和 CRUD 操作结果常量的companion对象进行扩展:

    interface Crud<T> { 
      companion object { 
        val BROADCAST_ACTION = "com.journaler.broadcast.crud" 
        val BROADCAST_EXTRAS_KEY_CRUD_OPERATION_RESULT = "crud_result" 
      } 
       ... 
    }

现在,打开DatabaseService并扩展它,负责在执行 CRUD 操作时发送广播消息的方法:

     class DatabaseService : IntentService("DatabaseService") { 
       ... 
       override fun onHandleIntent(p0: Intent?) { 
          p0?.let { 
            val note = p0.getParcelableExtra<Note>(EXTRA_ENTRY) 
            note?.let { 
                val operation = p0.getIntExtra(EXTRA_OPERATION, -1) 
                when (operation) { 
                    MODE.CREATE.mode -> { 
                        val result = Db.insert(note) 
                        if (result) { 
                            Log.i(tag, "Note inserted.") 
                        } else { 
                            Log.e(tag, "Note not inserted.") 
                        } 
                        broadcastResult(result) 
                    } 
                    MODE.EDIT.mode -> { 
                        val result = Db.update(note) 
                        if (result) { 
                            Log.i(tag, "Note updated.") 
                        } else { 
                            Log.e(tag, "Note not updated.") 
                        } 
                        broadcastResult(result) 
                    } 
                    else -> { 
                        Log.w(tag, "Unknown mode [ $operation ]") 
                    } 
                 } 
             } 
           } 
        } 
        ... 
        private fun broadcastResult(result: Boolean) { 
          val intent = Intent() 
          intent.putExtra( 
                Crud.BROADCAST_EXTRAS_KEY_CRUD_OPERATION_RESULT, 
                if (result) { 
                    1 
                } else { 
                    0 
                } 
          ) 
        } }

我们引入了一个新方法。其他一切都一样。我们将获取 CRUD 操作结果并将其作为消息广播。NoteActivity将监听它:

   class NoteActivity : ItemActivity() { 
     ... 
     private val crudOperationListener = object : BroadcastReceiver() { 
        override fun onReceive(ctx: Context?, intent: Intent?) { 
            intent?.let { 
                val crudResultValue =
                intent.getIntExtra(MODE.EXTRAS_KEY, 0) 
                sendMessage(crudResultValue == 1) 
            } 
        } 
      } 
      ... 
      override fun onCreate(savedInstanceState: Bundle?) { 
        .... 
        registerReceiver(crudOperationListener, intentFiler) 
      } 

      override fun onDestroy() { 
        unregisterReceiver(crudOperationListener) 
        super.onDestroy() 
      } 
      ... 
      private fun sendMessage(result: Boolean) { 
         Log.v(tag, "Crud operation result [ $result ]") 
         val msg = handler?.obtainMessage() 
         if (result) { 
            msg?.arg1 = 1 
         } else { 
            msg?.arg1 = 0 
         } 
         handler?.sendMessage(msg) 
     } }

这很简单!我们重新连接了原始的sendMessage()方法与 CRUD 操作结果。在接下来的章节中,我们将考虑应用程序可以通过监听启动、关机和网络广播消息来进行一些重大改进。

使用启动和关闭广播

有时,服务在应用程序启动时运行至关重要。有时,在终止之前进行一些清理工作也很重要。在下面的示例中,我们将扩展 Journaler 应用程序以监听这些广播消息并进行一些工作。我们要做的第一件事是创建两个扩展BroadcastReceiver类的类:

  • BootReceiver:这是处理系统启动事件的

  • ShutdownReceiver:这是处理系统关机事件的

manifest文件中注册它们如下:

    <manifest  
     ... 
    > 
    ... 
    <receiver 
        android:name=".receiver.BootReceiver" 
        android:enabled="true" 
        android:exported="false"> 
        <intent-filter> 
          <action android:name=
           "android.intent.action.BOOT_COMPLETED" /> 
        </intent-filter> 

        <intent-filter> 
          <action android:name=
          "android.intent.action.PACKAGE_REPLACED" /> 
          data android:scheme="package" /> 
        </intent-filter> 

        <intent-filter> 
          <action android:name=
          "android.intent.action.PACKAGE_ADDED" /> 
          <data android:scheme="package" /> 
        </intent-filter> 
     </receiver> 

    <receiver android:name=".receiver.ShutdownReceiver"> 

      <intent-filter> 
        <action android:name=
        "android.intent.action.ACTION_SHUTDOWN" /> 
        <action android:name=
        "android.intent.action.QUICKBOOT_POWEROFF" /> 
      </intent-filter> 
    </receiver> 
     ...
    </manifest> 

BootReceiver类将在启动或替换应用程序时触发。关闭将在关闭设备时触发。让我们创建适当的实现。打开BootReceiver类并定义如下:

     package com.journaler.receiver 

     import android.content.BroadcastReceiver 
     import android.content.Context 
     import android.content.Intent 
     import android.util.Log 

     class BootReceiver : BroadcastReceiver() { 

       val tag = "Boot receiver" 

       override fun onReceive(p0: Context?, p1: Intent?) { 
         Log.i(tag, "Boot completed.") 
         // Perform your on boot stuff here. 
       } 

     } 

如您所见,我们为这两个类定义了receiver包。对于ShutdownReceiver,请像这样定义类:

    package com.journaler.receiver 

    import android.content.BroadcastReceiver 
    import android.content.Context 
    import android.content.Intent 
    import android.util.Log 

    class ShutdownReceiver : BroadcastReceiver() { 

      val tag = "Shutdown receiver" 

      override fun onReceive(p0: Context?, p1: Intent?) { 
        Log.i(tag, "Shutting down.") 
        // Perform your on cleanup stuff here.   
      } }

为了使其工作,我们需要进行一次更改;否则,应用程序将崩溃。从Application类中启动main服务移动到主活动onCreate()方法中。这是Journaler类的第一个更新:

    class Journaler : Application() { 
      ... 
      override fun onCreate() { // We removed start service method
        execution. 
        super.onCreate() 
        ctx = applicationContext 
        Log.v(tag, "[ ON CREATE ]") 
     } 
     // We removed startService() method implementation. 
     ... 
    }

然后通过在onCreate()方法的末尾添加行来扩展MainActivity类:

    class MainActivity : BaseActivity() { 
      ... 
      override fun onCreate(savedInstanceState: Bundle?) { 
        ... 
        val serviceIntent = Intent(this, MainService::class.java) 
        startService(serviceIntent) 
     } 
    ... } }

构建并运行您的应用程序。首先关闭手机,然后再次开机。过滤您的 Logcat,使其仅显示应用程序的日志。您应该有以下输出:

... I/Shutdown receiver: Shutting down. 
... I/Boot receiver: Boot completed.

请记住,有时需要超过两分钟才能接收到启动事件!

监听网络事件

我们想要的最后一个改进是在建立连接时使我们的应用程序能够执行同步。在相同的NetworkReceiver包中创建一个名为的新类。确保您有以下实现:

    class NetworkReceiver : BroadcastReceiver() {
      private val tag = "Network receiver"
      private var service: MainService? = null

      private val serviceConnection = object : ServiceConnection { 
        override fun onServiceDisconnected(p0: ComponentName?) { 
          service = null 
        } 

        override fun onServiceConnected(p0: ComponentName?, binder:
        IBinder?) { 
            if (binder is MainService.MainServiceBinder) { 
                service = binder.getService() 
                service?.synchronize() 
            } 
        } 
       } 

       override fun onReceive(context: Context?, p1: Intent?) { 
       context?.let { 

            val cm = context.getSystemService
           (Context.CONNECTIVITY_SERVICE) as ConnectivityManager 

            val activeNetwork = cm.activeNetworkInfo 
            val isConnected = activeNetwork != null &&
            activeNetwork.isConnectedOrConnecting 
            if (isConnected) { 
                Log.v(tag, "Connectivity [ AVAILABLE ]") 
                if (service == null) { 
                    val intent = Intent(context,
                    MainService::class.java) 
                    context.bindService( 
                        intent, serviceConnection,
                        android.content.Context.BIND_AUTO_CREATE 
                    ) 
                } else { 
                    service?.synchronize() 
                } 
            } else { 
                Log.w(tag, "Connectivity [ UNAVAILABLE ]") 
                context.unbindService(serviceConnection) 
            } 
          } 
        } 
    }

当发生连接事件时,此接收器将接收消息。每当我们有上下文和连接时,我们将绑定到服务并触发同步。不要担心频繁的同步触发,因为在接下来的章节中,我们将在同步方法的实现中保护自己免受它的影响。通过更新 Journaler 应用程序类来注册您的监听器如下:

     class Journaler : Application() { 
       ... 
       override fun onCreate() { 
          super.onCreate() 
          ctx = applicationContext 
          Log.v(tag, "[ ON CREATE ]") 
          val filter =  
          IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) 
          registerReceiver(networkReceiver, filter) 
      } 
      ... 
    } 

构建并运行您的应用程序。关闭您的连接(Wi-Fi、移动数据)然后再次打开。观察以下 Logcat 输出:

... V/Network receiver: Connectivity [ AVAILABLE ] 
... V/Network receiver: Connectivity [ AVAILABLE ] 
... V/Network receiver: Connectivity [ AVAILABLE ] 
... W/Network receiver: Connectivity [ UNAVAILABLE ] 
... V/Network receiver: Connectivity [ AVAILABLE ] 
... V/Network receiver: Connectivity [ AVAILABLE ] 
... V/Network receiver: Connectivity [ AVAILABLE ] 

总结

在本章中,我们学习了如何使用广播消息。我们还学习了如何监听系统广播消息以及我们自己创建的广播消息。Journaler 应用程序得到了显著改进,变得更加灵活。我们不会止步于此,而是将通过学习新知识和扩展我们的代码来继续在 Android 框架中取得进展。

第十二章:后端和 API

在本章中,我们将把我们的应用程序连接到远程后端实例。我们创建的所有数据都将与后端同步。对于 API 调用,我们将使用 Retrofit。Retrofit 是 Android 平台上最常用的 HTTP 客户端。我们将逐步指导您通过常见的实践,以便您可以轻松地连接和实现将来在任何应用程序中连接到后端。

这一章是本书中迄今为止最长的一章,在这里,我们将涵盖许多重要的内容,如以下主题:

  • 使用 data 类

  • Retrofit

  • Gson 与 Kotson 库

  • 内容提供程序

  • 内容加载器

  • Android 适配器

  • 数据绑定

  • 使用列表和网格

仔细阅读本章,并享受玩耍您的应用程序。

识别使用的实体

在我们同步任何内容之前,我们必须确定我们将要同步的内容。这个问题的答案是显而易见的,但我们无论如何都会回顾一下我们的实体列表。我们计划同步的主要实体有两个:

  • Note实体

  • Todo实体

它们具有以下属性:

  • 共同属性:

  • titleString

  • messageString

  • locationLocation(将被序列化)

请注意,目前我们在数据库中用纬度和经度表示位置。我们将把这个改为Text类型,因为我们将引入 Gson 和 Kotson 进行序列化/反序列化!

  • Todo 特定属性如下:

  • scheduledForLong

再次打开您的类并查看它们。

使用 data 类

在 Kotlin 中,建议使用data类作为实体的表示。在我们的情况下,我们没有使用data类,因为我们扩展了一个包含NoteTodo类之间共享属性的通用类。

我们建议使用data类,因为它可以显著简化您的工作流程,特别是如果您在后端通信中使用这些实体。

我们经常需要只有一个目的的类--保存数据。使用data类的好处是,通常与其目的一起使用的一些功能会自动提供。您可能已经知道如何定义data类,您必须这样做:

   data class Entity(val param1: String, val param2: String) 

对于data类,编译器会自动为您提供以下内容:

  • equals()hashCode()方法

  • toString()方法以人类可读的形式,

Entity(param1=Something, param2=Something)

  • 用于克隆的copy()方法

所有data类必须满足以下要求:

  • 主构造函数需要至少有一个参数

  • 所有主构造函数参数都需要标记为valvar

  • data类不能是abstractopensealedinner

让我们介绍一些data类!由于我们计划使用远程后端实例,这将需要一些身份验证。我们将为身份验证过程中传递的数据创建新的实体(data类),以及身份验证结果。创建一个名为api的新包。然后,创建一个名为UserLoginRequest的新的data类,如下所示:

     package com.journaler.api 

     data class UserLoginRequest( 
        val username: String, 
        val password: String 
     )  

UserLoginRequest类将包含我们的身份验证凭据。API 调用将返回一个 JSON,该 JSON 将被反序列化为JournalerApiTokendata类,如下所示:

    package com.journaler.api 
    import com.google.gson.annotations.SerializedName 

    data class JournalerApiToken( 
        @SerializedName("id_token") val token: String, 
        val expires: Long 
    ) 

注意我们使用注解告诉 Gson,token 字段将从 JSON 中的id_token字段获取。

总之--始终考虑使用data类!特别是如果它们所代表的数据将用于保存数据库和后端信息。

将数据模型连接到数据库

如果您像 Journaler 应用程序一样拥有一个在数据库中保存数据并计划将其与远程后端实例同步的场景,首先创建一个将存储数据的持久层可能是一个好主意。将数据持久化到本地文件系统数据库可以防止数据丢失,特别是如果数据量较大!

所以,我们做了什么?我们创建了一个持久化机制,将所有数据存储到 SQLite 数据库中。然后,在本章中,我们将介绍后端通信机制。因为我们不知道我们的 API 调用是否会失败,或者后端实例是否可用,所以我们将数据持久化。如果我们只将数据保存在设备内存中,如果同步的 API 调用失败并且我们的应用程序崩溃,我们可能会丢失这些数据。假设 API 调用失败并且应用程序崩溃,但我们的数据已持久化,我们可以重试同步。数据仍然存在!

Retrofit 介绍

正如我们已经提到的,Retrofit 是一个开源库。它是当今最流行的 Android HTTP 客户端。因此,我们将向您介绍 Retrofit 的基础知识,并演示如何使用它。我们将涵盖的版本是 2.3.0。我们将为您提供如何使用它的逐步指导。

首先,Retrofit 也依赖于一些库。我们将与 Okhttp 一起使用。Okhttp 是由开发 Retrofit 的同一团队开发的 HTTP/HTTP2 客户端。在开始之前,我们将把依赖项放入我们的build.gradle配置中,如下所示:

    apply plugin: "com.android.application" 
    apply plugin: "kotlin-android" 
    apply plugin: "kotlin-android-extensions" 
    ... 
    dependencies { 
      ... 
      compile 'com.squareup.retrofit2:retrofit:2.3.0' 
      compile 'com.squareup.retrofit2:converter-gson:2.0.2' 
      compile 'com.squareup.okhttp3:okhttp:3.9.0' 
      compile 'com.squareup.okhttp3:logging-interceptor:3.9.0' 
   } 

我们将我们的 Retrofit 和 Okhttp 更新到最新版本。我们为以下内容添加了依赖项:

  • Retrofit 库

  • Gson 转换器,用于反序列化 API 响应

  • Okhttp 库

  • Okhttp 的日志拦截器,以便我们可以记录 API 调用的情况

在同步我们的 Gradle 配置之后,我们准备好开始了!

定义 Retrofit 服务

Retrofit 将您的 HTTP API 转换为 Kotlin 接口。在 API 包中创建一个名为JournalerBackendService的接口。让我们在其中放入一些代码:

    package com.journaler.api 

    import com.journaler.model.Note 
    import com.journaler.model.Todo 
    import retrofit2.Call 
    import retrofit2.http.* 

    interface JournalerBackendService { 

      @POST("user/authenticate") 
      fun login( 
            @HeaderMap headers: Map<String, String>, 
            @Body payload: UserLoginRequest 
      ): Call<JournalerApiToken> 

      @GET("entity/note") 
      fun getNotes( 
            @HeaderMap headers: Map<String, String> 
      ): Call<List<Note>> 

      @GET("entity/todo") 
      fun getTodos( 
            @HeaderMap headers: Map<String, String> 
      ): Call<List<Todo>> 

      @PUT("entity/note") 
      fun publishNotes( 
            @HeaderMap headers: Map<String, String>, 
            @Body payload: List<Note> 
      ): Call<Unit> 

      @PUT("entity/todo") 
      fun publishTodos( 
            @HeaderMap headers: Map<String, String>, 
            @Body payload: List<Todo> 
      ): Call<Unit> 

      @DELETE("entity/note") 
      fun removeNotes( 
            @HeaderMap headers: Map<String, String>, 
            @Body payload: List<Note> 
      ): Call<Unit> 

      @DELETE("entity/todo") 
      fun removeTodos( 
            @HeaderMap headers: Map<String, String>, 
            @Body payload: List<Todo> 
      ): Call<Unit> 

    } 

在这个接口中我们有什么?我们定义了一系列调用,可以执行以下操作:

  • 用户身份验证:这将接受请求标头和包含用户凭据的UserLoginRequest类的实例。它将用作我们调用的有效负载。执行调用将返回一个包装的JournalerApiToken实例。我们将需要一个令牌用于所有其他调用,并将其内容放入每个调用的标头中。

  • 获取NotesTODOs:这将接受包含身份验证令牌的请求标头。作为调用的结果,我们会得到一个包装的NoteTodo类实例的列表。

  • NotesTODOs放置(当我们向服务器发送新内容时):这将接受包含身份验证令牌的请求标头。调用的有效负载将是NoteTodo类实例的列表。对于这些调用,我们不会返回任何重要数据。重要的是响应代码是正数。

  • 删除NotesTODOs--这也将接受包含身份验证令牌的请求标头。调用的有效负载将是要从我们的远程后端服务器实例中删除的NoteTodo类实例的列表。对于这些调用,我们不会返回任何重要数据。重要的是响应代码是正数。

每个都有一个表示 HTTP 方法和路径的适当注释。我们还使用注释来标记有效负载主体和标头映射。

构建一个 Retrofit 服务实例

现在,在我们描述了我们的服务之后,我们需要一个真正的 Retrofit 实例,我们将用它来触发 API 调用。首先,我们将介绍一些额外的类。我们将在TokenManager对象中保存最新的令牌实例:

    package com.journaler.api 
     object TokenManager { 
       var currentToken = JournalerApiToken("", -1) 
     } 

我们还将有一个用于获取 API 调用标头映射的对象,名为BackendServiceHeaderMap,如下所示:

    package com.journaler.api 

    object BackendServiceHeaderMap { 

     fun obtain(authorization: Boolean = false): Map<String, String> { 
        val map = mutableMapOf( 
                Pair("Accept", "*/*"), 
                Pair("Content-Type", "application/json; charset=UTF-8") 
        ) 
        if (authorization) { 
            map["Authorization"] = "Bearer
             ${TokenManager.currentToken.token}" 
        } 
        return map 
    } 

   } 

现在我们可以向您展示如何构建Retrofit实例。创建一个名为BackendServiceRetrofit的新对象,并确保它看起来像这样:

    package com.journaler.api 

    import okhttp3.OkHttpClient 
    import okhttp3.logging.HttpLoggingInterceptor 
    import retrofit2.Retrofit 
    import retrofit2.converter.gson.GsonConverterFactory 
    import java.util.concurrent.TimeUnit 

    object BackendServiceRetrofit { 

      fun obtain( 
            readTimeoutInSeconds: Long = 1, 
            connectTimeoutInSeconds: Long = 1 
      ): Retrofit { 
        val loggingInterceptor = HttpLoggingInterceptor() 
        loggingInterceptor.level 
      = HttpLoggingInterceptor.Level.BODY 
        return Retrofit.Builder() 
                .baseUrl("http://127.0.0.1") 
                .addConverterFactory(GsonConverterFactory.create()) 
                .client( 
                        OkHttpClient 
                                .Builder() 
                                .addInterceptor(loggingInterceptor) 
                                .readTimeout(readTimeoutInSeconds,
                                TimeUnit.SECONDS) 
                                  .connectTimeout
                                (connectTimeoutInSeconds,
                                TimeUnit.SECONDS) 
                                .build() 
                ) 
                .build() 
     } 

    } 

调用obtain()方法将返回一个准备好发出 API 调用的Retrofit实例。我们创建了一个Retrofit实例,其后端基本 URL 设置为本地主机。我们还传递了 Gson 转换器工厂,用作 JSON 反序列化的机制。最重要的是,我们传递了我们将使用的客户端实例,并创建了一个新的 OkHttp 客户端。

使用 Kotson 库介绍 Gson

JSON 序列化和反序列化对于每个 Android 应用程序都非常重要,并且经常被使用。为此,我们将使用由 Google 开发的 Gson 库。此外,我们将使用 Kotson 和 Kotlin 绑定来使用 Gson。所以,让我们开始吧!

首先,我们需要根据以下内容为我们的build.gradle配置提供依赖项:

    apply plugin: "com.android.application" 
    apply plugin: "kotlin-android" 
    apply plugin: "kotlin-android-extensions" 
    ... 
    dependencies { 
      ... 
      compile 'com.google.code.gson:gson:2.8.0' 
      compile 'com.github.salomonbrys.kotson:kotson:2.3.0' 
      ... 
    } 

我们将更新我们的代码,使用 Gson 与 Kotson 绑定进行位置序列化/反序列化在数据库管理中。首先,我们需要对Db类进行一些小改动:

    class DbHelper(dbName: String, val version: Int) :
    SQLiteOpenHelper( 
      Journaler.ctx, dbName, null, version 
    ) { 

    companion object { 
        val ID: String = "_id" 
        val TABLE_TODOS = "todos" 
        val TABLE_NOTES = "notes" 
        val COLUMN_TITLE: String = "title" 
        val COLUMN_MESSAGE: String = "message" 
        val COLUMN_LOCATION: String = "location" 
        val COLUMN_SCHEDULED: String = "scheduled" 
    } 
    ... 
    private val createTableNotes =  """ 
                                    CREATE TABLE if not exists
                                     $TABLE_NOTES 
                                    ( 
                                        $ID integer PRIMARY KEY
                                        autoincrement, 
                                        $COLUMN_TITLE text, 
                                        $COLUMN_MESSAGE text, 
                                        $COLUMN_LOCATION text 
                                    ) 
                                    """ 

    private val createTableTodos =  """ 
                                    CREATE TABLE if not exists
                                     $TABLE_TODOS 
                                    ( 
                                        $ID integer PRIMARY KEY
                                         autoincrement, 
                                        $COLUMN_TITLE text, 
                                        $COLUMN_MESSAGE text, 
                                        $COLUMN_SCHEDULED integer, 
                                        $COLUMN_LOCATION text 
                                    ) 
                                    """ 
    ... 
   } 

正如您所见,我们改变了位置信息处理。现在,我们不再有位置纬度和经度列,而是只有一个数据库列--location。类型为Text。我们将保存由 Gson 库生成的序列化的Location类值。此外,当我们检索序列化的值时,我们将使用 Gson 将它们反序列化为Location类实例。

现在,我们必须实际使用 Gson。打开Db.kt并更新它以使用 Gson 序列化和反序列化Location类实例,如下所示:

    package com.journaler.database 
    ... 
    import com.google.gson.Gson 
    ... 
    import com.github.salomonbrys.kotson.* 

    object Db : Crud<DbModel> { 
      ... 
      private val gson = Gson() 
      ... 
      override fun insert(what: Collection<DbModel>): Boolean { 
        ... 
        what.forEach { 
            item -> 
            when (item) { 
                is Entry -> { 
                    ... 
                    values.put(DbHelper.COLUMN_LOCATION,
                     gson.toJson(item.location)) 
                    ... 
            } 
        } 
        ... 
        return success 
    } 
    ... 
    override fun update(what: Collection<DbModel>): Boolean { 
        ... 
        what.forEach { 
            item -> 
            when (item) { 
                is Entry -> { 
                    ... 
                    values.put(DbHelper.COLUMN_LOCATION,
                    gson.toJson(item.location)) 
                } 
       ... 
        return result 
    } 
    ... 
    override fun select(args: Pair<String, String>, clazz:  
    KClass<DbModel>): List<DbModel> { 
        return select(listOf(args), clazz) 
    } 

    override fun select( 
        args: Collection<Pair<String, String>>, clazz: Kclass<DbModel> 
    ): List<DbModel> { 
        ... 
        if (clazz.simpleName == Note::class.simpleName) { 
            val result = mutableListOf<DbModel>() 
            val cursor = db.query( 
                ... 
            ) 
            while (cursor.moveToNext()) { 
                ... 
                val locationIdx =
                cursor.getColumnIndexOrThrow(DbHelper.COLUMN_LOCATION) 
                val locationJson = cursor.getString(locationIdx) 
                val location = gson.fromJson<Location>(locationJson) 
                val note = Note(title, message, location) 
                note.id = id 
                result.add(note) 
            } 
            cursor.close() 
            return result 
        } 
        if (clazz.simpleName == Todo::class.simpleName) { 
                ... 
            ) 
            while (cursor.moveToNext()) { 
                ... 
                val locationIdx =
                cursor.getColumnIndexOrThrow(DbHelper.COLUMN_LOCATION) 
                val locationJson = cursor.getString(locationIdx) 
                val location = gson.fromJson<Location>(locationJson) 
                ... 
                val todo = Todo(title, message, location, scheduledFor) 
                todo.id = id 
                result.add(todo) 
            } 
            cursor.close() 
            return result 
        } 
        db.close() 
        throw IllegalArgumentException("Unsupported entry type: 
        $clazz") 
      } 
   }  

正如您所见,在上述代码中,使用 Gson 进行更新非常简单。我们依赖于从 Gson 类实例访问的以下两个 Gson 库方法:

  • fromJson<T>()

  • toJson()

由于 Kotson 和 Kotlin 绑定,我们可以使用fromJson<T>()方法对我们序列化的数据使用参数化类型。

还有什么其他的?

现在,我们将列出一些 Retrofit 和 Gson 的替代方案。在外部,有一个庞大的开源社区,每天都在做出伟大的事情。您不必使用我们提供的任何库。您可以选择任何替代方案,甚至创建自己的实现!

Retrofit 替代方案

正如其主页所说,Volley 是一个使 Android 应用程序的网络工作更加简单和更快的 HTTP 库。Volley 提供的一些关键功能包括:

  • 自动调度网络请求

  • 多个并发网络连接

  • 透明的磁盘和内存响应缓存与标准 HTTP 缓存一致性

  • 支持请求优先级。

  • 取消请求 API

  • 易于定制

  • 强有力的排序

  • 调试和跟踪工具

主页--github.com/google/volley

Gson 替代方案

Jackson 是一个低级别的 JSON 解析器。它与 Java StAX 解析器非常相似,用于 XML。Jackson 提供的一些关键功能包括:

  • 非常快速和方便

  • 广泛的注释支持

  • 流式读取和写入

  • 树模型

  • 开箱即用的 JAX-RS 支持

  • 集成对二进制内容的支持

主页--github.com/FasterXML/jackson

执行我们的第一个 API 调用

我们定义了一个带有所有 API 调用的 Retrofit 服务,但我们还没有将任何东西连接到它。现在是使用它的时候了。我们将扩展我们的代码以使用 Retrofit。每个 API 调用都可以同步或异步执行。我们将向您展示两种方式。您还记得我们将 Retrofit 服务的基本 URL 设置为本地主机吗?这意味着我们将需要一个本地后端实例来响应我们的 HTTP 请求。由于后端实现不是本书的主题,我们将把它留给您来创建一个简单的服务来响应此请求。您可以使用任何您喜欢的编程语言来实现它,比如 Kotlin、Java、Python 和 PHP。

如果您不耐烦,不想为处理 HTTP 请求实现自己的应用程序,您可以覆盖基本 URL,Notes 和 TODOs 路径,如下例所示,并使用后端实例进行尝试:

        @POST("authenticate") 
        // @POST("user/authenticate") 
        fun login( 
            ... 
        ): Call<JournalerApiToken> 
  • Notes GET到目标:
        @GET("notes") 
        // @GET("entity/note") 
        fun getNotes( 
            ... 
        ): Call<List<Note>> 
  • TODOs GET到目标:
        @GET("todos") 
        // @GET("entity/todo") 
       fun getTodos( 
            ... 
       ): Call<List<Todo>> 

这样,我们将针对远程后端实例返回我们的存根NotesTODOs。现在打开您的JournalerBackendService接口,并将其扩展如下:

    interface JournalerBackendService { 
      companion object { 
        fun obtain(): JournalerBackendService { 
            return BackendServiceRetrofit 
                    .obtain() 
                    .create(JournalerBackendService::class.java) 
        } 
     } 
      ... 
    } 

我们刚刚添加的方法将为我们提供一个使用 Retrofit 的JournalerBackendService实例。通过这个,我们将触发所有我们的调用。打开MainService类。找到synchronize()方法。记住我们在那里放了一个睡眠来模拟与后端的通信。现在,我们将用真实的后端调用替换它:

    /** 
    * Authenticates user synchronously, 
    * then executes async calls for notes and TODOs fetching. 
    * Pay attention on synchronously triggered call via execute() 
      method. 
    * Its asynchronous equivalent is: enqueue(). 
    */ 
    override fun synchronize() { 
        executor.execute { 
            Log.i(tag, "Synchronizing data [ START ]") 
            var headers = BackendServiceHeaderMap.obtain() 
            val service = JournalerBackendService.obtain() 
            val credentials = UserLoginRequest("username", "password") 
            val tokenResponse = service 
                    .login(headers, credentials) 
                    .execute() 
            if (tokenResponse.isSuccessful) { 
                val token = tokenResponse.body() 
                token?.let { 
                    TokenManager.currentToken = token 
                    headers = BackendServiceHeaderMap.obtain(true) 
                    fetchNotes(service, headers) 
                    fetchTodos(service, headers) 
                } 
            } 
            Log.i(tag, "Synchronizing data [ END ]") 
        } 
    } 

    /** 
    * Fetches notes asynchronously. 
    * Pay attention on enqueue() method 
    */ 
    private fun fetchNotes( 
            service: JournalerBackendService, headers: Map<String,  
    String> 
    ) { 
        service 
            .getNotes(headers) 
            .enqueue( 
            object : Callback<List<Note>> { 
              verride fun onResponse( 
               call: Call<List<Note>>?, response: Response<List<Note>>? 
                            ) { 
                                response?.let { 
                                    if (response.isSuccessful) { 
                                        val notes = response.body() 
                                        notes?.let { 
                                            Db.insert(notes) 
                                        } 
                                    } 
                                } 
                            } 

                            override fun onFailure(call: 
                            Call<List<Note>>?, t: Throwable?) { 
                                Log.e(tag, "We couldn't fetch notes.") 
                            } 
                        } 
                ) 
     } 

     /** 
     * Fetches TODOs asynchronously. 
     * Pay attention on enqueue() method 
     */ 
     private fun fetchTodos( 
            service: JournalerBackendService, headers: Map<String,  
      String> 
     ) { 
        service 
                .getTodos(headers) 
                .enqueue( 
                        object : Callback<List<Todo>> { 
                            override fun onResponse( 
                                    call: Call<List<Todo>>?, response:
         Response<List<Todo>>? 
                            ) { 
                                response?.let { 
                                    if (response.isSuccessful) { 
                                        val todos = response.body() 
                                        todos?.let { 
                                            Db.insert(todos) 
                                        } 
                                    } 
                                } 
                            } 

                            override fun onFailure(call:
                            Call<List<Todo>>?, t: Throwable?) { 
                                Log.e(tag, "We couldn't fetch notes.") 
                            } 
                        } 
                 ) 
     } 

慢慢分析代码,花点时间!有很多事情要做!首先,我们将创建标题和 Journaler 后端服务的实例。然后,我们通过触发execute()方法同步执行身份验证。我们收到了Response<JournalerApiToken>JournalerApiToken实例包装在Response类实例中。在我们检查响应是否成功,并且我们实际上收到并反序列化了JournalerApiToken之后,我们将其设置为TokenManager。最后,我们触发NotesTODOs检索的异步调用。

enqueue()方法触发异步操作,并且作为参数接受 Retrofit 回调具体化。我们将与同步调用做同样的事情。我们将检查它是否成功,以及是否有数据。如果一切正常,我们将把所有实例传递给我们的数据库管理器进行保存。

我们只实现了NotesTODOs的检索。对于其余的 API 调用,我们将把它留给您来实现。这是学习 Retrofit 的一个很好的方法!

让我们构建一个应用程序并运行它。当应用程序及其主服务启动时,API 调用将被执行。通过 OkHttp 过滤 Logcat 输出。观察以下内容。

身份验证日志行:

  • 请求:
 D/OkHttp: --> POST 
 http://static.milosvasic.net/jsons/journaler/authenticate 
        D/OkHttp: Content-Type: application/json; charset=UTF-8 
        D/OkHttp: Content-Length: 45 
        D/OkHttp: Accept: */* 
        D/OkHttp: {"password":"password","username":"username"} 
        D/OkHttp: --> END POST (45-byte body) 
  • 响应:
 D/OkHttp: <-- 200 OK 
 http://static.milosvasic.net/jsons/journaler/
 authenticate/ (302ms) 
       D/OkHttp: Date: Sat, 23 Sep 2017 15:46:27 GMT 
       D/OkHttp: Server: Apache 
       D/OkHttp: Keep-Alive: timeout=5, max=99 
       D/OkHttp: Connection: Keep-Alive 
       D/OkHttp: Transfer-Encoding: chunked 
       D/OkHttp: Content-Type: text/html 
       D/OkHttp: { 
       D/OkHttp:   "id_token": "stub_token_1234567", 
       D/OkHttp:   "expires": 10000 
       D/OkHttp: } 
       D/OkHttp: <-- END HTTP (58-byte body) 

Notes日志行:

  • 请求:
 D/OkHttp: --> GET 
 http://static.milosvasic.net/jsons/journaler/notes 
        D/OkHttp: Accept: */* 
        D/OkHttp: Authorization: Bearer stub_token_1234567 
        D/OkHttp: --> END GET 
  • 响应:
 D/OkHttp: <-- 200 OK 
 http://static.milosvasic.net/jsons/journaler/notes/ (95ms) 
        D/OkHttp: Date: Sat, 23 Sep 2017 15:46:28 GMT 
        D/OkHttp: Server: Apache 
        D/OkHttp: Keep-Alive: timeout=5, max=97 
        D/OkHttp: Connection: Keep-Alive 
        D/OkHttp: Transfer-Encoding: chunked 
        D/OkHttp: Content-Type: text/html 
        D/OkHttp: [ 
        D/OkHttp:   { 
        D/OkHttp:     "title": "Test note 1", 
        D/OkHttp:     "message": "Test message 1", 
        D/OkHttp:     "location": { 
        D/OkHttp:       "latitude": 10000, 
        D/OkHttp:       "longitude": 10000 
        D/OkHttp:     } 
        D/OkHttp:   }, 
        D/OkHttp:   { 
        D/OkHttp:     "title": "Test note 2", 
        D/OkHttp:     "message": "Test message 2", 
        D/OkHttp:     "location": { 
        D/OkHttp:       "latitude": 10000, 
        D/OkHttp:       "longitude": 10000 
        D/OkHttp:     } 
        D/OkHttp:   }, 
        D/OkHttp:   { 
        D/OkHttp:     "title": "Test note 3", 
        D/OkHttp:     "message": "Test message 3", 
        D/OkHttp:     "location": { 
        D/OkHttp:       "latitude": 10000, 
        D/OkHttp:       "longitude": 10000 
        D/OkHttp:     } 
        D/OkHttp:   } 
        D/OkHttp: ] 
        D/OkHttp: <-- END HTTP (434-byte body) 

TODOs日志行:

  • 请求:这是我们做的请求部分的一个例子:
 D/OkHttp: --> GET
 http://static.milosvasic.net/jsons/journaler/todos 
        D/OkHttp: Accept: */* 
        D/OkHttp: Authorization: Bearer stub_token_1234567 
        D/OkHttp: --> END GET 
  • 响应:这是我们收到的响应的一个例子:
 D/OkHttp: <-- 200 OK
 http://static.milosvasic.net/jsons/journaler/todos/ (140ms) 
       D/OkHttp: Date: Sat, 23 Sep 2017 15:46:28 GMT 
       D/OkHttp: Server: Apache 
       D/OkHttp: Keep-Alive: timeout=5, max=99 
       D/OkHttp: Connection: Keep-Alive 
       D/OkHttp: Transfer-Encoding: chunked 
       D/OkHttp: Content-Type: text/html 
       D/OkHttp: [ 
       D/OkHttp:   { 
       D/OkHttp:     "title": "Test todo 1", 
       D/OkHttp:     "message": "Test message 1", 
       D/OkHttp:     "location": { 
       D/OkHttp:       "latitude": 10000, 
       D/OkHttp:       "longitude": 10000 
       D/OkHttp:     }, 
       D/OkHttp:     "scheduledFor": 10000 
       D/OkHttp:   }, 
       D/OkHttp:   { 
       D/OkHttp:     "title": "Test todo 2", 
       D/OkHttp:     "message": "Test message 2", 
       D/OkHttp:     "location": { 
       D/OkHttp:       "latitude": 10000, 
       D/OkHttp:       "longitude": 10000 
       D/OkHttp:     }, 
       D/OkHttp:     "scheduledFor": 10000 
       D/OkHttp:   }, 
       D/OkHttp:   { 
       D/OkHttp:     "title": "Test todo 3", 
       D/OkHttp:     "message": "Test message 3", 
       D/OkHttp:     "location": { 
       D/OkHttp:       "latitude": 10000, 
       D/OkHttp:       "longitude": 10000 
       D/OkHttp:     }, 
       D/OkHttp:     "scheduledFor": 10000 
       D/OkHttp:   } 
       D/OkHttp: ] 
       D/OkHttp: <-- END HTTP (515-byte body) 

恭喜!您已经实现了您的第一个 Retrofit 服务!现在是时候实现其余的调用。还要进行一些代码重构!这是一个小作业任务给你。更新您的服务,使其可以接受登录凭据。在我们当前的代码中,我们硬编码了用户名和密码。您的任务将是重构代码并传递参数化的凭据。

可选地,改进代码,使其不再可能在同一时刻多次执行相同的调用。我们将这留作我们以前工作的遗留问题。

内容提供程序

现在是时候进一步改进我们的应用程序并向您介绍 Android 内容提供程序。内容提供程序是 Android 框架提供的顶级强大功能之一。内容提供程序的目的是什么?顾名思义,内容提供程序的目的是管理我们的应用程序存储的数据或其他应用程序存储的数据的访问。它们提供了一种机制来与其他应用程序共享数据,并为数据访问提供了安全机制,这些数据可能来自同一进程,也可能不来自同一进程。

看一下下面的插图,显示内容提供程序如何管理对共享存储的访问:

我们计划与其他应用程序共享NotesTODOs数据。由于抽象层内容提供者提供的抽象层,很容易在不影响上层的情况下对存储实现层进行更改。因此,即使你不打算与其他应用程序共享任何数据,你也可以使用内容提供者。例如,我们可以完全替换持久性机制,从 SQLite 到完全不同的东西。看一下下面的插图:

如果你不确定是否需要内容提供者,这就是你应该实现它的时候:

  • 如果你计划与其他应用程序共享你的应用程序数据

  • 如果你计划从你的应用程序复制和粘贴复杂数据或文件到其他应用程序

  • 如果你计划支持自定义搜索建议

Android 框架已经定义了一个内容提供者,你可以使用它来管理联系人、音频、视频或其他文件。内容提供者不仅限于 SQLite 访问,你也可以用它来处理其他结构化数据。

让我们再次强调主要的好处:

  • 访问数据的权限

  • 抽象数据层

所以,正如我们已经说过的,我们计划支持从 Journaler 应用程序暴露数据。在创建内容提供者之前,我们必须注意,这将需要重构当前的代码。不要担心,我们将向你介绍内容提供者,并解释给你所有我们所做的重构。在我们完成实现和重构之后,我们将创建一个示例客户端应用程序,该应用程序将使用我们的内容提供者并触发所有 CRUD 操作。

让我们创建一个ContentProvider类。创建一个名为provider的新包,并创建一个JournalerProvider类,继承ContentProvider类。

类开始:

    package com.journaler.provider 

    import android.content.* 
    import android.database.Cursor 
    import android.net.Uri 
    import com.journaler.database.DbHelper 
    import android.content.ContentUris 
    import android.database.SQLException 
    import android.database.sqlite.SQLiteDatabase 
    import android.database.sqlite.SQLiteQueryBuilder 
    import android.text.TextUtils 

    class JournalerProvider : ContentProvider() { 

      private val version = 1 
      private val name = "journaler" 
      private val db: SQLiteDatabase by lazy { 
        DbHelper(name, version).writableDatabase 
    } 

定义一个companion对象:

     companion object { 
        private val dataTypeNote = "note" 
        private val dataTypeNotes = "notes" 
        private val dataTypeTodo = "todo" 
        private val dataTypeTodos = "todos" 
        val AUTHORITY = "com.journaler.provider" 
        val URL_NOTE = "content://$AUTHORITY/$dataTypeNote" 
        val URL_TODO = "content://$AUTHORITY/$dataTypeTodo" 
        val URL_NOTES = "content://$AUTHORITY/$dataTypeNotes" 
        val URL_TODOS = "content://$AUTHORITY/$dataTypeTodos" 
        private val matcher = UriMatcher(UriMatcher.NO_MATCH) 
        private val NOTE_ALL = 1 
        private val NOTE_ITEM = 2 
        private val TODO_ALL = 3 
        private val TODO_ITEM = 4 
    } 

类初始化:

    /** 
     * We register uri paths in the following format: 
     * 
     * <prefix>://<authority>/<data_type>/<id> 
     * <prefix> - This is always set to content:// 
     * <authority> - Name for the content provider 
     * <data_type> - The type of data we provide in this Uri 
     * <id> - Record ID. 
     */ 
    init { 
        /** 
         * The calls to addURI() go here, 
         * for all of the content URI patterns that the provider should
          recognize. 
         * 
         * First: 
         * 
         * Sets the integer value for multiple rows in notes (TODOs) to 
         1\. 
         * Notice that no wildcard is used in the path. 
         * 
         * Second: 
         * 
         * Sets the code for a single row to 2\. In this case, the "#"
         wildcard is 
         * used. "content://com.journaler.provider/note/3" matches, but 
         * "content://com.journaler.provider/note doesn't. 
         * 
         * The same applies for TODOs. 
         * 
         * addUri() params: 
         * 
         * authority    - String: the authority to match 
         * 
         * path         - String: the path to match. 
         *              * may be used as a wild card for any text, 
         *              and # may be used as a wild card for numbers. 
         * 
         * code              - int: the code that is returned when a
        URI 
         *              is matched against the given components. 
         */ 
        matcher.addURI(AUTHORITY, dataTypeNote, NOTE_ALL) 
        matcher.addURI(AUTHORITY, "$dataTypeNotes/#", NOTE_ITEM) 
        matcher.addURI(AUTHORITY, dataTypeTodo, TODO_ALL) 
        matcher.addURI(AUTHORITY, "$dataTypeTodos/#", TODO_ITEM) 
    } 

重写onCreate()方法:

     /** 
     * True - if the provider was successfully loaded 
     */ 
    override fun onCreate() = true 

插入操作如下:

     override fun insert(uri: Uri?, values: ContentValues?): Uri { 
        uri?.let { 
            values?.let { 
                db.beginTransaction() 
                val (url, table) = getParameters(uri) 
                if (!TextUtils.isEmpty(table)) { 
                    val inserted = db.insert(table, null, values) 
                    val success = inserted > 0 
                    if (success) { 
                        db.setTransactionSuccessful() 
                    } 
                    db.endTransaction() 
                    if (success) { 
                        val resultUrl = ContentUris.withAppendedId
                        (Uri.parse(url), inserted) 
                        context.contentResolver.notifyChange(resultUrl,
                        null) 
                        return resultUrl 
                    } 
                } else { 
                    throw SQLException("Insert failed, no table for
                    uri: " + uri) 
                } 
            } 
        } 
        throw SQLException("Insert failed: " + uri) 
    } 

更新操作如下:

     override fun update( 
            uri: Uri?, 
            values: ContentValues?, 
            where: String?, 
            whereArgs: Array<out String>? 
    ): Int { 
        uri?.let { 
            values?.let { 
                db.beginTransaction() 
                val (_, table) = getParameters(uri) 
                if (!TextUtils.isEmpty(table)) { 
                    val updated = db.update(table, values, where,
                     whereArgs) 
                    val success = updated > 0 
                    if (success) { 
                        db.setTransactionSuccessful() 
                    } 
                    db.endTransaction() 
                    if (success) { 
                        context.contentResolver.notifyChange(uri, null) 
                        return updated 
                    } 
                } else { 
                    throw SQLException("Update failed, no table for
                     uri: " + uri) 
                } 
            } 
        } 
        throw SQLException("Update failed: " + uri) 
    } 

删除操作如下:

    override fun delete( 
            uri: Uri?, 
            selection: String?, 
            selectionArgs: Array<out String>? 
    ): Int { 
        uri?.let { 
            db.beginTransaction() 
            val (_, table) = getParameters(uri) 
            if (!TextUtils.isEmpty(table)) { 
                val count = db.delete(table, selection, selectionArgs) 
                val success = count > 0 
                if (success) { 
                    db.setTransactionSuccessful() 
                } 
                db.endTransaction() 
                if (success) { 
                    context.contentResolver.notifyChange(uri, null) 
                    return count 
                } 
            } else { 
                throw SQLException("Delete failed, no table for uri: "
               + uri) 
            } 
        } 
        throw SQLException("Delete failed: " + uri) 
    } 

执行查询:

     override fun query( 
            uri: Uri?, 
            projection: Array<out String>?, 
            selection: String?, 
            selectionArgs: Array<out String>?, 
            sortOrder: String? 
     ): Cursor { 
        uri?.let { 
            val stb = SQLiteQueryBuilder() 
            val (_, table) = getParameters(uri) 
            stb.tables = table 
            stb.setProjectionMap(mutableMapOf<String, String>()) 
            val cursor = stb.query(db, projection, selection,
             selectionArgs, null, null, null) 
            // register to watch a content URI for changes 
            cursor.setNotificationUri(context.contentResolver, uri) 
            return cursor 
        } 
        throw SQLException("Query failed: " + uri) 
    } 

    /** 
     * Return the MIME type corresponding to a content URI. 
     */ 
    override fun getType(p0: Uri?): String = when (matcher.match(p0)) { 
        NOTE_ALL -> { 
            "${ContentResolver.
            CURSOR_DIR_BASE_TYPE}/vnd.com.journaler.note.items" 
        } 
        NOTE_ITEM -> { 
            "${ContentResolver.
             CURSOR_ITEM_BASE_TYPE}/vnd.com.journaler.note.item" 
        } 
        TODO_ALL -> { 
            "${ContentResolver.
             CURSOR_DIR_BASE_TYPE}/vnd.com.journaler.todo.items" 
        } 
        TODO_ITEM -> { 
            "${ContentResolver.
            CURSOR_ITEM_BASE_TYPE}/vnd.com.journaler.todo.item" 
        } 
        else -> throw IllegalArgumentException
        ("Unsupported Uri [ $p0 ]") 
    } 

类结束:

     private fun getParameters(uri: Uri): Pair<String, String> { 
        if (uri.toString().startsWith(URL_NOTE)) { 
            return Pair(URL_NOTE, DbHelper.TABLE_NOTES) 
        } 
        if (uri.toString().startsWith(URL_NOTES)) { 
            return Pair(URL_NOTES, DbHelper.TABLE_NOTES) 
        } 
        if (uri.toString().startsWith(URL_TODO)) { 
            return Pair(URL_TODO, DbHelper.TABLE_TODOS) 
        } 
        if (uri.toString().startsWith(URL_TODOS)) { 
            return Pair(URL_TODOS, DbHelper.TABLE_TODOS) 
        } 
        return Pair("", "") 
       } 

     }  

从上到下,我们做了以下工作:

  • 定义数据库名称和版本

  • 定义数据库实例的延迟初始化

  • 定义我们将用于访问数据的 URI(s)

  • 实现了所有的 CRUD 操作

  • 为数据定义 MIME 类型

现在,当你有一个内容提供者实现时,需要在你的manifest中注册它,如下所示:

    <manifest xmlns:android=
    "http://schemas.android.com/apk/res/android" 
    package="com.journaler"> 
    ... 
      <application 
        ... 
      > 
        ... 
        <provider 
            android:exported="true" 
            android:name="com.journaler.provider.JournalerProvider" 
            android:authorities="com.journaler.provider" /> 
        ... 
     </application> 
    ... 
    </manifest> 

观察。我们将exported属性设置为True。这是什么意思?这意味着,如果为True,Journaler 提供者可供其他应用程序使用。任何应用程序都可以使用提供者的内容 URI 来访问数据。另一个重要的属性是multiprocess。如果应用程序在多个进程中运行,此属性确定是否创建 Journaler 提供者的多个实例。如果为True,每个应用程序的进程都有自己的内容提供者实例。

让我们继续。在Crud接口中,如果你还没有,将这个添加到companion对象中:

    companion object { 
        val BROADCAST_ACTION = "com.journaler.broadcast.crud" 
        val BROADCAST_EXTRAS_KEY_CRUD_OPERATION_RESULT = "crud_result" 
   }  

我们将把我们的Db类重命名为 Content。更新Content实现,如下所示,以使用JournalerProvider

    package com.journaler.database 

    import android.content.ContentValues 
    import android.location.Location 
    import android.net.Uri 
    import android.util.Log 
    import com.github.salomonbrys.kotson.fromJson 
    import com.google.gson.Gson 
    import com.journaler.Journaler 
    import com.journaler.model.* 
    import com.journaler.provider.JournalerProvider 

    object Content { 

      private val gson = Gson() 
      private val tag = "Content" 

      val NOTE = object : Crud<Note> { ... 

注意插入操作:


     ... 
     override fun insert(what: Note): Long { 
       val inserted = insert(listOf(what)) 
       if (!inserted.isEmpty()) return inserted[0] 
         return 0 
     } 

     override fun insert(what: Collection<Note>): List<Long> { 
        val ids = mutableListOf<Long>() 
        what.forEach { item -> 
           val values = ContentValues() 
           values.put(DbHelper.COLUMN_TITLE, item.title) 
           values.put(DbHelper.COLUMN_MESSAGE, item.message) 
           values.put(DbHelper.COLUMN_LOCATION,
           gson.toJson(item.location)) 
           val uri = Uri.parse(JournalerProvider.URL_NOTE) 
           val ctx = Journaler.ctx 
           ctx?.let { 
             val result = ctx.contentResolver.insert(uri, values) 
             result?.let { 
                 try { 
                      ids.add(result.lastPathSegment.toLong()) 
                  } catch (e: Exception) { 
                  Log.e(tag, "Error: $e") 
                } 
             } 
           } 
         } 
         return ids 
        } ... 

Note更新操作:

    .. 
    override fun update(what: Note) = update(listOf(what)) 

    override fun update(what: Collection<Note>): Int { 
      var count = 0 
      what.forEach { item -> 
          val values = ContentValues() 
          values.put(DbHelper.COLUMN_TITLE, item.title) 
          values.put(DbHelper.COLUMN_MESSAGE, item.message) 
          values.put(DbHelper.COLUMN_LOCATION,
          gson.toJson(item.location)) 
          val uri = Uri.parse(JournalerProvider.URL_NOTE) 
          val ctx = Journaler.ctx 
          ctx?.let { 
            count += ctx.contentResolver.update( 
              uri, values, "_id = ?", arrayOf(item.id.toString()) 
            ) 
          } 
         } 
         return count 
        } ... 

注意删除操作:

   ... 
   override fun delete(what: Note): Int = delete(listOf(what)) 

   override fun delete(what: Collection<Note>): Int { 
     var count = 0 
     what.forEach { item -> 
       val uri = Uri.parse(JournalerProvider.URL_NOTE) 
       val ctx = Journaler.ctx 
       ctx?.let { 
         count += ctx.contentResolver.delete( 
         uri, "_id = ?", arrayOf(item.id.toString()) 
       ) 
     } 
   } 
   return count 
  } ...  

Note选择操作:

     ...  
     override fun select(args: Pair<String, String> 
      ): List<Note> = select(listOf(args)) 

     override fun select(args: Collection<Pair<String, String>>):  
     List<Note> { 
            val items = mutableListOf<Note>() 
            val selection = StringBuilder() 
            val selectionArgs = mutableListOf<String>() 
            args.forEach { arg -> 
                selection.append("${arg.first} == ?") 
                selectionArgs.add(arg.second) 
            } 
            val ctx = Journaler.ctx 
            ctx?.let { 
                val uri = Uri.parse(JournalerProvider.URL_NOTES) 
                val cursor = ctx.contentResolver.query( 
                        uri, null, selection.toString(),
                  selectionArgs.toTypedArray(), null 
                ) 
                while (cursor.moveToNext()) { 
                    val id = cursor.getLong
                    (cursor.getColumnIndexOrThrow(DbHelper.ID)) 
                    val titleIdx = cursor.getColumnIndexOrThrow
                    (DbHelper.COLUMN_TITLE) 
                    val title = cursor.getString(titleIdx) 
                    val messageIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_MESSAGE) 
                    val message = cursor.getString(messageIdx) 
                    val locationIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_LOCATION) 
                    val locationJson = cursor.getString(locationIdx) 
                    val location = gson.fromJson<Location>
                    (locationJson) 
                    val note = Note(title, message, location) 
                    note.id = id 
                    items.add(note) 
                } 
                cursor.close() 
                return items 
            } 
            return items 
        } 

        override fun selectAll(): List<Note> { 
            val items = mutableListOf<Note>() 
            val ctx = Journaler.ctx 
            ctx?.let { 
                val uri = Uri.parse(JournalerProvider.URL_NOTES) 
                val cursor = ctx.contentResolver.query( 
                        uri, null, null, null, null 
                ) 
                while (cursor.moveToNext()) { 
                    val id = cursor.getLong
                    (cursor.getColumnIndexOrThrow(DbHelper.ID)) 
                    val titleIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_TITLE) 
                    val title = cursor.getString(titleIdx) 
                    val messageIdx = cursor.getColumnIndexOrThrow
                    (DbHelper.COLUMN_MESSAGE) 
                    val message = cursor.getString(messageIdx) 
                    val locationIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_LOCATION) 
                    val locationJson = cursor.getString(locationIdx) 
                    val location = gson.fromJson<Location>
                  (locationJson) 
                    val note = Note(title, message, location) 
                    note.id = id 
                    items.add(note) 
                } 
                cursor.close() 
            } 
            return items 
        } 
    }  

Todo对象定义及其插入操作:

     ... 
     val TODO = object : Crud<Todo> { 
        override fun insert(what: Todo): Long { 
            val inserted = insert(listOf(what)) 
            if (!inserted.isEmpty()) return inserted[0] 
            return 0 
        } 

        override fun insert(what: Collection<Todo>): List<Long> { 
            val ids = mutableListOf<Long>() 
            what.forEach { item -> 
                val values = ContentValues() 
                values.put(DbHelper.COLUMN_TITLE, item.title) 
                values.put(DbHelper.COLUMN_MESSAGE, item.message) 
                values.put(DbHelper.COLUMN_LOCATION,
                gson.toJson(item.location)) 
                val uri = Uri.parse(JournalerProvider.URL_TODO) 
                values.put(DbHelper.COLUMN_SCHEDULED,   
                item.scheduledFor) 
                val ctx = Journaler.ctx 
                ctx?.let { 
                    val result = ctx.contentResolver.insert(uri, 
                    values) 
                    result?.let { 
                        try { 
                            ids.add(result.lastPathSegment.toLong()) 
                        } catch (e: Exception) { 
                            Log.e(tag, "Error: $e") 
                        } 
                    } 
                } 
            } 
            return ids 
        } ... 

Todo更新操作:

     ... 
     override fun update(what: Todo) = update(listOf(what)) 

     override fun update(what: Collection<Todo>): Int { 
        var count = 0 
        what.forEach { item -> 
                val values = ContentValues() 
                values.put(DbHelper.COLUMN_TITLE, item.title) 
                values.put(DbHelper.COLUMN_MESSAGE, item.message) 
                values.put(DbHelper.COLUMN_LOCATION,
                gson.toJson(item.location)) 
                val uri = Uri.parse(JournalerProvider.URL_TODO) 
                values.put(DbHelper.COLUMN_SCHEDULED, 
                item.scheduledFor) 
                val ctx = Journaler.ctx 
                ctx?.let { 
                    count += ctx.contentResolver.update( 
                            uri, values, "_id = ?",
                           arrayOf(item.id.toString()) 
                    ) 
                } 
            } 
            return count 
        } ... 

Todo删除操作:

     ... 
     override fun delete(what: Todo): Int = delete(listOf(what)) 

     override fun delete(what: Collection<Todo>): Int { 
            var count = 0 
            what.forEach { item -> 
                val uri = Uri.parse(JournalerProvider.URL_TODO) 
                val ctx = Journaler.ctx 
                ctx?.let { 
                    count += ctx.contentResolver.delete( 
                            uri, "_id = ?", arrayOf(item.id.toString()) 
                    ) 
                } 
            } 
            return count 
        } 

Todo选择操作:

         ... 
        override fun select(args: Pair<String, String>): List<Todo> =  
        select(listOf(args)) 

        override fun select(args: Collection<Pair<String, String>>):
         List<Todo> { 
            val items = mutableListOf<Todo>() 
            val selection = StringBuilder() 
            val selectionArgs = mutableListOf<String>() 
            args.forEach { arg -> 
                selection.append("${arg.first} == ?") 
                selectionArgs.add(arg.second) 
            } 
            val ctx = Journaler.ctx 
            ctx?.let { 
                val uri = Uri.parse(JournalerProvider.URL_TODOS) 
                val cursor = ctx.contentResolver.query( 
                        uri, null, selection.toString(),
                        selectionArgs.toTypedArray(), null 
                ) 
                while (cursor.moveToNext()) { 
                    val id = cursor.getLong
                   (cursor.getColumnIndexOrThrow(DbHelper.ID)) 
                    val titleIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_TITLE) 
                    val 
                    title = 
                    cursor.getString(titleIdx) 
                    val messageIdx = cursor.getColumnIndexOrThrow
                    (DbHelper.COLUMN_MESSAGE) 
                    val message = cursor.getString(messageIdx) 
                    val locationIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_LOCATION) 
                    val locationJson = cursor.getString(locationIdx) 
                    val location = gson.fromJson<Location>
                    (locationJson) 
                    val scheduledForIdx = cursor.getColumnIndexOrThrow( 
                        DbHelper.COLUMN_SCHEDULED 
                    ) 
                    val scheduledFor = cursor.getLong(scheduledForIdx) 
                    val todo = Todo(title, message, location,
                    scheduledFor) 
                    todo.id = id 
                    items.add(todo) 
                } 
                cursor.close() 
            } 
            return items 
        } 

        override fun selectAll(): List<Todo> { 
            val items = mutableListOf<Todo>() 
            val ctx = Journaler.ctx 
            ctx?.let { 
                val uri = Uri.parse(JournalerProvider.URL_TODOS) 
                val cursor = ctx.contentResolver.query( 
                        uri, null, null, null, null 
                ) 
                while (cursor.moveToNext()) { 
                    val id = cursor.getLong
                   (cursor.getColumnIndexOrThrow(DbHelper.ID)) 
                    val titleIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_TITLE) 
                    val title = cursor.getString(titleIdx) 
                    val messageIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_MESSAGE) 
                    val message = cursor.getString(messageIdx) 
                    val locationIdx = cursor.getColumnIndexOrThrow
                   (DbHelper.COLUMN_LOCATION) 
                    val locationJson = cursor.getString(locationIdx) 
                    val location = gson.fromJson<Location>
                    (locationJson) 
                    val scheduledForIdx = cursor.getColumnIndexOrThrow( 
                        DbHelper.COLUMN_SCHEDULED 
                    ) 
                    val scheduledFor = cursor.getLong(scheduledForIdx) 
                    val todo = Todo
                    (title, message, location, scheduledFor) 
                    todo.id = id 
                    items.add(todo) 
                } 
                cursor.close() 
            } 
            return items 
         } 
      } 
   }  

仔细阅读代码。我们用内容提供者替换了直接的数据库访问。更新你的 UI 类以使用新的重构代码。如果你在做这个过程中遇到困难,你可以看一下包含这些更改的 GitHub 分支:

github.com/PacktPublishing/-Mastering-Android-Development-with-Kotlin/tree/examples/chapter_12

该分支还包含了 Journaler 内容提供程序客户端应用程序的示例。我们将突出显示客户端应用程序主屏幕上包含四个按钮的使用示例。每个按钮触发一个 CRUD 操作的示例,如下所示:

    package com.journaler.content_provider_client 

    import android.content.ContentValues 
    import android.location.Location 
    import android.net.Uri 
    import android.os.AsyncTask 
    import android.os.Bundle 
    import android.support.v7.app.AppCompatActivity 
    import android.util.Log 
    import com.github.salomonbrys.kotson.fromJson 
    import com.google.gson.Gson 
    import kotlinx.android.synthetic.main.activity_main.* 

   class MainActivity : AppCompatActivity() { 

     private val gson = Gson() 
     private val tag = "Main activity" 

     override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        setContentView(R.layout.activity_main) 

        select.setOnClickListener { 
            val task = object : AsyncTask<Unit, Unit, Unit>() { 
                override fun doInBackground(vararg p0: Unit?) { 
                    val selection = StringBuilder() 
                    val selectionArgs = mutableListOf<String>() 
                    val uri = Uri.parse
                    ("content://com.journaler.provider/notes") 
                    val cursor = contentResolver.query( 
                            uri, null, selection.toString(),
                            selectionArgs.toTypedArray(), null 
                    ) 
                    while (cursor.moveToNext()) { 
                        val id = cursor.getLong
                        (cursor.getColumnIndexOrThrow("_id")) 
                        val titleIdx =  cursor.
                        getColumnIndexOrThrow("title") 
                        val title = cursor.getString(titleIdx) 
                        val messageIdx = cursor.
                        getColumnIndexOrThrow("message") 
                        val message = cursor.getString(messageIdx) 
                        val locationIdx = cursor.
                        getColumnIndexOrThrow("location") 
                        val locationJson = cursor.
                        getString(locationIdx) 
                        val location = 
                        gson.fromJson<Location>(locationJson) 
                        Log.v( 
                                tag, 
                                "Note retrieved via content provider [
                                 $id, $title, $message, $location ]" 
                        ) 
                    } 
                    cursor.close() 
                } 
            } 
            task.execute() 
        } 

        insert.setOnClickListener { 
            val task = object : AsyncTask<Unit, Unit, Unit>() { 
                override fun doInBackground(vararg p0: Unit?) { 
                    for (x in 0..5) { 
                        val uri = Uri.parse
                       ("content://com.journaler.provider/note") 
                        val values = ContentValues() 
                        values.put("title", "Title $x") 
                        values.put("message", "Message $x") 
                        val location = Location("stub location $x") 
                        location.latitude = x.toDouble() 
                        location.longitude = x.toDouble() 
                        values.put("location", gson.toJson(location)) 
                        if (contentResolver.insert(uri, values) !=
                        null) { 
                            Log.v( 
                                    tag, 
                                    "Note inserted [ $x ]" 
                            ) 
                        } else { 
                            Log.e( 
                                    tag, 
                                    "Note not inserted [ $x ]" 
                            ) 
                        } 
                    } 
                } 
            } 
            task.execute() 
        } 

        update.setOnClickListener { 
            val task = object : AsyncTask<Unit, Unit, Unit>() { 
                override fun doInBackground(vararg p0: Unit?) { 
                    val selection = StringBuilder() 
                    val selectionArgs = mutableListOf<String>() 
                    val uri =
                    Uri.parse("content://com.journaler.provider/notes") 
                    val cursor = contentResolver.query( 
                            uri, null, selection.toString(),
                           selectionArgs.toTypedArray(), null 
                    ) 
                    while (cursor.moveToNext()) { 
                        val values = ContentValues() 
                        val id = cursor.getLong
                        (cursor.getColumnIndexOrThrow("_id")) 
                        val titleIdx =
                        cursor.getColumnIndexOrThrow("title") 
                        val title = "${cursor.getString(titleIdx)} upd:
                        ${System.currentTimeMillis()}" 
                        val messageIdx =
                       cursor.getColumnIndexOrThrow("message") 
                        val message = 
                       "${cursor.getString(messageIdx)} upd:
                       ${System.currentTimeMillis()}" 
                        val locationIdx = 
                       cursor.getColumnIndexOrThrow("location") 
                        val locationJson =
                       cursor.getString(locationIdx) 
                        values.put("_id", id) 
                        values.put("title", title) 
                        values.put("message", message) 
                        values.put("location", locationJson) 

                        val updated = contentResolver.update( 
                                uri, values, "_id = ?",
                                arrayOf(id.toString()) 
                        ) 
                        if (updated > 0) { 
                            Log.v( 
                                    tag, 
                                    "Notes updated [ $updated ]" 
                            ) 
                        } else { 
                            Log.e( 
                                    tag, 
                                    "Notes not updated" 
                            ) 
                        } 
                    } 
                    cursor.close() 
                } 
            } 
            task.execute() 
        } 

        delete.setOnClickListener { 
            val task = object : AsyncTask<Unit, Unit, Unit>() { 
                override fun doInBackground(vararg p0: Unit?) { 
                    val selection = StringBuilder() 
                    val selectionArgs = mutableListOf<String>() 
                    val uri = Uri.parse
                   ("content://com.journaler.provider/notes") 
                    val cursor = contentResolver.query( 
                            uri, null, selection.toString(),
                            selectionArgs.toTypedArray(), null 
                    ) 
                    while (cursor.moveToNext()) { 
                        val id = cursor.getLong
                        (cursor.getColumnIndexOrThrow("_id")) 
                        val deleted = contentResolver.delete( 
                                uri, "_id = ?", arrayOf(id.toString()) 
                        ) 
                        if (deleted > 0) { 
                            Log.v( 
                                    tag, 
                                    "Notes deleted [ $deleted ]" 
                            ) 
                        } else { 
                            Log.e( 
                                    tag, 
                                    "Notes not deleted" 
                            ) 
                        } 
                    } 
                    cursor.close() 
                } 

           } 
            task.execute() 
        } 
      } 
   } 

此示例演示了如何使用内容提供程序从其他应用程序触发 CRUD 操作。

Android 适配器

为了在我们的主屏幕上呈现内容,我们将使用 Android Adapter 类。Android 框架提供了适配器作为一种机制,以将项目提供给视图组,如列表或网格。为了展示适配器的使用示例,我们将定义我们自己的适配器实现。创建一个名为adapter的新包和一个扩展BaseAdapter类的EntryAdapter成员类:

    package com.journaler.adapter 

    import android.annotation.SuppressLint 
    import android.content.Context 
    import android.view.LayoutInflater 
    import android.view.View 
    import android.view.ViewGroup 
    import android.widget.BaseAdapter 
    import android.widget.TextView 
    import com.journaler.R 
    import com.journaler.model.Entry 

    class EntryAdapter( 
        private val ctx: Context, 
        private val items: List<Entry> 
    ) : BaseAdapter() { 

    @SuppressLint("InflateParams", "ViewHolder") 
    override fun getView(p0: Int, p1: View?, p2: ViewGroup?): View { 
        p1?.let { 
            return p1 
        } 
        val inflater = LayoutInflater.from(ctx) 
        val view = inflater.inflate(R.layout.adapter_entry, null) 
        val label = view.findViewById<TextView>(R.id.title) 
        label.text = items[p0].title 
        return view 
    } 

    override fun getItem(p0: Int): Entry = items[p0] 
    override fun getItemId(p0: Int): Long = items[p0].id 
    override fun getCount(): Int = items.size 
   } 

我们重写了以下方法:

  • getView(): 根据容器中的当前位置返回填充视图的实例

  • getItem(): 这将返回我们用来创建视图的项目实例;在我们的情况下,这是Entry类实例(NoteTodo

  • getItemId(): 这将返回当前项目实例的 ID

  • getCount(): 返回项目的总数

我们将连接适配器和我们的 UI。打开ItemsFragment并更新其onResume()方法,以实例化适配器并将其分配给ListView,如下所示:

    override fun onResume() { 
        super.onResume() 
        ... 
        executor.execute { 
            val notes = Content.NOTE.selectAll() 
            val adapter = EntryAdapter(activity, notes) 
            activity.runOnUiThread { 
                view?.findViewById<ListView>(R.id.items)?.adapter =
             adapter 
            } 
        } 
    } 

当您构建和运行应用程序时,您应该看到ViewPager的每个页面都填充了加载的项目,如下截图所示:

内容加载器

内容加载器为您提供了一种机制,用于从内容提供程序或其他数据源加载数据,以在 UI 组件(如 Activity 或 Fragment)中显示。这些是加载程序提供的好处:

  • 在单独的线程上运行

  • 通过提供回调方法简化线程管理

  • 加载程序在配置更改期间保持和缓存结果,从而防止重复查询

  • 我们可以实现并成为监视数据更改的观察者

我们将创建我们的内容加载器实现。首先,我们需要更新Adapter类。由于我们将处理游标,我们将使用CursorAdapter而不是BaseAdapterCursorAdapter在主构造函数中接受Cursor实例作为参数。CursorAdapter的实现比我们现在拥有的要简单得多。打开EntryAdapter并更新如下:

    class EntryAdapter(ctx: Context, crsr: Cursor) : CursorAdapter(ctx,
    crsr) { 

    override fun newView(p0: Context?, p1: Cursor?, p2: ViewGroup?):
    View { 
        val inflater = LayoutInflater.from(p0) 
        return inflater.inflate(R.layout.adapter_entry, null) 
    } 

    override fun bindView(p0: View?, p1: Context?, p2: Cursor?) { 
        p0?.let { 
            val label = p0.findViewById<TextView>(R.id.title) 
            label.text = cursor.getString( 
                cursor.getColumnIndexOrThrow(DbHelper.COLUMN_TITLE) 
            ) 
        } 
    } 

   } 

我们有以下两种要重写的方法:

  • newView(): 这将返回要填充数据的视图的实例

  • bindView(): 这将填充来自Cursor实例的数据

最后,让我们更新我们的ItemsFragment类,以便使用内容加载器实现:

    class ItemsFragment : BaseFragment() { 
      ... 
      private var adapter: EntryAdapter? = null 
      ... 
      private val loaderCallback = object :
      LoaderManager.LoaderCallbacks<Cursor> { 
        override fun onLoadFinished(loader: Loader<Cursor>?, cursor:
        Cursor?) { 
            cursor?.let { 
                if (adapter == null) { 
                    adapter = EntryAdapter(activity, cursor) 
                    items.adapter = adapter 
                } else { 
                    adapter?.swapCursor(cursor) 
                } 
            } 
        } 

        override fun onLoaderReset(loader: Loader<Cursor>?) { 
            adapter?.swapCursor(null) 
        } 

        override fun onCreateLoader(id: Int, args: Bundle?):
        Loader<Cursor> { 
            return CursorLoader( 
                    activity, 
                    Uri.parse(JournalerProvider.URL_NOTES), 
                    null, 
                    null, 
                    null, 
                    null 
            ) 
        } 
    } 

    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        loaderManager.initLoader( 
                0, null, loaderCallback 
        ) 
    } 

    override fun onResume() { 
        super.onResume() 
        loaderManager.restartLoader(0, null, loaderCallback) 

        val btn = view?.findViewById
       <FloatingActionButton>(R.id.new_item) 
        btn?.let { 
            animate(btn, false) 
        } 
    } 
   }  

我们通过调用 Fragment 的LoaderManager成员来初始化LoaderManager。我们执行的两个关键方法如下:

  • initLoader(): 这确保加载程序已初始化并处于活动状态

  • restartLoader(): 这将启动新的或重新启动现有的loader实例

这两种方法都接受 loader ID 和 bundle 数据作为参数,并提供了要重写的LoaderCallbacks<Cursor>实现,其中包括以下三种方法:

  • onCreateLoader(): 为我们提供的 ID 实例化并返回一个新的加载程序实例

  • onLoadFinished(): 当先前创建的 loader 完成加载时调用

  • onLoaderReset(): 当先前创建的 loader 正在被重置时调用,因此使其数据不可用

数据绑定

Android 支持一种数据绑定机制,以便将数据与视图绑定,并最小化粘合代码。通过更新您的构建 Gradle 配置来启用数据绑定,如下所示:

     android { 
       .... 
       dataBinding { 
        enabled = true 
       } 
     } 
     ... 
     dependencies { 
      ... 
      kapt 'com.android.databinding:compiler:2.3.1' 
    } 
    ...  

现在,您可以定义绑定表达式。看一下以下示例:

    <?xml version="1.0" encoding="utf-8"?> 
    <layout > 

    <data> 
        <variable 
            name="note" 
            type="com.journaler.model.Note" /> 
    </data> 

    <LinearLayout 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" 
        android:orientation="vertical"> 

        <TextView 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:text="@{note.title}" /> 

    </LinearLayout> 
  </layout>  

让我们按照以下方式绑定数据:

    package com.journaler.activity 

    import android.databinding.DataBindingUtil 
    import android.location.Location 
    import android.os.Bundle 
    import com.journaler.R 
    import com.journaler.databinding.ActivityBindingBinding 
    import com.journaler.model.Note 

    abstract class BindingActivity : BaseActivity() { 

    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        /** 
         * ActivityBindingBinding is auto generated class 
         * which name is derived from activity_binding.xml filename. 
         */ 
        val binding : ActivityBindingBinding =
        DataBindingUtil.setContentView( 
            this, R.layout.activity_binding 
        ) 
        val location = Location("dummy") 
        val note = Note("my note", "bla", location) 
        binding.note = note 
      } 

    }  

就是这样!看看将数据绑定到布局视图是多么简单!我们强烈建议您尽可能多地使用数据绑定。创建您自己的示例!随意尝试!

使用列表

我们向您展示了如何处理数据。正如您注意到的,在主视图数据容器中,我们使用了ListView。为什么我们选择它?首先,它是最常用的容器来保存您的数据。在大多数情况下,您将使用ListView来保存来自适配器的数据。永远不要在可滚动容器(如LinearLayout)中放置大量视图!尽可能使用ListView。当不再需要视图时,它会回收视图,并在需要时重新实例化它们。

使用列表可能会影响您的应用程序性能,因为它是一个用于显示数据的优化良好的容器。显示列表是几乎任何应用程序的基本功能!任何生成一组数据作为某些操作结果的应用程序都需要一个列表。在您的应用程序中几乎不可能不使用它。

使用网格

我们注意到列表的重要性。但是,如果我们计划将数据呈现为网格呢?对我们来说太幸运了!Android 框架为我们提供了一个与ListView非常相似的GridView。您在布局中定义您的GridView,并将适配器实例分配给GridView的适配器属性。GridView将为您回收所有视图,并在需要时执行实例化。列表和网格之间的主要区别在于您必须为您的GridView定义列数。以下示例将向您展示GridView的使用示例:

    <?xml version="1.0" encoding="utf-8"?> 
   <GridView  
      android:id="@+id/my_grid" 
      android:layout_width="match_parent" 
      android:layout_height="match_parent" 
      android:columnWidth="100dp" 
      android:numColumns="3" 
      android:verticalSpacing="20dp" 
      android:horizontalSpacing="20dp" 

      android:stretchMode="columnWidth" 
      android:gravity="center" 
    /> 

我们将突出显示我们在此示例中使用的重要属性:

  • columnWidth:指定每列的宽度

  • numColumns:指定列数

  • verticalSpacing:指定行之间的垂直间距

  • horizontalSpacing:指定网格中项目之间的水平间距

尝试将当前应用程序的主ListView更新为以GridView形式呈现数据。调整它,使其对最终用户看起来愉悦。再次,随意尝试实验!

实现拖放

在本章的最后一节,我们将向您展示如何实现拖放功能。这是您在大多数包含列表数据的应用程序中可能需要的功能。使用列表并不是执行拖放的必要条件,因为您可以拖动任何(视图)并将其释放到定义了适当监听器的任何位置。为了更好地理解我们所讨论的内容,我们将向您展示一个实现的例子。

让我们定义一个视图。在该视图上,我们将设置一个长按监听器,触发拖放操作:

    view.setOnLongClickListener { 
            val data = ClipData.newPlainText("", "") 
            val shadowBuilder = View.DragShadowBuilder(view) 
            view.startDrag(data, shadowBuilder, view, 0) 
            true 
   } 

我们使用ClipData类来传递数据以放置目标。我们定义了dragListener,并将其分配给我们期望它放置的视图:

    private val dragListener = View.OnDragListener { 
        view, event -> 
        val tag = "Drag and drop" 
        event?.let { 
            when (event.action) { 
                DragEvent.ACTION_DRAG_STARTED -> { 
                    Log.d(tag, "ACTION_DRAG_STARTED") 
                } 
                DragEvent.ACTION_DRAG_ENDED -> { 
                    Log.d(tag, "ACTION_DRAG_ENDED") 
                } 
                DragEvent.ACTION_DRAG_ENTERED -> { 
                    Log.d(tag, "ACTION_DRAG_ENDED") 
                } 
                DragEvent.ACTION_DRAG_EXITED -> { 
                    Log.d(tag, "ACTION_DRAG_ENDED") 
                } 
                else -> { 
                    Log.d(tag, "ACTION_DRAG_ ELSE ...") 
                } 
            } 
        } 
        true 
     } 

    target?.setOnDragListener(dragListener) 

拖放监听器将在我们开始拖动视图并最终释放到具有分配的监听器的target视图上时触发代码。

总结

在本章中,我们涵盖了许多主题。我们学习了关于后端通信,如何使用 Retrofit 与后端远程实例建立通信,以及如何处理我们获取的数据。本章的目的是使用内容提供程序和内容加载器。我们希望您意识到它们的重要性以及它们的好处。最后,我们演示了数据绑定;注意到我们的数据视图容器的重要性,比如ListViewGridView;并向您展示了如何执行拖放操作。在下一章中,我们将开始测试我们的代码。准备好进行性能优化,因为这是我们下一章要做的事情!

第十三章:调整以获得高性能

我们刚刚掌握了与后端和 API 的工作。我们接近旅程的尽头,但还没有结束!我们必须涵盖一些非常重要的要点!其中之一是性能优化。我们将指导你通过一些在实现这一目标时的良好实践。思考到目前为止我们已经开发的代码,以及如何应用这些建议。

在本章中,我们将涵盖以下主题:

  • 布局优化

  • 优化以保护电池寿命

  • 优化以获得最大的响应性

优化布局

为了实现最佳的 UI 性能,请遵循以下几点:

  • 优化你的布局层次结构:避免嵌套布局,因为它可能会影响性能!例如,你可以有多个嵌套的LinearLayout视图。与此相反,切换到RelativeLayout。这可以显著提高性能!嵌套布局需要更多的处理能力用于计算和绘制。

  • 尽可能重用布局:Android 提供了<include />来实现这一点。

看一个例子:

    to_be_included.xml: 
    <RelativeLayout xmlns:android=
    "http://schemas.android.com/apk/res/android" 

      android:layout_width="match_parent" 
      android:layout_height="wrap_content" 
      android:background="@color/main_bg" 
      tools:showIn="@layout/includes" > 

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

    </RelativeLayout>

    includes.xml 
      <LinearLayout xmlns:android=
      "http://schemas.android.com/apk/res/android" 
       android:orientation="vertical" 
       android:layout_width="match_parent" 
       android:layout_height="match_parent" 
       android:background="@color/main_bg" 
      > 
       ... 
      <include layout="@layout/to_be_included"/> 
      ... 
    </LinearLayout> 
  • 此外,可以使用<merge>。当在一个布局中包含另一个布局时,合并可以消除视图层次结构中多余的view groups。让我们看一个例子:
    to_merge.xml 
    <merge > 

     <ImageView 
        android:id="@+id/first" 
        android:layout_width="fill_parent" 
        android:layout_height="wrap_content" 
        android:src="img/first"/> 

     <ImageView 
        android:id="@+id/second" 
        android:layout_width="fill_parent" 
        android:layout_height="wrap_content" 
        android:src="img/second"/> 

   </merge> 

当我们在另一个布局中使用 include 包含to_merge.xml时,就像我们在之前的例子中所做的那样,Android 会忽略<merge>元素,并直接将我们的视图添加到<include />所放置的容器中:

  • 只在需要时将布局包含到屏幕中--如果你暂时不需要视图,将其可见性设置为Gone而不是InvisibleInvisible仍会创建视图的实例。使用Gone时,Android 只有在可见性更改为Visible时才会实例化视图。

  • 使用ListViewGridView等容器来保存你的数据组。我们已经在前一章中解释了为什么你应该使用它们。

优化电池寿命

有很多方法可以耗尽你的电池。其中一个例子就是在应用程序中做太多的工作。过多的处理会影响电池寿命。然而,我们将指出你可以节省电池的方法以及你必须避免的事情。遵循这些要点,并在开发应用程序时时刻牢记。

为了保持电池处于最佳状态,请遵循以下建议:

  • 尽量减少网络通信。频繁的网络调用会影响电池寿命。因此,尽量使其达到最佳状态。

  • 确定你的手机是否在充电。这可能是启动应用程序可能需要执行的密集和性能要求的操作的好时机。

  • 监控连接状态,并且只有在连接状态正常时执行与连接相关的操作。

  • 合理利用广播消息。频繁和不必要地发送广播消息会影响性能。考虑发送频率,并在不需要接收消息时注销接收器。

  • 注意 GPS 的使用强度。频繁的位置请求会显著影响电池寿命。

保持你的应用程序响应

你有多少次使用某个 Android 应用程序时收到应用程序无响应的消息?为什么会发生这种情况?我们会解释!注意以下几点,以免发生同样的情况:

  • 确保没有任何东西阻塞你的输入(任何密集的操作,特别是网络流量)。

  • 不要在主应用程序线程上执行长时间的任务。

  • 不要在广播接收器的onReceive()方法中执行长时间运行的操作。

  • 尽量使用AsyncTask类。考虑使用ThreadPoolExecutor

  • 尽可能使用内容加载器。

  • 避免同时执行太多线程。

  • 如果要写入文件系统,请使用单独的线程。

如果您的应用程序仍然出现 ANR,或者应用程序表现迟缓,请使用诸如 systrace 和 Traceview 之类的工具来跟踪问题的根源。

摘要

在这一简短但重要的章节中,我们强调了关于维护和实现良好应用性能和响应能力的重要要点。这些建议在应用程序优化中至关重要。因此,如果您的应用程序不遵循这些规则,您必须相应地进行优化。通过完成这一章,我们涵盖了您开发 Android 应用程序所需的一切。在下一章中,我们将对其进行测试。准备好编写一些单元测试和仪器测试!

第十四章:测试

我们开发了一个代码基础庞大的应用程序。我们尝试过它,我们认为我们的应用程序没有错误。但是,我们可能是错的!有时,即使我们确信我们的应用程序没有错误,也可能发生一个危险的问题在等待。如何预防这种情况?简单!我们将编写测试来为我们检查我们的代码。在本章中,我们将向您介绍测试,并举例说明如何设置、编写和运行您的测试。

在本章中,我们将涵盖以下主题:

  • 如何编写你的第一个测试

  • 使用测试套件

  • 如何测试 UI

  • 运行测试

  • 单元测试和仪器测试

添加依赖项

要运行测试,我们必须满足一些依赖关系。我们将通过扩展build.gradle来更新我们的应用程序配置,以支持测试并提供我们需要的类。打开build.gradle并扩展如下:

    apply plugin: "com.android.application" 
    apply plugin: "kotlin-android" 
    apply plugin: "kotlin-android-extensions" 

    repositories { 
      maven { url "https://maven.google.com" } 
    } 

    android { 
      ... 
      sourceSets { 
        main.java.srcDirs += [ 
                'src/main/kotlin', 
                'src/common/kotlin', 
                'src/debug/kotlin', 
                'src/release/kotlin', 
                'src/staging/kotlin', 
                'src/preproduction/kotlin', 
                'src/debug/java', 
                'src/release/java', 
                'src/staging/java', 
                'src/preproduction/java', 
                'src/testDebug/java', 
                'src/testDebug/kotlin', 
                'src/androidTestDebug/java', 
                'src/androidTestDebug/kotlin' 
        ] 
      } 
      ... 
      testOptions { 
        unitTests.returnDefaultValues = true 
      } 
    } 
    ... 
    dependencies { 
      ... 
      compile "junit:junit:4.12" 
      testCompile "junit:junit:4.12" 

      testCompile "org.jetbrains.kotlin:kotlin-reflect:1.1.51" 
      testCompile "org.jetbrains.kotlin:kotlin-stdlib:1.1.51" 

      compile "org.jetbrains.kotlin:kotlin-test:1.1.51" 
      testCompile "org.jetbrains.kotlin:kotlin-test:1.1.51" 

      compile "org.jetbrains.kotlin:kotlin-test-junit:1.1.51" 
      testCompile "org.jetbrains.kotlin:kotlin-test-junit:1.1.51" 

      compile 'com.android.support:support-annotations:26.0.1' 
      androidTestCompile 'com.android.support:support
     -annotations:26.0.1' 

      compile 'com.android.support.test:runner:0.5' 
      androidTestCompile 'com.android.support.test:runner:0.5' 

      compile 'com.android.support.test:rules:0.5' 
      androidTestCompile 'com.android.support.test:rules:0.5' 
     } 

    It is important to highlight use of: 
    testOptions { 
        unitTests.returnDefaultValues = true 
    } 

这将使我们能够测试内容提供程序并在我们的测试中使用所有相关的类。如果我们不启用此功能,我们将收到以下错误:

“错误:“方法...未模拟”!”

更新文件夹结构

文件夹结构和其中的代码必须遵循有关构建变体的约定。对于我们的测试,我们将使用结构的以下部分:

  • 对于单元测试:

  • 对于仪器测试:

现在我们准备开始编写我们的测试!

编写你的第一个测试

定位您的单元测试的root包,并创建一个名为NoteTest的新类,如下所示:

    package com.journaler 

    import android.location.Location 
    import com.journaler.database.Content 
    import com.journaler.model.Note 
    import org.junit.Test 

    class NoteTest { 

      @Test 
      fun noteTest() { 
        val note = Note( 
                "stub ${System.currentTimeMillis()}", 
                "stub ${System.currentTimeMillis()}", 
                Location("Stub") 
        ) 

        val id = Content.NOTE.insert(note) 
        note.id = id 

        assert(note.id > 0) 
     } 
    } 

测试非常简单。它创建一个Note的新实例,触发我们的内容提供程序中的 CRUD 操作来存储它,并验证接收到的 ID。要运行测试,请从项目窗格中右键单击类,然后选择“运行'NoteTest'”:

单元测试是这样执行的:

如您所见,我们成功地将我们的Note插入到数据库中。现在,在我们创建了第一个单元测试之后,我们将创建我们的第一个仪器测试。但在我们这样做之前,让我们解释一下单元测试和仪器测试之间的区别。仪器测试在设备或模拟器上运行。当您需要测试依赖于 Android 上下文的代码时,可以使用它们。让我们测试我们的主服务。在仪器测试的root包中创建一个名为MainServiceTest的新类,如下所示:

    package com.journaler 

    import android.content.ComponentName 
    import android.content.Context 
    import android.content.Intent 
    import android.content.ServiceConnection 
    import android.os.IBinder 
    import android.support.test.InstrumentationRegistry 
    import android.util.Log 
    import com.journaler.service.MainService 
    import org.junit.After 
    import org.junit.Before 
    import org.junit.Test 
    import kotlin.test.assertNotNull 

    class MainServiceTest { 

      private var ctx: Context? = null 
      private val tag = "Main service test" 

      private val serviceConnection = object : ServiceConnection { 
        override fun onServiceConnected(p0: ComponentName?, binder:
        IBinder?) { 
          Log.v(tag, "Service connected") 
        } 

        override fun onServiceDisconnected(p0: ComponentName?) { 
          Log.v(tag, "Service disconnected") 
        } 
     } 

     @Before 
     fun beforeMainServiceTest() { 
        Log.v(tag, "Starting") 
        ctx = InstrumentationRegistry.getInstrumentation().context 
     } 

     @Test 
     fun testMainService() { 
        Log.v(tag, "Running") 
        assertNotNull(ctx) 
        val serviceIntent = Intent(ctx, MainService::class.java) 
        ctx?.startService(serviceIntent) 
        val result = ctx?.bindService( 
           serviceIntent, 
           serviceConnection, 
           android.content.Context.BIND_AUTO_CREATE 
        ) 
        assert(result != null && result) 
     } 

     @After 
     fun afterMainServiceTest() { 
       Log.v(tag, "Finishing") 
       ctx?.unbindService(serviceConnection) 
       val serviceIntent = Intent(ctx, MainService::class.java) 
       ctx?.stopService(serviceIntent) 
    } 

   } 

要运行它,请创建一个新的配置,如下面的截图所示:

运行新创建的配置。您将被要求选择 Android 设备或模拟器实例,以在其上运行测试:

等待测试执行。恭喜!您已成功创建并运行了仪器测试。现在,为了练习,尽可能定义多个测试,以覆盖应用程序的所有代码。注意测试应该是单元测试还是仪器测试。

使用测试套件

测试套件是一组测试。我们将向您展示如何创建测试集合。创建一个测试来代表集合的容器。让我们称之为MainSuite

    package com.journaler 

    import org.junit.runner.RunWith 
    import org.junit.runners.Suite 

    @RunWith(Suite::class) 
    @Suite.SuiteClasses( 
        DummyTest::class, 
        MainServiceTest::class 
    ) 
    class MainSuite  

重复我们在示例中为仪器测试所做的步骤来运行你的测试套件。

如何测试 UI

测试 UI 可以帮助我们防止用户发现意外情况、使应用崩溃或性能不佳。我们强烈建议您编写 UI 测试,以确保您的 UI 表现如预期。为此,我们将介绍 Espresso 框架。

首先,我们将添加以下依赖项:

    ... 
    compile 'com.android.support.test.espresso:espresso-core:2.2.2' 
    androidTestCompile 'com.android.support.test.espresso:espresso-
    core:2.2.2' 
    ... 

在编写和运行 Espresso 测试之前,在测试设备上禁用动画,因为这会影响测试、预期时间和行为。我们强烈建议您这样做!在您的设备上,转到设置|开发者选项|并关闭以下选项:

  • 窗口动画比例

  • 过渡动画比例

  • 动画器持续时间比例

现在您已经准备好编写 Espresso 测试了。学习 Espresso 框架可能需要一些努力。对您来说可能会耗费一些时间,但它的好处将是巨大的!让我们来看一个 Espresso 测试的示例:

    @RunWith(AndroidJUnit4::class) 
    class MainScreenTest { 
       @Rule 
       val mainActivityRule =   
       ActivityTestRule(MainActivity::class.java) 

       @Test 
       fun testMainActivity(){ 
        onView((withId(R.id.toolbar))).perform(click()) 
        onView(withText("My dialog")).check(matches(isDisplayed())) 
      } 

   } 

我们已经确定我们将测试MainActivity类。在测试触发工具栏按钮点击后,我们检查对话框是否存在。我们通过检查标签可用性--"My dialog"来做到这一点。学习整个 Espresso 框架超出了本书的范围,但至少我们给了您一些可能性的提示。花些时间学习它,因为它肯定会帮助您!

运行测试

我们已经通过 Android Studio 执行了我们的测试。但是,一旦您编写了所有测试,您将希望一次运行它们所有。您可以为所有构建变体运行所有单元测试,但只能为特定风格或构建类型运行。插装测试也是如此。我们将向您展示使用 Journaler 应用程序的现有构建变体来执行此操作的几个示例。

运行单元测试

打开终端并导航到项目的root包。要运行所有单元测试,请执行以下命令行:

$ ./gtradlew test

这将运行我们编写的所有单元测试。测试将失败,因为NoteTest使用内容提供程序。为此,需要使用适当的Runner类来执行。默认情况下,Android Studio 会这样做。但是,由于这是一个单元测试,并且我们是从终端执行它,测试将失败。您会同意这个测试实际上是必须考虑为插装测试,因为它使用了 Android 框架组件。通常做法是,如果您的类依赖于 Android 框架组件,它必须作为插装测试来执行。因此,我们将NoteTest移动到插装测试目录中。现在我们没有任何单元测试。至少创建一个不依赖于 Android 框架组件的单元测试。您可以将现有的DummyTest移动到单元测试文件夹中以实现这一目的。从您的 IDE 中拖放它,并使用相同的命令重新运行测试。

要运行构建变体的所有测试,请执行以下命令行:

$ ./gradlew testCompleteDebug 

我们为Complete风格和Debug构建类型执行测试。

运行插装测试

要运行所有插装测试,请使用以下命令行:

$ ./gradlew connectedAndroidTest 

它的前提是已连接设备或正在运行的模拟器。如果有多台设备或模拟器存在,它们都将运行测试。

要运行构建变体的插装测试,请使用以下命令行:

$ ./gradlew connectedCompleteDebugAndroidTest 

这将触发Connected风格的所有插装测试,使用Debug构建类型。

总结

在本章中,我们学习了如何为我们的应用程序编写和运行测试。这是迈向生产的又一步。我们建立了一个书写良好且无 bug 的产品。很快,我们将实际发布它。请耐心等待,因为那一刻即将到来!

第十五章:迁移到 Kotlin

如果您有一个遗留项目或要迁移到 Kotlin 的现有 Java 模块,迁移应该很容易。做到这一点的人已经考虑到了这一点。正如您记得的,Kotlin 是可互操作的。因此,一些模块不需要完全迁移;相反,它们可以包含在 Kotlin 项目中。这取决于您的决定。因此,让我们准备好进行迁移!

在本章中,我们将涵盖以下主题:

  • 准备迁移

  • 转换类

  • 重构和清理

准备迁移

正如我们所说,我们需要决定是否完全将我们的模块重写为 Kotlin,还是继续用 Kotlin 编写代码,但保留其在纯 Java 中的遗留。我们会怎么做?在本章中,我们将展示一点点。

在这一点上,我们的当前项目没有任何需要迁移的内容。因此,我们将创建一些代码。如果您没有具有包结构的 Java 源目录,请创建它。现在,添加以下包:

  • activity

  • model

这些包等同于我们已经在 Kotlin 源代码中拥有的包。在activity包中,添加以下类:

  • MigrationActivity.java代码如下:
       package com.journaler.activity; 

       import android.os.Bundle; 
       import android.support.annotation.Nullable; 
       import android.support.v7.app.AppCompatActivity; 

       import com.journaler.R; 

       public class MigrationActivity extends AppCompatActivity { 

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

        @Override 
        protected void onResume() { 
          super.onResume(); 
        } 
       }
  • MigrationActivity2.java:确保其实现与MigrationActivity.java完全相同。我们只需要一些代码基础来展示和迁移。

在 Android manifest文件中注册两个活动,如下所示:

        <manifest xmlns:android=
        "http://schemas.android.com/apk/res/android" 
        package="com.journaler"> 
        ... 
        <application 
         ... 
        > 
        ... 
         <activity 
            android:name=".activity.MainActivity" 
            android:configChanges="orientation" 
            android:screenOrientation="portrait"> 
            <intent-filter> 
              <action android:name="android.intent.action.MAIN" /> 
              <category android:name=
              "android.intent.category.LAUNCHER" /> 
            </intent-filter> 
         </activity> 

         <activity 
            android:name=".activity.NoteActivity" 
            android:configChanges="orientation" 
            android:screenOrientation="portrait" /> 

         <activity 
            android:name=".activity.TodoActivity" 
            android:configChanges="orientation" 
            android:screenOrientation="portrait" /> 

         <activity 
            android:name=".activity.MigrationActivity" 
            android:configChanges="orientation" 
            android:screenOrientation="portrait" /> 

         <activity 
            android:name=".activity.MigrationActivity2" 
            android:configChanges="orientation" 
            android:screenOrientation="portrait" /> 
        </application> 

      </manifest> 

正如您所看到的,Java 代码与 Kotlin 代码一起使用没有任何问题。您的 Android 项目可以同时使用两者!现在,请考虑一下,您是否真的需要进行任何转换,还是您愿意保留现有的 Java 内容?让我们在model包中添加类:

  • Dummy.java代码如下:
        package com.journaler.model; 

        public class Dummy { 

          private String title; 
          private String content; 

          public Dummy(String title) { 
            this.title = title; 
          } 

          public Dummy(String title, String content) { 
            this.title = title; 
            this.content = content; 
          } 

          public String getTitle() { 
            return title; 
          } 

          public void setTitle(String title) { 
            this.title = title; 
          } 

          public String getContent() { 
            return content; 
          } 

         public void setContent(String content) { 
           this.content = content; 
         } 

       } 
  • Dummy2.java代码如下:
        package com.journaler.model; 

        import android.os.Parcel; 
        import android.os.Parcelable; 

        public class Dummy2 implements Parcelable { 

          private int count; 
          private float result; 

          public Dummy2(int count) { 
            this.count = count; 
            this.result = count * 100; 
         } 

         public Dummy2(Parcel in) { 
           count = in.readInt(); 
           result = in.readFloat(); 
         } 

         public static final Creator<Dummy2>
         CREATOR = new Creator<Dummy2>() { 
           @Override 
           public Dummy2 createFromParcel(Parcel in) { 
             return new Dummy2(in); 
           } 

           @Override 
           public Dummy2[] newArray(int size) { 
             return new Dummy2[size]; 
           } 
         }; 

         @Override 
         public void writeToParcel(Parcel parcel, int i) { 
           parcel.writeInt(count); 
           parcel.writeFloat(result); 
         } 

         @Override 
         public int describeContents() { 
           return 0; 
         } 

         public int getCount() { 
           return count; 
         } 

         public float getResult() { 
           return result; 
         } 
       }

让我们再次检查项目的 Kotlin 部分是否看到了这些类。在您的 Kotlin 源目录的根目录中创建一个新的.kt文件。让我们称之为kotlin_calls_java.kt

    package com.journaler 

    import android.content.Context 
    import android.content.Intent 
    import com.journaler.activity.MigrationActivity 
    import com.journaler.model.Dummy2 

    fun kotlinCallsJava(ctx: Context) { 

      /** 
      * We access Java class and instantiate it. 
      */ 
      val dummy = Dummy2(10) 

      /** 
      * We use Android related Java code with no problems as well. 
      */ 
       val intent = Intent(ctx, MigrationActivity::class.java) 
       intent.putExtra("dummy", dummy) 
       ctx.startActivity(intent) 

    } 

正如您所看到的,Kotlin 在使用 Java 代码时没有任何问题。因此,如果您仍然希望进行迁移,您可以这样做。没问题。我们将在接下来的章节中这样做。

危险信号

将庞大和复杂的 Java 类转换为 Kotlin 仍然是一个可选项。无论如何,提供适当的单元测试或仪器测试,以便在转换后重新测试这些类的功能。如果您的任何测试失败,请仔细检查失败的原因。

您想要迁移的类可以通过以下两种方式进行迁移:

  • 自动转换

  • 手动重写

在处理庞大和复杂的类时,这两种方法都可能会带来一些缺点。完全自动转换有时会给您带来不太美观的代码。因此,在完成后,您应该重新检查和重新格式化一些内容。第二个选项可能会花费您很多时间。

结论-您始终可以使用原始的 Java 代码。从您将 Kotlin 作为主要语言开始,您可以用 Kotlin 编写所有新的东西。

更新依赖关系

如果您将 Android 项目的 100%纯 Java 代码切换到 Kotlin,您必须从头开始。这意味着您的第一个迁移工作将是更新您的依赖关系。您必须更改build.gradle配置,以便识别 Kotlin 并使源代码路径可用。我们已经在第一章中解释了如何在开始 Android中设置 Gradle 部分; 因此,如果您的项目中没有与 Kotlin 相关的配置,您必须提供它。

让我们回顾一下我们的 Gradle 配置:

  • build.gradle根项目代表了主build.gradle文件,如下所示:
        buildscript { 
          repositories { 
            jcenter() 
            mavenCentral() 
          } 
          dependencies { 
            classpath 'com.android.tools.build:gradle:2.3.3' 
            classpath 'org.jetbrains.kotlin:kotlin-gradle-
            plugin:1.1.51' 
          } 
       } 

      repositories { 
       jcenter() 
       mavenCentral() 
      }
  • 主应用程序build.gradle解决了应用程序的所有依赖关系,如下所示:
        apply plugin: "com.android.application" 
        apply plugin: "kotlin-android" 
        apply plugin: "kotlin-android-extensions" 

        repositories { 
          maven { url "https://maven.google.com" } 
        } 

        android { 
         ... 
         sourceSets { 
          main.java.srcDirs += [ 
                'src/main/kotlin', 
                'src/common/kotlin', 
                'src/debug/kotlin', 
                'src/release/kotlin', 
                'src/staging/kotlin', 
                'src/preproduction/kotlin', 
                'src/debug/java', 
                'src/release/java', 
                'src/staging/java', 
                'src/preproduction/java', 
                'src/testDebug/java', 
                'src/testDebug/kotlin', 
                'src/androidTestDebug/java', 
                'src/androidTestDebug/kotlin' 
           ] 
          } 
          ... 
          } 
         ... 
        } 

        repositories { 
          jcenter() 
          mavenCentral() 
        } 

        dependencies { 
          compile "org.jetbrains.kotlin:kotlin-reflect:1.1.51" 
          compile "org.jetbrains.kotlin:kotlin-stdlib:1.1.51" 
           ... 
          compile "com.github.salomonbrys.kotson:kotson:2.3.0" 
            ... 

          compile "junit:junit:4.12" 
          testCompile "junit:junit:4.12" 

          testCompile "org.jetbrains.kotlin:kotlin-reflect:1.1.51" 
          testCompile "org.jetbrains.kotlin:kotlin-stdlib:1.1.51" 

          compile "org.jetbrains.kotlin:kotlin-test:1.1.51" 
          testCompile "org.jetbrains.kotlin:kotlin-test:1.1.51" 

          compile "org.jetbrains.kotlin:kotlin-test-junit:1.1.51" 
          testCompile "org.jetbrains.kotlin:kotlin-test-junit:1.1.51" 
          ... 
        }

这些都是您应该满足的与 Kotlin 相关的依赖关系。其中之一是 Kotson,为Gson库提供 Kotlin 绑定。

转换类

最后,我们将迁移我们的类。我们有两种自动选项可用。我们将两种都使用。找到MigrationActivity.java并打开它。选择代码 | 将 Java 文件转换为Kotlin文件。转换需要几秒钟。现在,将文件从Java包拖放到Kotlin源包中。观察以下源代码:

    package com.journaler.activity 

    import android.os.Bundle 
    import android.support.v7.app.AppCompatActivity 

    import com.journaler.R 

    class MigrationActivity : AppCompatActivity() { 

      override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        setContentView(R.layout.activity_main) 
      } 

      override fun onResume() { 
        super.onResume() 
      } 

    } 

正如我们所提到的,完全自动转换并不能得到完美的代码。在下一节中,我们将进行重构和清理。完成相同操作的第二种方法是将 Java 代码复制粘贴到Kotlin文件中。从MigrationActivity2中复制所有源代码。创建一个同名的新 Kotlin 类并粘贴代码。如果被询问,确认你希望执行自动转换。代码出现后,删除该类的 Java 版本。观察到源代码与迁移后的MigrationActivity类相同。

DummyDummy2类重复这两种方法。你得到的类将看起来像这样:

  • Dummy,第一个Dummy类示例:
       package com.journaler.model 

       class Dummy { 

         var title: String? = null 
         var content: String? = null 

         constructor(title: String) { 
           this.title = title 
         } 

         constructor(title: String, content: String) { 
           this.title = title 
           this.content = content 
        } 

      } 
  • Dummy2,第二个Dummy类示例:
        package com.journaler.model 

        import android.os.Parcel 
        import android.os.Parcelable 

        class Dummy2 : Parcelable { 

          var count: Int = 0 
          private set 
          var result: Float = 0.toFloat() 
          private set 

          constructor(count: Int) { 
            this.count = count 
            this.result = (count * 100).toFloat() 
          } 

          constructor(`in`: Parcel) { 
            count = `in`.readInt() 
            result = `in`.readFloat() 
          } 

          override fun writeToParcel(parcel: Parcel, i: Int) { 
            parcel.writeInt(count) 
            parcel.writeFloat(result) 
          } 

          override fun describeContents(): Int { 
            return 0 
          } 

         companion object { 

           val CREATOR: Parcelable.Creator<Dummy2>
           = object : Parcelable.Creator<Dummy2> { 
              override fun createFromParcel(`in`: Parcel): Dummy2 { 
                return Dummy2(`in`) 
            } 

           override fun newArray(size: Int): Array<Dummy2> { 
              return arrayOfNulls(size) 
            } 
          } 
        } 

    } 

Dummy2类在转换时出现了问题。在这种情况下,你必须自己修复它。修复源代码。问题发生在以下行:

    override fun newArray(size: Int): Array<Dummy2> { ... 

通过将类型从Array<Dummy2> int Array<Dummy2?>进行切换来修复它,如下所示:

    override fun newArsray(size: Int): Array<Dummy2?> { ... 

简单!

这正是你在进行迁移时可能会面临的挑战!显而易见的是,在DummyDummy2类中,我们通过切换到 Kotlin 显著减少了代码库。由于不再有 Java 实现,我们可以进行重构和清理。

重构和清理

为了在转换后获得最佳可能的代码,我们必须进行重构和清理。我们将调整我们的代码库以符合 Kotlin 的标准和习惯用法。为此,你必须全面阅读它。只有在这样做之后,我们才能认为我们的迁移完成了!

打开你的类并阅读代码。有很多改进的空间!在你做一些工作之后,你应该得到类似这样的结果:

MigrationActivity的代码如下:

    ... 
    override fun onResume() = super.onResume() 
    ... 

正如你所看到的,对于MigrationActivity(和MigrationActivity2)来说,并没有太多的工作。这两个类都非常小。对于DummyDummy2这样的类,预计需要更大的努力:

  • Dummy类的代码如下:
        package com.journaler.model 

        class Dummy( 
          var title: String, 
          var content: String 
          ) { 

            constructor(title: String) : this(title, "") { 
            this.title = title 
           } 

       } 
  • Dummy2类的代码如下:
        package com.journaler.model 

        import android.os.Parcel 
        import android.os.Parcelable 

        class Dummy2( 
          private var count: Int 
        ) : Parcelable { 

          companion object { 
            val CREATOR: Parcelable.Creator<Dummy2> 
            = object : Parcelable.Creator<Dummy2> { 
              override fun createFromParcel(`in`: Parcel): 
              Dummy2 = Dummy2(`in`) 
              override fun newArray(size: Int): Array<Dummy2?> =
              arrayOfNulls(size) 
            }    
          } 

         private var result: Float = (count * 100).toFloat() 

         constructor(`in`: Parcel) : this(`in`.readInt()) 

         override fun writeToParcel(parcel: Parcel, i: Int) { 
           parcel.writeInt(count) 
         } 

         override fun describeContents() = 0 

        } 

这两个类版本在重构后与它们最初的 Kotlin 版本相比,现在得到了极大的改进。试着将当前版本与我们最初的 Java 代码进行比较。你觉得呢?

总结

在本章中,我们发现了迁移到 Kotlin 编程语言的秘密。我们演示了技术并提供了如何进行迁移以及何时进行迁移的建议。幸运的是,对我们来说,这似乎并不难!下一章将是我们的最后一章,所以,正如你已经知道的,是时候将我们的应用发布到世界上了!

第十六章:部署您的应用程序

是时候让世界看到您的作品了。在我们发布之前还有一些事情要做。我们将做一些准备工作,然后最终将我们的应用程序发布到 Google Play 商店。

在本章中,我们将熟悉以下主题:

  • 准备部署

  • 代码混淆

  • 签署您的应用程序

  • 发布到 Google Play

准备部署

在发布您的应用程序之前,需要做一些准备工作。首先,删除任何未使用的资源或类。然后,关闭您的日志记录!使用一些主流的日志记录库是一个好习惯。您可以围绕Log类创建一个包装器,并且对于每个日志输出都有一个条件,检查它必须不是release构建类型。

如果您尚未将发布配置设置为可调试,请按照以下步骤操作:

    ... 
    buildTypes { 
      ... 
      release { 
        debuggable false 
      } 
    } 
    ...

完成后,请再次检查您的清单并进行清理。删除您不再需要的任何权限。在我们的情况下,我们将删除这个:

    <uses-permission android:name="android.permission.VIBRATE" /> 

我们添加了它,但从未使用过。我们要做的最后一件事是检查应用程序的兼容性。检查最小和最大 SDK 版本是否符合您的设备定位计划。

代码混淆

发布到 Google Play

    ... 
    buildTypes { 
      ... 
      release { 
        debuggable false 
        minifyEnabled true 
        proguardFiles getDefaultProguardFile('proguard-android.txt'),
         'proguard-rules.pro' 
      } 
    } 
    ... 

我们刚刚添加的配置将缩小资源并执行混淆。对于混淆,我们将使用 ProGuard。ProGuard 是一个免费的 Java 类文件缩小器,优化器,混淆器和预验证器。它执行检测未使用的类,字段,方法和属性。它还优化了字节码!

在大多数情况下,默认的 ProGuard 配置(我们使用的那个)足以删除所有未使用的代码。但是,ProGuard 可能会删除您的应用程序实际需要的代码!出于这个目的,您必须定义 ProGuard 配置以保留这些类。打开项目的 ProGuard 配置文件并追加以下内容:

    -keep public class MyClass 

以下是使用某些库时需要添加的 ProGuard 指令列表:

  • Retorfit:
        -dontwarn retrofit.** 
        -keep class retrofit.** { *; } 
        -keepattributes Signature 
        -keepattributes Exceptions 
  • 下一步是启用代码混淆。打开您的build.gradle配置并更新如下:
        -keepattributes Signature 
        -keepattributes *Annotation* 
        -keep class okhttp3.** { *; } 
        -keep interface okhttp3.** { *; } 
        -dontwarn okhttp3.** 
        -dontnote okhttp3.** 

        # Okio 
        -keep class sun.misc.Unsafe { *; } 
        -dontwarn java.nio.file.* 
        -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement 
  • Gson:
        -keep class sun.misc.Unsafe { *; } 
        -keep class com.google.gson.stream.** { *; } 

使用这些行更新您的proguard-rules.pro文件。

签署您的应用程序

在将发布上传到 Google Play 商店之前的最后一步是生成已签名的 APK。打开您的项目并选择构建|生成已签名的 APK:

选择主应用程序模块,然后继续单击“下一步”:

Okhttp3:

由于我们还没有密钥库,我们将创建一个新的。点击“创建新...”如下:

填充数据并单击“确定”。单击“下一步”,如果需要,输入您的主密码。检查两个签名并选择完整的口味进行构建。单击“完成”:

等待构建准备就绪。我们还将更新我们的build.gradle,以便每次构建发布时都进行签名:

    ... 
    android { 
      signingConfigs { 
        release { 
          storeFile file("Releasing/keystore.jks") 
          storePassword "1234567" 
          keyAlias "key0" 
          keyPassword "1234567" 
        } 
      } 
      release { 
        debuggable false 
        minifyEnabled false 
        signingConfig signingConfigs.release 
        proguardFiles getDefaultProguardFile('proguard-android.txt'),
        'proguard-rules.pro' 
      } 
    } 
    ... 

如果对您来说更容易,您可以按照以下步骤从终端运行构建过程:

$ ./gradlew clean 
$ ./gradlew assembleCompleteRelease 

在本例中,我们为完整的应用程序口味组装了发布版本。

部署的最后一步将是发布已签名的发布 APK。除了 APK,我们还需要提供一些其他东西:

  • 屏幕截图-从您的应用程序准备屏幕截图。您可以通过以下方式完成:从 Android Studio Logcat,单击屏幕截图图标(一个小相机图标)。从预览窗口,单击保存。将要求您保存图像:

  • 具有以下规格的高分辨率图标:

32 位 PNG 图像(带 Alpha)

512 像素乘以 512 像素的尺寸

1024K 最大文件大小

  • 功能图形(应用程序的主横幅):

JPEG 图像或 24 位 PNG(无 Alpha!)

1024 像素乘以 500 像素的尺寸

  • 如果您将应用程序发布为电视应用程序或电视横幅:

JPEG 图像或 24 位的 PNG(不带 alpha!)

1280p x 720px 的尺寸

  • 促销视频--YouTube 视频(不是播放列表)

  • 您的应用程序的文本描述

登录到开发者控制台(play.google.com/apps/publish)。

如果您尚未注册,请注册。这将使您能够发布您的应用程序。主控制台页面显示如下:

我们还没有发布任何应用程序。点击“在 Google Play 上发布 Android 应用程序”。将出现一个创建应用程序对话框。填写数据,然后点击“创建”按钮:

填写表单数据如下:

按照以下方式上传您的图形资产:

请参阅以下屏幕截图:

继续进行应用程序分类:

完成联系信息和隐私政策:

当您完成了所有必填数据后,滚动回到顶部,然后点击“保存草稿”按钮。现在从左侧选择“应用发布”。您将被带到应用发布屏幕,如下面的屏幕截图所示:

在这里,您有以下三个选项:

  • 管理生产

  • 管理测试版

  • 管理测试版

根据您计划发布的版本,选择最适合您的选项。我们将选择“管理生产”,然后点击“创建发布”按钮,如下所示:

开始填写有关您发布的数据:

首先,添加您最近生成的 APK。然后继续到页面底部,填写表单的其余部分。完成后,点击“审核”按钮以审核您的应用程序发布:

在将我们的发布推向生产之前,点击左侧的内容评级链接,然后点击继续,如下面的屏幕截图所示:

填写您的电子邮件地址并滚动到页面的底部。选择您的类别:

我们选择 UTILITY,PRODUCTIVITY,COMMUNICATION,OR OTHER;在下一个屏幕上,填写您被要求的信息,如下所示:

保存您的问卷,并点击“应用评级”:

现在切换到定价和分发部分:

这个表格很容易填写。按照表格设置您被要求的数据。完成后,点击屏幕顶部的保存草稿按钮。您会看到“准备发布”链接已经出现。点击它:

点击“管理发布”,如前面的屏幕截图所示。按照屏幕的指引,直到您到达应用发布部分的最后一个屏幕。现在您可以清楚地看到“开始推出到生产”按钮已启用。点击它,当被询问时,点击“确认”:

继续:

就是这样!您已成功将您的应用程序发布到 Google Play 商店!

总结

希望您喜欢这本书!这是一次伟大的旅程!我们从零开始,从学习基础知识开始。然后,我们继续学习关于 Android 的中级,困难和高级主题。这一章让我们对我们想要告诉您的关于 Android 的故事有了最后的总结。我们做了大量的工作!我们开发了应用程序,并逐步完成了整个部署过程。

接下来呢?嗯,你接下来应该做的事情是考虑一个你想要构建的应用程序,并从零开始着手制作它。花点时间。不要着急!在开发过程中,你会发现很多我们没有提到的东西。安卓系统非常庞大!要了解整个框架可能需要几年的时间。许多开发者并不了解它的每一个部分。你不会是唯一一个。继续你的进步,尽可能多地编写代码。这将提高你的技能,并使你学到的所有东西变得常规化。不要犹豫!投入行动吧!祝你好运!

posted @ 2024-05-22 15:13  绝不原创的飞龙  阅读(38)  评论(0编辑  收藏  举报