精通-Kotlin-和安卓-14-全-

精通 Kotlin 和安卓 14(全)

原文:zh.annas-archive.org/md5/20b282b75fbf367b6c82884dcdb56d52

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

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

这本书面向的对象

这本书面向有志成为 Android 开发者或正在使用 Java 进行 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 Coroutines 进行网络调用,讨论了如何使用网络库 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

分享您的想法

一旦您阅读了《精通 Kotlin for Android 14》,我们很乐意听听您的想法!请点击此处直接转到该书的亚马逊评论页面并分享您的反馈。

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

下载这本书的免费 PDF 副本

感谢您购买这本书!

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

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

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 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 背景 developer 的有用提示。

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

  • Kotlin 简介

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

  • 从 Java 迁移到 Kotlin

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

技术要求

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

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

Kotlin 简介

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

  • 冗长性: Java 有一个非常冗长的语法,这导致开发者即使在简单任务中也会编写大量的样板代码。

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

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

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

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

经过多年的发展,Kotlin 已经演变为多平台和服务器端语言,并且不再局限于服务端,还被用于数据科学。Kotlin 在一些特性上比 Java 有优势,如下所示:

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

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

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

  • 数据类:Kotlin 内置了数据类构造,这使得定义主要用于存储数据的类变得容易。数据类会自动生成 equals()hashCode()toString() 方法,减少了所需的样板代码量。

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

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

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

Kotlin 在这些年来已经发展,以支持以下不同的平台:

  • Kotlin 多平台:这用于开发针对不同平台的应用程序,如 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 有一个丰富的类型系统,具有以下类型:

  • 可空类型:在 Kotlin 中,每个类型都可以是可空的或不可空的。可空类型用问号运算符表示 – 例如,String?。不可空类型是正常的类型,结尾没有任何运算符 – 例如,String

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

  • 类类型:由于 Kotlin 是一种面向对象的编程语言,它提供了对类、密封类、接口等的支持。你使用 class 关键字定义一个类,并且可以添加方法、属性和构造函数。

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

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

    当你没有指定类型时,Kotlin 会自动推断类型。

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

  • 枚举类型:这些用于定义一组固定的选项。Kotlin 有 Enum 关键字供你声明枚举。

  • 函数类型:Kotlin 也是一种函数式语言,这意味着函数被视为一等公民。你可以将函数赋值给变量,从函数中返回函数值,并将函数作为参数传递给其他函数。要定义一个函数类型,你使用 (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,当它们组合在一起时,形成你项目的唯一标识符。

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

图 1.2 – 项目结构

图 1.2 – 项目结构

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

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

  2. 选择新建然后新建 Kotlin 类/文件。从出现的列表中选择文件选项,并将文件命名为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),它接受一个名称作为参数。在函数内部,我们将参数添加到我们的配料列表中。

  • 最后,我们有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 文件。

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

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

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

摘要

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

在下一章中,我们将学习如何使用 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 后,您需要下载 SDKs 并设置好一切,以便它准备好使用。打开您新安装的 Android Studio。您将看到以下欢迎屏幕:

图 2.1 – Android Studio 欢迎屏幕

图 2.1 – Android Studio 欢迎屏幕

在右上角,我们有以下快速选项:

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

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

  • 从 VCS 获取VCS代表版本控制系统。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 的应用程序,可以选择汽车

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

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

  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 Scripts: 在这里,我们包含了项目所需的全部 Gradle 脚本和 Gradle 属性文件。在我们的新项目中,我们有以下文件:

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

    • build.gradle (Module: 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 技巧与窍门

在本节中,我们将了解一些有用的技巧、快捷方式和 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,我们可以看到 MainActivity.kt 文件。从该标签页单击方法可以快速导航到我们的代码中的方法。在 图 2**.14 中,还有一个图标,用红色突出显示,显示继承的方法。当我们点击这个图标时,它会显示文件中的所有继承方法。

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

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

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

  • Windows 上的Ctrl + F6 或 Mac 上的Command + F6:这允许我们重构代码。我们包括重命名、更改方法签名、移动代码等许多操作。

  • 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 布局基础

一个好的用户界面和用户体验是我们应用的核心。作为安卓开发者,我们必须敏锐地关注这两个领域,并学习如何使用为我们提供的不同工具来创建用户界面。谷歌推出了Jetpack Compose,这是一个现代的 UI 工具包,可以帮助开发者轻松地创建直观的用户界面。

在本章中,我们将探讨 Jetpack Compose,这是一种为我们的应用创建 UI 的声明式方法。我们将学习 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 之前,我们通常是这样为我们的应用编写用户界面的:

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

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

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

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

谷歌开发了 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 以反映这些变化。

既然我们已经理解了命令式和声明式方法,让我们看看每个方法的示例。让我们创建一个简单的计数器 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 命令式编写的,使用 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 function is annotated with the @Composable annotation. The function takes a parameter, bookName, which is a String. Inside the function, we have another composable from the Material Design library. The composable renders some text to our 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 可组合中。这将添加 16.dp 的填充到 Text 可组合。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布局。

当我们想要垂直组织项目时,我们使用ColumnColumn的使用示例如下:

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

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

图 3.5 – 列预览

图 3.5 – 列预览

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

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的填充。

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

图 3.6 – 列修饰符预览

图 3.6 – 列修饰符预览

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

现在,让我们在下一节学习Row可组合元素。

Row

当我们想要水平组织项目时,我们使用Row。以下是一个Row的使用示例:

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

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

图 3.7 – 预览

图 3.7 – Row预览

文本元素都排列在水平行中。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 – Row修饰符预览

图 3.8所示,行占据了整个屏幕,文本元素在屏幕宽度内均匀分布。

在下一节,我们将学习Box布局。

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 – Box 预览

图 3.9 – Box 预览

如我们从图 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 的特性以及如何为我们的应用程序添加动态颜色。

第四章:使用材料设计 3 进行设计

材料设计是由谷歌开发的设计系统。它帮助我们创建美观的用户界面。它为我们提供了一套指南和组件,以便我们在开发安卓应用时使用。

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

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

  • 材料设计 3 及其特性

  • 在我们的应用中使用材料设计 3

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

  • 使我们的应用易于访问

技术要求

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

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

材料设计 3 及其特性

Material Design 3材料 3)的发布带来了许多新特性,帮助我们为应用构建用户界面。以下是 Material Design 3 的部分特性:

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

  • 更多组件:Material 3 提供了一组新的改进组件,可供使用。一些组件有新的用户界面,而其他组件则被添加到 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 中,我们有四种类型的顶部应用栏:居中对齐,如图所示。

Figure 4.1 – Small top app bar

Figure 4.1 – Small top app bar

Figure 4.2 – Center-aligned top app bar

Figure 4.2 – Center-aligned top app bar

Figure 4.3 – Medium top app bar

Figure 4.3 – Medium top app bar

Figure 4.4 – Large top app bar

Figure 4.4 – Large top app bar

图 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 拉姆达函数内部传递了一个带有 add 图标的 Icon 可组合组件。预览应该是以下内容:

Figure 4.5 – FloatingActionButton

Figure 4.5 – FloatingActionButton

FloatingActionButton 组件有这些尺寸:大、正常和小,你可以使用适合你目的的任意一个。

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

图 4.6 – 扩展 FloatingActionButton

图 4.6 – 扩展 FloatingActionButton

如前图所示,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组件,并且仍然传递了之前相同的参数。唯一的不同之处在于,在内容内部,我们传递了一个文本,因为contentlambda 暴露了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组件,并在actionslambda 内部传递了三个Icon可组合组件来表示我们应该显示的项目。这是可组合组件预览将看起来如下:

图 4.7 – BottomAppBar

图 4.7 – BottomAppBar

图 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 – 带有 FloatingActionButton 的 BottomAppBar

图 4.8 – 带有 FloatingActionButton 的 BottomAppBar

如前图所示,我们的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 的设备。通常,这些设备处于纵向模式。

  • Medium:这是指宽度在 600 dp 和 840 dp 之间的设备。横屏模式的平板电脑和可折叠设备属于这一类别。

  • Expanded:这是指宽度大于 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 和导航类型。这是一个非常强大的功能,我们可以用它来使我们的 apps 响应。这确保了我们充分利用屏幕大小并提供良好的用户体验。

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

现在我们已经知道了如何设计和构建适用于大屏幕和可折叠设备的 apps,让我们看看本章的另一个重要主题,即可访问性。

使我们的 app 可访问

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

  • 我们应该始终确保所有可点击或可触摸的元素,或者那些需要用户交互的元素,足够大,以便容易点击或触摸。大多数 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 设计了美观的 UI。我们还没有开始为我们的应用采用任何架构。在本节中,我们将探讨一些我们可以用于构建应用的架构。我们还将探讨在构建应用时可以遵循的一些最佳实践。首先,让我们看看使用应用架构的一些好处:

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

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

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

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

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

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

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

  • 首页功能

  • 个人资料功能

  • 设置功能

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

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

  • 模型、视图和视图模型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 个宠物,包括IDnamespecies

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

现在让我们为我们的 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 只组合屏幕上可见的项目。当用户滚动时,它会动态地组合和重新组合项目,确保在任何给定时间只渲染必要的元素。

  • 回收项目:类似于 RecyclerView 中的回收机制,LazyColumn 重新使用进入和离开视口的可组合项,最小化内存使用并防止不必要的重新组合。

  • 针对性能优化:通过懒加载和回收项目,LazyColumn 优化了渲染过程,使其非常适合显示大型数据集而不会消耗过多资源。

现在我们知道了 LazyColumn 的工作方式,让我们看看使用 LazyColumn 的好处。

LazyColumn 的优势

LazyColumn的一些好处如下:

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

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

  • 简化 UI 代码:Jetpack Compose 的声明性特性,结合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的可组合组件,它接受一个修饰符作为参数。然后,我们使用生命周期实用库中ViewModelviewModel()函数创建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 库是来自 Google 的一系列库和 API 的集合,帮助我们开发者使用更少的代码创建更好的应用程序。它们通常是为了解决我们在创建应用程序时遇到的一些痛点而创建的。让我们来看看这些痛点以及为解决这些痛点而创建的一些 Jetpack 库:

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

  • 在应用程序中完美实现导航是一个具有挑战性的任务:为了解决这个问题,创建了大量的开源库。它还要求编写大量的模板代码来在活动之间和片段之间导航,并保持一致和可预测的后退行为。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的新变量,其类型为module。我们使用 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类的单个实例。我们使用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"

在这里,我们正在定义应用中所有库的版本。我们使用版本关键字来定义版本。然后我们为每个库定义版本。当我们编辑此文件时,您将注意到 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" }

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

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

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

关键字允许我们将依赖项分组并作为一个整体使用。现在,我们可以同步项目。最后一步是更新我们的应用级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 开发的 Android、Java 和 Kotlin 的 Type-safe REST 客户端。该库提供了一个强大的框架,用于验证和与 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 serialization将 Kotlin 对象转换为 JSON 并从 JSON 转换回来的转换器。

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

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

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

    • 声明式语法:Kotlinx serialization 使用声明式语法,利用 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数据类具有与 Cat as a Service 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 协程中使用的不同术语和概念:

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

  • 协程构建器:这些是用于创建协程的函数。我们有启动异步协程构建器。启动用于创建不返回结果的协程,而异步用于创建返回结果的协程。结果是延迟对象,我们可以使用await()方法来获取结果。这两个构建器都返回一个作业对象,我们可以用它来检查协程是否仍然活跃或已被取消。我们还可以使用作业来等待协程完成。作业在完成或取消时结束。

  • 作业:作业是一个具有生命周期的协程实例,可以被取消。我们可以使用作业来检查协程是否仍然活跃或已被取消。我们还可以使用作业来等待协程完成。作业在完成或取消时结束。如前所述,启动异步协程构建器都返回一个作业对象,我们使用它来管理协程的生命周期。我们有一个普通的作业和一个监督作业。当任何子作业失败时,普通的作业会被取消。监督作业在子作业失败时不会被取消。当有多个协程同时运行时,建议使用监督作业

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

  • 协程上下文(Coroutine context):这是一个包含许多元素的集合。CoroutineContext使用如下元素等定义了我们的协程的行为:

    • 作业(Job):这管理协程的生命周期。

    • 协程调度器(CoroutineDispatcher):这定义了协程将在哪个线程上运行。

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

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

  • withContext()函数用于在不同调度器之间切换。withContext()是一个挂起函数,它切换协程的上下文。

  • 流(Flows):挂起函数仅返回单个值。流是一种可以返回多个值的异步数据流。我们可以使用流从挂起函数返回多个值。我们还可以使用流来执行异步操作。流是冷流。这意味着它们只有在被收集时才开始发出值。我们可以使用collect()函数从流中收集值。我们有StateFlowSharedFlow,它们是流的一种类型。StateFlow是一种流,它将当前值发送给新的收集器,并将新值发送给现有的收集器。SharedFlow是一种将新值发送给所有收集器的流。我们将在下一章中学习更多关于流的内容。在 Android 中,我们通常使用这两种类型的流将数据发射到我们的 UI。我们将看到StateFlowViewModel重构时使用协程的用法。

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

使用 Kotlin 协程进行网络调用

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

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

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"

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

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

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

@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属性来获取我们的网络请求状态。

  • 我们有一个可组合组件,它包含三个动画可见性可组合组件。第一个用于在网络请求加载时显示CircularProgressIndicator。第二个用于在网络请求成功时显示猫咪列表。最后一个用于在网络请求失败时显示错误信息。

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

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

我们还将将其添加到我们的compose包中,以便它可以与其他compose库一起提供。更新后的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 Navigation,以处理我们的应用中的导航。

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

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

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

  • Jetpack 导航概述

  • 导航到 Compose 目的地

  • 向目的地传递参数

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

技术要求

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

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

Jetpack 导航概述

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

在本章中,我们将在上一章使用的宠物应用程序的基础上,导航到一个具有返回到上一屏幕按钮的详细信息屏幕。我们还将向详细信息屏幕传递数据。

首先,我们需要将 Jetpack 导航 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。这很重要,因为我们需要能够在我们的应用中导航到不同的屏幕。

  • 我们创建了一个NavHost可组合组件,它接受navControllerstartDestinationstartDestination是我们启动应用时想要看到的第一个屏幕。在我们的例子中,它是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 中的返回按钮时导航回上一屏幕。

我们的下一步是将 PetDetailsScreen 组合器添加到我们的 AppNavigation.kt 文件中。让我们在 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 回调。我们向 Column 添加了 clickable 修饰符,并在修饰符内部调用了 onPetClicked 回调。我们将 cat 对象传递给回调。接下来,我们需要将 onPetClicked 回调添加到 PetList 组合器中,如下所示:

@Composable
fun PetList(modifier: Modifier, onPetClicked: (Cat) -> Unit) {
    // other code
}

接下来,我们需要将此回调传递到我们使用 PetListItem 组合器的地方。在 items 块内部调用点的修改后的 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. 让我们转到 PetDetailsScreenContent 可组合组件,位于 PetDetailsScreen.kt 文件中,并按以下方式修改它:

    @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 可组合组件中的 onPetClicked 回调,如下所示:

    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. 我们将首先创建一个名为NavigationType密封接口,它代表我们将在应用中使用的不同类型的导航。让我们在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 ?: "")
            }
        }
    }
    

    让我们解释一下前面的代码:

    • 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可组合组件了。

    • 让我们在视图包中创建一个名为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

    • 让我们在 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 时,它会显示 PetsNavigationRail 可组合组件。我们还添加了一个 Scaffold 可组合组件。我们将 AppNavigation 可组合组件用作 Scaffold 的内容,传递了 contentTypenavHostController。我们还使用了 PetsBottomNavigationBar 可组合组件作为 Scaffold 的底部栏。当 navigationTypeNavigationType.BottomNavigation 时,我们使用了 AnimatedVisibility 可组合组件来显示 PetsBottomNavigationBar 可组合组件。

    • 最后一步是将 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变量分配值:

      • 如果宽度大小是Compact,我们使用BottomNavigation作为navigationTypeList作为contentType

      • 如果宽度大小是Medium,我们使用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的内容。我们传递navigationTypecontentTypeonFavoriteClickedonHomeClickednavHostController参数。我们还传递drawerState参数。我们使用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")

这允许我们在我们的 app 模块中使用 KSP。为了最终设置 Room,现在让我们将我们之前声明的依赖项添加到 app 级别的build.gradle.kts文件中:

implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)

我们已经添加了 Room 依赖项和 Room KTX 库,使用implementation配置,以及使用ksp配置的 Room 编译器。我们现在已准备好开始在项目中使用 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函数。这是因为我们将使用协程将猫插入到我们的数据库中。将项目插入数据库需要在后台线程中完成,因为它是一个资源密集型操作。我们还使用了带有onConflict参数设置为OnConflictStrategy.REPLACE@Insert注解。这告诉 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 – 房间模式目录

图 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 来从数据库返回收藏猫。现在,我们需要向数据库添加一个迁移来添加新列,这确保了在更新数据库时不会丢失任何数据。Room 版本 2.4.0-alpha01 引入了一种处理迁移的新方法——自动迁移。这意味着我们不需要编写任何 SQL 查询来处理迁移;Room 将自动为我们处理迁移。

  3. 让我们修改 CatDatabase 以添加 autoMigration 如下:

    @Database(
        entities = [CatEntity::class],
        version = 2,
        autoMigrations = [
            AutoMigration(from = 1, to = 2)
        ]
    )
    

    我们已经将 autoMigrations 参数添加到我们的数据库中。我们向该参数传递了一个 AutoMigration 对象的列表。我们传递了 fromto 参数来指定数据库的版本。确保你添加了 AutoMigration 类的导入。请注意,我们也将数据库的 version 提高了。这是因为我们在数据库中添加了一个新列。构建项目以生成 schema json 文件。你应该会看到一个名为新数据库版本的新的模式 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
                        },
                    )
                }
            }
        }
    }
    

    我们已将Icon可组合组件添加到PetListItem可组合组件中。如果猫被收藏,我们使用了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可组合组件中,该组件位于PetsScreen.kt文件中,我们需要将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. 在库部分,添加以下依赖项:

    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 以确保只有在设备连接到互联网时才执行工作请求。我们还有其他网络类型,如下所示:

    • BatteryNotLow constraint 设置为 true 以确保只有在电池电量充足时才执行工作请求。然后我们使用 WorkManager.getInstance(applicationContext) 获取 WorkManager 的实例,并调用 enqueueUniqueWork 方法来入队我们的工作请求。我们将工作请求的名称、ExistingWorkPolicy 和工作请求传递给了 enqueueUniqueWork 方法。ExistingWorkPolicy 用于指定如果已经存在具有相同名称的工作请求时应该发生什么。我们使用了 ExistingWorkPolicy.KEEP 来确保如果已经存在具有相同名称的工作请求,则不会替换它。以下是一些其他可用的策略:

      • MainActivityonCreate 方法。

      • MainActivity.kt 文件中,在 onCreate 方法中添加以下代码:

        startPetsSync()
        

        由于我们正在使用 Koin,我们需要在我们的应用清单中禁用默认的 WorkManager 初始化。

      • 让我们转到 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 中移除了 App Startup (developer.android.com/topic/libraries/app-startup),这是 WorkManager 内部使用的。

      • 要设置自定义的 WorkManager 实例,转到 ChapterEightApplication.kt 文件,并在 startKoin 块内添加以下代码:

        workManagerFactory()
        
      • 构建并运行应用程序,没有任何变化。然而,我们已经安排了一个后台任务从远程数据源获取猫咪并将它们保存到我们的数据库中。

图 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 注解来将 KoinTestRule 传递给我们的测试类。我们还创建了一个 setup 函数,并用 @Before 注解。这个函数将在每个测试之前执行。我们使用 WorkManagerTestInitHelper 类来初始化 WorkManager 以进行仪器测试。我们使用 SynchronousExecutor 类来确保工作同步执行。这是为了确保我们的测试是确定性的。我们现在可以开始编写我们的测试了。

按照以下步骤创建我们的测试:

  1. 我们将首先创建一个测试函数来测试我们的工作功能。将以下代码添加到下面的 PetsSyncWorkerTest.kt 文件中的 setup 函数下方:

    @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 function来模拟约束条件得到满足。我们将我们工作请求的id传递给了setAllConstraintsMet方法。工作请求id有一个实例类型为Universally Unique Identifier (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 中,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)
                }
            }
        }
    }
    

    让我们分析前面的代码:

    • 我们创建了一个可组合的 PetsScreen 可组合组件。

    • 让我们转到 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,我们不显示屏幕的内容。

    • 构建并运行应用。一开始,我们将看到授权对话框,如下面的截图所示:

图 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

从先前的图中,我们可以看到在顶部,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)错误和内存不足崩溃。以下是内存泄漏的一些最常见原因:

  • 活动实例存储为在对象中作为上下文字段,该对象因配置更改而存活

  • 忘记在不再需要时注销广播接收器、监听器、回调或 RxJava 订阅

  • 在后台线程中存储上下文引用

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

    在前面的代码中,我们告诉 LeakCanaryLeakCanaryTest的单例实例将被垃圾回收。

  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 拦截器,将所有这些事件持久化在我们的应用中,并提供一个用于检查和共享其内容的用户界面。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()
    }
    

    以下是对前述代码的解释:

    • 我们已经创建了一个 OkHttpClient 实例来执行网络请求。

    • 让我们修改我们的 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 设置现在就完成了。

    • 构建并运行应用。我们可以看到 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 以及如何使用它来检查我们的应用中的网络请求。在下一节中,我们将学习如何使用 App Inspection 来检查我们的应用房间数据库并探索 App Inspection 提供的功能。

使用 App Inspection

App Inspection 允许我们调试数据库、检查网络流量以及调试后台任务。它是帮助我们调试应用的一个非常重要的工具。要使用 App Inspection,让我们运行我们的应用,然后在 Android Studio 中导航到 视图 | 工具窗口 | App Inspection

图 10.20 – App Inspection

图 10.20 – App Inspection

App Inspection 会自动连接到我们的应用。第一个标签是 数据库检查器。在左侧,我们可以看到我们应用创建的不同数据库。我们有 WorkManager、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 拥有一系列广泛的编码约定,涵盖了从命名约定到格式化的各个方面。遵循这些约定可以使我们的代码更容易阅读,并使其易于维护。以下是一些例子:

  • 变量名应该使用驼峰式命名法

  • 类名应该使用帕斯卡命名法

  • 常量应该使用大写

  • 函数应该使用驼峰式命名法

  • 多个单词的函数应该用下划线分隔

  • 单行表达式的函数应该内联

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 作为 Kotlin 中的集合类型。集合可以是 mutableimmutable 的。一个 mutable 集合在创建后可以被修改,而一个 immutable 集合在创建后不能被修改。以下是一个 mutable 列表的示例:

val mutableList = mutableListOf("Kotlin", "Java", "Python")
mutableList.add("Swift")

在前面的示例中,我们创建了一个字符串的 mutable 列表。我们使用 mutableListOf 函数创建列表。然后,我们使用 add 函数向列表中添加一个新的字符串。以下是一个 immutable 列表的示例:

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 拉姆达函数内部,我们执行了网络调用,这应该在后台发生。我们使用了 async 协程构建器,它允许我们等待网络调用的结果。async 协程构建器返回一个 Deferred 对象。最后,我们使用网络调用的结果更新了 UI。

when 语句

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 成功

如前图所示,构建成功。这意味着我们的项目格式正确。通过在 IDE 中使用 Git 工具(在 macOS 上按 Command + K,在 Windows 上按 Ctrl + K),我们可以看到有变化的文件以及 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 块、空的类块和空的函数及条件函数块。

  • 例外情况:此规则集报告与代码抛出和处理异常相关的问题。例如,它包含关于捕获通用异常以及其他与异常处理相关问题的规则。

  • 格式化:此规则集检查您的代码库是否遵循特定的格式化规则集。它允许检查缩进、间距、分号,甚至导入顺序等。

  • 命名: 此规则集包含断言代码库中不同部分命名的规则。它检查我们如何命名类、包、函数和变量。如果未遵循既定约定,则会报告错误。

  • 性能: 此规则集分析代码以查找潜在的性能问题。它报告的一些问题包括使用 ArrayPrimitives 或滥用 forEach 循环等。

  • 潜在问题: 此规则集提供了检测潜在问题的规则。

  • 规则作者: 此规则集提供了确保在编写自定义规则时遵循良好实践的规则。

  • 风格: 此规则集提供了断言代码风格的规则。这将有助于保持代码与给定的代码风格指南保持一致。

在理解了 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模型-视图-ViewModel)架构中的不同层添加测试。我们将了解向我们的应用添加测试的重要性以及如何添加单元测试、集成测试和仪器测试。

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

  • 测试的重要性

  • 测试网络和数据库层

  • 测试我们的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% 集成测试10% UI 测试。注意,随着我们向上移动金字塔,测试的数量会减少。这是因为随着我们向上移动金字塔,测试的编写和维护成本会更高。这就是为什么我们应该努力编写比集成测试更多的单元测试,以及比 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. 从提供的选项中选择Java 资源文件夹。这将创建一个名为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 以 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. 接下来,让我们创建我们的测试类。让我们创建一个新的 Kotlin 文件,命名为 CatsAPITest.kt,并添加以下代码:

    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 层。

测试我们的 ViewModel

我们的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. 点击顶部的Sync Now按钮以添加依赖项。添加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的模拟实例。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文件。我们需要在我们的 composable 中添加一个testTags修饰符。这是因为我们正在使用标签来识别我们的 composables。在PetListItem composable 中,修改 composable 内容如下:

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 来查找我们的 composables。一旦我们使用了 finders,我们就可以对 composables 执行操作并断言。现在让我们在我们的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 composable。在这个类内部,我们创建了一个名为composeTestRule的规则。这个规则将用于创建我们的 composables。通过这个规则,我们可以设置 Compose 内容或访问我们的 activity。

  • 我们有一个名为testPetListItem()的函数,它被@Test注解所标记。在这个函数中发生了几件事情:

    • 我们使用了with作用域函数来能够使用composeTestRule。然后我们设置了 composable 的内容。在这种情况下,是我们想要测试的PetListItem composable。我们向 composable 传递一个cat对象。

    • 我们使用onNodeWithTag()函数来查找我们的 composables。然后我们使用assertExists()函数来断言 composables 的存在。这将使用我们之前添加的标签来查找我们的 composables。

    • 我们使用onNodeWithText()函数来查找我们的 composables。然后我们使用assertIsDisplayed()函数来断言 composables 的存在。我们还使用了onNodeWithContentDescription()函数来查找我们的 composables。这两个函数帮助我们找到文本或内容描述与传递给函数的文本或内容描述相匹配的 composables。

    • 最后,我们使用performClick()函数在我们的 composables 上执行一个动作。在这种情况下,我们正在对PetListItemFavoriteIcon composable 执行点击动作。

点击测试类旁边的绿色运行图标来运行我们的测试。我们应该在运行窗口中看到以下输出:

图 12.9 – Jetpack Compose UI 测试

图 12.9 – Jetpack Compose UI 测试

我们的测试成功运行。此外,测试也在我们正在工作的设备上运行。我们还能看到显示的组件和执行的动作。

我们已经看到了如何在 Jetpack Compose 中编写 UI 测试。要了解更多关于 Jetpack Compose 的测试信息,请查看官方文档(developer.android.com/jetpack/compose/testing)。通过本章所获得的知识,我们可以为我们的应用程序的不同层添加更多测试。你可以尝试添加更多测试来检验你的知识。

摘要

在本章中,我们学习了如何在我们的 MVVM 架构的不同层中添加测试。我们了解了添加测试到我们的应用的重要性,以及如何添加单元测试、集成测试和仪器测试。

在下一章中,我们将逐步学习如何在 Google Play Store 发布新应用。我们将详细介绍如何创建签名应用包以及发布我们第一个应用到 Play Store 所需的事项。此外,我们还将了解一些 Google Play Store 的政策,以及如何始终保持合规,避免我们的应用被移除或账户被封禁。

第四部分:发布您的应用

在成功开发您的应用之后,下一阶段的内容将在本部分展开。您将了解如何将应用发布到 Google Play 商店的细节,以及如何通过遵守关键的 Google Play 商店政策来确保发布过程顺利。您还将深入探索持续集成与持续部署CI/CD)的领域,解锁自动化 Android 开发中关键常规任务的潜力。您将学习如何将第三部分中的测试和代码分析工具无缝集成到您的 CI/CD 管道中,简化开发工作流程。为了结束本部分,您将通过整合崩溃报告工具来提升应用性能,并通过实施推送通知来增强用户参与度,并学习一些有关如何确保应用安全的有用提示。

本节包含以下章节:

  • 第十三章, 发布您的应用

  • 第十四章, 持续集成与持续部署

  • 第十五章, 改进您的应用

第十三章:发布您的应用

一旦我们卓越的应用程序开发完成,接下来的阶段涉及将这些应用程序交付给我们的目标受众。这是通过在 Google Play Store 上发布我们的应用程序来实现的。本章将重点介绍这一过程。

在本章中,我们将逐步学习如何在 Google Play Store 中发布新应用。我们将了解如何创建签名应用包,以及回答有关我们应用内容的问题、创建发布、设置用户如何访问我们的应用——无论是通过受控测试轨道还是公开,以及更多。所有这些是我们将第一个应用发布到 Play Store 所必需的。此外,我们还将了解一些 Google Play Store 政策以及如何始终遵守这些政策,以避免我们的应用被移除或我们的账户被禁用。

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

  • 准备我们的应用发布

  • 将我们的应用发布到 Google Play Store

  • Google Play Store 政策概述

技术要求

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

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

准备我们的应用发布

在我们将应用上传到 Google Play Store 之前,我们必须做一些事情来为发布做准备。我有一个清单,每次发布应用时我都会过一遍。在本节中,我们将通过清单来确保我们在发布应用时不会忘记任何事情,并且应用是可用的。我们将在本书的第十五章中稍后处理一些清单项目,但在这里提一下它们是有价值的。

这里是清单:

  • 向您的应用添加分析

  • 向您的应用添加崩溃报告

  • 关闭日志和调试

  • 国际化和本地化您的应用

  • 改进错误信息

  • 在不同的设备上测试您的应用

  • 提供适当的反馈渠道

  • 减小您应用的大小

  • 使用 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 Package Kit (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 Store 时使用这个包。

启用压缩和混淆

压缩和混淆有助于减小应用的大小。压缩移除应用中的任何未使用代码。混淆有助于使代码不可读。压缩通过移除不必要的字符和重命名变量来减小代码库的大小,从而减小 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 Store 之前,在本地运行发布构建。

这是因为当我们启用压缩和混淆时,可能会移除一些代码。这可能会导致我们的应用崩溃。我们必须检查并查看是否需要添加关于如何混淆我们的代码的规则。

我们通过在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 – 创建开发者账户

选择自己选项将显示为您自己创建账户的说明:

图 13.9 – 创建自己的开发者账户

图 13.9 – 创建自己的开发者账户

如前述截图所示,您需要一个用户可以联系您的电子邮件地址,以及 Google Play 可以联系您的另一个电子邮件地址。您还需要为 Google Play 账户支付 25 美元的终身注册费。如果您还没有账户,您可以继续购买,因为这是一个非常直接的过程。

一旦您打开 Google Play 控制台账户,您将看到以下屏幕:

图 13.10 – Google Play 控制台登录页面

图 13.10 – Google Play 控制台登录页面

如果您已经有一些应用,这将显示所有您的应用列表。它还显示了开发者账户名称和账户 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 Store。您始终可以返回并编辑您应用程序的详细信息。

现在,您可以推广这个版本到生产环境,以便您的应用程序可以在 Google Play Store 上实时显示,如下所示:

图 13.43 – 推广发布

图 13.43 – 推广发布

在下一节中,让我们看看在您开发应用程序时需要考虑的一些政策。

Google Play Store 政策的概述

Google Play 政策是我们发布的一部分。因此,作为开发者,我们必须了解其中大部分,如果不是全部。这是因为如果我们违反了任何政策,我们的应用程序可能会被从 Google Play Store 中移除。我们还需要了解它们,因为其中一些政策会影响我们开发应用程序的方式。

在本节中,我们将查看我们在开发应用程序时需要了解的一些政策:

  • 后台位置访问:除非应用程序提供高质量、有益的用户体验,否则应用程序被限制在后台访问用户的地理位置。这是为了确保应用程序不会耗尽用户的设备电量。如果您的应用程序需要在后台访问位置,您必须提供一个令人信服的理由说明为什么它需要在后台访问位置。您还必须提供一个隐私政策,解释您如何使用您收集的位置数据。

  • 数据安全:随着 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 Store。

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

  • 设置 GitHub Actions

  • 在 GitHub Actions 上运行 lint 检查和测试

  • 使用 GitHub Actions 部署到 Play Store

技术要求

要遵循本章的说明,您需要下载 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."
    

    让我们了解前面工作流程文件中的不同字段:

    • 名称:这是我们工作流程的名称。这将在 GitHub Actions 页面上显示。

    • 触发条件:这是我们将会触发工作流程的事件。在我们的例子中,当我们向主分支推送代码时,我们将触发工作流程。

    • workflow_dispatch:这是一个手动触发器,我们可以用它从 GitHub Actions 页面触发我们的工作流程。当我们想手动触发工作流程时,这很有用。

    • 作业:这是我们工作流程触发时将运行的作业。在我们的例子中,我们有一个名为 build 的作业。这个作业将在由 runs-on 字段指定的最新版本的 Ubuntu 上运行。

    • 步骤:此字段包含将在我们的作业中运行的步骤。在我们的例子中,我们有一个将运行命令的单个步骤。此命令将打印出触发我们工作流程的事件。步骤可以包含 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
    

    让我们了解前面的代码:

    • 我们已经创建了一个名为 .yml 的步骤,这些文件对缩进非常敏感。因此,我们需要确保我们的代码缩进正确。

    • 提交更改,操作将自动运行。我们可以看到工作流程的结果,查看作业构建,我们可以看到所有运行的步骤,如下面的屏幕截图所示:

图 14.6 – GitHub Action 步骤

图 14.6 – GitHub Action 步骤

我们现在知道了 GitHub Actions 是什么,已经创建了我们的第一个操作,并看到了如何在 GitHub Actions 上运行 Android 特定的工作流程。在下一节中,我们将在我们的工作流程中运行代码检查和测试。

在 GitHub Actions 上运行代码检查和测试

第十一章中,我们学习了如何使用终端上的 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 环境中设置模拟器以运行我们的仪器化测试。在动作配置中,我们设置了以下内容:

    • working-directory:这是我们的项目所在的位置。

    • api-level:这是模拟器平台系统镜像的 API 级别。

    • target:这是模拟器系统镜像的目标。

    • architecture:我们指定我们想要在哪个架构上运行测试的模拟器。

    • disable-animations:我们在模拟器中禁用动画。

    • 最后,我们使用脚本字段指定我们想要运行的命令。在这种情况下,我们运行 connectedCheck 任务,该任务将运行我们的仪器化测试。

  5. 在进行上述更改后,提交更改,动作将运行。我们可以在 Actions 选项卡中查看动作的结果,如下面的截图所示:

图 14.7 – 更多 Github Actions 步骤

图 14.7 – 更多 Github Actions 步骤

我们通过合并额外的步骤来扩展我们的工作流程,以执行 lint 检查和测试。我们可以看到每个步骤的结果。我们还可以看到每个步骤运行所需的时间。Run connected test 步骤运行时间最长。这是因为它必须设置模拟器并运行测试。

我们需要修改 main.yml 文件运行的时间。目前,我们的工作流程在我们向主分支推送代码时运行。我们将将其更改为在创建主分支的 pull request 时也运行。这是因为我们希望在将代码移动到主分支之前运行我们的检查。为此,我们将在 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 商店。然而,在第十三章中,我们是手动完成的。在本章中,我们将学习如何使用 GitHub Actions 将我们的应用部署到 Google Play 商店。我们将使用 Google Play Publisher 动作将我们的应用部署到 Google Play 商店。此动作可在 GitHub 市场中找到。

在我们可以编写我们的工作流程之前,我们需要做一些设置。我们需要在我们的 Google Play 商店账户中创建一个服务账户。我们可以通过以下步骤来完成此操作:

  1. 按照以下步骤在 Google Cloud Platform 中配置服务账户:

    1. 导航到 cloud.google.com/gcp

    2. 导航到 IAM 和管理员 | 服务账户 | 创建 服务账户

    3. 选择一个名称并添加适当的权限,例如,所有者权限。

    4. 打开新创建的服务账户,点击 密钥 选项卡,并添加一个新的 JSON 类型密钥。

    5. 当密钥成功创建后,一个 JSON 文件将自动下载到您的机器上。

    6. 将此文件的内容存储在您的 GitHub 仓库密钥中。您可以通过进入仓库的 设置 选项卡,点击 密钥和变量 部分,并选择 操作 选项来完成此操作。

    7. 创建一个 新仓库密钥 并上传 JSON 文件。您可以将其命名为 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

您应该在保存您的 keystore 文件的目录中运行此命令。如果您将其命名为与 keystore 文件名匹配的名称,则可以进行更改。此命令将生成我们的 keystore 文件的 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 中运行代码检查和测试”部分创建的工作流程非常相似,只有细微的差别。我们有一个步骤可以自动增加 versionNameversionCode,而不是每次都手动进行。版本号作为不同软件迭代的结构化标识符。采用语义版本控制有助于传达更改的影响,区分主要不兼容更新、次要兼容性功能添加和补丁级别的错误修复。它在依赖关系管理中发挥着关键作用,促进不同组件之间的兼容性。此外,版本控制支持回滚、热修复和高效测试,确保应用程序的稳定性。发布说明和沟通流程简化,为用户和利益相关者提供对每个发布的清晰洞察。最终,版本控制有助于提供可靠和可预测的用户体验,在整个软件开发生命周期中促进信任和透明度。

我们还有一个步骤构建一个签名的 upload-google-play 动作,这自动化并简化了流程。我们在该动作上进行配置,例如指定我们的服务账户、我们在 Play Store 上的应用程序包名、我们的签名 AAB 将被找到的目录,以及最后,我们想要部署的轨道。将更改推送到主分支将再次触发动作,一旦 deploy-to-playstore 工作流程完成,我们应该在我们的 Play Store 页面上看到一个新内部测试版本,如下所示:

图 14.12 – 新内部测试版本

图 14.12 – 新内部测试版本

我们已经完成了 CI/CD 流程的建立。我们只需设置一次,就可以始终使用它来简化部署和自动化测试,使其对我们来说更加容易、快速和可靠。

摘要

在本章中,我们学习了如何使用 GitHub Actions 自动化一些手动任务,例如将新的构建部署到 Play Store。此外,我们还学习了如何在 CI/CD 管道中运行代码检查和测试,并使用 GitHub Actions 将构建推送到 Google Play Store。

在下一章中,我们将学习通过添加分析、使用 Firebase Crashlytics 以及使用云消息来提高我们应用的用户参与度的技术。此外,我们还将了解一些确保我们应用安全性的技巧和窍门。

第十五章:提升您的应用

在完成应用的开发和发布后,在使用有助于改进应用的事物时,如 Firebase Messaging 或 Crashlytics,始终保持警觉非常重要。在本章中,我们将学习如何使用 Firebase Messaging 和 Crashlytics。

在本章中,我们将逐步学习如何通过添加分析工具(Firebase Crashlytics)和使用云消息来提高用户参与度来改进我们的应用。我们将学习如何从 Firebase 控制台向应用发送通知。此外,我们还将学习一些确保用户数据不受损害的应用安全技巧。

本章将涵盖以下主要内容:

  • 使用 Firebase Crashlytics 检测崩溃

  • 使用 Firebase Messaging 提高应用参与度

  • 保护您的应用

技术要求

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

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

使用 Firebase Crashlytics 检测崩溃

应用程序可能因多种原因发生崩溃,包括常见的编码问题,如空指针异常、内存泄漏和不正确的数据处理。由不同设备硬件配置和不同 Android 操作系统引起的设备碎片化引入了兼容性问题,有时也可能导致崩溃。网络问题、资源不足或外部依赖(如第三方库)的管理不当也可能导致崩溃。有时,我们可以预见并优雅地处理它们。其他时候,它们是意外的,我们需要了解它们以便修复。我们的应用已经在 Google Play Store 上,所以有时我们可能没有在出现问题的设备上进行调试的便利。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 – 完成设置对话框

在我们的项目创建完成后,点击连接以最终完成 Firebase Crashlytics 在 Android Studio 中的设置。让我们回到 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 – 应用质量洞察

图 15.12 – 应用质量洞察

图 15.11 所示,我们需要登录到包含我们正在工作的项目的 Firebase 账户。我们可以通过点击 登录 按钮并在浏览器中完成登录过程来实现。登录后,我们可以看到应用中发生的崩溃:

图 15.13 – 应用质量洞察崩溃详情

图 15.13 – 应用质量洞察崩溃详情

这显示了所有详细信息,正如我们之前在 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 分析的最终步骤。

设置完成后,我们就可以从 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 Cloud Messaging选项。

  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 Cloud Messaging 登录页面

图 15.15 – Firebase Cloud Messaging 登录页面

这是我们第一次使用云消息服务,因此我们需要创建一个新的活动。点击创建您的第一个活动按钮。这将打开一个包含以下选项的新对话框:

图 15.16 – Firebase Cloud Messaging 选项

图 15.16 – Firebase Cloud Messaging 选项

我们可以发送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 Cloud Messaging 以及如何向其发送通知。在下一节中,我们将介绍另一个关键主题——保护我们的应用。

保护您的应用

确保您的应用安全尤其重要。我们需要确保我们的用户数据安全。我们还需要确保我们的应用不受攻击。如恶意软件、中间人攻击和数据拦截等攻击对敏感信息构成风险,而如 SQL 注入和权限提升等漏洞可能导致对数据库或应用功能的未授权访问和操纵。跨站脚本和代码注入为攻击者提供了在应用内执行恶意脚本或命令的途径,可能危及用户会话和数据。不安全的数据存储实践可能会泄露敏感信息,而拒绝服务攻击可能会中断应用服务。

在本节中,我们将了解一些关于如何保护我们应用的建议和技巧。以下是我们可以采取的一些措施来保护我们的应用:

  • HTTPS: 我们应该始终为所有网络请求使用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 中了解更多相关信息。

  • 了解更多关于 现代 Android 开发MAD)。MAD 是一套工具和库,帮助我们更快、更好地开发 Android 应用。您可以从官方文档 developer.android.com/modern-android-development 中了解更多关于 M.A.D 的信息。

  • 了解更多关于 Google Play Vitals 以及如何使用 play.google.com/console/about/vitals/ 上的信息来提升您应用的质量。

  • 继续学习 Kotlin 和 Android。构建更多应用并与世界分享。您还可以为开源项目做出贡献。

posted @ 2025-10-26 08:58  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报