精通-Kotlin-全-

精通 Kotlin(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Kotlin 是由 JetBrains 创建的一种编程语言,它在 Java 虚拟机JVM)上运行。它旨在解决诸如冗长、空指针异常、并发挑战以及在 Java 中发现的缺乏功能支持等问题。Kotlin 提供了一种更现代、更简洁的编程方法,同时仍然与现有的 Java 代码和库兼容。谷歌将 Kotlin 识别为构建 Android 应用程序的主要语言,这导致了支持开发者的重大努力。本书采用行业导向的方法,为您在任意公司担任 Android 开发者的角色做好准备。它遵循谷歌 Android 团队推荐的最佳实践,并基于实践经验提供见解。

通过实际示例,本书引导您使用 Kotlin 开发 Android 应用程序,传授成为熟练 Android 开发者所必需的实践经验。内容包括使用 Jetpack Compose 构建 App、融入 Material Design 3 以实现个性化体验,以及在 MVVM 架构中构建 App。指南进一步展示了如何通过依赖注入、使用 Room 等 Jetpack 库进行本地数据持久化、实施调试技术等功能来增强您的应用程序架构。它还涵盖了测试、使用 Ktlint 和 Detekt 等工具识别代码问题,并指导您完成 Google Play 商店的发布流程。探索通过 GitHub Actions 自动化连续发布以及使用 Firebase App Distribution 分发测试构建。此外,本书还讨论了应用程序改进策略,包括崩溃报告工具、提高用户参与度的技巧以及确保应用程序安全性的见解。

本书面向对象

本书面向有志成为 Android 开发者或正在使用 Java 进行 Android 开发的 Android 开发者,因为他们将学习如何从头开始使用 Kotlin 构建 Android 应用程序,了解 Android 中的架构和其他相关主题,并最终了解如何将他们的应用程序发布到 Google Play 商店。本书以当前最佳实践为出发点,并指导您为 Android 开发者的角色做好准备。

本书涵盖内容

第一章Kotlin Android 开发入门,介绍了 Kotlin 作为一种编程语言。它涵盖了适用于 Android 开发的特性及其对 Android 开发者的重要性。此外,它还涵盖了如何从 Java 迁移到 Kotlin,以及针对来自 Java 背景的开发者的有用提示。

第二章创建您的第一个 Android 应用程序,涵盖了如何创建 Android 应用程序。它使您熟悉 Android Studio,这是您将用于开发 Android 应用程序的 集成开发环境IDE)。它还涵盖了一些技巧、快捷方式和有用的 Android Studio 功能,并探讨了在 Android Studio 中创建项目的流程。

第三章Jetpack Compose 布局基础,探讨了 Jetpack Compose,一种用于创建应用用户界面的声明式方法。它涵盖了 Jetpack Compose 的基础及其布局。

第四章使用 Material Design 3 进行设计,介绍了 Material 3 及其提供的功能。它还涵盖了如何在 Android 应用中使用 Material 3 以及其一些组件。

第五章构建您的应用架构,探讨了 Android 项目中可用的不同架构。它深入探讨了 MVVM 架构及其不同层以及如何在其中使用一些 Jetpack 库。此外,它还展示了如何使用高级架构功能,例如依赖注入、Kotlin Gradle DSL 和版本目录来定义依赖项。

第六章使用 Kotlin 协程进行网络调用,讨论了如何使用网络库 Retrofit 进行网络调用。它展示了如何使用此库消费 应用程序编程接口API)。此外,它还涵盖了如何利用 Kotlin 协程执行异步网络请求。

第七章在您的应用中导航,解释了如何使用 Jetpack Compose 导航库导航到不同的 Jetpack Compose 屏幕。它涵盖了使用此库的技巧和最佳实践。此外,它还涵盖了如何在导航到屏幕时传递参数。最后,它还涵盖了如何在大型屏幕和可折叠设备上处理导航。

第八章本地持久数据和后台工作,介绍了如何将数据保存到本地数据库 Room,它是 Jetpack 库的一部分。它展示了如何保存项目和从 Room 数据库中读取。此外,它还涵盖了如何使用 WorkManager 执行长时间运行的操作以及一些最佳实践。

第九章运行时权限,深入探讨了运行时权限以及如何请求运行时权限。

第十章调试您的应用,讨论了调试技巧和窍门,如何使用 LeakCanary 检测泄漏,如何使用 Chucker 检查由应用触发的网络请求/响应,以及如何使用 App Inspection 检查 Room 数据库、网络请求和后台任务。

第十一章提升代码质量,探讨了 Kotlin 风格和编写 Kotlin 代码的最佳实践。它还演示了如何使用 Ktlint 和 Detekt 等插件来格式化、检查和早期检测代码异味。

第十二章测试您的应用,检查了如何在 MVVM 架构的不同层添加测试。它涵盖了添加测试的重要性以及如何添加单元测试、集成测试和仪器测试。

第十三章发布您的应用,深入探讨了如何将新应用发布到 Google Play Store。它逐步介绍了如何创建签名应用包,以及诸如回答有关我们应用内容的问题、创建发布版本、设置用户如何访问应用(无论是通过受控测试轨道还是公开)等主题。此外,它还涵盖了 Google Play Store 的一些政策以及如何始终遵守规定,以避免应用被移除或账户被封禁。

第十四章持续集成与持续部署,专注于如何使用 GitHub Actions 自动化一些手动任务,例如将新构建部署到 Google Play Store。它还涵盖了如何在 CI/CD 管道上运行测试,并使用 GitHub Actions 将构建推送到 Google Play Store。

第十五章改进您的应用,介绍了通过添加分析、Firebase Crashlytics 以及使用云消息来提高应用用户参与度的技术。它涵盖了如何从 Firebase 控制台向应用发送通知。此外,它还提供了确保用户数据不受损害的应用安全提示和技巧。

要充分利用本书

本书涵盖的软件/硬件 操作系统要求
Android Studio Hedgehog 或更高版本 Windows、macOS 或 Linux

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Mastering-Kotlin-for-Android。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他丰富的代码包,这些代码包来自我们的书籍和视频目录,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们创建了一个名为PetsRepositoryImpl的类,该类实现了PetsRepository接口。”

代码块设置如下:

class PetsViewModel: ViewModel() {
    private val petsRepository: PetsRepository = PetsRepositoryImpl()
    fun getPets() = petsRepository.getPets()
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都按以下方式编写:

FATAL EXCEPTION: main
Process: com.packt.chapterten, PID: 7168
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.packt.chapterten/com.packt.chapterten.MainActivity}: java.lang.RuntimeException: This is a crash
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3645)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3782)

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“点击新建项目按钮,这将带我们进入模板屏幕。”

提示或重要注意事项

看起来像这样。

联系我们

欢迎读者反馈。

customercare@packtpub.com并在邮件主题中提及本书标题。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

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

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享你的想法

一旦你阅读了 Mastering Kotlin for Android 14,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 复印本

感谢你购买这本书!

你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?

你的电子书购买是否与你的选择设备不兼容?

别担心,现在每购买一本 Packt 书,你都可以免费获得该书的 DRM-free PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用中。

好处不止于此,你还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

下载此书的免费 PDF 复印本

packt.link/free-ebook/9781837631711

  1. 提交你的购买证明

  2. 就这些!我们将直接将你的免费 PDF 和其他好处发送到你的电子邮件。

第一部分:构建你的应用

在这部分,你将开始一段探索 Kotlin 的旅程,了解使其成为 Android 开发最佳选择的特性。我们将引导你通过从 Java 迁移的过程,为从 Java 背景过渡的开发者提供有价值的见解。逐步深入,你将构建你的首个 Android 应用,包括设置你的开发环境,以及熟悉 Android Studio。重点扩展到掌握 Jetpack Compose,揭示制作直观用户界面的艺术。最后,我们将向你介绍如何将 Material Design 3 集成到你的应用中,阐明它为开发领域带来的丰富特性和组件。

本节包含以下章节:

  • 第一章开始 Kotlin Android 开发

  • 第二章创建你的首个 Android 应用

  • 第三章Jetpack Compose 布局基础

  • 第四章, 使用 Material Design 3 进行设计

第一章:Kotlin Android 开发入门

Kotlin 是一种静态编程语言,允许你编写简洁且类型化的代码。它是 Google 推荐用于 Android 开发的语言。

在本章中,我们将了解 Kotlin 作为一种编程语言。我们将涵盖对 Android 开发有用的特性及其对 Android 开发者的重要性。此外,我们还将介绍如何从 Java 迁移到 Kotlin,以及针对来自 Java 背景的开发者的有用提示。

本章我们将涵盖以下主要主题:

  • Kotlin 简介

  • Kotlin 语法、类型、函数和类

  • 从 Java 迁移到 Kotlin

  • Kotlin 为 Android 开发者提供的特性

技术要求

要遵循本章中的说明,你需要准备以下内容:

你可以在 github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterone 找到本章的代码。

Kotlin 简介

Kotlin 是由 JetBrains 开发的运行在 Java 虚拟机JVM)上的语言。它被开发出来是为了克服 Java 所面临的以下挑战:

  • 冗长性:Java 的语法非常冗长,这导致开发者即使在处理简单任务时也要编写大量的样板代码。

  • 空指针异常:默认情况下,Java 允许变量具有空值。这通常会导致空指针异常,这被称为 Java 中的 十亿美元 错误,因为许多应用程序都受到了影响。

  • 并发:Java 有线程,但有时管理并发和线程安全是一项艰巨的任务。这导致了许多性能和内存问题,严重影响了需要从主线程之外执行工作的应用程序。

  • 特性的缓慢采用:Java 的发布周期较慢,使用最新版本的 Java 开发 Android 应用程序比较困难,因为需要做很多工作来确保向后兼容性。这意味着 Android 开发者难以轻松采用新的语言特性和改进,因为他们被困在使用较旧版本中。

  • 缺乏函数式支持:Java 不是一个函数式语言,这使得开发者难以在 Java 中编写函数式代码。很难使用诸如高阶函数或将函数视为一等公民等特性。

经过多年的发展,Kotlin 已经演变为多平台和服务器端语言,并且不再受服务限制,同时也在数据科学领域得到应用。Kotlin 在某些特性上比 Java 有优势,以下是一些例子:

  • 简洁性:语法简洁,这反过来又减少了你需要编写的样板代码的数量。

  • 空安全:许多 Java 开发者都非常熟悉著名的 空指针异常,它是应用程序中许多错误和问题的来源。Kotlin 的设计考虑到了空安全。在声明变量时,可以指示变量可能具有空值,在使用这些变量之前,Kotlin 编译器会强制执行对空可访问性的检查,从而减少了异常和崩溃的数量。

  • 协程支持:Kotlin 内置了对 Kotlin 协程的支持。协程是轻量级的线程,你可以使用它们来执行异步操作。在应用程序中使用它们既容易理解又方便。

  • equals()hashCode()toString() 方法,减少了所需的样板代码量。

  • 扩展函数:Kotlin 允许开发者通过扩展函数向现有类添加功能,而无需从它们继承。这使得向现有类添加功能变得更加容易,并减少了样板代码的需求。

  • 智能转换:Kotlin 的智能转换系统使得在不进行显式转换的情况下进行变量转换成为可能。编译器会自动检测变量何时可以安全地进行转换,并自动执行转换。

JetBrains 也是 IntelliJ IDEA 的背后公司。这个 集成开发环境IDE)中的语言支持也非常出色。

Kotlin 在过去几年中已经发展起来,支持以下不同的平台:

  • Kotlin Multiplatform:这用于开发针对不同平台的应用程序,如 Android、iOS 和 Web 应用程序。

  • Kotlin for server side:这用于编写后端应用程序以及支持服务器端开发的多个框架。

  • Kotlin for Android:自 2017 年起,Google 已经支持 Kotlin 作为 Android 开发的首选语言。

  • Kotlin for JavaScript:这提供了将 Kotlin 代码转换为兼容 JavaScript 库的支持。

  • Kotlin/Native: 这将 Kotlin 代码编译成原生二进制文件,并且无需 Java 虚拟机JVM)即可运行。

  • Kotlin for data science:你可以使用 Kotlin 来构建和探索数据管道。

总结来说,Kotlin 提供了一种比 Java 更现代、更简洁的编程方法,同时仍然与现有的 Java 库和代码保持互操作性。此外,你可以编写 Kotlin 代码并针对不同的平台进行开发。

现在我们已经了解了 Kotlin 及其各种特性,接下来我们将进入下一节,在那里我们将了解 Kotlin 作为一种编程语言,并理解 Kotlin 语法、类型、函数和类。

Kotlin 语法、类型、函数和类。

在本节中,我们将探讨 Kotlin 语法,并熟悉这门语言。Kotlin 是一种强类型语言。变量的类型是在编译时确定的。Kotlin 拥有一个丰富的类型系统,具有以下类型:

  • String?. 可空类型是正常类型,结尾没有任何运算符 - 例如,String

  • 基本类型:这些类型与 Java 类似。例如包括 Int、Long、Boolean、Double 和 Char。

  • class 关键字,您可以添加方法、属性和构造函数。

  • 数组:支持基本类型和对象数组。要声明基本数组,您指定类型和大小,如下所示:

    val shortArray = ShortArray(10)
    val recipes = arrayOf("Chicken Soup", "Beef Stew", "Tuna Casserole")
    

    当您不指定类型时,Kotlin 会自动推断类型。

  • 集合:Kotlin 拥有一系列丰富的 API,提供如集合、映射和列表等类型。它们被设计得简洁且易于表达,并且语言提供了广泛的操作,如排序、过滤、映射等。

  • Enum 关键字用于声明枚举。

  • (Boolean) -> Unit 简写表示法。此示例接受一个 Boolean 参数并返回一个 Unit 值。

我们已经学习了 Kotlin 中可用的不同类型,我们将在下一节中使用这些知识来定义这些类型中的一些。

创建 Kotlin 项目

按照以下步骤创建您的第一个 Kotlin 项目:

  1. 打开 IntelliJ IDEA。在欢迎屏幕上,单击 New Project。您将看到一个对话框来创建您的新项目,如图所示:

图 1.1 – 新建项目对话框

图 1.1 – 新建项目对话框

让我们按照以下方式查看 图 1.1 中显示的对话框中的选项:

  1. 您首先给项目起一个名字。在这个例子中,它是 chapterone

  2. 您还需要指定您项目的位置。这通常是您存储工作项目的地方。将目录更改为您首选的位置。

  3. 接下来,从提供的选项中指定您的目标语言。在这种情况下,我们选择 Kotlin

  4. 在下一步中,指定您的构建系统。我们指定 Gradle

  5. 我们还需要指定项目将要使用的 Java 版本。在这个例子中,它是 Java 11

  6. 接下来,您指定要使用的 Gradle DSL。对于此项目,我们选择使用 Kotlin

  7. 最后,您指定组 ID 和工件 ID,当它们组合在一起时,形成您项目的唯一标识符。

  8. 点击 Create 以完成创建新项目。IDE 将创建您的项目,这可能需要几分钟。完成后,您将看到以下项目:

图 1.2 – 项目结构

图 1.2 – 项目结构

IDE 创建的项目结构如 图 1.2 所示。我们主要对 src/main/kotlin 包感兴趣,这是我们添加 Kotlin 文件的地方。

  1. 首先右键单击 src/main/kotlin 包。

  2. 选择 Main。IDE 将生成一个 Main.kt 文件。

现在我们已经创建了第一个 Kotlin 项目并添加了 Kotlin 文件,在下一节中,我们将在这个文件中创建函数。

创建函数

在 Kotlin 中,函数是一个执行特定任务的代码块。我们使用 fun 关键字来定义函数。函数名应该使用驼峰式命名法,并具有描述性,以表明函数正在做什么。函数可以接受参数并返回值。

在你的 Main.kt 文件中创建 main() 函数,如下所示:

fun main() {
    println("Hello World!")
}

在前面的代码中,我们使用了 fun 关键字来定义一个名为 main 的函数。在函数内部,我们有一个 println 语句,它打印消息 "Hello World!"

你可以通过按函数右侧的绿色运行图标来运行函数。你会看到一个控制台窗口弹出,显示消息 "Hello World!"

我们已经学习了如何创建函数并将输出打印到控制台。在下一节中,我们将学习如何在 Kotlin 中创建类。

创建类

在 Kotlin 中声明一个类,我们使用 class 关键字。我们将创建一个 Recipe 类,如下所示:

class Recipe {
    private val ingredients = mutableListOf<String>()
    fun addIngredient(name: String) {
        ingredients.add(name)
    }
    fun getIngredients(): List<String> {
        return ingredients
    }
}

让我们分析前面的类:

  • 我们将类命名为 Recipe,并且它没有构造函数。

  • 在类内部,我们有一个成员变量 ingredients,它是一个字符串的 MutableList。它是可变的,以便我们可以向列表中添加更多项目。在类中定义这样的变量允许我们在类的任何地方访问该变量。

  • 我们有 addIngredient(name: String),它接受一个名为 name 的参数。在函数内部,我们将参数添加到我们的配料列表中。

  • 最后,我们有 getIngredients() 函数,它返回一个不可变的字符串列表。它简单地返回我们的配料列表的值。

要使用这个类,我们必须修改我们的主函数,如下所示:

fun main() {
    val recipe = Recipe()
    recipe.addIngredient("Rice")
    recipe.addIngredient("Chicken")
    println(recipe.getIngredients())
}

这些更改可以这样解释:

  • 首先,我们创建 Recipe 类的一个新实例,并将其分配给 recipe 变量

  • 然后,我们在 recipe 变量上调用 addIngredient 方法,并传入字符串 Rice

  • 再次,我们在 recipe 变量上调用 addIngredient 方法,并传入字符串 Chicken

  • 最后,我们在 recipe 变量上调用 getIngredients 方法,并将结果打印到控制台

再次运行主函数,你的输出应该是这样的:

图 1.3 – 食谱

图 1.3 – 食谱

如前一个截图所示,输出是你添加的配料列表!现在你可以准备一顿美味的米饭和鸡肉大餐,但是在 Kotlin 中!

Kotlin 有很多特性,我们只是刚刚触及了表面。你可以查看官方的 Kotlin 文档(kotlinlang.org/docs/home.html)来了解更多。随着你深入本书,你还将学习更多特性。

我们已经学习了如何创建类、定义顶级变量以及向我们的类中添加函数。这有助于我们理解 Kotlin 中类的工作方式。在下一节中,我们将学习如何将 Java 类迁移到 Kotlin 以及一些可用于迁移的工具。

从 Java 迁移到 Kotlin

你是 Java 开发者并且你的应用是用 Java 编写的吗?你在想如何开始使用 Kotlin 吗?不用担心,这就是你的部分。Kotlin 提供了两种方式供你选择:

  • Java 到 Kotlin 迁移:我们使用的 IDE,即 IntelliJ IDEA,有一个工具可以将现有的 Java 文件转换为 Kotlin。

  • 互操作性:Kotlin 与 Java 代码高度互操作,这意味着你可以在同一个项目中使用 Java 和 Kotlin 代码。你可以在 Kotlin 项目中继续使用你喜欢的 Java 库。

让我们看看如何使用 IntelliJ IDEA 将一个示例 Java 类迁移到 Kotlin:

  1. src/main/kotlin 目录下打开 Song 类,它包含多个 Java 函数。

  2. 右键单击文件,你会在底部看到 将 Java 转换为 Kotlin 选项。选择此选项,你将看到一个确认对话框:

图 1.4 – 确认对话框

图 1.4 – 确认对话框

在转换之后,有时你可能需要做一些修正,这就是为什么我们有这个对话框。点击 以继续,你会看到你的代码现在已经是 Kotlin 格式了。这是一个非常有用的功能,它处理了 Kotlin 转换的大部分工作,你还可以学习到语法。

现在我们已经学习了如何将 Java 代码迁移到 Kotlin,在下一节中,我们将介绍 Kotlin 的一些特性,这些特性使其对 Android 开发者非常有用。

Kotlin 为 Android 开发者提供的特性

现在你已经对 Kotlin 有了一定的了解,让我们来看看为什么 Kotlin 是一个专门针对 Android 开发的优秀语言。

Google 在 2017 年宣布 Kotlin 是编写 Android 应用的首选语言。从那时起,已经做了大量工作以确保开发者拥有他们开发 Kotlin Android 应用所需的一切。以下是开发者可以从中受益的一些特性:

  • 提高开发者生产力:Kotlin 简洁且表达力强的语法可以帮助开发者更快地编写代码,并减少错误,这最终可以提高开发者的生产力。

  • 空安全:由于 Kotlin 是考虑到空值而编写的,它帮助我们避免与空指针异常相关的崩溃。

  • IDE 支持:IDE 支持一直在不断改进。基于 IntelliJ IDEA 构建的 Android Studio 已经收到了许多新特性,例如改进的自动完成支持,以提升 Kotlin 的使用体验。

  • Jetpack 库:Jetpack 库在 Kotlin 中可用,并且旧的库正在用 Kotlin 重新编写。这是一套库和工具,旨在帮助 Android 开发者编写更少的代码。它们解决了常见的开发者痛点并提高了开发效率。

  • Jetpack Compose:Jetpack Compose 是一个新的 UI 框架,完全用 Kotlin 编写,并利用了 Kotlin 语言的特性。它是一个声明式 UI 框架,使 Android 开发者能够轻松地为他们的应用构建美观的 UI。

  • Kotlin Gradle DSL: 您现在可以使用 Kotlin 编写 Gradle 文件。

  • ViewModel 类有一个 viewModelScope,您可以使用它来在 ViewModel 的生命周期中作用域 coroutines。这与 coroutines 的结构化并发原则相一致。这有助于在不再需要时取消所有 coroutines。一些库,包括 Room、Paging 3 和 DataStore,也支持 Kotlin coroutines。

  • Google 的支持: Google 持续投资 Kotlin。目前,有从文章到代码实验室、文档、视频和教程等资源,由 Google 的 Android DevRel 团队提供,以帮助您学习新的库和 Android 开发的架构。

  • 活跃的社区和工具: Kotlin 拥有一个充满活力和活跃的开发者社区,这意味着有大量的非官方资源、库和工具可供使用,以帮助进行 Android 开发。

摘要

在本章中,我们学习了 Kotlin 编程语言及其特性。我们探讨了 Kotlin 中对 Android 开发有用的特性以及为什么它对 Android 开发者来说很重要。此外,我们还介绍了如何从 Java 迁移到 Kotlin,以及一些针对 Java 背景 developer 的有用提示。

在下一章中,我们将学习如何使用 Android Studio 创建 Android 应用程序。我们将探索 Android Studio 提供的一些功能,并学习一些技巧和快捷方式。

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

Android 是由 Google 开发的移动操作系统,运行在超过 20 亿台设备上,如智能手机、平板电脑、电视、手表和汽车,开发者能够编写与这些不同设备兼容的代码。

在本章中,我们将创建我们的第一个 Android 应用。我们还将熟悉 Android Studio,这是我们用于开发 Android 应用的集成开发环境IDE)。我们还将学习一些技巧、快捷键和有用的 Android Studio 功能,并了解在 Android Studio 中创建项目的流程。

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

  • Android Studio 概述

  • 创建您的 Android 应用

  • Android Studio 技巧和窍门

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio/download)。

您可以在 github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chaptertwo 找到本章的代码。

Android Studio 概述

由 Google 开发的 Android Studio 是创建 Android 应用的官方 IDE。基于 JetBrains 的 IntelliJ IDEA 构建,它为 Android 应用开发提供了一个全面的平台。它拥有所有使您能够轻松开发 Android 应用的功能。

一旦您从官方网站下载了 Android Studio,您需要下载 SDK 并设置好一切,以便它准备好使用。打开您新安装的 Android Studio。您将看到以下欢迎屏幕:

图 2.1 – Android Studio 欢迎屏幕

图 2.1 – Android Studio 欢迎屏幕

在右上角,我们有这些快速选项:

  • 新建项目:我们使用这个选项在 Android Studio 中创建新项目。

  • 打开:当我们想使用 Android Studio 打开现有项目时,我们会使用这个选项。

  • 从版本控制系统获取VCS 代表 版本控制系统。版本控制系统的例子有 GitHub、GitLab 和 Bitbucket。我们可以始终链接我们的账户,并轻松地将托管在 VCS 上的项目导入到 Android Studio 中。

  • 更多选项图标:这为我们提供了更多选项,例如配置或调试 APK导入项目导入 Android 代码示例SDK 管理器虚拟设备管理器。我们只在使用时使用这些选项,所以在此处不会深入探讨它们。

现在,让我们看看左侧的导航选项:

  • 项目:这是默认选项。如果存在,它将显示您使用 Android Studio 创建的所有项目。如果没有,将显示一个空白的屏幕。

  • 自定义:这提供了一个设置屏幕,用于自定义 Android Studio 的各个方面,如下面的截图所示:

图 2.2 – 自定义 Android Studio 屏幕

图 2.2 – 自定义 Android Studio 屏幕

从前面的截图,我们可以看到我们可以快速自定义以下内容:

  • 颜色主题:我们可以根据我们的偏好将主题设置为深色(Dracula)、浅色(IntelliJ Light)或高对比度。

  • IDE 字体:在这里,我们设置 IDE 的首选字体大小。

  • 键盘映射:在这里,我们配置 IntelliJ 应使用什么来映射我们的键盘和鼠标快捷键。它将自动选择适合我们操作系统的选项。

在此屏幕底部,我们可以看到另外两个设置选项。一个是导入设置…,当我们想从之前的 Android Studio 安装或自定义文件导入设置时使用。另一个是所有设置…,它提供了更多的自定义选项。

  • 插件:在这里,我们可以安装外部插件到我们的 Android Studio,并管理我们已安装的插件。根据需要,市场上有几个插件我们可以安装。

图 2.3 – 插件屏幕

图 2.3 – 插件屏幕

现在我们已经对 Android Studio 欢迎屏幕上的几个基本选项有了概述,我们将使用新建项目选项在下一节创建我们的第一个 Android 应用。

创建您的 Android 应用

按照以下步骤创建您的第一个 Android 应用:

  1. 点击新建项目按钮,这将带您到模板屏幕,如图下所示:

图 2.4 – 新建项目 | 模板屏幕

图 2.4 – 新建项目 | 模板屏幕

在创建新项目时,IDE 为我们提供了多种选择,如图 2.4 所示。首先,在右侧,我们需要选择我们针对的具体形态。默认情况下,手机和平板被选中。我们还有其他选项,例如,如果我们想针对可穿戴设备,可以选择Wear OS;如果我们想开发在由 Android OS 驱动的智能电视上运行的应用,可以选择Android TV;最后,对于针对 Android Auto 的应用,可以选择Automotive

我们将使用默认选项,因为我们想针对 Android 和平板设备。

接下来,我们必须从提供的选项中选择一个模板。有几个模板我们可以使用来快速为我们的应用生成一些功能。例如,我们有底部导航视图活动来生成一个具有 UI 和 Koltin 代码的项目,用于显示底部标签。

  1. 我们将选择空活动,因为我们想从头开始。我们使用这个选项而不是无活动选项,因为后者为我们设置了一些依赖项。

  2. 点击下一步,我们将看到配置项目详情的屏幕,如下所示:

图 2.5 – 新建项目设置

图 2.5 – 新建项目设置

  1. 如前一个截图所示,为了最终创建项目,我们需要指定以下内容:

    • 名称:这是我们的项目的唯一名称。

    • 包名:这是我们的项目的唯一标识符。通常它是由公司网站和应用程序名称的组合。

    • 保存位置:在这里,我们指定项目所在的目录。

    • 最小 SDK 版本: 这是我们 Android 应用将支持的最低 Android 版本。Android Studio 给出使用所有版本的设备百分比,帮助我们决定要支持的最低 Android 版本。对于我们的项目,我们选择了API 24: Android 7.0 (Nougat),这将运行在大约 94% 的设备上。需要注意的是,选择较低的最低 SDK 版本意味着我们必须使我们的应用与不同的设备版本兼容,这可能是一项大量工作。此外,一些功能仅在较新的 SDK 版本中可用,因此我们必须为设备添加回退机制。

  2. 最后,点击完成—这将创建我们的项目。准备项目可能需要几分钟时间。一旦完成,我们将看到以下屏幕:

图 2.6 – 新项目

图 2.6 – 新项目

在这里,我们需要了解关于项目结构的几个问题。我们将在下一节深入探讨。

探索新项目

在本小节中,我们将查看整个项目结构,以便我们能够理解不同的组件。

在左侧,我们有包含不同目录和包的项目结构。在右侧是编辑器部分,默认情况下没有任何内容。当你在 Android Studio 中打开任何文件时,它们就会出现在这里。这是我们新项目的项目结构:

图 2.7 – 项目结构

图 2.7 – 项目结构

在左侧,我们有 app 目录,这是根目录,包含与项目相关的所有文件。从图 2.7 中,我们可以看到在 app 目录内,我们有以下不同的目录:

  • manifests: 这包含一个单独的 AndroidManifest.xml 文件,这对于我们的应用配置至关重要。一个清单文件具有 .xml 扩展名,包含对您的应用至关重要的信息。它将此信息传达给 Android 系统。在这个文件中,我们定义了应用所需的权限、应用名称和图标。我们还在此文件中声明活动和服务。如果不声明它们,我们的应用很难使用它们。

  • java 包:这个包,尽管命名为 java,包含我们项目的所有 Kotlin 文件。如果我们需要添加任何文件,这就是我们添加它们的地方。我们还可以创建帮助我们将具有相关功能的文件分组在一起的包。此目录进一步细分为以下:

    • com.packt.chaptertwo: 这是我们应用中的 Kotlin 文件

    • com.packt.chaptertwo (androidTest): 在这里,我们添加所有仪器测试的文件

    • com.packt.chaptertwo (test): 在这里,我们添加所有单元测试的文件

  • 资源:这个目录,通常简称为 res,包含我们应用所需的所有资源。这些资源可以包括图像、字符串和资产。从 图 2**.6 我们可以看到以下子目录:

    • drawable: 这个文件夹包含自定义的可绘制对象、矢量可绘制对象或用于应用的 PNG 和 JPEG 图像。

    • mipmap: 这个文件夹是我们放置启动器图标的地方。

    • values: 这个文件夹是我们放置颜色、字符串、样式和主题文件的地方。在这个文件夹中,我们定义全局值,以便在整个应用中使用。

    • xml: 在这个文件夹中,我们存储 XML 文件。

  • Gradle 脚本: 在这里,我们有我们项目所需的全部 Gradle 脚本和 Gradle 属性文件。在我们的新项目中,我们有以下文件:

    • build.gradle (项目: chaptertwo): 这是项目的顶层 Gradle 文件,在这里我们添加应用于整个项目和子模块的配置。

    • build.gradle (模块: app): 这是应用模块的 Gradle 文件。在这里,我们配置应用模块。为了理解这个文件,让我们看看为我们项目生成的文件:

      plugins {
          id 'com.android.application'
          id 'org.jetbrains.kotlin.android'
      }
      android {
          namespace 'com.packt.chaptertwo'
          compileSdk 33
          defaultConfig {
              applicationId "com.packt.chaptertwo"
              minSdk 24
              targetSdk 33
              versionCode 1
              versionName "1.0"
              testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
              vectorDrawables {
                  useSupportLibrary true
              }
          }
          buildTypes {
              release {
                  minifyEnabled false
                  proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
              }
          }
          compileOptions {
              sourceCompatibility JavaVersion.VERSION_1_8
              targetCompatibility JavaVersion.VERSION_1_8
          }
          kotlinOptions {
              jvmTarget = '1.8'
          }
          buildFeatures {
              compose true
          }
          composeOptions {
              kotlinCompilerExtensionVersion '1.3.2'
          }
          packagingOptions {
              resources {
                  excludes += '/META-INF/{AL2.0,LGPL2.1}'
              }
          }
      }
      dependencies {
          implementation 'androidx.core:core-ktx:1.10.1'
          implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
          implementation 'androidx.activity:activity-compose:1.7.2'
          implementation platform('androidx.compose:compose-bom:2022.10.00')
          implementation 'androidx.compose.ui:ui'
          implementation 'androidx.compose.ui:ui-graphics'
          implementation 'androidx.compose.ui:ui-tooling-preview'
          implementation 'androidx.compose.material3:material3'
          testImplementation 'junit:junit:4.13.2'
          androidTestImplementation 'androidx.test.ext:junit:1.1.5'
          androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
          androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
          androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
          debugImplementation 'androidx.compose.ui:ui-tooling'
          debugImplementation 'androidx.compose.ui:ui-test-manifest'
      }
      

    在最顶部,我们指定了模块所需的插件。在这种情况下,我们声明了 Android 应用和 Kotlin 插件。在 plugins 块之后,我们有 android 块。您可以看到,在这个块内部定义了以下属性:

    • namespace: 这被用作生成的 RBuildConfig 类的 Kotlin 或 Java 包名。

    • compileSDK: 这定义了 Gradle 将用于编译我们的应用的 Android SDK 版本。

    • defaultConfig: 这是一个块,我们在这里指定所有风味和构建类型的默认配置。在这个块内部,我们指定属性,如 applicationIdminSDKtargetSDKversionCodeversionNametestInstrumentationRunner

    • buildTypes: 这配置了我们的应用程序的不同构建类型,例如 debugrelease,或任何我们定义的自定义构建。在每个构建类型块内部,我们指定属性,如 minifyEnabledproguardFilesdebuggable

    • compileOptions: 我们使用这个块来配置与 Java 编译相关的属性。例如,我们定义了 sourceCompatibilitytargetCompatibility,它们指定了项目源代码的 Java 版本兼容性。

    • kotlinOptions: 我们使用这个块来配置与 Kotlin 相关的选项。常用的选项是 jvmTarget,它指定了用于 Kotlin 编译的 Java 版本。

    • buildFeatures: 我们使用这个块来启用和禁用项目中的特定功能。例如,我们在项目中启用了 compose。我们可以启用或禁用其他附加功能,如 viewBindingdataBinding

    • ComposeOptions: 这个块是针对使用 Jetpack Compose 的项目特定的。例如,在这个块内部,我们可以设置 kotlinCompilerExtensionVersion

    • packagingOptions: 我们使用此块来自定义项目的打包选项,特别是关于冲突和合并。

    • dependencies: 在此,我们指定项目中的依赖项。我们可以在该块中添加不同的库、模块或外部依赖项。

    • proguard-rules.pro: 这是一个定义 ProGuard 在混淆代码时使用规则的文件。我们将在第十三章*中深入探讨。

    • gradle.properties (项目属性): 在此处,我们定义适用于整个项目的属性。一些属性包括设置 Kotlin 风格以及指定要使用的内存。

    • gradle.properties (全局属性): 这是一个全局文件。我们指定希望应用到所有 Android Studio 项目的设置。

    • gradle-wrapper.properties (Gradle 版本): 在此文件中,我们指定 Gradle 包装器的属性,包括版本和下载 Gradle 包装器的 URL。

    • local.properties (本地属性): 在此文件中,我们指定需要应用到我们本地设置中的设置。通常,此文件不会被提交到版本控制,这意味着我们在此处添加的配置仅适用于我们的个人设置。

    • settings.gradle (项目设置): 我们使用此文件来应用一些项目设置。例如,如果我们需要在项目中添加更多模块,这就是指定它们的地方。

当我们构建项目时,Android Studio 会使用我们在 Gradle 文件中指定的配置来编译所有资源代码,并将它们转换为可以在我们的 Android 手机或模拟器上运行的 Android 应用程序包 (APK) 或 Android 应用程序包 (AAB)。

在本节中,我们探索了新创建的项目,并了解了一些由 Android Studio 生成的关键文件和文件夹。在下一节中,我们将看到我们如何自定义 Android Studio 内的一些设置。

Android Studio 小技巧

在本节中,我们将了解一些有用的技巧、快捷键和功能。

我们将首先打开 MainActivity.kt 文件。当你打开文件时,你会看到以下布局:

图 2.8 – MainActivity 文件

图 2.8 – MainActivity 文件

我们现在可以看到 MainActivity.kt 文件内部的代码,这是 Kotlin 源代码。在文件名标签上方,我们可以看到一个导航栏,如下面的截图所示:

图 2.9 – 导航栏

图 2.9 – 导航栏

导航栏使您能够轻松快速地在不同的项目文件之间导航。

我们还可以切换到 项目视图 来查看项目中的所有资源。切换按钮位于所有目录的最顶部。默认情况下,它设置为 Android 视图,并且根据您的偏好有更多选项。切换到项目视图会给我们以下文件夹结构:

图 2.10 – 项目视图

图 2.10 – 项目视图

图 2.10中,我们可以看到我们项目中的所有资源,并且可以轻松地浏览不同的文件和文件夹。

Android Studio 拥有各种不同的工具窗口,提供了多种选项。让我们从位于视图切换器标签下面的左上角的资源管理器工具窗口开始。打开这个窗口,你会看到以下内容:

图 2.11 – 资源管理器标签

图 2.11 – 资源管理器标签

图 2.11所示,资源管理器标签显示了项目中的所有资源。我们还可以快速添加新的矢量图和图像资源以及可绘制文件,并在这里导入可绘制资源。好事是,我们还可以预览这些资源,并轻松浏览项目中的资源。在这个标签下面,我们有项目标签,用于切换回项目视图,再下面是拉取请求标签,它使我们能够查看项目版本控制仓库中的开放拉取请求。这在我们与其他团队成员或同事协作时特别有用。

Android Studio 允许我们添加或删除这些标签,或者选择在左侧、右侧或底部显示哪些标签。要删除一个标签,你只需右键点击它,并选择从侧边栏移除选项。点击视图,然后点击工具窗口。这个操作将显示当前所有可用的工具窗口。

Android Studio 在视图菜单中提供了替代的查看选项。例如,当我们进行演示时,我们可以切换到演示模式。要做到这一点,仍然在视图菜单中,点击外观然后点击进入演示模式。这将显示一个最小化的 UI,如下面的图所示:

图 2.12 – 演示模式

图 2.12 – 演示模式

图 2.12所示,我们可以看到字体大小已增加,UI 非常简洁。这种模式在进行演示时非常有帮助。要退出此模式,请转到视图然后点击退出演示模式

在底部,我们还有一些更有用的工具,如下所示:

图 2.13 – Android Studio 底部标签

图 2.13 – Android Studio 底部标签

图 2.13中,让我们了解这些标签的功能:

  • 待办事项:显示我们所有的待办事项。这对于跟踪需要做的事情非常有用。

  • 问题:显示我们项目中的所有问题。这对于跟踪项目中的错误和警告非常有用。

  • 终端:允许我们运行终端命令。这对于运行 Git 或Android 调试桥接器ADB)命令非常有用。

  • 应用检查器:这允许我们检查应用中的各种元素,对调试应用非常有用。它允许我们检查我们的后台任务、数据库和网络请求。对于数据库,我们可以查看数据库中的数据,我们还可以使用数据运行查询。对于网络请求,我们可以查看网络请求及其 JSON 响应。对于后台任务,我们可以查看任务及其状态。这些对我们调试和检查应用中的问题非常有帮助。

  • 日志输出:这显示了所有我们的日志消息。它在调试错误时特别有用,因为错误会即时出现。

  • 应用质量洞察:这允许我们查看应用质量洞察。这使我们能够查看 Firebase Crashlytics 在 Android Studio 中检测到的崩溃。我们还可以看到崩溃堆栈跟踪和有问题的代码行,并可以轻松地从这里导航到该行。

  • 构建:显示构建输出。这对于调试构建错误非常有用。

  • 性能分析器:允许我们对应用进行性能分析。为了使性能分析器正常工作,我们必须有一个运行中的应用实例。性能分析器对于调试性能问题非常有用。它提供了关于应用如何使用 CPU、内存和能量的指标。我们可以使用这些指标来优化我们的应用。

这些标签的位置可能有所不同,有时某些标签可能不会显示。您也可以轻松地从侧边栏添加或删除它们。

现在我们来看看 Android Studio 中的一些有用快捷键。

一些有用的快捷键

快捷键可以帮助我们在 Android Studio 中快速完成任务。熟练掌握后,它们可以帮助我们提高生产力。有许多快捷键可供选择,您也可以自定义并创建自己的快捷键。以下是一些最常用的快捷键:

  • Alt + 7(在 Windows 上)或 Command + 7(在 Mac 上):这打开MainActivity.kt文件,我们可以看到以下结构:

图 2.14 – 结构标签

图 2.14 – 结构标签

图 2.14.14 中,我们可以看到MainActivity.kt文件。点击此标签中的方法可以快速导航到我们的代码中的方法。还有一个图标,在图 2.14.14 中以红色突出显示,显示继承的方法。当我们点击这个图标时,它会显示文件中的所有继承方法。

  • Alt + Enter(在 Windows 上)或 Option + Enter(在 Mac 上):这允许我们快速添加项目中的包、文件或依赖项的导入。它还提供了更多功能,例如提供错误快速修复以及允许我们实现方法。

  • 双击Shift:这打开通用搜索窗口。在这里,我们可以搜索项目中的类、符号和文件。

  • Ctrl + Shift + F(在 Windows 上)或 Command + Shift + F(在 Mac 上):这有助于在所有文件中搜索文本。

  • Ctrl + F6(在 Windows 上)或 Command + F6(在 Mac 上):这允许我们重构代码。我们可以重命名、更改方法签名、移动代码等等。

  • 在 Windows 上按 Ctrl + D 或在 Mac 上按 Command + D:这是用于复制一行代码或选定的代码段。

  • 在 Windows 上按 Ctrl + B 或在 Mac 上按 Command + B:这允许我们跳转到声明。

我们只介绍了一些这些快捷键。还有很多其他的快捷键可用。如果你想掌握这些快捷键中的大多数,你可以安装 Key Promoter X 插件(plugins.jetbrains.com/plugin/9792-key-promoter-x)。这些插件会在你执行具有快捷键的动作时提醒你,当你重复执行没有快捷键的操作时,它还会提示你创建快捷键。

摘要

在本章中,我们创建了我们的第一个 Android 应用。我们熟悉了 Android Studio,这是我们用来开发 Android 应用的 IDE。我们还了解了一些技巧、快捷键和有用的 Android Studio 功能,并了解了在 Android Studio 中创建项目的流程。

在下一章中,我们将介绍 Jetpack Compose 布局基础。我们将从 Jetpack Compose 的介绍开始,这是一种为我们的应用声明 UI 的声明式方法。

第三章:Jetpack Compose 布局基础

一个好的 UI 和用户体验是我们应用的核心。作为 Android 开发者,我们必须敏锐地意识到这两个领域,并学习如何使用为我们提供的不同工具来创建 UI。Google 引入了 Jetpack Compose,这是一种现代的 UI 工具包,可以帮助开发者轻松地创建直观的 UI。

在本章中,我们将探讨 Jetpack Compose,这是一种为我们的应用创建 UI 的声明式方法。我们将学习 Jetpack Compose 和 Jetpack Compose 中的布局的基础知识。

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

  • Jetpack Compose 简介

  • Jetpack Compose 布局

技术要求

要遵循本章中的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以在此章节的代码在 github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterthree 找到。

Jetpack Compose 简介

几年来,Android UI 开发经历了重大的转变,出现了各种框架和库来简化这个过程。

在 Jetpack Compose 之前,我们通常是这样为我们的应用编写 UI 的:

  • 视图是从 XML 布局文件中填充的。基于 XML 的视图仍然与 Jetpack Compose 一起支持,以实现向后兼容性和混合用例,其中应用既有 XML 布局也有 Jetpack Compose。

  • 主题、样式和值资源也定义在 XML 文件中。

  • 为了能够从 XML 文件中访问视图,我们使用了视图绑定或数据绑定。

  • 这种编写 UI 的方法需要巨大的努力,需要更多的样板代码,并且容易出错。

Google 开发了 Jetpack Compose 作为一种现代的 声明式 UI 工具包。它允许我们用更少的代码创建 UI。在 Jetpack Compose 中创建的布局对不同的屏幕尺寸和方向做出响应。在 Compose 中编写 UI 也更容易、更高效。使用 Jetpack Compose,我们可以在代码库中重用组件。Jetpack Compose 还允许我们在 composables 中使用来自 XML 组件的代码。

Jetpack Compose 完全使用 Kotlin 编写,这意味着它利用了 Kotlin 提供的强大语言功能。在 Compose 之前,用于创建 UI 的 视图系统更为过程化。我们必须管理复杂的生命周期并手动处理任何状态变化。Jetpack Compose 是一种全新的范式,它使用声明式编程。我们根据状态描述 UI 应该是什么样的。这使得我们能够拥有动态内容,更少的样板代码,并更快地开发我们的 UI。

要理解 Jetpack Compose,让我们首先深入了解声明式和命令式方法在编写 UI 之间的区别。

声明式与命令式 UI

在命令式 UI 中,我们逐步指定描述 UI 应如何构建和更新的指令。我们明确地定义创建和修改 UI 元素的操作序列。我们依赖于可变状态变量来表示 UI 的当前状态。我们手动更新这些状态变量,以响应 UI 的变化和用户交互。

在声明式 UI 中,我们专注于描述期望的结果,而不是指定逐步的指令。我们根据当前状态定义 UI 应该看起来是什么样子,框架处理其余部分。我们使用声明性标记或代码定义 UI。我们通过描述 UI 元素及其属性之间的关系来表达期望的 UI 结构、布局和行为。

声明式方法更强调不可变状态,其中 UI 状态由不可变数据对象表示。我们不是直接修改状态,而是创建数据对象的新实例来反映 UI 中期望的变化。

在声明式 UI 中,框架负责根据应用程序状态的变化更新 UI。我们指定 UI 和底层状态之间的关系,框架自动更新 UI 以反映这些变化。

现在我们已经理解了命令式和声明式方法,让我们看看每个方法的示例。让我们创建一个简单的 UI,用于计数器,使用 Jetpack Compose(Kotlin)中的声明式 UI 和 XML(Android XML 布局)中的命令式 UI。示例将展示两种方法在语法和方法上的差异。Jetpack Compose 版本如下所示:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        MyApp()
    }
  }
}
@Composable
fun MyApp() {
  var count by remember { mutableStateOf(0) }
  Column(
    modifier = Modifier.padding(16.dp)
  ) {
      Text(text = "Counter: $count", style = MaterialTheme.typography.bodyLarge)
      Spacer(modifier = Modifier.height(16.dp))
      Button(onClick = { count++ }) {
        Text("Increment")
      }
    }
}

在前面的示例中,我们有一个 MyApp 可组合函数,它定义了应用程序的 UI。UI 是通过使用可组合项来定义的,并通过 remember 可组合项处理状态变化。UI 是使用函数式方法定义的。我们还可以看到 UI 是以更简洁的方式定义的。

使用命令式方法,我们必须首先创建 XML UI,如下面的代码块所示:

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

  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:padding="16dp">
  <TextView
    android:id="@+id/counterTextView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerHorizontal="true"
    android:text="Counter: 0"
    android:textSize="20sp" />
  <Button
    android:id="@+id/incrementButton"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_below="@id/counterTextView"
    android:layout_centerHorizontal="true"
    android:layout_marginTop="16dp"
    android:text="Increment" />
</RelativeLayout>

在创建布局文件后,我们现在可以创建活动类,该类将填充布局文件并处理按钮点击:

import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
  private var count = 0
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    val counterTextView: TextView = findViewById(R.id.counterTextView)
    val incrementButton: Button = findViewById(R.id.incrementButton)
    incrementButton.setOnClickListener {
      count++
      counterTextView.text = "Counter: $count"
    }
  }
}

在这个示例中,XML 布局是在 MainActivity 类的 onCreate 方法中填充的,UI 元素是通过程序方式访问和操作的。

在前面的示例中,Jetpack Compose 代码是用 Kotlin 编写的,提供了一种更声明式的方法,通过函数式方式定义 UI。另一方面,XML 布局则是用 XML 命令式编写的,以更逐步的方式指定 UI 结构和属性,并在 Kotlin 代码中命令式地与之交互。Jetpack Compose 允许使用声明式语法更简洁、更富有表现力地表示 UI。

现在我们已经清楚地理解了命令式和声明式编写 UI 的方式,在下一节中,我们将深入探讨 Jetpack Compose 的构建块。

组合函数

图 3.1所示,组合函数是 Jetpack Compose 的主要构建块:

图 3.1 – Compose UI

图 3.1 – Compose UI

组合函数描述了如何渲染 UI。这个函数必须使用@Composable注解。当你用这个注解标记一个函数时,这意味着该函数描述了如何组合 UI 的特定部分。组合函数旨在可重用。它们可以在 UI 活动时多次调用。每当组合函数的状态发生变化时,它都会经历一个重新组合的过程,这使得 UI 能够显示最新的状态。

组合函数是纯函数,这意味着它们没有任何副作用。它们在多次以相同输入调用时会产生相同的输出。这确保了函数的可预测性和在派发更新到 UI 时的效率。然而,也有一些例外,例如,在组合函数中启动协程或调用具有副作用的第三方方法,这些应该避免或谨慎处理。

较小的组合函数可以组合起来构建复杂的 UI。你可以在其他组合函数内部重用和嵌套组合函数。

让我们看看一个组合函数的例子:

@Composable
fun PacktPublishing(bookName: String) {
    Text(text = "Title of the book is: $bookName")
}

在前面的代码片段中,PacktPublishing函数被@Composable注解标记。该函数接受一个参数bookName,它是一个String。在函数内部,我们使用了来自 Material Design 库的另一个组合函数。这个组合函数将一些文本渲染到我们的 UI 上。

在设计我们的 UI 时,我们通常想要在不运行我们的应用的情况下查看 UI 的外观。幸运的是,我们有预览,它可视化我们的组合函数。我们将在下一节中学习它们。

预览

在 Jetpack Compose 中,我们有@Preview注解,它可以在 Android Studio 中生成组合函数或一组 Compose 组件的预览。它有一个交互模式,允许我们与 Compose 函数交互。这为我们提供了一个快速可视化设计并在需要时轻松修改设计的方法。

这就是我们的PacktPublishing组合函数预览的样子:

@Preview(showBackground = true)
@Composable
fun PacktPublishingPreview() {
    PacktPublishing("Android Development with Kotlin")
}

我们使用了@Preview注解来表示我们想要为这个函数构建一个预览。此外,我们将showBackground参数设置为true,这为我们的预览添加了一个白色背景。我们使用Preview后缀命名了函数。预览也是一个组合函数。

要查看预览,您需要在您的编辑器中的拆分或设计模式下。这些选项通常位于 Android Studio 的右上角。我们还需要为 Android Studio 构建一次,以生成预览,其外观如下:

图 3.2 – 文本预览

图 3.2 – 文本预览

图 3**.2 所示,我们有一个显示我们传递给函数的字符串的文本。预览还有一个白色背景,其名称位于左上角。

我们可以为深色和浅色主题方案提供预览。我们还可以配置要应用的设备预览窗口等属性。

预览对于在设计 UI 时进行快速迭代非常有用。然而,它们不能替代实际的设备/模拟器测试,尤其是对于动画、交互或动态数据等事物。

在理解了预览是什么以及如何创建它们之后,让我们在下一节中探讨 Compose 的另一个功能,修饰符

修饰符

修饰符允许我们通过启用以下功能来装饰我们的可组合函数:

  • 改变可组合组件的大小、行为和外观

  • 添加更多信息

  • 处理用户输入

  • 添加交互,如点击和涟漪效果

使用修饰符,我们可以改变我们可组合组件的各个方面,如大小、填充、颜色和形状。大多数来自库的 Jetpack Compose 组件都允许我们将修饰符作为参数提供。例如,如果我们需要向我们的预览文本提供填充,我们将有以下内容:

Text(
    modifier = Modifier.padding(16.dp),
    text = "Title of the book is: $bookName"
)

我们已经将填充修饰符添加到 Text 可组合组件中。这将向 Text 可组合组件添加 16.dp 的填充。16.dp 是 Jetpack Compose 中的 密度无关 像素单位。这意味着它将保持一致,并适当地调整到不同的屏幕密度。

我们可以在一个可组合组件中链式调用不同的修饰符函数。当链式调用修饰符时,应用顺序至关重要。如果我们没有达到预期的结果,我们需要仔细检查顺序。让我们通过实际操作来观察这个概念:

Text(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .background(Color.Green),
    text = "Title of the book is: $bookName"
)

我们增加了两个新的修饰符。第一个是 fillMaxWidth 修饰符,它被添加到文本可组合组件中。这将使文本可组合组件占据父组件的全部宽度。另一个是添加到 Text 可组合组件的背景修饰符。这将向文本可组合组件添加背景颜色。我们的文本预览将如下所示:

图 3.3 – 文本修饰符预览

图 3.3 – 文本修饰符预览

如前一张截图所示,文本现在占据了设备的整个宽度,并具有绿色背景。它周围还有 16dp 的填充。

修饰符不会修改原始的可组合元素。它们返回一个新的、修改后的实例。这确保我们的可组合元素保持不变和不可变。不可变性是函数式编程的一个基本原则,它确保状态保持不变,简化了状态管理并减少了副作用。这种方法通过遵循引用透明性的原则,增强了可预测性和可读性。通过链式修饰符函数来组合函数的能力,使得复杂 UI 行为的简洁和可读表达成为可能,而不会改变原始的可组合元素。除了使用现有的修饰符之外,我们还可以在需要时创建自己的修饰符。

现在你已经了解了修饰符是什么,我们将在此基础上学习下一节中关于 Jetpack Compose 布局的内容。

Jetpack Compose 布局

Jetpack Compose 提供了多种预构建的布局供我们使用。在查看不同的布局之前,让我们首先了解 Jetpack Compose 如何将状态转换为 UI。

图 3.4 – Compose 如何将状态转换为 UI

图 3.4 – Compose 如何将状态转换为 UI

从前面的图中,我们可以看到我们的状态在以下步骤中转换为 UI:

  1. 组合

    这是初始阶段。Compose 编译器创建一个 UI 元素树。每个元素都是一个表示 UI 元素的函数。然后 Compose 调用这些函数来创建 UI 树。组合步骤负责确定哪些可组合元素需要更新以及哪些可以被重用。这是通过比较先前可组合元素的树和新的树,并且只更新那些已更改的元素来实现的。这使得这一步骤非常高效,因为只有带有更新的元素才会被更新。

  2. 布局

    这个步骤发生在组合阶段之后。在这里,Compose 编译器将组合阶段生成的树确定其大小、位置和布局。每个可组合元素根据其父元素和任何设置的约束在布局中进行测量和定位。这个阶段负责确定屏幕上每个 UI 元素的最终位置和大小。它还负责创建绘图阶段使用的最终布局树。

  3. 绘图

    这是将我们的 UI 转换为状态的最后阶段。在这个阶段,Compose 编译器将布局阶段创建的最终布局树用于在屏幕上绘制元素。这是通过遍历树并向底层图形系统发出绘制命令来完成的。这个阶段负责在屏幕上渲染最终的 UI。

这三个阶段共同作用,在 Jetpack Compose 中创建我们的 UI。组合阶段构建一个可组合组件树,布局阶段定位和调整它们的大小,绘制阶段在屏幕上渲染它们。整个过程经过优化,性能良好,效率高,即使在复杂的 UI 中也能实现快速和流畅的 UI 渲染。

现在我们已经了解了 Compose 编译器如何渲染我们的 UI,让我们看看 Compose 中的布局。

Jetpack Compose 默认提供以下布局:

  • Column

  • Row

  • Box

  • 列表

我们将在下一小节中详细查看这些布局。首先,让我们看看 Column 布局。

我们使用 Column 来垂直组织项目。Column 的一个使用示例如下:

Column {
    Text(text = "Android")
    Text(text = "Kotlin")
    Text(text = "Compose")
}

在前面的代码中,我们创建了包含三个文本元素的 Column。为这个添加预览为我们生成了以下 UI:

图 3.5 – 列预览

图 3.5 – 列预览

如前一个截图所示,设计是基本的。我们将通过使用修饰符来对其进行一些润色,因为 Jetpack Compose 也为这些布局提供了对修饰符的支持。让我们将这些更改添加到我们的 Column 中:

Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    Text(text = "Android")
    Text(text = "Kotlin")
    Text(text = "Compose")
}

在这里,我们向我们的 Column 添加了一个 Modifier。在修饰符参数中,我们指定了 fillMaxSize 修饰符,这使得我们的列填充父元素内的可用空间。这对于构建全屏 UI 非常有帮助。我们还为我们的列添加了 16.dp 的填充。

此外,我们还为我们的 Column 指定了两个额外的参数。一个是 verticalArrangement,我们用它来指定此视图子项的垂直排列。在这种情况下,我们指定 Arrangement.Center,它将我们的 Column 的所有子项垂直居中。另一个参数是 horizontalAlignment,它是布局子项的水平对齐方式。在这种情况下,我们指定值为 Alignment.CenterHorizontally,这将使所有子项水平居中。我们的预览经过前面的更改将看起来如下:

图 3.6 – 列修饰符预览

图 3.6 – 列修饰符预览

从前一个截图,我们现在可以看到我们的列占据了整个屏幕,并且所有文本元素都在父元素内水平和垂直居中。

让我们现在学习下一节中的 Row 可组合组件。

当我们想要水平组织项目时,我们使用 RowRow 的一个使用示例如下:

Row {
    Text(text = "Android")
    Text(text = "Kotlin")
    Text(text = "Compose")
}

在前面的代码中,Row 可组合组件被用来在一行中水平显示三个文本元素。这个预览看起来如下:

图 3.7 – 行预览

图 3.7 – 行预览

文本元素都排列在水平行中相邻。Row,就像可组合组件一样,支持添加修饰符。让我们将我们的 Row 修改如下:

Row(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp),
    verticalAlignment = Alignment.CenterVertically,
    horizontalArrangement = Arrangement.SpaceEvenly
) {
    Text(text = "Android")
    Text(text = "Kotlin")
    Text(text = "Compose")
}

在前面的代码中,我们为 Row 组合器添加了修饰符。fillMaxSize 修饰符使行填充整个可用空间。padding 修饰符为 Row 添加填充。verticalAlignmenthorizontalArrangement 修饰符分别用于垂直和水平对齐 Row 的子元素。注意,对于 horizontalArrangement 修饰符,我们使用了 Arrangement.SpaceEvenly 选项。这确保每个子元素在父元素中占据相等的空间。这个预览看起来如下:

图 3.8 – 行修饰符预览

图 3.8 – 行修饰符预览

图 3**.8 所见,行占据了整个屏幕,文本元素在屏幕宽度内均匀分布。

在下一节中,我们将学习关于 Box 布局的内容。

盒子

Box 布局允许我们使用 X 和 Y 坐标以灵活的方式定位子元素。让我们看看一个代码示例:

Box(
    modifier = Modifier
        .size(100.dp),
    contentAlignment = Alignment.Center
) {
    Icon(
        modifier = Modifier
            .size(80.dp),
        imageVector = Icons.Outlined.Notifications,
        contentDescription = null,
        tint = Color.Green
    )
    Text(text = "9")
}

在前面的代码中,我们有一个 Box 组合器,它有一个 IconText 组合器作为其子元素。我们将 Box 组合器的大小设置为 100.dp,将 Icon 组合器的大小设置为 80.dp。使用 contentAlignment 参数将文本和图标组合器放置在 Box 组合器的中心。它们被放置在 Box 组合器的中心,因为我们已将 contentAlignment 参数指定为 Alignment.Center。它们也堆叠在一起,因为 Box 组合器是一个布局组合器,它将子元素堆叠在其上方。我们的 Box 组合器的预览如下:

图 3.9 – 盒子预览

图 3.9 – 盒子预览

如我们从 图 3**.9 中所见,通知图标和文本是堆叠在一起的。Box 组合器使我们能够实现这一点以及更多功能。

现在,让我们在下一节中看看如何在 Jetpack Compose 中显示列表。

列表

作为 Android 开发者,我们需要制作显示项目列表的应用程序。这可能是一份电影列表、订单、歌曲或书籍列表。那么,我们在 Compose 中如何做到这一点呢?对我们来说,好消息是 Jetpack Compose 使我们更容易做到这一点。Compose 提供了 LazyColumnLazyRow 组件,可以用来显示项目列表。这些组件非常高效和性能良好。它们只渲染屏幕上可见的项目,而不是一次性渲染所有项目。LazyColumn 垂直显示项目,而 LazyRow 水平显示项目。LazyColumnLazyRow 通常针对大型数据集进行优化,有时并不适用于所有用例。这些组合器函数允许您定义列表的内容为一个返回单个项目的函数,然后 Compose 将自动生成并渲染屏幕上可见的每个项目的 UI 元素。

让我们看看 LazyColumn 的一个示例:

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .background(Color.LightGray)
) {
    items(100) {
        Text(
            modifier = Modifier
                .padding(8.dp),
            text = "Item number $it"
        )
    }
}

我们有包含 100 个项目的LazyColumn。每个项目都是一个Text可组合元素。这个预览看起来如下:

图 3.10 – LazyColumn 预览

图 3.10 – LazyColumn 预览

图 3.10中我们可以看到,我们现在有一个垂直滚动的项目列表。如前所述,它只显示可以适应屏幕的项目。如果我们使用预览的交互模式,我们将能够滚动到列表的底部。

让我们看看LazyRow的等效代码:

LazyRow(
    modifier = Modifier
        .fillMaxWidth()
        .background(Color.LightGray)
        .padding(8.dp)
) {
    items(100) {
        Text(
            modifier = Modifier
                .padding(8.dp),
            text = "Item number $it"
        )
    }
}

我们有包含 100 个项目的LazyRow。每个项目都是一个Text可组合元素。这个预览看起来如下:

图 3.11 – LazyRow 预览

图 3.11 – LazyRow 预览

图 3.11中我们可以看到,我们现在有一个水平滚动的项目列表。与LazyColumn类似,它只显示可以适应屏幕的项目。如果我们使用预览的交互模式,我们可以滚动到列表的末尾。

我们还有两种更多的列表布局类型,LazyVerticalGridLazyHorizontalGrid。这两种布局是懒加载网格的一部分,帮助我们以网格的形式排列内容。它们通常用于如画廊、电影和电子表格等应用程序。LazyVerticalGrid在网格中创建一个垂直的项目列表。让我们看看LazyVerticalGrid的示例代码:

LazyVerticalGrid(
    modifier = Modifier
        .fillMaxSize()
        .background(Color.LightGray)
        .padding(8.dp),
    columns = GridCells.Fixed(3)
) {
    items(100) {
        Text(
            modifier = Modifier
                .padding(8.dp),
            text = "Item number $it"
        )
    }
}

我们使用了LazyVerticalGrid可组合元素。我们像之前一样传递我们的修饰符。注意我们还有一个columns参数。此参数允许我们指定列数以及项目如何在列中排列。在这种情况下,我们指定GridCellsFixed。这意味着网格将具有固定数量的列或行,如果是LazyHorizontalGrid。我们还有Adaptive类型,它定义了一个网格,具有尽可能多的行或列,条件是每个单元格都有最小尺寸,所有额外空间都均匀分布。我们的预览将如下所示:

图 3.12 – LazyVerticalGrid 预览

图 3.12 – LazyVerticalGrid 预览

我们将文本元素放置在三个列的网格中。我们现在可以垂直滚动这些项目。现在让我们看看LazyHorizontalGrid的代码:

LazyHorizontalGrid(
    modifier = Modifier
        .fillMaxSize()
        .background(Color.LightGray)
        .padding(8.dp),
    rows = GridCells.Fixed(3)
) {
    items(100) {
        Text(
            modifier = Modifier
                .padding(8.dp),
            text = "Item number $it"
        )
    }
}

代码与LazyVerticalGrid的代码类似。唯一的区别是我们现在使用LazyHorizontalGrid,而不是列,我们现在传递rows来描述单元格将如何形成行。预览将如下所示:

图 3.13 – LazyHorizontalGrid 预览

图 3.13 – LazyHorizontalGrid 预览

图 3.13所示,我们现在在整个屏幕上有三行,我们也可以水平滚动它们。

除了LazyVerticalGridLazyHorizontalGrid,我们还有LazyVerticalStaggeredGridLazyHorizontalStaggeredGrid,它们非常相似;唯一的区别是它们分别适应子元素的高度和宽度,这意味着它们都没有统一的高度或宽度。

让我们现在看看下一节中的ConstraintLayout

ConstraintLayout

此布局使我们能够创建响应式布局。我们可以使用相对定位创建复杂布局。ConstraintLayout使用链、屏障和指南来定位子元素相对于彼此的位置。

它作为一个单独的依赖项提供,我们需要将其添加到我们的项目中。为了添加它,让我们将此依赖项添加到我们的应用程序build.gradle文件中:

 implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.1'

这将 Jetpack Compose 依赖项添加到我们的项目中。约束布局的布局代码如下:

ConstraintLayout(
  modifier = Modifier
    .padding(16.dp)
) {
    val (icon, text) = createRefs()
      Icon(
          modifier = Modifier
            .size(80.dp)
            .constrainAs(icon) {
                top.linkTo(parent.top)
                bottom.linkTo(parent.bottom)
                start.linkTo(parent.start)
            },
          imageVector = Icons.Outlined.Notifications,
          contentDescription = null,
          tint = Color.Green
      )
    Text(
        modifier = Modifier
          .constrainAs(text) {
              top.linkTo(parent.top)
              bottom.linkTo(parent.bottom)
              start.linkTo(icon.end) },
        text = "9",
        style = MaterialTheme.typography.titleLarge
    )
}

在前面的代码中,我们使用了ConstraintLayout可组合函数来创建ConstraintLayout。在ConstraintLayout内部,我们使用了createRefs()函数创建了两个引用,一个用于图标,一个用于文本。然后我们使用了constrainAs()函数将图标和文本约束到父元素上。我们使用了linkTo()函数将图标和文本链接到父元素。在这种情况下,我们将图标链接到父元素的起始、顶部和底部。对于文本,我们将其链接到父元素的顶部和底部。我们还额外将文本的起始链接到图标的结束。我们的预览将如下所示:

图 3.14 – ConstraintLayout 预览

图 3.14 – ConstraintLayout 预览

从前面的截图,我们可以看到图标右侧有一个图标和文本。ConstraintLayout有助于定位元素相对于父元素或彼此的位置。

摘要

在本章中,我们介绍了 Jetpack Compose,这是一种声明性方式创建应用程序 UI。我们还学习了 Compose 中的不同布局以及 Jetpack Compose 编译器如何将状态渲染到 UI 中。

在下一章中,我们将基于我们已经学到的内容,探讨如何使用Material Design 3设计美观且直观的应用程序。我们将学习 Material Design 3,其功能以及如何将动态颜色添加到我们的应用程序中。

第四章:使用 Material Design 3 进行设计

Material Design 是由 Google 开发的设计系统。它帮助我们创建美观的 UI。它提供了一套指南和组件,供我们在开发 Android 应用时使用。

在本章中,我们将介绍Material 3。我们还将介绍 Material 3 提供的功能。最后,我们将学习如何在我们的 Android 应用中使用 Material 3 以及 Material 3 中的某些组件。

在本章中,我们将介绍以下主要内容:

  • Material Design 3 及其功能

  • 在我们的应用中使用 Material Design 3

  • 为大屏幕和可折叠设备构建

  • 使我们的应用易于访问

技术要求

要遵循本章中的说明,您需要下载 Android Studio Hedgehog 或更高版本 (developer.android.com/studio)。

您可以在 github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterfour 找到本章的代码。

Material Design 3 及其功能

Material Design 3Material 3)的发布带来了许多新功能,帮助我们为应用构建 UI。以下是 Material Design 3 的部分功能:

  • 动态色彩:这是一个将我们应用的颜色设置为用户壁纸颜色的色彩系统。系统界面也会适应这种颜色。这使得用户能够拥有个性化的应用体验。请注意,动态色彩仅适用于 Android 12 及以上设备。

  • 更多组件:Material 3 提供了一组新的改进组件,可供使用。一些组件具有新的 UI,而其他组件已被添加到 API 中。

  • 简化排版:Material 3 对排版进行了更简化的命名和分组。我们有以下类型:展示标题标题正文标签,每种类型都支持尺寸。这使得我们更容易在应用中定义样式。

  • 改进的色彩方案:通过添加更多色彩方案以实现精细的色彩定制,色彩方案已经经历了许多改进。这也使得我们更容易在我们的应用中支持深色和浅色主题。此外,他们还创建了一个新的工具,Material Theme Builder (m3.material.io/theme-builder),它允许我们为我们的应用生成和导出深色和浅色主题颜色。

  • 简化形状:与排版类似,形状也被简化为以下几种:极小极大。所有这些形状都带有默认值,我们可以随时覆盖以使用自己的值。

对于我们来说,好消息是从 Android Studio Hedgehog 版本开始,我们有了预配置了 Material 3 的项目模板,这使得事情变得更容易。甚至我们在 第二章 中创建的项目也已经预配置了 Material 3。

Material 3 API 及其前身为我们提供了广泛的可用于我们应用的组件。在下一个子节中,我们将查看一些常见的组件。

材料组件

Material 库包含预构建的组件,我们可以使用它们来构建常见的 UI 组件。让我们看看一些常用的组件以及它们在 Material 3 中的更新。

顶部应用栏

这是一个显示在屏幕顶部的组件。它有一个标题,还可以包含与用户所在的屏幕相关的某些操作。一些常见的操作是屏幕右上角的设置图标。在 Material 3 中,我们有四种类型的顶部应用栏:居中对齐小号中等大号,如图所示。

图 4.1 – 小号顶部应用栏

图 4.1 – 小号顶部应用栏

图 4.2 – 居中对齐的顶部应用栏

图 4.2 – 居中对齐的顶部应用栏

图 4.3 – 中等顶部应用栏

图 4.3 – 中等顶部应用栏

图 4.4 – 大号顶部应用栏

图 4.4 – 大号顶部应用栏

图 4.1图 4.4 所示,所有顶部栏的宽度相同,只是在高度和标题文本的位置上有所不同。

让我们看看这些顶部应用栏的示例代码:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PacktCenterAlignedTopBar() {
    CenterAlignedTopAppBar(
        title = {
            Text(text = "Packt Publishing")
        }
    )
}

在这里,我们使用了自定义的可组合组件,并在其中使用了 Material 3 的 CenterAlignedTopBar 可组合组件,并将 Text 传递给 title 可组合组件。其他三个(LargeTopAppBarMediumTopAppBarTopAppBar)类似;唯一的区别是您将使用的可组合组件。请注意,我们使用了 @OptIn 注解,因为这些组件仍然是实验性的。

接下来,让我们看看 FloatingActionButton 组件。

FloatingActionButton

大多数应用都使用该组件来表示在应用中经常使用的操作调用。例如,在聊天应用中创建一个新的聊天。它通常位于屏幕的右下角或其他位置,具体取决于您的用例。这就是我们创建组件的方式:

FloatingActionButton(
    onClick = { /*TODO*/ },
    content = {
        Icon(
            imageVector = Icons.Default.Add ,
            contentDescription = "New Chat"
        )
    }
)

我们使用 Material 3 库中的 FloatingActionButton 组件。在可组合组件中,我们有 onclick 参数,并在 content lambda 中传递一个具有 add 图标的 Icon 可组合组件。预览应该是以下内容:

图 4.5 – FloatingActionButton

图 4.5 – FloatingActionButton

FloatingActionButton 组件有以下几种尺寸:大号、常规和小号,您可以使用适合您目的的任意一种。

我们还有一种名为 ExtendedFloatingActionButtonFloatingActionButton 组件,其外观如下:

图 4.6 – 扩展浮动操作按钮

图 4.6 – 扩展浮动操作按钮

如前图所示,ExtendedFloatingActionButton 组件允许我们向我们的 FAB 添加更多项目。它们比正常的 FloatActionButton 组件更宽。在这种情况下,我们有一个带有文本 新聊天Text 组合组件,以及图标。你可以带图标或不带图标使用它。这个实现的代码如下:

ExtendedFloatingActionButton(
    onClick = { /*TODO*/ },
    content = {
        Icon(
            imageVector = Icons.Default.Add ,
            contentDescription = "New Chat"
        )
        Text(
            modifier = Modifier.padding(10.dp),
            text = "New Chat"
        )
    }
)

这里,我们使用了 ExtendedFloatingActionButton 组件,并且仍然传递了之前的相同参数。唯一的区别是,在内容内部,我们传递了一个文本,因为 content 漏斗函数暴露了 RowScope,这意味着子组合组件将按行排列。

接下来,让我们看看底部应用栏组件。

底部应用栏

底部应用栏组件在屏幕底部显示导航项。它们通常对具有三个到五个主要目的地的应用程序非常有用。

让我们看看底部应用栏的代码:

BottomAppBar(
    actions = {
        Icon(imageVector = Icons.Rounded.Home, contentDescription = "Home Screen")
        Icon(imageVector = Icons.Rounded.ShoppingCart, contentDescription = "Cart Screen")
        Icon(imageVector = Icons.Rounded.AccountCircle, contentDescription = "Account Screen")
    }
)

我们使用 BottomAppBar 组件,并在 actions 漏斗函数中传递三个 Icon 组合组件来表示我们应该显示的项目。这就是组合组件预览将看起来像这样:

图 4.7 – 底部应用栏

图 4.7 – 底部应用栏

图 4,7* 中,我们可以看到有三个图标水平排列。

此外,在 BottomAppBar 中,我们还可以提供 FloatingActionButton 组件。我们将使用我们早期使用的 FloatingActionButton 组件。更新的组件代码如下:

BottomAppBar(
    actions = {
        Icon(imageVector = Icons.Rounded.Home, contentDescription = "Home Screen")
        Icon(imageVector = Icons.Rounded.ShoppingCart, contentDescription = "Cart Screen")
        Icon(imageVector = Icons.Rounded.AccountCircle, contentDescription = "Account Screen")
    },
    floatingActionButton = {
        PacktFloatingActionButton()
    }
)

在前面的代码中,我们使用了 floatingActionButton 参数,并传递了我们之前创建的 PacktFloatingActionButton()。更新的预览将如下所示:

图 4.8 – 带有浮动操作按钮的底部应用栏

图 4.8 – 带有浮动操作按钮的底部应用栏

如前图所示,我们的 BottomAppBar 现在右侧有一个漂亮的 FloatingActionButton。FAB 会自动为你定位到右侧。

我们已经单独看了不同的组件,但当我们将它们一起放置在同一个屏幕上时会发生什么?接下来,我们将要看看 Scaffold,它是为此而设计的。

Scaffold

这是一个由 Material Design 提供的布局,它有助于轻松地将所有组件放置在屏幕上它们期望的位置。

让我们看看一个具有顶部应用栏、浮动操作按钮、屏幕居中文本和底部导航栏的 Scaffold 示例:

Scaffold(
    topBar = {
        PacktSmallTopAppBar()
    },
    bottomBar = {
        PacktBottomNavigationBar()
    },
    floatingActionButton = {
        PacktFloatingActionButton()
    },
    content = { paddingValues ->
      Column(
          modifier = Modifier
              .fillMaxSize()
              .padding(paddingValues)
              .background(Color.Gray.copy(alpha = 0.1f)),
          verticalArrangement = Arrangement.Center,
          horizontalAlignment = Alignment.CenterHorizontally
      ) {
          Text(
              modifier = Modifier.padding(10.dp),
              text = "Mastering Kotlin for Android Development - Chapter 4",
              textAlign = TextAlign.Center
          )
        }
    }
)

这里发生了很多事情,所以让我们分解一下:

  • Scaffold 组合组件用于创建一个实现 Material Design 指南的布局。它是一个包含顶部栏、底部栏、浮动操作按钮和内容的容器。

  • topBar 参数用于指定顶部栏。在这种情况下,我们使用的是我们之前创建的 PacktSmallTopAppBar 组合组件。

  • bottomBar 参数用于指定底部栏。在这种情况下,我们使用的是 PacktBottomNavigationBar 组合器。

  • floatingActionButton 参数用于指定浮动操作按钮。在这种情况下,我们使用的是 PacktFloatingActionButton 组合器。

  • content 参数用于指定屏幕的内容。在这种情况下,我们使用了一个包含 Text 组合器的 Column 组合器。文本通过 verticalArrangementhorizontalAlignment 参数在列中居中。请注意,在 Column 内部,我们使用 paddingValues 参数为列添加填充。这是因为 Scaffold 组合器将 padding 值传递给了 content 参数。

我们的 Scaffold 组合器已经准备好了,让我们看看它的预览效果:

图 4.9 – Scaffold

图 4.9 – Scaffold

图 4**.9 中,我们可以看到 Scaffold 组合器已经将顶部栏、底部栏和浮动操作按钮添加到了屏幕上。组件也按照 Material Design 指南放置在了正确的位置。

我们已经了解了许多组件;Material 3 仍然为我们提供了更多开箱即用的组件。我们将在本书的后续章节中使用一些这些组件。要查看所有组件的完整列表,请访问 Material 3 组件网站(https://m3.material.io/components)以查看它们及其指南。

现在我们已经了解了 Material 3 及其特性,让我们看看如何将其添加到我们的应用中。

在我们的应用中使用 Material Design

为了利用上一节中我们查看的 Material 3 特性,我们需要将其添加到我们的应用中。幸运的是,随着 Android Studio Hedgehog 的推出,我们有 Material 3 模板。甚至我们一直在使用的示例应用也已经使用了 Material 3。真是太酷了!让我们快速扫描依赖项,了解正在发生的事情:

implementation 'androidx.core:core-ktx:1.10.1'
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation platform('androidx.compose:compose-bom:2022.10.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'

这个依赖项块为我们设置了几项,包括 compose 依赖项。其中最重要的是 androidx.compose.material3:material3 依赖项。这是包含 Material 3 组件的依赖项。我们使用 Compose 物料清单BOM)来管理我们的依赖项。这意味着我们不需要指定每个依赖项的版本。相反,我们指定 BOM 的版本,它将为我们管理依赖项的版本。这是在 Compose 中管理依赖项的推荐方式。这就是为什么我们没有指定每个依赖项的版本。

有了这些,我们的项目就准备好利用 Material 3 的特性了。在下一小节中,我们将为应用添加更多配色方案。

添加 Material Design 3 配色方案

如前所述,Material 3 带来了许多精细的配色方案,并引入了 动态颜色。然而,它们并不是与 Android Studio 生成的模板一起设置的。在接下来的几个步骤中,我们将设置它们。

转到ui/theme包并打开Color.kt文件,其中包含以下代码:

val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

到目前为止,此文件只定义了少量颜色。这些颜色并没有涵盖 Material 3 提供的所有颜色标记。随着我们在应用程序中需要更多颜色时,我们将添加更多颜色。

我们将使用材料主题构建器工具来生成这些颜色。让我们打开我们的浏览器并转到材料主题构建器工具(m3.material.io/theme-builder)。我们将看到以下屏幕:

图 4.10 – 材料主题构建器工具

图 4.10 – 材料主题构建器工具

此工具帮助我们可视化应用程序的颜色方案,并显示不同组件的主题化效果。它使我们更容易自定义并生成一致的颜色方案。它有两个选项卡:动态自定义。在动态选项卡中,我们可以选择预加载的颜色或壁纸来查看颜色变化。一个有用的功能是,我们还可以添加自己的壁纸并根据壁纸生成颜色。

自定义选项卡中,我们可以选择一种颜色,工具会根据我们选择的颜色为我们生成所有互补色,确保色彩搭配和谐:

图 4.11 – 材料主题构建器工具自定义颜色

图 4.11 – 材料主题构建器工具自定义颜色

在左侧,我们有核心颜色部分,我们可以为我们的应用程序选择主色次色三级色中性色

我们将选择主色选项,这将打开一个颜色选择器对话框:

图 4.12 – 颜色选择器

图 4.12 – 颜色选择器

由于我们正在更改主色,我们将能够看到视觉预览会相应地更改以匹配我们选择的颜色。现在我们已经准备好了主色,我们将导出文件以便在项目中使用:

图 4.13 – 导出选项

图 4.13 – 导出选项

图 4.13所示,视觉预览已更改为我们选择的颜色。点击包含Color.ktTheme.kt文件的ui文件夹中的theme文件夹。

让我们打开Color.kt文件,其中包含以下代码:

val md_theme_light_primary = Color(0xFF006C49)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFF7AFAC0)
val md_theme_light_onPrimaryContainer = Color(0xFF002113)
val md_theme_light_secondary = Color(0xFF4D6357)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFD0E8D8)
val md_theme_light_onSecondaryContainer = Color(0xFF0A1F16)
val md_theme_light_tertiary = Color(0xFF3D6473)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFC0E9FB)
val md_theme_light_onTertiaryContainer = Color(0xFF001F29)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFBFDF9)
val md_theme_light_onBackground = Color(0xFF191C1A)
val md_theme_light_surface = Color(0xFFFBFDF9)
val md_theme_light_onSurface = Color(0xFF191C1A)
val md_theme_light_surfaceVariant = Color(0xFFDCE5DD)
val md_theme_light_onSurfaceVariant = Color(0xFF404943)
val md_theme_light_outline = Color(0xFF707973)
val md_theme_light_inverseOnSurface = Color(0xFFEFF1ED)
val md_theme_light_inverseSurface = Color(0xFF2E312F)
val md_theme_light_inversePrimary = Color(0xFF5CDDA5)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF006C49)
val md_theme_light_outlineVariant = Color(0xFFC0C9C1)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF5CDDA5)
val md_theme_dark_onPrimary = Color(0xFF003824)
val md_theme_dark_primaryContainer = Color(0xFF005236)
val md_theme_dark_onPrimaryContainer = Color(0xFF7AFAC0)
val md_theme_dark_secondary = Color(0xFFB4CCBD)
val md_theme_dark_onSecondary = Color(0xFF20352A)
val md_theme_dark_secondaryContainer = Color(0xFF364B40)
val md_theme_dark_onSecondaryContainer = Color(0xFFD0E8D8)
val md_theme_dark_tertiary = Color(0xFFA5CDDE)
val md_theme_dark_onTertiary = Color(0xFF063543)
val md_theme_dark_tertiaryContainer = Color(0xFF244C5A)
val md_theme_dark_onTertiaryContainer = Color(0xFFC0E9FB)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF191C1A)
val md_theme_dark_onBackground = Color(0xFFE1E3DF)
val md_theme_dark_surface = Color(0xFF191C1A)
val md_theme_dark_onSurface = Color(0xFFE1E3DF)
val md_theme_dark_surfaceVariant = Color(0xFF404943)
val md_theme_dark_onSurfaceVariant = Color(0xFFC0C9C1)
val md_theme_dark_outline = Color(0xFF8A938C)
val md_theme_dark_inverseOnSurface = Color(0xFF191C1A)
val md_theme_dark_inverseSurface = Color(0xFFE1E3DF)
val md_theme_dark_inversePrimary = Color(0xFF006C49)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF5CDDA5)
val md_theme_dark_outlineVariant = Color(0xFF404943)
val md_theme_dark_scrim = Color(0xFF000000)

如你现在所看到的,我们添加了更多的颜色。让我们将这些颜色复制到我们项目中的Color.kt文件中。接下来,让我们打开解压文件夹中的Theme.kt文件。

我们会注意到它与Theme.kt文件类似,但它定义了所有 Material 3 颜色方案。复制此文件的全部内容并将其粘贴到我们项目中的Theme.kt文件中。我们将对代码进行一些小的编辑,以确保我们保持主题中的ChapterFourTheme名称和动态颜色逻辑。接下来,我们需要将DarkColorScheme变量的值更改为以下内容:

private val DarkColorScheme = darkColorScheme(
    primary = md_theme_light_primary,
    onPrimary = md_theme_light_onPrimary,
    primaryContainer = md_theme_light_primaryContainer,
    onPrimaryContainer = md_theme_light_onPrimaryContainer,
    secondary = md_theme_light_secondary,
    onSecondary = md_theme_light_onSecondary,
    secondaryContainer = md_theme_light_secondaryContainer,
    onSecondaryContainer = md_theme_light_onSecondaryContainer,
    tertiary = md_theme_light_tertiary,
    onTertiary = md_theme_light_onTertiary,
    tertiaryContainer = md_theme_light_tertiaryContainer,
    onTertiaryContainer = md_theme_light_onTertiaryContainer,
    error = md_theme_light_error,
    errorContainer = md_theme_light_errorContainer,
    onError = md_theme_light_onError,
    onErrorContainer = md_theme_light_onErrorContainer,
    background = md_theme_light_background,
    onBackground = md_theme_light_onBackground,
    surface = md_theme_light_surface,
    onSurface = md_theme_light_onSurface,
    surfaceVariant = md_theme_light_surfaceVariant,
    onSurfaceVariant = md_theme_light_onSurfaceVariant,
    outline = md_theme_light_outline,
    inverseOnSurface = md_theme_light_inverseOnSurface,
    inverseSurface = md_theme_light_inverseSurface,
    inversePrimary = md_theme_light_inversePrimary,
    surfaceTint = md_theme_light_surfaceTint,
    outlineVariant = md_theme_light_outlineVariant,
    scrim = md_theme_light_scrim,
)

在前面的代码中,我们使用 DarkColorScheme 函数来创建一个深色颜色方案。我们传递了使用 Material Theme Builder 工具生成的颜色。我们将使用此颜色方案来创建深色主题。深色颜色方案变量以类似的方式定义,并且我们可以从工具的 Theme.kt 文件中复制值并添加到那里。现在让我们看看我们的主题可组合项:

@Composable
fun ChapterFourTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            val window = (view.context as Activity).window
            window.statusBarColor = colorScheme.primary.toArgb()
            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
        }
    }
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

让我们分解前面的代码:

  • ChapterFourTheme 可组合项用于为我们的应用创建主题。它接受三个参数:

    • darkTheme 参数用于指定主题是深色还是浅色。默认情况下,我们使用系统主题。

    • dynamicColor 参数用于指定主题是否应使用动态颜色。默认情况下,我们使用动态颜色。

    • content 参数用于指定主题的内容。在这种情况下,我们使用 MaterialTheme 可组合项来为我们的应用创建主题。

  • colorScheme 变量用于指定要使用的颜色方案。我们使用 when 表达式来确定要使用的颜色方案。如果 dynamicColor 参数为 true 且设备正在运行 Android 12 或更高版本,我们使用 dynamicDarkColorSchemedynamicLightColorScheme 函数来创建动态颜色方案。当不使用动态颜色时,我们回退到正常主题。如果 darkTheme 参数为 true,我们使用 DarkColorScheme 变量来创建深色颜色方案。否则,我们使用 LightColorScheme 变量来创建浅色颜色方案。

  • view 变量用于获取正在使用该主题的视图。

  • SideEffect 可组合项用于执行副作用。在这种情况下,我们使用它来设置状态栏颜色和状态栏图标颜色。我们使用 WindowCompat 类来获取 InsetsController 并设置状态栏颜色和状态栏图标颜色。我们使用 colorScheme.primary 颜色来设置状态栏颜色。我们使用 darkTheme 参数来确定状态栏图标颜色应该是浅色还是深色。

  • 使用 MaterialTheme 可组合项来为我们的应用创建主题。我们使用 colorScheme 参数来指定要使用的颜色方案。我们使用 typography 变量来指定要使用的排版。

为了能够看到我们所做的更改,我们需要调用我们之前在 ChapterFourTheme 块中 MainActivity.kt 文件内创建的 PacktScaffold

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ChapterFourTheme {
                PacktScaffold()
            }
        }
    }
}

让我们构建并运行应用。我们应该能够看到以下内容:

图 4.14 – 第四章应用

图 4.14 – 第四章应用

就像我一样,你可能想知道为什么应用没有我们在 Material Theme Builder 工具上设置的那种绿色。还记得我们 ChapterFourTheme 可组合项中的动态颜色逻辑吗?它负责我们看到的那种棕色。看看我的壁纸设置:

图 4.15 – 壁纸设置

图 4.15 – 壁纸设置

图 4.15所示,我的壁纸设置了棕色。这意味着我们的动态颜色逻辑正在工作,我们的应用可以很好地适应我的壁纸设置!

我们已经看到了如何在我们的应用中使用 Material 3 功能。在下一节中,我们将看到如何在 Jetpack Compose 中设计适用于大屏幕和可折叠设备的 UI。

设计大屏幕和可折叠设备的 UI

近年来,随着 Material 3 的发布,对平板电脑和可折叠设备的关注有所增加。因此,作为开发者,我们必须确保我们的应用在这些设备上运行良好。在本节中,我们将探讨如何使我们的应用在大屏幕和可折叠设备上运行良好。我们需要确保我们的应用适应不同的屏幕尺寸。使我们的应用具有响应性可以提供良好的用户体验。

Material 3 提供了规范布局作为创建大屏幕和可折叠设备 UI 的指南。这些布局如下:

  • 列表详情视图:在这里,我们在左侧放置一个项目列表,在右侧显示单个项目的详细信息。

  • 动态内容:在这里,我们将内容元素(如卡片)排列在一个可定制的网格中,这为大量内容提供了一个良好的视图。

  • 支持面板:在这里,我们将应用内容组织成主要和次要显示区域。主要区域显示主要内容,而次要区域显示辅助内容。主要区域占据屏幕的大部分,而次要区域占据较小的一部分。

为了展示不同的布局,我们必须知道用户使用的设备的屏幕尺寸。幸运的是,Jetpack Compose 为我们提供了一种获取屏幕尺寸的方法。我们有 Material 3 的 WindowSizeClass 来帮助我们确定在应用中显示哪种布局。

我们将学习如何使用 WindowSizeClass

使用 WindowSizeClass

为了使用 WindowSizeClass,我们必须在我们的应用中添加以下依赖项:

implementation 'androidx.compose.material3:material3-window-size-class'

这是 Material 3 依赖项,它将 WindowSizeClass 添加到我们的项目中。请注意,我们没有为这个依赖项提供版本号。这是因为我们正在使用 Compose BOM 来管理我们的依赖项。BOM 将为我们管理这个依赖项的版本。

WindowSizeClass 将可用的屏幕宽度分为三个类别:

  • 紧凑型:适用于宽度小于 600 dp 的设备。通常,这些设备处于纵向模式。

  • 中等:适用于宽度在 600 dp 和 840 dp 之间的设备。属于这一类别的设备包括纵向模式下的平板电脑和可折叠设备。

  • 扩展型:适用于宽度大于 840 dp 的设备。属于这一类别的设备包括平板电脑和横向模式下的可折叠设备、横向模式下的手机和桌面电脑。

WindowSizeClass 使用 widthSizeClass 来获取屏幕宽度。除了 widthSizeClass,它还有 heightSizeClass 来帮助我们确定屏幕高度。

让我们看看 widthSizeClass 的实际应用:

when(calculateWindowSizeClass(activity = this).widthSizeClass) {
    WindowWidthSizeClass.Compact -> {
        CharactersScreen(
            navigationOptions = NavigationOptions.BottomNavigation,
            showDetails = false
        )
    }
    WindowWidthSizeClass.Medium -> {
        CharactersScreen(
            navigationOptions = NavigationOptions.NavigationRail,
            showDetails = true
        )
    }
    WindowWidthSizeClass.Expanded -> {
        CharactersScreen(
            navigationOptions = NavigationOptions.NavigationDrawer,
            showDetails = true
        )
    }
    else -> {
        CharactersScreen(
            navigationOptions = NavigationOptions.BottomNavigation,
            showDetails = false
        )
    }
}

下面是对前面代码所做解释:

  • calculateWindowSizeClass 函数用于计算 WindowSizeClass。我们将活动作为参数传递。该函数来自 WindowSizeClass API。它有 widthSizeClassheightSizeClass 属性,我们可以使用它们来分别获取屏幕的宽度和高度。

  • 我们使用 widthSizeClass 来自定义我们的显示选项:

    • 对于 WindowWidthSizeClass.Compact 的情况,我们使用 BottomNavigation 进行导航,UI 应仅显示角色列表

    • 对于 WindowWidthSizeClass.Medium 的情况,我们使用 NavigationRail 进行导航,UI 应显示角色列表和所选角色的详细信息

    • 对于 WindowWidthSizeClass.Expanded 的情况,我们使用 NavigationDrawer 进行导航,UI 应显示角色列表和所选角色的详细信息

    • 我们有一个默认情况,其中我们使用 BottomNavigation 进行导航,UI 应仅显示角色列表

一眼就能看出我们如何利用 WindowSizeClass 来根据屏幕大小自定义我们的 UI 和导航类型。这是一个非常强大的功能,我们可以用它来使我们的应用程序响应。这确保了我们充分利用屏幕大小,并提供良好的用户体验。

本节中展示的示例很简单。在本书的第七章中,我们将有一个更详细的示例,其中我们将使用 WindowSizeClass 来根据屏幕大小自定义我们的 UI。

现在我们知道了如何设计和构建适用于大屏幕和可折叠设备的应用程序,让我们看看本章的另一个重要主题,即无障碍性。

使我们的应用程序无障碍

使我们开发的应用程序无障碍非常重要。它确保我们的应用程序可以被每个人使用。在本节中,我们将探讨我们如何使我们的应用程序无障碍。Jetpack Compose 使用 语义 来使我们的应用程序无障碍。语义用于描述我们应用程序中的 UI 元素。它们被辅助功能服务用来使我们的应用程序无障碍。语义也被自动化测试工具用来测试我们的应用程序。使我们的应用程序无障碍的一些最佳实践如下:

  • 我们应始终确保所有可点击或可触摸的元素或需要用户交互的元素足够大,以便容易点击或触摸。大多数 Material 组件默认大小已经足够大,可以轻松点击或触摸。如果我们必须自行设置大小,我们应该确保大小至少为 48 dp x 48 dp。

  • 我们应该为我们的可组合元素添加内容描述。例如,IconImage组件提供了这个参数来描述视觉元素以供辅助服务使用。我们应该始终为这些组件提供内容描述。以下是一个示例:

    Icon(
        modifier = Modifier.size(48.dp),
        painter = painterResource(id = R.drawable.ic_launcher_foreground),
        contentDescription = "Icon"
    )
    

    你可以在前面的代码中看到,我们正在使用contentDescription参数为Icon提供描述。这是一个我们应该始终遵循的良好实践。

  • 我们应该为可点击元素添加标签。我们可以将可点击标签传递给可点击修饰符。这使得我们能够为我们的可点击元素添加描述。以下是一个示例:

    Text(
        modifier = Modifier
            .clickable(
                onClick = { /*TODO*/ },
                onClickLabel = "Click Me"
            )
            .padding(10.dp),
        text = "Click Me"
    )
    

    在前面的示例中,我们正在使用onClickLabel参数为Text可组合元素添加描述。这是一个我们应该始终遵循的良好实践。

  • 通过使用语义,我们还可以描述标题。标题用于描述其后的内容。我们可以使用语义为我们的可组合元素添加标题。以下是一个示例:

    Text(
        modifier = Modifier
            .semantics { heading() }
            .padding(10.dp),
        text = "Heading One"
    )
    
  • 我们还可以提供关于我们可组合元素状态的信息。例如,我们可以提供关于按钮状态的信息。我们可以使用语义来提供这些信息。以下是一个示例:

    Button(
        modifier = Modifier
            .semantics { stateDescription = "Disabled" }
            .padding(10.dp),
        onClick = { /*TODO*/ },
        enabled = false
    ) {
        Text(text = "Disabled Button")
    }
    

    这有助于我们向用户告知我们可组合元素的状态。

  • 对于某些组件组,我们也可以使用mergeDescendants参数合并子可组合元素的语义。以下是一个示例:

    Column(
        modifier = Modifier
            .padding(10.dp)
            .semantics(mergeDescendants = true) { }
    ) {
        Text(text = "Heading One")
        Text(text = "Heading Two")
        Text(text = "Heading Three")
    }
    

    当我们想要为多个可组合元素提供描述时,合并后裔是有用的。在前面的示例中,我们正在使用mergeDescendants参数合并Text可组合元素的语义。然而,在使用此参数时我们应该小心。我们只应该在想要为多个可组合元素提供描述时使用它。如果我们为大量可组合元素使用它,可能会导致性能问题,也可能使用户感到困惑。

要了解更多关于 Jetpack Compose 的辅助功能信息,请访问官方文档(developer.android.com/jetpack/compose/accessibility)。

摘要

在本章中,我们介绍了 Material 3。我们还涵盖了 Material 3 提供的功能。我们看到了如何在我们的应用程序中使用 Material 3。此外,我们还介绍了如何为大型屏幕设计和开发我们的应用程序,最后,我们看到了如何使我们的 Jetpack Compose UI 易于访问。

在下一章中,我们将继续构建从前几章中获得的知识。我们将探讨如何构建我们的应用程序以及可用的不同架构。我们将学习如何在我们的应用程序中使用 Jetpack 库以及如何处理依赖注入。

第二部分:使用高级功能

在掌握了第一部分的基础知识之后,本部分将带你进入高级概念,为你提供对 Android 开发的深入理解。深入各种架构,重点转向掌握你应用的 MVVM 架构。此外,你将通过整合 Retrofit 网络库来揭示进行网络调用的复杂性。更进一步,你将利用 Kotlin 协程的力量,无缝执行异步网络请求。你的导航技巧将通过使用 Jetpack Navigation 库得到磨练,探索在大屏幕和可折叠设备上高效导航的技术。旅程继续,你将了解如何处理后台长时间运行的任务,并利用 Room 进行本地数据存储。最后,我们将揭开运行时权限的神秘面纱,你将理解其重要性,并掌握在应用中动态请求权限的技巧。

本节包含以下章节:

  • 第五章, 构建你的应用

  • 第六章, 使用 Kotlin 协程进行网络调用

  • 第七章, 在应用内导航

  • 第八章, 本地持久化数据和后台工作

  • 第九章, 运行时权限

第五章:架构你的应用程序

开发应用程序的过程需要具有可扩展性,这样你就可以长期维护应用程序,并且可以轻松地将开发工作转交给其他开发者或团队。为了能够做到这一点,我们需要正确考虑我们应用程序的架构。在本章中,我们将探讨如何构建我们的应用程序。

在本章中,我们将基于前几章所学的内容进行构建。我们将探讨 Android 项目中可用的不同架构。我们将深入探讨MVVM 架构及其不同层以及如何在架构中使用一些 Jetpack 库。此外,我们还将学习如何使用高级架构功能,例如依赖注入和 Kotlin Gradle DSL,以及版本目录来定义依赖项。

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

  • 应用程序架构简介

  • MVVM 深度解析

  • Jetpack 库

  • 依赖注入

  • 迁移到 Kotlin Gradle DSL 和使用版本目录

技术要求

要遵循本章中的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以在github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterfive找到本章的代码。

应用程序架构简介

到目前为止,我们已经学习了如何使用 Material 3 和 Jetpack Compose 创建应用程序并设计美观的用户界面。我们还没有开始为我们的应用程序采用任何架构。在本节中,我们将探讨一些我们可以用来构建应用程序的应用程序架构。我们还将探讨在构建应用程序时可以遵循的一些最佳实践。首先,让我们看看使用应用程序架构的一些好处:

  • 关注点分离:使用架构允许我们将代码分成不同的层。每一层只做一件事。这使得轻松地将代码分离和分组到不同的层变得容易。每一层都有其责任。这防止了事物被混淆,并使得维护代码变得更容易。

  • 简易测试:使用架构使得测试我们的代码变得容易。由于事物之间不是紧密耦合的,我们可以轻松地单独测试代码的每一层。

  • 易于维护:使用架构使得维护我们的代码变得容易。我们可以轻松地修改代码,而不会影响代码的其他部分。这使得长期维护代码变得容易。我们还可以替换代码的不同部分,因为我们有测试,我们可以测试不同的实现并确保没有东西被破坏。

  • 易于扩展:使用架构使得扩展我们的代码变得容易。我们可以轻松地添加新功能到代码中,而不会影响代码的其他部分。

  • 易于团队合作:使用架构使得团队合作变得容易。每个团队成员或团队可以工作在代码的不同层。这使得在代码库的不同部分并行工作成为可能。

  • 促进可重用性:随着时间的推移,我们可以将一些常用代码放置在项目中的通用包或模块中,然后可以在整个项目中重用这些代码,而无需重复编写代码。

当涉及到为我们的应用选择架构时,我们有大量的选项可以选择。没有特定的架构适合所有用例,因此我们总是建议与我们的团队讨论,看看哪种架构适合用例。每种架构都有其优缺点,我们或整个团队必须评估哪个的优点多于缺点。我们可以通过功能或通过层来构建我们的应用架构。当我们按功能构建架构时,我们有代表功能的层。当我们按层构建架构时,我们有代表我们应用层的层。以下是一些按功能构建架构的示例:

  • 主页功能

  • 个人资料功能

  • 设置功能

上述示例展示了我们如何通过功能来构建我们的应用架构。我们有主页个人资料设置功能。每个功能都有自己的层,以及与该功能相关的所有代码。

我们可以用来构建应用的架构之一如下:

  • 模型、视图和视图模型MVVM):这是最常用的架构,甚至谷歌也推荐我们在应用中使用它。应用具有模型视图视图模型层。模型层负责存储数据。视图层负责显示数据。视图模型层负责存储数据的状态。它还负责与模型和视图层进行通信。MVVM 促进了不同层之间关注点的清晰分离。它还支持数据绑定,这使得在数据变化时更新 UI 变得容易。与其他架构相比,它还有更少的样板代码。然而,它也有其缺点,其中之一是在某些时候学习曲线较大,并且很容易变得复杂,尤其是在有很多功能的情况下。

  • 模型视图意图MVI):它有三个关键层。模型层负责存储数据。视图层负责显示数据。意图层代表用户操作或事件,这些操作或事件被调度到模型以更新状态。MVI 促进单向数据流,数据流向同一方向。

  • 模型-视图-控制器MVC):这种架构有三个层次。模型层代表业务逻辑并持有数据。视图层负责显示数据。控制器层负责并在模型和视图之间充当中间人。它处理用户输入并更新视图和模型。MVC 非常直接,尤其是在开始时,它能够实现快速迭代和展示应用架构。它唯一的问题是层与层之间耦合紧密,这使得测试和扩展变得困难。

  • 模型-视图-表示者MVP):这种架构有三个层次。模型层代表业务逻辑并持有数据。视图层显示数据和 UI 组件,并观察用户交互。视图将所有 UI 相关逻辑委托给表示者。表示者层包含表示逻辑,并在模型和视图之间充当中间人。它处理用户输入并更新视图和模型。MVP 具有良好的关注点分离,代码易于测试。然而,由于每个视图都必须有自己的表示者,因此它有很多样板代码。它也有一个较大的学习曲线,并且很容易变得复杂。

现在我们已经了解了不同的架构,让我们看看 MVVM 以及我们如何在应用中使用它。

深入了解 MVVM

我们已经看到了 MVVM 层及其优缺点。在本节中,我们将逐步在我们的应用中实现 MMVM 架构。我们将从模型层开始,向上进行。由于我们都喜欢有宠物的陪伴,我们将使用不同类型的宠物作为我们的数据。

让我们先创建一个 com.packt.chapterfive 包;然后,我们选择 data。在这个 data 包内,让我们创建一个 Pet 数据类,它将代表我们的宠物:

data class Pet(
    val id: Int,
    val name: String,
    val species: String
)

Pet 数据类包含了我们宠物所有的数据。接下来,我们将创建一个仓库接口及其实现,以便我们能够获取这些宠物。在 data 包内创建一个名为 PetsRepository 的新文件,并包含以下代码:

interface PetsRepository {
    fun getPets(): List<Pet>
}

这是一个包含一个方法返回 List<Pet> 的接口。接下来,让我们为我们的接口创建实现类。仍然在 data 包内,创建一个名为 PetsRepositoryImpl 的新文件,并包含以下代码:

class PetsRepositoryImpl: PetsRepository {
    override fun getPets(): List<Pet> {
        return listOf(
            Pet(1, "Bella", "Dog"),
            Pet(2, "Luna", "Cat"),
            Pet(3, "Charlie", "Dog"),
            Pet(4, "Lucy", "Cat"),
            Pet(5, "Cooper", "Dog"),
            Pet(6, "Max", "Cat"),
            Pet(7, "Bailey", "Dog"),
            Pet(8, "Daisy", "Cat"),
            Pet(9, "Sadie", "Dog"),
            Pet(10, "Lily", "Cat"),
        )
    }
}

为了解释前面代码的功能,请参阅以下内容:

  • 我们创建了一个名为 PetsRepositoryImpl 的类,它实现了 PetsRepository 接口

  • 我们重写了 getPets() 方法并返回一个宠物列表。我们的列表有 10 只宠物,包括 ID名称种类

我们使用了一种称为 仓库模式 的模式来获取我们的宠物。仓库模式是一种允许我们将数据层从应用程序的其余部分抽象出来的模式。它允许我们从不同的来源获取数据,而不会影响应用程序的其余部分。例如,我们可以从本地数据库或远程服务器获取数据。该类负责合并来自两个来源的数据,并维护我们数据的真实来源。仓库模式还允许我们轻松地测试我们的代码,因为我们可以轻松地模拟仓库并独立测试我们应用程序的不同层。由于我们的应用程序目前非常简单,我们已完成了我们架构的数据/模型层。

现在,让我们为我们的 ViewModel 层创建一个 ViewModel 类。首先,在 com.packt.chapterfive 包内创建一个 ViewModel 包。在这个 ViewModel 包内,创建一个名为 PetsViewModel 的新文件,并包含以下代码:

class PetsViewModel: ViewModel() {
    private val petsRepository: PetsRepository = PetsRepositoryImpl()
    fun getPets() = petsRepository.getPets()
}

为了解释前面的代码做了什么,请参阅以下内容:

  • 我们创建了一个名为 PetsViewModel 的类,它扩展了 ViewModel 类。这是一个来自 Jetpack 库 的类。它有助于数据在配置更改之间持久化。它还充当视图层和模型层之间的中介。我们使用它来向视图暴露数据,对用户交互进行操作,并在模型层中更新数据。

  • 我们创建了一个名为 petsRepository 的私有属性,其类型为 PetsRepository,并用 PetsRepositoryImpl 的一个实例初始化它。这是我们之前创建的仓库。

  • 我们创建了一个名为 getPets() 的方法,它返回一个宠物列表。我们从 petsRepository 属性调用 getPets() 方法,并返回结果。

这样,我们的 ViewModel 层就准备好向我们的视图暴露数据了。我们的 getPets() 方法返回一个宠物列表。为了在 LazyColumn 组合视图中显示列表,LazyColumn 采用懒加载方法,这意味着只有屏幕上当前可见的项目会被积极地组合,从而减少资源使用并提高性能。让我们看看 LazyColumn 在底层是如何工作的。

LazyColumn 的工作原理

这就是 LazyColumn 的工作方式:

  • LazyColumn 仅在屏幕上组合可见的项目。当用户滚动时,它会动态地组合和重新组合项目,确保在任何给定时间只渲染必要的元素。

  • RecyclerViewLazyColumn 重新使用在视图中移动进出的可组合项,最小化内存使用并防止不必要的重新组合。

  • LazyColumn 优化了渲染过程,使其非常适合显示大量数据集而不会消耗过多的资源。

既然我们已经了解了 LazyColumn 的工作原理,让我们看看使用 LazyColumn 的好处。

LazyColumn 的好处

LazyColumn 的一些好处如下:

  • LazyColumn 通过仅组合可见项来高效管理内存,确保应用不会不必要地存储和渲染列表中的所有项。这对于长列表或包含复杂 UI 元素的列表尤其有益。

  • 改进的渲染性能:懒加载机制显著提高了渲染性能,尤其是在处理大量数据集时。它避免了同时渲染和管理所有项的开销,从而实现了更平滑的滚动和减少延迟。

  • LazyColumn 允许我们简洁地表达 UI 逻辑。与传统的 Android View 方法相比,创建和管理大型列表的代码变得更加简单和易于阅读。

  • LazyColumn 自动仅重新组合受影响的项,减少了手动干预以更新 UI 的需求。

  • LazyColumn 适应不同的屏幕尺寸和分辨率,在各种设备上提供一致和响应式的用户体验。

现在,我们将创建一个显示宠物的可组合组件。

创建一个可组合组件

按照以下步骤创建一个可组合组件:

  1. com.packt.chapterfive 包内部创建一个名为 views 的新包。

  2. 在这个 views 包内部,创建一个名为 PetsList 的新文件,并包含以下代码:

    @Composable
    fun PetList(modifier: Modifier) {
        val petsViewModel: PetsViewModel = viewModel()
        LazyColumn(
            modifier = modifier
        ) {
            items(petsViewModel.getPets()) { pet ->
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(10.dp),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Text(text = "Name: ${pet.name}")
                    Text(text = "Species: ${pet.species}")
                }
            }
        }
    }
    

    在这里,我们创建了一个名为 PetList 的可组合组件,它接受一个修饰符作为参数。然后,我们使用生命周期实用库中为 ViewModel 在 compose 中的 viewModel() 函数创建 PetsViewModel 的一个实例。这有助于我们轻松创建 PetsViewModel 的实例。接着,我们使用 LazyColumn 可组合组件来显示宠物。我们将来自 ViewModel 的宠物列表传递给 LazyColumnitems 参数。然后,我们使用 Row 可组合组件来显示每个宠物的名称和种类。现在,我们已经完成了我们架构中的视图层。

  3. 要最终显示我们的宠物,我们需要在 MainActivity 类的 setContent 块内部调用我们的 PetList 可组合组件:

    ChapterFiveTheme {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = {
                       Text(text = "Pets")
                    },
                    colors =  TopAppBarDefaults.smallTopAppBarColors(
                        containerColor = MaterialTheme.colorScheme.primary,
                    )
                )
            },
            content =  { paddingValues ->
                PetList(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(paddingValues)
                )
            }
        )
    }
    

    我们正在使用我们已熟悉的 Scaffold 可组合组件。在我们的 Scaffold 中,我们传递了一个 TopAppBar 和我们的 PetList 可组合组件。我们还向 PetList 可组合组件传递了 paddingValues。这是因为我们正在使用 paddingValuesPetList 可组合组件添加填充。现在,我们已经完成了我们应用中的 MVVM 架构。让我们运行应用并查看结果:

图 5.1 – 宠物列表

图 5.1 – 宠物列表

如前图所示,我们有一个宠物列表,其中显示了宠物的名称和种类。

在本节中,我们多次提到了 Jetpack 库,但尚未解释它们是什么。在下一节中,我们将详细探讨 Jetpack 库。

Jetpack 库

Jetpack Libraries 是 Google 提供的一系列库和 API,帮助我们开发者使用更少的代码创建更好的应用程序。它们通常是为了解决我们在创建应用程序时面临的一些痛点而创建的。让我们看看一些这些痛点以及为解决它们而创建的一些 Jetpack 库:

  • 在本地存储数据和观察数据变化:我们不得不使用SQLite来本地存储数据。即使是简单的创建读取更新删除CRUD)操作,我们也必须编写大量的模板代码。我们还需要编写大量代码来观察数据的变化。对于这样一个简单的任务来说,这是一项大量的工作。为了解决这个痛点,创建了 Jetpack 库,例如RoomLiveData。Room 是一个库,使我们能够轻松地本地存储数据。它还允许我们轻松地观察数据的变化。LiveData 是一个库,使我们能够轻松地观察数据的变化。它还具有生命周期感知功能。这意味着当观察数据的组件的生命周期结束时,它会自动停止观察数据变化。这有助于我们避免在应用程序中发生内存泄漏。Room 还支持Kotlin Coroutines,我们将在第六章中深入探讨。这使得使用更少的模板代码轻松地本地存储和访问数据变得容易。

  • 在我们的应用程序中完美实现导航是一个挑战:为了解决这个问题,创建了大量的开源库。在活动、片段之间导航并保持一致和可预测的返回行为也需要大量的模板代码。Jetpack Navigation就是为了解决这个痛点而创建的。它允许我们轻松地在应用程序中的屏幕之间导航。它还允许我们轻松地保持一致和可预测的返回行为。它还允许我们在应用程序的屏幕之间传递数据。它还支持 Jetpack Compose 和函数,例如深链接,当用户点击链接时,它应该打开我们应用程序中的特定屏幕。

  • 处理活动和片段的生命周期:在 Android 中,活动和片段都有自己的生命周期,对于我们来说,了解这些生命周期非常重要,这样我们才能在正确的生命周期中进行操作。例如,当生命周期处于启动状态时,我们应该在我们的视图中观察数据,当生命周期处于停止或销毁状态时,我们应该释放资源。这样做比较困难,需要大量的代码,而且容易出错。谷歌团队提出了生命周期库来帮助我们管理活动和片段的生命周期。此外,我们还有像ViewModel这样的类,这是我们之前创建的,它允许数据在配置更改之间持久化。大多数 Jetpack 库也是生命周期感知的,这使得它们在我们的应用中使用起来非常方便。以 ViewModel 为例,它在其创建的活动或片段的生命周期之外仍然存在。这使得数据在配置更改之间持久化变得容易。它还使得在片段和活动之间共享数据变得简单。

  • 加载无限列表:我们开发者工作的大多数应用都需要向用户展示一个项目列表。通常,这个列表可能很大,我们无法一次性显示所有内容。我们应该分批显示它们,这被称为分页。为了我们自己实现这一点,我们必须做一些工作,比如观察滚动位置,当用户到达列表的顶部或底部时,获取下一批或上一批的项目。同样,这需要相当多的工作,谷歌团队引入了Paging库来帮助我们实现这一点。它允许我们轻松地批量加载数据并显示给用户。它还支持 Jetpack Compose 和 Kotlin Coroutines。这使得在我们的应用中显示无限列表变得容易。

  • 处理后台任务:为应用执行长时间的后台任务证明是有些挑战性的。常见的问题是,由于手机制造商为了提高手机性能而在手机上添加的不同限制,一些后台任务没有运行。谷歌团队引入了WorkManager库来帮助我们实现这一点。它允许我们轻松地在我们的应用中安排后台任务。它还支持周期性后台任务,并确保我们的任务在用户使用的手机品牌无关的情况下运行。

  • 性能:关于我们开发者如何最好地提高应用性能并没有明确的指导。这种情况已经不再存在了;我们有几个 Jetpack 库来帮助我们检测性能问题并提高应用性能。一个很好的例子是基准配置文件,它有助于提高应用启动时间,并使应用交互更加流畅。

Jetpack 库有很多。您可以在以下位置探索所有可用的 Jetpack 库:developer.android.com/jetpack/androidx/explorer。以下是一些使用 Jetpack 库的好处:

  • 我们可以遵循最佳实践

  • 我们可以编写更少的样板代码

  • 我们减少了碎片化

  • API 之间协同工作良好

我们在本章中已经看到了如何使用 ViewModel 类。我们还将在这本书的后续章节中使用其他 Jetpack 库。

我们已经探讨了 Jetpack 库如何与我们的架构的不同层相匹配。在下一节中,我们将探讨架构中的一个重要主题,即 依赖注入

依赖注入

依赖注入是我们管理并提供类执行其工作所需的依赖项的一种方式,而无需类自己创建这些依赖项。在这本书中,我们将使用 Koin (insert-koin.io/) 作为我们的依赖注入库。

我们的 PetsViewModel 类会自行创建 PetsRepository 类。这是一个适合依赖注入的候选者。我们将重构这部分代码以使用依赖注入。让我们首先将 Koin 依赖添加到我们的应用中。打开应用模块的 build.gradle 文件,并添加以下依赖项:

implementation 'io.insert-koin:koin-core:3.4.3'
implementation 'io.insert-koin:koin-android:3.4.3'
implementation 'io.insert-koin:koin-androidx-compose:3.4.6'

我们还添加了 Koin 的 coreandroidcompose 依赖项,这些依赖项将在我们的项目中使用,以提供所需的依赖。

在将此添加到我们的项目并同步项目后,我们需要创建 Koin 的 PetsRepository 类。在 com.packt.chapterfive 包内创建一个名为 di 的新包。在这个 di 包内,创建一个名为 Modules 的新文件,并添加以下代码:

val appModules = module {
    single<PetsRepository> { PetsRepositoryImpl() }
}

在上面的代码中,我们创建了一个名为 appModules 的新变量,其类型为模块。我们使用 Koin 库中的 module 函数创建一个模块。我们使用 single 函数创建 PetsRepository 类的单个实例。Koin 有依赖注入作用域,如 singlefactoryscoped,这些作用域控制容器内依赖实例的生命周期和可见性。single 作用域创建单例实例,它们在整个应用程序中持续存在,适合需要全局共享状态的对象,例如数据库实例。Factory 作用域在每次请求时生成新实例,适合无状态的实用类或不需要维护持久状态的对象。scoped 作用域将实例绑定到特定上下文,例如活动或片段生命周期,允许它们在指定的作用域内共享,但在不同的上下文中重新创建。single 作用域特别适用于有效地管理全局或长期依赖项,确保应用程序的各个组件之间始终共享单个实例,从而优化资源使用并维护统一的状态。这就是我们为什么使用 single 来创建 PetsRepository 类的实例。我们使用 PetsRepositoryImpl 类作为 PetsRepository 接口的实现。

接下来,我们将重构 PetsViewModel 类以使用依赖注入。打开 PetsViewModel 类,并按照以下代码片段进行更新:

class PetsViewModel(
    private val petsRepository: PetsRepository
): ViewModel() {
    fun getPets() = petsRepository.getPets()
}

在前面的代码中,我们从 PetsViewModel 类中移除了 PetsRepository 类的实例化。相反,我们添加了一个接受 PetsRepository 参数的 constructor。我们还需要在 appModules 变量中 PetsRepository 依赖项下方创建一个新的 ViewModel 依赖。让我们添加以下代码:

single { PetsViewModel(get()) }

在这里,我们创建 PetsViewModel 类的一个 single 实例。我们使用 get() 函数获取 PetsRepository 依赖项。我们将它传递给 PetsViewModel 类的构造函数。有了这个,我们的应用程序就准备好使用这些依赖项了。我们还将更改在 PetList 可组合组件中创建 PetsViewModel 实例的方式。打开 PetList 可组合组件,并按照以下方式更新 PetsViewModel 的初始化,如下所示:

val petsViewModel: PetsViewModel = koinViewModel()

我们不是使用生命周期库中的 ViewModel() 函数,而是使用 Koin 库中的 koinViewModel() 函数。这个函数帮助我们创建 PetsViewModel 类的一个实例。现在它返回一个具有 PetsRepository 依赖注入的 PetsViewModel 实例。

确保我们的应用已设置依赖注入的最后一步是在我们的应用中初始化 Koin。我们将创建一个扩展 Application 类的类,并在 onCreate() 方法中初始化 Koin。创建一个名为 ChapterFiveApplication 的新文件,并添加以下代码:

class ChapterFiveApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            modules(appModules)
        }
    }
}

我们的 ChapterFiveApplication 类扩展了 Application 类。我们正在重写 onCreate() 方法并调用 startKoin() 函数。我们使用 modules 参数传入我们之前创建的 appModules 变量。这初始化了我们的应用中的 Koin。我们还需要更新 AndroidManifest.xml 文件以使用我们的 ChapterFiveApplication 类。打开 AndroidManifest.xml 文件,并更新应用程序标签的名称属性,如下所示:

android:name=".ChapterFiveApplication"

我们正在将我们的 ChapterFiveApplication 类的名称传递给名称属性。现在,如果你运行应用,它仍然像以前一样运行,但这次它使用了依赖注入。

现在我们已经了解了依赖注入是什么以及如何在我们的应用中使用它,让我们看看 Kotlin Gradle DSL 以及我们如何使用 版本目录 来管理我们的依赖项。

迁移到 Kotlin Gradle DSL 和使用版本目录

第一章中,我们列出的使用 Kotlin 的优点之一是,我们还可以用 Kotlin 编写我们的 Gradle 文件。在本节中,我们将探讨如何将我们的 Gradle 文件迁移到 Kotlin Gradle DSL。我们还将探讨如何使用版本目录来管理我们的依赖项。

在我们迁移之前,让我们看看使用 Kotlin Gradle DSL 我们能得到的一些好处:

  • 代码自动完成:由于我们正在使用 Kotlin,我们在 Gradle 文件中编写代码时会得到代码完成的提示。

  • 类型安全:当我们在我们 Gradle 文件中犯错时,我们会得到编译时错误。

  • 函数调用和变量赋值:我们可以在 Gradle 文件中使用函数和变量,就像我们在 Kotlin 代码中使用它们一样。这使得我们编写和理解代码变得更加容易。

  • 编译时错误:当我们在我们 Gradle 文件中犯错时,我们会得到编译时错误。这有助于我们在构建应用时避免运行时错误。

  • 官方 Android Studio 支持:从 Android Studio Giraffe 版本开始,Kotlin Gradle DSL 是创建我们的 Gradle 文件的推荐方式。它也是从 Android Studio Giraffe 版本开始创建我们的 Gradle 文件的默认方式。

所以,这么多好处,对吧?现在让我们迁移我们的应用,以便使用 Kotlin Gradle DSL。

将我们的应用迁移到 Kotlin Gradle DSL

重要提示

如果你的应用已经使用了 Kotlin Gradle DSL,你可以跳过这一节。

按照以下步骤将你的应用迁移到 Kotlin Gradle DSL:

  1. 首先,我们必须将所有 Gradle 文件重命名为具有 .kts 扩展名,这样我们的 IDE 就能识别它们为 Kotlin Gradle 文件。将 build.gradle(Project : chapterfive)build.gradle(Module: app)settings.gradle 文件分别重命名为 build.gradle.kts(Project: chapterfive)build.gradle.kts(Module: app)settings.gradle.kts。这允许我们现在在 Gradle 文件中使用 Kotlin。

  2. 文件重命名后,我们必须更新它们的内容以使用 Kotlin Gradle DSL。让我们从 settings.gradle.kts 文件开始。打开 settings.gradle.kts 文件并更新它,如下面的代码所示:

    pluginManagement {
        repositories {
            google()
            mavenCentral()
            gradlePluginPortal()
        }
    }
    dependencyResolutionManagement {
        repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
        repositories {
            google()
            mavenCentral()
        }
    }
    rootProject.name = "chapterfive"
    include(":app")
    
  3. 接下来,更新 build.gradle.kts(Module: app) 文件,如下面的代码所示:

    plugins {
        id("com.android.application")
        id("org.jetbrains.kotlin.android")
    }
    android {
        namespace = "com.packt.chapterfive"
        compileSdk = 33
        defaultConfig {
            applicationId = "com.packt.chapterfive"
            minSdk = 24
            targetSdk = 33
            versionCode = 1
            versionName = "1.0"
            testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
            vectorDrawables {
                useSupportLibrary = true
            }
        }
        buildTypes {
            release {
                isMinifyEnabled = false
                setProguardFiles(
                    listOf(
                        getDefaultProguardFile("proguard-android.txt"),
                        "proguard-rules.pro"
                    )
                )
            }
        }
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_1_8
            targetCompatibility = JavaVersion.VERSION_1_8
        }
        kotlinOptions {
            jvmTarget = "1.8"
        }
        buildFeatures {
            compose = true
        }
        composeOptions {
            kotlinCompilerExtensionVersion = "1.4.6"
        }
        packagingOptions {
            resources {
                pickFirsts.add("META-INF/AL2.0")
                pickFirsts.add("META-INF/LGPL2.1")
            }
        }
    }
    dependencies {
        implementation("androidx.core:core-ktx:1.10.1")
        implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
        implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
        implementation("androidx.activity:activity-compose:1.7.2")
        implementation(platform("androidx.compose:compose-bom:2022.10.00"))
        implementation("androidx.compose.ui:ui")
        implementation("androidx.compose.ui:ui-graphics")
        implementation("androidx.compose.ui:ui-tooling-preview")
        implementation("androidx.compose.material3:material3")
        implementation("androidx.lifecycle:lifecycle-viewmodel-compose")
        implementation("io.insert-koin:koin-core:3.4.3")
        implementation("io.insert-koin:koin-android:3.4.3")
        implementation("io.insert-koin:koin-androidx-compose:3.4.6")
        testImplementation("junit:junit:4.13.2")
        androidTestImplementation("androidx.test.ext:junit:1.1.5")
        androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
        androidTestImplementation(platform("androidx.compose:compose-bom:2022.10.00"))
        androidTestImplementation("androidx.compose.ui:ui-test-junit4")
        debugImplementation("androidx.compose.ui:ui-tooling")
        debugImplementation("androidx.compose.ui:ui-test-manifest")
    }
    
  4. 最后,更新 build.gradle.kts(Project: chapterfive) 文件,如下面的代码片段所示:

    plugins {
        id("com.android.application") version "8.1.0" apply false
        id("com.android.library") version "8.1.0" apply false
        id("org.jetbrains.kotlin.android") version "1.8.20" apply false
    }
    

更新文件后,我们必须同步项目。我们可以在 IDE 右上角出现的同步提示处点击进行同步。项目同步后,我们现在可以运行应用,它应该会像以前一样运行。我们现在已成功将应用迁移到使用 Kotlin Gradle DSL。你还可以看到,语法高亮和函数、方法和变量的颜色已更改,以反映 Kotlin 语法。以下是一些关于这次迁移的关键变更,需要强调的是:

  • 为了给属性赋值,我们必须特别使用 = 操作符。例如,minSdk 24 变更为 minSdk = 24

  • 在我们的 android 配置块中,namespace 'com.packt.chapterfive' 变更为 namespace = "com.packt.chapterfive"。在 Kotlin 中,我们使用双引号来定义字符串;这就是为什么我们必须将所有字符串处的单引号更改为双引号。

  • 在定义我们的依赖项时,我们也必须使用双引号。例如,implementation 'androidx.activity:activity-compose:1.7.2' 变更为 implementation("androidx.activity:activity-compose:1.7.2")

  • 类似地,在 plugins 块中定义我们的插件也会发生变化。例如,id 'org.jetbrains.kotlin.android' 变更为 id("org.jetbrains.kotlin.android")

我们的项目 Gradle 配置最少,所以如果你有一个复杂的项目,你可能需要进行更多的迁移;你可以查看 Migrate to Kotlin DSL 官方文档(developer.android.com/build/migrate-to-kotlin-dsl)以获取更多示例。

我们现在已将应用迁移到使用 Kotlin Gradle DSL。在下一小节中,我们将探讨如何使用 版本目录 来管理我们的依赖项。

使用版本目录

引用官方文档(docs.gradle.org/current/userguide/platforms.html),版本目录是一份依赖项列表,以依赖项坐标的形式表示,用户在声明构建脚本中的依赖项时可以选择。它帮助我们轻松地在中央位置管理我们的依赖项及其版本。目前,您可以看到我们在应用级的build.gradle.kts文件中定义了所有依赖项及其版本。随着时间的推移,并且随着您向应用添加更多模块,共享这些依赖项变得困难,我们可能会发现自己处于不同模块具有类似依赖项不同版本的情况。这就是版本目录发挥作用帮助我们的时候。让我们看看它们提供的所有好处:

  • 它们提供了一个集中管理所有依赖项及其版本的地方。这使得在整个项目中共享依赖项变得更加容易

  • 它们具有简单且易于使用的语法

  • 它们显示需要更新的依赖项的提示

  • 它们使得更改变得更加容易,并且这些更改不会重新编译整个项目,这意味着构建速度更快

  • 我们可以将依赖项捆绑在一起并在整个项目中共享它们

  • 它们有官方支持,并且 Google 推荐从 Android Studio Giraffe 开始使用

现在我们来看看如何在我们的应用中使用版本目录。在gradle文件夹中,创建一个名为libs.versions.toml的新文件。在这个文件中,我们将遵循以下一些基本规则:

  • 我们可以使用分隔符,如-_v.,Gradle 会将它们在目录中规范化为.,这样我们就可以创建子目录。

  • 我们使用驼峰式命名法定义变量。

  • 对于库,我们通常检查是否可以将它们添加到任何现有的捆绑包中。对于通常一起使用的新的库,我们可以为它们创建一个新的捆绑包。

我们将首先定义依赖项的版本,如下所示:

[versions]
coreKtx = "1.10.1"
lifecycle = "2.6.1"
activity = "1.7.2"
composeBom = "2022.10.00"
koin = "3.4.3"
koinCompose = "3.4.6"
junit = "4.13.2"
junitExt = "1.1.5"
espresso = "3.5.1"

在这里,我们正在定义我们应用中所有库的所有版本。我们使用versions关键字来定义版本。然后我们为每个库定义版本。当我们编辑此文件时,您将注意到 IDE 会提示我们进行 Gradle 同步,以便将我们的更改添加到项目中。目前,我们可以忽略这一点并继续编辑文件。接下来,我们将定义依赖项的捆绑包:

[libraries]
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-android-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinCompose" }
test-junit = { module = "junit:junit", version.ref = "junit" }
test-junitExt = { module = "androidx.test.ext:junit", version.ref = "junitExt" }
test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
test-compose-junit4 = { group = "androidx.compose.ui:ui-test-junit4", name = "ui-test-junit4" }

在这里,我们使用libraries关键字定义了我们项目中的所有依赖项。

接下来,让我们使用bundles关键字为 Koin 和 compose 依赖项创建一个捆绑包,如下所示:

[bundles]
compose = ["compose.ui", "compose.ui.graphics", "compose.ui.tooling", "compose.material3", "compose.viewmodel"]
koin = ["koin-core", "koin-android", "koin-android-compose"]

bundles关键字允许我们将依赖项分组并作为一个整体使用。现在,我们可以同步项目。最后一步是将我们的应用级build.gradle.kts文件更新为使用版本目录。打开应用级build.gradle.kts文件,并按以下方式更新依赖项块:

dependencies {
    implementation(libs.core.ktx)
    implementation(libs.lifecycle)
    implementation(libs.activity.compose)
    implementation(platform(libs.compose.bom))
    implementation(libs.bundles.compose)
    implementation(libs.bundles.koin)
    testImplementation(libs.test.junit)
    androidTestImplementation(libs.test.junitExt)
    androidTestImplementation(libs.test.espresso)
    androidTestImplementation(platform(libs.compose.bom))
    androidTestImplementation(libs.test.compose.junit4)
    debugImplementation(libs.compose.ui.tooling)
    debugImplementation(libs.compose.manifest)
}

我们现在可以访问版本目录文件中的依赖项。请注意,我们必须以libs关键字开头,接下来是按照我们的版本目录命名的包或依赖项。添加这些更改后,我们现在可以进行 Gradle 同步。构建并运行应用。应用显示宠物列表,与之前一样,没有任何变化,因为我们只是重构了依赖项。

摘要

在本章中,我们基于前几章所学的内容进行了扩展。我们探讨了适用于 Android 项目的不同架构。我们深入研究了 MVVM 架构及其不同层以及如何在架构中使用一些 Jetpack 库。此外,我们还学习了如何使用高级架构特性,如依赖注入和 Kotlin Gradle DSL,以及使用 Gradle 版本目录来定义我们的依赖项。

在创建 MVVM 架构的过程中,我们为数据层使用了虚拟宠物数据。在下一章中,我们将学习如何进行网络调用以获取数据并在我们的应用中显示它。

第六章:使用 Kotlin 协程进行网络调用

我们在手机上使用的许多应用程序都会从服务器上托管的数据中获取数据。因此,作为开发者,我们必须了解如何向服务器请求和发送数据。在本章中,我们将学习如何发送和请求在线托管的数据,并在我们的应用程序中显示它。

在本章中,我们将学习如何使用网络库 Retrofit 来执行网络调用。我们将学习如何使用此库消费 应用程序编程接口API)。更重要的是,我们将学习如何利用 Kotlin 协程在我们的应用程序中执行异步网络请求。

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

  • 设置 Retrofit

  • Kotlin 协程简介

  • 使用 Kotlin 协程进行网络调用

技术要求

要遵循本章中的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以在github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chaptersix找到本章的代码。

设置 Retrofit

Retrofit 是由 Square 开发的一个类型安全的 REST 客户端,适用于 Android、Java 和 Kotlin。该库提供了一个强大的框架,用于验证和与 API 交互,以及使用 OkHttp 发送网络请求。在本书中,我们将使用 Retrofit 来执行我们的网络请求。

首先,我们将使用我们新创建的版本目录添加 Retrofit 依赖项。让我们在 libs.versions.toml 文件中定义以下版本:

retrofit = "2.9.0"
retrofitSerializationConverter = "1.0.0"
serializationJson = "1.5.1"
coroutines = "1.7.3"
okhttp3 = "4.11.0"

接下来,让我们在版本目录的库部分中定义 libs.versions.toml 文件中的库,如下所示:

retrofit = { module = "com.squareup.retrofit2:retrofit" , version.ref = "retrofit" }
retrofit-serialization = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofitSerializationConverter" }
coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" , version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" , version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationJson" }
okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp3" }

我们将添加以下依赖项到我们的项目中:

  • Retrofit: 如前所述,我们将使用 Retrofit 来执行我们的网络请求。

  • Retrofit 序列化: 这是一个使用 Kotlinx 序列化将 Kotlin 对象转换为 JSON 并从 JSON 转换为 Kotlin 对象的转换器。

  • 协程: 我们将使用 Kotlin 协程来异步执行我们的网络请求。我们很快就会了解更多关于协程的内容。

  • Kotlinx 序列化 JSON: 这是一个用于 JSON 的 Kotlin 序列化库。我们将使用它来解析我们的 JSON 响应。我们还有其他序列化库,如 Moshi 和 Gson,但我们选择使用 Kotlinx 序列化库的原因如下:

    • 以 Kotlin 为中心的开发: Kotlinx 序列化是针对 Kotlin 设计的,提供了无缝集成和原生支持 Kotlin 序列化。

    • 声明式语法: Kotlinx 序列化使用声明式语法,利用 Kotlin 的语言特性来编写简洁且可读的序列化代码。

    • 编译时安全性:编译时安全性是一个关键特性,在编译阶段捕获与序列化相关的错误,并减少运行时错误的可能性。

    • 自定义序列化策略:我们可以为特定类型或场景定义自定义序列化策略,提供对序列化过程的精细控制。

    • 与 Kotlin 生态系统的无缝集成:作为 Kotlin 生态系统的一部分,Kotlinx 序列化与其它 Kotlin 库和框架无缝集成,有助于提供一致的开发体验。

  • OkHttp:这是一个用于发送网络请求的 HTTP 客户端。它为使用 Retrofit 提供了一些实用工具。

所有这些依赖项都将被累加在一起,因此这是我们有机会在libs.versions.toml文件中将它们分组的机会,在我们的 Koin 包下面添加此包:

networking = ["retrofit", "retrofit-serialization", "serialization-json", "coroutines", "coroutines-android"]

在这里,我们创建了一个名为networking的新包,并添加了之前指定的所有依赖项。我们必须同步项目,以便将我们的更改添加到项目中。点击libs.versions.toml文件。同步后,让我们开始设置插件和依赖项。

首先,在我们的项目级别build.gradle.kts文件中,我们需要添加 Kotlinx 序列化插件。打开项目级别的build.gradle.kts文件,并在插件块中添加以下内容:

id("org.jetbrains.kotlin.plugin.serialization") version "1.8.20" apply false

我们定义了 Kotlinx 序列化插件并指定了要使用的版本。这将为我们设置 Kotlinx 序列化插件。该插件为可序列化类生成 Kotlin 代码。我们将使用此插件来生成我们的模型。接下来,让我们在我们的应用模块中设置插件。打开应用级别的build.gradle.kts文件,并在插件块中添加以下内容:

id("kotlinx-serialization")

这确保了我们的模块已设置好以使用 Kotlinx 序列化插件。接下来,我们将添加我们的networking包到我们的应用模块。在应用级别的build.gradle.kts文件中,添加以下内容:

implementation(libs.bundles.networking)

这将添加我们在networking包中指定的所有依赖项。完成所有这些后,我们的项目已设置好以使用 Retrofit。我们将使用 Koin 创建一个 Retrofit 实例,该实例将被注入到需要它的类中。让我们转到Module.kt文件并添加PetsViewModel定义:

single {
    Retrofit.Builder()
        .addConverterFactory(
            Json.asConverterFactory(contentType = "application/json".toMediaType())
        )
        .baseUrl("https://cataas.com/api/")
        .build()
}

在前面的代码中,我们使用 Retrofit 构建器创建了一个 Retrofit 实例。我们还添加了一个转换工厂,该工厂使用 Kotlinx 序列化将 Kotlin 对象转换为 JSON,并将 JSON 转换为 Kotlin 对象。我们还指定了 API 的基础 URL。我们使用了CatsAPI.kt并添加了以下方法:

@GET("cats")
suspend fun fetchCats(
    @Query("tag") tag: String,
): Response<List<Cat>>

在前面的代码中,我们使用@GET注解来指定我们将使用GET HTTP 方法进行此请求。在方法内部,我们还在指定一个将被附加到我们的基础 URL 的路径,以形成请求的完整 URL。使用GET方法意味着我们的方法将只请求数据。我们有以下内置的 HTTP 注解:

  • POST:当我们想要向服务器发送数据时使用

  • PUT: 当我们想要在服务器上更新数据时使用

  • DELETE: 当我们想要从服务器删除数据时使用

  • HEAD: 此方法请求与GET请求对应的响应,但不包含响应体

  • PATCH: 当我们想要在服务器上部分更新数据时使用

  • OPTIONS: 此方法请求目标资源的允许通信选项

回到我们的fetchCats()函数,你可以注意到我们使用@Query注解来指定请求的查询参数。我们使用tag查询参数来指定我们想要获取的猫的类型。我们还使用suspend关键字来指定此方法将从协程或另一个suspend函数中调用。我们将在本章的“Kotlin 协程简介”部分稍后了解更多关于协程的内容。我们还使用Response类来封装我们的响应。这个类由 Retrofit 提供,它包含 HTTP 响应元数据,如响应代码、头信息和原始响应体。我们还指定响应将是一个Cat对象的列表。Retrofit 将响应映射到Cat对象的列表。为了解决Cat数据类的错误,让我们创建它。在数据包中创建一个新的 Kotlin 数据类,命名为Cat.kt,并添加以下内容:

@Serializable
data class Cat(
    @SerialName ("createdAt")
    val createdAt: String,
    @SerialName("_id")
    val id: String,
    @SerialName("owner")
    val owner: String,
    @SerialName("tags")
    val tags: List<String>,
    @SerialName("updatedAt")
    val updatedAt: String
)

Cat数据类具有与猫作为服务的 API 的 JSON 响应对应的字段。它还注解了@Serializable注解。这个注解由 Kotlinx Serialization 提供,它用于标记一个类为可序列化。这个注解对于所有我们想要序列化或反序列化的类都是必需的。我们在数据类的每个变量之前都使用了@SerialName注解。@SerialName是一个用于自定义 Kotlin 属性名与序列化形式(如 JSON 或其他数据交换格式)中使用的对应名称之间的映射的注解。这个注解允许你在序列化或反序列化时为属性指定不同的名称,从而在处理命名约定时提供灵活性。

在我们的项目中,我们使用 Koin 进行依赖注入。因此,我们现在需要在我们的 Koin 模块中创建我们的CatsAPI类的实例。让我们回到Module.kt文件,在 Retrofit 实例下方添加以下内容:

single { get<Retrofit>().create(CatsAPI::class.java) }

在这里,我们获取我们的 Retrofit 实例,并使用它来创建我们的CatsAPI类的实例,我们使用它来执行实际的网络请求。有了这个,我们的项目就准备好执行网络请求了。但在那之前,让我们更多地了解 Kotlin 协程,因为我们将修改我们的仓库以使用协程。

Kotlin 协程简介

JetBrains 为 Kotlin 引入的协程提供了一种以更易读和同步的方式编写异步代码的方法。我们可以使用它们来执行后台任务,并且它们是执行网络请求和长时间运行任务(如读取和写入数据库)的绝佳方式。它们在主线程之外执行这些任务,并确保我们在执行这些操作时不会阻塞主线程。使用协程的主要好处如下:

  • 它们轻量级且易于使用。

  • 它们具有内置的取消支持。

  • 它们降低了应用出现内存泄漏的可能性。

  • 如前几章所述,Jetpack 库也支持并使用协程。

我们已经在我们的应用中添加了核心和 Android 协程库。在继续在我们的项目中使用协程之前,让我们先了解一些协程的基础知识。

协程基础

在本节中,我们将探讨 Kotlin 协程中使用的不同术语和概念:

  • suspend:这是一个用于标记函数的关键字。suspend 函数是一个可以在稍后暂停和恢复的函数。我们已经在我们的 CatsAPI 类中使用了这个关键字来标记 fetchCats() 函数为一个 suspend 函数。suspend 函数只能从另一个 suspend 函数或从协程中调用。

  • launchasync 协程构建器。launch 用于创建一个不返回结果的协程,而 async 用于创建一个返回结果的协程。结果是 Deferred 对象,我们可以使用 await() 方法来获取结果。这两个构建器都返回一个 Job 对象,我们可以使用它来检查协程是否仍然活跃或已被取消。我们还可以使用作业来等待协程完成。作业在完成或取消时结束。

  • launchasync 协程构建器返回一个 Job 对象,我们使用它来管理协程的生命周期。我们有一个普通的 JobSupervisorJob。当一个普通的 Job 的任何子任务失败时,它会被取消。SupervisorJob 在其任何子任务失败时不会被取消。当有多个协程同时运行时,建议使用 SupervisorJob

  • launchasync 构建器。它负责知道协程将存活多久。每个协程构建器都被定义为作用域的扩展函数。没有作用域就无法启动协程。我们有 GlobalScope,这是一个不与任何生命周期绑定的作用域。不建议使用此作用域,因为它可能导致内存泄漏。在 Android 中,KTX 库提供了 viewModelScope,这是一个与 ViewModel 绑定的作用域。我们可以使用此作用域来启动当 ViewModel 被销毁时将取消的协程。我们还有 lifecycleScope,这是一个与活动或片段生命周期绑定的作用域。我们可以使用此作用域来启动当生命周期被销毁时将取消的协程。如果我们想启动当自定义生命周期被销毁时将取消的协程,我们还可以创建自己的自定义作用域。

  • CoroutineContext 使用如下元素定义了我们的协程的行为:

    • Job: 这管理协程的生命周期。

    • CoroutineDispatcher: 这定义了协程将在哪个线程上运行。

    • CoroutineName: 这定义了协程的名称。

    • CoroutineExceptionHandler: 这用于处理协程中的未捕获异常。

  • Dispatchers.Main: 这是主线程。当我们需要在协程中与 UI 交互时使用它。

  • Dispatchers.IO: 这是一个针对 IO 任务(如读取和写入数据库或进行网络请求)进行优化的线程池。

  • Dispatchers.Default: 这是一个针对 CPU 密集型任务进行优化的线程池。

  • Dispatchers.Unconfined: 这是一个不受任何线程限制的调度器。它用于创建一个继承父协程上下文的协程。

在协程内部,我们可以使用 withContext() 函数在不同的调度器之间切换。withContext() 是一个 suspend 函数,它切换协程的上下文。

  • suspend 函数。我们还可以使用流来执行异步操作。流是 collect() 函数,用于从流中收集值。我们有 StateFlowSharedFlow,它们是流的类型。StateFlow 是一个向新收集器发出当前值并向现有收集器发出新值的流。SharedFlow 是一个向所有收集器发出新值的流。我们将在下一章中学习更多关于流的内容。在 Android 中,我们通常使用这两种类型的流将数据发射到我们的 UI。我们将看到 ViewModel 在重构以使用协程时的 StateFlow 的用法。

在理解了基础知识之后,在下一节中,我们将重构我们的仓库以使用协程。

使用 Kotlin 协程进行网络调用

在本节中,我们将重构我们的仓库以使用协程。我们将使用 StateFlowViewModel 发射数据到视图层。我们还将使用 Dispatchers.IO 调度器在后台线程上执行我们的网络请求。

让我们首先创建一个NetworkResult sealed class,它将表示我们的网络请求的不同状态:

sealed class NetworkResult<out T> {
    data class Success<out T>(val data: T) : NetworkResult<T>()
    data class Error(val error: String) : NetworkResult<Nothing>()
}

NetworkResult类是一个密封类,有两个子类。我们有Success数据类,它将用于表示成功的网络请求。它有一个数据属性,用于存储从网络请求返回的数据。我们还有一个Error类,它将用于表示失败的网络请求。它有一个error属性,用于存储从网络请求返回的错误信息。密封类封装了一个泛型数据类型T,这使得我们更容易在所有网络调用中重用该类。Success数据类也有一个泛型参数,出于相同的目的。

接下来,让我们按照以下方式修改PetsRepository

interface PetsRepository {
    suspend fun getPets(): NetworkResult<List<Cat>>
}

我们已经更新了接口以使用NetworkResult类。我们还标记了getPets()函数为suspend函数。我们将使用此方法从 API 获取猫。接下来,让我们修改PetsRepositoryImpl以添加从PetsRepository的更改:

class PetsRepositoryImpl(
    private  val catsAPI: CatsAPI,
    private val dispatcher: CoroutineDispatcher
): PetsRepository {
    override suspend fun getPets(): NetworkResult<List<Cat>> {
        return withContext(dispatcher) {
            try {
                val response = catsAPI.fetchCats("cute")
                if (response.isSuccessful) {
                    NetworkResult.Success(response.body()!!)
                } else {
                    NetworkResult.Error(response.errorBody().toString())
                }
            } catch (e: Exception) {
                NetworkResult.Error(e.message ?: "Unknown error")
            }
        }
    }
}

这里我们更改了多项内容:

  • 首先,我们添加了一个构造函数,它接受我们的CatsAPI类的实例,我们将使用它来执行网络请求。它还有一个dispatcher参数,它将用于指定我们将用于执行网络请求的调度器。我们将使用Dispatchers.IO调度器在后台线程上执行我们的网络请求。

  • 我们还将getPets()函数的返回类型更改为NetworkResult<List<Cat>>。这是因为我们将从这个方法返回一个NetworkResult对象。

  • 我们使用withContext()函数将协程的上下文切换到Dispatchers.IO调度器。这确保了网络请求是在后台线程上执行的。

  • 我们还用try-catch块包裹了我们的网络请求。这是为了确保我们能够捕获网络请求过程中可能发生的所有错误。

  • 在我们的try块中,我们使用CatsAPI实例进行网络请求。我们使用fetchCats()方法进行请求。我们传入cute标签来指定我们想要获取的猫的类型。我们检查响应是否成功。如果是,我们返回一个包含响应体的NetworkResult.Success对象。如果不是,我们返回一个包含错误信息的NetworkResult.Error对象。

  • 最后,我们捕获网络请求过程中可能发生的所有异常,并返回一个包含错误信息的NetworkResult.Error对象。

在我们的 Koin 模块中,我们还需要更改实例化我们的仓库的方式。让我们转到Module.kt并更新PetsRepository的定义如下:

single<PetsRepository> { PetsRepositoryImpl(get(), get()) }
single { Dispatchers.IO }

我们将 CatsAPI 实例和 dispatcher 注入到我们的仓库中。我们还声明 dispatcher 为一个单例实例。现在我们需要修改我们的 PetsViewModel 以适应这些更改。首先,我们需要创建一个状态类来存储我们网络请求的状态,并将其暴露给我们的视图。在 view 包内创建一个新的 Kotlin 数据类,命名为 PetsUIState.kt

data class PetsUIState(
    val isLoading: Boolean = false,
    val pets: List<Cat> = emptyList(),
    val error: String? = null
)

PetsUIState 类是一个数据类,用于存储我们网络请求的状态。它有三个属性:

  • isLoading:这是一个布尔值,用于指示网络请求是否正在加载。

  • pets:这是一个将通过网络请求返回的猫的列表。

  • error:这是一个字符串,将用于存储从网络请求返回的错误信息。

接下来,在 PetsViewModel 中,让我们创建一个变量来存储我们网络请求的状态:

val petsUIState = MutableStateFlow(PetsUIState())

我们使用 MutableStateFlow 类来存储我们网络请求的状态。MutableStateFlow 允许我们更新状态的值。我们用空的 PetsUIState 对象初始化它。接下来,让我们更新 getPets() 方法如下:

private fun getPets() {
    petsUIState.value = PetsUIState(isLoading = true)
    viewModelScope.launch {
        when (val result = petsRepository.getPets()) {
            is NetworkResult.Success -> {
                petsUIState.update {
                    it.copy(isLoading = false, pets = result.data)
                }
            }
            is NetworkResult.Error -> {
                petsUIState.update {
                    it.copy(isLoading = false, error = result.error)
                }
            }
        }
    }
}

在这里,我们将分解前面的代码:

  • 我们更新 petsUIState 变量的值以指示网络请求正在加载。

  • 我们使用 viewModelScope 来启动协程。这确保了当 ViewModel 被销毁时,协程会被取消。

  • 这里有一个 when 语句,它是 Kotlin 的模式匹配功能,用于检查网络请求的结果。如果结果是 NetworkResult.Success 对象,我们将更新 petsUIState 的值以指示网络请求成功,并传入猫的列表。如果结果是 NetworkResult.Error 对象,我们将更新 petsUIState 的值以指示网络请求失败,并传入错误信息。

PetsViewModel 中,让我们添加一个新的 init 块,该块将调用 getPets() 函数:

init {
    getPets()
}

这将确保在 ViewModel 创建时调用 getPets() 函数。我们现在需要更新我们的 PetList 可组合组件以适应这些更改,我们还将添加更多的 UI 组件,因为我们需要显示加载状态、图片和错误信息。让我们先添加一个库,允许我们从 URL 加载图片。我们将使用 Coil (coil-kt.github.io/coil/),这是一个图片加载库。在版本目录中,让我们添加以下内容:

coil-compose = "io.coil-kt:coil-compose:2.4.0"

我们还将把 coil-compose 依赖项添加到我们的 compose 包中,以便它可以与其他 compose 库一起提供。更新后的 compose 包如下所示:

compose = ["compose.ui", "compose.ui.graphics", "compose.ui.tooling", "compose.material3", "compose.viewmodel", "coil-compose"]

现在让我们创建一个新的可组合组件,用于在 view 包中的 PetListItem.kt 文件内显示每只猫的图片和标签,并添加以下内容:

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PetListItem(cat: Cat) {
    ElevatedCard(
        modifier = Modifier
            .fillMaxWidth()
            .padding(6.dp)
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 10.dp)
        ) {
            AsyncImage(
                model = "https://cataas.com/cat/${cat.id}",
                contentDescription = "Cute cat",
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp),
                contentScale = ContentScale.FillWidth
            )
            FlowRow(
                modifier = Modifier
                    .padding(start = 6.dp, end = 6.dp)
            ) {
                repeat(cat.tags.size) {
                    SuggestionChip(
                        modifier = Modifier
                            .padding(start = 3.dp, end = 3.dp),
                        onClick = { },
                        label = {
                            Text(text = cat.tags[it])
                        }
                    )
                }
            }
        }
    }
}

这个组合函数接收一个 Cat 对象,并显示猫咪的图片和标签。我们使用来自 Coil 库的 AsyncImage 组合函数从 URL 加载图片。我们还使用 FlowRow 组合函数来显示猫咪的标签。我们使用 SuggestionChip 组合函数来显示每个标签。我们在 ElevatedCard 组合函数中显示图片和标签。

接下来,让我们更新我们的 PetList 组合函数以适应这些更改。在 PetList.kt 文件中,按照以下方式更新 PetList 组合函数:

@Composable
fun PetList(modifier: Modifier) {
    val petsViewModel: PetsViewModel = koinViewModel()
    val petsUIState by petsViewModel.petsUIState.collectAsStateWithLifecycle()
    Column(
        modifier = modifier
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        AnimatedVisibility(
            visible = petsUIState.isLoading
        ) {
            CircularProgressIndicator()
        }
        AnimatedVisibility(
            visible = petsUIState.pets.isNotEmpty()
        ) {
            LazyColumn {
                items(petsUIState.pets) { pet ->
                    PetListItem(cat = pet)
                }
            }
        }
        AnimatedVisibility(
            visible = petsUIState.error != null
        ) {
            Text(text = petsUIState.error ?: "")
        }
    }
}

以下是对前面代码的分解:

  • 和之前一样,我们使用 koinViewModel() 函数来获取 PetsViewModel 的实例。

  • 我们使用 collectAsStateWithLifecycle() 函数来收集我们的网络请求状态。这个函数由 lifecycle-runtime-compose 库提供。它用于收集流程的状态,并在生命周期被销毁时自动取消收集。我们使用 PetsViewModelpetsUIState 属性来获取我们的网络请求状态。

  • 我们有一个包含三个 AnimatedVisibility 组合函数的 Column 组合函数。第一个用于在网络请求加载时显示 CircularProgressIndicator。第二个用于在网络请求成功时显示猫咪列表。最后一个用于在网络请求失败时显示错误信息。

collectAsStateWithLifecycle() 显示错误,因为我们还没有添加其依赖项。让我们将其添加到版本目录中的库部分,如下所示:

compose-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }

我们还将将其添加到我们的 compose 包中,以便它可以与其他组合库一起提供。更新的组合包将如下所示:

compose = ["compose.ui", "compose.ui.graphics", "compose.ui.tooling", "compose.material3", "compose.viewmodel", "coil-compose", "compose-lifecycle"]

执行 Gradle 同步,IDE 将提示您添加 collectAsStateWithLifecycle() 函数的导入。

我们已经完成了将所有层更新为使用新的协程方法。到目前为止做得很好!最后一件事:由于我们的应用现在是从在线托管 API 获取这些项目,我们需要在我们的应用中添加 INTERNET 权限。打开 AndroidManifest.xml 文件并添加以下内容:

<uses-permission android:name="android.permission.INTERNET" />

运行应用并查看一切是否按预期工作。我们可以看到显示标签的可爱猫咪列表。我们还可以在网络请求加载时看到加载指示器,在网络请求失败时看到错误信息。我们已经成功重构了我们的应用以使用协程。

图 6.1 – 可爱的猫咪

图 6.1 – 可爱的猫咪

摘要

在本章中,我们学习了如何使用 Retrofit 执行网络调用。更重要的是,我们学习了如何利用 Kotlin 协程在我们的应用中执行异步网络请求,并重构了我们的应用以使用 Kotlin 协程获取一些可爱的猫咪。

在下一章中,我们将探讨另一个 Jetpack 库,Jetpack 导航,以处理我们的应用中的导航。

第七章:在您的应用内导航

我们制作的应用需要从一个屏幕移动到另一个屏幕,在这些屏幕上显示不同的内容。到目前为止,我们一直只制作单屏幕的应用。在本章中,我们将学习如何从一个屏幕移动到另一个屏幕。我们将学习如何使用Jetpack Compose Navigation库在应用内导航到不同的 Jetpack Compose 屏幕。我们将学习使用此库的技巧和最佳实践。此外,我们还将介绍如何在大型屏幕和可折叠设备上处理导航。最后,我们将通过处理大型屏幕和可折叠设备上的导航来扩展我们在第四章中学到的内容。

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

  • Jetpack Navigation 概述

  • 导航到 Compose 目标

  • 向目标传递参数

  • 在可折叠设备和大型屏幕上的导航

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterseven找到本章的代码。

Jetpack Navigation 概述

Jetpack Navigation 库提供了一个 API,可以轻松处理复杂导航,同时遵循 Android Jetpack 的原则。该库适用于旧视图系统,该系统使用 XML(developer.android.com/guide/navigation),以及 Jetpack Compose(developer.android.com/jetpack/compose/navigation)。在本章中,我们将学习后者。

在本章中,我们将在之前章节中使用过的宠物应用的基础上,导航到一个带有返回上一屏幕按钮的详情屏幕。我们还将向详情屏幕传递数据。

首先,我们需要将 Jetpack Navigation Compose 依赖项添加到我们的项目中。让我们在libs.versions.toml文件的versions部分添加以下库:

compose-navigation = "androidx.navigation:navigation-compose:2.7.2"

接下来,我们需要将依赖项添加到我们的应用模块的build.gradle.kts文件中:

implementation(libs.compose.navigation)

执行 Gradle 同步操作以将库添加到我们的项目中。下一步是创建NavControllerNavHostNavController是一个管理NavHost内应用导航的类。NavHost是一个容器,它托管可组合项并处理它们之间的导航。让我们创建一个新的包名为navigation,并创建一个新的密封类名为Screens.kt。在文件内部,让我们添加以下代码:

sealed class Screens(val route: String) {
    object PetsScreen : Screens("pets")
    object PetDetailsScreen : Screens("petDetails")
}

这是一个有两个对象的密封类。密封类用于表示受限的类层次结构,其中对象或值只能具有密封类中定义的类型之一。第一个对象是 PetsScreen,它将是我们在启动应用时看到的第一个屏幕。第二个对象是 PetDetailsScreen,它将是我们在点击 PetsScreen 中的宠物项时导航到的屏幕。每次我们需要添加一个新的目的地屏幕时,我们都会在密封类中添加一个新的对象。

接下来,让我们在 navigation 包内创建一个名为 AppNavigation.kt 的新文件。在文件内部,让我们添加以下代码:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination =  Screens.PetsScreen.route
    ){
        composable(Screens.PetsScreen.route){
            PetsScreen()
        }
    }
}

让我们解释前面的代码:

  • 我们使用 rememberNavController() 函数创建 NavController。此函数用于创建将在重组之间被记住的 NavController。这很重要,因为我们需要能够在应用中导航到不同的屏幕。

  • 我们创建了一个接受 navControllerstartDestinationNavHost 组合。startDestination 是我们启动应用时想要看到的第一个屏幕。在我们的例子中,它是 PetsScreen

  • 我们添加了 PetsScreen 组合。这个组合有错误,因为我们还没有创建它。我们将很快做到这一点。

图 7.1 – PetsScreen 错误

图 7.1 – PetsScreen 错误

如前一个截图所示,PetsScreen 组合以红色突出显示,因为我们还没有创建这个组合。我们将对我们的代码进行一些重构。让我们创建一个名为 PetsScreen.kt 的新文件。在文件内部,让我们添加以下代码:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PetsScreen(onPetClicked: (Cat) -> Unit) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "Pets")
                },
                colors =  TopAppBarDefaults.smallTopAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primary,
                )
            )
        },
        content =  { paddingValues ->
            PetList(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                onPetClicked = onPetClicked
            )
        }
    )
}

PetsScreen 组合显示宠物列表。我们已添加一个 Scaffold 组合作为根元素。在 Scaffold 组合内部,我们添加了一个 TopAppBar 组合。我们还添加了一个 PetList 组合作为 Scaffold 组合的内容。我们还在 PetList 组合中添加了一个新的 onPetClicked 回调。我们将使用此回调在点击列表中的宠物项时导航到 PetDetailsScreen

这样,我们的导航图就准备好了。我们现在可以将 AppNavigation 组合添加到我们的 MainActivity.kt 文件中。让我们用以下代码替换 ChapterSevenTheme 块内的所有代码:

ChapterSevenTheme {
    AppNavigation()
}

构建并运行应用。应用仍然显示与之前相同的可爱宠物列表,但现在我们正在使用 Jetpack Navigation 库来处理我们的导航。

图 7.2 – 宠物

图 7.2 – 宠物

在下一节中,让我们学习如何在点击列表中的宠物项时导航到详情屏幕。

导航到 Compose 目的地

在本节中,我们将学习如何在点击列表中的宠物项时导航到详情屏幕。首先,我们需要为 PetDetailsScreen 创建一个新的组合。让我们创建一个名为 PetDetailsScreen.kt 的新文件,并创建 PetDetailsScreenContent 组合,如下所示:

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PetDetailsScreenContent(modifier: Modifier) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        AsyncImage(
            model = "https://cataas.com/cat/rV1MVEh0Af2Bm4O0",
            contentDescription = "Cute cat",
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp),
            contentScale = ContentScale.FillWidth
        )
        FlowRow(
            modifier = Modifier
                .padding(start = 6.dp, end = 6.dp)
        ) {
            repeat(2) {
                SuggestionChip(
                    modifier = Modifier
                        .padding(start = 3.dp, end = 3.dp),
                    onClick = { },
                    label = {
                        Text(text = "Tag $it")
                    }
                )
            }
        }
    }
}

在这里,我们创建了一个以 Column 作为根元素的组合组件。在 Column 元素内部,我们添加了一个显示猫图像的 AsyncImage 组合组件。我们还添加了一个 FlowRow 组合组件,当空间不足时,它会将项目流动到下一行,这是使用行无法实现的。FlowRow 显示了两个 SuggestionChip 组合组件。我们将使用此组合组件来显示宠物的详细信息。注意,我们现在正在使用硬编码的猫 ID 和标签。我们将在下一节中从 PetList 组合组件传递此数据。接下来,让我们按照以下方式创建 PetDetailsScreen 组合组件:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PetDetailsScreen(onBackPressed: () -> Unit) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "Pet Details")
                },
                colors =  TopAppBarDefaults.smallTopAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primary,
                ),
                navigationIcon = {
                    IconButton(
                        onClick = onBackPressed,
                        content = {
                            Icon(
                                imageVector = Icons.Default.ArrowBack,
                                contentDescription = "Back"
                            )
                        }
                    )
                }
            )
        },
        content = { paddingValues ->
            PetDetailsScreenContent(
                modifier = Modifier
                    .padding(paddingValues)
            )
        }
    )
}

PetDetailsScreen 组合组件显示宠物的详细信息。我们添加了一个 Scaffold 组合组件作为根元素。在 Scaffold 组合组件内部,我们添加了一个 TopAppBar 组合组件。我们还使用了之前创建的 PetDetailsScreenContent 组合组件作为 Scaffold 组合组件的内容。我们向 PetDetailsScreen 组合组件添加了一个新的 onBackPressed 回调。我们将使用此回调在点击 TopAppBar 中的返回按钮时导航回上一个屏幕。

我们的下一步是在 AppNavigation.kt 文件中添加 PetDetailsScreen 的组合组件。让我们在 PetsScreen 组合组件下面的 NavHost 中添加以下代码:

composable(Screens.PetDetailsScreen.route){
    PetDetailsScreen(
        onBackPressed = {
            navController.popBackStack()
        }
    )
}

在这里,我们为 PetDetailsScreen 添加了一个组合组件。我们传递了屏幕的路由和 PetDetailsScreen 组合组件作为内容。PetDetailsScreenonBackPressed 参数。该参数处理用户点击顶部左边的返回箭头图标的情况,这通常是位于顶部的。我们在 onBackPressed 参数内部使用 navController.popBackStack()。此方法尝试从返回堆栈中弹出当前目标并导航到上一个目标。

现在我们需要在点击列表中的宠物项时实际导航到 PetDetailsScreen。让我们转到 PetListItem 组合组件。我们将向 PetListItem 组合组件添加一个新的 onPetClicked 回调。修改后的组合组件应该看起来像这样:

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PetListItem(cat: Cat, onPetClicked: (Cat) -> Unit) {
    ElevatedCard(
        modifier = Modifier
            .fillMaxWidth()
            .padding(6.dp)
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 10.dp)
                .clickable {
                    onPetClicked(cat)
                }
        ) {
            AsyncImage(
                model = "https://cataas.com/cat/${cat.id}",
                contentDescription = "Cute cat",
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp),
                contentScale = ContentScale.FillWidth
            )
            FlowRow(
                modifier = Modifier
                    .padding(start = 6.dp, end = 6.dp)
            ) {
              repeat(cat.tags.size) {
                  SuggestionChip(
                      modifier = Modifier
                          .padding(start = 3.dp, end = 3.dp),
                      onClick = { },
                      label = {
                          Text(text = cat.tags[it])
                      }
                  )
              }
            }
        }
    }
}

在前面的代码中,我们向组合组件添加了一个新的 onPetClicked 回调。我们将 clickable 修饰符添加到 Column 上,并在修饰符内部调用 onPetClicked 回调。我们将 cat 对象传递给回调。接下来,我们需要按照以下方式将 onPetClicked 回调添加到 PetList 组合组件中:

@Composable
fun PetList(modifier: Modifier, onPetClicked: (Cat) -> Unit) {
    // other code
}

接下来,我们需要将此回调传递到我们使用 PetListItem 组合组件的地方。在项目块内部调用位置的修改后的 PetListItem 组合组件应该看起来像这样:

PetListItem(
    cat = pet,
    onPetClicked = onPetClicked
)

最后,我们需要修改 AppNavigation 组合组件,以便将 onPetClicked 回调传递给 PetsScreen 组合组件。修改后的 AppNavigation 组合组件应该看起来像这样:

PetsScreen(
    onPetClicked = {
        navController.navigate(Screens.PetDetailsScreen.route)
    }
)

在这里,我们将 onPetClicked 回调传递给 PetsScreen 组合组件。在回调内部,我们在 navController 上调用 navigate() 函数并传入 PetDetailsScreen 的路由。当我们点击列表中的宠物项目时,这将导航到 PetDetailsScreen

构建并运行应用程序。点击列表中的宠物项目。你会看到应用程序导航到 PetDetailsScreen

图 7.3 – 宠物详情屏幕

图 7.3 – 宠物详情屏幕

我们可以看到一张可爱的猫咪图片和一些标签。此外,如果我们按下 TopAppBar 中的返回按钮,我们将能够导航回 PetsScreen

到目前为止,我们已经能够从 PetsScreen 导航到 PetDetailsScreen。然而,我们没有向 PetDetailsScreen 传递任何数据。在下一节中,我们将学习如何向 PetDetailsScreen 传递数据。

向目标传递参数

在我们的 PetDetailsScreen 中,我们需要移除硬编码的猫咪 ID 和标签,并从 PetList 组合组件中传递它们。按照以下步骤操作:

  1. 让我们转到 PetDetailsScreen.kt 文件中的 PetDetailsScreenContent 组合组件,并按如下方式修改它:

    @OptIn(ExperimentalLayoutApi::class)
    @Composable
    fun PetDetailsScreenContent(modifier: Modifier, cat: Cat) {
        Column(
            modifier = modifier
                .fillMaxSize()
                .padding(16.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            AsyncImage(
                model = "https://cataas.com/cat/${cat.id}",
                contentDescription = "Cute cat",
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp),
                contentScale = ContentScale.FillWidth
            )
            FlowRow(
                modifier = Modifier
                    .padding(start = 6.dp, end = 6.dp)
            ) {
                repeat(cat.tags.size) {
                    SuggestionChip(
                        modifier = Modifier
                            .padding(start = 3.dp, end = 3.dp),
                        onClick = { },
                        label = {
                            Text(text = cat.tags[it])
                        }
                    )
                }
            }
        }
    }
    

    我们向组合组件添加了一个新的 cat 参数。我们使用了 cat 对象来显示猫咪图片和标签。

  2. 接下来,让我们转到 PetDetailsScreen 组合组件,并按如下方式修改它:

    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun PetDetailsScreen(onBackPressed: () -> Unit, cat: Cat) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = {
                        Text(text = "Pet Details")
                    },
                    colors =  TopAppBarDefaults.smallTopAppBarColors(
                        containerColor = MaterialTheme.colorScheme.primary,
                    ),
                    navigationIcon = {
                        IconButton(
                            onClick = onBackPressed,
                            content = {
                                Icon(
                                    imageVector = Icons.Default.ArrowBack,
                                    contentDescription = "Back"
                                )
                            }
                        )
                    }
                )
            },
            content = { paddingValues ->
                PetDetailsScreenContent(
                    modifier = Modifier
                        .padding(paddingValues),
                    cat = cat
                )
            }
        )
    }
    

    在这里,我们向组合组件添加了一个新的 cat 参数。我们将 cat 对象传递给了 PetDetailsScreenContent 组合组件。

  3. 接下来,让我们转到 AppNavigation 组合组件,并添加将 cat 对象传递给 PetDetailsScreen 的逻辑。我们首先需要修改 PetDetailsScreen 的组合组件,如下所示:

    composable(
        route = "${Screens.PetDetailsScreen.route}/{cat}",
        arguments = listOf(
            navArgument("cat") {
                type = NavType.StringType
            }
        )
    ){
        PetDetailsScreen(
            onBackPressed = {
                navController.popBackStack()
            },
            cat = Json.decodeFromString(it.arguments?.getString("cat") ?: "")
        )
    }
    

    让我们解释一下这些更改:

    • 在路由上,我们添加了一个新的参数,称为 cat。这是我们用来将 cat 对象传递到 PetDetailsScreen 的参数。

    • 我们添加了一个新的 arguments 参数。该参数用于将参数传递到目标屏幕。我们为 cat 参数添加了 navArgument。我们将类型设置为 String。这是因为我们将传递 cat 对象的字符串表示形式。

    • 我们将 cat 对象传递给 PetDetailsScreen 组合组件。我们使用了 Kotlinx Serialization 库中的 Json.decodeFromString(),这是我们之前在 第六章 中学习到的,将 cat 对象的字符串值转换为 Cat 对象。我们使用了 NavBackStackEntryarguments 属性来获取 cat 对象的字符串值。如果 arguments 属性为空,我们使用了 Elvis 运算符来返回一个空字符串。

  4. 最后,我们需要修改 AppNavigation 组合组件中 PetsScreenonPetClicked 回调,如下所示:

    composable(Screens.PetsScreen.route) {
        PetsScreen(
            onPetClicked = { cat ->
                navController.navigate(
                    "${Screens.PetDetailsScreen.route}/${Json.encodeToString(cat)}"
                )
            }
        )
    }
    

    我们已修改 navigate() 函数,将 Cat 对象作为字符串传递。我们还使用了 Kotlinx Serialization 库中的 Json.encodeToString(),将 Cat 对象转换为字符串。这将作为参数传递给 PetDetailsScreen,当我们点击列表中的宠物项时。

  5. 构建并运行应用程序。点击列表中的任何可爱猫咪图片,现在详情屏幕将显示我们选择的可爱猫咪的图片和标签:

图 7.4 – 宠物详情

图 7.4 – 宠物详情

我们现在已经能够将数据传递给 PetDetailsScreen。我们学习了如何导航到组合目的地并将数据传递到详情屏幕。在下一节中,我们将学习如何在可折叠设备和大型屏幕上处理导航。

可折叠设备和大型屏幕的导航

第四章为大型屏幕和可折叠设备设计 UI 部分,我们学习了 WindowSize 类以及我们如何在可折叠设备和大型屏幕上使我们的应用程序响应。在本节中,我们将使我们的宠物应用程序在可折叠设备和大型屏幕上响应。我们将进行一些更改,如下所示:

  • PetsScreen 添加一个底部栏,其中将包含几个选项。

  • 添加 NavigationRailNavigationDrawer,它们将根据屏幕大小使用。

  • 观察设备的可折叠状态并根据可折叠状态更改应用程序的布局。

  • 根据屏幕大小,我们还将更改内容类型。在大型屏幕上,我们将并列显示猫咪列表和所选猫咪的详细信息。在小屏幕上,我们将分别显示猫咪列表和所选猫咪的详细信息。

需要很多更改。好事是我已经做了这些更改,你可以在项目的 chapterseven 文件夹中找到最终版本。让我们逐一查看这些更改:

  1. 我们将首先创建一个名为 NavigationTypesealed interface,它代表我们将要在应用程序中使用的不同类型的导航。让我们在 navigation 包内创建一个名为 NavigationType.kt 的新文件,并添加以下代码:

    sealed interface NavigationType {
        object BottomNavigation : NavigationType
        object NavigationDrawer : NavigationType
        object NavigationRail : NavigationType
    }
    

    我们在这里使用密封接口而不是密封类。这是因为我们不需要在 NavigationType 中保留任何状态。我们也不需要将属性传递给任何 NavigationTypes。我们有三个选项:BottomNavigationNavigationDrawerNavigationRail。我们将使用这些选项根据屏幕大小更改导航类型。

  2. 接下来,让我们创建另一个名为 ContentType 的密封接口。这个接口将用于根据屏幕大小更改内容显示类型。让我们在 navigation 包内创建一个名为 ContentType.kt 的新文件,并添加以下代码:

    sealed interface ContentType {
        object List : ContentType
        object ListAndDetail : ContentType
    }
    

    这表示我们可以根据屏幕大小显示内容的两种模式。我们有 List 模式,仅显示猫的列表。我们还有 ListAndDetail 模式,它并列显示猫的列表和所选猫的详细信息。

  3. 接下来,在我们的 Screens.kt 文件中,我们必须添加一个新的目标屏幕,称为 FavoritesScreen。该文件的最终代码应如下所示:

    sealed class Screens(val route: String) {
        object PetsScreen : Screens("pets")
        object PetDetailsScreen : Screens("petDetails")
        object FavoritePetsScreen : Screens("favoritePets")
    }
    

    现在我们有三个目标用于我们的应用程序。

  4. 接下来,让我们将 WindowSize 依赖项添加到 libs.versions.toml 文件的库部分:

    compose-window-size = "androidx.compose.material3:material3-window-size-class:1.2.0-alpha07"
    androidx-window = "androidx.window:window:1.1.0"
    
  5. 我们还需要将依赖项添加到我们的应用程序模块的 build.gradle.kts 文件中:

    implementation(libs.compose.window.size)
    implementation(libs.androidx.window)
    

    执行 Gradle 同步以便将依赖项添加到我们的项目中。

  6. 接下来,我们需要创建 NavigationRailNavigationDrawerBottomNavigation 的组合器。让我们在 view 包内创建一个名为 PetsNavigationRail.kt 的新文件,并添加以下代码:

    @Composable
    fun PetsNavigationRail(
        onFavoriteClicked: () -> Unit,
        onHomeClicked: () -> Unit,
        onDrawerClicked: () -> Unit
    ) {
        val items = listOf(Screens.PetsScreen, Screens.FavoritePetsScreen)
        val selectedItem = remember { mutableStateOf(items[0]) }
        NavigationRail(
            modifier = Modifier
                .fillMaxHeight()
        ) {
            NavigationRailItem(
                selected = false,
                onClick = onDrawerClicked,
                icon = {
                    Icon(
                        imageVector = Icons.Default.Menu,
                        contentDescription = "Menu Icon"
                    )
                }
            )
            NavigationRailItem(
                selected = selectedItem.value == Screens.PetsScreen,
                onClick = {
                    onHomeClicked()
                    selectedItem.value = Screens.PetsScreen
                },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Home,
                        contentDescription = "Home Icon"
                    )
                }
            )
            NavigationRailItem(
                selected = selectedItem.value == Screens.FavoritePetsScreen,
                onClick = {
                    onFavoriteClicked()
                    selectedItem.value = Screens.FavoritePetsScreen
                },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Favorite,
                        contentDescription = "Favorite Icon"
                    )
                }
            )
        }
    }
    

    在前面的代码中,我们创建了 PetsNavigationRail() 组合器,它有三个参数:onFavoriteClickedonHomeClickedonDrawerClicked。前两个是回调,将用于导航到不同的屏幕。我们使用 onDrawerClicked 回调来在用户与之交互时关闭或打开抽屉。在顶部,我们有 items 变量,它包含所有我们的屏幕列表,以及 selectedItem 变量,它包含当前选中的屏幕。我们使用来自 Material 3 库的 NavigationRail 组合器来显示导航栏。要向 NavigationRail 添加项目,我们使用 NavigationRailItem 组合器。我们传入项目的选中状态、onClick 回调和要显示的图标。

  7. 接下来,让我们创建 PetsBottomNavigationBar 组合器。让我们在 view 包内创建一个名为 PetsBottomNavigationBar.kt 的新文件,并添加以下代码:

    @Composable
    fun PetsBottomNavigationBar(
        onFavoriteClicked: () -> Unit,
        onHomeClicked: () -> Unit
    ) {
        val items = listOf(Screens.PetsScreen, Screens.FavoritePetsScreen)
        val selectedItem = remember { mutableStateOf(items[0]) }
        NavigationBar(
            modifier = Modifier
                .fillMaxWidth(),
            containerColor = MaterialTheme.colorScheme.background
        ) {
            NavigationBarItem(
                selected = selectedItem.value == Screens.PetsScreen,
                onClick = {
                    onHomeClicked()
                    selectedItem.value = Screens.PetsScreen
                },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Home,
                        contentDescription = "Home Icon"
                    )
                }
            )
            NavigationBarItem(
                selected = selectedItem.value == Screens.FavoritePetsScreen,
                onClick = {
                    onFavoriteClicked()
                    selectedItem.value = Screens.FavoritePetsScreen
                },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Favorite,
                        contentDescription = "Favorite Icon"
                    )
                }
            )
        }
    }
    

    PetsBottomNavigationBar 组合器与 PetsNavigationRail 组合器类似。唯一的区别是我们使用 NavigationBar 组合器而不是 NavigationRail 组合器。我们有主页和收藏夹项目。我们使用 NavigationBarItem 组合器向 NavigationBar 添加项目。我们传入项目的选中状态、onClick 回调和要显示的图标。

  8. 接下来,让我们创建 PetsNavigationDrawer 组合器。让我们在 view 包内创建一个名为 PetsNavigationDrawer.kt 的新文件,并添加以下代码:

    @Composable
    fun PetsNavigationDrawer(
        onFavoriteClicked: () -> Unit,
        onHomeClicked: () -> Unit,
        onDrawerClicked: () -> Unit = {}
    ) {
        val items = listOf(Screens.PetsScreen, Screens.FavoritePetsScreen)
        val selectedItem = remember { mutableStateOf(items[0]) }
        Column(
            modifier = Modifier
                .wrapContentWidth()
                .fillMaxHeight()
                .background(MaterialTheme.colorScheme.inverseOnSurface)
                .padding(16.dp)
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = "Chapter Seven",
                    style = MaterialTheme.typography.titleMedium,
                    color = MaterialTheme.colorScheme.primary
                )
                IconButton(
                    onClick = onDrawerClicked
                ) {
                    Icon(
                        imageVector = Icons.Default.Menu,
                        contentDescription = "Navigation Drawer Icon"
                    )
                }
            }
            NavigationDrawerItem(
                label = { Text(text = "Pets") },
                selected = selectedItem.value == Screens.PetsScreen,
                onClick = {
                    onHomeClicked()
                    selectedItem.value = Screens.PetsScreen
                },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Home,
                        contentDescription = "Home Icon"
                    )
                }
            )
            NavigationDrawerItem(
                label = { Text(text = "Favorites") },
                selected = selectedItem.value == Screens.FavoritePetsScreen,
                onClick = {
                    onFavoriteClicked()
                    selectedItem.value = Screens.FavoritePetsScreen
                },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Favorite,
                        contentDescription = "Favorite Icon"
                    )
                }
            )
        }
    }
    

    我们使用了来自 Material 3 库的 NavigationDrawer 组合器来显示导航抽屉。我们使用 NavigationDrawerItem 组合器向 NavigationDrawer 添加项目。我们传入标签、项目的选中状态、onClick 回调和要显示的图标。

  9. 由于我们的 PetsNavigationDrawerPetsNavigationRailPetsBottomNavigationBar 组合器都包含 FavoritesScreen,让我们在视图包内创建一个名为 FavoritePetsScreen.kt 的新文件,并添加以下代码:

    @Composable
    fun FavoritePetsScreen() {
        Column(
            modifier = Modifier
                .fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(text = "Favorite Pets")
        }
    }
    

    这是一个简单的可组合组件,显示文本 "Favorite Pets"。我们将使用这个可组合组件作为 FavoritesScreen 的内容。我们还需要重构我们的 AppNavigation() 可组合组件,使其准备好处理不同的导航和内容类型。最终修改后的可组合组件应如下所示:

    @Composable
    fun AppNavigation(
        contentType: ContentType,
        navHostController: NavHostController = rememberNavController()
    ) {
        NavHost(
            navController = navHostController,
            startDestination = Screens.PetsScreen.route
        ) {
            composable(Screens.PetsScreen.route) {
                PetsScreen(
                    onPetClicked = { cat ->
                        navHostController.navigate(
                            "${Screens.PetDetailsScreen.route}/${Json.encodeToString(cat)}"
                        )
                    },
                    contentType = contentType
                )
            }
            composable(
                route = "${Screens.PetDetailsScreen.route}/{cat}",
                arguments = listOf(
                    navArgument("cat") {
                        type = NavType.StringType
                    }
                )
            ) {
                PetDetailsScreen(
                    onBackPressed = {
                        navHostController.popBackStack()
                    },
                    cat = Json.decodeFromString(it.arguments?.getString("cat") ?: "")
                )
            }
            composable(Screens.FavoritePetsScreen.route) {
                FavoritePetsScreen()
            }
        }
    }
    

    让我们突出显示这些更改:

    • 我们的 AppNavigation() 可组合组件现在接受一个类型为 ContentTypecontentType 参数。这是我们用来根据屏幕大小更改内容类型的参数。我们还传递了一个类型为 NavHostControllernavHostController 参数。这是我们用来在应用中导航到不同屏幕的参数。之前,navHostController 是在 AppNavigation() 可组合组件内部创建的。我们已经将其移动到调用位置,这样我们就可以在不同的可组合组件中使用相同的 navHostController

    • 我们使用了新的 PetsScreen() 可组合组件,它接受 contentType 参数。和之前一样,我们仍然传递 onPetClicked,它导航到 PetDetailsScreen。之前,我们使用的是 PetList 可组合组件。

    • 最后,我们将我们的新 FavoritePetsScreen 目的地添加到 NavHost 可组合组件中。

  10. 让我们看看新的更新 PetsScreen 可组合组件的样子。让我们转到 PetsScreen.kt 文件并按如下方式修改可组合组件:

    @Composable
    fun PetsScreen(
        onPetClicked: (Cat) -> Unit,
        contentType: ContentType,
    ) {
        val petsViewModel: PetsViewModel = koinViewModel()
        val petsUIState by petsViewModel.petsUIState.collectAsStateWithLifecycle()
        PetsScreenContent(
            modifier = Modifier
                .fillMaxSize(),
            onPetClicked = onPetClicked,
            contentType = contentType,
            petsUIState = petsUIState
        )
    }
    

    我们向可组合组件添加了一个新的 contentType 参数。我们还添加了一个新的 petsUIState 参数。这是 PetsScreen 的 UI 状态。我们将使用此状态来显示猫的列表。

  11. 接下来,创建一个名为 PetsScreenContent.kt 的新文件,并添加以下代码:

    @Composable
    fun PetsScreenContent(
        modifier: Modifier,
        onPetClicked: (Cat) -> Unit,
        contentType: ContentType,
        petsUIState: PetsUIState
    ) {
        Column(
            modifier = modifier
                .padding(16.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            AnimatedVisibility(
                visible = petsUIState.isLoading
            ) {
                CircularProgressIndicator()
            }
            AnimatedVisibility(
                visible = petsUIState.pets.isNotEmpty()
            ) {
                if (contentType == ContentType.List) {
                    PetList(
                        onPetClicked = onPetClicked,
                        pets = petsUIState.pets,
                        modifier = Modifier
                            .fillMaxWidth()
                    )
                } else {
                    PetListAndDetails(
                        pets = petsUIState.pets
                    )
                }
            }
            AnimatedVisibility(
                visible = petsUIState.error != null
            ) {
                Text(text = petsUIState.error ?: "")
            }
        }
    }
    

    让我们解释前面的代码:

    • PetsScreenContent 有一个父 Column 可组合组件。在 Column 可组合组件内部,我们添加了三个 AnimatedVisibility 可组合组件。第一个用于在 petsUIState 正在加载时显示 CircularProgressIndicator。第二个用于在 petsUIState 中的 pets 变量不为空时显示猫的列表。第三个用于在 petsUIState 有错误时显示错误消息。

    • 当显示猫的列表时,我们检查 contentType。如果 contentTypeList,我们显示 PetList 可组合组件。如果 contentTypeListAndDetail,我们显示 PetListAndDetails 可组合组件。我们将很快创建 PetListAndDetails 可组合组件。注意,PetList 可组合组件也被修改为接受 pets 参数。我们将使用此参数来显示猫的列表。我们将在稍后看到这些更改。

    • 最后,如果 petsUIState 有错误,我们显示错误消息。

    我们更新的 PetList 可组合组件应如下所示:

    @Composable
    fun PetList(
        onPetClicked: (Cat) -> Unit,
        pets: List<Cat>,
        modifier: Modifier
    ) {
        LazyColumn(
            modifier = modifier
        ) {
            items(pets) { pet ->
                PetListItem(
                    cat = pet,
                    onPetClicked = onPetClicked
                )
            }
        }
    }
    

    这里没有重大更改:我们只是添加了 pets 参数。我们使用此参数来在 LazyColumn 中显示猫的列表。随着这次更新,现在是时候创建 PetListAndDetails 可组合组件了。

  12. 让我们在视图包内创建一个名为 PetListAndDetails.kt 的新文件,并添加以下代码:

    @Composable
    fun PetListAndDetails(pets: List<Cat>) {
        var currentPet by remember {
            mutableStateOf(pets.first())
        }
        Row(
            modifier = Modifier
                .fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            PetList(
                onPetClicked = {
                    currentPet = it
                },
                pets = pets,
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f)
            )
            PetDetailsScreenContent(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
                    .weight(1f),
                cat = currentPet
            )
        }
    }
    

    这个可组合组件包含一个Row,其中有两个项目,每个项目的权重为1f。我们使用了之前创建的更新版的PetListComposablePetDetailsScreenContent。我们还添加了一个currentPet变量,它保存当前选中的猫。我们使用这个变量来显示选中猫的详细信息。我们还使用这个变量在点击列表中的宠物项目时更新currentPet。请确保您还更新了PetDetailsScreenContent以接受新的修饰符参数。

    经过我们所做的修改,现在让我们创建一个新的可组合组件,称为AppNavigationContent,它根据NavigationType显示NavigationRailBottomNavigation的逻辑。

  13. 让我们在navigation包内创建一个名为AppNavigationContent.kt的新文件,并添加以下代码:

    @Composable
    fun AppNavigationContent(
        contentType: ContentType,
        navigationType: NavigationType,
        onFavoriteClicked: () -> Unit,
        onHomeClicked: () -> Unit,
        navHostController: NavHostController,
        onDrawerClicked: () -> Unit = {}
    ) {
        Row(
            modifier = Modifier
                .fillMaxSize(),
        ) {
            AnimatedVisibility(
                visible = navigationType == NavigationType.NavigationRail
            ) {
                PetsNavigationRail(
                    onFavoriteClicked = onFavoriteClicked,
                    onHomeClicked = onHomeClicked,
                    onDrawerClicked = onDrawerClicked
                )
            }
            Scaffold(
                content = { paddingValues ->
                    Column(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(paddingValues)
                    ) {
                        AppNavigation(
                            contentType = contentType,
                            navHostController = navHostController
                        )
                    }
                },
                bottomBar = {
                    AnimatedVisibility(
                        visible = navigationType == NavigationType.BottomNavigation
                    ) {
                        PetsBottomNavigationBar(
                            onFavoriteClicked = onFavoriteClicked,
                            onHomeClicked = onHomeClicked
                        )
                    }
                }
            )
        }
    }
    

    让我们解释前面的代码:

    • AppNavigationContent可组合组件接受多个参数。contentType参数用于显示内容类型。navigationType参数用于切换导航选项。onFavoriteClickedonHomeClicked是回调函数,将用于导航到不同的屏幕。navHostController是一个对象,用于管理NavHost内的导航。onDrawerClicked用于在用户与之交互时关闭或打开抽屉。

    • 我们有Row作为根元素。在Row内部,我们有一个AnimatedVisibility可组合组件,当navigationTypeNavigationType.NavigationRail时显示PetsNavigationRail可组合组件。我们还添加了一个Scaffold可组合组件。我们使用AppNavigation可组合组件作为Scaffold的内容,传递contentTypenavHostController。我们还使用PetsBottomNavigationBar可组合组件作为Scaffold的底部栏。我们使用AnimatedVisibility可组合组件在navigationTypeNavigationType.BottomNavigation时显示PetsBottomNavigationBar可组合组件。

  14. 最后一步是将MainActivity.kt文件重构以使用新的AppNavigationContent可组合组件。我们将逐步说明更改。有几个更改:

    1. 首先,我们需要观察设备的可折叠状态。这将使我们能够更改内容类型和导航类型。让我们在navigation包内创建一个名为DeviceFoldPosture.kt的新文件,并添加以下代码:
    sealed interface DeviceFoldPosture {
        data class BookPosture(val hingePosition: Rect) : DeviceFoldPosture
        data class SeparatingPosture(
            val hingePosition: Rect,
            val orientation: FoldingFeature.Orientation
        ) : DeviceFoldPosture
        object NormalPosture : DeviceFoldPosture
    }
    @OptIn(ExperimentalContracts::class)
    fun isBookPosture(foldFeature: FoldingFeature?): Boolean {
        contract { returns(true) implies (foldFeature != null) }
        return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
                foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
    }
    @OptIn(ExperimentalContracts::class)
    fun isSeparating(foldFeature: FoldingFeature?): Boolean {
        contract { returns(true) implies (foldFeature != null) }
        return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating
    }
    

    在前面的代码中,我们有一个密封接口,表示可折叠设备可以处于的不同姿态。我们有BookPosture,表示设备处于纵向方向且折叠状态为半开时的姿态。我们有SeparatingPosture,表示折叠或铰链设备创建两个逻辑显示区域时的姿态。我们还有NormalPosture,表示设备未折叠时的姿态。我们有两个实用函数isBookPosture()isSeparating(),用于检查设备的姿态。我们将使用这些函数来检查设备的姿态并根据姿态改变应用程序的布局。

    1. 让我们转到MainActivity.kt文件,并在setContent块之前添加以下代码:
    val deviceFoldingPostureFlow = WindowInfoTracker.getOrCreate(this).windowLayoutInfo(this)
        .flowWithLifecycle(this.lifecycle)
        .map { layoutInfo ->
            val foldingFeature =
                layoutInfo.displayFeatures
                    .filterIsInstance<FoldingFeature>()
                    .firstOrNull()
            when {
                isBookPosture(foldingFeature) ->
                    DeviceFoldPosture.BookPosture(foldingFeature.bounds)
                isSeparating(foldingFeature) ->
                    DeviceFoldPosture.SeparatingPosture(
                        foldingFeature.bounds,
                        foldingFeature.orientation
                    )
                else -> DeviceFoldPosture.NormalPosture
            }
        }
        .stateIn(
            scope = lifecycleScope,
            started = SharingStarted.Eagerly,
            initialValue = DeviceFoldPosture.NormalPosture
        )
    

    在这里,我们使用WindowInfoTracker来获取窗口布局信息。我们使用flowWithLifecycle()来确保只有在活动处于正确的生命周期状态时才获取布局信息。然后我们使用map操作符将布局信息映射到不同的姿态。我们使用stateIn()操作符,它将一个冷的Flow转换为在给定的协程作用域中启动的热StateFlow,共享设备姿态的最新发出值。我们使用SharingStarted.Eagerly来确保当活动处于启动状态时,我们获取姿态的最新值。我们使用initialValue参数将姿态的初始值设置为DeviceFoldPosture.NormalPosture。我们将使用这个流来观察设备的姿态并根据姿态改变应用程序的布局。

    1. 接下来,在我们的setcontent块内部,我们需要在主题块之前添加变量:
    val devicePosture = deviceFoldingPostureFlow.collectAsStateWithLifecycle().value
    val windowSizeClass = calculateWindowSizeClass(activity = this)
    val scope = rememberCoroutineScope()
    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
    val navController = rememberNavController()
    

    我们添加了devicePosture变量,它持有设备的姿态。我们还添加了windowSizeClass变量,它持有设备的窗口大小类;scope变量,它持有CoroutineScopedrawerState变量,它持有抽屉的状态;以及navController变量,它持有NavHostController。我们将使用这个变量来导航到我们应用程序中的不同屏幕。

    1. 在我们的ChapterSevenTheme内部,我们需要添加以下代码:
    val navigationType: NavigationType
    val contentType: ContentType
    when (windowSizeClass.widthSizeClass) {
        WindowWidthSizeClass.Compact -> {
            navigationType = NavigationType.BottomNavigation
            contentType = ContentType.List
        }
        WindowWidthSizeClass.Medium -> {
            navigationType = NavigationType.NavigationRail
            contentType = if (devicePosture is DeviceFoldPosture.BookPosture
                || devicePosture is DeviceFoldPosture.SeparatingPosture
            ) {
                ContentType.ListAndDetail
            } else {
                ContentType.List
            }
        }
        WindowWidthSizeClass.Expanded -> {
            navigationType = if (devicePosture is DeviceFoldPosture.BookPosture) {
                NavigationType.NavigationRail
            } else {
                NavigationType.NavigationDrawer
            }
            contentType = ContentType.ListAndDetail
        }
        else -> {
            navigationType = NavigationType.BottomNavigation
            contentType = ContentType.List
        }
    }
    

    在这里,我们有两个变量:navigationTypecontentType。我们使用windowSizeClass来获取我们设备的宽度,并根据宽度大小为我们的navigationTypecontentType变量分配值:

    • 如果宽度大小是紧凑,我们使用BottomNavigation作为navigationType,使用List作为contentType

    • 如果宽度大小是中等,我们使用NavigationRail作为navigationType。对于contentType,我们检查devicePosture。如果devicePostureDeviceFoldPosture.BookPostureDeviceFoldPosture.SeparatingPosture,我们使用ListAndDetail作为contentType。如果devicePosture不是DeviceFoldPosture.BookPostureDeviceFoldPosture.SeparatingPosture,我们使用List作为contentType

    • 如果宽度大小是Expanded,我们检查devicePosture。如果devicePostureDeviceFoldPosture.BookPosture,我们使用NavigationRail作为navigationType。如果devicePosture不是DeviceFoldPosture.BookPosture,我们使用NavigationDrawer作为navigationType。我们使用ListAndDetail作为contentType

    • 最后,如果宽度大小是其他任何值,我们使用BottomNavigation作为navigationTypeList作为contentType

    1. 在前面的代码下方,添加此if语句:
    if (navigationType == NavigationType.NavigationDrawer) {
        PermanentNavigationDrawer(
            drawerContent = {
                PermanentDrawerSheet {
                    PetsNavigationDrawer(
                        onFavoriteClicked = {
                            navController.navigate(Screens.FavoritePetsScreen.route)
                        },
                        onHomeClicked = {
                            navController.navigate(Screens.PetsScreen.route)
                        }
                    )
                }
            }
        ) {
            AppNavigationContent(
                navigationType = navigationType,
                contentType = contentType,
                onFavoriteClicked = {
                    navController.navigate(Screens.FavoritePetsScreen.route)
                },
                onHomeClicked = {
                    navController.navigate(Screens.PetsScreen.route)
                },
                navHostController = navController
            )
        }
    }
    

    条件检查navigationType是否为NavigationType.NavigationDrawer。如果是,我们使用 Material 3 库中的PermanentNavigationDrawer可组合组件。我们使用我们为drawerContent创建的PetsNavigationDrawer可组合组件。我们将AppNavigationContent可组合组件用作PermanentNavigationDrawer的内容。我们传递navigationTypecontentTypeonFavoriteClickedonHomeClickednavHostController参数。

    1. 接下来,让我们为我们的if语句添加else条件:
    else {
      ModalNavigationDrawer(
          drawerContent = {
              ModalDrawerSheet {
                  PetsNavigationDrawer(
                      onFavoriteClicked = {
                          navController.navigate(Screens.FavoritePetsScreen.route)
                      },
                      onHomeClicked = {
                          navController.navigate(Screens.PetsScreen.route)
                      },
                      onDrawerClicked = {
                          scope.launch {
                              drawerState.close()
                          }
                      }
                  )
              }
          },
          drawerState = drawerState
      ) {
          AppNavigationContent(
              navigationType = navigationType,
              contentType = contentType,
              onFavoriteClicked = {
                  navController.navigate(Screens.FavoritePetsScreen.route)
              },
              onHomeClicked = {
                  navController.navigate(Screens.PetsScreen.route)
              },
              navHostController = navController,
              onDrawerClicked = {
                  scope.launch {
                      drawerState.open()
                  }
              }
          )
      }
    }
    

    在这里,当navigationType不是NavigationType.NavigationDrawer时,我们使用 Material 3 库中的ModalNavigationDrawer可组合组件。我们使用PetsNavigationDrawer可组合组件作为drawerContent。我们将AppNavigationContent可组合组件用作ModalNavigationDrawer的内容。我们传递navigationTypecontentTypeonFavoriteClickedonHomeClickednavHostControllerdrawerState参数。我们还传递onDrawerClicked回调,以便在用户与之交互时打开或关闭抽屉。

这些变化有很多;添加它们的工作做得很好!我们现在需要运行这些变化并看到它们付诸实践。幸运的是,我们有一个可调整大小的模拟器来帮助测试这些变化。我们将在下一小节创建一个并测试该应用。

创建和使用可调整大小的模拟器

要创建和使用可调整大小的模拟器,请按照以下步骤操作:

  1. 从 Android Studio 的右侧侧边栏打开设备管理器窗口。如果您在那里找不到它,请使用顶部的视图菜单选项并选择工具窗口;然后,您将看到设备 管理器选项。

图 7.5 – 设备管理器

图 7.5 – 设备管理器

  1. 选择虚拟设备选项卡,然后点击创建设备,这将带您进入此弹出窗口:

图 7.6 – 新设备配置

图 7.6 – 新设备配置

窗口允许您自定义您想要创建的设备的属性。您可以更改设备类别,并且还可以选择您想要创建的设备。

  1. 让我们在电话类别下选择可调整大小(实验性)选项。这将使我们能够创建一个可调整大小的设备。点击下一步,您将看到以下窗口:

图 7.7 – 系统镜像

图 7.7 – 系统镜像

  1. 在这里,你选择你想要使用的系统镜像。让我们选择 API 34 系统镜像。点击 下一步,你将看到以下窗口:

图 7.8 – 设备信息

图 7.8 – 设备信息

这是最后一步,你需要确认设备名称和设备方向。我们将保持生成的名称并使用纵向作为默认方向。

  1. 点击 完成,你将看到设备已添加到你的设备列表中:

图 7.9 – 设备列表

图 7.9 – 设备列表

  1. 启动模拟器并运行应用。

图 7.10 – 可调整大小的模拟器

图 7.10 – 可调整大小的模拟器

从模拟器中,我们可以看到有两个选项被突出显示。第一个选项允许我们将设备从小型/普通设备更改为折叠或平板电脑设备。第二个选项允许我们在更改为折叠设备时更改选项。让我们将设备更改为折叠设备。现在应用将导航选项更改为导航轨道,并且屏幕上也有打开的第一个猫的列表和详细信息。

图 7.11 – 折叠设备导航轨道

图 7.11 – 折叠设备导航轨道

点击 折叠 选项,我们可以看到以下选项:

图 7.12 – 折叠选项

图 7.12 – 折叠选项

在折叠部分选择第二个选项将带我们到以下屏幕:

图 7.13 – 折叠设备

图 7.13 – 折叠设备

从设备尺寸选项,我们也可以切换到平板电脑视图:

图 7.14 – 平板电脑视图

图 7.14 – 平板电脑视图

你现在可以看到应用已经有一个永久的导航抽屉,屏幕上也有打开的第一个猫的列表和详细信息。点击不同的猫,你将看到猫的详细信息显示在屏幕的右侧。我们还可以导航到 收藏 屏幕并返回到 宠物 屏幕。

图 7.15 – 收藏宠物屏幕

图 7.15 – 收藏宠物屏幕

我们还可以看到模态导航抽屉:

图 7.16 – 模态导航抽屉

图 7.16 – 模态导航抽屉

可调整大小的模拟器是我们测试不同设备尺寸应用的好方法,但它也有其局限性。以下是可以调整大小的模拟器的局限性:

  • 铰链模拟:虽然可调整大小的模拟器提供多窗口支持和模拟各种方向,但它们可能无法准确复制折叠设备上物理铰链的行为。铰链的物理特性和行为可能以不同的方式影响应用布局和交互。

  • 硬件特性:模拟器缺少折叠设备中存在的物理硬件组件,例如实际铰链机制、柔性显示屏、传感器和专有功能,这影响了折叠设备行为的真实模拟。

  • 性能差异:仿真器可能无法准确代表真实可折叠设备的性能能力,特别是在硬件特定的优化和性能特性方面。

  • 现实世界测试环境:可折叠设备可能具有影响用户体验的独特环境因素,例如外部照明条件会影响柔性显示屏。仿真器可能无法准确复制这些现实世界场景。

  • 软件仿真与硬件交互:某些可折叠设备的行为,如跨屏幕的拖放交互或独特的手势,可能由于硬件特定的交互而无法在软件中完全仿真。

我们学习了如何在可折叠设备和大型屏幕上处理导航,以及如何在用户在不同屏幕尺寸之间切换时提供出色的用户体验。这确保了我们的应用能够对不同设备做出响应,并且能够充分利用可用的屏幕尺寸。谷歌团队发布了支持大屏幕和可折叠设备的公司故事;您可以在此查看:developer.android.com/large-screens/stories

摘要

在本章中,我们学习了如何使用 Jetpack Compose 导航库在应用内导航到不同的 Jetpack Compose 屏幕。我们还学习了使用此库的技巧和最佳实践。此外,我们还介绍了如何在导航到屏幕时传递参数。最后,我们通过详细处理大屏幕和可折叠设备中的导航,在第四章所学内容的基础上进行了扩展。

我们已经创建了FavoritePetsScreen,但截至目前,它只有一个Text标签。在下一章中,我们将添加功能以在本地持久化数据,并在没有互联网访问的情况下检索这些数据。我们将学习如何将我们可爱的小猫照片保存到 Room,这是另一个用于离线存储的 Jetpack 库,并且还将添加宠物到我们的收藏列表中。

第八章:在本地持久化数据和执行后台工作

为了提供更好的用户体验,我们必须确保应用在用户每次打开应用时不要每次都获取数据。有时,用户可能处于没有互联网接入的地区,当用户在这种情况下无法使用您的应用时,这可能会非常令人沮丧。对于此类场景,我们必须本地存储数据。我们还必须以高效的方式存储和更新数据,这样不会耗尽设备的电池或阻止用户在应用上做其他事情。在本章中,我们将探讨如何为我们的应用做到这一点。

在本章中,我们将学习如何将数据保存到本地数据库 Room 中,这是 Jetpack 库的一部分。我们将能够保存条目并从 Room 数据库中读取。此外,我们还将学习如何使用 WorkManager 执行长时间运行的操作以及一些最佳实践。

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

  • 从本地数据库保存和读取数据

  • 处理 Room 数据库中的更新和迁移

  • 使用 WorkManager 安排后台任务

  • 测试您的工作者

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本 (developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在 github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chaptereight 找到本章的代码。

从本地数据库保存和读取数据

我们将基于宠物应用进行构建,该应用显示可爱猫的列表。我们将把我们的可爱猫保存在本地数据库 Room 中,Room 是 Android Jetpack 库的一部分,它为 SQLite 提供了一个包装和抽象层。我们还将使用 ViewModel。Room 数据库在 SQLite 上提供了一个抽象层,允许流畅的数据库访问,同时充分利用 SQLite 的全部功能。它还内置了对 Kotlin 协程和流的支撑,以允许异步数据库访问。Room 还在编译时是安全的,因此 SQL 查询中的任何错误都会在编译时被捕获。它允许我们用简洁的代码完成所有这些。

要在我们的项目中使用 Room,我们需要将其依赖项添加到我们的 libs.versions.toml 文件中。让我们首先在 versions 部分定义 Room 版本,如下所示:

room = "2.5.2"

接下来,让我们在我们的 libraries 部分添加依赖项:

room-runtime = { module = "androidx.room:room-runtime" , version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }

同步项目以便添加更改。在我们将这些依赖项添加到应用级别的 build.gradle.kts 文件之前,我们需要为 Room 编译器设置一个注解处理器。Room 使用 build.gradle.kts 文件:

id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false

我们已经添加了 build.gradle.kts 文件:

id("com.google.devtools.ksp")

这允许我们在我们的应用模块中使用 KSP。为了最终设置 Room,现在让我们将我们之前声明的依赖项添加到应用级别的build.gradle.kts文件中:

implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)

我们已经添加了我们的房间依赖和 Room KTX 库,使用implementation配置以及 Room 编译器使用ksp配置。我们现在可以开始在我们的项目中使用 Room 了。让我们先为我们的Cat对象创建一个实体类。这将是一个数据类,用于在数据库中存储我们的宠物。在data包内,创建一个名为CatEntity.kt的新文件,并添加以下代码:

@Entity(tableName = "Cat")
data class CatEntity(
    @PrimaryKey
    val id: String,
    val owner: String,
    val tags: List<String>,
    val createdAt: String,
    val updatedAt: String
)

这个数据类代表了我们猫的 Room 表。@Entity注解用于定义我们的猫的表。我们传递了tableName值来指定我们的表名。@PrimaryKey注解用于定义tags,它是一个字符串列表。Room 提供了使用@TypeConverter注解保存非原始类型的功能。让我们创建一个名为PetsTypeConverters.kt的新文件,并添加以下代码:

class PetsTypeConverters {
    @TypeConverter
    fun convertTagsToString(tags: List<String>): String {
        return Json.encodeToString(tags)
    }
    @TypeConverter
    fun convertStringToTags(tags: String): List<String> {
        return Json.decodeFromString(tags)
    }
}

这个类有两个函数被@TypeConverter注解标记。第一个函数将字符串列表转换为字符串。第二个函数将字符串转换为字符串列表。我们使用了 Kotlinx 序列化库将字符串列表转换为字符串,反之亦然。这个类将在我们即将创建的数据库类中引用。

我们现在准备好创建我们的数据库。我们需要创建一个data包,创建一个名为CatDao.kt的新文件,并添加以下代码:

@Dao
interface CatDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(catEntity: CatEntity)
    @Query("SELECT * FROM Cat")
    fun getCats(): Flow<List<CatEntity>>
}

接口被@Dao注解标记,以告诉 Room 我们将使用这个类作为我们的 DAO。我们在 DAO 中定义了两个函数。insert函数用于将猫插入到我们的数据库中。请注意,这是一个suspend函数。这是因为我们将使用协程将猫插入到我们的数据库中。由于插入数据库是一个资源密集型操作,需要在后台线程上执行。我们还使用@Insert注解,并将onConflict参数设置为OnConflictStrategy.REPLACE。这告诉 Room 如果猫已经在数据库中存在,则替换它。getCats函数用于从我们的数据库中获取所有猫。它有一个@Query注解,用于定义一个查询以从我们的数据库中获取猫。我们使用Flow从数据库返回猫。Flow是一个可观察的数据流。这意味着每次我们更新数据库时,更改将立即发送到视图层,而无需我们做任何额外的工作。酷,对吧?

现在我们需要创建我们的数据库类。在data包内,创建一个名为CatDatabase.kt的新文件,并添加以下代码:

@Database(
    entities = [CatEntity::class],
    version = 1
)
@TypeConverters(PetsTypeConverters::class)
abstract class CatDatabase: RoomDatabase() {
    abstract fun catDao(): CatDao
}

我们定义了一个继承自RoomDatabase类的抽象类。我们传递了entities参数来指定我们数据库中存储的实体或表。我们还传递了version参数来指定我们数据库的版本。我们使用了@TypeConverters注解来指定我们将在数据库中使用的类型转换器。我们还定义了一个返回我们的CatDao的抽象方法。我们需要为需要它的类提供一个数据库实例。我们将通过使用我们在项目中一直在使用的依赖注入模式来完成此操作。让我们转到di包,并在Module.kt文件中,在 Retrofit 依赖项下方添加 Room 依赖项:

single {
    Room.databaseBuilder(
        androidContext(),
        CatDatabase::class.java,
        "cat-database"
    ).build()
}
single { get<CatDatabase>().carDao() }

首先,我们创建了我们数据库的单个实例。我们使用了databaseBuilder方法来创建我们的数据库。我们传递了来自 Koin 的androidContext()方法以获取我们应用程序的上下文。我们还传递了CatDatabase::class.java来指定我们数据库的类。我们还传递了数据库的name。然后我们创建了一个CatDao的单个实例。我们使用get方法获取我们数据库的实例,然后调用catDao函数来获取我们的CatDao

我们的数据库名现已经准备好在我们的存储库中使用。我们将修改PetRepository及其实现,以便能够执行以下操作:

  • 将项目保存到我们的数据库

  • 从我们的数据库读取项目

  • 将我们的getPets()函数修改为返回一个宠物Flow

修改后的PetRepository.kt文件应如下所示:

interface PetsRepository {
    suspend fun getPets(): Flow<List<Cat>>
    suspend fun fetchRemotePets()
}

我们已经修改了getPets函数,使其返回一个宠物Flow。Room 不允许在主线程上访问数据库,因此,我们的查询必须是异步的。Room 提供了对可观察查询的支持,每次数据库中的数据发生变化时,它都会从我们的数据库中读取数据并发出新值以反映这些变化。这就是为什么我们从getPets函数返回Flow实例类型的原因。我们还添加了fetchRemotePets函数来从远程数据源获取宠物。现在让我们修改PetRepositoryImpl.kt文件,进行一些更改:

class PetsRepositoryImpl(
    private  val catsAPI: CatsAPI,
    private val dispatcher: CoroutineDispatcher,
    private val catDao: CatDao
): PetsRepository {
    override suspend fun getPets(): Flow<List<Cat>> {
        return withContext(dispatcher) {
           catDao.getCats()
               .map { petsCached ->
                   petsCached.map { catEntity ->
                       Cat(
                           id = catEntity.id,
                           owner = catEntity.owner,
                           tags = catEntity.tags,
                           createdAt = catEntity.createdAt,
                           updatedAt = catEntity.updatedAt
                       ) }
               }
               .onEach {
                     if (it.isEmpty()) {
                          fetchRemotePets()
                     }
               }
        }
    }
    override suspend fun fetchRemotePets() {
        withContext(dispatcher) {
            val response = catsAPI.fetchCats("cute")
            if (response.isSuccessful) {
                response.body()!!.map {
                    catDao.insert(CatEntity(
                        id = it.id,
                        owner = it.owner,
                        tags = it.tags,
                        createdAt = it.createdAt,
                        updatedAt = it.updatedAt
                    ))
                }
            }
        }
    }
}

我们进行了以下更改:

  • 我们在类的构造函数中添加了catDao属性。

  • 我们已经修改了getPets函数,使其返回一个宠物Flow。此外,我们还添加了一个map操作符,将CatEntity映射到Cat对象。我们还添加了一个onEach操作符来检查宠物列表是否为空。如果为空,我们将调用fetchRemotePets函数从远程数据源获取宠物。这为我们的用户提供了一个离线优先体验;也就是说,我们首先检查我们数据库中是否有数据,如果没有,我们就从远程数据源获取它。

  • 最后,我们修改了从远程数据源获取宠物的 fetchRemotePets 函数。当响应成功时,我们将响应映射到 CatEntity 实例类型,并将其插入到我们的数据库中。

我们需要在 Module.kt 文件中更新 PetsRepository 依赖项以添加 CatDao 依赖项:

single<PetsRepository> { PetsRepositoryImpl(get(), get(), get()) }

在我们的 PetsRepositoryImpl 类中,我们已经能够从 Room 数据库中读取和获取数据。接下来,我们将修改 PetsViewModel 中的 getPets() 函数以适应这些新变化。前往 PetsViewModel.kt 文件并修改 getPets() 函数,使其看起来如下:

private fun getPets() {
    petsUIState.value = PetsUIState(isLoading = true)
    viewModelScope.launch {
        petsRepository.getPets().asResult().collect { result ->
            when (result ) {
                is NetworkResult.Success -> {
                    petsUIState.update {
                        it.copy(isLoading = false, pets = result.data)
                    }
                }
                is NetworkResult.Error -> {
                    petsUIState.update {
                        it.copy(isLoading = false, error = result.error)
                    }
                }
            }
        }
    }
}

我们做了一些小的修改。我们使用了 asResult() 扩展函数将宠物的 Flow 转换为 NetworkResultFlow。这是因为我们现在从我们的仓库返回宠物的 Flow。其余的代码与之前相同。我们会得到一个错误,因为我们还没有创建 asResult() 扩展函数。让我们在我们的 NetworkResult.kt 文件中创建它:

fun <T> Flow<T>.asResult(): Flow<NetworkResult<T>> {
    return this
        .map<T, NetworkResult<T>> {
            NetworkResult.Success(it)
        }
        .catch { emit(NetworkResult.Error(it.message.toString())) }
}

这是一个 Flow 类的扩展函数。它将项目的 Flow 映射到 NetworkResult 类。现在我们可以回到我们的 PetsViewModel 类,并添加扩展函数导入以解决错误。

我们需要做的最后一个更改是在 Application 类中向我们的 Koin 实例提供应用程序上下文。前往 ChapterEightApplication.kt 文件并修改 startKoin 块如下:

startKoin {
    androidContext(applicationContext)
    modules(appModules)
}

我们已经将应用程序上下文提供给我们的 Koin 实例。现在,我们可以运行应用程序。你应该能看到可爱猫咪的列表。

图 8.1 – 可爱的猫咪

图 8.1 – 可爱的猫咪

应用程序仍然像以前一样工作,但现在我们从 Room 数据库中读取项目。如果你关闭数据和 Wi-Fi,应用程序仍然显示可爱猫咪的列表!令人惊讶,不是吗?我们已经能够使应用程序离线工作。拥有良好架构的应用程序的一个好处是,我们可以更改不同的层,而无需必然影响其他层。我们已经能够将数据源从远程数据源更改为本地数据源,而不会影响视图层。这就是拥有良好架构的力量。

我们知道如何从 Room 数据库中插入和读取数据,但更新数据怎么办?在下一节中,我们将学习如何更新 Room 数据库中的数据。在这个过程中,我们还将学习如何使用 Room 的自动****迁移功能从一个数据库版本迁移到另一个版本。

处理 Room 数据库的更新和迁移

FavoritePetsScreen 目前没有任何功能。我们将添加收藏宠物的功能,并在 Room 数据库中更新此信息。为了实现这一点,我们需要做以下事情:

  • 设置 Room 模式目录。

  • 在我们的 CatEntity 类中添加一个新列以存储猫咪的收藏状态。

  • CatDao 添加一个新功能以更新猫的收藏状态。

  • 使用收藏图标更新我们的 UI,一旦点击,就会更新猫的收藏状态。这意味着 ViewModel 和仓库类在过程中也会被更新。

让我们开始以下步骤:

  1. 让我们先设置模式目录。在我们的应用级别的 build.gradle.kts 文件中,添加以下代码:

    ksp {
        arg("room.schemaLocation", "$projectDir/schemas")
    }
    

    执行 Gradle 同步然后构建项目。这将生成一个名为当前数据库版本的 schema json 文件,如下所示图所示:

图 8.2 – Room 模式目录

图 8.2 – Room 模式目录

如前图所示,你必须切换到 CatEntity 接口来存储猫的收藏状态。

  1. 我们将把这个字段添加到 CatEntityCat 数据类中。转到 CatEntity.kt 文件并添加一个名为 isFavorite 的新字段:

    @ColumnInfo(defaultValue = "0")
    val isFavorite: Boolean = false
    

    这是一个默认值为 false 的布尔值。我们也使用了 @ColumnInfo 注解来指定我们数据库中列的默认值。我们将使用这个字段来存储猫的收藏状态。确保你也在 Cat 数据类中添加了 val isFavorite: Boolean = false 字段。我们现在需要更新我们的 CatDao 类以能够更新猫的收藏状态。

  2. 让我们转到 CatDao.kt 文件并添加以下功能:

    @Update
    suspend fun update(catEntity: CatEntity)
    @Query("SELECT * FROM Cat WHERE isFavorite = 1")
    fun getFavoriteCats(): Flow<List<CatEntity>>
    

    这里有两个函数。第一个函数将用于更新猫的收藏状态。我们使用了 @Update 注解来告诉 Room 这个函数将用于更新我们数据库中的 CatEntity 类。第二个函数将用于从我们的数据库中获取收藏的猫。我们使用了 @Query 注解来定义从我们的数据库中获取收藏猫的查询。我们使用了 Flow 来从我们的数据库中返回收藏的猫。现在,我们需要向我们的数据库添加一个迁移来添加新的列,这确保了在更新我们的数据库时不会丢失任何数据。向 CatDatabase 添加 autoMigration 如下:

    @Database(
        entities = [CatEntity::class],
        version = 2,
        autoMigrations = [
            AutoMigration(from = 1, to = 2)
        ]
    )
    

    我们已经向我们的数据库添加了 autoMigrations 参数。我们向参数传递了一个 AutoMigration 对象的列表。我们传递了 fromto 参数来指定我们数据库的版本。确保你添加了 AutoMigration 类的导入。注意,我们还将我们数据库的 version 提高了。这是因为我们向数据库添加了一个新列。构建项目以生成 schema json 文件。你应该会看到一个名为新数据库版本的新 schema JSON 文件。我们的模式目录应该看起来像以下这样:

图 8.3 – 更新的 Room 模式目录

图 8.3 – 更新的 Room 模式目录

如果我们打开 2.json 文件,我们会注意到新添加了 isFavorite 列到我们的表中。这是为了自动化迁移。我们现在可以更新我们的仓库以能够更新猫的喜欢状态。

  1. 让我们转到 PetsRepository.kt 文件,并添加以下函数:

    suspend fun updatePet(cat: Cat)
    suspend fun getFavoritePets(): Flow<List<Cat>>
    

updatePet(cat: Cat)getFavoritePets() 函数将用于更新猫的喜欢状态和获取喜欢的猫。

  1. 让我们在 PetsRepositoryImpl.kt 类中添加这两个函数的实现:

    override suspend fun updatePet(cat: Cat) {
        withContext(dispatcher) {
            catDao.update(CatEntity(
                id = cat.id,
                owner = cat.owner,
                tags = cat.tags,
                createdAt = cat.createdAt,
                updatedAt = cat.updatedAt,
                isFavorite = cat.isFavorite
            ))
        }
    }
    override suspend fun getFavoritePets(): Flow<List<Cat>> {
        return withContext(dispatcher) {
            catDao.getFavoriteCats()
                .map { petsCached ->
                    petsCached.map { catEntity ->
                        Cat(
                            id = catEntity.id,
                            owner = catEntity.owner,
                            tags = catEntity.tags,
                            createdAt = catEntity.createdAt,
                            updatedAt = catEntity.updatedAt,
                            isFavorite = catEntity.isFavorite
                        )
                    }
                }
        }
    }
    

    下面是这些函数的解释:

    • updatePet 函数中,我们使用了 CatDao 接口中的更新方法来更新猫的喜欢状态。我们还使用了 withContext 来确保更新在后台线程上运行。我们根据传递给函数的 Cat 对象创建了一个新的 CatEntity 类。

    • getFavoritePets 函数中,我们使用了 CatDao 接口中的 getFavoriteCats 函数从我们的数据库中获取喜欢的猫。我们还把 CatEntity 列表映射到了 Cat 列表中。然后我们返回了一个类型为 Flow 的喜欢的猫的实例。

  2. PetsRepositoryImpl.kt 文件中,我们需要更新 fetchRemotePetsgetPets 函数以更新猫的喜欢状态,如下所示:

    override suspend fun getPets(): Flow<List<Cat>> {
        return withContext(dispatcher) {
           catDao.getCats()
               .map { petsCached ->
                   petsCached.map { catEntity ->
                       Cat(
                           id = catEntity.id,
                           owner = catEntity.owner,
                           tags = catEntity.tags,
                           createdAt = catEntity.createdAt,
                           updatedAt = catEntity.updatedAt,
                           isFavorite = catEntity.isFavorite
                       )
                   }
               }
               .onEach {
                   if (it.isEmpty()) {
                       fetchRemotePets()
                   }
               }
        }
    }
    override suspend fun fetchRemotePets() {
        withContext(dispatcher) {
            val response = catsAPI.fetchCats("cute")
            if (response.isSuccessful) {
                response.body()!!.map {
                    catDao.insert(CatEntity(
                        id = it.id,
                        owner = it.owner,
                        tags = it.tags,
                        createdAt = it.createdAt,
                        updatedAt = it.updatedAt,
                        isFavorite = it.isFavorite
                    ))
                }
            }
        }
    }
    

    当我们将 CatEntity 类映射到 Cat 对象时,我们添加了 isFavorite 参数。这将确保我们在从远程和本地数据源获取猫时,有猫的喜欢状态。

  3. 让我们转到 PetsViewModel 类,并在 petsUIState 变量下方添加以下变量:

    private val _favoritePets = MutableStateFlow<List<Cat>>(emptyList())
    val favoritePets: StateFlow<List<Cat>> get() = _favoritePets
    

    在这里,我们创建了一个私有的 MutableStateFlow 喜欢的猫和一个公共的 StateFlow 喜欢的猫。我们将使用 _favoritePets 变量来更新喜欢的猫,并使用 favoritePets 变量来观察喜欢的猫。这种模式通常建议用于防止将可变状态暴露给视图层。

  4. 接下来,让我们在 PetsViewModel 中的 getPets() 函数下方添加这两个函数:

    fun updatePet(cat: Cat) {
        viewModelScope.launch {
            petsRepository.updatePet(cat)
        }
    }
    fun getFavoritePets() {
        viewModelScope.launch {
            petsRepository.getFavoritePets().collect {
                _favoritePets.value = it
            }
        }
    }
    

    updatePet 函数将从 UI 中调用以更新猫的喜欢状态。getFavoritePets 函数将从 UI 中调用以从我们的数据库中获取喜欢的猫。我们从数据库中收集喜欢的猫并更新 _favoritePets 变量。有了这些更改,我们现在可以更改我们的视图,以便能够喜欢一只猫并查看喜欢的宠物列表。

  5. 我们将首先将我们的喜欢图标添加到 PetListItem 组合组件中。让我们转到 PetList.kt 文件,并更新 PetListItem 组合组件如下:

    @OptIn(ExperimentalLayoutApi::class)
    @Composable
    fun PetListItem(
        cat: Cat,
        onPetClicked: (Cat) -> Unit,
        onFavoriteClicked: (Cat) -> Unit
    ) {
        ElevatedCard(
            modifier = Modifier
                .fillMaxWidth()
                .padding(6.dp)
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 10.dp)
                    .clickable {
                        onPetClicked(cat)
                    }
            ) {
                AsyncImage(
                    model = "https://cataas.com/cat/${cat.id}",
                    contentDescription = "Cute cat",
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(200.dp),
                    contentScale = ContentScale.FillWidth
                )
                Row(
                    modifier = Modifier
                        .padding(start = 6.dp, end = 6.dp)
                        .fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    FlowRow(
                        modifier = Modifier
                            .padding(start = 6.dp, end = 6.dp)
                    ) {
                        repeat(cat.tags.size) {
                            SuggestionChip(
                                modifier = Modifier
                                    .padding(start = 3.dp, end = 3.dp),
                                onClick = { },
                                label = {
                                    Text(text = cat.tags[it])
                                }
                            )
                        }
                    }
                    Icon(
                        modifier = Modifier
                            .clickable {
                                onFavoriteClicked(cat.copy(isFavorite = !cat.isFavorite))
                            },
                        imageVector = if (cat.isFavorite) {
                            Icons.Default.Favorite
                        } else {
                            Icons.Default.FavoriteBorder
                        },
                        contentDescription = "Favorite",
                        tint = if (cat.isFavorite) {
                            Color.Red
                        } else {
                            Color.Gray
                        },
                    )
                }
            }
        }
    }
    

    我们在 PetListItem 组合器中添加了 Icon 组合器。如果猫被收藏,我们使用 Icons.Default.Favorite 图标,如果没有收藏,我们使用 Icons.Default.FavoriteBorder 图标。我们还使用了 tint 参数来根据猫的收藏状态改变图标的颜色。Icon 现在在 Row 中,与 FlowRow 一起显示标签列表。我们还向 PetListItem 组合器添加了 onFavoriteClicked 参数。我们使用此参数更新猫的收藏状态。

  6. 让我们更新 PetList 组合器,添加一个名为 onFavoriteClicked 的新回调参数,并将该参数传递给 PetListItem 组合器:

    @Composable
    fun PetList(
        onPetClicked: (Cat) -> Unit,
        pets: List<Cat>,
        modifier: Modifier,
        onFavoriteClicked: (Cat) -> Unit
    ) {
        LazyColumn(
            modifier = modifier
        ) {
            items(pets) { pet ->
                PetListItem(
                    cat = pet,
                    onPetClicked = onPetClicked,
                    onFavoriteClicked = onFavoriteClicked
                )
            }
        }
    }
    
  7. 接下来,我们将添加 onFavoriteClicked 回调作为 PetsScreenContent 的参数:

    @Composable
    fun PetsScreenContent(
        modifier: Modifier,
        onPetClicked: (Cat) -> Unit,
        contentType: ContentType,
        petsUIState: PetsUIState,
        onFavoriteClicked: (Cat) -> Unit
    ) {
        // code
        }
    
  8. 我们现在可以将参数传递给 PetList 组合器:

    PetList(
        onPetClicked = onPetClicked,
        pets = petsUIState.pets,
        modifier = Modifier
        .fillMaxWidth(),
        onFavoriteClicked = onFavoriteClicked
    )
    
  9. 让我们更新 PetAndDetails 组合器,添加 onFavoriteClicked 参数:

    @Composable
    fun PetListAndDetails(
        pets: List<Cat>,
        onFavoriteClicked: (Cat) -> Unit
    ) {
        // code
        }
    
  10. 我们现在可以将参数传递给 PetList 组合器:

    PetList(
        onPetClicked = {
        currentPet = it
        },
        pets = pets,
        modifier = Modifier
        .fillMaxWidth()
        .weight(1f),
        onFavoriteClicked = onFavoriteClicked
    )
    
  11. 回到 PetsScreenContent.kt 文件,我们需要将 onFavoriteClicked 参数传递给 PetListAndDetails 组合器:

    PetListAndDetails(
        pets = petsUIState.pets,
        onFavoriteClicked = onFavoriteClicked
    )
    

    到目前为止,我们已做的所有更改的最终 PetScreenContent.kt 文件应该看起来像这样:

图 8.4 – 更新的 PetscreenContent

图 8.4 – 更新的 PetscreenContent

  1. 接下来,在 PetsScreen.kt 文件中的 PetsScreen 组合器中,我们需要将 onFavoriteClicked 参数添加到 PetsScreenContent 组合器中:

    PetsScreenContent(
        modifier = Modifier
            .fillMaxSize(),
        onPetClicked = onPetClicked,
        contentType = contentType,
        petsUIState = petsUIState,
        onFavoriteClicked = {
            petsViewModel.updatePet(it)
        }
    )
    

    我们已经将 onFavoriteClicked 回调传递给了 PetsScreenContent 组合器。我们用更新后的猫对象调用了我们的 PetsViewModel 类的 updatePet 方法。让我们运行应用;现在它有一个新的收藏图标。如果我们点击图标,图标会变成一个红色的实心心形图标:

图 8.5 – 憨态可爱好猫与收藏

图 8.5 – 憨态可爱好猫与收藏

  1. 最后,我们将更新 FavoritePetsScreen 以显示收藏猫的列表。让我们转到 FavoritePetsScreen.kt 文件并更新 FavoritePetsScreen 组合器,使其如下所示:

    @Composable
    fun FavoritePetsScreen(
        onPetClicked: (Cat) -> Unit
    ) {
        val petsViewModel: PetsViewModel = koinViewModel()
        LaunchedEffect(Unit) {
            petsViewModel.getFavoritePets()
        }
        val pets by petsViewModel.favoritePets.collectAsStateWithLifecycle()
        if (pets.isEmpty()) {
            Column(
                modifier = Modifier
                    .fillMaxSize(),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(text = "No favorite pets")
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
            ) {
                items(pets) { pet ->
                    PetListItem(
                        cat = pet,
                        onPetClicked = onPetClicked,
                        onFavoriteClicked = {
                            petsViewModel.updatePet(it)
                        }
                    )
                }
            }
        }
    }
    

    下面是更改的解释:

    • 我们向 FavoritePetsScreen 组合器添加了一个新参数 onPetClicked。我们将使用此参数导航到 PetDetailsScreen 组合器。

    • 我们使用 koinViewModel() 方法创建了我们 PetsViewModel 类的新实例。

    • 我们从我们的 PetsViewModel 类中调用了 getFavoritePets 函数来获取我们数据库中的收藏猫。我们使用 LaunchedEffect 在组合器首次启动时调用此方法。这是为了确保我们不会每次组合器重新组合时都调用该函数。

    • 我们有一个名为 pets 的新变量,它是一个收藏猫咪的 StateFlow。我们使用了 collectAsStateWithLifecycle 方法从我们的数据库中收集收藏猫咪。此方法具有生命周期感知性,因此它只会在可组合项活跃时收集收藏猫咪。

    • 我们添加了一个检查,以查看收藏猫咪列表是否为空。如果为空,我们向用户显示一条消息。如果不为空,我们显示收藏猫咪的列表。

  2. 我们需要更新 AppNavigation.kt 文件,将 onPetClicked 回调传递给 FavoritePetsScreen 可组合项:

    FavoritePetsScreen(
        onPetClicked = { cat ->
            navHostController.navigate(
                "${Screens.PetDetailsScreen.route}/${Json.encodeToString(cat)}"
            )
        }
    )
    

    这个逻辑类似于我们在 PetsScreen 中的逻辑,它处理在 FavoritePetsScreen 中导航到 PetDetailsScreen。构建并运行应用。点击底部栏上的收藏图标,你应该能看到你收藏的可爱猫咪列表。如果你点击收藏图标,猫咪会立即从收藏猫咪列表中移除。这是因为收藏猫咪列表是一个 Flow,每次 Room 更新数据时,它们都会立即发射到视图层。

图 8.6 – 收藏猫咪屏幕

图 8.6 – 收藏猫咪屏幕

我们已经能够添加收藏猫咪的功能,并在 Room 数据库中更新这些信息。我们还能处理 Room 数据库中的更新和迁移。在下一节中,我们将看到如何使用 WorkManager 来安排后台任务。在这种情况下,我们将使用 WorkManager 从远程数据源获取猫咪并将其保存到我们的数据库中。这改善了我们的首次离线体验,因为我们将在数据库中始终拥有最新的数据。

使用 WorkManager 来安排后台任务

WorkManager 是一个适合在后台执行长时间运行任务的 Jetpack 库。它确保即使在您的应用重启或手机重启时,后台任务也能完成。使用 WorkManager,您可以安排一次性作业或重复性作业。

我们将首先将 WorkManager 依赖项添加到我们的项目中。按照以下步骤操作:

  1. 让我们转到 libs.versions.toml 文件,并在我们的 versions 部分如下定义工作版本:

    work = "2.8.1"
    
  2. 在库(libraries)部分,添加以下依赖项:

    work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
    workmanager-koin = { module = "io.insert-koin:koin-androidx-workmanager", version.ref = "koin" }
    

    在这里,我们有两个依赖项:work-runtime-ktx 依赖项,这是 WorkManager 的核心依赖项,以及 koin-androidx-workmanager 依赖项,用于将 WorkManager 与 Koin 集成。同步项目以添加这些更改。

  3. 接下来,我们需要将依赖项添加到应用级别的 build.gradle.kts 文件中:

    implementation(libs.work.runtime)
    implementation(libs.workmanager.koin)
    
  4. 执行 Gradle 同步以将这些依赖项添加到我们的项目中。

我们现在已准备好开始在项目中使用 WorkManager。我们将使用 WorkManager 从远程数据源获取猫咪并将其保存到我们的数据库中。我们将使用 OneTimeWorkRequest 类来安排一次性作业,从远程数据源获取猫咪并将其保存到数据库中。让我们开始以下步骤:

  1. 让我们创建一个名为 workers 的新包,并在其中创建一个名为 PetsSyncWorker.kt 的新文件,并添加以下代码:

    class PetsSyncWorker(
        appContext: Context,
        workerParams: WorkerParameters,
        private val  petsRepository: PetsRepository
    ): CoroutineWorker(appContext, workerParams) {
        override suspend fun doWork(): Result {
            return try {
                petsRepository.fetchRemotePets()
                Result.success()
            } catch (e: Exception) {
                Result.failure()
            }
        }
    }
    

    在前面的代码块中,我们创建了一个实现了 CoroutineWorker 类的类。当我们想在后台执行长时间运行的任务时,我们会实现这个类。它使用协程来执行长时间运行的任务。我们将 appContextworkerParams 参数传递给了类的构造函数。我们还传递了 petsRepository 参数到类的构造函数。我们重写了 doWork 方法,这是一个将在工作调度时被调用的 suspend 函数。我们从 PetsRepository 调用了 fetchRemotePets 来从远程数据源获取猫并保存到我们的数据库中。如果工作成功,我们返回 Result.success();如果工作失败,我们返回 Result.failure()

  2. 接下来,让我们在我们的 Module.kt 文件中创建一个 PetsSyncWorker 的实例:

    worker { PetsSyncWorker(get(), get(), get()) }
    

    我们正在使用 worker Koin DSL 创建 PetsSyncWorker 的实例。这是从我们刚刚添加的 Koin WorkManager 库中来的。我们将 appContextworkerParamspetsRepository 参数传递给了 PetsSyncWorker 的构造函数。

  3. 接下来,让我们在我们的 MainActivity.kt 文件中添加这个函数:

    private fun startPetsSync() {
        val syncPetsWorkRequest = OneTimeWorkRequestBuilder<PetsSyncWorker>()
            .setConstraints(
                Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.CONNECTED)
                    .setRequiresBatteryNotLow(true)
                    .build()
            )
            .build()
        WorkManager.getInstance(applicationContext).enqueueUniqueWork(
            "PetsSyncWorker",
            ExistingWorkPolicy.KEEP,
            syncPetsWorkRequest
        )
    }
    

    在前面的代码中,我们使用 PetSyncWorker 创建了一个新的 OneTimeWorkRequest。我们还在我们的工作请求中设置了一些约束。我们将网络类型设置为 NetworkType.CONNECTED,以确保只有在设备连接到互联网时才执行工作请求。我们还有其他网络类型,如下所示:

    • NOT_REQUIRED: 对于此类类型,不需要网络。这对于不需要任何互联网连接即可工作的任务很有用。

    • UNMETERED: 对于此类网络,需要一个非计费的网络连接,如 Wi-Fi。这适用于涉及大量数据传输的任务。

    • METERED: 对于此类网络,需要一个计费网络连接。当设备处于计费连接时,可能需要大量数据的任务可能会被推迟,以避免不必要的费用。

    • NOT_ROAMING: 对于此类工作,需要一个非漫游连接。当设备处于漫游状态时执行可能会产生额外费用的任务与此相关。

    我们还设置了BatteryNotLow constrainttrue,以确保只有在电池电量充足时才执行工作请求。然后我们使用WorkManager.getInstance(applicationContext)获取 WorkManager 的实例,然后调用enqueueUniqueWork方法来入队我们的工作请求。我们将工作请求的名称、ExistingWorkPolicy和工作请求传递给enqueueUniqueWork方法。ExistingWorkPolicy用于指定如果已存在具有相同名称的工作请求时应该发生什么。我们使用了ExistingWorkPolicy.KEEP以确保如果已存在具有相同名称的工作请求,则不会替换工作请求。以下是一些其他可用的策略:

    • REPLACE:取消具有相同唯一名称的现有工作,并将新工作入队。这适用于我们只想执行最新版本的工作,丢弃先前实例的情况。

    • APPEND:即使存在具有相同唯一名称的现有工作,也会将新工作入队。新工作和现有工作将独立执行。这适用于我们希望同一工作的多个实例共存的情况。

    我们现在已准备好开始我们的工作请求。我们将在MainActivityonCreate方法中启动工作请求。

  4. MainActivity.kt文件中,在onCreate方法中添加以下代码:

    startPetsSync()
    

    由于我们正在使用 Koin,我们需要在我们的应用程序清单中禁用默认的 WorkManager 初始化。

  5. 让我们转到AndroidManifest.xml文件,并在应用程序标签内添加以下代码:

    <provider
        android:name="androidx.startup.InitializationProvider"
        android:authorities="${applicationId}.androidx-startup"
        android:exported="false"
        tools:node="merge">
        <!-- Removing WorkManager Default Initializer-->
        <meta-data
            android:name="androidx.work.WorkManagerInitializer"
            android:value="androidx.startup"
            tools:node="remove" />
    </provider>
    

    添加前面的代码可以防止 WorkManager 自动初始化。如果不这样做,一旦设置了 Koin 初始化,应用程序就会崩溃。崩溃是由 Koin 的依赖注入和 WorkManager 的默认初始化之间的冲突引起的。最后,我们还从 WorkManager 2.6 中移除了 App Startup(developer.android.com/topic/libraries/app-startup),这是 WorkManager 内部使用的。

  6. 要设置自定义 WorkManager 实例,转到ChapterEightApplication.kt文件,并在startKoin块内添加以下代码:

    workManagerFactory()
    
  7. 构建并运行应用程序,没有任何变化。然而,我们已经安排了一个后台任务,从远程数据源获取猫并将其保存到我们的数据库中。

图 8.7 – 可爱的猫

图 8.7 – 可爱的猫

我们已经创建了我们的PetsSyncWorker类,并学习了如何在后台执行工作。在下一节中,我们将为我们的PetsSyncWorker类编写测试。

测试你的工作者

测试代码非常重要。它确保我们的代码按预期工作,并帮助我们及早发现错误。在本节中,我们将为我们的工作者编写测试。要测试我们的工作者,我们首先需要按照以下步骤设置 WorkManager 测试依赖项:

  1. 让我们转到libs.versions.toml文件,并在libraries部分添加以下依赖项:

    work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
    

    同步您的项目。这将添加work-testing工件,帮助我们测试工作者到我们的项目中。

  2. 接下来,我们需要将依赖项添加到我们的应用级别的build.gradle.kts文件中:

    androidTestImplementation(libs.work.testing)
    

    我们使用了androidTestImplementation,因为我们将在androidTest文件夹中编写测试。执行 Gradle 同步操作以将依赖项添加到我们的项目中。我们现在可以开始编写测试了。

由于我们的PetsSyncWorker类需要一些依赖项,我们将创建一个测试规则,提供我们需要的 Koin 依赖项。让我们转到androidTest文件夹,创建一个名为KoinTestRule.kt的新文件,并添加以下代码:

class KoinTestRule: TestRule {
    override fun apply(base: Statement?, description: Description?): Statement {
        return object : Statement() {
            override fun evaluate() {
                stopKoin()
                startKoin {
                    androidLogger(Level.ERROR)
                    androidContext(ApplicationProvider.getApplicationContext())
                    modules(appModules)
                }
            }
        }
    }
}

KoinTestRule实现了TestRule接口。我们使用了这个规则来提供我们在测试中需要的 Koin 依赖项。我们使用了startKoin方法来提供我们需要的 Koin 依赖项。我们使用了androidContext(ApplicationProvider.getApplicationContext())方法来获取应用程序上下文。我们还使用了modules(appModules)方法来提供我们需要的 Koin 模块。现在,我们准备好开始编写测试了。让我们创建一个名为PetsSyncWorkerTest.kt的新文件,并添加以下代码:

@RunWith(AndroidJUnit4::class)
class PetsSyncWorkerTest {
    @get:Rule
    val koinTestRule = KoinTestRule()
    @Before
    fun setUp() {
        val config = Configuration.Builder()
            .setMinimumLoggingLevel(Log.DEBUG)
            .setExecutor(SynchronousExecutor())
            .build()
        // Initialize WorkManager for instrumentation tests.
        WorkManagerTestInitHelper.initializeTestWorkManager(
            ApplicationProvider.getApplicationContext(),
            config
        )
    }
}

我们创建了一个名为PetsSyncWorkerTest的测试类。我们使用@RunWith(AndroidJUnit4::class)注解了该类。我们还创建了一个KoinTestRule实例,并使用@get:Rule注解将其提供给我们的测试类。我们还创建了一个setup函数,并使用@Before注解。这个函数将在每个测试之前执行。我们使用WorkManagerTestInitHelper类来初始化 WorkManager 进行仪器测试。我们使用SynchronousExecutor类来确保工作同步执行。这是为了确保我们的测试是确定性的。我们现在准备好开始编写测试了。

按照以下步骤创建我们的测试:

  1. 我们将首先创建一个测试函数来测试我们工作者的功能。在setup函数下方将以下代码添加到PetsSyncWorkerTest.kt文件中:

    @Test
    fun testPetsSyncWorker() {
    }
    

    这是一个用@Test注解标记的空函数。

  2. 按照以下步骤创建工作请求:

    val syncPetsWorkRequest = OneTimeWorkRequestBuilder<PetsSyncWorker>()
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .setRequiresBatteryNotLow(true)
                .build()
        )
        .build()
    

    在前面的代码中,我们使用PetsSyncWorker类创建了一个一次性请求。我们还设置了工作请求的约束条件。我们将网络类型设置为NetworkType.CONNECTED,以确保只有在设备连接到互联网时才执行工作请求。我们还设置了BatteryNotLow约束为true,以确保只有在电池电量充足时才执行工作请求。

  3. 接下来,设置测试驱动程序:

    val workManager = WorkManager.getInstance(ApplicationProvider.getApplicationContext())
    val testDriver =
        WorkManagerTestInitHelper.getTestDriver(ApplicationProvider.getApplicationContext())!!
    

    在这里,我们已经设置了测试驱动程序,帮助我们模拟测试所需的条件。例如,它模拟了约束条件得到满足的情况。

  4. 将工作请求入队:

    workManager.enqueueUniqueWork(
        "PetsSyncWorker",
        ExistingWorkPolicy.KEEP,
        syncPetsWorkRequest).result.get()
    

    我们使用了 enqueueUniqueWork 方法来入队我们的工作请求。我们将工作请求的名称、ExistingWorkPolicy 和工作请求传递给了 enqueueUniqueWork 方法。我们使用了 ExistingWorkPolicy.KEEP 来确保如果已经存在具有相同名称的工作请求,则不会替换工作请求。我们还使用了 result.get() 方法来获取工作请求的结果。

  5. 使用 WorkInfo 类获取关于我们的工作请求的信息:

    val workInfo = workManager.getWorkInfoById(syncPetsWorkRequest.id).get()
    

    我们正在获取工作请求的 WorkInfo。我们使用了 getWorkInfoById 方法来获取工作请求的 WorkInfo。我们使用 result.get() 方法来获取工作请求的结果。

  6. 接下来,让我们获取工作进程的状态并断言我们的工作是入队的:

    assertEquals(WorkInfo.State.ENQUEUED, workInfo.state)
    

    我们使用了 assertEquals 方法来断言我们的工作是入队的。我们使用了 WorkInfo.State.ENQUEUED 来检查我们的工作是否入队。

  7. 接下来,让我们使用我们之前创建的 testDriver 实例来模拟我们的约束条件得到满足:

    testDriver.setAllConstraintsMet(syncPetsWorkRequest.id)
    

    我们使用 testDriver 来模拟约束条件得到满足。我们使用了 setAllConstraintsMet 函数来模拟约束条件得到满足。我们将工作请求的 id 传递给了 setAllConstraintsMet 方法。工作请求 id 具有通用唯一标识符(UUID)的实例类型。

  8. 最后,让我们获取我们的工作进程的输出和状态:

    val postRequirementWorkInfo =
        workManager.getWorkInfoById(syncPetsWorkRequest.id).get()
    assertEquals(WorkInfo.State.RUNNING, postRequirementWorkInfo.state)
    

    这是我们的测试的最终步骤。我们使用了 getWorkInfoById 方法来获取工作请求的 WorkInfo。我们使用了 result.get() 方法来获取工作请求的结果。我们使用了 WorkInfo.State.RUNNING 来检查我们的工作是否正在运行。我们的最终测试函数应该看起来像以下这样:

图 8.8 – PetsSyncWorker 测试

图 8.8 – PetsSyncWorker 测试

  1. 点击我们测试左侧的绿色运行图标以运行测试。测试运行并且全部为绿色!我们的测试通过了,如下截图所示:

图 8.9 – 测试结果

图 8.9 – 测试结果

让所有这些测试协同工作是一项了不起的工作。

摘要

在本章中,我们学习了如何将数据保存到本地数据库 Room,它是 Jetpack 库的一部分。我们还保存了项目并从 Room 数据库中读取。在这个过程中,我们还学习了如何在 Room 数据库中更新项目以及如何处理数据库中的自动迁移。此外,我们还学习了如何使用 WorkManager 进行长时间运行的操作,它的最佳实践以及如何为我们的工作进程编写测试。

在下一章中,我们将学习关于运行时权限以及如何在我们的应用中请求它们。

第九章:运行时权限

随着我们构建 Android 应用,有一些功能需要授权才能正常工作。由于隐私和数据安全政策,我们作为开发者不能自动为我们开发的应用授权。我们需要通知用户应用需要的权限以及为什么需要它们。

在本章中,我们将了解运行时权限以及如何在我们的应用中请求它们。

在本章中,我们将介绍以下主要内容:

  • 理解运行时权限

  • 在运行时请求权限

技术要求

要遵循本章中的说明,您需要已下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以使用前一章的代码来遵循本章的说明。您可以在github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapternine找到本章的代码。

理解运行时权限

requestPermissions()checkSelfPermission()。用户只需在应用生命周期内授权一次。

一些需要授权才能正常工作的功能包括相机、位置、麦克风和存储。在使用它们之前,请确保用户有权限使用它们。如果用户未授权,您必须向他们请求。如果用户拒绝授权,您必须显示一个对话框解释为什么需要它,并要求用户从设置中授权。如果用户已授权,您可以使用该功能。未能进行这些检查通常会导致应用崩溃或功能无法正常工作。如果您的应用针对 Android 6.0 及以上版本,您必须在运行时请求这些权限,并且用户必须授权才能使应用工作。

请求权限的流程如下所示:

图 9.1 – 运行时权限流程

图 9.1 – 运行时权限流程

如前图所示,这是流程:

  1. 第一步是在清单文件中声明权限。这是通过将权限添加到清单文件来完成的。

  2. 在将权限添加到清单文件后,我们必须设计 UX,以便需要授权的功能。

  3. 下一步是等待用户使用需要授权的特定功能。在此阶段,我们检查用户是否已授权。如果用户已授权,我们继续使用该功能。

  4. 如果用户尚未授权,我们首先检查是否需要展示理由来解释为什么我们需要权限。如果需要展示理由,我们将通过解释来展示它,然后请求用户授权。如果我们不需要展示理由,我们只需直接请求用户授权。

  5. 一旦请求了权限,我们就等待用户授予或拒绝权限。如果用户授予了权限,我们就继续使用该功能。如果用户拒绝了权限,我们允许应用继续运行,但用户无法使用需要权限才能运行的功能。

考虑到这个流程,让我们看看如何在代码中实现它。我们将请求访问位置的权限。

运行时请求权限

我们将遵循图 9.1 中涵盖的步骤来为我们的应用请求运行时权限:

  1. 让我们从将权限添加到清单文件开始。我们将请求访问用户位置的权限。为此,我们在清单文件中添加以下权限:

    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    

    这指定了我们的应用将使用ACCESS_COARSE_LOCATION权限。在清单中声明权限对于增强安全性、用户意识和整体应用兼容性至关重要。通过明确指定应用需要访问的动作或资源,可以在安装过程中通知用户,使他们能够做出明智的决定,关于是否授予或拒绝访问。这种声明确保了不同 Android 版本和设备之间的兼容性,促进了应用间的通信,并支持意图过滤以控制组件访问。权限在运行时请求危险权限中也发挥作用,有助于保持平台兼容性。此外,Play 商店的审核过程将权限作为提交流程的一部分,有助于遵守政策和指南。本质上,基于清单的权限声明对于创建安全、透明和用户控制的环境至关重要。

    下一步是创建需要权限的功能的用户界面。我们将创建一个对话框来请求用户的权限。它还将包含逻辑,如果之前拒绝了权限,则向用户展示理由。

  2. 让我们在view包中创建一个名为PermissionDialog.kt的新文件,并将实用函数添加到该文件中:

    fun checkIfPermissionGranted(context: Context, permission: String): Boolean {
        return (ContextCompat.checkSelfPermission(context, permission)
                == PackageManager.PERMISSION_GRANTED)
    }
    fun shouldShowPermissionRationale(context: Context, permission: String): Boolean {
        val activity = context as Activity?
        if (activity == null)
            Log.d("Permissions", "Activity is null")
        return ActivityCompat.shouldShowRequestPermissionRationale(
            activity!!,
            permission
        )
    }
    

    第一个函数使用ContextCompat.checkSelfPermission()函数检查权限是否已被授予。第二个函数检查是否需要向用户展示理由。这是通过使用ActivityCompat.shouldShowRequestPermissionRationale()函数来完成的。如果应用之前请求过此权限并且用户拒绝了请求,该函数返回true。如果用户之前拒绝了权限请求并选择了false

    接下来,让我们创建一个密封类,用于表示权限请求的状态。

  3. data包中创建一个名为PermissionAction.kt的新文件,并将以下代码添加到该文件中:

    sealed class PermissionAction {
        data object PermissionGranted : PermissionAction()
        data object PermissionDenied : PermissionAction()
    }
    

    该类有两个状态,PermissionGrantedPermissionDenied。用户可以授予或拒绝权限。

  4. 接下来,让我们创建一个用于请求用户权限的对话框。返回到 PermissionDialog.kt 文件,并向文件中添加以下代码:

    @Composable
    fun PermissionDialog(
        context: Context,
        permission: String,
        permissionAction: (PermissionAction) -> Unit
    ) {
        val isPermissionGranted = checkIfPermissionGranted(context, permission)
        if (isPermissionGranted) {
            permissionAction(PermissionAction.PermissionGranted)
            return
        }
        val permissionsLauncher = rememberLauncherForActivityResult(
            ActivityResultContracts.RequestPermission()
        ) { isGranted: Boolean ->
            if (isGranted) {
                permissionAction(PermissionAction.PermissionGranted)
            } else {
                permissionAction(PermissionAction.PermissionDenied)
            }
        }
        val showPermissionRationale = shouldShowPermissionRationale(context, permission)
        var isDialogDismissed by remember { mutableStateOf(false) }
        var isPristine by remember { mutableStateOf(true) }
        if ((showPermissionRationale && !isDialogDismissed) || (!isDialogDismissed && !isPristine)) {
            isPristine = false
            AlertDialog(
                onDismissRequest = {
                    isDialogDismissed = true
                    permissionAction(PermissionAction.PermissionDenied)
                },
                title = { Text(text = "Permission Required") },
                text = { Text(text = "This app requires the location permission to be granted.") },
                confirmButton = {
                    Button(
                        onClick = {
                            isDialogDismissed = true
                            permissionsLauncher.launch(permission)
                        }
                    ) {
                        Text(text = "Grant Access")
                    }
                },
                dismissButton = {
                    Button(
                        onClick = {
                            isDialogDismissed = true
                            permissionAction(PermissionAction.PermissionDenied)
                        }
                    ) {
                        Text(text = "Cancel")
                    }
                }
            )
        } else {
            if (!isDialogDismissed) {
                SideEffect {
                    permissionsLauncher.launch(permission)
                }
            }
        }
    }
    

    让我们分解前面的代码:

    • 我们创建了一个可组合的 PermissionDialog,它接受三个参数,contextpermission 字符串和一个 permissionAction 回调,该回调将用户选择的选项传递给调用位置。

    • 在可组合函数内部,我们首先检查权限是否已被授予。如果权限已被授予,我们调用 permissionAction 回调并传递 PermissionGranted 状态,然后返回。

    • 接下来,我们还创建了 permissionsLauncher,用于请求用户权限。我们使用 rememberLauncherForActivityResult() 函数创建一个用于合同的启动器。然后我们使用启动器请求用户权限。如果用户授予权限,我们调用 permissionAction 回调并传递 PermissionGranted 状态。如果用户拒绝权限,我们调用 permissionAction 回调并传递 PermissionDenied 状态。

    • 如果权限尚未被授予,我们检查是否需要向用户显示理由。如果需要,我们将使用解释显示理由,然后请求用户权限。如果我们不需要显示理由,我们必须请求用户权限。在我们的情况下,理由是 AlertDialog,包含两个操作项和一个解释为什么我们需要权限的消息。第一个操作项用于请求用户权限。第二个操作项用于取消权限请求。如果我们点击,permissionAction 回调将使用 PermissionDenied 状态被调用,并且对话框将被关闭。我们还有两个可变状态,isDialogDismissedisPristine。第一个用于检查对话框是否已被关闭。第二个让我们知道对话框是否之前显示过。我们使用这些状态结合来确定是否显示对话框。

    • 最后,如果我们不需要显示理由,我们只需请求用户权限。我们使用 SideEffect 请求用户权限,因为我们希望在对话框显示时立即请求权限。

    由于我们当前的应用程序中没有使用位置的实际功能,我们将使用 PetsScreen 可组合函数来模拟权限流程。

  5. 让我们转到 PetsScreen.kt 文件,并将其修改为以下内容:

    @Composable
    fun PetsScreen(
        onPetClicked: (Cat) -> Unit,
        contentType: ContentType,
    ) {
        val petsViewModel: PetsViewModel = koinViewModel()
        val petsUIState by petsViewModel.petsUIState.collectAsStateWithLifecycle()
        val context = LocalContext.current
        var showContent by rememberSaveable { mutableStateOf(false) }
        PermissionDialog(
            context = context,
            permission = Manifest.permission.ACCESS_COARSE_LOCATION
        ) { permissionAction ->
            when (permissionAction) {
                is PermissionAction.PermissionDenied -> {
                    showContent = false
                }
                is PermissionAction.PermissionGranted -> {
                    showContent = true
                    Toast.makeText(
                        context,
                        "Location permission granted!",
                        Toast.LENGTH_SHORT
                    ).show()
                }
            }
        }
        if (showContent) {
            PetsScreenContent(
                modifier = Modifier
                    .fillMaxSize(),
                onPetClicked = onPetClicked,
                contentType = contentType,
                petsUIState = petsUIState,
                onFavoriteClicked = {
                    petsViewModel.updatePet(it)
                }
            )
        }
    }
    

    我们只对这个文件做了一些修改:

    • 首先,我们添加了一个 showContent 可变状态,用于检查我们是否应该显示屏幕的内容。我们还设置了状态的初始值为 false。如果用户授予权限,我们将使用此状态来显示屏幕内容。我们还有 context 变量,用于获取屏幕的上下文。

    • 我们还向PetsScreen可组合组件添加了PermissionDialog可组合组件。我们传递了上下文和权限——在这种情况下,是ACCESS_COARSE_LOCATION权限——到可组合组件中。我们还传递了一个回调到可组合组件,用于获取权限请求的状态。如果用户授予了权限,我们将showContent状态设置为true并显示一个带有showContent状态的吐司,如果状态为false,则不显示屏幕内容。

    • 最后,我们添加了一个检查来查看showContent状态是否为true。如果状态为true,我们显示屏幕内容。如果状态为false,我们不显示屏幕内容。

  6. 构建并运行应用。一开始,我们将看到权限对话框,如下面的截图所示:

图 9.2 – 权限对话框

图 9.2 – 权限对话框

  1. 点击不允许选项,这将显示一个空白的白色屏幕,因为我们没有在用户未授予应用权限时显示任何内容。

图 9.3 – 无权限屏幕

图 9.3 – 无权限屏幕

下次我们运行应用时,将看到显示应用为何需要权限的理性对话框。

图 9.4 – 权限理性说明

图 9.4 – 权限理性说明

在这个理性对话框中,我们可以取消请求或授予访问权限。点击授予访问权限选项将显示如图9.2所示的权限对话框,通过点击使用应用时选项,我们授予应用位置权限,现在,我们应该能够再次看到可爱猫咪的列表。再次运行应用不会显示对话框,因为我们已经授予了应用位置权限。

图 9.5 – 可爱的猫咪

图 9.5 – 可爱的猫咪

摘要

在本章中,我们探讨了运行时权限是什么以及为什么我们应该在我们的应用中声明和请求权限。一步一步地,我们学习了如何在我们的应用中请求运行时权限以及如何显示权限理性对话框,解释为什么在用户拒绝应用访问权限的情况下,我们需要访问运行时权限。

在下一章中,我们将学习调试技巧和窍门,如何使用 LeakCanary 检测泄漏,如何使用 Chucker 检查我们应用发出的 HTTPS 请求/响应,以及如何检查 Room 数据库。

第三部分:代码分析和测试

在这部分,您将通过一系列宝贵的技巧和窍门掌握调试技能。揭示其复杂性,您将发现检测应用中内存泄漏的技术,并熟练地检查由您的应用程序触发的 HTTP 请求。我们的探索扩展到检查您的本地数据库,深入了解其内部工作原理。深入 Kotlin 最佳实践,您将深入分析您的应用程序代码,解决代码异味以提升代码质量。本部分还包括对测试方法的全面探索,使您能够无缝地将测试集成到 MVVM 架构的各个层次。

本节包含以下章节:

  • 第十章, 调试您的应用

  • 第十一章, 提升代码质量

  • 第十二章, 测试您的应用

第十章:调试您的应用

调试是开发应用的重要方面。它帮助我们识别和修复代码中的错误。对于开发者来说,这是一项非常重要的技能。它还有助于我们避免未来的错误。许多工具可以帮助我们调试代码。在本章中,我们将探讨一些可以帮助我们调试代码的工具。

在本章中,我们将学习调试技巧和窍门,如何使用 LeakCanary 检测泄漏,如何使用 Chucker 检查我们应用发出的网络请求/响应,以及如何使用 App Inspection 检查我们的 Room 数据库、网络请求和后台任务。

在本章中,我们将涵盖以下主要内容:

  • 通用调试技巧和窍门

  • 使用 LeakCanary 检测泄漏

  • 使用 Chucker 检查网络请求

  • 使用 App Inspection

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterten找到本章的代码。

通用调试技巧和窍门

Android Studio 提供了各种功能,帮助我们调试代码。以下列出了其中的一些功能:

  • Logcat

  • 堆栈跟踪

  • 断点

让我们逐一仔细看看这些内容。

Logcat

Android Studio 中的 Logcat 实时显示来自我们应用的日志消息。每个日志消息都附加了一个优先级级别。我们使用 Log 类在我们的应用中添加日志消息。这个类提供了我们可以用来记录消息的不同优先级。不同的优先级如下:

  • V: 详述(最低优先级)

  • D: 调试

  • I: 信息

  • W: 警告

  • E: 错误

  • F: 致命

  • S: 静默(最高优先级)

我们使用前面的字母来指定日志级别。例如,如果我们想以调试级别记录一条消息,我们将使用以下代码:

Log.d("TAG", "Message")

第一个参数是标签。标签用于识别日志消息的来源。第二个参数是我们想要记录的消息。每次我们运行我们的应用时,Logcat 都会在 Android Studio 窗口的底部显示,如下面的图所示:

图 10.1 – Logcat

图 10.1 – Logcat

从前面的图中,我们可以看到顶部显示的是我们在其上运行应用的设备。在这种情况下,我们正在一个Pixel 6 Pro API 33模拟器上运行我们的应用;这取决于你安装的模拟器,你的可能不同。在设备旁边,我们可以看到我们正在运行的应用的包名。这里有一个搜索栏,允许我们通过它们的标签搜索特定的日志。在搜索栏下方是实际的日志。我们可以看到每个日志都有一个标签,优先级级别,应用的包名,日志创建的时间,以及消息。我们还可以看到日志是彩色的。要查看我们 Logcat 的所有颜色设置,我们转到设置 | 编辑器 | 颜色方案 | Android Logcat,如图下所示:

图 10.2 – Logcat 颜色设置

图 10.2 – Logcat 颜色设置

这样,我们可以为我们的 Logcat 设置颜色方案。在先前的图中,颜色方案已被设置为经典亮色。有一个列表显示了每个日志级别的颜色,你可以为每个日志级别更改颜色。我们还可以更改 Logcat 的字体样式和大小。

现在,让我们看看如何创建我们的第一个 Logcat 消息。让我们转到MainActivity.kt文件,并在onCreate()方法中添加以下日志消息:

Log.d("First Log", "This is our first log message")

运行应用并在搜索栏中添加First Log作为搜索查询:

图 10.3 – Logcat 搜索

图 10.3 – Logcat 搜索

这只显示带有标签First Log的日志消息。当我们有很多日志,并且想要搜索特定日志时,这特别有用。

堆栈跟踪

堆栈跟踪是从应用开始到异常抛出点的调用方法列表。它在帮助我们识别异常原因方面非常有用。堆栈跟踪通常在 Logcat 中显示。

要能够看到我们的第一个堆栈跟踪,让我们转到MainActivity.kt文件,并在onCreate()方法中添加以下代码:

throw RuntimeException("This is a crash")

上述代码会在运行应用时导致应用崩溃。应用在执行上述代码后立即崩溃,通过检查我们的 Logcat,我们应该能够看到堆栈跟踪:

图 10.4 – 崩溃堆栈跟踪

图 10.4 – 崩溃堆栈跟踪

如前图所示,堆栈跟踪显示了异常或崩溃的原因。此外,它还显示了异常抛出的类和方法。它还显示了异常抛出的行号。这特别有助于我们识别异常原因。我们可以直接点击行号,它将带我们到抛出异常的代码行。

堆栈跟踪是帮助我们调试代码和快速检测崩溃及其原因的关键工具。在继续之前,请记住删除引发异常的代码,以便我们可以继续本章的其余部分。

Android Studio 允许我们从不同的来源复制和粘贴堆栈跟踪,并在我们的 Logcat 中查看它们。复制以下堆栈跟踪:

FATAL EXCEPTION: main
Process: com.packt.chapterten, PID: 7168
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.packt.chapterten/com.packt.chapterten.MainActivity}: java.lang.RuntimeException: This is a crash
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3645)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3782)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:101)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2307)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.app.ActivityThread.main(ActivityThread.java:7872)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
Caused by: java.lang.RuntimeException: This is a crash
at com.packt.chapterten.MainActivity.onCreate(MainActivity.kt:48)
at android.app.Activity.performCreate(Activity.java:8305)
at android.app.Activity.performCreate(Activity.java:8284)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1417)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3626)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3782)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:101)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2307)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.app.ActivityThread.main(ActivityThread.java:7872)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)

完成复制堆栈跟踪后,转到 Android Studio 顶部的 代码 选项卡,选择 分析堆栈跟踪或线程转储,粘贴堆栈跟踪,您将看到以下带有堆栈跟踪的对话框:

图 10.5 – 分析堆栈跟踪

图 10.5 – 分析堆栈跟踪

这显示了刚刚粘贴的堆栈跟踪的预览。点击 确定 将我们带到 Logcat,并显示与 图 10.4 中的堆栈跟踪相同的堆栈跟踪。

断点

我们使用 断点 来调试我们的应用代码。断点是我们希望调试器暂停代码执行的位置。当我们试图找到只在特定条件下出现的错误时,这非常有用。Android Studio 允许我们在代码中添加断点。我们可以通过点击以下图中所示行号的左侧来添加断点:

图 10.6 – 断点

图 10.6 – 断点

图 10.6 所示,当我们添加断点时,会显示一个红色圆圈。为了能够看到断点的工作方式,我们需要以 调试模式 运行应用。我们可以通过点击 调试 按钮来完成此操作:

图 10.7 – 调试应用

图 10.7 – 调试应用

由于断点设置在 onCreate() 方法上,调试器将在断点处暂停我们的代码执行。Android Studio 将突出显示断点所在的行:

图 10.8 – 行断点

图 10.8 – 行断点

它还在底部显示一个 调试 窗口:

图 10.9 – 调试窗口

图 10.9 – 调试窗口

调试 窗口显示断点作用域内的变量。我们还可以看到调用堆栈,它显示了在断点之前被调用的方法。我们还可以看到应用中正在运行的线程。我们还可以看到添加到代码中的断点。

调试 窗口顶部有一些按钮,在调试代码时非常有帮助:

图 10.10 – 调试窗口按钮

图 10.10 – 调试窗口按钮

按钮如 图 10.10 中所示突出显示。从左到右,按钮依次为:

  • 显示执行点:此按钮显示调试器暂停的代码行

  • 单步执行:此按钮允许我们执行当前代码行并移动到下一行代码

  • 进入:此按钮允许我们进入方法调用

  • 跳出:此按钮允许我们从方法调用中退出

  • 运行到光标处:此按钮允许我们运行代码,直到光标到达光标所在的代码行

调试 窗口的左侧,我们还可以看到 播放停止 按钮。播放 按钮允许我们恢复代码的执行并继续调试会话,直到下一个断点或程序完成执行。要了解更多关于断点的信息,请访问官方文档 developer.android.com/studio/debug

在本节中,我们学习了 Android Studio 中可用的不同调试选项。我们学习了 Logcat、堆栈跟踪和断点。我们还学习了如何使用这些工具来调试我们的代码。在下一节中,我们将学习另一个调试工具 LeakCanary 以及如何使用它来检测我们应用中的内存泄漏。

使用 LeakCanary 检测内存泄漏

LeakCanary 是由 Square 开发的一个开源库,帮助我们检测应用中的内存泄漏。该库了解 Android 框架的内部结构,使其能够缩小内存泄漏的原因。它有助于减少我们应用中的 应用无响应ANR) 错误和 内存不足崩溃。以下是一些最常见内存泄漏原因:

  • Activity 实例存储为对象中的 Context 字段,该对象因配置更改而存活,从而重新创建活动

  • 忘记在不再需要时注销广播接收器、监听器、回调或 RxJava 订阅

  • 在后台线程中存储对 Context 的引用

LeakCanary 非常容易设置,使用它不需要代码实现。我们只需在我们的 libs.version.toml 文件中添加 leakcanary-android 依赖项:

leakcanary-android = "com.squareup.leakcanary:leakcanary-android:2.12"

点击 build.gradle.kts 文件:

debugImplementation(libs.leakcanary.android)

我们已使用 debugImplementation 配置添加了依赖项,因此它仅添加到调试构建中。这是因为 LeakCanary 仅用于调试目的。我们现在可以运行我们的应用并查看 LeakCanary 的运行情况。如以下图所示,将在我们的设备或模拟器上安装一个单独的应用程序:

图 10.11 – 泄漏应用

图 10.11 – 泄漏应用

打开应用会显示以下屏幕:

图 10.12 – LeakCanary 屏幕截图

图 10.12 – LeakCanary 屏幕截图

屏幕显示了 泄漏堆转储关于 选项卡。泄漏 选项卡显示了在我们应用中检测到的内存泄漏。堆转储 选项卡显示了已捕获的堆转储。关于 选项卡显示了我们所使用的 LeakCanary 版本以及一般信息。目前,没有检测到内存泄漏。当检测到内存泄漏时,LeakCanary 将生成一个通知或日志输出,其中包含有关内存泄漏的详细信息。这些信息帮助我们识别内存泄漏的原因并修复它。

现在让我们在我们的应用中创建一个内存泄漏,并看看 LeakCanary 是如何工作的:

  1. com.packt.chapterten 包内创建一个新文件,并将其命名为 LeakCanaryTest.kt。将以下代码添加到文件中:

    class LeakCanaryTest
    class LeakTestUtils {
        companion object {
            val leakCanaryTest = LeakCanaryTest()
        }
    }
    

    在前面的代码中,我们创建了一个名为 LeakCanaryTest 的类和一个名为 LeakTestUtils 的类,其中包含一个伴生对象,该对象将 LeakCanaryTest 单例存储在一个静态字段中。

  2. 现在让我们转到 MainActivity.kt 文件,并在 onCreate() 代码中添加以下代码:

    AppWatcher.objectWatcher.expectWeaklyReachable(
        LeakTestUtils.leakCanaryTest,
        "Static reference to LeakCanaryTest"
    )
    

    在前面的代码中,我们正在告诉 LeakCanary,LeakCanaryTest 的单例实例将被垃圾回收。

  3. 让我们运行应用。我们可以看到 LeakCanary 检测到了内存泄漏,并显示了一个通知,如图所示:

图 10.13 – 内存泄漏通知

图 10.13 – 内存泄漏通知

  1. 点击通知,它将带我们到 LeakCanary 应用,在那里我们可以看到内存泄漏的详细信息:

图 10.14 – 内存泄漏详情

图 10.14 – 内存泄漏详情

如前图所示,LeakCanary 显示了内存泄漏的位置,并下划线了导致泄漏的对象。在这种情况下,是 leakCanaryTest 对象导致了内存泄漏。每次我们遇到内存泄漏时,我们都会收到这样的详细信息。

记得从 MainActivityonCreate() 方法中移除导致内存泄漏的代码,这样我们就可以继续本章的其余部分。

我们已经了解了 LeakCanary 以及如何使用它来检测应用中的内存泄漏。您可以在 square.github.io/leakcanary/ 上了解更多关于 LeakCanary 的信息。

在下一节中,我们将了解 Chucker,另一个调试工具,它帮助我们检查应用中的网络请求。

使用 Chucker 检查网络请求

这是从 Chucker GitHub (github.com/ChuckerTeam/chucker) 页面获取的:

Chucker 简化了检查我们 Android 应用发出的 HTTP(S) 请求/响应的过程。Chucker 作为 OkHttp Interceptor,将所有这些事件持久化在我们的应用中,并提供一个 UI 来检查和分享它们的内容。Chucker 显示一个通知,显示当前网络请求的摘要。

点击前面提到的 Chucker 通知将启动 Chucker UI。Chucker UI 显示了我们的应用发出的所有网络请求的列表。我们可以点击一个请求来查看请求的详细信息。

要使用 Chucker,请按照以下步骤操作:

  1. libs.versions.toml 文件中添加 chucker 依赖项:

    chucker = "com.github.chuckerteam.chucker:library:4.0.0"
    chucker-no-op = "com.github.chuckerteam.chucker:library-no-op:4.0.0"
    

    在前面的代码中,我们添加了两个依赖项:第一个是 Chucker 库,第二个是一个无操作库变体,用于将 Chucker 从发布构建中隔离出来,因为我们只想在调试构建中看到请求。

  2. 点击顶部的 Sync Now 按钮以同步项目。

  3. 接下来,我们需要在我们的应用程序的 build.gradle.kts 文件中添加依赖项:

    debugImplementation(libs.chucker)
    releaseImplementation(libs.chucker.no.op)
    
  4. 在顶部点击立即同步。这将仅在调试构建中添加 Chucker 依赖项。

  5. 接下来,我们需要创建一个新的 OkHttp 客户端并将 Chucker 拦截器添加到其中。让我们转到 Modules.kt 文件并添加以下模块到 appModules 模块块:

    single {
        val chuckerCollector = ChuckerCollector(
            context = androidContext(),
            showNotification = true,
            retentionPeriod = RetentionManager.Period.ONE_HOUR
        )
        val chuckerInterceptor = ChuckerInterceptor.Builder(androidContext())
            .collector(chuckerCollector)
            .maxContentLength(250000L)
            .redactHeaders(emptySet())
            .alwaysReadResponseBody(false)
            .build()
       OkHttpClient.Builder()
            .addInterceptor(chuckerInterceptor)
            .build()
    }
    

    下面的代码解释了前面的代码:

    • 我们创建了一个 ChuckerCollector 实例,并传递了 contextshowNotificationretentionPeriod 参数。context 是 Android 上下文,showNotification 是一个布尔值,用于确定在发出请求时是否显示通知,而 retentionPeriod 是数据保留的期限。在这种情况下,我们保留数据一小时。

    • 我们创建了一个 ChuckerInterceptor 实例,并传递了 contextcollectormaxContentLengthredactHeadersalwaysReadResponseBody 参数。context 是 Android 上下文,collector 是我们之前创建的 ChuckerCollector 实例,maxContentLength 是响应体的最大长度,redactHeaders 是一组用 ** 替换的头,而 alwaysReadResponseBody 是一个布尔值,用于确定是否始终读取响应体。

    • 我们创建了一个 OkHttpClient 实例并添加了 ChuckerInterceptor

    我们现在可以使用 OkHttpClient 实例来发出网络请求。

  6. 让我们修改我们的 Retrofit 实例以使用我们刚刚创建的 OkHttpClient 实例。仍然在 Modules.kt 文件中,按如下方式修改 Retrofit 实例:

    single {
        Retrofit.Builder()
            .addConverterFactory(
                Json.asConverterFactory(contentType = "application/json".toMediaType())
            )
            .client(get())
            .baseUrl("https://cataas.com/api/")
            .build()
    }
    

    我们添加了 client 参数,并使用 Koin 的 get() 调用传递了我们之前创建的 OkHttpClient 实例。

    这样,我们的 Chucker 设置现在就完成了。

  7. 构建并运行应用程序。我们可以看到 Chucker 已经检测到网络请求并显示了一个通知,如下图所示:

图 10.15 – Chucker 通知

图 10.15 – Chucker 通知

从前面的图中,我们可以看到 Chucker 已经检测到网络请求并显示了状态码、方法和请求的 URL。

  1. 我们可以点击通知,它将带我们到 Chucker UI:

图 10.16 – Chucker 请求列表

图 10.16 – Chucker 请求列表

这显示了我们的应用程序发出的网络请求列表。

  1. 点击一个请求以查看请求的详细信息:

图 10.17 – Chucker 概览标签页

图 10.17 – Chucker 概览标签页

如前图所示,详情屏幕有三个标签页。第一个标签页是概览标签页,如图 图 10**.17 所示。概览标签页显示了请求的概览。它显示了请求的详细信息,如 URL、方法、响应、持续时间、响应大小等。第二个标签页是请求标签页,它显示了请求头和正文:

图 10.18 – Chucker 请求标签页

图 10.18 – Chucker 请求选项卡

对于这个请求,由于它是一个 GET 请求,所以正文是空的。第三个选项卡是 响应 选项卡,它显示了响应头和正文:

图 10.19 – Chucker 响应选项卡

图 10.19 – Chucker 响应选项卡

从前面的图中,我们可以看到以 JSON 格式显示的响应头和正文。

详细信息屏幕的工具栏有 搜索分享保存 按钮。搜索 按钮允许我们在请求或响应中进行搜索。分享 按钮允许我们以 JSON 格式分享请求或响应的详细信息。保存 按钮允许我们将请求或响应的详细信息保存到文件中。

Chucker 在调试我们应用中的网络请求方面非常有帮助。当非技术团队测试可调试版本的应用时,我们总是可以告诉他们分享这些请求,尤其是在他们遇到网络请求问题时。

这就是 Chucker 的全部内容。每次我们的应用发起网络请求时,Chucker 都会显示一个通知,我们可以点击通知来查看请求的详细信息。

我们已经了解了 Chucker 以及如何使用它来检查我们应用中的网络请求。在下一节中,我们将学习如何使用应用检查来检查我们应用的 Room 数据库并探索应用检查提供的功能。

使用应用检查

应用检查允许我们调试数据库、检查网络流量以及调试后台任务。它是帮助我们调试应用的一个非常重要的工具。要使用应用检查,让我们在 Android Studio 中运行我们的应用,然后导航到 视图 | 工具窗口 | 应用检查

图 10.20 – 应用检查

图 10.20 – 应用检查

应用检查会自动连接到我们的应用。第一个选项卡是 数据库检查器。在左侧,我们可以看到我们应用创建的不同数据库。我们有之前创建的工作管理器、Chucker、LeakCanary 和 Cat 数据库。让我们点击 Cat 数据库,我们可以看到数据库中创建的表的列:

图 10.21 – 猫数据库

图 10.21 – 猫数据库

这显示了数据库中已保存的列和值。我们也可以在数据库中运行查询。查询选项在以下图中突出显示:

图 10.22 – 执行查询按钮

图 10.22 – 执行查询按钮

我们想运行一个显示具有特定 id 的猫的查询。我们可以通过运行以下查询来完成此操作:

SELECT * FROM CAT WHERE id == "rrsvsbRgL7zaJuR3"

您可以根据您拥有的数据使用不同的 id。查询结果将如下所示:

图 10.23 – 查询结果

图 10.23 – 查询结果

数据包含所有具有我们指定的 ID 的猫咪。需要注意的是,确保你在正确的数据库上执行查询,如图 图 10.23 所示。数据库检查器帮助我们调试并编写查询到我们的数据库,当我们在使用数据库的应用上工作时非常有用。

现在,让我们转到 网络检查器 标签页。此标签页显示了我们的应用发出的网络请求。我们可以看到请求方法、URL、状态码和请求持续时间:

图 10.24 – 网络检查器

图 10.24 – 网络检查器

从前面的图中,我们可以看到请求是用于加载可爱的猫咪图片以及请求的所有详细信息。与 Chucker 类似,它也有请求和响应标签页,提供了更多关于请求的信息。然而,与 Chucker 不同,网络检查器可以在代码中无需任何额外设置的情况下捕获应用发出的网络请求。网络检查器标签页在帮助我们调试应用中的网络请求时非常有用,尤其是在处理发出大量网络请求的应用时。

最后,让我们转到 背景任务检查器 标签页。此标签页显示了我们的应用发出的背景任务请求。我们的应用应显示以下内容:

图 10.25 – 背景任务检查器

图 10.25 – 背景任务检查器

背景任务检查器显示了我们的背景任务的详细信息。它提供了 WorkManager PetsSyncWorker 工作者已成功的信息。在右侧是工作者更详细的信息,如图 图 10.25 所示。这些详细信息显示了工作者的 UUID、约束、状态和结果,例如输出数据、重试次数以及开始时间。这些信息对于检查我们的背景任务是否按预期运行非常有帮助。

摘要

在本章中,我们学习了一些调试技巧和窍门,如何使用 LeakCanary 检测泄漏,如何使用 Chucker 检查我们应用发出的网络请求/响应,以及如何使用 App Inspection 调试我们的数据库、检查我们的网络请求以及检查背景任务。

在下一章中,我们将学习 Kotlin 风格和编写 Kotlin 代码的最佳实践。我们还将学习如何使用 Ktlint 和 Detekt 等插件来格式化、检查和早期检测代码异味。

第十一章:提高代码质量

随着我们开发 Android 应用程序,我们必须确保我们编写的代码符合既定规则并遵循最佳实践。这不仅有助于我们编写良好的代码,还使维护代码变得更加容易,并便于他人快速加入代码库。

在本章中,我们将学习 Kotlin 风格和编写 Kotlin 代码的最佳实践。我们还将学习如何使用 Ktlint 和 Detekt 等插件来格式化、检查和早期检测代码异味。

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

  • 掌握 Kotlin 风格和最佳实践

  • 使用 Ktlint 进行静态分析

  • 使用 Detekt 检测代码异味

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chaptereleven找到本章的代码。

掌握 Kotlin 风格和最佳实践

如我们在第一章中学到的,Kotlin 是一种非常简洁和静态的语言。因此,作为开发者,我们更容易不遵循一些推荐的最佳实践。这会导致许多代码异味和技术债务。代码异味是一种模式或实践,可能表明代码中存在更深层次的问题。它表明代码可能会引起潜在问题或阻碍可维护性。另一方面,技术债务指的是为了满足即时需求而选择快速且次优解决方案的成本或后果,而不是开发健壮且可维护的解决方案。我们总是需要回来对这些解决方案进行重构,使其更具可扩展性和可维护性。让我们从学习一些最佳实践和如何避免它们开始。

编码规范

Kotlin 拥有一系列广泛的编码规范,涵盖了从命名规范到格式化的各个方面。遵循这些规范使得阅读我们的代码更加容易,并使其易于维护。以下是一些例子:

  • 变量名应使用camelCase格式

  • 类名应使用PascalCase格式

  • 常量应使用大写格式

  • 函数名应使用camelCase格式

  • 包含多个单词的函数名应使用下划线分隔

  • 单行表达式的函数应内联

Kotlin 提供了许多编码规范。您可以在kotlinlang.org/docs/coding-conventions.html找到它们。

空安全

如在 第一章 中广泛讨论的那样,Kotlin 具有非常强大的 null 处理能力。当处理可空类型时,建议我们使用安全调用操作符 (?.) 和 Elvis 操作符 (?:) 来避免空指针异常。我们还应该使用 let 函数对可空类型执行操作。我们还应该使用安全类型转换操作符 (as?) 来避免类转换异常。以下是一个示例代码片段:

val name: String? = null
name?.let {
  println(it)
}

在前面的例子中,我们声明了一个 name 变量,其类型为 String 并将其赋值为 null。然后,我们使用 let 函数来检查变量是否为 null。如果变量不是 null,则 println(it) 函数将打印变量的值。

数据类

Kotlin 让我们很容易地创建 equalshashCodetoStringcopy 函数。当我们需要存储数据时,应该使用数据类:

data class Person(val name: String, val age: Int)

在前面的例子中,我们创建了一个具有两个属性的数据类 Personname(类型为 String)和 age(类型为 Int)。

扩展函数

Kotlin 提供了 扩展函数,允许我们向新类添加新函数,而无需从它们继承。这将帮助我们避免创建工具类。虽然扩展函数功能强大,但过度使用或不恰当的使用可能会导致难以阅读和维护的代码。以下是一个示例代码块:

fun String.removeFirstAndLastChar(): String {
  return when {
  this.length <= 1 -> ""
  else -> this.substring(1, this.length - 1)
  }
}
// Example usage
fun main(args: Array<String>) {
  val myString = "Hello Everyone"
  val result = myString.removeFirstAndLastChar ()
  println(result)
}

在前面的例子中,我们在 String 类上创建了一个扩展函数。该函数移除了字符串的第一个和最后一个字符,并返回剩余的字符串。此外,我们使用了 when 表达式来检查字符串的长度是否小于或等于 1。如果长度小于或等于 1,则函数返回一个空字符串。否则,它返回从索引 1 到字符串长度减 1 的子字符串。我们在 main 函数中使用扩展函数来移除 "Hello Everyone" 字符串的第一个和最后一个字符。结果随后按如下方式打印到控制台:

ello Everyon

类型推断

Kotlin 具有非常强大的类型系统。因此,我们可以省略指定变量的类型,让编译器推断类型。这将帮助我们避免大量的样板代码。以下是一个示例:

val name = "John Doe" // Compiler infers that name is of type String
val age = 25 // Compiler infers that age is of type Int

集合

Kotlin 拥有丰富的集合类型,包括 ListSetMapArraySequence。集合可以是 可变的不可变的。一个 可变的 集合在创建后可以被修改,而一个 不可变的 集合在创建后则不能被修改。以下是一个 可变的 列表的例子:

val mutableList = mutableListOf("Kotlin", "Java", "Python")
mutableList.add("Swift")

在前面的例子中,我们创建了一个字符串的 可变的 列表。我们使用 mutableListOf 函数创建列表。然后,我们使用 add 函数向列表中添加一个新的字符串。以下是一个 不可变的 列表的例子:

val immutableList = listOf("Kotlin", "Java", "Python")

在前面的示例中,我们创建了一个不可变的字符串列表。我们使用 listOf 函数创建列表。创建后,我们无法向列表中添加或删除项目。我们只能从列表中读取项目。Kotlin 集合还拥有广泛的功能,可以用于对集合执行操作。我们应该使用这些函数而不是编写自己的函数。这有助于我们使代码更加简洁和易读。让我们看看以下创建列表并过滤奇数和偶数的示例:

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val evenNumbers = numbers.filter { it % 2 == 0 }
val oddNumbers = numbers.filter { it % 2 != 0 }

在前面的示例中,我们创建了一个数字列表。然后我们使用 filter 函数来过滤偶数和奇数。filter 函数接受一个 lambda 作为参数。lambda 用于过滤数字。filter 函数返回一个包含过滤后数字的新列表。我们使用 it 关键字来引用列表中的当前项。然后我们使用 % 运算符来检查数字是否为偶数。如果数字是偶数,则 filter 函数返回 true,并将数字添加到 evenNumbers 变量中。对于奇数,filter 函数返回 false,并将数字添加到 oddNumbers 变量中。Kotlin 集合还拥有如 mapreducefoldflatMap 等函数。我们应该使用这些函数而不是编写自己的函数。这有助于我们使代码更加简洁和易读。

密封类和接口

Kotlin 提供了 密封类,用于表示受限的类和接口层次结构。这提高了代码的可读性,并确保我们知道一个类的所有可能的子类。以下是一个密封类的示例:

sealed class Shape {
  class Circle(val radius: Double) : Shape()
  class Square(val length: Double) : Shape()
  class Rectangle(val length: Double, val breadth: Double) : Shape()
}

在前面的示例中,我们创建了一个名为 Shape 的密封类。然后我们创建了三个类(CircleSquareRectangle),它们是 Shape 类的子类。Shape 类只能在声明它的同一文件中扩展。我们无法在另一个文件中扩展 Shape 类。

格式化

在 Kotlin 中,我们使用四个空格进行缩进。然而,请注意,这是一个约定,而不是严格规则。我们应该始终确保我们的代码按照团队或公司商定的约定进行适当的格式化。

函数式编程

我们应该利用 Kotlin 的 函数式编程 特性,如 lambda高阶函数内联函数。这将帮助我们使代码更加简洁和易读。以下是一个示例代码块:

val total = numbers.reduce { sum, element -> sum + element }

协程

Kotlin 提供了 协程,用于执行异步操作。它们非常轻量级且易于使用,帮助我们避免回调地狱。当我们需要执行异步操作时,应该使用协程。以下是一个 Kotlin 协程的示例:

fun makeNetworkCall() {
  viewModelScope.launch {
    val result = async {
    // perform network call
    }
    // update UI
  }
}

在前面的示例中,我们使用了viewModelScope来启动协程。这是为了确保当视图模型被销毁时,协程会被取消。我们使用了launch协程构建器来创建一个新的协程。在launch lambda 内部,我们执行了网络调用,这应该在后台发生。我们使用了async协程构建器,它允许我们等待网络调用的结果。async协程构建器返回一个Deferred对象。最后,我们使用网络调用的结果更新了 UI。

当语句

Kotlin 提供了when语句,用于替换switch语句。当我们需要执行条件操作时,我们应该使用when语句。如果我们有包含多个if else语句的块,我们应该考虑使用when语句。以下代码展示了示例:

val number = when {
  x % 2 == 0 -> "Even"
  x % 2 != 0 -> "Odd"
  else -> "Invalid"
}

类和函数

  • Kotlin 允许我们在单个文件中声明多个类。我们应该使用这个特性来避免创建大量的文件,特别是对于紧密相关的类。然而,我们应谨慎使用此功能,避免创建包含大量类的臃肿文件。

  • 我们必须确保每个类只定义一个主构造函数。而不是通过第二个构造函数来过载构造函数,我们总是可以考虑为构造函数参数使用默认值。

  • 我们可以使用伴随对象来创建静态成员。Kotlin 的伴随对象和 Java 的静态成员都便于创建类级别的成员,这些成员可以在不创建类实例的情况下访问。然而,Kotlin 的伴随对象通过允许访问非静态成员并提供更丰富的语法,提供了额外的灵活性。

  • 我们应该始终避免在函数中返回null。相反,我们应该使用可空类型。

  • 我们总是可以使用作用域函数来对对象执行操作。这将帮助我们避免创建大量的临时变量。

这只是一个最佳实践的小列表。随着代码库的增长,有时很难跟踪所有最佳实践。这就是静态代码分析工具派上用场的地方。它们帮助我们识别代码异味和技术债务。它们还帮助我们识别错误和安全漏洞。在下一节中,我们将了解一些我们可以使用的静态代码分析工具,以改进我们代码的质量。

使用 Ktlint 进行静态分析

根据官方文档,ktlint 是“一个内置格式化器的反自行车棚 Kotlin 代码检查器。”它帮助我们执行gradle任务,允许我们在项目中运行ktlint。我们还能够进行自动格式化。

要在我们的项目中设置 Ktlint,我们需要在项目的build.gradle.kts文件中的插件块中添加 Ktlint 插件,如下所示:

id("org.jlleitschuh.gradle.ktlint") version "11.6.1"

点击以下插件块下方的build.gradle.kts文件:

subprojects {
    apply(plugin = "org.jlleitschuh.gradle.ktlint")
    ktlint {
        verbose.set(true)
        android.set(true)
        filter {
            exclude("**/generated/**")
        }
    }
}

这将插件应用于所有项目模块。点击ktlint块。在我们的例子中,我们将verboseandroid属性设置为true。我们还将generated文件夹排除在分析之外。

这样,我们就准备好使用 Ktlint 了。不过,首先,让我们禁用一些格式化选项。为此,我们需要在我们的项目根目录中创建一个.editorconfig文件。在文件中,我们添加以下代码:

root = true
[*]
charset = utf-8
insert_final_newline = false
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_size = 4
ij_kotlin_packages_to_use_import_on_demand = unset
ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999

这将禁用一些格式化选项,其中关键的一个是在每个文件的末尾插入insert_final_newline。这对于现有项目特别有用,因为它可以防止我们不得不重新格式化整个项目。如果您需要自定义 Ktlint 行为,这是您用来启用或禁用一些选项的文件。

现在,让我们运行ktlintCheck任务。为此,让我们在我们的 IDE 中打开终端标签页并运行以下命令:

./gradlew ktlintCheck

任务完成后,我们将看到以下输出:

图 11.1 – Ktlint 检查失败

图 11.1 – Ktlint 检查失败

图 11.2 – Ktlint 检查失败继续

图 11.2 – Ktlint 检查失败继续

从前面的图中,我们可以看到任务以失败完成,这意味着我们的项目格式不正确。输出还显示了具体的格式错误和文件。我们可以选择自己修复这些问题,但首先,我们应始终检查 Ktlint 格式化器是否可以为我们修复这些问题。为此,我们可以运行以下命令:

./gradlew ktlintFormat

这将运行ktlintFormat任务。任务完成后,我们将看到以下输出:

图 11.3 – ktlintFormat 成功

图 11.3 – ktlintFormat 成功

如前图所示,任务成功完成。这意味着 Ktlint 格式化器能够为我们修复这些问题。如果 Ktlint 格式化器无法修复这些问题,它通常会突出显示插件无法修复的问题,并显示文件和行号。然后我们可以手动修复这些问题。目前,我们没有这样的问题。现在我们可以再次运行ktlintCheck任务以确认问题已修复。任务完成后,我们将看到以下输出:

图 11.4 – ktlintFormat 成功

图 11.4 – ktlintFormat 成功

如前图所示,构建成功完成。这意味着我们的项目格式正确。通过在 macOS 上按Command + K和在 Windows 上按Ctrl + K使用我们 IDE 中的 Git 工具,我们可以看到有变化的文件以及 Ktlint 格式化器所做的更改。从我们的项目中,这是提交模态的样子:

图 11.5 – Git 提交模态

图 11.5 – Git 提交模态

图 11**.5 所示,ktlintFormat 命令对我们的文件进行了一系列更改。我们还可以检查每个文件的更改。格式化器是一个很好的工具,可以帮助我们快速根据 Kotlin 风格和约定格式化代码。在提交代码之前,完成更改后运行 ktlintFormatktlintCheck 命令是必须的。这将帮助您避免提交格式不正确的代码。

我们已经完成了一个基本的设置,这对于大多数项目来说是足够的。有关插件、可用规则以及如何自定义的更多信息,您可以在此处了解:github.com/jlleitschuh/ktlint-gradle

我们已经看到了如何使用 Ktlint 插件来格式化和执行代码的静态分析。在下一节中,我们将学习如何使用 detekt 插件来检查代码异味和技术债务。

使用 detekt 检测代码异味

detekt 是 Kotlin 的另一个静态代码分析工具。它帮助我们尽早识别问题,并在整个开发过程中保持技术债务低。它强制执行一系列规则,帮助我们避免代码异味和技术债务。它还提供了创建我们自己的自定义规则集的灵活性。Detekt 提供以下功能:

  • 它识别 Kotlin 项目的代码异味

  • 它易于配置和自定义以满足我们的需求

  • 如果我们认为警告不适用,我们总是可以抑制它们

  • 我们可以指定我们想要强制执行的代码异味阈值

我们将在项目中使用这些功能。但在那之前,让我们了解规则集。detekt 有几个规则集,用于检查您的代码是否符合 Kotlin 风格指南。可用的规则集如下:

  • 注释:此规则集提供解决代码注释和文档中问题的规则。它检查头文件、私有方法的注释以及未记录的类、属性或方法。

  • 复杂性:此规则集包含报告复杂代码的规则。它检查复杂条件、方法、表达式和类,以及长方法和长参数列表。

  • 协程:此规则集分析代码以查找潜在的协程问题。

  • catch 块、空类块以及空函数和条件函数块。

  • 异常处理:此规则集报告与代码抛出和处理异常相关的问题。例如,它包含关于捕获泛型异常以及其他与异常处理相关问题的规则。

  • 格式化:此规则集检查您的代码库是否遵循特定的格式化规则集。它允许检查缩进、间距、分号,甚至导入顺序等。

  • 命名:此规则集包含确保代码库不同部分命名的规则。它检查我们如何命名我们的类、包、函数和变量。如果未遵循既定约定,它会报告错误。

  • 例如,ArrayPrimitivesforEach循环的误用。

  • 潜在错误:此规则集提供检测潜在错误的规则。

  • 规则作者:此规则集提供确保在编写自定义规则时遵循良好实践的规则。

  • 风格:此规则集提供确保代码风格的规则。这将有助于保持代码与给定的代码风格指南保持一致。

通过对 detekt 规则集和功能的理解,现在让我们在我们的项目中设置 detekt。

设置 detekt

与 Ktlint 类似,detekt 作为一个 Gradle 插件提供。要将插件添加到我们的项目中,我们需要在项目build.gradle.kts文件中的插件块中添加以下代码:

id("io.gitlab.arturbosch.detekt") version "1.23.1"

点击插件块下方的build.gradle.kts文件:

apply(plugin = "io.gitlab.arturbosch.detekt")
detekt {
    parallel = true
}

这将把 detekt 插件应用到我们项目中的所有模块,因此我们不需要为每个模块添加插件。我们还设置了parallel属性为true。这将帮助我们并行运行 detekt 任务,并在运行任务时节省时间。点击顶部的立即同步按钮以将更改添加到项目中。我们现在可以开始使用 detekt 了。打开您的终端并运行以下命令:

./gradlew detekt

这将运行detekt任务。任务完成后,我们将看到以下输出:

图 11.6 – detekt 错误

图 11.6 – detekt 错误

第一次运行任务时,我们将得到许多错误。如图所示,detekt 显示了包含错误的文件和行号以及未遵守的规则集类型。在图 11.6中,我们可以看到常见的错误包括函数太长,包含魔法数字等。在错误列表的末尾,detekt 通常显示加权问题的总数,如图所示:

图 11.7 – detekt 错误摘要

图 11.7 – detekt 错误摘要

我们总共有 121 个加权问题。我们将看到如何抑制一些问题以及提高其他问题的阈值,并尽快修复我们可以修复的问题。首先,我们需要更改 detekt 的默认行为。为此,我们需要在项目根目录中创建一个detekt-config.yml文件。detekt 有一个任务可以完成这个操作。让我们在我们的 IDE 中的终端选项卡中打开终端并运行以下命令:

./gradlew detektGenerateConfig

如果我们还没有配置,这将生成一个config文件。任务完成后,我们将看到以下输出:

图 11.8 – detekt 配置文件

图 11.8 – detekt 配置文件

我们需要将此文件引用到我们的 detekt 设置中。让我们转到项目级别的build.gradle.kts文件并修改我们的detekt块,使其看起来像这样:

detekt {
    parallel = true
    config.setFrom(files("${project.rootDir}/config/detekt/detekt.yml"))
}

这里,我们使用我们新创建的文件作为 config 文件。点击 config 文件。

自定义 detekt

有时,detekt 可能会报告我们不希望修复的问题,或者我们可能想要更改问题的严重性或阈值。这时自定义 detekt 就派上用场了。我们可以在 detekt.yml 文件中自定义 detekt,并自定义我们感兴趣的规则。我们将要禁用的第一个问题是 detekt.yml 文件,然后在 macOS 上按 Command + F 或在 Windows 上按 Ctrl + F 搜索 MagicNumber 问题,并按以下方式修改:

MagicNumber:
  active: false

我们将 active 属性设置为 false。这将禁用该问题。再次运行 ./gradlew detekt 命令后,我们可以看到错误已经从 121 减少到 60!这是一个显著的下降。我们还可以看到 MagicNumber 问题不再存在。

图 11.9 – 无魔法数字的 detekt

图 11.9 – 无魔法数字的 detekt

接下来,让我们确保 detekt 不会对 Jetpack Compose 函数命名提出抱怨。搜索 FunctionNaming 问题,并按以下方式修改:

FunctionNaming:
  active: true
  excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
  functionPattern: '[a-z][a-zA-Z0-9]*'
  excludeClassPattern: '$^'
  ignoreAnnotated: ['Composable']

我们添加了 ignoreAnnotated: ['Composable']。这将不会报告所有带有 @Composable 注解的函数的问题。Composable 函数使用 Pascal 大写命名约定。接下来,我们想要在 detekt 中禁用文件末尾的新行规则,因为我们已经在 Ktlint 中禁用了它。搜索 FinalNewline 问题,并按以下方式修改:

NewLineAtEndOfFile:
  active: false

这样就可以禁用该问题。再次运行 ./gradlew detekt 命令后,我们可以看到错误已经减少到只有八个:

图 11.10 – 减少错误后的 detekt

图 11.10 – 减少错误后的 detekt

现在,让我们看看如何提高阈值以解决 函数过长 问题。搜索 FunctionTooLong 问题,并按以下方式修改:

LongMethod:
  active: true
  threshold: 140

这样就可以解决所有与函数过长相关的问题。搜索 LongParameterList 问题,并按以下方式修改:

LongParameterList:
  active: true
  functionThreshold: 8

我们将阈值从 6 提高到 8。最后,搜索 ComplexCondition 问题,并按以下方式修改:

ComplexCondition:
  active: true
  threshold: 6

我们还将阈值从 4 提高到 6。再次运行 ./gradlew detekt 命令后,我们可以看到错误已经减少到只有三个:

图 11.11 – 移除更多错误

图 11.11 – 移除更多错误

提高阈值是减少错误的好方法。这也很好,因为它减少了我们必须进行的重构量。然而,我们应该小心不要将阈值提高得太多。现在,让我们尝试修复剩余的问题。让我们从 TooGenericExceptionCaughtSwallowedException 问题开始。这是在我们的 PetsSyncWorker doWork 函数中:

override suspend fun doWork(): Result {
    return try {
        petsRepository.fetchRemotePets()
        Result.success()
    } catch (e: Exception) {
        Result.failure()
    }
}

要解决这些问题,我们需要在 catch 块中添加一个 log 语句,并只捕获我们期望的异常。让我们将代码修改如下:

override suspend fun doWork(): Result {
    return try {
        petsRepository.fetchRemotePets()
        Result.success()
    } catch (e: IOException) {
        Log.d("PetsSyncWorker", "Error fetching pets", e)
        Result.failure()
    }
}

我们添加了一个log语句,并且只捕获IOException。最后,让我们修复UtilityClassWithPublicConstructor问题。这个问题在我们的LeakTestUtils类中:

class LeakTestUtils {
    companion object {
        val leakCanaryTest = LeakCanaryTest()
    }
}

这个类只有一个伴生对象,它返回LeakCanaryTest类的实例。我们可以使用对象而不是类。让我们修改这个类如下:

object LeakTestUtils {
    val leakCanaryTest = LeakCanaryTest()
}

所有的问题现在应该都修复了。通过再次运行./gradlew detekt命令,我们可以看到现在没有错误了:

图 11.12 – detekt 成功运行

图 11.12 – detekt 成功运行

我们的构建成功通过。我们的项目现在有一个插件,帮助我们进行代码的静态分析。我们可以在开发过程中早期识别代码异味和技术债务。我们还可以将这些插件用于持续集成/持续交付CI/CD)管道,以确保我们不会合并有代码异味和技术债务的代码。这将帮助我们保持代码库的清洁和可维护性,尤其是在团队工作中。我们将在第十四章中详细了解这些内容。

摘要

在本章中,我们学习了 Kotlin 风格和编写 Kotlin 代码的最佳实践。我们还学习了如何使用 Ktlint 和 Detekt 等插件进行格式化、代码审查和早期检测代码异味。

在下一章中,我们将学习如何为 MVVM 架构的不同层添加测试。我们将了解添加测试到我们的应用的重要性,以及如何添加单元测试、集成测试和仪器测试。

第十二章:测试您的应用

测试 Android 应用是开发过程中的一个关键方面,确保我们的应用按预期工作并满足用户期望。它帮助我们识别和修复在它们达到生产之前的问题,并确保我们的应用稳定且性能良好。本章将为您提供编写我们迄今为止创建的应用不同层的测试的技能。

在本章中,我们将学习如何为我们的MVVM模型-视图-视图模型)架构中的不同层添加测试。我们将了解添加测试到我们的应用的重要性以及如何添加单元测试、集成测试和仪器测试。

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

  • 测试的重要性

  • 测试网络和数据库层

  • 测试我们的ViewModels

  • 将 UI 测试添加到我们的可组合组件中

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chaptertwelve找到本章的代码。

测试的重要性

编写测试是应用开发的一个关键方面。它有以下好处:

  • 它有助于我们在它们达到生产阶段之前识别和修复错误。当我们为我们的代码编写测试时,我们可以在早期阶段看到问题,并在它们到达用户之前快速修复它们,这通常成本很高。

  • 确保代码质量。当我们编写测试时,我们被迫编写可测试的代码。这意味着我们编写的代码是模块化和松散耦合的。这使得我们的代码库更易于维护和协作。当我们发现难以测试的代码片段时,这是一个迹象,表明代码编写得不好,需要重构。

  • 编写测试可以提高文档和代码理解。当我们编写测试时,我们被迫思考我们的代码是如何工作的以及应该如何使用。这使得其他开发者更容易理解我们的代码。虽然测试可以作为文档的一种形式,但它们不应取代适当的代码文档。

  • 测试帮助我们有信心地重构代码。当我们有测试在位时,我们可以重构我们的代码,并确信我们没有破坏在重构之前工作良好的应用中的现有功能。这是因为我们可以运行我们的测试并查看它们是否通过或失败。

  • 测试帮助我们测试回归,尤其是添加新功能或修改现有功能。测试确保现有的功能仍然按预期工作,并且没有出现任何问题。

这些只是提及的一小部分。写测试的好处还有很多,而实现这些好处的最佳方式就是开始为你的代码编写测试。需要注意的是,我们还可以将测试添加到我们的持续集成/持续交付CI/CD)管道中,以确保在我们将代码推送到我们的仓库时,测试会自动运行。这也确保了在我们与其他人合作进行项目时,我们可以确信我们的代码始终处于良好状态,并且我们可以有信心地将代码部署到生产环境中。

在 Android 中,我们有一个称为测试金字塔的概念,它帮助我们理解我们可以为我们的应用程序编写的几种测试类型以及它们之间的关系。测试金字塔分为三个层次,如下面的图所示:

图 12.1 – 测试金字塔

图 12.1 – 测试金字塔

如前图所示,我们有三个层次的测试:

  • 单元测试:这些测试位于金字塔的底部。这些是测试单个代码单元的测试,它们在隔离状态下进行测试。单元测试旨在测试应用程序中最小的可测试部分——通常是方法和函数。它们运行速度最快,可靠性最高。它们也是编写和维护最简单的。单元测试仅在本地机器上运行。这些测试被编译为在Java 虚拟机JVM)上本地运行,以最小化执行时间。对于依赖于你自己的依赖项的测试,我们使用模拟对象来提供外部依赖项。MockKMockito是流行的模拟依赖项的框架。

  • 集成测试:这些测试位于金字塔的中间。它们测试不同的代码单元是如何协同工作的。它们的运行速度比单元测试慢。编写它们也很困难,因为它们需要多个组件和依赖项来工作和维护。Roboletric是编写集成测试的流行框架。

  • UI 测试:这些测试位于金字塔的顶部。它们测试我们应用程序的不同组件是如何协同工作的。由于它们在真实设备或模拟器上运行,所以运行速度最慢,可靠性最低。它们也是编写和维护成本最高的。有几个框架可以用来编写 UI 测试,包括EspressoUI AutomatorAppium

测试金字塔提供了一种在代码库上分配我们编写的测试的方法。理想的分配百分比是单元测试 70%集成测试 20%UI 测试 10%。注意,当我们向上移动金字塔时,测试的数量会减少。这是因为当我们向上移动金字塔时,测试的编写和维护成本会更高。这就是为什么我们应该努力编写比集成测试更多的单元测试,以及比 UI 测试更多的集成测试。

在接下来的几节中,我们将为所讨论的不同层编写测试。让我们先从测试我们的应用程序中的数据库和网络层开始。

测试网络和数据库层

在本节中,我们将逐步学习如何编写对网络和数据库层的测试。

测试网络层

为了测试我们的网络层,我们将编写单元测试。然而,由于我们使用 Retrofit 进行网络请求,我们将使用MockWebServer来模拟网络请求。MockWebServer是一个允许我们模拟网络请求的库。让我们首先在我们的版本目录中设置测试依赖项:

  1. 打开libs.version.toml文件,并在版本部分添加以下版本:

    mockWebServer = "5.0.0-alpha.2"
    coroutinesTest = "1.7.3"
    truth = "1.1.5"
    

    我们正在设置mockWebServercoroutinesTesttruth的版本。

  2. 接下来,在库部分,添加以下内容:

    test-mock-webserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockWebServer" }
    test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" }
    test-truth = { module = "com.google.truth:truth", version.ref = "truth" }
    

    在这里,我们正在添加这些库的依赖项。

  3. 接下来,我们将创建一个包,以便一次性添加所有测试依赖项。在包部分,添加以下内容:

    test = ["test-mock-webserver", "test-coroutines", "test-truth"]
    
  4. 点击顶部的立即同步按钮以添加依赖项。

  5. 最后,让我们转到应用程序级别的build.gradle.kts文件,并添加以下内容:

    testImplementation(libs.bundles.test)
    

    这将把测试包添加到我们的测试目录中。

  6. 点击立即同步按钮以将依赖项添加到我们的应用程序中。

在我们开始编写测试之前,我们需要做一些设置任务。首先,我们需要为测试将使用的请求创建一个 JSON 响应:

  1. 要做到这一点,右键单击app目录,选择新建,然后在弹出对话框的底部选择文件夹

  2. 从提供的选项中选择resources,如图所示:

图 12.2 – 资源文件夹

图 12.2 – 资源文件夹

  1. 在这个文件夹内,让我们创建一个名为catsresponse.json的新 JSON 文件,并添加以下 JSON:

    [
      {
        "_id": "eLjLV4oegWGFv9MH",
        "mimetype": "image/png",
        "size": 39927,
        "tags": [
          "cute",
          "pyret"
        ]
      },
      {
        "_id": "PA2gYEbMCzaiDrWv",
        "mimetype": "image/jpeg",
        "size": 59064,
        "tags": [
          "cute",
          "best",
          "siberian",
          "fluffy"
        ]
      },
      {
        "_id": "8PKU6iXscrogXrHm",
        "mimetype": "image/jpeg",
        "size": 60129,
        "tags": [
          "cute",
          "fat",
          "ragdoll",
          "beautiful",
          "sleeping"
        ]
      }
    ]
    

    我们的应用程序使用 Cat as a Service API,该 API 根据您应用的筛选条件返回猫的列表。API 以 JSON 响应的形式返回此猫列表,如前所述。在测试时,尤其是使用模拟数据时,JSON 响应的结构和数据类型应与真实 API 匹配,以确保我们的测试是正确的。

  2. 现在我们有了准备好的响应,我们必须创建一个类来利用这个响应,以及在前面的图中所示的com.packt.chaptertwelve (test)目录中的测试类:

图 12.3 – 测试目录

图 12.3 – 测试目录

  1. com.packt.chaptertwelve (test)目录内,让我们创建一个名为MockRequestDispatcher.kt的新 Kotlin 文件,并添加以下代码:

    import com.google.common.io.Resources
    import okhttp3.mockwebserver.Dispatcher
    import okhttp3.mockwebserver.MockResponse
    import okhttp3.mockwebserver.RecordedRequest
    import java.io.File
    import java.net.HttpURLConnection
    class MockRequestDispatcher : Dispatcher() {
        override fun dispatch(request: RecordedRequest): MockResponse {
            return when (request.path) {
                "/cats?tag=cute" -> {
                    MockResponse()
                        .setResponseCode(HttpURLConnection.HTTP_OK)
                        .setBody(getJson("catsresponse.json"))
                }
                else -> throw IllegalArgumentException("Unknown Request Path ${request.path}")
            }
        }
        private fun getJson(path: String): String {
            val uri = Resources.getResource(path)
            val file = File(uri.path)
            return String(file.readBytes())
        }
    }
    

    以下是前面代码的分解:

    • 我们创建了一个名为MockRequestDispatcher的类,它扩展了Dispatcher。这个类将用于模拟我们的网络请求。

    • 我们重写了 dispatch 函数,该函数接受 RecordedRequest 并返回 MockResponse。当向服务器发出请求时,将调用此函数。

    • 我们检查请求的路径,如果它与我们的请求路径匹配,我们返回带有响应代码 200 和我们之前创建的 Json 响应体的 MockResponse。目前,我们只模拟了成功的响应,但处理所有不同的 HTTP 响应代码和错误情况对于正确模拟现实世界场景非常重要。

    • 如果路径不匹配,我们将抛出 IllegalArgumentException

    • 最后,我们创建了一个 getJson 函数,它接受一个路径并返回一个 String 实例类型。这个函数用于读取我们之前创建的文件中的 Json 响应。

    我们可以添加任意多的路径到这个类中。由于我们的项目只有一个路径,所以我们只需要这些。

  2. 接下来,让我们创建我们的测试类。让我们创建一个名为 CatsAPITest.kt 的新 Kotlin 文件,并添加以下代码:

    class CatsAPITest {
        private lateinit var mockWebServer: MockWebServer
        private lateinit var catsAPI: CatsAPI
        @Before
        fun setup() {
            // Setup MockWebServer
            mockWebServer = MockWebServer()
            mockWebServer.dispatcher = MockRequestDispatcher()
            mockWebServer.start()
            // Setup Retrofit
            val json = Json {
                ignoreUnknownKeys = true
                isLenient = true
            }
            val retrofit = Retrofit.Builder()
                .baseUrl(mockWebServer.url("/"))
                .addConverterFactory(
                    json.asConverterFactory(
                        contentType = "application/json".toMediaType()
                    )
                )
                .build()
            catsAPI = retrofit.create(CatsAPI::class.java)
        }
        @Test
        fun `fetchCats() returns a list of cats`() = runTest {
            val response = catsAPI.fetchCats("cute")
            assert(response.isSuccessful)
        }
        @After
        @Throws(IOException::class)
        fun tearDown() {
            mockWebServer.shutdown()
        }
    }
    

    下面是对前面代码的分解:

    • 我们创建了一个名为 CatsAPITest 的类。这个类将用于测试我们的网络层。

    • 我们创建了两个变量:mockWebServercatsAPImockWebServer 变量将用于模拟我们的网络请求。catsAPI 变量将用于发送我们的网络请求。

    • 我们有 setup() 函数,该函数被 @Before 注解标记。这意味着这个函数将在我们的测试运行之前运行。在这个函数中,我们做了以下操作:

      • 我们创建了一个 MockWebServer 实例并将其分配给 mockWebServer 变量。然后我们将 mockWebServer 的调度程序设置为 MockRequestDispatcher 的一个实例。这是我们之前创建的类。然后我们启动 mockWebServer

      • 我们创建了一个 Retrofit 实例并添加了 kotlinx-serialization-converter 工厂。然后我们将 catsAPI 变量分配给 CatsAPI 的一个实例。

    • 我们有我们的测试函数,该函数被 @Test 注解标记。这意味着这个函数将被作为测试运行。在这个函数中,我们做了以下操作:

      • 我们将测试包裹在 runTest() 函数中。这是因为我们想要测试挂起函数。runTest 是一个协程测试构建器,专为测试协程设计。它是我们之前添加的 kotlinx-coroutines-test 库的一部分。

      • 我们使用之前创建的 catsAPI 实例向 mockWebServer 发送网络请求。然后我们断言响应是成功的。

      • 我们有 tearDown() 函数,该函数被 @After 注解标记。这意味着这个函数将在我们的测试运行之后运行。这个函数用于关闭我们的 mockWebServer 实例。

  3. 按下测试类旁边的绿色 运行 图标来运行我们的测试。我们应该在 运行 窗口中看到以下输出:

图 12.4 – 测试通过

图 12.4 – 测试通过

如前图所示,我们的测试运行成功。这意味着我们的网络层按预期工作。我们现在可以继续测试我们的数据库层。

测试数据库层

我们使用以下图所示的 androidTest 目录:

图 12.5 – Android 测试目录

图 12.5 – Android 测试目录

让我们创建一个名为 CatsDaoTest.kt 的新文件,并添加以下代码:

@RunWith(AndroidJUnit4::class)
class CatDaoTest {
    private lateinit var database: CatDatabase
    private lateinit var catDao: CatDao
    @Before
    fun createDatabase() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            CatDatabase::class.java
        ).allowMainThreadQueries().build()
        catDao = database.catDao()
    }
    @After
    fun closeDatabase() {
        database.close()
    }
}

下面是对前面代码的分解:

  • 我们创建了两个变量:databasecatDaodatabase 变量将用于创建我们数据库的实例。catDao 变量将用于创建我们 CatDao 接口的实例。

  • 我们有 createDatabase() 函数,该函数带有 @Before 注解。这意味着这个函数将在我们的测试运行之前运行。在函数内部,我们创建我们数据库的实例并将其分配给 database 变量。我们使用的是内存数据库。

  • 我们有 closeDatabase() 函数,该函数带有 @After 注解。这意味着这个函数将在我们的测试运行之后运行。此函数用于关闭我们的数据库。

完成这些后,我们现在可以开始编写我们的测试:

  1. CatsDaoTest 类中,添加以下测试函数:

    @Test
    fun testInsertAndReadCat() = runTest {
        // Given a cat
        val cat = CatEntity(
            id = "1",
            owner = "John Doe",
            tags = listOf("cute", "fluffy"),
            createdAt = "2021-07-01T00:00:00.000Z",
            updatedAt = "2021-07-01T00:00:00.000Z",
            isFavorite = false
        )
        // Insert the cat to the database
        catDao.insert(cat)
        // Then the cat is in the database
        val cats = catDao.getCats()
        assert(cats.first().contains(cat))
    }
    

    在这个测试中,我们创建了一个包含猫详细信息的 CatEntity 对象。然后我们将猫的详细信息插入到数据库中。最后,我们断言猫的详细信息在数据库中。

  2. 点击测试类旁边的绿色 运行 图标来运行我们的测试。你应该在 运行 窗口中看到以下输出:

图 12.6 – 插入和从数据库读取的测试

图 12.6 – 插入和从数据库读取的测试

我们的测试运行成功。这意味着我们的数据库层按预期工作。让我们添加另一个测试来测试将猫添加到收藏夹。

  1. 仍然在 CatsDaoTest 类内部,让我们添加以下测试函数:

    @Test
    fun testAddCatToFavorites() = runTest {
        // Given a cat
        val cat = CatEntity(
            id = "1",
            owner = "John Doe",
            tags = listOf("cute", "fluffy"),
            createdAt = "2021-07-01T00:00:00.000Z",
            updatedAt = "2021-07-01T00:00:00.000Z",
            isFavorite = false
        )
        // Insert the cat to the database
        catDao.insert(cat)
        // Favorite the cat
        catDao.update(cat.copy(isFavorite = true))
        // Assert that the cat is in the favorite list
        val favoriteCats = catDao.getFavoriteCats()
        assert(favoriteCats.first().contains(cat.copy(isFavorite = true)))
    }
    

    在这个测试中,我们创建了一个包含猫详细信息的 CatEntity 对象。然后我们将猫插入到数据库中。然后我们更新 CatEntity 对象,将 isFavorite 作为 true 传递。最后,我们断言猫在收藏列表中。

  2. 点击测试类旁边的绿色 运行 图标来运行我们的测试。你应该在 运行 窗口中看到以下输出:

图 12.7 – 收藏猫测试

图 12.7 – 收藏猫测试

我们的测试运行成功。这意味着我们添加猫到收藏夹的功能工作正常。

我们已经看到了如何测试我们的网络和数据库层。接下来,让我们测试我们的 ViewModel 层。

测试我们的 ViewModels

我们的 ViewModel 类从仓库获取数据并将其暴露给 UI。为了测试我们的 ViewModel,我们将编写单元测试。让我们从在我们的版本目录中设置测试依赖项开始:

  1. 打开 libs.version.toml 文件,并在版本部分添加以下版本:

    mockk = "1.13.3"
    
  2. 接下来,在库部分,添加以下内容:

    test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
    
  3. test-mockk 依赖项添加到 test 包中。我们的更新后的 test 包现在应该看起来像这样:

    test = ["test-mock-webserver", "test-coroutines", "test-truth", "test-mockk"]
    
  4. 点击 mockk 允许我们模拟我们的依赖项。

  5. 我们现在准备好创建测试类。在测试目录中创建一个新的 Kotlin 文件,命名为 CatsViewModelTest.kt,并添加以下代码:

    class PetsViewModelTest {
        private val petsRepository = mockk<PetsRepository>(relaxed = true)
        private lateinit var petsViewModel: PetsViewModel
        @Before
        fun setup() {
            Dispatchers.setMain(Dispatchers.Unconfined)
            petsViewModel = PetsViewModel(petsRepository)
        }
        @After
        fun tearDown() {
            Dispatchers.resetMain()
        }
    }
    

    下面是对前面代码的分解:

    • 我们创建了两个变量:petsRepositorypetsViewModelpetsRepository 变量将用于模拟我们的 PetsRepository 接口。我们使用 mockk<PetsRepository> 提供了一个 PetsRepository 的模拟实例。petsViewModel 变量将用于创建 PetsViewModel 的一个实例。

    • 我们有 setup() 函数,该函数被 @Before 注解标记。这意味着这个函数将在我们的测试运行之前执行。我们将主分发器设置为 Dispatchers.Unconfined。这是因为我们在 ViewModel 中使用协程。然后我们将 petsViewModel 属性赋值给 PetsViewModel 的一个实例。

    • 我们有 tearDown() 函数,该函数被 @After 注解标记。这意味着这个函数将在我们的测试运行之后执行。这个函数用于重置主分发器。

这样,我们就准备好编写测试了。在 tearDown() 函数下方,添加以下测试函数:

@Test
fun testGetPets() = runTest {
    val cats = listOf(
        Cat(
            id = "1",
            owner = "John Doe",
            tags = listOf("cute", "fluffy"),
            createdAt = "2021-07-01T00:00:00.000Z",
            updatedAt = "2021-07-01T00:00:00.000Z",
            isFavorite = false
        )
    )
    // Given
    coEvery { petsRepository.getPets() } returns flowOf(cats)
    // When
    petsViewModel.getPets()
    coVerify { petsRepository.getPets() }
    // Then
    val uiState = petsViewModel.petsUIState.value
    assertEquals(cats, uiState.pets)
}

在这个测试函数中,我们创建了一个猫的列表。然后我们模拟 PetsRepositorygetPets() 函数以返回一个猫的流。它返回一个猫的流,因为我们的 PetsRepository 中的 getPets() 函数返回 Flow<List<Cat>>;这样,我们模拟了该函数的正确行为。然后我们调用 PetsViewModelgetPets() 函数。然后我们断言 PetsRepositorygetPets() 函数被调用。最后,我们断言我们创建的猫列表与从 PetsViewModel 获取的猫列表相同。记住,如果尝试访问 getPets() 函数时出现错误,请从我们的 PetsViewModel 类中移除私有标记。点击测试类旁边的绿色 运行 图标以运行我们的测试。你应该在 运行 窗口中看到以下输出:

图 12.8 – PetsViewModelTest

图 12.8 – PetsViewModelTest

我们的测试运行成功。这意味着我们的 ViewModel 层按预期工作。现在我们可以继续测试我们的 UI 层。在下一节中,我们将学习如何在 Jetpack Compose 中编写 UI 测试。

将 UI 测试添加到我们的可组合项中

编写 UI 测试对我们来说变得更容易了。Jetpack Compose 提供了一套测试 API,用于查找元素、验证它们的属性,并在这些元素上执行操作。Jetpack Compose 使用 PetListItem 可组合项。

让我们转到 PetListItem.kt 文件。我们需要在我们的可组合项中添加一个 testTags 修饰符。这是因为我们正在使用标签来识别我们的可组合项。在 PetListItem 可组合项中,修改可组合项内容如下:

ElevatedCard(
    modifier = Modifier
        .fillMaxWidth()
        .padding(6.dp)
        .testTag("PetListItemCard"),
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(bottom = 10.dp)
            .testTag("PetListItemColumn")
            .clickable {
                onPetClicked(cat)
            }
    ) {
        AsyncImage(
            model = "https://cataas.com/cat/${cat.id}",
            contentDescription = "Cute cat",
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp),
            contentScale = ContentScale.FillWidth
        )
        Row(
            modifier = Modifier
                .padding(start = 6.dp, end = 6.dp)
                .fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            FlowRow(
                modifier = Modifier
                    .padding(start = 6.dp, end = 6.dp)
            ) {
                repeat(cat.tags.size) {
                    SuggestionChip(
                        modifier = Modifier
                            .padding(start = 3.dp, end = 3.dp),
                        onClick = { },
                        label = {
                            Text(text = cat.tags[it])
                        }
                    )
                }
            }
            Icon(
                modifier = Modifier
                    .testTag("PetListItemFavoriteIcon")
                    .clickable {
                        onFavoriteClicked(cat.copy(isFavorite = !cat.isFavorite))
                    },
                imageVector = if (cat.isFavorite) {
                    Icons.Default.Favorite
                } else {
                    Icons.Default.FavoriteBorder
                },
                contentDescription = "Favorite",
                tint = if (cat.isFavorite) {
                    Color.Red
                } else {
                    Color.Gray
                }
            )
        }
    }
}

注意我们已经为我们的组件添加了 testTag() 修饰符。有了这个修饰符,我们能够使用 Jetpack Compose 的 Finders API 来找到我们的可组合项。一旦我们使用了查找器,我们就可以对可组合项执行操作并断言。现在让我们在我们的 androidTest 目录中创建一个名为 PetListItemTest.kt 的新文件,并添加以下代码:

class PetListItemTest {
    @get:Rule
    val composeTestRule = createComposeRule()
    @Test
    fun testPetListItem() {
        with(composeTestRule) {
            setContent {
                PetListItem(
                    cat = Cat(
                        id = "1",
                        owner = "John Doe",
                        tags = listOf("cute", "fluffy"),
                        createdAt = "2021-07-01T00:00:00.000Z",
                        updatedAt = "2021-07-01T00:00:00.000Z",
                        isFavorite = false
                    ),
                    onPetClicked = { },
                    onFavoriteClicked = {})
            }
            // Assertions using tags
            onNodeWithTag("PetListItemCard").assertExists()
            onNodeWithTag("PetListItemColumn").assertExists()
            onNodeWithTag("PetListItemFavoriteIcon").assertExists()
            // Assertions using text
            onNodeWithText("fluffy").assertIsDisplayed()
            onNodeWithContentDescription("Favorite").assertIsDisplayed()
            onNodeWithContentDescription("Cute cat").assertIsDisplayed()
            // Actions
            onNodeWithTag("PetListItemFavoriteIcon").performClick()
        }
    }
}

下面是对前面代码的分解:

  • 我们创建了一个名为 PetListItemTest 的类。我们将使用这个类来测试我们的 PetListItem 可组合项。在这个类内部,我们创建了一个名为 composeTestRule 的规则。这个规则将用于创建我们的可组合项。通过这个规则,我们可以设置 Compose 内容或访问我们的活动。

  • 我们有一个被 @Test 注解的 testPetListItem() 函数,这个函数中发生了几件事情:

    • 我们使用了 with 范围函数来能够使用 composeTestRule。然后我们设置了我们的可组合项的内容。在这种情况下,是我们想要测试的 PetListItem 可组合项。我们向我们的可组合项传递了一个 cat 对象。

    • 我们使用 onNodeWithTag() 函数来找到我们的可组合项。然后我们使用 assertExists() 函数来断言可组合项的存在。这将使用我们之前添加的标签来找到我们的可组合项。

    • 我们使用 onNodeWithText() 函数来找到我们的可组合项。然后我们使用 assertIsDisplayed() 函数来断言可组合项的存在。我们还使用了 onNodeWithContentDescription() 函数来找到我们的可组合项。这两个函数帮助我们找到文本或内容描述与传递给函数的文本或内容描述匹配的可组合项。

    • 最后,我们使用 performClick() 函数对我们的可组合项执行操作。在这种情况下,我们对我们 PetListItemFavoriteIcon 可组合项执行点击操作。

点击我们测试类旁边的绿色 运行 图标来运行我们的测试。我们应该在 运行 窗口中看到以下输出:

图 12.9 – Jetpack Compose UI 测试

图 12.9 – Jetpack Compose UI 测试

我们的测试运行成功。此外,测试也在我们正在工作的设备上运行。我们还能看到组件的显示和执行的操作。

我们已经看到了如何在 Jetpack Compose 中编写 UI 测试。要了解更多关于 Jetpack Compose 的测试信息,请查看官方文档(developer.android.com/jetpack/compose/testing)。凭借我们在本章中获得的知识,我们可以为我们的应用的不同层添加更多测试。你可以尝试添加更多测试来检验你的知识。

摘要

在本章中,我们学习了如何为我们的 MVVM 架构中的不同层添加测试。我们了解了添加测试到我们的应用的重要性,以及如何添加单元测试、集成测试和仪器测试。

在下一章中,我们将逐步学习如何在 Google Play 商店发布一款新应用。我们将详细介绍如何创建签名应用包以及发布我们第一个应用到 Play Store 所需的事项。此外,我们还将了解一些 Google Play 商店政策以及如何始终保持合规,以避免我们的应用被移除或账户被封禁。

第四部分:发布您的应用

在成功开发您的应用之后,下一阶段的内容将在本部分展开。您将了解如何将应用发布到 Google Play 商店的细节,以及如何通过遵守关键的 Google Play 商店政策来确保发布过程顺利。您还将深入了解持续集成与持续部署CI/CD)领域,解锁自动化 Android 开发中常规任务的潜力。您将学习如何将第三部分中的测试和代码分析工具无缝集成到您的 CI/CD 管道中,从而简化开发工作流程。为了结束本部分,您将通过整合崩溃报告工具来提升应用性能,并通过实施推送通知来增强用户参与度,并学习一些有关如何确保应用安全的有用提示。

本节包含以下章节:

  • 第十三章, 发布您的应用

  • 第十四章, 持续集成与持续部署

  • 第十五章, 改进您的应用

第十三章:发布您的应用

一旦我们出色的应用开发完成,接下来的阶段就是将这些应用交付给我们的目标受众。这是通过在 Google Play 商店发布我们的应用来实现的。本章将重点介绍这一过程。

在本章中,我们将逐步学习如何在 Google Play 商店发布新应用。我们将介绍如何创建签名应用包,以及回答有关我们应用内容的问题、创建发布、设置用户如何访问我们的应用——无论是通过受控测试轨道还是公开,以及更多。所有这些是我们发布第一个应用到 Play 商店所必需的。此外,我们还将了解一些 Google Play 商店政策以及如何始终遵守规定,以避免我们的应用被移除或我们的账户被禁用。

在本章中,我们将介绍以下主要主题:

  • 为发布准备我们的应用

  • 将我们的应用发布到 Google Play 商店

  • Google Play 商店政策概述

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在 GitHub 上找到本章的代码(github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterthirteen)。

为发布准备我们的应用

在我们将应用上传到 Google Play 商店之前,我们必须做一些准备工作来为发布做准备。我有一个清单,每次发布应用时我都会过一遍。在本节中,我们将讨论这个清单。这个清单有助于确保我们在发布应用时不会忘记任何事情,并且应用是可用的。我们将在本书的第十五章中稍后处理一些清单项目,但在这里提一下它们是有价值的。

这里是清单:

  • 向您的应用中添加分析

  • 将崩溃报告添加到您的应用中

  • 关闭日志和调试

  • 国际化和本地化您的应用

  • 改进错误信息

  • 在不同的设备上测试您的应用

  • 提供适当的反馈渠道

  • 减小应用的大小

  • 使用 Android App Bundle

  • 启用最小化和混淆

现在,让我们更详细地了解每一项。

向您的应用中添加分析

向应用中添加分析有助于获取关于应用发布后表现的数据。这有助于我们做出改进应用的决策。Firebase Analytics、Google Analytics、Flurry 和 Mixpanel 等平台提供此类服务。我们将在本书的第十五章中添加 Firebase Analytics 到我们的应用。

将崩溃报告添加到您的应用中

崩溃报告库帮助我们获取我们应用的崩溃报告。这有助于修复导致崩溃的 bug。这在用户使用我们的应用时捕捉崩溃和堆栈跟踪时非常有帮助。我们将在本书的第十五章中将 Firebase Crashlytics 添加到我们的应用中作为我们的崩溃报告库。还有其他可以使用的崩溃报告工具,例如 Sentry、Bugsnag 和 Raygun。始终权衡哪种工具最适合我们的用例是很好的。

关闭日志和调试

为了确保我们不向其他用户暴露我们应用的任何敏感信息,我们必须确保我们的发布构建中没有启用日志调试

国际化和本地化您的应用

如果一个应用针对不同的国家,我们必须确保我们本地化和国际化该应用。这意味着我们必须将应用翻译成我们目标国家的语言。通过国际化,一个结构良好的代码库可以无缝集成辅助功能并支持多种语言。Unicode 支持确保与各种语言的字符兼容,增强可访问性。本地化适应内容、语言和文化细微差别,使我们的应用更容易满足有语言相关需求的用户。适应日期、时间和数字格式,以及视觉元素,有助于提高应用的可访问性和可用性。我们必须确保我们的应用在我们目标的不同国家运行良好。

改进错误信息

随着我们的应用发布,我们必须确保以用户易于理解的方式显示错误信息。我们必须避免显示针对开发者的错误信息。有了分析工具,我们总能记录错误的详细技术信息,并能够知道是什么原因导致错误。

在不同设备上测试您的应用

这是一个非常重要的清单项。由于最终用户通常不使用相同的设备和操作系统,因此我们在不同设备上测试应用非常重要。这有助于我们确保我们的应用在不同设备上运行良好,包括不同屏幕尺寸和不同操作系统。我们可以始终利用 Firebase Test Lab 等服务在不同设备上测试应用。Firebase Test Lab(firebase.google.com/docs/test-lab)提供各种物理和虚拟设备,我们可以使用这些设备来测试应用。

提供适当的反馈渠道

有时,即使完成了所有这些清单项,用户可能还是会遇到我们没有预料到的问题。我们必须确保我们为用户提供适当的 反馈渠道,以便他们可以联系我们。这有助于确保我们收到用户的反馈,并能修复他们可能遇到的问题。这也让用户感觉到我们关心他们,并准备好帮助他们。反馈渠道可以是电子邮件、社交媒体、电话号码或任何其他我们可以用来回应用户的渠道。

减小你的应用大小

我们必须确保我们的应用不要太大,尽可能减小其大小。这有助于确保我们的应用下载和安装得更快。我们通过从应用中移除任何未使用的资源来实现这一点。我们还可以压缩与应用捆绑的任何音频或图像。对于图像,我们应尽可能使用矢量图像。这些图像尺寸非常小,并且在不同屏幕尺寸上都能很好地缩放。

使用 Android App Bundle

Android App Bundle (AAB) 是一种发布格式,它包含了应用的所有编译代码和资源,但将 Android 包工具包 (APK) 的生成和签名推迟到 Google Play。这有助于减小应用的大小。使用 Android App Bundle,我们可以从更小的应用中受益,能够按需提供一些功能,并且可以向用户交付即时应用体验。

Android Studio 提供了一种创建已签名的 Android App Bundle 的方法。Android 要求所有 AAB 必须在上传到 Google Play 商店之前使用证书进行签名。签名包允许进行大小优化和动态交付,并简化了应用签名过程。

要创建一个已签名的 Android App Bundle,我们需要导航到 构建 | 生成已签名的包或 APK,如图所示:

图 13.1 – 生成已签名的包

图 13.1 – 生成已签名的包

如前一个屏幕截图所示,AAB 提供了我们已经讨论过的各种好处。点击 下一步 继续此过程:

图 13.2 – 选择你的密钥库证书

图 13.2 – 选择你的密钥库证书

在此步骤中,我们选择创建一个新的密钥库证书或使用现有的一个。点击 创建新... 来创建一个新的密钥库证书。然后,你会看到一个以下对话框:

图 13.3 – 创建新的密钥库证书

图 13.3 – 创建新的密钥库证书

在此对话框中,我们填写密钥库证书的详细信息。确保你使用一个你可以记住并存储在一个你可以随时访问的地方的密码。这是因为后续的发布将需要使用相同的证书进行签名。

此外,确保以 .jks 扩展名保存它。一旦填写完表格,点击确定以保存证书。这会将您带回到 图 13**.2 中显示的对话框,但现在已填写了证书详情。点击下一步继续处理过程。您将看到一个以下对话框:

图 13.4 – 选择构建变体

图 13.4 – 选择构建变体

在这里,我们将选择要签名的构建变体。我们将选择发布构建变体。点击创建以创建已签名的 Android App Bundle。Gradle 将构建已签名的 Android App Bundle。一旦过程完成,您将看到一个以下通知:

图 13.5 – 生成已签名包的通知

图 13.5 – 生成已签名包的通知

您可以选择分析来分析包,或者选择定位来定位包所在的文件夹。我们稍后将在将我们的应用上传到 Google Play 商店时使用这个包。

启用压缩和混淆

压缩和混淆有助于减小应用的大小。压缩会移除应用中的任何未使用代码。混淆有助于使代码不可读。压缩通过移除不必要的字符和重命名变量来减小代码库的大小,从而减小 APK 的大小并提高应用性能。另一方面,混淆侧重于重命名类、方法和字段以掩盖其原始名称,增强应用的安全性并防止逆向工程。这些技术共同通过优化代码大小并使黑客难以逆向工程应用,有助于创建更小、更快、更安全的 Android 应用程序。我们这样做是为了发布构建。

让我们转到我们的应用级别的 build.gradle.kts 文件并修改如此处所示的 buildTypes 块:

buildTypes {
    release {
        isMinifyEnabled = true
        isShrinkResources = true
        setProguardFiles(
            listOf(
                getDefaultProguardFile("proguard-android.txt"),
                "proguard-rules.pro"
            )
        )
    }
    debug {
        isMinifyEnabled = false
        isShrinkResources = false
    }
}

我们已经为我们的发布构建启用了压缩和混淆。我们还将调试构建的压缩和混淆禁用。这是因为我们希望在开发时能够调试我们的应用。完成此操作后,建议在将发布构建上传到 Google Play 商店之前,在本地运行发布构建。

这是因为当我们启用压缩和混淆时,可能会移除一些代码。这可能会导致我们的应用崩溃。我们必须检查并确定是否需要添加关于如何混淆我们的代码的规则。

我们通过在 proguard-rules.pro 文件中添加规则来实现这一点。例如,为了使 Retrofit 仍然对我们的 JSON 响应进行序列化,我们必须确保我们的模型类没有被混淆。

现在,让我们导航到 proguard-rules.pro 文件并添加以下规则:

-keep class com.packt.chapterthirteen.data.Cat.** { *; }
-keep class com.packt.chapterthirteen.data.CatEntity.** { *; }
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type rgument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response

在这些规则生效的情况下,当我们的代码被压缩时,我们指定的类将不会被混淆。由于我们的应用不是很大,我们没有很多规则。对于大型应用,请确保您彻底测试并检查您的依赖项文档,以确定您需要添加哪些规则。

构建变体 选项卡上,选择 发布 构建变体并运行发布构建,如下所示:

图 13.6 – 发布构建变体

图 13.6 – 发布构建变体

在选择发布构建变体后,我们需要设置我们之前创建的密钥签名证书,以便我们的发布变体可以运行。为此,让我们在我们的应用级别的 build.gradle.kts 文件中添加以下内容:

signingConfigs {
    create("release") {
        storeFile = file("../keystore/packt.jks")
        storePassword = "android"
        keyAlias = "packt"
        keyPassword = "android"
    }
}

在前面的代码中,我们在 signingConfigs 块中创建了一个新的配置,名为 release。我们指定了 keyAliaskeyPasswordstoreFilestorePassword。密钥库文件位于此项目的根目录中,如代码片段所示。如果您将其保存在了不同的位置,请确保更改路径。

点击 signingConfig 以选择我们的发布构建类型。为此,让我们在我们的发布构建类型中添加以下内容:

signingConfig = signingConfigs.getByName("release")

现在我们可以本地运行发布构建。然而,发布构建的构建时间比调试构建长。应用运行后,我们应该能够在应用中看到我们的可爱猫咪图片:

图 13.7 – 在应用中查看可爱的猫咪图片

图 13.7 – 在应用中查看可爱的猫咪图片

我们的清单已经完成,我们已经有了一个已签名的 AAB。在下一节中,我们将把我们的应用上传到 Google Play 商店。

将我们的应用发布到 Google Play 商店

在您可以将应用发布到 Google Play 商店之前,您需要有一个开发者账户。您可以通过在 Google Play 上注册(play.google.com/console/signup)来获取一个账户。

您可以创建自己的账户或组织的账户,如下所示:

图 13.8 – 创建开发者账户

图 13.8 – 创建开发者账户

选择 Yourself 选项将显示创建个人账户的说明:

图 13.9 – 为自己创建开发者账户

图 13.9 – 为自己创建开发者账户

如前图所示,您需要一个用户可以联系您的电子邮件地址,以及 Google Play 可以联系您的另一个电子邮件地址。您还需要为 Google Play 账户支付 25 美元的终身注册费。如果您还没有账户,您可以继续购买,因为这是一个非常简单的流程。

一旦您开通了 Google Play Console 账户,您将看到以下界面:

图 13.10 – Google Play Console 登录页面

图 13.10 – Google Play Console 登录页面

这显示了如果您已经有一些应用,则会显示您所有应用的列表。它还显示了开发者账户名称和账户 ID。在左侧,我们有一个导航抽屉,提供了与我们的开发者账户相关的几个选项。在右上角,我们有一个按钮,我们可以点击创建一个应用。让我们点击“创建应用”按钮。然后我们将看到一个需要填写一些字段的页面:

图 13.11 – 创建您的应用

图 13.11 – 创建您的应用

在这里,我们需要填写应用名称并设置默认语言。我们还要选择我们的应用是应用还是游戏,并指定它将是免费应用还是付费应用。

最后,我们接受显示的声明。让我们通过点击底部的“创建应用”来完成。这将创建一个新的应用。然而,该应用在 Google Play 商店中还不能被用户访问。这是因为我们还有许多待完成的任务,如创建应用后显示的应用仪表板所示。

图 13.12 – 设置您的应用

图 13.12 – 设置您的应用

图 13.12 所见,第一部分涉及设置应用详情。或者,您也可以选择使用一组内部测试人员测试应用。每个步骤都有自己的任务,列在列表中,Play Console 显示每个任务的进度。第二部分涉及为我们的应用创建发布。正是在这个部分,我们使用之前创建的应用程序包:

图 13.13 – 设置发布

图 13.13 – 设置发布

图 13.13 所见,本节包含不同的子节,涉及创建和发布我们的发布,测试我们的应用以获得许多用户的反馈,以及创建我们应用的预注册步骤等选项。所有步骤都有一个任务列表,指导我们如何处理它们。

由于这是我们第一个应用,让我们通过点击“查看任务”按钮在“设置您的 应用”步骤中扩展任务:

图 13.14 – 在“设置您的应用”步骤下列出的任务

图 13.14 – 在“设置您的应用”步骤下列出的任务

这为我们提供了一份需要完成的任务列表。所有这些任务都提供了有关我们应用内容的更多详细信息。正是在这里,我们提供我们应用的隐私政策,指定用户是否需要特殊访问来使用我们应用的功能,指定我们是否在我们的应用上投放广告,填写内容评级问卷和数据安全表格,并指定我们的目标受众,以及其他任务。我们将逐一处理这些任务:

  1. 设置 隐私政策

    在本节中,我们添加一个隐私政策链接,以提供有关我们如何使用从用户收集的数据的信息。未将隐私政策添加到您的应用中可能会导致应用被从 Google Play 商店移除。

    隐私政策应由法律团队完成。然而,许多在线工具可以帮助生成隐私政策。其中一个工具是app-privacy-policy-generator.firebaseapp.com/。我们将使用此工具为我们的应用生成隐私政策。一旦我们有了隐私政策,我们应该将其放在公开链接上,并在隐私政策 URL字段中添加隐私政策链接,如图所示:

图 13.15 – 隐私政策部分

图 13.15 – 隐私政策部分

点击保存以添加您的更改。然后您可以返回主任务列表,继续下一部分。

  1. 应用访问

    在本节中,我们指定用户是否需要特殊访问权限来使用我们应用中的功能。如果需要,我们必须提供登录凭证或说明如何能够访问这些功能。

    它们是必需的,因为应用审查过程涉及测试所有应用功能。未能提供说明可能会导致我们的应用被从 Google Play 商店中移除。由于我们的应用不需要特殊访问权限,我们选择我的应用中所有功能均可在没有任何访问限制的情况下使用并保存答案。

图 13.16 – 应用访问部分

图 13.16 – 应用访问部分

  1. 广告

    在这个任务中,我们指定我们的应用是否包含广告。包含广告的应用在 Play 商店上有一个包含广告的标签。由于我们的应用没有广告,我们选择否,我的应用不包含广告选项并保存答案。

图 13.17 – 广告部分

图 13.17 – 广告部分

  1. 内容评级

    在这个任务中,我们回答几个问题,这些问题决定了我们应用的内容评级。

图 13.18 – 内容评级部分

图 13.18 – 内容评级部分

点击开始问卷按钮开始问卷。您将分三步展示问题。第一步是关于应用的类别:

图 13.19 – 内容评级类别

图 13.19 – 内容评级类别

在这里,我们指定一个可以用来联系我们的关于内容评级和类别应用的电子邮件地址。我们有三个类别,每个类别都有一个描述和示例来指导我们选择正确的类别。我们将选择所有其他应用类型并在页面底部点击下一步继续到下一个步骤,如图所示:

图 13.20 – 内容评级问卷

图 13.20 – 内容评级问卷

本节包含许多关于应用内容的问题。在回答这些问题时,我们必须非常小心,以确保我们正确回答。一旦我们完成这一步骤,我们点击下一步继续到下一个步骤,该步骤将显示我们的答案摘要以及我们应用的评级:

图 13.21 – 内容评级摘要

图 13.21 – 内容评级摘要

我们还可以看到我们应用在世界不同地区的评分。我们可以保存评分并返回到主要任务列表。

  1. 目标受众

    本节包含关于我们应用目标受众的几个问题。如果我们针对的是 18 岁以下的群体,我们必须确保我们的内容适合该年龄段的用户。

图 13.22 – 目标受众和内容部分

图 13.22 – 目标受众和内容部分

我们的应用针对的是 18 岁以上的群体。下一个问题是应用是否会无意中吸引儿童。

图 13.23 – 对儿童的呼吁

图 13.23 – 对儿童的呼吁

我们选择了选项。这将跳过本节中的广告部分,因为我们表示我们的应用没有广告。

让我们点击下一步继续到下一个步骤,该步骤显示我们答案的摘要:

图 13.24 – 目标受众摘要

图 13.24 – 目标受众摘要

图 13.24所示,如果审查团队不同意本节中的答案,我们将无法更新我们的应用。因此,在回答问题时我们必须谨慎。我们可以保存答案并返回到主要任务列表。返回后,我们可以看到各个任务的进度:

图 13.25 – 设置任务进度

图 13.25 – 设置任务进度

  1. 新闻应用

    在本节中,我们指定我们的应用是否是新闻应用。请注意,我们必须遵守 Google Play 商店关于新闻政策的指南,我们可以在发布新闻应用时了解更多信息。在这里,我们选择选项并保存答案。我们可以返回到主要任务列表。

图 13.26 – 新闻应用部分

图 13.26 – 新闻应用部分

  1. COVID-19 接触追踪和状态应用

    本节适用于与 COVID-19 相关的应用。

图 13.27 – COVID-19 应用部分

图 13.27 – COVID-19 应用部分

我们将选择我的应用不是公开的 COVID-19 接触追踪或状态应用选项并保存答案。

  1. 数据安全

    在本节中,我们披露我们应用收集的数据类型以及我们如何使用这些数据。关于数据的收集、安全性、数据类型和使用的几个问题。如果我们的应用收集个人或敏感数据,我们必须提供隐私政策并在此节中回答问题。

图 13.28 – 数据安全部分

图 13.28 – 数据安全部分

我们选择选项,因为我们应用中不收集任何数据,并保存答案。填写完表格后,我们可以预览我们提供的答案并交叉检查一切是否就绪,如图所示:

图 13.29 – 数据安全摘要

图 13.29 – 数据安全摘要

图 13.29所示,我们可以看到我们答案的摘要和隐私政策链接。你必须确保我们正确回答本节,特别是如果你正在发布收集个人或敏感数据的应用程序。

  1. 政府应用程序

    本节适用于与政府相关的应用程序。

图 13.30 – 政府应用程序部分

图 13.30 – 政府应用程序部分

我们选择选项,因为我们的应用程序不是政府应用程序,并保存答案。

  1. 金融功能

    本节适用于具有金融功能的应用程序。如果我们的应用程序提供个人贷款、银行服务或其他金融服务,我们必须回答本节中的问题。

图 13.31 – 金融功能部分

图 13.31 – 金融功能部分

我们选择我的应用程序不提供任何金融功能选项,因为我们的应用程序不提供任何服务,并保存答案。

  1. 商店设置

    在本节中,我们提供联系详细信息,例如电子邮件地址电话号码网站电子邮件地址字段是唯一必填字段。

图 13.32 – 商店列表联系详细信息

图 13.32 – 商店列表联系详细信息

在填写联系详细信息后,我们还需要指定类别。

图 13.33 – 应用程序类别

图 13.33 – 应用程序类别

选择最能描述你的应用程序功能的类别。

  1. 商店列表

    在本节中,我们完成我们应用程序的详细信息。我们提供关于我们应用程序的简短和完整描述。我们还提供我们应用程序的不同图形。我们必须添加应用程序图标、手机和平板截图以及功能图形。这些信息将显示在我们应用程序的 Google Play 页面上。对于所有图形,我们都提供了所需的尺寸。

图 13.34 – 商店列表部分

图 13.34 – 商店列表部分

在填写所有这些部分后,我们现在准备好将我们的第一个版本推送到 Google Play 商店。如果我们需要编辑前面的任何部分,我们总是可以前往应用程序内容部分和商店设置部分,并编辑我们想要编辑的部分。此外,请注意,自本章编写以来,可能已添加了几个部分。Google Play 商店团队始终向开发者发送他们所做的任何更改的更新。

现在,让我们在下一节创建我们的第一个版本。

创建我们的第一个版本

Google Play 商店提供以下类型的发布:

  • 内部应用程序共享:这允许我们快速与内部团队成员共享我们的应用程序以进行测试。他们通过我们与他们共享的链接访问应用程序。在我们向公众发布之前,如果我们想在小范围内测试我们的应用程序,这非常有用。

  • 内部应用程序测试:这允许我们添加一组测试者——最多 100 人,他们可以自愿测试我们的应用程序。测试者可以是来自我们组织的一组人员或外部测试者。

  • 封闭测试:这允许我们创建一个或多个测试轨道,以便与更大规模的测试者群体测试我们应用程序的预发布版本。

  • 公开测试:这允许任何人自愿测试应用程序。测试者数量没有限制。

  • 生产版本:这是我们应用程序的最终版本。这是在 Google Play 商店对所有用户可用的版本。

对于所有这些发布,我们必须上传一个 Android App Bundle。我们已经有了一个之前创建的 Android App Bundle。让我们转到 测试 部分,并选择 内部测试 选项来创建我们的第一个发布。我们将创建一个 内部 测试发布

  1. 添加测试者

    我们需要创建一个新的电子邮件列表,包含测试者的电子邮件。

图 13.35 – 创建电子邮件列表

图 13.35 – 创建电子邮件列表

只有在这个电子邮件列表中的人才可以通过 Google Play 提供的特殊自愿链接访问应用程序。您可以在保存电子邮件列表后始终访问该链接,如图所示:

图 13.36 – 测试自愿链接

图 13.36 – 测试自愿链接

  1. 创建 一个版本

    在本节中,我们上传我们之前创建的 AAB。在上传之前,我们可以看到我们有一个警告来选择签名密钥,如图 图 13.37* 所示:

图 13.37 – 签名密钥警告

图 13.37 – 签名密钥警告

我们必须选择“Play 签名”选项,以及我们自己的选项。这确保了如果签名密钥丢失,我们可以恢复它。一旦我们选择,我们就可以上传我们的 AAB。选择我们的签名密钥后,我们可以继续创建我们的发布。

图 13.38 – 设置发布轨道

图 13.38 – 设置发布轨道

对于首次发布,请确保 应用 ID 正确,因为一旦上传 AAB,您就无法更改它。一旦我们的包成功上传,我们就可以看到正在填充的发布详情:

图 13.39 – 包详情

图 13.39 – 包详情

点击 下一步 继续处理流程。您将看到以下屏幕:

图 13.40 – 发布摘要

图 13.40 – 发布摘要

这显示了我们的发布摘要以及我们提供的所有详细信息。点击 保存 创建发布,如图所示:

图 13.41 – 内部测试轨道摘要

图 13.41 – 内部测试轨道摘要

我们可以点击 测试者 选项卡,在那里我们可以看到我们添加的测试者以及我们可以与测试者分享的自愿链接。

图 13.42 – 查看测试者

图 13.42 – 查看测试者

我们可以随时添加更多测试人员并按照本节所述的方式创建更多版本。所有其他轨道都有相同的过程,但受众不同。

恭喜您发布了第一个版本!您已经能够将您的第一个应用程序发布到 Google Play 商店。您随时可以返回并编辑应用程序的详细信息。

现在,您可以推广这个版本到生产环境,以便您的应用程序可以在 Google Play 商店上线,如下所示:

图 13.43 – 推广发布

图 13.43 – 推广发布

在下一节中,让我们看看在您开发应用程序时需要考虑的一些政策。

Google Play 商店政策的概述

Google Play 政策是我们发布的一部分。因此,作为开发者,我们必须了解其中大部分,如果不是全部。这是因为如果我们违反了任何政策,我们的应用程序可能会被从 Google Play 商店移除。我们还需要了解它们,因为其中一些政策会影响我们开发应用程序的方式。

在本节中,我们将查看在开发应用程序时我们需要了解的一些政策:

  • 后台位置访问:除非应用程序提供高质量、有益的用户体验,否则应用程序被限制在后台访问用户的地理位置。这是为了确保应用程序不会耗尽用户设备的电池。如果您的应用程序需要在后台访问位置,您必须提供一个令人信服的理由说明为什么它需要在后台访问位置。您还必须提供一个隐私政策,解释您如何使用您收集的位置数据。

  • 数据安全:随着较新版本的 Android,数据安全得到了重视。正如所述,在填写数据安全部分时,我们必须确保我们的应用程序符合政策。我们必须提供隐私政策、条款和条件,并告知用户我们如何使用他们的数据。

  • 金融应用程序:不同国家对金融应用程序有不同的规定。我们必须确保我们了解我们目标国家的规则。一些国家提供许可证,Google Play 要求我们上传这些许可证作为合规性的证明。如果我们正在开发金融应用程序,我们必须确保我们符合规定并拥有必要的许可证。

  • 受限内容:Google Play 有几种类型的受限内容。我们必须检查我们的应用程序是否包含任何受限内容。

  • 知识产权:我们必须确保我们的应用程序不侵犯任何知识产权。我们必须确保我们有权使用应用程序中的任何内容。

  • 使用短信或通话记录权限组:使用短信或通话记录权限组的应用程序需要经过 Google Play 的审核。这是为了确保应用程序不会滥用权限。如果我们的应用程序使用了这些权限中的任何一项,我们必须提交一份权限声明表。

  • 健康内容和服务:Google Play 现在不允许应用暴露用户于有害的健康相关内容。我们必须确保我们的应用不会暴露用户于此类内容。

要了解更多关于这些政策的信息,您可以在 Google Play 控制台帮助页面阅读更多内容(support.google.com/googleplay/android-developer/answer/13837496?hl=en)。该页面提供了关于政策的广泛概述,并包含违规行为的深入示例。您还可以访问 Google Play 学院(playacademy.withgoogle.com/)以了解更多关于 Google Play 的信息。

摘要

在本章中,我们逐步学习了如何在 Google Play Store 发布新应用。我们了解了如何创建签名应用包以及发布我们第一个应用到 Google Play Store 所需的事项。此外,我们还了解了一些 Google Play Store 政策以及如何始终保持合规,以避免我们的应用被移除或账户被禁用。

在下一章中,我们将学习如何使用 GitHub Actions 来自动化一些手动任务,例如将新构建部署到 Play Store。我们将学习如何在持续集成和持续交付CI/CD)管道上运行测试,并使用 GitHub Actions 将构建推送到 Google Play Store。

第十四章:持续集成和持续部署

在我们完成应用的开发和首次部署后,我们必须考虑如何使后续部署的过程更加顺畅,这就是 持续集成/持续交付CI/CD)发挥作用的地方。

在本章中,我们将学习如何使用 GitHub Actions 自动化一些手动任务,例如将新构建部署到 Google Play 商店。我们将学习如何在 CI/CD 管道上运行测试,并使用 GitHub Actions 将构建推送到 Play 商店。

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

  • 设置 GitHub Actions

  • 在 GitHub Actions 上运行代码检查和测试

  • 使用 GitHub Actions 部署到 Play 商店

技术要求

要遵循本章的说明,您需要下载 Android Studio Hedgehog 或更高版本 (developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在 github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterfourteen 找到本章的代码。

设置 GitHub Actions

在我们理解 GitHub Actions 之前,我们需要了解什么是 CI/CD。这是一个允许我们自动化构建、测试和将代码部署到生产环境的过程。CI/CD 不仅自动化了这些流程,还将它们整合成一个单一的连贯管道。这确保了代码更改在部署时更加可靠和稳定。定义应该强调 CI/CD 在促进频繁和可靠更新方面的作用。这是一个特别重要的过程,因为它旨在提高我们交付软件的速度、效率和可靠性。

CI/CD 的好处

让我们来看看 CI/CD 的一些好处:

  • 快速发布周期:CI/CD 允许我们更快、更频繁地发布我们的软件。这是因为我们正在自动化构建、测试和部署代码的过程。

  • 提高协作:由于许多流程都是自动化的,我们可以专注于代码和正在构建的功能。这使得我们能够更有效地与团队协作。

  • 减少手动工作:由于自动化,我们正在减少所做的手动工作。这意味着我们可以专注于代码和正在构建的功能。

  • 提高质量:自动化流程使我们能够更频繁、更有效地测试我们的代码。这意味着我们可以在流程的早期阶段捕捉到错误和漏洞。

现在我们已经了解了 CI/CD 的好处,让我们详细看看 CI/CD 是如何工作的。

CI/CD 的工作原理

让我们来看看 CI/CD 的工作原理:

  • CI:这是自动化构建和测试我们代码的过程。每次我们将代码推送到我们的存储库时,都会执行此操作。这使我们能够更早地捕捉到错误和缺陷。在此步骤中,一旦我们将代码推送到远程存储库(可以是 GitHub、Gitlab、Bitbucket 等),我们就会对这些更改运行检查和测试,以确保它们是功能性的并且符合代码质量标准。如果测试通过,我们可以将代码合并到主分支。如果测试失败,我们可以修复代码并再次运行测试。

  • CD:这是自动化将我们的代码部署到生产环境的过程。每次我们将代码推送到我们的存储库时,都会执行此操作。这使我们能够更快、更频繁地发布我们的软件。这发生在 CI 步骤之后。一旦更改合并到主分支或开发分支,我们就可以将代码部署到生产环境或需要部署到的任何环境。此步骤旨在更频繁地将小更改推送到生产环境。这使我们能够更快、更频繁地发布我们的软件。

在这个背景下,我们现在可以查看 GitHub Actions (docs.github.com/en/actions)。GitHub Actions 是一个 CI/CD 工具,允许我们自动化构建、测试和部署我们的代码。它是内置在 GitHub 中的,并且在使用一定限制内是免费的。它也非常易于使用和设置。

在下一节中,我们将为存储在本存储库中的项目设置 GitHub Actions:github.com/PacktPublishing/Mastering-Kotlin-for-Android

设置 GitHub Actions

要在我们的项目中启用 GitHub Actions,请按照以下步骤操作:

  1. 前往我们的存储库的 操作 选项卡,如图下截图所示:

图 14.1 – GitHub Actions 选项卡

图 14.1 – GitHub Actions 选项卡

此步骤将带我们进入 GitHub Actions 的主页:

图 14.2 – GitHub Actions 主页

图 14.2 – GitHub Actions 主页

  1. 如前图所示,我们有一些建议的操作,我们可以在我们的存储库中使用。目前,我们将自己设置操作,所以让我们点击 自行设置工作流程 选项。这会带我们到以下页面:

图 14.3 – 新的 GitHub Action

图 14.3 – 新的 GitHub Action

如前图所示,我们有一个编辑器用于编写我们的工作流程。注意,在顶部我们现在有一个名为 .github 的新文件夹。这是我们存储工作流程文件的地方。编辑器将工作流程文件保存在 .github/workflows 文件夹中。默认情况下,我们的工作流程命名为 main.yml。在右侧,我们有模板,我们可以使用这些模板轻松创建我们的工作流程。

  1. 目前,我们将创建自己的工作流程,所以请将以下代码添加到我们的工作流程中:

    name: Push
    on:
      push:
        branches: ["main" ]
      workflow_dispatch:
    jobs:
      build:
        name: Build
        runs-on: ubuntu-latest
        steps:
            - run: echo "The job was automatically triggered by a ${{ github.event_name }} event."
    

    让我们理解前面工作流程文件中的不同字段:

    • name: 这是我们的工作流程名称。这将在 GitHub Actions 页面上显示。

    • on: 这是将触发我们的工作流程的事件。在我们的例子中,我们在向主分支推送代码时触发我们的工作流程。

    • workflow_dispatch: 这是一个手动触发器,我们可以使用它从 GitHub Actions 页面触发我们的工作流程。这在我们需要手动触发工作流程时很有用。

    • jobs: 这是在我们的工作流程被触发时运行的作业。在我们的例子中,我们有一个名为 build 的作业。这个作业将在由 runs-on 字段指定的最新版本的 Ubuntu 上运行。

    • steps: 这个字段包含将在我们的作业中运行的步骤。在我们的例子中,我们有一个将运行命令的单个步骤。这个命令将打印出触发我们的工作流程的事件。一个步骤可以包含一个 shell 命令或来自 GitHub Marketplace 的动作。

  2. 点击 提交更改... 按钮。这将把我们的工作流程文件提交到我们的仓库并触发我们的工作流程。我们可以在 操作 选项卡中看到工作流程正在运行,如下面的截图所示:

图 14.4 – 第一个 GitHub Action

图 14.4 – 第一个 GitHub Action

在前面的图像中,我们可以看到触发工作流程的提交和工作流程本身。我们还可以看到运行的作业和步骤。我们还可以看到步骤的输出。此外,我们还可以看到运行工作流程所需的时间。如果我们点击动作,我们可以看到更多详细信息:

图 14.5 – Github Action 详细信息

图 14.5 – Github Action 详细信息

这显示了运行的步骤和作业运行的时间。它还显示了工作流程的总持续时间。

这是一个简单的只打印出触发工作流程的事件的工作流程。我们也可以在我们的工作流程中做更复杂的事情。

让我们看看我们如何在我们的工作流程中设置与 Android 相关的操作:

  1. 前往新创建的 .github/workflows 文件夹并编辑 main.yml 文件。

  2. 让我们在上一节中 步骤 3 运行的命令下方添加以下代码:

    - name: Checkout
      uses: actions/checkout@v3
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'zulu'
        cache: gradle
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
      working-directory: ./chapterfourteen
    - name: Build with Gradle
      run: ./gradlew assembleDebug
      working-directory: ./chapterfourteen
    

    让我们理解前面的代码:

    • 我们创建了一个名为 Checkout 的另一个步骤。这个步骤将从我们的仓库中 checkout 我们的代码。这是通过使用 checkout 动作完成的,我们使用 uses 字段指定了这个动作。这个动作是从 GitHub Marketplace 获取的。

    • 我们创建了一个名为 Set up JDK 17 的另一个步骤。这个步骤将设置 JDK 17。我们使用 uses 字段指定了 setup-java 动作。我们使用 java-version 字段指定了我们想要使用的 Java 版本。我们使用 distribution 字段指定了我们想要使用的 Java 发行版。我们还使用 cache 字段指定了我们要缓存 Gradle。

    • 我们创建了一个名为Grant execute permission for gradlew的另一个步骤。此步骤将为 gradlew 授予执行权限。这是通过run命令完成的。我们使用run字段指定我们想要运行的命令。我们还使用working-directory字段指定我们想要运行命令的工作目录。在这种情况下,我们希望在chapterfourteen文件夹中运行命令,因为我们仓库中有许多文件夹。请注意,此步骤是平台相关的,因为它对于基于 Unix 的系统是必要的,但对于基于 Windows 的系统可能是多余的。因此,它可能不是所有 CI/CD 设置中必需的。

    • 最后,我们创建了一个名为Build with Gradle的另一个步骤。此步骤将使用 Gradle 构建我们的项目。在这里,我们运行./gradlew assembleDebug命令。此命令为我们项目生成一个调试 Android APK。我们还指定了要运行命令的工作目录。

    有一个需要注意的事项是.yml文件对缩进非常敏感。因此,我们需要确保我们的代码缩进正确。

  3. 提交更改后,操作将自动运行。我们可以查看工作流程结果,并查看作业构建,我们可以看到所有运行的步骤,如下面的截图所示:

图 14.6 – GitHub 操作步骤

图 14.6 – GitHub 操作步骤

现在我们已经了解了 GitHub 操作是什么,创建了我们的第一个操作,并看到了如何在 GitHub 操作上运行 Android 特定的工作流程。在下一节中,我们将在工作流程中运行 lint 检查和测试。

在 GitHub 操作上运行 lint 检查和测试

第十一章中,我们学习了如何使用终端上的 shell 命令在我们的项目中运行 lint 检查。我们还学习了如何为我们的代码库编写测试。在本节中,我们将逐步在我们的新创建的操作上运行格式、lint 检查和测试:

  1. 首先,我们将添加ktlintCheck步骤:

    - name: Run ktlintCheck
      run: ./gradlew ktlintCheck
      working-directory: ./chapterfourteen
    

    在此代码中,我们添加了一个名为Run ktlintCheck的步骤。此步骤将运行ktlintCheck命令,该命令将检查我们的代码是否格式正确。如果我们的代码格式不正确,此步骤将失败。

  2. 接下来,我们添加detekt步骤:

    - name: Run detekt
      run: ./gradlew detekt
      working-directory: ./chapterfourteen
    

    在此步骤中,我们运行detekt命令,该命令将在第十一章中较早设置的代码上运行 detekt 检查。如果我们的代码未通过 detekt 检查,此步骤将失败。

  3. 接下来,我们添加测试步骤:

    - name: Run unit tests
      run: ./gradlew testDebugUnitTest
      working-directory: ./chapterfourteen
    

    此步骤将在我们的项目中运行所有单元测试。如果任何测试失败,此步骤将失败。

  4. 最后,我们添加运行仪器化测试的步骤:

    - name: Run connected tests
      uses: ReactiveCircus/android-emulator-runner@v2
      with:
        working-directory: ./chapterfourteen
        api-level: 33
        target: google_apis
        arch: x86_64
        disable-animations: true
        script: ./gradlew connectedCheck
    

    此步骤使用android-emulator-runner操作来在模拟器上运行我们的仪器化测试。此操作在 CI 环境中设置模拟器以运行我们的仪器化测试。在操作配置中,我们设置了以下内容:

    • 工作目录:这是我们的项目所在的位置。

    • api-level:这是我们模拟器平台系统镜像的 API 级别。

    • target:这是模拟器系统镜像的目标。

    • 架构:我们指定要在其上运行测试的模拟器的架构。

    • disable-animations:我们在模拟器中禁用动画。

    • 最后,我们使用脚本字段指定我们想要运行的命令。在这种情况下,我们运行connectedCheck任务,这将运行我们的仪器化测试。

  5. 在进行上述更改后,提交更改,操作将运行。我们可以在操作标签中查看操作的结果,如下面的截图所示:

图 14.7 – 更多 Github Actions 步骤

图 14.7 – 更多 Github Actions 步骤

我们通过增加额外的步骤来扩展我们的工作流程,以执行 lint 检查和测试。我们可以看到每个步骤的结果。我们还可以看到运行每个步骤所需的时间。运行连接测试步骤运行时间最长。这是因为它必须设置模拟器并运行测试。

我们需要修改main.yml文件运行的时间。目前,我们的工作流程在我们向主分支推送代码时运行。我们将将其更改为在向主分支创建拉取请求时也运行。这是因为我们希望在将代码移动到主分支之前运行我们的检查。为此,我们将在workflow_dispatch事件之上添加pull_request事件:

on:
  push:
    branches: ["main" ]
  pull_request:
  workflow_dispatch:

在进行此更改后,我们可以提交更改,操作将运行。现在,让我们创建一个拉取请求来测试这些更改。确保在执行以下步骤之前,您在我们的浏览器本地拉取了我们所做的所有更改:

  1. 首先,让我们创建一个名为test的新分支。

  2. 在 Android Studio 中打开终端并运行以下命令:

    test and switches to the newly created branch.
    
  3. 接下来,让我们修改应用级别的build.gradle.kts文件中的versionNameversionCode

    versionCode = 2
    versionName = "1.0.1"
    
  4. 点击立即同步以将这些更改同步到我们的项目中。

  5. 在对versionNameversionCode进行更改后,我们可以提交更改并将它们推送到我们的远程仓库。我们可以在终端中运行以下命令来完成此操作:

    it add .
    

    此命令将我们已做的所有更改暂存。

  6. 接下来,我们运行以下命令:

    git commit -m "Update app version name and code"
    

    此命令提交我们已做的更改。

  7. 接下来,我们运行以下命令:

    git push origin test
    

    此命令将更改推送到我们的远程仓库。

  8. 接下来,在浏览器中转到我们的仓库,打开拉取请求标签,然后点击新建拉取请求按钮。这将打开以下页面:

图 14.8 – 创建新的拉取请求

图 14.8 – 创建新的拉取请求

在此页面上,我们设置了base分支和compare分支。base分支是我们想要合并更改的分支。在我们的例子中,我们想要将更改合并到main分支。compare分支是包含最近更改的分支。在我们的例子中,我们想要将test分支的更改合并到main分支。我们可以在设置compare分支后立即看到我们所做的更改。

  1. 通过点击创建拉取请求按钮来最终确定拉取请求。创建拉取请求后,我们可以查看拉取请求的详细信息:

图 14.9 – 拉取请求检查

图 14.9 – 拉取请求检查

如前图所示,工作流程检查已经开始运行,因为我们创建了一个拉取请求,并指定了当创建拉取请求时应该运行我们的工作流程。由于工作流程仍在运行,合并拉取请求按钮被禁用。

一旦工作流程运行完成,我们可以合并拉取请求。我们可以根据检查强制执行更进一步的规则,但现在,我们默认行为就足够了。一旦工作流程完成并且所有检查都通过,我们应该看到以下内容:

图 14.10 – 拉取请求检查完成

图 14.10 – 拉取请求检查完成

我们现在已经学会了如何在 GitHub Actions 上运行 lint 检查和测试。在下一节中,我们将学习如何使用 GitHub Actions 将我们的应用程序部署到 Google Play Store。

使用 GitHub Actions 部署到 Play Store

第十三章中,我们学习了如何使用 Google Play Console 将我们的应用程序部署到 Google Play Store。然而,在第十三章中,我们是手动完成的。在本章中,我们将学习如何使用 GitHub Actions 将我们的应用程序部署到 Google Play Store。我们将使用 Google Play Publisher action 将我们的应用程序部署到 Google Play Store。这个操作可在 GitHub Marketplace 中找到。

在我们能够编写我们的工作流程之前,我们需要做一些设置。我们需要在我们的 Google Play Store 账户上创建一个服务账户。我们可以通过以下步骤来完成:

  1. 按照以下步骤在 Google Cloud Platform 中配置服务账户:

    1. 导航到cloud.google.com/gcp

    2. 导航到GOOGLE_SERVICES_JSON。这是我们将在工作流程中使用以访问 JSON 文件的名字。

  2. 按照以下步骤将用户添加到 Google Play Console:

    1. 打开play.google.com/console并选择您的开发者账户。

    2. 打开用户 和权限

    3. 点击邀请新用户并添加步骤 1 中创建的服务账户的电子邮件。

    4. 授予服务账户部署的应用程序内权限。

    如果您需要更多关于如何操作的详细信息,您可以查看以下链接:developers.google.com/android/management/service-account

就像我们在仓库秘密中创建GOOGLE_SERVICES_JSON变量一样,我们需要将我们的签名证书的详细信息添加到我们的变量中,这样我们就可以在 CI/CD 管道中使用它们。第一步是生成签名证书的base64编码版本。我们可以在终端中运行以下命令来完成此操作:

openssl base64 < packt.jks | tr -d '\n' | tee packt.jks.base64.txt

您应该在保存您的密钥库文件的目录中运行此命令。如果您将其命名为不同的名称,可以将其更改为与密钥库文件名匹配。此命令将生成密钥库文件的base64编码版本。然后我们可以复制文件内容并将其添加到我们的仓库秘密中。我们还需要将以下秘密添加到我们的仓库中:

图 14.11 – 仓库秘密

图 14.11 – 仓库秘密

新创建的秘密解释如下:

  • KEYSTORE_PASSWORD: 这是我们的密钥库文件的密码

  • KEY_ALIAS: 这是我们的密钥库文件的别名

  • KEY_PASSWORD: 这是我们的密钥库文件别名的密码

所有这些细节都应该像我们创建密钥库文件时使用的那样。现在,让我们编写我们的工作流程。在编写工作流程之前,请确保您已经完成了第十三章中我们的应用程序发布步骤,因为这是此操作所需。让我们前往.github/workflows文件夹,创建一个名为deploy-to-playstore.yml的新文件,并添加以下代码:

name: Deploy to Playstore
on:
  push:
    branches: [ "main"]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: set up JDK 17
        uses: actions/setup-java@v3
        with:
          distribution: 'zulu'
          java-version: '17'
      - name: Bump version
        uses: chkfung/android-version-actions@v1.1
        with:
          gradlePath: chapterfourteen/build.gradle.kts
          versionCode: ${{github.run_number}}
          versionName: ${{ format('1.0.{0}', github.run_number ) }}
      - name: Assemble Release Bundle
        working-directory: chapterfourteen
        run: ./gradlew bundleRelease
      - name: Deploy to Internal Testing
        uses: r0adkll/upload-google-play@v1.1.1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_SERVICES_JSON }}
          packageName: com.packt.chapterthirteen
          releaseFiles: chapterfourteen/build/outputs/bundle/release/app-release.aab
          track: internal
          whatsNewDirectory: whatsnew/
          status: completed

工作流程与我们之前在设置 GitHub Actions在 GitHub Actions 中运行 lint 检查和测试部分创建的工作流程非常相似,只有细微的差别。我们有一个步骤可以自动增加versionNameversionCode,而不是每次都手动进行。版本号作为不同软件版本的有序标识符。采用语义版本控制有助于传达更改的影响,区分主要不兼容更新、次要兼容性功能添加和补丁级别的错误修复。它在依赖关系管理中发挥着关键作用,促进不同组件之间的兼容性。此外,版本控制支持回滚、热修复和高效测试,确保应用程序的稳定性。发布说明和沟通流程简化,为用户和利益相关者提供对每个发布的清晰洞察。最终,版本控制有助于提供可靠和可预测的用户体验,在整个软件开发生命周期中促进信任和透明度。

我们还有另一个步骤,它构建了一个名为 upload-google-play 的签名操作,这自动化了过程并使其变得更简单。我们在该操作上执行配置,例如指定我们的服务账户、我们在 Play Store 上的应用包名、我们的签名 AAB 将被找到的目录,以及最后,我们想要部署的轨道。将更改推送到主分支将再次触发操作,一旦 deploy-to-playstore 工作流程完成,我们应该在我们的 Play Store 页面上看到一个新的内部测试版本,如下所示:

图 14.12 – 新的内部测试版本

图 14.12 – 新的内部测试版本

我们已经完成了 CI/CD 流程的建立。我们只设置一次这个环境,并且我们可以始终使用它来简化我们的部署和自动化测试,使其更快、更可靠。

摘要

在本章中,我们学习了如何使用 GitHub Actions 自动化一些手动任务,例如将新构建部署到 Play Store。此外,我们还学习了如何在 CI/CD 管道上运行 lint 检查和测试,并使用 GitHub Actions 将构建推送到 Google Play Store。

在下一章中,我们将学习通过添加分析、使用 Firebase Crashlytics 以及使用云消息来增加我们应用的用户参与度的技术。此外,我们还将学习一些确保我们应用安全性的技巧和窍门。

第十五章:提升应用

在我们完成应用的开发和发布后,在使用有助于改进我们应用的事物时,如 Firebase 消息传递或 Crashlytics,始终保持警觉是很重要的。在本章中,我们将学习如何使用 Firebase 消息传递和 Crashlytics。

在本章中,我们将逐步学习如何通过添加分析(Firebase Crashlytics)和使用云消息来提高用户参与度来改进我们的应用。我们将学习如何从 Firebase 控制台向我们的应用发送通知。此外,我们还将学习一些确保用户数据不受损害的应用安全技巧和窍门。

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

  • 使用 Firebase Crashlytics 检测崩溃

  • 使用 Firebase 消息传递提高应用参与度

  • 保护您的应用

技术要求

要遵循本章中的说明,您需要下载 Android Studio Hedgehog 或更高版本(developer.android.com/studio)。

您可以使用上一章的代码来遵循本章的说明。您可以在 GitHub 上找到本章的代码,地址为 github.com/PacktPublishing/Mastering-Kotlin-for-Android/tree/main/chapterfifteen

使用 Firebase Crashlytics 检测崩溃

应用程序可能因多种原因发生崩溃,包括常见的编码问题,如空指针异常、内存泄漏和不正确的数据处理。由不同设备硬件配置和不同 Android 操作系统引起的设备碎片化可能会引入兼容性问题,有时也会导致崩溃。网络问题、资源不足或外部依赖(如第三方库)的管理不当也可能导致崩溃。有时,我们可以预见并优雅地处理它们。其他时候,它们是意外的,我们需要了解它们以便修复它们。我们的应用已经在 Google Play 商店上,所以有时我们可能没有在出现问题的设备上进行调试的奢侈。像 Firebase Crashlytics 这样的工具可以帮助我们检测应用中的崩溃,并提供我们修复它们所需的信息。在本节中,我们将设置 Firebase Crashlytics 到我们的应用中,并了解我们如何使用它来检测崩溃。

Android Studio 内置了 Firebase 工具,可以帮助我们快速将 Firebase 添加到我们的应用中。我们可以从 工具 | Firebase 访问它,它应该会打开右侧的侧面板,如图下所示:

图 15.1 – Firebase 设置

图 15.1 – Firebase 设置

图 15.1 所示,我们可以使用此工具设置各种 Firebase SDK。我们感兴趣的是设置 Crashlytics。点击 Crashlytics 选项,我们将看到以下选项:

图 15.2 – Firebase Crashlytics 设置

图 15.2 – Firebase Crashlytics 配置

由于我们的项目是用 Kotlin 编写的,我们将选择开始使用 Firebase Crashlytics作为选项。这将打开一个新窗口,其中包含以下说明:

图 15.3 – 设置 Crashlytics 的步骤

图 15.3 – 设置 Crashlytics 的步骤

图 15.3所示,它提供了我们设置应用中 Firebase Crashlytics 所需的所有步骤。让我们点击连接到 Firebase选项。这将在我们浏览器中打开一个新的标签页,并显示 Firebase 控制台(见图 15.4)。它显示了我们在控制台上的所有 Firebase 项目(如果我们有任何)。使用创建项目选项并指定您首选的项目名称:

图 15.4 – 新的 Firebase 项目

图 15.4 – 新的 Firebase 项目

点击继续进入下一步。这将带我们到以下屏幕:

图 15.5 – Google Analytics 设置

图 15.5 – Google Analytics 设置

在此屏幕上,我们为我们的应用配置 Google Analytics。它还显示了 Google Analytics 为我们应用提供的功能。Google Analytics 收集我们应用的用法和行为数据。我们使用它来跟踪用户事件、系统事件或错误,分析应用性能,收集用户属性,如语言偏好和地理位置等。

点击继续进入下一步:

图 15.6 – 配置 Google Analytics

图 15.6 – 配置 Google Analytics

在此步骤中,我们为 Google Analytics 配置更多选项。我们必须选择一个 Google Analytics 账户。如果您已经配置了其他项目,选择默认账户将隐藏其他问题。完成后,点击创建项目以完成创建项目,您将看到以下对话框:

图 15.7 – 完成设置对话框

图 15.7 – 完成设置对话框

在我们的项目创建完成后点击连接,将使用 Android Studio 完成 Firebase Crashlytics 的设置。让我们回到 Android Studio 看看有什么变化。我们可以看到 Firebase Crashlytics 选项现在已被勾选:

图 15.8 – 应用已连接

图 15.8 – 应用已连接

下一步是将 Firebase Crashlytics SDK 添加到我们的应用中。我们可以通过点击将 Crashlytics SDK 和插件添加到您的应用中按钮来完成此操作。这将为我们应用添加必要的依赖项。您可以通过检查 Gradle 文件来查看这些更改。

所有步骤完成后,我们现在可以在MainActivity.kt文件中的onCreate()函数内添加以下代码:

throw RuntimeException("Test Crash")

现在,运行应用。应用将崩溃并显示以下堆栈跟踪:

图 15.9 – 崩溃堆栈跟踪

图 15.9 – 崩溃堆栈跟踪

我们可以转到 Firebase 控制台中我们新创建的项目,看看是否已经报告了崩溃。在 Firebase 控制台中,Crashlytics 通常位于导航抽屉中的发布和监控部分,位于屏幕的左侧。它显示了以下屏幕:

图 15.10 – Firebase 控制台崩溃概述

图 15.10 – Firebase 控制台崩溃概述

我们可以看到崩溃已经被报告。我们可以点击MainActivity.onCreate崩溃来查看更多详细信息。它显示了以下屏幕:

图 15.11 – Firebase 控制台崩溃详情

图 15.11 – Firebase 控制台崩溃详情

图 15.10所示,我们从 Firebase 控制台中获得了以下详细信息:

  • 堆栈跟踪:这显示了崩溃的堆栈跟踪。

  • 设备:这显示了崩溃发生的设备。我们可以看到设备型号、操作系统版本和设备状态。

  • 应用版本:这显示了崩溃的应用版本。它还显示了该版本发生的总崩溃次数。

当调试崩溃时,这些信息非常有帮助。Android Studio Hedgehog 和更早的版本也提供了一个有用的工具,App Quality Insights,它可以帮助我们从 Android Studio 中直接查看 Firebase 崩溃。我们可以从底部的工具标签中访问它,如下所示:

图 15.12 – App Quality Insights

图 15.12 – App Quality Insights

图 15.11所示,我们需要登录到包含我们正在工作的项目的 Firebase 账户。我们可以通过点击登录按钮并在浏览器中完成登录过程来实现这一点。登录后,我们可以看到应用中发生的崩溃:

图 15.13 – App Quality Insights 崩溃详情

图 15.13 – App Quality Insights 崩溃详情

这显示了所有详细信息,正如我们在 Firebase 控制台中之前看到的。我们也可以看到堆栈跟踪。它的好处是我们可以轻松地导航到导致崩溃的文件和行,而无需切换上下文并转到浏览器。

在设置 Firebase Crashlytics 后,我们现在可以检测应用中的崩溃并修复它们。这将帮助我们提高应用的质量,并让我们的用户感到满意。

重要提示

记得从我们的应用中移除我们添加的强制崩溃的代码。

接下来,让我们设置Firebase Analytics,它也会收集有关我们应用的有用信息。这将帮助我们了解用户如何使用我们的应用,并帮助我们做出有关如何改进应用的明智决策。

设置 Google Analytics

设置 Google Analytics 与设置 Firebase Crashlytics 类似。重复您为 Firebase Crashlytics 执行的以下步骤:

  1. 工具 | Firebase打开 Firebase 工具。

  2. 从类似图 15.1中看到的选项列表中选择Analytics

  3. 选择开始使用 Google Analytics选项。

  4. 由于我们的应用已经连接到 Firebase,我们可以跳过第一步,直接进行下一步。

  5. 点击 将分析添加到您的应用 按钮以将必要的依赖项添加到我们的应用中。

  6. 一旦 Gradle 同步完成,我们已经在应用中完成了 Firebase Analytics 的设置。

设置完成后,我们总是可以从 Firebase 控制台中查看分析。我们可以从导航抽屉中的 分析 部分访问它。它显示了以下屏幕:

图 15.14 – 应用分析

图 15.14 – 应用分析

我们已经学习了如何在应用中设置 Firebase Crashlytics 和 Firebase Analytics。现在我们可以检测应用中的崩溃并收集有关应用的有用信息。这将帮助我们提高应用的质量,并让我们的用户满意。

在下一节中,我们将学习如何使用 Firebase Cloud Messaging 向我们的应用发送通知。

使用 Firebase 消息传递提高应用参与度

当用户安装我们的应用后,他们完成他们想要做的事情后可能不会再使用它。这可能导致活跃用户数量的下降,对不同应用的影响可能不同。

我们可以利用 Firebase Cloud Messaging 向用户发送通知,提醒他们使用我们的应用。这将帮助我们提高应用的参与度,并增加活跃用户数量。在本节中,我们将设置 Firebase Cloud Messaging 到我们的应用中,并查看我们如何使用它向应用用户发送通知。

首先,我们需要在我们的应用上设置 Firebase Cloud Messaging SDK。这允许我们在项目中使用 SDK,并在设置完成后使我们的应用能够接收 Firebase 通知。我们将以与 Firebase Crashlytics 和 Firebase Analytics 相同的方式进行操作。重复以下步骤:

  1. 工具 | Firebase 打开 Firebase 工具。

  2. 从类似于我们在 图 15.1 中看到的选项列表中选择 云消息传递

  3. 选择 设置 Firebase 云消息传递 选项。

  4. 由于我们的应用已经连接到 Firebase,我们可以跳过第一步,直接进行下一步。

  5. 点击 将 FCM 添加到您的应用 按钮以将必要的依赖项添加到应用中。

  6. 一旦 Gradle 同步完成,FCM SDK 已经在我们的应用中设置好了。

我们需要创建一个新的服务来处理接收到的通知,并获取设备令牌。让我们创建一个新的包,命名为 firebase。在这个包内部,让我们创建一个新的文件,命名为 FirebaseMessagingService.kt。这将是我们处理接收到的通知的服务。

让我们添加以下代码:

class FirebaseNotificationService: FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        super.onNewToken(token)
        Log.d("Firebase Token", token)
    }
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        sendNotification(remoteMessage)
    }
    private fun sendNotification(remoteMessage: RemoteMessage) {
        val notification = NotificationCompat.Builder(applicationContext, "Pets Apps")
            .setContentTitle(remoteMessage.notification?.title)
            .setTicker(remoteMessage.notification?.ticker)
            .setContentText(remoteMessage.notification?.body)
            .setContentInfo(remoteMessage.notification?.body)
             .setStyle(NotificationCompat.BigTextStyle().bigText(remoteMessage.notification?.body))
            .setSmallIcon(R.drawable.ic_launcher_background)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            createChannel(notification, "Pets Apps")
        }
    }
    @RequiresApi(Build.VERSION_CODES.O)
    private fun createChannel(notificationBuilder: NotificationCompat.Builder, id: String) {
        notificationBuilder.setDefaults(Notification.DEFAULT_VIBRATE)
        val channel = NotificationChannel(
            id,
            "Pets Apps",
            NotificationManager.IMPORTANCE_HIGH
        )
        channel.description = "Pets Apps"
        val notificationManager: NotificationManager =
            getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.createNotificationChannel(channel)
    }
}

以下是对前面代码的分解:

  • 我们创建了一个名为 FirebaseNotificationService 的类,它扩展了 FirebaseMessagingService。这是处理接收到的通知的服务。

  • 我们重写了onNewToken()函数。当生成新令牌时,会调用此函数。我们可以使用此令牌向我们的应用发送通知。我们添加了一个日志消息来记录令牌到我们的 Logcat。或者,如果我们有这样的需求,我们可以将令牌发送到我们的后端服务器,用于向我们的应用发送通知。

  • 我们重写了onMessageReceived()函数。当接收到通知时,会调用此函数。我们调用了sendNotification()函数,并将RemoteMessage对象传递给它。

  • sendNotification()函数创建一个通知并将其显示给用户。我们使用了NotificationCompat.Builder类来创建通知。我们还使用了RemoteMessage对象来获取通知的标题正文提示符。我们还设置了一个用于通知的小图标。此外,如果设备运行在 Android Oreo 或更高版本,我们还设置了通知渠道。

  • 最后,我们创建了createChannel()函数,该函数使用NotificationChannel类创建通知渠道。我们还设置了渠道描述和渠道的重要性。此外,我们还设置了渠道的默认振动。最后,我们创建了通知管理器,并使用它来创建渠道。

服务创建后,我们需要在AndroidManifest.xml文件中注册它。让我们向其中添加以下代码:

<service
    android:name=".firebase.FirebaseNotificationService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

服务注册完成后,我们现在可以在我们的应用中接收通知。运行带有最近更改的应用。

接下来,让我们通过从 Firebase 控制台发送通知来测试这个功能。我们可以通过访问 Firebase 控制台,并在导航抽屉中选择云消息选项(位于参与类别中)来完成此操作。它显示了以下屏幕:

图 15.15 – Firebase 云消息登录页面

图 15.15 – Firebase 云消息登录页面

这是第一次使用云消息,因此我们需要创建一个新的活动。点击创建您的第一个活动按钮。这将打开一个包含以下选项的新对话框:

图 15.16 – Firebase 云消息选项

图 15.16 – Firebase 云消息选项

我们可以发送Firebase 通知消息Firebase 应用内消息。Firebase 通知旨在让用户在应用外接收消息,即使我们的应用处于非活动状态,也能通过推送通知传递消息。相比之下,Firebase 应用内消息旨在通过在应用界面内直接显示内容来吸引正在积极使用我们的应用的用户。Firebase 通知适用于发送及时更新或促销,而应用内消息对于传递上下文内容并增强我们应用内的用户体验非常有效。我们对Firebase 通知消息感兴趣。选择Firebase 通知消息选项并点击创建。这会带我们到以下屏幕:

图 15.17 – 创建新的通知

图 15.17 – 创建新的通知

在此屏幕上,我们添加通知的标题和文本。一旦填写了这些详细信息,点击下一步以进入下一步:

图 15.18 – 目标和调度设置

图 15.18 – 目标和调度设置

图 15.17所示,我们为我们的通知设置了目标应用。在这种情况下,它是我们在本章使用 Firebase Crashlytics 检测崩溃部分中较早创建的应用。然后我们为我们的通知设置了调度。我们将调度设置设置为立即发送。点击查看按钮,它将显示包含我们已设置信息的对话框:

图 15.19 – 查看消息对话框

图 15.19 – 查看消息对话框

点击发布按钮以发布通知。这将向我们的应用发送通知。我们现在可以从我们的应用中看到通知:

图 15.20 – 第一条通知

图 15.20 – 第一条通知

重要提示

确保您始终使用真实设备进行测试,以查看您的通知。

我们学习了如何在应用中设置 Firebase 云消息以及如何向其发送通知。在下一节中,我们将介绍另一个关键主题——保护我们的应用。

保护您的应用

确保您的应用安全尤其重要。我们需要确保我们的用户数据安全。我们还需要确保我们的应用不受攻击。如恶意软件、中间人攻击和数据拦截等攻击对敏感信息构成风险,而如 SQL 注入和权限提升等漏洞可能导致对数据库或应用功能的未授权访问和操纵。跨站脚本和代码注入为攻击者提供了在应用内执行恶意脚本或命令的途径,可能危及用户会话和数据。不安全的数据存储实践可能会泄露敏感信息,而拒绝服务攻击可能会中断应用服务。

在本节中,我们将看到一些关于如何保护我们的应用程序的技巧和窍门。以下是我们可以采取的一些措施来保护我们的应用程序:

  • 对于我们所有的网络请求使用HTTPS,这为我们的应用程序添加了一个额外的安全层。

  • 代码精简和混淆:我们应该始终精简和混淆我们的代码,以使攻击者更难逆向工程我们的应用程序。我们已经在第十三章中为我们的发布构建做了这件事。

  • 加密:我们应该始终加密我们存储在应用程序中的敏感数据。

  • 密码和私钥:永远不要在 Shared Preferences 中存储密码和私钥。我们可以始终将它们存储在其他安全的替代方案中,例如用于存储加密密钥的 Android Keystore 系统。

  • 最小化日志信息:我们应该始终最小化我们记录的信息。我们永远不应该记录敏感信息,如密码和私钥。

  • 内部存储:我们应该始终使用内部存储来存储敏感数据。这是因为内部存储仅对我们应用程序是私有的,其他应用程序无法访问它。

  • WebView:我们使用 WebView 在我们的应用程序中显示网页内容。这可能会在我们的应用程序中引入安全问题,因此在使用 WebView 时我们应该小心。

  • 依赖项:我们应该始终保持所有依赖项的最新状态。这是因为依赖项的新版本可能包含我们需要应用到我们的应用程序中的安全修复。我们使用如 Dependabot(github.com/dependabot)这样的工具来自动更新依赖项。

  • 模拟器或 rooted 设备:对于支付或银行应用程序,确保它们在模拟器或 rooted 设备上不工作。在模拟器和 rooted 设备上,很容易更改您的代码或查看发送到服务器或存储在我们应用程序中的数据。这可能导致安全问题。

  • 权限:我们应该始终在我们的应用程序中使用必要的权限。我们不应该使用我们不需要的权限。这是因为权限可以用来访问我们应用程序中的敏感数据。

在您开发应用程序时,始终牢记安全性。这将帮助您确保您的应用程序是安全的,并且您的用户数据是安全的。您可以从官方 Android 文档中了解更多关于 Android 安全的信息:developer.android.com/privacy-and-security/security-tips

总结

在本章中,我们学习了通过添加分析(在这种情况下,是 Firebase Crashlytics)来改进我们的应用程序的技术,以及如何使用云消息来增加我们应用程序的用户参与度。此外,我们还学习了关于保护我们应用程序的一些技巧和窍门。

我们已经到达了本章和本书的结尾。我们希望您喜欢跟随章节,并且现在您已经具备了从本书中获得的知识来开发 Android 应用程序。下一步是什么?以下是一些您可以做的事情:

  • 使用本章中介绍的小技巧和安全建议来保护您的应用。

  • 检查、改进和监控您应用的性能。您可以从官方文档中了解更多信息,文档地址为 developer.android.com/topic/performance/overview

  • 了解更多关于现代安卓开发MAD)的信息。MAD 是一套工具和库,帮助我们更快、更好地开发安卓应用。您可以从官方文档中了解更多关于 M.A.D 的信息,文档地址为 developer.android.com/modern-android-development

  • 了解更多关于 Google Play Vitals 以及如何使用 play.google.com/console/about/vitals/ 上的信息来提升您应用的质量。

  • 继续学习 Kotlin 和 Android。构建更多应用并与世界分享。您还可以为开源项目做出贡献。

posted @ 2025-10-26 08:59  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报