Kotlin-安卓应用构建操作手册第二版-全-
Kotlin 安卓应用构建操作手册第二版(全)
原文:
zh.annas-archive.org/md5/9765e2ab23000fd6cab23c32272be812
译者:飞龙
前言
过去十年中,Android 已经统治了应用市场,开发者们越来越倾向于开始构建自己的 Android 应用。如何使用 Kotlin 构建 Android 应用 从 Android 开发的构建块开始,教你如何使用 Android Studio,这是 Android 的集成开发环境(IDE),以及使用 Kotlin 编程语言进行应用开发。
然后,你将通过指导练习学习如何创建应用并在虚拟设备上运行它们。你将涵盖 Android 开发的 fundamentals,从构建应用的结构到使用活动、片段和各种导航模式构建 UI。随着章节的推进,你将深入研究 Android 的 RecyclerView,以充分利用显示数据列表,并熟悉从网络服务获取数据和处理图像。
然后,你将了解映射、位置服务和权限模型,在处理通知和如何持久化数据之前。接下来,你将使用 Jetpack Compose 构建用户界面。继续前进,你将掌握测试,涵盖测试金字塔的全面范围。你还将学习如何使用 Android 架构组件(AAC)来干净地组织你的代码,并探索各种架构模式和依赖注入的好处。
对于异步编程,将涵盖协程和 Flow API。然后,焦点回到 UI 上,演示如何在用户与你的应用交互时添加运动和过渡效果。接近尾声时,你将构建一个有趣的应用,从电影数据库检索并显示流行电影,然后了解如何将你的应用发布到 Google Play。
到这本书的结尾,你将拥有使用 Kotlin 构建完整 Android 应用所需的技术和信心。
这本书面向谁
如果你想要使用 Kotlin 构建自己的 Android 应用,但又不确定如何开始,那么这本书就是为你准备的。对 Kotlin 编程语言的基本理解将帮助你更快地掌握本书涵盖的主题。
这本书涵盖的内容
第一章,创建你的第一个应用,展示了如何使用 Android Studio 构建你的第一个 Android 应用。在这里,你将创建一个 Android Studio 项目,了解其构成,并探索在虚拟设备上构建和部署应用所需的工具。你还将了解 Android 应用的结构。
第二章,构建用户界面流程,深入探讨了 Android 生态系统和 Android 应用的构建块。将介绍活动及其生命周期、意图和任务等概念,以及恢复状态和在不同屏幕或活动之间传递数据。
第三章, 使用 Fragment 开发 UI,教授您使用 Fragment 为 Android 应用程序的用户界面奠定基础。您将学习如何以多种方式使用 Fragment 来构建适用于手机和平板电脑的应用布局,包括使用 Jetpack Navigation 组件。
第四章, 构建应用导航,介绍了应用中不同类型的导航。您将了解具有滑动布局的导航抽屉、底部导航和标签导航。
第五章, 核心库:Retrofit、Moshi 和 Glide,为您揭示了如何使用 Retrofit 库和 Moshi 库将数据转换为 Kotlin 对象,从而构建从远程数据源获取数据的 App。您还将了解 Glide 库,它可以将远程图片加载到您的应用中。
第六章, 添加和与 RecyclerView 交互,介绍了使用 RecyclerView 小部件构建列表并显示的概念。
第七章,Android 权限和 Google Maps,介绍了权限的概念以及如何从用户那里请求权限,以便您的应用执行特定任务,同时还将介绍 Maps API。
第八章, 服务、WorkManager 和通知,详细介绍了 Android 应用中的后台工作概念以及如何以对用户不可见的方式执行某些任务,同时还将介绍如何显示此类工作的通知。
第九章, 使用 Jetpack Compose 构建用户界面,展示了 Jetpack Compose 的工作原理,如何应用样式和主题,以及如何在具有布局文件的项目中使用 Jetpack Compose。
第十章, 使用 JUnit、Mockito 和 Espresso 进行单元测试和集成测试,教授您关于 Android 应用程序的不同类型测试,每种测试类型使用的框架,以及测试驱动开发的概念。
第十一章, Android 架构组件,为您揭示了 Android Jetpack 库中的组件,如 ViewModel,它有助于将业务逻辑与用户界面代码分离。然后我们将探讨如何使用可观察的数据流,如 LiveData,将数据传递到用户界面。最后,我们将分析 Room 库,了解如何持久化数据。
第十二章, 数据持久化,展示了在设备上存储数据的各种方法,从 SharedPreferences 到文件。还将介绍 Repository 概念,让您了解如何以不同层次结构构建您的应用。
第十三章,使用 Dagger、Hilt 和 Koin 进行依赖注入,解释了依赖注入的概念及其为应用程序提供的优势。Dagger、Hilt 和 Koin 等框架被介绍来帮助你管理依赖项。
第十四章,协程和 Flow,向你介绍如何使用协程和 Flow 进行后台操作和数据操作。你还将了解如何使用 Flow 运算符和 LiveData 转换来操作和显示数据。
第十五章,架构模式,解释了你可以用它来结构化你的 Android 项目,将它们分离成具有不同功能的独立组件的架构模式。这些模式使你更容易开发、测试和维护你的代码。
第十六章,使用 CoordinatorLayout 和 MotionLayout 进行动画和过渡,讨论了如何使用CoordinatorLayout
和MotionLayout
增强你的应用,添加动画和过渡效果。
第十七章,在 Google Play 上发布你的应用,通过向你展示如何在 Google Play 上发布你的应用来结束这本书:从准备发布到创建 Google Play 开发者账户,最后发布你的应用。
为了充分利用这本书
每一段伟大的旅程都始于一个谦卑的步伐。在我们能够在 Android 中做些惊人的事情之前,我们需要准备好一个高效的环境。在本节中,我们将了解如何做到这一点。
最小硬件要求
为了获得最佳的学习体验,我们推荐以下硬件配置:
-
处理器:英特尔酷睿 i5 或同等或更高
-
内存:8 GB 或更多 RAM
-
存储:至少 8 GB 可用空间
软件要求
你还需要提前安装以下软件:
-
操作系统:64 位 Windows 8/10/11、macOS 或 64 位 Linux
-
Android Studio Electric Eel 或更高版本
安装和设置
在开始这本书之前,你需要安装 Android Studio Electric Eel(或更高版本),这是你将在本章中使用到的软件。你可以从 https://developer.android.com/studio 下载 Android Studio。
在 macOS 上,启动 DMG 文件,将 Android Studio 拖放到Applications
文件夹中。完成此操作后,打开 Android Studio。在 Windows 上,启动 EXE 文件。如果你使用 Linux,将 ZIP 文件解压到你的首选位置。打开你的终端,导航到android-studio/bin/
目录并执行studio.sh
。
接下来,将弹出数据共享对话框;点击将使用统计信息发送给 Google按钮或不发送按钮以禁用向 Google 发送匿名使用数据:
数据共享对话框
在欢迎对话框中,点击下一步按钮开始设置:
欢迎对话框
在安装类型对话框中,选择标准以安装推荐设置。然后,点击下一步按钮:
安装类型对话框
在选择 UI 主题对话框中,选择您喜欢的 IDE 主题——无论是浅色还是Darcula(深色主题)——然后点击下一步按钮:
选择 UI 主题对话框
在验证设置对话框中,检查您的设置,然后点击完成按钮。设置向导将下载并安装额外的组件,包括 Android SDK:
验证设置对话框
下载完成后,您可以点击完成按钮。现在您已准备好创建您的 Android 项目。
如果您使用的是本书的数字版,我们建议您自己输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/How-to-Build-Android-Apps-with-Kotlin-Second-Edition
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/
获取。查看它们!
下载彩色图像
我们还提供包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/vnOCn
。
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和推特用户名。以下是一个示例:“您可以在主项目窗口下的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>
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“点击完成,您的虚拟设备将被创建。”
小贴士或重要提示
看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
copyright@packt.com
并附上材料的链接。
如果你有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《如何使用 Kotlin 构建 Android 应用(第 2 版)》,我们很乐意听听你的想法!请选择www.amazon.in/review/create-review/error?asin=1837634939
为此书提供反馈。
你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?
你的电子书购买是否与您选择的设备不兼容?
别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何地方、任何设备上阅读。从您最喜欢的技术书籍中直接搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。
按照以下简单步骤获取福利:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781837634934
-
提交你的购买证明
-
就这样!我们将直接将免费 PDF 和其他福利发送到您的电子邮件。
第一部分:Android 基础
这一部分首先向用户介绍 Android Studio,这是用于 Android 开发的集成开发环境(IDE),然后引导他们了解 Android 开发的构建块。这是一个对 Android 框架的全面概述,通过指导练习来强化学习目标,以便保留这些知识。
在本节中,我们将涵盖以下章节:
-
第一章, 创建你的第一个应用
-
第二章, 构建用户屏幕流程
-
第三章, 使用片段开发 UI
-
第四章, 构建应用导航
第一章:创建你的第一个应用
本章是 Android 的入门介绍,你将设置你的环境并专注于 Android 开发的基础知识。到本章结束时,你将获得从头创建 Android 应用并将其安装在虚拟或物理 Android 设备上所需的知识。
你将能够分析和理解 AndroidManifest.xml
文件的重要性,并使用 Gradle 构建工具配置你的应用并实现来自 Material Design 的 用户界面(UI)元素。
Android 是全球使用最广泛的移动电话操作系统,拥有超过三十亿活跃设备。通过学习 Android 并构建具有全球影响力的应用,这提供了巨大的贡献和影响力的机会。然而,对于初学 Android 的开发者来说,有许多问题你必须面对,以便开始学习和提高生产力。
本书将解决这些问题。在学习工具和开发环境之后,你将探索构建 Android 应用所需的基本实践。我们将涵盖开发者面临的广泛实际开发挑战,并探讨克服这些挑战的各种技术。
在本章中,你将学习如何创建一个基本的 Android 项目并向其中添加功能。你将介绍 Android Studio 的综合开发环境,并了解软件的核心区域,以便你能够高效地工作。
Android Studio 提供了应用开发的全部工具,但并不提供知识。本章将指导你有效地使用该软件来构建应用并配置 Android 项目的最常见区域。
本章将涵盖以下主题:
-
使用 Android Studio 创建 Android 项目
-
设置虚拟设备并运行你的应用
-
AndroidManifest 文件
-
使用 Gradle 构建、配置和管理应用依赖项
-
Android 应用结构
技术要求
本章所有练习和活动的完整代码可在 GitHub 上找到,链接为 packt.link/96l1D
使用 Android Studio 创建 Android 项目
为了在构建 Android 应用方面提高生产力,掌握如何使用 Android Studio 是至关重要的。这是 Android 开发的官方 集成开发环境(IDE),基于 JetBrains 的 IntelliJ IDEA IDE 构建,并由谷歌的 Android Studio 团队开发。你将在整个课程中使用它来创建应用并逐步添加更多高级功能。
Android Studio 的发展遵循 IntelliJ IDEA IDE 的发展。IDE 的基本功能当然存在,使你能够通过建议、快捷键和标准重构来优化你的代码。你将在整个课程中使用 Kotlin 编程语言来创建 Android 应用。之前创建 Android 应用的标准语言是 Java。
自从 2017 年 Google I/O(年度 Google 开发者大会)以来,这已经成为 Google 用于 Android 应用开发的优先语言。真正使 Android Studio 与其他 Android 开发环境区分开来的是,Kotlin 是由 IntelliJ IDEA(Android Studio 所基于的软件)的创建者 JetBrains 创建的。因此,你可以从 Kotlin 的稳定和不断发展的第一级支持中受益。
Kotlin 是为了解决 Java 在冗长性、处理空类型以及添加更多函数式编程技术等方面的不足而创建的。自从 2017 年 Kotlin 成为 Android 开发的首选语言,取代了 Java,你将在本书中使用它。
熟悉并熟悉 Android Studio 将使你在开发和构建 Android 应用时感到自信。那么,让我们开始创建你的第一个项目。
注意
Android Studio 的安装和设置在 前言 中有介绍。请确保你在继续之前已经完成了这些步骤。
练习 1.01 – 为你的应用创建 Android Studio 项目
这是创建你应用将构建其上的项目结构起点。模板驱动的方法将使你能够在短时间内创建一个基本项目,同时设置你可以用来开发应用的构建块。
要完成这个练习,请执行以下步骤:
-
打开 Android Studio 后,你将看到一个窗口询问你是否想要创建一个新项目或打开一个现有项目。选择 Create New Project。
-
现在,你将进入一个简单的向导驱动流程,这极大地简化了创建你的第一个 Android 项目。你将看到的下一个屏幕将为你想要应用拥有的初始设置提供大量选项:
图 1.1 – 为你的应用启动项目模板
- 欢迎来到你对 Android 开发生态系统的第一次介绍。在大多数项目类型中显示的词是 Activity。在 Android 中,一个 Activity 是一个页面或屏幕。你可以选择的选项都会以不同的方式创建这个初始屏幕。
描述说明了应用的第一屏将如何显示。这些是构建你应用的模板。从模板中选择 Empty Activity 并点击 Next。
项目配置屏幕如下:
图 1.2 – 项目配置
-
上一屏幕配置了你的应用。让我们逐一查看所有选项:
-
(
com.sample.shop.myshop
)。如图 1.2所示,项目默认保存位置为Users/MyUser/android/projects
。默认位置会根据你使用的操作系统而有所不同。默认情况下,项目将被保存在一个新文件夹中,该文件夹的名称为应用名称,空格将被删除。这会导致创建一个名为MyApplication
的项目文件夹。请将其更改为你正在工作的Exercise
或Activity
,因此对于这个项目,文件夹名称应为Exercise1.01
。 -
语言: Kotlin是 Google 为 Android 应用开发首选的语言。
-
最小 SDK 版本: 根据你下载的 Android Studio 版本不同,默认值可能与图 1.2中显示的相同,也可能不同。请保持这一设置不变。大多数 Android 的新特性都实现了向后兼容,因此你的应用可以在绝大多数旧设备上正常运行。然而,如果你希望针对新设备进行开发,你应该考虑提高最小 API 级别。有一个帮助我选择的链接,它会弹出一个对话框,解释了你可以在不同版本的 Android 上开发时使用的功能集,以及全球运行每个 Android 版本的设备百分比。
-
使用遗留的 android.support 库: 请不要勾选此项。你将使用 AndroidX 库,它是为使 Android 新版本的功能向后兼容旧版本而设计的支持库的替代品,但它提供了更多功能。它还包含名为Jetpack的新 Android 组件,正如其名所示,它可以提升你的 Android 开发,并提供一系列丰富的功能,你可以在应用中使用这些功能,从而简化常见操作。
-
在填写完所有这些详细信息后,在一个标签页中选择MainActivity
,在另一个标签页中选择用于屏幕的布局(activity_main.xml
)。应用程序结构文件夹位于左侧面板中:
图 1.3 – Android Studio 默认项目
在这个练习中,你已经完成了使用 Android Studio 创建你的第一个 Android 应用的步骤。这种基于模板的方法向你展示了你需要为应用配置的核心选项。
在下一节中,你将设置一个虚拟设备,并看到你的应用首次运行。
设置虚拟设备并运行你的应用
作为安装 Android Studio 的一部分,您下载并安装了最新的 Android 软件开发工具包 (SDK) 组件。这些包括一个基本模拟器,您将配置它以创建一个虚拟设备来运行 Android 应用。模拟器模仿真实设备的硬件和软件特性和配置。好处是您可以在开发应用的同时在您的桌面上快速看到所做的更改。尽管虚拟设备没有真实设备的所有功能,但反馈周期通常比通过连接真实设备的步骤要快。
此外,尽管您应该确保您的应用在不同设备上按预期运行,但您可以通过下载设备配置文件来标准化它,即使您没有真实设备,如果这是您项目的要求。
您在安装 Android Studio 时将看到的(或类似的东西)如下所示:
图 1.4 – SDK 组件
让我们看看已安装的 SDK 组件以及虚拟设备是如何适应的:
-
Android 模拟器:这是基本模拟器,我们将配置它以创建不同 Android 品牌和型号的虚拟设备。
-
Android SDK 构建工具:Android Studio 使用构建工具来构建您的应用。这个过程涉及编译、链接和打包您的应用,以便将其安装到设备上。
-
Android SDK 平台:这是您将用于开发应用的 Android 平台版本。平台指的是 API 级别。
-
Android SDK 平台工具:这些是您可以从命令行使用的工具,用于与您的应用交互和调试。
-
Android SDK 工具:与平台工具相比,这些是您主要在 Android Studio 内部使用的工具,用于完成某些任务,例如运行应用的虚拟设备以及 SDK 管理器用于下载和安装平台和其他 SDK 组件。
-
Intel x86 模拟器加速器(HAXM 安装程序):如果您的操作系统提供它,这将是在您的计算机硬件级别的一个功能,您将被提示启用,这允许您的模拟器运行得更快。
-
SDK 补丁应用器 v4:随着 Android Studio 新版本的可用,这允许应用补丁以更新您正在运行的版本。
带着这些知识,让我们开始本章的下一个练习。
练习 1.02 – 设置虚拟设备并在其上运行您的应用
我们在 练习 1.01 中设置了一个 Android Studio 项目来创建我们的应用,即 为您的应用创建 Android Studio 项目,现在我们将要在虚拟设备上运行它。您也可以在真实设备上运行您的应用,但在这个练习中您将使用虚拟设备。这个过程是您在应用上工作的持续循环。一旦您实现了某个功能,您就可以根据需要验证其外观和行为。
对于这个练习,您将创建一个虚拟设备,但您应该确保在多个设备上运行您的应用以验证其外观和行为的一致性。执行以下步骤:
- 在 Android Studio 的工具栏中,您将看到两个相邻的下拉框,其中app和无****设备被预先选中:
图 1.5 – Android Studio 工具栏
app是我们将要运行的应用的配置。由于我们尚未设置虚拟设备,它显示为无设备。
- 为了创建虚拟设备,请点击如图 图 1.5 所示的设备管理器,以打开虚拟设备窗口/屏幕。此选项也可以从工具菜单访问:
图 1.6 – 工具菜单中的设备管理器
- 点击按钮或工具栏选项以打开设备管理器窗口,并点击如图 图 1.7 所示的创建设备按钮:
图 1.7 – 设备管理器窗口
随后,您将看到一个屏幕,如图 图 1.8 所示:
图 1.8 – 设备定义创建
- 我们将选择Pixel 6设备。实际的(非虚拟)Pixel 设备系列由 Google 开发,并可以访问 Android 平台的最新版本。一旦选择,点击下一步按钮:
图 1.9 – 系统映像
这里显示的Tirimasu名称是 Android 13 的初始代码/发布名称。选择可用的最新系统映像。目标列也可能在名称中显示(Google Play)或(Google APIs)。Google APIs 表示系统映像预装了 Google Play 服务。
这是 Google APIs 和 Google 应用程序丰富的功能集,您的应用可以使用并与之交互。在首次运行应用时,您将看到地图和 Chrome 等应用,而不是一个普通的模拟器映像。Google Play 系统映像表示,除了 Google APIs 之外,Google Play 应用也将被安装。
- 您应该使用 Android 平台的最新版本开发您的应用,以利用最新功能。在首次创建虚拟设备时,您将需要下载系统映像。如果发布名称旁边显示下载链接,请点击它,并等待下载完成。选择下一步按钮以查看您设置的虚拟设备:
图 1.10 – 虚拟设备配置
- 点击完成,您的虚拟设备将被创建。然后您将看到您的设备被突出显示:
图 1.11 – 列出的虚拟设备
- 在 操作 列下按下播放箭头按钮以运行虚拟设备:
图 1.12 – 启动的虚拟设备
你将看到虚拟设备在 Android Studio 的 模拟器 工具窗口中运行。现在,你已经创建了虚拟设备并且它正在运行,你可以回到 Android Studio 中运行你的 app。
- 你已设置并启动的虚拟设备将被选中。按下绿色三角形/播放按钮以启动你的 app:
图 1.13 – App 启动配置
这将把 app 加载到模拟器中,如图 图 1.14 所示。
图 1.14 – 在虚拟设备上运行的 app
在这个练习中,你已经完成了创建虚拟设备并在其上运行你创建的 app 的步骤。你使用的 Android 虚拟设备管理器,允许你创建你想要为目标 app 定制的设备(或设备范围)。在虚拟设备上运行你的 app 可以让你快速地得到反馈,验证新功能开发的行为以及它是否以你期望的方式显示。
接下来,你将探索你的项目中的 AndroidManifest.xml
文件,它包含了你的 app 的信息和配置。
Android manifest
你刚刚创建的 app,虽然简单,但包含了你将在所有项目中使用的核心构建块。app 由 AndroidManifest.xml
文件驱动,这是一个详细说明 app 内容的 manifest 文件。它位于 app
| manifests
| AndroidManifest.xml
:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_
rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MyApplication"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.
MAIN" />
<category android:name="android.intent.
category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
从一般意义上讲,一个典型的 manifest 文件是一个顶级文件,它描述了包含的文件或其他数据以及相关的元数据,形成一个组或单元。Android manifest 将这个概念应用到你的 Android app 上,作为一个 XML 文件。
每个 Android app 都有一个应用程序类,允许你配置 app。在 <application>
元素打开后,你定义你的 app 的组件。由于我们刚刚创建了 app,它只包含以下代码中显示的第一个屏幕:
<activity android:name=".MainActivity">
下一个指定的子 XML 节点如下:
<intent-filter>
Android 使用意图作为与 apps 和系统组件交互的机制。意图被发送,意图过滤器注册了你的 app 对这些意图的反应能力。<android.intent.action.MAIN>
是你的 app 的主入口点,它在 .MainActivity
的封装 XML 中出现,指定当 app 启动时将启动此屏幕。Android.intent.category.LAUNCHER
表示你的 app 将出现在用户的设备启动器中。
由于您是从模板创建的应用程序,因此它有一个基本的清单,该清单将通过Activity
组件启动应用程序并在启动时显示初始屏幕。根据您想添加到应用程序中的其他功能,您可能需要在 Android 清单文件中添加权限。
权限分为三个不同的类别:正常、签名和危险:
-
正常:这些权限包括访问网络状态、Wi-Fi、互联网和蓝牙。这些通常在运行时无需请求用户同意即可允许。
-
签名:这些权限由必须使用相同证书签名的同一组应用程序共享。这意味着这些应用程序可以自由共享数据,但其他应用程序无法访问。
-
危险:这些权限围绕用户及其隐私,例如发送短信、访问账户和位置,以及读写文件系统和联系人。
这些权限必须在清单中列出,并且在危险权限的情况下,从 Android Marshmallow API 23(Android 6 Marshmallow)开始,您还必须请求用户在运行时授予这些权限。
在下一个练习中,我们将配置 Android 清单。有关此文件的详细文档,请参阅developer.android.com/guide/topics/manifest/manifest-intro
。
练习 1.03 – 配置 Android 清单互联网权限
大多数应用程序所需的关键权限是访问互联网。这默认情况下并未添加。在这个练习中,我们将修复这个问题,并在过程中加载一个WebView
,这使应用程序能够显示网页。这种情况在 Android 应用程序开发中非常常见,因为大多数商业应用程序都会显示隐私政策、条款和条件等。由于这些文档可能对所有平台都适用,通常的显示方式是加载一个网页。为此,请执行以下步骤:
-
创建一个新的 Android Studio 项目,就像在练习 1.01中为您的应用程序创建 Android Studio 项目一样。
-
切换到
MainActivity
类所在的标签页。从主项目窗口,它位于app
|java
|com
|example
|myapplication
。
您可以通过选择视图 | 工具窗口 | 项目来打开工具窗口,从而更改项目窗口显示的内容。这将选择项目视图。项目窗口顶部的下拉选项允许您更改查看项目的方式,最常用的显示方式是项目和Android:
图 1.15 – 工具窗口下拉菜单
在打开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)
}
}
你将在本章下一节中更详细地检查这个文件的内容,但到目前为止,你只需要知道 setContentView(R.layout.activity_main)
语句设置了你在虚拟设备中首次运行应用时看到的 UI 布局。
-
使用以下代码将其更改为以下内容:
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
关键字是一个只读属性引用,一旦设置后就不能更改。JavaScript 需要在 WebView
中启用才能执行 JavaScript。
注意
我们没有设置类型,但 Kotlin 有类型推断,所以如果可能的话,它会推断类型。因此,使用 val webView: WebView = WebView(this)
明确指定类型是不必要的。根据你过去使用的编程语言,定义参数名和类型的顺序可能或可能不熟悉。Kotlin 遵循 Pascal 语法,即名称后跟类型。
- 现在,运行应用,文本将如截图所示出现:
图 1.16 – 没有互联网权限错误信息
-
这个错误发生是因为你的
AndroidManifest.xml
文件中没有添加INTERNET
权限。(如果你收到net::ERR_CLEARTEXT_NOT_PERMITTED
错误,这是因为你正在加载到WebView
中的 URL 不是 HTTPS,并且从 API 级别 28 开始,非 HTTPS 流量被禁用,Android 9.0 Pie 及以上版本)。 -
让我们通过向清单中添加
INTERNET
权限来修复这个问题。打开 Android 清单文件,并在<application>
标签上方添加以下内容:<uses-permission android:name="android.permission.INTERNET" />
你可以在这里找到添加了权限的完整 Android 清单文件:packt.link/smzpl
在再次运行应用之前,从虚拟设备中卸载应用。你需要这样做,因为应用权限有时可能会被缓存。
通过长按应用图标并选择出现的 App Info 选项,然后按下带有 Uninstall 文字的 Bin 图标来完成此操作。或者,长按应用图标,然后将其拖动到屏幕右上角的 Uninstall 文字旁边的 Bin 图标。
- 再次安装应用,并查看网页出现在
WebView
中:
图 1.17 – 显示 WebView 的应用
在这个例子中,你学习了如何向清单文件中添加权限。Android 清单可以被视为你的应用的目录。它列出了你的应用使用的所有组件和权限。正如你从从启动器启动应用中看到的,它还提供了进入你应用的入口点。
在下一节中,你将探索 Android 构建系统,它使用 Gradle 构建工具来让你的应用运行起来。
使用 Gradle 构建、配置和管理应用依赖项
在创建此项目的过程中,你主要使用了 Android 平台 SDK。当你安装 Android Studio 时,下载了必要的 Android 库。然而,这些并不是创建你的应用所使用的唯一库。为了配置和构建你的 Android 项目或应用,使用了一个名为 Gradle 的构建工具。
Gradle 是 Android Studio 用于构建你的应用的多用途构建工具。默认情况下,Android Studio 使用 Groovy,一种动态类型的 Java 虚拟机 (JVM) 语言来配置构建过程,并允许轻松管理依赖项,因此你可以将库添加到你的项目中并指定版本。
Android Studio 也可以配置为使用 Kotlin 配置构建,但由于默认语言是 Groovy,你将使用这个。存储这些构建和配置信息的文件被命名为 build.gradle
。
当你首次创建你的应用时,有两个 build.gradle
文件,一个位于项目的根/顶级目录,另一个位于应用的 module
文件夹中,专门针对你的应用。
项目级别的 build.gradle
文件
现在我们来看看项目级别的 build.gradle
文件。这是你设置所有根项目设置的地方,这些设置可以应用于子模块/项目:
plugins {
id 'com.android.application' version '7.4.2' apply
false
id 'com.android.library' version '7.4.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0'
apply false
}
Gradle 依赖于插件系统,因此你可以编写自己的插件,执行任务或一系列任务,并将其插入到你的构建管道中。前面列出的三个插件执行以下操作:
-
com.android.application
: 这添加了对创建 Android 应用的支持 -
com.android.library
: 这使得子项目/模块能够成为 Android 库 -
org.jetbrains.kotlin.android
: 这为项目中的 Kotlin 提供了集成和语言支持
apply false
语句仅使这些插件应用于子项目/模块,而不是项目的根级别。version '7.3.1'
指定了插件版本,该版本应用于所有子项目/模块。
应用级别的 build.gradle
文件
build.gradle
应用特定于你的项目配置:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.example.myapplication'
compileSdk 33
defaultConfig {
applicationId "com.example.myapplication"
minSdk 24
targetSdk 33
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 {…}
根 build.gradle
文件中详细说明的 Android 和 Kotlin 插件,在这里通过 plugins
行的 ID 应用到你的项目中。
由 com.android.application
插件提供的 android
块,是你配置 Android 特定配置设置的地方:
-
namespace
: 这是从创建项目时指定的包名设置的。它将用于生成构建和资源标识符。 -
compileSdk
: 这用于定义应用编译时使用的 API 级别,应用可以使用此 API 及其以下级别的功能。 -
defaultConfig
: 这是你的应用的基本配置。 -
applicationId
: 这设置为你的应用的包名,是用于在 Google Play 上唯一标识你的应用的标识符。如果需要,它可以被更改以与包名不同。 -
minSdk
:这是你的应用程序支持的最低 API 级别。这将过滤掉低于此 API 级别的设备,使你的应用程序不会在 Google Play 中显示。 -
targetSdk
:这是你针对的 API 级别。这是你的构建应用程序打算工作和已测试的 API 级别。 -
versionCode
:这指定了你的应用程序的版本代码。每次需要更新应用程序时,版本代码都需要增加一个或多个。 -
versionName
:这是一个用户友好的版本名称,通常遵循 X.Y.Z 的语义版本控制,其中 X 是主版本,Y 是次要版本,Z 是修补版本,例如,1.0.3。 -
testInstrumentationRunner
:这是用于你的 UI 测试的测试运行器。 -
buildTypes
:在buildTypes
下,添加了一个配置你的应用程序以创建release
构建的发布版本。如果将minifyEnabled
设置为true
,则将通过删除任何未使用的代码以及混淆你的应用程序来缩小应用程序的大小。这个混淆步骤将源代码引用的名称更改为如a.b.c()
这样的值。这使得你的代码更不容易被逆向工程,并进一步减少了构建应用程序的大小。 -
compileOptions
:这是 Java 源代码的语言级别(sourceCompatibility
)和字节码(targetCompatibility
)。 -
kotlinOptions
:这是kotlin gradle
插件应该使用的jvm
库。
dependencies
块指定了你的应用程序在 Android 平台 SDK 之上使用的库,如下所示(带有注释):
dependencies {
// Kotlin extensions, jetpack component with Android
Kotlin language features
implementation 'androidx.core:core-ktx:1.7.0'
// Provides backwards compatible support libraries and
jetpack components
implementation 'androidx.appcompat:appcompat:1.6.1'
// Material design components to theme and style your
app
implementation
'com.google.android.material:material:1.8.0'
// The ConstraintLayout ViewGroup updated separately
from main Android sources
implementation
'androidx.constraintlayout:constraintlayout:2.1.4'
// Standard Test library for unit tests
testImplementation 'junit:junit:4.13.2'
// UI Test runner
androidTestImplementation
'androidx.test.ext:junit:1.1.5'
// Library for creating Android UI tests
androidTestImplementation
'androidx.test.espresso:espresso-core:3.5.1'
}
依赖关系遵循 Maven 的 groupId
、artifactId
和 versionId
,它们之间用 :
分隔。因此,例如,之前指定的兼容支持库显示如下:
'androidx.appcompat:appcompat:1.6.1'
groupId
是 android.appcompat
,artifactId
是 appcompat
,versionId
是 1.5.1
。构建系统会定位并下载这些依赖关系,从 settings.gradle
文件中详细说明的 repositories
块构建应用程序。
注意
在之前的代码部分以及本章节和其他章节的后续部分中指定的依赖版本可能会发生变化,并且会随着时间的推移而更新,因此当你创建这些项目时,它们可能更高。
添加这些库的 implementation
表示法意味着它们的内部依赖关系不会暴露给你的应用程序,这使得编译更快。
在这里,androidx
组件被添加为依赖项,而不是在 Android 平台源中。这样,它们可以独立于 Android 版本进行更新。androidx
包含了 Android Jetpack 库的套件和重新打包的支持库。
下一个要检查的 Gradle 文件是 settings.gradle
,它最初看起来像这样:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "My Application"
include ':app'
在首次使用 Android Studio 创建项目时,将只有一个模块,即 app
,但当你添加更多功能时,你可以添加新的模块,这些模块专门用于包含一个功能而不是将其打包在主 app
模块中。
这些被称为功能模块,你可以通过其他类型的模块来补充它们,例如共享模块,这些模块被所有其他模块使用,如网络模块。此文件还包含插件和依赖项的存储库,分别用于插件和依赖项的单独块。
设置RepositoriesMode.FAIL_ON_PROJECT_REPOS
的值确保所有依赖项存储库都定义在这里;否则,将触发构建错误。
练习 1.04 – 探索 Material Design 如何用于应用主题
在这个练习中,你将了解谷歌的新设计语言Material Design,并使用它来加载一个 Material Design 主题的应用。Material Design 是由谷歌创建的一种设计语言,它基于现实世界的效果(如光照、深度、阴影和动画)添加了丰富的 UI 元素。执行以下步骤以完成练习:
-
创建一个新的 Android Studio 项目,就像你在练习 1.01,为你的应用创建 Android Studio 项目时做的那样。
-
首先,查看
dependencies
块并找到 Material Design 依赖项:implementation 'com.google.android.material:material:1.8.0'
-
接下来,打开位于
app
|src
|main
|res
|values
|themes.xml
的themes.xml
文件:values-night
文件夹中也有一个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">?attr/ colorPrimaryVariant</item> <!-- Customize your theme here. --> </style> </resources>
注意Theme.MyApplication
的父主题是Theme.MaterialComponents.DayNight.DarkActionBar
。
在dependencies
块中添加的 Material Design 依赖项被用来应用应用的主题。与它们之前使用的AppCompat
主题相比,一个关键的区别是能够为应用的主色和辅助色提供变体。
例如,colorPrimaryVariant
允许你为主色添加色调,这可以是比colorPrimary
颜色更亮或更暗。此外,你还可以使用colorOnPrimary
在你的应用前景中为视图元素的颜色进行样式设置。
一起,这些为应用的主题带来了统一的品牌形象。要看到这种效果,请进行以下更改以颠倒主色和辅助色:
<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/teal_200</item>
<item name="colorPrimaryVariant">@color/
teal_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/purple_200
</item>
<item name="colorSecondaryVariant">@color/
purple_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/
colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
- 现在运行应用,你会看到应用的主题有所不同。动作栏和状态栏的背景色与默认的 Material 主题应用形成对比,如图图 1.18所示:
图 1.18 – 主色和辅助色颠倒的应用
在这个练习中,你学习了如何使用 Material Design 来为主题应用。由于你目前只显示TextView
在屏幕上,所以不清楚材料设计提供了哪些好处,但当你开始使用更多的 Material UI 设计小部件时,这将会改变。
现在你已经了解了项目的构建和配置方式,在下一节中,你将详细探索项目结构,了解它是如何创建的,并熟悉开发环境的核心区域。
安卓应用结构
现在我们已经介绍了 Gradle 构建工具的工作原理,我们将探索项目的其余部分。最简单的方法是检查应用文件夹结构。在 Android Studio 的左上角有一个工具窗口,称为 项目,它允许您浏览应用的内容。
默认情况下,当您的 Android 项目首次创建时,它被设置为 打开/选中。当您选择它时,您将看到一个类似于 图 1**.19 中的截图的视图。如果您在屏幕左侧看不到任何窗口栏,请转到顶部工具栏并选择 视图 | 外观 | 工具窗口栏,并确保它被勾选。
浏览项目有多种不同的选项,但让我们看看 app
文件夹结构。
这里是这些文件的概述,其中包含对最重要的文件更详细的说明。打开后,您将看到它包含以下文件夹结构:
图 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
关键字(正如您可能已经猜到的)代表 函数。savedInstanceState: Bundle?
参数是 Android 用于恢复先前保存状态的机制。对于这个简单的活动,您没有存储任何状态,因此此值将为 null
。跟随类型的问号 ?
声明此类型可以是 null
。
super.onCreate(savedInstanceState)
这一行调用基类的重写方法,最终,setContentView(R.layout.activity_main)
加载我们想要在活动中显示的布局;否则,如果没有定义布局,它将显示为空白屏幕。
让我们看看文件夹结构中存在的其他一些文件(图 1**.19):
-
ExampleInstrumentedTest
:这是一个示例 UI 测试。您可以通过在应用运行时运行测试来检查和验证应用的流程和结构。 -
ExampleUnitTest
:这是一个示例单元测试。创建 Android 应用的一个基本部分是编写单元测试来验证源代码是否按预期工作。 -
ic_launcher_background.xml
和ic_launcher_foreground.xml
:这两个文件一起构成了你的应用启动器的矢量格式图标,它将被 Android API 26(奥利奥)及更高版本的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_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
在 Android 中,可以使用 XML 或 Jetpack Compose 创建屏幕显示,Jetpack Compose 使用声明式 API 动态构建 UI。你将在第九章中学习 Jetpack Compose。对于 XML,文档从 XML 头开始,然后是顶级ViewGroup
(在这里是ConstraintLayout
),然后是一个或多个嵌套的Views
和ViewGroups
。
ConstraintLayout
ViewGroup
允许在屏幕上非常精确地定位视图,通过父视图和兄弟视图、指南和障碍物来约束视图。有关ConstraintLayout
的详细文档可以在developer.android.com/reference/androidx/constraintlayout/widget/ConstraintLayout
找到。
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_width
和android:layout_height
。
这些可以设置为绝对值,通常是密度无关像素(称为 dip
或 dp
),它们将像素大小缩放到不同密度设备上大致等效。然而,更常见的是,这些属性被设置为 wrap_content
或 match_parent
值。wrap_content
将根据需要的大小仅包含其内容。match_parent
将根据其父元素的大小进行设置。
有其他 ViewGroups
可以用来创建布局。例如,LinearLayout
垂直或水平排列视图,FrameLayout
通常用于显示单个子视图,而 RelativeLayout
是 ConstraintLayout
的一个更简单版本,它根据父视图和兄弟视图的位置排列视图。
ic_launcher.webp
文件是具有每个不同密度设备图标的 .webp
启动器图标。这种图像格式由 Google 创建,与 .png
图像相比具有更高的压缩率。由于我们使用的 Android 最小版本是 API 21:Android 5.0(Jelly Bean),因此这些 .webp
图像被包含在内,因为启动器矢量格式的支持直到 Android API 26(Oreo)才被引入。
ic_launcher.xml
文件使用矢量文件(ic_launcher_background.xml
和 ic_launcher_foreground.xml
)在 Android API 26(Oreo)及更高版本中缩放到不同的密度设备。
注意
要针对 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 6 模拟器的密度大约为 411dpi
,所以它使用来自超超高清密度桶(xxhdpi
)的资源,这是最接近的匹配。Android 倾向于将资源缩放以最佳匹配密度桶,因此位于 xhdpi
和 xxhdpi
桶之间的 400dpi
设备很可能会显示来自 xxhdpi
桶的 480dpi
资产。
要为不同密度创建替代位图可绘制对象,您应遵循六种主要密度之间的 3:4:6:8:12:16
缩放比例。例如,如果您有一个为中等密度屏幕的 48x48
像素的位图可绘制对象,所有不同的大小应如下所示:
-
36x36
(0.75x
) 用于低密度 (ldpi
) -
48x48
(1.0x
基线) 用于中等密度 (mdpi
) -
72x72
(1.5x
) 用于高密度 (hdpi
) -
96x96
(2.0x
) 用于超高密度 (xhdpi
) -
144x144
(3.0x
) 用于超高中密度 (xxhdpi
) -
192x192
(4.0x
) 用于超超高密度 (xxxhdpi
)
要比较这些按密度分组的物理启动器图标,请参考以下表格:
图 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>
格式基于 #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>
可以通过将 android:textStyle="bold"
作为属性设置在 TextView
上,直接将样式信息应用于视图。但是,您必须为每个想要以粗体显示的 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所示:
图 1.21 – 带有提示的 EditText
虽然这可以很好地启用用户添加/编辑文本,但TextInputEditText
材料和其包装器TextInputLayout
视图为EditText
显示增添了一些光泽。以下是EditText
可以更新的方式:
<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 – 带有提示的 TextInputLayout/TextInputEditText 材料
TextInputLayout
允许我们为TextInputEditText
视图创建一个标签,并在TextInputEditText
视图获得焦点(移动到字段顶部)时执行一个漂亮的动画,同时仍然显示标签。标签通过android:hint
指定。
您将更改应用中的Hello World
文本,以便用户可以输入他们的名字和姓氏,并通过按按钮进一步显示问候语。为此,请执行以下步骤:
-
按照与练习 1.01,为您的应用创建 Android Studio 项目相同的方式创建一个新的 Android Studio 项目,命名为 My Application。
-
通过将以下条目添加到
app
|src
|main
|res
|values
|strings.xml
来创建您应用中将使用的标签和文本:<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>
-
接下来,我们将更新我们的样式以在布局中使用,通过在
app
|src
|main
|res
|values
|themes.xml
主题中添加以下样式:<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>
注意
一些样式的父级引用了 Material 样式,因此这些样式将直接应用于视图和指定的样式。
-
现在我们已经添加了要应用于布局和文本的样式,我们可以更新
activity_main.xml
布局文件,位于app
|src
|main
|res
|layout
文件夹中:<?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/screen_layout_margin" tools:context=".MainActivity"> <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" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"> <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> <com.google.android.material.textfield.TextInputLayout android:id="@+id/last_name_wrapper" style="@style/text_input_greeting" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/last_name_text" app:layout_constraintTop_toBottomOf="@id/first_name_ wrapper" app:layout_constraintStart_toStartOf="parent"> <com.google.android.material.textfield. TextInputEditText android:id="@+id/last_name" android:layout_width="match_parent" android:layout_ height="wrap_content" /> </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.button.MaterialButton android:layout_width="match_parent" android:layout_height="wrap_content" style="@style/button_greeting" android:id="@+id/enter_button" android:text="@string/enter_button_text" app:layout_constraintTop_toBottomOf="@id/last_name_ wrapper" app:layout_constraintStart_toStartOf="parent"/> <TextView android:id="@+id/greeting_display" android:layout_width="match_parent" style="@style/greeting_display" app:layout_constraintTop_toBottomOf="@id/enter_ button" app:layout_constraintStart_toStartOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
-
运行应用并查看外观和感觉。您已为所有视图添加了 ID,以便它们可以与其兄弟元素进行约束,并在活动中提供获取
TextInputEditText
视图值的方法。style="@style.."
表示法应用了themes.xml
文件中的样式。
如果你选择一个TextInputEditText
视图,你会看到标签动画并移动到视图的顶部:
图 1.23 – 无焦点和有焦点的带有标签状态的 TextInputEditText 字段
-
现在,我们必须在我们的活动中添加与视图的交互。布局本身并不能做任何事情,除了允许用户在
EditText
字段中输入文本。在这个阶段点击按钮不会做任何事情。你将通过在按钮按下时捕获输入的文本,并使用表单字段的 ID 来使用这些文本填充一个TextView
消息来完成这个任务。 -
打开
MainActivity
并完成下一步,以处理输入的文本,并使用这些数据来显示问候语和处理任何表单输入错误。 -
在
onCreate
函数中,在按钮上设置一个ClickListener
,这样我们就可以响应用件点击并通过更新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.example.myapplication.R 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() //Add code below this line in step 9 to Check names are not empty here: } } }
-
然后,检查修剪后的名称是否不为空,并使用 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}!" }
-
最后,如果表单字段没有正确填写,则显示一条消息:
else { Toast.makeText(this, getString(R.string.please_ enter_a_name), Toast.LENGTH_LONG) .apply { setGravity(Gravity.CENTER, 0, 0) show() } }
指定的Toast
是一个小文本对话框,在主布局上方短暂出现,用于在消失前向用户显示消息。
- 运行应用,在字段中输入文本,并验证当两个文本字段都填写时,会显示问候消息;如果两个字段都没有填写,则会出现一个弹出消息,说明为什么问候没有被设置。你应该看到以下显示的每个案例:
图 1.24 – 填写正确的名称和有错误的 app
完整的练习代码可以在packt.link/UxbOu
查看。
前面的练习介绍了如何通过用户可以填写的EditText
字段添加交互性到你的应用中,添加一个点击监听器来响应用件事件,并执行一些验证。
在布局文件中访问视图
在布局文件中访问视图的常规方法是使用findViewById
和视图的 ID 名称。因此,在setContentView(R.layout.activity_main)
在 Activity 中设置布局之后,enter_button
按钮是通过findViewById<Button>(R.id.enter_button)
语法检索的。
你将在本课程中使用这项技术。谷歌还引入了findViewById
,它创建了一个绑定类来访问视图,并且具有空值和类型安全性的优势。你可以在developer.android.com/topic/libraries/view-binding
上了解更多信息。
进一步的输入验证
验证用户输入是处理用户数据的关键概念,你肯定在填写表单的必填字段时多次看到过它的实际应用。这就是前一个练习在检查用户是否在姓名和姓氏字段中输入了值时所验证的内容。
XML 视图元素中还有其他可用的验证选项。例如,假设你想验证一个字段中输入的 IP 地址。你知道 IP 地址可以是四个由点/句号分隔的数字,其中数字的最大长度为三位。
因此,可以输入字段的最大字符数是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 地址格式,还需要对字符序列以及它们是点/句号还是数字进行额外的验证,但这是为了帮助用户输入正确的字符的第一步。还有一个android:inputType
XML 属性,可以用来指定允许的字符并配置输入选项,例如,android:inputType="textPassword"
确保输入的字符是隐藏的。android:inputType="Phone"
是电话号码的输入方法。
通过从本章获得的知识,让我们从以下活动开始。
活动 1.01 – 创建 RGB 颜色应用
在此活动中,我们将探讨一个使用验证的场景。假设你被分配创建一个应用,展示红色、绿色和蓝色在 RGB 颜色空间中如何相加以创建颜色。
每个 RGB 通道应添加为两个十六进制字符,其中每个字符可以是 0-9 或 A-F 的值。然后,这些值将组合起来产生一个六字符的十六进制字符串,该字符串在应用中以颜色形式显示。
此活动旨在生成一个表单,其中包含可编辑的字段,用户可以为每种颜色添加两个十六进制值。在填写所有三个字段后,用户应点击一个按钮,该按钮将三个值连接起来创建一个有效的十六进制颜色字符串。然后,应将其转换为颜色并在应用的 UI 中显示。
以下步骤将帮助您完成活动:
-
创建一个新的 Android Studio 项目,就像你在练习 1.01中做的那样,为你的应用创建 Android Studio 项目。
-
在布局的顶部添加一个
标题
约束。 -
向用户添加一个简短的说明,说明如何完成表单。
-
添加三个
TextInputLayout
字段,分别包裹三个TextInputEditText
字段,这些字段出现在标题
下方。这些字段应该被约束,以便每个视图位于另一个视图之上(而不是旁边)。将TextInputEditText
字段分别命名为红通道
、绿通道
和蓝通道
,并为每个字段添加限制,只允许输入两个字符和十六进制字符。 -
添加一个按钮,该按钮从三个颜色字段获取输入。
-
在布局中添加一个显示生成的颜色的视图。
-
最后,在按钮按下且所有输入有效时,在布局中显示由三个通道创建的 RGB 颜色。
最终输出应如下所示(颜色将根据输入而变化):
图 1.25 – 显示颜色时的输出
注意
该活动的解决方案可在packt.link/By7eE
找到。
注意
当第一次将此课程的 GitHub 仓库中的所有已完成项目加载到 Android Studio 中时,不要使用顶部菜单中的文件 | 打开来打开项目。始终使用文件 | 新建 | 导入项目。这确保了应用程序可以正确构建。在初次导入后打开项目,您可以使用文件 | 打开或文件 | 打开最近的项目。
摘要
本章涵盖了大量关于 Android 开发基础的内容。您从如何使用 Android Studio 创建 Android 项目开始,然后在虚拟设备上创建并运行了应用程序。
章节随后通过探索AndroidManifest
文件来推进,该文件详细说明了您的应用程序内容和权限模型,接着介绍了 Gradle 以及添加依赖项和构建应用程序的过程。
然后是深入了解 Android 应用程序的细节以及文件和文件夹结构。介绍了布局和视图,并通过练习迭代来展示如何使用 Google 的 Material Design 构建 UI。
下一章将在此基础上学习活动生命周期、活动任务和启动模式,屏幕间的数据持久化和共享,以及如何在应用程序中创建健壮的用户旅程。
mdpi | hdpi | xhdpi | xxhdpi | xxxhdpi |
---|
| |
|
|
|
|
第二章:构建用户屏幕流程
本章涵盖了 Android 活动生命周期,并解释了 Android 系统如何与您的应用交互。在本章结束时,您将学会如何通过不同的屏幕构建用户旅程。您还将能够使用活动任务和启动模式,保存和恢复活动状态,使用日志报告您的应用程序,以及在不同屏幕之间共享数据。
上一章向您介绍了 Android 开发的核心理念,从使用AndroidManifest.xml
文件配置您的应用,处理简单的活动,以及 Android 资源结构,到使用gradle
构建应用和在虚拟设备上运行应用。
在本章中,您将进一步学习 Android 系统如何通过 Android 生命周期与您的应用交互,您如何被通知应用状态的变化,以及您如何使用 Android 生命周期来响应这些变化。
接下来,您将学习如何通过您的应用创建用户旅程以及如何在屏幕之间共享数据。您将介绍不同的技术来实现这些目标,以便您可以在自己的应用中使用它们,并在看到其他应用中使用它们时能够识别它们。
本章我们将涵盖以下主题:
-
活动生命周期
-
保存和恢复活动状态
-
活动与意图的交互
-
意图、任务和启动模式
技术要求
本章所有练习和活动的完整代码可在 GitHub 上找到,链接为packt.link/PmKJ6
活动生命周期
在上一章中,我们使用了onCreate(saveInstanceState: Bundle?)
方法在屏幕的 UI 中显示布局。现在,我们将更详细地探讨 Android 系统如何与您的应用交互以实现这一点。一旦活动启动,它将经历一系列步骤,从准备显示到部分显示,然后完全显示。
此外,还有与您的应用被隐藏、后台运行然后销毁相对应的步骤。这个过程被称为活动生命周期。对于这些步骤中的每一个,都有一个回调,您的活动可以使用它来执行在应用被置于后台时创建和更改显示以及保存数据,当应用回到前台后恢复这些数据的操作。
这些回调是在您的活动父级上进行的,您需要决定是否需要在您的活动中实现它们以执行任何相应的操作。每个这些回调函数都有override
关键字。在 Kotlin 中,override
关键字意味着这个函数要么提供了一个接口或抽象方法的实现,要么,在您当前的活动这里,作为一个子类,它提供了一个将覆盖其父类的实现。
现在您已经了解了 Activity 生命周期的一般工作原理,让我们更详细地探讨您将按顺序工作的主要回调,从创建 Activity 到 Activity 被销毁:
override fun onCreate(savedInstanceState: Bundle?)
: 这是您将最常用于绘制全屏活动的回调。在这里,您准备 Activity 布局以供显示。在这个阶段,方法完成后,它仍然没有显示给用户,尽管如果您没有实现任何其他回调,它看起来会是这样。您通常在这里通过调用setContentView(R.layout.activity_main)
方法来设置 Activity 的 UI,并执行任何所需的初始化。
在其生命周期中,此方法只会被调用一次,除非 Activity 再次被创建。对于某些操作(如将手机从纵向旋转到横向)默认情况下会发生这种情况。Bundle?
类型的savedInstanceState
参数(?
表示该类型可以为 null)在最简单的形式中是一个优化了保存和恢复数据的键值对映射。
如果这是应用程序启动后第一次运行 Activity,或者 Activity 是第一次被创建,或者 Activity 在没有保存任何状态的情况下被重新创建,那么它将是空的。
-
override fun onRestart()
: 当 Activity 重新启动时,这个方法会在onStart()
之前立即被调用。了解重启 Activity 和重新创建 Activity 之间的区别很重要。当 Activity 被按下主页按钮置于后台,然后再次进入前台时,onRestart()
会被调用。重新创建 Activity 发生在配置更改时,例如设备旋转。Activity 结束时会被重新创建,在这种情况下,onRestart()
不会被调用。 -
override fun onStart()
: 这是当 Activity 从后台被带到前台时第一次被调用的回调。 -
override fun onRestoreInstanceState(savedInstanceState: Bundle?)
: 如果已经使用onSaveInstanceState(outState: Bundle?)
保存了状态,那么在onStart()
之后,系统会调用此方法,您可以在其中检索Bundle
状态,而不是使用onCreate(savedInstanceState: Bundle?)
恢复状态。 -
override fun onResume()
: 这个回调是在第一次创建 Activity 的最后阶段运行的,也是当应用从后台被带到前台时。完成此回调后,屏幕/活动就准备好被使用,接收用户事件,并做出响应。 -
override fun onSaveInstanceState(outState: Bundle?)
: 如果你想保存 Activity 的状态,这个函数可以做到。你可以使用一个方便的函数来添加键值对,具体取决于数据类型。如果 Activity 在onCreate(saveInstanceState: Bundle?)
和onRestoreInstanceState(savedInstanceState: Bundle?)
中被重新创建,数据将可用。 -
override fun onPause()
: 当 Activity 开始变为后台或者另一个对话框或 Activity 进入前台时,会调用此函数。 -
override fun onStop()
: 当 Activity 被隐藏时,无论是由于它正在变为后台还是另一个 Activity 在它之上启动,都会调用此函数。 -
override fun onDestroy()
: 当系统资源低时,系统会调用此方法来结束 Activity,当 Activity 被显式调用finish()
方法,或者更常见的是,当用户通过关闭应用从最近/概览按钮关闭应用时,Activity 会被杀死。
回调/事件的流程在以下图中展示:
图 2.1 – Activity 生命周期
现在你已经了解了这些常见的生命周期回调的作用,让我们实现它们来看看它们何时被调用。
练习 2.01 – 记录 Activity 回调
创建一个名为Activity Callbacks
的应用程序,其中包含一个空的 Activity。这个练习的目的是记录 Activity 回调以及它们发生的顺序:
- 为了验证回调的顺序,让我们在每个回调的末尾添加一个日志语句。打开
MainActivity
,通过添加import android.util.Log
到import
语句中来准备 Activity 的日志记录。然后,在类中添加一个常量来标识你的 Activity。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
代表调试。有六个不同的日志级别可以用来输出从最不重要到最重要的消息信息 – v
代表详细,d
代表调试,i
代表信息,w
代表警告,e
代表错误,wtf
代表多么糟糕的失败(这个最后的日志级别强调了不应该发生的异常):
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")
-
现在,让我们看看在 Android Studio 中日志是如何显示的。打开Logcat窗口。可以通过点击屏幕底部的Logcat标签或通过工具栏中的视图 | 工具窗口 | Logcat来访问。
-
在虚拟设备上运行应用并检查 Logcat 窗口的输出。你应该看到像 图 2**.2 中的以下行格式化的日志语句。如果 Logcat 窗口看起来不同,你可能需要通过转到 Android Studio | 设置 | 实验性 并勾选说 启用新 Logcat 工具窗口 的复选框来启用 Logcat 的新版本。
图 2.2 – Logcat 中的日志输出
-
日志语句一开始可能很难理解,所以让我们将以下语句分解为其独立的各个部分:
2023-01-14 16:47:12.330 26715-26715/com.example.activitycallbacks/D/onCreate
让我们详细检查日志语句的元素:
字段 | 值 |
---|---|
日期 | 2023-01-14 |
时间 | 16:47:12.330 |
进程标识符和线程标识符(你的应用进程 ID 和当前线程 ID) | 26715-26715 |
类名 | MainActivity |
包名 | com.example.activitycallbacks |
日志级别 | D (用于调试) |
日志消息 | onCreate |
图 2.3 – 解释日志语句的表格
默认情况下,在日志过滤器(日志窗口上方的文本框中),显示 package:mine
,这是你的应用日志。你可以通过更改日志过滤器从 level:debug
到下拉菜单中的其他选项来检查设备上所有进程的不同日志级别的输出。如果你选择 level:verbose
,正如其名所示,你会看到大量的输出。
- 日志语句的
tag
选项很棒的地方在于,它允许你过滤出在tag
后跟的文本标签中报告的日志语句,如 图 2**.4 所示:
图 2.4 – 通过标签名过滤日志语句
因此,如果你正在调试 Activity 中的问题,你可以输入标签名并在你的 Activity 中添加日志以查看日志语句的顺序。这就是你接下来将要通过实现主要 Activity 回调并在每个回调中添加日志语句来查看它们何时运行要做的。
- 在
onCreate(savedInstanceState: Bundle?)
函数的闭合花括号后的一行放置你的光标,然后添加带有日志语句的onRestart()
回调。确保调用super.onRestart()
以确保 Activity 回调的现有功能按预期工作:
override fun onRestart() {
super.onRestart()
Log.d(TAG, "onRestart")
}
注意
在 Android Studio 中,你可以开始输入函数的名称,并会弹出自动完成选项,提供要覆盖的函数的建议。或者,如果你转到顶部菜单,然后选择 代码 | 生成 | 覆盖方法,你可以选择要覆盖的方法。
对以下所有回调函数都这样做:
onCreate(savedInstanceState: Bundle?)
onRestart()
onStart()
onRestoreInstanceState(savedInstanceState: Bundle)
onResume()
onPause()
onStop()
onSaveInstanceState(outState: Bundle)
onDestroy()
- 完成后的活动将用你的实现覆盖回调,并添加日志消息。以下截断的代码片段显示了
onCreate(savedInstanceState: Bundle?)
中的日志语句。完整的类可在packt.link/Lj2GT
找到:
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"
}
}
- 运行应用,然后一旦加载,如图 2.5所示,查看Logcat输出;你应该看到以下日志语句(这是一个简化的版本):
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onResume
活动已经创建、启动,并准备让用户与之交互:
图 2.5 – 应用已加载并显示 MainActivity
在模拟器窗口中虚拟设备上方的导航控制中心的圆形主页按钮上按下,并将应用置于后台。并非所有设备都使用相同的三个按钮导航,即后退(三角形图标)、主页(圆形图标)和最近的应用/概览(方形图标)。也可以启用手势导航,这样所有这些操作都可以通过滑动和可选地按住来实现。你现在应该能看到以下Logcat输出:
D/MainActivity: onPause
D/MainActivity: onStop
D/MainActivity: onSaveInstanceState
对于针对 Android Pie(API 28)以下版本的应用,onSaveInstanceState(outState: Bundle)
也可能在onPause()
或onStop()
之前被调用。
- 现在,通过按下模拟器控制中的最近的应用/概览方形按钮并选择应用,将应用恢复到前台。你现在应该看到以下内容:
D/MainActivity: onRestart
D/MainActivity: onStart
D/MainActivity: onResume
活动已经重新启动。你可能已经注意到onRestoreInstanceState(savedInstanceState: Bundle)
函数没有被调用。这是因为活动没有被销毁和重新创建。
- 再次按下最近的应用/概览方形按钮,然后向上滑动应用图像以结束活动。这是输出:
D/MainActivity: onPause
D/MainActivity: onStop
D/MainActivity: onDestroy
- 再次启动你的应用,然后旋转手机。你可能发现手机没有旋转,显示是侧置的。如果发生这种情况,向下拖动虚拟设备顶部的状态栏,寻找一个带有矩形图标和箭头的按钮,称为自动旋转,并选择它。
图 2.6 – 已选择 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
请注意,如第 9 步所述,onSaveInstanceState(outState: Bundle)
回调的顺序可能不同。
配置更改,如旋转手机,默认会重新创建活动。你可以选择不在应用中处理某些配置更改,这样活动就不会重新创建。
- 要在旋转时不重新创建活动,将
android:configChanges="orientation|screenSize|screenLayout"
添加到AndroidManifest.xml
文件中的MainActivity
。启动应用,然后旋转手机,你将看到的只有添加到MainActivity
的这些回调:
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onResume
orientation
和 screenSize
值在不同的 Android API 级别上检测屏幕方向变化的功能相同。screenLayout
值检测可能在可折叠手机上发生的其他布局更改。
这些是你可以选择自己处理的一些配置更改(另一个常见的是 keyboardHidden
以响应键盘访问的变化)。应用将通过以下回调由系统通知这些更改:
override fun onConfigurationChanged(newConfig:
Configuration) {
super.onConfigurationChanged(newConfig)
Log.d(TAG, "onConfigurationChanged")
}
如果你将这个回调函数添加到 MainActivity
,并且你在清单文件中的 Main
Activity 上添加了 android:
**configChanges="orientation|screenSize|screenLayout"`,你将看到它在旋转时被调用。
这种不重新启动活动的方法不建议使用,因为系统不会自动应用替代资源。因此,从纵向旋转到横向不会应用合适的横向布局。
在这个练习中,你了解了主要的 Activity 回调以及当用户通过系统与 MainActivity
的交互执行常见操作时它们是如何运行的。在下一节中,我们将介绍保存状态和恢复状态,以及查看更多 Activity 生命周期的工作示例。
保存和恢复 Activity 状态
在本节中,你将探索你的 Activity 如何保存和恢复状态。正如你在上一节中学到的,配置更改,如旋转手机,会导致 Activity 被重新创建。这也可能发生在系统需要杀死你的应用以释放内存的情况下。
在这些情况下,保留 Activity 的状态然后恢复它非常重要。在接下来的两个练习中,你将通过一个示例来确保当 TextView
创建并从用户填写表单后的数据中填充时,用户的数据被恢复。
练习 2.02 – 在布局中保存和恢复状态
在这个练习中,首先创建一个名为 Save and Restore
的应用程序,并包含一个空活动。你将要创建的应用程序将包含一个简单的表单,如果用户输入一些个人信息(实际上不会将任何信息发送到任何地方,所以你的数据是安全的),将提供用户的喜爱餐厅的折扣代码:
-
打开
strings.xml
文件(位于app
|src
|main
|res
|values
|strings.xml
),并添加以下字符串,这些字符串是你应用所需的:<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">Hey %s! Here is your discount code</string> <string name="add_text_validation">Please fill in all form fields</string>
-
在
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="4dp" android:layout_marginTop="4dp" tools:context=".MainActivity"> <TextView android:id="@+id/header_text" android:gravity="center" android:textSize="20sp" android:paddingStart="8dp" android:paddingEnd="8dp" 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>
你会看到这里 android:textSize
是以 sp
指定的,它代表无缩放像素。这种单位类型表示与密度无关像素相同的值,它根据运行应用程序的设备的密度定义大小测量,并根据用户在 设置 | 显示 | 字体样式(这可能是 字体大小和样式 或类似的内容,具体取决于你使用的确切设备)中定义的首选项更改文本大小。
布局中的其他属性会影响定位。最常见的是填充和边距。填充应用于视图的内部,是文本和边框之间的空间。边距在视图的外部指定,是视图外边缘的空间。例如,android:padding
使用指定的值在所有边上设置视图的填充。或者,你可以使用 android:paddingTop
、android:paddingBottom
、android:paddingStart
和 android:paddingEnd
指定视图四边之一的填充。这种模式也存在于指定边距,因此 android:layout_margin
为视图的所有四边指定边距值,而 android:layout_marginTop
、android:layout_marginBottom
、android:layout_marginStart
和 android:layout_marginEnd
允许为单个边设置边距。
为了在整个应用程序中使用这些定位值保持一致性和统一性,你可以将边距和填充值定义为包含在 dimens.xml
文件中的尺寸,这样它们就可以在多个布局中使用。例如,<dimen name="grid_4">4dp</dimen>
可以用作视图属性,如下所示:android:paddingStart="@dimen/grid_4"
。为了在视图内定位内容,你可以指定 android:gravity
。center
值在视图内垂直和水平方向上约束内容。
-
接下来,在
header_text
下方添加三个EditText
视图,供用户添加他们的名字、姓氏和电子邮件地址:<EditText android:id="@+id/first_name" android:textSize="20sp" android:layout_marginStart="24dp" android:layout_marginEnd="16dp" 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="20sp" android:layout_marginEnd="24dp" 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="20sp" android:layout_marginStart="24dp" android:layout_marginEnd="32dp" 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"
,不会强制在表单字段中添加 @
符号,但会向键盘提供提示以显示它。
-
最后,添加一个按钮供用户按下以生成折扣代码,一个
TextView
用于显示折扣代码,以及一个TextView
用于显示确认消息:<Button android:id="@+id/discount_button" android:textSize="20sp" android:layout_marginTop="12dp" 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="20sp" android:paddingStart="16dp" android:paddingEnd="16dp" android:layout_marginTop="8dp" 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="20sp" android:textStyle="bold" android:layout_marginTop="8dp" 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 布局文件顶部指定的 xmlns:tools="http://schemas.android.com/tools"
工具命名空间,可以启用某些功能,这些功能可以在创建你的应用程序时用于辅助配置和设计。
当你构建应用时,这些属性会被移除,因此它们不会对应用的整体大小产生影响。你正在使用tools:text
属性来显示通常会在表单字段中显示的文本。这有助于你在 Android Studio 中从查看Code
视图中的 XML 切换到Design
视图时,可以看到布局在设备上的近似显示效果。
- 运行应用,你应该会看到图 2.7中显示的输出。GET DISCOUNT按钮尚未启用,因此目前不会执行任何操作。
图 2.7 – 首次启动的活动屏幕
- 在每个表单字段中输入一些文本:
图 2.8 – 填写好的 EditText 字段
- 现在,使用虚拟设备控制中的第二个旋转按钮(
)将手机向右旋转 90 度:
图 2.9 – 虚拟设备切换到横屏方向
你能发现发生了什么吗?EditText
字段,如果它们设置了 ID,Android 框架将保留字段的当前状态。
-
返回到
activity_main.xml
布局文件,并为位于First Name
EditText
下方出现的Last Name
EditText
添加一个 ID:<EditText android:id="@+id/first_name" <EditText android:id="@+id/last_name" …
当你再次运行应用并旋转设备时,它将保留你输入的值。你现在已经看到,你需要为EditText
字段设置 ID 以保留状态。对于EditText
字段,当用户在表单中输入详细信息时,在配置更改时保留状态是常见的,这样如果字段有 ID,它就是默认行为。
显然,你希望在用户输入一些文本后获取EditText
字段的详细信息,这就是为什么你设置了 ID,但为其他字段类型,如TextView
设置 ID,如果你更新它们并且需要自己保存状态,则不会保留状态。为允许滚动的视图设置 ID,如RecyclerView
,也很重要,因为它可以在 Activity 重新创建时保持滚动位置。
现在,你已经定义了屏幕的布局,但你还没有添加创建和显示折扣代码的逻辑。在下一个练习中,我们将处理这个问题。
本练习中创建的布局可在packt.link/ZJleK
找到。
你可以在packt.link/Kh0kR
找到整个练习的代码。
练习 2.03 – 使用回调保存和恢复状态
这个练习的目的是在用户输入数据后,将布局中的所有 UI 元素组合起来生成折扣代码。为了做到这一点,您必须在按钮中添加逻辑以检索所有 EditText
字段,并向用户显示确认信息,以及生成折扣代码:
-
打开
MainActivity.kt
并将其内容替换为以下内容:package com.example.saveandrestore import android.content.Context import android.os.Bundle import android.util.Log import android.view.inputmethod.InputMethodManager import android.widget.Button import android.widget.EditText import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import java.util.* class MainActivity : AppCompatActivity() { private val discountButton: Button get() = findViewById(R.id.discount_button) private val firstName: EditText get() = findViewById(R.id.first_name) private val lastName: EditText get() = findViewById(R.id.last_name) private val email: EditText get() = findViewById(R.id.email) private val discountCodeConfirmation: TextView get() = findViewById(R.id.discount_code_ confirmation) private val discountCode: TextView get() = findViewById(R.id.discount_code) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) Log.d(TAG, "onCreate") // here we handle the Button onClick event discountButton.setOnClickListener { val firstName = firstName.text.toString(). trim() val lastName = lastName.text.toString(). trim() val email = email.text.toString() if (firstName.isEmpty() || lastName.isEmpty() || email.isEmpty()) { Toast.makeText(this, getString(R.string. add_text_validation), Toast.LENGTH_LONG) .show() } else { val fullName = firstName.plus(" ") .plus(lastName) discountCodeConfirmation.text = getString(R.string.discount_code_ confirmation, fullName) // Generates discount code discountCode.text = UUID.randomUUID(). toString().take(8).uppercase() hideKeyboard() } } } private fun hideKeyboard() { if (currentFocus != null) { val imm = getSystemService(Context.INPUT_ METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(currentFocus?. windowToken, 0) } } companion object { private const val TAG = "MainActivity" } }
get() = …
是一个属性的定制访问器。
点击折扣按钮后,您从 first_name
和 last_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
包生成的。这创建了一个唯一的 ID,然后使用 take()
Kotlin 函数获取前八个字符,在将它们设置为 uppercase 之前。最后,在视图中设置 discountCode
,隐藏键盘,并将所有表单字段重置为其初始值。
- 运行应用并在姓名和电子邮件字段中输入一些文本,然后点击
GET DISCOUNT
:
图 2.10 – 用户生成折扣代码后显示的屏幕
应用程序按预期运行,显示确认信息。
- 现在,通过在模拟器控制中按第二个旋转按钮 (
) 来旋转手机,并观察结果:
图 2.11 – 折扣代码不再显示在屏幕上
哦,不!折扣代码不见了。TextView
字段不会保留状态,因此您必须自己保存状态。
-
返回
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") }
这些回调,正如其名称所表明的,使您能够保存和恢复实例状态。on
SaveInstanceState(outState: Bundle) 允许您在 Activity 被置于后台或销毁时添加键值对,您可以在 onCreate(savedInstanceState: Bundle?)
或 onRestoreInstanceState
(savedInstanceState: Bundle)
中检索这些键值对。
因此,您有两个回调来检索已设置的状态。如果您在 onCreate(savedInstanceState: Bundle)
中进行了大量的初始化,那么在 Activity 被重新创建时使用 onRestoreInstanceState(savedInstanceState: Bundle)
来检索这个实例状态可能更好。这样,可以清楚地知道哪个状态正在被重新创建。然而,如果您需要的设置很少,您可能更喜欢使用 onCreate(savedInstanceState: Bundle)
。
无论你决定使用哪一个回调,你都将不得不获取在onSaveInstanceState(outState: Bundle)
调用中设置的状态。对于练习的下一步,你将使用onRestoreInstanceState(savedInstanceState: Bundle)
。
-
在
MainActivity
的伴生对象中添加两个常量,该对象位于MainActivity
的底部:private const val DISCOUNT_CONFIRMATION_MESSAGE = "DISCOUNT_CONFIRMATION_MESSAGE" private const val DISCOUNT_CODE = "DISCOUNT_CODE"
-
现在,将这些常量作为要保存和检索的值的键,并在
onSaveInstanceState(outState: Bundle)
和onRestoreInstanceState(savedInstanceState: Bundle)
函数中对 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()) }
-
运行应用,将值输入到
EditText
字段中,然后生成一个折扣码。然后旋转设备,你将看到折扣码在图 2.12*中恢复显示:
图 2.12 – 折扣码继续在屏幕上显示
在这个练习中,你首先看到了在配置更改时如何维护EditText
字段的状态。你还使用了活动生命周期函数onSaveInstanceState(outState: Bundle)
和onCreate(savedInstanceState: Bundle?)
/onRestoreInstanceState(savedInstanceState: Bundle)
来保存和恢复实例状态。这些函数提供了一种保存和恢复简单数据的方式。Android 框架还提供了ViewModel
,这是一个生命周期感知的 Android 架构组件。如何保存和恢复此状态(使用ViewModel
)的机制由框架管理,因此你不需要像前面示例中那样显式管理它。你将在第十一章,Android 架构组件中学习如何使用此组件。
本练习的完整代码可以在以下链接找到:packt.link/zsGW3
。
在下一节中,你将为应用添加另一个 Activity 并在活动之间进行导航。
Activity 与 Intents 的交互
Android 中的 intent 是一种组件间的通信机制。在你的应用内部,很多时候,当当前活动发生某些操作时,你将希望启动另一个特定的 Activity。指定确切哪个 Activity 将启动被称为从上一个练习中的AndroidManifest.xml
文件,你将看到在MainActivity
的<intent-filter>
XML 元素中设置了两个 intent 过滤器示例:
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category. LAUNCHER"/>
</intent-filter>
其中指定为 <action android:name="android.intent.action.MAIN" />
的意味着这是进入应用的主要入口点。根据设置的哪个类别,它控制当应用启动时哪个 Activity 首先启动。另一个指定的 intent 过滤器是 <category android:name="android.intent.category.LAUNCHER" />
,它定义了应用应该出现在启动器中。当结合使用时,这两个 intent 过滤器定义了当从启动器启动应用时,MainActivity
应该启动。移除 <action android:name="android.intent.action.MAIN" />
intent 过滤器会导致出现 "Error running 'app': Default Activity not found"
信息。由于应用没有主入口点,因此无法启动。如果您移除 <category android:name="android.intent.category.LAUNCHER" />
,则没有地方可以从中启动。
在下一个练习中,您将看到 intents 如何在您的应用中导航工作。
练习 2.04 – Intent 简介
本练习的目标是创建一个简单的应用,使用 intents 根据用户的输入向用户显示文本:
-
在 Android Studio 中创建一个新的项目,命名为
Intents Introduction
并选择一个空 Activity。一旦设置好项目,转到工具栏并选择File
|New
|Activity
|Empty
Activity
。将其命名为WelcomeActivity
并保留所有其他默认设置。它将被添加到AndroidManifest.xml
文件中,以便使用。您现在添加了WelcomeActivity
后遇到的问题是不知道如何使用它。MainActivity
在您启动应用时启动,但您需要一种方法来启动WelcomeActivity
,并且可选地传递数据给它,这就是您使用 intent 的时候。 -
为了完成此示例,请将以下字符串添加到
strings.xml
文件中。<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>
-
接下来,在
activity_main.xml
中更改MainActivity
布局,并用以下代码替换内容以添加一个EditText
和一个Button
:<?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:padding="28dp" android:layout_height="match_parent" tools:context=".MainActivity"> <EditText android:id="@+id/full_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="28sp" android:hint="@string/full_name_label" android:layout_marginBottom="24dp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"/> <Button android:id="@+id/submit_button" android:textSize="24sp" 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"/> </androidx.constraintlayout.widget.ConstraintLayout>
应用程序运行时看起来如 图 2.13 所示:
图 2.13 – 添加了 EditText 全名字段和提交按钮后的应用显示
您现在需要配置按钮,以便当它被点击时,从 EditText
字段中检索用户的全名,并将其通过 intent 发送,从而启动 WelcomeActivity
。
-
更新
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" tools:context=".WelcomeActivity"> <TextView android:id="@+id/welcome_text" android:textSize="24sp" android:padding="24sp" 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
字段来显示用户的全名,并带有欢迎信息。创建全名和欢迎信息的逻辑将在下一步展示。
-
现在,打开
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(){ … }
您将使用该常量来设置在 intent 中保存用户全名的键。
-
然后,将以下代码添加到
onCreate(savedInstanceState: Bundle?)
的底部:findViewById<Button>(R.id.submit_button).setOnClickListener { val fullName = findViewById<EditText>(R.id.full_ name).text.toString() if (fullName.isNotEmpty()) { // Set the name of the Activity to launch val welcomeIntent = Intent(this, WelcomeActivity::class.java) welcomeIntent.putExtra(FULL_NAME_KEY, fullName) startActivity(welcomeIntent) } else { Toast.makeText(this, getString( R.string.full_name_label), Toast.LENGTH_LONG).show() } }
有逻辑可以检索全名值并验证用户是否已填写此信息;如果没有填写,将显示一个弹出提示消息。然而,主要逻辑是获取 EditText
字段的 fullName
值,创建一个显式意图以启动 WelcomeActivity
,然后将一个 Extra
键及其值放入 Intent 中。最后一步是使用该意图启动 WelcomeActivity
。
- 现在,运行应用,输入你的名字,然后按 提交,如图 图 2.14 所示:
图 2.14 – 当未处理意图额外数据时显示的默认屏幕
嗯,这并不太令人印象深刻。你已经添加了发送用户名的逻辑,但没有显示它。
-
要启用此功能,请打开
WelcomeActivity
并将导入import android.widget.TextView
添加到导入列表中,并在onCreate(savedInstanceState: Bundle?)
的底部添加以下内容:if (intent != null) { val fullName = intent.getStringExtra(FULL_NAME_KEY) findViewById<TextView>(R.id.welcome_text).text = getString(R.string.welcome_text, fullName) }
我们检查启动 Activity 的意图是否不为空,然后通过获取字符串 FULL_NAME_KEY
额外键从 MainActivity
意图中检索字符串值。然后,我们通过从资源中获取字符串并将从意图中检索到的 fullname
值传递进去来格式化 <string name="welcome_text">Hello %s, we hope you enjoy using the app!</string>
资源字符串。最后,将此设置为 TextView
的文本。
- 再次运行应用,将显示一个简单的问候语,如图 图 2.15 所示:
图 2.15 – 用户欢迎信息显示
虽然这个练习在布局和用户交互方面非常简单,但它允许展示意图的一些核心原则。你将使用它们来添加导航并从你的应用的一个部分创建到另一个部分的用户流程。在下一节中,你将看到如何使用意图启动 Activity 并从其中获取结果。
练习 2.05 – 从 Activity 中检索结果
对于某些用户流程,你将只为从 Activity 中获取结果而启动 Activity。这种模式通常用于请求使用特定功能,弹出对话框询问用户是否允许访问联系人、日历等,然后向调用 Activity 报告是或否的结果。在这个练习中,你将要求用户选择彩虹的最喜欢的颜色,一旦选择,就在调用 Activity 中显示结果:
-
创建一个名为
Activity Results
的新项目,包含一个空 Activity,并将以下字符串添加到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>
-
将以下颜色添加到
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>
-
现在,你必须设置将在
MainActivity
中设置结果的 Activity。转到RainbowColorPickerActivity
。 -
更新
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" tools:context=".MainActivity"> <TextView android:id="@+id/header_text" android:textSize="20sp" android:padding="10dp" android:gravity="center" 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" android:textSize="18sp" 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" android:layout_width="320dp" android:layout_height="50dp" android:layout_margin="12dp" android:textSize="22sp" android:textColor="@android:color/white" android:gravity="center" android:visibility="gone" tools:visibility="visible" app:layout_constraintTop_toBottomOf="@id/submit_ button" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" tools:text="BLUE is your favorite color" tools:background="@color/blue"/> </androidx.constraintlayout.widget.ConstraintLayout>
-
你将使用
registerForActivityResult(ActivityResultContracts.StartActivityForResult())
函数从你启动的活动获取结果。为我们在 intent 中想要使用的值添加两个常量键,并在MainActivity
的类标题上方添加一个默认颜色常量,并更新导入,使其显示如下,包括包名和导入:package com.example.activityresults import android.content.Intent import android.graphics.Color import android.os.Bundle import android.widget.Button import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.isVisible const val RAINBOW_COLOR_NAME = "RAINBOW_COLOR_NAME" // Key to return rainbow color name in intent const val RAINBOW_COLOR = "RAINBOW_COLOR" // Key to return rainbow color in intent const val DEFAULT_COLOR = "#FFFFFF" // White class MainActivity : AppCompatActivity()…
-
然后,在类标题下方创建一个属性,用于启动新活动并从中返回结果:
private val startForResult = registerForActivityResult(ActivityResultContracts. StartActivityForResult()) { activityResult -> val data = activityResult.data 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 }
一旦返回结果,你可以继续查询期望的 intent 数据。对于这个练习,我们想要获取背景颜色名称(colorName
)和颜色的十六进制值(backgroundColor
),以便我们可以显示它。?
操作符检查值是否为 null(即在 intent 中未设置),如果是,则 Elvis 操作符(?:
)设置默认值。颜色消息使用字符串格式化来设置消息,将资源值中的占位符替换为颜色名称。现在你已经得到了颜色,你可以使 rainbow_color
TextView
字段可见,并将视图的背景颜色设置为 backgroundColor
,并添加显示用户彩虹色最喜欢的颜色名称的文本。
-
首先,将启动 Activity 的逻辑添加到之前定义的属性中,方法是在
onCreate(savedInstanceState: Bundle?)
的底部添加以下代码:findViewById<Button>(R.id.submit_button) .setOnClickListener { startForResult.launch(Intent(this, RainbowColorPickerActivity::class.java) ) }
这将创建一个用于返回结果的 Intent:Intent(this, RainbowColorPickerActivity::class.java)
。
-
对于
RainbowColorPickerActivity
活动的布局,你将显示一个带有背景颜色和颜色名称的按钮,每个彩虹的七种颜色:RED
、ORANGE
、YELLOW
、GREEN
、BLUE
、INDIGO
和VIOLET
。这些将在一个LinearLayout
垂直列表中显示。对于本书中的大多数布局文件,你将使用ConstraintLayout
,因为它提供了对单个视图的精细定位。对于需要显示少量垂直或水平列表的情况,LinearLayout
也是一个不错的选择。如果你需要显示大量项目,那么RecyclerView
是更好的选择,因为它可以缓存单个行的布局并回收屏幕上不再显示的视图。你将在 第六章,RecyclerView 中了解RecyclerView
。 -
在
RainbowColorPickerActivity
中,你需要做的第一件事是创建布局。这将是你向用户展示选择他们彩虹色最喜欢的颜色的选项的地方。 -
打开
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
只能接受一个子视图,即要滚动的布局。
-
接下来,在
ScrollView
内添加LinearLayout
以按添加顺序显示包含的视图,包括页眉和页脚。第一个子视图是一个带有页面标题的页眉,最后添加的视图是一个带有用户选择颜色说明的页脚:<LinearLayout 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" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="10dp" android:padding="10dp" android:text="@string/header_text_picker" android:textAllCaps="true" android:textSize="24sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:layout_width="380dp" android:layout_height="wrap_content" android:gravity="center" android:padding="10dp" android:text="@string/footer_text_picker" android:textSize="20sp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </LinearLayout>
布局现在应该看起来与应用中的图 2.16一样:
图 2.16 – 带有页眉和页脚的彩虹颜色屏幕
-
现在,最后,在页眉和页脚之间添加按钮视图以选择彩虹颜色,然后运行应用(以下代码仅显示第一个按钮)。完整的布局可在
packt.link/ZgdHX
找到:<Button android:id="@+id/red_button" android:layout_width="120dp" android:layout_height="wrap_content" android:backgroundTint="@color/red" android:text="@string/red" />
这些按钮按照彩虹的颜色顺序显示,带有颜色文本和背景。XML 中的id
属性是你在 Activity 中准备返回给调用 Activity 的结果。
-
现在,打开
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() } } }
setRainbowColor
函数创建一个意图,并将彩虹颜色名称和彩虹颜色的hex
值作为字符串附加信息添加。然后结果返回给调用 Activity,由于你不再使用此 Activity,你调用finish()
以便显示调用 Activity。检索用户选择的彩虹颜色的方式是在布局中的所有按钮上添加监听器。
-
现在,将以下内容添加到
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
通过使用when
语句确定setRainbowColor(colorName: String, color: Int)
函数要设置的颜色。when
语句相当于 Java 和基于 C 的语言中的switch
语句。它允许一个分支满足多个条件,并且更简洁。在前面的示例中,view.id
与彩虹布局按钮的 ID 匹配,当找到时,执行分支,从字符串资源设置颜色名称和十六进制值以传递给setRainbowColor(colorName: String, color: Int)
。
-
现在,将此点击监听器添加到前面的代码下面的布局中的按钮:
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。
- 现在,运行应用并按下图 2.17中显示的
选择颜色
按钮:
图 2.17 – 彩虹颜色应用启动屏幕
- 现在,选择您最喜欢的彩虹颜色:
图 2.18 – 彩虹颜色选择屏幕
- 一旦您选择了您最喜欢的颜色,就会显示一个带有您最喜欢的颜色的屏幕,如图 图 2.19 所示:
图 2.19 – 显示所选颜色的应用
如您所见,应用在 图 2.19 中显示了您选定的作为您最喜欢的颜色。
这个练习向您介绍了使用 registerFor
ActivityResult 创建用户流程的另一种方法。这在执行需要在使用者通过应用流程之前获取结果的专用任务时非常有用。接下来,您将探索启动模式以及它们在构建应用时对用户旅程流程的影响。
Intents、Tasks 和启动模式
到目前为止,您一直在使用创建 Activity 和从一个 Activity 转移到下一个 Activity 的标准行为。当您以默认行为从启动器打开应用时,它会创建自己的 Task,并且您创建的每个 Activity 都会被添加到一个回退栈中,因此当您作为用户旅程的一部分连续打开三个 Activity 时,按三次返回按钮会将用户带回到之前的屏幕/Activity,然后返回到设备的首页,同时仍然保持应用打开。
这种类型的 Activity 的启动模式称为 Standard
;它是默认的,不需要在 AndroidManifest.xml
的 Activity 元素中指定。即使您连续三次启动相同的 Activity,也会有三个相同的行为实例。
对于某些应用,您可能希望更改此行为,以便使用相同的实例。可以在这里帮助的启动模式称为 singleTop
。如果一个 singleTop
Activity 是最近添加的,当再次启动相同的 singleTop
Activity 时,则不会创建新的 Activity,而是使用相同的 Activity 并运行 onNewIntent
回调。在这个回调中,您会收到一个意图,然后您可以像之前在 onCreate
中所做的那样处理这个意图。
有三种其他启动模式需要了解,称为 singleTask
、singleInstance
和 singleInstancePerTask
。这些不是通用用途,仅用于特殊场景。所有启动模式的详细文档可以在此查看:developer.android.com/guide/topics/manifest/activity-element#lmode
。
在下一个练习中,您将探索 Standard
和 singleTop
启动模式的行为差异。
练习 2.06 – 设置 Activity 的启动模式
这个练习包含了许多不同的布局文件和活动,以说明两种最常用的启动模式。请从packt.link/DQrGI
下载代码:
- 打开
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.xml
和numbers.xml
。
-
打开并检查位于
res
|layout
文件夹中的letters.xml
和numbers.xml
文件。这两个文件非常相似,它们之间的区别仅在于包含的按钮的 ID 以及显示的文本标签。 -
运行应用,你会看到以下屏幕:
图 2.20 – 显示标准模式和单顶模式的 App
为了演示/说明standard
和singleTop
活动启动模式之间的区别,你必须依次启动两个或三个活动。
-
打开
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
主活动和其他活动包含的逻辑基本上是相同的。它显示一个活动,并允许用户按下按钮以相同的逻辑启动另一个活动,即创建一个ClickListener
并将其设置在你在练习 2.05,从 活动 获取结果中看到的按钮上。
-
打开
AndroidManifest.xml
文件,你会看到以下活动显示:<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"/>
你根据主屏幕上的按钮点击来启动一个活动,但字母和数字活动有不同的启动模式,这可以在AndroidManifest.xml
文件中看到。
这里指定了standard
启动模式,以说明standard
和singleTop
之间的区别,但standard
是默认的,如果没有android:launchMode
XML 属性,活动将以这种方式启动。
- 点击
标准
标题下的任意字母,你会看到以下屏幕(带有A、B或C):
图 2.21 – 显示标准活动的 App
-
持续按下任意字母按钮,这将启动另一个活动。日志已添加以显示此启动活动的序列。以下是随机按下 10 个字母活动后的日志:
MainActivity com.example.launchmodes onCreate Activity A com.example.launchmodes onCreate Activity A com.example.launchmodes onCreate Activity B com.example.launchmodes onCreate Activity B com.example.launchmodes onCreate Activity C com.example.launchmodes onCreate Activity B com.example.launchmodes onCreate Activity B com.example.launchmodes onCreate Activity A com.example.launchmodes onCreate Activity C com.example.launchmodes onCreate Activity B com.example.launchmodes onCreate Activity C com.example.launchmodes onCreate
如果你观察前面的日志,每次用户在启动模式下按下字符按钮时,都会启动一个新的字符活动实例并将其添加到返回栈中。
- 关闭应用,确保它不是后台运行(或在最近/概览菜单中),而是真正关闭,然后再次打开应用并按下 Single Top 标题下的一个数字按钮:
图 2.22 – 显示 Single Top 活动的应用
- 按下数字按钮 10 次,但确保在按下另一个数字按钮之前至少连续两次按下相同的数字按钮。
你应该在 Logcat 窗口中看到的日志应该类似于以下内容:
MainActivity com.example.launchmodes onCreate
Activity 1 com.example.launchmodes onCreate
Activity 1 com.example.launchmodes onNewIntent
Activity 2 com.example.launchmodes onCreate
Activity 2 com.example.launchmodes onNewIntent
Activity 3 com.example.launchmodes onCreate
Activity 2 com.example.launchmodes onCreate
Activity 3 com.example.launchmodes onCreate
Activity 3 com.example.launchmodes onNewIntent
Activity 1 com.example.launchmodes onCreate
Activity 1 com.example.launchmodes onNewIntent
你会注意到,当你连续至少两次按下相同的按钮时,不会调用 onCreate
,而是调用 onNewIntent
。如果你按下返回按钮,你会注意到,你只需点击不到 10 次就能退出应用并返回主屏幕,这反映了实际上没有创建 10 个活动。
活动二.01 – 创建登录表单
这个活动的目的是创建一个带有用户名和密码字段的登录表单。一旦这些字段中的值被提交,将这些输入的值与硬编码的值进行比较,如果匹配,则显示欢迎消息,如果不匹配,则显示错误消息,并将用户返回到登录表单。实现这一目标所需的步骤如下:
-
创建一个包含用户名和密码
EditText
视图以及LOGIN
按钮的表单。 -
将
ClickListener
接口添加到按钮上,以响应按钮点击事件。 -
验证表单字段是否已填写。
-
将提交的用户名和密码字段与硬编码的值进行比较。
-
如果成功,显示带有用户名的欢迎消息并隐藏表单。
-
如果不成功,显示错误消息并将用户重定向回表单。
你可以尝试完成这个活动的一些方法。以下是你可以采用的三种方法的想法:
-
使用
singleTop
活动并发送一个意图路由到相同的活动以验证凭据 -
使用
standard
活动将用户名和密码传递给另一个活动并验证凭据 -
使用
registerForActivityResult
在另一个活动中执行验证,然后返回结果
完成的应用在首次加载时应该看起来像 图 2.23:
图 2.23 – 应用首次加载时的显示
注意
这个活动的解决方案可以在 packt.link/PmKJ6
找到。
摘要
在本章中,您已经涵盖了您应用程序与 Android 框架交互的基础知识,从 Activity 生命周期回调到在活动中保留状态,从一屏幕导航到另一屏幕,以及意图和启动模式如何实现这一切。这些是您为了进入更高级主题所必须理解的核心概念。
在下一章中,您将了解到片段以及它们如何融入您应用程序的架构,同时还将探索更多关于 Android 资源框架的内容。
第三章:使用片段开发 UI
本章涵盖了片段和片段生命周期。它展示了如何使用它们来构建高效且动态的布局,这些布局能够响应不同的屏幕尺寸和配置,并允许你将你的 UI 划分为不同的部分。在本章结束时,你将能够创建静态和动态片段,在片段和活动之间传递数据,并使用 Jetpack Navigation
组件详细说明片段如何组合在一起。
在上一章中,我们探讨了 Android 活动生命周期,并研究了它在应用中用于屏幕间导航的方式。我们还分析了各种启动模式,这些模式定义了屏幕间转换是如何发生的。在本章中,你将探索片段。片段是一个部分、部分,或者正如其名称所暗示的,Android 活动的一部分。
在本章中,你将学习如何使用片段,了解它们可以存在于多个活动中,并发现如何在单个活动中使用多个片段。你将从向活动添加简单的片段开始,然后逐步学习静态和动态片段之间的区别。
可以使用片段来简化为具有较大尺寸的 Android 平板创建布局。例如,如果你有一个平均尺寸的手机屏幕,并且你想包含新闻故事列表,你可能只有足够的空间来显示列表。
如果你在一个平板上查看相同的新闻故事列表,你会拥有更多的可用空间,因此你可以显示相同的列表,并在列表的右侧显示一个故事。屏幕上的每个不同区域都可以使用一个片段。然后你可以在手机和平板上都使用相同的片段。你可以从重用和简化布局中受益,无需重复创建类似的功能。
在探索了片段的创建和使用方法之后,你将学习如何使用片段组织用户旅程。你将应用一些使用片段的既定实践。最后,你将学习如何通过使用 Android Jetpack Navigation
组件创建导航图来简化片段的使用,该组件允许你指定将片段与目的地链接在一起。
本章我们将涵盖以下主题:
-
片段生命周期
-
静态片段和双面板布局
-
动态片段
-
Jetpack 导航
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,网址为 packt.link/KmdBZ
片段生命周期
一个片段是一个具有自己生命周期的组件。理解片段的生命周期至关重要,因为它在片段创建、运行状态和销毁的某些阶段提供回调,这些回调配置了初始化、显示和清理。片段在活动中运行,一个片段的生命周期绑定到活动生命周期。
在许多方面,片段的生命周期与活动生命周期非常相似,乍一看,似乎前者复制了后者。片段生命周期中与活动生命周期相同或类似的回调有很多,例如onCreate(savedInstanceState: Bundle?)
。
片段生命周期与活动生命周期相关联,因此无论在哪里使用片段,片段回调都会与活动回调交织在一起。
初始化片段并准备在用户交互之前显示给用户之前,会执行相同的步骤。当应用处于后台、隐藏或退出时,片段也会经历与活动相同的拆解步骤。片段,就像活动一样,必须扩展/从父Fragment
类派生,你可以根据你的用例选择要覆盖哪些回调。生命周期在以下图中显示,随后是每个函数的更多详细信息。
Figure 3.1 – Fragment 生命周期
让我们现在来探索这些回调,它们出现的顺序,以及它们的作用。
onAttach
override fun onAttach(context: Context)
:这是你的片段与其所使用的活动关联起来的时刻。它允许你引用活动,尽管在这个阶段,片段和活动都还没有完全创建。
onCreate
override fun onCreate(savedInstanceState: Bundle?)
:这是你进行片段初始化的地方。这不是设置片段布局的地方,因为在这一阶段,没有可显示的 UI,也没有像活动中的setContentView
那样的设置。
onCreateView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View?
:现在,这是你创建片段布局的地方。在这里要记住的最重要的事情是,与活动不同,片段实际上会从这个函数返回布局View?
。
在你的布局中看到的视图在这里都可以参考,但也有一些注意事项。你需要在引用其中包含的视图之前创建布局,这就是为什么建议在onViewCreated
中进行视图操作。
onViewCreated
override fun onViewCreated(view View, savedInstanceState: Bundle?)
:这个回调是在片段完全创建和向用户可见之间的一个回调。通常在这里设置视图,并添加任何功能性和交互性到这些视图。这可能包括给按钮添加OnClickListener
,并在点击时调用一个函数。
onActivityCreated
override fun onActivityCreated(context: Context)
: 这是在活动 onCreate
运行后立即调用的。片段的视图状态的大多数初始化都将完成,如果需要,这是进行最终设置的地方。
onStart
override fun onStart()
: 当片段即将对用户可见但用户尚不能与之交互时调用。
onResume
override fun onResume()
: 在这次调用结束时,你的片段可供用户交互。通常,在此回调中定义的设置或功能最少,因为当应用进入后台然后返回前台时,此回调总是会调用。
因此,你不想在可以不运行当片段变得可见时不必要地重复设置片段。
onPause
override fun onPause()
: 与其对应的活动中的 onPause()
一样,onPause()
表示你的应用正在进入后台或被屏幕上的其他内容部分覆盖。使用此回调来保存片段状态的变化。
onStop
override fun onStop()
: 在这次调用结束时,该片段不再可见并进入后台。
onDestroyView
override fun onDestroyView()
: 这通常是在片段被销毁之前进行最终清理时调用的。如果你需要清理任何资源,应使用此回调。如果片段被推送到后台栈并保留,则也可以不销毁片段而调用它。完成此回调后,片段的布局视图将被移除。
onDestroy
override fun onDestroy()
: 正在销毁片段。这可能是因为应用被杀死,或者是因为这个片段被另一个片段替换。
onDetach
override fun onDetach()
: 当片段与其活动分离时调用。
有更多的片段回调,但这些都是你将在大多数情况下使用的。通常,你只会使用这些回调的一部分:onAttach()
将活动与片段关联,onCreate
初始化片段,onCreateView
设置布局,然后 onViewCreated
/onActivityCreated
进行进一步初始化,也许还有 onPause()
进行一些清理。
注意
这些回调的更多详细信息可以在官方文档中找到,网址为 https://developer.android.com/guide/fragments。
现在我们已经了解了片段生命周期的理论以及它是如何受宿主活动生命周期影响的,让我们看看这些回调在实际运行中的情况。
练习 3.01 – 添加基本片段和片段生命周期
在这个练习中,我们将创建并添加一个基本片段到应用中。这个练习的目的是熟悉片段如何添加到活动中以及它们显示的布局。为此,你将在 Android Studio 中创建一个新的空白片段及其布局。然后,你将添加片段到活动中,并通过片段布局的显示来验证片段已被添加。执行以下步骤:
-
在 Android Studio 中创建一个名为
Fragment Lifecycle
的空活动应用。 -
一旦应用构建完成,通过转到
Fragment (Blank)
选项创建一个新的片段。当你选择此选项时,你将看到 图 3**.2 中显示的屏幕:
图 3.2 – 创建新片段
-
将片段重命名为
MainFragment
和布局重命名为fragment_main
。然后,按Fragment
类将被创建并打开。已添加两个函数:onCreate
,用于初始化片段,以及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) }
-
当你打开
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_width
和 layout_height
被指定为 match_parent
,TextView
将占据整个屏幕。然而,文本本身将添加到视图的左上角,使用默认位置。
-
将
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,你将在 图 3**.3 中看到 Hello World! 显示:
图 3.3 – 添加片段前的初始应用布局显示
好吧,你可以看到一些你可能期望的 hello blank fragment
文本。当你创建片段时,片段及其布局不会自动添加到活动中。这是一个手动过程。
-
打开
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="com.example.fragmentlifecycle .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
元素。你已经将片段添加到 ConstraintLayout
中,layout_width
和 layout_height
的约束为 match_parent
,因此它将占据整个屏幕。
在这里需要检查的最重要的 xml
属性是 android:name
。正是在这里,你指定了要添加到布局中的 Fragment
类的完整限定名和包名,使用 com.example.fragmentlifecycle.MainFragment
。现在运行应用,你将看到 图 3**.4 中显示的输出:
图 3.4 – 添加片段后的应用布局显示
这证明了您的文本为Hello blank fragment
的片段已添加到活动中,并且您定义的布局正在显示。接下来,您将检查活动与片段之间的回调方法以及这是如何发生的。
-
打开
MainFragment
类,并在伴随对象中添加一个TAG
常量,其值为"MainFragment"
以标识该类。private const val TAG = "MainFragment"
然后,添加/更新函数并添加适当的日志语句。
您需要将Log
语句和context
的导入添加到类的顶部的导入中。以下代码片段被截断。请按照显示的链接查看您需要使用的完整代码块:
MainFragment.kt
import android.content.Context
import android.util.Log
override fun onAttach(context: Context) {
super.onAttach(context)
Log.d(TAG, "onAttach: ")
}
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
Log.d(TAG,"onCreate: ")
arguments?.let {
param1 = it.getString(ARG_PARAM1)
param2 = it.getString(ARG_PARAM2)
}
}
override fun onCreateView(
inflater: LayoutInflater, container:
ViewGroup?, savedInstanceState: Bundle?
): View? {
Log.d(TAG,"onCreateView: ")
// Inflate the layout for this fragment
return inflater.inflate(
R.layout.fragment_main, container, false)
}
您可以在packt.link/XcOJ4
找到此步骤的完整代码。
您需要在onCreateView
回调中添加Log.d(TAG, "onCreateView")
,在已存在的onCreate
回调中添加Log.d(TAG, "onCreate")
,并重写onAttach
函数,添加Log.d(TAG, "onAttach")
,以及添加onViewCreated
,添加Log.d(TAG, "onViewCreated")
。
-
接下来,打开
MainActivity
类,并添加一个带有TAG
常量的伴随对象,其值为"MainActivity"
。然后,将Log
导入添加到类的顶部,然后添加常见的onStart
和onResume
回调方法,如下面的代码片段所示。import android.util.Log override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) Log.d(TAG, "onCreate") } override fun onStart() { super.onStart() Log.d(TAG, "onStart") } override fun onResume() { super.onResume() Log.d(TAG, "onResume") } companion object { private const val TAG = "MainActivity" }
您会看到您还必须添加onCreate
日志语句Log.d(TAG, "onCreate")
,因为当您将活动添加到项目中时,这个回调已经存在。
您在第二章,构建用户屏幕流程中学习了如何查看日志语句,您将打开 Android Studio 中的Logcat窗口来检查日志以及它们在运行应用时的调用顺序。
在第二章,构建用户屏幕流程中,您查看了一个活动的日志,以便您可以看到它们被调用的顺序。现在,您将检查MainActivity
和MainFragment
回调发生的顺序。
- 打开以
Main
开头的MainActivity
和MainFragment
,您可以在搜索框中键入tag:Main
以过滤日志,只显示包含此文本的语句。运行应用,您应该看到以下内容:
图 3.5 – 启动应用时显示的 Logcat 语句
这里有趣的是,前几个回调来自片段。它通过onAttach
回调与放置在其中的活动链接。片段初始化,并在调用另一个回调onCreateView
之前显示其视图,以确认片段 UI 已准备好显示。
这是在调用活动的 onCreate
方法之前。这很有意义,因为活动根据其包含的内容创建其 UI。由于这是一个定义自己布局的片段,活动需要知道如何在 onCreate
方法中测量、布局和绘制片段。
然后,片段通过 onActivityCreated
回调确认已完成此操作,在片段和活动开始显示 UI 的 onStart
之前,在各自的 onResume
回调完成后准备用户与之交互。
注意
之前详细说明的活动和片段生命周期之间的交互是在静态片段被创建的情况下。对于动态片段,它们可以在活动运行时添加,交互可能会有所不同。
因此,现在片段和包含的活动都已显示,当应用被置于后台或关闭时会发生什么?当片段和活动暂停、停止和完成时,回调仍然交织在一起。
-
将以下回调添加到
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") }
-
然后,将这些回调添加到
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") }
-
构建应用,一旦运行,你将看到之前启动片段和活动的回调。你可以使用
Logcat
窗口左上角的垃圾桶图标来清除语句。然后,旋转应用并查看输出日志语句:
图 3.6 – 关闭应用时显示的 Logcat 语句
onPause
和 onStop
语句正如你所预期的那样,因为片段首先在活动内部被通知这些回调。你可以将其视为从内到外,即子元素在包含它们的父元素之前被通知,因此父元素知道如何响应。
然后,片段被拆解,从活动中移除,并在活动本身被销毁之前,使用 onDestroyView
、onDestroy
和 onDetach
函数被销毁,这是在 onDestroy
中完成任何最终清理之后。在所有组成活动的组件部分被移除之前,活动完成是没有意义的。
在 Android 中,完整的片段生命周期回调以及它们如何与活动回调相关联是复杂的,因为哪些回调在哪种情况下应用可能会有很大的差异。要查看更详细的概述,请参阅官方文档:https://developer.android.com/guide/fragments。
对于大多数情况,你将只使用前面的片段回调。此示例演示了自包含片段在其创建、显示和销毁过程中的独立性,以及它们与包含活动的相互依赖性。
现在我们已经通过一个基本示例了解了如何将片段添加到活动并检查片段与活动之间的交互,让我们看看如何更详细地示例如何将两个片段添加到活动。
练习 3.02 – 将片段静态添加到活动
这个练习将演示如何将两个片段添加到具有自己 UI 和独立功能的活动。你将创建一个简单的计数器类,该类可以增加和减少一个数字,以及一个颜色类,该类可以改变应用于某些 Hello World
文本的程序化颜色。执行以下步骤:
-
在 Android Studio 中创建一个名为
Fragment
Intro
的空活动。然后,在res
|values
|strings.xml
文件中添加以下所需的字符串:<string name="hello_world">Hello World</string> <string name="red_text">Red</string> <string name="green_text">Green</string> <string name="blue_text">Blue</string> <string name="zero">0</string> <string name="plus">+</string> <string name="minus">-</string> <string name="counter_text">Counter</string>
这些字符串在计数器片段以及你将要创建的颜色片段中都会使用。
-
通过前往具有
fragment_counter
布局名称的CounterFragment
来添加一个新的空白片段。 -
现在,修改
fragment_counter.xml
文件。以下代码因空间限制而截断。请遵循显示的链接以获取你需要使用的完整代码:
fragment_counter.xml
<TextView
android:id="@+id/counter_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/counter_text"
android:paddingTop="10dp"
android:textSize="44sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/counter_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/zero"
android:textSize="54sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf=
"@id/counter_text"
app:layout_constraintBottom_toTopOf="@id/plus"/>
你可以在 packt.link/ca4EK
找到此步骤的完整代码。
我们使用了一个简单的 ConstraintLayout
文件,其中设置了 TextView
作为 @+id/counter_text
标题,以及一个 TextView
用于计数器的值,android:id="@+id/counter_value"
(默认为 @string/zero
),它将由 android:id="@+id/plus"
和 android:id="@+id/minus"
按钮更改。
注意
对于这样一个简单的例子,你不会使用 style="@some_style"
符号在视图中设置单个样式,这是最佳实践,以避免在每个视图中重复这些值。
-
现在打开
CounterFragment
并在类标题下方添加一个属性作为计数器(它是一个var
,因此它是可变的,可以更改):var counter = 0
-
现在打开并添加以下
onViewCreated
函数。你还需要在类的顶部添加以下导入:import android.widget.Button import android.widget.TextView override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val counterValue = view.findViewById<TextView>(R.id.counter_value) view.findViewById<Button>(R.id.plus) .setOnClickListener { counter++ counterValue.text = counter.toString() } view.findViewById<Button>(R.id.minus) .setOnClickListener { if (counter > 0) { counter-- counterValue.text = counter.toString() } } }
我们添加了 onViewCreated
,这是当布局已应用于你的片段时运行的回调。创建视图的 onCreateView
回调是在片段本身创建时运行的。
你在先前的片段中指定的按钮已经设置了 OnClickListener
来增加和减少 counter
的值。
-
首先,在加号按钮的
OnClickListener
中,你正在增加计数器并将此值设置在视图中:counter++ counterValue.text = counter.toString()
-
然后,在减号按钮的
OnClickListener
中,它会将值减一,但仅当值大于一时,因此不会设置任何负数:if (counter > 0) { counter-- counterValue.text = counter.toString() }
-
你还没有将片段添加到
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>
为了简化布局,你将把布局从ConstraintLayout
改为LinearLayout
,因为在进入下一阶段时,你可以轻松地将一个片段添加到另一个片段之上。你通过fragment
XML 元素中的name
属性指定要使用的片段,使用用于类的完全限定包名:android:name="com.example.fragmentintro.CounterFragment"
。
如果你创建应用程序时使用了不同的包名,那么这必须指的是你创建的CounterFragment
。这里要掌握的重要一点是,你已经将一个片段添加到主活动布局中,并且片段也有一个布局。
这展示了使用片段的一些强大功能,因为你可以将应用程序一个功能的全部功能封装起来,包括布局文件和片段类,并将其添加到多个活动中。
完成此操作后,如图 3**.7所示,在虚拟设备上运行片段:
图 3.7 – 显示计数器片段的应用程序
你已经创建了一个简单的计数器。基本功能按预期工作,增加和减少计数器值。
-
在下一步中,你将在屏幕下半部分添加另一个片段。这展示了片段的通用性。你可以在屏幕的不同区域封装具有功能和特性的 UI 片段。
-
现在,使用创建
CounterFragment
的早期步骤创建一个新的片段,命名为ColorFragment
,布局名称为fragment_color
。 -
接下来,打开创建的
fragment_color.xml
文件,并用以下链接中的代码替换其内容。以下片段被截断 – 请参阅链接以获取完整代码:<TextView android:id="@+id/hello_world" android:layout_width="wrap_content" android:layout_height="0dp" android:textSize="34sp" android:paddingBottom="12dp" android:text="@string/hello_world" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/red_button" android:layout_width="wrap_content" android:layout_height="0dp" android:textSize="24sp" android:text="@string/red_text" app:layout_constraintEnd_toStartOf="@+id/green_button" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/hello_world" />
你可以在packt.link/GCYDR
找到此步骤的完整代码。
布局添加了一个TextView
和三个按钮。TextView
的文本以及所有按钮的文本都设置为字符串资源(@string
)。
-
接下来,进入
activity_main.xml
文件,在LinearLayout
内部CounterFragment
下方添加ColorFragment
:<?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/color_fragment" android:name="com.example.fragmentintro .ColorFragment" android:layout_width="match_parent" android:layout_height="match_parent"/> </LinearLayout>
当你运行应用程序时,你会看到ColorFragment
不可见,如图 3**.8所示:
图 3.8 – 未显示 ColorFragment 的应用程序
你已经在布局中包含了ColorFragment
,但由于CounterFragment
的宽度和高度设置为匹配其父元素(android:layout_width="match_parent" android:layout_height="match_parent"
),并且它是布局中的第一个视图,因此它占据了所有空间。
你需要一种方式来指定每个片段应占用的身高比例。LinearLayout
的方向设置为垂直,因此当layout_height
未设置为match_parent
时,片段将一个接一个地显示。
为了定义这个高度的比例,您需要在activity_main.xml
布局文件中的每个片段中添加另一个属性,layout_weight
。当您使用layout_weight
来确定片段应占用的比例高度时,将片段的layout_height
值设置为0dp
。
-
使用以下更改更新
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/color_fragment" android:name="com.example.fragmentintro .ColorFragment" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"/> </LinearLayout>
这些更改使CounterFragment
的高度是ColorFragment
的两倍,如图图 3.9 所示:
图 3.9 – CounterFragment 分配了两倍垂直空间
您可以通过更改权重值来实验,看看您可以对布局显示做出哪些改变。
-
到目前为止,按下红色、绿色和蓝色颜色按钮对Hello World文本没有任何影响。按钮动作尚未指定。下一步涉及向按钮添加交互性,以便更改Hello World文本的样式。
-
在
ColorFragment
中添加以下onViewCreated
函数,该函数覆盖其父类,在布局视图设置完毕后向片段添加行为。您还需要添加TextView
、Color
和Button
导入以更改文本颜色:import android.widget.Button import android.widget.TextView import android.graphics.Color override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val redButton = view.findViewById<Button>(R.id.red_button) val greenButton = view.findViewById<Button>(R.id.green_button) val blueButton = view.findViewById<Button>(R.id.blue_button) val helloWorldTextView = view.findViewById<TextView>(R.id.hello_world) redButton.setOnClickListener { helloWorldTextView.setTextColor(Color.RED) } greenButton.setOnClickListener { helloWorldTextView.setTextColor(Color.GREEN) } blueButton.setOnClickListener { helloWorldTextView.setTextColor(Color.BLUE) } }
在这里,您正在为布局中定义的每个按钮添加OnClickListener
,并将Hello World文本设置为所需的颜色。
- 运行应用并按下红色、绿色和蓝色按钮。你应该看到与图 3.10类似的显示。10*:
图 3.10 – ColorFragment 设置文本颜色为红色、绿色和蓝色
虽然这个练习很简单,但它展示了使用片段的一些基本概念。用户可以与之交互的应用程序功能可以独立开发,而不必将两个或更多功能捆绑到一个布局和活动文件中。这使得片段可重用,意味着您可以在开发应用程序时将注意力集中在将定义良好的 UI、逻辑和功能添加到单个片段中。
静态片段和双面板布局
之前的练习向您介绍了静态片段,那些可以在活动 XML 布局文件中定义的片段。您还可以为不同屏幕尺寸创建不同的布局和资源。这用于根据设备是手机还是平板电脑来决定显示哪些资源。
在更大的尺寸平板电脑上,布局 UI 元素的空间可以显著增加。Android 允许根据许多不同的形态因素指定不同的资源。在res
(资源)文件夹中,通常使用sw600dp
来定义平板电脑。
这表示如果设备的最短宽度(sw
)超过 600 dp,则使用这些资源。此限定符用于 7 英寸平板电脑及以上。平板电脑促进了所谓的双面板布局。一个面板代表用户界面的一个自包含部分。如果屏幕足够大,则可以支持两个面板(双面板布局)。这也提供了机会,让一个面板与另一个面板交互以更新内容。
练习 3.03 – 使用静态片段的双面板布局
在这个练习中,你将创建一个简单的应用程序,显示星座列表以及每个星座的详细信息。它将为手机和平板电脑使用不同的显示方式。
手机将显示一个列表,然后在另一个屏幕上打开所选列表项的内容,而平板电脑将在一个面板中显示相同的列表,并在同一屏幕上的另一个面板中打开列表项的内容,形成一个双面板布局。
为了做到这一点,你必须创建另一个仅用于 7 英寸平板电脑及以上的布局。执行以下步骤:
-
首先,创建一个新的 Android Studio 项目,名为
Dual
Pane Layouts
的Empty Activity
。 -
然后,确保在左侧的项目视图中选中 Android 视图,转到 文件 | 新建 | Android 资源文件 并填写以下对话框中的字段(你需要在对话框的左侧面板中选择 最小屏幕宽度 – 选中后,选项将更改为 屏幕宽度):
图 3.11 – 设备变体视图
- 这在
main
|res
文件夹中创建了一个名为'layout-sw600dp'
的新文件夹,并添加了activity_main.xml
布局文件。
目前,它是当你创建应用程序时添加的 activity_main.xml
文件的副本,但你会将其更改为自定义平板电脑的屏幕显示。
为了演示双面板布局的使用,你将创建一个星座列表,以便当选择列表项时,它会显示有关星座的一些基本信息。
- 前往顶部工具栏并选择
ListFragment
。
对于这个练习,你需要更新 strings.xml
和 themes.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
元素是一个 ScrollView
。ScrollView
是一个允许内容滚动的 ViewGroup
,由于你将在其中添加 12 个星座到 LinearLayout
,它可能比屏幕上可用的垂直空间更多。
添加ScrollView
可以防止内容在没有更多空间显示时垂直截断,并滚动布局。ScrollView
只能包含一个子视图。在这里,它是一个LinearLayout
,由于内容将垂直显示,因此方向设置为垂直(android:orientation="vertical"
)。在第一个标题TextView
下方,你添加了一个分隔符View
和一个用于第一个星座水瓶座的TextView
。
- 使用相同的格式添加其他 11 个星座,首先添加分隔符,然后添加
TextView
。每个TextView
的字符串资源名称和id
值应该相同。你将创建视图的星座名称在strings.xml
文件中指定。
注意
用于布局列表的技术对于示例来说是好的,但在实际应用中,你会创建一个用于显示可滚动列表的RecyclerView
,数据通过适配器绑定到列表。你将在后面的章节中介绍这一点。
-
接下来,在
MainActivity
类标题上方创建StarSignListener
,并通过添加以下代码使MainActivity
实现它:interface StarSignListener { fun onSelected(id: Int) } class MainActivity : AppCompatActivity(), StarSignListener { ... override fun onSelected(id: Int) { TODO("not implemented yet") } }
这就是当用户从ListFragment
中选择星座时,片段将如何将信息反馈给活动,逻辑将根据是否有双面板而添加。
-
一旦创建了布局文件,进入
ListFragment
类,并使用以下内容更新它,同时保留onCreateView()
。你可以在片段的onAttach()
回调中看到,你声明活动实现了StarSignListener
接口,以便在用户点击列表中的项目时通知活动。在文件顶部与其他导入一起添加所需的Context
导入:import android.content.Context class ListFragment : Fragment(), View.OnClickListener { // TODO: Rename and change types of parameters private var param1: String? = null private var param2: String? = null 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") } } override fun onCreateView(...) override fun onViewCreated(view: View, savedInstanceState:Bundle?) { super.onViewCreated(view, savedInstanceState) val starSigns = listOf<View>( view.findViewById(R.id.aquarius), view.findViewById(R.id.pisces), view.findViewById(R.id.aries), view.findViewById(R.id.taurus), view.findViewById(R.id.gemini), view.findViewById(R.id.cancer), view.findViewById(R.id.leo), view.findViewById(R.id.virgo), view.findViewById(R.id.libra), view.findViewById(R.id.scorpio), view.findViewById(R.id.sagittarius), view.findViewById(R.id.capricorn) ) starSigns.forEach { it.setOnClickListener(this) } } override fun onClick(v: View?) { v?.let { starSign -> starSignListener.onSelected(starSign.id) } } }
剩余的回调与你在之前的练习中看到的是相似的。你通过onCreateView
创建片段视图。你在onViewCreated
中使用OnClickListener
设置按钮,然后处理点击事件在onClick
中。
onViewCreated
中的listOf
语法是创建一个具有指定元素的只读列表的方法,在这种情况下是你的星座TextView
实例。然后,在接下来的代码中,你通过使用forEach
语句遍历这些TextView
,通过迭代TextView
列表为每个单独的TextView
设置OnClickListener
。这里的it
语法指的是正在操作的列表元素,它将是 12 个星座TextView
之一。
-
最后,当列表中的某个星座被点击时,
onClick
语句通过StarSignListener
将信息反馈给活动:v?.let { starSign -> starSignListener.onSelected(starSign.id) }
你使用?
检查指定的视图v
是否为 null,然后只有在不为 null 的情况下,使用let
作用域函数对其进行操作,在将星座的id
值传递给Activity
/StarSignListener
之前。
注意
监听器是响应更改的常见方式。通过指定 Listener
接口,您指定了一个需要满足的合同。实现类随后会通知监听器操作的结果。
-
接下来,创建
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
中设置这些值。
-
打开
DetailFragment
并用以下文本更新内容,同时将TextView
和Toast
导入添加到导入列表中:import android.widget.TextView import android.widget.Toast class DetailFragment : Fragment() { // TODO: Rename and change types of parameters private var param1: String? = null private var param2: String? = null private val starSign: TextView? get() = view?.findViewById(R.id.star_sign) private val symbol: TextView? get() = view?.findViewById(R.id.symbol) private val dateRange: TextView? get() = view?.findViewById(R.id.date_range) override fun onCreate(...) override fun onCreateView(...) fun setStarSignData(starSignId: Int) { when (starSignId) { R.id.aquarius -> { starSign?.text = getString(R.string.aquarius) symbol?.text = getString(R.string.symbol, "Water Carrier") dateRange?.text = getString(R.string.date_range, "January 20 - February 18") } } } }
onCreateView
方法会像平常一样填充布局。setStarSignData()
函数用于从传入的 starSignId
中填充数据。when
表达式用于确定星座的 ID 并设置适当的内容。
在这里,setStarSignData
函数使用 getString
函数格式化文本 – 例如,getString(R.string.symbol,"Water Carrier")
将文本 Water Carrier
传递到 symbol
字符串,<string name="symbol">Symbol: %s</string>
,并用传入的值替换 %s
。您可以在官方文档中查看其他字符串格式化选项:https://developer.android.com/guide/topics/resources/string-resource。
按照由 aquarius
引入的模式,将其他 11 个星座添加到完成文件中的 aquarius
块下方:packt.link/C9sWZ
。
目前,您已经添加了 ListFragment
和 DetailFragment
。然而,目前它们尚未添加到活动布局中,也没有同步,因此选择 ListFragment
中的星座项不会将内容加载到 DetailFragment
中。让我们看看如何改变这一点。
-
首先,您需要更改
layout
文件夹和layout-sw600dp
中的activity_main.xml
的布局。 -
如果处于项目视图,打开
res
|layout
|activity_main.xml
。在默认的 Android 视图中,打开res
|layout
|activity_main.xml
并选择顶部的activity_main.xml
文件(无sw600dp
)。用以下内容替换内容:<?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.dualpanelayouts .ListFragment" android:layout_height="match_parent" android:layout_width="match_parent"/> </androidx.constraintlayout.widget.ConstraintLayout>
如果现在运行应用程序并选择其中一个星座,您将得到一个 NotImplementedError
,因为我们需要用这个功能替换 TODO
项。
-
然后,如果处于项目视图,打开
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.dualpanelayouts .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.dualpanelayouts .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
占据三分之二。
-
要查看应用,请创建一个新的虚拟设备,如第一章中所示,创建您的第一个应用,并选择类别 | 平板 | Nexus 7。
-
这将创建一个 7 英寸的平板。然后,启动虚拟设备并运行应用。这是你在横屏模式下启动平板时将看到的初始视图:
图 3.12 – 初始星座应用 UI 显示
你可以看到列表占据了大约三分之一的屏幕,而空白空间占据了三分之二的屏幕。
-
点击虚拟设备底部的
旋转按钮,将虚拟设备顺时针旋转 90 度。
-
完成这些操作后,虚拟设备将进入横屏模式。然而,它不会改变屏幕的方向为横屏。
-
为了做到这一点,点击虚拟设备左下角的
旋转按钮。你还可以选择虚拟设备顶部的状态栏,按住并向下拖动以显示快速设置栏,其中你可以通过选择旋转按钮来打开自动旋转。(你可能需要在快速设置栏内左右滑动以显示 自动旋转 选项。)
图 3.13 – 已选择自动旋转的快速设置栏
- 这将随后将平板布局更改为横屏:
图 3.14 – 平板在横屏模式下初始星座应用 UI 显示
-
下一步是启用选择列表项以将内容加载到屏幕的
Detail
面板中。为此,我们需要在MainActivity
中进行更改。使用以下代码更新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) } } }
注意
片段是在 2011 年的 API 11 中引入的,当时有一个 FragmentManager
类来管理它们与活动的交互。SupportFragmentManager
被引入以支持在 Android 支持库中在 API 11 之前的 Android 版本中使用片段。SupportFragmentManager
已进一步发展,成为 Jetpack Fragment 库的基础,该库为片段管理添加了改进。
此示例及其后续示例使用 supportFragmentManager.findFragmentById
来检索片段。然而,你也可以通过在片段 XML 中添加标签来使用 Tag
检索片段,方法是使用 android:tag="MyFragmentTag"
。
-
你可以使用
supportFragmentManager.findFragmentByTag("MyFragmentTag")
来检索片段。 -
为了从片段检索数据,活动需要实现
StarSignListener
。这完成了片段中设置的合同,以便将详细信息传递回实现类。onCreate
函数设置布局,然后检查DetailFragment
是否在活动的填充布局中,通过检查R.id.star_sign_detail
ID 是否存在。 -
从项目视图来看,
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
并选择顶部的activity_main.xml
文件(不带sw600dp
),然后选择activity_main.xml
(sw600dp
) 以查看这些差异。 -
因此,现在我们可以通过
StarSignListener
将从ListFragment
传递回MainActivity
的星座 ID 检索出来,并将其传递到DetailFragment
。这是通过检查isDualPane
布尔值来实现的,如果评估结果为true
,则知道可以将星座 ID 通过此代码传递给DetailFragment
:val detailFragment = supportFragmentManager .findFragmentById (R.id.star_sign_detail) as DetailFragment detailFragment.setStarSignData(id)
-
你将片段从
id
强制转换为DetailFragment
并调用以下代码:detailFragment.setStarSignData(id)
-
由于你在片段中实现了此函数并通过
id
值检查要显示的内容,因此 UI 被更新:
图 3.15 – 星座应用在平板上横向双面板显示
-
现在点击列表项将按预期工作,显示双面板布局,内容设置正确。
-
然而,如果设备不是平板,即使点击列表项,也不会发生任何事,因为没有为非平板设备定义的
else
分支条件来执行任何操作,这由isDualPane
布尔值定义。显示将如 图 3**.16 所示,并且在选择项目时不会改变:
图 3.16 – 在手机上显示的初始星座应用 UI
-
你将要在另一个活动中显示星座详情。通过访问
activity_detail.xml
并使用此布局创建一个新的DetailActivity
:<?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.dualpanelayouts .DetailFragment" android:layout_height="match_parent" android:layout_width="match_parent"/> </androidx.constraintlayout.widget.ConstraintLayout>
-
这将
DetailFragment
添加为布局中的唯一片段。现在,更新DetailActivity
的onCreate
函数,内容如下: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) }
-
星座
id
预期将通过在 intent 的 extra(也称为 bundle)中设置一个键从另一个活动传递到这个活动。我们在第二章中介绍了 intent,构建用户屏幕流程,但为了提醒,它们可以在不同组件之间进行通信,并且可以发送数据。 -
在这种情况下,打开此活动的 intent 已经设置了一个星座 ID。它将使用
id
在DetailFragment
中设置星座 ID。接下来,你需要实现isDualPane
检查的else
分支以启动DetailActivity
,并通过 intent 传递星座 ID。 -
按照以下方式更新
MainActivity
以实现此功能: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) } }
-
一旦你在手机显示上点击一个星座名称,它就会显示在
DetailActivity
中的内容,占据整个屏幕而没有列表:
图 3.17 – 手机上的单面板星座详情屏幕
这个练习展示了片段的灵活性。它们可以封装逻辑并展示应用的不同功能,根据设备的形态可以以不同的方式集成。它们可以在屏幕上以多种方式排列,这些排列受到它们包含的布局的限制;因此,它们可以作为双面板布局的一部分,或者作为单面板布局的全部或部分。
这个练习展示了片段在平板电脑上并排布局,但它们也可以重叠布局,以及以其他多种方式布局。下一个主题将说明在应用中使用的片段配置不必在 XML 中静态指定,也可以动态完成。
动态片段
到目前为止,你只看到了在编译时在 XML 中添加的片段。虽然这可以满足许多用例,但你可能希望在运行时动态添加片段以响应用户的操作。这可以通过添加ViewGroup
作为片段的容器,然后从ViewGroup
中添加、替换和删除片段来实现。
这种技术更灵活,因为片段可以在不再需要时被移除,而不是像静态片段那样始终在 XML 布局中填充。如果在一个活动中需要三个或四个更多片段来满足不同的用户旅程,那么首选的选项是通过动态添加/替换片段来响应用户的 UI 交互。
当用户的 UI 交互在编译时固定,并且你事先知道需要多少个片段时,使用静态片段效果更好。例如,从列表中选择项目以显示内容的情况就是这样。
练习 3.04 – 向活动中动态添加片段
在这个练习中,我们将构建与之前相同的星座应用,但将展示如何动态地将列表和详情片段添加到屏幕布局中,而不是直接在 XML 布局中添加。你还可以向片段传递参数。为了简化,你将为手机和平板创建相同的配置。执行以下步骤:
-
创建一个名为
Dynamic Fragments
的Empty Activity
的新项目。 -
完成后,添加以下依赖项 - 你需要使用
FragmentContainerView
,这是一个用于处理片段事务的优化的ViewGroup
,在app/build.gradle
中的dependencies{ }
块中使用:implementation 'androidx.fragment:fragment-ktx:1.5.6'
-
从 Exercise 3.03 – 使用静态片段的双面板布局 中复制以下 XML 资源文件的内容,并将它们添加到本练习中相应的文件中:
strings.xml
(将app_name
字符串从Dual Pane Layouts
更改为Dynamic Fragments
),fragment_detail.xml
和fragment_list.xml
。所有这些文件都存在于上一个练习中创建的项目中,你只需将这些内容添加到这个新项目中。 -
然后,将
DetailFragment
和ListFragment
复制到新项目中。你将不得不在这两个文件中将包名从package com.example.dualpanelayouts
更改为package com.example.dynamicfragments
。最后,将上一个练习中在基本应用程序样式下方定义的样式添加到本项目的themes.xml
中。 -
你现在已经设置了与上一个练习相同的片段。现在,打开
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 中没有添加任何片段,因为这些片段将动态添加。
-
进入
MainActivity
并用以下内容替换其内容:package com.example.dynamicfragments import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import androidx.fragment.app.FragmentContainerView const val STAR_SIGN_ID = "STAR_SIGN_ID" interface StarSignListener { fun onSelected(id: Int) } 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
中指定的 FragmentContainerView
的引用,创建一个新的 ListFragment
,然后将这个片段添加到具有 fragment_container
ID 的 FragmentContainerView
中。
指定的片段事务是 add
,因为你第一次将片段添加到 FrameLayout
中。你调用 commit()
来立即执行事务。使用 savedInstanceState
进行空检查,以确保在没有要恢复的状态时才添加这个 ListFragment
,如果有片段之前被添加,就会存在这种状态。
-
接下来,让
MainActivity
通过添加以下代码来实现StarSignListener
:class MainActivity : AppCompatActivity(), StarSignListener { ... override fun onSelected(id: Int) { } }
-
现在如果你运行应用,你将看到星座列表在手机和平板上显示。
你现在面临的问题是,由于它不在 XML 布局中,如何将星座 ID 传递给 DetailFragment
。
一种选择是使用与上一个示例相同的技巧,通过创建一个新的活动并将星座 ID 通过 intent 传递,但你不需要创建一个新的活动来添加一个新的片段;否则,你不如放弃片段,直接使用活动。
你将用 DetailFragment
替换 FragmentContainerView
中的 ListFragment
,但首先,你需要找到一种方法将星座 ID 传递到 DetailFragment
。你这样做是通过在创建片段时将此 id
值作为参数传递。这样做的一种标准方式是在片段中使用一个 Factory
方法。
-
将以下代码添加到
DetailFragment
的底部(当你使用模板/向导创建片段时,将添加一个示例factory
方法,你可以在这里更新它):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) } } }
一个 companion
对象允许你在你的类中添加 Java 的静态成员。在这里,你实例化一个新的 DetailFragment
并设置传递给片段的参数。片段的参数存储在一个 Bundle()
中,所以与活动 intent 的额外内容(也是一个 bundle)一样,你添加值作为键值对。在这种情况下,你添加了 STAR_SIGN_ID
键和值 starSignId
。
-
下一步是重写
DetailFragment
的生命周期函数之一,以使用传入的参数:override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val starSignId = arguments?.getInt(STAR_SIGN_ID, 0) ?: 0 setStarSignData(starSignId) }
-
你这样做是在
onViewCreated
中,因为在这个阶段,片段的布局已经设置,你可以访问视图层次结构(而如果在onCreate
中访问参数,由于这是在onCreateView
中完成的,因此片段布局将不可用):val starSignId = arguments?.getInt(STAR_SIGN_ID, 0) ?: 0
-
这行代码从传入的片段参数中获取星座 ID,如果找不到
STAR_SIGN_ID
键,则设置默认值0
。然后,你调用setStarSignData(starSignId)
来显示星座内容。 -
现在你只需要在
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() } } } override fun onSelected(starSignId: Int) { findViewById<FragmentContainerView>( R.id.fragment_container)?.let { frameLayout -> val detailFragment = DetailFragment.newInstance(starSignId) supportFragmentManager.beginTransaction() .replace(frameLayout.id, detailFragment) .addToBackStack(null) .commit() } } }
你使用前面解释的方法创建 DetailFragment
,通过 factory
方法传入星座 ID:DetailFragment.newInstance(starSignId)
。
在这个阶段,ListFragment
仍然是已经添加到活动 FrameLayout
中的片段。你需要用 DetailFragment
替换它,这需要另一个事务。然而,这次你使用 replace
函数用 DetailFragment
替换 ListFragment
。
在提交事务之前,你调用 .addToBackStack(null)
,这样当按下返回按钮时,应用不会退出,而是通过弹出 DetailFragment
从片段堆栈返回到 ListFragment
。
这个练习介绍了动态将片段添加到你的活动。下一个主题将介绍创建片段的更明确的结构,称为导航图。
Jetpack 导航
使用动态和静态片段,虽然非常灵活,但会在您的应用中引入大量样板代码,并且当用户旅程需要添加、删除和替换多个片段同时管理回退栈时,可能会变得相当复杂。
如您在 第一章 中所学的,Google 引入了 Jetpack 组件,以在您的代码中使用既定的最佳实践。Jetpack 组件套件中的 Navigation
组件使您能够减少样板代码并简化应用内的导航。我们现在将使用它来更新星象应用。
练习 3.05 – 添加 Jetpack 导航图
在这个练习中,我们将重用上一个练习中的大多数类和资源。我们首先创建一个空项目并复制资源。接下来,我们将添加依赖项并创建一个导航图。
使用逐步方法,我们将配置导航图并添加目标以在片段之间导航。执行以下步骤:
-
创建一个名为
Jetpack Fragments
的Empty Activity
的新项目。 -
从上一个练习中复制
strings.xml
、fragment_detail.xml
、fragment_list.xml
、DetailFragment
和ListFragment
,记得在strings.xml
中更改app_name
字符串以及片段类的包名。您需要将以下行从import com.example.dynamicfragments.R
更改为import com.example.jetpacknavigation.R
。 -
最后,将上一个练习中在
themes.xml
中定义的样式添加到本项目的themes.xml
中。您还需要在MainActivity
的类头上方添加以下内容:const val STAR_SIGN_ID = "STAR_SIGN_ID" interface StarSignListener { fun onSelected(id: Int) }
-
完成此操作后,添加以下依赖项 - 您需要在
app/build.gradle
文件中的dependencies{ }
块中使用Navigation
组件:implementation "androidx.navigation:navigation-fragment-ktx:2.5.3" implementation "androidx.navigation:navigation-ui-ktx:2.5.3"
-
它将提示您选择
app
模块并转到 文件 | 新建 | Android 资源。 -
一旦出现此对话框,更改
nav_graph
:
图 3.18 – 新资源文件对话框
点击名为 Navigation
的 res
文件夹,其中包含 nav_graph.xml
文件。
-
使用以下代码更新
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>
前面的文件是一个有效的导航图。尽管语法不熟悉,但它相当直观易懂:
-
ListFragment
和DetailFragment
的存在方式与您添加静态片段时相同。 -
在根
<navigation>
元素中有一个id
值用于识别图,以及在片段本身上的 ID。导航图引入了目标的概念,因此在根navigation
级别,有app:startDestination
,其 ID 为starSignList
,对应ListFragment
,然后在<fragment>
标签内,有<action>
元素。 -
动作是将导航图内的目的地连接起来的东西。这里的动作有一个 ID,因此您可以在代码中引用它,并且有一个目的地,当使用时,它将引导到。
现在您已经添加了导航图,您需要使用它来将活动和片段连接起来。
-
打开
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" />
FragmentContainerView
已添加,名称为 android:name="androidx.navigation.fragment.NavHostFragment"
。它将托管您刚刚创建的 app:navGraph="@navigation/nav_graph"
中的片段。
app:defaultNavHost
表示它是应用程序的默认导航图。它还控制当一个片段替换另一个片段时的后退导航。您可以在布局中拥有多个 NavHostFragment
来控制两个或更多屏幕区域,这些区域管理自己的片段,您可能用于平板电脑的双面板布局,但只能有一个默认的。
您需要对应用程序进行一些更改,以便在 ListFragment
中按预期工作。
-
首先,移除类文件头和
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() {
-
接下来,在类的底部,移除
onClick
覆盖方法,因为您没有实现View.OnClicklistener
:override fun onClick(v: View?) { v?.let { starSign -> starSignListener.onSelected(starSign.id) } }
-
在
onViewCreated
方法中,替换遍历星象视图的forEach
语句:starSigns.forEach { it.setOnClickListener(this) }
用以下代码替换它,并将 Navigation
导入添加到导入列表中:
import androidx.navigation.Navigation
starSigns.forEach { starSign ->
val fragmentBundle = Bundle()
fragmentBundle.putInt(STAR_SIGN_ID, starSign.id)
starSign.setOnClickListener(
Navigation.createNavigateOnClickListener(
R.id.star_sign_id_action, fragmentBundle)
)
}
在这里,您正在创建一个包来传递 STAR_SIGN_ID
,带有所选星象视图的视图 ID 到 NavigationClickListener
。它使用 R.id.star_sign_id_action
动作的 ID 来加载 DetailFragment
,因为那是动作的目的地。DetailFragment
不需要任何更改,并使用传入的 fragment
参数来加载所选星象 ID 的详细信息。
- 运行应用程序,您会看到应用程序的行为与之前相同。
现在,您已经能够删除大量样板代码,并在导航图中记录应用程序内的导航。此外,您已经将更多片段生命周期的管理卸载到 Android 框架,从而节省更多时间来开发功能。
Jetpack 导航是一个强大的 androidx
组件,它使您能够映射整个应用程序以及片段、活动等之间的关系。您还可以选择性地使用它来管理应用程序中具有定义良好的用户流程的不同区域,例如应用程序的启动和引导用户通过一系列欢迎屏幕,或者一些向导布局的用户旅程,例如。
带着这些知识,让我们尝试使用从所有这些练习中学到的技术来完成一个活动。
活动三.01 – 在行星上创建一个测验
对于这个活动,您将创建一个问答,用户必须回答太阳系中行星的三个问题之一。您选择使用多少个片段由您决定。然而,考虑到本章的内容,即把 UI 和逻辑分离到单独的片段组件中,您可能需要使用两个或更多的片段来实现这一点。
下面的截图显示了完成此任务的一种方法,但创建此应用程序有多种方法。您可以使用本章中详细说明的方法之一,例如静态片段、动态片段或 Jetpack Navigation
组件,或者使用这些和其他方法的组合来创建自定义内容。
以下是问答的内容。在 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.
以下是一些截图,展示了如何实现您需要构建的应用程序的要求:
问题界面:
图 3.19 – 行星问答问题界面
答案选项界面:
图 3.20 – 行星问答多选题答案界面
答案界面:
图 3.21 – 行星问答详细答案界面
以下步骤将帮助您完成活动:
-
创建一个带有
Empty Activity
的 Android 项目。 -
使用您需要的项目条目更新
strings.xml
文件。 -
修改
themes.xml
文件以包含项目的样式。 -
创建一个
QuestionsFragment
,更新布局以包含问题,并添加按钮和OnClickListener
(s) 的交互。 -
可选地,创建一个多选题片段,并添加答案选项和按钮点击处理(这也可以通过将可能的答案选项添加到
QuestionsFragment
中来完成)。 -
创建一个
AnswersFragment
,显示相关问题的答案,并显示有关答案的更多详细信息。
注意
本活动的解决方案可在 packt.link/By7eE
找到。
摘要
本章深入探讨了片段,从了解片段生命周期和需要在您自己的片段中重写的关键功能开始。然后,我们转向在 XML 中将简单的片段静态地添加到应用程序中,并演示了 UI 显示和逻辑如何可以在单个片段中自包含。接着,我们介绍了如何使用FragmentContainerView
将片段添加到应用程序中,以及动态添加和替换片段的其他选项。最后,我们讨论了如何通过使用 Jetpack Navigation
组件来简化这一过程。
片段是 Android 开发的基本构建块之一。您在这里学到的概念将使您能够在此基础上构建,并逐步创建越来越高级的应用程序。片段是构建有效导航的核心,以便将简单易用的功能和功能绑定到您的应用程序中。
下一章将通过使用已建立的 UI 模式来构建清晰和一致的导航,并说明片段是如何被用来实现这一点的,来详细探讨这个领域。
第四章:构建应用导航
在本章中,你将通过三种主要模式:底部导航、导航抽屉和标签导航来构建用户友好的应用导航。通过指导理论和实践,你将了解这些模式如何工作,以便用户可以轻松访问你的应用内容。本章还将关注让用户意识到他们在应用中的位置以及他们可以导航到应用层次结构的哪个级别。
到本章结束时,你将了解如何使用这三种主要导航模式,并理解它们如何与应用栏一起支持导航。
在上一章中,你探索了片段和片段生命周期,并使用了 Jetpack 导航来简化它们在你的应用中的使用。在本章中,你将学习如何在继续使用 Jetpack 导航的同时,为你的应用添加不同类型的导航。
你将从学习导航抽屉开始,这是 Android 应用中最早广泛采用的导航模式,然后探索底部导航和标签导航。你将了解 Android 导航用户流程,它是如何围绕目标构建的,以及它们如何控制应用内的导航。
将解释主要和次要目标之间的区别,以及根据你的应用用例,三种主要导航模式中哪一个更适合。
在本章中,我们将涵盖以下主题:
-
导航概览
-
导航抽屉
-
底部导航
-
标签导航
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,网址为packt.link/B2rz6
。
导航概览
Android 导航用户流程围绕应用内的目标构建。在应用顶级有主要目标可用,随后始终显示在主应用导航和次要目标中。三个导航模式的一个指导原则是,在任何给定时间点,根据用户所在的应用主要部分提供上下文信息。
这可以表现为用户所在目标应用顶部栏中的一个标签,可选地显示一个箭头提示用户不在顶级,以及/或提供突出显示的文本和图标在用户界面(UI)中,指示用户所在的区域。你的应用中的导航应该是流畅和自然的,直观地引导用户,同时也在任何给定时间点提供一些他们所在位置的上下文。
你将要探索的三个导航模式以不同的方式实现这一目标。其中一些导航模式更适合与更多顶级主要目标一起使用来显示,而其他一些则适合较少的目标。
导航抽屉
导航抽屉是 Android 应用中最常用的导航模式之一,并且无疑是第一个被广泛采用的模式。以下是一个下一项练习的截图,显示了关闭状态下的简单导航抽屉:
图 4.1 – 导航抽屉关闭的应用
导航抽屉通过现在普遍称为汉堡菜单的方式访问,这是位于 图 4.1 顶部左边的带有三个水平线的图标。屏幕上不显示导航选项,但当前屏幕的上下文信息显示在顶部应用栏中。
在屏幕的右侧也可以有一个溢出菜单,通过它可以访问其他上下文相关的导航选项。以下是一个打开状态下的导航抽屉的截图,显示了所有导航选项:
图 4.2 – 导航抽屉打开的应用
选择汉堡菜单后,导航抽屉从左侧滑出,当前部分被突出显示。这可以显示带有或不带有图标。由于导航占据了屏幕的高度,它最适合五个或更多顶级目的地。
目的地也可以分组,以表示多个主要目的地的层次结构(如前一张截图中的分隔线所示),这些层次结构也可以有标签。此外,抽屉内容也是可滚动的。总之,导航抽屉是一种非常方便的方式,可以快速访问许多不同的应用目的地。
导航抽屉的一个弱点是它需要用户选择汉堡菜单才能使目的地可见。相比之下,标签和底部导航(带有固定标签)始终可见。然而,这反过来也是导航抽屉的一个优点,因为可以有更多屏幕空间用于应用内容。
让我们开始本章的第一个练习,创建一个导航抽屉,这样我们就可以访问应用的所有部分。
练习 4.01 – 创建带有导航抽屉的应用
在此练习中,你将在 Android Studio 中使用 Empty Activity
项目模板创建一个名为 Navigation Drawer
的新应用,同时保留所有其他默认设置。在向导选项中,你可以创建一个新项目,其中包含本章练习中将要生成的所有导航模式,但我们将逐步构建应用以指导你完成步骤。
你将构建一个经常使用导航抽屉的应用,例如新闻或邮件应用。我们将添加的分区包括 主页、收藏夹、最近、存档、回收站 和 设置。
执行以下步骤以完成此练习:
-
使用名为
Navigation Drawer
的Empty Activity
创建一个新的项目。不要使用 Navigation Drawer Activity 项目模板,因为我们打算使用增量步骤来构建应用。 -
在
app/build.gradle
中添加你将需要的 Gradle 依赖项:implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
-
更新
res/values
文件夹中的strings.xml
和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
<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" />
-
接下来,更新
AndroidManifest.xml
中的名为MainActivity
的活动元素,使其不使用 Action Bar。这将由导航抽屉布局提供。转到app
|manifests
|AndroidManifest.xml
并添加android:theme
属性,使用NoActionBar
风格,如以下代码片段所示:<activity android:name=".MainActivity" android:exported="true" android:theme= "@style/Theme.NavigationDrawer.NoActionBar">
-
创建以下片段(
HomeFragment
-
FavoritesFragment
-
RecentFragment
-
ArchiveFragment
-
SettingsFragment
-
BinFragment
-
ContentFragment
-
将每个片段布局更改为使用以下内容:
<?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_margin="8dp" android:text="@string/home_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
文件的相应字符串。因此,使用正确的字符串创建这些片段,以指示用户正在查看哪个片段。
这可能看起来有点重复,但一个单独的片段可以更新为这段文本,但它展示了如何在现实世界的应用中分离不同的部分。
-
使用以下内容更新
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 android:id="@+id/text_home" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" android:text="@string/home_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" /> <Button android:id="@+id/button_home" android:layout_width="140dp" android:layout_height="140dp" android:layout_marginTop="16dp" android:text="@string/link_to_content_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/text_home" /> </androidx.constraintlayout.widget.ConstraintLayout>
TextView
与其他片段布局中指定的内容相同,除了它有一个 ID (id
),用于约束其下方的按钮。
- 创建将在应用中使用的导航图。在项目中选择
res
文件夹,查看此选项。选择mobile_navigation.xml
。
这将创建导航图:
图 4.3 – Android Studio 新资源文件对话框
- 在
res/navigation
文件夹中打开mobile_navigation.xml
文件,并使用以下链接中的文件中的代码更新它。这里显示了代码的截断版本。使用此链接访问完整代码:packt.link/ZRDiT
。
mobile_navigation.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/mobile_navigation"
app:startDestination="@+id/nav_home">
<fragment
android:id="@+id/nav_home"
android:name="com.example.navigationdrawer.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>
…
这将在你的应用中创建所有目的地。然而,它并没有指定这些是主要目的地还是次要目的地。这应该与上一章中提到的片段 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
及其布局中设置。
- 打开
HomeFragment
并添加两个import
语句用于Button
和Navigation
导入,并更新onCreateView
以设置按钮:
HomeFragment
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
}
这使用 ClickListener
导航,当点击 button_home
时完成 R.id.nav_home_to_content
动作。
然而,这些更改目前不会产生任何效果,因为您仍然需要为您的应用程序设置导航宿主,并添加所有其他布局文件,包括导航抽屉。
-
通过在布局文件夹中创建一个名为
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" />
-
您会注意到导航图设置为刚刚创建的图:
app:navGraph="@navigation/mobile_navigation"
-
这样,应用程序的主体及其目的地就已经设置好了。现在,您需要设置 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="176dp" android:background="@color/teal_700" android:gravity="bottom" android:orientation="vertical" android:paddingStart="16dp" android:paddingTop="16dp" android:paddingEnd="16dp" android:paddingBottom="16dp" 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= "8dp" app:srcCompat="@mipmap/ic_launcher_round" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingTop= "8dp" android:text="@string/app_name" android:textAppearance= "@style/TextAppearance.AppCompat.Body1" /> </LinearLayout>
这是显示在导航抽屉标题中的布局。
-
使用名为
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>
这将应用程序的主体布局与出现在其上方的应用程序栏集成在一起。剩下的部分是创建将出现在导航抽屉中的项目,并使用这些项目创建和填充导航抽屉。
- 要使用这些菜单项的图标,您需要将完成练习的可绘制文件夹中的矢量资产复制到项目中的可绘制文件夹。矢量资产使用坐标来布局与相关颜色信息关联的图像。
与 PNG 和 JPG 图像相比,它们显著更小,矢量图可以调整到不同的大小而不会损失质量。您可以在以下位置找到它们:packt.link/CurtF
。
复制以下可绘制资源:
-
favorites.xml
-
archive.xml
-
recent.xml
-
home.xml
-
bin.xml
-
使用这些项目创建一个菜单。为此,转到
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 的名称是连接菜单项到导航图中目的地的魔法。
如果菜单项的 ID(在 activity_main_drawer.xml
中)与导航图中的目的地 ID(在这种情况下,是 mobile_navigation.xml
中的片段)完全匹配,则目的地将自动加载到导航宿主中。
-
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>
如您所见,使用了 include
来添加 app_bar_main.xml
。<include>
元素允许您添加在编译时将被实际布局替换的布局。
-
它们允许我们将不同的布局封装起来,因为它们可以在应用中的多个布局文件中重复使用。
NavigationView
(创建导航抽屉的类)指定你刚刚创建的布局文件来配置其页眉和菜单项:app:headerLayout="@layout/nav_header_main" app:menu="@menu/activity_main_drawer"
-
现在你已经指定了所有布局文件,通过添加以下交互逻辑来更新
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_layout
是nav_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()
}
应用栏还可以通过溢出菜单显示其他菜单项,当配置好时,这些菜单项在应用栏右侧顶部以三个垂直点显示。让我们创建一个溢出菜单来显示设置屏幕。
- 要将溢出菜单添加到应用栏中,请转到
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
。
-
要将溢出菜单添加到应用栏,请将以下内容添加到
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))
导航函数选择项时要执行的操作。这用于在导航图中导航到目的地。
- 运行应用程序,并使用导航抽屉导航到顶级目的地。以下屏幕截图显示了导航到“最近”目的地的示例:
图 4.4 – 从导航抽屉打开的“最近”菜单项
- 当你再次打开导航抽屉时,你会看到“最近”菜单项被选中:
图 4.5 – 导航抽屉中高亮的“最近”菜单项
- 再次选择主页菜单项以显示带有标签链接到 内容按钮的按钮:
图 4.6 – 带有次要目的地按钮的主屏幕
- 点击此按钮转到次要目的地。你会看到一个向上箭头显示:
图 4.7 – 显示向上箭头的次要目的地
在所有前面的屏幕截图中,都显示了溢出菜单。选择它后,你会看到一个显示向上箭头的片段:
图 4.8 – 设置片段
虽然设置具有导航抽屉的应用程序需要经过相当多的步骤,但一旦创建,它就非常可配置。通过向抽屉菜单添加一个菜单项条目和导航图中的一个目的地,可以创建并立即设置一个新的片段。
这将删除你在上一章中使用片段所需的大量样板代码。接下来,你将探索的下一个导航模式是底部导航。这已成为 Android 中最受欢迎的导航模式,主要是因为它使得应用程序的主要部分易于访问。
底部导航
底部导航用于当有有限数量的顶级目的地时,这些目的地可以从三个到五个主要目的地不等,它们之间没有关联。底部导航栏上的每个项目都显示一个图标和一个可选的文本标签。
这种导航允许快速访问,因为项目始终可用,无论用户导航到应用程序的哪个次要目的地。
练习 4.02 – 将底部导航添加到你的应用程序
在 Android Studio 中创建一个名为 Bottom Navigation
的新应用程序,使用 Empty Activity 项目模板,保留所有其他默认设置。不要使用 Bottom Navigation Activity 项目模板,因为我们打算使用增量步骤来构建应用程序。
你将构建一个忠诚度应用程序,为注册使用该应用程序的客户提供优惠、奖励等。底部导航对于这类应用程序来说相当常见,因为通常顶级目标有限。
让我们开始吧:
-
许多步骤与之前的练习非常相似,因为你将使用 Jetpack 导航并在导航图和相应的菜单中定义目标。
-
创建一个名为
Bottom Navigation
的Empty Activity
的新项目。 -
将所需的 Gradle 依赖项添加到
app/build.gradle
:implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
-
在
res/values
文件夹中追加strings.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>
-
创建以下名称的八个片段:
-
HomeFragment
-
ContentFragment
-
OffersFragment
-
RewardsFragment
-
SettingsFragment
-
TicketsFragment
-
CartFragment
-
-
为所有片段应用与之前练习中相同的布局,除了
fragment_home.xml
。对于此布局,使用你在 练习 4.01 – 创建带有 导航抽屉 的应用 中使用的相同布局文件。 -
按照之前的练习创建导航图,并将其命名为
mobile_navigation.xml
。使用以下链接中提供的文件中的代码更新它。这里显示的是代码的截断版本。请参阅链接以获取您需要使用的整个代码块:packt.link/Fwuyl
。
mobile_navigation.xml
<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/mobile_navigation"
app:startDestination="@+id/nav_home">
<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>
…
-
更新
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 }
-
现在导航图中的目标已经定义,创建底部导航菜单以引用这些目标。首先,你需要收集本练习中将要使用的图标。前往 GitHub 上的完成练习,并在
drawable
文件夹中找到矢量资产:packt.link/pUXvC
。
将以下可绘制文件复制到项目的 drawable
文件夹中:
-
cart.xml
-
home.xml
-
offers.xml
-
rewards.xml
-
tickets.xml
- 创建一个
bottom_nav_menu.xml
文件(在res
文件夹上右键点击并选择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>
- 更新
activity_main.xml
文件如下内容:
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"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="?attr/actionBarSize">
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:layout_marginEnd="0dp"
android:background="?android:attr/windowBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:menu="@menu/bottom_nav_menu"
app:labelVisibilityMode="labeled"/>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:name ="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/mobile_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>
BottomNavigation
视图配置了之前创建的菜单,即 app:menu="@menu/bottom_nav_menu"
,而 FragmentContainerView
配置为 app:navGraph="@navigation/mobile_navigation"
。由于应用程序中的底部导航没有直接连接到应用程序栏,因此需要设置的布局文件较少。
-
更新
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() } }
之前的代码应该非常熟悉,因为它在前面的练习中已经解释过了。这里的主要变化是,现在用 BottomNavigationView
替换了之前用于持有导航抽屉主要 UI 导航的 NavigationView
。之后的配置是相同的。
- 运行应用。你应该看到以下输出:
图 4.9 – 选择“主页”的底部导航
- 显示显示了您设置的四个菜单项,其中 主页 项被选中作为起始目的地。点击方形按钮将被带到 主页 内的辅助目的地:
图 4.10 – 主页内的辅助目的地
- 使这成为可能的行为是导航图中指定的
nav_home_to_content
动作:
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>
- 由于底部导航栏中添加的项目数量有限(通常是三到五个),有时会将在应用栏中添加操作项(那些有专用图标的项目)。创建另一个名为
main.xml
的菜单,并添加以下内容:
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>
- 此菜单将在应用栏的溢出菜单中使用。当你点击三个点时,溢出菜单将可用。由于
app:showAsAction
属性设置为always
,因此还会在顶部应用栏上显示一个cart
矢量资产。在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))
}
- 现在,这将在应用栏中显示主菜单。再次运行应用,你将看到以下内容:
图 4.11 – 带有溢出菜单的底部导航
选择购物车将带您到我们在导航图中配置的辅助目的地:
图 4.12 – 在辅助目的地中带有溢出菜单的底部导航
正如你在这次练习中看到的,设置底部导航相当直接。导航图和菜单设置简化了将菜单项链接到片段的过程。此外,集成动作栏和溢出菜单也是实现的小步骤。
如果你正在开发一个具有非常明确的高级目的地并且在这些目的地之间切换很重要的应用,那么这些目的地的可见性使得底部导航成为一个理想的选择。接下来要探索的最后一个主要导航模式是标签导航。
这是一个多功能的模式,因为它可以用作应用的主要导航,也可以作为我们研究过的其他导航模式中的辅助导航。
标签导航
标签导航主要用于你想显示相关项目时。如果有少量标签(通常在两个到五个标签之间),通常会使用固定标签,如果你有超过五个标签,则使用可滚动的水平标签。它们主要用于对处于同一层次级别的目的地进行分组。
如果目的地相关,这可以成为主要导航。这可能适用于你开发的应用程序在一个狭窄或特定主题领域,其中主要目的地是相关的,例如新闻应用。更常见的是,它与底部导航一起使用,以展示主要目的地内的次要导航。以下练习演示了使用标签导航来显示相关项目。
练习 4.03 – 使用标签进行应用导航
在 Android Studio 中创建一个新的应用程序,名为Tab Navigation
的Empty Activity
。你将构建一个显示电影类型的骨架电影应用程序。让我们开始吧:
- 使用以下内容更新
strings.xml
:
strings.xml
<string name="action">Action</string>
<string name="comedy">Comedy</string>
<string name="drama">Drama</string>
<string name="sci_fi">Sci-Fi</string>
<string name="family">Family</string>
<string name="crime">Crime</string>
<string name="history">History</string>
<string name="dummy_text">
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi.
</string>
<string name="dummy_text">
文件为每个电影类型提供了一些正文文本:
-
为了能够左右滑动标签页,我们需要使用一个
ViewPager
组件。在 app 的build.gradle
文件中添加以下依赖项:implementation "androidx.viewpager2:viewpager2:1.0.0"
-
创建一个新的空白
MoviesFragment
片段,它将显示一些正文文本,并用以下代码片段替换布局文件内容。
fragment_movies.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=".MoviesFragment">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:text="@string/dummy_text"
android:padding="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
-
使用以下内容更新
activity_main.xml
文件:<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" android:orientation="vertical"> <com.google.android.material.tabs.TabLayout app:layout_constraintTop_toTopOf="parent" android:id="@+id/tab_layout" android:layout_width="match_parent" android:layout_height="wrap_content" app:tabMode="fixed"/> <androidx.viewpager2.widget.ViewPager2 app:layout_constraintTop_toBottomOf="@id/tab_layout" android:id="@+id/view_pager" android:layout_width="match_parent" android:layout_height="wrap_content"/> </androidx.constraintlayout.widget.ConstraintLayout>
布局在顶部显示TabLayout
,并注意它使用app:tabMode="fixed"
属性将标签设置为固定。为了显示所需的内容,你将使用ViewPager
,这是一个可滑动的布局,允许你添加多个视图或片段,这样当用户滑动以更改其中一个标签时,主体内容将显示相应的视图或片段。对于这个练习,你将滑动在电影片段之间。
标签的格式可以是固定的,这样所有标签都可以同时显示在屏幕上,或者可以滚动,这样如果标签不适合可用的水平屏幕空间,一些标签最初将不在屏幕上。
接下来,我们需要为ViewPager
提供内容。在ViewPager
中提供数据的组件被称为适配器。
-
创建一个简单的适配器,用于显示我们的电影。转到
MovieGenresAdapter
:package com.example.tabnavigation import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter 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 ) val TAB_GENRES_FIXED = listOf(R.string.action, R.string.comedy, R.string.drama) class MovieGenresAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fragmentManager, lifecycle) { override fun getItemCount(): Int { return TAB_GENRES_FIXED.size } override fun createFragment(position: Int): Fragment { return MoviesFragment() } }
首先,查看MovieGenresAdapter
类的头部。它继承自FragmentStateAdapter
,这是一个用于在ViewPager
中填充片段的适配器。
回调方法的函数如下:
-
getItemCount()
:这个方法返回我们将要插入的片段总数,由于我们将页面数与标签数相匹配,所以是TABS_GENRE_FIXED
常量的大小。 -
createFragment(position Int)
: 这将创建在传递的参数位置显示在ViewPager
中的片段。在这里,我们将它设置为相同的片段,但在实际应用中,你会在其中填充不同的片段。
-
更新
MainActivity
以使用带有ViewPager
的标签:package com.example.tabnavigation import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val viewPager = findViewById<ViewPager2>(R.id.view_pager) val tabLayout = findViewById<TabLayout>(R.id.tab_layout) val adapter = MovieGenresAdapter(supportFragmentManager, lifecycle) viewPager.adapter = adapter TabLayoutMediator(tabLayout, viewPager) { tab, position -> tab.text = resources.getString(TAB_GENRES_FIXED[position]) }.attach() } }
-
然后,从布局中检索视图,并使用
TabLayoutMediator
将标签与ViewPager
链接。标签本身是公开的,以便你可以自定义。在这个例子中,我们只是设置了文本。位置也可以用来将标签位置与ViewPager
中的片段位置链接。创建这种标签导航既简单又有效。 -
运行应用,你应该看到以下内容:
图 4.13 – 带有固定标签的标签布局
你可以在页面主体中左右滑动以访问三个标签中的每一个,你也可以选择相应的标签以执行相同操作。现在,让我们更改正在显示的标签数据,并设置标签以便它们可以滚动。
-
首先,将
MovieGenresAdapter
更新为使用一些额外的类型,通过更新getItemCount
函数:override fun getItemCount(): Int { return TAB_GENRES_SCROLLABLE.size }
-
在
MainActivity
中,将TabLayoutMediator
设置为使用适配器中更新的项目计数来设置这些额外页面的标签文本:TabLayoutMediator(tabLayout, viewPager) { tab, position -> tab.text = resources.getString(TAB_GENRES_SCROLLABLE[position]) }.attach()
-
你还需要将
activity_layout.xml
文件中的app:tabMode="fixed"
行更改为app:tabMode="scrollable"
。 -
现在运行应用,你应该看到以下内容:
图 4.14 – 带有可滚动标签的标签导航布局
标签列表继续显示在屏幕之外。标签可以滑动和选择,主体内容也可以滑动,这样你就可以在标签页面中左右移动。
通过这个练习,你学习了标签在提供应用导航时的多功能性。固定宽度的标签可以用于主要和次要导航。同时,可滚动标签可以用来将相关项目分组以进行次要导航,因此你还需要向应用添加主要导航。
在这个例子中,为了简化,省略了主要导航,但对于更真实和复杂的应用,你可以添加导航抽屉或底部导航。
活动 4.01 – 构建主要和次要应用导航
你被分配了一个创建体育应用的任务。它可以有三个或更多顶级目的地。然而,其中一个主要目的地必须称为 My Sports
并链接到一个或多个次要目的地,即运动。你可以使用本章中探索的任何一种导航模式或它们的组合,你也可以引入你认为合适的任何自定义设置。
尝试此活动有多种方式。一种方法就是使用底部导航,并将单个二级体育目的地添加到导航图中,以便它可以链接到这些目的地。这相当简单,并且通过操作委托给导航图。以下是使用此方法后主屏幕应该看起来像什么:
图 4.15 – My Sports 应用程序的底部导航
注意
此活动的解决方案可在:packt.link/By7eE
找到。
摘要
本章涵盖了您需要了解的最重要的导航技术,以便在您的应用中创建清晰和一致的导航。您从学习如何使用 Jetpack 导航创建带有导航抽屉的 Android Studio 项目开始,将导航菜单项连接到单个片段。然后,您继续学习 Jetpack 导航中的操作,以便在导航图中导航到应用中的其他二级目的地。
接下来的练习使用了底部导航来显示始终可见于屏幕上的主要导航目的地。随后,我们探讨了标签导航,您学习了如何显示固定和可滚动的标签。对于每种导航模式,您都了解了根据您构建的应用类型,何时可能更适合。我们通过构建自己的应用并添加主要和次要目的地来结束本章。
本章在 第一章,创建您的第一个应用,以及您在 第二章,构建用户屏幕流程,和 第三章,使用片段开发 UI 中学到的关于活动和片段的知识的基础上构建。这些章节涵盖了您需要创建应用所需的知识、实践和基本 Android 组件。本章通过引导您了解可用的主要导航模式,将这些先前章节联系起来,使您的应用脱颖而出且易于使用。
下一章将在此基础上构建,并介绍更多高级的显示应用内容的方法。您将从学习如何使用 RecyclerView
将数据与列表绑定开始。之后,您将探索您可以使用的不同机制来检索和填充应用内的内容。
第二部分:显示网络调用
在这部分,我们将探讨如何集成用于构建 Android 应用的流行库和框架。我们将从用于从互联网获取和处理数据的库开始,然后继续使用用于渲染列表的 RecyclerView
库。
接下来,我们将探讨如何处理权限和使用 Google Maps,然后是使用 Services 和 WorkManager
在后台执行任务,接着向用户显示通知。最后,我们将探讨 Jetpack Compose 以及如何使用它来简化用户界面的创建。
在本节中,我们将涵盖以下章节:
-
第五章, 必备库:Retrofit, Moshi 和 Glide
-
第六章, 添加和与 RecyclerView 交互
-
第七章, Android 权限和 Google Maps
-
第八章, 服务、WorkManager 和通知
-
第九章, 使用 Jetpack Compose 构建用户界面
第五章:必要的库:Retrofit、Moshi 和 Glide
在本章中,我们将介绍向应用用户提供从远程服务器获取的动态内容的步骤。您将了解用于检索和处理这些动态数据的不同库。
到本章结束时,您将能够使用 Retrofit 从网络端点获取数据,使用 Moshi 将 JSON 有效载荷解析为 Kotlin 数据对象,并使用 Glide 将图片加载到ImageView
中。
在上一章中,我们学习了如何在应用中实现导航。在本章中,我们将学习如何在用户在应用中导航时向用户展示动态内容。
本章我们将涵盖以下主题:
-
介绍 REST、API、JSON 和 XML
-
从网络端点获取数据
-
解析 JSON 响应
-
从远程 URL 加载图片
技术要求
本章所有练习和活动的完整代码可在 GitHub 上找到,链接为packt.link/Uqtjm
。
介绍 REST、API、JSON 和 XML
呈现给用户的数据可以来自不同的来源。它可能被硬编码到应用中,但这会带来限制。要更改硬编码的数据,我们必须发布应用的更新。某些数据,如货币汇率、资产实时可用性和当前天气,由于其本质不能被硬编码。其他数据可能会过时,如应用的使用条款。
在这种情况下,您通常会从服务器获取相关数据。为这种数据提供服务最常见的一种架构是表示状态转移(REST)架构。REST 架构由一组六个约束定义:客户端-服务器架构、无状态、可缓存性、分层系统、按需代码(可选)和统一接口。
注意
想了解更多关于 REST 的信息,请访问packt.link/YsSRV
。
当应用于应用程序编程接口(API)的 Web 服务时,我们得到基于超文本传输协议(HTTP)的 RESTful API。HTTP 协议是万维网数据通信的基础,它托管在互联网上,并通过互联网访问。它是全球服务器用于以 HTML 文档、图片、样式表等形式向用户提供服务所使用的协议。
注意
关于这个主题的一篇有趣的文章可以在developer.mozilla.org/en-US/docs/Web/HTTP/Overview
找到。
RESTful API 依赖于标准的 HTTP 方法——GET
、POST
、PUT
、DELETE
和PATCH
——来获取和转换数据。这些方法允许我们从远程服务器获取、存储、删除和更新数据实体。
我们可以依赖内置的 Java HttpURLConnection
类来执行这些 HTTP 方法。或者,我们可以使用像 OkHttp
这样的库,它提供了额外的功能,如 gzip(压缩和解压缩)、重定向、重试以及同步和异步调用。有趣的是,从 Android 4.4 开始,HttpURLConnection
只是 OkHttp
的包装器。如果我们选择 OkHttp
,我们不妨选择 Retrofit(正如本章所做的那样),这是当前行业标准。然后我们可以从其类型安全中受益,这对于处理 REST 调用来说更适合。
最常见的情况下,数据是通过 JavaScript 对象表示法(JSON)表示的。JSON 是一种基于文本的数据传输格式。正如其名所示,它源自 JavaScript。然而,它已经成为了数据传输中最流行的标准之一,并且其最现代的编程语言都有库来编码或解码数据到或从 JSON。
一个简单的 JSON 有效负载可能看起来像这样:
{"employees":[
{"name": "James", "email": "james.notmyemail@gmail.com"},
{"name": "Lea", "email": "lea.dontemailme@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>
</employees>
在本章中,我们将专注于 JSON。
当获取 JSON 有效负载时,我们实际上接收的是一个字符串。要将该字符串转换为数据对象,我们有几种选择,最受欢迎的包括 org.json
包等库。由于其轻量级特性,我们将关注 Moshi。
最后,我们将探讨从网络加载图片。这样做将允许我们提供最新的图片,并为用户的设备加载正确的图片。它还将允许我们仅在需要时加载图片,从而保持我们的 APK 大小更小。
从网络端点获取数据
为了本节的目的,我们将使用 The Cat API (thecatapi.com/
)。这个 RESTful API 为我们提供了关于,嗯……猫的大量数据。
要开始,我们需要创建一个新的项目。然后我们必须授予我们的应用程序互联网访问权限。这通过在 AndroidManifest.xml
文件中 Application
标签之前添加以下代码来完成:
<uses-permission
android:name="android.permission.INTERNET" />
接下来,我们需要设置我们的应用程序以包含 Retrofit。Retrofit 是由 Square 提供的一个类型安全的库,它建立在 OkHttp
HTTP 客户端之上。Retrofit 通过提供与几个解析库的集成来帮助我们生成 统一资源定位符(URL),这是我们想要访问的服务器端点的地址。它还通过简化 JSON 有效负载的解码使发送数据到服务器变得更容易。
注意
你可以在这里了解更多关于 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 合约的方式。接下来,你会注意到接口的名称暗示了这个接口最终可以覆盖对TheCatAPIService
的所有调用。
很不幸,Square 将这些合约的传统后缀选为Service
,因为在 Android 世界中,术语service
有不同的含义,正如你在第八章中将要看到的,服务、WorkManager 和通知。尽管如此,这已经成为了一种惯例。
为了定义我们的端点,我们首先使用适当的注解声明将要使用的调用方法——在我们的例子中,是@GET
。传递给注解的参数是要访问的端点路径。你会注意到https://api.thecatapi.com/v1/
已经被从该路径中移除。
这是因为这是TheCatAPI
所有端点的通用地址,因此它将在构造时传递给我们的 Retrofit 实例。接下来,我们为我们的函数选择一个有意义的名称——在这种情况下,我们将调用图像搜索端点,所以searchImages
似乎很合适。searchImages
函数的参数定义了我们在调用 API 时可以传递的值。
我们有几种不同的方式可以将数据传输到 API。@Query
允许我们定义添加到请求 URL 查询中的值(这是 URL 中问号之后的可选部分)。它需要一个键值对(在我们的例子中,我们有limit
和size
)和一个数据类型。如果数据类型不是字符串,该类型的值将被转换为字符串。任何传递的值都将为我们进行 URL 编码。
另一种方式是使用@Path
。这个注解可以用来用提供的值替换我们路径中用花括号包裹的标记。@Header
、@Headers
和@HeaderMap
注解将允许我们在请求中添加或删除 HTTP 头。@Body
可以用来在POST
和PUT
请求的体中传递内容。
最后,我们有一个返回类型。为了在这个阶段保持简单,我们将接受响应作为字符串。我们将我们的字符串包装在 Call
接口内。Call
是 Retrofit 执行网络请求的机制,可以是同步的(通过 execute()
)或异步的(通过 enqueue(Callback)
)。当使用协程时,我们可以使函数成为一个 suspend
函数,并省略 Call
包装器(有关协程的更多信息,请参见 第十四章,协程和 Flow)。
在我们的合约定义之后,我们可以让 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 代码呢?在干净的架构中,数据由仓库提供。仓库反过来又拥有数据源。其中一个数据源可以是一个网络数据源。这就是我们将实现我们的网络调用的地方。然后,我们的 ViewModels 将通过用例从仓库请求数据。
在 模型-视图-视图模型(MVVM)的情况下,视图模型是视图的抽象,它公开属性和命令。
对于我们的实现,我们将通过在 Activity 中实例化 Retrofit 和服务来简化这个过程。这不是一个好的实践。不要在生产应用中这样做。它扩展性不好,并且很难测试。相反,采用一种将你的视图与你的业务逻辑和数据解耦的架构。参见 第十五章,架构模式,以获取一些想法。
练习 5.01 – 从 API 读取数据
在接下来的章节中,我们将开发一个为一家虚构的秘密机构开发的应用程序,该机构在全球范围内拥有特工网络,拯救世界免受无数危险。这个秘密机构相当独特:它运营着秘密的猫特工。
在这个练习中,我们将创建一个应用,它从 The Cat API 中随机展示一个秘密猫特工。在你能够向用户展示 API 数据之前,你首先必须获取这些数据。让我们从这里开始:
-
创建一个新的 空活动 项目(文件 | 新建 | 新建项目 | 空活动)。点击 下一步。
-
将你的应用程序命名为
Cat Agent Profile
。 -
确保你的包名是
com.example.catagentprofile
。 -
将 保存位置 设置为你想要保存项目的地方。
-
将其他所有内容保留为默认值并点击 完成。
-
确保你处于 Android 视图在你的 项目 窗格中:
图 5.1 – 项目窗格中的 Android 视图
-
打开你的
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>
-
要将 Retrofit 和标量转换器添加到你的应用中,打开
build.gradle
应用模块(Gradle 脚本
|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
'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:
converter-scalars:2.9.0'
...
}
在撰写本文和执行此练习之间,一些依赖项可能已更改。你仍然只需添加前面代码块中加粗的行。这些行将添加 Retrofit 和读取服务器响应作为单个字符串的支持。
备注
值得注意的是,Retrofit 现在至少需要 Android API 21 或 Java 8。
-
点击 Android Studio 中的 同步项目与 Gradle 文件 按钮。
-
以 文本 模式打开你的
activity_main.xml
文件。 -
为了能够使用你的标签来展示最新的服务器响应,你需要给它分配一个 ID:
<TextView android:id="@+id/main_server_response" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World!" ... />
-
在
com.example.catagentprofile
中,然后选择 新建 | 包。 -
将你的包命名为
api
。 -
现在,在 项目 窗格中右键单击新创建的包(
com.example.catagentprofile.api
),然后选择 新建 | Kotlin 文件/类。 -
将你的新文件命名为
TheCatApiService
。对于 类型,选择 接口。 -
将以下内容添加到
interface
块中:interface TheCatApiService { @GET("images/search") fun searchImages( @Query("limit") limit: Int, @Query("size") format: String ) : Call<String> }
这定义了图像搜索端点。请确保导入所有必需的 Retrofit 依赖项。
-
打开你的
MainActivity
文件。 -
在
MainActivity
类块顶部添加以下内容:class MainActivity : AppCompatActivity() { private val retrofit by lazy { Retrofit.Builder() .baseUrl("https://api.thecatapi.com/v1/") .addConverterFactory( ScalarsConverterFactory.create() ).build() } private val theCatApiService by lazy { retrofit.create(TheCatApiService::class.java) } ... }
这将实例化 Retrofit 和 API 服务。我们使用 lazy
确保实例仅在需要时创建。
-
添加
serverResponseView
作为字段:class MainActivity : AppCompatActivity() { private val serverResponseView: TextView by lazy { findViewById(R.id.main_server_response) }
这将在第一次访问 serverResponseView
时查找具有 main_server_response
ID 的视图,并保留对该视图的引用。
-
现在,在
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().orEmpty() }" ) } } }) }
此函数将触发搜索请求并处理可能的结果——成功响应、错误响应以及任何其他抛出的异常。
-
在
onCreate()
中调用getCatImageResponse()
。这将触发调用,一旦活动创建完成:override fun onCreate(savedInstanceState: Bundle?) { ... getCatImageResponse() }
-
添加缺失的导入。
-
通过点击运行‘app’按钮或按Ctrl + R来运行你的应用。在模拟器上,它应该看起来像这样:
图 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 都设计得很好,你可能会遇到没有意义的字段名称,如 fn
或 last
,或者不一致的命名。幸运的是,有一个解决方案可以解决这个问题。Moshi 提供了一个 @Json
注解。它可以用来将 JSON 字段名称映射到一个有意义的字段名称:
data class UserData(
@field:Json(name = "fn") val firstName: String,
@field:Json(name = "last") val lastName: String
)
使用 field:
前缀来确保生成的 Java 字段被正确注解。
有些人认为,即使 API 名称与字段名称相同,为了保持一致性,最好还是包含该注解。我们更喜欢当字段名称足够清晰时直接转换的简洁性。当我们的代码被混淆时,这种方法可能会受到挑战。如果我们这样做,我们必须排除我们的数据类或者确保所有字段都被注解。
虽然我们并不总是有幸拥有文档齐全的 API,但当我们确实有文档时,在设计我们的模型时最好查阅文档。我们的模型将是一个数据类,我们将在其中解码我们发出的所有调用的 JSON 数据。The Cat API 的图像搜索端点的文档可以在 protect-eu.mimecast.com/s/d7uqCWwlKf56Q17s6kKb5?domain=developers.thecatapi.com/
找到。
你经常会发现文档是不准确或错误的。如果这种情况发生,最好的办法是联系 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<String>
,而是现在可以使用 Call<List<ImageResultData>>
。
接下来,我们需要更新我们的 Retrofit 实例的构建。我们将不再使用 ScalarsConverterFactory
,而是现在将使用 MoshiConverterFactory
。最后,我们需要更新我们的回调,因为它不再应该处理字符串调用,而是应该处理 List<ImageResultData>
:
@GET("images/search")
fun searchImages(
@Query("limit") limit: Int,
@Query("size") format: String
) : Call<List<ImageResultData>>
练习 5.02 – 从 API 响应中提取图像 URL
因此,我们有一个作为字符串的服务器响应。现在,我们想要从该字符串中提取图像 URL,并在屏幕上仅显示该 URL:
-
打开应用的
build.gradle
文件,并将标量转换器的实现替换为 Moshi 转换器的一个:implementation 'com.squareup.retrofit2: retrofit:2.9.0' implementation 'com.squareup.retrofit2: converter-moshi:2.9.0' testImplementation 'junit:junit:4.13.2'
-
点击同步项目与 Gradle 文件按钮。
-
在您的应用包(
com.example.catagentprofile
)下创建一个model
包。 -
在
com.example.catagentprofile.model
包中,创建一个名为CatBreedData
的新 Kotlin 文件。 -
在新创建的文件中填充以下内容:
package com.example.catagentprofile.model data class CatBreedData( val name: String, val temperament: String )
-
接下来,在相同的包下创建
ImageResultData
。 -
设置其内容如下:
package com.example.catagentprofile.model import com.squareup.moshi.Json data class ImageResultData( @field:Json(name = "url") val imageUrl: String, val breeds: List<CatBreedData> )
-
打开
TheCatApiService
文件并更新searchImages
的返回类型:@GET("images/search") fun searchImages( @Query("limit") limit: Int, @Query("size") format: String ) : Call<List<ImageResultData>>
-
最后,打开
MainActivity
。 -
更新 Retrofit 初始化块以使用 Moshi 转换器反序列化 JSON:
private val retrofit by lazy { Retrofit.Builder() .baseUrl("https://api.thecatapi.com/v1/") .addConverterFactory(MoshiConverterFactor .create()) .build() }
-
更新
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().orEmpty()}" ) } } }) }
-
现在,您需要检查是否有成功的响应,并且至少有一个
ImageResultData
实例。然后,您可以读取该实例的imageUrl
属性并将其展示给用户。 -
运行您的应用程序。现在它应该看起来像以下这样:
图 5.3 – 应用展示解析后的图片 URL
- 再次强调,由于 API 响应的随机性,您的 URL 可能不同。
您现在已成功从 API 响应中提取了特定的属性。接下来,我们将学习如何从 API 提供的 URL 加载图片。
从远程 URL 加载图片
我们刚刚学习了如何从 API 响应中提取数据。这些数据通常包括我们想要展示给用户的图片的 URL。要实现这一点,涉及的工作量相当大。首先,您必须从 URL 获取图片作为二进制流。然后,您需要将那个二进制流转换成图片(它可能是 GIF、JPEG 或其他几种图像格式之一)。
然后,您需要将其转换为位图实例,可能需要调整大小以节省内存。您还可能想在此处应用其他转换。然后,您需要将其设置到ImageView
。
这听起来像是一项繁重的工作,不是吗?幸运的是,对我们来说,有几个库可以为我们完成所有这些工作(甚至更多)。最常用的库是 Square 的Picasso(见square.github.io/picasso/
)和 Bump Technologies 的Glide。Facebook 的Fresco(见frescolib.org/
)相对不太受欢迎。最近获得关注的一个库是Coil(见coil-kt.github.io/coil/
)。
我们将继续使用 Glide,因为它始终是两种流行的图像加载选项中最快的,无论是从互联网还是从缓存中加载。然而,值得注意的是,Picasso 更轻量级,因此这是一个权衡,这两个库都非常有用。
要将 Glide 添加到您的项目中,将其添加到应用程序的build.gradle
文件中的dependencies
块:
dependencies {
implementation 'com.github.bumptech.glide:glide:4.14.2'
...
}
事实上,因为我们可能在以后改变主意,这是一个很好的机会将具体的库抽象出来,以拥有我们自己的更简单的接口。所以,让我们首先定义我们的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 上下文非常智能。这意味着我们可以为 Activity
和 Fragment
范围分别实现不同的实现,并且 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_image
的 ImageView
。
我们现在可以在 MainActivity
中创建 GlideImageLoader
的实例:
private val imageLoader: ImageLoader by lazy {
GlideImageLoader(this) }
在生产应用中,你会注入依赖项,而不是直接创建。
接下来,我们告诉我们的 Glide 加载器加载图像,并在加载完成后将其中心裁剪到提供的 ImageView
中。这意味着图像将被缩放或缩小以完全填充 ImageView
,任何多余的内容将被裁剪掉。由于我们之前已经获取了一个图像 URL,我们只需要进行调用:
val firstImageUrl = imageResults?.firstOrNull()?.imageUrl
.orEmpty()
if (!firstImageUrl.isBlank()) {
imageLoader.loadImage(firstImageUrl, profileImageView)
} else {
Log.d("MainActivity", "Missing image URL")
}
我们必须确保结果包含一个非空或由空格组成的字符串(前一个代码块中的 isBlank()
)。然后,我们可以安全地将 URL 加载到 ImageView
中。完成。如果我们现在运行我们的应用,我们应该会看到以下截图:
图 5.4 – 服务器响应图像 URL 与实际图像
记住 API 返回的是随机结果,所以实际图像可能不同。如果我们很幸运,甚至可能会得到一个动画 GIF,我们将会看到它动画播放。
练习 5.03 – 从获取的 URL 加载图像
在之前的练习中,我们从 API 响应中提取了图像 URL。现在,我们将使用该 URL 从网络获取图像并在我们的应用中显示:
-
打开应用的
build.gradle
文件并添加 Glide 依赖:dependencies { ... implementation 'com.github.bumptech.glide:glide:4.14.2' ... }
将项目与 Gradle 文件同步。
-
在左侧
com.example.catagentprofile
) 上右键单击并选择 新建 | Kotlin 文件/类。 -
在 名称 字段中填写
ImageLoader
。对于 类型,选择 接口。 -
打开新创建的
ImageLoader.kt
文件并更新如下:interface ImageLoader { fun loadImage(imageUrl: String, imageView: ImageView) }
这将是应用中任何图像加载器的接口。
-
再次右键单击项目包名并选择 新建 | Kotlin 文件/类。
-
将新文件命名为
GlideImageLoader
并选择 类 作为 类型。 -
更新新创建的文件:
class GlideImageLoader(private val context: Context) : ImageLoader { override fun loadImage(imageUrl: String, imageView: ImageView) { Glide.with(context).load(imageUrl) .centerCrop().into(imageView) } }
-
打开
activity_main.xml
并更新如下:<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout ...> <TextView android:id="@+id/main_server_response" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World!" app:layout_constraintBottom_toTopOf="@+id/ main_profile_image" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <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" /> </androidx.constraintlayout.widget.ConstraintLayout>
这将在 TextView
下方添加一个名为 main_profile_image
的 ImageView
。
-
打开
MainActivity.kt
文件。 -
在类的顶部添加一个字段用于你新添加的
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) }
-
在
onCreate(Bundle?)
函数上方定义ImageLoader
:private val imageLoader: ImageLoader by lazy { GlideImageLoader(this) } override fun onCreate(savedInstanceState: Bundle?) {
-
更新您的
getCatImageResponse()
函数如下:private fun getCatImageResponse() { val call = theCatApiService.searchImages(1, "full") call.enqueue(object : Callback<List<ImageResultData>> { override fun onFailure(...) { ... } override fun onResponse(...) { if (response.isSuccessful) { val imageResults = response.body() val firstImageUrl = imageResults ?.firstOrNull()?.imageUrl.orEmpty() 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(). orEmpty()}" ) } } }) }
-
现在,一旦您有一个非空 URL,它将被加载到
profileImageView
。 -
运行应用程序:
图 5.5 – 展示随机图像及其源 URL 的练习结果
以下是一些附加步骤。
-
更新您的布局如下:
<?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
标签并整理视图布局。现在,您的布局看起来更像是一个正规的猫代理配置文件应用程序。
-
在
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) }
-
更新
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.orEmpty() 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(). orEmpty()}") } } }) }
这样做是为了将 API 返回的第一个品种加载到 agentNameView
中,如果失败则显示 Unknown
。
- 在撰写本文时,The Cat API 中没有多少图片包含品种数据。然而,如果您运行应用程序足够多次,您最终会看到如下内容:
图 5.6 – 展示猫代理图像和品种
在本章中,我们学习了如何从远程 API 获取数据。然后我们学习了如何处理这些数据并从中提取所需的信息。最后,我们学习了如何在给定图像 URL 的情况下在屏幕上显示图像。
在以下活动中,我们将应用我们的知识来开发一个应用程序,该应用程序会告诉用户纽约当前的天气,并向用户提供相关的天气图标。
活动第 5.01 部分 – 显示当前天气
假设我们想要构建一个显示纽约当前天气的应用程序。此外,我们还想显示代表当前天气的图标。
本活动旨在创建一个应用程序,该应用程序轮询 API 端点以获取当前天气的 JSON 格式,将数据转换为本地模型,并使用该模型来显示当前天气。它还提取表示当前天气的图标 URL 并将其图标加载到屏幕上显示。
在本活动中,我们将使用免费的 OpenWeatherMap API。文档可以在 openweathermap.org/api
找到。要注册 API 令牌,请访问 home.openweathermap.org/users/sign_up
。您可以在 home.openweathermap.org/api_keys
找到您的密钥并按需生成新的密钥。
本活动的步骤如下:
-
创建一个新的应用程序。
-
授予应用程序互联网权限,以便能够进行 API 和图像请求。
-
将 Retrofit、Moshi 转换器和 Glide 添加到应用程序中。
-
更新应用程序布局以支持以文本形式(简短和详细描述)以及天气图标图像的形式展示天气。
-
定义模型。创建包含服务器响应的类。
-
添加 Retrofit 服务以使用 OpenWeatherMap API,
api.openweathermap.org/data/2.5/weather
。 -
使用 Moshi 转换器创建 Retrofit 实例。
-
调用 API 服务。
-
处理成功的服务器响应。
-
处理不同的失败场景。
预期的输出如下所示:
图 5.7 – 最终的天气应用
注意
该活动的解决方案可以在 packt.link/By7eE
找到。
摘要
在本章中,我们学习了如何使用 Retrofit 从 API 获取数据。然后我们学习了如何使用 Moshi 处理 JSON 响应以及纯文本响应。我们还看到了如何处理不同的错误场景。
我们后来学习了如何使用 Glide 从 URL 加载图片,以及如何通过 ImageView
将它们展示给用户。
有很多流行的库用于从 API 获取数据以及加载图片。我们只介绍了一些最受欢迎的库。你可能想尝试其他一些库,以找出最适合你需求的库。
在下一章中,我们将介绍 RecyclerView
,这是一个强大的 UI 组件,我们可以用它向用户展示项目列表。
第六章:添加和与 RecyclerView 交互
在本章中,你将学习如何将项目列表和网格添加到你的应用中,并有效地利用RecyclerView
的回收能力。你还将学习如何处理屏幕上项目视图的用户交互,并支持不同的项目视图类型——例如,用于标题。在本章的后面部分,你将动态地添加和删除项目。
到本章结束时,你将掌握向用户展示丰富项目交互式列表所需的所有技能。
在上一章中,我们学习了如何从 API 中获取数据,包括项目列表和图像 URL,以及如何从 URL 加载图像。将这一知识结合展示项目列表的能力,是本章的目标。
很频繁地,你将想要向用户展示一个项目列表。例如,你可能想向他们展示设备上的图片列表,或者让他们从一个包含所有国家的列表中选择他们的国家。为此,你需要填充多个视图,所有这些视图共享相同的布局,但展示不同的内容。
从历史上看,这是通过使用ListView
或GridView
来实现的。虽然这两种方法仍然是可行的选项,但它们并不提供RecyclerView
的强大功能和灵活性。例如,它们不支持大型数据集,不支持水平滚动,并且不提供丰富的分隔符自定义。
注意
使用RecyclerView.ItemDecorator
可以轻松地自定义RecyclerView
中项目之间的分隔符。
那么,RecyclerView
到底做了什么?RecyclerView
负责协调表示项目列表的视图的创建、填充和重用(因此得名)。要使用RecyclerView
,你需要熟悉其两个依赖项——适配器(以及通过它,视图持有者)和布局管理器。这些依赖项为我们的RecyclerView
提供要显示的内容,并告诉它如何呈现这些内容以及如何在屏幕上布局。
适配器为RecyclerView
提供绘制在屏幕上的子视图(在RecyclerView
中嵌套的 Android 视图,用于表示单个数据项),将这些视图绑定到数据(通过ViewHolder
实例),并报告用户与这些视图的交互。
布局管理器告诉RecyclerView
如何布局其子项。我们默认提供了三种布局类型——线性、网格和交错网格,分别由LinearLayoutManager
、GridLayoutManager
和StaggeredGridLayoutManager
管理。
注意
本章依赖于使用 Jetpack RecyclerView
库:packt.link/FBX4d
。
在本章中,我们将开发一个应用,列出特工及其当前是否活跃或睡眠(因此不可用)。然后,应用将允许我们通过滑动来添加新特工或删除现有特工。不过,有一个转折——正如你在 第五章,“基本库:Retrofit、Moshi 和 Glide”中所看到的,我们所有的特工都将变成猫。
在本章中,我们将涵盖以下主题:
-
将
RecyclerView
添加到我们的布局 -
填充
RecyclerView
-
在
RecyclerView
中响应用户点击 -
支持不同的项目类型
-
滑动删除项目
-
交互式添加项目
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,链接为 packt.link/IJbeG
将 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_width
和 android:layout_height
属性。
我们可以使用可选的 tools:listitem
属性来告诉 Android Studio 在预览工具栏中哪个布局应该被填充为列表项。这将给我们一个关于 RecyclerView
在我们的应用中可能看起来如何的印象。
在我们的布局中添加一个 RecyclerView
标签意味着我们现在有一个空容器来存放代表列表项的子视图。一旦填充,它将为我们处理子视图的展示、滚动和回收。
练习 6.01 – 在主活动中添加一个空的 RecyclerView
要在你的应用中使用 RecyclerView
,你首先需要将它添加到你的布局之一中。让我们将它添加到由我们的主活动填充的布局中:
-
首先创建一个新的空活动项目(
My RecyclerView App
)。确保你的包名为com.example.myrecyclerviewapp
。 -
将保存位置设置为你要保存项目的地方。将其他所有内容保留在默认值,并点击 完成。确保你正在你的 项目 面板中的 Android 视图中:
图 6.1 – 项目面板中的 Android 视图
-
以 文本 模式打开你的
activity_main.xml
文件。 -
要将你的标签变成屏幕顶部的标题,并在其下方添加你的
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" />
-
在
TextView
标签之后添加以下内容,以将一个空的RecyclerView
元素添加到你的布局中,并限制在hello_label
TextView
标题下方:<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
...>
<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>
- 通过点击 运行应用 按钮或按 Ctrl + R(在 Windows 中为 Shift + F10)来运行你的应用。在模拟器上,它应该看起来像这样:
图 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
)
我们将如下定义 EmployeeRole
和 Gender
:
enum class EmployeeRole {
HumanResources,
Management,
Technology
}
enum class Gender {
Female,
Male,
Unknown
}
这些值当然只是作为示例提供的。请随意添加更多您自己的内容!
图 6.3 – 模型的层次结构
现在,我们知道在绑定到视图时可以期待什么数据,因此我们可以设计我们的视图来展示这些数据(这是实际布局的简化版本,我们将将其保存为 item_employee.xml
)。我们将从 ImageView
开始:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ...>
<ImageView
android:id="@+id/item_employee_photo"
android:layout_width="60dp"
android:layout_height="60dp"
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="♂" />
</androidx.constraintlayout.widget.ConstraintLayout>
到目前为止,没有什么新的内容。你应该能够识别出从 第二章,构建用户 屏幕流程 中提到的所有不同的视图类型:
图 6.4 – item_cat.xml 布局文件的预览
现在我们有了数据模型和布局,我们已经拥有了将数据绑定到视图所需的一切。为此,我们将实现一个视图持有者。通常,视图持有者有两个职责——它持有对视图的引用(正如其名称所暗示的),但它还负责将数据绑定到该视图。我们将如下实现我们的视图持有者:
private const val FEMALE_SYMBOL = "\u2640"
private const val MALE_SYMBOL = "\u2642"
private const val UNKNOWN_SYMBOL = "?"
class EmployeeViewHolder(
containerView: View,
private val imageLoader: ImageLoader
) : ViewHolder(containerView) {
private val employeeNameView: TextViewby lazy {
containerView.findViewById(R.id.item_employee_name) }
private val employeeRoleView: TextViewby lazy {
containerView.findViewById(R.id.item_employee_role) }
private val employeeBioView: TextViewby lazy {
containerView.findViewById(R.id.item_employee_bio) }
private val employeeGenderView: TextViewby 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 employees = mutableListOf<EmployeeUiModel>()
fun setData(newEmployees: List<EmployeeUiModel>) {
employees.clear()
employees.addAll(newEmployees)
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() = employees.size
override fun onBindViewHolder(
holder: EmployeeViewHolder, position: Int) {
holder.bindData(employees [position])
}
}
让我们回顾一下这个实现。首先,我们通过适配器的构造函数注入我们的依赖项。这将使测试我们的适配器变得容易得多,但也会允许我们轻松地更改其某些行为(例如,替换图像加载库)。实际上,在这种情况下,我们甚至不需要更改适配器。
然后,我们定义一个私有的可变EmployeeUiModel
列表来存储适配器当前提供给RecyclerView
的数据。我们还引入了一个setData
方法来填充该列表。请注意,我们保留一个本地列表并设置其内容,而不是直接设置employees
。
这主要是因为 Kotlin,就像 Java 一样,通过引用传递变量。通过引用传递变量意味着传递给适配器的列表内容的变化会改变适配器持有的列表。例如,如果某个项目在适配器外部被删除,适配器也会删除该项目。
这成为一个问题,因为适配器不会意识到这个变化,因此无法通知RecyclerView
。在适配器外部修改列表周围还有其他风险,但涵盖这些风险超出了本书的范围。
将数据修改封装在函数中的另一个好处是,我们避免了忘记通知RecyclerView
数据集已更改的风险,这是通过调用notifyDataSetChanged()
来实现的。
我们继续实现适配器的onCreateViewHolder(ViewGroup, Int)
函数。当RecyclerView
需要一个新的ViewHolder
来在屏幕上渲染数据时,会调用此函数。它为我们提供了一个ViewGroup
容器和一个视图类型(我们将在本章后面讨论视图类型)。
函数随后期望我们返回一个使用视图初始化的视图持有者(在我们的情况下,是一个已充气的视图)。因此,我们充气我们之前设计的视图,并将其传递给一个新的EmployeeViewHolder
实例。请注意,传递给充气函数的最后一个参数是false
。
这确保我们不会将新充气的视图附加到父视图上。视图的附加和分离将由布局管理器管理。将视图设置为true
或省略它会导致抛出IllegalStateException
。最后,我们返回新创建的EmployeeViewHolder
。
要实现getItemCount()
,我们只需返回我们的employees
列表的大小。
最后,我们实现onBindViewHolder(EmployeeViewHolder, Int)
。这是通过将存储在employees
中的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
了。
在您开始之前,快速回顾一下——在前一个练习中,我们介绍了一个空列表,用于存储用户可用的秘密猫代理列表。在这个练习中,您将填充这个列表,向用户展示机构中可用的秘密猫代理:
- 为了保持我们的文件结构整洁,我们首先创建一个模型包。右键单击应用程序的包名,然后选择新建 | 包:
图 6.5 – 创建新包
-
将新包命名为
model
。点击确定以创建包。 -
要创建我们的第一个模型数据类,右键单击新创建的模型包,然后选择新建 | Kotlin 文件/类。
-
在
CatUiModel
下,将kind保留为文件,然后点击确定。这将是我们关于每个单独猫代理的数据的类。 -
将以下内容添加到新创建的
CatUiModel.kt
文件中,以定义包含猫代理所有相关属性的数据类:data class CatUiModel( val gender: Gender, val breed: CatBreed, val name: String, val biography: String, val imageUrl: String )
对于每个猫代理,除了他们的名字和照片,我们还想了解他们的性别、品种和传记。这将帮助我们选择适合任务的正确代理。
-
再次右键单击模型包,然后导航到新建 | Kotlin 文件/类。
-
这次,将新文件命名为
CatBreed
并将kind
设置为Enum
类。这个类将包含我们的不同猫品种。 -
更新您新创建的
enum
,添加一些初始值,如下所示:enum class CatBreed { AmericanCurl, BalineseJavanese, ExoticShorthair }
-
重复 步骤 6 和 步骤 7,这次将文件命名为
Gender
。这将包含猫代理性别的有效值。 -
更新
Gender
枚举,如下所示:enum class Gender { Female, Male, Unknown }
-
现在,为了定义包含每个猫代理数据的视图的布局,通过右键单击
layout
并选择 新建 | 布局资源文件 创建一个新的布局资源文件:
图 6.6 – 创建新的布局资源文件
-
将资源命名为
item_cat
。保留所有其他字段不变,然后点击 确定。 -
更新新创建的
item_cat.xml
文件的内容(以下代码块因空间限制已被截断,请使用以下链接查看需要添加的完整代码):
item_cat.xml
<ImageView
android:id="@+id/item_cat_photo"
android:layout_width="60dp"
android:layout_height="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@color/colorPrimary" />
<TextView
android:id="@+id/item_cat_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_cat_photo"
app:layout_constraintTop_toTopOf="parent"
tools:text="Oliver" />
本步骤的完整代码可以在 packt.live/3sopUjo
找到。
这将创建一个包含用于我们列表中的名称、品种和传记的图片和文本字段的布局。
-
您需要一份
ImageLoader.kt
的副本,它在 第五章 的 Essential Libraries: Retrofit, Moshi, and Glide 中介绍过,因此右键单击您的应用包名称,导航到ImageLoader
并将其转换为 接口,然后点击 确定。 -
与 第五章 中的 Essential Libraries: Retrofit, Moshi, and Glide 类似,您在这里只需要添加一个函数:
interface ImageLoader { fun loadImage(imageUrl: String, imageView: ImageView) }
确保导入 ImageView
。
-
再次右键单击您的应用包名称,然后选择 新建 | Kotlin 文件/类。
-
将新文件命名为
CatViewHolder
。点击 确定。 -
要实现
CatViewHolder
,它将绑定猫代理数据到您的视图,用以下内容替换CatViewHolder.kt
文件的内容:private val FEMALE_SYMBOL = "\u2640" private val MALE_SYMBOL = "\u2642" 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(cat: CatUiModel) { imageLoader.loadImage(cat.imageUrl, catPhotoView) catNameView.text = cat.name catBreedView.text = when (cat.breed) { AmericanCurl -> "American Curl" BalineseJavanese -> "Balinese-Javanese" ExoticShorthair -> "Exotic Shorthair" } catBiographyView.text = cat.biography catGenderView.text = when (cat.gender) { Female -> FEMALE_SYMBOL Male -> MALE_SYMBOL else -> UNKNOWN_SYMBOL } } }
-
仍然在我们的应用包名称下,创建一个名为
CatsAdapter
的新 Kotlin 文件。 -
要实现
CatsAdapter
,它负责存储RecyclerView
的数据以及创建视图持有实例并使用它们将数据绑定到视图,用以下内容替换CatsAdapter.kt
文件的内容:package com.example.myrecyclerviewapp import { ... } class CatsAdapter( private val layoutInflater: LayoutInflater, private val imageLoader: ImageLoader ) : RecyclerView.Adapter<CatViewHolder>() { private val cats = mutableListOf<CatUiModel>() fun setData(newCats: List<CatUiModel>) { cats.clear() cats.addAll(newCats) 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() = cats.size override fun onBindViewHolder( holder: CatViewHolder, position: Int) { holder.bindData(cats[position]) } }
-
在这一点上,您需要在项目中包含 Glide。首先,将以下行代码添加到您的应用
gradle.build
文件中的dependencies
块内:implementation 'com.github.bumptech.glide:glide:4.14.2'
-
在您的应用包路径中创建一个
GlideImageLoader
类,包含以下内容:package com.example.myrecyclerviewapp [imports] class GlideImageLoader(context: Context) : ImageLoader { private val glide by lazy { Glide(context) } override fun loadImage(imageUrl: String, imageView: ImageView) { glide.load(imageUrl) .centerCrop().into(imageView) } }
这是一个简单的实现,假设加载的图片应该始终居中裁剪。
-
更新您的
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/image/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
并用一些硬编码的数据填充它。
-
在您的
AndroidManifest.xml
文件中,在manifest
标签中的应用标签之前添加以下内容:<uses-permission android:name="android.permission.INTERNET" />
有这个标签将允许您的应用从互联网下载图片。
-
为了进行一些最后的润色,例如给我们的标题视图一个合适的名称和文本,更新您的
activity_main.xml
文件,如下所示:<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout ...> <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>
-
此外,更新您的
strings.xml
文件,为您的应用提供一个合适的名称和标题:<resources> <string name="app_name">SCA - Secret Cat Agents</string> <string name="main_title">Our Agents</string> </resources>
-
运行您的应用。它应该看起来像这样:
图 6.7 – 带有硬编码的秘密猫特工的 RecyclerView
如您所见,RecyclerView
现在有了内容,您的应用开始成形。注意,相同的布局被用来根据绑定到每个实例的数据展示不同的项目。正如您所期望的,如果您添加足够的项目使它们超出屏幕,则可以滚动。接下来,我们将探讨允许用户与RecyclerView
内部的项目进行交互。
在 RecyclerView 中响应点击
如果我们想让用户从显示的列表中选择一个项目呢?为了实现这一点,我们需要将点击事件回传到我们的应用中。
实现点击交互的第一步是在ViewHolder
级别捕获项目的点击。为了保持我们的视图持有者和适配器之间的分离,我们在视图持有者中定义了一个嵌套的OnClickListener
接口。我们选择在视图持有者中定义接口,因为那和监听器是紧密耦合的。
在我们的情况下,这个接口将只有一个功能。这个函数的目的是通知视图持有者的所有者关于点击的信息。视图持有者的所有者通常是Fragment
或Activity
。由于我们知道视图持有者可以被重用,我们知道在构造时定义它可能会很有挑战性,这样我们就能知道哪个项目被点击了(因为随着重用,那个项目会随时间改变)。
我们通过在点击时将当前显示的项目回传给视图持有者的所有者来解决这个问题。这意味着我们的接口看起来会是这样:
interface OnClickListener {
fun onClick(cat: CatUiModel)
}
我们还将把这个监听器作为一个参数添加到我们的ViewHolder
构造函数中:
class CatViewHolder(
containerView: View,
private val imageLoader: ImageLoader,
private val onClickListener: OnClickListener
) : ViewHolder(containerView) {
...
}
它将这样使用:
containerView.setOnClickListener {
onClickListener.onClick(cat) }
现在,我们希望我们的适配器传递一个监听器。反过来,这个监听器将负责通知适配器的所有者关于点击的信息。这意味着我们的适配器也需要一个嵌套的监听器接口,这与我们在视图持有者中实现的接口非常相似。
注意
虽然这似乎是重复的,可以通过重用相同的监听器来避免,但这并不是一个好主意,因为它会导致通过监听器在视图持有者和适配器之间产生紧密耦合。当你想要你的适配器也通过监听器报告其他事件时会发生什么?你将不得不处理来自视图持有者的这些事件,即使它们实际上并没有在视图持有者中实现。
最后,为了处理点击事件并显示对话框,我们在活动中定义了一个监听器,并将其传递给适配器。我们设置这个监听器,在点击时显示对话框。在 MVVM 实现中,你会在这一点通知ViewModel
关于点击的信息。ViewModel
随后会更新其状态,告诉视图(我们的活动)应该显示对话框。
练习 6.03 – 响应点击
你的应用已经向用户展示了秘密猫特工的列表。现在是时候允许用户通过点击其视图来选择一个秘密猫特工了。点击事件从视图持有器委托到适配器再到活动,如图图 6.9所示:
图 6.8 – 点击事件流程
完成此练习需要遵循以下步骤:
-
打开你的
CatViewHolder.kt
文件。在最后的闭合花括号之前向其中添加一个嵌套接口:interface OnClickListener { fun onClick(cat: CatUiModel) }
这将是监听器必须实现以注册对单个猫项目点击事件的接口。
-
更新
CatViewHolder
构造函数以接受OnClickListener
并使containerView
可访问:class CatViewHolder( private val containerView: View, private val imageLoader: ImageLoader, private val onClickListener: OnClickListener ) : ViewHolder(containerView) {
现在,当构建CatViewHolder
构造函数时,你还需要为项目视图注册点击事件。
-
在
bindData(CatUiModel)
函数的顶部添加以下内容以拦截点击并报告给提供的监听器:containerView.setOnClickListener { onClickListener.onClick(cat) }
-
现在,打开你的
CatsAdapter.kt
文件。在最后的闭合花括号之前添加这个嵌套接口:interface OnClickListener { fun onItemClick(cat: CatUiModel) }
这定义了监听器必须实现的接口,以接收来自适配器的项目点击事件。
-
更新
CatsAdapter
构造函数以接受一个实现你刚刚定义的OnClickListener
适配器的调用:class CatsAdapter( private val layoutInflater: LayoutInflater, private val imageLoader: ImageLoader, private val onClickListener: OnClickListener ) : RecyclerView.Adapter<CatViewHolder>() {
-
在
onCreateViewHolder(ViewGroup, Int)
中,更新视图持有者的创建,如下所示:return CatViewHolder(view, imageLoader, object : CatViewHolder.OnClickListener { override fun onClick(cat: CatUiModel) = onClickListener.onItemClick(cat) })
这将添加一个匿名类,将ViewHolder
的点击事件委托给适配器监听器。
-
最后,打开你的
MainActivity.kt
文件。按照以下方式更新catsAdapter
的构建,以向适配器提供所需的依赖项,形式为一个匿名监听器,通过显示对话框来处理点击事件:private val catsAdapter by lazy { CatsAdapter(layoutInflater, GlideImageLoader(this), object : CatsAdapter.OnClickListener { override fun onClick(cat: CatUiModel) = onClickListener.onItemClick(cat) } ) }
-
在最后的闭合花括号之前添加以下函数:
private fun showSelectionDialog(cat: CatUiModel) { AlertDialog.Builder(this) .setTitle("Agent Selected") .setMessage("You have selected agent ${cat.name}") .setPositiveButton("OK") { _, _ -> }.show() }
此函数将显示一个对话框,显示传递进来的猫的数据名称。
- 确保导入正确的
AlertDialog
版本,即androidx.appcompat.app.AlertDialog
,而不是android.app.AlertDialog
。
注意
AppCompat 版本通常是一个更好的选择,因为它提供了向后兼容性。
- 运行你的应用。点击猫中的一个应该现在会打开一个对话框:
图 6.9 – 显示已选择代理的对话框
尝试点击不同的项目,并注意显示的不同消息。你现在知道如何响应用户在RecyclerView
内部点击物品。接下来,我们将探讨如何在我们的列表中支持不同的项目类型。
支持不同的项目类型
在前面的章节中,我们学习了如何处理单一类型的物品列表(在我们的案例中,所有物品都是CatUiModel
)。如果你想要支持多种类型的物品,会发生什么?一个很好的例子是在我们的列表中添加分组标题。
假设我们不是得到猫的列表,而是得到包含快乐猫和悲伤猫的列表。这两组猫的前面都有对应组的标题。我们的列表现在将包含 ListItem
实例,而不是 CatUiModel
实例的列表。ListItem
可能看起来像这样:
sealed class ListItem {
data class Group(val name: String) : ListItem()
data class Cat(val cat: 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_GROUP
和 VIEW_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(cat: CatUiModel) =
onClickListener.onItemClick(cat)
})
}
else -> throw IllegalArgumentException(
"Unknown view type requested: $viewType")
}
与此函数的早期实现不同,我们现在考虑了 viewType
的值。
正如我们所知,viewType
应该是我们从 getItemViewType(Int)
返回的值之一。
对于这些值(VIEW_TYPE_GROUP
和 VIEW_TYPE_CAT
),我们填充相应的布局并构建合适的视图持有者。请注意,我们从未期望收到任何其他值,因此如果遇到这样的值,我们会抛出异常。
注意
根据您的需求,您可以选择返回一个带有布局的默认视图持有者,显示错误或什么也不显示。记录这样的值可能也是一个好主意,这样您可以调查为什么收到它们,并决定如何处理它们。
对于我们的组标题布局,一个简单的 TextView
可能就足够了。对于猫,可以使用 item_cat.xml
布局。
现在,让我们继续到视图持有者。我们需要为组标题创建一个视图持有者。这意味着我们现在将有两个不同的视图持有者。然而,我们的适配器只支持一种适配器类型。最简单的解决方案是定义一个通用的视图持有者,GroupViewHolder
和 CatViewHolder
都会扩展它。
让我们称它为 ListItemViewHolder
。ListItemViewHolder
类可以是抽象的,因为我们从不打算直接使用它。为了使其更容易绑定数据,我们还可以在我们的抽象视图持有者中引入一个函数——abstract fun bindData(listItem: ListItemUiModel)
。
我们的具体实现可以期望接收特定类型,因此我们可以在 GroupViewHolder
和 CatViewHolder
分别添加以下行:
require(listItem is ListItemUiModel.Group)
{ "Expected ListItemUiModel.Group" }
require(listItem is ListItemUiModel.Cat)
{ "Expected ListItemUiModel.Cat" }
具体来说,在 CatViewHolder
中,得益于一些 Kotlin 魔法,我们可以使用 define val cat = listItem.cat
并保持类中其余部分不变。
进行了这些更改后,我们现在可以期待看到 快乐猫
和 悲伤猫
组标题,每个标题后面跟着相关的猫。
练习 6.04 – 向 RecyclerView 添加标题
我们现在希望能够以两组形式展示我们的秘密猫特工 – 可用于部署到现场的活跃特工和目前无法部署的卧底特工。我们将通过在活跃特工和卧底特工上方添加标题来实现这一点:
-
在
com.example.myrecyclerviewapp.model
下创建一个名为ListItemUiModel
的新 Kotlin 文件。 -
在
ListItemUiModel.kt
文件中添加以下内容,定义我们的两种数据类型 – 标题和猫:sealed class ListItemUiModel { data class Title(val title: String) : ListItemUiModel() data class Cat(val cat: CatUiModel) : ListItemUiModel() }
-
在
com.example.myrecyclerviewapp
下创建一个名为ListItem ViewHolder
的新 Kotlin 文件。这将是我们的基础视图持有者。 -
在
ListItemViewHolder.kt
文件中填充以下内容:abstract class ListItemViewHolder(containerView: View ) : RecyclerView.ViewHolder(containerView) { abstract fun bindData(listItem: ListItemUiModel) }
-
打开
CatViewHolder.kt
文件。 -
使
CatViewHolder
继承ListItemViewHolder
:class CatViewHolder(...) : ListItemViewHolder(containerView) {
-
将
bindData(CatUiModel)
参数替换为ListItemUiModel
并使其覆盖ListItemViewHolder
抽象函数:override fun bindData(listItem: ListItemUiModel)
-
在
bindData(ListItemUiModel)
函数顶部添加以下两行,以强制将ListItemUiModel
强制转换为ListItemUiModel.Cat
并从中获取猫数据:require(listItem is ListItemUiModel.Cat) { "Expected ListItemUiModel.Cat" } val cat = listItem.cat
保持文件其余部分不变。
-
创建一个新的布局文件。将你的布局命名为
item_title
。 -
将新创建的
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" />
这个新的布局,仅包含一个 16 sp 大小加粗字体的 TextView
,将承载我们的标题:
图 6.10 – item_title.xml 布局的预览
-
在
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
上设置文本,因此它也简单得多。
-
现在,为了使事情更整洁,选择
CatViewHolder
、ListItemViewHolder
和TitleViewHolder
。 -
将所有文件移动到新的命名空间;在文件上右键单击,然后选择 重构 | 移动(或按 F6)。
-
将
/viewholder
添加到预填充的 目标目录 字段。保留 搜索引用 和 更新包指令(Kotlin 文件) 复选框,并取消选中 在编辑器中打开移动的文件。点击 确定。 -
打开
CatsAdapter.kt
文件。 -
现在,将
CatsAdapter
重命名为ListItemsAdapter
。在代码窗口中右键单击CatsAdapter
类名,然后选择 重构 | 重命名(或按 Shift + F6)。
注意
维护变量、函数和类的命名以反映它们的实际使用,这对于避免未来的混淆非常重要。
-
当
CatsAdapter
被高亮显示时,输入ListItemsAdapter
并按Enter。 -
将适配器泛型类型更改为
ListItemViewHolder
:class ListItemsAdapter(...) : RecyclerView.Adapter<ListItemViewHolder>() {
-
更新
listData
和setData(List<CatUiModel>)
以处理ListItemUiModel
:private val listData = mutableListOf<ListItemUiModel>() fun setData(newListData: List<ListItemUiModel>) { listData.clear() listData.addAll(newListData) notifyDataSetChanged() }
-
更新
onBindViewHolder(CatViewHolder)
以符合适配器合约更改:override fun onBindViewHolder(holder: ListItemViewHolder, position: Int) { holder.bindData(listData[position]) }
-
在文件顶部,在导入之后和类定义之前,添加视图类型常量:
private const val VIEW_TYPE_TITLE = 0 private const val VIEW_TYPE_CAT = 1
-
实现
getItemViewType(Int)
,如下所示:override fun getItemViewType(position: Int) = when (listData[position]) { is ListItemUiModel.Title -> VIEW_TYPE_TITLE is ListItemUiModel.Cat -> VIEW_TYPE_CAT }
-
最后,更改你的
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(cat: CatUiModel) = onClickListener.onItemClick(catData) }) } else -> throw IllegalArgumentException("Unknown view type requested: $viewType") }
-
更新
MainActivity
以用适当的数据填充适配器,替换之前的catsAdapter.setData(List<CatUiModel>)
调用(注意以下代码因空间限制已被截断;请参阅代码块后的链接以获取需要添加的完整代码):
MainActivity.kt
listItemsAdapter.setData(
listOf(
ListItemUiModel.Title("Sleeper Agents"),
ListItemUiModel.Cat(
CatUiModel(Gender.Male,
CatBreed.ExoticShorthair, "Garvey",
"Garvey is as a lazy, fat, and cynical orange
cat.",
"https://cdn2.thecatapi.com/images/FZpeiLi4n.jpg"
)
),
ListItemUiModel.Cat(
CatUiModel(Gender.Unknown,
CatBreed.AmericanCurl, "Curious George",
"Award winning investigator",
"https://cdn2.thecatapi.com/images/vJB8rwfdX.
jpg"
)
),
ListItemUiModel.Title("Active Agents"),
此步骤的完整代码可以在packt.live/3icCrSt
找到。
-
由于
catsAdapter
不再持有CatsAdapter
而是ListItemsAdapter
,因此相应地重命名它。命名为listItemsAdapter
。 -
运行应用。你应该看到以下类似的内容:
图 6.11 – 带有睡眠者和活动者头部视图的 RecyclerView
如你所见,我们现在在我们的两个代理组上方有标题。与RecyclerView
不同。
滑动删除项目
在前面的章节中,我们学习了如何展示不同的视图类型。然而,到目前为止,我们一直在使用固定列表的项目。如果你想要能够从列表中删除项目怎么办?有一些常见的机制可以实现这一点——每个项目上的固定删除按钮、滑动删除和长按选择然后点击删除按钮,仅举几例。在本节中,我们将重点关注滑动删除的方法。
首先,让我们给我们的适配器添加删除功能。要告诉适配器删除一个项目,我们需要指出我们想要删除哪个项目。实现这一点的最简单方法是通过提供项目的位置。在我们的实现中,这将直接关联到listData
列表中项目的位置。因此,我们的removeItem(Int)
函数应该看起来像这样:
fun removeItem(position: Int) {
listData.removeAt(position)
notifyItemRemoved(position)
}
注意
就像设置数据一样,我们需要通知RecyclerView
数据集已更改——在这种情况下,一个项目已被删除。
接下来,我们需要定义滑动手势检测。这是通过利用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
定义了规则,这是手势的起始状态,因此允许手势从左侧或右侧开始。然后我们(使用 or
)将其与 ACTION_STATE_SWIPE
标志结合,允许进行中的手势向左或向右滑动。返回 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
添加到我们的应用程序中,并向其中添加了不同类型的项。现在,我们将允许用户通过左右滑动来删除一些项(我们希望让用户删除秘密猫特工,但不能删除标题):
-
要将项删除功能添加到我们的适配器中,在
setData(List<ListItemUiModel>)
函数之后添加以下函数到ListItemsAdapter
:fun removeItem(position: Int) { listData.removeAt(position) notifyItemRemoved(position) }
-
接下来,在您的
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
实例,传递我们感兴趣的指令——LEFT
和 RIGHT
。通过使用 or
布尔运算符来连接这些值。
我们已重写 getMovementFlags
函数,以确保我们只处理猫代理视图上的滑动,而不是标题上的滑动。为 ItemTouchHelper.ACTION_STATE_SWIPE
和 ItemTouchHelper.ACTION_STATE_IDLE
创建标志允许我们分别拦截滑动和释放事件。
一旦完成滑动(用户已从屏幕上抬起手指),onSwiped
将被调用,作为响应,我们将移除由拖动视图持有者提供的位置的项。
-
在适配器的顶部,公开您刚刚创建的
SwipeToDeleteCallback
类的一个实例:class ListItemsAdapter(...) : RecyclerView.Adapter<ListItemViewHolder>() { val swipeToDeleteCallback = SwipeToDeleteCallback()
-
最后,通过实现
ItemViewHelper
并将其附加到我们的RecyclerView
上来将所有这些整合在一起。将以下代码添加到您的MainActivity
文件的onCreate(Bundle?)
函数中,在将布局管理器分配给适配器之后:recyclerView.layoutManager = ... val itemTouchHelper = ItemTouchHelper(listItemsAdapter .swipeToDeleteCallback) itemTouchHelper.attachToRecyclerView(recyclerView)
-
为了解决当项被删除时出现的微小视觉错误,通过更新
activity_main.xml
中的代码来调整RecyclerView
以适应屏幕。更改位于RecyclerView
标签中,在app:layout_constraintTop_toBottomOf
属性之前:android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@+id/main_label" />
注意,这里有两个变化——我们在视图底部添加了一个约束到父视图的底部,并将布局高度设置为 0dp
。后者的变化告诉我们的应用程序根据其约束来计算 RecyclerView
的高度:
图 6.12 – RecyclerView 占据布局的全部高度
- 运行您的应用程序。现在,您应该能够通过左右滑动秘密猫特工来从列表中删除它们。请注意,
RecyclerView
为我们处理折叠动画:
图 6.13 – 向右滑动的猫
注意,尽管标题是项目视图,但它们不能被滑动。您已经实现了一个用于滑动手势的回调,它区分不同的项目类型,并通过删除被滑动的项来响应滑动。现在,您知道如何交互式地删除项。接下来,您将学习如何添加新项。
交互式添加项
我们刚刚学习了如何交互式地移除项目。那么添加新项目呢?让我们来看看。
与我们实现移除项目的方式类似,我们首先在我们的适配器中添加一个函数:
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.14 – 带有添加猫按钮的主布局
不要忘记更新你的 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 – 实现添加猫按钮
在实现了移除项目的机制后,现在是时候实现添加项目的机制了:
-
在
ListItemsAdapter
中添加一个函数以支持添加项目。在removeItem(Int)
函数下方添加它:fun addItem(position: Int, item: ListItemUiModel) { listData.add(position, item) notifyItemInserted(position) }
-
在
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" />
-
注意
android:text="Add A Cat"
被高亮显示。如果你将鼠标悬停在其上,你会看到这是由于硬编码的字符串造成的。点击 添加 单词,将编辑器光标放置在其上。 -
按下 Option + Enter(iOS)或 Alt + Enter(Windows)以显示上下文菜单,然后再次按下 Enter 以显示 提取 资源 对话框。
-
将资源命名为
add_button_label
。按 确定。 -
要更改
RecyclerView
的底部约束,以便按钮和RecyclerView
不重叠,在你的RecyclerView
标签内,找到以下内容:app:layout_constraintBottom_toBottomOf="parent"
用以下代码行替换它:
app:layout_constraintBottom_toTopOf="@+id/main_add_item_button"
-
在类顶部,紧接在
recyclerView
定义之后添加一个懒字段,持有按钮的引用:private val addItemButton: View by lazy { findViewById(R.id.main_add_item_button) }
注意,addItemButton
被定义为视图。这是因为,在我们的代码中,我们不需要知道视图的类型来向其添加点击监听器。选择更抽象的类型允许我们在以后更改布局中的视图类型,而无需修改此代码。
-
最后,更新
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
添加一个新项目。
- 运行应用。你应该在你的应用底部看到一个新按钮:
图 6.15 – 点击按钮添加了一只匿名猫
- 尝试点击几次。每次点击,都会在
RecyclerView
中添加一个新的匿名秘密猫特工。你可以像处理硬编码的猫一样滑动掉新添加的猫。
在这个练习中,你根据用户交互向RecyclerView
添加了新项目。你现在知道如何在运行时更改RecyclerView
的内容。了解如何在运行时更新列表是有用的,因为,相当常见的是,你在应用运行时向用户展示的数据会发生变化,而你希望向用户提供一个新鲜、最新的状态。
活动第 6.01 节 – 管理项目列表
想象一下,你想要开发一个食谱管理应用。你的应用将支持甜味和咸味食谱。你的应用用户可以添加新的甜味或咸味食谱,滚动查看已添加食谱的列表——按口味(甜味或咸味)分组——点击一个食谱以获取其信息,最后,通过滑动将其删除。
这个活动的目的是创建一个带有RecyclerView
的应用,列出按口味分组的食谱标题。RecyclerView
将支持用户交互。每个食谱将有一个标题、一个描述和一个口味。交互包括点击和滑动。
点击将向用户展示一个对话框,显示食谱的描述。滑动将删除被滑动的食谱。最后,使用两个EditText
字段(见第三章,使用片段开发 UI)和两个按钮,用户可以分别添加新的甜味或咸味食谱,标题和描述设置为EditText
字段中设置的值。
完成此操作的步骤如下:
-
创建一个新的空活动应用。
-
将
RecyclerView
支持添加到应用的build.gradle
文件中。 -
将
RecyclerView
、两个EditText
字段和两个按钮添加到主布局中。它应该看起来像这样:
图 6.16 – 带有 RecyclerView、两个 EditText 字段和两个按钮的布局
-
添加口味标题和食谱的模型,以及一个用于口味的枚举。
-
添加口味标题的布局。
-
添加食谱标题的布局。
-
为风味标题和食谱标题添加视图持有者,以及适配器。
-
添加点击监听器以显示包含食谱描述的对话框。
-
将
MainActivity
更新为构建新的适配器,并将按钮连接到添加新的咸味和甜味食谱。确保在添加食谱后清除表单。 -
添加一个滑动助手以删除项目。
最终输出将如下所示:
图 6.17 – 烹饪书 app
注意
此活动的解决方案可在packt.link/By7eE
找到。
摘要
在本章中,我们学习了如何将 RecyclerView
支持添加到我们的项目中。我们还学习了如何将 RecyclerView
添加到我们的布局中,以及如何用项目填充它。我们探讨了添加不同项目类型,这对于标题特别有用。我们涵盖了与 RecyclerView
的交互,响应单个项目的点击以及响应滑动手势。
最后,我们学习了如何动态地向 RecyclerView
添加和删除项目。RecyclerView
的世界非常丰富,我们只是触及了表面。进一步探索将超出本书的范围。然而,强烈建议您自行研究,以便您可以在应用程序中拥有轮播图、设计分隔符和更复杂的滑动效果。
您可以从这里开始您的探索:packt.link/ClmMn
。
在下一章中,我们将探讨代表我们的应用程序请求特殊权限,以便它能够执行某些任务,例如访问用户的联系人列表或麦克风。我们还将探讨使用 Google 的 Maps API 和访问用户的物理位置。
第七章:Android 权限和 Google Maps
本章将教您如何在 Android 中请求和获取应用权限。您将通过使用 Google Maps API 深入了解如何将本地和全局交互式地图包含到您的应用中,以及如何请求使用提供更丰富功能的设备功能的权限。
到本章结束时,您将能够为您的应用创建权限请求并处理缺失的权限。
在上一章中,我们学习了如何使用 RecyclerView
在列表中展示数据。然后,我们利用这些知识向用户展示了一个秘密猫特工的列表。在本章中,我们将学习如何在地图上找到用户的位置,以及如何通过在地图上选择位置来部署猫特工到现场。
首先,我们将探索 Android 权限系统。许多 Android 功能对我们来说并不是立即可用的。这些功能被权限系统所限制,以保护用户。为了访问这些功能,我们必须请求用户允许我们这样做。这些功能包括但不限于获取用户的位置、访问用户的联系人、访问他们的相机以及建立蓝牙连接。不同的 Android 版本实施不同的权限规则。
注意
例如,当 Android 6(棉花糖)在 2015 年推出时,您可以在安装时静默获取的一些权限被认为是不安全的,并成为运行时权限,需要明确的用户同意。
我们将接着查看 Google Maps API。此 API 允许我们向用户提供世界上任何期望位置的地图。我们将在该地图上添加数据,并让用户与地图进行交互。API 还允许您显示兴趣点并渲染支持位置的道路视图,尽管我们不会在本书中探索这些功能。
本章我们将涵盖以下主题:
-
从用户那里请求权限
-
显示用户的位置地图
-
地图点击和自定义标记
技术要求
本章所有练习和活动的完整代码可在 GitHub 上找到,链接为 packt.link/6ShZd
从用户那里请求权限
我们的应用可能需要实现一些谷歌认为危险的功能。这通常意味着访问这些功能可能会危及用户的隐私。例如,某些权限可能允许您读取用户的消息或确定他们的当前位置。
根据所需的权限和目标 Android API 级别,我们可能需要请求用户授予该权限。如果设备运行在 Android 6(棉花糖,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 为我们提供了一个名为shouldShowRequestPermissionRationale(Activity, String)
的函数来实现这个目的。在底层,这个函数检查用户是否之前拒绝过该权限,同时也检查用户是否之前拒绝过我们。
策略是让我们在请求权限之前向用户解释我们的请求理由,从而增加他们批准请求的可能性。一旦我们确定应用是否应该向用户展示我们的理由,或者是否不需要理由,我们就可以继续请求权限。
让我们看看我们如何请求权限。首先,我们必须在我们的应用gradle
文件中包含 Jetpack Activity 和 Fragment 依赖项:
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5"
这将为我们提供ActivityResultLauncher
,我们将使用它来启动权限请求对话框并处理用户的响应。
以下是一个请求Location
权限的Activity
类的示例:
class MainActivity : AppCompatActivity() {
private lateinit var requestPermissionLauncher:
ActivityResultLauncher<String>
override fun onCreate() {
...
requestPermissionLauncher = registerForActivityResult(
RequestPermission()) { isGranted ->
if (isGranted) {... } else { ... } }
当Activity
被创建时,我们注册启动器来处理权限请求响应。我们保留结果的引用以供以后使用。当Activity
恢复时,我们检查权限的状态并相应地继续:
override fun onResume() {
...
when {
hasLocationPermission() -> getLastLocation()
shouldShowRequestPermissionRationale(this,
ACCESS_FINE_LOCATION) -> {
showPermissionRationale {
requestPermissionLauncher
.launch(ACCESS_FINE_LOCATION)
}
}
else -> requestPermissionLauncher.
launch(ACCESS_FINE_LOCATION)
}
}
我们首先通过调用getHasLocationPermissions()
来检查位置权限(ACCESS_FINE_LOCATION
):
private fun hasLocationPermission() =
checkSelfPermission(this, Manifest.permission.ACCESS_FINE_
LOCATION) == PERMISSION_GRANTED
此函数通过调用checkSelfPermission(Context, String)
并传入请求的权限来检查用户是否授予了我们请求的权限。
如果用户没有授予我们权限,我们调用之前提到的shouldShowRequestPermissionRationale(Activity, String)
来检查是否应该向用户展示理由对话框。
如果需要显示我们的理由,我们调用showPermissionRationale(() -> Unit)
,传入一个 lambda,该 lambda 将在用户使用正面按钮关闭我们的理由对话框后,使用requestPermissionLauncher
启动请求对话框。如果不需要理由,我们直接使用requestPermissionLauncher
启动对话框。以下代码用于显示理由对话框:
private fun showPermissionRationale(
positiveAction: () -> Unit) {
AlertDialog.Builder(this)
.setTitle("Location permission")
.setMessage("We need your permission to find your current
position")
.setPositiveButton(android.R.string.ok) { _, _ ->
positiveAction()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss() }
.create().show()
}
我们的showPermissionRationale
函数向用户展示一个对话框,简要解释我们为什么需要他们的权限。确定按钮将执行提供的正面操作,而取消按钮将关闭对话框:
图 7.2 – 理由对话框
最后,我们通过调用之前声明的请求权限启动器requestPermissionLauncher.launch(ACCESS_FINE_LOCATION)
来请求权限。
如果我们已经从用户那里请求了位置权限,请求权限启动器将根据isGranted
返回的值处理响应,如下面的代码所示:
registerForActivityResult(RequestPermission()) { isGranted ->
if (isGranted) { getLastLocation() }
else {
showPermissionRationale { requestPermissionLauncher
.launch(ACCESS_FINE_LOCATION) }
}
}
这段代码是我们之前观察到的代码的扩展,添加到了Activity
的onCreate
函数中。本章将带我们开发一个应用程序,它可以在地图上显示我们的当前位置,并允许我们在想要部署我们的秘密猫特工的地方放置标记。
让我们从第一个练习开始。
练习 7.01 – 请求位置权限
在这个练习中,我们将请求用户提供位置权限。我们首先创建一个Google Maps Activity项目。然后,我们在清单文件中定义所需的权限。要开始,让我们实现请求用户允许访问其位置的代码:
- 首先,创建一个新的 Google Maps Activity 项目(文件 | 新建 | 新建项目 | Google Maps Activity)。在这个练习中,我们不会使用 Google Maps。然而,在这种情况下,Google Maps Activity 仍然是一个不错的选择。它将在下一个练习(练习 7.02)中为你节省大量的样板代码。不用担心,它对你的当前练习没有任何影响。点击下一步,如图所示:
图 7.3 – 选择你的项目
-
将你的应用程序命名为
Cat Agent Deployer
。 -
确保你的包名为
com.example.catagentdeployer
。 -
将项目保存位置设置为你要保存项目的地方。
-
将其他所有设置保留为默认值,然后点击完成。
-
确保你在项目面板的Android视图中:
图 7.4 – Android 视图
-
打开你的
AndroidManifest.xml
文件。确保位置权限已经添加到你的应用中:<manifest ...> <uses-permission android:name= "android.permission.ACCESS_FINE_LOCATION" /> <application ...> ... </application> </manifest>
注意
ACCESS_FINE_LOCATION
是你将需要根据 GPS 获取用户位置,以及使用 ACCESS_COARSE_LOCATION
权限可以获得的基于 Wi-Fi 和移动数据的不太准确的位置信息。
-
打开你的
MapsActivity.kt
文件。在MapsActivity
类块底部添加一个空的getLastLocation()
函数:class MapsActivity : ... { ... private fun getLastLocation() { Log.d("MapsActivity", "getLastLocation() called.") } }
这将是当你确保用户已经授予你位置权限时将调用的函数。
-
接下来,将请求权限启动器添加到
MapsActivity
类的顶部:class MapsActivity : ... { private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
这是我们将通过它启动权限请求并跟踪用户响应的变量。
现在导航到 onCreate()
函数的底部并注册活动结果,将结果存储在你在上一步中声明的 requestPermissionLauncher
中:
override fun onCreate(savedInstanceState: Bundle?) {
...
requestPermissionLauncher =
registerForActivityResult(RequestPermission()) {
isGranted ->
if (isGranted) { getLastLocation() }
else {
showPermissionRationale {
requestPermissionLauncher
.launch(ACCESS_FINE_LOCATION)
}
}
}
}
-
要向用户展示权限请求的理由,在
getLastLocation()
函数之前实现show
PermissionRationale(() -> Unit) 函数:private fun showPermissionRationale(positiveAction: () -> Unit) { AlertDialog.Builder(this) .setTitle("Location permission") .setMessage("This app will not work without knowing your current location") .setPositiveButton(android.R.string.ok) { _, _ -> positiveAction() } .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } .create().show() }
此函数将向用户展示一个简单的警告对话框,解释说明应用在没有知道他们当前位置的情况下无法工作,如图 图 7**.1 所示。点击 positiveAction
lambda。点击 取消 将关闭对话框。
-
要确定你的应用是否已经具有位置权限,在
requestPermissionWithRationaleIfNeeded()
函数之前引入以下hasLocationPermission()
函数:private fun hasLocationPermission() = ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED
-
最后,更新你的
MapsActivity
类的onMapReady()
函数以确定权限状态并相应地执行:override fun onMapReady(googleMap: GoogleMap) { mMap = googleMap when { hasLocationPermission() -> getLastLocation() shouldShowRequestPermissionRationale(this, ACCESS_FINE_LOCATION) -> { showPermissionRationale { requestPermissionLauncher .launch(ACCESS_FINE_LOCATION) } } else -> requestPermissionLauncher .launch(ACCESS_FINE_LOCATION) } }
when
语句将检查权限是否已经授予。如果没有,它将检查是否应该显示解释对话框。然后,如果用户接受了解释或不需要解释对话框,它将向用户展示一个标准的权限请求对话框(如图 图 7**.1 所示),请求他们允许应用访问他们的位置。你传递给用户请求的权限,即你希望用户授予你的应用 (Manifest.permission.ACCESS_FINE_LOCATION
)。
- 运行你的应用。你现在应该看到一个系统权限对话框,请求你允许应用访问设备的地理位置,如图 图 7**.5 所示。
图 7.5 – 应用请求位置权限
如果用户拒绝权限,将显示解释对话框。如果接受了解释,系统权限对话框将再次显示。直到 SDK 31,用户可以选择不让应用再次请求权限(图 7**.6)。从 SDK 31 开始,不再询问是默认设置。之后允许它需要使用设备设置。
图 7.6 – “不再询问”消息
一旦用户允许或永久拒绝权限,对话框将不再显示。要重置应用权限的状态,您必须通过应用****信息界面手动授予它权限。
现在我们已经获得了位置权限,我们将现在查看如何获取用户的当前位置。
显示用户的位置地图
在成功从用户那里获取访问其位置的权限后,我们现在可以要求用户的设备提供其最后已知的位置。这通常也是用户的当前位置。然后我们将使用这个位置向用户展示其当前位置的地图。
为了获取用户最后已知的位置,谷歌为我们提供了谷歌播放位置服务,更具体地说,提供了FusedLocationProviderClient
类。FusedLocationProviderClient
类帮助我们与谷歌的融合位置提供者 API 交互,这是一个智能地结合来自多个设备传感器的不同信号以提供设备位置信息的 API。
要访问FusedLocationProviderClient
类,我们必须首先在我们的项目中包含谷歌播放位置服务库。这仅仅意味着将以下代码片段添加到我们的build.gradle
应用的dependencies
块中:
implementation "com.google.android.gms:play-services-location:21.0.1"
在导入位置服务后,我们现在可以通过调用LocationServices.getFusedLocationProviderClient(this@MainActivity)
来获取FusedLocation
ProviderClient类的实例。
一旦我们有了融合位置客户端,我们可以通过调用fusedLocationClient.lastLocation
来获取用户的最后位置。
注意
这是在我们已经从用户那里收到位置权限的前提下。
由于这是一个异步调用,我们还应该提供一个最小成功监听器。如果我们想的话,我们还可以添加取消、失败和请求完成的监听器。调用lastLocation
返回Task<Location>
。Task
是谷歌 API 的一个抽象类,其实现执行异步操作。
在这种情况下,该操作返回一个位置。因此,添加监听器只是简单地链式调用。我们将添加以下代码片段到我们的调用中:
.addOnSuccessListener { location: Location? -> }
注意,如果客户端未能获取用户的当前位置,location
参数可能是null
。这种情况并不常见,但如果例如用户在调用期间禁用了他们的位置服务,则可能会发生。
一旦我们成功监听器块内的代码执行,并且location
不是null
,我们就有了用户当前的位置,形式为一个Location
实例。
一个Location
实例在地球上持有单个坐标,使用经纬度表示。
注意
对于我们的目的来说,知道地球表面的每个点都映射到一对唯一的经度(Lng)和纬度(Lat)值就足够了。
这就是令人兴奋的地方。谷歌允许我们通过使用SupportMapFragment
类在交互式地图上展示任何位置。只需注册一个免费的 API 密钥即可。当你使用 Google Maps Activity 创建你的应用程序时,Android Studio 会立即打开AndroidManifest.xml
文件。文件中的一个注释会引导我们前往packt.link/FK58V
以获取所需的 API 密钥。你可以将此链接复制到浏览器中,或者按Ctrl/Command + 点击它。
在页面上,按照说明操作,并点击元标签值中的YOUR_API_KEY
字符串,使用你新获得的密钥。
到目前为止,如果你运行你的应用程序,你已经在屏幕上看到了一个交互式地图,如图图 7.7所示。
图 7.7 – 交互式地图
为了根据我们的当前位置定位地图,我们创建一个LatLng
实例,其中包含来自我们的Location
实例的坐标,并在GoogleMap
实例上调用moveCamera(CameraUpdate)
。为了满足CameraUpdate
的要求,我们调用CameraUpdateFactory.newLatLng(LatLng)
,传入之前创建的LatLng
参数。调用看起来可能如下所示:
mMap.moveCamera(CameraUpdateFactory.newLatLng(latLng))
注意
要发现可用的CameraUpdateFactory
选项的其余部分,请访问packt.link/EBRnt
。
我们还可以调用newLatLngZoom(LatLng, Float)
来修改地图的缩放和缩放功能。
注意
有效的缩放值范围在 2.0(最远)到 21.0(最近)之间。超出此范围的值将被限制。
一些区域可能没有瓦片来渲染最近的缩放值。
我们在GoogleMap
实例上调用addMarker(MarkerOptions)
来在用户的坐标处添加一个标记。MarkerOptions
参数通过链式调用MarkerOptions()
实例进行配置。我们可以调用position(LatLng)
和title(String)
来为所需位置创建一个简单的标记。调用可能如下所示:
mMap.addMarker(MarkerOptions().position(latLng)
.title("Pin Label"))
我们链式调用顺序并不重要。
让我们在下面的练习中实践一下。
练习 7.02 – 获取用户当前位置
现在既然你的应用程序可以授予位置权限,你可以使用位置权限来获取用户的当前位置。然后你将显示地图,并更新它以缩放到用户的当前位置,并在该位置显示一个标记。为此,请执行以下步骤:
-
首先,将 Google Play 位置服务添加到你的
build.gradle
文件中。你应该在dependencies
块内添加它:dependencies { implementation "com.google.android.gms: play-services- location:21.0.1" ... }
-
在 Android Studio 中点击同步项目与 Gradle 文件按钮,以便 Gradle 获取新添加的依赖项。
-
获取 API 密钥:打开
AndroidManifest.xml
文件(app/src/main/AndroidManifest.xml
),使用Ctrl / Cmd + 点击链接developers.google.com/maps/documentation/android-sdk/get-api-key
。 -
按照网站上的说明操作,直到你生成了新的 API 密钥。
-
通过以下代码将
google_maps_api.xml
文件中的YOUR_API_KEY
替换为你的新 API 密钥:<meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_API_KEY" />
-
打开你的
MapsActivity.kt
文件。在MapsActivity
类的顶部,定义一个懒加载的融合位置提供者客户端:class MapsActivity : ... { private val fusedLocationProviderClient by lazy { LocationServices .getFusedLocationProviderClient(this) } override fun onCreate(savedInstanceState: Bundle?) { ... } ... }
通过懒加载初始化fusedLocationProviderClient
,你确保它仅在需要时初始化,这基本上保证了在初始化之前Activity
类已经被创建。
-
在
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)) }
-
现在更新你的
getLastLocation()
函数以检索用户的位置:private fun getLastLocation() { fusedLocationProviderClient.lastLocation .addOnSuccessListener { location: Location? -> location?.let { val userLocation = LatLng( location.latitude, location.longitude) updateMapLocation(userLocation) addMarkerAtLocation(userLocation, "You") } } }
你的代码通过调用lastLocation
请求最后位置,然后附加一个lambda
函数作为OnSuccessListener
接口。一旦获得位置,lambda
函数将被执行,更新地图位置。如果返回了非空位置,代码将在该位置添加一个带有You
标题的标记。
- 运行你的应用。它应该看起来像图 7.8。
图 7.8 – 在当前位置带有标记的交互式地图
一旦应用被授予权限,它可以通过融合位置提供者客户端从 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()
实例提供 BitmapDescriptor
。BitmapDescriptor
包装器绕过 GoogleMap
使用的位图来渲染标记和地面叠加层,但在此书中我们不会涉及这一点。
我们通过使用 BitmapDescriptorFactory
获取 BitmapDescriptor
。工厂将需要一个资产,可以通过几种方式提供。你可以提供 assets
目录中位图的名称,一个 Bitmap
,内部存储中文件的文件名,或者资源 ID。
工厂还可以创建不同颜色的默认标记。我们感兴趣的是 Bitmap
选项,因为我们打算使用矢量可绘制内容,而工厂不支持这些。此外,在将可绘制内容转换为 Bitmap
时,我们可以根据需要对其进行操作(例如,我们可以更改其颜色)。
Android Studio 默认提供了一系列免费的矢量 Drawables
。对于这个例子,我们想要 paw
可绘制内容。为此,在左侧 Android 窗格的任何位置右键单击,然后选择 新建 | 矢量资产。
现在,点击 剪贴画 标签旁边的 Android 图标以获取图标列表(见 图 7.9):
图 7.9 – 资产工作室
现在我们将访问一个窗口,从提供的剪贴画库中选择(图 7.10):
图 7.10 – 选择图标
一旦我们选择了一个图标,我们就可以给它命名,它将作为一个矢量可绘制 XML 文件为我们创建。我们将命名为 target_icon
。
要使用创建的资产,我们首先必须将其作为 Drawable
实例获取。这是通过调用 ContextCompat.getDrawable(Context, Int)
来完成的,传入活动和 R.drawable.target_icon
作为我们资产的引用。接下来,我们需要为 Drawable
实例定义绘制边界。
使用 (0
, 0
, drawable.intrinsicWidth
, drawable.intrinsicHeight
) 调用 Drawable.setBound(Int, Int, Int, Int)
将告诉可绘制内容在其固有尺寸内绘制。
要更改我们图标的颜色,我们可以着色它。为了以支持运行 API 低于21
的设备的方式着色Drawable
实例,我们必须首先通过调用DrawableCompat.wrap(Drawable)
将我们的Drawable
实例包装在DrawableCompat
中。然后,可以使用DrawableCompat.setTint(Drawable, Int)
着色返回的Drawable
。
接下来,我们需要创建一个Bitmap
来保存我们的图标。其尺寸可以与Drawable
边界匹配,我们希望其Config
为Bitmap.Config.ARGB_8888
。
注意
8
表示每个通道的位数。ARGB_8888
意味着我们想要 8 位的红色、绿色、蓝色和 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 – 在地图点击处添加自定义标记
在这个练习中,您将通过在用户点击的地图位置放置一个红色的爪形标记来响应用户的地图点击:
-
在
MapsActivity.kt
文件中(位于app/src/main/java/com/example/catagentdeployer
),在mMap
变量定义下方,定义一个可空的Marker
变量以保存对地图上爪形标记的引用:private lateinit var mMap: GoogleMap private var marker: Marker? = null
-
更新
addMarkerAtLocation(LatLng, String)
以接受一个可空的BitmapDescriptor
,默认值为null
:private fun addMarkerAtLocation( location: LatLng, title: String, markerIcon: BitmapDescriptor? = null ) = mMap.addMarker( MarkerOptions().title(title).position(location) .apply { markerIcon?.let { icon(markerIcon) } } )
如果提供的markerIcon
不为 null,则应用程序将其设置为MarkerOptions
。函数现在返回它添加到地图上的标记。
-
在您的
addMarkerAtLocation(LatLng, String, BitmapDescriptor?)
函数下方创建一个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() } }
此函数首先通过传递提供的资源 ID 使用ContextCompat
获取一个 drawable,然后设置 drawable 的绘制边界,将其包装在DrawableCompat
中,并将其着色为红色。
然后,它创建一个Bitmap
和一个Canvas
,用于该Bitmap
,在该Canvas
上绘制着色的 drawable。然后,将 bitmap 返回以供BitmapDescriptorFactory
构建BitmapDescriptor
使用。最后,回收Bitmap
以避免内存泄漏。
-
在您可以使用
Drawable
实例之前,您必须首先创建它。在 Android 面板上右键单击,然后选择新建 | 矢量资产。 -
在打开的窗口中,点击剪贴画标签旁边的 Android 图标以选择不同的图标(图 7.11.11):
图 7.11 – 资产工作室
- 从图标列表中选择
pets
到搜索字段中,如果你找不到图标。一旦你选择了 pets 图标,点击 确定:
图 7.12 – 选择图标
-
将你的图标命名为
target_icon
。点击 下一步 和 完成。 -
定义一个
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 } } }
-
更新你的
onMapReady(GoogleMap)
函数,在mMap
上设置一个OnMapClickListener
事件,这将向点击的位置添加一个标记或将现有的标记移动到点击的位置:override fun onMapReady(googleMap: GoogleMap) { mMap = googleMap.apply { setOnMapClickListener { latLng -> addOrMoveSelectedPositionMarker(latLng) } } if (hasLocationPermission()) { ... } }
运行你的应用程序。它应该看起来像 图 7**.13。
图 7.13 – 完整的应用程序
现在点击地图上的任何位置都会将爪子图标移动到该位置。点击爪子图标将显示 部署 此处 标签。
注意
爪子的位置是地理上的,而不是屏幕上的。这意味着如果你拖动地图或放大,爪子会随着地图移动并保持在相同的地理位置。
你现在知道如何响应用户在地图上的点击,并在周围添加和移动标记。你还知道如何自定义标记的外观。
活动 7.01 – 创建一个查找停车位置的应用程序
有些人经常忘记他们停车的地方。假设你想通过开发一个允许用户存储他们最后一次停车位置的应用程序来帮助这些人。当用户启动应用程序时,应用程序将在汽车的位置显示一个图钉。用户可以点击一个 我停在这里 按钮来更新下一次停车时图钉的位置。
在这个活动中,你的目标是开发一个向用户显示当前位置地图的应用程序。应用程序必须首先请求用户允许访问他们的位置。根据 SDK,如果需要,请确保也提供合理的对话框。
应用程序将在用户上次告诉它的汽车位置显示一个汽车图标。用户可以点击一个标签为 我停在这里 的按钮,将汽车图标移动到当前位置。当用户重新启动应用程序时,它将显示用户的当前位置和上次停车的汽车图标。
作为你应用程序的一个附加功能,你可以选择添加一个功能,用于存储汽车的位置,以便在用户关闭并重新打开应用程序后恢复。此附加功能依赖于使用 SharedPreferences
;这是一个将在 第十章,持久化数据 中介绍的概念。因此,这里的 步骤 9 和 步骤 10 将为你提供所需的实现。
以下步骤将帮助你完成活动:
-
创建一个 Google Maps Activity 应用程序。
-
为应用程序获取一个 API 密钥,并使用该密钥更新你的
google_maps_api.xml
文件。 -
在底部显示一个带有 我停在这里 标签的按钮。
-
在您的应用中包含位置服务。
-
请求用户权限以访问他们的位置。
-
获取用户的位置并在该位置在地图上放置一个图钉。
-
将汽车图标添加到您的项目中。
-
添加功能以将汽车图标移动到用户的当前位置。
-
奖励步骤:将选定的位置存储在
SharedPreferences
中。此函数,放置在您的活动中,将帮助您完成此操作:private fun saveLocation(latLng: LatLng) = getPreferences(MODE_PRIVATE)?.edit()?.apply { putString("latitude", latLng.latitude.toString()) putString("longitude", latLng.longitude.toString()) apply() }
-
奖励步骤:从
SharedPreferences
恢复任何已保存的位置。您可以使用以下函数:val latitude = sharedPreferences .getString("latitude", null) ?.toDoubleOrNull() ?: return null val longitude = sharedPreferences .getString("longitude", null) ?.toDoubleOrNull() ?: return null
完成此活动后,您已经证明了您对在 Android 应用中请求权限的理解。您还展示了您可以向用户展示地图并控制地图上的图钉。最后,您还展示了您获取用户当前位置的知识。做得好。
注意
此活动的解决方案可以在packt.link/By7eE
找到。
摘要
在本章中,我们学习了关于 Android 权限的内容。我们讨论了拥有权限的原因,并展示了如何请求用户权限以执行特定任务。我们还学习了如何使用 Google 的 Maps API 以及如何向用户展示交互式地图。
最后,我们利用了展示地图和请求权限的知识,以找出用户的当前位置并在地图上展示它。当然,使用 Google Maps API 还有更多可以做的事情,您可以使用某些权限探索更多可能性。
您现在应该对基础知识有足够的了解,可以进一步探索。要了解更多关于权限的信息,请访问packt.link/57BdN
。要了解更多关于 Maps API 的信息,请访问packt.link/8akrP
。
在下一章中,我们将学习如何使用Services
和WorkManager
执行后台任务。我们还将学习如何在应用未运行时向用户展示通知。作为移动开发者,这些是您工具箱中非常强大的工具。
第八章:服务、WorkManager 和通知
本章将向您介绍在应用后台管理长时间运行任务的概念。在本章结束时,您将能够触发后台任务,当后台任务完成时为用户创建一个通知,并从通知中启动一个应用。本章将为您提供一个关于如何管理后台任务并让用户了解这些任务进度的坚实基础。
在上一章中,我们学习了如何请求用户权限并使用谷歌的地图 API。有了这些知识,我们获取了用户的位置并允许他们在本地地图上部署一个代理。在本章中,我们将学习如何跟踪长时间运行的过程并向用户报告其进度。
我们将构建一个示例应用,假设秘密猫特工(SCAs)在 15 秒内被部署。当一只猫成功部署后,我们将通知用户并让他们启动应用,向他们展示成功的部署消息。
注意
我们将选择 15 秒,这样我们就可以避免在后台任务完成之前等待很长时间。
在移动世界中,持续的后台任务相当常见。即使应用未处于活动状态,后台任务也会运行。长时间运行的后台任务示例包括文件下载、资源清理作业、播放音乐和跟踪用户的位置。
从历史上看,谷歌为 Android 开发者提供了多种执行此类任务的方式:服务
、JobScheduler
、Firebase 的JobDispatcher
和AlarmManager
。由于 Android 世界的碎片化,处理起来相当混乱。幸运的是,自从 2019 年 3 月以来,我们有了更好的(更稳定的)选择。
随着WorkManager
的引入,谷歌已经为我们抽象了基于 API 版本选择后台执行机制的逻辑。我们仍然使用前台服务,这是一种特殊的服务,用于某些应该在运行时让用户知道的任务,例如播放音乐或在运行的应用中跟踪用户的位置。
注意
服务是设计在后台运行的应用组件,即使在应用未运行时也是如此。除了与通知相关联的前台服务外,服务没有用户界面。
在我们继续之前,先退一步。我们提到了服务,我们将专注于前台服务,但我们还没有完全解释什么是服务。服务是设计在后台运行的应用组件,即使在应用未运行时也是如此。
服务没有用户界面,除了前台服务。需要注意的是,服务在其宿主进程的主线程上运行。这意味着它们的操作可能会阻塞应用。我们必须在服务内部启动一个单独的线程来避免这种情况。
让我们开始,看看 Android 中用于管理后台任务的多种方法的实现。
在本章中,我们将涵盖以下主题:
-
使用
WorkManager
启动后台任务 -
对用户可见的后台操作 – 使用前台服务
技术要求
本章所有练习和活动的完整代码可在 GitHub 上找到,链接为packt.link/i8IRQ
使用WorkManager
启动后台任务
我们在这里要解决的第一个问题是是否选择WorkManager
或前台服务?为了回答这个问题,一个好的经验法则是问你是否需要用户实时跟踪操作。
如果答案是肯定的(例如,如果你有一个像响应用户的位置或播放背景音乐这样的任务),那么你应该使用带有附加通知的前台服务来给用户实时状态指示。当后台任务可以延迟或不需要用户交互(例如,下载大文件)时,使用WorkManager
。
注意
从WorkManager
的2.3.0-alpha02
版本开始,你可以通过调用setForegroundAsync(ForegroundInfo)
通过WorkManager
单例启动前台服务。我们对那个前台服务的控制相当有限。它确实允许你将(预定义的)通知附加到工作,这就是为什么它值得提及。
在我们的示例应用中,我们将跟踪 SCAs 的部署准备工作。在代理可以出发之前,他们需要拉伸,梳理毛发,访问猫砂盆,并穿戴装备。这些任务中的每一个都需要一些时间。因为不能催促猫,代理会根据自己的时间完成每个步骤。我们能做的只是等待(并通知用户任务何时完成)。WorkManager
非常适合这种场景。
要使用WorkManager
,我们需要熟悉其四个主要类:
-
WorkManager
:它接收工作并根据提供的参数和约束(如网络连接和设备充电)对其进行排队。 -
Worker
:这是需要执行的工作的包装器。它有一个函数doWork()
,我们重写它来实现后台工作代码。doWork()
函数将在后台线程中执行。 -
WorkRequest
:这个类将Worker
类绑定到参数和约束。WorkRequest
有两种类型:OneTimeWorkRequest
,它只运行一次工作,和PeriodicWorkRequest
,它可以用来安排工作在固定间隔运行。 -
ListenableWorker.Result
:你可能已经猜到了,但这是一个持有执行工作结果的类。结果可以是Success
、Failure
或Retry
。
除了这四个类之外,我们还有一个Data
类,它持有传递给工作者的数据和从工作者返回的数据。
让我们回到我们的例子。我们想要定义四个需要按顺序发生的任务:猫需要伸展,然后需要梳理毛发,然后去猫砂盆,最后需要穿上衣服。
在我们开始使用 WorkManager
之前,我们必须首先将其依赖项包含在我们的应用 build.gradle
文件中:
implementation "androidx.work:work-runtime:2.8.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 构造一个输出 data
类,并带着成功的结果返回它。
一旦我们为所有任务创建了工作者(CatStretchingWorker
、CatFurGroomingWorker
、CatLitterBoxSittingWorker
和 CatSuitUpWorker
),类似于我们创建第一个工作者那样,我们可以调用 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
实例传递给 WorkManager
的 enqueue()
函数来排队一个 WorkRequest
实例,或者你可以通过将所有 WorkRequest
实例作为一个列表传递给 WorkManager
的 enqueue()
函数来使多个 WorkRequest
实例并行运行。
当满足约束条件时,我们的任务将由 WorkManager
执行。
每个 Request
实例都有一个唯一的标识符。WorkManager
为每个请求公开了一个 LiveData
属性,允许我们通过传递其唯一标识符来跟踪其工作进度,如下面的代码所示:
workManager.getWorkInfoByIdLiveData(
catStretchingRequest.id
).observe(this) { info ->
if (info.state.isFinished) { doSomething() }
}
工作状态可以是 BLOCKED
(存在一个请求链,而这个请求不在链中),ENQUEUED
(存在一个请求链,而这个工作在链中),RUNNING
(doWork()
中的工作正在执行),和 SUCCEEDED
。工作也可以被取消,导致 CANCELLED
状态,或者它可能失败,导致 FAILED
状态。
最后,还有Result.retry
。返回此结果会告诉WorkManager
类再次入队工作。何时再次运行工作的策略由WorkRequest
Builder
上设置的backoff
标准定义。默认的backoff
策略是指数的,但我们可以将其设置为线性的。我们还可以定义初始的backoff
时间。
让我们将到目前为止获得的知识应用到以下练习中。在本节中,我们将从我们发出部署命令的那一刻开始跟踪我们的 SCA,直到它到达目的地的那一刻。
练习 8.01 – 使用 WorkManager 类执行后台工作
在这个第一个练习中,我们将通过入队链式WorkRequest
类来跟踪 SCA 在准备出发时的状态。
-
首先创建一个新的
Empty Activity
项目(File | New | New Project | Empty Activity)。点击Next。 -
将你的应用程序命名为
Cat
Agent Tracker
。 -
确保你的包名是
com.example.catagenttracker
。 -
将保存位置设置为你要保存项目的地方。
-
将其他所有内容保留为默认值,然后点击完成。
-
确保你在项目面板中处于 Android 视图。
-
打开你的应用的
build.gradle
文件。在dependencies
块中添加WorkManager
依赖项:dependencies { ... implementation "androidx.work:work-runtime:2.8.0" ... }
这将允许你在代码中使用WorkManager
及其依赖项。
-
在你的应用包下创建一个新的包(在
com.example.catagenttracker
上右键点击,然后选择com.example.catagenttracker.worker
。 -
在
com.example.catagenttracker.worker
下创建一个新的类,命名为CatStretchingWorker
(在worker
上右键点击,然后选择New | New Kotlin File/Class)。在Kind下,选择Class。 -
要定义一个将睡眠
3
秒的Worker
实例,更新新类如下:package com.example.catagenttracker.worker [imports] 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" } }
这将为Worker
实现添加所需的依赖项,并扩展Worker
类。要实现实际的工作,你需要重写doWork(): Result
,使其从输入中读取猫代理 ID,睡眠3
秒(3,000
毫秒),构建一个包含猫代理 ID 的输出数据实例,并通过Result.success
值传递它。
-
重复步骤 9和步骤 10以创建三个更多相同的工作者,分别命名为
CatFurGroomingWorker
、CatLitterBoxSittingWorker
和CatSuitUpWorker
。 -
打开
MainActivity
。在类末尾之前添加以下内容:private fun getCatAgentIdInputData( catAgentIdKey: String, catAgentIdValue: String) = Data.Builder() .putString(catAgentIdKey, catAgentIdValue).build()
此辅助函数为你构建一个包含猫代理 ID 的输入Data
实例。
-
将以下内容添加到
onCreate(Bundle?)
函数中:override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val networkConstraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val catAgentId = "CatAgent1" val catStretchingRequest = OneTimeWorkRequest. Builder(CatLitterBoxSittingWorker::class.java) .setConstraints(networkConstraints) .setInputData( getCatAgentIdInputData(CatStretchingWorker .INPUT_DATA_CAT_AGENT_ID, catAgentId) ).build() val catFurGroomingRequest = OneTimeWorkRequest. Builder(CatFurGroomingWorker::class.java) .setConstraints(networkConstraints) .setInputData(getCatAgentIdInputData( CatFurGroomingWorker.INPUT_DATA_CAT_AGENT_ID, catAgentId) ).build() val catLitterBoxSittingRequest = OneTimeWorkRequest. Builder(CatLitterBoxSittingWorker::class.java) .setConstraints(networkConstraints) .setInputData(getCatAgentIdInputData( CatLitterBoxSittingWorker.INPUT_DATA_CAT_AGENT_ID, catAgentId) ).build() val catSuitUpRequest = OneTimeWorkRequest.Builder( CatSuitUpWorker::class.java ).setConstraints(networkConstraints) .setInputData(getCatAgentIdInputData(CatSuitUpWorker. INPUT_DATA_CAT_AGENT_ID, catAgentId) ).build() }
添加的第一行定义了一个网络约束。它告诉WorkManager
类在执行工作之前等待网络连接。然后,你定义你的猫代理 ID。最后,你定义四个请求,传入你的Worker
类、网络约束和以输入数据形式提供的猫代理 ID。
-
在类的顶部,定义你的
WorkManager
:private val workManager = WorkManager.getInstance(this)
-
在你刚刚添加的代码下方添加一个链式
enqueue
请求,仍然在onCreate
函数内:val catSuitUpRequest = ... workManager.beginWith(catStretchingRequest) .then(catFurGroomingRequest) .then(catLitterBoxSittingRequest) .then(catSuitUpRequest) .enqueue()
你的WorkRequests
现在已入队,在它们的约束条件满足且WorkManager
类准备好执行它们时按顺序执行。
-
定义一个函数来显示带有提供消息的吐司。它应该看起来像这样:
private fun showResult(message: String) { Toast.makeText(this, message, LENGTH_SHORT).show() }
-
要跟踪入队
WorkRequest
实例的进度,请在enqueue
调用之后添加以下内容:workManager.beginWith(catStretchingRequest) ... .enqueue() workManager.getWorkInfoByIdLiveData( catStretchingRequest.id).observe(this) { info -> if (info.state.isFinished) { showResult("Agent done stretching") } } workManager.getWorkInfoByIdLiveData( catFurGroomingRequest.id).observe(this) { info -> if (info.state.isFinished) { showResult("Agent done grooming its fur") } } workManager.getWorkInfoByIdLiveData( catLitterBoxSittingRequest.id).observe(this) { info -> if (info.state.isFinished) { showResult("Agent done sitting in litter box") } } workManager.getWorkInfoByIdLiveData( catSuitUpRequest.id).observe(this) { info -> if (info.state.isFinished) { showResult("Agent done suiting up. Ready to go!") } }
上述代码观察了WorkManager
类为每个WorkRequest
提供的WorkInfo
可观察对象。当每个请求完成时,会显示一个包含相关信息的吐司(Toast)。
- 运行你的应用程序:
图 8.1 – 按顺序显示的吐司
现在,你应该会看到一个简单的Hello World!
屏幕。然而,如果你等待几秒钟,你将开始看到通知吐司,告知你 SCA 准备部署到现场时的进度。你会注意到,吐司的顺序与你入队请求的顺序一致,并且它们会按顺序执行延迟。
对用户可见的后台操作 – 使用前台服务
我们将我们的 SCA(服务组件架构)全部准备就绪,现在它们已经准备好前往指定的目的地。为了跟踪 SCA,我们将定期使用前台服务轮询 SCA 的位置,并更新附加到该服务上的粘性通知(用户无法取消的通知)以显示新的位置。
注意
为了简化起见,我们将伪造位置。根据你在第七章,“Android 权限和 Google Maps”中学到的知识,你可以稍后用使用地图的真实实现替换这个实现。
前台服务是执行后台操作的另一种方式。这个名字可能有点令人费解。它的目的是将这些服务与基本的 Android(后台)服务区分开来。前者与一个通知相关联,而后者在后台运行,没有内置的用户界面表示。
前台服务和后台服务之间的重要区别之一是,当系统内存不足时,后者是终止候选者,而前者则不是。
自 Android 9(派,或 API 级别 28)起,我们必须请求FOREGROUND_SERVICE
权限才能使用前台服务。由于它是一个正常权限,它将自动授予我们的应用程序。
在我们可以启动前台服务之前,我们必须首先创建一个。前台服务是 Android 抽象Service
类的子类。如果我们不打算绑定到服务,在我们的例子中我们也不这样做,我们可以简单地重写onBind(Intent)
使其返回null
。
作为旁注,绑定是感兴趣客户端与服务通信的一种方式。在这本书中,我们不会关注这种方法,因为还有其他更简单的方法,你将会发现。
前台服务必须与通知绑定。在 Android 8(Oreo,或 API 级别 26)及以上版本中,如果前台服务在应用程序无响应(ANR)时间窗口内(大约五秒)没有绑定到任何一个,服务将被停止,应用将被声明为无响应。
由于这个要求,我们最好尽快将服务与通知绑定。最好的地方是在服务的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 flag = if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.S) FLAG_IMMUTABLE else 0
val pendingIntent = Intent(this,
MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0,
notificationIntent, flag)
}
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 中,谷歌引入了通道的概念。通道用于分组通知,并允许用户过滤掉不想要的 notifications。
接下来,我们定义pendingIntent
。这将是在用户点击通知时启动的Intent
。在这个例子中,主活动将被启动。它是通过将一个启动活动的Intent
包装在PendingIntent
中来构建的。请求代码被设置为0
,因为在这个例子中我们不期望有结果,所以代码将不会被使用。
对于低于 S(31)的 API,标志被设置为0
。否则,它被设置为推荐的PendingIntent.FLAG_IMMUTABLE
,这意味着在发送时传递给Intent
的额外参数将被忽略。
使用通道 ID 和pendingIntent
,我们可以构建我们的通知。我们使用NotificationCompat
,它消除了支持旧 API 级别的一些样板代码。我们传入上下文和通道 ID。我们定义标题、文本、小图标、Intent
和滚动消息,并构建通知以完成构建器:
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(任何唯一的int
值来标识这个服务,该值不能为 0)和一个通知,该通知必须将其优先级设置为PRIORITY_LOW
或更高。在我们的例子中,我们没有指定优先级,这将其设置为PRIORITY_DEFAULT
:
如果启动,我们的服务现在将显示一个粘性通知。点击通知将启动我们的主活动。
目前,我们的服务除了显示通知外不执行任何操作。要向其中添加一些功能,我们需要重写onStartCommand(Intent?, Int, Int)
。当服务通过 Intent 启动时,会调用这个函数。
这也给了我们读取通过该 Intent 传递的任何额外数据的机会。它还提供了标志(可能设置为START_FLAG_REDELIVERY
或START_FLAG_RETRY
)和一个唯一的请求 ID。我们将在本章的后面部分学习如何读取额外数据。在简单实现中,你不需要担心标志或请求 ID。
重要的一点是onStartCommand(Intent?, Int, Int)
在 UI 线程上被调用,所以不要在这里执行任何长时间运行的操作,否则你的应用会冻结,给用户带来糟糕的体验。相反,我们可以创建一个新的HandlerThread
(一个具有 looper 的线程,用于为线程运行消息循环)并把它的工作发送给它。
这意味着我们将有一个无限循环在运行,等待我们通过一个Handler
来向其发送消息。当我们收到一个start
命令时,我们可以将我们想要执行的工作发送给它。然后,这项工作将在那个线程上执行。
当我们的长时间运行的工作完成时,我们可能希望发生几件事情。首先,我们可能希望通知任何感兴趣的人(例如,如果它正在运行,我们的主活动)我们已经完成。然后,我们可能想要停止在前台运行。最后,如果我们不期望再次需要该服务,我们可以停止它。
应用有几种与服务通信的方式:绑定、使用广播接收器、使用总线架构或使用结果接收器,仅举几例。在我们的例子中,我们将使用 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。
我们可以通过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
已经包含在 androidx.appcompat
中,这样我们就不必手动将其包含到我们的项目中。我们可以在服务的伴生对象中定义一个 LiveData
实例,如下所示:
companion object {
private val mutableWorkCompletion = MutableLiveData<String>()
val workCompletion: LiveData<String> = mutableWorkCompletion
}
注意,我们将 MutableLiveData
实例隐藏在 LiveData
接口后面。这样做是为了确保消费者只能读取数据。现在我们可以使用 mutableWorkCompletion
实例通过为其赋值来报告完成。然而,我们必须记住,值只能分配给主线程上的 LiveData
实例。
这意味着一旦我们的工作完成,我们必须切换回主线程。我们可以轻松实现这一点——我们只需要一个新的处理器,使用主 Looper
(通过调用 Looper.getMainLooper()
获取),然后我们可以向其发送更新。
现在我们已经准备好让服务开始工作,我们终于可以启动它了。在我们这样做之前,我们必须确保我们已经将服务添加到 <application></application>
块中的 AndroidManifest.xml
文件中,如下面的代码所示:
<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
类和多个 Worker 实例显示吐司来跟踪 SCA 准备出发的情况。在这个练习中,你将通过显示一个倒计时通知来跟踪 SCA 部署到现场并向指定的目标移动。
这个通知将由前台服务驱动,它将展示并持续更新它。在任何时候点击通知,如果主活动尚未运行,它将启动主活动,并始终将其带到前台:
-
首先,将
WorkManager
依赖项添加到你的应用的build.gradle
文件中:implementation "androidx.work:work-runtime:2.8.0"
-
创建一个名为
RouteTrackingService
的新类,继承自抽象类Service
:class RouteTrackingService : Service() { override fun onBind(intent: Intent): IBinder? = null }
在这个练习中,你将不会依赖绑定,因此在 onBind(Intent)
实现中简单地返回 null
是安全的。
-
在新创建的服务中,定义一些你稍后需要的常量,以及用于观察进度的
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
实例供观察。
-
在你的
RouteTrackingService
类中添加一个函数,以提供PendingIntent
给你的粘性通知:private fun getPendingIntent(): PendingIntent { val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_ CODES.S) FLAG_IMMUTABLE else 0 return PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), flag)
这将在用户点击Notification
时启动MainActivity
。您调用PendingIntent.getActivity()
,传递一个上下文、没有请求代码(0
)、将启动MainActivity
的Intent
以及如果可用则传递FLAG_IMMUTABLE
标志,否则不传递任何标志(0
)。您将获得一个PendingIntent
,它将启动该活动。
-
添加另一个函数以在运行 Android Oreo 或更高版本的设备上创建
NotificationChannel
并返回通道 ID:private fun createNotificationChannel(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val newChannelId = "CatDispatch" val channelName = "Cat Dispatch Tracking" val channel = NotificationChannel( newChannelId, channelName, NotificationManager.IMPORTANCE_DEFAULT ) val service = requireNotNull( ContextCompat.getSystemService(this, NotificationManager::class.java) ) service.createNotificationChannel(channel) newChannelId } else { "" }
您首先检查 Android 版本。只有在 Android O 或更高版本时才需要创建通道。否则,您可以返回一个空字符串。对于 Android O,您定义通道 ID。这需要对于包来说是唯一的。接下来,您定义一个用户可见的通道名称。
这可以(并且应该)进行本地化。为了简化,我们跳过了那部分。然后创建一个NotificationChannel
实例,并将重要性设置为IMPORTANCE_DEFAULT
。重要性决定了发送到该通道的通知的干扰程度。
最后,使用Notification Service
和NotificationChannel
实例中提供的数据创建一个通道。该函数返回通道 ID,以便可以用于构建Notification
。
-
创建一个函数以提供
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") .setOngoing(true)
此函数接受您之前创建的函数生成的pendingIntent
和channelId
实例,并构建一个NotificationCompat.Builder
类。
构建器允许您定义标题(第一行)、文本(第二行)、要使用的小图标(大小根据设备而异)、当用户点击Notification时触发的Intent
以及一个标签(用于辅助功能;在 Android Lollipop 之前,在通知显示之前显示)。
将通知设置为持续状态可以防止用户将其取消。这也防止了 Android 因频繁更新而静音通知。
您还可以设置其他属性。探索NotificationCompat.Builder
类。在实际项目中,请记住使用strings.xml
中的字符串资源而不是硬编码的字符串。
-
实现以下代码以引入一个启动前台服务的函数:
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 }
您首先使用您之前引入的getPendingIntent
函数获取PendingIntent
。然后,根据设备的 API 级别,您创建一个通知通道并获取其 ID 或设置一个空 ID。
您将pendingIntent
和channelId
传递给构建NotificationCompat.Builder
的函数,并以前台服务的方式启动服务,提供NOTIFICATION_ID
和由构建器构建的通知。该函数返回NotificationCompat.Builder
,稍后用于更新通知。
-
在您的服务中定义两个字段——一个用于持有可重用的
NotificationCompat.Builder
类,另一个用于持有Handler
的引用,您稍后将在后台使用它来发布工作:private lateinit var notificationBuilder: NotificationCompat.Builder private lateinit var serviceHandler: Handler
-
接下来,重写
onCreate()
以将服务作为前台服务启动,并保留Notification.Builder
的引用,然后创建serviceHandler
:override fun onCreate() { super.onCreate() notificationBuilder = startForegroundService() val handlerThread = HandlerThread("RouteTracking").apply { start() } serviceHandler = Handler(handlerThread.looper) }
注意,要创建Handler
实例,你必须首先初始化并启动HandlerThread
。
-
定义一个跟踪你部署的 SCA 在接近其指定目的地时的调用:
private fun trackToDestination(notificationBuilder: NotificationCompat.Builder) { val notificationManager = getSystemService(NOTIFICATION_ SERVICE) as NotificationManager for (i in 10 downTo 0) { Thread.sleep(1000L) notificationBuilder.setContentText( "$i seconds to destination").setSilent(true) notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) } }
这将首先获取NotificationManager
的引用。然后,它将从10
倒数到0
,在更新之间睡眠 1 秒,然后使用剩余时间更新通知。注意我们设置了静默通知。这避免了通知每秒播放声音。
-
添加一个在主线程上通知观察者完成情况的函数:
private fun notifyCompletion(agentId: String) { Handler(Looper.getMainLooper()).post { mutableTrackingCompletion.value = agentId } }
通过在主Looper
上发布消息,你确保更新发生在主(UI)应用线程上。当设置代理 ID 的值时,你是在通知所有观察者该代理 ID 已到达目的地。
-
按如下方式重写
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()
并返回一个向后兼容的状态,你可以返回这个状态。你存储这个返回值。接下来,你从通过Intent
传递的额外内容中获取 SCA ID。如果没有提供代理 ID,该服务将无法工作,因此你抛出一个异常。
接下来,你切换到onCreate
中定义的背景线程,以阻塞方式跟踪代理到其目的地。当跟踪完成时,你通知观察者任务已完成,停止前台服务(通过传递true
移除通知),并停止服务本身,因为你不期望很快再次需要它。然后你返回之前存储的从super
返回的值。
-
更新你的
AndroidManifest.xml
文件以请求必要的权限并引入服务:<manifest ...> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <application ...> <service android:name=".RouteTrackingService" android:enabled="true" android:exported="true" /> <activity ...>
首先,我们声明我们的应用需要FOREGROUND_SERVICE
权限。除非我们这样做,否则系统会阻止我们的应用使用前台服务。我们还请求POST_NOTIFICATIONS
权限,没有这个权限,我们无法在 SDK 33+上显示通知。接下来,我们声明服务。设置android:enabled="true"
告诉系统它可以实例化该服务。
默认值为"true"
,因此这是可选的。使用android:exported="true"
定义服务告诉系统其他应用程序可以启动该服务。在我们的情况下,我们不需要这个额外的功能,但我们添加它只是为了让你了解这种能力。
-
回到你的
MainActivity
。引入一个用于启动RouteTrackingService
的函数:private fun launchTrackingService() { RouteTrackingService.trackingCompletion.observe( this) { agentId -> showResult("Agent $agentId arrived!") } val serviceIntent = Intent(this, RouteTrackingService::class.java).apply { putExtra(EXTRA_SECRET_CAT_AGENT_ID, "007") } ContextCompat.startForegroundService(this, serviceIntent) }
此函数首先观察trackingCompletion
LiveData
以获取完成更新,并在完成时显示结果。然后,它定义一个用于启动服务的Intent
,将 SCA ID 作为该Intent
的额外参数。然后,它使用ContextCompat
以前台服务的方式启动服务,这为您隐藏了兼容性相关的逻辑。
-
现在,将
onCreate()
中的逻辑(setContentView
调用之后的所有内容)提取到一个名为dispatchCat
的私有函数中。 -
将
dispatchCat
更新为在 SCA 装备完毕并准备出发时立即开始跟踪:workManager.getWorkInfoByIdLiveData( catSuitUpRequest.id).observe(this) { info -> if (info.state.isFinished) { showResult("Agent done suiting up. Ready to go!") launchTrackingService() } }
-
创建一个名为
ensurePermissionGrantedAndDispatchCat
的新私有函数。在这个函数中,确保你有POST_NOTIFICATIONS
权限。如果没有,请请求它。有关实现细节,请参阅第七章,Android 权限和 Google Maps。如果你或你有权限,请调用dispatchCat
。 -
启动应用程序:
图 8.2 – 通知倒计时
在通知告知你 SCA 的准备工作步骤之后,你应该在你的状态栏中看到一个通知。然后,该通知应从 10 倒数到 0,消失,并替换为一个吐司通知,告知你代理已到达目的地。看到最后一个吐司通知告诉你,你成功将 SCA ID 传达给了服务,并在后台任务完成后将其返回。
利用本章获得的所有知识,让我们完成以下活动。
活动 8.01 – 喝水提醒
人类平均每天会流失大约 2,500 毫升(ml)的水。为了保持健康,我们需要消耗与流失相等的水量。然而,由于现代生活的繁忙性质,我们很多人会忘记定期补充水分。
注意
有关此信息的更多信息,请参阅packt.link/90nbQ
。
假设你想开发一个应用程序,该应用程序可以跟踪你的水分流失(统计上)并持续更新你的体液平衡。从平衡状态开始,应用程序会逐渐降低用户跟踪的水位。用户可以在喝了一杯水后告诉应用程序,应用程序会相应地更新水位。
水位持续更新的过程将利用你运行后台任务的知识,你还将利用与服务通信的知识来根据用户交互更新余额。
以下步骤将帮助你完成活动:
-
创建一个空的活动项目,并将你的应用程序命名为
My Water Tracker
。 -
将前台服务和发布通知权限添加到你的
AndroidManifest.xml
文件中。 -
创建一个新的服务。
-
在你的服务中定义一个变量来跟踪水位。
-
定义通知 ID 和额外的
Intent
数据键的常量。 -
从服务中设置通知的创建。
-
添加请求通知权限(如果需要)、启动前台服务和更新水位的功能。
-
将水位设置为每 5 秒降低 0.144 毫升。
-
处理来自服务外部的液体添加。
-
确保服务在销毁时清理回调和消息。
-
在
Manifest.xml
文件中注册服务。 -
在创建活动并在必要时授予通知权限后,从
MainActivity
启动服务。 -
在主活动布局中添加一个带有喝了一杯水标签的按钮。
-
当用户点击按钮时,通知服务需要增加 250 毫升的水位。
注意
该活动的解决方案可在packt.link/By7eE
找到。
摘要
在本章中,我们学习了如何使用WorkManager
和前台服务执行长时间运行的后台任务。我们讨论了如何向用户传达进度,以及如何在任务执行完成后将用户带回应用。本章涵盖的所有主题都非常广泛,你可以进一步探索与服务通信、构建通知和使用WorkManager
类。
对于大多数常见场景,你现在拥有了所需的工具。常见的用例包括后台下载、清理缓存资源、在应用不在前台运行时播放媒体,以及结合我们从第七章学到的知识,即Android 权限和 Google Maps,跟踪用户随时间变化的位置。
在下一章中,我们将探讨通过编写单元和集成测试来使我们的应用更加健壮和易于维护。这在你的代码在后台运行且错误发生时并不立即明显的情况下尤其有帮助。
第九章:使用 Jetpack Compose 构建用户界面
在本节中,你将学习如何使用 Kotlin 代码通过 Jetpack Compose 创建用户界面,了解 Compose 如何革命性地改变我们构建用户界面的方式,以及如何将现有应用程序转换为 Jetpack Compose。到本章结束时,你将熟悉 Compose 中最常见的 UI 元素以及如何处理用户操作。
在本章中,我们将涵盖以下主题:
-
什么是 Jetpack Compose?
-
处理用户操作
-
Compose 中的主题
-
将 Compose 添加到现有项目中
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,网址为 packt.link/kb5FW
什么是 Jetpack Compose?
在前面的章节中,你学习了如何将数据设置到 Android 视图层次结构中,以及如何使用不同类型的视图来实现不同的目的。这种用户界面构建方法被称为 命令式方法。
在命令式方法中,当我们想要改变用户界面的状态时,我们需要手动更改每个用户界面元素,直到达到我们期望的结果。
假设由于用户操作,我们希望我们的 TextView
改变文本和文本颜色。这意味着我们需要调用 setText
和 setTextColor
来实现我们期望的效果。
作为命令式方法的替代,我们有 声明式方法,其中我们需要描述我们希望用户界面达到的最终状态,然后内部执行所需的调用。
这意味着我们的 TextView
将具有文本和文本颜色作为属性,我们可以定义不同的对象来保存我们想要的状态。在 Jetpack Compose 中,这看起来就像以下示例:
@Composable
fun MyTextDisplay(myState: MyState) {
Text(text = myState.text, color = myState.color)
}
data class MyState(
val text: String,
val color: Color
)
在前面的例子中,我们定义了一个将显示 Text
元素的 @Composable
函数。text
和 color
的值将保存在一个单独的数据类中,该数据类将代表文本的状态。
这种机制允许 Jetpack Compose 在 MyState
发生任何变化时重新绘制用户界面。这个过程被称为 @Composable
函数发生了变化。
如果我们想在 Compose 中创建一个新的屏幕,我们可以选择使用 Row
或 Column
函数来排列元素。对于垂直排列,我们可以使用 Column
,而对于水平对齐,我们使用 Row
:
@Composable
fun MyScreen(){
Column {
Text(text = "My Static Text")
TextField(value = "My Text Field", onValueChange = {
})
Button(onClick = { }) { }
Icon(painter = painterResource(R.drawable.icon),
contentDescription = stringResource(id =
R.string.icon_content_description))
}
}
在前面的例子中,使用 Column
,我们展示了不同的用户界面元素是上下排列的:
-
Text
将显示一个简单的标签,其中My Static Text
标签作为text
。 -
TextField
将显示一个输入字段,该字段通过value
参数预先填充了My Text Field
文本。onValueChange
lambda 将捕获用户插入的任何文本。 -
Button
将显示一个按钮,通过onClick
lambda,我们可以捕获其上的点击事件。 -
Icon
将显示来自drawable
或mipmap
文件夹的特定图标,并将从字符串资源设置内容描述。
如果我们想显示项目列表,当项目数量已知且足够小,可以适应设备屏幕时,我们可以使用以下方法:
@Composable
fun MyList(items: List<String>) {
Column {
items.forEach { item -> Text(text = item) }
}
}
在前面的示例中,我们遍历列表中的每个项目,并为每个项目显示一个 Text
。如果项目的数量未知且足够大,需要滚动,那么我们就会遇到性能问题,因为重组会覆盖可见和不可见的项目。对于这种情况,我们可以使用以下方法:
@Composable
fun MyList(items: List<String>) {
LazyColumn {
item { Text(text = "Header") }
items(items){ item-> Text(text = item) }
item { Text(text = "Footer") }
}
}
在这里,我们使用 LazyColumn
函数,并用 items
函数包裹我们的集合。我们还可以通过 item
函数添加静态项作为列表的头部和尾部。像 Column
一样,我们可以使用 LazyRow
来显示具有水平滚动的项目列表。
如果我们想在 UI 元素之间添加间距,那么 Modifiers
就变得很有用:
@Composable
fun MyScreen(){
Column {
Text(text = "My Static Text")
TextField(value = "My Text Field", onValueChange =
{ }
}
在前面的示例中,我们在所有方向上为所有项目的整个列添加了 16.dp
的内边距。如果我们想在不同方向上使用不同的内边距,我们会得到以下类似的内容:
@Composable
fun MyScreen() {
Column(
modifier = Modifier.padding(
top = 5.dp, bottom = 5.dp,
start = 10.dp, end = 10.dp)
) {
}
}
在前面的示例中,我们设置了 5.dp
的垂直内边距和 10.dp
的水平内边距。因为值是重复的,我们可以使用以下方法:
@Composable
fun MyScreen() {
Column(
modifier = Modifier.padding(
vertical = 5.dp,
horizontal = 10.dp)
) {
}
}
这里,我们使用了 vertical
和 horizontal
参数来设置垂直和水平内边距。如果我们想使行可点击,则可以使用以下修饰符:
@Composable
fun MyScreen() {
Column(
modifier = Modifier.padding(
vertical = 5.dp,
horizontal = 10.dp
).clickable { }
) { }
}
在前面的示例中,我们使用了 Modifier
的 clickable
方法。这将在整个 Column
上注册一个点击监听器。
当涉及到 Activity 和 Fragment 以及在 Compose 中构建的屏幕之间的关系时,我们有以下 Compose 扩展:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MyScreen() }
}
}
在前面的示例中,我们为 ComponentActivity
定义了 setContent
扩展函数,该函数将 @Composable
函数设置为 Activity
的内容。对于片段也是如此;然而,在启动新项目时,当前的推荐是只有一个 Activity
,并将所有额外的屏幕作为单独的 @``Composable
函数。
注意
在 Android Studio 中,我们有使用 @``Preview
注解预览我们的 @Composable
函数的可能性。
练习 9.01 – 第一个 Compose 屏幕
创建一个使用 @Composable
函数定义的单屏幕 Android 应用程序。屏幕应包含以下元素:
-
Text
,将显示"Enter
a number"
-
TextField
,将只接受整数 -
Button
,其文本为"``Click Me"
-
LazyColumn
,将显示一个包含 100 个项目的列表,每个行的格式如下:"``Item c"
执行以下步骤以完成练习:
-
创建一个新的 Android Studio 项目并选择 Empty Compose Activity。
-
将以下内容添加到
res/values
文件夹中的strings.xml
:<string name="enter_number">Enter a number</string> <string name="click_me">Click Me</string> <string name="item_format">Item %s</string>
-
定义练习的用户界面:
@Composable fun MyScreen( items: List<String> ) { LazyColumn { item { Column(modifier = Modifier.padding(16.dp)) { Text(text = stringResource(id = R.string.enter_number)) TextField( value = "", keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), onValueChange = { }) Button(onClick = { }) { Text(text = stringResource(id = R.string.click_me)) } } } items(items) { item -> Column(modifier = Modifier.padding(vertical = 4.dp)) { Text(text = item) } } } }
我们选择将所有内容放置在LazyColumn
块中。这将使整个内容可滚动,包括Text
、TextField
和Button
,而不仅仅是项目列表。
为了使键盘只接受数字输入,我们使用了TextField
函数的keyboardOptions
参数。对于Button
,为了向其添加文本,我们需要使用函数的内容参数,并在 lambda 中放置一个新的Text
。
-
最后,修改
MainActivity
代码以使用我们刚刚定义的函数:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val items = (1..100).toList().map { stringResource(id = R.string.item_format, formatArgs = arrayOf("$it")) } MyScreen(items) } } }
在这里,我们生成一个新列表的项目,它们将显示为"Item [count]"
,并调用@Composable
函数MyScreen
。
如果我们运行前面的示例,我们将看到以下输出:
图 9.1 – 练习 9.01 的输出
我们可以在前面的图中看到,屏幕是根据练习规范构建的。需要注意的是,我们不允许在输入字段中输入任何文本。这是因为我们将TextField
的值设置为空字符串。我们将在本书的后续章节中探讨如何正确处理这一方面。
在本节中,我们探讨了如何使用 Jetpack Compose 以简单的方式构建用户界面,而不涉及其他语言和语法,如 Kotlin。接下来,我们将继续探索 Compose,以及我们如何处理用户操作和管理状态。
处理用户操作
在上一节中,我们学习了如何使用 Jetpack Compose 构建用户界面。在练习中,我们无法收集用户在TextField
中设置的数据。在本节中,我们将学习如何处理用户输入以及用户界面的状态。
假设我们有以下示例:
@Composable
fun MyScreen() {
Column { TextField(value = "", onValueChange = {}) }
}
在这个例子中,我们定义了一个空白的TextField
,没有处理值的变化。正如我们所看到的,这不会让我们从键盘引入任何新的输入,因为它总是会将文本设置为空字符串。
为了引入新的文本,我们需要创建一个可变变量来存储文本,并确保它在重新组合中存活。在 Jetpack Compose 中,我们可以使用名为@Composable
的函数remember
来定义一个MutableState
,它将保存我们的文本:
@Composable
fun MyScreen() {
var text by remember { mutableStateOf("") }
Column { TextField(value = text, onValueChange = {}) }
}
在前面的示例中,我们定义了一个名为text
的可变变量,并将其设置在TextField
中。text
变量通过remember
函数初始化,它将保存一个MutableState
,其初始值设置为空字符串。这还不是完整的;我们现在需要将状态与TextField
值的变化连接起来:
@Composable
fun MyScreen() {
var text by remember { mutableStateOf("") }
Column {
TextField(value = text, onValueChange = { text = it })
}
}
在这里,我们修改了onValueChange
的 lambda 表达式,以便使用用户插入的最新文本来改变text
状态。
注意
我们可以使用rememberSaveable
函数来保留对象在配置更改(如 Activity 重建)之间的值。
在处理状态时,我们倾向于将 @Composable
函数从无状态(不管理状态)转换为有状态(管理一个或多个状态)。作为一个指导原则,我们应该尽量通过名为 @Composable
函数的模式保持我们的函数尽可能无状态。这将使前面的例子变成以下形式:
@Composable
fun MyScreen() {
var text by rememberSaveable { mutableStateOf("") }
MyScreenContent(text = text, onTextChange = { text = it })
}
@Composable
fun MyScreenContent(text: String, onTextChange: (String) -> Unit) {
Column {
TextField(value = text, onValueChange =
onTextChange)
}
}
在前面的例子中,我们将我们的函数分成了两部分。MyScreen
函数将管理文本状态并调用 MyScreenContent
函数,该函数现在是无状态的。这种方法引入了多个好处,如我们无状态函数的 可重用性、解耦 状态管理和 单一事实来源。
注意
当你使用 Jetpack Compose 状态和 MutableState
对象时,你可能需要手动导入以下两个用于获取和设置状态的方法:androidx.compose.runtime.getValue
和 androidx.compose.runtime.setValue
。
在处理 Jetpack Compose 中的状态时,当状态发生变化时,会触发重新组合过程。这可能会在我们想要显示一次性事件,如 Snackbar
和 Toast
时引起问题。为了实现这一点,我们可以使用 LaunchedEffect
:
@Composable
fun MyScreenContent() {
val context = LocalContext.current
LaunchedEffect(anObjectToChange) {
Toast.makeText(context, "Toast text",
Toast.LENGTH_SHORT).show()
}
}
前面的例子会在 anObjectToChange
每次取不同值时显示一个 Toast
消息。如果我们用 Unit
替换 anObjectToChange
,那么 LaunchedEffect
块将只执行一次。
练习 9.02 – 处理用户输入
修改 练习 9.01 – 第一个 Compose 屏幕,使得当用户在 TextField
中输入一个数字并点击按钮时,将生成一个与输入数字大小相同的项目列表,并将它们填充在按钮下面的列表中。每个项目的文本将与之前相同。
为了表示用户界面的状态,将创建一个数据类来保存项目数量,默认为 0
,以及项目列表,默认为空。
执行以下步骤以完成练习:
-
创建
MyScreenState
数据类,它将保存用户界面的状态:data class MyScreenState( val itemCount: String = "", val items: List<String> = emptyList() )
-
创建一个名为
MyScreenContent
的@Composable
方法,它将MyScreenState
作为参数并渲染状态:@Composable fun MyScreenContent( myScreenState: MyScreenState, onItemCountChange: (String) -> Unit, onButtonClick: @Composable () -> Unit ) { LazyColumn { item { Column(modifier = Modifier.padding(16.dp)) { Text(text = stringResource(id = R.string.enter_number)) TextField( value = myScreenState.itemCount, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), onValueChange = onItemCountChange ) Button(onClick = onButtonClick) { Text(text = stringResource(id = R.string.click_me)) } } } items(myScreenState.items) { item -> Column(modifier = Modifier.padding(vertical = 4.dp)) { Text(text = item) } } } }
在前面的例子中,我们在 TextField
中设置了 myScreenState
的 itemCount
,并将 myScreenState
的 items
作为列表中的项目。我们还添加了文本更改监听器和按钮监听器作为函数的参数,使其变为无状态。
-
修改
MyScreen
函数,使其调用MyScreenContent
并处理文本更改和按钮点击的监听器:@Composable fun MyScreen() { var state by remember { mutableStateOf(MyScreenState()) } val context = LocalContext.current MyScreenContent(state, { state = state.copy(itemCount = it) }, { state = state.copy(items = (1..state.itemCount.toInt()).toList().map { context.getString(R.string.item_format, "$it") }) }) }
在这里,我们创建了一个新的 MutableState
,它将保存具有默认值的 MyScreenState
。然后我们将调用 MyScreenContent
并传递状态。当文本更改时,我们将状态设置为现有状态的副本,并带有新文本,当按钮被点击时,我们将生成一个新项目列表,直到当前的 itemCount
并更新状态。
-
更新
MainActivity
类以调用不带任何参数的MyScreen
函数:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyScreen() } } }
如果我们运行练习并插入一个数字,那么我们应该看到以下屏幕:
图 9.2 – 练习 9.02 的输出
当应用程序首次启动时,我们应该看到TextField
为空,并且按钮下没有元素。当设置一个数字时,状态将改变以反映新的文本,当点击按钮时,将显示包含插入数字大小的项目列表。
在本节中,我们探讨了如何处理用户输入,保持其状态,并在重新组合中管理该状态。在接下来的章节中,我们将探讨如何进一步装饰我们的用户界面元素。
Compose 中的主题化
在上一节中,我们学习了如何处理用户操作以及如何管理特定屏幕的状态。但我们是如何保持应用程序的用户界面元素在整个应用程序中的一致性呢?在本节中,我们将探讨如何创建与应用程序主题相关联的可重用元素。
您可能已经注意到,在执行前面的练习时,Android Studio 在ui.theme
包中创建了一些文件。这是因为 Jetpack Compose 建立在 Material Design 库之上,并将为您的应用程序分配一个基于 Material Design 的主题。所采用的方法如下:
-
在
Color.kt
文件中,声明了应用程序的所有颜色:val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5)
在前面的示例中,我们有颜色十六进制名称。
-
在
Shape.kt
文件中,生成了以下代码:val Shapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(4.dp), large = RoundedCornerShape(0.dp) )
这将指示您在应用程序中使用的图标的大小。
-
在
Type.kt
文件中,生成了以下代码:val Typography = Typography( body1 = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp ) )
这将表示您的应用程序中文本是如何渲染的。Typography
类包含有关标题、副标题、段落、按钮和标题文本应如何配置的设置。
-
在
Theme.kt
文件中,定义了两个颜色调色板:private val DarkColorPalette = darkColors( primary = Purple200, primaryVariant = Purple700, secondary = Teal200 ) private val LightColorPalette = lightColors( primary = Purple500, primaryVariant = Purple700, secondary = Teal200 )
这里定义了浅色和深色颜色调色板,并设置了primary
、primaryVariant
和secondary
颜色。lightColors
和darkColors
函数中的其余颜色将保留其默认值。
-
在同一文件中,生成应用程序的主题:
@Composable fun MyApplicationTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { val colors = if (darkTheme) { DarkColorPalette } else { LightColorPalette } MaterialTheme( colors = colors, typography = Typography, shapes = Shapes, content = content ) }
在这里,将检查设备是否启用了浅色或深色模式,并为每种模式使用适当的颜色集。它还将设置您配置的字体样式以及应用程序中形状的样式。尽管它已在主题中设置,但这并不意味着我们的用户界面元素会自动继承它。
-
当生成
MainActivity
类时,它将具有以下结构:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyApplicationTheme { Surface(color = MaterialTheme.colors.background) { } } } } }
当调用setContent
时,您的应用程序主题将被调用,并且Surface
函数将设置应用程序的背景。
我们现在可以将前面的设置作为起点来定义应用程序的主题,并开始创建可重用的用户界面组件。假设我们希望应用程序中的所有段落都使用相同的排版和颜色;在这种情况下,我们将使用 MaterialTheme.typography.body1
和 MaterialTheme.colors.onBackground
:
@Composable
fun ParagraphText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
}
在前面的示例中,我们定义了 ParagraphText
函数,该函数将设置从 MaterialTheme
来的文本样式和颜色。如果我们想使用相同的样式和不同的文本颜色,可能需要为每个样式属性重复,这时我们可能遇到问题。另一个解决方案是创建两个函数——一个用于样式,另一个用于其上的颜色:
@Composable
fun OnBackgroundParagraphText(text: String) {
ParagraphText(text = text, color =
MaterialTheme.colors.onBackground)
}
@Composable
fun ParagraphText(text: String, color: Color) {
Text(
text = text,
style = MaterialTheme.typography.body1,
color = color
)
}
在前面的示例中,我们将颜色移动到 ParagraphText
函数的参数中,然后创建了一个名为 OnBackgroundParagraphText
的新函数,这使得我们可以将 MaterialTheme.colors.onBackground
设置为在 ParagraphText
中定义的 Text
。如果我们想使用我们的新函数,我们可以这样做:
@Composable
fun MyScreen() {
OnBackgroundParagraphText(text = "My text")
}
这是一个简单的函数调用,就像使用 Text
函数一样。
现在,假设我们正在将此文本应用于整个应用程序,并且应用程序经历了一次重新设计,其中不再使用 MaterialTheme.typography.body1
,而是需要使用 MaterialTheme.typography.body2
,并且文本颜色需要是红色。在这种情况下,我们将修改 ParagraphText
函数如下:
@Composable
fun ParagraphText(text: String, color: Color) {
Text(
text = text,
style = MaterialTheme.typography.body2,
color = color
)
}
在这里,我们将 Text
函数的样式更改为使用 MaterialTheme.typography.body2
。要更改颜色,我们可以修改 OnBackgroundParagraphText
,但当前使用的颜色建议用于当前背景之上,因此我们也可以更改 MaterialTheme.colors.onBackground
的值。为此,我们可以进入 Theme.kt
并执行以下操作:
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200,
onBackground = Color.Red
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200,
onBackground = Color.Red
)
在这里,我们将 onBackground
的值更改为红色,这将影响所有引用 onBackground
的用户界面元素。我们现在可以看到如何轻松地将此应用于应用程序中的所有用户界面元素,而无需触及使用这些元素的地方的代码。
如果我们想在应用程序中拥有多个屏幕,我们可以将 Compose 与 navigation
库连接起来,该库在此处可用:
implementation "androidx.navigation:navigation-compose:2.5.3
现在假设我们在 Jetpack Compose 中定义了两个屏幕:
@Composable
fun Screen1(onButtonClick: () -> Unit) {
Button(onClick = onButtonClick) {
Text(text = "Click Me")
}
}
@Composable
fun Screen2(input1: String, input2: String) {
Text(text = "My inputs are $input1 and $input2")
}
Screen1
将显示一个按钮,而 Screen2
有两个输入将被显示。我们现在想连接这两个屏幕,以便当在 Screen1
上点击按钮时,Screen2
会打开,并传递两个硬编码的输入。这看起来如下所示:
@Composable
fun MyApp(navController: NavHostController) {
NavHost(navController = navController,
startDestination = "screen1") {
composable("screen1") {
Screen1 { navController.navigate
("screen2/Input1?input2=Input2") }
}
composable(
"screen2/{input1}?input2={input2}",
arguments = listOf(navArgument("input1") {
type = NavType.StringType },
navArgument("input2") { type =
NavType.StringType }
)
) {
Screen2(
input1 = it.arguments?
.getString("input1").orEmpty(),
input2 = it.arguments?
.getString("input2").orEmpty()
)
}
}
}
我们定义了一个新的 @Composable
函数 MyApp
,它使用 NavHost
来保持应用程序中的所有屏幕。NavHost
将通过 screen1
URL 默认打开 Screen1
。在 Screen1
的 onButtonClick
lambda 中,我们导航到 Screen2
并传递 input1
和 input2
字符串。
这是通过screen2/{input1}?input2={input2}
URL 完成的。这也是我们在两个屏幕之间传递参数的方式,无论是通过路径参数(input1
)还是通过参数(input2
)。对于每个输入,我们需要指定我们期望的是一个字符串类型。
然后,将打开Screen2
,并通过it
变量提取输入,it
是一个NavBackStackEntry
类型。我们可以从 Activity 的setContent
方法中调用此函数:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(color =
MaterialTheme.colors.background) {
val navController =
rememberNavController()
MyApp(navController)
}
}
}
}
}
在这里,我们提升NavHostController
的状态,然后调用MyApp
函数。
练习 9.03 – 应用主题
修改练习 9.02 – 处理用户输入,以便将MyScreen
、MyScreenContent
和MyScreenState
拆分为两个屏幕,其中ItemCountScreen
、ItemCountScreenContent
和ItemCountScreenState
在一侧,将包含Text
、TextField
和Button
,而ItemScreen
、ItemScreenContent
和ItemScreenState
在另一侧,将包含项目列表。
两个屏幕将被保存在ItemCountScreen.kt
和ItemScreen.kt
文件中。ItemCountScreen
将首先显示,当按钮被点击时,然后显示ItemScreen
,并在上一个屏幕中设置项目数量。
还将创建新的函数来表示应用程序中使用的Text
:一个用于"Enter a number"
文本,它将是MaterialTheme.typography.h5
;"Click Me"
文本将是MaterialTheme.typography.button
;而"Item [count]"
将是MaterialTheme.typography.body1
。
文本的颜色将设置为MaterialTheme.colors.onBackground
,按钮文本的颜色为Color.red
。
执行以下步骤以完成练习:
-
在
app/build.gradle
文件中,添加navigation
库依赖项:implementation "androidx.navigation:navigation-compose:2.5.3"
-
在
ui.theme
包中,创建一个名为Elements
的 Kotlin 文件。 -
在
Elements.kt
文件中,添加第一屏幕上标题文本的函数:@Composable fun OnBackgroundTitleText(text: String) { TitleText(text = text, color = MaterialTheme.colors.onBackground) } @Composable fun TitleText(text: String, color: Color) { Text(text = text, style = MaterialTheme.typography.h5, color = color) }
-
在同一文件中,添加
"Item [``count]"
文本的函数:@Composable fun OnBackgroundItemText(text: String) { ItemText(text = text, color = MaterialTheme.colors.onBackground) } @Composable fun ItemText(text: String, color: Color) { Text(text = text, style = MaterialTheme.typography.body1, color = color) }
-
在同一文件中,添加按钮文本的函数:
@Composable fun PrimaryTextButton(text: String, onClick: () -> Unit) { TextButton(text = text, textColor = Color.Red, onClick = onClick) } @Composable fun TextButton(text: String, textColor: Color, onClick: () -> Unit) { Button( onClick = onClick, colors = ButtonDefaults .buttonColors(contentColor = textColor) ) { Text(text = text, style = MaterialTheme.typography.button) } }
在这个例子中,由于按钮以不同的方式设置内容的颜色,我们不得不使用ButtonColors
类中的contentColor
。
-
创建一个名为
ItemCountScreen
的新 Kotlin 文件。 -
在此文件中,创建一个名为
ItemCountScreenState
的新类:data class ItemCountScreenState( val itemCount: String = "" )
-
在同一文件中,创建一个名为
ItemCountScreenContent
的新函数,该函数将包含新创建的OnBackgroundTitleText
和PrimaryTextButton
函数:@Composable fun ItemCountScreenContent( itemCountScreenState: ItemCountScreenState, onItemCountChange: (String) -> Unit, onButtonClick: () -> Unit ) { Column { OnBackgroundTitleText(text = stringResource(id = R.string.enter_number)) TextField( value = itemCountScreenState.itemCount, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number), onValueChange = onItemCountChange ) PrimaryTextButton(text = stringResource(id = R.string.click_me), onClick = onButtonClick) } }
-
在同一文件中,创建一个名为
ItemCountScreen
的新函数:@Composable fun ItemCountScreen(onButtonClick: (String) -> Unit) { var state by remember { mutableStateOf(ItemCountScreenState()) } ItemCountScreenContent(state, { state = state.copy(itemCount = it) }, { onButtonClick(state.itemCount) }) }
-
创建一个名为
ItemScreen
的新 Kotlin 文件。 -
在该文件中,创建一个名为
ItemScreenState
的新类:data class ItemScreenState( val items: List<String> = emptyList() )
-
在同一文件中,创建一个名为
ItemScreenContent
的新函数,该函数将使用OnBackgroundItemText
:@Composable fun ItemScreenContent( itemScreenState: ItemScreenState ) { LazyColumn { items(itemScreenState.items) { item -> Column(modifier = Modifier.padding(vertical = 4.dp)) { OnBackgroundItemText(text = item) } } } }
-
在同一文件中,创建一个名为
ItemScreen
的新函数:@Composable fun ItemScreen(itemCount: String) { ItemScreenContent(itemScreenState = ItemScreenState((1..itemCount.toInt()).toList() .map { stringResource(id = R.string.item_format, formatArgs = arrayOf("$it")) })) }
-
在
MainActivity
文件中,创建MyApp
函数,该函数将管理之前定义的两个屏幕:@Composable fun MyApp(navController: NavHostController) { NavHost(navController = navController, startDestination = "itemCountScreen") { composable("itemCountScreen") { ItemCountScreen { navController.navigate( "itemScreen/?itemCount=$it") } } composable( "itemScreen/?itemCount={itemCount}", arguments = listOf(navArgument("itemCount") {type = NavType.StringType }) ) { ItemScreen( it.arguments?.getString("itemCount") .orEmpty() ) } } }
-
最后,修改
setContent
函数,以便调用MyApp
:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyApplicationTheme { Surface(color = MaterialTheme.colors.background) { val navController = rememberNavController() Column(modifier = Modifier.padding(16.dp)) { MyApp(navController) } } } } } }
如果我们运行应用程序,我们应该看到以下输出:
图 9.3 – 练习 9.03 的输出
我们应该能够看到屏幕被分成两部分,并且在在一个屏幕中输入一个数字后,我们应该过渡到另一个屏幕,并显示一个生成的项目列表。我们还应该看到为 Text
函数新定义的样式。我们只能从 Elements
类中控制这些样式,而不会对屏幕本身进行任何修改。
在本节中,我们学习了如何将主题应用到应用程序中,以及我们如何使用 Jetpack Compose 创建多个屏幕并在它们之间导航。在下一节中,我们将探讨如何将 Compose 集成到现有项目中,以及它如何与其他流行的库集成。
将 Compose 添加到现有项目
在本节中,我们将探讨将 Jetpack Compose 引入现有 Android 应用程序的各种选项,以及如何使 Compose 与不同的库一起工作。
当使用 Jetpack Compose 时,理想情况下应该有少量活动,如果可能的话,只有一个,并且所有屏幕都使用 Compose 构建。为了使现有项目能够实现这一点,它需要从 View
层级的底部开始,这意味着你的现有视图应该开始迁移到使用 Compose 构建。
为了方便这种过渡,Jetpack Compose 提供了在 XML 布局中使用 ComposeView
的可能性,如下例所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
在这里,我们有一个现有的布局,它需要包含一个使用 Jetpack Compose 定义的视图。在布局 XML 文件中,我们可以在我们的视图将存在的位置放置一个 ComposeView
占位符,然后在 Kotlin 代码中,我们可以包含 Compose 用户界面元素:
class MyFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(
R.layout.my_fragment_layout, container).apply {
findViewById<ComposeView>(R.id.compose_view)
.apply {
setViewCompositionStrategy(
ViewCompositionStrategy
.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme {
Text("My Text")
}
}
}
}
}
}
在这个例子中,Fragment
展示了 XML 布局,查找 ComposeView
,并标记当 Fragment
的 View
被销毁时,Compose 内容也应该被销毁,以防止任何泄漏,然后设置 ComposeView
的内容为 Text
。
当我们想要走相反的路,将 Android 视图添加到 Compose 代码中时,我们有使用 AndroidView
的选项:
@Composable
fun MyCustomisedElement(text: String) {
AndroidView(factory = { context ->
TextView(context).apply {
this.text = text
}
})
}
在这个例子中,我们定义了一个新的 @Composable
函数,名为 MyCustomisedElement
,它将调用 AndroidView
,然后创建一个 TextView
,并将我们定义的文本作为参数设置到该文本视图上。
正如我们在前面的章节中看到的,我们可以使用 LocalContext.current
来获取 Context
引用。这允许我们执行启动活动、服务以及显示 Toasts
等操作。
Compose 还能够与其他在构建 Android 应用程序时有用的库进行交互。我们将在接下来的章节中分析这些库,但现在,我们将看看它们是如何与 Jetpack Compose 一起工作的:
-
ViewModel
库对于在 Activity 和 Fragment 的配置更改中保持数据很有用,并有助于使我们的代码更容易测试。Compose 可以通过名为@Composable
的函数viewModel
获取ViewModel
对象的引用:@Composable fun MyScreen(viewModel: MyViewModel = viewModel()) { Text(text = viewModel.myText) }
在这里,我们调用viewModel
来获取MyViewModel
的引用,并使用viewModel
持有的值设置Text
。
- 数据流库与
ViewModel
库结合使用很有用,因为我们想要从互联网或本地文件系统异步加载数据时,需要通知用户界面数据已加载。
常见的数据流库有 LiveData、RxJava 和 Coroutines 和 Flows。我们已看到,当我们要管理用户界面的状态时,Compose 使用State
对象。对于这三个库,Compose 提供了扩展库,将数据流转换为State
对象:
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
viewModel.myLiveData.observeAsState()?.let{
myLiveDataText->
Text(text = myLiveDataText)
}
viewModel.myObservable.subscribeAsState()?.let{
myObservableText->
Text(text = myObservableText)
}
viewModel.myFlow.collectAsState()?.let{
myFlowText->
Text(text = myFlowText)
}
}
在这个例子中,我们的viewModel
对象将包含每个数据流,这些数据流将保存一个字符串。对于每个流,Compose 调用等效的方法来订阅并监控字符串值的更改。当每个流发出新的值时,Compose 将其设置在Text
中。
-
Hilt 是一个为 Android 应用开发设计的依赖注入库。如果项目中没有
navigation
库,那么使用前面描述的viewModel
函数就足够获取到ViewModel
的引用;然而,如果navigation
库存在,那么还需要包含一个使hilt
和navigation
协同工作的库:implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
要在 Compose 代码中获取ViewModel
对象的引用,我们需要将viewModel
的调用替换为hiltViewModel
的调用。
你可以在developer.android.com/jetpack/compose
找到有关将 Jetpack Compose 集成到你的 Android 应用程序中的更多信息,以及与其他库的兼容性信息:developer.android.com/jetpack/compose/libraries
。
在本节中,我们探讨了如何集成 Jetpack Compose 库,使其与项目中的现有View
对象和现有库协同工作。
活动第 9.01 节 – 第一个 Compose 应用
使用 Jetpack Compose 创建一个新的应用,它将包含三个屏幕:
-
插入行屏幕将有一个标题、一个文本字段和一个按钮,可以在其中插入一个数字。当按钮被点击时,用户将导航到下一个屏幕。
-
插入列屏幕将有一个标题、一个可以插入数字的文本字段和一个按钮。当按钮被点击时,用户将导航到下一个屏幕。
-
网格屏幕将显示一个网格,其行数和列数将插入在上方。每一行将独立使用
LazyRow
进行滚动,而对于列,将使用LazyColumn
。每个网格项将显示文本"Item [row][column]"
。
前两个屏幕将使用相同的样式来显示标题、文本字段和按钮,而第三个屏幕将有一个用于显示网格中文本的样式。
要完成此活动,您需要执行以下步骤:
-
使用空 Compose Activity 创建一个新的 Android Studio 项目。
-
将
navigation
库依赖项添加到app/build.gradle
文件中。 -
在
ui.theme
包中,创建一个名为Elements
的新 Kotlin 文件。 -
在该文件中,为应用中使用的标题创建
@Composable
函数。 -
在同一文件中,为应用中使用的文本字段创建
@Composable
函数。 -
在同一文件中,为网格项创建
@Composable
函数。 -
在同一文件中,为按钮创建
@Composable
函数。 -
创建一个名为
InsertRowsScreen
的新 Kotlin 文件。 -
创建
InsertRowsScreenState
、InsertRowsScreenContent
和InsertRowsScreen
,它们将负责保持屏幕状态和屏幕内容,并管理屏幕状态。 -
创建一个名为
InsertColumnsScreen
的新 Kotlin 文件。 -
创建
InsertColumnsScreenState
、InsertColumnsScreenContent
和InsertColumnsScreen
,它们将负责保持屏幕状态和屏幕内容,并管理屏幕状态。 -
创建一个名为
GridScreen
的新 Kotlin 文件。 -
创建
GridScreenState
、GridScreenContent
和GridScreen
,它们将负责保持屏幕状态和屏幕内容,并管理屏幕状态。 -
在
MainActivity
中,创建一个新的函数来设置屏幕之间的导航。 -
在
MainActivity
中,修改setContent
方法块以调用之前创建的函数。
注意
该活动的解决方案可在 packt.link/Le1jE
找到。
摘要
在本章中,我们探讨了如何使用 Jetpack Compose 构建用户界面。我们首先创建简单的用户界面元素,并探讨了如何使用 @Composable
函数构建整个屏幕,而不需要任何 XML 代码。
然后,我们分析了状态管理以及如何处理用户输入,并探讨了诸如状态提升等模式,其中我们尽可能保持函数无状态以提高可重用性。然后,我们探讨了如何定义我们自己的用户界面元素并将主题和样式应用于它们,这使得我们可以在不修改使用更改元素的屏幕的情况下更改整个应用程序的外观。
最后,我们探讨了如何将 Compose 添加到现有项目中,以及 Compose 如何与用于应用开发的流行库交互。在章节活动中,我们应用了所有这些概念,并创建了一个具有一致的用户界面定义的应用程序,其中定义了多个由 Compose 定义的屏幕。
在下一章中,我们将分析如何测试我们的 Android 代码,并查看一些我们可以用来实现这一目标的流行库。
第三部分:测试和代码结构
在这部分,我们将探讨如何构建我们的代码以使其可测试,以及我们可以在代码库中执行的各种测试类型。Android 架构组件将被用于通过将执行可测试任务的代码与与用户界面交互的代码分离来辅助代码结构化,后者更难测试。
然后,我们将探讨我们在设备上保存数据时拥有的可用选项。最后,我们将探讨在依赖注入的帮助下如何管理应用程序内部的依赖关系。
在本节中,我们将涵盖以下章节:
-
第十章, 使用 JUnit、Mockito 和 Espresso 进行单元测试和集成测试
-
第十一章, Android 架构组件
-
第十二章, 持久化数据
-
第十三章, 使用 Dagger、Hilt 和 Koin 进行依赖注入
第十章:使用 JUnit、Mockito 和 Espresso 进行单元测试和集成测试
在本章中,你将了解在 Android 平台上的测试以及如何创建单元测试、集成测试和 UI 测试。你将看到如何创建每种类型的测试,分析它们的运行方式,并使用 JUnit、Mockito、Robolectric 和 Espresso 等框架进行工作。
你还将了解测试驱动开发(TDD),这是一种软件开发实践,它将测试优先于实现。在本章结束时,你将能够结合你的新测试技能来参与一个真实的项目。
在前面的章节中,你学习了如何加载后台数据并在 UI 中显示它,以及如何设置 API 调用以检索数据。但你如何确保一切正常工作?如果你处于必须修复一个你过去很少与之交互的项目中的 bug 的情况怎么办?你如何知道你正在应用修复不会触发另一个 bug?这些问题的答案是测试。
在本章中,我们将分析开发者可以编写的测试类型,并查看可用的测试工具以简化测试体验。首先出现的问题是,桌面或笔记本电脑(它们具有不同的操作系统)用于开发移动应用程序。这意味着测试也必须在设备或模拟器上运行,这将减慢测试速度。
为了解决这个问题,我们提供了两种类型的测试:test
文件夹将在你的机器上运行,而androidTest
文件夹将在设备或模拟器上运行。
在本章中,我们将涵盖以下主题:
-
JUnit
-
Android Studio 测试技巧
-
Mockito
-
集成测试
-
UI 测试
-
TDD
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到:packt.link/pNbuk
测试类型
这两种测试都依赖于 Java JUnit库,它帮助开发者设置他们的测试并将它们分组到不同的类别。它还提供了不同的配置选项,以及其他库可以构建在其上的扩展。我们还将研究测试金字塔,它有助于指导开发者如何构建他们的测试。
我们将从金字塔的底部开始,底部由单元测试表示,然后向上通过集成测试,最终达到顶部,顶部由端到端测试(UI 测试)表示。你将有机会了解辅助编写这些测试类型所需的工具:
-
mockito-kotlin
主要帮助进行单元测试,并且对于创建我们可以操作输入以断言不同场景的模拟或测试双倍对象非常有用。(模拟或测试双倍对象是模仿另一个对象实现的对象。每次测试与模拟交互时,你都可以指定这些交互的行为。) -
Robolectric,这是一个开源库,将 Android 框架带到您的机器上,允许您在本地测试活动片段,而不是在模拟器上。这可以用于单元测试和集成测试。
-
EditText
组件等)以及断言(验证视图是否显示特定文本、是否正在向用户显示、是否启用等)在仪器化测试中的应用程序 UI 上。
在本章中,我们还将探讨TDD。这是一种软件开发流程,其中测试优先。描述它的简单方法是先编写测试。我们将分析在开发 Android 应用程序功能时如何采取这种方法的实例。要记住的一件事是,为了使应用程序得到适当的测试,其类必须得到适当的编写。做到这一点的一种方法是通过明确定义类之间的边界,并根据您希望它们完成的任务来拆分它们。
一旦您实现了这一点,您也可以在编写类时依赖依赖倒置和依赖注入原则。当这些原则得到正确应用时,您应该能够将假对象注入到测试的主题中,并操纵输入以适应您的测试场景。
依赖注入在编写仪器化测试时也有帮助,可以帮助您交换执行网络调用的模块与本地数据,以便使您的测试独立于外部因素,例如网络。仪器化测试是在设备或模拟器上运行的测试。instrument
关键字来自仪器化框架,该框架组装这些测试,然后在设备上执行它们。
理想情况下,每个应用程序都应该有三种类型的测试:
-
单元测试:这些是本地测试,用于验证单个类和方法。它们应该代表您的大部分测试,并且应该快速、易于调试和易于维护。它们也被称为小型测试。
-
集成测试:这些是使用 Robolectric 的本地测试或验证应用程序模块和组件之间交互的仪器化测试。它们比单元测试更慢、更复杂。复杂性的增加是由于组件之间的交互。它们也被称为中型测试。
-
UI 测试(端到端测试):这些是验证完整用户旅程和场景的仪器化测试。这使得它们更复杂,更难维护;它们应该代表您总测试数量中最小的一部分。它们也被称为大型测试。
在以下图中,您可以观察到测试金字塔。谷歌的建议是保持测试的 70:20:10(单元测试:集成测试:UI 测试)的比例:
图 10.1 – 测试金字塔
如前所述,单元测试是验证代码一小部分的测试,并且大多数测试应该是覆盖所有各种场景(成功、错误、限制等)的单元测试。理想情况下,这些测试应该是本地的,但有一些例外,你可以使它们成为可测量的。这些情况很少见,应该限制在你想与设备的具体硬件交互时。
JUnit
JUnit 是一个用于在 Java 和 Android 中编写单元测试的框架。它负责测试的执行方式,允许开发者配置他们的测试。它提供了许多功能,例如以下内容:
-
@Before
和@After
注解。 -
断言:这些用于验证操作的结果与预期值是否一致。
-
规则:这些允许开发者设置多个测试共有的输入。
-
运行器:使用这些,你可以指定测试如何执行。
-
参数:这些允许测试方法使用多个输入执行。
-
排序:这些指定了测试应该执行的顺序。
-
匹配器:这些允许你定义可以用来验证测试主题结果的模式,或者帮助你控制模拟的行为。
在 Android Studio 中,创建新项目时,app
模块在 Gradle 中带有 JUnit 库。这应该在app/build.gradle
中可见:
testImplementation 'junit:junit:4.13.2'
让我们看看我们需要测试的以下类:
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):
-
安排:初始化输入的地方
-
执行:测试方法被调用的地方
-
断言:验证的地方
我们可以再写一个测试来确保值是正确的,但最终我们会重复代码。现在我们可以尝试编写一个参数化测试。为此,我们需要使用参数化测试运行器。前面的测试由 JUnit 提供的内置运行器提供。
参数化运行器将针对我们提供的不同值重复运行测试,并且看起来如下——请注意,为了简洁起见,已经删除了import
语句:
@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 这是一个具有多个参数的测试,并允许我们为测试添加一个构造函数,该构造函数将代表我们的factorial
函数的输入值和输出。然后我们使用@Parameterized.Parameters
注解定义了一个参数集合。
这个测试的每个参数都是一个包含输入和预期输出的单独列表。当 JUnit 运行这个测试时,它将为每个参数运行一个新实例,然后执行测试方法。当我们测试0!
时,这将产生五个成功和一个失败,这意味着我们找到了一个错误。
我们从未考虑到n = 0
的情况。现在,我们可以回到我们的代码中修复失败。我们可以通过用允许我们指定初始值的fold
函数替换不允许我们指定初始值的reduce
函数来实现这一点:
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
类,它可以存储大数。我们可以按如下方式更新测试(省略了import
语句):
@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
循环,并在重构过程中使用测试来引导我们。
我们可以观察到在应用程序中拥有测试的两个好处:
-
它们作为如何实现特性的更新文档。
-
当重构代码时,它们通过保持相同的断言并检测代码的新更改是否破坏了它来引导我们。
让我们更新代码以去除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
}
如果我们执行前面代码的测试,我们会开始看到一些测试会失败。这是因为第一次测试执行factorial
函数后,结果将具有已执行测试的值,当执行新的测试时,阶乘的结果将被乘以前一个结果值。
通常,这会很好,因为测试告诉我们我们在做错事,我们应该纠正它,但在这个例子中,我们将直接在测试中解决这个问题:
@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
来消除重复。我们可以为前面的例子设置一个测试规则。测试规则应在test
或androidTest
包中,因为它们的用途仅限于测试。它们通常在多个测试中使用,因此您可以将您的规则放在一个rules
包中(未显示import
语句):
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
}
}
}
}
}
在前面的例子中,我们可以看到规则将实现TestRule
,它反过来又带有apply()
方法。然后我们创建一个新的Statement
对象,该对象将执行base
语句(即测试本身)并在语句前后重置结果值。现在我们可以按如下方式修改测试:
@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
需要一个公共字段而不是方法。
在本节中,我们学习了如何使用 JUnit 编写测试,通过验证不同参数的结果、错误或行为来验证我们代码的小单元。我们还学习了当它们是测试类的一部分时,每个测试是如何运行的以及操作调用的顺序。在下一节中,我们将探讨如何使用 Android Studio 来了解如何运行测试和查看结果。
Android Studio 测试技巧
Android Studio 自带了一套良好的快捷键和可视化工具,有助于进行测试。如果您想为您的类创建一个新的测试或转到您类中的现有测试,您可以使用Ctrl + Shift + T(Windows)或Command + Shift + T(Mac)快捷键。您需要确保在编辑器中当前聚焦的是您类的内容,以便快捷键生效。
运行测试有多种选项:右键单击文件或包,然后选择 运行 'Tests in…' 选项,或者如果您想独立运行测试,可以进入特定的测试方法,并选择类顶部的绿色图标,这将执行该类中的所有测试。
图 10.2 – 运行测试组
对于单个测试,您可以点击 @Test
注释方法旁边的绿色图标。
图 10.3 – 运行单个测试的图标
这将触发测试执行,将在 运行 选项卡中显示,如下面的截图所示。当测试完成后,它们将根据其成功状态变为红色或绿色:
图 10.4 – Android Studio 中的测试输出
测试中可以找到的另一个重要功能是调试功能。这很重要,因为您既可以调试测试,也可以调试被测试的方法,所以如果您在修复问题时遇到问题,可以使用此功能查看测试使用的输入以及您的代码如何处理输入。在测试旁边的绿色图标旁边,您还可以找到的第三个功能是 带有覆盖率运行 选项。
这有助于开发者识别哪些代码行被测试覆盖,哪些被跳过。覆盖率越高,发现崩溃和错误的机会就越大:
图 10.5 – Android Studio 中的测试覆盖率
在前面的图中,您可以看到我们类的覆盖率分解为受测试的类数量、受测试的方法数量和受测试的行数。
运行 Android 应用测试的另一种方式是通过命令行。这在项目设置了 持续集成 的情况下通常很方便,这意味着每次您将代码上传到云端的代码库时,都会触发一系列脚本来测试它并确保其功能。
由于这是在云端完成的,因此不需要安装 Android Studio。为了简化,我们将使用 Android Studio 中的 终端 选项卡来模拟这种行为。终端 选项卡通常位于 Android Studio 底部栏中,靠近 日志输出 选项卡。
在每个 Android Studio 项目中,都存在一个名为 gradlew
的文件。这是一个可执行文件,允许开发者执行 Gradle 命令。要运行本地单元测试,您可以使用以下命令:
-
gradlew.bat test
(适用于 Windows) -
./gradlew test
(适用于 macOS 和 Linux)
执行该命令后,应用将被构建和测试。您可以在 Android Studio 右侧的 Gradle 选项卡中找到可以在 终端 中输入的各种命令。
如果你看到消息说任务列表尚未构建,请点击它并取消选中在 Gradle 同步期间不构建 Gradle 任务列表,点击确定,然后同步项目的 Gradle 文件。然后任务列表应出现在列表中。
当从app/build/reports
文件夹执行测试时,测试的输出。
图 10.6 – Android Studio 中的 Gradle 命令
在本节中,我们学习了 Android Studio 提供的各种测试选项以及我们如何可视化测试结果。在下一节中,我们将探讨如何在测试中模拟对象以及如何使用 Mockito 来实现这一点。
Mockito
在前面的示例中,我们看到了如何设置单元测试以及如何使用断言验证操作的结果。如果我们想验证某个方法是否被调用呢?或者如果我们想操纵测试输入以测试特定场景呢?在这些情况下,我们可以使用Mockito。
这是一个帮助开发者设置可以注入到测试对象中的虚拟对象并允许他们验证方法调用、设置输入,甚至监控测试对象的库。
应将此库添加到您的test
Gradle 设置中,如下所示:
testImplementation 'org.mockito:mockito-core:4.5.1'
现在,让我们看看以下代码示例(请注意,为了简洁,以下代码片段中已删除import
语句):
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)
}
}
在测试中,我们创建了一个mock
上下文。当测试concatenate
方法时,我们使用 Mockito 在调用getString()
方法并传入特定输入时返回一个特定的字符串。这允许我们随后断言结果。
注意
`
是 Kotlin 中存在的一个转义字符,不应与引号混淆。它允许开发者给方法起任何他们想要的名称,包括特殊字符或保留字。
Mockito 不仅限于模拟 Android 框架类。我们可以创建一个SpecificStringConcatenator
类,它将使用StringConcatenator
将strings.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 发生冲突,使得所有类都成为final,除非我们指定它们为open。
幸运的是,有一个配置我们可以应用,以解决这个困境,而不必使测试中的类成为open:
-
在
test
包中创建一个名为resources
的文件夹。 -
在
resources
中,创建一个名为mockito-extensions
的文件夹。 -
在
mockito-extensions
文件夹中,创建一个名为org.mockito.plugins.MockMaker
的文件。 -
在文件内部,添加以下行:
mock-maker-inline
在你拥有回调或异步工作且不能使用 JUnit 断言的情况下,你可以使用mockito
来验证回调或 lambda 的调用:
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
注解的模拟对象注入变量。接下来,它将创建一个新的带有@InjectMocks
注解的非模拟实例。当这个实例被创建时,Mockito 将尝试注入与该对象构造函数签名匹配的模拟对象。
在本节中,我们探讨了在编写测试时如何模拟对象,以及如何使用 Mockito 来实现这一点。在接下来的章节中,我们将探讨一个更适合与 Kotlin 编程语言一起使用的 Mockito 专用库,即 mockito-kotlin。
你可能已经注意到,在前面的例子中,Mockito 的when
方法已经逃逸了。这是因为与 Kotlin 编程语言的冲突。Mockito 主要是为 Java 构建的,当 Kotlin 被创建时,它引入了this
关键字。像这样的冲突可以通过反引号字符`
来避免。
这,加上一些其他小问题,在使用 Mockito 时造成了一些不便。一些库被引入来包装 Mockito,并提供更好的使用体验。其中之一是mockito-kotlin
。你可以使用以下命令将此库添加到你的模块中:
testImplementation "org.mockito.kotlin:
mockito-kotlin:4.1.0"
这个库带来的一个重大可见变化是用whenever
替换了when
方法。另一个有用的变化是将mock
方法改为依赖于泛型,而不是类对象。其余的语法与 Mockito 语法类似。
我们现在可以使用新库更新之前的测试,从StringConcatenatorTest
开始(为了简洁,已删除import
语句):
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对象的模拟初始化已经简化。我们可以对
SpecificStringConcatenatorTest类(为了简洁,已删除
import` 语句)做同样的事情:
@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)
}
}
在本节中,我们探讨了如何使用 mockito-kotlin
库以及它如何简化 Kotlin 中的 Mockito 函数。在接下来的内容中,我们将进行一项练习,展示如何使用 JUnit 和 Mockito 编写单元测试。
练习 10.01 – 测试数字之和
使用 JUnit、Mockito 和 mockito-kotlin
为以下类编写一组测试,以覆盖以下场景:
-
断言
0
、1
、5
、20
和Int.MAX_VALUE
的值 -
断言负数的输出结果
-
修复代码,并将数字之和替换为公式 n(n+1)/2*
注意
在整个练习过程中,没有显示 import
语句。要查看完整的代码文件,请参阅 packt.link/rv8C2
。
要测试的代码如下:
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()
}
执行以下步骤来完成这个练习:
-
确保将必要的库添加到
app/build.gradle
文件中:testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:4.5.1' testImplementation 'org.mockito.kotlin: mockito-kotlin:4.1.0'
-
创建一个名为
NumberAdder
的类,并将前面的代码复制到其中。 -
将光标移至新创建的类内部,使用 Command + Shift + T 或 Ctrl + Shift + T 创建一个名为
NumberAdderParameterTest
的测试类。 -
在这个类内部创建一个参数化测试,该测试将断言
0
、1
、5
、20
和Int.MAX_VALUE
的输出结果:@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) } }
-
创建一个单独的测试类来处理当存在负数时抛出的异常,命名为
NumberAdderErrorHandlingTest
:@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 + 2 + ...n = n * (n + 1) / 2,我们可以在代码中使用这个公式,这将使方法的执行速度更快:
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] 来运行测试。将出现类似于以下输出的结果,表示测试已通过:
图 10.7 – 练习 10.01 的输出
通过完成这个练习,我们迈出了单元测试的第一步,为单个操作创建了多个测试用例,迈出了理解 Mockito 的第一步,并使用测试来指导我们如何重构代码而不引入任何新问题。
集成测试
假设你的项目已经覆盖了单元测试,其中包含大量的逻辑。现在你必须将这些测试过的类添加到活动或片段中,并要求它们更新你的 UI。你如何确保这些类能够很好地协同工作?这个问题的答案是通过对集成测试。
这种测试类型背后的思想是确保你的应用程序内部的不同组件能够很好地相互集成。以下是一些例子:
-
确保您的 API 相关组件能够很好地解析数据并与您的存储组件良好交互
-
存储组件能够正确存储和检索数据
-
UI 组件加载并显示适当的数据
-
应用程序中不同屏幕之间的转换
为了帮助进行集成测试,有时要求以Given - When - Then
的格式编写。这些通常代表用户故事的验收标准。以下是一个例子:
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 框架中的类进行测试,而不是使用仪器化测试。最近,它得到了谷歌的支持,并已与 AndroidX Jetpack 组件集成。
这个库的主要好处之一是测试活动和片段的简单性。当涉及到集成测试时,这是一个优点,因为我们可以使用这个特性来确保我们的组件能够很好地相互集成。
Robolectric 的一些功能如下:
-
实例化和测试活动和片段生命周期的可能性
-
测试视图膨胀的可能性
-
提供不同 Android API、方向、屏幕尺寸、布局方向等配置的可能性
-
改变
Application
类的可能性,这有助于将模块更改以允许插入数据模拟
要添加 Robolectric 以及 AndroidX 集成,我们需要以下库:
testImplementation 'org.robolectric:robolectric:4.9'
testImplementation 'androidx.test.ext:junit:1.1.4'
第二个库将提供一组用于测试 Android 组件的实用方法和类。
假设我们必须交付一个功能,其中我们显示文本Result x
,其中x
是用户将在EditText
元素中输入的数字的阶乘函数。我们将假设我们将使用一个带有EditText
、TextView
和Button
的活动。当按钮被点击时,我们将在TextView
中显示在EditText
中输入的数字的阶乘结果。
为了实现这一点,我们有两个类,一个用于计算阶乘,另一个将单词Result
与正数的阶乘连接起来,如果数字是负数,它将返回文本Error
。
factorial
类看起来可能如下(在整个示例中,为了简洁起见,已删除import
语句):
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)
textFormatter = 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 来测试我们的活动。通过测试创建组件的活动,我们也可以测试所有三个组件之间的交互。我们可以编写如下所示的测试:
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
private val context =
getApplicationContext<Application>()
@Test
fun `show factorial result in text view`() {
val scenario = launch(MainActivity::class.java)
scenario.moveToState(Lifecycle.State.RESUMED)
scenario.onActivity { activity ->
activity.findViewById<EditText>(R.id.edit_text)
.setText(5.toString())
activity.findViewById<Button>(R.id.button)
.performClick()
assertEquals(
context.getString(R.string.result, "120"),
activity.findViewById<TextView>(
R.id.text_view).text
)
}
}
}
在前面的示例中,我们可以看到 AndroidX 对活动测试的支持。AndroidJUnit4
测试运行器将设置 Robolectric 并创建必要的配置,而 launch
方法将返回一个 scenario
对象,然后我们可以通过它来达到测试所需的条件。我们还可以观察到我们如何使用 `
字符来为我们的函数提供更长的名称,其中可以包括空白字符。
如果我们想要为测试添加配置,我们可以在类和每个测试方法上使用 @Config
注解:
@Config(
sdk = [Build.VERSION_CODES.TIRAMISU],
minSdk = Build.VERSION_CODES.KITKAT,
maxSdk = Build.VERSION_CODES.TIRAMISU,
application = Application::class,
assetDir = "/assetDir/"
)
@RunWith(AndroidJUnit4::class)
class MainActivityTest
我们还可以在 test/resources
文件夹中的 robolectric.properties
文件中指定全局配置,如下所示:
sdk=33
minSdk = 14
maxSdk = 33
最近添加到 Robolectric 中的另一个重要功能是对 Espresso 库的支持。这允许开发者使用 Espresso 的语法与视图交互并对视图进行断言。
另一个可以与 Robolectric 结合使用的库是 FragmentScenario
,它允许测试片段。这些库可以通过以下方式添加到 Gradle 中:
testImplementation 'androidx.fragment:
fragment-testing:1.5.5'
testImplementation 'androidx.test.espresso:
espresso-core:3.5.0'
使用 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
,我们可以提供自定义的 ViewMatchers
。其中一些最常见的是 withId
和 withText
。这两个允许我们根据它们的 R.id.myId
标识符或文本标识符来识别视图。理想情况下,第一个应该用来识别特定的视图。
Espresso 的另一个有趣方面是它依赖于 Hamcrest
库进行匹配器。这是一个旨在改进测试的 Java 库。这允许在必要时组合多个匹配器。假设相同的 ID 出现在您的 UI 的不同视图中。您可以使用以下表达式来缩小对特定视图的搜索:
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
、null
和 notNull
。
ViewActions
与 ViewMatchers
有相似的方法。我们可以在 ViewActions
类中找到共同的实现。常见的包括 typeText
、click
、scrollTo
、clearText
、swipeLeft
、swipeRight
、swipeUp
、swipeDown
、closeSoftKeyboard
、pressBack
、pressKey
、doubleClick
和 longClick
。如果你有自定义视图并且需要某些特定操作,那么你可以通过实现 ViewAction
接口来创建自己的 ViewAction
元素。
与前面的示例类似,ViewAssertions
有自己的类。通常使用 matches
方法,然后你可以使用 ViewMatchers
和 Hamcrest
匹配器来验证结果:
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,这将给我们以下结果(这里没有显示 import
语句):
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@Test
fun `show factorial result in text view`() {
val scenario = launch(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 将数字 5
输入到 EditText
中,然后点击按钮,然后使用 onView()
方法获取视图引用,并通过 perform()
执行操作或使用 check()
进行断言来断言 TextView
中显示的文本。
注意
对于以下练习,你需要一个启用了 USB 调试的模拟器或物理设备。你可以在 Android Studio 中通过选择 工具 | AVD 管理器 来这样做。然后,你可以通过选择模拟器类型,点击 下一步,然后选择一个 x86 图像来创建一个。对于这个练习,任何大于 Lollipop 的图像都将是合适的。接下来,你可以给你的图像命名并点击 完成。
练习 10.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 实现单元测试和集成测试,并将集成测试迁移为仪器测试。
注意
在整个练习过程中,没有显示 import
语句。要查看完整的代码文件,请参考 packt.link/EcmiV
。
实现以下步骤来完成这个练习:
-
让我们从向
app/build.gradle
添加必要的测试库开始:testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:4.5.1' testImplementation 'org.mockito.kotlin:mockito-kotlin:4.1.0' testImplementation 'org.robolectric:robolectric:4.9' testImplementation 'androidx.test.ext:junit:1.1.4' testImplementation 'androidx.test.espresso:espresso-core:3.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.4' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' androidTestImplementation 'androidx.test:rules:1.5.0'
-
对于 Robolectric,我们需要添加额外的配置,首先是向
app/build.gradle
中的android
封闭添加以下行:testOptions.unitTests.includeAndroidResources = true
-
在
test
包中创建一个resources
目录。你需要将 Android Studio 项目视图从 Android 切换到 Project。 -
添加
robolectric.properties
文件,并将以下配置添加到该文件中:sdk=32
-
在
resources
中创建一个名为mockito-extensions
的文件夹。 -
在
mockito-extensions
文件夹中,创建一个名为org.mockito.plugins.MockMaker
的文件,并在文件中添加以下行:mock-maker-inline
-
创建
NumberAdder
类。这与 练习 10.01 中的类似: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() }
-
在
test
文件夹中为NumberAdder
创建测试。首先创建NumberAdderParameterTest
:@RunWith(Parameterized::class) class NumberAdderParameterTest( private val input: Int, private val expected: BigInteger ) { private val numberAdder = NumberAdder() @Test fun sum() { val callback = mock<(BigInteger) -> Unit>() numberAdder.sum(input, callback) verify(callback).invoke(expected) } }
本步骤的完整代码可以在packt.link/ghcTs
找到。
-
然后,创建
NumberAdderErrorHandlingTest
测试:@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) } }
-
在根包的
main
文件夹中,创建一个将格式化总和并将其与必要的字符串连接的类: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)) } } }
-
对此类进行单元测试,以测试成功和错误场景。从成功场景开始:
@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) } }
然后,创建错误场景的测试:
@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)
}
-
在
main/res/values/strings.xml
中添加以下字符串:<string name="the_sum_of_numbers_from_1_to_is"> The sum of numbers from 1 to %1$d is: %2$s</string> <string name="error_invalid_number">Error: Invalid number</string> <string name="calculate">Calculate</string>
-
在
main/res/layout
文件夹中创建activity_main.xml
布局:<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" />
本步骤的完整代码可以在packt.link/hxZ0I
找到。
-
在根包的
main
文件夹中,创建MainActivity
类,它将包含所有其他组件: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 } } } }
-
在
test
目录中创建MainActivity
的测试,它将包含两个测试方法,一个用于成功,一个用于错误:@RunWith(AndroidJUnit4::class) class MainActivityTest { @Test fun `show sum result in text view`() { val scenario = launch(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")))) } } }
本步骤的完整代码可以在packt.link/fZI3u
找到。
如果您通过右键单击包含测试的包并选择运行所有在 [包名],则会显示如下输出:
图 10.8 – 执行 10.02 练习测试文件夹中测试的结果
如果您执行前面的测试,您应该会看到一个类似图 10**.8的输出。Robolectric 测试的执行方式与常规单元测试相同;然而,执行时间有所增加。
-
现在,将前面的测试迁移到仪器化集成测试。为此,我们将从
test
包中复制前面的测试到androidTest
包,并从测试中删除与场景相关的代码。确保在androidTest
文件夹中有一个包含与main/java
文件夹同名包的 Java 文件夹。您需要将测试移动到这个包。 -
在复制文件后,我们将使用
ActivityTestRule
,它将在每个测试执行之前启动我们的活动。我们还需要重命名类以避免重复,并重命名测试方法,因为语法不支持对仪器化测试的支持:@RunWith(AndroidJUnit4::class) class MainActivityUiTest { @Test fun showSumResultInTextView() { val scenario = launch(MainActivity::class.java) scenario.moveToState(Lifecycle.State.RESUMED) 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(getApplicationContext<Application> ().getString(R.string .the_sum_of_numbers_from_1_to_is, 5, "15")) )) } }
本步骤的完整代码可以在packt.link/hNB4A
找到。
如果您通过右键单击包含测试的包并选择运行所有在 [包名],则会显示如下输出:
图 10.9 – 执行 10.02 练习 androidTest 文件夹中测试的结果
在图 10.9中,我们可以看到 Android Studio 显示的结果输出。如果在测试执行时注意模拟器,你可以看到对于每个测试,你的活动将被打开,输入将被设置在字段中,按钮将被点击。
我们的两个集成测试(在工作站和模拟器上)都试图匹配需求的接受标准。集成测试验证相同的行为,唯一的区别是其中一个在本地检查,而另一个在 Android 设备或模拟器上检查。这里的主要好处是 Espresso 能够弥合它们之间的差距,使得集成测试更容易设置和执行。
在本节中,我们实现了一个练习,其中我们使用 Robolectric 库和 Espresso 库编写了测试,并探讨了如何将我们的 Robolectric 测试从test
文件夹迁移到androidTest
文件夹。在接下来的部分,我们将探讨如何通过在物理设备或模拟器上运行的 instrumented 测试来扩展现有的测试套件。
UI 测试
UI 测试是 instrumented 测试,开发者可以模拟用户旅程并验证应用程序不同模块之间的交互。它们也被称为端到端测试。对于小型应用程序,你可以有一个测试套件,但对于大型应用程序,你应该将测试套件拆分以覆盖用户旅程(登录、创建账户、设置流程等)。
因为它们是在设备上执行的,所以你需要将它们写入androidTest
包中,这意味着它们将使用Instrumentation框架运行。Instrumentation 的工作原理如下:
-
应用程序在设备上构建和安装
-
还将在设备上安装一个测试应用程序来监控你的应用程序
-
测试应用程序将在你的应用程序上执行测试并记录结果
这其中的一个缺点是测试将共享持久数据,所以如果一个测试在设备上存储了数据,那么第二个测试就可以访问这些数据,这意味着存在失败的风险。另一个缺点是如果测试遇到崩溃,这将停止整个测试,因为被测试的应用程序已经停止。
这些问题在 Jetpack 更新中通过引入orchestrator框架得到了解决。Orchestrators 允许你在每个测试执行后清除数据,从而免除开发者进行任何调整的需要。Orchestrator 由另一个应用程序表示,该应用程序将管理测试应用程序如何协调测试和数据。
为了将其添加到你的项目中,你需要在app/build.gradle
文件中有一个类似的配置:
android {
...
defaultConfig {
...
testInstrumentationRunner
"androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments
clearPackageData: 'true'
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
}
dependencies {
...
androidTestUtil 'androidx.test:orchestrator:1.4.2'
}
你可以使用 Gradle 的connectedCheck
命令在连接的设备上执行 orchestrator 测试,无论是从终端还是从 Gradle 命令列表中。
在配置中,您会注意到以下行:testInstrumentationRunner
。这允许我们为测试创建一个自定义配置,这给了我们向模块注入模拟数据的机会:
testInstrumentationRunner "com.android.CustomTestRunner"
CustomTestRunner
看起来像这样(以下代码片段中未显示 import
语句):
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)
}
}
测试类本身可以通过使用 androidx.test.ext.junit.runners.AndroidJUnit4
测试运行器的帮助,应用 JUnit4 语法来编写:
@RunWith(AndroidJUnit4::class)
class MainActivityUiTest {
}
@Test
方法本身在专用的测试线程中运行,这就是为什么像 Espresso 这样的库很有帮助。Espresso 将自动将每个与 UI 上的视图交互的操作移动到 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.5.0'
在添加库之后,您需要使用 Intents
类的 init
方法设置必要的意图监控,并且要停止监控,可以使用同一类的 release
方法。这些操作可以在您的测试类的 @Before
和 @After
注解方法中完成。
为了断言意图的值,你需要触发适当的行为,然后使用 intended
方法:
onView(withId(R.id.button)).perform(click())
intended(allOf(hasComponent(hasShortClassName
(".MainActivity")), hasExtra(MainActivity
.MY_EXTRA, "myExtraValue")))
intended
方法与 onView
方法的工作方式类似。它需要一个可以与 Hamcrest
匹配器结合的匹配器。与意图相关的匹配器可以在 IntentMatchers
类中找到。这个类包含了一系列断言 Intent
类不同方法的方法:extras、data、components、bundles 等。
另一个重要的扩展库也帮助了 RecyclerView
。Espresso 的 onData
方法只能测试 AdapterViews
,例如 ListView
,而不能断言 RecyclerView
。为了使用这个扩展,你需要将以下库添加到你的项目中:
androidTestImplementation
'com.android.support.test.espresso:espresso-contrib:3.5.0'
这个库提供了一个 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))
这将滚动到列表中的第 10 个项目:
onView(withText("myText")).check(matches(isDisplayed()))
前面的代码将检查是否显示了带有 myText
文本的视图,这同样适用于 RecyclerView
项目。
在 Jetpack Compose 中的测试
Jetpack Compose 提供了使用与 Espresso 类似的方法来测试 @Composable
函数。如果我们使用 Robolectric,我们可以在 test
文件夹中编写我们的测试代码;如果不使用,我们可以使用 androidTest
文件夹,并且我们的测试将被视为仪器化测试。测试库如下:
androidTestImplementation "androidx.compose.ui:
ui-test-junit4:1.1.1"
如果我们还想测试设置 @Composable
函数作为内容的 Activity
,那么我们还需要添加以下库:
debugImplementation "androidx.compose.ui:
ui-test-manifest:1.1.1"
为了进行测试,我们需要使用一个提供用于与 @Composable
元素交互和对其执行断言的方法集的测试规则。我们可以通过以下方法获得该规则:
class MyTest {
@get:Rule
var composeTestRuleForActivity =
createAndroidComposeRule(MyActivity::class.java)
@get:Rule
var composeTestRuleForNoActivity = createComposeRule()
@Test
fun testNoActivityFunction(){
composeTestRuleForNoActivity.setContent {
// Set method you want to test here
}
}
}
在前面的代码片段中,我们有两条测试规则。第一条,composeTestRuleForActivity
,将启动包含我们想要测试的 @Composable
函数的 Activity
,并将包含我们想要断言的所有节点。
第二个,composeTestRuleForNoActivity
,提供了将我们想要测试的函数设置为内容的权限。这样,规则就可以访问所有的 @Composable
元素。
如果我们想要从我们的函数中识别元素,我们有以下方法:
composeTestRule.onNodeWithText("My text")
composeTestRule.onNodeWithContentDescription(
"My content description")
composeTestRule.onNodeWithTag("My test tag")
在前面的代码片段中,我们有 onNodeWithText
方法,它将使用用户可见的文本标签来识别特定的 UI 元素。onNodeWithContentDescription
方法将使用设置的内容描述来识别元素,而 onNodeWithTag
将使用测试标签来识别元素,该标签是通过 Modifier.testTag
方法设置的。
与 Espresso 类似,一旦我们确定了想要与之交互或对其执行断言的元素,我们就有类似的方法来处理这两种情况。对于与元素交互,我们有以下方法:
composeTestRule.onNodeWithText("My text")
.performClick()
.performScrollTo()
.performTextInput("My new text")
.performGesture {
}
在前面的代码片段中,我们对元素执行了点击、滚动、文本插入和手势操作。对于断言,以下是一些示例:
composeTestRule.onNodeWithText("My text")
.assertIsDisplayed()
.assertIsNotDisplayed()
.assertIsEnabled()
.assertIsNotEnabled()
.assertIsSelected()
.assertIsNotSelected()
在前面的示例中,我们断言元素是显示的、未显示的、启用的、未启用的、选中的或未选中的。
如果我们的用户界面有多个具有相同文本的元素,我们可以使用以下方法提取所有这些元素:
composeTestRule.onAllNodesWithText("My text")
composeTestRule.onAllNodesWithContentDescription(
"My content description")
composeTestRule.onAllNodesWithTag("My test tag")
在这里,我们提取所有具有My text
作为文本、My content description
作为内容描述和My test tag
作为测试标签的节点。返回的是一个集合,允许我们单独断言集合中的每个元素,如下所示:
composeTestRule.onAllNodesWithText("My text")[0]
.assertIsDisplayed()
在这里,我们断言第一个具有My text
文本的元素是显示的。我们还可以对集合执行断言,如下所示:
composeTestRule.onAllNodesWithText("My text")
.assertCountEquals(3)
.assertAll(SemanticsMatcher.expectValue(
SemanticsProperties.Selected, true))
.assertAny(SemanticsMatcher.expectValue(
SemanticsProperties.Selected, true))
在这里,我们断言具有My text
作为文本集的元素数量为三个,断言所有元素是否匹配SemanticsMatcher
,或断言任何元素是否匹配SemanticMatcher
。在这种情况下,它将断言所有元素都是选中的,并且至少有一个元素被选中。
在测试 Jetpack Compose 时,与 Espresso 相似的一个相似之处是IdlingResource
的使用。Compose 提供自己的IdlingResource
抽象,它独立于 Espresso,可以注册到我们的测试规则中,如下所示:
@Before
fun setUp() {
composeTestRule.registerIdlingResource(
idlingResource)
}
@After
fun tearDown() {
composeTestRule.unregisterIdlingResource(
idlingResource)
}
在前面的代码片段中,我们在@Before
注解的方法中注册IdlingResource
,并在@After
方法中注销它。
练习 10.03 – 随机等待时间
编写一个应用程序,它将有两个屏幕。第一个屏幕将有一个按钮。当用户按下按钮时,它将在 1 到 5 秒之间等待随机的时间,然后启动第二个屏幕,该屏幕将显示文本在 x 秒后打开,其中x是经过的秒数。编写一个 UI 测试,以涵盖此场景,并调整以下功能以进行测试:
-
当测试运行时,
random
函数将返回1
的值 -
CountingIdlingResource
将用于指示计时器何时停止
注意
在整个练习中,没有显示import
语句。要查看完整的代码文件,请参阅packt.link/GG32r
。
按以下步骤完成此练习:
-
创建一个新的没有 Activity 的 Android Studio 项目。
-
将以下库添加到
app/build.gradle
:implementation 'androidx.test.espresso: espresso-idling-resource:3.5.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test:rules:1.5.0'
-
在根包的
main
文件夹中创建一个类;从Randomizer
类开始:open class Randomizer(private val random: Random) { open fun getTimeToWait(): Int { return random.nextInt(5) + 1 } }
-
在根包的
main
文件夹中创建一个类;创建一个Synchronizer
类,该类将使用Randomizer
和Timer
来等待随机的时长间隔。它还将使用CountingIdlingResource
来标记任务的开始和结束: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() } } }
-
现在,创建一个
Application
类,它将负责创建前面所有类的实例:class MyApplication : Application() { val countingIdlingResource = CountingIdlingResource("Timer resource") val randomizer = Randomizer(Random()) val synchronizer = Synchronizer(randomizer, Timer(), countingIdlingResource) }
-
将
MyApplication
类添加到AndroidManifest
中的application
标签,并使用android:name
属性。 -
创建一个
activity_1
布局文件,它将包含一个父布局和一个按钮:<?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>
-
创建一个
activity_2
布局文件,它将包含一个父布局和一个TextView
:<?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>
-
创建
Activity1
类,它将实现按钮点击的逻辑: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)) } } } }
-
创建
Activity2
类,它将通过 intent 显示接收到的数据: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)) } }
-
确保将相关字符串添加到
strings.xml
中:<string name="press_me">Press Me</string> <string name="opened_after_x_seconds">Opened after %d seconds</string>
-
确保将两个活动添加到
AndroidManifest.xml
中:<application … > <activity android:name=".Activity1" android:exported="true"> </application>
这个步骤的完整代码可以在packt.link/TkEX9
找到。
-
在
androidTest
目录中创建一个FlowTest
类,它将注册来自MyApplication
对象的IdlingResource
并断言点击的结果:@RunWith(AndroidJUnit4::class) @LargeTest class FlowTest { @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)))) } }
这个步骤的完整代码可以在packt.link/711Vw
找到。
-
多次运行测试并检查测试结果。注意,测试有 20%的成功率,但它会等待
Activity1
中的按钮被点击。这意味着 idling 资源正在工作。另一个需要注意的事情是这里存在随机性元素。 -
测试不喜欢随机性,因此我们需要通过使
Randomizer
类对外开放并在androidTest
目录中创建一个子类来消除它。我们也可以对MyApplication
类做同样的事情,并提供一个名为TestRandomizer
的不同随机器:class TestRandomizer(random: Random) : Randomizer(random) { override fun getTimeToWait(): Int { return 1 } }
-
现在,以可以覆盖子类中的随机器的方式修改
MyApplication
类: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()) }
-
在
androidTest
目录下创建TestMyApplication
,它将扩展MyApplication
并重写createRandomizer
方法:class TestMyApplication : MyApplication() { override fun createRandomizer(): Randomizer { return TestRandomizer(Random()) } }
-
最后,在根包的
androidTest/java
文件夹中创建一个 instrumentation 测试运行器,它将在测试中使用这个新的Application
类: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) } }
-
将新的测试运行器添加到 Gradle 配置中:
android { ... defaultConfig { ... testInstrumentationRunner "com.android.testable.myapplication .MyApplicationTestRunner" } }
如果我们现在运行测试,测试应该通过;然而,我们在依赖项上遇到了一些问题。对于Randomizer
类,我们不得不使我们的类对外开放,以便在androidTest
文件夹中扩展它。
另一个问题是我们应用程序代码中包含了对测试库中 idling 资源的引用。为了解决这两个问题,我们需要为Randomizer
和Synchronizer
类定义抽象。
-
在根包的
main/java
文件夹中创建一个名为Randomizer
的接口:interface Randomizer { fun getTimeToWait(): Int }
-
将之前的
Randomizer
类重命名为RandomizerImpl
并按照以下方式实现Randomizer
接口:class RandomizerImpl(private val random: Random) : Randomizer { override fun getTimeToWait(): Int { return random.nextInt(5) + 1 } }
-
在
MyApplication
中修改createRandomizer
方法,使其返回类型为Randomizer
,这将返回一个RandomizerImpl
实例:open class MyApplication : Application() { … open fun createRandomizer() : Randomizer = RandomizerImpl(Random()) }
-
修改
TestRandomizer
以实现Randomizer
接口:class TestRandomizer : Randomizer { override fun getTimeToWait(): Int { return 1 } }
-
修改
TestMyApplication
以纠正编译错误:class TestMyApplication : MyApplication() { override fun createRandomizer(): Randomizer { return TestRandomizer() } }
-
在
app/build.gradle
中,将 idling 资源依赖项设置为androidTestImplementation
:androidTestImplementation 'androidx.test.espresso: espresso-idling-resource:3.5.1'
-
在根包的
main/java
文件夹中创建一个名为Synchronizer
的接口:interface Synchronizer { fun executeAfterDelay(callback: (Int) -> Unit) }
-
将之前的
Synchronizer
类重命名为SynchronizerImpl
,实现Synchronizer
接口,并删除对CountingIdlingResource
的使用:class SynchronizerImpl( private val randomizer: Randomizer, private val timer: Timer ) : Synchronizer { override fun executeAfterDelay(callback: (Int) -> Unit) { val timeToWait = randomizer.getTimeToWait() 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) } } }
-
修改
MyApplication
以使其能够从TestMyApplication
类中提供不同的Synchronizer
实例:open class MyApplication : Application() { lateinit var synchronizer: Synchronizer override fun onCreate() { super.onCreate() synchronizer = createSynchronizer() } open fun createRandomizer(): Randomizer = RandomizerImpl(Random()) open fun createSynchronizer(): Synchronizer = SynchronizerImpl(createRandomizer(), Timer()) }
-
在
androidTest
文件夹中,创建一个名为TestSynchronizer
的类,它将包装一个Synchronizer
,然后使用CountingIdlingResource
在executeAfterDelay
开始和结束时增加和减少计数器:class TestSynchronizer( private val synchronizer: Synchronizer, private val countingIdlingResource: CountingIdlingResource ) : Synchronizer { override fun executeAfterDelay(callback: (Int) -> Unit) { countingIdlingResource.increment() synchronizer.executeAfterDelay { callback(it) countingIdlingResource.decrement() } } }
在前面的示例中,我们有一个对 Synchronizer
实例的引用。当调用 executeAfterDelay
时,我们通知 Espresso 等待。然后我们调用实际的 Synchronizer
实例,当它完成执行后,我们通知 Espresso 继续执行。
-
修改
TestMyApplication
以提供TestSynchronizer
的实例:class TestMyApplication : MyApplication() { val countingIdlingResource = CountingIdlingResource("Timer resource") override fun createRandomizer(): Randomizer { return TestRandomizer() } override fun createSynchronizer(): Synchronizer { return TestSynchronizer(super.createSynchronizer(), countingIdlingResource) } }
在前面的代码片段中,我们创建了一个新的 TestSynchronizer
,它包装了在 MyApplication
中定义的 Synchronizer
并添加了 CountingIdlingResource
。
-
在
FlowTest
中,将MyApplication
的引用更改为TestMyApplication
:private val myApplication = getApplicationContext<TestMyApplication>()
当现在运行测试时,一切应该通过,如图 图 10.10 所示:
图 10.10 – 练习 10.03 的输出
这种类型的练习展示了如何避免测试中的随机性,并提供具体且可重复的输入以使我们的测试可靠。在依赖注入框架中,也采取了类似的方法,可以在测试套件中替换整个模块以确保测试的可靠性。
最常见需要替换的是 API 通信。这种方法解决的另一个问题是减少等待时间。如果这种类型的场景在测试中重复出现,那么它们的执行时间会因为这一点而增加。
在这个练习中,我们探讨了如何编写仪器化测试并在模拟器或物理设备上执行它们。我们还分析了如何使用 CountingIdlingResources
装饰我们的对象以监控异步操作,以及如何切换导致不稳定的依赖并提供存根数据。
TDD
假设你被分配了一个任务来构建一个显示带有加、减、乘、除选项的计算器的活动。你还必须为你的实现编写测试。通常,你会构建你的 UI 和活动以及一个单独的 Calculator
类。然后,你会为你的 Calculator
类编写单元测试,然后为你的 activity
类编写测试。
如果你将 TDD 流程转换为在 Android 应用中实现功能,你将需要首先编写你的 UI 测试用例。为了实现这一点,你可以创建一个骨架 UI 以避免编译时错误。在完成 UI 测试后,你需要编写你的 Calculator
测试。在这里,你还需要在 Calculator
类中创建必要的函数以避免编译时错误。
如果你在这个阶段运行了测试,它们将失败。这将迫使你实现代码直到测试通过。一旦你的Calculator
测试通过,你就可以将计算器连接到你的 UI,直到你的 UI 测试通过。虽然这看起来像是一种反直觉的方法,但一旦掌握了这个过程,它就能解决两个问题:
-
由于你需要确保你的代码是可测试的,并且只需编写通过测试所需的最少代码,因此编写代码所需的时间会更少
-
由于开发者将能够分析不同的结果,因此引入的 bug 会更少
看看以下图,它显示了 TDD 周期:
图 10.11 – TDD 周期
在前面的图中,我们可以看到 TDD 过程中的开发周期。你应该从一个测试失败的点开始。实现更改以通过测试。当你更新或添加新功能时,你可以重复此过程。
回到我们的阶乘示例,我们从一个没有涵盖所有场景的factorial
函数开始,每次添加新测试时都必须更新该函数。TDD 正是基于这个想法构建的。你从一个空函数开始。你开始定义你的测试场景:成功的条件是什么?最小值是多少?最大值是多少?是否有任何违反主要规则的例外情况?它们是什么?这些问题可以帮助开发者定义他们的测试用例。然后,这些案例可以编写。现在让我们通过下一个练习看看如何实际操作。
练习 10.04 – 使用 TDD 计算数字之和
编写一个函数,该函数以整数n
作为输入,并返回从1
到n
的数字之和。该函数应使用 TDD 方法编写,并满足以下标准:
-
对于
n<=0
,函数将返回值-1
-
函数应该能够返回
Int.MAX_VALUE
的正确值 -
函数应该快速,即使是对于
Int.MAX_VALUE
执行以下步骤以完成此练习:
-
创建一个新的没有活动的 Android Studio 项目
-
确保将以下库添加到
app/build.gradle
中:testImplementation 'junit:junit:4.13.2'
-
在根包的
main/java
文件夹中,创建一个Adder
类,并带有返回0
的sum
方法,以满足编译器:class Adder { fun sum(n: Int): Int = 0 }
-
在
test
目录下创建一个AdderTest
类并定义我们的测试用例。我们将有以下测试用例:n=1
,n=2
,n=0
,n=-1
,n=10
,n=20
,以及n=Int.MAX_VALUE
。我们可以将成功的场景拆分到一个方法中,而将不成功的场景拆分到另一个方法中: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)) } }
如果我们运行AdderTest
类的测试,我们将看到以下图所示的输出,这意味着所有测试都失败了:
图 10.12 – 练习 10.04 的初始测试状态
-
让我们先通过在
1
到n
的循环中实现求和来处理成功场景:class Adder { fun sum(n: Int): Long { var result = 0L for (i in 1..n) { result += i } return result } }
如果我们现在运行测试,你会看到其中一个会通过,另一个会失败,就像以下图所示:
图 10.13 – 解决 10.04 练习的成功场景后的测试状态
-
如果我们看看执行成功测试所需的时间,似乎有点长。当在一个项目中存在成千上万的单元测试时,这可能会累积起来。现在,我们可以通过应用 n(n+1)/2 公式来优化我们的代码以处理这个问题:
class Adder { fun sum(n: Int): Long { return (n * (n.toLong() + 1)) / 2 } }
现在运行测试将大大减少执行时间到几毫秒。
-
现在,让我们专注于解决我们的失败场景。我们可以通过添加一个当
n
小于或等于0
的条件来完成这个任务:class Adder { fun sum(n: Int): Long { return if (n > 0) (n * (n.toLong() + 1)) / 2 else -1 } }
如果我们现在运行测试,我们应该看到它们都通过,就像以下图所示:
图 10.14 – 10.04 练习的通过测试
在这个练习中,我们将 TDD 的概念应用于一个非常小的示例,以展示该技术如何被使用。我们观察到,从骨架代码开始,我们可以创建一系列测试来验证我们的条件,并且通过不断运行测试,我们改进了代码,直到所有测试都通过。正如你可能已经注意到的,这个概念并不是直观的。一些开发者发现很难定义骨架代码应该有多大才能开始创建测试用例,而另一些开发者,出于习惯,首先编写代码然后再开发测试。在任何情况下,开发者都需要大量练习这项技术,直到熟练掌握。
10.01 活动活动 – 使用 TDD 进行开发
使用 TDD 方法,开发一个包含三个活动并按以下方式工作的应用程序:
-
在活动 1 中,你将显示一个数字
EditText
元素和一个按钮。当按钮被点击时,EditText
中的数字将被传递到活动 2。 -
活动二将异步生成一个项目列表。项目的数量将由活动 1 传递的数字表示。你可以使用具有 1 秒延迟的
Timer
类。列表中的每个项目将显示文本Item x
,其中x
是列表中的位置。当点击一个项目时,你应该将点击的项目传递到活动 3。 -
活动三将显示文本
您点击了 y
,其中y
是用户点击的项目文本。
应用程序的测试如下:
-
使用 Mockito 和
mockito-kotlin
注解的@SmallTest
的单元测试 -
使用 Robolectric 和 Espresso 注解的
@MediumTest
的集成测试 -
使用
Robot
模式并注解为@LargeTest
的 Espresso 的 UI 测试
从命令行运行测试命令。为了完成这个活动,你需要采取以下步骤:
-
你需要 Android Studio 4.1.1 或更高版本,以及 Kotlin 1.4.21 或更高版本,用于 Parcelize Kotlin 插件。
-
创建三个活动以及每个活动的 UI。
-
在
androidTest
文件夹中,为每个活动创建三个机器人:-
机器人 1 将包含与
EditText
和按钮的交互 -
机器人 2 将断言屏幕上的项目数量并与列表中的项目进行交互
-
机器人 3 将断言在
TextView
中显示的文本
-
-
创建一个具有一个使用前面机器人的测试方法的仪器化测试类。
-
创建一个
Application
类,该类将包含所有将进行单元测试的类的实例。 -
创建三个类来表示集成测试,每个活动一个类。这些类中的每一个将包含一个用于交互和数据加载的测试方法。每个集成测试都将断言活动之间传递的意图。
-
创建一个类,该类将提供所需的 UI 文本。它将引用一个
Context
对象,并包含两个方法,这些方法将提供 UI 的文本,并返回一个空字符串。 -
为前面的类创建测试,以测试这两个方法。
-
实现前面的类,以便测试通过。
-
创建一个类,该类将负责在
Activity2
中加载列表,并提供一个用于加载的空方法。该类将引用计时器和空闲资源。在这里,你还应该创建一个数据类,该类将代表RecyclerView
的模型。 -
为前面的类创建一个单元测试。
-
为前面的类创建实现并运行单元测试,直到它们通过。
-
在
Application
类中,实例化已进行单元测试的类,并在您的活动中开始使用它们。这样做,直到您的集成测试通过。 -
提供
IntegrationTestApplication
,这将返回负责加载的新实现类。这是为了避免使您的Activity2
集成测试等待加载完成。 -
提供
UiTestApplication
,这将再次减少您模型的加载时间,并将空闲资源连接到 Espresso。实现剩余的 UI 测试,以便通过。
注意
该活动的解决方案可以在packt.link/Ma4tD
找到。
摘要
在本章中,我们探讨了不同的测试类型和实现这些测试的框架。我们还探讨了测试环境,以及如何为每个环境构建它,以及如何将代码结构化成多个组件,这些组件可以单独进行单元测试。
我们分析了不同的测试代码的方法,我们应该如何进行测试,以及通过查看不同的测试结果,我们可以如何改进我们的代码。通过 TDD,我们了解到,通过从测试开始,我们可以更快地编写代码,并确保它更少出错。
活动是所有这些概念汇集在一起构建简单 Android 应用程序的地方,我们可以观察到,通过添加测试,开发时间会增加,但长期来看,这通过消除代码修改时可能出现的潜在错误而得到回报。
我们研究过的框架是最常见的其中一些,但还有其他一些建立在它们之上,并被开发者在他们的项目中使用,例如 Mockk(一个为 Kotlin 设计的模拟库,利用了语言的大量功能)和 Barista(基于 Espresso 编写,简化了 UI 测试的语法),仅举几个例子。
将这里提出的所有概念视为构建块,它们适合软件工程世界中存在的两个过程:自动化和持续集成。自动化将冗余和重复的工作从开发者的手中移走,并将其交给机器。
而不是让一个质量保证团队测试你的应用程序以确保满足要求,你可以通过各种测试和测试案例指导机器来测试应用程序,并且只需要一个人来审查测试结果。
持续集成建立在自动化的概念之上,以便在你将代码提交给其他开发者进行审查时立即验证你的代码。具有持续集成功能的项目将按照以下方式进行设置:开发者将工作提交到源代码控制仓库,例如 GitHub。
然后,云中的机器将开始执行整个项目的测试,确保没有出现任何问题,开发者可以继续新的任务。如果测试通过,那么其他开发者可以审查代码,当代码正确时,它可以被合并,并在云中创建一个新的构建版本,然后分发到整个团队和测试人员。
所有这些都在初始开发者安全地从事其他工作的同时进行。如果在过程中有任何失败,他们可以暂停新任务,并去解决他们工作中出现的问题。然后可以将持续集成过程扩展到持续交付,当准备提交到 Google Play 时,可以设置类似的自动化,几乎可以由机器完全处理,开发者的参与度很小。
在接下来的章节中,你将了解如何在构建更复杂的应用程序时组织代码,这些应用程序利用设备的存储功能并连接到云以请求数据。这些组件中的每一个都可以单独进行单元测试,并且你可以应用集成测试来断言多个组件的成功集成。
第十一章:Android 架构组件
在本章中,你将了解 Android Jetpack 库的关键组件以及它们为标准 Android 框架带来的好处。你还将学习如何借助 Jetpack 组件来组织你的代码,并为你的类分配不同的职责。最后,你将提高你代码的测试覆盖率。
到本章结束时,你将能够轻松地创建处理活动和片段生命周期的应用程序。你还将了解更多关于如何在 Android 设备上使用 Room 持久化数据以及如何使用 ViewModels 将你的逻辑与视图分离。
在前面的章节中,你学习了如何编写单元测试。问题是:你可以对什么进行单元测试?你能否对活动和片段进行单元测试?由于它们构建的方式,在你的机器上对活动和片段进行单元测试是困难的。如果你能将代码从活动和片段中移除,测试将会更容易。
此外,考虑一下你正在构建一个支持不同方向(如横屏和竖屏)和多种语言的应用程序的情况。在这些场景中,默认情况下,当用户旋转屏幕时,活动和片段会为新的显示方向重新创建。
现在,假设这种情况发生在你的应用程序正在处理数据的过程中。你必须跟踪你正在处理的数据,跟踪用户如何与你的屏幕进行交互,并避免造成上下文泄露。
注意
当你的已销毁活动因为被具有更长生命周期的组件(例如当前正在处理你的数据的线程)引用而无法被垃圾回收时,就会发生上下文泄露。
在本章中,我们将涵盖以下主题:
-
ViewModel
-
数据流
-
Room
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,链接为 packt.link/89BCi
Android 组件背景
在许多情况下,你必须使用 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 库的一部分。架构组件旨在帮助开发者构建代码结构,编写可测试的组件,并帮助减少样板代码。
其他架构组件包括 Databinding
(它将视图与模型或 ViewModels
绑定,允许直接在视图中设置数据),WorkManager
(它允许开发者轻松处理后台工作),Navigation
(它允许开发者创建视觉导航图并指定活动与片段之间的关系),以及 Paging
(它允许开发者加载分页数据,这在需要无限滚动的情况下很有帮助)。
ViewModel
ViewModel
组件负责保存和处理用户界面(UI)所需的数据。它有一个好处,即它能够在片段和活动被销毁和重新创建的配置更改中存活下来,这使得它能够保留数据,然后可以用来重新填充 UI。
当活动或片段被销毁而没有被重新创建,或者当应用程序进程被终止时,它最终会被销毁。这允许ViewModel
履行其职责,并在不再需要时进行垃圾回收。ViewModel
唯一的方法是onCleared()
方法,当ViewModel
终止时会被调用。你可以重写这个方法来终止正在进行的任务和释放不再需要的资源。
将数据处理从活动迁移到ViewModel
有助于创建更好、更快的单元测试。测试活动需要在一个设备上执行 Android 测试。活动也有状态,这意味着你的测试应该将活动置于适当的状态,以便断言能够工作。ViewModel
可以在你的开发机器上本地进行单元测试,并且可以是无状态的,这意味着你可以单独测试数据处理逻辑。
ViewModel
最重要的特性之一是它允许片段之间的通信。要在没有ViewModel
的情况下在片段之间进行通信,你必须让你的片段与活动通信,然后活动会调用你想要通信的片段。
要使用ViewModel
实现这一点,你只需将其附加到父活动,并在你想要通信的片段中使用相同的ViewModel
。这将减少之前所需的样板代码。
在下面的图中,你可以看到ViewModel
可以在活动生命周期的任何时刻被创建(在实践中,它们通常在onCreate
中初始化,对于活动而言,以及onCreateView
或onViewCreated
对于片段而言,因为这些代表了视图被创建并准备好更新的点),一旦创建,它将和活动一样长时间存在:
图 11.1 – 活动生命周期与 ViewModel 生命周期的比较
在前面的图中,我们可以看到Activity
的生命周期与ViewModel
的生命周期是如何比较的。红色线条表示当Activity
被重新创建时发生的情况,从onPause
方法开始,以onDestroy
结束,然后在新实例的Activity
中从onCreate
到onResume
。
下面的图显示了ViewModel
如何连接到一个片段:
图 11.2 – Fragment 生命周期与 ViewModel 生命周期的比较
在前面的图中,我们可以看到Fragment
的生命周期与ViewModel
的生命周期是如何比较的。红色线条表示当Fragment
被重新创建时发生的情况,从onPause
方法开始,以onDetach
结束,然后在新实例的Fragment
中从onAttach
到onResume
。
在本节中,我们学习了什么是 ViewModel 以及它提供的与测试和执行逻辑相关的优势,这些逻辑可以在活动或片段重建后继续存在。
练习 11.01 – 共享 ViewModel
你被分配了一个任务,要构建一个应用,当在纵向模式时屏幕垂直分割成两部分,在横向模式时水平分割。第一部分包含一些文本,下面是一个按钮。
第二部分只包含文本。当屏幕打开时,两部分的文本都显示 Total: 0。当点击按钮时,文本将变为 Total: 1。再次点击,文本将变为 Total: 2,依此类推。当设备旋转时,将显示最后总和的新方向。
为了解决这个任务,我们将定义以下内容:
-
一个将包含两个片段的活动 - 一个用于纵向,另一个用于横向:
-
一个包含
TextView
和按钮的一个布局的片段: -
一个包含
TextView
的一个布局的片段: -
一个将在两个片段之间共享的
ViewModel
:
让我们先设置我们的配置:
-
在 Android Studio 中创建一个新的项目,并添加一个名为
SplitActivity
的空活动。 -
让我们将
ViewModel
库添加到app/build.gradle
:implementation "androidx.lifecycle: lifecycle-viewmodel-ktx:2.5.1"
-
将以下字符串添加到
values/strings.xml
:<string name="press_me">Press Me</string> <string name="total">Total %d</string>
-
创建并定义
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) } }
-
将
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>
-
现在,让我们创建并定义
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) } }
-
将
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>
-
定义
SplitActivity
:class SplitActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_split) } }
-
在
res/layout
文件夹中创建activity_split.xml
文件:<?xml version="1.0" encoding="utf-8"?> <LinearLayout> <androidx.fragment.app.FragmentContainerView android:id="@+id/activity_fragment_split_1" android:name="{package.path}.SplitFragmentOne" android:layout_width="match_parent" /> <androidx.fragment.app.FragmentContainerView android:id="@+id/activity_fragment_split_2" android:name="{package.path}.SplitFragmentTwo" android:layout_width="match_parent" /> </LinearLayout>
本步骤的完整代码可以在 packt.link/HPy9p
找到。
将 {package.path}
替换为你片段所在的包名。
-
接下来,让我们在
res
文件夹中创建一个layout-land
文件夹。然后,在layout-land
文件夹中,我们将创建一个具有以下布局的activity_split.xml
文件:<?xml version="1.0" encoding="utf-8"?> <LinearLayout> <androidx.fragment.app.FragmentContainerView android:id="@+id/activity_fragment_split_1" android:name="{package.path}.SplitFragmentOne" /> <androidx.fragment.app.FragmentContainerView android:id="@+id/activity_fragment_split_2" android:name="{package.path}.SplitFragmentTwo" /> </LinearLayout>
本步骤的完整代码可以在 packt.link/1zRQa
找到。
将 {package.path}
替换为你片段所在的包名。注意两个 activity_split.xml
文件中的相同 android:id
属性。这允许操作系统在旋转期间正确保存和恢复片段的状态。
-
在根包的
main/java
文件夹中,创建一个类似于以下的TotalsViewModel
:class TotalsViewModel : ViewModel() { var total = 0 fun increaseTotal(): Int { total++ return total } }
注意我们扩展了 ViewModel
类,它是生命周期库的一部分。在 ViewModel
类中,我们定义了一个方法,该方法增加总值并返回更新后的值。
-
现在,将
updateText
和prepareViewModel
方法添加到SplitFragment1
片段中:class SplitFragmentOne : Fragment() { … override fun onViewCreated(view: View, savedInstanceState: Bundle?) { … 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) } }
-
在
prepareViewModel()
函数中,让我们开始添加我们的ViewModel
:private fun prepareViewModel() { val totalsViewModel = ViewModelProvider(this) .get(TotalsViewModel::class.java) }
这是如何访问 ViewModel
实例的方式。ViewModelProvider(this)
将 TotalsViewModel
绑定到片段的生命周期。.get(TotalsViewModel::class.java)
将检索我们之前定义的 TotalsViewModel
实例。
如果片段是第一次被创建,它将产生一个新的实例,而如果片段在旋转后重新创建,它将提供之前创建的实例。我们传递类作为参数,因为片段或活动可以有多个 ViewModel
,而这个类作为我们想要 ViewModel
类型的标识符。
-
现在,在视图中设置最后已知值:
private fun prepareViewModel() { val totalsViewModel = ViewModelProvider(this) .get(TotalsViewModel::class.java) updateText(totalsViewModel.total) }
第二行将在设备旋转时有所帮助。它将设置最后计算的总数。如果我们删除这一行并重新构建,那么我们将看到 1
。
-
当点击
fragment_split_one_button
按钮时更新视图:private fun prepareViewModel() { val totalsViewModel = ViewModelProvider(this) .get(TotalsViewModel::class.java) updateText(totalsViewModel.total) view?.findViewById<Button> (R.id.fragment_split_one_button) ?.setOnClickListener { updateText(totalsViewModel.increaseTotal()) } }
最后几行表明,当按钮被点击时,我们告诉 ViewModel
重新计算总数并设置新值。
-
将我们之前使用的相同
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) updateText(totalsViewModel.total) } private fun updateText(total: Int) { view?.findViewById<TextView> ( R.id. fragment_split_two_text_view)?.text = getString(R.string.total, total) } }
如果我们现在运行应用程序,我们将看到没有任何变化。第一个片段仍然按预期工作,但第二个片段没有收到任何更新。这是因为尽管我们定义了一个 ViewModel
,但我们为每个片段都有两个该 ViewModel
的实例。
我们需要将实例数量限制为每个片段一个。我们可以通过使用名为 requireActivity
的方法将我们的 ViewModel
附着到 SplitActivity
生命周期来实现这一点。
-
让我们修改我们的片段。在两个片段中,我们需要找到并更改以下代码:
val totalsViewModel = ViewModelProvider(this).get(TotalsViewModel::class.java)
我们将把它改为以下内容:
val totalsViewModel = ViewModelProvider(requireActivity()) .get(TotalsViewModel::class.java)
注意
使用 ViewModel
在片段之间进行通信仅在片段放置在同一个活动时才会工作。
如果我们运行应用程序,我们应该看到以下内容:
图 11.3 – 练习 11.01 的输出
当按钮被点击时,总更新显示在屏幕的上半部分,但不在下半部分。如果我们旋转屏幕,ViewModel
最后的值也会设置在第二个屏幕上。这意味着我们的应用程序没有正确地响应 ViewModel
的变化。
这意味着我们需要一个发布者-订阅者方法来监控我们数据中发生的变化。在下一节中,我们将查看一些 ViewModel
可以用来通知数据变化的常见数据流。
在这个练习中,我们实现了一个 ViewModel
,它负责增加一个将在屏幕上显示的整数值。在接下来的部分,我们将连接数据流以在数值增加时做出反应。
数据流
在数据可观察性方面,我们有多种实现方法,无论是手动构建的机制、Java 语言的组件、第三方组件,还是最终为 Android 开发的特定解决方案。在 Android 方面,一些最常用的解决方案是 LiveData
、协程组件中的 Flows 以及 RxJava。
我们首先将探讨的是 LiveData
,因为它属于 Android 架构组件的一部分,这意味着它是专门针对 Android 定制的。然后我们将探讨如何使用其他类型的数据流,这些内容将在未来的章节中更深入地介绍。
LiveData
LiveData
是一个生命周期感知组件,允许更新您的 UI,但仅当 UI 处于活动状态时(例如,如果活动或片段处于 STARTED
或 RESUMED
状态之一)。要监控 LiveData
的变化,您需要一个与 LifecycleOwner
结合的观察者。当活动设置为活动状态时,当发生变化时,观察者将被通知。
如果活动被重新创建,那么观察者将被销毁,并将重新附加一个新的观察者。一旦发生这种情况,LiveData
的最后一个值将被发出,以便我们可以恢复状态。活动和片段是 LifecycleOwner
,但片段有一个单独的 LifecycleOwner
用于视图状态。片段由于在 BackStack
中的行为而具有这个特定的 LifecycleOwner
。
当在回退栈中替换片段时,它们不会被完全销毁;只有它们的视图会被销毁。开发者常用的触发处理逻辑的一些常见回调包括 onViewCreated()
、onActivityResumed()
和 onCreateView()
。如果我们在这类方法中注册 LiveData
的观察者,我们可能会遇到每次我们的片段返回屏幕时都会创建多个观察者的情况。
当更新 LiveData
模型时,我们面临两种选择:setValue()
和 postValue()
。setValue()
会立即传递结果,并且只应在 UI 线程上调用。另一方面,postValue()
可以在任何线程上调用。当调用 postValue()
时,LiveData
将在 UI 线程空闲时安排更新值。
在 LiveData
类中,这些方法是受保护的,这意味着存在允许我们更改数据的子类。MutableLiveData
使这些方法公开,这为我们提供了在大多数情况下观察数据的一个简单解决方案。MediatorLiveData
是 LiveData
的一个特殊实现,它允许我们将多个 LiveData
对象合并为一个(这在我们的数据保存在不同的存储库中,我们希望显示一个组合结果的情况下非常有用)。
TransformLiveData
是另一种专用实现,它允许我们将一个对象转换成另一个对象(这在我们从某个存储库获取数据并希望从依赖于先前数据的另一个存储库请求数据的情况下很有帮助,以及在我们想要对存储库的结果应用额外逻辑的情况下)。
注意事项
在ViewModel
中使用LiveData
是一种常见做法。当发生配置更改时,在片段或活动中持有LiveData
会导致数据丢失。
以下图表展示了 LiveData
如何与 LifecycleOwner
的生命周期相连接:
图 11.4 – LiveData 与 LifecycleOwner 之间的关系
注意事项
我们可以为 LiveData
注册多个观察者,并且每个观察者可以注册到不同的 LifecycleOwner
。在这种情况下,LiveData
将变为非活动状态,但仅当所有观察者都处于非活动状态时。
在本节中,我们探讨了LiveData
组件的工作原理以及它为观察活动(activities)和片段(fragments)的生命周期提供的好处。在下一节中,我们将探讨一个使用LiveData
的练习。
练习 11.02 – 使用 LiveData 进行观察
修改 练习 11.01 – 共享 ViewModel,以便当按钮被点击时,两个片段都将更新为点击的总数。
执行以下步骤以实现此功能:
-
将
LiveData
库添加到app/build.gradle
文件中:implementation "androidx.lifecycle: lifecycle-livedata-ktx:2.5.1"
-
TotalsViewModel
应该被修改以支持LiveData
:class TotalsViewModel : ViewModel() { private val _total = MutableLiveData<Int>() val total: LiveData<Int> = _total init { _total.postValue(0) } fun increaseTotal() { _total.postValue((_total.value ?: 0) + 1) } }
在这里,我们创建了MutableLiveData
,它是LiveData
的一个子类,允许我们更改数据的值。当创建ViewModel
时,我们将其默认值设置为0
,然后当我们增加总数时,我们发布前一个值加1
。
我们对总和进行重复表示的原因是我们希望将可变的部分保留为类的私有属性,同时将不可变的总和暴露给其他对象进行观察。
-
现在,我们需要修改我们的片段,以便它们适应新的
ViewModel
。对于SplitFragmentOne
,我们进行以下操作:override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val totalsViewModel = ViewModelProvider(requireActivity()) .get(TotalsViewModel::class.java) totalsViewModel.total.observe( viewLifecycleOwner, { updateText(it) }) view.findViewById<Button>( R.id.fragment_split_one_button) .setOnClickListener { totalsViewModel.increaseTotal() } }
-
对于
SplitFragmentTwo
,我们执行以下操作:override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val totalsViewModel = ViewModelProvider(requireActivity()) .get(TotalsViewModel::class.java) totalsViewModel.total.observe( viewLifecycleOwner, { updateText(it) }) }
如果我们查看以下行:totalsViewModel.getTotal().observe(view **LifecycleOwner**, { updateText(it)})
,observe
方法的 LifecycleOwner
参数被命名为 viewLifecycleOwner
。这是从 fragment
类继承而来的,当我们观察数据而片段管理的视图正在渲染时,这很有帮助。在我们的例子中,将 viewLifecycleOwner
与 this
交换并不会造成影响。
但如果我们的片段是回退栈功能的一部分,那么就有创建多个观察者的风险,这会导致对于相同的数据集被多次通知。
-
现在,让我们为我们的新
ViewModel
编写一个测试。我们将命名为TotalsViewModelTest
并将其放置在test
包中,而不是androidTest
。这是因为我们希望这个测试在我们的工作站上执行,而不是在设备上:class TotalsViewModelTest { private lateinit var totalsViewModel: TotalsViewModel @Before fun setUp() { totalsViewModel = TotalsViewModel() assertEquals(0, totalsViewModel.total.value) } @Test fun increaseTotal() { val total = 5 for (i in 0 until total) { totalsViewModel.increaseTotal() } assertEquals(4, totalsViewModel.total.value) } }
-
在前面的测试中,在测试开始之前,我们断言
LiveData
的初始值被设置为0
。然后,我们编写一个小测试,其中我们将总数增加五次,并断言最终值为5
。让我们运行测试并看看会发生什么:java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked.
-
将会显示与前面类似的消息。这是因为
LiveData
的实现方式。内部,它使用 Handlers 和 Loopers,这是 Android 框架的一部分,从而阻止我们执行测试。幸运的是,有一个解决办法。我们需要在我们的测试的 Gradle 文件中添加以下配置:testImplementation "androidx.arch.core: core-testing:2.1.0"
-
这将向我们的测试代码中添加一个测试库,而不是应用代码。现在,让我们在我们的代码中
ViewModel
类的实例化之上添加以下行:class TotalsViewModelTest { @get:Rule val rule = InstantTaskExecutorRule() private val totalsViewModel = TotalsViewModel()
我们添加了一个TestRule
,它表示每次LiveData
的值发生变化时,它将立即进行更改,并避免使用 Android 框架组件。
在这个类中我们编写的每个测试都将受到这个规则的影响,从而给我们提供了在每种新的测试方法中玩转LiveData
类的自由。如果我们再次运行测试,我们会看到以下内容:
java.lang.RuntimeException: Method getMainLooper in
android.os.Looper not mocked
-
这是否意味着我们的新规则不起作用?并不完全是这样。如果你查看你的
TotalsViewModels
类,你会看到以下内容:init { total.postValue(0) }
-
这意味着由于我们在规则的作用域之外创建了
ViewModel
类,因此规则不会适用。我们可以做两件事来避免这种情况:我们可以更改我们的代码以处理我们首次订阅LiveData
类时发送的空值,或者我们可以调整我们的测试,以便将ViewModel
类放在规则的作用域内。让我们选择第二种方法,并更改我们在测试中创建ViewModel
类的方式。它应该看起来像这样:@get:Rule val rule = InstantTaskExecutorRule() private lateinit var totalsViewModel: TotalsViewModel @Before fun setUp() { totalsViewModel = TotalsViewModel() assertEquals(0, totalsViewModel.total.value) }
-
让我们再次运行测试并看看会发生什么:
java.lang.AssertionError: Expected :4 Actual :5
看看你是否能找到测试中的错误,修复它,然后重新运行:
图 11.5 – Exercise 11.02 的输出
横屏模式下的相同输出将如下所示:
图 11.6 – 横屏模式下 Exercise 11.02 的输出
通过查看前面的示例,我们可以看到使用LiveData
和ViewModel
方法的组合是如何帮助我们解决我们的问题,同时考虑到 Android 操作系统的特定性:
-
ViewModel
:这帮助我们跨设备方向变化保持数据,并解决了片段间通信的问题 -
LiveData
:这有助于我们在考虑片段的生命周期时检索我们处理的最最新信息。 -
这两种方法的结合使我们能够有效地委托我们的处理逻辑,从而允许我们对这种处理逻辑进行单元测试。
其他数据流
最近流行起来的一种数据流类型是协程和流的用法,主要因为它们在 Android 中的异步操作方法。以下是一个在 ViewModel
中发出数据的流的示例:
class TotalsViewModel : ViewModel() {
private val _total = MutableStateFlow(0)
val total: StateFlow<Int> = _total
fun increaseTotal() {
_total.value = _total.value + 1
}
}
在前面的代码片段中,我们有公共和私有使用的两个总声明。我们不是使用 LiveData
,而是使用 StateFlow
,它将在我们订阅时发出当前值和所有后续的新值。因为它会发出最后一个值,所以我们必须在初始化时始终设置一个初始值。如果我们想订阅总值的更改,我们可以使用以下方法:
val totalsViewModel =
ViewModelProvider(requireActivity())
.get(TotalsViewModel::class.java)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
totalsViewModel.total.collect {
updateText(it)
}
}
}
在前面的代码片段中,每次 viewLifecycleOwner
进入 CREATED
阶段时,都会订阅 StateFlow
。这将 StateFlow
与 Fragment
的生命周期连接起来,以防止任何可能的泄漏。我们将在未来的章节中探讨流和协程的机制。
另一个数据流的例子是 RxJava 库,它代表了另一种发出数据的方式。该库最适合执行异步工作和转换,因为它基于 Java 而不是 Android 操作系统,所以它缺乏任何生命周期感知。例如,将 RxJava 与 ViewModels 结合使用看起来如下所示:
class TotalsViewModel : ViewModel() {
private val _total = BehaviorSubject.createDefault(0)
val total: Observable<Int> = _total
fun increaseTotal() {
_total.onNext(_total.blockingLast())
}
}
在这里,我们使用 BehaviorSubject
来替换 StateFlow
。BehaviorSubject
与状态流具有相同的属性。它将保留最新的值,并在组件订阅时以及订阅后的所有新值时发出。订阅对象看起来如下所示:
private var disposable: Disposable? = null
override fun onViewCreated(view: View,
savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val totalsViewModel =
ViewModelProvider(requireActivity())
.get(TotalsViewModel::class.java)
disposable = totalsViewModel.total.subscribe {
updateText(it)
}
}
override fun onDestroyView() {
disposable?.dispose()
super.onDestroyView()
}
在这里,我们在 onViewCreated
中使用 Disposable 来保持订阅。在 onDestroyView
中,我们销毁订阅以防止任何上下文泄漏。这是使用如 LiveData
和 StateFlow
这样的生命周期感知组件的替代方案。
在本节中,我们探讨了 Android 应用程序中可能存在的其他类型的数据流,如 Kotlin 流和 RxJava,并分析了它们的特性。在下一节中,我们将探讨如何使用 Room 库持久化数据。
Room
房间持久化库在您的应用程序代码和 SQLite 存储之间充当包装器。您可以将 SQLite 视为一个无需自己的服务器即可运行的数据库,并将所有应用程序数据保存到仅对应用程序可访问的内部文件中(如果设备未越狱)。
Room 位于应用程序代码和 SQLite Android 框架之间,处理必要的创建、读取、更新和删除(CRUD)操作,同时提供了一个抽象,应用程序可以使用它来定义数据和如何处理数据。这种抽象以以下对象的形式出现:
-
实体(Entities):你可以指定你想要如何存储数据以及数据之间的关系
-
数据访问对象(Data access object,DAO):可以在你的数据上执行的操作
-
数据库:你可以指定数据库应具有的配置(数据库的名称和迁移场景)
这些可以在以下图中看到:
图 11.7 – 应用程序与 Room 组件之间的关系
在前面的图中,我们可以看到 Room 组件之间的交互方式。通过一个例子来可视化这一点会更简单。假设你想制作一个消息应用,并将每条消息存储在本地存储中。在这种情况下,Entity
将是一个Message
对象,它将有一个 ID,并包含消息的内容、发送者、时间、状态等。
为了从本地存储中访问消息,你需要MessageDao
,它将包含insertMessage()
、getMessagesFromUser()
、deleteMessage()
和updateMessage()
等方法。此外,由于这是一个消息应用,你还需要一个Contact
实体来存储消息的发送者和接收者的信息。
Contact
实体将包含诸如姓名、最后在线时间、电话号码、电子邮件等信息。为了访问联系信息,你需要一个ContactDao
接口,它将包含createUser()
、updateUser()
、deleteUser()
和getAllUsers()
。这两个实体将在 SQLite 中创建一个匹配的表,该表包含我们在实体类内部定义的字段作为列。为了实现这一点,我们需要创建MessagingDatabase
,在其中我们将引用这两个实体。
在没有 Room 或类似 DAO 库的世界中,我们需要使用 Android 框架的 SQLite 组件。这通常涉及到在设置数据库时编写代码,例如创建表的查询,以及为每个我们将拥有的表应用类似的查询。每次我们查询表以获取数据时,我们都需要将结果对象转换为 Java 或 Kotlin 对象。
默认情况下,Room 不允许在 UI 线程上进行任何操作,以强制执行与输入输出操作相关的 Android 标准。为了异步调用以访问数据,Room 与许多库和框架兼容,例如 Kotlin 协程、RxJava 和LiveData
,在其默认定义之上。
现在我们应该对 Room 的工作原理及其主要组件有一个概述。接下来,我们将逐一查看这些组件以及我们如何使用它们进行数据持久化。
实体
实体有两个用途:定义表的结构并存储表行中的数据。让我们以我们的消息应用场景为例,定义两个实体:一个用于用户,一个用于消息。
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
)
这段代码向消息表添加了三个列(lat
、long
和 location_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
如果实体指定了如何定义和保存我们的数据,那么 DAO 指定了如何处理这些数据。DAO 类是一个定义我们的 CRUD 操作的地方。理想情况下,每个实体都应该有一个相应的 DAO,但在某些情况下会发生交叉(通常,这发生在我们必须处理两个表之间的 JOIN 时)。
继续我们之前的例子,让我们为我们的实体构建一些相应的 DAO:
@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
类的实例。理想情况下,由于构建新数据库对象涉及到的复杂性,我们的应用程序应该只有一个数据库实例。然而,这可以通过依赖注入框架更好地实现。
假设你已经发布了你的聊天应用程序。你的数据库目前是版本一,但你的用户抱怨消息状态功能缺失。你决定在下一个版本中添加这个功能。这涉及到更改数据库结构,这可能会影响已经构建了结构的数据库。
幸运的是,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
之间添加了迁移。在这里,我们向表中添加了status
列。当我们构建数据库时,我们将添加这个迁移。
一旦我们发布了新代码,当更新后的应用程序打开并执行构建数据库的代码时,它将比较存储数据的版本与我们类中指定的版本,并发现差异。然后,它将执行指定的迁移,直到达到最新版本。这使我们能够在多年内维护应用程序,而不会影响用户体验。
如果你看看我们的Message
类,你可能已经注意到我们定义了时间戳为Long
。在 Java 和 Kotlin 中,我们有Date
对象,这可能比消息的时间戳更有用。幸运的是,Room 提供了一个名为TypeConverter
的解决方案。
以下表格显示了我们在代码中可以使用的数据类型以及 SQLite 的等效类型。复杂的数据类型需要通过 TypeConverters 降级到这些级别:
图 11.8 – 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 和协程(coroutines)配合良好。这解决了两个问题:多线程和观察数据变化。
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
方法通过 Publisher
、Observable
或 Flowable
等组件实现响应式,并通过 Completable
、Single
或 Maybe
使其他方法异步:
@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()
}
以下示例将在每次我们想要检索用户列表时创建一个新的线程并启动它。这段代码有两个主要问题:
-
线程创建是一个昂贵的操作
-
代码难以测试
第一个问题的解决方案是使用 ThreadPools
和 Executors
。Java 框架在提供 ThreadPools
方面提供了一套强大的选项。线程池是一个负责线程创建和销毁的组件,允许开发者指定池中的线程数。线程池中的多个线程将确保多个任务可以并发执行。
我们可以将前面的代码重写如下:
private val executor:Executor =
Executors.newSingleThreadExecutor()
fun getUsers(usersCallback:(List<User>)->Unit){
executor.execute {
usersCallback.invoke(userDao.loadUsers())
}
}
在前面的示例中,我们定义了一个将使用一个线程池的执行器。当我们想要访问用户列表时,我们将查询移动到执行器内部,当数据加载完成后,我们的回调 lambda 将被调用。
练习 11.03 – 留出一些空间
你被一家新闻机构雇佣来构建一个新闻应用程序。该应用程序将显示由记者撰写的文章列表。一篇文章可以由一位或多位记者撰写,每位记者可以撰写一篇或多篇文章。每篇文章的数据信息包括文章的标题、内容和日期。
记者的信息包括他们的名字、姓氏和职位。你需要构建一个包含这些信息的 Room 数据库,以便进行测试。在我们开始之前,让我们看看实体之间的关系。在聊天应用程序的示例中,我们定义了一个规则:一个用户可以发送一条或多条消息。
这种关系被称为一对多关系。该关系通过一个实体到另一个实体(用户在消息表中定义,以便与发送者连接)的引用来实现。
在这种情况下,我们有一个多对多关系。为了实现多对多关系,我们需要创建一个包含引用的实体,这些引用将链接其他两个实体。让我们开始吧:
-
创建一个不带活动的新的 Android 项目。
-
让我们先向
app/build.gradle
添加注解处理插件。这将读取 Room 使用的注解并生成与数据库交互所需的代码:plugins { … id 'kotlin-kapt' }
-
接下来,让我们在
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 注解生成样板代码。在修改了你的 Gradle 文件之后,你应该会收到一个提示来同步你的项目,你应该点击它。
-
让我们在
main/java
文件夹和根包中定义我们的实体:@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 )
-
现在,在
main/java
文件夹和根包中定义连接记者和文章的实体以及适当的约束:@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,但文章和记者将一起使用时将是唯一的。我们还为我们的实体引用的每个其他实体定义了外键。
-
在
main/java
文件夹和根包中创建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> }
-
现在,在
main/java
文件夹和根包中创建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> }
-
在
main/java
文件夹和根包中创建JoinedArticleJournalistDao
DAO:@Dao interface JoinedArticleJournalistDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertArticleJournalist( joinedArticleJournalist: JoinedArticleJournalist ) @Delete Fun deleteArticleJournalist( joinedArticleJournalist: JoinedArticleJournalist ) }
让我们稍微分析一下我们的代码。对于文章和记者,我们可以添加、插入、删除和更新查询。对于文章,我们可以提取所有文章,也可以从特定作者那里提取文章。
我们还有提取所有撰写文章的记者的选项。这是通过与我们中间实体的 JOIN 来完成的。对于该实体,我们定义了插入(这将链接一篇文章到一个记者)和删除(这将移除该链接)的选项。
-
最后,让我们在
main/java
文件夹和根包中定义我们的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
文件夹中创建它。
-
让我们先设置测试数据。在这里,我们将向数据库添加一些文章和记者,然后测试检索、更新和删除条目:
@RunWith(AndroidJUnit4::class) class NewsDatabaseTest { @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) } }
此步骤的完整代码可以在 packt.link/6H8X2
找到。
在这里,我们定义了一些如何测试 Room 数据库的示例。有趣的是我们如何构建数据库。我们的数据库是一个内存数据库。这意味着所有数据都会在测试运行期间保留,并在之后丢弃。
这允许我们为每个新状态从零开始,避免了每个测试会话的后果相互影响。在我们的测试中,我们设置了 5 篇文章和 10 名记者。第一篇文章是由前两名记者撰写的,而第二篇文章是由第一名记者撰写的。
其余的文章没有作者。通过这样做,我们可以测试我们的更新和删除方法。对于删除方法,我们还可以测试我们的外键关系。在测试中,我们可以看到如果我们删除文章 1
,它将删除文章与撰写它的记者之间的关系。
在测试你的数据库时,你应该添加你的应用程序将使用的场景。请随意添加其他测试场景并改进你自己的数据库中的先前测试。请注意,如果你正在使用 androidTest
文件夹,那么这将是一个仪器化测试,这意味着你需要一个模拟器或设备来测试。
活动 11.01 – 购物笔记应用
你想跟踪你的购物项目,因此你决定构建一个应用程序来保存你下次去商店时希望购买的项目。这些要求如下:
-
UI 将分为两部分:纵向模式下的顶部/底部和横向模式下的左侧/右侧。UI 的外观将与以下截图所示类似。
-
第一部分将显示笔记数量、文本字段和按钮。每次按下按钮时,都会在文本字段中添加一个笔记。
-
第二部分将显示笔记列表。
-
对于每一半,你将有一个视图模型来保存相关数据。
-
你应该定义一个存储库,该存储库将用于在 Room 数据库之上访问你的数据。
-
你还应该定义一个 Room 数据库来保存你的笔记。
-
笔记实体将具有以下属性:
id
和text
:
图 11.9 – 活动 11.01 的可能输出示例
执行以下步骤以完成此活动:
-
从创建
Entity
、Dao
和Database
方法开始 Room 集成。对于Dao
,使用@Query
注解的方法可以直接返回LiveData
对象,这样观察者就可以在数据更改时直接收到通知。 -
以接口的形式定义我们存储库的模板。
-
实现存储库。存储库将包含一个对我们之前定义的
Dao
对象的引用。插入数据的代码必须移动到单独的线程中。 -
创建
NotesApplication
类以提供将在整个应用程序中使用的仓库的一个实例。确保更新AndroidManifest.xml
文件中的<application>
标签以添加你的新应用程序类。 -
单元测试仓库并定义
ViewModels
,如下所示:-
定义
NoteListViewModel
和相关的测试。这将有一个对仓库的引用并返回笔记列表。 -
定义
CountNotesViewModel
和相关的测试。CountViewModel
将有一个对仓库的引用并返回笔记的总数作为LiveData
。它还将负责插入新的笔记。 -
定义
CountNotesFragment
和相关的fragment_count_notes.xml
布局。在布局中,定义一个TextView
,用于显示总数,一个用于新笔记名称的EditText
,以及一个按钮,该按钮将插入在EditText
中引入的笔记。 -
定义一个名为
NoteListAdapter
的笔记列表适配器以及相关的布局文件view_note_item.xml
。 -
定义相关的布局文件,称为
fragment_note_list.xml
,其中将包含RecyclerView
。该布局将由NoteListFragment
使用,它将连接NoteListAdapter
到RecyclerView
。它还将观察来自NoteListViewModel
的数据并更新适配器。 -
定义具有相关布局的
NotesActivity
,包括横屏模式和竖屏模式。
-
-
确保你已经在
strings.xml
中有了所有必要的数据。
注意
该活动的解决方案可以在 packt.link/ZhnDx
找到。
摘要
在本章中,我们分析了构建可维护应用程序所需的基本组件。我们还探讨了开发者在使用 Android 框架时遇到的最常见问题之一,即在生命周期变化期间维护对象的状态。
我们首先分析了 ViewModels
以及它们如何解决在方向变化时保持数据的问题。我们向 ViewModels
添加了 LiveData
来展示这两个是如何相互补充的,并探讨了如何使用其他数据流与 ViewModels
一起使用,并将这些与 LiveData
进行比较。
我们接着转向 Room,展示如何以最小的努力和几乎没有 SQLite 模板代码的情况下持久化数据。我们还探讨了一对一和多对多关系,以及如何迁移数据和将复杂对象分解为原始数据以进行存储。
本章中我们完成的活动是 Android 应用发展方向的一个示例。然而,这并不是一个完整的示例,因为你会发现许多框架和库,这些框架和库为开发者提供了灵活性,可以走向不同的方向。
本章中你学到的信息将有助于下一章,下一章将扩展仓库的概念。这将允许你将来自服务器的数据保存到 Room 数据库中。
随着你探索其他持久化数据的方式,例如通过SharedPreferences
、DataStore
和文件,持久化数据的概念也将得到扩展。我们的重点将放在某些类型的文件上:从设备相机获取的媒体文件。
第十二章:持久化数据
本章深入探讨了 Android 中的数据持久化。到本章结束时,你将了解多种在设备上直接存储(持久化)数据的方法以及可用于此目的的框架。在处理文件系统时,你将了解其分区方式以及如何在不同的位置读取和写入文件,以及如何使用不同的框架。
在上一章中,你学习了如何构建代码和保存数据。在活动中,你还有机会构建一个仓库并使用它通过 Room 访问和保存数据。在本章中,你将了解通过 Android 文件系统在设备上持久化数据的替代方法以及它的结构如何分为外部和内部内存。
你还将加深对读写权限的理解,学习如何创建FileProvider
类以允许其他应用访问你的文件,以及如何在不需要请求外部驱动器权限的情况下保存这些文件。你还将了解如何从互联网下载文件并将它们保存到文件系统中。
本章还将探讨另一个概念,即使用相机应用代表你的应用拍照和录制视频,并使用文件提供者将它们保存到外部存储。
本章将涵盖以下主题:
-
预设和 DataStore
-
文件
-
作用域存储
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到:packt.link/XlTwZ
预设和 DataStore
想象一下,你被分配了一个任务,需要集成一个使用 OAuth 之类的机制来实现 Facebook、Google 等登录的第三方 API。这些机制的工作方式如下——它们会给你一个令牌,你必须将其存储在本地,然后可以使用该令牌发送其他请求以访问用户数据。
这引发了一些问题。你该如何存储那个令牌?你是否只为一个令牌使用 Room?你是否将令牌保存在单独的文件中并实现写入文件的方法?如果那个文件需要同时从多个地方访问怎么办?SharedPreferences
和DataStore
是这些问题的答案。SharedPreferences
是一种允许你将布尔值、整数、浮点数、长整型、字符串和字符串集合保存到 XML 文件的功能。
当你想保存新值时,你指定要为相关键保存哪些值,完成后,你提交更改,这将异步触发将更改保存到 XML 文件。SharedPreferences
映射也保存在内存中,这样当你想要读取这些值时,它将瞬间完成,从而消除了读取 XML 文件的异步调用需求。
我们现在有两种方式以键值对的形式存储数据,即SharedPreferences
和DataStore
。现在我们将探讨每个的工作原理以及它们各自提供的优势。
SharedPreferences
访问 SharedPreference
对象的方式是通过 Context
对象:
val prefs = getSharedPreferences("my-prefs-file",
Context.MODE_PRIVATE)
第一个参数是您指定首选项名称的地方,第二个参数是您希望如何将文件暴露给其他应用程序。目前,最佳模式是私有模式。其他所有模式都存在潜在的安全风险。
如果您想将数据写入首选项文件,您首先需要获取对首选项编辑器的访问权限。编辑器将为您提供写入数据的权限。然后您可以在其中写入数据。一旦完成写入,您将必须应用更改,这将触发对 XML 文件的持久化并更改内存中的值。
您有两个选择来应用对首选项文件的变化 – apply
或 commit
。选择 apply
将立即在内存中保存您的更改,但写入磁盘将是异步的,这在您想从应用程序的主线程保存数据时很有用。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()
现在,您想要清除所有数据。相同的原理适用;您将需要 editor
、clear
和 apply
:
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)
现在我们应该对如何使用 SharedPreferences
持久化数据有一个了解,我们可以在下一节的练习中应用这一点。
练习 12.01 – 包装 SharedPreferences
我们将构建一个应用程序,该程序显示 TextView
、EditText
和一个按钮。TextView
将显示在 SharedPreferences
中之前保存的值。用户可以输入新的文本,当按钮被点击时,文本将被保存到 SharedPreferences
中,并且 TextView
将显示更新后的文本。我们需要使用 ViewModel
和 LiveData
来使代码更易于测试。
为了完成这个练习,我们需要创建一个 Wrapper
类,该类将负责保存文本。这个类将返回文本的值作为 LiveData
。这将注入到我们的 ViewModel
中,该 ViewModel
将绑定到活动:
-
使用 Android Studio 创建一个新的空活动项目。
-
让我们从向
app/build.gradle
添加适当的库开始:implementation "androidx.lifecycle: lifecycle-viewmodel-ktx:2.5.1" implementation "androidx.lifecycle: lifecycle-livedata-ktx:2.5.1"
-
让我们在
main/java
文件夹中的root
包下创建一个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, "")) } } } } }
此步骤的完整代码可以在 packt.link/a2RuN
找到。
注意文件的顶部。我们添加了一个监听器,以便当我们的 SharedPreferences
值发生变化时,我们可以查找新值并更新我们的 LiveData
模型。这将允许我们观察 LiveData
的任何更改并仅更新 UI。
saveText
方法将打开编辑器,设置新值,并应用更改。getText
方法将读取最后保存的值,将其设置在 LiveData
中,并返回 LiveData
对象。这在应用打开并希望在应用关闭之前访问最后值的情况下非常有用。
-
现在,让我们在根包的
main/java
文件夹中设置Application
类的偏好实例:class PreferenceApplication : Application() { lateinit var preferenceWrapper: PreferenceWrapper override fun onCreate() { super.onCreate() preferenceWrapper = PreferenceWrapper(getSharedPreferences( "prefs", Context.MODE_PRIVATE)) } }
-
现在,让我们在
AndroidManifest.xml
的application
标签中添加适当的属性:android:name=".PreferenceApplication"
-
接下来,让我们在根包的
main/java
文件夹中构建ViewModel
组件:class PreferenceViewModel(private val preferenceWrapper: PreferenceWrapper) : ViewModel() { fun saveText(text: String) { preferenceWrapper.saveText(text) } fun getText(): LiveData<String> { return preferenceWrapper.getText() } }
-
现在,让我们在
res/layout
文件夹中定义我们的activity_main.xml
布局文件:<TextView android:id="@+id/activity_main_text_view" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="50dp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <EditText android:id="@+id/activity_main_edit_text" android:layout_width="200dp" android:layout_height="wrap_content" android:inputType="none" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf= "@id/activity_main_text_view" />
此步骤的完整代码可以在 packt.link/2c5Ay
找到。
-
最后,在
MainActivity
中执行以下步骤:class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { 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()) } } }
此步骤的完整代码可以在 packt.link/ZRWNc
找到。
上述代码将产生 图 12.1 中所示的输出:
图 12.1 – 练习 12.01 的输出
一旦你插入了一个值,尝试关闭应用程序并重新打开它。应用程序将显示最后持久化的值。
数据存储
当我们想要通过偏好 DataStore
以键值对的形式存储数据,或者想要通过 Proto DataStore
存储整个对象时,DataStore
持久化库是 SharedPreferences
的一个替代方案。这两个库都避免了与 Android 框架的依赖(与需要初始化 Context
对象的 SharedPreferences
不同),并且使用协程和流构建,因此在你的项目中使用协程和流时,它们是理想的候选者。
这种集成允许 DataStore
通知订阅者所有更改,这意味着开发者不再需要担心处理这些更改:
val Context.dataStore: DataStore<Preferences> by
preferencesDataStore(name = "myDataStore")
val KEY_MY_INT = intPreferencesKey("my_int_key")
val KEY_MY_BOOLEAN =
booleanPreferencesKey("my_boolean_key")
val KEY_MY_STRING = stringPreferencesKey("my_string_key")
class MyAppSettings(private val context: Context) {
val myIntValue: Flow<Int> = context.dataStore.data
.map { preferences ->
preferences[KEY_MY_INT] ?: 0
}
val myBooleanValue: Flow<Boolean> =
context.dataStore.data
.map { preferences ->
preferences[KEY_MY_BOOLEAN] ?: false
}
val myStringValue: Flow<String> =
context.dataStore.data
.map { preferences ->
preferences[KEY_MY_STRING] ?: ""
}
}
在前面的代码片段中,我们在顶层 Kotlin 文件中初始化 Context.dataStore
。然后我们定义了三个不同的键,分别对应我们想要读取的不同类型。在 MyAppSettings
中,我们将 context.dataStore.data
中的值映射到我们的键,并从中提取值。
如果我们想在 DataStore
中存储数据,则需要执行以下操作:
class MyAppSettings(private val context: Context) {
…
suspend fun saveMyIntValue(intValue: Int) {
context.dataStore.edit { preferences ->
preferences[KEY_MY_INT] = intValue
}
}
suspend fun saveMyBooleanValue(booleanValue: Boolean) {
context.dataStore.edit { preferences ->
preferences[KEY_MY_BOOLEAN] = booleanValue
}
}
suspend fun saveMyStringValue(stringValue: String) {
context.dataStore.edit { preferences ->
preferences[KEY_MY_STRING] = stringValue
}
}
}
suspend
关键字来自协程,它表示我们需要将方法调用放入异步调用中。context.dataStore.edit
将 DataStore
中的偏好设置为可变的,并允许我们更改值。
练习 12.02 – 预设数据存储
我们将构建一个显示 TextView
、EditText
和按钮的应用程序。TextView
将显示添加到 DataStore
的值。用户可以输入新的文本,当按钮被点击时,文本将被保存到 DataStore
,并且 TextView
将显示更新的文本。
我们将需要使用 ViewModel
和 LiveData
。在 ViewModel
中,我们将收集来自 DataStore
的数据并将其放置在一个 LiveData
对象中:
-
创建一个带有空活动的新的 Android Studio 项目。
-
让我们从向
app/build.gradle
添加适当的库开始:implementation "androidx.datastore: datastore-preferences:1.0.0" implementation "androidx.lifecycle: lifecycle-viewmodel-ktx:2.5.1" implementation "androidx.lifecycle: lifecycle-livedata-ktx:2.5.1"
-
在根包的
main/java
文件夹中创建一个名为SettingsStore
的新类,该类将包含从DataStore
加载和保存数据的方法:val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settingsStore") val KEY_TEXT = stringPreferencesKey("key_text") class SettingsStore(private val context: Context) { val text: Flow<String> = context.dataStore.data .map { preferences -> preferences[KEY_TEXT] ?: "" } suspend fun saveText(text: String) { context.dataStore.edit { preferences -> preferences[KEY_TEXT] = text } } }
在前面的代码片段中,我们定义了一个用于存储文本的键、用于检索已保存文本的字段以及用于保存它的方法。
-
在根包的
main/java
文件夹中创建一个名为SettingsViewModel
的新类,该类将从SettingsStore
收集数据并将其保存到LiveData
对象中:class SettingsViewModel(private val settingsStore: SettingsStore) : ViewModel() { private val _textLiveData = MutableLiveData<String>() val textLiveData: LiveData<String> = _textLiveData init { viewModelScope.launch { settingsStore.text.collect { _textLiveData.value = it } } } fun saveText(text: String) { viewModelScope.launch { settingsStore.saveText(text) } } }
在前面的示例中,viewModelScope
是 ViewModel
的扩展,代表 CoroutineScope
,这确保了在 ViewModel
仍然活跃时完成后台工作,以避免任何可能的泄漏。使用这个,我们可以在 ViewModel
初始化时收集现有的文本到 LiveData
,然后从 SettingsStore
调用 saveText
方法。
-
现在,让我们在根包的
main/java
文件夹中设置Application
类,并包含SettingsStore
实例:class SettingsApplication : Application() { lateinit var settingsStore: SettingsStore override fun onCreate() { super.onCreate() settingsStore = SettingsStore(this) } }
-
接下来,让我们在
AndroidManifest.xml
的application
标签中添加适当的属性:android:name=".SettingsApplication"
-
最后,让我们在
res/layout
文件夹中定义我们的activity_main.xml
布局文件:<TextView android:id="@+id/activity_main_text_view" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="50dp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <EditText android:id="@+id/activity_main_edit_text" android:layout_width="200dp" android:layout_height="wrap_content" android:inputType="none" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf= "@id/activity_main_text_view" />
此步骤的完整代码可以在 packt.link/8f854
找到。
-
最后,在
MainActivity
中执行以下步骤:class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val preferenceWrapper = (application as SettingsApplication) .settingsStore val preferenceViewModel = ViewModelProvider(this, object : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { return SettingsViewModel( preferenceWrapper) as T } }).get(SettingsViewModel::class.java) } }
此步骤的完整代码可以在 packt.link/gydeC
找到。
如果我们现在运行应用程序,我们应该看到以下屏幕:
图 12.2 – 练习 12.02 的输出
如果我们输入新的文本并点击 DataStore
,它将为每次更改发出新的值。我们将在未来的章节中查看流和其他响应式流。
在这个练习中,我们探讨了 DataStore
库的工作原理及其优势,特别是在数据流方面。在接下来的章节中,我们将继续探讨使用文件持久化数据的其他方法。
文件
我们已经讨论了 Room、SharedPreferences
和 DataStore
,并指定了它们存储的数据是如何写入文件的。你可能想知道,这些文件存储在哪里?这些特定的文件存储在内部存储中。内部存储是每个应用程序的专用空间,其他应用程序无法访问(除非设备已越狱)。你的应用程序可以使用存储空间没有限制。
然而,用户可以从设置菜单中删除他们应用的文件。内部存储只占用总可用空间的一小部分,这意味着在存储文件时应小心。还有外部存储。您的应用存储在外部存储中的文件对其他应用是可访问的,其他应用中的文件对您的应用也是可访问的。
注意
在 Android Studio 中,您可以使用/data/data/{packageName}
。如果您可以访问此文件夹,这意味着设备已 root。使用此方法,您可以可视化数据库文件和SharedPreferences
文件。
设备文件浏览器的外观示例可以在以下图中查看:
图 12.3 – 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
将字节写入文件。
注意
处理文件有许多替代方案。读者(StreamReader
、StreamWriter
等)更适合处理基于字符的数据。还有第三方库可以帮助处理磁盘 I/O 操作。其中最常用的第三方库之一是名为OkHttp
的库,它与 Retrofit 结合使用以进行 API 调用。Okio 提供的方法与它在 HTTP 通信中读写数据所使用的方法相同。
外部存储
在外部存储中读取和写入需要用户权限。如果授予写入权限,则您的应用程序具有读取外部存储的能力。一旦这些权限被授予,则您的应用程序可以在外部存储上做任何它想做的事情。
这可能带来问题,因为用户可能不会选择授予这些权限。然而,有一些专门的方法提供了将数据写入您的应用程序专用文件夹的可能性。
访问外部存储的最常见方式之一是从Context
和Environment
对象:
-
Context.getExternalFilesDir(mode)
:此方法将返回外部存储上为您的应用程序专用的目录路径。指定不同的模式(图片、电影等)将创建不同的子文件夹,具体取决于您希望如何保存文件。此方法不需要权限。 -
Context.getExternalCacheDir()
:这将指向外部存储上的应用程序缓存目录。对此缓存
文件夹应适用与内部存储选项相同的考虑。此方法不需要权限。 -
Environment
类可以访问设备上一些最常见文件夹的路径。然而,在新设备上,应用程序可能无法访问这些文件和文件夹。
注意
避免使用硬编码的文件和文件夹路径。Android 操作系统可能会根据设备或 Android 版本将文件夹的位置移动。
文件提供者
这代表了一种专门实现的内容提供者,它有助于组织应用程序的文件和文件夹结构。如果您选择这样做,它允许您指定一个 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 中引入的文件选择器,应用程序可以使用它让用户选择文件,目的是处理或上传文件。您可以在以下场景中使用它:
-
您的应用需要用户处理另一个应用(如照片和视频)保存在设备上的文件
-
您希望在一个设备上保存文件,并让用户选择文件的保存位置和文件名
-
您希望将应用程序使用的文件提供给其他应用,用于与列表中第一个场景类似的情况
这同样很有用,因为您的应用程序将避免读写权限,但仍可以写入和访问外部存储。这种方式是基于意图的。您可以注册GetDocument
或CreateDocument
的活动结果。然后,在活动结果回调中,系统将为您提供临时权限的 URI,允许您读取和写入该文件。
SAF 的另一个好处是文件不必在设备上。例如,Google Drive 这样的应用通过 SAF 公开其内容,当选择 Google Drive 文件时,它将被下载到设备上,并将 URI 作为结果发送。
另一个需要提到的重要事情是 SAF 对虚拟文件的支持,这意味着它将公开具有自己格式的 Google 文档,但当这些文档通过 SAF 下载时,它们的格式将被转换为 PDF 等通用格式。
资产文件
您项目中的assets
文件夹。然后您可以使用文件夹在您的资产中分组文件。
您可以通过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
文件夹来存储字体文件。
注意
对于以下练习,您需要一个模拟器。您可以在 Android Studio 中通过选择 工具 | AVD 管理器 来这样做。然后,您可以通过选择 创建虚拟设备 选项,选择模拟器类型,点击 下一步,然后选择一个 x86 图像来创建一个。任何大于 Lollipop 的图像都适用于此练习。接下来,您可以给您的图像命名并点击 完成。
练习 12.03 – 复制文件
让我们创建一个应用程序,该应用程序将在 assets
目录中保留一个名为 my-app-file.txt
的文件。该应用程序将显示两个按钮,分别称为 FileProvider
和 SAF
。当点击 FileProvider
按钮时,文件将被保存在应用程序外部存储的专用区域(Context.getExternalFilesDir(null)
)中。SAF
按钮将打开 SAF,并允许用户指定文件应保存的位置。
为了实现这个练习,请按照以下步骤操作:
-
定义一个文件提供者,它将使用
Context.getExternalFilesDir(null)
位置。 -
当点击
FileProvider
按钮时,将my-app-file.txt
复制到前面的位置。 -
当点击
SAF
按钮时,使用Intent.ACTION_CREATE_DOCUMENT
并将文件复制到提供的位置。 -
使用单独的线程进行文件复制,以符合 Android 指南。
-
使用 Apache IO 库帮助实现文件复制功能,通过提供允许我们从
InputStream
复制到OutputStream
的方法。
完成步骤如下:
-
创建一个新的 Android Studio 项目,包含一个空活动。
-
让我们从我们的 Gradle 配置开始:
implementation 'commons-io:commons-io:2.6'
-
在
main/assets
文件夹中创建my-app-file.txt
文件。您可以随意填充您想要读取的文本。如果main/assets
文件夹不存在,您可以创建它。要创建assets
文件夹,您可以在main
文件夹上右键单击,选择assets
。
现在,此文件夹将被构建系统识别,并且其中的任何文件也将与应用程序一起安装到设备上。您可能需要将 项目视图 从 Android 切换到 项目,以便能够查看此文件结构。
-
我们还可以在根包的
main/java
文件夹中定义一个类来包装AssetManager
,并定义一个方法来访问这个特定的文件:class AssetFileManager(private val assetManager: AssetManager) { fun getMyAppFileInputStream() = assetManager.open("my-app-file.txt") }
-
现在,让我们专注于
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>
-
接下来,我们需要将
FileProvider
添加到AndroidManifest.xml
文件中,并将其与<application>
标签内定义的新路径链接:<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
字段表示您的应用程序域(通常是应用程序的包名)。
导出字段表示我们是否希望与其他应用程序共享我们的提供者,而grantUriPermissions
表示我们是否希望通过 URI 授予其他应用程序访问某些文件的权利。元数据将我们之前定义的 XML 文件与FileProvider
链接起来。
-
在根包的
main/java
文件夹中定义ProviderFileManager
类,该类负责访问docs
文件夹并将数据写入文件:class ProviderFileManager( ) { fun writeStream(name: String, inputStream: InputStream) { executor.execute { val fileToSave = File(getDocsFolder(), name) val outputStream = context.contentResolver .openOutputStream(fileToUriMapper .getUriFromFile(context, fileToSave), "rw") IOUtils.copy(inputStream, outputStream) } } }
此步骤的完整代码可以在packt.link/Gp0Ph
找到。
getDocsFolder
将返回我们在 XML 中定义的docs
文件夹的路径。如果文件夹不存在,则将其创建。
writeStream
方法将提取我们希望保存的文件的 URI,并使用 Android 的ContentResolver
类,将给我们提供访问我们将要保存的文件的OutputStream
类的权限。请注意,FileToUriMapper
尚未存在。代码被移动到一个单独的类中,以便使此类可测试。
-
在根包的
main/java
文件夹中创建FileToUriMapper
类:class FileToUriMapper { fun getUriFromFile(context: Context, file: File): Uri { return FileProvider.getUriForFile(context, "com.android.testable.files", file) } }
getUriForFile
方法是FileProvider
类的一部分,其作用是将文件的路径转换为ContentProviders
和ContentResolvers
可以使用以访问数据的 URI。因为此方法是静态的,所以它阻止我们进行适当的测试。
-
确保以下字符串被添加到
strings.xml
中:<string name="file_provider">FileProvider</string> <string name="saf">SAF</string>
-
现在,让我们继续定义我们的
activity_main.xml
文件在res/layout
文件夹中的 UI:<Button android:id="@+id/activity_main_file_provider" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="200dp" android:text="@string/file_provider" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/activity_main_saf" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="50dp" android:text="@string/saf" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf= "@id/activity_main_file_provider" />
此步骤的完整代码可以在packt.link/Pw37X
找到。
-
现在,让我们在根包的
main/java
文件夹中定义我们的MainActivity
类:class MainActivity : AppCompatActivity() { 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" providerFileManager.writeStream( newFileName, assetFileManager .getMyAppFileInputStream()) } } }
此步骤的完整代码可以在packt.link/FBXgY
找到。
对于这个例子,我们选择了MainActivity
来创建我们的对象并将数据注入到我们拥有的不同类中。如果我们执行此代码并点击FileProvider
按钮,我们在 UI 上不会看到输出。
然而,如果我们查看 Android 的设备文件浏览器,我们可以找到文件保存的位置。在不同的设备和操作系统上,路径可能不同。路径可能如下所示:
-
mnt/sdcard/Android/data/<package_name>/files/docs
-
sdcard/Android/data/<package_name>/files/docs
-
storage/emulated/0/Android/data/<package_name>/files/docs
输出将如下所示:
图 12.4 – 通过 FileProvider 的复制输出
- 让我们添加
SAF
按钮的逻辑。我们需要启动一个指向SAF
的活动,使用CREATE_DOCUMENT
意图,其中我们指定我们想要创建一个文本文件。
我们将需要SAF
的结果,以便我们可以将文件复制到用户选择的地点。在MainActivity
的onCreate
中,我们可以添加以下内容:
val createDocumentResult =
registerForActivityResult(
ActivityResultContracts.CreateDocument(
"text/plain")) { uri ->
uri?.let {
val newFileName = "Copied.txt"
providerFileManager
.writeStreamFromUri(newFileName,
assetFileManager
.getMyAppFileInputStream(), uri)
}
}
findViewById<Button>(R.id.activity_main_saf)
.setOnClickListener {
createDocumentResult.launch("Copied.txt")
}
前面的代码将要执行的操作是在用户创建新文件时注册一个 Activity
结果。然后,我们将从 ProviderFileManager
调用 writeStreamFromUri
来保存用户创建的文件中 assets
文件夹的内容。当按钮被点击时,我们将从 SAF 启动文件创建界面。
-
现在我们有了 URI。我们可以在
ProviderFileManager
中添加一个方法,将我们的文件复制到由uri
指定的位置:fun writeStreamFromUri(name: String, inputStream: InputStream, uri:Uri){ executor.execute { val outputStream = context.contentResolver .openOutputStream(uri, "rw") IOUtils.copy(inputStream, outputStream) } }
如果我们运行前面的代码并点击 SAF 按钮,我们将看到 图 12.5 中展示的输出:
图 12.5 – 通过 SAF 复制的输出
如果您选择保存文件,SAF 将关闭,并调用 registerForActivityResult
的回调,这将触发文件复制。之后,您可以通过 Android 设备文件管理器工具导航,以查看文件是否已正确保存。
范围存储
自 Android 10 以来,随着 Android 11 的进一步更新,引入了范围存储的概念。其背后的主要思想是允许应用程序对其外部存储中的文件有更多的控制权,并防止其他应用程序访问这些文件。
这意味着 READ_EXTERNAL_STORAGE
和 WRITE_EXTERNAL_STORAGE
只适用于用户交互的文件(如媒体文件)。这会阻止应用程序在外部存储中创建自己的目录,而是坚持使用通过 Context.getExternalFilesDir
提供给它们的目录。
文件提供者和 SAF 是使您的应用程序符合范围存储实践的好方法,一个允许应用程序使用 Context.getExternalFilesDir
,另一个使用内置的文件浏览器应用程序,现在将避免外部存储中 Android/data
和 Android/obb
文件夹中的其他应用程序的文件。
摄像头和媒体存储
Android 提供了多种与其设备上的媒体进行交互的方式,从构建自己的相机应用程序并控制用户如何拍照和录像,到使用现有的相机应用程序并指导其如何拍照和录像。
Android 还附带了一个 MediaStore
内容提供者,允许应用程序提取有关设备上设置并应用程序间共享的媒体文件的信息。
这在需要为设备上存在的媒体文件(如照片或音乐播放器应用程序)提供自定义显示的情况下很有用,以及在您使用 MediaStore.ACTION_PICK
意图从设备中选择照片并希望提取所选媒体图像信息的情况下(这通常是对于无法使用 SAF 的旧应用程序而言)。
为了使用现有的相机应用程序,您需要使用 MediaStore.ACTION_IMAGE_CAPTURE
意图启动一个用于结果的相机应用程序,并传递您希望保存的图像的 URI。然后用户将进入相机活动并拍照,然后您处理操作的结果:
val imageCaptureLauncher =
registerForActivityResult(
ActivityResultContracts.TakePicture()){
}
imageCaptureLauncher.launch(photoUri)
photoUri
参数将代表您希望保存照片的位置。它应该指向一个具有 JPEG 扩展名的空文件。您可以通过两种方式构建此文件:
-
使用
File
对象在外部存储上创建一个文件(这需要WRITE_EXTERNAL_STORAGE
权限),然后使用Uri.fromFile()
方法将其转换为URI
(这在 Android 10 及以上版本中不再适用) -
使用
File
对象在FileProvider
位置创建一个文件,然后使用FileProvider.getUriForFile()
方法获取 URI,并在必要时授予它权限(当您的应用程序针对 Android 10 和 Android 11 时推荐的方法)
注意
同样的机制可以使用 MediaStore.ACTION_VIDEO_CAPTURE
用于视频。
如果您的应用程序严重依赖相机功能,那么您可以通过在 AndroidManifest.xml
文件中添加 <uses-feature>
标签来排除没有相机的用户的应用程序。您还可以指定相机为非必需,并使用 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 -> }
-
直接使用
ContentResolver
将媒体插入ContentProvider
: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.getExternal
FilesDir 添加文件。如果应用程序选择与其他应用程序共享其媒体文件,它们应选择使用 insert
方法。
练习 12.04 – 拍照
我们将构建一个具有两个按钮的应用程序;第一个按钮将打开相机应用程序拍照,第二个按钮将打开相机应用程序录制视频。我们将使用 FileProvider
将照片保存到外部存储(external-path)的两个文件夹中,pictures
和 movies
。
照片将使用 img_{timestamp}.jpg
保存,视频将使用 video_{timestamp}.mp4
保存。在照片和视频保存后,您将把文件从 FileProvider
复制到 MediaStore
,以便其他应用程序可以看到:
-
创建一个新的 Android Studio 项目,其中包含一个空活动。
-
让我们在
app/build.gradle
中添加库:implementation 'commons-io:commons-io:2.6'
-
我们需要为 Android 10 之前的设备请求
WRITE_EXTERNAL_STORAGE
权限,这意味着我们需要在<application>
标签之外AndroidManifest.xml
中包含以下内容:<uses-permission android:name="android.permission .WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
-
让我们在根包的
test
文件夹中定义一个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 }
-
让我们在
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 .myapplication/files/Pictures" /> <external-path name="videos" path="Android/data/com.android.testable .myapplication/files/Movies" /> </paths>
让我们在根包中的AndroidManifest.xml
文件内的<application>
标签中添加文件提供者路径:
<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>
-
现在我们定义一个模型,它将包含
Uri
和与根包中main/java
文件夹中的文件关联的路径:data class FileInfo( val uri: Uri, val file: File, val name: String, val relativePath:String, val mimeType:String )
-
让我们在根包中的
main/java
文件夹中创建一个ContentHelper
类,它将为我们提供ContentResolver
所需的数据。我们将定义两个用于访问照片和视频内容 URI 的方法,以及两个创建ContentValues
的方法。
我们这样做是因为需要静态方法来获取 URI 和创建ContentValues
,这使得此功能难以测试。以下代码为了节省空间而被截断。您需要添加的完整代码可以通过以下代码块后的链接找到:
class MediaContentHelper {
fun getImageContentUri(): Uri =
if (android.os.Build.VERSION.SDK_INT >=
android.os.Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri
(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media
.EXTERNAL_CONTENT_URI
}
fun generateImageContentValues(fileInfo:
FileInfo) = ContentValues().apply {
this.put(MediaStore.Images.Media
.DISPLAY_NAME, fileInfo.name)
if (android.os.Build.VERSION.SDK_INT >=
android.os.Build.VERSION_CODES.Q) {
this.put(MediaStore.Images.Media
.RELATIVE_PATH, fileInfo.relativePath)
}
this.put(MediaStore.Images.Media .MIME_TYPE,
fileInfo.mimeType)
}
此步骤的完整代码可以在packt.link/DhOLR
找到。
-
现在,让我们在根包中的
main/java
文件夹中创建ProviderFileManager
类,我们将定义用于生成相机将使用的照片和视频文件的方法,以及将文件保存到媒体存储的方法。同样,为了简洁,代码已被截断。请参阅以下代码块后的链接以获取您需要使用的完整代码:class ProviderFileManager( ) { fun generatePhotoUri(time: Long): FileInfo { val name = "img_$time.jpg" val file = File( context.getExternalFilesDir( fileHelper .getPicturesFolder()), name ) return FileInfo( fileHelper.getUriFromFile(file), file, name, fileHelper.getPicturesFolder(), "image/jpeg" ) }
此步骤的完整代码可以在packt.link/ohv7a
找到。
注意我们如何将根文件夹定义为context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
和context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
。这连接到file_provider_paths.xml
,它将在应用程序的外部存储专用文件夹中创建一个名为Movies
和Pictures
的文件夹集合。insertToStore
方法是将文件复制到MediaStore
的地方。
首先,我们将创建一个条目到该存储中,这将给我们一个该条目的 URI。接下来,我们将从由FileProvider
生成的 URI 中复制文件的 内容到OutputStream
,指向MediaStore
条目。
-
将以下字符串添加到
strings.xml
中:<string name="photo">Photo</string> <string name="video">Video</string>
-
让我们在
res/layout/activity_main.xml
中定义我们活动的布局:<Button android:id="@+id/photo_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/photo" /> <Button android:id="@+id/video_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="5dp" android:text="@string/video" />
此步骤的完整代码可以在packt.link/6iSNp
找到。
-
让我们在根包中
main/java
文件夹中创建MainActivity
类,我们将检查是否需要请求WRITE_STORAGE_PERMISSION
,如果需要,就请求它,并在它被授权后,打开相机拍照或录像。和之前一样,这里的代码为了简洁而被截断。您可以通过以下链接访问完整的代码:class MainActivity : AppCompatActivity() { private lateinit var providerFileManager: ProviderFileManager private var photoInfo: FileInfo? = null private var videoInfo: FileInfo? = null private var isCapturingVideo = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) providerFileManager = ProviderFileManager(applicationContext, FileHelper(applicationContext), contentResolver, Executors .newSingleThreadExecutor(), MediaContentHelper() )
此步骤的完整代码可以在 packt.link/YeHWC
找到。
如果我们执行前面的代码,我们将看到以下内容:
图 12.6 – 练习 12.04 的输出
- 通过点击任一按钮,你将被重定向到相机应用,在那里如果你在 Android 10 及以上版本上运行示例,你可以拍照或录像。如果你在较低的 Android 版本上运行,则首先会请求权限。
一旦你拍摄了照片并确认,你将被带回到应用程序。照片将被保存在你在 FileProvider
中定义的位置:
图 12.7 – 通过相机应用捕获的文件位置
在前面的屏幕截图中,你可以看到文件的位置,这是通过 Android Studio 的设备文件浏览器帮助实现的。如果你打开任何文件管理应用,如文件、图库或谷歌照片应用,你将能够看到拍摄的视频和图片。
图 12.8 – 应用程序在文件浏览器应用中显示的文件
活动 12.01 – 狗下载器
你被分配了一个任务,需要构建一个针对 API 21 以上 Android 版本的应用程序,显示狗照片的 URL 列表。你将连接到的 URL 是 https://dog.ceo/api/breed/hound/images/random/{number}
,其中 number
将通过一个设置屏幕进行控制,用户可以在该屏幕中选择他们想要显示的 URL 数量。
设置屏幕将通过主屏幕上的选项打开。当用户点击 URL 时,图像将下载到应用程序的外部缓存路径。在图像下载过程中,用户将看到一个不确定的进度条。URL 列表将使用 Room 本地持久化。
我们将使用以下技术:
-
Retrofit 用于检索 URL 列表和下载文件
-
Room 用于持久化 URL 列表
-
SharedPreferences
用于存储要检索的 URL 数量 -
FileProvider
用于在缓存中存储文件 -
Apache IO 用于写入文件
-
仓库来组合所有数据源
-
使用
LiveData
和ViewModel
处理来自用户的逻辑 -
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"
}
完成此活动的步骤如下:
-
创建一个包含网络相关类的
api
包。 -
创建一个数据类来模拟响应 JSON。
-
创建一个 Retrofit
Service
类,其中将包含两个方法。第一个方法将表示返回品种列表的 API 调用,第二个方法将表示下载文件的 API 调用。 -
创建一个
storage
包,并在其中创建一个room
包。 -
创建一个名为
Dog
的实体,它将包含一个自动生成的 ID 和一个 URL。 -
创建一个包含插入
Dogs
列表、删除所有Dogs
和查询所有Dogs
方法的DogDao
类。delete
方法是必需的,因为 API 模型没有任何唯一标识符。 -
在
storage
包内部,创建一个preference
包。 -
在
preference
包内部,创建一个围绕SharedPreferences
的包装类,该类将返回我们需要使用的 URL 数量并设置该数量。默认值为10
。 -
在
res/xml
中,为FileProvider
定义你的文件夹结构。文件应保存在external-cache-path
标签的根文件夹中。 -
在
storage
包内部,创建一个filesystem
包。 -
在
filesystem
包内部,定义一个类,该类将负责将InputStream
写入FileProvider
中的文件,使用Context.externalCacheDir
。 -
创建一个
repository
包。 -
在
repository
包内部,创建一个密封类,该类将包含 API 调用的结果。密封类的子类将是Success
、Error
和Loading
。 -
定义一个包含两个方法的
Repository
接口,一个用于加载 URL 列表,另一个用于下载文件。 -
定义一个
DogUi
模型类,它将在应用程序的 UI 层中使用,并在你的仓库中创建。 -
定义一个映射类,它将把你的 API 模型转换为实体,并将实体转换为 UI 模型。
-
定义一个
Repository
的实现,该实现将实现前面的两个方法。该仓库将包含对DogDao
、RetrofitService
类、Preferences
包装类、管理文件的类、Dog
映射类和多线程的Executor
类的引用。在下载文件时,我们将使用从 URL 中提取的文件名。 -
创建一个扩展
Application
的类,该类将初始化仓库。 -
定义你的 UI 使用的
ViewModel
类,它将有一个对Repository
的引用,并调用它来加载 URL 列表和下载图片。 -
定义你的 UI,它将由两个活动组成:
-
MainActivity
,显示 URL 列表,并具有点击动作以启动下载。此活动将有一个进度条,在下载发生时将显示。屏幕还将有一个SettingsActivity
。 -
SettingsActivity
,它将显示EditText
和Button
并保存 URL 的数量。
-
注意
该活动的解决方案可以在 packt.link/z6g5j
找到。
摘要
在本章中,我们分析了在持久化数据方面 Room 的替代方案。我们首先看了 SharedPreferences
以及它如何构成一种方便的数据持久化解决方案,当数据以键值格式存在且数据量较小时。我们还看了 DataStore
以及我们如何像使用 SharedPreferences
一样使用它,但具有内置的可观察性,它会通知我们值何时发生变化。
接下来,我们回顾了在 Android 框架中持续变化的某个方面——关于文件系统的抽象演变。我们首先概述了 Android 拥有的存储类型,然后更深入地探讨了其中两种抽象——FileProvider
,您的应用可以使用它来在设备上存储文件并在必要时与他人共享,以及 SAF,它可以用作在用户选择的位置保存设备上的文件。
我们还利用了FileProvider
的优势来生成文件的 URI,以便使用相机应用拍照和录制视频,同时将它们保存在应用文件中,并添加到MediaStore
中。
本章中执行的活动结合了之前讨论的所有元素,以说明即使在应用内部需要平衡多个来源,也可以以更可读的方式完成。
注意,在本章和上一章的活动和练习中,我们不得不一直使用application
类来实例化数据源。在下一章中,您将学习如何通过依赖注入来克服这一点,并了解它如何使 Android 应用受益。
第十三章:使用 Dagger、Hilt 和 Koin 进行依赖注入
本章涵盖了依赖注入的概念及其对 Android 应用程序提供的益处。我们将探讨如何借助容器类手动执行依赖注入。我们还将介绍一些可用于 Android、Java 和 Kotlin 的框架,这些框架可以帮助开发者应用这一概念。在本章结束时,你将能够使用 Dagger 2 和 Koin 来管理你的应用程序依赖,并了解如何有效地组织它们。
在上一章中,我们探讨了如何将代码结构化成不同的组件,包括 ViewModels、API 组件和持久化组件。其中一直存在的困难之一是所有这些组件之间的依赖关系,尤其是在进行单元测试时。
本章将涵盖以下主题:
-
手动依赖注入
-
Dagger 2
-
Hilt
-
Koin
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,链接为packt.link/IIQmX
依赖注入的必要性
我们一直使用Application
类来创建这些组件的实例,并将它们传递给组件上层构造函数(我们创建了 API 和 Room 实例,然后是 Repository 实例,等等)。我们所做的是依赖注入的简化版本。
ViewModels
)。这样做的原因是为了提高代码的可重用性和可测试性,并将创建实例的责任从我们的组件转移到Application
类。
依赖注入的一个好处是涉及代码库中对象的创建方式。依赖注入将对象的创建与其使用分离。换句话说,一个对象不应该关心另一个对象是如何创建的;它只应该关注与其他对象的交互。
在本章中,我们将分析三种在 Android 中注入依赖的方法:手动依赖注入、Dagger 和 Koin:
-
手动依赖注入(Manual DI):这是一种开发者通过创建容器类来手动处理依赖注入的技术。在本章中,我们将探讨如何在 Android 中实现这一过程。通过研究我们如何手动管理依赖关系,我们将深入了解其他依赖注入框架的工作原理,并为我们如何集成这些框架打下基础。
-
Dagger:这是一个为 Java 开发的依赖注入框架。它允许你将依赖分组到不同的模块中。你还可以定义组件,其中模块被添加以创建依赖图,Dagger 会自动实现以执行注入。它依赖于注解处理器来生成执行注入所需的代码。Dagger 的一个专门实现Hilt对 Android 应用程序非常有用,因为它消除了大量样板代码并简化了过程。
-
Koin:这是一个为 Kotlin 开发的轻量级依赖注入库。它不依赖于注解处理器;它依赖于 Kotlin 的机制来执行注入。在这里,我们还可以将依赖项拆分为模块。
在本章中,我们将探讨这两个库是如何工作的,以及将它们添加到简单 Android 应用程序所需的步骤。
手动依赖注入
为了理解依赖注入是如何工作的,我们首先可以分析如何在 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
可以扩展以提供 MyActivity
所需的实例,在这些实例的生命周期应与活动相同的情况下,或者构造函数可以扩展以提供具有不同生命周期的实例。
现在,让我们将这些示例应用到练习中。
练习 13.01 – 手动注入
在这个练习中,我们将编写一个 Android 应用程序,该应用程序将应用手动依赖注入的概念。该应用程序将有一个仓库,它将生成一个随机数,以及一个带有 LiveData
对象的 ViewModel
对象,该对象负责检索仓库生成的数字并在 LiveData
对象中发布它。
为了做到这一点,我们需要创建两个容器来管理以下依赖项:
-
仓库
-
负责创建
ViewModel
的ViewModel
工厂
应用程序本身将在每次点击按钮时显示随机生成的数字:
-
创建一个新的 Android Studio 项目,并包含一个空活动。
-
首先,让我们将
ViewModel
和LiveData
库添加到app/build.gradle
文件中:implementation "androidx.lifecycle: lifecycle-viewmodel-ktx:2.5.1" implementation "androidx.lifecycle: lifecycle-livedata-ktx:2.5.1"
-
接下来,让我们在根包的
main/java
文件夹中编写一个NumberRepository
接口,它将包含一个获取整数的函数:interface NumberRepository { fun generateNextNumber(): Int }
-
现在,我们将在这个根包的
main/java
文件夹中提供这个实现的实现。我们可以使用java.util.Random
类来生成随机数:class NumberRepositoryImpl(private val random: Random) : NumberRepository { override fun generateNextNumber(): Int { return random.nextInt() } }
-
我们现在将转到根包的
main/java
文件夹中的MainViewModel
类,它将包含一个包含从仓库生成的每个数字的LiveData
对象:class MainViewModel(private val numberRepository: NumberRepository) : ViewModel() { private val _numberLiveData = MutableLiveData<Int>() val numberLiveData: LiveData<Int> = _numberLiveData fun generateNextNumber() { _numberLiveData.postValue(numberRepository .generateNextNumber()) } }
-
接下来,让我们继续创建用于显示数字的
TextView
和用于生成下一个随机数的Button
。这将是res/layout/activity_main.xml
文件的一部分:<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" />
此步骤的完整代码可以在packt.link/lr5Fx
找到。
-
确保将按钮的字符串添加到
res/values/strings.xml
文件中:<string name="randomize">Randomize</string>
-
现在,让我们在根包的
main/java
文件夹中创建我们的Application
类:class RandomApplication : Application() { override fun onCreate() { super.onCreate() } }
-
让我们也将
Application
类添加到AndroidManifest.xml
文件中的application
标签下:<application ... android:name=".RandomApplication" .../>
-
现在,让我们在根包的
main/java
文件夹中创建我们的第一个容器,该容器负责管理NumberRepository
依赖项:class ApplicationContainer { val numberRepository: NumberRepository = NumberRepositoryImpl(Random()) }
-
接下来,让我们将这个容器添加到
RandomApplication
类中:class RandomApplication : Application() { val applicationContainer = ApplicationContainer() override fun onCreate() { super.onCreate() } }
-
我们现在继续在根包的
main/java
文件夹中创建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 } } } }
-
最后,我们可以修改
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.numberLiveData.observe(this, Observer { findViewById<TextView>( R.id.activity_main_text_view).text = it.toString() } ) findViewById<TextView>( R.id.activity_main_button).setOnClickListener { viewModel.generateNextNumber() } } }
在高亮显示的代码中,我们可以看到我们正在使用在ApplicationContainer
中定义的仓库并将其注入到MainContainer
中,然后它将通过ViewModelProvider.Factory
将其注入到ViewModel
中。前面的示例应该会呈现图 13.1.1*中的输出:
图 13.1 – 练习 13.01 的模拟器输出,显示随机生成的数字
手动依赖注入是设置小型应用程序依赖项的一种简单方法,但随着应用程序的增长,它可能会变得极其困难。想象一下,如果在练习 13.01的手动注入中,我们有两个从NumberRepository
扩展的类。我们将如何处理这种情况?开发者将如何知道哪个被用于哪个活动?这些问题在 Google Play 上大多数知名应用程序中变得非常普遍,这就是为什么手动依赖注入很少使用。当使用时,它通常采用类似于我们接下来将要查看的依赖注入框架的形式。
Dagger 2
Dagger 2提供了一种全面的方式来组织应用程序的依赖项。它具有首先在 Android 上被开发社区采用的优势,在 Kotlin 引入之前。这是许多 Android 应用程序使用 Dagger 作为其依赖注入框架的原因之一。
该框架的另一个优势是针对用 Java 编写的 Android 项目,因为库是用相同的语言开发的。该框架最初由 Square(Dagger 1)开发,后来过渡到 Google(Dagger 2)。在本章中,我们将介绍 Dagger 2 及其优势。
Dagger 2 提供的一些关键功能如下所示:
-
注入
-
在模块中分组的依赖项
-
用于生成依赖图的组件
-
标准化
-
范围
-
子组件
当处理 Dagger 时,注解是关键元素,因为它通过注解处理器生成执行 DI 所需的代码。主要注解可以按以下方式分组:
-
@Module
负责提供可以注入的对象(依赖对象) -
@Inject
注解用于定义依赖项 -
被
@Component
注解的接口定义了提供者和消费者之间的连接
你需要在app/build.gradle
文件中添加以下依赖项,以将 Dagger 添加到你的项目中:
implementation 'com.google.dagger:dagger:2.44.2'
kapt 'com.google.dagger:dagger-compiler:2.44.2'
由于我们正在处理注解处理器,在同一个build.gradle
文件中,你需要为它们添加插件:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
我们现在应该对 Dagger 2 如何执行 DI 有一个概念。接下来,我们将查看 Dagger 2 提供的每个注解组。
消费者
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 将生成必要的代码,仅用于在ClassB
和ClassA
之间注入依赖。
提供者
你可能会遇到应用程序使用外部依赖项的情况。这意味着你不能通过构造函数注入来提供实例。构造函数注入不可行的情况还包括使用接口或抽象类。
在这种情况下,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)
}
如前例所示,ClassA
和 ClassB
没有任何 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
注解的变量执行依赖注入,从而让我们访问模块中定义的 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)
}
基于这些知识,让我们继续进行练习。
练习 13.02 – Dagger 注入
在这个练习中,我们将编写一个 Android 应用程序,该程序将使用 Dagger 应用 DI 概念。该应用程序将具有与 练习 13.01,手动注入 中定义相同的 Repository
和 ViewModel
。
我们需要使用 Dagger 来公开相同的两个依赖项:
-
Repository
:这将具有@Singleton
作用域,并由ApplicationModule
提供。现在,ApplicationModule
将作为ApplicationComponent
的一部分公开。 -
ViewModelProvider.Factory
:这将具有自定义定义的作用域名称MainScope
,并由MainModule
提供。现在,MainModule
将由MainSubComponent
公开。此外,MainSubComponent
将由ApplicationComponent
生成。
应用程序本身将在每次点击按钮时显示一个随机生成的数字。为了实现这一点,请执行以下步骤:
-
创建一个新的 Android Studio 项目,包含空活动。
-
首先,我们将 Dagger 和
ViewModel
库添加到app/build.gradle
文件中:implementation 'com.google.dagger:dagger:2.44.2' kapt 'com.google.dagger:dagger-compiler:2.44.2' implementation "androidx.lifecycle: lifecycle-viewmodel-ktx:2.5.1" implementation "androidx.lifecycle: lifecycle-livedata-ktx:2.5.1"
-
我们还需要在
app/build.gradle
模块中添加kapt
插件。按照以下方式附加插件:apply plugin: 'kotlin-kapt'
-
我们现在需要添加
NumberRepository
、NumberRepositoryImpl
、Main
ViewModel 和RandomApplication
类,并使用Main
Activity 构建我们的 UI。这可以通过遵循 练习 13.01,手动注入 中的 步骤 2–9 来完成。 -
现在,让我们转向根包的
main/java
文件夹中的ApplicationModule
,它将提供NumberRepository
依赖项:@Module class ApplicationModule { @Provides fun provideRandom(): Random = Random() @Provides fun provideNumberRepository(random: Random): NumberRepository = NumberRepositoryImpl(random) }
-
现在,让我们在根包的
main/java
文件夹中创建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 } } } }
-
现在,让我们在根包的
main/java
文件夹中创建MainScope
:@Scope @MustBeDocumented @kotlin.annotation.Retention(AnnotationRetention .RUNTIME) annotation class MainScope
-
我们还需要在根包的
main/java
文件夹中创建MainSubcomponent
,它将使用前面的作用域:@MainScope @Subcomponent(modules = [MainModule::class]) interface MainSubcomponent { fun inject(mainActivity: MainActivity) }
-
接下来,我们需要在根包的
main/java
文件夹中引入ApplicationComponent
:@Singleton @Component(modules = [ApplicationModule::class]) interface ApplicationComponent { fun createMainSubcomponent(): MainSubcomponent }
-
接下来,我们修改
RandomApplication
类以添加初始化 Dagger 依赖图的代码:class RandomApplication : Application() { lateinit var applicationComponent: ApplicationComponent override fun onCreate() { super.onCreate() applicationComponent = DaggerApplicationComponent.create() } }
-
现在我们修改
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) } }
本步骤的完整代码可以在 packt.link/A7ozE
找到。
- 我们需要导航到
Build
并在 Android Studio 中点击Rebuild project
,以便 Dagger 生成执行 DI 的代码。
如果您运行前面的代码,它将构建一个应用程序,当您点击按钮时将显示不同的随机输出:
图 13.2 – 练习 13.02 的模拟器输出,显示随机生成的数字
- 图 13.3 显示了应用程序的外观。您可以在
app/build
文件夹中查看生成的 Dagger 代码:
图 13.3 – 为练习 13.02 生成的 Dagger 代码
在 图 13.3 中,我们可以看到 Dagger 生成的代码,以满足依赖项之间的关系。对于每个需要注入的依赖项,Dagger 将生成一个适当的 Factory
类(基于 Factory
设计模式),该类将负责创建依赖项。
Dagger 还会查看需要注入依赖的地方,并生成一个 Injector
类,该类将负责将值分配给依赖项(在这种情况下,它将分配给 MainActivity
类中注解了 @Inject
的成员)。
最后,Dagger 为带有 @Component
注解的接口创建实现。在实现中,Dagger 将处理模块的创建,并提供一个构建器,开发者可以指定如何构建模块。
当组织 Android 应用程序的依赖项时,您会发现的一个常见设置如下:
-
ApplicationModule
:这是定义整个项目共通依赖的地方。可以在此提供如 context、资源和其他 Android 框架对象。 -
NetworkModule
:这是存储与 API 调用相关的依赖的地方。 -
StorageModule
:这是存储与持久化相关的依赖的地方。它可以分为DatabaseModule
、FilesModule
、SharedPreferencesModule
等。 -
ViewModelsModule
:这是存储ViewModel
或ViewModel
工厂依赖的地方。 -
FeatureModule
:这是为具有自己ViewModel
的特定活动或片段组织依赖的地方。在这里,可以使用子组件或 Android 注入器来完成此目的。
我们提出了一些关于手动 DI 可能出错的问题。现在我们已经看到 Dagger 如何解决这些问题。尽管它完成了工作,并且在性能方面做得很快,但它也是一个复杂且学习曲线非常陡峭的框架。
Hilt
当我们在 Android 应用程序中使用 Dagger 时,我们被迫编写一些模板代码。其中一些是关于处理与 Activities 和 Fragments 链接的对象的生命周期,这导致我们创建子组件;其他部分是关于 ViewModels 的使用。
为了简化 Android 中的 Dagger,曾尝试使用 Dagger-Android 库,但后来在 Dagger 的基础上开发了一个新的库,称为 Hilt。这个库通过使用新的注解简化了大部分的 Dagger 使用,这导致产生了更多的模板代码,这些代码可以被生成。
要在项目中使用 Hilt,我们需要以下内容:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.dagger.hilt.android'
或者根据你的项目如何使用 Gradle,你可能需要使用以下内容:
plugins {
…
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
在这两种情况下,你都需要一个插件来处理注解,以及一个单独的插件来处理项目中的 Hilt。
要将 Hilt 添加到你的项目中,你需要以下内容:
dependencies {
implementation "com.google.dagger:hilt-android:2.44.2"
kapt "com.google.dagger:hilt-compiler:2.44.2"
}
Hilt 首先在 Application
类中做出的改变是,不再需要调用特定的 Dagger 组件来初始化,使用 Hilt,你只需使用 @HiltAndroidApp
注解即可:
@HiltAndroidApp
class MyApplication : Application() {
}
上述代码片段将让 Hilt 知道你的应用程序的入口点,并开始生成依赖图。
Hilt 的另一个好处在于与 Android 组件(如 Activities
、Fragments
、Views
、Services
和 BroadcastReceivers
)交互时。对于这些组件,我们可以使用 @AndroidEntryPoint
注解将依赖注入到每个这些类中,如下所示:
@AndroidEntryPoint
class MyActivity : AppCompatActivity() {
@Inject
lateinit var myObject: MyObject
}
在上述代码片段中,@AndroidEntryPoint
的使用允许 Hilt 将 myObject
注入到 MyActivity
中。类似的方法也可以用于通过 @HiltViewModel
注解将依赖注入到 ViewModels
中:
@HiltViewModel
class MyViewModel @Inject constructor(private val myObject:
MyObject) : ViewModel()
在上述代码片段中,@HiltViewModel
注解允许 Hilt 将 myObject
注入到 MyViewModel
中。我们还可以观察从 Dagger 继承而来的 @Inject
注解,不需要使用模块。
当涉及到模块时,Hilt 继续采用 Dagger 的方法,增加了一个小的改进:使用 @InstallIn
注解。这会将注解模块与特定的组件关联起来。Hilt 提供了一系列预构建的组件,例如 SingletonComponent
、ViewModelComponent
、ActivityComponent
、FragmentComponent
、ViewComponent
和 ServiceComponent
。
这些组件中的每一个都将注解模块中依赖项的生命周期与应用程序、ViewModel
、Activity
、Fragment
、View
和 Service
的生命周期相链接:
@Module
@InstallIn(SingletonComponent::class)
class MyModule {
@Provides
fun provideMyObject(): MyObject = MyObject()
}
在上述代码片段中,我们可以看到 Hilt 中的 @Module
的样子以及我们如何使用 @InstallIn
注解来指定 MyObject
的生命周期与我们的应用程序的生命周期相同。
当涉及到仪器化测试时,Hilt 提供了有用的注解来更改测试的依赖项。如果我们想利用这些功能,那么我们需要以下测试依赖项:
androidTestImplementation 'com.google.dagger:
hilt-android-testing:2.44.2'
kaptAndroidTest 'com.google.dagger:
hilt-android-compiler:2.44.2'
然后,我们可以进入我们的测试,并按照以下方式引入 Hilt:
@HiltAndroidTest
class MyInstrumentedTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var myObject: MyObject
@Before
fun init() {
hiltRule.inject()
}
}
在前面的代码片段中,使用了@HiltAndroid
测试和hiltRule
来交换应用程序中使用的依赖项与测试依赖项。注入调用使我们能够将MyObject
依赖项注入到测试类中。为了提供测试依赖项,我们可以在androidTest
文件夹中编写一个新的模块,如下所示:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [MyModule::class]
)
class MyTestModule {
@Provides
fun provideMyObject(): MyObject = MyTestObject()
}
在这里,我们使用@TestInstallIn
注解,该注解将用MyTestModule
替换依赖图中现有的MyModule
,从而提供我们想要交换的依赖项的不同子类。
为了使 Hilt 能够初始化用于测试的仪器,我们需要定义一个自定义测试运行器,以从 Hilt 库提供一个测试应用程序。该运行器可能看起来如下所示:
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name:
String?, context: Context?): Application {
return super.newApplication(cl,
HiltTestApplication::class.java.name, context)
}
}
此运行器需要在运行测试的模块的build.gradle
中注册:
android {
…
defaultConfig {
…
testInstrumentationRunner "{app_package_name}
.HiltTestRunner"
}
}
在本节中,我们研究了 Hilt 库及其在移除使用 Dagger 所需的样板代码方面的优势。
练习 13.03 – Hilt 注入
修改练习 13.02、Dagger 注入,以便删除@Component
和@Subcomponent
类,并使用 Hilt:
-
在顶级
build.gradle
文件中添加 Hilt 插件:plugins { … id 'com.google.dagger.hilt.android' version '2.44.2' apply false }
-
在
app/build.gradle
中添加 Hilt 插件:plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-kapt' id 'com.google.dagger.hilt.android' }
-
在同一文件中,将 Dagger 依赖项替换为 Hilt 依赖项,并添加用于生成
ViewModel
的片段扩展库:implementation "com.google.dagger: hilt-android:2.44.2" kapt "com.google.dagger:hilt-compiler:2.44.2" implementation 'androidx.fragment: fragment-ktx:1.5.5'
-
从项目中删除
ApplicationComponent
、MainModule
、MainScope
和MainSubcomponent
。 -
将
@InstallIn
注解添加到ApplicationModule
:@Module @InstallIn(SingletonComponent::class) class ApplicationModule { }
-
从
RandomApplication
内部删除所有代码并添加@HiltAndroidApp
注解:@HiltAndroidApp class RandomApplication : Application()
-
修改
MainViewModel
以添加@HiltViewModel
和@Inject
注解:@HiltViewModel class MainViewModel @Inject constructor(private val numberRepository: NumberRepository) : ViewModel() { … }
-
修改
MainActivity
以注入MainViewModel
,删除之前删除的所有组件依赖项,并添加@AndroidEntryPoint
注解:@AndroidEntryPoint class MainActivity : AppCompatActivity() { private val mainViewModel: MainViewModel by viewModels() }
此步骤的完整代码可以在packt.link/k7hs7
找到。
在前面的代码片段中,我们使用viewModels
方法来获取MainViewModel
依赖项。这是androidx.fragment:fragment-ktx:1.5.5
扩展函数中内置的一种机制,它将寻找获取我们ViewModel
实例的工厂。
如果我们运行代码,我们应该看到以下输出:
图 13.4 – 练习 13.03 的输出
我们可以看到,使用 Hilt 而不是 Dagger 可以简化应用程序代码的程度。例如,我们不再需要处理被@Component
和@Subcomponent
注解的类,以及在应用程序组件中管理子组件,而且我们也不需要从Application
类手动初始化依赖图,因为 Hilt 为我们处理了这一点。这些都是 Hilt 成为 Android 应用程序中依赖注入最广泛采用的库的主要原因之一。
Koin
Koin 是一个适合小型应用程序的轻量级框架。它不需要代码生成,并且基于 Kotlin 的功能扩展构建。它也是一个领域特定语言(DSL)。您可能已经注意到,当使用 Dagger 时,必须编写大量代码来设置 DI。Koin 对 DI 的方法解决了这些问题中的大多数,允许更快地集成。
您可以通过将以下依赖项添加到build.gradle
文件中来将 Koin 添加到您的项目中:
implementation "io.insert-koin:koin-core:3.2.2"
要在您的应用程序中设置 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 的日志级别,这将根据级别(在androidLogger
方法中)在LogCat
结果中输出 Koin 操作的结果,并列出应用程序使用的模块。创建模块时使用类似的语法:
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))
}
}
在前面的示例中,两个对象将有两个不同的生命周期。当使用单例符号提供依赖项时,整个应用程序生命周期中只会使用一个实例。这对于仓库、数据库和 API 组件很有用,因为多个实例对应用程序来说成本高昂。
工厂符号将在每次执行注入时创建一个新的对象。在对象需要像活动或片段一样长时间存活的情况下,这可能很有用。
可以使用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 "io.insert-koin:koin-android:3.2.2"
如果你记得,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 的语言特性,并减少了定义你的模块及其作用域所需的样板代码量。
练习 13.04 – Koin 注入
在这里,我们将编写一个 Android 应用,该应用将使用 Koin 执行依赖注入。该应用将基于练习 13.01,手动注入,保留NumberRepository
,NumberRepositoryImpl
,MainViewModel
和MainActivity
。以下依赖项将被注入:
-
Repository
:作为名为appModule
的模块的一部分。 -
MainViewModel
:这将依赖于 Koin 为ViewModels
提供的专用实现。这将作为名为mainModule
的模块的一部分提供,并将具有MainActivity
的作用域。
执行以下步骤以完成练习:
-
每次点击按钮时,应用都会显示一个随机生成的数字。让我们先添加 Koin 库:
implementation "androidx.lifecycle: lifecycle-viewmodel-ktx:2.5.1" implementation "androidx.lifecycle: lifecycle-livedata-ktx:2.5.1" implementation "io.insert-koin:koin-android:3.2.2" implementation "io.insert-koin:koin-core:3.2.2" testImplementation 'junit:junit:4.13.2'
-
接下来,在
RandomApplication
类中定义appModule
变量。这将与 Dagger 设置中的AppModule
有类似的结构:class RandomApplication : Application() { val appModule = module { single { Random() } single<NumberRepository> { NumberRepositoryImpl(get()) } } }
-
现在,让我们在
appModule
之后添加活动模块变量:val mainModule = module { scope(named<MainActivity>()) { scoped { MainViewModel(get()) } } }
-
接下来,让我们在
RandomApplication
的onCreate()
方法中初始化Koin
:super.onCreate() startKoin { androidLogger() androidContext(this@RandomApplication) modules(listOf(appModule, mainModule)) }
-
最后,让我们将依赖注入到活动中:
class MainActivity : ScopeActivity() { private val mainViewModel: MainViewModel by inject() }
此步骤的完整代码可以在packt.link/0Njdv
找到。
-
如果你运行前面的代码,应用应该按照之前的示例工作。然而,如果你检查
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
在图 13.5中,我们可以看到与之前练习相同的输出:
图 13.5 – 练习 13.04 的模拟器输出,显示随机生成的数字
如我们从本练习中看到的那样,Koin 集成得更快、更简单,特别是与其ViewModel
库一起使用。这对于小型项目来说很有用,但一旦项目规模扩大,其性能将受到影响。
活动 13.01 – 注入仓库
在这个活动中,你将在 Android Studio 中创建一个应用,该应用通过 Retrofit 库连接到示例 API,jsonplaceholder.typicode.com/posts
,从网页上检索帖子列表,然后将其显示在屏幕上。
然后,你需要设置一个 UI 测试,检查数据是否正确地显示在屏幕上,但不是连接到实际端点,而是为测试提供用于在屏幕上显示的虚拟数据。你将使用 DI 概念,在应用执行时而不是在测试应用时,使用 Hilt 交换依赖。
为了实现这一点,你需要构建以下内容:
-
一个负责下载和解析 JSON 文件的网络组件
-
一个访问 API 层的数据的仓库
-
一个访问仓库的
ViewModel
实例 -
一个带有
RecycleView
的活动,用于显示数据 -
一个 UI 测试,将断言行并使用虚拟对象生成 API 数据
注意
对于这项活动,可以避免错误处理。
执行以下步骤以完成此活动:
-
在 Android Studio 中,创建一个带有
Empty Activity
(MainActivity
)的应用程序,并添加一个api
包,其中存储你的 API 调用。 -
定义一个负责 API 调用的类。
-
创建一个
repository
包。 -
定义一个包含一个方法、返回包含帖子列表的
LiveData
的repository
接口。 -
为
repository
类创建实现。 -
创建一个
ViewModel
实例来调用repository
以检索数据。 -
为 UI 的行创建适配器。
-
创建将渲染 UI 的活动。
-
设置一个初始化网络相关依赖的 Hilt 模块。
-
创建一个负责定义活动所需依赖的 Hilt 模块。
-
设置 UI 测试和测试应用程序,并提供一个单独的
RepositoryModule
类,该类将返回包含虚拟数据的依赖项。 -
实现 UI 测试。
注意
这个活动的解决方案可以在packt.link/3xfkt
找到。
摘要
在本章中,我们分析了 DI 的概念以及它应该如何应用于分离关注点,防止对象承担创建其他对象的责任,以及这对测试的巨大好处。我们通过分析手动 DI 的概念开始本章;这作为一个很好的例子,说明了 DI 是如何工作的以及它如何应用于 Android 应用程序;它作为比较 DI 框架的基准。
我们还分析了两个最受欢迎的框架,这些框架帮助开发者注入依赖。我们从名为 Dagger 2 的强大且快速的框架开始,它依赖于注解处理器来生成执行注入的代码。然后我们研究了 Hilt 如何简化 Android 应用的 Dagger 复杂性。我们还调查了 Koin,这是一个用 Kotlin 编写的轻量级框架,性能较慢但集成简单,并且大量关注 Android 组件。
本章的练习旨在探索如何使用多种解决方案来解决相同的问题,并比较这些解决方案的难度级别。在本章的活动实践中,我们利用 Dagger、Hilt 和 Koin 的模块在运行应用程序时注入某些依赖项,在运行使用ViewModels
、仓库和 API 加载数据的应用程序的测试时注入其他依赖项。
这部分旨在展示多个框架无缝集成的过程,这些框架实现不同的目标。在章节的活动中,我们探讨了如何使用 Hilt 在测试目的下交换依赖项,并注入我们可以断言是否显示在屏幕上的模拟数据。
在接下来的章节中,你将有机会通过添加与线程处理和后台操作相关的概念来构建迄今为止获得的知识。此外,你将有机会探索 RxJava 及其对线程的响应式方法,你还将了解协程,它采用不同的线程处理方法。
你还将观察到协程和 RxJava 如何与 Room 和 Retrofit 等库非常有效地结合。最后,你将能够将这些概念结合到一个健壮的应用程序中,该应用程序将具有很高的未来可扩展性。
第四部分:打磨和发布应用
在这部分中,我们将探讨如何使用协程和流异步加载数据,以及如何将它们集成到不同的架构模式中,这进一步有助于我们如何构建应用程序的代码结构。
接下来,我们将探讨如何使用CoordinatorLayout
和MotionLayout
在用户界面中渲染动画。最后,我们将了解在 Google Play 上发布应用程序的过程。
在本节中,我们将涵盖以下章节:
-
第十四章,协程和流
-
第十五章,架构模式
-
第十六章,使用 CoordinatorLayout 和 MotionLayout 进行动画和过渡
-
第十七章,在 Google Play 上发布您的应用
第十四章:协程和 Flow
本章介绍了使用协程和 Flow 进行后台操作和数据操作。你还将学习如何使用LiveData
转换和 Kotlin Flow 操作来操作和显示数据。
到本章结束时,你将能够使用协程和 Flow 在后台管理网络调用。你还将能够使用LiveData
转换和 Flow 操作来操作数据。
你已经学习了 Android 应用开发的基础知识,并实现了如 RecyclerViews、通知、从网络服务获取数据以及服务等功能。你还掌握了测试和持久化数据的最佳实践。在前一章中,你学习了依赖注入。现在,你将学习关于后台操作和数据操作的内容。
一些 Android 应用程序可以独立工作。然而,大多数应用程序都需要后端服务器来检索或处理数据。这些操作可能需要一段时间,具体取决于网络连接、设备设置和服务器规格。如果长时间运行的操作在主用户界面(UI)线程上运行,应用程序将被阻塞,直到任务完成。应用程序可能会变得无响应,并提示用户关闭并停止使用它。
为了避免这种情况,可能需要无限期时间的任务必须异步运行。异步任务意味着它可以与另一个任务并行运行或在后台运行。例如,在异步从数据源获取数据时,你的 UI 仍然可以显示,用户交互也可以发生。
你可以使用如协程和 Flow 之类的库来进行异步操作。我们将在本章中讨论这两个库。
本章我们将涵盖以下关键主题:
-
在 Android 上使用协程
-
转换
LiveData
-
在 Android 上使用 Flow
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,链接为 packt.link/puLUO
让我们开始学习协程。
在 Android 上使用协程
协程是在 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) { ... }
}
在下一节中,我们将介绍如何创建协程。
创建协程
你可以使用 async
和 launch
关键字创建协程。launch
关键字创建一个协程,不返回任何内容。另一方面,async
关键字返回一个值,你可以稍后使用 await
函数获取。
async
和 launch
关键字必须从 CoroutineScope
创建,它定义了协程的生命周期。例如,主线程的协程作用域是 MainScope
。然后你可以使用以下方式创建协程:
MainScope().async { ... }
MainScope().launch { ... }
你也可以通过使用 CoroutineScope
创建一个自己的 CoroutineScope
而不是使用 MainScope
。你可以通过使用 CoroutineScope
并传递协程的上下文来创建一个。例如,为了在网络调用中使用 CoroutineScope
,你可以定义以下内容:
val scope = CoroutineScope(Dispatchers.IO)
当函数不再需要时,例如关闭活动时,可以取消协程。你可以通过从 CoroutineScope
调用 cancel
函数来实现这一点:
scope.cancel()
ViewModel 还有一个用于创建协程的默认 CoroutineScope
:viewModelScope
。Jetpack 的 LifeCycle 也有你可以使用的 lifecycleScope
。当 ViewModel 被销毁时,viewModelScope
会被取消;当生命周期被销毁时,lifecycleScope
也会被取消。因此,你不再需要取消它们。
在下一节中,你将学习如何将协程添加到你的项目中。
添加协程到你的项目
你可以通过将以下代码添加到你的 app/build.gradle
文件依赖项中来将协程添加到你的项目中:
implementation 'org.jetbrains.kotlinx:
kotlinx-coroutines-core:1.6.4'
implementation 'org.jetbrains.kotlinx:
kotlinx-coroutines-android:1.6.4'
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.5.1'
让我们尝试在一个 Android 项目中使用协程。
练习 14.01 – 在 Android 应用中使用协程
对于本章,你将使用 The Movie Database API 显示热门电影的应用程序。前往 developers.themoviedb.org
并注册 API 密钥。在这个练习中,你将使用协程来获取热门电影列表:
-
在本书代码仓库的
Chapter14
目录中打开 Android Studio 中的Popular Movies
项目。 -
打开
AndroidManifest.xml
文件并在 manifest 标签内但不在 application 标签外添加INTERNET
权限:<uses-permission android:name="android.permission .INTERNET" />
-
打开
app/build.gradle
文件并添加 Kotlin 协程的依赖项:implementation 'org.jetbrains.kotlinx: kotlinx-coroutines-core:1.6.4' implementation 'org.jetbrains.kotlinx: kotlinx-coroutines-android:1.6.4'
这些将允许你在项目中使用协程。
-
此外,添加 ViewModel 和
LiveData
扩展库的依赖项:implementation 'androidx.lifecycle: lifecycle-livedata-ktx:2.5.1' implementation 'androidx.lifecycle: lifecycle-viewmodel-ktx:2.5.1'
-
打开
MovieService
接口并将其替换为以下代码:interface MovieService { @GET("movie/popular") suspend fun getPopularMovies(@Query("api_key") apiKey: String): PopularMoviesResponse }
这将 getPopularMovies
标记为挂起函数。
-
打开
MovieRepository
并添加apiKey
(使用来自电影数据库 API 的值):private val apiKey = "your_api_key_here"
-
在
MovieRepository
文件中,为电影列表添加电影和错误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
-
添加挂起的
fetchMovies
函数以从端点检索列表:suspend fun fetchMovies() { try { val popularMovies = movieService.getPopularMovies(apiKey) movieLiveData.postValue(popularMovies .results) } catch (exception: Exception) { errorLiveData.postValue( "An error occurred: ${exception.message}") } }
-
打开
MovieApplication
并为movieRepository
添加一个属性:class MovieApplication: Application() { lateinit var movieRepository: MovieRepository }
-
覆盖
MovieApplication
类的onCreate
函数并初始化movieRepository
:override fun onCreate() { super.onCreate() val retrofit = Retrofit.Builder() .baseUrl("https://api.themoviedb.org/3/") .addConverterFactory( MoshiConverterFactory.create()) .build() val movieService = retrofit.create( MovieService::class.java) movieRepository = MovieRepository(movieService) }
-
使用以下代码更新
MovieViewModel
的内容:init { fetchPopularMovies() } val popularMovies: LiveData<List<Movie>> get() = movieRepository.movies val error: LiveData<String> = movieRepository.error private fun fetchPopularMovies() { viewModelScope.launch(Dispatchers.IO) { movieRepository.fetchMovies() } }
fetchPopularMovies
函数有一个协程,使用 viewModelScope
从 movieRepository
获取电影。
-
打开
MainActivity
类。在onCreate
函数的末尾,创建movie
仓库 和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 } })[MovieViewModel::class.java]
-
之后,向
movieViewModel
的popularMovies
和error
LiveData
添加观察者:movieViewModel.popularMovies.observe(this) { popularMovies -> movieAdapter.addMovies(popularMovies .filter { it.releaseDate.startsWith( Calendar.getInstance() .get(Calendar.YEAR) .toString() ) } .sortedByDescending { it.popularity } ) } movieViewModel.error.observe(this) { error -> if (error.isNotEmpty()) Snackbar.make( recyclerView, error, Snackbar .LENGTH_LONG).show() }
这将更新活动中的 RecyclerView,显示获取到的电影。电影列表使用 Kotlin 的 filter
函数进行过滤,仅包括今年上映的电影。然后使用 Kotlin 的 sortedByDescending
函数按受欢迎程度排序。
- 运行应用程序。你会看到应用程序将显示当前年份的热门电影列表,按受欢迎程度排序:
图 14.1 – 显示按受欢迎程度排序的今年上映热门电影的 app
- 点击一部电影,你将看到其详细信息,例如上映日期和概述:
图 14.2 – 电影详情屏幕
你已经使用了协程和 LiveData
来从远程数据源检索并显示热门电影列表,而不会阻塞主线程。
在将 LiveData
传递到 UI 进行显示之前,你也可以先转换数据。你将在下一节中了解这一点。
转换 LiveData
有时候,从 ViewModel 传递到 UI 层的 LiveData
需要先进行处理,然后再显示。例如,你可能只能选择数据的一部分,或者先对其进行一些处理。在之前的练习中,你过滤了数据,只选择了当年流行的电影。
要修改 LiveData
,你可以使用 Transformations
类。它有两个函数,Transformations.map
和 Transformations.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
转换对使用协程获取的电影列表进行转换。
练习 14.02 – LiveData 转换
在这个练习中,你将在将 LiveData
列表传递给 MainActivity
文件中的观察者之前对其进行转换:
-
打开你在之前练习中在 Android Studio 中工作的
Popular Movies
项目。 -
打开
MainActivity
文件。在onCreate
函数中的movieViewModel.popularMovies
观察者中,移除过滤和sortedByDescending
函数调用。代码应如下所示:movieViewModel.getPopularMovies().observe(this, Observer { popularMovies -> movieAdapter.addMovies(popularMovies) })
现在,将显示列表中的所有电影,而不会按流行度排序。
- 运行应用程序。你应该看到所有电影(包括去年的电影),但不会按流行度排序:
图 14.3 – 未排序的流行电影应用
-
打开
MovieViewModel
类,并使用LiveData
转换来过滤和排序电影:val popularMovies: LiveData<List<Movie>> get() = movieRepository.movies.map { list -> list.filter { val cal = Calendar.getInstance() it.releaseDate.startsWith( "${cal.get(Calendar.YEAR)}" ) }.sortedByDescending { it.popularity } }
这将选择当年发布的电影,并按标题排序后再传递给 MainActivity
中的 UI 观察者。
- 运行应用程序。你会看到应用显示了一个按流行度排序的当年流行电影列表:
图 14.4 – 按流行度排序的上个月发布的电影应用
你已经使用了 LiveData
转换来修改电影列表,只选择上个月发布的电影。在将它们传递给 UI 层的观察者之前,它们也按受欢迎程度进行了排序。
在下一节中,你将了解 Kotlin 流。
在 Android 上使用 Flow
在本节中,你将探讨在 Android 中使用流进行异步编程。Flow 是一个基于 Kotlin 协程构建的异步流库,非常适合在应用程序中更新实时数据。Android Jetpack 库包括 Room、WorkManager 和 Jetpack Compose,第三方库支持 Flow。
数据流由 kotlinx.coroutines.flow.Flow
接口表示。流一次发射相同类型的一个值。例如,Flow<String>
是一个发射字符串值的流。
当你在协程或另一个挂起函数中调用挂起的 collect
函数时,流开始发射值。在下面的示例中,collect
函数是从使用 lifecycleScope
的 launch
构建器创建的协程中调用的:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.fetchMovies().collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
class MovieViewModel : ViewModel() {
...
fun fetchMovies(): Flow<Movie> { ... }
}
在这里,对 viewModel.fetchMovies()
调用了 collect{}
函数。这将启动 Flow 的电影发射;然后记录下每部电影的标题。
要更改 Flow 运行的 CoroutineContext
,你可以使用 flowOn()
函数来更改调度器。前面的示例可以使用不同的调度器进行更新,如下面的代码所示:
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.fetchMovies()
.flowOn(Dispatchers.IO)
.collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
在这个示例中,Flow 的调度器将被更改为 Dispatchers.IO
。调用 flowOn
只会更改它之前的函数,而不会更改它之后的函数和操作符。
在下一节中,你将学习如何在 Android 上收集流。
在 Android 上收集流
在 Android 中,流通常在 Activity 或 Fragment 中收集以在 UI 中显示。将应用移至后台不会停止数据收集。应用不应这样做,并继续更新屏幕以避免内存泄漏和防止资源浪费。
你可以通过手动处理生命周期变化或使用 lifecycle-runtime-ktx
库中的 Lifecycle.repeatOnLifecycle
和 Flow.flowWithLifecycle
(从版本 2.4.0 开始提供)来安全地在 UI 层收集流。
要在项目中使用它,请将以下内容添加到你的 app/build.gradle
依赖项中:
implementation 'androidx.lifecycle:
lifecycle-runtime-ktx:2.4.1'
这会将 lifecycle-runtime-ktx
库添加到你的项目中,这样你就可以使用 Lifecycle.repeatOnLifecycle
和 Flow.flowWithLifecycle
。
Lifecycle.repeatOnLifecycle(state, block)
将挂起父协程,直到生命周期被销毁,并在生命周期至少处于提供的 state
时执行挂起的 block
代码。当生命周期离开该状态时,流将停止,当生命周期返回该状态时,流将重新启动。Lifecycle.repeatOnLifecycle
必须在 Activity 的 onCreate
或 Fragment 的 onViewCreated
中调用。
当使用 Lifecycle.State.STARTED
作为 state
时,repeatOnLifecycle
将在 Lifecycle 开始时开始 Flow 收集,并在 Lifecycle 停止时停止(调用 onStop()
)。
如果你使用 Lifecycle.State.RESUMED
,开始将在 Lifecycle 恢复时,停止将在 onPause
被调用或 Lifecycle 暂停时。
以下示例展示了如何使用 Lifecycle.repeatOnLifecycle
:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.fetchMovies()
.collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
}
在这个类中,repeatOnLifecycle
与 Lifecycle.State.STARTED
一起使用,当生命周期开始时开始收集电影 Flow,当生命周期停止时停止。
Flow.flowWithLifecycle
是在 Android 中安全收集 Flows 的另一种方式。它在生命周期至少处于你设置的或默认的 Lifecycle.State.STARTED
状态时,从 Flow 发射值和调用之前的操作符(上游 Flow)。内部使用 Lifecycle.repeatOnLifecycle
。以下示例展示了如何使用 Flow.flowWithLifecycle
:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.fetchMovies()
.flowWithLifecycle(lifecycle,
Lifecycle.State.STARTED)
.collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
这里,我们使用了 flowWithLifecycle
与 Lifecycle.State.STARTED
来在生命周期开始时收集电影 Flow,并在生命周期停止时停止。
在下一节中,你将学习如何创建 Flows。
使用 Flow Builders 创建 Flows
你可以使用 Kotlin Flow API 中的 Flow Builders 来创建 Flows。以下是可以使用的 Flow Builders:
-
flow{}
: 这会从一个可挂起的 lambda 块创建一个新的 Flow。你可以使用emit
函数发送值。 -
flowOf()
: 这会从指定的值或vararg
值创建一个 Flow。 -
asFlow()
: 这是一个扩展函数,用于将类型(序列、数组、范围或集合)转换为 Flow。
以下示例展示了如何在应用程序中使用 Flow Builders:
class MovieViewModel : ViewModel() {
...
fun fetchMovies: Flow<List<Movie>> = flow {
fetchMovieList().forEach { movie - > emit(movie) }
}
fun fetchTop3Titles: Flow<List<String>> {
val movies = fetchTopMovies()
return flowOf(movies[0].title,
movies[1].title, movies[2].title)
}
fun fetchMovieIds: Flow<Int> {
return fetchMovies().map { it.id }.asFlow()
}
}
在这个例子中,fetchMovies
使用 flow{}
创建了一个 Flow,并从列表中发射了每部电影。fetchTop3Titles
函数使用 flowOf
创建了一个包含前三部电影标题的 Flow。最后,fetchMovieIds
使用 asFlow
函数将 ID 列表转换成了电影 ID 的 Flow。
在下一节中,你将了解你可以与 Flows 一起使用的 Kotlin Flow 操作符。
使用 Flows 的操作符
Flows 有一些内置的操作符可以与 Flows 一起使用。你可以使用终端操作符收集 Flows,并使用中间操作符转换 Flows。
终端操作符,如前例中使用的 collect
函数,用于收集 Flows。以下是可以使用的其他终端操作符:
-
count
-
first
和firstOrNull
-
last
和lastOrNull
-
fold
-
reduce
-
single
和singleOrNull
-
toCollection
、toList
和toSet
这些操作符与 Kotlin Collection
函数同名操作符的工作方式类似。
你可以使用中间操作符来修改 Flow 并返回一个新的 Flow。它们也可以链式使用。以下中间操作符与 Kotlin 集合函数同名操作符的工作方式相同:
-
filter
、filterNot
、filterNotNull
和filterIsInstance
-
map
和mapNotNull
-
onEach
-
runningReduce
和runningFold
-
withIndex
此外,还有一个 transform
操作符可以用来应用你自己的操作。例如,这个类有一个使用 transform
操作符的 Flow:
class MovieViewModel : ViewModel() {
...
fun fetchTopRatedMovie(): Flow<Movie> {
return fetchMoviesFlow()
.transform {
if(it.voteAverage > 0.6f) emit(it)
}
}
}
在这里,使用了 transform
操作符在电影 Flow 中仅发射 voteAverage
大于 0.6
(60%)的电影。
此外,还有大小限制的 Kotlin Flow 操作符,如 drop
、dropWhile
、take
和 takeWhile
,它们的功能与 Kotlin 集合函数同名类似。
让我们将 Kotlin Flow 添加到 Android 项目中。
练习 14.03 – 在 Android 应用中使用 Flow
在这个练习中,你将更新流行电影应用以使用 Kotlin Flow 来获取电影列表:
-
打开 Android Studio 中上一个练习的“流行电影”项目。
-
前往
MovieRepository
类,删除movies
和error
LiveData
。然后,用以下内容替换fetchMovies
函数:fun fetchMovies(): Flow<List<Movie>> { return flow { emit(movieService .getPopularMovies(apiKey).results) }.flowOn(Dispatchers.IO) }
这将修改 fetchMovies
函数以使用 Kotlin Flow。Flow 将从 movieService.getPopularMovies
发射电影列表,并在 Dispatchers.IO
调度器上流动。
-
打开
MovieViewModel
类。在类声明中,添加一个具有默认值Dispatchers.IO
的调度器参数:class MovieViewModel( private val movieRepository: MovieRepository, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { ... }
这将是之后用于 Flow 的调度器。
-
将
popularMovies
LiveData
替换为以下内容:private val _popularMovies = MutableStateFlow( emptyList<Movie>()) val popularMovies: StateFlow<List<Movie>> = _popularMovies
你将使用这些来获取 MovieRepository
中电影列表的值。StateFlow
是一个可观察的 Flow,它向收集器发射状态更新,而 MutableStateFlow
是一个可以更改值的 StateFlow
。在 Android 中,StateFlow
可以作为 LiveData
的替代品。
-
删除
error
LiveData
并替换为以下内容:private val _error = MutableStateFlow("") val error: StateFlow<List<String>> =_error
你将使用这些来处理 Flow 遇到异常的情况。
-
将
fetchPopularMovies
函数的内容更改为以下内容:private fun fetchPopularMovies() { viewModelScope.launch(dispatcher) { movieRepository.fetchMovies().catch { _error.value = "An exception occurred: ${it.message}" }.collect { _popularMovies.value = it } } }
这将收集来自 movieRepository
的电影列表并将其设置到 _popularMovies
中的 MutableStateFlow
(以及 popularMovies
中的 StateFlow
)。
-
打开
app/build.gradle
文件,并在依赖项中添加以下内容:implementation 'androidx.lifecycle: lifecycle-runtime-ktx:2.5.1'
这允许你在 MainActivity
中使用 lifecycleScope
来收集 Flows。
-
前往
MainActivity
文件,从MovieViewModel
中删除观察popularMovies
和error
的代码行。添加以下内容以收集来自MovieViewModel
的 Flow:lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { movieViewModel.popularMovies.collect { movies ->movieAdapter.addMovies( movies) } } launch { movieViewModel.error.collect { error -> if (error.isNotEmpty()) Snackbar .make(recyclerView, error, Snackbar .LENGTH_LONG).show() } } } }
-
运行应用程序。应用将显示电影列表,如下面的截图所示:
图 14.5 – 显示流行电影的 app
在这个练习中,你将 Kotlin Flow 添加到 Android 项目中。MovieRepository
返回电影列表作为 Flow,然后在 MovieViewModel
中收集。MovieViewModel
使用 StateFlow
,然后 MainActivity
收集它以在 RecyclerView 中显示。
让我们继续下一个活动。
活动十四点零一 - 创建电视指南应用
许多人观看电视。不过,大多数时候,他们不确定当前正在播出的电视节目是什么。假设你想开发一个应用程序,该应用程序可以使用 Kotlin Flow 从 Movie Database API 的 tv/on_the_air
端点显示这些节目的列表。
应用程序将有两个屏幕:主屏幕和详情屏幕。在主屏幕上,你将显示正在播出的电视节目列表。电视节目将按名称排序。点击一个电视节目将打开详情屏幕,该屏幕显示所选电视节目的更多信息。
以下步骤是为了完成活动:
-
在 Android Studio 中创建一个新的项目,命名为
TV Guide
。设置其包名。 -
在
AndroidManifest.xml
文件中添加INTERNET
权限。 -
在你的
app/build.gradle
文件中添加 Retrofit、Coroutines、Moshi、Lifecycle 和其他库的依赖项。 -
添加一个
layout_margin
尺寸值。 -
创建一个
view_tv_show_item.xml
布局文件,其中包含用于海报的ImageView
和用于电视节目名称的TextView
。 -
在
activity_main.xml
文件中,删除Hello World
TextView 并将 RecyclerView 添加到电视节目列表中。 -
创建一个
TVShow
模型类。 -
为从 API 端点获取正在播出的电视节目的响应创建另一个名为
TVResponse
的类。 -
创建一个名为
DetailsActivity
的新活动,其布局文件为activity_details.xml
。 -
打开
AndroidManifest.xml
文件,并在DetailsActivity
声明中添加parentActivityName
属性。 -
在
activity_details.xml
中添加电视节目详情的视图。 -
打开
DetailsActivity
并添加显示所选电视节目详情的代码。 -
为电视节目列表创建一个
TVShowAdapter
适配器类。 -
创建一个
TelevisionService
类以添加Retrofit
方法。 -
创建一个
TVShowRepository
类,其中包含tvService
构造函数以及apiKey
和tvShows
属性。 -
创建一个函数以从端点检索电视节目列表。
-
创建一个
TVShowViewModel
类,其中包含TVShowRepository
构造函数。添加tvShows
和error
StateFlow 以及一个fetchTVShows
函数,该函数收集来自存储库的 Flow。 -
创建一个名为
TVApplication
的应用程序类,其中包含TVShowRepository
属性。 -
在
AndroidManifest.xml
文件中将TVApplication
设置为应用程序的值。 -
打开
MainActivity
并添加代码以在ViewModel
的 Flow 更新其值时更新 RecyclerView。添加一个函数,当点击列表中的电视节目时将打开详情屏幕。 -
运行你的应用程序。应用程序将显示电视节目列表。点击一个电视节目将打开详情活动,该活动显示节目详情。主屏幕和详情屏幕将与以下截图类似:
图 14.6 – TV Guide 应用程序的主屏幕和详情屏幕
注意
这个活动的解决方案可以在 packt.link/By7eE
找到。
摘要
本章重点介绍了使用 Kotlin 协程和 Flow 进行后台操作。后台操作用于长时间运行的任务,例如从本地数据库或远程服务器访问数据。
你从 Kotlin 协程的基本用法开始,这是 Google 推荐的异步编程解决方案。你了解到你可以使用 suspend
关键字将后台任务转换为挂起函数。协程可以通过 async
或 launch
关键字启动。
你学习了如何创建挂起函数以及如何启动协程。你还使用了调度器来改变协程运行的线程。然后,你使用协程进行网络调用,并使用 map
和 switchMap
LiveData
转换函数修改检索到的数据。
然后,你转向在 Android 应用中使用 Kotlin 流来在后台加载数据。为了在 UI 层面上安全地收集流程,防止内存泄漏,并避免资源浪费,你可以使用 Lifecycle.repeatOnLifecycle
和 Flow.flowWithLifecycle
。
你学习了如何使用 Flow Builders 来创建流程。flow
构建函数从一个挂起 lambda 块创建一个新的流程,然后你可以通过 emit()
发送值。flowOf
函数创建一个发出值或 vararg
值的流程。你可以使用 asFlow()
扩展函数将集合和函数类型转换为流程。
最后,你探索了 Flow 操作符,并学习了如何在 Kotlin 流中使用它们。终端操作符用于启动流的收集。通过中间操作符,你可以将一个流程转换成另一个流程。
在下一章中,你将学习关于架构模式的内容。你将学习诸如 模型-视图-视图模型(MVVM)的模式,以及你如何改进你应用的架构。
第十五章:架构模式
本章将介绍你可以用于你的 Android 项目的架构模式。它涵盖了使用 模型-视图-视图模型(MVVM)模式、添加视图模型和使用数据绑定。你还将了解使用存储库模式进行数据缓存和使用 WorkManager 在计划的时间间隔内检索和存储数据。
到本章结束时,你将能够使用 MVVM 和数据绑定来构建你的 Android 项目结构。你还将能够使用 Room 库的存储库模式来缓存数据,并使用 WorkManager 在计划的时间间隔内检索和存储数据。
在上一章中,你学习了如何使用协程和流进行后台操作和数据操作。现在,你将学习架构模式,以便你可以改进你的应用。
在开发 Android 应用时,你可能倾向于在活动或片段中编写大部分代码(包括业务逻辑)。这将使你的项目在以后难以测试和维护。随着你的项目增长和变得更加复杂,难度也会增加。你可以通过使用架构模式来改进你的项目。
架构模式是设计和发展应用程序部分的一般解决方案,特别是对于大型应用。你可以使用架构模式将你的项目结构化成不同的层(表示层、用户界面(UI)层和数据层)或功能(观察者/可观察)。使用架构模式,你可以以使开发、测试和维护更容易的方式进行代码组织。
对于 Android 开发,常用的模式包括 模型-视图-控制器(MVC)、模型-视图-表示者(MVP)和 MVVM。推荐的架构模式是 MVVM,这将在本章中讨论。你还将了解数据绑定、使用 Room 库的存储库模式和工作管理器。
本章我们将涵盖以下主题:
-
开始使用 MVVM
-
在 Android 上使用数据绑定绑定数据
-
使用 Retrofit 和 Moshi
-
实现存储库模式
-
使用 WorkManager
技术要求
本章中所有练习和活动的完整代码可在 GitHub 上找到,网址为 packt.link/PZNNT
开始使用 MVVM
MVVM 允许你分离 UI 和业务逻辑。当你需要重新设计 UI 或更新模型/业务逻辑时,你只需触摸相关组件,而不会影响你应用的其他组件。这将使你更容易添加新功能和测试现有代码。MVVM 在创建使用大量数据和视图的巨大应用中也很有用。
使用 MVVM 架构模式,你的应用将被分为三个组件:
-
模型:这代表数据层
-
视图:这是显示数据的 UI
-
Model
和将其提供给View
通过以下图表可以更好地理解 MVVM 架构模式:
图 15.1 – MVVM 架构模式
模型包含应用程序的数据。用户看到并与之交互的活动、片段和布局是 MVVM 中的视图。视图只处理应用程序的外观。它们让ViewModel
知道用户操作(如打开活动或点击按钮)。
ViewModel 链接View
和Model
。ViewModel 还执行业务逻辑处理,并将它们转换为在视图中显示。视图订阅 ViewModel,并在值更改时更新 UI。
您可以使用 Jetpack 的 ViewModel 为您的应用程序创建 ViewModel 类。Jetpack 的 ViewModel 管理自己的生命周期,因此您不需要自己处理它。
您可以通过在您的app/build.gradle
文件中添加以下代码将 ViewModel 添加到项目中:
implementation 'androidx.lifecycle:
lifecycle-viewmodel-ktx:2.5.1'
例如,如果您正在开发一个显示电影的 App,您可能有一个MovieViewModel
。这个 ViewModel 将有一个函数来获取电影列表:
class MovieViewModel : ViewModel() {
private val _movies: MutableStateFlow<List<Movie>>
fun movies: StateFlow<List<Movie>> { ... }
...
}
在您的活动中,您可以使用ViewModelProvider
创建 ViewModel:
class MainActivity : AppCompatActivity() {
private val movieViewModel by lazy {
ViewModelProvider(this).get(MovieViewModel::
class.java)
}
...
}
然后,您可以从ViewModel
连接到movies
Flow,并在电影列表更改时自动更新 UI 上的列表:
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
movieViewModel.popularMovies.collect {
movies ->
movieAdapter.addMovies(movies)
}
}
}
}
...
}
当ViewModel
中的值发生变化时,视图会收到通知。您还可以使用数据绑定将View
与ViewModel
中的数据连接起来。您将在下一节中了解更多关于数据绑定的信息。
使用数据绑定在 Android 上绑定数据
视图绑定和数据绑定是两种将数据绑定到 Android 视图的方法。视图绑定是一种更简单、更快的绑定方式,您可以使用它来替换代码中的findViewById
。数据绑定功能更强大,可以自定义以将数据与布局变量和表达式连接起来。
使用数据绑定,您可以将布局中的视图与来自源(如 ViewModel)的数据连接起来。您不需要在布局文件中添加代码来查找视图并在 ViewModel 的值更改时更新它们,数据绑定可以自动为您处理这些操作。
要在您的 Android 项目中使用数据绑定,您应该在app/build.gradle
文件的android
块中添加以下内容:
buildFeatures {
dataBinding true
}
在layout
文件中,您必须使用layout
标签包装根元素。在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>
movie
布局变量代表将在布局中显示的com.example.model.Movie
类。要将属性设置为数据模型中的字段,您需要使用@{}
语法。例如,要使用电影的标题作为TextView
的文本值,您可以使用以下代码:
<TextView
...
android:text="@{movie.title}"/>
你还需要更改你的活动文件。如果你的layout
文件名为activity_movies.xml
,数据绑定库将在你的项目构建文件中生成一个名为ActivityMoviesBinding
的绑定类。在活动中,你可以将setContentView(R.layout.activity_movies)
行替换为以下内容:
val binding: ActivityMoviesBinding = DataBindingUtil
.setContentView(this, R.layout.activity_movies)
你也可以使用binding
类或DataBindingUtil
类的inflate
方法:
val binding: ActivityMoviesBinding = ActivityMoviesBinding
.inflate(getLayoutInflater())
然后,你可以使用名为movie
的布局变量将movie
实例绑定到布局中:
val movieToDisplay = ...
binding.movie = movieToDisplay
如果你使用LiveData
或Flow
作为绑定到布局的项目,你需要为binding
变量设置lifeCycleOwner
。lifeCycleOwner
指定了对象的范围。你可以使用活动作为binding
类的lifeCycleOwner
:
binding.lifeCycleOwner = this
这样,当ViewModel
中的值发生变化时,View
将自动使用新值更新。
你使用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 项目中实现数据绑定。
练习 15.01 - 在 Android 项目中使用数据绑定
在上一章中,你使用 Movie Database API 开发了一个显示热门电影的程序。在本章中,你将使用 MVVM 来改进这个应用。你可以使用上一章的“热门电影”项目,或者复制它。在这个练习中,你将为从ViewModel
到 UI 绑定电影列表添加数据绑定:
-
在 Android Studio 中打开“热门电影”项目。
-
打开
app/build.gradle
文件,并在android
块中添加以下内容:buildFeatures { dataBinding true }
这将启用你的应用程序的数据绑定。
-
在你的
app/build.gradle
文件中的插件块末尾添加kotlin-kapt
插件:plugins { ... id 'kotlin-kapt' }
kotlin-kapt
插件是 Kotlin 注解处理工具,它是使用数据绑定所必需的。
-
创建一个名为
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
。
-
打开
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>
这样,数据绑定库将能够为这个布局生成一个绑定类。
-
在
layout
标签和ConstraintLayout
标签之前,添加一个数据元素,包含一个名为viewModel
的变量:<data> <variable name="viewModel" type="com.example.popularmovies .MovieViewModel" /> </data>
这创建了一个与你的MovieViewModel
类对应的viewModel
布局变量。
-
在
RecyclerView
中,使用app:list
添加要显示的列表:app:list="@{viewModel.popularMovies}"
popularMovies
从 MovieViewModel.getPopularMovies
将作为电影列表传递给 RecyclerView
。
-
打开
MainActivity
。在onCreate
函数中,将setContentView
行替换为以下内容:val binding: ActivityMainBinding = DataBindingUtil .setContentView(this, R.layout.activity_main)
这设置了要使用的 layout
文件并创建了一个绑定对象。
-
从
movieViewModel
中删除popularMoviesView
的集合。 -
在
movieViewModel
初始化之后添加以下代码:binding.viewModel = movieViewModel binding.lifecycleOwner = this
这将 movieViewModel
绑定到 activity_main.xml
文件中的 viewModel
布局变量。
- 运行应用程序。它应该像往常一样工作,显示热门电影的列表,点击其中一项将打开所选电影的详细信息:
图 15.2 – 主屏幕(左侧)显示当年的热门电影和详细信息屏幕(右侧)显示所选电影的更多信息
在这个练习中,您已经在 Android 项目中使用了数据绑定。
数据绑定将视图与 ViewModel 连接起来。ViewModel 从模型中检索数据。您可以使用的某些库来获取数据是 Retrofit 和 Moshi,您将在下一节中了解更多关于它们的信息。
使用 Retrofit 和 Moshi
当连接到您的远程网络时,您可以使用 Retrofit。Retrofit 是一个 HTTP 客户端,它使实现向后端服务器发送请求和检索响应变得容易。
您可以通过在 app/build.gradle
文件依赖项中添加以下代码将 Retrofit 添加到您的项目中:
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
您可以使用 Moshi,一个将 JSON 解析为 Java 对象的库,将 Retrofit 的 JSON 响应进行转换。例如,您可以将获取电影列表的 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 调用数据层。为了减少其复杂性,您可以使用 Repository 模式来加载和缓存数据。您将在下一节中了解这一点。
实现 Repository 模式
而不是直接由 ViewModel
调用获取和存储数据的服务,它应该将这项任务委托给另一个组件,例如仓库。
使用 Repository 模式,您可以将处理数据层的代码从 ViewModel
中移动到单独的类。这减少了 ViewModel
的复杂性,使其更容易维护和测试。仓库将管理数据的获取和存储,就像使用本地数据库或网络服务来获取或存储数据一样:
图 15.3 – 带有 Repository 模式的 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.4.3'
implementation 'androidx.room:room-ktx:2.4.3'
kapt 'androidx.room:room-compiler:2.4.3'
让我们尝试将 Repository 模式和 Room 添加到 Android 项目中。
练习 15.02 – 在 Android 项目中使用 Room 的 Repository
在上一个练习中,你已经在热门电影
项目中添加了数据绑定。在这个练习中,你将使用 Repository 模式来更新应用。
当打开应用时,它会从网络上获取电影列表。这需要一些时间。每次你获取这些数据时,你都会将它们缓存到本地数据库中。当用户下次打开应用时,应用将立即在屏幕上显示从数据库获取的电影列表。你将使用 Room 进行数据缓存:
-
打开你在上一个练习中使用的
热门电影
项目。 -
打开
app/build.gradle
文件,并添加 Room 库的依赖项:implementation 'androidx.room:room-runtime:2.4.3' implementation 'androidx.room:room-ktx:2.4.3' kapt 'androidx.room:room-compiler:2.4.3'
-
打开
Movie
类,并为它添加一个Entity
注解:@Entity(tableName = "movies", primaryKeys = [("id")]) data class Movie( ... )
Entity
注解将为电影列表创建一个名为movies
的表。它还将id
设置为表的键。
-
创建一个新的包
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> }
这个类包含一个用于在数据库中添加电影列表的函数,以及一个用于从数据库获取所有电影的函数。
-
在
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
函数来生成数据库的实例。
-
更新
MovieRepository
类,为movieDatabase
添加构造函数:class MovieRepository(private val movieService: MovieService, private val movieDatabase: MovieDatabase) { ... }
-
更新
fetchMovies
函数:fun fetchMovies(): Flow<List<Movie>> { return flow { val movieDao: MovieDao = movieDatabase.movieDao() val savedMovies = movieDao.getMovies() if(savedMovies.isEmpty()) { val movies = movieService .getPopularMovies(apiKey).results movieDao.addMovies(movies) emit(movies) } else { emit(savedMovies) } }.flowOn(Dispatchers.IO) }
它将从数据库中获取电影。如果没有保存任何内容,它将从一个网络端点检索列表并将其保存。
-
打开
MovieApplication
,在onCreate
函数中,将movieRepository
的初始化替换为以下内容:val movieDatabase = MovieDatabase.getInstance(applicationContext) movieRepository = MovieRepository(movieService, movieDatabase)
-
运行应用。它将显示流行电影的列表,点击其中一个将打开所选电影的详细信息。如果你关闭移动数据或断开无线网络连接,它仍然会显示电影列表,这些电影现在已缓存在数据库中:
图 15.4 – 使用 Repository 和 Room 的 Popular Movies 应用
在这个练习中,你通过将数据的加载和存储移动到仓库来改进了应用。你还使用了 Room 来缓存数据。
仓库从数据源获取数据。如果数据库中还没有存储数据,应用将调用网络请求数据。这可能需要一段时间。你可以通过在预定时间预取数据来改善用户体验,这样当用户下次打开应用时,他们已经可以看到更新后的内容。你可以使用 WorkManager 来实现这一点,我们将在下一节中讨论。
使用 WorkManager
WorkManager 是一个用于后台操作的 Jetpack 库,它可以延迟执行,可以根据你设置的约束条件运行。它非常适合执行必须运行但可以稍后或定期执行的任务,无论应用是否正在运行。
你可以使用 WorkManager 在预定的时间间隔运行任务,例如从网络获取数据并将其存储到你的数据库中。即使应用已被关闭或设备重启,WorkManager 也会运行任务。这将确保你的数据库与后端保持最新。
你可以通过将以下代码添加到你的 app/build.gradle
文件依赖项中来将 WorkManager 添加到你的项目中:
implementation 'androidx.work:work-runtime:2.7.1'
WorkManager 可以调用仓库从本地数据库或网络服务器获取和存储数据。
让我们尝试将 WorkManager 添加到 Android 项目中。
练习 15.03 – 将 WorkManager 添加到 Android 项目
在上一个练习中,你通过 Room 将 Repository 模式添加到本地数据库中缓存数据。现在,应用可以从数据库而不是网络中获取数据。现在,你将添加 WorkManager 来安排从服务器获取数据并将其保存到数据库的定时任务:
-
打开你在上一个练习中使用的 Popular Movies 项目。
-
打开
app/build.gradle
文件并添加 WorkManager 库的依赖项:implementation 'androidx.work:work-runtime:2.7.1'
这将允许你将 WorkManager 工作者添加到你的应用中。
-
打开
MovieRepository
并添加一个用于从网络使用movieDatabase
中的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) { Log.d("MovieRepository", "An error occurred: ${exception.message}") } }
这将是 Worker
类将要调用的函数,用于检索和保存电影。
-
在
com.example.popularmovies
包中创建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() } }
-
打开
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
将从网络获取电影列表并将其保存到本地数据库中。
- 运行应用程序。关闭它并确保设备已连接到互联网。超过一小时后,再次打开应用程序并检查显示的电影列表是否已更新。如果没有,几小时后再试。显示的电影列表将定期更新,大约每小时更新一次,即使应用程序已关闭。
图 15.5 – 使用 WorkManager 更新热门电影应用程序的列表
在这个练习中,你向应用程序添加了 WorkManager,以自动使用从网络检索的电影列表更新数据库。
活动十五点零一 - 重访电视指南应用程序
在上一章中,你开发了一个可以显示正在播出的电视剧列表的应用程序。该应用程序有两个屏幕:主屏幕和详情屏幕。在主屏幕上,有一个电视剧列表。点击电视剧时,将显示选中电视剧的详情屏幕。
当运行应用程序时,显示节目列表需要一段时间。更新应用程序以缓存列表,以便在打开应用程序时立即显示。此外,通过使用数据绑定和添加 WorkManager 来改进应用程序。
你可以使用上一章中工作的电视指南应用程序或从 GitHub 仓库(packt.link/Eti8M
)下载它。以下步骤将帮助你完成此活动:
-
在 Android Studio 中打开电视指南应用程序。打开
app/build.gradle
文件并添加kotlin-kapt
插件、数据绑定依赖项以及 Room 和 WorkManager 的依赖项。 -
为
RecyclerView
创建一个绑定适配器类。 -
在
activity_main.xml
中,将所有内容包裹在一个layout
标签内。 -
在
layout
标签内和ConstraintLayout
标签之前,添加一个数据元素,其中包含ViewModel
的变量。 -
在
RecyclerView
中,使用app:list
添加要显示的列表。 -
在
MainActivity
中,将setContentView
的行替换为DataBindingUtil.setContentView
函数。 -
将
TVShowViewModel
中的观察者替换为数据绑定代码。 -
在
TVShow
类中添加Entity
注解。 -
为访问
TV
shows
表创建一个TVDao
数据访问对象。 -
创建一个
TVDatabase
类。 -
使用
tvDatabase
构造函数更新TVShowRepository
。 -
更新
fetchTVShows
函数以从本地数据库获取电视剧。如果还没有,则从端点检索列表并将其保存到数据库中。 -
添加一个挂起函数
fetchTVShowsFromNetwork
,从网络获取电视剧并将其保存到数据库中。 -
创建
TVShowWorker
类。 -
打开
TVApplication
文件。在onCreate
中,安排TVShowWorker
检索并保存节目。 -
运行你的应用。应用将显示电视节目列表。点击一个电视节目将打开详情活动,显示电影详情。主屏幕和详情屏幕将类似于以下内容:
图 15.6 – TV 指南应用的主屏幕和详情屏幕
注意
该活动的解决方案可在packt.link/By7eE
找到。
摘要
本章重点介绍了 Android 的架构模式。你从 MVVM 架构模式开始学习。你了解了它的三个组成部分:模型(Model)、视图(View)和视图模型(ViewModel)。你还使用了数据绑定来将视图与视图模型连接起来。
接下来,你学习了如何使用 Repository 模式来缓存数据。然后,你学习了 WorkManager 以及如何安排诸如从网络获取数据并将数据保存到数据库以更新本地数据等任务。
在下一章中,你将学习如何通过动画来改善和提高你应用的外观和设计。你将使用CoordinatorLayout
和MotionLayout
为你的应用添加动画和过渡效果。
第十六章:使用 CoordinatorLayout 和 MotionLayout 的动画和过渡
本章将向你介绍动画以及如何处理布局之间的切换。它描述了在 Android 中使用MotionLayout
和 Motion Editor 移动对象,以及约束集的详细解释。本章还涵盖了修改路径和向帧的运动添加关键帧。
到本章结束时,你将能够使用CoordinatorLayout
和MotionLayout
创建动画,并使用 Android Studio 中的 Motion Editor 创建MotionLayout
动画。
在上一章中,你学习了关于架构模式,如 MVVM。你现在知道如何改进应用的架构。接下来,我们将学习如何使用动画来增强我们应用的外观和感觉,使其与其他应用不同且更好。
有时候,我们开发的某些应用可能看起来有点单调,因此我们可以在应用中加入一些动态部分和令人愉悦的动画,使它们更加生动,并改善 UI 和用户体验。例如,我们可以添加视觉提示,以便用户不会对下一步该做什么感到困惑,并引导他们了解可以采取的步骤。
加载时的动画可以在内容被检索或处理时娱乐用户。当应用遇到错误时,漂亮的动画可以帮助防止用户对发生的事情感到愤怒,并告知他们有哪些选项。
在本章中,我们将首先探讨一些使用 Android 进行动画的传统方法。我们将以探讨较新的MotionLayout
选项结束本章。让我们从活动过渡开始,这是最容易且最常用的动画之一。
本章我们将涵盖以下主题:
-
活动过渡
-
使用
CoordinatorLayout
的动画 -
使用
MotionLayout
的动画
技术要求
本章所有练习和活动的完整代码可在 GitHub 上找到,链接为packt.link/G8RoL
活动过渡
当打开和关闭活动时,Android 会播放默认过渡动画。我们可以自定义活动过渡以反映品牌和/或区分我们的应用。活动过渡从 Android 5.0 Lollipop(API 级别 21)开始提供。
活动过渡有两个部分——进入过渡和退出过渡。进入过渡定义了活动及其视图在活动打开时如何动画化。同时,退出过渡描述了活动及其视图在活动关闭或打开新活动时如何动画化。Android 支持以下内置过渡:
-
Explode:这会将视图从中心移动到内部或外部
-
Fade:此视图会缓慢出现或消失
-
Slide:这会将视图从边缘移动到内部或外部
现在,让我们看看我们如何在下一节中添加活动过渡。有两种方法可以添加活动过渡 – 通过 XML 和通过代码。首先,我们将学习如何通过 XML 添加过渡,然后通过代码。
通过 XML 添加活动过渡
您可以通过 XML 添加活动过渡。第一步是启用窗口内容过渡。这是通过在 themes.xml
中的活动主题中添加以下内容来完成的:
<item name="android:windowActivityTransitions">true</item>
之后,您可以使用 android:windowEnterTransition
和 android: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.enterTransition
和 window.exitTransition
分别添加进入和退出过渡。我们可以使用 android.transition
包中的内置 Explode()
、Slide()
和 Fade()
过渡。例如,如果我们想使用 Explode()
作为进入过渡,Slide()
作为退出过渡,我们可以添加以下代码:
window.enterTransition = Explode()
window.exitTransition = Slide()
如果您的应用最低支持的 SDK 版本低于 21,请记住在调用 setContentView()
之前对这些调用进行 Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
的检查。
现在您已经知道如何通过代码或 XML 添加进入和退出活动过渡,您需要学习如何在打开活动时激活过渡。我们将在下一节中这样做。
使用活动过渡开始活动
一旦您已将活动过渡添加到活动(无论是通过 XML 还是编码),您可以在打开活动时激活过渡。而不是使用 startActivity(intent)
调用,您应该传递一个包含过渡动画的 bundle。为此,使用以下代码启动您的活动:
startActivity(intent, ActivityOptions
.makeSceneTransitionAnimation(this).toBundle())
ActivityOptions.makeSceneTransitionAnimation(this).toBundle()
参数将创建一个包含我们为活动指定的进入和退出过渡(通过 XML 或代码)的 bundle。
让我们通过向应用添加活动过渡来尝试一下我们到目前为止所学的内容。
练习 16.01 – 在应用中创建活动过渡
在许多机构中,留下小费(通常称为赏金)是很常见的。这是一笔表示对服务感激之情的钱款 – 例如,给餐厅的侍应生。小费是在最终账单上标明的基本费用之外提供的。
在本章中,我们将使用一个计算应支付小费金额的应用程序。这个值将基于账单金额(基本费用)和用户想要给出的额外百分比。用户将输入这两个值,应用程序将计算小费金额。
在这个练习中,我们将自定义输入屏幕和输出屏幕之间的活动过渡:
-
在 Android Studio 中,从书籍代码仓库的
Chapter16
目录中打开 Tip Calculator 项目。 -
运行应用。点击
OutputActivity
并返回。当MainActivity
关闭和OutputActivity
打开及关闭时,会有一个默认的动画。 -
现在,让我们开始添加过渡动画。打开
themes.xml
文件,并更新活动主题,使用windowActivityTransitions
、windowEnterTransition
和windowExitTransition
风格属性:<item name="android:windowActivityTransitions"> true</item> <item name="android:windowEnterTransition"> @android:transition/explode</item> <item name="android:windowExitTransition"> @android:transition/slide_left</item>
这将启用活动过渡,添加一个爆炸进入过渡,并添加一个向左滑动退出过渡到活动。
-
返回到
MainActivity
文件,将startActivity(intent)
替换为以下内容:startActivity(intent, ActivityOptions .makeSceneTransitionAnimation(this).toBundle())
这将使用我们在 XML 文件中指定的过渡动画打开 OutputActivity
(我们在上一步中设置了该动画)。
- 运行应用。你会看到打开和关闭
MainActivity
和OutputActivity
时的动画已经改变。当 Android UI 打开OutputActivity
时,注意文本是向中心移动的。在关闭时,视图向左滑动:
图 16.1 – 应用屏幕 – 输入屏幕(在左侧)和输出屏幕(在右侧)
我们已经为一个应用添加了活动过渡。当我们打开一个新的活动时,新活动的进入过渡将被播放。当活动关闭时,其退出过渡将被播放。
有时候,当我们从一个活动打开另一个活动时,两个活动中都存在一个共同元素。在下一节中,我们将学习如何添加这个共享元素过渡。
添加共享元素过渡
有时候,一个应用从一个活动切换到另一个活动,并且这两个活动中都存在一个共同元素。我们可以为这个共享元素添加一个动画,以使用户注意到这两个活动之间的联系。
例如,在一个电影应用程序中,一个包含电影列表(带有缩略图图像)的活动可以打开一个新的活动,显示所选电影的详细信息,以及顶部的一个全尺寸图像。为图像添加共享元素过渡将把列表活动中的缩略图与详情活动中的图像联系起来。
共享元素过渡有两个部分——进入过渡和退出过渡。这些过渡可以通过 XML 或代码实现。
第一步是启用窗口内容过渡。你可以通过将活动的主题添加到themes.xml
中来实现,如下所示:
<item name="android:windowContentTransitions">true</item>
你也可以通过在调用setContentView()
之前调用以下函数来编程实现:
window.requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)
将android:windowContentTransitions
属性设置为true
和window.requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)
将启用窗口内容过渡。
之后,你可以添加共享元素进入过渡和共享元素退出过渡。如果你在res/transitions
目录中有enter_transition.xml
和exit_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.shared ElementEnterTransition
将我们的进入过渡设置为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.shared ElementExitTransition
将我们的退出过渡设置为exit_transition.xml
文件。
你已经学会了如何添加共享元素过渡。在下一节中,我们将学习如何使用共享元素过渡启动活动。
使用共享元素过渡启动活动
一旦你将共享元素过渡添加到活动(无论是通过 XML 还是通过编程方式),你就可以在打开活动时激活过渡。在你这样做之前,添加一个transitionName
属性。将其值设置为两个活动中的共享元素相同的文本。
例如,在ImageView
中,我们可以为transitionName
属性添加一个transition_name
值:
<ImageView
...
android:transitionName="transition_name"
android:id="@+id/sharedImage"
... />
要使用共享元素启动活动,我们将传递一个包含过渡动画的包。为此,使用以下代码启动你的活动:
startActivity(intent, ActivityOptions
.makeSceneTransitionAnimation(this, sharedImage,
"transition_name").toBundle());
ActivityOptions.makeSceneTransitionAnimation(this, sharedImage, "transition_name").toBundle()
参数将创建一个包含共享元素(sharedImage
)和过渡名称(transition_name
)的包。
如果你有一个以上的共享元素,你可以传递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。
让我们通过向小费 计算器应用程序添加共享元素过渡来尝试我们到目前为止所学的内容。
练习 16.02 – 创建共享元素过渡
在第一个练习中,我们为MainActivity
和OutputActivity
自定义了活动过渡。在这个练习中,我们将向两个活动添加图片。当从输入屏幕移动到输出屏幕时,这个共享元素将被动画化。我们将使用应用程序启动器图标(res/mipmap/ic_launcher
)作为ImageView
。你可以更改它而不是使用默认图标:
-
打开我们在上一个练习中开发的
Tip Calculator
项目。 -
进入
activity_main.xml
文件,在金额文本字段顶部添加ImageView
:<ImageView android:id="@+id/image" ... android:transitionName="transition_name" ... />
本步骤的完整代码可以在packt.link/NvDO2
找到。
transitionName
值为transition_name
将用于识别这是一个共享元素。
-
通过将
app:layout_constraintTop_toTopOf="parent"
更改为以下内容来更改 ID 为amount_text_layout
的TextInputLayout
的顶部约束:app:layout_constraintTop_toBottomOf="@id/image"
这将把amount_text_layout
移动到图片下方。
-
现在,打开
activity_output.xml
文件,在提示TextView
上方添加一个图片,高度和宽度为 200 dp,scaleType
设置为fitXY
以适应ImageView
的尺寸:<ImageView android:id="@+id/image" ... android:transitionName="transition_name" ... />
本步骤的完整代码可以在packt.link/jpgVe
找到。
transitionName
值为transition_name
与MainActivity
中ImageView
的值相同。
-
打开
MainActivity
并将startActivity
代码更改为以下内容:val image: ImageView = findViewById(R.id.image) startActivity(intent, ActivityOptions .makeSceneTransitionAnimation(this, image, "transition_name").toBundle())
这将启动从MainActivity
中的ImageView
开始的过渡,ID 为 image 过渡到OutputActivity
中的另一个ImageView
,其transitionName
值也是transition_name
。
- 运行应用程序。提供金额和百分比,然后点击
OutputActivity
:
图 16.2 – 应用程序屏幕 – 输入屏幕(在左侧)和输出屏幕(在右侧)
我们已经学习了如何添加活动过渡和共享元素过渡。现在,让我们看看如何在布局内部动画化视图。如果我们内部有多个元素,可能很难对每个元素进行动画化。可以使用CoordinatorLayout
来简化这个动画。我们将在下一节讨论这个问题。
使用 CoordinatorLayout 的动画
CoordinatorLayout
是一个处理其子视图之间运动的布局。当你将 CoordinatorLayout
作为父视图组使用时,你可以轻松地对其内部的视图进行动画处理。你可以通过在 app/build.gradle
文件中添加以下依赖项将 CoordinatorLayout
添加到你的项目中:
implementation 'androidx.coordinatorlayout:
coordinatorlayout:1.2.0'
这将允许我们在布局文件中使用 CoordinatorLayout
。
假设我们有一个包含在 CoordinatorLayout
中的浮动操作按钮的布局文件。当点击浮动操作按钮时,UI 显示一个 Snackbar
消息。
注意
Snackbar
是一个 Android 小部件,它会在屏幕底部向用户提供一个简短的消息。
如果你使用除 CoordinatorLayout
之外的任何布局,带有消息的 Snackbar
将会渲染在浮动操作按钮的上方。如果我们使用 CoordinatorLayout
作为父视图组,布局将推动浮动操作按钮向上,在它下方显示 Snackbar
,并在 Snackbar
消失时将其移回。图 16**.3 展示了布局如何调整以防止 Snackbar
覆盖浮动操作按钮:
图 16.3 – 左侧截图显示了在显示 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 创建了 MotionLayout
。这是一种通过 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.1.4'
这将添加 ConstraintLayout 的最新版本(在撰写本文时为 2.1.4)到您的应用中。
添加依赖项后,我们现在可以使用 MotionLayout
创建动画。我们将在下一节中这样做。
使用 MotionLayout 创建动画
MotionLayout
是我们老朋友 ConstraintLayout 的子类。要使用 MotionLayout
创建动画,打开我们将添加动画的布局文件。将根 ConstraintLayout
容器替换为 androidx.constraintlayout.motion.widget.MotionLayout
。
动画本身不会在布局文件中,而是在另一个名为 motion_scene
的 XML 文件中。这将指定 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:指定要动画化的视图/布局的起始和结束位置及样式
-
过渡:指定要在视图上执行的动画的开始、结束、持续时间和其他细节
让我们尝试通过将 MotionLayout
添加到我们的 小费 计算器 应用中来添加动画。
练习 16.03 – 使用 MotionLayout 添加动画
在这个练习中,我们将更新我们的 小费计算器 应用,添加 MotionLayout
动画。在输出屏幕中,当点击提示文本上方的图片时,它会向下移动;再次点击时,它会回到原始位置:
-
在 Android Studio 4.0 或更高版本中打开 小费计算器 项目。
-
打开
activity_output.xml
文件并将根ConstraintLayout
标签更改为MotionLayout
。将androidx.constraintlayout.widget.ConstraintLayout
更改为以下内容:androidx.constraintlayout.motion.widget.MotionLayout
-
将
app:layoutDescription="@xml/motion_scene"
添加到MotionLayout
标签中。IDE 会警告你该文件尚不存在。现在请忽略这个警告,因为我们将在下一步中添加它。你的文件应该看起来像这样:<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.motion.widget.MotionLayout ... app:layoutDescription="@xml/motion_scene"> ... </androidx.constraintlayout.motion.widget .MotionLayout>
-
在
res/xml
目录下创建一个motion_scene.xml
文件。这将是我们定义动画配置的motion_scene
文件。使用motion_scene
作为文件的根元素。 -
通过在
motion_scene
文件中添加以下内容来添加起始Constraint
元素:<ConstraintSet android:id="@+id/start_constraint"> <Constraint android:id="@id/image" .../> </ConstraintSet>
本步骤的完整代码可以在packt.link/jdJrD
找到。
这是图像在当前位置(约束在屏幕顶部)的显示效果。
-
接下来,通过在
motion_scene
文件中添加以下内容来添加结束Constraint
元素:<ConstraintSet android:id="@+id/end_constraint"> <Constraint android:id="@id/image" ... /> </ConstraintSet>
本步骤的完整代码可以在packt.link/jdJrD
找到。
在结束动画中,ImageView
将位于屏幕底部。
-
现在我们来为
ImageView
添加在ConstraintSet
之后的过渡:<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
事件。切换将使视图从起始状态动画化到结束状态,如果视图已经在结束状态,它将动画化回到起始状态。
- 运行应用并点击
ImageView
。它将在大约 2 秒内直接向下移动。再次点击,它将在 2 秒内返回到上方。图 16.4显示了动画的起始和结束状态:
图 16.4 – 起始动画(左)和结束动画(右)
在这个练习中,我们通过指定起始约束、结束约束以及带有持续时间和OnClick
事件的过渡,在MotionLayout
中动画化了ImageView
。MotionLayout
会自动从起始位置播放动画到结束位置(在我们看来,当点击时,它看起来像是在自动向上或向下直线移动)。
我们已经使用MotionLayout
创建了动画。在下一节中,我们将使用 Android Studio 的运动编辑器来创建MotionLayout
动画。
运动编辑器
从版本 4.0 开始,Android Studio 包含了运动编辑器。运动编辑器可以帮助开发者使用MotionLayout
创建动画。这使得开发者更容易创建和预览过渡和其他动作,而不是手动操作并运行应用来查看更改。编辑器还会自动生成相应的文件。
你可以通过在布局编辑器中右键点击预览并点击转换为运动布局项将你的 ConstraintLayout 转换为MotionLayout
。Android Studio 将执行转换并为你创建运动场景文件。
当在设计视图中查看以MotionLayout
为根布局的布局文件时,运动编辑器 UI 将包含在设计视图中,如图图 16.5所示:
图 16.5 – Android Studio 4.0 中的运动编辑器
在右上角的窗口(MotionLayout
以及起始和结束约束。过渡以从起始约束发出的箭头表示。靠近起始约束的点显示了过渡的点击动作。图 16.6显示了选中的start_constraint
:
图 16.6 – 选择 start_constraint 时运动编辑器的概览面板
右下角的窗口是当选择start_constraint
时选中的MotionLayout
:
图 16.7 – 运动编辑器的选择面板显示 start_constraint 的 ConstraintSet
当你在概览面板的左侧点击MotionLayout
时,下方的选择面板将显示视图及其约束,如图图 16.8所示:
图 16.8 – 选择 MotionLayout 时的概览和选择面板
当你点击start_constraint
或end_constraint
时,左侧的预览窗口将显示起始或结束状态的样子。start_constraint
被选中:
图 16.9 – 选择 start_constraint 时运动编辑器的样子
图 16.10显示了如果你选择end_constraint
时运动编辑器将如何显示:
图 16.10 – 选择 end_constraint 时运动编辑器的样子
连接start_constraint
和end_constraint
的箭头代表MotionLayout
的过渡。在选择面板上,有播放或跳转到第一个或最后一个状态的控件。你还可以将箭头拖动到特定位置。图 16.11显示了它在动画中间(50%)的样子:
图 16.11 – 动画中间的过渡
在使用MotionLayout
开发动画的过程中,如果我们能够调试动画以确保我们正确地执行动画会更好。我们将在下一节讨论如何做到这一点。
运动布局的调试
为了在运行应用程序之前帮助你可视化 MotionLayout
动画,你可以在运动编辑器中显示运动路径和动画的进度。运动路径是动画对象从起始状态到结束状态将采取的直线路径。
要显示路径和/或进度动画,我们可以在 MotionLayout
容器中添加一个 motionDebug
属性。
我们可以为 motionDebug
使用以下值:
-
SHOW_PATH
:这仅显示运动的路径 -
SHOW_PROGRESS
:这仅显示动画进度 -
SHOW_ALL
:这显示动画的路径和进度 -
NO_DEBUG
:这隐藏了所有动画
要显示 MotionLayout
的路径和进度,我们可以使用以下方法:
<androidx.constraintlayout.motion.widget.MotionLayout
...
app:motionDebug="SHOW_ALL"
...>
SHOW_ALL
值将显示动画的路径和进度。图 16.12 展示了当我们使用 SHOW_PATH
和 SHOW_PROGRESS
时它将看起来如何:
图 16.12 – 使用 SHOW_PATH(左)显示动画路径,而 SHOW_PROGRESS(右)显示动画进度
虽然 motionDebug
听起来像只在调试模式下出现的东西,但它也会出现在发布构建中,因此在你准备发布你的应用程序时应该将其删除。
在 MotionLayout
的动画过程中,起始约束将过渡到结束约束,即使有可以阻挡运动对象的元素或元素。我们将在下一节讨论如何避免这种情况发生。
修改 MotionLayout 路径
在 MotionLayout
的动画中,UI 将从起始约束播放到结束约束,即使中间有可以阻挡我们移动视图的元素。例如,如果 MotionLayout
涉及从屏幕顶部到底部移动的文本,反之亦然,并且我们在中间添加了一个按钮,按钮将覆盖移动的文本。
图 16.13 展示了 OK 按钮如何阻挡动画中间的移动文本:
图 16.13 – OK 按钮阻挡了文本动画的中间部分
MotionLayout
沿着直线路径从起始约束播放到结束约束,并根据指定的属性调整视图。我们可以在起始和结束约束之间添加关键帧来调整动画路径和/或视图属性。例如,在动画过程中,除了将移动文本的位置改变以避开按钮外,我们还可以更改文本或其他视图的属性。
关键帧可以作为 motion_scene
转换属性的子元素添加到 KeyFrameSet
中。我们可以使用以下关键帧:
-
KeyPosition
:这指定了动画过程中视图在特定点的位置,以调整路径 -
KeyAttribute
: 这指定了动画特定点的视图属性。 -
KeyCycle
: 这在动画期间添加了振荡。 -
KeyTimeCycle
: 这允许循环由时间而不是动画进度驱动。 -
KeyTrigger
: 这添加了一个可以根据动画进度触发事件的元素。
我们将重点关注 KeyPosition
和 KeyAttribute
,因为 KeyCycle
、KeyTimeCycle
和 KeyTrigger
是更高级的关键帧。
KeyPosition
允许我们在 MotionLayout
动画中间更改视图的位置。它有以下属性:
-
motionTarget
: 这指定了由关键帧控制的对象。 -
framePosition
: 从 1 到 99 编号,这指定了位置改变时的运动百分比。例如,25 表示动画的四分之一处,而 50 是动画的中点。 -
percentX
: 这指定了路径的 x 值将被修改多少。 -
percentY
: 这指定了路径的 y 值将被修改多少。 -
keyPositionType
: 这指定了KeyPosition
如何修改路径。
keyPositionType
属性可以有以下值:
-
parentRelative
:percentX
和percentY
是基于视图的父元素指定的。 -
pathRelative
:percentX
和percentY
是基于从起始约束到结束约束的直线路径指定的。 -
deltaRelative
:percentX
和percentY
是基于视图的位置指定的。
例如,如果我们想在动画的精确中点(50%)修改具有 text_view
ID 的 TextView
的路径,通过将其沿 x 方向移动 10% 和沿 y 方向移动 10%,相对于 TextView
的父容器,我们将在 motion_scene
中有以下关键位置:
<KeyPosition
app:motionTarget="@+id/text_view"
app:framePosition="50"
app:keyPositionType="parentRelative"
app:percentY="0.1"
app:percentX="0.1"
/>
同时,KeyAttribute
允许我们在 MotionLayout
动画进行时更改视图的属性。我们可以更改的一些视图属性包括 visibility
、alpha
、elevation
、rotation
、scale
和 translation
。KeyAttribute
有以下属性:
-
motionTarget
: 这指定了由关键帧控制的对象。 -
framePosition
: 从 1 到 99 编号,这指定了应用视图属性时的运动百分比。例如,20 表示动画的五分之一处,而 75 是动画的三分之四处。
让我们尝试向 小费计算器 应用添加关键帧。当 ImageView
动画时,它会覆盖显示小费的文本。我们将通过添加关键帧来修复这个问题。
练习 16.04 – 使用关键帧修改动画路径
在上一个练习中,我们使图像在点击时向下移动(或当它已经在底部时向上移动)。当它在中间时,图像覆盖了提示 TextView
。我们将通过在 Android Studio 的 Motion 编辑器中添加 KeyFrame
到 motion_scene
来解决此问题:
-
使用 Android Studio 4.0 或更高版本打开 小费计算器 应用。
-
在
res/layout
目录中打开activity_output.xml
文件。 -
将
app:motionDebug="SHOW_ALL"
添加到MotionLayout
容器中。这将允许我们在 Android Studio 和我们的设备/模拟器上看到路径和进度信息。您的MotionLayout
容器将如下所示:<androidx.constraintlayout.motion.widget.MotionLayout ... app:motionDebug="SHOW_ALL">
-
运行应用程序并执行计算。在输出屏幕上,点击图像。在动画进行时查看提示文本。注意,文本在动画中间被图像覆盖,如图 16.14所示:
图 16.14 – 图像隐藏显示提示的 TextView
-
返回 Android Studio 中的
activity_output.xml
文件。确保它在设计视图中打开。 -
在
start_constraint
和end_constraint
中。将选择面板中的向下箭头拖动到中间(50%),如图 16.15所示:
图 16.15 – 选择表示起始和结束约束之间转换的箭头
- 点击选择面板(带有绿色+符号的面板)中转换右侧的创建关键帧图标,如图 16.16所示:
图 16.16 – 创建关键帧图标
-
选择关键位置。我们将使用关键位置来调整输出屏幕上的图像,使其不会与包含提示文本的文本重叠。
-
选择
50
。类型是parentRelative
,1.5
,如图 16.17所示。这将为转换中间(50%)的图像添加一个关键位置属性,相对于父元素的x轴是 1.5 倍:
图 16.17 – 提供要创建的关键位置输入
- 点击
ImageView
。现在它将位于TextView
的右侧:
图 16.18 – 路径现在将是曲线的;转换面板将出现新的 KeyPosition 项
- 点击播放图标以查看动画效果。在设备或模拟器上运行应用程序以验证它。您将看到动画现在向右弯曲,而不是采取之前的直线路径,如图 16.19所示:
图 16.19 – 现在动画避免了带有提示的 TextView
-
Motion 编辑器将自动生成
KeyPosition
的代码。如果您转到motion_scene.xml
文件,您将看到 Motion 编辑器在转换属性中添加了以下代码:<KeyFrameSet> <KeyPosition app:framePosition="50" app:keyPositionType="parentRelative" app:motionTarget="@+id/image" app:percentX="1.5" /> </KeyFrameSet>
在过渡期间添加了 KeyPosition
属性。在动画的 50% 时,图像的 x 位置相对于其父视图移动了 1.5 倍。这允许图像在动画过程中避免其他元素。
在这个练习中,你添加了一个关键位置,这将调整 MotionLayout
动画,确保它不会阻塞或被其路径上的另一个视图阻塞。
通过进行另一个活动来测试你所学的一切。
活动 16.01 – 密码生成器
使用强密码来保护我们的在线账户非常重要。它必须是唯一的,并且必须包含大写和小写字母、数字和特殊字符。在这个活动中,你将开发一个可以生成强密码的应用程序。
应用程序将有两个屏幕 – 输入屏幕和输出屏幕。在输入屏幕中,用户可以提供密码的长度并指定是否必须包含大写或小写字母、数字或特殊字符。
输出屏幕将显示三个可能的密码,当用户选择一个时,其他密码将移开,并显示一个按钮以将密码复制到剪贴板。您应该自定义从输入屏幕到输出屏幕的过渡。
完成以下步骤:
-
在 Android Studio 4.0 或更高版本中创建一个新的项目,并将其命名为
Password Generator
。设置其包名和最小 SDK。 -
将
MaterialComponents
依赖项添加到您的app/build.gradle
文件中。 -
更新
ConstraintLayout
的依赖项。 -
确保活动的主题在
themes.xml
文件中使用MaterialComponents
中的一个。 -
在
activity_main.xml
文件中,删除Hello World
TextView
并添加用于长度的输入文本字段。 -
添加用于大写字母、数字和特殊字符的复选框代码。
-
在复选框底部添加一个生成按钮。
-
创建另一个活动,并将其命名为
OutputActivity
。 -
将从输入屏幕(
MainActivity
)到OutputActivity
的活动过渡进行自定义。打开themes.xml
并使用windowActivityTransitions
、windowEnterTransition
和windowExitTransition
风格属性更新活动主题。 -
更新
MainActivity
中的onCreate
函数的末尾。 -
更新
activity_output.xml
文件中androidx.constraintlayout.widget.ConstraintLayout
的代码。 -
将
app:layoutDescription="@xml/motion_scene"
和app:motion Debug="SHOW_ALL"
添加到MotionLayout
标签中。 -
向输出活动添加三个
TextView
实例,用于显示生成的三个密码。 -
在屏幕底部添加一个复制按钮。
-
在
OutputActivity
中添加generatePassword
函数。 -
添加代码以根据用户输入生成三个密码,并为复制按钮添加一个
ClickListener
组件,以便用户可以将选定的密码复制到剪贴板。 -
在
OutputActivity
中,为每个密码TextView
创建一个动画。 -
为默认视图创建
ConstraintSet
。 -
当第一个、第二个和第三个密码被选中时,添加
ConstraintSet
。 -
接下来,当每个密码被选中时,添加
Transition
。 -
通过访问运行菜单并点击运行应用程序菜单项来运行应用程序。
-
输入一个长度,选择大写字母、数字和特殊字符的复选框,然后点击生成按钮。将显示三个密码。
-
选择一个,其余的将移出视图。还会显示一个复制按钮。点击它并检查您选择的密码是否现在在剪贴板上。输出屏幕的初始和最终状态将与图 16**.20相似:
图 16.20 – 密码生成器应用中 MotionLayout 的起始和结束状态
注意
该活动的解决方案可以在packt.link/By7eE
找到。
摘要
本章介绍了如何使用CoordinatorLayout
和MotionLayout
创建动画和过渡。动画可以提高我们应用程序的可用性,并使其与其他应用程序相比脱颖而出。
我们从自定义使用活动过渡打开和关闭活动时的过渡开始。我们还学习了当打开的活动及其打开的活动都包含相同元素时添加共享元素过渡,使我们能够向用户突出显示共享元素之间的这种链接。
我们学习了如何使用CoordinatorLayout
来处理其子视图的运动。一些视图具有内置的行为,可以处理它们在CoordinatorLayout
内部的工作方式。您也可以向其他视图添加自定义行为。然后,我们转向使用MotionLayout
通过指定起始约束、结束约束以及它们之间的过渡来创建动画。我们还探讨了通过在动画中间添加关键帧来修改运动路径。我们学习了关于关键帧的知识,例如KeyPosition
,它可以改变视图的位置,以及KeyAttribute
,它可以改变视图的样式。我们还探讨了在 Android Studio 中使用运动编辑器来简化动画的创建和预览以及路径的修改。
在下一章中,我们将学习关于 Google Play 商店的内容。我们将讨论如何创建账户并准备应用程序发布,以及如何将它们发布供用户下载和使用。
第十七章:在 Google Play 上发布您的应用程序
本章将向您介绍 Google Play 控制台、发布渠道和整个发布过程。它涵盖了创建 Google Play 开发者账户、设置我们开发的应用程序的商店入口以及创建密钥库(包括介绍密码的重要性以及文件存储的位置)。我们还将了解应用程序包和 APK,查看如何生成应用程序的 APK 或 AAB 文件。在章节的后面部分,我们将设置发布路径、公开测试版和封闭测试版,最后,我们将上传我们的应用程序到商店并在设备上下载。
到本章结束时,您将能够创建自己的 Google Play 开发者账户,准备您的已签名 APK 或应用程序包以发布,并在 Google Play 上发布您的第一个应用程序。
您在第十六章中学习了如何使用 CoordinatorLayout
和 MotionLayout
添加动画和过渡,使用 CoordinatorLayout 和 MotionLayout 的动画和过渡。现在,您已经准备好开发和发布 Android 应用程序。
在开发 Android 应用后,它们只能在您的设备和模拟器上使用。您必须使它们对所有人可用,以便他们可以下载。反过来,您将获得用户,并且可以从他们那里赚钱。Android 应用的官方市场是 Google Play。通过 Google Play,您发布的应用程序和游戏将可供全球超过 20 亿台活跃的 Android 设备使用。还有其他市场,您可以在那里发布您的应用程序,但它们超出了本书的范围。
在本章中,我们将学习如何在 Google Play 上发布您的应用程序。我们将从准备应用程序发布和创建 Google Play 开发者账户开始。然后,我们将继续上传您的应用程序和管理应用程序发布。
在本章中,我们将介绍以下主题:
-
准备您的应用程序发布
-
创建开发者账户
-
将应用程序上传到 Google Play
-
管理应用程序发布
准备您的应用程序发布
Android Studio 通常使用调试密钥对您的构建进行签名。这种调试构建允许您快速构建和测试您的应用程序。要发布您的应用程序到 Google Play,您必须创建一个使用您自己的密钥签名的发布构建。这个发布构建将不可调试,并且可以优化大小。
发布构建还必须具有正确的版本信息。否则,您将无法发布新应用程序或更新已发布的应用程序。
让我们从向您的应用程序添加版本开始。
应用程序版本控制
您的应用程序版本对于以下原因很重要:
-
用户可以看到他们下载的版本。他们可以在检查是否有更新或报告应用程序的错误/问题时使用此信息。
-
设备和 Google Play 使用版本值来确定应用程序是否可以或应该更新。
-
开发者还可以使用这个值来为特定版本添加功能支持。他们还可以警告或强制用户升级到最新版本以获取对错误或安全问题的重要修复。
一个 Android 应用有两个版本:versionCode
和versionName
。现在,versionCode
是一个由开发者、Google Play 和 Android 系统使用的整数,而versionName
是用户在 Google Play 页面上看到的应用字符串。
应用程序的初始发布应该有一个versionCode
值为1
,你应该为每个新版本增加它。
versionName
可以是x.y格式(其中x是主版本,y是次版本)。你也可以使用语义版本,如x.y.z,通过添加补丁版本z。
要了解更多关于语义版本的信息,请参阅semver.org
。
在模块的build.gradle
文件中,当你在 Android Studio 中创建一个新项目时,versionCode
和versionName
会自动生成。它们位于android
块下的defaultConfig
块中。一个示例build.gradle
文件显示了这些值:
android {
compileSdk 33
defaultConfig {
applicationId "com.example.app"
minSdk 21
targetSdk 33
versionCode 1
versionName "1.0"
...
}
...
}
在发布更新时,正在发布的新的包必须有一个更高的versionCode
值,因为用户不能降级他们的应用,只能下载新版本。
在确保应用版本正确后,发布流程的下一步是获取一个密钥库来签名应用。这将在下一节中讨论。
创建密钥库
Android 应用在运行时会被自动使用调试密钥签名。然而,在它可以在 Google Play 商店发布之前,应用必须使用发布密钥进行签名。为此,你必须有一个密钥库。如果你还没有,你可以在 Android Studio 中创建一个。
练习 17.01 – 在 Android Studio 中创建密钥库
在这个练习中,我们将使用 Android Studio 创建一个可以用于签名 Android 应用的密钥库。按照以下步骤完成这个练习:
-
在 Android Studio 中打开一个项目。
-
前往构建菜单,然后点击生成签名包****或 APK:
图 17.1 – 生成签名包或 APK 对话框
APK文件是用户可以安装应用的文件格式。Android 应用包是一种新的文件发布格式,它允许 Google Play 向设备分发特定且更小的 APK,因此开发者不需要发布和管理多个 APK 以支持不同的设备。
- 确保已选择APK或Android 应用包,然后点击下一步按钮。在这里,你可以选择一个现有的密钥库或创建一个新的:
图 17.2 – 选择 APK 并点击下一步按钮后的生成签名包或 APK 对话框
- 点击创建新…按钮。然后会出现新密钥库对话框:
图 17.3 – 新密钥库对话框
-
在
users/packt/downloads/keystore.keystore
。 -
在密码和确认字段中提供密码。
-
在密钥下的证书部分,输入姓名、组织单位、组织、城市或地区、州或省和国家代码字段的值。其中只需提供一项,但提供所有信息是好的。
-
点击确定按钮。如果没有错误,密钥库将在您提供的路径中创建,您将回到生成签名包或 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 密钥库,有效期为 9,125 天(25 年),文件名为my-key.jks
,别名为key-alias
。您可以将有效期、文件名和别名更改为您喜欢的值。命令行将提示您输入密钥库密码,然后提示您再次输入以确认。
它将依次询问您姓名、组织单位、组织名称、城市或地区、州或省和国家代码,只需提供一项;如果您想留空,可以按Enter键。尽管如此,提供所有信息是良好的实践。
在国家代码提示后,您将被要求验证提供的输入。您可以输入yes
以确认。然后您将被要求提供密钥别名的密码。如果您想它与密钥库密码相同,您可以按Enter。然后密钥库将被生成。
现在您已经有了用于签名应用的密钥库,您需要知道如何保持其安全。您将在下一节中了解这一点。
存储密钥库和密码
您需要将密钥库和密码保存在安全的地方,因为如果您丢失密钥库和/或其凭证,您将无法再为您的应用发布更新。如果黑客也获得了对这些凭证的访问权限,他们可能能够在未经您同意的情况下更新您的应用。
您可以将密钥库存储在您的 CI/构建服务器或安全服务器上。
保持凭证有点棘手,因为您稍后需要在为应用更新签名时使用它们。您可以这样做的一种方法是在项目的app/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
文件中并不安全,因为任何可以访问项目或仓库的人都可以危及应用。
您可以将这些凭据存储在环境变量中以提高安全性。采用这种方法,即使恶意人员获取了您的代码访问权限,应用更新仍然安全,因为签名配置不是存储在您的代码中,而是在系统上。环境变量是在您的集成开发环境(IDE)或项目之外设置的键值对,例如,在您的个人电脑或构建服务器上。
要在 Gradle 中为密钥库配置使用环境变量,您可以创建存储文件路径、存储密码、密钥别名和密钥密码的环境变量。例如,您可以使用KEYSTORE_FILE
、KEYSTORE_PASSWORD
、KEY_ALIAS
和KEY_PASSWORD
环境变量。
在 macOS 和 Linux 上,您可以使用以下命令设置环境变量:
export KEYSTORE_PASSWORD=securepassword
如果您使用的是 Windows,可以使用以下命令完成:
set KEYSTORE_PASSWORD=securepassword
此命令将创建一个KEYSTORE_PASSWORD
环境变量,其值为securepassword
。在app/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,您必须使用在 Android Studio 或命令行中创建的密钥库使用自己的密钥对 APK 或应用包进行签名。
假设您已在您的build.gradle
文件中添加了发布构建的签名配置;您可以通过选择项目app/build/output
目录中的发布构建来自动构建签名 APK 或应用包。
练习 17.02 – 创建签名 APK
在这个练习中,我们将使用 Android Studio 为 Android 项目创建一个签名 APK:
-
在 Android Studio 中打开一个项目。
-
前往构建菜单,然后点击生成签名包或 APK菜单项:
图 17.4 – “生成签名包或 APK”对话框
- 选择APK,然后点击下一步按钮:
图 17.5 – 点击“下一步”按钮后的“生成签名包或 APK”对话框
-
选择您在练习 17.01 – 在 Android Studio 中创建密钥库中创建的密钥库。
-
在密钥库密码字段中提供您为创建的密钥库设置的密码。
-
在密钥别名字段中,点击右侧的图标并选择密钥别名。
-
在密钥 密码字段中提供您为密钥库设置的别名密码。
-
点击下一步按钮。
-
选择签名 APK 将生成的目标文件夹。
-
在构建变体字段中,确保选择了发布变体:
图 17.6 – 在“生成签名包或 APK”对话框中选择发布构建
对于签名版本,选择V1和V2。V2(完整 APK 签名)是一个整个文件方案,它增加了您的应用安全性并使其安装速度更快。这仅适用于 Android 7.0 Nougat 及以上版本。如果您针对的版本低于这个版本,您还应该使用V1(JAR 签名),这是签名的旧方法,但比V2安全性低。
- 点击完成按钮。Android Studio 将构建签名 APK。IDE 通知将告知您已生成签名 APK。您可以通过点击定位来转到签名 APK 文件所在的目录:
在这个练习中,您已经制作了一个已签名的 APK,现在您可以将其发布到 Google Play。在下一节中,您将了解 Android App Bundle,这是一种新的发布应用打包方式。
Android 应用包
传统的发布 Android 应用的方式是通过 APK 或应用包。这个 APK 文件是用户在安装您的应用时下载到设备上的文件。这个大文件包含了所有设备配置的所有字符串、图像和其他资源。
随着您支持的设备类型和国家的增加,这个 APK 文件的大小将会增长。用户下载的 APK 将包含他们设备上并不真正需要的部分。这可能会成为您的问题,因为存储空间有限的用户可能没有足够的空间来安装您的应用。拥有昂贵的数据计划或慢速互联网连接的用户可能会避免下载过大的应用。他们也可能卸载您的应用以节省存储空间。
一些开发者已经构建并发布了多个 APK 来避免这些问题。然而,这既复杂又低效,尤其是在您针对不同的屏幕密度、CPU 架构和语言时。此外,每个发布版本维护的 APK 文件会太多。
Android App Bundle 是发布应用的一种新打包方式。您只需生成单个应用包文件(使用 Android Studio 3.2 及以上版本)并将其上传到 Google Play。Google Play 将自动生成基础 APK 文件以及每个设备配置、CPU 架构和语言的 APK 文件。安装您的应用的用户将只下载他们设备所需的 APK。与通用 APK 相比,这将更小。
这适用于运行 Android 5.0 Lollipop 及以上版本的设备;对于低于此版本的设备,生成的 APK 文件仅用于设备配置和 CPU 架构。所有语言和其他资源都将包含在每个 APK 文件中。
练习 17.03 – 创建签名应用包
在这个练习中,我们将使用 Android Studio 为 Android 项目创建一个签名应用包:
-
在 Android Studio 中打开一个项目。
-
转到构建菜单,然后点击生成签名包或 APK菜单项:
图 17.8 – 生成签名包或 APK 对话框
- 选择Android App Bundle,然后点击下一步按钮:
图 17.9 – 点击下一步按钮后的生成签名包或 APK 对话框
-
在练习 17.01 – 在 Android Studio 中创建密钥库中创建的密钥库中选择。
-
在密钥库 密码字段中提供您为创建的密钥库设置的密码。
-
在密钥别名字段中,点击右侧的图标并选择密钥别名。
-
在密钥 密码字段中提供您为在 Android Studio 中创建的密钥库设置的别名密码。
-
点击下一步按钮。
-
选择生成签名应用包的目标文件夹。
-
在构建变体字段中,确保选择了发布变体:
图 17.10 – 在生成签名包或 APK 对话框中选择发布构建
- 点击完成按钮。Android Studio 将构建签名应用包。IDE 通知将告知您已生成签名应用包。您可以点击定位以转到签名应用包文件所在的目录:
图 17.11 – 已生成签名应用包的弹出通知
在这个练习中,您已经创建了一个签名应用包,现在可以将其发布到 Google Play。
要能够以 Android 应用包格式将您的应用发布到 Google Play 商店,您首先需要选择 Google Play 的应用签名功能。我们将在下一节中讨论 Google Play 应用签名。
Google Play 应用签名
Google Play 提供了一种名为应用签名的服务,允许 Google 管理和保护您的应用签名密钥,并自动为用户重新签名您的应用。
使用 Google Play 应用签名服务,您可以允许 Google 生成签名密钥或上传您自己的密钥。您还可以为额外的安全性创建不同的上传密钥。您可以使用上传密钥对应用进行签名并在 Play Console 上发布应用。
Google 将检查上传密钥,将其删除,并使用应用签名密钥重新签名应用以分发给用户。当应用启用应用签名时,上传密钥可以被重置。如果您丢失了上传密钥或认为它已经被破坏,您可以简单地联系 Google Play 开发者支持,验证您的身份,并获取一个新的上传密钥。
在发布新应用时加入应用签名很容易。在 Google Play Console (play.google.com/console
) 中,您可以前往 发布管理 | 应用发布 部分,并在 让 Google 管理和保护您的应用签名密钥 部分选择 继续。您最初用于签名应用的密钥将成为上传密钥,Google Play 将生成一个新的应用签名密钥。
您还可以配置现有应用以使用应用签名。这可以在 Google Play Console 中应用的 发布 | 设置 | 应用签名 部分找到。您需要上传现有的应用签名密钥并生成一个新的上传密钥。
一旦您注册了 Google Play 应用签名,您就无法取消注册。此外,如果您使用第三方服务,您必须使用应用签名密钥的证书。这可以在 发布管理 | 应用签名 中找到。
应用签名还允许您上传应用包,Google Play 将自动签名并生成 APK 文件,用户在安装您的应用时将下载这些文件。
在下一节中,您将创建 Google Play 开发者账户,以便您可以将签名的 APK 或应用包发布到 Google Play。
创建开发者账户
要在 Google Play 上发布应用程序,您需要采取的第一步是创建 Google Play 开发者账户。前往 play.google.com/console/signup
并使用您的 Google 账户登录。如果您还没有,您应该先创建一个。
我们建议使用您计划长期使用的 Google 账户,而不是一次性账户。阅读开发者分发协议并同意服务条款。
注意
如果您的目标是销售付费应用或在您的应用/游戏中添加内购产品,您还必须创建一个商户账户。遗憾的是,这并非在所有国家都可用。我们在这里不会涉及此内容,但您可以在注册页面或packt.link/LDncA
上了解更多信息。
您必须支付 25 美元的注册费来创建您的 Google Play 开发者账户。(这是一次性支付)。费用必须使用有效的借记卡/信用卡支付,但某些预付费/虚拟信用卡也适用。您可以使用的内容因地区/国家而异。
最后一步是完成账户详情,例如开发者的姓名、电子邮件地址、网站和电话号码。这些信息可以在以后更新,并将形成在您的应用商店列表中显示的开发者信息。
完成注册后,你将收到一封确认邮件。你的付款可能需要几小时(最多 48 小时)才能处理并注册你的账户,所以请耐心等待。理想情况下,你应该提前这样做,即使你的应用还没有准备好,这样你就可以在它准备好发布时轻松发布。
当你收到谷歌的确认邮件后,你就可以开始将应用和游戏发布到谷歌 Play 了。
在下一节中,我们将讨论如何将应用上传到谷歌 Play。
上传应用到谷歌 Play
一旦你有了一个准备发布的应用和一个谷歌 Play 开发者账号,你就可以访问谷歌 Play 控制台(play.google.com/console
)来发布应用。
要上传应用,请访问 Play 控制台,点击所有应用,然后点击创建应用。提供应用程序的名称和默认语言。在应用或游戏部分,设置它是一个应用还是一个游戏。同样,在免费或付费部分,设置它是免费还是付费。创建你的商店列表,准备应用发布,并推出发布。我们将在本节中查看详细步骤。
创建商店列表
当用户在谷歌 Play 上打开你的应用页面时,首先看到的是商店列表。如果应用已经发布,你可以转到增长、商店存在感,然后选择主要商店列表。
应用详情
你将导航到应用详情页面。在应用详情页面,你需要填写以下字段:
-
应用名称:在这里,你提供你的应用名称(最大字符数为 50)。
-
简短描述:在这里,你提供一段简短的文字总结你的应用(最大字符数为 80)。
-
完整描述:这是你应用的详细描述。限制为 4,000 个字符,所以你可以在这里添加很多相关信息,例如它的功能以及用户需要知道的事情。
注意
对于产品详情,你可以根据你的应用将在哪些语言/国家发布添加本地化版本。
你的应用标题和描述不得包含受版权保护的材料和垃圾邮件,因为这可能会导致你的应用被拒绝。
图形资产
在本节中,提供以下详细信息:
-
一个图标(一个 512 x 512 的高分辨率图标)。
-
特效图形(1,024 x 500)。
-
应用程序的 2-8 张截图。如果你的应用支持其他形式因素(平板电脑、电视或 Wear OS),你也应该为每个形式因素添加截图。
如果你有的话,也可以添加促销图形和视频。如果你的应用使用了违反谷歌 Play 政策的图形,它可能会被拒绝,所以请确保你使用的图片是你的,并且不包含受版权保护或不适当的内容。
准备发布
在准备发布之前,请确保您的构建已使用签名密钥签名。如果您正在发布应用更新,请确保它与相同的包名、使用相同的密钥签名,并且版本代码高于 Play Store 上的当前版本。
您还必须确保遵循开发者政策(以避免任何违规行为),并确保您的应用遵循应用质量指南。更多内容列在发布清单中,您可以在support.google.com/googleplay/android-developer/
查看。
APK/app bundle
您可以上传 APK 或更新的格式:Android App Bundle。转到发布,然后应用发布。这将显示每个轨道中活跃和草稿发布的摘要。
您可以在不同的轨道上发布应用:
-
生产
-
开放测试
-
封闭测试
-
内部测试
我们将在本章的管理应用发布部分详细讨论发布轨道。
选择您将创建发布的轨道。对于生产轨道,您可以在左侧选择管理。对于其他轨道,请先点击测试,然后选择轨道。要在封闭测试轨道上发布,您还必须选择管理轨道,然后通过点击创建轨道来创建一个新的轨道。
完成后,您可以在页面右上角点击创建新发布。在要添加的 Android App Bundles 和 APKs部分,您可以上传您的 APK 或 app bundle。
确保 app bundle 或 APK 文件已由您的发布签名密钥签名。如果未正确签名,Google Play 控制台将不接受。如果您正在发布更新,app bundle 或 APK 的版本代码必须高于现有版本。
您还可以添加发布名称和发布说明。发布名称供开发者使用以跟踪发布,对用户不可见。默认情况下,上传的 APK 或 app bundle 的版本名称设置为发布名称。发布说明将显示在 Play 页面上,并告知用户应用的更新内容。
发布说明的文本必须添加到语言的标签内。例如,默认的美国英语语言的开头和结尾标签是<en-US>
和</en-US>
。如果您的应用支持多种语言,默认情况下,每个语言标签都会显示在发布说明的字段中。然后您可以添加每种语言的发布说明。
如果您已经发布了应用,您可以从之前的发布中复制发布说明,并通过点击从上一个发布复制按钮并从列表中选择来重用或修改它们。
当您点击保存按钮时,发布将被保存,您可以在以后返回。审查发布按钮将带您到可以审查和发布发布的屏幕。
发布版本
如果您准备好发布您的发布,请转到 Play 控制台并选择您的应用。转到发布并选择您的发布轨道。点击发布标签,然后点击发布旁边的编辑按钮:
图 17.12 – 生产轨道上的草稿发布
您可以审查 APK 或应用包、发布名称和发布说明。点击审查发布按钮以开始发布的发布。Play 控制台将打开审查和发布屏幕。在这里,您可以审查发布信息并检查是否有警告或错误。
如果你正在更新一个应用,你还可以在创建另一个版本时选择发布百分比。将其设置为 100%意味着它将可供所有用户下载。当你将其设置为较低的百分比,例如,50%时,发布将可供一半的现有用户使用。
如果你对自己的发布有信心,请点击页面底部的开始发布到生产按钮。发布您的应用后,它需要一段时间(新应用可能需要 7 天或更长时间)才能进行审查。您可以在 Google Play 控制台的右上角查看状态。这些状态包括以下内容:
-
待发布(您的应用正在审查中)
-
已发布(您的应用现在可在 Google Play 上使用)
-
拒绝(您的应用因违反政策而未发布)
-
暂停(您的应用违反了 Google Play 政策并被暂停)
如果您的应用存在问题,您可以解决它们并重新提交应用。您的应用可能因版权侵权、假冒和垃圾邮件等原因被拒绝。
一旦应用发布,用户现在可以下载它。新应用或应用更新在 Google Play 上变为可用可能需要一些时间。如果您正在尝试在 Google Play 上搜索您的应用,它可能不可搜索。请确保您在生产和开放轨道上发布它。
管理应用发布
您可以缓慢地在不同的轨道上发布您的应用以测试它们,然后再向用户公开发布。您还可以进行定时发布,使应用在特定日期可用,而不是一旦通过 Google 的审核就自动发布。
发布轨道
在为应用创建发布时,您可以选择四个不同的轨道:
-
生产是所有人都可以看到应用的地方。
-
公开测试针对更广泛的公众测试。发布将在 Google Play 上可用,任何人都可以加入测试计划并测试。
-
封闭测试旨在为测试预发布版本的小组用户。
-
内部测试是在开发/测试应用时为开发者/测试者构建的。
内部、封闭和公开渠道允许开发者创建一个特殊版本,并允许真实用户在其余用户使用生产版本的同时下载它。这让你能够知道发布版本是否有错误,并在将其推广给所有人之前快速修复它们。这些渠道的用户反馈也不会影响你应用的公开评论/评分。
理想的方式是在开发期间和内部测试阶段首先在内部渠道发布。当预发布版本准备就绪时,你可以为少量信任的人/用户/测试者创建一个封闭测试。然后,你可以创建一个公开测试,允许其他用户在全面上线生产之前尝试你的应用。
要访问每个渠道并管理发布,你可以进入 Google Play 控制台的发布部分,并选择生产或测试,然后选择公开测试、封闭测试或内部测试渠道。
反馈渠道和订阅链接
在内部、封闭和公开渠道中,有一个用于反馈 URL 或电子邮件地址和测试者如何加入你的测试的部分。你可以在反馈 URL 或电子邮件地址下提供一个电子邮件地址或网站,测试者可以将他们的反馈发送到那里。当他们在你的测试计划中订阅时,这会被显示出来。
在测试者如何加入你的测试部分,你可以复制链接与测试者分享。他们可以使用此链接加入测试计划。
内部测试
此渠道用于在开发/测试应用时的构建。这里的发布将快速对内部测试者可用。在测试者标签页中,有一个测试者部分。你可以选择现有的列表或创建一个新的列表。内部测试的测试者数量上限为 100。
封闭测试
在测试者标签页中,你可以为测试者选择电子邮件列表或Google Groups。如果你选择电子邮件列表,请选择一个测试者列表或创建一个新的列表。封闭测试的测试者数量上限为 2,000。
如果你选择the-alpha-group@googlegroups.com
),该组的所有成员都将成为测试者。
公开测试
在测试者标签页中,你可以为测试者设置无限或有限数量。你可以设置的有限测试的最小测试者数量是 1,000。
在公开、封闭和内部渠道中,你可以添加用户作为你的应用的测试者。你将在下一节中学习如何添加测试者。
分阶段推广
在推出应用更新时,你可以首先将它们发布给一小群用户。然后,当发布版本出现问题时,你可以停止推广或发布另一个更新来修复问题。如果没有问题,你可以逐渐增加推广百分比。这被称为分阶段推广。
如果你已将更新发布给不到 100% 的用户,你可以转到 Play Console,选择发布,点击跟踪,然后选择发布选项卡。在你要更新的发布下方,你可以看到管理发布下拉菜单。它将包含更新或停止发布的选项。
你可以选择管理发布,然后更新发布来增加发布发布的百分比。将出现一个对话框,你可以输入发布百分比。你还可以点击更新按钮来更新百分比。
完全发布将使发布内容对所有用户可用。低于该百分比的任何百分比意味着发布内容仅对相应百分比的用户可用。
如果在分阶段发布过程中发现重大错误或崩溃,你可以转到 Play Console,选择发布,点击跟踪,然后选择发布选项卡。在你要更新的发布下面,选择管理发布,然后停止发布。将出现一个包含额外信息的对话框。添加可选的备注,然后点击停止发布按钮以确认:
图 17.13 – 停止分阶段发布的对话框
当分阶段发布被停止时,你的跟踪页面中的发布页面将更新为发布已停止和恢复发布按钮:
图 17.14 – 停止分阶段发布的发布页面
如果你已修复问题,例如在后台,且无需发布新更新,你可以恢复你的分阶段发布。要这样做,请转到 Play Console,选择发布,点击跟踪,然后选择发布选项卡。选择发布并点击恢复发布按钮。在恢复分阶段发布对话框中,你可以更新百分比并点击恢复发布以继续发布。
托管发布
当你在 Google Play 上发布新版本时,它将在几分钟内发布。你可以将其更改为稍后发布。这在针对特定日期时很有用,例如,与 iOS/web 发布的同一日期或发布日期之后。
在创建和发布你想要控制的发布的更新之前,必须设置托管发布。当你选择 Google Play Console 上的你的应用时,你可以在左侧选择发布概览。在托管发布部分,点击开启托管****发布按钮:
图 17.15 – 发布概览中的托管发布
托管发布对话框将显示。在此,你可以开启或关闭托管发布,然后点击保存按钮。
当你开启管理发布时,你可以继续添加和提交应用的更新。你可以在更改审查下的发布概述中看到这些更改。
一旦更改被批准,待审查的更改将变为空,并移动到准备发布的更改部分。在那里,你可以点击发布更改按钮。在出现的对话框中,你可以点击发布更改按钮以确认。然后你的更新将立即发布:
图 17.16 – 准备发布的更改
在本节中,你了解了可以测试发布、执行分阶段发布的发布轨道,以及管理发布时间。
通过一个活动来测试你所学的一切。
活动 17.01 – 发布应用
作为本书的最后一项活动,你被要求创建一个 Google Play 开发者账户并发布你构建的新 Android 应用。你可以发布本书或另一个项目中构建的应用之一。你可以使用以下步骤作为指南:
-
前往 Google Play 开发者控制台(
play.google.com/console
)并创建一个账户。 -
创建一个密钥库,你可以用它来签署发布构建。
-
为发布生成 Android 应用包。
-
在发布到生产轨道之前,在公开测试轨道上发布应用。
注意
本章已解释了发布应用的详细步骤,因此没有为这个活动提供单独的解决方案。你可以遵循本章的练习来成功完成前面的步骤。所需的精确步骤将取决于你的应用和你要使用的设置。
摘要
本章涵盖了 Google Play 商店:从准备发布到创建 Google Play 开发者账户,最后发布你的应用。我们从对应用进行版本管理、生成密钥库、创建 APK 文件或 Android 应用包并使用发布密钥库对其进行签名,以及存储密钥库及其凭证开始。然后我们转向在 Google Play 控制台中注册账户、上传你的 APK 文件或应用包以及管理发布。
这是本书贯穿始终的工作成果的总结——发布你的应用并将其向世界开放是一项伟大的成就,并展示了你在整个课程中的进步。
在本书的整个过程中,你获得了许多技能,从 Android 应用开发的基础到实现 RecyclerViews、从网络服务获取数据、通知和测试等功能。你看到了如何通过最佳实践、架构模式和动画来改进你的应用,最后,你学习了如何将它们发布到 Google Play。
这只是你作为安卓开发者的旅程的开始。随着你继续构建更复杂的个人应用并在此基础上扩展你的学习,你还有许多更高级的技能需要掌握。
记住,安卓系统是持续演进的,所以请确保自己跟上最新的安卓版本。你可以访问developer.android.com/
来获取最新的资源,并进一步沉浸在安卓的世界中。