如何使用-Kotlin-构建安卓应用-全-

如何使用 Kotlin 构建安卓应用(全)

原文:zh.annas-archive.org/md5/AFA545AAAFDFD0BBAD98F56388586295

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于本书

Android 在过去十年一直统治着应用市场,开发者们越来越多地希望开始构建自己的 Android 应用程序。使用 Kotlin 构建 Android 应用程序从 Android 开发的基础知识开始,教你如何使用 Android Studio(Android 的集成开发环境)和 Kotlin 编程语言进行应用程序开发。然后,你将学习如何通过引导式练习创建应用程序并在虚拟设备上运行。你将学习 Android 开发的基础知识,从应用程序结构到使用 Activities 和 Fragments 构建 UI 以及各种导航模式。随着章节的进行,你将深入了解 Android 的 RecyclerView,以充分利用显示数据列表,并熟悉从 Web 服务获取数据和处理图像。然后,你将学习地图、位置服务和权限模型,然后处理通知和数据持久化。接下来,你将掌握测试,涵盖测试金字塔的全部范围。你还将学习如何使用 AAC(Android 架构组件)来清晰地构建你的代码,并探索各种架构模式和依赖注入的好处。异步编程的核心库 RxJava 和 Coroutines 也被涵盖在内。然后重点回到 UI,演示用户与应用程序交互时如何添加动作和过渡效果。最后,你将构建一个有趣的应用程序,从电影数据库中检索并显示热门电影,然后学习如何在 Google Play 上发布你的应用程序。通过本书的学习,你将具备使用 Kotlin 构建完整的 Android 应用程序所需的技能和信心。

关于作者

Alex Forrester是一名经验丰富的软件开发者,拥有超过 20 年的移动、Web 开发和内容管理系统开发经验。他在 Android 领域工作了 8 年以上,在 Sky、The Automobile Association、HSBC、The Discovery Channel 和 O2 等著名公司开发了旗舰应用。Alex 和妻女住在赫特福德郡。在不开发软件的时候,他喜欢橄榄球和在 Chiltern 山丘上跑步。

Eran Boudjnah是一名拥有超过 20 年开发桌面应用程序、网站、互动景点和移动应用程序经验的开发者。他在 Android 领域工作了大约 7 年,为各种客户开发应用程序并领导移动团队,从初创公司(JustEat)到大型公司(Sky)和企业集团。他热衷于桌游(拥有数百款游戏的收藏)并且有一套他非常自豪的变形金刚收藏品。Eran 和妻子 Lea 住在伦敦北部。

Alexandru Dumbravan于 2011 年开始从事 Android 开发,在一家数字代理公司工作。2016 年,他搬到伦敦,在金融科技领域工作。在职业生涯中,他有机会分析和集成许多不同的技术到 Android 设备上,从像 Facebook 登录这样的知名应用到像专有网络协议这样的不太知名的技术。

Jomar Tigcal是一名拥有超过 10 年移动和软件开发经验的 Android 开发者。他曾在小型初创公司和大型公司的应用开发的各个阶段工作过。Jomar 还曾就 Android 进行讲座和培训,并举办过相关的工作坊。在业余时间,他喜欢跑步和阅读。他和妻子 Celine 住在加拿大温哥华。

受众

如果你想使用 Kotlin 构建自己的 Android 应用程序,但不确定如何开始,那么这本书适合你。对 Kotlin 编程语言的基本理解将帮助你更快地掌握本书涵盖的主题。

关于章节

第一章,创建您的第一个应用程序,展示了如何使用 Android Studio 构建您的第一个 Android 应用程序。在这里,您将创建一个 Android Studio 项目,并了解其组成部分,并探索构建和部署应用程序到虚拟设备所需的工具。您还将了解 Android 应用程序的结构。

第二章,构建用户屏幕流程,深入探讨了 Android 生态系统和 Android 应用程序的构建模块。将介绍活动及其生命周期、意图和任务等概念,以及恢复状态和在屏幕或活动之间传递数据。

第三章,使用片段开发 UI,教您如何使用片段来构建 Android 应用程序的用户界面的基础知识。您将学习如何以多种方式使用片段来为手机和平板电脑构建应用程序布局,包括使用 Jetpack Navigation 组件。

第四章,构建应用程序导航,介绍了应用程序中不同类型的导航。您将了解具有滑动布局的导航抽屉、底部导航和选项卡导航。

第五章,Essential Libraries: Retrofit, Moshi, and Glide,为您提供了如何构建可以使用 Retrofit 库和 Moshi 库从远程数据源获取数据的应用程序的见解,并将数据转换为 Kotlin 对象。您还将了解 Glide 库,它可以将远程图像加载到您的应用程序中。

第六章,RecyclerView,介绍了使用 RecyclerView 小部件构建列表并显示列表的概念。

第七章,Android 权限和 Google 地图,介绍了权限的概念以及如何向用户请求权限,以便您的应用程序执行特定任务,并向您介绍了地图 API。

第八章,服务、WorkManager 和通知,详细介绍了 Android 应用程序中后台工作的概念,以及如何使您的应用程序以对用户不可见的方式执行某些任务,以及如何显示此工作的通知。

第九章,使用 JUnit、Mockito 和 Espresso 进行单元测试和集成测试,教您了解 Android 应用程序的不同类型的测试,每种测试所使用的框架,以及测试驱动开发的概念。

第十章,Android 架构组件,深入了解了来自 Android Jetpack 库的组件,如 LiveData 和 ViewModel,这些组件可以帮助您构建代码,以及 Room,它允许您在设备上持久保存数据到数据库中。

第十一章,数据持久化,向您展示了在设备上存储数据的各种方式,从 SharedPreferences 到文件。还将介绍存储库的概念,让您了解如何在不同层次上构建应用程序。

第十二章,使用 Dagger 和 Koin 进行依赖注入,解释了依赖注入的概念及其对应用程序的好处。介绍了 Dagger 和 Koin 等框架,以帮助您管理依赖关系。

第十三章,RxJava 和 Coroutines,向您介绍了如何使用 RxJava 和 Coroutines 进行后台操作和数据操作。您还将学习如何使用 RxJava 操作符和 LiveData 转换来操作和显示数据。

第十四章,架构模式,解释了您可以使用的架构模式,将 Android 项目结构化为具有不同功能的不同组件。这使您更容易开发、测试和维护您的代码。

第十五章,使用 CoordinatorLayout 和 MotionLayout 进行动画和过渡,讨论了如何使用 CoordinatorLayout 和 MotionLayout 增强您的应用程序的动画和过渡。

第十六章,在 Google Play 上发布您的应用程序,通过展示如何在 Google Play 上发布您的应用程序来结束本书:从准备发布到创建 Google Play 开发者帐户,最终发布您的应用程序。

约定

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:

“您可以在MyApplication | app | src | main主项目窗口下找到它。”

一块代码设置如下:

<resources>
    <string name="app_name">My Application</string>
</resources>

在某些情况下,重要的代码行会被突出显示。这些情况如下所示:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">My Application</string>
    <string name="first_name_text">First name:</string>
    <string name="last_name_text">Last name:</string>
</resources>

屏幕上显示的文字,例如菜单或对话框中的文字,也会在文本中出现,如:“单击完成,您的虚拟设备将被创建。”

新术语和重要单词显示如下:“这是官方的集成开发环境IDE)用于 Android 开发,构建在 JetBrains 的IntelliJ IDEA 软件上,并由 Google 的 Android Studio 团队开发。”

开始之前

每次伟大的旅程都始于一小步。在我们可以在 Android 上做出色的事情之前,我们需要准备一个高效的环境。在本节中,我们将看到如何做到这一点。

最低硬件要求

为了获得最佳的学习体验,我们建议以下硬件配置:

  • 处理器:Intel Core i5 或同等或更高

  • 内存:最低 4GB RAM;建议 8GB RAM

  • 存储:4GB 可用空间

软件要求

您还需要预先安装以下软件:

  • 操作系统:Windows 7 SP1 64 位,Windows 8.1 64 位或 Windows 10 64 位,macOS 或 Linux

  • Android Studio 4.1 或更高版本

安装和设置

在开始阅读本书之前,您需要安装 Android Studio 4.1(或更高版本),这是您将在整个章节中使用的主要工具。您可以从 https://developer.android.com/studio 下载 Android Studio。

在 macOS 上,启动 DMG 文件,将 Android Studio 拖放到“应用程序”文件夹中。完成后,打开 Android Studio。在 Windows 上,启动 EXE 文件。如果您使用 Linux,请将 ZIP 文件解压缩到您喜欢的位置。打开终端并导航到android-studio/bin/目录,执行studio.sh。如果看到“导入设置”对话框弹出,请选择“不导入设置”,然后单击“确定”按钮(通常在之前安装了 Android Studio 时会出现):

图 0.1:导入设置对话框

图 0.1:导入设置对话框

接下来,将弹出“数据共享”对话框;单击“不发送”按钮以禁用向 Google 发送匿名使用数据:

图 0.2:数据共享对话框

图 0.2:数据共享对话框

在“欢迎”对话框中,单击“下一步”按钮开始设置:

图 0.3:欢迎对话框

图 0.3:欢迎对话框

在“安装类型”对话框中,选择“标准”以安装推荐的设置。然后,单击“下一步”按钮:

图 0.4:安装类型对话框

图 0.4:安装类型对话框

在“选择 UI 主题”对话框中,选择您喜欢的 IDE 主题—“浅色”或“德拉库拉”(暗色主题)—然后单击“下一步”按钮:

图 0.5:选择 UI 主题对话框

图 0.5:选择 UI 主题对话框

在“验证设置”对话框中,查看您的设置,然后单击“完成”按钮。设置向导会下载并安装其他组件,包括 Android SDK:

图 0.6:验证设置对话框

图 0.6:验证设置对话框

下载完成后,您可以单击“完成”按钮。现在,您已经准备好创建 Android 项目了。

安装代码包

您可以从 GitHub 上下载代码文件和活动解决方案,网址为 https://github.com/PacktPublishing/How-to-Build-Android-Apps-with-Kotlin。参考这些代码文件获取完整的代码包。

保持联系

我们始终欢迎读者的反馈。

customercare@packtpub.com

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在本书中发现了错误,我们将不胜感激,如果您能向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

copyright@packt.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书做出贡献,请访问 authors.packtpub.com。

请留下评论

通过在亚马逊上留下详细、公正的评论,让我们知道您的想法。我们感激所有的反馈 - 它帮助我们继续制作优秀的产品,并帮助有抱负的开发人员提升他们的技能。请花几分钟时间分享您的想法 - 这对我们有很大的影响。

第一章:创建您的第一个应用

概述

本章是 Android 的介绍,您将设置您的环境并专注于 Android 开发的基础知识。通过本章的学习,您将获得创建 Android 应用程序所需的知识,并将其安装在虚拟或物理 Android 设备上。您将能够分析和理解AndroidManifest.xml文件的重要性,并使用 Gradle 构建工具来配置您的应用程序,并从 Material Design 实现 UI 元素。

介绍

Android 是世界上使用最广泛的手机操作系统,全球市场份额超过 70%(参见gs.statcounter.com/os-market-share/mobile/worldwide)。这为学习 Android 和构建具有全球影响力的应用提供了巨大的机会。对于新手 Android 开发者来说,有许多问题需要解决才能开始学习和提高生产力。本书将解决这些问题。在学习工具和开发环境之后,您将探索构建 Android 应用的基本实践。我们将涵盖开发者面临的各种现实世界开发挑战,并探索克服这些挑战的各种技术。

在本章中,您将学习如何创建一个基本的 Android 项目并为其添加功能。您将介绍 Android Studio 的全面开发环境,并了解软件的核心领域,以使您能够高效地工作。Android Studio 提供了应用程序开发的所有工具,但不提供知识。本章将指导您有效地使用软件来构建应用程序,并配置 Android 项目的最常见区域。

让我们开始创建一个 Android 项目。

使用 Android Studio 创建 Android 项目

要在构建 Android 应用方面提高生产力,熟练使用Android Studio至关重要。这是 Android 开发的官方集成开发环境IDE),建立在 JetBrains 的IntelliJ IDEA IDE上,由 Google 的 Android Studio 团队开发。您将在本课程中使用它来创建应用程序,并逐步添加更多高级功能。

Android Studio 的开发遵循了 IntelliJ IDEA IDE 的发展。当然,IDE 的基本功能都存在,使您能够通过建议、快捷方式和标准重构来优化您的代码。在本课程中,您将使用 Kotlin 来创建 Android 应用程序。自 2017 年 Google I/O(Google 的年度开发者大会)以来,这一直是 Google 首选的 Android 应用程序开发语言。Android Studio 与其他 Android 开发环境的真正区别在于Kotlin是由 JetBrains 创建的,这是 Android Studio 构建在其上的 IntelliJ IDEA 软件的公司。因此,您可以受益于 Kotlin 的成熟和不断发展的一流支持。

Kotlin 是为了解决 Java 的一些缺点而创建的,包括冗长、处理空类型和添加更多的函数式编程技术等问题。自 2017 年以来,Kotlin 一直是 Android 开发的首选语言,取代了 Java,您将在本书中使用它。

熟悉并熟悉 Android Studio 将使您有信心在 Android 应用上工作和构建。所以,让我们开始创建您的第一个项目。

注意

Android Studio 的安装和设置在前言中有介绍。请确保在继续之前已完成这些步骤。

练习 1.01:为您的应用创建 Android Studio 项目

这是创建应用程序结构的起点。模板驱动的方法将使您能够在短时间内创建一个基本项目,同时设置您可以用来开发应用程序的构建块。要完成此练习,请执行以下步骤:

注意

您将使用的 Android Studio 版本为v4.1.1(或更高)。

  1. 打开 Android Studio 后,您将看到一个窗口,询问您是要创建新项目还是打开现有项目。选择创建新项目

启动窗口将如下所示:

图 1.1:Android Studio 版本 4.1.1

图 1.1:Android Studio 版本 4.1.1

  1. 现在,您将进入一个简单的向导驱动流程,大大简化了您的第一个 Android 项目的创建。您将看到的下一个屏幕上有大量选项,用于您希望应用程序具有的初始设置:图 1.2:为您的应用程序启动项目模板

图 1.2:为您的应用程序启动项目模板

  1. 欢迎来到您对Activity的第一次介绍。在 Android 中,Activity是一个页面或屏幕。您可以从前面的屏幕上选择的选项中以不同的方式创建此初始屏幕。描述描述了应用程序的第一个屏幕将如何显示。这些是用于构建应用程序的模板。从模板中选择空白 Activity,然后单击下一步。

项目配置屏幕如下:

图 1.3:项目配置

图 1.3:项目配置

  1. 前面的屏幕配置了您的应用程序。让我们逐个浏览所有选项:

名称:与您的 Android 项目名称类似,当应用程序安装在手机上并在 Google Play 上可见时,此名称将显示为应用程序的默认名称。您可以用自己的名称替换名称字段,或者现在设置为您将要创建的应用程序。

包名称:这使用标准的反向域名模式来创建名称。它将用作应用程序中源代码和资产的地址标识符。最好使此名称尽可能清晰、描述性,并与您的应用程序的目的密切相关。因此,最好更改此名称以使用一个或多个子域(例如com.sample.shop.myshop)。如图 1.3所示,将应用程序的名称(小写并去除空格)附加到域名后面。

保存位置:这是您的计算机上的本地文件夹,应用程序最初将存储在其中。将来可以更改此位置,因此您可以保留默认设置或将其编辑为其他内容(例如Users/MyUser/android/projects)。默认位置将根据您使用的操作系统而变化。

语言 - Kotlin:这是 Google 推荐的用于 Android 应用程序开发的语言。

最低 SDK:取决于您下载的 Android Studio 版本,其默认值可能与图 1.3中显示的相同,也可能不同。保持不变。大多数 Android 的新功能都是向后兼容的,因此您的应用程序将在绝大多数旧设备上运行良好。但是,如果您想要针对新设备进行开发,您应该考虑提高最低 API 级别。有一个名为帮助我选择的链接,指向一个对话框,解释了您可以访问的功能集,以便在不同版本的 Android 上进行开发,以及全球各地运行每个 Android 版本的设备的当前百分比。

(复选框)使用传统的 android.support 库。不要选中此复选框。您将使用 AndroidX 库,这是支持库的替代品,旨在使新版本 Android 上的功能向后兼容旧版本,但它提供的远不止于此。它还包含称为 Jetpack 的新 Android 组件,正如其名称所示,它可以“增强”您的 Android 开发,并提供一系列丰富的功能,您将希望在应用程序中使用,从而简化常见操作。

一旦您填写了所有这些细节,选择完成。您的项目将被构建,然后您将看到以下屏幕或类似的屏幕:您可以立即在一个选项卡中看到已创建的活动(MainActivity),在另一个选项卡中看到用于屏幕的布局(activity_main.xml)。应用程序结构文件夹在左侧面板中。

图 1.4:Android Studio 默认项目

图 1.4:Android Studio 默认项目

在这个练习中,您已经完成了使用 Android Studio 创建您的第一个 Android 应用程序的步骤。这是一个模板驱动的方法,向您展示了您需要为应用程序配置的核心选项。

在下一节中,您将设置一个虚拟设备,并首次看到您的应用程序运行。

设置虚拟设备并运行您的应用

作为安装 Android Studio 的一部分,您下载并安装了最新的 Android SDK 组件。其中包括一个基本的模拟器,您将配置它来创建一个虚拟设备来运行 Android 应用程序。好处是您可以在开发应用程序时在桌面上进行更改并快速查看它们。虽然虚拟设备没有真实设备的所有功能,但反馈周期通常比连接真实设备的步骤更快。

另外,虽然您应该确保您的应用在不同设备上正常运行,但如果这是项目的要求,您可以通过下载模拟器皮肤来针对特定设备进行标准化,即使您没有真实设备也可以做到这一点。

您在安装 Android Studio 时看到的屏幕(或类似的内容)如下:

图 1.5:SDK 组件

图 1.5:SDK 组件

让我们来看看已安装的 SDK 组件以及虚拟设备的作用:

  • Android 模拟器:这是基本模拟器,我们将配置它来创建不同 Android 品牌和型号的虚拟设备。

  • Android SDK 构建工具:Android Studio 使用构建工具来构建您的应用程序。这个过程涉及编译、链接和打包您的应用程序,以便为设备安装做好准备。

  • 在创建项目向导中选择了Jelly Bean来配置项目的最低 API 级别。从 Android 10 开始,版本将不再有与版本名称不同的代码名称。(Build-Tools 和 Platform 的版本将随着新版本的发布而改变)

  • Android SDK 平台工具:这些工具通常是您可以从命令行中使用的工具,用于与您的应用程序进行交互和调试。

  • Android SDK 工具:与平台工具相比,这些工具主要是您在 Android Studio 中使用的工具,用于完成某些任务,例如运行应用程序的虚拟设备和 SDK 管理器以下载和安装 SDK 的平台和其他组件。

  • Intel x86 模拟器加速器(HAXM 安装程序):如果您的操作系统提供了它,这是您的计算机硬件级别的功能,您将被提示启用,这样您的模拟器可以运行得更快。

  • SDK 补丁应用程序 v4:随着新版本的 Android Studio 的推出,这使得可以应用补丁来更新您正在运行的版本。

有了这些知识,让我们开始本章的下一个练习。

练习 1.02:设置虚拟设备并在其上运行您的应用

我们在练习 1.01中设置了一个 Android Studio 项目来创建我们的应用程序,现在我们将在虚拟设备上运行它。您也可以在真实设备上运行您的应用程序,但在本练习中,您将使用虚拟设备。在开发应用程序时,这个过程是一个持续的循环。一旦您实现了一个功能,您可以根据需要验证其外观和行为。在本练习中,您将创建一个虚拟设备,但您应该确保在多个设备上运行您的应用程序,以验证其外观和行为是否一致。执行以下步骤:

  1. 在 Android Studio 的顶部工具栏中,您将看到两个并排的下拉框,预先选择了app无设备图 1.6:Android Studio 工具栏

图 1.6:Android Studio 工具栏

app是我们将要运行的应用程序的配置。由于我们还没有设置虚拟设备,因此显示为无设备

  1. 要创建虚拟设备,请点击AVD Manager工具菜单:图 1.7:工具菜单中的 AVD 管理器

图 1.7:工具菜单中的 AVD 管理器

  1. 点击按钮或工具栏选项以打开您的虚拟设备窗口:图 1.8:您的虚拟设备窗口

图 1.8:您的虚拟设备窗口

  1. 点击创建虚拟设备...按钮,如图 1.8所示:图 1.9:设备定义创建

图 1.9:设备定义创建

  1. 我们将选择Pixel 3设备。由 Google 开发的真实(非虚拟设备)Pixel 系列设备可以访问最新版本的 Android 平台。选择后,点击下一步按钮:图 1.10:系统镜像

图 1.10:系统镜像

这里显示的R名称是 Android 11 的初始代码/发布名称。选择最新的系统镜像。目标列可能还会显示名称中的(Google Play)(Google APIs)。Google APIs 表示系统镜像预装了 Google Play 服务。这是一组丰富的 Google API 和 Google 应用程序功能,您的应用程序可以使用和交互。首次运行应用程序时,您将看到诸如地图和 Chrome 之类的应用程序,而不是普通的模拟器图像。Google Play 系统镜像意味着除了 Google API 之外,还将安装 Google Play 应用程序。

  1. 您应该使用最新版本的 Android 平台开发您的应用程序,以从最新功能中受益。首次创建虚拟设备时,您将需要下载系统镜像。如果发布名称旁边显示下载链接,请点击它并等待下载完成。选择下一步按钮以查看您设置的虚拟设备:图 1.11:虚拟设备配置

图 1.11:虚拟设备配置

然后您将看到最终的配置屏幕。

  1. 点击完成,您的虚拟设备将被创建。然后您将看到您的设备被突出显示:图 1.12:虚拟设备列表

图 1.12:虚拟设备列表

  1. 按下操作列下的右箭头按钮来启动虚拟设备:图 1.13:虚拟设备已启动

图 1.13:虚拟设备已启动

现在,您已经创建了虚拟设备并且正在运行,您可以回到 Android Studio 运行您的应用程序。

  1. 您设置并启动的虚拟设备将被选中。按下绿色三角形/播放按钮启动您的应用程序:

图 1.14:应用程序启动配置

图 1.14:应用程序启动配置

图 1.15:在虚拟设备上运行的应用程序

图 1.15:在虚拟设备上运行的应用程序

在这个练习中,您已经完成了创建虚拟设备并在其上运行您创建的应用程序的步骤。您用于执行此操作的 Android 虚拟设备管理器使您能够为您的应用程序定位目标设备(或设备范围)。在虚拟设备上运行您的应用程序可以快速验证新功能开发的行为方式以及它是否显示您期望的方式。

接下来,您将探索项目的AndroidManifest.xml文件,其中包含应用程序的信息和配置。

Android 清单

您刚刚创建的应用程序虽然简单,但包含了您在创建的所有项目中将使用的核心构建模块。该应用程序是从AndroidManifest.xml文件驱动的,这是一个详细描述您的应用程序内容的清单文件。它包含了所有组件,如活动、内容提供程序、服务、接收器以及应用程序实现其功能所需的权限列表。例如,应用程序需要相机权限来在应用程序中拍摄照片。您可以在项目视图下找到它,路径为MyApplication | app | src | main。或者,如果您正在查看 Android 视图,则它位于app | manifests | AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myapplication">
    <!--Permissions like camera go here-->
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyApplication">
        <activity android:name=".MainActivity"           android:screenOrientation="portrait">
            <intent-filter>
              <action android:name="android.intent.action.MAIN" />

             <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

一般来说,典型的清单文件是一个描述所包含的文件或其他数据以及形成组或单元的相关元数据的顶层文件。Android 清单将这个概念应用到您的 Android 应用程序中,作为一个 XML 文件。指定的应用程序的区别特征是在清单 XML 根部定义的包:

package="com.example.myapplication"

每个 Android 应用程序都有一个应用程序类,允许您配置应用程序。默认情况下,在 Android Studio 的 4.1.1 版本中,应用程序元素中创建了以下 XML 属性和值:

  • android:allowBackup="true":这将在重新安装或切换设备时备份目标并在 Android 6.0(API 级别 23)或更高版本上运行的应用程序的用户数据。

  • android:icon="@mipmap/ic_launcher":Android 使用的资源在 XML 中以@符号开头引用,mipmap 指的是存储启动器图标的文件夹。

  • android:label="@string/app_name":这是您创建应用程序时指定的名称。它目前显示在应用程序的工具栏中,并将显示为用户设备上启动器中应用程序的名称。它由@符号后跟着您创建应用程序时指定的名称的字符串引用引用。

  • android:roundIcon="@mipmap/ic_launcher_round":根据用户所使用的设备,启动器图标可能是方形的或圆形的。当用户的设备在启动器中显示圆形图标时,将使用roundIcon

  • android:supportsRtl="true":这指定了应用程序及其布局文件是否支持从右到左的语言布局。

  • android:theme="@style/Theme.MyApplication":这指定了您的应用程序的主题,包括文本样式、颜色和应用程序内的其他样式。

<application>元素打开后,您可以定义应用程序包含的组件。由于我们刚刚创建了我们的应用程序,它只包含以下代码中显示的第一个屏幕:

<activity android:name=".MainActivity"> 

接下来指定的子 XML 节点如下:

<intent-filter> 

Android 使用意图作为与应用程序和系统组件交互的机制。意图被发送,而意图过滤器注册了您的应用程序对这些意图做出反应的能力。<android.intent.action.MAIN>是您的应用程序的主要入口点,它在.MainActivity的封闭 XML 中出现,指定了当应用程序启动时将启动该屏幕。android.intent.category.LAUNCHER表示您的应用程序将出现在用户设备的启动器中。

由于您是从模板创建应用程序,它具有一个基本的清单,将通过Activity组件启动应用程序并在启动时显示初始屏幕。根据您想要为应用程序添加哪些其他功能,您可能需要在 Android 清单文件中添加权限。

权限分为三种不同的类别:普通、签名和危险。

  • 普通权限包括访问网络状态、Wi-Fi、互联网和蓝牙。通常情况下,这些权限在运行时可以不经用户同意而被允许。

  • 签名权限是由同一组应用程序共享的权限,必须使用相同的证书进行签名。这意味着这些应用程序可以自由共享数据,但其他应用程序无法访问。

  • 危险权限围绕用户及其隐私展开,例如发送短信、访问帐户和位置,以及读写文件系统和联系人。

这些权限必须在清单中列出,并且从 Android Marshmallow API 23(Android 6 Marshmallow)开始,对于危险权限,您还必须在运行时要求用户授予权限。

在下一个练习中,我们将配置 Android 清单文件。

练习 1.03:配置 Android 清单互联网权限

大多数应用程序需要的关键权限是访问互联网。这不是默认添加的。在这个练习中,我们将修复这个问题,并在此过程中加载一个WebView,这使得应用程序可以显示网页。这种用例在 Android 应用程序开发中非常常见,因为大多数商业应用程序都会显示隐私政策、条款和条件等。由于这些文件可能对所有平台都是通用的,通常显示它们的方式是加载一个网页。执行以下步骤:

  1. 像在练习 1.01中一样创建一个新的 Android Studio 项目,为您的应用程序创建一个 Android Studio 项目。

  2. 切换到MainActivity类的标签。从主项目窗口,它位于MyApplication | app | src | main | java | com | example | myapplication。这遵循您创建应用程序时定义的包结构。或者,如果您正在项目窗口中查看 Android 视图,则它位于app | java | com | example | myapplication

您可以通过选择View | Tool Windows | Project来打开Tool窗口,从而更改Project窗口显示的内容 - 这将选择Project视图。Project窗口顶部的下拉选项允许您更改查看项目的方式,最常用的显示方式是ProjectAndroid

图 1.16 工具窗口下拉

package com.example.myapplication
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.Activity_main)
    }
}

您将在本章的下一部分更详细地检查此文件的内容,但现在,您只需要知道setContentView(R.layout.Activity_main)语句设置了您在虚拟设备上首次运行应用程序时看到的 UI 布局。

  1. 使用以下代码更改为以下内容:
package com.example.myapplication
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.webkit.WebView
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val webView = WebView(this)
        webView.settings.javaScriptEnabled = true
        setContentView(webView)
        webView.loadUrl("https://www.google.com")
    }
}

因此,您正在用WebView替换布局文件。val关键字是只读属性引用,一旦设置就无法更改。WebView 需要启用 JavaScript 才能执行 JavaScript。

注意

我们没有设置类型,但 Kotlin 具有类型推断,因此如果可能的话,它会推断出类型。因此,不需要使用val webView: WebView = WebView(this)显式指定类型。根据您过去使用的编程语言,定义参数名称和类型的顺序可能会很熟悉,也可能不会。Kotlin 遵循 Pascal 符号,即名称后跟类型。

  1. 现在,运行应用程序,文本将显示如下所示的屏幕截图:图 1.17 无互联网权限错误消息

图 1.17 无互联网权限错误消息

  1. 这个错误是因为在您的AndroidManifest.xml文件中没有添加INTERNET权限。 (如果您收到错误net::ERR_CLEARTEXT_NOT_PERMITTED,这是因为您加载到WebView中的 URL 不是 HTTPS,而从 API 级别 28、Android 9.0 Pie 及以上版本开始,非 HTTPS 流量被禁用。) 让我们通过向清单添加 Internet 权限来解决这个问题。打开 Android 清单,并在<application>标签上方添加以下内容:
<uses-permission android:name="android.permission.INTERNET" />

您的清单文件现在应该如下所示:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myapplication">
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name=                  "android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

在再次运行应用程序之前,从虚拟设备中卸载应用程序。有时需要这样做,因为应用程序权限有时会被缓存。长按应用图标,选择出现的App Info选项,然后按下带有Uninstall文本的垃圾桶图标。或者,长按应用图标,然后将其拖动到屏幕右上角带有Uninstall文本的垃圾桶图标旁边。

  1. 再次安装应用程序,看到网页出现在WebView中:

图 1.18 应用程序显示 WebView

图 1.18 应用程序显示 WebView

在这个例子中,您学会了如何向清单中添加权限。Android 清单可以被视为您的应用程序的目录。它列出了应用程序使用的所有组件和权限。正如您从启动器启动应用程序所看到的那样,它还提供了进入应用程序的入口点。

在下一节中,您将探索 Android 构建系统,该系统使用 Gradle 构建工具来使您的应用程序正常运行。

使用 Gradle 构建、配置和管理应用程序依赖项

在创建此项目的过程中,您主要使用了 Android 平台 SDK。安装 Android Studio 时,必要的 Android 库已经下载。然而,这些并不是创建您的应用程序所使用的唯一库。为了配置和构建您的 Android 项目或应用程序,使用了一个名为 Gradle 的构建工具。Gradle 是 Android Studio 用来构建您的应用程序的多用途构建工具。在 Android Studio 中,默认情况下使用 Groovy,这是一种动态类型的 JVM 语言,用于配置构建过程,并允许轻松管理依赖项,以便向项目添加库并指定版本。Android Studio 也可以配置为使用 Kotlin 来配置构建,但是由于默认语言是 Groovy,您将使用这种语言。存储此构建和配置信息的文件名为build.gradle。当您首次创建应用程序时,会有两个build.gradle文件,一个位于项目的根/顶级目录,另一个位于应用程序module文件夹中。

项目级build.gradle文件

现在让我们来看一下项目级build.gradle文件。这是您添加到所有子项目/模块的通用配置选项的地方,如下所示:

buildscript {
    ext.kotlin_version = "1.4.21"
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:4.4.1"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:          $kotlin_version"
        // NOTE: Do not place your application dependencies here; 
        //they belong in the individual module build.gradle files
    }
}
allprojects {
    repositories {
        google()
        jcenter()
    }
}
task clean(type: Delete) {
    delete rootProject.buildDir
}

buildscript块包含了实际创建项目的构建和配置信息,而allprojects块指定了所有应用程序模块的配置。Groovy 工作在一个插件系统上,因此您可以编写自己的插件来执行任务或一系列任务,并将其插入到构建流水线中。这里指定的两个插件是 Android 工具插件,它连接到gradle构建工具包,并提供了特定于 Android 的设置和配置来构建您的 Android 应用程序,以及 Kotlin gradle插件,它负责在项目中编译 Kotlin 代码。依赖项本身遵循 Maven 的groupIdartifactIdversionId,用":"冒号分隔。因此,上面的 Android 工具插件依赖项如下所示:

'com.android.tools.build:gradle:4.4.1'

groupIdcom.android.tools.buildartifactIdgradleversionId4.4.1。这样,构建系统通过使用repositories块中引用的仓库来定位和下载这些依赖项。

库的具体版本可以直接指定(就像 Android tools插件中所做的那样)在依赖项中,或者作为变量添加。变量上的ext.前缀表示它是 Groovy 扩展属性,也可以在应用程序build.gradle文件中使用。

注意

在前面的代码部分和本章节以及其他章节的后续部分中指定的依赖版本可能会发生变化,并且随着时间的推移会进行更新,因此在创建这些项目时可能会更高。

应用级别的 build.gradle

build.gradle应用程序是特定于您的项目配置的:

plugins {
    id 'com.android.application'
    id 'kotlin-android'
}
android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"
    defaultConfig {
        applicationId "com.example.myapplication"
        minSdkVersion 16
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner           "androidx.test.runner.AndroidJUnitRunner"
        buildTypes {
            release {
                minifyEnabled false
                proguardFiles getDefaultProguardFile(                  'proguard-android-optimize.txt'), 'proguard-rules.pro'
            }
        }
        compileOptions {
            sourceCompatibility JavaVersion.VERSION_1_8
            targetCompatibility JavaVersion.VERSION_1_8
        }
        kotlinOptions {
            jvmTarget = '1.8'
        }
    }
    dependencies {
        implementation "org.jetbrains.kotlin:kotlin-stdlib:          $kotlin_version"
        implementation 'androidx.core:core-ktx:1.3.2'
        implementation 'androidx.appcompat:appcompat:1.2.0'
        implementation 'com.google.android.material:material:1.2.1'
        implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
        testImplementation 'junit:junit:4.+'
        androidTestImplementation 'androidx.test.ext:junit:1.1.2'
        androidTestImplementation 'androidx.test.espresso           :espresso-core:3.3.0'
    }
}

在前面的解释中详细介绍的 Android 和 Kotlin 插件通过plugins行中的 id 应用于您的项目。

com.android.application插件提供的android块是您配置 Android 特定配置设置的地方:

  • compileSdkVersion:用于定义应用程序已编译的 API 级别,应用程序可以使用此 API 及更低版本的功能。

  • buildToolsVersion:构建应用程序所需的构建工具的版本。(默认情况下,buildToolsVersion行将被添加到您的项目中,但是为了始终使用最新版本的构建工具,您可以将其删除)。

  • defaultConfig:这是您的应用程序的基本配置。

  • applicationId:这是设置为您的应用程序包的标识符,并且是在 Google Play 上用于唯一标识您的应用程序的应用程序标识符。如果需要,可以更改为与包名称不同。

  • minSdkVersion:您的应用程序支持的最低 API 级别。这将使您的应用程序在低于此级别的设备上不会在 Google Play 中显示。

  • targetSdkVersion:您正在针对的 API 级别。这是您构建的应用程序预期使用并已经测试的 API 级别。

  • versionCode:指定您的应用程序的版本代码。每次需要对应用程序进行更新时,版本代码都需要增加 1 或更多。

  • versionName:一个用户友好的版本名称,通常遵循 X.Y.Z 的语义版本,其中 X 是主要版本,Y 是次要版本,Z 是补丁版本,例如,1.0.3。

  • testInstrumentationRunner:用于 UI 测试的测试运行器。

  • buildTypes:在buildTypes下,添加了一个release,用于配置您的应用程序创建一个release构建。如果minifyEnabled值设置为true,将通过删除任何未使用的代码来缩小应用程序的大小,并对应用程序进行混淆。这个混淆步骤会将源代码引用的名称更改为诸如a.b.c()的值。这使得您的代码不太容易被逆向工程,并进一步减小了构建应用程序的大小。

  • compileOptions:java 源代码的语言级别(sourceCompatibility)和字节码(targetCompatibility

  • kotlinOptionskotlin gradle插件应该使用的jvm

dependencies块指定了您的应用程序在 Android 平台 SDK 之上使用的库,如下所示:

    dependencies {
//The version of Kotlin your app is being built with
        implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:          $kotlin_version"
//Kotlin extensions, jetpack 
//component with Android Kotlin language features
implementation 'androidx.core:core-ktx:1.3.2'
//Provides backwards compatible support libraries and jetpack components
        implementation 'androidx.appcompat:appcompat:1.2.0'
//Material design components to theme and style your app
        implementation 'com.google.android.material:material:1.2.1'
//The ConstraintLayout ViewGroup updated separately 
//from main Android sources
        implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
//Standard Test library for unit tests. 
//The '+' is a gradle dynamic version which allows downloading the 
//latest version. As this can lead to unpredictable builds if changes 
//are introduced all projects will use fixed version '4.13.1'
        testImplementation 'junit:junit:4.+'
//UI Test runner
        androidTestImplementation 'androidx.test:runner:1.1.2'
//Library for creating Android UI tests
        androidTestImplementation           'androidx.test.espresso:espresso-core:3.3.0'
    }

使用implementation标记来添加这些库意味着它们的内部依赖不会暴露给您的应用程序,从而加快编译速度。

您将看到这里androidx组件被添加为依赖项,而不是在 Android 平台源中。这样可以使它们独立于 Android 版本进行更新。androidx是重新打包的支持库和 Jetpack 组件。为了添加或验证您的gradle.properties文件是否启用了androidx,您需要检查项目根目录下的gradle.properties文件,并查找android.useAndroidXandroid.enableJetifier属性,并确保它们设置为true

您现在可以打开gradle.properties文件,您会看到以下内容:

# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. 
# More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds
# .html#sec
#:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are 
# bundled with #the Android operating system, and which are packaged 
# with your app's APK
# https://developer.android.com/topic/libraries/support-library/
# androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

当你使用 Android Studio 模板创建项目时,它将这些标志设置为true,并将应用程序使用的相关androidx依赖项添加到应用程序的build.gradle文件的dependencies块中。除了前面的注释解释之外,android.useAndroidX=true标志表示项目正在使用androidx库,而不是旧的支持库,android.enableJetifier=true还将把第三方库中使用的旧版本支持库转换为 AndroidX 格式。kotlin.code.style=official将把代码风格设置为官方的 kotlin 风格,而不是默认的 Android Studio 风格。

要检查的最终 Gradle 文件是settings.gradle。这个文件显示了你的应用程序使用的模块。在使用 Android Studio 创建项目时,只会有一个模块app,但当你添加更多功能时,你可以添加新的模块,这些模块专门用于包含该功能的源代码,而不是将其打包到主app模块中。这些被称为特性模块,你可以用其他类型的模块来补充它们,比如被所有其他模块使用的共享模块,比如网络模块。settings.gradle文件将如下所示:

include ':app'
rootProject.name='My Application'

练习 1.04:探索如何使用 Material Design 主题应用程序

在这个练习中,你将学习关于谷歌的新设计语言Material Design,并使用它来加载一个Material Design主题的应用程序。Material Design是谷歌创建的一种设计语言,它增加了基于现实世界效果的丰富 UI 元素,比如光照、深度、阴影和动画。执行以下步骤:

  1. 像在练习 1.01中一样创建一个新的 Android Studio 项目,为你的应用程序创建一个 Android Studio 项目

  2. 首先,查看dependencies块,并找到 material design 依赖

implementation 'com.google.android.material:material:1.2.1'
  1. 接下来,打开位于app | src | main | res | values | themes.xmlthemes.xml文件:
<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.MyApplication"       parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor"           tools:targetApi="l">?attr/colorPrimaryVariant</item>
        <!-- Customize your theme here. -->    </style></resources>

注意Theme.MyApplication的父级是Theme.MaterialComponents.DayNight.DarkActionBar

dependencies块中添加的 Material Design 依赖项被用于应用程序的主题。

  1. 如果现在运行应用程序,你将看到默认的 Material 主题应用程序,如图 1.15所示。

在这个练习中,你已经学会了如何在屏幕上使用TextView,不清楚 material design 提供了什么好处,但当你开始更多地使用 Material UI 设计小部件时,这将会改变。现在你已经学会了项目是如何构建和配置的,在接下来的部分中,你将详细探索项目结构,了解它是如何创建的,并熟悉开发环境的核心领域。

Android 应用程序结构

现在我们已经介绍了 Gradle 构建工具的工作原理,我们将探索项目的其余部分。最简单的方法是检查应用程序的文件夹结构。在 Android Studio 的左上角有一个名为Project的工具窗口,它允许你浏览应用程序的内容。默认情况下,在创建 Android 项目时,它是打开/选中的。当你选择它时,你会看到一个类似于图 1.19中截图的视图。(如果你在屏幕左侧看不到任何窗口栏,那么去顶部工具栏,选择View | Appearance | Tool Window Bars,确保它被选中)。浏览项目有许多不同的选项,但Android将被预先选择。这个视图将app文件夹结构整齐地分组在一起,让我们来看看它。

这里是这些文件的概述,更详细地介绍了最重要的文件。打开它时,你会看到它包括以下文件夹结构:

图 1.19:应用程序中文件和文件夹结构的概述

图 1.19:应用程序中文件和文件夹结构的概述

您指定为应用程序启动时运行的 Kotlin 文件(MainActivity)如下:

package com.example.myapplication
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

import语句包括此活动使用的库和源。类头class MainActivity : AppCompatActivity()创建了一个扩展AppCompatActivity的类。在 Kotlin 中,:冒号字符用于从类派生(也称为继承)和实现接口。

MainActivity派生自androidx.appcompat.app.AppCompatActivity,这是向后兼容的活动,旨在使您的应用程序在旧设备上运行。

Android 活动具有许多回调函数,您可以在活动生命周期的不同点重写这些函数。这就是所谓的onCreate函数,如下所示:

override fun onCreate(savedInstanceState: Bundle?) 

Kotlin 中的override关键字指定您正在为父类中定义的函数提供特定的实现。fun关键字(您可能已经猜到)代表functionsavedInstanceState: Bundle?参数是 Android 用于恢复先前保存状态的机制。对于这个简单的活动,您没有存储任何状态,因此这个值将是null。跟随类型的问号?声明了这种类型可以是nullsuper.onCreate(savedInstanceState)行调用了基类的重写方法,最后,setContentView(R.layout.Activity_main)加载了我们想要在活动中显示的布局;否则,它将显示为空屏幕,因为没有定义布局。

让我们看看文件夹结构中存在的一些其他文件(图 1.19):

  • ExampleInstrumentedTest:这是一个示例 UI 测试。您可以在应用程序运行时运行 UI 测试来检查和验证应用程序的流程和结构。

  • ExampleUnitTest:这是一个示例单元测试。创建 Android 应用程序的一个重要部分是编写单元测试,以验证源代码是否按预期工作。

  • ic_launcher_background.xml/ic_launcher_foreground.xml:这两个文件一起以矢量格式组成应用程序的启动器图标,将由 Android API 26(Oreo)及以上版本中的启动器图标文件ic_launcher.xml使用。

  • activity_main.xml:这是 Android Studio 创建项目时创建的布局文件。它由MainActivity用于绘制初始屏幕内容,该内容在应用程序运行时显示:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

为了支持应用程序的国际化和从右到左(rtl)布局,如果存在这些属性,您应该删除它们:

        app:layout_constraintStart_toLeftOf="parent"
        app:layout_constraintEnd_toRightOf="parent"

用以下内容替换它们:

        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"

这样,开始和结束由应用程序语言确定,而左和右只在从左到右的语言中表示开始和结束。

Android 中的大多数屏幕显示都是使用 XML 布局创建的。文档以 XML 标头开头,后跟顶级ViewGroup(这里是ConstraintLayout),然后是一个或多个嵌套的ViewsViewGroups

ConstraintLayout ViewGroup允许在屏幕上非常精确地定位视图,通过将视图约束到父视图和兄弟视图、指南线和障碍物。

TextView,当前是ConstraintLayout的唯一子视图,通过android:text属性在屏幕上显示文本。将视图水平定位到父级的开始和结束来完成视图的水平定位,因为应用了两个约束,所以视图在水平方向上居中(从左到右的语言(ltr)中的开始和结束是左和右,但在non ltr语言中是从右到左)。通过将视图约束到其父级的顶部和底部,将视图垂直定位在中心。应用所有四个约束的结果是在ConstraintLayout中将TextView水平和垂直居中。

ConstraintLayout标签中有三个 XML 命名空间:

  • xmlns:android指的是 Android 特定的命名空间,用于主要 Android SDK 中的所有属性和值。

  • xmlns:app命名空间用于 Android SDK 中没有的任何内容。因此,在这种情况下,ConstraintLayout不是主要 Android SDK 的一部分,而是作为库添加的。

  • xmnls:tools指的是用于向 XML 添加元数据的命名空间,用于指示布局在哪里使用(tools:context=".MainActivity")。

Android XML 布局文件的两个最重要的属性是android:layout_widthandroid:layout_height

这些可以设置为绝对值,通常是密度无关像素(称为dipdp),它们将像素大小缩放到不同密度设备上大致相等。然而,更常见的是,这些属性的值设置为wrap_contentmatch_parentwrap_content将根据其内容大小调整大小。match_parent将根据其父级大小调整大小。

还有其他ViewGroups可以用来创建布局。LinearLayout垂直或水平布局视图,FrameLayout通常用于显示单个子视图,RelativeLayoutConstraintLayout的简化版本,它布局视图相对于父视图和兄弟视图的位置。

ic_launcher.png文件是.png启动图标,为不同密度的设备提供了图标。由于我们使用的最低版本的 Android 是 API 16:Android 4.1(果冻豆),因此这些.png图像被包含在内,因为直到 Android API 26(奥利奥)之前,对启动器矢量格式的支持才被引入。

ic_launcher.xml文件使用矢量文件(ic_launcher_background.xml/ic_launcher_foreground.xml)在 Android API 26(奥利奥)及以上版本中缩放到不同密度的设备。

注意

为了在 Android 平台上针对不同密度的设备,除了每一个ic_launcher.png图标外,您将看到括号中标注了它所针对的密度。由于设备的像素密度差异很大,Google 创建了密度桶,以便根据设备的每英寸点数选择正确的图像来显示。

不同密度限定符及其详细信息如下:

  • nodpi:密度无关资源

  • ldpi:120 dpi 的低密度屏幕

  • mdpi:160 dpi 的中密度屏幕(基线)

  • hdpi:240 dpi 的高密度屏幕

  • xhdpi:320 dpi 的超高密度屏幕

  • xxhdpi:480 dpi 的超高密度屏幕

  • xxxhdpi:640 dpi 的超超高密度屏幕

  • tvdpi:电视资源(约 213 dpi)

基线密度桶在中密度设备上以每英寸160点创建,并称为每英寸160点/像素,最大的显示桶是xxxhdpi,它有每英寸640点。Android 根据各个设备来确定显示的适当图像。因此,Pixel 3 模拟器的密度约为443dpi,因此它使用来自超超高密度桶(xxhdpi)的资源,这是最接近的匹配。Android 更倾向于缩小资源以最好地匹配密度桶,因此具有400dpi的设备,介于xhdpixxhdpi桶之间,可能会显示来自xxhdpi桶的480dpi资产。

为了为不同密度创建替代位图可绘制对象,您应该遵循六种主要密度之间的3:4:6:8:12:16缩放比例。例如,如果您有一个用于中密度屏幕的48x48像素的位图可绘制对象,则所有不同大小应该是:

  • 36x360.75x)用于低密度(ldpi

  • 48x481.0x基线)用于中密度(mdpi

  • 72x721.5x)用于高密度(hdpi

  • 96x962.0x)用于超高密度(xhdpi

  • 144x1443.0x)用于超超高密度(xxhdpi

  • 192x1924.0x)用于超超超高密度(xxxhdpi

要比较每个密度桶中的这些物理启动器图标,请参考以下表格:

图 1.20:主要密度桶发射器图像尺寸比较

图 1.20:主要密度桶发射器图像尺寸比较

注意

启动器图标比应用程序中的普通图像略大,因为它们将被设备的启动器使用。由于一些启动器可以放大图像,这是为了确保图像没有像素化和模糊。

现在您将查看应用程序使用的一些资源。这些资源在 XML 文件中被引用,并保持应用程序的显示和格式一致。

colors.xml文件中,您以十六进制格式定义了您想在应用程序中使用的颜色。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>
</resources>

该格式基于 RGB 颜色空间,因此前两个字符是红色,接下来两个是绿色,最后两个是蓝色,其中#00表示没有添加任何颜色来组成复合颜色,而#FF表示添加了所有颜色。

如果您希望颜色具有一定的透明度,则在前面加上两个十六进制字符,从#00表示完全透明到#FF表示完全不透明。因此,要创建蓝色和 50%透明的蓝色字符,格式如下:

    <color name="colorBlue">#0000FF</color>
    <color name="colorBlue50PercentTransparent">#770000FF</color>

strings.xml文件显示应用程序中显示的所有文本:

<resources>
    <string name="app_name">My Application</string>
</resources>

您可以在应用程序中使用硬编码的字符串,但这会导致重复,并且意味着如果要使应用程序支持多种语言,则无法自定义文本。通过将字符串添加为资源,如果在应用程序的不同位置使用了该字符串,您还可以在一个地方更新该字符串。

您想要在整个应用程序中使用的常见样式都添加到themes.xml文件中。

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.MyApplication"       parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor"           tools:targetApi="l">?attr/colorPrimaryVariant</item>
        <!-- Customize your theme here. -->
    </style></resources>

可以通过在TextView的属性上设置android:textStyle="bold"来直接向视图应用样式信息。但是,如果您想要将多个TextView显示为粗体,您将不得不在多个地方重复这样做。当您开始向单个视图添加多个样式属性时,会出现大量重复,并且在想要对所有类似视图进行更改时可能会导致错误,并且错过更改一个视图上的样式属性。如果您定义了一个样式,您只需更改样式,它将更新所有应用了该样式的视图。在创建项目时,AndroidManifest.xml文件中的应用程序标签应用了顶级主题,并被称为为应用程序中包含的所有视图设置样式的主题。您在colors.xml文件中定义的颜色在此处使用。实际上,如果您更改了colors.xml文件中定义的颜色之一,它现在也会传播到应用程序的样式中。

您现在已经探索了应用程序的核心领域。您已经添加了TextView视图来显示标签、标题和文本块。在下一个练习中,您将介绍允许用户与您的应用程序进行交互的 UI 元素。

练习 1.05:向用户添加交互式 UI 元素以显示定制的问候语

本练习的目标是使用户能够添加和编辑文本,然后提交此信息以显示带有输入数据的定制问候语。您需要添加可编辑的文本视图来实现这一点。EditText视图通常是这样做的,可以在 XML 布局文件中添加如下:

<EditText
    android:id="@+id/full_name"
    style="@style/TextAppearance.AppCompat.Title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:hint="@string/first_name" />

这使用了一个 Android 样式TextAppearance.AppCompat.Title来显示标题,如下所示:

图 1.21:带提示的 EditText

图 1.21:带提示的 EditText

虽然这对于启用用户添加/编辑文本是完全可以的,但是材料TextInputEditText及其包装视图TextInputLayoutEditText显示提供了一些修饰。让我们使用以下代码:

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/first_name_wrapper"
        style="@style/text_input_greeting"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/first_name_text">
        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/first_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </com.google.android.material.textfield.TextInputLayout>

输出如下:

图 1.22:带提示的 Material TextInputLayout/TextInputEditText

图 1.22:带提示的 Material TextInputLayout/TextInputEditText

TextInputLayout允许我们为TextInputEditText视图创建一个标签,并在TextInputEditText视图聚焦时进行漂亮的动画(移动到字段的顶部),同时仍然显示标签。标签是使用android:hint指定的。

您将更改应用程序中的Hello World文本,以便用户可以输入他们的名字和姓氏,并在按下按钮时显示问候。执行以下步骤:

  1. 通过将以下条目添加到app | src | main | res | values | strings.xml中,创建您的应用程序中要使用的标签和文本:
<resources>
    <string name="app_name">My Application</string>
    <string name="first_name_text">First name:</string>
    <string name="last_name_text">Last name:</string>
    <string name="enter_button_text">Enter</string>
    <string name="welcome_to_the_app">Welcome to the app</string>
    <string name="please_enter_a_name">Please enter a full name!
    </string>
</resources>
  1. 接下来,我们将通过在app | src | main | res | themes.xml中添加以下样式来更新我们要在布局中使用的样式(在基本应用程序主题之后)
<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.MyApplication"       parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor"           tools:targetApi="l">?attr/colorPrimaryVariant</item>
        <!-- Customize your theme here. -->
    </style>
    <style name="text_input_greeting"       parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox">
        <item name="android:layout_margin">8dp</item>
    </style>
    <style name="button_greeting">
        <item name="android:layout_margin">8dp</item>
        <item name="android:gravity">center</item>
    </style>
    <style name="greeting_display"         parent="@style/TextAppearance.MaterialComponents.Body1">
        <item name="android:layout_margin">8dp</item>
        <item name="android:gravity">center</item>
        <item name="android:layout_height">40dp</item>
    </style>
    <style name="screen_layout_margin">
        <item name="android:layout_margin">12dp</item>
    </style>
</resources>

注意

一些样式的父样式引用了材料样式,因此这些样式将直接应用于视图,以及指定的样式。

  1. 现在,我们已经添加了要应用于布局和文本中的视图的样式,我们可以在app | src | main | res | layout文件夹中的activity_main.xml中更新布局。下面的代码由于空间原因而被截断,但您可以使用下面的链接查看完整的源代码。
activity_main.xml
10    <com.google.android.material.textfield.TextInputLayout
11        android:id="@+id/first_name_wrapper"
12        style="@style/text_input_greeting"
13        android:layout_width="match_parent"
14        android:layout_height="wrap_content"
15        android:hint="@string/first_name_text"
16        app:layout_constraintTop_toTopOf="parent"
17        app:layout_constraintStart_toStartOf="parent">
18
19        <com.google.android.material.textfield.TextInputEditText
20            android:id="@+id/first_name"
21            android:layout_width="match_parent"
22            android:layout_height="wrap_content" />
23
24    </com.google.android.material.textfield.TextInputLayout>
25
26    <com.google.android.material.textfield.TextInputLayout
27        android:id="@+id/last_name_wrapper"
28        style="@style/text_input_greeting"
29        android:layout_width="match_parent"
30        android:layout_height="wrap_content"
31        android:hint="@string/last_name_text"
32        app:layout_constraintTop_toBottomOf="@id/first_name_wrapper"
33        app:layout_constraintStart_toStartOf="parent">
34
35        <com.google.android.material.textfield.TextInputEditText
36            android:id="@+id/last_name"
37            android:layout_width="match_parent"
38            android:layout_height="wrap_content" />
39
40    </com.google.android.material.textfield.TextInputLayout>
41
42    <com.google.android.material.button.MaterialButton
43        android:layout_width="match_parent"
44        android:layout_height="wrap_content"
45        style="@style/button_greeting"
46        android:id="@+id/enter_button"
47        android:text="@string/enter_button_text"
48        app:layout_constraintTop_toBottomOf="@id/last_name_wrapper"
49        app:layout_constraintStart_toStartOf="parent"/>
50
51    <TextView
52        android:id="@+id/greeting_display"
53        android:layout_width="match_parent"
54        style="@style/greeting_display"
55        app:layout_constraintTop_toBottomOf="@id/enter_button"
56        app:layout_constraintStart_toStartOf="parent" />
The complete code for this step can be found at http://packt.live/35T5IMN.

您已为所有视图添加了 ID,以便可以将它们约束到它们的兄弟视图,并且还提供了一种在活动中获取TextInputEditText视图的值的方法。style="@style.."符号应用了themes.xml文件中的样式。

  1. 运行应用程序并查看外观和感觉。如果您选择TextInputEditText视图中的一个,您将看到标签被动画化并移动到视图的顶部:图 1.23:TextInputEditText 字段的标签状态,无焦点和有焦点

图 1.23:TextInputEditText 字段的标签状态,无焦点和有焦点

  1. 现在,我们必须在我们的活动中添加与视图的交互。布局本身除了允许用户在EditText字段中输入文本之外,不会做任何事情。在这个阶段点击按钮不会做任何事情。您将通过在按钮被按下时使用表单字段的 ID 捕获输入的文本,然后使用文本填充TextView消息来实现这一点。

  2. 打开MainActivity并完成下一步,处理输入的文本并使用这些数据显示问候并处理任何表单输入错误。

  3. onCreate函数中,为按钮设置一个点击监听器,这样我们就可以响应按钮点击并通过更新MainActivity来检索表单数据,显示如下内容:

package com.example.myapplication
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.Gravity
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import com.google.android.material.textfield.TextInputEditText
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<Button>(R.id.enter_button)?.setOnClickListener {
            //Get the greeting display text
            val greetingDisplay =               findViewById<TextView>(R.id.greeting_display)
            //Get the first name TextInputEditText value
            val firstName = findViewById<TextInputEditText>              (R.id.first_name)?.text.toString().trim()
            //Get the last name TextInputEditText value
            val lastName = findViewById<TextInputEditText>              (R.id.last_name)?.text.toString().trim()
            //Check names are not empty here:
        }
    }
}
  1. 然后,检查修剪后的名称是否为空,并使用 Kotlin 的字符串模板格式化名称:
if (firstName.isNotEmpty() && lastName.isNotEmpty()) {
    val nameToDisplay = firstName.plus(" ").plus(lastName)
    //Use Kotlin's string templates feature to display the name
    greetingDisplay?.text =
        " ${getString(R.string.welcome_to_the_app)} ${nameToDisplay}!"
}
  1. 最后,如果表单字段没有正确填写,显示一条消息:
else {
    Toast.makeText(this, getString(R.string.please_enter_a_name),       Toast.LENGTH_LONG).
    apply{
        setGravity(Gravity.CENTER, 0, 0)
        show()
    }
}

指定的Toast是一个小型文本对话框,它在主布局上方短暂出现,以向用户显示消息,然后消失。

  1. 运行应用程序并在字段中输入文本,验证当两个文本字段都填写时是否显示问候消息,并且如果两个字段都没有填写,则弹出消息显示为什么没有设置问候。您应该看到以下显示:图 1.24:名称填写正确和错误的应用程序

图 1.24:名称填写正确和错误的应用程序

完整的练习代码可以在这里查看:packt.live/39JyOzB

前面的练习介绍了如何通过EditText字段向应用程序添加交互性,用户可以填写这些字段,添加点击监听器以响应按钮事件并执行一些验证。

访问布局文件中的视图

在布局文件中访问视图的已建立的方法是使用findViewById和视图的 id 名称。因此,在 Activity 中的setContentView(R.layout.activity_main)设置布局后,可以通过语法findViewById<Button>(R.id.enter_button)检索enter_button Button。您将在本课程中使用这种技术。Google 还引入了 ViewBinding 来替代findViewById,它创建一个绑定类来访问视图,并具有空值和类型安全的优势。您可以在这里阅读有关此内容:developer.android.com/topic/libraries/view-binding

进一步的输入验证

验证用户输入是处理用户数据的关键概念,当您没有在表单中输入必填字段时,您必须已经多次看到它的作用。在上一个练习中,当检查用户是否已经在名字和姓氏字段中输入值时,就是在验证用户输入。

还有其他验证选项可以直接在 XML 视图元素中使用。例如,假设您想要验证输入到字段中的 IP 地址。您知道 IP 地址可以是由句点/点分隔的四个数字,其中数字的最大长度为 3。因此,可以输入到字段中的字符的最大数量为 15,并且只能输入数字和句点。有两个 XML 属性可以帮助我们进行验证:

  • android:digits="0123456789.":通过列出所有允许的单个字符,限制可以输入到字段中的字符。

  • android:maxLength="15":限制用户输入超过 IP 地址将包含的最大字符数。

因此,这是您可以在表单字段中显示的方式:

<com.google.android.material.textfield.TextInputLayout
    style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    <com.google.android.material.textfield.TextInputEditText
        android:id="@+id/ip_address"
        android:digits="0123456789."
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:maxLength="15" />
</com.google.android.material.textfield.TextInputLayout>

此验证限制了可以输入的字符和最大长度。还需要对字符序列以及它们是否为句点/点或数字进行额外验证,如 IP 地址格式所述,但这是帮助用户输入正确字符的第一步。

在本章中获得的知识,让我们从以下活动开始。

活动 1.01:创建一个应用程序来生成 RGB 颜色

在这个活动中,我们将研究一个使用验证的场景。假设您被要求创建一个应用程序,显示红色、绿色和蓝色的 RGB 通道如何添加到 RGB 颜色空间中以创建颜色。每个 RGB 通道应该作为两个十六进制字符添加,其中每个字符的值可以是 0-9 或 A-F。然后将这些值组合起来,生成一个 6 个字符的十六进制字符串,该字符串将作为颜色显示在应用程序中。

这个活动的目的是生成一个具有可编辑字段的表单,用户可以为每种颜色添加两个十六进制值。填写完所有三个字段后,用户应单击一个按钮,该按钮获取三个值并将它们连接起来以创建有效的十六进制颜色字符串。然后将其转换为颜色,并显示在应用程序的 UI 中。

以下步骤将帮助您完成该活动:

  1. 创建一个名为Colors的新项目

  2. 将标题添加到布局,约束到布局的顶部。

  3. 向用户添加一个简短的说明,说明如何填写表单。

  4. 在“标题”下方添加三个材料TextInputLayout字段,包裹三个TextInputEditText字段。这些应该被约束,以便每个视图位于另一个视图的上方(而不是侧面)。分别将TextInputEditText字段命名为“红色通道”、“绿色通道”和“蓝色通道”,并对每个字段添加限制,只能输入两个字符并添加十六进制字符。

  5. 添加一个按钮,该按钮获取三个颜色字段的输入。

  6. 添加一个视图,用于在布局中显示生成的颜色。

  7. 最后,在布局中显示由三个通道创建的 RGB 颜色。

最终输出应如下所示(颜色将根据输入而变化):

图 1.25:显示颜色时的输出

图 1.25:显示颜色时的输出

注意

此活动的解决方案可在此处找到:packt.live/3sKj1cp

本章中所有练习和活动的来源都在这里:packt.live/2LLY9kb

注意

当首次将此课程的所有已完成项目从 Github 存储库加载到 Android Studio 时,不要使用顶部菜单中的File | Open打开项目。始终使用File | New | Import Project。这是为了正确构建应用程序。在初始导入后打开项目时,可以使用File | OpenFile | Open Recent

摘要

本章已经涵盖了很多关于 Android 开发基础的内容。您首先学习了如何使用 Android Studio 创建 Android 项目,然后在虚拟设备上创建和运行应用程序。接着,本章通过探索AndroidManifest文件来详细介绍了应用程序的内容和权限模型,然后介绍了 Gradle 以及添加依赖项和构建应用程序的过程。然后深入了解了 Android 应用程序的细节以及文件和文件夹结构。介绍了布局和视图,并进行了练习,以说明如何使用 Google 的 Material Design 构建用户界面。下一章将在此基础上继续学习活动生命周期、活动任务和启动模式,以及在屏幕之间持久化和共享数据,以及如何通过应用程序创建强大的用户体验。

第二章:构建用户屏幕流程

概述

本章涵盖了 Android 活动生命周期,并解释了 Android 系统如何与您的应用程序交互。通过本章的学习,您将学会如何在不同屏幕之间构建用户旅程。您还将能够使用活动任务和启动模式,保存和恢复活动的状态,使用日志报告您的应用程序,并在屏幕之间共享数据。

介绍

上一章向您介绍了 Android 开发的核心元素,从使用AndroidManifest.xml文件配置您的应用程序,使用简单活动和 Android 资源结构,到使用build.gradle构建应用程序并在虚拟设备上运行应用程序。在本章中,您将进一步学习 Android 系统如何通过 Android 生命周期与您的应用程序交互,您将被通知应用程序状态的变化,以及您如何使用 Android 生命周期来响应这些变化。然后,您将学习如何在应用程序中创建用户旅程以及如何在屏幕之间共享数据。您将介绍不同的技术来实现这些目标,以便您能够在自己的应用程序中使用它们,并在其他应用程序中看到它们被使用时能够识别出来。

活动生命周期

在上一章中,我们使用onCreate(saveInstanceState: Bundle?)方法在屏幕的 UI 中显示布局。现在,我们将更详细地探讨 Android 系统如何与您的应用程序交互以实现这一点。一旦启动 Activity,它就会经历一系列步骤,使其经过初始化并准备好显示部分显示,然后完全显示。还有一些步骤对应着您的应用程序被隐藏、后台运行,然后被销毁。这个过程被称为Activity 生命周期。对于这些步骤中的每一个,都有一个回调,您的 Activity 可以使用它来执行操作,比如在您的应用程序被放入后台时创建和更改显示,并在您的应用程序恢复到前台后恢复数据。您可以将这些回调视为系统与您的 Activity/屏幕交互的钩子。

每个 Activity 都有一个父 Activity 类,它是扩展的。这些回调是在您的 Activity 的父类上进行的,由您决定是否需要在自己的 Activity 中实现它们以执行任何相应的操作。这些回调函数中的每一个都有override关键字。在 Kotlin 中,override关键字表示这个函数要么提供接口或抽象方法的实现,要么在这里的 Activity 中,它是一个子类,它提供了将覆盖其父类的实现。

现在您已经了解了Activity 生命周期的一般工作原理,让我们更详细地了解您将按顺序使用的主要回调,从创建 Activity 到销毁 Activity:

  • override fun onCreate(savedInstanceState: Bundle?): 这是你在绘制全屏幕大小的活动中最常用的回调。在这里,你准备好你的活动布局以便显示。在此阶段,方法完成后,尽管如果你不实现任何其他回调,它仍未显示给用户,但如果你不实现任何其他回调,它看起来是这样的。你通常通过调用setContentView方法setContentView(R.layout.activity_main)来设置活动的 UI,并进行任何必要的初始化。这个方法只会在其savedInstanceState参数中调用一次,Bundle?类型(?表示类型可以为 null),在其最简单的形式中是一种优化保存和恢复数据的键值对映射。如果这是应用程序启动后首次运行活动,或者活动首次创建或重新创建而没有保存任何状态,它将为 null。如果在活动重新创建之前已在onSaveInstanceState(outState: Bundle?)回调中保存了状态,它可能包含一个保存的状态。

  • override fun onRestart(): 当活动重新启动时,此方法会在onStart()之前立即调用。重启活动和重新创建活动之间的区别很重要。当活动通过按下主页按钮置于后台时,例如,当它再次进入前台时,将调用onRestart()。重新创建活动是指发生配置更改,例如设备旋转时发生的情况。活动被结束然后重新创建。

  • override fun onStart(): 当活动首次显示时进行的回调。此外,在通过按下返回、主页或最近应用/概览硬件按钮将应用置于后台后,从最近应用/概览菜单或启动器中再次选择应用时,也会运行此函数。这是可见生命周期方法中的第一个。

  • override fun onRestoreInstanceState(savedInstanceState: Bundle?): 如果状态已经使用onSaveInstanceState(outState: Bundle?)保存,系统会在onStart()之后调用此方法,你可以在这里检索Bundle状态,而不是在onCreate(savedInstanceState: Bundle?)期间恢复状态。

  • override fun onResume(): 这个回调函数在首次创建活动的最后阶段运行,也在应用程序被置于后台然后再次进入前台时运行。在完成这个回调后,屏幕/活动已经准备好被使用,接收用户事件,并且响应。

  • override fun onSaveInstanceState(outState: Bundle?): 如果你想保存活动的状态,这个函数可以做到。你可以使用便捷函数之一添加键值对,具体取决于数据类型。如果你的活动在onCreate(saveInstanceState: Bundle?)onRestoreInstanceState(savedInstanceState: Bundle?)中重新创建,这些数据将可用。

  • override fun onPause(): 当活动开始被置于后台或另一个对话框或活动进入前台时,调用此函数。

  • override fun onStop(): 当活动被隐藏时调用此函数,无论是因为被置于后台还是因为另一个活动在其上启动。

  • override fun onDestroy(): 当系统资源不足时,显式调用finish()方法,或者更常见的是用户从最近应用/概览按钮关闭应用时,系统会调用此函数来销毁活动。

既然你了解了这些常见的生命周期回调函数的作用,让我们实现它们,看它们何时被调用。

练习 2.01:记录活动回调

让我们创建一个名为Activity Callbacks的应用程序,其中包含一个空活动,就像您在第一章中所做的那样,创建您的第一个应用程序。这个练习的目的是记录活动回调以及它们发生的顺序,以进行常见操作:

  1. 应用程序创建后,MainActivity将如下所示:
package com.example.activitycallbacks
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

为了验证回调的顺序,让我们在每个回调的末尾添加一个日志语句。为了准备活动进行日志记录,通过在import语句中添加import android.util.Log来导入 Android 日志包。然后,在类中添加一个常量来标识您的活动。Kotlin 中的常量由const关键字标识,并且可以在顶层(类外)或在类内的对象中声明。如果需要公共常量,通常使用顶级常量。对于私有常量,Kotlin 提供了一种方便的方法,通过声明伴生对象来向类添加静态功能。在类的底部以下添加以下内容onCreate(savedInstanceState: Bundle?)

companion object {
    private const val TAG = "MainActivity"
}

然后在onCreate(savedInstanceState: Bundle?)的末尾添加一个日志语句:

Log.d(TAG, "onCreate")

我们的活动现在应该有以下代码:

package com.example.activitycallbacks
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, "onCreate")
    }
    companion object {
        private const val TAG = "MainActivity"
    }
}

在前面的日志语句中,d代表debug。有六种不同的日志级别可以用来输出从最不重要到最重要的消息信息 - v代表verbosed代表debugi代表infow代表warne代表errorwtf代表what a terrible failure。(最后一个日志级别突出显示了一个不应该发生的异常。)

        Log.v(TAG, "verbose message")
        Log.d(TAG, "debug message")
        Log.i(TAG, "info message")
        Log.w(TAG, "warning message")
        Log.e(TAG, "error message")
        Log.wtf(TAG, "what a terrible failure message")
  1. 现在,让我们看看日志在 Android Studio 中是如何显示的。打开Logcat窗口。可以通过单击屏幕底部的Logcat选项卡或者从工具栏中转到View | Tool WindowsLogcat来访问它。

  2. 在虚拟设备上运行应用程序并检查Logcat窗口输出。您应该看到您添加的日志语句的格式如图 2.1中的以下行:图 2.1:Logcat 中的日志输出

图 2.1:Logcat 中的日志输出

  1. 日志语句一开始可能很难解释,所以让我们将以下语句分解为其各个部分:
2020-03-03  20:36:12.308  21415-21415/com.example.activitycallbacks D/MainActivity: onCreate

让我们详细检查日志语句的元素:

图 2.2:解释日志语句的表

图 2.2:解释日志语句的表

您可以通过将日志过滤器从Debug更改为下拉菜单中的其他选项来检查不同日志级别的输出。如果您选择Verbose,正如其名称所示,您将看到大量输出。

  1. 日志语句的TAG选项之所以好用,是因为它使您能够通过输入标签的文本来过滤在 Android Studio 的Logcat窗口中报告的日志语句,如图 2.3所示:图 2.3:通过 TAG 名称过滤日志语句

图 2.3:通过 TAG 名称过滤日志语句

因此,如果您正在调试活动中的问题,您可以输入TAG名称并向您的活动添加日志以查看日志语句的顺序。这就是您接下来要做的事情,通过实现主要活动回调并向每个回调添加一个日志语句来查看它们何时运行。

  1. onCreate(savedInstanceState: Bundle?)函数的右括号后的新行上放置光标,然后添加onRestart()回调和一个日志语句。确保调用super.onRestart(),以便活动回调的现有功能按预期工作:
override fun onRestart() {
    super.onRestart()
    Log.d(TAG, "onRestart")
}
  1. 一旦您开始输入函数的名称,Android Studio 的自动完成功能将建议您要重写的函数的名称选项。
onCreate(savedInstanceState: Bundle?)
onRestart()
onStart()
onRestoreInstanceState(savedInstanceState: Bundle?)
onResume()
onPause()
onStop()
onSaveInstanceStateoutState: Bundle?)
onDestroy()
  1. 您的活动现在应该有以下代码(此处截断)。您可以在 GitHub 上查看完整的代码[packt.live/38W7jU5](http://packt.live/38W7jU5

完成的活动现在将使用您的实现覆盖回调,其中添加了一个日志消息:

package com.example.activitycallbacks
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, "onCreate")
    }
    override fun onRestart() {
        super.onRestart()
        Log.d(TAG, "onRestart")
    }
    //Remaining callbacks follow: see github link above
    companion object {
        private const val TAG = "MainActivity"
    }
}
  1. 运行应用程序,一旦加载完成,就像图 2.4中一样,查看Logcat输出;您应该会看到以下日志语句(这是一个缩短版):
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onResume

已创建、启动并准备好供用户进行交互的活动:

图 2.4:应用程序加载并显示 MainActivity

图 2.4:应用程序加载并显示 MainActivity

  1. 按下底部导航控件中心的圆形主页按钮,将应用程序放到后台。您现在应该看到以下Logcat输出:
D/MainActivity: onPause
D/MainActivity: onStop
D/MainActivity: onSaveInstanceState

对于目标低于 Android Pie(API 28)的应用程序,onSaveInstanceState(outState: Bundle?)也可能在onPause()onStop()之前被调用。

  1. 现在,通过按下右侧的最近/概览按钮(通常是一个方形或三条垂直线)并选择应用程序,或者通过转到启动器并打开应用程序,将应用程序带回前台。您现在应该看到以下内容:
D/MainActivity: onRestart
D/MainActivity: onStart
D/MainActivity: onResume

活动已重新启动。您可能已经注意到onRestoreInstanceState(savedInstanceState: Bundle)函数未被调用。这是因为活动未被销毁和重建。

  1. 按下底部导航控件左侧(也可能在右侧)的三角形返回按钮,您将看到活动被销毁。您还可以通过按下最近/概览按钮,然后向上滑动应用程序来终止活动。这是输出:
D/MainActivity: onPause
D/MainActivity: onStop
D/MainActivity: onDestroy
  1. 再次启动应用程序,然后旋转手机。您可能会发现手机不会旋转,显示屏是横向的。如果发生这种情况,请在虚拟设备顶部拉下状态栏,并选择设置中从右边数第二个的自动旋转按钮。图 2.5:快速设置栏,选中 Wi-Fi 和自动旋转按钮
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onResume
D/MainActivity: onPause
D/MainActivity: onStop
D/MainActivity: onSaveInstanceState
D/MainActivity: onDestroy
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onRestoreInstanceState
D/MainActivity: onResume

请注意,如步骤 11 所述,onSaveInstanceState(outState: Bundle?)回调的顺序可能会有所不同。

  1. 默认情况下,配置更改(例如旋转手机)会重新创建活动。您可以选择不在应用程序中处理某些配置更改,这样就不会重新创建活动。要对旋转进行此操作,请在AndroidManifest.xml文件的MainActivity中添加android:configChanges="orientation|screenSize|screenLayout"。启动应用程序,然后旋转手机,您将看到已添加到MainActivity的唯一回调:
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onResume

orientationscreenSize值对于不同的 Android API 级别具有相同的功能,用于检测屏幕方向的更改。screenLayout值检测可能在可折叠手机上发生的其他布局更改。这些是您可以选择自行处理的一些配置更改(另一个常见的更改是keyboardHidden,用于对访问键盘的更改做出反应)。应用程序仍将通过以下回调被系统通知这些更改:

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    Log.d(TAG, "onConfigurationChanged")
}

如果您将此回调函数添加到MainActivity,并且在清单中为MainActivity添加了android:configChanges="orientation|screenSize|screenLayout",您将在旋转时看到它被调用。

在这个练习中,您已经了解了主要的活动回调以及当用户通过系统与MainActivity进行常见操作时它们是如何运行的。在下一节中,您将学习保存状态和恢复状态,以及看到活动生命周期的更多示例。

保存和恢复活动状态

在本节中,你将探索你的 Activity 如何保存和恢复状态。正如你在上一节中学到的,配置更改,比如旋转手机,会导致 Activity 被重新创建。如果系统需要杀死你的应用程序以释放内存,也会发生这种情况。在这些情景中,保留 Activity 的状态然后恢复它是很重要的。在接下来的两个练习中,你将通过一个示例确保当TextView被创建并从用户的数据中填充表单后,用户的数据得到恢复。

练习 2.02:在布局中保存和恢复状态

在这个练习中,首先创建一个名为Save and Restore的应用程序,其中包含一个空的活动。你将创建的应用程序将有一个简单的表单,如果用户输入一些个人信息,就会提供一个用户最喜欢的餐厅的折扣码(实际上不会发送任何信息,所以你的数据是安全的):

  1. 打开strings.xml文件(位于app | src | main | res | values | strings.xml),并创建你的应用程序所需的以下字符串:
<resources>
    <string name="app_name">Save And Restore</string>
    <string name="header_text">Enter your name and email       for a discount code at Your Favorite Restaurant!        </string>
    <string name="first_name_label">First Name:</string>
    <string name="email_label">Email:</string>
    <string name="last_name_label">Last Name:</string>
    <string name="discount_code_button">GET DISCOUNT</string>
    <string name="discount_code_confirmation">Your       discount code is below %s. Enjoy!</string>
</resources>
  1. 你还将直接指定一些文本大小、布局边距和填充,因此在app | src | main | res | values文件夹中创建dimens.xml文件,并添加你的应用程序所需的尺寸(你可以通过在 Android Studio 中右键单击res | values文件夹,然后选择New values来完成):
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="grid_4">4dp</dimen>
    <dimen name="grid_8">8dp</dimen>
    <dimen name="grid_12">12dp</dimen>
    <dimen name="grid_16">16dp</dimen>
    <dimen name="grid_24">24dp</dimen>
    <dimen name="grid_32">32dp</dimen>
    <dimen name="default_text_size">20sp</dimen>
    <dimen name="discount_code_text_size">20sp</dimen>
</resources>

在这里,你正在指定练习中所需的所有尺寸。你将看到default_text_sizediscount_code_text_sizesp中指定。它们代表与密度无关的像素,不仅根据你的应用程序运行的设备的密度定义尺寸测量,而且根据用户在设置 | 显示 | 字体样式中定义的偏好更改文本大小(这可能是字体大小和样式或类似的,具体取决于你使用的确切设备)。

  1. R.layout.activity_main中,添加以下 XML,创建一个包含布局文件,并添加一个带有Enter your name and email for a discount code at Your Favorite Restaurant!文本的标题TextView。这是通过添加android:text属性和@string/header_text值来完成的:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="@dimen/grid_4"
    android:layout_marginTop="@dimen/grid_4"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/header_text"
        android:gravity="center"
        android:textSize="@dimen/default_text_size"
        android:paddingStart="@dimen/grid_8"
        android:paddingEnd="@dimen/grid_8"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/header_text"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

你正在使用ConstraintLayout来约束父视图和同级视图。

尽管通常应该使用样式来指定视图的显示,但你可以直接在 XML 中进行,就像这里的一些属性所做的那样。android:textSize属性的值是@dimen/default_text_size,在前面的代码块中定义,你可以使用它来避免重复,并且它使你能够在一个地方更改所有文本的大小。使用样式是设置文本大小的首选选项,因为你将获得合理的默认值,并且你可以在样式中覆盖该值,或者像你在这里做的那样,在单独的视图上覆盖该值。

其他影响定位的属性也直接在视图中指定。最常见的是填充和边距。填充应用在视图的内部,是文本和边框之间的空间。边距在视图的外部指定,是视图的外边缘之间的空间。例如,在ConstraintLayout中,android:padding设置了具有指定值的视图的填充。或者,你可以使用android:paddingTopandroid:paddingBottomandroid:paddingStartandroid:paddingEnd来指定视图的四个边的填充。这种模式也存在于指定边距,所以android:layout_margin指定了视图四个边的边距值,android:layoutMarginTopandroid:layoutMarginBottomandroid:layoutMarginStartandroid:layoutMarginEnd允许设置单独边的边距。

对于小于 17 的 API 级别(并且您的应用程序支持到 16),如果使用android:layoutMarginStart,则还必须添加android:layoutMarginLeft,如果使用android:layoutMarginEnd,则必须添加android:layoutMarginRight。为了在整个应用程序中保持一致性和统一性,您将边距和填充值定义为包含在dimens.xml文件中的尺寸。

要在视图中定位内容,您可以指定android:gravitycenter值会在View内垂直和水平方向上约束内容。

  1. 接下来,在header_text下方添加三个EditText视图,供用户添加他们的名字、姓氏和电子邮件:
    <EditText
        android:id="@+id/first_name"
        android:textSize="@dimen/default_text_size"
        android:layout_marginStart="@dimen/grid_24"
        android:layout_marginLeft="@dimen/grid_24"
        android:layout_marginEnd="@dimen/grid_16"
        android:layout_marginRight="@dimen/grid_16"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:hint="@string/first_name_label"
        android:inputType="text"
        app:layout_constraintTop_toBottomOf="@id/header_text"
        app:layout_constraintStart_toStartOf="parent" />
    <EditText
        android:textSize="@dimen/default_text_size"
        android:layout_marginEnd="@dimen/grid_24"
        android:layout_marginRight="@dimen/grid_24"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:hint="@string/last_name_label"
        android:inputType="text"
        app:layout_constraintTop_toBottomOf="@id/header_text"
        app:layout_constraintStart_toEndOf="@id/first_name"
        app:layout_constraintEnd_toEndOf="parent" />
    <!-- android:inputType="textEmailAddress" is not enforced, 
      but is a hint to the IME (Input Method Editor) usually a 
      keyboard to configure the display for an email - 
      typically by showing the '@' symbol -->
    <EditText
        android:id="@+id/email"
        android:textSize="@dimen/default_text_size"
        android:layout_marginStart="@dimen/grid_24"
        android:layout_marginLeft="@dimen/grid_24"
        android:layout_marginEnd="@dimen/grid_32"
        android:layout_marginRight="@dimen/grid_32"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/email_label"
        android:inputType="textEmailAddress"
        app:layout_constraintTop_toBottomOf="@id/first_name"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

EditText字段具有inputType属性,用于指定可以输入到表单字段中的输入类型。一些值,例如EditText上的number,限制了可以输入到字段中的输入,并在选择字段时建议键盘的显示方式。其他值,例如android:inputType="textEmailAddress",不会强制在表单字段中添加@符号,但会提示键盘显示它。

  1. 最后,添加一个按钮,供用户按下以生成折扣代码,并显示折扣代码本身和确认消息:
    <Button
        android:id="@+id/discount_button"
        android:textSize="@dimen/default_text_size"
        android:layout_marginTop="@dimen/grid_12"
        android:gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/discount_code_button"
        app:layout_constraintTop_toBottomOf="@id/email"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>
    <TextView
        android:id="@+id/discount_code_confirmation"
        android:gravity="center"
        android:textSize="@dimen/default_text_size"
        android:paddingStart="@dimen/grid_16"
        android:paddingEnd="@dimen/grid_16"
        android:layout_marginTop="@dimen/grid_8"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/discount_button"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        tools:text="Hey John Smith! Here is your discount code" />
    <TextView
        android:id="@+id/discount_code"
        android:gravity="center"
        android:textSize="@dimen/discount_code_text_size"
        android:textStyle="bold"
        android:layout_marginTop="@dimen/grid_8"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/discount_code           _confirmation"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        tools:text="XHFG6H9O" />

还有一些以前没有见过的属性。在 xml 布局文件顶部指定的 tools 命名空间xmlns:tools="http://schemas.android.com/tools"启用了在创建应用程序时可以使用的某些功能,以帮助配置和设计。这些属性在构建应用程序时会被移除,因此它们不会影响应用程序的整体大小。您正在使用tools:text属性来显示通常会显示在表单字段中的文本。当您从 Android Studio 中的Code视图切换到Design视图时,这有助于您看到布局在设备上的显示近似值。

  1. 运行应用程序,您应该看到输出显示在图 2.6中:图 2.6:首次启动时的 Activity 屏幕

图 2.6:首次启动时的 Activity 屏幕

  1. 在每个表单字段中输入一些文本:图 2.7:填写的 EditText 字段

图 2.7:填写的 EditText 字段

  1. 现在,使用虚拟设备控件中的第二个旋转按钮(1)将手机向右旋转 90 度:图 2.8:虚拟设备转为横向方向

图 2.8:虚拟设备转为横向方向

您能发现发生了什么吗?Last Name字段的值不再设置。它在重新创建活动的过程中丢失了。为什么呢?嗯,在EditText字段的情况下,如果它们有一个 ID 设置,Android 框架将保留字段的状态。

  1. 回到activity_main.xml布局文件,并为EditText字段中的Last Name值添加一个 ID:
<EditText
    android:id="@+id/last_name"
    android:textSize="@dimen/default_text_size"
    android:layout_marginEnd="@dimen/grid_24"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:hint="@string/last_name_label"
    android:inputType="text"
    app:layout_constraintTop_toBottomOf="@id/header_text"
    app:layout_constraintStart_toEndOf="@id/first_name"
    app:layout_constraintEnd_toEndOf="parent"
    tools:text="Last Name:"/>

当您再次运行应用程序并旋转设备时,它将保留您输入的值。您现在已经看到,您需要在EditText字段上设置一个 ID 来保留状态。对于EditText字段,当用户输入表单中的详细信息时,保留状态是常见的,因此如果字段有一个 ID,它就是默认行为。显然,您希望在用户输入一些文本后获取EditText字段的详细信息,这就是为什么要设置一个 ID,但是为其他字段类型,例如TextView,设置 ID 不会保留状态,如果您更新它们,您需要自己保存状态。为启用滚动的视图设置 ID,例如RecyclerView,也很重要,因为它可以在重新创建 Activity 时保持滚动位置。

现在,您已经为屏幕定义了布局,但尚未添加任何逻辑来创建和显示折扣代码。在下一个练习中,我们将解决这个问题。

本练习中创建的布局可在packt.live/35RSdgz找到

您可以在packt.live/3p1AZF3找到整个练习的代码

练习 2.03:使用回调保存和恢复状态

本练习的目的是将布局中的所有 UI 元素组合在一起,在用户输入数据后生成折扣码。为了做到这一点,您将不得不添加逻辑到按钮中,以检索所有EditText字段,然后向用户显示确认信息,并生成一个折扣码:

  1. 打开MainActivity.kt并替换项目创建时的默认空 Activity。这里显示了代码片段,但您需要使用下面给出的链接找到需要添加的完整代码块:
MainActivity.kt
14  class MainActivity : AppCompatActivity() {
15
16    private val discountButton: Button
17        get() = findViewById(R.id.discount_button)
18
19    private val firstName: EditText
20        get() = findViewById(R.id.first_name)
21
22    private val lastName: EditText
23        get() = findViewById(R.id.last_name)
24
25    private val email: EditText
26        get() = findViewById(R.id.email)
27  
28    private val discountCodeConfirmation: TextView
29        get() = findViewById(R.id             .discount_code_confirmation)
30
31    private val discountCode: TextView
32        get() = findViewById(R.id.discount_code)    
33  
34    override fun onCreate(savedInstanceState: Bundle?) {
35        super.onCreate(savedInstanceState)
36        setContentView(R.layout.activity_main)
37        Log.d(TAG, "onCreate")
You can find the complete code here http://packt.live/38XcdQS.

get() = …是属性的自定义访问器。

单击折扣按钮后,您将从first_namelast_name字段中检索值,将它们与一个空格连接,然后使用字符串资源格式化折扣码确认文本。您在strings.xml文件中引用的字符串如下:

<string name="discount_code_confirmation">Hey  %s! Here is   your discount code</string>

%s值指定在检索字符串资源时要替换的字符串值。通过在获取字符串时传入全名来完成此操作:

getString(R.string.discount_code_confirmation, fullName)

该代码是使用java.util包中的 UUID(通用唯一标识符)库生成的。这将创建一个唯一的 ID,然后使用take() Kotlin 函数来获取前八个字符并将其设置为大写。最后,在视图中设置 discount_code,隐藏键盘,并将所有表单字段设置回初始值。

  1. 运行应用程序并在名称和电子邮件字段中输入一些文本,然后单击GET DISCOUNT图 2.9:用户生成折扣码后显示的屏幕

图 2.9:用户生成折扣码后显示的屏幕

应用程序表现如预期,显示确认信息。

  1. 现在,旋转手机(按下虚拟设备图片右侧带箭头的第五个按钮)并观察结果:图 2.10:折扣码不再显示在屏幕上

图 2.10:折扣码不再显示在屏幕上

哦,不!折扣码不见了。TextView字段不保留状态,因此您必须自己保存状态。

  1. 返回MainActivity.kt并添加以下 Activity 回调函数:
override fun onRestoreInstanceState(savedInstanceState:   Bundle) {
    super.onRestoreInstanceState(savedInstanceState)
    Log.d(TAG, "onRestoreInstanceState")
}
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    Log.d(TAG, "onSaveInstanceState")
}

这些回调函数,正如它们的名称所声明的那样,使您能够保存和恢复实例状态。onSaveInstanceState(outState: Bundle)允许您在 Activity 被置于后台或销毁时添加键值对,您可以在onCreate(savedInstanceState: Bundle?)onRestoreInstanceState(savedInstanceState: Bundle)中检索这些键值对。

所以,一旦状态被设置,您有两个回调函数来检索状态。如果您在onCreate(savedInstanceState: Bundle)中进行了大量初始化,最好使用onRestoreInstanceState(savedInstanceState: Bundle)来在 Activity 被重新创建时检索此实例状态。这样,清楚地知道正在重新创建哪个状态。但是,如果只需要进行最小的设置,您可能更喜欢使用onCreate(savedInstanceState: Bundle)

无论您决定使用这两个回调函数中的哪一个,您都必须获取在onSaveInstanceState(outState: Bundle)调用中设置的状态。在练习的下一步中,您将使用onRestoreInstanceState(savedInstanceState: Bundle)

  1. MainActivity伴生对象中添加两个常量:
private const val DISCOUNT_CONFIRMATION_MESSAGE =   "DISCOUNT_CONFIRMATION_MESSAGE"
private const val DISCOUNT_CODE = "DISCOUNT_CODE"
  1. 现在,通过向 Activity 添加以下内容,将这些常量作为键添加到要保存和检索的值中:
    override fun onRestoreInstanceState(
        savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        Log.d(TAG, "onRestoreInstanceState")
        //Get the discount code or an empty           string if it hasn't been set
        discountCode.text = savedInstanceState           .getString(DISCOUNT_CODE,"")
        //Get the discount confirmation message           or an empty string if it hasn't been set
        discountCodeConfirmation.text =          savedInstanceState.getString(            DISCOUNT_CONFIRMATION_MESSAGE,"")
    }
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Log.d(TAG, "onSaveInstanceState")
        outState.putString(DISCOUNT_CODE,          discountCode.text.toString())
        outState.putString(DISCOUNT_CONFIRMATION_MESSAGE,          discountCodeConfirmation.text.toString())
    }
  1. 运行应用程序,输入值到EditText字段中,然后生成折扣代码。然后,旋转设备,您将看到折扣代码在图 2.11中得到恢复:图 2.11:折扣代码继续显示在屏幕上

图 2.11:折扣代码继续显示在屏幕上

在这个练习中,您首先看到了EditText字段的状态如何在配置更改时保持不变。您还使用了 Activity 生命周期onSaveInstanceState(outState: Bundle)onCreate(savedInstanceState: Bundle?)/onRestoreInstanceState(savedInstanceState: Bundle)函数保存和恢复了实例状态。这些函数提供了一种保存和恢复简单数据的方法。Android 框架还提供了ViewModel,这是一个生命周期感知的 Android 架构组件。如何保存和恢复此状态(使用ViewModel)的机制由框架管理,因此您不必像在前面的示例中那样显式管理它。您将在第十章Android 架构组件中学习如何使用此组件。

到目前为止,您已经创建了一个单屏应用程序。虽然简单的应用程序可以使用一个 Activity,但您可能希望将应用程序组织成处理不同功能的不同活动。因此,在下一节中,您将向应用程序添加另一个 Activity,并在活动之间导航。

与意图交互的活动

在 Android 中,意图是组件之间的通信机制。在您自己的应用程序中,很多时候,您希望在当前活动中发生某些操作时启动另一个特定的 Activity。指定将启动哪个 Activity 称为AndroidManifest.xml文件,并且您将看到在<intent-filter> XML 元素内设置了两个意图过滤器的示例:

<activity android:name=".MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.          LAUNCHER" />
    </intent-filter>
</activity>

使用<action android:name="android.intent.action.MAIN" />指定的意图表示这是应用程序的主入口点。根据设置的类别,它决定了应用程序启动时首先启动的 Activity。另一个指定的意图过滤器是<category android:name="android.intent.category.LAUNCHER" />,它定义了应用程序应该出现在启动器中。当结合在一起时,这两个意图过滤器定义了从启动器启动应用程序时应启动MainActivity。删除任何一个这些意图过滤器都会导致"Error running 'app': Default Activity not found"的消息。由于应用程序没有主入口点,因此无法启动,这也是当您删除<action android:name="android.intent.action.MAIN". />时发生的情况。如果删除<category android:name="android.intent.category.LAUNCHER" />并且不指定类别,则无法从任何地方启动它。

在下一个练习中,您将了解意图如何在应用程序中导航。

练习 2.04:意图简介

本练习的目标是创建一个简单的应用程序,使用意图根据用户的输入向用户显示文本。在 Android Studio 中创建一个新项目,并选择一个空的 Activity。设置好项目后,转到工具栏,选择File | New | Activity | Empty Activity。将其命名为WelcomeActivity,并将所有其他默认设置保留不变。它将被添加到AndroidManifest.xml文件中,准备使用。现在您添加了WelcomeActivity后的问题是如何处理它?MainActivity在启动应用程序时启动,但您需要一种方法来启动WelcomeActivity,然后,可选地,向其传递数据,这就是使用意图的时候:

  1. 为了通过这个示例,将以下代码添加到strings.xml文件中。这些是您将在应用程序中使用的字符串:
<resources>
    <string name="app_name">Intents Introduction</string>
    <string name="header_text">Please enter your name and       then we\'ll get started!</string>
    <string name="welcome_text">Hello %s, we hope you enjoy       using the app!</string>
    <string name="full_name_label">Enter your full       name:</string>
    <string name="submit_button_text">SUBMIT</string>
</resources>
  1. 接下来,在themes.xml文件中更新样式,添加标题样式。
    <style name="header" parent=      "TextAppearance.AppCompat.Title">
        <item name="android:gravity">center</item>
        <item name="android:layout_marginStart">24dp</item>
        <item name="android:layout_marginEnd">24dp</item>
        <item name="android:layout_marginLeft">24dp</item>
        <item name="android:layout_marginRight">24dp</item>
        <item name="android:textSize">20sp</item>
    </style>
    <!--  continued below -->

接下来,添加fullnamebuttonpage样式:

    <style name="full_name" parent=      "TextAppearance.AppCompat.Body1">
        <item name="android:layout_marginTop">16dp</item>
        <item name="android:layout_gravity">center</item>
        <item name="android:textSize">20sp</item>
        <item name="android:inputType">text</item>
    </style>
    <style name="button" parent=      "TextAppearance.AppCompat.Button">
        <item name="android:layout_margin">16dp</item>
        <item name="android:gravity">center</item>
        <item name="android:textSize">20sp</item>
    </style>
    <style name="page">
        <item name="android:layout_margin">8dp</item>
        <item name="android:padding">8dp</item>
    </style>

通常,您不会直接在样式中指定尺寸。它们应该被引用为dimens值,这样它们可以在一个地方更新,更加统一,并且可以被标记为代表实际尺寸是什么。出于简单起见,这里没有这样做。

  1. 接下来,在activity_main.xml中更改MainActivity布局并添加一个TextView标题:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    style="@style/page"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/header_text"
        style="@style/header"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/header_text"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

这应该是显示的第一个视图,并且由于它使用ConstraintLayout约束到其父级的顶部,它显示在屏幕顶部。由于它还被约束到其父级的开始和结束,当您运行应用程序时,它将显示在中间,如图 2.12所示:

图 2.12:在添加 TextView 标题后的初始应用显示

图 2.12:在添加 TextView 标题后的初始应用显示

  1. 现在,在activity_main.xml文件中,在TextView标题下方添加一个用于全名的EditText字段和一个用于提交按钮的Button字段:
    <EditText
        android:id="@+id/full_name"
        style="@style/full_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:hint="@string/full_name_label"
        app:layout_constraintTop_toBottomOf="@id/header_text"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>
    <Button
        android:id="@+id/submit_button"
        style="@style/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/submit_button_text"
        app:layout_constraintTop_toBottomOf="@id/full_name"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>

运行应用程序时,显示如图 2.13所示:

图 2.13:在添加 EditText 全名字段和提交按钮后的应用显示

图 2.13:在添加 EditText 全名字段和提交按钮后的应用显示

现在,您需要配置按钮,以便当点击按钮时,它从EditText字段中检索用户的全名,然后将其发送到启动WelcomeActivity的意图中。

  1. 更新activity_welcome.xml布局文件以准备进行此操作:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    style="@style/page"
    tools:context=".WelcomeActivity">
    <TextView
        android:id="@+id/welcome_text"
        style="@style/header"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        tools:text="Welcome John Smith we hope you enjoy           using the app!"/>
</androidx.constraintlayout.widget.ConstraintLayout>

您正在添加一个TextView字段来显示用户的全名和欢迎消息。创建全名和欢迎消息的逻辑将在下一步中显示。

  1. 现在,打开MainActivity并在类头部添加一个常量值,并更新导入:
package com.example.intentsintroduction
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
const val FULL_NAME_KEY = "FULL_NAME_KEY"
class MainActivity : AppCompatActivity()… 

您将使用常量来设置保存用户全名的键,通过在意图中设置它。

  1. 然后,在onCreate(savedInstanceState: Bundle?)的底部添加以下代码:
findViewById<Button>(R.id.submit_button).setOnClickListener {
    val fullName = findViewById<EditText>(R.id.full_name)      .text.toString().trim()
    if (fullName.isNotEmpty()) {
        //Set the name of the Activity to launch
        Intent(this, WelcomeActivity::class.java)          .also { welcomeIntent ->
            //Add the data
            welcomeIntent.putExtra(FULL_NAME_KEY, fullName)
            //Launch
            startActivity(welcomeIntent)
        }
    } else {
        Toast.makeText(this, getString(          R.string.full_name_label),           Toast.LENGTH_LONG).show()
    }
}

有逻辑来检索全名的值并验证用户是否已填写;否则,如果为空,将显示一个弹出式提示消息。然而,主要逻辑是获取EditText字段的fullName值,并创建一个显式意图来启动WelcomeActivityalso作用域函数允许您继续使用您刚刚创建的意图Intent(this, WelcomeActivity::class.java),并进一步操作它,使用一个叫做it的东西,但为了清晰起见,我们将其称为welcomeIntent。然后,您可以在welcomeIntent.putExtra(FULL_NAME_KEY, fullName)行中使用 lambda 参数来向意图添加fullName字段,使用FULL_NAME_KEY作为键,fullName作为意图持有的额外值。

然后,您使用意图启动WelcomeActivity

  1. 现在,运行应用程序,输入您的姓名,然后按提交,如图 2.14所示:图 2.14:当意图额外数据未被处理时显示的默认屏幕

图 2.14:当意图额外数据未被处理时显示的默认屏幕

嗯,这并不是很令人印象深刻。您已经添加了发送用户姓名的逻辑,但没有显示它。

  1. 要实现这一点,请打开WelcomeActivity并在onCreate(savedInstanceState: Bundle?)回调的底部添加以下内容:
//Get the intent which started this activity
intent?.let {
    //Set the welcome message
    val fullName = it.getStringExtra(FULL_NAME_KEY)
    findViewById<TextView>(R.id.welcome_text).text =
      getString(R.string.welcome_text, fullName)
}

我们使用intent?.let{}引用启动 Activity 的意图,指定如果意图不为空,则将运行let块,let是一个作用域函数,您可以在其中使用默认的 lambda 参数it引用上下文对象。这意味着您不必在使用之前分配变量。您使用it引用意图,然后通过获取FULL_NAME_KEY额外键从MainActivity意图中传递的字符串值。然后,通过从资源中获取字符串并传入从意图中检索的fullname值来格式化<string name="welcome_text">Hello %s, we hope you enjoy using the app!</string>资源字符串。最后,将其设置为TextView的文本。

  1. 再次运行应用程序,将显示一个简单的问候语,如图 2.15所示:图 2.15:显示用户欢迎消息

图 2.15:显示用户欢迎消息

尽管这个练习在布局和用户交互方面非常简单,但它可以演示意图的一些核心原则。您将使用它们来添加导航,并从应用程序的一个部分创建用户流程到另一个部分。在下一节中,您将看到如何使用意图来启动一个 Activity,并从中接收结果。

练习 2.05:从 Activity 中检索结果

对于某些用户流程,您只会启动一个 Activity,目的是从中检索结果。这种模式通常用于请求使用特定功能的权限,弹出一个带有关于用户是否同意访问联系人、日历等的问题的对话框,然后将结果报告给调用 Activity。在这个练习中,您将要求用户选择他们喜欢的彩虹颜色,然后一旦选择了,就在调用 Activity 中显示结果:

  1. 创建一个名为Activity Results的新项目,并将以下字符串添加到strings.xml文件中:
    <string name="header_text_main">Please click the button       below to choose your favorite color of the rainbow!        </string>
    <string name="header_text_picker">Rainbow Colors</string>
    <string name="footer_text_picker">Click the button       above which is your favorite color of the rainbow.        </string>
    <string name="color_chosen_message">%s is your favorite       color!</string>
    <string name="submit_button_text">CHOOSE COLOR</string>
    <string name="red">RED</string>
    <string name="orange">ORANGE</string>
    <string name="yellow">YELLOW</string>
    <string name="green">GREEN</string>
    <string name="blue">BLUE</string>
    <string name="indigo">INDIGO</string>
    <string name="violet">VIOLET</string>
    <string name="unexpected_color">Unexpected color</string>
  1. 将以下颜色添加到 colors.xml
    <!--Colors of the Rainbow -->
    <color name="red">#FF0000</color>
    <color name="orange">#FF7F00</color>
    <color name="yellow">#FFFF00</color>
    <color name="green">#00FF00</color>
    <color name="blue">#0000FF</color>
    <color name="indigo">#4B0082</color>
    <color name="violet">#9400D3</color>
  1. 将相关的新样式添加到themes.xml文件。下面显示了一个片段,但您需要按照给定的链接查看您需要添加的所有代码:
themes.xml
11    <!-- Style for page header on launch screen -->
12    <style name="header" parent=        "TextAppearance.AppCompat.Title">
13        <item name="android:gravity">center</item>
14        <item name="android:layout_marginStart">24dp</item>
15        <item name="android:layout_marginEnd">24dp</item>
16        <item name="android:layout_marginLeft">24dp</item>
17        <item name="android:layout_marginRight">24dp</item>
18        <item name="android:textSize">20sp</item>
19    </style>
20
21    <!-- Style for page header on rainbow color         selection screen -->
22    <style name="header.rainbows" parent="header">
23        <item name="android:textSize">22sp</item>
24        <item name="android:textAllCaps">true</item>
25    </style>
You can find the complete code here http://packt.live/39J0qES.

注意

出于简单起见,尚未将尺寸添加到dimens.xml中。

  1. 现在,您必须设置将在MainActivity中接收的结果的 Activity。转到文件 | 新建 | Activity | EmptyActivity,创建一个名为RainbowColorPickerActivity的 Activity。

  2. 更新activity_main.xml布局文件以显示标题、按钮,然后是隐藏的android:visibility="gone"视图,当报告结果时将其设置为可见并设置为用户喜欢的彩虹颜色:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    style="@style/page"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/header_text"
        style="@style/header"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/header_text_main"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>
    <Button
        android:id="@+id/submit_button"
        style="@style/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/submit_button_text"
        app:layout_constraintTop_toBottomOf="@id/header_text"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>
    <TextView
        android:id="@+id/rainbow_color"
        style="@style/color_block"
        android:visibility="gone"
        app:layout_constraintTop_toBottomOf="@id/          submit_button"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        tools:text="This is your favorite color of the           rainbow"/>
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 您将使用startActivityForResult(Intent intent, int requestCode)函数从您启动的 Activity 中获取结果。为了确保您收到的结果是您期望的操作,您必须设置requestCode。添加此请求代码的常量,以及另外两个用于在意图中使用的值的键,以及在 MainActivity 类头部上方设置一个默认颜色,以便显示如下所示,带有包名和导入:
package com.example.activityresults
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
const val PICK_RAINBOW_COLOR_INTENT = 1  // The request code
// Key to return rainbow color name in intent
const val RAINBOW_COLOR_NAME = "RAINBOW_COLOR_NAME" 
// Key to return rainbow color in intent
const val RAINBOW_COLOR = "RAINBOW_COLOR" 
const val DEFAULT_COLOR = "#FFFFFF" // White
class MainActivity : AppCompatActivity()…
  1. 然后,在MainActivityonCreate(savedInstanceState: Bundle?)底部添加以下内容:
        findViewById<Button>(R.id.submit_button).setOnClickListener {
        //Set the name of the Activity to launch passing 
        //in request code
            Intent(this, RainbowColorPickerActivity::class.java)
            .also { rainbowColorPickerIntent ->
                startActivityForResult(
                    rainbowColorPickerIntent,
                    PICK_RAINBOW_COLOR_INTENT
                )
            }
        }

这使用了您之前使用also的语法来创建一个意图,并使用具有上下文对象的命名 lambda 参数。在这种情况下,您使用rainbowColorPickerIntent来引用您刚刚使用Intent(this, RainbowColorPickerActivity::class.java)创建的意图。

关键调用是startActivityForResult(rainbowColorPickerIntent, PICK_RAINBOW_COLOR_INTENT),它使用请求代码启动RainbowColorPickerActivity。那么我们什么时候收到这个结果呢?当它被设置时,您将通过覆盖onActivityResult(requestCode: Int, resultCode: Int, data: Intent?)来接收结果。

此调用指定了请求代码,您可以检查以确认它与您发送的请求代码相同。resultCode报告操作的状态。您可以设置自己的代码,但通常设置为Activity.RESULT_OKActivity.RESULT_CANCELED,最后一个参数data是由为结果启动的活动设置的意图,RainbowColorPickerActivity。

  1. MainActivityonActivityResult(requestCode: Int, resultCode: Int, data: Intent?)回调中添加以下内容:
override fun onActivityResult(requestCode: Int, resultCode:   Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == PICK_RAINBOW_COLOR_INTENT &&       resultCode == Activity.RESULT_OK) {
        val backgroundColor = data?.getIntExtra(RAINBOW_COLOR,           Color.parseColor(DEFAULT_COLOR)) ?:             Color.parseColor(DEFAULT_COLOR)
        val colorName = data?.getStringExtra           (RAINBOW_COLOR_NAME) ?: ""
        val colorMessage = getString           (R.string.color_chosen_message, colorName)
        val rainbowColor = findViewById           <TextView>(R.id.rainbow_color)
rainbowColor.setBackgroundColor(ContextCompat.getColor(this,   backgroundColor))
        rainbowColor.text = colorMessage
        rainbowColor.isVisible = true
    }
}
  1. 因此,您要检查请求代码和响应代码的值是否符合预期,然后继续查询意图数据以获取您期望的值。对于此练习,您希望获取背景颜色名称(colorName)和颜色的十六进制值(backgroundColor),以便我们可以显示它。?运算符检查值是否为 null(即未在意图中设置),如果是,则 Elvis 运算符(?:)设置默认值。颜色消息使用字符串格式设置消息,用颜色名称替换资源值中的占位符。现在您已经获得了颜色,可以使rainbow_color TextView字段可见,并将视图的背景颜色设置为backgroundColor,并添加显示用户最喜欢的彩虹颜色名称的文本。

  2. 对于RainbowColorPickerActivity活动的布局,您将显示一个按钮,每个按钮都有彩虹的七种颜色的背景颜色和颜色名称:REDORANGEYELLOWGREENBLUEINDIGOVIOLET。这些将显示在LinearLayout垂直列表中。在课程中的大多数布局文件中,您将使用ConstrainLayout,因为它提供了对单个视图的精细定位。对于需要显示少量项目的垂直或水平列表的情况,LinearLayout也是一个不错的选择。如果需要显示大量项目,则RecyclerView是更好的选择,因为它可以缓存单行的布局并回收不再显示在屏幕上的视图。您将在第五章RecyclerView中了解有关RecyclerView的信息。

  3. RainbowColorPickerActivity中,您需要做的第一件事是创建布局。这将是您向用户提供选择其最喜欢的彩虹颜色的选项的地方。

  4. 打开activity_rainbow_color_picker.xml并替换布局,插入以下内容:

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

我们正在添加ScrollView以允许内容在屏幕高度无法显示所有项目时滚动。ScrollView只能接受一个子视图,即要滚动的布局。

  1. 接下来,在ScrollView中添加LinearLayout以按添加顺序显示包含的视图,并添加一个标题和页脚。第一个子视图是一个带有页面标题的标题,最后添加的视图是一个带有指示用户选择其最喜欢的颜色的说明的页脚:
    <LinearLayout
        style="@style/page"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:orientation="vertical"
        tools:context=".RainbowColorPickerActivity">
    <TextView
        android:id="@+id/header_text"
        style="@style/header.rainbows"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/header_text_picker"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>
    <TextView
        style="@style/body"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/footer_text_picker"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>
    </LinearLayout>

应用程序中的布局现在应如图 2.16所示:

图 2.16:带有标题和页脚的彩虹颜色屏幕

图 2.16:带有标题和页脚的彩虹颜色屏幕

  1. 现在,最后,在标题和页脚之间添加按钮视图以选择彩虹的颜色,然后运行应用程序:
    <Button
        android:id="@+id/red_button"
        style="@style/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/red"
        android:text="@string/red"/>
    <Button
        .......
        android:text="@string/orange"/>
    <Button
        .......
        android:text="@string/yellow"/>
    <Button
        .......
        android:text="@string/green"/>
    <Button
        .......
        android:text="@string/blue"/>
    <Button
        .......
        android:text="@string/indigo"/>
    <Button
        .......
        android:text="@string/violet"/>

前面创建的布局可在以下链接找到:packt.live/2M7okBX

这些视图是按照彩虹颜色的顺序显示的按钮。尽管按钮标签是颜色和背景颜色,但最重要的 XML 属性是id。这是您将在 Activity 中使用的内容,以准备返回给调用活动的结果。

  1. 现在,打开RainbowColorPickerActivity并用以下内容替换内容:
package com.example.activityresults
import android.app.Activity
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.widget.Toast
class RainbowColorPickerActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_rainbow_color_picker)
    }
    private fun setRainbowColor(colorName: String, color: Int) {
        Intent().let { pickedColorIntent ->
            pickedColorIntent.putExtra(RAINBOW_COLOR_NAME,               colorName)
            pickedColorIntent.putExtra(RAINBOW_COLOR, color)
            setResult(Activity.RESULT_OK, pickedColorIntent)
            finish()
        }
    }
}

这是创建意图并放置相关的字符串额外信息的函数,其中包含彩虹颜色名称和彩虹颜色hex值。然后将结果返回给调用的 Activity,由于你不再需要这个 Activity,所以调用finish()以显示调用的 Activity。你通过为布局中的所有按钮添加监听器来检索用户选择的彩虹颜色。

  1. 现在,在onCreate(savedInstanceState: Bundle?)的底部添加以下内容:
val colorPickerClickListener = View.OnClickListener { view ->
    when (view.id) {
        R.id.red_button -> setRainbowColor(          getString(R.string.red), R.color.red)
        R.id.orange_button -> setRainbowColor(          getString(R.string.orange), R.color.orange)
        R.id.yellow_button -> setRainbowColor(          getString(R.string.yellow), R.color.yellow)
        R.id.green_button -> setRainbowColor(          getString(R.string.green), R.color.green)
        R.id.blue_button -> setRainbowColor(          getString(R.string.blue), R.color.blue)
        R.id.indigo_button -> setRainbowColor(          getString(R.string.indigo), R.color.indigo)
        R.id.violet_button -> setRainbowColor(          getString(R.string.violet), R.color.violet)
        else -> {
            Toast.makeText(this, getString(              R.string.unexpected_color), Toast.LENGTH_LONG)                .show()
        }
    }
}

在前面的代码中添加的colorPickerClickListener点击监听器确定了要为setRainbowColor(colorName: String, color: Int)函数设置哪些颜色,它使用了when语句。when语句相当于 Java 和基于 C 的语言中的switch语句。它允许满足多个条件并执行一个分支,并且更加简洁。在前面的例子中,view.id与彩虹布局按钮的 ID 匹配,找到后执行该分支,将颜色名称和十六进制值从字符串资源传递到setRainbowColor(colorName: String, color: Int)中。

  1. 现在,将此点击监听器添加到布局中的按钮:
findViewById<View>(R.id.red_button).setOnClickListener(  colorPickerClickListener)
findViewById<View>(R.id.orange_button).setOnClickListener(  colorPickerClickListener)
findViewById<View>(R.id.yellow_button).setOnClickListener(  colorPickerClickListener)
findViewById<View>(R.id.green_button).setOnClickListener(  colorPickerClickListener)
findViewById<View>(R.id.blue_button).setOnClickListener(  colorPickerClickListener)
findViewById<View>(R.id.indigo_button).setOnClickListener(  colorPickerClickListener)
findViewById<View>(R.id.violet_button).setOnClickListener(  colorPickerClickListener)

每个按钮都附加了一个ClickListener接口,由于操作相同,它们都附加了相同的ClickListener接口。然后,当按钮被按下时,它设置用户选择的颜色的结果并将其返回给调用的 Activity。

  1. 现在运行应用程序并按下“选择颜色”按钮,如图 2.17所示:图 2.17:彩虹颜色应用程序启动屏幕

图 2.17:彩虹颜色应用程序启动屏幕

  1. 现在,选择你彩虹中最喜欢的颜色:图 2.18:彩虹颜色选择屏幕

图 2.18:彩虹颜色选择屏幕

  1. 一旦你选择了你最喜欢的颜色,屏幕上会显示你最喜欢的颜色,如图 2.19所示:图 2.19:应用程序显示所选颜色

图 2.19:应用程序显示所选颜色

如你所见,应用程序显示了你选择的最喜欢的颜色,如图 2.19所示。

这个练习向你介绍了使用startActivityForResult创建用户流程的另一种方式。这对于执行需要在继续用户在应用程序中的流程之前获得结果的专用任务非常有用。接下来,你将探索启动模式以及它们在构建应用程序时如何影响用户旅程的流程。

意图、任务和启动模式

到目前为止,你一直在使用创建 Activity 和从一个 Activity 到另一个 Activity 的标准行为。你一直使用的是默认的流程,在大多数情况下,这将是你选择使用的流程。当你使用默认行为从启动器打开应用程序时,它会创建自己的任务,并且你创建的每个 Activity 都会添加到后退堆栈中,因此当你连续打开三个 Activity 作为用户旅程的一部分时,按三次返回按钮将使用户返回到之前的屏幕/Activity,然后返回到设备的主屏幕,同时保持应用程序打开。

这种类型的 Activity 的启动模式称为“标准”;这是默认的,不需要在AndroidManifest.xml的 Activity 元素中指定。即使你连续三次启动相同的 Activity,仍然会有三个展现之前描述行为的相同 Activity 的实例。

对于一些应用程序,您可能希望更改此行为。最常用的不符合此模式的场景是当您想要重新启动活动而不创建新的单独实例时。这种情况的常见用例是当您有一个主菜单和用户可以阅读不同新闻故事的主屏幕。一旦用户浏览到单个新闻故事,然后从菜单中按下另一个新闻故事标题,当用户按下返回按钮时,他们将期望返回到主屏幕而不是以前的新闻故事。在这里可以帮助的启动模式称为singleTop。如果singleTop活动位于任务的顶部(在这种情况下,“顶部”表示最近添加的),则启动相同的singleTop活动时,它将使用相同的活动并运行onNewIntent回调,而不是创建新的活动。在上述情况中,这将使用相同的活动来显示不同的新闻故事。在此回调中,您将收到一个意图,然后可以像以前在onCreate中一样处理此意图。

还有两种启动模式需要注意,称为SingleTaskSingleInstance。这些不是用于一般用途,只用于特殊情况。对于这两种启动模式,应用程序中只能存在一种此类型的活动,并且它始终位于其任务的根部。如果使用此启动模式启动活动,它将创建一个新任务。如果已经存在,则将通过onNewIntent调用路由意图,而不会创建另一个实例。SingleTaskSingleInstance之间的唯一区别是SingleInstance是其任务中唯一的活动。不能将新活动启动到其任务中。相反,SingleTask允许其他活动启动到其任务中,但SingleTask活动始终位于根部。

这些启动模式可以添加到AndroidManifest.xml的 XML 中,也可以通过添加意图标志以编程方式创建。最常用的是以下几种:

  • FLAG_ACTIVITY_NEW_TASK:将活动启动到新任务中。

  • FLAG_ACTIVITY_CLEAR_TASK:清除当前任务,因此完成所有活动并启动当前任务的根处的活动。

  • FLAG_ACTIVITY_SINGLE_TOP:复制launchMode="singleTop" XML 的启动模式。

  • FLAG_ACTIVITY_CLEAR_TOP:删除所有高于同一活动的任何其他实例的活动。如果在标准启动模式活动上启动此活动,则它将清除任务,直到第一个现有实例的同一活动,并然后启动同一活动的另一个实例。这可能不是您想要的,您可以使用FLAG_ACTIVITY_SINGLE_TOP标志启动此标志,以清除所有活动,直到与您要启动的活动的相同实例,并且不创建新实例,而是将新意图路由到现有活动。要使用这两个intent标志创建活动,您需要执行以下操作:

val intent = Intent(this, MainActivity::class.java).apply {
    flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or
    Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(intent)

如果意图启动具有前面代码块中指定的一个或多个意图标志的活动,则指定的启动模式将覆盖在AndroidManifest.xml文件中设置的启动模式。

意图标志可以以多种方式组合。有关更多信息,请参阅官方文档developer.android.com/reference/android/content/Intent

您将在下一个练习中探索这两种启动模式的行为差异。

练习 2.06:设置活动的启动模式

这个练习有许多不同的布局文件和活动,用来说明两种最常用的启动模式。请从packt.live/2LFWo8t下载代码,然后我们将在packt.live/2XUo3Vk上进行练习:

  1. 打开activity_main.xml文件并检查它。

这说明了在使用布局文件时的一个新概念。如果您有一个布局文件,并且希望在另一个布局中包含它,您可以使用<include>XML 元素(查看以下布局文件片段)。

<include layout="@layout/letters"
    android:id="@+id/letters_layout"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toBottomOf="@id/      launch_mode_standard"/>
<include layout="@layout/numbers"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toBottomOf="@id/      launch_mode_single_top"/> 

前面的布局使用include XML 元素来包含两个布局文件:letters.xmlnumbers.xml

  1. 打开并检查res | layout文件夹中的letters.xmlnumbers.xml文件。这些文件非常相似,只是通过按钮本身的 ID 和它们显示的文本标签来区分它们包含的按钮。

  2. 运行应用程序,您将看到以下屏幕:图 2.20:应用程序显示标准和 single top 模式

图 2.20:应用程序显示标准和 single top 模式

为了演示/说明standardsingleTop活动启动模式之间的区别,您必须连续启动两到三个活动。

  1. 打开MainActivity并检查签名后的onCreate(savedInstanceState: Bundle?)代码块的内容:
    val buttonClickListener = View.OnClickListener { view ->
        when (view.id) {
            R.id.letterA -> startActivity(Intent(this,               ActivityA::class.java))
            //Other letters and numbers follow the same pattern/flow
            else -> {
                Toast.makeText(
                    this,
                    getString(R.string.unexpected_button_pressed),
                    Toast.LENGTH_LONG
                )
                .show()
            }
        }
    }
    findViewById<View>(R.id.letterA).setOnClickListener(buttonClickListener)
    //The buttonClickListener is set on all the number and letter views
}

主要活动和其他活动中包含的逻辑基本相同。它显示一个活动,并允许用户按下按钮使用与在练习 2.05 中看到的相同的逻辑来启动另一个活动。

  1. 打开AndroidManifest.xml文件,您将看到以下内容:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.launchmodes">
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.LaunchModes">
        <activity android:name=".ActivityA"           android:launchMode="standard"/>
        <activity android:name=".ActivityB"           android:launchMode="standard"/>
        <activity android:name=".ActivityC"           android:launchMode="standard"/>
        <activity android:name=".ActivityOne"           android:launchMode="singleTop"/>
        <activity android:name=".ActivityTwo"           android:launchMode="singleTop"/>
        <activity android:name=".ActivityThree"           android:launchMode="singleTop"/>
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name=                  "android.intent.action.MAIN" />
                <category android:name=                  "android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

您可以根据主屏幕上按下的按钮启动一个活动,但字母和数字活动具有不同的启动模式,您可以在AndroidManifest.xml文件中看到指定的启动模式。

在此处指定了standard启动模式,以说明standardsingleTop之间的区别,但standard是默认值,如果android:launchMode XML 属性不存在,则会启动 Activity。

  1. 按下Standard标题下的字母之一,您将看到以下屏幕(带有A或字母CB):图 2.21:应用程序显示标准活动

图 2.21:应用程序显示标准活动

  1. 继续按下任何字母按钮,这将启动另一个活动。已添加日志以显示启动活动的顺序。以下是随机按下 10 个字母活动后的日志:
2019-10-23 20:50:51.097 15281-15281/com.example.launchmodes D/MainActivity: onCreate
2019-10-23 20:51:16.182 15281-15281/com.example.launchmodes D/Activity B: onCreate
2019-10-23 20:51:18.821 15281-15281/com.example.launchmodes D/Activity B: onCreate
2019-10-23 20:51:19.353 15281-15281/com.example.launchmodes D/Activity C: onCreate
2019-10-23 20:51:20.334 15281-15281/com.example.launchmodes D/Activity A: onCreate
2019-10-23 20:51:20.980 15281-15281/com.example.launchmodes D/Activity B: onCreate
2019-10-23 20:51:21.853 15281-15281/com.example.launchmodes D/Activity B: onCreate
2019-10-23 20:51:23.007 15281-15281/com.example.launchmodes D/Activity C: onCreate
2019-10-23 20:51:23.887 15281-15281/com.example.launchmodes D/Activity B: onCreate
2019-10-23 20:51:24.349 15281-15281/com.example.launchmodes D/Activity C: onCreate

如果您观察前面的日志,每次用户按下启动模式中的字符按钮时,都会启动并添加一个新的字符 Activity 到返回堆栈中。

  1. 关闭应用程序,确保它不在后台(或在最近/概述菜单中),而是实际关闭,然后再次打开应用程序,并按下Single Top标题下的数字按钮之一:图 2.22:应用程序显示 Single Top 活动

图 2.22:应用程序显示 Single Top 活动

  1. 按下数字按钮 10 次,但确保在按下另一个数字按钮之前至少连续按下相同的数字按钮两次。

您应该在Logcat窗口(View | Tool Windows | Logcat)中看到类似以下的日志:

2019-10-23 21:04:50.201 15549-15549/com.example.launchmodes D/MainActivity: onCreate
2019-10-23 21:05:04.503 15549-15549/com.example.launchmodes D/Activity 2: onCreate
2019-10-23 21:05:08.262 15549-15549/com.example.launchmodes D/Activity 3: onCreate
2019-10-23 21:05:09.133 15549-15549/com.example.launchmodes D/Activity 3: onNewIntent
2019-10-23 21:05:10.684 15549-15549/com.example.launchmodes D/Activity 1: onCreate
2019-10-23 21:05:12.069 15549-15549/com.example.launchmodes D/Activity 2: onNewIntent
2019-10-23 21:05:13.604 15549-15549/com.example.launchmodes D/Activity 3: onCreate
2019-10-23 21:05:14.671 15549-15549/com.example.launchmodes D/Activity 1: onCreate
2019-10-23 21:05:27.542 15549-15549/com.example.launchmodes D/Activity 3: onNewIntent
2019-10-23 21:05:31.593 15549-15549/com.example.launchmodes D/Activity 3: onNewIntent
2019-10-23 21:05:38.124 15549-15549/com.example.launchmodes D/Activity 1: onCreate

您会注意到,当您再次按下相同的按钮时,不会调用onCreate,而是调用onNewIntent。如果按下返回按钮,您会注意到返回到主屏幕只需要不到 10 次点击,反映出并未创建 10 个活动。

活动 2.01:创建登录表单

此活动的目的是创建一个带有用户名和密码字段的登录表单。一旦提交这些字段中的值,请检查这些输入的值与硬编码的值是否匹配,并在它们匹配时显示欢迎消息,或者在它们不匹配时显示错误消息,并将用户返回到登录表单。实现此目的所需的步骤如下:

  1. 创建一个带有用户名和密码EditText视图和一个LOGIN按钮的表单。

  2. 为按钮添加一个ClickListener接口以对按钮按下事件做出反应。

  3. 验证表单字段是否已填写。

  4. 检查提交的用户名和密码字段与硬编码的值是否匹配。

  5. 如果成功,显示带有用户名的欢迎消息并隐藏表单。

  6. 如果不成功,显示错误消息并将用户重定向回表单。

有几种可能的方法可以尝试完成这个活动。以下是您可以采用的三种方法的想法:

  • 使用singleTop Activity 并发送意图到同一个 Activity 以验证凭据。

  • 使用一个标准Activity 将用户名和密码传递到另一个 Activity 并验证凭据。

  • 使用startActivityForResult在另一个 Activity 中进行验证,然后返回结果。

完成的应用程序,在首次加载时,应该如图 2.23所示:

图 2.23:首次加载时的应用程序显示

图 2.23:首次加载时的应用程序显示

注意

这个活动的解决方案可以在以下网址找到:http://packt.live/3sKj1cp

本章中所有练习和活动的源代码位于packt.live/3o12sp4

总结

在本章中,您已经涵盖了应用程序如何与 Android 框架交互的许多基础知识,从 Activity 生命周期回调到在活动中保留状态,从一个屏幕导航到另一个屏幕,以及意图和启动模式如何实现这一点。这些都是您需要了解的核心概念,以便进入更高级的主题。

在下一章中,您将介绍片段以及它们如何适应应用程序的架构,以及更多探索 Android 资源框架。

第三章:使用片段开发 UI

概述

本章涵盖了片段和片段的生命周期。它演示了如何使用它们来构建高效和动态的布局,以响应不同的屏幕尺寸和配置,并允许您将 UI 划分为不同的部分。在本章结束时,您将能够创建静态和动态片段,将数据传递到片段和活动,并使用 Jetpack 导航组件详细说明片段如何组合在一起。

介绍

在上一章中,我们探讨了 Android活动生命周期,并研究了它在应用程序中用于在屏幕之间导航的方式。我们还分析了定义了屏幕之间过渡方式的各种启动模式。在本章中,您将探索片段。片段是 Android 活动的一部分、部分或片段,正如其名称所暗示的那样。

在整个章节中,您将学习如何使用片段,看到它们可以存在于多个活动中,并发现多个片段可以在一个活动中使用。您将首先向活动添加简单的片段,然后进一步了解静态和动态片段之间的区别。片段可用于简化使用双面板布局的 Android 平板电脑的更大形态因素创建布局。例如,如果您有一个中等大小的手机屏幕,并且想要包含一个新闻故事列表,您可能只有足够的空间来显示列表。如果您在平板电脑上查看相同的故事列表,您将有更多的可用空间,因此您可以显示相同的列表,还可以在列表右侧显示故事本身。屏幕的每个不同区域都可以使用一个片段。然后您可以在手机和平板电脑上使用相同的片段。您可以从重用和简化布局中受益,并且不必重复创建类似的功能。

一旦您探索了如何创建和使用片段,您将学习如何使用片段组织用户旅程。您将应用一些已建立的实践方法来使用片段。最后,您将学习如何通过使用 Android Jetpack 导航组件创建导航图来简化片段使用,该组件允许您指定将片段与目的地链接在一起。

让我们开始学习片段的生命周期。

片段生命周期

片段是具有自己生命周期的组件。了解片段生命周期至关重要,因为它在片段创建、运行状态和销毁的某些阶段提供回调,您可以在其中配置初始化、显示和清理。片段在活动中运行,片段的生命周期与活动的生命周期绑定。

在许多方面,片段的生命周期与活动的生命周期非常相似,乍一看,似乎前者复制了后者。在片段生命周期中有与活动生命周期相同或相似的回调,例如onCreate(savedInstanceState: Bundle?)

片段的生命周期与活动的生命周期紧密相连,因此无论在何处使用片段,片段回调都与活动回调交错。

注意

片段和活动之间的互动完整顺序在官方文档中有所说明:developer.android.com/guide/fragments/lifecycle

在初始化片段并准备将其显示给用户之前,需要经历相同的步骤,然后才能供用户进行交互。当应用程序转入后台、隐藏和退出时,片段也会经历与活动相同的拆卸步骤。与活动一样,片段必须从父Fragment类扩展/派生,并且您可以根据您的用例选择要覆盖的回调。现在让我们探索这些回调,它们出现的顺序以及它们的作用。

onAttach

override fun onAttach(context: Context): 这是您的片段与其所用活动关联的时刻。它允许您引用活动,尽管在此阶段片段和活动都尚未完全创建。

创建

override fun onCreate(savedInstanceState: Bundle?): 在此处进行片段的任何初始化。这不是设置片段布局的地方,因为在此阶段,没有可用于显示的 UI,也没有像活动中的setContentView那样可用。与活动的onCreate()函数一样,您可以使用savedInstanceState参数在片段被重新创建时恢复片段的状态。

创建视图

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View?: 现在,您可以创建片段的布局。在这里最重要的是要记住,与活动设置布局不同,片段实际上会从此函数返回布局View?。您的布局中的视图可以在此引用,但有一些注意事项。您需要在引用其中包含的视图之前创建布局,这就是为什么最好在onViewCreated中进行视图操作。

视图创建

override fun onViewCreated(view View, savedInstanceState: Bundle?): 此回调位于片段完全创建和对用户可见之间。在这里,您通常会设置视图并向这些视图添加任何功能和交互性。这可能是向按钮添加click listener,然后在单击时调用函数。

活动已创建

override fun onActivityCreated(context: Context): 在活动的onCreate运行后立即调用。大部分片段的视图状态初始化将已完成,如果需要,这是进行最终设置的地方。

开始

override fun onStart(): 当片段即将对用户可见但尚不可供用户交互时调用此方法。

恢复

override fun onResume(): 在此调用结束时,您的片段将可供用户交互。通常,在此回调中定义的设置或功能很少,因为当应用程序进入后台然后再次进入前台时,此回调将始终被调用。因此,当片段变为可见时,您不希望不必要地重复片段的设置。

暂停

override fun onPause(): 与其对应的活动中的onPause()一样,表示您的应用程序进入后台或在屏幕上被其他内容部分覆盖。使用此方法保存对片段状态的任何更改。

停止

override fun onStop(): 在此调用结束时,片段不再可见并进入后台。

销毁视图

override fun onDestroyView(): 这通常用于在片段被销毁之前进行最终清理。如果需要清理任何资源,应该使用此回调。如果片段被推送到后退栈并保留,则也可以在不销毁片段的情况下调用它。在完成此回调后,片段的布局视图将被移除。

销毁

override fun onDestroy(): 片段正在被销毁。这可能是因为应用程序被终止,也可能是因为此片段被另一个片段替换。

分离

override fun onDetach(): 当片段已从其活动中分离时调用此方法。

还有更多的片段回调,但这些是您在大多数情况下会使用的。通常,您只会使用这些回调的一个子集:onAttach()将活动与片段关联,onCreate初始化片段,onCreateView设置布局,然后onViewCreated/onActivityCreated进行进一步初始化,也许onPause()进行一些清理。

注意

这些回调的更多细节可以在官方文档中找到:developer.android.com/guide/fragments

现在我们已经了解了片段生命周期的一些理论以及它如何受到宿主活动生命周期的影响,让我们看看这些回调是如何运行的。

练习 3.01:添加基本片段和片段生命周期

在这个练习中,我们将创建并添加一个基本片段到一个应用程序。这个练习的目的是熟悉如何将片段添加到活动中以及它们显示的布局。为此,您将在 Android Studio 中创建一个新的空白片段和布局。然后将片段添加到活动,并通过片段布局的显示来验证片段是否已添加。执行以下步骤:

  1. 在 Android Studio 中创建一个名为Fragment Lifecycle的空活动应用程序,包名为com.example.fragmentlifecyle

  2. 接下来,通过转到文件|新建|片段(空白)来创建一个新的片段。在这个阶段,您只想创建一个普通的片段,所以您使用片段(空白)选项。当您选择了这个选项后,您将看到图 3.1中显示的屏幕:图 3.1:创建一个新的片段

图 3.1:创建一个新的片段

  1. 将片段重命名为MainFragment,布局重命名为fragment_main。然后,按Finish,片段类将被创建并打开。已添加了一个函数onCreateView(如下所示),它会填充片段使用的布局文件。
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_main,           container, false)
    }
  1. 当您打开fragment_main.xml布局文件时,您会看到以下代码:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"      android:layout_width="match_parent"        android:layout_height="match_parent"          tools:context=".MainFragment">
    <!-- TODO: Update blank fragment layout -->
    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@string/hello_blank_fragment" />
</FrameLayout>

一个简单的布局已经添加了一个TextView和一些示例文本,使用了@string/hello_blank_fragment。这个字符串资源包含文本hello blank fragment。由于layout_widthlayout_height被指定为match_parentTextView将占据整个屏幕。然而,文本本身将被添加到视图的左上角,使用默认位置。

  1. 添加android:gravity="center"属性和值到TextView,以便文本出现在屏幕中央:
    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/hello_blank_fragment" />

如果现在运行 UI,您将看到“Hello World!”显示在图 3.2中:

图 3.2:没有添加片段的初始应用布局显示

图 3.2:没有添加片段的初始应用布局显示

嗯,您可以看到一些Hello World!文本,但可能没有您期望的hello blank fragment文本。当您创建活动时,片段及其布局不会自动添加到活动中。这是一个手动的过程。

  1. 打开activity_main.xml文件,并用以下内容替换其中的内容:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <fragment
        android:id="@+id/main_fragment"
        android:name="com.example.fragmentlifecycle.MainFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

就像您可以在 XML 布局中添加视图声明一样,还有一个fragment元素。您已经使用match_parent的约束将片段添加到ConstraintLayout中,因此它将占据整个屏幕。这里要检查的最重要的xml属性是android:name。在这里,您指定要添加到布局中的包和Fragment类的完全限定名称,使用com.example.fragmentlifecycle.MainFragment

  1. 现在运行应用程序,您将看到图 3.3中显示的输出:图 3.3:添加了片段的应用布局显示

图 3.3:添加了片段的应用布局显示

这证明了您的片段文本Hello blank fragment已添加到活动中,并且您定义的布局正在显示。接下来,您将检查活动和片段之间的回调方法以及发生这种情况的原因。

  1. 打开MainFragment类,并在伴生对象中添加一个TAG常量,值为"MainFragment",以标识该类。然后添加/更新适当的日志语句的函数。您需要在类顶部的导入中添加'Log'语句和'context'的导入。下面的代码片段已经被截断。点击链接查看您需要使用的完整代码块:
MainFragment.kt 
Log.d(TAG, "onCreateView") to the onCreateView callback and Log.d(TAG, "onCreate") to the onCreate callback which already exist. 
  1. 接下来,打开MainActivity类,并添加常见的回调方法onStartonResume。然后添加一个伴生对象,其中包含一个值为"MainActivity"TAG常量,如下所示,并在类顶部添加 Log 导入:
    onCreate log statement Log.d(TAG, "onCreate") as this callback was already there when you added the activity in the project.You learned in *Chapter 2*, *Building User Screen Flows*, how to view log statements, and you are going to open the `Logcat` window in Android Studio to examine the logs and the order they are called when you run the app. In *Chapter 2*, *Building User Screen Flows*, you were viewing logs from a single activity so you could see the order they were called in. Now you'll examine the order in which the `MainActivity` and `MainFragment` callbacks happen. 
  1. 打开Logcat窗口。(提醒一下,可以通过单击屏幕底部的Logcat选项卡或者通过工具栏的View | Tool Windows | Logcat来访问它)。由于MainActivityMainFragment都以文本Main开头,您可以在搜索框中输入Main以过滤日志,只显示带有此文本的语句。运行应用程序,您应该看到以下内容:图 3.4:启动应用程序时显示的 Logcat 语句

图 3.4:启动应用程序时显示的 Logcat 语句

有趣的是,前几个回调来自片段。它通过onAttach回调与其放置的活动相连。片段在onCreateonCreateView中初始化并显示其视图,然后调用另一个回调onViewCreated,确认片段 UI 已准备好显示。这是在活动的onCreate方法被调用之前。这是有道理的,因为活动根据其包含的内容创建其 UI。由于这是一个定义了自己布局的片段,活动需要知道如何测量、布局和绘制片段,就像在onCreate方法中一样。然后,在onActivityCreated回调中,片段收到确认已完成这一点,然后在它们各自的onResume回调完成后,片段和活动开始显示 UI。

注意

先前详细介绍的活动和片段生命周期之间的交互是针对静态片段的情况,即在活动的布局中定义的片段。对于动态片段,可以在活动已经运行时添加,交互可能会有所不同。

因此,现在片段和包含的活动都显示出来了,当应用程序转入后台或关闭时会发生什么呢?当片段和活动暂停、停止和完成时,回调仍然交错进行。

  1. 将以下回调添加到MainFragment类中:
override fun onPause() {
    super.onPause()
    Log.d(TAG, "onPause")
}
override fun onStop() {
    super.onStop()
    Log.d(TAG, "onStop")
}
override fun onDestroyView() {
    super.onDestroyView()
    Log.d(TAG, "onDestroyView")
}
override fun onDestroy() {
    super.onDestroy()
    Log.d(TAG, "onDestroy")
}
override fun onDetach() {
    super.onDetach()
    Log.d(TAG, "onDetach")
}
  1. 然后将这些回调添加到MainActivity中:
override fun onPause() {
    super.onPause()
    Log.d(TAG, "onPause")
}
override fun onStop() {
    super.onStop()
    Log.d(TAG, "onStop")
}
override fun onDestroy() {
    super.onDestroy()
    Log.d(TAG, "onDestroy")
}
  1. 构建应用程序,一旦它运行起来,你会看到之前的回调同时启动片段和活动。您可以使用Logcat窗口左上角的垃圾桶图标来清除语句。然后关闭应用程序并查看输出日志语句:图 3.5:关闭应用程序时显示的 Logcat 语句

图 3.5:关闭应用程序时显示的 Logcat 语句

onPauseonStop语句与您预期的一样,因为片段首先收到这些回调,因为它包含在活动中。您可以将其视为从内向外的通知,即在通知包含父项之前,子元素会收到通知,因此父项知道如何响应。然后片段被拆除,从活动中移除,然后在onDestroyViewonDestroyonDetach函数中被销毁,之后在onDestroy中完成任何最终清理后,活动本身被销毁。在活动的组成部分被移除之前,活动完成是没有意义的。

完整的片段生命周期回调及其与活动回调的关系是 Android 的一个复杂领域,因为在不同情况下应用哪些回调可能会有相当大的不同。要查看更详细的概述,请参阅官方文档developer.android.com/guide/fragments

对于大多数情况,您只会使用前面的片段回调。此示例演示了片段在创建、显示和销毁时的自包含性,以及它们对包含活动的相互依赖性。通过onAttachonActivityCreated回调,它们可以访问包含活动及其状态,这将在下面的示例中演示。

现在我们已经通过一个向活动添加片段的基本示例,并检查了片段与活动之间的交互,让我们看一个更详细的示例,演示如何向活动添加两个片段。

练习 3.02:静态向活动添加片段

此练习将演示如何向活动添加两个具有自己 UI 和独立功能的片段。您将创建一个简单的计数器类,用于增加和减少数字,以及一个样式类,用于以编程方式更改应用于一些Hello World文本的样式。执行以下步骤:

  1. 在 Android Studio 中创建一个名为Fragment Intro的空活动应用。然后在res | values | strings.xml文件中替换内容为以下练习所需的字符串:
<resources>
    <string name="app_name">Fragment Intro</string>
    <string name="hello_world">Hello World</string>
    <string name="bold_text">Bold</string>
    <string name="italic_text">Italic</string>
    <string name="reset">Reset</string>
    <string name="zero">0</string>
    <string name="plus">+</string>
    <string name="minus">-</string>
    <string name="counter_text">Counter</string>
</resources>

这些字符串既用于计数器片段,也用于样式片段,接下来您将创建样式片段。

  1. 通过转到File | New | Fragment (Blank),添加一个名为CounterFragment的新空片段,布局名称为fragment_counter

  2. 现在对fragment_counter.xml文件进行更改。要添加字段,您需要在Fragment类中创建counter。以下代码由于空间原因而被截断。点击链接查看您需要使用的完整代码:

fragment_counter.xml
9    <TextView
10        android:id="@+id/counter_text"
11        android:layout_width="wrap_content"
12        android:layout_height="wrap_content"
13        android:text="@string/counter_text"
14        android:paddingTop="10dp"
15        android:textSize="44sp"
16        app:layout_constraintEnd_toEndOf="parent"
17        app:layout_constraintStart_toStartOf="parent"
18        app:layout_constraintTop_toTopOf="parent"/>
19
20    <TextView
21        android:id="@+id/counter"
22        android:layout_width="wrap_content"
23        android:layout_height="wrap_content"
24        android:text="@string/zero"
25        android:textSize="54sp"
26        android:textStyle="bold"
27        app:layout_constraintEnd_toEndOf="parent"
28        app:layout_constraintStart_toStartOf="parent"
29        app:layout_constraintTop_toBottomOf="@id/counter_text"
30        app:layout_constraintBottom_toTopOf="@id/plus"/>
You can find the complete code for this step at http://packt.live/2LFCJpa.

我们使用一个简单的ConstraintLayout文件,其中为标题@+id/counter_text和值android:id="@+id/counter"(默认为@string/zero)设置了TextViews,这些值将由android:id="@+id/plus"android:id="@+id/minus"按钮更改。

注意

对于像这样的简单示例,您不会使用style="@some_style"符号在视图上设置单独的样式,最佳做法是避免在每个视图上重复这些值。

  1. 现在打开CounterFragment并重写onViewCreated函数。您还需要添加以下导入:
onViewCreated, which is the callback run when the layout has been applied to your fragment. The onCreateView callback, which creates the view, was run when the fragment itself was created. The buttons you've specified in the preceding fragment have click listeners set up on them to increment and decrement the value of the counter view.
  1. 首先,使用此行,您将检索计数器的当前值作为整数:
var counterValue = counter.text.toString().toInt()
  1. 然后,使用以下行,您可以使用++符号将值增加1
counter.text = (++counterValue).toString()

由于这是通过在counterValue之前添加++来完成的,它会在将整数值转换为字符串之前递增整数值。如果您没有这样做,而是使用counter++进行后递增,那么该值只会在您在语句中下一次使用该值时可用,这会重置计数器为相同的值。

  1. 减号按钮click listener中的行执行与加号click listener类似的操作,但将值减1
if (counterValue > 0) counter.text = (--counterValue).toString()

只有当值大于0时才执行操作,以便不设置负数。

  1. 您还没有将片段添加到MainActivity布局中。要做到这一点,进入activity_main.xml并添加以下代码:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">
    <fragment
        android:id="@+id/counter_fragment"
        android:name="com.example.fragmentintro.CounterFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

您将把布局从FrameLayout更改为LinearLayout,因为当您添加下一个片段时,您需要将一个片段放在另一个片段上方。您通过name属性在fragment XML 元素中指定要在其中使用的片段,使用类的完全限定包名称:android:name="com.example.fragmentintro.CounterFragment。如果您在创建应用程序时使用了不同的包名称,则这必须指向您创建的CounterFragment。这里需要理解的重要一点是,您已经将一个片段添加到了主活动布局中,并且该片段还有一个布局。这显示了使用片段的一些功能,因为您可以封装应用程序的一个功能,包括布局文件和片段类,并将其添加到多个活动中。

完成此操作后,像图 3.6中一样在虚拟设备中运行片段:

图 3.6:应用程序显示计数器片段

图 3.6:应用程序显示计数器片段

您已经创建了一个简单的计数器。基本功能按预期工作,递增和递减计数器值。

  1. 在下一步中,您将在屏幕的下半部分添加另一个片段。这展示了片段的多功能性。您可以在屏幕的不同区域拥有具有功能和特性的封装 UI 片段。

  2. 现在使用创建CounterFragment的早期步骤创建一个名为StyleFragment的新片段,布局名称为fragment_style

  3. 接下来,打开已创建的fragment_style.xml文件,并用下面链接中的代码替换内容。下面显示的片段已被截断-请参阅完整代码的链接:

fragment_style.xml
10    <TextView
11        android:id="@+id/hello_world"
12        android:layout_width="wrap_content"
13        android:layout_height="0dp"
14        android:textSize="34sp"
15        android:paddingBottom="12dp"
16        android:text="@string/hello_world"
17        app:layout_constraintEnd_toEndOf="parent"
18        app:layout_constraintStart_toStartOf="parent"
19        app:layout_constraintTop_toTopOf="parent" />
20
21    <Button
22        android:id="@+id/bold_button"
23        android:layout_width="wrap_content"
24        android:layout_height="0dp"
25        android:textSize="24sp"
26        android:text="@string/bold_text"
27        app:layout_constraintEnd_toStartOf="@+id/italic_button"
28        app:layout_constraintStart_toStartOf="parent"
29        app:layout_constraintTop_toBottomOf="@id/hello_world" />
You can find the complete code for this step at http://packt.live/2KykTDS.

布局添加了一个带有三个按钮的TextViewTextView文本和所有按钮的文本都设置为字符串资源(@string)。

  1. 接下来,进入activity_main.xml文件,并在LinearLayout内的CounterFragment下方添加StyleFragment
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">
    <fragment
        android:id="@+id/counter_fragment"
        android:name="com.example.fragmentintro.CounterFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
    <fragment
        android:id="@+id/style_fragment"
        android:name="com.example.fragmentintro.StyleFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

当您运行应用程序时,您会发现StyleFragment不可见,如图 3.7所示:

图 3.7:应用程序显示没有显示 StyleFragment

图 3.7:应用程序显示没有显示 StyleFragment

您已经在布局中包含了StyleFragment,但是因为CounterFragment的宽度和高度设置为与其父级匹配(android:layout_width="match_parent android:layout_height="match_parent"),并且它是布局中的第一个视图,它占据了所有的空间。

您需要的是指定每个片段应占用的高度比例的方法。LinearLayout的方向设置为垂直,因此当layout_height未设置为match_parent时,片段将一个在另一个上方显示。为了定义这个高度的比例,您需要在activity_main.xml布局文件中的每个片段中添加另一个属性layout_weight。当您使用layout_weight来确定这个比例高度时,片段应该占用您设置的layout_height0dp的高度。

  1. 使用以下更改更新activity_main.xml布局,将两个片段的layout_height设置为0dp,并添加以下值的layout_weight属性:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">
    <fragment
        android:id="@+id/counter_fragment"
        android:name="com.example.fragmentintro.CounterFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="2"/>
    <fragment
        android:id="@+id/style_fragment"
        android:name="com.example.fragmentintro.StyleFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>
</LinearLayout>

这些更改使CounterFragment占据了StyleFragment两倍的高度,如图 3.8所示:

图 3.8:CounterFragment 分配了两倍的垂直空间

图 3.8:CounterFragment 分配了两倍的垂直空间

您可以通过更改权重值来尝试不同的布局显示效果。

  1. 此时,按下样式按钮“粗体”和“斜体”将不会对文本Hello World产生影响。按钮操作尚未指定。下一步涉及向按钮添加交互性,以更改Hello World文本的样式。添加以下onViewCreated函数,覆盖其父类以在布局视图设置完成后向片段添加行为。您还需要添加以下小部件和字体导入以更改文本的样式:
click listeners to each button defined in the layout and setting the Hello World text to the desired Typeface. (In this context, Typeface refers to the style which will be applied to the text and not a font). The conditional statement for the bold_button checks whether the italic Typeface is set and if it is, to make the text bold and italic, and if not, just make the text bold. This logic works the opposite way for the italic_button, checking the state of the Typeface and making the corresponding changes to the Typeface, initially setting it to italic if no TypeFace is defined. 
  1. 最后,reset_button清除Typeface并将其设置回正常。运行应用程序并单击ITALICBOLD按钮。您应该看到如图 3.9所示的显示:图 3.9:StyleFragment 将文本设置为粗体和斜体

图 3.9:StyleFragment 将文本设置为粗体和斜体

这个练习虽然简单,但展示了使用片段的一些基本概念。用户可以与应用程序的功能进行交互,可以独立开发,并不依赖于将两个或更多功能捆绑到一个布局和活动中。这使得片段可重用,并意味着在开发应用程序时,您可以专注于将定义良好的 UI、逻辑和功能添加到单个片段中。

静态片段和双窗格布局

上一个练习介绍了静态片段,可以在活动 XML 布局文件中定义。Android 开发环境的一个优点是可以为不同的屏幕尺寸创建不同的布局和资源。这用于决定根据设备是手机还是平板电脑来显示哪些资源。随着平板电脑尺寸的增大,布局 UI 元素的空间也会大幅增加。Android 允许根据许多不同的形状因素指定不同的资源。用于在res(资源)文件夹中定义平板电脑的限定符经常是sw600dp。这表示如果设备的最短宽度sw)超过 600 dp,则使用这些资源。此限定符用于 7 英寸平板电脑及更大的设备。平板电脑支持所谓的双窗格布局。窗格代表用户界面的一个独立部分。如果屏幕足够大,那么可以支持两个窗格(双窗格)布局。这也提供了一个窗格与另一个窗格互动以更新内容的机会。

练习 3.03:静态片段的双窗格布局

在这个练习中,您将创建一个简单的应用程序,显示星座列表和每个星座的特定信息。它将在手机和平板电脑上使用不同的显示方式。手机将显示一个列表,然后在另一个屏幕上打开所选列表项的内容,而平板电脑将在同一屏幕上的另一个窗格中显示相同的列表,并在另一个窗格中打开列表项的内容,以双窗格布局。为了实现这一点,您必须创建另一个布局,仅用于 7 英寸平板电脑及以上。执行以下步骤:

  1. 首先,使用“空活动”创建一个名为“双窗格布局”的新 Android Studio 项目。创建完成后,转到已创建的布局文件res|layout|activity_main.xml

  2. 选择设计视图顶部工具栏中的此选项后,选择方向布局按钮。图 2

  3. 在此下拉菜单中,您可以选择“创建平板电脑变体”来创建应用程序的新文件夹。这将在main|res文件夹中创建一个名为'layout-sw600dp'的新文件夹,并添加布局文件activity_main.xml图 3.10:设计视图方向按钮下拉菜单

图 3.10:设计视图方向按钮下拉菜单

目前,它是在创建应用程序时添加的activity_main.xml文件的副本,但您将更改它以自定义平板电脑的屏幕显示。

为了演示双窗格布局的使用,您将创建一个星座列表,当选择列表项时,将显示有关星座的一些基本信息。

  1. 转到顶部工具栏,选择文件 | 新建 | 片段 | 片段(空白)。将其命名为ListFragment

对于这个练习,您需要更新strings.xmlthemes.xml文件,添加以下条目:

strings.xml
    <string name="star_signs">Star Signs</string>
    <string name="symbol">Symbol: %s</string>
    <string name="date_range">Date Range: %s</string>
    <string name="aquarius">Aquarius</string>
    <string name="pisces">Pisces</string>
    <string name="aries">Aries</string>
    <string name="taurus">Taurus</string>
    <string name="gemini">Gemini</string>
    <string name="cancer">Cancer</string>
    <string name="leo">Leo</string>
    <string name="virgo">Virgo</string>
    <string name="libra">Libra</string>
    <string name="scorpio">Scorpio</string>
    <string name="sagittarius">Sagittarius</string>
    <string name="capricorn">Capricorn</string>
    <string name="unknown_star_sign">Unknown Star Sign</string>
themes.xml
    <style name="StarSignTextView"       parent="Base.TextAppearance.AppCompat.Large" >
        <item name="android:padding">18dp</item>
    </style>
    <style name="StarSignTextViewHeader"       parent="Base.TextAppearance.AppCompat.Display1" >
        <item name="android:padding">18dp</item>
    </style>

打开main | res | layout | fragment_list.xml文件,并用以下内容替换默认内容:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:context=".ListFragment">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textSize="24sp"
            android:textStyle="bold"
            style="@style/StarSignTextView"
            android:text="@string/star_signs" />
        <View
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:background="?android:attr/dividerVertical" />
        <TextView
            android:id="@+id/aquarius"
            style="@style/StarSignTextView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/aquarius" />
    </LinearLayout>
</ScrollView>

您将看到,第一个xml元素是一个ScrollViewScrollView是一个ViewGroup,允许内容滚动,由于您将向其中添加 12 个星座到包含的LinearLayout中,这可能会占用比屏幕上可用的更多的垂直空间。

添加ScrollView可以防止内容在垂直方向上被截断,当没有足够的空间来显示它们时,可以滚动布局。ScrollView只能包含一个子视图。在这里,它是一个LinearLayout,由于内容将垂直显示,方向设置为垂直(android:orientation="vertical")。在第一个标题TextView下,您已添加了一个分隔符View和一个第一个星座水瓶座的TextView

  1. 按照相同的格式添加其他 11 个星座,首先添加分隔符,然后添加TextView。每个TextView的字符串资源名称和id应该相同。要创建视图的星座名称在strings.xml文件中指定。

注意

用于布置列表的技术对于示例来说是可以的,但在真实的应用程序中,您将创建一个专用于显示可以滚动的列表的RecyclerView,并通过适配器将数据绑定到列表上。您将在后面的章节中介绍这个。

  1. 接下来创建StarSignListener,并通过添加以下内容使MainActivity实现它:
interface StarSignListener {
    fun onSelected(id: Int)
}
class MainActivity : AppCompatActivity(), StarSignListener {
    ...
    override fun onSelected(id: Int) {
        TODO("not implemented yet")
    }
}

这就是当用户从ListFragment中选择一个星座时,片段将如何与活动进行通信,并根据是否可用双窗格添加逻辑。

  1. 创建布局文件后,进入ListFragment类,并使用下面的内容更新它,保持onCreateView()不变。您可以在onAttach()回调中看到,您声明活动实现了StarSignListener接口,因此当用户点击列表中的项目时可以通知它:在文件顶部与其他导入一起添加onAttach所需的Context的导入:
onCreateView. You set up the buttons with a click listener in onViewCreated and then you handle clicks in onClick.The `listOf` syntax in `onViewCreated` is a way of creating a `readonly` list with the specified elements, which in this case are your star sign `TextViews`. Then, in the following code, you loop over these `TextViews`, setting the `click listener` for each of the individual `TextViews` by iterating over the `TextView` list with the `forEach` statement. The `it` syntax here refers to the element of the list that is being operated on, which will be one of the 12 star sign `TextViews`.
  1. 最后,onClick语句通过StarSignListener与活动通信,当列表中的星座之一被点击时:
v?.let { starSign ->
    starSignListener.onSelected(starSign.id)
}

您可以使用?检查指定为v的视图是否为空,然后只有在它不为空时才使用let作用域函数进行操作,然后将星座的id传递给Activity/StarSignListener

注意

监听器是对变化做出反应的常见方式。通过指定Listener接口,您正在指定一个要履行的合同。然后通知实现类监听器操作的结果。

  1. 接下来创建DetailFragment,它将显示星座的详细信息。创建一个与之前相同的片段,并将其命名为DetailFragment。用以下 XML 文件替换fragment_detail布局文件的内容:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".DetailFragment">
    <TextView
        android:id="@+id/star_sign"
        style="@style/StarSignTextViewHeader"
        android:textStyle="bold"
        android:gravity="center"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:text="Aquarius"/>
    <TextView
        android:id="@+id/symbol"
        style="@style/StarSignTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:text="Water Carrier"/>
    <TextView
        android:id="@+id/date_range"
        style="@style/StarSignTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:text="Date Range: January 20 - February 18" />
</LinearLayout>

在这里,您创建一个简单的LinearLayout,它将显示星座名称、星座符号和日期范围。您将在DetailFragment中设置这些值。

  1. 打开DetailFragment,并使用以下文本更新内容,并将小部件导入到导入列表中:
onCreateView inflates the layout as normal. The setStarSignData() function is what populates the data from the passed in starSignId. The when expression is used to determine the ID of the star sign and set the appropriate contents.The `setStarSignData` function above formats text passed with the `getString` function – `getString(R.string.symbol,"Water Carrier")`, for example, passes the text `Water Carrier` into the `symbol` string, `<string name="symbol">Symbol: %s</string>`, and replaces the `%s` with the passed-in value. You can see what other string formatting options there are in the official docs: [`developer.android.com/guide/topics/resources/string-resource`](https://developer.android.com/guide/topics/resources/string-resource).Following the pattern introduced by the star sign `aquarius`, add the other 11 star signs below the `aquarius` block. For simplicity, all of the detailed text of the star sign has not been added into the `strings.xml` file. Consult the example here for the completed class file:[`packt.live/35Vynkx`](http://packt.live/35Vynkx)Right now, you have added both `ListFragment` and `DetailFragment`. Currently, however, they have not been synced together, so selecting the star sign item in the `ListFragment` does not load contents into the `DetailFragment`. Let's look at how you can change that. 
  1. 首先,您需要在layout文件夹和layout-sw600dp中更改activity_main.xml的布局。

  2. 在项目视图中打开res | layout | activity_main.xml。在默认的 Android 视图中打开res | layout | activity_main.xml,并选择不带(sw600dp)的顶部activity_main.xml文件。用以下内容替换内容:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <fragment
        android:id="@+id/star_sign_list"
        android:name="com.example.staticfragments.ListFragment"
        android:layout_height="match_parent"
        android:layout_width="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 然后打开res | layout-sw600dp | activity_main.xml(如果在项目视图中)。在默认的 Android 视图中打开res | layout | activity_main.xml(sw600dp)。用以下内容替换内容:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:context=".MainActivity">
    <fragment
        android:id="@+id/star_sign_list"
        android:name="com.example.staticfragments.ListFragment"
        android:layout_height="match_parent"
        android:layout_width="0dp"
        android:layout_weight="1"/>
    <View
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:background="?android:attr/dividerVertical" />
    <fragment
        android:id="@+id/star_sign_detail"
        android:name="com.example.staticfragments.DetailFragment"
        android:layout_height="match_parent"
        android:layout_width="0dp"
        android:layout_weight="2"/>
</LinearLayout>

您正在添加一个LinearLayout,默认情况下会水平布局其内容。

您添加了ListFragment,一个分隔线,然后是DetailFragment并分配了适当的 ID。还要注意,您正在使用权重的概念来分配每个片段可用空间。当您这样做时,您指定了android:layout_width="0dp"。然后,layout_weight根据权重测量设置了宽度的比例,因为LinearLayout被设置为水平布局片段。ListFragment指定为android:layout_weight="1"DetailFragment指定为android:layout_weight="2",这告诉系统将DetailFragment分配为ListFragment的两倍宽度。在这种情况下,包括固定 dp 宽度的分隔线在内,这将导致ListFragment大约占据宽度的三分之一,而DetailFragment占据宽度的三分之二。

  1. 要查看应用程序,请按照第一章创建您的第一个应用程序中显示的方式创建一个新的虚拟设备,并选择Category | Tablet | Nexus 7

  2. 这将创建一个 7 英寸的平板。然后启动虚拟设备并运行应用程序。当您在纵向模式下启动平板时,您将看到初始视图:图 3.11:初始星座应用 UI 显示

图 3.11:初始星座应用 UI 显示

您可以看到列表占据了屏幕的大约三分之一,空白空间占据了屏幕的三分之二。

  1. 单击虚拟设备上的2底部旋转按钮,将虚拟设备顺时针旋转 90 度。

  2. 完成后,虚拟设备将进入横向模式。但是,它不会改变屏幕方向为横向。

  3. 要做到这一点,请单击虚拟设备左下角的3旋转按钮。您还可以选择虚拟设备顶部的状态栏,向下拖动以显示快速设置栏,然后通过选择旋转按钮来打开自动旋转。图 3.12:已选择自动旋转的快速设置栏

图 3.12:自动旋转已选择的快捷设置栏

  1. 然后,这将改变平板的布局为横向:图 3.13:平板上横向显示的初始星座应用 UI 显示

图 3.13:平板上横向显示的初始星座应用 UI 显示

  1. 接下来要做的是启用选择列表项以将内容加载到屏幕的Detail窗格中。为此,我们需要在MainActivity中进行更改。更新以下代码以按照检索视图的 ID 模式检索片段:
package com.example.dualpanelayouts
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
const val STAR_SIGN_ID = "STAR_SIGN_ID"
interface StarSignListener {
    fun onSelected(id: Int)
}
class MainActivity : AppCompatActivity(), StarSignListener {
    var isDualPane: Boolean = false
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        isDualPane = findViewById<View>(R.id.star_sign_detail) != null
    }
    override fun onSelected(id: Int) {
       if (isDualPane) {
           val detailFragment = supportFragmentManager
            .findFragmentById(R.id.star_sign_detail) as DetailFragment
           detailFragment.setStarSignData(id)
       } else {
           val detailIntent = Intent(this, 
             DetailActivity::class.java).apply {
              putExtra(STAR_SIGN_ID, id)
           }
            startActivity(detailIntent)
        }
    }
}

注意

此示例及其后续示例使用supportFragmentManager.findFragmentById

但是,如果您在片段 XML 中添加标签,也可以通过Tag检索片段,方法是使用android:tag="MyFragmentTag"

  1. 然后,您可以使用supportFragmentManager.findFragmentByTag("MyFragmentTag")检索片段。

  2. 为了从片段中检索数据,活动需要实现StarSignListener。这完成了在片段中设置的合同,以将详细信息传递回实现类。onCreate函数设置布局,然后通过检查DetailFragment是否在活动的膨胀布局中,通过检查 id R.id.star_sign_detail是否存在来检查。从项目视图中,res | layout | activity_main.xml文件只包含ListFragment,但您已在res | layout-sw600dp | activity_main.xml文件中添加了代码,以包含带有android:id="@+id/star_sign_detail"DetailFragment。这将用于 Nexus 7 平板的布局。在默认的 Android 视图中打开res | layout | activity_main.xml,然后选择顶部的不带(sw600dp)的activity_main.xml文件,然后选择activity_main.xml (sw600dp)以查看这些差异。

  3. 现在我们可以通过StarSignListenerListFragment传递星座 ID 回到MainActivity,并将其传递到DetailFragment。通过检查isDualPane布尔值来实现这一点,如果评估为true,则可以使用以下代码将星座 ID 传递给DetailFragment

val detailFragment = supportFragmentManager .findFragmentById   (R.id.star_sign_detail) as DetailFragment
detailFragment.setStarSignData(id)
  1. 您将片段从id转换为DetailFragment并调用以下内容:
detailFragment.setStarSignData(id)
  1. 由于您已在片段中实现了此功能,并通过id进行内容显示的检查,因此 UI 已更新:图 3.14:平板上星座应用双窗口显示

图 3.14:平板上星座应用双窗口显示

  1. 现在点击列表项按预期工作,显示双窗格布局,并正确设置内容。

  2. 然而,如果设备不是平板,即使点击了列表项,也不会发生任何事情,因为没有else分支条件来处理设备不是平板的情况,这由isDualPane布尔值定义。显示将如图 3.15所示,并且在选择项目时不会发生变化:图 3.15:手机上初始星座应用 UI 显示

图 3.15:手机上初始星座应用 UI 显示

  1. 您将在另一个活动中显示星座详情。通过转到文件 | 新建 | 活动 | 空活动来创建一个新的DetailActivity。创建后,使用此布局更新activity_detail.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".DetailActivity">
    <fragment
        android:id="@+id/star_sign_detail"
        android:name="com.example.staticfragments.DetailFragment"
        android:layout_height="match_parent"
        android:layout_width="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 这将DetailFragment添加为布局中唯一的片段。现在使用以下内容更新DetailActivity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_detail)
    val starSignId = intent.extras?.getInt(STAR_SIGN_ID, 0) ?: 0
    val detailFragment = supportFragmentManager       .findFragmentById(R.id.star_sign_detail) as DetailFragment
    detailFragment.setStarSignData(starSignId)
}
  1. 预计星座id将通过在意图的额外设置键(也称为id)从另一个活动传递到此活动,以在DetailFragment中设置星座 ID。接下来,您需要实现isDualPane检查的else分支,以通过意图传递星座 ID 启动DetailActivity。更新MainActivity以执行以下操作。您还需要将Intent导入添加到导入列表中:
import android.content.Intent
override fun onSelected(id: Int) {
    if (isDualPane) {
        val detailFragment = supportFragmentManager
          .findFragmentById(R.id.star_sign_detail) 
          as DetailFragment
        detailFragment.setStarSignData(id)
    } else {
        val detailIntent = Intent(this, DetailActivity::class.java)
          .apply {
           putExtra(STAR_SIGN_ID, id)
        }
        startActivity(detailIntent)
    }
}
  1. 在手机显示上点击星座名称中的一个,它会在DetailActivity中显示内容,占据整个屏幕而不显示列表:图 3.16:手机上单窗格星座详情屏幕

图 3.16:手机上单窗格星座详情屏幕

这个练习展示了片段的灵活性。它们可以封装应用程序不同功能的逻辑和显示,可以根据设备的形态因素以不同的方式集成。它们可以以各种方式在屏幕上排列,这受到它们所包含的布局的限制,因此它们可以作为双窗格布局的一部分或全部,也可以作为单窗格布局的一部分。这个练习展示了在平板上并排布置片段,但它们也可以以其他方式叠放在一起以及以各种其他方式排列。下一个主题将说明应用程序中使用的片段配置不必在 XML 中静态指定,而也可以动态完成。

动态片段

到目前为止,您只看到了在编译时以 XML 形式添加的片段。虽然这可以满足许多用例,但您可能希望在运行时动态添加片段以响应用户的操作。这可以通过将ViewGroup添加为片段的容器,然后向ViewGroup添加、替换和移除片段来实现。这种技术更灵活,因为片段可以一直处于活动状态,直到不再需要,然后被移除,而不是像您在静态片段中看到的那样总是在 XML 布局中被膨胀。如果需要 3 或 4 个以上的片段来满足一个活动中的不同用户旅程,那么首选选项是通过动态添加/替换片段来响应用户在 UI 中的交互。当用户与 UI 的交互在编译时是固定的,并且您预先知道需要多少片段时,使用静态片段效果更好。例如,从列表中选择项目以显示内容就是这种情况。

练习 3.04:动态向活动添加片段

在这个练习中,我们将构建与之前相同的星座应用程序,但将演示如何动态地将列表和详细片段添加到屏幕布局中,而不是直接在 XML 布局中添加。您还可以向片段传递参数。为简单起见,您将为手机和平板创建相同的配置。执行以下步骤:

  1. 创建一个名为Dynamic FragmentsEmpty Activity的新项目。

  2. 完成后,添加以下依赖项,您需要使用FragmentContainerView,这是一个优化的 ViewGroup,用于处理 Fragment Transactions 到app/build.gradle中的dependences{ }块中:

implementation 'androidx.fragment:fragment-ktx:1.2.5'
  1. 练习 3.03使用静态片段创建双窗格布局中复制以下 XML 资源文件的内容,并将其添加到此练习中的相应文件中:strings.xml(将app_name字符串从Static Fragments更改为Dynamic Fragments),fragment_detail.xmlfragment_list.xml。所有这些文件都存在于上一个练习中创建的项目中,您只是将内容添加到这个新项目中。然后将DetailFragmentListFragment复制到新项目中。您需要将这两个文件中的包名称从package com.example.staticfragments更改为package com.example.dynamicfragments。最后,将上一个练习中在 themes.xml 中基本应用程序样式下定义的样式添加到此项目中的 themes.xml 中。

  2. 您现在已经设置了与上一个练习中相同的片段。现在打开activity_main.xml布局,并用以下内容替换其内容:

<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView   xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/fragment_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

这是您将要向其中添加片段的FragmentContainerView。您会注意到在布局 XML 中没有添加片段,因为这些将会动态添加。

  1. 进入MainActivity并用以下内容替换其内容:
package com.example.dynamicfragments
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.fragment.app.FragmentContainerView
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        if (savedInstanceState == null) {
            findViewById<FragmentContainerView>              (R.id.fragment_container)?.let { frameLayout ->
                val listFragment = ListFragment()
                supportFragmentManager.beginTransaction()
                    .add(frameLayout.id, listFragment).commit()
            }
        }
    }
}

您正在获取activity_main.xml中指定的FrameLayout的引用,创建一个新的ListFragment,然后将此片段添加到 ID 为fragment_containerViewGroup FrameLayout中。指定的片段事务是add,因为您首次向FrameLayout添加片段。您调用commit()立即执行事务。使用savedInstanceState进行空值检查,只有在没有状态需要恢复时才添加此ListFragment,如果先前已添加了片段,则会有状态需要恢复。

  1. 接下来,使MainActivity实现StarSignListener,并添加一个常量以将星座从ListFragment传递到DetailFragment
class MainActivity : AppCompatActivity(), StarSignListener {
...
override fun onSelected(id: Int) {
    }
}
  1. 现在,如果运行应用程序,您将看到星座列表显示在手机和平板电脑上。

现在你遇到的问题是如何将星座 ID 传递给DetailFragment,因为它现在不在 XML 布局中了。

一种选择是使用与上一个示例相同的技术,创建一个新活动并通过意图传递星座 ID,但是您不应该创建新活动来添加新片段,否则您可能会放弃片段而只使用活动。您将用DetailFragment替换FrameLayout中的ListFragment,但首先,您需要找到一种方法将星座 ID 传递到DetailFragment中。您可以通过在创建片段时将此id作为参数传递来实现这一点。这样做的标准方法是在片段中使用Factory方法。

  1. 将以下代码添加到DetailFragment的底部:(当您使用模板/向导创建片段时,将添加一个示例工厂方法,您可以在此处更新)
companion object {
    private const val STAR_SIGN_ID = "STAR_SIGN_ID"
    fun newInstance(starSignId: Int) = DetailFragment().apply {
        arguments = Bundle().apply {
            putInt(STAR_SIGN_ID, starSignId)
        }
    }
}

伴随对象允许您将 Java 的静态成员等效添加到类中。在这里,您正在实例化一个新的DetailFragment并设置传递到片段中的参数。片段的参数存储在Bundle()中,因此与活动的意图额外项(也是一个 bundle)一样,您将值添加为键对。在这种情况下,您正在添加键STAR_SIGN_ID和值starSignId

  1. 接下来要做的是重写DetailFragment生命周期函数之一,以使用传入的参数:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    val starSignId = arguments?.getInt(STAR_SIGN_ID, 0) ?: 0
    setStarSignData(starSignId)
}
  1. 您在onViewCreated中执行此操作,因为在此阶段已完成片段的布局,并且可以访问视图层次结构(而如果您在onCreate中访问参数,则片段布局将不可用,因为这是在onCreateView中完成的):
val starSignId = arguments?.getInt(STAR_SIGN_ID, 0) ?: 0
  1. 此行从传入的片段参数中获取星座 ID,如果找不到STAR_SIGN_ID键,则设置默认值为0。然后调用setStarSignData(starSignId)来显示星座内容。

  2. 现在,您只需要在MainActivity中实现StarSignListener接口,以从ListFragment中检索星座 ID:

class MainActivity : AppCompatActivity(), StarSignListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        if (savedInstanceState == null) {
            findViewById<FragmentContainerView>              (R.id.fragment_container)?.let { frameLayout ->
                val listFragment = ListFragment()
                supportFragmentManager.beginTransaction()
                    .add(frameLayout.id, listFragment).commit()
            }
        }
    }
DetailFragment as explained earlier with the factory method passing in the star sign ID: DetailFragment.newInstance(starSignId).

此时,ListFragment仍然是添加到活动FrameLayout的片段。您需要用DetailFragment替换它,这需要另一个事务。但这次,您使用replace函数将ListFragment替换为DetailFragment。在提交事务之前,您调用.addToBackStack(null),这样当按下返回按钮时应用程序不会退出,而是通过弹出DetailFragment来返回ListFragment

此练习介绍了动态向活动添加片段。下一个主题介绍了创建片段的更明确定义的结构,称为导航图。

Jetpack Navigation

使用动态和静态片段,虽然非常灵活,但会在您的应用中引入大量样板代码,并且在用户旅程需要添加、删除和替换多个片段并管理返回堆栈时可能会变得非常复杂。正如您在第一章创建您的第一个应用中学到的,谷歌引入了 Jetpack 组件,以在您的代码中使用已建立的最佳实践。Jetpack 组件套件中的Navigation组件使您能够减少样板代码并简化应用程序内的导航。我们现在将使用它来更新星座应用程序以使用这个组件。

练习 3.05:添加 Jetpack 导航图

在这个练习中,我们将重用上一个练习中的大部分类和资源。我们将首先创建一个空项目并复制资源。接下来,我们将添加依赖项并创建一个导航图。使用逐步方法,我们将配置导航图并添加目的地以在片段之间导航。执行以下步骤:

  1. 创建一个名为Jetpack FragmentsEmpty Activity的新项目。

  2. 从上一个练习中复制strings.xmlfragment_detail.xmlfragment_list.xmlDetailFragmentListFragment,记得在strings.xml中更改app_name字符串和片段类的包名称。最后,将上一个练习中在 themes.xml 中定义的样式添加到此项目的 themes.xml 中的基本应用程序样式下面。您还需要在MainActivity的类头上方添加常量属性const val STAR_SIGN_ID = "STAR_SIGN_ID"

  3. 完成后,将以下依赖项添加到app/build.gradle中的dependences{ }块中,以便使用Navigation组件:

implementation "androidx.navigation:navigation-fragment-ktx:2.3.2"
implementation "androidx.navigation:navigation-ui-ktx:2.3.2"
  1. 它会提示您在屏幕右上角点击立即同步以更新依赖项。点击按钮,更新后,请确保选择了'app'模块,然后转到文件 | 新建 | Android 资源文件:图 3.17:创建 Android 资源文件的菜单选项

图 3.17:创建 Android 资源文件的菜单选项

  1. 一旦出现此对话框,将资源类型更改为导航,然后将文件命名为nav_graph图 3.18:新资源文件对话框

图 3.18:新资源文件对话框

单击“确定”继续。这将在res文件夹中创建一个名为Navigation的新文件夹,其中包含nav_graph.xml

  1. 打开文件,您会看到以下代码:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph">
</navigation>

因为它没有被任何地方使用,您可能会看到<navigation>元素被红色下划线标记的警告:

图 3.19:导航未使用警告下划线

图 3.19:导航未使用警告下划线

现在先忽略这个。

  1. 使用以下代码更新nav_graph.xml导航文件:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/  android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/starSignList">
    <fragment
        android:id="@+id/starSignList"
        android:name="com.example.jetpackfragments.ListFragment"
        android:label="List"
        tools:layout="@layout/fragment_list">
        <action
            android:id="@+id/star_sign_id_action"
            app:destination="@id/starSign">
        </action>
    </fragment>
    <fragment
        android:id="@+id/starSign"
        android:name="com.example.jetpackfragments.DetailFragment"
        android:label="Detail"
        tools:layout="@layout/fragment_detail" />
</navigation>

上述文件是一个可工作的Navigation图。虽然语法不熟悉,但它非常容易理解:

a. ListFragmentDetailFragment存在,就像您添加静态片段时一样。

b. 在根<navigation>元素上有一个id来标识图形,以及在片段本身上有 ID。导航图引入了目的地的概念,因此在根navigation级别上,有app:startDestination,它具有starSignList的 ID,这是ListFragment,然后在<fragment>标签内,有<action>元素。

c. 操作是将导航图中的目的地链接在一起的内容。此处的目的地操作具有 ID,因此您可以在代码中引用它,并且具有一个目的地,当使用时,它将指向。

现在您已经添加了导航图,您需要使用它来将活动和片段链接在一起。

  1. 打开activity_main.xml,并将ConstraintLayout内的TextView替换为以下FragmentContainerView
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_graph" /> 

已添加了一个名为android:name="androidx.navigation.fragment.NavHostFragment"FragmentContainerView。它将托管刚刚创建的app:navGraph="@navigation/nav_graph"中的片段。app:defaultNavHost表示它是应用程序的默认导航图。它还在一个片段替换另一个片段时控制后退导航。你可以在布局中有多个NavHostFragment来控制屏幕的两个或更多区域,这些区域管理它们自己的片段,你可能会在平板电脑上使用双窗格布局,但只能有一个默认值。

你需要做一些改变,以使应用程序在ListFragment中按预期工作。

  1. 首先,删除类文件头和对StarSignListener的引用。因此,以下内容将被替换:
interface StarSignListener {
    fun onSelected(starSignId: Int)
}
class ListFragment : Fragment(), View.OnClickListener {
    private lateinit var starSignListener: StarSignListener
    override fun onAttach(context: Context) {
        super.onAttach(context)
        if (context is StarSignListener) {
            starSignListener = context
        } else {
            throw RuntimeException("Must implement StarSignListener")
        }
    }

并用下面的一行代码替换它:

class ListFragment : Fragment() {
  1. 接下来,在类的底部,删除onClick重写方法,因为你没有实现View.OnClicklistener
override fun onClick(v: View?) {
    v?.let { starSign ->
        starSignListener.onSelected(starSign.id)
    }
}
  1. onViewCreated方法中,替换循环遍历星座视图的forEach语句:
starSigns.forEach {
    it.setOnClickListener(this)
}

用下面的代码替换它,并将导航导入到导入列表中:

STAR_SIGN_ID with the view ID of the selected star sign to a NavigationClickListener. It uses the ID of the R.id.star_sign_id_action action to load the DetailFragment when clicked as that is the destination for the action. The DetailFragment does not need any changes and uses the passed-in fragment argument to load the details of the selected star sign ID. 
  1. 运行应用程序,你会发现应用程序的行为与之前一样。

现在你已经能够删除大量样板代码,并在导航图中记录了应用程序内的导航。此外,你已经卸载了更多的androidx组件管理,并使你能够映射整个应用程序以及片段、活动等之间的关系。你还可以有选择性地使用它来管理应用程序的不同区域,这些区域具有定义的用户流程,比如启动应用程序并引导用户浏览一系列欢迎屏幕,或者一些向导布局用户旅程,例如。

有了这些知识,让我们尝试使用从这些练习中学到的技术完成一个活动。

活动 3.01:创建一个关于行星的小测验

对于这个活动,你将创建一个小测验,用户必须回答关于太阳系行星的三个问题中的一个。你选择使用的片段数量取决于你。然而,考虑到本章内容,即将 UI 和逻辑分离为单独的片段组件,你可能会使用两个或更多的片段来实现这一点。接下来的截图展示了一种可能的实现方式,但创建这个应用程序有多种方法。你可以使用本章详细介绍的方法之一,比如静态片段、动态片段、Jetpack 导航组件,或者使用这些和其他方法的组合来创建自定义的方法。

小测验的内容如下。在 UI 中,你需要问用户以下三个问题:

  • 哪个是最大的行星?

  • 哪个行星有最多的卫星?

  • 哪个行星的自转是侧倒的?

然后,你需要提供一个行星列表,用户可以选择他们认为是问题的答案的行星:

  • 水星

  • 金星

  • 地球

  • 火星

  • 木星

  • 土星

  • 天王星

  • 海王星

一旦他们给出了答案,你需要告诉他们他们的答案是正确还是错误。正确答案应该伴随着一些详细解释问题答案的文字。

Jupiter is the largest planet and is 2.5 times the mass of all the other planets put together.
Saturn has the most moons and has 82 moons.
Uranus spins on its side with its axis at nearly a right angle to the sun.

以下是一些屏幕截图,展示了如何实现应用程序的要求的 UI:

问题屏幕

图 3.20:行星小测验问题屏幕

图 3.20:行星小测验问题屏幕

答案选项屏幕

图 3.21:行星小测验多项选择答案屏幕

图 3.21:行星小测验多项选择答案屏幕

答案屏幕

图 3.22:带有详细答案的行星小测验答案屏幕

图 3.22:带有详细答案的行星小测验答案屏幕

以下步骤将帮助完成这个活动:

  1. 创建一个带有“空活动”的 Android 项目

  2. 使用项目所需的条目更新strings.xml文件。

  3. 使用项目的样式修改themes.xml文件。

  4. 创建一个QuestionsFragment,更新布局以显示问题,并添加与按钮和点击侦听器的交互。

  5. 可选地,创建一个多选片段,并添加答案选项和按钮点击处理(这也可以通过将可能的答案选项添加到QuestionsFragment中来完成)。

  6. 创建一个AnswersFragment,显示相关问题的答案,并显示有关答案本身的更多细节。

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

本章中所有练习和活动的资源位于packt.live/3qw0nms

总结

本章深入介绍了片段,从学习ViewGroup和动态添加和替换片段开始。然后我们介绍了如何通过使用 Jetpack Navigation 组件来简化这一过程。

片段是 Android 开发的基本构建块之一。您在这里学到的概念将使您能够不断构建并进步,创建越来越先进的应用程序。片段是构建有效导航到您的应用程序核心的一部分,以绑定简单易用的功能和功能。下一章将通过使用已建立的 UI 模式来详细探讨这一领域,以构建清晰一致的导航,并说明片段如何用于实现这一目的。

第四章:构建应用程序导航

概述

在本章中,您将通过三种主要模式构建用户友好的应用程序导航:底部导航、导航抽屉和选项卡导航。通过引导理论和实践,您将学习每种模式的工作原理,以便用户可以轻松访问您应用程序的内容。本章还将重点关注让用户意识到他们在应用程序中的位置以及可以导航到应用程序层次结构的哪个级别。

在本章结束时,您将了解如何使用这三种主要导航模式,并了解它们如何与应用程序栏一起支持导航。

介绍

在上一章中,您探索了片段和片段生命周期,并使用 Jetpack 导航简化了它们在应用程序中的使用。在本章中,您将学习如何在应用程序中添加不同类型的导航,同时继续使用 Jetpack 导航。您将首先学习导航抽屉,这是 Android 应用程序中最早被广泛采用的导航模式,然后探索底部导航和选项卡导航。您将了解 Android 导航用户流程,它是如何围绕目的地构建的,以及它们如何在应用程序内进行导航。将解释主要目的地和次要目的地之间的区别,以及根据您的应用程序用例,三种主要导航模式中哪一种更适合使用。

让我们开始导航概述。

导航概述

Android 导航用户流程是围绕您应用程序中称为目的地的内容构建的。有一些主要目的地可在应用程序的顶层使用,并且随后始终显示在主要应用程序导航中,还有次要目的地。三种导航模式的指导原则之一是在任何时间点上为用户提供关于用户所在的应用程序主要部分的上下文信息。

这可以采用在用户所在目的地的顶部应用程序栏中的标签的形式,可选择显示一个箭头提示,表明用户不在顶层,并/或者在 UI 中提供突出显示的文本和图标,指示用户所在的部分。应用程序中的导航应该是流畅和自然的,直观地引导用户,同时在任何给定时间点提供一些关于他们所在位置的上下文。您即将探索的三种导航模式中的每一种都以不同的方式实现了这一目标。其中一些导航模式更适合用于显示较多的顶级主要目的地,而其他一些则适合用于较少的目的地。

导航抽屉

导航抽屉是在 Android 应用程序中使用最普遍的导航模式之一,肯定是最早被广泛采用的模式。以下是下一个练习的总结截图,显示了导航抽屉在关闭状态下的简单导航抽屉:

图 4.1:导航抽屉关闭的应用程序

图 4.1:导航抽屉关闭的应用程序

导航抽屉是通过通常被称为汉堡菜单的方式访问的,这是位于图 4.1左上角的具有三条水平线的图标。导航选项在屏幕上不可见,但有关您所在屏幕的上下文信息显示在顶部应用程序栏中。这也可以伴随着屏幕右侧的溢出菜单,通过它可以访问其他上下文相关的导航选项。以下截图显示了导航抽屉处于打开状态,显示了所有导航选项:

图 4.2:导航抽屉打开的应用程序

图 4.2:导航抽屉打开的应用程序

在选择汉堡菜单后,导航抽屉从左侧滑出,当前部分突出显示。这可以显示带有或不带有图标。由于导航占据屏幕的高度,最适合五个或更多个顶级目的地。目的地也可以分组在一起,以指示主要目的地的多个层次结构(如前面截图中的分隔线所示),这些层次结构也可以具有标签。此外,抽屉内容也是可滚动的。总之,导航抽屉是提供快速访问应用程序许多不同目的地的非常便利的方式。导航抽屉的一个弱点是,需要用户选择汉堡菜单才能使目的地可见。相比之下,选项卡和底部导航(带有固定选项卡)始终可见。这反过来也是导航抽屉的一个优点,因为可以使用更多的屏幕空间来显示应用程序的内容。

让我们开始本章的第一个练习,创建一个导航抽屉,以便我们可以访问应用程序的所有部分。

练习 4.01:创建带有导航抽屉的应用程序

在这个练习中,您将在 Android Studio 中创建一个名为Navigation Drawer的新应用程序,使用空活动项目模板,同时保留所有其他默认设置。有向导选项,您可以创建一个新项目,其中包含本章练习中要生成的所有导航模式,但我们将逐步构建应用程序,以指导您完成这些步骤。您将构建一个经常使用导航抽屉的应用程序,例如新闻或邮件应用程序。我们将添加的部分是主页收藏夹最近存档回收站设置

执行以下步骤完成此练习:

  1. 使用空活动创建一个名为 Navigation Drawer 的新项目。不要使用Navigation Drawer Activity项目模板,因为我们将逐步构建应用程序。

  2. 将您需要的 Gradle 依赖项添加到app/build.gradle中:

implementation   'androidx.navigation:navigation-fragment-ktx:2.3.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.2'
  1. 然后,添加/更新应用程序中需要的所有资源文件。首先将dimens.xml文件添加到res/values文件夹中:

dimens.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="activity_horizontal_padding">16dp</dimen>
    <dimen name="activity_vertical_padding">16dp</dimen>
    <dimen name="nav_header_vertical_spacing">8dp</dimen>
    <dimen name="nav_header_height">176dp</dimen>
</resources>
  1. 更新strings.xml,并在res/values文件夹中用以下内容替换themes.xml

strings.xml

    <string name="nav_header_desc">Navigation header</string>
    <string name="home">Home</string>
    <string name="settings">Settings</string>
    <string name="content">Content</string>
    <string name="archive">Archive</string>
    <string name="recent">Recent</string>
    <string name="favorites">Favorites</string>
    <string name="bin">Bin</string>
    <string name="home_fragment">Home Fragment</string>
    <string name="settings_fragment">      Settings Fragment</string>
    <string name="content_fragment">Content Fragment</string>
    <string name="archive_fragment">Archive Fragment</string>
    <string name="recent_fragment">Recent Fragment</string>
    <string name="favorites_fragment">      Favorites Fragment</string>
    <string name="bin_fragment">Bin Fragment</string>
    <string name="link_to_content_button">      Link to Content Button</string>

themes.xml

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.NavigationDrawer" parent=      "Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">          @color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">          @color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
        <!-- Customize your theme here. -->
    </style>
    <style name="Theme.NavigationDrawer.NoActionBar">
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
    </style>
    <style name="Theme.NavigationDrawer.AppBarOverlay"       parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
    <style name="Theme.NavigationDrawer.PopupOverlay"         parent="ThemeOverlay.AppCompat.Light" />
    <style name="button_card" parent=        "Widget.MaterialComponents.Button.OutlinedButton">
        <item name="strokeColor">@color/purple_700</item>
        <item name="strokeWidth">2dp</item>
    </style>
</resources>
  1. 创建以下片段(文件 | 新建 | 片段 | 片段(空白)来自工具栏:)
  • HomeFragment

  • FavoritesFragment

  • RecentFragment

  • ArchiveFragment

  • SettingsFragment

  • BinFragment

  • ContentFragment

  1. 更改每个片段布局以使用以下内容:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"
        android:layout_marginEnd="8dp"
        android:text="@string/archive_fragment"
        android:textAlignment="center"
        android:layout_gravity="center_horizontal"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

唯一的区别是android:text属性,它将具有来自strings.xml文件的相应字符串。因此,请使用正确的字符串创建这些片段,指示用户正在查看哪个片段。这可能看起来有点重复,一个单一的片段可以更新为此文本,但它演示了如何在真实的应用程序中分隔不同的部分。

  1. 更新fragment_home.xml布局,向其中添加一个按钮(这是您可以在图 4.1中看到的主体内容,带有关闭的导航抽屉):
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        TextView is the same as what's specified in the other fragment layouts, except it has an ID (id) with which it constrains the button below it. 
  1. 创建将在应用程序中使用的导航图。

选择文件 | 新建 | Android 资源文件(确保在项目窗口中选择了 res 文件夹,以便看到此选项),或者右键单击 res 文件夹以查看此选项。选择导航作为资源类型,并将其命名为mobile_navigation.xml

这将创建导航图:

图 4.3:Android Studio 新资源文件对话框

  1. 打开res/navigation文件夹中的mobile_navigation.xml文件,并使用下面链接中文件的代码进行更新。这里显示了代码的缩略版本。请查看链接以获取您需要使用的完整代码块:
mobile_navigation.xml   
8    <fragment   
9        android:id="@+id/nav_home"    
10        android:name="com.example.navigationdrawer                            .HomeFragment"    
11        android:label="@string/home"    
12        tools:layout="@layout/fragment_home">    
13        <action    
14            android:id="@+id/nav_home_to_content"    
15            app:destination="@id/nav_content"    
16            app:popUpTo="@id/nav_home" />    
17    </fragment>    
18    
19    <fragment    
20        android:id="@+id/nav_content"    
21        android:name="com.example.navigationdrawer                            .ContentFragment"    
22        android:label="@string/content"    
23        tools:layout="@layout/fragment_content" />    Th complete code for this step can be found at http://packt.live/38W9maC.    

这将创建应用程序中的所有目的地。但是,它没有指定这些是主要目的地还是次要目的地。这应该是您在上一章的 fragment Jetpack 导航练习中熟悉的。这里最重要的一点是app:startDestination="@+id/nav_home,它指定了导航加载时将显示的内容,并且在HomeFragment中有一个可用的操作,可以移动到图中的nav_content目的地:

        <action
            android:id="@+id/nav_home_to_content"
            app:destination="@id/nav_content"
            app:popUpTo="@id/nav_home" />    

现在,您将看到如何在HomeFragment及其布局中设置这些内容。1. 打开 fragment_home.xml 布局文件。然后通过在右上角选择“设计”选项来在设计视图中打开布局文件:

图 4.4:Android Studio 设计视图标题

<style name="button_card"   parent="Widget.MaterialComponents.Button.OutlinedButton">
    <item name="strokeColor">@color/colorPrimary</item>
    <item name="strokeWidth">2dp</item>
</style>
  1. 打开 HomeFragment并更新onCreateView以设置按钮:

R.id.nav_home_to_content操作当单击button_home时。

然而,这些更改目前还不会产生任何效果,因为您仍然需要为应用程序设置导航主机,并添加所有其他布局文件,以及导航抽屉。

  1. 通过在布局文件夹中创建一个名为content_main.xml的新文件来创建一个Nav主机片段。这可以通过右键单击res目录中的layout文件夹,然后转到文件|新建|布局资源文件来完成。创建后,使用FragmentContainerView更新它:
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.fragment       .NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/mobile_navigation" />
  1. 您会注意到导航图设置为您刚刚创建的图:
        app:navGraph="@navigation/mobile_navigation"
  1. 有了这些,应用程序的主体和其目的地已经设置好了。现在,您需要设置 UI 导航。创建另一个布局资源文件,命名为nav_header_main.xml,并添加以下内容:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="@dimen/nav_header_height"
    android:background="@color/teal_700"
    android:gravity="bottom"
    android:orientation="vertical"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:theme="@style/ThemeOverlay.AppCompat.Dark">
    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:contentDescription="@string/nav_header_desc"
        android:paddingTop=          "@dimen/nav_header_vertical_spacing"
        app:srcCompat="@mipmap/ic_launcher_round" />
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingTop=          "@dimen/nav_header_vertical_spacing"
        android:text="@string/app_name"
        android:textAppearance=          "@style/TextAppearance.AppCompat.Body1" />
</LinearLayout>

这是导航抽屉头部显示的布局。

  1. 创建一个名为app_bar_main.xml的工具栏布局文件,并包含以下内容:
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme=          "@style/Theme.NavigationDrawer.AppBarOverlay">
        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme=              "@style/Theme.NavigationDrawer.PopupOverlay" />
    </com.google.android.material.appbar.AppBarLayout>
    <include layout="@layout/content_main" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

这将应用程序的主体布局与出现在其上方的应用栏集成起来。剩下的部分是创建将出现在导航抽屉中的项目,并创建和填充这些项目的导航抽屉。

  1. 要在这些菜单项目中使用图标,您需要将矢量资产复制到已完成练习的 drawable 文件夹中,然后将其复制到项目的 drawable 文件夹中。矢量资产使用点、线和曲线的坐标来布局带有相关颜色信息的图像。与 png 和 jpg 图像相比,它们的大小要小得多,并且可以在不损失质量的情况下调整大小。您可以在这里找到它们:[packt.live/2XQnY5a](http://packt.live/2XQnY5a

)

复制以下可绘制对象:

  • favorites.xml

  • archive.xml

  • recent.xml

  • home.xml

  • bin.xml

  1. 这些图标将用于菜单项目。要创建自己的图标,请将.svg文件导入到 Android Studio 中,或者选择 Android Studio 捆绑的库存图像之一。要查看此操作,请转到文件|新建|矢量资产,并确保选择了res文件夹,以便这些菜单选项出现。以下是其中一个资产的示例(您可以选择“剪贴画”图标以查看其他图标):图 4.5:配置矢量资产

图 4.5:配置矢量资产

  1. 使用本地文件选项导入.svg.psd文件,或选择剪贴画以添加 Android Studio 图标之一。

  2. 创建一个包含这些项目的菜单。要做到这一点,转到文件|新建|Android 资源文件,选择菜单作为资源类型,将其命名为activity_main_drawer,然后使用以下内容填充它:

<?xml version="1.0" encoding="utf-8"?>
<menu   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    tools:showIn="navigation_view">
    <group
        android:id="@+id/menu_top"
        android:checkableBehavior="single">
        <item
            android:id="@+id/nav_home"
            android:icon="@drawable/home"
            android:title="@string/home" />
        <item
            android:id="@+id/nav_recent"
            android:icon="@drawable/recent"
            android:title="@string/recent" />
        <item
            android:id="@+id/nav_favorites"
            android:icon="@drawable/favorites"
            android:title="@string/favorites" />
    </group>
    <group
        android:id="@+id/menu_bottom"
        android:checkableBehavior="single">
        <item
            android:id="@+id/nav_archive"
            android:icon="@drawable/archive"
            android:title="@string/archive" />
        <item
            android:id="@+id/nav_bin"
            android:icon="@drawable/bin"
            android:title="@string/bin" />
    </group>
</menu>

这设置了将显示在导航抽屉中的菜单项。将菜单项与导航图中的目的地联系起来的魔法是 ID 的名称。如果菜单项(在activity_main_drawer.xml中)的 ID 与导航图中的目的地的 ID 完全匹配(在这种情况下是mobile_navigation.xml中的片段),则目的地将自动加载到导航主机中。

  1. MainActivity的布局将导航抽屉与先前指定的所有布局联系起来。打开activity_main.xml并使用以下内容进行更新:
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:openDrawer="start">
    <include
        layout="@layout/app_bar_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <com.google.android.material.navigation.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:fitsSystemWindows="true"
        app:headerLayout="@layout/nav_header_main"
        app:menu="@menu/activity_main_drawer" />
</androidx.drawerlayout.widget.DrawerLayout>
  1. 如您所见,有一个include用于添加app_bar_main.xml<include>元素允许您添加在编译时将被实际布局替换的布局。它们允许我们封装不同的布局,因为它们可以在应用程序中的多个布局文件中重用。NavigationView(创建导航抽屉的类)指定了您刚刚创建的布局文件,以配置其标题和菜单项:
        app:headerLayout="@layout/nav_header_main"
        app:menu="@menu/activity_main_drawer"
  1. 现在,您已经指定了所有的布局文件,通过添加以下交互逻辑来更新MainActivity
package com.example.navigationdrawer
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.*
import com.google.android.material.navigation.NavigationView
class MainActivity : AppCompatActivity() {
    private lateinit var appBarConfiguration:       AppBarConfiguration
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(findViewById(R.id.toolbar))
        val navHostFragment = supportFragmentManager           .findFragmentById(R.id.nav_host_fragment) as             NavHostFragment
        val navController = navHostFragment.navController
        //Creating top level destinations 
        //and adding them to the draw
        appBarConfiguration = AppBarConfiguration(
            setOf(
                R.id.nav_home, R.id.nav_recent,                   R.id.nav_favorites, R.id.nav_archive,                     R.id.nav_bin
            ), findViewById(R.id.drawer_layout)
        )
        setupActionBarWithNavController(navController,           appBarConfiguration)
        findViewById<NavigationView>(R.id.nav_view)          ?.setupWithNavController(navController)
    }
    override fun onSupportNavigateUp(): Boolean {
        val navController =           findNavController(R.id.nav_host_fragment)
        return navController.navigateUp(appBarConfiguration)           || super.onSupportNavigateUp()
    }
}

现在,让我们来看一下前面的代码。setSupportActionBar(toolbar)通过引用布局中的工具栏并设置它来配置应用中使用的工具栏。使用以下代码检索NavHostFragment

        val navHostFragment = supportFragmentManager
          .findFragmentById(R.id.nav_host_fragment) as             NavHostFragment
        val navController = navHostFragment.navController

接下来,您可以添加要在导航抽屉中显示的菜单项:

appBarConfiguration = AppBarConfiguration(
    setOf(
        R.id.nav_home, R.id.nav_recent, R.id.nav_favorites,           R.id.nav_archive, R.id.nav_bin
    ), findViewById(R.id.drawer_layout)
)

drawer_layoutnav_view、主应用栏及其包含的内容的容器。

这可能看起来像是在做两次,因为这些项目显示在导航抽屉的activity_main_drawer.xml菜单中。但是,在AppBarConfiguration中设置这些主要目的地的功能是,当它们被选中时,它们不会显示向上箭头,因为它们处于顶层。它还将drawer_layout作为最后一个参数添加,以指定在选择汉堡菜单以在导航抽屉中显示时应使用哪个布局。

下一行是:

setupActionBarWithNavController(navController,   appBarConfiguration)

这将使用导航图设置应用栏,以便对目的地进行的任何更改都会反映在应用栏中:

findViewById<NavigationView>  (R.id.nav_view)?.setupWithNavController(navController)

这是onCreate中的最后一条语句,它指定了在用户点击时应突出显示导航抽屉中的项目。

类中的下一个函数处理按下次要目的地的向上按钮,确保它返回到其父级主要目的地:

override fun onSupportNavigateUp(): Boolean {
    val navController =       findNavController(R.id.nav_host_fragment)
    return navController.navigateUp(appBarConfiguration) ||       super.onSupportNavigateUp()
}

应用栏还可以通过溢出菜单显示其他菜单项,配置后,它会显示在右上方的三个垂直点中。查看menu/main.xml文件:

<?xml version="1.0" encoding="utf-8"?>
<menu   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/nav_settings"
        android:title="@string/settings"
        app:showAsAction="never" />
</menu>

此配置显示一个项目:设置。由于它指定了与导航图中的SettingsFragment目的地相同的 ID,android:id="@+id/nav_settings",它将打开SettingsFragment。将属性设置为app:showAsAction="never"可确保它保持为三个点溢出菜单中的菜单选项,并且不会出现在应用栏本身上。app:showAsAction的其他值可以将菜单选项设置为始终出现在应用栏上,如果有空间的话。在这里查看完整列表:developer.android.com/guide/topics/resources/menu-resource

  1. 要将溢出菜单添加到应用栏,请将以下内容添加到MainActivity类中:
override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.main, menu)
    return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return item.onNavDestinationSelected(findNavController       (R.id.nav_host_fragment))
}

您还需要添加以下导入项:

import android.view.Menu
import android.view.MenuItem

onCreateOptionsMenu函数选择要添加到应用栏的菜单,而onOptionsItemSelected处理当使用item.onNavDestinationSelected(findNavController(R.id.nav_host_fragment))导航函数选择项目时要执行的操作。这用于导航到导航图中的目的地。

  1. 运行应用程序,并使用导航抽屉导航到顶级目的地。以下屏幕截图显示了导航到“最近”目的地的示例:图 4.6:从导航抽屉中打开的最近菜单项

图 4.6:从导航抽屉中打开的最近菜单项

  1. 当您再次选择导航抽屉以切换它时,您将看到“最近”菜单项被选中:图 4.7:导航抽屉中突出显示的最近菜单项

图 4.7:导航抽屉中突出显示的最近菜单项

  1. 再次选择“主页”菜单项。此屏幕显示了一个链接,该链接位于材料主题按钮中,指向次要内容目的地。当您选择按钮时,一个漂亮的材料动画将从按钮中心向外部发散:图 4.8:带有指向次要目的地的按钮的主屏幕

图 4.8:带有指向次要目的地的按钮的主屏幕

  1. 单击此按钮以转到次要目的地。您将看到一个向上箭头被显示:

图 4.9:显示带有向上箭头的次要目的地

图 4.9:显示带有向上箭头的次要目的地

在所有先前的屏幕截图中,都显示了溢出菜单。选择它后,您将看到“设置”选项出现。按下它后,您将进入SettingsFragment,并显示向上箭头:

图 4.10:设置片段

图 4.10:设置片段

虽然设置带有导航抽屉的应用程序需要经历相当多的步骤,但一旦创建,它就非常灵活。通过向抽屉菜单添加菜单项条目和向导航图添加目的地,可以立即创建新的片段并设置好以供使用。这消除了在上一章中使用片段时需要使用的大量样板代码。您将要探索的下一个导航模式是底部导航。这已经成为 Android 中最流行的导航模式,主要是因为它使应用程序的主要部分易于访问。

底部导航

当顶级目的地数量有限且彼此不相关时,将使用底部导航,这些目的地可以是三到五个主要目的地。底部导航栏上的每个项目都显示一个图标和一个可选的文本标签。这种导航允许快速访问,因为这些项目始终可用,无论用户导航到应用程序的哪个次要目的地。

练习 4.02:向应用程序添加底部导航

在 Android Studio 中创建一个名为“底部导航”的新应用程序,使用“空活动”项目模板,将所有其他默认设置保持不变。不要使用“底部导航活动”项目模板,因为我们将逐步构建应用程序。您将构建一个忠诚度应用程序,为已注册使用该应用程序的客户提供优惠、奖励等。对于这种类型的应用程序,底部导航是非常常见的,因为通常会有有限的顶级目的地。让我们开始吧:

  1. 许多步骤与上一个练习非常相似,因为您将使用 Jetpack 导航并在导航图和相应菜单中定义目的地。

  2. 使用“导航抽屉”创建一个新项目,使用“空活动”命名。

  3. 将您需要的 Gradle 依赖项添加到app/build.gradle中:

implementation 'androidx.navigation:navigation-fragment-  ktx:2.3.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.2'
  1. 用以下内容替换colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#6200EE</color>
    <color name="colorPrimaryDark">#3700B3</color>
    <color name="colorAccent">#03DAC5</color>
</resources>
  1. res/values文件夹中附加strings.xmlthemes.xml与以下值:

strings.xml

    <!-- Bottom Navigation -->
    <string name="home">Home</string>
    <string name="tickets">Tickets</string>
    <string name="offers">Offers</string>
    <string name="rewards">Rewards</string>
    <!-- Action Bar -->
    <string name="settings">Settings</string>
    <string name="cart">Shopping Cart</string>
    <string name="content">Content</string>
    <string name="home_fragment">Home Fragment</string>
    <string name="tickets_fragment">Tickets Fragment</string>
    <string name="offers_fragment">Offers Fragment</string>
    <string name="rewards_fragment">Rewards Fragment</string>
    <string name="settings_fragment">      Settings Fragment</string>
    <string name="cart_fragment">      Shopping Cart Fragment</string>
    <string name="content_fragment">Content Fragment</string>
    <string name="link_to_content_button">      Link to Content Button</string>

themes.xml

    <style name="button_card" parent=      "Widget.MaterialComponents.Button.OutlinedButton">
        <item name="strokeColor">@color/colorPrimary</item>
        <item name="strokeWidth">2dp</item>
        <item name="android:textColor">          @color/colorPrimary</item>
    </style>

您在此处使用了与上一个练习中用于创建主屏幕按钮的相同材料样式。

  1. 创建八个片段,名称如下:
  • HomeFragment

  • ContentFragment

  • OffersFragment

  • RewardsFragment

  • SettingsFragment

  • TicketsFragment

  • CartFragment

  1. 应用与之前练习中应用的相同布局,为所有片段添加相应的字符串资源,除了fragment_home.xml。对于此布局,请使用在之前练习中使用的相同布局文件。

  2. 创建导航图,就像在上一个练习中一样,并将其命名为mobile_navigation。使用下面链接文件中的代码进行更新。以下是代码的截断片段。点击链接查看完整的代码:

mobile_navigation.xml

8    <fragment
9        android:id="@+id/nav_home"
10        android:name="com.example.bottomnavigation                            .HomeFragment"
11        android:label="@string/home"
12        tools:layout="@layout/fragment_home">
13
14        <action
15            android:id="@+id/nav_home_to_content"
16            app:destination="@id/nav_content"
17            app:popUpTo="@id/nav_home" />
18    </fragment>
19
20    <fragment
21        android:id="@+id/nav_content"
22        android:name="com.example.bottomnavigation                            .ContentFragment"
23        android:label="@string/content"
24        tools:layout="@layout/fragment_content" />
The complete code for this step can be found at http://packt.live/2KrgcLV.
  1. 更新HomeFragment中的onCreateView函数,以使用导航图中的目的地导航到ContentFragment。您还需要添加以下导入:
import android.widget.Button
import androidx.navigation.Navigation
    override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    val view = inflater.inflate(R.layout.fragment_home,       container, false)
    view.findViewById<Button>(R.id.button_home)      ?.setOnClickListener(
        Navigation.createNavigateOnClickListener           (R.id.nav_home_to_content, null)
    )
    return view
}
  1. 现在导航图中已经定义了目的地,创建底部导航中的菜单以引用这些目的地。但首先,您需要收集将在此练习中使用的图标。转到 GitHub 上的已完成练习,并在drawable文件夹中找到矢量资产:

packt.live/3qvUzJQ

复制以下可绘制对象:

  • cart.xml

  • home.xml

  • offers.xml

  • rewards.xml

  • tickets.xml

  1. 创建一个bottom_nav_menu(右键单击res文件夹,选择Android 资源文件,并选择Menu,使用除cart.xml矢量资产之外的所有这些图标。请注意,项目的 ID 与导航图中的 ID 匹配。

bottom_nav_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<menu   xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/nav_home"
        android:icon="@drawable/home"
        android:title="@string/home" />
    <item
        android:id="@+id/nav_tickets"
        android:icon="@drawable/tickets"
        android:title="@string/tickets"/>
    <item
        android:id="@+id/nav_offers"
        android:icon="@drawable/offers"
        android:title="@string/offers" />
    <item
        android:id="@+id/nav_rewards"
        android:icon="@drawable/rewards"
        android:title="@string/rewards"/>
</menu>
  1. 使用以下内容更新activity_main.xml文件:

BottomNavigation视图配置了您之前创建的菜单,即app:menu="@menu/bottom_nav_menu",而NavHostFragment配置了app:navGraph="@navigation/mobile_navigation"。由于应用程序中的底部导航不直接连接到应用栏,因此需要设置的布局文件较少。这与导航抽屉不同,导航抽屉在应用栏中有汉堡菜单来切换导航抽屉。

  1. 使用以下内容更新MainActivity
package com.example.bottomnavigation
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.*
import com.google.android.material.bottomnavigation
  .BottomNavigationView
class MainActivity : AppCompatActivity() {
    private lateinit var appBarConfiguration:       AppBarConfiguration
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    val navHostFragment =       supportFragmentManager.findFragmentById         (R.id.nav_host_fragment) as NavHostFragment
    val navController = navHostFragment.navController
        //Creating top level destinations 
        //and adding them to bottom navigation
        appBarConfiguration = AppBarConfiguration(setOf(
            R.id.nav_home, R.id.nav_tickets, R.id.nav_offers,               R.id.nav_rewards))
        setupActionBarWithNavController(navController,           appBarConfiguration)
        findViewById<BottomNavigationView>(R.id.nav_view)          ?.setupWithNavController(navController)
    }
    override fun onSupportNavigateUp(): Boolean {
        val navController           = findNavController(R.id.nav_host_fragment)
        return navController.navigateUp(appBarConfiguration)           || super.onSupportNavigateUp()
    }
}

前面的代码应该非常熟悉,因为它在之前的练习中已经解释过了。这里的主要变化是,不再使用包含导航抽屉的主 UI 导航的NavigationView,而是用BottomNavigationView替换。此后的配置是相同的。

  1. 运行应用程序。您应该看到以下输出:图 4.11:底部导航,选择了主页

图 4.11:底部导航,选择了主页

  1. 显示了您设置的四个菜单项,其中Home项被选择为起始目的地。单击方形按钮将带您到Home内的次要目的地:图 4.12:Home 内的次要目的地

图 4.12:Home 内的次要目的地

  1. 使这成为可能的操作在导航图中指定:

mobile_navigation.xml(片段)

<fragment
    android:id="@+id/nav_home"
    android:name="com.example.bottomnavigation.HomeFragment"
    android:label="@string/home"
    tools:layout="@layout/fragment_home">
    <action
        android:id="@+id/nav_home_to_content"
        app:destination="@id/nav_content"
        app:popUpTo="@id/nav_home" />
</fragment>
  1. 由于底部导航 UI 中没有汉堡菜单,有时会将操作项(具有专用图标的项)添加到应用栏中。创建另一个名为Main的菜单,并添加以下内容:

main.xml

<?xml version="1.0" encoding="utf-8"?>
<menu   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/nav_cart"
        android:title="@string/cart"
        android:icon="@drawable/cart"
        app:showAsAction="always" />
    <item
        android:id="@+id/nav_settings"
        android:title="@string/settings"
        app:showAsAction="never" />
</menu>
  1. 此菜单将在应用栏中的溢出菜单中使用。单击三个点时,将显示溢出菜单。cart矢量资产也将显示在顶部应用栏上,因为app:showAsAction属性设置为always。通过添加以下内容在MainActivity中配置溢出菜单:

在文件顶部添加以下两个导入:

import android.view.Menu
import android.view.MenuItem

然后添加以下两个函数:

override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.main, menu)
    return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
    super.onOptionsItemSelected(item)
    return item.onNavDestinationSelected(findNavController       (R.id.nav_host_fragment))
}
  1. 现在将在应用栏中显示主菜单。再次运行应用程序,您将看到以下内容:

图 4.13:带有溢出菜单的底部导航

图 4.13:带有溢出菜单的底部导航

选择购物车将带您到我们在导航图中配置的次要目的地:

图 4.14:带有溢出菜单的底部导航中的次要目的地

图 4.14:底部导航与次要目的地中的溢出菜单

正如您在本练习中所见,设置底部导航非常简单。导航图和菜单设置使将菜单项链接到片段变得简单。此外,集成操作栏和溢出菜单也是实现的小步骤。如果您正在开发一个具有非常明确定义的顶级目的地并且在它们之间切换很重要的应用程序,那么这些目的地的可见性使底部导航成为理想选择。要探索的最终主要导航模式是选项卡导航。这是一种多功能模式,因为它可以用作应用程序的主要导航,但也可以与我们学习过的其他导航模式一起用作次要导航。

选项卡导航

选项卡导航主要用于显示相关项目。如果只有少量选项卡(通常在两个到五个选项卡之间),通常会使用固定选项卡,如果有超过五个选项卡,则会使用水平滚动选项卡。它们主要用于对处于相同层次结构级别的目的地进行分组。

如果目的地相关,这可以是主要导航。如果您开发的应用程序属于狭窄或特定主题领域,其中主要目的地相关,比如新闻应用程序,这可能是情况。更常见的是,它与底部导航一起使用,以呈现在主要目的地内可用的次要导航。以下练习演示了使用选项卡导航来显示相关项目。

练习 4.03:使用选项卡进行应用程序导航

在 Android Studio 中创建一个名为Tab Navigation的空活动的新应用程序。您将构建一个显示电影类型的骨架电影应用程序。让我们开始吧:

  1. 替换res/values文件夹中的strings.xml内容并更新themes.xml

<string name="dummy_text">文件提供了每个电影类型的一些正文文本:

themes.xml

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.TabNavigation"       parent="Theme.AppCompat.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">          @color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">          @color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Customize your theme here. -->
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor"           tools:targetApi="l">?attr/colorPrimaryVariant</item>
    </style>
    <style name="Theme.TabNavigation.AppBarOverlay"       parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
    <style name="Theme.TabNavigation.PopupOverlay"       parent="ThemeOverlay.AppCompat.Light" />
    <style name="title" >
        <item name="android:textSize">24sp</item>
        <item name="android:textStyle">bold</item>
    </style>
    <style name="body" >
        <item name="android:textSize">16sp</item>
    </style>
</resources>
  1. 创建一个名为MoviesFragment的单个片段,显示电影类型的标题和一些虚拟文本。标题将动态更新。然后更新电影片段布局:

fragment_movies布局中,具有 ID 为movie_typeTextView标签将动态更新为标题。您添加到strings.xml文件中的虚拟文本将显示在其下方。

  1. 使用以下内容更新MoviesFragment

MoviesFragment。由于这是从伴随对象完成的,因此可以直接从另一个类中使用静态语法引用,例如MoviesFragment.newInstance(movieGenre)。第二点是工厂方法将MOVIE_GENRE键设置为Bundle参数,并将movieGenre字符串设置为Bundle参数,以便以后可以从MoviesFragment中检索它。

  1. 使用以下内容更新activity_main.xml文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme           ="@style/Theme.TabNavigation.AppBarOverlay">
        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme               ="@style/Theme.TabNavigation.PopupOverlay"/>
        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabs"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:tabIndicatorHeight="4dp"
            app:tabIndicatorColor="@color/teal_200"
            app:tabRippleColor="@android:color/transparent"
            android:background="?attr/colorPrimary" />
    </com.google.android.material.appbar.AppBarLayout>
    <androidx.viewpager.widget.ViewPager
        android:id="@+id/view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

AppBarLayout标签和其中包含的工具栏在以前的练习中已经很熟悉了。在工具栏下方,显示了一个TabLayout标签,其中将包含电影选项卡。您可以使用各种属性来设置选项卡的样式。在这里,您正在设置选项卡的高度、颜色和材料涟漪效果为透明,以不显示正常的材料样式按钮。为了显示所需的内容,您将使用ViewPagerViewPager是一个可滑动的布局,允许您添加多个视图或片段,以便当用户滑动以更改其中一个选项卡时,正文内容显示相应的视图或片段。在本练习中,您将在电影片段之间滑动。提供在ViewPager中使用的数据的组件称为适配器。

  1. 创建一个简单的适配器,用于显示我们的电影。将其命名为MovieGenresPagerAdapter
package com.example.tabnavigation
import android.content.Context
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
    private val TAB_GENRES_SCROLLABLE = listOf(
        R.string.action,
        R.string.comedy,
        R.string.drama,
        R.string.sci_fi,
        R.string.family,
        R.string.crime,
        R.string.history
)
private val TAB_GENRES_FIXED = listOf(
    R.string.action,
    R.string.comedy,
    R.string.drama
)
class MovieGenresPagerAdapter(private val context: Context,   fm: FragmentManager)
: FragmentPagerAdapter(fm,   BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
    override fun getItem(position: Int): Fragment {
        return MoviesFragment.newInstance(context.resources           .getString(TAB_GENRES_FIXED[position]))
    }
    override fun getPageTitle(position: Int): CharSequence? {
        return context.resources           .getString(TAB_GENRES_FIXED[position])
    }
    override fun getCount(): Int {
        // Show total pages.
        return TAB_GENRES_FIXED.size
    }
}

首先,看一下MovieGenresPagerAdapter类头部。它扩展自FragmentPagerAdapter,这是专门用于滑动的适配器。这也称为通过片段进行分页。当您有一组不太大的定义的片段时,使用FragmentPagerAdapter是理想的。由于您将其用于一组选项卡,这是理想的。

由于FragmentPagerAdapter在不在屏幕上时保留片段在内存中,因此不适用于大量片段。在这种情况下,您将使用FragmentStatePagerAdpater,它可以在不在屏幕上时回收片段。

创建FragmentPagerAdapter时,传入一个FragmentManager,负责管理活动中使用的片段。BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT标志只保留当前片段处于用户可交互状态(RESUMED)的状态。其他片段处于STARTED状态,这意味着它们可以在滑动时可见,但不活动。

回调方法的功能如下:

  • getCount(): 此方法返回要显示的项目总数。

  • getPageTitle(position: Int): CharSequence?: 这通过使用特定位置检索列表中指定位置的流派标题。

  • getItem(position: Int): Fragment: 这在列表中的此位置获取MoviesFragment(或者如果首次访问,则创建新的MoviesFragment),通过传入要在片段中显示的流派标题。创建后,MoviesFragment将保留在内存中。

选项卡可以是固定的或可滚动的。您将看到的第一个示例是带有固定选项卡的。由于所有这些方法都使用了TAB_GENRES_FIXED,因此只会显示三个选项卡。但是,这并没有将TabLayout设置为固定或可滚动。这需要在活动中完成。

  1. 更新MainActivity,使其使用带有ViewPager的选项卡:
package com.example.tabnavigation
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.viewpager.widget.ViewPager
import com.google.android.material.tabs.TabLayout
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(findViewById(R.id.toolbar))
        val viewPager = findViewById<ViewPager>          (R.id.view_pager)
        val tabs = findViewById<TabLayout>(R.id.tabs)
        viewPager.adapter = MovieGenresPagerAdapter(this,           supportFragmentManager)
        tabs?.tabMode = TabLayout.MODE_FIXED
        tabs?.setupWithViewPager(viewPager)
    }
}
  1. onCreate方法中,在设置布局之后,设置应用栏,使其使用工具栏:
setSupportActionBar(toolbar)
  1. 设置要在可滑动的ViewPager中显示的数据,以来自MovieGenresPagerAdapter
view_pager.adapter = MovieGenresPagerAdapter(this,   supportFragmentManager)
  1. 设置TabLayout,以显示配置的ViewPager
tabs?.tabMode = TabLayout.MODE_FIXED
tabs?.setupWithViewPager(viewPager)
  1. 这负责设置选项卡标题并使选项卡主体内容可滑动。tabMode已设置为FIXEDtabs.tabMode = TabLayout.MODE_FIXED),以便选项卡将以与屏幕宽度相等的宽度均匀布局。现在运行应用程序。你应该看到以下内容:图 4.15:带有固定选项卡的选项卡布局

图 4.15:带有固定选项卡的选项卡布局

您可以在页面的主体中左右滑动,以转到三个选项卡中的每一个,并且还可以选择其中一个选项卡来执行相同的操作。现在,让我们更改正在显示的选项卡数据,并设置选项卡,以便可以滚动浏览。

  1. 首先,更改MovieGenresPagerAdapter,使其使用一些额外的流派:
class MovieGenresPagerAdapter(private val context: Context,   fm: FragmentManager)
: FragmentPagerAdapter(fm,   BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
    override fun getItem(position: Int): Fragment {
        return MoviesFragment.newInstance(context.resources           .getString(TAB_GENRES_SCROLLABLE[position]))
    }
    override fun getPageTitle(position: Int): CharSequence? {
        return context.resources           .getString(TAB_GENRES_SCROLLABLE[position])
    }
    override fun getCount(): Int {
        // Show total pages.
        return TAB_GENRES_SCROLLABLE.size
    }
}
  1. MainActivity中,设置tabMode,使其可滚动:
tabs.tabMode = TabLayout.MODE_SCROLLABLE
  1. 运行应用程序。你应该看到以下内容:

图 4.16:带有可滚动选项卡的选项卡布局

图 4.16:带有可滚动选项卡的选项卡布局

选项卡列表继续显示在屏幕外。可以滑动和选择选项卡,并且主体内容也可以滑动,这样你就可以通过选项卡页面左右移动。

通过这个练习,你学会了选项卡在应用程序中提供导航时的多功能性。固定宽度的选项卡可以用于主要和次要导航,而可滚动的选项卡可以用于将相关项目分组进行次要导航。可滚动选项卡充当次要导航,因此您还需要向应用程序添加主要导航。在这个例子中,出于简单起见,主要导航已被省略,但对于更真实世界和复杂的应用程序,您可以添加导航抽屉或底部导航。

活动 4.01:构建主要和次要应用程序导航

您的任务是创建一个体育应用程序。它可以有三个或更多的顶级目的地。但是,其中一个主要目的地必须称为“我的体育”,并且应链接到一个或多个次要目的地,即体育项目。您可以使用本章中探讨过的任何一种导航模式,或它们的组合,还可以引入您认为合适的任何自定义。用户当前所在的每个目的地都应显示在“应用”栏中。

有不同的方法可以尝试这个活动。一种方法是使用底部导航,并将各个次要体育目的地添加到导航图中,以便可以链接到这些目的地。这相当简单,并通过操作委托给导航图。使用此方法后,主屏幕应如下所示:

图 4.17:我的体育应用的底部导航

图 4.17:我的体育应用的底部导航

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

本章中所有练习和活动的来源位于此处:packt.live/39IAjxL

总结

本章涵盖了您需要了解的最重要的导航技术,以便在应用程序中创建清晰和一致的导航。您首先学习了如何使用 Jetpack 导航在 Android Studio 项目中创建导航抽屉,将导航菜单项连接到单独的片段。然后,您进一步了解了 Jetpack 导航中的操作,以在导航图中导航到应用程序中的其他次要目的地。

接下来的练习使用底部导航来显示始终可见于屏幕上的主要导航目的地。然后我们看了标签式导航,您学会了如何显示固定和可滚动的标签。对于每种不同的导航模式,您都会看到在构建应用程序时何时更适合使用。我们通过构建我们自己的应用程序并添加主要和次要目的地来完成了本章。

本章建立在我们在第一章中提供的关于使用 Android Studio 创建 Android 的全面介绍的基础上,以及您在第二章第三章中学到的有关活动和片段的知识,以及使用片段开发 UI 的知识。这些章节涵盖了您创建应用程序所需的知识、实践和基本 Android 组件。本章通过引导您了解可用的主要导航模式,将这些先前的章节联系在一起,使您的应用程序脱颖而出并易于使用。

下一章将在这些概念的基础上构建,并向您介绍更高级的显示应用内容的方法。您将首先学习使用RecyclerView将数据与列表绑定。之后,您将探索可以用于检索和填充应用程序内容的不同机制。

第五章:必要的库:Retrofit、Moshi 和 Glide

概述

在本章中,我们将介绍呈现来自远程服务器获取的动态内容所需的步骤。您将了解到检索和处理此动态数据所需的不同库。

在本章结束时,您将能够使用 Retrofit 从网络端点获取数据,使用 Moshi 将 JSON 有效负载解析为 Kotlin 数据对象,并使用 Glide 将图像加载到ImageViews中。

介绍

在上一章中,我们学习了如何在应用程序中实现导航。在本章中,我们将学习如何在用户在我们的应用程序中导航时向他们呈现动态内容。

向用户呈现的数据可以来自不同的来源。它可以硬编码到应用程序中,但这会带来一些限制。要更改硬编码数据,我们必须发布应用程序的更新。某些数据由于其性质而无法硬编码,例如货币汇率、资产的实时可用性和当前天气等。其他数据可能会过时,例如应用程序的使用条款。

在这种情况下,您通常会从服务器获取相关数据。用于提供此类数据的最常见架构之一是表现状态转移REST)架构。REST 架构由一组六个约束定义:客户端-服务器架构、无状态性、可缓存性、分层系统、按需代码(可选)和统一接口。要了解有关 REST 的更多信息,请访问medium.com/extend/what-is-rest-a-simple-explanation-for-beginners-part-1-introduction-b4a072f8740f

当应用于 Web 服务应用程序编程接口API)时,我们得到了基于超文本传输协议HTTP)的 RESTful API。HTTP 协议是互联网数据通信的基础,也被称为万维网。它是全球各地服务器用来向用户提供 HTML 文档、图像、样式表等的协议。有关此主题的有趣文章,可以在developer.mozilla.org/en-US/docs/Web/HTTP/Overview找到。

RESTful API 依赖于标准的 HTTP 方法——GETPOSTPUTDELETEPATCH——来获取和转换数据。这些方法允许我们在远程服务器上获取、存储、删除和更新数据实体。

要执行这些 HTTP 方法,我们可以依赖于内置的 Java HttpURLConnection类,或者使用诸如OkHttp之类的库,它提供了额外的功能,如 gzip 压缩、重定向、重试以及同步和异步调用。有趣的是,从 Android 4.4 开始,HttpURLConnection只是OkHttp的一个包装器。如果我们选择OkHttp,我们也可以选择Retrofit,正如我们将在本章中所做的那样,以从其类型安全中受益,这更适合处理 REST 调用。

最常用的数据表示形式是JavaScript 对象表示法JSON)。JSON 是一种基于文本的数据传输格式。顾名思义,它源自 JavaScript。然而,它已经成为了最流行的数据传输标准之一,大多数现代编程语言都有编码或解码数据到 JSON 或从 JSON 的库。一个简单的 JSON 有效负载可能如下所示:

{"employees":[  
    {"name": "James", "email": "james.notmyemail@gmail.com"},
    {"name": "Lea", "email": "lea.dontemailme@gmail.com"},
    {"name": "Steve", "email": "steve.notreally@gmail.com"}
]}

RESTful 服务常用的另一种数据结构是可扩展标记语言XML),它以一种人类和机器可读的格式对文档进行编码。XML 比 JSON 更冗长。以 XML 表示的与前述相同的数据结构可能如下所示:

<employees>
    <employee>
        <name>James</name>
        <email>james.notmyemail@gmail.com</email>
    </employee>
    <employee>
        <name>Lea</name>
        <email>lea.dontemailme@gmail.com</email>
    </employee>
    <employee>
        <name>Steve</name>
        <email>steve.notreally@gmail.com</email>
    </employee>
</employees>

在本章中,我们将专注于 JSON。

当获取 JSON 有效负载时,我们实质上是接收一个字符串。要将该字符串转换为数据对象,我们有一些选项,其中最流行的是org.json包等库。由于其轻量级特性,我们将专注于 Moshi。

最后,我们将研究如何从网络加载图像。这样做不仅可以让我们提供最新的图像,还可以为用户的设备加载正确的图像。这样做还可以让我们在需要时才加载图像,从而保持 APK 大小较小。

从网络端点获取数据

为了完成本节的目的,我们将使用 TheCatAPI(thecatapi.com/)。这个 RESTful API 为我们提供了大量关于猫的数据。

要开始,我们将创建一个新项目。然后我们必须授予我们的应用程序互联网访问权限。这是通过在您的AndroidManifest.xml文件中,在Application标签之前添加以下代码来完成的:

    <uses-permission android:name="android.permission.INTERNET" />

接下来,我们需要设置我们的应用程序以包含 Retrofit。OkHttp HTTP 客户端。 Retrofit 帮助我们生成统一资源定位符URL),这些是我们要访问的服务器端点的地址。它还通过与几个解析库的集成,使 JSON 有效负载的解码更容易。使用 Retrofit 发送数据到服务器也更容易,因为它有助于对请求进行编码。您可以在这里阅读更多关于 Retrofit 的信息:square.github.io/retrofit/

要将 Retrofit 添加到我们的项目中,我们需要将以下代码添加到我们应用程序的build.gradle文件的dependencies块中:

implementation 'com.squareup.retrofit2:retrofit:(insert latest version)'

注意

您可以在这里找到最新版本:github.com/square/retrofit

在我们的项目中包含了 Retrofit 后,我们可以继续设置它。

首先,要访问 HTTP(S)端点,我们首先要定义与该端点的合同。访问https://api.thecatapi.com/v1/images/search端点的合同如下:

interface TheCatApiService {
    @GET("images/search")
    fun searchImages(
        @Query("limit") limit: Int,
        @Query("size") format: String
    ): Call<String>
}

这里有几点需要注意。首先,您会注意到合同被实现为一个接口。这是您为 Retrofit 定义合同的方式。接下来,您会注意到接口的名称暗示着这个接口最终可以涵盖对 TheCatAPI 服务的所有调用。遗憾的是 Square 选择了Service作为这些合同的常规后缀,因为在 Android 世界中,服务一词有不同的含义,您将在第八章服务、广播接收器和通知中看到。尽管如此,这是惯例。

要定义我们的端点,我们首先要声明使用适当注释进行调用的方法。在我们的情况下,是@GET。传递给注释的参数是要访问的端点的路径。您会注意到https://api.thecatapi.com/v1/从该路径中删除了。这是因为这是 TheCatAPI 所有端点的常用地址,因此将在构建时传递给我们的 Retrofit 实例。接下来,我们选择一个有意义的函数名字,比如在这种情况下,我们将调用图像搜索端点,所以searchImages似乎是合适的。searchImages函数的参数定义了我们在进行调用时可以传递给 API 的值。

有多种方式可以将数据传输到 API。@Query允许我们定义添加到请求 URL 查询的值(这是 URL 中问号后面的可选部分)。它接受一个键值对(在我们的例子中,我们有limitsize)和一个数据类型。如果数据类型不是字符串,那么该类型的值将被转换为字符串。传递的任何值都将被 URL 编码。

另一种方法是使用@Path。此注释可用于将路径中用大括号括起来的标记替换为提供的值。@Header@Headers@HeaderMap注释将允许我们向请求添加或删除 HTTP 标头。@Body可用于在POST/PUT请求的正文中传递内容。

最后,我们有一个返回类型。在这个阶段,为了保持简单,我们将接受响应作为字符串。我们将字符串包装在Call接口中。Call是 Retrofit 执行网络请求的机制,可以同步(通过execute())或异步(通过enqueue(Callback))执行。当使用 RxJava(ReactiveX 的 Java 实现,或者叫做 Reactive Extensions;您可以在https://reactivex.io/上了解更多关于 ReactiveX 的信息)时,我们可以适当地将结果包装在Observable类(发出数据的类)或Single类(一次发出数据的类)中(有关 RxJava 的更多信息,请参见第十三章RxJava 和协程)。

定义了我们的合同,我们可以让 Retrofit 实现我们的服务接口:

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.thecatapi.com/v1/")
    .build()
val theCatApiService = retrofit.create(TheCatApiService::class.java)

如果我们尝试使用此代码运行应用程序,应用程序将崩溃并显示IllegalArgumentException。这是因为 Retrofit 需要我们告诉应用程序如何将服务器响应处理为字符串。这个处理是通过 Retrofit 调用的ConverterFactory实例来完成的,我们需要向我们的retrofit实例添加以下内容:

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.thecatapi.com/v1/")
    .addConverterFactory(ScalarsConverterFactory.create())
    .build()

为了使我们的项目识别ScalarsConverterFactory,我们需要通过添加另一个依赖项来更新我们的应用程序的build.gradle文件:

implementation 'com.squareup.retrofit2:converter-scalars:(insert latest version)'

现在,我们可以通过调用val call = theCatApiService.searchImages(1, "full")来获得一个Call实例。通过以这种方式获得的实例,我们可以通过调用call.enqueue(Callback)来执行异步请求。

我们的Callback实现将有两个方法:onFailure(Call, Throwable)onResponse(Call, Response)。请注意,如果调用了onResponse,我们不能保证会有一个成功的响应。只要我们成功地从服务器接收到任何响应并且没有发生意外异常,就会调用onResponse。因此,为了确认响应是成功的响应,我们应该检查response.isSuccessful属性。在网络错误或沿途某处发生意外异常的情况下,将调用onFailure函数。

那么我们应该在哪里实现 Retrofit 代码呢?在干净的架构中,数据由存储库提供。存储库又有数据源。这样的数据源可以是网络数据源。这就是我们将实现网络调用的地方。然后,我们的 ViewModel(在Model-View-ViewModelMVVM)的情况下,ViewModel 是暴露属性和命令的视图的抽象)将通过用例从存储库请求数据。

对于我们的实现,我们将简化流程,通过在 Activity 中实例化 Retrofit 和服务来完成。这不是一个好的做法。在生产应用中不要这样做。它不具有良好的可扩展性,并且非常难以测试。相反,采用一种将视图与业务逻辑和数据解耦的架构。参见第十四章架构模式,了解一些想法。

练习 5.01:从 API 读取数据

在接下来的章节中,我们将开发一个为一家全球特工机构的虚构应用程序,该机构拥有一个遍布全球的特工网络,拯救世界免受无数危险。所涉及的秘密机构非常独特:它运营秘密猫特工。在这个练习中,我们将创建一个应用程序,该应用程序将向我们展示来自 TheCatAPI 的一个随机秘密猫特工。在向用户呈现 API 数据之前,您首先必须获取该数据。让我们开始:

  1. 首先创建一个新的Empty Activity项目(文件|新建|新项目|空活动)。然后点击下一步

  2. 将应用程序命名为Cat Agent Profile

  3. 确保您的包名称为com.example.catagentprofile

  4. 将保存位置设置为您要保存项目的位置。

  5. 将其他所有内容保留为默认值,然后点击“完成”。

  6. 确保你在Project窗格中处于Android视图下:图 5.1:项目窗格中的 Android 视图

图 5.1:项目窗格中的 Android 视图

  1. 打开你的AndroidManifest.xml文件。像这样为你的应用程序添加互联网权限:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.catagentprofile">
    <uses-permission android:name="android.permission.INTERNET" />
    <application ...>
        ...
    </application>
</manifest>
  1. 要向你的应用程序添加 Retrofit 和标量转换器,打开应用程序模块的build.gradleGradle Scripts | build.gradle (Module: app)),并在dependencies块的任何位置添加以下行:
dependencies {
    ...
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
    ...
}

你的dependencies块现在应该看起来像这样:

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib
      :$kotlin_version"
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.2.1'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation 'androidx.navigation:navigation-fragment
      -ktx:2.2.2'
    implementation 'androidx.navigation:navigation-ui-ktx:2.2.2'
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core
      :3.3.0'
}

在你进行这个练习的时间和实施之间,一些依赖关系可能已经发生了变化。你应该仍然只添加前面代码块中加粗的行。这些将添加 Retrofit 和支持读取服务器响应作为单个字符串的功能。

注意

值得注意的是,Retrofit 现在要求至少 Android API 21 或 Java 8。

  1. 在 Android Studio 中点击Sync Project with Gradle Files按钮。

  2. Text模式打开你的activity_main.xml文件。

  3. 为了能够使用标签来呈现最新的服务器响应,你需要为它分配一个 ID:

<TextView
    android:id="@+id/main_server_response"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello World!"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
  1. 在左侧的Project窗格中,右键单击你的应用程序包(com.example.catagentprofile),然后选择New | Package

  2. 将你的包命名为api

  3. 现在,在新创建的包(com.example.catagentprofile.api)上右键单击,然后选择New | Kotlin File/Class

  4. 给你的新文件命名为TheCatApiService。对于Kind,选择Interface

  5. interface块中添加以下内容:

interface TheCatApiService {
    @GET("images/search")
    fun searchImages(
        @Query("limit") limit: Int,
        @Query("size") format: String
    ) : Call<String>
}

这定义了图像搜索的端点。确保导入所有必需的 Retrofit 依赖项。

  1. 打开你的MainActivity文件。

  2. MainActivity类块的顶部添加以下内容:

class MainActivity : AppCompatActivity() {
lazy to make sure the instances are only created when needed.
  1. serverResponseView添加为一个字段:
class MainActivity : AppCompatActivity() {
main_server_response ID the first time serverRespnseView is accessed and then keep a reference to it.
  1. 现在,在onCreate(Bundle?)函数之后添加getCatImageResponse()函数:
override fun onCreate(savedInstanceState: Bundle?) {
    ...
}
private fun getCatImageResponse() {
val call = theCatApiService.searchImages(1, "full")
    call.enqueue(object : Callback<String> {
        override fun onFailure(call: Call<String>, t: Throwable) {
            Log.e("MainActivity", "Failed to get search results", t)
        }
        override fun onResponse(
            call: Call<String>,
            response: Response<String>
        ) {
            if (response.isSuccessful) {
                serverResponseView.text = response.body()
            } else {
                Log.e(
                    "MainActivity",
                    "Failed to get search results\n
                      ${response.errorBody()?.string() ?: ""}"
                )
            }
        }
    })
}

这个函数将发出搜索请求并处理可能的结果——成功的响应、错误的响应和任何其他抛出的异常。

  1. onCreate()中调用getCatImageResponse()。这将在活动创建时触发调用:
override fun onCreate(savedInstanceState: Bundle?) {
    ...
    getCatImageResponse()
}
  1. 添加缺失的导入。

  2. 通过点击Run 'app'按钮或按下Ctrl + R来运行你的应用程序。在模拟器上,它应该看起来像这样:

图 5.2:应用程序呈现服务器响应 JSON

图 5.2:应用程序呈现服务器响应 JSON

因为每次运行应用程序都会进行一次新的调用并返回一个随机的响应,所以你的结果可能会有所不同。然而,无论你的结果如何,如果成功的话,它应该是一个 JSON 负载。接下来,我们将学习如何解析该 JSON 负载并从中提取我们想要的数据。

解析 JSON 响应

现在我们已经成功从 API 中检索到了 JSON 响应,是时候学习如何使用我们获取到的数据了。为了做到这一点,我们需要解析 JSON 负载。这是因为负载是一个表示数据对象的纯字符串,我们对该对象的特定属性感兴趣。如果你仔细看图 5.2,你可能会注意到 JSON 包含品种信息、图像 URL 和一些其他信息。然而,为了让我们的代码使用这些信息,首先我们需要提取它。

如介绍中所述,存在多个库可以解析 JSON 负载。最流行的是 Google 的 GSON(github.com/google/gson)和最近更受欢迎的是 Square 的 Moshi(github.com/square/moshi)。Moshi 非常轻量级,这就是为什么我们选择在本章中使用它的原因。

JSON 库的作用是什么?基本上,它们帮助我们将数据类转换为 JSON 字符串(序列化)以及反之(反序列化)。这帮助我们与理解 JSON 字符串的服务器进行通信,同时允许我们在代码中使用有意义的数据结构。

要在 Retrofit 中使用 Moshi,我们需要将 Moshi Retrofit 转换器添加到我们的项目中。这是通过将以下行添加到我们应用程序的build.gradle文件的dependencies块中来完成的:

implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'

由于我们将不再接受字符串作为响应,我们可以继续删除标量 Retrofit 转换器。

接下来,我们需要创建一个数据类,将服务器 JSON 响应映射到该类。一个惯例是给 API 响应数据类的名称添加后缀Data——所以我们将我们的数据类称为ImageResultData。另一个常见的后缀是Entity

当我们设计服务器响应数据类时,我们需要考虑两个因素:JSON 响应的结构和我们的数据需求。第一个将影响我们的数据类型和字段名称,而第二个将允许我们省略我们当前不需要的字段。JSON 库知道它们应该忽略我们在数据类中未定义的字段中的数据。

JSON 库为我们做的另一件事是,如果它们恰好具有完全相同的名称,它们会自动将 JSON 数据映射到字段。虽然这是一个很好的功能,但也有问题。如果我们完全依赖它,我们的数据类(以及访问它们的代码)将与 API 命名紧密耦合。因为并非所有 API 都设计良好,您可能最终会得到毫无意义的字段名称,例如fnlast,或者不一致的命名。幸运的是,这个问题有解决办法。Moshi 为我们提供了一个@field:Json注解。它可以用来将 JSON 字段名称映射到有意义的字段名称:

data class UserData(
    @field:Json(name = "fn") val firstName: String,
    @field:Json(name = "last") val lastName: String
)

有人认为,出于一致性考虑,即使 API 名称与字段名称相同,也最好包括该注解。当字段名称足够清晰时,我们更喜欢直接转换的简洁性。当我们混淆我们的代码时,这种方法可能会受到挑战。如果我们这样做,我们要么排除我们的数据类,要么确保对所有字段进行注释。

虽然我们并不总是幸运地拥有适当记录的 API,但当我们拥有时,最好在设计我们的模型时咨询文档。我们的模型将是一个数据类,其中我们进行的所有调用的 JSON 数据将被解码。TheCatAPI 图像搜索端点的文档可以在docs.thecatapi.com/api-reference/images/images-search找到。您经常会发现文档是部分的或不准确的。如果是这种情况,您能做的最好的事情就是联系 API 的所有者,并要求他们更新文档。不幸的是,您可能不得不尝试使用端点。这是有风险的,因为未记录的字段或结构不能保证保持不变,所以在可能的情况下,尽量获取文档更新。

根据从上述链接获取的响应模式,我们可以定义我们的模型如下:

data class ImageResultData(
    @field:Json(name = "url") val imageUrl: String,
    val breeds: List<CatBreedData>
)
data class CatBreedData(
    val name: String,
    val temperament: String
)

请注意,响应结构实际上是结果列表。这意味着我们需要将我们的响应映射到List<ImageResultData>,而不仅仅是ImageResultData

现在,我们需要更新TheCatApiService。现在我们可以有Call<List<ImageResultData>>,而不是Call<String>

接下来,我们需要更新我们的 Retrofit 实例的构造。现在我们将有MoshiConverterFactory,而不是ScalarsConverterFactory

最后,我们需要更新我们的回调,因为它不再处理字符串调用,而是处理List<ImageResultData>

@GET("images/search")
fun searchImages(
    @Query("limit") limit: Int,
    @Query("size") format: String
) : Call<List<ImageResultData>>

练习 5.02:从 API 响应中提取图像 URL

因此,我们有一个作为字符串的服务器响应。现在,我们想从该字符串中提取图像 URL,并仅在屏幕上显示该 URL:

  1. 打开应用程序的build.gradle文件,并用 Moshi 转换器替换标量转换器实现:
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
    testImplementation 'junit:junit:4.12'
  1. 单击“与 Gradle 文件同步”按钮。

  2. 在您的应用程序包(com.example.catagentprofile)下,创建一个model包。

  3. com.example.catagentprofile.model包中,创建一个名为CatBreedData的新的 Kotlin 文件。

  4. 使用以下内容填充新创建的文件:

package com.example.catagentprofile.model
data class CatBreedData(
    val name: String,
    val temperament: String
)
  1. 接下来,在同一个包下创建ImageResultData

  2. 将其内容设置为以下内容:

package com.example.catagentprofile.model
import com.squareup.moshi.Json
data class ImageResultData(
    @field:Json(name = "url") val imageUrl: String,
val breeds: List<CatBreedData>
)
  1. 打开TheCatApiService文件并更新searchImages返回类型:
    @GET("images/search")
    fun searchImages(
        @Query("limit") limit: Int,
        @Query("size") format: String
    ) : Call<List<ImageResultData>>
  1. 最后,打开MainActivity

  2. 更新 Retrofit 初始化块以使用 Moshi 转换器进行反序列化 JSON:

    private val retrofit by lazy {
        Retrofit.Builder()
            .baseUrl("https://api.thecatapi.com/v1/")
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
    }
  1. 更新getCatImageResponse()函数以处理List<ImageResultData>的请求和响应:
private fun getCatImageResponse() {
    val call = theCatApiService.searchImages(1, "full")
    call.enqueue(object : Callback<List<ImageResultData>> {
        override fun onFailure(call: Call<List<ImageResultData>>,           t: Throwable) {
            Log.e("MainActivity", "Failed to get search results",             t)
        }
        override fun onResponse(
           call: Call<List<ImageResultData>>,
            response: Response<List<ImageResultData>>
        ) {
            if (response.isSuccessful) {
                val imageResults = response.body()
                val firstImageUrl = imageResults?.firstOrNull()                  ?.imageUrl ?: "No URL"
                serverResponseView.text = "Image URL:                   $firstImageUrl"
            } else {
                Log.e(
                    "MainActivity",
                    "Failed to get search                        results\n${response.errorBody()?.string()                          ?: ""}"
                )
            }
        }
    })
}
  1. 现在,您需要检查的不仅是成功的响应,还要确保至少有一个ImageResultData实例。然后,您可以读取该实例的imageUrl属性并将其呈现给用户。

  2. 运行您的应用程序。现在它应该看起来像下面这样:图 5.3:应用程序呈现解析后的图像 URL

图 5.3:应用程序呈现解析后的图像 URL

  1. 由于 API 响应的随机性,您的 URL 可能会不同。

您现在已成功从 API 响应中提取了特定属性。接下来,我们将学习如何从 API 提供的 URL 加载图像。

从远程 URL 加载图像

我们刚学会了如何从 API 响应中提取特定数据。很多时候,这些数据将包括我们想要呈现给用户的图像的 URL。这需要相当多的工作。首先,您必须从 URL 中获取图像作为二进制流。然后,您需要将该二进制流转换为图像(可以是 GIF、JPEG 或其他几种图像格式之一)。然后,您需要将其转换为位图实例,可能调整大小以使用更少的内存。

您可能还希望在此时对其进行其他转换。然后,您需要将其设置为ImageView。听起来是很多工作,不是吗?幸运的是,有一些库可以为我们完成所有这些工作(甚至更多)。最常用的库是 Square 的Picassosquare.github.io/picasso/)和 Bump Technologies 的Glidegithub.com/bumptech/glide)。Facebook 的Frescofrescolib.org/)相对不太受欢迎。我们将继续使用 Glide,因为它始终是加载图像的两者中更快的一个,无论是来自互联网还是缓存。值得注意的是 Picasso 更轻量级,所以这是一个权衡,两个库都非常有用。

要在项目中包含 Glide,请将其添加到应用程序的build.gradle文件的dependencies块中:

dependencies {
    implementation 'com.github.bumptech.glide:glide:4.10.0'
    ...
}

实际上,因为我们可能在以后改变主意,这是一个很好的机会来将具体库抽象出来,以拥有更简单的接口。所以,让我们从定义我们的ImageLoader接口开始:

interface ImageLoader {
    fun loadImage(imageUrl: String, imageView: ImageView)
}

这是一个天真的实现。在生产实现中,您可能希望添加参数(或多个函数)以支持不同的裁剪策略或具有加载状态。

我们的接口实现将依赖于 Glide,因此看起来会像这样:

class GlideImageLoader(private val context: Context) : ImageLoader {
    override fun loadImage(imageUrl: String, imageView: ImageView) {
        Glide.with(context)
            .load(imageUrl)
            .centerCrop()
            .into(imageView)
    }
}

我们在类名前加上Glide以区别于其他潜在的实现。使用context构建GlideImageLoader允许我们实现清晰的loadImage(String, ImageView)接口,而不必担心 Glide 所需的上下文,这实际上是 Glide 对 Android 上下文的智能处理。这意味着我们可以针对ActivityFragment范围有单独的实现,而 Glide 会知道何时图像加载请求超出范围。

由于我们尚未在布局中添加ImageView,现在让我们这样做:

<TextView
    ...
    app:layout_constraintBottom_toTopOf="@+id/main_profile_image"
    ... />
<ImageView
    android:id="@+id/main_profile_image"
    android:layout_width="150dp"
    android:layout_height="150dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/main_server_response" />

这将在我们的TextView下方添加一个 ID 为main_profile_imageImageView

现在我们可以在MainActivity中创建GlideImageLoader的实例:

private val imageLoader: ImageLoader by lazy { GlideImageLoader(this) }

在生产应用中,您将注入依赖项,而不是内联创建。

接下来,我们告诉我们的 Glide 加载器加载图像,并且一旦加载完成,就在提供的ImageView内居中裁剪它。这意味着图像将被放大或缩小以完全填充ImageView,任何多余的内容都将被裁剪掉。由于我们之前已经获得了图像 URL,所以我们需要做的就是调用:

val firstImageUrl = imageResults?.firstOrNull()?.imageUrl ?: ""
if (!firstImageUrl.isBlank()) {
    imageLoader.loadImage(firstImageUrl, profileImageView)
} else {
    Log.d("MainActivity", "Missing image URL")
}

我们必须确保结果包含一个不为空或由空格组成的字符串(在前面的代码块中使用isBlank())。然后,我们可以安全地将 URL 加载到我们的ImageView中。然后就完成了。如果现在运行我们的应用程序,应该会看到类似以下的东西:

图 5.4:服务器响应图像 URL 与实际图像

图 5.4:服务器响应图像 URL 与实际图像

请记住,API 返回随机结果,因此实际图像可能会有所不同。如果我们幸运的话,甚至可能会得到一个动画 GIF,然后我们就会看到它动画。

练习 5.03:从获取的 URL 加载图像

在上一个练习中,我们从 API 响应中提取了图像 URL。现在,我们将使用该 URL 从网络获取图像并在我们的应用程序中显示它:

  1. 打开应用程序的build.gradle文件并添加 Glide 依赖项:
dependencies {
    ... 
    implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
    implementation 'com.github.bumptech.glide:glide:4.11.0'
    testImplementation 'junit:junit:4.12'
    ...
}

将项目与 Gradle 文件同步。

  1. 在左侧的Project面板上,右键单击您的项目包名称(com.example.catagentprofile),然后选择New | Kotlin File/Class

  2. Name字段中填写ImageLoader。对于Kind,选择Interface

  3. 打开新创建的ImageLoader.kt文件,并像这样更新它:

interface ImageLoader {
    fun loadImage(imageUrl: String, imageView: ImageView)
}

这将是应用程序中任何图像加载器的接口。

  1. 右键单击项目包名称,然后再次选择New | Kotlin File/Class

  2. 将新文件命名为GlideImageLoader,并选择Class作为Kind

  3. 更新新创建的文件:

class GlideImageLoader(private val context: Context) : ImageLoader {
override fun loadImage(imageUrl: String, imageView: ImageView) {
        Glide.with(context)
            .load(imageUrl)
            .centerCrop()
            .into(imageView)
    }
}
  1. 打开activity_main.xml

像这样更新它:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/main_server_response"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toImageView named main_profile_image below your TextView.
  1. 打开MainActivity.kt文件。

  2. 在您的类顶部添加一个新添加的ImageView字段:

private val serverResponseView: TextView
    by lazy { findViewById(R.id.main_server_response) } 
private val profileImageView: ImageView
by lazy { findViewById(R.id.main_profile_image) } 
  1. onCreate(Bundle?)函数的上方定义ImageLoader
private val imageLoader: ImageLoader by lazy { GlideImageLoader(this) }
override fun onCreate(savedInstanceState: Bundle?) {
  1. 像这样更新您的getCatImageResponse()函数:
private fun getCatImageResponse() {
    val call = theCatApiService.searchImages(1, "full")
    call.enqueue(object : Callback<List<ImageResultData>> {
        override fun onFailure(call: Call<List<ImageResultData>>,           t: Throwable) {
            Log.e("MainActivity", "Failed to get search results", t)
        }
        override fun onResponse(
            call: Call<List<ImageResultData>>,
            response: Response<List<ImageResultData>>
        ) {
            if (response.isSuccessful) {
                val imageResults = response.body()
                val firstImageUrl =                   imageResults?.firstOrNull()?.imageUrl ?: ""
                if (firstImageUrl.isNotBlank()) {
imageLoader.loadImage(firstImageUrl, 
                      profileImageView)
                } else {
                    Log.d("MainActivity", "Missing image URL")
                }
                serverResponseView.text = "Image URL: $firstImageUrl"
            } else {
                Log.e(
                    "MainActivity",
                    "Failed to get search results\n
                      ${response.errorBody()?.string() ?: ""}"
                )
            }
        }
    })
}
  1. 现在,一旦您有一个非空的 URL,它将被加载到profileImageView中。

  2. 运行应用程序:图 5.5:练习结果-显示随机图像及其源 URL

图 5.5:练习结果-显示随机图像及其源 URL

以下是额外的步骤。

  1. 像这样更新您的布局:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/main_agent_breed_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="16dp"
        android:text="Agent breed:"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    <TextView
        android:id="@+id/main_agent_breed_value"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:paddingTop="16dp"
        app:layout_constraintStart_toEndOf=
          "@+id/main_agent_breed_label"
        app:layout_constraintTop_toTopOf=
          "@+id/main_agent_breed_label" />
    <ImageView
        android:id="@+id/main_profile_image"
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:layout_margin="16dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf=
          "@+id/main_agent_breed_label" />
</androidx.constraintlayout.widget.ConstraintLayout>

这将添加一个Agent breed标签并整理视图布局。现在,您的布局看起来更像是一个合适的猫代理配置文件应用程序。

  1. MainActivity.kt中,找到以下行:
private val serverResponseView: TextView
    by lazy { findViewById(R.id.main_server_response) } 

用以下内容替换该行以查找新的名称字段:

private val agentBreedView: TextView
    by lazy { findViewById(R.id.main_agent_breed_value) }
  1. 更新getCatImageResponse()如下:
private fun getCatImageResponse() {
    val call = theCatApiService.searchImages(1, "full")
    call.enqueue(object : Callback<List<ImageResultData>> {
        override fun onFailure(call: Call<List<ImageResultData>>,           t: Throwable) {
            Log.e("MainActivity", "Failed to get search results", t)
        }
        override fun onResponse(
            call: Call<List<ImageResultData>>,
            response: Response<List<ImageResultData>>
        ) {
            if (response.isSuccessful) {
                val imageResults = response.body()
                val firstImageUrl =                   imageResults?.firstOrNull()?.imageUrl ?: ""
                if (!firstImageUrl.isBlank()) {
                    imageLoader.loadImage(firstImageUrl, 
                      profileImageView)
                } else {
                    Log.d("MainActivity", "Missing image URL")
                }
                agentBreedView.text =
                    imageResults?.firstOrNull()?.breeds?                      .firstOrNull()?.name ?: "Unknown"
            } else {
                Log.e(
                  "MainActivity",
                  "Failed to get search results\n
                    ${response.errorBody()?.string() ?:""}"
                )
            }
        }
    })
}

这是为了将 API 返回的第一个品种加载到agentNameView中,如果没有则回退到Unknown

  1. 在撰写本文时,TheCatAPI 中没有太多带有品种数据的图片。但是,如果您运行应用程序足够多次,最终会看到类似这样的东西:

图 5.6:显示猫代理图像和品种

图 5.6:显示猫代理图像和品种

在本章中,我们学习了如何从远程 API 获取数据。然后,我们学习了如何处理这些数据并从中提取我们需要的信息。最后,我们学习了如何在给定图像 URL 时在屏幕上呈现图像。

在接下来的活动中,我们将应用我们的知识开发一个应用程序,告诉用户纽约的当前天气,并向用户展示相关的天气图标。

活动 5.01:显示当前天气

假设我们想要构建一个应用程序,显示纽约的当前天气。此外,我们还想显示代表当前天气的图标。

这个活动旨在创建一个应用程序,它会轮询一个 API 端点以获取 JSON 格式的当前天气,将这些数据转换为本地模型,并使用该模型呈现当前天气。它还会提取代表当前天气的图标的 URL,并获取该图标以在屏幕上显示。

我们将使用免费的 OpenWeatherMap.org API 来完成这个活动的目的。文档可以在www.metaweather.com/api/找到。要注册 API 令牌,请转到home.openweathermap.org/users/sign_up。您可以在home.openweathermap.org/api_keys找到您的密钥,并根据需要生成新的密钥。

步骤如下:

  1. 创建一个新的应用程序。

  2. 授予应用程序互联网权限,以便能够进行 API 和图像请求。

  3. 将 Retrofit、Moshi 转换器和 Glide 添加到应用程序中。

  4. 更新应用程序布局,以支持以文本形式(简短和长描述)呈现天气以及天气图标图像。

  5. 定义模型。创建包含服务器响应的类。

  6. 为 OpenWeatherMap API 添加 Retrofit 服务,api.openweathermap.org/data/2.5/weather

  7. 使用 Moshi 转换器创建一个 Retrofit 实例。

  8. 调用 API 服务。

  9. 处理成功的服务器响应。

  10. 处理不同的失败场景。

预期输出如下:

图 5.7:最终的天气应用程序

图 5.7:最终的天气应用程序

注意

此活动的解决方案可以在此处找到:http://packt.live/3sKj1cp

总结

在本章中,我们学会了如何使用 Retrofit 从 API 获取数据。然后我们学会了如何使用 Moshi 处理 JSON 响应,以及纯文本响应。我们还看到了如何处理不同的错误场景。

后来我们学会了如何使用 Glide 从 URL 加载图像,以及如何通过ImageView呈现给用户。

有很多流行的库可以从 API 中获取数据,以及加载图像。我们只涵盖了一些最流行的库。您可能想尝试一些其他库,找出哪些最适合您的目的。

在下一章中,我们将介绍RecyclerView,这是一个强大的 UI 组件,我们可以用它来向用户呈现项目列表。

第六章:RecyclerView

概述

在这一章中,您将学习如何向您的应用程序添加项目列表和网格,并有效地利用RecyclerView的回收功能。您还将学习如何处理屏幕上项目视图的用户交互,并支持不同的项目视图类型,例如标题。在本章的后面,您将动态添加和删除项目。

通过本章结束时,您将具备呈现交互式丰富项目列表所需的技能。

介绍

在上一章中,我们学习了如何从 API 中获取数据,包括项目列表和图像 URL,并如何从 URL 加载图像。将这些知识与显示项目列表的能力结合起来是本章的目标。

通常,您会希望向用户呈现项目列表。例如,您可能希望向他们显示设备上的图片列表,或者让他们从所有国家的列表中选择自己的国家。为此,您需要填充多个视图,所有这些视图共享相同的布局,但呈现不同的内容。

在历史上,这是通过使用ListViewGridView来实现的。虽然这两者仍然是可行的选择,但它们不具备RecyclerView的健壮性和灵活性。例如,它们不太好地支持大型数据集,不支持水平滚动,并且不提供丰富的分隔符自定义。使用RecyclerView.ItemDecorator可以轻松实现对RecyclerView中项目之间的分隔符进行自定义。

那么,RecyclerView是做什么的呢?RecyclerView协调创建、填充和重用(因此得名)表示项目列表的视图。要使用RecyclerView,您需要熟悉其两个依赖项:适配器(以及通过它的视图持有者)和布局管理器。这些依赖项为我们的RecyclerView提供要显示的内容,并告诉它如何呈现该内容以及如何在屏幕上布置它。

适配器为RecyclerView提供子视图(RecyclerView中用于表示单个数据项的嵌套 Android 视图),绑定这些视图到数据(通过ViewHolder实例),并报告用户与这些视图的交互。布局管理器告诉RecyclerView如何布置其子项。我们默认提供了三种布局类型:线性、网格和交错网格,分别由LinearLayoutManagerGridLayoutManagerStaggeredGridLayoutManager管理。

在本章中,我们将开发一个列出秘密特工及其当前活动状态或休眠状态(因此不可用)的应用程序。然后,该应用程序将允许我们添加新特工或通过滑动将现有特工删除。不过,有一个转折,正如您在第五章中看到的,基本库:Retrofit、Moshi 和 Glide,我们所有的特工都将是猫。

将 RecyclerView 添加到我们的布局中

第三章屏幕和 UI中,我们看到了如何向我们的布局中添加视图,以便由活动、片段或自定义视图膨胀。RecyclerView只是另一个这样的视图。要将其添加到我们的布局中,我们需要向我们的布局添加以下标签:

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recycler_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:listitem="@layout/item_sample" />

您应该已经能够识别android:id属性,以及android:layout_widthandroid:layout_height属性。

我们可以使用可选的tools:listitem属性告诉 Android Studio 在我们的预览工具栏中膨胀哪个布局作为列表项。这将让我们对RecyclerView在我们的应用程序中的外观有一个概念。

向我们的布局添加RecyclerView标签意味着我们现在有一个空容器来容纳表示我们列表项目的子视图。一旦填充,它将为我们处理子视图的呈现、滚动和回收。

练习 6.01:向主活动添加一个空的 RecyclerView

要在应用程序中使用RecyclerView,您首先需要将其添加到您的布局之一中。让我们将其添加到我们的主活动膨胀的布局中:

  1. 首先创建一个新的空活动项目(文件 | 新建 | 新项目 | 空活动)。将应用程序命名为My RecyclerView App。确保您的包名称为com.example.myrecyclerviewapp

  2. 将保存位置设置为您要保存项目的位置。将其他所有内容保持默认值,然后单击完成。确保您在项目窗格中处于Android视图下:图 6.1:项目窗格中的 Android 视图

图 6.1:项目窗格中的 Android 视图

  1. Text模式下打开您的activity_main.xml文件。

  2. 将您的标签转换为屏幕顶部的标题,您可以在其下添加您的RecyclerView,为TextView添加一个 ID,并将其对齐到顶部,如下所示:

<TextView
    android:id="@+id/hello_label"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello World!"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
  1. TextView标签之后添加以下内容,以在您的hello_label TextView标题下方添加一个空的RecyclerView元素:
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recycler_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toBottomOf="@+id/hello_label" />

您的布局文件现在应该看起来像这样:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/hello_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@+id/hello_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 通过单击运行应用程序按钮或按Ctrl + R(在 Windows 上为Shift + F10)来运行您的应用程序。在模拟器上,它应该看起来像这样:

图 6.2:带有空的 RecyclerView 的应用程序(为节省空间而裁剪的图像)

图 6.2:带有空的 RecyclerView 的应用程序(为节省空间而裁剪的图像)

正如您所看到的,我们的应用程序正在运行,并且我们的布局显示在屏幕上。但是,我们没有看到我们的RecyclerView。为什么呢?在这个阶段,我们的RecyclerView没有内容。默认情况下,没有内容的RecyclerView不会呈现—因此,虽然我们的RecyclerView确实在屏幕上,但它是不可见的。这就带我们到下一步—填充RecyclerView,以便我们实际上可以看到内容。

填充 RecyclerView

因此,我们将RecyclerView添加到我们的布局中。为了从RecyclerView中受益,我们需要向其中添加内容。让我们看看如何做到这一点。

正如我们之前提到的,要向我们的RecyclerView添加内容,我们需要实现一个适配器。适配器将我们的数据绑定到子视图。简单来说,这意味着它告诉RecyclerView如何将数据插入到设计用于呈现该数据的视图中。

例如,假设我们想要呈现一个员工列表。

首先,我们需要设计我们的 UI 模型。这将是一个数据对象,其中包含视图呈现单个员工所需的所有信息。因为这是一个 UI 模型,一个惯例是在其名称后缀中加上UiModel

data class EmployeeUiModel(
    val name: String,
    val biography: String,
    val role: EmployeeRole,
    val gender: Gender,
    val imageUrl: String
)

我们将定义EmployeeRoleGender如下:

enum class EmployeeRole {
    HumanResources,
    Management,
    Technology
}
enum class Gender {
    Female,
    Male,
    Unknown
}

这些值仅供参考。请随意添加更多!

图 6.3:模型的层次结构

图 6.3:模型的层次结构

现在我们知道在绑定视图时可以期望什么样的数据,因此,我们可以设计我们的视图来呈现这些数据(这是实际布局的简化版本,我们将其保存为item_employee.xml)。我们将从ImageView开始:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="8dp">
    <ImageView
        android:id="@+id/item_employee_photo"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:contentDescription="@string/item_employee_photo"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:background="@color/colorPrimary" />

然后为每个TextView添加:

    <TextView
        android:id="@+id/item_employee_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginLeft="16dp"
        android:textStyle="bold"
        app:layout_constraintStart_toEndOf="@+id/item_employee_photo"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Oliver" />
    <TextView
        android:id="@+id/item_employee_role"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/colorAccent"
        app:layout_constraintStart_toStartOf="@+id/item_employee_name"
        app:layout_constraintTop_toBottomOf="@+id/item_employee_name"
        tools:text="Exotic Shorthair" />
    <TextView
        android:id="@+id/item_employee_biography"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="@+id/item_employee_role"
        app:layout_constraintTop_toBottomOf="@+id/item_employee_role"
        tools:text="Stealthy and witty. Better avoid in dark alleys." />
    <TextView
        android:id="@+id/item_employee_gender"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="30sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="&#9794;" />
</androidx.constraintlayout.widget.ConstraintLayout>

到目前为止,没有什么新的。您应该能够从第二章 构建用户屏幕流中识别出所有不同的视图类型:

图 6.4:item_cat.xml 布局文件的预览

图 6.4:item_cat.xml 布局文件的预览

有了数据模型和布局,我们现在拥有了将数据绑定到视图所需的一切。为此,我们将实现一个视图持有者。通常,视图持有者有两个职责:它保存对视图的引用(正如其名称所暗示的那样),但它也将数据绑定到该视图。我们将实现我们的视图持有者如下:

private val FEMALE_SYMBOL by lazy {
    HtmlCompat.fromHtml("&#9793;", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private val MALE_SYMBOL by lazy {
    HtmlCompat.fromHtml("&#9794;", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private const val UNKNOWN_SYMBOL = "?"
class EmployeeViewHolder(
    containerView: View,
    private val imageLoader: ImageLoader
) : ViewHolder(containerView) {
private val employeeNameView: TextView
by lazy { containerView.findViewById(R.id.item_employee_name) }
private val employeeRoleView: TextView
by lazy { containerView.findViewById(R.id.item_employee_role) }
private val employeeBioView: TextView
by lazy { containerView.findViewById(R.id.item_employee_bio) }
private val employeeGenderView: TextView
by lazy { containerView.findViewById(R.id.item_employee_gender) }
    fun bindData(employeeData: EmployeeUiModel) {
        imageLoader.loadImage(employeeData.imageUrl, employeePhotoView)
        employeeNameView.text = employeeData.name
        employeeRoleView.text = when (employeeData.role) {
            EmployeeRole.HumanResources -> "Human Resources"
            EmployeeRole.Management -> "Management"
            EmployeeRole.Technology -> "Technology"
        }
        employeeBioView.text = employeeData.biography
        employeeGenderView.text = when (employeeData.gender) {
            Gender.Female -> FEMALE_SYMBOL
            Gender.Male -> MALE_SYMBOL
            else -> UNKNOWN_SYMBOL
        }
    }
}

在上述代码中有一些值得注意的事情。首先,按照惯例,我们在视图持有者的名称后缀为ViewHolder。其次,请注意EmployeeViewHolder需要实现抽象的RecyclerView.ViewHolder类。这是必需的,以便我们的适配器的通用类型可以是我们的视图持有者。最后,我们懒惰地保留对我们感兴趣的视图的引用。当第一次调用bindData(EmployeeUiModel)时,我们将在布局中找到这些视图并保留对它们的引用。

接下来,我们引入了一个bindData(EmployeeUiModel)函数。这个函数将被我们的适配器调用,将数据绑定到视图持有者持有的视图上。最后但最重要的一点是,我们始终确保为任何可能的输入设置所有修改视图的状态。

设置了我们的视图持有者后,我们可以继续实现我们的适配器。我们将首先实现最少所需的函数,再加上一个设置数据的函数。我们的适配器将看起来像这样:

class EmployeesAdapter(
    private val layoutInflater: LayoutInflater,
    private val imageLoader: ImageLoader
) : RecyclerView.Adapter<EmployeeViewHolder>() {
    private val employeesData = mutableListOf<EmployeeUiModel>()
    fun setData(employeesData: List<EmployeeUiModel>) {
        this.employeesData.clear()
        this.employeesData.addAll(employeesData)
        notifyDataSetChanged()
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int):       EmployeeViewHolder {
        val view = layoutInflater.inflate(R.layout.item_employee,           parent, false)
        return EmployeeViewHolder(view, imageLoader)
    }
    override fun getItemCount() = employeesData.size
    override fun onBindViewHolder(holder: EmployeeViewHolder,       position:Int) {
        holder.bindData(employeesData[position])
    }
}

让我们来看看这个实现。首先,我们通过构造函数向适配器注入我们的依赖项。这将使测试我们的适配器变得更容易,但也将允许我们轻松地更改一些其行为(例如,替换图像加载库)。实际上,在这种情况下,我们根本不需要更改适配器。

然后,我们定义一个私有的可变的EmployeeUiModel列表,用于存储适配器当前提供给RecyclerView的数据。我们还引入了一个设置该列表的方法。请注意,我们保留一个本地列表并设置其内容,而不是直接允许employeesData被设置。这主要是因为 Kotlin 和 Java 一样,通过引用传递变量。通过引用传递变量意味着对适配器传入的列表内容的更改将改变适配器持有的列表。因此,例如,如果在适配器外部删除了一个项目,适配器也会将该项目删除。这成为一个问题,因为适配器不会意识到这种变化,因此无法通知RecyclerView。列表从适配器外部修改的其他风险,但涵盖它们超出了本书的范围。

将数据修改封装在一个函数中的另一个好处是,我们避免了忘记通知RecyclerView数据集已更改的风险,我们通过调用notifyDataSetChanged()来实现这一点。

我们继续实现适配器的onCreateViewHolder(ViewGroup, Int)函数。当RecyclerView需要一个新的ViewHolder来在屏幕上呈现数据时,将调用此函数。它为我们提供了一个容器ViewGroup和一个视图类型(我们将在本章后面讨论视图类型)。然后,该函数期望我们返回一个使用视图(在我们的情况下是一个膨胀的视图)初始化的视图持有者。因此,我们膨胀我们之前设计的视图,并将其传递给一个新的EmployeeViewHolder实例。请注意,膨胀函数的最后一个参数是false。这确保我们不将新膨胀的视图附加到父视图上。附加和分离视图将由布局管理器管理。将其设置为true或省略将导致IllegalStateException被抛出。最后,我们返回新创建的EmployeeViewHolder

要实现getItemCount(),我们只需返回我们的employeesData列表的大小。

最后,我们实现了onBindViewHolder(EmployeeViewHolder, Int)。这是通过将存储在catsData中的EmployeeUiModel在给定位置传递给我们的视图持有者的bindData(EmployeeUiModel)函数来完成的。我们的适配器现在已经准备好了。

如果我们尝试在这一点上将我们的适配器插入我们的RecyclerView并运行我们的应用程序,我们仍然看不到任何内容。这是因为我们仍然缺少两个小步骤:向我们的适配器设置数据和为我们的RecyclerView分配布局管理器。完整的工作代码将如下所示:

class MainActivity : AppCompatActivity() {
    private val employeesAdapter by lazy { 
        EmployeesAdapter(layoutInflater, GlideImageLoader(this)) }
    private val recyclerView: RecyclerView by lazy
        { findViewById(R.id.main_recycler_view) }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        recyclerView.adapter = employeesAdapter
        recyclerView.layoutManager =
            LinearLayoutManager(this, LinearLayoutManager.VERTICAL, 
              false)
        employeesAdapter.setData(
            listOf(
                EmployeeUiModel(
                    "Robert",
                    "Rose quickly through the organization",
                    EmployeeRole.Management,
                    Gender.Male,
                    "https://images.pexels.com/photos/220453                       /pexels-photo-220453.jpeg?auto                         =compress&cs=tinysrgb&h=650&w=940"
                ),
                EmployeeUiModel(
                    "Wilma",
                    "A talented developer",
                    EmployeeRole.Technology,
                    Gender.Female,
                    "https://images.pexels.com/photos/3189024                       /pexels-photo-3189024.jpeg?auto=compress&cs                         =tinysrgb&h=650&w=940"
                ),
                EmployeeUiModel(
                    "Curious George",
                    "Excellent at retention",
                    EmployeeRole.HumanResources,
                    Gender.Unknown,
                    "https://images.pexels.com/photos/771742                       /pexels-photo-771742.jpeg?auto                         =compress&cs=tinysrgb&h=750&w=1260"
                )
            )
        )
    }
}

现在运行我们的应用程序,我们会看到我们的员工列表。

请注意,我们对员工列表进行了硬编码。在生产应用程序中,应遵循ViewModel。还要注意,我们保留了对employeesAdapter的引用。这样我们可以在以后确实将数据设置为不同的值。一些实现依赖于从RecyclerView本身读取适配器——这可能导致不必要的强制转换操作和适配器尚未分配给RecyclerView的意外状态,因此这通常不是一种推荐的方法。

最后,请注意,我们选择使用LinearLayoutManager,为其提供活动上下文、VERTICAL方向标志和false来告诉它我们不希望列表中的项目顺序被颠倒。

练习 6.02:填充您的 RecyclerView

RecyclerView如果没有任何内容就不太有趣。现在是时候通过向其中添加您的秘密猫代理来填充RecyclerView了。

在你开始之前,让我们快速回顾一下:在上一个练习中,我们介绍了一个空列表,用于保存用户可以使用的秘密猫代理的列表。在这个练习中,您将填充该列表,以向用户展示机构中可用的秘密猫代理:

  1. 为了保持文件结构的整洁,我们将首先创建一个模型包。右键单击我们应用程序的包名称,然后选择New | Package图 6.5:创建新的包

图 6.5:创建新的包

  1. 将新包命名为model。单击OK以创建该包。

  2. 要创建我们的第一个模型数据类,请右键单击新创建的模型包,然后选择New | Kotlin File/Class

  3. name下,填写CatUiModel。将kind保持为File,然后单击OK。这将是包含我们对每个猫代理的数据的类。

  4. 将以下内容添加到新创建的CatUiModel.kt文件中,以定义具有所有相关属性的数据类的猫代理:

data class CatUiModel(
    val gender: Gender,
    val breed: CatBreed,
    val name: String,
    val biography: String,
    val imageUrl: String
)

除了他们的姓名和照片之外,我们还想知道每个猫代理的性别、品种和传记。这将帮助我们为任务选择合适的代理。

  1. 再次右键单击模型包,然后转到New | Kotlin File/Class

  2. 这次,将新文件命名为CatBreed,并将kind设置为Enum类。这个类将保存我们不同的猫品种。

  3. 使用一些初始值更新您新创建的枚举,如下所示:

enum class CatBreed {
    AmericanCurl,
    BalineseJavanese,
    ExoticShorthair
}
  1. 重复步骤 67,只是这一次将文件命名为Gender。这将保存猫代理的性别的接受值。

  2. 像这样更新Gender枚举:

enum class Gender {
    Female,
    Male,
    Unknown
}
  1. 现在,通过右键单击layout,然后选择New | Layout resource file来定义包含有关每个猫代理数据的视图布局资源文件:图 6.6:创建新的布局资源文件

图 6.6:创建新的布局资源文件

  1. 将您的资源命名为item_cat。将所有其他字段保持不变,然后单击OK

  2. 更新新创建的item_cat.xml文件的内容。(以下代码块已经被截断以节省空间。使用下面的链接查看您需要添加的完整代码。)

item_cat.xml
10    <ImageView
11        android:id="@+id/item_cat_photo"
12        android:layout_width="60dp"
13        android:layout_height="60dp"
14        android:contentDescription="@string/item_cat_photo"
15        app:layout_constraintStart_toStartOf="parent"
16        app:layout_constraintTop_toTopOf="parent"
17        tools:background="@color/colorPrimary" />
18
19    <TextView
20        android:id="@+id/item_cat_name"
21        android:layout_width="wrap_content"
22        android:layout_height="wrap_content"
23        android:layout_marginStart="16dp"
24        android:layout_marginLeft="16dp"
25        android:textStyle="bold"
26        app:layout_constraintStart_toEndOf="@+id/item_cat_photo"
27        app:layout_constraintTop_toTopOf="parent"
28        tools:text="Oliver" />
The complete code for this step can be found at http://packt.live/3sopUjo.

这将创建一个布局,其中包含用于列表中使用的名称、品种和传记的图像和文本字段。

  1. 您会注意到第 14 行被标记为红色。这是因为您还没有在res/values文件夹下的strings.xml中声明item_cat_photo。现在通过将文本光标放在item_cat_photo上,然后按Alt + Enter(Mac 上为Option + Enter),然后选择Create string value resource 'item_cat_photo'来进行声明:图 6.7:尚未定义的字符串资源

图 6.7:尚未定义的字符串资源

  1. Resource value下,填写Photo。按下OK

  2. 你需要一个ImageLoader.kt的副本,它在第五章 Essential Libraries: Retrofit, Moshi, and Glide中介绍,所以右键单击你的应用程序的包名称,导航到New | Kotlin File/Class,然后将名称设置为ImageLoaderkind设置为Interface,然后点击OK

  3. 第五章 Essential Libraries: Retrofit, Moshi, and Glide类似,你只需要在这里添加一个函数:

interface ImageLoader {
    fun loadImage(imageUrl: String, imageView: ImageView)
}

确保导入ImageView

  1. 再次右键单击你的应用程序的包名称,然后选择New | Kotlin File/Class

  2. 将新文件命名为CatViewHolder。点击OK

  3. 要实现CatViewHolder,它将把猫特工数据绑定到你的视图,用以下内容替换CatViewHolder.kt文件的内容:

private val FEMALE_SYMBOL by lazy {
    HtmlCompat.fromHtml("&#9793;", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private val MALE_SYMBOL by lazy {
    HtmlCompat.fromHtml("&#9794;", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private const val UNKNOWN_SYMBOL = "?"
class CatViewHolder(
    containerView: View,
    private val imageLoader: ImageLoader
) : ViewHolder(containerView) {
    private val catBiographyView: TextView
        by lazy { containerView.findViewById(R.id.item_cat_biography) }
    private val catBreedView: TextView
        by lazy { containerView.findViewById(R.id.item_cat_breed) }
    private val catGenderView: TextView
        by lazy { containerView.findViewById(R.id.item_cat_gender) } 
    private val catNameView: TextView
        by lazy { containerView.findViewById(R.id.item_cat_name) } 
    private val catPhotoView: ImageView
        by lazy { containerView.findViewById(R.id.item_cat_photo) }
    fun bindData(catData: CatUiModel) {
        imageLoader.loadImage(catData.imageUrl, catPhotoView)
        catNameView.text = catData.name
        catBreedView.text = when (catData.breed) {
            CatBreed.AmericanCurl -> "American Curl"
            CatBreed.BalineseJavanese -> "Balinese-Javanese"
            CatBreed.ExoticShorthair -> "Exotic Shorthair"
        }
        catBiographyView.text = catData.biography
        catGenderView.text = when (catData.gender) {
            Gender.Female -> FEMALE_SYMBOL
            Gender.Male -> MALE_SYMBOL
            else -> UNKNOWN_SYMBOL
        }
    }
}
  1. 在我们的应用程序包名称下,创建一个名为CatsAdapter的新的 Kotlin 文件。

  2. 要实现CatsAdapter,它负责存储RecyclerView的数据,以及创建视图持有者的实例并使用它们将数据绑定到视图,用以下内容替换CatsAdapter.kt文件的内容:

package com.example.myrecyclerviewapp
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.myrecyclerviewapp.model.CatUiModel
class CatsAdapter(
    private val layoutInflater: LayoutInflater,
    private val imageLoader: ImageLoader
) : RecyclerView.Adapter<CatViewHolder>() {
    private val catsData = mutableListOf<CatUiModel>()
    fun setData(catsData: List<CatUiModel>) {
        this.catsData.clear()
        this.catsData.addAll(catsData)
        notifyDataSetChanged()
    }
    override fun onCreateViewHolder(parent: ViewGroup, 
      viewType: Int): CatViewHolder {
        val view = layoutInflater.inflate(R.layout.item_cat, 
      parent, false)
        return CatViewHolder(view, imageLoader)
    }
    override fun getItemCount() = catsData.size
    override fun onBindViewHolder(holder: CatViewHolder, 
      position: Int) {
        holder.bindData(catsData[position])
    }
}
  1. 在这一点上,你需要在你的项目中包含 Glide。首先,在你的应用程序的gradle.build文件的dependencies块中添加以下代码行:
implementation 'com.github.bumptech.glide:glide:4.11.0'
  1. 在你的应用程序包路径中创建一个GlideImageLoader类,包含以下内容:
package com.example.myrecyclerviewapp
import android.content.Context
import android.widget.ImageView
import com.bumptech.glide.Glide
class GlideImageLoader(private val context: Context) : ImageLoader {
    override fun loadImage(imageUrl: String, imageView: ImageView) {
        Glide.with(context)
            .load(imageUrl)
            .centerCrop()
            .into(imageView)
    }
}

这是一个简单的实现,假设加载的图像应始终是中心裁剪的。

  1. 更新你的MainActivity文件:
class MainActivity : AppCompatActivity() {
    private val recyclerView: RecyclerView
        by lazy { findViewById(R.id.recycler_view) }
    private val catsAdapter by lazy { CatsAdapter(layoutInflater,       GlideImageLoader(this)) }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        recyclerView.adapter = catsAdapter
        recyclerView.layoutManager = LinearLayoutManager(this, 
          LinearLayoutManager.VERTICAL, false)
        catsAdapter.setData(
            listOf(
                CatUiModel(
                    Gender.Male,
                    CatBreed.BalineseJavanese,
                    "Fred",
                    "Silent and deadly",
                    "https://cdn2.thecatapi.com/images/DBmIBhhyv.jpg"
                ),
                CatUiModel(
                    Gender.Female,
                    CatBreed.ExoticShorthair,
                    "Wilma",
                    "Cuddly assassin",
                    "https://cdn2.thecatapi.com/images/KJF8fB_20.jpg"
                ),
                CatUiModel(
                    Gender.Unknown,
                    CatBreed.AmericanCurl,
                    "Curious George",
                    "Award winning investigator",
                    "https://cdn2.thecatapi.com/images/vJB8rwfdX.jpg"
                )
            )
        )
    }
}

这将定义你的适配器,将它附加到RecyclerView,并用一些硬编码的数据填充它。

  1. 在你的AndroidManifest.xml文件中,在应用程序标签之前的manifest标签中添加以下内容:
<uses-permission android:name="android.permission.INTERNET" />

这将允许你的应用程序从互联网上下载图像。

  1. 为了一些最后的修饰,比如给我们的标题视图一个合适的名称和文本,像这样更新你的activity_main.xml文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/main_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/main_title"
        android:textSize="24sp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@+id/main_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 还要更新你的strings.xml文件,给你的应用程序一个合适的名称和标题:
<resources>
    <string name="app_name">SCA - Secret Cat Agents</string>
    <string name="item_cat_photo">Cat photo</string>
    <string name="main_title">Our Agents</string>
</resources>
  1. 运行你的应用程序。它应该是这样的:

图 6.8:具有硬编码秘密猫特工的 RecyclerView

图 6.8:具有硬编码秘密猫特工的 RecyclerView

如你所见,RecyclerView现在有内容,你的应用程序开始成形。请注意,相同的布局用于根据绑定到每个实例的数据呈现不同的项目。正如你所期望的,如果你添加了足够的项目使它们超出屏幕,滚动是有效的。接下来,我们将研究允许用户与我们的RecyclerView中的项目进行交互。

在 RecyclerView 中响应点击

如果我们想让用户从呈现的列表中选择一个项目怎么办?为了实现这一点,我们需要将点击事件传递回我们的应用程序。

实现点击交互的第一步是在ViewHolder级别捕获项目的点击。

为了保持视图持有者和适配器之间的分离,我们在视图持有者中定义了一个嵌套的OnClickListener接口。我们选择在视图持有者内定义接口,因为它们是紧密耦合的。在我们的情况下,接口只有一个功能。这个功能的目的是通知视图持有者的所有者有关点击的信息。视图持有者的所有者通常是一个 Fragment 或一个 Activity。由于我们知道视图持有者可以被重用,我们知道在构造时定义它可能会很具有挑战性,因为它会告诉我们点击了哪个项目(因为该项目会随着重用而随时间变化)。我们通过在点击时将当前呈现的项目传递回视图持有者的所有者来解决这个问题。这意味着我们的接口看起来像这样:

interface OnClickListener {
    fun onClick(catData: CatUiModel)
}

我们还将把这个监听器作为参数添加到我们的ViewHolder构造函数中:

class CatViewHolder(
    containerView: View,
    private val imageLoader: ImageLoader,
    private val onClickListener: OnClickListener
) : ViewHolder(containerView) {
    .
    .
    .
}

它将被用于这样:

containerView.setOnClickListener { onClickListener.onClick(catData) }

现在,我们希望我们的适配器传递一个监听器。反过来,该监听器将负责通知适配器的所有者点击事件。这意味着我们的适配器也需要一个嵌套的监听器接口,与我们在视图持有者中实现的接口非常相似。

虽然这似乎是可以通过重用相同的监听器来避免的重复,但这并不是一个好主意,因为它会导致视图持有者和适配器之间通过监听器的紧密耦合。当您希望适配器也通过监听器报告其他事件时会发生什么?即使视图持有者实际上没有实现这些事件,您也必须处理来自视图持有者的这些事件。

最后,为了处理点击事件并显示对话框,我们在活动中定义一个监听器并将其传递给适配器。我们设置该监听器在点击时显示对话框。在 MVVM 实现中,您现在将通知ViewModel点击。ViewModel然后更新其状态,告诉视图(我们的活动)应该显示对话框。

练习 6.03:响应点击

您的应用程序已经向用户显示了一组秘密猫代理。现在是时候允许用户通过点击其视图选择秘密猫代理。点击事件是从视图持有者委托给适配器再委托给活动的,如图 6.9所示:

图 6.9:点击事件的流程

图 6.9:点击事件的流程

以下是您需要遵循的步骤来完成此练习:

  1. 打开您的CatViewHolder.kt文件。在最终的闭合大括号之前添加一个嵌套接口:
    interface OnClickListener {
        fun onClick(catData: CatUiModel)
    }

这将是监听器必须实现的接口,以便在单个猫项目上注册点击事件。

  1. 更新CatViewHolder构造函数以接受OnClickListener并使 containerView 可访问:
class CatViewHolder(
    CatViewHolder constructor, you also register for clicks on item views.
  1. 在您的bindData(CatUiModel)函数顶部,添加以下内容以拦截点击并将其报告给提供的监听器:
containerView.setOnClickListener { onClickListener.onClick(catData) }
  1. 现在,打开您的CatsAdapter.kt文件。在最终的闭合大括号之前添加此嵌套接口:
interface OnClickListener { 
    fun onItemClick(catData: CatUiModel) 
}

这定义了监听器必须实现的接口,以接收来自适配器的项目点击事件。

  1. 更新CatsAdapter构造函数,以接受刚刚定义的OnClickListener适配器的调用:
class CatsAdapter(
    private val layoutInflater: LayoutInflater,
    private val imageLoader: ImageLoader,
    private val onClickListener: OnClickListener
) : RecyclerView.Adapter<CatViewHolder>() {
  1. onCreateViewHolder(ViewGroup, Int)中,按照以下方式更新视图持有者的创建:
        return CatViewHolder(view, imageLoader, ViewHolder click events to the adapter listener. 
  1. 最后,打开您的MainActivity.kt文件。按照以下方式更新您的catsAdapter构造,以通过显示对话框处理点击事件来为适配器提供所需的依赖项:
    private val catsAdapter by lazy {
        CatsAdapter(
            layoutInflater,
            GlideImageLoader(this),
            object : CatsAdapter.OnClickListener {
            override fun onClick(catData: CatUiModel) =               onClickListener.onItemClick(catData)
            }
        )
    }
  1. 在最终的闭合大括号之前添加以下函数:
    private fun showSelectionDialog(catData: CatUiModel) {
        AlertDialog.Builder(this)
            .setTitle("Agent Selected")
            .setMessage("You have selected agent ${catData.name}")
            .setPositiveButton("OK") { _, _ -> }
            .show()
    }

此函数将显示一个对话框,其中包含传递的猫数据的名称。

  1. 确保导入正确版本的AlertDialog,即androidx.appcompat.app.AlertDialog,而不是android.app.AlertDialog。这通常是支持向后兼容的更好选择。

  2. 运行您的应用程序。现在点击其中一只猫应该会显示一个对话框:

图 6.10:显示已选择代理的对话框

图 6.10:显示已选择代理的对话框

尝试点击不同的项目并注意呈现的不同消息。您现在知道如何响应用户点击RecyclerView中的项目。接下来,我们将看看如何支持列表中的不同项目类型。

支持不同的项目类型

在前面的部分中,我们学习了如何处理单一类型的项目列表(在我们的情况下,所有项目都是CatUiModel)。如果您想要支持多种类型的项目会发生什么?一个很好的例子是在我们的列表中有组标题。

假设我们不是获取一组猫的列表,而是获取一个包含快乐猫和悲伤猫的列表。每组猫之前都有相应组的标题。我们的列表现在不再包含CatUiModel实例,而是包含ListItem实例。ListItem可能如下所示:

sealed class ListItem {
    data class Group(val name: String) : ListItem()
    data class Cat(val data: CatUiModel) : ListItem()
}

我们的项目列表可能如下所示:

listOf(
    ListItem.Group("Happy Cats"),
    ListItem.Cat(
        CatUiModel(
            Gender.Female,
            CatBreed.AmericanCurl,
            "Kitty",
            "Kitty is warm and fuzzy.",
            "https://cdn2.thecatapi.com/images/..."
        )
    ),
    ListItem.Cat(
        CatUiModel(
            Gender.Male,
            CatBreed.ExoticShorthair,
            "Joey",
            "Loves to cuddle.",
            "https://cdn2.thecatapi.com/images/..."
        )
    ),
    ListItem.Group("Sad Cats"),
    ListItem.Cat(
        CatUiModel(
            Gender.Unknown,
            CatBreed.AmericanCurl,
            "Ginger",
            "Just not in the mood.",
            "https://cdn2.thecatapi.com/images/..."
        )
    ),
    ListItem.Cat(
        CatUiModel(
            Gender.Female,
            CatBreed.ExoticShorthair,
            "Butters",
            "Sleeps most of the time.",
            "https://cdn2.thecatapi.com/images/..."
        )
    )
)

在这种情况下,只有一个布局类型是不够的。幸运的是,正如您可能已经在我们早期的练习中注意到的那样,RecyclerView.Adapter为我们提供了处理这种情况的机制(记得onCreateViewHolder(ViewGroup, Int)函数中使用的viewType参数吗?)。

为了帮助适配器确定每个项目需要哪种视图类型,我们重写了它的getItemViewType(Int)函数。一个对我们来说可以解决问题的实现示例如下:

override fun getItemViewType(position: Int) = when (listData[position]) {
    is ListItem.Group -> VIEW_TYPE_GROUP
    is ListItem.Cat -> VIEW_TYPE_CAT
}

在这里,VIEW_TYPE_GROUPVIEW_TYPE_CAT的定义如下:

private const val VIEW_TYPE_GROUP = 0
private const val VIEW_TYPE_CAT = 1

这个实现将给定位置的数据类型映射到表示我们已知布局类型之一的常量值。在我们的情况下,我们知道标题和猫,因此有两种类型。我们使用的值可以是任何整数值,因为它们会原样传递给我们在onCreateViewHolder(ViewGroup, Int)函数中。我们只需要确保不重复相同的值超过一次。

现在我们已经告诉适配器需要哪些视图类型以及在哪里需要,我们还需要告诉它对于每种视图类型使用哪种视图持有者。这是通过实现onCreateViewHolder(ViewGroup, Int)函数来完成的:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =   when (viewType) {
    VIEW_TYPE_GROUP -> {
        val view = layoutInflater.inflate(R.layout.item_title, 
          parent, false)
        GroupViewHolder(view)
    }
    VIEW_TYPE_CAT -> {
        val view = layoutInflater.inflate(R.layout.item_cat, parent, false)
        CatViewHolder(view, imageLoader, object : 
          CatViewHolder.OnClickListener {
            override fun onClick(catData: CatUiModel) =              onClickListener.onItemClick(catData)
        })
    }
    else -> throw IllegalArgumentException("Unknown view type requested:       $viewType")
}

与此函数的早期实现不同,我们现在考虑viewType的值。

正如我们现在知道的,viewType预计是我们从getItemViewType(Int)返回的值之一。

对于这些值(VIEW_TYPE_GROUPVIEW_TYPE_CAT),我们会填充相应的布局并构建一个合适的视图持有者。请注意,我们永远不希望收到任何其他值,因此如果遇到这样的值,我们会抛出异常。根据您的需求,您也可以返回一个显示错误或根本不显示任何内容的默认视图持有者。记录这些值也可能是一个好主意,以便您调查为什么收到它们并决定如何处理它们。

对于我们的标题布局,一个简单的TextView可能就足够了。item_cat.xml布局可以保持不变。

现在到了视图持有者。我们需要为标题创建一个视图持有者。这意味着我们现在将有两个不同的视图持有者。然而,我们的适配器只支持一种适配器类型。最简单的解决方案是定义一个通用的视图持有者,GroupViewHolderCatViewHolder都将扩展它。让我们称之为ListItemViewHolderListItemViewHolder类可以是抽象的,因为我们永远不打算直接使用它。为了方便绑定数据,我们还可以在我们的抽象视图持有者中引入一个函数——abstract fun bindData(listItem: ListItemUiModel)。我们的具体实现可以期望接收特定类型,因此我们可以分别向GroupViewHolderCatViewHolder添加以下行:

require(listItem is ListItemUiModel.Cat) {
    "Expected ListItemUiModel.Cat"
}

我们还可以添加以下内容:

require(listItem is ListItemUiModel.Cat) { "Expected ListItemUiModel.Cat" }

具体来说,在CatViewHolder中,由于一些 Kotlin 魔法,我们可以使用define val catData = listItem.data,并且保持类的其余部分不变。

做出这些更改后,我们现在可以期望看到“快乐的猫”和“悲伤的猫”组标题,每个标题后面跟着相关的猫。

练习 6.04:向 RecyclerView 添加标题

现在我们希望能够在两个组中呈现我们的秘密猫特工:可部署到现场的活跃特工和目前无法部署的沉睡特工。我们将通过在活跃特工上方添加一个标题,并在沉睡特工上方添加另一个标题来实现这一点:

  1. 创建一个名为ListItemUiModel的新的 Kotlin 文件。

  2. ListItemUiModel.kt文件中添加以下内容,定义我们的两种数据类型——标题和猫:

sealed class ListItemUiModel {
    data class Title(val title: String) : ListItemUiModel()
    data class Cat(val data: CatUiModel) : ListItemUiModel()
}
  1. com.example.myrecyclerviewapp中创建一个名为ListItemViewHolder的新的 Kotlin 文件。这将是我们的基本视图持有者。

  2. com.example.myrecyclerviewapp.model下,用以下内容填充ListItemViewHolder.kt文件。

abstract class ListItemViewHolder(
    containerView: View
) : RecyclerView.ViewHolder(containerView) {
    abstract fun bindData(listItem: ListItemUiModel)
}
  1. 打开CatViewHolder.kt文件。

  2. 使CatViewHolder扩展ListItemViewHolder

class CatViewHolder(
    ...
) : ListItemViewHolder(containerView) {
  1. ListItemUiModel替换bindData(CatUiModel)参数,并使其覆盖ListItemViewHolder的抽象函数:
    override fun bindData(listItem: ListItemUiModel)
  1. bindData(ListItemUiModel)函数的顶部添加以下两行,以强制将ListItemUiModel转换为ListItemUiModel.Cat并从中获取猫数据:
require(listItem is ListItemUiModel.Cat) { 
  "Expected ListItemUiModel.Cat" } 
val catData = listItem.data

保持文件的其余部分不变。

  1. 创建一个新的布局文件。将布局命名为item_title

  2. 用以下内容替换新创建的item_title.xml文件的默认内容:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/item_title_title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="8dp"
    android:textSize="16sp"
    android:textStyle="bold"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:text="Sleeper Agents" />

这个新的布局只包含一个带有 16sp 大小的粗体字体的TextView,将承载我们的标题:

图 6.11:item_title.xml 布局的预览

图 6.11:item_title.xml 布局的预览

  1. com.example.myrecyclerviewapp下的同名文件中实现TitleViewHolder
class TitleViewHolder(
    containerView: View
) : ListItemViewHolder(containerView) {
    private val titleView: TextView
        by lazy { containerView           .findViewById(R.id.item_title_title) }
    override fun bindData(listItem: ListItemUiModel) {
        require(listItem is ListItemUiModel.Title) {
            "Expected ListItemUiModel.Title"
        }
        titleView.text = listItem.title
    }
}

这与CatViewHolder非常相似,但由于我们只在TextView上设置文本,因此它也简单得多。

  1. 现在,为了使事情更整洁,选择CatViewHolderListItemViewHolderTitleViewHolder

  2. 将所有文件移动到新的命名空间:右键单击其中一个文件,然后选择重构 | 移动(或按F6)。

  3. /viewholder附加到预填的到目录字段。保持搜索引用更新包指令(Kotlin 文件)选中,不选中在编辑器中打开移动的文件。单击确定

  4. 打开CatsAdapter.kt文件。

  5. 现在,将CatsAdapter重命名为ListItemsAdapter。重命名变量,函数和类的命名以反映其实际用途是很重要的,以避免将来的混淆。在代码窗口中右键单击CatsAdapter类名,然后选择重构 | 重命名(或Shift + F6)。

  6. CatsAdapter被突出显示时,键入ListItemsAdapter并按Enter

  7. 将适配器通用类型更改为ListItemViewHolder

class ListItemsAdapter(
    ...
) : RecyclerView.Adapter<ListItemViewHolder>() {
  1. 更新listDatasetData(List<CatUiModel>)以处理ListItemUiModel
    private val listData = mutableListOf<ListItemUiModel>()
    fun setData(listData: List<ListItemUiModel>) {
        this.listData.clear()
        this.listData.addAll(listData)
        notifyDataSetChanged()
    }
  1. 更新onBindViewHolder(CatViewHolder)以符合适配器合同更改:
    override fun onBindViewHolder(holder: ListItemViewHolder,       position: Int) {
        holder.bindData(listData[position])
    }
  1. 在文件顶部,在导入之后和类定义之前,添加视图类型常量:
private const val VIEW_TYPE_TITLE = 0
private const val VIEW_TYPE_CAT = 1
  1. 实现getItemViewType(Int)如下:
    override fun getItemViewType(position: Int) =       when (listData[position]) {
        is ListItemUiModel.Title -> VIEW_TYPE_TITLE
        is ListItemUiModel.Cat -> VIEW_TYPE_CAT
    }
  1. 最后,更改您的onCreateViewHolder(ViewGroup, Int)实现如下:
    override fun onCreateViewHolder(parent: ViewGroup,       viewType: Int) = when (viewType) {
        VIEW_TYPE_TITLE -> {
            val view = layoutInflater.inflate(R.layout.item_title,               parent, false)
            TitleViewHolder(view)
        }
        VIEW_TYPE_CAT -> {
            val view = layoutInflater.inflate(R.layout.item_cat,               parent, false)
            CatViewHolder(
                view,
                imageLoader,
                object : CatViewHolder.OnClickListener {
                    override fun onClick(catData: CatUiModel) =
                        onClickListener.onItemClick(catData)
                })
        }
        else -> throw IllegalArgumentException("Unknown view type           requested: $viewType")
    }
  1. 更新MainActivity以使用适当的数据填充适配器,替换先前的catsAdapter.setData(List<CatUiModel>)调用。(请注意,以下代码已经被截断以节省空间。请参考下面的链接以访问您需要添加的完整代码。)
MainActivity.kt
32      listItemsAdapter.setData(
33          listOf(
34              ListItemUiModel.Title("Sleeper Agents"),
35              ListItemUiModel.Cat(
36                  CatUiModel(
37                      Gender.Male,
38                      CatBreed.ExoticShorthair,
39                      "Garvey",
40                      "Garvey is as a lazy, fat, and cynical orange cat.",
41                      "https://cdn2.thecatapi.com/images/FZpeiLi4n.jpg"
42                  )
43              ),
44              ListItemUiModel.Cat(
45                  CatUiModel(
46                      Gender.Unknown,
47                      CatBreed.AmericanCurl,
48                      "Curious George",
49                      "Award winning investigator",
50                      "https://cdn2.thecatapi.com/images/vJB8rwfdX.jpg"
51                  )
52              ),
53              ListItemUiModel.Title("Active Agents"),
The complete code for this step can be found at http://packt.live/3icCrSt.
  1. 由于catsAdapter不再持有CatsAdapter而是ListItemsAdapter,因此相应地进行重命名。将其命名为listItemsAdapter

  2. 运行应用程序。您应该看到类似以下的内容:

图 6.12:带有休眠代理/活动代理标题视图的 RecyclerView

图 6.12:带有休眠代理/活动代理标题视图的 RecyclerView

如您所见,我们现在在两个代理组上方有标题。与Our Agents标题不同,这些标题将随内容滚动。接下来,我们将学习如何滑动项目以将其从RecyclerView中移除。

滑动以删除项目

在之前的部分中,我们学习了如何呈现不同的视图类型。但是,直到现在,我们一直在使用固定的项目列表。如果您想要能够从列表中删除项目怎么办?有一些常见的机制可以实现这一点-固定的删除按钮,滑动删除,长按选择然后点击删除按钮等。在本节中,我们将专注于“滑动删除”方法。

让我们首先向我们的适配器添加删除功能。要告诉适配器删除一个项目,我们需要指示要删除的项目。实现这一点的最简单方法是提供项目的位置。在我们的实现中,这将直接对应于listData列表中项目的位置。因此,我们的removeItem(Int)函数应该如下所示:

fun removeItem(position: Int) {
    listData.removeAt(position)
    notifyItemRemoved(position)
}

注意

就像设置数据时一样,我们需要通知RecyclerView数据集已更改-在这种情况下,已删除一个项目。

接下来,我们需要定义滑动手势检测。这是通过利用ItemTouchHelper来完成的。现在,ItemTouchHelper通过回调向我们报告某些触摸事件,即拖动和滑动。我们通过实现ItemTouchHelper.Callback来处理这些回调。此外,RecyclerView提供了ItemTouchHelper.SimpleCallback,它消除了大量样板代码的编写。

我们希望响应滑动手势,但忽略移动手势。更具体地说,我们希望响应向右滑动。移动用于重新排序项目,这超出了本章的范围。因此,我们的SwipToDeleteCallback的实现将如下所示:

inner class SwipeToDeleteCallback :
    ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) {
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean = false
    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ) = if (viewHolder is CatViewHolder) {
        makeMovementFlags(
            ItemTouchHelper.ACTION_STATE_IDLE,
            ItemTouchHelper.RIGHT
        )or makeMovementFlags(
            ItemTouchHelper.ACTION_STATE_SWIPE,
            ItemTouchHelper.RIGHT
        )
    } else {
        0
    }
    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, 
      direction: Int) {
        val position = viewHolder.adapterPosition
        removeItem(position)
    }
}

由于我们的实现与我们的适配器及其视图类型紧密耦合,因此我们可以将其舒适地定义为内部类。我们获得的好处是能够直接在适配器上调用方法。

正如您所看到的,我们从onMove(RecyclerView, ViewHolder, ViewHolder)函数中返回false。这意味着我们忽略移动事件。

接下来,我们需要告诉ItemTouchHelper哪些项目可以被滑动。我们通过重写getMovementFlags(RecyclerView, ViewHolder)来实现这一点。当用户即将开始拖动或滑动手势时,将调用此函数。ItemTouchHelper希望我们返回所提供的视图持有者的有效手势。我们检查ViewHolder类,如果是CatViewHolder,我们希望允许滑动,否则不允许。我们使用makeMovementFlags(Int, Int),这是一个帮助函数,用于以ItemTouchHelper可以解析的方式构造标志。请注意,我们为ACTION_STATE_IDLE定义了规则,这是手势的起始状态,因此允许手势从左侧或右侧开始。然后我们将其与ACTION_STATE_SWIPE标志结合起来(使用or),允许进行中的手势向左或向右滑动。返回0意味着对于所提供的视图持有者,既不会发生滑动也不会移动。

一旦滑动操作完成,将调用onSwiped(ViewHolder, Int)。然后,我们通过调用adapterPosition从传入的视图持有者中获取位置。现在,adapterPosition很重要,因为这是获取视图持有者呈现的项目的真实位置的唯一可靠方法。

有了正确的位置,我们可以通过在适配器上调用removeItem(Int)来移除项目。

为了公开我们新创建的SwipeToDeleteCallback实现,我们在适配器中定义一个只读变量,即swipeToDeleteCallback,并将其设置为SwipeToDeleteCallback的新实例。

最后,为了将我们的callback机制插入RecyclerView,我们需要构造一个新的ItemTouchHelper并将其附加到我们的RecyclerView上。我们应该在设置我们的RecyclerView时执行此操作,我们在主活动的onCreate(Bundle?)函数中执行此操作。这是创建和附加的方式:

val itemTouchHelper = ItemTouchHelper(listItemsAdapter.swipeToDeleteCallback)
itemTouchHelper.attachToRecyclerView(recyclerView)

现在我们可以滑动项目以将其从列表中移除。请注意,我们的标题无法被滑动,这正是我们想要的。

您可能已经注意到一个小故障:在动画向上播放时,最后一个项目被切断了。这是因为RecyclerView在动画开始之前会缩小以适应新的(较小)项目数量。快速修复这个问题的方法是通过将其底部限制在其父级的底部来固定我们的RecyclerView的高度。

练习 6.05:添加滑动删除功能

我们之前向我们的应用程序添加了RecyclerView,然后向其中添加了不同类型的项目。现在,我们将允许用户通过向左或向右滑动来删除一些项目(我们希望让用户删除秘密猫特工,但不是标题):

  1. 要向我们的适配器添加项目移除功能,请在setData(List<ListItemUiModel>)函数之后添加以下函数到ListItemsAdapter中:
    fun removeItem(position: Int) {
        listData.removeAt(position)
        notifyItemRemoved(position)
    }
  1. 接下来,在您的ListItemsAdapter类的闭合大括号之前,添加以下callback实现,以处理用户向左或向右滑动猫特工的操作:
    inner class SwipeToDeleteCallback :
        ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or           ItemTouchHelper.RIGHT) {
        override fun onMove(
            recyclerView: RecyclerView,
            viewHolder: RecyclerView.ViewHolder,
            target: RecyclerView.ViewHolder
        ): Boolean = false
        override fun getMovementFlags(
            recyclerView: RecyclerView,
            viewHolder: RecyclerView.ViewHolder
        ) = if (viewHolder is CatViewHolder) {
            makeMovementFlags(
                ItemTouchHelper.ACTION_STATE_IDLE,
                ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
            ) or makeMovementFlags(
                ItemTouchHelper.ACTION_STATE_SWIPE,
                ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
            )
        } else {
            0
        }
        override fun onSwiped(viewHolder: RecyclerView.ViewHolder,           direction: Int) {
            val position = viewHolder.adapterPosition
            removeItem(position)
        }
    }

我们实现了一个ItemTouchHelper.SimpleCallback实例,传入我们感兴趣的方向——LEFTRIGHT。通过使用or布尔运算符来连接这些值。

我们已经重写了getMovementFlags函数,以确保我们只处理猫代理视图上的滑动,而不是标题。为ItemTouchHelper.ACTION_STATE_SWIPEItemTouchHelper.ACTION_STATE_IDLE分别创建标志,允许我们拦截滑动和释放事件。

一旦滑动完成(用户从屏幕上抬起手指),onSwiped将被调用,作为响应,我们将删除拖动视图持有者提供的位置处的项目。

  1. 在你的适配器顶部,暴露刚刚创建的SwipeToDeleteCallback类的一个实例:
class ListItemsAdapter(
    ...
) : RecyclerView.Adapter<ListItemViewHolder>() {
    val swipeToDeleteCallback = SwipeToDeleteCallback()
  1. 最后,通过实现ItemViewHelper并将其附加到我们的RecyclerView来将所有内容绑定在一起。在为适配器分配布局管理器之后,将以下代码添加到MainActivity文件的onCreate(Bundle?)函数中:
    recyclerView.layoutManager = ...
    val itemTouchHelper = ItemTouchHelper(listItemsAdapter       .swipeToDeleteCallback)
    itemTouchHelper.attachToRecyclerView(recyclerView)
  1. 为了解决当项目被移除时会出现的小视觉故障,通过更新activity_main.xml中的代码来缩放RecyclerView以适应屏幕。更改在RecyclerView标签中,在app:layout_constraintTop_toBottomOf属性之前:
        android:layout_height="0dp. The latter change tells our app to calculate the height of RecyclerView based on its constraints:![Figure 6.13: RecyclerView taking the full height of the layout    ](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_06_13.jpg)Figure 6.13: RecyclerView taking the full height of the layout
  1. 运行你的应用。现在你应该能够向左或向右滑动秘密猫代理,将它们从列表中移除。请注意,RecyclerView会为我们处理折叠动画:

图 6.14:一只猫被向右滑动

图 6.14:一只猫被向右滑动

请注意,即使标题是项目视图,它们也不能被滑动。您已经实现了一个用于滑动手势的回调,它区分不同的项目类型,并通过删除被滑动的项目来响应滑动。现在我们知道如何交互地移除项目。接下来,我们将学习如何添加新项目。

交互式添加项目

我们刚刚学会了如何交互地移除项目。那么添加新项目呢?让我们来看看。

与我们实现移除项目的方式类似,我们首先向适配器添加一个函数:

fun addItem(position: Int, item: ListItemUiModel) {
    listData.add(position, item)
    notifyItemInserted(position)
}

您会注意到,这个实现与我们之前实现的removeItem(Int)函数非常相似。这一次,我们还收到要添加的项目和要添加的位置。然后我们将它添加到我们的listData列表中,并通知RecyclerView我们在请求的位置添加了一个项目。

要触发对addItem(Int, ListItemUiModel)的调用,我们可以在我们的主活动布局中添加一个按钮。这个按钮可以是这样的:

<Button
    android:id="@+id/main_add_item_button"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Add A Cat"
    app:layout_constraintBottom_toBottomOf="parent" />

应用现在看起来是这样的:

图 6.15:主布局,带有一个添加猫的按钮

图 6.15:主布局,带有一个添加猫的按钮

不要忘记更新您的RecyclerView,以便其底部将受到此按钮顶部的约束。否则,按钮和RecyclerView将重叠。

在生产应用中,您可以添加关于新项目的理由。例如,您可以为用户填写不同的细节提供一个表单。为了简单起见,在我们的示例中,我们将始终添加相同的虚拟项目——一个匿名的女性秘密猫代理。

要添加项目,我们在我们的按钮上设置OnClickListener

addItemButton.setOnClickListener {
    listItemsAdapter.addItem(
        1,
        ListItemUiModel.Cat(
            CatUiModel(
                Gender.Female,
                CatBreed.BalineseJavanese,
                "Anonymous",
                "Unknown",
                "https://cdn2.thecatapi.com/images/zJkeHza2K.jpg"
            )
        )
    )
}

就是这样。我们在位置 1 添加项目,这样它就会添加在我们的第一个标题下面,也就是位置 0 的项目。在生产应用中,您可以有逻辑来确定插入项目的正确位置。它可以在相关标题下方,或者始终添加在顶部、底部,或者在正确的位置以保留一些现有的顺序。

现在我们可以运行应用程序。现在我们将有一个新的“添加猫”按钮。每次点击按钮时,一个匿名的秘密猫代理将被添加到RecyclerView中。新添加的猫可以被滑动移除,就像它们之前的硬编码猫一样。

练习 6.06:实现一个“添加猫”按钮

在实现了删除项目的机制之后,现在是时候实现添加项目的机制了:

  1. ListItemsAdapter添加一个支持添加项目的函数。将其添加到removeItem(Int)函数下面:
    fun addItem(position: Int, item: ListItemUiModel) {
        listData.add(position, item)
        notifyItemInserted(position)
    }
  1. activity_main.xml中添加一个按钮,就在RecyclerView标签后面:
    <Button
        android:id="@+id/main_add_item_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Add A Cat"
        app:layout_constraintBottom_toBottomOf="parent" />
  1. 您会注意到android:text="Add A Cat"被突出显示。如果您将鼠标悬停在上面,您会发现这是因为硬编码的字符串。点击Add单词将编辑光标放在上面。

  2. Option + Enter(iOS)或Alt + Enter(Windows)显示上下文菜单,然后再次按Enter显示“提取资源”对话框。

  3. 将资源命名为add_button_label。按下“确定”。

  4. 更改RecyclerView上的底部约束,以便按钮和RecyclerView不重叠,在RecyclerView标签内部,找到以下内容:

    app:layout_constraintBottom_toBottomOf="parent"

用以下代码行替换它:

    app:layout_constraintBottom_toTopOf="@+id/main_add_item_button"
  1. 在类的顶部添加一个引用按钮的惰性字段,就在recyclerView的定义之后:
    private val addItemButton: View
        by lazy { findViewById(R.id.main_add_item_button) }

注意addItemButton被定义为一个 View。这是因为在我们的代码中,我们不需要知道 View 的类型来为其添加点击监听器。选择更抽象的类型允许我们以后更改布局中视图的类型,而无需修改此代码。

  1. 最后,更新MainActivity以处理点击。找到以下内容的行:
        itemTouchHelper.attachToRecyclerView(recyclerView)

在此之后,添加以下内容:

        addItemButton.setOnClickListener {
          listItemsAdapter.addItem(
            1,
            ListItemUiModel.Cat(
              CatUiModel(
                Gender.Female,
                CatBreed.BalineseJavanese,
                "Anonymous",
                "Unknown",
                "https://cdn2.thecatapi.com/images/zJkeHza2K.jpg"
              )
            )
          )

这将在每次点击按钮时向RecyclerView添加一个新项目。

  1. 运行应用程序。您应该在应用程序底部看到一个新按钮:图 6.16:点击按钮添加一个匿名猫

图 6.16:点击按钮添加一个匿名猫

  1. 尝试点击几次。每次点击时,都会向您的RecyclerView添加一个新的匿名秘密猫特工。您可以像删除硬编码的猫一样滑动删除新添加的猫。

在这个练习中,您通过用户交互向RecyclerView添加了新项目。您现在知道如何在运行时更改RecyclerView的内容。了解如何在运行时更新列表很有用,因为在应用程序运行时,您向用户呈现的数据经常会发生变化,您希望向用户呈现一个新鲜、最新的状态。

活动 6.01:管理项目列表

想象一下,您想开发一个食谱管理应用程序。您的应用程序将支持甜食和咸食食谱。您的应用程序的用户可以添加新的甜食或咸食食谱,浏览已添加的食谱列表(按口味分组为甜食或咸食),点击食谱以获取有关它的信息,最后,他们可以通过滑动将食谱删除。

这个活动的目的是创建一个带有RecyclerView的应用程序,列出食谱的标题,按口味分组。RecyclerView将支持用户交互。每个食谱都将有一个标题、一个描述和一个口味。交互将包括点击和滑动。点击将向用户显示一个对话框,显示食谱的描述。滑动将从应用程序中删除已滑动的食谱。最后,通过两个EditText字段(参见第三章,屏幕和 UI)和两个按钮,用户可以分别添加新的甜食或咸食食谱,标题和描述设置为EditText字段中设置的值。

完成的步骤如下:

  1. 创建一个新的空活动应用程序。

  2. 在应用程序的build.gradle文件中添加RecyclerView支持。

  3. 在主布局中添加RecyclerView、两个EditText字段和两个按钮。您的布局应该看起来像这样:图 6.17:带有 RecyclerView、两个 EditText 字段和两个按钮的布局

图 6.17:带有 RecyclerView、两个 EditText 字段和两个按钮的布局

  1. 为口味标题和食谱添加模型,并为口味添加枚举。

  2. 添加一个口味标题的布局。

  3. 为食谱标题添加一个布局。

  4. 为口味标题和食谱标题添加视图持有者,以及一个适配器。

  5. 添加点击监听器以显示带有食谱描述的对话框。

  6. 更新MainActivity以构建新的适配器并连接按钮,用于添加新的咸味和甜味食谱。确保在添加食谱后清除表单。

  7. 添加一个滑动助手来移除项目。

最终输出如下:

图 6.18:食谱书应用

图 6.18:食谱书应用

注意

这个活动的解决方案可以在以下网址找到:http://packt.live/3sKj1cp

总结

在本章中,我们学习了如何将RecyclerView添加到我们的项目中。我们还学习了如何将其添加到我们的布局中,并如何用项目填充它。我们介绍了添加不同类型的项目,这对于标题特别有用。我们涵盖了与RecyclerView的交互:响应单个项目的点击和响应滑动手势。最后,我们学习了如何动态地向RecyclerView添加和删除项目。RecyclerView的世界非常丰富,我们只是触及了表面。进一步的探索将超出本书的范围。然而,强烈建议您自行调查,以便在应用程序中拥有旋转木马、设计分隔线和更花哨的滑动效果。您可以从这里开始您的探索:awesomeopensource.com/projects/recyclerview-adapter

在下一章中,我们将探讨代表我们的应用程序请求特殊权限,以便执行某些任务,例如访问用户的联系人列表或其麦克风。我们还将研究如何使用谷歌的地图 API 和访问用户的物理位置。

第七章:Android 权限和 Google 地图

概述

本章将为您提供如何在 Android 中请求和获取应用程序权限的知识。您将深入了解如何在应用程序中包含本地和全局交互地图,以及如何请求使用 Google Maps API 提供更丰富功能的设备功能的权限。

在本章结束时,您将能够为您的应用程序创建权限请求并处理缺失的权限。

介绍

在上一章中,我们学习了如何使用RecyclerView在列表中呈现数据。我们利用这些知识向用户展示了一个秘密猫特工列表。在本章中,我们将学习如何在地图上找到用户的位置,以及如何通过在地图上选择位置来部署猫特工。

首先,我们将研究 Android 权限系统。许多 Android 功能对我们来说并不是立即可用的。为了保护用户,这些功能被放在权限系统的后面。为了访问这些功能,我们必须请求用户允许我们这样做。一些这样的功能包括但不限于获取用户的位置,访问用户的联系人,访问他们的相机,以及建立蓝牙连接。不同的 Android 版本实施不同的权限规则。例如,当 2015 年引入 Android 6(Marshmallow)时,一些权限被认为是不安全的(您可以在安装时悄悄获得)并成为运行时权限。

接下来我们将看一下 Google Maps API。这个 API 允许我们向用户展示任何所需位置的地图,向地图添加数据,并让用户与地图进行交互。它还可以让你显示感兴趣的点,并在支持的位置呈现街景,尽管在本书中我们不会涉及这些功能。

向用户请求权限

我们的应用程序可能希望实现一些被 Google 认为是危险的功能。这通常意味着访问这些功能可能会危及用户的隐私。例如,这些权限可能允许您读取用户的消息或确定他们当前的位置。

根据特定权限和我们正在开发的目标 Android API 级别,我们可能需要向用户请求该权限。如果设备运行在 Android 6(Marshmallow,或 API 级别 23)上,并且我们应用的目标 API 是 23 或更高,几乎肯定会是这样,因为现在大多数设备都会运行更新版本的 Android,那么在安装时不会有用户通知警告用户应用程序请求的任何权限。相反,我们的应用必须在运行时要求用户授予它这些权限。

当我们请求权限时,用户会看到一个对话框,类似于以下截图所示:

图 7.1 设备位置访问权限对话框

图 7.1 设备位置访问权限对话框

注意

有关权限及其保护级别的完整列表,请参见这里:developer.android.com/reference/android/Manifest.permission

当我们打算使用某个权限时,我们必须在清单文件中包含该权限。具有SEND_SMS权限的清单将类似于以下代码片段:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.snazzyapp">
    <uses-permission android:name="android.permission.SEND_SMS"/>
    <application ...>
...
    </application>
</manifest>

安全权限(或正常权限,如 Google 所称)将自动授予用户。然而,危险权限只有在用户明确批准的情况下才会被授予。如果我们未能向用户请求权限并尝试执行需要该权限的操作,结果最好是该操作不会运行,最坏的情况是我们的应用程序崩溃。

要向用户请求权限,我们应该首先检查用户是否已经授予我们该权限。

如果用户尚未授予我们权限,我们可能需要检查是否需要在请求权限之前显示理由对话框。这取决于请求的理由对用户来说是否显而易见。例如,如果相机应用请求访问相机的权限,我们可以安全地假设用户会清楚理由。然而,有些情况对用户来说可能不那么清晰,特别是如果用户不精通技术。在这些情况下,我们可能需要向用户解释请求的理由。Google 为我们提供了一个名为shouldShowRequestPermissionRationale(Activity, String)的函数来实现这个目的。在幕后,这个函数检查用户是否先前拒绝了权限,但也检查用户是否在权限请求对话框中选择了不再询问。这个想法是给我们一个机会,在请求之前向用户解释我们请求权限的理由,从而增加他们批准的可能性。

一旦我们确定是否应向用户呈现权限理由,或者用户是否应接受我们的理由或者不需要理由,我们就可以继续请求权限。

让我们看看如何请求权限。

我们请求权限的Activity类必须实现OnRequestPermissionsResultCallback接口。这是因为一旦用户被授予(或拒绝)权限,将调用onRequestPermissionsResult(Int, Array<String>, IntArray)函数。FragmentActivity类,AppCompatActivity扩展自它,已经实现了这个接口,所以我们只需要重写onRequestPermissionsResult函数来处理用户对权限请求的响应。以下是一个请求Location权限的Activity类的示例:

private const val PERMISSION_CODE_REQUEST_LOCATION = 1
class MainActivity : AppCompatActivity() {
    override fun onResume() {
        ...
        val hasLocationPermissions = getHasLocationPermission()
    }

当我们的Activity类恢复时,我们通过调用getHasLocationPermissions()来检查我们是否有位置权限(ACCESS_FINE_LOCATION):

    private fun getHasLocationPermission() = if (
        ContextCompat.checkSelfPermission(
            this, Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    ) {
        true
    } else {
        if (ActivityCompat.shouldShowRequestPermissionRationale(
                this, Manifest.permission.ACCESS_FINE_LOCATION
            )
        ) {
            showPermissionRationale { requestLocationPermission() }
        } else {
            requestLocationPermission()
        }
        false
    }

这个函数首先通过调用checkSelfPermission(Context, String)来检查用户是否已经授予了我们请求的权限。如果用户没有授予,我们调用我们之前提到的shouldShowRequestPermissionRationale(Activity, String)来检查是否应向用户呈现理由对话框。

如果需要显示我们的理由,我们调用showPermissionRationale(() -> Unit),传入一个在用户关闭我们的理由对话框后将调用requestLocationPermission()的 lambda。如果不需要理由,我们直接调用requestLocationPermission()

    private fun showPermissionRationale(positiveAction: () -> Unit) {
        AlertDialog.Builder(this)
            .setTitle("Location permission")
            .setMessage("We need your permission to find               your current position")
            .setPositiveButton(
                "OK"
            ) { _, _ -> positiveAction() }
            .create()
            .show()
    }

我们的showPermissionRationale函数简单地向用户呈现一个对话框,简要解释为什么我们需要他们的权限。确认按钮将执行积极的操作:

图 7.2 理由对话框

图 7.2 理由对话框

    private fun requestLocationPermission() {
        ActivityCompat.requestPermissions(
            this,
            arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION
            ),
            PERMISSION_CODE_REQUEST_LOCATION
        )
    }

最后,我们的requestLocationPermission()函数调用requestPermissions(Activity, Array<out String>, Int),向我们的活动传递一个包含请求的权限和我们独特的请求代码的数组。我们将使用这个代码来稍后识别响应属于这个请求。

如果我们已经向用户请求了位置权限,现在我们需要处理响应。这是通过重写onRequestPermissionsResult(Int, Array<out String>, IntArray)函数来完成的,如下面的代码所示:

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, 
      grantResults)
    when (requestCode) {
        PERMISSION_CODE_REQUEST_LOCATION -> getLastLocation()
    }
}

onRequestPermissionsResult被调用时,会传入三个值。第一个是请求代码,将与我们调用requestPermissions时提供的请求代码相同。第二个是请求的权限数组。第三个是我们请求的结果数组。对于每个请求的权限,这个数组将包含PackageManager.PERMISSION_GRANTEDPackageManager.PERMISSION_DENIED

本章将带领我们开发一个应用程序,在地图上显示我们当前的位置,并允许我们在想要部署我们的秘密猫特工的地方放置一个标记。让我们从我们的第一个练习开始。

练习 7.01:请求位置权限

在这个练习中,我们将请求用户提供位置权限。我们将首先创建一个 Google Maps Activity 项目。我们将在清单文件中定义所需的权限。让我们开始实现所需的代码,以请求用户访问其位置的权限:

  1. 首先创建一个新的 Google Maps Activity 项目(文件 | 新建 | 新项目 | Google Maps Activity)。在这个练习中我们不会使用 Google Maps。然而,在这种情况下,Google Maps Activity 仍然是一个不错的选择。它将在下一个练习(练习 7.02)中为你节省大量样板代码。不用担心;这不会对你当前的练习产生影响。点击下一步,如下截图所示:图 7.3:选择你的项目

图 7.3:选择你的项目

  1. 将你的应用程序命名为Cat Agent Deployer

  2. 确保你的包名是com.example.catagentdeployer

  3. 将保存位置设置为你想要保存项目的位置。

  4. 将其他所有内容保持默认值,然后点击完成

  5. 确保你的Project窗格中处于Android视图:图 7.4:Android 视图

图 7.4:Android 视图

  1. 打开你的AndroidManifest.xml文件。确保位置权限已经添加到你的应用程序中:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.catagentdeployer">
    ACCESS_FINE_LOCATION is the permission you will need to obtain the user's location based on GPS in addition to the less accurate Wi-Fi and mobile data-based location information you could obtain by using the ACCESS_COARSE_LOCATION permission.
  1. 打开你的MapsActivity.kt文件。在MapsActivity类块的底部添加一个空的getLastLocation()函数:
class MapsActivity : AppCompatActivity(), OnMapReadyCallback {
    ...
    private fun getLastLocation() {
 Log.d("MapsActivity", "getLastLocation() called.")
    }
}

这将是当你确保用户已经授予了位置权限时你将调用的函数。

  1. 接下来,在文件顶部的导入和类定义之间添加请求代码常量:
...
import com.google.android.gms.maps.model.MarkerOptions
private const val PERMISSION_CODE_REQUEST_LOCATION = 1
class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

这将是我们在请求位置权限时传递的代码。无论我们在这里定义什么值,当用户完成与请求对话框的交互并授予或拒绝我们权限时,都将返回给我们。

  1. 现在在getLastLocation()函数之前添加requestLocationPermission()函数:
private fun requestLocationPermission() {
    ActivityCompat.requestPermissions(
        this,
        arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
        PERMISSION_CODE_REQUEST_LOCATION
    )
}
private fun getLastLocation() {
    ...
}

这个函数将向用户呈现一个标准的权限请求对话框(如下图所示),要求他们允许应用程序访问他们的位置。我们传递了将接收回调的活动(this),你希望用户授予你的应用程序的请求权限的数组(Manifest.permission.ACCESS_FINE_LOCATION),以及你刚刚定义的PERMISSION_CODE_REQUEST_LOCATION常量,以将其与权限请求关联起来:

图 7.5:权限对话框

图 7.5:权限对话框

  1. 重写你的MapsActivity类的onRequestPermissionsResult(Int, Array<String>, IntArray)函数:
override fun onRequestPermissionsResult(
    requestCode: Int, permissions: Array<out String>,       grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode,           permissions,grantResults)
    when (requestCode) {
            PERMISSION_CODE_REQUEST_LOCATION -> if (
                grantResults[0] == PackageManager.PERMISSION_GRANTED
            ) {
                getLastLocation()
            }
    }
}

你应该首先调用 super 实现(当你重写函数时,这应该已经为你完成)。这将处理权限响应处理的委托给相关的子片段。

然后,你可以检查requestCode参数,看看它是否与你传递给requestPermissions(Activity, Array<out String>, Int)函数的requestCode参数匹配(PERMISSION_CODE_REQUEST_LOCATION)。如果匹配,由于你知道你只请求了一个权限,你可以检查第一个grantResults值。如果它等于PackageManager.PERMISSION_GRANTED,则用户已经授予了你的应用程序权限,你可以通过调用getLastLocation()来继续获取他们的最后位置。

  1. 如果用户拒绝了你的应用程序请求的权限,你可以向他们提出请求的理由。在requestLocationPermission()函数之前实现showPermissionRationale(() -> Unit)函数:
private fun showPermissionRationale(positiveAction: () -> Unit) {
    AlertDialog.Builder(this)
        .setTitle("Location permission")
        .setMessage("This app will not work without knowing your           current location")
        .setPositiveButton(
            "OK"
        ) { _, _ -> positiveAction() }
        .create()
        .show()
}

此函数将向用户呈现一个简单的警报对话框,解释应用程序如果不知道其当前位置将无法工作,如下截图所示。单击“确定”将执行提供的positiveAction lambda:

图 7.6:理由对话框

图 7.6:理由对话框

  1. 添加所需的逻辑来确定是显示权限请求对话框还是理由对话框。在showPermissionRationale(() -> Unit)函数之前创建requestPermissionWithRationaleIfNeeded()函数:
private fun requestPermissionWithRationaleIfNeeded() = if (
    ActivityCompat.shouldShowRequestPermissionRationale(
        this, Manifest.permission.ACCESS_FINE_LOCATION
    )
) {
    showPermissionRationale {
        requestLocationPermission()
    }
} else {
    requestLocationPermission()
}

此函数检查您的应用程序是否应显示理由对话框。如果应该,它将调用showPermissionRationale(() -> Unit),传入一个 lambda,该 lambda 将通过调用requestLocationPermission()来请求位置权限。否则,它将直接通过调用requestLocationPermission()函数来请求位置权限。

  1. 确定您的应用程序是否已经具有位置权限,请在requestPermissionWithRationaleIfNeeded()函数之前引入此处所示的hasLocationPermission()函数:
private fun hasLocationPermission() =
    ContextCompat.checkSelfPermission(
        this, Manifest.permission.ACCESS_FINE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED
  1. 最后,更新MapsActivity类的onMapReady()函数,以在地图准备就绪时请求权限或获取用户的当前位置:
override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap
    if (hasLocationPermission()) {
        getLastLocation()
    } else {
        requestPermissionWithRationaleIfNeeded()
    }
}
  1. 为了确保在用户拒绝权限时呈现理由,更新onRequestPermissionsResult(Int, Array<String>, IntArray),加入一个else条件:
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, 
      grantResults)
    when (requestCode) {
        PERMISSION_CODE_REQUEST_LOCATION -> if (
            grantResults[0] == PackageManager.PERMISSION_GRANTED
        ) {
            getLastLocation()
        } else {
            requestPermissionWithRationaleIfNeeded()
        }
    }
}
  1. 运行您的应用程序。现在,您应该看到一个系统权限对话框,请求您允许应用程序访问设备的位置:

图 7.7:应用程序请求位置权限

图 7.7:应用程序请求位置权限

如果您拒绝权限,将出现理由对话框,然后是另一个系统权限对话框,请求权限,如下截图所示。这次,用户可以选择不让应用程序再次请求权限。每当用户选择拒绝权限时,理由对话框将再次呈现给他们,直到他们选择允许权限或选中不再询问选项:

图 7.8:不再询问

图 7.8:不再询问

一旦用户允许或永久拒绝权限,对话框将不会再次显示。要重置应用程序权限的状态,您必须通过应用信息界面手动授予该权限。

现在我们可以获取位置权限,接下来我们将查看如何获取用户的当前位置。

显示用户位置的地图

成功获得用户访问其位置的权限后,我们现在可以要求用户的设备提供其上次已知的位置,这通常也是用户的当前位置。然后,我们将使用此位置向用户呈现其当前位置的地图。

为了获取用户的上次已知位置,Google 为我们提供了 Google Play 位置服务,更具体地说是FusedLocationProviderClient类。FusedLocationProviderClient类帮助我们与 Google 的 Fused 位置提供程序 API 进行交互,这是一个智能地结合来自多个设备传感器的不同信号以向我们提供设备位置信息的位置 API。

要访问FusedLocationProviderClient类,我们必须首先在项目中包含 Google Play 位置服务库。这意味着将以下代码片段添加到应用程序build.gradledependencies块中:

implementation "com.google.android.gms:play-services-location:17.1.0"

导入位置服务后,我们现在可以通过调用LocationServices.getFusedLocationProviderClient(this@MainActivity)来获取FusedLocationProviderClient类的实例。

一旦我们有了融合位置客户端,并且已经从用户那里获得了位置权限,我们可以通过调用fusedLocationClient.lastLocation来获取用户的最后位置。由于这是一个异步调用,我们至少应该提供一个成功的监听器。如果需要的话,我们还可以添加取消、失败和请求完成的监听器。getLastLocation()调用(在 Kotlin 中为lastLocation)返回一个Task<Location>。Task 是一个 Google API 的抽象类,其实现执行异步操作。在这种情况下,该操作是返回一个位置。因此,添加监听器只是简单地进行链接。我们将在我们的调用中添加以下代码片段:

.addOnSuccessListener { location: Location? ->
}

请注意,如果客户端未能获取用户的当前位置,则location参数可能为null。这并不常见,但如果例如用户在通话期间禁用了其位置服务,这种情况可能发生。

一旦我们成功监听器块内的代码被执行并且location不为 null,我们就可以得到用户当前位置的Location实例。

Location实例保存地球上的单个坐标,使用经度和纬度表示。对于我们的目的,知道地球表面上的每个点都映射到一对经度(缩写:Lng)和纬度(缩写:Lat)值就足够了。

这就是真正令人兴奋的地方。谷歌让我们可以使用SupportMapFragment类在交互式地图上呈现任何位置。只需注册一个免费的 API 密钥。当您使用 Google Maps Activity 创建应用程序时,Google 会为我们生成一个额外的文件,名为google_maps_api.xml,可以在res/values下找到。该文件对于我们的SupportMapFragment类是必需的,因为它包含我们的 API 密钥。它还包含如何获取新 API 密钥的清晰说明。方便的是,它还包含一个链接,该链接将为我们填写大部分所需的注册数据。链接看起来类似于https://console.developers.google.com/flows/enableapi?apiid=...。从google_maps_api.xml文件中复制它到您的浏览器(或在链接上CMD + click),一旦页面加载,按照页面上的说明操作,然后点击Create。一旦您获得了密钥,用您新获得的密钥替换文件底部的YOUR_KEY_HERE字符串。

此时,如果您运行您的应用程序,您将在屏幕上看到一个交互式地图:

图 7.9:交互式地图

图 7.9:交互式地图

为了根据我们的当前位置定位地图,我们使用来自我们的Location实例的坐标创建一个LatLng实例,并在GoogleMap实例上调用moveCamera(CameraUpdate)。为满足CameraUpdate的要求,我们调用CameraUpdateFactory.newLatLng(LatLng),传入之前创建的LatLng参数。调用看起来会像这样:

mMap.moveCamera(CameraUpdateFactory.newLatLng(latLng))

我们还可以调用newLatLngZoom(LatLng, Float)来修改地图的放大和缩小功能。

注意

有效的缩放值范围在2.0(最远)和21.0(最近)之间。超出该范围的值将被限制。

某些区域可能没有瓦片来渲染最接近的缩放值。要了解其他可用的CameraUpdateFactory选项,请访问developers.google.com/android/reference/com/google/android/gms/maps/CameraUpdateFactory.html

要在用户的坐标处添加一个标记(在 Google 的地图 API 中称为标记),我们在GoogleMap实例上调用addMarker(MarkerOptions)MarkerOptions参数通过链接到MarkerOptions()实例的调用进行配置。对于我们所需位置的简单标记,我们可以调用position(LatLng)title(String)。调用看起来类似于以下内容:

mMap.addMarker(MarkerOptions().position(latLng).title("Pin Label"))

我们链接调用的顺序并不重要。

让我们在以下练习中练习一下。

练习 7.02:获取用户的当前位置

现在,您的应用程序可以被授予位置权限,您可以继续利用位置权限来获取用户的当前位置。然后,您将显示地图并更新地图以放大到用户的当前位置并在该位置显示一个图钉。执行以下步骤:

  1. 首先,将 Google Play 位置服务添加到您的build.gradle文件中。您应该在dependencies块内添加它:
dependencies {
    implementation "com.google.android.gms:play-services-      location:17.1.0"
    implementation "org.jetbrains.kotlin:kotlin-      stdlib:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.2.1'
    implementation 'com.google.android.gms:play-services-maps:17.0.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test       .espresso:espresso-core:3.3.0'
}
  1. 单击 Android Studio 中的Sync Project with Gradle Files按钮,以便 Gradle 获取新添加的依赖项。

  2. 获取 API 密钥:首先打开生成的google_maps_api.xml文件(app/src/debug/res/values/google_maps_api.xml),然后CMD + 点击以开始的链接,该链接以https://console.developers.google.com/flows/enableapi?apiid=开头。

  3. 按照网站上的说明操作,直到生成一个新的 API 密钥。

  4. 通过将以下行中的YOUR_KEY_HERE替换为您的新 API 密钥来更新您的google_maps_api.xml文件:

<string name="google_maps_key" templateMergeStrategy="preserve"   translatable="false">YOUR_KEY_HERE</string>
  1. 打开您的MapsActivity.kt文件。在您的MapsActivity类的顶部,定义一个延迟初始化的融合位置提供程序客户端:
class MapsActivity : AppCompatActivity(), OnMapReadyCallback {
    fusedLocationProviderClient initialize lazily, you are making sure it is only initialized when needed, which essentially guarantees the Activity class will have been created before initialization.
  1. getLastLocation()函数之后立即引入一个updateMapLocation(LatLng)函数和一个addMarkerAtLocation(LatLng, String)函数,以在给定位置放大地图并在该位置添加一个标记:
private fun updateMapLocation(location: LatLng) {
    mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(location, 7f))
}
private fun addMarkerAtLocation(location: LatLng, title: String) {
    mMap.addMarker(MarkerOptions().title(title).position(location))
}
  1. 现在更新您的getLastLocation()函数以检索用户的位置:
private fun getLastLocation() {
    fusedLocationProviderClient.lastLocation
        .addOnSuccessListener { location: Location? ->
            location?.let {
                val userLocation = LatLng(location.latitude,                   location.longitude)
                updateMapLocation(userLocation)
                addMarkerAtLocation(userLocation, "You")
            }
        }
}

您的代码通过调用lastLocation以 Kotlin 简洁的方式请求最后的位置,然后将lambda函数附加为OnSuccessListener接口。一旦获得位置,lambda函数将被执行,更新地图位置并在该位置添加一个标题为You的标记(如果返回的位置不为空)。

  1. 运行您的应用程序:

图 7.10:带有当前位置标记的交互式地图

图 7.10:带有当前位置标记的交互式地图

一旦应用程序获得了权限,它可以通过融合位置提供程序客户端从 Google Play 位置服务获取用户的最后位置。这为您提供了一种轻松简洁的方式来获取用户的当前位置。请记住在设备上打开位置以使应用程序正常工作。

有了用户的位置,您的应用程序可以告诉地图在哪里放大并在哪里放置一个图钉。如果用户点击图钉,他们将看到您分配给它的标题(在练习中为You)。

在下一节中,我们将学习如何响应地图上的点击事件以及如何移动标记。

地图点击和自定义标记

通过在正确的位置放大并放置一个图钉来显示用户当前位置的地图,我们已经初步了解了如何渲染所需的地图,以及如何获取所需的权限和用户当前位置。

在本节中,我们将学习如何响应用户与地图的交互,以及如何更广泛地使用标记。我们将学习如何在地图上移动标记以及如何用自定义图标替换默认的图钉。当我们知道如何让用户在地图上任何地方放置一个标记时,我们可以让他们选择在哪里部署秘密猫特工。

监听地图上的点击事件,我们需要向GoogleMap实例添加一个监听器。查看我们的MapsActivity.kt文件,最好的地方是在onMapReady(GoogleMap)中这样做。一个天真的实现看起来像这样:

override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap.apply {
        setOnMapClickListener { latLng ->
            addMarkerAtLocation(latLng, "Deploy here")
        }
    }
    ...
}

但是,如果我们运行此代码,我们会发现对地图上的每次点击都会添加一个新的标记。这不是我们期望的行为。

要控制地图上的标记,我们需要保留对该标记的引用。这可以通过保留对GoogleMap.addMarker(MarkerOptions)的输出的引用来轻松实现。addMarker函数返回一个Marker实例。要在地图上移动标记,我们只需通过调用其position设置器为其分配一个新值。

要用自定义图标替换默认的标记图标,我们需要为标记或MarkerOptions()实例提供BitmapDescriptorBitmapDescriptor包装器可以解决GoogleMap用于渲染标记(和地面覆盖,但我们不会在本书中涵盖)的位图。我们通过使用BitmapDescriptorFactory来获取BitmapDescriptor。工厂将需要一个资产,可以通过多种方式提供。您可以使用assets目录中位图的名称、Bitmap、内部存储中文件的文件名或资源 ID 来提供它。工厂还可以创建不同颜色的默认标记。我们对Bitmap选项感兴趣,因为我们打算使用矢量可绘制,而这些不是工厂直接支持的。此外,当将可绘制对象转换为Bitmap时,我们可以对其进行操作以满足我们的需求(例如,我们可以更改其颜色)。

Android Studio 为我们提供了相当广泛的免费矢量Drawables。在这个例子中,我们想要paw可绘制。为此,右键单击左侧 Android 窗格中的任何位置,然后选择New | Vector Asset

现在,点击Clip Art标签旁边的 Android 图标,查看图标列表:

图 7.11:资产工作室

图 7.11:资产工作室

现在我们将访问一个窗口,我们可以从提供的剪贴画池中选择:

图 7.12:选择图标

图 7.12:选择图标

一旦我们选择了一个图标,我们可以给它命名,它将作为一个矢量可绘制的 XML 文件为我们创建。我们将它命名为target_icon

要使用创建的资产,我们必须首先将其作为Drawable实例获取。这是通过调用ContextCompat.getDrawable(Context, Int)来实现的,传入活动和R.drawable.target_icon作为对我们资产的引用。接下来,我们需要为Drawable实例定义绘制的边界。调用Drawable.setBound(Int, Int, Int, Int),参数为(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight),告诉它在其固有大小内绘制。

要更改图标的颜色,我们必须对其进行着色。要以一种受到早于21的 API 运行的设备支持的方式对Drawable实例进行着色,我们必须首先通过调用DrawableCompat.wrap(Drawable)将我们的Drawable实例包装在DrawableCompat中。然后可以使用DrawableCompat.setTint(Drawable, Int)对返回的Drawable进行着色。

接下来,我们需要创建一个Bitmap来容纳我们的图标。它的尺寸可以与Drawable的边界匹配,我们希望它的ConfigBitmap.Config.ARGB_8888 - 这意味着完整的红色、绿色、蓝色和 alpha 通道。然后我们为Bitmap创建一个Canvas,允许我们通过调用Drawable.draw(Canvas)来绘制我们的Drawable实例:

private fun getBitmapDescriptorFromVector(@DrawableRes   vectorDrawableResourceId: Int): BitmapDescriptor? {
    val bitmap =
        ContextCompat.getDrawable(this, vectorDrawableResourceId)?.let {           vectorDrawable ->
            vectorDrawable
                .setBounds(0, 0, vectorDrawable.intrinsicWidth,                   vectorDrawable.intrinsicHeight)
            val drawableWithTint = DrawableCompat.wrap(vectorDrawable)
            DrawableCompat.setTint(drawableWithTint, Color.RED)
            val bitmap = Bitmap.createBitmap(
                vectorDrawable.intrinsicWidth,
                vectorDrawable.intrinsicHeight,
                Bitmap.Config.ARGB_8888
            )
            val canvas = Canvas(bitmap)
            drawableWithTint.draw(canvas)
            bitmap
        }
    return BitmapDescriptorFactory.fromBitmap(bitmap)      .also {
          bitmap?.recycle()
    }
}

有了包含我们图标的Bitmap,我们现在可以从BitmapDescriptorFactory中获取一个BitmapDescriptor实例。不要忘记在之后回收您的Bitmap。这将避免内存泄漏。

您已经学会了如何通过将地图居中在用户的当前位置并使用标记标记显示他们的当前位置来向用户呈现有意义的地图。

练习 7.03:在地图被点击的地方添加自定义标记

在这个练习中,您将通过在地图上的用户点击位置放置一个红色的爪形标记来响应用户的地图点击:

  1. MapsActivity.kt(位于app/src/main/java/com/example/catagentdeployer下),在mMap变量的定义下面,定义一个可空的Marker变量,用于在地图上保存爪标记的引用:
private lateinit var mMap: GoogleMap
private var marker: Marker? = null
  1. 更新addMarkerAtLocation(LatLng, String),也接受一个可空的BitmapDescriptor,默认值为null
private fun addMarkerAtLocation(
    location: LatLng,
    title: StringmarkerIcon provided is not null, the app sets it to MarkerOptions. The function now returns the marker it added to the map.
  1. 在您的addMarkerAtLocation(LatLng, String, BitmapDescriptor?): Marker函数下面创建一个getBitmapDescriptorFromVector(Int): BitmapDescriptor?函数,以提供给定Drawable资源 ID 的BitmapDescriptor
private fun getBitmapDescriptorFromVector(@DrawableRes   vectorDrawableResourceId: Int): BitmapDescriptor? {
    val bitmap =
        ContextCompat.getDrawable(this,           vectorDrawableResourceId)?.let { vectorDrawable ->
            vectorDrawable
                .setBounds(0, 0, vectorDrawable.intrinsicWidth,                   vectorDrawable.intrinsicHeight)
            val drawableWithTint = DrawableCompat               .wrap(vectorDrawable)
            DrawableCompat.setTint(drawableWithTint, Color.RED)
            val bitmap = Bitmap.createBitmap(
                vectorDrawable.intrinsicWidth,
                vectorDrawable.intrinsicHeight,
                Bitmap.Config.ARGB_8888
            )
            val canvas = Canvas(bitmap)
            drawableWithTint.draw(canvas)
            bitmap
        }
    return BitmapDescriptorFactory.fromBitmap(bitmap).also {
        bitmap?.recycle()
    }
}

此函数首先使用ContextCompat获取可绘制对象,通过传入提供的资源 ID。然后为可绘制对象设置绘制边界,将其包装在DrawableCompat中,并将其色调设置为红色。

然后,它为该Bitmap创建了一个Canvas,在其上绘制了着色的可绘制对象。然后将位图返回以供BitmapDescriptorFactory使用以构建BitmapDescriptor。最后,为了避免内存泄漏,回收Bitmap

  1. 在您可以使用Drawable实例之前,您必须首先创建它。右键单击 Android 窗格,然后选择New | Vector Asset

  2. 在打开的窗口中,单击“剪贴画”标签旁边的 Android 图标,以选择不同的图标:图 7.13:资源工作室

图 7.13:资源工作室

  1. 从图标列表中,选择pets图标。如果找不到图标,可以在搜索框中输入pets。选择pets图标后,单击“确定”:图 7.14:选择图标

图 7.14:选择图标

  1. 将您的图标命名为target_icon。单击“下一步”和“完成”。

  2. 定义一个addOrMoveSelectedPositionMarker(LatLng)函数来创建一个新的标记,或者如果已经创建了一个标记,则将其移动到提供的位置。在getBitmapDescriptorFromVector(Int)函数之后添加它:

private fun addOrMoveSelectedPositionMarker(latLng: LatLng) {
    if (marker == null) {
        marker = addMarkerAtLocation(
            latLng, "Deploy here",               getBitmapDescriptorFromVector(R.drawable.target_icon)
        )
    } else {
        marker?.apply {
            position = latLng
        }
    }
}
  1. 更新您的onMapReady(GoogleMap)函数,为mMap设置一个OnMapClickListener事件,该事件将在点击的位置添加一个标记,或将现有标记移动到点击的位置:
override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap.apply {
        setOnMapClickListener { latLng ->
            addOrMoveSelectedPositionMarker(latLng)
        }
    }
    if (hasLocationPermission()) {
        getLastLocation()
    } else {
        requestPermissionWithRationaleIfNeeded()
    }
}
  1. 运行您的应用程序:图 7.15:完整的应用程序

图 7.15:完整的应用程序

现在,单击地图上的任何位置将会将爪印图标移动到该位置。单击爪印图标将显示“部署在这里”标签。请注意,爪印的位置是地理位置,而不是屏幕位置。这意味着如果您拖动地图或放大地图,爪印将随地图移动并保持在相同的地理位置。您现在知道如何响应用户在地图上的点击,以及如何添加和移动标记。您还知道如何自定义标记的外观。

活动 7.01:创建一个查找停放汽车位置的应用程序

有些人经常忘记他们停放汽车的地方。假设您想通过开发一个应用程序来帮助这些人,让用户存储他们上次停放的地方。当用户启动应用程序时,它将显示一个在用户告诉应用程序汽车位置的最后一个地方的标记。用户可以单击“我停在这里”按钮,以便在下次停放时将标记位置更新为当前位置。

您在此活动中的目标是开发一个应用程序,向用户显示带有当前位置的地图。它首先必须要求用户允许访问其位置。根据 SDK,确保在需要时还提供合理的对话框。该应用程序将在用户上次告诉它汽车位置的地方显示汽车图标。用户可以单击标有“我停在这里”的按钮,将汽车图标移动到当前位置。当用户重新启动应用程序时,它将显示用户的当前位置和汽车上次停放的位置。

作为应用程序的额外功能,您可以选择添加存储汽车位置的功能,以便在用户关闭然后重新打开应用程序后可以恢复该位置。此额外功能依赖于使用SharedPreferences;这是第十一章“持久化数据”中将介绍的一个概念。因此,下面的第 9 和第 10 步将为您提供所需的实现。

以下步骤将帮助您完成此活动:

  1. 创建一个 Google Maps Activity 应用程序。

  2. 获取应用程序的 API 密钥,并使用该密钥更新您的google_maps_api.xml文件。

  3. 在底部显示一个标有“我停在这里”的按钮。

  4. 在您的应用程序中包含位置服务。

  5. 请求用户的位置访问权限。

  6. 获取用户的位置并在地图上放置一个标记。

  7. 将汽车图标添加到您的项目中。

  8. 为汽车图标添加功能,将其移动到用户当前位置。

  9. 将选定的位置存储在SharedPreferences中。放置在您的活动中的此函数将有所帮助:

private fun saveLocation(latLng: LatLng) =
    getPreferences(MODE_PRIVATE)?.edit()?.apply {
        putString("latitude", latLng.latitude.toString())
        putString("longitude", latLng.longitude.toString())
        apply()
    }
  1. SharedPreferences中恢复任何保存的位置。您可以使用以下函数:
    val latitude = sharedPreferences.getString("latitude", null)      ?.toDoubleOrNull() ?: return null
    val longitude = sharedPreferences.getString("longitude",       null)?.toDoubleOrNull()       ?: return null

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

摘要

在本章中,我们学习了关于 Android 权限的知识。我们谈到了拥有这些权限的原因,并看到了如何请求用户的权限来执行某些任务。我们还学习了如何使用谷歌的地图 API 以及如何向用户呈现交互式地图。最后,我们利用了呈现地图和请求权限的知识,找出用户当前的位置并在地图上呈现出来。使用谷歌地图 API 还有很多可以做的事情,您可以通过某些权限探索更多可能性。现在您应该有足够的基础理解来进一步探索。要了解更多关于权限的信息,请访问 https://developer.android.com/reference/android/Manifest.permission。要了解更多关于地图 API 的信息,请访问developers.google.com/maps/documentation/android-sdk/intro

在下一章中,我们将学习如何使用ServicesWorkManager执行后台任务。我们还将学习如何在应用程序未运行时向用户呈现通知。作为移动开发人员,拥有这些强大的工具是非常重要的。

第八章:服务、WorkManager 和通知

概述

本章将向您介绍在应用程序的后台管理长时间运行任务的概念。通过本章结束时,您将能够触发后台任务,为用户创建通知,当后台任务完成时启动应用程序。本章将使您对如何管理后台任务并让用户了解这些任务的进度有一个扎实的理解。

介绍

在上一章中,我们学习了如何从用户那里请求权限并使用谷歌的地图 API。有了这些知识,我们获得了用户的位置,并允许他们在本地地图上部署特工。在本章中,我们将学习如何跟踪长时间运行的进程,并向用户报告其进度。

我们将构建一个示例应用程序,假设秘密猫特工SCAs)在 15 秒内部署。这样,我们就不必等待很长时间才能完成后台任务。当猫成功部署时,我们将通知用户,并让他们启动应用程序,向他们呈现成功部署的消息。

移动世界中,长时间运行的后台任务非常常见。即使应用程序不活跃,后台任务也会运行。长时间运行的后台任务的例子包括文件下载、资源清理作业、播放音乐和跟踪用户位置。在历史上,谷歌为 Android 开发者提供了多种执行此类任务的方式:服务、JobScheduler、Firebase 的JobDispatcherAlarmManager。随着 Android 世界的碎片化,处理这些任务变得非常混乱。幸运的是,自 2019 年 3 月以来,我们有了更好(更稳定)的选择。随着WorkManager的推出,谷歌已经为我们抽象出了根据 API 版本选择后台执行机制的逻辑。我们仍然使用前台服务,这是一种特殊类型的服务,用于在运行中的应用程序中应用用户应该知道的某些任务,比如播放音乐或跟踪用户的位置。

在我们继续之前,先快速回顾一下。我们已经提到了服务,我们将专注于前台服务,但我们还没有完全解释服务是什么。服务是设计为在后台运行的应用程序组件,即使应用程序不运行。除了与通知相关联的前台服务外,服务没有用户界面。重要的是要注意,服务在其托管进程的主线程上运行。这意味着它们的操作可能会阻塞应用程序。我们需要在服务内部启动一个单独的线程来避免这种情况。

让我们开始看一下 Android 中管理后台任务的多种方法的实现。

使用 WorkManager 启动后台任务

我们将在这里首先要解决的问题是,我们应该选择WorkManager还是前台服务?要回答这个问题,一个很好的经验法则是问:您是否需要用户实时跟踪操作?如果答案是肯定的(例如,如果您有任务,如响应用户位置或在后台播放音乐),那么您应该使用前台服务,并附加通知以向用户实时指示状态。当后台任务可以延迟或不需要用户交互时(例如,下载大文件),请使用WorkManager

注意

WorkManager的 2.3.0-alpha02 版本开始,您可以通过调用setForegroundAsync(ForegroundInfo)来启动前台服务。我们对前台服务的控制相当有限。它确实允许您将(预定义的)通知附加到工作中,这就是值得一提的原因。

在我们的例子中,在我们的应用程序中,我们将跟踪 SCA 的部署准备。在特工出发之前,他们需要伸展、梳理毛发、去猫砂盆和穿上衣服。每一个任务都需要一些时间。因为你不能催促一只猫,特工将在自己的时间内完成每一步。我们能做的就是等待(并在任务完成时通知用户)。WorkManager对于这样的情况非常合适。

要使用WorkManager,我们需要熟悉它的四个主要类:

  • 第一个是WorkManager本身。WorkManager接收工作并根据提供的参数和约束(如互联网连接和设备充电)对其进行排队。

  • 第二个是Worker。现在,Worker是需要执行的工作的包装器。它有一个函数doWork(),我们重写它来实现后台工作代码。doWork()将在后台线程中执行。

  • 第三个类是WorkRequest。这个类将Worker类与参数和约束绑定在一起。有两种类型的WorkRequestOneTimeWorkRequest,它运行一次工作,和PeriodicWorkRequest,它可以用来安排工作以固定间隔运行。

  • 第四个类是ListenableWorker.Result。你可能已经猜到了,但这是保存执行工作结果的类。结果可以是SuccessFailureRetry中的一个。

除了这四个类,我们还有Data类,它保存了传递给工作者和从工作者传递出来的数据。

让我们回到我们的例子。我们想定义四个需要按顺序发生的任务:猫需要伸展,然后它需要梳理毛发,然后去猫砂盆,最后,它需要穿上衣服。

在我们开始使用WorkManager之前,我们必须首先在我们的应用程序build.gradle文件中包含其依赖项:

implementation "androidx.work:work-runtime:2.4.0"

有了WorkManager包含在我们的项目中,我们将继续创建我们的工作者。第一个工作者将如下所示:

class CatStretchingWorker(
    context: Context,
    workerParameters: WorkerParameters
) : Worker(context, workerParameters) {
    override fun doWork(): Result {
        val catAgentId = inputData.getString(INPUT_DATA_CAT_AGENT_ID)
        Thread.sleep(3000L)
        val outputData = Data.Builder()
            .putString(OUTPUT_DATA_CAT_AGENT_ID, catAgentId)
            .build()
        return Result.success(outputData)
    }
    companion object {
        const val INPUT_DATA_CAT_AGENT_ID = "id"
        const val OUTPUT_DATA_CAT_AGENT_ID = "id"
    }
}

我们首先通过扩展Worker并重写其doWork()函数来开始。然后,我们从输入数据中读取 SCA ID。然后,因为我们没有真正的传感器来跟踪猫伸展的进度,我们通过引入一个 3 秒(3,000 毫秒)的Thread.sleep(Long)调用来伪造等待。最后,我们用我们在输入中收到的 ID 构造一个输出数据类,并将其与成功的结果一起返回。

一旦我们为所有任务创建了工作者(CatStretchingWorkerCatFurGroomingWorkerCatLitterBoxSittingWorkerCatSuitUpWorker),类似于我们创建第一个工作者的方式,我们可以调用WorkManager来将它们链接起来。假设我们无法在没有连接到互联网时了解特工的进度。我们的调用将如下所示:

val catStretchingInputData = Data.Builder()
  .putString(CatStretchingWorker.INPUT_DATA_CAT_AGENT_ID, 
    "catAgentId").build()
val catStretchingRequest = OneTimeWorkRequest
  .Builder(CatStretchingWorker::class.java)
val catStretchingRequest =   OneTimeWorkRequest.Builder(CatStretchingWorker::class.java)
    .setConstraints(networkConstraints)
    .setInputData(catStretchingInputData)
    .build()
...
WorkManager.getInstance(this).beginWith(catStretchingRequest)
    .then(catFurGroomingRequest)
    .then(catLitterBoxSittingRequest)
    .then(catSuitUpRequest)
    .enqueue()

在上述代码中,我们首先构造了一个Constraints实例,声明我们需要连接到互联网才能执行工作。然后,我们定义了我们的输入数据,将其设置为 SCA ID。接下来,我们通过构造OneTimeWorkRequest将约束和输入数据绑定到我们的Worker类。其他WorkRequest实例的构造已经被省略了,但它们与这里显示的基本相同。现在我们可以将所有请求链接起来并将它们排队到WorkManager类上。您可以通过直接将单个WorkRequest实例传递给WorkManagerenqueue()函数来排队一个单独的WorkRequest实例,或者您也可以通过将它们全部传递给WorkManagerenqueue()函数作为列表来并行运行多个WorkRequest实例。

当满足约束时,我们的任务将由WorkManager执行。

每个Request实例都有一个唯一的标识符。WorkManager为每个请求公开了一个LiveData属性,允许我们通过传递其唯一标识符来跟踪其工作的进度,如下面的代码所示:

workManager.getWorkInfoByIdLiveData(catStretchingRequest.id)
    .observe(this, Observer { info ->
        if (info.state.isFinished) {
            doSomething()
        }
    })

最后,还有 Result.retry。返回此结果会告诉 WorkManager 类重新排队工作。决定何时再次运行工作的策略由设置在 WorkRequest Builder 上的 backoff 标准定义。默认的 backoff 策略是指数的,但我们也可以将其设置为线性的。我们还可以定义初始的 backoff 时间。

这将为 Worker 实现添加所需的依赖项,然后扩展 Worker 类。要实现实际的工作,你将重写 doWork(): Result,使其从输入中读取 Cat Agent ID,休眠 3 秒(3000 毫秒),使用 Cat Agent ID 构造一个输出数据实例,并将其传递到 Result.success 值中。

在这个第一个练习中,我们将跟踪 SCA 在准备出发时通过排队的链式 WorkRequest 类:

在这一部分,我们将从我们发出部署到现场的命令开始跟踪我们的 SCA,直到它到达目的地。

要定义一个将休眠 3 秒的 Worker 实例,更新新类如下:

练习 8.01:使用 WorkManager 类执行后台工作

  1. 首先创建一个新的 Empty Activity 项目(File -> New -> New Project -> Empty Activity)。点击 Next

  2. 让我们在接下来的练习中实践到目前为止所学到的知识。

  3. 确保你在 Project 窗格中处于 Android 视图。

  4. 确保你的包名是 com.example.catagenttracker

  5. 将其他所有内容保持默认值,然后点击 Finish

  6. 将以下内容添加到 onCreate(Bundle?) 函数中:

  7. 打开你的应用程序的 build.gradle 文件。在 dependencies 块中,添加 WorkManager 依赖项:

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    ...
    WorkManager and its dependencies in your code.
  1. 工作的状态可以是 BLOCKED(存在一系列请求,它不是下一个请求)、ENQUEUED(存在一系列请求,这项工作是下一个请求)、RUNNINGdoWork() 中的工作正在执行)和 SUCCEEDED。工作也可以被取消,导致 CANCELLED 状态,或者失败,导致 FAILED 状态。

  2. 用户可见的后台操作 - 使用前台服务

  3. com.example.catagenttracker.worker 下创建一个名为 CatStretchingWorker 的新类(右键单击 worker,然后选择 New | New Kotlin File/Class)。在 Kind 下,选择 Class

package com.example.catagenttracker.worker
import android.content.Context
import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
class CatStretchingWorker(
    context: Context,
    workerParameters: WorkerParameters
) : Worker(context, workerParameters) {
    override fun doWork(): Result {
        val catAgentId = inputData.getString(INPUT_DATA_CAT_AGENT_ID)
        Thread.sleep(3000L)
        val outputData = Data.Builder()
            .putString(OUTPUT_DATA_CAT_AGENT_ID, catAgentId)
            .build()
        return Result.success(outputData)
    }
    companion object {
        const val INPUT_DATA_CAT_AGENT_ID = "inId"
        const val OUTPUT_DATA_CAT_AGENT_ID = "outId"
    }
}

将你的应用程序命名为 Cat Agent Tracker

  1. 运行你的应用程序:

  2. 打开 MainActivity。在类的末尾之前,添加以下内容:

private fun getCatAgentIdInputData(catAgentIdKey: String,   catAgentIdValue: String) =
    Data.Builder().putString(catAgentIdKey, catAgentIdValue)
        .build()

这个辅助函数为你构造了一个带有 Cat Agent ID 的输入 Data 实例。

  1. 将以下内容按行翻译成中文:
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    WorkManager class to wait for an internet connection before executing work. Then, you define your Cat Agent ID. Finally, you define four requests, passing in your Worker classes, the network constraints, and the Cat Agent ID in the form of input data.
  1. 将保存位置设置为你想要保存项目的位置。
private val workManager = WorkManager.getInstance(this)
  1. 在你的应用程序包下创建一个新的包(右键单击 com.example.catagenttracker,然后选择 New | Package)。将新包命名为 com.example.catagenttracker.worker
val catSuitUpRequest =   OneTimeWorkRequest.Builder(CatSuitUpWorker::class.java)
    .setConstraints(networkConstraints)
    .setInputData(
        getCatAgentIdInputData(CatSuitUpWorker           .INPUT_DATA_CAT_AGENT_ID, catAgentId)
    ).build()
WorkRequests are now enqueued to be executed in sequence when their constraints are met and the WorkManager class is ready to execute them.
  1. 定义一个显示带有提供的消息的提示的函数。它应该看起来像这样:
private fun showResult(message: String) {
    Toast.makeText(this, message, LENGTH_SHORT).show()
}
  1. 为了跟踪排队的 WorkRequest 实例的进度,在 enqueue 调用之后添加以下内容:
workManager.beginWith(catStretchingRequest)
    .then(catFurGroomingRequest)
    .then(catLitterBoxSittingRequest)
    .then(catSuitUpRequest)
    .enqueue()
WorkInfo observable provided by the WorkManager class for each WorkRequest. When each request is finished, a toast is shown with a relevant message.
  1. 在类的顶部,定义你的 WorkManager

图 8.1:按顺序显示的提示

现在你应该看到一个简单的 Hello World! 屏幕。但是,如果你等待几秒钟,你将开始看到提示信息,告诉你 SCA 准备部署到现场的进度。你会注意到这些提示信息按照你排队请求的顺序执行它们的延迟。

图 8.1:按顺序显示的提示

在你刚刚添加的代码下方,仍然在 onCreate 函数内,添加一个链式的 enqueue 请求:# 重复步骤 9 和 10,创建三个更多相同的工作程序,分别命名为 CatFurGroomingWorkerCatLitterBoxSittingWorkerCatSuitUpWorker。我们的 SCA 已经准备好去指定的目的地了。为了跟踪 SCA,我们将使用前台服务定期轮询 SCA 的位置,并使用新位置更新附加到该服务的粘性通知(用户无法解除的通知)。为了简单起见,我们将伪造位置。根据您在第七章中学到的内容,Android 权限和 Google 地图,您可以稍后用使用地图的真实实现替换这个实现。

前台服务是执行后台操作的另一种方式。名称可能有点违反直觉。它的目的是区分这些服务与基本的 Android(后台)服务。前者与通知绑定,而后者在后台运行,没有用户界面表示。前台服务和后台服务之间的另一个重要区别是,当系统内存不足时,后者可能会被终止,而前者不会。

从 Android 9(Pie,或 API 级别 28)开始,我们必须请求FOREGROUND_SERVICE权限来使用前台服务。由于这是一个普通权限,它将自动授予我们的应用程序。

在我们启动前台服务之前,我们必须先创建一个。前台服务是 Android 抽象Service类的子类。如果我们不打算绑定到服务,而在我们的示例中确实不打算这样做,我们可以简单地重写onBind(Intent),使其返回null。顺便说一句,绑定是感兴趣的客户端与服务通信的一种方式。在本书中,我们不会专注于这种方法,因为您将在下面发现其他更简单的方法。

前台服务必须与通知绑定。在 Android 8(Oreo 或 API 级别 26)及更高版本中,如果前台服务在服务的onCreate()函数中没有与通知绑定。一个快速的实现看起来会像这样:

private fun onCreate() {
    val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {        
        val newChannelId = "ChannelId"
        val channelName = "My Background Service"
        val channel =
            NotificationChannel(newChannelId, channelName,               NotificationManager.IMPORTANCE_DEFAULT)
        val service = getSystemService(Context.NOTIFICATION_SERVICE) as           
            NotificationManager        
        service.createNotificationChannel(channel)       
        newChannelId    
    } else {        
        ""    
    }    
    val pendingIntent = Intent(this, MainActivity::class.java).let {       
        notificationIntent ->        
            PendingIntent.getActivity(this, 0, notificationIntent, 0)
    }    
    val notification = NotificationCompat.Builder(this, channelId)        
        .setContentTitle("Content title")
        .setContentText("Content text")
        .setSmallIcon(R.drawable.notification_icon)
        .setContentIntent(pendingIntent)
        .setTicker("Ticker message")
        .build()
    startForeground(NOTIFICATION_ID, notificationBuilder.build())
}

让我们来分解一下。我们首先要定义频道 ID。这仅适用于 Android Oreo 或更高版本,在早期版本的 Android 中将被忽略。在 Android Oreo 中,Google 引入了频道的概念。频道用于分组通知,并允许用户过滤掉不需要的通知:

    val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {        
        val newChannelId = "ChannelId"
        val channelName = "My Background Service"        
        val channel =
            NotificationChannel(newChannelId, channelName,               NotificationManager.IMPORTANCE_DEFAULT)
        val service = getSystemService(Context.NOTIFICATION_SERVICE) as
            NotificationManager
        service.createNotificationChannel(channel)        
        newChannelId    
    } else {
        ""    
    }

接下来,我们定义pendingIntent。这将是用户点击通知时启动的意图。在这个例子中,主活动将被启动:

    val pendingIntent = Intent(this, MainActivity::class.java).let {       
        notificationIntent ->        
            PendingIntent.getActivity(this, 0, notificationIntent, 0)
    }

有了频道 ID 和pendingIntent,我们就可以构建我们的通知。我们使用NotificationCompat,它可以减少对支持旧 API 级别的样板代码。我们将服务作为上下文和频道 ID 传递进去。我们定义标题、文本、小图标、意图和滚动消息,并构建通知:

    val notification = NotificationCompat.Builder(this, channelId)        
        .setContentTitle("Content title")
        .setContentText("Content text") 
        .setSmallIcon(R.drawable.notification_icon)
        .setContentIntent(pendingIntent)
        .setTicker("Ticker message")
        .build()

要启动一个前台服务,并将通知附加到其中,我们调用startForeground(Int, Notification)函数,传入一个通知 ID(任何唯一的整数值来标识此服务,不能为 0)和一个通知,其优先级必须设置为PRIORITY_LOW或更高。在我们的情况下,我们没有指定优先级,这将使其设置为PRIORITY_DEFAULT

    startForeground(NOTIFICATION_ID, notificationBuilder.build())

如果启动,我们的服务现在将显示一个粘性通知。点击通知将启动我们的主活动。但是,我们的服务不会执行任何有用的操作。要为其添加一些功能,我们需要重写onStartCommand(Intent?, Int, Int)。当服务通过意图启动时,此函数将被调用,这也给了我们机会读取通过该意图传递的任何额外数据。它还为我们提供了标志(可能设置为START_FLAG_REDELIVERYSTART_FLAG_RETRY)和一个唯一的请求 ID。

我们将在本章后面读取额外的数据。在简单的实现中,您不需要担心标志或请求 ID。重要的是要注意,onStartCommand(Intent?, Int, Int)在 UI 线程上调用,因此不要在这里执行任何长时间运行的操作,否则您的应用程序将冻结,给用户带来不良体验。相反,我们可以使用新的HandlerThread(一个带有 looper 的线程,用于为线程运行消息循环的类)创建一个新的处理程序,并将我们的工作发布到其中。这意味着我们将有一个无限循环运行,等待我们通过Handler发布工作。当我们收到启动命令时,我们可以将要执行的工作发布到其中。然后该工作将在该线程上执行。

当我们的长时间运行的工作完成时,有一些事情可能会发生。首先,我们可能希望通知感兴趣的人(例如,如果主要活动正在运行,则通知主要活动)我们已经完成。然后,我们可能希望停止在前台运行。最后,如果我们不希望再次需要服务,我们可以停止它。

应用程序有几种与服务通信的方式——绑定、使用广播接收器、使用总线架构或使用结果接收器等。在我们的示例中,我们将使用 Google 的LiveData

在我们继续之前,值得一提的是广播接收器。广播接收器允许我们的应用程序使用类似发布-订阅设计模式的模式发送和接收消息。

系统广播事件,例如设备启动或充电已开始。我们的服务也可以广播状态更新。例如,它们可以在完成时广播长时间的计算结果。

如果我们的应用程序注册接收某个消息,系统将在广播该消息时通知它。这曾经是与服务通信的常见方式,但LocalBroadcastManager类现在已被弃用,因为它是一个鼓励反模式的应用程序范围事件总线。

话虽如此,广播接收器仍然对系统范围的事件很有用。我们首先定义一个类,覆盖BroadcastReceiver抽象类:

class ToastBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {        
        StringBuilder().apply {
            append("Action: ${intent.action}\n")
            append("URI: ${intent.toUri(Intent.URI_INTENT_SCHEME)}\n")
            toString().let { eventText ->
                Toast.makeText(context, eventText,
                    Toast.LENGTH_LONG).show()
            }        
        }    
    }
}

ToastBroadcastReceiver接收到事件时,它将显示一个显示事件操作和 URI 的 toast。我们可以通过Manifest.xml文件注册我们的接收器:

<receiver android:name=".ToastBroadcastReceiver" android:exported="true">
    <intent-filter>        
        <action android:name=          
            "android.intent.action.ACTION_POWER_CONNECTED" />    
    </intent-filter>
</receiver>

指定android:exported="true"告诉系统此接收器可以接收来自应用程序外部的消息。操作定义了我们感兴趣的消息。我们可以指定多个操作。在此示例中,我们监听设备开始充电的情况。请记住,将此值设置为"true"允许其他应用程序,包括恶意应用程序,激活此接收器。我们也可以在代码中注册消息:

val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION).apply {    
    addAction(Intent.ACTION_POWER_CONNECTED)}
    registerReceiver(ToastBroadcastReceiver(), filter)

将此代码添加到活动或自定义应用程序类中将注册一个新的接收器实例。只要上下文(活动或应用程序)有效,此接收器将保持存在。因此,相应地,如果活动或应用程序被销毁,我们的接收器将被释放以进行垃圾回收。现在回到我们的实现。要在我们的应用程序中使用LiveData,我们必须在app/build.gradle文件中添加一个依赖项:

Dependencies {    
    ...    
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"    
    ...
}

然后我们可以在服务的伴生对象中定义一个LiveData实例,如下所示:

companion object {    
    private val mutableWorkCompletion = MutableLiveData<String>()    
    val workCompletion: LiveData<String> = mutableWorkCompletion
}

请注意,我们将MutableLiveData实例隐藏在LiveData接口后面。这样消费者只能读取数据。现在我们可以使用mutableWorkCompletion实例通过为其分配一个值来报告完成。但是,我们必须记住,只能在主线程上为LiveData实例分配值。这意味着一旦我们的工作完成,我们必须切换回主线程。我们可以很容易地实现这一点——我们只需要一个具有主Looper的新处理程序(通过调用Looper.getMainLooper()获得),我们可以将我们的更新发布到其中。

现在我们的服务已经准备好做一些工作,我们最终可以启动它。在我们这样做之前,我们必须确保将服务添加到我们的AndroidManifest.xml文件中的<application></application>块中,如下面的代码所示:

<application ...>    
    <service android:name=".ForegroundService" />
</application>

要启动我们刚刚添加到清单中的服务,我们创建Intent,传入所需的任何额外数据,如下面的代码所示:

val serviceIntent = Intent(this, ForegroundService::class.java).apply {    
    putExtra("ExtraData", "Extra value")
}

然后,我们调用ContextCompat.startForegroundService(Context, Intent)来触发Intent并启动服务。

练习 8.02:使用前台服务跟踪您的 SCA 的工作

在第一个练习中,您使用WorkManager类跟踪了 SCA 在准备出发时的情况。在这个练习中,您将通过显示一个粘性通知来跟踪 SCA 在部署到现场并朝着指定目标移动的情况,倒计时到达目的地的时间。这个通知将由一个前台服务驱动,它将呈现并持续更新它。随时点击通知将启动您的主活动,如果它尚未运行,它将始终将其置于前台:

  1. 通过更新应用的build.gradle文件,首先向您的项目添加LiveData依赖项:
    implementation "androidx.work:work-runtime:2.4.0"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"    
  1. 然后,创建一个名为RouteTrackingService的新类,扩展抽象的Service类:
class RouteTrackingService : Service() {
    override fun onBind(intent: Intent): IBinder? = null
}

在这个练习中,您不会依赖绑定,因此在onBind(Intent)实现中简单地返回null是安全的。

  1. 在新创建的服务中,定义一些稍后需要的常量,以及用于观察进度的LiveData实例:
    companion object {
        const val NOTIFICATION_ID = 0xCA7        
        const val EXTRA_SECRET_CAT_AGENT_ID = "scaId"        
        private val mutableTrackingCompletion = MutableLiveData<String>()        
        val trackingCompletion: LiveData<String> = mutableTrackingCompletion    
    }    

NOTIFICATION_ID必须是此服务拥有的通知的唯一标识符,不能是0。现在,EXTRA_SECRET_CAT_AGENT_ID是您用于向服务传递数据的常量。mutableTrackingCompletion是私有的,用于允许您通过LiveData在服务内部发布完成更新,而不会在服务外部暴露可变性。然后使用trackingCompletion以不可变的方式公开LiveData实例以供观察。

  1. 在您的RouteTrackingService类中添加一个函数,以提供给您的粘性通知PendingIntent
    private fun getPendingIntent() =        
        PendingIntent.getActivity(this, 0, Intent(this,       MainActivity::class.java), 0)    

这将在用户点击Notification时启动MainActivity。您调用PendingIntent.getActivity(),传递上下文、无请求代码(0)、将启动MainActivityIntent,以及没有标志(0)。您会得到一个PendingIntent,它将启动该活动。1. 添加另一个函数来为运行 Android Oreo 或更新版本的设备创建NotificationChannel

    @RequiresApi(Build.VERSION_CODES.O)    
    private fun createNotificationChannel(): String {
        val channelId = "routeTracking"
        val channelName = "Route Tracking"
        val channel =
            NotificationChannel(channelId, channelName,           
                NotificationManager.IMPORTANCE_DEFAULT)
        val service = getSystemService(Context.NOTIFICATION_SERVICE) as       
            NotificationManager        
        service.createNotificationChannel(channel)        return channelId
    }

首先定义频道 ID。这需要对包进行唯一标识。接下来,定义一个对用户可见的频道名称。这可以(并且应该)进行本地化。出于简单起见,我们跳过了这部分。然后创建一个NotificationChannel实例,将重要性设置为IMPORTANCE_DEFAULT。重要性决定了发布到此频道的通知有多么具有破坏性。最后,使用Notification Service使用NotificationChannel实例中提供的数据创建一个频道。该函数返回频道 ID,以便用于构造Notification

  1. 创建一个函数来提供Notification.Builder
    private fun getNotificationBuilder(pendingIntent: PendingIntent, channelId: String) =
        NotificationCompat.Builder(this, channelId)
            .setContentTitle("Agent approaching destination")
            .setContentText("Agent dispatched")
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentIntent(pendingIntent)
            .setTicker("Agent dispatched, tracking movement")

此函数使用您之前创建的函数生成的pendingIntentchannelId实例,并构造一个NotificationCompat.Builder类。该构建器允许您定义标题(第一行)、文本(第二行)、要使用的小图标(根据设备而异的大小)、用户点击Notification时触发的意图以及一个提示(用于辅助功能;在 Android Lollipop 之前,这在通知被呈现之前显示)。您也可以设置其他属性。探索NotificationCompat.Builder类。在实际项目中,请记住使用来自 strings.xml 的字符串资源而不是硬编码的字符串。

  1. 实现以下代码,引入一个函数来启动前台服务:
    private fun startForegroundService(): NotificationCompat.Builder {        
        val pendingIntent = getPendingIntent()
        val channelId =       
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {            
                createNotificationChannel()
            } else {
                ""
            }
        val notificationBuilder = getNotificationBuilder(pendingIntent,       
            channelId)
        startForeground(NOTIFICATION_ID, notificationBuilder.build())        return notificationBuilder
    }

您首先使用您之前引入的函数获取PendingIntent。然后,根据设备的 API 级别,您创建一个通知通道并获取其 ID,或者设置一个空 ID。您将pendingIntentchannelId传递给构造NotificationCompat.Builder的函数,并将服务作为前台服务启动,为其提供NOTIFICATION_ID和使用构建器构建的通知。该函数返回NotificationCompat.Builder,以便稍后用于更新通知。

  1. 在您的服务中定义两个字段——一个用于保存可重用的NotificationCompat.Builder类,另一个用于保存对Handler的引用,稍后您将在后台中使用它来发布工作:
    private lateinit var notificationBuilder: NotificationCompat.Builder    
    private lateinit var serviceHandler: Handler    
  1. 接下来,重写onCreate()以将服务作为前台服务启动,保留对Notification.Builder的引用,并创建serviceHandler
    override fun onCreate() {
        super.onCreate()
        notificationBuilder = startForegroundService()        
        val handlerThread = HandlerThread("RouteTracking").apply {
            start()
        }
        serviceHandler = Handler(handlerThread.looper)
    }

请注意,要创建Handler实例,必须首先定义并启动HandlerThread

  1. 定义一个跟踪已部署的 SCA 接近其指定目的地的调用:
    private fun trackToDestination(notificationBuilder:   
        NotificationCompat.Builder) {
        for (i in 10 downTo 0) {
            Thread.sleep(1000L)
            notificationBuilder
               .setContentText("$i seconds to destination")            
            startForeground(NOTIFICATION_ID,           
                notificationBuilder.build())
        }
    }    

这将从10倒数到1,在更新之间每隔 1 秒休眠,然后使用剩余时间更新通知。

  1. 添加一个函数,在主线程上通知观察者完成:
    private fun notifyCompletion(agentId: String) {
        Handler(Looper.getMainLooper()).post {            
            mutableTrackingCompletion.value = agentId
        }
    }    

通过在主Looper上使用处理程序发布,您确保更新发生在主(UI)应用程序线程上。当将值设置为代理 ID 时,您正在通知所有观察者该代理 ID 已到达目的地。

  1. 像这样重写onStartCommand(Intent?, Int, Int)
    override fun onStartCommand(intent: Intent?, flags: Int,
        startId: Int): Int {
        val returnValue = super.onStartCommand(intent, flags, startId)    
        val agentId =
            intent?.getStringExtra(EXTRA_SECRET_CAT_AGENT_ID)
            ?: throw IllegalStateException("Agent ID must be provided")
        serviceHandler.post {
            trackToDestination(notificationBuilder)            
            notifyCompletion(agentId)
            stopForeground(true)
            stopSelf()        
        }        
        return returnValue
    }    

您首先将调用委托给super,它在内部调用onStart()并返回一个向后兼容的状态,您可以返回。您存储此返回值。接下来,您从通过意图传递的额外参数中获取 SCA ID。如果没有提供代理 ID,则此服务将无法工作,因此如果没有提供代理 ID,您将抛出异常。接下来,您切换到在onCreate中定义的后台线程,以阻塞方式跟踪代理到其目的地。跟踪完成后,您通知观察者任务已完成,停止前台服务(通过传递true来删除通知),并停止服务本身,因为您不希望很快再次需要它。然后,您返回之前存储的super的返回值。

  1. 更新您的AndroidManifest.xml以请求FOREGROUND_SERVICE权限并引入服务:
    <manifest ...>

除非我们这样做,否则系统将阻止我们的应用程序使用前台服务。接下来,我们声明服务。设置android:enabled="true"告诉系统它可以实例化服务。默认值为true,因此这是可选的。用 android 定义服务:exported="true"告诉系统其他应用程序可以启动该服务。在我们的例子中,我们不需要这个额外的功能,但是我们添加它只是为了让您知道这个功能。

  1. 回到您的MainActivity。引入一个函数来启动RouteTrackingService
    private fun launchTrackingService() {
        RouteTrackingService.trackingCompletion.observe(this, Observer {
            agentId -> showResult("Agent $agentId arrived!")
        })        
        val serviceIntent = Intent(this, 
            RouteTrackingService::class.java).apply {
                putExtra(EXTRA_SECRET_CAT_AGENT_ID, "007")
            }
        ContextCompat.startForegroundService(this, serviceIntent)
    }    

该函数首先观察LiveData以获取完成更新,完成时显示结果。然后,它为启动服务定义Intent,为该Intent的额外参数设置 SCA ID。然后,使用ContextCompat启动前台服务,该服务隐藏了与兼容性相关的逻辑。

  1. 最后,更新onCreate()以在准备好并准备好启动时立即开始跟踪 SCA:
    workManager.getWorkInfoByIdLiveData(catSuitUpRequest.id)
        .observe(this, Observer { info ->
            if (info.state.isFinished) {
                showResult("Agent done suiting up. Ready to go!")   
                launchTrackingService()
            }
        })    
  1. 启动应用程序:

图 8.2:倒计时通知

图 8.2:倒计时通知

在通知您 SCA 准备步骤之后,您应该在状态栏中看到一个通知。该通知然后应该从 10 倒数到 0,消失,并被一个 toast 替换,通知您代理已到达目的地。看到最后的 toast 告诉您,您成功将 SCA ID 传递给服务,并在后台任务完成时将其取回。

通过本章获得的所有知识,让我们完成以下活动。

活动 8.01:提醒喝水

平均每天人体失去约 2500 毫升的水(参见en.wikipedia.org/wiki/Fluid_balance#Output)。为了保持健康,我们需要摄入与失去的水量相同的水。然而,由于现代生活的繁忙性质,很多人经常忘记定期补水。假设您想开发一个应用程序,跟踪您的水分流失(统计数据),并给您不断更新的液体平衡。从平衡状态开始,该应用程序将逐渐减少用户跟踪的水位。用户可以告诉应用程序他们何时喝了一杯水,它将相应地更新水位。水位的持续更新将利用您运行后台任务的知识,并且您还将利用与服务通信的知识来响应用户交互更新平衡。

以下步骤将帮助您完成此活动:

  1. 创建一个空活动项目,并将您的应用命名为My Water Tracker

  2. 在您的AndriodManifest.xml文件中添加前台服务权限。

  3. 创建一个新的服务。

  4. 在您的服务中定义一个变量来跟踪水位。

  5. 为通知 ID 和额外意图数据键定义常量。

  6. 设置从服务创建通知。

  7. 添加函数来启动前台服务和更新水位。

  8. 将水位设置为每 5 秒减少一次。

  9. 处理来自服务外部的流体添加。

  10. 确保服务在销毁时清理回调和消息。

  11. Manifest.xml文件中注册服务。

  12. MainActivity中创建活动时启动服务。

  13. 在主活动布局中添加一个按钮。

  14. 当用户点击按钮时,通知服务需要增加水位。

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

摘要

在本章中,我们学习了如何使用WorkManager和前台服务执行长时间运行的后台任务。我们讨论了如何向用户传达进度,以及如何在任务执行完成后让用户重新进入应用程序。本章涵盖的所有主题都非常广泛,您可以进一步探索与服务通信、构建通知以及使用WorkManager类。希望对于大多数常见情况,您现在已经拥有所需的工具。常见用例包括后台下载、清理缓存资产、在应用程序不在前台运行时播放音乐,以及结合我们从第七章 Android 权限和谷歌地图中获得的知识,随时间跟踪用户的位置。

在下一章中,我们将通过编写单元测试和集成测试来使我们的应用程序更加健壮和可维护。当您编写的代码在后台运行并且当出现问题时不会立即显现时,这将特别有帮助。

第九章:使用 JUnit、Mockito 和 Espresso 进行单元测试和集成测试

概述

在本章中,你将学习关于在 Android 平台上进行测试以及如何创建单元测试、集成测试和 UI 测试。你将看到如何创建这些类型的测试,分析它们的运行方式,并使用 JUnit、Mockito、Robolectric 和 Espresso 等框架进行工作。你还将学习关于测试驱动开发,这是一种将测试置于实现之上的软件开发实践。通过本章的学习,你将能够将你的新测试技能结合起来应用到一个真实项目中。

介绍

在之前的章节中,你学习了如何加载背景数据并在用户界面中显示它,以及如何设置 API 调用来检索数据。但是你如何确保一切正常?如果你处于一个你过去没有太多互动的项目中需要修复一个错误的情况下怎么办?你如何知道你所应用的修复不会触发另一个错误?这些问题的答案是通过测试。

在本章中,我们将分析开发人员可以编写的测试类型,并查看可用的测试工具以简化测试体验。首先出现的问题是台式机或笔记本电脑使用不同的操作系统来开发移动应用。这意味着测试也必须在设备或模拟器上运行,这将减慢测试的速度。为了解决这个问题,我们有两种类型的测试:test文件夹将在你的机器上运行,而androidTest文件夹将在设备或模拟器上运行。

这两种测试都依赖于 Java 的JUnit库,它帮助开发人员设置他们的测试并将它们分组到不同的类别中。它还提供了不同的配置选项,以及其他库可以构建的扩展。我们还将研究测试金字塔,它帮助指导开发人员如何组织他们的测试。我们将从金字塔的底部开始,代表着单元测试,向上移动通过集成测试,最终达到顶部,代表着端到端测试(UI 测试)。你将有机会学习到帮助编写每种类型测试的工具:

  • Mockitomockito-kotlin主要帮助进行单元测试,并且对于创建模拟或测试替身非常有用,我们可以操纵输入以便断言不同的场景。(模拟或测试替身是一个模仿另一个对象实现的对象。每当一个测试与模拟对象交互时,你可以指定这些交互的行为。)

  • Robolectric是一个开源库,它将 Android 框架引入你的机器,允许你在本地测试活动和片段,而不是在模拟器上。这可以用于单元测试和集成测试。

  • EditText组件等)和断言(验证视图显示特定文本,当前显示给用户,启用等)在应用的 UI 中的仪器测试。

在本章中,我们还将介绍测试驱动开发TDD)。这是一个测试优先的软件开发过程。简单来说,就是先编写测试。我们将分析在为 Android 应用程序开发功能时采用这种方法。要记住的一件事是,为了正确测试应用程序,其类必须正确编写。一种方法是清晰地定义类之间的边界,并根据您希望它们完成的任务对它们进行拆分。一旦您做到了这一点,您还可以在编写类时依赖于依赖反转依赖注入原则。当这些原则得到正确应用时,您应该能够将虚假对象注入到测试对象中,并操纵输入以适应您的测试场景。依赖注入还有助于编写插装测试,以帮助您用本地数据替换进行网络调用的模块,以使您的测试独立于网络等外部因素。插装测试是在设备或模拟器上运行的测试。 "插装"关键字来自插装框架,该框架组装这些测试,然后在设备上执行它们。

理想情况下,每个应用程序应该有三种类型的测试:

  • 单元测试:这些是验证单个类和方法的本地测试。它们应该占大多数测试,并且它们应该快速、易于调试和易于维护。它们也被称为小型测试。

  • 集成测试:这些是使用 Robolectric 的本地测试,或验证应用程序模块和组件之间交互的插装测试。这些比单元测试更慢,更复杂。复杂性的增加是由于组件之间的交互。这些也被称为中型测试。

  • UI 测试(端到端测试):这些是验证完整用户旅程和场景的插装测试。这使它们更复杂,更难以维护;它们应该代表您总测试数量中的最少部分。这些也被称为大型测试。

在下图中,您可以观察到测试金字塔。Google 的建议是保持 70:20:10(单元测试:集成测试:UI 测试)的比例:

图 9.1:测试金字塔

图 9.1:测试金字塔

如前一节所述,单元测试是验证代码的一小部分的测试,大多数测试应该是覆盖各种场景(成功、错误、限制等)的单元测试。理想情况下,这些测试应该是本地的,但也有一些例外情况,您可以将它们制作成插装测试。这些情况很少,应该限制在您想要与设备的特定硬件交互时。

JUnit

JUnit 是一个用于在 Java 和 Android 中编写单元测试的框架。它负责测试的执行方式,允许开发人员配置他们的测试。它提供了许多功能,例如以下内容:

  • @Before@After注解。

  • 断言:用于验证操作的结果与预期值是否一致。

  • 规则:这些允许开发人员设置多个测试的常见输入。

  • 运行器:使用这些,您可以指定如何执行测试。

  • 参数:这些允许使用多个输入执行测试方法。

  • 排序:这些指定测试应该以何种顺序执行。

  • 匹配器:这些允许您定义模式,然后用于验证测试对象的结果,或者帮助您控制模拟的行为。

在 Android Studio 中,创建新项目时,app模块会在 Gradle 中带有 JUnit 库。这应该在app/build.gradle中可见:

testImplementation 'junit:junit:4.13.1'

让我们看看我们需要测试的以下类:

class MyClass {
    fun factorial(n: Int): Int {
        return IntArray(n) {
            it+1
        }.reduce { acc, i ->
            acc * i
        }
    }
}

这个方法应该返回数字 n 的阶乘。我们可以从一个检查值的简单测试开始。为了创建一个新的单元测试,您需要在项目的 test 目录中创建一个新的类。大多数开发人员遵循的典型约定是在 test 目录中的相同包下为您的类名称添加 Test 后缀,并将其放在相同的包下。例如,com.mypackage.ClassA 将在 com.mypackage.ClassATest 中进行测试:

import org.junit.Assert.assertEquals
import org.junit.Test
class MyClassTest {
    private val myClass = MyClass()
    @Test
    fun computesFactorial() {
        val n = 3
        val result = myClass.factorial(n)
        assertEquals(6, result)
    }
}

在这个测试中,您可以看到我们初始化了被测试的类,测试方法本身使用了 @Test 注解。测试方法本身将断言 (3!)==6。断言使用了 JUnit 库中的 assertEquals 方法。开发中的一个常见做法是将测试分为三个区域,也称为 AAA(Arrange-Act-Assert):

  • Arrange - 初始化输入的地方

  • Act - 调用被测试方法的地方

  • Assert - 验证的地方

我们可以编写另一个测试来确保值是正确的,但这意味着我们最终会重复代码。现在我们可以尝试编写一个参数化测试。为了做到这一点,我们需要使用参数化测试运行器。前面的测试有其自己的内置运行器,由 JUnit 提供。参数化运行器将为我们提供的不同值重复运行测试,并且看起来像下面这样。(请注意,出于简洁起见,已删除了导入语句。)

@RunWith(Parameterized::class)
class MyClassTest(
    private val input: Int,
    private val expected: Int
) {
    companion object {
        @Parameterized.Parameters
        @JvmStatic
        fun getData(): Collection<Array<Int>> = listOf(
            arrayOf(0, 1),
            arrayOf(1, 1),
            arrayOf(2, 2),
            arrayOf(3, 6),
            arrayOf(4, 24),
            arrayOf(5, 120)
        )
    }
    private val myClass = MyClass()
    @Test
    fun computesFactorial() {
        val result = myClass.factorial(input)
        assertEquals(expected, result)
    }
}

这实际上将运行六个测试。@Parameterized 注解的使用告诉 JUnit 这是一个具有多个参数的测试,并且还允许我们为测试添加一个构造函数,该构造函数将表示我们的阶乘函数的输入值和输出。然后我们使用 @Parameterized.Parameters 注解定义了一组参数。这个测试的每个参数都是一个单独的列表,包含输入和期望的输出。当 JUnit 运行这个测试时,它将为每个参数运行一个新的实例,然后执行测试方法。这将产生五个成功和一个失败的结果,当我们测试 0! 时会失败,这意味着我们发现了一个错误。我们从未考虑 n = 0 的情况。现在,我们可以回到我们的代码来修复失败。我们可以通过用 fold 函数替换 reduce 函数来做到这一点,fold 函数允许我们指定初始值为 1

fun factorial(n: Int): Int {
        return IntArray(n) {
            it + 1
        }.fold(1, { acc, i -> acc * i })
    }

现在运行测试,它们都会通过。但这并不意味着我们在这里就完成了。有很多事情可能会出错。如果 n 是一个负数会发生什么?由于我们在处理阶乘,可能会得到非常大的数字。在我们的示例中,我们使用整数,这意味着整数在 12! 之后会溢出。通常情况下,我们会在 MyClassTest 类中创建新的测试方法,但由于使用了参数化运行器,我们所有的新方法都会运行多次,这将花费我们的时间,因此我们将创建一个新的测试类来检查我们的错误:

class MyClassTest2 {
    private val myClass = MyClass()
    @Test(expected = MyClass.FactorialNotFoundException::class)
    fun computeNegatives() {
        myClass.factorial(-10)
    }
}

这将导致被测试的类发生以下变化。

class MyClass {
    @Throws(FactorialNotFoundException::class)
    fun factorial(n: Int): Int {
        if (n < 0) {
            throw FactorialNotFoundException
        }
        return IntArray(n) {
            it + 1
        }.fold(1, { acc, i -> acc * i })
    }
    object FactorialNotFoundException : Throwable()
}

让我们解决非常大的阶乘的问题。我们可以使用 BigInteger 类,它能够容纳大数字。我们可以更新测试如下(未显示导入语句):

@RunWith(Parameterized::class)
class MyClassTest(
    private val input: Int,
    private val expected: BigInteger
) {
    companion object {
        @Parameterized.Parameters
        @JvmStatic
        fun getData(): Collection<Array<Any>> = listOf(
            arrayOf(0, BigInteger.ONE),
            arrayOf(1, BigInteger.ONE),
            arrayOf(2, BigInteger.valueOf(2)),
            arrayOf(3, BigInteger.valueOf(6)),
            arrayOf(4, BigInteger.valueOf(24)),
            arrayOf(5, BigInteger.valueOf(120)),
            arrayOf(13, BigInteger("6227020800")),
            arrayOf(25, BigInteger("15511210043330985984000000"))
        )
    }
    private val myClass = MyClass()
    @Test
    fun computesFactorial() {
        val result = myClass.factorial(input)
        assertEquals(expected, result)
    }
}

现在被测试的类看起来像这样:

    @Throws(FactorialNotFoundException::class)
    fun factorial(n: Int): BigInteger {
        if (n < 0) {
            throw FactorialNotFoundException
        }
        return IntArray(n) {
            it + 1
        }.fold(BigInteger.ONE, { acc, i -> acc * i.toBigInteger() })
    }

在前面的示例中,我们使用 IntArray 实现了阶乘。这个实现更多地基于 Kotlin 能够将方法链接在一起的能力,但它有一个缺点:当不需要时它使用了数组的内存。我们只关心阶乘,而不是存储从 1 到 n 的所有数字。我们可以将实现更改为一个简单的 for 循环,并在重构过程中使用测试来指导我们。在您的应用程序中有测试的两个好处可以在这里观察到:

  • 它们作为更新后的文档,说明了特性应该如何实现。

  • Android Studio 测试技巧

让我们更新代码,摆脱IntArray

    @Throws(FactorialNotFoundException::class)
    fun factorial(n: Int): BigInteger {
        if (n < 0) {
            throw FactorialNotFoundException
        }
        var result = BigInteger.ONE
        for (i in 1..n){
            result = result.times(i.toBigInteger())
        }
        return result
    }

如果我们修改factorial函数,如前面的示例所示,并运行测试,我们应该看到它们都通过了。

在某些情况下,您的测试将使用测试或应用程序常见的资源(数据库、文件等)。理想情况下,这不应该发生在单元测试中,但总会有例外。让我们分析一下这种情况,看看 JUnit 如何帮助我们。我们将添加一个companion对象,它将存储结果,以模拟这种行为:

    companion object {
        var result: BigInteger = BigInteger.ONE
    }
    @Throws(FactorialNotFoundException::class)
    fun factorial(n: Int): BigInteger {
        if (n < 0) {
            throw FactorialNotFoundException
        }
        for (i in 1..n) {
            result = result.times(i.toBigInteger())
        }
        return result
    }

它们在重构代码时通过保持相同的断言并检测新的代码更改是否破坏了它来指导我们。

    @Before
    fun setUp(){
        MyClass.result = BigInteger.ONE
    }
    @After
    fun tearDown(){
        MyClass.result = BigInteger.ONE
    }
    @Test
    fun computesFactorial() {
        val result = myClass.factorial(input)
        assertEquals(expected, result)
    }

在测试中,我们添加了两个带有@Before@After注解的方法。当引入这些方法时,JUnit 将按以下方式更改执行流程:所有带有@Before注解的方法将被执行,将执行带有@Test注解的方法,然后将执行所有带有@After注解的方法。这个过程将对类中的每个@Test方法重复执行。

如果您发现自己在@Before方法中重复相同的语句,可以考虑使用@Rule来消除重复。我们可以为前面的示例设置一个测试规则。测试规则应该在testandroidTest包中,因为它们的使用仅限于测试。它们往往用于多个测试中,因此可以将规则放在rules包中(未显示导入语句):

class ResultRule : TestRule {
    override fun apply(
        base: Statement,
        description: Description?
    ): Statement? {
        return object : Statement() {
            @Throws(Throwable::class)
            override fun evaluate() {
                MyClass.result = BigInteger.ONE
                try {
                    base.evaluate()
                } finally {
                    MyClass.result = BigInteger.ONE
                }
            }
        }
    }
}

如果我们执行前面代码的测试,将开始看到一些测试失败。这是因为在第一个测试执行factorial函数后,结果将具有执行测试的值,当执行新测试时,阶乘的结果将乘以结果的先前值。通常,这是好的,因为测试告诉我们我们做错了什么,我们应该纠正这个问题,但是对于这个示例,我们将直接在测试中解决这个问题:

    @JvmField
    @Rule
    val resultRule = ResultRule()
    private val myClass = MyClass()
    @Test
    fun computesFactorial() {
        val result = myClass.factorial(input)
        assertEquals(expected, result)
    }

为了将规则添加到测试中,我们使用@Rule注解。由于测试是用 Kotlin 编写的,我们使用@JvmField来避免生成 getter 和 setter,因为@Rule需要一个公共字段而不是方法。

![图 9.2:Android Studio 中的测试输出

在前面的示例中,我们可以看到规则将实现TestRule,而TestRule又带有apply()方法。然后我们创建一个新的Statement对象,它将执行基本语句(测试本身)并在语句之前和之后重置结果的值。现在我们可以修改测试如下:Android Studio 提供了一套很好的快捷方式和可视化工具来帮助测试。如果要为类创建新的测试或转到类的现有测试,可以使用Ctrl + Shift + T(Windows)或Command + Shift + T(Mac)快捷键。要运行测试,有多种选项:右键单击文件或包,然后选择Run Tests in...选项,或者如果要独立运行测试,可以转到特定的测试方法并选择顶部的绿色图标,这将执行类中的所有测试;或者,对于单个测试,可以单击@Test注解方法旁边的绿色图标。这将触发测试执行,显示在Run选项卡中,如下截图所示。测试完成后,它们将变成红色或绿色,取决于它们的成功状态:Android Studio 提供了一套很好的快捷方式和可视化工具来帮助测试。如果要为类创建新的测试或转到类的现有测试,可以使用Ctrl + Shift + T(Windows)或Command + Shift + T(Mac)快捷键。要运行测试,有多种选项:右键单击文件或包,然后选择Run Tests in...选项,或者如果要独立运行测试,可以转到特定的测试方法并选择顶部的绿色图标,这将执行类中的所有测试;或者,对于单个测试,可以单击@Test注解方法旁边的绿色图标。这将触发测试执行,显示在Run选项卡中,如下截图所示。测试完成后,它们将变成红色或绿色,取决于它们的成功状态:图 9.2:Android Studio 中的测试输出在测试中可以找到的另一个重要功能是调试功能。这很重要,因为您可以调试测试和被测试的方法,所以如果在修复问题时遇到问题,您可以使用此功能查看测试使用的输入以及代码如何处理输入。您可以在测试旁边的绿色图标中找到的第三个功能是Run With Coverage选项。这有助于开发人员确定测试覆盖的代码行以及跳过的代码行。覆盖率越高,发现崩溃和错误的机会就越大:图 9.3:Android Studio 中的测试覆盖率

图 9.3:Android Studio 中的测试覆盖率

在上图中,您可以看到我们的类的覆盖范围,其中包括被测试的类的数量、被测试的方法的数量和被测试的行数。

另一种运行 Android 应用程序测试的方法是通过命令行。这通常在您的项目中有Terminal选项卡的情况下非常方便。Terminal选项卡通常位于 Android Studio 底部栏附近的Logcat选项卡附近。在每个 Android Studio 项目中,都有一个名为gradlew的文件。这是一个可执行文件,允许开发人员执行 Gradle 命令。为了运行本地单元测试,您可以使用以下命令:

  • gradlew.bat test(适用于 Windows)

  • ./gradlew test(适用于 Mac 和 Linux)

执行该命令后,应用程序将被构建和测试。您可以在 Android Studio 右侧的Gradle选项卡中找到可以在Terminal中输入的各种命令。从TerminalGradle选项卡执行时,测试的输出可以在app/build/reports文件夹中找到:

图 9.4:Android Studio 中的 Gradle 命令

图 9.4:Android Studio 中的 Gradle 命令

Mockito

在前面的示例中,我们看了如何设置单元测试以及如何使用断言来验证操作的结果。如果我们想要验证某个方法是否被调用呢?或者如果我们想要操纵测试输入以测试特定情景呢?在这些情况下,我们可以使用Mockito。这是一个帮助开发人员设置虚拟对象的库,可以将其注入到被测试的对象中,并允许它们验证方法调用、设置输入,甚至监视测试对象本身。

该库应该添加到您的test Gradle 设置中,如下所示:

testImplementation 'org.mockito:mockito-core:3.6.0'

现在,让我们看一下以下代码示例(请注意,为简洁起见,以下代码片段中的导入语句已被删除):

class StringConcatenator(private val context: Context) {
    fun concatenate(@StringRes stringRes1: Int, 
      @StringRes stringRes2: Int): String {
      return context.getString(stringRes1).plus(context
          .getString(stringRes2))
    }
}

在这里,我们有Context对象,通常无法进行单元测试,因为它是 Android 框架的一部分。我们可以使用mockito创建一个测试替身,并将其注入到StringConcatenator对象中。然后,我们可以操纵对getString()的调用,以返回我们选择的任何输入。这个过程被称为模拟。

class StringConcatenatorTest {
    private val context = Mockito.mock(Context::class.java)
    private val stringConcatenator = StringConcatenator(context)
    @Test
    fun concatenate() {
        val stringRes1 = 1
        val stringRes2 = 2
        val string1 = "string1"
        val string2 = "string2"
        Mockito.`when`(context.getString(stringRes1)).thenReturn(string1)
        Mockito.`when`(context.getString(stringRes2)).thenReturn(string2)
        val result = stringConcatenator.concatenate(stringRes1,
            stringRes2)
        assertEquals(string1.plus(string2), result)
    }
}

注意

`是 Kotlin 中的转义字符,不应与引号混淆。它允许开发人员为方法设置任何他们想要的名称,包括特殊字符或保留字。

在测试中,我们已经创建了一个mock上下文。当测试concatenate方法时,我们使用 Mockito 在调用特定输入的getString()方法时返回一个特定的字符串。这样我们就可以断言最终的结果。

Mockito 不仅限于仅模拟 Android Framework 类。我们可以创建一个SpecificStringConcatenator类,它将使用StringConcatenatorstrings.xml中连接两个特定的字符串:

class SpecificStringConcatenator(private val stringConcatenator:   StringConcatenator) {
    fun concatenateSpecificStrings(): String {
        return stringConcatenator.concatenate(R.string.string_1,           R.string.string_2)
    }
}

我们可以这样为它编写测试:

class SpecificStringConcatenatorTest {
    private val stringConcatenator = Mockito
      .mock(StringConcatenator::class.java)
    private val specificStringConcatenator = 
      SpecificStringConcatenator(stringConcatenator)
    @Test
    fun concatenateSpecificStrings() {
        val expected = "expected"
        Mockito.'when'(stringConcatenator.concatenate(R.string.string_1, 
          R.string.string_2))
            .thenReturn(expected)
        val result = specificStringConcatenator
          .concatenateSpecificStrings()
        assertEquals(expected, result)
    }
}

在这里,我们模拟了先前的StringConcatenator并指示模拟返回一个特定的结果。如果我们运行测试,它将失败,因为 Mockito 仅限于模拟最终类。在这里,它遇到了与 Kotlin 冲突的问题,除非我们将类指定为open,否则所有类默认都是final。幸运的是,我们可以应用一个配置来解决这个问题,而不必使受测试的类为open

  1. test包中创建一个名为resources的文件夹。

  2. resources文件夹中,创建一个名为mockito-extensions的文件夹。

  3. mockito-extensions文件夹中,创建一个名为org.mockito.plugins.MockMaker的文件。

  4. 在文件中,添加以下行:

    mock-maker-inline
    ```

在无法使用 JUnit 断言的回调或异步工作的情况下,可以使用`mockito`来验证对回调或 lambdas 的调用:

```kt
class SpecificStringConcatenator(private val stringConcatenator:   StringConcatenator) {
    fun concatenateSpecificStrings(): String {
        return stringConcatenator.concatenate(R.string.string_1,           R.string.string_2)
    }
    fun concatenateWithCallback(callback: Callback) {
        callback.onStringReady(concatenateSpecificStrings())
    }
    interface Callback {
        fun onStringReady(input: String)
    }
}

在上面的例子中,我们添加了concatenateWithCallback方法,它将使用concatenateSpecificStrings方法的结果来调用回调。对这个方法的测试将如下所示:

    @Test
    fun concatenateWithCallback() {
        val expected = "expected"
        Mockito.`when`(stringConcatenator.concatenate(R.string.string_1,           R.string.string_2))
            .thenReturn(expected)
        val callback =           Mockito.mock(SpecificStringConcatenator.Callback::class.java)
        specificStringConcatenator.concatenateWithCallback(callback)
        Mockito.verify(callback).onStringReady(expected)
    }

这里,我们创建了一个模拟的Callback对象,然后可以验证最终的预期结果。请注意,我们不得不重复设置concatenateSpecificStrings方法来测试concatenateWithCallback方法。您不应该模拟正在测试的对象;然而,您可以使用spy来更改它们的行为。我们可以对stringConcatenator对象进行监视,以改变concatenateSpecificStrings方法的结果:

    @Test
    fun concatenateWithCallback() {
        val expected = "expected"
        val spy = Mockito.spy(specificStringConcatenator)
        Mockito.`when`(spy.concatenateSpecificStrings())          .thenReturn(expected)
        val callback           = Mockito.mock(SpecificStringConcatenator.Callback::class.java)
        specificStringConcatenator.concatenateWithCallback(callback)
        Mockito.verify(callback).onStringReady(expected)
    }

Mockito 还依赖于依赖注入来初始化类变量,并且有一个自定义构建 JUnit 测试运行器。这可以简化我们变量的初始化,如下所示:

@RunWith(MockitoJUnitRunner::class)
class SpecificStringConcatenatorTest {
    @Mock
    lateinit var stringConcatenator: StringConcatenator
    @InjectMocks
    lateinit var specificStringConcatenator: SpecificStringConcatenator
}

在上面的例子中,MockitoRunner将使用@Mock注释的变量用模拟对象注入。接下来,它将创建一个新的非模拟实例字段,并用@InjectionMocks注释。当创建这个实例时,Mockito 将尝试注入符合该对象构造函数签名的模拟对象。

mockito-kotlin

您可能已经注意到,在前面的示例中,来自 Mockito 的when方法已经转义。这是因为与 Kotlin 编程语言冲突。Mockito 主要是为 Java 构建的,当 Kotlin 创建时,它引入了这个关键字。类似这样的冲突可以使用`字符。这个,连同其他一些小问题,导致在 Kotlin 中使用 Mockito 时有些不便。引入了一些库来包装 Mockito 并在使用时提供更好的体验。其中之一是mockito-kotlin。您可以使用以下命令将此库添加到您的模块中:

testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"

这个库添加的一个明显的变化是用whenever替换when方法。另一个有用的变化是用泛型替换mock方法,而不是类对象。其余的语法与 Mockito 语法类似。

现在我们可以使用新的库更新之前的测试,从StringConcatenatorTest开始(为了简洁起见,导入语句已被删除):

class StringConcatenatorTest {
    private val context = mock<Context>()
    private val stringConcatenator = StringConcatenator(context)
    @Test
    fun concatenate() {
        val stringRes1 = 1
        val stringRes2 = 2
        val string1 = "string1"
        val string2 = "string2"
        whenever(context.getString(stringRes1)).thenReturn(string1)
        whenever(context.getString(stringRes2)).thenReturn(string2)
        val result =           stringConcatenator.concatenate(stringRes1, stringRes2)
        assertEquals(string1.plus(string2), result)
    }
}

正如你所看到的,`字符消失了,我们对Context对象的模拟初始化也简化了。我们可以对SpecificConcatenatorTest类应用同样的东西(为了简洁起见,已经删除了导入语句):

@RunWith(MockitoJUnitRunner::class)
class SpecificStringConcatenatorTest {
    @Mock
    lateinit var stringConcatenator: StringConcatenator
    @InjectMocks
    lateinit var specificStringConcatenator: SpecificStringConcatenator
    @Test
    fun concatenateSpecificStrings() {
        val expected = "expected"
        whenever(stringConcatenator.concatenate(R.string.string_1,           R.string.string_2))
            .thenReturn(expected)
        val result =           specificStringConcatenator.concatenateSpecificStrings()
        assertEquals(expected, result)
    }
    @Test
    fun concatenateWithCallback() {
        val expected = "expected"
        val spy = spy(specificStringConcatenator)
        whenever(spy.concatenateSpecificStrings()).thenReturn(expected)
        val callback = mock<SpecificStringConcatenator.Callback>()
        specificStringConcatenator.concatenateWithCallback(callback)
        verify(callback).onStringReady(expected)
    }
}

练习 9.01: 测试数字的总和

使用 JUnit、Mockito 和 mockito-kotlin 为下面的类编写一组测试,这些测试应该覆盖以下场景:

  • 断言01520Int.MAX_VALUE的值。

  • 断言负数的结果。

  • 修复代码,并用公式 n(n+1)/2* 替换数字的求和部分。

    在整个练习过程中,未显示导入语句。要查看完整的代码文件,请参考packt.live/35TW8JI

要测试的代码如下。

class NumberAdder {
    @Throws(InvalidNumberException::class)
    fun sum(n: Int, callback: (BigInteger) -> Unit) {
        if (n < 0) {
            throw InvalidNumberException
        }
        var result = BigInteger.ZERO
        for (i in 1..n){
          result = result.plus(i.toBigInteger())
        }
        callback(result)

    }
    object InvalidNumberException : Throwable()
}

执行以下步骤完成这个练习:

  1. 让我们确保必要的库被添加到app/build.gradle 文件中:
     testImplementation 'junit:junit:4.13.1'
     testImplementation 'org.mockito:mockito-core:3.6.0'
     testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
    ```

1.  创建一个名为`NumberAdder`的类,然后将上述代码复制到其中。

1.  将光标移动到新创建的类内部,然后使用 *Command* + *Shift* + *T* 或 *Ctrl* + *Shift* + *T* 创建一个名为`NumberAdderParameterTest`的测试类。

1.  在这个类内创建一个参数化测试,它将断言对`0`、`1`、`5`、`20`和`Int.MAX_VALUE`值的结果:

```kt
    @RunWith(Parameterized::class)
    class NumberAdderParameterTest(
        private val input: Int,
        private val expected: BigInteger
    ) {
        companion object {
            @Parameterized.Parameters
            @JvmStatic
            fun getData(): List<Array<out Any>> = listOf(
                arrayOf(0, BigInteger.ZERO),
                arrayOf(1, BigInteger.ONE),
                arrayOf(5, 15.toBigInteger()),
                arrayOf(20, 210.toBigInteger()),
                arrayOf(Int.MAX_VALUE, BigInteger("2305843008139952128"))
            )
        }
        private val numberAdder = NumberAdder()
        @Test
        fun sum() {
            val callback = mock<(BigInteger) -> Unit>()
            numberAdder.sum(input, callback)
            verify(callback).invoke(expected)
        }
    }
    ```

1.  创建一个专门处理负数抛出异常的测试类,名为`NumberAdderErrorHandlingTest`:

```kt
    @RunWith(MockitoJUnitRunner::class)
    class NumberAdderErrorHandlingTest {
        @InjectMocks
        lateinit var numberAdder: NumberAdder
        @Test(expected = NumberAdder.InvalidNumberException::class)
        fun sum() {
            val input = -1
            val callback = mock<(BigInteger) -> Unit>()
            numberAdder.sum(input, callback)
        }
    }
    ```

1.  由于 *1 + 2 + ...n = n * (n + 1) / 2*,我们可以在代码中使用这个公式,这将使方法的执行更快:

```kt
    class NumberAdder {
        @Throws(InvalidNumberException::class)
        fun sum(n: Int, callback: (BigInteger) -> Unit) {
            if (n < 0) {
                throw InvalidNumberException
            }
             callback(n.toBigInteger().times((n.toBigInteger() +            1.toBigInteger())).divide(2.toBigInteger()))
        }
        object InvalidNumberException : Throwable()
    }
    ```

通过右键单击测试所在的包并选择`Run all in [package_name]`来运行测试。将出现类似以下的输出,表示测试已通过:

![图 9.5: 练习 9.01 的输出](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_09_05.jpg)

图 9.5: 练习 9.01 的输出

通过完成这个练习,我们已经迈出了单元测试的第一步,成功为一个操作创建了多个测试用例,初步了解了 Mockito,并通过测试指导我们如何重构代码,而不引入新问题。

# 集成测试

假设您的项目已通过单元测试覆盖了大部分逻辑。现在您需要将这些经过测试的类添加到活动或片段中,并要求它们更新您的 UI。您如何确定这些类能够良好地协同工作?这个问题的答案就在于集成测试。这种测试的理念是确保应用程序内的不同组件能够良好地集成在一起。一些示例包括如下内容:

+   确保与您的存储组件良好地解析数据并进行良好的交互。  

+   存储组件能够正确地存储和检索数据。  

+   UI 组件加载和显示适当的数据。  

+   应用程序中不同屏幕之间的过渡。  

为了帮助集成测试,有时会以“给定 - 当 - 然后”格式编写要求。这些通常代表用户故事的验收标准。看下面的例子:  

```kt
Given I am not logged in
And I open the application
When I enter my credentials
And click Login
Then I see the Main screen

我们可以使用这些步骤来解决如何为正在开发的功能编写集成测试。

在 Android 平台上,可以通过两个库实现集成测试:

  • Robolectric:此库使开发人员能够对 Android 组件进行单元测试;也就是在没有实际设备或模拟器的情况下执行集成测试。

  • Espresso:这个库对于在 Android 设备或模拟器上进行仪器测试非常有用。

我们将在下一节详细研究这些库。

Robolectric

Robolectric 最初是一个开源库,旨在让用户能够在本地测试中单元测试 Android 框架的类,而不是仪器测试的一部分。最近,它得到了 Google 的认可,并已与 AndroidX Jetpack 组件集成。该库的主要好处之一是简化了活动和碎片的测试。这在集成测试时也很有用,因为我们可以使用此功能确保我们的组件互相良好集成。一些 Robolectric 的特点如下:

  • 实例化和测试活动和碎片生命周期的可能性

  • 测试视图膨胀的可能性

  • 为不同的 Android API、方向、屏幕大小、布局方向等提供配置的可能性

  • 改变Application类的可能性,从而有助于更改模块以允许插入数据模拟

为了添加 Robolectric 以及 AndroidX 集成,我们需要以下库:

    testImplementation 'org.robolectric:robolectric:4.3'
    testImplementation 'androidx.test.ext:junit:1.1.1'

第二个库将带来一组测试 Android 组件所需的utility方法和类。

假设我们必须交付一个功能,其中我们显示文本Result x,其中x是用户将在EditText元素中插入的数字的阶乘函数。为了实现这一点,我们有两个类,一个计算阶乘,另一个是如果数字是正数,则将单词Result与阶乘连接起来,如果数字是负数,则返回文本Error。阶乘类将如下所示(在此示例中,为简洁起见,省略了导入语句):

class FactorialGenerator {
    @Throws(FactorialNotFoundException::class)
    fun factorial(n: Int): BigInteger {
        if (n < 0) {
            throw FactorialNotFoundException
        }
        var result = BigInteger.ONE
        for (i in 1..n) {
            result = result.times(i.toBigInteger())
        }
        return result
    }
    object FactorialNotFoundException : Throwable()
}

TextFormatter类将如下所示:

class TextFormatter(
    private val factorialGenerator: FactorialGenerator,
    private val context: Context
) {
    fun getFactorialResult(n: Int): String {
        return try {
            context.getString(R.string.result,               factorialGenerator.factorial(n).toString())
        } catch (e: FactorialGenerator.FactorialNotFoundException) {
            context.getString(R.string.error)
        }
    }
}

我们可以在我们的活动中组合这两个组件,类似于这样:

class MainActivity : AppCompatActivity() {
    private lateinit var textFormatter: TextFormatter
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        ttextFormatter = TextFormatter(FactorialGenerator(), 
          applicationContext)
        findViewById<Button>(R.id.button).setOnClickListener {
            findViewById<TextView>(R.id.text_view).text               = textFormatter.getFactorialResult(findViewById<EditText>                (R.id.edit_text).text.toString().toInt())
        }
    }
}

在这种情况下,我们可以观察到三个组件相互作用。我们可以使用 Robolectric 来测试我们的 activity。通过测试创建组件的 activity,我们还可以测试这三个组件之间的交互。我们可以编写如下的测试:

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    private val context = getApplicationContext<Application>()
    @Test
    fun `show factorial result in text view`() {
        val scenario = launch<MainActivity>(MainActivity::class.java)
        scenario.moveToState(Lifecycle.State.RESUMED)
        scenario.onActivity { activity ->
            activity.edit_text.setText(5.toString())
            activity.button.performClick()
            assertEquals(context.getString(R.string.result,               "120"),activity.text_view.text)
        }
    }
}

在前面的例子中,我们可以看到 AndroidX 对 activity 测试的支持。AndroidJUnit4 测试运行器将设置 Robolectric 并创建必要的配置,而 launch 方法将返回一个 scenario 对象,我们可以通过这个对象来实现测试所需的条件。

如果我们想为测试添加配置,可以在类和每个测试方法上使用 @Config 注释:

@Config(
    sdk = [Build.VERSION_CODES.P],
    minSdk = Build.VERSION_CODES.KITKAT,
    maxSdk = Build.VERSION_CODES.Q,
    application = Application::class,
    assetDir = "/assetDir/"
)
@RunWith(AndroidJUnit4::class)
class MainActivityTest 

我们也可以在 test/resources 文件夹中的 robolectric.properties 文件中指定全局配置,就像这样:

sdk=28
minSdk = 14
maxSdk = 29

最近添加到 Robolectric 的另一个重要功能是对 Espresso 库的支持。这允许开发人员使用 Espresso 的语法与视图进行交互并对视图进行断言。可以与 Robolectric 结合使用的另一个库是 FragmentScenario,它允许测试片段的可能性。可以通过以下方式在 Gradle 中添加这些库:

    testImplementation 'androidx.fragment:fragment-testing:1.1.0'
    testImplementation 'androidx.test.espresso:espresso-core:3.2.0'

使用 scenario 设置测试片段与使用 scenario 设置测试活动类似:

val scenario = launchFragmentInContainer<MainFragment>()
scenario.moveToState(Lifecycle.State.CREATED)

Espresso

Espresso 是一个旨在以简洁方式执行交互和断言的库。最初设计为用于仪表化测试,现在已迁移到与 Robolectric 一起使用。执行操作的典型用法如下:

onView(Matcher<View>).perform(ViewAction)

用于验证,我们可以使用以下内容:

onView(Matcher<View>).check(ViewAssertion)

如果在 ViewMatchers 类中找不到自定义的 ViewMatchers,我们可以自定义。其中最常见的是 withIdwithText。这两个允许我们基于它们的 R.id.myId 标识符或文本标识符识别视图。理想情况下,第一个应该用于识别特定的视图。Espresso 的另一个有趣之处是依赖于 Hamcrest 库进行匹配。这是一个旨在改进测试的 Java 库。如果必要,这允许组合多个匹配器。比如说,您的 UI 上具有相同的 ID,可以使用以下表达式缩小搜索范围以找到特定视图:

onView(allOf(withId(R.id.edit_text), withParent(withId(R.id.root))))

allOf 表达式将评估所有其他操作符,并且只有在内部所有操作符都通过时才会通过。前述的表达式将转换为“找到具有 id=edit_text 的父视图为 id=R.id.root 的视图。” 其他 Hamcrest 操作符可能包括 anyOf, both, either, is, isA, hasItem, equalTo, any, instanceOf, not, nullnotNull

ViewActionsViewMatchers 有类似的方法。我们可以在 ViewActions 类中找到常见的方法。常见的包括 typeTextclickscrollToclearTextswipeLeftswipeRightswipeUpswipeDowncloseSoftKeyboardpressBackpressKeydoubleClicklongClick。如果您有自定义视图并且需要特定操作,则可以通过实现 ViewAction 接口来实现自己的 ViewAction 元素。

与前面的示例类似,ViewAssertions 有自己的类。通常,可以使用 matches 方法,然后使用 ViewMatchersHamcrest 匹配器来验证结果:

onView(withId(R.id.text_view)).check(matches(withText("My text")))) 

前面的示例将验证具有 text_view ID 的视图中是否包含文本 My text

onView(withId(R.id.button)).perform(click())

这将点击具有 ID 按钮的视图。

现在我们可以重写 Robolectric 测试并添加 Espresso,这将给我们带来以下内容(未显示导入语句):

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    @Test
    fun `show factorial result in text view`() {
        val scenario = launch<MainActivity>(MainActivity::class.java)
        scenario.moveToState(Lifecycle.State.RESUMED)
        scenario.onActivity { activity ->
            onView(withId(R.id.edit_text)).perform(typeText("5"))
            onView(withId(R.id.button)).perform(click())
            onView(withId(R.id.text_view))
              .check(matches(withText(activity                 .getString(R.string.result, "120"))))
        }
    }
}

在前面的代码示例中,我们可以观察到如何使用 Espresso 输入数字 5EditText 中,然后点击按钮,然后使用 onView() 方法获取到 TextView 中显示的文本,并使用 perform() 执行操作或使用 check() 进行断言。

注意

对于以下练习,您需要一个启用了 USB 调试的模拟器或实际设备。您可以通过在 Android Studio 中选择 工具 | AVD 管理器 来创建一个,然后选择 创建虚拟设备 选项,并选择虚拟机类型,点击 下一步,然后选择 x86 映像。对于此练习来说,任何大于 Lollipop 的映像都应该可以。接下来,您可以给您的映像命名并单击 完成

练习 9.02:双重积分

开发一个应用程序,观察以下要求:

Given I open the application
And I insert the number n
When I press the Calculate button
Then I should see the text "The sum of numbers from 1 to n is [result]"
Given I open the application
And I insert the number -n
When I press the Calculate button
Then I should see the text "Error: Invalid number"

您应该使用 Robolectric 和 Espresso 实现单元测试和集成测试,并将集成测试迁移到成为仪器化测试。

注意

在整个练习的过程中,我们没有显示导入语句。要查看完整的代码文件,请参考packt.live/2M1MtcY

实施以下步骤来完成此练习:

  1. 让我们首先将必要的测试库添加到 app/build.gradle 中:
        testImplementation 'junit:junit:4.13.1'
        testImplementation 'org.mockito:mockito-core:3.6.0'
        testImplementation 'com.nhaarman.mockitokotlin2
          :mockito-kotlin:2.2.0'
        testImplementation 'org.robolectric:robolectric:4.4'
        testImplementation 'androidx.test.ext:junit:1.1.2'
        testImplementation 'androidx.test.espresso:espresso-core:3.3.0'
        androidTestImplementation 'androidx.test.ext:junit:1.1.2'
        androidTestImplementation 'androidx.test
          .espresso:espresso-core:3.3.0'
        androidTestImplementation 'androidx.test:rules:1.3.0'
    ```

1.  对于 Robolectric,我们需要添加额外的配置,首先在 `android` 闭包中的 `app/build.gradle` 中添加以下行:

```kt
    testOptions.unitTests.includeAndroidResources = true
    ```

1.  在 `test` 包中创建一个名为 `resources` 的目录。

1.  添加 `robolectric.properties` 文件,并在该文件中添加以下配置:

```kt
    sdk=28
    ```

1.  在 `test` 包中创建一个名为 `resources` 的文件夹。

1.  在 `resources` 中,创建一个名为 `mockito-extensions` 的文件夹。

1.  在 `mockito-extensions` 文件夹中,创建一个名为 `org.mockito.plugins.MockMaker` 的文件,并在文件中添加以下行:

```kt
    mock-maker-inline
    ```

1.  创建 `NumberAdder` 类。这与 *练习 9.01* 中的类似:

```kt
    import java.math.BigInteger
    class NumberAdder {
        @Throws(InvalidNumberException::class)
        fun sum(n: Int, callback: (BigInteger) -> Unit) {
            if (n < 0) {
                throw InvalidNumberException
            }
            callback(n.toBigInteger().times((n.toLong()           + 1).toBigInteger()).divide(2.toBigInteger()))
        }
        object InvalidNumberException : Throwable()
    }
    ```

1.  在 `test` 文件夹中为 `NumberAdder` 创建测试。首先,创建 `NumberAdderParameterTest`:

```kt
    @RunWith(Parameterized::class)
    class NumberAdderParameterTest(
        private val input: Int,
        private val expected: BigInteger
    ) {
        companion object {
            @Parameterized.Parameters
            @JvmStatic
            fun getData(): List<Array<out Any>> = listOf(
                arrayOf(0, BigInteger.ZERO),
                arrayOf(1, BigInteger.ONE),
                arrayOf(5, 15.toBigInteger()),
                arrayOf(20, 210.toBigInteger()),
                arrayOf(Int.MAX_VALUE, BigInteger("2305843008139952128"))
            )
        }
        private val numberAdder = NumberAdder()
        @Test
        fun sum() {
            val callback = mock<(BigInteger) -> Unit>()
            numberAdder.sum(input, callback)
            verify(callback).invoke(expected)
        }
    }
    ```

1.  然后,创建`NumberAdderErrorHandlingTest`测试:

```kt
    @RunWith(MockitoJUnitRunner::class)
    class NumberAdderErrorHandlingTest {
        @InjectMocks
        lateinit var numberAdder: NumberAdder
        @Test(expected = NumberAdder.InvalidNumberException::class)
        fun sum() {
            val input = -1
            val callback = mock<(BigInteger) -> Unit>()
            numberAdder.sum(input, callback)
        }
    }
    ```

1.  创建一个将总和格式化并与必要的字符串连接的类:

```kt
    class TextFormatter(
        private val numberAdder: NumberAdder,
        private val context: Context
    ) {
        fun getSumResult(n: Int, callback: (String) -> Unit) {
            try {
                numberAdder.sum(n) {
                    callback(
                        context.getString(
                            R.string.the_sum_of_numbers_from_1_to_is,
                            n,
                            it.toString()
                        )
                    )
                }
            } catch (e: NumberAdder.InvalidNumberException) {
                callback(context.getString
                  (R.string.error_invalid_number))
            }
        }
    }
    ```

1.  为这个类的成功和错误情况进行单元测试。从成功场景开始:

```kt
    @RunWith(MockitoJUnitRunner::class)
    class TextFormatterTest {
        @InjectMocks
        lateinit var textFormatter: TextFormatter
        @Mock
        lateinit var numberAdder: NumberAdder
        @Mock
        lateinit var context: Context
        @Test
        fun getSumResult_success() {
            val n = 10
            val sumResult = BigInteger.TEN
            val expected = "expected"
            whenever(numberAdder.sum(eq(n), any())).thenAnswer {
                (it.arguments[1] as (BigInteger)->Unit)
                    .invoke(sumResult)
            }
            whenever(context.getString
              (R.string.the_sum_of_numbers_from_1_to_is, n, 
                sumResult.toString())).thenReturn(expected)
            val callback = mock<(String)->Unit>()
            textFormatter.getSumResult(n, callback)
            verify(callback).invoke(expected)
        }
    ```

然后,为错误场景创建测试:

```kt
        @Test
        fun getSumResult_error() {
            val n = 10
            val expected = "expected"
            whenever(numberAdder.sum(eq(n),           any())).thenThrow(NumberAdder.InvalidNumberException)
            whenever(context.getString(R.string.error_invalid_number))          .thenReturn(expected)
            val callback = mock<(String)->Unit>()
            textFormatter.getSumResult(n, callback)
            verify(callback).invoke(expected)
        }
    }
    ```

1.  为`activity_main.xml`创建布局:

```kt
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/root"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        <EditText
            android:id="@+id/edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="number" />
        <Button
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:text="@string/calculate" />
        <TextView
            android:id="@+id/text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal" />
    </LinearLayout>
    ```

1.  创建包含所有其他组件的`MainActivity`类:

```kt
    class MainActivity : AppCompatActivity() {
        private lateinit var textFormatter: TextFormatter

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            textFormatter = TextFormatter(NumberAdder(), applicationContext)
            findViewById<Button>(R.id.button).setOnClickListener {
                textFormatter.getSumResult(findViewById<EditText>              (R.id.edit_text).text.toString().toIntOrNull() ?: 0) {
                    findViewById<TextView>(R.id.text_view).text = it
                }
            }
        }
    }
    ```

1.  创建`MainActivity`的测试,并将其放在`test`目录中。它将包含两个测试方法,一个用于成功,一个用于错误:

```kt
    @RunWith(AndroidJUnit4::class)
    class MainActivityTest {
        @Test
        fun `show sum result in text view`() {
            val scenario = launch<MainActivity>(MainActivity::class.java)
            scenario.moveToState(Lifecycle.State.RESUMED)
            scenario.onActivity { activity ->
                onView(withId(R.id.edit_text)).perform(replaceText("5"))
                onView(withId(R.id.button)).perform(click())
                onView(withId(R.id.text_view)).check(matches(withText
                 (activity.getString
                   (R.string.the_sum_of_numbers_from_1_to_is, 5, "15"))))
            }
        }
        @Test
        fun `show error in text view`() {
            val scenario = launch<MainActivity>(MainActivity::class.java)
            scenario.moveToState(Lifecycle.State.RESUMED)
            scenario.onActivity { activity ->
                onView(withId(R.id.edit_text))
                    .perform(replaceText("-5"))
                onView(withId(R.id.button)).perform(click())
                onView(withId(R.id.text_view)).check(
                    matches(withText(activity.getString(
                    R.string.error_invalid_number))))
            }
        }
    }
    ```

如果你通过右键单击包含测试的包并选择“在[package_name]中全部运行”来运行测试,那么会出现类似以下的输出:

![图 9.6:执行 Exercise 9.02 test 文件夹中的测试的结果](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_09_06.jpg)

图 9.6:执行 Exercise 9.02 test 文件夹中的测试的结果

如果你执行前面的测试,你应该会看到类似*图 9.6*的输出。Robolectric 测试的执行方式与常规单元测试相同;但是,执行时间会增加。

1.  现在让我们将前面的测试迁移到一个有仪器的集成测试中。为了做到这一点,我们将把前面的测试从`test`包复制到`androidTest`包,并删除我们的测试中与场景相关的代码。复制文件之后,我们将使用`ActivityTestRule`,它将在每次测试执行之前启动我们的活动。我们还需要重命名类以避免重复,并且重命名测试方法,因为这种语法不支持仪器化测试:

```kt
    @RunWith(AndroidJUnit4::class)
    class MainActivityUiTest {
        @JvmField
        @Rule
        var activityRule: ActivityTestRule<MainActivity> = 
          ActivityTestRule(MainActivity::class.java)
        @Test
        fun showSumResultInTextView() {
            activityRule.activity.let { activity ->
                onView(withId(R.id.edit_text)).perform(replaceText("5"))
                onView(withId(R.id.button)).perform(click())
                onView(withId(R.id.text_view)).check(matches
                 (withText(activity.getString
                  (R.string.the_sum_of_numbers_from_1_to_is, 5, "15"))))
            }
        }
        @Test
        fun showErrorInTextView() {
            activityRule.activity.let { activity ->
                onView(withId(R.id.edit_text)).perform(replaceText("-5"))
                onView(withId(R.id.button)).perform(click())
                onView(withId(R.id.text_view)).check(matches               (withText(activity.getString                 (R.string.error_invalid_number))))
            }
        }
    }
    ```

    如果你通过右键单击包含测试的包并选择“在[package_name]中全部运行”来运行测试,那么会出现类似以下的输出:

![图 9.7:执行 Exercise 9.02 androidTest 文件夹中的测试的结果](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_09_07.jpg)

图 9.7:执行 Exercise 9.02 androidTest 文件夹中的测试的结果

在*图 9.7*中,我们可以看到 Android Studio 显示的结果输出。如果你在测试执行时仔细观察仿真器,你会发现对于每个测试,你的活动都会被打开,输入会被设置在字段中,并且按钮会被点击。我们的集成测试(在工作站和仿真器上)都尝试匹配需求的接受标准。集成测试验证相同的行为,唯一的区别是一个在本地检查,另一个在 Android 设备或仿真器上检查。这里的主要好处是 Espresso 能够弥合它们之间的鸿沟,使得集成测试更容易设置和执行。

# UI 测试

UI 测试是开发人员可以模拟用户行程并验证应用程序不同模块之间的交互的测试,也被称为端到端测试。对于小型应用程序,您可以拥有一个测试套件,但对于较大的应用程序,您应该分割您的测试套件以覆盖特定的用户行程(登录,创建账户,设置流程等)。因为它们在设备上执行,所以您需要在`androidTest`包中编写它们,这意味着它们将使用**Instrumentation**框架来运行。Instrumentation 的工作方式如下:

+   应用程序已构建并安装在设备上。

+   将在设备上安装一个测试应用程序,用于监视您的应用程序。

+   测试应用程序将执行对您的应用程序的测试并记录结果。

其中一个缺点是测试将共享持久化数据,因此如果一个测试在设备上存储数据,那么第二个测试可以访问该数据,这意味着存在失败的风险。另一个缺点是,如果测试遭遇崩溃,这将停止整个测试,因为被测试的应用程序已经停止。在 Jetpack 更新中引入了`app/build.gradle`文件来解决这些问题:

```kt
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner           "androidx.test.runner.AndroidJUnitRunner"
        testInstrumentationRunnerArguments clearPackageData: 'true'
    }
    testOptions {
        execution 'ANDROIDX_TEST_ORCHESTRATOR'
    }
}
dependencies {
    ...
    androidTestUtil 'androidx.test:orchestrator:1.3.0'
}

您可以使用 Gradle 的connectedCheck命令在连接的设备上执行协调器测试,无论是从Terminal还是从 Gradle 命令列表中。

在配置中,您将注意到以下行:testInstrumentationRunner。这使我们能够为测试创建自定义配置,这给了我们机会将模拟数据注入到模块中:

testInstrumentationRunner "com.android.CustomTestRunner"

CustomTestRunner如下(以下代码段未显示导入语句):

class CustomTestRunner: AndroidJUnitRunner() {
    @Throws(Exception::class)
    override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application? {
        return super.newApplication(cl,           MyApplication::class.java.name, context)
    }
}

测试类本身可以通过应用 JUnit4 语法,并借助androidx.test.ext.junit.runners.AndroidJUnit4测试运行程序来编写:

@RunWith(AndroidJUnit4::class)
class MainActivityUiTest {
}

来自 AndroidX 测试支持的另一个重要功能是活动规则。当使用默认构造函数时,此规则将在每次测试之前启动活动,并准备好进行交互和断言:

    @JvmField
    @Rule
    var activityRule: ActivityTestRule<MainActivity>       = ActivityTestRule(MainActivity::class.java)

您还可以使用规则来避免启动活动,并自定义用于启动测试的意图:

    @JvmField
    @Rule
    var activityRule: ActivityTestRule<MainActivity> =       ActivityTestRule(MainActivity::class.java, false ,false)
    @Test
    fun myTestMethod() {
        val myIntent = Intent()
        activityRule.launchActivity(myIntent)
    }

@Test方法本身在专用测试线程中运行,这就是为什么类似 Espresso 这样的库很有用。Espresso 将自动将与 UI 线程上的视图的每次交互移动。Espresso 可以与 Robolectric 测试一样用于 UI 测试:

    @Test
    fun myTest() {
        onView(withId(R.id.edit_text)).perform(replaceText("5"))
        onView(withId(R.id.button)).perform(click())
        onView(withId(R.id.text_view))          .check(matches(withText("my test")))
    }

通常,在 UI 测试中,您将发现可能重复的交互和断言。为了避免在您的代码中重复多个场景,您可以应用一种称为Robot类的模式,其中交互和断言可以分组到特定方法中。您的测试代码将使用这些机器人并进行断言。典型的机器人看起来会像这样:

class MyScreenRobot {
    fun setText(): MyScreenRobot {
        onView(ViewMatchers.withId(R.id.edit_text))          .perform(ViewActions.replaceText("5"))
        return this
    }
    fun pressButton(): MyScreenRobot {
        onView(ViewMatchers.withId(R.id.button))          .perform(ViewActions.click())
        return this
    }
    fun assertText(): MyScreenRobot {
        onView(ViewMatchers.withId(R.id.text_view))          .check(ViewAssertions.matches(ViewMatchers           .withText("my test")))
        return this
    }
}

测试结果将如下所示:

    @Test
    fun myTest() {
       MyScreenRobot()
           .setText()
           .pressButton()
           .assertText()
    }

因为应用程序可能是多线程的,有时需要一段时间从各种来源(互联网、文件、本地存储等)加载数据,UI 测试将必须知道何时 UI 可用以进行交互。一个实现这一点的方法是通过使用空闲资源。这些是可以在测试之前向 Espresso 注册并注入到您的应用程序组件中的对象,在这些组件中进行多线程工作。应用程序将在工作进行中将它们标记为非空闲,并且当工作完成时为空闲。正是在这一点上,Espresso 将开始执行测试。其中最常用的之一是CountingIdlingResource。这个特定的实现使用一个计数器,在您希望 Espresso 等待您的代码完成执行时应该增加它,并在您希望让 Espresso 验证您的代码时减少它。当计数器达到0时,Espresso 将恢复测试。具有空闲资源的组件示例看起来像这样:

class MyHeavyliftingComponent(private val   countingIdlingResource:CountingIdlingResource) {
    fun doHeavyWork() {
        countingIdlingResource.increment()
        // do work
        countingIdlingResource.decrement()
    }
}

Application类可以用来注入空闲资源,就像这样:

class MyApplication : Application(){
    val countingIdlingResource = CountingIdlingResource("My heavy work")
    val myHeavyliftingComponent =       MyHeavyliftingComponent(countingIdlingResource)
}

然后,在测试中,我们可以访问Application类并将资源注册到 Espresso:

@RunWith(AndroidJUnit4::class)
class MyTest {
    @Before
    fun setUp() {
        val myApplication = getApplicationContext<MyApplication>()
        IdlingRegistry.getInstance()          .register(myApplication.countingIdlingResource)
    }
}

Espresso 配备了一组扩展,可用于断言不同的 Android 组件。其中一种扩展是意图测试。在想要单独测试活动时(更适用于集成测试)这将会很有用。为了使用它,您需要将该库添加到 Gradle 中:

androidTestImplementation 'androidx.test.espresso:espresso-intents:3.3.0'

添加完库后,您需要使用IntentsTestRule来设置必要的意图监控。该规则是ActivityTestRule的子类:

    @JvmField
    @Rule
    var intentsRule: IntentsTestRule<MainActivity>       = IntentsTestRule(MainActivity::class.java)

为了断言意图的值,您需要触发适当的操作,然后使用intended方法:

        onView(withId(R.id.button)).perform(click())
        intended(allOf(
            hasComponent(hasShortClassName(".MainActivity")),
            hasExtra(MainActivity.MY_EXTRA, "myExtraValue")))

intended方法的工作方式类似于onView方法。它需要一个可以与Hamcrest匹配器组合的匹配器。与 Intent 相关的匹配器可以在IntentMatchers类中找到。该类包含了断言Intent类的不同方法:extras、data、components、bundles 等等。

另一个重要的扩展库来帮助RecyclerView。Espresso 的onData方法只能测试AdapterViews,如ListView,而不能断言RecyclerView。为了使用该扩展,您需要向项目中添加以下库:

androidTestImplementation   'com.android.support.test.espresso:espresso-contrib:3.0.2'

该库提供了一个RecyclerViewActions类,其中包含一组方法,允许您对RecyclerView内的项目执行操作:

onView(withId(R.id.recycler_view))  .perform(RecyclerViewActions.actionOnItemAtPosition(0, click()))

前面的语句将点击位置为0的项目:

onView(withId(R.id.recycler_view)).perform(RecyclerViewActions   .scrollToPosition<RecyclerView.ViewHolder>(10))

这将滚动到列表中的第十个项目:

onView(withText("myText")).check(matches(isDisplayed()))

前面的代码将检查是否显示了带有myText文本的视图,这也适用于RecyclerView项。

练习 9.03:随机等待时间

编写一个应用程序,它将有两个屏幕。第一个屏幕将有一个按钮。当用户按下按钮时,它将等待 1 到 5 秒之间的随机时间,然后启动显示文本x 秒后打开的第二屏幕,其中x是经过的秒数。编写一个 UI 测试,以覆盖此场景,并调整以下特性以用于测试:

  • 当运行测试时,random函数将返回值1

  • CountingIdlingResource将用于指示计时器何时停止。

注意

在本练习中,未显示导入语句。要查看完整的代码文件,请参考packt.live/38V7krh

进行以下步骤来完成这个练习:

  1. 将以下库添加到app/build.gradle
        implementation 'androidx.test.espresso:espresso-core:3.3.0'
        testImplementation 'junit:junit:4.13.1'
        androidTestImplementation 'androidx.test.ext:junit:1.1.2'
        androidTestImplementation 'androidx.test:rules:1.3.0'
    ```

1.  然后,从`Randomizer`类开始:

```kt
    class Randomizer(private val random: Random) {
        fun getTimeToWait(): Int {
            return random.nextInt(5) + 1
        }
    }
    ```

1.  接下来,创建一个`Synchronizer`类,它将使用`Randomizer`和`Timer`等待随机时间间隔。它还将使用`CountingIdlingResource`来标记任务的开始和结束:

```kt
    class Synchronizer(
        private val randomizer: Randomizer,
        private val timer: Timer,
        private val countingIdlingResource: CountingIdlingResource
    ) {
        fun executeAfterDelay(callback: (Int) -> Unit) {
            val timeToWait = randomizer.getTimeToWait()
            countingIdlingResource.increment()
            timer.schedule(CallbackTask(callback, timeToWait),           timeToWait * 1000L)
        }
        inner class CallbackTask(
            private val callback: (Int) -> Unit,
            private val time: Int
        ) : TimerTask() {
            override fun run() {
                callback(time)
                countingIdlingResource.decrement()
            }
        }
    }
    ```

1.  现在创建一个`Application`类,负责创建前述所有类的实例:

```kt
    class MyApplication : Application() {
        val countingIdlingResource =       CountingIdlingResource("Timer resource")
        val randomizer = Randomizer(Random())
        val synchronizer = Synchronizer(randomizer, Timer(),       countingIdlingResource)
    }
    ```

1.  将`MyApplication`类添加到`AndroidManifest`中`application`标签中,带有`android:name`属性。

1.  创建一个`activity_1`布局文件,其中包含一个父布局和一个按钮:

```kt
    <?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/activity_1_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="@string/press_me" />
    </LinearLayout>
    ```

1.  创建一个包含父布局和`TextView`的`activity_2`布局文件:

```kt
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        <TextView
            android:id="@+id/activity_2_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center" />
    </LinearLayout>
    ```

1.  创建`Activity1`类,它将实现按钮点击的逻辑:

```kt
    class Activity1 : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_1)
            findViewById<Button>(R.id.activity_1_button)
              .setOnClickListener {
                (application as MyApplication).synchronizer
                  .executeAfterDelay {
                    startActivity(Activity2.newIntent(this, it))
                }
            }
        }
    }
    ```

1.  创建`Activity2`类,它将通过意图显示接收到的数据:

```kt
    class Activity2 : AppCompatActivity() {
        companion object {
            private const val EXTRA_SECONDS = "extra_seconds"
            fun newIntent(context: Context, seconds: Int) =
                Intent(context, Activity2::class.java).putExtra(
                    EXTRA_SECONDS, seconds
                )
        }
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_2)
            findViewById<TextView>(R.id.activity_2_text_view).text =
                getString(R.string.opened_after_x_seconds,               intent.getIntExtra(EXTRA_SECONDS, 0))
        }
    }
    ```

1.  在`androidTest`目录中创建一个`FlowTest`类,它将注册`MyApplication`对象的`IdlingResource`并断言点击的结果:

```kt
    @RunWith(AndroidJUnit4::class)
    @LargeTest
    class FlowTest {
        @JvmField
        @Rule
        var activityRule: ActivityTestRule<Activity1> =       ActivityTestRule(Activity1::class.java)
        private val myApplication = getApplicationContext<MyApplication>()
        @Before
        fun setUp() {
            IdlingRegistry.getInstance().register(myApplication           .countingIdlingResource)
        }
        @Test
        fun verifyFlow() {
            onView(withId(R.id.activity_1_button)).perform(click())
            onView(withId(R.id.activity_2_text_view))          .check(matches(withText(myApplication             .getString(R.string.opened_after_x_seconds, 1))))
        }
    }
    ```

1.  多次运行测试并检查测试结果。请注意,测试有 20%的成功机会,但它将等待按钮被点击。这意味着闲置资源正在工作。还要观察这里存在随机因素。

1.  测试不喜欢随机性,所以我们需要通过使`Randomizer`类为开放的,并在`androidTest`目录中创建一个子类来消除它。我们可以对`MyApplication`类做同样的事情,并提供一个称为`TestRandomizer`的不同随机器:

```kt
    class TestRandomizer(random: Random) : Randomizer(random) {
        override fun getTimeToWait(): Int {
            return 1
        }
    }
    ```

1.  现在,以一种我们可以从子类中重写随机器的方式修改`MyApplication`类:

```kt
    open class MyApplication : Application() {
        val countingIdlingResource =       CountingIdlingResource("Timer resource")
        lateinit var synchronizer: Synchronizer
        override fun onCreate() {
            super.onCreate()
            synchronizer = Synchronizer(createRandomizer(), Timer(),           countingIdlingResource)
        }
        open fun createRandomizer() = Randomizer(Random())
    }
    ```

1.  在`androidTest`目录中,创建`TestMyApplication`,它将扩展`MyApplication`并重写`createRandomizer`方法:

```kt
    class TestMyApplication : MyApplication() {
        override fun createRandomizer(): Randomizer {
            return TestRandomizer(Random())
        }
    }
    ```

1.  最后,创建一个仪表测试运行器,其将在测试内使用这个新的`Application`类:

```kt
    class MyApplicationTestRunner : AndroidJUnitRunner() {
        @Throws(Exception::class)
        override fun newApplication(
            cl: ClassLoader?,
            className: String?,
            context: Context?
        ): Application? {
            return super.newApplication(cl,           TestMyApplication::class.java.name, context)
        }
    }
    ```

1.  将新的测试运行器添加到 Gradle 配置中:

```kt
    android {
        ...
        defaultConfig {        
            ...
            testInstrumentationRunner            "com.android.testable.myapplication            .MyApplicationTestRunner"
        }
    }
    ```

现在运行测试,一切应该与*图 9.8*类似地通过:

![图 9.8:练习 9.03 的输出](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_09_08.jpg)

图 9.8:练习 9.03 的输出

这种类型的练习展示了如何避免测试中的随机性,并提供具体和可重复的输入,使我们的测试更加可靠。类似的方法也适用于依赖注入框架,可以在测试套件中替换整个模块,以确保测试的可靠性。最常替换的一种是 API 通信。这种方法解决的另一个问题是等待时间的减少。如果这种情况在你的测试中重复出现,那么它们的执行时间会因此增加。

# 测试驱动开发

假设你的任务是构建一个显示带有加、减、乘、除选项的计算器的活动。你还必须为你的实现编写测试。通常情况下,你会构建你的 UI 和你的活动以及一个单独的`Calculator`类。然后,你会为`Calculator`类编写单元测试,然后为你的活动类编写单元测试。

在`Calculator`测试下。在这里,你还需要在`Calculator`类中创建必要的方法,以避免编译时错误。

如果你在这个阶段运行你的测试,它们会失败。这将迫使你实现你的代码,直到测试通过。一旦你的`Calculator`测试通过,你就可以把你的计算器连接到你的 UI,直到你的 UI 测试通过。虽然这看起来像是一个违反直觉的方法,一旦掌握了这个过程,它能解决两个问题:

+   因为你会确保你的代码是可测试的,所以写代码的时间会减少,并且你只需要写必要数量的代码来使测试通过。

+   由于开发者能够分析不同的结果,会减少引入的 bug。

请看下图,显示了 TDD 循环:

![图 9.9:TDD 循环](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_09_09.jpg)

图 9.9:TDD 循环

在前述图中,我们可以看到 TDD 过程中的开发循环。你应该从测试失败的点开始。实现变更以使测试通过。当你更新或添加新特性时,你可以重复这个过程。

回到我们的阶乘例子,我们开始时有一个没有涵盖所有场景的阶乘函数,不得不在添加新测试时不断更新函数。TDD 就是以这个想法为基础的。你从一个空函数开始。你开始定义你的测试场景:成功的条件是什么?最小值是多少?最大值是多少?有没有例外情况?它们是什么?这些问题可以帮助开发者定义他们的测试案例。然后,这些案例可以被书写。接下来我们看看这如何通过下一个练习来实际做到。

## 练习 9.04:使用 TDD 计算数字之和

编写一个函数,该函数将以整数*n*作为输入,并返回 1 到*n*的数字之和。这个函数应该采用 TDD 方法编写,并且应满足以下标准:

+   对于*n<=0*,该函数将返回值`-1`。

+   该函数应能返回`Int.MAX_VALUE`的正确值。

+   该函数应该快速,即使对于`Int.MAX_VALUE`也是如此。

注

在整个练习过程中,不会显示导入语句。要查看完整的代码文件,请参考[`packt.live/3a0jJd9`](http://packt.live/3a0jJd9):

执行以下步骤完成此练习:

1.  确保以下库已添加到`app/build.gradle`中:

```kt
    testImplementation 'junit:junit:4.13.1'
    ```

1.  创建一个具有`sum`方法的`Adder`类,该方法将返回`0`,以满足编译器:

```kt
    class Adder {
        fun sum(n: Int): Int = 0
    }
    ```

1.  在测试目录中创建一个`AdderTest`类并定义我们的测试用例。我们将有以下测试用例:*n=1*、*n=2*、*n=0*、*n=-1*、*n=10*、*n=20* 和 *n=Int.MAX_VALUE*。我们可以将成功场景分为一个方法,不成功的场景分为另一个方法:

```kt
    class AdderTest {
        private val adder = Adder()
        @Test
        fun sumSuccess() {
            assertEquals(1, adder.sum(1))
            assertEquals(3, adder.sum(2))
            assertEquals(55, adder.sum(10))
            assertEquals(210, adder.sum(20))
            assertEquals(2305843008139952128L, adder.sum(Int.MAX_VALUE))
        }
        @Test
        fun sumError(){
            assertEquals(-1, adder.sum(0))
            assertEquals(-1, adder.sum(-1))
        }
    }
    ```

1.  如果我们对`AdderTest`类运行测试,我们会看到类似以下图表的输出,意味着所有测试都失败了:![图 9.10:练习 9.04 的初始测试状态](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_09_10.jpg)

图 9.10:练习 9.04 的初始测试状态

1.  让我们先通过在循环中实现从 1 到*n*的求和来处理成功场景:

```kt
    class Adder {
        fun sum(n: Int): Long {
            var result = 0L
            for (i in 1..n) {
                result += i
            }
            return result
        }
    }
    ```

1.  如果我们现在运行测试,你会发现其中一个会通过,另一个会失败,类似于以下图表:![图 9.11:解决练习 9.04 成功场景后的测试状态](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_09_11.jpg)

图 9.11:解决练习 9.04 成功场景后的测试状态

1.  如果我们查看执行成功测试所需的时间,似乎有点长。当一个项目中存在成千上万的单元测试时,这些时间就会相加起来。我们现在可以通过应用*n(n+1)/2*的公式来优化我们的代码以解决这个问题:

```kt
    class Adder {
        fun sum(n: Int): Long {
            return (n * (n.toLong() + 1)) / 2
        }
    }
    ```

现在运行测试将显著减少速度到几毫秒。

1.  现在,让我们专注于解决我们的失败场景。我们可以通过为*n*小于或等于`0`时添加一个条件来做到这一点:

```kt
    class Adder {
        fun sum(n: Int): Long {
            return if (n > 0) (n * (n.toLong() + 1)) / 2 else -1
        }
    }
    ```

1.  如果现在运行测试,我们应该看到它们全部通过,类似于以下图:![图 9.12:练习 9.04 的通过测试

](img/B15216_09_12.jpg)

图 9.12:练习 9.04 的通过测试

在这个练习中,我们已经将 TDD 的概念应用到了一个非常小的示例中,以演示如何使用该技术。我们已经观察到,从骨架代码开始,我们可以创建一套测试来验证我们的条件,通过不断运行测试,我们改进了代码,直到所有测试都通过的地步。您可能已经注意到,这个概念并不直观。一些开发人员很难定义骨架代码应该有多大,才能开始创建测试用例,而其他人则出于习惯,专注于先编写代码,然后再开发测试。无论哪种情况,开发人员都需要通过该技术进行大量练习,直到熟练掌握为止。

## Activity 9.01:使用 TDD 开发

使用 TDD 方法,开发一个包含三个活动并按以下方式工作的应用程序:

+   在活动 1 中,您将显示一个数字`EditText`元素和一个按钮。单击按钮后,将`EditText`中的数字传递给活动 2。

+   Activity 2 将异步生成项目列表。项目的数量将由从活动 1 传递的数字表示。您可以使用`Timer`类,延迟 1 秒。列表中的每个项目将显示文本`Item x`。 `x`是列表中的位置。单击项目时,应将单击的项目传递给活动 3。

+   活动 3 将显示文本`You clicked y`。 `y`是用户单击的项目的文本。

应用程序将具有以下测试:

+   使用 Mockito 和`mockito-kotlin`进行单元测试,注释为`@SmallTest`

+   使用 Robolectric 和 Espresso 进行集成测试,并用`@MediumTest`进行注释

+   使用 Espresso 进行 UI 测试,并用`@LargeTest`进行注释,使用 Robot 模式

从命令行运行测试命令。

为了完成此活动,您需要采取以下步骤:

1.  您需要 Android Studio 4.1.1 或更高版本,以及 Kotlin 1.4.21 或更高版本的 Parcelize Kotlin 插件

1.  为每个活动和其 UI 创建三个活动。

1.  在`androidTest`文件夹中,为每个活动创建三个机器人:

+   Robot 1 将包含与`EditText`和按钮的交互。

+   Robot 2 将断言屏幕上的项目数量和与列表中项目的交互。

+   Robot 3 将断言`TextView`中显示的文本。

1.  创建一个仪器测试类,其中将使用前述机器人进行一个测试方法。

1.  创建一个`Application`类,其中将保存将进行单元测试的所有类的实例。

1.  创建三个表示集成测试的类,每个类对应一个活动。每个集成测试类将包含一个测试方法,用于交互和数据加载。每个集成测试将断言在活动之间传递的意图。

1.  创建一个类,用于提供 UI 所需的文本。它将引用一个`Context`对象,并包含两个方法,用于为 UI 提供文本,将返回一个空字符串。

1.  创建前述类的测试,在其中测试两种方法。

1.  实现类以使前述测试通过。

1.  创建一个类,负责在`Activity2`中加载列表,并提供一个加载的空方法。该类将引用计时器和空闲资源。在这里,您还应该创建一个数据类,用于表示`RecyclerView`的模型。

1.  为前述类创建一个单元测试。

1.  创建前述类的实现并运行单元测试,直到它们通过。

1.  在`Application`类中,实例化已进行单元测试的类,并开始在您的活动中使用它们。直到您的集成测试通过为止。

1.  提供`IntegrationTestApplication`,它将返回负责加载的类的新实现。这是为了避免使您的活动 2 的集成测试等待加载完成。

1.  提供`UiTestApplication`,它将再次减少模型的加载时间,并将空闲资源连接到 Espresso。实现剩下的工作以使 UI 测试通过。

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

# 总结

在本章中,我们看了不同类型的测试和可用于实施这些测试的框架。我们还看了测试环境以及如何为每个环境构建结构,以及如何将代码结构化为可以单独进行单元测试的多个组件。我们分析了测试代码的不同方式,我们应该如何进行测试,以及通过查看不同的测试结果,我们如何改进我们的代码。通过 TDD,我们了解到通过从测试开始,我们可以更快地编写代码,并确保它更少出错。活动是所有这些概念汇集在一起构建一个简单的 Android 应用程序的地方,我们可以观察到,通过添加测试,开发时间增加了,但这在长期内通过消除在修改代码时出现的可能错误而得到了回报。

我们学习过的框架是一些最常见的框架,但还有其他一些建立在这些框架之上并被开发人员在他们的项目中使用的框架,包括 mockk(一种为 Kotlin 设计的模拟库,充分利用了语言的许多特性),Barista(建立在 Espresso 之上,简化了 UI 测试的语法),屏幕截图测试(对 UI 测试进行截图并进行比较以验证是否引入了错误),UIAutomator 和 monkeyrunner(执行 UI 测试而无需访问应用程序代码,但是建立在其之上),Spoon(允许在多个模拟器上并行执行 UI 测试以减少测试时间),以及 Firebase 测试实验室(允许在云中执行测试)。

将这里介绍的所有概念视为适用于软件工程世界中的两个过程的构建块:自动化和持续集成。自动化将开发人员手中的冗余和重复工作交给机器。与其让一组质量保证人员测试您的应用程序以确保满足要求,不如通过各种测试和测试用例指示机器来测试应用程序,只需一个人审查测试结果。持续集成建立在自动化的概念之上,以便在您提交代码进行其他开发人员审查时立即验证代码。具有持续集成的项目将按以下方式设置:开发人员将工作提交到 GitHub 等源代码存储库进行审查。

然后,云中的一台机器将开始执行整个项目的测试,确保没有任何问题,开发人员可以继续进行新的任务。如果测试通过,那么其他开发人员可以审查代码,当正确时,可以合并并在云中创建新的构建并分发给团队的其他成员和测试人员。在初始开发人员可以安全地进行其他工作的同时进行所有这些操作。如果在过程中出现任何失败,那么他们可以暂停新任务并解决工作中的任何问题。然后可以将持续集成过程扩展为持续交付,在准备提交到 Google Play 时可以设置类似的自动化,几乎完全由机器处理,开发人员只需进行少量参与。在接下来的章节中,您将了解如何在构建使用设备存储功能并连接到云以请求数据的更复杂的应用程序时组织代码。每个组件都可以进行单独的单元测试,并且可以应用集成测试来断言多个组件的成功集成。


# 第十章:Android 架构组件

概述

在本章中,您将了解 Android Jetpack 库的关键组件以及它们为标准 Android 框架带来的好处。您还将学习如何使用 Jetpack 组件来构建代码并为您的类分配不同的责任。最后,您将提高代码的测试覆盖率。

通过本章的学习,您将能够轻松处理活动和片段的生命周期。您还将了解如何使用 Room 在 Android 设备上持久保存数据,以及如何使用 ViewModels 将逻辑与视图分离。

# 介绍

在之前的章节中,您学会了如何编写单元测试。问题是:您可以对什么进行单元测试?您可以对活动和片段进行单元测试吗?由于它们的构建方式,它们在您的机器上很难进行单元测试。如果您可以将代码从活动和片段中移出来,测试将会更容易。

另外,考虑一下您正在构建一个支持不同方向(如横向和纵向)和支持多种语言的应用程序的情况。在这些情景中,默认情况下会发生的情况是,当用户旋转屏幕时,活动和片段会为新的显示方向重新创建。现在,想象一下这发生在您的应用程序正在处理数据的中间。您必须跟踪您正在处理的数据,您必须跟踪用户正在做什么来与您的屏幕交互,并且您必须避免造成上下文泄漏。

注意

上下文泄漏是指您销毁的活动由于在生命周期更长的组件中引用而无法进行垃圾回收 - 比如当前正在处理数据的线程。

在许多情况下,您将不得不使用`onSaveInstanceState`来保存活动/片段的当前状态,然后在`onCreate`或`onRestoreInstanceState`中,您需要恢复活动/片段的状态。这将给您的代码增加额外的复杂性,也会使其重复,特别是如果处理代码将成为您的活动或片段的一部分。

这些情景是`ViewModel`和`LiveData`发挥作用的地方。`ViewModels`是专门用于在生命周期发生变化时保存数据的组件。它们还将逻辑与视图分离,这使它们非常容易进行单元测试。`LiveData`是一个组件,用于保存数据并在发生更改时通知观察者,同时考虑它们的生命周期。简单来说,片段只处理视图,`ViewModel`负责繁重的工作,`LiveData`负责将结果传递给片段,但只有在片段准备好时才会这样做。

如果您曾经使用 WhatsApp 或类似的消息应用,并关闭了互联网,您会注意到您仍然能够使用该应用程序。原因是因为消息被本地存储在您的设备上。在大多数情况下,这是通过使用名为**SQLite**的数据库文件实现的。Android 框架已经允许您为您的应用程序使用此功能。这需要大量样板代码来读取和写入数据。每次您想要与本地存储交互时,您必须编写 SQL 查询。当您读取 SQLite 数据时,您必须将其转换为 Java/Kotlin 对象。所有这些都需要大量的代码、时间和单元测试。如果有人处理 SQLite 连接,而您只需专注于代码部分呢?这就是**Room**的作用。这是一个包装在 SQLite 上的库。您只需要定义数据应该如何保存,然后让库来处理其余部分。

假设您希望您的活动在有互联网连接和互联网断开时知道。您可以使用称为 BroadcastReceiver 的东西。这样做的一个小问题是,每次在活动中注册 BroadcastReceiver 时,您都必须在活动销毁时注销它。您可以使用 Lifecycle 来观察活动的状态,从而允许您的接收器在所需状态下注册,并在补充状态下注销(例如,RESUMED-PAUSED,STARTED-STOPPED 或 CREATED-DESTROYED)。

ViewModels,LiveData 和 Room 都是 Android 架构组件的一部分,它们是 Android Jetpack 库的一部分。架构组件旨在帮助开发人员构建其代码,编写可测试的组件,并帮助减少样板代码。其他架构组件包括数据绑定(将视图与模型或 ViewModel 绑定,允许数据直接设置在视图中)、WorkManager(允许开发人员轻松处理后台工作)、导航(允许开发人员创建可视化导航图并指定活动和片段之间的关系)和分页(允许开发人员加载分页数据,在需要无限滚动的情况下有所帮助)。

# ViewModel 和 LiveData

ViewModel 和 LiveData 都代表生命周期机制的专门实现。它们在希望在屏幕旋转时保持数据保存以及在希望数据仅在视图可用时显示时非常有用,从而避免开发人员面临的最常见问题之一——NullPointerException——当尝试更新视图时。一个很好的用法是当您希望显示您最喜爱球队比赛的实时比分和比赛的当前分钟数时。

## ViewModel

ViewModel 组件负责保存和处理 UI 所需的数据。它的好处是在销毁和重新创建片段和活动的配置更改时能够存活,从而保留数据,然后用于重新填充 UI。当活动或片段在不重新创建或应用程序进程终止时,它最终会被销毁。这使得 ViewModel 能够履行其责任,并在不再需要时进行垃圾回收。ViewModel 唯一的方法是 onCleared()方法,当 ViewModel 终止时会调用该方法。您可以重写此方法以终止正在进行的任务并释放不再需要的资源。

将数据处理从活动迁移到 ViewModel 有助于创建更好和更快的单元测试。测试活动需要在设备上执行的 Android 测试。活动还具有状态,这意味着您的测试应该将活动置于适当的状态以使断言起作用。ViewModel 可以在开发机器上进行本地单元测试,并且可以是无状态的,这意味着您的数据处理逻辑可以单独进行测试。

ViewModel 最重要的功能之一是它允许片段之间进行通信。要在没有 ViewModel 的情况下在片段之间进行通信,您必须使您的片段与活动进行通信,然后再调用您希望进行通信的片段。通过 ViewModel 实现这一点,您可以将它们附加到父活动并在希望进行通信的片段中使用相同的 ViewModel。这将减少以前所需的样板代码。

在下图中,您可以看到`ViewModel`可以在活动的生命周期中的任何时刻创建(实际上,它们通常在`onCreate`中初始化活动和`onCreateView`或`onViewCreated`中初始化 fragment,因为这些代表了视图创建和准备更新的时刻),一旦创建,它将与活动一样长久存在:

![图 10.1:活动的生命周期与 ViewModel 生命周期的比较](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_10_01.jpg)

图 10.1:活动的生命周期与 ViewModel 生命周期的比较

以下图表显示了`ViewModel`如何连接到一个 fragment:

![图 10.2:片段的生命周期与 ViewModel 生命周期的比较](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_10_02.jpg)

图 10.2:片段的生命周期与 ViewModel 生命周期的比较

## LiveData

`LiveData`是一个生命周期感知组件,允许更新 UI,但只有在 UI 处于活动状态时才会更新(例如,如果活动或片段处于`STARTED`或`RESUMED`状态)。要监视`LiveData`的更改,您需要一个与`LifecycleOwner`结合的观察者。当活动设置为活动状态时,观察者将在更改发生时收到通知。如果活动被重新创建,那么观察者将被销毁并重新附加。一旦发生这种情况,`LiveData`的最后一个值将被发出,以便我们恢复状态。活动和片段都是`LifecycleOwners`,但片段有一个单独的`LifecycleOwner`用于视图状态。片段有这个特殊的`LifecycleOwner`是因为它们在片段`BackStack`中的行为。当片段在返回堆栈中被替换时,它们并不完全被销毁;只有它们的视图被销毁。开发人员用来触发处理逻辑的一些常见回调是`onViewCreated()`、`onActivityResumed()`和`onCreateView()`。如果我们在这些方法中在`LiveData`上注册观察者,我们可能会遇到多个观察者在片段再次出现在屏幕上时被创建的情况。

在更新`LiveData`模型时,我们有两个选项:`setValue()`和`postValue()`。`setValue()`会立即传递结果,并且只应在 UI 线程上调用。另一方面,`postValue()`可以在任何线程上调用。当调用`postValue()`时,`LiveData`将安排在 UI 线程上更新值,并在 UI 线程空闲时更新值。

在`LiveData`类中,这些方法是受保护的,这意味着有子类允许我们更改数据。`MutableLiveData`使方法公开,这为我们提供了在大多数情况下观察数据的简单解决方案。`MediatorLiveData`是`LiveData`的专门实现,允许我们将多个`LiveData`对象合并为一个(这在我们的数据保存在不同存储库并且我们想要显示组合结果的情况下非常有用)。`TransformLiveData`是另一个专门的实现,允许我们将一个对象转换为另一个对象(这在我们从一个存储库中获取数据并且我们想要从另一个依赖于先前数据的存储库中请求数据的情况下有所帮助,以及在我们想要对存储库的结果应用额外逻辑的情况下有所帮助)。`Custom LiveData`允许我们创建自己的`LiveData`实现(通常在我们定期接收更新的情况下,比如体育博彩应用中的赔率、股市更新以及 Facebook 和 Twitter 的动态)。

注意

在`ViewModel`中使用`LiveData`是一种常见做法。在 fragment 或 activity 中持有`LiveData`会导致在配置更改发生时丢失数据。

以下图表显示了`LiveData`如何与`LifecycleOwner`的生命周期连接:

![图 10.3:LiveData 与生命周期之间的关系与 LifecycleOwners 的观察者](https://gitee.com/OpenDocCN/freelearn-android-zh/raw/master/docs/hwt-bd-andr-app-kt/img/B15216_10_03.jpg)

图 10.3:LiveData 与生命周期所有者和生命周期观察者之间的关系

注意

我们可以在`LiveData`上注册多个观察者,并且每个观察者可以为不同的`LifecycleOwner`注册。在这种情况下,`LiveData`将变为非活动状态,但只有当所有观察者都处于非活动状态时。

## 练习 10.01:创建具有配置更改的布局

您的任务是构建一个应用程序,当在纵向模式下时,屏幕分为两个部分,纵向分割,当在横向模式下时,屏幕分为两个部分,横向分割。第一部分包含一些文本,下面是一个按钮。第二部分只包含文本。打开屏幕时,两个部分的文本都显示`Total: 0`。点击按钮后,文本将更改为`Total: 1`。再次点击后,文本将更改为`Total: 2`,依此类推。当设备旋转时,最后的总数将显示在新的方向上。

为了解决这个任务,我们将定义以下内容:

+   一个包含两个片段的活动-一个用于纵向,另一个用于横向。

+   一个包含`TextView`和一个按钮的布局的片段。

+   一个包含`TextView`的布局的片段。

+   一个将在两个片段之间共享的`ViewModel`。

+   一个将保存总数的`LiveData`。

让我们从设置我们的配置开始:

1.  创建一个名为`ViewModelLiveData`的新项目,并添加一个名为`SplitActivity`的空活动。

1.  在根`build.gradle`文件中,添加`google()`存储库:

```kt
allprojects {
    repositories {
        google()
        jcenter()
    }
}

这将允许 Gradle(构建系统)知道在哪里定位由 Google 开发的 Android Jetpack 库。

  1. 让我们将ViewModelLiveData库添加到app/build.gradle中:
dependencies {
    ... 
    def lifecycle_version = "2.2.0"
    implementation "androidx.lifecycle:lifecycle-      extensions:$lifecycle_version"
    ...
}

这将把ViewModelLiveData代码都引入我们的项目。

  1. 创建和定义SplitFragmentOne
class SplitFragmentOne : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_split_one,           container, false)
    }
    override fun onViewCreated(view: View, savedInstanceState:       Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        view.findViewById<TextView>          (R.id.fragment_split_one_text_view).text =             getString(R.string.total, 0)
    }
}
  1. fragment_split_one.xml文件添加到res/layout文件夹中:
<?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:gravity="center"
    android:orientation="vertical">
    <TextView
        android:id="@+id/fragment_split_one_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
    <Button
        android:id="@+id/fragment_split_one_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/press_me" />
</LinearLayout>
  1. 现在,让我们创建并定义SplitFragmentTwo
class SplitFragmentTwo : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_split_two,           container, false)
    }
    override fun onViewCreated(view: View, savedInstanceState:       Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        view.findViewById<TextView>          (R.id.fragment_split_two_text_view).text =             getString(R.string.total, 0)
    }
}
  1. fragment_split_two.xml文件添加到res/layout文件夹中:
<?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:gravity="center"
    android:orientation="vertical">
    <TextView
        android:id="@+id/fragment_split_two_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</LinearLayout>
  1. 定义SplitActivity
class SplitActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_split)
    }
}
  1. res/layout文件夹中创建activity_split.xml文件:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android   ="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".SplitActivity">
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/activity_fragment_split_1"
        android:name="com.android           .testable.viewmodellivedata.SplitFragmentOne"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/activity_fragment_split_2"
        android:name="com.android           .testable.viewmodellivedata.SplitFragmentTwo"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
</LinearLayout>
  1. 接下来,让我们在res文件夹中创建一个layout-land文件夹。然后,在layout-land文件夹中,我们将创建一个名为activity_split.xml的文件,其中包含以下布局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android=  "http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:baselineAligned="false"
    android:orientation="horizontal"
    tools:context=".SplitActivity">
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/activity_fragment_split_1"
        android:id attribute in both activity_split.xml files. This allows the operating system to correctly save and restore the state of the fragment during rotation.NoteMake sure to properly point to your fragments with the right package declaration in the `android:name` attribute in the `FragmentContainerView` tag in both `activity_split.xml` files. Also, the `id` attribute is a must in the ` FragmentContainerView` tag, so make sure it's present; otherwise, the app will crash.
  1. 以下字符串应添加到res/strings.xml中:
<string name="press_me">Press Me</string>
<string name="total">Total %d</string>
  1. 确保ActivitySplit存在于AndroidManifest.xml文件中:
<activity android:name=".SplitActivity">

注意

如果这是您清单中唯一的活动,请确保添加启动器intent-filter标签,以便系统知道在安装应用程序时应打开哪个活动:

<intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /></intent-filter>

现在,让我们运行这个项目。运行后,您可以旋转设备,看到屏幕根据规格定向。Total设置为 0,点击按钮不会有任何反应:

图 10.4:练习 10.01 的输出

图 10.4:练习 10.01 的输出

我们需要构建所需的逻辑,以便每次单击按钮时都添加 1。该逻辑也需要是可测试的。我们可以构建一个ViewModel并将其附加到每个片段。这将使逻辑可测试,并且还将解决生命周期的问题。

练习 10.02:添加 ViewModel

现在,我们需要实现将我们的ViewModel与按钮点击连接起来的逻辑,并确保该值在配置更改(如旋转)时保持不变。让我们开始吧:

  1. 创建一个TotalsViewModel,如下所示:
class TotalsViewModel : ViewModel() {
    var total = 0
    fun increaseTotal(): Int {
        total++
        return total
    }
}

请注意,我们是从ViewModel类扩展的,这是生命周期库的一部分。在ViewModel类中,我们定义了一个增加总数并返回更新值的方法。

  1. 现在,将updateTextprepareViewModel方法添加到SplitFragment1片段中:
class SplitFragmentOne : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_split_one,           container, false)
    }
    override fun onViewCreated(view: View, savedInstanceState:       Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        prepareViewModel()
    }

    private fun prepareViewModel() {
}
    private fun updateText(total: Int) {
 view?.findViewById<TextView>        (R.id.fragment_split_one_text_view)?.text =          getString(R.string.total, total)
    }
}
  1. prepareViewModel()函数中,让我们开始添加我们的ViewModel
private fun prepareViewModel() {
    val totalsViewModel       = ViewModelProvider(this).get(TotalsViewModel::class.java)
}

这是访问ViewModel实例的方式。ViewModelProvider(this)将使TotalsViewModel绑定到 fragment 的生命周期。.get(TotalsViewModel::class.java)将检索我们之前定义的TotalsViewModel的实例。如果 fragment 是第一次创建,它将产生一个新实例,而如果 fragment 在旋转后重新创建,它将提供先前创建的实例。我们将类作为参数传递的原因是因为一个 fragment 或 activity 可以有多个 ViewModels,而类作为我们想要的ViewModel类型的标识符。

  1. 现在,在视图上设置最后已知的值:
private fun prepareViewModel() {
    val totalsViewModel       = ViewModelProvider(this).get(TotalsViewModel::class.java)
Total 0 every time we rotate, and after every click we will see the previously computed total plus 1.
  1. 当点击按钮时更新视图:
private fun prepareViewModel() {
    val totalsViewModel       = ViewModelProvider(this).get(TotalsViewModel::class.java)
    updateText(totalsViewModel.total)
ViewModel to recompute the total and set the new value.
  1. 现在,运行应用程序,按下按钮,旋转屏幕,看看会发生什么:

图 10.5:练习 10.02 的输出

图 10.5:练习 10.02 的输出

当您按下按钮时,您会看到总数增加,当您旋转显示时,值保持不变。如果您按下返回按钮并重新打开 activity,您会注意到总数被设置为 0。我们需要通知另一个 fragment 值已更改。我们可以通过使用接口并让 activity 知道来实现这一点,以便 activity 可以通知SplitFragmentOne。或者,我们可以将我们的ViewModel附加到 activity,这将允许我们在 fragments 之间共享它。

练习 10.03:在 fragments 之间共享我们的 ViewModel

我们需要在SplitFragmentOne中访问TotalsViewModel并将我们的ViewModel附加到 activity。让我们开始吧:

  1. 将我们之前使用的相同ViewModel添加到SplitFragmentTwo中:
class SplitFragmentTwo : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_split_two,           container, false)
    }
    override fun onViewCreated(view: View, savedInstanceState:       Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val totalsViewModel = ViewModelProvider(this)          .get(TotalsViewModel::class.java)
ViewModel, we actually have two instances of that ViewModel for each of our fragments. We will need to limit the number of instances to one per fragment. We can achieve this by attaching our ViewModel to the SplitActivity life cycle using a method called requireActiviy.
  1. 让我们修改我们的 fragments。在两个 fragments 中,我们需要找到并更改以下代码:
val totalsViewModel =   ViewModelProvider(this).get(TotalsViewModel::class.java)

我们将其更改为以下内容:

val totalsViewModel =   ViewModelProvider(requireActivity())    .get(TotalsViewModel::class.java)
  1. 现在,让我们运行应用程序:

图 10.6:练习 10.03 的输出

图 10.6:练习 10.03 的输出

同样,在这里,我们可以观察到一些有趣的东西。当点击按钮时,我们在第二个 fragment 中看不到任何变化,但我们确实看到了总数。这意味着 fragments 之间进行了通信,但不是实时的。我们可以通过LiveData来解决这个问题。通过在两个 fragments 中观察LiveData,我们可以在值发生变化时更新每个 fragment 的TextView类。

注意

使用 ViewModels 在 fragments 之间进行通信只有在 fragments 放置在同一个 activity 中时才有效。

练习 10.04:添加 LiveData

现在,我们需要确保我们的 fragments 实时地相互通信。我们可以使用LiveData来实现这一点。这样,每当一个 fragment 进行更改时,另一个 fragment 将收到关于更改的通知并进行必要的调整。

执行以下步骤来实现这一点:

  1. 我们的TotalsViewModel应该被修改以支持LiveData
class TotalsViewModel : ViewModel() {
    private val total = MutableLiveData<Int>()
    init {
        total.postValue(0)
    }
    fun increaseTotal() {
        total.postValue((total.value ?: 0) + 1)
    }
    fun getTotal(): LiveData<Int> {
        return total
    }
}

在这里,我们创建了一个MutableLiveData,它是LiveData的子类,允许我们更改数据的值。当创建ViewModel时,我们将0的默认值设置为0,然后当我们增加总数时,我们发布先前的值加 1。我们还创建了getTotal()方法,它返回一个可以从 fragment 中观察但不能修改的LiveData类。

  1. 现在,我们需要修改我们的 fragments,使它们适应新的ViewModel。对于SplitFragmentOne,我们执行以下操作:
    override fun onViewCreated(view: View, savedInstanceState:       Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val totalsViewModel =           ViewModelProvider(requireActivity())            .get(TotalsViewModel::class.java)
        totalsViewModel.getTotal().observe(viewLifecycleOwner,           Observer {
            updateText(it)
        })
        view.findViewById<Button>          (R.id.fragment_split_one_button).setOnClickListener {
            totalsViewModel.increaseTotal()
        }
    }
    private fun updateText(total: Int) {
        view?.findViewById<TextView>          (R.id.fragment_split_one_text_view)?.text             = getString(R.string.total, total)
    }

对于SplitFragmentTwo,我们执行以下操作:

    override fun onViewCreated(view: View, savedInstanceState:       Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val totalsViewModel =           ViewModelProvider(requireActivity())            .get(TotalsViewModel::class.java)
        totalsViewModel.getTotal().observe(viewLifecycleOwner,           Observer {
            updateText(it)
        })
    }
    private fun updateText(total: Int) {
       view?.findViewById<TextView>         (R.id.fragment_split_two_text_view)?.text =            getString(R.string.total, total)
    }
totalsViewModel.getTotal().observe(viewLifecycleOwner, Observer {  updateText(it)})

observe方法的LifecycleOwner参数称为viewLifecycleOwner。这是从fragment类继承的,当我们在观察数据时,它有助于在渲染 fragment 管理的视图时进行观察。在我们的示例中,将viewLifecycleOwner替换为this不会造成影响。但如果我们的 fragment 是后退堆栈功能的一部分,那么就会有创建多个观察者的风险,这将导致对相同数据集多次通知。

  1. 现在,让我们为我们的新ViewModel编写一个测试。我们将其命名为TotalsViewModelTest,并将其放在test包中,而不是androidTest。这是因为我们希望这个测试在我们的工作站上执行,而不是在设备上:
class TotalsViewModelTest {
    private val totalsViewModel = TotalsViewModel()
    @Before
    fun setUp() {
        assertEquals(0, totalsViewModel.getTotal().value)
    }
    @Test
    fun increaseTotal() {
        val total = 5
        for (i in 0 until total) {
            totalsViewModel.increaseTotal()
        }
        assertEquals(4, totalsViewModel.getTotal().value)
    }
}
  1. 在前面的测试中,在测试开始之前,我们断言LiveData的初始值设置为 0。然后,我们编写了一个小测试,其中我们将总数增加了五次,并断言最终值为5。让我们运行测试,看看会发生什么:
java.lang.RuntimeException: Method getMainLooper in   android.os.Looper not mocked.
  1. 会出现类似于前面的消息。这是因为LiveData的实现方式。在内部,它使用处理程序和循环器,这是 Android 框架的一部分,因此阻止我们执行测试。幸运的是,有一个解决方法。我们需要在 Gradle 文件中为我们的测试添加以下配置:
testImplementation 'android.arch.core:core-testing:2.1.0'
  1. 这将向我们的测试代码添加一个测试库,而不是我们的应用程序代码。现在,让我们在代码中添加以下行,位于ViewModel类的实例化之前:
class TotalsViewModelTest {
    @get:Rule
    val rule = InstantTaskExecutorRule()
    private val totalsViewModel = TotalsViewModel()
  1. 我们在这里所做的是添加了一个TestRule,它表示每当LiveData的值发生变化时,它将立即进行更改,并避免使用 Android 框架组件。我们将在这个类中编写的每个测试都受到这个规则的影响,从而使我们有自由为每个新的测试方法使用LiveData类。如果我们再次运行测试,我们将看到以下内容:
java.lang.RuntimeException: Method getMainLooper
  1. 这是否意味着我们的新规则没有起作用?并非完全如此。如果您查看TotalsViewModels类,您会看到这个:
init {
        total.postValue(0)
}
  1. 这意味着因为我们在规则范围之外创建了ViewModel类,所以规则不适用。我们可以做两件事来避免这种情况:我们可以更改我们的代码以处理当我们首次订阅LiveData类时发送的空值,或者我们可以调整我们的测试,以便将ViewModel类放在规则的范围内。让我们采用第二种方法,并更改测试中创建ViewModel类的方式。它应该看起来像这样:
@get:Rule
val rule = InstantTaskExecutorRule()
private lateinit var totalsViewModel: TotalsViewModel
@Before
fun setUp() {
    totalsViewModel = TotalsViewModel()
    assertEquals(0, totalsViewModel.getTotal().value)
}
  1. 让我们再次运行测试,看看会发生什么:
java.lang.AssertionError: 
Expected :4
Actual   :5

看看您能否找到测试中的错误,修复它,然后重新运行它:

图 10.7:练习 10.04 的输出

图 10.7:练习 10.04 的输出

横向模式下的相同输出如下所示:

图 10.8:横向模式下练习 10.04 的输出

图 10.8:横向模式下练习 10.04 的输出

通过查看前面的例子,我们可以看到使用LiveDataViewModel方法的结合如何帮助我们解决了问题,同时考虑了 Android 操作系统的特殊性:

  • ViewModel帮助我们在设备方向更改时保持数据,并解决了在片段之间通信的问题。

  • LiveData帮助我们在考虑片段生命周期的同时检索我们处理过的最新信息。

  • 这两者的结合帮助我们以高效的方式委托我们的处理逻辑,使我们能够对这个处理逻辑进行单元测试。

Room

Room 持久性库充当您的应用程序代码和 SQLite 存储之间的包装器。您可以将 SQLite 视为一个在没有自己服务器的情况下运行的数据库,并将所有应用程序数据保存在一个只能由您的应用程序访问的内部文件中(如果设备未被 root)。Room 将位于应用程序代码和 SQLite Android 框架之间,并将处理必要的创建、读取、更新和删除(CRUD)操作,同时公开一个抽象,您的应用程序可以使用该抽象来定义数据以及您希望处理数据的方式。这种抽象以以下对象的形式出现:

  • 实体:您可以指定数据存储方式以及数据之间的关系。

  • 数据访问对象DAO):可以对数据执行的操作。

  • 数据库:您可以指定数据库应具有的配置(数据库名称和迁移方案)。

这些可以在以下图表中看到:

图 10.9:您的应用程序与 Room 组件之间的关系

图 10.9:您的应用程序与 Room 组件之间的关系

在上图中,我们可以看到 Room 组件如何相互交互。通过一个例子更容易将其可视化。假设您想制作一个消息应用程序并将每条消息存储在本地存储中。在这种情况下,Entity将是一个包含 ID 的Message对象,它将包含消息的内容、发送者、时间、状态等。为了从本地存储中访问消息,您将需要一个MessageDao,其中将包含诸如insertMessage()getMessagesFromUser()deleteMessage()updateMessage()等方法。由于这是一个消息应用程序,您将需要一个Contact实体来保存消息的发送者和接收者的信息。Contact实体将包含诸如姓名、最后在线时间、电话号码、电子邮件等信息。为了访问联系人信息,您将需要一个ContactDao接口,其中将包含createUser()updateUser()deleteUser()getAllUsers()。两个实体将在 SQLite 中创建一个匹配的表,其中包含我们在实体类中定义的字段作为列。为了实现这一点,我们将不得不创建一个MessagingDatabase,在其中我们将引用这两个实体。

在没有 Room 或类似的 DAO 库的世界中,我们需要使用 Android 框架的 SQLite 组件。这通常涉及到设置数据库时的代码,比如创建表的查询,并为每个表应用类似的查询。每次我们查询表中的数据时,我们都需要将结果对象转换为 Java 或 Kotlin 对象。然后,对于我们更新或创建的每个对象,我们都需要进行相反方向的转换并调用适当的方法。Room 消除了所有这些样板代码,使我们能够专注于应用程序的需求。

默认情况下,Room 不允许在 UI 线程上执行任何操作,以强制执行与输入输出操作相关的 Android 标准。为了进行异步调用以访问数据,Room 与许多库和框架兼容,例如 Kotlin 协程、RxJava 和LiveData,在其默认定义之上。

实体

实体有两个目的:定义表的结构和保存表行的数据。让我们使用消息应用程序的场景,并定义两个实体:一个用于用户,一个用于消息。User实体将包含有关谁发送消息的信息,而Message实体将包含有关消息内容、发送时间以及消息发送者的引用的信息。以下代码片段提供了如何使用 Room 定义实体的示例:

@Entity(tableName = "messages")
data class Message(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "message_id")       val id: Long,
    @ColumnInfo(name = "text", defaultValue = "") val text: String,
    @ColumnInfo(name = "time") val time: Long,
    @ColumnInfo(name = "user") val userId: Long,
)
@Entity(tableName = "users")
data class User(
    @PrimaryKey @ColumnInfo(name = "user_id") val id: Long,
    @ColumnInfo(name = "first_name") val firstName: String,
    @ColumnInfo(name = "last_name") val lastName: String,
    @ColumnInfo(name = "last_online") val lastOnline: Long
)

正如您所看到的,实体只是带有注释的数据类,这些注释将告诉 Room 如何在 SQLite 中构建表。我们使用的注释如下:

  • @Entity注释定义了表。默认情况下,表名将是类的名称。我们可以通过Entity注释中的tableName方法更改表的名称。在我们希望我们的代码被混淆但希望保持 SQLite 结构的一致性的情况下,这是有用的。

  • @ColumnInfo定义了特定列的配置。最常见的是列的名称。我们还可以指定默认值、字段的 SQLite 类型以及字段是否应该被索引。

  • @PrimaryKey指示我们的实体中将使其唯一的内容。每个实体应该至少有一个主键。如果您的主键是整数或长整数,那么我们可以添加autogenerate字段。这意味着每个插入到Primary Key字段的实体都将由 SQLite 自动生成。通常,这是通过递增前一个 ID 来完成的。如果您希望将多个字段定义为主键,那么可以调整@Entity注释以适应此情况;例如以下内容:

@Entity(tableName = "messages", primaryKeys = ["id", "time"])

假设我们的消息应用程序想要发送位置。位置有纬度、经度和名称。我们可以将它们添加到Message类中,但这会增加类的复杂性。我们可以创建另一个实体并在我们的类中引用 ID。这种方法的问题是,我们每次查询Message实体时都会查询Location实体。Room 通过@Embedded注释提供了第三种方法。现在,让我们看看更新后的Message实体:

@Entity(tableName = "messages")
data class Message(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "message_id")       val id: Long,
    @ColumnInfo(name = "text", defaultValue = "") val text: String,
    @ColumnInfo(name = "time") val time: Long,
    @ColumnInfo(name = "user") val userId: Long,
    @Embedded val location: Location?
)
data class Location(
    @ColumnInfo(name = "lat") val lat: Double,
    @ColumnInfo(name = "long") val log: Double,
    @ColumnInfo(name = "location_name") val name: String
)

这段代码的作用是向消息表添加三列(latlonglocation_name)。这样可以避免对象具有大量字段,同时保持表的一致性。

如果我们查看我们的实体,我们会发现它们是相互独立的。Message实体有一个userId字段,但没有任何阻止我们从无效用户添加消息。这可能导致我们收集没有任何目的的数据。如果我们想要删除特定用户以及他们的消息,那么我们必须手动执行。Room 提供了一种通过ForeignKey定义这种关系的方法:

@Entity(
    tableName = "messages",
    foreignKeys = [ForeignKey(
        entity = User::class,
        parentColumns = ["user_id"],
        childColumns = ["user"],
onDelete = ForeignKey.CASCADE
    )]
)
data class Message(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "message_id")       val id: Long,
    @ColumnInfo(name = "text", defaultValue = "") val text: String,
    @ColumnInfo(name = "time") val time: Long,
    @ColumnInfo(name = "user") val userId: Long,
    @Embedded val location: Location?
)

在前面的例子中,我们添加了foreignKeys字段,并为User实体创建了一个新的ForeignKey,而对于父列,我们在User类中定义了user_id字段,对于子列,在Message类中定义了user字段。每次我们向表中添加消息时,users表中都需要有一个User条目。如果我们尝试删除一个用户,而仍然存在来自该用户的任何消息,那么默认情况下,这将不起作用,因为存在依赖关系。但是,我们可以告诉 Room 执行级联删除,这将删除用户和相关的消息。

DAO

如果实体指定了我们如何定义和保存我们的数据,那么 DAOs 指定了对该数据的操作。DAO 类是我们定义 CRUD 操作的地方。理想情况下,每个实体应该有一个对应的 DAO,但也有一些情况发生了交叉(通常是在我们需要处理两个表之间的 JOIN 时发生)。

继续我们之前的例子,让我们为我们的实体构建一些相应的 DAOs。

@Dao
interface MessageDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertMessages(vararg messages: Message)
    @Update
    fun updateMessages(vararg messages: Message)
    @Delete
    fun deleteMessages(vararg messages: Message)
    @Query("SELECT * FROM messages")
    fun loadAllMessages(): List<Message>
    @Query("SELECT * FROM messages WHERE user=:userId AND       time>=:time")
    fun loadMessagesFromUserAfterTime(userId: String, time: Long):       List<Message>
}
@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUser(user: User)
    @Update
    fun updateUser(user: User)
    @Delete
    fun deleteUser(user: User)
    @Query("SELECT * FROM users")
    fun loadAllUsers(): List<User>
}

对于我们的消息,我们已经定义了以下函数:插入一个或多个消息,更新一个或多个消息,删除一个或多个消息,以及检索某个用户在特定时间之前的所有消息。对于我们的用户,我们可以插入一个用户,更新一个用户,删除一个用户,并检索所有用户。

如果查看我们的Insert方法,您会看到我们已经定义了在冲突的情况下(当我们尝试插入已经存在的 ID 的内容时),它将替换现有条目。Update字段具有类似的配置,但在我们的情况下,我们选择了默认值。这意味着如果更新无法发生,将不会发生任何事情。

@Query注释与其他所有注释不同。这是我们使用 SQLite 代码定义读取操作的地方。SELECT *表示我们要读取表中每一行的所有数据,这将填充所有我们实体的字段。WHERE子句表示我们要应用于查询的限制。我们也可以定义一个方法如下:

@Query("SELECT * FROM messages WHERE user IN (:userIds) AND   time>=:time")
fun loadMessagesFromUserAfterTime(userIds: List<String>, time: Long):   List<Message>

这使我们可以过滤来自多个用户的消息。

我们可以定义一个新类如下:

data class TextWithTime(
    @ColumnInfo(name = "text") val text: String,
    @ColumnInfo(name = "time") val time: Long
)

现在,我们可以定义以下查询:

@Query("SELECT text,time FROM messages")
fun loadTextsAndTimes(): List<TextWithTime>

这将允许我们一次从某些列中提取信息,而不是整行。

现在,假设你想要将发送者的用户信息添加到每条消息中。在这里,我们需要使用与之前相似的方法:

data class MessageWithUser(
    @Embedded val message: Message,
    @Embedded val user: User
)

通过使用新的数据类,我们可以定义这个查询:

@Query("SELECT * FROM messages INNER JOIN users on   users.user_id=messages.user")
fun loadMessagesAndUsers(): List<MessageWithUser>

现在,我们为要显示的每条消息都有了用户信息。这在诸如群聊之类的场景中会很有用,我们应该显示每条消息的发送者姓名。

设置数据库

到目前为止,我们有一堆 DAO 和实体。现在是将它们放在一起的时候了。首先,让我们定义我们的数据库:

@Database(entities = [User::class, Message::class], version = 1)
abstract class ChatDatabase : RoomDatabase() {
    companion object {
        private lateinit var chatDatabase: ChatDatabase
        fun getDatabase(applicationContext: Context): ChatDatabase {
            if (!(::chatDatabase.isInitialized)) {
                chatDatabase =
                    Room.databaseBuilder(applicationContext,                       chatDatabase::class.java, "chat-db")
                        .build()
            }
            return chatDatabase
        }
    }
    abstract fun userDao(): UserDao
    abstract fun messageDao(): MessageDao
}

@Database注解中,我们指定了哪些实体放入我们的数据库,还指定了我们的版本。然后,对于每个 DAO,我们在RoomDatabase中定义了一个抽象方法。这允许构建系统构建我们类的子类,在其中为这些方法提供实现。构建系统还将创建与我们实体相关的表。

伴生对象中的getDatabase方法用于说明我们如何创建ChatDatabase类的实例。理想情况下,由于构建新数据库对象涉及的复杂性,我们的应用程序应该只有一个数据库实例。这可以通过依赖注入框架更好地实现。

假设你已经发布了你的聊天应用程序。你的数据库当前是版本 1,但你的用户抱怨说消息状态功能缺失。你决定在下一个版本中添加这个功能。这涉及改变数据库的结构,可能会影响已经构建其结构的数据库。幸运的是,Room 提供了一种叫做迁移的东西。在迁移中,我们可以定义我们的数据库在版本 1 和 2 之间的变化。所以,让我们看看我们的例子:

data class Message(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "message_id")       val id: Long,
    @ColumnInfo(name = "text", defaultValue = "") val text: String,
    @ColumnInfo(name = "time") val time: Long,
    @ColumnInfo(name = "user") val userId: Long,
    @ColumnInfo(name = "status") val status: Int,
    @Embedded val location: Location?
)

在这里,我们向Message实体添加了状态标志。

现在,让我们看看我们的ChatDatabase

Database(entities = [User::class, Message::class], version = 2)
abstract class ChatDatabase : RoomDatabase() {
    companion object {
        private lateinit var chatDatabase: ChatDatabase
        private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE messages ADD COLUMN                   status INTEGER")
            }
        }
        fun getDatabase(applicationContext: Context): ChatDatabase {
            if (!(::chatDatabase.isInitialized)) {
                chatDatabase =
                    Room.databaseBuilder(applicationContext,                       chatDatabase::class.java, "chat-db")
                        .addMigrations(MIGRATION_1_2)
                        .build()
            }
            return chatDatabase
        }
    }
    abstract fun userDao(): UserDao
    abstract fun messageDao(): MessageDao
}

在我们的数据库中,我们将版本增加到 2,并在版本 1 和 2 之间添加了迁移。在这里,我们向表中添加了状态列。当我们构建数据库时,我们将添加此迁移。一旦我们发布了新代码,当打开更新后的应用程序并执行构建数据库的代码时,它将比较存储数据上的版本与我们类中指定的版本,并注意到差异。然后,它将执行我们指定的迁移,直到达到最新版本。这使我们能够在多年内维护应用程序,而不影响用户的体验。

如果你看我们的Message类,你可能已经注意到我们将时间定义为 Long。在 Java 和 Kotlin 中,我们有Date对象,这可能比消息的时间戳更有用。幸运的是,Room 在 TypeConverters 中有解决方案。以下表格显示了我们可以在我们的代码中使用的数据类型和 SQLite 等效。需要使用 TypeConverters 将复杂数据类型降至这些级别:

图 10.10:Kotlin/Java 数据类型与 SQLite 数据类型之间的关系

图 10.10:Kotlin/Java 数据类型与 SQLite 数据类型之间的关系

在这里,我们修改了lastOnline字段,使其为Date类型:

data class User(
    @PrimaryKey @ColumnInfo(name = "user_id") val id: Long,
    @ColumnInfo(name = "first_name") val firstName: String,
    @ColumnInfo(name = "last_name") val lastName: String,
    @ColumnInfo(name = "last_online") val lastOnline: Date
)

在这里,我们定义了一对方法,将Date对象转换为Long,反之亦然。@TypeConverter注解帮助 Room 识别转换发生的位置:

class DateConverter {
    @TypeConverter
    fun from(value: Long?): Date? {
        return value?.let { Date(it) }
    }
    @TypeConverter
    fun to(date: Date?): Long? {
        return date?.time
    }
}

最后,我们将通过@TypeConverters注解将我们的转换器添加到 Room 中:

@Database(entities = [User::class, Message::class], version = 2)
@TypeConverters(DateConverter::class)
abstract class ChatDatabase : RoomDatabase() {

在下一节中,我们将看一些第三方框架。

第三方框架

Room 与 LiveData、RxJava 和协程等第三方框架很好地配合。这解决了多线程和观察数据变化的两个问题。

LiveData将使 DAO 中的@Query注解方法具有反应性,这意味着如果添加了新数据,LiveData将通知观察者:

    @Query("SELECT * FROM users")
    fun loadAllUsers(): LiveData<List<User>>

Kotlin 协程通过使@Insert@Delete@Update方法异步化来补充LiveData

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User)
    @Update
    suspend fun updateUser(user: User)
    @Delete
    suspend fun deleteUser(user: User)

@Query方法通过PublisherObservableFlowable等组件变得响应式,并通过CompletableSingleMaybe等使其余的方法异步化:

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUser(user: User) : Completable
    @Update
    fun updateUser(user: User) : Completable
    @Delete
    fun deleteUser(user: User) : Completable
    @Query("SELECT * FROM users")
    fun loadAllUsers(): Flowable<List<User>>

执行器和线程是 Java 框架自带的,如果你的项目中没有前面提到的第三方集成,它们可以是解决 Room 中线程问题的有用解决方案。你的 DAO 类不会受到任何修改的影响;然而,你需要访问 DAO 的组件来调整并使用执行器或线程:

    @Query("SELECT * FROM users")
    fun loadAllUsers(): List<User>
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUser(user: User)
    @Update
    fun updateUser(user: User)
    @Delete
    fun deleteUser(user: User)

访问 DAO 的一个例子如下:

    fun getUsers(usersCallback:()->List<User>){
        Thread(Runnable {
           usersCallback.invoke(userDao.loadUsers())
        }).start()
     }

上面的例子将创建一个新的线程,并在每次我们想要检索用户列表时启动它。这段代码有两个主要问题:

  • 线程创建是一个昂贵的操作

  • 这段代码很难测试

第一个问题的解决方案可以通过ThreadPoolsExecutors来解决。Java 框架在ThreadPools方面提供了强大的选项。线程池是一个负责线程创建和销毁的组件,并允许开发人员指定池中的线程数量。线程池中的多个线程将确保可以同时执行多个任务。

我们可以将上面的代码重写如下:

    private val executor:Executor =       Executors.newSingleThreadExecutor()
    fun getUsers(usersCallback:(List<User>)->Unit){
        executor.execute {
            usersCallback.invoke(userDao.loadUsers())
        }
    }

在上面的例子中,我们定义了一个使用 1 个线程池的执行器。当我们想要访问用户列表时,我们将查询放在执行器内部,当数据加载时,我们的回调 lambda 将被调用。

练习 10.05:做一个小小的 Room

你被一家新闻机构聘用来构建一个新闻应用程序。该应用程序将显示由记者撰写的文章列表。一篇文章可以由一个或多个记者撰写,每个记者可以撰写一篇或多篇文章。每篇文章的数据信息包括文章的标题、内容和日期。记者的信息包括他们的名字、姓氏和职称。你需要构建一个 Room 数据库来保存这些信息以便进行测试。

在我们开始之前,让我们看一下实体之间的关系。在聊天应用程序的例子中,我们定义了一个用户可以发送一个或多个消息的规则。这种关系被称为一对多关系。这种关系被实现为一个实体对另一个实体的引用(用户在消息表中被定义,以便与发送者连接)。在这种情况下,我们有一个多对多的关系。为了实现多对多的关系,我们需要创建一个实体,它持有将连接另外两个实体的引用。让我们开始吧:

  1. 让我们首先在app/build.gradle中添加注解处理插件。这将读取 Room 使用的注解,并生成与数据库交互所需的代码:
    apply plugin: 'kotlin-kapt' 
  1. 接下来,让我们在app/build.gradle中添加 Room 库:
def room_version = "2.2.5"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

第一行定义了库版本,第二行引入了 Java 和 Kotlin 的 Room 库,最后一行是 Kotlin 注解处理器。这允许构建系统从 Room 注解中生成样板代码。

  1. 让我们定义我们的实体:
@Entity(tableName = "article")
data class Article(
    @PrimaryKey(autoGenerate = true)       @ColumnInfo(name = "id") val id: Long = 0,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "content") val content: String,
    @ColumnInfo(name = "time") val time: Long
)
@Entity(tableName = "journalist")
data class Journalist(
    @PrimaryKey(autoGenerate = true)       @ColumnInfo(name = "id") val id: Long = 0,
    @ColumnInfo(name = "first_name") val firstName: String,
    @ColumnInfo(name = "last_name") val lastName: String,
    @ColumnInfo(name = "job_title") val jobTitle: String
)
  1. 现在,定义连接记者和文章以及适当的约束的实体:
@Entity(
    tableName = "joined_article_journalist",
    primaryKeys = ["article_id", "journalist_id"],
    foreignKeys = [ForeignKey(
        entity = Article::class,
        parentColumns = arrayOf("id"),
        childColumns = arrayOf("article_id"),
        onDelete = ForeignKey.CASCADE
    ), ForeignKey(
        entity = Journalist::class,
        parentColumns = arrayOf("id"),
        childColumns = arrayOf("journalist_id"),
        onDelete = ForeignKey.CASCADE
    )]
)
data class JoinedArticleJournalist(
    @ColumnInfo(name = "article_id") val articleId: Long,
    @ColumnInfo(name = "journalist_id") val journalistId: Long
)

在上面的代码中,我们定义了我们的连接实体。正如你所看到的,我们没有为唯一性定义 ID,但是当文章和记者一起使用时,它们将是唯一的。我们还为我们的实体引用的每个其他实体定义了外键。

  1. 创建ArticleDao DAO:
@Dao
interface ArticleDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertArticle(article: Article)
    @Update
    fun updateArticle(article: Article)
    @Delete
    fun deleteArticle(article: Article)
    @Query("SELECT * FROM article")
    fun loadAllArticles(): List<Article>
    @Query("SELECT * FROM article INNER JOIN       joined_article_journalist ON         article.id=joined_article_journalist.article_id WHERE           joined_article_journalist.journalist_id=:journalistId")
    fun loadArticlesForAuthor(journalistId: Long): List<Article>
}
  1. 现在,创建JournalistDao数据访问对象:
@Dao
interface JournalistDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertJournalist(journalist: Journalist)
    @Update
    fun updateJournalist(journalist: Journalist)
    @Delete
    fun deleteJournalist(journalist: Journalist)
    @Query("SELECT * FROM journalist")
    fun loadAllJournalists(): List<Journalist>
    @Query("SELECT * FROM journalist INNER JOIN       joined_article_journalist ON         journalist.id=joined_article_journalist.journalist_id           WHERE joined_article_journalist.article_id=:articleId")
    fun getAuthorsForArticle(articleId: Long): List<Journalist>
}
  1. 创建JoinedArticleJournalistDao DAO:
@Dao
interface JoinedArticleJournalistDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertArticleJournalist(joinedArticleJournalist:       JoinedArticleJournalist)
    @Delete
    fun deleteArticleJournalist(joinedArticleJournalist:       JoinedArticleJournalist)
}

让我们稍微分析一下我们的代码。对于文章和记者,我们有添加、插入、删除和更新查询的能力。对于文章,我们有提取所有文章的能力,还可以从特定作者提取文章。我们还有选项来提取写过文章的所有记者。这是通过与我们的中间实体进行 JOIN 来完成的。对于该实体,我们定义了插入选项(将文章链接到记者)和删除选项(将删除该链接)。

  1. 最后,让我们定义我们的Database类:
@Database(
    entities = [Article::class, Journalist::class,       JoinedArticleJournalist::class],
    version = 1
)
abstract class NewsDatabase : RoomDatabase() {
    abstract fun articleDao(): ArticleDao
    abstract fun journalistDao(): JournalistDao
    abstract fun joinedArticleJournalistDao():       JoinedArticleJournalistDao
}

我们避免在这里定义getInstance方法,因为我们不会在任何地方调用数据库。但如果我们不这样做,我们怎么知道它是否有效?答案是我们将测试它。这不会是在您的计算机上运行的测试,而是在设备上运行的测试。这意味着我们将在androidTest文件夹中创建它。

  1. 让我们从设置测试数据开始。在这里,我们将向数据库中添加一些文章和记者:
NewsDatabaseTest.kt
15@RunWith(AndroidJUnit4::class)
16class NewsDatabaseTest {
17
18    private lateinit var db: NewsDatabase
19    private lateinit var articleDao: ArticleDao
20    private lateinit var journalistDao: JournalistDao
21    private lateinit var joinedArticleJournalistDao:         JoinedArticleJournalistDao
22
23     @Before
24     fun setUp() {
25        val context =             ApplicationProvider.getApplicationContext<Context>()
26        db = Room.inMemoryDatabaseBuilder(context,             NewsDatabase::class.java).build()
27        articleDao = db.articleDao()
28        journalistDao = db.journalistDao()
29        joinedArticleJournalistDao =             db.joinedArticleJournalistDao()
30        initData()
31    }
The complete code for this step can be found at http://packt.live/3oWok6a.
  1. 让我们测试数据是否已更新:
    @Test
    fun updateArticle() {
        val article = articleDao.loadAllArticles()[0]
        articleDao.updateArticle(article.copy(title =           "new title"))
        assertEquals("new title",           articleDao.loadAllArticles()[0].title)
    }
    @Test
    fun updateJournalist() {
        val journalist = journalistDao.loadAllJournalists()[0]
        journalistDao.updateJournalist(journalist.copy(jobTitle           = "new job title"))
        assertEquals("new job title",           journalistDao.loadAllJournalists()[0].jobTitle)
    }
  1. 接下来,让我们测试清除数据:
    @Test
    fun deleteArticle() {
        val article = articleDao.loadAllArticles()[0]
        assertEquals(2,           journalistDao.getAuthorsForArticle(article.id).size)
        articleDao.deleteArticle(article)
        assertEquals(4, articleDao.loadAllArticles().size)
        assertEquals(0,           journalistDao.getAuthorsForArticle(article.id).size)
    }

在这里,我们定义了一些测试 Room 数据库的示例。有趣的是我们如何构建数据库。我们的数据库是一个内存数据库。这意味着只要测试运行,所有数据都将被保留,并在之后被丢弃。这使我们可以为每个新状态从零开始,并避免每个测试会话的后果相互影响。在我们的测试中,我们设置了五篇文章和十位记者。第一篇文章是由前两位记者写的,而第二篇文章是由第一位记者写的。其余的文章没有作者。通过这样做,我们可以测试我们的更新和删除方法。对于删除方法,我们还可以测试我们的外键关系。在测试中,我们可以看到,如果我们删除文章 1,它将删除文章和写作它的记者之间的关系。在测试数据库时,您应该添加您的应用程序将使用的场景。请随意添加其他测试场景,并改进您自己数据库中的先前测试。

自定义生命周期

之前,我们讨论了LiveData以及如何通过LifecycleOwner观察它。我们可以使用 LifecycleOwners 订阅LifecycleObserver,以便它将监视所有者状态的变化。这在您希望在调用特定生命周期回调时触发某些函数的情况下非常有用;例如,从您的活动/片段请求位置、启动/停止视频以及监视连接更改。我们可以通过使用LifecycleObserver来实现这一点。

class ToastyLifecycleObserver(val onStarted: () -> Unit) :   LifecycleObserver {
    @OnLifecycleEvent(Lifecycle.Event.ON_START)
    fun onStarted() {
        onStarted.invoke()
    }
}

在上述代码中,我们定义了一个实现LifecycleObserver接口的类,并定义了一个在生命周期进入ON_START事件时将被调用的方法。@OnLifecycleEvent注解将被构建系统用于生成调用它所用于的注解的样板代码。

接下来,我们需要在活动/片段中注册我们的观察者:

    lifecycle.addObserver(ToastyLifecycleObserver {
        Toast.makeText(this, "Started", Toast.LENGTH_LONG).show()
})

在上述代码中,我们在Lifecycle对象上注册了观察者。Lifecycle对象是通过getLifecycle()方法从父活动类继承的。

注意

LiveData是这一原则的专门用途。在LiveData场景中,您可以有多个 LifecycleOwners 订阅单个LiveData。在这里,您可以为相同的LifecycleOwner订阅新的所有者。

练习 10.06:重新发明轮子

在这个练习中,我们将实现一个自定义的LifecycleOwner,当活动启动时,它将触发ToastyLifecycleObserver中的Lifecycle.Event.ON_START事件。让我们开始创建一个名为 SplitActivity 的空活动的新 Android Studio 项目:

  1. 让我们从将观察者添加到我们的活动开始:
class SplitActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycle.addObserver(ToastyLifecycleObserver {
            Toast.makeText(this, "Started",               Toast.LENGTH_LONG).show()
        })
    }
}

如果您运行代码并打开活动,旋转设备,将应用程序置于后台,然后恢复应用程序,您将看到Started提示。

  1. 现在,定义一个新的活动,将重新发明轮子并使其变得更糟:
class LifecycleActivity : Activity(), LifecycleOwner {
    private val lifecycleRegistry: LifecycleRegistry =       LifecycleRegistry(this)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleRegistry.currentState = Lifecycle.State.CREATED
        lifecycleRegistry.addObserver(ToastyLifecycleObserver {
            Toast.makeText(applicationContext, "Started",               Toast.LENGTH_LONG).show()
        })
    }
    override fun getLifecycle(): Lifecycle {
        return lifecycleRegistry
    }

    override fun onStop() {
super.onStop()
        lifecycleRegistry.currentState = Lifecycle.State.STARTED
    }
}
  1. AndroidManifest.xml文件中,您可以用 LifecycleActivity 替换 SplitActivity,效果会是这样的
        <activity android:name=".LifecycleActivity" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN"                   />
                <category android:name=                  "android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

如果我们运行上述代码,我们将看到每次启动活动时都会出现一个提示。

图 10.11:练习 10.06 的输出

图 10.11:练习 10.06 的输出

请注意,这是在不覆盖Activity类的onStart()方法的情况下触发的。您可以进一步尝试使用LifecycleObserver类来触发Activity类的其他状态中的提示。

现在,让我们分析一下我们新活动的代码。请注意,我们扩展了活动而不是AppCompatActivity类。这是因为AppCompatActivity类已经包含了LifecycleRegistry逻辑。在我们的新活动中,我们定义了一个LifecycleRegistry,它将负责添加我们的观察者和改变状态。然后,我们实现了LifecycleOwner接口,并在getLifecycle()方法中返回LifecycleRegistry。然后,对于我们的每个回调,我们可以改变注册表的状态。在onCreate()方法中,我们将注册表设置为CREATED状态(这将触发LifecycleObservers上的ON_CREATE事件),然后我们注册了我们的LifecycleObserver。为了实现我们的任务,我们在onStop()方法中发送了STARTED事件。如果我们运行上述示例并最小化我们的活动,我们应该会看到我们的Started提示。

活动 10.01:购物笔记应用

您想跟踪您的购物物品,因此决定构建一个应用程序,您可以在其中保存您希望在下次去商店时购买的物品。此需求如下:

  • UI 将分为两部分:纵向模式为上/下,横向模式为左/右。UI 将类似于以下截图所示。

  • 第一半将显示笔记的数量、文本字段和按钮。每次按下按钮时,将使用放置在文本字段中的文本添加一个笔记。

  • 第二半将显示笔记列表。

  • 对于每一半,您将拥有一个将保存相关数据的视图模型。

  • 您应该定义一个存储库,它将在 Room 数据库之上使用以访问您的数据。

  • 您还应该定义一个 Room 数据库,用于保存您的笔记。

  • 笔记实体将具有以下属性:id、text:

图 10.12:活动 10.01 可能的输出示例

图 10.12:活动 10.01 可能的输出示例

执行以下步骤以完成此活动:

  1. 通过创建EntityDaoDatabase方法开始 Room 集成。对于Dao@Query注释的方法可以直接返回LiveData对象,以便如果数据发生更改,观察者可以直接收到通知。

  2. 以接口形式定义我们的存储库的模板。

  3. 实现存储库。存储库将有一个对我们之前定义的Dao对象的引用。插入数据的代码需要移动到一个单独的线程。创建NotesApplication类以提供将在整个应用程序中使用的存储库的一个实例。确保更新AndroidManifest.xml文件中的<application>标签,以添加您的新应用程序类。

  4. 对存储库进行单元测试并定义ViewModels,如下所示:

  • 定义NoteListViewModel和相关测试。这将引用存储库并返回笔记列表。

  • 定义CountNotesViewModel和相关测试。CountViewModel将引用存储库并返回LiveData的笔记总数。它还将负责插入新的笔记。

  • 定义CountNotesFragment及其关联的fragment_count_notes.xml布局。在布局中,定义一个将显示总数的TextView,一个用于新笔记名称的EditText,以及一个将插入EditText中引入的笔记的按钮。

  • 为笔记列表定义一个适配器,名为NoteListAdapter,并为行定义一个关联的布局文件,名为view_note_item.xml

  • 定义关联的布局文件,名为fragment_note_list.xml,其中将包含一个RecyclerView。该布局将被NoteListFragment使用,它将连接NoteListAdapterRecyclerView。它还将观察来自NoteListViewModel的数据并更新适配器。

  • 为横向模式和纵向模式定义NotesActivity及其关联的布局。

  1. 确保你在strings.xml中有所有必要的数据。

注意

此活动的解决方案可以在以下网址找到:http://packt.live/3sKj1cp

总结

在本章中,我们分析了构建可维护应用程序所需的基本组件。我们还研究了在使用 Android 框架时开发人员经常遇到的最常见问题之一,即在生命周期更改期间维护对象的状态。

我们首先分析了ViewModels以及它们如何解决在方向更改期间保存数据的问题。我们将LiveData添加到ViewModels中,以展示它们如何互补。

然后,我们转向 Room,展示了如何在不需要大量 SQLite 样板代码的情况下轻松持久化数据。我们还探讨了一对多和多对多关系,以及如何迁移数据并将复杂对象分解为存储的基本类型。

之后,我们重新发明了Lifecycle轮,以展示LifecycleOwnersLifecycleObservers如何交互。

我们还建立了我们的第一个存储库,在接下来的章节中,当其他数据源被添加到其中时,我们将对其进行扩展。

本章完成的活动作为 Android 应用程序发展方向的一个示例。然而,由于您将发现许多框架和库,这并不是一个完整的示例,这些框架和库将为开发人员提供灵活性,使他们能够朝不同的方向发展。

在本章中学到的信息将为下一章服务,下一章将扩展存储库的概念。这将允许您将从服务器获取的数据保存到 Room 数据库中。持久化数据的概念也将得到扩展,您将探索通过SharedPreferences和文件等其他持久化数据的方式。我们将重点放在某些类型的文件上:从设备相机获取的媒体文件。

第十一章:持久化数据

概述

本章将深入探讨 Android 中的数据持久性,以及探索存储库模式。在本章结束时,您将能够构建一个可以连接到多个数据源的存储库,然后使用该存储库从 API 下载文件并将其保存在设备上。您将了解直接在设备上存储(持久化)数据的多种方法以及可用于执行此操作的框架。在处理文件系统时,您将学习其如何分区以及如何在不同位置和使用不同框架中读取和写入文件。

介绍

在上一章中,您学习了如何构建代码结构以及如何保存数据。在活动中,您还有机会构建一个存储库,并使用它来访问数据并通过 Room 保存数据。您可能会问:为什么需要这个存储库?本章将试图回答这个问题。通过存储库模式,您将能够以集中的方式从服务器检索数据并将其存储在本地。该模式在需要在多个地方使用相同数据的情况下非常有用,从而避免代码重复,同时还保持 ViewModel 清除任何不必要的额外逻辑。

如果您查看设备上的设置应用程序或许多应用程序的设置功能,您将看到一些相似之处。一系列带有可以打开或关闭的切换的项目。这是通过SharedPreferencesPreferenceFragments实现的。SharedPreferences是一种允许您以键值对的方式将值存储在文件中的方法。它具有专门的读写机制,从而消除了关于线程的担忧。它对小量数据非常有用,并消除了对诸如 Room 之类的东西的需求。

在本章中,您还将了解 Android 文件系统以及其如何结构化为外部和内部存储器。您还将加深对读取和写入权限的理解,以及如何创建FileProvider类以便其他应用程序访问您的文件,以及如何在外部驱动器上保存这些文件而无需请求权限。您还将了解如何从互联网下载文件并将其保存在文件系统中。

本章还将探讨的另一个概念是使用相机应用程序代表您的应用程序拍摄照片和视频,并使用 FileProviders 将它们保存到外部存储。

存储库

存储库是一种模式,它帮助开发人员将数据源的代码与活动和 ViewModel 分开。它提供对数据的集中访问,然后可以进行单元测试:

图 11.1:存储库架构图

图 11.1:存储库架构图

在上图中,您可以看到存储库在应用程序代码中的核心作用。其职责包括:

  • 保留活动或应用程序所需的所有数据源(SQLite、网络、文件系统)

  • 将来自多个源的数据组合和转换为活动级别所需的单一输出

  • 将数据从一个数据源传输到另一个数据源(将网络调用的结果保存到 Room 中)

  • 刷新过期数据(如果需要)

Room、网络层和FileManager代表存储库可以拥有的不同类型的数据源。Room 可用于保存来自网络的大量数据,而文件系统可用于存储小量(SharedPreferences)或整个文件。

ViewModel将引用您的存储库并将结果传递给活动,活动将显示结果。

注意

存储库应该根据域进行组织,这意味着您的应用程序应该针对不同的域具有不同的存储库,而不是一个巨大的存储库。

练习 11.01:创建存储库

在这个练习中,我们将在 Android Studio 中创建一个应用程序,该应用程序使用 Retrofit 连接到位于jsonplaceholder.typicode.com/posts的 API,并检索一系列帖子,然后使用 Room 保存。UI 将在RecyclerView中显示每个帖子的标题和正文。我们将使用ViewModel实现存储库模式。

为了完成这个练习,我们需要构建以下内容:

  • 负责下载和解析 JSON 文件的网络组件

  • 负责使用一个实体存储数据的 Room 数据库

  • 管理先前构建的组件之间的数据的存储库

  • 访问存储库的ViewModel

  • 显示数据的带有RecyclerView模型的活动

执行以下步骤以完成此练习:

  1. 让我们从app/build.gradle文件夹开始添加。
    implementation "androidx.constraintlayout       :constraintlayout:2.0.4"
    implementation 'androidx.recyclerview:recyclerview:1.1.0'
    def lifecycle_version = "2.2.0"
    implementation "androidx.lifecycle:lifecycle-extensions       :$lifecycle_version"
    def room_version = "2.2.5"
      implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    implementation 'com.squareup.retrofit2:retrofit:2.6.2'
    implementation 'com.squareup.retrofit2:converter-gson:2.6.2'
    implementation 'com.google.code.gson:gson:2.8.6'
    testImplementation 'junit:junit:4.12'
    testImplementation 'android.arch.core:core-testing:2.1.0'
    testImplementation 'org.mockito:mockito-core:2.23.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-      core:3.3.0
  1. 我们将需要对处理 API 通信的类进行分组。我们将通过创建一个包含所需网络类的api包来实现这一点。

  2. 接下来,我们定义一个Post类,它将映射 JSON 文件中的数据。在我们的新模型中,将定义 JSON 文件中表示帖子的每个字段:

data class Post(
    @SerializedName("id") val id: Long,
    @SerializedName("userId") val userId: Long,
    @SerializedName("title") val title: String,
    @SerializedName("body") val body: String
)
  1. 接下来,我们创建一个PostService接口,负责通过 Retrofit 从服务器加载数据。该类将具有一个用于检索帖子列表的方法,并将执行HTTP GET调用以检索数据:
interface PostService {
    @GET("posts")
    fun getPosts(): Call<List<Post>>
}
  1. 接下来,让我们设置我们的 Room 数据库,其中将包含一个实体和一个数据访问对象。让我们为此定义一个db包。

  2. PostEntity类将与Post类具有类似的字段:

@Entity(tableName = "posts")
data class PostEntity(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id")       val id: Long,
    @ColumnInfo(name = "userId") val userId: Long,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "body") val body: String
)
  1. PostDao应包含用于存储帖子列表和检索帖子列表的方法:
@Dao
interface PostDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertPosts(posts: List<PostEntity>)
    @Query("SELECT * FROM posts")
    fun loadPosts(): LiveData<List<PostEntity>>
}
  1. 最后,在 Room 配置的情况下,Post数据库应如下所示:
@Database(
    entities = [PostEntity::class],
    version = 1
)
abstract class PostDatabase : RoomDatabase() {
    abstract fun postDao(): PostDao
}

现在是时候进入Repository领域了。因此,让我们创建一个存储库包。

  1. 之前,我们定义了两种类型的Post,一个是基于 JSON 的模型,一个是实体。让我们定义一个PostMapper类,将一个转换为另一个:
class PostMapper {
    fun serviceToEntity(post: Post): PostEntity {
        return PostEntity(post.id, post.userId, post.title,           post.body)
    }
}
  1. 现在,让我们定义一个存储库接口,负责加载数据。存储库将从 API 加载数据并使用 Room 存储,然后提供带有 UI 层将消耗的Room实体的LiveData
interface PostRepository {
    fun getPosts(): LiveData<List<PostEntity>>
}
  1. 现在,让我们为此提供实现:
class PostRepositoryImpl(
    private val postService: PostService,
    private val postDao: PostDao,
    private val postMapper: PostMapper,
    private val executor: Executor
) : PostRepository {
    override fun getPosts(): LiveData<List<PostEntity>> {
        postService.getPosts().enqueue(object :           Callback<List<Post>> {
            override fun onFailure(call: Call<List<Post>>, t:               Throwable) {
            }
            override fun onResponse(call: Call<List<Post>>,               response: Response<List<Post>>) {
                response.body()?.let { posts ->
                    executor.execute {
                        postDao.insertPosts(posts.map { post ->
                            postMapper.serviceToEntity(post)
                        })
                    }
                }
            }
        })
        return postDao.loadPosts()
    }
}

如果您查看上述代码,您会看到当加载帖子时,我们将异步调用网络以加载帖子。调用完成后,我们将在单独的线程上使用新的帖子列表更新 Room。该方法将始终返回 Room 返回的内容。这是因为当 Room 中的数据最终发生变化时,它将传播到观察者。

  1. 现在让我们设置我们的依赖关系。因为我们没有依赖注入框架,所以我们将不得不依赖Application类,这意味着我们将需要一个RepositoryApplication类,在其中我们将初始化存储库所需的所有服务,然后创建存储库:
class RepositoryApplication : Application() {
    lateinit var postRepository: PostRepository
    override fun onCreate() {
        super.onCreate()
        val retrofit = Retrofit.Builder()
            .baseUrl("https://jsonplaceholder.typicode.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        val postService =           retrofit.create<PostService>(PostService::class.java)
        val notesDatabase =
            Room.databaseBuilder(applicationContext,               PostDatabase::class.java, "post-db")
                .build()
        postRepository = PostRepositoryImpl(
            postService,
            notesDatabase.postDao(),
            PostMapper(),
            Executors.newSingleThreadExecutor()
        )
    }
}
  1. RepositoryApplication添加到AndroidManifest.xml<application>标签中的android:name

  2. 将互联网权限添加到AndroidManifest.xml文件中:

<uses-permission android:name="android.permission.INTERNET" />
  1. 现在让我们定义我们的ViewModel
class PostViewModel(private val postRepository: PostRepository) :   ViewModel() {
    fun getPosts() = postRepository.getPosts()
}
  1. 每行的view_post_row.xml布局文件将如下所示:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp">
    <TextView
        android:id="@+id/view_post_row_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    <TextView
        android:id="@+id/view_post_row_body"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="5dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf           ="@id/view_post_row_title" />
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 我们活动的activity_main.xml布局文件将如下所示:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/activity_main_recycler_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 用于行的PostAdapter类将如下所示:
class PostAdapter(private val layoutInflater: LayoutInflater) :
    RecyclerView.Adapter<PostAdapter.PostViewHolder>() {
    private val posts = mutableListOf<PostEntity>()
    override fun onCreateViewHolder(parent: ViewGroup, viewType:       Int): PostViewHolder =
        PostViewHolder(layoutInflater.inflate           (R.layout.view_post_row, parent, false))
    override fun getItemCount() = posts.size
    override fun onBindViewHolder(holder: PostViewHolder,       position: Int) {
        holder.bind(posts[position])
    }
    fun updatePosts(posts: List<PostEntity>) {
        this.posts.clear()
        this.posts.addAll(posts)
        this.notifyDataSetChanged()
    }
    inner class PostViewHolder(containerView: View) :       RecyclerView.ViewHolder(containerView) {
        private val titleTextView: TextView =           containerView.findViewById<TextView>            (R.id.view_post_row_title)
        private val bodyTextView: TextView = 
          containerView.findViewById<TextView>            (R.id.view_post_row_body)
        fun bind(postEntity: PostEntity) {
            bodyTextView.text = postEntity.body
            titleTextView.text = postEntity.title
        }
    }
}
  1. 最后,MainActivity文件将如下所示:
class MainActivity : AppCompatActivity() {
    private lateinit var postAdapter: PostAdapter
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        postAdapter = PostAdapter(LayoutInflater.from(this))
        val recyclerView = findViewById<RecyclerView>          (R.id.activity_main_recycler_view)
        recyclerView.adapter = postAdapter
        recyclerView.layoutManager = LinearLayoutManager(this)
        val postRepository = (application as           RepositoryApplication).postRepository
        val postViewModel = ViewModelProvider(this, object :           ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass:               Class<T>): T {
                return PostViewModel(postRepository) as T
            }
        }).get(PostViewModel::class.java)
        postViewModel.getPosts().observe(this, Observer {
            postAdapter.updatePosts(it)
        })
    }
}

如果您运行上述代码,您将看到以下输出:

图 11.2:练习 11.01 的输出

图 11.2:练习 11.01 的输出

您现在可以打开和关闭互联网,关闭和重新打开应用程序,以查看最初持久化的数据是否会继续显示。在当前实现中,错误处理目前为空。这意味着如果在检索帖子列表时出现问题,用户将不会得到通知。这可能会成为一个问题,并使用户感到沮丧。大多数应用程序在其用户界面上显示一些错误消息或其他内容,其中最常见的错误消息之一是“出现问题,请重试”,这是在错误没有被正确识别时用作通用占位符。

练习 11.02:添加错误处理

在这个练习中,我们将修改之前的练习。在出现互联网错误的情况下,我们将确保它会显示一个带有消息“出现问题”的提示。在添加错误处理的过程中,我们还需要通过创建一个新的模型类来消除 UI 和实体类之间的依赖,该模型类将保存相关数据。

为了处理错误,我们需要构建以下内容:

  • 一个新的模型类,只包含正文和文本

  • 一个包含成功、错误和加载三个内部类的密封类

  • 我们的新模型和网络帖子之间的映射函数

执行以下步骤以完成此练习:

  1. 让我们从我们的新模型开始。当与存储库模式结合使用时,这种类型的模型很常见,原因很简单。新模型可能包含特定于此屏幕的数据,需要一些额外的逻辑(假设您有一个具有firstNamelastName的用户,但您的 UI 要求在同一个TextView中显示两者。通过创建一个具有名称字段的新模型,您可以解决此问题,并且还可以对转换进行单元测试,并避免将连接移动到 UI 层):
data class UiPost(
    val title: String,
    val body: String
)
  1. 现在我们来看看我们的新密封类。这个密封类的子类包含了数据加载的所有状态。当存储库开始加载数据时,将发出“加载”状态;当存储库成功加载数据并包含帖子列表时,将发出“成功”状态;当发生错误时,将发出“错误”状态:
sealed class Result {
    object Loading : Result()
    class Success(val uiPosts: List<UiPost>) : Result()
    class Error(val throwable: Throwable) : Result()
}
  1. PostMapper中的映射方法将如下所示。它有一个额外的方法,将从 API 中提取的数据转换为 UI 模型,该模型只包含 UI 正确显示所需的字段:
class PostMapper {
    fun serviceToEntity(post: Post): PostEntity {
        return PostEntity(post.id, post.userId, post.title,           post.body)
    }
    fun serviceToUi(post: Post): UiPost {
        return UiPost(post.title, post.body)
    }
}
  1. 现在,让我们修改PostRepository
interface PostRepository {
    fun getPosts(): LiveData<Result>
}
  1. 现在让我们修改PostRepositoryImpl。我们的结果将是MutableLiveData,它将以“加载”值开始,并根据 HTTP 请求的状态,它将发送一个带有项目列表的“成功”消息,或者带有错误“Retrofit 遇到”的“错误”消息。这种方法将不再依赖于始终显示存储的值。当请求成功时,将传递 HTTP 调用的输出,而不是 Room 的输出:
override fun getPosts(): LiveData<Result> {
        val result = MutableLiveData<Result>()
        result.postValue(Result.Loading)
        postService.getPosts().enqueue(object :           Callback<List<Post>> {
            override fun onFailure(call: Call<List<Post>>, t:               Throwable) {
                result.postValue(Result.Error(t))
            }
            override fun onResponse(call: Call<List<Post>>,               response: Response<List<Post>>) {
                if (response.isSuccessful) {
                    response.body()?.let { posts ->
                        executor.execute {
                            postDao.insertPosts(posts.map                               { post ->
                                postMapper.serviceToEntity(post)
                            })
                            result.postValue(Result                               .Success(posts.map { post ->
                                postMapper.serviceToUi(post)
                            }))
                        }
                    }
                } else {
                    result.postValue(Result.Error                       (RuntimeException("Unexpected error")))
                }
            }
        })
        return result
    }
  1. 在您观察实时数据的活动中,需要实现以下更改。在这里,我们将检查每个状态并相应地更新 UI。如果出现错误,我们显示错误消息;如果成功,我们显示项目列表;当正在加载时,我们显示一个进度条,向用户指示后台正在进行工作:
        postViewModel.getPosts().observe(this,           Observer { result ->
            when (result) {
                is Result.Error -> {
                    Toast.makeText(applicationContext,                       R.string.error_message, Toast.LENGTH_LONG)
                        .show()
                    result.throwable.printStackTrace()
                }
                is Result.Loading -> {
                    // TODO show loading spinner
                }
                is Result.Success -> {
                    postAdapter.updatePosts(result.uiPosts)
                }
            }
        })
  1. 最后,您的适配器应该如下所示:
class PostAdapter(private val layoutInflater: LayoutInflater) :
    RecyclerView.Adapter<PostAdapter.PostViewHolder>() {
    private val posts = mutableListOf<UiPost>()
    override fun onCreateViewHolder(parent: ViewGroup, viewType:       Int): PostViewHolder =
        PostViewHolder(layoutInflater           .inflate(R.layout.view_post_row, parent, false))
    override fun getItemCount(): Int = posts.size
    override fun onBindViewHolder(holder: PostViewHolder,       position: Int) {
        holder.bind(posts[position])
    }
    fun updatePosts(posts: List<UiPost>) {
        this.posts.clear()
        this.posts.addAll(posts)
        this.notifyDataSetChanged()
    }
    inner class PostViewHolder(containerView: View) :       RecyclerView.ViewHolder(containerView) {
        private val titleTextView: TextView =         containerView.findViewById<TextView>          (R.id.view_post_row_title)
        private val bodyTextView: TextView =           containerView.findViewById<TextView>            (R.id.view_post_row_body)
        fun bind(post: UiPost) {
            bodyTextView.text = post.body
            titleTextView.text = post.title
        }
    }
}

当您运行上述代码时,您应该看到图 11.3中呈现的屏幕:

图 11.3:练习 11.02 的输出

图 11.3:练习 11.02 的输出

从这一点开始,存储库可以以多种方式扩展:

  • 添加算法,只有在经过一定时间后才会请求数据

  • 定义一个更复杂的结果类,该类将能够存储缓存数据以及错误消息

  • 添加内存缓存

  • 添加滑动刷新功能,当RecyclerView向下滑动时刷新数据,并将加载小部件连接到Loading状态

偏好设置

假设您的任务是集成使用 OAuth 等内容的第三方 API,以实现使用 Facebook、Google 等方式进行登录。这些机制的工作方式如下:它们会给您一个令牌,您必须将其存储在本地,然后可以使用它发送其他请求以访问用户数据。您面临的问题是:您如何存储该令牌?您是否只使用 Room 存储一个令牌?您是否将令牌保存在单独的文件中,并实现用于编写文件的方法?如果必须同时访问该文件的多个位置怎么办?SharedPreferences是这些问题的答案。SharedPreferences是一种功能,允许您将布尔值、整数、浮点数、长整型、字符串和字符串集保存到 XML 文件中。当您想要保存新值时,您指定要为关联键保存哪些值,完成后,您提交更改,这将以异步方式触发将更改保存到 XML 文件中。SharedPreferences映射也保存在内存中,因此当您想要读取这些值时,它是瞬时的,从而消除了读取 XML 文件的异步调用的需要。

访问SharedPreferences数据的标准方式是通过SharedPreferences对象和更近期的EncryptedSharedPreferences选项(如果您希望保持数据加密)。还有一种通过PreferenceFragments的专门实现。在您想要实现类似设置的屏幕,并且希望存储用户希望调整的不同配置数据的情况下,这些是有用的。

SharedPreferences

访问SharedPreference对象的方式是通过Context对象:

val prefs = getSharedPreferences("my-prefs-file",   Context.MODE_PRIVATE)

第一个参数是您指定偏好名称的地方,第二个是您希望如何将文件暴露给其他应用程序。目前,最佳模式是私有模式。其他所有模式都存在潜在的安全风险。

有一种专门的实现用于访问默认的SharedPreferences文件,这是由PreferenceFragment使用的。

PreferenceManager.getDefaultSharedPreferences(context)

如果要将数据写入偏好文件,首先需要访问偏好编辑器。编辑器将允许您访问写入数据。然后可以在编辑器中写入数据。完成写入后,必须应用更改,这将触发将数据持久保存到 XML 文件,并同时更改内存中的值。对于应用偏好文件上的更改,您有两种选择:applycommitapply将立即保存更改到内存中,但然后写入磁盘将是异步的,这对于您想从应用程序的主线程调用此操作是有利的。 commit会同步执行所有操作,并给您一个布尔结果,通知您操作是否成功。在实践中,apply往往优于commit

     val editor = prefs.edit()
     editor.putBoolean("my_key_1", true)
     editor.putString("my_key_2", "my string")
     editor.putLong("my_key_3", 1L)
     editor.apply()

现在,您想要清除所有数据。同样的原则将适用;您需要editorclearapply

     val editor = prefs.edit()
     editor.clear()
     editor.apply()

如果要读取先前保存的值,可以使用SharedPreferences对象读取存储的值。如果没有保存的值,可以选择返回默认值。

     prefs.getBoolean("my_key_1", false)
     prefs.getString("my_key_2", "")
     prefs.getLong("my_key_3", 0L)

练习 11.03:包装 SharedPreferences

我们将构建一个应用程序,显示TextViewEditText和一个按钮。TextView将显示在SharedPreferences中保存的先前值。用户可以输入新文本,当单击按钮时,文本将保存在SharedPreferences中,TextView将显示更新后的文本。为了使代码更具可测试性,我们需要使用ViewModelLiveData

为了完成这个练习,我们需要创建一个Wrapper类,它将负责保存文本。这个类将以LiveData的形式返回文本的值。这将被注入到我们的ViewModel中,并绑定到活动中:

  1. 让我们首先将适当的库添加到app/build.gradle中:
    implementation       "androidx.constraintlayout:constraintlayout:2.0.4"
    def lifecycle_version = "2.2.0"
    implementation "androidx.lifecycle:lifecycle-      extensions:$lifecycle_version"
    testImplementation 'junit:junit:4.12'
    testImplementation 'android.arch.core:core-testing:2.1.0'
    testImplementation 'org.mockito:mockito-core:2.23.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation       'androidx.test.espresso:espresso-core:3.3.0'
  1. 让我们制作我们的Wrapper类,它将监听SharedPreferences的更改,并在偏好更改时更新LiveData的值。该类将包含保存新文本和检索LiveData的方法:
const val KEY_TEXT = "keyText"
class PreferenceWrapper(private val sharedPreferences:   SharedPreferences) {
    private val textLiveData = MutableLiveData<String>()
    init {
        sharedPreferences           .registerOnSharedPreferenceChangeListener { _, key ->
            when (key) {
                KEY_TEXT -> {
                    textLiveData.postValue(sharedPreferences                       .getString(KEY_TEXT, ""))
                }
            }
        }
    }
    fun saveText(text: String) {
        sharedPreferences.edit()
            .putString(KEY_TEXT, text)
            .apply()
    }
    fun getText(): LiveData<String> {
        textLiveData.postValue(sharedPreferences           .getString(KEY_TEXT, ""))
        return textLiveData
    }
}

注意文件顶部。我们添加了一个监听器,这样当我们的SharedPreferences值改变时,我们可以查找新值并更新我们的LiveData模型。这将允许我们观察LiveData的任何更改并只更新 UI。saveText方法将打开编辑器,设置新值并应用更改。getText方法将读取上次保存的值,在LiveData中设置它,并返回LiveData对象。这在应用程序打开并且我们想要在应用程序关闭之前访问上次的值时非常有用。

  1. 现在,让我们使用偏好设置的实例设置Application类:
class PreferenceApplication : Application() {
    lateinit var preferenceWrapper: PreferenceWrapper
    override fun onCreate() {
        super.onCreate()
        preferenceWrapper =           PreferenceWrapper(getSharedPreferences("prefs",             Context.MODE_PRIVATE))
    }
}
  1. 现在,让我们在AndroidManifest.xmlapplication标签中添加适当的属性:
android:name=".PreferenceApplication"
  1. 现在,让我们构建ViewModel组件:
class PreferenceViewModel(private val preferenceWrapper:   PreferenceWrapper) : ViewModel() {
    fun saveText(text: String) {
        preferenceWrapper.saveText(text)
    }
    fun getText(): LiveData<String> {
        return preferenceWrapper.getText()
    }
}
  1. 最后,让我们定义我们的activity_main.xml布局文件:
activity_main.xml
9    <TextView
10        android:id="@+id/activity_main_text_view"
11        android:layout_width="wrap_content"
12        android:layout_height="wrap_content"
13        android:layout_marginTop="50dp"
14        app:layout_constraintLeft_toLeftOf="parent"
15        app:layout_constraintRight_toRightOf="parent"
16        app:layout_constraintTop_toTopOf="parent" />
17
18    <EditText
19        android:id="@+id/activity_main_edit_text"
20        android:layout_width="200dp"
21        android:layout_height="wrap_content"
22        android:inputType="none"
23        app:layout_constraintLeft_toLeftOf="parent"
24        app:layout_constraintRight_toRightOf="parent"
25        app:layout_constraintTop_toBottomOf=             "@id/activity_main_text_view" />
26
27    <Button
28        android:id="@+id/activity_main_button"
29        android:layout_width="wrap_content"
30        android:layout_height="wrap_content"
31        android:inputType="none"
32        android:text="@android:string/ok"
33        app:layout_constraintLeft_toLeftOf="parent"
34        app:layout_constraintRight_toRightOf="parent"
35        app:layout_constraintTop_toBottomOf=            "@id/activity_main_edit_text" /> 
The complete code for this step can be found at http://packt.live/39RhIj0.
  1. 最后,在MainActivity中执行以下步骤:
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val preferenceWrapper = (application as         PreferenceApplication).preferenceWrapper
        val preferenceViewModel = ViewModelProvider(this, object           : ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass:               Class<T>): T {
                return PreferenceViewModel(preferenceWrapper)                   as T
            }
        }).get(PreferenceViewModel::class.java)
        preferenceViewModel.getText().observe(this, Observer {
        findViewById<TextView>(R.id.activity_main_text_view)           .text = it
        })
        findViewById<Button>(R.id.activity_main_button)          .setOnClickListener {
        preferenceViewModel.saveText(findViewById<EditText>          (R.id.activity_main_edit_text).text.toString())
        }
    }
}

上述代码将产生图 11.4中呈现的输出:

图 11.4:练习 11.03 的输出

图 11.4:练习 11.03 的输出

插入值后,尝试关闭应用程序并重新打开它。应用程序将显示上次持久化的值。

PreferenceFragment

如前所述,PreferenceFragment是依赖于SharedPreferences来存储用户设置的片段的专门实现。其功能包括基于开/关切换存储布尔值,基于向用户显示的对话框存储文本,基于单选和多选对话框存储字符串集,基于SeekBars存储整数,并对部分进行分类并链接到其他PreferenceFragment类。

虽然PreferenceFragment类是 Android 框架的一部分,但它们被标记为已弃用,这意味着片段的推荐方法是依赖于 Jetpack Preference 库,该库引入了PreferenceFragmentCompatPreferenceFragmentCompat对确保新的 Android 框架和旧的 Android 框架之间的向后兼容性非常有用。

构建PreferenceFragment类需要两个东西:

  • res/xml文件夹中的资源,其中包含偏好设置的结构

  • 一个扩展PreferenceFragment的类,它将 XML 文件与片段链接起来

如果您想从非PreferenceFragment资源访问您的PreferenceFragment存储的值,可以使用PreferenceManager.getDefaultSharedPreferences(context)方法访问SharedPreference对象。访问值的键是您在 XML 文件中定义的键。

名为 settings_preference.xml 的偏好 XML 文件示例如下:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
    <PreferenceCategory app:title="Primary settings">
        <SwitchPreferenceCompat
            app:key="work_offline"
            app:title="Work offline" />
        <Preference
            app:icon="@mipmap/ic_launcher"
            app:key="my_key"
            app:summary="Summary"
            app:title="Title" />
    </PreferenceCategory>
</PreferenceScreen>

对于每个偏好设置,您可以显示图标、标题、摘要、当前值以及它是否可选择。一个重要的事情是键以及如何将其链接到您的 Kotlin 代码。您可以使用strings.xml文件声明不可翻译的字符串,然后在您的 Kotlin 代码中提取它们。

您的PreferenceFragment将类似于这样:

class MyPreferenceFragment : PreferenceFragmentCompat() {
    override fun onCreatePreferences(savedInstanceState: Bundle?,       rootKey: String?) {
        setPreferencesFromResource(R.xml.settings_preferences,           rootKey)
    }
}

onCreatePreferences方法是抽象的,您需要实现它以通过setPreferencesFromResource方法指定偏好设置的 XML 资源。

您还可以使用findPreference方法以编程方式访问偏好设置:

findPreference<>(key)

这将返回一个将从Preference扩展的对象。对象的性质应与在 XML 中为该特定键声明的类型匹配。您可以以编程方式修改Preference对象并更改所需的字段。

您还可以使用PreferenceFragment中继承的PreferenceManager类上的createPreferenceScreen(Context)来以编程方式构建设置屏幕:

val preferenceScreen =   preferenceManager.createPreferenceScreen(context)

您可以在PreferenceScreen容器上使用addPreference(Preference)方法添加新的Preference对象:

val editTextPreference = EditTextPreference(context)
editTextPreference.key = "key"
editTextPreference.title = "title"
val preferenceScreen = preferenceManager.createPreferenceScreen(context)
preferenceScreen.addPreference(editTextPreference)
setPreferenceScreen(preferenceScreen)

现在让我们继续下一个练习,自定义您的设置。

练习 11.04:自定义设置

在这个练习中,我们将构建 VPN 应用的设置。设置页面的产品要求如下:

  • SeekBar

  • 配置:IP 地址 - 文本;域 - 文本

  • 使用移动数据,带有一个切换和一个下面包含文本明智地管理您的移动数据的不可选择选项。

执行以下步骤以完成此练习:

  1. 让我们首先添加 Jetpack Preference 库:
implementation 'androidx.preference:preference-ktx:1.1.1'
  1. res/values中,创建一个名为preference_keys.xml的文件,并定义More preferences屏幕的键:
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="key_mobile_data"       translatable="false">mobile_data</string>
</resources>
  1. 如果res中没有xml文件夹,请创建一个。

  2. res/xml文件夹中创建preferences_more.xml文件。

  3. preferences_more.xml文件中,添加以下首选项:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app=  "http://schemas.android.com/apk/res-auto">
    <SwitchPreferenceCompat
        app:key="@string/key_mobile_data"
        app:title="@string/mobile_data" />
    <Preference
        app:selectable="false"
        app:summary="@string/manage_data_wisely" />
</PreferenceScreen>
  1. strings.xml中,添加以下字符串:
<string name="mobile_data">Mobile data</string>
<string name="manage_data_wisely">Manage your data   wisely</string>
  1. 创建一个名为MorePreferenceFragmentPreferenceFragment类:
class MorePreferenceFragment : PreferenceFragmentCompat() {
    override fun onCreatePreferences(savedInstanceState: Bundle?,       rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences_more,           rootKey)
    }
}

我们已经完成了More部分。现在让我们创建主要部分。

  1. 让我们为主要首选项部分创建键。在preference_keys.xml中,添加以下内容:
<string name="key_network_scan"   translatable="false">network_scan</string>
<string name="key_frequency"   translatable="false">frequency</string>
<string name="key_ip_address"   translatable="false">ip_address</string>
<string name="key_domain" translatable="false">domain</string>
  1. res/xml中,创建preferences_settings.xml文件。

  2. 现在,根据规格定义您的首选项:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app=  "http://schemas.android.com/apk/res-auto">
    <PreferenceCategory app:title="@string/connectivity">
        <SwitchPreferenceCompat
            app:key="@string/key_network_scan"
            app:title="@string/network_scan" />
        <SeekBarPreference
            app:key="@string/key_frequency"
            app:title="@string/frequency" />
    </PreferenceCategory>
    <PreferenceCategory app:title="@string/configuration">
        <EditTextPreference
            app:key="@string/key_ip_address"
            app:title="@string/ip_address" />
        <EditTextPreference
            app:key="@string/key_domain"
            app:title="@string/domain" />
    </PreferenceCategory>
PreferenceFragment and another. By default, the system will do the transition for us, but there is a way to override this behavior in case we want to update our UI.
  1. strings.xml中,确保您有以下值:
<string name="connectivity">Connectivity</string>
<string name="network_scan">Network scan</string>
<string name="frequency">Frequency</string>
<string name="configuration">Configuration</string>
<string name="ip_address">IP Address</string>
<string name="domain">Domain</string>
<string name="more">More</string>
  1. 创建一个名为SettingsPreferenceFragment的片段。

  2. 添加以下设置:

class SettingsPreferenceFragment : PreferenceFragmentCompat() {
    override fun onCreatePreferences(savedInstanceState: Bundle?,       rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences_settings,           rootKey)
    }
}
  1. 现在,让我们将Fragments添加到我们的活动中。

  2. activity_main.xml中,定义一个FrameLayout标签来包含片段:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout   xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:id="@+id/fragment_container"/>
  1. 最后,在MainActivity中执行以下步骤:
class MainActivity : AppCompatActivity(),
    onPreferenceStartFragment from the PreferenceFragmentCompat.OnPreferenceStartFragmentCallback interface. This allows us to intercept the switch between fragments and add our own behavior. The first half of the method will use the inputs of the method to create a new instance of MorePreferenceFragment, while the second half performs the fragment transaction. Then, we return true because we have handled the transition ourselves.
  1. 运行上述代码将产生以下输出:图 11.5:练习 11.04 的输出

图 11.5:练习 11.04 的输出

现在,我们可以监视首选项的更改并在 UI 中显示它们。我们可以将此功能应用于 IP 地址和域部分,以显示用户输入的摘要。

  1. 现在让我们修改SettingsPreferenceFragment,以便在值更改时以编程方式设置监听器,这将在摘要中显示新值。当首次打开屏幕时,我们还需要设置保存的值。我们需要使用findPreference(key)来定位我们想要修改的首选项。这允许我们以编程方式修改首选项。我们还可以在首选项上注册监听器,这将使我们能够访问新值。在我们的情况下,我们可以注册一个监听器,以便在 IP 地址更改时更新字段的摘要,这样我们就可以根据用户在EditText中输入的内容更新字段的摘要:
class SettingsPreferenceFragment : PreferenceFragmentCompat() {
    override fun onCreatePreferences(savedInstanceState: Bundle?,       rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences_settings,           rootKey)
        val ipAddressPref =           findPreference<EditTextPreference>(getString             (R.string.key_ip_address))
        ipAddressPref?.setOnPreferenceChangeListener {           preference, newValue ->
            preference.summary = newValue.toString()
            true
        }
        val domainPref = findPreference<EditTextPreference>          (getString(R.string.key_domain))
        domainPref?.setOnPreferenceChangeListener { preference,           newValue ->
            preference.summary = newValue.toString()
            true
        }
        val sharedPrefs = PreferenceManager           .getDefaultSharedPreferences(requireContext())
        ipAddressPref?.summary = sharedPrefs           .getString(getString(R.string.key_ip_address), "")
        domainPref?.summary = sharedPrefs           .getString(getString(R.string.key_domain), "")
    }
}

PreferenceFragment是为任何应用构建类似设置功能的好方法。它与SharedPreferences的集成和内置 UI 组件允许开发人员比通常更快地构建元素,并解决处理每个设置元素的点击和插入的许多问题。

文件

我们已经讨论了 Room 和SharedPreferences,并指定了它们存储的数据是如何写入文件的。您可能会问自己,这些文件存储在哪里?这些特定的文件存储在内部存储中。内部存储是每个应用程序的专用空间,其他应用程序无法访问(除非设备已 root)。您的应用程序使用的存储空间没有限制。但是,用户可以从“设置”菜单中删除您的应用程序文件的能力。内部存储占用总可用空间的一小部分,这意味着在存储文件时应该小心。还有外部存储。您的应用程序存储的文件可供其他应用程序访问,其他应用程序存储的文件也可供您的应用程序访问:

注意

在 Android Studio 中,您可以使用设备文件浏览器工具浏览设备或模拟器上的文件。内部存储位于/data/data/{packageName}。如果您可以访问此文件夹,这意味着设备已经 root。使用这个,您可以可视化数据库文件和SharedPreferences文件。

图 11.6:Android 设备文件浏览器

图 11.6:Android 设备文件浏览器

内部存储

内部存储不需要用户的权限。要访问内部存储目录,可以使用Context对象的以下方法之一:

  • getDataDir(): 返回应用沙盒的根文件夹。

  • getFilesDir(): 一个专门用于应用文件的文件夹;推荐使用。

  • getCacheDir(): 一个专门用于缓存文件的文件夹。在这里存储文件并不保证以后可以检索到它们,因为系统可能决定删除此目录以释放内存。这个文件夹与“设置”中的“清除缓存”选项相关联。

  • getDir(name, mode): 返回一个文件夹,如果不存在则根据指定的名称创建。

当用户从“设置”中使用“清除数据”选项时,大多数这些文件夹将被删除,使应用程序回到类似于新安装的状态。当应用被卸载时,这些文件也将被删除。

读取缓存文件的典型示例如下:

        val cacheDir = context.cacheDir
        val fileToReadFrom = File(cacheDir, "my-file.txt")
        val size = fileToReadFrom.length().toInt()
        val bytes = ByteArray(size)
        val tmpBuff = ByteArray(size)
        val fis = FileInputStream(fileToReadFrom)
        try {
            var read = fis.read(bytes, 0, size)
            if (read < size) {
                var remain = size - read
                while (remain > 0) {
                    read = fis.read(tmpBuff, 0, remain)
                    System.arraycopy(tmpBuff, 0, bytes,                                      size - remain, read)
                    remain -= read
                }
            }
        } catch (e: IOException) {
            throw e
        } finally {
            fis.close()
        }

上面的示例将从Cache目录中的my-file.txt读取,并为该文件创建FileInputStream。然后,将使用一个缓冲区来收集文件中的字节。收集到的字节将被放入bytes字节数组中,其中包含从该文件中读取的所有数据。当文件的整个长度被读取时,读取将停止。

写入my-file.txt文件将如下所示:

        val bytesToWrite = ByteArray(100)
        val cacheDir = context.cacheDir
        val fileToWriteIn = File(cacheDir, "my-file.txt")
        try {
            if (!fileToWriteIn.exists()) {
                fileToWriteIn.createNewFile()
            }
            val fos = FileOutputStream(fileToWriteIn)
            fos.write(bytesToWrite)
            fos.close()
        } catch (e: Exception) {
            e.printStackTrace()
        }

上面的示例所做的是获取要写入的字节数组,创建一个新的File对象,如果不存在则创建文件,并通过FileOutputStream将字节写入文件。

注意

处理文件有许多替代方法。读取器(StreamReaderStreamWriter等)更适合基于字符的数据。还有第三方库可以帮助进行磁盘 I/O 操作。其中一个最常见的帮助进行 I/O 操作的第三方是 Okio。它起初是OkHttp库的一部分,用于与 Retrofit 一起进行 API 调用。Okio 提供的方法与它用于在 HTTP 通信中写入和读取数据的方法相同。

外部存储

在外部存储中读写需要用户的读写权限。如果授予写入权限,则您的应用程序可以读取外部存储。一旦这些权限被授予,您的应用程序就可以在外部存储上做任何它想做的事情。这可能会带来问题,因为用户可能不选择授予这些权限。然而,有专门的方法可以让您在专门为您的应用程序提供的外部存储中进行写入。

ContextEnvironment对象中访问外部存储的一些常见方式是:

  • Context.getExternalFilesDir(mode):这个方法将返回专门为你的应用程序在外部存储上的目录路径。指定不同的模式(图片、电影等)将创建不同的子文件夹,具体取决于你希望如何保存你的文件。这个方法不需要权限

  • Context.getExternalCacheDir():这将指向外部存储上应用程序的缓存目录。对这个cache文件夹应用相同的考虑。这个方法不需要权限

  • Environment类可以访问设备上一些最常见文件夹的路径。然而,在新设备上,应用可能无法访问这些文件和文件夹。

注意

避免使用硬编码的文件和文件夹路径。安卓操作系统可能会根据设备或操作系统的不同而改变文件夹的位置。

FileProvider

这代表了ContentProviders的一个专门实现,有助于组织应用程序的文件和文件夹结构。它允许你指定一个 XML 文件,在其中定义你的文件应该如何在内部和外部存储之间分割。它还让你有能力通过隐藏路径并生成一个唯一的 URI 来授予其他应用程序对你的文件的访问权限。

FileProvider让你可以在六个不同的文件夹中选择设置你的文件夹层次结构:

  • Context.getFilesDir()(文件路径)

  • Context.getCacheDir()(缓存路径)

  • Environment.getExternalStorageDirectory()(外部路径)

  • Context.getExternalFilesDir(null)(外部文件路径)

  • Context.getExternalCacheDir()(外部缓存路径)

  • Context.getExternalMediaDirs()的第一个结果(外部媒体路径)

FileProvider的主要优点在于它提供了对文件的抽象,因为它让开发人员在 XML 文件中定义路径,并且更重要的是,如果你选择将文件存储在外部存储上,你不需要向用户请求权限。另一个好处是它使共享内部文件更容易,同时让开发人员控制其他应用程序可以访问哪些文件,而不会暴露它们的真实位置。

让我们通过以下例子更好地理解:

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <files-path name="my-visible-name" path="/my-folder-name" />
</paths>

上述例子将使FileProvider使用内部的files目录,并创建一个名为my-folder-name的文件夹。当路径转换为 URI 时,URI 将使用my-visible-name

存储访问框架(SAF)

SAF 是在 Android KitKat 中引入的文件选择器,应用程序可以使用它让用户选择要处理或上传的文件。你可以在你的应用程序中使用它来处理以下情况:

  1. 你的应用程序需要用户处理由其他应用程序保存在设备上的文件(照片和视频)。

  2. 你希望在设备上保存一个文件,并让用户选择文件的保存位置和文件的名称。

  3. 你希望为你的应用程序使用的文件提供给其他应用程序,以满足类似于第 1 种情况的场景。

这再次有用,因为你的应用程序将避免读写权限,但仍然可以写入和访问外部存储。这是基于意图的工作方式。你可以使用Intent.ACTION_OPEN_DOCUMENTIntent.ACTION_CREATE_DOCUMENT启动一个活动以获取结果。然后,在onActivityResult中,系统将给你一个 URI,授予你对该文件的临时权限,允许你读写。

SAF 的另一个好处是文件不必在设备上。诸如谷歌云这样的应用程序在 SAF 中公开其内容,当选择谷歌云文件时,它将被下载到设备,并且 URI 将作为结果发送。另一个重要的事情是 SAF 对虚拟文件的支持,这意味着它将公开谷歌文档,这些文档有自己的格式,但是当这些文档通过 SAF 下载时,它们的格式将被转换为 PDF 等通用格式。

资产文件

资产文件是您可以打包为 APK 的文件。如果您使用过在应用程序启动时或作为教程的一部分播放某些视频或 GIF 的应用程序,那么这些视频很可能已经与 APK 捆绑在一起。要将文件添加到资产中,您需要项目中的assets文件夹。然后,您可以使用文件夹将文件分组到资产中。

您可以通过AssetManager类在运行时访问这些文件,AssetManager本身可以通过上下文对象访问。AssetManager为您提供了查找文件和读取文件的能力,但不允许任何写操作:

        val assetManager = context.assets
        val root = ""
        val files = assetManager.list(root)
        files?.forEach {
            val inputStream = assetManager.open(root + it)
        }

前面的示例列出了assets文件夹根目录中的所有文件。open函数返回inputStream,如果需要,可以用它来读取文件信息。

assets文件夹的一个常见用途是用于自定义字体。如果您的应用程序使用自定义字体,那么可以使用assets文件夹来存储字体文件。

练习 11.05:复制文件

注意

对于这个练习,您将需要一个模拟器。您可以在 Android Studio 中选择Tools | AVD Manager来创建一个。然后,您可以使用Create Virtual Device选项创建一个,选择模拟器类型,单击Next,然后选择 x86 映像。大于棒棒糖的任何映像都应该适用于这个练习。接下来,您可以给您的映像命名并单击Finish

让我们创建一个应用程序,将在assets目录中保留一个名为my-app-file.txt的文件。该应用程序将显示两个名为FileProviderSAF的按钮。单击FileProvider按钮时,文件将保存在应用程序的外部存储专用区域(Context.getExternalFilesDir(null))。SAF按钮将打开 SAF,并允许用户指示文件应保存在何处。

为了实现这个练习,将采用以下方法:

  • 定义一个文件提供程序,它将使用Context.getExternalFilesDir(null)位置。

  • 单击FileProvider按钮时,将my-app-file.txt复制到前面的位置。

  • 单击SAF按钮时使用Intent.ACTION_CREATE_DOCUMENT,并将文件复制到提供的位置。

  • 为文件复制使用单独的线程,以符合 Android 指南。

  • 使用 Apache IO 库来帮助文件复制功能,提供允许我们从 InputStream 复制数据到 OutputStream 的方法。

完成的步骤如下:

  1. 让我们从 Gradle 配置开始:
implementation 'commons-io:commons-io:2.6'
testImplementation 'org.mockito:mockito-core:2.23.0'
  1. main/assets文件夹中创建my-app-file.txt文件。随意填写您想要阅读的文本。如果main/assets文件夹不存在,则可以创建它。要创建assets文件夹,可以右键单击main文件夹,然后选择New,然后选择Directory并命名为assets。此文件夹现在将被构建系统识别,并且其中的任何文件也将与应用程序一起安装在设备上。

  2. 我们还可以定义一个类,它将包装AssetManager并定义一个访问这个特定文件的方法:

class AssetFileManager(private val assetManager: AssetManager) {
    fun getMyAppFileInputStream() =       assetManager.open("my-app-file.txt")
}
  1. 现在,让我们来处理FileProvider方面。在res文件夹中创建xml文件夹。在新文件夹中定义file_provider_paths.xml。我们将定义external-files-path,命名为docs,并将其放在docs/文件夹中:
<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-files-path name="docs" path="docs/"/>
</paths>
  1. 接下来,我们需要将FileProvider添加到AndroidManifest.xml文件中,并将其与我们定义的新路径链接起来:
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.android.testable.files"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support                               .FILE_PROVIDER_PATHS"
                android:resource="@xml/file_provider_paths" />
        </provider>

名称将指向 Android 支持库的FileProvider路径。authorities 字段表示应用程序的域(通常是应用程序的包名称)。exported 字段指示我们是否希望与其他应用程序共享我们的提供程序,grantUriPermissions指示我们是否希望通过 URI 授予其他应用程序对某些文件的访问权限。meta-data 将我们之前定义的 XML 文件与FileProvider链接起来。

  1. 定义ProviderFileManager类,负责访问docs文件夹并将数据写入文件:
class ProviderFileManager(
    private val context: Context,
    getDocsFolder will return the path to the docs folder we defined in the XML. If the folder does not exist, then it will be created. The writeStream method will extract the URI for the file we wish to save and, using the Android ContentResolver class, will give us access to the OutputStream class of the file we will be saving into. Notice that FileToUriMapper doesn't exist yet. The code is moved into a separate class in order to make this class testable.
  1. FileToUriMapper类如下所示:
class FileToUriMapper {
    fun getUriFromFile(context: Context, file: File): Uri {
        getUriForFile method is part of the FileProvider class and its role is to convert the path of a file into a URI that can be used by ContentProviders/ContentResolvers to access data. Because the method is static, it prevents us from testing properly.Notice the test rule we used. This comes in handy when testing files. What it does is supply the test with the necessary files and folders and when the test finishes, it will remove all the files and folders.
  1. 现在让我们继续定义activity_main.xml文件的 UI:
activity_main.xml
9    <Button
10        android:id="@+id/activity_main_file_provider"
11        android:layout_width="wrap_content"
12        android:layout_height="wrap_content"
13        android:layout_marginTop="200dp"
14        android:text="@string/file_provider"
15        app:layout_constraintEnd_toEndOf="parent"
16        app:layout_constraintStart_toStartOf="parent"
17        app:layout_constraintTop_toTopOf="parent" />
18
19    <Button
20        android:id="@+id/activity_main_saf"
21        android:layout_width="wrap_content"
22        android:layout_height="wrap_content"
23        android:layout_marginTop="50dp"
24        android:text="@string/saf"
25        app:layout_constraintEnd_toEndOf="parent"
26        app:layout_constraintStart_toStartOf="parent"
27        app:layout_constraintTop_toBottomOf=            "@id/activity_main_file_provider" /> 
The complete code for this step can be found at http://packt.live/3bTNmz4.
  1. 现在,让我们定义我们的MainActivity类:
class MainActivity : AppCompatActivity() {
    private val assetFileManager: AssetFileManager by lazy {
        AssetFileManager(applicationContext.assets)
    }
    private val providerFileManager: ProviderFileManager by lazy {
        ProviderFileManager(
            applicationContext,
            FileToUriMapper(),
            Executors.newSingleThreadExecutor()
        )
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<Button>(R.id.activity_main_file_provider)          .setOnClickListener {
            val newFileName = "Copied.txt"
MainActivity to create our objects and inject data into the different classes we have. If we execute this code and click the FileProvider button, we don't see an output on the UI. However, if we look with Android Device File Explorer, we can locate where the file was saved. The path may be different on different devices and operating systems. The paths could be as follows:*   `mnt/sdcard/Android/data/<package_name>/files/docs`*   `sdcard/Android/data/<package_name>/files/docs`*   `storage/emulated/0/Android/data/<package_name>/files/docs`

输出如下:

图 11.7:通过 FileProvider 复制的输出

图 11.7:通过 FileProvider 复制的输出

  1. 让我们为SAF按钮添加逻辑。我们需要启动一个指向SAF的活动,并使用CREATE_DOCUMENT意图,指定我们要创建一个文本文件。然后我们需要SAF的结果,这样我们就可以将文件复制到用户选择的位置。在MainActivityonCreateMethod中,我们可以添加以下内容:
        findViewById<Button>(R.id.activity_main_saf)      .setOnClickListener {
            if (Build.VERSION.SDK_INT >=               Build.VERSION_CODES.KITKAT) {
                val intent =                   Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
                    addCategory(Intent.CATEGORY_OPENABLE)
                    type = "text/plain"
                    putExtra(Intent.EXTRA_TITLE, "Copied.txt")
                }
                startActivityForResult(intent,                   REQUEST_CODE_CREATE_DOC)
            }
        }

上述代码将创建一个意图,以创建一个名为Copied.txt的文档,并使用text/plain MIME(多用途互联网邮件扩展)类型(适用于文本文件)。此代码仅在大于 KitKat 的 Android 版本中运行。

  1. 现在让我们告诉活动如何处理文档创建的结果。我们将收到一个 URI 对象,其中用户选择了一个空文件。现在我们可以将我们的文件复制到该位置。在MainActivity中,我们添加onActivityResult,如下所示:
    override fun onActivityResult(requestCode: Int, resultCode:       Int, data: Intent?) {
        if (requestCode == REQUEST_CODE_CREATE_DOC           && resultCode == Activity.RESULT_OK) {
            data?.data?.let { uri ->
            }
        } else {
            super.onActivityResult(requestCode, resultCode, data)
        }
    }
  1. 现在我们有了 URI。我们可以在ProviderFileManager中添加一个方法,将我们的文件复制到uri指定的位置:
    fun writeStreamFromUri(name: String, inputStream:       InputStream, uri:Uri){
        executor.execute {
            val outputStream =               context.contentResolver.openOutputStream(uri, "rw")
            IOUtils.copy(inputStream, outputStream)
        }
    }
  1. 我们可以从MainActivityonActivityResult方法中调用此方法,如下所示:
        if (requestCode == REQUEST_CODE_CREATE_DOC           && resultCode == Activity.RESULT_OK) {
            data?.data?.let { uri ->
                val newFileName = "Copied.txt"
                providerFileManager.writeStreamFromUri(
                    newFileName,
                    assetFileManager.getMyAppFileInputStream(),
                    uri
                )
            }
        }

如果我们运行上述代码并单击SAF按钮,我们将看到图 11.8中呈现的输出:

图 11.8:通过 SAF 复制的输出

图 11.8:通过 SAF 复制的输出

如果您选择保存文件,SAF 将关闭,并且我们的活动的onActivityResult方法将被调用,这将触发文件复制。之后,您可以导航到 Android 设备文件管理器工具,查看文件是否已正确保存。

作用域存储

自 Android 10 以来,并在 Android 11 中进一步更新,引入了作用域存储的概念。其背后的主要思想是允许应用程序更多地控制外部存储上的文件,并防止其他应用程序访问这些文件。这意味着READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE仅适用于用户与之交互的文件(如媒体文件)。这会阻止应用程序在外部存储上创建自己的目录,而是坚持使用通过Context.getExternalFilesDir提供给它们的目录。

FileProviders 和存储访问框架是保持应用程序符合作用域存储实践的好方法,因为其中一个允许应用程序使用Context.getExternalFilesDir,另一个使用内置的文件浏览器应用程序,现在将避免在外部存储的Android/dataAndroid/obb文件夹中的其他应用程序文件。

相机和媒体存储

Android 提供了多种与 Android 设备上的媒体交互的方式,从构建自己的相机应用程序并控制用户如何拍照和录像,到使用现有的相机应用程序并指导其如何拍照和录像。Android 还配备了MediaStore内容提供程序,允许应用程序提取有关设备上设置的媒体文件和应用程序之间共享的媒体文件的信息。这在您希望为设备上存在的媒体文件(如照片或音乐播放器应用程序)自定义显示的情况下非常有用,并且在使用MediaStore.ACTION_PICK意图从设备中选择照片并希望提取所选媒体图像的信息的情况下也非常有用(这通常是旧应用程序的情况,无法使用 SAF)。

要使用现有的相机应用程序,您需要使用MediaStore.ACTION_IMAGE_CAPTURE意图启动相机应用程序以获取结果,并传递您希望保存的图像的 URI。然后用户将转到相机活动,拍照,然后您处理操作的结果:

        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
        startActivityForResult(intent, REQUEST_IMAGE_CAPTURE)

photoUri参数将表示您希望保存照片的位置。它应指向一个具有 JPEG 扩展名的空文件。您可以通过两种方式构建此文件:

  • 在外部存储上使用File对象创建文件(这需要WRITE_EXTERNAL_STORAGE权限),然后使用Uri.fromFile()方法将其转换为URI - 在 Android 10 及以上版本不再适用

  • 使用File对象在FileProvider位置创建文件,然后使用FileProvider.getUriForFile()方法获取 URI 并在必要时授予权限-适用于您的应用程序目标为 Android 10 和 Android 11 的推荐方法

注意

相同的机制也可以应用于使用MediaStore.ACTION_VIDEO_CAPTURE的视频。

如果您的应用程序严重依赖相机功能,则可以通过将<uses-feature>标签添加到AndroidManifest.xml文件中来排除没有相机的设备的用户。您还可以将相机指定为非必需,并使用Context.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)方法查询相机是否可用。

如果您希望将文件保存在MediaStore中,有多种方法可以实现:

  • 发送带有媒体 URI 的ACTION_MEDIA_SCANNER_SCAN_FILE广播:
            val intent =               Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
       intent.data = photoUri
       sendBroadcast(intent)
  • 使用媒体扫描程序直接扫描文件:
        val paths = arrayOf("path1", "path2")
        val mimeTypes= arrayOf("type1", "type2")
        MediaScannerConnection.scanFile(context,paths,           mimeTypes) { path, uri ->
        }
  • 直接将媒体插入ContentProvider使用ContentResolver
        val contentValues = ContentValues()
        contentValues.put(MediaStore.Images.ImageColumns.TITLE,           "my title")
            contentValues.put(MediaStore.Images.ImageColumns               .DATE_ADDED, timeInMillis)
            contentValues.put(MediaStore.Images.ImageColumns               .MIME_TYPE, "image/*")
            contentValues.put(MediaStore.Images.ImageColumns               .DATA, "my-path")
            val newUri = contentResolver.insert(MediaStore.Video               .Media.EXTERNAL_CONTENT_URI, contentValues)
                newUri?.let { 
              val outputStream = contentResolver                 .openOutputStream(newUri)
                // Copy content in outputstream
            }

注意

在 Android 10 及以上版本中,MediaScanner功能不再添加来自Context.getExternalFilesDir的文件。如果应用程序选择与其他应用程序共享其媒体文件,则应依赖insert方法。

练习 11.06:拍照

我们将构建一个应用程序,其中有两个按钮:第一个按钮将打开相机应用程序以拍照,第二个按钮将打开相机应用程序以录制视频。我们将使用FileProvider将照片保存到外部存储(external-path)中的两个文件夹:picturesmovies。照片将使用img_{timestamp}.jpg保存,视频将使用video_{timestamp}.mp4保存。保存照片和视频后,您将从FileProvider复制文件到MediaStore中,以便其他应用程序可以看到。

  1. 让我们在app/build.gradle中添加库:
    implementation 'commons-io:commons-io:2.6'
    testImplementation 'org.mockito:mockito-core:2.23.0'
  1. 我们将以 Android 11 为目标,这意味着我们需要在app/build.gradle中进行以下配置
...
compileSdkVersion 30
    defaultConfig {
        ...
        targetSdkVersion 30
        ...
    }
...
  1. 我们需要为低于 Android 10 的设备请求 WRITE_EXTERNAL_STORAGE 权限,这意味着我们需要在AndroidManifest.xml中添加以下内容:
<uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />
  1. 让我们定义一个FileHelper类,其中包含一些在test包中难以测试的方法:
class FileHelper(private val context: Context) {
    fun getUriFromFile(file: File): Uri {
        return FileProvider.getUriForFile(context,           "com.android.testable.camera", file)
    }
    fun getPicturesFolder(): String =       Environment.DIRECTORY_PICTURES

    fun getVideosFolder(): String = Environment.DIRECTORY_MOVIES
}
  1. 让我们在res/xml/file_provider_paths.xml中定义我们的FileProvider路径。确保在FileProvider中包含适当的应用程序包名称:
<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path name="photos" path="Android/data       /com.android.testable.camera/files/Pictures"/>
    <external-path name="videos" path="Android/data       /com.android.testable.camera/files/Movies"/>
</paths>
  1. 让我们将文件提供程序路径添加到AndroidManifest.xml文件中:
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.android.testable.camera"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support                   .FILE_PROVIDER_PATHS"
                android:resource="@xml/file_provider_paths" />
        </provider>
  1. 现在让我们定义一个模型,该模型将保存Uri和文件的关联路径:
data class FileInfo(
    val uri: Uri,
    val file: File,
    val name: String,
    val relativePath:String,
    val mimeType:String
)
  1. 让我们创建一个ContentHelper类,它将为我们提供ContentResolver所需的数据。我们将定义两种方法来访问照片和视频内容 Uri,以及两种方法来创建ContentValues。我们这样做是因为获取 Uri 和创建ContentValues所需的静态方法使得这个功能难以测试。由于篇幅限制,以下代码已被截断。您需要添加的完整代码可以通过下面的链接找到。
MediaContentHelper.kt
7    class MediaContentHelper {
8
9        fun getImageContentUri(): Uri =
10            if (android.os.Build.VERSION.SDK_INT >=                 android.os.Build.VERSION_CODES.Q) {
11                MediaStore.Images.Media.getContentUri                     (MediaStore.VOLUME_EXTERNAL_PRIMARY)
12            } else {
13                MediaStore.Images.Media.EXTERNAL_CONTENT_URI
14            }
15
16        fun generateImageContentValues(fileInfo: FileInfo)             = ContentValues().apply {
17            this.put(MediaStore.Images.Media
                     .DISPLAY_NAME, fileInfo.name)
18        if (android.os.Build.VERSION.SDK_INT >= 
                android.os.Build.VERSION_CODES.Q) {
19                this.put(MediaStore.Images.Media                     .RELATIVE_PATH, fileInfo.relativePath)
20        }
21        this.put(MediaStore.Images.Media             .MIME_TYPE, fileInfo.mimeType)
22    }
The complete code for this step can be found at http://packt.live/3ivwekp.
  1. 现在,让我们创建ProviderFileManager类,在其中我们将定义生成照片和视频文件的方法,然后由相机使用,并保存到媒体存储的方法。同样,为简洁起见,代码已被截断。请查看下面的链接以获取您需要使用的完整代码:
ProviderFileManager.kt
12    class ProviderFileManager(
13        private val context: Context,
14        private val fileHelper: FileHelper,
15        private val contentResolver: ContentResolver,
16        private val executor: Executor,
17        private val mediaContentHelper: MediaContentHelper
18    ) {
19
20        fun generatePhotoUri(time: Long): FileInfo {
21            val name = "img_$time.jpg"
22            val file = File(
23                context.getExternalFilesDir(fileHelper                     .getPicturesFolder()),
24                name
25            )
26            return FileInfo(
27                fileHelper.getUriFromFile(file),
28                file,
29                name,
30                fileHelper.getPicturesFolder(),
31                "image/jpeg"
32            )
33        }
The complete code for this step can be found at http://packt.live/2XXB9Bu.

请注意我们如何将根文件夹定义为context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)。这与file_provider_paths.xml相关联,并将在外部存储器上的应用程序专用文件夹中创建一组名为MoviesPictures的文件夹。insertToStore方法是文件将被复制到MediaStore的地方。首先,我们将在存储中创建一个条目,这将为我们提供该条目的 Uri。接下来,我们将从FileProvider生成的 Uri 中将文件内容复制到指向MediaStore条目的OutputStream中。

  1. 让我们在res/layout/activity_main.xml中定义我们活动的布局:
activity_main.xml
10    <Button
11        android:id="@+id/photo_button"
12        android:layout_width="wrap_content"
13        android:layout_height="wrap_content"
14        android:text="@string/photo" />
15
16    <Button
17        android:id="@+id/video_button"
18        android:layout_width="wrap_content"
19        android:layout_height="wrap_content"
20        android:layout_marginTop="5dp"
21        android:text="@string/video" />
The complete code for this step can be found at http://packt.live/3qDSyLU.
  1. 让我们创建MainActivity类,我们将在其中检查是否需要请求 WRITE_STORAGE_PERMISSION,如果需要,则请求它,并在授予权限后打开相机拍摄照片或视频。与上文一样,为简洁起见,代码已被截断。您可以使用下面显示的链接访问完整的代码:
MainActivity.kt
14    class MainActivity : AppCompatActivity() {
15 
16        companion object {
17
18            private const val REQUEST_IMAGE_CAPTURE = 1
19            private const val REQUEST_VIDEO_CAPTURE = 2
20            private const val REQUEST_EXTERNAL_STORAGE = 3
21        }
22
23        private lateinit var providerFileManager:             ProviderFileManager
24        private var photoInfo: FileInfo? = null
25        private var videoInfo: FileInfo? = null
26        private var isCapturingVideo = false
27
28        override fun onCreate(savedInstanceState: Bundle?) {
29            super.onCreate(savedInstanceState)
30            setContentView(R.layout.activity_main)
31            providerFileManager =
32                ProviderFileManager(
33                    applicationContext,
34                    FileHelper(applicationContext),
35                    contentResolver,
36                    Executors.newSingleThreadExecutor(),
37                    MediaContentHelper()
38                )
The complete code for this step can be found at http://packt.live/3ivUTpm.

如果我们执行上述代码,我们将看到以下结果:

图 11.9:练习 11.06 的输出

图 11.9:练习 11.06 的输出

  1. 通过点击任一按钮,您将被重定向到相机应用程序,在那里您可以拍摄照片或视频(如果您在 Android 10 及以上版本上运行示例)。如果您在较低的 Android 版本上运行,则会首先要求权限。一旦您拍摄并确认了照片,您将被带回应用程序。照片将保存在您在FileProvider中定义的位置:图 11.10:通过相机应用程序捕获文件的位置

图 11.10:通过相机应用程序捕获文件的位置

在上述截图中,您可以看到借助 Android Studio 设备文件浏览器文件的位置。

  1. 修改MainActivity并添加onActivityResult方法来触发文件保存到 MediaStore 的操作:
    override fun onActivityResult(requestCode: Int,       resultCode: Int, data: Intent?) {
        when (requestCode) {
            REQUEST_IMAGE_CAPTURE -> {
                providerFileManager.insertImageToStore(photoInfo)
            }
            REQUEST_VIDEO_CAPTURE -> {
                providerFileManager.insertVideoToStore(videoInfo)
            }
            else -> {
                super.onActivityResult(requestCode,                   resultCode, data)
            }
        }
    }

如果您打开任何文件浏览应用程序,如“文件”应用程序、画廊或 Google 照片应用程序,您将能够看到拍摄的视频和图片。

图 11.11:应用程序中的文件在文件浏览器应用程序中的位置

图 11.11:应用程序中的文件在文件浏览器应用程序中的位置

活动 11.01:狗下载器

您的任务是构建一个针对 Android 版本高于 API 21 的应用程序,该应用程序将显示狗照片的 URL 列表。您将连接到的 URL 是 https://dog.ceo/api/breed/hound/images/random/{number},其中 number 将通过设置屏幕控制,用户可以选择要显示的 URL 数量。设置屏幕将通过主屏幕上呈现的选项打开。当用户点击 URL 时,图像将在应用程序的外部缓存路径中本地下载。在下载图像时,用户将看到一个不确定的进度条。URL 列表将使用 Room 在本地持久化。

将使用以下技术:

  • Retrofit 用于检索 URL 列表和下载文件

  • Room 用于持久化 URL 列表

  • SharedPreferencesPreferencesFragment 用于存储要检索的 URL 数量

  • FileProvider 用于将文件存储在缓存中

  • Apache IO 用于写文件

  • 组合所有数据源的存储库

  • LiveDataViewModel 用于处理用户的逻辑

  • RecyclerView 用于项目列表

响应 JSON 将类似于这样:

{
    "message": [
        "https://images.dog.ceo/breeds/hound-          afghan/n02088094_4837.jpg",
        "https://images.dog.ceo/breeds/hound-          basset/n02088238_13908.jpg",
        "https://images.dog.ceo/breeds/hound-          ibizan/n02091244_3939.jpg"
    ],
    "status": "success"
}

执行以下步骤以完成此活动:

  1. 创建一个包含与网络相关的类的 api 包。

  2. 创建一个数据类,用于建模响应 JSON。

  3. 创建一个 Retrofit Service 类,其中包含两个方法。第一个方法将代表 API 调用以返回品种列表,第二个方法将代表 API 调用以下载文件。

  4. 创建一个 storage 包,并在 storage 包内创建一个 room 包。

  5. 创建一个包含自动生成的 ID 和 URL 的 Dog 实体。

  6. 创建一个 DogDao 类,其中包含插入 Dogs 列表、删除所有 Dogs 和查询所有 Dogs 的方法。delete 方法是必需的,因为 API 模型没有任何唯一标识符。

  7. storage 包内,创建一个 preference 包。

  8. preference 包内,创建一个围绕 SharedPreferences 的包装类,该类将返回我们需要使用的 URL 数量。默认值为 10

  9. res/xml 中,为 FileProvider 定义文件夹结构。文件应保存在 external-cache-path 标签的根文件夹中。

  10. storage 包内创建一个 filesystem 包。

  11. filesystem 包内,定义一个类,负责将 InputStream 写入 FileProvider 中的文件,使用 Context.externalCacheDir

  12. 创建一个 repository 包。

  13. repository 包内,创建一个密封类,该类将保存 API 调用的结果。密封类的子类将是 SuccessErrorLoading

  14. 定义一个包含两个方法的 Repository 接口,一个用于加载 URL 列表,另一个用于下载文件。

  15. 定义一个 DogUi 模型类,该类将用于应用程序的 UI 层,并将在存储库中创建。

  16. 定义一个映射器类,将您的 API 模型转换为实体,实体转换为 UI 模型。

  17. 定义一个实现 Repository 的实现,该实现将实现前两个方法。存储库将持有对 DogDao、Retrofit Service 类、Preferences 包装类、管理文件的类、Dog 映射类和用于多线程的 Executor 类的引用。在下载文件时,我们将使用从 URL 提取的文件名。

  18. 创建一个将初始化存储库的 Application 类。

  19. 定义 UI 使用的 ViewModel,它将引用 Repository 并调用 Repository 加载 URL 列表和下载图片。

  20. 定义您的 UI,它将由两个活动组成:

  • 该活动显示 URL 列表,并将具有单击操作以开始下载。该活动将具有进度条,在下载发生时将显示。屏幕还将有一个“设置”选项,它将打开设置屏幕。

  • 设置活动将显示一个设置,指示要加载的 URL 数量。

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

摘要

在本章中,我们分析了 Android 中持久化数据的不同方式,以及如何通过存储库模式将它们集中起来。我们首先看了一下模式本身,看看我们如何通过结合 Room 和 Retrofit 来组织数据源。

然后,我们继续分析了在持久化数据方面替代 Room 的选择。我们首先看了SharedPreferences,以及当数据以键值格式且数据量较小时,它们构成了一个方便的数据持久化解决方案。然后我们看了如何使用SharedPreferences直接在设备上保存数据,然后我们研究了PreferenceFragments以及它们如何用于接收用户输入并在本地存储。

接下来,当涉及到 Android 框架时,我们审视了一个持续变化的内容。那就是关于文件系统抽象的演变。我们首先概述了 Android 拥有的存储类型,然后更深入地研究了两种抽象:FileProvider,您的应用程序可以使用它在设备上存储文件,并在有需要时与他人共享;以及 SAF,它可以用于在用户选择的位置在设备上保存文件。

我们还利用了FileProvider的好处,为文件生成 URI,以便使用相机应用程序拍照和录制视频,并将它们保存在应用程序文件中,同时将它们添加到MediaStore

本章中进行的活动结合了上述所有元素,以说明即使您必须在应用程序内部平衡多个来源,也可以以更可读的方式进行。

请注意,在本章和上一章的活动和练习中,我们一直不得不使用应用程序类来实例化数据源。在下一章中,您将学习如何通过依赖注入来克服这一问题,并了解它如何有益于 Android 应用程序。

第十二章:使用 Dagger 和 Koin 进行依赖注入

概述

本章涵盖了依赖注入的概念以及它为 Android 应用程序提供的好处。我们将看看如何通过容器类手动执行依赖注入。我们还将介绍一些可用于 Android、Java 和 Kotlin 的框架,这些框架可以帮助开发人员应用这一概念。通过本章的学习,您将能够使用 Dagger 和 Koin 来管理应用程序的依赖项,并且将知道如何有效地组织它们。

介绍

在上一章中,我们看到了如何将代码结构化为不同的组件,包括 ViewModels、repositories、API 组件和持久性组件。其中一个经常出现的困难是所有这些组件之间的依赖关系,特别是当我们为它们编写单元测试时。

我们一直使用Application类来创建这些组件的实例,并将它们传递给上一层组件的构造函数(我们创建了 API 和 Room 实例,然后是 Repository 实例,依此类推)。我们所做的是依赖注入的简化版本。

ViewModels)。这样做的原因是为了增加代码的可重用性和可测试性,并将创建实例的责任从我们的组件转移到Application类。DI 的一个好处在于对象在整个代码库中的创建方式。DI 将对象的创建与其使用分离。换句话说,一个对象不应该关心另一个对象是如何创建的;它只应该关心与另一个对象的交互。

在本章中,我们将分析在 Android 中注入依赖项的三种方式:手动 DI、Dagger 和 Koin。

手动 DI是一种技术,开发人员通过创建容器类来手动处理 DI。在本章中,我们将看看如何在 Android 中实现这一点。通过研究我们如何手动管理依赖项,我们将了解其他 DI 框架的运作方式,并为我们如何集成这些框架奠定基础。

Dagger是为 Java 开发的 DI 框架。它允许您将依赖项分组到不同的模块中。您还可以定义组件,在这些组件中添加模块以创建依赖图,Dagger 会自动实现以执行注入。它依赖于注解处理器来生成必要的代码以执行注入。

Koin是为 Kotlin 开发的轻量级 DI 库。它不依赖于注解处理器;它依赖于 Kotlin 的机制来执行注入。在这里,我们还可以将依赖项拆分成模块

接下来,我们将探讨这两个库的工作原理以及将它们添加到简单 Android 应用程序所需的步骤。

手动 DI

为了理解 DI 的工作原理,我们可以首先分析如何在 Android 应用程序中手动注入依赖项到不同的对象中。这可以通过创建包含应用程序中所需依赖项的容器对象来实现。您还可以创建代表应用程序中所需不同范围的多个容器。在这里,您可以定义只在特定屏幕显示时才需要的依赖项,并且当屏幕被销毁时,实例也可以被垃圾回收。

这里展示了一个将持续存在应用程序的实例的容器示例:

class AppContainer(applicationContext:Context) {
    val myRepository: MyRepository
    init {
        val retrofit =           Retrofit.Builder().baseUrl("https://google.com/").build()
        val myService=           retrofit.create<MyService>(MyService::class.java)
        val database = Room.databaseBuilder(applicationContext,           MyDatabase::class.java, "db").build()
        myRepository = MyRepositoryImpl(myService, database.myDao())
    }
}

使用该容器的Application类如下所示:

class MyApplication : Application() {
    lateinit var appContainer: AppContainer
    override fun onCreate() {
        super.onCreate()
        appContainer = AppContainer(this)
    }
}

正如您在前面的示例中所看到的,创建依赖项的责任已经从Application类转移到了Container类。代码库中的活动仍然可以使用以下命令访问依赖项:

    override fun onCreate(savedInstanceState: Bundle?) {
        .... 
        val myRepository = (application as           MyApplication).appContainer. myRepository
        ...
}

具有有限范围的模块可以用于创建ViewModel工厂之类的东西,这些工厂又被框架用来创建ViewModel

class MyContainer(private val myRepository: MyRepository) {
    fun geMyViewModelFactory(): ViewModelProvider.Factory {
        return object : ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass:               Class<T>): T {
                return MyViewModel(myRepository) as T
            }
        }
    }
}

这个特定的容器可以被一个活动或片段用来初始化ViewModel

class MyActivity : AppCompatActivity() {
    private lateinit var myViewModel: MyViewModel
    private lateinit var myContainer: MyContainer
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ....
        val myRepository = (application as           MyApplication).appContainer. myRepository
        myContainer = MyContainer (myRepository)
        myViewModel = ViewModelProvider(this,           myContainer.geMyViewModelFactory())            .get(MyViewModel::class.java)
    }
}

再次,我们在这里看到,创建Factory类的责任已从Activity类转移到Container类。MyContainer可以扩展以在需要的情况下提供与活动相同的实例,或者构造函数可以扩展以提供具有不同生命周期的实例。

现在,让我们将其中一些示例应用于练习。

练习 12.01:手动注入

在这个练习中,我们将编写一个应用程序,应用手动 DI 的概念。该应用程序将具有一个存储库,该存储库将生成一个随机数,并具有一个ViewModel对象,其中包含一个LiveData对象,负责检索存储库生成的数字并在LiveData对象中发布它。为了做到这一点,我们需要创建两个管理以下依赖项的容器:

  • 存储库

  • 负责创建ViewModelViewModel工厂

应用程序本身将在每次点击按钮时显示随机生成的数字:

  1. 让我们首先将ViewModelLiveData库添加到app/build.gradle文件中:
    implementation "androidx.lifecycle:lifecycle-      extensions:2.2.0"
  1. 接下来,让我们编写一个NumberRepository接口,其中将包含一个检索整数的方法:
interface NumberRepository {
    fun generateNextNumber(): Int
}
  1. 现在,我们将为此提供实现。我们可以使用java.util.Random类来生成随机数:
class NumberRepositoryImpl(private val random: Random) :   NumberRepository {
    override fun generateNextNumber(): Int {
        return random.nextInt()
    }
}
  1. 我们现在将继续创建MainViewModel类,其中将包含一个包含存储库中每个生成的数字的LiveData对象:
class MainViewModel(private val numberRepository:   NumberRepository) : ViewModel() {
    private val numberLiveData = MutableLiveData<Int>()
    fun getLiveData(): LiveData<Int> = numberLiveData
    fun generateNextNumber() {
        numberLiveData.postValue(numberRepository           .generateNextNumber())
    }
}
  1. 接下来,让我们继续创建包含用于显示数字的TextView和用于生成下一个随机数字的Button的 UI。这将成为res/layout/activity_main.xml文件的一部分:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">
    <TextView
        android:id="@+id/activity_main_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
    <Button
        android:id="@+id/activity_main_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/randomize" />
</LinearLayout>
  1. 确保将按钮的字符串添加到res/values/strings.xml文件中:
   <string name="randomize">Randomize</string>
  1. 现在让我们创建负责呈现前述 UI 的MainActivity类:
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}
  1. 现在,让我们创建我们的Application类:
class RandomApplication : Application() {
    override fun onCreate() {
        super.onCreate()
    }
}
  1. 让我们还将Application类添加到AndroidManifest.xml文件中的application标签中:
    <application
        ...
        android:name=".RandomApplication"
.../>
  1. 现在,让我们创建我们的第一个容器,负责管理NumberRepository依赖项:
class ApplicationContainer {
    val numberRepository: NumberRepository =       NumberRepositoryImpl(Random())
}
  1. 让我们将此容器添加到RandomApplication类中:
class RandomApplication : Application() {
    val applicationContainer = ApplicationContainer()
    override fun onCreate() {
        super.onCreate()
    }
}
  1. 我们现在继续创建MainContainer,它将需要引用NumberRepository依赖项,并将提供一个依赖项给创建MainViewModel所需的ViewModel工厂:
class MainContainer(private val numberRepository:   NumberRepository) {
    fun getMainViewModelFactory(): ViewModelProvider.Factory {
        return object : ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass:               Class<T>): T {
                return MainViewModel(numberRepository) as T
            }
        }
    }
}
  1. 最后,我们可以修改MainActivity以从我们的容器中注入依赖项,并连接 UI 元素以显示输出:
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val mainContainer = MainContainer((application as           RandomApplication).applicationContainer             .numberRepository)
        val viewModel = ViewModelProvider(this,           mainContainer.getMainViewModelFactory())            .get(MainViewModel::class.java)
        viewModel.getLiveData().observe(this, Observer {
            findViewById<TextView>              (R.id.activity_main_text_view).text = it.toString()
        }
        )
        findViewById<TextView>(R.id.activity_main_button)          .setOnClickListener {
            viewModel.generateNextNumber()
        }
    }
}
  1. 在突出显示的代码中,我们可以看到我们正在使用ApplicationContainer中定义的存储库,并将其注入到MainContainer中,然后通过ViewModelProvider.Factory将其注入到ViewModel中。前面的示例应该呈现出图 12.1中呈现的输出:

图 12.1:练习 12.01 的模拟器输出,显示随机生成的数字

图 12.1:练习 12.01 的模拟器输出,显示随机生成的数字

手动 DI 是在应用程序较小的情况下设置依赖项的一种简单方法,但随着应用程序的增长,它可能变得非常困难。想象一下,在练习 12.01手动注入中,我们有两个类都扩展自NumberRepository。我们将如何处理这种情况?开发人员如何知道哪个类适用于哪个活动?这些类型的问题在 Google Play 上大多数知名应用程序中变得非常普遍,这就是为什么很少使用手动 DI。在使用时,它可能会采用我们接下来将要查看的 DI 框架类似的形式。

Dagger

Dagger 提供了一种全面组织应用程序依赖关系的方式。它在 Kotlin 引入之前首先被 Android 开发者社区采用,这是许多 Android 应用程序将 Dagger 作为它们的 DI 框架的原因之一。该框架的另一个优势是对于用 Java 编写的 Android 项目,因为该库也是用相同的语言开发的。该框架最初由 Square(Dagger 1)开发,后来过渡到了 Google(Dagger 2)。我们将在本章中介绍 Dagger 2 并描述其优势。Dagger 2 提供的一些关键功能包括:

  • 注入

  • 模块化的依赖项

  • 用于生成依赖图的组件

  • 限定符

  • 作用域

  • 子组件

注解是处理 Dagger 时的关键元素,因为它生成所需的代码来执行通过注解处理器进行 DI。主要注解可以分为以下几类:

  • @Module负责提供可以被注入的对象(依赖对象)。

  • @Inject注解用于定义依赖关系。

  • @Component注解的接口定义了提供者和消费者之间的连接。

为了将 Dagger 添加到您的项目中,在app/build.gradle文件中,您将需要以下依赖项:

implementation 'com.google.dagger:dagger:2.29.1' 
kapt 'com.google.dagger:dagger-compiler:2.29.1'

由于我们正在处理注解处理器,在同一个build.gradle文件中,您需要为它们添加插件:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

消费者

Dagger 使用javax.inject.Inject来识别需要注入的对象。有多种注入依赖的方式,但推荐的方式是通过构造函数注入和字段注入。构造函数注入看起来类似于以下代码:

import javax.inject.Inject
class ClassA @Inject constructor()
class ClassB @Inject constructor(private val classA: ClassA)

当构造函数被@Inject注解时,Dagger 将生成Factory类来负责实例化对象。在ClassB的示例中,Dagger 将尝试找到符合构造函数签名的适当依赖项,而在这个例子中,就是ClassA,而 Dagger 已经为其创建了一个实例。

如果您不希望 Dagger 管理ClassB的实例化,但仍然希望注入对ClassA的依赖关系,您可以使用字段注入,代码看起来会像这样:

import javax.inject.Inject
class ClassA @Inject constructor()
class ClassB {
    @Inject
    lateinit var classA: ClassA
}

在这种情况下,Dagger 将生成必要的代码来注入ClassBClassA之间的依赖关系。

提供者

您会发现自己处于应用程序使用外部依赖的情况。这意味着您将无法通过构造函数注入提供实例。另一种构造函数注入不可能的情况是使用接口或抽象类。在这种情况下,Dagger 提供了使用@Provides注解来提供实例的可能性。然后,您需要将提供实例的方法分组到用@Module注解的模块中:

import dagger.Module
import dagger.Provides
class ClassA
class ClassB(private val classA: ClassA)
@Module
object MyModule {
    @Provides
    fun provideClassA(): ClassA = ClassA()
    @Provides
    fun provideClassB(classA: ClassA): ClassB = ClassB(classA)
}

如前面的示例所示,ClassAClassB没有任何 Dagger 注解。创建了一个模块,将为ClassA提供实例,然后用于提供ClassB的实例。在这种情况下,Dagger 将为每个@Provides注解的方法生成一个Factory类。

连接器

假设我们将有多个模块,我们需要将它们组合成一个依赖图,可以在整个应用程序中使用。Dagger 提供了@Component注解。这通常用于由 Dagger 实现的接口或抽象类。除了组装依赖图之外,组件还提供了向某个对象的成员注入依赖的功能。在组件中,您可以指定返回模块中提供的依赖项的提供方法:

import dagger.Component
@Component(modules = [MyModule::class])
interface MyComponent {
    fun inject(myApplication: MyApplication)
}

对于前面的Component,Dagger 将生成一个DaggerMyComponent类,并且我们可以按照以下代码进行构建:

import android.app.Application
import javax.inject.Inject
class MyApplication : Application() {
    @Inject
    lateinit var classB: ClassB
    override fun onCreate() {
        super.onCreate()
        val component = DaggerMyComponent.create()
        //needs to build the project once to generate 
        //DaggerMyComponent.class
        component.inject(this)
    }
}

Application类将创建 Dagger 依赖项图和组件。Component中的inject方法允许我们对Application类中用@Inject注释的变量执行 DI,从而让我们访问模块中定义的ClassB对象。

限定符

如果要提供同一类的多个实例(例如在整个应用程序中注入不同的字符串或整数),可以使用限定符。这些是可以帮助您标识实例的注释。其中最常见的是@Named限定符,如下面的代码所述:

@Module
object MyModule {
    @Named("classA1")
    @Provides
    fun provideClassA1(): ClassA = ClassA()
    @Named("classA2")
    @Provides
    fun provideClassA2(): ClassA = ClassA()
    @Provides
    fun provideClassB(@Named("classA1") classA: ClassA): ClassB =       ClassB(classA)
}

在此示例中,我们创建了两个ClassA的实例,并为它们分配了不同的名称。然后,我们尽可能使用第一个实例来创建ClassB。我们还可以创建自定义限定符,而不是@Named注释,如下面的代码所述:

import javax.inject.Qualifier
@Qualifier
@MustBeDocumented
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class ClassA1Qualifier
@Qualifier
@MustBeDocumented
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class ClassA2Qualifier

可以像这样更新模块:

@Module
object MyModule {
    @ClassA1Qualifier
    @Provides
    fun provideClassA1(): ClassA = ClassA()
    @ClassA2Qualifier
    @Provides
    fun provideClassA2(): ClassA = ClassA()
    @Provides
    fun provideClassB(@ClassA1Qualifier classA: ClassA): ClassB =       ClassB(classA)
}

作用域

如果要跟踪组件和依赖项的生命周期,可以使用作用域。Dagger 提供了@Singleton作用域。这通常表示您的组件将与应用程序一样长。作用域对对象的生命周期没有影响;它们旨在帮助开发人员识别对象的生命周期。建议为组件指定一个作用域,并将代码分组以反映该作用域。Android 上一些常见的 Dagger 作用域与活动或片段相关:

import javax.inject.Scope
@Scope
@MustBeDocumented
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class ActivityScope
@Scope
@MustBeDocumented
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class FragmentScope

注释可以在提供依赖项的模块中使用:

    @ActivityScope
    @Provides
    fun provideClassA(): ClassA = ClassA()

Component的代码将如下所示:

@ActivityScope
@Component(modules = [MyModule::class])
interface MyComponent {
}

前面的示例表明Component只能使用具有相同作用域的对象。如果此Component的任何模块包含具有不同作用域的依赖项,Dagger 将抛出错误,指示作用域存在问题。

子组件

与作用域紧密相关的是子组件。它们允许您为较小的作用域组织您的依赖项。Android 上的一个常见用例是为活动和片段创建子组件。子组件从父组件继承依赖项,并为子组件的作用域生成新的依赖项图。

让我们假设我们有一个单独的模块:

class ClassC
@Module
object MySubcomponentModule {
    @Provides
    fun provideClassC(): ClassC = ClassC()
}

将为该模块生成依赖项图的Subcomponent将如下所示:

import dagger.Subcomponent
@ActivityScope
@Subcomponent(modules = [MySubcomponentModule::class])
interface MySubcomponent {
    fun inject(mainActivity: MainActivity)
}

父组件需要声明新组件,如下面的代码片段所示:

import dagger.Component
@Component(modules = [MyModule::class])
interface MyComponent {
    fun inject(myApplication: MyApplication)
    fun createSubcomponent(mySubcomponentModule:       MySubcomponentModule): MySubcomponent
}

您可以将ClassC注入到您的活动中,如下所示:

@Inject
    lateinit var classC: ClassC
     override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        (application as MyApplication).component           .createSubcomponent(MySubcomponentModule).inject(this)
}

有了这些知识,让我们继续进行练习。

练习 12.02:Dagger 注入

在这个练习中,我们将编写一个 Android 应用程序,该应用程序将应用 Dagger 的 DI 概念。该应用程序将具有与“Exercise 12.01”,“手动注入”中定义的相同的RepositoryViewModel。我们需要使用 Dagger 来公开相同的两个依赖项:

  • Repository:这将具有@Singleton作用域,并将由ApplicationModule提供。现在,ApplicationModule将作为ApplicationComponent的一部分公开。

  • ViewModelProvider.Factory:这将具有名为MainScope的自定义作用域,并将由MainModule提供。现在,MainModule将由MainSubComponent公开。此外,MainSubComponent将由ApplicationComponent生成。

应用程序本身将在每次单击按钮时显示随机生成的数字:

  1. 让我们首先在app/build.gradle文件中添加 Dagger 和:
    implementation 'com.google.dagger:dagger:2.29.1'
    kapt 'com.google.dagger:dagger-compiler:2.29.1'
    implementation "androidx.lifecycle:lifecycle-      extensions:2.2.0"
  1. 我们还需要在app/build.gradle模块中添加kapt插件。按照以下方式附加插件:
apply plugin: 'kotlin-kapt'
  1. 现在,我们需要添加NumberRepositoryNumberRepositoryImplMainViewModelRandomApplication类,并使用MainActivity构建我们的 UI。可以通过按照“Exercise 12.01”,“手动注入”的步骤 2-9来完成。

  2. 现在,让我们继续进行ApplicationModule,它将提供NumberRepository依赖项:

@Module
    class ApplicationModule {
        @Provides
        fun provideRandom(): Random = Random()
        @Provides
        fun provideNumberRepository(random: Random):           NumberRepository = NumberRepositoryImpl(random)
}
  1. 现在,让我们创建MainModule,它将提供ViewModel.Factory的实例:
@Module
class MainModule {

    @Provides
    fun provideMainViewModelFactory(numberRepository:       NumberRepository): ViewModelProvider.Factory {
        return object : ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass:               Class<T>): T {
                return MainViewModel(numberRepository) as T
            }
        }
    }
}
  1. 现在,让我们创建MainScope
@Scope
@MustBeDocumented
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class MainScope
  1. 我们将需要MainSubcomponent,它将使用前面的作用域:
@MainScope
@Subcomponent(modules = [MainModule::class])
interface MainSubcomponent {
    fun inject(mainActivity: MainActivity)
}
  1. 接下来,我们将需要ApplicationComponent
@Singleton
@Component(modules = [ApplicationModule::class])
interface ApplicationComponent {
    fun createMainSubcomponent(): MainSubcomponent
}
  1. 我们需要导航到Build,在 Android Studio 中点击Rebuild project,以便生成 Dagger 代码来执行 DI。

  2. 接下来,我们修改RandomApplication类,以添加所需的代码来初始化 Dagger 依赖图:

class RandomApplication : Application() {
    lateinit var applicationComponent: ApplicationComponent
    override fun onCreate() {
        super.onCreate()
        applicationComponent =           DaggerApplicationComponent.create()
    }
}
  1. 现在我们修改MainActivity类,以注入ViewModelProvider.Factory并初始化ViewModel,以便显示随机数字:
class MainActivity : AppCompatActivity() {
    @Inject
    lateinit var factory: ViewModelProvider.Factory
    override fun onCreate(savedInstanceState: Bundle?) {
        (application as RandomApplication).applicationComponent           .createMainSubcomponent().inject(this)
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val viewModel = ViewModelProvider(this,           factory).get(MainViewModel::class.java)
        viewModel.getLiveData().observe(this, Observer {
            findViewById<TextView>(R.id.activity_main_text_view)              .text = it.toString()
        }
        )
        findViewById<TextView>(R.id.activity_main_button)          .setOnClickListener {
            viewModel.generateNextNumber()
        }
    }
}

如果运行上述代码,将构建一个应用程序,当您点击按钮时将显示不同的随机输出:

图 12.2:Exercise 12.02 的模拟器输出,显示随机生成的数字

图 12.2:Exercise 12.02 的模拟器输出,显示随机生成的数字

  1. 图 12.2显示了应用程序的外观。您可以在app/build文件夹中查看生成的 Dagger 代码:

图 12.3:Exercise 12.02 的生成 Dagger 代码

图 12.3:Exercise 12.02 的生成 Dagger 代码

图 12.3中,我们可以看到 Dagger 生成的代码,以满足依赖关系。对于每个需要注入的依赖项,Dagger 将生成一个适当的Factory类(基于Factory设计模式),它将负责创建依赖项。Dagger 还会查看需要注入依赖项的位置,并生成一个Injector类,它将负责为依赖项分配值(在本例中,它将为MainActivity类中标有@Inject的成员分配值)。最后,Dagger 为具有@Component注解的接口创建实现。在实现中,Dagger 将处理模块的创建,并提供一个构建器,开发人员可以指定如何构建模块。

Dagger Android

在前面的示例中,您可能已经注意到在活动中,您必须调用组件和子组件来执行注入。这在应用程序中往往会变得重复。也不建议活动和片段知道谁在执行注入。所有这些都来自 Dagger 和 Android 框架之间的根本冲突。在 Dagger 中,您负责提供和注入您的依赖关系。在 Android 中,片段和活动是由系统实例化的。换句话说,您不能将活动或片段的创建移到 Dagger 模块中并注入依赖关系,因此您必须求助于构建子组件。通过使用子组件,然后创建子组件和活动之间的依赖关系。幸运的是,Dagger 提供了一套库来解决这些 Android 问题,可以添加到您的build.gradle文件中:

    implementation 'com.google.dagger:dagger-android:2.29.1' 
    implementation 'com.google.dagger:dagger-android-support:2.29.1' 
    kapt 'com.google.dagger:dagger-android-processor:2.29.1'

Android Dagger 库提供了专门的注入方法,Dagger 使用这些方法将依赖项注入到活动和片段中。这种设置还通过消除子组件的需要,简化了较简单项目的依赖设置。一个设置注入到活动的模块将看起来像这样:

@Module
abstract class ActivityProviderModule {
    @ContributesAndroidInjector(modules = [ActivityModule::class])
    @ActivityScope
    abstract fun contributeMyActivityInjector(): MyActivity
}

(请注意,这些示例中没有显示导入语句。)

这里的一个重要事项是引入@ContributesAndroidInjector注解,当应用于抽象方法时,允许 Dagger 创建一个实现,其中它将创建AndroidInjector,然后用于对活动进行注入。Application组件将需要一个专用的AndroidInjectionModuleAndroidSupportInjection模块(如果您正在使用兼容库来实现您的片段):

@Singleton
@Component(
    modules = [AndroidSupportInjectionModule::class,
        ApplicationModule::class,
        ActivityProviderModule::class
    ]
)
interface ApplicationComponent {
    fun inject(myApplication: MyApplication)
}

AndroidSupportInjectionModule来自 Dagger Android 库,并提供了一组绑定,当使用 Android 框架类时,通过跟踪您添加到ApplicationActivityFragment类的不同注入器,这些绑定会变得有用。这就是 Dagger 将知道如何将每个依赖项注入到您的活动或片段中。

在您的Application类中,您将需要一个HasAndroidInjector实现。这将负责为您的应用程序的每个活动提供注入。如果您正在使用服务或ContentProvider,可以应用相同的规则:

class MyApplication : Application(), HasAndroidInjector {
    @Inject
    lateinit var dispatchingAndroidInjector:       DispatchingAndroidInjector<Any>
    lateinit var applicationComponent: ApplicationComponent
    override fun onCreate() {
        super.onCreate()
        applicationComponent = DaggerApplicationComponent.create()
        applicationComponent.inject(this)
    }
    override fun androidInjector(): AndroidInjector<Any> =       dispatchingAndroidInjector
}

Dagger 将在您的Application类中,在onCreate()中创建图形,并将AndroidInjector对象注入Application类。然后,AndroidInjector对象将用于将依赖项注入到每个指定的活动中。最后,在您的活动中,您可以使用AndroidInjection.inject()方法来注入依赖项。当调用inject()时,Dagger 将查找负责 DI 的注入器。如果从活动中调用inject(),那么它将使用应用程序注入器。这是 Dagger 将调用应用程序中的androidInjector()方法的时刻。如果注入器有效,则将执行 DI。如果从片段中调用inject(),那么 Dagger 将在父活动中查找注入器。如果从嵌套片段中调用inject(),那么 Dagger 将在父片段中查找注入器,这就是为什么它只限于一个嵌套片段:

class MyActivity : AppCompatActivity() {
    @Inject
    lateinit var myClass: MyClass
    override fun onCreate(savedInstanceState: Bundle?) {
        AndroidInjection.inject(this)
        super.onCreate(savedInstanceState)
    }
}

为了在您的片段中执行 DI,必须遵循每个先前执行的活动的类似原则。假设MyActivityMyFragment。我们将需要为MyActivity实现HasAndroidInjector

class MyActivity : AppCompatActivity(), HasAndroidInjector {
    @Inject
    lateinit var dispatchingAndroidInjector:       DispatchingAndroidInjector<Any>
    override fun onCreate(savedInstanceState: Bundle?) {
        AndroidInjection.inject(this)
        super.onCreate(savedInstanceState)
    }
    override fun androidInjector(): AndroidInjector<Any> =       dispatchingAndroidInjector
}

接下来,我们将需要一个与活动的提供程序模块类似的片段的提供程序模块:

@Module
abstract class FragmentProviderModule {
    @ContributesAndroidInjector(modules = [FragmentModule::class])
    @FragmentScope
    abstract fun contributeMyFragmentInjector(): MyFragment
}

最后,在ActivityProviderModule中,您需要添加FragmentProviderModule

    @ContributesAndroidInjector(modules = [ActivityModule::class,       FragmentProviderModule::class])
    @ActivityScope
    abstract fun contributeMyActivityInjector(): MyActivity

这对于每个具有需要注入的依赖项的片段的活动都是必需的。

Dagger Android 提供了一组具有HasAndroidInjector实现的类。如果您希望避免在您的类中实现HasAndroidInjector方法,可以使用以下一些类:DaggerApplicationDaggerActivityDaggerFragmentDaggerSupportFragment。只需扩展它们而不是ApplicationActivity等即可。

练习 12.03:更改注入器

在本练习中,我们将更改Exercise 12.02Dagger Injection,以添加 Android 注入器功能。输出将显示一个随机生成的数字,并且相同的依赖项需要以以下方式公开:

  • Repository:这将具有@Singleton范围,并将由ApplicationModule提供。现在,ApplicationModule将作为ApplicationComponent的一部分公开(与Exercise 12.02Dagger Injection相同)。

  • ViewModelProvider.Factory:这将具有名为MainScope的自定义范围,并将由MainModule提供。现在,MainModule将由MainProviderModule公开。

  • 依赖项将使用 Android 注入器注入到MainActivity中。Android 注入器将被添加到RandomApplication中,以便注入正常工作。

执行以下步骤以完成练习:

  1. 让我们将 Dagger Android 依赖项添加到app/build.gradle文件中,这将使您的依赖项看起来像这样:
    implementation 'com.google.dagger:dagger:2.29.1'
    kapt 'com.google.dagger:dagger-compiler:2.29.1'
    implementation 'com.google.dagger:dagger-android:2.29.1'
    implementation 'com.google.dagger:dagger-android-      support:2.29.1'
    kapt 'com.google.dagger:dagger-android-processor:2.29.1'
  1. 接下来,删除MainSubcomponent类。

  2. 创建一个MainProviderModule类,它将提供MainActivity的引用:

@Module
abstract class MainProviderModule {
    @MainScope
    @ContributesAndroidInjector(modules = [MainModule::class])
    abstract fun contributeMainActivityInjector(): MainActivity
}
  1. 更新ApplicationComponent以添加Application类的inject方法,并添加ActivityProviderModuleAndroidSupportInjectionModule
@Singleton
@Component(modules = [ApplicationModule::class, AndroidSupportInjectionModule::class, MainProviderModule::class])
interface ApplicationComponent {
    fun inject(randomApplication: RandomApplication)
}
  1. Application类更改为实现HasAndroidInjector,并让 Dagger 将一个注入器对象注入其中:
class RandomApplication : Application(), HasAndroidInjector {
    @Inject
    lateinit var dispatchingAndroidInjector:       DispatchingAndroidInjector<Any>
    lateinit var applicationComponent: ApplicationComponent
    override fun onCreate() {
        super.onCreate()
        applicationComponent =           DaggerApplicationComponent.create()
        applicationComponent.inject(this)
    }
    override fun androidInjector(): AndroidInjector<Any> =       dispatchingAndroidInjector
}
  1. MainActivity中,用AndroidInjection.inject方法替换旧的注入:
    override fun onCreate(savedInstanceState: Bundle?) {
        AndroidInjection.inject(this)
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val viewModel = ViewModelProvider(this,             factory).get(MainViewModel::class.java)
        viewModel.getLiveData().observe(this, Observer {
            findViewById<TextView>(R.id.activity_main_text_view)              .text = it.toString()
        }
        )
        findViewById<TextView>(R.id.activity_main_button)          .setOnClickListener {
            viewModel.generateNextNumber()
        }
    }

最终输出将如下所示:

图 12.4:练习 12.03 的模拟器输出显示随机生成的数字

图 12.4:练习 12.03 的模拟器输出显示随机生成的数字

在构建应用程序时查看生成的代码:

图 12.5:练习 12.03 的生成的 Dagger 代码

图 12.5:练习 12.03 的生成的 Dagger 代码

运行上述代码不应该改变练习的结果或图 12.3中呈现的依赖关系的范围。您可以观察到MainActivity对象不再依赖于Application类或任何组件或子组件。图 12.5显示了 Dagger Android 注入器的生成代码。其中大部分与现有代码类似,但我们可以看到为MainProviderModule生成的代码,它实际上生成了一个子组件。

当涉及组织其依赖关系时,您将发现 Android 应用程序的常见设置如下:

  • ApplicationModule:这是定义整个项目通用依赖关系的地方。可以在这里提供诸如上下文、资源和其他 Android 框架对象之类的对象。

  • NetworkModule:这是存储与 API 调用相关的依赖关系的地方。

  • DatabaseModuleFilesModuleSharedPreferencesModule等等。

  • ViewModelsViewModel工厂被存储。

  • ViewModel。在这里,要么使用子组件,要么使用 Android 注入器来实现这一目的。

我们已经提出了一些关于手动 DI 可能出错的问题。现在我们已经看到了 Dagger 如何解决这些问题。虽然它能够胜任工作,并且在性能方面做得很快,但它也是一个非常复杂的框架,学习曲线非常陡峭。

Koin

Koin 是一个适用于较小应用的轻量级框架。它不需要代码生成,并且是基于 Kotlin 的函数扩展构建的。它也是一种领域特定语言DSL)。您可能已经注意到,使用 Dagger 时,必须编写大量代码来设置 DI。Koin 对 DI 的处理方式解决了大部分这些问题,可以实现更快的集成。

可以通过将以下依赖项添加到您的build.gradle文件中将 Koin 添加到您的项目中:

implementation 'org.koin:koin-android:2.2.0-rc-4'

为了在您的应用程序中设置 Koin,您需要使用 DSL 语法进行startKoin调用:

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger(Level.INFO)
            androidContext(this@MyApplication)
            androidFileProperties()
            modules(myModules)
        }
    }
}

在这里,您可以配置您的应用程序上下文(在androidContext方法中),指定属性文件以定义 Koin 配置(在androidFileProperties中),指定 Koin 的 Logger Level,在LogCat中输出 Koin 操作的结果,具体取决于 Level(在androidLogger方法中),并列出您的应用程序使用的模块。创建模块时使用类似的语法:

class ClassA
class ClassB(private val classB: ClassA)
    val moduleForClassA = module {
        single { ClassA() }
    }
    val moduleForClassB = module {
        factory { ClassB(get()) }
    }
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger(Level.INFO)
            androidContext(this@MyApplication)
            androidFileProperties()
            modules(listOf(moduleForClassA, moduleForClassB))
        }
    }

在上面的示例中,这两个对象将具有两个不同的生命周期。当使用single符号提供依赖项时,那么整个应用程序生命周期内只会使用一个实例。这对于存储库、数据库和 API 组件非常有用,因为多个实例对应用程序来说成本很高。factory符号将在执行注入时创建一个新对象。这在对象需要与活动或片段一样长寿的情况下可能很有用。

可以使用by inject()方法或get()方法注入依赖项,如下所示:

    class MainActivity : AppCompatActivity() {
      val classB: ClassB by inject()
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val classB: ClassB = get()
    }

Koin 还提供了使用named()方法和限定符的可能性,当创建模块时可以使用。这允许您提供相同类型的多个实现(例如,提供两个或更多具有不同内容的列表对象):

    val moduleForClassA = module {
        single(named("name")) { ClassA() }
    }

Koin 的一个主要特性是为 Android 应用程序提供活动和片段的作用域,可以按照以下代码片段中所示进行定义:

    val moduleForClassB = module {
        scope(named<MainActivity>()) {
            scoped { ClassB(get()) }
        }
    }

前面的示例将ClassB的生命周期依赖项连接到MainActivity的生命周期。为了将实例注入到您的活动中,您需要扩展ScopeActivity类。该类负责在活动存在期间保持引用。其他 Android 组件(如片段(ScopeFragment)和服务(ScopeService))也存在类似的类。

class MainActivity : ScopeActivity() {
    val classB: ClassB by inject()
}

您可以使用inject()方法将实例注入到您的活动中。这在您希望限制谁可以访问依赖项的情况下非常有用。在前面的示例中,如果另一个活动想要访问对ClassB的引用,那么它将无法在作用域中找到它。

另一个对 Android 非常有用的功能是ViewModel注入。为了设置这个,您需要将库添加到build.gradle中:

implementation "org.koin:koin-android-viewmodel:2.2.0-rc-4"

如果您还记得,ViewModels需要ViewModelProvider.Factories才能被实例化。Koin 自动解决了这个问题,允许直接注入ViewModels并处理工厂工作:

    val moduleForClassB = module {
        factory {
            ClassB(get())
        }
        viewModel { MyViewModel(get()) }
    }

为了将ViewModel的依赖项注入到您的活动中,您可以使用viewModel()方法:

class MainActivity : AppCompatActivity() {
    val model: MyViewModel by viewModel()
}

或者,您可以直接使用该方法:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val model : MyViewModel = getViewModel()
    }

正如我们在前面的设置中所看到的,Koin 充分利用了 Kotlin 语言的特性,并减少了定义模块及其作用域所需的样板代码量。

练习 12.04:Koin 注入

在这里,我们将编写一个 Android 应用程序,该应用程序将使用 Koin 执行 DI。该应用程序将基于练习 12.01手动注入,保留NumberRepositoryNumberRepositoryImplMainViewModelMainActivity。将注入以下依赖项:

  • Repository:作为名为appModule的模块的一部分。

  • MainViewModel:这将依赖于 Koin 对ViewModels的专门实现。这将作为名为mainModule的模块的一部分提供,并且将具有MainActivity的作用域。

执行以下步骤完成练习:

  1. 应用程序本身将在每次单击按钮时显示一个随机生成的数字。让我们从添加 Koin 库开始:
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation 'org.koin:koin-android:2.2.0-rc-4'
implementation "org.koin:koin-android-viewmodel:2.2.0-rc-4"
  1. 让我们从在MyApplication类中定义appModule变量开始。这将与 Dagger 设置的AppModule具有类似的结构:
class RandomApplication : Application() {
    val appModule = module {
        single {
            Random()
        }
        single<NumberRepository> {
            NumberRepositoryImpl(get())
        }
}
}
  1. 现在,在appModule之后添加活动模块变量:
    val mainModule = module {
        scope(named<MainActivity>()) {
            scoped {
                MainViewModel(get())
            }
        }
    }
  1. 现在,让我们在RandomApplicationonCreate()方法中初始化Koin
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@RandomApplication)
            modules(listOf(appModule, mainModule))
        }
    }
  1. 最后,让我们将依赖项注入到活动中:
class MainActivity :  ScopeActivity() {
    private val mainViewModel: MainViewModel by inject()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        mainViewModel.getLiveData().observe(this, Observer {
            findViewById<TextView>(R.id.activity_main_text_view)              .text = it.toString()
        }
        )
        findViewById<TextView>(R.id.activity_main_button)          .setOnClickListener {
            mainViewModel.generateNextNumber()
        }
    }
}
  1. 如果您运行前面的代码,应用程序应该按照之前的示例正常工作。但是,如果您检查LogCat,您将看到类似于这样的输出:
[Koin]: [init] declare Android Context
[Koin]: bind type:'android.content.Context' ~ [type:Single,primary_type:'android.content.Context']
[Koin]: bind type:'android.app.Application' ~ [type:Single,primary_type:'android.app.Application']
[Koin]: bind type:'java.util.Random' ~ [type:Single,primary_type:'java.util.Random']
[Koin]: bind type:'com.android.testable.randomapplication   .NumberRepository' ~ [type:Single,primary_type:'com.android   .testable.randomapplication.NumberRepository']
[Koin]: total 5 registered definitions
[Koin]: load modules in 0.4638 ms

图 12.6中,我们可以看到与以前练习中相同的输出:

图 12.6:模拟器输出练习 12.04 显示随机生成的数字

图 12.6:模拟器输出练习 12.04 显示随机生成的数字

从这个练习中可以看出,Koin 集成起来更快更容易,特别是其ViewModel库。这对于小型项目非常方便,但一旦项目增长,其性能将受到影响。

活动 12.01:注入的存储库

在这个活动中,您将在 Android Studio 中创建一个应用程序,该应用程序连接到一个示例 API,jsonplaceholder.typicode.com/posts,使用 Retrofit 库检索网页上的帖子列表,然后在屏幕上显示。然后,您需要设置一个 UI 测试,其中您将断言数据在屏幕上正确显示,但是不是连接到实际端点,而是提供虚拟数据供测试显示在屏幕上。您将利用 DI 概念,在应用程序执行时交换依赖项,而不是在进行测试时。

为了实现这一点,您需要构建以下内容:

  • 负责下载和解析 JSON 文件的网络组件

  • 从 API 层访问数据的存储库

  • 一个访问存储库的ViewModel实例

  • 一个带有RecycleView的活动,显示数据

  • 提供存储库实例的 Dagger 模块和提供ViewModel工厂实例的模块,以及一个将交换存储库依赖项的测试模块

  • 一个 UI 测试,断言行并使用虚拟对象生成 API 数据

注意

本次活动可以避免错误处理。

按顺序执行以下步骤以完成此活动:

  1. 在 Android Studio 中,创建一个带有Empty ActivityMainActivity)的应用程序,并添加一个api包,其中存储了 API 调用。

  2. 定义一个负责 API 调用的类。

  3. 创建一个repository包。

  4. 定义一个存储库接口,该接口将具有一个方法,返回帖子列表的LiveData

  5. 创建存储库类的实现。

  6. 创建一个ViewModel实例,该实例将调用存储库以检索数据。

  7. 为 UI 的行创建一个适配器。

  8. 创建渲染 UI 的活动。

  9. 设置一个 Dagger 模块,用于初始化与网络相关的依赖项。

  10. 创建一个 Dagger 模块,负责定义活动所需的依赖项。

  11. 创建一个子组件,该子组件将使用相关模块,并在活动中进行注入。

  12. 创建AppComponent,它将管理所有模块。

  13. 设置 UI 测试和测试应用程序,并提供一个单独的RepositoryModule类,该类将返回一个包含虚拟数据的依赖项。

  14. 实施 UI 测试。

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

Activity 12.02:Koin-Injected Repositories

在这个活动中,您将迁移Activity 12.01中构建的应用程序,即Injected Repositories,从 Dagger 到 Koin,保持要求不变。

假设您的代码中的组件与上一个活动的相同,需要按照以下步骤完成活动:

  1. build.gradlekapt插件中删除 Dagger 2 的依赖项。这将产生编译错误,可以指导您删除不必要的代码。

  2. 添加标准的Koin库和ViewModels库。

  3. 从代码中删除 Dagger 模块和组件。

  4. 创建networkModulerepositoryModuleactivityModule模块。

  5. 使用上述模块设置 Koin。

  6. ViewModel注入MainActivity

  7. TestApplication中覆盖repositoryModule,返回DummyRepository

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

总结

在本章中,我们分析了 DI 的概念以及如何应用它以分离关注点,并防止对象具有创建其他对象的责任,以及这对于测试的巨大好处。我们从分析手动 DI 的概念开始本章。这是 DI 如何工作以及如何应用于 Android 应用程序的一个很好的例子;它作为比较 DI 框架时的基线。

我们还分析了两种帮助开发人员进行依赖注入的最流行框架。我们首先介绍了一个强大而快速的框架,即 Dagger 2,它依赖于注解处理器来生成代码以执行注入。我们还研究了 Koin,这是一个用 Kotlin 编写的轻量级框架,性能较慢,但集成更简单,且非常关注 Android 组件。

本章的练习旨在探索如何使用多种解决方案解决同一问题,并比较解决方案之间的难度程度。在本章的活动中,我们利用 Dagger 和 Koin 的模块来在运行应用程序时注入某些依赖项,并在运行使用ViewModels、存储库和 API 加载数据的测试时注入其他依赖项。这旨在展示多个框架的无缝集成,这些框架实现了不同的目标。这些活动还代表了在先前章节中学到的不同技能的结合,从教您如何在 UI 上显示数据的基本技能到与网络、测试、ViewModels、存储库和依赖注入相关的更复杂的技能。

在接下来的章节中,您将有机会在已经获得的知识基础上构建,通过添加与线程和处理后台操作相关的概念。您将有机会探索诸如 RxJava 及其对线程的响应式方法,以及协程等库,后者对线程采取了不同的方法。您还将观察到协程和 RxJava 如何与 Room 和 Retrofit 等库结合得非常有效。最后,您将能够将所有这些概念结合在一个具有高度可扩展性的强大应用程序中。

第十三章:RxJava 和协程

概述

本章将介绍如何使用 RxJava 和协程进行后台操作和数据操作。它涵盖了如何使用 RxJava 从外部 API 检索数据以及如何使用协程进行操作。您还将学习如何使用 RxJava 操作符和 LiveData 转换来操作和显示数据。

在本章结束时,您将能够使用 RxJava 在后台管理网络调用,并使用 RxJava 操作符转换数据。您还将能够使用 Kotlin 协程在后台执行网络任务,并使用 LiveData 转换操作来操作数据。

介绍

您现在已经学会了 Android 应用程序开发的基础知识,并实现了诸如 RecyclerView、通知、从网络服务获取数据和服务等功能。您还掌握了最佳实践的测试和持久化数据的技能。在上一章中,您学习了依赖注入。现在,您将学习后台操作和数据操作。

一些 Android 应用程序可以自行运行。但是,大多数应用程序可能需要后端服务器来检索或处理数据。这些操作可能需要一段时间,具体取决于互联网连接、设备设置和服务器规格。如果长时间运行的操作在主 UI 线程中运行,应用程序将被阻塞,直到任务完成。应用程序可能会变得无响应,并可能提示用户关闭并停止使用它。

为了避免这种情况,可以将可能需要花费不确定时间的任务异步运行。异步任务意味着它可以与另一个任务并行运行或在后台运行。例如,当异步从数据源获取数据时,您的 UI 仍然可以显示或与用户交互。

您可以使用 RxJava 和协程等库进行异步操作。我们将在本章讨论它们。让我们开始使用 RxJava。

RxJava

RxJava 是Reactive ExtensionsRx)的 Java 实现,这是一种用于响应式编程的库。在响应式编程中,您有可以被观察的数据流。当值发生变化时,您的观察者可以收到通知并做出相应的反应。例如,假设点击按钮是您的可观察对象,并且您有观察者在监听它。如果用户点击该按钮,您的观察者可以做出反应并执行特定操作。

RxJava 使异步数据处理和处理错误变得更简单。以常规方式编写它很棘手且容易出错。如果您的任务涉及一系列异步任务,那么编写和调试将会更加复杂。使用 RxJava,可以更轻松地完成,并且代码量更少,更易读和易于维护。RxJava 还具有广泛的操作符,可用于将数据转换为所需的类型或格式。

RxJava 有三个主要组件:可观察对象、观察者和操作符。要使用 RxJava,您需要创建发出数据的可观察对象,使用 RxJava 操作符转换数据,并使用观察者订阅可观察对象。观察者可以等待可观察对象产生数据,而不会阻塞主线程。

可观察对象、观察者和操作符

让我们详细了解 RxJava 的三个主要组件。

可观察对象

可观察对象是可以被监听的数据源。它可以向其监听者发出数据。

Observable类表示一个可观察对象。您可以使用Observable.justObservable.from方法从列表、数组或对象创建可观察对象。例如,您可以使用以下方式创建可观察对象:

val observable = Observable.just("This observable emits this string")
val observableFromList = Observable.fromIterable(listOf(1, 2, 3, 4))

还有更多函数可用于创建可观察对象,例如 Observable.createObservable.deferObservable.emptyObservable.generateObservable.neverObservable.rangeObservable.intervalObservable.timer。您还可以创建一个返回 observable 的函数。了解有关创建可观察对象的更多信息,请访问 github.com/ReactiveX/RxJava/wiki/Creating-Observables

可观察对象可以是热的或冷的。冷可观察对象只有在有订阅者监听时才会发出数据。例如数据库查询或网络请求。另一方面,热可观察对象即使没有观察者也会发出数据。例如,Android 中的 UI 事件,如鼠标和键盘事件。

一旦创建了可观察对象,观察者就可以开始监听可观察对象将发送的数据。

操作符

操作符允许您在将数据传递给观察者之前修改和组合从可观察对象获取的数据。使用操作符会返回另一个可观察对象,因此您可以链接操作符调用。例如,假设您有一个可观察对象,它会发出从 1 到 10 的数字。您可以对其进行过滤,只获取偶数,并将列表转换为另一个包含每个项目平方的列表。要在 RxJava 中执行此操作,您可以使用以下代码:

Observable.range(1, 10)
.filter { it % 2 == 0 }
.map { it * it }

上述代码的输出将是一个数据流,其中包含值 4、16、36、64 和 100。

观察者

观察者订阅可观察对象,并在观察者发出数据时收到通知。它们可以监听可观察对象发出的下一个值或错误。Observer 类是观察者的接口。在创建观察者时,它有四种方法可以重写:

  • onComplete:当可观察对象完成发送数据时

  • onNext:当可观察对象发送新数据时

  • onSubscribe:当观察者订阅可观察对象时

  • onError:当可观察对象遇到错误时

要订阅可观察对象,可以调用 Observable.subscribe(),传入 Observer 接口的新实例。例如,如果要订阅从 210 的偶数可观察对象,可以执行以下操作:

Observable.fromIterable(listOf(2, 4, 6, 8, 10))
    .subscribe(object : Observer<Int> {
        override fun onComplete() {
            println("completed")
        }
        override fun onSubscribe(d: Disposable) {
            println("subscribed")
        }
        override fun onNext(t: Int) {
            println("next integer is $t")
        }
        override fun onError(e: Throwable) {
            println("error encountered")
        }
    })

使用此代码,观察者将打印下一个整数。它还会在订阅时打印文本,当可观察对象完成时,以及当遇到错误时。

Observable.subscribe() 具有不同的重载函数,其中您可以传递 onNextonErroronCompleteonSubscribe 参数。这些函数返回一个 disposable 对象。在关闭活动时,可以调用其 dispose 函数以防止内存泄漏。例如,您可以使用一个变量来存储 disposable 对象:

val disposable = observable
            ...
            .subscribe(...)

然后,在您创建可观察对象的活动的 onDestroy 函数中,您可以调用 disposable.dispose() 来阻止观察者监听可观察对象:

override fun onDestroy() {
    super.onDestroy()
    disposable.dispose()
}

除了可观察对象、观察者和操作符之外,您还需要了解 RxJava 调度程序,这将在下一节中介绍。

调度程序

默认情况下,RxJava 是同步的。这意味着所有进程都在同一个线程中完成。有一些任务需要一段时间,例如数据库和网络操作,需要异步执行或在另一个线程中并行运行。为此,您需要使用调度程序。

调度程序允许您控制操作将在其中运行的线程。您可以使用两个函数:observeOnsubscribeOn。您可以使用 subscribeOn 函数设置可观察对象将在哪个线程上运行。observeOn 函数允许您设置下一个操作将在哪里执行。

例如,如果您有 getData 函数,该函数从网络获取数据并返回一个可观察对象,您可以订阅 Schedulers.io 并使用 AndroidSchedulers.mainThread() 观察 Android 主 UI 线程:

val observable = getData()
   .subscribeOn(Schedulers.io())
   .observeOn(AndroidSchedulers.mainThread())
   ...

AndroidSchedulers是 RxAndroid 的一部分,它是 RxJava 在 Android 上的扩展。您需要 RxAndroid 来在 Android 应用程序开发中使用 RxJava。

在下一节中,您将学习如何将 RxJava 和 RxAndroid 添加到您的项目中。

将 RxJava 添加到您的项目

您可以通过将以下代码添加到app/build.gradle文件的依赖项中,将 RxJava 添加到您的项目中:

implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'io.reactivex.rxjava3:rxjava:3.0.7'

这将向您的 Android 项目中添加 RxJava 和 RxAndroid 库。RxAndroid 库已经包含了 RxJava,但最好还是添加 RxJava 依赖项,因为 RxAndroid 捆绑的版本可能不是最新版本。

在 Android 项目中使用 RxJava

RxJava 有几个好处,其中之一是处理长时间运行的操作,比如在非 UI 线程中进行网络请求。网络调用的结果可以转换为可观察对象。然后,您可以创建一个观察者来订阅可观察对象并呈现数据。在向用户显示数据之前,您可以使用 RxJava 操作符转换数据。

如果您使用 Retrofit,可以通过添加调用适配器工厂将响应转换为 RxJava 可观察对象。首先,您需要在app/build.gradle文件的依赖项中添加adapter-rxjava3,如下所示:

implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'

有了这个,您可以在您的Retrofit实例中使用RxJava3CallAdapterFactory作为调用适配器。您可以使用以下代码来实现:

val retrofit = Retrofit.Builder()
    ...
    .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
    ...

现在,您的 Retrofit 方法可以返回您可以在代码中监听的Observable对象。例如,在调用电影端点的getMovies Retrofit 方法中,您可以使用以下内容:

@GET("movie")
fun getMovies() : Observable<Movie>

让我们尝试一下您迄今为止学到的知识,通过将 RxJava 添加到 Android 项目中。

练习 13.01:在 Android 项目中使用 RxJava

本章中,您将使用一个应用程序来显示使用 The Movie Database API 的热门电影。转到developers.themoviedb.org/并注册 API 密钥。在这个练习中,您将使用 RxJava 从电影/流行的端点获取所有热门电影的列表,而不考虑年份:

  1. 在 Android Studio 中创建一个新项目。将项目命名为Popular Movies,并使用包名com.example.popularmovies

  2. 设置您想要保存项目的位置,然后单击完成按钮。

  3. 打开AndroidManifest.xml并添加INTERNET权限:

<uses-permission android:name="android.permission.INTERNET" />

这将允许您使用设备的互联网连接进行网络调用。

  1. 打开app/build.gradle文件,并在插件块的末尾添加 kotlin-parcelize 插件:
plugins {
    ...
    id 'kotlin-parcelize'
}

这将允许您为模型类使用 Parcelable。

  1. android块中添加以下内容:
compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
    jvmTarget = '1.8'
}

这将允许您在项目中使用 Java 8。

  1. app/build.gradle文件中添加以下依赖项:
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'io.reactivex.rxjava3:rxjava:3.0.7'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.github.bumptech.glide:glide:4.11.0'

这些行将向您的项目中添加 RecyclerView、Glide、Retrofit、RxJava、RxAndroid 和 Moshi 库。

  1. res/values目录中创建一个dimens.xml文件,并添加一个layout_margin维度值:
<resources>
    <dimen name="layout_margin">16dp</dimen>
</resources>

这将用于视图的垂直和水平边距。

  1. 创建一个名为view_movie_item.xml的新布局文件,并添加以下内容:
view_movie_item.xml
9    <ImageView
10        android:id="@+id/movie_poster"
11        android:layout_width="match_parent"
12        android:layout_height="240dp"
13        android:contentDescription="Movie Poster"
14        app:layout_constraintBottom_toBottomOf="parent"
15        app:layout_constraintEnd_toEndOf="parent"
16        app:layout_constraintStart_toStartOf="parent"
17        app:layout_constraintTop_toTopOf="parent"
18        tools:src="img/scenic" />
19
20    <TextView
21        android:id="@+id/movie_title"
22        android:layout_width="match_parent"
23        android:layout_height="wrap_content"
24        android:layout_marginStart="@dimen/layout_margin"
25        android:layout_marginEnd="@dimen/layout_margin"
26        android:ellipsize="end"
27        android:gravity="center"
28        android:lines="1"
29        android:textSize="20sp"
30        app:layout_constraintEnd_toEndOf="@id/movie_poster"
31        app:layout_constraintStart_toStartOf="@id/movie_poster"
32        app:layout_constraintTop_toBottomOf="@id/movie_poster"
33        tools:text="Movie" />
The complete code for this step can be found at http://packt.live/3sD8zmN.

这个布局文件包含电影海报和标题文本,将用于列表中的每部电影。

  1. 打开activity_main.xml。用 RecyclerView 替换 Hello World TextView:
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/movie_list"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
app:layoutManager=  "androidx.recyclerview.widget.GridLayoutManager"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:spanCount="2"
    tools:listitem="@layout/view_movie_item" />

这个 RecyclerView 将显示电影列表。它将使用GridLayoutManager,有两列。

  1. 为您的模型类创建一个新包com.example.popularmovies.model。创建一个名为Movie的新模型类,如下所示:
@Parcelize
data class Movie(
    val adult: Boolean = false,
    val backdrop_path: String = "",
    val id: Int = 0,
    val original_language: String = "",
    val original_title: String = "",
    val overview: String = "",
    val popularity: Float = 0f,
    val poster_path: String = "",
    val release_date: String = "",
    val title: String = "",
    val video: Boolean = false,
    val vote_average: Float = 0f,
    val vote_count: Int = 0
) : Parcelable

这将是代表 API 中的Movie对象的模型类。

  1. 创建一个名为DetailsActivity的新活动,使用activity_details.xml作为布局文件。

  2. 打开AndroidManifest.xml文件,并将MainActivity作为DetailsActivityparentActivityName属性的值添加进去:

<activity android:name=".DetailsActivity"
            android:parentActivityName=".MainActivity" />

这将在详细信息活动中添加一个向上图标,以返回到主屏幕。

  1. 打开activity_details.xml。添加所需的视图。(以下代码由于空间限制而被截断。请参考下面链接的文件以获取您需要添加的完整代码。)
activity_details.xml
9    <ImageView
10        android:id="@+id/movie_poster"
11        android:layout_width="160dp"
12        android:layout_height="160dp"
13        android:layout_margin="@dimen/layout_margin"
14        android:contentDescription="Poster"
15        app:layout_constraintStart_toStartOf="parent"
16        app:layout_constraintTop_toTopOf="parent"
17        tools:src="img/avatars" />
18
19    <TextView
20        android:id="@+id/title_text"
21        style="@style/TextAppearance.AppCompat.Title"
22        android:layout_width="0dp"
23        android:layout_height="wrap_content"
24        android:layout_margin="@dimen/layout_margin"
25        android:ellipsize="end"
26        android:maxLines="4"
27        app:layout_constraintEnd_toEndOf="parent"
28        app:layout_constraintStart_toEndOf="@+id/movie_poster"
29        app:layout_constraintTop_toTopOf="parent"
30        tools:text="Title" />
The complete code for this step can be found at http://packt.live/38WyRbQ.

这将在详情屏幕上添加海报、标题、发布日期和概述。

  1. 打开DetailsActivity并添加以下内容:
class DetailsActivity : AppCompatActivity() {
    companion object {
        const val EXTRA_MOVIE = "movie"
        const val IMAGE_URL = "https://image.tmdb.org/t/p/w185/"
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_details)
        val titleText: TextView = findViewById(R.id.title_text)
        val releaseText: TextView = findViewById(R.id.release_text)
        val overviewText: TextView = findViewById(R.id.overview_text)
        val poster: ImageView = findViewById(R.id.movie_poster)
        val movie = intent.getParcelableExtra<Movie>(EXTRA_MOVIE)
        movie?.run {
            titleText.text = title
            releaseText.text = release_date.take(4)
            overviewText.text = "Overview: $overview"
            Glide.with(this@DetailsActivity)
                .load("$IMAGE_URL$poster_path")
                .placeholder(R.mipmap.ic_launcher)
                .fitCenter()
                .into(poster)
        }
    }
}

这将显示所选电影的海报、标题、发布日期和概述。

  1. 为电影列表创建一个适配器类。将类命名为MovieAdapter。添加以下内容:
class MovieAdapter(private val clickListener: MovieClickListener) :   RecyclerView.Adapter<MovieAdapter.MovieViewHolder>() {
    private val movies = mutableListOf<Movie>()
override fun onCreateViewHolder(parent: ViewGroup,   viewType: Int): MovieViewHolder {
        val view = LayoutInflater.from(parent.context)          .inflate(R.layout.view_movie_item, parent, false)
        return MovieViewHolder(view)
    }
    override fun getItemCount() = movies.size
    override fun onBindViewHolder(holder: MovieViewHolder,       position: Int) {
        val movie = movies[position]
        holder.bind(movie)
        holder.itemView.setOnClickListener {           clickListener.onMovieClick(movie) }
    }
    fun addMovies(movieList: List<Movie>) {
        movies.addAll(movieList)
        notifyItemRangeInserted(0, movieList.size)
    }
}

这个类将是您的 RecyclerView 的适配器。

  1. onBindViewHolder函数之后为您的类添加ViewHolder
class MovieAdapter...
    ...
    class MovieViewHolder(itemView: View) :       RecyclerView.ViewHolder(itemView) {
        private val imageUrl = "https://image.tmdb.org/t/p/w185/"
        private val titleText: TextView by lazy {
            itemView.findViewById(R.id.movie_title)
        }
        private val poster: ImageView by lazy {
            itemView.findViewById(R.id.movie_poster)
        }
        fun bind(movie: Movie) {
            titleText.text = movie.title
            Glide.with(itemView.context)
                .load("$imageUrl${movie.poster_path}")
                .placeholder(R.mipmap.ic_launcher)
                .fitCenter()
                .into(itemView.poster)
        }
    }
}

这将是MovieAdapter用于 RecyclerView 的ViewHolder

  1. MovieViewHolder声明之后,添加MovieClickListener
class MovieAdapter...
    ...
    interface MovieClickListener {
        fun onMovieClick(movie: Movie)
    }
}

这个接口将在点击电影查看详情时使用。

  1. com.example.popularmovies.model包中创建另一个名为PopularMoviesResponse的类:
data class PopularMoviesResponse (
    val page: Int,
    val results: List<Movie>
)

这将是您从热门电影 API 端点获取的响应的模型类。

  1. 创建一个新的包com.example.popularmovies.api,并添加一个带有以下内容的MovieService接口:
interface MovieService {
@GET("movie/popular")
fun getPopularMovies(@Query("api_key") apiKey: String):   Observable<PopularMoviesResponse>
}

这将定义您将使用的端点来检索热门电影。

  1. 创建一个MovieRepository类,并为movieService添加一个构造函数:
class MovieRepository(private val movieService: MovieService) { ... } 
  1. 添加apiKey(值为 The Movie Database API 的 API 密钥)和一个fetchMovies函数来从端点检索列表:
private val apiKey = "your_api_key_here"
fun fetchMovies() = movieService.getPopularMovies(apiKey)
  1. 创建一个名为MovieApplication的应用程序类,并为movieRepository添加一个属性:
class MovieApplication : Application() {
    lateinit var movieRepository: MovieRepository
}

这将是应用程序的应用程序类。

  1. 覆盖MovieApplication类的onCreate函数并初始化movieRepository
override fun onCreate() { 
  super.onCreate()
  val retrofit = Retrofit.Builder()
    .baseUrl("https://api.themoviedb.org/3/")
    .addConverterFactory(MoshiConverterFactory.create())
    .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
    .build()
  val movieService = retrofit.create(MovieService::class.java)
  movieRepository = MovieRepository(movieService) 
}
  1. MovieApplication设置为AndroidManifest.xml文件中应用程序的android:name属性的值:
<application
    ...
    android:name=".MovieApplication"
    ... />
  1. 创建一个MovieViewModel类,并为movieRepository添加一个构造函数:
class MovieViewModel(private val movieRepository: MovieRepository) :   ViewModel() { ... }
  1. popularMovieserrordisposable添加属性:
private val popularMoviesLiveData = MutableLiveData<List<Movie>>()
private val errorLiveData = MutableLiveData<String>()
val popularMovies: LiveData<List<Movie>>
    get() = popularMoviesLiveData
val error: LiveData<String>
    get() = errorLiveData
private var disposable = CompositeDisposable()
  1. 定义fetchPopularMovies函数。在函数内部,从movieRepository获取热门电影:
    fun fetchPopularMovies() {
        disposable.add(movieRepository.fetchMovies()
            .subscribeOn(Schedulers.io())
            .map { it.results }
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe({
                popularMoviesLiveData.postValue(it)
            }, { error ->
                errorLiveData.postValue("An error occurred:                   ${error.message}")
            })
        )
    }

当订阅时,这将在Schedulers.io线程中异步获取热门电影,并返回一个可观察对象,并在主线程上使用操作符。

  1. 覆盖MovieViewModelonCleared函数并处理disposable
    override fun onCleared() {
        super.onCleared()
        disposable.dispose()
    }

当 ViewModel 被清除时,例如当活动被关闭时,这将处理disposable

  1. 打开MainActivity并定义一个电影适配器的字段:
private val movieAdapter by lazy {
    MovieAdapter(object : MovieAdapter.MovieClickListener {
        override fun onMovieClick(movie: Movie) {
            openMovieDetails(movie)
        }
    })
}

这将有一个监听器,当点击电影时将打开详情屏幕。

  1. onCreate函数中,为movie_listRecyclerView设置适配器:
val recyclerView: RecyclerView = findViewById(R.id.movie_list)
recyclerView.adapter = movieAdapter 
  1. MainActivity上创建一个getMovies函数。在内部,初始化movieRepositorymovieViewModel
    private fun getMovies() {
        val movieRepository = (application as           MovieApplication).movieRepository
        val movieViewModel = ViewModelProvider(this, object :           ViewModelProvider.Factory {
            override fun <T : ViewModel?>               create(modelClass: Class<T>): T {
                return MovieViewModel(movieRepository) as T
            }
        }).get(MovieViewModel::class.java)
    }
  1. getMovies函数的末尾,向movieViewModelpopularMovieserror LiveData 添加一个观察者:
private fun getMovies() {
        ...
        movieViewModel.fetchPopularMovies()
        movieViewModel.popularMovies
            .observe(this, { popularMovies ->
                movieAdapter.addMovies(popularMovies)
            })
            movieViewModel.error.observe(this, { error ->
                Toast.makeText(this, error, Toast.LENGTH_LONG).show()
            })
    }
  1. MainActivity类的onCreate函数的末尾,调用getMovies()函数:
getMovies()
  1. 在点击列表中的电影时添加openMovieDetails函数以打开详情屏幕:
private fun openMovieDetails(movie: Movie) { 
    val intent =       Intent(this, DetailsActivity::class.java).apply { 
        putExtra(DetailsActivity.EXTRA_MOVIE, movie)
    }
    startActivity(intent)
}
  1. 运行您的应用程序。您将看到该应用程序将显示一个热门电影标题列表:图 13.1:热门电影应用程序的外观

图 13.1:热门电影应用程序的外观

  1. 点击电影,您将看到它的发布日期和概述等详情:

图 13.2:电影详情屏幕

图 13.2:电影详情屏幕

您已经学会了如何使用 RxJava 从外部 API 检索响应。在下一节中,您将使用 RxJava 操作符将获取的数据转换为需要显示的数据。

使用 RxJava 操作符修改数据

当您有一个发出数据的 observable 时,您可以使用操作符在将其传递给观察者之前修改数据。您可以使用单个操作符或一系列操作符来获取所需的数据。您可以使用不同类型的操作符,例如转换操作符和过滤操作符。

转换操作符可以将 observable 中的项目修改为您喜欢的数据。 flatMap()操作符将项目转换为 observable。在练习 13.01 在 Android 项目中使用 RxJava中,您将 observable PopularMoviesResponse转换为 observable Movies如下:

.flatMap { Observable.fromIterable(it.results) }

另一个可以转换数据的操作符是mapmap(x)操作符将函数x应用于每个项目,并返回具有更新值的另一个 observable。例如,如果您有一个数字列表的 observable,可以使用以下内容将其转换为另一个 observable 列表,其中每个数字都乘以 2:

.map { it * 2 }

过滤操作符允许您仅选择其中的一些项目。使用filter(),您可以根据一组条件选择项目。例如,您可以使用以下内容过滤奇数:

.filter { it % 2 != 0 }

first()last()操作符允许您获取第一个和最后一个项目,而使用take(n)takeLast(n),您可以获取n个第一个或最后一个项目。还有其他过滤操作符,如debounce()distinct()elementAt()ignoreElements()sample()skip()skipLast()

还有许多其他 RxJava 操作符可供您使用。让我们尝试在 Android 项目中使用 RxJava 操作符。

练习 13.02:使用 RxJava 操作符

在上一个练习中,您使用了 RxJava 从 The Movie Database API 获取了热门电影列表。现在,在将它们显示在 RecyclerView 之前,您将添加操作符来按标题排序电影并仅获取上个月发布的电影:

  1. 打开练习 13.01 在 Android 项目中使用 RxJava中的Popular Movies项目。

  2. 打开MovieViewModel并导航到fetchPopularMovies函数。

  3. 您将修改应用程序以仅显示今年的热门电影。将.map { it.results }替换为以下内容:

.flatMap { Observable.fromIterable(it.results) }
.toList()

这将把MovieResponse的 Observable 转换为Movies的 observable。

  1. toList()调用之前,添加以下内容:
.filter {
    val cal = Calendar.getInstance()
    cal.add(Calendar.MONTH, -1)
    it.release_date.startsWith(
        "${cal.get(Calendar.YEAR)}-${cal.get(Calendar.MONTH) + 1}"
    )
}

这将仅选择上个月发布的电影。

  1. 运行应用程序。您将看到其他电影不再显示。只有今年发布的电影才会出现在列表上:图 13.3:年度热门电影应用程序

图 13.3:年度热门电影应用程序

  1. 您还会注意到显示的电影没有按字母顺序排列。在toList()调用之前使用sorted操作符对电影进行排序:
.sorted { movie, movie2 -> movie.title.compareTo(movie2.title) }

这将根据它们的标题对电影进行排序。

  1. 运行应用程序。您将看到电影列表现在按标题按字母顺序排序:图 13.4:按标题排序的年度热门电影应用程序

图 13.4:按标题排序的年度热门电影应用程序

  1. toList()调用之前,使用map操作符将电影列表映射到另一个标题为大写的列表中:
.map { it.copy(title = it.title.toUpperCase(Locale.getDefault())) }
  1. 运行应用程序。您将看到电影标题现在是大写字母:图 13.5:电影标题为大写的应用程序

图 13.5:电影标题为大写的应用程序

  1. toList()调用之前,使用take操作符仅从列表中获取前四部电影:
.take(4)
  1. 运行应用程序。您将看到 RecyclerView 只会显示四部电影:图 13.6:只有四部电影的应用程序

图 13.6:只有四部电影的应用程序

  1. 尝试其他 RxJava 操作符并运行应用程序查看结果。

您已经学会了如何使用 RxJava 操作符在显示它们之前操作来自外部 API 的检索响应。

在下一节中,您将学习如何使用协程而不是 RxJava 从外部 API 获取数据。

协程

Kotlin 1.3 中添加了协程,用于管理后台任务,例如进行网络调用和访问文件或数据库。Kotlin 协程是 Google 在 Android 上异步编程的官方推荐。他们的 Jetpack 库,如 LifeCycle、WorkManager 和 Room,现在包括对协程的支持。

使用协程,您可以以顺序方式编写代码。长时间运行的任务可以转换为挂起函数,当调用时可以暂停线程而不阻塞它。当挂起函数完成时,当前线程将恢复执行。这将使您的代码更易于阅读和调试。

将函数标记为挂起函数,可以在其中添加suspend关键字;例如,如果您有一个调用getMovies函数的函数,该函数从您的端点获取movies然后显示它:

val movies = getMovies()
displayMovies(movies) 

您可以通过添加suspend关键字将getMovies()函数设置为挂起函数:

suspend fun getMovies(): List<Movies> { ... }

在这里,调用函数将调用getMovies并暂停。在getMovies返回电影列表后,它将恢复其任务并显示电影。

挂起函数只能在挂起函数中调用,或者从协程中调用。协程具有上下文,其中包括协程调度程序。调度程序指定协程将使用的线程。您可以使用三个调度程序:

  • Dispatchers.Main:用于在 Android 的主线程上运行

  • Dispatchers.IO:用于网络、文件或数据库操作

  • Dispatchers.Default:用于 CPU 密集型工作

要更改协程的上下文,可以使用withContext函数,用于您希望在不同线程中使用的代码。例如,在您的挂起函数getMovies中,该函数从您的端点获取电影,您可以使用Dispatchers.IO

suspend fun getMovies(): List<Movies>  {
    withContext(Dispatchers.IO) { ... }
}

在下一节中,我们将介绍如何创建协程。

创建协程

您可以使用asynclaunch关键字创建一个协程。launch关键字创建一个协程并不返回任何东西。另一方面,async关键字返回一个值,您可以稍后使用await函数获取。

asynclaunch必须从CoroutineScope创建,它定义了协程的生命周期。例如,主线程的协程范围是MainScope。然后,您可以使用以下内容创建协程:

MainScope().async { ... }
MainScope().launch { ... }

您还可以创建自己的CoroutineScope,而不是使用MainScope,通过使用CoroutineScope创建一个协程的上下文。例如,要为网络调用创建CoroutineScope,可以定义如下内容:

val scope = CoroutineScope(Dispatchers.IO)

当不再需要函数时,例如关闭活动时,可以取消协程。您可以通过从CoroutineScope调用cancel函数来实现:

scope.cancel()

ViewModel 还具有用于创建协程的默认CoroutineScopeviewModelScope。Jetpack 的 LifeCycle 还具有lifecycleScope,您可以使用它。当 ViewModel 被销毁时,viewModelScope被取消;当生命周期被销毁时,lifecycleScope也被取消。因此,您不再需要取消它们。

在下一节中,您将学习如何将协程添加到您的项目中。

将协程添加到您的项目中

您可以通过将以下代码添加到您的app/build.gradle文件的依赖项中,将协程添加到您的项目中:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"

kotlinx-coroutines-core是协程的主要库,而kotlinx-coroutines-android为主要的 Android 线程添加了支持。

在 Android 中进行网络调用或从本地数据库获取数据时,可以添加协程。

如果您使用的是 Retrofit 2.6.0 或更高版本,可以使用suspend将端点函数标记为挂起函数:

@GET("movie/latest")
suspend fun getMovies() : List<Movies>

然后,您可以创建一个协程,调用挂起函数getMovies并显示列表:

CoroutineScope(Dispatchers.IO).launch {
    val movies = movieService.getMovies()
    withContext(Dispatchers.Main) {
        displayMovies(movies)
    }
}

您还可以使用 LiveData 来响应您的协程。LiveData 是一个 Jetpack 类,可以保存可观察的数据。通过添加以下依赖项,您可以将 LiveData 添加到 Android 项目中:

implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'

让我们尝试在 Android 项目中使用协程。

练习 13.03:在 Android 应用程序中使用协程

在这个练习中,您将使用协程从 The Movie Database API 获取热门电影列表。您可以使用上一个练习中的Popular Movies项目,或者复制一个:

  1. 在 Android Studio 中打开Popular Movies项目。

  2. 打开app/build.gradle文件,并删除以下依赖项:

implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'io.reactivex.rxjava3:rxjava:3.0.7'

由于您将使用协程而不是 RxJava,因此将不再需要这些依赖项。

  1. app/build.gradle文件中,添加 Kotlin 协程的依赖项:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'

这将允许您在项目中使用协程。

  1. 还要添加 ViewModel 和 LiveData 扩展库的依赖项:
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
  1. 打开MovieService接口,并用以下代码替换它:
interface MovieService {
    @GET("movie/popular")
    suspend fun getPopularMovies(@Query("api_key") apiKey: String):       PopularMoviesResponse
}

这将把getPopularMovies标记为挂起函数。

  1. 打开MovieRepository并为电影列表添加 movies 和 error LiveData:
    private val movieLiveData = MutableLiveData<List<Movie>>()
    private val errorLiveData = MutableLiveData<String>()
    val movies: LiveData<List<Movie>>
        get() = movieLiveData
    val error: LiveData<String>
        get() = errorLiveData
  1. fetchMovies函数替换为一个挂起函数,以从端点检索列表:
    suspend fun fetchMovies() {
        try {
            val popularMovies = movieService.getPopularMovies(apiKey)
            movieLiveData.postValue(popularMovies.results)
        } catch (exception: Exception) {
            errorLiveData.postValue("An error occurred:               ${exception.message}")
        }
    }
  1. 使用以下代码更新MovieViewModel的内容:
    init {
        fetchPopularMovies()
    }
    val popularMovies: LiveData<List<Movie>>
    get() = movieRepository.movies
    fun getError(): LiveData<String> = movieRepository.error
    private fun fetchPopularMovies() {
        viewModelScope.launch(Dispatchers.IO)  {
            movieRepository.fetchMovies()
        }
    }

fetchPopularMovies函数有一个协程,使用viewModelScope,它将从movieRepository获取电影。

  1. 打开MovieApplication文件。在onCreate函数中,删除包含addCallAdapterFactory的行。它应该是这样的:
    override fun onCreate() {
        super.onCreate()
        val retrofit = Retrofit.Builder()
            .baseUrl("https://api.themoviedb.org/3/")
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
        ...
    }
  1. 打开MainActivity类。删除getMovies函数。

  2. onCreate函数中,删除对getMovies的调用。然后,在onCreate函数的末尾,创建movieViewModel

val movieRepository =   (application as MovieApplication).movieRepository
val movieViewModel =   ViewModelProvider(this, object: ViewModelProvider.Factory {
    override fun <T : ViewModel?>       create(modelClass: Class<T>): T {
        return MovieViewModel(movieRepository) as T
    }
}).get(MovieViewModel::class.java)
  1. 之后,向movieViewModelgetPopularMovieserror LiveData 添加观察者:
        movieViewModel.popularMovies.observe(this, { popularMovies ->
            movieAdapter.addMovies(popularMovies
                .filter {
                    it.release_date.startsWith(
                        Calendar.getInstance().get(Calendar.YEAR)                          .toString()
                    )
                }
                .sortedBy { it.title }
            )
        })
        movieViewModel.getError().observe(this, { error ->
            Toast.makeText(this, error, Toast.LENGTH_LONG).show()
})

这将使用 Kotlin 的filter函数对电影列表进行过滤,只包括今年发布的电影。然后使用 Kotlin 的sortedBy函数按标题排序。

  1. 运行应用程序。您将看到应用程序将显示今年发布的热门电影标题列表,按标题排序:

图 13.7:应用程序显示今年发布的热门电影,按标题排序

图 13.7:应用程序显示今年发布的热门电影,按标题排序

您已经使用协程和 LiveData 从远程数据源检索和显示了一组热门电影列表,而不会阻塞主线程。

在将 LiveData 传递到 UI 进行显示之前,您还可以首先转换数据。您将在下一节中了解到这一点。

转换 LiveData

有时,您从 ViewModel 传递到 UI 层的 LiveData 在显示之前需要进行处理。例如,您只能选择部分数据或者首先对其进行一些处理。在上一个练习中,您对数据进行了过滤,只选择了当前年份的热门电影。

要修改 LiveData,您可以使用Transformations类。它有两个函数,Transformations.mapTransformations.switchMap,您可以使用。

Transformations.map将 LiveData 的值修改为另一个值。这可用于过滤、排序或格式化数据等任务。例如,您可以将movieLiveData从电影标题转换为字符串 LiveData:

private val movieLiveData: LiveData<Movie>
val movieTitleLiveData : LiveData<String> = 
   Transformations.map(movieLiveData) { it.title }

movieLiveData的值发生变化时,movieTitleLiveData也会根据电影的标题发生变化。

使用Transformations.switchMap,您可以将 LiveData 的值转换为另一个 LiveData。当您想要使用原始 LiveData 进行涉及数据库或网络操作的特定任务时使用。例如,如果您有一个表示电影id对象的 LiveData,您可以通过应用函数getMovieDetails将其转换为电影 LiveData,该函数从id对象(例如从另一个网络或数据库调用)返回电影详细信息的 LiveData:

private val idLiveData: LiveData<Int> = MutableLiveData()
val movieLiveData : LiveData<Movie> = 
    Transformations.switchMap(idLiveData) { getMovieDetails(it) }
fun getMovieDetails(id: Int) : LiveData<Movie> = { ... }

让我们在使用协程获取的电影列表上使用 LiveData 转换。

练习 13.04:LiveData 转换

在这个练习中,您将在传递给MainActivity文件中的观察者之前转换电影的 LiveData 列表:

  1. 在 Android Studio 中,打开您在上一个练习中使用的“热门电影”项目。

  2. 打开MainActivity文件。在onCreate函数中的movieViewModel.popularMovies观察者中,删除过滤器和sortedBy函数调用。代码应如下所示:

movieViewModel.getPopularMovies().observe(this,   Observer { popularMovies ->
    movieAdapter.addMovies(popularMovies)
})

现在将显示列表中的所有电影,而不按标题排序。

  1. 运行应用程序。您应该看到所有电影(甚至是去年的电影),而不是按标题排序:图 13.8:未排序的热门电影应用程序

图 13.8:未排序的热门电影应用程序

  1. 打开MovieViewModel类,并使用 LiveData 转换来更新popularMovies以过滤和排序电影:
        val popularMovies: LiveData<List<Movie>>
        get() = movieRepository.movies.map { list ->
        list.filter {
            val cal = Calendar.getInstance()
            cal.add(Calendar.MONTH, -1)
            it.release_date.startsWith(
                "${cal.get(Calendar.YEAR)}-${cal.get(Calendar.MONTH)                   + 1}"
            )
        }.sortedBy { it.title }
    }

这将选择上个月发布的电影,并在传递给MainActivity中的 UI 观察者之前按标题排序。

  1. 运行应用程序。您会看到应用程序显示了按标题排序的今年热门电影列表:

图 13.9:按标题排序的今年发布的电影应用程序

图 13.9:按标题排序的今年发布的电影应用程序

您已经使用了 LiveData 转换来修改电影列表,只选择今年发布的电影。它们在传递给 UI 层的观察者之前也按标题排序。

协程通道和流

如果您的协程正在获取数据流或您有多个数据源并且逐个处理数据,您可以使用通道或流。

通道允许在不同的协程之间传递数据。它们是热数据流。它将在被调用时运行并发出值,即使没有监听器。而流是冷异步流。只有在收集值时才会发出值。

要了解有关通道和流的更多信息,您可以访问kotlinlang.org

RxJava 与协程

RxJava 和协程都可以用于在 Android 中执行后台任务,例如网络调用或数据库操作。

那么应该使用哪一个?虽然您可以在应用程序中同时使用两者,例如,对于一个任务使用 RxJava,对于另一个任务使用协程,您还可以与LiveDataReactiveStreamskotlinx-coroutines-rx3一起使用它们。然而,这将增加您使用的依赖项数量和您的应用程序的大小。

那么,RxJava 还是协程?以下表格显示了两者之间的区别:

图 13.10:协程和 RxJava 之间的区别

图 13.10:协程和 RxJava 之间的区别

让我们继续下一个活动。

活动 13.01:创建电视指南应用程序

很多人看电视。然而,大多数时候,他们不确定当前有哪些电视节目正在播放。假设你想开发一个应用程序,可以使用 Kotlin 协程和 LiveData 从 The Movie Database API 的tv/on_the_air端点显示这些节目的列表。

该应用程序将有两个屏幕:主屏幕和详情屏幕。在主屏幕上,您将显示正在播出的电视节目列表。电视节目将按名称排序。点击一个电视节目将打开详情屏幕,显示有关所选电视节目的更多信息。

完成步骤:

  1. 在 Android Studio 中创建一个名为TV Guide的新项目,并设置其包名称。

  2. AndroidManifest.xml文件中添加INTERNET权限。

  3. app/build.gradle文件中,添加 Java 8 兼容性和 RecyclerView、Glide、Retrofit、RxJava、RxAndroid、Moshi、ViewModel 和 LiveData 库的依赖项。

  4. 添加layout_margin维度值。

  5. 创建一个view_tv_show_item.xml布局文件,其中包含用于海报的ImageView和用于电视节目名称的TextView

  6. activity_main.xml文件中,删除 Hello World TextView,并添加一个用于电视节目列表的 RecyclerView。

  7. 创建一个名为TVShow的模型类。

  8. 创建一个名为DetailsActivity的新活动,使用activity_details.xml作为布局文件。

  9. 打开AndroidManifest.xml文件,在DetailsActivity声明中添加parentActivityName属性。

  10. activity_details.xml中,添加用于电视节目详情的视图。

  11. DetailsActivity中,添加用于显示所选电视节目详情的代码。

  12. 为电视节目列表创建一个TVShowAdapter适配器类。

  13. 创建另一个名为TVResponse的类,用于从 API 端点获取正在播出的电视节目的响应。

  14. 创建一个TelevisionService类,用于添加 Retrofit 方法。

  15. 创建一个名为TVShowRepository的类,其中包含tvService的构造函数,以及apiKeytvShows的属性。

  16. 创建一个挂起函数,从端点检索电视节目列表。

  17. 创建一个TVShowViewModel类,其中包含TVShowRepository的构造函数。添加一个getTVShows函数,返回电视节目列表的 LiveData,以及fetchTVShows函数,从存储库中获取列表。

  18. 创建一个名为TVApplication的应用程序类,其中包含TVShowRepository的属性。

  19. TVApplication设置为AndroidManifest.xml文件中应用程序的值。

  20. 打开MainActivity并添加代码,以在ViewModel更新其值时更新 RecyclerView。添加一个函数,点击列表中的电视节目将打开详情屏幕。

  21. 运行你的应用程序。该应用程序将显示一个电视节目列表。点击一个电视节目将打开详情活动,显示节目详情。主屏幕和详情屏幕将类似于以下图示:

图 13.11:电视指南应用的主屏幕和详情屏幕

图 13.11:电视指南应用的主屏幕和详情屏幕

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

总结

本章重点介绍了使用 RxJava 和协程进行后台操作。后台操作用于长时间运行的任务,例如从本地数据库或远程服务器访问数据。

您从 RxJava 的基础知识开始:可观察对象、观察者和操作符。可观察对象是提供数据的数据源。观察者监听可观察对象;当可观察对象发出数据时,观察者可以做出相应反应。操作符允许您修改可观察对象的数据,使其能够传递给观察者所需的数据。

接下来,您学习了如何使用调度程序使 RxJava 调用异步。调度程序允许您设置执行所需操作的线程。subscribeOn函数用于设置可观察对象将在哪个线程上运行,observeOn函数允许您设置下一个操作将在哪里执行。然后,您使用 RxJava 从外部 API 获取数据,并使用 RxJava 操作符对数据进行过滤、排序和修改。

接下来,你将学习使用 Kotlin 协程,这是 Google 推荐的异步编程解决方案。你可以使用suspend关键字将后台任务转换为挂起函数。协程可以使用asynclaunch关键字启动。

你已经学会了如何创建挂起函数以及如何启动协程。你还使用调度程序来改变协程运行的线程。最后,你使用协程来进行网络调用,并使用 LiveData 转换函数mapswitchMap修改检索到的数据。

在下一章中,你将学习关于架构模式。你将学习诸如MVVMModel-View-ViewModel)之类的模式,以及如何改进应用程序的架构。

第十四章:架构模式

概述

本章将介绍您可以用于 Android 项目的架构模式。它涵盖了使用MVVM(模型-视图-ViewModel)模式,添加 ViewModels 以及使用数据绑定。您还将了解使用存储库模式进行数据缓存和使用 WorkManager 进行数据检索和存储的调度。

在本章结束时,您将能够使用 MVVM 和数据绑定来构建 Android 项目。您还将能够使用 Room 库的存储库模式缓存数据和使用 WorkManager 在预定的时间间隔内获取和保存数据。

介绍

在上一章中,您了解了如何使用 RxJava 和协程进行后台操作和数据处理。现在,您将学习架构模式,以便改进您的应用程序。

在开发 Android 应用程序时,您可能倾向于在活动或片段中编写大部分代码(包括业务逻辑)。这将使您的项目难以测试和维护。随着项目的增长和变得更加复杂,困难也会增加。您可以通过架构模式改进您的项目。

架构模式是设计和开发应用程序部分的通用解决方案,特别是对于大型应用程序。有一些架构模式可以用来将项目结构化为不同的层(表示层、用户界面(UI)层和数据层)或功能(观察者/可观察者)。通过架构模式,您可以以更容易开发、测试和维护的方式组织代码。

对于 Android 开发,常用的模式包括MVC(模型-视图-控制器)、MVP(模型-视图-表示器)和 MVVM。谷歌推荐的架构模式是 MVVM,本章将对此进行讨论。您还将了解数据绑定、使用 Room 库的存储库模式以及 WorkManager。

让我们开始 MVVM 架构模式。

MVVM

MVVM 允许您将 UI 和业务逻辑分开。当您需要重新设计 UI 或更新模型/业务逻辑时,您只需触及相关组件,而不影响应用程序的其他组件。这将使您更容易添加新功能并测试现有代码。MVVM 在创建使用大量数据和视图的大型应用程序时也很有用。

使用 MVVM 架构模式,您的应用程序将分为三个组件:

  • 模型:代表数据层

  • 视图:显示数据的用户界面

  • Model提供给View

通过以下图表更好地理解 MVVM 架构模式:

图 14.1:MVVM 架构模式

图 14.1:MVVM 架构模式

模型包含应用程序的数据和业务逻辑。在 MVVM 中,用户看到并与之交互的活动、片段和布局是视图。视图只处理应用程序的外观。它们让 ViewModel 知道用户的操作(例如打开活动或点击按钮)。

ViewModel 链接视图和模型。ViewModel 从模型获取数据并将其转换为视图中的显示。视图订阅 ViewModel,并在值更改时更新 UI。

您可以使用 Jetpack 的 ViewModel 为应用程序创建 ViewModel 类。Jetpack 的 ViewModel 管理其自己的生命周期,因此您无需自行处理。

您可以通过在app/build.gradle文件的依赖项中添加以下代码来将 ViewModel 添加到您的项目中:

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

例如,如果您正在开发一个显示电影的应用程序,您可以拥有一个MovieViewModel。这个 ViewModel 将有一个函数,用于获取电影列表:

class MovieViewModel : ViewModel() {
    private val movies: MutableLiveData<List<Movie>>
    fun getMovies(): LiveData<List<Movie>> { ... }
    ...
}

在您的活动中,您可以使用ViewModelProvider创建一个 ViewModel:

class MainActivity : AppCompatActivity() {
    private val movieViewModel by lazy {
        ViewModelProvider(this).get(MovieViewModel::class.java)
    }
    ...
}

然后,你可以订阅 ViewModel 中的getMovies函数,并在电影列表发生变化时自动更新 UI 中的列表:

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    movieViewModel.getMovies().observe(this, Observer { popularMovies ->
        movieAdapter.addMovies(popularMovies)
    })
    ...
}

当 ViewModel 中的值发生变化时,视图会收到通知。你还可以使用数据绑定将视图与 ViewModel 中的数据连接起来。你将在下一节中学到更多关于数据绑定的知识。

数据绑定

数据绑定将布局中的视图与来自 ViewModel 等来源的数据进行绑定。不需要添加代码来查找布局文件中的视图,并在 ViewModel 的值改变时更新它们,数据绑定可以自动处理这些。

要在 Android 项目中使用数据绑定,你应该在app/build.gradle文件的android块中添加以下内容:

buildFeatures {
    dataBinding true
}

在布局文件中,你必须用layout标签包裹根元素。在layout标签内,你需要定义要绑定到该布局文件的数据的data元素:

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable name="movie" type="com.example.model.Movie"/>
    </data>
    <ConstraintLayout ... />
</layout>

电影布局变量代表将在布局中显示的com.example.model.Movie类。要将属性设置为数据模型中的字段,你需要使用@{}语法。例如,要将电影的标题作为TextView的文本值,你可以使用以下内容:

<TextView
    ...
    android:text="@{movie.title}"/>

你还需要更改你的活动文件。如果你的布局文件名为activity_movies.xml,数据绑定库将在项目的构建文件中生成一个名为ActivityMainBinding的绑定类。在活动中,你可以用以下内容替换setContentView(R.layout.activity_movies)这一行:

val binding: ActivityMoviesBinding = DataBindingUtil.setContentView(this,   R.layout.activity_movies)

你还可以使用绑定类的inflate方法或DataBindingUtil类:

val binding: ActivityMoviesBinding =   ActivityMoviesBinding.inflate(getLayoutInflater())

然后,你可以将movie实例绑定到布局中名为movie的布局变量中:

val movieToDisplay = ...
binding.movie = movieToDisplay

如果你将LiveData作为要绑定到布局的项目,你需要设置绑定变量的lifeCycleOwnerlifeCycleOwner指定了LiveData对象的范围。你可以使用活动作为绑定类的lifeCycleOwner

binding.lifeCycleOwner = this

有了这个,当 ViewModel 中的LiveData的值改变时,视图将自动更新为新的值。

你可以使用android:text="@{movie.title}"在 TextView 中设置电影标题。数据绑定库有默认的绑定适配器来处理android:text属性的绑定。有时,没有默认的属性可供使用。你可以创建自己的绑定适配器。例如,如果你想要为RecyclerView绑定电影列表,你可以创建一个自定义的BindingAdapter

@BindingAdapter("list")
fun bindMovies(view: RecyclerView, movies: List<Movie>?) {
    val adapter = view.adapter as MovieAdapter
    adapter.addMovies(movies ?: emptyList())
}

这将允许你为RecyclerView添加一个app:list属性,接受一个电影列表:

app:list="@{movies}"

让我们尝试在 Android 项目中实现数据绑定。

练习 14.01:在 Android 项目中使用数据绑定

在上一章中,你开发了一个使用电影数据库 API 显示热门电影的应用程序。在本章中,你将使用 MVVM 改进该应用程序。你可以使用上一章的 Popular Movies 项目,或者复制一个。在这个练习中,你将添加数据绑定,将 ViewModel 中的电影列表绑定到 UI 上:

  1. 在 Android Studio 中打开Popular Movies项目。

  2. 打开app/build.gradle文件,并在android块中添加以下内容:

buildFeatures {
    dataBinding true
}

这样就可以为你的应用启用数据绑定。

  1. app/build.gradle文件的插件块末尾添加kotlin-kapt插件:
plugins {
    ...
    id 'kotlin-kapt'
}

kotlin-kapt 插件是 Kotlin 注解处理工具,用于使用数据绑定。

  1. 创建一个名为RecyclerViewBinding的新文件,其中包含RecyclerView列表的绑定适配器:
@BindingAdapter("list")
fun bindMovies(view: RecyclerView, movies: List<Movie>?) {
    val adapter = view.adapter as MovieAdapter
    adapter.addMovies(movies ?: emptyList())
}

这将允许你为RecyclerView添加一个app:list属性,你可以将要显示的电影列表传递给它。电影列表将被设置到适配器中,更新 UI 中的RecyclerView

  1. 打开activity_main.xml文件,并将所有内容包裹在layout标签内:
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <androidx.constraintlayout.widget.ConstraintLayout
        ... >
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

有了这个,数据绑定库将能够为这个布局生成一个绑定类。

  1. layout标签内,在ConstraintLayout标签之前,添加一个带有viewModel变量的数据元素:
<data>
    <variable
        name="viewModel"
        type="com.example.popularmovies.MovieViewModel" />
</data>

这将创建一个与您的MovieViewModel类对应的viewModel布局变量。

  1. RecyclerView中,使用app:list添加要显示的列表:
app:list="@{viewModel.popularMovies}"

MovieViewModel.getPopularMoviesLiveData将作为RecyclerView的电影列表传递。

  1. 打开MainActivity。在onCreate函数中,用以下内容替换setContentView行:
val binding: ActivityMainBinding =   DataBindingUtil.setContentView(this, R.layout.activity_main)

这将设置要使用的布局文件并创建一个绑定对象。

  1. 用以下内容替换movieViewModel观察者:
binding.viewModel = movieViewModel
binding.lifecycleOwner = this

这将movieViewModel绑定到activity_main.xml文件中的viewModel布局变量。

  1. 运行应用程序。它应该像往常一样工作,显示流行电影的列表,点击其中一个将打开所选电影的详细信息:

图 14.2:按标题排序的今年热门电影的主屏幕(左)和有关所选电影的详细信息的详细屏幕(右)

图 14.2:按标题排序的今年热门电影的主屏幕(左)和有关所选电影的详细信息的详细屏幕(右)

在这个练习中,您已经在 Android 项目上使用了数据绑定。

数据绑定将视图链接到 ViewModel。ViewModel 从模型中检索数据。您可以使用 Retrofit 和 Moshi 等一些库来获取数据,您将在下一节中了解更多信息。

Retrofit 和 Moshi

连接到远程网络时,您可以使用 Retrofit。Retrofit 是一个 HTTP 客户端,可以轻松实现创建请求并从后端服务器检索响应。

您可以通过将以下代码添加到您的app/build.gradle文件的依赖项中,将 Retrofit 添加到您的项目中:

implementation 'com.squareup.retrofit2:retrofit:2.9.0'

然后,您可以使用 Moshi 将 Retrofit 的 JSON 响应转换为 Java 对象。例如,您可以将获取电影列表的 JSON 字符串响应转换为ListofMovie对象,以便在应用程序中显示和存储。

您可以通过将以下代码添加到您的app/build.gradle文件的依赖项中,将 Moshi Converter 添加到您的项目中:

implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'

在您的 Retrofit 构建器代码中,您可以调用addConverterFactory并传递MoshiConverterFactory

Retrofit.Builder()
    ...
    .addConverterFactory(MoshiConverterFactory.create())
    ...

您可以从 ViewModel 中调用数据层。为了减少其复杂性,您可以使用存储库模式来加载和缓存数据。您将在下一节中了解到这一点。

存储库模式

ViewModel 不应直接调用服务来获取和存储数据,而应将该任务委托给另一个组件,例如存储库。

使用存储库模式,您可以将处理数据层的 ViewModel 中的代码移动到一个单独的类中。这减少了 ViewModel 的复杂性,使其更易于维护和测试。存储库将管理从哪里获取和存储数据,就像使用本地数据库或网络服务获取或存储数据一样:

图 14.3:具有存储库模式的 ViewModel

图 14.3:具有存储库模式的 ViewModel

在 ViewModel 中,您可以添加一个存储库属性:

class MovieViewModel(val repository: MovieRepository): ViewModel() {
... 
}

ViewModel 将从存储库获取电影,或者它可以监听它们。它将不知道您实际从哪里获取列表。

您可以创建一个存储库接口,连接到数据源,例如以下示例:

interface MovieRepository { 
    fun getMovies(): List<Movie>
}

MovieRepository接口具有一个getMovies函数,您的存储库实现类将覆盖该函数以从数据源获取电影。您还可以拥有一个单一的存储库类,该类处理从本地数据库或远程端点获取数据:

当将本地数据库用作存储库的数据源时,您可以使用 Room 库,它可以让您更轻松地使用 SQLite 数据库,编写更少的代码,并在查询时进行编译时检查。

您可以通过将以下代码添加到app/build.gradle文件的依赖项中来将 Room 添加到您的项目中:

implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
kapt 'androidx.room:room-compiler:2.2.5'

让我们尝试向 Android 项目添加带有 Room 的存储库模式。

练习 14.02:在 Android 项目中使用带有 Room 的存储库

在上一个练习中,您已经在流行电影项目中添加了数据绑定。在这个练习中,您将使用存储库模式更新应用程序。

打开应用程序时,它会从网络获取电影列表。这需要一段时间。每次获取数据时,您都将将这些数据缓存在本地数据库中。用户下次打开应用程序时,应用程序将立即在屏幕上显示来自数据库的电影列表。您将使用 Room 进行数据缓存:

  1. 打开您在上一个练习中使用的Popular Movies项目。

  2. 打开app/build.gradle文件并添加 Room 库的依赖项:

implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
kapt 'androidx.room:room-compiler:2.2.5'
  1. 打开Movie类并为其添加一个Entity注解:
@Entity(tableName = "movies",  primaryKeys = [("id")])
data class Movie( ... )

Entity注解将为电影列表创建一个名为movies的表。它还将id设置为表的主键。

  1. 创建一个名为com.example.popularmovies.database的新包。为访问movies表创建一个MovieDao数据访问对象:
@Dao
interface MovieDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addMovies(movies: List<Movie>)
@Query("SELECT * FROM movies")
fun getMovies(): List<Movie>
}

该类包含一个用于将电影列表添加到数据库的函数,另一个用于从数据库中获取所有电影的函数。

  1. com.example.popularmovies.database包中创建一个MovieDatabase类:
@Database(entities = [Movie::class], version = 1)
abstract class MovieDatabase : RoomDatabase() {
    abstract fun movieDao(): MovieDao
    companion object {
        @Volatile
        private var instance: MovieDatabase? = null
        fun getInstance(context: Context): MovieDatabase {
            return instance ?: synchronized(this) {
                instance ?: buildDatabase(context).also {                   instance = it                     }
            }
        }
        private fun buildDatabase(context: Context): MovieDatabase {
            return Room.databaseBuilder(context,               MovieDatabase::class.java, "movie-db")
                .build()
        }
    }
}

该数据库的版本为 1,有一个名为Movie的实体和用于电影的数据访问对象。它还有一个getInstance函数来生成数据库的实例。

  1. 使用构造函数更新MovieRepository类的movieDatabase
class MovieRepository(private val movieService: MovieService,   private val movieDatabase: MovieDatabase) { ... }
  1. 更新fetchMovies函数:
suspend fun fetchMovies() {
    val movieDao: MovieDao = movieDatabase.movieDao()
    var moviesFetched = movieDao.getMovies()
    if (moviesFetched.isEmpty()) {
        try {
            val popularMovies = movieService.getPopularMovies(apiKey)
            moviesFetched = popularMovies.results
            movieDao.addMovies(moviesFetched)
        } catch (exception: Exception) {
            errorLiveData.postValue("An error occurred:               ${exception.message}")
        }
    }
    movieLiveData.postValue(moviesFetched)
}

它将从数据库中获取电影。如果尚未保存任何内容,它将从网络端点检索列表,然后保存。

  1. 打开MovieApplication并在onCreate函数中,用以下内容替换movieRepository的初始化:
val movieDatabase = MovieDatabase.getInstance(applicationContext)
movieRepository = MovieRepository(movieService, movieDatabase)
  1. 运行应用程序。它将显示流行电影的列表,单击其中一个将打开所选电影的详细信息。如果关闭移动数据或断开无线网络连接,它仍将显示电影列表,该列表现在已缓存在数据库中:

图 14.4:使用带有 Room 的流行电影应用程序的存储库

图 14.4:使用带有 Room 的流行电影应用程序的存储库

在这个练习中,您通过将数据的加载和存储移到存储库中来改进了应用程序。您还使用了 Room 来缓存数据。

存储库从数据源获取数据。如果数据库中尚未存储数据,应用程序将调用网络请求数据。这可能需要一段时间。您可以通过在预定时间预取数据来改善用户体验,这样用户下次打开应用程序时,他们将立即看到更新的内容。您可以使用我们将在下一节中讨论的 WorkManager 来实现这一点。

WorkManager

WorkManager 是一个 Jetpack 库,用于延迟执行并根据您设置的约束条件运行后台操作。它非常适合执行必须运行但可以稍后或定期运行的操作,无论应用程序是否正在运行。

您可以使用 WorkManager 定期运行任务,例如从网络获取数据并将其存储在数据库中。即使应用程序已关闭或设备重新启动,WorkManager 也会运行任务。这将使您的数据库与后端保持最新。

您可以通过将以下代码添加到app/build.gradle文件的依赖项中来将 WorkManager 添加到您的项目中:

implementation 'androidx.work:work-runtime:2.4.0'

WorkManager 可以调用存储库从本地数据库或网络服务器获取和存储数据。

让我们尝试向 Android 项目添加 WorkManager。

练习 14.03:向 Android 项目添加 WorkManager

在上一个练习中,您使用 Room 添加了存储库模式以将数据缓存到本地数据库中。该应用现在可以从数据库中获取数据,而不是从网络获取。现在,您将添加 WorkManager 以安排定期从服务器获取数据并将其保存到数据库的任务:

  1. 打开您在上一个练习中使用的Popular Movies项目。

  2. 打开app/build.gradle文件并添加 WorkManager 库的依赖项:

implementation 'androidx.work:work-runtime:2.4.0'

这将允许您向应用程序添加 WorkManager 工作程序。

  1. 打开MovieRepository并添加一个挂起函数,用于使用 The Movie Database 的 apiKey 从网络获取电影并将其保存到数据库中:
suspend fun fetchMoviesFromNetwork() {
    val movieDao: MovieDao = movieDatabase.movieDao()
    try {
        val popularMovies = movieService.getPopularMovies(apiKey)
        val moviesFetched = popularMovies.results
        movieDao.addMovies(moviesFetched)
    } catch (exception: Exception) {
        errorLiveData.postValue("An error occurred:           ${exception.message}")
    }
}

这将是Worker类调用的函数,该类将运行以获取和保存电影。

  1. 创建MovieWorker类:
class MovieWorker(private val context: Context,   params: WorkerParameters) : Worker(context, params) {
    override fun doWork(): Result {
        val movieRepository =           (context as MovieApplication).movieRepository
        CoroutineScope(Dispatchers.IO).launch {
            movieRepository.fetchMoviesFromNetwork()
        }
        return Result.success()
    }
}
  1. 打开MovieApplication,并在onCreate函数的末尾,安排MovieWorker以检索并保存电影:
override fun onCreate() {
    ...
    val constraints =
        Constraints.Builder().setRequiredNetworkType(          NetworkType.CONNECTED).build()
    val workRequest = PeriodicWorkRequest
        .Builder(MovieWorker::class.java, 1, TimeUnit.HOURS)
        .setConstraints(constraints)
        .addTag("movie-work")
        .build()
    WorkManager.getInstance(applicationContext).enqueue(workRequest)
}

当设备连接到网络时,这将安排MovieWorker每小时运行。MovieWorker将从网络获取电影列表并将其保存到本地数据库。

  1. 运行应用程序。关闭它并确保设备已连接到互联网。一个多小时后,再次打开应用程序并检查显示的电影列表是否已更新。如果没有,请几个小时后再试一次。即使应用程序已关闭,显示的电影列表也会定期更新,大约每小时更新一次。

图 14.5:Popular Movies 应用程序使用 WorkManager 更新其列表

图 14.5:Popular Movies 应用程序使用 WorkManager 更新其列表

在本练习中,您向应用程序添加了 WorkManager,以自动使用从网络检索的电影列表更新数据库。

活动 14.01:重新审视电视指南应用程序

在上一章中,您开发了一个可以显示正在播出的电视节目列表的应用程序。该应用程序有两个屏幕:主屏幕和详细信息屏幕。在主屏幕上,有一个电视节目列表。单击电视节目时,将显示详细信息屏幕,并显示所选节目的详细信息。

运行应用程序时,显示节目列表需要一段时间。更新应用程序以缓存列表,以便在打开应用程序时立即显示。此外,通过使用 MVVM 与数据绑定并添加 WorkManager 来改进应用程序。

您可以使用上一章中使用的电视指南应用程序,也可以从 GitHub 存储库中下载。以下步骤将帮助您完成此活动:

  1. 在 Android Studio 中打开电视指南应用程序。打开app/build.gradle文件并添加kotlin-kapt插件,数据绑定依赖项以及 Room 和 WorkManager 的依赖项。

  2. RecyclerView创建一个绑定适配器类。

  3. activity_main.xml中,将所有内容包装在layout标签内。

  4. layout标签内并在ConstraintLayout标签之前,添加一个包含 ViewModel 变量的数据元素。

  5. RecyclerView中,使用app:list添加要显示的列表。

  6. MainActivity中,用DataBindingUtil.setContentView函数替换setContentView行。

  7. 用数据绑定代码替换TVShowViewModel中的观察者。

  8. TVShow类中添加一个Entity注解。

  9. 创建一个TVDao数据访问对象,用于访问电视节目表。

  10. 创建一个TVDatabase类。

  11. 使用tvDatabase构造函数更新TVShowRepository

  12. 更新fetchTVShows函数以从本地数据库获取电视节目。如果还没有数据,从端点检索列表并将其保存在数据库中。

  13. 创建TVShowWorker类。

  14. 打开TVApplication文件。在onCreate中,安排TVShowWorker以检索并保存节目。

  15. 运行你的应用程序。应用程序将显示一个电视节目列表。点击电视节目将打开显示电影详情的详情活动。主屏幕和详情屏幕将类似于图 14.6

图 14.6:TV Guide 应用的主屏幕和详情屏幕

图 14.6:TV Guide 应用的主屏幕和详情屏幕

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

总结

本章重点介绍了 Android 的架构模式。您从 MVVM 架构模式开始。您学习了它的三个组件:模型、视图和视图模型。您还使用数据绑定将视图与视图模型链接起来。

接下来,您了解了存储库模式如何用于缓存数据。然后,您了解了 WorkManager 以及如何安排任务,例如从网络检索数据并将数据保存到数据库以更新本地数据。

在下一章中,您将学习如何使用动画来改善应用程序的外观和设计。您将使用CoordinatorLayoutMotionLayout为您的应用程序添加动画和过渡效果。

第十五章:使用 CoordinatorLayout 和 MotionLayout 进行动画和过渡

概述

本章将向您介绍动画以及如何处理布局之间的变化。它涵盖了使用MotionLayout和 Android 中的 Motion Editor 描述移动对象的说明,以及对约束集进行详细解释。本章还涵盖了修改路径和为帧的运动添加关键帧。

通过本章结束时,您将能够使用CoordinatorLayoutMotionLayout创建动画,并使用 Android Studio 中的 Motion Editor 创建MotionLayout动画。

介绍

在上一章中,您了解了 MVVM 等架构模式。您现在知道如何改进应用程序的架构。接下来,我们将学习如何使用动画来增强我们应用程序的外观和感觉,并使其与其他应用程序不同且更好。

有时,我们开发的应用程序可能看起来有点单调。我们可以在应用程序中包含一些移动部分和令人愉悦的动画,使其更加生动,并使 UI 和用户体验更好。例如,我们可以添加视觉提示,以便用户不会困惑下一步该做什么,并可以引导他们可以采取哪些步骤。在加载时进行动画可以在内容被获取或处理时娱乐用户。当应用程序遇到错误时进行漂亮的动画可以帮助防止用户对发生的事情感到愤怒,并可以告知他们有哪些选项。

在本章中,我们将首先看一些在 Android 中进行动画的传统方法。我们将在本章结束时看一下较新的MotionLayout选项。让我们从活动过渡开始,这是最简单和最常用的动画之一。

活动过渡

在打开和关闭活动时,Android 会播放默认过渡。我们可以自定义活动过渡以反映品牌和/或区分我们的应用程序。活动过渡从 Android 5.0 Lollipop(API 级别 21)开始提供。

活动过渡有两部分:进入过渡和退出过渡。进入过渡定义了当活动打开时活动及其视图将如何进行动画。而退出过渡则描述了当活动关闭或打开新活动时活动和视图如何进行动画。Android 支持以下内置过渡:

  • Explode:这会将视图从中心移入或移出。

  • Fade:这会使视图缓慢出现或消失。

  • Slide:这会将视图从边缘移入或移出。

现在,让我们看看如何在下一节中添加活动过渡。有两种方法可以添加活动过渡:通过 XML 和通过代码。首先,我们将学习如何通过 XML 添加过渡,然后通过代码。

通过 XML 添加活动过渡

您可以通过 XML 添加活动过渡。第一步是启用窗口内容过渡。这是通过在themes.xml中添加活动的主题来完成的,如下所示:

<item name="android:windowActivityTransitions">true</item>

之后,您可以使用android:windowEnterTransitionandroid:windowExitTransition样式属性添加进入和退出过渡。例如,如果您想要使用来自@android:transition/的默认过渡,您需要添加的属性如下:

<item name="android:windowEnterTransition">  @android:transition/slide_left</item>
<item name="android:windowExitTransition">  @android:@transition/explode</item>

然后,您的themes.xml文件将如下所示:

    <style name="AppTheme"       parent="Theme.AppCompat.Light.DarkActionBar">
        ...
        <item name="android:windowActivityTransitions">true</item>
        <item name="android:windowEnterTransition">          @android:@transition/slide_left</item>
        <item name="android:windowExitTransition">          @android:@transition/explode</item>
    </style>

活动过渡通过<item name="android:windowActivityTransitions">true</item>启用。<item name="android:windowEnterTransition">@android:transition/slide_left</item>属性设置了进入过渡,而@android:@transition/explode是退出过渡文件,由<item name="android:windowExitTransition">@android:transition/explode</item>属性设置。

在下一节中,您将学习如何通过编码添加活动过渡。

通过代码添加活动过渡

活动转换也可以以编程方式添加。第一步是启用窗口内容转换。您可以在调用setContentView()之前在活动中调用以下函数来实现这一点:

window.requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)

您可以随后使用window.enterTransitionwindow.exitTransition添加进入和退出事务。我们可以使用android.transition包中内置的Explode()Slide()Fade()转换。例如,如果我们想要使用Explode()作为进入转换和Slide()作为退出转换,我们可以添加以下代码:

window.enterTransition = Explode()
window.exitTransition = Slide()

如果您的应用程序的最低支持的 SDK 低于 21,请记得将这些调用包装在Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP的检查中。

现在您知道如何通过代码或 XML 添加进入和退出活动转换,您需要学习如何在打开活动时激活转换。我们将在下一节中进行。

使用 Activity 转换启动 Activity

一旦您向活动添加了活动转换(通过 XML 或编码),您可以在打开活动时激活转换。您应该传递一个带有转换动画的 bundle,而不是startActivity(intent)调用。为此,请使用以下代码启动您的活动:

startActivity(intent,ActivityOptions   .makeSceneTransitionAnimation(this).toBundle())

ActivityOptions.makeSceneTransitionAnimation(this).toBundle()参数将创建一个带有我们为活动指定的进入和退出转换的 bundle(通过 XML 或代码)。

通过向应用程序添加活动转换来尝试我们到目前为止所学到的内容。

练习 15.01:在应用程序中创建活动转换

在许多场所,留下小费(通常称为小费)是很常见的。这是为了表示对服务的感激而给出的一笔钱,例如给餐厅的服务员。小费是在最终账单上标明的基本费用之外提供的。

在本章中,我们将使用一个应用程序,该应用程序计算应该给出的小费金额。这个值将基于账单金额(基本费用)和用户想要给出的额外百分比。用户将输入这两个值,应用程序将计算小费金额。

在这个练习中,我们将自定义输入和输出屏幕之间的活动转换:

  1. 在 Android Studio 中创建一个新项目。

  2. 选择您的项目对话框中,选择空活动,然后单击下一步

  3. 配置您的项目对话框中,如图 15.1所示,将项目命名为Tip Calculator,并将包名称设置为com.example.tipcalculator图 15.1:配置您的项目对话框

图 15.1:配置您的项目对话框

  1. 设置要保存项目的位置。选择API 21:Android 5.0 Lollipop作为最低 SDK,然后单击完成按钮。这将创建一个默认的MainActivity和一个布局文件activity_main.xml

  2. MaterialComponents依赖项添加到您的app/build.gradle文件中:

implementation 'com.google.android.material:material:1.2.1'

我们需要这样做才能使用TextInputLayoutTextInputEditText来输入文本字段。

  1. 打开themes.xml文件,并确保活动的主题使用MaterialComponents的主题。参见以下示例:
<style name="AppTheme"   parent="Theme.MaterialComponents.Light.DarkActionBar">

我们需要这样做,因为我们稍后将使用的TextInputLayoutTextInputEditText需要您的活动使用MaterialComponents主题。

  1. 打开activity_main.xml。删除Hello World TextView并添加金额的输入文本字段:
<com.google.android.material.textfield.TextInputLayout
    android:id="@+id/amount_text_layout"
    style="@style/Widget.MaterialComponents       .TextInputLayout.OutlinedBox"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginStart="16dp"
    android:layout_marginTop="100dp"
    android:layout_marginEnd="16dp"
    android:layout_marginBottom="16dp"
    android:alpha="1"
    android:hint="Amount"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent">
    <com.google.android.material.textfield       .TextInputEditText
        android:id="@+id/amount_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="numberDecimal"
        android:textSize="18sp" />
</com.google.android.material.textfield.TextInputLayout>
  1. 在金额文本字段下方添加另一个小费百分比的输入文本字段:
<com.google.android.material.textfield.TextInputLayout
    android:id="@+id/percent_text_layout"
    style="@style/Widget.MaterialComponents       .TextInputLayout.OutlinedBox"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="16dp"
    android:alpha="1"
    android:hint="Tip Percent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf       ="@id/amount_text_layout">
    <com.google.android.material.textfield       .TextInputEditText
        android:id="@+id/percent_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="numberDecimal"
        android:textSize="18sp" />
</com.google.android.material.textfield.TextInputLayout>
  1. 最后,在小费百分比文本字段底部添加一个计算按钮:
<Button
    android:id="@+id/compute_button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="36dp"
    android:text="Compute"
    app:layout_constraintEnd_toEndOf       ="@+id/percent_text_layout"
    app:layout_constraintTop_toBottomOf       ="@+id/percent_text_layout" />
  1. 创建另一个活动。转到文件菜单,单击新建 | 活动 | 空活动。将其命名为OutputActivity。确保选中生成布局文件,以便创建activity_output

  2. 打开MainActivity。在onCreate函数的末尾,添加以下代码:

        val amountText: EditText =           findViewById(R.id.amount_text)
        val percentText: EditText =           findViewById(R.id.percent_text)
        val computeButton: Button =           findViewById(R.id.compute_button)
        computeButon.setOnClickListener {
            val amount =
                if (amountText.text.toString().isNotBlank())                   amountText.text.toString() else "0"
            val percent =
                if (percentText.text.toString().isNotBlank())                   percentText.text.toString() else "0"
            val intent = Intent(this,               OutputActivity::class.java).apply {
                putExtra("amount", amount)
                putExtra("percent", percent)
            }
            startActivity(intent)
        }

这将为Compute按钮添加一个ClickListener组件,这样当点击时,系统将打开OutputActivity并将金额和百分比值作为意图额外传递。

  1. 打开activity_output.xml并添加一个用于显示小费的TextView
   <TextView
        android:id="@+id/tip_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        style="@style/TextAppearance.AppCompat.Headline"
        tools:text="The tip is " />
  1. 打开OutputActivity。在onCreate函数的末尾,添加以下代码:
        val amount = intent?.getStringExtra("amount")          ?.toBigDecimal() ?: BigDecimal.ZERO
        val percent = intent?.getStringExtra("percent")          ?.toBigDecimal() ?: BigDecimal.ZERO
        val tip = amount * (percent.divide("100"          .toBigDecimal()))
        val tipText: TextView = findViewById(R.id.tip_text)
        tipText.text = "The tip is $tip"

这将根据输入金额和百分比计算并显示小费。

  1. 运行应用程序。点击Compute按钮,注意打开OutputActivity和返回时发生的情况。在关闭MainActivity和打开/关闭OutputActivity时,会有默认动画。

  2. 现在,让我们开始添加过渡动画。打开themes.xml并使用windowActivityTransitionswindowEnterTransitionwindowExitTransition样式属性更新活动主题:

        <item name="android:windowActivityTransitions">          true</item>
        <item name="android:windowEnterTransition">          @android:transition/explode</item>
        <item name="android:windowExitTransition">          @android:transition/slide_left</item>

这将启用活动过渡,添加一个爆炸进入过渡,并向活动添加一个向左滑动退出过渡。

  1. 返回MainActivity文件,并用以下内容替换startActivity(intent)
startActivity(intent, ActivityOptions   .makeSceneTransitionAnimation(this).toBundle())

这将使用我们在上一步中设置的 XML 文件中指定的过渡动画打开OutputActivity

  1. 运行应用程序。您会看到打开和关闭MainActivityOutputActivity时的动画已经改变。当 Android UI 打开OutputActivity时,您会注意到文本向中心移动。在关闭时,视图向左滑动:

图 15.2:应用程序屏幕:输入屏幕(左侧)和输出屏幕(右侧)

图 15.2:应用程序屏幕:输入屏幕(左侧)和输出屏幕(右侧)

我们已经为应用程序添加了活动过渡。当我们打开一个新的活动时,新活动的进入过渡将播放。当活动被关闭时,将播放其退出过渡。

有时,当我们从一个活动打开另一个活动时,两个活动中存在一个共同的元素。在下一节中,我们将学习如何添加这个共享元素过渡。

添加共享元素过渡

有时,一个应用程序从一个活动转移到另一个活动,两个活动中都存在一个共同的元素。我们可以为这个共享元素添加动画,以突出向用户展示两个活动之间的链接。

例如,在一个电影应用程序中,一个包含电影列表(带有缩略图图像)的活动可以打开一个新的活动,显示所选电影的详细信息,并在顶部显示全尺寸图像。为图像添加共享元素过渡将把列表活动上的缩略图与详细信息活动上的图像链接起来。

共享元素过渡有两部分:进入过渡和退出过渡。这些过渡可以通过 XML 或代码完成。

第一步是启用窗口内容过渡。您可以通过将活动的主题添加到themes.xml中来实现:

<item name="android:windowContentTransitions">true</item>

您还可以通过在调用setContentView()之前在活动中调用以下函数来以编程方式执行此操作:

window.requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)

android:windowContentTransitions属性的值为truewindow.requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)将启用窗口内容过渡。

之后,您可以添加共享元素进入过渡和共享元素退出过渡。如果您的res/transitions目录中有enter_transition.xmlexit_transition.xml,您可以通过添加以下样式属性来添加共享元素进入过渡:

<item name="android:windowSharedElementEnterTransition">  @transition/enter_transition</item>

您也可以通过以下代码以编程方式完成这一操作:

val enterTransition = TransitionInflater.from(this)  .inflateTransition(R.transition.enter_transition)
window.sharedElementEnterTransition = enterTransition

windowSharedElementEnterTransition属性和window.sharedElementEnterTransition将把我们的进入过渡设置为enter_transition.xml文件。

要添加共享元素退出过渡,可以添加以下样式属性:

<item name="android:windowSharedElementExitTransition">  @transition/exit_transition</item>

这可以通过以下代码以编程方式完成:

val exitTransition = TransitionInflater.from(this)  .inflateTransition(R.transition.exit_transition)
window.sharedElementExitTransition = exitTransition

windowSharedElementExitTransition属性和window.sharedElementExitTransition将把我们的退出过渡设置为exit_transition.xml文件。

您已经学会了如何添加共享元素过渡。在下一节中,我们将学习如何开始具有共享元素过渡的活动。

使用共享元素过渡开始活动

一旦您向活动添加了共享元素过渡(无论是通过 XML 还是通过编程方式),您可以在打开活动时激活过渡。在这之前,添加一个transitionName属性。将其值设置为两个活动中共享元素的相同文本。

例如,在ImageView中,我们可以为transitionName属性添加一个transition_name值:

    <ImageView
        ...
        android:transitionName="transition_name"
        android:id="@+id/sharedImage"
    ... />

要开始具有共享元素的活动,我们将传递一个带有过渡动画的 bundle。为此,请使用以下代码启动您的活动:

startActivity(intent, ActivityOptions   .makeSceneTransitionAnimation(this, sharedImage,     "transition_name").toBundle());

ActivityOptions.makeSceneTransitionAnimation(this, sharedImage, "transition_name").toBundle()参数将创建一个带有共享元素(sharedImage)和过渡名称(transition_name)的 bundle。

如果有多个共享元素,您可以传递Pair<View, String>的可变参数,其中View和过渡名称String。例如,如果我们将视图的按钮和图像作为共享元素,我们可以这样做:

val buttonPair: Pair<View, String> = Pair(button, "button") 
val imagePair: Pair<View, String> = Pair(image, "image") 
val activityOptions = ActivityOptions   .makeSceneTransitionAnimation(this, buttonPair, imagePair)
startActivity(intent, activityOptions.toBundle())

注意

请记住导入android.util.Pair而不是kotlin.Pair,因为makeSceneTransitionAnimation需要来自 Android SDK 的 pair。

让我们尝试一下到目前为止学到的内容,通过向Tip Calculator应用程序添加共享元素过渡。

练习 15.02:创建共享元素过渡

在第一个练习中,我们为MainActivityOutputActivity自定义了活动过渡。在这个练习中,我们将向两个活动添加一个图像。当从输入屏幕移动到输出屏幕时,将对此共享元素进行动画处理。我们将使用应用程序启动器图标(res/mipmap/ic_launcher)作为ImageView。您可以更改您的图标,而不是使用默认的:

  1. 打开我们在Exercise 15.01中开发的Tip Calculator项目,创建活动过渡。

  2. 转到activity_main.xml文件,并在金额文本字段顶部添加一个ImageView

    <ImageView
        android:id="@+id/image"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="100dp"
        android:src="img/ic_launcher"
        android:transitionName="transition_name"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

transitionName值为transition_name将用于标识此为共享元素。

  1. 通过更改app:layout_constraintTop_toTopOf="parent"来更改amount_text_layout TextInputLayout的顶部约束为以下内容:
app:layout_constraintTop_toBottomOf="@id/image"

这将使金额TextInputLayout类移动到图像下方。

  1. 现在,打开activity_output.xml文件,并在tip TextView上方添加一个图像,高度和宽度为 200dp,scaleTypefitXY以适应图像到ImageView的尺寸。
    <ImageView
        android:id="@+id/image"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_marginBottom="40dp"
        android:src="img/ic_launcher"
        android:scaleType="fitXY"
        android:transitionName="transition_name"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintBottom_toTopOf="@id/tip_text" />

transitionName值为transition_nameMainActivity中的ImageView的值相同。

  1. 打开MainActivity并将startActivity代码更改为以下内容:
val image: ImageView = findViewById(R.id.image)
startActivity(intent, ActivityOptions   .makeSceneTransitionAnimation(this, image,     "transition_name").toBundle())

这将从MainActivity中的 ID 为 image 的ImageView开始一个过渡,到OutputActivity中另一个具有transitionName值也为transition_name的图像。

  1. 运行应用程序。提供金额和百分比,然后点击Compute按钮。您会看到输入活动中的图像似乎放大并定位到OutputActivity中:

图 15.3:应用程序屏幕:输入屏幕(左侧)和输出屏幕(右侧)

图 15.3:应用程序屏幕:输入屏幕(左侧)和输出屏幕(右侧)

我们已经学会了如何添加活动过渡和共享元素过渡。现在,让我们来看看如何在布局中对视图进行动画处理。如果内部有多个元素,要对每个元素进行动画处理可能会很困难。CoordinatorLayout可用于简化此动画。我们将在下一节中讨论这个问题。

使用 CoordinatorLayout 进行动画

CoordinatorLayout是一个处理其子视图之间动作的布局。当您将CoordinatorLayout用作父视图组时,可以轻松地对其中的视图进行动画处理。您可以通过在app/build.gradle文件的依赖项中添加以下内容将CoordinatorLayout添加到您的项目中:

implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'

这将允许我们在布局文件中使用CoordinatorLayout

假设我们有一个布局文件,其中包含CoordinatorLayout内的浮动操作按钮。当点击浮动操作按钮时,UI 会显示一个Snackbar消息。

注意

Snackbar是一个 Android 小部件,可以在屏幕底部向用户提供简短的消息。

如果您使用的是除CoordinatorLayout之外的任何布局,则带有消息的 Snackbar 将呈现在浮动操作按钮的顶部。如果我们将CoordinatorLayout用作父视图组,布局将向上推动浮动操作按钮,将 Snackbar 显示在其下方,并在 Snackbar 消失时将其移回。图 15.4显示了布局如何调整以防止 Snackbar 位于浮动操作按钮的顶部:

图 15.4:左侧截图显示了 Snackbar 显示之前和之后的 UI。右侧的截图显示了 Snackbar 可见时的 UI

图 15.4:左侧截图显示了 Snackbar 显示之前和之后的 UI。右侧的截图显示了 Snackbar 可见时的 UI

浮动操作按钮移动并为 Snackbar 消息提供空间,因为它具有名为FloatingActionButton.Behavior的默认行为,这是CoordinatorLayout.Behavior的子类。FloatingActionButton.Behavior在显示 Snackbar 时移动浮动操作按钮,以便 Snackbar 不会覆盖浮动操作按钮。

并非所有视图都具有CoordinatorLayout行为。要实现自定义行为,可以通过扩展CoordinatorLayout.Behavior来开始。然后,您可以使用layout_behavior属性将其附加到视图上。例如,如果我们在com.example.behavior包中为按钮创建了CustomBehavior,我们可以在布局中使用以下内容更新按钮:

...
<Button
    ...
    app:layout_behavior="com.example.behavior.CustomBehavior">
    .../>

我们已经学会了如何使用CoordinatorLayout创建动画和过渡。在下一节中,我们将研究另一个布局MotionLayout,它允许开发人员更多地控制动作。

使用 MotionLayout 创建动画

在 Android 中创建动画有时是耗时的。即使是创建简单的动画,您也需要处理 XML 和代码文件。更复杂的动画和过渡需要更多的时间来制作。

为了帮助开发人员轻松制作动画,Google 创建了MotionLayoutMotionLayout是通过 XML 创建动作和动画的新方法。它从 API 级别 14(Android 4.0)开始提供。

使用MotionLayout,我们可以对一个或多个视图的位置、宽度/高度、可见性、透明度、颜色、旋转、高程和其他属性进行动画处理。通常,其中一些属性很难通过代码实现,但MotionLayout允许我们使用声明性 XML 轻松调整它们,以便我们可以更多地专注于我们的应用程序。

让我们开始通过将MotionLayout添加到我们的应用程序中。

添加 MotionLayout

要将MotionLayout添加到您的项目中,您只需要添加 ConstraintLayout 2.0 的依赖项。ConstraintLayout 2.0 是 ConstraintLayout 的新版本,增加了包括MotionLayout在内的新功能。在您的 app/build.gradle文件的依赖项中添加以下内容:

implementation 'androidx.constraintlayout:constraintlayout:2.0.4'

这将向您的应用程序添加最新版本的 ConstraintLayout(在撰写本文时为 2.0.4)。对于本书,我们将使用 AndroidX 版本。如果您尚未更新项目,请考虑从支持库更新到 AndroidX。

添加依赖项后,我们现在可以使用MotionLayout来创建动画。我们将在下一节中进行这样的操作。

使用 MotionLayout 创建动画

MotionLayout是我们好朋友 ConstraintLayout 的一个子类。要使用MotionLayout创建动画,请打开要添加动画的布局文件。将根 ConstraintLayout 容器替换为androidx.constraintlayout.motion.widget.MotionLayout

动画本身不会在布局文件中,而是在另一个名为motion_scene的 XML 文件中。motion_scene将指定MotionLayout如何对其中的视图进行动画。motion_scene文件应放置在res/xml目录中。布局文件将使用根视图组中的app:layoutDescription属性链接到这个motion_scene文件。您的布局文件应该类似于以下内容:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout
    ...
    app:layoutDescription="@xml/motion_scene">
    ...
</androidx.constraintlayout.motion.widget.MotionLayout>

要使用MotionLayout创建动画,我们必须有视图的初始状态和最终状态。MotionLayout将自动在两者之间进行过渡动画。您可以在同一个motion_scene文件中指定这两个状态。如果布局中有很多视图,您还可以使用两个不同的布局来表示动画的开始和结束状态。

motion_scene文件的根容器是motion_scene。这是我们为MotionLayout添加约束和动画的地方。它包含以下内容:

  • ConstraintSet:指定要进行动画的视图/布局的开始和结束位置和样式。

  • Transition:指定要在视图上执行的动画的开始、结束、持续时间和其他详细信息。

让我们尝试通过将其添加到我们的Tip Calculator应用程序中,使用MotionLayout添加动画。

练习 15.03:使用 MotionLayout 添加动画

在这个练习中,我们将使用MotionLayout动画更新我们的Tip Calculator应用程序。在输出屏幕上,点击图像将向下移动,并在再次点击时返回到原始位置:

  1. 在 Android Studio 4.0 或更高版本中打开Tip Calculator项目。

  2. 打开app/build.gradle文件,并用以下内容替换ConstraintLayout的依赖项:

implementation 'androidx   .constraintlayout:constraintlayout:2.0.4'

有了这个,我们就可以在我们的布局文件中使用MotionLayout了。

  1. 打开activity_output.xml文件,并将根ConstraintLayout标记更改为MotionLayout。将androidx.constraintlayout.widget.ConstraintLayout更改为以下内容:
androidx.constraintlayout.motion.widget.MotionLayout
  1. app:layoutDescription="@xml/motion_scene"添加到MotionLayout标记中。IDE 将警告您该文件尚不存在。暂时忽略,因为我们将在下一步中添加它。您的文件应该类似于这样:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/motion_scene"
    tools:context=".OutputActivity">
    ...
</androidx.constraintlayout.motion.widget.MotionLayout>
  1. res/xml目录中创建一个motion_scene.xml文件。这将是我们的motion_scene文件,其中将定义动画配置。使用motion_scene作为文件的根元素。

  2. 通过在motion_scene文件中添加以下内容来添加起始的Constraint元素:

   <ConstraintSet android:id="@+id/start_constraint">
        <Constraint
            android:id="@id/image"
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:layout_marginBottom="40dp"
            app:layout_constraintBottom_toTopOf="@id/tip_text"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />
    </ConstraintSet>

这是图像在当前位置的样子(约束在屏幕顶部)。

  1. 接下来,在motion_scene文件中添加结束的Constraint元素,方法如下:
    <ConstraintSet android:id="@+id/end_constraint">
        <Constraint
            android:id="@id/image"
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:layout_marginBottom="40dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />
    </ConstraintSet>

在结束动画时,ImageView将位于屏幕底部。

  1. 现在让我们为ImageView添加过渡效果:
    <Transition
        app:constraintSetEnd="@id/end_constraint"
        app:constraintSetStart="@id/start_constraint"
        app:duration="2000">
        <OnClick
            app:clickAction="toggle"
            app:targetId="@id/image" />
    </Transition>

在这里,我们正在指定开始和结束的约束条件,将在 2,000 毫秒(2 秒)内进行动画。我们还在ImageView上添加了一个OnClick事件。切换将使视图从开始到结束进行动画,如果视图已经处于结束状态,它将动画返回到开始状态。

  1. 您完成的motion_scene.xml文件应如下所示:
<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android   ="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <ConstraintSet android:id="@+id/start_constraint">
        <Constraint
            android:id="@id/image"
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:layout_marginBottom="40dp"
            app:layout_constraintBottom_toTopOf="@id/tip_text"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />
    </ConstraintSet>
    <ConstraintSet android:id="@+id/end_constraint">
        <Constraint
            android:id="@id/image"
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:layout_marginBottom="40dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />
    </ConstraintSet>
    <Transition
        app:constraintSetEnd="@id/end_constraint"
        app:constraintSetStart="@id/start_constraint"
        app:duration="2000">
        <OnClick
            app:clickAction="toggle"
            app:targetId="@id/image" />
    </Transition>
</MotionScene>
  1. 运行应用程序并点击ImageView。它将在大约 2 秒内直线向下移动。再次点击它,它将在 2 秒内向上移动。图 15.5显示了此动画的开始和结束:

图 15.5:起始动画(左)和结束动画(右)

图 15.5:起始动画(左)和结束动画(右)

在这个练习中,我们通过指定开始约束、结束约束和持续时间以及OnClick事件,在MotionLayout中对ImageView进行了动画处理。MotionLayout会自动播放动画,从开始位置到结束位置(对我们来说,看起来就像在轻按时自动上下移动)。

我们已经使用MotionLayout创建了动画。在下一节中,我们将使用 Android Studio 的 Motion Editor 来创建MotionLayout动画。

Motion Editor

从 4.0 版本开始,Android Studio 包括了 Motion Editor。Motion Editor 可以帮助开发人员使用MotionLayout创建动画。这使得开发人员更容易创建和预览过渡和其他动作,而不是手工操作并运行来查看更改。编辑器还会自动生成相应的文件。

您可以通过右键单击预览并单击Convert to MotionLayout来将 ConstraintLayout 转换为 MotionLayout。Android Studio 会进行转换,还会为您创建动作场景文件。

Design视图中查看具有MotionLayout作为根的布局文件时,Motion Editor UI 将包含在Design视图中,如图 15.6所示:

图 15.6:Android Studio 4.0 中的 Motion Editor

图 15.6:Android Studio 4.0 中的 Motion Editor

在右上窗口(Overview面板)中,您可以看到MotionLayout的可视化以及开始和结束约束。过渡显示为从开始的箭头。靠近开始约束的点显示了过渡的点击操作。图 15.7显示了选择了start_constraintOverview面板:

图 15.7:Motion Editor 的概述面板中选择了 start_constraint

图 15.7:选择了 start_constraint 的 Motion Editor 的概述面板

右下窗口是Selection面板,显示了在Overview面板中选择的约束集或MotionLayout中的视图。当选择过渡箭头时,它还可以显示过渡。图 15.8显示了选择start_constraint时的Selection面板:

图 15.8:Motion Editor 的选择面板显示了 start_constraint 的 ConstraintSet

图 15.8:Motion Editor 的选择面板显示了 start_constraint 的 ConstraintSet

当您在Overview面板的左侧点击MotionLayout时,下方的Selection面板将显示视图及其约束,如图 15.9所示:

图 15.9:选择 MotionLayout 时的概述和选择面板

图 15.9:选择 MotionLayout 时的概述和选择面板

当您点击start_constraintend_constraint时,左侧的预览窗口将显示开始或结束状态的外观。Selection面板还会显示视图及其约束。看一下图 15.10,看看选择start_constraint时的外观:

图 15.10:选择了 start_constraint 时 Motion Editor 的外观

图 15.10:选择了 start_constraint 时 Motion Editor 的外观

图 15.11显示了如果选择end_constraint,Motion Editor 会是什么样子:

图 15.11:选择 end_constraint 时 Motion Editor 的外观

图 15.11:选择 end_constraint 时 Motion Editor 的外观

连接start_constraintend_constraint的箭头代表了MotionLayout的过渡。在Selection面板上,有播放或转到第一个/最后一个状态的控件。您还可以将箭头拖动到特定位置。图 15.12显示了动画中间的外观(50%):

图 15.12:动画中间的过渡

图 15.12:动画中间的过渡

在开发带有MotionLayout的动画时,最好能够调试动画以确保我们做得正确。我们将在下一节讨论如何做到这一点。

调试 MotionLayout

为了帮助您在运行应用程序之前可视化MotionLayout动画,您可以在 Motion Editor 中显示运动路径和动画的进度。运动路径是要动画的对象从起始状态到结束状态所采取的直线路线。

显示路径和/或进度动画,我们可以向MotionLayout容器添加motionDebug属性。我们可以使用以下值来设置motionDebug

  • SHOW_PATH:仅显示运动路径。

  • SHOW_PROGRESS:仅显示动画进度。

  • SHOW_ALL:显示动画的路径和进度。

  • NO_DEBUG:隐藏所有动画。

要显示MotionLayout路径和进度,我们可以使用以下内容:

<androidx.constraintlayout.motion.widget.MotionLayout
    ...
    app:motionDebug="SHOW_ALL"
    ...>

SHOW_ALL值将显示动画的路径和进度。图 15.13显示了当我们使用SHOW_PATHSHOW_PROGRESS时的效果:

图 15.13:使用 SHOW_PATH(左)显示动画路径,而 SHOW_PROGRESS(右)显示动画进度

图 15.13:使用 SHOW_PATH(左)显示动画路径,而 SHOW_PROGRESS(右)显示动画进度

虽然motionDebug听起来像是只在调试模式下出现的东西,但它也会出现在发布版本中,因此在准备应用程序发布时应将其删除。

MotionLayout动画期间,起始约束将过渡到结束约束,即使有一个或多个元素可以阻挡运动中的对象。我们将在下一节讨论如何避免这种情况发生。

修改 MotionLayout 路径

MotionLayout动画中,UI 将从起始约束播放动作到结束约束,即使中间有元素可以阻挡我们移动的视图。例如,如果MotionLayout涉及从屏幕顶部到底部移动的文本,然后反之,我们在中间添加一个按钮,按钮将覆盖移动的文本。

图 15.14显示了OK按钮如何挡住了动画中间的移动文本:

图 15.14:OK 按钮挡住了文本动画的中间部分

图 15.14:OK 按钮挡住了文本动画的中间部分

MotionLayout以直线路径播放动画从起始到结束约束,并根据指定的属性调整视图。我们可以在起始和结束约束之间添加关键帧来调整动画路径和/或视图属性。例如,在动画期间,除了改变移动文本的位置以避开按钮之外,我们还可以改变文本或其他视图的属性。

关键帧可以作为motion_scene的过渡属性的子级添加到KeyFrameSet中。我们可以使用以下关键帧:

  • KeyPosition:指定动画过程中特定点上视图的位置以调整路径。

  • KeyAttribute:指定动画过程中特定点上视图的属性。

  • KeyCycle:在动画期间添加振荡。

  • KeyTimeCycle:这允许循环由时间驱动而不是动画进度。

  • KeyTrigger:添加一个可以根据动画进度触发事件的元素。

我们将重点放在KeyPositionKeyAttribute上,因为KeyCycleKeyTimeCycleKeyTrigger是更高级的关键帧,并且仍然可能会发生变化。

KeyPosition允许我们在MotionLayout动画中更改视图的位置。它具有以下属性:

  • motionTarget:指定由关键帧控制的对象。

  • framePosition:从 1 到 99 编号,指定位置将在动作变化时的百分比。例如,25 表示动画的四分之一处,50 表示动画的中间点。

  • percentX:指定路径的x值将被修改多少。

  • percentY:指定路径的y值将被修改多少。

  • keyPositionType:指定KeyPosition如何修改路径。

keyPositionType属性可以具有以下值:

  • parentRelativepercentXpercentY是基于视图的父级指定的。

  • pathRelativepercentXpercentY是基于从开始约束到结束约束的直线路径指定的。

  • deltaRelativepercentXpercentY是基于视图位置指定的。

例如,如果我们想要在动画的正中间(50%)修改text_view ID 的TextView的路径,通过将其相对于TextView的父容器在xy方向上移动 10%,我们将在motion_scene中有以下关键位置:

<KeyPosition
    app:motionTarget="@+id/text_view"
    app:framePosition="50"
    app:keyPositionType="parentRelative"
    app:percentY="0.1"
    app:percentX="0.1"
/>

同时,KeyAttribute允许我们在MotionLayout动画进行时更改视图的属性。我们可以更改的一些视图属性包括visibilityalphaelevationrotationscaletranslation。它具有以下属性:

  • motionTarget:指定由关键帧控制的对象。

  • framePosition:从 1 到 99 编号,指定应用视图属性的动作百分比。例如,20 表示动画的五分之一处,75 表示动画的四分之三处。

让我们尝试向Tip Calculator应用程序添加关键帧。在ImageView的动画过程中,它会覆盖显示小费的文本。我们将使用关键帧来解决这个问题。

练习 15.04:使用关键帧修改动画路径

在上一个练习中,我们动画化了图像在被点击时向下移动(或者当它已经在底部时向上移动)。当图像处于中间位置时,它会覆盖小费TextView。我们将通过在 Android Studio 的 Motion Editor 中向motion_scene添加KeyFrame来解决这个问题:

  1. 使用 Android Studio 4.0 或更高版本打开Tip Calculator应用程序。

  2. res/layout目录中打开activity_output.xml文件。

  3. 切换到Design视图。

  4. app:motionDebug="SHOW_ALL"添加到MotionLayout容器中。这将允许我们在 Android Studio 和设备/模拟器上看到路径和进度信息。您的MotionLayout容器将如下所示:

<androidx.constraintlayout.motion.widget.MotionLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/motion_scene"
    app:motionDebug="SHOW_ALL"
        tools:context=".OutputActivity"> 
  1. 运行应用程序并进行计算。在输出屏幕上,点击图像。观察动画进行时的小费文本。您会注意到在动画的中间,图像会覆盖文本,如图 15.15所示:图 15.15:图像遮挡显示小费的 TextView

图 15.15:图像遮挡显示小费的 TextView

  1. 返回 Android Studio 中的activity_output.xml文件。确保它在Design视图中打开。

  2. 在右上角的Overview面板中,单击连接start_constraintend_constraint的箭头。在Selection面板中将下箭头拖到中间(50%),如图 15.16所示:图 15.16:选择表示过渡的箭头开始和结束约束

图 15.16:选择表示开始和结束约束之间过渡的箭头

  1. 点击Selection面板中Transition右侧的Create KeyFrames图标(带有绿色+符号)。参考图 15.17查看图标:图 15.17:创建关键帧图标

图 15.17:创建关键帧图标

  1. 选择KeyPosition。我们将使用KeyPosition来调整文本位置,避免按钮。

  2. 选择ID,选择image,并将输入位置设置为50TypeparentRelativePercentX1.5,如图 15.18所示。这将在过渡的中间(50%)为图像添加一个KeyPosition属性,相对于父视图的x轴为 1.5 倍:图 15.18:提供要进行的关键位置的输入

图 15.18:提供要进行的关键位置的输入

  1. 点击Add按钮。你会在Design预览中看到,如图 15.19所示,运动路径不再是一条直线。在位置 50(动画的中间),文本将不再被ImageView覆盖。ImageView将位于TextView的右侧:图 15.19:路径现在将是曲线而不是直线。过渡面板还将添加一个新的项目

图 15.19:路径现在将是曲线而不是直线。过渡面板还将添加一个新的KeyPosition项目

  1. 点击播放图标查看动画效果。在设备或模拟器上运行应用程序进行验证。你会看到动画现在向右弯曲,而不是沿着以前的直线路径,如图 15.20所示:图 15.20:动画现在避开了带有提示的 TextView

图 15.20:动画现在避开了带有提示的 TextView

  1. Motion Editor 将自动生成KeyPosition的代码。如果你转到motion_scene.xml文件,你会看到 Motion Editor 在过渡属性中添加了以下代码:
<KeyFrameSet>
    <KeyPosition
        app:framePosition="50"
        app:keyPositionType="parentRelative"
        app:motionTarget="@+id/image"
        app:percentX="1.5" />
</KeyFrameSet>

在过渡期间的关键帧中添加了KeyPosition属性。在动画的 50%处,图像的x位置将相对于其父视图移动 1.5 倍。这允许图像在动画过程中避开其他元素。

在这个练习中,你已经添加了一个关键位置,它将调整MotionLayout动画,使其不会阻塞或被路径中的其他视图阻塞。

让我们通过做一个活动来测试你学到的一切。

活动 15.01:密码生成器

使用强密码来保护我们的在线账户是很重要的。它必须是独一无二的,必须包括大写和小写字母,数字和特殊字符。在这个活动中,你将开发一个可以生成强密码的应用程序。

该应用程序将有两个屏幕:输入屏幕和输出屏幕。在输入屏幕上,用户可以提供密码的长度,并指定它是否必须包含大写或小写字母,数字或特殊字符。输出屏幕将显示三个可能的密码,当用户选择一个时,其他密码将移开,并显示一个按钮将密码复制到剪贴板。你应该自定义从输入到输出屏幕的转换。

完成的步骤如下:

  1. 在 Android Studio 4.0 或更高版本中创建一个名为Password Generator的新项目。设置它的包名和Minimum SDK

  2. MaterialComponents依赖项添加到你的app/build.gradle文件中。

  3. 更新ConstraintLayout的依赖关系。

  4. 确保活动的主题在themes.xml文件中使用了MaterialComponents的主题。

  5. activity_main.xml文件中,删除Hello WorldTextView,并添加密码长度的输入文本字段。

  6. 为大写字母、数字和特殊字符添加复选框代码。

  7. 在复选框底部添加一个Generate按钮。

  8. 创建另一个名为OutputActivity的活动。

  9. 自定义从输入屏幕(MainActivity)到OutputActivity的活动转换。打开themes.xml并使用windowActivityTransitionswindowEnterTransitionwindowExitTransition样式属性更新活动主题。

  10. 更新MainActivityonCreate函数的结尾。

  11. 更新activity_output.xml文件中androidx.constraintlayout.widget.ConstraintLayout的代码。

  12. MotionLayout标签中添加app:layoutDescription="@xml/motion_scene"app:motionDebug="SHOW_ALL"

  13. 在输出活动中为生成的三个密码添加三个TextView实例。

  14. 在屏幕底部添加一个“复制”按钮。

  15. OutputActivity中添加generatePassword函数。

  16. 添加代码根据用户输入生成三个密码,并为用户添加一个ClickListener组件,以便将所选密码复制到剪贴板。

  17. OutputActivity中,为每个密码TextView创建一个动画。

  18. 为默认视图创建ConstraintSet

  19. 当选择第一个、第二个和第三个密码时,添加ConstraintSet

  20. 当选择每个密码时,添加Transition

  21. 通过转到“运行”菜单并点击“运行应用”菜单项来运行应用程序。

  22. 输入一个长度,选择大写字母、数字和特殊字符,然后点击“生成”按钮。将显示三个密码。

  23. 选择一个密码,其他密码将移出视图。还会显示一个“复制”按钮。点击它,检查你选择的密码是否现在在剪贴板上。输出屏幕的初始状态和最终状态将类似于图 15.21

图 15.21:密码生成器应用中 MotionLayout 的起始和结束状态

图 15.21:密码生成器应用中 MotionLayout 的起始和结束状态

注意

此活动的解决方案可在以下网址找到:http://packt.live/3sKj1cp

总结

本章介绍了如何使用CoordinatorLayoutMotionLayout创建动画和过渡。动画可以提高应用的可用性,并使其与其他应用脱颖而出。

我们首先定制了打开和关闭活动时的过渡,使用了活动过渡。我们还了解了当一个活动和它打开的活动都包含相同的元素时,如何添加共享元素过渡,以便我们可以向用户突出显示这些共享元素之间的链接。

我们学习了如何使用CoordinatorLayout来处理其子视图的运动。一些视图具有内置的行为,用于处理它们在CoordinatorLayout中的工作方式。您也可以为其他视图添加自定义行为。然后,我们开始使用MotionLayout来创建动画,通过指定起始约束、结束约束和它们之间的过渡。我们还研究了通过在动画中间添加关键帧来修改运动路径。我们了解了关键帧,比如KeyPosition,它可以改变视图的位置,以及KeyAttribute,它可以改变视图的样式。我们还研究了在 Android Studio 中使用 Motion Editor 来简化动画的创建和预览以及修改路径。

在下一章中,我们将学习关于 Google Play 商店。我们将讨论如何创建帐户并准备您的应用发布,以及如何发布供用户下载和使用。

第十六章:在 Google Play 上发布您的应用程序

概述

本章将向您介绍 Google Play 控制台、发布渠道和整个发布流程。它涵盖了创建 Google Play 开发者帐户、为我们开发的应用程序设置商店条目以及创建密钥库(包括密码的重要性和文件存储位置)。我们还将了解应用程序包和 APK,以及如何生成应用程序的 APK 或 AAB 文件。在本章的后面部分,我们将设置发布路径、开放测试版和封闭测试版,最后我们将把我们的应用上传到商店并在设备上下载。

通过本章结束时,您将能够创建自己的 Google Play 开发者帐户,为发布准备已签名的 APK 或应用程序包,并在 Google Play 上发布您的第一个应用程序。

介绍

在上一章中,您学会了如何使用CoordinatorLayoutMotionLayout添加动画和过渡。现在,您已经准备好开发和发布 Android 应用程序。

开发 Android 应用程序后,它们只能在您的设备和模拟器上使用。您必须使它们对所有人都可用,以便他们可以下载。反过来,您将获得用户,并且可以从他们那里赚钱。Android 应用程序的官方市场是 Google Play。通过 Google Play,您发布的应用程序和游戏可以在全球超过 20 亿活跃的 Android 设备上使用。

在本章中,我们将学习如何在 Google Play 上发布您的应用程序。我们将从为发布准备应用程序和创建 Google Play 开发者帐户开始。然后,我们将继续上传您的应用程序并管理应用程序发布。

让我们开始准备在 Google Play 上发布您的应用程序。

为发布准备您的应用程序

在 Google Play 上发布应用程序之前,您必须确保它使用了发布密钥进行签名,并且具有正确的版本信息。否则,您将无法发布新应用程序或更新已发布的应用程序。

让我们从为您的应用程序添加版本开始。

应用程序版本

您的应用程序版本之所以重要,有以下几个原因:

  • 用户可以看到他们已下载的版本。在检查是否有更新或报告应用程序的已知问题时,他们可以使用这个信息。

  • 设备和 Google Play 使用版本值来确定应用程序是否可以或应该更新。

  • 开发人员还可以使用这个值在特定版本中添加功能支持。他们还可以警告或强制用户升级到最新版本,以获得有关错误或安全问题的重要修复。

Android 应用程序有两个版本:versionCodeversionName。现在,versionCode是一个整数,由开发人员、Google Play 和 Android 系统使用,而versionName是一个字符串,用户在 Google Play 页面上看到的。

应用程序的初始发布可以将versionCode值设为1,每次新发布都应该增加这个值。

versionName可以采用x.y格式(其中x是主要版本,y是次要版本)。您还可以使用语义版本控制,如x.y.z,通过添加z来添加补丁版本。要了解更多关于语义版本控制的信息,请参阅semver.org

在模块的build.gradle文件中,在 Android Studio 中创建新项目时,versionCodeversionName会自动生成。它们位于android块下的defaultConfig块中。一个示例build.gradle文件显示了这些值:

android {
    compileSdkVersion 29
    defaultConfig {
        applicationId "com.example.app"
        minSdkVersion 16
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        ...
    }
    ...
}

在发布更新时,要发布的新包必须具有更高的versionCode值,因为用户无法降级其应用程序,只能下载新版本。

在确保应用程序版本正确之后,发布流程的下一步是获取一个密钥库来对应用程序进行签名。这将在下一节中讨论。

创建密钥库

Android 应用程序在运行时会自动使用调试密钥进行签名。但是,在将应用程序发布到 Google Play 商店之前,必须使用发布密钥对应用程序进行签名。为此,您必须拥有一个密钥库。如果您还没有,可以在 Android Studio 中创建一个。

练习 16.01:在 Android Studio 中创建密钥库

在这个练习中,我们将使用 Android Studio 创建一个密钥库,用于签署 Android 应用程序。按照以下步骤完成这个练习:

  1. 在 Android Studio 中打开一个项目。

  2. 转到“构建”菜单,然后单击“生成已签名的捆绑包或 APK…”:图 16.1:生成已签名的捆绑包或 APK 对话框

图 16.1:生成已签名的捆绑包或 APK 对话框

  1. 确保选择了APK或“Android 应用程序捆绑包”,然后点击“下一步”按钮。在这里,您可以选择现有的密钥库或创建一个新的:图 16.2:选择 APK 后点击“下一步”按钮后的生成已签名的捆绑包或 APK 对话框并点击“下一步”按钮

图 16.2:选择 APK 并点击“下一步”按钮后的生成已签名的捆绑包或 APK 对话框

  1. 点击“创建新…”按钮。然后将出现“新密钥库”对话框:图 16.3:新密钥库对话框

图 16.3:新密钥库对话框

  1. 在“密钥库路径”字段中,选择要保存密钥库文件的位置。您可以点击右侧的文件夹图标选择文件夹并输入文件名。该值将类似于users/packt/downloads/keystore.keystore

  2. 在“密码”和“确认”字段中提供密码。

  3. 在“密钥”下的证书部分,输入名字、组织单位、组织、城市/地点、州/省和国家代码。这些信息中只有一个是必需的,但最好提供所有信息。

  4. 点击“确定”按钮。如果没有错误,密钥库将在您提供的路径中创建,并且您将回到“生成已签名的捆绑包或 APK”对话框,以便继续生成 APK 或应用程序捆绑包。如果您只想创建一个密钥库,可以关闭对话框。

在这个练习中,您已经创建了自己的密钥库,可以用来签署可以发布到 Google Play 的应用程序。

如果您更喜欢使用命令行生成密钥库,也可以这样做。keytool命令在Java 开发工具包JDK)中可用。命令如下:

keytool -genkey -v -keystore my-key.jks -keyalg RSA -keysize 
  2048 -validity 9125 -alias key-alias

此命令在当前工作目录中创建一个 2,048 位的 RSA 密钥库,文件名为my-key.jks,别名为key-alias;有效期为 9,125 天(25 年)。命令行将提示您输入密钥库密码,然后提示您再次确认。然后,它将要求您依次输入名字、组织单位、组织名称、城市或地点、州或省和国家代码。这些信息中只有一个是必需的;如果要留空,可以按“Enter”键。不过,最好提供所有信息。

在国家代码提示之后,将要求您验证提供的输入。您可以输入 yes 进行确认。然后将要求您提供密钥别名的密码。如果要与密钥库密码相同,可以按“Enter”。然后将生成密钥库。

现在您已经有了一个用于签署应用程序的密钥库,需要知道如何保护它。您将在下一节中了解到这一点。

存储密钥库和密码

您需要将密钥库和密码存放在安全的地方,因为如果您丢失了密钥库和/或其凭据,将无法再发布应用程序更新。如果黑客也能够访问这些内容,他们可能会未经您的同意更新您的应用程序。

您可以将密钥库存储在 CI/构建服务器或安全服务器中。

保留凭据有点棘手,因为在以后签署应用更新时,您将需要它们。您可以通过将此信息包含在项目的build.gradle文件中来实现这一点。

android块中,您可以有signingConfigs,它引用密钥库文件、其密码以及密钥的别名和密码:

android {
    ...
    signingConfigs {
        release {
            storeFile file("keystore-file")
            storePassword "keystore-password"
            keyAlias "key-alias"
            keyPassword "key-password"
        }
    }
    ...
}

在项目的build.gradle文件中的buildTypes的发布块下,您可以在signingConfigs块中指定发布配置:

buildTypes {
        release {
            ...
            signingConfig signingConfigs.release
        }
        ...
}

将签名配置存储在build.gradle文件中并不安全,因为可以访问项目或存储库的人可能会 compromise the app。

您可以将这些凭据存储在环境变量中,以使其更安全。通过这种方法,即使恶意人士获得了对您代码的访问权限,应用更新仍将是安全的,因为签名配置并未存储在您的代码中,而是存储在系统中。环境变量是在 IDE 或项目之外设置的键值对,例如在您自己的计算机上或在构建服务器上。

要在 Gradle 中使用环境变量进行密钥库配置,您可以为存储文件路径、存储密码、密钥别名和密钥密码创建环境变量。例如,您可以使用KEYSTORE_FILEKEYSTORE_PASSWORDKEY_ALIASKEY_PASSWORD环境变量。

在 Mac 和 Linux 上,您可以使用以下命令设置环境变量:

export KEYSTORE_PASSWORD=securepassword

如果您使用 Windows,可以这样做:

set KEYSTORE_PASSWORD=securepassword

这个命令将创建一个名为KEYSTORE_PASSWORD的环境变量,其值为securepassword。在应用的build.gradle文件中,您可以使用环境变量中的值:

storeFile System.getenv("KEYSTORE_FILE")
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")

您的密钥库将用于为发布签署您的应用,以便您可以在 Google Play 上发布它。我们将在下一节中讨论这个问题。

为发布签署您的应用

当您在模拟器或实际设备上运行应用时,Android Studio 会自动使用调试密钥库对其进行签名。要在 Google Play 上发布应用,您必须使用您自己的密钥对 APK 或应用捆绑包进行签名,使用您在 Android Studio 中或通过命令行创建的密钥库。

如果您已经在build.gradle文件中为发布版本添加了签名配置,您可以通过在Build Variants窗口中选择发布版本构建自动构建已签名的 APK 或应用捆绑包。然后,您需要转到Build菜单,单击Build Bundle(s)项目,然后选择Build APK(s)Build Bundle(s)。APK 或应用捆绑包将生成在项目的app/build/output目录中。

练习 16.02:创建已签名的 APK

在这个练习中,我们将使用 Android Studio 为 Android 项目创建一个已签名的 APK:

  1. 在 Android Studio 中打开一个项目。

  2. 转到Build菜单,然后单击生成已签名的 Bundle 或 APK…菜单项:图 16.4:生成签名的 Bundle 或 APK 对话框

图 16.4:生成签名的 Bundle 或 APK 对话框

  1. 选择APK,然后单击下一步按钮:图 16.5:单击下一步按钮后的生成签名的 Bundle 或 APK 对话框

图 16.5:单击下一步按钮后的生成签名的 Bundle 或 APK 对话框。

  1. 选择您在练习 16.01中创建的密钥库,在 Android Studio 中创建密钥库

  2. Key store password字段中提供密码。

  3. Key alias字段中,单击右侧的图标并选择密钥别名。

  4. Key password字段中提供别名密码。

  5. 单击下一步按钮。

  6. 选择生成已签名 APK 的目标文件夹。

  7. Build Variants字段中,确保选择了release变体:图 16.6:在生成签名的 Bundle 或 APK 对话框中选择发布版本

图 16.6:在生成签名的 Bundle 或 APK 对话框中选择发布版本

  1. 对于签名版本,请选择 V1 和 V2。 “V2(完整 APK 签名)”是一种整个文件方案,可以增加应用程序的安全性并使其安装速度更快。 这仅适用于 Android 7.0 Nougat 及更高版本。 如果您的目标低于此版本,还应使用“V1(Jar 签名)”,这是签署 APK 的旧方式,但安全性不及 v2。

  2. 单击“完成”按钮。 Android Studio 将构建已签名的 APK。 IDE 通知将弹出,通知您已生成已签名的 APK。 您可以单击“定位”以转到已签名 APK 文件所在的目录:

图 16.7:成功生成已签名 APK 的弹出通知

图 16.7:成功生成已签名 APK 的弹出通知

在本练习中,您已经制作了一个已签名的 APK,现在可以在 Google Play 上发布。 在下一节中,您将了解 Android 应用捆绑包,这是一种发布应用的新方式。

Android 应用捆绑包

发布 Android 应用的传统方式是通过 APK 或应用程序包。 当用户安装您的应用时,将下载此 APK 文件到他们的设备上。 这是一个包含所有设备配置的字符串、图像和其他资源的大文件。

随着您支持更多的设备类型和更多的国家/地区,此 APK 文件的大小将增长。 用户下载的 APK 将包含实际上对其设备不是必需的内容。 对于存储空间较小的用户,这将是一个问题,因为他们可能没有足够的空间来安装您的应用。 有昂贵数据计划或较慢的互联网连接的用户可能会避免下载太大的应用。 他们可能还会卸载您的应用以节省存储空间。

一些开发人员一直在构建和发布多个 APK 以避免这些问题。 但是,这是一个复杂且低效的解决方案,特别是当您针对不同的屏幕密度、CPU 架构和语言时。 这将是每个发布版本维护太多 APK 文件。

Android 应用捆绑包是发布应用的新方式。 您只需生成一个单个的应用程序捆绑包文件(使用 Android Studio 3.2 及更高版本),然后将其上传到 Google Play。 Google Play 将自动生成基本 APK 文件以及每个设备配置、CPU 架构和语言的 APK 文件。 当用户安装您的应用时,他们只会下载其设备所需的 APK 文件。 与通用 APK 相比,这将更小。

这将适用于 Android 5.0 棒棒糖及更高版本的设备; 对于低于此版本的设备,将仅生成设备配置和 CPU 架构的 APK 文件。 所有语言和其他资源将包含在每个 APK 文件中。

练习 16.03:创建已签名的应用程序捆绑包

在本练习中,我们将使用 Android Studio 为 Android 项目创建一个已签名的应用程序捆绑包:

  1. 在 Android Studio 中打开一个项目。

  2. 转到“构建”菜单,然后单击“生成签名捆绑包或 APK…”菜单项:图 16.8:生成签名捆绑包或 APK 对话框

图 16.8:生成签名捆绑包或 APK 对话框

  1. 选择“Android 应用捆绑包”,然后单击“下一步”按钮:图 16.9:单击“下一步”按钮后生成签名捆绑包或 APK 对话框

图 16.9:单击“下一步”按钮后生成签名捆绑包或 APK 对话框

  1. 选择您在练习 16.01中创建的密钥库,在 Android Studio 中创建密钥库

  2. 在“密钥库密码”字段中提供密码。

  3. 在“密钥别名”字段中,单击右侧的图标并选择密钥别名。

  4. 在“密钥密码”字段中提供别名密码。

  5. 单击“下一步”按钮。

  6. 选择生成已签名应用程序捆绑包的目标文件夹。

  7. 在“构建变体”字段中,确保选择了“发布”变体:图 16.10:在生成签名捆绑包或 APK 对话框中选择发布版本

图 16.10:在生成已签名的应用程序包或 APK 对话框中选择发布版本

  1. 单击完成按钮。Android Studio 将构建已签名的应用程序包。IDE 通知将弹出,通知您已生成已签名的应用程序包。您可以单击定位以转到已签名的应用程序包文件所在的目录:

图 16.11:弹出通知,已生成已签名的应用程序包

图 16.11:弹出通知,已生成已签名的应用程序包

在这个练习中,您已经制作了一个已签名的应用程序包,现在可以在 Google Play 上发布。

要能够以 Android 应用程序包格式发布您的应用程序,您需要选择通过 Google Play 进行应用签名。我们将在下一节讨论 Google Play 应用签名。

Google Play 应用签名

Google Play 提供了一项名为应用签名的服务,允许 Google 管理和保护您的应用签名密钥,并为用户自动重新签名您的应用。

使用 Google Play 应用签名,您可以让 Google 生成签名密钥,也可以上传您自己的签名密钥。您还可以为了额外的安全性创建不同的上传密钥。您可以使用上传密钥对应用程序进行签名,并在 Play 控制台上发布应用程序。Google 将检查上传密钥,删除它,并使用应用签名密钥重新签名应用程序以分发给用户。当应用签名对应用程序启用时,可以重置上传密钥。如果您丢失了上传密钥或认为它已经被泄露,您可以简单地联系 Google Play 开发者支持,验证您的身份,并获得新的上传密钥。

在发布新应用程序时,选择应用签名是很容易的。在 Google Play 控制台(play.google.com/console)中,您可以转到发布管理 | 应用发布部分,并在让 Google 管理和保护您的应用签名密钥部分选择继续。您最初用于签署应用的密钥将成为上传密钥,Google Play 将生成新的应用签名密钥。

您还可以将现有的应用程序转换为使用应用签名。这在 Google Play 控制台中应用程序的发布 | 设置 | 应用签名部分中可用。您需要上传现有的应用签名密钥并生成新的上传密钥。

一旦您加入 Google Play 应用签名,您将无法再退出。此外,如果您使用第三方服务,您需要使用应用签名密钥的证书。这在发布管理 | 应用签名中可用。

应用签名还使您能够上传应用程序包,Google Play 将自动签名并生成用户在安装您的应用程序时将下载的 APK 文件。

在下一节中,您将创建一个 Google Play 开发者帐户,以便您可以将应用程序的已签名 APK 或应用程序包发布到 Google Play。

创建开发者帐户

要在 Google Play 上发布应用程序,您需要采取的第一步是创建一个 Google Play 开发者帐户。前往play.google.com/console/signup并使用您的 Google 账户登录。如果您还没有 Google 账户,您应该首先创建一个。

建议使用长期计划使用的 Google 账户,而不是一次性账户。阅读开发者分发协议并同意服务条款。

注意

如果您的目标是销售付费应用程序或向您的应用程序/游戏添加应用内产品,您还必须创建一个商户账户。不幸的是,这并非所有国家都可以使用。我们在这里不会涉及这个问题,但您可以在注册页面或support.google.com/googleplay/android-developer/answer/150324上阅读更多信息。

您需要支付 25 美元的注册费用来创建您的 Google Play 开发者帐户(这是一次性付款)。该费用必须使用有效的借记卡/信用卡支付,但某些预付/虚拟信用卡也可以使用。您可以根据位置/国家使用的方式有所不同。

最后一步是完成帐户详细信息,如开发者名称、电子邮件地址、网站和电话号码。这些信息也可以稍后更新,将形成显示在您应用程序商店列表上的开发者信息。

完成注册后,您将收到一封确认电子邮件。您的付款可能需要几个小时(最多 48 小时)才能处理并注册您的帐户,所以请耐心等待。理想情况下,即使您的应用程序还没有准备好,也应提前进行此操作,以便一旦准备好发布,您就可以轻松地发布该应用程序。

当您收到来自 Google 的确认电子邮件时,您可以开始将应用程序和游戏发布到 Google Play。

在接下来的部分中,我们将讨论将应用程序上传到 Google Play。

上传应用程序到 Google Play

一旦您准备好发布应用程序并拥有 Google Play 开发者帐户,您可以转到 Google Play 控制台(play.google.com/console)发布应用程序。

要上传应用程序,请转到 Play 控制台,单击“所有应用程序”,然后单击“创建应用程序”。提供应用程序的名称和默认语言。在应用程序或游戏部分,设置它是应用程序还是游戏。同样,在免费或付费部分,设置它是免费还是付费。创建您的商店列表,准备应用程序发布,并推出发布。我们将在本节中详细介绍这些步骤。

创建商店列表

商店列表是用户在打开您的应用程序在 Google Play 上的页面时首先看到的内容。如果应用程序已经发布,您可以转到“增长”,然后选择“商店出现”,然后选择“主商店列表”。

应用详细信息

您将被导航到“应用详细信息”页面。在“应用详细信息”页面上,您需要填写以下字段:

  • 应用名称:您的应用程序名称(最多 50 个字符)。

  • 简短描述:总结您的应用程序的简短文本(最多 80 个字符)。

  • 完整描述:您应用程序的长描述。限制为 4,000 个字符,因此您可以在此处添加大量相关信息,例如其功能和用户需要了解的内容。

注意

对于产品详细信息,您可以根据您将发布应用程序的语言/国家添加本地化版本。

您的应用程序标题和描述不得包含受版权保护的材料和垃圾邮件,因为这可能会导致您的应用程序被拒绝。

图形资产

在此部分提供以下详细信息:

  • 图标(512 x 512 的高分辨率图标)。

  • 特色图形(1,024 x 500):

  • 应用程序的 2-8 张屏幕截图。如果您的应用程序支持其他形式因素(平板电脑、电视或 Wear OS),您还应该为每种形式因素添加屏幕截图:

您还可以添加促销图形和促销视频,如果有的话。

如果您使用违反 Google Play 政策的图形,您的应用可能会被拒绝,因此请确保您使用的图像是您自己的,并且不包含受版权保护或不适当的内容。

准备发布

在准备发布之前,请确保您的构建已使用签名密钥签名。如果您要发布应用程序更新,请确保它与 Play 上当前版本代码更高的相同包名称、相同密钥签名。

您还必须确保遵循开发者政策(以避免任何违规行为),并确保您的应用程序符合应用程序质量指南。更多信息列在发布检查表上,您可以在support.google.com/googleplay/android-developer/上查看。

APK/应用程序包

您可以上传 APK(Android 包)或更新的格式:Android 应用程序包。转到“发布”,然后转到“应用发布”。这将显示每个跟踪中活动和草稿发布的摘要。

有不同的跟踪可以发布应用程序:

  • 生产

  • 开放测试

  • 封闭测试

  • 内部测试

我们将在本章的“管理应用发布”部分详细讨论发布跟踪。

选择要创建发布的跟踪。对于生产跟踪,您可以在左侧选择“管理”。对于其他跟踪,请先单击“测试”,然后选择跟踪。要在封闭测试跟踪上发布,您还必须选择“管理跟踪”,然后通过单击“创建跟踪”来创建新的跟踪。

完成后,您可以在页面右上角单击“创建新发布”。在“要添加的 Android 应用程序包和 APK”部分,您可以上传您的 APK 或应用程序包。

确保应用程序包或 APK 文件由您的发布签名密钥签名。如果没有正确签名,Google Play 控制台将不接受它。如果您要发布更新,则应用程序包或 APK 的版本代码必须高于现有版本。

您还可以添加发布名称和发布说明。发布名称是开发人员用来跟踪发布的,不会对用户可见。默认情况下,上传的 APK 或应用程序包的版本名称将设置为发布名称。发布说明形成了将显示在 Play 页面上的文本,并将通知用户应用程序的更新内容。

发布说明的文本必须添加在语言标签内。例如,默认的美国英语语言的开放和闭合标签分别为。如果您的应用支持多种语言,则默认情况下每种语言标签都将显示在发布说明字段中。然后,您可以为每种语言添加发布说明。

如果您已经发布了应用程序,可以通过单击“从以前的发布复制”按钮并从列表中进行选择来复制以前发布的发布说明并重用或修改它们。

单击“保存”按钮后,发布将被保存,您可以随后返回。单击“审核发布”按钮将带您到屏幕,您可以在其中审核和发布发布。

发布发布

如果您准备发布您的发布,请转到 Play 控制台并选择您的应用。转到“发布”并选择您的发布跟踪。单击发布选项卡,然后单击发布旁边的“编辑”按钮:

图 16.12:生产跟踪上的草稿发布

图 16.12:生产跟踪上的草稿发布

您可以查看 APK 或应用程序包、发布名称和发布说明。单击“审核发布”按钮开始发布。Play 控制台将打开“审核和发布”屏幕。在这里,您可以查看发布信息,并检查是否有警告和错误。

如果您正在更新应用程序,还可以在创建另一个发布时选择发布百分比。将其设置为 100%表示所有用户都可以下载。当您将其设置为较低的百分比,例如 50%,则发布将对一半现有用户可用。

如果您对发布有信心,可以在页面底部选择“开始发布到生产”按钮。发布应用后,需要一段时间(新应用可能需要 7 天或更长时间)进行审核。您可以在 Google Play 控制台的右上角看到状态。这些状态包括以下内容:

  • 待发布(您的新应用正在审核中)

  • 已发布(您的应用现在可以在 Google Play 上使用)

  • 拒绝(您的应用因违反政策而未发布)

  • 暂停(您的应用违反了 Google Play 政策并被暂停)

如果您的应用程序存在问题,您可以解决问题并重新提交应用程序。您的应用程序可能因侵犯版权、冒充和垃圾邮件等原因而被拒绝。

应用程序发布后,用户现在可以下载它。新应用程序或应用程序更新在 Google Play 上变为可用之前可能需要一些时间。如果您试图在 Google Play 上搜索您的应用程序,可能无法搜索到。确保将其发布到生产或公开轨道上。

管理应用发布

您可以在不同轨道上逐步发布应用程序,以便在向公众推出之前对其进行测试。您还可以进行定时发布,使应用程序在特定日期可用,而不是在获得 Google 批准后自动发布。

发布轨道

在为应用程序创建发布时,您可以在四个不同的轨道之间进行选择:

  • 生产是每个人都可以看到应用程序的地方。

  • 公开测试是针对更广泛的公开测试。发布将在 Google Play 上可用,任何人都可以加入测试计划并进行测试。

  • 封闭测试是为测试预发布版本的小群用户而设计的。

  • 内部测试是用于开发/测试应用程序时的开发者/测试人员构建。

内部、封闭和公开轨道允许开发人员创建特殊发布,并允许真实用户下载,而其他用户则使用生产版本。这将使您能够快速了解发布是否存在错误,并在将其推出给所有人之前快速修复。这些轨道上的用户反馈也不会影响您应用程序的公共评论/评分。

理想的方式是在开发和内部测试期间首先在内部轨道上发布。当预发布版本准备就绪时,您可以为一小群受信任的人/用户/测试人员创建封闭测试。然后,您可以创建一个公开测试,让其他用户在完全发布之前尝试您的应用程序。

要进入每个轨道并管理发布,您可以转到 Google Play 控制台的“发布”部分,然后选择“生产”或“测试”,然后选择公开、封闭或内部轨道。

反馈渠道和选择加入链接

在内部、封闭和公开轨道上,有一个“反馈 URL 或电子邮件地址”和“测试人员如何加入您的测试”的部分。您可以在“反馈 URL 或电子邮件地址”中提供一个电子邮件地址或网站,测试人员可以在加入测试计划时发送反馈。当他们选择加入您的测试计划时,这将显示出来。

在“测试人员加入您的测试”部分,您可以复制链接与测试人员分享。然后他们可以使用此链接加入测试程序。

内部测试

此轨道用于开发/测试应用程序时的构建。在此处发布的版本将很快在 Google Play 上供内部测试人员使用。在“测试人员”选项卡中,有一个测试人员部分。您可以选择现有列表或创建新列表。内部测试最多可有 100 名测试人员。

封闭测试

在“测试人员”选项卡中,您可以为测试人员选择电子邮件列表或 Google Groups。如果选择电子邮件列表,请选择测试人员列表或创建新列表。封闭测试最多可有 2,000 名测试人员:

如果您选择 Google Groups,您可以提供 Google Group 的电子邮件地址(例如,the-alpha-group@googlegroups.com),该组的所有成员将成为测试人员:

公开测试

在“测试人员”选项卡中,您可以为测试人员设置“无限”或“有限数量”。有限测试的最小测试人员数量为 1,000 人:

在公开、封闭和内部轨道中,您可以添加用户作为您应用程序的测试人员。您将在下一节学习如何添加测试人员。

分阶段发布

在推出应用程序更新时,您可以首先将其发布给一小部分用户。如果发布存在问题,您可以停止发布或发布另一个更新来修复问题。如果没有问题,您可以逐渐增加发布百分比。这被称为分阶段发布

如果您已向少于 100%的用户发布了更新,您可以转到 Play 控制台,选择“发布”,单击轨道,然后选择“发布”选项卡。在您想要更新的发布下方,您可以看到“管理推出”下拉菜单。它将有更新或停止推出的选项。

您可以选择“管理推出”,然后选择“更新推出”以增加发布的推出百分比。将出现一个对话框,您可以在其中输入推出百分比。您可以单击“更新”按钮以更新百分比。

100%的推出将使发布对所有用户可用。低于该百分比意味着发布只对该百分比的用户可用。

如果在分阶段推出期间发现了重大错误或崩溃,您可以转到 Play 控制台,选择“发布”,单击轨道,然后选择“发布”选项卡。在您想要更新的发布下方,选择“管理推出”,然后选择“停止推出”。将出现一个带有附加信息的对话框。添加一个可选的注释,然后单击“停止”按钮进行确认:

图 16.13:停止分阶段推出的对话框

图 16.13:停止分阶段推出的对话框

当分阶段推出被停止时,您的轨道页面中的发布页面将更新为“推出已停止”文本和“恢复推出”按钮:

图 16.14:停止分阶段推出的发布页面

图 16.14:停止分阶段推出的发布页面

如果您已解决了问题,例如在后端,而且没有必要发布新的更新,您可以恢复分阶段推出。要做到这一点,转到 Play 控制台,选择“发布”,单击轨道,然后选择“发布”选项卡。选择发布并单击“恢复推出”按钮。在“恢复分阶段推出”对话框中,您可以更新百分比,然后单击“恢复推出”以继续推出。

托管发布

在 Google Play 上推出新版本后,将在几分钟内发布。您可以将其更改为在以后的时间发布。当您针对特定日期时,例如与 iOS/web 发布的同一天或在发布日期之后,这将非常有用。

在创建和发布您想要控制发布的更新之前,必须设置托管发布。当您在 Google Play 控制台上选择您的应用时,您可以在左侧选择“发布概述”。在“托管发布状态”部分,单击“管理”按钮:

图 16.15:发布概述上的托管发布

图 16.15:发布概述上的托管发布

将显示托管发布状态。在这里,您可以打开或关闭托管发布,然后单击“保存”按钮。

当您打开“托管发布”时,您可以继续添加和提交应用的更新。您可以在“发布概述”中的“审查更改”部分看到这些更改:

一旦更改得到批准,“审查更改”将为空,并将移至“准备发布的更改”部分。在那里,您可以单击“审查和发布”按钮。在出现的对话框中,您可以单击“发布”按钮进行确认。然后您的更新将立即发布。

图 16.16:托管发布准备发布的更改

图 16.16:托管发布准备发布的更改

活动 16.01:发布应用

作为本书的最后一个活动,您的任务是创建一个 Google Play 开发者帐户,并发布您构建的新开发的 Android 应用程序。您可以发布本书的其中一个应用,或者您一直在开发的其他项目。您可以使用以下步骤作为指南:

  1. 转到 Google Play 开发者控制台(play.google.com/console)并创建一个帐户。

  2. 创建一个可用于签署发布版本的密钥库。

  3. 为发布生成一个 Android 应用程序包。

  4. 在将应用发布到生产轨道之前,可以将其发布到公开测试版轨道。

注意

本章已经详细解释了发布应用的步骤,因此针对这一活动没有单独的解决方案。您可以按照本章的练习成功完成前面的步骤。所需的确切步骤将是与您的应用独特相关的,并且将取决于您想要使用的设置。

总结

本章涵盖了 Google Play 商店:从准备发布、创建 Google Play 开发者帐户,到最终发布您的应用。我们从为您的应用版本化开始,生成密钥库,创建 APK 文件或 Android 应用捆绑包,并使用发布密钥库对其进行签名,以及存储密钥库及其凭据。然后我们转向在 Google Play 控制台上注册帐户,上传您的 APK 文件或应用捆绑包,并管理发布。

这是本书贯穿始终的工作的最终成果——发布您的应用并向世界开放,这是一个伟大的成就,也证明了您在本课程中取得的进步。

在本书中,您已经掌握了许多技能,从 Android 应用程序开发的基础开始,逐步实现诸如RecyclerViews、从 Web 服务获取数据、通知和测试等功能。您已经了解了如何通过最佳实践、架构模式和动画来改进您的应用,最后,您已经学会了如何将其发布到 Google Play。

这只是您作为 Android 开发人员旅程的开始。随着您继续构建更复杂的应用程序并扩展您在这里学到的知识,您还有许多更高级的技能需要发展。请记住,Android 在不断发展,因此及时了解最新的 Android 发布情况是很重要的。您可以访问developer.android.com/查找最新资源,并进一步沉浸在 Android 世界中。

posted @ 2024-05-22 15:14  绝不原创的飞龙  阅读(27)  评论(0编辑  收藏  举报