协程与流式安卓开发简化指南-全-
协程与流式安卓开发简化指南(全)
原文:
zh.annas-archive.org/md5/daa69ecbb23266ad6e23a3f0bc95c71c译者:飞龙
前言
Kotlin 协程和流允许开发者使用简单、现代且可测试的代码在 Android 中进行异步编程。
这本书侧重于通过实践学习协程和流。您将从异步编程的基础开始,包括对协程和流的概述,同时将它们集成到您的 Android 项目中。您将了解如何管理取消和异常,然后探索如何测试您的协程和流。
在本书结束时,您将能够使用 Kotlin 协程和流来简化 Android 中的异步编程。
本书面向对象
这本书是为想要使用协程和流构建高质量应用并提升 Android 开发技能的 Android 开发者而写的。对 Android 开发和 Kotlin 有基本知识的初学者也会发现这本书很有用。
本书涵盖内容
第一章, Android 异步编程简介,探讨了 Android 中的异步编程,并展示了目前所采用的各种方式。在结尾部分,将介绍新的推荐方式——协程和流。
第二章, 理解 Kotlin 协程,介绍了 Kotlin 协程并展示了它们如何在 Android 中用于异步编程。它将演示如何创建协程,并讨论协程构建器、作用域、调度器、上下文和作业。
第三章, 处理协程取消和异常,讨论了协程取消以及如何正确管理协程取消、超时和异常。
第四章, 测试 Kotlin 协程,描述了如何在 Android 中测试 Kotlin 协程。
第五章, 使用 Kotlin 流,涵盖了 Kotlin 流的基本知识以及它们如何在 Android 中用于异步编程。它继续介绍使用流构建器创建流。还将讨论流操作符、缓冲和组合流,以及 StateFlow 和 SharedFlow。
第六章, 处理流程取消和异常,探讨了如何在您的流程中管理取消、完成和异常。
第七章, 测试 Kotlin 流,提供了如何在您的 Android 项目中测试流的详细信息。
为了最大限度地利用本书
您需要具备基本的 Android 开发技能和 Kotlin 使用知识。
您需要拥有最新版本的 Android Studio。您可以在 developer.android.com/studio 下载最新版本。为了获得最佳体验,以下规格是推荐的:
-
英特尔酷睿 i5 或更高性能的处理器
-
至少 4 GB RAM
-
至少 4 GB 可用空间

如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于你避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows/。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个示例:“runOnUIThread将在主 UI 线程上执行displayText(text)函数。”
代码块设置如下:
lifecycleScope.launch(Dispatchers.IO) {
val fetchedText = fetchText()
withContext(Dispatchers.Main) {
displayText(fetchedText)
}
}
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
private fun fetchTextWithThread() {
Thread {
// get text from network
val text = getTextFromNetwork()
runOnUiThread {
// Display on UI
displayText(text)
}
}.start()
}
任何命令行输入或输出都按以下方式编写:
java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在 Android Studio 中,编辑器窗口使用行号旁边的横幅图标标识你的代码中的挂起函数调用。”
小贴士或重要提示
看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果你对此书任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果你在网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果你能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com
分享你的想法
一旦您阅读了《使用协程和流简化 Android 开发》,我们非常期待听到您的想法!请点击此处直接访问亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供高质量的内容。
第一部分 – Android 上的 Kotlin 协程
在本部分,我们将介绍异步编程的概念,并讨论使用协程来实现的新推荐方法。我们将学习如何创建协程、处理取消和异常,以及测试它们。
本节包括以下章节:
-
第一章,Android 中的异步编程简介
-
第二章,理解 Kotlin 协程
-
第三章,处理协程取消和异常
-
第四章,测试 Kotlin 协程
第一章:第一章:Android 异步编程简介
有些 Android 应用程序可以独立工作。但大多数应用程序从本地数据库或后端服务器检索数据或发送数据。这些例子包括从社交网络获取帖子、保存列表中的收藏、上传图片或更新个人资料信息。这些任务和其他资源密集型计算可能立即完成或需要一段时间才能完成。诸如互联网连接、设备规格和服务器设置等因素会影响这些操作所需的时间。
长时间运行的操作不应在主 UI 线程上执行,因为应用程序将在它们完成之前被阻塞。应用程序可能会对用户无响应。用户可能不知道发生了什么,这可能会促使他们关闭应用程序并重新打开它(取消原始任务或重新执行)。应用程序也可能突然崩溃。如果这种情况频繁发生,一些用户甚至可能停止使用你的应用程序。
为了防止这种情况发生,你需要使用异步编程。可能需要不定时长的任务必须异步执行。它们必须在后台运行,与其他任务并行。例如,当向你的后端服务器发送信息时,应用程序显示用户界面,用户可以与之交互。当操作完成时,你可以更新用户界面或通知用户(通过对话框或 Snackbar 消息)。
通过这本书,你将学习如何使用 Kotlin 协程和流简化 Android 中的异步编程。
在本章中,你将首先回顾异步编程的概念。然后,你将了解现在在 Android 中正在进行的各种方式,以及它们可能不再是未来最佳的方式。接着,你将介绍 Android 中执行异步编程的新、推荐方式:协程和流。
本章涵盖三个主要主题:
-
异步编程
-
线程、AsyncTasks 和
Executors -
新的方法——协程和流
到本章结束时,你将基本了解异步编程,并知道如何在 Android 中使用线程、AsyncTasks 和 Executors 来实现它。最后,你将发现 Kotlin 协程和流,因为这些是 Android 中推荐进行异步编程的方式。
技术要求
你需要下载并安装 Android Studio 的最新版本。你可以在 developer.android.com/studio 找到最新版本。为了获得最佳学习体验,建议使用以下规格的计算机:Intel Core i5 或更高版本,至少 4 GB RAM,以及 4 GB 可用空间。
本书中的代码示例可以在 GitHub 上找到,网址为 https: github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows。
理解异步编程
在本节中,我们将首先探讨异步编程。异步编程是一种编程方法,允许工作独立于主应用程序线程进行。
一个正常的程序将按顺序运行。它将执行一个任务,并在前一个任务完成后移动到下一个任务。对于简单的操作,这是可以的。然而,有些任务可能需要很长时间才能完成,例如以下任务:
-
从数据库中获取数据或将数据保存到数据库
-
从网络获取、添加或更新数据
-
处理文本、图像、视频或其他文件
-
复杂的计算
当应用程序执行这些任务时,它将看起来冻结且对用户无响应。它们将无法在应用程序中执行其他任何操作,直到任务完成。
异步编程解决了这个问题。您可以在后台线程(与主线程并行)上运行可能无限期处理的任务,而不会冻结应用程序。这将使用户在原始任务运行时仍然可以与应用程序或用户界面进行交互。当任务完成或遇到错误时,您可以使用主线程通知用户。
异步编程的视觉表示如下所示:

图 1.1 – 异步编程
任务 1 和 任务 2 在主线程上运行。任务 2 在后台线程上启动 任务 3。当 任务 3 运行时,主线程可以继续执行其他任务,例如 任务 4。当 任务 3 完成后,它将返回主线程。
异步编程是开发人员必须掌握的重要技能,尤其是对于移动应用程序开发。移动设备功能有限,并非所有位置都有稳定的网络连接。
在 Android 中,如果您在主线程上运行一个任务并且它花费了太长时间,应用程序可能会变得无响应或看起来冻结。应用程序也可能意外崩溃。您可能会遇到应用程序无响应(ANR)错误,如下面的截图所示:

图 1.2 – ANR 对话框
从 Android 3.0(蜂巢)开始,在主线程上运行网络操作将导致 android.os.NetworkOnMainThreadException,这将使您的应用程序崩溃。
ANR 对话框和崩溃可能会让您的用户感到烦恼。如果它们经常发生,他们可能会完全停止使用您的应用程序,并选择另一个应用程序。为了防止它们在您的应用程序中发生,您必须在后台线程上运行可能需要很长时间的任务。
在本节中,你回顾了异步编程的概念以及如何使用它来运行长时间运行的任务而不会冻结应用程序。你将在下一节中探索在 Android 中使用异步编程的各种方法。
探索线程、AsyncTasks 和 Executors
在 Android 中,你可以通过多种方式在后台线程上运行任务。在本节中,你将探索在 Android 中进行异步编程的各种方法,包括使用线程、AsyncTask 和Executors。你将学习如何在后台线程上启动任务,然后使用结果更新主线程。
线程
线程是并发运行代码的执行单元。在 Android 中,UI 线程是主线程。你可以使用java.lang.Thread类在另一个线程上执行任务:
private fun fetchTextWithThread() {
Thread {
// get text from network
val text = getTextFromNetwork()
}.start()
}
要运行线程,调用Thread.start()。大括号内的所有内容将在另一个线程上执行。你可以在这里执行任何操作,但不能更新 UI,因为你将遇到NetworkOnMainThreadException。
要更新 UI,例如从网络中获取并显示在TextView中的文本,你需要使用Activity.runOnUiThread()。runOnUIThread内部的代码将在主线程上执行,如下所示:
private fun fetchTextWithThread() {
Thread {
// get text from network
val text = getTextFromNetwork()
runOnUiThread {
// Display on UI
displayText(text)
}
}.start()
}
runOnUIThread将在主 UI 线程上执行displayText(text)函数。
如果你不是从活动启动线程,你可以使用处理器而不是runOnUiThread来更新 UI,如图1.3所示:

图 1.3 – 线程和处理器
一个处理器(android.os.Handler)允许你在线程之间进行通信,例如从后台线程到主线程,如图所示。你可以将一个循环器传递给处理器构造函数以指定任务将在哪个线程上运行。循环器是一个在线程队列中运行消息的对象。
要将处理器附加到主线程,你应该使用Looper.getMainLooper(),如下例所示:
private fun fetchTextWithThreadAndHandler() {
Thread {
// get text from network
val text = getTextFromNetwork()
Handler(Looper.getMainLooper()).post {
// Display on UI
displayText(text)
}
}.start()
}
Handler(Looper.getMainLooper())创建一个与主线程绑定的处理器,并在主线程上发布displayText()可运行函数。
Handler.post (Runnable)函数将可运行的函数入队到指定线程上执行。post 函数的其他变体包括postAtTime(Runnable)和postDelayed (Runnable, uptimeMillis)。
或者,你也可以使用处理器发送一个android.os.Message对象,如图1.4所示:

图 1.4 – 线程、处理器和消息
一个线程的处理器允许你向线程的消息队列发送消息。处理器的循环器将执行队列中的消息。
要在您的 Message 对象中包含您想要发送的实际消息,您可以使用 setData(Bundle) 来传递一个包含数据的单个数据包。您还可以使用消息 class 的公共字段(arg1、arg2 和 what 用于整数值,以及 obj 用于对象值)。
您必须然后创建 Handler 的子类并重写 handleMessage(Message) 函数。在那里,您可以从消息中获取数据并在处理程序的线程中处理它。
您可以使用以下函数来发送消息:sendMessage(Message)、sendMessageAtTime(Message, uptimeMillis) 和 sendMessageDelayed(Message, delayMillis)。以下代码显示了使用 sendMessage 函数发送包含数据包的消息的用法:
private val key = "key"
private val messageHandler = object :
Handler(Looper.getMainLooper()) {
override fun handleMessage(message: Message) {
val bundle = message.data
val text = bundle.getString(key, "")
//Display text
displayText(text)
}
}
private fun fetchTextWithHandlerMessage() {
Thread {
// get text from network
val text = getTextFromNetwork()
val message = handler.obtainMessage()
val bundle = Bundle()
bundle.putString(key, text)
message.data = bundle
messageHandler.sendMessage(message)
}.start()
}
在这里,fetchTextWithHandlerMessage() 在后台线程中从网络获取文本。然后它创建一个包含具有 key 键的字符串的数据包对象的 Message,以发送该文本。然后处理程序可以通过 handleMessage() 函数获取消息的数据包,并使用相同的键从数据包中获取字符串。
您还可以发送具有整数值(即 what)的空消息,您可以在 handleMessage 函数中使用这些空消息来识别接收到的消息。这些发送空消息的函数是 sendEmptyMessage(int)、sendEmptyMessageAtTime(int, long) 和 sendEmptyMessageDelayed(int, long)。
此示例使用 0 和 1 作为值来表示什么(“what” 是 Message 类的一个字段,它是一个用户定义的消息代码,以便接收者可以识别这条消息的内容):1 表示后台任务成功的情况,0 表示失败的情况:
private val emptymesageHandler = object :
Handler(Looper.getMainLooper()) {
override fun handleMessage(message: Message) {
if (message.what == 1) {
//Update UI
} else {
//Show Error
}
}
}
private fun fetchTextWithEmptyMessage() {
Thread {
// get text from network
...
if (failed) {
emptyMessageHandler.sendEmptyMessage(0)
} else {
emptyMessageHandler.sendEmptyMessage(1)
}
}.start()
}
在前面的代码片段中,后台线程从网络获取文本。如果操作成功,则发送一个空的 1 消息,如果不成功,则发送 0。处理程序通过 handleMessage() 函数获取消息的 what 整数值,这对应于 0 或 1 的空消息。根据此值,它可以选择更新 UI 或向主线程显示错误。
使用线程和处理程序适用于后台处理,但它们有以下缺点:
-
每次您需要在后台运行任务时,都应该创建一个新的线程并使用
runOnUiThread或一个新的处理程序来将消息回发到主线程。 -
创建线程可能会消耗大量的内存和资源。
-
它也可能减慢您的应用程序。
-
多个线程会使您的代码更难调试和测试。
-
代码可能变得难以阅读和维护。
使用线程会使处理异常变得困难,这可能导致崩溃。
由于线程是异步编程的低级 API,因此最好使用建立在线程之上的 API,例如 executors 和,直到它被弃用之前的 AsyncTask。您可以通过使用 Kotlin 协程来完全避免它,您将在本章后面了解更多关于 Kotlin 协程的内容。
在下一节中,您将探索回调,这是另一种异步 Android 编程的方法。
回调
在 Android 中进行异步编程的另一种常见方法是使用回调。回调是一个在异步代码执行完成后将运行的函数。一些库提供了回调函数,开发者可以在他们的项目中使用。
以下是一个简单的回调示例:
private fun fetchTextWithCallback() {
fetchTextWithCallback { text ->
//display text
displayText(text)
}
}
fun fetchTextWithCallback(onSuccess: (String) -> Unit) {
Thread {
val text = getTextFromNetwork()
onSuccess(text)
}.start()
}
在前面的例子中,在后台获取文本后,将调用 onSuccess 回调,并在 UI 线程上显示文本。
回调对于简单的异步任务工作得很好。然而,它们很容易变得复杂,尤其是在嵌套回调函数和处理错误时。这使得阅读和测试变得困难。你可以通过避免嵌套回调并将函数拆分为子函数来避免这种情况。在本章的稍后部分,你将了解更多关于协程的内容。
AsyncTask
使用 AsyncTask,你不需要手动处理线程。
要使用 AsyncTask,你必须创建一个具有三个泛型类型的子类:
AsyncTask<Params?, Progress?, Result?>()
这些类型如下:
-
Params: 这是AsyncTask的输入类型,如果没有输入则需要,则为空。 -
Progress: 此参数用于指定后台操作的进度,如果没有需要跟踪进度,则为Void。 -
Result: 这是AsyncTask的输出类型,如果没有输出要显示,则为空。
例如,如果你要创建用于从特定端点下载文本的 AsyncTask,你的 Params 将是 URL (String),而 Result 将是文本输出 (String)。如果你想跟踪下载文本剩余时间的百分比,你可以使用 Integer 作为 Progress。你的类声明可能如下所示:
class DownloadTextAsyncTask : AsyncTask<String, Integer,
String>()
你可以使用以下代码启动 AsyncTask:
DownloadTextAsyncTask().execute("https://example.com")
AsyncTask 有四个事件可以用于你的后台处理:
-
doInBackground: 此事件指定将在后台运行的实际任务,例如从远程服务器获取/保存数据。这是唯一一个你必须重写的事件。 -
onPostExecute: 此事件指定在后台操作完成后在 UI 线程上运行的任务,例如显示结果。 -
onPreExecute: 此事件在执行实际任务之前在 UI 线程上运行,通常显示进度加载指示器。 -
onProgressUpdate: 此事件在 UI 线程上运行,表示后台进程的进度,例如显示完成任务剩余的时间。
图 1.5 中的图表可视化了这些 AsyncTask 事件以及它们在哪些线程上运行:

图 1.5 – 主线程和后台线程中的 AsyncTask 事件
onPreExecute、onProgressUpdate 和 onPostExecute 函数将在主线程上运行,而 doInBackground 在后台线程上执行。
回到我们的例子,你的 DownloadTextAsync 类可能如下所示:
class DownloadTextAsyncTask : AsyncTask<String, Void,
String>() {
override fun doInBackground(vararg params:
String?): String? {
valtext = getTextFromNetwork(params[0] ?: "")
//get text from network
return text
}
override fun onPostExecute(result: String?) {
//Display on UI
}
}
在DownloadTextAsync中,doInBackground从网络获取文本并将其作为字符串返回。然后onPostExecute将被调用,并带有可以显示在 UI 线程中的字符串。
AsyncTask可能导致上下文泄露、错过回调或配置更改时崩溃。例如,如果你旋转屏幕,活动将被重新创建,并且可以创建另一个AsyncTask实例。原始实例不会自动取消,当它完成并返回onPostExecute()时,原始活动已经不存在了。
使用AsyncTask也会使你的代码更加复杂,可读性降低。截至 Android 11,AsyncTask已被弃用。建议使用java.util.concurrent或 Kotlin 协程。
在下一节中,你将探索用于异步编程的java.util.concurrent类之一,即Executors。
执行器
在java.util.concurrent包中,你可以使用java.util.concurrent.Executor类进行异步编程。执行器是一个高级 Java API,用于管理线程。它是一个接口,具有一个单一的功能,即execute(Runnable),用于执行任务。
要创建执行器,你可以使用java.util.concurrent.Executors类中的实用方法。Executors.newSingleThreadExecutor()创建一个具有单个线程的执行器。
使用Executor的异步代码将如下所示:
val handler = Handler(Looper.getMainLooper())
private fun fetchTextWithExecutor() {
val executor = Executors.newSingleThreadExecutor()
executor.execute {
// get text from network
val text = getTextFromNetwork()
handler.post {
// Display on UI
}
}
}
使用Looper.getMainLooper()的处理器允许你在后台任务完成后与主线程通信,以便更新 UI。
ExecutorService是一个可以执行更多操作的执行器,其子类之一是ThreadPoolExecutor,这是一个实现了线程池的ExecutorService类,你可以自定义它。
ExecutorService有submit(Runnable)和submit(Callable)函数,可以执行后台任务。它们都返回一个表示结果的Future对象。
Future对象有两个你可以使用的功能,Future.isDone()用于检查执行器是否已完成任务,Future.get()用于获取任务的结果,如下所示:
val handler = Handler(Looper.getMainLooper()
private fun fetchTextWithExecutorService() {
val executor = Executors.newSingleThreadExecutor()
val future = executor.submit {
displayText(getTextFromNetwork())
}
...
val result = future.get()
}
在前面的代码中,使用新的单线程执行器创建的执行器被用来提交可运行函数以从网络获取并显示文本。submit函数返回一个Future对象,你可以稍后使用Future.get()来获取结果。
在本节中,你学习了可以在 Android 中进行异步编程的一些方法。虽然它们仍然有效,你仍然可以使用它们(除了现在已弃用的AsyncTask),但如今,它们并不是最佳的选择。
在下一节中,你将学习 Android 中异步编程的新推荐方法:使用 Kotlin 协程和流。
新的方法——协程和流
在本节中,你将了解 Android 异步编程的推荐方法:使用协程和流。协程是 Kotlin 库,你可以在 Android 中使用它来执行异步任务。协程是一个用于管理返回单个值的后台任务的库。流是在协程之上构建的,可以返回多个值。
Kotlin 协程
协程是 Kotlin 库,用于管理后台任务,例如进行网络调用、访问文件或数据库,或执行长时间运行的后台任务。使用 Kotlin 协程是 Google 对 Android 异步编程的官方推荐。他们的 Android Jetpack 库,如 Lifecycle、WorkManager 和 Room-KTX,现在都支持协程。其他 Android 库,如 Retrofit、Ktor 和 Coil,为 Kotlin 协程提供了一级支持。
使用 Kotlin 协程,你可以以顺序方式编写代码。可以将长时间运行的任务制作成 suspend 函数。挂起函数是一种可以暂停线程而不阻塞它的函数,因此线程可以继续运行其他任务。当挂起函数完成后,当前线程将恢复执行。这使得代码更容易阅读、调试和测试。协程遵循结构化并发原则。
你可以通过在 app/build.gradle 文件依赖项中添加以下行来将协程添加到你的 Android 项目中:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-
core:1.6.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-
android:1.6.0"
kotlinx-coroutines-core 是 Kotlin 协程的主要库,而 kotlinx-coroutines-android 添加了对主 Android 线程(Dispatchers.Main)的支持。
要将一个函数标记为挂起函数,你可以向它添加 suspend 关键字;例如,这里有一个调用 fetchText() 函数的函数,它从端点检索文本,然后在 UI 线程中显示它:
fun fetchText(): String {
...
}
你可以通过在 suspend 关键字前缀来将 fetchText() 函数制作成挂起函数,如下所示:
suspend fun fetchText(): String { ... }
然后,你可以创建一个协程,该协程将调用 fetchText() 挂起函数并显示列表,如下所示:
lifecycleScope.launch(Dispatchers.IO) {
val fetchedText = fetchText()
withContext(Dispatchers.Main) {
displayText(fetchedText)
}
}
lifecycleScope 是协程将运行的上下文。launch 创建一个在 Dispatchers.IO 中运行的协程,这是一个用于 I/O 或网络操作的线程。
fetchText() 函数将在开始网络请求之前暂停协程。在协程暂停期间,主线程可以执行其他工作。
获取文本后,它将恢复协程。withContext(Dispatchers.Main) 将协程上下文切换到主线程,其中 displayText(text) 函数将被执行(Dispatchers.Main)。
在 Android Studio 中,你的代码中的 suspend 函数调用会显示在行号旁边的横幅图标。如图所示,fetchText() 和 withContext() 行旁边有 suspend 函数调用的横幅图标:

图 1.6 – Android Studio 暂停功能调用凹槽图标
你可以在 第二章,理解 Kotlin 协程 中了解更多关于 Kotlin 协程的信息。
在下一节中,你将了解基于协程构建的 Kotlin Flows,它可以返回多个值序列。
Kotlin Flows
Flow 是一个基于 Kotlin 协程构建的新 Kotlin 异步流库。Flow 可以发出多个值,而不是单个值,并在一段时间内发出。当需要异步返回多个值时,例如从数据源自动更新,Kotlin Flow 是理想的选择。
Flow 现在已用于 Jetpack 库如 Room-KTX,Android 开发者已经在他们的应用程序中使用 Flow。
要在你的 Android 项目中使用 Kotlin Flows,你必须添加协程。创建对象流的一个简单方法就是使用 flow{} 构建器。使用 flow{} 构建器函数,你可以通过调用 emit 向流中添加值。
假设在你的 Android 应用中,你有一个 getTextFromNetwork 函数,它从网络端点获取文本并将其作为 String 对象返回:
fun getTextFromNetwork(): String { ... }
如果我们想要创建一个包含文本中每个单词的流,我们可以用以下代码实现:
private fun getWords(): Flow<String> = flow {
getTextFromNetwork().split(" ").forEach {
delay(1_000)
emit(it)
}
}
Flow 不会运行或发出值,直到使用任何终端操作符(如 collect、launchIn 或 single)收集流。你可以使用 collect() 函数启动流并处理每个值,如下所示:
private suspend fun displayWords() {
getWords().collect {
Log.d("flow", it)
}
}
以下图中展示了该流的可视化表示:

图 1.7 – Kotlin Flow 的可视化表示
如你在 图 1.7 中所见,一旦 getWords() 流发出字符串,displayWords 函数就会收集该字符串并立即在日志中显示。
你将在 第五章,使用 Kotlin Flows 中了解更多关于 Kotlin Flows 的信息。
在本节中,你学习了 Kotlin 协程和 Flows,这是在 Android 中执行异步编程的推荐方式。协程是 Kotlin 用于管理后台长时间运行任务的库。Flow 是一个基于协程构建的新 Kotlin 异步流库,可以在一段时间内发出多个值。
摘要
在本章中,你重新回顾了异步编程的概念。我们了解到异步编程可以帮助你在后台执行长时间运行的任务,而不会冻结应用并烦扰用户。
然后,你学习了在 Android 中进行异步编程的各种方法,包括使用线程、AsyncTask 和 Executors。我们还了解到,它们允许你在后台执行任务并更新主线程。AsyncTask 已经被弃用,而线程和 Executors 并不是在 Android 中执行异步编程的最佳方式。
最后,你被介绍到了在 Android 中执行异步编程的新推荐方法:使用 Kotlin 的协程和 Flow。我们了解到协程是一个 Kotlin 库,你可以用它轻松地在后台执行异步、非阻塞和长时间运行的任务。建立在协程之上的 Flow 允许你处理随时间返回多个值的函数。
在下一章中,你将更深入地了解 Kotlin 协程,并学习如何在你的 Android 项目中使用它们。
进一步阅读
本书假设你具有使用 Kotlin 进行 Android 开发的经验和技能。如果你想了解更多关于这方面的内容,你可以阅读书籍《如何使用 Kotlin 构建安卓应用》(Packt Publishing,2021,ISBN 9781838984113)。
第二章:第二章:理解 Kotlin 协程
在上一章中,您回顾了异步编程的概念以及它是如何帮助您在后台执行长时间运行的任务,而不会冻结应用并烦扰应用的用户。您学习了如何使用线程、AsyncTasks 和 Executors 进行异步编程。最后,您被介绍到 Android 上执行此操作的新方法:Kotlin 协程和流。
协程是 Kotlin 用于多线程和异步编程的库,例如进行网络调用或访问文件或数据库。Kotlin 协程是 Google 对 Android 上异步编程的官方推荐。Android Jetpack 库,如 ViewModel、Lifecycle、WorkManager 和 Room,都包括对 Kotlin 协程的支持。第三方 Android 库,如 Retrofit,现在也提供了对 Kotlin 协程的支持。
在本章中,我们将深入探讨 Kotlin 协程。您将学习如何使用协程在 Android 中通过简单的代码执行异步编程。您还将学习如何在您的 Android 应用中创建协程。然后,我们将讨论协程的其他构建块,如构建器、作用域、调度器、上下文和任务。
在本章中,我们将涵盖以下主题:
-
在 Android 中创建协程
-
探索协程构建器、作用域和调度器
-
理解协程上下文和任务
-
练习 – 在 Android 应用中使用协程
到本章结束时,您将了解如何使用 Kotlin 协程。您将能够在您的 Android 应用中为各种情况添加协程。您还将了解协程的基本构建块:构建器、作用域、调度器、上下文和任务。
技术要求
对于本章,您需要下载并安装最新版本的 Android Studio。您可以在developer.android.com/studio找到最新版本。为了获得最佳的学习体验,建议使用以下配置的计算机:Intel Core i5 或更高性能的处理器,至少 4 GB 的 RAM,以及 4 GB 的可用空间。
本章的代码示例可以在 GitHub 上找到,地址为github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows/tree/main/Chapter02。
在 Android 中创建协程
在本节中,我们将首先探讨如何在 Android 中创建协程。协程提供了一种使用 Kotlin 标准函数编写异步代码的简单方法。您可以在进行网络调用或从本地数据库获取或保存数据时使用协程。
一个简单的协程看起来如下所示:
CoroutineScope(Dispatchers.IO).launch {
performTask()
...
}
它有四个部分:CoroutineScope、Dispatchers、launch 以及协程将执行的 lambda 函数。为协程的作用域创建了一个 CoroutineScope 实例。Dispatchers.IO 是指定此协程将在 I/O 调度器上运行的调度器,通常用于 launch 的协程构建器是创建协程的协程构建器。我们将在本章后面详细探讨这些组件。
以下图表总结了协程的这些部分:

图 2.1 – 协程的部分
在 Android Studio 中,performTask() 调用旁边有暂停函数调用行号图标:

图 2.2 – Android Studio 暂停函数调用行号图标
假设你有一个显示当前电影院正在上映的电影列表的 Android 应用程序。那么,让我们看看你可以使用 suspend 函数并将协程添加到项目中的方法。
如果你正在使用 Retrofit 2.6.0 或更高版本,你可以使用 suspend 将端点函数标记为暂停函数,如下所示:
@GET("movie/now_playing")
suspend fun getMovies() : List<Movies>
然后,你可以创建一个协程,该协程将调用 getMovies 暂停函数并显示列表:
CoroutineScope(Dispatchers.IO).launch {
val movies = movieService.getMovies()
withContext(Dispatchers.Main) {
displayMovies(movies)
}
}
这将创建一个在后台获取电影的协程。withContext 调用将改变协程的上下文,使其使用 Dispatchers.Main 在主线程上显示获取到的电影。
如果你正在使用 Room-KTX 2.1 或更高版本,你可以在你的 数据访问对象 (DAO) 函数中添加 suspend 关键字,这样查询或操作就可以在后台线程上执行,结果将在主线程上发布。以下是一个示例:
@Dao
interface MovieDao {
@Query("SELECT * from movies")
suspend fun getMovies(): List<Movies>
...
}
这将使 getMovies 查询成为一个暂停函数。当你调用此函数时,Room-KTX 内部会在后台线程上执行查询。结果可以在主线程上显示,而不会冻结你的应用。
当你在另一个协程内部创建协程时,新的协程成为原始协程的子协程。原始协程成为新协程的父协程。这可以在以下代码中看到:
CoroutineScope(Dispatchers.IO).launch {
performTask1()
launch {
performTask2()
}
...
}
使用 performTask2 启动的第二个协程是使用父协程的 Coroutine Scope 创建的。
在本节中,你探索了如何将协程添加到你的 Android 项目中,并学习了如何为你的应用程序创建协程。在下一节中,你将探索协程的一些构建块:构建器、作用域和调度器。
探索协程构建器、作用域和调度器
在本节中,你将学习如何使用协程构建器,并探索协程作用域和调度器。协程构建器是用于创建协程的函数。协程作用域是协程运行的作用域。调度器指定协程将在哪个线程上运行。
协程构建器
在前一节中,你使用 launch 创建了一个协程。然而,还有其他创建协程的方法。协程构建器是你可以用来创建协程的函数。要创建一个协程,你可以使用以下 Kotlin 协程构建器:
-
launch -
async -
runBlocking
async 和 launch 需要在协程作用域中启动。同时,runBlocking 不需要从协程作用域中启动。
launch 关键字创建一个协程,并且不返回任何值。相反,它返回一个表示协程的 Job 对象。
当你想运行一个任务然后忘记它(这意味着你不需要等待操作的结果)时,launch 协程构建器是理想的选择。以下是一个使用 launch 协程构建器的示例:
class MainActivity : AppCompatActivity() {
val scope = MainScope()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val progressBar =
findViewById<ProgressBar>(R.id.progressBar)
scope.launch {
delay(1_000)
progressBar.isVisible = true
}
}
}
一旦创建了活动,就会启动一个协程。这个协程将调用 delay 暂停函数来延迟协程一秒,然后恢复,并显示进度条;然后,任务完成。
另一方面,async 构建器与 launch 类似,但它返回一个值:一个 Deferred 对象。稍后,你可以使用 await 函数获取这个值。当你想执行一个任务并获取该任务的输出时,应该使用 async 构建器。以下是一个使用 async 协程构建器的示例:
class MainActivity : AppCompatActivity() {
val scope = MainScope()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView =
findViewById<TextView>(R.id.textView)
scope.launch {
val text = async {
getText()
}
delay(1_000)
textView.text = text.await()
}
}
}
这里,使用 async 启动了一个协程来调用 getText 函数。这将返回一个名为 text 的延迟对象。将会有 1 秒的延迟,然后使用 text.await() 调用 text 的实际值,这将作为 textView 的文本设置。使用 async,可以并行计算两个任务。
runBlocking 启动一个新的协程并阻塞当前线程,直到任务执行完毕。这在需要阻塞线程的情况下很有用。创建单元测试就是这种情况之一:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val progressBar =
findViewById<ProgressBar>(R.id.progressBar)
runBlocking {
delay(2_000)
progressBar.isVisible = true
}
}
}
在前面的代码中,runBlocking 代码将创建一个协程并阻塞线程。在延迟 2,000 毫秒(2 秒)后,它将显示进度条。
在本节中,你了解了如何使用协程构建器创建协程。你还学习了 async、launch 和 runBlocking 协程构建器。
在下一节中,你将探索协程作用域。
协程作用域
CoroutineScope 是协程将运行的上下文。它定义了从它创建的协程的生命周期,从其开始到其结束。如果你取消一个作用域,它将取消它创建的所有协程。协程遵循结构化并发原则——即提供协程结构的机制。
launch 和 async 协程构建器是 CoroutineScope 的扩展函数,用于创建协程。
例如,假设我们使用 MainScope 创建了一个协程:
class MainActivity : AppCompatActivity() {
val scope = MainScope()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val progressBar =
findViewById<ProgressBar>(R.id.progressBar)
scope.launch {
progressBar.isVisible = true
}
}
}
这将使用 MainScope 启动一个协程以显示进度条。
MainScope 是主线程的主要 CoroutineScope,它使用 Dispatchers.Main 进行其协程。它通常用于创建将更新用户界面的协程。
您也可以通过使用 CoroutineScope 工厂函数创建一个 CoroutineScope 来代替使用 MainScope。CoroutineScope 函数需要您传递一个协程上下文。CoroutineContext 是协程元素集合,用于指定协程应该如何运行。
在之前的示例中,您为协程上下文传递了一个调度器以及一个调度器和作业。调度器和作业是协程上下文元素。您将在本章后面了解更多关于协程上下文的内容。
您的 CoroutineScope 必须有一个作业以及一种取消协程的方式,例如当 Activity、Fragment 或 ViewModel 被关闭时。
在下一节中,我们将查看一个名为 lifecycleScope 的内置协程作用域,它是 Jetpack 的 Lifecycle 库的一部分。
lifecycleScope
lifecycleScope 是 Jetpack 的 Lifecycle 库中的一个 CoroutineScope,您可以使用它来创建协程。它与 Lifecycle 对象(类似于您的活动或片段)相关联,并在生命周期被销毁时自动取消。因此,您不再需要手动取消它们。
lifecycleScope 简化了作用域的创建方式、作业的处理方式以及它们如何在您的活动或片段中被取消。lifecycleScope 使用 Dispatchers.Main.immediate 作为其调度器,并使用 SupervisorJob 作为其作业,例如 viewModelScope。
要使用 lifecycleScope,您必须将以下行添加到您的 app/build.gradle 文件依赖项中:
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
lifeCycleScope 的一个示例如下:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val progressBar =
findViewById<ProgressBar>(R.id.progressBar)
lifecycleScope.launch {
progressBar.isVisible = true
}
}
}
当活动创建时,它从 lifecycleScope 启动一个协程来显示进度条。
要更改协程将使用的调度器,您可以在使用 launch 和 async 协程构建器时传递一个调度器:
lifecycleScope.launch(Dispatchers.IO) { ... }
这将使用 Dispatchers.IO 调度器而不是启动的协程对象的默认 Dispatchers.Main.immediate。
除了 launch 之外,lifecycleScope 还具有额外的协程构建器,这取决于生命周期状态:
-
launchWhenCreated -
launchWhenStarted -
launchWhenResumed
如其名所示,launchWhenCreated 在生命周期创建时启动协程,launchWhenStarted 在生命周期启动时启动协程,而 launchWhenResumed 在生命周期回到 Resumed 状态时启动协程。
在下一节中,我们将查看从 ViewModel 中内置的 CoroutineScope,称为 viewModelScope。
viewModelScope
viewModelScope 是 ViewModel 创建协程的默认 CoroutineScope。如果你需要从 ViewModel 执行长时间运行的任务,这是理想的选择。当 ViewModel 被清除(即调用 onCleared)时,此作用域和所有正在运行的任务将自动取消。
viewModelScope 简化了 Scope 的创建、作业处理和 ViewModel 内部的取消。viewModelScope 使用 Dispatchers.Main.immediate 作为其调度器,并使用 SupervisorJob 作为作业。SupervisorJob 是 Job 的一个特殊版本,允许其子协程相互独立地失败。
要使用 viewModelScope,你必须将以下行添加到你的 app/build.gradle 文件依赖项中:
implementation "androidx.lifecycle:lifecycle-viewmodel-
ktx:2.4.1"
你可以这样使用 viewModelScope:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch {
fetchMovies()
}
}
}
这将从 viewModelScope 启动一个协程,用于运行 fetchMovies() 函数。
要更改协程将使用的调度器,你可以在使用 launch 和 async 协程构造器时传递一个调度器:
viewModelScope.launch (Dispatchers.IO) { ... }
这将使用 Dispatchers.IO 为协程,而不是 viewModelScope 的默认值 Dispatchers.Main。
coroutineScope{} 和 supervisorScope{}
coroutineScope{} 挂起构造器允许你创建一个具有其外部作用域协程上下文的 CoroutineScope。这调用代码块内部,直到所有事情都完成才完成。
你可以使用一个 coroutineScope{} 构造器,如下所示:
private suspend fun fetchAndDisplay() = coroutineScope {
launch {
val movies = fetchMovies()
displayMovies(movies)
}
launch {
val shows = fetchShows()
DisplayShows(shows)
}
}
这将创建一个作用域,它将调用 fetchMovies 函数,将返回值设置为 movies 对象,然后使用 movies 调用 displayMovies 函数。另一个子协程将调用 fetchShows 函数,将返回值设置为 shows 对象,然后使用 shows 调用 displayShows 函数。
当一个子协程失败时,它将取消父协程和兄弟协程。如果你不希望发生这种情况,你可以使用 supervisorScope{} 而不是 coroutineScope{}。
supervisorScope{} 构造器与 coroutineScope{} 构造器类似,但协程的 Scope 具有管理作业。这允许 supervisorScope 的子协程相互独立地失败。
supervisorScope 的一个示例如下:
private suspend fun fetchAndDisplayMovies() =
supervisorScope {
launch {
val movies = fetchMovies()
displayMovies(movies)
}
launch {
val shows = fetchShows()
displayShows(shows)
}
}
这将创建一个带有 SupervisorJob 的管理作用域,它将调用 fetchMovies 函数。当一个子协程失败时,父协程和兄弟协程将继续工作,不会受到影响。
GlobalScope
GlobalScope 是一个特殊的 CoroutineScope,它与对象或作业无关。它只应在必须运行在应用程序存活期间始终活跃的任务或任务的情况下使用。因此,如果你想使用 GlobalScope,你必须使用 @OptIn(DelicateCoroutinesApi::class) 注解调用。
对于 Android 中的所有其他情况,建议使用 viewModelScope、lifecycleScope 或自定义协程作用域。
测试作用域
Kotlin 有一个名为kotlinx-coroutines-test的库用于测试协程。这个测试库包括一个特殊的协程作用域,你可以用它来为你的协程创建测试。你将在第四章中了解更多关于测试协程的内容,测试 Kotlin 协程。
在本节中,你学习了CoroutineScope的相关知识,以及如何使用CoroutineScope函数创建协程作用域。你还了解了内置的作用域,例如viewModelScope和lifecycleScope。
在下一节中,你将学习关于协程调度器的内容。
协程调度器
协程有一个上下文,其中包括协程调度器。调度器指定协程将使用哪个线程来执行任务。以下调度器可以被使用:
-
Dispatchers.Main:这用于在 Android 的主线程上运行,通常用于用户界面的更新。Dispatchers.Main的一个特殊版本,称为Dispatchers.Main.immediate,用于立即在主线程中执行协程。viewModelScope和lifecycleScope协程作用域默认使用Dispatchers.Main.immediate。 -
Dispatchers.IO:这是为网络操作设计的,用于从文件或数据库中读取或写入。 -
Dispatchers.Default:这用于 CPU 密集型工作,如复杂的计算或处理文本、图像或视频。如果你没有设置调度器,默认将选择Dispatchers.Default。 -
Dispatchers.Unconfined:这是一个特殊的调度器,它不受任何特定线程的限制。它将在当前线程中执行协程,并在任何由挂起函数使用的线程中恢复它。
你可以在设置CoroutineScope中的上下文或使用协程构建器时设置调度器。
当使用MainScope作为你的协程作用域时,默认使用Dispatchers.Main:
MainScope().launch { ... }
这个协程将自动使用Dispatchers.Main,因此你不再需要指定它。
如果你使用了不同的协程作用域,你可以传递协程将使用的调度器:
CoroutineScope(Dispatchers.IO).launch {
fetchMovies()
}
上一段代码创建了一个将使用Dispatchers.IO调度器的CoroutineScope。
你也可以在使用launch和async协程构建器时传递调度器:
viewModelScope.launch(Dispatchers.Default) { ... }
这将使用Dispatchers.Default调度器启动一个协程。
要更改协程的上下文,你可以使用withContext函数来为想要使用不同线程的代码。例如,在你的挂起函数getMovies中,该函数从你的端点获取电影,你可以使用Dispatchers.IO:
suspend fun getMovies(): List<Movies> {
withContext(Dispatchers.IO) { ... }
}
在前面的代码中,getMovies函数使用Dispatchers.IO从网络端点获取电影列表。
在本节中,你学习了调度器是什么,以及根据你的需求可以使用哪些调度器。你还学习了如何使用withContext来更改协程运行的特定线程。
在下一节中,你将探索协程上下文和任务。
理解协程上下文和任务
在本节中,你将了解协程上下文和任务。协程在协程上下文中运行。任务是指协程的上下文,它允许你管理协程的执行。
协程上下文
每个协程都在协程上下文中运行。协程上下文是一组元素,用于指定协程应该如何运行。协程作用域有一个默认的协程上下文;如果它是空的,它将有一个EmptyCoroutineContext。
当你创建一个CoroutineScope或使用协程构建器时,你可以传递一个CoroutineContext。在之前的示例中,我们传递了一个调度器:
CoroutineScope(Dispatchers.IO) {
…
}
viewModelScope.launch(Dispatchers.Default) { ... }
之前的示例展示了如何在CoroutineScope函数或协程构建器中传递调度器。
你在这些函数中传递的是CoroutineContext。以下是一些你可以使用的CoroutineContext元素:
-
CoroutineDispatcher -
Job -
CoroutineName -
CoroutineExceptionHandler
主要的CoroutineContext元素是调度器和任务。调度器指定协程运行的线程,而协程的任务允许你管理协程的任务。
任务允许你管理协程的生命周期,从协程的创建到任务的完成。你可以使用这个任务来取消协程本身。你将在第三章中了解更多关于协程取消和异常处理的内容。
CoroutineName是另一个你可以使用的CoroutineContext,用于设置一个字符串来命名协程。这个名称对于调试目的可能很有用。例如,你可以使用以下代码添加一个CoroutineName:
val scope = CoroutineScope(Dispatchers.IO)
scope.launch(CoroutineName("IOCoroutine")) {
performTask()
}
这将为使用Dispatchers.IO调度器启动的协程赋予IOCoroutine的名称。
由于协程上下文是协程元素集合,你可以使用如+这样的运算符来组合上下文元素以创建一个新的CoroutineContext:
val context = Dispatchers.Main + Job()
例如,MainScope、viewModelScope和lifecycleScope使用以下内容作为协程作用域上下文:
SupervisorJob() + Dispatchers.Main.immediate
你还可以使用的另一个协程上下文元素是CoroutineExceptionHandler,这是一个你可以用来处理异常的元素。你将在第三章中了解更多关于CoroutineExceptionHandler的内容,处理协程取消和异常。
在上一节中,你使用了withContext函数来更改调度器以指定不同的线程来运行你的协程。正如其名所示,这会通过调度器更改协程上下文,调度器本身也是一个CoroutineContext元素:
withContext(Dispatchers.IO) { ... }
这通过一个新的调度器Dispatchers.IO更改了协程上下文。
在下一节中,你将了解任务。
协程任务
一个ContextCoroutine元素,您可以使用它来创建协程上下文。您可以使用作业来管理协程的任务和其生命周期。作业可以被取消或合并。
launch协程构建器创建一个新的作业,而async协程构建器返回一个Deferred<T>对象。Deferred本身就是一个Job对象——也就是说,一个有结果的作业。
要从协程访问作业,您可以将其设置为一个变量:
val job = viewModelScope.launch(Dispatchers.IO) { ... }
launch协程构建器创建一个在Dispatchers.IO线程上运行的协程,并返回一个作业。作业可以有子作业,使其成为父作业。Job有一个children属性,您可以使用它来获取作业的子作业:
val job1 = viewModelScope.launch(Dispatchers.IO) {
val movies = fetchMovies()
val job2 = launch {
...
}
...
}
在此示例中,job2成为job1的子作业,即父作业。这意味着job2将继承父作业的协程上下文,尽管您也可以更改它。
如果父作业被取消或失败,其子作业也会自动取消。当一个子作业被取消或失败时,其父作业也会被取消。
SupervisorJob是作业的一个特殊版本,允许其子作业相互独立地失败。
使用作业还可以让您创建一个可以在以后启动的协程,而不是默认立即运行。为此,您必须在协程构建器中将start参数的值设置为CoroutineStart.LAZY,并将结果分配给一个Job变量。稍后,您可以使用start()函数来运行协程,如下所示:
val lazyJob = viewModelScope.launch (start=CoroutineStart.LAZY) {
delay(1_000)
...
}
...
lazyJob.start()
这将创建一个懒加载的协程。当您准备好启动它时,您可以简单地调用lazyJob.start()。
使用Job对象,您还可以使用join()挂起函数等待作业完成,然后再继续另一个作业或任务:
viewModelScope.launch {
val job1 = launch {
showProgressBar()
}
...
job1.join()
...
val job2 = launch {
fetchMovies()
}
}
在此示例中,job1将首先运行,job2将不会执行,直到前面的作业(job1)完成。
在下一节中,您将了解更多关于协程作业的状态。
协程作业状态
作业有以下状态:
-
新建
-
活动
-
完成状态
-
已完成
-
取消中
-
已取消
以下图表总结了作业及其生命周期的这些状态:

图 2.3 – 协程作业生命周期
当您启动一个协程时,start()或join()函数中会创建一个作业。作业在运行时处于活动状态。
完成作业将其移动到完成状态,一旦其子任务完成,则进入已完成状态。
如果作业被手动取消或由于异常而失败,它将进入取消中状态,一旦其子任务完成,则进入已取消状态。
Job对象有三个属性,您可以使用它们来检查作业的状态:
-
isActive: 当作业正在运行或完成时,此属性为true,否则为false。 -
isComplete: 当作业完成其任务(已取消或完成)时,此属性为true,否则为false。 -
isCancelled:如果作业已被取消或正在被取消(手动或由于异常),则此属性为true,否则为false。
你将在 第三章 中学习更多关于作业的内容,以及它们是如何用于取消协程的,处理协程取消和异常。
在本节中,你学习了关于协程上下文和作业的内容。CoroutineContext 是协程上下文元素的集合,用于协程,它指定了协程应该如何运行。CoroutineContext 元素的例子包括调度器和作业。作业是由协程创建的。你可以用它来管理协程的任务和生命周期。
现在,你将使用到目前为止所学的内容,将协程添加到 Android 项目中。
练习 – 在 Android 应用中使用协程
在这个练习中,你将使用一个显示当前电影院正在上映的电影的应用程序。你将使用 The Movie Database API 版本 3 来获取电影列表。访问 developers.themoviedb.org/3 并注册一个 API 密钥。完成此操作后,按照以下步骤进行:
-
打开本书代码库中
Chapter02目录下的Movie App项目。 -
打开
MovieRepository并使用 The Movie Database API 的值更新apiKey:private val apiKey = "your_api_key_here" -
打开
app/build.gradle文件,并添加kotlinx-coroutines-android的依赖项:implementation ‘org.jetbrains.kotlinx:kotlinx- coroutines-android:1.6.0’
这将把 kotlinx-coroutines-core 和 kotlinx-coroutines-android 库添加到你的项目中,允许你在代码中使用协程。
-
此外,添加
ViewModel扩展库的依赖项:implementation ‘androidx.lifecycle:lifecycle- viewmodel-ktx:2.4.1’
这将把 ViewModel KTX 库添加到你的项目中。它包括一个 viewModelScope 用于 ViewModel。
-
打开
MovieViewModel类,导航到fetchMovies函数,并添加以下代码:fun fetchMovies() { _loading.value = true viewModelScope.launch(Dispatchers.IO) { } }
这将创建一个将在 Dispatchers.IO(网络操作的后台线程)上运行的协程。协程将通过 viewModelScope 启动。
-
在
fetchMovies协程中,调用 MovieRepository 的fetchMovies函数以从 The Movie Database API 获取电影列表:fun fetchMovies() { _loading.value = true viewModelScope.launch(Dispatchers.IO) { movieRepository.fetchMovies() _loading.postValue(false) } }
协程将被启动,并将调用 MovieRepository 中的 fetchMovies 函数。
- 运行应用程序。你将看到应用程序显示了一个电影列表(带有海报和标题),如下面的截图所示:
![图 2.4 – 显示电影列表的应用程序]

图 2.4 – 显示电影列表的应用程序
在这个练习中,你使用 ViewModel 的 viewModelScope 创建了一个协程,使用了 launch 协程构建器,并执行了一个从仓库获取电影的任务。
摘要
在本章中,你学习了更多关于 Kotlin 协程以及如何在 Android 中使用它们进行异步编程。
你学习了如何使用 launch、async 和 runBlocking 等协程构建器创建协程。然后,你学习了调度器以及如何使用它们来设置协程运行的线程。你还学习了协程作用域和内置作用域,如 viewModelScope 和 lifecycleScope。
之后,你学习了协程上下文和任务。CoroutineContext 是协程的上下文,包括协程将运行的调度器等元素,以及一个任务,你可以用它来管理协程的任务。
最后,你完成了一个练习,在其中你将一个协程添加到了 Android 项目中。你使用了 ViewModel 的 viewModelScope 作为协程作用域,launch 协程构建器,并实现了使用 Dispatchers.IO 获取电影列表的协程。
在下一章中,你将学习如何处理协程取消、超时和异常。
第三章:第三章:处理协程取消和异常
在上一章中,你深入学习了 Kotlin 协程,并了解了如何使用简单的代码在 Android 中进行异步编程。你学习了如何使用协程构建器创建协程。最后,你探索了协程调度器、协程作用域、协程上下文和任务。
当协程的目的已经实现或任务已完成时,可以取消协程。你也可以根据你应用程序中的特定实例来取消它们,例如当你想要用户通过点击按钮手动停止一个任务时。协程并不总是成功的,也可能失败;开发者必须能够处理这些情况,以确保应用程序不会崩溃,并且可以通过显示 toast 或 snackbar 消息来通知用户。
在本章中,我们将首先理解协程取消。你将学习如何取消协程,并处理协程的取消和超时。然后,你将学习如何管理协程中可能发生的失败和异常。
在本章中,我们将涵盖以下主题:
-
取消协程
-
管理协程超时
-
在协程中捕获异常
到本章结束时,你将理解协程取消以及如何使你的协程可取消。你将能够在你自己的协程中添加和处理超时。你还将知道如何在协程中添加代码来捕获异常。
技术要求
你需要下载并安装 Android Studio 的最新版本。你可以在 developer.android.com/studio 找到最新版本。为了获得最佳的学习体验,建议使用以下配置的计算机:Intel Core i5 或更高版本,至少 4 GB RAM,以及 4 GB 可用空间。
本章的代码示例可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows/tree/main/Chapter03。
取消协程
在本节中,我们将首先探讨协程取消。开发者可以在他们的项目中手动或通过程序取消协程。你必须确保你的应用程序可以处理这些取消。
如果你的应用程序正在进行一个长时间运行的操作,耗时超过了预期,你认为它可能会导致崩溃,你可能想要停止该任务。你也可以结束那些不再必要的任务,以释放内存和资源,例如当用户离开启动任务的 activity 或关闭应用程序时。如果你在应用程序中提供了该功能,用户也可以手动停止某些操作。协程使开发者更容易取消这些任务。
如果你正在使用ViewModel或lifecycleScope从 Jetpack Lifecycle Kotlin 扩展库中,你可以轻松地创建协程而无需手动处理取消。当ViewModel被清除时,viewModelScope会自动取消,而lifecycleScope在生命周期被销毁时会自动取消。如果你创建了自定义的协程作用域,你必须自己添加取消操作。
在上一章中,你了解到使用如launch之类的协程构建器会返回一个cancel()函数来取消协程。以下是一个示例:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch {
val job = launch {
fetchMovies()
}
...
job.cancel()
}
}
}
job.cancel()函数将取消启动以调用fetchMovies()函数的协程。
在取消作业后,你可能想在继续下一个任务之前等待取消完成,以避免竞态条件。你可以通过在调用call函数后调用join函数来实现这一点:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch() {
val job = launch {
fetchMovies()
}
...
job.cancel()
job.join()
hideProgressBar()
}
}
}
在这里添加job.join()会使代码在执行下一个任务hideProgressBar()之前等待作业被取消。
你还可以使用Job.cancelAndJoin()扩展函数,它与调用cancel然后调用join函数相同:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch() {
val job = launch {
fetchMovies()
}
...
job.cancelAndJoin()
hideProgressBar()
}
}
}
cancelAndJoin函数将调用cancel和join函数简化为单行代码。
协程作业可以有子协程作业。当你取消一个作业时,其子作业(如果有)也将被取消,递归地。
如果你的协程作用域有多个协程,并且你需要取消所有这些协程,你可以使用协程作用域中的cancel函数而不是逐个取消作业。这将取消作用域中的所有协程。以下是一个使用协程作用域的cancel函数来取消协程的示例:
class MovieViewModel: ViewModel() {
private val scope = CoroutineScope(Dispatchers.Main +
Job())
init {
scope.launch {
val job1 = launch {
fetchMovies()
}
val job2 = launch {
displayLoadingText()
}
}
}
override fun onCleared() {
scope.cancel()
}
}
在这个例子中,当调用scope.cancel()时,它将取消在协程scope作用域中创建的job1和job2协程。
使用协程作用域中的cancel函数可以更容易地取消使用指定作用域启动的多个作业。然而,在你对该作用域调用cancel函数之后,协程作用域将无法再启动新的协程。如果你想取消作用域的协程,但之后还想从作用域中创建协程,你可以使用scope.coroutineContext.cancelChildren()代替:
class MovieViewModel: ViewModel() {
private val scope = CoroutineScope(Dispatchers.Main +
Job())
init {
scope.launch() {
val job1 = launch {
fetchMovies()
}
val job2 = launch {
displayLoadingText()
}
}
}
fun cancelAll() {
scope.coroutineContext.cancelChildren()
}
...
}
调用cancelAll函数将取消作用域协程上下文中的所有子作业。你之后仍然可以使用该作用域来创建协程。
取消协程将抛出CancellationException,这是一个特殊异常,表示协程已被取消。这个异常不会使应用程序崩溃。你将在本章的后面部分学习更多关于协程和异常的内容。
你还可以将CancellationException的子类传递给cancel函数以指定不同的原因:
class MovieViewModel: ViewModel() {
private lateinit var movieJob: Job
init {
movieJob = scope.launch() {
fetchMovies()
}
}
fun stopFetching() {
movieJob.cancel(CancellationException("Cancelled by
user"))
}
...
}
当用户调用 stopFetching 函数时,此操作会使用包含消息 Cancelled by user 的 CancellationException 取消 movieJob 作业。
当你取消一个协程时,协程作业的状态将变为 Cancelling。它不会自动进入 Cancelled 状态并取消协程。除非你的协程有可以停止其运行的代码,否则协程可以在取消后继续运行。作业及其生命周期中的这些状态总结在下图中:
![Figure 3.1 – Coroutine job life cycle]
![Figure 3.1_B17773.jpg]
![Figure 3.1 – Coroutine job life cycle]
你的协程代码需要协作才能可取消。协程应尽可能快地处理取消操作。它必须检查协程的取消操作,如果协程已被取消,则抛出 CancellationException。
使你的协程可取消的一种方法是通过使用 isActive 来检查协程作业是否处于活动状态(仍在运行或完成)或不是。一旦协程作业的状态变为 Cancelling、Cancelled 或 Completed,isActive 的值将变为假。你可以通过以下方法使用 isActive 使你的协程可取消:
-
当
isActive为真时执行任务。 -
只有当
isActive为真时才执行任务。 -
如果
isActive为假,则返回或抛出异常。
你还可以使用的另一个函数是 Job.ensureActive()。它将检查协程作业是否处于活动状态,如果不是,则抛出 CancellationException。
下面是一个示例,说明如何使用 isActive 使你的协程可取消:
class SensorActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var job: Job
…
private fun processSensorData() {
job = scope.launch {
if (isActive) {
val data = fetchSensorData()
saveData(data)
}
}
}
fun stopProcessingData() {
job.cancel()
}
...
}
processSensorData 函数中的协程将检查作业是否处于活动状态,并且只有在 isActive 的值为真时才会继续执行任务。
使你的协程代码可取消的另一种方法是使用 kotlinx.coroutines 包中的挂起函数,例如 yield 或 delay。yield 函数将当前协程调度器的线程(或线程池)让给其他协程运行。
yield 和 delay 函数已经检查了取消操作,并停止了执行或抛出了 CancellationException。因此,当你在协程中使用它们时,不再需要手动检查取消操作。以下是一个使用前面代码片段的示例,该片段已更新为使用挂起函数 delay 以使协程可取消:
class SensorActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var job: Job
override fun onCreate(savedInstanceState: Bundle?) {
...
processSensorData()
}
private fun processSensorData() {
job = scope.launch {
delay (1_000L)
val data = fetchSensorData()
saveData(data)
}
}
fun stopProcessingData() {
job.cancel()
}
...
}
delay 挂起函数将检查协程作业是否已取消,如果是,则抛出 CancellationException,使你的协程可取消。
在下一节中,我们将学习如何在 Android 项目中实现协程取消。
练习 3.01 – 在 Android 应用中取消协程
在这个练习中,你将工作于一个使用协程从 100 慢慢倒数到 0 并在 TextView 上显示值的程序。然后你将添加一个按钮来取消协程,在计数达到 0 之前停止倒计时:
-
在 Android Studio 中创建一个新的项目。不要更改
MainActivity活动的建议名称。 -
打开
app/build.gradle文件并添加kotlinx-coroutines-android的依赖项:implementation ‘org.jetbrains.kotlinx:kotlinx- coroutines-android:1.6.0’
这将把kotlinx-coroutines-core和kotlinx-coroutines-android库添加到你的项目中,允许你在代码中使用协程。
-
打开
activity_main.xml布局文件并为TextView添加一个id属性:<TextView android:id="@+id/textView" style="@style/TextAppearance.AppCompat.Large" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="0" />
id属性将允许你稍后更改此TextView的内容。
-
打开
MainActivity文件。向MainActivity类添加以下属性:private val scope = CoroutineScope(Dispatchers.Main) private?var job: Job? = null private lateinit var textView: TextView private var count = 100 -
第一行指定协程的作用域,
CoroutineScope,使用Dispatchers.Main作为调度器。第二行创建一个协程作业的job属性。textView属性将用于显示倒计时文本,count初始化倒计时为 100。在MainActivity文件的onCreate函数中,初始化TextView的值:textView = findViewById(R.id.textView)
你将使用这个textView来更新值减少的值。
-
创建一个倒计时函数,用于计数:
private fun countdown() { count-- textView.text = count.toString() }
这将count的值减 1 并在文本视图中显示它。
-
在
onCreate函数中,在textView初始化下方,添加以下代码以启动协程来计数并显示在文本视图中:job = scope.launch { while (count > 0) { delay(100) countdown() } }
这将每0.1秒调用倒计时函数,它将倒计时并显示在文本视图中。
- 运行应用程序。你会看到它缓慢地倒计时并显示从 100 到 0 的值,类似于以下内容:
![图 3.2 – 应用从 100 倒计时到 0
![img/Figure_3.02_B17773_new.jpg]
图 3.2 – 应用从 100 倒计时到 0
-
打开
strings.xml文件并为按钮添加一个字符串:<string name="stop">Stop</string>
你将使用这个作为停止倒计时的按钮文本。
-
再次打开
activity_main.xml文件,在TextView下方添加一个按钮:<Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/stop" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textView" />
这将在TextView下方添加一个Button。该按钮将用于稍后停止倒计时。
-
打开
MainActivity并在作业初始化后,为按钮创建一个变量:val button = findViewById<Button>(R.id.button)
当这个按钮被点击时,将允许用户停止倒计时。
-
在下面,给按钮添加一个取消作业的点击监听器:
button.setOnClickListener { job?.cancel() }
当你点击按钮时,它将取消协程。
- 再次运行应用程序。点击STOP按钮并注意倒计时停止,如图所示:
![图 3.3 – 点击 STOP 按钮取消协程
![img/Figure_3.03_B17773_new.jpg]
图 3.3 – 点击 STOP 按钮取消协程
点击job.cancel()调用。这之所以有效,是因为协程正在使用挂起的delay函数,该函数会检查协程是否处于活动状态。
在这个练习中,你已经通过点击按钮添加了代码来取消 Android 应用中正在运行的协程。
可能存在一些情况,即使你取消了作业,你仍然想继续工作。为了确保即使在协程被取消的情况下任务也会完成,你可以在任务上使用withContext(NonCancellable)。
在本节中,你学习了如何取消协程以及如何确保你的协程代码是可取消的。你将在下一节中学习如何处理协程超时。
管理协程超时
在本节中,你将学习关于超时以及如何使用超时取消你的协程。设置一个固定的时间,在此之后停止运行时间超过预期的异步代码,可以帮助你节省资源,并立即通知用户任何问题。
当你的应用程序正在执行后台任务时,你可能想停止它,因为它花费了太长时间。你可以手动跟踪时间并取消任务。或者你可以使用withTimeout挂起函数。使用withTimeout函数,你可以设置以毫秒或Duration为单位的超时时间。一旦这个超时时间被超过,它将抛出TimeOutCancellationException,这是CancellationException的一个子类。以下是如何使用withTimeout的一个示例:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch {
val job = launch {
withTimeout(5_000L) {
fetchMovies()
}
}
...
}
}
}
为协程设置了 5,000 毫秒(5 秒)的超时。如果fetchMovies任务花费的时间超过这个时间,协程将超时并抛出TimeoutCancellationException。
你还可以使用另一个函数withTimeoutOrNull。它与withTimeout函数类似,但如果超时被超过,它将返回 null。以下是如何使用withTimeoutOrNull的一个示例:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch() {
val job = async {
fetchMovies()
}
val movies = withTimeoutOrNull(5_000L) {
job.await()
}
...
}
}
...
}
如果fetchMovies在 5 秒后超时,协程将返回 null,如果没有超时,它将返回获取到的电影列表。
如你在前一节中学到的,协程必须是可取消的,这样它才会在超时后被取消。在下一节中,你将学习如何处理协程的取消异常。
在本节中,你了解了协程超时以及如何设置一个时间,在此之后自动取消协程。
在协程中捕获异常
在本节中,你将学习关于协程异常以及如何在你的应用程序中处理它们。由于你的协程可能会失败,因此学习如何捕获异常以避免崩溃并通知用户是非常重要的。
要处理协程中的异常,你可以简单地使用try-catch。例如,如果你使用launch协程构建器启动了一个协程,你可以执行以下操作来处理异常:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch() {
try {
fetchMovies()
} catch (exception: Exception) {
Log.e("MovieViewModel",
exception.message.toString())
}
}
}
...
}
如果fetchMovies有异常,ViewModel将异常信息写入日志。
如果你使用async协程构建器构建了协程,当你在Deferred对象上调用await函数时,将抛出异常。处理异常的代码可能如下所示:
class MovieViewModel: ViewModel() {
init {
viewModelScope.launch() {
val job = async {
fetchMovies()
}
var movies = emptyList<Movie>()
try {
movies = job.await()
} catch (exception: Exception) {
Log.e("MovieViewModel",
exception.message.toString())
}
}
}
...
}
如果在fetchMovies调用运行时遇到异常,电影列表将是一个空的电影列表,并且ViewModel将异常消息写入日志。
当协程遇到异常时,它将取消作业并将异常传递给其父级。这个父级协程以及其子协程都将被取消。如果你使用如下SupervisorJob,子协程中的异常不会影响父级及其兄弟协程:
-
使用
supervisorScope{}构建器创建协程作用域 -
为你的协程作用域使用
SupervisorJob:CoroutineScope(SupervisorJob())
如果你的协程异常是CancellationException的子类,例如TimeoutCancellationException或你传递给cancel函数的自定义异常,异常将不会传递给父级。
在处理协程异常时,你也可以使用CoroutineExceptionHandler在单个位置处理这些异常。CoroutineExceptionHandler是你可以添加到你的协程中以处理未捕获异常的协程上下文元素。以下代码行展示了如何使用它:
class MovieViewModel: ViewModel() {
private val exceptionHandler =
CoroutineExceptionHandler { _, exception ->
Log.e("MovieViewModel",
exception.message.toString())
}
private val scope = CoroutineScope(exceptionHandler)
...
}
从作用域启动的协程异常将由exceptionHandler处理,如果它没有在任何可能出错的地方被处理,它将把异常消息写入日志。
让我们尝试添加代码来处理你的协程中的异常。
练习 3.02 – 在你的协程中捕获异常
在这个练习中,你将继续工作于在TextView上显示从 100 开始并逐渐减少到 0 的应用程序。你将添加代码来处理协程中的异常:
-
打开你在上一个练习中构建的倒计时应用程序。
-
前往
MainActivity文件,并在倒计时函数的末尾添加以下代码以模拟异常:if ((0..9).random() == 0) throw Exception("An error occurred")
这将生成一个从 0 到 9 的随机数,如果是 0,它将抛出异常。这将模拟协程遇到异常。
-
运行应用程序。它将开始倒计时,并在某个时刻抛出异常并崩溃应用。
-
在你的协程中的代码周围使用
try-catch块来捕获应用中的异常:job = scope.launch { try { while (count > 0) { delay(100) countdown() } } catch (exception: Exception) { //TODO } }
这将捕获倒计时函数中的异常。应用将不再崩溃,但你需要通知用户关于异常的信息。
-
在
catch块中,将//TODO替换为Snackbar以显示异常消息:Snackbar.make(textView, exception.message.toString(), Snackbar.LENGTH_LONG).show()
这将显示一个包含文本An error occurred的 snackbar 消息,这是异常的消息。
- 再次运行应用程序。它将开始倒计时,但不会崩溃,而是会显示一个 snackbar 消息,如图下所示:
![图 3.4 – 当协程遇到异常时显示的 Snackbar]
![img/Figure_3.04_B17773_new.jpg]
图 3.4 – 当协程遇到异常时显示的 Snackbar
在这个练习中,你更新了你的应用程序,使其能够处理协程中的异常而不是崩溃。
在本节中,你学习了关于协程异常以及如何在你的 Android 应用中捕获它们。
摘要
在本章中,你学习了关于协程取消的内容。你可以通过使用协程作业中的cancel或cancelAndJoin函数,或者协程作用域中的cancel函数来取消协程。
你了解到协程取消需要是协作式的。你还学习了如何通过使用isActive检查或使用kotlinx.coroutines包中的挂起函数来更改你的代码,使你的协程可取消。
然后,你学习了关于协程超时的内容。你可以使用withTimeout或withTimeoutOrNull来设置超时(以毫秒或Duration为单位)。
你还学习了协程异常以及如何捕获它们。可以使用try-catch块来处理异常。你还可以在协程作用域中使用CoroutineExceptionHandler来在单个位置捕获和处理异常。
最后,你进行了一个练习,向协程添加取消功能,以及另一个练习来更新你的代码以处理协程异常。
在下一章中,你将深入探讨如何在你的 Android 项目中创建和运行协程的测试。
第四章:第四章: 测试 Kotlin 协程
在上一章中,你学习了关于协程取消和如何使你的协程可取消的内容。然后,你学习了关于以毫秒或 try-catch 和 CoroutineExceptionHandler 形式的协程超时。
创建测试是应用程序开发的重要部分。你编写的代码越多,出现错误和缺陷的可能性就越高。有了测试,你可以确保你的应用程序按你编程的方式工作。你可以快速发现问题并立即修复它们。测试可以使开发更容易,节省时间和资源。它们还可以帮助你自信地重构和维护代码。
在本章中,你将学习如何在 Android 中测试 Kotlin 协程。首先,我们将更新 Android 项目以进行测试。然后,我们将继续学习创建 Kotlin 协程测试的步骤。
在本章中,我们将涵盖以下主题:
-
为测试协程设置 Android 项目
-
单元测试挂起函数
-
测试协程
在本章结束时,你将了解协程测试。你将能够为你的 Android 应用中的协程编写和运行单元测试和集成测试。
技术要求
你需要下载并安装 Android Studio 的最新版本。你可以在 developer.android.com/studio 找到最新版本。为了获得最佳学习体验,建议使用以下规格的计算机:
-
英特尔酷睿 i5 或更高版本
-
最小 4 GB RAM
-
可用空间 4 GB
本章的代码示例可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows/tree/main/Chapter04。
为测试协程设置 Android 项目
在本节中,我们将首先探讨如何更新你的 Android 应用以使其准备好添加和运行测试。一旦你的项目设置得当,添加单元和集成测试将变得容易。
在 Android 上创建单元测试时,你必须有 app/build.gradle 依赖项,当你在 Android Studio 中创建新的 Android 项目时。
如果你的 Android 项目还没有 JUnit 4,你可以通过在 app/build.gradle 依赖中包含以下内容来添加它:
dependencies {
...
testImplementation ‘junit:junit:4.13.2’
}
这允许你使用 JUnit 4 框架进行单元测试。
为了创建测试的模拟对象,你也可以使用模拟库。Mockito 是最受欢迎的 Java 模拟库,你可以在 Android 上使用它。要将 Mockito 添加到你的测试中,请将以下内容添加到 app/build.gradle 文件中的依赖项:
dependencies {
...
testImplementation ‘org.mockito:mockito-core:4.0.0’
}
添加此依赖项允许你在项目中使用 Mockito 创建用于单元测试的模拟对象。
如果您更喜欢使用 Mockito 与惯用的 Kotlin 代码,您可以使用 Mockito-Kotlin。Mockito-Kotlin 是一个包含辅助函数的 Mockito 库,可以使您的代码更接近 Kotlin。
要在您的 Android 单元测试中使用 Mockito-Kotlin,您可以在您的 app/build.gradle 文件依赖项中添加以下依赖项:
dependencies {
...
testImplementation ‘org.mockito.kotlin:mockito-
kotlin:4.0.0’
}
这将使您能够使用 Mockito 使用惯用的 Kotlin 代码创建测试的模拟对象。
如果您在项目中同时使用 Mockito (mockito-core) 和 Mockito-Kotlin,您只需添加 Mockito-Kotlin 的依赖项。它已经包含了 mockito-core 的依赖项,它将自动导入。
要测试 LiveData 等 Jetpack 组件,请添加 androidx.arch.core:core-testing 依赖项:
dependencies {
...
testImplementation ‘androidx.arch.core:core-
testing:2.1.0’
}
此依赖项包含对测试 Jetpack 架构组件的支持。它包括 InstantTaskExecutorRule 等 JUnit 规则,您可以使用这些规则来测试您的代码中的 LiveData 对象。
测试协程比常规测试要复杂一些。这是因为协程是异步的,任务可以并行运行,并且任务可能需要一段时间才能完成。您的测试必须快速且一致。
为了帮助您测试协程,您可以使用来自 kotlinx-coroutines-test 包的协程测试库。它包含一些实用类,使测试协程更容易、更高效。要在您的 Android 项目中使用它,您必须在您的 app/build.gradle 文件依赖项中添加以下内容:
dependencies {
...
testImplementation ‘org.jetbrains.kotlinx:kotlinx-
coroutines-test:1.6.0’
}
这将导入 kotlinx-coroutines-test 依赖项到您的 Android 项目中。然后您将能够使用 Kotlin 协程测试库中的实用类为您的协程创建单元测试。
如果您想在将在模拟器或物理设备上运行的 Android 仪器测试中使用 kotlinx-coroutines-test,您应该在您的 app/build.gradle 文件中添加以下依赖项:
dependencies {
...
androidTestImplementation
‘org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0’
}
将此添加到您的依赖项中,将允许您在您的仪器测试中使用 kotlinx-coroutines-test。
截至 1.6.0 版本,协程测试库仍然被标记为实验性。您可能需要使用 @ExperimentalCoroutinesApi 注解来注释测试类,如下面的示例所示:
@ExperimentalCoroutinesApi
class MovieRepositoryUnitTest {
...
}
在本节中,您学习了如何设置您的 Android 项目以添加测试。您将在下一节中学习如何为挂起函数创建单元测试。
单元测试挂起函数
在本节中,我们将重点介绍您如何对挂起函数进行单元测试。您可以创建对 ViewModel 等类进行单元测试,这些类启动协程或具有挂起函数。
为挂起函数创建单元测试比编写挂起函数本身更困难,因为挂起函数只能从协程或另一个协程中调用。您可以做的事情是使用 runBlocking 协程构建器,并从那里调用挂起函数。例如,假设您有一个类似于以下 MovieRepository 类:
class MovieRepository (private val movieService:
MovieService) {
...
private val movieLiveData =
MutableLiveData<List<Movie>>()
fun fetchMovies() {
...
val movies = movieService.getMovies()
movieLiveData.postValue(movies.results)
}
}
这个MovieRepository有一个名为fetchMovies的挂起函数。这个函数通过调用movieService中的getMovies挂起函数来获取电影列表。
要为fetchMovies函数创建测试,你可以使用runBlocking来调用挂起函数,如下所示:
class MovieRepositoryTest {
...
@Test
fun fetchMovies() {
...
runBlocking {
...
val movieLiveData =
movieRepository.fetchMovies()
assertEquals(movieLiveData.value, movies)
}
}
}
使用runBlocking协程构建器允许你调用挂起函数并进行断言检查。
runBlocking协程构建器对于测试很有用。然而,有时它可能会因为代码中的延迟而变慢。你的单元测试理想上应该尽可能快地运行。协程测试库可以通过其runTest协程构建器帮助你。它与runBlocking协程构建器相同,只是它立即且无延迟地运行挂起函数。
在上一个示例中将runBlocking替换为runTest会使你的测试看起来像以下这样:
@ExperimentalCoroutinesApi
class MovieRepositoryTest {
...
@Test
fun fetchMovies() {
...
runTest {
...
val movieLiveData =
movieRepository.fetchMovies()
assertEquals(movieLiveData.value, movies)
}
}
}
runTest函数允许你调用movieRepository.fetchMovies()挂起函数,然后检查操作的结果。
在本节中,你学习了如何在 Android 项目中编写挂起函数的单元测试。在下一节中,你将学习如何测试协程。
测试协程
在本节中,我们将关注如何测试你的协程。你可以为启动协程的类,如ViewModel,编写测试。
对于使用Dispatchers.Main启动的协程,你的单元测试将失败,并显示以下错误消息:
java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
这个异常发生是因为Dispatchers.Main使用Looper.getMainLooper(),这是应用程序的主线程。在 Android 中,对于本地单元测试,这个主循环器是不可用的。为了使你的测试工作,你必须使用Dispatchers.setMain扩展函数来更改Main分发器。例如,你可以在你的测试类中创建一个在测试之前运行的函数:
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
setUp函数将在测试之前运行。它将主分发器更改为另一个分发器以供你的测试使用。
Dispatchers.setMain将更改所有后续的Dispatchers.Main使用。在测试之后,你必须通过调用Dispatchers.resetMain()来将Main分发器改回。你可以做如下操作:
@After
fun tearDown() {
Dispatchers.resetMain()
}
测试运行后,将调用tearDown函数,该函数将重置Main分发器。
如果你有很多协程要测试,在每个测试类中复制和粘贴这个样板代码并不是一个好的选择。你可以创建一个自定义 JUnit 规则,这样你就可以在测试类中重复使用它。这个 JUnit 规则必须位于你的测试源集的根文件夹中,如图图 4.01所示:
![图 4.1 – 根测试文件夹中的自定义 TestCoroutineRule]
![图 4.1 – 根测试文件夹中的自定义 TestCoroutineRule]
图 4.1 – 根测试文件夹中的自定义 TestCoroutineRule
你可以编写的自定义 JUnit 规则示例,用于自动设置Dispatchers.setMain和Dispatchers.resetMain,是这个TestCoroutineRule:
@ExperimentalCoroutinesApi
class TestCoroutineRule(val dispatcher: TestDispatcher =
UnconfinedTestDispatcher()):
TestWatcher() {
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
}
}
这个自定义 JUnit 规则将允许你的测试在测试之前自动调用Dispatchers.setMain,并在测试之后调用Dispatchers.resetMain。
你可以通过添加@get:Rule注解在你的测试类中使用这个TestCoroutineRule:
@ExperimentalCoroutinesApi
class MovieRepositoryTest {
@get:Rule
var coroutineRule = TestCoroutineRule()
...
}
使用这段代码,你不需要在测试类中每次都添加Dispatchers.setMain和Dispatchers.resetMain函数调用。
在测试你的协程时,你必须用TestDispatcher替换你的协程调度器。为了能够替换调度器,你的代码应该有一种方法来更改将用于协程的调度器。例如,这个MovieViewModel类有一个用于设置调度器的属性:
class MovieViewModel(private val dispatcher:
CoroutineDispatcher = Dispatchers.IO): ViewModel() {
...
fun fetchMovies() {
viewModelScope.launch(dispatcher) {
...
}
}
}
MovieViewModel使用其构造函数中指定的调度器或默认值(Dispatchers.IO)来启动协程。
在你的测试中,你可以为测试目的设置不同的Dispatcher。对于前面的ViewModel,你的测试可以像以下示例那样使用不同的调度器初始化ViewModel:
@ExperimentalCoroutinesApi
class MovieViewModelTest {
...
@Test
fun fetchMovies() {
...
runTest {
...
val viewModel =
MovieViewModel(UnconfinedTestDispatcher())
viewModel.fetchMovies()
...
}
}
}
在MovieViewModelTest的fetchMovies测试中,viewModel使用UnconfinedTestDispatcher作为协程调度器以进行测试初始化。
在前面的例子中,你使用了UnconfinedTestDispatcher作为测试的TestDispatcher。在kotlinx-coroutines-test库中,有两个TestDispatcher的实现可用:
-
StandardTestDispatcher: 不自动运行协程,让你完全控制执行顺序 -
UnconfinedTestDispatcher: 自动运行协程;不提供控制协程启动顺序的能力
StandardTestDispatcher和UnconfinedTestDispatcher都有构造函数属性:scheduler用于TestCoroutineScheduler,name用于识别调度器。如果你没有指定调度器,TestDispatcher将默认创建一个TestCoroutineScheduler。
StandardTestDispatcher的TestCoroutineScheduler控制协程的执行。TestCoroutineScheduler有三个你可以调用的函数来控制任务的执行:
-
runCurrent(): 运行直到当前虚拟时间已安排的任务 -
advanceUntilIdle(): 运行所有挂起的任务 -
advanceTimeBy(milliseconds): 运行挂起的任务,直到当前虚拟时间增加指定的毫秒数
TestCoroutineScheduler还有一个currentTime属性,它指定了当前虚拟时间(以毫秒为单位)。当你调用如advanceTimeBy这样的函数时,它将更新调度器的currentTime属性。
runTest协程构建器创建一个具有TestScope协程范围的协程。这个TestScope包含一个TestCoroutineScheduler(testScheduler),你可以使用它来控制任务的执行。
这个 testScheduler 还有一个名为 currentTime 的扩展属性和 runCurrent、advanceUntilIdle 以及 advanceTimeBy 的扩展函数,这简化了从 TestScope 的 testScheduler 调用这些函数。
使用 runTest 和 TestDispatcher 可以测试协程中存在时间延迟的情况,您想在执行下一行代码之前测试一行代码。例如,如果您的 ViewModel 有一个在网络操作之前设置为 true 的 loading 布尔变量,然后操作完成后重置为 false,您的 loading 变量的测试可能看起来像这样:
@Test
fun loading() {
val dispatcher = StandardTestDispatcher()
runTest() {
val viewModel = MovieViewModel(dispatcher)
viewModel.fetchMovies()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(false, viewModel.loading.value)
}
}
这个测试使用 StandardTestDispatcher,这样您就可以控制任务的执行。在调用 fetchMovies 后,您在调度器的 scheduler 上调用 advanceUntilIdle 来运行任务,任务完成后将 loading 值设置为 false。
在本节中,您学习了如何为您的协程添加测试。让我们通过向 Android 项目中现有的协程添加一些测试来测试我们到目前为止所学的内容。
练习 4.01 – 在 Android 应用中添加协程测试
对于这个练习,您将继续在 练习 2.01,在 Android 应用中使用协程 中工作的电影应用。您将通过以下步骤为项目中的协程添加单元测试:
-
打开您在 练习 2.01,在 Android 应用中使用协程 中工作的电影应用,在 Android Studio 中。
-
前往
app/build.gradle文件,添加以下依赖项,这些依赖项将用于单元测试:testImplementation ‘org.mockito.kotlin:mockito- kotlin:4.0.0’ testImplementation ‘androidx.arch.core:core- testing:2.1.0’ testImplementation ‘org.jetbrains.kotlinx:kotlinx- coroutines-test:1.6.0’
第一行将添加 Mockito-Core 和 Mockito-Kotlin,第二行将添加架构测试库,最后一行将添加 Kotlin 协程测试库。您将使用这些库来为 Android 项目添加的单元测试。
- 在
app/src/test/resources中创建一个mockito-extensions目录。在该目录中,创建一个名为org.mockito.plugins.MockMaker的新文件,如图 4.2 所示:
![图 4.2 – 需要添加到 app/src/test/mockito-extensions 目录的文件]
![img/Figure_4.2_B17773.jpg]
图 4.2 – 需要添加到 app/src/test/mockito-extensions 目录的文件
-
在
app/src/test/mockito-extensions/org.mockito.plugins.MockMaker文件中,添加以下内容:mock-maker-inline
这将允许您使用 Mockito 为代码中的最终类创建模拟对象。如果没有这个,您的测试将失败,并显示以下错误消息:
Mockito cannot mock/spy because : final class
-
您首先将为
MovieRepository类添加一个单元测试。在app/src/test中创建一个名为MovieRepositoryTest的测试类,并为此类添加@OptIn(ExperimentalCoroutinesApi::class)注解:@OptIn(ExperimentalCoroutinesApi::class) class MovieRepositoryTest { ... }
这将是 MovieRepository 的测试类。添加了 ExperimentalCoroutinesApi 的 OptIn 注解,因为 kotlinx-coroutines-test 库中的一些类仍然标记为实验性。
-
在
MovieRepositoryTest类内部,添加一个InstantTaskExecutorRule的 JUnit 测试规则:@get:Rule val rule = InstantTaskExecutorRule()
InstantTaskExecutorRule允许测试同步执行任务。这对于MovieRepository中的LiveData对象是必需的。
-
创建一个名为
fetchMovies的测试函数,以测试MovieRepository中的fetchMovies挂起函数成功检索电影列表:@Test fun fetchMovies() { ... }
这将是MovieRepository.fetchMovies的第一个测试:一个显示电影列表的成功场景。
-
在
MovieRepositoryTest类的fetchMovies函数中,添加以下代码以模拟MovieRepository和MovieService:@Test fun fetchMovies() { val movies = listOf(Movie(id = 3), Movie(id = 4)) val response = MoviesResponse(1, movies) val movieService: MovieService = mock { onBlocking { getMovies(anyString()) } doReturn response } val movieRepository = MovieRepository(movieService) }
这将模拟MovieService,使其在调用其getMovies函数时始终返回我们提供的movies列表。
-
在
MovieRepositoryTest的fetchMovies函数末尾添加以下代码,以测试从MovieRepository类调用fetchMovies返回我们期望返回的电影列表:@Test fun fetchMovies() { ... runTest { movieRepository.fetchMovies() val movieLiveData = movieRepository.movies assertEquals(movies, movieLiveData.value) } }
这将调用MovieRepository类中的fetchMovies函数,该函数将调用MovieService中的getMovies函数。我们正在检查它是否确实返回了我们之前在模拟的MovieService中设置的影片列表。
-
运行
MovieRepositoryTest类。MovieRepositoryTest应该通过,并且不应该有错误。 -
在
MovieRepositoryTest类中创建另一个名为fetchMoviesWithError的测试函数,以测试MovieRepository中的fetchMovies挂起函数在检索电影列表时失败:@Test fun fetchMoviesWithError() { ... }
这将测试MovieRepository在检索电影列表时失败的情况。
-
在
MovieRepositoryTest类的fetchMoviesWithError函数中,添加以下代码:@Test fun fetchMoviesWithError() { val exception = “Test Exception” val movieService: MovieService = mock { onBlocking { getMovies(anyString()) } doThrow RuntimeException(exception) } val movieRepository = MovieRepository(movieService) }
这将模拟MovieService,使其在调用其getMovies函数时始终抛出一个包含消息Test Exception的异常。
-
在
MovieRepositoryTest的fetchMoviesWithError函数末尾添加以下代码,以测试从MovieRepository类调用fetchMovies返回我们期望返回的电影列表:@Test fun fetchMovies() { ... runTest { movieRepository.fetchMovies() val movieLiveData = movieRepository.movies assertNull(movieLiveData.value) val errorLiveData = movieRepository.error assertNotNull(errorLiveData.value) assertTrue(errorLiveData.value.toString() .contains(exception)) } }
这将调用MovieRepository类中的fetchMovies函数,该函数将调用始终在调用时抛出异常的MovieService中的getMovies函数。
在第一个断言中,我们检查movieLiveData是否为 null,因为没有检索到电影。第二个断言检查errorLiveData是否不为 null,因为有异常。最后一个断言检查errorLiveData是否包含我们在上一步设置的Test Exception消息。
-
运行
MovieRepositoryTest测试。fetchMovies和fetchMoviesWithError测试都应该没有错误,并且都应该通过。 -
然后,我们将为
MovieViewModel创建一个测试。首先,我们需要更新MovieViewModel,以便我们可以更改协程运行的调度器。打开MovieViewModel类,并通过添加一个用于设置协程调度器的dispatcher属性来更新其构造函数:class MovieViewModel(private val movieRepository: MovieRepository, private val dispatcher: CoroutineDispatcher = Dispatchers.IO) : ViewModel() { ... }
这将允许您使用另一个dispatcher更改MovieViewModel的dispatcher,您将在测试中这样做。
-
在
fetchMovies函数中,将launch协程构建器更改为使用构造函数中的dispatcher而不是硬编码的dispatcher:viewModelScope.launch(dispatcher) { ... }
此更新将代码修改为使用从构造函数设置的dispatcher或默认的Dispatchers.IO。现在您可以创建一个针对MovieViewModel类的单元测试。
-
在
app/src/test目录中,为MovieViewModel创建一个名为MovieViewModelTest的测试类,并将@OptIn(ExperimentalCoroutinesApi::class)注解添加到类中:@OptIn(ExperimentalCoroutinesApi::class) class MovieViewModelTest { ... }
这将是MovieViewModel的测试类。添加了ExperimentalCoroutinesApi注解,因为kotlinx-coroutines-test库中的一些类仍然是实验性的。
-
在
MovieViewModelTest类内部,添加一个针对InstantTaskExecutorRule的 JUnit 测试规则:@get:Rule val rule = InstantTaskExecutorRule()
单元测试中的InstantTaskExecutorRule会同步执行任务。这是为了MovieViewModel中的LiveData对象。
-
创建一个名为
fetchMovies的测试函数,以测试MovieViewModel中的fetchMovies挂起函数:@Test fun fetchMovies() { val expectedMovies = MutableLiveData<List<Movie>>() expectedMovies.postValue(listOf(Movie (title = “Movie”))) val movieRepository: MovieRepository = mock { onBlocking { movies } doReturn expectedMovies } }
这将模拟MovieRepository,使其movies属性始终返回expectedMovies作为其值。
-
在
MovieViewModelTest的fetchMovies测试结束时,添加以下内容以测试MovieViewModel的movies将等于expectedMovies:@Test fun fetchMovies() { ... val movieViewModel = MovieViewModel(movieRepository) assertEquals(expectedMovies.value, movieViewModel.movies.value) }
这将使用模拟的MovieRepository创建一个MovieViewModel。我们正在检查MovieViewModel的movies值是否等于我们设置到模拟的MovieRepository中的expectedMovies值。
-
运行
MovieViewModelTest或所有测试(MovieRepositoryTest和MovieViewModelTest)。所有测试都应该通过。 -
在
MovieViewModelTest中创建另一个名为loading的测试函数,以测试MovieViewModel中的loadingLiveData:@Test fun loading() { ... }
这将测试MovieViewModel的loading LiveData属性。在获取电影时,加载属性为true并显示ProgressBar。在成功获取电影或遇到错误后,它变为false并隐藏ProgressBar。
-
在
loading测试函数中,添加以下内容以模拟MovieRepository并初始化一个将用于MovieViewModel的dispatcher:@Test fun loading() { val movieRepository: MovieRepository = mock() val dispatcher = StandardTestDispatcher() ... }
这将模拟MovieRepository并创建一个用于MovieViewModel测试的StandardTestDispatcher类型的dispatcher。这将允许您控制任务的执行,稍后用于检查MovieViewModel的loading属性值。
-
在
loading测试函数的末尾,添加以下内容以测试加载MovieViewModel的loading属性:@Test fun loading() { ... runTest { val movieViewModel = MovieViewModel(movieRepository, dispatcher) movieViewModel.fetchMovies() assertTrue( movieViewModel.loading.value == true) dispatcher.scheduler.advanceUntilIdle() assertFalse(movieViewModel.loading.value == true) } }
这将创建一个带有模拟的MovieRepository和dispatcher的MovieViewModel。然后,从MovieViewModel调用fetchMovies以获取电影列表。
第一个断言检查 loading 值是否为 true。然后我们使用调度器的 scheduler 中的 advanceUntilIdle 来执行所有任务。这应该将 loading 值更改为 false。最后一行检查这确实发生了。
- 运行
MovieRepositoryTest和MovieViewModelTest。所有测试都应该通过。
在这个练习中,你在一个使用协程的 Android 项目上工作,并为这些协程添加了单元测试。
摘要
本章重点介绍了在 Android 应用程序中测试协程。你从学习如何设置 Android 项目以准备添加协程测试开始。协程测试库(kotlinx-coroutines-test)帮助你为协程创建测试。
你学习了如何为你的挂起函数添加单元测试。你可以使用 runBlocking 和 runTest 来测试调用挂起函数的代码。runTest 立即运行代码,没有延迟。
然后,你学习了如何测试协程。你可以通过 TestDispatcher(StandardTestDispatcher 或 UnconfinedTestDispatcher)更改测试中的调度器。TestCoroutineScheduler 允许你控制协程任务的执行。
最后,你完成了一个练习,在该练习中,你为现有的 Android 项目中的协程添加了单元测试。
在下一章中,你将探索 Kotlin 流(Kotlin Flows)并学习如何使用它们在 Android 中进行异步编程。
进一步阅读
本书假设你已经具备测试 Android 应用程序的知识。如果你想了解更多关于 Android 测试的内容,你可以阅读《如何用 Kotlin 构建 Android 应用程序》(Packt Publishing,2021,ISBN 9781838984113)一书中第九章,“使用 JUnit、Mockito 和 Espresso 进行单元测试和集成测试”。你还可以查看 Android 测试文档,网址为 developer.android.com/training/testing。
截至撰写本文时,协程测试库仍然被标记为实验性。在库变得稳定之前,类中可能会有一些破坏代码的更改。你可以在 GitHub 上查看库的最新版本,网址为 github.com/Kotlin/kotlinx.coroutines/tree/master/kotlinx-coroutines-test,以获取关于协程测试库的最新信息。
第二部分 – Kotlin 流在 Android 上的应用
在本部分,我们将学习如何使用 Kotlin 流获取数据。我们还将讨论如何组合流、处理取消和异常,以及为它们创建测试。
本节包括以下章节:
-
第五章, 使用 Kotlin 流
-
第六章, 处理流取消和异常
-
第七章, 测试 Kotlin 流
第五章:第五章:使用 Kotlin Flows
在前三章中,我们深入探讨了 Kotlin 协程,并学习了如何在 Android 中使用它们进行异步编程。我们学习了协程构建器、作用域、调度器、上下文和作业。然后我们学习了如何处理协程取消、超时和异常。我们还学习了如何在代码中对协程进行测试。
在接下来的三章中,我们将重点关注 Kotlin Flow,这是一个基于 Kotlin 协程构建的新异步流库。Flow 可以在一段时间内发出多个值,而不仅仅是单个值。你可以使用 Flows 来处理数据流,例如实时位置、传感器读数和实时数据库值。
在本章中,我们将探索 Kotlin Flows。我们将从构建 Kotlin Flows 开始。然后,我们将探讨你可以用于转换、组合、缓冲以及更多 Flows 的各种操作符。最后,我们将了解 StateFlows 和 SharedFlows。
本章涵盖了以下主要主题:
-
在 Android 中使用 Flows
-
使用 Flow 构建器创建 Flows
-
使用 Flows 的操作符
-
缓冲和组合 Flows
-
探索 StateFlow 和 SharedFlow
到本章结束时,你将更深入地了解使用 Kotlin Flows。你将能够在你 Android 应用程序的各种情况下使用 Flows。你还将了解流构建器、操作符、组合 Flows、StateFlow 和 SharedFlow。
技术要求
你需要下载并安装 Android Studio 的最新版本。你可以在 developer.android.com/studio 找到最新版本。为了获得最佳学习体验,建议使用以下配置的计算机:
-
英特尔酷睿 i5 或更高性能的处理器
-
至少 4 GB RAM
-
4 GB 可用空间
本章的代码示例可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows/tree/main/Chapter05。
在 Android 中使用 Flows
在本节中,我们将首先使用 Android 中的 Flows 进行异步编程。Flows 对于涉及实时数据更新的应用程序部分来说非常理想。
数据流由 Flow<String> 表示,这是一个发出字符串值的 Flow。
Android Jetpack 库,如 Room、Paging、DataStore、WorkManager 和 Jetpack Compose,都内置了对 Flow 的支持。
Room 数据库库从 2.2 版本开始支持 Flows。这允许你通过使用 Flows 来通知数据库值的变化。
如果你的 Android 应用程序使用 数据访问对象(DAO)来显示电影列表,你的项目可以有一个如下所示的 DAO:
@Dao
interface MovieDao {
@Query("SELECT * FROM movies")
fun getMovies(): List<Movie>
...
}
通过从 MovieDao 调用 getMovies 函数,你可以从数据库中获取电影列表。
上述代码在调用getMovies后只会获取一次电影列表。你可能希望你的应用程序在数据库中的电影被添加、删除或更新时自动更新电影列表。你可以通过使用 Room-KTX 并将你的MovieDao更改为使用 Flow 来getMovies来实现这一点:
@Dao
interface MovieDao {
@Query("SELECT * FROM movies")
fun getMovies(): Flow<List<Movie>>
...
}
使用此代码,每当movies表发生变化时,getMovies将发出一个包含数据库中电影列表的新列表。然后你的应用程序可以使用它来自动更新列表或RecyclerView中显示的电影。
如果你使用LiveData并想将LiveData转换为Flow或Flow转换为LiveData,你可以使用 LiveData KTX。
要将LiveData转换为Flow,你可以使用LiveData.asFlow()扩展函数。使用Flow.asLiveData()扩展函数将Flow转换为LiveData。你可以通过在你的app/build.gradle依赖项中包含以下内容将 LiveData KTX 添加到你的项目中:
dependencies {
...
implementation ‘androidx.lifecycle:lifecycle-livedata-
ktx:2.2.0'
}
这将 LiveData KTX 添加到你的项目中,允许你使用asFlow()和asLiveData()扩展函数将LiveData转换为Flow和Flow转换为LiveData。
第三方 Android 库现在也支持 Flows;一些函数可以返回 Flow 对象。如果你在项目中使用 RxJava 3,你可以使用Flow到Flowable或Observable,反之亦然。
当你调用collect函数时,Flow 才会开始发出值。collect函数是一个挂起函数,所以你应该从协程或另一个挂起函数中调用它。
在下面的例子中,collect()函数是从使用lifecycleScope中的launch协程构建器创建的协程中调用的:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.fetchMovies().collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
class MovieViewModel : ViewModel() {
...
fun fetchMovies(): Flow<Movie> {
...
}
}
在这个例子中,collect{}函数被调用在Flow<Movie>上,并通过调用viewModel.fetchMovies()返回。这将导致 Flow 开始发出值,然后你可以处理每个值。
流的收集发生在父协程的CoroutineContext中。在上面的例子中,协程上下文来自viewModelScope。
要更改 Flow 运行的CoroutineContext,你可以使用flowOn()函数。如果你想将前一个例子中的 Flow 的Dispatcher更改为Dispatchers.IO,你可以使用flowOn(Dispatchers.IO),如下面的例子所示:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.fetchMovies()
.flowOn(Dispatchers.IO)
.collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
在这里,在收集 Flow 之前,通过调用flowOn并使用Dispatchers.IO,将 Flow 运行的调度器更改为Dispatchers.IO。
当你调用flowOn时,它只会改变调用之前的函数或操作符,而不会改变调用之后的。在下面的例子中,在调用flowOn之后调用了map操作符来更改分发器,因此其上下文不会改变:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.fetchMovies()
.flowOn(Dispatchers.IO)
.map { ... }
.collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
在这个例子中,flowOn只会改变调用之前的上下文,所以map调用不会改变。它仍然会使用原始上下文(即来自lifecycleScope的上下文)。
在 Android 中,你可以在 Fragment 或 Activity 类中收集 Flow 以在 UI 中显示数据。如果 UI 进入后台,你的 Flow 将继续收集数据。你的应用必须停止收集 Flow 并更新屏幕,以防止内存泄漏和避免资源浪费。
为了在 Android UI 层面上安全地收集 Flows,你需要自己处理生命周期变化。你也可以使用 Lifecycle.repeatOnLifecycle 和 Flow.flowWithLifecycle,这些在 app/build.gradle 依赖中可用:
dependencies {
...
implementation ‘androidx.lifecycle:lifecycle-runtime-
ktx:2.4.1
}
这添加了 Lifecycle.repeatOnLifecycle 和 Flow.flowWithLifecycle。
Lifecycle.repeatOnLifecycle(state, block) 将父协程挂起,直到生命周期被销毁,并在生命周期至少处于你设置的 state 时执行挂起的 block 代码。当生命周期离开该状态时,repeatOnLifecycle 将停止 Flow 并在生命周期回到该状态时重新启动。
如果你使用了 repeatOnLifecycle,它将在生命周期启动时开始收集 Flow。它将在生命周期停止、调用生命周期的 onStop() 时停止。
当你使用 repeatOnLifecycle 时,它将在每次生命周期恢复时开始收集 Flow,并在生命周期暂停或调用 onPause() 时停止。
建议在活动的 onCreate 方法或片段的 onViewCreated 方法中调用 Lifecycle.repeatOnLifecycle。
以下展示了如何在你的 Android 项目中使用 Lifecycle.repeatOnLifecycle:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.fetchMovies()
.collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
}
这里,我们使用了 repeatOnLifecycle 与 Lifecycle.State.STARTED 来在生命周期启动时开始收集电影流,并在生命周期停止时停止。
你可以使用 Lifecycle.repeatOnLifecycle 收集多个 Flow。为此,你必须在不同协程中并行收集它们:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.fetchMovies().collect { movie ->
Log.d("movies", "${movie.title}")
}
}
launch {
viewModel.fetchTVShows.collect { show ->
Log.d("tv shows", "${show.title}")
}
}
}
}
}
}
这里有两个 Flows:一个用于收集电影,另一个用于收集电视剧。Flow 的收集是从不同的 launch 协程构建器开始的。
如果你只有一个 Flow 要收集,你也可以使用 Flow.flowWithLifecycle。当生命周期至少处于 Lifecycle.repeatOnLifecycle 内部时,它会从上游 Flow(调用之前的 Flow 和操作符)发出值。你可以像以下代码所示使用 Flow.flowWithLifecycle:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.fetchMovies()
.flowWithLifecycle(lifecycle,
Lifecycle.State.STARTED)
.collect { movie ->
Log.d("movies", "${movie.title}")
}
}
}
}
在这个示例中,你使用了 flowWithLifecycle 与 Lifecycle.State.STARTED 来在生命周期启动时开始收集电影流,并在生命周期停止时停止。
在本节中,你学习了如何在你的 Android 应用中使用 Kotlin Flows。你可以在 Room 等 Android Jetpack 库以及第三方库中使用 Flow。为了在 UI 层面上安全地收集 Flows 并防止内存泄漏以及避免资源浪费,你可以使用 Lifecycle.repeatOnLifecycle 和 Flow.flowWithLifecycle。
在下一节中,我们将探讨你可以用于为你的应用程序创建 Flows 的不同 Flow 构建器。
使用 Flow 构建器创建 Flows
在本节中,我们将首先探讨创建 Flows。要创建一个 Flow,你可以使用 Flow 构建器。
Kotlin Flow API 有你可以用来创建 Flows 的流构建器。以下是你可以使用 Kotlin Flow 构建器:
-
flow {} -
flowOf() -
asFlow()
flow 构建器函数从一个可挂起 lambda 块创建一个新的 Flow。在块内部,你可以使用 emit 函数发送值。例如,这个 MovieViewModel 的 fetchMovieTitles 函数返回 Flow<String>:
class MovieViewModel : ViewModel() {
...
fun fetchMovieTitles(): Flow<String> = flow {
val movies = fetchMoviesFromNetwork()
movies.forEach { movie ->
emit(movie.title)
}
}
private fun fetchMoviesFromNetwork(): List<Movie> {
…
}
}
在这个示例中,fetchMovieTitles 创建了一个包含电影标题的 Flow。它遍历了从 fetchMoviesFromNetwork 获取的电影列表,并为每部电影使用 emit 函数发出电影的标题。
使用 flowOf 函数,你可以创建一个生成指定值或 vararg 值的 Flow。在下面的示例中,flowOf 函数被用来创建一个包含前三部电影标题的 Flow:
class MovieViewModel : ViewModel() {
...
fun fetchTop3Titles(): Flow<List<Movie>> {
val movies = fetchMoviesFromNetwork().sortedBy {
it.popularity }
return flowOf(movies[0].title,
movies[1].title,
movies[2].title)
}
private fun fetchMoviesFromNetwork(): List<Movie> {
…
}
}
在这里,fetchTop3Titles 使用 flowOf 创建了一个包含前三部电影标题的 Flow。
asFlow() 扩展函数允许你将类型转换为 Flow。你可以在序列、数组、范围、集合和函数类型上使用它。例如,这个 MovieViewModel 有 fetchMovieIds 返回 Flow<Int>,包含电影 ID:
class MovieViewModel : ViewModel() {
...
private fun fetchMovieIds(): Flow<Int> {
val movies: List<Movie> = fetchMoviesFromNetwork()
return movies.map { it.id }.asFlow()
}
private fun fetchMoviesFromNetwork(): List<Movie> {
…
}
}
在这个示例中,我们在电影列表上使用了一个 map 函数来创建一个电影 ID 的列表。然后,使用 asFlow() 扩展函数将该电影 ID 列表转换为 Flow<String>。
在本节中,我们学习了如何使用 Flow 构建器创建 Flows。在下一节中,我们将检查你可以用于转换、组合和更多操作 Flows 的各种 Kotlin Flow 操作符。
使用操作符与 Flows 一起使用
在本节中,我们将重点关注各种 Flow 操作符。Kotlin Flow 有内置的操作符,你可以与 Flows 一起使用。我们可以使用终端操作符收集 Flows,并使用中间操作符转换 Flows。
使用终端操作符收集 Flows
在本节中,我们将探讨你可以用于在 Flows 上启动收集的终端操作符。我们在前面的示例中使用的 collect 函数是最常用的终端操作符。然而,还有其他内置的终端 Flow 操作符。
以下是你可以使用以启动 Flow 收集的内置终端 Flow 操作符:
-
toList:收集 Flow 并将其转换为列表 -
toSet:收集 Flow 并将其转换为集合 -
toCollection:收集 Flow 并将其转换为集合 -
count:返回 Flow 中的元素数量 -
first:返回 Flow 的第一个元素,如果 Flow 为空则抛出 NoSuchElementException -
firstOrNull:返回 Flow 的第一个元素,如果 Flow 为空则返回 null -
last: 返回流的最后一个元素,如果流为空则抛出NoSuchElementException -
lastOrNull: 返回流的最后一个元素,如果流为空则返回 null -
single: 返回流中发出的单个元素,如果流为空或包含多个值则抛出异常 -
singleOrNull: 返回流中发出的单个元素,如果流为空或包含多个值则返回 null -
reduce: 对每个发出的项目应用一个函数,从第一个元素开始,并返回累积的结果 -
fold: 对每个发出的项目应用一个函数,从初始值开始,并返回累积的结果
这些终端流操作符与标准 Kotlin 库中具有相同名称的 Kotlin 集合函数类似。
在以下示例中,使用firstOrNull终端操作符代替collect操作符来从ViewModel收集流:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
val topMovie =
viewModel.fetchMovies().firstOrNull()
displayMovie(topMovie)
}
}
}
}
在这里,firstOrNull被用于流以获取第一个项目(如果流为空则为 null),这代表顶级电影。然后它将在屏幕上显示。
在本节中,你了解了可以使用哪些流的终端操作符来开始从流中收集数据。在下一节中,我们将学习如何使用中间操作符转换流。
使用中间操作符转换流
在本节中,我们将关注你可以用来转换流的中间流操作符。使用中间操作符,你可以根据原始流返回一个新的流。
中间操作符允许你修改一个流并返回一个新的流。你可以链接各种操作符,并且它们将按顺序应用。
你可以通过对它们应用操作符来转换流,就像你可以对 Kotlin 集合做的那样。以下中间操作符与具有相同名称的 Kotlin 集合函数类似:
-
filter: 返回一个流,仅选择满足你传递的条件的流中的值 -
filterNot: 返回一个流,仅选择不满足你传递的条件的流中的值 -
filterNotNull: 返回一个流,仅包含来自原始流且不为 null 的值 -
filterIsInstance: 返回一个流,仅包含流中指定类型的实例 -
map: 返回一个流,包含使用你指定的操作转换的流中的值 -
mapNotNull: 类似于map(使用指定的操作转换流),但仅包括非 null 的值 -
withIndex: 返回一个流,将每个值转换为包含值的索引和值本身的IndexedValue -
onEach: 返回一个流,在发出每个值之前执行指定的操作 -
runningReduce: 返回一个包含从第一个元素开始按顺序运行操作所得到的累积值的流 -
runningFold: 返回一个包含从指定操作按顺序运行并从设置的初始值开始的累积值的 Flow -
scan: 类似于runningFold操作符
还有一个 transform 操作符,您可以使用它来应用自定义或复杂的操作。使用 transform 操作符,您可以通过调用带有要发送值的 emit 函数将值发射到新的 Flow 中。
例如,这个 MovieViewModel 有一个 fetchTopMovieTitles 函数,它使用 transform 返回包含顶级电影的 Flow:
class MovieViewModel : ViewModel() {
...
fun fetchTopMovies(): Flow<Movie> {
return fetchMoviesFlow()
.transform {
if (it.popularity > 0.5f) emit(it)
}
}
}
在本例中,在电影的 Flow 中使用了 transform 操作符来返回一个新的 Flow。transform 操作符用于只发射流行度高于 0.5 的电影列表,这意味着超过 50% 的流行度。
您还可以使用大小限制操作符与 Flow 一起使用。以下是一些这些操作符:
-
drop(x): 返回一个忽略前 x 个元素的 Flow -
dropWhile: 返回一个忽略满足指定条件的第一个元素的 Flow -
take(x): 返回包含 Flow 的前 x 个元素的 Flow -
takeWhile: 返回一个包含满足指定条件的第一个元素的 Flow
这些大小限制操作符的功能与 Kotlin 收集函数同名操作符类似。
在本节中,我们学习了中间流操作符。中间操作符将 Flow 转换为新的 Flow。在下一节中,我们将学习如何缓冲和组合 Kotlin 流。
缓冲和组合流
在本节中,我们将学习关于 Kotlin 流的缓冲和组合。您可以使用 Flow 操作符缓冲和组合 Flow。缓冲允许具有长时间运行任务的 Flow 独立运行并避免竞争条件。组合允许在处理或显示在屏幕上之前将不同的 Flow 源连接起来。
缓冲 Kotlin 流
在本节中,我们将学习关于 Kotlin 流的缓冲。缓冲允许您并行运行数据发射。
使用 Flow 运行时,数据发射和收集是顺序进行的。当发射新值时,它将被收集。只有当之前的数据被收集后,才能发射新值。如果从 Flow 发射或收集数据需要较长时间,整个过程将花费更长的时间。
使用缓冲,可以使 Flow 的数据发射和收集并行运行。您可以使用以下三个操作符来缓冲 Flow:
-
buffer -
conflate -
collectLatest
buffer() 允许 Flow 在数据仍在收集时发射值。发射和收集数据在单独的协程中运行,因此它是并行的。以下是如何使用 buffer 与 Flow 的示例:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.fetchMovies()
.buffer()
.collect { movie ->
processMovie(movie)
}
}
}
}
}
在这里,在调用 collect 之前添加了 buffer 操作符。如果收集中的 processMovie(movie) 函数执行时间较长,Flow 将在收集和处理之前发射并缓冲这些值。
conflate() 与 buffer() 操作符类似,但 conflate 中,收集器将只处理在处理完上一个值之后的最新发出的值。它将忽略之前发出的其他值。以下是在 Flows 中使用 conflate 的示例:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getTopMovie()
.conflate()
.collect { movie ->
processMovie(movie)
}
}
}
}
}
在这个例子中,添加 conflate 操作符将允许我们只处理 Flows 的最新值,并使用该值调用 processMovie。
collectLatest(action) 是一个终端操作符,它将以与 collect 相同的方式收集 Flows,但每当发出新值时,它将重新启动操作并使用这个新值。以下是在 Flows 中使用 collectLatest 的示例:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getTopMovie()
.collectLatest { movie ->
displayMovie(movie)
}
}
}
}
}
在这里,collectLatest 被用来代替 collect 终端操作符来收集来自 viewModel.getTopMovie() 的 Flows。每当这个 Flows 发出新值时,它将重新启动并使用新值调用 displayMovie。
在本节中,你学习了如何使用 buffer、conflate 和 collectLatest 缓冲 Kotlin Flows。在下一节中,你将学习如何将多个 Flows 合并成一个单一的 Flows。
合并 Flows
在本节中,我们将学习如何合并 Flows。Kotlin Flow API 提供了可用于合并多个 Flows 的操作符。
如果你有多条 Flows 并且想要将它们合并成一个,你可以使用以下 Flows 操作符:
-
zip -
merge -
combine
merge 是一个顶级函数,它将来自多个相同类型的 Flows 的元素合并成一个。你可以传递任意数量的 Flows 来进行合并。当你有两个或更多数据源需要先合并后再收集时,这非常有用。
在以下示例中,有两个 Flows 来自 viewModel.fetchMoviesFromDb 和 viewModel.fetchMoviesFromNetwork,它们使用 merge 进行合并:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
merge(viewModel.fetchMoviesFromDb(),
viewModel.fetchMoviesFromNetwork())
.collect { movie ->
processMovie(movie)
}
}
}
}
}
在这个例子中,在收集之前,使用了 merge 来合并来自 viewModel.fetchMoviesFromDb 和 viewModel.fetchMoviesFromNetwork 的 Flows。
zip 操作符使用你指定的函数将第一个 Flows 的数据与第二个 Flows 的数据配对成一个新的值。如果一个 Flows 的值比另一个少,zip 将在处理完这个 Flows 的所有值后结束。
以下展示了如何使用 zip 操作符合并两个 Flows,userFlow 和 taskFlow:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
val userFlow = viewModel.getUsers()
val taskFlow = viewModel.getTasks()
userFlow.zip(taskFlow) { user, task ->
AssignedTask(user, task)
}.collect { assignedTask ->
displayAssignedTask(assignedTask)
}
}
}
}
}
在这个例子中,你使用了 zip 来将 userFlow 的每个值与 taskFlow 配对,并使用 user 和 task 的值返回一个 AssignedTask 的 Flows。这个新的 Flows 将被收集,然后使用 displayAssignedTask 函数显示。
combine 将第一个 Flows 的数据与第二个 Flows 的数据配对,就像 zip 一样,但使用每个 Flows 发出的最新值。只要 Flows 发出一个值,它就会继续运行。还有一个用于多个 Flows 的顶级 combine 函数。
以下示例展示了如何使用 combine 操作符在你的应用程序中将两个 Flows 连接起来:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
val yourMesssage =
viewModel.getLastMessageSent()
val friendMessage =
viewModel.getLastMessageReceived()
userFlow.combine(taskFlow) { yourMesssage,
friendMessage ->
Conversation(yourMessage,
friendMessage)
}.collect { conversation ->
displayConversation(conversation)
}
}
}
}
}
在这里,你有两个流,yourMessage 和 friendMessage。combine 函数将 yourMessage 和 friendMessage 的最新值配对,以创建一个 Conversation 对象。每当任一流发出新值时,combine 将配对最新值并将其添加到结果流中以进行收集。
在本节中,我们探讨了如何组合流。在下一节中,我们将重点关注 StateFlow 和 SharedFlow 以及如何在您的 Android 应用程序中使用它们。
探索 StateFlow 和 SharedFlow
在本节中,我们将深入了解 StateFlow 和 SharedFlow。SharedFlow 和 StateFlow 是热流,与默认为冷流的普通 Kotlin 流不同。
流是一个冷数据流。流仅在值被收集时发出值。使用 SharedFlow 和 StateFlow 热流,您可以在调用它们时立即运行和发出值,甚至在它们没有监听器时也是如此。SharedFlow 和 StateFlow 是流,因此您也可以在它们上使用操作符。
SharedFlow 允许您向多个监听器发出值。SharedFlow 可以用于一次性事件。SharedFlow 将执行的任务将只运行一次,并由监听器共享。
您可以使用 MutableSharedFlow 然后使用 emit 函数将值发送到所有收集器。
在以下示例中,SharedFlow 用于 MovieViewModel 中获取的电影列表:
class MovieViewModel : ViewModel() {
private val _message = MutableSharedFlow<String>()
val movies: SharedFlow<String> =
_message.asSharedFlow()
...
fun onError(): Flow<List<Movie>> {
...
_message.emit("An error was encountered")
}
}
在此示例中,我们使用了 SharedFlow 来处理消息。我们使用 emit 函数将错误消息发送到流的监听器。
StateFlow 是 SharedFlow,但它只向其监听器发出最新值。StateFlow 使用一个值(初始状态)初始化并保持此状态。您可以使用 StateFlow 的可变版本 MutableStateFlow 来更改 StateFlow 的值。更新值会将新值发送到流。
在 Android 中,StateFlow 可以作为 LiveData 的替代方案。您可以为 ViewModel 使用 StateFlow,然后您的活动或片段可以收集值。例如,在以下 ViewModel 中,使用了 StateFlow 来表示电影列表:
class MovieViewModel : ViewModel() {
private val _movies =
MutableStateFlow(emptyList<Movie>())
val movies: StateFlow<List<Movie>> = _movies
...
fun fetchMovies(): Flow<List<Movie>> {
...
_movies.value = movieRepository.fetchMovies()
}
}
在前面的代码中,从仓库获取的电影列表将被设置为 _movies 的 MutableStateFlow,这也会改变 movies 的 StateFlow。然后您可以在活动或片段中收集 movies 的 StateFlow,如下所示:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.movies.collect { movies ->
displayMovies(movies)
}
}
}
}
}
在这里,viewModel.movies 的 StateFlow 将被收集,然后使用 displayMovies 函数将电影列表显示在屏幕上。
在本节中,我们学习了 StateFlow 和 SharedFlow 以及我们如何在 Android 项目中使用它们。
让我们尝试到目前为止所学的内容,通过将 Kotlin Flow 添加到 Android 项目中。
练习 5.01 – 在 Android 应用中使用 Kotlin Flow
对于这个练习,你将继续在Exercise 4.01 – 在 Android 应用程序中添加协程测试中工作的电影应用程序。这个应用程序显示当前正在电影院上映的电影。你将通过以下步骤将 Kotlin Flow 添加到项目中:
-
打开你在Exercise 4.01 – 在 Android 应用程序中添加协程测试中工作的电影应用程序。在 Android Studio 中。
-
前往
MovieRepository类,添加一个新的fetchMoviesFlow()函数,使用flow构建器返回一个 Flow,并发出来自MovieService的电影列表,如下面的代码片段所示:fun fetchMoviesFlow(): Flow<List<Movie>> { return flow { emit(movieService.getMovies(apiKey).results) }.flowOn(Dispatchers.IO) }
这与fetchMovies()函数相同,但这个函数使用 Kotlin Flow,并将返回Flow<List<Movie>>给将收集它的函数或类。Flow 将从movieService.getMovies发出电影列表,并在Dispatchers.IO调度器上流动。
-
打开
MovieViewModel类,将获取自movieRepository的moviesLiveData的初始化替换为以下行:private val _movies = MutableStateFlow(emptyList<Movie>()) val movies: StateFlow<List<Movie>> = _movies
这将允许你使用_movies MutableStateFlow的值作为movies StateFlow的值,你将在从movieRepository的 Flow 中获取电影列表后稍后更改它。
-
对
errorLiveData做同样的处理,并用以下行替换其初始化,从movieRepository获取的值:private val _error = MutableStateFlow("") val error: StateFlow<String> = _error
这将使用_error MutableStateFlow的值来设置error StateFlow。你将在稍后能够更改这个StateFlow的值,以处理 Flow 遇到异常的情况。
-
用以下行替换
loading和_loading变量:private val _loading = MutableStateFlow(true) val loading: StateFlow<String> = _loading
这将使用_loading MutableStateFlow的值来设置loading StateFlow。你将在稍后更新这个值以指示电影加载正在进行。
-
删除
fetchMovies()函数及其内容。你将在下一步替换它。 -
添加一个新的
fetchMovies()函数,它将收集来自movieRepository.fetchMoviesFlow的 Flow,如下面的代码块所示:fun fetchMovies() { _loading.value = true viewModelScope.launch (dispatcher) { MovieRepository.fetchMoviesFlow() .collect { _movies.value = it _loading.value = false } } }
这将收集来自movieRepository.fetchMoviesFlow的电影列表,并将其设置为_movies MutableStateFlow和movies StateFlow。然后,这个电影列表将在MainActivity中显示。
-
打开
app/build.gradle文件。在依赖项中添加以下行:implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
这将允许我们在MainActivity稍后使用lifecycleScope收集 flows。
-
打开
MainActivity,删除观察movies、error和loadingLiveData的代码行。用以下代码替换它们:lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { movieViewModel.movies.collect { movies -> movieAdapter.addMovies(movies) } } launch { movieViewModel.error.collect { error -> if (error.isNotEmpty()) Snackbar.make(recyclerView, error, Snackbar.LENGTH_LONG).show() } } launch { movieViewModel.loading.collect { loading -> progressBar.isVisible = loading } } } }
这将收集movies并将它们添加到列表中,收集error并在error不为空时显示SnackBar消息,并收集loading并根据其值更新progressBar。
- 运行应用程序。应用程序应该仍然显示电影列表(带有海报和标题),如下面的截图所示:

图 5.1 – 包含电影列表的电影应用
在这个练习中,我们通过创建一个返回电影列表作为 Flow 的 MovieRepository 函数,将 Kotlin Flow 添加到 Android 应用中。然后,这个 Flow 被收集到 MovieViewModel 中。
摘要
本章重点介绍了在 Android 中使用 Kotlin Flows 进行异步编程。Flows 是建立在 Kotlin 协程之上的。一个 Flow 可以按顺序发出多个值,而不仅仅是单个值。
我们从学习如何在您的 Android 应用中使用 Kotlin Flows 开始。Jetpack 库如 Room 和一些第三方库支持 Flow。为了在 UI 层安全地收集 Flows、防止内存泄漏和避免资源浪费,您可以使用 Lifecycle.repeatOnLifecycle 和 Flow.flowWithLifecycle。
我们随后转向使用 Flow 构建器创建 Flows。flowOf 函数创建一个发出您提供的值或 vararg 值的 Flow。您可以使用 asFlow() 扩展函数将集合和函数类型转换为 Flow。flow 构建器函数从挂起 lambda 块中创建一个新的 Flow,在其中您可以使用 emit() 发送值。
然后,我们探讨了 Flow 操作符,并学习了如何与 Kotlin Flows 一起使用它们。使用终端操作符,您可以开始 Flow 的收集。中间操作符允许您将一个 Flow 转换为另一个 Flow。
我们随后学习了缓冲和组合 Flows。使用 buffer、conflate 和 collectLatest 操作符,您可以缓冲 Flows。您可以使用 merge、zip 和 combine Flow 操作符组合 Flows。
我们随后探讨了 SharedFlow 和 StateFlow。这些可以在您的 Android 项目中使用。使用 SharedFlow,您可以向多个监听器发出值。StateFlow 是只向其监听器发出最新值的 SharedFlow。
最后,我们进行了一个练习,将 Kotlin Flows 添加到 Android 应用程序中。我们在 MovieRepository 中使用了一个 Flow,然后它在 MovieViewModel 中被收集。
在下一章中,我们将关注如何在您的应用程序中处理 Kotlin Flows 的取消和异常。
第六章:第六章:处理 Flow 取消和异常
在上一章中,我们专注于 Kotlin Flows,并学习了如何在我们的 Android 项目中使用它们。我们学习了如何使用 Flow builders 创建 Kotlin Flows。然后,我们探讨了 Flow operators 以及如何与 Kotlin Flows 一起使用它们。接着,我们学习了缓冲和组合 Flows。最后,我们探讨了 SharedFlow 和 StateFlow。
Flows 可以被取消,也可能失败或遇到异常。开发者必须能够正确处理这些问题,以防止应用程序崩溃,并通过对话框或吐司消息通知用户。我们将在本章中讨论如何完成这些任务。
在本章中,我们将首先理解 Flow 取消。我们将学习如何取消 Flows 并处理 Flows 的取消。然后,我们将学习如何管理 Flows 中可能发生的失败和异常。我们还将学习关于重试和处理 Flow 完成的情况。
在本章中,我们将涵盖以下主要主题:
-
取消 Kotlin Flows
-
使用 Flow 重试任务
-
在 Flows 中捕获异常
-
处理流完成
到本章结束时,您将了解如何取消 Flows,并学习如何管理取消以及如何在 Flows 中处理异常。
技术要求
您需要下载并安装最新版本的 Android Studio。您可以在developer.android.com/studio找到最新版本。为了获得最佳学习体验,建议使用以下配置的计算机:
-
英特尔酷睿 i5 或等效或更高配置
-
至少 4 GB RAM
-
至少 4 GB 可用空间
本章的代码示例可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows/tree/main/Chapter06
取消 Kotlin Flows
在本节中,我们将首先探讨 Kotlin Flow 的取消。与协程一样,Flows 也可以手动或自动取消。
在第三章,“处理协程取消和异常”中,我们学习了如何取消协程,以及协程取消必须是合作的。由于 Kotlin Flows 是建立在协程之上的,Flow 遵循协程的协作取消。
使用 flow{} 构建器创建的 Flows 默认可取消。每次向 Flow 发送新值的 emit 调用也会内部调用 ensureActive。这会检查协程是否仍然处于活动状态,如果不是,它将抛出 CancellationException。
例如,我们可以使用 flow{} 构建器创建一个可取消的 Flow,如下所示:
class MovieViewModel : ViewModel() {
...
fun fetchMovies(): Flow<Movie> = flow {
movieRepository.fetchMovies().forEach {
emit(it)
}
}
}
在这里的 fetchMovies 函数中,我们使用了 flow 构建器来创建由 movieRepository.fetchMovies 返回的电影 Flows。这个 Flow<Movie> 默认将是一个可取消的 Flow。
所有其他 Flow,如使用asFlow和flowOf Flow 构建器创建的 Flow,默认情况下不可取消。我们必须自己处理取消。有一个cancellable()操作符可以用于 Flow,使其可取消。这将添加一个ensureActive调用到每个新值的发射。
以下示例展示了我们如何使用cancellable Flow 操作符使 Flow 可取消:
class MovieViewModel : ViewModel() {
...
fun fetchMovies(): Flow<Movie> {
return movieRepository.fetchMovies().cancellable()
}
}
在这个示例中,我们使用了movieRepository.fetchMovies()返回的 Flow 上的可取消操作符,使结果 Flow 可取消。
在本节中,我们学习了如何取消 Kotlin Flows 以及如何确保你的 Flows 可取消。在下一节中,我们将关注如何使用 Kotlin Flows 重试你的任务。
使用 Flow 重试任务
在本节中,我们将探讨 Kotlin Flow 的重试。在某些情况下,重试操作对于你的应用程序是必要的。
在执行长时间运行的任务,如网络调用时,有时需要再次尝试调用。这包括登录/注销、发布数据,甚至获取数据的情况。用户可能处于网络连接低下的区域,或者可能存在其他导致调用失败的因素。使用 Kotlin Flows,我们有retry和retryWhen操作符,可以用来自动重试 Flows。
retry操作符允许你设置retries作为 Flow 重试的最大次数。你也可以设置一个predicate条件,一个当返回true时将重试 Flow 的代码块。predicate有一个Throwable参数,代表发生的异常;你可以使用它来检查是否需要进行重试。
以下示例展示了我们如何使用retry Flow 操作符来重试我们的 Flow 中的任务:
class MovieViewModel : ViewModel() {
...
fun favoriteMovie(id: Int) =
movieRepository.favoriteMovie(id)
.retry(3) { cause -> cause is IOException }
}
在这里,当遇到IOException异常时,movieRepository.favoriteMovie(id)的 Flow 将会重试最多三次。
如果你没有为重试传递值,将使用默认值Long.MAX_VALUE。当未提供predicate时,默认值为true,这意味着如果尚未达到retries,Flow 将始终重试。
retryWhen操作符类似于retry操作符。我们需要指定predicate,这是条件,只有当true时才会执行重试。predicate有true,将重试 Flow。以下代码展示了使用retryWhen在 Flow 中重试任务的示例:
class MovieViewModel : ViewModel() {
...
fun favoriteMovie(id: Int) =
movieRepository.favoriteMovie(id)
.retryWhen { cause, attempt -> attempt <3 &&
cause is IOException }
}
在这个示例中,我们使用了retryWhen并指定了当attempt的值小于三且异常为IOException时进行重试。
使用retryWhen操作符,我们还可以向 Flow(使用emit函数)发出一个值,我们可以使用它来表示重试尝试或一个值。然后我们可以显示这个值或在屏幕上处理它。以下示例展示了我们如何使用emit与retryWhen操作符:
class MovieViewModel : ViewModel() {
...
fun getTopMovieTitle(): Flow<String> {
return movieRepository.getTopMovieTitle(id)
.retryWhen { cause, attempt ->
emit("Fetching title again...")
attempt <3 && cause is IOException
}
...
}
在这里,当尝试次数少于三次时,如果异常是活动或片段可以处理的Fetching title again字符串,那么 Flow 的任务将会重试。
在本节中,你学习了如何使用 Kotlin Flow 重试网络请求等任务。在下一节中,我们将探讨 Kotlin Flow 异常以及如何更新我们的代码来捕获这些异常。
流中的异常捕获
当你的代码中的 Flow 被取消或收集值时,可能会遇到CancellationException或其他异常。在本节中,我们将学习如何处理这些 Kotlin Flow 异常。
在收集值或使用 Flow 上的任何操作符时,Flows 可能会发生异常。我们可以通过在代码中将 Flow 的收集用try-catch块包围来处理 Flows 中的异常。例如,在以下代码中,try-catch块被用来添加异常处理:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
try {
viewModel.fetchMovies().collect { movie ->
processMovie(movie)
}
} catch (exception: Exception) {
Log.e("Error", exception.message)
}
}
}
}
}
在这里,viewModel.fetchMovies返回的 Flow 的收集代码被包裹在一个try-catch块中。如果在 Flow 中遇到异常,异常消息将使用Error标签和exception.message作为消息进行记录。
我们还可以使用catch Flow 操作符来处理我们的 Flow 中的异常。使用catch操作符,我们可以捕获来自上游 Flow 的异常,或者是在调用catch操作符之前的功能和操作符。
在以下示例中,catch操作符被用来捕获viewModel.fetchMovies返回的 Flow 中的异常:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.fetchMovies()
.catch { exception ->
handleException(exception) }
.collect { movie -> processMovie(movie) }
}
}
}
}
在这里,catch操作符被用于 Flow 中捕获异常。该异常是一个将要处理异常的handleException函数的实例。
我们还可以使用catch操作符来发出一个新值来表示错误或用作备用值,例如一个空列表。在以下示例中,当 Flow 返回顶级电影标题时发生异常,将使用默认字符串值No Movie Fetched:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getTopMovieTitle()
.catch { emit("No Movie Fetched") }
.collect { title -> displayTitle(title) }
}
}
}
}
在此示例中,我们使用catch操作符在从ViewModel获取顶级电影标题时发生异常时发出No Movie Fetched字符串。这将是在displayTitle()调用中使用的值。
由于catch操作符仅处理上游 Flow 中的异常,因此在collect{}调用期间发生的异常不会被捕获。虽然你可以使用try-catch块来处理这些异常,但你也可以将收集代码移动到onEach操作符中,在其后添加catch操作符,并使用collect()来开始收集。
以下示例显示了当使用onEach操作符进行值收集和catch操作符处理异常时,你的代码可能看起来像什么:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.fetchMovies()
.onEach { movie -> processMovie(movie) }
.catch { exception ->
handleError(exception) }
.collect()
}
}
}
}
在这里,使用了不带参数的collect()函数,onEach操作符将处理 Flow 中的每一部电影。
在本节中,我们学习了如何在 Flows 中捕获异常。在下一节中,我们将重点关注 Kotlin Flow 的完成。
处理 Flow 完成
在本节中,我们将探讨如何处理 Flow 的完成。我们可以在我们的 Flows 完成后添加代码来执行额外的任务。
当 Flow 遇到异常时,它将被取消并完成 Flow。当 Flow 的最后一个元素被发出时,Flow 也会完成。
要在 Flow 完成时在你的 Flow 中添加监听器,你可以使用 onCompletion 操作符并添加当 Flow 完成时将运行的代码块。onCompletion 的一个常见用法是在 Flow 完成时隐藏你的 UI 中的 ProgressBar,如下面的代码所示:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.fetchMovies()
.onStart { progressBar.isVisible = true }
.onEach { movie -> processMovie(movie) }
.onCompletion { progressBar.isVisible =
false }
.catch { exception ->
handleError(exception) }
.collect()
}
}
}
}
在这个例子中,我们向 Flow 中添加了 onCompletion 操作符来在 Flow 完成时隐藏 progressBar。我们还使用了 onStart 来显示 progressBar。
onStart 操作符是 onCompletion 的对立面。它将在 Flow 开始发出值之前被调用。在之前的示例中,使用了 onStart 以确保在 Flow 开始之前,progressBar 将显示在屏幕上。
在你添加到 onStart 和 onCompletion(如果 Flow 成功完成且没有异常)的代码块中,你也可以发出值,例如初始值和最终值。在以下示例中,使用了 onStart 操作符来发出一个初始值,该值将在屏幕上显示:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getTopMovieTitle()
.onStart { emit("Loading...") }
.catch { emit("No Movie Fetched") }
.collect { title -> displayTitle(title) }
}
}
}
}
在这里,onStart 用于监听 Flow 开始时的情况。当 Flow 开始时,它将发出一个 Loading… 字符串作为 Flow 的初始值。这将随后成为屏幕上显示的第一个条目。
onCompletion 代码块还有一个可空的 catch,异常本身不会被处理,所以你仍然需要使用 catch 或 try-catch 来处理这个异常。
以下示例显示了我们可以如何使用这个可空的 onCompletion 调用:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getTopMovieTitle()
.onCompletion { cause ->
progressBar.isVisible = false
if (cause != null) displayError(cause)
}
.catch { emit("No Movie Fetched") }
.collect { title -> displayTitle(title) }
}
}
}
}
在这个例子中,我们在 onCompletion 块中检查了原因,如果它不为空(这意味着遇到了异常),则将调用 displayError 并将原因传递给它。
在本节中,我们学习了如何使用 onStart 和 onCompletion 来处理 Flows 开始和完成的情况。
让我们尝试将你所学到的知识应用到在 Android 项目中处理 Flows 可能发生的异常的代码中。
练习 6.01 – 在 Android 应用中处理 Flow 异常
在这个练习中,你将继续使用你在 练习 5.01 – 在 Android 应用中使用 Kotlin Flow 中工作的电影应用。这个应用显示正在电影院上映的电影。你将通过以下步骤更新项目以处理 Flow 取消和异常:
-
在 Android Studio 中,打开你在 练习 5.01 – 在 Android 应用中使用 Kotlin Flow 中工作的电影应用。
-
前往
MovieViewModel类。在fetchMovies函数中,删除设置_loading值为true的行。你的函数将看起来像以下这样:fun fetchMovies() { viewModelScope.launch (dispatcher) { MovieRepository.fetchMoviesFlow() .collect { _movies.value = it _loading.value = false } } }
您已移除设置 loading 为 true(并在屏幕上显示 ProgressBar)的代码。它将在下一步被 onStart Flow 操作符所替代。
-
在
collect调用之前添加一个onStart操作符,当 Flow 开始时,它将_loading的值设置为true,如下所示:fun fetchMovies() { viewModelScope.launch (dispatcher) { MovieRepository.fetchMoviesFlow() .onStart { _loading.value = true } .collect { _movies.value = it _loading.value = false } } }
onStart 操作符将在 Flow 开始时将 _loading 的值设置为 true 并在屏幕上显示 ProgressBar。
-
接下来,从
collect调用内部的代码块中移除设置_loading为false的行。您的函数将如下所示:fun fetchMovies() { viewModelScope.launch (dispatcher) { MovieRepository.fetchMoviesFlow() .onStart { _loading.value = true } .collect { _movies.value = it } } }
您已移除在 Flow 收集时将 _loading 的值设置为 false 并在屏幕上隐藏 ProgressBar 的代码。
-
在
collect调用之前添加一个onCompletion操作符,当 Flow 完成时,它将_loading的值设置为false,如下所示:fun fetchMovies() { viewModelScope.launch (dispatcher) { MovieRepository.fetchMoviesFlow() .onStart { _loading.value = true } .onCompletion { _loading.value = false } .collect { _movies.value = it } } }
onCompletion Flow 操作符将 _loading 的值设置为 false。这将隐藏屏幕上显示的 ProgressBar,在获取电影时显示。
-
在
collect函数之前添加一个catch操作符,以处理 Flow 遇到异常的情况:fun fetchMovies() { viewModelScope.launch (dispatcher) { MovieRepository.fetchMoviesFlow() .onStart { _loading.value = true } .onCompletion { _loading.value = false } .catch { _error.value = "An exception occurred: ${it.message}" } .collect { _movies.value = it } } }
这将设置一个包含 An exception occurred: 和异常信息的字符串,并将其作为 _error LiveData 的值。这个 _error LiveData 将在 MainActivity 中显示错误信息。
- 在您的设备或模拟器上关闭 Wi-Fi 和移动数据。然后运行应用程序。这将导致获取电影时出现错误,因为没有互联网连接。应用程序将显示一个
SnackBar消息,如下面的截图所示:
![Figure 6.1 – 电影应用中显示的错误信息
![img/Figure_6.1_B17773_new.jpg]
图 6.1 – 电影应用中显示的错误信息
- 关闭应用程序,并在您的设备或模拟器上开启 Wi-Fi 和/或移动数据。再次运行应用程序。应用程序应显示
ProgressBar,在屏幕上显示电影列表(包括电影标题和海报),并在完成后隐藏ProgressBar,如下面的截图所示:
![Figure 6.2 – 包含电影列表的电影应用
![img/Figure_6.2_B17773.jpg]
图 6.2 – 包含电影列表的电影应用
在这个练习中,您已更新应用程序,使其能够处理 Flow 中的异常而不是崩溃。
摘要
本章重点介绍了 Kotlin Flow 的取消操作。您了解到 Flows 遵循协程的协作取消。flow{} 构建器和 StateFlow 以及 SharedFlow 实现默认可取消。您可以使用 cancellable 操作符使其他 Flows 可取消。
我们接着学习了使用 Kotlin Flow 重试任务。您可以使用 retry 和 retryWhen 函数根据尝试次数和 Flow 遇到的异常来重试 Flow。
然后,我们学习了在 Flow 中的数据发射或收集过程中可能发生的异常处理。你可以使用try-catch块或catch Flow 操作符来处理 Flow 异常。
我们学习了如何处理 Flow 的完成。使用onStart和onCompletion操作符,你可以在 Flows 开始和结束时监听并运行代码。你还可以使用onStart和onCompletion代码块来发射值,例如当你想要为 Flow 设置初始值和最终值时。
最后,我们进行了一个练习来更新我们的 Android 项目并处理在 Flow 中可能遇到的异常。我们使用了catch Flow 操作符来处理项目中的异常。
在下一章中,我们将深入探讨在 Android 项目中创建和运行 Kotlin Flows 的测试。
第七章:第七章:测试 Kotlin 流
在上一章中,我们专注于理解 Kotlin 流的取消,学习如何使流可取消,并处理取消。我们还学习了使用流重试任务以及处理流中的完成和异常。
为您的代码中的 Kotlin 流添加测试是应用程序开发的重要部分。测试将确保我们添加到项目中的流没有错误或错误,并且它们将按预期工作。它们可以使开发应用程序更容易,并帮助您自信地重构和维护代码。
在本章中,我们将学习如何在 Android 中测试 Kotlin 流。首先,我们将了解如何为测试流设置您的 Android 项目。然后,我们将继续创建和运行 Kotlin 流的测试。
本章涵盖了以下主要内容:
-
为测试流设置 Android 项目
-
测试 Kotlin 流
-
使用 Turbine 测试流
到本章结束时,您将了解 Kotlin 流测试。您将能够为您的 Android 应用程序中的流编写和运行单元和集成测试。
技术要求
您需要下载并安装最新版本的 Android Studio。您可以在 developer.android.com/studio 找到最新版本。为了获得最佳学习体验,建议使用以下规格的计算机:
-
英特尔酷睿 i5 或更高版本
-
至少 4 GB 的 RAM
-
可用空间至少 4 GB
本章的代码示例可以在 GitHub 上找到,网址为 github.com/PacktPublishing/Simplifying-Android-Development-with-Coroutines-and-Flows/tree/main/Chapter07。
为测试流设置 Android 项目
在本节中,我们将首先了解如何为测试 Kotlin 流设置我们的 Android 项目。一旦我们完成了这个,添加我们项目中流的单元和集成测试将变得容易。
要在 Android 中创建单元测试,您的项目必须具有 JUnit 4 测试库,这是 Java 的单元测试框架。在 Android Studio 中创建的新项目应该已经将此添加到 app/build 依赖项中。如果您的项目还没有 JUnit,您可以通过在 app/build.gradle 依赖项中添加以下内容来添加它:
dependencies {
…
testImplementation 'junit:junit:4.13.2'
}
将此添加到您的依赖项中,您可以使用 JUnit 4 测试框架对您的代码进行单元测试。
使用模拟对象进行测试也是一个好主意。Mockito 是一个流行的 Java 模拟库,您可以在 Android 上使用它。您还可以使用 Mockito-Kotlin 来使用 Mockito 与惯用的 Kotlin 代码一起使用。要将 Mockito 和 Mockito-Kotlin 添加到您的 Android 测试中,您可以在 app/build.gradle 依赖项中添加以下内容:
dependencies {
…
testImplementation 'org.mockito:mockito-core:4.0.0'
testImplementation 'org.mockito.kotlin:mockito-
kotlin:4.0.0'
}
这将允许你使用 Mockito 通过 Kotlin 代码创建用于 Android 测试的模拟对象。Mockito-Kotlin 依赖于 mockito-core 和 mockito-kotlin:
dependencies {
…
testImplementation 'org.mockito.kotlin:mockito-
kotlin:4.0.0'
}
由于 Kotlin Flow 是建立在协程之上的,你可以使用 kotlinx-coroutines-test 库来帮助你添加对协程和 Flows 的测试。这个库包含了一些实用类,可以让你更容易地编写测试。要将它添加到你的项目中,你可以在 app/build.gradle 的依赖项中添加以下内容:
dependencies {
…
testImplementation 'org.jetbrains.kotlinx:kotlinx-
coroutines-test:1.6.0'
}
添加这个库允许你在项目中使用 kotlinx-coroutines-test 库来测试协程和 Flows。
在本节中,我们学习了如何设置我们的 Android 项目以测试 Kotlin 流。在下一节中,我们将学习如何测试 Kotlin 流。
测试 Kotlin 流
在本节中,我们将专注于测试使用 Flow 的 Kotlin 流。我们可以为使用 Flow 的类,如 ViewModel,创建单元和集成测试。
要测试收集 Flow 的代码,你可以使用可以返回值的模拟对象来进行断言检查。例如,如果你的 ViewModel 监听来自存储库的 Flow,你可以创建一个自定义的 Repository 类,该类发出一个包含预定义值的 Flow,以便更容易地进行测试。
例如,假设你有一个 MovieViewModel 类,如下所示,它有一个返回 Flow 的 fetchMovies 函数:
class MovieViewModel(private val movieRepository:
MovieRepository) {
...
suspend fun fetchMovies() {
movieRepository.fetchMovies().collect {
_movies.value = it
}
}
}
在这里,fetchMovies 函数从 movieRepository.fetchMovies() 收集一个 Flow。你可以通过创建 MovieRepository 并返回一组特定的值来为这个 MovieViewModel 编写测试,然后检查这些值是否与 MovieViewModel 中的 movies LiveData 设置的值相同。这个实现的示例如下:
class MovieViewModelTest {
...
@Test
fun fetchMovies() {
...
val list = listOf(movie1, movie2)
val expected = MutableLiveData<List<Movie>>()
expectedMovies.value = list
val movieRepository: MovieRepository = mock {
onBlocking { fetchMoviesFlow() } doReturn
flowOf(movies)
}
val dispatcher = StandardTestDispatcher()
val movieViewModel =
MovieViewModel(movieRepository, dispatcher)
runTest {
movieViewModel.fetchMovies()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(expectedMovies.value,
movieViewModel.movies.value)
...
}
}
}
在这个示例中,MovieRepository 的 fetchMoviesFlow 返回一个只有一个项目的电影列表。在调用 movieViewModel.fetchMovies() 之后,测试检查 MovieViewModel.movies LiveData 中的值是否被设置为这个列表。
你也可以通过收集它到另一个对象来测试一个 Flow。你可以通过将 Flow 转换为列表(使用 toList())或集合(使用 toSet()),获取第一个元素(使用 first),获取元素(使用 take())和其他终端操作来实现。然后,你可以检查返回的值与预期值是否一致。
例如,假设你有一个 MovieViewModel,它有一个返回 Flow 的函数,如下面的类所示:
class MovieViewModel(private val movieRepository:
MovieRepository) {
...
fun fetchFavoriteMovies(): Flow<List<Movie>> {
...
}
}
在这里,fetchFavoriteMovies 函数返回一个 List<Movie> 的 Flow。你可以通过将 Flow<List<Movie>> 转换为列表来为这个函数编写测试,如下面的示例所示:
class MovieViewModelTest {
...
@Test
fun fetchFavoriteMovies() {
...
val expectedList = listOf(movie1, movie2)
val movieRepository: MovieRepository = mock {
onBlocking { fetchFavoriteMovies() } doReturn
flowOf(expectedList)
}
val movieViewModel =
MovieViewModel(movieRepository)
runTest {
...
assertEquals(expectedList,
movieViewModel.fetchFavoriteMovies().toList())
}
}
}
在这个示例中,你将 movieViewModel.fetchFavoriteMovies() 返回的电影列表 Flow 转换为电影列表,并与预期的列表进行比较。
要测试 Flow 中的错误处理,你可以模拟你的测试对象以抛出异常。然后你可以检查抛出的异常或处理它的代码。以下示例展示了如何为 Flow 的失败情况编写测试:
class MovieRepositoryTest {
...
@Test
fun fetchMoviesFlowWithError() {
val movieService: MovieService = mock {
onBlocking { getMovies(anyString()) } doThrow
IOException(exception)
}
val movieRepository = MovieRepository(movieService)
runTest {
movieRepository.fetchMoviesFlow().catch {
assertEquals(exception, it.message)
}
}
}
}
在这个测试类中,每次调用 MovieService.getMovies() 时,它都会抛出 IOException。然后我们调用 movieRepository.fetchMoviesFlow() 并使用 catch 操作符来处理异常。然后,我们将异常消息与预期字符串进行比较。
我们也可以通过模拟我们的类以返回一个可以触发重试的特定异常来测试 Flow 重试。对于之后仍然失败的重试,你可以检查异常或异常处理。要测试成功的重试,你可以模拟你的类以抛出异常或返回一个可以与预期值比较的 Flow。
以下示例展示了如何测试一个具有对 IOException 重试和任意次数尝试的 Flow:
class MovieViewModelTest {
...
@Test
fun fetchMoviesWithError() {
...
val movies = listOf(Movie(title = "Movie"))
val exception = "Exception"
val hasRetried = false
val movieRepository: MovieRepository = mock {
onBlocking { fetchMoviesFlow() } doAnswer {
flow {
if (hasRetried) emit(movies) else throw
IOException (exception)
}
}
}
...
}
}
在这里,我们使用了一个 hasRetried 变量来确定是否返回一个电影流或抛出一个可以触发重试的异常。默认情况下它是 false 以允许重试。在代码的后面部分,我们可以将此值更改为 true 以返回一个电影流,然后我们可以将其与预期值进行比较。
在本节中,我们学习了如何在 Android 项目中创建和运行 Kotlin Flow 的测试。我们将在下一节中学习如何使用 Turbine 测试热流。
使用 Turbine 测试 Flows
在本节中,我们将学习如何使用 Turbine 测试 Flows,这是一个第三方库,我们可以用它来测试项目中的流。
热流,如 SharedFlow 和 StateFlow,正如你在上一章中学到的,即使在没有监听器的情况下也会发出值。它们也会继续发出值而不会完成。测试它们稍微复杂一些。你无法将这些流转换为列表并与预期值进行比较。
为了测试热流并使测试其他 Flows 更容易,你可以使用 Cash App 中的一个名为 Turbine 的库(github.com/cashapp/turbine)。Turbine 是一个用于 Kotlin Flow 的小型测试库,你可以在 Android 中使用它。
你可以通过在你的 app/build.gradle 依赖项中添加以下内容来在你的 Android 项目中使用 Turbine 测试库:
dependencies {
…
testImplementation 'app.cash.turbine:turbine:0.8.0'
}
添加此内容将允许你在项目中使用 Turbine 测试库来测试代码中的 Flow。
Turbine 在 Flow 上有一个 test 扩展函数。它有一个挂起验证块,你可以逐个从 Flow 中消费项目并与预期值进行比较。然后在验证块的末尾取消 Flow。
以下代码块展示了如何使用 Turbine 和 test 扩展函数测试 Flows 的示例:
class MovieViewModelTest {
...
@Test
fun fetchMovies() {
...
val expectedList = listOf(movie1, movie2)
val movieRepository: MovieRepository = mock {
onBlocking { fetchMovies() } doReturn
flowOf(expectedList)
}
val movieViewModel =
MovieViewModel(movieRepository)
runTest {
movieViewModel.fetchMovies().test {
assertEquals(movie1, awaitItem())
assertEquals(movie2, awaitItem())
awaitComplete()
}
}
}
}
在这里,测试使用了 awaitItem() 函数来获取 Flow 发出的下一个项目,并将其与预期的项目进行比较。然后,它使用了 awaitComplete() 函数来断言 Flow 已完成。
要测试 Flow 抛出的异常,你可以使用返回 Throwable 的 awaitError() 函数。然后你可以将这个 Throwable 与你期望抛出的异常进行比较。以下是如何使用此方法测试你的 Flow 的示例:
class MovieViewModelTest {
...
@Test
fun fetchMoviesError() {
...
val exception = "Test Exception"
val movieRepository: MovieRepository = mock {
onBlocking { fetchMovies() } doAnswer
flow {
throw RuntimeException(exception)
}
}
}//mock
val movieViewModel =
MovieViewModel(movieRepository)
runTest {
movieViewModel.fetchMovies().test {
assertEquals(exception,
awaitError().message)
}
}
}
}
在这个例子中,我们使用了 awaitError() 函数来接收异常,并将其消息与预期的异常进行比较。
要测试热流,你必须在 test lambda 中发出值。你也可以使用 cancelAndConsumeRemainingEvents() 函数或 cancelAndIgnoreRemainingEvents() 函数来取消 Flow 中任何剩余的事件。
以下是一个使用 cancelAndIgnoreRemainingEvents() 函数的示例,在检查 Flow 的第一个项目之后:
class MovieViewModelTest {
...
@Test
fun fetchMovies() {
...
val expectedList = listOf(movie1, movie2)
val movieRepository: MovieRepository = mock {
onBlocking { fetchMovies() } doReturn
flowOf(expectedList)
}
val movieViewModel =
MovieViewModel(movieRepository)
runTest {
movieViewModel.fetchMovies().test {
assertEquals(movie1, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}
}
在这里,测试将检查 Flow 的第一个项目,忽略任何剩余的项目,并取消 Flow。
在本节中,你学习了如何使用 Turbine 测试 Flows。让我们通过向 Android 项目中的 Flows 添加一些测试来尝试我们迄今为止所学的内容。
练习 7.01 – 在 Android 应用中为 Flows 添加测试
在这个练习中,你将继续你在 练习 6.01 – 在 Android 应用中处理 Flow 异常 中工作的电影应用。这个应用显示当前正在电影院上映的电影。你将通过以下步骤在项目中添加对 Kotlin Flows 的测试:
-
在 Android Studio 中打开你在 练习 6.01 – 在 Android 应用中处理 Flow 异常 中工作的电影应用。
-
前往
MovieViewModelTest类。运行测试类,fetchMovies()测试函数将失败。那是因为我们在上一章中更改了实现以使用 Flow。 -
移除
fetchMovies()测试函数的内容,并替换为以下内容:@Test fun fetchMovies() { val dispatcher = StandardTestDispatcher() val movies = listOf(Movie(title = "Movie")) val expectedMovies = MutableLiveData<List<Movie>>() expectedMovies.postValue(movies) val movieRepository: MovieRepository = mock { onBlocking { fetchMoviesFlow() } doReturn flowOf(movies) } val movieViewModel = MovieViewModel(movieRepository, dispatcher) }
使用此代码,我们将模拟 MovieRepository 返回一个包含单个电影的列表流 movies。
-
在
fetchMovies()函数的末尾,添加以下代码来测试MovieViewModel的fetchMovies()函数:@Test fun fetchMovies() { ... runTest { movieViewModel.fetchMovies() dispatcher.scheduler.advanceUntilIdle() assertEquals(expectedMovies.value, movieViewModel.movies.value) } }
这将调用 movieViewModel 中的 fetchMovies() 函数。然后我们将比较返回的 movieViewModel.movies 是否与预期的 movies 列表(包含单个 Movie 项目)相同。
-
在
loading()测试函数中,将断言替换为以下内容:assertTrue(movieViewModel.loading.value) dispatcher.scheduler.advanceUntilIdle() assertFalse(movieViewModel.loading.value)
loading 变量不再可以为空,因此这简化了断言语句。
-
再次运行
MovieViewModelTest类。它应该成功运行,并且所有测试都将通过。 -
打开
MovieRepositoryTest类。我们将为MovieRepository的fetchMoviesFlow()函数添加测试。首先,添加以下函数来测试函数的成功情况:@Test fun fetchMoviesFlow() { val movies = listOf(Movie(id = 3), Movie(id = 4)) val response = MoviesResponse(1, movies) val movieService: MovieService = mock { onBlocking { getMovies(anyString()) } doReturn response } val movieRepository = MovieRepository(movieService) runTest { movieRepository.fetchMoviesFlow().collect { assertEquals(movies, it) } } }
这将模拟MovieRepository,使其始终返回我们将要稍后与fetchMoviesFlow()函数中的电影进行比较的电影列表。
-
添加以下函数以添加对
fetchMoviesFlow()函数抛出异常情况的测试:@Test fun fetchMoviesFlowWithError() { val exception = "Test Exception" val movieService: MovieService = mock { onBlocking { getMovies(anyString()) } doThrow RuntimeException(exception) } val movieRepository = MovieRepository(movieService) runTest { movieRepository.fetchMoviesFlow().catch { assertEquals(exception, it.message) } } }
这个测试将使用一个假的MovieRepository,在调用fetchMoviesFlow时始终抛出错误。然后,我们将测试抛出的异常是否与我们预期的相同。
-
运行
MovieRepositoryTest类。MovieRepository Test中的所有测试应该运行并通过,没有错误。 -
现在,我们将使用 Turbine 测试库来测试
MovieRepository的fetchMoviesFlow()函数生成的 Flow。在app/build.gradle依赖中添加以下内容:testImplementation 'app.cash.turbine:turbine:0.8.0'
这将使我们能够使用 Turbine 测试库为 Android 项目中的 Flows 创建单元测试。
-
添加以下新测试函数以测试
fetchMoviesFlow()函数的成功情况:@Test fun fetchMoviesFlowTurbine() { val movies = listOf(Movie(id = 3), Movie(id = 4)) val response = MoviesResponse(1, movies) val movieService: MovieService = mock { onBlocking { getMovies(anyString()) } doReturn response } val movieRepository = MovieRepository(movieService) runTest { movieRepository.fetchMoviesFlow().test { assertEquals(movies, awaitItem()) awaitComplete() } } }
通过这种方式,我们将模拟MovieRepository以返回一个电影列表。然后,我们将使用awaitItem()将其与movieRepository.fetchMoviesFlow()返回的列表进行比较。然后awaitComplete()函数将检查 Flow 是否已终止。
-
在
fetchMoviesFlow抛出异常的情况下,添加以下函数来测试使用 Turbine:@Test fun fetchMoviesFlowWithErrorTurbine() { val exception = "Test Exception" val movieService: MovieService = mock { onBlocking { getMovies(anyString()) } doThrow RuntimeException(exception) } val movieRepository = MovieRepository(movieService) runTest { movieRepository.fetchMoviesFlow().test { assertEquals(exception, awaitError().message) } } }
这将使用一个MovieRepository模拟类,当调用fetchMoviesFlow()时将抛出RuntimeException。然后,我们将使用awaitError()调用测试异常信息是否与获取的相同。
- 再次运行
MovieRepositoryTest类。MovieRepository Test中的所有测试应该运行并通过,没有错误。
在这个练习中,我们处理了一个使用 Kotlin Flow 的 Android 项目,并为这些 Flows 创建了测试。
摘要
本章重点介绍了在 Android 项目中测试 Kotlin Flows。我们首先为添加 Flows 测试设置了项目。协程测试库(kotlinx-coroutines-test)可以帮助你创建协程和 Flows 的测试。
我们学习了如何在 Android 应用程序中添加 Flows 的测试。你可以使用返回值流的模拟类,然后将其与返回的值进行比较。你还可以将 Flow 转换为List或Set,或从 Flow 中获取值;然后你可以将它们与预期值进行比较。
然后,我们学习了如何使用 Turbine 测试库测试热流,这是一个用于测试 Kotlin 流的第三方测试库。Turbine 在 Flow 上有一个test扩展,你可以逐个消费和比较值。
最后,我们在一个练习中为现有 Android 项目中的 Kotlin Flows 编写了测试。我们还使用了 Turbine 测试库来简化 Flows 测试的编写。
在整本书中,我们获得了关于 Android 中异步编程的知识和技能。我们学习了如何使用 Kotlin 协程和 Flow 简化 Android 项目中的异步编程。
Android 中的所有内容都在不断进化。还有一些关于协程和 Flow 的更高级主题我们没有涉及。保持对 Android、Kotlin 协程和 Kotlin Flow 最新更新的了解是很好的。您可以在 developer.android.com/kotlin/coroutines 上找到关于 Android 协程的最新信息,以及在 developer.android.com/kotlin/flow 上找到关于 Android Kotlin Flow 的最新信息。

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及行业领先的工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。
第八章:为什么订阅?
-
使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,增加编码时间
-
通过为您量身定制的技能计划提高学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。
在 www.packt.com,您还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能还会对 Packt 的其他书籍感兴趣:
整洁的 Android 架构
Alexandru Dumbravan
ISBN: 978-1-80323-458-8
-
发现并解决 Android 旧应用程序中的问题
-
精通整洁架构背后的原则
-
掌握编写松耦合和可测试的代码
-
了解如何将应用程序的代码在单独的层中结构化
-
理解每一层在保持应用程序整洁中的作用
使用 Jetpack 和 Kotlin 快速启动现代 Android 开发
Catalin Ghita
ISBN: 978-1-80181-107-1
-
使用 Kotlin 将流行的 Jetpack 库如 Compose、ViewModel、Hilt 和 Navigation 集成到实际的 Android 应用中
-
应用现代应用程序架构概念,如 MVVM、依赖注入和整洁架构
-
探索 Android 库如 Retrofit、Coroutines 和 Flow
-
将 Compose 与 Jetpack 库的其余部分或其他流行的 Android 库集成
-
在集成支持分页的实际 REST API 的同时,与其他 Jetpack 库如 Paging 和 Room 一起工作
Packt 正在寻找像您这样的作者
如果你对成为 Packt 的作者感兴趣,请访问 authors.packtpub.com 并今天申请。我们与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将他们的见解与全球技术社区分享。你可以提交一个一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
分享你的想法
嗨,
我是 Jomar Tigcal,使用协程和 Flows 简化 Android 开发的作者。我真心希望你喜欢阅读这本书,并觉得它对你的协程和 Flows 的生产力和效率有所帮助。
如果你能在 Amazon 上留下对使用协程和 Flows 简化 Android 开发的评论,这将对我(以及其他潜在读者!)真的非常有帮助。
点击以下链接留下你的评论:
你的评论将帮助我们了解这本书中哪些地方做得好,以及未来版本中哪些地方可以改进,所以这真的非常感谢。
祝好,
Jomar Tigcal

其他你可能喜欢的书籍


浙公网安备 33010602011771号