Jetpack-现代安卓开发启动指南-全-

Jetpack 现代安卓开发启动指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

使用 Jetpack 库,您可以构建和设计高质量、健壮的 Android 应用程序,这些应用程序具有改进的架构,并且能够在不同的版本和设备上保持一致的工作。这本书将帮助您了解 Jetpack 如何让开发者遵循最佳实践和架构模式来构建 Android 应用程序,同时还能消除样板代码。

使用 Android 和 Kotlin 进行开发的开发者将能够通过这本浓缩的实用指南将他们的知识应用于构建应用程序,该指南涵盖了最受欢迎的 Jetpack 库,包括 Jetpack Compose、ViewModel、Hilt、Room、Paging、Lifecycle 和 Navigation。您将在构建具有真实世界数据的现代应用程序的同时,获得相关库和架构模式的总览,包括 Android 生态系统中的流行库,如 Retrofit、Coroutines 和 Flow。

在完成这本 Android 应用程序开发书籍之后,您将学会如何利用 Jetpack 库和您的架构概念知识来构建、设计和测试适用于各种用例的健壮 Android 应用程序。

本书面向对象

本书面向希望提升 Android 开发技能的初级和中级 Android 开发者,以使用 Jetpack 库和其他前沿技术来开发高质量的应用程序。对 Android 开发基础知识有基本了解的初学者也会发现这本书很有用。假设读者熟悉 Kotlin。

本书涵盖内容

第一章使用 Jetpack Compose 创建现代 UI,介绍了使用 Jetpack Compose 工具包在 Android 上以新的声明性方式构建 UI 的方法,同时开始使用这个新框架从头开始构建应用程序。

第二章使用 Jetpack ViewModel 处理 UI 状态,探讨了 ViewModel 架构组件的概念和用法,以及 Compose 应用程序中的 UI 状态概念,以及 ViewModel 如何处理和缓存此类状态。

第三章使用 Retrofit 从 REST API 显示数据,介绍了 Retrofit 是什么以及它如何被用作在书中开发的项目中的 Android 网络客户端。

第四章使用协程处理异步操作,介绍了 Kotlin 协程背后的核心概念。本章探讨了协程是什么,挂起函数是什么,以及协程的其他重要组件。

第五章使用 Jetpack Navigation 在 Compose 中添加导航,介绍了使用 Jetpack Navigation 库在基于 Compose 的屏幕之间进行导航的基础知识,同时探讨了如何支持对 Compose UI 的深链接。

第六章([B17788_06_ePub.xhtml#_idTextAnchor186]),使用 Jetpack Room 添加离线功能,介绍了 Room 作为存储结构化数据的解决方案,并探讨了在 Android 上作为构建健壮应用程序的架构决策的数据持久性。

第七章([B17788_07_ePub.xhtml#_idTextAnchor249]),介绍 Android 中的展示模式,探讨了架构展示模式及其必要性,同时分析了 MVC、MVP 和 MVVM。

第八章([B17788_08_ePub.xhtml#_idTextAnchor285]),在 Android 中使用 Clean Architecture 入门,探讨了 Clean Architecture 在 Android 中的体现以及如何通过在书中开发的项目中实现 Use Cases 来分离业务逻辑。

第九章([B17788_09_ePub.xhtml#_idTextAnchor293]),使用 Jetpack Hilt 实现依赖注入,探讨了依赖注入是什么,为什么需要它以及它带来的优势。本章还探讨了 Dagger 的基础知识并介绍了 Jetpack Hilt。

第十章([B17788_10_ePub.xhtml#_idTextAnchor305]),使用 UI 和单元测试测试您的应用程序,探讨了测试的重要性,并将它们分为两大类:UI 测试和单元测试。在本章中,您将学习如何通过创建单元测试来测试 Compose UI 和应用程序逻辑。

第十一章([B17788_11_ePub.xhtml#_idTextAnchor317]),使用 Jetpack Paging 和 Kotlin Flow 创建无限列表,探讨了分页的概念,并解释了如何借助 Jetpack Paging 在 Android 上实现分页,同时使用 Kotlin Flow。

第十二章([B17788_12_ePub.xhtml#_idTextAnchor327]),探索 Jetpack 生命周期组件,探讨了 Jetpack 生命周期中组件(如 ViewModel 和 LiveData)的内部工作原理。在本章中,您还将学习如何创建自己的生命周期感知组件。

为了充分利用本书

您需要在您的计算机上安装 Android Studio 的版本——2020.3.1 版本或更高。

所有代码示例都已使用 Kotlin 1.6.10 和 Android Studio 2020.3.1 在 macOS 和 Windows 上测试过。

假设您熟悉 Kotlin 和 Android 的基础知识。

图片

对于第三章([B17788_03_ePub.xhtml#_idTextAnchor094])的一部分,使用 Retrofit 显示 REST API 数据,预期您拥有一个谷歌账户。

对于第六章([B17788_06_ePub.xhtml#_idTextAnchor186])的一部分,使用 Jetpack Room 添加离线功能,预期您对 SQL 数据库和查询有基本的了解。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

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

我们还提供了一系列来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色图像 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801811071_ColorImages.pdf

使用的约定

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

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Text是我们旧爱TextView的 Compose 版本。”

代码块设置如下:

@Composable 
fun FriendlyMessage(name: String) { 
   Text(text = "Greetings $name!") 
}

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

@Composable 
fun ColoredBox() { 
   Box(modifier = Modifier.size(120.dp)) 
}

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

npm install component_name

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在电话和平板模板部分,选择Empty Compose Activity然后选择下一步。”

小贴士或重要提示

看起来像这样。

联系我们

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

一般反馈: 如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

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

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。

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

分享您的想法

一旦您阅读了《使用 Apache Arrow 的内存分析》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

第一部分:探索核心 Jetpack 套件和其他库

在本部分中,我们将借助 Jetpack 库(如 Compose、ViewModel 和 Navigation)以及其他流行的库(包括协程和 Retrofit),构建一个现代且健壮的 Android 应用。

本节包含以下章节:

  • 第一章, 使用 Jetpack Compose 创建现代 UI

  • 第二章, 使用 Jetpack ViewModel 处理 UI 状态

  • 第三章, 使用 Retrofit 从 REST API 显示数据

  • 第四章, 使用协程处理异步操作

  • 第五章, 在 Compose 中使用 Jetpack Navigation 添加导航

第一章:第一章:使用 Jetpack Compose 创建现代 UI

Jetpack 库使您能够构建和设计高质量、健壮的 Android 应用,这些应用具有可靠的架构,并在不同版本和设备上保持一致。同时,Jetpack 套件允许您消除样板代码,最终专注于真正重要的事情——构建必要的功能。

在本章中,我们将探讨构建用户界面(UIs)最流行的 Jetpack 库之一,称为 Compose。简单来说,Jetpack Compose 是一个强大的现代工具包,允许您使用 Kotlin 函数和 API 直接在 Android 上构建原生 UI。

Compose 通过利用声明式编程的力量,结合 Kotlin 编程语言的易用性,加速并极大地简化了 UI 开发。新的工具包在允许您通过声明式函数构建 UI 时,仅依赖于 Kotlin API。

到本章结束时,您将了解如何在 Android 上使用更少的代码、强大的工具、直观的 API 以及无需使用如 XML 等额外语言来构建 UI。

在第一部分,“理解 Compose 的核心概念”,我们将探索 Compose 背后的基本概念,并了解它们如何帮助我们编写更好、更干净的 UI。我们将看到如何使用可组合函数来描述 UI,同时理解在 Android 上构建 UI 的新声明式方法是如何工作的。我们还将探索为什么在 Compose 中组合优于继承,以及数据流是如何在 Compose 中工作的。最后,我们将介绍重新组合是什么,并看到它对我们声明式 UI 的必要性。

在第二部分,“探索 Compose UI 的构建块”,我们将研究 Compose 提供的最重要的一些可组合函数。之后,我们将看到如何预览我们的 Compose UI 以及活动如何渲染它。

我们将在“构建基于 Compose 的屏幕”部分创建我们的第一个关于餐厅的 Compose 项目。在最后一节,标题为“探索 Compose 列表”,我们将学习如何在 Compose 的帮助下正确地显示更多内容。

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

  • 理解 Compose 的核心概念

  • 探索 Compose UI 的构建块

  • 构建 Compose-based 屏幕

  • 使用 Compose 探索列表

    注意

    由于 Compose 是一个专门的本地 UI 框架,我们将简要介绍核心概念、常见组件和工具包的使用,而不深入探讨高级主题。

技术要求

当构建基于 Compose 的 Android 项目时,您通常需要日常的 Android 开发工具。然而,为了顺利跟进,请确保您拥有以下工具:

  • Android Studio 的 Arctic Fox 2020.3.1 版本。您也可以使用更新的 Android Studio 版本或甚至 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 必须在 Android Studio 中安装 Kotlin 1.6.10 或更高版本的插件。

  • Jetpack Compose 1.1.1 或更高版本。您应该遵循本章,并使用此版本的工程。如果您愿意,可以探索更新的版本,尽管可能会出现 API 差异。

您可以在此处找到包含本书源代码的 GitHub 仓库:github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/

要访问本章中展示的代码,请导航到Chapter_01目录。前两节中展示的代码片段可以在位于根目录的ExamplesActivity.kt文件中找到。我们将在本章的最后几节中开发的餐厅应用程序的项目编码解决方案可以在chapter_1_restaurants_app Android 项目目录中找到。

理解 Compose 的核心概念

Jetpack Compose 极大地改变了我们在 Android 上编写 UI 的方式。现在,UI 是用 Kotlin 开发的,这使我们可以使用称为可组合组件的控件编写新的声明性布局范式。

在本节中,我们将了解什么是可组合函数以及它们是如何用于编写 UI 的。我们将学习编程范式是如何转变的,以及现在如何强制执行组合,从而增加了我们定义 UI 的方式的灵活性。我们还将讨论 UI 内部的数据流以及尝试理解这些新概念带来的好处。

总结来说,我们将涵盖以下主题:

  • 使用可组合函数描述 UI

  • 在 Android 上创建 UI 的范式转变

  • 优先使用组合而非继承

  • 数据的单向流动

  • 重组

因此,让我们开始吧。

使用可组合函数描述 UI

Compose 允许您通过定义和调用表示屏幕上控件的@Composable注解来构建 UI。

Compose 在 Kotlin 的类型检查和代码生成阶段通过 Kotlin 编译器插件的帮助工作。Compose 编译器插件确保您可以创建可组合组件。

例如,一个显示文本片段的可组合组件可能看起来像这样:

@Composable
fun FriendlyMessage(name: String) {
   Text(text = "Greetings $name!")
}

在前面的代码块中,我们通过使用@Composable注解定义了FriendlyMessage可组合函数。通过查看函数定义和主体,我们可以轻松推断出它显示一个问候信息。

重要的是要注意,任何带有 @Composable 注解的函数都可以在屏幕上渲染,因为它将生成一个显示内容的 UI 层次结构。在真正意义上,可组合函数根据其定义发出 UI 小部件。

在我们的例子中,之前的函数应该通过将接收到的 String 参数值与预定义的消息连接起来来显示问候消息。由于该函数依赖于其输入参数以在每次使用时显示不同的消息,因此可以说可组合函数是数据(如下图中所示为 F(data))的函数,这些函数被转换为 UI 或小部件的片段:

![Figure 1.1 – 在 Compose 中,UI 是数据的功能img/B17788_01_01.jpg

Figure 1.1 – 在 Compose 中,UI 是数据的功能

在后面的 单向数据流 小节中,我们将了解为什么拥有描述 UI 小部件的函数对我们项目有益,因为它导致 UI 层更少出现错误。

回到我们的例子,你可能想知道 Text 函数调用代表什么。与每个其他框架一样,Compose 提供了 Text 等可组合函数,我们可以直接使用。

如其名所示,Text 可组合函数允许你在屏幕上显示一些文本。我们将在 探索 Compose UI 构建块 部分中介绍 Compose 提供的其他可组合函数。

在此之前,让我们再次查看之前的代码示例,并突出定义可组合函数时最重要的规则:

  • 它应该是一个带有 @Composable 注解的常规函数。

  • 其 UI 输出由通过其输入参数接收到的数据定义。由于可组合函数发出 UI 元素而不像常规函数那样返回数据,因此它们应该返回 Unit。大多数时候,我们省略定义 Unit 返回类型或甚至返回 Unit – 因为 Kotlin 将其标记为冗余 – 就像在之前的例子中那样。

  • 它可以包含其他可组合函数或常规 Kotlin 代码。在之前的例子中,FriendlyMessage 可组合函数使用了另一个名为 Text 的可组合函数,但它也可以调用常规 Kotlin 代码(我们将在接下来的章节中讨论这一点)。

  • 它应该以名词或以暗示形容词开头的名词(但永远不是动词)命名。这样,可组合函数设想的是小部件而不是动作。此外,其名称应遵守 PascalCase 命名约定,这意味着变量中每个复合词的第一个字母都应大写。

  • 建议该函数是公开的,而不是在类中定义,而是在 Kotlin 文件中直接定义。这样,Compose 促进可组合函数的重用。

现在我们已经了解了什么是可组合函数以及如何定义它,让我们继续探索 Compose 为 Android UI 开发带来的范式转变。

Android 上创建 UI 的范式转变

Compose 为 Android UI 开发带来了一种新的方法,那就是提供一种声明式的方式来描述你的 UI。在尝试理解声明式方法是如何工作之前,我们将学习传统的视图系统如何依赖于不同的范式——命令式范式。

命令式范式

当用 XML 描述你的 UI 时,你将视图层次结构表示为称为视图的组件树,这些组件通常被称为视图。在传统的视图系统中,视图是指从android.view.View类继承的所有组件,从TextViewButtonImageViewLinearLayoutRelativeLayout等等。

然而,对于视图系统来说,它所依赖的命令式范式是至关重要的。因为你的应用程序必须知道如何对用户交互做出反应,并相应地改变 UI 的状态,所以你可以通过findViewById调用引用你的视图,然后通过setText()setBackgroundResource()等调用更新它们的值。

由于视图保持其内部状态并公开设置器和获取器,你必须通过 imperative 方式为每个组件设置新的状态,如下面的图所示:

![图 1.2 – 命令式范式下的 Android 视图系统特性]

![img/B17788_01_02.jpg]

图 1.2 – 命令式范式下的 Android 视图系统特性

手动操作视图的状态会增加你 UI 中出错和出现 bug 的机会。因为你需要处理多个可能的状态,并且数据块在几个这样的状态下显示,所以很容易搞乱你 UI 的结果。当你的 UI 变得复杂时,非法状态或状态之间的冲突也相对容易出现。

此外,由于布局定义在额外的组件中——即 XML 文件——ActivityFragmentViewModel与基于 XML 的 UI 之间的耦合增加。这意味着在 XML 文件中更改 UI 将经常导致ActivityFragmentViewModel类中的更改,这是状态处理发生的地方。不仅如此,由于语言差异,内聚性也降低了:一个组件在 Java/Kotlin 中,而另一个在 XML 中。这意味着为了 UI 能够工作,它不仅需要一个ActivityFragment,还需要 XML。

声明式范式

为了解决标准视图系统中的某些问题,Compose 依赖于一个现代的声明式 UI 模型,这极大地简化了在 Android 上构建、更新和维护 UI 的过程。

如果在传统的视图系统中,命令式范式描述了 UI 应该如何改变,那么在 Compose 中,声明式范式描述了 UI 在某个特定时间点应该渲染的内容。

Compose 通过将屏幕定义为可组合物的树状结构来实现这一点。如下面的示例所示,每个可组合物都会将其数据传递给嵌套的可组合物,就像我们在上一节代码示例中的 FriendlyMessage 可组合物将一个名字传递给 Text 可组合物一样:

![图 1.3 – 可组合小部件树的可视化以及数据如何向下传递图片

图 1.3 – 可组合小部件树的可视化以及数据如何向下传递

当输入参数发生变化时,Compose 会从头开始重新生成整个小部件树。它应用必要的更改,并消除了手动更新每个小部件的需要和关联的复杂性。

这意味着在 Compose 中,可组合物相对无状态,因此它们不暴露 getter 和 setter 方法。这允许调用者对交互做出反应,并单独处理创建新状态的过程。它是通过调用相同的可组合物但使用不同的参数值来实现的。正如我们在 使用可组合函数描述 UI 部分中讨论的那样,Compose 中的 UI 是数据的函数。由此我们可以得出结论,如果向可组合物传递新数据,就可以产生新的 UI 状态。

最后,与视图系统相比,Compose 只依赖于 Kotlin API,这意味着 UI 现在可以用单一技术、单一组件来定义,从而增加了内聚性并减少了耦合性。

现在,让我们看看 Compose 带来的另一个设计上的转变,并讨论组合如何比继承提供更灵活的 UI 定义方式。

优先使用组合而非继承

在 Android 视图系统中,每个视图都从父 View 类继承功能。由于系统完全依赖于继承,因此创建自定义视图的任务只能通过定义复杂的层次结构来完成。

Button 视图为例。它从 TextView 继承功能,而 TextView 又从 View 继承:

![图 1.4 – Button 视图的类继承层次结构图片

图 1.4 – Button 视图的类继承层次结构

这种策略对于重用功能来说很棒,但当试图创建一个视图的多个变体时,继承变得难以扩展,并且灵活性很小。

假设你想要 Button 视图渲染图像而不是文本。在视图系统中,你必须创建一个全新的继承层次结构,如下面的层次结构图所示:

![图 1.5 – ImageButton 视图的类继承层次结构图片

图 1.5 – ImageButton 视图的类继承层次结构

但如果你需要一个同时容纳 TextViewImageView 的按钮呢?这个任务将极具挑战性,因此很容易得出结论,为每个自定义视图拥有独立的继承层次结构既不灵活也不可扩展。

这些例子是真实的,它们展示了视图系统的局限性。正如我们之前看到的,缺乏灵活性的最大原因之一是视图系统的继承模型。

为了解决这个问题,组合更倾向于组合而非继承。如图下所示,这意味着组合通过使用更小的组件来构建更复杂的 UI,而不是通过从一个单一父组件继承功能:

![图 1.6 – 继承与组合图片

图 1.6 – 继承与组合

让我们简要地解释一下我们之前关于继承和组合的比较:

  • 使用继承,你只能继承你的父组件,就像Button只从TextView继承一样。

  • 使用组合,你可以组合多个其他组件,就像Button可组合组件包含了一个ImageText可组合组件一样,这为你构建 UI 提供了更大的灵活性。

让我们尝试构建一个具有图像和文本的按钮的可组合组件。使用继承时,这是一个巨大的挑战,但 Compose 通过允许你在Button可组合组件内部组合ImageText可组合组件来简化了这一点:

@Composable
fun SuggestiveButton() {
    Button(onClick = { }) {
        Row() {
            Image(painter = 
                     painterResource(R.drawable.drawable),
                  contentDescription = "")
            Text(text = "Press me")
        }
    }
}

现在,我们的SuggestiveButton可组合组件包含了ImageText可组合组件。这种美在于它可以包含任何其他内容。一个Button可组合组件可以接受其他可组合组件,并将其作为其按钮主体的部分进行渲染。现在不必担心这个方面,也不必担心那个奇怪的名为Row的可组合组件。探索 Compose UI 构建块部分将更详细地介绍这两个方面。

从这个例子中,我们需要记住的是,Compose 为我们提供了轻松构建自定义 UI 的灵活性。接下来,让我们来探讨在 Compose 中数据和事件是如何流动的。

数据的单向流动

知道每个可组合组件都会将数据传递给其子可组合组件,我们可以推断出内部状态不再需要。这也转化为数据单向流动,因为可组合组件只期望作为输入的数据,而从不关心它们的状态:

![图 1.7 – 可视化数据与事件的单向流动图片

图 1.7 – 可视化数据与事件的单向流动

同样,在数据方面,每个可组合组件都会向下传递回调函数给其子可组合组件。但这次,回调函数是由用户交互引起的,它们创建了一个从每个嵌套可组合组件到其父组件以及更上层的回调函数的上游。这意味着不仅数据是单向的,事件也是如此,只是方向相反。

从这个例子中,很明显,数据和事件只在一个方向上流动,这是好事,因为只有一个真相来源——理想情况下是ViewModel——负责处理它们,这导致在 UI 扩展时出现更少的错误和更易于维护。

让我们考虑一个使用 Jetpack Compose 提供的另一个可组合组件Button的例子。正如其名所示,它在屏幕上发出一个按钮小部件,并且它公开一个名为onClick的回调函数,该函数在用户点击按钮时通知我们。

在以下示例中,我们的MailButton可组合组件接收一个电子邮件标识符mailId和一个事件回调函数mailPressedCallback

@Composable
fun MailButton(
    mailId: Int,
    mailPressedCallback: (Int) -> Unit
) {
    Button(onClick = { mailPressedCallback(mailId) }) {
        Text(text = "Expand mail $mailId")
    }
}

当它通过mailId接收数据时,它还会将mailPressedCallback函数设置为每次按钮被点击时调用,从而将事件发送回其父级。这样,数据向下流动,回调向上流动。

注意

构建您的 Compose UI 的理想方式是,由ViewModel提供的数据从父级可组合组件流向子级可组合组件,事件从每个可组合组件流向ViewModel。如果您不熟悉ViewModel组件,请不要担心,我们将在即将到来的第二章使用 Jetpack ViewModel 处理 UI 状态中介绍它。

重组合

我们已经介绍了如何通过输入数据定义可组合函数,并指出每当数据发生变化时,可组合组件都会重建,以渲染与新接收到的数据相对应的新 UI 状态。

当输入改变时再次调用您的可组合函数的过程称为重组合。当输入改变时,Compose 会自动为我们触发重组合过程并重建 UI 小部件树,重新绘制由可组合组件发出的小部件,以便它们显示新接收到的数据。

然而,重新组合整个 UI 层次结构是计算密集型的,这就是为什么 Compose 只调用具有新输入的函数,而跳过那些输入未改变的函数。优化重建可组合树的过程是一项复杂的工作,通常被称为智能重组合

注意

在传统的视图系统中,我们会手动调用视图的设置器和获取器,但使用 Compose,只需为我们提供的可组合组件提供新参数即可。这将允许 Compose 为 UI 的部分启动重组合过程,以便显示更新的值。

在深入实际的重组合示例之前,让我们快速看一下可组合函数的生命周期。其生命周期由组合生命周期定义,如下所示:

![图 1.8 – 可组合函数的组合生命周期]

![图片 B17788_01_08.jpg]

图 1.8 – 可组合函数的组合生命周期

这意味着一个可组合组件首先进入组合状态,在离开此过程之前,它可以按需重新组合多次——也就是说,在它从屏幕消失之前,它可以被重新组合和重建多次,每次可能显示不同的值。

重组通常是由State对象内部的变化触发的,因此让我们通过一个例子来探讨这一过程是如何在我们几乎没有干预的情况下无缝发生的。假设你有一个TimerText可组合组件,它期望显示一个特定的已过seconds数,这个数在一个Text可组合组件中显示。计时器从 0 开始,每秒更新一次(或 1,000 毫秒),显示已过的秒数:

var seconds by mutableStateOf(0)
val stopWatchTimer = timer(period = 1000) { seconds++ }
   ...
@Composable
fun TimerText(seconds: Int) {
   Text(text = "Elapsed: $seconds")
}

第二章定义和处理 Compose 状态部分,我们将更详细地定义 Compose 中的状态,但在此之前,让我们将seconds视为一个简单的状态对象(使用mutableStateOf()实例化),其初始值为0,并且其值会随时间变化,每次变化都会触发一次重组。

每当stopWatchTimer增加seconds状态对象的值时,Compose 都会触发一次重组,重建组件树并使用新的参数重新绘制可组合组件。

在我们的情况下,TimerText将因为接收不同的参数而进行重组或重建——第一次,它将接收0,然后是12,依此类推。这反过来又触发了Text可组合组件也进行重组,这就是为什么 Compose 会在屏幕上重新绘制它以显示更新的消息。

重组是一个复杂的话题。由于我们现在无法深入探讨,因此也很重要地介绍一些更高级的概念,如文档中所述:developer.android.com/jetpack/compose/mental-model#any-order

现在我们已经介绍了重组是什么以及 Compose 背后的核心概念,是时候更深入地了解用于构建 Compose UI 的可组合组件了。

探索 Compose UI 的构建块

我们迄今为止只简要地看了TextButton可组合组件。这就是为什么在本节中,我们不仅将了解活动如何渲染可组合组件而不是 XML,以及如何预览它们,而且还将更深入地了解最重要和最常用的可组合函数:从我们看到的,如TextButton,到新的如TextFieldImageRowColumnBox

总结来说,本节将涵盖以下主题:

  • 设置内容和预览可组合组件

  • 探索核心可组合组件

  • 使用修饰符自定义可组合组件

  • Compose 中的布局

让我们开始了解如何在屏幕上渲染可组合函数。

设置内容和预览可组合组件

我们快速浏览了一些可组合函数,但并没有涉及到使应用程序显示 Compose UI 的方面。

通过简单地替换传统的setContentView(R.layout.XML)调用为setContent()并将一个 composable 函数传递给它,就可以轻松设置 composable 内容,并鼓励你在Activity类中这样做:

import androidx.activity.compose.setContent
class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           Text("Hello world")
       }
   }
}

由于 Compose 不再需要AppCompat API 进行向后兼容,我们让我们的MainActivity继承自基础ComponentActivity类。

在前面的例子中,我们在MainActivityonCreate回调中调用了setContent方法,并将一个Text composable 函数传递给它。如果我们运行这个应用,我们将看到"Hello world"消息。

setContent方法是为ComponentActivity提供的扩展函数,它将给定的 composable 组合到给定的活动中。它只接受一个@Composable函数作为尾随 lambda。输入的 composable 函数将成为活动的根视图,并作为你的 Compose 层次结构的容器。

注意

你可以使用ComposeView类将 composable 函数添加到已经定义了 XML UI 的片段或活动中,但关于互操作性的细节我们将不会过多介绍。

由于 XML 为我们提供了一个预览工具,一个很好的问题是 Compose 是否也有一个。Compose 带来的是一个更强大的预览工具,它允许我们在每次想要查看 UI 如何演变时,无需在模拟器或真实设备上运行应用程序。

预览你的 composable 非常简单;只需将其添加到@Preview注解中:

@Preview(showBackground = true)
@Composable
fun FriendlyMessage() {
   Text(text = "Greetings!")
}

IDE 会自动检测你想要预览这个 composable,并将其显示在屏幕的右侧。请确保你重新构建你的项目并启用分割选项:

![Figure 1.9 – Previewing composable functions in Android Studio

![img/B17788_01_09.jpg]

图 1.9 – 在 Android Studio 中预览 composable 函数

可选地,你可以通过传递值为trueshowBackground参数来指定预览显示背景以获得更好的可见性。

注意

确保你想要预览的 composable 函数没有输入参数。如果有,请为它们提供默认值,以便预览工具可以工作。

尽管这个预览工具比这更强大,因为它支持交互模式,允许你与 UI 交互,以及实时编辑文本,如果启用,每次你更改宽度、高度或其他设置时,预览都会重新加载,就像真实的 UI 一样。你可以在下面的屏幕截图中看到这两个选项:

![Figure 1.10 – Using the Preview feature in Compose

![img/B17788_01_10.jpg]

图 1.10 – 在 Compose 中使用预览功能

注意

要在 Android Studio Arctic Fox 上启用交互模式,请转到文件 | 设置 | 实验性(Windows)或Android Studio | 首选项 | 实验性(macOS)。

此外,如果你在每个函数上使用 @Preview 注解,你可以同时拥有多个预览。你可以通过 name 参数为每个预览添加名称,甚至可以通过 device 参数告诉预览工具它应该在哪个设备上显示:

@Preview(
    name = "Greeting preview",
    showSystemUi = true,
    device = Devices.PIXEL_2_XL
)
@Composable
fun FriendlyMessagePreview() { Text(text = "Greetings!") }
@Preview(
        showSystemUi = true,
        device = Devices.NEXUS_5)
@Composable
fun FriendlyMessagePreview2() { Text(text = "Goodbye!") }

确保你也设置 showSystemUitrue 以查看整个设备。

备注

@Preview 函数应该有不同的名称,以避免预览冲突。

现在我们已经学习了如何设置和预览 Compose UI,是时候探索新的可组合组件了。

探索核心可组合组件

我们已经快速浏览了一些最基本的可组合函数:TextButtonImage。在本小节中,我们将花更多的时间探索这些可组合组件,以及新的组件,如 TextField

文本

Text 是我们旧的和喜爱的 TextView 的 Compose 版本。Text 由 Compose 提供,实现了任何应用程序中最基本但最重要的功能:显示文本的能力。我们已经在几个示例中使用了这个可组合组件:

Text(text = "Greetings $name!")

你可能想知道我们如何自定义它。让我们查看 Text 的源代码或文档,以找到它最基本的和最常用的参数:

  • text 是唯一的必填参数。它期望一个 String 并设置输出文本。

  • color 指定输出文本的颜色,并期望一个 Color 对象。

  • fontSize 类型为 TextUnitfontStyle 类型为 FontStylefontFamily 类型为 FontFamilyfontWeight 类型为 FontWeight 都允许你自定义文本的外观和样式。

  • textAlign 指定文本的水平对齐方式。它期望一个 TextAlign 对象。

  • maxLines 期望一个 Int 值,用于设置输出文本的最大行数。

  • style 期望一个 TextStyle 对象,并允许你通过主题定义和重用样式。

我们不是逐个检查 Text 的所有参数,而是看看一个我们可以自定义 Text 可组合函数外观的例子:

@Composable
fun MyAppText() {
   Text(
       text = stringResource(id = R.string.app_name),
       fontStyle = FontStyle.Italic,
       textAlign = TextAlign.Center,
       color = Color.Magenta,
       fontSize = 24.sp,
       fontWeight = FontWeight.ExtraBold)
}

而不是传递一些硬编码的文本,我们通过内置的 stringResource 函数传递了一个字符串资源,并得到了以下结果:

图 1.11 – 探索自定义的 Text 可组合组件

图 1.11 – 探索自定义的 Text 可组合组件

现在我们已经学会了如何使用 Text 可组合组件显示文本,接下来让我们转向按钮。

按钮

在任何应用程序中,显示文本都是至关重要的,但拥有可点击的按钮可以让应用程序变得交互式。我们之前已经使用过 Button 可组合组件(在视图系统中也称为 Button),其主要特点是 onClick 回调函数,它会通知我们用户何时按下按钮。

虽然 Button 有很多自定义参数,但让我们看看最常用的参数:

  • onClick 是一个必填参数,它期望一个函数,当用户按下按钮时将被调用。

  • colors期望一个定义内容/背景颜色的ButtonColors对象。

  • shape期望一个自定义/材料主题Shape对象,用于设置按钮的形状。

  • content是一个强制参数,它期望一个显示此Button内部内容的可组合函数。我们可以在其中添加任何可组合函数,包括TextImage等。

让我们尝试构建一个Button函数,使其利用这些核心参数:

@Composable
fun ClickableButton() {
   Button(
       onClick = { /* callback */ },
       colors = ButtonDefaults.buttonColors(
           backgroundColor = Color.Blue,
           contentColor = Color.Red),
       shape = MaterialTheme.shapes.medium
   ) { Text("Press me") }
}

我们还传递了一个预定义的MaterialTheme形状。让我们预览生成的可组合函数:

![图 1.12 – 探索自定义按钮可组合函数![图 1.12 – 探索自定义按钮可组合函数图 1.12 – 探索自定义按钮可组合函数有了这个,我们已经看到了如何轻松地使用Button可组合函数创建自定义按钮。接下来,让我们尝试玩转另一个可组合函数——TextField。### TextField 添加按钮是拥有交互式 UI 的第一步,但这个领域最重要的元素是TextField可组合函数,在视图系统中之前被称为EditText。就像EditText所做的那样,TextField可组合函数允许用户输入和修改文本。虽然TextField有很多参数,但它最重要的参数如下:+ value是一个强制String参数,因为它是显示的文本。这个值应该在我们输入时改变,通过将其保持在State对象中;关于这一点,我们很快就会详细介绍。+ onValueChange是一个强制函数,每次用户输入新字符或删除现有字符时都会触发。+ label期望一个可组合的函数,允许我们添加一个描述性标签。让我们看看一个简单的TextField用法,它也处理自己的状态:java@Composablefun NameInput() {   val textState = remember { mutableStateOf("") }   TextField(        value = textState.value,        onValueChange = { newValue ->            textState.value = newValue        },        label = { Text("Your name") })}它通过定义一个MutableState来持有TextField显示的文本来实现这一点。这意味着textState在重新组合过程中不会改变,所以每次 UI 因为其他可组合函数而更新时,textState都应该被保留。此外,我们还在MutableState对象上包裹了一个remember块,这告诉 Compose 在重新组合过程中,它不应该将值重置为其初始值;即""。要获取或设置StateMutableState对象的值,我们的NameInput可组合函数使用value访问器。因为TextField通过value访问器访问MutableState对象,所以 Compose 知道每次textState值改变时——在我们的案例中,在onValueChange回调中——重新触发重新组合。通过这样做,我们确保当我们输入TextField中的文本时,UI 也会更新,以显示已添加或从键盘删除的新字符。如果 Compose 中的状态概念现在不太容易理解,请不要担心——我们将在第二章中更详细地介绍如何在 Compose 中定义状态,即使用 Jetpack ViewModel 处理 UI 状态。注意:与 EditText 不同,TextField 没有内部状态。这就是为什么我们创建并处理了它;否则,当我们输入时,UI 不会相应地更新。结果的 NameInput 可组合项正确更新了 UI,看起来像这样:图 1.13 – 探索 TextField 可组合项

图 1.13 – 探索 TextField 可组合项

现在我们已经学会了如何在基于 Compose 的应用程序中添加输入字段,是时候探索任何 UI 中最常见的元素之一了。

图像

在我们的应用程序中显示图形信息是至关重要的,Compose 提供了一个方便的可组合项,称为 Image,它是 View 系统中 ImageView 的可组合版本。

虽然 Image 具有许多自定义参数,但让我们检查一下最常用的参数:

  • painter 期望一个 Painter 对象。此参数是必需的,因为它设置了图像资源。作为替代,你可以使用 Image 的重载版本,直接将 ImageBitmap 对象传递给其 bitmap 参数。

  • contentDescription 是一个必需的 String,它被辅助功能服务使用。

  • contentScale 期望一个 ContentScale 对象,该对象指定了图片的缩放。

让我们添加一个显示应用程序图标的 Image 可组合项,使用 painterResource

@Composable
fun BeautifulImage() {
    Image(
        painter =
           painterResource(R.drawable.ic_launcher_foreground),
        contentDescription = "My app icon",
        contentScale = ContentScale.Fit
    )
}

最后,让我们预览 BeautifulImage 函数,然后继续下一节:

图 1.14 – 探索 Image 可组合项

图 1.14 – 探索 Image 可组合项

我们也尝试过使用 Compose 显示图片,但你可能仍在 wondering,我们如何自定义所有这些可组合函数?

使用修饰符自定义可组合项

我们到目前为止所涵盖的所有可组合项都包含一个我们尚未涵盖的参数:modifier。它期望一个 Modifier 对象。简单来说,修饰符告诉一个可组合项如何在父可组合项中显示、排列或表现。通过传递修饰符,我们可以为可组合项指定许多配置:从大小、填充或形状到背景颜色或边框。

让我们从一个例子开始,使用 Box 可组合项并为其指定一个 size 修饰符:

@Composable
fun ColoredBox() {
   Box(modifier = Modifier.size(120.dp))
}

我们将在稍后介绍 Box 可组合项,但在此之前,你可以将其视为一个容器,我们将使用它来在屏幕上绘制几个形状。这里重要的是我们传递了 Modifier.size() 修饰符,它设置了盒子的大小。它接受一个 dp 值,代表可组合项的宽度和高度。你还可以在 size() 修饰符内部或使用 height()width() 修饰符单独传递宽度和高度作为参数。

通常只为可组合项指定一个修饰符是不够的。这就是为什么 修饰符可以被链式调用。让我们通过向我们的 Box 添加几个其他配置来链式调用多个修饰符:

@Composable
fun ColoredBox() {
   Box(modifier = Modifier
           .size(120.dp)
           .background(Color.Green)
           .padding(16.dp)
           .clip(RoundedCornerShape(size = 20.dp))
           .background(Color.Red))
}

如我们之前提到的,链式修饰符很简单:从一个空的Modifier对象开始,然后依次链接着新的修饰符。我们已经链接着几个新的修饰符,从background开始,然后是paddingclip,最后是另一个background。当这些修饰符组合在一起时,产生了一个输出,它是一个绿色的矩形,其中包含一个嵌套的红色圆角矩形:

![图 1.15 – 探索链式修饰符图片

图 1.15 – 探索链式修饰符

注意

链中修饰符的顺序很重要,因为修饰符是从外层应用到内层的。每个修饰符都会修改可组合组件,并为链中的下一个修饰符准备它。不同的修饰符顺序会产生不同的结果。

在前面的例子中,因为修饰符是从最外层应用到最内层的,所以整个矩形框是绿色的,因为绿色是第一个应用的颜色修饰符。向内移动,我们应用了 16 dp的内边距。之后,仍然向内移动,应用了RoundedCornerShape修饰符。最后,在最内层,我们应用了另一个颜色修饰符——这次是红色——我们得到了最终的结果。

现在我们已经玩转了最常见的可组合组件,是时候开始构建实际布局了,这些布局使用了多个可组合函数。

Compose 中的布局

通常,即使按照前面的例子,也无法仅通过构建一个简单的屏幕,因为其中大多数例子只包含一个可组合组件。对于简单的用例,可组合函数只包含一个子可组合组件。

要构建更复杂的 UI 组件,Compose 中的布局组件允许您添加所需数量的子可组合组件。

在本节中,我们将介绍那些允许您以线性或叠加方式放置子可组合组件的可组合函数,例如以下内容:

  • Row用于水平排列子可组合组件

  • Column用于垂直排列子可组合组件

  • Box用于将子可组合组件堆叠在一起

根据这些定义,让我们通过以下图表来设想布局可组合组件:

![图 1.16 – 探索 Column、Row 和 Box图片

图 1.16 – 探索 Column、Row 和 Box

现在很清楚,通过ColumnRowBox以不同的方式排列子可组合组件可以很容易地实现,所以现在是时候更详细地研究它们了。

在屏幕上显示多个小部件是通过使用一个Row可组合组件来水平排列其子可组合组件来实现的,就像旧的具有水平方向的LinearLayout一样:

@Composable
fun HorizontalNumbersList() {
   Row(
       horizontalArrangement = Arrangement.Start,
       verticalAlignment = Alignment.CenterVertically,
       modifier = Modifier.fillMaxWidth()
   ) {
       Text("1", fontSize = 36.sp)
       Text("2", fontSize = 36.sp)
       Text("3", fontSize = 36.sp)
       Text("4", fontSize = 36.sp)
   }
}

我们已将 Row 设置为仅占用可用宽度,并添加了多个 Text 函数作为子元素可组合。这次,我们指定了 horizontalArrangementStart,这样它们就从父元素的左侧开始,同时也确保它们通过传递 verticalAlignment 参数的 CenterVertically 对齐来垂直居中。结果是直接的:

![图 1.17 – 探索 Row 可组合元素图片 B17788_01_17.jpg

图 1.17 – 探索 Row 可组合元素

主要来说,Row 可组合元素的基本参数与子元素的排列或对齐方式相关:

  • horizontalArrangement 定义了子元素相对于彼此以及相对于父元素 Row 的水平位置。除了 Arrangement.Start,还可以传递 CenterEndSpaceBetweenSpaceEvenlySpaceAround

  • verticalAlignment 设置了子元素在父元素 Row 中的垂直位置。除了 Alignment.CenterVertically,还可以传递 TopBottom

现在我们已经水平排列了子元素可组合,让我们尝试垂直排列它们。

Column

在屏幕上显示垂直列表可以通过使用排列其子元素可组合元素的垂直 Column 可组合元素来实现,就像旧的具有垂直方向的 LinearLayout 一样:

@Composable
fun NamesVerticalList() {
   Column(verticalArrangement = Arrangement.SpaceEvenly,
       horizontalAlignment = Alignment.CenterHorizontally,
       modifier = Modifier.fillMaxSize()
   ) {
       Text("John", fontSize = 36.sp)
       Text("Amanda", fontSize = 36.sp)
       Text("Mike", fontSize = 36.sp)
       Text("Alma", fontSize = 36.sp)
   }
}

我们已将 Column 设置为占用所有可用空间,并添加了多个 Text 函数作为子元素可组合。这次,我们指定了 verticalArrangementSpaceEvenly,这样子元素在父元素内部均匀分布,但我们还确保它们通过传递 horizontalAlignment 参数的 CenterHorizontally 对齐来水平居中:

![图 1.18 – 探索 Column 可组合元素图片 B17788_01_18.jpg

图 1.18 – 探索 Column 可组合元素

Row 类似,Column 的基本参数也与子元素的排列或对齐方式相关。不过,这次排列是垂直的,而对齐是水平的:

  • verticalArrangement 定义了子元素在父元素 Column 中的垂直位置。这些值与行的 horizontalArrangement 相同。

  • horizontalAlignment 定义了子元素在父元素 Column 中的对齐方式。除了 Alignment.CenterHorizontally,还可以传递 StartEnd

    注意

    如果你感到勇敢,现在是探索不同对齐和排列方式的好时机,看看 UI 如何变化。确保使用 @Preview 注解预览你的可组合函数。

Box

到目前为止,我们已经学习了如何水平垂直排列子元素,但如果我们想将它们堆叠在一起呢?Box 可组合元素就派上用场了,因为它允许我们堆叠子元素可组合。Box 还允许我们将子元素相对于它进行定位。

让我们尝试构建自己的 Box。我们将在 Box 内部堆叠两个可组合元素:

  • 一个绿色圆圈,将使用 Surface 的帮助创建。Surface 可组合项允许您轻松定义具有特定形状、背景或高度的材质表面。

  • Text 可组合项内部添加的一个加号(+),该可组合项与其父 Box 的中心对齐。

这就是代码的样子:

@Composable
fun MyFloatingActionButton() {
   Box {
       Surface(
           modifier = Modifier.size(32.dp),
           color = Color.Green,
           shape = CircleShape,
           content = { })
       Text(text = "+",
            modifier = Modifier.align(Alignment.Center))
   }
}

Surface 可组合项使用一个强制性的 content 参数定义,该参数接受另一个可组合项作为其内部内容。我们不想在其中添加可组合项。相反,我们想在它上面堆叠一个 Text 可组合项,因此我们将一个空函数传递给 content 参数。

结果类似于我们所有人都习惯的 FAB:

图 1.19 – 探索 Box 可组合项

图 1.19 – 探索 Box 可组合项

要充分利用 Box,您必须注意以下几点:

  • Box 内部添加可组合项的顺序定义了它们被绘制和堆叠的顺序。如果您交换 SurfaceText 的顺序,+ 图标将被绘制在绿色圆圈下方,使其变得不可见。

  • 您可以通过为每个子项的对齐修饰符传递不同的值来相对于 Box 父项对齐子项可组合项。这就是为什么,除了 Alignment.Center 之外,您还可以使用 CenterStartCenterEndTopStartTopCenterTopEndBottomStartBottomEndBottomCenter 来定位子项可组合项。

现在我们已经涵盖了基础知识,是时候卷起袖子创建我们的第一个 Compose 项目了!

构建基于 Compose 的屏幕

假设我们想要构建一个展示一些餐厅的应用程序。我们将使用 Compose 构建 UI,并逐步创建一个新的 Compose 项目。然后我们将为这样的餐厅构建一个列表项,并最终显示一个此类项目的示例列表。

总结来说,在本节中,我们将构建我们的第一个基于 Compose 的应用程序:一个餐厅探索应用程序!为了实现这一点,我们必须显示一些餐厅,我们将通过以下主题来完成:

  • 创建您的第一个 Compose 项目

  • 构建餐厅元素布局

  • 使用 Compose 显示餐厅列表

现在我们已经明确了路径,让我们开始吧。

创建您的第一个 Compose 项目

要构建餐厅应用程序,我们必须创建一个新的基于 Compose 的项目:

  1. 打开 Android Studio 并选择 新建项目 选项:

图 1.20 – 使用 Android Studio 开始新项目

图 1.20 – 使用 Android Studio 开始新项目

如果您已经打开了 Android Studio,请转到 文件,然后是 新建,最后是 新建项目

注意

确保您拥有 Arctic Fox 2020.3.1 或更高版本的 Android Studio。如果您使用的是较新版本,则某些文件可能生成的代码存在差异。

  1. 手机和平板 模板部分,选择 空 Compose 活动,然后选择 下一步

图 1.21 – 使用 Android Studio 开始新项目

  1. 接下来,输入一些关于您应用程序的详细信息。在Restaurants app中。将语言保留为Kotlin,并将最小 SDK设置为API 21。然后,单击完成

    重要提示

    即将进行的步骤是一个重要的配置步骤。它确保项目 Android Studio 为您配置的依赖项版本(从 Compose 到 Kotlin 和其他依赖项)与我们全书所使用的版本相同。通过这样做,您将能够跟随代码片段并检查代码源,而无需担心任何 API 差异。

  2. 在新创建的项目中,在检查代码之前,请确保生成的项目使用了全书所使用的依赖项版本。

要这样做,首先转到项目级别的build.gradle文件,并在dependencies块内确保 Kotlin 版本设置为1.6.10

buildscript {
    […]
    dependencies {
        classpath "com.android.tools.build:gradle:7.0.2"
        classpath "org.jetbrains.kotlin:kotlin-gradle- 
            plugin:1.6.10"
      […]
    }
}

或者,如果您使用的是较新版本的 Android Studio,您可能会在plugins块中找到本项目中使用的 Kotlin 版本,如下所示:

plugins {
    […]
    id 'org.jetbrains.kotlin.android' version '1.6.10' 
        apply false
}

如果您还没有安装,可能需要安装 Android Studio 中 1.6.10 插件的版本。要做到这一点,请单击1.6.10

仍然在项目级别的build.gradle文件中,因为 Compose 与我们在项目中使用的 Kotlin 版本相关联,请确保在ext { }块内将 Compose 版本设置为1.1.1

buildscript {
    ext {
        compose_version = '1.1.1'
    }
    repositories {…}
    dependencies {…}
}

然后,进入应用级别的build.gradle文件。首先检查composeOptions { }块看起来是否如下:

plugins { ... }
android {
    [...]
    buildFeatures { compose true }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
    }
    packagingOptions { ... }
}

在某些版本的 Android Studio 中,composeOptions { }块可能会添加一个过时的kotlinCompilerVersion '1.x.xx'行,应该将其删除。

最后,确保应用级别的build.gradle文件的dependencies块包含以下版本的依赖项:

dependencies {
    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.4.1'
    implementation 'com.google.android.material:
        material:1.5.0'
    implementation "androidx.compose.ui:ui:
        $compose_version"
    implementation "androidx.compose.material:
        material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling-
        preview:$compose_version"
    implementation 'androidx.lifecycle:lifecycle-
        runtime-ktx:2.4.1'
    implementation 'androidx.activity:activity-
        compose:1.4.0'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation
        'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 
        'androidx.test.espresso:espresso-core:3.4.0'
    androidTestImplementation "androidx.compose.ui:ui-
        test-junit4:$compose_version"
    debugImplementation "androidx.compose.ui:ui-
        tooling:$compose_version"
}

如果您必须进行任何更改,通过在 Android Studio 中单击同步项目与 Gradle 文件按钮或通过按文件菜单选项然后选择同步项目与 Gradle 文件来同步您的项目与 Gradle 文件。

现在我们已经设置好了。让我们回到 Android Studio 生成的源代码。

现在我们已经到达了这里——我们的第一个 Compose 项目已经设置好了!让我们通过导航到MainActivity.kt文件来查看源代码。我们可以得出结论,它由三个主要部分组成:

  • MainActivity

  • Greeting可组合函数

  • DefaultPreview可组合函数

MainActivity类是将内容传递到onCreate回调中的setContent方法的地点。正如我们所知,我们需要调用setContent来设置 Compose UI 并传递可组合函数作为我们的 UI:

setContent {
   RestaurantsAppTheme {
       Surface(color = MaterialTheme.colors.background) {
           Greeting("Android")
       }
   }
}

IDE 模板已经实现了一个Greeting可组合函数,它被包裹在一个使用主题背景色的Surface中。但那个作为setContent方法父可组合函数传递的RestaurantsAppTheme函数是什么?

如果你按Ctrl + BCommand + B在函数名上,你将被带到生成主题的Theme.kt文件,这是我们的主题生成的地方。RestaurantsAppTheme是一个由 IDE 自动生成的可组合函数,因为它包含了应用的名字:

@Composable
fun RestaurantsAppTheme(
   darkTheme: Boolean = isSystemInDarkTheme(),
   content: @Composable() -> Unit
) {
   ...
   MaterialTheme(
       colors = colors,
       typography = Typography,
       shapes = Shapes,
       content = content)
}

该应用的主题是MaterialTheme的包装,如果我们将其传递给setContent调用,它允许我们在应用主题内部重用自定义样式和配色方案。为了使其生效并重用自定义样式,我们必须将我们的可组合函数传递给主题可组合的content参数——在我们的案例中,在MainActivity中,将包裹在Surface可组合中的Greeting可组合传递给RestaurantsAppTheme可组合。

让我们回到MainActivity.kt文件中,看看 Android Studio 生成的其他部分。我们可以看到,Greeting可组合通过Text显示文本,类似于我们之前的示例中的可组合函数。

为了预览Greeting可组合,IDE 还为我们生成了一个名为DefaultPreview的预览可组合,它允许我们预览MainActivity显示的内容;即Greeting。它还使用了主题可组合来获取一致的主题 UI。

现在我们已经实现了一个大里程碑,即创建了一个基于 Compose 的应用程序,现在是时候开始着手我们的餐厅应用了!

构建餐厅元素布局

是时候动手构建应用中餐厅的布局了:

  1. 通过左键单击应用程序包并选择RestaurantsScreen作为名称,然后选择类型为文件来创建一个新文件。

  2. 在这个文件中,让我们为我们的第一个 Compose 屏幕创建一个RestaurantsScreen可组合函数:

    @Composable
    fun RestaurantsScreen() {
       RestaurantItem()
    }
    
  3. 接下来,在RestaurantsScreen.kt文件内部,让我们定义RestaurantItem可组合,它具有带有提升和填充的Card可组合:

    @Composable
    fun RestaurantItem() {
        Card(elevation = 4.dp,
             modifier = Modifier.padding(8.dp)
        ) {
            Row(verticalAlignment =
                    Alignment.CenterVertically,
                modifier = Modifier.padding(8.dp)) {
                RestaurantIcon(
                    Icons.Filled.Place,
                    Modifier.weight(0.15f))
                RestaurantDetails(Modifier.weight(0.85f))
            }
        }
    }
    

确保你包含的每个导入都是androidx.compose.*包的一部分。如果你不确定要包含哪些导入,请查看以下 URL 中RestaurantsScreen.kt文件的源代码:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/blob/main/Chapter_01/chapter_1_restaurants_app/app/src/main/java/com/codingtroops/restaurantsapp/RestaurantsScreen.kt

回到之前的代码片段,我们可以这样说,Card可组合类似于旧视图系统中的Cardview,因为它允许我们通过边框或提升美化代表餐厅的 UI 组件。

在我们的案例中,Card 包含一个 Row 组合器,其子组合器在垂直方向上居中,并且周围有一些填充。我们使用 Row 是因为我们将以水平方式显示一些关于餐厅的详细信息:一个图标和一些文本细节。

我们将 RestaurantIconRestaurantDetails 组合器作为 Row 组合器的子项传递,但这些函数尚未定义,因此我们遇到了编译错误。现在,不要担心权重修饰符。让我们首先定义 RestaurantIcon 组合器!

  1. 仍然在 RestaurantsScreen.kt 文件中,创建另一个名为 RestaurantIcon 的组合器函数,其代码如下:

    @Composable
    private fun RestaurantIcon(icon: ImageVector, modifier: Modifier) {
       Image(imageVector = icon,
             contentDescription = "Restaurant icon",
             modifier = modifier.padding(8.dp))
    }
    

RestaurantIcon 组合器将 ImageVector 图标设置到 Image 组合器中 - 在我们的案例中,是一个预定义的 Material Theme 图标,称为 Icons.Filled.Place。它还设置了一个 contentDescription 值,并在它接收到的修饰符上添加了填充。

然而,最有趣的部分是 RestaurantIcon 接收来自其父 RowModifier 作为参数。它接收到的参数是 Modifier.weight(0.15f),这意味着我们的 Row 为其水平定位的每个子项分配了权重。这个值 - 在这种情况下,0.15f - 意味着这个子 RestaurantIcon 将从其父 Row 中占用 15% 的水平空间。

  1. 现在,仍然在 RestaurantsScreen.kt 文件中,创建一个显示餐厅详细信息的 RestaurantDetails 函数:

    @Composable
    private fun RestaurantDetails(modifier: Modifier) {
       Column(modifier = modifier) {
           Text(text = "Alfredo's dishes",
                style = MaterialTheme.typography.h6)
           CompositionLocalProvider(
               LocalContentAlpha provides 
                   ContentAlpha.medium) {
               Text(text = "At Alfredo's … seafood dishes.",
                    style = MaterialTheme.typography.body2)
           }
       }
    }
    

类似地,RestaurantDetails 接收来自 RowModifier.weight(0.85f) 修饰符作为参数,这将使其占据剩余的 85% 水平空间。

RestaurantDetails 组合器是一个简单的 Column,垂直排列了两个 Text 组合器,一个是餐厅的标题,另一个是它的描述。

CompositionLocalProvider 是怎么回事?为了显示与标题相比变暗的描述,我们应用了一个 LocalContentAlphaContentAlpha.medium。这样,带有餐厅描述的子 Text 将被淡化或灰色显示。

CompositionLocalProvider 允许我们将数据传递到组合器层次结构中。在这种情况下,我们希望子 Text 被灰色显示,因此我们使用后缀 provides 方法传递了一个具有 ContentAlpha.medium 值的 LocalContentAlpha 对象。

  1. 暂时转到 MainActivity.kt 并删除 DefaultPreview 组合器函数,因为我们将在下一个步骤中定义自己的 @Preview 组合器。

  2. 返回 RestaurantsScreen.kt 文件内部,定义一个 @Preview 组合器:

    @Preview(showBackground = true)
    @Composable
    fun DefaultPreview() {
       RestaurantsAppTheme {
           RestaurantsScreen()
       }
    }
    

如果你为你的应用程序选择了不同的名称,你可能需要更新之前在 Theme.kt 文件中定义的主题组合器代码片段。

  1. 重新构建项目,并通过预览新创建的 DefaultPreview 组合器来检查 RestaurantsScreen() 组合器,该组合器应显示一个餐厅项目:

图 1.22 – 预览餐厅项目

  1. 最后,回到 MainActivity.kt 文件,移除 Greeting 可组合组件。同时,在 setContent 方法中移除 SurfaceGreeting 函数调用,并用 RestaurantScreen 替换:

    setContent {
       RestaurantsAppTheme {
           RestaurantsScreen()
       }
    }
    

通过将 RestaurantScreen 传递给 MainActivitysetContent 方法,我们确保在构建和运行时应用程序将渲染所需的 UI。

  1. 可选地,你现在可以运行应用程序,直接在你的设备或模拟器上查看餐厅。

现在我们已经为餐厅构建了一个布局,是时候学习如何显示更多餐厅了!

使用 Compose 显示餐厅列表

到目前为止,我们已经显示了一个餐厅项,现在是时候显示整个列表了:

  1. 首先,在根包中创建一个新类,在 MainActivity.kt 旁边,命名为 Restaurant.kt。在这里,我们将添加一个名为 Restaurantdata class 并添加我们期望餐厅拥有的字段:

    data class Restaurant(val id: Int,
                          val title: String,
                          val description: String)
    
  2. 在相同的 Restaurant.kt 文件中,创建一个 Restaurant 项目的示例列表,最好至少有 10 个以填充整个屏幕:

    data class Restaurant(val id: Int,
                          val title: String,
                          val description: String)
    val dummyRestaurants = listOf(
        Restaurant(0, "Alfredo foods", "At Alfredo's …"),
        [...],
        Restaurant(13, "Mike and Ben's food pub", "")
    )
    

你可以在本书的 GitHub 仓库中找到预填充的列表,位于 Restaurant.kt 文件中:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/blob/main/Chapter_01/chapter_1_restaurants_app/app/src/main/java/com/codingtroops/restaurantsapp/Restaurant.kt.

  1. 回到 RestaurantsScreen.kt 文件,并更新你的 RestaurantItem,使其接收一个 Restaurant 对象作为参数,同时将餐厅的 titledescription 作为参数传递给 RestaurantDetails 可组合组件:

    @Composable
    fun RestaurantItem(item: Restaurant) {
        Card(...) {
            Row(...) {
                RestaurantIcon(...)
                RestaurantDetails(
                    item.title,
                    item.description,
                    Modifier.weight(0.85f)
                )
            }
        }
    }
    
  2. 我们已经将餐厅的 titledescription 作为参数传递给了 RestaurantDetails 可组合组件。在 RestaurantDetails 可组合组件中传播这些更改,并将 title 传递给第一个 Text 可组合组件,将 description 传递给第二个 Text 可组合组件:

    @Composable
    fun RestaurantDetails(title: String, description: String, modifier: Modifier){
       Column(modifier = modifier) {
           Text(text = title, ...)
           CompositionLocalProvider( … ) {
               Text(text = description, ...)
           }
       }
    }
    
  3. 回到 RestaurantsScreen 可组合组件,并更新它以显示 Restaurant 对象的垂直列表。我们已经知道我们可以使用 Column 来实现这一点。然后,遍历 dummyRestaurants 中的每个餐厅并将其绑定到 RestaurantItem

    @Composable
    fun RestaurantsScreen() {
       Column {
           dummyRestaurants.forEach { restaurant ->
               RestaurantItem(restaurant)
           }
       }
    }
    

这样将创建一个漂亮的垂直列表,我们可以通过 DefaultPreview 可组合组件来预览。

  1. 重建项目以查看由 DefaultPreview 可组合组件生成的更新预览:

![图 1.23 – 使用 Column 可组合组件预览 RestaurantsScreen

![img/B17788_01_23.jpg]

图 1.23 – 使用 Column 可组合组件预览 RestaurantsScreen

或者,你可以运行应用程序,直接在你的设备或模拟器上查看餐厅。

我们终于用 Compose 创建了第一个列表!它看起来非常漂亮,但有一个巨大的问题 – 它不能滚动!我们将在下一节中一起解决这个问题。

探索 Compose 中的列表

在上一节中,我们构建了一个基于 Compose 的屏幕,其中包含餐厅列表。然而,如果你在交互模式下运行应用程序或预览屏幕,你会注意到列表无法滚动。这是一个巨大的不便,我们将在本节中通过向我们的 Column 可组合组件添加滚动功能来解决它。

接下来,我们将说明为什么 Column 适合静态内容,而如果列表很大且其大小是动态的或由服务器响应决定的,我们应该使用 懒加载可组合组件。我们将探索各种懒加载可组合组件,并了解为什么它们更适合大型列表。

总结来说,本节将涵盖以下主题:

  • Column 可组合组件添加滚动

  • 介绍懒加载可组合组件

  • 使用 LazyColumn 显示餐厅

让我们从向我们的 RestaurantsScreen 可组合组件添加滚动功能开始。

向 Column 可组合组件添加滚动

我们的餐厅列表很长,无法滚动。这是一个糟糕的用户体验,所以让我们来修复它。

让我们通过传递一个接收 ScrollStateModifier.verticalScroll 修饰符来使 Column 可滚动:

@Composable
fun RestaurantsScreen() {
   Column(Modifier.verticalScroll(rememberScrollState())) {
       ...
   }
}

我们希望滚动位置在重新组合时保持不变。这就是为什么,通过将 rememberScrollState 传递给 verticalScroll 修饰符,我们确保每次 UI 重新组合时,滚动状态都会被记住并保留。rememberScrollState 持久化机制类似于我们之前使用的 remember { } 块,用于在重新组合之间保留 TextField 的状态。

现在,你可以 运行 应用或以 交互模式 预览它,并检查滚动效果。

然而,我们还有一个与 Column 如何布局和组合其元素相关的问题。现在让我们深入探讨这个问题,并尝试找到一个更好的替代方案。

介绍懒加载可组合组件

让我们从我们的餐厅应用中短暂休息一下,并尝试思考处理大型列表的更好方法。使用 RowColumn 来显示长列表的项目,或者可能是一个未知大小的列表,可能会对你的 UI 和应用性能产生负面影响。这是因为 RowColumn 会渲染或布局所有子元素,无论它们是否可见。它们适合显示静态内容,但传递一个大型列表可能会导致你的 UI 变得卡顿,甚至无法使用。

两个名为 LazyColumnLazyRow 的懒加载可组合组件将帮助你解决问题,因为它们只组合或输出屏幕上当前可见的项目,因此得名 lazy。所以,正如你所见,它们在某种程度上类似于旧的 RecyclerView

作为“行”和“列”之间唯一的区别是儿童在屏幕上的排列方式——水平或垂直——同样的情况也适用于LazyRowLazyColumn。这些懒加载的可组合组件会水平或垂直排列其子组件,并自带滚动功能。由于它们只渲染可见项,懒加载的可组合组件非常适合大型列表。

然而,懒加载的可组合组件与我们迄今为止使用的常规可组合组件不同。这主要是因为它们不是接受@Composable内容,而是暴露一个LazyListScope块:

@Composable
fun LazyColumn(
   ...
   content: LazyListScope.() -> Unit
) { … }

LazyListScope DSL 允许我们描述我们想要作为列表一部分显示的内容。最常用的有item()items()。以下是一个使用 DSL 的LazyColumn示例用法:

LazyColumn {
   item() {
       Text(text = "Custom header item")
   }
   items(myLongList) { myItem ->
       MyComposable(myItem)
   }
   item(2) {
       Text(text = "Custom footer item")
   }
}

item()向列表添加单个可组合元素,而items()不仅可以接收如myLongList这样的独立内容列表,还可以接收一个Int,这将多次添加相同的项。

我们之前展示的代码应该渲染一个包含以下内容的垂直列表:

  • 一个标题Text可组合组件

  • 一个与myLongList大小相同的MyComposable可组合组件列表

  • 两个Text页脚可组合组件

返回到 DSL 世界,懒加载的可组合组件的一个值得注意的论点是contentPadding,它允许你定义围绕你的列表的水平/垂直填充。这个参数期望一个PaddingValues对象——我们很快就会使用它;不要担心!

现在,我们很快就会从远程服务器接收餐厅,这意味着我们不知道列表的大小,因此是时候在我们的餐厅应用程序中实现这样的懒加载可组合组件了。

使用LazyColumn显示餐厅

我们目前使用Column来显示我们的dummyRestaurants列表。我们知道这不是最佳实践,因此为了优化我们的 UI 以适应动态内容,我们将用LazyColumn替换它,这样我们就可以继续垂直显示餐厅。

返回到RestaurantsScreen.kt文件,并在RestaurantScreen可组合组件内部,将Column可组合组件替换为LazyColumn

@Composable
fun RestaurantsScreen() {
   LazyColumn(
       contentPadding = PaddingValues(
           vertical = 8.dp,
           horizontal = 8.dp)) {
       items(dummyRestaurants) { restaurant ->
           RestaurantItem(restaurant)
       }
   }
}

我们已经使用了它的 DSL,并指定了items属性,通过传递dummyRestaurants列表来填充我们的LazyColumn。我们获得了对每个项目的访问权限,它是一个类型为Restaurant的餐厅,并通过RestaurantItem可组合组件进行渲染。

我们还通过contentPadding参数向我们的LazyColumn添加了额外的填充,通过传递一个配置了垂直和水平填充的PaddingValues对象。

现在,你可以使用LazyColumn而不是Column

我们做到了!我们在探索大量可组合函数的同时,从头开始构建了我们的第一个基于 Compose 的应用程序。我们添加了一个滚动优美的列表,现在我们可以为这个结果感到自豪了!

摘要

在本章中,我们学习了如何通过使用 Jetpack Compose 工具包直接在 Kotlin 中构建现代 Android UI。你了解到在 Compose 中,一切都是可组合函数,以及这种新的声明性定义 UI 的方式如何使构建 UI 的方式变得更加容易和更少出错。

我们了解到 Compose 通过简洁的 Kotlin API 和无需 XML 或其他额外语言的需求,加速并极大地简化了 UI 开发。然后我们介绍了 Compose 背后的基本概念和允许你构建 UI 的核心组件。

最后,我们看到了如何通过创建一个显示餐厅列表的基于 Compose 的屏幕来轻松构建 UI。

第二章 使用 Jetpack ViewModel 处理 UI 状态中,我们将利用本章学到的基本原理来回顾 Compose 中的状态概念,并学习它是如何表示的,以及我们如何借助另一个 Jetpack 组件:ViewModel来正确管理它。

首先,我们将了解ViewModel是什么以及为什么需要这样的组件。然后,通过继续我们在本章开始时启动的餐厅应用程序的工作,我们将学习如何在我们的ViewModel类中定义和提升 UI 的状态。

进一步阅读

在一个章节中探索 Compose 这样规模的库几乎是不可能的。这就是为什么你也应该探索其他在用 Compose 构建 UI 时非常重要的主题:

这篇文章还涵盖了 Compose 的内部机制,所以如果你对 Compose 的执行模型或编译器插件在幕后做了什么感到好奇,确保查看这篇文章。

  • 使用 Compose 构建 UI 简单易行,但 Compose 是一个非常强大的框架,它允许你编写高度可重用的 UI。为了利用这一点,每个 Composable 都应该接收一个定义其在调用父元素内部如何排列的Modifier对象。通过查看这篇精彩文章了解这意味着什么,然后尝试练习一下:chris.banes.dev/always-provide-a-modifier/

  • 你的布局应该能够适应不同屏幕尺寸或形式的设备。你可以通过查看官方文档了解更多信息,并尝试进行一些实验:developer.android.com/jetpack/compose/layouts/adaptive

第二章:第二章:使用 Jetpack ViewModel 处理 UI 状态

在这一章中,我们将介绍 Jetpack 中最重要的库之一:ViewModel 架构组件。我们将了解它是什么,为什么我们需要在我们的应用中使用它,以及我们如何在上一章开始的项目“Restaurants”中实现它。

在下一节 使用 Compose 定义和处理状态 中,我们将研究状态在 Compose 中的管理方式,并在我们的项目中展示状态的使用示例。之后,在 在 Compose 中提升状态 部分,我们将了解状态提升是什么,为什么我们需要实现它,然后我们将将其应用到我们的应用中。

最后,在 从系统触发的进程死亡中恢复 部分,我们将介绍什么是系统触发的进程死亡,它是如何发生的,以及对于我们应用能够通过恢复先前的状态细节来从其中恢复,这是多么重要。

总结来说,在这一章中,我们将涵盖以下主要主题:

  • 理解 Jetpack ViewModel

  • 使用 Compose 定义和处理状态

  • 在 Compose 中提升状态

  • 从系统触发的进程死亡中恢复

在深入之前,让我们为这一章设置技术要求。

技术要求

当使用 Jetpack ViewModel 构建基于 Compose 的 Android 项目时,你通常需要你的日常工具。然而,为了顺利跟进,请确保你具备以下条件:

  • Arctic Fox 2020.3.1 版本的 Android Studio。你也可以使用更新的 Android Studio 版本,甚至是 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 在 Android Studio 中安装了 Kotlin 1.6.10 或更新的插件。

  • 上一章的“Restaurants”应用代码。

本章的起点是我们上一章开发的“Restaurants”应用。如果你没有跟随实现过程,可以通过导航到本书 GitHub 仓库的 Chapter_01 目录并导入名为 chapter_1_restaurants_app 的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到 Chapter_02 文件夹:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_02/chapter_2_restaurants_app

我们将在本章中开发的“Restaurants”应用的代码解决方案可以在 chapter_2_restaurants_app Android 项目文件夹中找到,你可以导入。

理解 Jetpack ViewModel

在开发 Android 应用程序时,你一定听说过“ViewModel”这个术语。如果你还没有听说过,那么不要担心——本节旨在清楚地说明这个组件代表什么,以及我们为什么一开始就需要它。

总结一下,本节将涵盖以下主题:

  • 什么是 ViewModel?

  • 为什么你需要 ViewModels?

  • 介绍 Android Jetpack ViewModel

  • 实现你的第一个 ViewModel

让我们从第一个问题开始:我们一直在 Android 中听到的这个ViewModel是什么?

什么是 ViewModel?

最初,ViewModel被设计成允许开发者跨配置更改持久化 UI 状态。随着时间的推移,ViewModel成为了一种从边缘情况(如系统启动的进程死亡)中恢复的方法。

然而,通常,Android 应用需要你编写负责从服务器获取数据、转换它、缓存它,然后显示它的代码。为了委托一些工作,开发者使用了这个单独的组件,该组件应该模拟 UI(也称为View)——即ViewModel

因此,我们可以将ViewModel类视为一个管理和缓存 UI 状态的组件:

图片

图 2.1 – ViewModel 存储状态并接收交互

如我们所见,ViewModel不仅处理 UI 状态并将其提供给 UI,而且还从View接收用户交互事件并相应地更新状态。

在 Android 中,视图通常由ActivityFragmentComposable表示,因为它们旨在显示 UI 数据。这些组件在配置变化发生时容易重新创建,所以ViewModel必须找到一种方法来缓存并恢复 UI 状态——更多内容将在下一节为什么你需要 ViewModels?中介绍。

注意

ViewModel监督发送回 UI 控制器的数据以及 UI 状态如何对用户生成的事件做出反应。这就是为什么我们可以称ViewModel为 UI 控制器的“大师”——因为它代表了执行 UI 相关事件决策的权威。

我们可以尝试列举一些ViewModel应该执行的核心活动。ViewModel应该能够做到以下几点:

  • 保持、管理和保存整个 UI 状态。

  • 从服务器或其他来源请求数据或重新加载内容。

  • 通过应用各种转换(如 map、sort、filter 等)来准备要显示的数据。

  • 接受用户交互事件并根据这些事件改变状态。

尽管你现在已经理解了什么是ViewModel,但你可能还在想,为什么我们需要一个单独的类来保存 UI 状态或准备要显示的数据?为什么我们不能直接在 UI、ActivityFragment或 Composable 中做这件事?我们将在下一节中解答这个问题。

为什么你需要 ViewModels?

想象一下,如果我们把所有状态处理逻辑都放在 UI 类中。按照这种方法,我们很快就会添加其他逻辑来处理网络请求、缓存或任何其他实现细节——所有这些都会在 UI 层内部。

显然,这不是一个好的方法。如果我们这样做,最终会得到一个拥有过多责任的 ActivityFragmentcomposable 函数。换句话说,我们的 UI 组件将变得臃肿,充斥着大量代码和责任,从而使整个项目难以维护、修复或扩展。

ViewModel 是一个架构组件,可以缓解这些潜在问题。通过将 ViewModel 组件添加到我们的项目中,我们迈出了迈向坚实架构的第一步,因为我们可以将 UI 控制器的责任委托给 ViewModel 等组件。

注意

ViewModel 不应引用 UI 控制器,并且应独立于它运行。这减少了 UI 层与 ViewModel 之间的耦合,并允许多个 UI 组件重用相同的 ViewModel

防止 UI 控制器承担多重责任是良好系统架构的基石,因为它促进了一个非常简单的原则,即关注点分离。这个原则指出,我们应用中的每个组件/模块都应该有一个并处理一个关注点。

如果在我们的情况下,我们将整个应用程序逻辑放入 ActivityFragmentcomposable 中,这些组件将变成巨大的代码块,违反了关注点分离原则,因为它们知道如何做任何事情:从显示 UI 到获取数据并服务其 UI 状态。为了缓解这个问题,我们可以开始实现 ViewModels。

接下来,我们将看到如何在 Android 中设计 ViewModels。

介绍 Android Jetpack ViewModel

创建一个用于管理 View 的 UI 状态的 ViewModel 类是可行且直接的。我们只需创建一个单独的类并将相应的逻辑移动到那里。

然而,正如我们之前提到的,UI 控制器有自己的生命周期:ActivityFragment 对象有自己的生命周期,而可组合项有自己的组合周期。这就是为什么 UI 控制器通常是脆弱的,并在发生不同事件(如配置更改或进程死亡)时被重新创建。当这种情况发生时,任何 UI 状态信息都会丢失。

此外,UI 控制器通常需要进行异步调用(例如从服务器获取数据),这些调用必须得到正确管理。这意味着当系统销毁 UI 控制器(例如通过在 Activity 上调用 onDestroy())时,您需要手动中断或取消任何挂起或正在进行的操作。否则,由于您的 UI 控制器的内存引用无法由系统释放,您的应用程序可能会泄漏内存。这是因为它仍在尝试完成一些异步工作。

为了保留 UI 状态并更容易管理异步工作,我们的 ViewModel 类应该能够克服这些缺点。但如何做到呢?

ViewModel 具有生命周期感知性,这意味着它知道如何超越由用户触发的配置更改等事件。

它通过将一个生命周期范围与 UI 控制器的生命周期绑定来实现这一点。让我们看看Activitycomposable的生命周期是如何定义的,与ViewModel的生命周期相比:

图 2.2 – 与 UI 控制器生命周期相比的 ViewModel 生命周期

重要提示

当在 Compose 中使用ViewModel时,它默认与父FragmentActivity的生存期一样长。为了使ViewModel的生存期与顶级composable(或屏幕composable)函数一样长,如前图所示,必须将composable与导航库结合使用。更细粒度的composable可以具有更短的生存期。不用担心,我们将在第五章在 Compose 中使用 Jetpack Navigation 添加导航中介绍如何将ViewModel的生存期范围限定在屏幕composable的生存期上。

当 UI 因为此类事件被重新创建或重新组合时,ViewModel的生命周期感知能力允许它比这些事件存活得更久,避免被销毁,从而允许状态得以保留。当整个生命周期最终结束时,将调用ViewModelonCleared()方法,以便你可以轻松地清理任何挂起的异步工作。

然而,又出现了一个问题:Jetpack 的ViewModel是如何做到这一点的?

按设计,ViewModel类比特定实例化的LifecycleOwner存活得更久。在我们的例子中,UI 控制器是LifecycleOwner,因为它们有一个指定的生命周期,并且可以是ActivityFragment对象。

要了解ViewModel组件是如何针对特定的Lifecycle进行范围划分的,让我们看看获取ViewModel实例的传统方法:

val vm = ViewModelProvider(this)[MyViewModel::class.java]

要获取MyViewModel的实例,我们将一个ViewModelStoreOwner传递给ViewModelProvider构造函数。我们过去在ActivityFragment类中是这样获取我们的ViewModel的,所以这是一个对当前ViewModelStoreOwner的引用。

要控制MyViewModel实例的生存期,ViewModelProvider需要一个ViewModelStoreOwner实例,因为当它创建MyViewModel的实例时,它将这个实例的生存期与ViewModelStoreOwner的生存期绑定——也就是说,与我们的Activity的生存期绑定。

ActivityFragment组件是具有生命周期的LifecycleOwner,这意味着每次你获取对ViewModel的引用时,你收到的对象都是针对LifecycleOwner的生命周期进行范围划分的。这意味着你的ViewModel将一直存活在内存中,直到LifecycleOwner的生命周期结束。

注意

我们将在第十二章探索 Jetpack 生命周期组件中更详细地解释ViewModel组件的内部工作原理以及它们是如何针对LifecycleOwner的生命周期进行范围划分的。

在 Compose 中,使用一个名为 viewModel() 的特殊内联函数来实例化 ViewModel 对象,该函数抽象了之前需要的所有样板代码。

注意

可选地,如果您需要传递在运行时决定值的参数到您的 ViewModel 中,您可以在 viewModel() 构造函数中创建并传递一个 ViewModelFactory 实例。ViewModelFactory 是一个特殊类,它允许您控制 ViewModel 的实例化方式。

现在我们已经概述了 Android ViewModel 的工作原理,让我们创建一个吧!

实现您的第一个 ViewModel

是时候在上一章中创建的餐厅应用程序中创建一个 ViewModel 了。为此,请按照以下步骤操作:

  1. 首先,通过左键单击应用程序包,选择 RestaurantsViewModel 作为名称,并选择 文件 作为类型来创建一个新文件。在新建的文件中,添加以下代码:

    import androidx.lifecycle.ViewModel
    class RestaurantsViewModel(): ViewModel() {
       fun getRestaurants() = dummyRestaurants
    }
    

我们的 RestaurantsViewModel 继承自 ViewModel 类(之前被称为 Jetpack ViewModel),该类定义在 androidx.lifecycle.ViewModel 中,因此它能够感知到实例化它的组件的生命周期。

此外,我们在 ViewModel 中添加了 getRestaurants() 方法,使其能够成为我们 dummyRestaurants 列表的提供者——这是向其赋予管理 UI 状态责任的第一步和初步尝试。

接下来,是时候准备实例化我们的 RestaurantsViewModel 了。在 Compose 中,我们不能使用之前实例化 ViewModel 对象的语法,因此我们将使用一种特殊且专用的语法。

  1. 要获取此特殊语法,请转到应用程序模块中的 build.gradle 文件,并在 dependencies 块内添加 ViewModel-Compose 依赖项:

    dependencies {
          […]
        debugImplementation "androidx.compose.ui:ui-
            tooling:$compose_version"
        implementation "androidx.lifecycle:lifecycle-
            viewmodel-compose:2.4.1"
    }
    

在更新 build.gradle 文件后,请确保将您的项目与其 Gradle 文件同步。您可以通过点击 文件 菜单选项,然后选择 同步项目与 Gradle 文件 来完成此操作。

  1. 回到 RestaurantsScreen 文件,我们希望在 RestaurantsScreen 可组合函数内部实例化 RestaurantsViewModel。我们可以通过使用 viewModel() 内联函数语法并指定我们期望的 ViewModel 类型来实现这一点;即 RestaurantsViewModel

    @Composable
    fun RestaurantsScreen() {
       val viewModel: RestaurantsViewModel = viewModel()
       LazyColumn( … ) {
           items(viewModel.getRestaurants()) { restaurant->
                 RestaurantItem(restaurant)
           }
       }
    }
    

在幕后,viewModel() 函数获取 RestaurantsScreen() 可组合组件的默认 ViewModelStoreOwner。由于我们尚未实现导航库,默认的 ViewModelStoreOwner 将是我们的可组合组件的调用父组件——MainActivity 组件。这意味着,目前,尽管我们的 RestaurantsViewModel 已在可组合组件内部实例化,但它将像 MainActivity 一样持续存在。

换句话说,我们的 RestaurantsViewModel 的作用域是 MainActivity 的生命周期,因此它比 RestaurantsScreens 可组合组件或任何其他我们通过 MainActivity 内部的 setContent() 方法调用传递给的可组合组件存在的时间更长。

为了确保我们的ViewModel在需要它的可组合函数存在期间保持活跃,我们将在第五章中实现导航库,在 Compose 中使用 Jetpack Navigation 添加导航

我们还确保现在通过在viewModel变量上调用getRestaurants()来从我们的RestaurantsViewModel获取要显示的餐厅。

注意

从现在开始,在某些较旧的 Compose 版本中,Compose 预览功能可能不再按预期工作。由于RestaurantsScreen可组合函数现在依赖于RestaurantsViewModel对象,Compose 可能无法推断传递给预览可组合函数的数据,因此无法显示内容。这就是为什么在屏幕可组合函数中直接引用ViewModel不是一种好做法。我们将在第八章中修复这个问题,在 Android 中使用 Clean Architecture 入门。或者,为了查看代码中的任何更改,您可以直接在模拟器或物理设备上运行应用程序。

回到我们的餐厅应用程序,我们已经成功添加了一个ViewModel,但我们的RestaurantsViewModel并没有处理任何 UI 状态。它只发送一个硬编码的餐厅列表,没有任何状态。我们设想它的目的是管理 UI 的状态,所以让我们暂时放下ViewModel,专注于理解状态。

使用 Compose 定义和处理状态

状态和事件对于任何应用程序都是基本的,因为它们的存续意味着 UI 可以随着您的交互而随时间改变。

在本节中,我们将介绍状态和事件的概念,然后将它们集成到我们的餐厅应用程序中。

总结,本节将涵盖以下主题:

  • 理解状态和事件

  • 为我们的餐厅应用程序添加状态

让我们从探索 Android 应用程序中状态和事件的基本概念开始。

理解状态和事件

状态代表在某个时间点 UI 的可能形式。这种形式可以改变或变异。当用户与 UI 交互时,会创建一个事件,该事件触发 UI 状态的改变。因此,事件由用户发起的不同交互表示,这些交互针对应用程序,并导致其状态更新。

简而言之,状态因事件而随时间变化。另一方面,UI 应该观察状态中的变化,以便相应地更新:

图 2.3 – UI 更新流程

在 Compose 中,默认情况下,可组合函数是无状态的。这就是为什么当我们试图在前一章中使用TextField可组合函数时,它没有将我们在键盘上输入的内容显示在 UI 上。这是因为可组合函数没有定义状态,并且它没有与新值重新组合以显示新内容!

这就是为什么在 Compose 中,我们的任务是定义我们可组合组件的状态对象。借助 状态 对象,我们确保每次状态对象的值发生变化时都会触发重新组合。

要使这样的 TextField 显示我们正在输入的文本,请记住我们添加了一个 textState 变量。我们的 TextField 需要这样一个包含 String 值的状态对象。这个值代表我们输入的文本,它可以随着我们继续输入而改变:

@Composable
fun NameInput() {
   val textState: MutableState<String> =
       remember { mutableStateOf("") }
   TextField(…) 
}

让我们更仔细地看看我们是如何定义我们的 TextField 的状态对象的:

  • 首先,我们创建了一个变量来保存我们的状态对象,并确保其值可以随时间改变,通过将其定义为 MutableState。我们通过定义一个类型为 MutableStatetextState 变量来实现这一点,它反过来又持有类型为 String 的数据。

在其核心,textState 是一个 androidx.compose.runtime.State 对象,但由于我们希望能够在时间上改变其值,我们直接使用了一个实现了 State 接口的 MutableState

  • 我们使用 mutableStateOf("") 构造函数实例化了 textState 以创建一个状态对象,并传递了它所持有的数据的初始值:一个空字符串。

我们还将在 remember { } 块内部包装了 mutableStateOf("") 构造函数。remember 块。

现在我们已经涵盖了如何定义状态对象,一些问题仍然存在:我们如何更改状态以重新触发重新组合,以及我们如何确保我们的 TextField 访问来自 textState 的更新值?让我们添加这些缺失的部分:

@Composable
fun NameInput() {
   val textState = remember { mutableStateOf("")}
   TextField(
            value = textState.value,
            onValueChange = { newValue ->
                textState.value = newValue
            },
            label = { Text("Your name") })
}

让我们更仔细地看看我们如何在 TextField 内部连接一切:

  • 为了使 TextField 总是能够访问 textState 状态对象的最新值,我们使用 textState.value 通过 .value 访问器获取当前状态值,然后将其传递给 TextFieldvalue 参数以显示它。

  • 要更改状态值,我们使用了 onValueChange 回调,这可以被视为一个 事件。在这个回调内部,我们通过使用相同的 .value 访问器来更新 textState 状态值,并设置接收到的新的值,称为 newValue。由于我们更新了一个 State 对象,UI 应该重新组合,我们的 TextField 应该渲染从键盘输入的新输入值。只要我们继续写作,这个过程就会重复。

现在我们已经掌握了在 Compose 中定义和修改状态的方法,是时候将这种状态功能添加到我们的餐厅应用中了。

将状态添加到我们的餐厅应用中

让我们想象一下,用户可以滚动浏览餐厅列表,然后点击特定的一个,从而将其标记为收藏。为了使这个过程更具提示性,我们将为每个餐厅添加一个爱心图标。为此,请按照以下步骤操作:

  1. RestaurantsScreen.kt 文件中,在 RestaurantItem 内部添加另一个可组合组件,称为 FavoriteIcon。然后传递一个权重 0.15f 以使其占用父 Row 的 15%。

    @Composable
    fun RestaurantItem(item: Restaurant) {
       Card(...) {
           Row(...) {
               RestaurantIcon(..., Modifier.weight(0.15f))
               RestaurantDetails(..., Modifier.weight(0.7f))
               FavoriteIcon(Modifier.weight(0.15f))
           }
       }
    }
    

我们还确保将 RestaurantDetails 的权重从 85% 降低到 70%。

  1. 仍然在 RestaurantsScreen.kt 文件中,定义缺失的 FavoriteIcon 组合组件,该组件接收一个 imageVector 作为预定义图标 Icons.Filled.FavoriteBorder。同时,让它接收一个具有 8.dp 填充的 Modifier 对象:

    @Composable
    private fun FavoriteIcon(modifier: Modifier) {
       Image(
           imageVector = Icons.Filled.FavoriteBorder,
           contentDescription = "Favorite restaurant icon",
           modifier = modifier.padding(8.dp))
    }
    
  2. 如果我们尝试刷新预览或运行应用,我们可以看到几个类似于以下的 RestaurantItem 组合组件:

图 2.4 – 带有收藏图标的 RestaurantItem 组合组件

我们的 RestaurantItem 组合组件现在有一个收藏图标。然而,当我们点击它时,什么也没有发生。点击它应该将心形图标变为实心,标记餐厅为收藏。为了修复这个问题,我们必须添加一个状态,使我们能够保持餐厅的收藏状态。

  1. 通过添加以下代码向 FavoriteIcon 组合组件添加状态:

    @Composable
    private fun FavoriteIcon(modifier: Modifier) {
       val favoriteState = remember { 
           mutableStateOf(false) }
       val icon = if (favoriteState.value)
           Icons.Filled.Favorite
       else
           Icons.Filled.FavoriteBorder
        Image(
            imageVector = icon,
            contentDescription = "Favorite restaurant icon",
            modifier = modifier
                .padding(8.dp)
                .clickable { favoriteState.value =
                        !favoriteState.value
                }
        )
    }
    

为了保持是否收藏的状态并触发这个状态值的改变,我们做了以下操作:

  1. 我们添加了一个 favoriteState 变量,它持有类型为 BooleanMutableState,初始值为 false。像往常一样,我们将 mutableStateOf 构造函数包裹在一个 remember 块中,以在重新组合之间保留状态值。

  2. 我们定义了一个 icon 变量,它可以持有 Icons.Filled.Favorite 的值,这意味着该餐厅是您的收藏,或者持有 Icons.Filled.FavoriteBorder 的值,这意味着该餐厅不是您的收藏。

  3. 我们将 icon 值传递给 Image 组合组件的 imageVector 参数。

  4. 我们添加了一个 clickable 修饰符,它在 padding 修饰符之后链式使用。在这个回调中,我们确保通过获取并写入先前取反的值来更新 favoriteState

    注意

    在 Compose 中定义状态对象时,可以用属性委托替换赋值运算符(=),这可以通过 by 运算符实现:val favoriteState by remember { … }。通过这样做,您将不再需要使用 .value 访问器,因为它被委托了。

当我们在运行或实时预览应用程序时,我们可以看到,当我们点击每个餐厅的空心形图标时,它变成了实心,标记餐厅为收藏:

图 2.5 – 带有项目收藏状态的 RestaurantsScreen 组合组件

大多数情况下,不建议在组合函数内部保持状态和处理状态逻辑。让我们探讨为什么这不是最佳实践,以及我们如何借助状态提升来改进我们管理状态的方式。

在 Compose 中提升状态

组合函数通常根据状态处理分为两大类:

  • State 对象。

  • ViewModel 组件可以是它们状态的唯一真相来源,以控制和管理 UI 变化,同时避免非法状态。

在我们的案例中,当餐厅被标记为收藏或未收藏时,状态会发生变化。由于我们希望在ViewModel类中控制这种交互以跟踪哪些餐厅已被收藏,因此我们需要将状态从FavoriteIcon可组合组件提升上来。

将状态从可组合组件向上移动到其调用者可组合组件的模式被称为具有两个参数的状态对象:

  • 一个用于定义当前状态的value参数

  • 当发出新值时触发的回调函数

通过接收数据作为输入并将事件转发给父可组合组件,我们确保我们的 Compose UI 遵循之前引入的状态和事件单向流动的概念。这个概念定义了状态值和事件应该只在一个方向上流动:事件向上流动,状态向下流动,并且通过状态提升,我们强制执行这一点。

状态提升的好处如下:

  • ViewModel。可组合组件可以从其状态中解耦,以避免在 UI 中出现非法状态。

  • 可重用性:由于可组合组件只渲染接收到的输入数据,因此它们在其它可组合组件中的重用要容易得多,因为你只需传递不同的值即可。

  • 封装限制:只有有状态的可组合组件可以内部更改其状态。这意味着你可以限制处理其状态的可组合组件的数量,这可能导致非法的 UI 状态。

现在我们简要介绍了状态提升的概念及其好处,是时候在我们的 Restaurants 应用程序中提升状态了:

  1. 首先,通过从函数体顶部移除现有的favoriteStateicon变量及其实例化逻辑,将状态从FavoriteIcon可组合组件提升出来。同时,更新FavoriteIcon可组合组件以接受一个icon参数来接收输入数据,以及一个onClick事件回调来向上转发事件:

    @Composable
    private fun FavoriteIcon(icon: ImageVector, 
                             modifier: Modifier,
                             onClick: () -> Unit) {
       Image(
           imageVector = icon,
           contentDescription = "Favorite restaurant icon",
           modifier = [...]
               .clickable { onClick() })
    }
    

此外,我们将icon传递给Image可组合组件的imageVector参数,并在clickable事件触发时调用onClick回调函数。通过应用这些更改,我们将状态提升上来,并将FavoriteIcon从有状态的组件转换为无状态的组件。

  1. 现在,将favoriteState变量移动到FavoriteIcon的父可组合组件RestaurantItem中。RestaurantItem可组合组件为FavoriteIcon提供状态,并负责随时间更新其状态:

    @Composable
    fun RestaurantItem(item: Restaurant) {
       val favoriteState = remember { 
           mutableStateOf(false) }
       val icon = if (favoriteState.value)
           Icons.Filled.Favorite
       else
           Icons.Filled.FavoriteBorder
       Card(...) {
           Row(...) {
              [...]
              FavoriteIcon(icon, Modifier.weight(0.15f)) {
                   favoriteState.value = 
                       !favoriteState.value
              }
           }
       }
    }
    

每个状态的对应图标现在传递给FavoriteIcon。此外,RestaurantItem现在在尾随的 lambda 块中监听onClick事件,在该事件中它修改favoriteState对象,每次点击都会触发重新组合。

然而,查看FavoriteIconRestaurantIcon,我们可以看到许多相似之处。它们都是无状态的组合组件,接收一个ImageVector作为参数。由于它们是无状态的并且执行类似的功能,让我们重用其中一个并删除另一个。

  1. RestaurantIcon内部,添加一个类似的onClick函数参数(就像FavoriteIcon有的一样)并将其绑定到clickable修饰符的回调:

    @Composable
    private fun RestaurantIcon(icon: ImageVector, modifier: Modifier, onClick: () -> Unit = { }) {
       Image([...],
           modifier = modifier
               .padding(8.dp)
               .clickable { onClick() }
       )
    }
    

由于我们不想在餐厅配置文件图标上执行点击事件,我们为onClick参数提供了一个默认的空函数({ })值。

完成这些操作后,你可以删除FavoriteIcon组合组件,因为我们不再需要它了。

  1. RestaurantItem组合组件内部,将FavoriteIcon替换为RestaurantIcon

    @Composable
    fun RestaurantItem(item: Restaurant) {
       val favoriteState = ...
       Card(...) {
           Row(...) {
              RestaurantIcon(…)
              RestaurantDetails(...)
              RestaurantIcon(icon, Modifier.weight(0.15f)) {
                   favoriteState.value = !favoriteState.value
               }
           }
       }
    }
    

现在,你已经将状态从RestaurantIcon提升到了RestaurantItem组合组件。

让我们继续提升状态,将其提升到RestaurantsScreen组合组件。然而,我们无法在这个组合组件内部为每个RestaurantItem保留单独的State对象,因此我们必须将State对象更改为包含一系列Restaurant对象,每个对象都有一个单独的isFavorite值。

  1. Restaurant.kt文件内部,为Restaurant添加另一个属性isFavorite。它应该有一个默认值false,因为默认情况下,当应用程序启动时,餐厅不会被标记为收藏:

    data class Restaurant(val id: Int,
                          val title: String,
                          val description: String,
                          var isFavorite: Boolean = false)
    val dummyRestaurants = listOf(…)
    
  2. 返回到RestaurantsScreen.kt文件内部,再次提升状态,这次是从RestaurantItem开始的,通过添加一个在RestaurantIcon的回调函数参数内部被触发的onClick函数参数。由于我们已经有类型为Restaurantitem参数,因此我们不需要为输入数据添加新的参数,并且可以安全地移除favoriteState变量,因为我们不再需要它了:

    @Composable
    fun RestaurantItem(item: Restaurant, 
                       onClick: (id: Int) -> Unit) {
       val icon = if (item.isFavorite)
           Icons.Filled.Favorite
       else
           Icons.Filled.FavoriteBorder
       Card(...) {
           Row(...) {
               ...
              RestaurantIcon(…)
              RestaurantDetails(…)
              RestaurantIcon(…) {
                  onClick(item.id)
              }
           }
       }
    }
    

这次,item参数将是我们的Restaurant对象。Restaurant现在包含一个isFavorite: Boolean属性,表示餐厅是否被收藏。这就是为什么我们根据项的字段设置icon变量的正确值,通过检查item.isFavorite的值。

现在,RestaurantItem是一个无状态的组合组件,因此是时候向其父组件添加一个State对象了。

  1. RestaurantsScreen内部,添加一个state变量,它将保存我们的餐厅列表。其类型将是MutableState<List<Restaurant>>,我们将从viewModel设置餐厅作为其初始值,最后将状态对象的value传递给LazyColumnitems构造函数:

    @Composable
    fun RestaurantsScreen() {
      val viewModel: RestaurantsViewModel = viewModel()
      val state: MutableState<List<Restaurant>> =
        remember {
          mutableStateOf(viewModel.getRestaurants())
        }
      LazyColumn(...) {
       items(state.value) { restaurant ->
         RestaurantItem(restaurant) { id ->
           val restaurants = state.value.toMutableList()
           val itemIndex =
             restaurants.indexOfFirst { it.id == id }
           val item = restaurants[itemIndex]
           restaurants[itemIndex] =
             item.copy(isFavorite = !item.isFavorite)
           state.value = restaurants
          }
        }
      }
    }
    

RestaurantItemonClick尾随 lambda 块内部,我们必须切换相应餐厅的收藏状态并更新状态。正因为如此,我们做了以下操作:

  1. 我们通过调用state.value并将其转换为可变列表来获取当前的餐厅列表,以便我们可以替换需要更新isFavorite字段值的项。

  2. 我们通过indexOfFirst函数获得了应该更新isFavorite字段的项的索引,其中我们匹配了Restaurant对象的id属性。

  3. 找到itemIndex后,我们获得了类型为Restaurantitem对象,并应用了copy()构造函数,其中我们取反了isFavorite字段。结果值替换了itemIndex处的现有item

  4. 最后,我们使用.value访问器将更新后的restaurants列表返回给state对象。

    注意

    对于Compose要观察类型为T的对象列表中的变化,其中T是一个数据类,称为List<T>,你必须更新更新项的内存引用。你可以通过调用Tcopy()构造函数来实现这一点,这样当更新的列表返回到你的State对象时,Compose会触发重组。或者,你可以使用mutableStateListOf<Restaurant>()来更容易地触发重组事件。

如果我们尝试运行应用程序,我们应该注意到功能相同,但状态已经提升,我们现在可以更容易地重用RestaurantItemRestaurantIcon等可组合组件。

但如果我们切换了几家收藏餐厅,然后旋转设备,从而改变屏幕方向会发生什么呢?

尽管我们使用了remember块来在重组之间保留状态,但我们的选择丢失了,所有餐厅都被标记为非收藏。这是因为我们的RestaurantsScreen可组合组件的宿主MainActivity已被重新创建,因此当配置更改发生时,任何状态也丢失了。

为了解决这个问题,我们可以做以下操作:

  • remember块替换为rememberSaveable。这将允许状态在宿主Activity的配置更改之间自动保存。

  • 将状态提升到ViewModel。我们知道RestaurantsViewModel尚未限定于我们的RestaurantsScreen的生命周期,因为尚未使用导航库,这意味着它限定于MainActivity,这允许它在配置更改中存活。

你可以尝试将remember块替换为rememberSaveable,然后旋转屏幕以查看状态现在在配置更改之间得到了保留。然而,我们希望走正道,确保ViewModel是我们状态的唯一真相来源。让我们开始吧:

  1. 要将状态提升到ViewModel,我们必须将State对象从RestaurantsScreen可组合组件移动到RestaurantsViewModel,并且我们必须创建一个新的方法toggleFavorite,该方法将允许RestaurantsViewModel在尝试切换餐厅的收藏状态时每次都修改state变量的值:

    class RestaurantsViewModel() : ViewModel() {
       val state = mutableStateOf(dummyRestaurants)
        fun toggleFavorite(id: Int) {
            val restaurants = state.value.toMutableList()
            val itemIndex =
                restaurants.indexOfFirst { it.id == id }
            val item = restaurants[itemIndex]
            restaurants[itemIndex] =
                item.copy(isFavorite = !item.isFavorite)
            state.value = restaurants
        } 
    }
    

新的toggleFavorite方法接受目标餐厅的id属性。在这个方法内部,我们将RestaurantItemonClick尾随 lambda 块中的代码移动出来,在那里我们切换对应项的收藏状态并更新其状态。

到目前为止,你可以安全地从RestaurantsViewModel类中移除getRestaurants()方法,因为我们不再需要它了。

注意

包含在ViewModel中的State对象不应公开供其他类修改,因为我们希望它被封装,并且只允许ViewModel更新它。我们将在*第七章**,介绍 Android 中的呈现模式中修复这个问题。

  1. RestaurantsScreen可组合组件内部,移除state变量,并通过访问.value访问器使用viewModel.state.valueRestaurantsViewModel传递餐厅:

    fun RestaurantsScreen() {
       val viewModel: RestaurantsViewModel = viewModel()
       LazyColumn(...) {
           items(viewModel.state.value) { restaurant ->
               RestaurantItem(restaurant) { id ->
                   viewModel.toggleFavorite(id)
               }
           }
       }
    }
    

我们还从RestaurantItemonClick尾随 lambda 块中移除了旧代码,并用对ViewModeltoggleFavorite方法的调用替换了它。

如果你运行应用程序,UI 应该按预期执行,因此你应该能够切换任何餐厅为收藏,并在事件如方向改变时保存你的选择。

唯一的区别是现在,RestaurantsViewModelRestaurantsScreen状态的唯一真相来源,我们不再需要在可组合组件内部持有或保存 UI 状态。

现在,我们知道如何将状态提升到ViewModel中。接下来,让我们探讨一个与 Android 世界中的进程死亡相关的重要场景。

从系统触发的进程死亡中恢复

我们已经了解到,每当发生配置更改时,我们的Activity都会被重新创建,这可能导致我们的 UI 丢失状态。为了绕过这个问题并保留 UI 的状态,我们最终实现了ViewModel组件并将 UI 状态提升到那里。

但在系统触发的进程死亡的情况下会发生什么?

当用户将我们的应用程序置于后台并决定暂时使用其他应用时,会发生系统触发的进程死亡。然而,在此期间,系统决定杀死我们的应用程序进程以释放系统资源,从而触发进程死亡。

让我们尝试模拟这样一个事件并看看会发生什么:

  1. 使用 IDE 的运行按钮启动 Restaurants 应用程序,并将一些餐厅标记为收藏:

Figure 2.6 – The RestaurantsScreen composable with favorite selections made

图 2.6 – 已制作收藏选择的 RestaurantsScreen 可组合组件

  1. 通过按设备/模拟器的主页按钮将应用置于后台。

  2. 在 Android Studio 中,选择Logcat窗口,然后按左侧的红色方块按钮来终止应用程序:

Figure 2.7 – Killing the process in Logcat to simulate system-initiated process death

图 2.7 – 在 Logcat 中杀死进程以模拟系统触发的进程死亡

  1. 从应用抽屉中重新启动应用程序:

Figure 2.8 – The RestaurantsScreen composable with favorite selections lost

图 2.8 – 收藏选择丢失的餐厅屏幕组件

我们现在模拟了一个系统会杀死我们的进程的情况。当我们返回到应用程序时,我们可以看到我们的选择现在消失了,而且被收藏的餐厅现在处于默认状态。

为了在系统启动进程死亡时恢复状态,我们曾经使用过活动的 onSaveInstanceState() 回调。

类似地,每个使用默认 ViewModelFactoryViewModel(就像我们之前使用 viewModel() 内联语法所做的那样)都可以通过其构造函数访问一个 SavedStateHandle 对象。如果你使用自定义 ViewModelFactory,请确保它扩展 AbstractSavedStateViewModelFactory

SavedStateHandle 对象是一个键值映射,允许你保存并恢复对状态至关重要的对象。当系统启动此事件时,此映射在进程死亡事件发生时仍然存在,这允许你检索和恢复保存的对象。

注意

当我们保存状态相关数据时,保存定义状态的轻量级对象而不是屏幕上描述的整个数据至关重要。对于大量数据,我们应该使用本地持久化。

让我们在应用程序中尝试这样做,通过保存被切换为收藏的餐厅的 id 值列表到 SavedStateHandle。保存 id 值比保存整个餐厅列表更好,因为 Int 值列表更轻量。而且,由于我们可以在运行时始终获取餐厅列表,唯一缺少的是记住哪些被收藏了。

注意

通常,SavedStateHandle 用于保存用户执行的操作,如排序或筛选选择,或其他需要在系统启动进程死亡时恢复的选择。然而,在我们的案例中,收藏的餐厅不仅需要在系统启动进程死亡时恢复,还需要在简单的应用程序重启时恢复。这就是为什么我们将在第六章“使用 Jetpack Room 添加离线功能”中稍后,将这些选择作为应用程序在本地数据库中的域数据保存。

让我们使用 SavedStateHandle 对象从系统启动进程死亡中恢复:

  1. SavedStateHandle 参数添加到你的 RestaurantsViewModel

    class RestaurantsViewModel(
        private val stateHandle: SavedStateHandle) : 
    ViewModel() {
       …
    }
    
  2. toggleFavorite 方法中切换餐厅的收藏状态时,调用 storeSelection 方法,并传递相应的餐厅:

    class RestaurantsViewModel(…) {
       fun toggleFavorite(id: Int) {
           …
           restaurants[itemIndex] = item.copy(isFavorite = 
               !item.isFavorite)
           storeSelection(restaurants[itemIndex])
           state.value = restaurants
       }
     ...
    }
    

然而,这段代码无法编译,因为我们还没有定义 storeSelection 方法。让我们接下来定义它。

  1. RestaurantsViewModel 中,创建一个新的 storeSelection 方法,该方法接收一个 Restaurant 对象,其 isFavorite 属性刚刚被更改,并将该选择保存到 RestaurantsViewModel 类提供的 SavedStateHandle 对象中:

    private fun storeSelection(item: Restaurant) {
       val savedToggled = stateHandle
         .get<List<Int>?>(FAVORITES)
         .orEmpty().toMutableList()
       if (item.isFavorite) savedToggled.add(item.id)
       else savedToggled.remove(item.id)
       stateHandle[FAVORITES] = savedToggled
    }
    companion object {
      const val FAVORITES = "favorites"
    }
    

这个新方法将尝试在每次切换餐厅的收藏状态时在我们的stateHandle对象中保存餐厅的id值。它是这样做的:

  1. 它通过访问映射中的FAVORITES键从stateHandle获取包含之前收藏的餐厅 ID 的列表。结果存储在savedToggle可变列表中。如果没有餐厅被收藏,列表将为空。

  2. 如果这个餐厅被标记为收藏,它将餐厅的 ID 添加到savedToggle列表中。否则,它将其从列表中移除。

  3. stateHandle映射中使用FAVORITES键保存更新后的收藏餐厅列表。

我们还在RestaurantsViewModel类中添加了一个companion object构造,作为静态扩展对象。我们使用这个companion object定义了一个用于在stateHandle映射中保存餐厅选择的键的常量值。

现在,我们已经确保在进程死亡之前缓存了收藏餐厅的选择,因此我们的下一步是找到一种方法在应用从系统启动的进程死亡事件中恢复后恢复这些选择。

  1. 在我们传递给state对象的初始值dummyRestaurants列表上调用restoreSelections()扩展方法。这个调用应该恢复 UI 选择:

    class RestaurantsViewModel(
       private val stateHandle: SavedStateHandle):
          ViewModel() {
       val state = mutableStateOf(
         dummyRestaurants.restoreSelections()
       )
        ...
    }
    

然而,这段代码无法编译,因为我们还没有定义restoreSelections方法。让我们接下来定义它。

  1. RestaurantsViewModel内部,定义一个restoreSelections扩展函数,这将允许我们在进程死亡时检索被收藏的餐厅:

    private fun List<Restaurant>.restoreSelections():
            List<Restaurant> {
        stateHandle.get<List<Int>?>(FAVORITES)?.let {
                selectedIds ->
            val restaurantsMap = this.associateBy { it.id }
            selectedIds.forEach { id ->
                restaurantsMap[id]?.isFavorite = true
            }
            return restaurantsMap.values.toList()
        }
        return this
    }
    

这个扩展函数将允许我们标记那些在系统启动进程死亡时用户之前标记为收藏的餐厅。restoreSelections扩展函数通过以下方式实现这一点:

  1. 首先,通过从stateHandle中获取包含之前收藏的餐厅唯一标识符的列表,通过访问映射中的FAVORITES键。如果列表不是null,这意味着发生了进程死亡,它将列表引用为selectedIds;否则,它将返回未做任何修改的列表。

  2. 然后,通过创建一个映射,其键是餐厅的id值,值是Restaurant对象本身,从输入的餐厅列表中创建一个映射。

  3. 通过遍历收藏餐厅的唯一标识符,并为每个标识符尝试从我们的新列表中访问相应的餐厅,并将它的isFavorite值设置为true

  4. 通过从restaurantMap返回修改后的餐厅列表。这个列表现在应该包含在死亡过程发生之前恢复的isFavorite值。

  5. 最后,构建应用,然后重复之前模拟系统启动进程死亡时的步骤 1步骤 2步骤 3步骤 4

应用程序现在应该能够正确显示 UI 状态,包括在系统启动进程死亡之前之前收藏的餐厅。

通过这样,我们确保了我们的应用程序不仅存储了 UI 状态在ViewModel级别,而且还可以从系统启动的进程死亡等异常事件中恢复。

摘要

在本章中,我们学习了ViewModel类是什么,我们探讨了定义它的概念,并学习了如何实例化一个。我们探讨了为什么ViewModel作为 UI 的状态的单个真实来源是有用的:为了避免非法和不希望的状态。

为了让这有意义,我们探讨了 UI 是如何通过其状态定义的,以及如何在 Compose 中定义这样的状态。然后我们理解了状态提升是什么,以及如何将 widget 在无状态有状态的 composable 之间分离。

最后,我们将所有这些新概念付诸实践,通过在我们的餐厅应用中定义状态,提升它,然后将它提升到新创建的ViewModel中。

最后,我们学习了系统启动的进程死亡发生的方式以及如何通过使用SavedStateHandle恢复之前的状态来允许应用恢复。

在下一章中,我们将通过使用 Retrofit 将其连接到我们的数据库来向我们的餐厅应用添加真实数据。

进一步阅读

在 Compose 中与 ViewModel 一起工作并处理状态变化是可靠项目的两个基本主题。让我们看看围绕它们的其他主题是什么。

探索使用运行时提供的参数的 ViewModel

在大多数情况下,你可以在构造函数中声明并提供依赖项给ViewModel,在编译时。然而,在某些情况下,你可能需要使用仅在运行时才知道的参数来初始化ViewModel实例。

例如,当我们添加一个显示餐厅详细信息的 composable 屏幕时,而不是通过函数调用从 composable 将目标餐厅的 ID 发送到ViewModel,我们可以通过ViewModelFactory直接将其提供给ViewModel构造函数。

要探索构建ViewModelFactory的过程,请查看以下 Codelab:developer.android.com/codelabs/kotlin-android-training-view-model#7

探索 Kotlin Multiplatform 项目的 ViewModel

虽然本章涵盖了纯 Android 应用中的 Jetpack ViewModel for Compose,但如果你的目标是使用Kotlin Multiplatform (KMP) 或 Kotlin Multiplatform Mobile (KMM)来构建跨平台项目,Jetpack ViewModel 可能不是最佳选择。

当我们在构建跨平台项目时,我们应该尽量避免平台特定的依赖。Jetpack ViewModel 适用于 Android,因此是一个 Android 依赖项,所以我们可能需要构建或定义一个 ViewModel。

要了解更多关于 KMM 和平台无关的 ViewModel 的信息,请查看以下 GitHub 示例:github.com/dbaroncelli/D-KMP-sample

理解如何最小化重组次数

在本章中,我们学习了如何通过使用State对象来触发重组。虽然在使用 Compose 时重组经常发生,但我们还没有机会优化基于 Compose 的屏幕的性能。

我们可以通过确保可组合组件的输入是深度稳定的来减少重组的次数。要了解更多关于如何实现这一点,请访问developer.android.com/jetpack/compose/lifecycle?hl=bn-IN&skip_cache=true#skipping

第三章:第三章:使用 Retrofit 显示 REST API 的数据

在这一章中,我们将暂时放下 Jetpack 库,通过使用 Android 上一个非常流行的网络库 Retrofit,来专注于在我们的餐厅应用程序中添加真实数据。

Retrofit 是一个 HTTP 客户端库,允许您声明性地创建 HTTP 客户端,并抽象出处理网络请求和响应的大部分底层复杂性。这个库使我们能够连接到真实的网络 API,并在我们的应用程序中检索真实数据。

理解应用如何与远程服务器通信 部分,我们将专注于探索移动应用程序如何检索和向远程网络 API 发送数据。在 使用 Firebase 创建并填充数据库 部分,我们将借助 Firebase 为我们的餐厅应用程序创建数据库,并用 JSON 内容填充它。

探索 Retrofit 作为 Android 的 HTTP 网络客户端 部分,我们将了解 Retrofit 是什么,以及它如何帮助我们创建餐厅应用程序中的网络请求。

最后,在 改进我们应用处理网络请求的方式 部分,我们将解决 Android 应用在创建异步工作以从网络 API 获取数据时出现的常见问题。我们将识别这些问题并修复它们。

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

  • 理解应用如何与远程服务器通信

  • 使用 Firebase 创建并填充数据库

  • 探索 Retrofit 作为 Android 的 HTTP 网络客户端

  • 改进我们应用处理网络请求的方式

在深入之前,让我们为这一章设定技术要求。

技术要求

使用 Retrofit 构建 Compose 基础的 Android 项目通常只需要您日常使用的工具。然而,为了顺利跟进,请确保您具备以下条件:

  • Android Studio 的 Arctic Fox 2020.3.1 版本。您也可以使用更新的 Android Studio 版本,甚至可以尝试 Canary 版本,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 在 Android Studio 中安装了 Kotlin 1.6.10 或更高版本。

  • 上一章的餐厅应用程序代码。

  • 一个 Google 账户来创建 Firebase 项目。

本章的起点是我们上一章开发的餐厅应用程序。如果您没有遵循上一章的编码步骤,可以通过导航到本书 GitHub 仓库的 Chapter_02 目录并导入名为 chapter_2_restaurants_app 的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到 Chapter_03 目录:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_03

理解应用程序如何与远程服务器通信

现代应用程序需要显示可以随时间变化的真实内容,并且需要避免像我们在前几章中那样硬编码数据。让我们简要地了解一下它们是如何做到这一点的。

大多数网络连接的应用程序使用 HTTP 协议通过 REST API 以 JSON 格式发送或接收数据。

我们刚刚向你抛出了很多词汇,所以让我们来分解它们:

  • 超文本传输协议HTTP)是一种用于从网络服务器异步获取各种资源的协议。在我们的案例中,资源是我们应用程序需要显示的数据。

  • JavaScript 对象表示法JSON)是 HTTP 请求中传输的内容的数据格式。它是结构化的、轻量级的,并且由于其由易于解析的键值对组成,因此易于阅读,常用于应用程序和服务器之间数据交换的合适格式。在我们的应用程序中,我们将以这种 JSON 格式从网络服务器接收数据。

  • GETPUTPOSTDELETE 等等。

  • REST API 是一个符合 REST 架构约束的 应用程序编程接口API),它允许你与 REST 网络服务进行交互。REST API 是应用程序用于从后端获取或发送数据的合约和入口点。

让我们尝试可视化这些实体之间的关系:

![图 3.1 – 应用程序与网络服务器之间 HTTP 通信概述图 B17788_03_01.jpg

图 3.1 – 应用程序与网络服务器之间 HTTP 通信概述

我们希望在我们的餐厅应用程序中实现类似的功能。为了使其工作,我们需要一个 REST 服务器。为了简化,我们将使用 Firebase 实时数据库并创建一个数据库。

使用 Firebase 创建和填充你的数据库

到目前为止,我们只使用了硬编码的数据作为我们餐厅应用程序内容来源。由于几乎每个真实的应用程序都使用来自后端服务器通过 REST API 的动态数据,是时候提高我们的水平,创建一个模拟这种远程 API 的数据库了。

我们可以在 Firebase 的帮助下免费完成这项工作。Firebase 由 Google 支持,代表了一种 后端即服务BaaS),这使得我们能够非常容易地构建数据库。我们将使用 Firebase 的实时数据库服务,而无需使用 Firebase Android SDK。尽管这样的数据库不是一个合适的 REST 网络服务,但我们可以使用其数据库 URL 作为 REST 端点,并假装那是我们的 REST 接口,从而模拟一个真实的后端。

注意

正如我们在 技术要求 部分中提到的,请确保你有一个现有的 Google 账户,或者事先创建一个。

让我们开始创建一个数据库:

  1. 导航到 Firebase 控制台,通过访问 console.firebase.google.com/ 登录你的 Google 账户。

  2. 创建一个新的 Firebase 项目:

图 3.2 – 创建新的 Firebase 项目

图 3.2 – 创建新的 Firebase 项目

  1. 输入您项目的名称(它应该是关于餐厅的!)然后按继续

  2. 可选地,在下一个对话框中,您可以取消选择 Google Analytics,因为我们不会使用 Firebase SDK。再次按继续。此时,项目应该已经创建。

  3. 从左侧菜单中,展开构建选项卡,搜索实时数据库,然后选择它:

图 3.3 – 访问实时数据库

图 3.3 – 访问实时数据库

  1. 在新显示的页面上,通过点击创建数据库来创建一个新的数据库。

  2. 设置数据库对话框中,选择数据库的位置,然后点击下一步

图 3.4 – 设置实时数据库

图 3.4 – 设置实时数据库

注意

如果后来对您的 Firebase 数据库的任何网络调用因不明原因失败,您可能会发现自己处于 Firebase 限制位置——正如我撰写这一章节时,由于当前由东战引起的情况,罗马尼亚的所有互联网服务提供商都被限制,对 Firebase 数据库的任何网络调用都失败了。如果这种情况发生在您身上,请尝试为实时数据库实例选择不同的位置。

  1. 在相同的对话框中,通过选择以测试模式开始然后点击启用来定义您的安全规则。

图 3.5 – 设置数据库的安全规则

图 3.5 – 设置数据库的安全规则

重要提示

测试模式的默认安全规则允许任何人在创建后的前 30 天内查看或修改您数据库中的内容。在这 30 天后,如果您想继续以测试模式使用数据库,您需要通过更改".read"".write"字段的时戳值来更新安全规则,并使用更大的时戳值。为了跳过这一步骤,我们将在下一步中将".read"".write"字段设置为true。然而,如果您无限期地不设置任何规则而让数据库开放访问,Firebase 可能会限制您的访问权限——这就是为什么我建议您经常访问 Firebase 控制台并检查您数据库的安全规则,以确保访问权限没有被撤销。

此时,您应该被重定向到数据选项卡中您数据库的主页:

图 3.6 – 观察实时数据库主页

图 3.6 – 观察实时数据库主页

您现在将注意到此数据库的 URL:restaurants-db-default-rtdb.firebaseio.com/.

您的 URL 应该类似,但可能因您为数据库选择的名称而有所不同。

注意,数据库看起来似乎是空的;我们只有一个名为 restaurants-db-default-rtdb 的空根节点。现在是时候向我们的数据库添加数据了。

  1. 通过导航到本书 GitHub 仓库的 Chapter_03 目录来访问本章的解决方案代码。然后,选择 restaurants.json 文件。您也可以通过以下链接访问它:github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/blob/main/Chapter_03/restaurants.json

从这里,下载 restaurants.json 文件,因为我们很快就会需要它。为此,点击 Github 网站提供的 Raw 按钮,然后右键点击已打开的文档,通过选择 另存为 下载 JSON 文件。

  1. 返回 Firebase 控制台,点击数据库 URL 右侧的三点菜单,并选择 导入 JSON

图 3.7 – 将 JSON 内容导入实时数据库

图 3.7 – 将 JSON 内容导入实时数据库

确保您选择之前从书籍的 GitHub 仓库下载的 restaurants.json 文件。

  1. 等待页面刷新并查看数据库中填充的内容:

图 3.8 – 观察我们数据库中的内容结构

图 3.8 – 观察我们数据库中的内容结构

在这里,我们可以看到我们的数据库包含一个 Restaurant 类的列表:一个 ID、标题和描述。我们数据库中的餐厅还包含我们目前不需要的其他字段,所以让我们忽略它们。

备注

如果你比较我们数据库中的内容结构与已上传的 JSON 文件的结构,我们可以看到它们非常相似:我们有一个包含对象的数组的 restaurants 节点,每个对象都包含一致的键值对。唯一的例外是每个餐厅的索引(0、1、2 等等),这些是由 Firebase 自动创建的。我们应该忽略这些,因为它们不会影响我们。

现在,尽管我们之前已经将安全规则设置为测试模式,但让我们重新审视它们。

  1. ".read" 键的值从 "true" 移开:

图 3.9 – 更新实时数据库中的安全规则

  1. 由于我们正在进行测试而不是发布任何内容到生产环境,请按 true

现在,我们可以将数据库 URL 作为简单的 REST 端点来访问,模拟一个我们可以连接到的真实 REST 服务器。为了实验,复制您新创建的数据库的 URL,追加 restaurants.json,并将其粘贴到浏览器中。

访问此 URL 应该返回我们的餐厅的 JSON 响应,其结构我们将在稍后介绍。在此之前,我们需要指示我们的应用程序创建 HTTP 请求以从我们新创建的数据库中获取该数据。所以,让我们继续吧。

探索 Retrofit 作为 Android 的 HTTP 网络客户端

为了让应用程序从我们的数据库获取数据,我们需要实现一个 HTTP 客户端,该客户端将向数据库的 REST API 发送网络请求。

我们将使用 Retrofit HTTP 客户端库,而不是与 Android 默认提供的 HTTP 库一起工作,它允许你创建一个非常容易与之一起工作的 HTTP 客户端。

如果你计划开发一个与 REST API 交互的 HTTP 客户端,你将不得不处理很多事情——从建立连接、重试失败的请求或缓存到响应解析和错误处理。Retrofit 通过抽象处理网络请求和响应的大部分底层复杂性,为你节省了开发时间和潜在的头痛。

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

  • 使用 Retrofit

  • 将 Retrofit 添加到 Restaurants 应用程序

  • 将 JSON 映射到模型类

  • 执行对 Firebase REST API 的 GET 请求

让我们从 Retrofit 的基础知识开始吧!

使用 Retrofit

Retrofit 使 Android 应用中的网络变得简单。它允许我们轻松消费网络服务,创建网络请求,并在减少与实现相关的样板代码的同时接收响应。

注意

Retrofit 还允许你轻松添加自定义头和请求类型、文件上传、模拟响应等。

要使用 Retrofit 执行网络请求,我们需要以下三个组件:

  • 定义需要执行 HTTP 操作的接口。这样的接口可以指定请求类型,如 GETPUTPOSTDELETE 等。

  • 一个 Retrofit.Builder 实例,它创建了一个我们之前定义的接口的具体实现。Builder API 允许我们定义网络参数,如 HTTP 客户端类型、HTTP 操作的 URL 端点、反序列化 JSON 响应的转换器等。

  • 模型类允许 Retrofit 知道如何将反序列化的 JSON 对象映射到常规数据类。

理论已经足够了——让我们尝试在 Restaurants 应用程序中实现 Retrofit 并使用我们之前介绍过的组件。

将 Retrofit 添加到 Restaurants 应用程序

我们希望将我们的 Restaurants 应用程序连接到新创建的 Firebase 数据库,并向其发送 HTTP 网络请求。更具体地说,当 Restaurants 应用程序启动并且 RestaurantsScreen 组合组件被组合时,我们希望在运行时获取餐厅列表,而不是依赖于应用程序内的硬编码内容。

让我们借助 Retrofit 来完成这项工作:

  1. 在应用程序模块的 build.gradle 文件中,在 dependencies 块内添加 Retrofit 的依赖项:

    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    
  2. 在更新 build.gradle 文件后,请确保将项目与其 Gradle 文件同步。您可以通过点击 文件 菜单选项,然后选择 同步项目与 Gradle 文件 来完成此操作。

  3. 创建一个接口,定义我们的应用程序和数据库之间执行的 HTTP 操作。通过点击应用程序包,将名称选为 RestaurantsApiService,并选择 接口 作为类型。在新建的文件中,添加以下代码:

    import retrofit2.Call
    import retrofit2.http.GET
    interface RestaurantsApiService {
        @GET("restaurants.json")
        fun getRestaurants(): Call<Any>
    }
    

让我们将我们刚刚添加的代码分解成有意义的操作:

  • Retrofit 将您的 HTTP API 转换为简单的 Java/Kotlin 接口,因此我们创建了一个 RestaurantsApiService 接口,该接口定义了我们需要的 HTTP 操作。

  • 我们在接口内部定义了一个 getRestaurants 方法,该方法返回一个带有 Kotlin 的 Any 类型标记的未定义响应类型的 Call 对象。来自 RestaurantsApiService 的每个 Call 都可以向远程 Web 服务器发出同步或异步 HTTP 请求。

  • 我们使用 @GET 注解注释了 getRestaurants 方法,从而告诉 Retrofit 该方法应执行 GET HTTP 操作以从我们的 Web 服务器获取数据。在 @GET 注解中,我们传递了端点的路径,它代表我们 Firebase 数据库中的 restaurants 节点。这意味着当我们执行此请求时,restaurants.json 端点将被附加到 HTTP 客户端的基 URL。

注意

我们提到可以使用我们的 Firebase 实时数据库 URL 作为 REST API。为了访问特定的节点,例如我们数据库中的 restaurants 节点,我们还附加了 .json 格式,以确保 Firebase 数据库的行为类似于 REST API 并返回 JSON 响应。

之后,在我们实例化 Retrofit 构建器之后,库将知道如何将我们的 getRestaurants 方法转换为适当的 HTTP 请求。

但在之前,你可能已经注意到,我们接口中的 getRestaurants HTTP 请求将其响应类型定义为 Any。我们期望接收将我们的餐厅 JSON 内容映射到我们可以用于我们代码的数据类的 JSON 内容。所以,让我们继续这个工作。

将 JSON 映射到模型类

Retrofit 允许您自动序列化请求体和反序列化响应体。在我们的情况下,我们对我们将响应体从 JSON 反序列化为 Java/Kotlin 对象感兴趣。

为了反序列化 JSON 响应,我们将指示 Retrofit 使用 GSON 反序列化库,但在此之前,让我们看看我们的数据库返回的 JSON 响应。记住,当我们填充 Firebase 数据库时,我们导入了一个名为 restaurants.json 的 JSON 文件。

让我们用任何文本编辑器打开该文件并观察其结构:

图 3.10 – Firebase 数据库内容的 JSON 结构

图 3.10 – Firebase 数据库内容的 JSON 结构

我们可以在 JSON 响应结构中观察到以下元素:

  • 它包含由 [] 标识符标记的一系列 JSON 对象。

  • JSON 数组元素的 内容 由 {} 标识符标记,并包含餐厅的 JSON 对象结构。

  • 餐厅 JSON 对象包含四个键值对,由 , 分隔。

来自我们数据库的响应将是 List<?> 类型,因为响应包含一个 JSON 对象数组。剩下的问题是我们应用程序应该期望列表中包含什么数据类型?

为了回答这个问题,我们必须更仔细地检查餐厅 JSON 对象的结构:

图 3.11 – 餐厅 JSON 对象的结构

在这里,我们可以看到 JSON 餐厅有四个键值对,分别对应餐厅的 idtitledescriptionshutdown 状态。这种结构与项目中的 Restaurant.kt 数据类类似:

data class Restaurant(val id: Int,
                      val title: String,
                      val description: String,
                      var isFavorite: Boolean = false)

我们的 Restaurant 还包含 idtitledescription 字段。目前我们对 shutdown 状态不感兴趣,因此使用 Restaurant 类作为响应的模型很诱人,从而使 RestaurantsApiService 中的 getRestaurants() 方法返回 List<Restaurant> 作为请求的响应。

这种方法的缺点是我们需要告诉 Retrofit 将 r_id 键的值与我们的 id 字段匹配。同样,r_title 应该与 title 字段匹配,依此类推。我们可以用两种方法来处理这个问题:

  • Restaurant 数据类的字段重命名,以便它们与响应键匹配:r_idr_title 等。在这种情况下,反序列化将自动将我们的字段与 JSON 对象的字段匹配,因为 JSON 键与字段名称相同。

  • 使用特殊的序列化匹配器注释 Restaurant 数据类的字段,以告诉 Retrofit 哪些键应该与每个字段匹配。这不会改变变量名。

第一种方法不好,因为我们的 Restaurant 数据类最终会包含由服务器指定的下划线命名的字段。它也不再符合 Kotlin 的 CamelCase 规范来定义字段变量。

让我们选择第二种方法,其中我们自行指定序列化键。为此,我们将告诉 Retrofit 使用 GSON 反序列化库来反序列化 JSON,这是一个将 JSON 字符串转换为 Java/Kotlin 对象以及相反方向的强大框架:

  1. 首先,我们需要添加 GSON 库依赖项来标记我们的字段,使用自定义序列化键。在 app 模块的 build.gradle 文件中,在 dependencies 块内添加 GSON 依赖项:

    implementation "com.google.code.gson:gson:2.8.6"
    
  2. 更新 build.gradle 文件后,请确保将项目与其 Gradle 文件同步。您可以通过点击 文件 菜单选项,然后选择 与 Gradle 文件同步项目 来完成此操作。

  3. Restaurant.kt 中,为每个字段添加 @SerializedName 注解,并指定来自 JSON 结构的相应序列化键:

    import com.google.gson.annotations.SerializedName
    data class Restaurant(
        @SerializedName("r_id")
        val id: Int,
        @SerializedName("r_title")
        val title: String,
        @SerializedName("r_description")
        val description: String,
        var isFavorite: Boolean = false)
    

通过这样做,我们确保 Retrofit 将正确地将每个 JSON 键的值与 Restaurant 数据类中的相应字段匹配,同时匹配数据类型:

  • r_id 键与 id 字段匹配。在 JSON 结构中,r_id 键的值是一个整数,所以我们将其值存储在 id: Int 字段中。

  • r_title 键与 title 字段匹配。r_title 键的值是带有 "" 标识符的文本,所以我们将其值存储在 title: String 字段中。

  • r_description 键与 description 字段匹配。r_description 键的值是带有 "" 标识符的文本,所以我们将其值存储在 description: String 字段中。

注意

目前,我们正在将 Restaurant 数据模型同时用作 API 响应模型和整个应用程序中使用的域模型。从架构角度来看,这种做法并不推荐,我们将在 第八章 中解释为什么这样做不合适,并将在 Android 清洁架构入门 中修复它。

  1. 更新 RestaurantsApiService 中的 getRestaurants() 方法,使其从服务器返回一个类型参数与预期响应匹配的 Call 对象。在我们的例子中,这将是一个 List<Restaurant>

    interface RestaurantsApiService {
        @GET("restaurants.json")
        fun getRestaurants(): Call<List<Restaurant>>
    }
    

通过这样,我们的 Retrofit API 接口已经定义好了,用于从我们的 Firebase 数据库接收餐厅数据库的内容。剩下的唯一步骤是配置一个 Retrofit 构建器实例并执行请求。

执行对 Firebase REST API 的 GET 请求

让我们配置执行请求所需的最后一个组件——Retrofit.builder 对象:

  1. 首先,我们需要为 Retrofit 添加 GSON 转换器库依赖,以便 Retrofit 在反序列化 JSON 响应时遵循我们之前添加的 GSON 序列化注解。在应用模块的 build.gradle 文件中,在 dependencies 块内添加 Retrofit GSON 转换器的依赖项:

    implementation "com.squareup.retrofit2:converter-
        gson:2.9.0"
    
  2. 更新 build.gradle 文件后,请确保将项目与其 Gradle 文件同步。您可以通过点击 文件 菜单选项,然后选择 与 Gradle 文件同步项目 来完成此操作。

  3. RestaurantsViewModel 中,添加一个类型为 RestaurantsApiServicerestInterface 变量,并创建一个 init 块,我们将在这里实例化 Retrofit.builder 对象:

    class RestaurantsViewModel(…) : ViewModel() {
        private var restInterface: RestaurantsApiService
        val state = mutableStateOf(
            dummyRestaurants.restoreSelections()
        )
        init {
            val retrofit: Retrofit = Retrofit.Builder()
                .addConverterFactory(
                    GsonConverterFactory.create()
                )
                .baseUrl(
                    "https://restaurants-db-default
                            -rtdb.firebaseio.com/"
                )
                .build()
            restInterface = retrofit.create(
                RestaurantsApiService::class.java
            )
        }
        […]
    }
    

我们已经为我们的网络客户端添加了所有必要的组件。让我们分析一下这段代码:

  • 首先,我们定义了一个类型为 RestaurantsApiServicerestInterface 变量,我们将调用它来执行所需的网络请求。在此阶段,restInterface 变量没有任何值。

  • 我们添加了一个 init 块来实例化 Retrofit 构建器对象。由于主构造函数不能包含任何代码,我们将初始化代码放置在一个以 init 关键字为前缀的初始化块中。

  • 我们使用 Retrofit.Builder 访问器实例化了一个 retrofit: Retrofit 变量,并指定了以下内容:

  • 一个 GsonConverterFactory,明确告诉 Retrofit 我们想要使用 GSON 转换器反序列化 JSON,遵循我们在 Restaurant 数据类中指定的 @Serialized 注解。

  • 一个 baseUrl 用于执行所有请求——在你的情况下,将此 URL 替换为你的 Firebase 数据库的 URL。

  • 最后,我们在之前获得的 Retrofit 对象上调用了 .create(),并传递了我们的接口以及所需的请求:RestaurantsApiService。幕后,Retrofit 创建了我们接口的具体实现,该实现将处理所有网络逻辑,我们无需担心这一点。我们将 Retrofit 中的这个实例存储在 restInterface 变量中。

现在,我们可以执行请求——在我们的案例中,是获取餐厅列表的请求。

  1. RestaurantsViewModel 内部,添加 getRestaurants 方法:

    fun getRestaurants() {
        restInterface.getRestaurants().execute().body()
            ?.let { restaurants ->
                state.value = restaurants.restoreSelections()
            }
    }
    

我们为我们的网络请求执行添加了所有必要的步骤。让我们分解这段代码:

  1. 我们通过调用 getRestaurants() 接口方法从 Retrofit 的 restInterface 变量中获取了一个名为 Call<List<Restaurant>>Call 对象。Call 对象代表了调用 Retrofit 方法发送网络请求并接收响应的调用。Call 对象的类型参数与响应类型相匹配;即 <List<Restaurant>>

  2. 在之前获得的 Call 对象上,我们调用了 execute() 方法。execute() 方法是使用 Retrofit 开始网络请求的最简单方法,因为它在主线程(UI 线程)上同步运行请求,并在响应到达之前阻塞它。尽管我们很快会解决这个问题,但任何网络请求都不应该阻塞 UI 线程。

  3. execute() 方法返回一个 Retrofit Response 对象,它允许我们查看响应是否成功,并获取结果体。

  4. body() 访问器返回一个可能为空的 List<Restaurant>>? 类型的列表。我们应用 Kotlin 的 let 扩展函数,并将列表命名为 restaurants

  5. 在系统启动的进程死亡的情况下,我们在恢复选择后,将生成的 restaurants 列表传递给我们的 state 对象,这与我们为初始状态值所做的方法类似。

通过这种方式,我们指示我们的 ViewModel 如何从数据库中获取餐厅列表,并将此结果传递到屏幕的状态中。我们稍后必须解决的问题之一是我们没有捕获 Retrofit 如果请求失败可能抛出的任何错误。在此之前,让我们专注于使用新的结果更新状态。

  1. RestaurantsViewModel内部,我们需要更新状态对象的初始值,使其包含一个空列表。这是因为,当屏幕首次显示时,我们不再有餐厅来渲染 – 我们将在网络请求中稍后获取它们。通过移除dummyList并放置一个emptyList()来更新state对象的初始值:
val state = mutableStateOf(emptyList<Restaurant>())
  1. Restaurant.kt文件中,移除dummyRestaurants列表,因为我们将通过之前定义的请求在运行时获取餐厅。

  2. 我们希望触发网络请求以从服务器获取餐厅。在RestaurantsScreen.kt内部,更新RestaurantsScreen可组合函数,使其调用viewModelgetRestaurants()方法,这将触发网络请求以从服务器获取餐厅:

    @Composable
    fun RestaurantsScreen() {
        val viewModel: RestaurantsViewModel = viewModel()
        viewModel.getRestaurants()
        LazyColumn( … ) { … }
    }
    

通过调用viewModel.getRestaurants(),我们试图在RestaurantsScreen可组合函数首次组合时加载餐厅列表。这种做法是不推荐的,我们将在以下步骤中看到为什么这是不推荐的,以及如何修复它。

  1. AndroidManifest.xml文件中添加互联网权限:

    <manifest xmlns:android="…"
        package="com.codingtroops.restaurantsapp">
        <uses-permission                                   android:name="android.permission.INTERNET" />
          <application> … </application>
    </manifest>
    
  2. 通过点击运行按钮来运行应用程序。

不幸的是,应用程序很可能会崩溃。如果我们检查Logcat,我们会注意到一个类似于以下异常堆栈:

图 3.12 – 在主线程上执行网络请求的崩溃堆栈跟踪

这里抛出的异常是NetworkOnMainThreadException,我们很清楚我们的代码哪里出了问题:我们正在主线程上执行网络请求。

这是因为,在 Android Honeycomb SDK 中,在主线程上执行网络请求是被禁止的,因为应用程序的 UI 将冻结,直到服务器响应到达,这使得应用程序在该时间段内无法使用。换句话说,我们不能也不应该使用 Retrofit Call对象的.execute()方法,因为请求将在主线程上同步运行。

相反,我们可以使用一个替代方案,它不仅将以异步方式在单独的线程上执行请求,而且还允许我们处理 Retrofit 抛出的任何错误。

  1. ViewModelgetRestaurants()方法中,将.execute()调用替换为.enqueue()

    fun getRestaurants() {
       restInterface.getRestaurants().enqueue(
        object : Callback<List<Restaurant>> {
            override fun onResponse(
                call: Call<List<Restaurant>>,
                response: Response<List<Restaurant>>
            ) {
                response.body()?.let { restaurants ->
    state.value = 
                        restaurants.restoreSelections()
                }
            }
            override fun onFailure(
                call: Call<List<Restaurant>>, t: Throwable
            ) {
                t.printStackTrace()
            }
        })
    }
    

当为CallCallbackResponse类添加缺少的导入时,确保你添加的是以import retrofit2.*开头的 Retrofit2 导入。

回到我们添加的代码,让我们更详细地看看它:

  • 在我们从restInterface.getRestaurants()方法获得的Call对象上,我们调用了.enqueue()函数。.enqueue()调用是.execute()的一个更好的替代方案,因为它在单独的线程上异步运行网络请求,因此它将不再在 UI 线程上运行,也不会阻塞 UI。

  • .enqueue() 函数接收一个 Callback 对象作为参数,允许我们监听成功或失败回调。Callback 对象的类型参数定义了预期的 Response 对象。由于我们期望响应类型为 <List<Restaurant>>,返回的 Callback 类型被定义为 Callback<List<Restaurant>>

  • 我们实现了所需的 object : Callback<List<Restaurant>> 并实现了其两个回调:

  • onResponse(),这是在网络请求成功时被调用的成功回调。它为我们提供了初始的 Call 对象,但更重要的是 Response 对象;即 Response<List<Restaurant>>。在这个回调内部,我们获取响应体并更新 state 变量的值,就像我们使用 execute() 时做的那样。

  • onFailure(),这是失败回调。当与服务器通信时发生网络异常,或者在创建请求或处理响应时发生意外异常时,它会被调用。这个回调为我们提供了初始的 Call 对象和被拦截的 Throwable 异常,我们打印了其堆栈跟踪。

现在,你可以运行应用程序。它不应该再崩溃了,因为调用 enqueue() 允许请求在单独的线程上运行,这样我们就可以安全地等待响应而不阻塞 UI。

注意

作为一种良好的实践,确保当你使用 Retrofit 发起请求时,始终调用 enqueue() 函数而不是 execute() 函数。你希望用户在等待网络响应时不会遇到崩溃,并且能够与应用程序进行交互。

即使有了这个添加,我们的代码中仍然存在两个令人担忧的问题。你是否注意到了?让我们尝试识别它们。

改进应用程序处理网络请求的方式

我们的应用程序现在可以成功地在运行时从服务器动态获取数据。不幸的是,我们在代码中犯了两个主要错误,这两个错误都与应用程序处理请求的方式有关。让我们来识别它们:

  • 首先,我们没有取消我们的网络请求作为清理措施。如果我们的 UI 组件(在我们的例子中是 MainActivity)在服务器响应到达之前被销毁(例如,如果用户导航到另一个活动),我们可能会创建潜在的内存泄漏。这是因为我们的 RestaurantsViewModel 仍然绑定到 Callback<List<Restaurant>> 对象上,该对象等待服务器的响应。由于这个原因,垃圾收集器不会释放与这两个实例相关的内存。

  • 其次,我们没有从受控环境中触发网络请求。viewModel.getRestaurants() 方法是在 RestaurantsScreen() 组合函数内部被调用的,没有任何特殊考虑。这意味着每次 UI 重新组合时,组合函数都会要求 ViewModel 执行网络请求,从而导致可能的多余和重复请求。

让我们先关注第一个问题。

将取消网络请求作为清理措施

我们 RestaurantsViewModel 中的主要问题是我们在排队一个 Call 对象,并通过 Callback 对象等待响应,但我们从未取消那个排队的 Call。我们应该在宿主 ActivityViewModel 被清除时取消它,以防止内存泄漏。让我们在这里做这件事:

  1. RestaurantsViewModel 中,定义一个类型为 Call 且具有 List<Restaurant> 类型参数的类变量。将其命名为 restaurantsCall,因为我们将会使用它来保存对排队的 Call 对象的引用:

    class RestaurantsViewModel(…): ViewModel() {
        private var restInterface: RestaurantsApiService
        val state = […]
        private lateinit var restaurantsCall: 
            Call<List<Restaurant>>
        init {…}
       […]
    }
    

我们已将 restaurantsCall 标记为 lateinit 变量,以便在执行网络请求时稍后实例化它。

  1. RestaurantsViewModelgetRestaurants() 方法内部,将您从 restInterface.getRestaurants() 方法调用中获得的 Call 对象分配给 restaurantsCall 成员变量,并对其调用 enqueue()

    fun getRestaurants() {
        restaurantsCall = restInterface.getRestaurants()
        restaurantsCall.enqueue(object : 
     Callback<List<Restaurant>> {…})
    }
    
  2. RestaurantsViewModel 中,重写 onCleared() 方法并调用 restaurantCall 对象的 cancel() 方法:

    override fun onCleared() {
        super.onCleared()
        restaurantsCall.cancel()
    }
    

onCleared() 回调方法由 Jetpack ViewModel 提供,并在 ViewModel 由于附加的活动/片段或组合函数被销毁或从组合中移除而销毁之前被调用。

这个回调为我们提供了一个完美的机会来取消任何正在进行的工作——或者在我们的情况下,取消在 restaurantCall 对象中排队的挂起 Call 对象。这样,我们防止了内存泄漏,并因此修复了我们代码中的第一个问题。

现在,是时候关注第二个问题,即 RestaurantsScreen() 组合函数在没有任何特殊考虑的情况下调用了 viewModel.getRestaurants() 方法。

从受控环境中触发网络请求

调用 viewModel.getRestaurants() 方法是因为我们想在 UI 中应用一个 副作用。副作用是对应用程序状态所做的更改,通常发生在组合函数的作用域之外。在我们的情况下,副作用是我们需要在用户进入屏幕时首次开始加载餐厅。

根据经验法则,组合函数应该是无副作用的,但在我们的应用程序中,我们需要知道何时触发网络请求,以及还有什么比我们的 UI 首次组合时更好的时机呢?

现有的简单从组合层调用ViewModel方法的方法存在一个问题,那就是 Compose UI 可以在屏幕上多次重新组合。例如,当渲染动画时,Compose UI 会多次重新组合以执行动画的关键帧。在每次 UI 重新组合时,我们的组合都会调用RestaurantsViewModel上的getRestaurants()方法,这会反过来执行网络请求以从服务器获取餐厅,这可能导致多次和冗余的请求。

为了防止这种情况发生,Compose 为我们提供了处理副作用的有效工具:Effects API。

一个effect是一个组合函数,它不是发出 UI 元素,而是在组合过程完成后执行副作用。这些组合基于 Kotlin Coroutine API,允许你在它们的主体中运行异步工作。然而,我们现在将忽略协程,因为我们将在第四章中介绍它们,即使用协程处理异步操作。

在 Compose 中,有许多类型的 effect 组合函数我们可以使用,但我们将不会深入探讨这一点。然而,在我们的情况下,一个合适的效果可以是LaunchedEffect组合函数,因为它允许我们在第一次进入组合时只运行一次任务。

LaunchedEffect的签名很简单——它包含一个key1参数和一个block参数,我们可以在这里执行我们的代码。现在,我们应该忽略协程术语,只需将block函数参数视为可以异步执行的代码块:

![图 3.13 – LaunchedEffect组合函数的签名

![图 3.13 – LaunchedEffect组合函数的签名

图 3.13 – LaunchedEffect组合函数的签名

LaunchedEffect进入组合过程时,它会运行block参数函数,该函数作为参数传递。如果LaunchedEffect离开组合,则取消执行该块。如果LaunchedEffect使用传递给key1参数的不同键重新组合,则现有的代码块执行将被取消,并启动新的执行迭代。

现在我们知道了LaunchedEffect是如何工作的,我们可以同意它似乎是我们问题的可行解决方案,至少目前是这样:我们想要确保在初始组合中只执行一次对ViewModel的调用,所以LaunchedEffect似乎能满足我们的需求。

让我们添加一个LaunchedEffect来防止我们的 UI 在每次重新组合时反复从ViewModel请求餐厅:

  1. RestaurantsScreen组合函数内部,将viewModel.getRestaurants()调用包裹在LaunchedEffect组合函数中:

    @Composable
    fun RestaurantsScreen() {
        val viewModel: RestaurantsViewModel = viewModel()
        LaunchedEffect(key1 = "request_restaurants") {
            viewModel.getRestaurants()
        }
        LazyColumn(…) { … }
    }
    

为了实现LaunchedEffect组合函数,我们做了以下操作:

  • 我们将硬编码的 "request_restaurants" 字符串值传递给 key1 参数。我们向 key1 参数传递硬编码的值,因为我们希望传递给 LaunchedEffect composable 中的代码块在每次重组时都不执行。我们可以向 key1 传递任何常量,但这里重要的是这个值不应该随时间改变。

  • 我们在效果的 block 参数中传递了调用 getRestaurants() 方法的代码。由于 block 参数是 LaunchedEffect composable 的最后一个参数,并且是一个函数,我们使用了尾随 lambda 语法。

  1. 运行应用程序。现在,LaunchedEffect 内部的代码应该只执行一次。

即使添加了这一功能,我们的代码仍然存在问题。如果你尝试旋转你正在测试的模拟器或设备,将会触发配置更改,并执行另一个网络请求。但我们之前提到,LaunchedEffect 只会执行一次 viewModel.getRestaurants() 调用,那么为什么会出现这种情况呢?

LaunchedEffect 工作正常——问题在于配置更改时活动被销毁。如果活动被销毁,UI 将从头开始重新组合,并且对于它来说,LaunchedEffect 将会第一次运行 block 参数中的代码。

你能想到一个更好的替代方案来解决这个问题,即避免由于配置更改而导致活动被销毁吗?

另一个选择是使用 ViewModel 组件,因为它会存活于配置更改。如果我们只在 RestaurantsViewModel 中触发一次请求,我们就不再关心配置更改是否发生——请求将不会再次执行。按照以下步骤操作:

  1. RestaurantsViewModel 中,找到 init 块,并在其中调用 getRestaurants()

    init {
        val retrofit: Retrofit = Retrofit.Builder().[…].build()
        restInterface = retrofit.create(
            RestaurantsApiService::class.java
        )
        getRestaurants()
    }
    

当创建 ViewModel 实例时,init 块只会被调用一次,因此在这里放置我们的网络请求比在任何 composable 的 UI 层面上更安全。确保你在 restInterface 变量实例化之后放置 getRestaurants() 调用,因为 getRestaurants() 方法依赖于该变量准备好工作。

  1. 仍然在 RestaurantsViewModel 中,导航到 getRestaurants() 方法,将其标记为 private

    private fun getRestaurants() {
        …
    }
    

我们不再需要将此方法公开暴露给 UI,因为它现在仅在 ViewModel 内部调用。

  1. RestaurantsScreen composable 中,移除包含所有代码的 LaunchedEffect composable 函数,因为我们不再需要它。

  2. 运行应用程序。在做出配置更改时,网络请求不应该再次执行,因为 RestaurantsViewModel 实例被保留,并且其 init 块中的代码不会再次执行。

我们已经采取了相当多的步骤来确保我们的应用程序正确处理网络请求,这是创建现代应用程序的一个很好的第一步。

概述

在本章中,我们学习了移动应用程序如何使用 HTTP 连接和 REST API 与远程 Web API 通信。然后,我们在 Firebase 的帮助下为我们的 Restaurants 应用程序创建了一个数据库,并填充了内容。

之后,我们探讨了 Retrofit 是什么以及它是如何抽象处理应用程序与 Web API 之间 HTTP 连接中的网络请求和响应的复杂性的。

然后,我们在我们的 Restaurants 应用程序中使用 Retrofit 执行了一个网络请求,并学习了服务器发送的 JSON 内容是如何被我们的 Retrofit 网络客户端解析或反序列化的。我们还学习了如何正确等待网络响应,以及如何在响应到达时通知应用程序。

最后,我们解决了当我们的应用程序异步与 Web API 通信以检索数据时出现的一些常见问题,尤其是在 Compose 的上下文中。

在下一章中,我们将探讨 Android 中一个用于异步工作的非常高效的工具,这个工具与 Kotlin 一起提供:协程!

进一步阅读

在 Retrofit 接口内部使用自定义注解的帮助下,这个库隐藏了与处理网络请求相关的绝大多数复杂性。我们看到了在我们的RestaurantsApiService接口中,当我们使用@GET注解注释我们的请求时,简单的GET请求:

interface RestaurantsApiService {
    @GET("restaurants.json")
    fun getRestaurants(): Call<List<Restaurant>>
}

然而,除了普通的GET操作之外,这样的 Retrofit 接口还可以处理其他请求类型,如PUTPOSTDELETE

例如,如果您需要定义一个将一些数据传递给服务器的请求,这些数据可能需要存储,您可以通过添加@POST注解到您希望的方法来使用POST请求:

@POST("user/edit")
fun updateUser(@Field("first_name") firstName: String): 
    Call<User>

要了解如何使用 Retrofit 处理此类情况,或更高级的情况,请查看官方文档:https://square.github.io/retrofit/

第四章:第四章:使用协程处理异步操作

在本章中,我们专注于另一个库,尽管它不在 Jetpack 库套件中,但对于编写稳健的应用程序至关重要:Kotlin 协程

协程代表了在 Android 上处理异步工作和并发任务的一种更方便的方式。

在本章中,我们将研究如何在我们的餐厅应用中用协程替换回调。在第一部分介绍 Kotlin 协程中,我们将更好地理解协程是什么,它们是如何工作的,以及为什么我们需要在我们的应用中使用它们。

在下一节探索协程的基本元素中,我们将探索协程的核心元素,并了解如何更简洁地使用它们来处理异步工作。

最后,在使用协程进行异步工作部分,我们将在我们的餐厅应用中实现协程,并让它们处理网络请求。此外,我们还将添加错误处理,并在 Android 应用中使用协程时整合一些最佳实践。

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

  • 介绍 Kotlin 协程

  • 探索协程的基本元素

  • 使用协程进行异步工作

在深入之前,让我们为这一章设置技术要求。

技术要求

使用协程构建基于 Compose 的 Android 项目通常需要您的日常工具。但是,为了顺利地跟随本章,请确保您有以下内容:

  • Arctic Fox 2020.3.1 版本的 Android Studio。您也可以使用更新的 Android Studio 版本或甚至 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • Kotlin 1.6.10 或更新的插件,已安装在 Android Studio 中

  • 上一章的餐厅应用代码。

本章的起点是上一章开发的餐厅应用。如果您没有跟随上一章的实现,请通过导航到存储库中的Chapter_03目录并导入名为chapter_3_restaurants_app的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到Chapter_04目录:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_04/chapter_4_restaurants_app

介绍 Kotlin 协程

协程是 Kotlin API 的一部分。它们引入了一种新的、更简单的方式来处理异步工作和并发任务。

通常,在 Android 中,我们需要在幕后运行或执行不同的任务。同时,我们不想阻塞应用程序的主线程,并得到一个无响应的用户界面。

为了减轻这个问题,协程允许你更容易地执行异步工作,同时为你的 Android 应用提供主线程安全性。你可以根据需要启动一个或多个协程来使用协程 API。

在本节中,我们将涵盖三个关于协程 API 的基本问题,这些问题源于我们之前提到的内容:

  • 什么是协程?

  • 协程有哪些特性和优势?

  • 协程是如何工作的?

让我们开始吧!

什么是协程?

协程是异步工作的并发设计模式。协程代表了一个可挂起的计算实例

换句话说,协程是表示可挂起计算任务的代码序列或块。我们称它们为可挂起的,因为协程可以在执行过程中被挂起和恢复,这使得它们对并发任务非常高效。

当比较协程和线程时,我们可以这样说:

  • 协程是线程的轻量级版本,但并非线程。协程之所以轻量,是因为创建协程不会分配新的线程——通常,协程使用预定义的线程池。

  • 和线程一样,协程可以并行运行,互相等待,并进行通信。

  • 与线程不同,协程非常便宜:我们可以创建成千上万的协程,而在性能方面几乎不付出任何代价。

接下来,让我们更好地理解协程背后的目的。

协程的特性和优势

到目前为止,我们知道在 Android 上,协程可以帮助我们将长时间运行的异步工作从主线程移动到单独的线程。本质上,协程有两个主要可能的用途:

  • 用于处理异步工作

  • 用于处理多线程

在本章中,我们将仅介绍如何在 Android 应用中正确地使用协程处理异步工作。

然而,在我们尝试理解如何使用协程来实现这一点之前,让我们先探索协程相较于我们过去使用的其他替代方案(如AsyncTask类、回调和响应式框架)所提供的优势。协程被描述如下:

  • 轻量级:我们可以在单个线程上启动许多协程。协程支持在线程上的执行挂起,而不是阻塞它,这导致更少的内存开销。此外,协程并不总是绑定到特定的线程——它可能在一个线程上开始执行,并在另一个线程上产生结果。

  • 易于取消:当取消父协程时,在同一作用域内启动的任何子协程都将被取消。如果你启动了多个并发运行的操作的协程,取消操作简单直接,并适用于整个受影响的协程层次结构;因此,这消除了任何潜在的内存泄漏。

  • ActivityFragmentViewModel等。这意味着您可以从这些组件安全地启动协程,因为当发生不同的生命周期事件时,它们将自动取消,因此您不必担心内存泄漏。

    注意

    我们多次提到了单词作用域,我保证我们会在稍后解释它。在此之前,您可以将协程作用域视为一个控制已启动协程生命周期的实体。

现在我们已经对协程的功能有了概念。然而,为了更好地理解它们的目的,首先,我们需要了解为什么我们应该将异步工作从主线程卸载到单独的工作线程。

协程是如何工作的?

在 Android 运行时,主线程负责两件事:

  • 在屏幕上绘制应用程序的 UI

  • 在用户交互时更新 UI

简单来说,主线程在屏幕画布上调用一个绘图方法。这个方法可能对您来说很熟悉,就是onDraw()方法,我们可以假设为了您的设备以每秒 60 帧的速度渲染 UI,Android 运行时会大约每 16 毫秒调用这个方法一次。

如果由于某种原因,我们在主线程上执行了繁重的异步工作,应用程序可能会冻结或卡顿。这是因为主线程忙于处理我们的异步工作;因此,它错过了几个本可以更新 UI 并防止冻结效果的onDraw()调用。

假设我们需要向我们的服务器发起一个网络请求。这个操作可能需要时间,因为我们必须等待响应,这取决于 Web API 的速度和用户的连接性。让我们想象这样一个方法被命名为getNetworkResponse(),并且我们是从主线程调用它的:

图 4.1 – 异步工作阻塞主线程

从它发起网络请求的那一刻起,主线程一直在等待响应,同时无法做任何事情。我们可以看到,由于主线程忙于执行我们的getNetworkResponse()方法调用并等待结果,因此错过了几个onDraw()调用。

为了减轻这个问题,我们过去使用了许多机制。然而,协程的使用更加简单,并且与 Android 生态系统完美配合。因此,是时候看看它们如何使我们能够执行异步工作了:

图 4.2 – 通过协程在另一个线程上执行异步工作

使用协程,我们可以将任何讨厌的阻塞调用(如getNetworkResponse()方法调用)从主线程卸载到协程。

协程在单独的线程上运行,负责执行网络请求并等待响应。这样,主线程就不会被阻塞,也不会错过任何onDraw()调用;因此,我们避免了出现任何屏幕冻结效果。

现在我们已经对协程的工作原理有了基本的了解,是时候探索协程所基于的组件了。

探索协程的基本元素

使用协程完成异步工作的一个非常简单的方案可以表达如下:首先定义挂起函数,然后创建执行挂起函数的协程。

然而,我们不仅不确定挂起函数看起来像什么,而且也不知道如何允许协程为我们执行异步工作。

让我们一步一步来,从我们需要使用协程执行异步工作的两个基本操作开始:

  • 创建挂起函数

  • 启动协程

所有这些术语现在都几乎没有什么意义,所以让我们来解决这个问题,从挂起函数开始!

创建挂起函数

为了使用协程,我们首先需要定义一个挂起函数,其中包含阻塞任务。

挂起函数是一种特殊函数,可以在某个时间点暂停(挂起),然后在稍后某个时间点恢复。这允许我们在函数挂起时执行长时间运行的任务,并在工作完成时最终恢复它。

我们代码中的常规函数调用大多数都是在主线程上同步执行的。本质上,挂起函数允许我们在后台异步执行任务,而不会阻塞调用这些函数的线程。

假设我们需要将有关用户的某些详细信息保存到本地数据库中。这个操作需要时间,因此我们需要显示一个动画直到它完成:

fun saveDetails(user: User) {
    startAnimation()
    database.storeUser(user)
    stopAnimation()
}

如果这个操作在主线程上调用,当用户的详细信息被保存时,动画将冻结几百毫秒。

仔细看看之前提供的代码,并问自己以下问题:哪个方法调用应该是可挂起的?

由于storeUser()方法需要一段时间才能完成,我们希望这个方法成为一个挂起函数,因为这个函数应该在用户的详细信息保存后暂停,然后在任务完成时恢复。这确保了我们不会阻塞主线程或冻结动画。

然而,我们如何使storeUser()方法成为一个挂起函数?

挂起函数是一个带有suspend关键字的常规函数:

suspend fun storeUser(user: User) {
    // blocking action
}

我们知道storeUser()方法将详细信息保存到数据库中,这需要一段时间。因此,为了防止这个任务阻塞 UI,我们用额外的suspend关键字标记了该方法。

然而,如果我们用一个suspend关键字标记一个方法,试图在我们的代码中调用它,会导致编译错误:

图片

图 4.3 – 从常规函数调用挂起函数会导致编译错误

挂起函数只能从协程内部或从另一个挂起函数内部调用。而不是从常规方法中调用我们的storeUser()挂起方法,让我们创建一个协程并从那里调用它。

启动协程

要执行一个暂停函数,首先,我们需要创建并启动一个协程。为此,我们需要在协程作用域上调用协程构建器:

fun saveDetails(user: User) {
    GlobalScope.launch(Dispatchers.IO) {
startAnimation()
        database.storeUser(user)
        stopAnimation()
    }
}

我们刚刚启动了我们的第一个协程,并在其中调用了暂停函数!让我们分析一下刚刚发生了什么:

  • 我们使用了一个GlobalScope协程作用域,它管理着在其中启动的协程。

  • 在协程作用域内,我们调用了launch()协程构建器来创建一个协程。

  • 然后,我们将Dispatchers.IO调度器传递给协程构建器。在这种情况下,我们希望在为 I/O 操作保留的线程中保存用户详细信息。

  • launch()协程构建器为我们提供的块内,我们调用我们的storeUser()暂停函数。

现在我们已经成功地将阻塞工作从主线程移至工作线程。因此,我们确保了 UI 不会被阻塞,动画可以流畅运行。

然而,现在我们在saveDetails()方法中实现了工作暂停,你可能想知道这个方法内函数调用的顺序是什么。

为了更好地理解正常同步世界与暂停世界的融合,让我们在我们的代码片段中添加一些日志:

fun saveDetails(user: User) {
    Log.d("TAG", "Preparing to launch coroutine")
    GlobalScope.launch(Dispatchers.IO) {
        startAnimation()
        Log.d("TAG", "Starting to do async work")
        database.storeUser(user)
        Log.d("TAG", "Finished async work")
        stopAnimation()
    }
    Log.d("TAG", "Continuing program execution")
}

记住,在这段代码块中,唯一需要花费一些时间来计算的暂停函数是database.storeUser()。现在,让我们想象我们已经运行了前面的代码片段。

练习

在检查以下输出之前,试着自己思考日志的顺序。你期望函数调用的顺序是什么?

让我们看看输出:

图片

图 4.4 – 正常函数和暂停函数的输出顺序

函数调用的顺序有点混乱,但绝对是正确的。让我们看看发生了什么:

  1. 首先,带有Preparing to launch coroutine信息的日志函数被调用。这个方法调用是在主线程(UI)上完成的。

  2. 尽管接下来我们启动了协程,但我们可以看到第二个日志函数调用是我们代码中的最后一条:Continuing program execution

这是因为协程是连接到暂停世界的桥梁,所以协程中的每一个函数调用都会在主线程之外的不同线程上运行。更精确地说,从主线程切换到Dispatchers.IO的操作将花费一些时间。这意味着协程内的所有这些方法都将在外部协程的方法调用之后执行。

  1. 下一条日志函数调用是带有Starting to do async work信息的。这个方法在协程中在一个为 I/O 操作保留的线程上被调用。这条日志标志着所有暂停工作的执行开始。

  2. 最后,在database.storeUser()暂停函数中的所有阻塞工作完成后,最后一条带有Finished async work信息的日志函数调用被调用。这条日志标志着协程执行的结束。

现在我们已经了解了在函数调用方面,常规世界与挂起世界是如何融合的,但仍有许多术语和概念被抛给了你。主要的是,你可能想知道以下内容:

  • 协程作用域是什么?

  • 什么是协程调度器?

  • 什么是协程构建器?

让我们澄清这些概念,从协程作用域开始。

协程作用域

实际上,协程在 协程作用域 中运行。要启动一个协程,首先需要一个协程作用域,因为它跟踪其内部启动的所有协程,并且有取消它们的能力。这样,你可以控制协程应该存活多久以及何时取消。

协程作用域包含一个 CoroutineContext 对象,它定义了协程运行的环境。在上一个示例中,我们使用了预定义的作用域 GlobalScope,但你也可以通过构造一个 CoroutineContext 对象并将其传递给 CoroutineScope() 函数来定义一个自定义作用域,如下所示:

val job = Job()
val myScope = CoroutineScope(context = job + Dispatchers.IO)

CoroutineScope() 函数期望一个传递给其 context 参数的 CoroutineContext 对象,并且知道如何通过特殊的 plus 运算符接收元素,然后在幕后构建上下文。

大多数情况下,构建 CoroutineContext 对象的两个最重要的元素就是我们刚刚传递的:

  • 一个 Job 对象:这代表一个可取消的组件,它控制特定作用域中启动的协程的生命周期。当一个作业被取消时,该作业将取消它所管理的协程。例如,如果我们在一个 Activity 类中定义了一个 job 对象和一个自定义的 myScope 对象,那么在 onDestroy() 回调中调用 job 对象的 cancel() 方法是一个取消协程的好地方:

    override fun onDestroy() {
        super.onDestroy()
        job.cancel()
    }
    

通过这样做,我们确保了在协程中完成的异步工作,该协程使用 myScope 作用域,将在活动被销毁时停止,并且不会造成任何内存泄漏。

  • 一个 Dispatcher 对象:将方法标记为挂起并不会提供关于它应该在哪个线程池上运行的详细信息。因此,通过将一个 Dispatcher 对象传递给 CoroutineScope 构造函数,我们可以确保在协程中调用并使用此作用域的所有挂起函数将默认使用指定的 Dispatcher 对象。在我们的示例中,所有在 myScope 中启动的协程将默认在 Dispatchers.IO 线程池中运行,并且不会阻塞 UI。

注意,CoroutineContext 对象还可以包含一个异常处理对象,我们将在稍后定义。

除了我们之前定义的自定义作用域之外,你还可以使用预定义的与特定生命周期组件绑定的协程作用域。在这种情况下,你将不再需要使用作业定义作用域或手动取消协程作用域:

  • GlobalScope: 这允许协程与应用程序的生命周期保持一致。在之前的例子中,我们为了简单起见使用了这个作用域,但应该避免使用GlobalScope,因为在这个协程作用域内启动的工作只有当应用程序被销毁时才会取消。在具有比应用程序更窄的生命周期的组件(如Activity组件)中使用此作用域可能会允许协程超出该组件的生命周期并产生内存泄漏。

  • lifecycleScope: 这个作用域将协程与LifecycleOwner实例的生命周期相关联,例如Activity组件或Fragment组件。我们可以使用 Jetpack KTX 扩展包中定义的lifecycleScope作用域:

    class UserFragment : Fragment() {
        ...
        fun saveDetails(user: User) {
            lifecycleScope.launch(Dispatchers.IO) {
                startAnimation()
                database.storeUser(user)
                stopAnimation()
            }
        }
    }
    

通过在这个上下文中启动协程,我们确保如果Fragment组件被销毁,协程作用域将自动取消;因此,这也会取消我们的协程。

  • viewModelScope: 为了让我们的协程与ViewModel组件的生命周期保持一致,我们可以使用预定义的viewModelScope作用域:

    class UserViewModel: ViewModel() {
        fun saveDetails(user: User) {
            // do some work
            viewModelScope.launch(Dispatchers.IO) {
                database.storeUser(user)
            }
            // do some other work
        }
    }
    

通过在这个上下文中启动协程,我们确保如果ViewModel组件被清除,协程作用域将取消其工作——换句话说,它将自动取消我们的协程。

  • rememberCoroutineScope: 为了将协程作用域与可组合函数的组合周期相关联,我们可以使用预定义的rememberCoroutineScope作用域:

    @Composable
    fun UserComposable() {
        val scope = rememberCoroutineScope()
        LaunchedEffect(key1 = "save_user") {
            scope.launch(Dispatchers.IO) { 
                viewModel.saveUser()
            }
        }
    }
    

因此,协程的生命周期绑定到UserComposable的组合周期。这意味着当UserComposable离开组合时,作用域将自动取消,从而防止协程超出其父可组合组件的组合生命周期。

由于我们希望协程仅在组合时启动一次,而不是在每次重新组合时启动,所以我们用LaunchedEffect可组合包装了协程。

现在我们已经了解了协程作用域是什么以及它们如何允许我们控制协程的生命周期,是时候更好地理解分发器了。

分发器

CoroutineDispatcher对象允许我们配置工作应该执行在哪个线程池上。协程的目的是帮助我们将阻塞工作从主线程移开。因此,我们需要以某种方式指导协程使用哪些线程来执行我们传递给它们的工作。

为了做到这一点,我们需要配置协程的CoroutineContext对象以设置特定的分发器。实际上,当我们介绍协程作用域时,我们已经解释了CoroutineContext是由一个作业和一个分发器定义的。

当创建自定义作用域时,我们可以在实例化作用域时指定默认的分发器,就像我们之前做的那样:

val myScope = CoroutineScope(context = job + Dispatchers.IO)

在这种情况下,myScope的默认分发器是Dispatchers.IO。这意味着无论我们传递给使用myScope启动的协程的挂起工作是什么,工作都会被移动到专门用于 I/O 后台工作的线程池。

对于预定义的协程作用域,例如使用 lifecycleScopeviewModelScoperememberCoroutineScope,我们可以在启动协程时指定所需的默认调度器:

scope.launch(Dispatchers.IO) {
    viewModel.saveUser()
}

我们使用 launchasync 等协程构建器启动协程,这些内容将在下一节中介绍。在此之前,我们需要了解在启动协程时,我们还可以通过指定 CoroutineDispatcher 对象来修改协程的 CoroutineContext 对象。

现在我们已经在所有示例中使用了 Dispatchers.IO 作为调度器。但还有其他对我们有用的调度器吗?

Dispatchers.IO 是 Coroutines API 提供的调度器,但除了这个之外,协程还提供了其他调度器。以下列出最显著的调度器:

  • Dispatchers.Main:在 Android 上将工作分发到主线程。对于轻量级工作(不会阻塞 UI)或实际的 UI 函数调用和交互来说,这是理想的。

  • Dispatchers.IO:将阻塞工作分发到专门处理磁盘密集型或网络密集型操作的后台线程池。对于在本地数据库上挂起工作或执行网络请求,应指定此调度器。

  • Dispatchers.Default:将阻塞工作分发到专门处理 CPU 密集型任务(如排序长列表、解析 JSON 等)的后台线程池。

在之前的示例中,我们为启动的协程的 CoroutineContext 对象设置了特定的 Dispatchers.IO 调度器,确保挂起的任务将由这个特定的调度器分发。

但我们犯了一个关键的错误!让我们再次查看代码:

class UserFragment : Fragment() {
    ...
    fun saveDetails(user: User) {
        lifecycleScope.launch(Dispatchers.IO) {
            startAnimation()
            database.storeUser(user)
            stopAnimation()
        }
    }
}

这段代码的主要问题是 startAnimation()stopAnimation() 函数可能甚至不是挂起函数,因为它们与 UI 进行交互。

我们希望将 database.storeUser() 的阻塞工作在后台线程上运行,因此我们将 Dispatchers.IO 调度器指定给 CoroutineContext 对象。但这意味着协程块中的所有其他代码(即 startAnimation()stopAnimation() 函数调用)将被分发到旨在处理后台工作的线程池,而不是分发到主线程。

为了更精细地控制函数被分发到哪些线程,协程允许我们通过使用 withContext 块来控制调度器,该块创建了一个可以在不同调度器上运行的代码块。

由于 startAnimation()stopAnimation() 必须在主线程上工作,让我们重构我们的示例。

让我们使用 Dispatchers.Main 的默认调度器启动我们的协程,然后使用 withContext 块包装必须运行在后台线程(即 database.storeUser(user) 挂起函数)上的工作:

fun saveDetails(user: User) {
    lifecycleScope.launch(Dispatchers.Main) {
        startAnimation()
        withContext(Dispatchers.IO) {
            database.storeUser(user)
        }
        stopAnimation()
    }
}

withContext 函数允许我们为其暴露的块定义一个更细粒度的 CoroutineContext 对象。在我们的例子中,我们必须传递 Dispatchers.IO 分派器,以确保我们的数据库阻塞工作在后台线程上运行,而不是被分派到主线程。

换句话说,我们的协程将把所有工作分派给 Dispatchers.Main 分派器,除非你定义另一个更细粒度的上下文,该上下文有自己的 CoroutineDispatcher 设置。

现在我们已经介绍了如何使用分派器以及如何确保对工作分派到不同线程有更细粒度的控制。然而,我们还没有介绍 launch { } 块的含义。让我们接下来看看这个。

协程构建器

(launch) 是 CoroutineScope 的扩展函数,允许我们创建和启动协程。本质上,它们是正常同步世界(具有常规函数)和挂起世界(具有挂起函数)之间的桥梁。

由于我们无法在常规函数内部调用挂起函数,因此在 CoroutineScope 对象上执行协程构建方法会创建一个作用域协程,它为我们提供了一个代码块,我们可以在这里调用我们的挂起函数。没有作用域,我们无法创建协程 - 这很好,因为这种做法有助于防止内存泄漏。

我们可以使用三个构建函数来创建协程:

  • launch: 这将启动一个与代码其余部分并发运行的协程。使用 launch 启动的协程不会将结果返回给调用者 - 相反,所有挂起函数都会在 launch 暴露的块内部顺序执行。我们的任务是获取挂起函数的结果,然后与该结果进行交互:

    fun getUser() {
        lifecycleScope.launch(Dispatchers.IO) {
            val user = database.getUser()
            // show details to UI
        }
    }
    

大多数时候,如果你不需要并发工作,launch 是启动协程的首选选项,因为它允许你在提供的代码块内部运行你的挂起工作,并且不关心其他任何事情。

如果在协程构建器中没有指定分派器,将要使用的分派器是用于启动协程的 CoroutineScope 默认提供的分派器。在我们的例子中,如果我们没有指定分派器,使用 launch 协程构建器启动的协程将使用由 lifecycleScope 默认定义的 Dispatchers.Main 分派器。

除了 lifecycleScopeviewModelScope 也提供了相同的预定义分派器 Dispatchers.Main。另一方面,如果未向协程构建器提供分派器,则 GlobalScope 默认为 Dispatchers.Default

  • async: 这将启动一个新的协程,并允许你将结果作为 Deferred<T> 对象返回,其中 T 是你期望的数据类型。延迟对象是对你的结果 T 将在未来返回的承诺。要启动协程并获取结果,你需要调用挂起函数 await,这将阻塞调用线程:

    lifecycleScope.launch(Dispatchers.IO) {
        val deferredAudio: Deferred<Audio> =
            async { convertTextToSpeech(title) }
        val titleAudio = deferredAudio.await()
        playSound(titleAudio)
    }
    

我们不能在普通函数中使用 async,因为它必须调用 await 挂起函数来获取结果。为了解决这个问题,我们首先使用 launch 创建了一个父协程,并在其中启动了子协程。这意味着使用 async 启动的子协程从使用 launch 启动的父协程继承了其 CoroutineContext 对象。

使用 async,我们可以在一个地方获取并发工作的结果。async 协程构建器在具有并行执行且需要结果的任务中表现出色(并且推荐使用)。

假设我们需要同时将两段文本转换为语音,然后同时播放这两个结果:

lifecycleScope.launch(Dispatchers.IO) {
    val deferredTitleAudio: Deferred<Audio> =
        async { convertTextToSpeech(title) }
    val deferredSubtitleAudio: Deferred<Audio> =
        async { convertTextToSpeech(subtitle) }
    playSounds(
        deferredTitleAudio.await(),
        deferredSubtitleAudio.await()
    )
}

在这个特定的例子中,deferredTitleAudiodeferredSubtitleAudio 这两个结果任务将并行运行。

由于我们的餐厅应用之前没有包含并发工作,我们不会深入探讨并发主题。

  • runBlocking:这将在被调用的当前线程上启动一个协程,直到协程完成才会阻塞该线程。由于创建线程和阻塞它们效率较低,因此应避免在我们的应用中用于异步工作。然而,这个协程构建器可以用于单元测试。

现在我们已经涵盖了协程的基础知识,是时候在我们的餐厅应用中实现协程了!

使用协程进行异步工作

我们必须做的第一件事是确定我们在餐厅应用中已经完成的异步/重型工作。

不看代码,我们知道我们的应用通过使用 Retrofit 初始化网络请求并等待响应来从服务器检索餐厅列表。这个动作符合异步任务的标准,因为我们不希望应用在等待网络响应到达时阻塞主(UI)线程。

如果我们查看 RestaurantsViewModel 类,我们可以确定 getRestaurants() 方法是我们应用中发生重型阻塞工作的唯一地方:

private fun getRestaurants() {
    restaurantsCall = restInterface.getRestaurants()
    restaurantsCall.enqueue(object : Callback
        <List<Restaurant>> {
            override fun onResponse(...) {
                response.body()?.let { restaurants -> ... }
            }
            override fun onFailure(...) {
                t.printStackTrace()
            }
        })
}

当我们实现网络请求时,我们使用了 Retrofit 的 enqueue() 方法,并将一个 Callback 对象传递给它,这样我们就可以等待结果而不阻塞主线程。

为了简化我们从服务器获取餐厅信息这一异步操作的处理方式,我们将实现协程。这将使我们能够放弃回调,使我们的代码更加简洁。

在本节中,我们将介绍两个主要步骤:

  • 用协程代替回调

  • 通过协程改进我们的应用工作方式

让我们开始吧!

用协程代替回调

要使用协程处理异步工作,我们需要执行以下操作:

  • 在一个挂起函数中定义我们的异步工作。

  • 接下来,创建一个协程,并在其中调用挂起函数以异步获取结果。

理论已经足够了,现在是时候编写代码了!执行以下步骤:

  1. RestaurantsApiService 接口内部,将 suspend 关键字添加到 getRestaurants() 方法中,并将方法的 Call<List<Restaurant>> 返回类型替换为 List<Restaurant>

    interface RestaurantsApiService {
        @GET("restaurants.json")
        suspend fun getRestaurants(): List<Restaurant>
    }
    

Retrofit 默认支持协程用于网络请求。这意味着我们可以在 Retrofit 接口中的任何方法上使用 suspend 关键字;因此,我们可以将网络请求转换为不会阻塞应用程序主线程的挂起工作。

由于这个原因,Call<T> 返回类型是多余的。我们不再需要 Retrofit 返回一个 Call 对象,我们通常会在上面排队一个 Callback 对象来监听响应 - 所有这些都将由协程 API 处理。

  1. 由于我们不再从 Retrofit 接收 Call 对象,因此我们也不再需要在 RestaurantsViewModel 类中使用 Callback 对象。清理 RestaurantsViewModel 组件:

    • 移除 restaurantsCall: Call<List<Restaurant>> 成员变量。

    • 移除 onCleared() 回调中的 restaurantsCall.cancel() 方法调用。

    • 移除 getRestaurants() 方法的整个主体。

  2. getRestaurants() 方法内部,调用 restInterface.getRestaurants() 挂起函数并将结果存储在 restaurants 变量中:

    private fun getRestaurants() {
        val restaurants = restInterface.getRestaurants()
    }
    

IDE 将抛出一个错误,告诉我们不能在 ViewModel 组件的常规 getRestaurants() 函数中调用 restInterface.getRestaurants() 挂起函数。

为了解决这个问题,我们必须创建一个协程,启动它,并在那里调用挂起函数。

  1. 在创建协程之前,我们需要创建一个 CoroutineScope 对象。在 ViewModel 组件内部,定义一个类型为 Job 的成员变量和另一个类型为 CoroutineScope 的成员变量,就像我们之前学过的那样:

    class RestaurantsViewModel(…): ViewModel() {
        private var restInterface: RestaurantsApiService
        val state = mutableStateOf(…)
        val job = Job()
        private val scope = CoroutineScope(job + 
            Dispatchers.IO)
        …
    }
    

job 变量是允许我们取消协程范围的句柄,而 scope 变量将确保我们跟踪将要使用它的协程。

由于网络请求是一个重量级的阻塞操作,我们希望其挂起工作在 IO 线程池上执行,以避免阻塞主线程,因此我们为我们的 scope 对象指定了 Dispatchers.IO 分发器。

  1. onCleared() 回调方法内部,调用新创建的 job 变量中的 cancel() 方法:

    override fun onCleared() {
        super.onCleared()
        job.cancel()
    }
    

通过在 job 变量上调用 cancel(),我们确保如果 RestaurantsViewModel 组件被销毁(例如,在用户导航到不同屏幕的场景中),协程 scope 对象将通过其 job 对象引用被取消。实际上,这将取消任何挂起工作,并防止协程导致内存泄漏。

  1. 在我们的 ViewModel 组件中的 getRestaurants() 方法内部,通过在先前定义的 scope 对象上调用 launch 来创建一个协程,并在协程暴露的体内添加我们获取餐厅的现有代码:

    private fun getRestaurants() {
        scope.launch {
            val restaurants = restInterface.getRestaurants()
        }
    }
    

成功!我们已经启动了一个协程,执行了从服务器获取餐厅的挂起工作。

  1. 接下来,添加初始代码来更新我们的 State 对象,以便 Compose UI 显示新收到的餐厅:

    scope.launch {
        val restaurants = restInterface.getRestaurants()
        state.value = restaurants.restoreSelections()
    }
    

然而,这种方法是有缺陷的。你能指出为什么吗?

嗯,我们正在错误的线程上更新 UI。我们的 scope 被定义为在 Dispatchers.IO 线程池中的一个线程上运行协程,但更新 UI 应该在主线程上发生。

  1. getRestaurants() 方法中,将更新 Compose State 对象的代码行用 withContext 块包装,该块指定了 Dispatchers.Main 分发器:

    scope.launch {
        val restaurants = restInterface.getRestaurants()
        withContext(Dispatchers.Main) {
            state.value = restaurants.restoreSelections()
        }
    }
    

通过这样做,我们确保在后台线程上执行繁重的工作的同时,UI 从主线程更新。

我们现在已经在我们的应用中成功实现了协程。我们定义了一个作用域并创建了一个协程,在那里我们执行了我们的挂起工作:一个网络请求。

  1. 你现在可以 运行 应用程序,并注意在外部,应用程序的行为并没有改变。然而,在幕后,我们的异步工作是在比以前更优雅的方式下通过协程完成的。

即使如此,还有一些事情可以改进。让我们接下来解决这些问题。

改进我们应用与协程协同工作的方式

我们的应用使用协程将繁重的工作从主线程移动到专门的线程。

然而,如果我们考虑我们的特定实现,我们可以找到一些改进与协程相关的代码的方法:

  • 使用预定义的作用域而不是自定义的作用域。

  • 添加错误处理。

  • 确保每个 suspend 函数都可以安全地调用在任何 Dispatcher 对象上。

让我们从有趣的部分开始:用预定义的作用域替换我们的自定义作用域!

使用预定义的作用域而不是自定义的作用域

在我们的当前实现中,我们定义了一个自定义的 CoroutineScope 对象,这将确保其协程的寿命与 RestaurantsViewModel 实例一样长。为了实现这一点,我们将一个 Job 对象传递给我们的 CoroutineScope 构建器,并在 ViewModel 组件被销毁时取消它:在 onCleared() 回调方法中。

现在,记住协程与 Jetpack 库很好地集成,当我们定义作用域时,我们也讨论了预定义的作用域,例如 lifecycleScopeviewModelScope 以及更多。这些作用域确保它们的协程寿命与它们所绑定组件的寿命一样长,例如,lifecycleScope 是绑定到 FragmentActivity 组件的。

注意

无论何时你在 ActivityFragmentViewModel 或甚至可组合函数等组件中启动协程,请记住,你不需要创建和管理自己的 CoroutineScope 对象,你可以使用预定义的,它们会自动取消协程。通过使用预定义的作用域,你可以更好地避免内存泄漏,因为任何挂起的工作在需要时都会被取消。

在我们的场景中,我们可以简化我们的代码,并用 viewModelScope 替换我们的自定义 CoroutineScope 对象。幕后,这个预定义的作用域将负责在其父 ViewModel 实例被清除或销毁时取消所有与之一起启动的协程。

让我们来做这件事:

  1. RestaurantsViewModel 类的 getRestaurants() 方法中,将 scope 替换为 viewModelScope

    private fun getRestaurants() {
        viewModelScope.launch {
            val restaurants = …
            …
        }
    }
    
  2. 由于我们不再使用我们的 scope 对象,我们需要确保我们的协程将在后台运行挂起工作,就像它使用之前的范围一样。将 Dispatchers.IO 分发器传递给 launch 方法:

    viewModelScope.launch(Dispatchers.IO) {
        val restaurants = restInterface.getRestaurants()
        withContext(Dispatchers.Main) {
            state.value = restaurants.restoreSelections()
        }
    }
    

通常,launch 协程构建器从其父协程继承 CoroutineContext。然而,在我们的特定情况下,如果没有指定分发器,使用 viewModelScope 启动的协程将默认使用 Dispatchers.Main

然而,我们希望我们的网络请求在专门的 I/O 线程池的背景线程上执行,因此我们在 launch 调用中传递了一个带有 Dispatchers.IO 分发器的初始 CoroutineContext 对象。

  1. 完全从 ViewModel 类中移除 onCleared() 回调方法。我们将不再需要从 job 对象中取消我们的协程 scope,因为 viewModelScope 会为我们处理这件事。

  2. RestaurantsViewModel 类中移除 jobscope 成员变量。

  3. 现在,你可以 运行 应用程序,并再次注意到在外部,应用程序的行为并没有改变。我们的代码现在工作方式相同,但大大简化了,因为我们使用了预定义的作用域,而不是自己处理所有事情。

接下来,我们必须重新在我们的项目中添加错误处理。然而,这次,我们将在协程的上下文中进行。

添加错误处理

在之前的带有回调的实现中,我们从 Retrofit 接收了一个错误回调。然而,使用协程时,似乎由于我们的挂起函数返回 List<Restaurant>,所以没有空间来处理错误。

事实上,我们并没有处理可能抛出的任何错误。例如,如果你现在尝试在没有互联网的情况下启动应用程序,Retrofit 将会抛出一个 Throwable 对象,这反过来会导致我们的应用程序崩溃,并出现如下类似的错误:

E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-1

为了处理错误,我们可以在挂起函数调用中简单地使用 try catch 块:

viewModelScope.launch(Dispatchers.IO) {
    try {
        val restaurants = restInterface.getRestaurants()
        // show restaurants
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

前面的方法是可以的,但由于又增加了一层嵌套,代码变得不那么简洁。此外,为了更好地支持单点错误处理,协程允许你将 CoroutineExceptionHandler 对象传递到你的 CoroutineScope 对象的上下文中:

![Figure 4.5 – The signature of CoroutineExceptionHandler]

![img/B17788_04_5.jpg]

图 4.5 – CoroutineExceptionHandler 的签名

CoroutineExceptionHandler对象允许我们处理在CoroutineScope对象内部启动的任何协程抛出的错误,无论它可能有多层嵌套。此处理程序为我们提供了访问函数,该函数公开了CoroutineContext对象和在此特定上下文中抛出的Throwable对象。

让我们在RestaurantsViewModel类中添加这样的处理程序。执行以下步骤:

  1. 定义一个类型为CoroutineExceptionHandlererrorHandler成员变量,并打印exception: Throwable参数的堆栈跟踪:

    class RestaurantsViewModel() : ViewModel() {
        ...
    private val errorHandler = 
            CoroutineExceptionHandler { _, exception ->
                exception.printStackTrace()
        }
        ...
    }
    

我们对类型为CoroutineContext的第一个参数不感兴趣,所以我们用下划线命名它,_

  1. getRestaurants()方法内部,使用+运算符将errorHandler变量传递给launch块:

    private fun getRestaurants() { 
           viewModelScope.launch(Dispatchers.IO +
                                 errorHandler) { 
                … 
           } 
    }
    

通过将我们的errorHandler变量传递给launch方法,我们确保这个协程的CoroutineContext对象设置了此CoroutineExceptionHandler,这将允许我们在处理程序内部处理错误。

  1. 尝试在没有互联网的情况下再次运行应用。

现在应用不应该崩溃,因为errorHandler变量会捕获 Retrofit 抛出的Throwable对象,并允许我们打印其堆栈跟踪。

注意

作为改进,尝试找到一种方法来通知 UI 已发生错误,从而告知用户刚刚发生了什么。

我们现在使用协程处理错误,因此是时候转向最后一个改进点——正确处理调度器的切换。

确保每个挂起函数可以在任何调度器上安全调用

定义挂起函数时,一个好的做法是确保每个挂起函数可以在任何Dispatcher对象上调用。这样,调用者(在我们的情况下,是协程)就不必担心需要什么线程来执行挂起函数。

让我们用协程分析我们的代码:

private fun getRestaurants() {
    viewModelScope.launch(Dispatchers.IO + errorHandler) {
        val restaurants = restInterface.getRestaurants()
        withContext(Dispatchers.Main) {
            state.value = restaurants.restoreSelections()
        }
    }
}

restInterface: RestaurantsApiService接口的getRestaurants()方法是一个挂起函数。这个函数应该始终在Dispatchers.IO上运行,因为它执行一个重 I/O 操作,即网络请求。

然而,这意味着每次我们必须调用restInterface.getRestaurants()时,我们要么必须从具有Dispatchers.IO范围的协程中调用这个挂起函数——就像我们之前做的那样——或者总是将调用者协程中的withContext(Dispatchers.IO)块包装起来。

这两种替代方案都不太容易扩展。想象一下,你必须在RestaurantsViewModel类中调用restInterface.getRestaurants() 10 次。你总是需要在调用此函数时小心设置调度器。

让我们通过创建一个单独的方法来解决这个问题,我们可以在其中指定挂起函数的正确调度器:

  1. RestaurantsViewModel类内部,创建一个单独的挂起方法,称为getRemoteRestaurants(),并在其中用withContext()块包裹restinterface.getRestaurants()调用:

    private suspend fun getRemoteRestaurants(): List<Restaurant> {
        return withContext(Dispatchers.IO) {
            restInterface.getRestaurants()
        }
    }
    

我们向withContext方法传递了为此挂起函数对应的调度器:Dispatchers.IO

这意味着每当调用此挂起函数(从一个协程或另一个挂起函数)时,调度器将切换到Dispatchers.IO以执行restinterface.getRestaurants()调用。

通过这样做,我们确保调用getRemoteRestaurants()的人不必关心此方法内容的正确线程调度器。

  1. ViewModel组件的getRestaurants()方法中,将restInterface.getRestaurants()方法调用替换为getRemoteRestaurants()

    private fun getRestaurants() {
        viewModelScope.launch(Dispatchers.IO + errorHandler) 
        {
            val restaurants = getRemoteRestaurants()
            withContext(Dispatchers.Main) {
                state.value = restaurants.restoreSelections()
            }
        }
    }
    
  2. 由于getRemoteRestaurants()方法的内容将在其适当的调度器上调用,我们不再需要将Dispatchers.IO传递给启动块。从协程的launch块中移除Dispatchers.IO调度器:

    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            val restaurants = getRemoteRestaurants()
            withContext(Dispatchers.Main) {
                state.value = restaurants.
                    restoreSelections()
            }
        }
    }
    

默认情况下,启动块将从其父协程继承CoroutineContext(以及其定义的Dispatcher对象)。在我们的例子中,没有父协程,因此launch块将在viewModelScope自定义作用域预定义的Dispatchers.Main线程上启动协程。

  1. 由于协程现在将在Dispatchers.Main线程上运行,我们可以从getRestaurants()方法中移除多余的withContext(Dispatchers.Main)块。getRestaurants()方法现在应该看起来像这样:

    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            val restaurants = getRemoteRestaurants()
            state.value = restaurants.restoreSelections()
        }
    }
    

现在,我们启动协程的getRestaurants()方法更容易阅读和理解。例如,我们的挂起函数调用getRemoteRestaurants()是在Dispatchers.Main调度器上的这个协程内部进行的。然而,与此同时,我们的挂起函数有自己的withContext()块,并设置了相应的Dispatcher对象:

private suspend fun getRemoteRestaurants()
        : List<Restaurant> {
    return withContext(Dispatchers.IO) {
        restInterface.getRestaurants()
    }
}

这种做法允许我们从具有任何给定Dispatcher对象的协程中调用挂起函数,因为挂起函数有自己的CoroutineContext对象,并设置了适当的Dispatcher对象。

在运行时,尽管协程是在它们的初始Dispatcher对象上启动的,但当我们调用挂起函数时,Dispatcher对象会暂时被withContext块内部包装的每个挂起函数覆盖。

注意

对于像restInterface.getRestaurants()这样的 Retrofit 接口调用,我们可以跳过将它们包裹在withContext()块中,因为 Retrofit 已经在幕后做了这件事,并为它接口内的所有挂起方法设置了Dispatchers.IO调度器。

最后,应用程序应该表现相同。然而,在良好的实践方面,我们确保每个挂起函数都默认设置了正确的 Dispatcher 对象,而无需我们在每个协程中手动设置它。

现在我们改进了在挂起函数和协程中设置调度器的方式,是时候总结本章内容了。

摘要

在本章中,我们学习了协程如何让我们以更清晰、更简洁的方式编写异步代码。

我们了解了协程是什么,它们是如何工作的,以及为什么一开始就需要它们。我们揭示了协程的核心元素:从 suspend 函数到 CoroutineScope 对象,再到 CoroutineContext 对象和 Dispatcher 对象。

然后,我们在我们的 Restaurants 应用程序中将回调替换为协程,并注意到代码变得更加易于理解,嵌套程度更低。此外,我们还学习了如何使用协程进行错误处理,并在与协程一起工作时整合了一些最佳实践。

在下一章中,我们将向我们的 Restaurants 应用程序添加另一个基于 Compose 的屏幕,并学习如何在 Compose 中使用另一个 Jetpack 库在屏幕之间进行导航。

进一步阅读

虽然借助相关的 Job 对象取消协程可能看起来很简单,但重要的是要注意任何取消都必须是协作的。更具体地说,当协程根据条件语句执行挂起操作时,你必须确保协程在取消方面是协作的。

你可以在官方文档中了解更多关于这个主题的信息:kotlinlang.org/docs/cancellation-and-timeouts.html#cancellation-is-cooperative

第五章:第五章: 使用 Jetpack 导航在 Compose 中添加导航

在本章中,我们将重点关注一个核心的 Jetpack 库,即导航组件。这个库对我们来说至关重要,因为它允许我们轻松地在应用程序屏幕之间导航。

到目前为止,我们只在我们的餐厅应用程序中创建了一个屏幕,其中显示了食客的列表。现在是时候提升游戏水平,并为我们的应用程序添加另一个屏幕!

在第一部分,介绍 Jetpack 导航组件,我们将探讨导航组件的基本概念和元素。在第二部分,创建基于 Compose 的新屏幕,我们将创建一个新的屏幕来显示特定餐厅的详细信息,并意识到我们不知道如何导航到它。

在第三部分,使用 Jetpack 导航实现导航,我们将向餐厅应用程序添加导航组件,并使用它导航到第二个屏幕。最后,在添加对深链接的支持部分,我们将创建一个指向我们新创建的屏幕的深链接,并确保我们的应用程序知道如何处理它。

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

  • 介绍 Jetpack 导航组件

  • 创建一个基于 Compose 的新屏幕

  • 使用 Jetpack 导航实现导航

  • 添加对深链接的支持

在开始之前,让我们为本章设置技术要求。

技术要求

使用 Jetpack 导航构建基于 Compose 的 Android 项目通常需要您的日常工具。然而,为了顺利跟进,请确保您有以下内容:

  • Arctic Fox 2020.3.1 版本的 Android Studio。您也可以使用更新的 Android Studio 版本,甚至 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 在 Android Studio 中安装了 Kotlin 1.6.10 或更高版本的插件

  • 上一章的餐厅应用程序代码

本章的起点是上一章开发的餐厅应用程序,第四章使用协程处理异步操作。如果您没有跟随上一章的实现,请通过导航到存储库的Chapter_04目录并导入名为chapter_4_restaurants_app的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到Chapter_05目录:github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_05/chapter_5_restaurants_app

介绍 Jetpack 导航组件

Navigation组件是 Jetpack 针对 Android 应用程序内导航的解决方案。这个库允许您轻松实现应用程序屏幕之间的导航。

为了促进可预测的用户体验和一致处理应用程序流程的方式,导航组件遵循一系列原则。其中最重要的两个原则如下:

  • 该应用程序有一个固定的起始目的地(屏幕)——这允许应用程序的行为可预测,因为无论从哪里启动,应用程序都会首先展示这个目的地。

在我们的餐厅应用程序中,我们计划将起始目的地设置为现有的包含餐厅列表的屏幕(由RestaurantsScreen()可组合函数表示)。换句话说,这是用户从 Android 启动器屏幕启动应用程序时始终会看到的第一个屏幕。

  • 导航状态被定义为目的地栈,通常称为后台栈。当应用程序最初启动时,栈将包含应用程序的起始目的地——让我们称它为屏幕 A。如果您从屏幕 A导航到屏幕 BB将被添加到栈顶。这同样适用于导航到屏幕 C。为了更好地理解后台栈的工作原理,让我们尝试在以下场景中对其进行说明:

图 5.1 – 用户在应用程序内导航时屏幕的后台栈演变

图 5.1 – 用户在应用程序内导航时屏幕的后台栈演变

在后台栈的顶部,您将始终有用户现在所在的当前屏幕。当导航回上一个屏幕时,后台栈的顶部将被弹出,就像我们在图 5.1中所做的那样,从屏幕 C导航到屏幕 B导致从后台栈中弹出屏幕 C

所有这些操作始终在栈顶进行,而栈底将始终包含固定的起始目的地——在我们的案例中,屏幕 A

导航组件负责在幕后为我们处理后台栈操作。

注意

最初,导航组件主要专注于在Fragment组件之间提供导航。如今,该库也支持 Compose 以及可组合函数之间的导航。

除了在 UI 导航方面遵循清晰的原则外,导航组件有三个主要组成部分:

  • 导航图:与您应用程序内导航相关的核心信息源。在导航图中,您定义所有目的地以及用户在整个应用程序中可以采取的可能路径以完成不同的任务。

  • NavHost:一个容器可组合函数,将显示可组合目的地。随着用户在各个目的地之间导航,导航宿主的内容将被交换和重新组合。

  • 当用户开始在屏幕之间导航时,NavHost作为导航开始。

现在,当您在基于 Compose 的 Android 应用中实现导航组件时,您将获得许多好处。以下列出了一些示例:

  • 您无需处理在可组合函数之间导航的复杂性。库会为您自动处理。

  • 您无需自己处理 向上返回 操作。如果您按下系统的 返回 按钮,库将自动从返回栈中弹出当前目的地,并将用户发送到上一个目的地。

  • 您可以从特定导航图或目的地受益于作用域内的 ViewModel 组件。这意味着用于可组合目的地的 ViewModel 实例将和可组合屏幕一样长时间存在。

  • 您无需从头开始实现深链接。深链接允许您直接导航到应用程序中的特定目的地,而无需遍历到达那里的整个屏幕路径。我们将在本章的 添加对深链接的支持 部分中看到它们是如何工作的。

现在我们对使用 Jetpack Navigation 的元素和优势有了基本的了解,是时候创建一个新的屏幕,以便我们在餐厅应用中实现导航了。

创建新的基于 Compose 的屏幕

现实世界的应用程序需要显示大量内容,所以一个屏幕可能不够。到目前为止,我们的餐厅应用程序有一个简单的屏幕,显示我们从远程数据库接收到的所有餐厅。

让我们通过创建一个新的屏幕来练习我们迄今为止学到的所有技能,这个屏幕将显示特定餐厅的详细信息。计划是当用户在我们的 RestaurantsScreen() 可组合屏幕中的列表中点击某个特定餐厅时,我们应该带他们到一个新的详情屏幕,显示该特定餐厅的详细信息。

要在两个屏幕之间进行导航,我们首先需要构建第二个屏幕。与第一个可组合屏幕不同,现在是时候改变我们的策略,从上到下构建它。让我们首先通过定义网络请求,然后在它自己的 ViewModel 中执行它,最后创建一个将消费数据的可组合 UI,如下所示:

  • 定义获取餐厅内容的 HTTP 请求

  • 获取特定餐厅的内容

  • 构建餐厅详情屏幕

让我们开始吧!

定义获取餐厅内容的 HTTP 请求

我们需要知道如何获取我们新的餐厅详情屏幕的数据。我们不想依赖于之前检索到的数据(餐厅列表),我们希望使我们的应用程序中的每个屏幕尽可能独立。这样,我们设计应用程序以轻松支持深链接,并更好地保护自己免受系统启动的进程死亡等事件的影响。

正因如此,我们将构建这个新的屏幕,使其有自己的内容。换句话说,在新屏幕中,我们将从我们获取餐厅列表的同一数据库中获取特定餐厅的详细信息。但我们如何做到这一点呢?

记住,我们 Firebase 数据库中的餐厅有一个唯一的Integer标识字段,称为r_id,如下截图所示:

图 5.2 – 识别 Firebase 中餐厅的唯一标识字段

我们可以使用此字段来获取一个特定餐厅的详细信息。由于r_id映射到Restaurant对象的id: Int字段,这意味着当用户在我们的RestaurantsScreen可组合组件上点击餐厅时,我们可以将id值传递到第二个屏幕。

在第二屏中,我们将向我们的 Firebase REST API 执行一个 API 请求,并传递我们应用中对应远程数据库中餐厅r_id标识符的唯一 ID 的餐厅 ID 值。

Firebase REST API 为我们处理此类情况。如果我们想获取餐厅 JSON 内容中一个元素的详细信息,我们必须将两个查询参数附加到用于检索整个餐厅列表的相同 URL:

  • 使用orderBy=r_id来指示 Firebase 根据其r_id字段过滤元素。

  • 使用equalTo=2让 Firebase 知道我们正在寻找的餐厅元素的r_id字段值 – 在这种情况下是2

为了练习,在您的浏览器地址栏中放置您到目前为止用于获取餐厅的 Firebase URL,并附加以下两个查询参数:

https://restaurants-db-default-rtdb.firebaseio.com/restaurants.json?orderBy="r_id"&equalTo=2

如果您访问您的链接,不幸的是,响应将如下所示:

{ "error" : "Index not defined, add \".indexOn\": \"r_id\", for path \"/restaurants\", to the rules" }

为了让我们能够获取列表中单个元素的详细信息,Firebase 需要一些额外的配置,所以现在让我们来做这件事:

  1. 导航到您的 Firebase 控制台,通过访问此链接登录您的 Google 账户:console.firebase.google.com/

  2. 从 Firebase 项目列表中选择您之前创建的用于存储餐厅的项目。

  3. 在左侧菜单中,展开构建选项卡,搜索实时数据库,然后选择它。

  4. 离开预先选择的数据选项卡,并选择规则选项卡。

  5. 我们需要允许 Firebase 根据其r_id字段索引餐厅,因此更新写入规则如下:

    {
        "rules": {
            ".read": "true",
            ".write": "true",
            "restaurants": {
              ".indexOn": ["r_id"]
             }
        }
    }
    

通过这样做,我们已指示 Firebase,位于restaurants节点处的 JSON 数组内容可以被索引并单独访问。

  1. 现在,再次尝试使用具有id字段值为2的餐厅的详细信息访问 URL:

图 5.3 – 获取 Firebase 对一个餐厅 JSON 对象的响应

图 5.3 – 获取 Firebase 对一个餐厅 JSON 对象的响应

注意:

为了在浏览器中以更可读的方式查看 JSON 响应的结构,你可以在请求 URL 的末尾添加&print=pretty查询参数。

成功!我们已经获取了具有r_id字段值为2的餐厅的详细信息。

现在,让我们在我们的应用程序中实现这个请求:

  1. 首先,在RestaurantsApiService接口内部,定义一个名为getRestaurant()suspend函数,它将作为另一个@GET HTTP 方法,用于获取一个餐厅的详细信息:

    interface RestaurantsApiService {
        […]
        @GET("restaurants.json?orderBy=\"r_id\"")
        suspend fun getRestaurant(
          @Query("equalTo") id: Int): Unit
    }
    

让我们分解一下我们刚刚添加到第二个 HTTP 方法中的代码:

  • 由该方法定义的 HTTP 调用是一个异步任务,需要一些时间才能完成,因此我们通过添加suspend关键字将方法标记为挂起函数。

  • @GET注解内部,我们不仅指定了想要访问restaurants.json JSON 内容,这次我们还硬编码了orderBy查询参数并指定了r_id值,以便通过它们的r_id键的值来过滤元素。

  • 此方法接收一个基本参数 – id: Int,它代表与数据库中r_id字段对应的餐厅的唯一 ID。为了告诉 Retrofit 此方法参数是所需 HTTP 调用中的查询参数,我们用@Query注解了它,并传递了"equalTo"值。

然而,我们由getRestaurant()方法定义的 HTTP 调用缺少一个关键的部分:响应类型。我们已经将Unit设置为响应类型,但我们需要接收一个合适的响应对象。为了了解我们期望的内容,让我们更仔细地查看我们在浏览器中之前收到的响应:

图 5.4 –餐厅对象的 JSON 响应结构

![图 5.4 –餐厅对象的 JSON 响应结构如果我们查看这些字段,is_shutdownr_descriptionr_idr_title,我们可以很容易地识别出响应 JSON 对象与我们在现有 HTTP 请求中接收到的所有餐厅的 JSON 对象相同。由于我们过去已经使用@Serialized注解将这样的 JSON 对象映射到我们的Restaurant数据类中,所以我们可以说我们的新getRestaurant() HTTP 调用将接收一个简单的Restaurant对象作为响应。我们不会离真理太远,但这个响应不会完全正确。如果我们更仔细地查看之前的 JSON 响应,我们会注意到餐厅 JSON 对象是一个值对象,对应于一个值为2String键:![图 5.5 –识别餐厅对象的键字段图片 B17788_05_5.jpg

![图 5.5 –识别餐厅对象的键字段

这个键对应于 Firebase 生成的内部索引,它表示相应餐厅被添加到数据库中的顺序号。这种响应结构对于大多数 REST API 响应来说并不典型,但 Firebase 有一个怪癖,即在编译时未知的关键字中包装你的 JSON 对象。

  1. 为了解决这个问题,在RestaurantsApiService接口内部,更新getRestaurant()方法以返回一个包含未知String键和Restaurant数据类型值的Map对象:

    interface RestaurantsApiService {
        …
        @GET("restaurants.json?orderBy=\"r_id\"")
        suspend fun getRestaurant(@Query("equalTo") id: Int)
          : Map<String, Restaurant>
    }
    

干得好!我们的应用程序已经准备好执行第二个网络请求,以获取特定餐厅的详细信息,所以现在是调用这个请求的时候了。

获取特定餐厅的内容

现在我们知道了如何获取特定餐厅的详细信息,现在是时候执行我们新定义的网络请求了。

我们现有的RestaurantsScreen可组合委托将请求显示必须显示的餐厅列表的责任委托给一个ViewModel类,因此让我们创建另一个ViewModel,以便我们的第二个屏幕也能做到同样的事情:

  1. 通过左键单击应用程序包,将RestaurantDetailsViewModel作为名称,并选择文件作为类型来创建一个新文件。在新建的文件中,添加以下代码:

    class RestaurantDetailsViewModel(): ViewModel() {
        private var restInterface: RestaurantsApiService
        init {
            val retrofit: Retrofit = Retrofit.Builder()
                .addConverterFactory(GsonConverterFactory
                    .create())
                .baseUrl("your-firebase-base-url")
                .build()
            restInterface = retrofit.create(
                RestaurantsApiService::class.java)
        }
    }
    

在前面的代码片段中,我们创建了一个ViewModel类,在其中实例化了一个RestaurantsApiService类型的 Retrofit 客户端,就像我们在RestaurantsViewModel类中所做的那样。

初始化 Retrofit 客户端的代码确实在我们两个ViewModel类中都重复了,但不用担心,因为您将在第九章中修复它,使用 Jetpack Hilt 实现依赖注入

注意

记得将您的 Firebase 数据库 URL 传递给baseUrl()方法。这个 URL 应该与RestaurantsViewModel类中使用的 URL 相同,并且应该对应于您的 Firebase 实时数据库项目。

  1. 在新建的ViewModel中,创建一个getRemoteRestaurant()方法,该方法接收一个id参数,并负责执行网络请求以获取特定餐厅的详细信息:

    class RestaurantDetailsViewModel() : ViewModel() {
        private var restInterface: RestaurantsApiService
         init { […] }
        private suspend fun getRemoteRestaurant(id: Int):
                Restaurant {
            return withContext(Dispatchers.IO) {
                val responseMap = restInterface
                    .getRestaurant(id)
                return@withContext responseMap.values.first()
            }
        }
    }
    

让我们分解getRemoteRestaurant()方法内部发生的事情:

  • 它接收一个id参数,对应于我们需要详细信息的餐厅,并返回特定的Restaurant对象。

  • 由于执行网络请求的工作是会阻塞主线程的挂起工作,因此该方法被标记为suspend关键字。

  • 它被包裹在一个指定了Dispatchers.IO调度器的withContext()块中,因为挂起工作应该在专门的 IO 线程上运行。

  • 它通过在restInterface上调用getRestaurant()挂起函数并传递特定餐厅的id来执行网络请求以获取餐厅的详细信息。

  • 最后,它从 REST API 获取Map<String, Restaurant>。为了解包并获取餐厅,我们调用Mapvalues()函数,并使用.first()扩展函数获取第一个Restaurant对象。

注意:

Mapvalues() 函数返回的 Collection<Restaurant> 对象上调用 first() 扩展函数。使用这个扩展函数,我们正在获取第一个元素,即我们感兴趣的 Restaurant 对象。然而,如果由于某种原因查询了一个不存在的餐厅,first() 扩展函数可能会抛出 NoSuchElementException。在生产环境中,你也应该通过捕获此类异常来处理这种情况。

  1. 由于 RestaurantDetailsViewModel 将持有餐厅详情屏幕的状态,添加一个 MutableState 对象来持有 Restaurant 对象,并用 null 值初始化它,直到我们完成检索它的网络请求:

    class RestaurantDetailsViewModel(): ViewModel() {
        private var restInterface: RestaurantsApiService
        val state = mutableStateOf<Restaurant?>(null)
         […]
    }
    
  2. RestaurantDetailsViewModelinit 块中,在 Retrofit 客户端实例化之后,使用 viewModelScope 构建器启动一个协程:

    init {
        […]
    restInterface = retrofit.create(…)
        viewModelScope.launch {
            val restaurant = getRemoteRestaurant(2)
            state.value = restaurant
        }
    }
    

我们需要启动一个协程,因为从我们的远程 Firebase API 获取 Restaurant 对象的任务可能会阻塞主线程。我们使用了内置的 viewModelScope 协程构建器来确保启动的协程将像 RestaurantDetailsViewModel 实例一样长时间存在。在协程内部,我们做了以下操作:

  1. 我们首先调用了挂起函数 getRemoteRestaurants() 并将硬编码的值 2 作为餐厅的 id 传递。此时,RestaurantsViewModel 还不知道它正在寻找的餐厅的 id 是什么——我们将在执行导航时很快解决这个问题。

  2. 我们将获取到的 Restaurant 存储在 restaurant 变量中,并将其传递给 RestaurantDetailsViewModel 类的 state 变量,以便 UI 将使用新接收到的餐厅内容重新组合。

我们已执行网络请求以获取有关餐厅的详细信息,并准备了状态,以便基于 Compose 的屏幕可以显示其内容。接下来,让我们构建新的屏幕。

构建“餐厅详情”屏幕

我们需要创建一个新的可组合屏幕,用于显示特定餐厅的详细信息:

  1. 在应用程序包内创建一个名为 RestaurantDetailsScreen 的新文件,并创建一个 RestaurantDetailsScreen 可组合组件:

    @Composable
    fun RestaurantDetailsScreen() {
        val viewModel: RestaurantDetailsViewModel =         viewModel()
        val item = viewModel.state.value
        if (item != null) {
            // composables
        }
    }
    

在其中,我们实例化了其对应的 ViewModel 并访问了 State 对象,就像我们在之前的 RestaurantsScreen 可组合组件中所做的那样。State 对象持有 Restaurant 对象,我们将其存储在 item 变量中。如果 item 不是 null,我们将通过传递一个可组合层次结构来显示有关餐厅的详细信息。

  1. 由于我们计划重用第一个屏幕中的一些可组合函数,请返回 RestaurantsScreen.kt 文件,并将 RestaurantIconRestaurantDetails 可组合组件标记为公共,以便通过移除它们的 private 关键字来使用。

  2. RestaurantDetails可组合组件添加一个名为horizontalAlignment的新参数,并将其传递给列的horizontalAlignment参数:

    @Composable
    fun RestaurantDetails(
        … ,
        modifier: Modifier,
        horizontalAlignment: Alignment.Horizontal
                                        = Alignment.Start
    ) {
        Column(
            modifier = modifier,
            horizontalAlignment = horizontalAlignment
        ) { ... }
    }
    

通过这样做,我们可以控制Column子项的水平对齐方式,以便我们可以在新屏幕中更改此行为。由于我们希望Column默认将其子项水平向左对齐(这样在RestaurantsScreen可组合组件中的效果不会不同),因此我们将Alignment.Start作为默认值传递。

  1. RestaurantDetailsScreen可组合组件内,添加一个包含RestaurantIconRestaurantDetailsText可组合组件的Column实例,所有这些组件都垂直排列并水平居中:

    @Composable
    fun RestaurantDetailsScreen() {
        val viewModel: RestaurantDetailsViewModel = 
            viewModel()
        val item = viewModel.state.value
        if (item != null) {
            Column(
    horizontalAlignment = 
                    Alignment.CenterHorizontally,
    modifier = 
                    Modifier.fillMaxSize().padding(16.dp)
            ) {
                RestaurantIcon(
                    Icons.Filled.Place,
                    Modifier.padding(
    top = 32.dp, 
                        bottom = 32.dp
                    )
                )
                RestaurantDetails(
                    item.title,
                    item.description,
                    Modifier.padding(bottom = 32.dp),
                    Alignment.CenterHorizontally)
                Text("More info coming soon!")
            }
        }
    }
    

为了证明重用可组合组件有多简单,我们将第一个屏幕中使用的相同RestaurantIconRestaurantDetails可组合组件传递给了我们的Column。我们使用不同的Modifier对象配置了它们,并且还向之前添加的新对齐参数RestaurantDetails可组合组件传递了Alignment.centerHorizontally

  1. 为了测试一切是否正常工作,并且我们的新屏幕能够渲染具有id值为2的硬编码餐厅的详细信息,导航回MainActivity并在setContent方法内,将RestaurantsScreen可组合组件替换为RestaurantDetailsScreen

    setContent {
    RestaurantsAppTheme {
            //RestaurantsScreen()
            RestaurantDetailsScreen()
        }
    }
    
  2. 运行应用程序,我们得到以下截图:

![图 5.6 – 显示 RestaurantDetailsScreen() 可组合组件

![图片 B17788_05_6.jpg]

图 5.6 – 显示 RestaurantDetailsScreen() 可组合组件

太棒了!我们现在已经创建了第二个屏幕,即餐厅详情屏幕。我们现在可以开始考虑两个屏幕之间的导航。

使用 Jetpack 导航实现导航

应用程序内的导航表示那些允许用户在几个屏幕之间导航来回的交互。

在我们的餐厅应用程序中,我们现在有两个屏幕,我们想要从第一个屏幕导航到第二个屏幕。在第一个屏幕中,我们显示餐厅列表,当用户按下列表中的某个餐厅项时,我们希望将他们带到第二个屏幕,即详情屏幕:

![图 5.7 – 从列表屏幕导航到详情屏幕

![图片 B17788_05_7.jpg]

![图 5.7 – 从列表屏幕导航到详情屏幕

基本上,我们想要执行一个简单的导航操作,从RestaurantsScreen可组合组件导航到RestaurantDetailsScreen可组合组件。为了实现简单的导航操作,我们需要实现一个导航库,这个库不仅允许我们从第一个屏幕切换到第二个屏幕,而且还应该允许我们通过按下返回按钮返回上一个屏幕。

如我们所知,Jetpack 导航组件会帮助我们解决问题,因为它将帮助我们实现这种行为!让我们从以下步骤开始:

  1. 在应用模块中的build.gradle文件内,在依赖项块内添加对包含 Compose 的导航组件的依赖项:

    implementation "androidx.navigation:navigation-compose:2.4.2"
    

在更新 build.gradle 文件后,请确保将项目与其 Gradle 文件同步。您可以通过点击 文件 菜单选项,然后选择 与 Gradle 文件同步项目 来完成此操作。

  1. MainActivity 类中,创建一个新的空组合函数,名为 RestaurantsApp()

    @Composable
    private fun RestaurantsApp() {
    }
    

此组合函数将作为我们餐厅应用程序的父组合函数。在这里,将定义应用程序的所有屏幕。

  1. onCreate() 方法中,将传递给 setContent 方法的 RestaurantsDetailsScreen() 组合可替换为 RestaurantsApp() 组合可:

    setContent {
        RestaurantsAppTheme {
            RestaurantsApp()
        }
    }
    
  2. RestaurantsApp() 组合函数内部,通过 rememberNavController() 方法实例化 NavController

    @Composable
    private fun RestaurantsApp() {
        val navController = rememberNavController()
    }
    

NavController 对象处理组合屏幕之间的导航 – 它在组合目的地的回退栈上操作。这意味着在重新组合之间,它必须保持导航栈的当前状态。为了实现这一点,它必须是一个有状态的对象 – 这就是为什么我们使用了 rememberNavController 语法,这与我们在定义 State 对象时使用的 remember 块类似。

  1. 接下来,我们需要创建一个将显示组合目的地的 NavHost 容器组合可。每次在组合可之间完成导航操作时,NavHost 内的内容会自动重新组合。

添加一个 NavHost 组合可,并将之前创建的 NavController 实例以及一个空的 String 传递给 startDestination 参数:

import androidx.navigation.compose.NavHost
[…]
@Composable
private fun RestaurantsApp() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = "") {
    }
}

在其他参数中,NavHost 指定了三个强制参数:

  • 一个与单个 NavHost 组合可关联的 navController: NavHostController 对象。NavHostNavController 与定义应用程序可能目的地的导航图连接起来。在我们的例子中,我们将 navController 变量传递给了这个参数。

  • 一个 startDestination: String 对象,它定义了入口点 String,它定义了到达特定目的地(组合屏幕)的路径。每个目的地都应该有一个唯一的路由。在我们的例子中,因为我们还没有定义任何路由,所以我们已将一个空的 String 传递给 startDestination

  • builder: NavGraphBuilder.() -> Unit 后置 lambda 参数,它使用了来自导航 Kotlin DSL 的 lambda 语法(就像 LazyColumnLazyRow 使用它们自己的 DSL 一样)来构建导航图。在这里,我们应该定义路由并设置相应的组合可,但到目前为止,我们已将一个空的 { } 函数设置到后置 lambda 参数中。

  1. 要构建导航图,我们必须使用 builder 参数,而不是只传递一个空函数,在它内部,我们需要开始添加指定组合目的地的路由。

要做到这一点,请使用名为 composable() 的 DSL 函数,您可以为 route 参数提供一个路由字符串,并将对应于所需目的地的可组合函数传递给尾随的 lambda content 参数:

@Composable
private fun RestaurantsApp() {
    val navController = rememberNavController()
    NavHost(
        navController,
        startDestination = "restaurants"
 ) {
        composable(route = "restaurants") {
            RestaurantsScreen()
        }
    }
}

通过 composable() DSL 函数,我们创建了一个导航到 RestaurantsScreen() 可组合组件的 "restaurants" 值的路由。

此外,我们将相同的路由传递给 NavHoststartDestination 参数,从而使我们的 RestaurantsScreen() 可组合组件成为我们应用程序的唯一入口点。

  1. 通过在导航图构建器内部再次调用 composable() DSL 函数,添加另一个指向 RestaurantDetailsScreen() 目标的路由,并且通过附加 {restaurant_id} 参数占位符从 "restaurants" 路由派生:

    NavHost(navController, startDestination = "...") {
    composable(route = "restaurants") { … }
    composable(route = "restaurants/{restaurant_id}") { 
            RestaurantDetailsScreen()
        }
    }
    

我们希望从 "restaurants" 路由导航到指向 RestaurantDetailsSreen() 可组合组件的新路由,因此 {restaurant_id} 占位符将取我们试图导航的餐厅的 id 值。

在实践中,此路由从 "restaurants" 路由分支出来,并且虽然其结构类似于 URL(因为 ""/ 元素分隔了新的路径),但我们可以这样说,此路由可以具有多个值,具体取决于我们想要导航到的餐厅的 id。例如,此路由在运行时可以具有 "restaurants/0""restaurants/2" 等值。

  1. 在导航图中,我们已经定义了路由及其对应的目标,但我们还没有真正执行两个屏幕之间的实际导航。为此,我们首先需要一个触发器或回调来通知我们用户在餐厅列表中点击了餐厅项,这样我们就可以导航到餐厅详情屏幕。

RestaurantsScreen.kt 文件内部,修改 RestaurantItem 可组合组件以公开一个提供被点击餐厅 idonItemClick 回调函数,并在整个餐厅的 Card 被按下时调用它:

@Composable
fun RestaurantItem(item: Restaurant,
                   onClick: (id: Int) -> Unit, 
                   onItemClick: (id: Int) -> Unit) {
    val icon = …
    Card(elevation = 4.dp,
         modifier = Modifier
            .padding(8.dp)
            .clickable { onItemClick(item.id) }) { … }
}
  1. 为了避免混淆,通过将旧的 onClick 参数重命名为更具说明性的名称,如 onFavoriteClick 来重构 RestaurantItem 可组合组件:

    @Composable
    fun RestaurantItem(item: Restaurant,
                       onFavoriteClick: (id: Int) -> Unit, 
                       onItemClick: (id: Int) -> Unit) {
        val icon = …
        Card(…) {
            Row(…) {
                …
                RestaurantIcon(icon, Modifier.weight(0.15f)) 
                {
                    onFavoriteClick(item.id)
                }
            }
        }
    }
    
  2. RestaurantsScreen() 可组合组件内部,添加一个类似的 onItemClick 回调函数作为参数,并在 onItemClick 回调来自 RestaurantItem 可组合组件时调用它:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit = { }) {
        val viewModel: RestaurantsViewModel = viewModel()
        LazyColumn(...) {
            items(viewModel.state.value) { restaurant ->
                RestaurantItem(
                    restaurant,
                    onFavoriteClick =
                      { id -> viewModel.toggleFavorite(id) },
                    onItemClick = { id -> onItemClick(id) })
            }
        }
    }
    

此外,我们将 RestaurantItem 可组合调用中的 onClick 参数名称更改为与其签名更匹配的名称,例如 onFavoriteClick

我们本质上是在通过从子可组合组件到父可组合组件的回调来传播事件。

  1. NavHost 内部,更新 RestaurantsScreen() 可组合目标以监听导航回调,然后在回调内部,通过调用 navigate() 方法来触发可组合组件之间的导航,该方法期望 route 作为参数:

    @Composable
    private fun RestaurantsApp() {
        val navController = rememberNavController()
        NavHost(navController, startDestination = "...") {
            composable(route = "restaurants") {
                RestaurantsScreen { id ->
                    navController.navigate("restaurants/$id")
                }
            }
            composable(
                route = "restaurants/{restaurant_id}"
            ) {
                RestaurantDetailsScreen()
            }
        }
    }
    

RestaurantsScreen的新尾随 lambda 函数中,我们现在接收需要导航到的餐厅的id值。为了触发导航,我们调用了navigate()方法,并将其route参数传递为"restaurants/$id"字符串,以匹配我们其他可组合目标RestaurantDetailsScreen()的路线。

  1. 尝试运行应用程序并验证以下内容。

当应用启动时,RestaurantsScreen()可组合组件被组合并显示。换句话说,你处于"restaurants"路由,因为我们已经将此路由设置为导航图的startDestination。在导航回退栈中,此目标将被添加:

![图 5.8 – 包含起始目标的回退栈图片

图 5.8 – 包含起始目标的回退栈

当点击列表中的某个餐厅时,会触发导航,并到达RestaurantDetailsScreen()可组合目标。在导航回退栈顶部,此目标将被添加:

![图 5.9 – 导航到另一个目标后的回退栈图片

图 5.9 – 导航到另一个目标后的回退栈

当点击系统的RestaurantDetailsScreen()目标时,你将被发送回回退栈中的现有目标,即RestaurantsScreen()。这意味着在回退栈中,顶部目标将被弹出,只剩下根目标:

图 5.10 – 返回起始目标后的回退栈

导航是有效的,但如果你注意到了,它总是指向同一个餐厅。这有两个原因:

  • 虽然我们在指向RestaurantDetailsScreen()的路线中定义了{restaurant_id}占位符参数,但我们没有在composable()函数内部将其定义为导航参数,因此导航组件不知道如何将其发送到路线的可组合目标。

  • RestaurantDetailsViewModel中,我们硬编码了餐厅的 id 为值2

我们希望用户能够看到被点击的餐厅的详细信息,所以让我们修复这些问题,并动态地传递餐厅的 ID。

  1. 对于RestaurantDetailsScreen()目标,除了route之外,还需要添加一个期望接收NamedNavArgument对象列表的arguments参数,并使用navArgument函数传递这样的参数:

    NavHost(navController, startDestination = "..."){
        composable(route = "restaurants") { … }
        composable(
            route = "restaurants/{restaurant_id}",
            arguments =
                listOf(navArgument("restaurant_id") {
                    type = NavType.IntType
                })
        ) { RestaurantDetailsScreen() }
    }
    

此参数指定了我们在route中添加的相同的"restaurant_id"键,这允许 Navigation 库将此参数暴露给目标可组合组件。此外,navArgument函数暴露了NavArgumentBuilder,我们在其中指定了参数的类型为IntType

要在RestaurantDetailsScreen()目标内部获取参数的值,composable() DSL 函数暴露了一个NavBackStackEntry对象,允许我们按以下方式获取值:

composable(…) { navStackEntry ->
    val id =
        navStackEntry.arguments?.getInt("restaurant_id")
    RestaurantDetailsScreen() 
}

然而,我们的 RestaurantDetailsScreen() 目标不期望餐厅的 id,但 RestaurantDetailsViewModel 需要,所以我们不会执行之前访问 navStackEntry 的更改;相反,我们将在 ViewModel 中尽快进行类似的操作。

  1. 在幕后,导航组件将存储在 NavStackEntry 中的导航参数保存到 SavedStateHandle 中,这是我们的 VM 暴露的。这意味着我们可以利用这一点,而不是在 RestaurantDetailsScreen() 可组合函数内部获取餐厅的 ID,我们可以在 RestaurantDetailsViewModel 中直接获取它。

首先,将 SavedStateHandle 参数添加到 RestaurantDetailsViewModel 构造函数中,就像我们在 RestaurantsViewModel 中做的那样:

class RestaurantDetailsViewModel(
    private val stateHandle: SavedStateHandle
) : ViewModel() {
    […]
    init { […]  }
    private suspend fun getRemoteRestaurant(id: Int) {
        […]
    }
}
  1. ViewModelinit { } 块中,在实例化 Retrofit 客户端下方,将餐厅的 ID 存储在一个新的 id 变量中,同时从 SavedStateHandle 对象中动态获取它,然后将它传递给 getRemoteRestaurant() 方法调用:

    class RestaurantDetailsViewModel(private val stateHandle: SavedStateHandle): ViewModel() {
        …
        init {
            val retrofit: Retrofit = Retrofit[…].build()
            restInterface = […]
            val id = stateHandle.get<Int>("restaurant_id") 
                ?: 0
            viewModelScope.launch {
                val restaurant = getRemoteRestaurant(id)
                state.value = restaurant
            }
        }
        …
    }
    

我们已指示 navArgument 该参数的类型为 Int,因此我们从 stateHandle 中获取它作为 Int 值,并传递了相同的 "restaurant_id" 键,我们曾用它来定义 navArgument

这种方法还可以保护我们免受系统引起的进程死亡场景。用户可以导航到具有 id 值为 2 的餐厅的 RestaurantDetailsScreen() 目标,然后暂时最小化应用。在此期间,系统可能会决定杀死应用进程以释放内存,因此当用户恢复应用时,系统会将其恢复,并为我们提供一个包含具有 2 值的餐厅 ID 的 SavedStateHandle 对象。

总之,应用将知道获取用户最初导航到的餐厅的详细信息,因此应用对于这种边缘情况的行为是正确的。

  1. 再次运行应用,并验证这次当在 RestaurantsScreen() 起始目标中点击一个餐厅项时,该餐厅的详细信息将在第二个目标 RestaurantDetailsScreen() 中显示。

    注意

    我们使用了具有可组合函数目标的导航组件。在这些可组合函数内部,我们实例化 ViewModel 对象。由于这些可组合函数位于目标的后退栈中,它们的 ViewModel 对象的范围限定在可组合函数的生命周期内。换句话说,随着导航组件的添加,ViewModel 对象的生命周期与它们附加的可组合屏幕相同。

完美!现在我们的餐厅应用有两个屏幕,用户可以在点击列表中的任何餐厅时在这些屏幕之间导航。是时候探索另一种导航事件了。

添加对深度链接的支持

深度链接允许您将用户重定向到应用程序的特定部分,而无需他们通过所有中间屏幕。这种技术对于营销活动特别有用,因为它可以提高用户参与度,同时提供良好的用户体验。

深度链接通常包含在 URI 方案或自定义方案中。这允许您配置从图片广告、文本广告,甚至二维码,当点击或扫描时,会将其重定向到应用程序的特定页面。如果您的应用程序配置为知道如何处理此类方案,用户将能够使用您的应用程序打开该特定链接。

例如,假设对于我们的餐厅应用程序,我们启动一项营销活动,其中在互联网上包含一些展示一些特色餐厅的广告。我们配置广告使其可点击,并重定向到以下链接,其中包含广告餐厅的 ID,例如2https://www.restaurantsapp.details.com/2

当将此 URI 加载到浏览器应用程序中时(因为没有这样的网站),它将无法工作,但我们可以配置我们的应用程序以了解如何将其解释为深度链接。

当用户在搜索引擎中浏览并点击我们餐厅之一的活动广告时,应用程序应知道如何处理这些操作,并允许用户被重定向到我们的应用程序:

![图 5.11 – 不高效的跳转到我们的餐厅应用程序图片

图 5.11 – 不高效的跳转到我们的餐厅应用程序

我们的应用程序以RestaurantsScreen()可组合组件作为起始目的地,因此用户应手动找到最初在广告中展示的餐厅,并点击它以导航到RestaurantDetailsScreen()目的地。

这显然是一种不良做法,因为我们不希望用户在我们的应用程序中进行手动导航以到达广告中的餐厅。想象一下,如果其他应用程序要求用户按照我们的应用程序那样通过一个或两个屏幕,而是更多屏幕进行导航——这将导致糟糕的用户体验,并且活动将无效。

然而,深度链接允许您自动将用户重定向到您希望的目的地:

图片

图 5.12 – 直接深度链接到感兴趣的屏幕

通过直接将用户重定向到感兴趣的屏幕,我们改善了用户体验,并期望我们的广告活动表现更好。

让我们借助导航组件库在我们的餐厅应用程序中实现这样的深度链接:

  1. RestaurantDetailsScreen() DSL composable()函数内部,除了routearguments之外,添加另一个名为deepLinks的参数,它期望一个NavDeepLink对象的列表,并使用navDeepLink函数传递这样的参数:

    NavHost(navController, startDestination = "restaurants")
    {
      composable(route = "restaurants") {…}
      composable(
        route = "restaurants/{restaurant_id}",
        arguments = listOf(
          navArgument("restaurant_id") {…}
        ),
        deepLinks = listOf(navDeepLink {
          uriPattern =
        "www.restaurantsapp.details.com/{restaurant_id}"
          })
         ) { RestaurantDetailsScreen() }
    }
    

navDeepLink 函数依次期望一个 NavDeepLinkDslBuilder 扩展函数,该函数暴露了自己的 DSL。我们已将 uriPattern DSL 变量设置为期望我们的自定义 URI www.restaurantsapp.details.com,同时添加了占位符 "restaurant_id" 参数,这将允许导航组件解析并提供我们从深度链接中获取的餐厅 ID。

目前,我们的应用程序知道如何处理深度链接,但仅限于内部。

  1. 要使我们的深度链接对外可用,在 AndroidManifest.xml 文件中,在 MainActivity<activity> 元素内添加以下 <intent-filter> 元素:

    <application … >
        <activity
            android:name=".MainActivity"
            […] >
            <intent-filter>
                <action android:name="[…].action.MAIN" />
                <category android:name="[…].LAUNCHER" />
            </intent-filter>
            <intent-filter>
              <data
                    android:host="www.restaurantsapp.
                        details.com"
                    android:scheme="https" />
              <action android:name="android.intent.
                  action.VIEW" />
              <category android:name="android.intent.
                  category.DEFAULT" />
              <category android:name="android.intent.
                  category.BROWSABLE" />
            </intent-filter>
        </activity>
    </application>
    

让我们分解一下新 <intent-filter> 元素中我们刚刚添加的内容:

  • 一个指定以下内容的 <data> 元素:

  • host 参数作为我们在导航图中之前设置的深度链接 URI。这是我们广告应该链接到的 URI。

  • 深度链接的 scheme 参数为 https。每个 <data> 元素都应该定义一个方案,以便识别 URI。

  • 一个 BROWSABLE 类别的 <category> 元素,这是必需的,以便从网络浏览器应用中访问意图过滤器。

  • 一个 DEFAULT 类别的 <category> 元素,使应用隐式拦截深度链接的意图。如果没有它,应用只能在深度链接意图指定了应用程序组件名称时启动。

要测试深度链接,我们需要模拟一个深度链接动作。让我们假设我们想测试一个指向 ID 值为 2 的餐厅的深度链接。深度链接看起来像这样:https://www.restaurantsapp.details.com/2

由于我们没有任何指向我们的深度链接的广告,我们有两种选择:

  • 使用此 URL 创建一个二维码,然后用我们的设备扫描它。

  • 从命令行启动一个意图来模拟深度链接。

让我们选择第二种方案。

  1. 构建项目并在模拟器或物理设备上运行应用程序。这一步是必要的,以便安装的应用程序知道如何响应我们的深度链接。

  2. 关闭应用或将其最小化,但请确保您的模拟器或设备连接到 Android Studio。

  3. 打开 Android Studio 中的终端,粘贴以下命令并输入:

    $ adb shell am start -W -a android.intent.action.VIEW -d "https://www.restaurantsapp.details.com/2"
    
  4. 连接到 Android Studio 的模拟器/设备现在应该会提示一个歧义对话框,询问您想用哪个应用打开深度链接:

图 5.13 – 启动深度链接时显示的歧义对话框

](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/kkst-mdn-andr-dev-jtpk-kt/img/B17788_05_13.jpg)

图 5.13 – 启动深度链接时显示的歧义对话框

我们的应用程序是那些应用程序之一,这意味着它已经被正确配置为拦截我们的深度链接。

  1. 选择 RestaurantDetailsScreen() 目标并显示所需餐厅的详细信息。

可选地,您可以尝试按系统中的 RestaurantsScreen 组合式。

现在我们已经成功地将深度链接功能添加到我们的餐厅应用中,是时候结束这一章了。

摘要

在本章中,我们学习了如何在餐厅应用内部导航屏幕。我们借助 Jetpack Navigation 组件库轻松地做到了这一点。

我们从学习 Jetpack Navigation 库的基础知识开始,并了解到在处理导航回退栈时生活变得多么简单。之后,我们创建了一个新的屏幕,实现了 Navigation 库,并探索了在可组合组件之间添加导航的流畅性。最后,我们添加了对深度链接的支持,并确保在我们的应用中测试了这样的深度链接。

接下来,是时候专注于提升我们餐厅应用的质量和架构了。

第二部分:使用 Jetpack 库的干净应用架构指南

在本部分,我们将学习如何融入干净和现代的架构,使用 Room 添加离线功能,通过 Hilt 包含依赖注入,并使用上一节中的示例项目通过 UI 和单元测试来测试 UI 和应用逻辑。

本节包含以下章节:

  • 第六章使用 Jetpack Room 添加离线功能

  • 第七章介绍 Android 中的展示模式

  • 第八章在 Android 中使用干净架构入门

  • 第九章使用 Jetpack Hilt 实现依赖注入

  • 第十章使用 UI 和单元测试测试您的应用

第六章:第六章:使用 Jetpack Room 添加离线功能

在本章中,我们将从确保我们的应用程序可以在没有互联网连接的情况下使用开始,从而探索构建应用程序架构的方法。

介绍 Jetpack Room 部分,我们将简要介绍 Android 上的各种缓存机制。然后,我们将介绍 Jetpack Room 库及其核心元素。

接下来,在 通过实现 Room 启用离线使用 部分,我们将在我们的餐馆应用程序中实现 Room,并允许用户在没有互联网连接的情况下使用应用程序。在 将部分更新应用于 Room 数据库 部分,我们将学习如何部分更新 Room 内部的数据,以便我们可以保存诸如用户是否喜欢餐馆之类的选择。

最后,在 将本地数据作为应用程序内容的单一事实来源 部分,我们将了解为什么拥有应用程序数据的单一事实来源是有益的,然后我们将设置 Room 数据库作为我们应用程序的单一内容来源。

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

  • 介绍 Jetpack Room

  • 通过实现 Room 启用离线使用

  • 将部分更新应用于 Room 数据库

  • 将本地数据作为应用程序内容的单一事实来源

在深入之前,让我们为本章设置技术要求。

技术要求

通常,使用 Jetpack Room 构建 Compose 基础的 Android 项目需要您的日常工具。然而,为了顺利地跟随示例,请确保您有以下内容:

  • Arctic Fox 2020.3.1 版本的 Android Studio。您也可以使用更新的 Android Studio 版本或甚至 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 安装在 Android Studio 中的 Kotlin 1.6.10 或更新的插件

  • 上一章的餐馆应用程序代码。

  • 对 SQL 数据库和查询的最低了解

本章的起点是上一章开发的餐馆应用程序。如果您没有遵循上一章中描述的实现,请通过导航到存储库的 Chapter_05 目录来访问本章的起始代码。然后,导入名为 chapter_5_restaurants_app 的 Android 项目。

要访问本章的解决方案代码,请导航到 Chapter_06 目录:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_06/chapter_6_restaurants_app.

介绍 Jetpack Room

现代应用应在任何条件下都可用,包括当用户没有互联网连接时。这允许应用在用户的设备无法访问网络的情况下提供无缝的用户体验和可用性。

在本节中,我们将讨论以下内容:

  • 探索 Android 上的缓存机制

  • 介绍 Jetpack Room 作为本地缓存的解决方案

那么,让我们开始吧!

探索 Android 上的缓存机制

为了缓存特定内容或应用数据,可靠的 Android 应用会使用适合不同用例的各种离线缓存机制:

  • 共享首选项用于存储轻量级数据(例如与用户相关的选择)作为键值对。这个选项不应该用来存储应用内容的一部分对象。

  • 设备存储(无论是内部还是外部)用于存储重量级数据(例如文件、图片等)。

  • SQLite 数据库用于在私有数据库中以结构化方式存储应用内容。SQLite 是一个开源的 SQL 数据库,它将数据存储在私有文本文件中。

在本章中,我们将专注于学习如何在 SQLite 数据库中缓存结构化内容(通常是 Kotlin data class 对象持有的内容)。这样,我们允许用户在离线状态下浏览应用的数据。

注意

Android 内置了 SQLite 数据库实现,允许我们保存结构化数据。

在我们的应用中,我们可以将餐厅数组视为一个完美的候选者,可以在 SQLite 数据库中保存的应用内容。由于数据是有结构的,使用 SQLite,我们能够执行不同的操作,例如在数据库中搜索餐厅、更新特定餐厅等。

通过这种方式缓存应用内容,我们可以允许用户在离线状态下浏览应用的餐厅。然而,为了使这一功能正常工作,用户需要之前使用活跃的互联网连接打开过应用,从而允许应用缓存内容以供未来的离线使用。

现在,为了将餐厅保存到 SQLite 私有数据库,我们需要使用 SQLite API。这些 API 功能强大。然而,使用它们时,你会面临相当多的缺点:

  • API 是低级别的,相对难以使用。

  • SQLite API 不提供 SQL 查询的编译时验证,这可能导致不希望的运行时错误。

  • 创建数据库、执行 SQL 查询等操作涉及大量的模板代码。

为了减轻这些问题,Google 提供了 Jetpack Room 库。这个库不过是一个包装库,它简化了我们访问和交互 SQLite 数据库的方式。

介绍 Jetpack Room 作为本地缓存的解决方案

Room 是一个持久化库,它被定义为 SQLite 之上的抽象层,提供了简化的数据库访问,同时利用了 SQLite API 的强大功能。

与使用原始 SQLite API 相比,Room 抽象了与 SQLite 一起工作的大部分复杂性。该库消除了在 Android 上设置和与 SQLite 数据库交互所需的大部分不愉快的样板代码,同时提供了 SQL 查询的编译时检查。

要使用 Room 库并使用其 API 缓存内容,您需要定义三个主要组件:

  • Restaurant 数据类作为一个实体。这意味着我们将有一个包含 Restaurant 对象的表。换句话说,表的行由我们的餐厅实例表示。

  • 一个将包含并公开实际数据库的数据库类。

  • 数据访问对象(DAOs)代表一个接口。这允许我们获取、插入、删除或更新数据库中的实际内容。

数据库类为我们提供了与 SQLite 数据库关联的 DAO 接口的引用:

![图 6.1 – 应用程序与 Room 数据库之间的交互img/B17788_06_1.jpg

图 6.1 – 应用程序与 Room 数据库之间的交互

如前所述,我们可以使用 DAO 以实体对象的形式从数据库检索或更新数据 – 在我们的案例中,实体是餐厅,因此我们将对这些餐厅对象执行此类操作。

现在我们已经基本了解了 Room 的工作原理以及我们如何与之交互,是时候亲自看到它的实际应用并在我们餐厅应用中实现 Room 了。

通过实现 Room 启用离线使用

我们希望将我们从 Firebase 数据库接收到的所有餐厅本地缓存。由于此内容是有结构的,我们希望使用 Room 来帮助我们完成这项任务。

实质上,我们试图在用户在线浏览我们的餐厅应用时挽救餐厅。然后,当用户离线浏览应用时,我们将重新使用它们:

img/B17788_06_2.jpg

图 6.2 – 餐厅应用的数据检索,具有两个数据源

当在线时,我们从我们的网络 API 获取餐厅。在向用户显示之前,首先,我们将它们缓存到 Room 数据库中。如果离线,我们将从 Room 数据库中检索餐厅并向用户显示。

实质上,我们正在为我们的应用创建两个数据源:

  • 用户在线时的远程 API

  • 用户离线时的本地 Room 数据库

在下一节中,我们将讨论为什么这种方法并不理想。然而,在此之前,我们对于能够在离线状态下使用应用的事实感到满意。

让我们开始实现 Room,然后让我们缓存那些餐厅!执行以下步骤:

  1. 在应用模块的 build.gradle 文件中,在 dependencies 块内添加 Room 的依赖项:

    implementation "androidx.room:room-runtime:2.4.2"
    kapt "androidx.room:room-compiler:2.4.2"
    implementation "androidx.room:room-ktx:2.4.2"
    
  2. 当您仍在 build.gradle 文件中时,在 plugins 块内添加 Room 的 kotlin-kapt 插件:

    plugins {
        id 'com.android.application'
        id 'kotlin-android'
        id 'kotlin-kapt'
    }
    

kapt插件代表Kotlin 注解处理工具。这允许 Room 在编译时生成注解代码,同时将大部分相关复杂性隐藏起来。

在更新build.gradle文件后,请确保将您的项目与其 Gradle 文件同步。您可以通过点击文件菜单选项,然后选择同步项目与 Gradle 文件来完成此操作。

  1. 由于我们想在本地数据库中存储餐厅对象,让我们指示 Room,Restaurant数据类是一个必须保存的实体。进入Restaurant.kt文件,并在类声明上方添加@Entity注解:

    @Entity(tableName = "restaurants")
    data class Restaurant(…)
    

@Entity注解内部,我们通过tableName参数传递了表的名称。我们将使用此名称进行查询。

  1. 现在,Room 将创建一个以Restaurant对象为行的表,是时候定义实体的列(或字段)了。当我们在Restaurant.kt类内部时,让我们在每个我们感兴趣的、应该代表列的字段上添加@ColumnInfo注解:

    @Entity(tableName = "restaurants")
    data class Restaurant(
        @ColumnInfo(name = "r_id")
        @SerializedName("r_id")
        val id: Int,
        @ColumnInfo(name = "r_title")
        @SerializedName("r_title")
        val title: String,
        @ColumnInfo(name = "r_description")
        @SerializedName("r_description")
        val description: String,
        var isFavorite: Boolean = false
    )
    

对于我们感兴趣的每个字段,我们添加了@ColumnInfo注解,并将String值传递给name参数。这些名称将对应于表列的名称。目前,我们并不感兴趣保存isFavorite字段;我们稍后会做这件事。

  1. 代表表的实体应该有一个主键列,以确保在数据库中的唯一性。为此,我们可以使用从我们的 Firebase 数据库配置为唯一的id字段。当仍在Restaurant.kt类内部时,让我们在id字段上添加@PrimaryKey注解:

    @Entity(tableName = "restaurants")
    data class Restaurant(
        @PrimaryKey()
        @ColumnInfo(name = "r_id")
        @SerializedName("r_id")
        val id: Int,
        …)
    

现在我们已经定义了数据库的实体并配置了表的列。

是时候创建一个 DAO 了,它将作为我们数据库的入口点,使我们能够执行各种操作。

  1. 通过单击应用程序包,选择RestaurantsDao作为名称,并选择接口作为类型来创建一个 DAO。在新的文件中,添加以下代码:

    import androidx.room.*
    @Dao
    interface RestaurantsDao { }
    

由于 Room 将负责实现我们需要与数据库交互的任何操作,DAO 是一个接口,就像 Retrofit 也有一个用于 HTTP 方法的接口一样。为了指示 Room 这是一个 DAO 实体,我们在接口声明上方添加了@Dao注解。

  1. RestaurantsDao接口内部,添加两个suspend函数,这将帮助我们保存餐厅并从数据库中检索它们:

    @Dao
    interface RestaurantsDao {
        @Query("SELECT * FROM restaurants")
        suspend fun getAll(): List<Restaurant>
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        suspend fun addAll(restaurants: List<Restaurant>)
    }
    

现在,让我们分析我们添加的两个方法:

  • getAll()是一个查询语句,它返回之前缓存在数据库中的餐厅。由于我们需要在调用此方法时执行 SQL 查询,我们已使用@Query注解标记它,并指定我们想要从在Restaurant实体数据类中定义的restaurants表中获取所有餐厅(通过添加*)。

  • addAll()是一个将接收到的餐厅缓存到数据库中的insert语句。为了将其标记为 SQL insert语句,我们添加了@Insert注解。但是,如果正在插入的餐厅已经在数据库中存在,我们应该用新的替换旧的,以刷新我们的缓存。我们通过将OnConflictStrategy.REPLACE值传递给@Insert注解来指示 Room 这样做。

这两个方法都被标记为suspend函数,因为与 Room 数据库的任何交互都可能需要时间,并且是一个异步任务;因此,它不应该阻塞 UI。

现在,我们已经定义了一个实体类和一个 DAO 类,我们必须定义 Room 为了运行所需的最后一个组件,即数据库类。

  1. 通过点击应用程序包创建 Room 数据库类。将名称选为RestaurantsDb,并将类型选为文件。在新的文件中,添加以下代码:

    @Database(
        entities = [Restaurant::class], 
        version = 1, 
        exportSchema = false)
    abstract class RestaurantsDb : RoomDatabase() { }
    

现在,让我们分析我们刚刚添加的代码:

  • RestaurantsDb是一个继承自RoomDatabase()的抽象类。这将允许 Room 在幕后创建数据库的实际实现,并隐藏所有繁重的实现细节。

  • 对于RestaurantsDb类,我们添加了@Database注解,以便 Room 知道这个类代表一个数据库并提供其实现。在这个注解内部,我们传递了以下内容:

  • Restaurant类传递给entities参数。此参数告诉 Room 哪些实体与该数据库相关联,以便它可以创建相应的表。该参数期望一个数组,因此您可以添加任意多的实体类,只要它们被注解为@Entity

  • 1作为数据库的version版本号。每当数据库的架构发生变化时,我们应该增加这个版本号。由于Restaurant类是一个实体,我们可能会更改数据库的架构,Room 需要知道这一点以进行迁移。

  • false传递给exportSchema参数。Room 可以外部导出我们数据库的架构;然而,为了简单起见,我们选择不这样做。

  1. RestaurantsDb类内部,添加一个抽象的RestaurantsDao变量:

    @Database(…)
    abstract class RestaurantsDb : RoomDatabase() {
        abstract val dao: RestaurantsDao
    }
    

我们知道数据库类应该公开一个 DAO 对象,这样我们就可以与数据库交互。通过将其留为抽象的,我们允许 Room 在幕后提供其实现。

  1. 尽管我们声明了一个变量来保存我们的 DAO 对象,我们仍然需要找到一种方法来构建数据库并获取 Room 为我们创建的RestaurantsDao实例的引用。在RestaurantsDb类内部,添加companion object然后添加buildDatabase方法:

    @Database(…)
    abstract class RestaurantsDb : RoomDatabase() {
        abstract val dao: RestaurantsDao
        companion object {
    private fun buildDatabase(context: Context): 
                RestaurantsDb =
                Room.databaseBuilder(
                    context.applicationContext,
                    RestaurantsDb::class.java,
                    "restaurants_database")
                    .fallbackToDestructiveMigration()
                    .build()
        }
    }
    

实际上,此方法返回一个RestaurantsDb实例。要构建 Room 数据库,我们需要调用Room.databaseBuilder构造函数,它期望以下参数:

  • 我们从buildDatabase方法的context输入参数提供的Context对象。

  • 你正在尝试构建的数据库的类,即RestaurantsDb类。

  • 数据库的名称——我们将其命名为"restaurants_database"

构建器返回一个RoomDatabase.Builder对象,我们在其上调用.fallbackToDestructiveMigration()。这意味着,在模式更改的情况下(例如在实体类中执行更改并修改表列),表将被删除(或删除),而不是尝试从先前的模式(这将更复杂一些)迁移内容。

最后,我们在构建器对象上调用build(),以便我们的buildDatabase()方法返回一个RestaurantsDb实例。

现在是时候获取我们 DAO 的引用,以便我们可以开始使用数据库了。

  1. 仍然在RestaurantsDb类的companion object内部,添加以下代码:

    companion object {
        @Volatile
        private var INSTANCE: RestaurantsDao? = null
    fun getDaoInstance(context: Context): RestaurantsDao 
        {
            synchronized(this) {
                var instance = INSTANCE
                if (instance == null) {
                    instance = buildDatabase(context).dao
                    INSTANCE = instance
                }
                return instance
            }
        }
        private fun buildDatabase(…) = …
    }
    

现在,让我们分解我们所做的工作:

  • 我们添加了一个类型为RestaurantsDaoINSTANCE变量。由于这个变量在伴生对象内部,INSTANCE是静态的。此外,我们用@Volatile标记了它。这意味着对这个字段的写入会立即对其他线程可见。不必过于担心这些多线程概念——我们很快就会摆脱这些样板代码。

  • 我们创建了一个getDaoInstance()方法,在其中添加了一块代码,调用buildDatabase()方法并通过调用.dao访问器获取 DAO 对象。

由于我们只想有一个数据库的内存引用(而不是在应用程序的其他部分创建其他数据库实例),我们确保我们的INSTANCE变量符合单例模式。本质上,单例模式允许我们持有对象的静态引用,使其在应用程序的生命周期内持续存在。

通过这种方法,每次我们需要从应用程序的不同部分访问 Room 数据库时,我们都可以调用getDaoInstance()方法,它返回一个RestaurantsDao实例。此外,我们可以确信它始终是相同的内存引用,并且由于我们在synchronized块中封装了实例创建代码,因此不会发生并发问题。

  1. 你可能已经注意到,为了获取我们的 DAO 并将餐厅缓存到数据库中,RestaurantsDb.getDaoInstance()方法期望一个Context对象。这是创建数据库实例所需的。然而,我们想在RestaurantsViewModel类中获取我们的 DAO,而我们那里没有上下文,所以我们该怎么办?

让我们从应用程序类中暴露应用上下文!通过点击应用程序包,将名称设置为RestaurantsApplication,并选择文件类型来创建应用程序类。在新文件中,添加以下代码:

class RestaurantsApplication: Application() {
    init { app = this }
    companion object {
        private lateinit var app: RestaurantsApplication
        fun getAppContext(): Context = 
            app.applicationContext
    }
}

这个类现在继承自 android.app.Application 并通过静态 getAppContext() 方法公开其上下文。唯一的问题是,尽管我们有应用程序类,但我们还没有配置项目以识别它。

  1. AndroidManifest.xml 文件中,在 <application> 元素内部,添加设置我们的 RestaurantsApplication 类为应用程序类的 android:name 标识符:

    <application
        android:allowBackup="true"
        android:name=".RestaurantsApplication"
        android:icon="@mipmap/ic_launcher"
        …
        <activity> … </activity>
    </application>
    

现在是时候开始在我们的数据库中缓存那些餐厅了。

  1. RestaurantsViewModel 类内部,添加一个 restaurantsDao 变量。然后,通过静态方法 RestaurantsDb.getDaoInstance 实例化它:

    class RestaurantsViewModel(…) : ViewModel() {
        private var restInterface: RestaurantsApiService
        private var restaurantsDao = RestaurantsDb
            .getDaoInstance(
                RestaurantsApplication.getAppContext()
            )
         ....
    }
    

确保通过在应用程序类内部新创建的 getAppContext() 方法传递应用程序上下文。

  1. 现在我们已经准备好在本地保存餐厅了!当你仍然在 RestaurantsViewModel 类中时,在 getRemoteRestaurants() 方法中添加这些新行代码:

    private suspend fun getRemoteRestaurants(): 
        List<Restaurant> {
        return withContext(Dispatchers.IO) {
            val restaurants = restInterface.getRestaurants()
            restaurantsDao.addAll(restaurants)
            return@withContext restaurants
        }
    }
    

实质上,我们所做的是以下操作:

I. 从远程 API(此处为 Retrofit 的 restInterface 变量)获取餐厅。

II. 通过调用 restaurantsDao.addAll() 在本地数据库中通过 Room 缓存那些餐厅。

III. 最后,将餐厅返回到 UI。

  1. 当你有正常工作的互联网连接时运行应用程序。

在 UI 方面,不应该有任何变化——你应该仍然看到餐厅。但话虽如此,在幕后,餐厅现在应该已经被缓存了。

  1. 再次运行应用程序,但不要连接到互联网。

很可能你什么也看不到。餐厅不在那里。

这是因为,当我们离线时,我们从未尝试从 Room 数据库获取之前缓存的餐厅。此外,当离线时,restinterface.getRestaurants() 挂起函数会抛出一个错误,因为获取餐厅的 HTTP 调用失败了——这个异常应该出现在 CoroutineExceptionHandler 中。异常是由 Retrofit 抛出的,因为相关的网络请求失败了。

  1. 让我们利用这样一个事实:当我们离线时,restinterface.getRestaurants() 函数调用会抛出异常。这样我们就可以将 getRemoteRestaurants() 内部的整个代码块包裹在一个 try-catch 块中:

    private suspend fun getRemoteRestaurants():
    List<Restaurant> {
        return withContext(Dispatchers.IO) {
            try {
                val restaurants = restInterface
                    .getRestaurants()
                restaurantsDao.addAll(restaurants)
                return@withContext restaurants
            } catch (e: Exception) {
                when (e) {
                    is UnknownHostException,
                    is ConnectException,
                    is HttpException -> {
    return@withContext 
                            restaurantsDao.getAll()
                    }
                    else -> throw e
                }
            }
        }
    }
    

实质上,现在发生的情况是,如果用户离线,我们将捕获 Retrofit 抛出的异常。或者,我们可以通过调用 restaurantsDao.getAll() 从 Room 数据库返回缓存的餐厅。

作为额外操作,我们还检查我们捕获的异常是否是由于用户糟糕或不存在的网络连接引起的。如果 Exception 对象是 UnknownHostExceptionConnectExceptionHttpException 类型,我们将通过我们的 DAO 从 Room 加载餐厅;否则,我们将传播异常,以便它稍后被 CoroutineExceptionHandler 捕获。

  1. 在运行应用程序之前,让我们稍微重构一下getRemoteRestaurants()方法。现在,方法名暗示它从远程源检索餐厅。然而,实际上,如果用户离线,它也会从 Room 中检索餐厅。Room 是本地数据源,因此这个方法的名字不再合适。

getRemoteRestaurants()方法重命名为getAllRestaurants()

private suspend fun getAllRestaurants(): 
    List<Restaurant> {  }

此外,请记住在启动协程的getRestaurants()方法中更改其使用方式:

private fun getRestaurants() {
    viewModelScope.launch(errorHandler) {
        val restaurants = getAllRestaurants()
        state.value = restaurants.restoreSelections()
    }
}
  1. 再次在没有互联网连接的情况下运行应用程序。

由于餐厅之前已被缓存,现在用户处于离线状态,我们从 Room 中获取它们。你应该即使在没有互联网的情况下也能看到餐厅。成功了!

尽管我们已经走了很长的路,并成功使餐厅应用程序在没有互联网的情况下也可用,但我们仍然忽略了一些东西。为了重现它,请执行以下步骤:

  1. 尝试运行应用程序(无论是在线还是离线),然后标记几家餐厅为收藏。

  2. 断开您的设备与互联网的连接,并确保您现在处于离线状态。

  3. 在离线状态下重新启动应用程序。

你将看到餐厅,但你的之前的选项已经丢失。更确切地说,尽管我们标记了一些餐厅为收藏,但现在所有餐厅都显示为非收藏。是时候修复这个问题了!

对 Room 数据库应用部分更新

目前,我们的应用程序正在将我们从远程 Web API 接收到的餐厅直接保存到 Room 数据库中。

这不是一个坏的方法;然而,每次我们标记一家餐厅为收藏时,我们并没有更新 Room 中的对应餐厅。如果我们查看RestaurantsViewModel类,并检查其toggleFavorite()方法,我们可以看到我们只更新了state变量中餐厅的isFavorite标志:

fun toggleFavorite(id: Int) {
    val restaurants = state.value.toMutableList()
    val itemIndex = restaurants.indexOfFirst { it.id == id }
    val item = restaurants[itemIndex]
    restaurants[itemIndex] = item.copy(isFavorite =  
        !item.isFavorite)
    storeSelection(restaurants[itemIndex])
    state.value = restaurants
}

我们没有在 Room 中更新对应餐厅的isFavorite字段值。所以,每次我们在离线状态下使用应用程序时,餐厅将不再显示为收藏,即使我们在在线时可能已经标记了一些为收藏。

为了修复这个问题,每次我们标记一家餐厅为收藏或非收藏时,我们都需要在我们的 Room 数据库中的特定Restaurant对象上应用部分更新。部分更新不应替换整个Restaurant对象,而应仅更新其isFavorite字段值。

让我们开始吧!执行以下步骤:

  1. 通过单击应用程序包,选择PartialRestaurant作为名称,并选择文件作为类型来创建一个部分实体类。在新的文件中,添加以下代码:

    @Entity
    class PartialRestaurant(
        @ColumnInfo(name = "r_id")
        val id: Int,
        @ColumnInfo(name = "is_favorite")
        val isFavorite: Boolean)
    

在这个@Entity注解的类中,我们只添加了两个字段:

  • 一个带有 @ColumnInfo() 注解的 id 字段,其值("r_id") 与传递给 name 参数的 Restaurant 对象的 id 字段相同。这允许 Room 将 Restaurant 对象的 id 字段与 PartialRestaurant 中的对应字段匹配。

  • 一个带有 @ColumnInfo() 注解的 isFavorite 字段,其名称设置为 "is_favorited"。到目前为止,Room 还不能将此字段与 Restaurant 中的字段匹配,因为在 Restaurant 中,我们还没有用 @ColumnInfo 注解 isFavorite 字段——我们将在下一步中这样做。

  1. 现在,我们的部分实体 PartialRestaurant 已经有一个与 isFavorite 字段对应的列,是时候也为 Restaurant 实体的 isFavorite 字段添加一个具有相同值("is_favorite") 的 @ColumnInfo() 注解了:

    @Entity(tableName = "restaurants")
    data class Restaurant(
        …
        val description: String,
        @ColumnInfo(name = "is_favorite")
        val isFavorite: Boolean = false
    )
    

作为一种良好的实践,我们还已将 isFavorite 字段从 var 改为 val,以防止在对象创建后更改其值。因为 Restaurant 是传递给 Compose State 对象的对象,我们希望在其字段上提升不可变性,以确保重新组合事件发生。

注意

通过将数据类字段作为 var,我们可以在运行时轻松更改其值,并冒着 Compose 错过所需的重新组合的风险。不可变性确保每当对象字段的值发生变化时,就会创建一个新的对象(就像我们使用 .copy() 函数那样),并且 Compose 会被通知,以便它可以触发重新组合。

  1. 由于 isFavorite 字段现在是 valRestaurantViewModel 内部的 restoreSelections() 扩展函数已损坏。更新其代码如下:

    private fun List<Restaurant>.restoreSelections(): … {
        stateHandle.[…]let { selectedIds ->
            val restaurantsMap = this.associateBy { it.id }
                .toMutableMap()
            selectedIds.forEach { id ->
    val restaurant = 
                    restaurantsMap[id] ?: return@forEach
                restaurantsMap[id] =
                    restaurant.copy(isFavorite = true)
            }
            return restaurantsMap.values.toList()
        }
        return this
    }
    

实质上,我们所做的是确保我们的 restaurantsMap 类型为 Map<Int, Restaurant> 是可变的,这样我们就可以替换其内部元素。采用这种方法,我们现在通过传递一个带有 copy 函数的新对象引用来替换 id 条目的餐厅。我们不会深入探讨,因为这部分代码很快就会被移除。

  1. 现在我们已经定义了一个部分实体,我们需要在 DAO 中添加另一个函数,该函数将通过 PartialRestaurant 实体更新 Restaurant 实体。在 RestaurantsDao 中添加 update() 函数:

    @Dao
    interface RestaurantsDao {
        …
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        suspend fun addAll(restaurants: List<Restaurant>)
        @Update(entity = Restaurant::class)
        suspend fun update(partialRestaurant:
            PartialRestaurant)
    }
    

让我们一步一步地了解新的 update() 函数是如何工作的:

I. 它是一个 suspend 函数,因为我们现在知道,任何与本地数据库的交互都是一个挂起作业,不应在主线程上运行。

II. 它接收一个 PartialRestaurant 实体作为参数,并返回空值。部分实体的字段值对应于我们试图更新的餐厅。

III. 它被注解为 @Update 注解,我们传递了 Restaurant 实体。更新过程有两个步骤,如下所示:

i. 首先,PartialRestaurant 暴露了 id 字段,其值与对应 Restaurant 对象的 id 字段值相匹配。

ii. 一旦匹配完成,isFavorite字段的值被设置为匹配的Restaurant对象的isFavorite字段。

这些匹配是可能的,因为两个实体的idisFavorite字段具有相同的@ColumnInfo名称值。

  1. 既然我们的 DAO 知道如何部分更新我们的Restaurant实体,现在是时候执行更新了。

首先,在RestaurantsViewModel内部添加一个新的挂起函数,称为toggleFavoriteRestaurant()

private suspend fun toggleFavoriteRestaurant(id: Int, oldValue: Boolean) =
    withContext(Dispatchers.IO) {
        restaurantsDao.update(
            PartialRestaurant(
                id = id,
                isFavorite = !oldValue
            )
        )
    }

让我们一步一步地理解这个新方法做了什么:

I. 它接收我们试图更新的餐厅的id字段,以及oldValue字段,它代表用户在切换餐厅心形图标之前isFavorite字段的值。

II. 要部分更新餐厅,它需要与 Room DAO 对象交互。这意味着toggleFavoriteRestaurant方法必须是一个suspend函数。作为一个好的实践,我们将其包裹在一个withContext块中,指定其工作必须在IO调度器内完成。虽然 Room 确保我们将挂起的工作用特殊的调度器包裹起来,但我们明确指定了Dispatchers.IO调度器,以更好地突出这种重工作应该在适当的调度器中完成。

III. 它构建一个PartialRestaurant对象,然后将其传递给之前创建的 DAO 的update()方法。PartialRestaurant对象获取我们正在更新的餐厅的id字段,以及isFavorite标志的否定值。如果用户之前没有将餐厅标记为收藏,当点击心形图标时,我们应该否定旧的(false)值并获取true,反之亦然。

既然我们已经有了更新餐厅的方法,现在是时候调用它了。

  1. 当你仍然在RestaurantsViewModel中时,让toggleFavorite方法在其主体末尾启动一个协程。然后,在它内部,调用新的toggleFavoriteRestaurant()挂起函数:

    fun toggleFavorite(id: Int) {
        …
        restaurants[itemIndex] = item.copy(isFavorite =  
           !item.isFavorite)
        storeSelection(restaurants[itemIndex])
        state.value = restaurants
        viewModelScope.launch {
            toggleFavoriteRestaurant(id, item.isFavorite)
        }
    }
    

我们向toggleFavoriteRestaurant()函数传递以下内容:

  • id参数,它代表用户试图标记为收藏或不收藏的餐厅的 ID

  • 根据定义在item字段的isFavorite标志中的餐厅收藏状态的老值

现在,每当用户按下心形图标时,我们不仅更新 UI,还通过部分更新在本地数据库中缓存这个选择。

  1. 构建并运行应用程序,因为现在是时候测试我们刚刚实现的内容了!不幸的是,应用程序崩溃了。你能想到一个导致这种情况的原因吗?如果我们查看错误的堆栈跟踪,我们将看到以下消息:

    java.lang.IllegalStateException: Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number.
    

这个错误信息完全合理,因为我们已经更改了数据库的模式,现在 Room 不知道是迁移旧条目还是删除它们。但我们是如何更改模式的呢?

好吧,当我们为“餐厅”表定义了一个新的列时,我们更改了模式,通过在isFavorite字段上添加@ColumnInfo()注解。

  1. 为了减轻这个问题,我们必须增加数据库的version号。在RestaurantsDb类中,将version号从1增加到2

    @Database(
        entities = [Restaurant::class],
        version = 2,
        exportSchema = false)
    abstract class RestaurantsDb : RoomDatabase() { .. }
    

现在,Room 知道我们已经更改了数据库的模式。反过来,因为我们没有提供迁移策略,而是在最初实例化数据库时在Room.databaseBuilder构造函数中调用了fallbackToDestructiveMigration()方法,Room 将删除旧的内容和表,并为我们提供一个全新的开始。

  1. 尝试在线运行应用程序,然后标记一些餐厅为收藏。

  2. 断开您的设备与互联网的连接,并确保您现在处于离线状态。

  3. 在离线状态下重新启动应用程序。

好消息!现在选择被保留了,我们可以看到哪些餐厅之前被标记为收藏!

  1. 为了继续测试,当您离线时,您可以尝试将其他餐厅标记为收藏。

然后,仍然在离线模式下,重新启动应用,你会注意到这些新的选择也已经保存。

  1. 将您的设备连接到互联网并运行应用程序——当您在线时。

哎呀!我们之前标记为收藏的餐厅不再显示为收藏,尽管我们之前已经在 Room 数据库中缓存了这些选择。

实际上,每次我们打开应用并连接到互联网时,我们都会丢失所有之前的选项,并且没有餐厅被标记为收藏。

我们代码中有两个问题导致了这个问题!你能想到为什么会发生这种情况吗?

在下一节中,我们将识别并解决这些问题。此外,我们还将确保 Room 是应用内容的唯一真相来源。

将本地数据作为应用内容的唯一真相来源

每次我们带互联网启动应用时,所有餐厅都显示为非收藏,尽管我们之前已将它们标记为收藏并在 Room 数据库中缓存了选择。

为了识别问题,让我们回到RestaurantsViewModel内部,并检查getAllRestaurants()方法:

private suspend fun getAllRestaurants(): List<Restaurant> {
    return withContext(Dispatchers.IO) {
        try {
            val restaurants = restInterface.getRestaurants()
            restaurantsDao.addAll(restaurants)
            return@withContext restaurants
        } catch (e: Exception) {
            when (e) {
                is UnknownHostException, […] -> {
                    return@withContext restaurantsDao.getAll()
                }
                else -> throw e
            }
        }
    }
}

现在,当我们在线启动应用时,我们做三件事:

  • 我们通过调用restInterface.getRestaurants()从服务器加载餐厅。对于这些餐厅,我们没有收到isFavorite标志,所以我们自动将其设置为false。这是因为我们的Restaurant类默认将isFavorite的值设置为false,如果没有从 Gson 反序列化传递值:

    @Entity(tableName = "restaurants")
    data class Restaurant(
        …
        @ColumnInfo(name = "is_favorite")
        val isFavorite: Boolean = false)
    
  • 然后,我们通过调用restaurantsDao.addAll(restaurants)将这些餐厅保存到 Room 中。然而,因为我们已经在 DAO 的addAll()函数内部使用了REPLACE策略,并且因为我们从服务器接收了相同的餐厅,所以我们覆盖了数据库中相应餐厅的isFavorite标志为false。因此,尽管我们的 Room 中的餐厅可能已经将isFavorite标志设置为true,但由于我们从服务器接收了具有相同id字段的餐厅,我们最终将这些值全部重置为false

  • 接下来,我们将从服务器接收到的restaurants列表传递给 UI。正如我们已经知道的,这些餐厅的isFavorite字段的值为false。因此,每次我们在连接到互联网时启动应用程序,我们都会看到没有标记为收藏的餐厅。

如果我们仔细思考,这里有两个主要问题:

  • 我们的应用程序有两个单一数据来源:

    • 当在线时,它显示远程服务器上的餐厅。

    • 当离线时,它显示本地数据库中的餐厅。

  • 每当我们缓存本地数据库中已经存在的餐厅时,我们将它们的isFavorite标志重置为false

如果我们可以通过让我们的 UI 从单一数据源接收内容来修复这两个问题,我们也将能够消除对SavedStateHandle以及与进程重建相关的所有特殊处理的需求——我们将在稍后看到原因。

实际上,在本节中,我们将执行以下操作:

  • 将餐厅应用重构为具有单一数据来源

  • 在进程重建的情况下移除在SavedStateHandle中持久化状态的逻辑

因此,让我们从手头的第一个问题开始吧!

将餐厅应用重构为具有单一数据来源

采用多个数据来源的方法可能导致许多不一致性和微小的错误——就像我们的应用现在在用户在线或离线时显示的数据不一致一样。

备注

设计系统仅依赖于用于存储和更新内容的单一数据源的概念与一种称为单一数据来源SSOT)的实践相关。对于 UI 消耗的数据,拥有多个数据来源可能导致预期显示给 UI 的内容与实际显示的内容之间不一致。SSOT 概念帮助我们结构化数据访问,以便只有一个数据来源被信任为应用提供数据。

让我们确保我们的应用程序只有一个单一数据来源,但我们应该选择哪一个?

一方面,我们无法控制从我们的 Firebase 数据库发送的数据,而且我们也不能在用户将某个餐厅标记为收藏时更新数据库中的餐厅。

另一方面,我们可以在 Room 中做到这一点!事实上,我们已经在做了——每次用户将餐厅标记为收藏或不收藏时,我们都会在本地数据库中对该餐厅进行部分更新。

因此,让我们将本地 Room 数据库作为我们唯一的 数据来源:

图 6.3 – 使用本地数据库作为 SSOT 的餐厅应用的数据检索

当用户在线时,我们应该从服务器获取餐厅,将其缓存到 Room 中,然后再次从 Room 获取餐厅,最后将它们发送到 UI。

同样,如果用户离线,我们只需从 Room 获取餐厅并显示它们。

注意

或者,你不必总是从 Room 数据库请求最新的内容,你可以更新 DAO 接口以提供我们可以观察的响应式数据流。这样,每当数据更新时,你都会自动以响应式的方式收到最新的内容,而无需手动请求。为了实现这一点,你必须使用由 Jetpack LiveData、Kotlin Flow 或 RxJava 等库提供的特殊数据持有者。我们将在第十一章中探讨 Kotlin Flow,使用 Jetpack Paging 和 Kotlin Flow 创建无限列表

我们两个场景的相似之处在于,现在,无论用户的网络连接如何,我们的 UI 总是显示 Room 数据库内的餐厅。换句话说,本地数据库是我们的 SSOT(单点登录)!

让我们开始实现!执行以下步骤:

  1. RestaurantsViewModel内部,重构getAllRestaurants()函数,使其始终返回 Room 数据库中的餐厅:

    private suspend fun getAllRestaurants(): 
         List<Restaurant> {
        return withContext(Dispatchers.IO) {
            try { … } catch (e: Exception) { […] }
            return@withContext restaurantsDao.getAll()
        }
    }
    

在这里,我们的应用试图在任何情况下显示本地数据库中的餐厅。

  1. 现在,是时候重构getAllRestaurants()方法内的try – catch块了!本质上,我们想要做的是从服务器获取餐厅,然后将其本地缓存。

try { }块内的内容替换为新的refreshCache()方法:

return withContext(Dispatchers.IO) {
    try {
        refreshCache()
    } catch (e: Exception) { […] }
    return@withContext restaurantsDao.getAll()
}
  1. 此外,我们想要定义refreshCache()函数,从远程服务器获取餐厅并将它们缓存到本地数据库中,从而刷新其内容:

    private suspend fun refreshCache() {
        val remoteRestaurants = restInterface
            .getRestaurants()
        restaurantsDao.addAll(remoteRestaurants)
    }
    
  2. 我们知道,如果缓存刷新失败,我们仍然会从 Room 显示本地餐厅。但如果是本地数据库为空呢?

继续重构getAllRestaurants()方法,更新其catch块。你可以通过从is UnknownHostException, is ConnectException, is HttpException分支中移除(现在已冗余)return@withContext restaurantsDao.getAll()调用,并用以下代码替换它:

try { … } catch (e: Exception) {
    when (e) {
        is UnknownHostException, is ConnectException,
        is HttpException -> {
            if (restaurantsDao.getAll().isEmpty())
                throw Exception(
                    "Something went wrong. " +
                            "We have no data.")
        }
        else -> throw e
    }
}

事实上,如果抛出了网络异常,我们可以检查 Room 数据库中是否保存了任何本地餐厅:

  • 如果列表为空,我们通过抛出自定义异常从父方法中提前返回,以通知用户我们没有数据可以显示。

  • 然而,如果本地数据库有元素,我们不做任何操作,让 getAllRestaurants() 方法将缓存的餐厅返回给 UI。

现在,在 ViewModeltoggleFavorite() 函数内部,无论何时切换餐厅的收藏状态,我们都可以观察到我们在使用部分更新更新 Room 数据库。然而,我们没有再次从 Room 中获取餐厅,因此 UI 永远不会被告知这种变化:

fun toggleFavorite(id: Int) {
    …
    restaurants[itemIndex] = item.copy(isFavorite =
        !item.isFavorite)
    storeSelection(restaurants[itemIndex])
    state.value = restaurants
    viewModelScope.launch {
        toggleFavoriteRestaurant(id, item.isFavorite)
    }
}

相反,我们正在更新 state 变量的值 – 因此 UI 接收了内存中更新的餐厅。这意味着我们并没有遵循 SSOT 实践,即我们选择始终从本地数据库向 UI 提供餐厅。让我们解决这个问题。

  1. toggleFavoriteRestaurant() 函数返回我们本地数据库中的餐厅。你可以通过在 withContext() 块内部调用 restaurantsDao.getAll() 函数来实现这一点:

    private suspend fun toggleFavoriteRestaurant(
        id: Int,
        oldValue: Boolean
    ) = withContext(Dispatchers.IO) {
            restaurantsDao.update(
               PartialRestaurant(id = id, isFavorite = 
                   !oldValue))
            restaurantsDao.getAll()
        }
    
  2. toggleFavorite() 方法内部,将 toggleFavoriteRestaurant() 方法返回的更新后的餐厅存储在 updatedRestaurants 变量中,然后,这次将 state.value = restaurants 行从协程外部移动到内部,同时让它接收由 updatedRestaurants 变量存储的值:

    fun toggleFavorite(id: Int) {
        val restaurants = state.value.toMutableList()
        […]
        storeSelection(restaurants[itemIndex])
        viewModelScope.launch(errorHandler) {
            val updatedRestaurants = 
                toggleFavoriteRestaurant(id, item.isFavorite)
            state.value = updatedRestaurants
        }
    }
    

在这里,我们没有使用前一个状态值中的 restaurants 值更新 state 对象的值。相反,我们传递了从 toggleFavoriteRestaurant() 函数获得的本地数据库中的餐厅。

既然我们已经将本地数据库作为数据的唯一真相来源,我们可能会认为我们的问题已经解决。然而,请记住,每次我们从服务器缓存具有相同 ID 的餐厅时,我们仍在覆盖本地餐厅的 isFavorite 字段值。

正因如此,最终问题在于 refreshCache() 方法:

private suspend fun refreshCache() {
    val remoteRestaurants = restInterface
        .getRestaurants()
    restaurantsDao.addAll(remoteRestaurants)
}

我们必须找到一种方法,在调用 restaurantsDao.addAll(remoteRestaurants) 时保留餐厅的 isFavorite 字段。

我们可以通过复杂化 refreshCache() 函数内部发生的逻辑来解决这个问题。

  1. refreshCache() 函数内部,添加以下代码:

    private suspend fun refreshCache() {
        val remoteRestaurants = restInterface
            .getRestaurants()
        val favoriteRestaurants = restaurantsDao
            .getAllFavorited()
        restaurantsDao.addAll(remoteRestaurants)
        restaurantsDao.updateAll(
            favoriteRestaurants.map {
                PartialRestaurant(it.id, true)
            })
    }
    

现在,让我们分解我们刚刚所做的事情:

i. 首先,就像之前一样,我们通过调用 restInterface.getRestaurants() 从服务器获取了餐厅(它们的 isFavorite 字段将默认设置为 false)。

ii. 然后,从 Room 中,我们通过调用 restaurantsDao.getAllFavorited() 获取了所有收藏的餐厅 – 我们还没有添加这个函数,所以如果你的代码还没有编译,请不要担心。

iii. 然后,就像之前一样,我们通过调用 restaurantsDao.addAll(remoteRestaurants) 将远程餐厅保存到 Room 中。这样,我们就覆盖了具有与 remoteRestaurants 相同 ID 的现有餐厅的 isFavorite 字段(将其设置为 false)。

iv. 最后,我们通过调用 restaurantsDao.updateAll() 部分更新了 Room 中所有的餐厅。我们将一个 PartialRestaurant 对象的列表传递给这个方法(我们尚未实现)。

这些对象是从之前缓存的 favoriteRestaurants 对象(类型为 Restaurant)映射到具有 isFavorite 字段设置为 true 的对象 PartialRestaurant。通过这种方法,我们现在已经恢复了那些最初缓存的已收藏餐厅的 isFavorite 字段值。

  1. RestaurantsDao 中,我们必须实现之前使用的两个方法:

    @Dao
    interface RestaurantsDao {
        […]
        @Update(entity = Restaurant::class)
    suspend fun updateAll(partialRestaurants: 
            List<PartialRestaurant>)
    @Query("SELECT * FROM restaurants WHERE 
            is_favorite = 1")
        suspend fun getAllFavorited(): List<Restaurant>
    }
    

我们添加了以下内容:

  • updateAll() 方法:这是一个部分更新,与 update() 方法的工作方式相同。这里唯一的区别是我们更新了一组餐厅的 isFavorite 字段,而不是只更新一个。

  • getAllFavorited() 方法:这是一个查询,就像 getAll() 方法一样,但更具体,因为它获取所有 isFavorite 字段值等于 1(代表 true)的餐厅。

我们终于完成了!现在是时候测试应用程序了!

  1. 尝试离线运行应用程序,然后标记几家餐厅为收藏。

  2. 将您的设备连接到互联网并运行应用程序——当您在线时。

您现在应该能够看到之前的选项——所有最初标记为收藏的餐厅现在在所有场景中都被持久化了。

然而,我们还有一件事要处理!

在进程重建的情况下移除持久化状态逻辑

现在我们的应用程序有一个单一的事实来源,即本地数据库:

  • 每当我们从服务器接收到餐厅信息时,我们会将它们缓存到 Room,然后使用 Room 中的餐厅信息刷新 UI。

  • 每当我们标记一家餐厅为收藏或非收藏时,我们会将选择缓存到 Room,然后同样地,我们使用 Room 中的餐厅刷新 UI。

这意味着如果发生系统启动的进程死亡,我们应该能够轻松地恢复 UI 状态,因为现在 Room 中的餐厅也缓存了 isFavorite 字段。

换句话说,我们的应用程序不再需要依赖 SavedStateHandle 来恢复已收藏或未收藏的餐厅;我们的应用程序的本地数据源现在将自动处理这一点。

让我们移除对系统启动的进程死亡的特殊处理:

  1. RestaurantsViewModel 中,移除 stateHandle: SavedStateHandle 参数:

    class RestaurantsViewModel() : ViewModel() { … }
    
  2. RestaurantsViewModel 中,移除 storeSelection()restoreSelections() 方法。

  3. 移除 RestaurantsViewModel 类的 companion object

  4. 当您仍然在 ViewModel 中时,从 toggleFavorite() 方法中移除所有与 stateHandle 变量相关的逻辑。现在该方法应如下所示:

    fun toggleFavorite(id: Int) {
        viewModelScope.launch(errorHandler) {
            val updatedRestaurants =
                toggleFavoriteRestaurant(id, item.isFavorite)
            state.value = updatedRestaurants
        }
    }
    

问题是我们不再有item变量,因此不知道应该将什么传递给toggleFavoriteRestaurant()函数的oldValue参数来代替item.isFavorite。我们需要修复这个问题。

  1. toggleFavorite()方法添加一个新参数,称为oldValue

    fun toggleFavorite(id: Int, oldValue: Boolean) {
        viewModelScope.launch(errorHandler) {
            val updatedRestaurants =
                toggleFavoriteRestaurant(id, oldValue)
            state.value = updatedRestaurants
        }
    }
    

这个Boolean参数应该告诉我们餐厅之前是否被标记为收藏。

  1. 然后,重构getRestaurants()方法,使其不再使用restoreSelections()方法。该方法现在应该看起来像这样:

    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            state.value = getAllRestaurants()
        }
    }
    
  2. 接下来,导航到RestaurantsScreen文件。然后,在RestaurantItem可组合组件内部,向onFavoriteClick回调函数添加另一个oldValue参数:

    @Composable
    fun RestaurantItem([…],
            onFavoriteClick: (id: Int, oldValue: Boolean)
                -> Unit,
            onItemClick: (id: Int) -> Unit) {
        ...
        Card(…) {
            Row(…) {
                [...]
                RestaurantDetails(...)
                RestaurantIcon(icon, Modifier.weight(0.15f)) 
                {
                    onFavoriteClick(item.id, item.isFavorite)
                }
            }
        }
    }
    

此外,确保在调用onFavoriteClick函数时,将item.isFavorite值传递给新添加的参数。

  1. RestaurantsScreen()可组合组件内部,确保您注册并传递新接收的oldValue函数参数到ViewModeltoggleFavorite方法:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        val viewModel: RestaurantsViewModel = viewModel()
        LazyColumn(…) {
            items(viewModel.state.value) { restaurant ->
                RestaurantItem(
                    restaurant,
                    onFavoriteClick = { id, oldValue ->
                        viewModel
                            .toggleFavorite(id, oldValue)
                    },
                    onItemClick = { id -> onItemClick(id) })
            }
        }
    }
    

我们完成了!现在,是时候模拟系统触发的进程死亡场景了。

  1. 构建项目并运行应用程序。

  2. 标记一些餐厅为收藏。

  3. 通过按设备/模拟器的 home 按钮将应用置于后台。

  4. 选择Logcat窗口,然后按左侧的红色矩形按钮来终止应用程序:

![图 6.4 – 模拟系统触发的进程死亡

![img/B17788_06_4.jpg]

图 6.4 – 模拟系统触发的进程死亡

  1. 从应用抽屉中重新启动应用程序。

因为应用程序依赖于本地数据库中保存的内容,所以它现在应该正确地显示 UI 状态,包括系统触发的进程死亡之前的已收藏餐厅。

作业

在本章中,我们确保在 Room 中缓存了餐厅,以便在没有互联网的情况下可以访问应用程序的第一个屏幕。作为家庭作业,你可以尝试重构应用程序的详细信息屏幕(显示特定餐厅的详细信息),如果用户在没有互联网的情况下进入应用程序,可以从 Room 获取其自己的数据。

摘要

在本章中,我们了解了 Room 是如何成为 Jetpack 库的必要组成部分的,因为它允许我们为应用程序提供离线功能。

首先,我们探讨了 Room 的核心元素,以了解如何设置私有数据库。其次,我们在 Restaurants 应用程序中实现了 Room,并探讨了如何从本地数据库保存和检索缓存内容。

之后,我们发现了部分更新是什么,以及如何实现它们以保留用户在应用程序中的选择。

在本章的末尾,我们了解了为什么对于应用程序的内容来说有一个单一的真实来源是有益的,以及这如何帮助我们处理边缘情况,例如系统触发的进程死亡。

在下一章中,我们将通过探索架构展示模式,更深入地探讨定义我们应用程序架构的各种方法。

第七章:第七章:介绍 Android 中的展示模式

在本章中,我们继续探索构建 Android 应用程序的方法。更确切地说,我们将通过引入展示模式来确保我们的应用程序正确地划分了责任。

在第一部分,介绍 MVC、MVP 和 MVVM 作为展示模式,我们将简要概述为什么我们需要展示模式,并探讨这些最常见模式在 Android 项目中的实现方式。

接下来,在 将我们的餐厅应用重构为适合展示模式 部分,我们将重构我们的餐厅应用以适应 MVVM 展示模式,同时理解为什么 MVVM 最适合我们的基于 Compose 的应用。

在上一节中,在 ViewModel 中改进状态封装,我们将看到为什么这对于 ViewModel 很重要,并且我们将探讨如何实现这一点。

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

  • 介绍 模型-视图-控制器MVC)、模型-视图-表示者MVP)和 模型-视图-视图模型MVVM)作为展示模式

  • 将我们的餐厅应用重构为展示模式

  • 在 ViewModel 中改进状态封装

在深入之前,让我们为这一章节设置技术要求。

技术要求

为了构建本章的基于 Compose 的 Android 项目,通常需要你日常使用的工具。然而,为了顺利跟进,请确保你拥有以下内容:

  • Arctic Fox 2020.3.1 版本的 Android Studio。你也可以使用更新的 Android Studio 版本,甚至是 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 在 Android Studio 中安装了 Kotlin 1.6.10 或更高版本的插件。

  • 上一个章节中的餐厅应用代码。

本章的起点是上一章开发的餐厅应用。如果你没有跟随上一章的实现,可以通过导航到存储库的 Chapter_06 目录并导入名为 chapter_6_restaurants_app 的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到 github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_07/chapter_7_restaurants_app 中的 Chapter_07 目录。

介绍 MVC、MVP 和 MVVM 作为展示模式

在开始之前,大多数 Android 项目都是设计成一系列设置对应可扩展标记语言XML)布局的 ActivityFragment 类。

随着项目的增长和新功能的请求,开发者必须在ActivityFragment类中添加更多逻辑,一个开发周期接着一个开发周期。这意味着任何针对特定屏幕的新功能、改进或错误修复都必须在这些ActivityFragment类中进行。

经过一段时间,这些类变得越来越大,在某个时候,添加改进或修复错误可能变得像噩梦一样。原因在于ActivityFragment类承担了特定项目内的所有责任。这些类会执行以下操作:

  • 定义 UI

  • 准备要显示的数据并定义不同的 UI 状态

  • 从不同的来源获取数据

  • 将不同的业务规则应用于数据

*这种方法在项目的不同责任和关注点之间引入了耦合。对于这样的项目,例如,如果需要更改 UI 的一部分,你的更改可能会轻易影响应用程序的其他关注点:数据展示的方式、获取数据逻辑、业务规则等等。

这种情况最糟糕的部分是,当你只需要更改一部分(比如 UI 的一部分)时,你最终会改变其他部分(比如表示层或数据逻辑),你可能会破坏原本正常工作的相关部分,因此可能引入新的错误。

采用所有项目代码都捆绑在ActivityFragment类中的方法,会导致你的项目出现以下问题:

  • 脆弱且难以扩展:添加新功能或改进可能会破坏应用程序的其他部分。

  • 难以测试:由于应用程序的所有逻辑都捆绑在一个地方,因此仅测试逻辑的一部分非常困难,因为所有逻辑都纠缠在一起,并且与平台相关的依赖项紧密相连。

  • 难以调试:当责任交织在一起时,你的代码库的各个部分也会交织并耦合。调试一个特定问题变得极其困难,因为很难追踪到确切的罪魁祸首。

为了缓解这些问题,我们可以尝试确定应用程序的核心责任,然后将相应的逻辑和代码分离到不同的组件(或类)中,这些组件(或类)是特定层的组成部分。这样,我们试图遵循关注点分离SoC)的原则,其中每个层将包含仅与其对应层关注点紧密相关的类。

为了确保我们的项目遵守 SoC 原则,我们可以将应用程序的责任分为两大类,并为每一类定义一个层,如下所示:

  • 表示层包含负责定义 UI 和准备要展示的数据的类(或其他组件)。

  • 模型层包含获取、建模和更新应用程序数据的类。

即使这两层似乎要做的事情不止一件,但所有这些行为定义了一个更广泛的专用责任,它封装了特定的关注点。

在本章中,虽然我们将主要关注结构化表示层,但我们也将开始构建模型层。我们将在第八章中继续重构模型层,Android 中的 Clean Architecture 入门

为了在表示层内部分离关注点,你可以使用表示设计模式。表示设计模式是定义我们的应用程序中表示层结构的架构模式。

表示层是我们项目中与用户所见内容相关联的部分:UI 及其表示。换句话说,表示层处理与两种类型逻辑相关的两个粒度细小但相关的责任,如下所述:

  • UI 逻辑:定义了在单个屏幕或流程中以特定方式显示内容的能力。例如,当为屏幕构建 XML 布局或组合层次结构时,我们正在定义该特定屏幕的 UI 逻辑,因为我们正在定义其 UI 元素。

  • 表示逻辑:定义 UI(对于单个屏幕或流程)的状态以及当用户与我们的 UI 交互时如何变化,从而定义数据如何呈现给 UI。当我们必须执行以下操作时,我们正在编写表示逻辑:

    • 确保屏幕在特定时间处于加载状态或错误状态

    • 以特定方式格式化内容以呈现屏幕

为了定义 UI 逻辑和表示逻辑,表示层需要一些数据来工作。这就是为什么它必须连接到模型层,模型层提供原始数据,无论是来自网络服务、本地数据库还是其他来源。以下图表展示了这一过程:

图 7.1 – 表示层及其与模型层的关系

图 7.1 – 表示层及其与模型层的关系

图 7.1 – 表示层及其与模型层的关系

目前,我们将模型层视为一个黑盒,它只是为我们提供数据。

我们可以说,这样的分离使得 UI 可以通过表示层内部发生的转换成为模型数据的表示,同时拥有责任不重叠的组件。

在 Android 中,从表示层内部进行的转换是通过以下三种流行的表示模式来建模的,这些模式在其他技术堆栈中也被广泛使用:

  • MVC

  • MVP

  • MVVM

    注意

    作为 Android 开发者,我们已经调整了这些展示模式的实现,以适应 Android 的特定需求。这就是为什么我们展示或实现它们的方式可能与创始人给出的原始定义有所不同——所有这些都是为了观察它们在 Android 项目中的常见用法。

这些展示模式将使我们能够将每个屏幕或应用内流程的 UI 逻辑与展示逻辑分离。通过这样做,我们确保我们的表示层具有更少的耦合代码,更容易维护,更容易随着新功能扩展,更容易测试。

历史上,大多数 Android 项目已经从 MVC 转向 MVP,如今又转向 MVVM。然而,无论它们的结构如何,重要的是要提到,这些展示模式所推崇的 SoC(分离关注点)通常意味着每个 UI 流程都被分解成执行特定任务的类或组件,这些组件与它们的职责紧密相关。

为了了解我在说什么,让我们简要地介绍一下它们,从 MVC 开始。

MVC

在 Android 项目中,MVC 模式的常见实现定义其层如下:

  • 视图:从 XML 布局中填充的视图,作为 UI 的表示。这一层只会将控制器接收到的内容渲染到屏幕上。

  • ActivityFragment。这个组件将通过准备从模型层接收到的数据以供展示,或通过拦截 UI 事件(这些事件反过来会改变状态)来定义 UI 的状态。此外,控制器将负责将实际数据设置到视图层。

  • 模型:数据的入口点。实际的结构不依赖于 MVC,但我们可以将其视为通过查询本地数据库或远程来源(如 Web 应用程序编程接口 (API))获取所需内容的层。

让我们通过以下方式可视化这种模式带来的实际分离:

图 7.2 – MVC 模式中的表示层

图 7.2 – MVC 模式中的表示层

这种 MVC 模式的实现实现了表示层和模型层之间的适当分离,因此解放了 ActivityFragment 控制器,不再需要它们从 REpresentational State Transfer (REST) API 或本地数据库中获取数据。然而,至少在这个形式因素中,MVC 并没有在它应该发光的地方发光,因为表示层内部的实际分离可以进一步改进。

这种模式的缺点可能包括以下内容:

  • 控制器(ActivityFragment 控制器)和视图层之间的高耦合。由于控制器是一个具有生命周期的组件,它还必须提供构建和设置 Android 视图的基础设施,包括内容(如构建 Adapter 类和传递数据),因此测试它变得困难,因为它与 Android API 紧密耦合。

  • 控制器有两个职责:它处理 UI 的状态(表示逻辑),同时也为视图层提供基础设施以使其功能(UI 逻辑)。这两个职责变得纠缠在一起——当你测试其中一个时,你也会测试另一个。

让我们继续探讨 Android 中另一种流行的表示模式。

MVP

Android 项目中 MVP 模式的一种常见实现定义其层如下:

  • ActivityFragment类及其从 XML 中膨胀的视图。这一层现在封装了整个 UI 逻辑:它提供了构建和设置渲染 Android 视图的基础设施,并包含内容。

  • ActivityFragment)被建立。该接口允许表示者将准备好的用于表示的数据传递给 UI 层,并在 UI 级别直接修改 UI 状态。

与 MVC 中的控制器不同,表示者不再与生命周期组件或 Android View API 耦合,因此测试它包含的表示逻辑变得更加容易。

  • 模型:与 MVC 中的相同。

让我们可视化这种模式带来的实际分离,如下所示:

图 7.3 – MVP 模式中的表示层

图 7.3 – MVP 模式中的表示层

与 MVC 不同,ActivityFragment现在是视图层的一部分,这看起来更自然,因为它们都与 Android UI 紧密相关。这种方法允许表示者准备必须表示的数据,并命令式地修改 UI。

由于我们现在有一个负责向 UI 表示数据的独立实体,我们可以说,与 MVC 不同,MVP 在表示层内部执行 SoC(分离关注点)做得更好。

然而,这种方法仍然存在一些问题,如下所述:

  • 表示者手动直接在ActivityFragment类中更新 UI 的命令式方法可能会随着项目的增长和新功能的添加而容易出错,并可能导致非法 UI 状态(例如同时显示错误信息和加载状态)。这与 UI 控制器(如Activity)也命令式地修改 XML 视图的方法类似——当我们引入具有声明性范式的 Compose 时,我们认为这种方法容易出问题。

  • 如果表示层和视图层之间的接口合同设计不当或完全缺失,两者将变得耦合,为其他ActivityFragment控制器重用表示者可能会很困难。

让我们继续探讨另一种重要的表示模式。

MVVM

MVVM 是 Android 中非常流行的一种表示模式,主要是因为它解决了之前提到的 MVP 实现中提到的问题。

Android 项目中 MVVM 的一种常见实现定义其层如下:

图 7.4 – MVVM 模式中的表示层

图 7.4 – MVVM 模式中的表示层

让我们看看层是如何定义的:

  • ActivityFragment类及其 XML 视图,就像在 MVP 中一样。不过,与 MVP 不同的是,视图层观察来自ViewModel的可观察状态或可观察字段,两者都包含 UI 数据。每当从这些可观察实体接收到新更新时,视图层就会使用接收到的内容更新 UI。

  • ViewModel将 UI 状态定义为可观察属性(或多个可观察字段),并且与视图层完全解耦,因为它没有对其的引用。

  • 模型:与 MVC 或 MVP 相同。

与 MVP 中的 Presenter 相比,ViewModel的一个优点是它不再与视图层耦合,因此可以更容易地重用。与 MVP 相比,视图层负责引用ViewModel以获取和观察可观察状态,因此ViewModel不再需要引用视图层,变得完全独立。

换句话说,MVVM 中的ViewModel强制视图层订阅数据,这与 MVP 不同,在 MVP 中,Presenter 手动设置视图层与数据。这种方法允许多个视图绑定到同一个ViewModel,因此在同一ViewModel共享相同的 UI 状态。

另一个优点是,由于视图层从ViewModel观察 UI 状态并将其作为效果绑定,ViewModel不再像 MVP 中的 Presenter 那样通过接口强制更新 UI。换句话说,视图层从ViewModel获取 UI 状态并将其绑定到 UI——这导致数据流向单向,不太可能引入错误或非法状态。

注意

在考虑 MVVM 的原始定义时,不应将 ViewModel 与 Jetpack ViewModel 组件混淆——ViewModel 可以是一个简单的类,通过可观察状态来呈现数据。对于我们来说,然而,将 Jetpack ViewModel 视为 MVVM 的实际 ViewModel 是方便的,因为它带来了一些即开即用的优点。

然而,在 Android 中常用模式实现中,将 Jetpack ViewModel 视为 MVVM 中的 ViewModel,这既带来了一系列优点,也带来了一些缺点。

使用 Jetpack ViewModel 作为 MVVM 中的ViewModel有以下好处:

  • Jetpack ViewModel 的作用域与 View 的生命周期相同,并提供方便的 API 来取消工作,例如onCleared()回调或viewModelScope协程作用域,因此提供了一个方便的 API 来取消异步任务并最小化内存泄漏的风险。

  • Jetpack ViewModel 能够存活于配置更改中,因此允许你在用户更改设备方向等情况下自动保留 UI 状态。

  • 由于 Jetpack ViewModel 提供了SavedStateHandle对象,你可以轻松地在系统启动的进程死亡后恢复 UI 状态。

不幸的是,这种方法存在以下缺点:

  • 现在,ViewModel已经成为一个库依赖(Jetpack ViewModel),它引入了与 Android 平台的耦合(因为它暴露了如SavedStateHandle之类的 API)。这阻止了我们为跨平台项目使用Kotlin MultiplatformKMP)重用表示组件。

  • 由于 Jetpack ViewModel 是一个除了数据表示之外还处理其他责任的库依赖,例如在系统启动进程死亡后恢复 UI 状态,我们可以认为表示层的关注点并没有很好地分离。

现在我们对表示模式有了快速的了解,是时候来一个实际例子了。

将我们的餐厅应用程序重构以适应表示模式

我们计划将我们的餐厅应用程序重构以适应表示模式。从我们之前的比较中,我们可以认为 MVVM 最适合我们的基于 Compose 的应用程序。不用担心——我们稍后会详细讨论这个决定。

但在我们这样做之前,让我们在应用程序中添加更多功能,以更好地突出责任混合可能导致代码难以维护。

总结来说,在本节中,我们将进行以下操作:

  • 在我们的餐厅应用程序中添加更多功能

  • 将我们的餐厅应用程序重构为 MVVM

让我们开始吧!

在我们的餐厅应用程序中添加更多功能

当餐厅应用程序启动时,RestaurantsScreen()可组合组件被渲染。在这个屏幕内部,我们正在从服务器加载一系列餐厅,然后我们将它们展示给用户。

尽管我们的应用程序正在等待网络请求完成以及本地缓存到 Room 的操作(以便它能够为 UI 接收餐厅信息),但屏幕仍然保持空白,用户对正在发生的事情一无所知。为了提供更好的用户体验UX),我们应该以某种方式向用户暗示我们正在等待从服务器获取内容。

我们可以通过一个加载进度条来实现这一点!在RestaurantsScreen()可组合组件内部,我们可以添加一个在LazyColumn可组合组件(渲染餐厅列表)填充之前显示的加载 UI 元素。当内容到达时,我们应该隐藏它,从而让用户知道应用程序已经加载了其内容。

让我们立即按照以下方式执行:

  1. 首先,在RestaurantsScreen()可组合组件内部,将状态中的餐厅列表(从RestaurantsViewModel检索)保存到restaurants变量中,如下所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        val viewModel: RestaurantsViewModel = viewModel()
        val restaurants = viewModel.state.value
        LazyColumn(…){
            items(restaurants) { restaurant ->
                RestaurantItem(…)
            }
        }
    }
    

确保还将restaurants变量传递给LazyColumn可组合组件的items领域特定语言DSL)函数。

  1. 我们需要定义一个条件,让我们知道何时显示加载指示器。作为一个初步尝试,我们可以说当 restaurants 变量包含一个空的 List<Restaurant> 作为值时,这意味着餐厅尚未到达,内容仍在加载。添加一个 isLoading 变量来考虑这一点,如下所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        val viewModel: RestaurantsViewModel = viewModel()
        val restaurants = viewModel.state.value
        val isLoading = restaurants.isEmpty()
        LazyColumn(…){ … }
    }
    

然而,如果餐厅从服务器到达,state 变量将被更新,并且 restaurants 变量不再包含空的餐厅列表。此时,isLoading 变量变为 false

  1. 我们希望在 isLoading 变量为 true 时显示加载指示器。要做到这一点,将 LazyColumn 可组合组件包裹在一个 Box 可组合组件中,并在 LazyColumn 代码下方检查 isLoading 变量是否为 true,并传递一个 CircularProgressIndicator 可组合组件。代码如下所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        …
        val isLoading = restaurants.isEmpty()
        Box() {
            LazyColumn(…){…}
            if(isLoading)
                CircularProgressIndicator()
        }
    }
    

Box 可组合组件允许我们叠加两个可组合组件:LazyColumnCircularProgressIndicator。由于我们添加了 if 条件,我们现在有以下两种情况:

  • isLoadingtrue(应用正在等待餐厅),因此两个可组合组件都被组合。当 CircularProgressIndicator 可组合组件显示在 LazyColumn 可组合组件之上时,LazyColumn 可组合组件不包含任何元素,因此不可见。

  • isLoadingfalse(应用现在有要显示的餐厅),因此只有 LazyColumn 可组合组件被组合并可见。

  1. 要使 CircularProgressIndicator 可组合组件居中,请将 Alignment.Center 对齐方式添加到 Box 可组合组件的 contentAlignment 参数中,同时传递一个 Modifier.fillMaxSize() 修饰符。代码如下所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        …
        Box(contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize()) {
            …
        }
    }
    
  2. 构建并运行应用。在一段时间内(直到餐厅加载完成),你应该看到一个加载进度指示器。当餐厅显示时,这个指示器应该消失。

在 UI 层中,我们现在已经添加了加载指示器以及决定何时显示它的逻辑。在这个简单场景中,我们的逻辑是有效的,但如果服务器(或本地数据库)返回一个空的餐厅列表,会发生什么呢?那么加载指示器将永远不会消失。

或者,如果发生错误会发生什么?我们的 RestaurantsScreen 可组合组件并不知道错误已被生成。这意味着它不仅不知道何时显示错误,而且也不知道如果发生此类错误,何时隐藏加载指示器。

这些问题源于我们试图在 UI 层(可组合组件所在之处)中定义展示逻辑(何时显示或隐藏加载指示器;何时显示错误消息),从而将 UI 逻辑与展示逻辑混合在一起。

我们现在可以看到一些由将 UI 逻辑与展示逻辑混合而产生的限制,但还有一个事实是,在前几章中,我们已经将展示逻辑与数据逻辑混合了。我们当前方法的长远影响是令人恐惧的:调试将变得困难,测试更是如此。

是时候重构我们的餐厅应用为 MVVM,以便我们更好地分离其职责。

重构我们的餐厅应用为 MVVM

为了更好地分离职责,我们将选择最流行的展示模式:MVVM。尽管它有缺陷,但当你将其与我们之前给出的 MVC 和 MVP 定义进行比较时,它目前是最佳候选人,以下是一些原因:

  • 它在 UI 逻辑和展示逻辑之间提供了很好的分离。

  • 我们的 UI 层(可组合组件)被设计成期望一个可观察的状态(更确切地说,是 Compose State 对象),就像 MVVM 中的 ViewModel 被设置为暴露的那样。

现在,我们的餐厅应用已经使用了 Jetpack ViewModel(它暴露了一个 Compose State 对象,该对象在可组合组件内部被观察和消费),因此我们可以这样说,我们不知不觉地开始实现这个修改后的模式,其中 Jetpack ViewModel 是 MVVM 中的 ViewModel。

注意

目前,我们认为使用 Jetpack ViewModel 作为 MVVM 中的 ViewModel 的优点超过了它带来的缺点,因此我们将保持现状。

然而,仅仅因为我们使用了 ViewModel,并不意味着我们也正确实现了 MVVM 展示模式。让我们首先看看我们如何为显示餐厅列表的第一个屏幕构建组件和类。你可以看到这里的样子:

![图 7.5 – MVVM 模式中每层组件职责分离不良]

![img/B17788_07_5.jpg]

图 7.5 – MVVM 模式中每层组件职责分离不良

对于这个屏幕,我们注意到两个违反规则的地方,即层包含超过一个职责,如下所述:

  • 视图层(由 RestaurantsScreen() 可组合组件表示)执行 UI 逻辑和展示逻辑。虽然这个可组合组件应该只包含 UI 逻辑(消费状态内容的无状态可组合组件),但当计算 isLoading 变量时,一些展示逻辑隐藏在其中,如下面的代码片段所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        …
        val isLoading = restaurants.isEmpty()
        …
    }
    

可组合组件不应该负责决定它们自己的状态,就像在这个案例中——RestaurantsScreen() 可组合组件不应该持有展示逻辑;相反,这应该移动到 ViewModel 内部。

  • RestaurantsViewModel 类包含展示逻辑(例如,持有和更新 UI 的状态)和数据逻辑(因为它在获取和缓存餐厅时与 Retrofit 服务 Room 数据访问对象DAO)一起工作),如下面的代码片段所示:

    class RestaurantsViewModel() : ViewModel() {
        private var restInterface: RestaurantsApiService
        private var restaurantsDao = ...
        val state = mutableStateOf(emptyList<Restaurant>())
        private suspend fun getAllRestaurants(): … {…}
            ...
        private suspend fun refreshCache() {...}
    }
    

很明显,当 state 变量更新时会发生表示逻辑,但还有大量的数据逻辑,当从 restInterface 变量获取餐厅信息,然后将其缓存在 restaurantsDao 变量中,等等。

所有这些数据逻辑不应位于 ViewModel 内部,而应位于模型层,因为 ViewModel 应仅呈现数据,而不关心数据源及其使用方式——它只知道它应该接收一些数据。

现在,让我们看看我们应该如何正确地构建我们的类(遵循 MVVM)以显示餐厅列表的第一流程。组件应该看起来像这样:

图 7.6 – MVVM 模式中每层具有良好分离责任的组件

图 7.6 – MVVM 模式中每层具有良好分离责任的组件

在之前的图中,每个组件都处理自己的责任,如下所示:

  • View 组件仅包含具有 UI 逻辑的可组合项(RestaurantsScreen)(消费 UI 状态)。

  • ViewModel 组件(RestaurantsViewModel)仅包含表示逻辑(持有 UI 状态并对其进行修改)。

  • 模型组件(我们将创建一个 RestaurantsRepository 类——稍后会更详细地介绍)仅包含数据逻辑(从远程源获取餐厅信息,将其缓存在本地源中等)。

为了实现这种分离,在本节中,我们将执行以下操作:

  • 将 UI 逻辑与表示逻辑分离

  • 将表示逻辑与数据逻辑分离

让我们开始吧!

将 UI 逻辑与表示逻辑分离

UI 逻辑(渲染可组合项)已经在 UI 层(Compose UI)中,因此我们从这个角度来看不需要做任何事情。然而,我们需要将表示逻辑从 UI 层提取到 ViewModel 中,那里应该是它的位置。

更具体地说,从 RestaurantsScreen() 可组合项内部,我们希望将 isLoading 变量的计算移动到 RestaurantsViewModel 类中,仅仅因为 ViewModel 应该决定并且更清楚地知道屏幕何时应该处于加载状态。

为了做到这一点,我们将创建一个 state 类,它将保存 UI 需要的所有信息,以便渲染正确的状态。这种方法更有效率,因为 ViewModel 负责请求数据,因此更清楚地知道何时内容到达等。因此,稍后我们将非常容易地允许 ViewModel 决定屏幕何时必须显示错误状态。按照以下步骤进行操作:

  1. 创建一个将模拟 RestaurantsScreen() 可组合的 UI 状态的类。通过点击应用程序包,将 RestaurantsScreenState 作为名称,并选择 restaurants 列表和 isLoading 标志。以下代码片段展示了这一过程:

    data class RestaurantsScreenState(
        val restaurants: List<Restaurant>,
        val isLoading: Boolean)
    

由于我们使用了data class而不是常规的class,我们将能够轻松地使用.copy()函数对这个对象进行修改,从而确保 Compose 的state对象将接收到一个新的对象,并知道触发重新组合。

  1. RestaurantsViewModel类中,更新state变量的初始状态值并传递一个RestaurantsScreenState对象,如下所示:

    class RestaurantsViewModel() : ViewModel() {
        …
        val state = mutableStateOf(
            RestaurantsScreenState(
                restaurants = listOf(),
                isLoading = true)
        )
        …
    }
    

我们将restaurants字段标记为空列表,并且isLoadingtrue,因为从这一点开始,我们正在等待餐厅信息,UI 应该渲染加载状态。

  1. 仍然在RestaurantsViewModel类中,找到getRestaurants()方法并更新我们更新state变量的方式,如下所示:

    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            val restaurants = getAllRestaurants()
            state.value = state.value.copy(
                restaurants = restaurants,
                isLoading = false)
        }
    }
    

我们首先将餐厅信息存储在restaurants变量中。然后,我们使用copy()函数将接收到的新的餐厅列表传递给restaurants字段,并将isLoading字段标记为false,因为数据已经到达,UI 不再需要渲染加载状态。

  1. 仍然在RestaurantsViewModel类中,确保toggleFavorite()方法正确地使用copy()函数更新state变量对象,如下所示:

    fun toggleFavorite(id: Int, oldValue: Boolean) {
        viewModelScope.launch(errorHandler) {
            val updatedRestaurants = […]
            state.value = state.value.copy(restaurants =      
                updatedRestaurants)
        }
    }
    

好了——我们已经在ViewModel中添加了所有的展示逻辑,现在是时候更新 UI(我们的 composables)以渲染新的可能的 UI 状态。

  1. 重构RestaurantsScreen() composable 以消费新的 UI 状态内容,如下所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        val viewModel: RestaurantsViewModel = viewModel()
        val state = viewModel.state.value
        Box(…) {
            LazyColumn(…) {
                 items(state.restaurants) {…}
            }
            if (state.isLoading)
                CircularProgressIndicator()
        }
    }
    

让我们分解我们所做的工作,如下所示:

  • 我们将restaurants变量重命名为state,以更好地表明这个变量持有这个屏幕的状态。

  • 我们将state.restaurants传递给LazyColumn composable 的items DSL 函数。

  • 我们删除了这一行:val isLoading = restaurants.isEmpty()

  • 我们根据state.isLoading的值更新了显示CircularProgressIndicator()的条件——在这个 composable 中不再需要决策逻辑。

  1. 构建并运行应用。

你应该能够看到加载指示器,就像之前一样,但不同之处在于展示逻辑更好地分离并由ViewModel持有。使用我们的新方法,如果出于任何原因我们从数据源(Retrofit 和 Room)收到空列表,应用程序不会表现不佳并显示加载状态,因为 UI 正在检查列表是否为空。

要了解如何简单地向我们的 Compose-based UI 添加新状态,让我们继续设置在ViewModel内部抛出任何错误时的错误状态。

  1. RestaurantsScreenState类中,添加一个error: String参数,用于存储可能发生的错误信息,如下所示:

    data class RestaurantsScreenState(
        val restaurants: List<Restaurant>,
        val isLoading: Boolean,
        val error: String? = null
    )
    

为了简化我们在ViewModel内部处理状态的工作,我们将error字段的默认值设置为null,因为屏幕的初始状态不应该包含任何错误。

  1. RestaurantsViewModel类中,找到我们用来捕获可能由我们的协程抛出的任何异常的errorHandler变量,并通过传递exception.message错误消息到error字段来更新state对象。代码如下所示:

    class RestaurantsViewModel() : ViewModel() {
        …
        private val errorHandler =
            CoroutineExceptionHandler { _, exception ->
                exception.printStackTrace()
                state.value = state.value.copy(
                    error = exception.message,
                    isLoading = false
                )
            }
        ...
    }
    

此外,我们已将新状态中的isLoading字段设置为false,仅仅是因为如果抛出错误,我们不想让 UI 保持在加载状态。

如果您想在错误发生后显示并按下重试按钮,您必须在按下该按钮时将error字段设置为null,这样 UI 就不会无限期地保持在错误状态。

  1. RestaurantsScreen()可组合函数中,在Box可组合函数内添加另一个if语句。此语句检查state对象是否包含要显示的错误消息,如果是true,则添加一个Text可组合函数来显示错误消息。代码如下所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        …
        Box(…) {
            LazyColumn(...) {…}
            if (state.isLoading)
                CircularProgressIndicator()
            if (state.error != null)
                Text(state.error)
        }
    }
    
  2. 构建项目,现在,让我们测试错误场景。要看到错误消息,我们需要模拟一个错误。

如果您还记得,在我们的RestaurantsViewModel类的getAllRestaurants()方法中,我们检查是否从服务器(Retrofit 客户端)检索餐厅失败,并且如果 Room DAO 也为空,我们抛出此错误消息:“出了点问题。我们没有数据。”

为了重现此场景,请确保以下条件成立:

  • 您已清除应用程序的缓存。为此,在您的设备或模拟器中,进入设置,然后进入应用程序,搜索我们的餐厅应用并点击它。然后,点击存储和缓存,然后点击清除存储

  • 您的设备/模拟器已断开互联网连接。

  1. 运行应用程序。您应该在屏幕中央看到此消息:“出了点问题。我们没有数据。”

    注意

    为了简化,我们确保 UI 逻辑仅在我们应用程序的第一个屏幕中与展示逻辑分离。当您想要将逻辑移动到相应的类中,从而确保 SoC 时,您需要确保为应用程序中的所有其他屏幕以及它们的ViewModel类等执行此操作。

现在我们已经将 UI 逻辑与展示逻辑分离,是时候分离一些数据逻辑了。

分离展示逻辑和数据逻辑

虽然RestaurantsViewModel类因为与 Retrofit 服务和 Room DAO 交互以获取和缓存餐厅数据而包含数据逻辑,但它应该只包含展示逻辑,因为其核心责任是管理 UI 状态。

我们的RestaurantsViewModel积累了大量逻辑的另一个迹象是,它目前大约有 90 行代码——这看起来可能并不多,但请记住,我们的应用程序相当简单,我们只有很少的展示逻辑,所以 90 行对于生产就绪的应用程序来说肯定会变成数千行。

我们希望将数据逻辑从RestaurantsViewModel移入一个不同的类。由于数据逻辑是应用程序模型层的一部分,在本节中,我们将开始探讨如何借助Repository类来定义模型层。

Repository模式代表了一种在应用程序内部抽象数据访问的策略。换句话说,Repository 类隐藏了从服务器解析数据、将其存储在本地数据库或执行任何缓存/刷新机制的所有复杂性。

在我们的应用中,RestaurantsViewModel类必须决定是否从restInterface(远程)源或从restaurantsDao(本地)源获取数据,同时确保刷新缓存。以下代码片段显示了执行的相关代码:

class RestaurantsViewModel() : ViewModel() {
    private var restInterface: RestaurantsApiService
    private var restaurantsDao = [...]
        ...
    private suspend fun refreshCache() {...}
}

这显然是错误的。ViewModel不应该关心调用哪个特定的数据源,因为它不应该需要是那个启动本地源缓存的源。ViewModel应该只关心接收一些内容,它将为展示做准备。

通过创建一个将抽象所有数据逻辑的Repository类来从RestaurantsViewModel类中移除这个负担,因为它将与两个数据源(网络 API 和 Room DAO)交互以执行以下操作:

  • 向表示层提供一个List<Restaurant>对象

  • 处理任何缓存逻辑,例如从网络 API 检索餐厅并将其缓存到 Room 本地数据库

  • 定义数据的单一事实来源SSOT)——Room 数据库

为了做到这一点,我们必须只将数据逻辑从ViewModel中移出,并在Repository类中将其分离。让我们开始,如下所示:

  1. 通过点击应用程序包,将名称设置为RestaurantsRepository并选择作为类型来创建一个Repository类:

    class RestaurantsRepository { }
    

现在,让我们开始移动一些代码!

  1. RestaurantsViewModel类内部,从init块中剪切restInterface变量及其初始化逻辑,并将其粘贴到RestaurantsRepository中,如下所示:

    class RestaurantsRepository {
        private var restInterface: RestaurantsApiService =
            Retrofit.Builder()
                .addConverterFactory(…)
                .baseUrl(…)
                .build()
                .create(RestaurantsApiService::class.java)
    }
    
  2. 对于restaurantsDao变量,也执行相同的操作,如下所示:

    class RestaurantsRepository {
        private var restInterface: RestaurantsApiService = …
        private var restaurantsDao = RestaurantsDb
            .getDaoInstance(
                RestaurantsApplication.getAppContext())
    }
    
  3. RestaurantsViewModel类内部,添加一个repository变量,并使用RestaurantsRepository()构造函数对其进行实例化,如下所示:

    class RestaurantsViewModel() : ViewModel() {
        private val repository = RestaurantsRepository()
        val state = mutableStateOf(…)
        private val errorHandler = CoroutineExceptionHandler { … } 
        init {
            getRestaurants()
        }
         […]
    }
    

确保RestaurantsViewModel不再包含restInterface变量、restaurantsDao变量或它们在init块中的初始化代码。

  1. RestaurantsViewModel类的toggleFavoriteRestaurant()getAllRestaurants()refreshCache()方法移动到RestaurantsRepository类,如下所示:

    class RestaurantsRepository {
        private var restInterface: RestaurantsApiService = …
        private var restaurantsDao = […]
        private suspend fun toggleFavoriteRestaurant(…) = […]
        private suspend fun getAllRestaurants(): […] { … }
        private suspend fun refreshCache() { … }
    }
    
  2. 确保除了init { }块之外,RestaurantsViewModel类只包含toggleFavorite()getRestaurants()方法,如下所示:

    class RestaurantsViewModel() : ViewModel() {
        […]
        init { getRestaurants() }
        fun toggleFavorite(id: Int, oldValue: Boolean) {…}
        private fun getRestaurants() {…}
    }
    
  3. RestaurantsRepository 类中,移除 getAllRestaurants()toggleFavoriteRestaurant() 方法的 private 修饰符,因为 RestaurantsViewModel 需要调用它们,所以它们必须是公开的。代码如下所示:

    class RestaurantsRepository {
        […]
        suspend fun toggleFavoriteRestaurant(…) = […]
        suspend fun getAllRestaurants(): […] { … }
        private suspend fun refreshCache() { … }
    }
    
  4. 返回到 RestaurantsViewModel 类内部,更新 getRestaurants() 方法,现在调用 repository.getAllRestaurants(),如下所示:

    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            val restaurants = repository.getAllRestaurants()
            state.value = state.value.copy(…)
        }
    }
    
  5. 仍然在 RestaurantsViewModel 类中,更新 toggleFavorite() 方法,现在调用 repository.toggleFavoriteRestaurant(),如下所示:

    fun toggleFavorite(id: Int, oldValue: Boolean) {
        viewModelScope.launch(errorHandler) {
            val updatedRestaurants = repository
                .toggleFavoriteRestaurant(id, oldValue)
            state.value = state.value.copy(…)
        }
    }
    

完成了!虽然第一个屏幕的功能应该保持不变,但我们现在已经在这个第一个流程中划分了责任,不仅是在展示层内部,而且在展示层和模型层之间。

任务

你可以在“餐厅”应用的详情屏幕上尝试练习本节学到的内容。

接下来,让我们暂时回到展示层,检查 UI 状态是如何从我们的 ViewModel 中暴露出来的。

提高 ViewModel 中的状态封装

让我们看看 RestaurantsViewModel 类中 UI 状态的定义,如下所示:

class RestaurantsViewModel() : ViewModel() {
    …
    val state = mutableStateOf(RestaurantsScreenState(
        restaurants = listOf(),
        isLoading = true))
    …
}

RestaurantsViewModel 中,我们使用 MutableState<RestaurantsScreenState> 推断类型在 state 变量中持有状态。这个变量是公开的,因此在内层 UI 中,从 RestaurantsScreen() 组合函数内部,我们可以通过访问 viewModel 变量并直接获取 state 对象来消费它,如下所示:

@Composable
fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
    val viewModel: RestaurantsViewModel = viewModel()
    val state = viewModel.state.value
    Box(…) {…}
}

这种方法的问题可能并不明显,但既然 state 变量是 MutableState 类型,我们不仅能够读取它的值,还能够写入它的值。换句话说,从组合 UI 层内部,我们通过 .value 访问器对 state 变量有写访问权限。

这里的危险在于,我们(或我们开发团队中的其他同事)可能会错误地从 UI 层更新 UI 状态,如下所示:

@Composable
fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
    val viewModel: RestaurantsViewModel = viewModel()
    val state = viewModel.state.value
    Box(…) {…}
    viewModel.state.value = viewModel.state.value.copy(
        isLoading = false)
}

你可以尝试添加之前突出显示的代码行,但之后要将其删除!

这代表了展示层内责任的违反:UI 层不应该执行展示逻辑。换句话说,UI 层不应该能够修改存储在 ViewModel 中的自己的状态;相反,只有 ViewModel 应该有这个权利。

这样,ViewModel 是唯一负责展示逻辑的实体,例如定义或修改 UI 状态。同时,我们展示模式内的责任将得到适当的划分和尊重。

为了解决这个问题,我们必须以某种方式强制 RestaurantsViewModel 类公开一个类型为 Statestate 变量,而不是 MutableState。这将防止 UI 层意外地修改自己的状态。

我们可以通过使用 Kotlin 的state变量来实现这一点。这个特性表明,如果一个类有两个在概念上相同的属性,但其中一个属于公共 API,另一个是实现细节,我们可以使用下划线作为私有属性的名称前缀。

让我们通过直接在代码中应用它来了解这意味着什么,如下所示:

  1. 首先,在RestaurantsViewModel类中,让我们防止我们的state变量被访问,因为它属于MutableState类型,如下所示:

    class RestaurantsViewModel() : ViewModel() {
        …
        private val state = mutableStateOf(…)
        …
    }
    
  2. 然后,仍然在RestaurantsViewModel类中,将state变量重命名为_state。你可以通过选择state变量,然后按Shift + F6来实现这一点。确保所有之前的state使用现在都称为_state。代码在以下片段中展示:

    class RestaurantsViewModel() : ViewModel() {
        …
        private val _state = mutableStateOf(…)
        private val errorHandler =
            CoroutineExceptionHandler {
                …
                exception.printStackTrace()
                _state.value = _state.value.copy(…)
            }
        […]
        fun toggleFavorite(id: Int, oldValue: Boolean) {
            viewModelScope.launch(errorHandler) {
                val updatedRestaurants = …
                _state.value = _state.value.copy(…)
            }
        }
        private fun getRestaurants() {
            viewModelScope.launch(errorHandler) {
                val restaurants =
                    repository.getAllRestaurants()
                _state.value = _state.value.copy(…)
            }
        }
    }
    

_state变量现在是类型为MutableState的私有状态,因此它是我们所说的实现细节。这意味着ViewModel可以更改它,但它不应该暴露给外部世界。那么我们应该向 UI 层暴露什么?

  1. 仍然在RestaurantsViewModel中,创建另一个类型为State<RestaurantsScreenState>state变量,并通过get()语法定义其自定义获取器,如下所示:

    class RestaurantsViewModel() : ViewModel() {
        …
        private val _state = mutableStateOf(...)
        val state: State<RestaurantsScreenState>
            get() = _state
        …
    }
    

state变量现在是公共状态类型State(因此它是公共 API 的一部分),这意味着当 UI 层尝试获取其值时,将调用get()语法,并返回_state变量中的内容。

在幕后,_state变量的类型MutableState被转换为state变量的State类型。这意味着可组合组件将无法在ViewModel内部更改状态。

从概念上讲,state变量和_state变量是相同的,但state用作与外部世界的公共契约的一部分(因此它可以被 UI 层消费),而_state用作内部实现细节(一个可以被ViewModel更新的MutableState对象)。

  1. 最后,在RestaurantsScreen()可组合函数中,确保state变量被消费,如下所示:

    @Composable
    fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
        val viewModel: RestaurantsViewModel = viewModel()
        val state = viewModel.state.value
        Box(…) {…}
    }
    

如果你现在尝试更改state变量的值,就像我们在本节开头所做的那样,那么val变量,如下面的代码片段所示:

@Composable
fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
    val viewModel: RestaurantsViewModel = viewModel()
    val state = viewModel.state.value
    Box(…) {…}
    viewModel.state.value = viewModel.state.value.copy(
        isLoading = false)
}

这实际上意味着我们的 UI 不再可能意外地更改其自身的状态。

作业

你可以在餐厅应用的详细信息屏幕上尝试练习本节中学到的内容。

摘要

在本章中,我们首次了解了 SoC 原则。我们理解了为什么我们必须将应用程序的责任分割到几个层次,并探讨了如何通过展示设计模式来实现这一点。

在本章的第一部分,我们简要回顾了 Android 中最常见的展示模式实现:MVC、MVP 和 MVVM。

之后,我们确定 MVVM 可能适合我们的基于 Compose 的餐厅应用程序。我们了解了每种类型的逻辑必须位于哪个层,然后尝试在我们的应用程序中尽可能实现 SoC(单一职责原则)。

在本章的最后部分,我们注意到我们的 UI 层如何容易地扩展其职责,并通过在ViewModel中修改 UI 状态来开始执行展示逻辑。为了应对这种情况,我们学习了如何通过使用后置属性来更好地封装 UI 状态。

让我们在下一章继续我们的旅程,提高我们应用程序的架构。在这一章中,我们将尝试从著名的清洁架构软件设计哲学中采纳一些设计决策。

第八章:第八章:Android 中开始使用 Clean Architecture

在本章中,我们继续改进 Restaurants 应用程序的架构设计之旅。

更具体地说,我们将尝试采用一些来自知名 Clean Architecture 的设计决策。Clean Architecture 是一种软件设计哲学,试图创建具有以下最佳水平的项目:

  • 关注点的分离

  • 可测试性

  • 外围层(如 UI 或模型层)使用的框架或库的独立性

通过这样做,Clean Architecture 试图让我们的应用程序的业务部分能够适应不断变化的技术和接口。

Clean Architecture 是一个非常广泛且复杂的话题,因此,在本章中,我们将尝试仅通过进一步分离现有层来建立更好的关注点分离,但更重要的是,通过定义一个名为 领域层 的新层。

在本章中,我们将一方面通过 使用用例定义领域层 部分和 将领域模型与数据模型分离 部分借鉴 Clean Architecture 的一些架构决策。另一方面,我们将通过 创建包结构 部分和 将基于 Compose 的 UI 层与 ViewModel 解耦 部分尝试使用其他技术改进项目架构。

Clean Architecture 的另一个基本原则是 依赖规则,我们将在 进一步阅读 部分简要介绍,在那里您将找到适当的资源以跟进。

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

  • 使用用例定义领域层

  • 将领域模型与数据模型分离

  • 创建包结构

  • 将基于 Compose 的 UI 层与 ViewModel 解耦

在深入之前,让我们为本章设置技术要求。

技术要求

为本章构建基于 Compose 的 Android 项目通常需要您日常使用的工具。然而,为了顺利跟进,请确保您拥有以下内容:

  • Arctic Fox 2020.3.1 版本的 Android Studio。您也可以使用更新的 Android Studio 版本或甚至 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 安装在 Android Studio 中的 Kotlin 1.6.10 或更高版本的插件

  • 上一章的 Restaurants 应用程序代码

本章的起点由上一章开发的 Restaurants 应用程序表示。如果您没有跟随上一章的实施,请通过导航到存储库的 Chapter_07 目录并导入名为 chapter_7_restaurants_app 的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到 Chapter_08 目录:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_08/chapter_8_restaurants_app.

使用用例定义领域层

到目前为止,我们讨论了表示层(带有 UI 和表示逻辑)和模型层(带有数据逻辑)。然而,除了这两个层之外,大多数时候,应用程序还会封装一种不同类型的逻辑,这种逻辑不同于 UI、表示或数据逻辑。

要识别这种逻辑,我们首先必须承认大多数应用程序都有一个专门的业务范围——例如,一个食品配送应用程序的业务范围可能是接受订单并为股东创造收入。股东是指对业务感兴趣的实体,例如拥有连锁餐厅的公司。

这样的应用程序可以包含由股东强加的业务规则,这些规则可能包括最低订单金额、某些餐厅的定制可用性范围或不同配送费用的预定义时间框架;这个列表可以继续下去。我们可以将这种由股东规定的业务规则称为业务逻辑

对于我们的餐厅应用程序,让我们想象一下,股东(例如,我们为该公司构建的应用程序)要求我们始终按字母顺序显示餐厅,无论什么情况。这不应该是什么用户会知道的事情;相反,它应该是一个预定义的业务规则,我们必须实现它。

现在,按字母顺序排序餐厅并不是什么大问题,所以自然而然出现的问题是,我们应该在哪里应用这种排序逻辑?

为了弄清楚这一点,让我们回顾一下项目的当前分层。目前,表示层连接到模型层。

图 8.1 – 餐厅应用程序的责任分层

图 8.1 – 餐厅应用程序的责任分层

根据我们现有的分层结构,我们可以在以下方面对餐厅进行排序:

  • UI 级别(可组合项):由于这种排序逻辑是业务逻辑,我们应该尽量避免在这里添加它。

  • ViewModel类;然而,请记住,这个规则是业务需求的一部分,用户不应该知道这一点,所以在这里实现它可能不是一个好主意。

  • 在存储库内部:在这里,我们存储数据逻辑(例如缓存),这与业务逻辑不同。

没有任何一种选择是理想的,我们很快就会看到为什么是这样。在此之前,让我们妥协一下,在模型层内部添加这个业务规则:

  1. RestaurantsRepository内部,重构getAllRestaurants()方法,通过在返回的餐厅上调用sortedBy { }扩展函数来按title排序餐厅:

    suspend fun getAllRestaurants(): List<Restaurant> {
        return withContext(Dispatchers.IO) {
            try {
                refreshCache()
            } catch (e: Exception) {…}
            return@withContext restaurantsDao.getAll()
                .sortedBy { it.title }
        }
    }
    
  2. 构建并运行应用程序。

餐厅现在已按标题正确排序,但如果你将餐厅切换为收藏夹,你可能会注意到列表上的初始闪烁和重新排序效果。你能猜到这是为什么吗?

这里的问题是,在RestaurantsRepository中,toggleFavoriteRestaurant()方法从数据访问对象DAO)返回未排序的餐厅版本:

suspend fun toggleFavoriteRestaurant(…)= withContext(…){
    …
    restaurantsDao.getAll()
}

为了解决这个问题,我们可以重复getAllRestaurants()方法中的相同排序逻辑。

然而,这种方法是有问题的,因为我们可能会重复或复制排序业务规则。更糟糕的是,由于我们处于模型层,我们将数据逻辑与业务逻辑混合在一起。我们不应该将数据缓存逻辑与业务规则混合。

很明显,为了正确封装业务逻辑并能够重用,我们应该将其提取到单独的一层。就像我们想要防止任何对表示层的更改影响其他层,例如 UI 或模型层,我们希望将业务逻辑放在一个单独的层中,以便任何对业务逻辑的更改都不应影响其他层及其相应的逻辑。

根据清洁架构的概念,封装业务规则和业务逻辑的层被称为领域层。该层位于表示层和模型层之间。它应该通过应用其包含的业务规则来处理模型层的数据,然后将符合业务规则的内容提供给表示层。

![图 8.2 – 餐厅应用中的责任分层,包括领域层

![图片 B17788_08_02.jpg]

图 8.2 – 餐厅应用中的责任分层,包括领域层

换句话说,在特定的流程中(例如,在包含餐厅列表的屏幕中),表示层将通过ViewModel连接到领域层而不是模型层。反过来,领域层将从模型层获取数据。

注意

并非所有应用程序、屏幕或流程都包含业务逻辑。对于这些情况,领域层是可选的。领域层应该包含业务逻辑,但如果不存在此类逻辑,则不应存在此类层。

但领域层应该包含什么内容呢?

根据清洁架构的概念,与特定应用程序操作或流程相关的可重复业务逻辑应该封装在用例中。换句话说,用例是类,它们将与应用程序单个功能相关的可重复业务规则作为一个业务逻辑单元提取出来。

例如,一个在线订餐应用可以拥有与仅显示用户附近商店相关的业务逻辑。为了封装这个业务规则,我们可以创建一个GetStoresInProximityUseCase类。或者,可能有一些与用户触发的注销操作相关的业务逻辑(例如在幕后执行一些用户福利或积分计算);那么,我们可以实现LogOutUserUseCase

因此,在我们的餐厅应用中,任何业务逻辑都必须封装在位于表示层和模型层之间的用例中:

![图 8.3 – 责任分层,其中领域层包含用例图片 B17788_08_03.jpg

图 8.3 – 责任分层,其中领域层包含用例

分离的领域层带来以下好处:

  • 通过将业务逻辑分离到其自己的类中,提高了应用程序的可测试性。这样,业务责任与其他组件分离,并且可以单独测试其逻辑,而无需关心其他层的组件。

  • 通过在用例内部分离业务逻辑,我们避免了代码重复,并提高了业务规则及其对应逻辑的可重用性。

  • 通过将业务逻辑分离到其自己的类中,提高了包含用例依赖项的类的可读性。这是因为每个业务单元现在都是单独提取的,并为开发者提供了关于每个屏幕或流程执行的业务动作的宝贵见解。

在深入实际例子之前,让我们简要介绍一些用例的重要方面:

  • 它们可以使用(或依赖于)其他用例。由于用例定义了一个可重用的单一业务逻辑单元,因此用例可以使用其他用例来定义复杂业务逻辑。

  • 它们通常从模型层获取数据,但不是仅限于一个Repository类 – 换句话说,你可以在用例内部访问多个仓库。

  • 它们通常只有一个公共方法,主要是因为用例封装了与您的应用单个功能相关的业务规则(就像LogOutUserUseCase所做的那样)。

  • 它们应该遵循一个命名约定。用例类的流行约定是现在时态的动词,定义动作,通常后面跟几个表示什么的词,并以 UseCase 后缀结尾。一些例子可以是GetStoresInProximityUseCaseCalculateOrderTotalUseCase

是时候看看用例类是什么样的了。在我们的餐厅应用中,按字母顺序排序餐厅的业务逻辑非常适合提取到用例中,因为以下原因:

  • 这是一个由利益相关者规定的业务规则。

  • 它重复了两次。

  • 它是应用中特定操作的一部分(获取餐厅信息)。

让我们定义我们的第一个用例类!

  1. 点击应用程序包,将GetRestaurantsUseCase作为名称,选择,并添加以下代码:

    class GetRestaurantsUseCase {
        private val repository: RestaurantsRepository = 
            RestaurantsRepository()
        suspend operator fun invoke(): List<Restaurant> {
            return repository.getAllRestaurants()
                            .sortedBy { it.title }
        }
    }
    

功能上,这个用例类从RestaurantsRepository获取餐厅,应用了按字母顺序排序餐厅的业务规则,就像RestarauntsViewModel所做的那样,然后返回列表。换句话说,GetRestaurantsUseCase现在负责应用业务规则。

这个用例通过一个公共方法实现,这个方法也是一个suspend函数,因为repository.getAllRestaurants()调用是一个挂起函数调用。但更重要的是,为什么我们给用例的函数命名为invoke(),同时指定了operator关键字?

我们这样做是因为 Kotlin 允许我们在类上定义一个invoke操作符,这样我们就可以在类的任何实例上调用它而不需要方法名。这就是我们将如何调用GetRestaurantsUseCaseinvoke()操作符:

val useCase = GetRestaurantsUseCase()
val result = useCase()

这种语法对我们特别有用,因为我们的用例类只有一个方法,类的名称已经足够有暗示性,所以我们不需要命名函数。

  1. 确保删除我们在RestaurantsRepositorygetAllRestaurants()方法中最初添加的排序逻辑。该方法返回的数据应如下所示:

    suspend fun getAllRestaurants(): List<Restaurant> {
        return withContext(Dispatchers.IO) {
            try { … } catch (e: Exception) {…}
            return@withContext restaurantsDao.getAll()
        }
    }
    
  2. RestaurantsViewModel内部,向GetRestaurantsUseCase类添加一个新的依赖项:

    class RestaurantsViewModel() : ViewModel() {
      private val repository = RestaurantsRepository()
      private val getRestaurantsUseCase = GetRestaurantsUseCase()
      […]
    }
    
  3. 然后,在ViewModelgetRestaurants()方法中,移除对repository变量的餐厅调用,而是调用getRestaurantsUseCase变量的invoke()操作符:

    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            val restaurants = getRestaurantsUseCase()
            _state.value = _state.value.copy(
                restaurants = restaurants, […])
        }
    }
    

在构建和运行应用程序之前,让我们尝试识别此应用程序特定流程的任何其他业务规则。

如果我们查看RestaurantsRepository内部,toggleFavoriteRestaurant()方法接受一个oldValue: Boolean参数,并在将其传递给PartialRestaurantisFavorite字段之前对其进行取反:

suspend fun toggleFavoriteRestaurant(
    id: Int,
    oldValue: Boolean
) =
    withContext(Dispatchers.IO) {
        restaurantsDao.update(
            PartialRestaurant(
                id = id,
                isFavorite = !oldValue
            )
        )
        restaurantsDao.getAll()
    }

这发生在我们标记餐厅为收藏或非收藏的任何时候。通过传递!oldValue取消餐厅收藏状态oldValue的规则可以被视为利益相关者强加的业务规则:每当用户点击餐厅的心形图标时,我们必须将其收藏状态切换到相反的值

为了能够重用这个业务逻辑,而不是由RestaurantsRepository执行,让我们也将这个规则提取到一个用例中。

  1. 首先,在RestaurantsRepository内部,将oldValue参数重命名为value,并确保在将其传递给PartialRestaurantisFavorite字段时不再对其进行取反:

    suspend fun toggleFavoriteRestaurant(id: Int, value: Boolean)=
        withContext(Dispatchers.IO) {
            restaurantsDao.update(
               PartialRestaurant(id = id, isFavorite = value)
            )
            restaurantsDao.getAll()
        }
    
  2. 点击应用程序包,将ToggleRestaurantUseCase作为名称,选择,并添加以下代码:

    class ToggleRestaurantUseCase {
        private val repository: RestaurantsRepository =
            RestaurantsRepository()
        suspend operator fun invoke(
            id: Int,
            oldValue: Boolean
        ): List<Restaurant> {
            val newFav = oldValue.not()
            return repository
                .toggleFavoriteRestaurant(id, newFav)
        }
    }
    

这个用例现在封装了通过val newFav = oldValue.not()行取消餐厅收藏标志的业务规则。虽然这里的业务逻辑相当简单,但在生产应用中,事情往往会变得更加复杂。每当我们将餐厅标记为收藏或非收藏时,都应该调用此用例。

  1. RestaurantsViewModel内部,添加对ToggleRestaurantUseCase类的新依赖:

    class RestaurantsViewModel() : ViewModel() {
        private val getRestaurantsUseCase =
    GetRestaurantsUseCase()
        private val toggleRestaurantsUseCase = ToggleRestaurantUseCase()
        […]
    }
    

在这一步,你也可以安全地通过移除repository变量来移除RestaurantsViewModel类对RestaurantsRepository类的依赖。

  1. 然后,在ViewModeltoggleFavorite()方法中,移除对repository变量的切换餐厅调用,而是调用toggleRestaurantUseCase变量的invoke()运算符:

    fun toggleFavorite(id: Int, oldValue: Boolean) {
        viewModelScope.launch(errorHandler) {
            val updatedRestaurants = 
                toggleRestaurantsUseCase(id, oldValue)
            _state.value = _state.value.copy(…)
        }
    }
    

现在,将餐厅切换为收藏或非收藏的业务规则是在ToggleRestaurantUseCase内部完成的。

  1. 现在我们已经将业务逻辑提取到 Use Case 类中,构建并运行应用程序。应用程序应该表现相同。

然而,如果你尝试切换餐厅为收藏,餐厅列表仍然闪烁,它们的顺序似乎在改变。你能想到为什么会发生这种情况吗?

让我们回到RestaurantsRepository并检查toggleFavoriteRestaurant方法:

suspend fun toggleFavoriteRestaurant(…)= withContext(…) {
    restaurantsDao.update(
        PartialRestaurant(id = id, isFavorite = value)
    )
    restaurantsDao.getAll()
}

这种方法的缺点在于它通过调用restaurantsDao.getAll()从 Room DAO 获取餐厅,这些餐厅并没有按照我们的业务规则进行字母顺序排序。因此,每次我们切换餐厅为收藏时,我们都会用未排序的餐厅列表更新 UI。

我们需要以某种方式重用GetRestaurantsUseCase中的排序逻辑:

  1. 首先,在RestaurantsRepository内部,从toggleFavoriteRestaurant方法中移除restaurantsDao.getAll()调用:

    suspend fun toggleFavoriteRestaurant(…)= withContext(…) {
        restaurantsDao.update(
            PartialRestaurant(id = id, isFavorite = value)
        )
    }
    

这样,这个方法就不再返回餐厅列表;它只是更新一个特定的餐厅。到目前为止,toggleFavoriteRestaurant方法不再返回任何内容。

  1. 然后,在ToggleRestaurantUseCase类中,移除repository.toggleFavoriteRestaurant()行的返回语句,而是通过直接实例化和调用GetRestaurantsUseCase类的invoke()运算符来返回排序后的餐厅列表:

    class ToggleRestaurantUseCase {
        private val repository: … = RestaurantsRepository()
        suspend operator fun invoke(…): List<Restaurant> {
            val newFav = oldValue.not()
            repository.toggleFavoriteRestaurant(id, newFav)
            return GetRestaurantsUseCase().invoke()
        }
    }
    

这种方法解决了我们的问题——每次我们切换餐厅为收藏或非收藏时,UI 不再闪烁,因为 UI 是用正确排序的列表更新的——但这发生在一个漫长的延迟之后。

不幸的是,这个功能非常低效,因为每次我们切换餐厅为收藏或非收藏时,GetRestaurantsUseCase都会调用RestaurantsRepository类的getAllRestaurants()方法,这反过来又触发了从 Web API 获取餐厅的请求,尝试将它们缓存到Room中,然后才提供给我们列表,这就是我们刚才经历的延迟。

在良好的应用程序架构中,获取新项目列表的网络请求不应该在每次与项目进行 UI 交互后都执行。让我们通过重构我们的代码并创建一个新的仅检索缓存餐厅、排序并返回它们的 Use Case 来解决这个问题:

  1. 首先,在RestaurantsRepository内部添加一个名为getRestaurants()的新方法,该方法仅从我们的 Room DAO 检索餐厅:

    suspend fun getRestaurants() : List<Restaurant> {
        return withContext(Dispatchers.IO) {
            return@withContext restaurantsDao.getAll()
        }
    }
    
  2. 点击应用程序包,选择GetSortedRestaurantsUseCase作为名称,选择,并添加以下代码:

    class GetSortedRestaurantsUseCase {
        private val repository: RestaurantsRepository = 
            RestaurantsRepository()
        suspend operator fun invoke(): List<Restaurant> {
            return repository.getRestaurants()
                .sortedBy { it.title }
        }
    }
    

GetSortedRestaurantsUseCase类现在通过调用之前创建的getRestaurants()方法(不触发任何网络请求或缓存)从RestaurantsRepository检索餐厅,应用排序业务规则,并最终返回餐厅列表。

  1. ToggleRestaurantUseCase中使用新创建的GetSortedRestaurantsUseCase类,这样我们每次切换餐厅的收藏或不收藏状态时都只获取缓存的餐厅:

    class ToggleRestaurantUseCase {
        private val repository: … = RestaurantsRepository()
        private val getSortedRestaurantsUseCase =
     GetSortedRestaurantsUseCase()
        suspend operator fun invoke(…): List<Restaurant> {
            val newFav = oldValue.not()
            repository.toggleFavoriteRestaurant(id, newFav)
            return getSortedRestaurantsUseCase()
        }
    }
    

现在,我们必须重构GetRestaurantsUseCase,以便重用GetSortedRestaurantsUseCase中的排序业务逻辑,因为字母排序逻辑现在在两个 Use Case 中都重复了:

  1. 首先,在RestaurantsRepository内部更新getAllRestaurants方法,不再通过不再返回restaurantsDao.getAll()来返回餐厅,同时删除函数的返回类型:

    suspend fun getAllRestaurants() {
        return withContext(Dispatchers.IO) {
            try { … } catch (e: Exception) { … }
        }
    }
    
  2. getAllRestaurants方法重命名为loadRestaurants()以更好地反映其职责:

    suspend fun loadRestaurants() {
        return withContext(Dispatchers.IO) {
            try { … } catch (e: Exception) { … }
        }
    }
    
  3. GetRestaurantsUseCase内部添加一个新依赖项到GetSortedRestaurantUseCase类,并按以下方式重构该类:

    class GetRestaurantsUseCase {
        private val repository: … = RestaurantsRepository()
        private val getSortedRestaurantsUseCase = 
            GetSortedRestaurantsUseCase()
        suspend operator fun invoke(): List<Restaurant> {
            repository.loadRestaurants()
            return getSortedRestaurantsUseCase()
        }
    }
    

invoke()函数内部,我们确保首先调用新命名的loadRestaurants()方法,然后调用GetSortedRestaurantsUseCase,它现在也被返回。

  1. 为了更好地反映其目的,将GetRestaurantsUseCase类重命名为GetInitialRestaurantsUseCase

    class GetInitialRestaurantsUseCase {
        private val repository: … = RestaurantsRepository()
        private val getSortedRestaurantsUseCase = 
            GetSortedRestaurantsUseCase()
        suspend operator fun invoke(): List<Restaurant> {...}
    }
    
  2. 作为结果,在RestaurantsViewModel内部更新getRestaurantsUseCase变量的类型:

    class RestaurantsViewModel() : ViewModel() {
      private val repository = RestaurantsRepository()
      private val getRestaurantsUseCase = 
          GetInitialRestaurantsUseCase()
      …
    }
    
  3. 构建并运行应用。现在,当标记餐厅为收藏或不收藏时,应用应表现正确;餐厅保持字母顺序排序。

让我们现在转向另一种改进我们应用架构的方法。

将域模型与数据模型分离

在域层内部,除了 Use Cases 之外,我们应用中的另一个重要业务组件是域模型组件。域模型组件是那些代表应用中使用的核心业务数据或概念的类。

注意

由于域模型位于域层内部,它们应该对任何第三方库或依赖项无感知——理想情况下,它们应该是纯 Java 或 Kotlin 类。

例如,在我们的餐厅应用中,整个应用中使用的核心实体(检索、更新和显示)是Restaurant数据类,它包含诸如titledescription之类的数据。

如果我们仔细思考,我们的餐厅应用的核心业务实体就是餐厅本身:这正是应用的主题,因此将Restaurant类视为业务实体是顺理成章的。

注意

在 Clean Architecture 中,领域模型类通常被称为实体类。然而,重要的是要提到,Room 数据库 @Entity 注解与 Clean Architecture 没有关系;任何带有 Room @Entity 注解的类都不会自动成为实体。实际上,根据 Clean Architecture,实体类应该没有库依赖,例如数据库注解。

尽管如此,如果我们看一下我们的 Restaurant 数据类,我们可以识别出一个严重的问题:

import androidx.room.ColumnInfo
      …
import com.google.gson.annotations.SerializedName
@Entity(tableName = "restaurants")
data class Restaurant(
    @PrimaryKey()
    @ColumnInfo(name = "r_id")
    @SerializedName("r_id")
    val id: Int,
    @ColumnInfo(name = "r_title")
    @SerializedName("r_title")
    val title: String,
      …
)

你能找到问题吗?

虽然一开始,Restaurant 数据类只是一个包含一些字段的纯 Kotlin 数据类,但随着时间的推移,它变得比这更多。

我们首先将 Retrofit 添加到我们的应用程序中,以便从 Web API 获取餐厅,并必须使用 @SerializedName 注解标记我们获取的字段,以便 GSON(Google Gson)反序列化能够工作。然后我们添加了 Room,因为我们想缓存餐厅,所以我们必须在类上添加 @Entity 注解,以及对其字段的其他注解,如 @PrimaryKey@ColumnInfo

虽然在整个应用程序中使用单个数据模型类对我们来说很方便,但现在我们已经将领域模型类 (Restaurant.kt) 与库依赖项(如 GSON 或 Room)耦合。这意味着我们的领域模型与负责获取数据的或模型层耦合。

根据 Clean Architecture,领域模型类应位于领域层,并且对与从多个来源检索或缓存数据的方式紧密相关的任何库保持无知。

换句话说,我们需要通过为这两种类型创建单独的类来在领域模型和 数据传输对象DTOs)之间进行分离。虽然领域模型是普通的 Kotlin 类,但 DTOs 是包含特定数据操作所需字段以及依赖项(如库注解)的类。

通过这种分离,领域模型现在是一个不关心实现细节(如库)的业务实体,因此每次我们可能需要用另一个库(如 Retrofit 或 Room)替换库时,我们只需更新 DTOs(即模型层),而无需更新领域模型中的类。

图 8.4 – 将领域模型与 DTO 模型分离

图 8.4 – 将领域模型与 DTO 模型分离

要在我们的餐厅应用程序中实现这种分离,我们必须将 Restaurant 类拆分为三个类。我们必须执行以下操作:

  • 创建两个 DTO 作为 data class 类,它们将被用于传输数据:

    • 一个 RemoteRestaurant 类,它将包含从 Web API 接收的字段。这些字段也将带有 Retrofit 解析响应所需的 GSON 序列化注解。

    • 一个 LocalRestaurant 类,它将包含 Room 缓存餐厅所需的字段及其相应的注解。

  • Restaurant数据类重构为没有第三方依赖的纯 Kotlin 数据类。这样,Restaurant数据类将是一个合适的领域模型类,独立于紧密耦合到第三方库的模型层。

让我们开始吧!

  1. 点击应用程序包,选择RemoteRestaurant作为名称,选择,并将此代码添加以定义我们的远程源(Firebase 远程数据库)的 DTO:

    data class RemoteRestaurant(
        @SerializedName("r_id")
        val id: Int,
        @SerializedName("r_title")
        val title: String,
        @SerializedName("r_description")
        val description: String)
    

在这个类内部,我们添加了从 Web API 接收的所有字段及其相应的序列化字段。你可以从Restaurant类中获取这些注解及其导入。

拥有一个单独的 DTO 类的另一个优点是,它现在只包含必要的字段——例如,与Restaurant不同,RemoteRestaurant不再包含isFavorite字段,因为我们没有从我们的 Firebase 数据库的 REST API 中接收它。

  1. 点击应用程序包并创建一个名为LocalRestaurant的新文件。将此代码添加以定义我们的本地源(Room 本地数据库)的 DTO:

    @Entity(tableName = "restaurants")
    data class LocalRestaurant(
        @PrimaryKey()
        @ColumnInfo(name = "r_id")
        val id: Int,
        @ColumnInfo(name = "r_title")
        val title: String,
        @ColumnInfo(name = "r_description")
        val description: String,
        @ColumnInfo(name = "is_favorite")
        val isFavorite: Boolean = false)
    

你可以从Restaurant类中获取字段、注解及其导入。

  1. 现在,导航到Restaurant类。是时候移除所有对 Room 和 GSON 的第三方依赖,将其保留为一个简单的领域模型类,包含定义我们餐厅实体的字段。现在它应该看起来像这样:

    data class Restaurant(
        val id: Int,
        val title: String,
        val description: String,
        val isFavorite: Boolean = false)
    

确保删除任何对 GSON 和 Room 注解的导入。

  1. RestaurantsDb类内部,更新 Room 中使用的实体为我们的新创建的LocalRestaurant,同时更新模式版本为3,只是为了确保 Room 将提供一个全新的开始:

    @Database(
        entities = [LocalRestaurant::class],
        version = 3,
        exportSchema = false)
    abstract class RestaurantsDb : RoomDatabase() {
        abstract val dao: RestaurantsDao
        …
    }
    
  2. PartialRestaurant类重命名为PartialLocalRestaurant以更好地说明这个类是用于我们的本地数据源,Room:

    @Entity
    class PartialLocalRestaurant(
    @ColumnInfo(name = "r_id")
    val id: Int,
    @ColumnInfo(name = "is_favorite")
    val isFavorite: Boolean)
    
  3. RestaurantsDao接口内部,将Restaurant类的使用替换为LocalRestaurant,将PartialRestaurant类的使用替换为PartialLocalRestaurant

    @Dao
    interface RestaurantsDao {
        @Query("SELECT * FROM restaurants")
        suspend fun getAll(): List<LocalRestaurant>
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        suspend fun addAll(restaurants: 
            List<LocalRestaurant>)
        @Update(entity = LocalRestaurant::class)
        suspend fun update(partialRestaurant: 
           PartialLocalRestaurant)
        @Update(entity = LocalRestaurant::class)
        suspend fun updateAll(partialRestaurants: 
            List<PartialLocalRestaurant>)
        @Query("SELECT * FROM restaurants WHERE 
            is_favorite = 1")
        suspend fun getAllFavorited(): List<LocalRestaurant>
    }
    
  4. RestaurantsRepository内部,导航到toggleFavoriteRestaurant()方法,并将PartialRestaurant的使用替换为PartialLocalRestaurant

    suspend fun toggleFavoriteRestaurant(
          …
    ) = withContext(Dispatchers.IO) {
        restaurantsDao.update(
          PartialLocalRestaurant(id = id, isFavorite = value)
        )
    }
    
  5. 仍然在RestaurantsRepository内部,导航到getRestaurants()方法,并将LocalRestaurant对象(通过restaurantsDao.getAll()方法调用接收)映射到Restaurant对象:

    suspend fun getRestaurants() : List<Restaurant> {
        return withContext(Dispatchers.IO) {
            return@withContext restaurantsDao.getAll().map {
                Restaurant(it.id, it.title, 
                    it.description, it.isFavorite)
            }
        }
    }
    

我们通过使用.map { }扩展函数将List<LocalRestaurant>映射到List<Restaurant>。我们通过从LocalRestaurant构造并返回一个Restaurant对象来实现,it是隐含变量名。

注意

你的模型层(在这里由Repository表示),应该只向领域实体返回领域模型对象。在我们的案例中,RestaurantsRepository应该返回Restaurant对象,而不是LocalRestaurants对象,简单来说,因为使用这个Repository的使用案例类(因此,领域层)不应该有任何关于模型层 DTO 类的知识。

  1. 导航到 RestaurantsApiService 接口(Retrofit 接口)并将 Restaurant 类的使用替换为 RemoteRestaurant

    interface RestaurantsApiService {
       @GET("restaurants.json")
        suspend fun getRestaurants(): List<RemoteRestaurant>
       @GET("restaurants.json?orderBy=\"r_id\"")
        suspend fun getRestaurant(…):  
            Map<String, RemoteRestaurant>
    }
    
  2. 回到 RestaurantsRepository,导航到 refreshCache() 方法并将 Retrofit 的 remoteRestaurants 列表映射到 LocalRestaurant 对象,以便 restaurantsDao 可以缓存它们:

    private suspend fun refreshCache() {
        val remoteRestaurants = restInterface
            .getRestaurants()
        val favoriteRestaurants = restaurantsDao
            .getAllFavorited()
        restaurantsDao.addAll(remoteRestaurants.map {
            LocalRestaurant(
                it.id,
                it.title,
                it.description,
                false
            )
        })
        restaurantsDao.updateAll(
            favoriteRestaurants.map {
                PartialLocalRestaurant(
                    id = it.id,
                    isFavorite = true
                )
            })
    }
    

此外,请确保在 restaurantsDao.updateAll() 方法调用中更新 PartialRestaurant 的使用为 PartialLocalRestaurant

  1. 导航到 RestaurantsDetailsViewModel 并在 getRemoteRestaurant() 方法中,使用 ?.let{ } 扩展函数将从 Retrofit API 收到的 RemoteRestaurant 对象映射到 Restaurant 对象:

    private suspend fun getRemoteRestaurant(id: Int): Restaurant {
        return withContext(Dispatchers.IO) {
            val response =  restInterface.getRestaurant(id)
            return@withContext response.values.first().let {
                Restaurant(
                    id = it.id, 
                    title = it.title, 
                    description = it.description
                )
            }
        }
    }
    

记住,在餐厅详情屏幕中,我们没有任何业务逻辑或用例,甚至没有 Repository,所以我们直接在 ViewModel 中添加了一个 Retrofit 接口的变量 – 这就是为什么我们在 ViewModel 中映射领域模型的原因。

  1. 构建并运行应用程序。应用程序应该表现相同。

现在让我们暂时放下创建类的工作,让我们对项目进行一些组织。

创建包结构

我们的餐厅应用已经走了很长的路。随着我们尽可能地将责任和关注点分离,新的类出现了 – 实际上有很多。

如果我们在 Android Studio 的左侧查看,在 项目 选项卡中,我们可以看到我们在项目中定义的类的概述。

![图 8.5 – 没有任何包结构策略的项目结构![图 8.5 – 没有任何包结构策略的项目结构图 8.5 – 没有任何包结构策略的项目结构很明显,我们的项目完全没有文件夹结构 – 所有文件和类都随意放在 restaurantsapp 根包中。注意如果您为您的应用程序选择了不同的名称,根包的名称可能会有所不同。因为我们选择将任何新的类都放入根包中,所以很难对项目有清晰的可见性。我们的方法类似于在我们的电脑桌面上添加几十个文件和资产 – 随着时间的推移,在屏幕上找到任何东西都变得不可能。为了缓解这个问题,我们可以为我们的项目选择一种打包策略,其中每个类都属于一个文件夹。清晰的文件夹结构允许开发者有良好的可见性,并能够获得对应用程序组件的宝贵洞察,从而更容易访问和导航项目文件。最常见的包组织策略如下:+ presentation 包将包含与表示层相关的所有文件,无论它们属于哪个功能,例如所有带有 composables 的文件以及所有的 ViewModel 类。+ 同样,一个 data 包将包含与模型层相关的所有文件,无论它们属于哪个功能,例如仓库、Retrofit 接口或 Room DAO 接口。+ restaurants 包将包含与 restaurants 功能相关的所有类,从 UI 类到 ViewModel 类、用例和仓库。这两种方法都有其优缺点,但最值得注意的是,如果应用程序有很多功能,按层组织包的方式扩展性不佳,因为没有方法来区分来自不同功能类的类。另一方面,如果每个功能包中的所有类都没有任何明确的分类,那么按功能组织包可能会出现问题。对于我们的餐厅应用程序,我们将结合使用这两种策略。更具体地说,我们将执行以下操作:+ 将 RestaurantsApplication.kt 放置在根包内。+ 为我们应用程序的唯一功能创建一个根包,命名为 restaurants。这个包将包含显示餐厅列表和详情屏幕的功能。+ 在 restaurants 包内为每个层创建子包: + Presentation: 用于可组合的类和 ViewModel 类。在这个包内,我们还可以将我们已有的屏幕拆分为单独的包:list 用于包含餐厅列表的第一个屏幕,details 用于包含单个餐厅详情的第二个屏幕。此外,我们还将 MainActivity 类放在 presentation 包内,因为它是我们 UI 的宿主组件。 + Data: 用于模型层内的类。在这里,我们不仅会添加 RestaurantsRepository,还会为两个不同的数据源创建两个子包:local(用于缓存类,如 RestaurantsDaoLocalRestaurant),以及 remote(用于与远程源相关的类,如 RestaurantsApiServiceRemoteRestaurant)。 + Domain: 用于业务相关的类、用例类,以及 Restaurant.kt 领域模型类。使用这种方法,如果我们添加一个新功能,比如与订单相关的功能(我们可以称之为 ordering),包结构将立即提供我们应用程序包含的功能信息。当我们展开某个功能包时,我们可以展开我们感兴趣工作的层的包,并清楚地了解我们需要更新或修改的组件。要实现这种包装结构,您将需要多次执行以下操作:1. 创建一个新的包。为此,左键单击某个现有包(例如 restaurantsapp 包),选择 新建,然后 ,最后输入包名。1. 将现有类移动到现有包中。为此,只需将文件拖动并放入所需的包中。最后,我们描述并希望实现的包结构如下:图 8.6 – 应用我们的包结构策略后的项目结构

图 8.6 – 应用我们的包结构策略后的项目结构

然而,请注意,当将 MainActivity.kt 文件从其初始位置移动到 presentation 包时,你可能需要更新 Manifest.xml 文件以引用新的正确路径到 MainActivity.kt 文件:

<manifest […]>
    […]
    <application
        […]
        <activity
            android:name=".restaurants.presentation.
                MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.RestaurantsApp.
                NoActionBar">
            <intent-filter>
                […]
            </intent-filter>
            <intent-filter>
                […]
            </intent-filter>
        </activity>
    </application>
</manifest>

一些版本的 Android Studio 会为你自动完成这项工作;然而,如果它们没有这样做,你可能会遇到一个讨厌的编译错误,因为 Manifest.xml 文件不再检测到我们的 Activity

现在我们已经重构了项目的结构,我们可以这样说,包结构为我们提供了关于应用功能(在我们的案例中,只有一个与餐厅相关的功能)的即时信息,同时也提供了一个关于特定功能的组件的清晰概览。

注意

Compose 项目的自动生成文件(Color.ktShape.ktTheme.ktType.kt)被留在了 ui 包内的 theme 包中。这是因为主题应该在整个功能中保持一致性。

让我们现在转向另一种改进 UI 层内部组合函数与 ViewModel 之间解耦的方法。

解耦基于 Compose 的 UI 层与 ViewModel

我们的 UI 层(由组合函数表示)与 ViewModel 紧密耦合。这是自然的,因为屏幕组合函数会实例化自己的 ViewModel 来执行以下操作:

  • 获取 UI 状态并消费它

  • 将事件(如点击 UI 项目)传递到 ViewModel

例如,我们可以看到 RestaurantsScreen() 组合函数是如何使用 RestaurantsViewModel 实例的:

@Composable
fun RestaurantsScreen(onItemClick: (id: Int) -> Unit) {
    val viewModel: RestaurantsViewModel = viewModel()
    val state = viewModel.state.value
    Box(…) { … }
}

我们的方法的问题是,如果我们想稍后测试 UI 层,那么在测试中,RestaurantsScreen 组合函数将实例化 RestaurantsViewModel,这反过来又会从 Use Case 类获取数据,进而触发 RestaurantsRepository 中的重 I/O 工作(如获取餐厅的网络请求,或将其保存在本地数据库中的操作)。

当我们必须测试 UI 时,我们不应该关心 ViewModel 是否正确获取数据并将其转换为适当的 UI 状态。分离关注点的效果是,在不需要关心其他层执行其工作的前提下,便于测试目标类(或本讨论中的组合函数)。

目前,我们的屏幕组合函数与一个库依赖项,即 ViewModel,紧密相连,理想情况下,我们应该尽可能解耦这样的依赖项,以促进可重用性和可测试性。

为了尽可能地将 RestaurantsScreen() 组合函数与其 ViewModel 解耦,我们将对其进行重构,以便以下情况发生:

  • 它将不再引用一个 ViewModel 类(即 RestaurantsViewModel 类)。

  • 相反,它将接收一个 RestaurantsScreenState 对象作为参数。

  • 它还将定义新的函数参数来向其调用者公开回调 - 我们将在稍后看到调用者是谁。

    注意

    通过从屏幕组合器,如 RestaurantsScreen() 中提取 ViewModel 实例化,我们在可重用性方面进行了提升,这意味着我们可以更容易地替换创建此组合器状态的 ViewModel 类型。这种方法还使我们能够更容易地将基于 Compose 的 UI 层移植到 Kotlin MultiplatformKMP)项目中。

让我们开始吧!

  1. RestaurantsScreen 文件中,通过删除其 viewModelstate 变量来更新 RestaurantsScreen() 组合器,同时确保它接收一个 RestaurantsScreenState 对象作为 state 参数和一个 onFavoriteClick 函数:

    @Composable
    fun RestaurantsScreen(
        state: RestaurantsScreenState,
        onItemClick: (id: Int) -> Unit,
        onFavoriteClick: (id: Int, oldValue: Boolean) -> Unit
    ) {
        Box(…) {
            LazyColumn(…) {
                items(state.restaurants) { restaurant ->
                    RestaurantItem(
                        restaurant,
                        onFavoriteClick = { id, oldValue ->
                            onFavoriteClick(id, oldValue)
                        },
                        onItemClick = { id ->
                            onItemClick(id)
                        }
                    )
                }
            }
            […]
        }
    }
    

此外,请确保删除 viewModel.toggleFavorite() 调用,并改为在 RestaurantItem 对应的回调中调用新添加的 onFavoriteClick() 函数。

  1. 由于我们更改了 RestaurantsScreen() 函数的签名,我们必须更新 DefaultPreview() 组合器以正确调用 RestaurantsScreen() 组合器:

    @Preview(showBackground = true)
    @Composable
    fun DefaultPreview() {
        RestaurantsAppTheme {
            RestaurantsScreen(
                RestaurantsScreenState(listOf(), true),
                {},
                { _, _ -> }
            )
        }
    }
    
  2. MainActivity 类和 RestaurantsApp() 组合器内部,使 RestaurantsScreen() 的目标组合器负责连接屏幕组合器与其 ViewModel,从而确保 RestaurantsScreen()RestaurantsViewModel 之间良好的通信:

    @Composable
    private fun RestaurantsApp() {
        val navController = rememberNavController()
        NavHost(navController, startDestination = 
            "restaurants") {
            composable(route = "restaurants") {
                val viewModel: RestaurantsViewModel = 
                    viewModel()
                RestaurantsScreen(
                    state = viewModel.state.value,
                    onItemClick = { id ->
                        navController
                            .navigate("restaurants/$id")
                    },
                    onFavoriteClick = { id, oldValue ->
                       viewModel.toggleFavorite(id, oldValue)
                    })
            }
            composable(
                route = "restaurants/{restaurant_id}",
                […]) { RestaurantDetailsScreen() }
            }
    

使用这种方法,与初始路由 "restaurants" 组合的 composable() 是管理并连接 RestaurantsScreen() 组合到其内容的组合器,通过以下步骤实现:

  • 实例化 RestaurantsViewModel

  • 获取并将状态传递给 RestaurantsScreen()

  • 处理 onItemClick()onFavoriteClick() 回调

  1. 构建并运行应用程序。应用应该表现相同。

  2. 你会注意到,如果你重新构建项目并导航回 RestaurantsScreen() 组合器,预览现在将正常工作,因为 RestaurantsScreen() 组合器不再绑定到 ViewModel,因此 Compose 可以非常容易地预览其内容。

    赋值

    在本章中,我们更好地解耦了应用的第一屏幕(RestaurantScreen() 组合器)与其 ViewModel,以促进重用性和可测试性。作为作业,你可以练习对 RestaurantDetailScreen() 组合器做同样的处理。

摘要

在本章中,我们初步探讨了 Android 中的 Clean Architecture。我们首先了解了一些关于 Clean Architecture 的含义以及我们如何在我们的餐厅应用中实现它的一些最佳方法,同时也涵盖了遵循这种软件设计哲学的主要好处。

在第一部分,我们开始了 Clean Architecture 的介绍,其中我们定义了领域层与用例,并在第二部分继续重构,将领域模型与数据模型分离。

然后,我们通过创建包结构和进一步解耦基于 Compose 的 UI 层与 ViewModel 类来改进应用的架构。

在下一章中,我们将继续通过采用依赖注入来改进我们应用程序的架构之旅。

进一步阅读

清洁架构是一个非常复杂的话题,单靠一章内容是远远不够涵盖的。然而,清洁架构带来的最重要的概念之一就是依赖规则。依赖规则指出,在一个项目中,依赖关系只能指向内部。

要理解依赖规则是什么,让我们通过同心圆的简化版本来可视化我们的餐厅应用程序的层依赖关系。每个同心圆代表软件的不同领域及其相应的层依赖关系(和库)。

图 8.7 – 层和组件的依赖规则

图 8.7 – 层和组件的依赖规则

这种表示方法规定,实现细节应放置在外部圆圈中(正如 Compose 是 UI 层的实现细节,或者 Retrofit 是数据层的实现细节),而业务策略(来自领域层的用例)则放置在内部圆圈中。

这种表示的目的是强制执行依赖规则,该规则指出依赖关系应该只指向内部。

依赖规则(用指向内部的箭头表示)展示了以下内容:

  • 表示层依赖于领域层(正如我们应用中的ViewModel类正确地依赖于用例类)以及数据层应该如何也依赖于领域层(在我们的应用中,用例依赖于Repository类,而应该是相反的——更多内容将在下一部分讨论)。

  • 领域层不应依赖于外部层——在我们的应用中,用例依赖于Repository类,这违反了依赖规则。

让表示层和数据层(包含实现细节,如 Compose、Room 和 Retrofit 库)依赖于内部领域层的方法是有益的,因为它允许我们有效地将业务策略(即内部圆圈,也就是领域层)与外部层分离。外部层经常改变其实现,我们不希望这些变化影响到内部领域层。

然而,在我们的餐厅应用程序中,领域层依赖于数据层,因为用例类依赖于Repository类。换句话说,由于内部圆圈(领域层)依赖于外部圆圈,因此违反了依赖规则。

为了解决这个问题,我们可以为数据层(对于Repository类)定义一个interface类,并将其视为领域层的一部分(目前,通过将其移动到domain包内部)。

这样,用例依赖于在领域层内部定义的接口,因此现在领域层没有外部依赖。另一方面,Repository 类(数据层)实现了由领域层提供的接口,因此数据层(来自外部圈)现在依赖于领域层(来自内部圈),从而正确地遵守了依赖规则。

注意

另一种分离关注点(或层)并确保遵守依赖规则的方法是将应用程序模块化成层,其中每一层都是一个 Gradle 模块。

我鼓励你研究罗伯特·C·马丁的博客中关于依赖规则的更多内容,同时也可以查看实现整洁架构的其他策略:blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

第九章:第九章:使用 Jetpack Hilt 实现依赖注入

在本章中,我们继续改进 Restaurants 应用的架构设计。更确切地说,我们将把 依赖注入DI)融入到我们的项目中。

在第一部分,什么是 DI?,我们将从定义 DI 并理解其基本概念开始,从依赖项是什么,依赖项的类型,以及注入代表什么,到依赖容器和手动注入等概念。

之后,在 为什么需要 DI? 这一部分,我们将更详细地关注 DI 为我们的项目带来的好处。

在上一节中,使用 Hilt 实现依赖注入,我们将首先了解 Jetpack Hilt DI 库的工作原理以及如何使用它,最后,借助它的帮助,我们将把依赖注入融入到我们的 Restaurants 应用程序中。

总结来说,在本章中,我们将涵盖以下内容:

  • 什么是 DI?

  • 为什么需要 DI?

  • 使用 Hilt 实现依赖注入

在深入之前,让我们为本章设置技术要求。

技术要求

构建本章的基于 Compose 的 Android 项目通常需要您日常使用的工具;然而,为了顺利跟进,请确保您拥有以下内容:

  • Arctic Fox 2020.3.1 版本的 Android Studio。您也可以使用更新的 Android Studio 版本,甚至可以尝试 Canary 版本,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 在 Android Studio 中安装了 Kotlin 1.6.10 或更高版本的插件

  • 上一章的 Restaurants 应用程序代码

本章的起点是上一章开发的 Restaurants 应用程序。如果您没有跟随上一章的实现,可以通过导航到存储库中的 Chapter_08 目录并导入名为 chapter_8_restaurants_app 的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到 Chapter_09 目录:github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_09/chapter_9_restaurants_app

什么是 DI?

简而言之,DI 代表了提供类所需依赖实例的概念,而不是让它自己构建。但,什么是依赖项?

ExampleViewModel 类可能包含一个类型为 Repositoryrepository 变量:

class ExampleViewModel {
    private val repository: Repository = Repository()
    fun doSomething() {
        repository.use()
    }
}

因此,ExampleViewModel 依赖于 Repository,或者 RepositoryExampleViewModel 的依赖项。大多数情况下,类有更多的依赖项,但为了简单起见,我们将只关注一个。在这种情况下,ExampleViewModel 提供了自己的依赖项,因此创建其实例非常简单:

fun main() {
    val vm = ExampleViewModel()
    vm.doSomething()
}

现在,前面的例子没有实现 DI,主要是因为ExampleViewModel为其自己的依赖项提供实例。它是通过实例化一个Repository实例(通过Repository()构造函数)并将其传递给repository变量来做到这一点的。

为了实现 DI,我们必须创建一个提供ExampleViewModel依赖项的组件:

object DependencyContainer {
    val repo: Repository = Repository()
}

如其名所示,DependencyContainer类将充当依赖项容器,因为它将为我们的类提供所有所需的依赖项实例。当一个类需要其依赖项的实例时,这个容器将提供它。这样,我们可以在项目的单个位置集中处理依赖项实例的创建过程(这对于具有其他依赖项的复杂项目来说可能会变得复杂)。

注意

除了 DI 技术,您还可以使用ServiceLocator组件。DI 和服务定位器模式都很有用;然而,在本章中我们只会介绍 DI。

回到实现 DI,我们接下来必须允许DependencyContainerExampleViewModel提供一个Repository实例:

class ExampleViewModel {
    private val repository: Repository = 
        DependencyContainer.repo
    fun doSomething() {
        repository.use()
    }
}

这种将依赖项声明为变量(例如,ExampleViewModel包含一个repository变量)然后通过容器提供其实例的技术,是一种称为字段注入的 DI 形式。

这种方法有几个问题,主要是由我们将依赖项声明为字段变量的事实引起的。最显著的问题如下:

  • ExampleViewModel类与我们的DependencyContainer紧密耦合,我们不能在没有它的情况下使用ViewModel

  • 依赖项是ExampleViewModel不知道ViewModel类的依赖项或它们的创建。

这将不允许我们使用相同的ExampleViewModel与其他依赖项的实现(考虑到其依赖项,如Repository,是可以通过不同类实现的接口)一起重用。

  • 由于ExampleViewModel有隐藏的依赖项,这使得我们很难对其进行测试。当我们实例化ExampleViewModel并将其置于测试之下时,它将创建自己的Repository实例,这可能会为每个测试进行真实的 I/O 请求。我们希望我们的测试快速且可靠,并且不依赖于真实的第三方 API。

为了减轻这些问题,我们首先必须重构ExampleViewModel,使其通过其公共 API 向外界暴露其依赖项。最合适的方法是通过其公共构造函数

class ExampleViewModel constructor(private val repo: Repository) {
    fun doSomething() { repo.use() }
}

现在,ExampleViewModel通过其构造函数向外界暴露其依赖项,使这些依赖项明确化。然而,谁将提供外部的依赖项呢?

当我们需要实例化ExampleViewModel时,DependencyContainer将提供它所需的依赖项:

fun main() {
    val repoDependency = DependencyContainer.repository
val vm = ExampleViewModel(repoDependency)
vm.doSomething()
}

在前面的例子中,我们不是使用字段注入,而是通过其构造函数从外部世界使用ExampleViewModel

与字段注入相比,构造函数注入允许我们做以下事情:

  • 将我们的类与 DI 容器解耦,就像ExampleViewModel不再依赖于DependencyContainer一样。

  • 依赖项暴露给外部世界,因此我们可以使用相同的ExampleViewModel与其他Repository实现(假设Repository是一个接口)一起重用。

  • ExampleViewModel类不能再决定获取并使用哪种依赖实现,就像字段注入那样,所以我们现在将这个责任从ExampleViewModel反转给了外部世界。

  • ExampleViewModel更容易测试,因为我们可以轻松传递一个模拟或伪造的Repository实现(假设Repository是一个接口),它将在测试中以我们期望的方式表现。

到目前为止,借助依赖容器,我们通过允许DependencyContainer为我们提供依赖项的实例(即ExampleViewModel的实例)来自己实现 DI。这种技术被称为手动 DI

除了手动 DI 之外,你还可以通过框架自动完成 DI,这些框架可以减轻你以下负担:

  • 为需要它们的类提供依赖项的实例。更具体地说,框架帮助您为所需的依赖项配置复杂的对象关系,因此您不必编写样板代码来生成实例并将它们传递给适当的对象。这种基础设施代码对于大型应用程序来说通常很繁琐,因此一个为您自动完成这项工作的框架可以非常方便。

  • 将依赖项的范围限制在特定的生命周期范围内,例如Application范围或Activity范围。例如,如果你想某个依赖项是单例(即与应用程序的生命周期相关联),你必须手动确保在内存中只创建一个实例,同时避免由于并发访问而导致的并发问题。框架可以在幕后为你完成这项工作。

在 Android 中,一个非常简单的 DI 库是Hilt,我们将在使用 Hilt 实现 DI部分中探讨它。但在此之前,让我们更好地理解为什么 DI 最初是必要的。

为什么需要 DI?

DI 并非所有项目都必须使用。到目前为止,我们的餐厅应用在没有集成 DI 的情况下运行得很好。然而,虽然不包含 DI 可能看起来不是一个大问题,但通过引入它,你可以给你的项目带来很多好处;最显著的优势是你可以做以下事情:

  • 编写更少的样板代码。

  • 编写可测试的类。

让我们接下来讨论这两点。

编写更少的样板代码

让我们回到我们的餐厅应用,看看我们如何在RestaurantsRepository类中实例化 Retrofit 接口:

class RestaurantsRepository {
    private var restInterface: RestaurantsApiService =
        Retrofit.Builder()
             .addConverterFactory(
                 GsonConverterFactory.create())
             .baseUrl("your_firebase_database_url")
            .build()
            .create(RestaurantsApiService::class.java)
    [...]
}

现在,让我们看看我们如何在RestaurantsDetailsViewModel类中类似地实例化 Retrofit 接口:

class RestaurantDetailsViewModel(…): ViewModel() {
    private var restInterface: RestaurantsApiService
    [...]
    init {
        val retrofit: Retrofit = Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl("your_firebase_database_url")
            .build()
        restInterface = retrofit
            .create(RestaurantsApiService::class.java)
        [...]
    }
    [...]
}

虽然代码看起来不同,但本质上,它需要实例化RestaurantsApiService具体实例的相同代码。不幸的是,我们在两个地方重复了此实例化代码,即在RestaurantsRepository类和RestaurantsDetailsViewModel类中。

在中等或大型生产应用中,对象之间的关系通常要复杂得多,这使得此类基础设施代码成为每个类的常见问题,主要是因为,在没有任何依赖注入(DI)的情况下,每个类都会构建它所需的依赖项的实例。此类代码通常在整个项目中重复出现,最终变得难以管理。

DI 将帮助我们集中管理此类基础设施代码,并将消除在整个项目中提供依赖项实例所需的所有重复代码。

回到我们的餐厅应用,如果我们使用手动 DI,所有这些实例化代码都可以提取到一个DependencyContainer类中,该类会在我们需要的地方提供RestaurantsApiService实例,这样我们就没有更多的重复代码了!别担心,我们将在即将到来的使用 Hilt 实现 DI部分中引入 DI。

现在我们已经讨论了 DI 如何帮助我们包含和组织与构建类实例相关的代码,现在是时候检查 DI 的另一个基本优势了。

编写可测试的类

假设我们想要测试RestaurantsRepository的行为,以确保它按预期执行。但首先,让我们快速查看RestaurantsRepository的现有实现:

class RestaurantsRepository {
    private var restInterface: RestaurantsApiService = 
        Retrofit.Builder()
            .[...]
            .create(RestaurantsApiService::class.java)
    private var restaurantsDao = RestaurantsDb
        .getDaoInstance(
            RestaurantsApplication.getAppContext()
        )
    suspend fun toggleFavoriteRestaurant(…) = {…}
    suspend fun getRestaurants(): List<Restaurant> {…}
    [...]
}

我们可以看到,目前还没有引入 DI,因为RestaurantsRepository有两个隐式依赖项:一个RestaurantsApiService实例和一个RestaurantsDao实例。RestaurantsRepository通过构造一个Retrofit.Builder()对象并调用.create(…)来创建具体实例,首先为它的依赖项提供实例。

现在,假设我们想要测试这个RestaurantsRepository类,并通过运行不同的验证来确保它表现正确。让我们想象这样一个测试类会是什么样子:

class RestaurantsRepositoryTest {
    @Test
    fun repository_worksCorrectly() {
        val repo = RestaurantsRepository()
        assertNotNull(repo)
        // Perform other verifications
    }
}

之前的测试结构很简单:我们通过其构造函数创建了一个RestaurantsRepository实例,并将其保存在repo变量中。然后我们断言Repository实例不是null,这样我们就可以继续测试其行为。

这虽然是可选的,但如果你正在尝试编写前面的测试类并遵循此流程,请确保RestaurantsRepositoryTest类放置在应用程序的test目录中:

图 9.1 – 测试类在项目结构中的位置

图 9.1 – 测试类在项目结构中的位置

现在,如果我们尝试运行这个测试,它将在有机会验证任何内容之前抛出异常。堆栈跟踪将如下所示:

Figure 9.2 – Stack trace of running invalid test

img/B17788_09_2.jpg

图 9.2 – 运行无效测试的堆栈跟踪

这是因为我们试图为RestaurantsRepository编写一个小测试,但这个类目前还不能进行测试(实际上,我们正在尝试执行单元测试——我们将在第十章使用 UI 和单元测试测试您的应用程序)中更详细地讨论这个问题)。

但是,为什么我们的简单测试会抛出UninitializedPropertyAccessException

如果我们查看堆栈跟踪,我们可以看到崩溃是由于我们的测试尝试通过RestaurantsApplication类中的getAppContext()方法获取应用程序上下文而引起的。

这是有道理的,因为如果我们再次查看RestaurantsRepository,我们可以看到为了获取restaurantsDao实例,Repository调用RestaurantsDb.getDaoInstance()来初始化 Room 数据库,而这需要Context实例来完成:

class RestaurantsRepository {
    […]
    private var restaurantsDao = RestaurantsDb
        .getDaoInstance(
            RestaurantsApplication.getAppContext()
        )
    suspend fun toggleFavoriteRestaurant(…) = {…}
    suspend fun getRestaurants(): List<Restaurant> {…}
    [...]
}

我们的小测试不需要Context对象,仅仅是因为它不应该尝试创建 Room 数据库,也不应该创建 Retrofit 客户端实例;它甚至不应该依赖于这些具体实现。这对于小测试来说并不高效,因为这些操作内存开销大,只会使我们的测试变慢。

此外,我们不想让我们的小测试(应该轻松运行,非常快,在短时间内多次运行)通过 Room 查询,或者更糟糕的是,通过 Retrofit 进行网络请求,仅仅是因为测试依赖于外部世界,因此它们变得昂贵且难以自动化。

然而,如果我们已经实现了构造函数注入的依赖注入,我们就可以创建自己的类来模拟行为,最终使我们的Repository类易于测试,并且独立于执行重 I/O 操作的具体实现。我们将在第十章使用 UI 和单元测试测试您的应用程序)中更详细地介绍关于测试和模拟的内容。

回到我们的应用程序,我们还没有准备好编写测试,因为正如你所看到的,我们在项目中缺少 DI。现在我们已经看到,没有 DI,生活似乎有些艰难,让我们学习如何使用 Hilt 库将 DI 集成到 Restaurants 应用程序中!

使用 Hilt 实现 DI

DI 库通常用于简化并加速我们在项目中集成 DI,尤其是在手动 DI 所需的基础设施代码在大型项目中难以管理时。

Hilt是 Jetpack 的一部分的 DI 库,它通过生成您否则必须手动开发的代码和基础设施,消除了 Android 应用程序中手动依赖注入的冗余代码。

注意

Hilt 是一个基于另一个流行的依赖注入框架 Dagger 的 DI 库,这意味着它们之间有很强的关联,因此在本章中我们经常将 Hilt 称为 Dagger Hilt。由于 Dagger API 的学习曲线陡峭,Hilt 被开发为一个抽象层,以允许在 Android 项目中更容易地采用自动 DI。

Dagger Hilt 依赖于注解处理器在构建时自动生成代码,这使得它能够创建和优化在整个项目中管理和提供依赖项的过程。正因为如此,其核心概念与注解的使用紧密相连,因此在我们开始向我们的餐馆应用中添加和实现 Hilt 之前,我们必须首先了解一些概念,以便更好地理解 Dagger Hilt 的工作原理。

总结一下,在本节中我们将进行以下操作:

  • 理解 Dagger Hilt 的基础知识

  • 设置 Hilt

  • 使用 Hilt 进行依赖注入

让我们开始吧!

理解 Dagger Hilt 的基础知识

让我们分析三个最重要的概念及其对应的注解,这些注解是我们为了在项目中启用自动 DI 所必须与之合作的:

  • 注入

  • 模块

  • 组件

让我们开始注入吧!

注入

Dagger Hilt 需要知道我们希望它为我们提供的实例类型。当我们讨论手动构造函数注入时,我们最初希望 ExampleViewModel 在需要的地方被注入,为此我们使用了 DependencyContainer 类。

如果我们想让 Dagger Hilt 在某个地方注入类的实例,我们必须首先声明一个该类型的变量,并用 @Inject 注解它。

假设在我们用于手动 DI 示例的 main() 函数中,我们不再想使用手动 DI 来获取 ExampleViewModel 的实例。相反,我们希望 Dagger 实例化这个类。这就是为什么我们将 ExampleViewModel 变量用 Java 的 @Inject 注解,并避免自己实例化 ViewModel 类。现在,Dagger Hilt 应该为我们做这件事:

import javax.inject.Inject
@Inject
val vm: ExampleViewModel
fun main() {
    vm.doSomething()
}

现在,为了使 Dagger Hilt 知道如何为我们提供 ExampleViewModel 类的实例,我们还必须将 @Inject 注解添加到 ExampleViewModel 的依赖项上,这样 Dagger 就知道如何实例化 ViewModel 类。

由于 ExampleViewModel 的依赖项位于构造函数中(从我们使用手动构造函数注入时开始),我们可以直接在 constructor 上添加 @Inject 注解:

class ExampleViewModel @Inject constructor(private val repo:Repository) {
    fun doSomething() { repo.use() }
}

现在,Dagger Hilt 还需要知道如何注入 ExampleViewModel 的依赖项,更确切地说,是 Repository 类。

让我们假设 Repository 只有一个依赖项,一个 Retrofit 构造函数变量。为了使 Dagger 知道如何注入 Repository 类,我们必须用 @Inject 注解其构造函数:

class Repository @Inject constructor(val retrofit: Retrofit){
    fun use() { retrofit.baseUrl() }
}

到目前为止,我们通过@Inject注解得以幸免,因为我们有访问我们试图注入的类和依赖项,但现在,Dagger 如何知道如何为我们提供Retrofit实例呢?我们没有方法进入Retrofit类并注解其构造函数为@Inject,因为它位于外部库中。

要指导 Dagger 如何为我们提供特定的依赖项,让我们了解一下模块吧!

模块

@Module允许我们指导 Dagger Hilt 如何提供依赖。例如,我们需要 Dagger Hilt 为我们提供Repository中的Retrofit实例,因此我们可以定义一个DataModule类,告诉 Dagger Hilt 如何这样做:

@Module
object DataModule {
    @Provides
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder().baseUrl("some_url").build()
    }
}

要告诉库如何为我们提供依赖项,我们必须在@Module注解的类内部创建一个方法,手动构建该类的实例。

由于我们没有访问Retrofit类,并且需要将其注入,因此我们创建了一个带有@Provides注解的provideRetrofit()方法(你可以按任何你想要的方式调用它),它返回一个Retrofit对象。在方法内部,我们手动创建了Retrofit实例,按照我们需要的构建方式。

现在,Dagger Hilt 知道如何为我们提供ExampleViewModel需要的所有依赖(它的直接Repository依赖和Repository Retrofit依赖)。然而,Dagger 会抱怨它需要一个组件类,其中我们必须安装我们创建的模块。

让我们简要地看看组件吧!

组件

组件是表示一组特定依赖项的接口。组件接受模块并确保其依赖项的注入与特定的生命周期相符合。

对于我们的ExampleViewModelRepositoryRetrofit依赖项的示例,假设我们创建一个管理这些依赖项创建的组件。

使用 Dagger Hilt,你可以使用@DefineComponent注解定义一个组件:

@DefineComponent()
interface MyCustomComponent(…) { /* component build code */ }

然后,我们可以在该组件中安装我们的DataModule

@Module
@InstallIn(MyCustomComponent::class)
object DataModule {
    @Provides
    fun provideRetrofit(): Retrofit { […] }
}

实际上,定义和构建组件的过程比这更复杂。这是因为组件必须将其依赖项范围到某个生命周期范围内(例如应用程序的生命周期)并且有一个预存在的父组件。

幸运的是,Hilt 为我们提供了现成的组件。这些预定义的组件允许我们在其中安装模块,并将依赖项范围到它们对应的生命周期范围内。

其中一些最重要的预定义组件如下:

  • SingletonComponent:允许我们通过使用@Singleton注解将依赖项范围到应用程序的生命周期,作为单例。每次请求带有@Singleton注解的依赖项时,Dagger 都会提供相同的实例。

  • ActivityComponent:允许我们使用 @ActivityScoped 注解将依赖项的范围限定为 Activity 的生命周期。如果 Activity 被重建,将提供一个新的依赖项实例。

  • ActivityRetainedComponent:允许我们使用 @ActivityRetainedScoped 注解将依赖项的范围限定为 Activity 的生命周期,超越其方向改变后的重建。如果 Activity 在方向改变时被重建,将提供相同的依赖项实例。

  • ViewModelComponent:允许我们使用 @ViewModelScoped 注解将依赖项的范围限定为 ViewModel 的生命周期。

由于这些组件的生命周期范围不同,这也转化为每个组件从彼此那里推导出其生命周期范围,从最宽的 @Singleton 生命周期范围(应用)到更窄的范围,如 @ActivityScoped(活动):

![图 9.3 – Dagger Hilt 范围注解及其对应组件的简化版本img/B17788_09_3.jpg

图 9.3 – Dagger Hilt 范围注解及其对应组件的简化版本

在我们的餐厅应用中,我们将主要使用 SingletonComponent 和其 @Singleton 范围注解;重要的是要注意,Dagger Hilt 提供了更广泛预定义的组件和范围。您可以在以下文档中查看它们:dagger.dev/hilt/components.html

现在我们简要介绍了组件,是时候将 Hilt 添加到我们的餐厅应用中了!

设置 Hilt

在使用 Hilt 注入依赖项之前,我们必须首先设置 Hilt。让我们开始吧!

  1. 在项目级别的 build.gradle 文件中,在 dependencies 块内,添加 Hilt-Android Gradle 依赖项:

    buildscript {
        ...
        dependencies {
            ...
            classpath 'com.google.dagger:hilt-android-
                gradle-plugin:2.40.5'
        }
    }
    
  2. 在应用级别的 build.gradle 文件中移动,在 plugins 块内添加 Dagger Hilt 插件:

    plugins {
        […]
        id 'kotlin-kapt'
        id 'dagger.hilt.android.plugin'
    }
    
  3. 仍然在应用级别的 build.gradle 中,在 dependencies 块内,添加 Android-Hilt 依赖项:

    dependencies {
        […]
        implementation "com.google.dagger:hilt-
            android:2.40.5"
        kapt "com.google.dagger:hilt-compiler:2.40.5"
    }
    

kapt 关键字代表 Kotlin 注解处理器工具,它是 Dagger Hilt 生成基于我们将使用的注解的代码所必需的。

更新 build.gradle 文件后,请确保将项目与其 Gradle 文件同步。您可以通过点击 文件 菜单选项,然后选择 与 Gradle 文件同步项目 来完成此操作。

  1. 使用 @HiltAndroidApp 注解标注 RestaurantsApplication 类:

    @HiltAndroidApp
    class RestaurantsApplication: Application() { […] }
    

要使用 Hilt 的自动化依赖注入,我们必须使用 HiltAndroidApp 注解来标注我们的 Application 类。这个注解允许 Hilt 生成与依赖注入相关的样板代码,从应用级别的依赖容器开始。

  1. 构建项目以触发 Hilt 的代码生成。

  2. 可选地,如果您想查看生成的类,首先,展开左侧的 项目 选项卡,然后展开生成代码的包。这些类是 Hilt 在幕后生成大量代码的证明,这样我们可以更容易地实现依赖注入:

![Figure 9.4 – Automatically generated classes by Hilt]

![img/B17788_09_4.jpg]

图 9.4 – Hilt 自动生成的类

让我们继续实际的实现!

使用 Hilt 进行依赖注入

在本小节中,我们将使用 Hilt 在应用的第一屏中实现依赖注入,该屏显示餐厅列表。换句话说,我们希望注入 RestaurantsScreen() 所需或依赖的所有依赖项。

为了有一个起点,让我们看看 RestaurantsScreen() 目的地中的 RestaurantsApp() 组合函数,看看我们首先需要注入什么:

@Composable
private fun RestaurantsApp() {
   val navController = rememberNavController()
   NavHost(navController, startDestination = "restaurants") {
      composable(route = "restaurants") {
         val viewModel: RestaurantsViewModel = viewModel()
         RestaurantsScreen(state = viewModel.state.value, […])
      }
      composable(…) { RestaurantDetailsScreen() }
   }
}

很明显,RestaurantsScreen() 依赖于 RestaurantsViewModel 来获取其状态并消费它。

这意味着我们必须首先在 RestaurantsScreen() 所在的 composable() 目的地中注入一个 RestaurantsViewModel 实例:

  1. 由于我们无法在组合函数内部添加 @Inject 注解,我们必须使用一个特殊的组合函数来注入 ViewModel。为此,首先,在应用级别的 build.gradle 文件的 dependencies 块中添加 hilt-navigation-compose 依赖项:

    dependencies {
        […]
        implementation "com.google.dagger:hilt-
            android:2.40.5"
        kapt "com.google.dagger:hilt-compiler:2.40.5"
        implementation 'androidx.hilt:hilt-navigation-
            compose:1.0.0'
    }
    

更新 build.gradle 文件后,请确保将项目与其 Gradle 文件同步。您可以通过点击 文件 菜单选项,然后选择 与 Gradle 文件同步项目 来完成此操作。

  1. 然后,回到 RestaurantsApp() 组合函数内部,在我们的 RestaurantsScreen() 组合函数的 DSL composable() 目标中,将 RestaurantsViewModelviewModel() 构造函数替换为 hiltViewModel() 组合函数:

    @Composable
    private fun RestaurantsApp() {
       val navController = rememberNavController()
       NavHost(navController, startDestination = 
               "restaurants") {
          composable(route = "restaurants") {
             val viewModel: RestaurantsViewModel = 
                 hiltViewModel()
             RestaurantsScreen(…)
          }
          composable(…) { RestaurantDetailsScreen() }
       }
    }
    

hiltViewModel() 函数注入一个 RestaurantsViewModel 实例,其作用域与 RestaurantsScreen() 导航组件目标的生命周期相同。

  1. 由于现在我们的组合层次结构在 Hilt 的帮助下在某个点注入了一个 ViewModel,我们必须使用 @AndroidEntryPoint 注解来注释 RestaurantsApp() 根组合函数的宿主 Android 组件。在我们的例子中,RestaurantsApp() 组合函数由 MainActivity 类托管,因此我们必须使用 @AndroidEntryPoint 注解来注释它:

    @AndroidEntryPoint
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                RestaurantsAppTheme { RestaurantsApp() }
            }
        }
    }
    

@AndroidEntryPoint 注解为我们的 Activity 生成另一个组件,其生命周期比应用程序的生命周期更短。更确切地说,这个组件允许我们将依赖项的作用域限定到我们的 Activity 的生命周期。

  1. RestaurantsViewModel 类中,首先将其重构为通过将其移动到其构造函数中来显式声明其依赖项,从而通过构造函数注入提高可测试性:

    class RestaurantsViewModel constructor(
       private val getRestaurantsUseCase: 
           GetInitialRestaurantsUseCase,
        private val toggleRestaurantsUseCase: 
            ToggleRestaurantUseCase
    ) : ViewModel() {
        private val _state = mutableStateOf(...)
        [...]
    }
    

注意,虽然我们将两个 Use Case 变量提取到构造函数中,但我们不再实例化它们——我们将留给 Hilt 来完成。

  1. 为了让 Hilt 帮我们注入 RestaurantsViewModel,用 @HiltViewModel 注解标记 ViewModel,同时用 @Inject 注解其构造函数,以便 Hilt 理解 ViewModel 的哪些依赖项必须由 Hilt 提供:

    @HiltViewModel
    class RestaurantsViewModel @Inject constructor(
       private val getRestaurantsUseCase: […] ,
       private val toggleRestaurantsUseCase: […]) : 
           ViewModel() {
        [...]
    }
    

现在,由于我们的 ViewModel 被标记为 @HiltViewModelRestaurantsViewModel 的实例将由尊重 ViewModel 生命周期的 ViewModelComponent 提供(绑定到可组合目的地的生命周期,同时也能在配置更改中存活)。

  1. 现在,我们已经指导 Hilt 如何提供 RestaurantsViewModel,我们可能会认为我们已经完成了;然而,如果我们构建应用程序,我们将得到这个异常:

图 9.5 – Hilt 编译错误

图 9.5 – Hilt 编译错误

问题在于,虽然我们指示 Hilt 注入 RestaurantsViewModel 及其依赖项,但我们从未确保 Hilt 知道如何提供这些依赖项:既没有 GetInitialRestaurantsUseCase 依赖项,也没有 ToggleRestaurantsUseCase 依赖项。

换句话说,如果我们想注入 RestaurantsViewModel,我们需要确保其依赖项可以由 Hilt 提供,以及它们的依赖项,依此类推。

  1. 让我们先确保 Hilt 知道如何将 GetInitialRestaurantsUseCase 注入到 RestaurantsViewModel 中。在 GetInitialRestaurantsUseCase 类中,将其依赖项移动到构造函数中,并用 @Inject 标记,就像我们对 RestaurantsViewModel 做的那样:

    class GetInitialRestaurantsUseCase @Inject constructor(
        private val repository: RestaurantsRepository,
        private val getSortedRestaurantsUseCase: 
            GetSortedRestaurantsUseCase) {
        suspend operator fun invoke(): List<Restaurant> { … }
    }
    

在构造函数中添加 repositorygetSortedRestaurantsUseCase 变量后,请记住从 GetInitialRestaurantsUseCase 的主体中删除旧成员变量以及它们的实例化代码。

注意,我们没有在 GetInitialRestaurantsUseCase 类上使用任何 Hilt 作用域注解,仅仅是因为我们不希望它与某个生命周期作用域绑定。

现在,Hilt 知道如何注入 GetInitialRestaurantsUseCase 类,但我们也必须指导 Hilt 如何提供其依赖项:RestaurantsRepositoryGetSortedRestaurantsUseCase

我们需要确保 Hilt 知道如何提供 RestaurantsRepository 的实例。我们可以看到,它的依赖项是 RestaurantsApiService(Retrofit 接口)和 RestaurantsDao(Room 数据访问对象接口):

class RestaurantsRepository {
    private var restInterface: RestaurantsApiService =
        Retrofit.Builder()
        […]
        .create(RestaurantsApiService::class.java)
    private var restaurantsDao = RestaurantsDb
        .getDaoInstance(
            RestaurantsApplication.getAppContext()
        )
    […]
}

这里的问题是,一旦我们将这些依赖项放入构造函数并注入它们,Hilt 就将无法知道如何提供它们——仅仅是因为我们无法像对 RestaurantsViewModelGetInitialRestaurantsUseCase 和现在的 RestaurantsRepository 那样,深入 Room 或 Retrofit 的内部工作并注入它们的依赖项。

为了让 Hilt 知道如何提供我们无法触及的依赖项,我们必须创建一个 module 类,在其中我们将指导 Hilt 如何提供 RestaurantsApiServiceRestaurantsDao 的实例:

  1. 展开 restaurants 包,然后在 data 包上右键单击,创建一个名为 di(代表依赖注入)的新包。在这个包内部,创建一个名为 RestaurantsModule 的新 object 类,并在其中添加以下代码:

    @Module
    @InstallIn(SingletonComponent::class)
    object RestaurantsModule { }
    

RestaurantsModule 将允许我们指导 Hilt 如何向 RestaurantsRepository 提供 Room 和 Retrofit 依赖项。由于这是一个 Hilt 模块,我们做了以下操作:

  • 使用 @Module 注解,以便 Hilt 识别它为一个提供依赖项实例的模块。

  • 使用 @InstallIn() 注解,并传递了 Hilt 提供的预定义 SingletonComponent 组件。由于我们的模块安装在这个组件中,包含的依赖项可以在应用程序的任何地方提供,因为 SingletonComponent 是一个应用程序级别的依赖项容器。

  1. 接下来,在 RestaurantsModule 中,我们需要告诉 Hilt 如何提供我们的依赖项,因此我们将从 RestaurantsDao 开始。为了获取 RestaurantsDao 的实例,我们必须首先指导 Hilt 如何实例化 RestaurantsDb 类。

添加一个带有 @Provides 注解的 provideRoomDatabase 方法,该方法将指导 Hilt 如何通过从 RestaurantsDb 类的 companion object 中借用部分 database 类的实例化代码来提供一个 RestaurantsDb 对象:

@Module
@InstallIn(SingletonComponent::class)
object RestaurantsModule {
    @Singleton
    @Provides
    fun provideRoomDatabase(
        @ApplicationContext appContext: Context
    ): RestaurantsDb {
        return Room.databaseBuilder(
            appContext,
            RestaurantsDb::class.java,
            "restaurants_database"
        ).fallbackToDestructiveMigration().build()
    }
}

首先,我们使用 @Singleton 实例注解了 provideRoomDatabase() 方法,这样 Hilt 就会为整个应用程序创建一个 RestaurantsDb 实例,从而节省内存。

然后,我们可以看到 provideRoomDatabase() 方法构建了一个 RestaurantsDb 实例,但为了使其工作,我们需要向 Room.databaseBuilder() 方法提供应用程序级别的上下文。为此,我们通过将 Context 对象作为 provideRoomDatabase() 方法的参数传递,并使用 @ApplicationContext 注解来实现这一点。

要了解 Hilt 如何为我们提供应用程序 Context 对象,我们必须首先注意,每个 Hilt 容器都附带一组默认绑定,我们可以将其作为依赖项注入。SingletonComponent 容器通过定义 @ApplicationContext 注解,在需要的地方为我们提供应用程序级别的 Context 对象。

  1. 现在,Hilt 知道为我们提供 RestaurantsDb,我们可以创建另一个 @Provides 方法,该方法接受一个 RestaurantsDb 变量(Hilt 将知道如何提供)并返回一个 RestaurantsDao 实例:

    @Module
    @InstallIn(SingletonComponent::class)
    object RestaurantsModule {
      @Provides
    fun provideRoomDao(database: RestaurantsDb): 
          RestaurantsDao  {
            return database.dao
      }
      @Singleton
      @Provides
      fun provideRoomDatabase(
          @ApplicationContext appContext: Context
      ): RestaurantsDb { ... }
    }
    
  2. 仍然在 RestaurantsModule 中,我们现在必须告诉 Hilt 如何为我们提供一个 RestaurantsApiService 的实例。像之前一样操作,但这次添加一个为 Retrofit 实例的 @Provides 方法,以及一个为 RestaurantsApiService 实例的 @Provides 方法。现在,RestaurantsModule 应该看起来像这样:

    @Module
    @InstallIn(SingletonComponent::class)
    object RestaurantsModule {
        @Provides
        fun provideRoomDao(database: RestaurantsDb): […] {
            return database.dao
        }
        @Singleton
        @Provides
        fun provideRoomDatabase(@ApplicationContext
            appContext: Context): RestaurantsDb {  [...]  }
        @Singleton
        @Provides
        fun provideRetrofit(): Retrofit {
            return Retrofit.Builder()
                .addConverterFactory([…])
                .baseUrl("[…]")
                .build()
        }
        @Provides
        fun provideRetrofitApi(retrofit: Retrofit):  
            RestaurantsApiService {
        return retrofit
            .create(RestaurantsApiService::class.java)
        }
    }
    

记住所有这些实例化代码都位于 RestaurantsRepository 中,因此你可以从那里获取。

  1. 现在 Hilt 知道如何提供 RestaurantsRepository 的两个依赖项,让我们回到 RestaurantsRepository 类,并使用 Hilt 应用构造函数注入,通过在构造函数上添加 @Inject 注解,并将其 RestaurantsApiServiceRestaurantsDao 依赖项移动到构造函数中:

    @Singleton
    class RestaurantsRepository @Inject constructor(
        private val restInterface: RestaurantsApiService,
        private val restaurantsDao: RestaurantsDao
    ) {
        suspend fun toggleFavoriteRestaurant(…) = […]
             […]  
        }
    

通常,Repository 类有一个静态实例,这样在整个应用程序中只有一个实例被重用。当在 Repository 类中全局存储不同的数据时,这很有用(在使用系统启动的进程死亡时要小心,因为这会清除内存中的所有内容!)。

最后,为了有一个可以在整个应用程序中重用的 RestaurantsRepository 实例,我们使用 @Singleton 注解了这个类。这个注解由 Hilt 的 SingletonComponent 容器提供,允许我们将类的实例范围限定在应用程序的生命周期内。

  1. 现在 Hilt 知道如何注入 RestaurantsRepository,让我们回到 GetInitialRestaurantsUseCase 的其他剩余依赖项:GetSortedRestaurantsUseCase 类。进入这个类,并确保通过将 repository 变量移动到构造函数中(就像我们之前对其他类所做的那样)来注入其依赖项:

    class GetSortedRestaurantsUseCase @Inject constructor(
        private val repository: RestaurantsRepository
    ) {
        suspend operator fun invoke(): List<Restaurant> {
            return repository.getRestaurants()
                .sortedBy { it.title }
        }
    }
    

虽然我们已经用范围注解注解了 RestaurantsRepository,但我们没有为这个 Use Case 类添加任何范围注解,仅仅是因为我们不希望实例在特定的生命周期内被保留。

现在,我们已经指导 Hilt 如何为 RestaurantsViewModel 的第一个依赖项提供所有依赖项,即 GetInitialRestaurantsUseCase

  1. 接下来,让我们告诉 Hilt 如何为 RestaurantsViewModel 的第二个和最后一个依赖项 ToggleRestaurantUseCase 类提供依赖项。进入这个类,并确保通过将 repositorygetSortedRestaurantsUseCase 变量移动到构造函数中(就像我们之前对其他类所做的那样)来注入其依赖项:

    class ToggleRestaurantUseCase @Inject constructor(
        private val repository: RestaurantsRepository,
    private val getSortedRestaurantsUseCase: 
            GetSortedRestaurantsUseCase
    ) {
        suspend operator fun invoke(id: Int, oldValue: 
            Boolean): List<Restaurant> {
            val newFav = oldValue.not()
            repository.toggleFavoriteRestaurant(id, newFav)
            return getSortedRestaurantsUseCase()
        }
    }
    
  2. 可选地,你可以进入 RestaurantsDb 类并删除负责为我们 RestaurantsDao 提供单例实例的整个 companion object。现在,RestaurantsDb 类应该更加精简,看起来应该是这样的:

    @Database(
        entities = [LocalRestaurant::class],
        version = 3,
        exportSchema = false
    )
    abstract class RestaurantsDb : RoomDatabase() {
        abstract val dao: RestaurantsDao
    }
    

现在可以安全地删除这个实例化代码,因为从现在开始,Hilt 会为我们自动完成这项工作。

  1. 此外,如果你遵循了清理 RestaurantsDb 类的上一步骤,在 RestaurantsApplication 中,你也可以删除这个类中与获取应用程序级 Context 对象相关的所有逻辑。从现在开始,Hilt 会为我们自动完成这项工作。

RestaurantsApplication 类应该更加精简,看起来应该是这样的:

@HiltAndroidApp
class RestaurantsApplication: Application()
  1. 构建并运行应用程序。现在,构建应该成功,因为 Hilt 负责提供我们需要的依赖项。

在 DI 的帮助下,我们现在提高了测试性,同时也提取了与构建类实例相关的样板代码。

作业

我们已经将 DI 与 Hilt 集成到 RestaurantsApplication 的第一个屏幕中。然而,项目还没有完全采用 DI,因为我们的应用第二个目的地(由 RestaurantDetailsScreen() 组合表示)既没有注入其 RestaurantDetailsViewModel,也没有注入这个 ViewModel 类的依赖项。作为课后作业,将 DI 集成到这个第二个屏幕中。这将允许你从 RestaurantDetailsViewModel 中移除冗余的 Retrofit 客户端实例化 – 记住,你现在可以直接使用 Hilt 注入 RestaurantsApiService 实例!

摘要

在本章中,我们通过引入 DI 改进了“餐厅应用”的架构。

我们讨论了什么是 DI,并涵盖了其基本概念:具有隐式或显式类型的依赖项、注入、依赖容器和手动注入。

我们随后检查了 DI 为我们的项目带来的主要好处:可测试的类和更少的样板代码。

最后,我们介绍了 DI 框架如何帮助我们进行依赖项注入,并探讨了 Jetpack Hilt 库作为 Android 上 DI 的可行解决方案。之后,我们在“餐厅应用”中结合 Hilt 实施了我们所学的知识。

自从我们引入了依赖注入(DI)后,我们的类可以更容易地进行测试这一点变得更加清晰,因此是时候在下一章开始编写一些测试了!

进一步阅读

通常情况下,了解如何使用 Hilt 的基础知识就足够应对大多数项目。然而,有时你可能需要使用 Hilt 或 Dagger 的更高级功能。要了解更多关于 Dagger 以及框架如何通过构建依赖图自动为你创建依赖项的信息,请查看这篇文章:medium.com/android-news/dagger-2-part-i-basic-principles-graph-dependencies-scopes-3dfd032ccd82

同样地,除了我们应用中最常用的 @Singleton 作用域之外,Dagger Hilt 还公开了更广泛预定义的组件和作用域,允许你将不同的类范围到各种生命周期。更多关于组件及其作用域的信息,请查看官方文档:dagger.dev/hilt/components.html

除了组件及其作用域之外,在某些项目中,你可能需要允许在除了 Activity 之外的其他 Android 类中进行依赖项的注入。要查看哪些 Android 类可以被标注为 @AndroidEntryPoint,请查阅文档:https://dagger.dev/hilt/android-entry-point。

第十章:第十章:使用 UI 和单元测试测试您的应用

在前面的章节中,我们主要关注的是拥有可测试的架构。我们试图通过解耦不同的组件来实现这一点。

在本章中,由于我们实施的架构,我们将看到测试餐厅应用的不同部分是多么容易进行隔离。

探索测试基础部分,我们将了解测试的好处并探索各种测试类型。在学习测试 Compose UI 的基础部分,我们将学习如何测试我们的 Compose UI。

最后,在覆盖单元测试核心逻辑的基础部分,我们将学习如何测试餐厅应用的核心功能。

总结来说,在本章中,我们将涵盖以下部分:

  • 探索测试的基础

  • 学习测试您的 Compose UI 的基础

  • 覆盖单元测试核心逻辑的基础

在深入之前,让我们为本章设置技术要求。

技术要求

为本章构建基于 Compose 的 Android 项目通常需要您日常使用的工具。然而,为了顺利跟进,请确保您还具备以下条件:

  • Arctic Fox 2020.3.1 版本的 Android Studio。您也可以使用更新的 Android Studio 版本或 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 安装在 Android Studio 中的 Kotlin 1.6.10 或更新的插件。

  • 前一章中餐厅应用的代码。

本章的起点是前一章中开发的餐厅应用。如果您没有跟随前一章的实现,可以通过导航到存储库的Chapter_09目录并导入名为chapter_9_restaurants_app的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到Chapter_10目录:github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_10/chapter_10_restaurants_app

探索测试的基础

在本节中,我们将简要介绍测试的基础。更确切地说,我们将做以下几件事:

  • 理解测试的好处

  • 探索测试类型

让我们从测试的好处开始吧!

理解测试的好处

测试我们的代码是至关重要的。通过测试,我们确保我们的应用功能行为是正确的,符合预期,同时确保它可用,就像它被设计的那样。通过执行测试,我们可以向最终用户发布稳定且功能齐全的应用。

更重要的是,如果我们开发了一个应用并持续对其进行测试,我们就能确保新的更新(带有新功能)不会破坏现有功能,并且不会出现新的错误。这通常被称为回归测试

您可以通过在您的设备或模拟器上导航应用并确保每条数据都正确显示,同时能够正确与每个 UI 组件交互,来手动测试您的应用。

然而,手动测试既不高效也不快速。使用手动测试,您必须遍历每个用户流程,生成每个用户交互,并验证在任何时刻显示的数据的完整性。此外,您必须在每次应用更新时持续这样做。此外,手动测试的扩展性较差,因为每当包含新功能的每个新更新时,测试整个应用的手动工作量都会增加。

随着时间的推移,手动测试对于中大型应用来说变得负担沉重。此外,手动测试涉及人工测试员,这会产生人为因素——这基本上意味着测试员在某些情况下可能会忽略一些错误。

为了缓解这些问题,在本章中,我们将编写自动化测试。实际上,我们将定义一些脚本测试,然后让工具自动运行它们。这种方法更快、更一致、更高效,因为它可以更好地适应项目规模。

换句话说,我们将编写其他代码块来测试我们应用中的代码。虽然这听起来可能有些奇怪,但拥有自动化测试的方法更加高效、可靠,并且比手动测试节省时间。

接下来,让我们来了解我们可以编写的不同类型的测试。

探索测试类型

为了更好地理解如何编写测试,我们首先必须决定在我们的应用中确切可以测试什么。从这个角度来看,让我们来了解一下最重要的测试类型:

  • 功能测试:应用是否在按预期工作?我们已经在“理解测试的好处”部分提到了功能测试及其优势。

  • 兼容性测试:应用是否在所有设备和 Android API 级别上都能正确工作?如果你考虑到设备的多样性和制造商,Android 生态系统使得这一点尤其困难。

  • 性能测试:应用是否足够快或高效?有时,应用可能会遭受瓶颈和 UI 卡顿,这些可以通过性能基准测试来识别。

  • 可访问性测试:应用是否与可访问性服务配合良好?这些服务用于帮助有障碍的用户使用我们的 Android 应用。

在本章中,我们将主要关注功能测试,以确保我们应用的功能完整性。

现在,除了决定要测试什么之外,我们还必须考虑测试的范围或大小。范围表示我们正在测试的应用程序部分的大小。从测试范围的角度来看,我们有以下内容:

  • 单元测试:通常被称为小型测试,这些测试在隔离环境中测试方法、类或类组的函数行为。通常,单元测试针对应用程序的小部分,不与真实世界环境交互;因此,它们比依赖于外部输入的测试更可靠。

  • 集成测试:通常被称为中等测试,这些测试是否多个单元相互作用并正确地一起工作。

  • 端到端测试:通常被称为大型测试,这些测试覆盖了应用程序的大面积,从多个屏幕到整个用户流程。

根据测试的大小,每种类型都有一定程度的隔离。隔离程度与测试范围紧密相关,因为它衡量了我们正在测试的组件对其他组件的依赖程度。随着测试大小的增加,从小到大,测试的隔离级别会降低。

在本章中,我们将主要关注单元测试,因为它们运行速度快,设置简单,并且最可靠地帮助我们验证应用程序的功能。这些特性与单元测试从外部组件的高隔离级别紧密相关。

最后,我们还必须根据它们将要运行的系统对测试进行分类:

  • 本地测试:在您的工作站或开发系统上运行(用于持续集成CI)等实践),无需使用 Android 设备或模拟器。它们通常很小且运行速度快,将测试的组件与其他应用程序部分隔离开来。大多数情况下,单元测试都是本地测试。

  • 仪器化测试:在 Android 设备上运行,无论是物理设备还是模拟器。大多数情况下,UI 测试被认为是仪器化测试,因为它们允许在 Android 设备上自动测试应用程序。

在本章中,当我们将测试某些组件的核心逻辑并隔离时,我们的单元测试将是本地的;当我们对特定屏幕进行 UI 单元测试并隔离时,它们将是仪器化的。

让我们先从本地 UI 测试开始!

学习测试 Compose UI 的基础知识

UI 测试使我们能够评估我们的 Compose 代码的行为是否符合预期。这样,我们可以在 UI 开发过程的早期阶段捕捉到错误。

要测试我们的 UI,我们首先必须决定我们想要评估什么。为了简化,在本节中,我们将在一个隔离环境中对 UI 进行单元测试。换句话说,我们想要测试以下内容:

  • 我们的 composable 屏幕按预期消耗接收到的状态。我们想确保 UI 正确地表示它可以接收的不同状态值。

  • 对于我们的可组合屏幕,用户生成的事件被正确地转发到可组合的调用者。

为了保持我们的测试简单,我们将定义这些测试为单元测试,并尝试将屏幕可组合组件与其ViewModel或其他屏幕可组合组件隔离;否则,我们的测试将变成集成测试或端到端测试。

换句话说,我们将分别测试每个屏幕,完全不考虑其可组合功能定义之外的内容。即使我们的测试将在 Android 设备上运行,它们也只会测试一个单元——一个屏幕可组合组件。

注意

一些 UI 测试也可以被认为是单元测试,只要它们只测试您应用程序 UI 的一个部分,就像我们在这个部分将要做的那样。

首先,我们需要测试我们应用程序的第一个屏幕,由RestaurantsScreen()可组合组件表示。让我们开始吧!

  1. 首先,在 app 级别的build.gradle文件的dependencies块中添加以下测试依赖项:

    dependencies {
        […]
        androidTestImplementation "androidx.compose.ui:ui-
            test-junit4:$compose_version"
        debugImplementation "androidx.compose.ui:ui-test-
            manifest:$compose_version"
    }
    

这些依赖项将允许我们在 Android 设备上运行我们的 Compose UI 测试。

在更新build.gradle文件后,请确保将您的项目与其 Gradle 文件同步。您可以通过点击文件菜单选项,然后选择同步项目与 Gradle 文件来完成此操作。

  1. 在创建测试类之前,找到适合仪器测试的androidTest包:

![图 10.1 – 观察 UI 测试的 androidTest 包]

![img/B17788_10_01.jpg]

![图 10.1 – 观察 UI 测试的 androidTest 包]

在 Android 项目中,此目录存储 UI 测试的源文件。此外,请注意预构建的ExampleInstrumentedTest类位于此目录中。

  1. androidTest包内创建一个名为RestaurantsScreenTest的空 Kotlin 类。

在这个类中,我们将为每个独立的测试定义一个方法。幕后,每个方法都将成为一个独立的 UI 测试,它可以成功或失败。

  1. 在创建我们的第一个测试方法之前,在RestaurantsScreenTest类内部添加以下代码:

    import androidx.compose.ui.test.junit4.*
    import org.junit.Rule
    class RestaurantsScreenTest {
        @get:Rule
    val testRule: ComposeContentTestRule = 
            createComposeRule()
    }
    

为了运行我们的 Compose UI 测试,我们使用 JUnit 测试框架,它将允许我们在隔离环境中使用测试规则编写可重复的单元测试。测试规则允许我们在测试类中的所有测试中添加功能。

在我们的情况下,我们需要在每个测试方法中测试 Compose UI,因此我们必须使用一个特殊的ComposeContentTestRule对象。为了访问此规则,我们之前已经导入了一个特殊的 JUnit 规则依赖项,因此我们的测试类现在定义了一个testRule变量,并通过使用createComposeRule()方法对其进行实例化。

ComposeContentTestRule不仅允许我们设置要测试的 Compose UI,还可以在 Android 设备上运行测试,同时让我们能够与测试中的可组合组件交互或执行 UI 断言。

在编写我们的第一个测试方法之前,我们需要清楚地了解我们试图测试的行为。

让我们看看我们的RestaurantsScreen()可组合组件是如何从其state参数中消费RestaurantsScreenState实例的,以及它是如何通过onItemClickonFavoriteClick函数参数将事件传递给调用者的:

@Composable
fun RestaurantsScreen(
    state: RestaurantsScreenState,
    onItemClick: (id: Int) -> Unit,
    onFavoriteClick: (id: Int, oldValue: Boolean) -> Unit
) {
    Box(…) {
        LazyColumn(…) {
            items(state.restaurants) { restaurant ->
                RestaurantItem(restaurant,
                   onFavoriteClick = { id, oldValue ->
                        onFavoriteClick(id, oldValue) },
                   onItemClick = { id -> 
                        onItemClick (id) })
            }
        }
        if(state.isLoading)
            CircularProgressIndicator()
        if(state.error != null)
            Text(state.error)
    }
}

通过查看前面的代码片段,我们可以看到我们可以测试onItemClickonFavoriteClick函数是如何根据不同的 UI 交互被调用的,以及我们可以测试状态是否被正确消费。然而,我们无法很好地推断出我们的可组合组件接收到的状态的可能值。

为了了解我们想要输入到RestaurantsScreen()中以测试其行为的可能状态,我们需要查看其状态生成器,RestaurantsViewModel

class RestaurantsViewModel @Inject constructor(…) : ViewModel() {
    private val _state = mutableStateOf(
        RestaurantsScreenState(
            restaurants = listOf(),
            isLoading = true
        )
    )
    […]
    private val errorHandler = CoroutineExceptionHandler
    { ... ->
        exception.printStackTrace()
        _state.value = _state.value.copy(
            error = exception.message,
            isLoading = false)
}
    init { getRestaurants() }
    fun toggleFavorite(itemId: Int, oldValue: Boolean) {
        […]
    }
    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            val restaurants = getRestaurantsUseCase()
            _state.value = _state.value.copy(
                restaurants = restaurants,
                isLoading = false)
        }
    }
}

我们可以说,我们的屏幕应该有三个可能的状态:

  • ViewModel类的初始化中,_state变量,其中RestaurantsScreenStaterestaurants参数设置为emptyList(),而isLoading参数设置为true

  • getRestaurants()方法中,我们修改了初始状态,将isLoading参数设置为false,同时将餐厅列表传递给restaurants参数。

  • CoroutineExceptionHandler中,将isLoading参数设置为false以重置加载状态,同时将Exception的消息传递给error参数。

既然我们知道RestaurantsScreen可组合组件应该表现出什么行为以及我们应该传递什么输入来产生这种行为,那么现在是时候实际上对我们的可组合屏幕进行测试了。

让我们先验证RestaurantsScreen可组合组件是否正确渲染了第一个状态——即初始加载状态。

  1. RestaurantsScreenTest类内部,添加一个名为initialState_isRendered()的空测试函数,稍后它将测试我们的RestaurantsScreen()可组合组件是否正确渲染初始状态:

    class RestaurantsScreenTest {
        @get:Rule
        val testRule: ComposeContentTestRule = 
            createComposeRule()
    
        @Test
        fun initialState_isRendered() {  }
    }
    

为了让 JUnit 测试库运行此方法的单个测试,我们已使用@Test注解对其进行注解。

此外,请注意,我们围绕该方法试图测试的具体行为来命名这个方法,从我们要测试的内容(初始状态)到它应该如何表现(正确渲染),同时用下划线将这两个状态分开。对于单元测试,有许多命名约定,但我们将尝试坚持之前提到的简单版本。

注意

每个带有@Test注解的测试方法都应该只关注一个特定的行为,就像initialState_isRendered()将测试RestaurantsScreen()是否正确渲染初始状态,而不涉及其他部分。这使我们能够专注于每个测试方法上的单一行为,以便我们可以在以后更好地识别哪些具体行为不再按预期工作。

  1. 准备initialState_isRendered()方法,通过调用testRule.setContent()来设置 Compose UI,就像我们的MainActivity使用自己的setContent()方法一样:

        @Test
        fun initialState_isRendered() {
            testRule.setContent { }
        }
    
  2. setContent()方法暴露的代码块内部,我们必须传递被测单元,这实际上就是我们试图测试的可组合组件。

在我们的情况下,我们将传递RestaurantsScreen()可组合组件,而不是在将其包裹在RestaurantsAppTheme()主题函数之前,这样被测试的 Compose UI 将模仿我们的实际生产代码中显示的内容:

@Test
fun initialState_isRendered() {
    testRule.setContent {
        RestaurantsAppTheme {
            RestaurantsScreen()
        }
    }
}

如果你给你的应用命名不同,那么主题可组合组件可能有不同的定义。

  1. 现在,RestaurantsScreen()可组合组件正在期待一个RestaurantsScreenState对象作为其state参数,以及两个函数用于其onFavoriteClick()onItemClick()参数。让我们添加这些,同时传递屏幕ViewModel中预期的初始状态:

        @Test
        fun initialState_isRendered() {
            testRule.setContent {
                RestaurantsAppTheme {
                    RestaurantsScreen(
                        state = RestaurantsScreenState(
                            restaurants = emptyList(),
                            isLoading = true),
    onFavoriteClick = 
                            {  _: Int, _: Boolean ->  },
                        onItemClick = { })
                }
            }
        }
    

由于我们正在测试RestaurantsScreen()是否正确渲染初始状态,我们传递了一个RestaurantsScreenState实例,其中restaurants参数设置为emptyList(),而isLoading参数设置为true,而error参数默认设置为null

我们现在已经完成了RestaurantsScreen()可组合组件的设置,并提供了预期的初始状态。现在是时候执行断言,以确定我们的可组合组件是否正确消费了这个初始状态。

RestaurantsScreen()可组合组件中,初始状态主要由表示应用等待内容的加载指示器定义:

@Composable
fun RestaurantsScreen(…) {
    Box(…) {
        LazyColumn(…) {…}
        if(state.isLoading)
            CircularProgressIndicator()
        if(state.error != null)
            Text(state.error)
    }
}

正因如此,我们才能检查屏幕上是否可见CircularProgressIndicator()。但我们是怎样断言这个可组合组件是否可见的呢?

Compose 为我们提供了几个测试 API,帮助我们查找元素、验证它们的属性,甚至执行用户操作。对于使用 Compose 的 UI 测试,我们将 UI 的各个部分视为节点,我们可以借助语义来识别。语义为 UI 元素赋予意义,而对于整个可组合组件层次结构,会生成一个语义树来描述它。

换句话说,我们应该能够借助其暴露的语义来识别屏幕上描述的任何内容。

以一个Text可组合组件为例,它显示一个String对象,例如"Hello",它将成为语义树中的一个节点,我们可以通过其text属性值——"Hello"来识别。同样,Image等可组合组件暴露了一个强制性的contentDescription参数,其值将允许我们在测试中识别语义树中的相应节点。不用担心——我们将在下一秒看到一个实际例子。

注意

虽然语义属性主要用于辅助功能目的(例如,contentDescription是一个参数,它允许残疾人更好地理解它所描述的视觉元素),但它也是一个很好的工具,它暴露了用于在测试中识别节点所使用的语义。

现在我们简要介绍了如何使用语义信息来识别 UI 元素作为节点,现在是时候回到我们的测试了,该测试应该验证在由RestaurantsScreen()消耗的初始状态下,其CircularProgressIndicator()是否可见。

然而,如果我们再次审视CircularProgressIndicator()的使用,我们可以看到它没有暴露任何语义,我们可以在之后的测试中用来识别它:

@Composable
fun RestaurantsScreen(…) {
    Box(…) {
        LazyColumn(…) {…}
        if(state.isLoading)
            CircularProgressIndicator()
        […]
    }
}

没有提供contentDescription参数,也没有显示任何视觉文本。为了能够识别CircularProgressIndicator()的节点,我们必须手动添加一个语义contentDescription属性。

  1. 让我们暂时离开androidTest目录,回到我们的生产代码所在的主包内部。在presentation包内部,创建一个名为Description的新object类,并定义一个常量描述String变量用于我们的加载组合:

    object Description {
        const val RESTAURANTS_LOADING =
                "Circular loading icon"
    }
    
  2. RestaurantsScreen()组合内部,将semantics修饰符传递给CircularProgressIndicator()组合,并将其contentDescription属性设置为之前定义的RESTAURANTS_LOADING

    @Composable
    fun RestaurantsScreen(…) {
        Box(…) {
            LazyColumn(…) { … }
            if (state.isLoading)
                CircularProgressIndicator(
                    Modifier.semantics {
                        this.contentDescription =
                           Description.RESTAURANTS_LOADING
                    })
            […]
        }
    }
    

现在,我们将能够通过使用contentDescription语义属性来识别 UI 测试中由CircularProgressIndicator()组合表示的节点。

  1. 现在,回到androidTest目录内部,导航到RestaurantsScreenTest类,并在initialState_isRendered()测试方法中,使用testRule变量通过onNodeWithContentDescription()方法识别具有RESTAURANT_LOADING内容描述的节点,并最终使用assertIsDisplayed()方法验证节点是否显示:

    @Test
    fun initialState_isRendered() {
        testRule.setContent {
            RestaurantsAppTheme { RestaurantsScreen(…) }
    initialState_isRendered() method, every test method has two parts – the setup of the expected behavior and then the assertions that verify that the resultant behavior is correct.
    
  2. RestaurantsScreenTest类内部选择运行 RestaurantsScreenTest

此命令将在该类内部运行所有测试(在我们的例子中只有一个)在 Android 设备上(无论是你的物理 Android 设备还是你的模拟器)。

如果我们切换到RestaurantsScreen()是否正确渲染并通过了测试:

图 10.2 – 观察已通过的 UI 测试

图 10.2 – 观察已通过的 UI 测试

注意

虽然我们已经定义了一个通过语义属性识别 UI 元素的测试,但也可以通过使 UI 元素包含一个testTag修饰符来匹配一个 UI 元素,该修饰符稍后可以通过hasTestTag()匹配器识别。然而,你应该避免这种做法,因为这将使你的 Compose UI 生产代码被仅用于测试的测试标识符所污染。

当你的测试在 Android 设备或模拟器上运行时,你可能会注意到其屏幕上没有显示任何 UI。这是因为 UI 测试非常快。如果你想看到你正在测试的 UI,你可以在测试方法末尾添加一个 Thread.sleep() 调用;然而,你应该避免在生产测试代码中这样做。

现在,是时候测试 RestaurantsScreen() 组件是否正确渲染另一个状态了——带有内容的州。在这个状态下,餐厅已经到达,因此我们将加载状态重置为 false 并渲染餐厅。

  1. RestaurantsScreenTest 类内部,添加另一个名为 stateWithContent_isRendered() 的测试函数,该函数应测试带有内容的州是否正确渲染:

    @Test
    fun stateWithContent_isRendered() {
        testRule.setContent {
            RestaurantsAppTheme {
                RestaurantsScreen(
                    state = RestaurantsScreenState(
                        restaurants =,
                        isLoading = false),
                    onFavoriteClick =  
                        { _: Int, _: Boolean -> },
                    onItemClick = { }
                )
            }
        }
    }
    

在这个测试方法内部,我们已经将 RestaurantsScreen() 组件的状态设置为 isLoading 字段为 false(因为餐厅已经到达),但还没有传递餐厅列表。我们需要创建一个模拟餐厅列表来模拟数据层的一些餐厅。

  1. 让我们暂时离开 androidTest 目录,回到我们的生产代码所在的主包内部。在 restaurants 包内部,创建一个名为 DummyContent 的新 object 类,并在该类内部添加一个 getDomainRestaurants() 方法,该方法将返回一个包含 Restaurant 对象的模拟数组列表:

    object DummyContent {
        fun getDomainRestaurants() = arrayListOf(
            Restaurant(0, "title0", "description0", false),
            Restaurant(1, "title1", "description1", false),
            Restaurant(2, "title2", "description2", false),
            Restaurant(3, "title3", "description3", false))
    }
    
  2. 现在,回到 androidTest 目录,导航到 RestaurantsScreenTest 类。在 stateWithContent_isRendered() 方法内部,声明一个 restaurants 变量,该变量将保存来自 DummyContent 类的模拟餐厅,并将其传递给 RestaurantsScreenStaterestaurants 参数:

    @Test
    fun stateWithContent_isRendered() {
        val restaurants = DummyContent.getDomainRestaurants()
        testRule.setContent {
            RestaurantsAppTheme {
                RestaurantsScreen(
                    state = RestaurantsScreenState(
                        restaurants = restaurants,
                        isLoading = false), […])
            }
        }
    }
    

现在我们已经完成了这个测试方法的设置部分,是时候执行我们的断言了。由于我们正在测试 RestaurantsScreen() 是否正确渲染包含餐厅的状态,让我们快速查看一下正在测试的组件:

@Composable
fun RestaurantsScreen(state: RestaurantsScreenState, […]) {
    Box(…) {
        LazyColumn(…) {
            items(state.restaurants) { restaurant ->
                 RestaurantItem(…)
             }
        }
        if(state.isLoading)
            CircularProgressIndicator()
        if(state.error != null)
            Text(state.error)
    }
}

我们可以推断出我们可以断言的两个条件如下:

  • 来自 RestaurantsScreenState 的餐厅显示在屏幕上。

  • CircularProgressIndicator() 组件没有被渲染,因此其节点在屏幕上不可见。

让我们从第一个断言开始。我们不必依赖于 contentDescription 语义属性,而可以使用另一个更明显的语义属性——屏幕上显示的文本。由于 LazyColumn 将渲染一个 RestaurantItem() 组件的列表,每个组件都会调用一个 Text 组件,该组件将渲染传递给其 text 参数的餐厅标题和描述。借助我们的 ComposeContentTestRule,我们可以通过调用 onNodeWithText() 方法来识别具有特定文本值的节点。

  1. 回到 stateWithContent_isRendered() 方法,让我们断言从我们的模拟列表中第一个 Restaurant 对象的 title 是可见的。

通过将restaurants变量中第一个元素的标题传递给onNodeWithText()方法,从而识别其对应的节点。最后,调用assertIsDisplayed()方法来验证此节点是否显示:

@Test
fun stateWithContent_isRendered() {
    val restaurants = DummyContent.getDomainRestaurants()
    testRule.setContent {
        RestaurantsAppTheme {
            RestaurantsScreen(
                state = RestaurantsScreenState(
                    restaurants = restaurants,
                    isLoading = false
                ),
                […])
        }
    }
    testRule.onNodeWithText(restaurants[0].title)
        .assertIsDisplayed()
}
  1. 同样,为了断言我们模拟列表中第一家餐厅的标题节点是否显示,验证第一家餐厅的描述节点是否显示:

    @Test
    fun stateWithContent_isRendered() {
        val restaurants = DummyContent.getDomainRestaurants()
        testRule.setContent { ... }
        testRule.onNodeWithText(restaurants[0].title)
    .assertIsDisplayed()
        testRule.onNodeWithText(restaurants[0].description)
            .assertIsDisplayed()
    }
    

你可能想知道为什么我们不断言DummyContent类中所有元素的titledescription是否可见。重要的是要理解,我们的测试是断言屏幕上是否显示了某些节点。

因此,如果我们的restaurants列表包含 10 或 15 个元素,并且我们测试所有标题和描述节点是否可见,那么在设备较高的设备上,这个测试方法可能会通过,因为所有餐厅都会适应屏幕并显示,但如果测试设备较小,只有一些餐厅适应屏幕并显示,那么测试可能会失败。

这会使我们的测试变得不可靠。为了防止测试变得不可靠,我们只断言第一家餐厅是否可见,因此最小化了测试在极小屏幕上运行失败的可能性。

为了测试内容是否正确渲染,你可以采取的一个有趣的策略是在测试中模拟滚动动作到底部,并检查最后一个元素是否可见。然而,这更复杂,所以我们将继续使用我们已实现的简单版本。

  1. 最后,让我们断言对应于CircularProgressIndicator()可组合组件的节点不存在,从而确保应用不再加载任何内容。通过在具有RESTAURANTS_LOADING内容描述的节点上调用assertDoesNotExist()方法来完成此操作:

    @Test
    fun stateWithContent_isRendered() {
        val restaurants = DummyContent.getDomainRestaurants()
        testRule.setContent { … }
        testRule.onNodeWithText(restaurants[0].title)
            .assertIsDisplayed()
        testRule.onNodeWithText(restaurants[0].description)
            .assertIsDisplayed()
        testRule.onNodeWithContentDescription(
            Description.RESTAURANTS_LOADING
        ).assertDoesNotExist()
    }
    
  2. 现在我们已经完成了编写第二个测试方法,断言RestaurantsScreen()可组合组件是否正确渲染了带有内容的界面状态,在RestaurantsScreenTest类中并选择运行 RestaurantsScreenTest

测试应该运行并通过。

作业

尝试自己编写一个测试方法,断言RestaurantsScreen()可组合组件是否正确渲染了错误状态。作为一个提示,你应该将错误文本传递给RestaurantsScreen()error参数,然后你应该断言具有该特定文本的节点是否可见,同时验证对应于CircularProgressIndicator()可组合组件的节点不存在。

最后,让我们编写一个测试方法,其中我们可以验证点击我们模拟列表中的餐厅元素时,父RestaurantsScreen()可组合组件是否暴露了正确的回调:

  1. RestaurantsScreenTest 类中,添加另一个名为 stateWithContent_ClickOnItem_isRegistered() 的测试函数。在这个方法中,将模拟列表存储在 restaurants 变量中,然后将我们将要点击的第一个餐厅存储在 targetRestaurant 变量中:

    @Test
    fun stateWithContent_ClickOnItem_isRegistered() {
        val restaurants = DummyContent.getDomainRestaurants()
        val targetRestaurant = restaurants[0]
    }
    
  2. 然后,将 RestaurantsScreen() 设置为测试对象,并通过将 restaurants 变量的内容传递给 RestaurantsScreenStaterestaurants 参数来提供具有内容的状态:

    @Test
    fun stateWithContent_ClickOnItem_isRegistered() {
        val restaurants = DummyContent.getDomainRestaurants()
    val targetRestaurant = restaurants[0]
        testRule.setContent {
            RestaurantsAppTheme {
                RestaurantsScreen(
                    state = RestaurantsScreenState(
                        restaurants = restaurants,
                        isLoading = false),
                    onFavoriteClick = { _, _ -> },
                    onItemClick = { id ->  })
            }
        }
    }
    
  3. 然后,识别包含 targetRestauranttitle 文本的节点,然后通过调用 performClick() 方法模拟用户点击此节点:

    @Test
    fun stateWithContent_ClickOnItem_isRegistered() {
        val restaurants = DummyContent.getDomainRestaurants()
        val targetRestaurant = restaurants[0]
        testRule.setContent {
            RestaurantsAppTheme {
                RestaurantsScreen(
                    state = RestaurantsScreenState(
                        restaurants = restaurants,
                        isLoading = false),
                    onFavoriteClick = { _, _ -> },
                    onItemClick = { id -> })
            }
        }
        testRule.onNodeWithText(targetRestaurant.title)
            .performClick()
    }
    
  4. 现在我们已经模拟了用户点击交互,让我们断言由 RestaurantsScreen() 组合函数暴露的 onItemClick 回调中的 id 值与我们所点击的餐厅的 id 值相匹配:

    @Test
    fun stateWithContent_ClickOnItem_isRegistered() {
        val restaurants = DummyContent.getDomainRestaurants()
        val targetRestaurant = restaurants[0]
        testRule.setContent {
            RestaurantsAppTheme {
                RestaurantsScreen(
                    state = RestaurantsScreenState(
                        restaurants = restaurants,
                        isLoading = false),
                    onFavoriteClick = { _, _ -> },
                    onItemClick = { id ->
                        assert(id == targetRestaurant.id)
                    }
                )
            }
        }
        testRule.onNodeWithText(targetRestaurant.title)
            .performClick()
    }
    
  5. RestaurantsScreenTest 类中,选择运行 RestaurantsScreenTest

这三个测试应该运行并通过。

注意

你可能已经注意到,我们没有关注测试当用户切换餐厅为收藏或非收藏时 UI 如何更新。我们能够做到这一点的方法是为列表中每个餐厅的心形图标添加一个专门的语义属性,然后测试该属性的值。然而,我们会测试一个语义属性值而不是 UI – 对于此类情况,最好考虑截图测试策略。截图测试是一种 UI 测试实践,它生成应用程序的截图,然后将其与最初定义的正确版本进行比较。

现在我们简要介绍了使用 Compose 的 UI 测试,是时候对应用程序的后台功能进行单元测试了!

覆盖单元测试核心逻辑的基础

除了测试我们的 UI 层之外,我们还必须测试应用程序的核心逻辑。这意味着我们应该尽可能多地验证表示逻辑(测试 ViewModel 类)、业务逻辑(测试 UseCase 类)或甚至数据逻辑(测试 Repository 类)的行为。

验证此类逻辑最简单的方法是为我们试图验证的每个类或类组编写单元测试。

在本节中,我们将编写 RestaurantsViewModel 类和 ToggleRestaurantUseCase 类的单元测试。由于这些组件不直接与 UI 交互,它们的单元测试将直接在本地工作站的 Java 虚拟机JVM)上运行,而不是像我们的 UI 测试那样在 Android 设备上运行。

总结来说,在本节中,我们将执行以下操作:

  • 测试 ViewModel 类的功能

  • 测试 UseCase 类的功能

让我们从测试 RestaurantsViewModel 类开始!

测试 ViewModel 类的功能

我们想要测试 RestaurantsViewModel 的功能,以确保它正确地执行了 RestaurantsScreen() 组合组件的状态生产者角色。

为了实现这一点,我们将为这个 ViewModel 类单独编写单元测试。让我们开始:

  1. 首先,找到适合常规单元测试的 test 包:

![图 10.3 – 观察用于常规单元测试的测试包图片

图 10.3 – 观察用于常规单元测试的测试包

此外,请注意,预构建的 ExampleUnitTest 类位于这个包内。

  1. test 包内创建一个名为 RestaurantsViewModelTest 的空 Kotlin 类。在这个类中,我们将为每个独立的测试定义一个方法。幕后,每个方法都将成为一个独立的单元测试,它可以通过或失败。

在开始编写我们的第一个测试方法之前,让我们再次查看 RestaurantsViewModel 类,以便我们可以提醒自己我们要测试哪些情况:

class RestaurantsViewModel @Inject constructor(…) : ViewModel() {
    private val _state = mutableStateOf(
        RestaurantsScreenState(
            restaurants = listOf(),
            isLoading = true
        )
    )
    […]
    private val errorHandler = CoroutineExceptionHandler
    { … ->
        exception.printStackTrace()
        _state.value = _state.value.copy(
            error = exception.message, 
            isLoading = false)
    }
    init { getRestaurants() }
    fun toggleFavorite(itemId: Int, oldValue: Boolean) {
        […] 
    }
    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) {
            val restaurants = getRestaurantsUseCase()
            _state.value = _state.value.copy(
                restaurants = restaurants,
                isLoading = false)
        }
    }
}

我们可以说,我们的 RestaurantsViewModel 应该产生我们提供给 RestaurantsScreen() 组合组件的精确的三种状态,这些状态在其自己的 UI 测试中:

  • ViewModel 类的初始化中 _state 变量,其中 RestaurantsScreenStaterestaurants 参数设置为 emptyList(),而 isLoading 参数设置为 true

  • getRestaurants() 方法,其中我们修改了初始状态,并将 isLoading 参数设置为 false,同时将餐厅列表传递给 restaurants 参数。

  • CoroutineExceptionHandler,其中 isLoading 参数设置为 false 以重置加载状态,同时将 Exception 的消息传递给 error 参数。

最后,我们基本上必须断言暴露给 UI 的 state 变量(类型为 RestaurantsScreenState)的值随时间正确演变,从初始状态到所有可能的状态。

让我们从断言初始状态是否按预期产生的测试方法开始:

  1. RestaurantsViewModelTest 类中,添加一个名为 initialState_isProduced() 的空测试函数,稍后它将测试我们的 RestaurantsViewModel 类是否正确地产生了初始状态:

        @Test
        fun initialState_isProduced() {  }
    

正如 UI 测试一样,我们将使用 JUnit 测试库来定义和运行每个带有 @Test 注解的方法的单独单元测试。

同样类似于 UI 测试,我们根据这个方法试图测试的具体行为给它命名,从我们要测试的内容(初始状态)到应该发生的事情(正确产生状态)。

  1. initialState_isProduced() 方法内部,我们必须创建被测试主题的实例 – 那就是说,RestaurantsViewModel。定义一个 viewModel 变量,并用 getViewModel() 方法返回的值来实例化它,我们将在稍后定义这个方法:

    @Test
    fun initialState_isProduced() {
        val viewModel = getViewModel()
    }
    
  2. 仍然在RestaurantsViewModelTest类内部,定义getViewModel()方法,它将返回一个RestaurantsViewModel实例:

    private fun getViewModel(): RestaurantsViewModel {
        return RestaurantsViewModel()
    }
    

现在的问题是RestaurantsViewModel构造函数需要一个GetInitialRestaurantsUseCaseToggleRestaurantsUseCase的实例。反过来,这两个类也有其他依赖项,我们必须实例化。让我们更清楚地看看我们需要实例化哪些类:

图 10.4 – 观察 RestaurantsViewModel 的直接和传递依赖关系

图 10.4 – 观察 RestaurantsViewModel 的直接和传递依赖关系

图 10.4 – 观察 RestaurantsViewModel 的直接和传递依赖关系

我们可以看到,GetInitialRestaurantsUseCaseToggleRestaurantsUseCase都依赖于GetSortedRestaurantsUseCaseRestaurantsRepository。后者随后依赖于两个库接口 – RestaurantsApiServiceRestaurantsDao

实质上,我们必须实例化所有这些类来测试我们的RestaurantsViewModel

  1. RestaurantsViewModelTest类内部,重构getViewModel()方法以构建RestaurantsViewModel的所有必要依赖项:

    private fun getViewModel(): RestaurantsViewModel {
        val restaurantsRepository = 
            RestaurantsRepository(?, ?)
        val getSortedRestaurantsUseCase =
            GetSortedRestaurantsUseCase(restaurantsRepository)
        val getInitialRestaurantsUseCase =
            GetInitialRestaurantsUseCase(
                restaurantsRepository,
                getSortedRestaurantsUseCase)
        val toggleRestaurantUseCase =
            ToggleRestaurantUseCase(
                restaurantsRepository,
                getSortedRestaurantsUseCase
            )
    return RestaurantsViewModel(
            getInitialRestaurantsUseCase,
            toggleRestaurantUseCase
        )
    }
    

如果你从上一个代码片段的底部向上阅读,你会注意到我们能够构建RestaurantsViewModel的所有依赖项,以及它们的依赖项,等等,直到我们遇到RestaurantsRepository。这依赖于两个库接口RestaurantsApiServiceRestaurantsDao,它们的实现由 Retrofit 和 Room 库提供。

在我们的生产代码中,这两个接口跨越到真实世界的边界,因为它们的实现,由 Retrofit 和 Room 库提供,与真实的 Firebase REST API 和真实的 Room 本地数据库通信:

图 10.4 – 观察 RestaurantsViewModel 的直接和传递依赖关系

RestaurantsViewModel 的依赖项

图 10.5 – 观察 RestaurantsViewModel 传递依赖关系跨越的真实世界边界

图 10.5 – 观察 RestaurantsViewModel 的传递依赖关系跨越的真实世界边界

如果我们在测试代码中使用 Retrofit 和 Room 提供的这两个接口的现有实现,RestaurantsViewModel实例将与外部世界通信,我们的测试将不会隔离。相反,我们的测试代码将会慢且不可靠,因为它将依赖于我们的 Web REST API 和真实的本地数据库。

然而,我们如何使我们的RestaurantsViewModel测试变得隔离、快速和可靠呢?我们可以简单地确保,而不是让 Retrofit 和 Room 为RestaurantsApiServiceRestaurantsDao提供实现,我们为这些接口定义了不会与真实世界通信的模拟实现。

这些占位实现通常被称为伪造对象。伪造对象是我们希望在测试中与之交互的接口的简化实现。这些实现以非常简化的方式模拟生产实现的行为,通常是通过返回占位数据。伪造对象将仅用于我们的测试,这样我们就可以确保我们的测试环境是隔离的。

除了伪造对象外,为了模拟跨越真实世界边界的组件的功能,你还可以使用模拟对象。模拟对象是模拟真实对象行为的对象;然而,你可以动态配置它们的输出,而无需任何额外的类。

在本章中,我们将只关注伪造对象,因为大多数情况下,要创建模拟对象,你需要使用特殊的模拟框架。此外,伪造对象通常更实用,可以在多个测试中重复使用,而模拟对象则可能导致测试变得杂乱,因为它们会引入大量的模板代码。

注意

无论你有一个与真实世界交互的组件,无论是网络 API、本地数据库还是其他生产系统,你都应该为它定义一个接口。这样,在你的生产代码中,其他组件与该接口的真实实现交互,而你的测试则与该接口的伪造实现交互。

让我们看看如何实现伪造对象。在我们的例子中,RestaurantsRepository 需要伪造的 RestaurantsApiServiceRestaurantsDao 接口的实现。让我们从 RestaurantsApiService 接口的伪造实现开始:

  1. 要为 RestaurantsApiService 接口创建一个伪造对象,我们必须定义一个将实现该接口并模拟 REST API 功能的类。在 test 包内,创建一个名为 FakeApiService 的 Kotlin 类,实现 RestaurantsApiService 接口,并在其中添加以下代码:

    class FakeApiService : RestaurantsApiService {
        override suspend fun getRestaurants()
                : List<RemoteRestaurant> {
            delay(1000)
            return DummyContent.getRemoteRestaurants()
        }
        override suspend fun getRestaurant(id: Int)
                : Map<String, RemoteRestaurant> {
            TODO("Not yet implemented")
        }
    }
    

我们的 FakeApiService 覆盖了所需的方法,并从 DummyContent 类返回一些占位餐厅信息。在 getRestaurants() 方法中,我们还调用了一个基于协程的 delay() 函数,持续时间为 1,000 毫秒,以更好地模拟异步响应。由于我们现在不会在测试中使用 getRestaurant() 方法,所以我们还没有在它内部添加任何实现。

回到返回的占位内容,请注意,getRestaurants() 方法必须返回一个 RemoteRestaurant 对象的列表,因此我们在 DummyContent 类上调用了一个不存在的 getRemoteRestaurants() 方法。接下来,让我们定义这个方法。

  1. 回到主源集,我们的生产代码就在那里。在 DummyContent 类中,添加一个名为 getRemoteRestaurants() 的新方法,该方法将 getDomainRestaurants() 方法返回的 Restaurant 对象列表映射到 RemoteRestaurant 对象:

    object DummyContent {
        fun getDomainRestaurants() = arrayListOf(…)
        fun getRemoteRestaurants() = getDomainRestaurants()
            .map {
                RemoteRestaurant(
                    it.id,
                    it.title,
                    it.description
                )
            }
    }
    
  2. 现在,回到test包中。我们已经为RestaurantsApiService接口创建了一个模拟,但我们也必须为RestaurantsDao接口创建一个模拟,该模拟将实现接口并模拟本地数据库的功能。在test包内部,创建一个名为FakeRoomDao的 Kotlin 类,该类实现RestaurantsDao接口,并在其中添加以下代码:

    class FakeRoomDao : RestaurantsDao {
        private var restaurants =
                  HashMap<Int, LocalRestaurant>()
        override suspend fun getAll()
                : List<LocalRestaurant> {
            delay(1000)
            return restaurants.values.toList()
        }
        override suspend fun addAll(
            restaurants: List<LocalRestaurant>
        ) {
            restaurants.forEach { 
                this.restaurants[it.id] = it 
            }
        }
        override suspend fun update(
            partialRestaurant: PartialLocalRestaurant
        ) {
            delay(1000)
            updateRestaurant(partialRestaurant)
        }
        override suspend fun updateAll(
            partialRestaurants: List<PartialLocalRestaurant>
        ) {
            delay(1000)
            partialRestaurants.forEach { 
                updateRestaurant(it) 
            }
        }
        override suspend fun getAllFavorited()
                : List<LocalRestaurant> {
            return restaurants.values.toList()
                .filter { it.isFavorite }
        }
    }
    

我们的FakeRoomDao类模仿了真实 Room 数据库的功能,但不是将餐厅存储在本地 SQL 数据库中,而是将它们存储在内存中的restaurants变量中。我们不会涵盖FakeRoomDao的每个方法实现。

然而,我们将得出结论,每个方法都模拟了与持久存储服务的交互。此外,由于我们的FakeRoomDao模拟与真实本地数据库的交互,它的每个操作都会由预构建的挂起delay()函数触发的延迟。

然而,我们的FakeRoom类使用了我们尚未定义的updateRestaurant()方法。让我们现在就定义它。

  1. FakeRoom类的末尾,添加缺失的updateRestaurant()方法,该方法切换isFavorite字段的值:

    class FakeRoomDao : RestaurantsDao {
        [...]
        override suspend fun getAllFavorited()
                : List<LocalRestaurant> { ... }
        private fun updateRestaurant(
            partialRestaurant: PartialLocalRestaurant
        ) {
    val restaurant = 
               this.restaurants[partialRestaurant.id]
           if (restaurant != null)
               this.restaurants[partialRestaurant.id] =
                   restaurant.copy(
    isFavorite = 
                           partialRestaurant.isFavorite
                   )
        }
    }
    
  2. 现在我们已经实现了RestaurantsApiServiceRestaurantsDao接口的模拟,是时候在我们的测试中传递它们所需的位置了。记住,最后缺失的部分是提供RestaurantsRepository依赖项的模拟实现,以便我们的测试是隔离的。

回到RestaurantsViewModelTest类中,并更新getViewModel()函数,将FakeApiServiceFakeRoomDao类的实例传递给RestaurantsRepository

private fun getViewModel(): RestaurantsViewModel {
    val restaurantsRepository = RestaurantsRepository(
        FakeApiService(), FakeRoomDao())
    […]
    return RestaurantsViewModel(…)
}

现在,getViewModel()方法能够返回一个RestaurantsViewModel实例,我们可以轻松地进行测试,让我们回到initialState_isProduced()测试方法,它目前看起来是这样的:

@Test
fun initialState_isProduced() {
    val viewModel = getViewModel()
}

记住,这个测试方法的范围是验证当我们的RestaurantsViewModel初始化时,它产生一个正确的初始状态。让我们现在就做这件事。

  1. 首先,在initialState_isProduced()测试方法中,将初始状态存储在initialState变量中:

    @Test
    fun initialState_isProduced() {
        val viewModel = getViewModel()
        val initialState = viewModel.state.value
    }
    
  2. 接下来,使用内置的assert()函数,验证initialState的内容是否符合预期:

    @Test
    fun initialState_isProduced() {
        val viewModel = getViewModel()
        val initialState = viewModel.state.value
        assert(
            initialState == RestaurantsScreenState(
                restaurants = emptyList(),
                isLoading = true,
                error = null)
        )
    }
    

在这个测试方法中,我们断言initialState变量的值是否是一个具有false isLoading字段和emptyList()值在restaurants字段中的RestaurantsScreenState对象。此外,我们还测试error字段中没有存储任何值。

  1. 现在我们已经定义了我们的第一个测试方法,是时候运行测试了!

RestaurantsViewModelTest类中,选择运行 RestaurantsViewModelTest。此命令将直接在您的本地 JVM 上运行此类中的所有测试(在我们的情况下只有一个),而不是在 Android 设备上运行,就像我们的 UI 测试那样。

如果你切换到 运行 选项卡,你会看到我们的测试失败了:

图 10.6 – 观察 RestaurantsViewModelTest 类中的测试失败

图 10.6 – 观察 RestaurantsViewModelTest 类中的测试失败

这个异常被抛出,是因为我们的餐厅应用通过协程帮助处理异步工作,而我们的测试代码不知道如何与它们交互。

例如,我们的 RestaurantsViewModel 启动了调用多个挂起函数的协程,所有这些都在 viewModelScope 上发生,该作用域默认设置了 Dispatchers.Main 分派器:

@HiltViewModel
class RestaurantsViewModel @Inject constructor(...) : […] {
    [...]
    fun toggleFavorite(itemId: Int, oldValue: Boolean) {
        viewModelScope.launch(errorHandler) { ... }
    }
    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) { ... }
    }
}

这里主要的问题是,我们的协程是在本地 JVM 的主线程上启动的,这无法与 UI 线程协同工作。

注意

Dispatchers.Main 分派器使用 Android 的 Looper.getMainLooper() 函数在 UI 线程上运行代码。该方法在 UI 测试中可用,但在我们 JVM 上运行的常规单元测试中不可用。

为了使我们的测试代码符合协程的使用,我们需要使用 Kotlin 协程测试库,它将为我们提供专门用于测试协程的范围和分派器。如果我们的测试代码是从使用这些专用范围和分派器构建的协程中运行的,我们的测试将不再失败。

让我们添加 Kotlin 协程测试库!

  1. 在应用级别的 build.gradle 文件中,向 Kotlin 协程测试包添加一个 testImplementation 依赖项:

    dependencies {
        […]
        testImplementation "com.google.truth:truth:1.1.2"
        testImplementation 'org.jetbrains.kotlinx:kotlinx-
            coroutines-test:1.6.1'
    }
    
  2. 通过在 Android Studio 中单击 与 Gradle 文件同步项目 按钮,或通过按 文件 菜单选项然后选择 与 Gradle 文件同步项目 来同步你的项目与 Gradle 文件:

  3. 返回到 RestaurantsViewModelTest 类中,定义一个 StandardTestDispatcher 对象的变量和一个基于之前定义的分派器的 TestScope 对象的变量:

    @ExperimentalCoroutinesApi
    class RestaurantsViewModelTest {
        private val dispatcher = StandardTestDispatcher()
        private val scope = TestScope(dispatcher)
        @Test
        fun initialState_isProduced() {…}
        private fun getViewModel(): RestaurantsViewModel {…}
    }
    

此外,我们还在 RestaurantsViewModelTest 类上添加了 @ExperimentalCoroutinesApi 注解,因为这些测试 API 仍然是实验性的。

  1. 接下来,确保 initialState_isProduced() 测试方法体内的所有代码都在一个特定于测试的协程中运行。为此,通过在 TestScope 类型的 scope 变量上调用 runTest() 协程构建器来启动一个封装此方法体的协程:

    @Test
    fun initialState_isProduced() = scope.runTest {
        val viewModel = getViewModel()
    val initialState = viewModel.state.value
        assert(
            initialState == RestaurantsScreenState(
                restaurants = emptyList(),
                isLoading = true,
                error = null))
    }
    
  2. 运行 RestaurantsViewModelTest 类。如果你切换到 运行 选项卡,你会看到我们的测试再次失败,与之前的异常相同。由于我们在测试方法的主体内部封装了一个测试协程,我们的测试代码仍然抛出异常,告诉我们我们仍然需要更改协程的分派器。

如果我们再次查看 RestaurantsViewModel,我们可以注意到,使用 viewModelScope 启动的所有协程都没有设置分派器,因此它们在幕后使用 Dispatchers.Main

@HiltViewModel
class RestaurantsViewModel @Inject constructor(...) : […] {
    [...]
    fun toggleFavorite(itemId: Int, oldValue: Boolean) {
        viewModelScope.launch(errorHandler) { ... }
    }
    private fun getRestaurants() {
        viewModelScope.launch(errorHandler) { ... }
    }
}

然而,在我们的测试中,所有启动的协程都应该使用在测试类中定义的StandardTestDispatcher。那么,我们如何将测试调度器传递给在RestaurantsViewModel中启动的协程?

我们可以通过使用构造函数并在其中接受一个CoroutineDispatcher对象来将调度器注入到RestaurantsViewModel类中,该对象将被传递给所有启动的协程。

这样,在我们的生产代码中,RestaurantsViewModel将接收并使用Dispatchers.Main调度器,而在我们的测试代码中,它将接收并使用StandardTestDispatcher调度器。

注意

将调度器注入到基于协程的类中是一种推荐的做法,因为它允许我们更好地隔离和控制单元测试的测试环境。

  1. 返回到主要源集,其中包含我们的生产代码。在RestaurantsViewModel内部,添加一个类型为CoroutineDispatcherdispatcher构造函数参数,并将其传递给viewModelScope()调用:

    @HiltViewModel
    class RestaurantsViewModel @Inject constructor(
       private val getRestaurantsUseCase: […],
       private val toggleRestaurantsUseCase: […],
       private val dispatcher: CoroutineDispatcher) :  
    ViewModel(){
        [...]
        fun toggleFavorite(itemId: Int, oldValue: Boolean) {
            viewModelScope.launch(errorHandler 
                + dispatcher) { … }
        }
        private fun getRestaurants() {
            viewModelScope.launch(errorHandler 
                + dispatcher) { … }
        }
    }
    

然而,如果我们现在构建项目,将会得到一个错误,因为 Hilt 不知道如何为RestaurantsViewModel提供CoroutineDispatcher的实例。

为了指导 Hilt 如何为我们提供的ViewModel提供所需的调度器(即Dispatchers.Main),我们必须创建一个 Hilt 模块。

  1. di包内部,创建一个名为DispatcherModule的新类,并添加以下代码,告诉 Hilt 如何使用Dispatchers.Main提供任何CoroutineDispatcher依赖项:

    @Module
    @InstallIn(SingletonComponent::class)
    object DispatcherModule {
        @Provides
        fun providesMainDispatcher(): CoroutineDispatcher 
            = Dispatchers.Main
    }
    

然而,目前 Hilt 将始终为任何CoroutineDispatcher依赖项提供Dispatchers.Main。如果我们以后需要获取不同于Dispatchers.Main的调度器怎么办?让我们看看我们如何为此做好准备。

  1. DispatcherModule类的主体部分,定义一个名为MainDispatcher的注解类,并使用@Qualifier注解进行标注:

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class MainDispatcher
    
    @Module
    @InstallIn(SingletonComponent::class)
    object DispatcherModule {…}
    

@Qualifier注解允许我们为CoroutineDispatcher依赖项提供不同的调度器。在我们的例子中,我们定义了一个@MainDispatcher注解将提供Dispatchers.Main调度器。

  1. @MainDispatcher注解添加到providesMainDispatcher()方法中,这样当这样的注解用于依赖项时,Hilt 将知道提供哪个调度器:

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class MainDispatcher
    @Module
    @InstallIn(SingletonComponent::class)
    object DispatcherModule {
        @MainDispatcher
        @Provides
        fun providesMainDispatcher(): CoroutineDispatcher
            = Dispatchers.Main
    }
    
  2. 然后,在RestaurantsViewModel内部,使用新创建的@MainDispatcher注解标注dispatcher参数,这样 Hilt 就会为我们提供Dispatchers.Main调度器:

    @HiltViewModel
    class RestaurantsViewModel @Inject constructor(
       private val getRestaurantsUseCase: […],
       private val toggleRestaurantsUseCase: […],
       @MainDispatcher private val dispatcher: 
    CoroutineDispatcher
    ) : ViewModel() { ... }
    
  3. 现在,由于RestaurantsViewModel在我们的生产代码中使用Dispatcher.Main调度器,返回到test源集,并在RestaurantsViewModelTest类内部,通过将dispatcher成员字段传递给RestaurantsViewModel构造函数调用,更新其getViewModel()方法:

    @ExperimentalCoroutinesApi
    class RestaurantsViewModelTest {
        private val dispatcher = StandardTestDispatcher()
        private val scope = TestScope(dispatcher)
        @Test
        fun initialState_isProduced() = scope.runTest {…}
        private fun getViewModel(): RestaurantsViewModel {
            […]
            return RestaurantsViewModel(
                getInitialRestaurantsUseCase,
                toggleRestaurantUseCase,
                dispatcher)
        }
    }
    

现在,在我们的测试代码中,RestaurantsViewModel将使用在测试类中定义的StandardTestDispatcher调度器来启动所有协程。

  1. 现在,再次运行 RestaurantsViewModelTest 类。如果你切换到 运行 选项卡,你会看到我们的测试现在已经通过了:

图 10.7 – 观察 RestaurantsViewModelTest 类中的测试是否成功

图 10.7 – 观察 RestaurantsViewModelTest 类中的测试是否成功

由于我们的测试使用 runTest() 协程构建器,我们伪造实现中的任何 delay() 调用都将被跳过,这使得我们的测试运行得很快,只需几百毫秒。

现在我们已经测试了我们的 ViewModel 是否产生了一个正确的初始状态,现在是时候测试这个初始状态之后的状态——具有内容的状态了。这个状态是在餐厅到达(来自我们的数据层)时产生的,因此 isLoading 字段应该重置为 false,而 restaurants 字段应该包含餐厅列表。

  1. RestaurantsViewModelTest 中添加一个新的测试方法,名为 stateWithContent_isProduced(),断言是否产生了预期的包含餐厅的状态:

    @Test
    fun stateWithContent_isProduced() = scope.runTest {
        val testVM = getViewModel()
        val currentState = testVM.state.value
        assert(
            currentState == RestaurantsScreenState(
                restaurants = 
                       DummyContent.getDomainRestaurants(),
                isLoading = false,
                error = null)
        )
    }
    

由于 FakeApiServiceDummyContent 类返回 RemoteRestaurant 的模拟列表,因此我们自然期望在 ViewModel 中得到相同的内容,但以 Restaurant 对象的形式——因此我们断言 currentStaterestaurants 字段包含 DummyContent 中的餐厅。

不幸的是,如果我们运行 RestaurantsViewModelTest 类,stateWithContent_isProduced() 测试将失败,告诉我们 currentStateisLoading 字段的值为 true,并且 restaurants 字段中没有餐厅。

这个问题是有意义的,因为我们基本上获取了初始状态并期望它是具有内容的状态,但实际上这个状态会在稍后出现。因为我们的 FakeApiServiceFakeRoomDao 实现中有几个 delay() 调用,我们必须允许时间过去,以便 ViewModel 产生第二个状态——包含餐厅的状态。但我们如何做到这一点呢?

在测试中,为了立即执行所有挂起的任务(例如在 ViewModel 中获取餐厅启动的协程)并推进虚拟时钟直到最后一个延迟之后,我们可以调用协程测试库公开的 advanceUntilIdle() 函数。

  1. stateWithContent_isProduced() 测试方法中,在 RestaurantsViewModel 实例化之后但在我们的断言之前,添加 advanceUntilIdle() 方法调用:

    @Test
    fun stateWithContent_isProduced() = scope.runTest {
        val testVM = getViewModel()
        advanceUntilIdle()
        val currentState = testVM.state.value
        assert(
            currentState == RestaurantsScreenState(
                restaurants = 
                    DummyContent.getDomainRestaurants(),
                isLoading = false,
                error = null)
        )
    }
    

当我们在类型为 TestScopescope 变量内部调用 advanceUntilIdle() 时,我们正在推进 StandardTestDispatcherTestCoroutineScheduler 的虚拟时钟,这是我们最初传递给 scope 的。

  1. 现在,再次运行 RestaurantsViewModelTest 类。如果你切换到 stateWithContent_isProduced() 测试仍然失败。

这里的主要问题是,当我们试图通过利用我们的测试实例RestaurantsViewModel现在在其传递给它的StandardTestDispatcher实例上启动协程的事实来推进测试的虚拟时钟时,我们还有一个传递它自己的CoroutineDispatcher的类。

如果我们更仔细地查看我们的RestaurantsRepository,我们可以看到它正在将一个用于生产的Dispatchers.IO分发器传递给所有的withContext()调用:

@Singleton
class RestaurantsRepository @Inject constructor(…) {
    suspend fun toggleFavoriteRestaurant(…) =
        withContext(Dispatchers.IO) {…}
    suspend fun getRestaurants() : List<Restaurant> {
        return withContext(Dispatchers.IO) {…}
    }
    suspend fun loadRestaurants() {
        return withContext(Dispatchers.IO) {…}
    }
    private suspend fun refreshCache() {…}
}

因为我们的RestaurantsViewModel间接依赖的RestaurantsRepository实例使用的是Dispatchers.IO分发器,而不是StandardTestDispatcher,所以测试的虚拟时钟并没有像预期的那样推进。让我们通过在RestaurantsRepository中注入分发器来解决这个问题,就像我们对RestaurantsViewModel所做的那样。

  1. 然而,在进行注入之前,我们必须首先定义一个新的CoroutineDispatcher类型,让 Hilt 知道如何注入——即Dispatchers.IO分发器。

进入DispatchersModule类中,就像我们为Dispatchers.Main分发器所做的那样,指导 Hilt 如何为我们提供Dispatchers.IO分发器:

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class MainDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher                         
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
    @MainDispatcher
    @Provides
    fun providesMainDispatcher(): CoroutineDispatcher =        
Dispatchers.Main
    @IoDispatcher
    @Provides
fun providesIoDispatcher(): CoroutineDispatcher = 
        Dispatchers.IO
}
  1. 回到主源集,我们的生产代码就位于其中。在RestaurantsRepository类中,注入CoroutineDispatcher,用@IoDispatcher注解它,然后将注入的dispatcher传递给所有的withContext()调用:

    @Singleton
    class RestaurantsRepository @Inject constructor(
        private val restInterface: RestaurantsApiService,
        private val restaurantsDao: RestaurantsDao,
    @IoDispatcher private val dispatcher: 
            CoroutineDispatcher
    ) {
        suspend fun toggleFavoriteRestaurant(…) =  
            withContext(dispatcher) {…}
        suspend fun getRestaurants() : List<Restaurant> {
            return withContext(dispatcher) {…}
        }
        suspend fun loadRestaurants() {
            return withContext(dispatcher) {…}
        }
        private suspend fun refreshCache() {…}            }
    
  2. 然后,回到我们的test包中,在RestaurantsViewModelTest类中,更新getViewModel()方法,将我们的StandardTestDispatcher类型的dispatcher字段传递给RestaurantsRepository构造函数:

    private fun getViewModel(): RestaurantsViewModel {
        val restaurantsRepository = RestaurantsRepository(
            FakeApiService(), 
            FakeRoomDao(),
            dispatcher)
        […]
        return RestaurantsViewModel(
            getInitialRestaurantsUseCase,
            toggleRestaurantUseCase,
            dispatcher)
    }
    
  3. 现在,再次运行RestaurantsViewModelTest类。如果你切换到RestaurantsViewModel,它正确地生成了一个错误状态。作为一个提示,确保在FakeApiService内部抛出一个Exception类的实例,但仅针对这个特定的测试方法,你正在验证错误状态。为了实现这一点,你可以在FakeApiService中配置一个构造函数参数,以便在需要时抛出异常。

现在我们已经测试了RestaurantsViewModel如何生成 UI 状态,让我们简要地看看我们如何测试一个业务组件。

测试UseCase类的功能

除了对应用程序的表现层进行单元测试之外,测试应用程序中存在的业务规则也非常重要。在我们的餐厅应用程序中,业务逻辑封装在UseCase类中。

假设我们想要测试ToggleRestaurantUseCase。本质上,我们想要确保当我们为特定的餐厅执行这个UseCase类时,否定RestaurantisFavorite字段的业务逻辑是正常工作的。

换句话说,如果一家餐厅没有被标记为收藏夹,在执行针对该特定餐厅的ToggleRestaurantUseCase之后,其isFavorite字段应该变为true。虽然这个业务逻辑确实很简单,但在中到大型的应用程序中,这样的业务逻辑可能会变得更加复杂。

让我们看看ToggleRestaurantUseCase的单元测试将如何进行:

@ExperimentalCoroutinesApi
class ToggleRestaurantUseCaseTest {
    private val dispatcher = StandardTestDispatcher()
    private val scope = TestScope(dispatcher)
    @Test
    fun toggleRestaurant_IsUpdatingFavoriteField() = 
            scope.runTest {
        // Setup useCase
        val restaurantsRepository = RestaurantsRepository(
            FakeApiService(),
            FakeRoomDao(),
            dispatcher)
        val getSortedRestaurantsUseCase = 
            GetSortedRestaurantsUseCase(restaurantsRepository)
        val useCase = ToggleRestaurantUseCase(
            restaurantsRepository,
            getSortedRestaurantsUseCase)
        // Preload data
        restaurantsRepository.loadRestaurants()
        advanceUntilIdle()
        // Execute useCase
        val restaurants = DummyContent.getDomainRestaurants()
        val targetItem = restaurants[0]
        val isFavorite = targetItem.isFavorite
        val updatedRestaurants = useCase(
            targetItem.id, 
            isFavorite
        )
        advanceUntilIdle()
        // Assertion
        restaurants[0] = targetItem.copy(isFavorite = 
            !isFavorite)
        assert(updatedRestaurants == restaurants)
    }
}

这个单元测试与我们在RestaurantsViewModel上编写的测试类似,因为它也使用了StandardTestDispatcherTestScope,仅仅是因为ToggleRestaurantUseCaseinvoke()操作符是一个suspending函数。

测试的结构分为三个部分,由提示性注释分隔:

  • ToggleRestaurantUseCase实例及其直接和间接依赖项,同时将我们的测试分发器传递给需要它的依赖项。

  • 为了使ToggleRestaurantUseCase能够在特定餐厅上执行其业务逻辑,我们首先必须确保我们的RestaurantsRepository实例已加载了模拟餐厅。然后我们调用了advancedUntilIdle(),允许与获取和缓存模拟餐厅相关的任何挂起(阻塞)工作完成。

  • 我们想要切换的isFavorite字段作为targetItem,获取其当前的isFavorite字段值,并执行ToggleRestaurantUseCase,将结果餐厅存储在updatedRestaurants变量中。由于这个操作刷新并重新从模拟本地数据库中获取餐厅,所以我们随后调用了advancedUntilIdle(),允许任何挂起的工作完成。

  • 最后,我们断言结果updatedRestaurants列表与我们预期正确的结果相同——即restaurants

如果你运行这个测试,它应该通过。

作业

尝试测试其他用例类(如GetSortedRestaurantsUseCase)或数据层中的类(如RestaurantsRepository)的行为。

摘要

在本章中,我们首先探讨了测试的好处,并根据不同方面对测试进行了分类。之后,我们尝试测试基于 Compose 的 UI,并学习了如何利用语义修饰符的强大功能编写 UI 单元测试。

最后,我们学习了如何编写常规的——非 UI——单元测试,以验证我们应用程序的核心功能。在本部分中,我们学习了如何测试基于协程的代码,以及注入CoroutineDispatcher对象的重要性。

在下一章中,我们将从 Android 开发的架构方面转向,我们将借助另一个有趣的库 Jetpack Paging 来实现数据分页。

进一步阅读

在本章中,我们简要介绍了 UI 和单元测试的基础知识,因此这里教授的核心概念应该为你提供一个坚实的起点。然而,在你继续测试冒险的过程中,你可能还需要了解几个其他主题:

第三部分:深入其他 Jetpack 库

在本节的最后一部分,我们将探索并利用其他重要的 Jetpack 库,包括分页和生命周期,同时使用 Kotlin Flow 创建一个新的演示项目。

本节包含以下章节:

  • 第十一章, 使用 Jetpack 分页和 Kotlin Flow 创建无限列表

  • 第十二章, 探索 Jetpack 生命周期组件

第十一章:第十一章:使用 Jetpack Paging 和 Kotlin Flow 创建无限列表

在前面的章节中,我们构建了伟大的餐厅应用,该应用显示了来自我们自己的后端的内容。然而,餐厅应用中显示的餐厅数量是固定的,用户只能浏览我们添加到 Firebase 数据库中的少数几家餐厅。

在本章中,我们将了解分页如何帮助我们显示大量项目数据集,而不会给后端施加压力,也不会消耗巨大的网络带宽。我们将在一个新应用中创建无限列表项目的印象,这个应用就是我们正在工作的 Repositories 应用,我们将借助另一个名为 Paging 的 Jetpack 库来实现这一点。

在第一部分,为什么我们需要分页?中,我们将探讨数据分页是什么以及它如何帮助我们将大型数据集拆分为数据页,从而优化我们应用与后端服务器之间的通信。接下来,在导入和探索 Repositories 应用部分,我们将探索一个将集成分页的项目:显示 GitHub 仓库信息的 Repositories 应用。

然后,在使用 Kotlin Flow 处理数据流部分,我们将介绍如何将分页内容表示为数据流,以及 Kotlin Flow 如何成为处理此类内容的绝佳解决方案。在最后一节,探索 Jetpack Paging 的分页功能中,我们首先将探索 Jetpack Paging 库作为我们在 Android 应用中处理分页内容的解决方案,然后,借助这个新库,我们将在我们的 Repositories 应用中集成分页功能,以创建无限列表仓库的错觉。

总结来说,在本章中,我们将涵盖以下部分:

  • 为什么我们需要分页?

  • 导入和探索 Repositories 应用

  • 使用 Kotlin Flow 处理数据流

  • 探索 Jetpack Paging 的分页功能

在深入之前,让我们为本章设置技术要求。

技术要求

构建本章的 Compose-based Android 项目通常需要你日常使用的工具。然而,为了顺利跟进,请确保你具备以下条件:

  • Arctic Fox 2020.3.1 版本的 Android Studio。你也可以使用更新的 Android Studio 版本,甚至是 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与本书中使用的不同。

  • 在 Android Studio 中安装了 Kotlin 1.6.10 或更高版本的插件

  • 来自本书 GitHub 仓库的现有 Repositories 应用

本章的起点是你可以通过导航到本书 GitHub 仓库的 Chapter_11 目录,然后从 Android Studio 内导入 repositories_app_starting_point_ch11 目录找到的 Repositories 应用。不用担心,我们将在本章的后面一起完成这个操作。

要访问本章的解决方案代码,请导航到 Chapter_11 目录,然后在 Android Studio 中从 repositories_app_solution_ch11 目录导入。

您可以通过以下链接找到 Chapter_11 目录:

github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_11

我们为什么需要分页?

假设我们有一个 Android 应用程序,允许你通过显示项目列表来探索 GitHub 仓库。它是通过使用 Retrofit 查询 GitHub 表示状态传输REST应用程序编程接口API)并获取应用内的固定数量的仓库来实现的。虽然 REST API 为每个仓库提供详细的信息,但应用只使用并显示仓库的标题和描述。

注意

不要混淆我们项目架构中抽象数据逻辑的仓库类与在我们的仓库应用中显示的 GitHub 仓库。

现在,让我们想象这个应用检索并显示 20 个仓库元素。因此,用户将能够滚动内容直到第 20 个元素,因此将能够可视化不超过 20 个元素。

但如果我们想允许用户在我们的列表中探索更多仓库呢?最终,应用的目的在于浏览更多的仓库,而不仅仅是 20 个。

我们可以更新网络调用,并一次性请求一个更大的元素列表。换句话说,我们可以重构我们的应用,在应用启动时一次性获取并显示 10,000 个仓库列表。

然而,采用这种方法,我们可以想到三个主要问题,如下所述:

  • LazyColumn 可用于以懒加载方式(当需要时)渲染 UI 元素,因此我们可以得出结论,这个问题可以很容易地解决。

  • 应用会对后端造成很大压力——想象一下,如果每个 Android 应用客户端都从后端服务器请求 10,000 条数据库记录会发生什么——这些服务将不得不消耗大量资源来查询和返回这么多元素。

  • 这样的 超文本传输协议HTTP)请求和响应将导致由必须传输的大量 JavaScript 对象表示法JSON)有效负载引起的高网络带宽消耗。所有 10,000 个元素可能包含许多字段和嵌套信息——很明显,在我们的应用和服务器之间发送这样的有效负载将非常低效。

虽然我们可以轻松解决第一个问题,但我们得出结论,第二个和第三个问题非常令人担忧。许多现实世界的应用程序和系统面临这些问题,为了减轻这些问题,采用了分页的概念,用于大多数基于客户端-服务器通信的关系,在这些关系中必须向最终用户显示大量数据集。

分页是一种对服务器友好的通信方法,它将大量结果分成多个较小的块。换句话说,如果您的后端支持分页,您的应用程序可以请求只获取部分数据(通常称为页面)并接收部分响应,从而允许在双方之间更快、更有效地传输。

当应用程序需要更多结果时,它只需请求另一页,再一页,依此类推。这种方法对应用和后端服务都有益,因为只有一小部分数据在某一时刻被提供和解释。

使用分页,如果用户决定只可视化一小部分项目然后切换到另一个应用,您的应用只会请求这一小部分数据。没有分页的情况下,在相同的情况下,您的后端会为您的应用提供整个项目的集合,而一些用户可能没有机会看到所有这些项目。这从您应用的角度来看是资源的浪费,但从您后端服务的角度来看尤其如此。此外,只有互联网上发送的大量负载中的一小部分是需要的。

要在 UI 上实现这种分页行为,对于移动应用来说有两种众所周知的 UI 方法,如下所述:

  • 在一个类似于网页的屏幕上显示固定数量的项目。在这个页面上,有固定数量的滚动空间,因为如果用户想看到新项目,必须按下一个按钮来切换页面(通常表示特定页面的编号),然后加载并显示一组新数据,替换现有内容。

从移动用户体验UX)的角度来看,这是一个糟糕的设计选择,因为与用于网页的显示器屏幕相比,在尺寸较小的设备(如手机)上滚动内容更为自然。

  • 随着用户滚动,显示的项目列表会增长,从而产生列表无限的印象——这种做法通常被称为无限滚动。虽然没有无限列表这样的东西,但这种做法模仿了它。它从对初始页面/的几个请求开始,当用户滚动以查看更多元素时,它会动态地请求包含更多内容的更多页面。这种方法高度依赖于滚动,通常创造出更好的用户体验。

在本章中,我们将选择第二种方案——换句话说,我们将尝试实现分页以模拟无限列表效果。让我们也尝试在以下简化示例中可视化应用如何随着用户滚动而请求更多项目,其中第 1 页仅包含六个元素:

![图 11.1 – 观察如何通过分页实现无限列表

![img/B17788_11_01.jpg]

图 11.1 – 观察如何通过分页实现无限列表

为了让应用请求包含项目的第二页,用户必须进一步向下滚动,从而通知应用他们想要查看更多元素。

当应用捕捉到这个意图(因为用户到达列表的末尾)时,它会请求包含后端项目的第二页,使仓库列表增长,并允许用户浏览新内容。这个过程会不断重复,因为用户会不断到达列表的末尾。

在实现此分页方法之前,让我们首先了解我们的起点——GitHub 仓库应用!

导入并探索仓库应用

仓库应用项目是一个简单的应用,它显示从 GitHub 搜索 API 获取的仓库列表。该项目是 Compose 应用的一个简化版本,它只包含前几章中的一些概念,因为它试图成为实现分页的 Jetpack Paging 库的良好候选者,而不是一个完全实现的应用程序,该应用程序应用了书中教授的所有概念。

尽管如此,我们将看到仓库应用如何遵循ViewModel类来保持状态和展示数据,使用协程进行从服务器获取数据的异步async)操作,以及使用 Compose 进行 UI 层。

让我们从将此项目导入 Android Studio 开始,如下所示:

  1. 导航到本书的 GitHub 仓库页面,位于github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin

  2. 下载仓库文件。您可以通过按下代码按钮,然后选择下载 zip来完成此操作。

  3. 解压下载的文件,并记住您这样做的地方。

  4. 打开 Android Studio,点击文件选项卡,然后选择打开

  5. 搜索您解压项目文件的目录。一旦找到,导航到Chapter_11目录,选择repositories_app_starting_point_ch11目录,然后按打开

  6. 在您的测试设备上运行应用程序。

您应该注意到我们的仓库应用显示了一个仓库列表,列表中每个仓库项目的索引显示在左侧,如下面的截图所示:

![图 11.2 – 观察没有分页的仓库应用

![img/B17788_11_02.jpg]

图 11.2 – 查看未启用分页的仓库应用

如果你继续向下滚动,你会注意到只能查看 20 个元素。这意味着我们的应用不支持分页,用户只能浏览 20 个仓库。

如果我们查看RepositoriesApiService.kt文件,我们会注意到我们的应用通过@GET()端点统一资源定位符URL)指令 REST API 获取仓库的第一页,每次只获取每页 20 个项目,如下面的代码片段所示:

interface RepositoriesApiService {
   @GET("repositories?q=mobile&sort=stars&page=1&per_page=20")
   suspend fun getRepositories(): RepositoriesResponse
}

如果你查看请求中硬编码的参数,你会注意到我们的应用总是请求仓库的第一页。此外,因为它可以指定页码,这显然意味着我们访问的后端支持分页,但因为我们总是请求页码1,所以我们的应用没有利用这一点。

更具体地说,当应用执行此请求时,它将从索引为1的页面从后端检索 20 条记录。在本章的后面部分,我们将学习如何进行多个网络调用,请求不同的页码,从而实现分页。

注意

如果你想要构建一个支持分页的应用,你必须首先确保你的后端支持分页,就像 GitHub 搜索 API 一样。记住,分页的整个目的是减轻后端 API 的工作负担,并最小化与检索大量 JSON 有效负载相关的网络带宽消耗,所以如果你的后端不支持分页,你无法在你的应用中实现分页。

让我们简要地看一下我们从 GitHub API 收到的响应,通过导航到Repository.kt文件。基本上,我们得到一个Repository对象的列表,并解析仓库的idnamedescription值,如下面的代码片段所示:

data class RepositoriesResponse(
    @SerializedName("items") val repos: List<Repository>
)
data class Repository(
    @SerializedName("id")
    val id: String,
    @SerializedName("full_name")
    val name: String,
    @SerializedName("description")
    val description: String)

如前所述,我们的应用使用了 GitHub 搜索 API,这可以在DependencyContainer.kt类中更好地观察到,其中手动构建了 Retrofit 的RepositoriesApiService依赖项,并传递了此 API 的基本 URL。你可以查看此过程的代码如下所示:

object DependencyContainer {
    val repositoriesRetrofitClient: RepositoriesApiService =         
        Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl("https://api.github.com/search/")
            .build().create(RepositoriesApiService::class.java)
}

如果你想要了解更多关于本章中使用的 API 的信息,请访问 GitHub 搜索 API 的官方文档,网址为docs.github.com/en/rest/search#search-repositories

现在,回到我们的仓库应用,如果我们导航到RepositoriesViewModel.kt文件,我们会看到我们的ViewModel类使用RepositoriesApiService依赖项通过启动协程并将结果设置为一个包含Repository对象列表的 Compose State对象来获取仓库列表。代码如下所示:

class RepositoriesViewModel(
    private val restInterface: RepositoriesApiService
    = DependencyContainer.repositoriesRetrofitClient
) : ViewModel() {
    val repositories = mutableStateOf(emptyList<Repository>())
    init {
        viewModelScope.launch {
            repositories.value =
                restInterface.getRepositories().repos
        }
    }
}

使用 Jetpack ViewModel 启动协程并通过 Retrofit 获取数据的做法与我们在 Restaurants App 中所做的是非常相似的。

UI 层也与 Restaurants App 类似。如果我们导航到 MainActivity.kt 文件,我们可以看到我们的 Activity 类创建了一个 ViewModel 实例,检索了一个 Compose State 对象,获取了其类型为 List<Repository> 的值,并将其传递给一个可组合函数以消费它,如下面的代码片段所示:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            RepositoriesAppTheme {
                val viewModel: RepositoriesViewModel = 
                    viewModel()
                val repos = viewModel.repositories.value
                RepositoriesScreen(repos)
            }
        }
    }
}

消费 Repository 对象列表的可组合函数位于 RepositoriesScreen.kt 文件中,如下面的代码片段所示:

@Composable
fun RepositoriesScreen(repos: List<Repository>) {
    LazyColumn(
        contentPadding = PaddingValues(
            vertical = 8.dp,
            horizontal = 8.dp)
    ) {
        itemsIndexed(repos) { index, repo ->
            RepositoryItem(index, repo) 
        }
    }
}

正如 Restaurants App 一样,我们屏幕级别的可组合组件使用 LazyColumn 可组合组件来优化 UI 在列表中渲染元素的方式。

对于我们尝试实现分页的用例,LazyColumn 的使用非常重要,因为我们不希望我们的 UI 渲染成千上万的 UI 元素。幸运的是,正如我们所知,LazyColumn 已经为我们解决了这个问题,因为它只组合和布局屏幕上的可见元素。

现在,你可能已经注意到 RepositoriesScreen 可组合组件使用了我们在 Restaurants App 中使用的 itemsIndexed()items() 函数。这是因为,由于我们的应用将支持分页,我们希望显示屏幕上显示的元素的索引,以便更好地理解我们现在在哪里。为了获取屏幕上可见的可组合项的索引,itemsIndexed() 函数为我们提供了这种信息。

最后,让我们简要地看一下显示 Repository 对象内容的 RepositoryItem 可组合组件的结构,同时渲染仓库的索引,如下所示:

@Composable
fun RepositoryItem(index: Int, item: Repository) {
    Card(
        elevation = 4.dp,
        modifier = Modifier.padding(8.dp).height(120.dp)
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.padding(8.dp)
        ) {
            Text(
                text = index.toString(),
                style = MaterialTheme.typography.h6,
                modifier = Modifier
                    .weight(0.2f)
                    .padding(8.dp))
            Column(modifier = Modifier.weight(0.8f)) {
                Text(
                    text = item.name,
                    style = MaterialTheme.typography.h6)
                Text(
                    text = item.description,
                    style = MaterialTheme.typography.body2,
                    overflow = TextOverflow.Ellipsis,
                    maxLines = 3)
            }
        }
    }
}

现在我们已经简要介绍了 Repositories App 的当前状态,我们可以得出结论,它确实需要分页来显示更多仓库,尤其是在 GitHub 搜索 API 支持分页的情况下。现在是时候探讨另一个 pagination 强迫我们注意的重要方面,那就是数据流的概念。

使用 Kotlin Flow 处理数据流

如果我们想让我们的应用以无限列表的形式支持分页,很明显,我们现有的通过一次性的请求后端并导致一次 UI 更新的方法是不够的。

让我们先看一下以下代码片段,看看我们的 RepositoriesViewModel 类是如何请求数据的:

class RepositoriesViewModel(
    private val restInterface: RepositoriesApiService =  [...]
) : ViewModel() {
    val repositories = mutableStateOf(emptyList<Repository>())
    init {
        viewModelScope.launch {
            repositories.value =
                restInterface.getRepositories().repos
        }
    }
}

ViewModel 初始化时,它在一个协程内部执行 getRepositories() 挂起函数。挂起函数返回一个 Repository 对象列表,并将其传递给 repositories 变量。这意味着我们的 ViewModel 以一次性调用挂起函数的形式进行一次性的数据请求——在用户滚动列表时不会进行其他请求以获取新的仓库。这就是为什么我们的应用程序从后端接收一个包含数据(对象初始列表)的单个事件作为单个结果。

我们可以想象,调用与我们的应用程序中类似的 getRepositories() 挂起函数将同样返回一次性响应,因为它的返回类型将是 List<Repository>,如下面的截图所示:

![图 11.3 – 使用挂起函数观察单次数据结果

![img/B17788_11_03.jpg]

图 11.3 – 使用挂起函数观察单次数据结果

注意

虽然我们的 ViewModel 包含一个类型为 MutableStaterepositories 变量,这意味着它的值可以在时间上改变,但我们不会使用 Compose State 对象来观察数据层的变化,因为这会破坏层的职责。目前,在我们的代码中,我们调用一个返回单个结果或一组数据的挂起函数,该结果异步地传递给 repositories 变量,因此尽管我们的 UI 状态可以随时间改变,但它只接收一个更新。

为了支持无限列表,我们必须以某种方式设计我们的应用程序以接收随时间推移的多个结果,就像接收数据流一样。换句话说,我们的应用程序必须在用户滚动时请求新的 Repository 对象,从而接收包含多个事件的数据,而不仅仅是单个事件。每当有新的数据事件到来时,我们的应用程序应该获得一个新的 Repository 对象列表,其中现在包含了新接收的仓库。

要使我们的 ViewModel 以数据流的形式接收多个数据事件,我们可以使用 Flow。Kotlin 的 Flow 是建立在协程之上的数据类型,它公开了一个异步计算的多值流。

与只发出单个结果的挂起函数不同,Flow 允许我们在时间上顺序地发出多个值。然而,就像挂起函数以异步方式发出结果,你可以在协程中稍后获取一样,Flow 也以异步方式发出结果,因此你必须从启动的协程中观察其结果。

您可以使用 Flow 来监听来自各种来源的事件;例如,您可以使用 Flow 在用户位置每次改变时获取位置更新。或者,您可以使用 Flow 从您的 Room 数据库获取连续更新——而不是每次插入或更新项目时都手动查询数据库,您可以告诉 Room 返回一个流,该流将在您执行插入、更新等操作时发出包含最新内容的更新。

回到我们关于仓库的例子,让我们假设我们的 getRepositories() 函数不再是一个挂起函数,而是返回一个包含类型为 List<Repository> 的数据的流,如下面的截图所示:

![图 11.4 – 使用 Kotlin Flow 随时间观察多个结果]

![图片 B17788_11_04.jpg]

![图 11.4 – 使用 Kotlin Flow 随时间观察多个结果]

正如 Compose 的 State 对象持有特定类型的数据(例如,State<Int> 发射 Int 类型的值)一样,Flow 也持有特定类型的数据;在我们之前的例子中,该类型是我们感兴趣发射的数据类型,即 List<Repository>

但我们如何观察流的发出值呢?

让我们以前面的例子为例,其中 getRepositories() 方法返回一个 Flow<List<Repository>> 实例,并假设我们正在尝试在一个 UI 组件中观察其值,如下所示:

class SomeViewModel(…) : ViewModel() {
    init {
        viewModelScope.launch {
            getRepositories().collect { repos ->
                // Update UI
            }
        }
    }
    […]
}

由于流异步发出值,我们在启动的协程中获得了 Flow<List<Repository>> 实例,然后调用了 .collect() 方法,该方法反过来提供了一个代码块,我们可以在这里消费 List<Repository> 值。

与从挂起函数调用中获取此类列表相反,重要的是要记住流发出的值会随时间变化(或者至少应该变化)。换句话说,对于每个提供存储在 repos 变量中的值的回调,其类型为 List<Repository> 的值的内容可能不同,这允许我们在每次新的发射中更新 UI。

在本节中,我们探讨了流是什么以及我们如何消费它。然而,Kotlin Flow 是一个非常复杂的话题;例如,我们不会涵盖您创建流的方式或如何修改产生的流。如果您想了解更多关于 Flow 的信息,请查看官方 Android 文档:developer.android.com/kotlin/flow

现在我们来探索拼图中最后一块缺失的拼图——分页库。

探索使用 Jetpack Paging 的分页

要在我们的仓库应用中实现无限列表的仓库,我们必须找到一种方法,当用户滚动现有列表并到达底部时,请求更多仓库,从而动态添加新元素。而不是手动决定用户何时接近当前仓库列表的底部,然后触发网络请求以获取新项目,我们可以使用 Jetpack Paging 库,它将所有这些复杂性隐藏起来。

Jetpack Paging 是一个库,帮助我们从大量数据中加载和显示数据页,无论是通过网络请求还是从我们的本地数据存储,从而允许我们节省网络带宽并优化系统资源的利用。

在本章中,为了简单起见,我们将使用 Paging 库来显示从网络源(即 GitHub 搜索 API)获取的无限列表仓库,而不涉及本地缓存。

注意

Jetpack Paging 库现在处于其第三个实现迭代,通常被称为 Paging 3 或 Paging v3。在本章中,我们将使用这个最新版本,因此尽管我们只是简单地称之为 Jetpack Paging,但实际上我们指的是 Jetpack Paging 3。

Jetpack Paging 库抽象了与请求正确页面的复杂性,这取决于用户的滚动位置。实际上,它带来了许多好处,例如以下内容:

  • 避免数据请求重复——你的应用程序只有在需要时才会请求数据;例如,当用户到达列表末尾并且必须渲染更多项目时。

  • 分页数据默认在内存中缓存。在应用程序进程的生命周期内,一旦加载了一页,你的应用程序将永远不会再次请求它。如果你在本地数据库中缓存分页数据,那么你的应用程序在应用重启后等情况下不需要请求特定的页面。

  • 分页数据以适合您需求的数据流类型暴露:Kotlin Flow、LiveData 或 RxJava。正如你可能猜到的,我们将使用 Flow。

  • 对基于 View System 或 Compose 的 UI 组件的原生支持,当用户滚动到列表末尾时,这些组件会自动请求数据。有了这种支持,我们不必知道何时请求新的数据页,因为 UI 层会为我们自动触发。

  • 由 UI 组件直接触发的重试和刷新功能。

在实际集成 Paging 库之前,让我们花点时间了解一下 Paging API 的主要组件部分。为了确保使用 Jetpack Paging API 在你的应用程序中实现分页,你必须使用以下内容:

  • 一个 PagingSource 组件——定义分页内容的源数据。该对象决定请求哪一页,并从你的远程或本地数据源加载它。如果你希望你的分页内容既有本地又有远程数据源,你可以使用 Paging 库内置的 RemoteMediator API。有关更多信息,请参阅 进一步阅读 部分。

  • 一个 Pager 组件——基于定义的 PagingSource 组件,你可以构建一个 Pager 对象,该对象将公开 PagingData 对象的流。你可以通过将 PagingConfig 对象传递给其构造函数并指定数据的大小来配置 Pager 对象,例如。

PagingData 类是对你的分页数据的包装,包含对应页面的项目集合。PagingData 对象负责触发对新页面的查询,该页面包含的项目随后被转发到 PagingSource 组件。

  • 一个支持分页的专用 UI 组件——为了消费分页内容的流,你的 UI 必须使用能够处理分页数据的专用 UI 组件。如果你的 UI 基于传统的 View 系统,你可以使用 PagingDataAdapter 组件。由于我们的 UI 层基于 Compose,LazyColumn 已经为我们处理了分页数据的消费(更多内容将在下一节中介绍)。

为了直观地了解所有这些组件应该如何配合,让我们看看以下示例,即在我们的 Repositories 应用程序中可能的 Paging 库实现:

图 11.5 – 观察 Paging 库 API 在 Repositories 应用程序中的使用

图 11.5 – 观察 Paging 库 API 在 Repositories 应用程序中的使用

在 UI 层面,我们的可组合组件收集了一个包含 PagingData<Repository> 对象流的流。PagingData 对象包含一个 Repository 对象列表,在幕后,它负责将新页面的请求转发到 PagingSource,而 PagingSource 则从我们的 REST API 获取新项目。

ViewModel 内部,我们将有一个 Pager 对象,它将使用 PagingSource 的一个实例。我们将定义一个 PagingSource 对象,以便它知道要请求哪一页以及在哪里请求——即 GitHub 搜索 API。

现在我们已经涵盖了使用 Jetpack Paging 进行分页集成的理论方面,让我们看看在本节中我们将要完成哪些实际任务。我们将执行以下操作:

  • 使用 Jetpack Paging 实现分页

  • 实现加载和错误状态以及重试功能

让我们开始第一个任务:将分页集成到我们的 Repositories 应用程序中。

使用 Jetpack Paging 实现分页

在本节中,我们将集成分页到我们的 Repositories 应用程序中,并借助 Jetpack Paging 创建一个无限列表的仓库。为了实现这一点,我们将实现并添加上一节中描述的所有组件。

让我们开始吧!按照以下步骤进行:

  1. 首先,在应用级别的build.gradle文件中,在dependencies块中,添加 Jetpack Paging 的 Compose Gradle 依赖项,如下所示:

    dependencies {
        […]
        implementation "androidx.paging:
            paging-compose:1.0.0-alpha14"
    }
    

更新build.gradle文件后,请确保将项目与其 Gradle 文件同步。您可以通过点击文件菜单选项,然后选择同步项目与 Gradle 文件来完成此操作。

  1. 接下来,让我们重构我们的 Retrofit RepositoriesApiService接口,通过从@GET()请求注解中移除硬编码的页面索引1,并添加一个表示我们想要获取的页面索引的page查询参数类型Int。代码如下所示:

    interface RepositoriesApiService {
        @GET("repositories?q=mobile&sort=stars&per_page=20")
        suspend fun getRepositories(@Query("page") page:Int): 
            RepositoriesResponse
    }
    

在这些更改之前,我们总是获取仓库结果的首页。现在,我们已经更新了我们的网络请求,以利用分页 REST API 的力量——即根据用户的滚动位置请求不同的页面索引。

为了实现这一点,我们使用了 Retrofit 的@Query()注解,它基本上会将我们在getRepositories()方法中定义的page参数值插入到GET请求中。由于 GitHub 搜索 API 期望在 URL 请求中有一个"page"查询键,因此我们将"page"键传递给了@Query()注解。

  1. 现在是时候构建一个PagingSource组件了,该组件将通过我们的RepositoriesApiService依赖项请求新页面,并跟踪要请求的页面,同时还要在内存中缓存之前检索到的页面。

在应用的根包中,创建一个名为RepositoriesPagingSource的新类,并将其以下代码粘贴到该类下面:

class RepositoriesPagingSource(
    private val restInterface: RepositoriesApiService
    = DependencyContainer.repositoriesRetrofitClient,
) : PagingSource<Int, Repository>() {
    override suspend fun load(params: LoadParams<Int>)
            : LoadResult<Int, Repository> {
    }
    override fun getRefreshKey(
        state: PagingState<Int, Repository>,
    ): Int? {
        return null
    }
}

让我们分析我们刚刚添加的代码。此组件正在执行以下操作:

  • 它负责请求新页面,因此它依赖于RepositoriesApiService作为restInterface构造函数字段。

  • 它是一个PagingSource组件,因此它继承自PagingSource类,同时定义了以下内容:

  • 作为页面索引类型的键——在我们的例子中,GitHub 搜索 API 需要一个表示页面索引的整数,因此我们将键设置为Int

  • 加载数据的类型——在我们的例子中,是Repository对象。

    • 实现以下两个必需的函数:
  • load()挂起函数,该函数由 Paging 库自动调用,应异步获取更多项目。此方法接受一个LoadParams对象,该对象跟踪有关信息,例如必须请求的页面(索引)的键(索引)或项目的初始加载大小。此外,此方法返回一个LoadResult对象,指示特定查询结果是否成功或失败。

  • getRefreshKey()函数,在刷新事件发生时被调用以获取并返回最新的页面键,以便用户返回到最新的已知页面(而不是第一个页面)。刷新事件可以来自各种来源,例如用户触发的手动 UI 刷新、数据库缓存失效事件、系统事件等等。

为了简单起见,也因为我们将不会实现刷新功能,我们将跳过实现getRefreshKey()方法,所以我们只是在方法的主体中返回null。然而,如果你也想支持这种行为,请查看进一步阅读部分,其中列出了额外的资源,以帮助你提供此方法的实现。

  1. 现在我们已经涵盖了两个强制方法的用途,让我们实现我们真正感兴趣的load()函数。

此方法应返回一个LoadResult对象,因此首先添加一个try-catch块,并在catch块内部,通过传递捕获到的Exception对象返回LoadResult.Error实例,如下面的代码片段所示:

class RepositoriesPagingSource(…) : […] {
    override suspend fun load(params: LoadParams<Int>)
    : LoadResult<Int, Repository> {
        try {
        } catch (e: Exception) {
            return LoadResult.Error(e)
        }
    }
    override fun getRefreshKey(…): Int? { … }
}

采用这种方法,如果新页面的请求失败,我们通过返回LoadResult.Error对象让 Paging 库知道发生了错误事件。

  1. 接下来,在try块内部,我们首先必须获取并存储我们感兴趣的下一页。将下一页的索引存储在nextPage变量中,如下所示:

    class RepositoriesPagingSource(…) : […] {
        override suspend fun load(params: LoadParams<Int>)
        : LoadResult<Int, Repository> {
            try {
                val nextPage = params.key ?: 1
            } catch (e: Exception) {
                return LoadResult.Error(e) 
            }
        }
        override fun getRefreshKey(…): Int? { … }
    }
    

我们通过访问params参数并获取其key字段来获取下一页的索引——这个字段将始终给我们提供必须加载的下一页的索引。如果是第一次请求页面,key字段将是null,所以在这种情况下我们默认为1

  1. 由于我们现在知道了我们需要加载的下一个仓库页面的索引,让我们通过调用restInterfacegetRepositories()方法并传递新定义的nextPage参数来查询我们的 REST API 以获取该特定页面,如下所示:

    class RepositoriesPagingSource(…) : […] {
        override suspend fun load(params: LoadParams<Int>)
                : LoadResult<Int, Repository> {
            try {
                val nextPage = params.key ?: 1
                val repos = restInterface
                    .getRepositories(nextPage).repos
            } catch (e: Exception) {
                return LoadResult.Error(e) 
            }
        }
        override fun getRefreshKey(…): Int? { … }
    }
    

在这一步中,我们还将在reposResponse变量中存储响应内的Repository对象列表。

  1. 接下来,我们必须返回一个LoadResult对象,因为此时我们的 REST API 请求是成功的。让我们实例化并返回一个LoadResult.Page对象,如下所示:

    class RepositoriesPagingSource(…) : […] {
        override suspend fun load(params: LoadParams<Int>)
                : […] {
            try {
                val nextPage = params.key ?: 1
                val repos = restInterface
                    .getRepositories(nextPage).repos
                return LoadResult.Page(
                    data = repos,
                    prevKey = if (nextPage == 1) null
                              else nextPage - 1,
                    nextKey = nextPage + 1)
            } catch (e: Exception) {
                return LoadResult.Error(e)
            }
        }
        override fun getRefreshKey(…): Int? { … }
    }
    

我们必须传递以下内容到LoadResult.Page()构造函数:

  • 从新请求的页面到data参数的Repository对象列表。

  • 新请求页面的上一个键到prevKey参数。这个键很重要,如果由于某种原因,之前的页面被无效化,并且当用户开始向上滚动时必须重新加载,这个键就很重要。大多数情况下,我们会从nextPage值中减去1,但我们还确保,如果我们刚刚请求了第一页(nextPage的值将是1),我们将向prevKey参数传递null

  • nextPage 之后的关键参数是 nextKey。这是一个简单的参数,因为我们刚刚将 nextPage 的值增加了 1

现在我们完成了 PagingSource 的实现,是时候构建 Pager 组件并获取分页数据流了。

  1. RepositoriesViewModel 内部,将 RepositoriesApiService 依赖项替换为新创建的 RepositoriesPagingSource 类,如下所示:

    class RepositoriesViewModel(
        private val reposPagingSource:
        RepositoriesPagingSource = RepositoriesPagingSource()
    ) : ViewModel() {
    }
    

同时,我们确保从 RepositoriesViewModel 中移除任何现有的实现,为即将进行的步骤留出空白。

  1. 仍然在 RepositoriesViewModel 中,定义一个 repositories 变量,它将保存我们的分页数据流,如下所示:

    import kotlinx.coroutines.flow.Flow
    class RepositoriesViewModel(
        private val reposPagingSource:
        RepositoriesPagingSource = RepositoriesPagingSource()
    ) : ViewModel() {
        val repositories: Flow<PagingData<Repository>>
    }
    

包含 Repository 项的分页内容位于 PagingData 容器中,使我们的数据流类型为 Flow<PagingData<Repository>>

现在,我们必须实例化我们的 repositories 变量。然而,创建一个流并非易事,尤其是在数据(仓库列表)必须随着用户滚动而增长的情况下。Paging 库为我们解决了这个问题,因为它将隐藏这种复杂性,并为我们提供一个按预期发出数据的流:当用户滚动到列表的末尾时,会向后端发出新的请求,并将新的 Repository 对象附加到列表中。

  1. 获取分页数据流的第一个步骤是创建一个基于之前创建的 PagingSource 对象的 Pager 类实例,如下所示:

    class RepositoriesViewModel(
        private val reposPagingSource:
        RepositoriesPagingSource = RepositoriesPagingSource()
    ) : ViewModel() {
        val repositories: Flow<PagingData<Repository>> =
            Pager(
                config = PagingConfig(pageSize = 20),
                pagingSourceFactory = {
                    reposPagingSource
                })
    }
    

要创建一个 Pager 实例,我们调用了 Pager() 构造函数并传递了以下内容:

  • 一个 PagingConfig 对象,其 pageSize 值为 20(将此值与从后端请求的仓库数量相匹配)传递给 config 参数。

  • RepositoriesPagingSource 类型的 reposPagingSource 实例传递给 pagingSourceFactory 参数。这样做,Paging 库将知道查询新页面的 PagingSource 对象。

  1. 最后,为了获取来自新创建的 Pager 实例的数据流,我们只需访问由结果 Pager 实例公开的 flow 字段,如下所示:

    class RepositoriesViewModel(...) : ViewModel() {
        val repositories: Flow<PagingData<Repository>> =
            Pager(
                config = PagingConfig(pageSize = 20),
                pagingSourceFactory = {
                    reposPagingSource
                }).flow.cachedIn(viewModelScope)
    }
    

在结果流上,我们还调用了 cachedIn() 扩展函数,确保只要传递的 CoroutineScope 对象保持活动状态,数据流就会保持活跃,然后返回它被调用的同一个流。由于我们希望分页内容在 ViewModel 保持内存中的情况下被缓存,我们将 viewModelScope 范围传递给这个扩展函数。这确保了在 ViewModel 生存的事件中(例如,配置更改)流也会被保留。

  1. 现在,我们必须在我们的基于 Compose 的 UI 中获取流,因此,在 MainActivity 内部调用 RepositoriesAppTheme() 可组合函数时,将 repos 变量替换为包含对 ViewModelrepositories 流变量的引用的 reposFlow 变量,如下所示:

    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                RepositoriesAppTheme {
                    val viewModel: RepositoriesViewModel = 
                        viewModel()
                    val reposFlow = viewModel.repositories
                    RepositoriesScreen()
                }
            }
        }
    }
    
  2. 接下来,我们必须使用一个特殊的收集函数(类似于前一个部分中使用的 collect() 函数),它可以消费并记住在 Compose 的上下文中 reposFlow 内部的分页数据。

声明一个新的变量 lazyRepoItems 并使用对 reposFlowcollectAsLazyPagingItems() 调用的结果来实例化它,如下所示:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            RepositoriesAppTheme {
                val viewModel: […] = viewModel()
                val reposFlow = viewModel.repositories
                val lazyRepoItems
                        : LazyPagingItems<Repository> =
                    reposFlow.collectAsLazyPagingItems()
                RepositoriesScreen(lazyRepoItems)
            }
        }
    }
}

collectAsLazyPagingItems() 函数返回了一个填充有 Repository 对象的 LazyPagingItems 对象。LazyPagingItems 对象负责从流中访问 Repository 对象,以便它们可以在稍后由我们的 LazyColumn 组件消费——这就是为什么最终我们将 lazyRepoItems 传递给了 RepositoriesScreen() 可组合函数。

  1. 转到拼图的最后一部分,RepositoriesScreen() 可组合函数,确保它通过添加 repos 参数接受我们的流程返回的 LazyPagingItems 对象,如下所示:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>
    ) {
        LazyColumn (…) {
        }
    }
    

此外,在你进行此步骤的同时,移除由 LazyColumn 暴露的 DSL content 块内的所有代码,因为我们将在下一个步骤中以不同的结构重新添加它。

  1. 最后,仍然在 RepositoriesScreen() 内部,将 repos 输入参数传递给另一个接受 LazyPagingItemsitemsIndexed() DSL 函数,如下所示:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>
    ) {
        LazyColumn(…) {
            itemsIndexed(repos) { index, repo ->
                if (repo != null) {
                    RepositoryItem(index, repo)
                }
            }
        }
    }
    

LazyColumn API 知道如何消费分页数据,并且当需要加载新页面时,如何向我们的 PagerPagingSource 实例报告,这就是为什么我们使用了接受 LazyPagingItems 作为内容的 itemsIndexed() DSL 函数的重载版本。

此外,因为返回的 repo 值可以是 null,我们在将其传递给我们的 RepositoryItem() 可组合函数之前添加了一个空值检查。

  1. 最后,构建并运行应用程序。尝试滚动到仓库列表的底部。这应该会触发获取新项目的请求,因此你应该能够滚动并浏览一个 无限 的仓库列表。

    注意

    如果你向 GitHub 搜索 API 发送太多请求,你可能会暂时受限,应用程序将停止加载新项目并抛出错误。为了使我们的应用程序能够表达这种事件,我们将学习如何显示错误状态,接下来。

接下来,让我们通过在无限列表的上下文中添加加载和错误状态来改进我们应用程序的 UI 和 UX。

实现加载和错误状态以及重试功能

尽管我们的应用程序现在具有用户可以滚动的无限列表,但它不表达任何加载或错误状态。好消息是,Paging 库告诉我们何时必须显示加载状态或错误状态。

然而,在深入实际实现之前,我们应该首先了解与具有分页功能的应用程序交互时可能出现的可能的加载状态和错误状态。幸运的是,所有这些情况都已经由 Paging API 覆盖。

虽然 LazyPagingItems API 为我们提供了多个 LoadState 对象,但最常见的是——我们将在本节中需要的是 refreshappend 类型,如以下内容更详细地解释:

  • LoadState.refresh 实例的 LoadState 代表在分页项的第一个请求或刷新事件之后发生的初始状态。我们对此对象感兴趣的两个值如下:

    • LoadState.Loading — 此状态表示应用表示初始加载状态。当此状态在应用启动后第一次到达时,屏幕上不会绘制任何内容。

    • LoadState.Error — 此状态表示应用表示初始错误状态。就像之前的状态一样,如果此状态在应用启动后第一次到达,则没有内容。

  • LoadState.append 实例的 LoadState 代表在后续分页项请求结束时发生的状态。我们对此对象感兴趣的两个值与 refresh 类型相似,但具有不同的意义,如下所述:

    • LoadState.Loading — 此状态表示在后续请求包含存储库的页面结束时,应用处于加载状态;换句话说,应用已请求另一个包含存储库的页面,并且正在等待结果到达。此时,应该渲染来自先前页面的内容。

    • LoadState.Error — 此状态表示在请求包含存储库的页面后,应用达到错误状态。换句话说,应用已请求另一个包含存储库的页面,但请求失败。就像之前的状态一样,应该渲染来自先前页面的内容。

让我们在应用中监听这些状态,并从 LoadState.refresh 类型开始,如下所示:

  1. RepositoriesScreen() 可组合内部,在 itemsIndexed() 调用下方,将 refresh 加载状态实例存储在 refreshLoadstate 变量中,如下所示:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>
    ) {
        LazyColumn(…) {
            itemsIndexed(repos) { index, repo ->
                if (repo != null) {
                    RepositoryItem(index, repo)
                }
            }
            val refreshLoadState = repos.loadState.refresh
        }
    }
    

每次刷新时,LoadState 将会改变;refreshLoadState 中的值将是最新值,并且与它们发生所在的页面相对应。

  1. 接下来,创建一个 when 表达式并验证 refreshLoadState 是否为 LoadState.Loading 类型,如果是,则在新的 item() 调用中传递我们将稍后定义的 LoadingItem() 可组合。代码如下所示:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>
    ) {
        LazyColumn(…) {
            itemsIndexed(repos) { index, repo ->
                if (repo != null) {
                    RepositoryItem(index, repo)
                }
            }
            val refreshLoadState = repos.loadState.refresh
            when {
                refreshLoadState is LoadState.Loading -> {
                    item {
                        LoadingItem(
                            Modifier.fillParentMaxSize())
                    }
                }
            }
        }
    }
    

由于我们在 itemsIndexed() DSL 调用下方添加了另一个 item() 调用,因此我们实际上在 itemsIndexed() 调用的可组合列表下方添加了另一个可组合。然而,由于 refreshLoadState 在对项目页面进行第一次请求时可以是 LoadState.Loading 类型,这意味着此时屏幕是空的,因此我们还向 LoadingItem() 可组合传递了 fillParentMaxSize 修饰符,从而确保此可组合将占据整个屏幕的大小。

  1. 接下来,在RepositoriesScreen.kt文件的底部,让我们快速定义一个LoadingItem()函数,该函数将包含一个CirculatorProgressIndicator()可组合项,如下所示:

    @Composable
    fun LoadingItem(
        modifier: Modifier = Modifier
    ) {
        Column(
            modifier = modifier.padding(24.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment =
                Alignment.CenterHorizontally
        ) { CircularProgressIndicator() }
    }
    
  2. 现在,运行应用,注意在加载第一个仓库页面之前进度指示器动画是如何运行的,以及它是如何占据整个屏幕的,如下面的截图所示:

![图 11.6 – 为分页内容的第一次请求添加加载动画图片

图 11.6 – 为分页内容的第一次请求添加加载动画

  1. 现在,让我们讨论refreshLoadStateLoadstate.Error类型的情况。回到RepositoriesScreen()可组合组件的LazyColumn组件内部,在第一个when分支下面,添加对状态为LoadState.Loading的另一个检查——如果是这种情况,添加一个我们稍后将定义的ErrorItem()可组合项。你必须添加的代码如下面的代码片段所示:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>
    ) {
        LazyColumn(…) {
            itemsIndexed(repos) { index, repo -> […] }
            val refreshLoadState = repos.loadState.refresh
            when {
                refreshLoadState is LoadState.Loading -> {
                    item { LoadingItem(…) }
                }
                refreshLoadState is LoadState.Error -> {
                    val error = refreshLoadState.error
                    item {
                        ErrorItem(
                            message = error.localizedMessage
                                ?: "",
                            modifier =
                               Modifier.fillParentMaxSize()
                        )
                    }
                }
            }
        }
    }
    

ErrorItem()可组合项需要一个要显示的错误信息,因此我们将LoadState中的Throwable对象存储在error变量中,并将其localizedMessage值传递给可组合项的message参数。

与之前的LoadState.Loading情况类似,我们在itemsIndexed() DSL 调用下面添加了另一个item()调用,因此我们实际上在itemsIndexed()调用下面的可组合项列表中添加了另一个可组合项。此外,由于refreshLoadState在请求第一页项目时可以是LoadState.Error类型,这意味着屏幕此时是空的,所以我们还向ErrorItem()可组合项传递了fillParentMaxSize修改器,从而确保这个可组合项占据了整个屏幕的大小。

  1. 接下来,在RepositoriesScreen.kt文件的底部,让我们快速定义一个ErrorItem()函数,该函数将包含一个显示红色错误信息的Text()可组合项,如下所示:

    @Composable
    fun ErrorItem(
        message: String,
        modifier: Modifier = Modifier) {
        Row(
            modifier = modifier.padding(16.dp),
            horizontalArrangement = 
                Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(
                text = message,
                maxLines = 2,
                modifier = Modifier.weight(1f),
                style = MaterialTheme.typography.h6,
                color = Color.Red)
        }
    }
    
  2. 为了模拟错误状态,在没有网络连接的情况下在你的模拟器或物理设备上运行应用,你应该会看到一个类似的错误占据整个屏幕,如下面的截图所示:

![图 11.7 – 为分页内容的第一次请求添加错误信息图片

图 11.7 – 为分页内容的第一次请求添加错误信息

注意,错误信息可能根据你创建的错误场景的不同而不同。

在继续到LoadState的附加类型之前,让我们简要介绍 Paging 库提供的开箱即用的重试功能。换句话说,我们希望给用户提供重试获取数据的选择,以防出现错误,比如在我们的强制错误案例中,将测试设备从互联网断开连接。

让我们接下来做这件事。

  1. 重构 ErrorItem() 可组合函数以接受一个 onClick() 函数参数,该参数将由按下新的重试 Button() 可组合函数引起的 onClick 事件触发,如下所示:

    @Composable
    fun ErrorItem(
        message: String,
        modifier: Modifier = Modifier,
        onClick: () -> Unit) {
        Row(...) {
            Text(...)
            Button(
                onClick = onClick,
                modifier = Modifier.padding(8.dp)
            ) { Text(text = "Try again") }
        }
    }
    

此外,在显示错误信息的 Row() 可组合函数内部,我们现在添加了一个 Button() 可组合函数,当按下时,将事件转发给其调用者。

  1. 然后,回到 RepositoriesScreen()LazyColumn 组件内部,找到 LoadStateLoadState.Error 类型的案例,并实现 ErrorItem() 可组合函数的 onClick 参数,该参数将触发重试。代码如下所示:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>
    ) {
        LazyColumn(…) {
            itemsIndexed(repos) { index, repo -> […] }
            val refreshLoadState = repos.loadState.refresh
            when {
                refreshLoadState is LoadState.Loading -> {
                    …
                }
                refreshLoadState is LoadState.Error -> {
                    val error = refreshLoadState.error
                    item {
                        ErrorItem(
                            message = error.localizedMessage
                                ?: "",
                            modifier =
                               Modifier.fillParentMaxSize(),
                            onClick = { repos.retry() })
                    }
                }
            }
        }
    }
    

要触发重新加载,我们调用了由我们的 LazyPagingItems 实例提供的 retry() 函数。在幕后,当调用 retry() 函数时,分页库会通知 PagingSource 再次请求有问题的页面——在这种情况下,对我们来说,是包含仓库的第一页。

  1. 在没有互联网连接的模拟器或物理设备上运行应用。您现在应该看到错误状态占据了整个屏幕,包含错误消息,还有一个重试按钮。以下截图提供了这一情况的描述:

![图 11.8 – 为分页内容的第一次请求添加错误信息和重试按钮图 11.8 – 为分页内容的第一次请求添加错误信息和重试按钮

图 11.8 – 为分页内容的第一次请求添加错误信息和重试按钮

还不要按重试按钮。

  1. 将您的设备重新连接到互联网,然后按下重试按钮。此操作的结果是内容现在应该成功加载。

现在我们已经涵盖了 refresh 状态下可能的 LoadState 值,现在是时候也涵盖 append 状态下的值了。正如我们之前所述,类型 LoadState.append 表示在后续分页项请求结束时发生的状态。

对于此场景,我们感兴趣的可能的 LoadState 状态是 LoadState.Loading 状态——意味着用户已经滚动到列表的末尾,应用正在等待另一个包含仓库的页面——以及 LoadState.Error 状态——意味着用户已经滚动到列表的末尾,但获取新页面的请求失败了。

  1. RepositoriesScreen() 可组合函数中 itemsIndexed() 调用暴露的代码块内部,就像我们对 refresh 状态所做的那样,将 append 状态存储在一个新的 appendLoadState 变量中,然后在 when 表达式中添加两个相应的分支来处理 LoadState.LoadingLoadState.Error 的情况。代码如下所示:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>
    ) {
        LazyColumn(…) {
            itemsIndexed(repos) { […] }
            val refreshLoadState = repos.loadState.refresh
            val appendLoadState = repos.loadState.append
            when {
                refreshLoadState is LoadState.Loading -> {
                    item {
                        LoadingItem(...)
                    }
                }
                refreshLoadState is LoadState.Error -> {
                    val error = refreshLoadState.error
                    item {
                        ErrorItem(
                            message = error.localizedMessage
                                ?: "",
                            modifier = ...,
                            onClick = { repos.retry() })
                    }
                }
                appendLoadState is LoadState.Loading -> {
                    item {
                        LoadingItem(
                            Modifier.fillMaxWidth())
                    }
                }
                appendLoadState is LoadState.Error -> {
                    val error = appendLoadState.error
                    item {
                        ErrorItem(
                            message = error.localizedMessage
                                ?: "",
                            onClick = { repos.retry() })
                    }
                }
            }
        }
    }
    

我们处理 appendLoadState 的可能值的方式与我们处理 refreshLoadState 的可能值的方式非常相似。然而,一个明显的区别是 appendLoadState 状态值发生在应用程序已经加载了一些页面,并且用户已经滚动到我们列表的末尾时,这意味着我们的应用程序要么正在等待带有存储库的新页面,要么未能加载它。

因此,在 LoadState.Loading 的情况下,我们将 Modifier.fillMaxWidth() 修饰符传递给 LoadingItem() 可组合项,从而确保加载指示器条目作为列表元素出现在列表底部。换句话说,加载元素将仅占用可用宽度,它不会像我们在 refreshLoadStateLoadState.Loading 类型时那样覆盖整个屏幕。

类似地,对于 LoadState.Error 的情况,我们将 Modifier.fillMaxWidth() 修饰符传递给 ErrorItem() 可组合项,从而确保错误元素作为列表元素出现,并且不会像我们在 refreshLoadStateLoadState.Error 类型时那样覆盖整个屏幕。

让我们实际看看这两个案例,让我们从我们的 appendLoadState 实例具有 LoadState.Loading 值的案例开始。

  1. 首先,在测试设备连接到互联网的情况下运行应用程序。如果你滚动到存储库列表的底部,你应该会看到加载指示器动画显示,直到加载带有存储库的新页面,如图所示:

图 11.9 – 为分页内容的后续请求添加加载动画

图 11.9 – 为分页内容的后续请求添加加载动画

与最初显示的加载指示器不同,这个指示器作为列表中的一个条目出现,从而表明应用程序正在等待带有存储库的新页面。

注意

如果你的网络速度非常快,当你滚动浏览新页面时,你可能会错过加载旋转器。为了模拟更慢的连接,你可以通过进入 AVD 管理器,按下你的模拟器的 编辑 按钮,然后选择 显示高级设置 来更改你的 Android 模拟器的网络速度。在这个菜单中,你可以降低模拟器的互联网速度,以便你可以看到加载旋转器。

现在,让我们测试当我们的 appendLoadState 实例类型为 LoadState.Error 的情况。

  1. 首先,在测试设备连接到互联网的情况下运行应用程序。

  2. 然后,将你的测试设备从互联网断开连接,并滚动到存储库列表的底部。最初,你可能会看到加载指示器,但经过一段时间后,你应该会看到错误元素出现在列表底部,如图所示:

图 11.10 – 为分页内容的后续请求添加错误元素

图 11.10 – 为分页内容的后续请求添加错误元素

与最初显示的错误消息不同,这个错误元素作为列表中的一个条目出现,从而表明应用未能获取到包含仓库的下一页。

  1. 可选地,你可以重新连接你的设备到互联网并按下重试按钮——新的包含仓库的页面现在应该已经加载,这样你可以继续浏览和滚动查看更多项目。

摘要

在本章中,我们首先了解了什么是分页,以及如何使用分页以更有效的方式向用户展示大量项目数据集。

然后,我们遇到了 Repositories App,这是一个简单的 Android 项目,其中显示了固定数量的 GitHub 仓库。在那个时刻,我们决定用户应该能够浏览 GitHub 搜索 API 揭示的大量仓库,因此唯一的解决方案就是在我们的应用中集成分页。

然而,我们随后意识到,我们首先需要理解分页背景下数据流的概念,因此我们学习了一些关于 Kotlin Flow 的知识,以及它如何成为消费分页内容的一个简单解决方案。

然后,我们探讨了 Jetpack Paging 库是如何为我们的应用添加分页提供了一种优雅的解决方案,最终通过在 Repositories App 中使用这个库来集成分页的实际任务。最后,我们将 Repositories App 转换成了一个现代应用,它创建了一个看似无限列表的仓库,包括初始加载、中间加载或错误状态,以及重试功能。

在下一章中,我们将探讨另一个 Jetpack 主题——生命周期组件!

进一步阅读

在本章中,我们简要介绍了如何将 Jetpack Paging 集成到 Android 应用程序中。然而,在分页和 Jetpack Paging 的背景下,还有一些更高级的主题可能会让你感到好奇,如下所述:

如你所知,测试非常重要。在分页的背景下,测试可能会变得有些复杂。如果你对学习如何测试你的分页应用感兴趣,请查看官方文档:developer.android.com/topic/libraries/architecture/paging/test

第十二章:第十二章:探索 Jetpack 生命周期组件

在本章中,我们将在第十一章使用 Jetpack Paging 和 Kotlin Flow 创建无限列表的基础上,向我们的仓库应用添加倒计时计时器组件,同时探索 Jetpack 生命周期组件。

在第一部分,介绍 Jetpack 生命周期组件,我们想要探索生命周期事件和状态是如何与 Android 组件如ActivityFragment相关联的,然后预定义的Lifecycle包中的组件是如何对它们做出反应的。

接下来,在在仓库应用中添加倒计时组件部分,我们将创建并添加一个倒计时计时器组件到仓库应用中。当 60 秒倒计时结束时,我们将向用户颁发一个虚构的奖品。

然而,我们希望倒计时在计时器在屏幕上可见时运行;否则,用户可以通过最小化应用程序并在后台运行倒计时来作弊。在创建自己的生命周期感知组件部分,我们将通过使我们的计时器组件感知 Android 组件遍历的不同生命周期事件和状态来防止用户作弊。

使我们的倒计时组件感知 composables 的生命周期部分,我们会意识到用户也可以通过滚动和隐藏计时器倒计时 UI 元素来在倒计时竞赛中作弊。为了防止他们这样做,我们还将确保我们的倒计时组件知道如何对我们的 Compose UI 功能中的组合周期做出反应。

总结来说,在本章中,我们将涵盖以下部分:

  • 介绍 Jetpack 生命周期组件

  • 在仓库应用中添加倒计时组件

  • 创建自己的生命周期感知组件

  • 使我们的倒计时组件感知 composables 的生命周期

在深入之前,让我们为本章设置技术要求。

技术要求

为本章构建基于 Compose 的 Android 项目通常需要您日常使用的工具。然而,为了顺利跟随本章,请确保您还具有以下内容:

  • Arctic Fox 2020.3.1 版本的 Android Studio。您也可以使用更新的 Android Studio 版本或甚至 Canary 构建,但请注意,IDE 界面和其他生成的代码文件可能与此书中的不同。

  • 在 Android Studio 中安装的 Kotlin 1.6.10 或更高版本的插件。

  • 来自本书 GitHub 仓库的现有仓库应用。

本章的起点是上一章开发的仓库应用。如果您没有跟随上一章的实现,可以通过导航到仓库的Chapter_11目录并导入名为repositories_app_solution_ch11的 Android 项目来访问本章的起点。

要访问本章的解决方案代码,请导航到Chapter_12目录:github.com/PacktPublishing/Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin/tree/main/Chapter_12/repositories_app_ch12

介绍 Jetpack Lifecycle 组件

到现在为止,秘密已经不再是秘密了,Android 框架中的组件在需要与之交互时都有一定的生命周期,我们必须尊重这些生命周期。拥有生命周期的最常见组件是ActivityFragment

作为程序员,我们无法控制 Android 组件的生命周期,因为它们的生命周期是由系统或 Android 的工作方式定义和控制的。

回到 Lifecycle 组件,一个很好的例子是 Android 应用的入口点,由Activity组件表示,正如我们所知,它具有生命周期。这意味着为了在 Android 应用中创建一个屏幕,我们需要创建一个Activity组件——从这一点开始,我们所有的组件都必须了解其生命周期,以避免内存泄漏。

现在,当我们说Activity有一个系统定义的生命周期时,这实际上意味着我们的Activity类从ComponentActivity()继承,它反过来包含一个Lifecycle对象。如果我们查看来自 Repositories 应用的MainActivity类,我们可以看到它从ComponentActivity()继承:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        […]
    }
}

现在,如果我们深入研究ComponentActivity.java类的源代码,我们可以看到它实现了LifecycleOwner接口:

图 12.1 – 观察 ComponentActivity 如何实现 LifecycleOwner 接口

图 12.1 – 观察 ComponentActivity 如何实现 LifecycleOwner 接口

换句话说,ComponentActivity类是一个生命周期的拥有者。如果我们查看源代码中向下几百行处的LifecycleOwner接口实现,我们可以看到LifecycleOwner接口包含一个名为getLifecycle()的单个方法,它返回一个Lifecycle对象:

图 12.2 – 观察 LifecycleOwner 接口方法实现

图 12.2 – 观察 LifecycleOwner 接口方法实现

从这些发现中,我们可以推断出我们的Activity类具有系统定义的生命周期,因为它们实现了LifecycleOwner接口,这反过来意味着它们拥有一个Lifecycle对象。

注意

在 Android 中还有其他几个组件具有生命周期。在Activity类的上下文中,有其他直接或间接继承自ComponentActivity的类,因此拥有一个Lifecycle对象 – 例如AppCompatActivityFragmentActivity。或者,就像Activity类有生命周期一样,Fragment组件也有生命周期。如果你查看Fragment类的源代码,你会注意到它也实现了LifecycleOwner接口,因此它也包含一个Lifecycle对象。

简而言之,组件具有生命周期的概念可以归结为它提供了Lifecycle接口的具体实现。这带来了这样的想法,即具有生命周期的组件,如Activity,会公开与其生命周期相关的信息。

为了更好地理解我们可以从组件的生命周期中找到什么信息,我们必须探索Lifecycle抽象类的源代码。如果我们这样做,我们将了解到Lifecycle类包含了它所绑定组件(如ActivityFragment)的生命周期状态信息。Lifecycle类以枚举的形式提供了两个主要的信息追踪点:

  • onCreate(), onStart(), onResume(), onPause(), onStop(), 和 onDestroy()).

  • INITIALIZED, DESTROYED, CREATED, STARTED, 和 RESUMED。如果我们的Activity刚刚接收到onResume()回调,这意味着在新的事件到来之前,它将保持在RESUMED状态。每当有新的事件(生命周期回调)发生时,状态就会改变。

虽然我们已经对生命周期事件(回调)相当熟悉,但我们可能需要更好地理解生命周期状态是如何定义的。

让我们通过一个实际例子来探讨Lifecycle对象可以提供关于Activity组件的哪些信息。如前所述,信息是以事件和状态的形式组织的:

图 12.3 – 活动生命周期图,展示了其生命周期事件和状态

图 12.3 – 活动生命周期图,展示了其生命周期事件和状态

在前面的图中,我们能够通过事件和状态来剖析Activity组件的生命周期。现在我们也对生命周期事件如何触发生命周期状态之间的转换有了更好的了解。

但为什么所有这些事件和状态对我们来说都很重要呢?

实际上,我们的大部分代码都是根据生命周期信息驱动的。为了避免潜在的崩溃、内存泄漏或资源浪费,仅在正确的状态或正确的生命周期事件上执行操作是至关重要的。

当我们考虑生命周期事件时,我们可以这样说,不同类型的功能只能在适当的时间或在某些生命周期回调之后执行。例如,我们不会想在ActivityonDestroy()回调之后更新我们的 UI 组件,因为这很可能导致我们的应用崩溃,因为此时 UI 已经被丢弃。另一个例子是,当我们的Activity中的onResume()事件被调用时,我们知道我们的Activity已经获得了(或重新获得了)焦点,因此我们可以在代码中执行某些操作,比如初始化我们的相机组件。

当我们考虑生命周期状态时,我们可以这样说,不同的持续动作只能在某些生命周期期间运行——例如,如果状态是RESUMED,我们可能想要开始观察数据库变化,因为那时用户可以与屏幕交互并更改数据。当这个状态转换到另一个状态,比如CREATEDDESTROYED时,我们可能想要停止观察数据库变化,以避免内存泄漏和资源浪费。

从前面的例子中可以看出,我们的代码应该了解 Android 组件的生命周期。当我们根据生命周期事件或状态编写代码时,我们正在编写了解特定组件生命周期的代码。

让我们举一个例子,并稍微发挥一下想象力——Presenter类具有由多个网络请求产生的一个数据流。这个数据流被观察并传递到 UI。然而,任何正在进行的网络请求都必须在cancelOngoingNetworkRequests()方法中取消,因为我们的 UI 不再需要消费它们的响应:

class Presenter() {
    // observe data and pass it to the UI
    fun cancelOngoingNetworkRequests() {
        // stop observing data
    }
}

假设我们的Presenter类实例在MainActivity中使用。自然,它必须尊重MainActivity类的生命周期。这就是为什么我们应该通过在MainActivity类的onDestroyed()生命周期回调中调用Presenter类的cancelOngoingNetworkRequests()方法来停止Presenter类中的任何正在进行的网络请求:

class MainActivity : ComponentActivity() {
    val presenter = Presenter()
    override fun onStart() {
        super.onStart()
        //consume data from presenter
    }
    override fun onDestroy() {
        super.onDestroy()
        presenter.cancelOngoingNetworkRequests()
    }
}

我们可以说,我们的Presenter类了解其宿主MainActivity的生命周期。

如果一个组件尊重 Android 组件(如Activity)的生命周期,那么我们可以认为该组件是生命周期感知的

然而,我们通过手动从MainActivity生命周期回调中调用某个清理方法,手动使我们的Presenter类成为生命周期感知的。换句话说,我们的MainActivity手动告诉Presenter它必须停止其正在进行的工作。

此外,无论何时我们需要在其他ActivityFragment类中使用我们的Presenter,该组件都需要记住在某个生命周期回调中调用PresentercancelOngoingNetworkRequests()方法,因此会产生样板代码。如果Presenter需要在某些生命周期回调上执行多个操作,那么这些样板代码就会成倍增加。

使用ActivityFragment组件手动通知我们的类某个生命周期事件已被触发,或达到了某种状态 - Lifecycle包将帮助我们以更有效的方式在我们的组件内部直接接收回调。

Jetpack 的Lifecycle包为我们提供了以下内容:

  • 预定义的不同目的的生命周期感知组件,这些组件需要我们更少的样板代码或工作。这些组件是两个 Jetpack 库:

    • ViewModel

    • LiveData

  • 一个生命周期 API,使我们能够通过更少的样板代码更容易地创建一个自定义的生命周期感知组件。

在创建我们自己的生命周期感知组件之前,我们应该简要介绍 Jetpack Lifecycle包为我们提供的两个预定义的生命周期感知组件。让我们从ViewModel开始。

ViewModel

在这本书中,我们已经介绍了 Jetpack 的ViewModel作为一个类,我们的 UI 状态驻留于此,并且大多数展示逻辑也在这里。然而,我们也了解到,为了正确取消数据流或正在进行的网络请求,ViewModel了解其宿主ActivityFragment甚至其组合目的地(与 Jetpack 导航组件一起)的生命周期。

与我们手动将生命周期与宿主Activity的生命周期关联的Presenter类相比,Jetpack 的ViewModel是一个生命周期感知组件,我们可以用它来消除ActivityFragment组件中的任何样板调用。

更精确地说,ViewModel知道其宿主组件的生命周期何时结束,并提供了一个回调方法,我们可以通过重写onCleared()方法来使用它。在这个回调内部,我们可以取消任何我们不再感兴趣的挂起工作,以避免内存泄漏或资源浪费。

例如,如果我们的ViewModelActivity托管,那么它知道在Activity的生命周期中何时调用了onDestroy()事件,因此它会自动触发onCleared()回调:

图 12.4 – ViewModel 的生命周期与 Activity 的生命周期相关联

图 12.4 – ViewModel 的生命周期与 Activity 的生命周期相关联

这基本上意味着,我们不需要手动让我们的Activity通知ViewModel其生命周期已结束,以便它可以停止其工作,ViewModel是一个生命周期感知组件,它为您提供了处理该事件的句柄 - 即onCleared()回调:

class MyViewModel(): ViewModel() {
    override fun onCleared() {
        super.onCleared()
        // Cancel work
    }
}

此外,在Activity宿主的环境中,ViewModel组件也了解由配置更改等事件引起的任何生命周期回调,因此它知道如何超越这些回调并帮助我们保持 UI 状态,即使在配置更改之后。

但是,ViewModel是如何知道Activity组件的生命周期回调的呢?为了回答这个问题,我们可以通过使用ViewModelProvider API 并在Activity内部实例化ViewModel来查看传统的做法,并指定必须检索的ViewModel类型——即MyViewModel

class MyActivity: ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val vm = 
          ViewModelProvider(this)[MyViewModel::class.java]
         // Perform operations
    }
}

要获取MyViewModel的实例,我们使用了ViewModelProvider()构造函数,并将MyActivity类的this实例传递给期望一个ViewModelStoreOwner对象的owner参数。MyActivity间接实现了ViewModelStoreOwner接口,因为ComponentActivity实现了这个接口。

为了控制ViewModel实例的生命周期,ViewModelProvider需要一个ViewModelStoreOwner实例,因为当它实例化我们的MyViewModel时,它将链接此实例的生命周期到ViewModelStoreOwner的生命周期——即MyActivity

但是,ViewModel是如何知道它必须被清除的呢?换句话说,什么触发了MyViewModel类的onCleared()方法?

ComponentActivity将等待其onDestroy()生命周期回调,当该事件被触发时,它将调用ViewModelStoreOwner接口的getViewModelStore()方法,并获取一个ViewModelStore对象。在这个对象上,它将调用clear()方法来清除与ComponentActivity关联的ViewModel实例——在我们的例子中,是MyViewModel实例。

如果你查看ComponentActivity类的源代码,你会找到以下实现,这证明了我们试图表达的前述观点:

![图 12.5 – 在ComponentActivityonDestroy()回调中清除ViewModelimg/B17788_12_05.jpg

图 12.5 – 在ComponentActivityonDestroy()回调中清除ViewModel

现在,ViewModel生命周期感知组件非常有用,因为它允许我们轻松停止挂起的任务,并在配置更改之间持久化 UI 状态。

然而,还有另一个重要的生命周期感知组件我们没有在本章中介绍,并且我们应该简要提及,那就是LiveData

LiveData

LiveData是一个可观察的数据持有类,它允许我们在 Android 组件内部以生命周期感知的方式获取数据更新,例如ActivityFragment。虽然 Kotlin Flow 数据流的特定实现与LiveData相似,因为两者都允许我们在一段时间内接收多个数据事件,但LiveData具有作为生命周期感知组件的优势。

注意

在本节中,我们不会广泛介绍LiveData的 API,而是尝试突出其生命周期感知特性。目前,你不需要跟随代码编写。

不深入细节,让我们看看在ViewModel类内部保持的LiveData对象的一个简单用法,并从Activity组件中消费它。

ViewModel 中,我们实例化了一个 MutableLiveData 对象,该对象将持有 Int 类型的值,传递了一个初始值为 0,然后在 init{} 块中启动了一个协程,在 5000 毫秒的延迟后,将值设置为 100

class MyViewModel(): ViewModel() {
    val numberLiveData: MutableLiveData<Int> = 
        MutableLiveData(0)
    init {
        viewModelScope.launch {
            delay(5000)
            numberLiveData.value = 100
        }
    }
}

numberLiveData 现在是一个数据持有者,它将首先通知观察它的任何组件其值为 0,然后在 5 秒后,值变为 100

现在,一个 Activity 可以通过首先获取 MyViewModel 的实例,访问其 numberLiveData 对象,然后通过 observe() 方法开始观察变化来进行观察:

class MyActivity: ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val vm = 
          ViewModelProvider(this)[MyViewModel::class.java]
        vm.numberLiveData.observe(this, object: Observer<Int> {
            override fun onChanged(t: Int?) {
                // Consume values
            }
        })
    }
}

现在,让我们看看传递给 observe() 方法的以下内容:

  • 首先,将 MyActivity 类的 this 实例传递给期望 LifecycleOwner 对象的 owner 参数。这之所以有效,是因为 MyActivity 通过 ComponentActivity 间接实现了 LifecycleOwner 接口,因此拥有一个 Lifecycle 对象。observe() 方法期望 LifecycleOwner 作为其第一个参数,这样观察功能就具有对 MainActivity 生命周期的生命周期感知性。

  • 一个 Kotlin 内部 Observer<Int> 对象,允许我们在 onChanged() 回调中接收来自 MutableLiveData 对象的数据事件(持有 Int 值)。每次有新值传播时,此回调将被触发,我们将接收到最新的值。

现在我们已经简要介绍了如何使用 LiveData,让我们更好地理解我们为什么要谈论 LiveData 的整个原因。正如我们提到的,LiveData 是一个生命周期感知组件,但它是如何实现这一点的呢?

当我们将 MainActivity 作为 LifecycleOwner 传递给 observe() 方法的 owner 参数时,在幕后,LiveData 开始了一个依赖于提供 ownerLifecycle 对象的观察过程。

更确切地说,作为 observe() 方法的第二个参数提供的 Observer 对象将仅在所有者(即 MainActivity)处于 STARTEDRESUMED 生命周期状态时接收更新。

这种行为是至关重要的,因为它允许 Activity 组件仅在它们可见或处于焦点时才从 ViewModel 组件接收 UI 更新,从而确保 UI 可以安全地处理数据事件,不会浪费资源

然而,如果更新发生在其他状态,而 UI 未经初始化,我们的应用可能会出现异常行为,甚至更糟,可能会崩溃或引入内存泄漏。为了确保这种行为不会发生,如果所有者移动到 DESTROYED 状态,Observer 对象将被自动移除。

在下面的图中,您将能够可视化 LiveData 更新仅在 Activity 组件处于 RESUMEDSTARTED 状态时发生,同时当状态变为 DESTROYED 时,自动移除 Observer 对象:

图 12.6 – 当接收到 LiveData 更新并移除 LiveData 观察者时的生命周期状态和事件

在这种行为下,LiveData成为了一个生命周期感知组件,因为任何LifecycleOwner都必须处于一个活跃的生命周期状态才能从它那里接收更新。

现在我们已经介绍了Lifecycle包中的两个预定义的生命周期感知组件(ViewModelLiveData),现在是时候在我们的“仓库”应用中添加一个倒计时计时器组件了,这样我们就可以在以后使用 Lifecycle API 将其转换为一个自定义的生命周期感知组件。

在“仓库”应用中添加倒计时组件

我们的计划是学习如何创建我们自己的生命周期感知组件。然而,在我们能够做到这一点之前,我们必须首先创建一个默认情况下不知道任何 Android 组件生命周期的普通组件。

为了做到这一点,我们可以在我们的“仓库”应用中创建一个倒计时计时器组件,该组件将跟踪用户是否在应用上至少花费了 60 秒,如果是这样,我们将向用户颁发一个虚构的奖品。

更具体地说,我们的计划是在RepositoriesScreen()内部创建一个倒计时计时器小部件,当倒计时达到 60 秒时,将向用户颁发奖品。然而,为了使倒计时工作并颁发奖品,用户必须处于RepositoriesScreen()内部,并且倒计时组合可见。

倒计时将表现得如下:

  • 它将从 60 开始,当倒计时达到 0 时结束。每过一秒,计时器将减少 1 个单位。

  • 当倒计时结束时,将显示一个奖品信息。

  • 如果倒计时组合不可见,它将被暂停。换句话说,如果用户不在RepositoriesScreen()组合内部,或者计时器组合在RepositoriesScreen()内部不可见或隐藏,那么倒计时应该被暂停。

现在我们有了计划,让我们实现一个倒计时计时器组件:

  1. 在根包内部,创建一个名为CustomCountdown的新类,并定义其构造函数,使其具有两个将被作为倒计时计时器函数调用的函数参数:

    class CustomCountdown(
        private val onTick: ((currentValue: Int) -> Unit),
        private val onFinish: (() -> Unit),
    ) {
    }
    

我们必须在每过一秒后调用onTick()函数,并在倒计时结束时调用onFinish()函数。

  1. 现在,在CustomCountdown类内部,让我们创建一个名为InternalTimer的内部类,它将继承自内置的 Android android.os.CountDownTimer类,并处理实际的倒计时序列:

    class CustomCountdown(
        private val onTick: ((currentValue: Int) -> Unit),
        private val onFinish: (() -> Unit),
    ) {
        class InternalTimer(
            private val onTick: ((currentValue: Int) -> Unit),
            private val onFinish: (() -> Unit),
            millisInFuture: Long,
            countDownInterval: Long
    ) : CountDownTimer(millisInFuture, 
                           countDownInterval){
        }
    }
    

虽然InternalTimer的构造函数也接受两个相同的函数参数,就像CustomCountdown一样,但必须注意它传递给内置 Android CountDownTimer类的millisInFuturecountDownInterval参数。这两个参数将配置计时器的核心功能——倒计时开始的时间和计时器滴答之间的时间间隔。

  1. 接下来,让我们完成InternalTimer类的实现:

    class CustomCountdown(
        private val onTick: ((currentValue: Int) -> Unit),
        private val onFinish: (() -> Unit),
    ) {
        class InternalTimer(
            private val onTick: ((currentValue: Int) -> Unit),
            private val onFinish: (() -> Unit),
            millisInFuture: Long,
            countDownInterval: Long
        ) : CountDownTimer(millisInFuture, 
            countDownInterval) {
            init {
                this.start()
            }
            override fun onFinish() {
                onFinish.invoke()
            }
            override fun onTick(millisUntilFinished: Long) {
                onTick(millisUntilFinished.toInt())
            }
        }
    }
    

为了确保计时器按预期工作,我们已执行以下操作:

  • init{}块中调用继承的父类CountDownTimer提供的start()方法。这应该在创建时自动启动计时器。

  • 实现了继承的父类CountDownTimer的两个强制onFinish()onTick()方法,并通过调用其onFinish()onTick()函数参数将事件传播给InternalTimer的调用者。

  1. 然后,回到CustomCountdown类中,让我们创建一个InternalTimer实例,并配置它像一个从60开始到0结束的 60 秒倒计时计时器:

为了做到这一点,让我们将其构造函数传递给onFinishonTick函数参数,并将 60 秒(作为60000毫秒)传递给millisInFuture参数,将 1 秒(作为1000毫秒)传递给countDownInterval参数:

class CustomCountdown(
    private val onTick: ((currentValue: Int) -> Unit),
    private val onFinish: (() -> Unit),
) {
    var timer: InternalTimer = InternalTimer(
        onTick = onTick,
        onFinish = onFinish,
        millisInFuture = 60000,
        countDownInterval = 1000)
    class InternalTimer(
        private val onTick: ((currentValue: Int) -> Unit),
        private val onFinish: (() -> Unit),
        millisInFuture: Long,
        countDownInterval: Long
    ): CountDownTimer(millisInFuture, countDownInterval)
    { … }
}
  1. 仍然在CustomCountdown内部,为了提供一个取消倒计时的方法,添加一个stop()方法,这将允许我们调用从 Android CountDownTimer类继承的InternalTimercancel()方法:

    class CustomCountdown(…) {
        var timer: InternalTimer = InternalTimer(…)
        fun stop() {
            timer.cancel()
        }
        class InternalTimer(
            […]
        ): CountDownTimer(millisInFuture, countDownInterval)
        { … }
    }
    
  2. 然后,在RepositoriesViewModel中,添加一个timerState变量,它将保存我们的倒计时可组合显示的文本状态,以及一个timer变量,它将保存一个CustomCountdown对象:

    class RepositoriesViewModel(…) : ViewModel() {
        val repositories: Flow<PagingData<Repository>> = […]
        val timerState = mutableStateOf("")
        var timer: CustomCountdown = CustomCountdown(
            onTick = { msLeft ->
                timerState.value =
    (msLeft / 1000).toString() + 
                       " seconds left"
            },
            onFinish = {
                timerState.value = "You won a prize!"
            })
    }
    

onTick回调内部,我们正在计算剩余的秒数,并将关于我们的倒计时的String消息设置到timerState。然后,在onFinish回调中,我们将奖品信息设置到timerState

  1. 作为一种良好的实践,在RepositoriesViewModel内部,确保在用户移动到不同的屏幕时在onCleared()回调中停止计时器。这意味着RepositoriesScreen()将不再被组合,因此这个ViewModel将被清除,倒计时应该停止,这样它就不会发送事件和浪费资源:

    class RepositoriesViewModel(…) : ViewModel() {
        val repositories: Flow<PagingData<Repository>> = […]
        val timerState = mutableStateOf("")
        var timer: CustomCountdown = CustomCountdown(…)
        override fun onCleared() {
            super.onCleared()
            timer.stop()
        }
    }
    
  2. 现在,转到MainActivity并确保,正如仓库被消费并传递给RepositoriesScreen()可组合一样,由ViewModel产生的倒计时计时器文本也被消费并传递给RepositoriesScreen()

    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                RepositoriesAppTheme {
                    val viewModel: RepositoriesViewModel = …
                    val reposFlow = viewModel.repositories
    val timerText = 
                        viewModel.timerState.value
                    val lazyRepoItems: […] = […]
                    RepositoriesScreen(
                        lazyRepoItems,
                        timerText
                    )
                }
            }
        }
    }
    
  3. 然后,在RepositoriesScreen.kt文件末尾,创建一个简单的CountdownItem()可组合函数,该函数接受一个timerText: String参数并将其值设置为Text可组合:

    @Composable
    private fun CountdownItem(timerText: String) {
        Text(timerText)
    }
    
  4. 接下来,在RepositoriesScreen()可组合中,添加一个名为timerText的新参数,并在LazyColumn作用域内,在itemsIndexed()调用之前,添加一个单独的item() CountdownItem()可组合,并将timerText变量传递给它:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>,
        timerText: String
    ) {
        LazyColumn(…) {
            item {
                CountdownItem(timerText)
            }
            itemsIndexed(repos) { index, repo -> […] }
            […]
        }
    }
    

通过这样做,我们确保倒计时计时器显示在屏幕顶部,作为仓库列表中的第一个项目。

  1. 构建并运行应用程序。你应该首先看到倒计时计时器告诉你需要等待多长时间,大约 1 分钟后,你应该看到奖品信息显示:

![图 12.7 – 观察倒计时计时器的工作情况]

图 12.7 – 观察倒计时计时器的工作情况

图 12.7 – 观察倒计时计时器的工作情况

我们现在已经完成了将倒计时计时器整合到应用中,该计时器通过授予用户虚构的奖品来结束。

然而,有一种情况是我们计时器的工作并不如预期。让我们来识别它:

  1. 重新启动应用。你可以通过关闭当前应用实例并重新打开它来实现。

此时,倒计时应该再次从 60 开始。

  1. 在倒计时结束之前,记住或写下当前的倒计时值,然后将应用置于后台。

  2. 等待几秒钟,然后将应用恢复到前台。

你应该注意到,当应用处于后台时,倒计时仍在继续。我们希望当应用被置于后台时计时器会暂停,当应用被恢复到前台时计时器会继续。这将允许我们奖励那些积极使用应用并且使倒计时计时器可见的用户。然而,这种行为没有发生,因为当应用不可见或未聚焦时,计时器仍在继续计数。

这是因为当应用进入后台或恢复到前台时,我们没有做任何暂停计时器的操作。换句话说,我们的倒计时计时器没有生命周期感知,因此它不会收到通知,也无法对Activity宿主的生命周期事件做出反应。

接下来,让我们使我们的倒计时计时器成为一个具有生命周期感知的组件。

创建自己的生命周期感知组件

我们需要让我们的CustomCountdown了解MainActivity的生命周期。换句话说,我们的倒计时逻辑应该观察并响应我们的LifecycleOwner(即MainActivity)的生命周期事件。

为了使我们的CustomCountdown具有生命周期感知,我们必须强制它实现DefaultLifecycleObserver接口。通过这样做,CustomCountdown将观察LifecycleOwner提供的Lifecycle对象定义的生命周期事件或状态。

我们的主要目标是当应用被置于后台时暂停倒计时,当应用被恢复到前台时恢复倒计时。更确切地说,我们的CustomCountdown必须对MainActivity的以下生命周期事件做出反应:

  • onPause(): 当onPause()回调进入MainActivity时,CustomCountdown必须暂停其倒计时。

  • onResume(): 当onResume()回调进入MainActivity时,CustomCountdown必须恢复其倒计时。

使用这种行为,我们可以奖励那些积极使用应用并且使倒计时计时器可见并处于焦点的用户。

现在我们有了计划,让我们开始编码。

  1. CustomCountdown类实现DefaultLifecycleObserver接口,然后重写我们感兴趣的两种生命周期回调,onResume()onPause()

    class CustomCountdown(
        […]
    ): DefaultLifecycleObserver {
        var timer: InternalTimer = InternalTimer(
            onTick = onTick,
            onFinish = onFinish,
            millisInFuture = 60000,
            countDownInterval = 1000)
        override fun onResume(owner: LifecycleOwner) {
            super.onResume(owner)
        }
        override fun onPause(owner: LifecycleOwner) {
            super.onPause(owner)
        }
        fun stop() { timer.cancel() }
        class InternalTimer(…) {…}
    }
    

一旦我们的 CustomCountdown 观察了 MainActivity 的生命周期,当 MainActivityonResume() 回调被调用时,它的 onResume(owner: LifecycleOwner) 回调将被调用,同样地,当 MainActivityonPause() 回调被调用时,它的 onPause(owner: LifecycleOwner) 回调将被调用。

  1. 现在我们知道了何时暂停和恢复我们的倒计时计时器,我们需要找到实际暂停和恢复它的方法。

首先,让我们在 onPause() 回调中通过调用 timer 变量的 cancel() 方法来暂停倒计时。

class CustomCountdown(
    […]
): DefaultLifecycleObserver {
    var timer: InternalTimer = InternalTimer(…)
    override fun onResume(owner: LifecycleOwner) {
        super.onResume(owner)
    }
    override fun onPause(owner: LifecycleOwner) {
        super.onPause(owner)
        timer.cancel()
    }
    fun stop() { timer.cancel() }
    class InternalTimer(…) : CountDownTimer(…) {…}
}

在这种行为下,当 MainActivity 暂停时,我们正在停止由 timer 变量中持有的 InternalTime 实例执行的倒计时。

  1. 接下来,我们需要在 onResume() 回调中恢复 timer。然而,为了恢复它,我们需要知道在 onPause() 回调触发和计时器被取消之前最后一次倒计时的值。有了这个最后的已知倒计时值,我们可以在 onResume() 回调中重新初始化我们的计时器。

在内部 InternalTimer 类中,创建一个 lastKnownTime 变量,用 millisInFuture 的值初始化它,然后确保在 onFinish()onTick() 计时器回调中更新它:

class CustomCountdown(
    […]
): DefaultLifecycleObserver {
    var timer: InternalTimer = InternalTimer(
        […]
        millisInFuture = 60000,
        countDownInterval = 1000)
    override fun onResume(owner: LifecycleOwner) { … }
    override fun onPause(owner: LifecycleOwner) { … }
    fun stop() { timer.cancel() }
    class InternalTimer(…) : CountDownTimer(…) {
        var lastKnownTime: Long = millisInFuture
        init { this.start() }
        override fun onFinish() {
            lastKnownTime = 0
            onFinish.invoke()
        }
        override fun onTick(millisUntilFinished: Long) {
            lastKnownTime = millisUntilFinished
            onTick(millisUntilFinished.toInt())
        }
    }
}

onFinish() 回调中,我们已经将 lastKnownTime 设置为 0,因为倒计时已经完成;在 onTick() 回调中,我们确保将最新接收到的 onTick() 回调值——即 millisUntilFinished——保存到 lastKnownTime 变量中。

  1. 现在,回到父类 CustomCountdown,在 CustomCountdownonResume() 回调中恢复倒计时,首先取消上一个计时器的倒计时,然后在 timer 变量中存储另一个 InternalTimer 实例,该实例现在从上一个 InternalTimer 实例的 lastKnownTime 值开始倒计时:

    class CustomCountdown(
        […]
    ): DefaultLifecycleObserver {
        var timer: InternalTimer = InternalTimer(
            onTick = onTick,
            onFinish = onFinish,
            millisInFuture = 60000,
            countDownInterval = 1000)
        override fun onResume(owner: LifecycleOwner) {
            super.onResume(owner)
            if (timer.lastKnownTime > 0) {
                timer.cancel()
                timer = InternalTimer(
                    onTick = onTick,
                    onFinish = onFinish,
                    millisInFuture = timer.lastKnownTime,
                    countDownInterval = 1000)
            }
        }
        override fun onPause(owner: LifecycleOwner) { […] }
        fun stop() { timer.cancel() }
        class InternalTimer(…) : CountDownTimer(…) {…}
    }
    

在这种行为下,当 MainActivity 恢复时,我们正在创建一个新的 InternalTimer 实例,该实例从上一个计时器在暂停之前记录的值开始倒计时。此外,请注意,新的 InternalTimer 实例接收与 timer 变量的第一次初始化相同的参数——相同的 onTick()onFinish() 回调以及相同的 countDownInterval ——唯一的区别是倒计时的起点,现在应该小于 60 秒。

为了使 CustomCountdown 类的 onPause()onResume() 回调在 MainActivity 内部调用相应生命周期事件时被调用,我们必须有效地将我们的 DefaultLifecycleObserver——即 CustomCountdown 实例——绑定到我们的 LifecycleOwner——即 MainActivity 的生命周期上。

让我们接下来这么做。

  1. 返回到RepositoriesScreen.kt文件,在CountdownItem()组合组件内部,首先通过调用LocalLifeCycleOwner API 获取组合函数所属的LifecycleOwner实例,然后通过访问其current变量来获取所有者:

    @Composable
    private fun CountdownItem (timerText: String) {
    val lifecycleOwner: LifecycleOwner  = 
            LocalLifecycleOwner.current
        Text(timerText)
    }
    

最后,我们将LifecycleOwner实例存储到lifecycleOwner变量中。

需要指出的是,由于CountdownItem()的父组合组件即RepositoriesScreen()是由MainActivity承载的,因此我们获得的LifecycleOwner实例实际上是MainActivity,这是很自然的。

  1. 然后,我们需要确保我们的lifecycleOwnerLifecycle实例添加和移除我们的DefaultLifecycleObserver计时器。

为了实现这一点,我们首先需要创建一个组合副作用,这样我们就可以知道CountdownItem()组合组件首次进入组合的时间,以便我们可以添加观察者,然后当它从组合中移除时,我们可以移除观察者。

对于这种情况,我们可以使用DisposableEffect()组合组件,它为我们提供了一个代码块,在组合组件进入组合时我们可以执行操作,然后通过其内部的onDispose()块执行其他操作:

@Composable
private fun CountdownItem (timerText: String) {
    val lifecycleOwner: LifecycleOwner = 
        LocalLifecycleOwner.current
    DisposableEffect(key1 = lifecycleOwner) {
        onDispose {

        }
    }
    Text(timerText)
}

由于这是一个副作用,我们在DisposableEffect函数暴露的代码块中添加的任何内容在重新组合时都不会重新执行。然而,如果提供给key1参数的值发生变化,这种效果将会重新启动。在我们的情况下,我们希望这种效果在lifecycleOwner的值发生变化时重新启动——这将允许我们在副作用组合组件内部访问正确的lifecycleOwner实例。

  1. 既然我们知道何时何地可以添加和移除观察者,让我们首先从lifecycleOwner变量中获取Lifecycle对象,以便我们可以将其存储在lifecycle变量中:

    @Composable
    private fun CountdownItem(timerText: String) {
        val lifecycleOwner: LifecycleOwner = 
            LocalLifecycleOwner.current
        val lifecycle = lifecycleOwner.lifecycle
        DisposableEffect(key1 = lifecycleOwner) {
            onDispose {
    
            }
        }
        Text(timerText)
    }
    

接下来,在lifecycle变量内部的Lifecycle对象上,我们将添加和移除观察者。

  1. DisposableEffect()组合组件暴露的代码块内部,通过调用其addObserver()方法在lifecycle变量上添加观察者,然后在其暴露的onDispose()回调中,使用removeObserver()方法将其移除:

    @Composable
    private fun CountdownItem(timerText: String) {
        val lifecycleOwner: LifecycleOwner 
            = LocalLifecycleOwner.current
        val lifecycle = lifecycleOwner.lifecycle
        DisposableEffect(key1 = lifecycleOwner) {
            lifecycle.addObserver()
            onDispose {
                lifecycle.removeObserver()
            }
        }
        Text(timerText)
    }
    

使用这种方法,当CountdownItem()组合组件首次被组合时,我们的倒计时组件将开始观察MainActivity的生命周期事件。然后,当CountdownItem()离开组合时,我们的倒计时组件将不再观察这些事件。

然而,你可能已经注意到,addObserver()removeObserver()方法都期望一个LifecycleObserver对象,但我们没有提供任何。

实际上,我们应该将CustomCountdown实例传递给addObserver()removeObserver()方法,因为CustomCountdown是实现DefaultLifecycleObserver的组件,而我们希望它能够响应我们的MainActivity的生命周期变化。

接下来,让我们获取CustomCountdown实例。

  1. 更新CountdownItem()函数定义,以接收一个返回CustomCountdown计时器的getTimer()函数参数。这个回调方法应该被调用,为addObserver()removeObserver()方法提供一个LifecycleObserver实例:

    @Composable
    private fun CountdownItem(timerText: String, 
        getTimer: () -> CustomCountdown) {
        val lifecycleOwner: LifecycleOwner 
            = LocalLifecycleOwner.current
        val lifecycle = lifecycleOwner.lifecycle
        DisposableEffect(key1 = lifecycleOwner) {
            lifecycle.addObserver(getTimer())
            onDispose {
                lifecycle.removeObserver(getTimer())
            }
        }
        Text(timerText)
    }
    

由于CustomCountdown类实现了DefaultLifecycleObserver,它继承自FullLifecycleObserver,而FullLifecycleObserver又继承自LifecycleObserver,因此addObserver()removeObserver()方法接受我们的CustomCountdown实例作为观察者,观察我们的lifecycleOwner(即MainActivity)的Lifecycle对象。

  1. 由于CountdownItem()现在期望一个getTimer: ()-> CustomCountdown回调函数,我们必须也强制我们的RepositoriesScreen()可组合接受这样的回调函数,并将其传递给我们的CountdownItem()可组合:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>,
        timerText: String,
        getTimer: () -> CustomCountdown
    ) {
        LazyColumn(…) {
            item {
                CountdownItem(timerText, getTimer)
            }
            itemsIndexed(repos) { … }
            […]
        }
    }
    
  2. 最后,在MainActivity内部,更新RepositoriesScreen()可组合调用,以提供getTimer()函数的实现,我们将从viewModel变量的timer字段中获取CustomCountdown实例:

    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                RepositoriesAppTheme {
                    […]
                    RepositoriesScreen(
                        lazyRepoItems, 
                        timerText,
                        getTimer = {viewModel.timer}
                    )
                }
            }
        }
    }
    

我们最终将我们的DefaultLifecycleObserver(即CustomCountdown实例)绑定到了我们的LifecycleOwner(即MainActivity)的生命周期上。现在CustomCountdown类应该能够响应我们的MainActivity的生命周期事件,让我们测试之前的问题场景。

  1. 构建并运行应用。此时倒计时应该从 60 再次开始。

  2. 在倒计时结束之前,记得或记下当前的倒计时值,并将应用置于后台。

  3. 等待几秒钟,然后将应用带回前台。

你现在应该注意到,当应用处于后台时,倒计时被暂停了。我们希望当应用被置于后台时计时器被暂停,当应用被带到前台时再恢复——现在这正是发生的!我们可以现在奖励那些积极使用应用的用户。

然而,我们还没有涵盖一个边缘情况。让我们来发现它:

  1. 构建并运行应用。此时倒计时应该从 60 再次开始。

  2. 在倒计时结束之前,记得或记下当前的倒计时值,然后快速向下滚动列表,超过四到五个存储库,直到倒计时不再可见。

  3. 等待几秒钟,然后向上滚动到列表的顶部,以便倒计时再次可见。

注意,在我们向下滚动后,当计时器不可见时,倒计时仍在继续。我们希望当计时器不再可见时暂停计时器,当计时器再次可见时恢复计时器——这样我们就可以奖励那些计时器可见的用户,使他们不会在我们的比赛中作弊。然而,这种行为没有发生,因为当计时器不可见时,计时器仍在继续计数。

这是因为当计时器组合组件离开组合或再次组合时,我们没有做任何操作来暂停计时器,也没有在组合组件再次组合时恢复它。换句话说,我们的倒计时计时器并不了解我们的计时器组合组件的生命周期。

接下来,让我们使我们的倒计时计时器了解 Compose 组合周期,这样用户就不会在我们的比赛中作弊。

使我们的倒计时组件了解组合组件的生命周期

主要问题是我们的CustomCountdown组件即使在CountdownItem()组合组件离开组合后仍在运行倒计时。我们希望在相应的组合组件不再可见时暂停计时器。采用这种方法,我们可以防止用户作弊,并且只能奖励那些整个倒计时计时器都可见的用户。基本上,如果计时器不再可见,倒计时应该停止。

为了在相应的组合组件离开组合时暂停计时器,我们必须以某种方式调用CustomCountdown暴露的stop()函数。但我们应该何时这样做呢?

如果你查看CountdownItem()组合组件的主体,你会注意到我们已注册了一个DisposableEffect()组合组件,它会通知我们当CountdownItem()组合组件离开组合时,通过暴露onDispose()回调:

@Composable
private fun CountdownItem(…) {
    val lifecycleOwner: […] = LocalLifecycleOwner.current
    val lifecycle = lifecycleOwner.lifecycle
    DisposableEffect(key1 = lifecycleOwner) {
        lifecycle.addObserver(getTimer())
        onDispose {
            lifecycle.removeObserver(getTimer())
        }
    }
    Text(timerText)
}

当组合组件离开组合时,在onDispose()回调中,我们已经在将CustomCountdown作为观察者从我们的MainActivity的生命周期中移除。正是在这一点上,我们也可以暂停计时器,因为组合组件已经离开了组合:

  1. 更新CountdownItem()函数定义,使其接受一个新的onPauseTimer()回调函数,然后确保在DisposableEffect()onDispose()回调中调用它:

    @Composable
    private fun CountdownItem(timerText: String,
        getTimer: () -> CustomCountdown,
        onPauseTimer: () -> Unit) {
        val lifecycleOwner: […] = LocalLifecycleOwner.current
        val lifecycle = lifecycleOwner.lifecycle
        DisposableEffect(key1 = lifecycleOwner) {
            lifecycle.addObserver(getTimer())
            onDispose {
                onPauseTimer()
                lifecycle.removeObserver(getTimer())
            }
        }
        Text(timerText)
    }
    
  2. 由于CountdownItem()现在期望一个onPauseTimer: () -> Unit回调函数,我们必须也强制我们的RepositoriesScreen()组合组件接受这样的回调函数,并将其传递给我们的CountdownItem()组合组件:

    @Composable
    fun RepositoriesScreen(
        repos: LazyPagingItems<Repository>,
        timerText: String,
        getTimer: () -> CustomCountdown,
        onPauseTimer: () -> Unit
    ) {
        LazyColumn(…) {
            item {
                CountdownItem(
                    timerText, 
                    getTimer,
                    onPauseTimer
                )
            }
            itemsIndexed(repos) { … }
            […]
        }
    }
    
  3. 最后,在MainActivity内部,更新RepositoriesScreen()组合组件的调用,以提供onPauseTimer()函数实现,我们将通过viewModel变量通过其timer字段获取的CustomCountdown实例的stop()方法来暂停计时器:

    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                RepositoriesAppTheme {
                    […]
                    RepositoriesScreen(lazyRepoItems, 
                        timerText,
                        getTimer = { viewModel.timer },
    onPauseTimer = 
                            { viewModel.timer.stop() }
                    )
                }
            }
        }
    }
    
  4. 构建并运行应用。此时,倒计时应该再次从 60 开始。

  5. 在倒计时结束之前,记得或记下当前的倒计时值,然后快速向下滚动列表,直到倒计时不再可见。确保滚动过几个仓库,这样 Compose 就会移除计时器可组合组件的节点——如果你只滚动一点,计时器的节点就不会被移除。

  6. 等待几秒钟,然后向上滚动到列表的顶部,以便倒计时再次可见。

注意,当CountdownItem()可组合组件不可见时,计时器现在是暂停的。我们现在已经实现了预期的效果!

但为什么当可组合组件再次可见时,倒计时会恢复?我们没有做任何处理来覆盖这种情况——当CountdownItem()可组合组件离开组合状态时,我们停止了计时器,但没有在它重新进入组合状态时恢复它。

幸运的是,当CountdownItem()可组合组件重新进入组合状态时,计时器会自动恢复——但这是为什么?

这种行为是由于 Lifecycle API 提供的一个有趣的副作用所导致的。更确切地说,一旦我们将LifecycleObserver实例绑定到LifecycleOwnerLifecycle实例上,观察者就会立即接收到对应LifecycleOwner当前状态的事件。

让我们来看看CountdownItem()可组合组件内部,看看这是如何发生的:

@Composable
private fun CountdownItem(timerText: String,
    getTimer: () -> CustomCountdown,
    onPauseTimer: () -> Unit) {
    val lifecycleOwner: LifecycleOwner
                = LocalLifecycleOwner.current
    val lifecycle = lifecycleOwner.lifecycle
    DisposableEffect(key1 = lifecycleOwner) {
        lifecycle.addObserver(getTimer())
        onDispose {
            onPauseTimer()
            lifecycle.removeObserver(getTimer())
        }
    }
    Text(timerText)
}

在我们的案例中,一旦我们将DefaultLifecycleObserver实例——即CustomCountdown——绑定到LifecycleOwner实例的Lifecycle上——即MainActivity——观察者就会接收到对应当前状态的事件作为第一个事件。

换句话说,一旦我们的计时器可组合组件可见,我们就将其作为观察者添加到MainActivity类的生命周期中。在那个时刻,RESUMED状态是MainActivity的当前状态,因此onResume()回调在CustomCountdown组件内部被触发,从而在我们的特定场景中有效地恢复了计时器的倒计时:

class CustomCountdown([…]): DefaultLifecycleObserver {
    var timer: InternalTimer = InternalTimer(…)
    override fun onResume(owner: LifecycleOwner) {
        super.onResume(owner)
        if (timer.lastKnownTime > 0) {
            timer.cancel()
            timer = InternalTimer(
                onTick = onTick,
                onFinish = onFinish,
                millisInFuture = timer.lastKnownTime,
                countDownInterval = 1000)
        }
    }
    override fun onPause(owner: LifecycleOwner) { […] }
    fun stop() { timer.cancel() }
    class InternalTimer(…) : CountDownTimer(…) {…}
}

现在,我们的倒计时计时器已经能够感知 Compose 组合周期了。

摘要

在本章中,我们了解了生命周期感知组件是什么,以及我们如何创建一个。

我们首先探讨了生命周期事件和状态是如何与 Android 组件,如ActivityFragment相关联的,然后是如何通过Lifecycle包中的预定义组件来响应它们。然后,我们在 Repositories 应用中创建并添加了一个倒计时计时器组件。

最后,我们通过使我们的计时器组件不仅能够感知Activity组件的不同生命周期事件和状态,还能感知可组合组件的生命周期,从而阻止用户作弊。

进一步阅读

在本章中,我们简要介绍了如何通过使我们的CustomCountdown组件了解MainActivity所展示的生命周期事件来创建一个生命周期感知的组件。然而,当需要时,我们也可以利用LifecycleOwner的生命周期状态。要了解如何做到这一点,请查看官方文档中的示例:developer.android.com/topic/libraries/architecture/lifecycle#lco

[Packt.com](https://Packt.com

)

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的工具来帮助你规划个人发展和提升职业生涯。更多信息,请访问我们的网站。

第十三章:为什么订阅?

  • 使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速获取关键信息

  • 复制粘贴、打印和收藏内容

你知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在 packt.com 升级到电子书版本,作为印刷版书籍的顾客,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com

www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢以下书籍

如果你喜欢这本书,你可能还会对 Packt 的其他书籍感兴趣:

使用 Jetpack Compose 开发 Android UI

托马斯·库内特

ISBN: 978-1-80181-216-0

  • 深入理解 Jetpack Compose 的核心概念

  • 开发美观、整洁且沉浸式的 UI 元素,这些元素用户友好、可靠且性能出色

  • 使用 Jetpack Compose 构建完整的应用程序

  • 将 Jetpack Compose 添加到现有的 Android 应用程序中

  • 测试和调试使用 Jetpack Compose 的应用程序

![图形用户界面

自动生成的描述](https://www.packtpub.com/product/simplifying-application-development-with-kotlin-multiplatform-mobile/9781801812580)

使用 Kotlin Multiplatform Mobile 简化应用程序开发

罗伯特·纳吉

ISBN: 978-1-80181-258-0

  • 了解多平台方法以及 KMM 的竞争优势

  • 了解 Kotlin Multiplatform 在底层是如何工作的

  • 在 Swift 的背景下快速掌握 Kotlin 语言

  • 了解如何在 Android 和 iOS 之间共享代码

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在您已经完成了 《Apache Arrow 内存分析》,我们非常想听听您的想法!如果您在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面,分享您的反馈或在该购买网站上留下评论。

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

您可能还会喜欢的其他书籍

您可能还会喜欢的其他书籍

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