安卓-13-现代开发秘籍-全-
安卓 13 现代开发秘籍(全)
原文:
zh.annas-archive.org/md5/5cf25921d714ee3b6d555ed458f86f26译者:飞龙
前言
《现代安卓 13 开发烹饪书》 为寻求使用最新技术构建尖端安卓应用程序的开发者提供了一本全面的指南。安卓是全球最广泛使用的移动操作系统,为数十亿设备提供动力。
安卓 13,安卓平台的最新主要版本,引入了几个令人兴奋的功能和改进,旨在提升用户体验,提高性能,并使开发者能够创建强大且创新的应用程序。这本烹饪书专注于利用这些新功能构建现代、功能丰富的安卓应用程序,以满足当今用户的需求。
随着安卓生态系统的快速演变,开发者面临着跟上最新工具、库和最佳实践的挑战。《现代安卓 13 开发烹饪书》 通过提供实用的食谱和逐步说明来解决这个问题,以解决日常开发任务并实现现代安卓应用程序架构模式。
随着你在这本烹饪书中的食谱中不断进步,你将构建新的短期项目,这些项目将让你接触到更多模式和组件,帮助你获得构建现代安卓应用程序的宝贵见解。我选择这种方法,因为这是一本烹饪书,为所有章节构建一个项目会变得冗余,所以准备好开始构建吧。无论你是刚开始安卓开发之旅的新手,还是寻求提升技能的经验丰富的开发者,《现代安卓 13 开发烹饪书》都是你掌握安卓应用开发最新技术和最佳实践的必备资源。
本书面向对象
本书旨在满足在安卓开发领域有一到四年经验的开发生态。无论你是希望扩展知识的初级开发者,还是寻求提升技能的中级开发者,《现代安卓 13 开发烹饪书》 都能提供宝贵的见解和实用的解决方案,以增强你的安卓开发专业知识。
通过假设对安卓开发概念有基础了解,并对安卓生态系统熟悉,本书深入探讨更高级的主题和现代开发技术。它作为一本全面资源,帮助你跟上安卓 13 的最新进展,并学习如何在项目中有效地利用它们。
烹饪书格式提供了一种实用方法,通过一系列解决安卓开发者日常开发任务和挑战的食谱来呈现。每个食谱都提供了清晰、逐步的说明和相关的代码示例,允许你直接在项目中实施解决方案。
以 《现代安卓 13 开发烹饪书》 为指南,你将具备知识和技能,随着你技能的提升来应对安卓项目。
本书涵盖内容
第一章, 现代 Android 开发技能入门,介绍了现代 Android 开发,并从介绍 Android 开发的基础知识开始,包括 Android Studio IDE 和 Kotlin 编程语言。然后讨论 Android 应用的各个组成部分,例如在 Compose 中创建你的第一个按钮、Android 项目结构,以及使用 Gradlew 命令运行 Android 项目。
第二章, 使用声明式 UI 创建屏幕并探索 Compose 原则,介绍了声明式 UI 的概念以及如何用它来创建 Android 应用中的屏幕。声明式 UI 是一种描述应用 UI 的方式,它关注的是 UI 应该看起来是什么样子,而不是如何实现。这使得创建既美观又易于维护的复杂 UI 变得更加容易。本章接着探讨了 Jetpack Compose 的基本原则,这是 Android 的声明式 UI 框架,通过简单易懂的项目进行讲解。
第三章, 在 Jetpack Compose 中处理 UI 状态和使用 Hilt,深入探讨了处理 UI 状态和使用 Hilt 在 Jetpack Compose 中的基本概念,为你提供了实用的食谱来管理状态并确保应用的有效稳健性。到本章结束时,你将牢固掌握 ViewModel 概念、使用 Hilt 进行依赖注入、将 Compose 集成到现有项目中,以及为 Compose 视图和 ViewModel 编写全面测试。
第四章, 现代 Android 开发中的导航,深入探讨了 Compose 中的导航主题,探索了各种食谱,这些食谱将为你提供在 Android 应用中实现高效且无缝导航体验所需的技能。到本章结束时,你将全面理解 Compose 中的导航概念和技术,这将使你能够构建直观且交互式的用户体验,无缝地引导用户通过你的应用。
第五章, 使用 Datastore 存储数据并进行测试,深入探讨了在 Android 应用中实现和管理 DataStore 的必要方面。我们将涵盖一系列主题,并提供实用的食谱来帮助你熟练处理 Android 项目中的数据。到本章结束时,你将全面理解实现 DataStore、使用依赖注入、在 Android Proto DataStore 和 DataStore 之间进行选择、管理数据迁移,以及为 DataStore 实现编写实践测试。
第六章,使用 Room 数据库和测试,探讨了 Room 数据库库的强大功能,并深入探讨了确保数据库驱动 Android 应用程序完整性和功能性的测试策略。到本章结束时,您将牢固掌握使用 Room 数据库库和测试策略来确保数据库驱动 Android 应用程序的质量和可靠性的方法。
第七章,开始使用 WorkManager,提供了对 WorkManager 的概述,WorkManager 是一个强大的 Jetpack 库,它使 Android 应用程序中的高效和灵活的后台处理成为可能。我们将涵盖 WorkManager 的基本概念和功能,使您能够无缝地将后台任务集成到项目中。到本章结束时,您将牢固掌握使用 WorkManager,使您能够将高效且可靠的后台处理能力集成到您的 Android 应用程序中。
第八章,开始使用 Paging,介绍了 Paging,这是一个强大的 Jetpack 库,它促进了 Android 应用程序中高效且无缝的数据加载。我们涵盖了 Paging 的必要概念和功能,使您能够实现数据分页并优化应用程序的性能。到本章结束时,您将牢固理解 Paging 及其功能,使您能够实现高效的数据分页在您的 Android 应用程序中。
第九章,为大型屏幕构建应用,探讨了设计和管理能够在可折叠和其他大型屏幕(如平板电脑)上提供引人入胜体验的 Android 应用程序的原则和技术。我们将涵盖适应您的应用程序用户界面、优化布局以及有效利用 Material 3 提供的额外屏幕空间的各种方面。到本章结束时,您将牢固理解设计和管理在大型屏幕上提供引人入胜体验的 Android 应用程序的原则和技术。
第十章,使用 Jetpack Compose 实现您的第一个 Wear OS 应用,提供了使用 Jetpack Compose(一个现代 UI 工具包,用于构建 Android 应用程序)实现您的第一个 Wear OS 应用的指导。我们将涵盖创建引人入胜且直观的可穿戴体验的必要步骤和概念。到本章结束时,您将掌握在 Wear OS 中创建组件的方法,并能够在您的虚拟设备上运行 Wear OS。
第十一章,《GUI 警报 - 在现代 Android 开发中菜单、对话框、Toast、Snackbar 等的新特性》,探讨了 GUI 警报、菜单、对话框、Toast、Snackbar 和其他用户界面组件的最新增强功能和特性。我们将介绍使开发者能够创建更互动和吸引人的用户体验的进步。
第十二章,《在 Android Studio 中使用的小技巧和窍门》分享了一系列宝贵的技巧和窍门,帮助你在使用 Android Studio 进行 Android 应用开发时最大化你的生产力和效率。我们将涵盖各种功能、快捷键和隐藏的宝藏,这些都可以简化你的工作流程并提升你的开发体验。
要充分利用本书
您需要在您的笔记本电脑上安装 Java 和 Android Studio,这是我们使用的 IDE。我们假设您已经了解 Java 安装和 Git 源代码控制,因为您将需要从技术要求部分获取大部分代码以进行编码。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Android Studio | Windows、macOS 或 Linux |
| Java 版本 11 | |
| Android Studio 版本 | Android Studio Flamingo | 2022.2.1 Patch 1 |
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Modern-Android-13-Development-Cookbook。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他丰富的图书和视频资源库中的代码包可供在github.com/PacktPublishing/下载。查看它们吧!
下载彩色图像
我们还提供了一份包含书中截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/HlgRf。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在我们的第二个示例中,我们有两个函数,main()和reverseString()。main()不接受任何输入。”
代码块设置如下:
fun main() {
val stringToBeReversed = "Community"
println(reverseString(stringToBeReversed))
}
fun reverseString(stringToReverse: String): String {
return stringToReverse.reversed()
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
data class City(
val id: Int,
@StringRes val nameResourceId: Int,
@DrawableRes val imageResourceId: Int
)
任何命令行输入或输出都按照以下方式编写:
$ git clone git@github.com:PacktPublishing/Modern-Android-13-Development-Cookbook.git
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“点击完成并等待 Gradle 同步。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们读者的反馈总是受欢迎的。
一般反馈:如果您对本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《现代 Android 13 开发食谱》,我们很乐意听听您的想法!请选择www.amazon.in/review/create-review/?asin=1803235578为这本书留下您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载这本书的免费 PDF 副本
感谢您购买这本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?您的电子书购买是否与您选择的设备不兼容?
别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取福利:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781803235578
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。
第一章:现代 Android 开发技能入门
Android 操作系统(OS)是移动设备中最受欢迎的平台之一,全球有众多用户。该操作系统用于汽车和可穿戴设备,如智能手表、电视和手机,这使得 Android 开发者的市场相当广泛。因此,新开发者需要学习如何利用新的现代 Android 开发(MAD)技能来构建 Android 应用程序。
自从 2008 年推出并在第一个集成开发环境(IDE)Eclipse 和 NetBeans 中使用以来,Android 已经取得了长足的进步。如今,Android Studio 是 Android 开发的推荐 IDE,与之前相比,当 Java 是首选语言时,Kotlin 现在是首选语言。
Android Studio 包括对 Kotlin、Java、C++和其他编程语言的支持,这使得这个 IDE 适合具有不同技能集的开发者。
因此,通过本章的食谱,您将在本章结束时安装 Android Studio,使用 Jetpack Compose 构建您的第一个 Android 应用程序,并学习一些 Kotlin 语法,利用 Android 开发的优选语言。此外,本介绍将为您理解对 MAD 至关重要的高级材料打下基础。
在本章中,我们将介绍以下食谱:
-
使用变量和惯用用法用 Kotlin 编写您的第一个程序
-
使用 Android Studio 创建一个“Hello, Android Community”应用程序
-
在 Android Studio 中设置您的模拟器
-
在 Jetpack Compose 中创建一个按钮
-
利用
gradlew命令在 Android Studio 中清理和运行您的项目 -
理解 Android 项目结构
-
在 Android Studio 中进行调试和日志记录
技术要求
成功运行 Android IDE 和模拟器可能对您的计算机来说是一项艰巨的任务。您可能听说过这样一个笑话:运行 Android Studio 的机器在冬天可以用作暖气。好吧,这确实有一定的真实性,因此您的计算机应该具备以下规格,以确保您的系统能够应对 IDE 的需求:
-
安装 64 位 Microsoft Windows、macOS 或 Linux,并配备稳定的互联网连接。本书中的食谱是在 macOS 上开发的。您也可以使用 Windows 或 Linux 笔记本电脑,因为使用它们之间没有区别。
-
对于 Windows 和 Linux 用户,您可以点击此链接安装 Android Studio:
developer.android.com/studio/install。 -
至少需要 8 GB 的 RAM 或更多。
-
Android Studio、Android 软件开发工具包(SDK)和 Android 模拟器至少需要 8 GB 的可用磁盘空间。
-
建议的最低屏幕分辨率为 1280 x 800。
-
您可以从
developer.android.com/studio下载 Android Studio。
本章的完整源代码可在 GitHub 上找到:github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_one
使用变量和惯用法编写你的第一个 Kotlin 程序
Kotlin 是 Android 开发的推荐语言;你仍然可以使用 Java 作为你的首选语言,因为许多遗留应用程序仍然严重依赖于 Java。然而,在这本书中,我们将使用 Kotlin,如果你是第一次使用 Kotlin 语言构建 Android 应用程序,Kotlin 组织有优秀的资源可以帮助你开始,包括免费的练习练习和自我定速评估,称为 Kotlin Koans (play.kotlinlang.org/koans/overview)。
此外,你可以使用 Kotlin 语言进行多平台开发,使用 Kotlin Multiplatform Mobile (KMM),其中你可以在 iOS 和 Android 应用程序之间共享标准代码,仅在必要时编写特定平台的代码。KMM 目前处于 Alpha 版本。
准备工作
在这个菜谱中,你可以使用在线 Kotlin 游乐场 (play.kotlinlang.org/) 来运行你的代码,或者在 Android Studio IDE 中运行代码。或者,如果你计划使用 Koans 进行更多 Kotlin 练习,你可以下载并使用 IntelliJ IDEA IDE。
如何做...
在这个菜谱中,我们将探索并修改一个我们将用 Kotlin 编写的简单程序;你可以将程序视为我们给计算机或移动设备下达的指令,以执行我们给它们的动作。例如,我们将在程序中创建一个问候语,稍后编写一个不同的程序。
对于这个菜谱,你可以选择 Android Studio 或免费的在线 IDE,因为我们将会涉及到一些 Kotlin 功能:
- 如果你第一次使用 Kotlin 在线游乐场,你会看到如下截图,其中有一个
println语句显示Hello, world,但为了我们的示例,我们将这个问候语改为 Hello, Android Community,并运行代码。

图 1.1 – 在线 Kotlin 编辑器
-
让我们看看另一个例子;一个在面试中常用的流行算法问题——反转字符串。例如,你有一个字符串
Community,我们想要反转这个字符串,以便输出将是ytinummoC。解决这个问题有几种方法,但我们将使用 Kotlin 的惯用方式来解决它。 -
在你的 IDE 的游乐场或 Kotlin 游乐场中输入以下代码:
fun main() {val stringToBeReversed = "Community"println(reverseString(stringToBeReversed))}fun reverseString(stringToReverse: String): String {return stringToReverse.reversed()}
它是如何工作的...
在 Kotlin 中提到,通过利用默认参数值和仅设置需要更改的参数,有独特的方法可以使你的代码更干净、更精确、更简单。
fun是 Kotlin 编程语言中的一个词汇,代表函数,Kotlin 中的函数是执行特定任务的程序的一部分。我们第一个例子中的函数名是main(),在我们的main()函数中,我们没有任何输入。通常,函数有名字,这样我们就能在代码库复杂时区分它们。
此外,在 Java 中,函数类似于方法。函数名有两个括号和大括号,以及println,它告诉系统打印一行文本。
如果你曾经使用过 Java,你可能会注意到 Kotlin 编程语言与 Java 非常相似。然而,开发者们现在都在谈论 Kotlin 语言对开发者来说是多么的出色,因为它提供了更丰富的语法和复杂的类型系统,并且处理了 Java 多年来一直存在的空指针问题。为了充分利用 Kotlin 语言的力量并编写更简洁的代码,了解 Kotlin 惯用表达式可能会有所帮助。Kotlin 惯用表达式是经常使用的集合,有助于操作数据并使 Android 开发者的体验更加轻松。
在我们的第二个例子中,我们有两个函数,main()和reverseString()。main()没有输入,但reverseString()确实接收一个字符串输入。你也会注意到我们使用了val,这是 Kotlin 中用来指代只能设置为单个值的不可变值的独特词汇,与var相对,var是可变的变量,意味着它可以被重新赋值。
我们创建了一个val stringToBeReversed,它是一个字符串,并将其命名为"Community",然后在main()函数内部调用println,并将我们想要在reverseString()函数中打印的文本传递进去。此外,在这个例子中,我们的reverseString函数从字符串对象接收一个string参数,然后返回一个字符串类型。

图 1.2 – Kotlin playground 上的反转字符串
还有更多东西要学习,而且公平地说,我们在本食谱中涵盖的内容只是 Kotlin 惯用表达式所能做到的一小部分。本食谱旨在介绍我们可能在后续章节中触及或使用的一些概念,但不会深入探讨,因为我们将后续章节中更多地探索 Kotlin。因此,现在了解 Kotlin 惯用表达式及其重要性是很好的。
参考资料还有
对 Kotlin 语法和常用用例的更好理解对你的日常工作至关重要,因此请查看以下资源:
-
JetBrains Academy 在这里提供了一个很棒的免费 Kotlin 基础课程:
hyperskill.org/tracks/18。 -
Kotlin 文档也是一个很好的资源,可以随时查阅:
kotlinlang.org/docs/home.html。
使用 Android Studio 创建 Hello, Android Community 应用程序
现在我们已经安装了 Android Studio,我们将创建我们的第一个 Android 应用程序。此外,我们将使用 Compose – 提前说明一下,在这个菜谱中,我们不会深入探讨 Compose,因为我们有一个专门的章节关于 Compose,即 第二章,使用声明性 UI 创建屏幕并探索 Compose 原则。
准备工作
在开始之前,了解你的 Android 项目所在位置对于保持一致性很有帮助。默认情况下,Android Studio 在你的家目录中创建一个名为 AndroidStudioProjects 的包,在这里你可以找到你创建的所有项目。
如果你想要更改它,你也可以决定文件夹应该放在哪里。此外,确保你使用的是 Android Studio 的最新版本以利用所有优秀功能。要了解最新的 Android 版本,你可以使用以下链接:developer.android.com/studio/releases。
如何操作...
在 Android Studio IDE 中,一个项目模板是一个包含创建应用程序所需所有部分的 Android 应用程序,它帮助你开始并设置。
因此,一步一步地,我们将创建我们的第一个 Android 应用程序并在模拟器上运行它:
-
通过点击你坞栏中的 Android Studio 图标或你存储 Android Studio 的位置来启动 Android Studio。
-
你将看到一个欢迎的 Android Studio 窗口打开,你可以点击 New Project。或者,你也可以转到 文件 并点击 New Project。
-
选择 Empty Compose Activity 并点击 Next。

图 1.3 – 创建空的 Compose 活动
- 一旦加载了空的 Compose 活动屏幕 (图 1.4), 你将看到包括
Android Community在内的字段,其他设置保持不变。你还会注意到默认语言是 Kotlin。
关于 最小 SDK 版本,我们的目标是 API 21: Android 5.0 (Lollipop),这表示你的应用可以运行的最小 Android 版本,在我们的案例中,大约是 98.8% 的设备。你也可以点击下拉菜单来了解更多关于最小 SDK 的信息。

图 1.4 – 命名你的空 Compose 活动
点击 Finish 并等待 Gradle 同步。
-
继续探索包,您将注意到一个扩展
ComponentActivity()的MainActivity类,而ComponentActivity()又扩展了Activity();在内部,我们有一个fun onCreate,这是从ComponentActivity继承而来的。您还会看到一个setContent{},这是一个用于设置可组合函数内容的函数。setContent{}函数接受一个包含应显示的 UI 元素的 lambda 表达式,在我们的情况下,它持有我们应用程序的主题。在Greeting()函数中,我们将更改提供的内容,并添加我们自己的问候语"Hello, Android Community"并运行,这样我们就创建了第一个Greeting:class MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?){super.onCreate(savedInstanceState)setContent {AndroidCommunityTheme {Surface(modifier = Modifier.fillMaxSize(),color =MaterialTheme.colors.background) {Greeting("Hello, AndroidCommunity")}}}}} -
让我们继续修改
Greeting()函数,并将name参数分配给文本:@Composablefun Greeting(name: String) {Text(text = name)}
此外,您也可以直接将 "Hello, Android Community" 传递给默认实现,这将产生相同的用户界面。
-
就像在 XML 视图中一样,您可以使用
@Preview(showBackground = true)在不运行模拟器中的应用程序的情况下轻松查看您正在构建的 UI,所以如果它不可用,我们就将其添加到我们的代码中。默认情况下,项目附带一个包含Preview()模板的模板:@Preview(showBackground = true)@Composablefun DefaultPreview() {} -
最后,当您运行应用程序时,您应该有一个类似于 图 1**.5 的屏幕。在接下来的食谱中,我们将查看如何逐步设置您的模拟器,所以请不用担心这一点。

图 1.5 – 显示“Hello, Android Community”的屏幕
它是如何工作的…
使用 Jetpack Compose 创建视图的关键好处是,由于您使用相同的语言(Kotlin)编写整个代码库,因此可以加快开发速度,并且更容易进行测试。您还可以创建可重用的组件,并根据您的需求进行自定义。
因此,确保降低错误发生的可能性,并且不需要使用 XML 编写视图,因为这是繁琐且麻烦的。onCreate() 函数被认为是 Android 应用程序的入口点。此外,我们使用 modifier 函数来添加行为并装饰可组合项。我们将在下一章中更多地讨论 modifier 和 Surface 函数可以做什么。
在 Android Studio 中设置您的模拟器
Android Studio 是一个可靠且成熟的集成开发环境。因此,自 2014 年以来,Android Studio 一直是开发 Android 应用程序的首选 IDE。当然,您仍然可以使用其他 IDE,但 Android Studio 的优势是您不需要单独安装 Android SDK。
准备工作
您需要完成之前的食谱,才能跟随本食谱进行操作,因为我们将会设置我们的模拟器来运行我们刚刚创建的项目。
如何操作…
本章旨在对初学者友好,并在您通过食谱进行工作时,帮助您顺利过渡到更高级的 Android。
让我们按照以下步骤进行,看看您如何设置模拟器并在使用 Android Studio 创建 Hello, Android Community App菜谱中运行您的项目:
-
导航到工具 | 设备管理器。一旦设备管理器就绪,您有两个选项:虚拟或物理。虚拟意味着您将使用模拟器,而物理意味着您将启用您的 Android 手机以调试 Android 应用程序。就我们的目的而言,我们将选择虚拟。
-
点击创建设备,将弹出虚拟设备配置屏幕。
-
选择手机。您会注意到 Android Studio 还有其他类别,例如电视、Wear OS、平板电脑和汽车。现在我们先使用手机,在未来的章节中,我们将尝试使用Wear OS。点击下一步。

图 1.6 – 选择虚拟设备
- 在图 1.7 中,您将看到一个推荐系统镜像的列表。您可以选择任何一个或使用默认的,在我们的案例中是S,针对 Android 12,尽管您可能想使用最新的 API,33,然后点击下一步。

图 1.7 – 选择系统镜像
- 您现在将到达Android 虚拟设备(AVD)屏幕,您可以命名您的虚拟设备。您可以输入一个名称或直接保留默认值,Pixel 2 API 31,然后点击完成。

图 1.8 – 设置 AVD 的最后步骤
- 通过运行它来测试您的虚拟设备,并确保它按预期工作。一旦您在模拟器上运行应用程序,您应该会看到类似于*图 1**.9 的内容。

图 1.9 – 运行模拟器的设备管理器部分
重要提示
要创建物理测试设备,您必须进入您的 Android 手机的设置,选择关于手机 | 软件信息 | 构建号,并在释放按钮的同时按住,直到您看到您现在离成为开发者只有四步之遥。一旦计数完成,您将看到一个通知说开发者选项已成功启用。现在您只需要使用通用串行总线(USB)并切换 USB 调试。最后,您将看到您的物理手机已准备好进行测试。
它是如何工作的…
测试并确保您的应用程序显示预期的结果非常重要。这就是为什么 Android Studio 使用模拟器来帮助开发者确保他们的应用程序在标准设备上运行的方式。此外,Android 手机自带开发者选项,方便开发者使用,这使得支持不同数量的 Android 设备变得更加容易,也有助于在模拟器中重现难以找到的 bug。
在 Jetpack Compose 中创建按钮
我们必须注意,我们无法在一个菜谱中涵盖所有视图;我们有一个专门用于学习更多关于 Jetpack Compose 的章节,所以在我们创建的项目中,我们只是尝试为我们的项目创建两个额外的视图。
准备工作
打开Android 社区项目,因为这是我们将在本菜谱中构建的项目。
如何做到这一点...
让我们从在 Compose 中实现一个简单的按钮开始:
-
让我们继续组织我们的代码,并通过添加
Column()来使文本居中。这应该添加到setContent{}函数中:Column(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center),horizontalAlignment = Alignment.CenterHorizontally) {Greeting("Hello, Android Community")}} -
现在,创建一个函数并命名为
SampleButton;在这个例子中,我们将不传递任何内容。然而,我们将有一个RowScope{},它定义了适用于我们按钮的modifier函数,并且我们将给我们的按钮起一个名字:click me。 -
在 Compose 中,当你创建一个按钮时,你可以设置其形状、图标和高度,检查它是否启用,检查其内容等等。你可以通过在
Button()组件上命令点击来检查如何自定义你的按钮:@Composablefun SampleButton() {Button(onClick = { /*TODO*/ },modifier = Modifier.fillMaxWidth().padding(24.dp),shape = RoundedCornerShape(20.dp),border = BorderStroke(2.dp, Color.Blue),colors = ButtonDefaults.buttonColors(contentColor = Color.Gray,backgroundColor = Color.White)) {Text(text = stringResource(id =R.string.click_me),fontSize = 14.sp,modifier = Modifier.padding(horizontal =30.dp, vertical = 6.dp))}}
在我们的SampleButton中,onClick没有任何操作;我们的按钮具有最大填充宽度的修饰符,24 密度无关像素(dp)的内边距,以及半径为 20 dp 的圆角。
我们还设置了按钮的颜色,并添加了click me作为文本。我们将字体大小设置为 14 缩放无关像素(sp),因为这有助于确保文本将很好地适应屏幕和用户的偏好。
- 此外,点击右上角的Split来预览你的屏幕元素,或者你可以点击设计部分来查看整个屏幕而不显示代码。

图 1.10 – 在 Android Studio 中查看代码和设计
-
最后,让我们调用我们的
SampleButton函数,其中包含Greeting函数,并运行应用程序:Column(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center),horizontalAlignment = Alignment.CenterHorizontally) {Greeting("Hello, Android Community")SampleButton()} -
编译并运行程序;你的应用应该看起来类似于图 1.11。

图 1.11 – 展示文本和按钮的屏幕截图
它是如何工作的...
一个 Composable 应用程序由多个 composable 函数组成,这些函数是带有@Composable注解的正常函数。正如 Google 文档所解释的,这个注解告诉 Compose 为更新和维护你的 UI 提供特殊支持。Compose 还允许你将代码结构化为小型可维护的块,你可以在任何给定点进行调整和重用。
还有更多...
由于很难在一个菜谱中涵盖所有视图,我们将在第二章中工作更多视图,使用声明性 UI 创建屏幕并探索 Compose 原则,探索最佳实践,并测试我们的 composable 视图。
利用 gradlew 命令在 Android Studio 中清理和运行你的项目
gradlew 命令是一个功能强大的 Gradle 包装器,具有出色的使用效果。然而,在 Android Studio 中,你不需要安装它,因为它是一个包含在项目中的脚本。
准备工作
然而,目前我们不会查看所有 Gradle 命令,而是使用最流行的命令来清理、构建、提供信息、调试,并在运行应用程序时扫描项目以查找任何问题。只要你在正确的目录中,你就可以在笔记本电脑的终端中运行这些命令,或者使用 Android Studio 提供的终端。
如何做…
按照以下步骤检查并确认 Gradle 是否按预期工作:
- 你可以通过简单地运行
./gradlew来检查版本。

图 1.12 – gradlew 版本
-
要构建和清理你的项目,你可以运行
./gradlew clean和./gradlew build命令。如果你的项目有任何问题,构建将失败,你可以调查错误。此外,在 Android 中,你始终可以不使用 Gradle 命令运行你的项目,只需利用 IDE 的运行和清理选项。我们将在 第十二章 中深入讨论这个主题,Android Studio 开发技巧和窍门帮助你。 -
以下是一些更有用的
gradlew命令;例如,当你的构建失败并且你想知道出了什么问题,使用这些命令进行调查或点击错误消息(见 图 1.13*):-
使用
--stacktrace选项运行以获取堆栈跟踪 -
使用
--info或--debug选项运行以获取更多日志输出 -
使用
--scan运行以获取全面洞察
-

图 1.13 – 构建错误输出
它是如何工作的
Gradle 是一种通用构建工具,在 Android 开发中证明非常强大。此外,你可以创建和发布自定义插件来封装你的约定和构建功能。Gradle 的优点包括增量构建工作,适用于测试执行、编译以及构建系统中发生的任何其他任务。
相关内容
更多关于 Gradle 及其功能的介绍,请参阅:gradle.org/.
理解 Android 项目结构
如果你第一次查看 Android 项目文件夹,你可能想知道在哪里添加你的代码以及包的含义。这个菜谱将带你了解每个文件夹包含的内容以及代码放在哪里。
准备工作
如果你打开你的项目,你会注意到许多文件夹。你的 Android 项目中的主要文件夹如下所示:
-
manifest文件夹 -
java文件夹 (test/androidTest) -
Res资源文件夹 -
Gradle 脚本
如何做…
让我们边学习边导航每个文件夹,了解存储在哪里,为什么:
-
在 图 1.14 中,你可以看到 包 下拉菜单;点击它,将弹出一个包含 项目、包、项目文件 等的窗口。
-
你可以选择使用 Android 标志,通过下拉菜单旁边的
Project高亮部分来查看你的项目。当你应用程序中有许多模块并且想要添加特定代码时,项目视图是最好的。请随意点击这些部分,看看它们包含的内容。

图 1.14 – Android Studio 项目结构
-
manifest文件夹是 Android 应用的真相来源;它包含AndroidManifest.xml文件。点击文件内部,你会注意到你有一个意图启动器,它会在你的模拟器上启动 Android 应用程序。 -
此外,版本号通常在 Gradle 中设置,然后合并到
manifest文件中,在 manifest 文件中我们添加所有需要的权限。你还会注意到包名、元数据、数据提取规则、主题和图标;如果你有一个独特的图标,你可以在这里添加一个。

图 1.15 – Android Studio 项目结构 manifest 文件
重要提示
使你的图标适应性强是添加图标到应用程序中的新流行方式。自适应图标在 MAD 中根据个别用户的主题显示不同。请参阅 developer.android.com/develop/ui/views/launch/icon_design_adaptive。
java文件夹包含我们在构建 Android 应用程序时创建的所有 Kotlin (.kt) 和 Java (.java) 文件。例如,在 图 1**.16 中,我们有一个包含 (androidTest) 和 (test) 的包,这就是我们添加测试的地方。点击所有文件夹,看看它们包含的内容。

图 1.16 – Android Studio 项目结构 Java 文件夹
- 在
androidTest文件夹中,我们编写我们的 UI 测试以测试 UI 功能,在测试文件夹中,我们编写我们的单元测试。单元测试测试我们代码的小部分,以确保所需的行为符合预期。测试驱动开发 (TDD) 在应用程序开发期间非常出色且有价值。一些公司遵循这个规则,但一些公司并不强制执行。然而,这是一个很好的技能,因为始终测试你的代码是一种良好的实践。
res 文件夹包含 XML 布局、UI 字符串、可绘制图像和 Mipmap 图标。另一方面,values 文件夹包含许多有用的 XML 文件,如 dimensions、colors 和 themes。点击 res 文件夹以熟悉其中的内容,因为我们将在下一章中使用它。

图 1.17 – Android Studio 项目结构 res 文件夹
重要提示
除非你从头开始构建一个新项目,否则许多应用程序仍然使用 XML 布局,开发者现在选择使用 Jetpack Compose 作为一项进步来开发新屏幕。因此,你可能需要维护或了解如何用 XML 编写视图。
- 最后,在
Gradle Scripts中,你会看到定义我们可以应用于我们的模块的构建配置的文件。例如,在build.gradle(Project: AndroidCommunity)中,你会看到一个顶层文件,你可以在这里添加适用于所有子项目模块的配置选项。

图 1.18 – Android Studio 项目结构中的 Gradle 脚本
它是如何工作的…
在 Android Studio 中,对于初学者来说,不知道文件在哪里以及什么是重要的可能会令人不知所措。因此,有一个逐步指南说明在哪里添加你的测试或代码,并理解 Android 项目结构是至关重要的。此外,在复杂的项目中,你可能会发现不同的模块;因此,理解项目结构是有帮助的。在 Android Studio 中,一个模块是一组源文件和构建设置,它允许你将项目划分为具有特定目的的独立实体。
Android Studio 中的调试和日志记录
调试和日志记录在 Android 开发中至关重要,你可以编写在 Logcat 中出现的日志消息,以帮助你找到代码中的问题或验证代码在应该执行时是否执行。
我们将在这里介绍这个主题,但说我们将在一个菜谱中涵盖所有内容是不公平的;因此,我们将更多地介绍调试和日志记录,在 第十二章,Android Studio 开发技巧与窍门 中。
准备工作
让我们用一个例子来理解日志记录。以下日志方法按优先级从高到低列出。它们在记录网络错误、成功调用和其他错误时是合适的:
-
Log.e(): 日志错误 -
Log.w(): 日志警告 -
Log.i(): 日志信息 -
Log.d(): 调试显示对开发者的关键消息,最常用的日志 -
Log.v(): 详细
一个好的做法是将每个日志与一个 TAG 相关联,以便快速识别 Logcat 中的错误消息。一个“TAG”是指可以分配给 Android 应用程序中的 View 或其他 UI 元素的一个文本标签。在 Android 中使用 标签 的主要目的是提供一种方法,将附加信息或元数据与 UI 元素关联起来。
如何做…
让我们继续在我们的小型项目中添加日志消息:
-
我们将创建一个调试日志,然后运行应用程序:
Log.d(TAG, "asdf Testing call")
在 asdf 中查看,看你是否能找到消息。

图 1.19 – Android Logcat
你会注意到日志中有一个类名,我们的 TAG (MainActivity),以及显示的日志消息;请参见 图 1**.19 中的右箭头。
- 左箭头显示了提到的日志类型,通过下拉菜单,你可以快速查看基于特定规格的消息。
它是如何工作的…
调试是指在类中设置断点、减慢模拟器速度,并尝试找出代码中的问题。如果,例如,你在代码中遇到竞态条件,或者你的代码在某些设备上工作而在其他设备上不工作,调试功能就非常强大。
此外,为了利用调试功能,你首先需要将调试器附加到模拟器上,然后以调试模式运行。另一方面,日志记录可以帮助你记录在遇到问题时可能对你有帮助的信息。有时,调试可能会很具挑战性,但将日志放置在代码中需要的位置可能会非常有帮助。
一个实际案例是当你从 API 加载数据时;你可能希望在出现网络错误时记录它,以通知你如果网络调用失败会发生什么。因此,使用断点进行调试可能会帮助你放慢评估值的过程,由于我们在这个章节中没有构建很多内容,我们可以在后面的章节中通过不同的食谱重新审视这个主题。
参见
Timber 是一个具有小型、可扩展 API 的日志记录器,它提供了在 Android 标准Log类之上的实用功能,许多开发者用它来进行日志记录。有关 Timber 的更多信息,请参阅github.com/JakeWharton/timber。
第二章:使用声明式 UI 创建屏幕并探索 Compose 原则
移动应用程序需要用户界面(UI)以供用户交互。例如,在安卓中创建 UI 的旧方式是命令式的。这意味着使用独特的可扩展标记语言(XML)布局来创建应用程序 UI 的单独原型,而不是构建逻辑所使用的相同语言。
然而,随着现代安卓开发,有一种趋势是停止使用命令式编程,开始使用声明式的方式来制作用户界面,这意味着开发者根据接收到的数据来设计用户界面。这种设计范式使用一种编程语言来创建整个应用程序。
公平地说,对于新开发者来说,在构建用户界面时决定学习什么可能看起来有些困难:是采用旧的方式创建视图,还是选择新的 Jetpack Compose。然而,如果你在 Jetpack Compose 时代之前已经构建了一个安卓应用程序。
在这种情况下,你可能已经知道使用 XML 有点繁琐,尤其是如果你的代码库很复杂。然而,将 Jetpack Compose 作为首选选择可以使工作变得更简单。此外,它通过确保开发者使用更少的代码来简化 UI 开发,因为他们利用了直观的 Kotlin API。因此,新开发者创建视图时倾向于使用 Jetpack Compose 而不是 XML。
然而,了解两者可能是有益的,因为许多应用程序仍然使用 XML 布局,你可能会需要维护视图,但使用 Jetpack Compose 来构建新的。在本章中,我们将通过尝试使用列、行、盒子、懒列等来实现小型示例来查看 Jetpack Compose 的基础知识。
在本章中,我们将介绍以下食谱:
-
在 Jetpack Compose 中实现 Android 视图
-
在 Jetpack Compose 中实现可滚动列表
-
使用 Jetpack Compose 和视图页实现第一个标签布局
-
在 Compose 中实现动画
-
在 Jetpack Compose 中实现无障碍功能
-
使用 Jetpack Compose 实现声明式图形
技术要求
本章的完整源代码可以在github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter-two找到。要查看所有食谱,你需要分别运行所有预览函数。因此,寻找@Preview可组合函数来查看创建的 UI。
在 Jetpack Compose 中实现 Android 视图
在每个安卓应用程序中,拥有 UI 元素非常关键。安卓中的视图是 UI 的简单构建块。视图确保用户可以通过点击或其他动作与你的应用程序交互。这个食谱将探讨不同的 Compose UI 元素,并查看我们如何构建它们。
准备工作
在这个菜谱中,我们将创建一个项目,我们将在整个章节中重复使用它,所以让我们继续按照第一章,“现代 Android 开发技能入门”,中的步骤创建你的第一个 Android 项目。
创建一个名为Compose Basics的项目。此外,我们将主要使用预览部分来查看我们创建的 UI 元素。
如何做到这一点...
一旦你创建了项目,请按照以下步骤构建几个 Compose UI 元素:
-
在我们的项目中,让我们继续创建一个新的包,命名为 components。这是我们添加所有创建的组件的地方。
-
创建一个名为
UIComponents.kt的 Kotlin 文件;在UIComponent内部,继续创建一个可组合函数,命名为EditTextExample(),并调用OutlinedTextField()函数;这将提示你导入所需的导入,即androidx.Compose.material.OutlinedTextField:@Composablefun EditTextExample() {OutlinedTextField()} -
当你深入查看
OutlineTextField(见图 2.1),你会注意到该函数接受几个输入,当你需要自定义自己的可组合函数时,这非常有用。

图 2.1 – The OutlinedTextField 输入
-
在我们的例子中,我们不会对创建的 UI 做太多处理,而是仅仅看看我们是如何创建它们的。
-
现在,为了完全创建基于我们看到的输入类型的
OutlinedTextField(),我们可以给它一个文本和颜色,并使用Modifier()对其进行装饰;也就是说,通过给它特定的指令,例如fillMaxWidth(),这设置了最大宽度。当我们说填充时,我们只是指定它应该完全填充。我们将.padding(top)设置为16.dp,这会在dp中为内容的每一边应用额外的空间。它还有一个值,即要输入到OutlinedTextField中的值,以及一个onValueChangelambda,它监听输入变化。 -
我们还为我们的一些
OutlinedText在聚焦和未聚焦时设置了边框颜色,以反映不同的状态。因此,如果你开始输入,框会变成蓝色,如代码中指定的:@Composablefun EditTextExample() {OutlinedTextField(value = "",onValueChange = {},label = { Text(stringResource(id =R.string.sample)) },modifier = Modifier.fillMaxWidth().padding(top = 16.dp),colors =TextFieldDefaults.outlinedTextFieldColors(focusedBorderColor = Color.Blue,unfocusedBorderColor = Color.Black))} -
我们还有一种类型的
TextField,它不是轮廓式的,如果你比较OutlinedTextField接受的输入,你会注意到它们相当相似:@Composablefun NotOutlinedEditTextExample() {TextField(value = "",onValueChange = {},label = { Text(stringResource(id =R.string.sample)) },modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 16.dp),colors =TextFieldDefaults.outlinedTextFieldColors(focusedBorderColor = Color.Blue,unfocusedBorderColor = Color.Black))} -
你可以通过在
@Preview可组合函数内添加 Compose 函数来运行应用程序。在我们的例子中,我们可以创建UIElementPreview(),这是一个用于显示我们 UI 的预览函数。在图 2.2中,顶部视图是OutlinedTextField,而第二个是普通的TextField。

图 2.2 – OutlinedTextField 和 TextField
- 现在,让我们继续看看按钮的例子。我们将探讨以不同形状创建按钮的不同方法。如果你悬停在
Button()组合函数上,你会看到它接受什么作为输入,如图 图 2**.3 所示。

图 2.3 – 按钮输入
在我们的第二个例子中,我们将尝试创建一个带有图标按钮。此外,我们还将添加文本,这在创建按钮时至关重要,因为我们需要指定给用户什么动作或按钮在被点击后将执行什么操作。
-
因此,请继续在同一个 Kotlin 文件中创建一个名为
ButtonWithIcon()的 Compose 函数,然后导入Button()组合函数。 -
在其中,你需要导入一个带有
painterResource输入的Icon(),一个内容描述,Modifier和tint。我们还需要Text(),这将给我们的按钮一个名字。在我们的例子中,我们不会使用tint:@Composablefun ButtonWithIcon() {Button(onClick = {}) {Icon(painterResource(id =R.drawable.ic_baseline_shopping_bag_24 ),contentDescription = stringResource(id = R.string.shop),modifier = Modifier.size(20.dp))Text(text = stringResource(id = R.string.buy),Modifier.padding(start = 10.dp))}} -
让我们继续创建一个新的组合函数,并将其命名为
CornerCutShapeButton();在这个例子中,我们将尝试创建一个带有切角的按钮:@Composablefun CornerCutShapeButton() {Button(onClick = {}, shape = CutCornerShape(10)) {Text(text = stringResource(id = R.string.cornerButton)) }}}} -
让我们继续创建一个新的组合函数,并将其命名为
RoundCornerShapeButton();在这个例子中,我们将尝试创建一个带有圆角的按钮:@Composablefun RoundCornerShapeButton() {Button(onClick = {}, shape =RoundedCornerShape(10.dp)) {Text(text = stringResource(id = R.string.rounded))}} -
让我们继续创建一个新的组合函数,并将其命名为
ElevatedButtonExample();在这个例子中,我们将尝试创建一个带有凸起的按钮:@Composablefun ElevatedButtonExample() {Button(onClick = {},elevation = ButtonDefaults.elevation(defaultElevation = 8.dp,pressedElevation = 10.dp,disabledElevation = 0.dp)) {Text(text = stringResource(id = R.string.elevated))}} -
当你运行应用程序时,你应该会有一个类似于 图 2**.4 的图像;在
TextField之后的第一按钮是ButtonWithIcon(),第二个是CornerCutShapeButton(),第三个是RoundCornerShapeButton(),最后一个是ElevatedButtonExample()。

图 2.4 – 不同的按钮类型和其他 UI 元素
- 现在,让我们来看最后一个例子,因为我们在整本书中将会使用不同的视图和样式,并且在这个过程中我们会学到更多。现在,让我们来看一个图像视图;
Image()组合函数接受几个输入,如图 图 2**.5 所示。

图 2.5 – 不同的 ImageView 输入类型
-
在我们的例子中,
Image()将只有一个画家,它不是可空的,这意味着你需要为这个组合函数提供一个图像,一个用于辅助功能的文本描述和一个修改器:@Composablefun ImageViewExample() {Image(painterResource(id = R.drawable.android),contentDescription = stringResource(id = R.string.image),modifier = Modifier.size(200.dp))} -
你还可以尝试玩弄其他东西,比如添加
RadioButton()和CheckBox()元素并自定义它们。当你运行你的应用程序时,你应该会有类似于 图 2**.6 的东西。

图 2.6 – 几个 UI 组件
它是如何工作的…
每个可组合函数都使用@Composable注解。这个注解告诉 Compose 编译器,提供的编译器旨在将提供的数据转换为 UI。同时,需要注意的是,每个可组合函数的名称需要是名词,而不是动词或形容词,谷歌提供了这些指南。您创建的任何可组合函数都可以接受参数,使应用逻辑能够描述或修改您的 UI。
我们提到 Compose 编译器,这意味着编译器是任何特殊的程序,它接受我们编写的代码,检查它,并将其转换为计算机可以理解的东西——或者机器语言。
在Icon()中,painterResouce指定我们将添加到按钮中的图标,内容描述有助于辅助功能,而修饰符用于装饰我们的图标。
我们可以通过添加@Preview注解并设置showBackground = true来预览我们构建的 UI 元素:
@Preview(showBackground = true)
@Preview功能强大,我们将在未来的章节中探讨如何更好地利用它。
在 Jetpack Compose 中实现可滚动列表
当构建 Android 应用程序时,我们都可以同意的一件事是,您必须知道如何构建RecyclerView来显示您的数据。使用我们新的现代构建 Android 应用程序的方式,如果我们需要使用RecyclerView,我们可以使用LazyColumn,它类似。在这个食谱中,我们将查看行、列和LazyColumn,并使用我们的模拟数据构建一个可滚动列表。
此外,我们将在过程中学习一些 Kotlin。
准备工作
我们将继续使用Compose Basics项目来构建一个可滚动列表;因此,要开始,您需要完成之前的食谱。
如何操作...
按照以下步骤构建您的第一个可滚动列表:
-
让我们继续构建我们的第一个可滚动列表,但首先,我们需要创建我们的模拟数据,这是我们希望在列表中显示的项目。因此,创建一个名为
favoritecity的包,我们的可滚动示例将驻留其中。 -
在
favoritecity包内部,创建一个新的数据类,并将其命名为City;这将是我们模拟数据源的数据类data class City()。 -
让我们为我们的
City数据类建模。确保您在添加注解值后添加必要的导入:data class City(val id: Int,@StringRes val nameResourceId: Int,@DrawableRes val imageResourceId: Int) -
现在,在我们的模拟数据中,我们需要创建一个 Kotlin 类,并将其命名为
CityDataSource。在这个类中,我们将创建一个名为loadCities()的函数,它将返回我们的List<City>列表,我们将在可滚动列表中显示它。请查看技术要求部分,以获取所有必需的导入以获取所有代码和图像:class CityDataSource {fun loadCities(): List<City> {return listOf<City>(City(1, R.string.spain, R.drawable.spain),City(2, R.string.new_york,R.drawable.newyork),City(3, R.string.tokyo, R.drawable.tokyo),City(4, R.string.switzerland,R.drawable.switzerland),City(5, R.string.singapore,R.drawable.singapore),City(6, R.string.paris, R.drawable.paris),)}} -
现在,我们已经有了我们的模拟数据,是时候将其显示在我们的可滚动列表上了。让我们在我们的
components包中创建一个新的 Kotlin 文件,并将其命名为CityComponents。在CityComponents中,我们将创建一个名为@Preview的函数:@Preview(showBackground = true)@Composableprivate fun CityCardPreview() {CityApp()} -
在我们的
@Preview函数内部,我们还有一个组合函数CityApp();在这个函数内部,我们将调用我们的CityList组合函数,该函数有一个列表作为参数。此外,在这个组合函数中,我们将调用LazyColumn,items将是CityCard(cities)。请参阅如何工作部分以获取关于LazyColumn和items的进一步解释:@Composablefun CityList(cityList: List<City>) {LazyColumn {items(cityList) { cities ->CityCard(cities)}}} -
最后,让我们构建我们的
CityCard(city)组合函数:@Composablefun CityCard(city: City) {Card(modifier = Modifier.padding(10.dp),elevation = 4.dp) {Column {Image(painter = painterResource(city.imageResourceId),contentDescription = stringResource(city.nameResourceId),modifier = Modifier.fillMaxWidth().height(154.dp),contentScale = ContentScale.Crop)Text(text = LocalContext.current.getString(city.nameResourceId),modifier = Modifier.padding(16.dp),style = MaterialTheme.typography.h5)}}} -
当你运行
CityCardPreview组合函数时,你应该有一个可滚动的列表,如图图 2.6所示。

图 2.7 – 可滚动的城市列表
它是如何工作的…
在 Kotlin 中,列表有两种类型,不可变和可变。不可变列表是无法修改的项目,而可变列表是可以修改的列表中的项目。为了定义列表,我们可以说列表是一个泛型有序元素集合,这些元素可以是整数、字符串、图像等,这主要取决于我们希望列表包含的数据类型。例如,在我们的例子中,我们有一个字符串和图像,通过名称和图像帮助我们识别我们最喜欢的城市。
在我们的City数据类中,我们使用@StringRes和@DrawableRes来直接从res文件夹中轻松获取Drawable和String,它们也代表了图像和字符串的 ID。
我们创建了CityList,并使用组合函数进行注释,声明城市对象列表作为函数的参数。在 Jetpack Compose 中,可滚动的列表是通过LazyColumn实现的。LazyColumn与Column的主要区别在于,使用Column时,你只能显示少量项目,因为 Compose 一次加载所有项目。
此外,列只能持有固定的组合函数,而LazyColumn,正如其名所示,按需加载内容,这使得在需要时加载更多项目变得很好。此外,LazyColumn内置了滚动功能,这使得开发者的工作更加容易。
我们还创建了一个组合函数CityCard,其中我们导入了来自 Compose 的Card()元素。卡片包含关于单个对象的内容和操作;在我们的例子中,例如,我们的卡片有一个图像和城市的名称。Compose 中的Card()元素在其参数中有以下输入:
@Composable
fun Card(
modifier: Modifier = Modifier,
shape: Shape = MaterialTheme.shapes.medium,
backgroundColor: Color = MaterialTheme.colors.surface,
contentColor: Color = contentColorFor(backgroundColor),
border: BorderStroke? = null,
elevation: Dp = 1.dp,
content: @Composable () -> Unit
),
这意味着你可以轻松地将卡片模型调整为最佳匹配;我们的卡片有填充和提升,范围有一个列。在这个列中,我们有一个图像和文本,这有助于描述图像以提供更多上下文。
参见
在 Compose 中还有更多关于列表和网格要学习的内容;你可以使用此链接了解更多信息:developer.android.com/jetpack/compose/lists。
使用 Jetpack Compose 和视图页实现第一个标签布局
在 Android 开发中,页面之间有滑动是非常常见的,一个重要的用例是在引导页或甚至当你尝试以标签页、轮播的方式显示特定数据时。在这个菜谱中,我们将构建一个简单的水平分页器在 Compose 中,并看看我们如何利用新知识来构建更好、更现代的 Android 应用。
准备工作
在这个例子中,我们将构建一个水平分页器,当被选中时改变颜色以显示选中状态。我们将深入研究状态,在 第三章,在 Jetpack Compose 中处理 UI 状态和使用 Hilt,以获得更好的理解。打开 Compose Basics 项目开始。
如何操作…
按照以下步骤构建你的标签页轮播:
-
将以下分页器依赖项添加到
build.gradle(Module:app):implementation "com.google.accompanist:accompanist-pager:0.x.x"implementation "com.google.accompanist:accompanist-pager-indicators:0.x.x"implementation 'androidx.Compose.material:material:1.x.x'
Jetpack Compose 提供了 Accompanist,这是一组旨在通过开发者常用的功能来支持它的库 – 例如,在我们的案例中,就是分页器。
-
在之前菜谱的相同项目中,让我们创建一个包并命名为
pagerexample;在其内部,创建一个 Kotlin 文件并命名为CityTabExample;在这个文件中,创建一个可组合函数并命名为CityTabCarousel:@Composablefun CityTabCarousel(){} -
现在,让我们继续构建我们的
CityTabCarousel;在我们的例子中,我们将创建一个包含来自先前项目的城市的模拟页面列表:@Composablefun CityTabCarousel(pages: MutableList<String> = arrayListOf("Spain","New York","Tokyo","Switzerland","Singapore","Paris" )) {. . .} -
我们需要根据状态改变按钮的颜色,为此,我们需要使用
LocalContext,它提供了我们可以使用的上下文。我们还需要创建一个var pagerState = rememberPagerState(),这将记住我们的分页器状态,最后,当点击时,我们需要将分页器移动到下一个城市,这将非常有帮助。因此,继续向CityTabCarousel可组合函数中添加以下内容:val context = LocalContext.currentvar pagerState = rememberPagerState()val coroutineScope = rememberCoroutineScope() -
现在,让我们创建
Column元素并添加我们的ScrollableTabRow()可组合函数:Column {ScrollableTabRow(selectedTabIndex = pagerState.currentPage,indicator = { tabPositions ->TabRowDefaults.Indicator(...)},edgePadding = 0.dp,backgroundColor = Color(context.resources.getColor(R.color.white,null)),) {pages.forEachIndexed { index, title ->val isSelected =pagerState.currentPage == indexTabHeader(title,isSelected,onClick = { coroutineScope.launch {pagerState.animateScrollToPage(index)} },)}} -
为
HorizontalPager添加Text()和TabHeader():HorizontalPager(count = pages.size,state = pagerState,modifier = Modifier.fillMaxWidth().fillMaxHeight().background(Color.White)) { page ->Text(text = "Display City Name:${pages[page]}",modifier = Modifier.fillMaxWidth(),style = TextStyle(textAlign = TextAlign.Center))} -
请通过在 技术要求 部分提供的链接下载此菜谱的完整代码,以添加所有必需的代码。最后,运行
@Preview函数,你的应用应该看起来像 图 2**.8。

图 2.8 – 城市标签页
作用原理…
Accompanist 包含一些重要的库 – 例如,系统 UI 控制器、AppCompact Compose 主题适配器、Material 主题适配器、分页器、Drawable 绘画器和流布局,仅举几例。
我们在 CityTabCarousel 函数中的 Column 内部使用的 ScrollableTabRow() 包含一排标签,并有助于显示当前聚焦或选中的标签下的指示器。此外,正如其名称所暗示的,它允许滚动,你不需要实现额外的滚动工具。它还将其标签偏移放置在起始边缘,你可以快速滚动屏幕外的标签,正如你在运行 @Preview 函数并与之互动时所看到的那样。
当我们在 Compose 中调用 remember() 时,这意味着我们保持任何值在重新组合时的一致性。Compose 提供这个函数来帮助我们存储单个对象到内存中。当我们触发应用程序运行时,remember() 存储初始值。正如其词义,它只是保留值并返回存储的值,以便可组合函数可以使用它。
此外,每当存储的值发生变化时,你可以更新它,而 remember() 函数将保持它。下次我们在应用程序中触发另一个运行并发生重新组合时,remember() 函数将提供最新的存储值。
你也会注意到我们的 MutableList<String> 在每个位置都有索引,我们这样做是为了检查哪个被选中了。正是在这个 Lambda 中,我们调用 TabHeader 并展示选中的标签页。forEachIndexed 对每个元素执行给定的操作,并提供元素的顺序索引。我们还确保当用户点击特定的标签时,我们处于正确的页面:
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }
HorizontalPager 是一个水平滚动的布局,允许我们的用户从左到右翻动项目。它接受几个输入,但我们根据我们的用例提供计数、状态和修饰符。在 Lambda 中,我们显示文本 – 在我们的例子中,显示我们所在的页面,这有助于导航,如 图 2**.9 所示:

图 2.9 – HorizontalPager
我们的 TabHeader 可组合函数有一个 Box();在 Jetpack Compose 中,一个盒子将始终根据内容大小调整自身,并且这受指定约束的限制。在我们的例子中,我们用可选择的修饰符装饰我们的 Box,这配置组件作为互斥组的一部分可被选择,允许在任何给定时间只选择每个项目一次。
重要提示
确保你的目标和编译 SDK 目标为 33。此外,你会注意到 Accompanist 的大多数库都是实验性的,这意味着它们可能会改变。关于是否在生产中使用这些库存在争议,因此你应该始终咨询你的团队关于这些 API。要查看 Accompanist 支持的库的完整列表,你可以点击以下链接:github.com/google/accompanist。
在 Compose 中实现动画
Android 中的动画是将运动效果添加到视图的过程。这可以通过使用图像、文本,甚至启动一个新的屏幕来实现,在那里使用运动效果可以明显地注意到过渡。在现代 Android 开发中,动画至关重要,因为现代 UI 更加交互式,更能适应更平滑的体验,用户也喜欢它们。
此外,如今的应用程序评分是基于它们的 UI 和用户体验有多好,因此确保你的应用程序现代化且稳健是必要的。在这个例子中,我们将构建一个可折叠的工具栏,这是一种在 Android 世界中广泛使用的动画。
准备工作
我们将继续使用 Compose 基础 项目。
如何实现...
在这个食谱中,我们将构建一个可折叠的工具栏;现在你可以利用 Compose 的力量构建其他出色的动画。力量在你手中:
-
我们不需要为此食谱添加任何依赖项。我们已经有了一切所需。因此,让我们继续创建一个新的包并添加一个 Kotlin 文件,名为
collapsingtoolbar。 -
在 Kotlin 文件中,继续创建一个新的可组合函数,
CollapsingTool``BarExample():@Composablefun CollapsingToolbarExample() {...} -
我们将需要的所有可组合函数都将放在一个框中;你可以参考之前的食谱来刷新你的记忆。我们还需要定义我们将开始折叠视图的高度,这可以基于个人喜好;在我们的例子中,我们可以将
height设置为260.dp:private val height = 260.dpprivate val titleToolbar = 50.dp -
让我们继续添加更多的可组合函数,并使用虚拟文本数据来显示,一旦我们滚动内容。我们可以假设这个应用程序用于阅读我们显示的城市的信息:
@Composablefun CollapsingToolbarExample() {val scrollState: ScrollState =rememberScrollState(0)val headerHeight = with(LocalDensity.current) {height.toPx() }val toolbarHeight = with(LocalDensity.current) {titleToolbar.toPx() }Box(modifier = Modifier.fillMaxSize()) {CollapsingHeader(scrollState, headerHeight)FactsAboutNewYork(scrollState)OurToolBar(scrollState, headerHeight,toolbarHeight)City()}} -
在我们的
CollapsingHeader函数中,我们传递滚动状态和headerHeight一个浮点数。我们用Modifier.graphicLayer装饰 Box,其中我们设置了一个视差效果,使其看起来很好,并且易于展示。 -
我们还确保添加了一个
Brush()并设置了所需的颜色,并指定了它应该开始的位置:Box(Modifier.fillMaxSize().background(brush = Brush.verticalGradient(colors = listOf(Color.Transparent,Color(0xFF6D38CA)),startY = 1 * headerHeight / 5)))... -
FactsAboutNewYork不是一个复杂的可组合函数,只是虚拟文本;然后,最后,在ToolBar中,我们利用AnimatedVisibility并声明我们的enter和exit过渡:AnimatedVisibility(visible = showToolbar,enter = fadeIn(animationSpec = tween(200)),exit = fadeOut(animationSpec = tween(200))) {... -
最后,运行
@Preview函数,你将得到一个可折叠的工具栏,这将为你的 UI 带来平滑的体验。此外,在技术要求部分获取完整的代码。

图 2.10 – 一个可折叠的工具栏
工作原理...
在现代 Android 开发中,Jetpack Compose 库提供了许多作为可组合函数可用的动画 API。例如,你可能希望你的图片或文本淡入淡出。
因此,如果你正在动画化出现和消失,这可以是一个图像、文本、单选组、按钮等等,你可以使用AnimatedVisibility来实现这一点。否则,如果你正在根据状态交换内容,并希望你的内容交叉淡入淡出,你可以使用CrossFade或AnimatedContent。
val headerHeight = with(LocalDensity.current) { height.toPx() } 提供密度,该密度将用于转换 DP 和 SP 单位,当我们在提供 DP 时可以使用它,我们将在稍后将其转换为布局的主体。
您可以调用修饰符并使用 graphicsLayer 独立更新其上方的任何内容,以最小化无效内容。此外,graphicsLayer 可以用于应用缩放、旋转、不透明度、阴影或甚至裁剪等效果。
translationY = -scroll.value.toFloat() / 2f基本上设置了层相对于其顶部边界的垂直像素偏移。默认值始终为零,但您可以自定义以适应您的需求。我们还确保渐变只应用于包裹标题,startY = 1 * headerHeight / 5。
EnterTransition 定义目标内容应该如何出现;这里的“目标”可以是图像、文本,甚至是单选组。另一方面,ExitTransition 定义当退出应用程序或导航离开时,初始目标内容应该如何消失。
AnimatedContent 提供 slideIntoContainer 和 slideOutOfContainer,并根据目标状态的变化动画化其内容,这是非常出色的。此外,您还可以通过创建一个包含所有动画值和 Update() 函数的类来封装转换并使其可重用,该函数返回该类的实例。
同样,值得一提的是,与使用 MotionLayout 在 Android 中进行动画的旧方法一样,在 Jetpack Compose 中有许多进行转换的方法。例如,在 表 2.1 中,您将看到不同类型的转换:
| EnterTransition | ExitTransition |
|---|---|
SlideIn |
SlideOut |
FadeIn |
FadeOut |
SlideInHorizontally |
SlideOutHorizontally |
SlideInVertically |
SlideOutVertically |
ScaleIn |
SlaceOut |
ExpandIn |
ShrinkOut |
ExpandHorizontally |
ShinkHorizontally |
ExpandVertically |
ShrinkVertically |
表 2.1 – 一个显示不同类型转换的表格
此外,您可以通过通过 AnimatedVisibility 内容 lambda 中的 transition 属性访问基本转换实例,在 Jetpack Compose 中添加您自己的自定义动画效果,而无需超出已内置的进入和退出动画。您还会注意到已添加的任何动画状态。
在 Jetpack Compose 中实现可访问性
在我们构建 Android 应用程序时,我们需要始终牢记可访问性,因为这使技术更具包容性,并确保在构建应用程序时考虑到所有有特殊需求的人。
可访问性应该是团队的努力。如果处理得当,优势包括有更多人使用您的应用程序。可访问的应用程序对每个人来说都更好。您还可以降低被起诉的风险。
有不同的残疾类型,如视觉、听觉和运动障碍。如果你打开你的 无障碍 设置,你会看到残疾人士在其设备上使用的不同选项。
准备工作
如同之前的食谱,我们将继续使用之前食谱中的示例项目;你不需要安装任何东西。
如何操作…
对于这个食谱,我们将描述视觉元素,这些元素非常重要:
-
默认情况下,当我们添加一个
Image函数时,你可能会注意到它有两个参数,一个是用于图像的绘制器,另一个是用于视觉描述元素的文本描述:Image(painter = , contentDescription = ) -
当你将内容描述设置为
null时,你向 Android 框架表明此元素没有关联的动作或状态。所以,让我们继续更新我们所有的内容描述:Image(modifier = modifierpainter = painterResource(city.imageResourceId),contentDescription =stringResource(R.string.city_images))) -
确保将字符串添加到
stringres文件夹:<string name="city_images">City Images</string> -
因此,请确保为需要它的每个图像添加内容描述。
-
在 Compose 中,你可以通过在修饰符中指定并使用语义来显示这是一个标题,轻松地指示一个文本是否是标题。让我们在我们的装饰文本中添加这个:
...modifier = Modifier.padding(18.dp).semantics { heading() }... -
最后,我们可以继续编译、运行并测试我们的应用程序是否可以通过以下链接手动测试(使用 talkback 或使用自动化测试)来验证无障碍性:
developer.android.com/guide/topics/ui/accessibility/testing。
它是如何工作的…
Jetpack Compose 是考虑到无障碍性构建的;也就是说,如 RadioButton、Switch 等材料组件在内部设置了大小,但仅当这些组件可以接收用户交互时。
此外,任何用户可以点击或与之交互的屏幕元素应该足够大,以便可靠地交互。标准格式将这些元素设置为至少 48dp 的宽度和高度。
例如,Switch 的 onCheckChanged 参数被设置为非空值,包括至少 48dp 的宽度和高度;我们将有 CheckableSwitch() 和 NonCheckableSwitch():
@Composable
fun CheckableSwitch(){
var checked by remember { mutableStateOf(false) }
Switch(checked = checked, onCheckedChange = {} )
}
@Composable
fun NonCheckableSwitch(){
var checked by remember { mutableStateOf(false) }
Switch(checked = checked, onCheckedChange = null )
}
一旦你在你的应用程序中实现了无障碍功能,你可以通过从 Play Store 安装分析工具(uiautomatorviewer 和 lint)来轻松测试它。你也可以使用 Espresso 或 Roboelectric 自动化你的测试,以检查无障碍支持。
最后,你可以通过转到 设置,然后转到 无障碍,并选择 talkback 来手动测试你的应用程序的无障碍支持。这位于屏幕顶部;然后按 开启 或 关闭 来打开或关闭 talkback 功能。然后,导航到对话框确认,点击 确定 以确认权限。
更多…
在开发者构建应用程序时,他们应该考虑更多关于可访问性的问题,包括一个状态,使他们能够通知用户是否已选择Switch按钮。这确保了他们的应用程序支持可访问性并符合标准。
使用 Jetpack Compose 实现声明式图形
在 Android 开发中,你的应用程序可能有不同的需求,这种需求可能是为了特定目的而构建自己的自定义图形。这在许多稳定的大型 Android 代码库中非常常见。任何自定义视图的关键部分是其外观。此外,自定义绘图可能是一个非常简单或复杂的任务,这取决于应用程序的需求。在现代 Android 开发中,Jetpack Compose 使得处理自定义图形变得更加容易,因为需求巨大。例如,许多应用程序可能需要精确控制屏幕上发生的事情;用例可能简单到在屏幕上放置一个圆,或者构建更复杂的图形来处理已知用例。
准备工作
打开Compose Basics项目开始这个菜谱。你可以在技术要求部分找到完整的代码。
如何做到这一点...
在我们的项目中,让我们创建一个新的包,命名为circularexample;在这个包内部,创建一个 Kotlin 文件,命名为DrawCircleCompose;在文件内部,创建一个CircleProgressIndicatorExample可组合函数。目前你不需要导入任何内容:
-
现在,让我们继续定义我们的可组合函数。由于在我们的例子中,我们想在圆中显示一个跟踪器,我们需要浮动以填充我们的圆。我们还将定义颜色,以帮助我们识别进度:
@Composablefun CircleProgressIndicatorExample(tracker: Float, progress: Float) {val circleColors = listOf(colorResource(id = R.color.purple_700),colorResource(id = R.color.teal_200)) -
现在,让我们调用
Canvas来绘制我们的弧线。我们给我们的圆设置大小为200.dp,并添加8.dp的内边距。有趣的部分在于onDraw。startAngle设置为-90;起始角度以度为单位设置,以便更好地理解。
零代表 3 点钟方向,你也可以调整你的起始角度来观察-90是如何转换的。useCenter布尔值表示是否将弧线闭合到边界中心。因此,在我们的例子中,我们将它设置为false。然后,最后,我们设置style,这可以根据我们的喜好设置为任何内容:
Canvas(
modifier = Modifier
.size(200.dp)
.padding(8.dp),
onDraw = {
this.drawIntoCanvas {
drawArc(
color = colorSecondary,
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = 55f, cap =
StrokeCap.Butt),
size = Size(size.width, size.height)
)
colorResource(id = R.color.teal_200)
. . .
-
我们刚刚画出了圆的第一部分;现在,我们需要用
Brush绘制进度,它使用linearGradient:drawArc(brush = Brush.linearGradient(colors =circleColors),startAngle = -90f,sweepAngle = progress(tracker, progress),useCenter = false,style = Stroke(width = 55f, cap =StrokeCap.Round),size = Size(size.width, size.height)) . . .. . . -
最后,我们的
progress函数告诉sweepAngle我们的进度应该基于我们的跟踪能力:private fun progress(tracker: Float, progress: Float): Float {val totalProgress = (progress * 100) / trackerreturn ((360 * totalProgress) / 100)}. . . -
运行
preview函数,你应该会看到一个与图 2.11相同的圆形进度指示器。

图 2.11 – 显示圆形进度图像
重要提示
Canvas组合函数使用Canvas来组合一个对象,该对象反过来创建并帮助管理基于视图的 Canvas。同时,重要的是要提到,Compose 通过维护状态和创建及释放任何必要的辅助对象,使开发者更容易使用。
它是如何工作的...
通常,Canvas允许你指定屏幕上你想要绘制区域的区域。在旧的 Android 应用程序构建方式中,我们也使用了Canvas,而现在在 Compose 中,它更加强大和有价值。
linearGradient使用指定的颜色和提供的起始和结束坐标创建一个线性渐变。在我们的示例中,我们提供了项目自带的基本颜色。
绘图函数有一些默认的参数,你可以使用。例如,默认情况下,drawArc,如你所见,接受几个输入:

图 2.12 – 展示 drawArc 函数的输入
在我们的示例中,sweepAngle,它表示相对于startAngle按顺时针方向绘制的弧度大小,返回一个计算进度的函数。这个函数可以根据你的需求进行定制。在我们的示例中,我们传递了一个跟踪器和进度,并返回一个浮点数。
由于我们想要填充圆形,我们创建cal totalProgress,它检查progress * 100除以跟踪器,并返回*360 (circle) 我们的进度除以 100。你可以根据你的需求定制这个函数。你还可以编写代码来监听你的位置,并根据你创建的监听器的输入值移动进度。
还有更多...
你可以用Canvas和自定义绘图做更多的事情。一个增强你对这个主题知识的好方法是查看 Stack Overflow 上发布的旧解决方案,例如绘制一个心形或任何其他形状,并看看你能否在 Compose 中做到同样的效果。
第三章:在 Jetpack Compose 和使用 Hilt 中处理 UI 状态
所有 Android 应用程序都向用户显示状态,这有助于通知用户结果是什么以及何时发生。Android 应用程序中的 状态是任何随时间变化的价值,一个很好的例子是在出现错误时显示消息的 toast。在本章中,读者将学习如何使用新的 Jetpack 库更好地处理 UI 状态。
有一句老话说,权力越大,责任越大,管理任何可组合组件的状态需要与使用较旧的构建 Android 视图的方式(或许多人称之为命令式方式)截然不同的方法。这意味着 Jetpack 的库 Compose 完全不同于 XML 布局。
在 XML 视图系统中处理 UI 状态非常简单。这个过程包括设置视图的属性以反映当前状态——也就是说,根据需要显示或隐藏视图。例如,当从 API 加载数据时,你可以隐藏加载视图,显示内容视图,并用所需的视图填充它。
然而,在 Compose 中,一旦应用程序绘制了可组合组件,就无法更改该组件。但是,你可以通过更改每个可组合组件接收到的状态来更改传递给每个可组合组件的值。因此,在学习如何更好地管理构建健壮的 Android 应用程序的状态时,这将非常有用。
在本章中,我们将介绍以下食谱:
-
使用 Jetpack Hilt 实现 依赖注入(DI)
-
实现
ViewModel类并在 Compose 中理解状态 -
在现有的基于 XML 布局的项目中实现 Compose
-
理解和处理 Jetpack Compose 中的重组
-
为你的 Compose 视图编写 UI 测试
-
为你的
ViewModel类编写测试
技术要求
本章的完整源代码可以在github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_three找到。
使用 Jetpack Hilt 实现依赖注入
在面向对象编程中,依赖注入至关重要。有些人使用它,有些人出于自己的原因而选择不使用它。然而,依赖注入是一种设计对象的方式,其中它们从其他代码片段接收对象实例,而不是内部构建它们。
如果你了解 SOLID 原则,你知道它们的主要目标是使软件设计更容易维护、阅读、测试和构建。此外,依赖注入帮助我们遵循一些 SOLID 原则。依赖倒置原则允许代码库容易地通过添加新的功能进行扩展和扩展,并提高了可重用性。在现代 Android 开发中,依赖注入是必不可少的,我们将在本食谱中实现它。
在 Android 中,你可以使用不同类型的库来进行依赖注入,例如 Koin、Dagger 和 Hilt;Hilt 利用 Dagger 的力量,并从编译时正确性、良好的运行时性能、Android Studio 支持以及可扩展性中受益。对于这个菜谱,我们将使用 Hilt,它为我们的项目中每个 Android 类提供容器,并自动管理它们的生命周期。
准备工作
就像在之前的菜谱中一样,我们将使用之前菜谱中使用的项目来添加依赖注入。
如何做到这一点...
Hilt 使用 Java 特性;确保你的项目在 app/build.gradle 中,并且你有以下编译选项:
android {
...
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
}
这已经自动添加了,但请确保你检查一下,以防万一。让我们开始吧:
-
首先,我们必须将
Hilt-android-gradle-plugin插件添加到我们项目的根文件build.gradle(Project:SampleLogin)中:plugins {id 'com.google.dagger.Hilt.android' version '2.44'apply false} -
然后,在我们的
app/build.gradle文件中添加以下依赖项,并同步项目。它应该没有问题地运行:plugins {id 'kotlin-kapt'id 'dagger.Hilt.android.plugin'}dependencies {implementation "com.google.dagger:Hilt-android:2.44"kapt "com.google.dagger:Hilt-compiler:2.44"} -
现在,让我们继续添加
Application类。所有使用 Hilt 的应用程序都必须有一个被@HiltAndroidApp注解的Application类,并且我们需要调用在Manifest中创建的Application类:@HiltAndroidAppclass LoginApp : Application() -
在我们的
Manifest文件夹中,让我们添加LoginApp:<applicationandroid:name=".LoginApp"... -
现在我们已经完成了设置,我们需要通过给我们的类添加所需的注解来开始使用 Hilt。在
MainActivity.kt中,我们需要添加@AndroidEntryPoint注解:@AndroidEntryPointclass MainActivity : ComponentActivity() {... -
让我们运行
./gradlew :app:dependencies命令,看看我们做了什么,我们会看到类似于 图 3**.1 的内容。

图 3.1 – Dagger Hilt 依赖关系树
你也可以在 Android Studio 中查看依赖关系。这需要点击右侧的 Gradle 选项卡,然后选择 展开:yourmodule | Tasks | android。然后,最后,双击 androidDependencies 来运行它。
最后,编译并运行项目;它应该能够成功运行。
它是如何工作的...
@HiltAndroidApp 触发 Hilt 的代码生成,包括我们的应用程序的基础类,它充当应用程序级别的依赖项容器。@AndroidEntryPoint 注解向被注解的 Android 类添加 DI 容器。当使用 Hilt 时,生成的 Hilt 组件附加到 Application 对象的生命周期,并提供其依赖项。Hilt 目前支持以下 Android 类:
-
被注解为
@HiltViewModel的ViewModel -
被注解为
@HiltAndroidApp的Application -
Activity -
Fragment -
View -
Service -
BroadcastReceiver
我们将在 Hilt 中稍后使用其他必要的注解,例如 @Module 注解、@InstallIn 和 @Provides。@Module 注解意味着你可以添加绑定类型的类,这些类型不能在构造函数中注入。@InstallIn 指示哪个 Hilt 生成的 DI 容器(或单例组件)必须在代码模块绑定中可用。
最后,@Provides 将一个不能通过构造函数注入的类型绑定。它的返回类型是绑定类型,它可以接受依赖参数,并且每次需要实例时,如果类型没有被作用域限制,函数体就会执行。
实现 ViewModel 类和了解 Compose 中的状态
在 Android 中,ViewModel 是一个负责有意识地管理与 UI 相关的数据生命周期的类。社区中也有许多关于开发人员是否应该在 Compose 中使用 ViewModel 的争论。然而,谷歌的高级 Android 开发者关系工程师 Manuel Vivo 表示:
“如果它们的优点适用于您的应用程序,我会包括它们。如果您自己处理所有配置更改并且不使用 Navigation Compose,则无需使用它们。否则,使用 ViewModels 而不是重新发明轮子。”
“另一方面,关于为什么不应该使用 ViewModels 的争论基于这样一个论点:在纯 Compose 中,由于 Compose 处理配置更改,您的 Composable 函数引用 ViewModel 是不必要的。”
您还可以参考 Jim Sproch 的这条推文:twitter.com/JimSproch/status/1397169679647444993。
注意
您可以在以下位置找到有关使用 ViewModel 的好处更多信息:developer.android.com/jetpack/compose/state#viewmodels-source-of-truth。
这意味着在 Jetpack Compose 中,使用 ViewModel 来处理应用程序的状态将是一个个人选择的问题。目前 Android 推荐的架构模式是 模型-视图-视图-模型(MVVM),许多应用程序都在使用它。
Jetpack Compose 使用单向数据流设计模式;这意味着数据或状态只向下流动,而事件向上流动。因此,清楚地了解我们如何利用单向模式,尽可能多地使用 ViewModel 类使我们的代码更易于阅读、维护和测试,将是有帮助的。
此外,ViewModel 适用于为您的应用程序提供访问业务逻辑、准备屏幕上展示的数据以及使您的代码可测试的能力。
准备工作
在这个菜谱中,我们将使用一个预构建的骨架 SampleLogin 项目,您可以从 技术要求 部分下载它。我们将在这个菜谱中使用 Hilt,因为项目使用了 Hilt,但我们将在一个后续菜谱中解释 Hilt。
如何做到这一点…
现在,您将创建一个 ViewModel 类并修改 LoginContent Kotlin 文件中的大部分代码:
- 为了使我们的类和文件保持良好的组织结构,让我们首先创建一个包来存放我们的 UI 和视图模型。导航到主
Package文件夹,右键单击以打开提示,然后向下到 Package,将出现一个带有包名的对话框。

图 3.2 – 如何创建一个包
-
将包命名为
Login;在LoginContent文件中添加新的类,LoginViewModel。接下来创建一个ViewModel类:class LoginViewModel {...} -
现在我们已经创建了
LoginViewModel类,我们需要添加HiltViewModel的 DI 注解并确保我们扩展了ViewModel类:@HiltViewModelclass LoginViewModel @Inject constructor(): ViewModel(){. . .} -
在我们的
ViewModel构造函数中,我们需要添加stateHandle: SavedStateHandle,这将帮助我们维护和从保存的状态中检索对象。这些值即使在系统杀死进程后仍然持续存在,并且可以通过相同的对象保持可用:@HiltViewModelclass LoginViewModel @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() {...} -
在我们构建
ViewModel之前,让我们继续创建一个数据类,AuthenticationState()。这个类在我们的测试中非常有用,因为我们需要能够测试大多数验证案例。一个View状态类,加上拥有单一事实来源,有许多优点,并且是模型-视图-意图(MVI)的原则之一:data class AuthenticationState(val userName: String = "",val password: String = "",val loading: Boolean = false,var togglePasswordVisibility: Boolean = true) {companion object {val EMPTY_STATE = AuthenticationState()}} -
现在,让我们继续创建一个辅助类,
MutableSavedState<T>(),它将接受savedStateHandle、一个键和一个默认值。这个类充当MutableStateFlow(),但保存数据和值,并在应用程序死亡时通过SavedStateHandle检索它们:class MutableSavedState<T>(private val savedStateHandle: SavedStateHandle,private val key: String,defValue: T,) {. . .} -
现在,让我们继续创建当用户在我们的
LoginViewModel中输入用户名和密码时将被调用的回调:private val username = MutableSavedState(stateHandle,"UserName",defValue = "")fun userNameChanged(userName: String){username.value = userName} -
接下来,对密码和密码切换可见性执行相同的操作。
-
现在,我们需要创建一个
combineFlows辅助类。在 Kotlin 中,你可以组合超过两个流程;协程flow是一种按顺序发出多个值的类型,与返回单个值的suspend函数相反。有关如何组合流程的更多详细信息,请参阅combine(flow1, flow2, flow3, flow4) {t1, t2, t3, t4 -> resultMapper}.stateIn(scope):fun <T1, T2, T3, T4, T5, T6, R> combine(flow: Flow<T1>,flow2: Flow<T2>,flow3: Flow<T3>,flow4: Flow<T4>,flow5: Flow<T5>,flow6: Flow<T6>,transform: suspend (T1, T2, T3, T4, T5, T6) -> R): Flow<R> = combine(combine(flow, flow2, flow3, ::Triple),combine(flow4, flow5, flow6, ::Triple)) { t1, t2 ->transform(t1.first,t1.second,t1.third,t2.first,t2.second,t2.third)}
更多信息请参阅此处 – stackoverflow.com/questions/67939183/kotlin-combine-more-than-2-flows:
val state = combineFlows(
username.flow,
password.flow,
passwordVisibilityToggle.flow,
loadingProgress.flow
) { username, password, passwordToggle, isLoading ->
AuthenticationState(
userName = username,
password = password,
togglePasswordVisibility = passwordToggle,
loading = isLoading
)
}.stateIn(. . .)
-
现在,让我们继续创建我们的协程辅助类,命名为
SampleLoginDispatchers();它将帮助我们测试代码并确保我们的代码易于阅读。此外,我们使用协程调度器,这些调度器有助于确定相应的协程应该使用哪个线程进行执行:.stateIn(coroutineScope = viewModelScope + dispatchers.main,initialValue = AuthenticationState.EMPTY_STATE)
SharedFlow表示一个只读状态,具有单个可更新的数据值,它向其收集器发出任何更新。另一方面,状态流程是一个热流程,因为它的活动实例的存在独立于收集器的存在。
-
Android 中的
SharingStarted协程流程操作符用于在多个收集器之间共享流程的执行。它通常用于创建一个“热”流程,这意味着流程一旦创建就开始发出数据,并且数据被所有活跃的流程收集器共享。这些可以是连续的相同命令的发射,并且没有任何效果:fun <T> Flow<T>.stateIn(coroutineScope: CoroutineScope,initialValue: T): StateFlow<T> = stateIn(scope = coroutineScope,started = SharingStarted.WhileSubscribed(5000),initialValue = initialValue) -
有四种类型的分发器。在我们的例子中,我们只会使用三种。此外,你可以注入单个分发器,而不需要类,因此这可以基于个人偏好。看看四种类型分发器是如何工作的:
class SampleLoginDispatchers(val default: CoroutineDispatcher,val main: CoroutineDispatcher,val io: CoroutineDispatcher) {companion object {fun createTestDispatchers(coroutineDispatcher:CoroutineDispatcher): SampleLoginDispatchers {return SampleLoginDispatchers(default = coroutineDispatcher,main = coroutineDispatcher,io = coroutineDispatcher)}}}
现在我们已经创建了我们的辅助类,我们必须通过依赖注入(DI)提供分发器。我们有一个专门针对 Hilt 的整个食谱,所以我们将探讨其中的概念以及注解在 Hilt 食谱中的含义。
-
创建一个新的包,并将其命名为
di。在这个包中,创建一个新的对象,并将其命名为AppModule;我们将通过依赖图将我们的分发器提供给ViewModel构造函数:@Module@InstallIn(SingletonComponent::class)object AppModule {@Providesfun provideSlimeDispatchers():SampleLoginDispatchers {return SampleLoginDispatchers(default = Dispatchers.Default,main = Dispatchers.Main,io = Dispatchers.IO)}} -
我们现在需要前往
LoginContent并修改代码——也就是说,通过添加与我们的ViewModel对应的回调,并且每当我们有视图——例如,UserNameField()——我们将使用回调。请参阅示例代码:@Composablefun LoginContent(modifier: Modifier = Modifier,uiState: AuthenticationState,onUsernameUpdated: (String) -> Unit,onPasswordUpdated: (String) -> Unit,onLogin: () -> Unit,passwordToggleVisibility: (Boolean) -> Unit){. . .UserNameField(authState = uiState, onValueChanged =onUsernameUpdated)PasswordInputField(text = stringResource(id = R.string.password),authState = uiState,onValueChanged = onPasswordUpdated,passwordToggleVisibility =passwordToggleVisibility)LoginButton(text = stringResource(id = R.string.sign_in),enabled = if (uiState.isValidForm()) {!uiState.loading} else {false},onLoginClicked = {onLogin.invoke()},isLoading = uiState.loading). . .} -
现在,在我们的
LoginContentScreen组合函数中,我们将传递我们的LoginViewModel:@Composablefun LoginContentScreen(loginViewModel: LoginViewModel,onRegisterNavigateTo: () -> Unit) {val viewState byloginViewModel.state.collectAsState()LoginContent(uiState = viewState,onUsernameUpdated =loginViewModel::userNameChanged,onPasswordUpdated =loginViewModel::passwordChanged,onLogin = loginViewModel::login,passwordToggleVisibility =loginViewModel::passwordVisibility,onRegister = onRegisterNavigateTo)} -
最后,在
MainActivity中,我们现在可以继续调用LoginContentScreen,传入我们的ViewModel,并指定当用户点击onRegister时我们想要执行的操作:LoginContentScreen(loginViewModel = HiltViewModel(), onRegisterNavigateTo = {. . .} -
对于整个代码,请确保查看 技术要求 部分的链接。

图 3.3 – 输入特殊字符 ! 时显示的错误状态
它是如何工作的…
Jetpack Compose 使用单向数据流设计模式。这意味着数据或状态只向下流动,而事件向上流动,如图 图 3**.4 所示。

图 3.4 – 单向数据流
也就是说,组合函数接收状态并在屏幕上显示它。另一方面,事件可以导致状态需要更新,这些事件可以来自组合函数或应用程序的任何其他部分。此外,无论谁处理状态,在我们的例子中是 ViewModel,都会接收事件并为我们调整状态。
我们还使用协程,它们不过是轻量级的线程,它们帮助我们轻松处理同步和异步编程。此外,协程允许执行被挂起并在稍后恢复。主要优点是它们轻量级、内置取消支持、内存泄漏的可能性较低,并且 Jetpack 库提供了协程支持。
有四种类型的分发器:
-
Main分发器在主线程中执行,这通常用于当你的应用程序需要在协程中执行一些 UI 操作时。这是因为 UI 只能从主线程更改。主线程的另一个名称是 UI 线程。 -
IO分发器在 I/O 线程中启动协程;在编程中,I/O 简单意味着输入和输出。这也用于执行所有数据工作,例如网络、从数据库读取或写入。你可以简单地说,从 I/O 操作中获取数据是在 I/O 线程中完成的。 -
Default分发器从默认状态开始。如果你的应用程序计划进行复杂的长运行计算,这可能会阻塞 UI/main 线程并使你的 UI 冻结或导致GlobalScope,你可以通过简单地调用GlobalScope.launch{...}来使用它。 -
如其名所示,
Unconfined是一个不受任何特定线程限制的分发器。这会将分发器执行到当前调用帧中,并允许协程恢复相应函数使用的任何线程。
参见…
本章涵盖了大量的内容,承认仅仅这个简单的菜谱无法完全解释ViewModel是公平的。要了解更多信息,请参阅以下链接:developer.android.com/topic/libraries/architecture/viewmodel。
在基于现有 XML 布局的项目中实现 Compose
由于 Compose 是一个新的 UI 框架,许多代码库仍然严重依赖于 XML 布局。然而,许多公司正在选择使用 Compose 构建新的屏幕,这是通过利用现有的 XML 布局并添加使用ComposeView XML 标签的独特视图来实现的。这个菜谱将探讨如何将 Compose 视图添加到 XML 布局中。
准备工作
在这个菜谱中,我们可以创建一个新的项目,或者选择使用一个不严重依赖 Compose 的现有项目。我们将尝试显示GreetingDialog并使用 XML 布局来展示我们如何在 XML 布局中使用ComposeView标签。如果你已经有了一个项目,你不需要设置这个;你可以跳到前面如何做 它…部分中的步骤 4。
如何做…
现在我们来探索如何利用现有的 XML 布局与 Compose 结合使用:
-
让我们先创建一个新的项目或使用一个现有的项目;如果你创建了一个不是 Compose 的新活动,你可以使用
EmptyActivity,并给它任何名字。 -
如果你已经设置了一个项目,你可以跳过这一步。如果你选择创建一个新的项目,你将会有
MainActivity,由于这是创建视图的老方法,你会在resource文件夹中注意到一个包含Hello world的 TextView 的 XML 布局。我们可以继续删除它,因为我们不会使用它。 -
如果你已经有一个准备好的项目,你可以在任何你想要的屏幕上启动
GreetingDialog。此外,如果你选择创建一个按钮而不是对话框,这也是可以的,因为目的是展示我们如何使用 Jetpack Compose 中的 XML 标签。 -
现在,让我们继续在
activity_main.xml中添加一个 XML 标签,并给我们的 Compose 视图一个id值。第一次添加ComposeView时,如果你还需要添加依赖项,你会看到一个错误消息。继续点击在 android.compose.ui:ui 上添加依赖项,项目将同步,如图图 3**.5所示。

图 3.5 – XML 中的 Compose 视图
-
一旦你同步了你的项目,错误就会消失,你应该能够在
MainActivity或你想使用ComposeView的地方使用这个视图:<androidx.Compose.ui.platform.ComposeViewandroid:id="@+id/alert_dialog"android:layout_width="match_parent"android:layout_height="match_parent"/> -
让我们也在
build.gradle(Module:app)中添加viewBinding,这样我们就可以在MainActivity中轻松访问我们的视图。如果你已经设置了viewBinding,你可以跳过这部分:buildFeatures{viewBinding true} -
一旦我们同步了项目,我们就可以在
MainActivity中通过绑定访问ComposeView。此外,它将有一个setContent{}方法,你可以设置所有你的可组合元素并将其包裹在主题中:class MainActivity : AppCompatActivity() {private lateinit var activityBinding:ActivityMainBindingoverride fun onCreate(savedInstanceState: Bundle?){super.onCreate(savedInstanceState)activityBinding =ActivityMainBinding.inflate(layoutInflater)setContentView(activityBinding.root)activityBinding.alertDialog.setContent {GreetingAlertDialog()}}} -
我们的
GreetingAlertDialog()将包含一个AlertDialog()可组合元素、一个标题和文本,它将我们的消息作为一个简单的文本元素提供。标题将说Hello,因为这是一个问候,消息将是Hello,感谢您成为 Android 社区的一员。你可以根据需要自定义它:@Composablefun SimpleAlertDialog() {AlertDialog(onDismissRequest = { },confirmButton = {TextButton(onClick = {}){ Text(text = "OK") }},dismissButton = {TextButton(onClick = {}){ Text(text = "OK") }},title = { Text(text = "Hello") },text = { Text(text = "Hello, and thank you forbeing part of the Android community") })} -
要创建 Compose 组件,你需要在你的 gradle app 中添加 Compose Material Design 依赖项。根据你的应用程序支持的情况,你可以利用 Compose Material 3 组件,这是 Material Design 的下一个进化版本,并带有更新的主题。
-
你可以轻松地自定义动态颜色等功能。我们将在第十一章中探讨 Material 3,GUI Alerts – What’s New in Menus, Dialog, Toast, Snackbars, and More in Modern Android Development。因此,目前,由于我使用的应用程序尚未迁移到 Material 3,我将使用这个导入 –
implementation "androidx.Compose.material:material:1.x.x"。在这里,你可以使用任何适合你需要的导入。 -
你还可以创建一个继承自
AbstractComposeView的自定义视图:class ComposeAlertDialogComponent @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyle: Int = 0) : AbstractComposeView(context, attrs, defStyle) {@Composableoverride fun Content() {GreetingAlertDialog()}} -
最后,当你运行你的应用程序时,你应该有一个包含标题和文本的对话框;图 3**.6显示了一个来自已存在的项目的对话框,所以这肯定会根据你采取的步骤而有所不同:

图 3.6 – XML 中的对话框 Compose 视图
它是如何工作的…
首先,我们膨胀了在布局资源文件夹中定义的 XML 布局。然后,使用绑定,我们通过创建的 XML ID 获取ComposeView,设置最适合我们宿主视图的 Compose 策略,并调用setContent来使用 Compose。在你的活动中,为了能够创建任何基于 Compose 的屏幕,你必须确保调用setContent{}方法并传递你创建的任何可组合函数。
要进一步探索 setContent 方法,它被写成 ComponentActivity 的扩展函数,并期望一个 Composable 函数作为最后一个参数。还有更好的方法来展示 setContent{} 的工作原理,以便将 Composable 树集成到你的 Android 应用程序中。

图 3.7 – 当你调用 setContent{} 时发生的情况
ViewCompositionStrategy 帮助确定何时销毁组合;因此,Compose UI 视图如 ComposeView 和 AbstractComposeView 使用 ViewCompositonStrategy,这有助于定义此行为。
你可以通过以下链接了解更多关于互操作 API 的信息:developer.android.com/jetpack/compose/interop/interop-apis#composition-strategy。
理解和处理 Jetpack Compose 中的重组
Jetpack Compose 仍然非常新,许多公司开始使用它。此外,谷歌通过为开发者提供大量文档来帮助他们接受这个新的 UI 工具包,做得非常出色。然而,尽管有所有这些文档,仍有一个概念需要澄清。那就是重组。
没错,所有新的软件都有其优点和缺点,随着越来越多的人开始使用它,更多的人开始提供反馈——因此,需要更多的改进。在 Compose 中,重组涉及在输入变化时再次调用你的 Composable。或者你可以将其视为组合结构和关系发生变化时。
除非其参数发生变化,我们希望在大多数用例中避免重新调用可组合函数。因此,在这个菜谱中,我们探讨重组是如何发生的,以及你如何在应用程序中调试和解决任何重组问题。
如何做到这一点…
由于我们的视图系统很简单,我们将检查在我们的 Login 项目中是否有任何重组:
-
我们可以看看一个简单的例子以及重组是如何发生的:
@Composablefun UserDetails(name: String,gender: String,) {Box() {Text(name)Spacer()Text(gender)}}
在我们的示例中,当 name 发生变化时,Text 函数将重新组合,而不是当 gender 发生变化时。此外,只有当 gender 发生变化时,gender:String 输入值才会重新组合。
- 你也可以启动并使用
LoginContent来查看我们是否有任何重组。

图 3.8 – 布局检查器
- 一旦你启动 布局检查器,你需要确保你的模拟器已连接到它。

图 3.9 – 连接检查器
- 前往并展开
SampleLoginTheme入口,你会注意到我们的当前视图系统并不复杂。正如你所见,布局检查器没有显示任何重组计数。
也就是说,如果我们的应用程序有任何重组计数,它们将显示在 布局检查器 中。

图 3.10 – 组件树
- 最后,正如你所看到的,我们的应用程序没有发生任何重新组合,但检查你的应用程序以了解可能引起重新组合的原因并修复它总是有益的。
重要提示
使用副作用可能会导致你的应用程序的用户在应用程序中遇到奇怪且不可预测的行为。此外,副作用是指对应用程序其余部分可见的任何更改。例如,向共享对象的属性写入、在ViewModel中更新可观察对象以及更新共享首选项都是危险的副作用。
它是如何工作的…
为了适应性,Compose 会跳过lambda调用或任何没有对其输入进行更改的子函数。这种更好的资源处理方式是有意义的,因为在 Compose 中,动画和其他 UI 元素可以在每一帧中触发重新组合。
我们可以深入探讨并使用图表来展示 Jetpack 组合生命周期的运作方式。简而言之,可组合函数的生命周期由三个重要事件定义:
-
被组合
-
是否重新组合或不会重新组合
-
不再被组合

图 3.11 – 可组合的组成生命周期
要理解 Compose 的工作原理,了解构成 Compose 架构层的元素是有益的。Jetpack Compose 架构层的高级概述包括Material、Foundation、UI和Runtime方面。

图 3.12 – 显示 Jetpack Compose 架构层的图解
在Material中,此模块实现了 Compose UI 的 Material Design 系统。
此外,它提供了一个主题系统、样式化组件以及更多。Row、Column等。UI层由多个模块组成,这些模块实现了 UI 工具包的基本原理。
参见
Compose 团队正在推出 Jetpack Compose Composition Tracing,这是第一个 alpha 版本,将帮助开发者轻松追踪他们的组合;你可以在这里了解更多信息:
为你的 Compose 视图编写 UI 测试
在开发 Android 应用程序时,测试你的代码是至关重要的,尤其是如果你的应用程序拥有许多用户。此外,当你为你的代码编写测试时,你基本上是在验证 Android 应用程序的功能、行为、正确性和多功能性。Android 中最受欢迎的 UI 测试工具是 Espresso、UI Automator、Calabash 和 Detox。
然而,在这本书中,我们将使用 Espresso。Espresso 最显著的优点如下:
-
设置起来很容易
-
它具有高度稳定的测试周期
-
它支持 JUnit 4
-
它专为 Android UI 测试制作
-
它适合编写黑盒测试
-
它还支持测试应用程序之外的活动
准备工作
你需要完成之前的食谱才能跟随这个食谱。
如何操作…
就像本章中的其他食谱一样,我们将使用我们在第一章“现代 Android 开发技能入门”中创建的新项目:
-
让我们继续导航到我们的项目文件夹中的
androidTest包。 -
首先,在
androidTest包中创建一个新类,命名为LoginContentTest.kt。在 Jetpack Compose 中,测试变得更加容易,我们需要为我们的视图提供唯一的标签。 -
因此,对于这一步,让我们回到我们的主包(
com.name.SampleLogin)并创建一个新的包,命名为util。在util内部,让我们创建一个新的类,命名为TestTags,它将是一个对象。在这里,我们将有另一个对象,命名为LoginContent,并创建我们可以调用在视图中的常量值:object TestTags {object LoginContent {const val SIGN_IN_BUTTON = "sign_in_button"const val LOGO_IMAGE = "logo_image_button"const val ANDROID_TEXT = "community_text"const val USERNAME_FIELD = "username_fields"const val PASSWORD_FIELD = "password_fields"}} -
现在我们已经创建了测试标签,让我们回到我们的
LoginContent并添加它们到Modifier()中的所有视图中,这样在测试时,使用我们添加的测试标签来识别视图会更容易。请看以下代码片段:Image(modifier = modifier.testTag(LOGO_IMAGE),painter = painterResource(id =R.drawable.ic_launcher_foreground),contentDescription = "Logo") -
在我们的
LoginContentTest类内部,现在让我们继续设置我们的测试环境。我们需要创建@get:Rule,它注解引用规则或返回规则的字段。在规则下,让我们创建ComposeRuleTest并初始化它:@get:Ruleval ComposeRuleTest = createAndroidComposeRule<MainActivity>() -
添加以下函数以帮助我们设置内容。我们应该在我们的
Test注解函数中调用此函数:private fun initCompose() {ComposeRuleTest.activity.setContent {SampleLoginTheme {LoginContent()}}} -
最后,让我们继续添加我们的第一个测试。对于我们将要编写的测试,我们将验证视图是否以我们预期的样子显示在屏幕上:
@Testfun assertSignInButtonIsDisplayed(){initCompose()ComposeRuleTest.onNodeWithTag(SIGN_IN_BUTTON,true).assertIsDisplayed()}@Testfun assertUserInputFieldIsDisplayed(){initCompose()ComposeRuleTest.onNodeWithTag(USERNAME_FIELD,true).assertIsDisplayed()} -
SIGN_IN_BUTTON和USERNAME_FIELD是从我们创建的测试标签导入的,并且目前只被一个视图(登录按钮)使用。 -
开始运行测试,将弹出一个对话框显示运行过程;如果成功,测试将通过。在我们的情况下,测试应该通过。

图 3.13 – 显示通过测试的截图
重要提示
对于这些测试,你不需要添加任何依赖项;我们所需的一切都已经为我们准备好了。
它是如何工作的…
当访问活动时,我们使用createAndroidComposeRule<>()。测试并确保你的应用程序显示预期的结果是非常重要的。这就是为什么 Android Studio 使用模拟器来帮助开发者测试他们的代码,以确保他们的应用程序在标准设备上运行。
此外,Android 手机自带开发者选项,方便开发者使用,这使得 Android 支持的不同设备数量更多,并有助于在模拟器中重现难以发现的 bug。
当我们测试我们的 Compose 代码时,通过在开发过程的早期阶段捕捉错误来提高我们应用的质量。在本章中,我们讨论了创建更多视图来展示 Jetpack Compose 的工作方式;此外,我们的测试用例需要处理用户操作,因为我们尚未实现。
在不同的环境中,我们可以编写更关键的测试来确认预期的操作,我们将在后面的章节中这样做。此外,Compose 提供了用于查找元素、验证它们的属性和执行用户操作的测试 API。此外,它们还包括时间操作等高级功能。
在编写测试时显式调用 @Test 注解非常重要,因为这个注解告诉 JUnit,附加到其上的函数将作为一个 Test 函数运行。此外,Compose 中的 UI 测试使用 .onNodeWithTag。
UI 部分或元素可以是从单个 Composable 到全屏的任何内容。如果您尝试访问错误的节点,与 UI 层级一起生成的语义树将会抱怨。
还有更多...
还有其他测试工具,如下所示:
-
Espresso Test Recorder 为开发者提供了一种更快、更直观的方式来测试他们应用中日常的用户输入行为和视觉元素。
-
App Crawler 无疑采用了一种更加不干预的方法来帮助您测试用户操作,而无需维护或编写任何代码。使用这个工具,您可以轻松配置输入,例如输入您的用户名和密码凭据。
-
Monkey 是一个命令行设备,它通过向设备或模拟器实例发送随机的用户验证/输入或点击操作来对您的应用进行压力测试。
要了解更多关于测试和 Compose 中的语义的信息,请阅读以下内容:developer.android.com/jetpack/compose/semantics。
为您的 ViewModels 编写测试
与 模型-视图-控制器 (MVC) 和 模型-视图-呈现器 (MVP) 不同,MVVM 由于其单向数据和依赖关系流,在现代 Android 开发中是首选的设计模式。此外,它使得单元测试变得更加容易,正如您将在本配方中看到的那样。
准备工作
我们将使用我们之前的配方,在 Compose 中实现 ViewModel 并理解状态,来测试我们的逻辑和状态变化。
如何操作...
在这个配方中,我们将编写单元测试来验证我们的身份验证状态变化,因为这是我们迄今为止实现的内容:
- 首先在
test包中创建一个LoginViewModelTest类:

图 3.14 – 创建的单元测试
-
我们将使用
cashapp/turbine测试库来测试协程流程,以测试我们创建的流程。因此,您需要在build.gradle中包含处理代码片段:repositories {mavenCentral()}dependencies {testImplementation 'app.cash.turbine:turbine:0.x.x'} -
一旦创建了类,就可以设置
@Before,它将在每个测试之前运行:class LoginViewModelTest {private lateinit var loginViewModel: LoginViewModel@Beforefun setUp(){loginViewModel = LoginViewModel(dispatchers =SampleLoginDispatchers.createTestDispatchers(UnconfinedTestDispatcher()),stateHandle = SavedStateHandle())}} -
如您所见,我们使用了
SampleLoginDispatchers.createTestDispatchers。对于UnconfinedTestDispatcher,您必须包含测试依赖项并导入,import kotlinx.coroutines.test.UnconfinedTestDispatcher。 -
现在我们已经准备好了设置,让我们继续创建我们的测试,验证认证状态的变化:
@Testfun `test authentication state changes`() = runTest {...} -
在我们的
Test函数中,我们现在需要访问loginViewModel函数并将假值传递给参数:@Testfun `test authentication state changes`() = runTest {loginViewModel.userNameChanged("Madona")loginViewModel.passwordChanged("home")loginViewModel.passwordVisibility(true)loginViewModel.state.test {val stateChange = awaitItem()Truth.assertThat(stateChange).isEqualTo(AuthenticationState(userName = "Madona",password = "home",togglePasswordVisibility = true))}} -
最后,运行测试,它应该通过。

图 3.15 – 单元测试通过
它是如何工作的…
如前所述,MVVM 最显著的优势是能够编写可以快速测试的代码。此外,Android 的架构全部关于选择权衡。每种架构都有其优缺点;根据您公司的需求,您可能需要使用不同的架构。
我们创建 lateint var loginViewModel 来设置一个用于测试的类,这是因为要测试的逻辑在 ViewModel 中。
我们使用 UnconfinedDispatcher,它创建了一个 Unconfined 分发器的实例。这意味着它执行的任务不受任何特定线程的限制,形成一个事件循环。它与所有 TestDispatcher 实例不同之处在于它跳过了延迟。默认情况下,runTest() 提供了 StandardTestDispatcher,它不会立即执行子协程。
我们使用 Truth 进行断言,以帮助我们编写更易读的代码,Truth 的显著优势如下:
-
它将实际值对齐到左侧
-
它为我们提供了更详细的错误信息
-
它提供了更丰富的操作来帮助进行测试
还有其他替代方案,例如 Mockito、Mockk 等,但在这个部分,我们使用了 Truth。我们还使用了 Cashapp 的一个库,它帮助我们测试协程流程。您可以在github.com/cashapp/turbine了解更多关于 turbine 库的信息。
第四章:现代安卓开发中的导航
在安卓开发中,导航是允许安卓应用程序用户在应用程序的不同屏幕之间导航、返回的操作,这在移动生态系统中是非常关键的。
Jetpack 导航简化了屏幕之间的导航,在本章中,我们将学习如何通过简单的视图点击、通过导航参数以及更多方式实现导航。
本章将涵盖以下菜谱:
-
使用导航目的地实现底部导航栏
-
在 Compose 中导航到新屏幕
-
带参数进行导航
-
为目的地创建深链接
-
编写导航测试
技术要求
本章的完整源代码可以在 github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_four 找到。
使用导航目的地实现底部导航栏
在安卓开发中,拥有底部导航栏是非常常见的;它有助于告知用户应用程序中有不同的部分。此外,其他应用程序选择包含一个包含个人资料和应用程序其他信息的导航抽屉活动。
一个很好的同时利用导航抽屉和底部导航的应用程序示例是 Twitter。此外,值得注意的是,一些公司更喜欢将顶部导航栏作为首选。此外,像 Google Play 商店这样的其他公司则同时使用底部和抽屉导航。
准备工作
使用你喜欢的编辑器或 Android Studio 创建一个新的安卓项目,或者你可以使用之前菜谱中的任何项目。
如何实现...
在这个菜谱中,我们将创建一个新的项目并命名为 BottomNavigationBarSample:
-
在创建我们的新空
Activity BottomNavigationBarSample项目后,我们首先将在build.gradle中添加所需的导航依赖项,然后同步项目:implementation 'android.navigation:navigation-compose:2.5.2' -
如前一个新项目所示,当你创建一个新项目时,它会附带一些代码,例如
Greeting()函数;你可以继续删除这些代码。 -
删除这些代码后,让我们继续在主包目录中创建一个
sealed类,命名为Destination.kt,我们将在这里定义我们的route字符串、icon:Int和title: String用于底部导航项:sealed class Destination(val route: String, val icon: Int, val title: String) {...}
严格来说,我们可能不需要 sealed 类,但它是一种更优雅的导航实现方式。在 Kotlin 中,sealed 类代表一个受限的类层次结构,它提供了对继承的更多控制。或者,你可以将其视为一个类,其值可以从有限集合中选择一种类型,但不能有其他类型。
-
在
sealed类内部,现在让我们继续创建我们的目的地。对于我们的示例,我们将假设我们正在创建一个预算应用。因此,我们可以有的目的地是Transactions、Budgets、Tasks和Settings。关于如何获取图标,请参阅下一步;此外,你还需要导入它们。为了良好的实践,你可以提取String资源并将其保存到StringXML 文件中。你可以尝试这个小练习:sealed class Destination(val route: String, val icon: Int, val title: String) {object Transaction : Destination(route = "transactions", icon =R.drawable.ic_baseline_wallet,title = "Transactions")object Budgets : Destination(route = "budget", icon =R.drawable.ic_baseline_budget,title = "Budget")object Tasks : Destination(route = "tasks", icon =R.drawable.ic_add_task, title = "Tasks")object Settings : Destination(route = "settings", icon =R.drawable.ic_settings,title = "Settings")companion object {val toList = listOf(Transaction, Budgets,Tasks, Settings)}} -
对于图标,你可以通过点击资源文件夹(
res),然后导航到矢量资产 | 剪贴画来轻松访问它们,这将启动并显示你可以使用的免费图标,如图 4.1所示。1*:

图 4.1 – 如何访问矢量资产
- 你还可以上传 SVG 文件并通过资产工作室访问它。对于更多图标,你可以查看这个链接:
fonts.google.com/icons。

图 4.2 – 资产工作室
-
现在,对于我们刚刚添加的目的地,让我们继续添加一些占位文本来验证,当我们导航时,我们确实在正确的屏幕上。创建一个新文件,并将其命名为
AppContent.kt。在AppContent中,我们将添加我们的Transaction函数,这将是我们的主页,新用户第一次进入应用时将在这里;然后他们可以导航到其他屏幕:@Composablefun Transaction(){Column(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center)) {...}} -
继续添加剩余的三个屏幕,
Task、Budget和Settings,使用相同的可组合模式。 -
我们现在需要创建一个可组合的底部导航栏,并告诉
Composable函数如何响应用户点击,以及在选择之前选中的项目时如何恢复状态:@Composablefun BottomNavigationBar(navController: NavController, appItems: List<Destination>) {BottomNavigation(backgroundColor = colorResource(id =R.color.purple_700),contentColor = Color.White) {...}} -
现在,让我们转到
MainActivity并创建NavHost以及几个可组合函数,AppScreen()、AppNavigation()和BottomNavigationBar()。每个导航控制器必须与一个单独的导航宿主可组合函数相关联,因为它将控制器与一个帮助指定可组合方向的导航图连接起来:@Composablefun AppNavigation(navController: NavHostController) {NavHost(navController, startDestination =Destination.Transaction.route) {composable(Destination.Transaction.route) {Transaction()}composable(Destination.Budgets.route) {Budget()}composable(Destination.Tasks.route) {Tasks()}composable(Destination.Settings.route) {Settings()}}} -
最后,让我们通过创建另一个可组合函数并命名为
AppScreen()来将所有内容粘合在一起。我们将在onCreate()函数中的setContent中调用此函数:@Composablefun AppScreen() {val navController = rememberNavController()Scaffold(bottomBar = {BottomNavigationBar(navController =navController, appItems =Destination.toList) },content = { padding ->Box(modifier = Modifier.padding(padding)){AppNavigation(navController =navController)}})} -
然后,在
setContent{}中调用此创建的函数;导入应该是import androidx.activity.compose.setContent,根据事实,有时可能会导入错误的一个。运行应用程序。你会注意到一个带有四个标签的屏幕,当你选择一个标签时,选中的标签会被突出显示,如图 4.3所示:

图 4.3 – 底部导航栏
它是如何工作的…
在 Compose 中,导航有一个关键术语称为 route。键是一个定义通往你的 composable 路径的字符串。键基本上是真相的来源——或者你可以将其视为一个深链接,它带你到特定的目的地,每个目的地都应该有一个唯一的路由。
此外,每个目的地都应该由一个唯一的键路由组成。在我们的例子中,我们添加了图标和标题。如图 图 4**.3 所示,图标显示了底部导航包含的内容,标题描述了我们在那个确切时刻正在浏览的特定屏幕。此外,这些是可选的,并且仅在某些路由中需要。
NavController() 是我们导航组件的主要 API,它跟踪组成我们应用程序屏幕的 composables 的每个返回栈条目以及每个屏幕的状态。我们使用 rememberNavController() 创建了这个功能:正如我们在上一章中提到的,remember这个名字意味着记住值;在这个例子中,我们正在记住 NavController:
val navController = rememberNavController()
另一方面,NavHost() 需要之前通过 rememberNavController() 创建的 NavController() 以及我们图入口的目的地路由。此外,rememberNavController() 返回 NavHostController,它是 NavController() 的一个子类,它提供了一些 NavHost 所需的额外 API。
这与 Android 开发者在组合片段之前构建导航的方式非常相似。步骤包括创建一个带有 menu 项的底部导航菜单,如下面的代码块所示:
<?xml version="1.0" encoding="utf-8"?>
<menu >
<item
android:id="@+id/transaction_home"
android:icon="@drawable/card"
android:title="@string/transactions"/>
<item
android:id="@+id/budget_home"
android:icon="@drawable/ic_shopping_basket_black_
24dp"
android:title="@string/budgets"
/>
...
</menu>
然后,我们在 navigation 包中创建另一个资源,该资源指向屏幕(片段):
<?xml version="1.0" encoding="utf-8"?>
<navigation
app:startDestination="@+id/transaction_home">
<fragment
android:id="@+id/transaction_home"
android:name="com.fragments.TransactionsFragment"
android:label="@string/title_transcation"
tools:layout="@layout/fragment_transactions" >
<action
android:id="@+id/action_transaction_home_to_
budget_home"
app:destination="@id/budget_home" />
</fragment>
<fragment
android:id="@+id/budget_home"
android:name="com.fragments.BudgetsFragment"
android:label="@string/title_budget"
tools:layout="@layout/fragment_budget" >
<action
android:id="@+id/action_budget_home_to_tasks_
home"
app:destination="@id/tasks_home" />
</fragment>
</navigation>
在 Compose 中导航到新屏幕
我们将在登录页面上构建一个注册屏幕提示,用于注册我们应用程序的新用户。这是一个标准模式,因为我们需要保存用户的凭据,这样下次他们登录到我们的应用程序时,我们只需登录而无需再次注册。
准备工作
在开始这个之前,你应该已经完成了之前的食谱,使用导航目的地实现底部导航栏。
如何做到这一点...
在这个食谱中,我们需要使用我们的 SampleLogin 项目并添加一个新屏幕,用户在首次使用应用程序时可以导航到该屏幕。这在许多应用程序中是一个典型的用例:
-
打开你的
SampleLogin项目,创建一个新的sealed类,并将其命名为Destination。为了确保我们保持良好的封装,将此类添加到util中。同样,就像底部栏一样,我们将有一个路由,但这次我们不需要任何图标或标题:sealed class Destination (val route: String){object Main: Destination("main_route")object LoginScreen: Destination("login_screen")object RegisterScreen:Destination("register_screen")} -
在创建目的地之后,我们现在需要在
LoginContent中添加可点击文本,询问用户是否是首次使用应用程序。他们应该点击RegisterContent。如果你需要参考任何步骤,可以通过查看 技术要求 部分来打开项目:@Composablefun LoginContent(...onRegister: () -> Unit) {ClickableText(modifier = Modifier.padding(top = 12.dp),text = AnnotatedString(stringResource(id =R.string.register)),onClick = { onRegister.invoke() },style = TextStyle(colorResource(id = R.color.purple_700),fontSize = 16.sp)) -
现在,当您点击
ClickableText时,我们的可点击文本是指您可以点击的文本,它将帮助用户通过 首次用户?注册 导航到注册屏幕。一旦点击此按钮,它应该导航到不同的屏幕,用户现在可以注册,如图 图 4.4 所示:

图 4.4 – 新的注册屏幕
-
对于 注册 屏幕,您可以在 技术要求 部分找到整个代码。我们将重用我们创建的用户输入字段,只需更改文本:
@Composablefun PasswordInputField(text: String) {OutlinedTextField(label = { Text(text = text) },...} -
在
MainActivity中,我们将有一个Navigation()函数,如下所示:@Composablefun Navigation(navController: NavHostController) {NavHost(navController, startDestination =Destination.LoginScreen.route) {composable(Destination.LoginScreen.route) {LoginContentScreen(loginViewModel =hiltViewModel(),onRegisterNavigateTo = {navController.navigate(Destination.RegisterScreen.route)})}composable(Destination.RegisterScreen.route) {RegisterContentScreen(registerViewModel =hiltViewModel())}}} -
在
PasswordInputField中,我们将为每个输入项适当地命名以提高其可重用性:PasswordInputField(text = stringResource(id = R.string.password),authState = uiState,onValueChanged = onPasswordUpdated,passwordToggleVisibility =passwordToggleVisibility) -
此外,您还可以通过点击硬件 返回 按钮导航到上一个 登录 屏幕。
-
最后,在
setContent中,我们需要更新代码以适应新的导航:@AndroidEntryPointclass MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?){super.onCreate(savedInstanceState)setContent {SampleLoginTheme {// A surface container using the'background' color from the themeSurface(modifier = Modifier.fillMaxSize(),color =MaterialTheme.colors.background) {val navController =rememberNavController()Navigation(navController =navController)}}}}}
运行代码并点击 注册 文本,您现在应该被带到新屏幕。
它是如何工作的…
您会注意到我们刚刚创建了一个不同的目的地入口点,其中 ClickableText 用于导航到新创建的屏幕。此外,为了在导航图中导航到组合目的地,您必须使用 navController.navigate(Destination.RegisterScreen.route),如前所述,字符串表示目的地路由。
此外,navigate() 默认将我们的目的地添加到返回栈中,但如果我们需要修改行为,我们可以在 navigate() 调用中添加额外的导航选项来轻松做到这一点。
假设您想在导航时使用动画。在这种情况下,您可以通过使用 Accompanist 库轻松做到这一点 – github.com/google/accompanist –该库提供了一组旨在补充 Jetpack Compose 的功能,这些功能主要是由开发人员所需的,并且目前尚未提供。
您可以使用 enterTransition,它明确指定了您导航到特定目的地时运行的动画,而 exitTransition 执行相反的操作:
AnimatedNavHost(
modifier = Modifier
.padding(padding),
navController = navController,
startDestination = Destination.LoginScreen.route,
route = Destination.LoginScreen.route,
enterTransition = { fadeIn(animationSpec = tween(2000)) },
exitTransition = { fadeOut(animationSpec = tween(200))
}
)
您还可以使用 popEnterTransition,它指定了在通过 popBackStack() 后目的地重新进入屏幕时运行的动画,或者 popExitTransition,它执行相反的操作。
重要提示
重要的是要注意,当您从您的组合函数中暴露事件给知道如何正确处理该逻辑的应用程序调用者时,这是一种良好的实践,这对于提升状态变得相关。此外,底层的导航完全是状态管理的。
参见
更多关于 AnimatedNavHost 的信息,您可以在此处找到:google.github.io/accompanist/navigation-animation/。
带参数的导航
在 Android 开发中,在目的地之间传递数据非常重要。新的 Jetpack 导航允许开发者通过为目的地定义一个参数来将数据附加到导航操作。读者将学习如何使用参数在目的地之间传递数据。
一个好的用例是,比如说,你加载了一个 API 的数据,并希望显示更多关于你刚刚显示的数据的描述;你可以通过唯一的参数导航到下一个屏幕。
准备工作
我们将探讨最常见的面试项目要求,即从 API 获取数据,显示一个屏幕,并为额外分数添加一个额外的屏幕。
假设 API 是 GitHub API,你想显示所有组织。然后,你想要导航到另一个屏幕,查看每个公司拥有的仓库数量。
如何操作...
对于这个菜谱,我们将查看一个使用参数进行导航的例子,作为一个概念,因为除了创建传递给已构建项目的基参数之外,没有更多的事情要做——SampleLogin:
-
让我们继续创建
SearchScreen,这个屏幕将只包含一个搜索功能,EditText以及一个用于显示从 API 返回的数据的列:SearchScreen(viewModel = hiltViewModel(),navigateToRepositoryScreen = { orgName ->navController.navigate(Destination.BrowseRepositoryScreen.route +"/" + orgName)}) -
现在,当设置导航到
BrowseRepository时,你需要添加以下代码。这段代码是为了从一个屏幕传递一个必需的数据参数到另一个屏幕,但同时也添加了传递可选参数的示例;默认值将帮助用户:composable(route = Destination.BrowseRepositoryScreen.route +"/{org_name}",arguments = listOf(navArgument("org_name") { type= NavType.StringType }),enterTransition = { scaleIn(tween(700)) },exitTransition = { scaleOut(tween(700)) },) {BrowseRepositoryScreen(viewModel = hiltViewModel(),)}
我们还使用了enter和exit过渡动画。在这个菜谱中,我们刚刚触及了使用参数进行导航的概念,这个概念可以应用于许多项目。
它是如何工作的...
当你想向目的地传递一个可能需要的参数时,你需要明确将其附加到在初始化navigate函数调用时的路由上,就像你在下面的代码片段中看到的那样:
navController.navigate(Destination.BrowseScreen.route + "/" + orgName)
我们在我们的路由中添加了一个参数占位符,类似于我们在使用基础导航库时添加到深链接中的参数。
此外,还有一个导航库支持的列表;如果你有不同的用例,你可以查看此文档:developer.android.com/guide/navigation/navigation-pass-data#supported_argument_types。

图 4.5 – 导航支持参数类型(来源:developers.android.com)
更多内容...
关于导航还有更多要学习的内容,如果你想更深入地了解如何使用参数进行导航、在导航时检索复杂数据以及添加额外的参数,你可以在这里了解更多:developer.android.com/jetpack/compose/navigation。
为目的地创建深链接
在现代 Android 开发中,深链接非常重要。一个帮助你直接导航到应用中特定目的地的链接被称为Navigation组件,它让你可以创建两种类型的深链接:显式和隐式。
Compose 导航支持隐式深链接,这可以是你的 Composable 函数的一部分。也公平地说,使用 XML 布局处理这些并没有太大的区别。
准备工作
由于我们的应用中没有深链接用例,在这个菜谱中,我们将探讨如何通过学习如何实现隐式深链接来利用这些知识。
如何操作...
你可以使用统一资源定位符(URI)、intent 动作或多用途互联网邮件扩展(MIME)类型来匹配深链接。此外,你可以轻松指定多个匹配单个深链接的类型,但请记住,URI 参数比较始终优先,其次是 intent 动作,然后是 MIME 类型。
Compose 让开发者处理深链接变得更加容易。composable函数接受一个NavDeepLinks参数列表,这个列表可以通过navDeepLink方法轻松创建:
-
我们将首先通过向我们的
AndroidManifest.xml文件添加适当的 intent 过滤器来使深链接外部可用:<activity><intent-filter>...<data android:scheme="https"android:host="www.yourcompanieslink.com" /></intent-filter></activity> -
现在在我们的
composable函数中,我们可以使用deepLinks参数,指定navDeepLink列表,然后传递 URI 模式:val uri = "www.yourcompanieslink.com"composable(deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })){...} -
请注意,当另一个应用触发深链接时,导航会自动将深链接链接到那个 composable。
许多应用在导航时仍然使用launchMode。这就是在以下代码片段中使用 Navigation Jetpack 组件时的情况:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
navigationController.handleDeepLink(intent)
}
- 最后,你还可以像使用任何其他
PendingIntent一样使用deepLinkPendingIntent来在深链接目的地启动你的 Android 应用。
重要提示
当触发隐式深链接时,返回栈状态取决于隐式 intent 何时以Intent.FLAG_ACTIVITY_NEW_TASK启动。此外,如果设置了标志,则清除返回栈任务,然后用预期的深链接目的地替换。
它是如何工作的...
在 Android 开发中,深链接指的是应用的一个特定目的地。例如,当你调用深链接时,当用户点击指定的链接,链接会打开应用对应的指定目的地。
这指的是链接点击后打算引导到的位置。显式深链接是一个使用PendingIntent将用户带到应用中特定位置的单一实例。一个很好的用例是在使用通知或应用小部件时。
更多内容...
关于深链接还有很多东西可以学习;例如,如何创建显式深链接。你可以在developer.android.com/training/app-links/deep-linking了解更多关于深链接的信息。
编写导航测试
现在我们为我们的 SampleLogin 项目创建了一个新的屏幕,我们需要修复损坏的测试并添加新的 UI 包测试。如果你还记得,在 第三章,在 Jetpack Compose 中处理 UI 状态和使用 Hilt,我们进行了单元测试而不是 UI 测试。这意味着在添加所有 ViewModel 实例后,我们的 UI 测试现在已损坏。在这个菜谱中,我们将修复失败的测试并添加一个导航测试。
准备工作
在这个菜谱中,你不需要创建任何新的项目;使用已经创建的项目,SampleLogin。
如何做…
你可以将这些概念应用到测试我们创建的底部导航栏。因此,我们不会为 BottomNavigationBarSample 项目编写测试。打开 SampleLogin 并导航到 androidTest 包。我们将在这里添加对新的 RegisterScreen() 可组合函数的测试,并修复损坏的测试:
-
让我们打开
LoginContentTest类。现在,让我们将LoginContent类移动到一个我们将创建的辅助类中,以帮助我们测试 UI 逻辑:@Composablefun contentLoginForTest(uiState: AuthenticationState =AuthenticationState(),onUsernameUpdated : (String) -> Unit = {},onPasswordUpdated :(String) -> Unit = {},onLogin : () -> Unit = {},passwordToggleVisibility: (Boolean) -> Unit = {},onRegisterNavigateTo: () -> Unit = {}) {LoginContent(uiState = uiState,onUsernameUpdated = onUsernameUpdated,onPasswordUpdated = onPasswordUpdated,onLogin = onLogin,passwordToggleVisibility =passwordToggleVisibility,onRegister = onRegisterNavigateTo)} -
在
LoginContentTest类内部,现在我们将LoginContent替换为在initCompose函数内部新创建的contentLoginForTest()函数:private fun initCompose() {composeRuleTest.activity.setContent {SampleLoginTheme {contentLoginForTest()launchRegisterScreenWithNavGraph()}}} -
现在我们已经修复了测试,我们现在可以为我们的新创建的可点击的
TextView添加一个test标签:const val REGISTER_USER = "register_user" -
一旦完成,我们现在需要创建
lateint var NavHostController,以及一个launchRegisterScreenWithNavGraph函数来帮助我们设置导航:private fun launchRegisterScreenWithNavGraph() {composeRuleTest.activity.setContent {SampleLoginTheme {navController = rememberNavController()NavHost(navController = navController,startDestination =Destination.LoginScreen.route) {composable(Destination.LoginScreen.route) {LoginContentScreen(onRegisterNavigateTo = {navController.navigate(Destination.RegisterScreen.route)}, loginViewModel = hiltViewModel())}composable(Destination.RegisterScreen.route) {RegisterContentScreen(hiltViewModel())}}}}}
你可以在 initCompose 函数内部或我们即将创建的新测试函数中调用创建的函数。
-
现在,让我们创建一个测试函数,并将其命名为
assertRegisterClickableButtonNavigatesToRegisterScreen()。在这个测试用例中,我们将设置我们的路由,然后在正确的TextView被点击时使用assert;我们将导航到正确的目的地:@Testfun assertRegisterClickableButtonNavigatesToRegisterScreen() {initCompose()composeRuleTest.onNodeWithTag(TestTags.LoginContent.REGISTER_USER).performClick()val route =navController.currentDestination?.routeassert(route.equals(Destination.RegisterScreen.route))} -
最后,运行测试,UI 测试应该通过,如图 图 4**.6 所示:

图 4.6 – 测试通过
它是如何工作的…
我们创建了 contentLoginForTest,可以帮助我们验证导航。也就是说,当用户输入有效的用户名和密码时,他们可以导航到主屏幕。此外,我们还创建了 launchRegisterScreenWithNavGraph(),这是一个辅助函数,用于为我们的导航测试用例创建测试图。
如果你使用 FragmentScenario,这里有关于测试导航的极好提示,你可以在这里看到:developer.android.com/guide/navigation/navigation-testing。
第五章:使用 DataStore 存储数据并进行测试
Modern Android Development 实践帮助 Android 开发者创建更好的应用程序。DataStore 是 Android Jetpack 库提供的数据存储解决方案。它允许开发者异步和具有一致性保证地存储键值对或复杂对象。数据在 Android 开发中至关重要,我们如何保存和持久化数据至关重要。在本章中,我们将探讨使用 DataStore 持久化我们的数据,并查看使用 DataStore 的最佳实践。
在本章中,我们将介绍以下内容:
-
实现 DataStore
-
将依赖注入添加到 DataStore
-
使用 Android Proto DataStore 与 DataStore
-
使用 DataStore 处理数据迁移
-
为我们的 DataStore 实例编写测试
技术要求
本章的完整源代码可以在 github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_five 找到。
实现 DataStore
在构建移动应用程序时,确保您持久化数据对于实现平滑加载、减少网络问题或完全离线处理数据至关重要。在本例中,我们将探讨如何使用名为 DataStore 的 Modern Android Development Jetpack 库在 Android 应用程序中存储数据。
DataStore 是 Android 应用程序的数据存储解决方案,它允许您使用协议缓冲区存储键值对或任何类型对象。此外,DataStore 使用 Kotlin 协程和流来一致、事务和异步地存储数据。
如果您之前构建过 Android 应用程序,您可能已经使用了 SharedPreferences。新的 Preferences DataStore 旨在取代这种方法。也可以说,Preferences DataStore 利用 SharedPreferences 的力量,因为它们非常相似。此外,Google 的文档建议,如果您目前在项目中使用 SharedPreferences 存储数据,您应考虑迁移到最新的 DataStore 版本。
在 Android 中存储数据的另一种方式是使用 Room。这将在 第六章,使用 Room 数据库和测试 中介绍;现在,我们只需看看 DataStore。此外,需要注意的是,DataStore 适用于简单的或小型数据集,并且不支持部分更新或引用完整性。
如何操作…
让我们继续创建一个新的、空的 Compose 项目,并将其命名为 DataStoreSample。在我们的示例项目中,我们将创建一个任务条目应用程序,用户可以保存任务。我们将允许用户只保存三个任务,然后使用 DataStore 存储任务,并稍后记录数据以查看是否正确插入。另一个尝试的练习是在用户想要查看数据时显示数据:
-
在我们新创建的项目中,让我们继续删除我们不需要的代码。在这种情况下,我们指的是所有空 Compose 项目中包含的
Greeting(name: String)。保留 Preview 函数,因为我们将会使用它来查看我们创建的屏幕。 -
现在,让我们继续添加 DataStore 和同步项目的所需依赖项。注意,DataStore 库有针对 RxJava 2 和 3 的特定版本:
dependencies {implementation "androidx.DataStore:DataStore-preferences:1.x.x"} -
创建一个新的包,并将其命名为
data。在data中,创建一个新的 Kotlin 数据类,并将其命名为Tasks。 -
让我们现在继续构建我们的数据类,并包含预期的输入字段:
data class Tasks(val firstTask: String,val secondTask: String,val thirdTask: String) -
在同一个包内,让我们添加一个
TaskDataSource枚举,因为我们将会重用这个项目来展示使用 Android Proto DataStore 的 Using Android Proto DataStore versus DataStore 菜单:enum class TaskDataSource {PREFERENCES_DATA_STORE} -
在我们的包内,让我们继续添加一个
DataStoreManager接口。在我们的类中,我们将有一个saveTasks()函数来保存数据,以及一个getTasks()函数来帮助我们检索保存的数据。Kotlin 中的suspend函数简单地说是一个可以被暂停并在稍后恢复的函数。
此外,挂起函数可以执行长时间运行的操作并等待完成,而不会阻塞:
interface DataStoreManager {
suspend fun saveTasks(tasks: Tasks)
fun getTasks(): Flow<Tasks>
}
-
接下来,我们需要实现我们的接口,所以让我们继续创建一个
DataStoreManagerImpl类并实现DataStoreManager。为了刷新你对 Flows 的了解,请参考 第三章,在 Jetpack Compose 中处理 UI 状态以及使用 Hilt:class DataStoreManagerImpl(): DataStoreManager {override suspend fun saveTasks(tasks: Tasks) {TODO("Not yet implemented")}override fun getTasks(): Flow<Tasks> {TODO("Not yet implemented")}} -
你会注意到,一旦我们实现了接口,我们将一个视图带到了函数中,但它显示
TODO,并且还没有实现任何内容。为了继续这一步,让我们继续添加 DataStore 并在我们的构造函数中传递Preference。我们还需要为每个任务创建字符串偏好键:class DataStoreManagerImpl(private val tasksPreferenceStore:DataStore<Preferences>) : DataStoreManager {private val FIRST_TASK =stringPreferencesKey("first_task")private val SECOND_TASK =stringPreferencesKey("second_task")private val THIRD_TASK =stringPreferencesKey("third_task")override suspend fun saveTasks(tasks: Tasks) {tasksPreferenceStore.edit {taskPreferenceStore ->taskPreferenceStore[FIRST_TASK] =tasks.firstTasktaskPreferenceStore[SECOND_TASK] =tasks.secondTasktaskPreferenceStore[THIRD_TASK] =tasks.thirdTask}}override fun getTasks(): Flow<Tasks> {TODO("Not yet implemented")}} -
最后,让我们通过向
getTasks函数添加功能来完成DataStore部分的实现:override fun getTasks(): Flow<Tasks> = tasksPreferenceStore.data.map { taskPreference ->Tasks(firstTask = taskPreference[FIRST_TASK] ?: "",secondTask = taskPreference[SECOND_TASK] ?:"",thirdTask = taskPreference[THIRD_TASK] ?: "")} -
在我们的
MainActivity类中,让我们继续创建一个简单的 UI:三个TextField和一个 保存 按钮。保存 按钮将保存我们的数据,并且我们可以在一切按预期工作后尝试记录数据。请参考本章的 技术要求 部分以获取 UI 代码。

图 5.1 – DataStore UI 示例
现在我们已经准备好了实现,在接下来的菜谱中,将依赖注入添加到 DataStore,我们将添加依赖注入并将一切粘合在一起。
它是如何工作的…
新的 Modern Android Development Jetpack 库 Preferences DataStore 的主要目标是替换 SharedPreferences。为了实现 Preferences DataStore,正如你在菜谱中看到的,我们使用一个接受 Preference 抽象类的 DataStore 接口,我们可以使用它来编辑和映射条目数据。此外,我们为键值对的关键部分创建键:
private val FIRST_TASK = stringPreferencesKey("first_task")
private val SECOND_TASK = stringPreferencesKey("second_task")
private val THIRD_TASK = stringPreferencesKey("third_task")
要将数据保存到 DataStore,我们使用 edit(),这是一个需要从 CoroutineContext 调用的挂起函数。与 SharedPreferences 相比,使用 Preferences DataStore 的一个关键区别是,DataStore 在 UI 线程上调用是安全的,因为它在底层使用 dispatcher.IO。
你也不需要使用 apply{} 或 commit 函数来保存更改,正如在 SharedPreferences 中所要求的。此外,它以事务方式处理数据更新。更多功能列在 图 5.2 中。

图 5.2 – 从 developers.android.com 选取的 Datastore 功能示例
还有更多东西要学习,并且公平地说,我们在本菜谱中涵盖的内容只是 DataStore 可以做到的一小部分。我们将在接下来的菜谱中介绍更多功能。
将依赖注入添加到 DataStore
依赖注入是软件工程中的一个重要设计模式,其在 Android 应用开发中的应用可以使代码更干净、更易于维护。当谈到 Android 中的 DataStore 时,它是在 Android Jetpack 中引入的现代数据存储解决方案,添加依赖注入可以带来几个好处:
-
通过使用依赖注入,你可以将创建 DataStore 实例的关注点与使用它的代码分离。这意味着你的业务逻辑代码不需要担心如何创建 DataStore 实例,而可以专注于它需要处理的数据。
-
依赖注入使得为你的应用编写单元测试变得更加容易。通过将模拟的 DataStore 实例注入到测试中,你可以确保测试不受 DataStore 实际状态的影响。
-
依赖注入可以帮助你将代码分解成更小、更易于管理的模块。这使得添加新功能或修改现有功能变得更加容易,而不会影响整个代码库。
-
通过使用依赖注入,你可以轻松地在不同的 DataStore 实现之间切换。这在测试不同类型的数据存储或从一种存储解决方案迁移到另一种存储解决方案时非常有用。
如何操作...
你需要完成前面的菜谱才能继续进行,通过执行以下步骤:
-
打开你的项目并添加必要的 Hilt 依赖。如果你需要帮助设置它,请参阅 第三章 中的 在 Jetpack Compose 中处理 UI 状态和使用 Hilt 菜谱。
-
接下来,让我们继续添加我们的
@HiltAndroidApp类,并在我们的Manifest文件夹中添加.name =TaskApp: android:name=".TaskApp":@HiltAndroidAppclass TaskApp : Application()<applicationandroid:allowBackup="true"android:name=".TaskApp"tools:targetApi="31">... -
现在我们已经实现了依赖注入,让我们继续将
@AndroidEntryPoint添加到MainActivity类中,并在DataStoreManagerImpl中添加@Inject constructor。我们应该有类似以下代码片段的内容:class DataStoreManagerImpl @Inject constructor(private val tasksPreferenceStore:DataStore<Preferences>) : DataStoreManager { -
现在,我们需要创建一个新的文件夹,并将其命名为
di;这是我们放置DataStoreModule类的地方。我们创建一个名为store_tasks的文件来存储偏好值:@Module@InstallIn(SingletonComponent::class)class DataStoreModule {private val Context.tasksPreferenceStore :DataStore<Preferences> bypreferencesDataStore(name = "store_tasks")@Singleton@Providesfun provideTasksPreferenceDataStore(@ApplicationContext context: Context): DataStore<Preferences> =context.tasksPreferenceStore} -
我们还需要在
di包内部为DataStoreManagerModule创建一个abstract类。为了减少使用手动依赖注入的样板代码,我们的应用程序也为需要它们的类提供了所需的依赖。你可以在第三章中了解更多信息,处理 Jetpack Compose 中的 UI 状态和使用 Hilt:@Module@InstallIn(SingletonComponent::class)abstract class DataStoreManagerModule {@Singleton@Bindsabstract funbindDataStoreRepository(DataStoreManagerImpl:DataStoreManagerImpl): DataStoreManager} -
现在让我们继续创建一个新的包,并将其命名为
service:interface TaskService {fun getTasksFromPrefDataStore(): Flow<Tasks>suspend fun addTasks(tasks: Tasks)}class TaskServiceImpl @Inject constructor(private val DataStoreManager: DataStoreManager) : TaskService {override fun getTasksFromPrefDataStore() =DataStoreManager.getTasks()override suspend fun addTasks(tasks: Tasks) {DataStoreManager.saveTasks(tasks)}} -
让我们也确保我们有为新创建的服务所需的依赖:
@Singleton@Bindsabstract fun bindTaskService(taskServiceImpl:TaskServiceImpl): TaskService} -
现在我们已经完成了依赖注入和添加 DataStore 所需的所有功能,我们将继续添加一个
ViewModel类,并在用户点击保存按钮时实现保存数据的函数:fun saveTaskData(tasks: Tasks) {viewModelScope.launch {Log.d("Task", "asdf Data was insertedcorrectly")taskService.addTasks(tasks)}} -
在 Compose 视图中,在 Compose 保存按钮内部调用
saveTaskData函数来保存我们的数据:TaskButton(onClick = {val tasks = Tasks(firstTask = firstText.value,secondTask = secondText.value,thirdTask = thirdText.value)taskViewModel.saveTaskData(tasks)},text = stringResource(id = R.string.save)) -
最后,我们需要验证一切是否正常工作,即我们的 UI 和数据存储过程。我们可以通过在 TextFields 中输入数据并点击保存按钮来验证这一点,当记录消息时,它确认数据确实已保存。

图 5.3 – 任务条目
- 如果你最初错过了,这个视图的代码可以在技术要求部分找到。现在,当你输入数据时,就像图 5**.4中那样,我们应该能够在 Logcat 中记录数据并验证我们的数据是否已正确插入。

图 5.4 – 通过调试进行任务条目
- 如果一切正常,也应该在 Logcat 标签中显示一条日志消息。

图 5.5 – 表示数据正确插入的调试日志
它是如何工作的…
在这个菜谱中,我们选择使用依赖注入来为特定类提供所需的依赖。我们已经深入探讨了依赖注入是什么,所以我们将不再解释它,而是讨论我们创建的模块。
在我们的项目中,我们创建了DataStoreManagerModule和DataStoreModule,我们所做的一切只是提供所需的依赖。我们创建了一个名为store_tasks的文件,它帮助我们存储偏好值:
private val Context.tasksPreferenceStore : DataStore<Preferences> by preferencesDataStore(name = "store_tasks")
默认情况下,DataStore 使用协程并返回一个流值。根据文档,使用 DataStore 时需要记住的一些重要规则如下:
-
DataStore 在同一进程中为给定文件只需要一个实例。因此,我们永远不应该创建多个 DataStore 实例。
-
总是让通用的
DataStore类型不可变,以减少不必要的难以追踪的错误。 -
你永远不应该在同一个文件中混合使用单进程 DataStore 和多进程 DataStore。
还有更多...
作为一项练习,你可以尝试添加另一个按钮,并在懒列或文本字段中显示保存的数据。
参见
关于 DataStore 还有更多要学习的内容,这个菜谱只为你概述了你可以用 DataStore 做什么。你可以通过点击以下链接了解更多信息:developer.android.com/topic/libraries/architecture/datastore。
使用 Android Proto DataStore 与 DataStore 的比较
图 5.2 展示了 PreferencesDataStore、SharedPreferences 和 ProtoDataStore 之间的区别。在这个菜谱中,我们将探讨我们如何使用 Proto DataStore。Proto DataStore 实现使用 DataStore 和 Protocol Buffers 将类型化对象持久化到磁盘。
Proto DataStore 与 Preferences DataStore 类似,但与 Preferences DataStore 不同,Proto 不使用键值对,只是在流程中返回生成的对象。文件类型和数据结构取决于 .protoc 文件的模式。
准备工作
我们将使用已经创建的项目来展示如何在 Android 中使用 Proto DataStore。我们还将使用已经创建的类,只是给函数不同的名字。
如何做到这一点...
-
我们需要首先设置所需的依赖项,所以让我们继续在我们的 Gradle 应用级别文件中添加以下内容:
implementation "androidx.DataStore:DataStore:1.x.ximplementation "com.google.protobuf:protobuf-javalite:3.x.x" -
接下来,我们需要在
build.gradle文件中的plugins添加protobuf:plugins {...id "com.google.protobuf" version "0.8.12"} -
我们需要在
build.gradle文件中添加protobuf配置来完成设置:protobuf {protoc {artifact = "com.google.protobuf:protoc:3.11.0"}generateProtoTasks {all().each { task ->task.builtins {java {option 'lite'}}}}} -
现在,在我们的
package文件夹内,我们需要在app/src/main/下添加我们的proto文件,然后创建一个新的目录并命名为proto。你现在应该在app/src/main/proto文件目录中看到以下内容:syntax = "proto3";option java_package ="com.madonasyombua.DataStoreexample";option java_multiple_files = true;message TaskPreference {string first_task = 1;string second_task = 2;string third_task = 3;}
设置起来有很多。现在我们可以开始添加代码来连接一切了。
-
让我们修改可能需要
ProtoDataStore的类。首先,让我们将PROTO_DATA_STORE添加到TaskDataSource枚举类中:enum class TaskDataSource {PREFERENCES_DATA_STORE,PROTO_DATA_STORE} -
在
DataStoreManager中,让我们添加saveTaskToProtoStore()和getUserFromProtoStore(),我们新的接口将看起来像这样:interface DataStoreManager {suspend fun saveTasks(tasks: Tasks)fun getTasks(): Flow<Tasks>suspend fun saveTasksToProtoStore(tasks: Tasks)fun getTasksFromProtoStore(): Flow<Tasks>} -
由于我们刚刚修改了我们的接口,我们还需要继续添加实现类的新功能。你也会注意到,一旦添加了函数,项目会抱怨:
override suspend fun saveTasksToProtoStore(tasks: Tasks) {TODO("Not yet implemented")}override fun getTasksFromProtoStore(): Flow<Tasks> {TODO("Not yet implemented")} -
如推荐,我们需要定义一个实现
Serializer<Type>的类,其中类型在 Proto 文件中定义。这个序列化类的目的是告诉 DataStore 如何读取和写入我们的数据类型。所以,让我们创建一个新的对象并命名为TaskSerializer():object TaskSerializer : Serializer<TaskPreference> {override val defaultValue: TaskPreference =TaskPreference.getDefaultInstance()override suspend fun readFrom(input: InputStream):TaskPreference{try {return TaskPreference.parseFrom(input)} catch (exception:InvalidProtocolBufferException) {throw CorruptionException("Cannot readproto.", exception)}}override suspend fun writeTo(t: TaskPreference,output: OutputStream) = t.writeTo(output)} -
TaskPreference类是自动生成的,你可以通过点击它直接访问它,但不能编辑它。除非你更改原始文件,否则自动生成的文件是不可编辑的。

图 5.6 – 展示自动生成的 TaskPreference 类的屏幕截图
-
现在我们已经创建了我们的数据类型类,我们需要使用与 DataStore 一起使用的上下文创建一个
taskProtoDataStore: DataStore<TaskPreference>。因此,在DataStoreModule中,让我们继续添加以下代码:private val Context.taskProtoDataStore: DataStore<TaskPreference> by DataStore(fileName = "task.pd",serializer = TaskSerializer)@Singleton@Providesfun provideTasksProtoDataStore(@ApplicationContext context: Context):DataStore<TaskPreference> = context.taskProtoDataStore -
现在,让我们回到
DataStoreManagerImpl并着手实现我们尚未实现的函数:override suspend fun saveTasksToProtoStore(tasks: Tasks) {taskProtoDataStore.updateData { taskData ->taskData.toBuilder().setFirstTask(tasks.firstTask).setSecondTask(tasks.secondTask).setThirdTask(tasks.thirdTask).build()}}override fun getTasksFromProtoStore(): Flow<Tasks> =taskProtoDataStore.data.map { tasks ->Tasks(tasks.firstTask,tasks.secondTask,tasks.thirdTask) -
在
TaskService中,我们还将继续添加getTasksFromProto和getTasks():interface TaskService {fun getTasksFromPrefDataStore() : Flow<Tasks>suspend fun addTasks(tasks: Tasks)fun getTasks(): Flow<Tasks>fun getTasksFromProtoDataStore(): Flow<Tasks>} -
当你实现一个接口时,首先被实现的类可能会显示编译错误,这会提示你将接口功能覆盖到类中。因此,在
TaskServiceImpl类中,添加以下代码:class TaskServiceImpl @Inject constructor(private val DataStoreManager: DataStoreManager) : TaskService {override fun getTasksFromPrefDataStore() =DataStoreManager.getTasks()override suspend fun addTasks(tasks: Tasks) {DataStoreManager.saveTasks(tasks)DataStoreManager.saveTasksToProtoStore(tasks)}override fun getTasks(): Flow<Tasks> =getTasksFromProtoDataStore()override fun getTasksFromProtoDataStore():Flow<Tasks> =DataStoreManager.getTasksFromProtoStore()}
最后,现在我们已经保存了所有数据,我们可以记录以确保数据在 UI 上符合预期;查看 技术要求 部分的链接,了解这是如何实现的。
重要提示
Apple M1 有一个与 proto 相关的问题。为此问题已打开一个工单;通过以下链接解决问题:github.com/grpc/grpc-java/issues/7690。希望它会在本书出版时得到修复。重要的是要注意,如果你使用 DataStore-preferences-core 仓库与 Proguard 一起使用,你必须手动将 Proguard 规则添加到你的规则文件中,以防止删除已经编写的字段。你还可以遵循相同的流程来记录并检查数据是否按预期插入。
它是如何工作的…
你可能已经注意到我们以实例的形式存储了我们的自定义数据类型。这正是 Proto DataStore 所做的;它以自定义数据类型的实例形式存储数据。实现需要我们使用协议缓冲区定义一个模式,但它提供了类型安全。
在 Android 的 Proto Datastore 库中,Serializer<Type> 接口将特定类型(Type)的对象转换为相应的协议缓冲区格式,反之亦然。此接口提供了将对象序列化为字节和将字节反序列化为对象的方法。
Android 中的协议缓冲区是一种语言和平台无关的可扩展机制,用于序列化您的结构化数据。协议缓冲区以二进制流的形式编码和解码您的数据,这种流非常轻量级。
在定义数据类或序列化模型类中的属性时,使用 override val defaultValue。它是 Kotlin 序列化库的一部分,该库通常用于将对象序列化和反序列化到不同的数据格式,如 JSON 或协议缓冲区。
我们通过从存储对象中公开流式 DataStore 数据并编写一个提供 updateData() 函数的 proto DataStore 来公开适当的属性,该函数以事务方式更新存储对象。
updateData 函数以我们数据类型的实例形式提供当前数据状态,并在原子读-写-修改操作中更新它。
参见
关于如何创建定义良好的模式还有很多东西可以学习。您可以在以下位置查看 protobuf 语言指南:developers.google.com/protocol-buffers/docs/proto3。
使用 DataStore 处理数据迁移
如果你之前开发过 Android 应用程序,你可能使用过 SharedPreferences;现在的好消息是,现在有了迁移支持,你可以使用 SharedPreferenceMigration 从 SharedPreferences 迁移到 DataStore。与任何数据一样,我们总是会修改我们的数据集;例如,我们可能想要重命名我们的数据模型值或甚至更改它们的类型。
在这种情况下,我们需要进行 DataStore 到 DataStore 的迁移;这正是我们将在这个菜谱中工作的。这个过程与从 SharedPreferences 迁移非常相似;事实上,SharedPreferencesMigration 是 DataMigration 接口类的一个实现。
准备工作
由于我们刚刚创建了一个新的 PreferenceDataStore,我们不需要迁移它,但我们可以看看在需要时如何实现迁移。
如何实现…
在这个菜谱中,我们将探讨如何利用所学知识来帮助你处理需要迁移到 DataStore 的情况:
-
让我们先看看帮助迁移的接口。以下代码部分展示了
DataMigration接口,该接口由SharedPreferencesMigration实现:/* Copyright 2022 Google LLC.SPDX-License-Identifier: Apache-2.0 */public interface DataMigration<T> {public suspend fun shouldMigrate(currentData: T): Booleanpublic suspend fun migrate(currentData: T): Tpublic suspend fun cleanUp()} -
在
Tasks数据中,我们可能想要将条目更改为Int;这意味着更改我们的数据类型之一。我们将想象这个场景,并尝试基于此创建一个迁移。我们可以从创建一个新的migrateOnePreferencesDataStore开始:private val Context.migrateOnePreferencesDataStore : DataStore<Preferences> by preferencesDataStore(name = "store_tasks") -
现在,让我们继续实现
DataMigration并覆盖其函数。您需要指定迁移是否应该发生的条件。迁移数据显示了如何将旧数据精确地转换为新数据。然后,一旦迁移完成,清理旧存储:private val Context.migrationTwoPreferencesDataStore by preferencesDataStore(name = NEW_DataStore,produceMigrations = { context ->listOf(object : DataMigration<Preferences> {override suspend funshouldMigrate(currentData:Preferences) = trueoverride suspend fun migrate(currentData:Preferences): Preferences {val oldData = context.migrateOnePreferencesDataStore.data.first().asMap()val currentMutablePrefs =currentData.toMutablePreferences()oldToNew(oldData, currentMutablePrefs)returncurrentMutablePrefs.toPreferences()}override suspend fun cleanUp() {context.migrateOnePreferencesDataStore.edit { it.clear() }}})}) -
最后,让我们创建
oldToNew()函数,这是我们添加要迁移的数据的地方:private fun oldToNew(oldData: Map<Preferences.Key<*>, Any>,currentMutablePrefs: MutablePreferences) {oldData.forEach { (key, value) ->when (value) {//migrate data types you wish to migrate...}}}
它是如何工作的…
为了更好地理解 DataMigration 的工作原理,我们需要查看 DataMigration 接口中的函数。在我们的接口中,我们有三个函数,如下面的代码块所示:
public suspend fun shouldMigrate(currentData: T):
Boolean
public suspend fun migrate(currentData: T): T
public suspend fun cleanUp()
shouldMigrate() 函数,正如其名所示,用于确定是否需要执行迁移。例如,如果没有执行迁移,这意味着该函数将返回 false,则不会进行迁移或清理。此外,需要注意的是,每次我们调用我们的 DataStore 实例时,此函数都会被初始化。另一方面,Migrate() 函数执行迁移操作。
偶然情况下,如果操作失败或不符合预期,DataStore 将不会将任何数据提交到磁盘。此外,清理过程将不会发生,并且会抛出异常。最后,cleanUp()正如其名,只是清除之前数据存储中的任何旧数据。
为我们的 DataStore 实例编写测试
在 Android 开发中编写测试至关重要,在这个菜谱中,我们将为我们的 DataStore 实例编写一些测试。为了测试我们的 DataStore 实例或任何 DataStore 实例,我们首先需要设置 instrumentation 测试,因为我们将在实际的文件(DataStore)中进行读写操作,并且验证准确更新是至关重要的。
如何做到这一点…
我们将首先创建一个简单的单元测试来测试我们的视图模型函数:
-
在我们的单元测试文件夹中,创建一个新的文件夹,命名为
test,并在其中创建一个新的类,命名为TaskViewModelTest:class TaskViewModelTest {} -
接下来,我们需要添加一些测试依赖项:
testImplementation "io.mockk:mockk:1.13.3"androidTestImplementation "io.mockk:mockk-android:1.13.3"testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2" -
现在我们已经添加了所需的依赖项,让我们继续创建我们的模拟任务服务类,然后对其进行模拟,并在设置中初始化它:
private lateinit var classToTest: TaskViewModelprivate val mockTaskService = mockk<TaskService>()private val dispatcher = TestCoroutineDispatcher()@Beforefun setUp(){classToTest = TaskViewModel(mockTaskService)} -
由于我们使用协程,我们将在
@Before注解中设置我们的分发器,并在@After注解中使用Dispatchers.resetMain()清除任何存储的数据。如果你在没有设置协程的情况下运行测试,它们将因错误而失败。使用Main分发器的模块初始化失败。对于测试,可以使用kotlinx-coroutines-test模块中的Dispatchers.setMain:@Beforefun setUp(){classToTest = TaskViewModel(mockTaskService)Dispatchers.setMain(dispatcher)}@Afterfun tearDown() {Dispatchers.resetMain()} -
完成那之后,我们继续创建一个新的测试,命名为
Verify add tasks function adds tasks as needed。在这个测试中,我们将创建一个fakeTask,将这些任务添加到saveTaskData中,并通过检查我们没有存储null来确保数据按预期插入:@Testfun `Verify add tasks function adds tasks as needed`() = runBlocking {val fakeTasks = Tasks(firstTask = "finish school work",secondTask = "buy gifts for the holiday",thirdTask = "finish work")val expected = classToTest.saveTaskData(fakeTasks)Assert.assertNotNull(expected)}
最后,当你运行单元测试时,它应该通过,你将看到一个绿色的勾号。

图 5.7 – 视图模型中的测试通过
它是如何工作的…
在 Android 中使用了不同的模拟库:Mockito、Mockk等。在这个菜谱中,我们使用了Mockk,这是一个用户友好的 Android 模拟库。testImplementation "io.mockk:mockk:1.13.3"用于单元测试,而androidTestImplementation "io.mockk:mockk-android:1.13.3"用于 UI 测试。
要测试 UI,我们需要遵循一个模式,创建一个包含默认值的测试 DataStore 实例。然后,我们创建测试主题,并验证从我们的函数中来的测试 DataStore 值是否与预期结果匹配。我们还需要使用TestCoroutineDispatcher:
private val coroutineDispatcher: TestCoroutineDispatcher =
TestCoroutineDispatcher()
上述代码执行协程,默认情况下是立即执行的。这仅仅意味着所有计划无延迟运行的任务都会立即执行。我们也为我们的视图模型使用相同的协程。这也是因为 DataStore 基于 Kotlin 协程;因此,我们需要确保我们的测试有正确的设置。
参见
关于 DataStore 还有更多内容可以学习。我们无法在一章中涵盖所有内容。有关 DataStore(即偏好设置和 Proto)的更多信息,您可以查看此链接:developer.android.com/topic/libraries/architecture/datastore。
第六章:使用 Room 数据库和测试
Android 应用程序可以从本地存储数据中显著受益。Room 持久化库利用了 SQLite 的力量。特别是,Room 为 Android 开发者提供了卓越的好处。此外,Room 提供了离线支持,数据是本地存储的。在本章中,我们将学习如何实现 Room,一个 Jetpack 库。
在本章中,我们将介绍以下食谱:
-
在你的应用程序中实现 Room
-
在 Room 中实现依赖注入
-
支持多个实体
-
将现有的 SQL 数据库迁移到 Room
-
测试你的本地数据库
还需要提到的是,还有一些库与 Room 一起使用——例如,RxJava 和分页集成。在本章中,我们不会关注它们,而是专注于如何利用 Room 来构建现代 Android 应用。
技术要求
本章节的完整源代码可以在github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_six找到。
在你的应用程序中实现 Room
Room 是一个用于 Android 数据持久化的对象关系映射库,是现代 Android 开发中推荐的数据持久化方案。此外,它易于使用、理解和维护,并利用了 SQLiteDatabase 的功能,同时也有助于减少许多开发者在使用 SQLite 时遇到的样板代码问题。编写测试也非常直接且易于理解。
Room 最显著的优势是它易于与其他架构组件集成,并为开发者提供运行时编译检查——也就是说,如果你在未迁移的情况下犯错或更改了模式,Room 会提出警告,这既实用又有助于减少崩溃。
如何操作…
让我们创建一个新的空 Compose 项目,并将其命名为 RoomExample。在我们的示例项目中,我们将创建一个用户表单输入;这是用户可以保存他们的名字、姓氏、出生日期、性别、居住的城市以及他们的职业的地方。
我们将在 Room 数据库中保存我们的用户数据,然后稍后检查我们插入的元素是否已保存在我们的数据库中,并在屏幕上显示数据:
-
在我们新创建的项目中,让我们先删除不必要的 wanted 代码——即,所有空 Compose 项目都包含的
Greeting(name: String),保留预览功能,因为我们将会用它来查看我们创建的屏幕。 -
现在,让我们继续添加 Room 所需的依赖并同步项目。我们将在第十二章中介绍如何使用
buildSrc进行依赖管理,Android Studio 技巧和窍门,帮助你开发过程中。你可以在developer.android.com/jetpack/androidx/releases/room找到 Room 的最新版本;我们将添加kapt,代表Kotlin Annotation Processing Tool,以使我们能够使用 Java 注解处理器与 Kotlin 代码一起使用:dependencies {implementation "androidx.Room:Room-runtime:2.x.x"kapt "androidx.Room:Room-compiler:2.x.x"}//include kapt on your pluginsplugins {id 'kotlin-kapt'} -
创建一个新的包并命名为
data。在data内部创建一个新的 Kotlin 类并命名为UserInformationModel()。数据类用于仅保存数据——在我们的案例中,我们将从用户那里收集的数据类型将是名字、姓氏、出生日期等等。 -
通过使用 Room,我们使用
@Entity注解给我们的模型一个表名;因此,在我们的新创建的UserInformation类中,让我们继续添加@Entity注解并称我们的表为用户信息:@Entity(tableName = "user_information")data class UserInformationModel(val id: Int = 0,val firstName: String,val lastName: String,val dateOfBirth: Int,val gender: String,val city: String,val profession: String) -
接下来,就像所有数据库一样,我们需要为我们的数据库定义一个主键。因此,在我们的 ID 中,我们将添加
@PrimaryKey注解来告诉 Room 这是我们主键,并且应该自动生成。如果你不希望自动生成,你可以将布尔值设置为false,但这可能不是一个好主意,因为可能会在数据库中引起冲突:@PrimaryKey(autoGenerate = true) -
现在,你应该有一个具有表名、主键和你的数据类型的实体:
import androidx.Room.Entityimport androidx.Room.PrimaryKey@Entity(tableName = "user_information")data class UserInformationModel(@PrimaryKey(autoGenerate = true)val id: Int = 0,...)
在我们的数据包内部,让我们继续创建一个新的包并命名为DAO,代表UserInformationDao;这个接口将包含创建、读取、更新和删除(CRUD)功能——即更新、插入、删除和查询。
我们还必须在我们的接口上使用@Dao注解来告诉 Room 这是我们自己的 DAO。我们在更新和插入函数上使用OnConflictStrategy.REPLACE来帮助我们处理可能在我们数据库中遇到的冲突情况。在这种情况下,OnConflictStrategy意味着如果Insert有相同的 ID,它将用特定的 ID 替换该数据:
private const val DEFAULT_USER_ID = 0
@Dao
interface UserInformationDao {
@Query("SELECT * FROM user_information")
fun getUsersInformation():
Flow<List<UserInformationModel>>
@Query("SELECT * FROM user_information WHERE id =
:userId")
fun loadAllUserInformation(userId: Int =
DEFAULT_USER_ID): Flow<UserInformationModel>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUserInformation(userInformation:
UserInformationModel)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateUserInformation(userInformation:
UserInformationModel)
@Delete
suspend fun deleteUserInformation(userInformation:
UserInformationModel)
}
-
现在我们有了实体和 DAO,我们最终将创建一个
Database类,它扩展了RoomDatabase()。在这个类中,我们将使用@Database注解,传入我们创建的实体,即UserInformation实体,并给我们的数据库一个版本名称,目前是one。我们还将指定是否应该导出我们的数据库模式。所以,让我们继续创建Database抽象类:@Database(entities = [UserInformation::class], version = 1, exportSchema = false)abstract class UserInformationDatabase : RoomDatabase() {abstract fun userInformationDao():UserInformationDao} -
最后,我们已经设置了
Room并准备好了。现在,我们需要添加依赖注入和用户界面;你可以在技术要求部分找到代码。此外,UI 在这个阶段相当基础;你可以将其作为一个挑战来改进,因为这个示例项目只是为了演示目的。

图 6.1 – 应用程序的 UI
它是如何工作的…
现代安卓开发 Room 库有三个重要的 Room 数据库组件:
-
实体
-
DAO
-
数据库
Entity 是数据库中的一个表。Room 为每个带有 @Entity 注解的类生成一个表;如果你之前使用过 Java,你可以把实体想象成 普通的 Java 对象(POJO)。实体类通常很小,不包含任何逻辑,只持有对象的数据类型。
将数据库中的表映射的一些重要注解是外键、索引、主键和表名。还有其他重要的注解,例如 ColumnInfo,它提供了列信息,以及 Ignore,如果使用它,你希望忽略的任何数据将不会被 Room 持久化。

图 6.2 – Room DAO
@DAO 定义了访问数据库的函数。把它想象成 CRUD;如果你在 Room 之前使用过 SQLite,这类似于使用游标对象。最后,@Database 包含数据库函数,并作为任何底层连接到我们应用程序关系数据的入口点。
如果需要使用这个功能,你可以用 @Database 进行注释,就像我们在数据库类中做的那样。此外,这个类扩展了 RoomDatabase 并包含了我们创建的实体列表。它还包含了一个我们创建的抽象方法,没有参数,并返回我们用 @Dao 注释的类。我们通过调用 Room.databaseBuilder() 来运行数据库。
在 Room 中实现依赖注入
与其他食谱一样,依赖注入至关重要,在这个食谱中,我们将介绍我们如何注入 DatabaseModule 并提供所需的 Room 数据库。
准备工作
为了能够一步一步地跟随这个食谱,你需要先了解 Hilt 的工作原理。
如何做…
打开 RoomExample 项目并添加 Hilt,这是我们用于依赖注入的库。在 第三章,在 Jetpack Compose 中处理 UI 状态和使用 Hilt,我们介绍了 Hilt,所以这里我们不会讨论它,但会展示你如何与 Room 一起使用它:
-
打开你的项目并添加必要的 Hilt 依赖。参见 第三章,在 Jetpack Compose 中处理 UI 状态和使用 Hilt,如果你需要帮助设置 Hilt 或访问
dagger.dev/hilt/。 -
接下来,让我们添加我们的
@HiltAndroidApp类,并在Manifest文件夹中添加我们的HiltAndroidApp的名称,在我们的例子中,是UserInformation:@HiltAndroidAppclass UserInformation : Application()<applicationandroid:allowBackup="true"android:name=".UserInformation"tools:targetApi="33">... -
现在我们有了依赖注入,让我们在
MainActivity类中添加@AndroidEntryPoint,并在我们的项目中创建一个新的包,命名为di。在里面,我们将创建一个新的类,DatabaseModule,并添加我们的功能。 -
在
DatabaseModule中,让我们继续创建一个provideDatabase()函数,我们将返回Room对象,添加数据库名称,并确保我们构建了数据库:@Module@InstallIn(SingletonComponent::class)class DataBaseModule {@Singleton@Providesfun provideDatabase(@ApplicationContext context:Context): UserInformationDatabase {return Room.databaseBuilder(context,UserInformationDatabase::class.java,"user_information.db").build()}@Singleton@Providesfun provideUserInformationDao(userInformationDatabase: UserInformationDatabase):UserInformationDao {returnuserInformationDatabase.userInformationDao()}} -
现在我们已经设置了依赖注入数据库模块,我们现在可以开始添加服务,这些服务是帮助我们向数据库添加用户信息并从数据库获取用户信息的函数。因此,让我们继续创建一个新的包,名为
service。在包内部,创建一个新的接口,UserInfoService,并添加上述两个函数:interface UserInfoService {fun getUserInformationFromDB():Flow<UserInformation>suspend fun addUserInformationInDB(userInformation: UserInformation)} -
由于
UserInfoService是一个接口,我们需要在我们的Impl类中实现这些功能,因此现在让我们继续创建一个新的类,名为UserInfoServiceImpl,以及一个单例类,然后实现接口:@Singletonclass UserInfoServiceImpl() : UserInfoService {override fun getUserInformationFromDB():Flow<UserInformation> {TODO("Not yet implemented")}override suspend fun addUserInformationInDB(userInformation: UserInformation) {TODO("Not yet implemented")}} -
我们需要注入构造函数并传递
UserInformationDao(),因为我们将会使用插入函数来插入用户数据:class UserInfoServiceImpl @Inject constructor(private val userInformationDao: UserInformationDao): UserInfoService -
现在,我们需要在我们的函数中添加代码,这些函数中有 TODO。让我们先看看用户信息。使用
userInformationDao,我们将调用插入函数来告诉 Room 我们想要插入这条用户信息:override suspend fun addUserInformationInDB(userInformation: UserInformation) {userInformationDao.insertUserInformation(UserInformation(firstName = userInformation.firstName,lastName = userInformation.lastName,dateOfBirth = userInformation.dateOfBirth,gender = userInformation.gender,city = userInformation.city,profession = userInformation.profession))} -
然后,我们需要从数据库中获取用户信息;这将可视化用户数据在屏幕上:
override fun getUserInformationFromDB() =userInformationDao.getUsersInformation().filter {information -> information.isNotEmpty()}.flatMapConcat {userInformationDao.loadAllUserInformation().map { userInfo ->UserInfo(id = userInfo.id,firstName = userInfo.firstName,lastName = userInfo.lastName,dateOfBirth =userInfo.dateOfBirth,gender = userInfo.gender,city = userInfo.city,profession = userInfo.profession)}} -
最后,我们需要确保通过依赖注入提供实现,因此现在让我们继续添加前面的代码,然后清理项目,运行它,并确保一切按预期工作:
@Module@InstallIn(SingletonComponent::class)abstract class UserInfoServiceModule {@Singleton@Bindsabstract fun bindUserService(userInfoServiceImpl: UserInfoServiceImpl):UserInfoService} -
一旦运行项目,你应该能够看到它无问题地启动。我们将继续在我们的
ViewModel中添加一个函数来将数据插入我们的数据库;ViewModel将在我们创建的视图中使用:@HiltViewModelclass UserInfoViewModel @Inject constructor(private val userInfoService: UserInfoService) : ViewModel() {fun saveUserInformationData(userInfo: UserInfo) {viewModelScope.launch {userInfoService.addUserInformationInDB(userInfo)}}} -
现在,我们可以检查数据库,看它是否正确创建。运行应用,一旦在 IDE 中准备就绪,点击应用检查,如图图 6.3所示。你应该能够打开数据库检查器。

图 6.3 – 应用检查
- 一旦数据库检查器加载完毕,你应该能够选择当前运行的 Android 模拟器,如图图 6.4所示:

图 6.4 – 用于应用检查的选定应用
- 在图 6.5中,你可以看到数据库检查器已打开,以及我们的数据库。

图 6.5 – 我们的 user_information 数据库
- 在图 6.6中,你可以看到我们插入的数据被显示出来,这意味着我们的插入函数按预期工作。

图 6.6 – 我们的数据库
它是如何工作的…
在这个菜谱中,我们选择使用依赖注入来提供所需依赖给特定类。我们在前面的章节中深入介绍了依赖注入是什么,所以在这个菜谱中我们不再解释它,而是讨论我们创建的模块。
我们在 Hilt 中使用了 @Singleton 注解来指示 provideDatabase,它提供 Room 的实例,应在应用程序的生命周期内只创建一次,并且这个实例应该被所有依赖它的组件共享。此外,当您使用 @Singleton 注解一个类或绑定方法时,Hilt 确保只创建该类或对象的单个实例,并且所有需要该对象的组件都将接收到相同的实例。
重要的是要知道,当我们使用 Hilt 中的 @Singleton 时,它与软件设计中的 Singleton 模式并不相同,这可能会引起混淆。Hilt 的 @Singleton 只保证在特定组件层次结构上下文中创建一个类的单个实例。
在我们的项目中,我们创建了 DatabaseModule() 和 UserInfoServiceModule()。在 DatabaseModule() 类中,我们有两个函数,provideDatabase 和 provideUserInformationDao。第一个函数 provideDatabase 返回 UserInformationDatabase Room 实例,在那里我们可以创建数据库并构建它:
fun provideDatabase(@ApplicationContext context: Context): UserInformationDatabase {
return Room
.databaseBuilder(context,
UserInformationDatabase::class.java,
"user_information.db")
.build()
}
在 provideUserInformationDao 中,我们在构造函数中传递 UserInformationDatabase 并返回 UserInformationDao 抽象类:
fun provideUserInformationDao(userInformationDatabase: UserInformationDatabase): UserInformationDao {
return userInformationDatabase.userInformationDao()
}
重要提示
如果您在迁移时想要丢失现有数据,或者您的迁移路径缺失,您可以在创建数据库时使用 .fallbackToDestructiveMigration() 函数。
相关内容
在 Room 中还有更多东西要学习,这个配方只为您提供了一个关于您可以做什么的简要概述。您可以通过点击developer.android.com/reference/androidx/room/package-summary中的链接来了解更多信息。
在 Room 中支持多个实体
在这个配方中,您将学习如何在 Room 中处理多个实体。这在您有一个需要不同数据输入的大项目时非常有用。我们可以一起工作的一个很好的例子是一个预算应用程序。
在 Room 中支持多个实体,您需要定义多个类来表示您的数据库表。每个类都应该有自己的注解和字段,这些字段对应于表中的列。例如,一个预算应用程序可能需要不同类型的模型,如下所示:
-
BudgetData -
ExpenseData -
ExpenseItem
因此,有时拥有多个实体是必要的,了解如何处理这一点会很有帮助。
准备工作
要跟随这个配方,您必须完成之前的配方。
如何操作 …
您可以使用您选择的任何项目来实现本配方中讨论的主题。此外,您还可以将此示例用于您现有的项目来实现该主题。
-
在
RoomExample中,您可以向应用程序添加更多功能并尝试添加更多实体,但在这个项目中,让我们继续展示如何在 Room 中处理多个实体。 -
在本例中,我们将使用我们在前一章中介绍的示例预算应用程序,并且由于我们正在处理实体,这将更容易理解。让我们创建一个新的实体,并将其命名为
BudgetData;预算数据类可能包含多个字段,例如budgetName、budgetAmount、expenses、startDate、endDate、notify、currency和totalExpenses;因此,我们的BudgetData数据类将看起来像这样:@Entity(tableName = "budgets")data class BudgetData(@PrimaryKey(autoGenerate = true)var id: Int = 0,var budgetName: String = "",var budgetAmount: Double = 0.0,var expenses: String = "",var startDate: String = "",var endDate: String = "",var notify: Int = 0,var currency: String = "",var totalExpenses: Double) -
让我们继续添加两个更多实体。首先,我们将添加
ExpenseData,它可能包含以下字段和类型:@Entity(tableName = "expenses")data class ExpenseData(@PrimaryKey(autoGenerate = true)var id: Int = 0,var expenseName: String = "",var expenseType: String = "",var expenseAmount: Double = 0.0,@ColumnInfo(name = "updated_at")var expenseDate: String = "",var note: String = "",var currency: String = "") -
然后,让我们添加
ExpenseItem,它可能包含以下字段:@Entity(tableName = "items")Data class ExpenseItem(@PrimaryKey(autoGenerate = true)private var _id: Intval name: Stringvar type: String?val imageContentId: Intval colorContentId: Int) -
如你所见,我们有三个实体;基于这些实体,你应该为每个实体创建不同的 DAO:
abstract class AppDatabase : RoomDatabase() {abstract fun budgetDao(): BudgetDaoabstract fun itemDao(): ItemDaoabstract fun expenseDao(): ExpenseDao} -
在
AppDatabase抽象类的顶部,我们将使用@Database注解并传递给所有我们的实体:@Database(entities = [ExpenseItem::class, BudgetData::class,ExpenseData::class],version = 1)@TypeConverters(DateConverter::class)abstract class AppDatabase : RoomDatabase() {abstract fun budgetDao(): BudgetDaoabstract fun itemDao(): ItemDaoabstract fun expenseDao(): ExpenseDao} -
你也可以使用嵌套对象;
@Embedded注解包括实体内的嵌套或相关实体。它允许你通过在父实体中嵌入一个或多个相关实体来表示实体之间的关系:data class ExpenseItem(...@Embedded val tasks: Tasks)data class Tasks(...)
在我们前面的例子中,我们在ExpenseItem实体的任务属性上使用了@Embedded注解。这告诉 Room 将Tasks数据类的字段包含在ExpenseItem表中,而不是为我们的ExpenseItem实体创建一个单独的表。
-
然后,
Tasks数据类可以包含description、priority、updatedAt和 ID:data class Tasks (@PrimaryKey(autoGenerate = true)var id = 0var description: Stringvar priority: Int@ColumnInfo(name = "updated_at")var updatedAt: Date)
因此,表示ExpenseItem对象的表将包含具有新添加字段的附加列。
就这样;一旦你在数据库中声明了实体并将它们作为所需内容传递,你将在你的Database中支持多个实体。
重要提示
如果你的实体有多个相同类型的嵌套字段,你可以通过设置Prefix属性来保持每个列的唯一性;然后,Room 将在嵌套对象中的每个列名前添加提供的值。更多信息请参阅developer.android.com/.
它是如何工作的...
根据 Room 的规则,你可以以三种不同的方式定义实体关系。
-
一对多关系或多对一关系
-
一对一关系
-
多对多关系
正如你所见,在一个类中使用实体使其易于管理和跟踪;因此,这对于 Android 工程师来说是一个极好的解决方案。一个值得注意的注解是@Relation,它指定了创建显示实体之间关系的对象的位置。
还有更多...
在 Room 中还有更多东西要学习——例如,定义对象之间的关系、编写异步数据可访问对象查询以及引用复杂数据。可以说,我们不可能在一章中涵盖所有内容,但我们可以提供一些指导,帮助您导航构建现代 Android 应用程序。有关 Room 的更多信息,请访问 developer.android.com/training/data-storage/room。
将现有的 SQL 数据库迁移到 room
如我们之前提到的,Room 确实利用了 SQLite 的力量,并且由于许多应用程序仍在使用遗留版本,您可能会发现一些应用程序仍在使用 SQL,并想知道如何迁移到 Room 并利用最新的 Room 功能。
在这个菜谱中,我们将通过逐步示例来介绍将现有的 SQL 数据库迁移到 Room。此外,Room 提供了一个抽象层来帮助处理 SQLite 迁移——也就是说,通过向开发者提供 Migration 类。
如何操作...
由于我们没有创建一个新的 SQLite 数据库示例,因为那不是必要的,我们将尝试使用一个虚拟的样本 SQLite 数据库来模拟场景,展示如何将现有的 SQLite 数据库迁移到 Room:
-
由于我们将在现有的 SQLite 项目中添加 Room,您需要确保添加所需的依赖项。要设置此环境,请参考 在您的应用程序中实现 Room 菜谱。
-
接下来,您需要继续创建一个新的 DAO 和实体,因为 Room 要求这样做。因此,在第一个 Room 菜谱的后续步骤中,您可以更新模型类为实体。这相当简单,因为您主要需要做的是用
@Entity注解类,并使用@Database注解中的table属性来设置表名。 -
您还必须为实体类添加
@PrimaryKey和@ColumnInfo注解。以下是一个样本 SQLite 数据库:fun onCreate(db: SQLiteDatabase) {// Create a String that contains the SQL statementto create the items tableval SQL_CREATE_ITEMS_TABLE =("CREATE TABLE " + ItemsContract.ItemsEntry.TABLE_NAME.toString() + " ("+ ItemsContract.ItemsEntry._Id.toString()+ " INTEGER PRIMARY KEYAUTOINCREMENT, "+ ItemsContract.ItemsEntry.COLUMN_ITEM_NAME.toString()+ " TEXT NOT NULL, "+ ItemsContract.ItemsEntry.COLUMN_ITEM_TYPE.toString()+ " TEXT NOT NULL, "+ ItemsContract.ItemsEntry.COLUMN_ITEM_LOGO.toString()+ " INTEGER NOT NULL DEFAULT 0, "+ ItemsContract.ItemsEntry.COLUMN_ITEM_COLOR.toString()+ " INTEGER NOT NULL DEFAULT 0, "+ ItemsContract.ItemsEntry.COLUMN_ITEM_CREATED_DATE.toString() + " DATE NOT NULLDEFAULT CURRENT_TIMESTAMP);")// Execute the SQL statementdb.execSQL(SQL_CREATE_ITEMS_TABLE)}
然而,Room 简化了这个过程,我们不再需要创建 Contracts。在 Android 中,Contracts 是开发者定义并强制执行一组规则以访问应用程序中数据的方式。这些合约通常定义数据库表的结构和模式,以及其中数据的预期数据类型和格式。在 Android 的 SQLite 情况下,合约通常用于定义数据库的表和列,以及它们之间任何约束或关系。
-
一旦我们创建了所有需要的实体和 DAO,我们就可以继续创建数据库。正如我们在 在您的应用程序中实现 Room 菜谱中看到的,我们可以在
@Database注解中添加所有我们的实体,并且由于我们处于第一个(1)版本,我们可以将版本增加到 (2):val MIGRATION_1_2 = object : Migration(1, 2) {override fun migrate(database:SupportSQLiteDatabase) {//alter items tabledatabase.execSQL("CREATE TABLE new_items (_idINTEGER PRIMARY KEY AUTOINCREMENT NOTNULL, name TEXT NOT NULL, type TEXT,imageContentId INTEGER NOT NULL,colorContentId INTEGER NOT NULL)")database.execSQL("INSERT INTO new_items(_id,name,type,imageContentId,colorContentId)Select_id,name,type,imageContentId, colorContentId FROMitems")database.execSQL("DROP TABLE items")database.execSQL("ALTER TABLE new_items RENAMETO items")} -
然后,重要的是确保我们调用
build()方法到 Room 数据库:Room.databaseBuilder(androidContext(),AppDatabase::class.java, "budget.db").addCallback(object : RoomDatabase.Callback() {override funonCreate(db:SupportSQLiteDatabase){super.onCreate(db)}}).addMigrations(MIGRATION_1_2).build() -
一旦你的数据层开始使用 Room,你可以正式用 DAO 调用替换所有的
Cursor和ContentValue代码。在我们的AppDatabase类中,我们有我们的实体,并且我们的类扩展了RoomDatabase():@Database(entities = [<List of entities>],version = 2)abstract class AppDatabase : RoomDatabase() {abstract fun itemDao(): ItemDao}
因为 Room 提供了运行时错误,如果发生任何错误,你将在 Logcat 中收到通知。
- 公平地说,并不是所有内容都可以在一个菜谱中涵盖,因为 SQLite 确实需要很多代码来设置——例如,创建查询和处理游标——但 Room 有助于加快这些过程,这就是为什么它被强烈推荐。
它是如何工作的...
如前所述,迁移复杂的数据库可能会很繁琐,需要谨慎,因为它如果没有经过彻底测试就推送到生产环境,可能会影响用户。还强烈建议使用RoomDatabase暴露的OpenHelper,以实现数据库的更直接或最小更改。此外,值得一提的是,如果你有任何使用 SQLite 的遗留代码,它将用 Java 在高级别编写,因此需要与团队合作,找到更好的迁移解决方案。
在你的项目中,你必须更新扩展SQLiteOpenHelper的类。我们使用SupportSQLiteDatabase是因为我们需要更新获取可写和可读数据库的调用。这是一个更干净的数据库抽象类,用于插入和查询数据库。
重要提示
重要的一点是,迁移到一个具有许多表和复杂查询的复杂数据库可能会很复杂。然而,如果你的数据库具有少量表且没有复杂查询,迁移可以通过在功能分支中进行相对较小的增量更改快速完成。下载应用程序的数据库可能会有所帮助,你可以通过访问以下链接来完成:developer.android.com/training/data-storage/room/testing-db#command-line。
测试你的本地数据库
到目前为止,我们已经确保在必要时为我们的项目编写测试。现在,我们需要继续编写RoomExample项目的测试,因为这是至关重要的,你可能在现实世界的场景中需要这样做。因此,在这个菜谱中,我们将查看编写数据库 CRUD 测试的逐步指南。
准备工作
你需要打开RoomExample项目来开始这个菜谱。
如何做到这一点...
让我们首先添加所有需要的 Room 测试依赖项,然后开始编写测试。对于 Hilt 测试设置,请参阅技术要求部分,在那里你可以找到所有所需的代码:
-
你需要将以下内容添加到你的
build.gradle中:androidTestImplementation "com.google.truth:truth:1.1.3"androidTestImplementation "android.arch.core:core-testing:1.1.1" -
在你已经在 Android 测试中添加了所需的依赖项之后,继续创建一个新的类,命名为
UserInformationDBTest:class UserInformationDBTest {...} -
在我们能够设置
@Before函数之前,我们需要创建两个lateinit var实例,我们将在@Before函数中初始化它们:private lateinit var database: UserInformationDatabaseprivate lateinit var userInformationDao: UserInformationDao -
现在,让我们继续设置我们的
@Before函数并创建我们的数据库,使用内存数据库进行测试目的:@Beforefun databaseCreated() {database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(),UserInformationDatabase::class.java).allowMainThreadQueries().build()userInformationDao = database.userInformationDao()} -
由于我们是在内存中运行和创建数据库,所以在完成后我们需要关闭它;因此,在我们的
@After调用中,我们需要在我们的数据库上调用close():@Afterfun closeDatabase() {database.close()} -
现在我们设置完成,我们将继续开始测试我们的 CRUD 操作——即插入、删除和更新。让我们先创建一个插入测试:
@Testfun insertUserInformationReturnsTrue() = runBlocking {val userOne = UserInformationModel(id = 1,firstName = "Michelle",lastName = "Smith",dateOfBirth = 9121990,gender = "Male",city = "New york",profession = "Software Engineer")userInformationDao.insertUserInformation(userOne)val latch = CountDownLatch(1)val job = async(Dispatchers.IO) {userInformationDao.getUsersInformation().collect {assertThat(it).contains(userOne)latch.countDown()}}latch.await()job.cancelAndJoin()} -
最后,让我们添加
delete函数,这样就可以暂时完成我们的 Room 测试:@Testfun deleteUserInformation() = runBlocking {val userOne = UserInformationModel(id = 1,firstName = "Michelle",lastName = "Smith",dateOfBirth = 9121990,gender = "Male",city = "New york",profession = "Software Engineer")val userTwo = UserInformationModel(id = 2,firstName = "Mary",lastName = "Simba",dateOfBirth = 9121989,gender = "Female",city = "New york",profession = "Senior Android Engineer")userInformationDao.insertUserInformation(userOne)userInformationDao.insertUserInformation(userTwo)userInformationDao.deleteUserInformation(userTwo)val latch = CountDownLatch(1)val job = async(Dispatchers.IO) {userInformationDao.loadAllUserInformation().collect {assertThat(it).doesNotContain(userTwo)latch.countDown()}}latch.await()job.cancelAndJoin()} -
当你运行测试时,它们都应该通过,显示绿色的勾号:

图 6.7 – 我们的测试通过
它是如何工作的…
你可能已经注意到我们使用了 Truth,这是一个提供流畅和表达性 API 以在测试中编写断言的测试框架。它由 Google 开发,使用 Truth 的优点包括可读性、灵活性和清晰的错误消息。我们可以轻松地使用更接近自然语言的结构——例如,isEqualTo 和 shouldBe——这使得测试断言对我们开发者来说更加直观和易于阅读。
当使用框架时,你将获得一系列断言方法,允许你测试各种条件,包括相等性、顺序和包含。它还允许你定义自定义断言方法,这让你对测试行为有更多的控制。
@Before 注解确保我们的 databaseCreated() 函数在每个类之前执行。然后我们的函数使用 Room.inMemoryDatabaseBuilder 创建一个数据库,在 @After 调用中,我们将关闭数据库:
@After
fun closeDatabase() {
database.close()
}
正如你可能看到的,我们的测试在 AndroidTest 中,因为我们是在主线程中启动 Room 并在完成后关闭它。测试类仅测试 DAO 函数——即 Update、Insert、Delete 和 Query。
第七章:开始使用 WorkManager
在 Android 中,WorkManager 是 Google 作为 Android Jetpack 库的一部分引入的 API。它是一个强大且灵活的后台任务调度库,允许您在您的应用未运行或设备处于低功耗状态时执行可延迟的异步任务。
WorkManager 提供了一个统一的 API 来调度需要在特定时间或特定条件下执行的任务。它负责根据设备空闲状态、网络连接性和电池水平等因素高效地管理和运行任务。
此外,WorkManager 允许观察工作状态和链式创建。本章将探讨如何通过示例实现 WorkManager,并学习其工作原理及其用例。
在本章中,我们将介绍以下食谱:
-
理解 Jetpack
WorkManager库 -
理解
WorkManager状态 -
理解
WorkManager中的线程 -
理解链式和取消工作请求
-
实现从 Firebase
JobDispatcher迁移到新推荐WorkManager -
如何调试
WorkManager -
测试
Worker实现
技术要求
本章使用逐步示例,并不创建一个完整的项目。WorkManager 非常有用,但由于用例可能不同,利用示例来查看代码如何满足您的需求是学习编程中的一项优秀技能。
理解 Jetpack WorkManager 库
WorkManager 是最强大的 Jetpack 库之一,用于持久化工作。该 API 允许观察持久状态,并能够创建复杂的工作链。在构建 Android 应用程序时,可能需要您的数据持久化;如果您需要帮助刷新您的知识,可以参考第六章,使用 Room 数据库 和测试。
WorkManager 是任何后台进程最推荐的 API,并且已知可以处理如图所示的独特类型的工作:
-
立即执行:正如其名所示,这些任务是必须立即完成或尽快完成的
-
长时间运行:运行时间较长的任务
-
可延迟的:可以重新安排的任务,可以分配不同的开始时间,也可以定期运行
您可以使用 WorkManager 的更多示例用例包括,例如,如果您的公司想要创建自定义通知、发送分析事件、上传图片、定期同步本地数据与网络,等等。此外,WorkManager 是首选的 API,并且强烈推荐,因为它取代了 Android 中所有之前的后台调度 API。
还有其他用于调度工作的 API。它们已被弃用,在这本书中,我们不会介绍它们,但会提及它们,因为您可能在处理遗留代码时遇到它们;它们如下:
-
Firebase Job Dispatcher
-
任务调度器
-
GCM
网络管理器 -
WorkManager
准备工作
在这个菜谱中,我们将继续查看一个简单的示例,说明我们如何使用WorkManager创建自己的自定义通知。
如果您正在监听任何日志,您也可以使用相同的概念来发送应用程序的日志或报告分析。我们选择这个任务是因为向用户发送通知至关重要,而且大多数应用程序都会这样做,相比之下,上传图片的情况较少。此外,随着 Android 13 和新的 API,请求android.permission.POST_NOTIFICATIONS是强制性的。
如何操作…
对于这个菜谱,您不需要创建一个项目,因为可以在已经构建的项目中使用这些概念;相反,我们将查看示例,并带解释地走过这些示例:
-
我们需要确保我们有所需的依赖项:
implementation "androidx.work:work-runtime-ktx:version-number"
您可以通过查阅developer.android.com/jetpack/androidx/releases/work上的文档来获取最新的版本号。
-
让我们现在继续创建我们的通知通道。为此,谷歌提供了一个很好的指南,说明您如何在
developer.android.com/develop/ui/views/notifications/channels创建一个,所以复制以下代码:private fun createCustomNotificationChannel() {if (Build.VERSION.SDK_INT >=Build.VERSION_CODES.O) {val name = getString(R.string.notification_channel)val notificationDescription = getString(R.string.notification_description)val importance =NotificationManager.IMPORTANCE_DEFAULTval channel = NotificationChannel(CHANNEL_ID,name, importance).apply {description = notificationDescription}// Register the channel with the systemval notificationManager: NotificationManager =getSystemService(Context.NOTIFICATION_SERVICE) asNotificationManagernotificationManager.createNotificationChannel(channel)}}
此外,请注意,创建不同的通道以区分通知类型是可能的。正如 Android 13 所推荐的那样,这使用户在不需要时更容易打开和关闭它们。例如,用户可能想了解您的应用程序正在销售的最新品牌,而不是您向用户发送有关现有品牌的旧信息。
- 现在我们可以创建我们的
workManagerInstance。让我们考虑一个场景,我们需要每 20 或 30 分钟从我们的服务器获取数据,并检查是否有通知可用。在这种情况下,我们可能会遇到一个问题,即用户不再使用我们的应用程序,这意味着应用程序将被置于后台,或者进程甚至可能被终止。
因此,问题变成了当应用程序被终止时我们如何获取数据?这就是WorkManager发挥作用的时候。
-
我们现在可以创建
WorkManager的一个实例:val workManagerInstance = WorkManager.getInstance(application.applicationContext) -
现在,我们需要继续设置约束:
val ourConstraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).setRequiresBatteryNotLow(false).build() -
我们还需要设置要传递给工作者的数据;因此,我们将创建新的值数据,然后我们将字符串放入端点请求中:
val data = Data.Builder()data.putString(ENDPOINT_REQUEST, endPoint) -
现在,我们可以继续创建我们的
PeriodicWorkRequestBuilder<GetDataWorker>。在我们的工作中,我们将设置约束,设置我们的输入数据,并传递GetDataWorker()类型,我们将创建并构建它。此外,由于我们想要从服务器每 20 或 30 分钟获取数据,我们使用PeriodicWorkRequestBuilder<Type>()来达到这个目的:val job =PeriodicWorkRequestBuilder<GetDataWorker>(20,TimeUnit.MINUTES).setConstraints(ourConstraints).setInputData(data.build()).build() -
我们现在可以最终调用
workManagerInstance并排队我们的工作:workManagerInstance.enqueue(work) -
我们现在可以继续构建我们的
GetDataWorker()。在这个类中,我们将扩展Worker类,这将覆盖doWork()函数。然而,在我们的情况下,我们不会扩展Worker类,而是扩展CoroutineWorker(context, workerParameters),这有助于我们收集数据流。我们还将使用 Hilt,因此我们将调用@HiltWorker:@HiltWorkerclass GetDataWorker @AssistedInject constructor(@Assisted context: Context,@Assisted workerParameters: WorkerParameters,private val viewModel: NotificationViewModel) : CoroutineWorker(context, workerParameters) {override suspend fun doWork(): Result {val ourEndPoint = inputData.getString(NotificationConstants.ENDPOINT_REQUEST)if (endPoint != null) {getData(endPoint)}val dataToOutput = Data.Builder().putString(NotificationConstants.NOTIFICATION_DATA,"Data").build()return Result.success(dataToOutput)}
在我们的情况下,我们返回 success. 在我们的 getData() 函数中,我们传递端点,我们可以假设我们的数据有两个或三个关键属性:ID、标题和描述。
-
我们现在可以发送通知:
val notificationIntent = Intent(this, NotifyUser::class.java).apply {flags = Intent.FLAG_ACTIVITY_NEW_TASK orIntent.FLAG_ACTIVITY_CLEAR_TASK}notificationIntent.putExtra(NOTIFICATION_EXTRA, true)notificationIntent.putExtra(NOTIFICATION_ID, notificationId)val notifyPendingIntent = PendingIntent.getActivity(this, 0, notificationIntent,PendingIntent.FLAG_UPDATE_CURRENT)val builder = NotificationCompat.Builder(context, Channel_ID_DEFAULT).setSmallIcon(notificationImage).setContentTitle(notificationTitle).setContentText(notificationContent).setPriority(NotificationCompat.PRIORITY_HIGH).setContentIntent(notifyPendingIntent).setAutoCancel(true)with(NotificationManagerCompat.from(context)) {notify(notificationId, builder.build())} -
我们还需要创建一个
PendingIntent.getActivity(),这意味着当点击通知时,用户将启动一个活动。为了实现这一点,我们可以在点击通知时调用getStringExtra(NotificationConstants.NOTIFICATION_ID)并在我们的意图中添加额外的数据。这需要在我们的活动中发生:private fun verifyIntent(intent: Intent?) {intent?.let {if (it.hasExtra(NotificationConstants.NOTIFICATION_EXTRA)){it.getStringExtra(NotificationConstants.NOTIFICATION_ID)}}} -
在我们的
onResume()中,我们现在可以调用我们的verifyIntent()函数:override fun onResume() {super.onResume()verifyIntent(intent)}
就这样;我们使用了 WorkManager() 来自定义通知。
它是如何工作的…
当创建通知时,importance 参数有助于确定如何为任何给定通道中断用户,因此为什么应该在 NotificationChannel 构造函数中指定它。如果重要性高且设备运行 Android 5.0+,你将看到紧急通知,否则,它将只显示在状态栏上的图标。然而,重要的是要注意,所有通知,无论其重要性如何,都会出现在屏幕顶部的非中断性 UI 中。
WorkManager 这个词非常直接,因此从 API 中消除了歧义。当使用 WorkManager 时,Work 通过 Worker 类引用。此外,我们调用的 doWork() 函数在 WorkManager() 提供的后台线程中异步运行。
doWork() 函数返回一个 Result{},这个结果可以是 Success、Failure 或 Retry。当我们返回成功的 Result{} 时,工作将完成并成功结束。Failure,正如其名所示,意味着工作失败,然后我们调用 Retry,以重试工作。
在我们的 GetDataWorker() 中,我们传递 NotificationViewModel 并使用 Hilt 将其注入到我们的工作器中。有时你可能会遇到冲突。好消息是,对于可能发生的任何冲突,都有四种处理选项的支持。
这种情况是当你安排独特的工作时独有的;告诉 WorkManager 当出现冲突时必须采取什么行动是有意义的。你可以通过使用现有的工作策略 ExisitingWorkPolicy 来轻松解决这个问题,它有 REPLACE、KEEP APPEND 和 APPEND_OR_REPLACE 选项。
如同其名,Replace会替换现有工作,而Keep会保留现有工作并忽略新工作。当您调用Append时,这会将新工作添加到现有工作,而Append or Replace则简单地不依赖于先决工作状态。
重要提示
WorkManager是一个单例,因此它只能初始化一次,即在您的应用或库中。此外,如果您正在使用任何具有自定义依赖项的工作者,那么您必须在自定义初始化时向配置提供WorkerFactory()。
还有更多...
我们在这里只能涵盖一些WorkManager步骤。Google 有一些很好的示例代码实验室,您可以跟随并了解如何使用WorkManager。
想了解更多关于WorkManager的信息,您可以点击这个链接:developer.android.com/guide/background/persistent。
理解 WorkManager 状态
在之前的菜谱理解 Jetpack WorkManager 库中,我们探讨了如何使用WorkManager。在那个菜谱中,您可能已经注意到Work会经历一系列状态变化,并且doWork函数返回一个结果。
在这个菜谱中,我们将深入探讨状态。
如何做到这一点...
我们将继续探讨如何将在这个菜谱中学到的概念应用到您已经构建的项目中的示例:
-
您可能已经注意到我们之前提到过,我们有三种状态:
Success、Failure和Retry。然而,Work状态有不同的处理类型;我们可以有一个一次性工作状态、周期性工作状态或阻塞状态:ResultSUCCESS, FAILURE, RETRY
您可以通过点击结果并查看其编写方式来深入了解这个抽象类。
-
在第一个菜谱理解 Jetpack WorkManager 库中,我们探讨了设置
WorkManager的步骤。另一个很好的例子是下载文件。您可以重写fun doWork()并检查您的 URI 是否不等于 null,然后返回成功,否则失败:override suspend fun doWork(): Result {val file = inputData.getString(FileParameters.KEY_FILE_NAME) ?: ""if (file.isEmpty()){Result.failure()}val uri = getSavedFileUri(fileName = file,context = context)return if (uri != null){Result.success(workDataOf(FileParameters.KEY_FILE_URI touri.toString()))}else{Result.failure()}} -
在处理状态时,您可以轻松检查状态成功指定动作、未能执行动作以及当
WorkInfo.State等于RUNNING时调用running();请参阅以下代码片段:when (state) {WorkInfo.State.SUCCEEDED -> {success(//do something)}WorkInfo.State.FAILED -> {failed("Downloading failed!")}WorkInfo.State.RUNNING -> {running()}else -> {failed("Something went wrong")}} -
成功结果返回一个
ListenableWorker.Result实例,用于指示工作已成功完成。 -
对于提到的状态,您可以使用
enqueueUniqueWork(),它用于一次性工作,或者使用PeriodicWorkRequestBuilder,它用于周期性工作。在我们的例子中,我们使用了PeriodicWorkRequestBuilder<Type>:WorkManager.enqueueUniqueWork()WorkManager.enqueueUniquePeriodicWork()
它是如何工作的...
我们总是以Enqueued状态开始我们的请求,对于一次性工作状态,这意味着一旦满足约束条件,工作就会立即运行。之后,我们进入Running状态,如果遇到Success,则工作完成。
如果在任何情况下,我们最终处于 运行 状态,但没有达到 成功,那么这意味着我们失败了。然后,我们将回到 入队 状态,因为我们需要重试。图 7.1 和 图 7.2 更好地解释了一次性工作和周期性工作状态的状态。
最后,如果我们的入队工作被取消,那么我们就将其移动到已取消状态。

图 7.1 – 一次性工作请求的工作方式
虽然前面的图像显示了一次性工作状态,但下面的图表描述了周期性工作状态。

图 7.2 – 周期性工作状态的工作方式
理解 WorkManager 中的线程处理
你可以将 WorkManager 想象为在后台线程中运行的任何进程。当我们使用 Worker(),并且 WorkManager 调用 doWork() 函数时,这个动作在后台线程中执行。具体来说,后台线程来自 WorkManager 配置中指定的 Executor。
你也可以为你的应用程序需求创建自己的自定义执行器,但如果这不是必需的,你可以使用现有的一个。这个菜谱将探讨 Worker() 中的线程处理以及如何创建自定义执行器。
准备工作
在这个菜谱中,因为我们将会查看示例,你可以通过阅读和查看它是否适用于你来进行跟随。
如何做到这一点...
让我们学习如何在 WorkManager 中进行线程处理:
-
为了手动配置
WorkManager,你需要指定你的执行器。这可以通过调用WorkManager.initialize(),然后传递上下文和配置构建器来完成:WorkManager.initialize(context,Configuration.Builder().setExecutor(Executors.newFixedThreadPool(CONSTANT_THREAD_POOL_INT)).build()) -
在先前的菜谱中,理解 WorkManager 状态,我们讨论了一个下载文件的使用案例。这些文件可以是 PDF、JPG、PNG 或甚至是 MP4 的形式。我们将查看一个下载内容 20 次的示例;你可以指定你希望内容下载的次数:
class GetFiles(context: Context, params: WorkerParameters) : Worker(context, params) {override fun doWork(): ListenableWorker.Result {repeat(20) {try {downloadSynchronously("Your Link")} catch (e: IOException) {returnListenableWorker.Result.failure()}}return ListenableWorker.Result.success()}} -
目前,如果我们没有处理
Worker()被停止的情况,那么确保处理这种情况是一个好的做法,因为这是一个边缘情况。为了处理这种情况,我们需要重写Worker.onStopped()方法或在必要时调用Worker.isStopped以释放一些资源:override fun doWork(): ListenableWorker.Result {repeat(20) {if (isStopped) {break}try {downloadSynchronously("Your Link")} catch (e: IOException) {returnListenableWorker.Result.failure()}}return ListenableWorker.Result.success()} -
最后,当你停止工作线程时,结果将被完全忽略,直到你再次启动该过程。我们在先前的示例中使用了
CoroutineWorker,因为WorkManager提供了对协程的支持,因此我们在流中收集了数据。
重要提示
定制你的执行器需要手动初始化 WorkManager。
它是如何工作的...
在 WorkManager Jetpack 库中还有更多东西可以学习,并且公平地说,它不可能只通过几个菜谱就能全部涵盖。例如,在某些场景中,当提供自定义线程策略时,你应该使用 ListenableWorker。
ListenableWorker 是 Android Jetpack WorkManager 库中的一个类,它允许你以灵活和高效的方式执行后台工作。它是 Worker 类的子类,并添加了从其 doWork() 方法返回 ListenableFuture 的能力,这使得异步操作的处理更加容易。
通过使用 ListenableWorker,你可以创建一个返回 ListenableFuture 并注册当未来完成时将执行的回调的工人。这对于需要异步操作的任务,如网络请求或数据库操作,非常有用。
Worker、CoroutineWorker 和 RxWorker 都从这个特定的类派生。正如之前提到的,Worker 在后台线程中运行;CoroutineWorker 对于使用 Kotlin 的开发者来说非常推荐。RxWorker 在这里不会涉及,因为 Rx 本身是一个大主题,它针对的是在响应式编程中开发的用户。
参见
你的应用程序可能正在使用 Rx。在这种情况下,这里有关于 Rx 中线程工作方式以及如何使用 RxWorker 的详细信息。更多信息请参阅:developer.android.com/guide/background/persistent/threading/rxworker。
理解链式操作和取消工作请求
在 Android 开发中,确保你正确处理应用程序的生命周期至关重要。不用说,这也适用于所有后台工作,因为一个简单的错误可能导致应用程序耗尽用户的电池、内存泄漏,甚至导致应用程序崩溃或出现 应用程序无响应 (ANR) 错误。这可能导致 Play 商店中的糟糕评价,这最终会影响你的业务,并为开发者带来压力。你如何确保这个问题得到妥善处理?
这可以通过确保在使用 WorkManager 时出现的所有冲突都得到适当的处理,或者保证我们在上一个菜谱中提到的策略得到良好的编码来实现。在这个菜谱中,我们将探讨链式操作和取消工作请求,以及如何正确处理长时间运行的工作。
假设你的项目需要按照特定的顺序运行操作;WorkManager 允许你入队并创建一个指定多个依赖任务的链,在这里你可以设置操作发生的顺序。
准备工作
在这个菜谱中,我们将查看一个示例,说明你如何链式操作你的工作;由于这是基于概念的,我们将查看示例并解释它是如何工作的。
如何做到这一点…
要使用 WorkManager 进行链式操作,请按照以下步骤进行:
-
在我们的示例中,我们将假设我们有四个独特的并行运行的
Worker任务。这些任务的输出将被传递给一个上传Worker。然后,这些任务将被上传到我们的服务器,就像我们在 Understanding the Jetpack WorkManager library 菜单中看到的示例项目一样。 -
我们将使用
WorkManager()并传入我们的上下文;然后我们将调用beginWith并传递一个包含我们作业的列表:WorkManager.getInstance(context).beginWith(listOf(job1, job2, job3, job4)).then(ourCache).then(upload).enqueue() -
为了能够维护或保留我们作业的所有输出,我们需要使用
ArrayCreatingInputMerger::class:val ourCache: OneTimeWorkRequest = OneTimeWorkRequestBuilder<GetDataWorker>().setInputMerger(ArrayCreatingInputMerger::class).setConstraints(constraints).build()
就这些了。肯定还有更多东西要学,但这已经达到了我们的目的。
它是如何工作的…
为了能够创建工作链,我们使用WorkManager.beginWith(OneTimeWorkRequest)或者使用WorkManager.beginWith并传递一个包含你指定的单次工作请求的列表。
WorkManager.beginWith<List<OneTimeWorkRequest>>操作返回一个WorkContinuation实例。
我们使用WorkContinuation.enqueue()函数将我们的WorkContinuation链入队列。ArrayCreatingInputMerger确保我们将每个键与一个数组配对。此外,ArrayCreatingInputMerger是 Android Jetpack WorkManager库中的一个类,它允许你将多个ListenableWorker实例的输入数据合并成一个数组。
此外,如果我们的键是unique,我们将得到一个只有一个元素的数组。图 7.3显示了输出:

图 7.3 – 数组创建输入合并器的工作原理
如果我们有任何冲突的键,那么我们的值将按图 7.4中的方式在我们的数组中分组。

图 7.4 – 键冲突和结果
通常情况下,工作链是顺序执行的。这取决于工作是否成功完成。你可能想知道当工作请求被添加到多个工作请求的队列中时会发生什么;就像一个常规队列一样,所有后续的工作都会暂时阻塞,直到第一个工作请求完成。把它想象成先来先服务。
参见
你可能想知道如何支持长时间运行的工作者;你可以在developer.android.com/guide/background/persistent/how-to/long-running了解更多信息。
实现从 Firebase JobDispatcher 到新推荐的工作管理器的迁移
在理解 Jetpack WorkManager 库的食谱中,我们讨论了用于安排和执行可延迟后台工作的其他库。Firebase JobDispatcher是其中之一。如果你使用过 Firebase JobDispatcher,你可能知道它使用JobService()子类作为其入口点。在这个食谱中,我们将探讨如何迁移到新推荐的WorkManager。
准备工作
我们将探讨如何从JobService迁移到WorkerManager。这可能适用于你的项目,也可能不适用。然而,由于WorkManager被高度推荐,我们都有一些遗留代码,所以这一点是必须涵盖的。然而,如果你的项目是新的,你可以跳过这个食谱。
如何做到这一点…
要将 Firebase JobDispatcher迁移到WorkManager,请按照以下步骤操作:
-
首先,你需要添加所需的依赖项;为此,你可以参考理解 Jetpack WorkManager 库配方。
-
如果你已经在你的项目中有了 Firebase
JobDispatcher,你可能会有以下代码片段类似的代码:class YourProjectJobService : JobService() {override fun onStartJob(job: JobParameters):Boolean {// perform some jobreturn false}override fun onStopJob(job: JobParameters):Boolean {return false}} -
如果你的应用程序使用
JobServices(),那么它将映射到ListenableWorker。然而,如果你的应用程序正在使用SimpleJobService,那么在这种情况下,你应该使用Worker:class YourWorker(context: Context, params: WorkerParameters) :ListenableWorker(context, params) {override fun startWork():ListenableFuture<ListenableWorker.Result> {TODO("Not yet implemented")}override fun onStopped() {TODO("Not yet implemented")}} -
如果你的项目使用
Job.Builder.setRecurring(true),在这种情况下,你应该将其更改为WorkManager提供的PeriodicWorkRequest类。你也可以指定你的标签、服务(如果作业是重复的)、触发窗口等:val job = dispatcher.newJobBuilder()....build() -
此外,为了能够实现我们的目标,我们需要输入数据,这些数据将作为我们
Worker的输入数据,然后使用我们的输入数据和特定的约束来构建我们的WorkRequest。你可以参考理解 Jetpack WorkManager 库配方,并最终将工作请求入队。
最后,你可以创建一次性的或周期性的工作请求,并确保你处理任何边缘情况,例如取消工作。
它是如何工作的…
在 Firebase JobDispatcher中,JobService.onStartJob(),这是一个在JobSccheduler中的函数,以及startWork()在主线程上被调用。相比之下,在WorkManager中,ListenableWorker是基本的工作单元。在我们的例子中,YourWorker实现了ListenableWorker并返回一个ListenableFuture实例,这有助于在信号工作完成时使用。然而,你可以根据你的应用程序需求实现你的单线程策略。
在 Firebase 中,FirebaseJobBuilder使用Job.Builder作为作业元数据。相比之下,WorkManager使用WorkRequest来执行类似的角色。WorkManager通常通过利用ContentProvider来初始化自己。
如何调试 WorkManager
任何需要在后台工作并有时进行网络调用的操作都需要适当的异常处理。这是因为你不想让你的用户面临问题,并且缺乏异常处理可能会让你的团队或作为开发者的你感到困扰。
因此,了解如何调试WorkManager将非常有用,因为这是那些可能持续数日的问题之一。在这个配方中,我们将探讨如何调试WorkManager。
准备工作
要遵循这个配方,你必须完成本章中所有之前的配方。
如何操作…
你可能会遇到一个问题,即如果WorkManager不同步,它将不再运行。遵循这个配方来调试WorkManager:
-
为了设置调试,我们首先需要在我们的
AndroidManifest.xml文件中创建一个自定义初始化,即通过禁用WorkManager初始化器:<provider...tools:node="remove"/> -
然后,我们继续在我们的应用程序类中设置一个最低日志级别以进行调试:
class App() : Application(), Configuration.Provider {override fun getWorkManagerConfiguration() =Configuration.Builder().setMinimumLoggingLevel(android.util.Log.DEBUG).build()}
一旦完成这些,我们就能轻松地看到带有前缀WM-的日志,这将使我们的调试工作更加直接,哇,我们可以更近一步地解决我们的问题。
它是如何工作的...
有时候,仅仅利用详尽的WorkManager日志来捕获任何异常可能很有帮助。此外,你可以启用日志记录并使用你自己的自定义初始化。这就是我们在食谱的第一步所做的事情。此外,当我们声明自己的自定义WorkManager配置时,我们的WorkManager将在我们调用WorkManager.getInstance(context)时初始化,而不是在应用启动时自然初始化。
测试工作实现
测试你的Worker实现至关重要,因为它有助于确保你的代码得到妥善处理,并且你的团队遵循编写优秀代码的正确指南。这将是一个集成测试,这意味着我们将把我们的代码添加到androidTest文件夹中。这个食谱将探讨如何为你的工作添加测试。
准备工作
要跟随这个食谱,你需要完成本章的所有先前食谱。
如何操作...
按照以下步骤开始测试WorkManager。我们将在本食谱中查看示例:
-
首先,你需要在你的
build.gradle文件中添加测试依赖:androidTestImplementation("androidx.work:work-testing:$work_version")
在未来 API 中某些内容发生变化的情况下,有一个稳定的版本你可以使用,你可以在通过以下链接的文档中找到它:developer.android.com/jetpack/androidx/releases/work。
-
我们需要设置由 Google 提供的
@Before函数:@RunWith(AndroidJUnit4::class)class BasicInstrumentationTest {@Beforefun setup() {val context =InstrumentationRegistry.getTargetContext()val config = Configuration.Builder().setMinimumLoggingLevel(Log.DEBUG).setExecutor(SynchronousExecutor()).build()// Initialize WorkManager for instrumentationtests.WorkManagerTestInitHelper.initializeTestWorkManager(context, config)}} -
现在我们已经设置了
WorkManager,我们可以继续构建我们的测试:class GetDataWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {override fun doWork(): Result {return when(endpoint) {0 -> Result.failure()else -> Result.success(dataOutput)}}} -
你可以通过以下示例轻松测试和验证状态:
@Test@Throws(Exception::class)fun testGetDataWorkerHasNoData() {...val workInfo =workManager.getWorkInfoById(request.id).get()assertThat(workInfo.state,`is`(WorkInfo.State.FAILED))}
你可以添加更多测试,例如验证状态成功时或检查初始延迟;你还可以更进一步,测试约束条件等。
它是如何工作的...
我们使用的库为测试Worker提供了出色的支持。例如,我们通过库获得了WorkManagerTestInitHelper。此外,我们还有SynchronousExecutor,它通过确保同步编写测试变得容易,使我们的工作作为开发者变得更加容易。同时,处理多个线程、闩锁和锁的问题也得到了解决。
在我们的testGetDataWorkerHasNoData测试中,我们创建一个请求,然后将其入队并等待结果。我们稍后获取信息,然后断言当状态失败时,它应该失败。你也可以测试它成功的情况。
还有更多...
要测试不同变体的工作实现,你可以通过以下链接进行:developer.android.com/guide/background/testing/persistent/worker-impl。
第八章:开始使用 Paging
在 Android 开发中,Paging 库帮助开发者从本地存储或通过网络从更大的数据集中加载数据页面。如果您的应用程序需要加载大量数据供人们阅读,这可能会很常见。例如,一个好的例子是 Twitter;您可能会注意到由于人们每天发送的许多推文,数据会刷新。
因此,在现代 Android 开发(MAD)中,Android 开发者可能希望在他们的应用程序中实现 Paging 库来帮助他们处理加载数据的此类情况。在本章中,您将学习如何在项目中利用 Paging 库。
在本章中,我们将介绍以下菜谱:
-
实现 Jetpack Paging 库
-
管理当前和加载状态
-
在 Jetpack Compose 中实现自定义分页
-
加载和显示分页数据
-
理解如何转换数据流
-
迁移到 Paging 3 并了解其优势
-
为您的 Paging Source 编写测试
技术要求
本章的完整源代码可以在github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_eight找到。您还需要为newsapi.org/获取一个 API 密钥。"NewsApi"是一个全球性的新闻 API。
实现 Jetpack Paging 库
Paging 库为开发者提供了令人难以置信的功能。如果您的代码库已经建立且规模庞大,开发者们已经创建了其他自定义方法来帮助他们高效地加载数据。Paging 的一个显著优势是其页面数据的内存缓存,这确保了您的应用程序在处理已分页数据时高效地使用系统资源。
此外,它还支持 Kotlin 协程流和 LiveData,并具有内置的去重功能,这确保了您的应用程序在处理已分页数据时高效地使用系统资源,这有助于节省电池。最后,Paging 库提供了错误处理支持,包括在刷新和重试数据时。
准备工作
在这个菜谱中,我们需要创建一个新的项目;如果您需要参考创建新项目的先前菜谱,您可以访问第一章,现代 Android 开发技能入门。
如何操作…
让我们继续创建一个新的空 Compose 项目,并将其命名为PagingJetpackExample。在我们的示例项目中,我们将使用免费的NewsApi向用户展示新闻。要开始,请查看这个链接newsapi.org/docs/get-started。同时,确保您为项目获取 API,因为这是本菜谱的要求。按照以下步骤开始:
-
让我们继续添加以下必需的依赖项。此外,由于我们将进行网络调用,我们需要添加一个库来处理这个问题。关于正确的版本控制,请查看技术要求部分以获取代码和正确的版本。我们将提供
2.x.x,这样你可以在升级或已经在你的项目中使用Retrofit和 Coil 时检查兼容性。Coil 是一个快速、轻量级且灵活的图像加载库。它旨在简化从各种来源(如网络、本地存储或内容提供者)加载图像并在 ImageView 或其他图像相关 UI 组件中显示图像的过程://Retrofitimplementation 'com.squareup.retrofit2:retrofit:2.x.x'implementation 'com.squareup.retrofit2:converter-gson:2.x.x'//Coil you can also use Glide in this caseimplementation 'com.google.accompanist:accompanist-coil:0.x.x'//Paging 3.0implementation 'Androidx.Paging:Paging-compose:1.x.x' -
在项目同步并准备就绪后,继续删除项目附带来的
Greeting可组合函数。你应该只有你的主题,你的界面应该是空的。此外,对于这个食谱的用户界面(UI)部分,你可以从技术要求部分获取整个代码。 -
此外,当使用 API 时,开发者往往会忘记在清单中添加
Android.permission.INTERNET权限,所以现在让我们先做这件事,以免忘记:<uses-permission Android:name="Android.permission.INTERNET"/> -
现在,创建一个名为
data的包;我们将把我们的模型和服务文件添加到这个包中。此外,确保你阅读了新闻 API 的文档部分,以了解 API 的工作原理:data class NewsArticle(val author: String,val content: String,val title: String ...) -
现在我们来创建我们的
NewsArticleResponse数据类,我们将在NewsApiService接口中实现它。我们的 API 调用类型是@GET(),这意味着“获取”。关于GET的更详细解释可以在如何工作部分找到。我们的调用旨在返回一个包含NewsArticleResponse数据类形式的调用对象:data class NewsArticleResponse(val articles: List<NewsArticle>,val status: String,val totalResults: Int)interface NewsApiService{@GET("everything?q=apple&sortBy=popularity&apiKey= ${YOURAPIKEY}&pageSize=20")suspend fun getNews(@Query("page") page: Int): NewsArticleResponse} -
创建另一个名为
NewsArticlePagingSource()的类;我们的类将使用NewsApiService作为输入参数。当通过 API 公开任何大型数据集时,我们需要提供一个机制来分页资源列表。为了实现它,我们需要传递分页键的类型和要加载数据的类型,在我们的例子中是NewsArticle:class NewsArticlePagingSource(private val newsApiService: NewsApiService,): PagingSource<Int, NewsArticle>() {. . .} -
最后,让我们继续并重写由
PagingSource和load()挂起函数提供的getRefreshKey()。我们将在加载数据和显示分页数据食谱中详细讨论load()和PagingSource挂起函数:class NewsArticlePagingSource(private val newsApiService: NewsApiService,) : PagingSource<Int, NewsArticle>() {override fun getRefreshKey(state: PagingState<Int,NewsArticle>): Int? {return state.anchorPosition?.let {anchorPosition ->state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)}}override suspend fun load(params:LoadParams<Int>): LoadResult<Int, NewsArticle> {return try {val page = params.key ?: 1val response = newsApiService.getNews(page = page)LoadResult.Page(data = response.articles,prevKey = if (page == 1) null elsepage.minus(1),nextKey = if(response.articles.isEmpty()) nullelse page.plus(1),)} catch (e: Exception) {LoadResult.Error(e)}}} -
现在,让我们创建我们的仓库;仓库是一个将数据源(如网络服务或 Room 数据库)与应用程序的其他部分隔离的类。由于我们没有 Room 数据库,我们将使用网络服务数据:
class NewsArticleRepository @Inject constructor(private val newsApiService: NewsApiService) {fun getNewsArticle() = Pager(config = PagingConfig(pageSize = 20,),PagingSourceFactory = {NewsArticlePagingSource(newsApiService)}).flow} -
在我们的项目中,我们将使用 Hilt 进行依赖注入并构建所需模块。对于本节,你可以参考第三章中的步骤,在 Jetpack Compose 中处理 UI 状态和使用 Hilt,了解如何将 Hilt 添加到你的项目中以及如何创建所需的模块。此外,如果你遇到困难,可以通过 技术要求 部分访问整个代码:
@Module@InstallIn(SingletonComponent::class)class RetrofitModule{@Singleton@Providesfun provideRetrofitInstance(): NewsApiService =Retrofit.Builder().baseUrl(BASE_URL).addConverterFactory(GsonConverterFactory.create()).build().create(NewsApiService::class.java)} -
最后,在我们实现了我们的
PagingSource之后,我们可以继续创建一个Pager,这通常指的是我们的 ViewModel 中的ViewPager,并指定我们的页面大小。这可以根据项目的需求或偏好进行选择。此外,当使用 Paging 3.0 时,我们不需要单独处理或转换任何数据以应对屏幕配置更改,因为这会自动为我们完成。
我们可以使用 cachedIn(viewModelScope) 简单地缓存我们的 API 结果。此外,为了通知 PagingData 的任何更改,你可以使用 CombinedLoadState 回调来处理加载状态:
@HiltViewModel
class NewsViewModel @Inject constructor(
private val repository: NewsArticleRepository,
) : ViewModel() {
fun getNewsArticle():
Flow<PagingData<NewsArticle>> =
repository.getNewsArticle().cachedIn(
viewModelScope)
}
- 最后,当你运行应用程序时,你应该看到一个类似于 图 8**.1 的显示,显示作者的名字、图像和内容。我们还将内容包装起来,因为此示例只是为了学习目的;你可以将其作为一个挑战来改进 UI 并显示更多细节:

图 8.1 – 使用 Paging 3 库加载的新闻文章
它是如何工作的...
在 Android 开发中,Retrofit 请求通常指的是使用 Retrofit 库进行的网络请求,Retrofit 是一个流行的 Android HTTP 客户端库。
这里是一些常见的 Retrofit 请求类型及其用法:
-
GET:此请求用于从服务器检索数据。它是 Android 应用中最常见的请求类型,通常用于检索数据以填充 UI 元素,如列表或网格。 -
POST:此请求用于向服务器提交数据。它通常用于在服务器上创建新的资源,如新的用户账户或新的帖子。 -
PUT:此请求用于更新服务器上的现有资源。它通常用于更新用户的账户信息或修改现有的帖子。 -
DELETE:此请求用于在服务器上删除资源。它通常用于删除用户账户或删除帖子。 -
PATCH:此请求部分更新服务器上的现有资源。当只需要更新资源的一小部分而不是使用PUT请求更新整个资源时,通常使用它。
在使用 Retrofit 请求时,开发者通常会定义一个接口来描述端点和请求参数。Retrofit 然后为该接口生成客户端实现,可用于进行实际的网络调用。
通过使用 Retrofit,开发者可以抽象出网络请求的许多底层细节,这使得从 Android 应用中与服务器通信变得更加容易和高效。有关 Retrofit 的示例,请查看以下链接 square.github.io/retrofit/。
分页库确保它遵循推荐的 Android 架构模式。此外,其组件包括Repository、ViewModel和UI层。以下图表显示了分页组件在每个层中的操作方式以及它们如何协同工作以加载和显示你的分页数据:

图 8.2 – 分页库架构
Paging Source组件是Repository层中的主要组件,如*图 8**.2 所示。该对象通常为每份数据声明一个源,并处理如何从该源重试数据。如果你注意到,这正是我们在示例中做的:
class NewsArticleRepository @Inject constructor(
private val newsApiService: NewsApiService
) { . . .
我们创建了一个包含 API 基本 URL 的 Retrofit builder()对象,该 URL 我们在Constant类中定义,const val BASE_URL = "https://newsapi.org/v2/",我们使用Gson转换器来转换我们的 JSON API 响应。然后我们声明了apiService变量,我们将使用它将 Retrofit builder()对象与我们的接口连接并完成我们的 Retrofit 模块。
重要提示
由于其改进以及一些功能难以使用 Paging 2 处理,建议所有使用分页库的人迁移到 Paging 3。
管理当前和加载状态
分页库通过其加载状态对象向用户提供加载状态信息,该对象可以根据其当前的加载状态呈现不同的形式。例如,如果你有一个正在进行的加载操作,那么状态将是LoadState.Loading。
如果你有一个错误状态,那么状态将是LoadState.Error;最后,可能没有正在进行的加载操作,这种状态被称为LoadState.NotLoading。在本食谱中,我们将探讨不同的状态并了解它们;这里展示的示例也可以在以下链接中找到:developer.android.com/topic/libraries/architecture/paging/load-state。在这个例子中,我们假设你的项目使用的是遗留代码,该代码利用 XML 作为视图系统。
准备工作
要跟随本食谱,你需要完成上一个食谱中的代码。你也可以跳过这一步,如果它在你项目中不是必需的。
如何做到这一点...
在本食谱中,我们不会创建一个新的项目,而是逐步查看我们如何使用监听器访问加载状态或使用适配器呈现加载状态。按照以下步骤操作以开始:
-
当您想要访问状态时,将此信息传递给您的 UI。您可以使用
PagingDataAdapter提供的addLoadStateListener函数的loadedStateFlow流轻松地做到这一点:lifecycleScope.launch {thePagingAdapter.loadStateFlow.collectLatest {loadStates ->progressBar.isVisible = loadStates.refresh isLoadState.Loadingretry.isVisible = loadState.refresh !isLoadState.LoadingerrorMessage.isVisible = loadState.refresh isLoadState.Error}} -
对于我们的示例,我们不会查看
addLoadStateListener函数,因为这与适配器类一起使用,并且由于新的 Jetpack Compose,这几乎不再执行,因为有更多的推动力去使用基于 Jetpack Compose 的 UI 应用程序。 -
根据您应用程序的具体事件,过滤加载状态流可能是有意义的。这确保了您的应用程序 UI 在正确的时间更新,以避免问题。因此,使用协程,我们等待刷新加载状态更新:
lifecycleScope.launchWhenCreated{yourAdapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.filter { it.refresh is LoadState.NotLoading }.collect { binding.list.scrollToPosition(0) }}
它是如何工作的…
当从 loadStateFlow 和 addLoadStateListener() 获取更新时,这些更新保证是同步的,并且根据需要更新 UI。这仅仅意味着在 Android 的 Paging 3 库中,LoadState.Error 是一个表示在从 PagingSource 加载数据时发生错误的状态。
在 Android 的 Paging 3 库中,LoadState.NotLoading 是一个表示 PagingDataAdapter 当前没有加载数据,并且所有可用数据都已加载的状态。
当 PagingDataAdapter 首次创建时,它处于 LoadState.NotLoading 状态。这意味着尚未加载数据,适配器正在等待第一次加载发生。
在第一次加载之后,适配器可能会根据数据加载过程的当前状态转换到不同的加载状态。然而,一旦所有可用数据都已加载,适配器将转换回 LoadState.NotLoading 状态。
LoadState.NotLoading 可以用来通知 UI 数据加载过程已完成,并且除非用户启动刷新或其他操作,否则不会加载更多数据。
要处理此状态,您可以在 PagingDataAdapter 中注册对 LoadState 变化的监听器,并相应地更新 UI。例如,您可以向用户显示一条消息,表明所有数据已加载,或禁用任何“加载更多”按钮或手势。
更多内容...
您可以通过以下链接了解状态以及如何更好地处理分页:developer.android.com/topic/libraries/architecture/paging/load-state。
在 Jetpack Compose 中实现自定义分页
Paging 库为开发者提供了令人难以置信的功能,但有时您会遇到挑战,不得不创建自定义分页。在章节的开头,我们讨论了复杂代码库中或创建分页的情况。
在这个示例中,我们将探讨如何通过一个简单的列表示例来实现这一点,以及您如何使用此示例在您的应用程序中创建自定义分页。
准备工作
在这个示例中,我们需要创建一个新的项目,并将其命名为 CustomPagingExample。
如何操作...
在我们的示例项目中,我们将尝试创建一个学生配置文件卡片,并使用自定义分页在 Jetpack Compose 中加载配置文件。
-
对于这个菜谱,让我们继续添加
lifecycle-ViewModel依赖项,因为我们将会用到它:implementation "Androidx.lifecycle:lifecycle-viewmodel-compose:2.x.x" -
让我们继续创建一个新的包并称其为
data。在我们的data包中,我们将添加我们将在卡片上显示的项目。目前,我们只将显示学生的姓名、学校和专业:data class StudentProfile(val name: String,val school: String,val major: String) -
现在我们有了我们的
data类,我们将继续构建我们的仓库,由于在我们的示例中我们没有使用 API,我们将使用我们的远程数据源,我们可以尝试加载,比如说,50 到 100 个配置文件。然后,在data中添加另一个类并称其为StudentRepository:class StudentRepository {private val ourDataSource = (1..100).map {StudentProfile(name = "Student $it",school = "MIT $it",major = "Computer Science $it")}suspend fun getStudents(page: Int, pageSize: Int):Result<List<StudentProfile>> {delay(timeMillis = 2000L) //the delay added isjust to mimic a network connection.val start = page * pageSizereturn if (start + pageSize <=ourDataSource.size) {Result.success(ourDataSource.slice(start until start+ pageSize))} else Result.success(emptyList())}} -
既然我们已经创建了我们的仓库,让我们继续创建我们的自定义分页。我们将通过创建一个新的接口并称其为
StudentPaginator来完成此操作:interface StudentPaginator<Key, Student> {suspend fun loadNextStudent()fun reset()} -
由于
StudentPaginator是一个接口,我们必须创建一个类来实现我们刚刚创建的两个函数。现在,让我们继续创建StudentPaginatorImpl并实现我们的接口:class StudentPaginatorImpl<Key, Student>() : StudentPaginator<Key, Student> {override suspend fun loadNextStudent() {TODO("Not yet implemented")}override fun reset() {TODO("Not yet implemented")}} -
接下来,你需要处理
StudentPaginator实现类中需要处理的内容。例如,在我们的构造函数中,我们需要创建一个键来监听load、request、error、success和next key,然后在reset()函数中能够重置我们的分页。你可以在 技术要求 部分查看完整的代码。你可能也会注意到它看起来与本章第一个菜谱中的分页源很相似:class StudentPaginatorImpl<Key, Student>(private val key: Key,private inline val loadUpdated: (Boolean) -> Unit,private inline val request: suspend (nextKey: Key)->. . .) : StudentPaginator<Key, Student> {private var currentKey = keyprivate var stateRequesting = falseoverride suspend fun loadNextStudent() {if (stateRequesting) {return}stateRequesting = true. . .}override fun reset() {currentKey = key} -
让我们继续创建一个新的包并称其为
uistate。在uistate中,我们将创建一个新的数据类并称其为UIState以帮助我们处理 UI 状态:data class UIState(val page: Int = 0,val loading: Boolean = false,val studentProfile: List<StudentProfile> =emptyList(),val error: String? = null,val end: Boolean = false,) -
现在让我们继续并最终确定我们的 Kotlin 中的
ViewModelinit块,这是我们用于初始化的块。我们还创建了val ourPaginator,并将其声明给StudentPaginatorImpl类,并使用我们用于 UI 的数据来处理输入:class StudentViewModel() : ViewModel() {var state by mutableStateOf(UIState())private val studentRepository =StudentRepository()init {loadStudentProfile()}private val ourPaginator = StudentPaginatorImpl(key = state.page,loadUpdated = { state = state.copy(loading =it) },request = { studentRepository.getStudents(it,24) },nextKey = { state.page + 1 },error = { state = state.copy(error =it?.localizedMessage) },success = { student, newKey ->state = state.copy(studentProfile = state.studentProfile+ student,page = newKey,end = student.isEmpty())})fun loadStudentProfile(){viewModelScope.launch {ourPaginator.loadNextStudent()}}} -
最后,在我们的
MainActivity类中,我们现在在我们的卡片上加载学生配置文件并在屏幕上显示它,如图 图 8**.3 所示。一个额外的巨大练习是尝试在示例项目中使用依赖注入来提高你的 Android 技能。你可以利用 第三章,在 Jetpack Compose 中处理 UI 状态和使用 Hilt,来添加依赖注入,并尝试为ViewModel类编写测试:

图 8.3 – 懒加载列上加载的数据
在 图 8**.4 中,当你向下滚动到 学生 4 等等时,你会看到一个进度加载符号,这在处理大量数据时可以非常出色:

图 8.4 – 加载数据
它是如何工作的…
一旦你得到一个列表,可能会遇到问题,并且通知单个项目可能很困难。然而,你可以轻松地实现分页;在我们的项目中,我们模拟了一个远程数据源,但请记住,你可以使用任何 API 来演示这个例子。
我们的主要关注点是StudentPaginatorImpl类——你会注意到我们传递了一个键、一个loadUpdated值和一个请求,这是一个返回我们的Student类型结果的挂起函数;我们还传递了nextkey,它告诉我们我们的位置。然后,在出现错误的情况下,我们有可抛出的错误和一个suspend值success,它提供了success结果:
class StudentPaginatorImpl<Key, Student>(
private val key: Key,
private inline val loadUpdated: (Boolean) -> Unit,
private inline val request: suspend (nextKey: Key) ->
Result<List<Student>>,
private inline val nextKey: suspend (List<Student>) ->
Key,
private inline val error: suspend (Throwable?) -> Unit,
private inline val success: suspend (items:
List<Student>, newKey: Key) -> Unit
) : StudentPaginator<Key, Student> {
因此,当我们从loadNextStudent()接口重写我们的函数时,我们首先检查当前状态请求,并将初始值作为false返回,但在状态检查之后更新它。我们还确保通过将currentKey设置为nextKey来重置键。
currentKey = nextKey(studentProfiles)
success(studentProfiles, currentKey)
loadUpdated(false)
如果你需要自定义LazyColumn中的项目,这将变得很容易,确保你拥有优秀的列表。
loadStudentProfile()函数包含一个viewModelScope.launch {...}。在我们的应用程序中,为每个 ViewModel 定义了一个 ViewModel 作用域。此外,在这个作用域中启动的任何协程,如果 ViewModel 被清除,将会自动取消。
你可能想知道 ViewModel 是什么。为了帮助你刷新知识,你可以查看第三章,在 Jetpack Compose 中处理 UI 状态以及使用 Hilt。
加载和显示分页数据
在加载和显示分页数据时,有一些基本步骤需要考虑。此外,Paging 库为加载和显示大型、分页数据集提供了巨大的优势。你必须记住的一些步骤是确保首先定义数据源,如果需要,设置 Paging Source 流,等等。
在这个菜谱中,我们将探讨如何加载和显示分页数据。
如何做…
你需要完成实现 Jetpack Paging 库菜谱,才能跟随本菜谱的解释:
-
你可能已经注意到在我们的第一个菜谱中,我们重写了
load()方法,这是我们用来指示如何从对应的数据源检索分页数据的方法:override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NewsArticle> {return try {val page = params.key ?: 1val response = newsApiService.getNews(page =page)LoadResult.Page(data = response.articles,prevKey = if (page == 1) null elsepage.minus(1),nextKey = if (response.articles.isEmpty())null else page.plus(1),)} catch (e: Exception) {LoadResult.Error(e)}} -
如果我们在重写
getRefreshKey()时val page = params.key ?: 1未定义,我们则从页码1开始刷新;我们尝试从我们的上一个键或下一个键中找到锚定位置最近的页码。我们还需要确保我们处理可能存在的null值的情况:override fun getRefreshKey(state: PagingState<Int, NewsArticle>): Int? {return state.anchorPosition?.let { anchorPosition->state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)}}
它是如何工作的…
当使用 Paging 库时,你可以使用anchorPosition参数指定屏幕上要显示的第一个项目的位置。此外,anchorPosition是一个可选参数,你可以将其传递给用于显示分页数据的PagingItems可组合函数。anchorPosition参数用于指定在可组合函数首次渲染时屏幕上要显示的第一个项目的位置。
LoadParams 对象携带有关要执行加载数据操作的信息。此外,它知道要加载的键和要在 UI 上显示的项目数量。此外,为了更好地理解load()函数如何接收每个特定加载的键并更新它,请查看以下图表:

图 8.5 – load()如何使用和更新键
理解如何转换数据流
当编写任何处理分页的代码时,你需要了解你如何在将数据加载到用户时转换数据流。例如,你可能需要在将数据提供给 UI 之前过滤项目列表或甚至将项目转换为不同的类型。
因此,确保你直接将转换应用于流数据,可以使你的仓库和 UI 逻辑保持清晰分离。在本食谱中,我们将尝试了解我们如何转换数据流。
准备工作
要跟上进度,你必须熟悉分页库的主要用法;因此,请确保你已经阅读了本章之前的食谱。
如何做…
在这个食谱中,我们将执行以下步骤:
-
探索我们如何应用基本转换。
-
转换和过滤数据。
-
在 UI 中处理分隔符并转换 UI 模型。
如果你已经在你的应用程序中使用分页,这个食谱对你有帮助。
-
首先,我们需要将转换放在一个
map{PagingData ->}内部。在 Kotlin 中,一个映射将给定的 lambda 函数应用于每个元素,并返回一个 lambda 结果的列表:yourPager.flow.map { PagingData ->// here is where the transformations areapplied to the items in the paged data.} -
其次,当我们想要转换数据或过滤时,一旦我们访问到我们的
PagingData对象,我们就可以在分页列表中的每个单独的项目上再次使用map()。一个典型的用例是当你想要将数据库或网络层对象映射到可能在 UI 层特别使用的对象上:yourPager.flow.map { PagingData ->PagingData.map { sports -> SportsModel(sports)}} -
我们需要将过滤操作放在
map内部,因为过滤应用于PagingData对象。然后一旦从我们的PagingData中过滤出数据,新的实例将被分页到 UI 层并显示:yourPager.flow.map { PagingData ->PagingData.filter { sports ->!sports.displayInUi }} -
最后,在处理 UI 中的分隔符或转换 UI 模型时,最重要的步骤是确保你做以下事情:
-
将 UI 模型转换为适应你的分隔符项。
-
在展示和加载数据之间动态转换数据并添加分隔符。
-
更新 UI 以更好地处理分隔符项。
-
它是如何工作的…
PagingData 被封装在一个响应式流中;这意味着在加载数据并显示给用户之前,你可以逐步将转换应用于数据。在复杂的应用程序中,转换数据流可能至关重要,提前处理这种情况可能会帮助确保你的应用程序更好地扩展,并有助于最小化数据增长复杂性。
相关内容
公平地说,这个菜谱不能涵盖您需要了解的所有关于转换数据流的信息。话虽如此,如果您遇到问题并想了解更多,您始终可以参考以下链接来了解如何处理 UI 中的分隔符以及更多内容:developer.android.com/topic/libraries/architecture/paging/v3-transform。
迁移到分页 3 并了解其优势
您可能正在使用旧的分页版本,在这种情况下,可能是分页 2 或 1,并且您可能需要迁移以利用分页 3 提供的好处。分页 3 提供了增强的功能,并确保它解决了人们在使用分页 2 时遇到的最常见挑战。
在这个菜谱中,我们将探讨如何迁移到最新推荐的分页库。
准备工作
如果您的应用程序已经使用分页 3,那么您可以跳过这个菜谱;这个逐步迁移指南是为目前使用分页库旧版本的用户准备的。
如何操作…
从旧版本的分页库迁移可能看起来很复杂,因为每个应用程序都是独特的,复杂性可能不同。然而,在我们的例子中,我们将讨论一种低级别的迁移,因为我们的示例应用程序不需要任何迁移。
要执行从旧分页库的迁移,请按照以下步骤操作:
-
第一步是替换刷新键,这是因为我们需要定义如何从加载数据的中间部分恢复刷新。我们将通过首先实现
getRefreshKey()来完成这项工作,它使用PagingState.anchorPosition作为最近索引来映射正确的初始键:override fun getRefreshKey(PagingState: PagingState): String? {return PagingState.anchorPosition?.let { position->PagingState.getClosestItemToPosition(position)?.id}} -
接下来,我们需要确保我们替换位置数据源:
override fun getRefreshKey(PagingState: PagingState): Int? {return PagingState.anchorPosition} -
如果您正在使用旧的分页库,分页数据使用
DataSource.map()、mapByPage、Factory.map()和Factory.mapByPage。然而,在分页 3 中,所有这些都被应用为PagingData的操作符。 -
最后,为了确保您从分页 2 的
PageList迁移,您需要迁移到PagingData。最显著的变化是PagedList.Config不是PagingConfig。此外,Pager()暴露了一个带有其流的可观察的Flow<PagingData>:val yourFlow = Pager(PagingConfig(pageSize = 24)) {YourPagingSource(yourBackend, yourQuery)}.flow.cachedIn(viewModelScope)
它是如何工作的…
为了确保您的迁移过程完整且成功,您必须确保从分页 2 迁移所有重要组件。这包括如果您的应用程序使用它,则必须迁移DataSource类、PagedList和PagedListAdapter。此外,一些分页 3 组件与其他版本兼容良好,这意味着它是向后兼容的。
在 Paging 3 中,对PagingSource最显著的变化是它将所有加载函数合并为一个,现在在PagingSource中称为load()。这确保了代码中没有冗余,因为加载逻辑通常与旧 API 相同。此外,Paging 3 中加载函数的参数现在使用LoadParams密封类,它为每种加载类型都有子类。
在 Paging 2 中使用的PagedList,当你迁移时,你可能会使用PagingData和Pager。当你开始从 Paging 3 使用PagingData时,你应该确保配置已从旧的PagedList.Config移动到PagingConfig。
为你的 Paging Source 编写测试
为你的实现编写测试至关重要。在这个食谱中,我们将为我们的PagingSource实现编写单元测试来测试我们的逻辑。一些可能值得编写的测试是检查新闻 Paging 加载失败的情况。
我们还可以测试成功状态等。你可以遵循这个模式为你的项目或用例编写测试。
准备工作
要逐步遵循这个食谱,你需要已经遵循了实现 Jetpack Paging 库的食谱,并且你需要使用PagingJetpackExample项目。
如何做到这一点…
打开PagingJetpackExample,并跟随这个项目添加单元测试:
-
将以下测试库添加到你的
build.gradle应用中:testImplementation 'org.assertj:assertj-core:3.x.x'testImplementation "org.mockito:mockito-core:3.x.x"testImplementation 'Androidx.arch.core:core-testing:2.x.x'testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.x.x' -
添加依赖项后,在项目结构中
test包中创建一个新的包,并将其命名为data。如果你需要帮助找到文件夹,可以参考第一章,现代 Android 开发技能入门中的理解 Android 项目结构食谱。 -
创建一个测试类,并将其命名为
NewsArticlePagingSourceTest。 -
在类内部,让我们继续添加
Mock来模拟我们的ApiService接口,并创建一个lateinit var newsApiService,我们将在@Before步骤中初始化它:@Mockprivate lateinit var newsApiService: NewsApiServicelateinit var newsPagingSource: NewsArticlePagingSource -
现在,让我们继续创建我们的
@Before,这样我们就可以运行我们的CoroutineDispatchers,它被所有标准构建器(如 async)和启动到我们的@Before步骤:@Beforefun setup() {Dispatchers.setMain(testDispatcher)newsPagingSource =NewsArticlePagingSource(newsApiService)} -
我们需要编写的第一个测试是检查失败发生的情况。因此,让我们继续设置我们的测试。
403响应是一个禁止状态码,表示服务器理解了你的请求但没有授权它:@Testfun `news article Paging Source load failure http error`() = runBlockingTest {//setupval error = HttpException(Response.error<ResponseBody>(403, "some content".toResponseBody("plain/text".toMediaTypeOrNull()))) . . . -
为了继续我们的测试,我们需要使用
Mockito.doThrow(error):Mockito.doThrow(error).`when`(newsApiService).getNews(1). . . -
然后,最后,我们触发
PagingSource.LoadResult.Error并传入类型,然后断言://assertassertEquals(expectedResult, newsPagingSource.load(PagingSource.LoadParams.Refresh(key = null,loadSize = 1,placeholdersEnabled = false))) -
你可以添加两个额外的测试,然后添加
tearDown来清理协程:@Afterfun tearDown() {testDispatcher.cleanupTestCoroutines()}
它是如何工作的…
我们在单元测试中使用Mock,其基本思想是基于测试对象可能依赖于其他复杂对象的观念。基于这一点,通过模拟对象,我们可以更容易地隔离我们想要的对象的行为,这确保了它具有与我们的真实对象相同的行为,从而使测试变得更容易:
@Mock
private lateinit var newsApiService: NewsApiService
我们的lateinit var newsPagingSource: NewsArticlePagingSource用于延迟初始化,我们是在@``Before函数中对其进行初始化的。
第九章:为大屏幕构建
我们现在都可以同意,我们生活在一个拥有可折叠手机的世界,这是一个我们从未预料到的技术,因为它们的需求和普及度在增长。十年前,如果你告诉一个开发者我们会有可折叠手机,没有人会相信它,因为屏幕复杂性和信息传输的不确定性。
然而,现在这些设备已经在我们身边了。由于其中一些设备运行在 Android 操作系统上,了解我们开发者将如何构建我们的应用程序以适应可折叠性,以及我们现在在市场上看到的 Android 平板电脑的数量至关重要。对大屏幕的支持现在似乎是强制性的,在本章中,我们将探讨在新的现代安卓开发中支持大屏幕。
在本章中,我们将介绍以下菜谱:
-
在现代安卓开发中构建自适应布局
-
使用
ConstraintLayouts构建自适应布局 -
处理大屏幕配置更改和连续性
-
理解活动嵌入
-
在 Compose 中使用材质主题
-
在可折叠设备上测试你的应用程序
技术要求
本章的完整源代码可以在github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_nine找到。
在现代安卓开发中构建自适应布局
当你在现代安卓开发中构建应用程序的 UI 时,可以说你应该考虑确保应用程序能够响应不同的屏幕尺寸、方向和形态。最后,开发者现在可以移除横屏模式下的锁定。在这个菜谱中,我们将利用从前面的菜谱中学到的想法,为不同的屏幕尺寸和方向构建一个自适应的应用程序。
准备工作
我们将使用城市应用程序来创建旅行者档案,并且我们的屏幕应该能够根据不同的屏幕尺寸变化,并支持可折叠设备和平板电脑。要获取完整代码,请查看 技术要求 部分。
如何操作…
对于这个菜谱,我们将创建一个新的项目,这次,我们不会选择空的 Compose Activity 模板,而是选择空的 Compose Activity (Material 3)。Material 3旨在改善我们的应用程序在 Android 中的外观和感觉。它包括更新的主题、组件和出色的功能,例如使用动态颜色进行 Material You 个性化:
- 让我们从创建一个空的 Compose Activity (
Material3) 项目并命名为Traveller开始;请注意,你可以将你的项目命名为任何你想要的。

图 9.1 – 创建一个新的 Empty Compose Activity (Material3) 项目
复杂的应用程序使用响应式 UI,在大多数情况下,确保你为你的应用程序选择正确的导航类型很有用。Material 库为开发者提供了导航组件,例如底部导航、导航抽屉和导航轨道。你可以在 技术 要求 部分获取这些组件的起始代码。
-
添加以下依赖项,并检查项目的正确版本号,
1.1.0:implementation "androidx.compose.material3:material3-window-size-class:1.1.0" -
当确保我们的代码具有适应性时,我们必须记住,响应式 UI 在手机旋转、折叠或展开时保留数据。最重要的是确保我们处理了姿势。我们将创建一个函数,
cityPosture,它接受FoldingFeature作为输入并返回一个布尔值:@OptIn(ExperimentalContracts::class)fun cityPosture(foldFeature: FoldingFeature?): Boolean {contract { returns(true) implies (foldFeature !=null) }return foldFeature?.state ==FoldingFeature.State.HALF_OPENED &&foldFeature.orientation ==FoldingFeature.Orientation.VERTICAL}
我们根据提供的三个状态处理状态。我们还用实验性类注释它,因为这个 API 仍然是实验性的,这意味着它将来可能会改变,并且不是很稳定。
-
接下来,我们需要涵盖
isSeparating,它监听FLAT完全打开 和isSeparating布尔值,这计算了是否应该考虑FoldingFeature,将窗口分割成多个用户可以视为逻辑上独立的物理区域:@OptIn(ExperimentalContracts::class)fun separating(foldFeature: FoldingFeature?): Boolean {contract { returns(true) implies (foldFeature !=null) }return foldFeature?.state ==FoldingFeature.State.FLAT &&foldFeature.isSeparating} -
我们还将创建一个密封的接口,
DevicePosture。这是一个 Jetpack Compose UI 组件,允许你检测设备的姿势或方向,例如设备是处于纵向还是横向模式:sealed interface DevicePosture {object NormalPosture : DevicePosturedata class CityPosture(val hingePosition: Rect) : DevicePosturedata class Separating(val hingePosition: Rect,var orientation: FoldingFeature.Orientation) : DevicePosture} -
在我们的
MainActivity中,我们现在需要确保我们计算窗口大小:val windowSize = calculateWindowSizeClass(activity = this) -
然后,我们将通过创建
postureStateFlow来确保我们处理所有尺寸,它将监听我们的DevicePosture,并在cityPosture是折叠、展开或正常时采取行动:val postureStateFlow = WindowInfoTracker.getOrCreate(this).windowLayoutInfo(this). . .when {cityPosture(foldingFeature) ->DevicePosture.CityPosture(foldingFeature.bounds)separating(foldingFeature) ->DevicePosture.Separating(foldingFeature.bounds,foldingFeature.orientation)else -> DevicePosture.NormalPosture}}. . .) -
现在,我们需要设置一个可折叠测试虚拟设备。如果你需要复习如何创建虚拟设备的第一章步骤,可以重复执行;否则,你应该继续创建一个可折叠设备。图 9**.2 中的箭头显示了如何控制可折叠屏幕。

图 9.2 – 可折叠控制
- 然后,最后,当你运行应用程序时,你会看到它根据折叠和展开状态而变化,工作得很好。图 9**.3 显示了折叠状态。

图 9.3 – 折叠状态
- 在 图 9**.4 中,你可以看到我们更改了底部导航,现在将导航抽屉设置为侧面以实现更直接的导航。应该承认,这个项目很庞大,所以我们不能涵盖代码的所有部分。确保利用上一章学到的 Compose 概念来处理这一部分。

图 9.4 – 全屏状态(未折叠)
注意,当你展开导航抽屉时,你可以看到所有项目,并且你应该能够轻松导航。

图 9.5 – 导航抽屉打开
你也可以在侧面板上看到一个更详细的 UI 视图,这有助于调试问题。

图 9.6 – 设备姿态
重要提示
该项目的代码库非常庞大,因此不能仅在一个菜谱中涵盖;你可以在技术要求部分找到完整的代码。
它是如何工作的……
我们在第四章中介绍了底部导航,现代 Android 开发中的导航。然而,在这一章中,我们使用它来展示如果你的应用安装在可折叠设备上,应用如何随着屏幕的变化而变化,这在现代 Android 开发中非常重要。
导航栏用于中等屏幕尺寸,而导航抽屉,就像在旧的应用程序编写方式中一样,用作侧抽屉,适用于大屏幕设备。FoldFeature是 Jetpack Compose 的一个内置 UI 组件,允许你在点击时创建折叠动画效果。
这里是使用FoldFeature在您的 Android 应用程序中的步骤。您还可以通过提供必要的参数来自定义FoldFeature:
-
foldableState:此状态控制FoldFeature的折叠和展开。你可以使用rememberFoldState()函数创建一个FoldState实例。 -
foldedContent:当FoldFeature折叠时,将显示内容。 -
expandedContent:当FoldFeature处于展开状态时,将显示此内容。 -
foldingIcon:这是将显示以指示FoldFeature折叠状态的图标。
可折叠设备能够处于各种状态和姿态。我们示例中使用的 Jetpack WindowManager库的WindowLayoutInfo类提供了以下详细信息。state有助于描述设备所处的折叠状态。当手机完全打开时,状态为FLAT或HALF_OPENED。我们还可以玩转orientation,这是铰链的朝向。
铰链可以是HORIZONTAL或VERTICAL。我们有occlusionType,当铰链隐藏部分显示屏时,此值为FULL。否则,值为NONE。最后,我们有isSeparating,当铰链创建两个逻辑显示时,该值有效。
使用 ConstraintLayout 构建自适应布局
Jetpack Compose,一个用于构建优秀 UI 的声明式 UI 工具包,非常适合实现和设计能够自动调整并适应不同屏幕尺寸的屏幕布局。
这在构建你的应用程序时可以考虑,因为你的应用程序被安装在可折叠设备上的可能性很高。此外,这可以从简单的布局调整到填充类似平板电脑的可折叠空间。
准备工作
您需要阅读前面的章节才能跟随这个菜谱。
如何做到这一点...
对于这个菜谱,我们将构建一个单独的可组合函数来向您展示如何在同一项目中使用ConstraintLayout,而不是创建一个新的:
-
让我们继续打开
Traveller。添加一个新的包,命名为constraintllayoutexample。在包内部,创建一个名为ConstraintLayoutExample的 Kotlin 文件,然后向项目中添加以下依赖项:implementation "Androidx.constraintlayout:constraintlayout-compose:1.x.x" -
在我们的示例中,我们将创建一个有趣的
AndroidCommunity(),并使用ConstraintLayout创建title、aboutCommunity和AndroidImage引用:@Composablefun AndroidCommunity() {ConstraintLayout {val (title, aboutCommunity, AndroidImage) =createRefs(). . .} -
createRefs(),即创建引用,为我们的ConstrainLayout中的每个可组合组件创建一个引用。 -
现在,让我们继续创建我们的标题文本
aboutCommunity和AndroidImage:Text(text = stringResource(id =R.string.Android_community),modifier = Modifier.constrainAs(title) {top.linkTo(parent.top)start.linkTo(parent.start)end.linkTo(parent.end)}.padding(top = 12.dp),style = TextStyle(color = Color.Blue,fontSize = 24.sp)) -
我们的主题文本有一个具有约束定义的修饰符,如果您之前使用过 XML,您可能会注意到这正好与 XML 的工作方式相同。我们使用
constrainAs()修饰符提供约束,在我们的情况下,它将引用作为参数,并允许我们在 lambda 体中指定其约束。此后,我们将使用linkTo(...)或其他方法指定约束,但在此情况下,我们将使用linkTo(parent.top)。
我们现在可以使用类似的方式将各个部分连接起来,此外,请确保您检查整个代码的技术要求部分:
Text(
text = stringResource(id =
R.string.about_community),
modifier = Modifier.constrainAs(aboutCommunity) {
top.linkTo(title.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
}
.padding(top = 12.dp, start = 12.dp,
end = 12.dp),
style = TextStyle(
fontSize = 18.sp
)
)
-
然后,我们构建图像:
Image(painter = painterResource(id =R.drawable.Android),contentDescription = stringResource(id =R.string.Android_image),modifier = Modifier.constrainAs(AndroidImage) {top.linkTo(aboutCommunity.bottom,margin = 16.dp)centerHorizontallyTo(parent)}). . . -
最后,要运行这段代码,您可以运行
@Preview部分:@Preview(showBackground = true)@Composablefun ShowAndroidCommunity() {TravellerTheme() {AndroidCommunity()}} -
当您运行应用程序时,它应该能够很好地渲染和适应屏幕尺寸。例如,如果状态是全屏(这意味着未折叠),数据应显示在整个屏幕上(参见图 9.7)。

图 9.7 – 未折叠状态的全屏
- 在图 9.8中,您可以看到当屏幕折叠时数据的版本以及它如何适应指定的尺寸。

图 9.8 – 折叠状态
它是如何工作的...
我们使用修饰符来调整组件之间的间距,并使用维度资源来定义图像和aboutCommunity之间的边距。我们的布局将根据屏幕尺寸调整,以在小屏幕和大屏幕上都看起来很好。
我们还使用ConstraintLayout,这是一个允许我们使用扁平视图层次结构创建复杂布局的布局管理器。它还内置了对响应式布局的支持,以创建不同屏幕尺寸和方向的布局。
ConstraintLayout的最佳用例包括以下内容:
-
当您想避免嵌套多个列和行时;这可以包括当您想将元素定位在屏幕上以方便代码可读性时
-
当您需要在使用定位时使用指南、链或障碍时利用它
我们在之前的章节中提到了修饰符,它们类似于 XML 布局中的属性。它们允许我们根据屏幕尺寸将不同的样式和行为应用到布局中的组件。您可以使用修饰符根据屏幕尺寸更改组件的大小、位置和其他属性。
在我们的示例中,我们使用动态填充和边距,您可以使用它们根据屏幕尺寸调整组件之间的间距。例如,您可以使用修饰符在较大屏幕上为组件添加更多填充。
这允许您创建响应式布局,根据屏幕尺寸进行调整。
处理大屏幕配置更改和连续性
安卓设备在其运行过程中会经历各种配置更改。其中一些最显著或标准的包括以下内容:
-
屏幕方向更改:这发生在用户旋转设备的屏幕时,触发配置更改。这是设备从纵向模式切换到横向模式或反之亦然的时候。
-
屏幕尺寸更改:这是当用户更改设备的屏幕尺寸时——例如,通过连接或断开外部显示,触发配置更改。
-
语言或区域设置更改:这是当用户更改设备的语言或区域设置时,触发配置更改。这可能会影响文本和日期的格式化,以及其他方面。
-
主题更改:这是当用户更改设备的主题时,触发配置更改。这可能会影响 UI 的外观。
-
键盘可用性更改:这是当用户将键盘连接或断开连接到设备时,触发配置更改。这可能会影响 UI 的布局,等等。
在这个菜谱中,我们将探讨如何利用这些知识来更好地处理处理可折叠设备时的屏幕尺寸变化。
准备工作
在第一个菜谱中,在现代安卓开发中构建自适应布局,我们讨论了不同的状态配置以及如何更好地处理它们。在这个菜谱中,我们将学习如何使用 Jetpack Compose 中已经提供的rememberFoldableState函数来处理可折叠设备上的屏幕变化。
如何做到这一点...
让我们使用已经创建的Traveller项目来进行这个示例;您不需要创建一个新的项目:
-
要使用
rememberFoldableState,我们需要将其导入到我们的项目中:import Androidx.window.layout.FoldableState -
然后,我们将创建一个新的
val/属性 foldableState并将其初始化为我们的rememberFoldableState:val foldState = rememberFoldableState() -
使用
foldState对象,我们可以获取有关可折叠设备的信息,使我们的应用程序能够响应正确的状态,并根据需要显示数据。可用的三种状态是STATE_FLAT、STATE_HALF_OPENED和STATE_CLOSED:when (foldState.state) {FoldableState.STATE_FLAT -> {// Our Device is flat (unfolded)do something}FoldableState.STATE_HALF_OPENED -> {//Our Device is partially folded. Do something}FoldableState.STATE_CLOSED -> {//Our Device is fully folded do something}} -
我们可以使用这些信息相应地调整我们的 UI,例如根据可折叠状态或指定位置显示或隐藏某些元素。此外,我们可以为设备折叠和展开时创建两个不同的布局:
val isFolded = foldState.state == FoldableState.STATE_CLOSEDif (isFolded) {// Create our layout for when the device is folded} else {// Create our layout for when the device isunfolded}
就这样;如果您有一个需要更好处理的复杂 UI 系统,这将有助于解决可折叠状态问题。
它是如何工作的...
在 Android Jetpack Compose 中处理重要的屏幕配置更改,尤其是在可折叠设备上,可能会很具挑战性。以下是一些可以帮助您使用配置 API 的技巧。它允许您获取有关设备屏幕配置的信息,例如屏幕大小、方向和可折叠状态。您可以使用这些信息相应地调整您的 UI。
Compose 的布局系统使得创建能够适应不同屏幕尺寸和宽高比的响应式 UI 变得容易。使用灵活的布局,如列和行,创建一个可以根据需要放大或缩小的 UI。
rememberFoldableState函数允许您获取有关设备可折叠状态的信息,并相应地调整您的 UI。例如,您可以使用此函数创建两个不同的布局,一个用于设备折叠时,一个用于设备展开时。
使用不同的屏幕配置测试您的应用也是确保其正常工作的关键。您可以使用 Android 模拟器或物理设备来测试您的应用。
理解活动嵌入
在 Jetpack Compose 中,活动嵌入指的是在活动上下文中包含一个可组合函数的过程。这允许您创建可以无缝集成到现有 Android 活动中的自定义视图。
要在活动中嵌入一个可组合函数,您可以使用活动的setContent方法。此方法接受一个可组合函数作为参数,该参数可用于定义活动的布局。
准备工作
您需要完成之前的食谱才能继续学习。
如何做到这一点...
让我们看看在活动中嵌入一个可组合函数的示例:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyCustomView()
}
}
}
@Composable
fun MyCustomView() {
Text(text = "Hello, Android Community!")
}
在这个示例中,setContent方法将MyCustomView可组合函数嵌入到MainActivity中。当活动创建时,MyCustomView函数将被调用以生成活动的布局。
它是如何工作的...
MyCustomView函数被定义为使用@Composable注解的可组合函数。这允许函数被多次调用而不会产生任何副作用。在这种情况下,该函数仅显示一个带有文本Hello, Android Community!的Text可组合组件。
通过在活动中嵌入可组合函数,您可以创建可以轻松集成到您的 Android 应用中的自定义视图。这特别有用于创建可重用组件或自定义现有活动的布局。
Compose 中的材料主题
Compose 中的材料主题是由 Google 引入的设计系统,它提供了设计用户界面的指南和原则。材料主题帮助设计师创建一致、易于使用且视觉上吸引人的界面。Jetpack Compose 中材料主题的一些关键特性包括以下内容:
-
MaterialTheme可组合组件,允许你将调色板应用于整个应用程序。 -
Typography可组合组件,允许你将字体样式应用于你的文本。 -
Shape可组合组件,允许你将形状应用于你的组件。 -
Icon可组合组件,允许你在应用程序中使用 Material 图标。
通过在 Jetpack Compose 中使用 Material 主题,你可以创建一致、易于使用且视觉上吸引人的界面。Jetpack Compose 中的 Material 主题可以帮助你专注于设计应用程序的功能,而设计系统则负责视觉细节。
准备工作
为了能够跟上,你需要完成之前的食谱。
如何操作…
许多应用程序仍然没有使用 Material 3,但如果你从头开始构建一个新应用程序,强烈建议你选择 Material 3。需要注意的是,当你创建一个项目时,Material 3 并不是预先安装的;这意味着你需要自行更新 Material 库到 Material 3。
让我们看看在 Android 应用程序中使用 Jetpack Compose 实现 Material 3 主题的一个例子:
-
你需要在你的应用程序的
build.gradle文件中添加所需的Material 3依赖项:implementation 'Androidx.compose.material3:material3:1.0.0-alpha14' -
然后,你需要声明你的应用程序主题:
@Composablefun MyAppMaterialTheme(content: @Composable () -> Unit) {MaterialTheme(colorScheme = /**/,typography = /**/,shapes = /**/,content = content)} -
最后,你可以在整个应用程序中使用你的主题:
@Composablefun MyApp() {MyAppMaterialTheme {}}
在这个例子中,我们使用了 Material 3 的颜色、字体和形状来创建一个一致且视觉上吸引人的界面。我们还使用了 Material 3 的图标来增强用户体验。最后,我们将应用程序的内容包裹在 MyAppMaterialTheme 可组合组件中,以应用 Material 3 主题。
它是如何工作的…
这是 Material 3 在 Jetpack Compose 中的工作方式。Material 3 引入了新的和更新的组件,如 AppBar、BottomNavigation 和 TabBar,这些组件可以使用 Androidx.compose.material3 包在 Jetpack Compose 中使用。这些组件具有更新的设计和功能,并遵循 Material 3 指南。
Material 3 还引入了一个新的主题系统,允许更多的自定义和灵活性——也就是说,在 Jetpack Compose 中,Material 3 主题可以通过 MaterialTheme3 可组合组件应用。这个可组合组件允许你自定义应用程序的颜色方案、字体和形状,并且它还提供了新的选项来自定义组件的凸起和阴影。
图标现在更加现代且易于访问,这对我们开发者来说是一个很大的加分项。最后,Material 3 引入了一个新的字体系统,为应用程序中的字体提供了更新的样式和指南。在 Jetpack Compose 中,Material 3 字体可以通过 Material3Typography 对象应用,该对象为你的文本提供了几个预定义的样式。
通过在 Jetpack Compose 中使用 Material 3,你可以创建遵循最新设计指南的现代且视觉上吸引人的界面。同时请注意,Material 3 组件、主题、图标和字体类型都可以一起使用,为你的应用创建一个统一和一致的设计系统。
相关内容…
材料设计有很多内容需要介绍,试图在单一菜谱中涵盖所有组件并不能真正公正地对待它们。要了解更多关于组件以及如何确保你的应用遵循材料设计指南,请在此处阅读更多:material.io/components。
在可折叠设备上测试你的应用
在可折叠设备上测试你的应用对于确保它们正确运行并提供了卓越的用户体验至关重要。在这个菜谱中,我们将探讨一些在 Jetpack Compose 中测试你的应用的提示。
准备工作
你需要完成之前的菜谱。你可以在 技术 要求 部分访问整个代码。
如何实现…
这里有一些在可折叠设备上测试你的应用的提示:
-
使用模拟器:你可以使用 Android 模拟器来测试你的应用,而无需购买物理设备。模拟器提供了一系列可折叠设备配置,你可以使用它们来测试你的应用。
-
使用真实设备:在真实的可折叠设备上测试你的应用可以更准确地反映你的应用在这些设备上的工作情况。如果你可以访问到可折叠设备,强烈建议你在其上测试你的应用。
-
测试不同的屏幕模式:可折叠设备有不同的屏幕模式,如单屏、双屏和扩展屏幕。测试你的应用在不同屏幕模式下以确保它在所有模式下都能正确运行至关重要。
-
测试不同屏幕尺寸:可折叠设备有不同的尺寸,因此测试你的应用在不同屏幕尺寸上以确保它在所有设备上都能良好运行至关重要。
-
测试应用过渡:测试你的应用在不同屏幕模式之间的过渡可以帮助你识别应用布局或行为中可能存在的问题。确保测试所有过渡模式,如折叠、展开和铰链。
-
使用自动化测试:自动化测试可以帮助你更高效地在不同的屏幕尺寸、模式和方向上测试你的应用。你可以使用如 Espresso 或 UI Automator 等工具为你的应用编写自动化测试。
它是如何工作的…
总体而言,在可折叠设备上测试你的应用需要仔细考虑设备的独特特性和能力。通过遵循这些提示,你可以确保你的应用针对可折叠设备进行了优化,并提供了卓越的用户体验。
第十章:使用 Jetpack Compose 实现你的第一个 Wear OS
Wear OS是由谷歌为智能手表和其他可穿戴设备开发的操作系统。有几个原因说明为什么在现代 Android 开发中创建 Wear OS 对于 Android 至关重要。首先,这意味着扩展 Android 生态系统;Wear OS 通过允许开发者创建可以通过智能手表或其他可穿戴设备访问的应用和服务来扩展 Android 生态系统。
这扩大了 Android 的覆盖范围,为开发者和用户创造了新的机会。此外,它还提供了与 Android 智能手机的无缝集成,使用户能够轻松地在他们的智能手表上访问通知、电话和其他信息,从而为用户提供了一种更方便、更高效的方式与您的应用程序互动。
让我们不要忘记,从中受益最显著的应用程序是健康和健身追踪应用,包括心率监测、步数追踪和锻炼追踪。这使用户能够跟踪他们的健身目标并保持动力去实现它们。最后,Wear OS 允许用户通过不同的表盘、应用程序和部件来定制他们的智能手表。这提供了一个个性化的体验,满足个人的需求和偏好。
可穿戴技术是一个快速发展的市场,随着技术的不断进步,Wear OS 有潜力成为可穿戴技术市场的主要参与者。Wear OS 仍然非常新,在本章中,我们将探讨一些简单的基本示例,因为许多 API 可能在将来发生变化。因此,了解它是如何工作的以及如何创建卡片、按钮和显示列表将是有帮助的。
在本章中,我们将涵盖以下菜谱:
-
在 Android Studio 中开始你的第一个 Wear OS
-
创建你的第一个按钮
-
实现一个可滚动的列表
-
在 Wear OS 中实现卡片(
TitleCard和AppCard) -
实现一个芯片和一个切换芯片
-
实现
ScalingLazyColumn以展示你的内容
技术要求
本章的完整源代码可以在github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_ten找到。
在 Android Studio 中开始你的第一个 Wear OS
Android 操作系统在全球范围内被广泛使用,其中一种使用案例是 Wear OS(我们所说的“wear”指的是智能手表)。这对 Android 开发者来说是个好消息,因为这意味着更多的就业机会。此外,现在许多应用程序都需要支持 Wear OS,例如 Spotify、健身追踪应用、心率监测应用等,这预示着将出现更多的使用案例,并且公司将会采用为Wear OS构建,即使只是为了通知目的。因此,本章将探讨如何开始。
准备工作
在这个菜谱中,我们将探讨如何开始使用 Wear OS 以及如何设置你的虚拟手表测试环境。
如何做到这一点…
要在 Jetpack Compose 中创建您的第一个 Wear OS 项目,请按照以下步骤操作:
-
首先,在 Android Studio 中创建一个新的 Android 项目,并确保您已安装最新版本的 Android Studio 和 Wear OS SDK。
-
然后,按照创建您的第一个应用程序的流程,参考 第一章,“现代 Android 开发技能入门”,选择 Wear OS 而不是 手机和平板,如图 图 10.1 所示。

图 10.1 – 选择 Wear OS
- 选择
WearOSExample。您会注意到它使用的是最小 SDKAPI 30: Android11.0 (R)。

图 10.2 – 最小 SDK 版本
-
点击 完成,您应该能够看到为您提供的示例代码模板。
-
现在,让我们继续设置我们的虚拟 Wear OS 测试设备以运行提供的代码模板。导航到 工具 | 设备管理器,然后创建一个新设备。如果您在这个部分需要帮助,请参考 第一章,“现代 Android 开发技能入门”。
-
现在,查看 图 10.3 以选择您的 Wear OS 虚拟测试设备。请注意,您也可以选择圆形、方形或矩形设备。我们将使用圆形。

图 10.3 – 设置 Wear OS 虚拟设备
- 点击 下一步,然后下载系统镜像 – 在我们的案例中,R,API 级别 30。

图 10.4 – 安装测试系统镜像
-
然后,点击 完成,您应该有一个可用的 Wear OS 虚拟测试设备。
-
现在,将
Greeting()中的文本更改为代码模板中的"Hello, Android Community"并运行,您应该会有类似于 图 10.5 的结果。如果一切安装正确,您不应该有构建错误。

图 10.5 – 在 Wear OS 虚拟设备上显示问候语
- 此外,确保您也更改了圆形字符串资源上的文本。
就这样,您已成功设置您的第一个 Wear OS,并且我们已经能够运行提供的 Greeting()。在接下来的配方中,我们将查看创建一个简单的按钮。
它是如何工作的…
您会注意到模板看起来与构建 Android 应用程序的方式完全一样,唯一的区别是使用的库。模板使用 Compose,这使得我们在开发中工作更加容易,因为我们将会使用我们在前几章中学到的大多数概念。
以下是比较,以帮助您了解 Wear OS 依赖项和标准依赖项之间的区别:

图 10.6 – 不同类型的依赖关系(来源:developer.android.com)
创建您的第一个按钮
在这个菜谱中,我们将创建 Wear OS 上的第一个按钮,以探索构建 Wear OS 的原则和最佳实践。
准备工作
您需要完成上一个菜谱,才能开始这个菜谱。我们将基于已经创建的 WearOSExample 项目进行构建。
如何实现...
要在 Jetpack Compose 中创建 Wear OS 上的第一个按钮,您可以按照以下步骤操作:
- 使用已创建的项目,我们将添加一个新的按钮。让我们继续删除一些已提供的代码,
fun Greeting(greetingName: String):

图 10.7 – 展示要删除内容的截图
-
删除在
WearOSExampleTheme中调用的Greeting()函数将引发错误;继续删除它。 -
然后创建一个新的
Composable函数来定义您的按钮。您可以使用 Jetpack Compose 提供的Button函数:@Composablefun SampleButton() {Button(onClick = { /* Handle button click */ },modifier = Modifier.fillMaxWidth()) {Text("Click me")}} -
然后,在我们的
WearApp()函数中调用新函数:@Composablefun WearApp() {WearOSExampleTheme {/* If you have enough items in your list, use[ScalingLazyColumn] which is an optimizedversion of LazyColumn for wear devices withsome added features. For more information,see d.android.com/wear/compose./*/Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),verticalArrangement = Arrangement.Center) {SampleButton()}}} -
然后,在我们的活动中,使用按钮的
Composable函数作为参数调用setContent方法:class MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?){super.onCreate(savedInstanceState)setContent {WearApp()}}} -
您还可以利用已提供的
Preview函数来查看更改。您会注意到我们明确指定了设备,@Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true):@Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true)@Composablefun DefaultPreview() {WearApp()} -
运行您的 Wear OS 应用,您应该在屏幕上看到您的按钮,如图 图 10.8 所示:

图 10.8 – Wear OS 中的按钮
-
让我们看看另一个例子,这是一个带图标的按钮;这与第一个按钮非常相似,但在这个例子中,我们将只添加图标而不是文本。
-
创建一个名为
SampleButton2()的新函数,并添加以下代码:@Composablefun SampleButton2() {Row(modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),horizontalArrangement = Arrangement.Center) {Button(modifier = Modifier.size(ButtonDefaults.LargeButtonSize),onClick = { /* Handle button click */ },) {Icon(imageVector =Icons.Rounded.AccountBox,contentDescription = stringResource(id = R.string.account_box_icon),modifier = Modifier.size(24.dp).wrapContentSize(align = Alignment.Center))}}} -
最后,注释掉
SampleButton,添加SampleButton2,并运行;您应该看到类似于 图 10.9 的内容:

图 10.9 – Wear OS 中的带图标的按钮
重要提示
需要注意的是,Wear OS 平台在设计和应用测试方面有一些独特的考虑因素,例如屏幕尺寸较小和需要优化电池寿命。在真实设备上测试您的应用对于确保它在 Wear OS 上按预期工作至关重要。
它是如何工作的...
根据您对 Compose 的先前知识,我们迄今为止所做的一切都应该看起来很熟悉。在我们的例子中,我们使用 Wear OS Compose 库中的 SampleButton 和 WearOSExampleTheme 来创建一个专门为 Wear OS 设备设计的按钮。
SampleButton 接收一个 onClick lambda,当按钮被点击时会被调用,以及一个设置按钮大小的修饰符,根据我们指定的内容,在我们的例子中,是一个简单的 fillMaxWidth()。
我们在列中使用horizontalArrangement来居中按钮,并使用MaterialTheme颜色来绘制背景。在 Wear OS 的情况下,谷歌建议使用默认的材料穿戴形状;这些形状已经针对非圆形和圆形设备进行了优化,这使得我们的工作作为开发者来说更容易。有关形状的更多信息,请参阅以下链接:developer.android.com/reference/kotlin/androidx/wear/compose/material/Shapes。
最后,我们使用Text可组合函数来显示按钮文本,这对于告诉用户按钮的预期用途至关重要。
实现可滚动列表
实现可滚动列表对于创建一个有效且用户友好的满足用户需求的 Android 应用至关重要。可滚动列表允许您在小型屏幕上显示大量信息,这在小型设备(如手表)上尤其有益。通过滚动列表,用户可以快速轻松地访问所有项目,而无需导航到不同的屏幕或页面。
用户在与列表交互时期望有一个平滑且响应灵敏的滚动体验。实现具有优化性能的可滚动列表可以帮助确保应用对用户来说感觉快速且响应灵敏。可滚动列表可以根据各种用例和设计要求进行定制。您可以调整列表的布局、外观和行为,以满足您应用的特定需求并提供独特的用户体验。在本食谱中,我们将探讨如何在 Wear OS 中实现可滚动列表。
准备工作
您需要完成上一个食谱才能开始这个食谱。我们将使用已经创建的WearOSExample项目来继续这一部分。
如何实现...
按照以下步骤使用 Jetpack Compose 在 Wear OS 中构建可滚动列表:
-
在您的
MainActivity.kt文件中,让我们创建一个新的Composable函数来包含您的可滚动列表。您可以将其命名为任何您喜欢的名称,但为了本例,我们将将其命名为WearOSList。 -
另一个选项是创建一个新的包来更好地组织我们的代码,并将其命名为
components。在components内部,创建一个新的 Kotlin 文件,并将其命名为WearOsList。 -
在我们的
WearOSList函数中,我们需要一个字符串列表作为示例;我们只需创建一些示例数据来展示一个例子:@Composablefun WearOSList(itemList: List<String>) {. . .} -
在我们的
WearOSList函数内部,创建ScalingLazyColumn,它是针对 Wear OS 优化的。这将是我们的可滚动列表的容器。我们将在本章后面讨论ScalingLazyColumn:@Composablefun WearOSList(itemList: List<String>) {ScalingLazyColumn() {// TODO: Add items to the list here}}
由于内容大小,构建 Wear OS 可能会具有挑战性,因此熟悉 Wear 的最佳实践是必要的。
-
对于我们的项目,我们将创建一个新的
Composable函数,命名为WearOSListItem,它将只有一个text,因为我们只是展示文本:@Composablefun WearOSListItem(item: String) {Text(text = item)} -
对于我们的数据,我们将创建一个虚拟列表,所以请继续在
WearApp()函数中添加以下内容:val itemList = listOf("Item 1","Item 2","Item 3","Item 4","Item 5",. . .) -
最后,注释掉我们创建的两个按钮,调用
WearOSList,传入itemList,并运行应用程序:{// SampleButton()//SampleButton2()WearOSList(itemList = itemList,modifier = contentModifier)} -
您应该看到一个类似于 图 10.10 的列表:

图 10.10 – 可滚动项目列表
它是如何工作的…
在此示例中,我们使用 Wear OS Compose 库中的 WearOsList 和 WearOSExampleTheme 来创建一个专为 Wear OS 设备设计的列表。
我们首先创建一个接受项目列表作为参数的 WearOSList 可组合函数。在 ScalingLazyColumn 内部,我们使用 items 函数遍历项目列表并为每个项目创建一个 WearOSListItem。
WearOSListItem 可组合函数有一个 Composable text 函数。
在 Wear OS 中实现卡片(TitleCard 和 AppCard)
当为 Wear OS 构建时,我们需要考虑两个重要的卡片:AppCard 和 TitleCard。卡片的一个良好用途是 通知 和 智能回复。如果您使用可穿戴设备,您可能知道这些是什么;如果您不使用可穿戴设备,您可以查找它们,但在这个菜谱中,我们也会探索示例。
此外,如果您创建通知卡片,您的目的是提供一个快速简便的方式查看和回复来自您应用的通知。当通知到达时,它将作为卡片出现在您的手表表面上,然后您可以滑动或点击以打开并与之交互。
对于智能回复卡片,此功能使用机器学习根据消息的上下文建议回复消息。这些卡片作为响应选项出现在通知中,允许您快速发送消息而无需手动输入。
通知和智能回复卡片都是必不可少的,因为它们提供了一种高效且简化的方式来管理通知和回复消息,而无需不断拿出手机。它们允许您在移动时保持连接,并让您在不打扰日常生活的情况下了解重要信息,这就是为什么 Wear OS 会一直存在,并且了解如何为其构建将非常有用。在这个菜谱中,我们将创建一个简单的卡片,并看看如何在 Wear OS 中处理导航。
准备工作
您必须完成之前的菜谱才能继续此菜谱。
如何做到这一点…
这是一个在 Wear OS 中使用 Jetpack Compose 创建卡片的示例。打开 WearOSExample 项目并跟随代码:
-
在
components包内,让我们创建一个新的 Kotlin 文件,并将其命名为MessageCardExample。 -
在
MessageCardExample内部,创建一个新的可组合函数,命名为MessageCard:@Composablefun MessageCard(){. . .} -
我们现在必须调用
AppCard(),因为这是我们想要的。AppCard接受appName、time、title等,如图 图 10.11 所示。这意味着您可以根据需要自定义AppCard():

图 10.11 – AppCard 可组合函数
-
这使得我们的工作更容易,作为开发者,我们知道在构建时需要什么,从而提高了开发者的生产力:
@Composablefun MessageCard() {AppCard(onClick = { /*TODO*/ },appName = { /*TODO*/ },time = { /*TODO*/ },title = { /*TODO*/ }) {}} -
现在,让我们继续实现我们的
AppCard()并向我们的用户发送消息。在我们的例子中,我们将硬编码数据,但如果你有一个端点,你可以拉取数据并按需显示:@Composablefun MessageCard() {AppCard(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),appImage = {Icon(modifier = Modifier.size(24.dp).wrapContentSize(align = Alignment.Center),imageVector = Icons.Rounded.Email,contentDescription = stringResource(id = R.string.message_icon))},onClick = { /*Do something*/ },appName = { stringResource(id = R.string.notification_message) },time = { stringResource(id = R.string.time) },title = { stringResource(id = R.string.notification_owner) }) {Text(text = stringResource(id = R.string.hi_android))}} -
在
MainActivity中,注释掉其他可组合函数,现在添加MessageCard()并运行它:

图 10.12 – 带通知的 AppCard
它是如何工作的……
TitleCard 和 AppCard 都用于在 Wear OS 上显示信息,但它们有不同的用途。在我们的例子中,我们使用 AppCard(),但正如你在 图 10**.13 中所看到的,TitleCard() 接收几个与 AppCard() 类似的输入:

图 10.13 – TitleCard 输入
你可以使用 TitleCard() 来显示与当前上下文相关的信息,例如正在播放的歌曲名称或正在观看的电影标题。它通常显示在屏幕顶部,可以通过滑动来关闭。一个好的例子是 Spotify。
当使用 AppCard() 时,你可以显示当前运行的应用程序的信息,例如应用程序的名称和它所做简要描述,就像我们在示例中所做的那样。它通常显示在一个较小的卡片上,可以点击打开应用程序。这就是为什么它有 onClick{/**TODO*/},这可以引导到更多信息。
当决定是否使用 TitleCard() 或 AppCard() 时,你应该考虑以下因素:
-
需要显示的信息量
-
信息与当前上下文的相关性
-
所期望的用户体验
如果你需要显示大量信息,TitleCard() 可能是一个更好的选择。如果你只需要显示少量信息,AppCard() 可能是一个更好的选择。如果你想显示的信息与当前上下文相关,TitleCard() 可能是一个更好的选择。如果你想显示的信息在一个可以点击打开应用程序的较小卡片上,AppCard() 可能是一个更好的选择。
实现芯片和切换芯片
在这个菜谱中,我们将探索重要的 Wear 组件;芯片和切换芯片都用于显示和交互数据。
芯片是一个可以用来显示文本、图标和其他信息的微小、矩形元素。它通常用来显示相关或具有共同主题的项目。
切换芯片是一个可以用来表示二进制值的组件。它通常用来表示诸如开/关、是/否或真/假等事物。
公平地说,你可以在你的常规应用中使用这些组件,我们将在 第十一章 中进一步探讨它们。在决定使用哪个组件时,你应该考虑以下因素:
-
你想要显示的数据类型
-
你想要启用的交互类型
-
你想要达到的外观和感觉
准备工作
我们将使用已经创建的项目来处理本节内容。
如何操作…
在这个菜谱中,我们将创建一个芯片和一个切换芯片。按照以下步骤操作:
-
让我们继续构建我们的第一个芯片;在
components包内,创建一个 Kotlin 文件,并将其命名为ChipExample.kt。 -
在文件内部,创建一个名为
ChipWearExample()的可组合函数。 -
现在,让我们调用
Chip()可组合函数。你还可以使用Chip组件来显示动态信息。为此,你可以使用modifier属性来指定一个函数,该函数将被调用来更新芯片上显示的信息:@Composablefun ChipWearExample(){Chip(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),onClick = { /*TODO */ },label = {Text(text = stringResource(id = R.string.chip_detail),maxLines = 1,overflow = TextOverflow.Ellipsis)},icon = {Icon(imageVector = Icons.Rounded.Phone,contentDescription = stringResource(id = R.string.phone),modifier = Modifier.size(24.dp).wrapContentSize(align = Alignment.Center))},)} -
在
MainActivity中,先注释掉现有的Composable函数,添加ChipWearExample(),然后运行应用:

图 10.14 – 带有消息的芯片
-
现在,让我们创建一个切换芯片;在我们的
component包内,创建一个 Kotlin 文件,并将其命名为ToggleChipExample。 -
在
ToggleChipExample内部,创建一个名为ToggleChipWearExample()的Composable函数。我们将使用ToggleChip()组件:@Composablefun ToggleChipWearExample() {var isChecked by remember { mutableStateOf(true) }ToggleChip(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),checked = isChecked,toggleControl = {Switch(checked = isChecked)},onCheckedChange = {isChecked = it},label = {Text(text = stringResource(id = R.string.alert),maxLines = 1,overflow = TextOverflow.Ellipsis)})} -
最后,运行代码,你应该能够根据是否想要接收通知来切换芯片的开和关:

图 10.15 – 切换芯片
工作原理…
要在 Wear OS Jetpack Compose 中实现芯片,我们需要使用已经提供的 Chip() 组件。Chip() 组件是体育场形状的,具有最大高度设计,最多只能显示两行文本,可以用来显示文本、图标和其他信息。
你也可以使用 Chip() 组件来显示动态信息。为此,你可以使用 modifier 属性来指定一个函数,该函数将被调用来更新芯片上显示的信息。你可以查看 Chip() 组件以了解它接受哪些参数。
ToggleChip() 可组合函数接受多个参数;以下是一些重要的参数:
-
checked:一个表示切换芯片当前是否被选中的布尔值 -
onCheckedChange:一个当切换芯片的选中状态改变时将被调用的 lambda 函数 -
modifier:一个可选的修饰符,可以用来自定义切换芯片的外观或行为 -
colors:一个可选的ToggleChipColors对象,可以用来自定义切换芯片的颜色
我们使用TextOverflow来处理溢出的文本,因为我们处理的是小屏幕。查看图 10.15以获取关于ToggleChip接受哪些参数的更多详细信息:

图 10.16 – ToggleChip 可组合函数接受的参数
将ScalingLazyColumn实现为展示您的内容
ScalingLazyColumn扩展了LazyColumn,在 Jetpack Compose 中非常强大。您可以将ScalingLazyColumn视为 Wear OS 中用于显示可垂直滚动的项目列表的组件。项目根据其在列表中的位置进行缩放和定位,整个列表可以通过拖动列表的顶部或底部进行滚动。
您可以使用它,例如,来显示组件列表;在我们的示例中,我们将使用它来显示我们在之前的食谱中创建的所有元素。您还会注意到我们在实现可滚动列表食谱中使用了它,在那里我们有一个列表并显示了项目。
准备工作
您需要完成之前的食谱才能继续进行本食谱。此外,在本食谱中,我们不会对创建的所有元素进行注释,而是将它们作为ScalingLazyColumn中的项目显示。
如何操作…
按照以下步骤构建您的第一个ScalingLazyColumn:
-
在
MainActivity中,您会注意到一条注释:/* If you have enough items in your list, use [ScalingLazyColumn] which is an optimized* version of LazyColumn for wear devices with some added features. For more information,* see d.android.com/wear/compose.*/
评论呼吁开发者利用ScalingLazyColumn,这是LazyColumn针对 Wear OS 的优化版本。
-
我们需要首先创建一个
scalingListState值并将其初始化为rememberScalingLazyListState():val scalingListState = rememberScalingLazyListState()
rememberScalingLazyListState()函数简单地按照其定义执行,即记住状态。
-
现在,我们需要通过移除我们添加的修饰符并使用一个修饰符来处理所有视图来清理我们的 Composable 函数。让我们创建一个
contentModifier = Modifier,以及一个用于我们的图标:val contentModifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)val iconModifier = Modifier.size(24.dp).wrapContentSize(align = Alignment.Center) -
我们还需要创建一个
Scaffold(),它实现了 Wear Material Design 的视觉布局结构。Scaffold()使用modifier、vignette、positionIndicator、pageIndicator、timeText和content。 -
让我们继续构建我们的屏幕。在
Scaffold中,我们将使用三个参数:vignette(这是一个全屏槽,用于在 scaffold 的内容上应用 vignette)、positionIndicator和timeText。查看它如何工作…部分以了解更多关于参数的信息:Scaffold(timeText = {} , vignette = {}, positionIndicator = {}) {. . .} -
对于
TimeText,我们将调用Modifier.scrollAway并传递scalingListState:TimeText(modifier = Modifier.scrollAway(scalingListState)) -
由于我们的项目样本只有一个屏幕,且可滚动,我们将尝试同时显示所有项目并始终保持。因此,在
vignette中,我们将说位置将是TopAndBottom:Vignette(vignettePosition = VignettePosition.TopAndBottom) -
最后,在
positionIndicator上,我们只需传递scalingListState:PositionIndicator( scalingLazyListState = scalingListState) -
现在,我们终于可以构建我们的
ScalingLazyColumn()。我们将使用fillMaxSize作为修饰符,并将autoCentering设置为零索引;然后对于state,传递我们已创建的scalingListState,在项目项中传递我们的组件:Scaffold(timeText = { TimeText(modifier =Modifier.scrollAway(scalingListState)) },vignette = { Vignette(vignettePosition =VignettePosition.TopAndBottom) },positionIndicator = {PositionIndicator(scalingLazyListState = scalingListState)}) {ScalingLazyColumn(modifier = Modifier.fillMaxSize(),autoCentering = AutoCenteringParams(itemIndex = 0),state = scalingListState){item { /*TODO*/ }item { /*TODO*/ }item { /*TODO*/ }item { /*TODO*/ }}} -
您可以在 技术要求 部分获取完整的代码。为了清理
item{}中的部分代码,我们有以下内容:item { SampleButton(contentModifier) }item { SampleButton2(contentModifier, iconModifier) }item { MessageCard(contentModifier, iconModifier) }item { ChipWearExample(contentModifier, iconModifier) }item { ToggleChipWearExample(contentModifier) } -
最后,当您运行应用程序时,您应该能够看到所有显示的项目,并且能够平滑地滚动。

图 10.17 – Wear OS 上的我们的可组合元素
它是如何工作的…
Wear OS Jetpack Compose 是一个用于使用 Jetpack Compose 框架构建 Wear OS 应用的 UI 工具包。它旨在使开发者更容易、更高效地创建具有现代和响应式界面的可穿戴应用。正如之前提到的,名为 Scaffold() 的 Composable 函数有几个输入。在 图 10**.18 中,您将看到它们的意义以及为什么您可能想要使用它们:

图 10.18 – Scaffold 函数参数
Wear OS 在 Jetpack Compose 中的某些显著优势是它提供了一套针对 Wear OS 设备独特特性优化的预构建 UI 组件。其中一个关键好处是它通过减少创建 UI 所需的样板代码量来简化开发过程。
它还提供了一致且灵活的 UI 设计语言,可以在不同的应用中使用。关于 Wear OS 还有更多可以学习的内容;此外,由于这是一项新技术,这里的一些概念可能会因为未来 API 的变化而改变或进步,但到目前为止,您可以通过以下链接了解更多信息:developer.android.com/wear。
重要提示
在 Wear OS 中还有更多可以构建的内容;例如,您可以构建一个瓷砖,并在瓷砖项被点击时执行操作。要了解更多关于如何创建您的第一个瓷砖的信息,请点击此链接:developer.android.com/codelabs/wear-tiles。
第十一章:GUI 警告 - 在现代安卓开发中菜单、对话框、Toast、Snackbar 等的新特性
图形用户界面(GUI)警告对用户至关重要,因为它们提供了关于程序或应用程序状态的临界信息,并帮助用户避免错误和做出明智的决定。警告可以在各种情况下触发,例如当发生错误、程序执行关键操作或当用户即将执行不可逆操作时。
GUI 警告的主要好处之一是它们为用户提供即时反馈。例如,如果用户在表单中输入了错误信息,警告可以迅速通知他们错误,允许他们在继续之前进行纠正。这有助于防止错误并在长期内节省时间。
图形用户界面(GUI)警告的另一个好处是它们可以帮助防止意外操作。例如,如果用户即将删除一个重要的文件,警告可以提醒他们这一行为的潜在后果,在继续之前给他们一个重新考虑的机会。本章将探讨在现代安卓开发中如何实现 GUI。
本章将涵盖以下内容:
-
在现代安卓开发中创建和显示菜单
-
实现 Toast/Snackbars 来提醒用户
-
创建一个警告对话框
-
创建一个
BottomSheet对话框 -
创建一个单选按钮
-
创建浮动操作按钮(FABs)和扩展 FABs
技术要求
本章的完整源代码可以在github.com/PacktPublishing/Modern-Android-13-Development-Cookbook/tree/main/chapter_eleven找到。
在现代安卓开发中创建和显示菜单
在安卓应用中创建菜单可以提供以下好处:
-
菜单可以帮助用户快速访问应用内的不同功能和特性。一个设计良好的菜单可以通过简化导航和使用应用来提升用户体验。
-
在应用的不同屏幕上保持一致的菜单可以帮助用户快速找到他们想要的内容,使应用看起来更加精致和专业。
-
菜单可以用来将相关的选项和功能分组在一个地方,减少需要许多按钮和选项的杂乱屏幕。
-
菜单也可以根据应用的具体需求进行定制,包括不同类型的菜单,如上下文菜单、弹出菜单和导航抽屉。
-
菜单的设计应考虑可访问性,使有障碍的用户更容易导航应用。
也就是说,在安卓应用中创建菜单可以提升用户体验,提供一致性,节省空间,并提高可访问性。
准备工作
对于本章,我们将创建一个新的 Material 3 项目,命名为GUIAlerts;这是我们将在其中添加本章所有 UI 组件的地方,你也可以利用这个项目来修改视图以满足你的需求。
如何实现…
按照以下步骤构建你的第一个汉堡菜单:
-
在我们新创建的项目
GUIAlerts中,让我们创建一个包组件,并在包内创建一个名为MenuComponent.kt的 Kotlin 文件。 -
让我们在 Kotlin 文件中创建一个可组合函数
OurMenu:@Composablefun OurMenu(){ } -
现在,让我们继续创建我们的菜单。为了我们的目的,我们只展示一些项目,当有人点击时,不会发生任何事情,因为我们不会实现
onClick函数。首先,我们需要确保它不是以expanded开始的,这意味着用户需要点击来展开菜单,并且它将响应地变为true:@Composablefun OurMenu(){var expanded by remember { mutableStateOf(false) }val menuItems = listOf("Item 1", "Item 2", "Item 3","Item 4") }
对于我们的菜单项,我们将只展示四个项目。
-
接下来,我们必须创建一个
Box(),将其对齐到中心,并响应修改器中的expanded状态。我们还需要添加一个图标ArrowDropDown,以通知用户他们可以点击,并且我们还有更多项目:Box(contentAlignment = Alignment.Center,modifier = Modifier.clickable { expanded = true }) {Text(stringResource(id = R.string.menu))Icon(imageVector = Icons.Default.ArrowDropDown,contentDescription = stringResource(id = R.string.menu_drop_down),modifier = Modifier.align(Alignment.CenterEnd))} -
最后,我们需要添加
DropDownMenu,当点击图标时它会展开,我们将onDismissRequest设置为false;当用户请求关闭菜单时调用,例如,当轻触时。 -
然后,我们将显示我们的项目在
DropdownMenuItem函数上,以便当它被点击时执行操作。在我们的示例中,我们不做任何事情:DropdownMenu(expanded = expanded,onDismissRequest = { expanded = false }) {menuItems.forEachIndexed { index, item ->DropdownMenuItem(text = { Text(item)},onClick = { /*TODO*/ })}} -
最后,当你运行应用程序时,你应该看到一个带有可点击项目的菜单下拉菜单。

图 11.1 – 下拉菜单
重要提示
你可以根据需要和风格自定义下拉菜单。
它是如何工作的…
在我们的示例中,我们首先声明一个可变状态变量expanded,用于跟踪菜单是否展开,以及另一个可变状态变量selectedMenuItem,用于跟踪当前选定的菜单项。
我们还定义了一个menuItems列表,这有助于我们知道菜单列表。
在我们的Box中,我们定义了一个Column(),它包含菜单标题、一个可点击的Box,用于显示选定的菜单项,以及一个DropdownMenu,当展开时显示菜单项。我们使用Box和DropdownMenu组件来定位菜单项相对于可点击的Box的位置。
DropDownMenu接受一些输入,正如你在图 11.2中看到的,这有助于你根据需要自定义下拉菜单。

图 11.2 – DropDownMenuItem 输入参数
最后,我们使用DropdownMenuItem组件来显示每个菜单项,并在点击菜单项时更新selectedMenuItem和expanded变量。
实现 Toast/Snackbar 来提醒用户
在 Android 开发中,Toast/Snackbar 是一种小型的弹出消息,通常出现在屏幕底部。它用于向用户提供简短的信息或反馈。这是一种在不打断用户工作流程的情况下向用户显示短消息的简单方式。
准备中
在本节中,我们将对我们在DropMenuItem中创建的项目做出反应,因此你必须遵循之前的食谱才能继续本食谱。
如何实现…
执行以下步骤以在点击项目时添加消息,告诉用户他们已选择特定项目:
-
在 Android 中创建 Toast 非常简单;你可以简单地使用 Android SDK 提供的
Toast类来完成。你可以通过调用Toast类的静态makeText()方法来创建一个新的Toast对象,并传递上下文、消息和 Toast 的持续时间。 -
一旦创建了
Toast对象,你可以调用show()方法来在屏幕上显示它:Toast.makeText(context, "Hello, Android!", Toast.LENGTH_SHORT).show(); -
然而,在 Jetpack Compose 中,要显示 Toast,我们需要使用
coroutineScope,但请注意,在所有情况下显示 Toast 并不一定需要协程,在我们的示例中,我们将使用launch函数来启动一个显示 Toast 消息的协程:val coroutineScope = rememberCoroutine()coroutineScope.launch {Toast.makeText(context,"Selected item: $item",Toast.LENGTH_SHORT).show()} -
要连接
onClick(),请参阅技术要求部分的代码以获取完整代码。最后,当你运行应用程序时,你应该看到一条包含所选项目作为消息的 Toast 消息。

图 11.3 – 显示的 Toast 消息
-
在以下示例中,我们将使用 Snackbar 而不是 Toast:
coroutineScope.launch {Toast.makeText(context,"Selected item: $item",Toast.LENGTH_SHORT).show()}
在 Jetpack Compose 中,使用 Snackbar 的方式有多种;你可以用它与Scaffold一起使用,也可以不使用它。然而,建议使用带有Scaffold的 Snackbar。在我们的示例中,我们将使用Scaffold:
menuItems.forEachIndexed { index, item ->
DropdownMenuItem(
text = { Text(item) },
onClick = {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = "Selected Item: $item"
)
}
}
)
}
- 最后,当你运行应用程序时,你将看到一条包含所选项目文本和所选项目的 Snackbar。
Toast和Snackbar都服务于相同的目的。

图 11.4 – 显示的 Snackbar 消息
它是如何工作的…
Toasts 和 Snackbars 是两种可以在 Android 应用程序中使用的通知消息类型,用于向用户显示短消息。
Toast 和 Snackbar 之间的主要区别如下:
-
Toast 消息通常在屏幕中央显示,而 Snackbar 消息通常在屏幕底部显示。
-
Toast 消息通常持续很短的时间,通常约为 2-3 秒,然后自动消失。Snackbar 消息通常显示更长时间,并且可以通过用户滑动或点击按钮来关闭。
-
Toast 消息是非交互式的,用户无法将其取消。另一方面,Snackbar 消息可以包含操作按钮,允许用户对消息做出特定响应。
-
Toast 消息通常是出现在小弹出窗口中的纯文本消息。另一方面,Snackbar 消息可以设计成包含图标、颜色和其他视觉元素,使其更具视觉吸引力和信息性。
我们修改了DropdownMenuItem组件的onClick回调,以启动一个显示Toast消息的协程,使用Toast.makeText函数。我们通过LocalContext.current传入当前上下文,它获取当前上下文函数和要显示在Toast消息中的文本作为字符串。您还应该指定Toast的持续时间,可以是Short或Long。
当使用Snackbar时,我们创建一个SnackbarHostState,并将其传递给我们的Scaffold。我们的可组合组件包括一个snackbarHost参数,指定在显示时显示 Snackbar 的函数。SnackbarHost函数接受两个参数:snackbarData,它包含 Snackbar 的消息和操作按钮,以及一个 lambda,指定如何创建Snackbar可组合组件。
在 Android 中,Scaffold是一个预构建的 UI 组件或布局,它为构建屏幕和 UI 组件提供了基本结构。术语Scaffold通常与模板或样板术语互换使用。
Scaffold 在 Android 应用程序开发框架(如 Flutter 或 Jetpack Compose)中很常见,用于为创建新屏幕或 UI 组件提供起点。
例如,Android 中的 Material Design 库提供了几个预构建的 scaffolds,用于常见的屏幕类型,如登录屏幕、设置屏幕或列表屏幕。这些 scaffolds 提供了一致的外观和感觉,并有助于确保应用程序遵循 Material Design 指南。
使用 scaffolds 可以通过提供构建屏幕和 UI 组件的起点来节省应用程序开发的时间和精力。然而,开发者也可以自定义和扩展 scaffolds 以满足他们应用程序的特定要求。
创建警告对话框
弹出警告对话框是 Android 应用程序 UI 的一个基本组件。它们用于向用户显示重要消息、通知和警告。使用弹出警告对话框在 Android 中至关重要的原因如下:
-
它们可以帮助突出显示用户需要知道的重要信息。例如,如果用户即将执行可能导致数据丢失或损坏的操作,应用程序可以在弹出警告对话框中显示警告消息,以确保用户知道后果。
-
它们可以用来获取用户对必要操作的确认,例如删除文件或购买某物。通过显示一个请求用户确认操作的消息,应用程序可以帮助防止意外或不受欢迎的操作。
-
它们可以用来向用户提供反馈,例如让他们知道一个操作是成功还是失败。例如,如果用户尝试保存一个已存在的文件,应用程序可以显示一个弹出警告对话框,告知用户问题并提供如何继续的建议。
-
它们可以通过提供精确且简洁的消息来帮助用户理解应用程序内部正在发生的事情,从而帮助提高应用程序的整体用户体验。
警告对话框是 Android 应用设计的重要组成部分。它们可以用来突出显示重要信息、获取用户确认、提供反馈,并改善整体用户体验。
准备工作
我们将继续使用同一个项目,所以请确保你已经完成了之前的菜谱。为了跟上,请确保你也获得了 技术要求 部分的代码。
如何做…
按照以下步骤创建一个警告对话框:
-
让我们先创建一个 Kotlin 文件,并将其命名为
AlertDialogDemo.kt。 -
在
AlertDialogDemo中创建一个可组合函数,并命名为AlertDialogExample():@Composablefun AlertDialogExample() {...}
实现 AlertDialog() 有不同的方法;在我们的例子中,我们将创建一个按钮,点击该按钮将启动 dialog()。
-
然后,我们必须向
AlertDialog添加标题和文本属性。我们使用Text组件来定义标题和消息文本,并根据需要设置fontWeight和color属性:AlertDialog(onDismissRequest = { dialog.value = false },title = {Text(text = stringResource(id = R.string.title_message),fontWeight = FontWeight.Bold,color = Color.Black)},text = {Text(text = stringResource(id = R.string.body),color = Color.Gray)},. . . -
然后,我们将向
AlertDialog添加confirmButton和dismissButton属性。我们使用Button组件来定义按钮,并将onClick属性设置为 lambda 表达式,当按钮被点击时将执行适当的操作:confirmButton = {Button(onClick = {/*TODO*/ }) {Text(text = stringResource(id = R.string.ok))}},dismissButton = {Button(onClick = { dialog.value = false }) {Text(text = stringResource(id = R.string.cancel))}},)}. . . -
最后,当你运行应用程序时,你将看到一个带有标题、消息和两个操作调用(确认或取消)的对话框。

图 11.5 – 警告对话框
它是如何工作的…
在我们的例子中,我们首先创建一个名为 openDialog 的 mutableStateOf 变量,它具有一个布尔值,表示对话框是否应该显示。然后我们使用这个变量,通过一个 if 语句有条件地使用 AlertDialog 组件来渲染。
AlertDialog 组件有几个我们可以设置的属性,包括标题、文本、confirmButton 和 dismissButton。我们还可以使用 backgroundColor 和 contentColor 属性设置背景和内容颜色。
最后,我们添加一个 Button 组件,当点击时切换 openDialog 变量,导致对话框显示或隐藏。
创建底部对话框
底部对话框是 Android 中流行的设计模式,因为它们提供了一种简单且高效的方式来显示上下文信息或操作,而不会占用太多屏幕空间。在开发 Android 应用程序时,以下是为什么底部对话框被认为是好的选择的一些原因:
-
它们被设计为从屏幕底部滑动出来,占用最小的屏幕空间。这使得它们在显示补充信息或操作而不让用户感到压倒性时成为一个极好的选择。
-
它们通常用于提供与当前上下文相关的附加信息,例如针对当前视图的特定选项或设置。
-
由于底部面板对话框被设计为从屏幕底部滑动出来,它们给用户一种对交互的控制感。用户可以通过向下滑动对话框或点击对话框外部轻松地关闭对话框。
总体而言,底部面板对话框是一个极好的选择,因为它们提供了一种节省空间、上下文相关且用户友好的方式来向用户显示附加信息或操作。
准备工作
我们将继续使用相同的工程,所以请确保你已经完成了之前的食谱。
如何实现...
使用相同的工程,按照以下步骤构建你的第一个BottomSheet对话框:
-
让我们从创建一个名为
BottomSheetDemo.kt的 Kotlin 文件开始。 -
在
BottomSheetDemo内部创建一个可组合函数,并命名为BottomSheetExample():@Composablefun BottomSheetExample() {...} -
由于我们正在使用 Material 3,我们将承认大多数 API 仍然是实验性的,这意味着很多东西都可能改变。让我们为我们的底部面板对话框创建状态:
val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
skipPartiallyExpanded布尔值检查如果面板足够高,是否应该跳过部分展开状态。
-
现在我们需要继续创建我们的
ModalBottomSheet(),它接受几个参数;我们只需使用onDismiss和sheetState:ModalBottomSheet(onDismissRequest = { openBottomSheet = false },sheetState = bottomSheetState,) {Column(Modifier.fillMaxWidth(),horizontalAlignment =Alignment.CenterHorizontally) {Button(onClick = {coroutineScope.launch {bottomSheetState.hide() }.invokeOnCompletion {if (!bottomSheetState.isVisible) {openBottomSheet = false}}}) {Text(text = stringResource(id = R.string.content))}. . . -
在“技术要求”部分之后,现在让我们继续实施两个按钮并获取整个代码。
-
最后,运行应用,你将已经实现了你的底部面板对话框。注意你可能需要根据你的需求添加更多逻辑。

图 11.6 – 底部面板对话框
它是如何工作的...
在我们的例子中,ModalBottomSheet被用作移动端内联菜单或简单对话框的替代品,尤其是在提供长列表的操作项或项目需要更长的描述和图标时。
就像 Android 中的任何其他对话框一样,模态底部面板出现在应用内容之前。
还有更多...
通过访问以下链接了解更多关于底部面板对话框和可用实验性功能的信息:m3.material.io/components/bottom-sheets/overview。
创建单选按钮
在现代 Android 开发中,RadioButton的使用方式与传统 Android 开发中的使用方式相似。RadioButton允许用户从互斥选项列表中选择一个项目,这意味着一次只能选择一个选项。
在 Jetpack Compose 中,RadioButton 是 Material Design 库的一部分,可以通过导入 Androidx.compose.Material.RadioButton 包来使用。要创建一组 RadioButton 实例,你通常会使用 RadioGroup 组合组件,它也是 Material Design 库的一部分。
RadioGroup 组合组件接受一个选项列表作为输入,以及一个选中选项和一个当选中选项改变时被调用的回调。可以使用 RadioButton 组合组件创建单个 RadioButton 实例,并将其作为 RadioGroup 的子组件添加。
准备工作
在这个菜谱中,我们将继续使用相同的工程,所以请确保你已经完成了之前的菜谱。
如何实现...
使用相同的工程,按照以下步骤构建你的第一个 RadioButton:
-
首先,创建一个 Kotlin 文件,并将其命名为
RadioButtonDemo.kt。 -
在
RadioButtonDemo中创建一个组合函数,并命名为RadioButtonExample():@Composablefun RadioButtonExample() {...} -
我们将开始创建一个选择列表,在我们的例子中,我们可以使用水果,然后跟踪选中的选择:
val choices = listOf("Mangoes", "Avocado", "Oranges")var selectedOption by remember {mutableStateOf(choices[0]) } -
由于我们使用的是 Google 提供的
RadioButton组合组件,根据你的需求,你可以按自己的喜好自定义RadioButton:Row(Modifier.fillMaxWidth(),verticalAlignment = Alignment.CenterVertically) {RadioButton(selected = selectedOption == option,onClick = { selectedOption = option })Text(text = option,style = MaterialTheme.typography.body1,modifier = Modifier.padding(start = 6.dp))} -
最后,当我们运行应用时,你应该能看到类似于 图 11**.7 的内容。

图 11.7 – 单选按钮
它是如何工作的...
在我们的例子中,我们创建了一个 RadioButtonExample() 组合组件,它显示了一组具有以下选择的 RadioButton 实例 – 芒果、鳄梨 和 橙子。
使用 remember 组合组件将选中的选项存储在 selectedOption 变量中,以在重新组合之间保持状态。每个 RadioButton 都被包裹在一个 Row(){...} 中,包括选择文本,并且 RadioButton 的选中属性根据当前选项是否与选中选项匹配来设置。
当用户点击 RadioButton 时,selectedOption 变量会更新为新选中的选项。
创建 FAB/扩展 FAB
FAB 是一个圆形按钮,看起来像是在 Android 应用的 UI 上 浮动。它通常用来表示应用的主要操作,并放置在易于访问的可见位置。
扩展 FAB 是 Android 中 FAB 的一个变体,为用户提供更多选项和功能。扩展 FAB 是一个可以显示文本和图标的矩形按钮,在按下时展开成相关操作的菜单。
准备工作
在这个菜谱中,我们将继续使用相同的工程,所以请确保你已经完成了之前的菜谱。
如何实现...
使用相同的工程,按照以下步骤构建 FAB 和扩展 FAB:
-
首先,创建一个 Kotlin 文件,并将其命名为
ActionComponentsDemo.kt。 -
在
ActionComponentsDemo中创建一个组合函数,并命名为ActionComponentsExample():@Composablefun RadioButtonExample() {...} -
在
ActionComponentsDemo中创建一个组合器函数,并调用它ActionComponentsExample()。 -
我们将首先创建一个 FAB。
FloatingActionButton是一个圆形按钮,它浮在 UI 之上,通常用于触发应用中的主要操作。你可以在 Jetpack Compose 中使用FloatingActionButton创建一个FloatingActionButton组合器:FloatingActionButton(onClick = { /* do something */ }) {Icon(Icons.Default.Add, contentDescription =stringResource(id = R.string.add))}
ExtendedFloatingActionButton 是一个带有附加文本或图标的 FloatingActionButton 实例。它通常用于应用中的次要操作。
-
在 Jetpack Compose 中创建
ExtendedFloatingActionButton,你可以使用ExtendedFloatingActionButton组合器。此代码使用文本标签"Add item"和一个加号图标创建它。onClick参数指定当按钮被点击时执行的操作:ExtendedFloatingActionButton(text = { Text("Add item") },onClick = { /* do something */ },icon = {Icon(Icons.Default.Add,contentDescription = stringResource(id = R.string.add))}) -
当你运行应用时,你应该看到两个按钮,一个浮动按钮和一个扩展按钮:

图 11.8 – FAB 和扩展 FAB
它是如何工作的…
扩展 FAB 与 FAB 类似,但为文本和/或图标提供了额外的空间。它通常用于提供更多关于将要执行的操作的上下文或信息。例如,一个 扩展浮动操作按钮 (EFAB) 可能会显示文本 创建新预算 并附带一个钢笔图标。
两个 FAB 和 EFAB 都是 Material Design 指南的一部分,并在 Jetpack Compose 中作为组件提供。
还有更多 …
要了解更多关于 Material 3 组件和指南的信息,请参阅以下链接:m3.Material.io/ 和 developer.android.com/reference/kotlin/androidx/compose/material3/package-summary。
第十二章:Android Studio 开发技巧与窍门
作为一名 Android 开发者,编写代码不应只是最终目标;而是理解如何找到您应用程序中的问题,使用格式化技巧更快地导航代码库,以及其他技能都非常有用。
编程过程包括大量的协作。这可以是在同行代码审查、结对编程或调试问题。在这些情况下,如果您能够快速行动,例如在提交拉取请求之前调试或格式化代码,那就非常方便。在本章中,您将学习到许多优秀的 Git 和 Android Studio 技巧和窍门,以帮助您在日常开发中更加得心应手。
在本章中,我们将涵盖以下菜谱:
-
分析 Android 应用程序的重要性
-
快速的 Android 短路键来加速您的开发
-
您需要了解的 JetBrains Toolbox 和基本插件
-
调试您的代码
-
如何提取方法和方法参数
-
理解 Git 基础知识
技术要求
本章的完整源代码可以在 github.com/PacktPublishing/Modern-Android-13-Development-Cookbook 找到。
分析 Android 应用程序的重要性
在 Android 中,分析是指分析应用程序性能以识别其优势和劣势的过程。分析您的 Android 应用程序对于以下原因至关重要:
-
它帮助您识别性能瓶颈,如缓慢的代码、内存泄漏和过度的 CPU 使用。这些知识可以帮助您优化代码,使您的应用程序运行得更高效。
-
它有助于提升用户体验。性能不佳的应用程序可能导致用户沮丧和负面评价。通过分析您的应用程序并优化其性能,您可以提供更好的用户体验,从而提高用户参与度和正面评价。
-
它有助于节省时间和金钱。在问题变得复杂之前尽早修复性能问题要比后来尝试修复它们容易得多,也便宜得多。
因此,在本菜谱中,我们将探讨为什么分析您的 Android 应用程序是必要的,并查看最佳实践和技巧。
准备工作
对于这个菜谱,我们将探讨如何使用分析器来分析我们的 Android 应用程序。您不需要创建一个新的项目;您可以直接使用现有的项目来跟随操作。
如何操作...
按照以下步骤开始使用分析器:
- 对于本章,我们使用的是 Android Studio Flamingo 2022.2.1 补丁 1。在您的 Android Studio 中,转到 视图 | 工具窗口 | 分析器 并点击分析器,它将启动。
注意
要查看任何活动,您需要启动您的模拟器。

图 12.1 – Android Studio 中的分析器
- 你还可以导航到底部菜单选项,靠近App Inspection;参见图 12.2中的绿色箭头,它指示你可以从另一个地方开始 Profiler。读箭头表示连接以可视化配置文件的模拟器。

图 12.2 – 在 Android Studio 中启动 Profiler
- 当你的 Profiler 开始运行时,这意味着它已连接到你的应用程序,你应该能看到CPU、MEMORY和ENERGY。根据你的应用程序资源,数据可能与你在图 12.3中看到的不同。

图 12.3 – Profiler 正在运行
- 你可以做很多事情,例如简单地记录所有方法跟踪,查看你的资源是如何被利用的,以及分析火焰图。

图 12.4 – 利用 Profiler 的不同方式
- CPU 火焰图是一种性能可视化类型,它显示了程序随时间执行的层次结构。它通常在图表顶部包含一个时间线,函数调用以垂直堆叠的矩形表示。
根据颜色,每个矩形的宽度代表函数调用的持续时间,矩形的颜色代表该函数的 CPU 使用情况。图表允许 Android 开发者快速识别占用最多 CPU 时间的函数,并有助于他们确定调试和优化性能的位置,如图图 12.5所示。

图 12.5 – CPU 火焰图
换句话说,应用程序堆是一个专门、固定、有限的内存池,分配给你的应用程序。
注意
如果你的应用程序达到堆容量并尝试分配任何额外内存,你将收到一个OutOfMemoryError消息。
- 最后,内存泄漏是一种软件错误,其中程序或应用程序反复未能释放它不再需要的内存,或者你的垃圾收集器没有按预期工作。这可能导致程序随着时间的推移逐渐消耗越来越多的内存,最终导致性能下降或应用程序崩溃。
它是如何工作的...
如果应用程序响应缓慢、动画不流畅、经常冻结或消耗大量电力,则性能不佳。修复性能问题涉及识别应用程序未优化资源使用的地方,例如 CPU、内存、图形、网络或设备电池。
Android Studio 提供了几个工具来帮助开发者发现和可视化潜在问题:
-
CPU Profiler,它有助于跟踪运行时性能问题
-
内存 Profiler,它有助于跟踪任何内存分配
-
网络 Profiler,它监控网络流量使用
-
能量 Profiler,它跟踪能源使用,这可能导致电池耗尽
您可以通过查看图 12.6 来了解 Android 中的性能分析。

图 12.6 – 性能源模型(android.developer.com)
查看更多...
通过以下链接了解 OutOfMemoryError 的更多信息:developer.Android.com/reference/java/lang/OutOfMemoryError。您也可以通过此链接了解有关性能分析的信息:developer.android.com/studio/profile。
快速 Android 快捷键,让您的开发更高效
快捷键可以帮助开发者通过使他们的工作更快更高效,让他们能够专注于编写代码和解决问题,而不是在菜单和工具栏中导航。快捷键可以帮助自动化重复性任务,如格式化代码、重命名变量或在不同文件之间导航,从而为开发者节省时间和精力,让他们有更多时间从事更有意义的工作。
此外,当开发者在不同工具和应用程序中使用相同的快捷键时,可以帮助保持他们工作流程的一致性,并减少因意外使用错误命令或工具而造成的错误风险。对于有残疾或身体限制的开发者来说,使用快捷键可能比使用鼠标或触摸板与软件交互更方便。
准备工作
这实际上不是一个食谱,而是一份有用的快捷键列表,我们将查看在笔记本电脑上使用最广泛的 Windows 和 Mac 快捷键。
如何做到这一点...
这里列出了一些在 Mac 和 Windows 操作系统上使用的 Android Studio 快捷键,可以帮助您加快工作流程:
-
这里有一些基本的导航快捷键:
-
打开类或文件:Ctrl + N(Windows)或 Cmd + O(Mac)
-
在项目范围内查找文本:Ctrl + Shift + F(Windows)或 Cmd + Shift + F(Mac)
-
打开最近文件弹出窗口:Ctrl + E(Windows)或 Cmd + E(Mac)
-
搜索并执行任何操作或命令:Ctrl + Shift + A(Windows)或 Cmd + Shift + A(Mac)
-
-
代码编辑快捷键:
-
代码补全建议:Ctrl + 空格键(Windows 和 Mac)
-
完成当前语句:Ctrl + Shift + Enter(Windows)或 Cmd + Shift + Enter(Mac)
-
复制当前行:Ctrl + D(Windows)和 Cmd + D(Mac)
-
剪切当前行:Ctrl + X(Windows)和 Cmd + X(Mac)
-
移动当前行上下:Ctrl + Shift + 上/下箭头(Windows)或 Cmd + Shift + 上/下箭头(Mac)
-
-
重构快捷键:
-
从当前代码块提取方法:Ctrl + Alt + M(Windows)和 Cmd + Option + M(Mac)
-
从当前代码块提取变量:Ctrl + Alt + V(Windows)和 Cmd + Option + V(Mac)
-
从当前代码块中提取字段:Ctrl + Alt + F(Windows)和 Cmd + Option + F(Mac)
-
重命名类、方法或变量:Shift + F6(Windows)和 Fn + Shift + F6(Mac)
-
-
调试快捷键:
-
跳过到下一行代码:F8(Windows 和 Mac)
-
进入当前代码行:F7(Windows 和 Mac)
-
退出当前方法:Shift + F8(Windows 和 Mac)
-
切换当前代码行的断点:Ctrl + F8(Windows)或 Cmd + F8(Mac)
-
-
杂项快捷键:
-
运行应用:Ctrl + Shift + F10(Windows)或 Cmd + Shift + F10(Mac)
-
调试应用:Ctrl + Shift + F9(Windows)或 Cmd + Shift + F9(Mac)
-
重要提示
请注意,某些快捷键可能因您的具体键盘布局或操作系统首选项而有所不同。此外,请记住,Android Studio 还有更多快捷键可供使用,因此请务必探索键映射设置,以找到可以使您的开发工作流程更高效的额外快捷键。
它是如何工作的…
快捷键可以是寻求简化工作流程、提高生产力和减少开发过程中错误和重复性劳损风险的开发者的强大工具。
您需要了解的 JetBrains Toolbox 和基本插件
JetBrains Toolbox 是一种软件管理工具,允许开发者在计算机上管理和安装 JetBrains IDE 和相关工具。JetBrains 是一家软件公司,为各种编程语言(如 Java、Kotlin、Python、Ruby 和 JavaScript)提供强大的 IDE(特别是 IntelliJ)。换句话说,插件只是实现了插件接口的任何类。
下面是一些 JetBrains Toolbox 功能以及您应该尝试使用它的原因:
-
您可以从 Toolbox 轻松下载和安装任何 JetBrains IDE,并确保您的计算机上安装了 IDE 的最新版本。
-
Toolbox 会自动检查更新,并保持所有已安装的 JetBrains IDE 和插件最新,这意味着如果您的当前 Android Studio 不稳定,您可以回滚到更稳定的版本。
-
您可以从 Toolbox 管理您的 JetBrains 许可证并激活/停用它们。
-
Toolbox 提供了一种通过创建可共享链接与团队成员共享项目的方法。
-
Toolbox 与 JetBrains 服务(如 JetBrains 账户和 JetBrains Space)集成。
准备工作
这不是真正的食谱,而是一份有用的插件列表,我们将探讨一些对开发者有用的插件。
如何操作…
让我们来看看我们如何利用 Gradle 进行日常 Android 开发。
-
Gradle 是一种构建自动化工具,用于构建和部署 Android 应用。它可以帮助您管理依赖项、生成 APK 文件和运行测试。
-
ADB 插件为 Android 调试桥(ADB)提供了一个图形用户界面,ADB 是一种命令行工具,可以与 Android 设备或模拟器交互。
-
Live Templates 允许您快速插入常用代码片段。例如,您可以创建一个用于吐司消息的实时模板,然后只需输入快捷键并按 Tab 键即可插入代码。要创建实时模板,请转到 Android Studio | 设置 | 编辑器 | 实时模板。

图 12.7 – 如何访问 Live Templates
-
Android Studio 的代码补全功能可以为您节省大量时间。当您键入时,Android Studio 将为您代码的完成建议提供可能的补全。使用 Tab 键接受建议。
-
调试器是查找和修复代码中错误的有力工具。您将在 调试您的代码 食谱中学习如何使用调试器逐步执行代码并查看每个步骤中发生的情况。
-
Android Studio 的布局编辑器允许您轻松创建和修改应用程序的用户界面。您可以使用布局编辑器将用户界面组件拖放到布局中,并轻松修改它们的属性。
-
资源管理器允许您轻松管理应用程序的资源,例如图像、字符串和颜色。您可以使用资源管理器添加和修改资源,并在代码中轻松引用它们。
-
Android Studio 支持各种插件,可以扩展其功能。您也可以轻松搜索插件以帮助您完成诸如生成代码或管理依赖项等任务。
-
LeakCanary 是一个内存泄漏检测库,可以帮助您识别和修复应用程序中的内存泄漏。这有助于开发者查找泄漏。
-
Firebase 是一套移动开发工具,可用于向您的应用程序添加功能,如身份验证、分析和云消息。当您作为独立开发者构建第一个项目时,您可以利用这一点。
工作原理…
您可以通过简单地转到 AndroidStudio | 设置 | 快捷键映射 并使用下拉菜单查看可用的快捷键映射,如图 图 12.8 所示。

图 12.8 – 快捷键映射
重要提示
通过以下链接了解 Android Studio 的最新版本以及提供的功能:developer.android.com/studio/releases。
调试您的代码
作为一名 Android 开发者,调试是软件开发过程中的一个重要部分,因为它有助于识别和修复代码中的错误。在调试时,您可以快速识别和修复可能导致应用程序崩溃、行为异常或产生不正确结果的代码错误或缺陷。
在本食谱中,我们将探讨如何轻松添加断点并调试您的代码。
准备工作
要开始使用此食谱,您需要打开一个项目并在您的模拟器上运行该项目。您不需要创建新项目,可以使用 GUIAlert 项目。
如何操作…
我们将尝试调试我们的代码,并确保当我们点击菜单中的项目时,我们选择了正确的项目。例如,如果我们选择项目 2,当我们评估该项目时,我们应该看到结果为2:
- 首先,你需要确保你的应用正在运行;然后,点击图 12.9中显示的图标。

图 12.9 – 调试器图标
- 当你点击图 12.9中显示的图标时,会出现一个弹出屏幕,这意味着你将把运行中的应用程序附加到调试器上。

图 12.10 – 将调试器附加到包上的选项
- 现在,回到代码库并添加断点。你通过点击你希望测试逻辑的行号所在的侧边栏来添加断点。

图 12.11 – 断点
- 如果你点击项目时应用正在运行,比如选项 1,调试器将显示活动状态,这意味着我们在其上放置断点的行已被命中。然后,会出现一个带有控制按钮的弹出窗口。

图 12.12 – 调试活动状态
- 你可以使用左侧的绿色按钮来运行,红色方块按钮来停止。你也可以使用Step Over、Step Into、Force Step Into、Step Out、Drop Frame、Run to Cursor和Evaluate Expression…。在我们的例子中,我们将使用Evaluate Expression…。

图 12.13 – 调试按钮步骤
- 有时,你可能会有额外的断点,这可能会减慢进程。在这种情况下,你可以使用图 12.14中用红色箭头指出的选项来查看所有断点。

图 12.14 – 跟踪所有断点
- 最后,当应用仍然处于调试模式时,打开
Item。根据当前项,你应该看到显示的数字。

图 12.15 – 当我们评估断点时,当前选中的项
它是如何工作的…
Android Studio 为我们开发者提供了一个强大的调试器。要使用 Android Studio 调试应用程序,你首先需要在设备或模拟器上构建和部署应用程序,然后将调试器附加到运行进程。这也是一项需要学习和练习的技能。因此,了解你如何使用日志或断点来调试应用程序,这会很有帮助。
重要提示
关于调试,还有更多内容需要学习,需要多个菜谱来涵盖这个主题。点击此链接了解更多信息:developer.android.com/studio/debug。
如何提取方法和方法参数
提取方法和方法参数可能会向您的代码中添加额外的导入。这是因为当您提取方法或参数时,之前在方法或参数内部的方法被移动到单独的方法中。如果这段代码依赖于尚未导入到您的代码中的其他类或方法,提取过程可能会自动添加必要的导入语句到您的文件中。
例如,假设您有一个包含执行某些计算并返回结果的 Kotlin 类的方法。此方法依赖于在另一个包中定义的辅助类,并且您仍然需要将此类导入到您的代码中。如果您决定将方法提取到同一或不同类中的特定方法,提取过程可能会添加一个import语句用于辅助类,以便提取方法内部的代码可以引用辅助类。
类似地,当您提取方法参数时,提取过程可能需要包括添加导入语句,以确保在参数类型中使用的任何类或接口都能正确解析。
准备工作
您不需要创建任何项目来遵循这个食谱。
如何操作…
要在 Android 中提取方法和方法参数,您可以按照以下步骤操作:
-
打开您想要提取方法和参数的 Kotlin 文件。
-
识别包含您想要提取的方法和参数的类。
-
将您的光标放在类声明内部,然后右键单击以打开上下文菜单。
-
从上下文菜单中选择重构选项,然后从子菜单中选择提取。
-
在提取子菜单中,您将看到提取方法或参数的选项。选择与您想要提取的元素相匹配的选项。
-
按照上下文中的提取向导中的提示来配置提取过程。您可能需要为提取的元素提供名称,指定元素的范围,或者根据您正在提取的元素配置其他设置。
-
一旦在提取过程中配置完毕,点击完成以从您的代码中提取元素。
它是如何工作的…
在方法或参数提取期间添加导入是重构过程的正常部分,有助于确保您的代码保持良好的组织并易于维护。
理解 Git 基础知识
这个食谱旨在帮助任何可能偶然发现这本书的新开发者。Git是一个流行的版本控制系统,允许开发者管理和跟踪代码库的变化。
这里有一些基本概念需要理解:
-
仓库是一组文件和文件夹的集合,Git 正在跟踪这些文件和文件夹。它也被称为repo。这是最常见的术语。
-
提交是对仓库中更改的快照。每个提交都有一个唯一的标识符,包含有关更改的信息,例如作者、日期以及描述更改的消息。
-
分支是一个独立的开发线,允许开发者同时工作在不同的功能或项目的版本上。它就像是一个仓库的平行宇宙。
-
在开发过程中,合并你的工作指的是将一个分支的变化合并到另一个分支中。通常在功能完成并准备好集成到主分支时使用。
-
拉取请求是 GitHub 的一个功能,允许开发者提出对仓库的更改,并请求将这些更改合并到主分支。它包括更改的描述以及任何支持性文档或测试。
-
克隆是在你的本地机器上创建仓库的副本。
-
推送是将变化从你的本地机器发送到远程仓库的过程,例如 GitHub 或 GitLab。
-
拉取是从远程仓库下载变化到本地机器的过程。
通过理解这些基本概念,你可以有效地使用 Git 来管理你的代码库并与其他开发者协作。
准备工作
我们在这里不会遵循食谱,而是看看你可以利用哪些 Git 命令来使协作更简单。
如何操作…
这里是一些最常用的 Git 命令:
-
要在当前目录中初始化一个新的 Git 仓库,你可以简单地做以下操作:
$ git init -
当你想将更改添加到暂存区时,你可以简单地使用
git add:$ git add file.txt -
在向仓库提交更改时,只需使用以下命令:
$ git commit -m "message" -
最重要的是,当你开始协作时,能够克隆项目;你可以简单地运行以下命令:
$ git clone git@github.com:PacktPublishing/Modern-Android-13-Development-Cookbook.git -
当你想从远程仓库拉取变化到本地仓库时,只需使用以下命令:
$ git pull origin main -
你也可以使用以下命令将本地仓库的变化推送到远程仓库:
$ git push origin main -
使用
git branch列出所有本地分支:$ git branch -
以下命令切换到不同的分支:
$ git checkout branch_name -
使用以下命令检出新的分支:
$ git checkout -b branch_name -
使用以下命令将一个分支的变化合并到另一个分支中。注意,你也可以使用
rebase;这取决于组织的偏好:$ git merge branch_name
这些只是最常用的 Git 命令中的一部分。还有很多其他的 Git 命令和选项可供选择,因此探索 Git 文档以了解更多信息是值得的。
它是如何工作的…
Git 是一个分布式版本控制系统,允许用户跟踪代码随时间的变化。以下是 Git 工作的高级概述。
Git 不仅仅存储你对代码所做的更改;它实际上存储了你在不同时间点的整个项目的快照。每个快照代表项目在特定时刻的状态。它以树状结构存储你的代码,每个项目快照由一个提交对象表示。
每个提交对象都指向它所代表的项目的快照以及它之前的提交对象。它还使用一个唯一的指针HEAD来跟踪当前分支和该分支上最近的提交。
当你提交一个新的更改时,Git 会更新 HEAD 指针以指向新的提交。此外,Git 中的每个提交都有一个唯一的哈希值,这是一个由字母和数字组成的 40 个字符的字符串。这个哈希值是根据提交的内容以及它所指向的任何先前提交的哈希值生成的。
由于 Git 将你的项目快照存储在本地计算机上,你可以离线工作,同时仍然可以向项目提交更改。当你准备好分享你的更改时,你可以将它们推送到远程仓库。
这些只是 Git 工作背后的几个关键概念。Git 是一个功能强大且灵活的工具,具有许多高级功能,因此值得了解更多关于它是如何工作的信息。


浙公网安备 33010602011771号