(译) 协程在 Android 上的应用 (第二部分): 入门

原文链接

这是关于在 Android 上使用协程的多部分系列的一部分。这篇文章的重点是开始工作并跟上已经开始的工作。

跟上协程的进展

在第一部分中,我们探讨了协程擅长解决的问题。作为回顾,协程是两个常见编程问题的绝佳解决方案:

  1. 长时间运行的任务,是那些花费太长时间以至于阻塞主线程的任务
  2. 主线程安全性允许你确保可以从主线程调用所有挂起方法。

为了解决这些问题,协程通过添加 suspendresume 来构建常规功能。当特定线程上的所有协程都被挂起时,线程可以自由地执行其它工作。

但是,协程本身并不能帮助你跟踪在做的工作。拥有大量协程(数百甚至数千个),同时暂停所有协程是完全没问题的。而且,尽管协程很轻量,但它们执行的工作通常很耗时,比如读取文件或发出网络请求。

使用代码手动记录一千个协程是非常困难的。你可以尝试跟踪所有这些,并手动确保它们完成或取消,但是这样的代码很乏味且容易出错。如果代码不完美,它将丢失协程的记录,这就是我所说的工作泄漏。

工作泄漏就像是内存泄漏,但是更糟。这是一个已经丢失的协程。除了使用内存之外,工作泄漏还可以自行恢复以使用 CPU,磁盘,甚至启动网络请求。

泄露的协程可能会浪费内存,CPU,磁盘,甚至启动没有必要的网络请求。

为了帮助避免泄露协程,Kotlin 引入了结构化并发。结构化并发是语言功能和最佳实践的结合,如果遵循这些功能,可以帮助你记录协同程序中运行的所有工作。

在Android上,我们可以使用结构化并发来做三件事:

  • 不再需要时取消工作。
  • 在工作运行时记录工作。
  • 当协程失败时的发出错误信号

让我们深入了解每一个,看看结构化并发性如何帮助我们确保我们永远不会丢失协程和泄漏工作。

使用 scope 取消工作

在 Kotlin 中,协程 必须运行在一个名为 CoroutineScope 的东西中。 CoroutineScope 会记录你的协程,甚至是挂起的协程。与我们在第一部分中谈到的Dispatchers 不同,它实际上并不执行你的协程 - 它只是确保你不会丢失它们。

为了确保记录所有协程,Kotlin不允许你在没有 CoroutineScope 的情况下启动新的协程。你可以将 CoroutineScope 视为具有超能力的 ExecutorService 的轻量级版本。它授予你启动新协程的能力,这些协程随之挂起并恢复,就如我们在第一部分勘查到的 。

CoroutineScope 跟踪所有协程,它可以取消所有协程中的协程。这非常适合Android开发,你希望确保在用户离开时清理屏幕启动的所有内容。

CoroutineScope 会跟踪所有协程,它可以取消所有协程中的协程。

启动新的协程

重要的是要注意,你不能只从任何地方调用 suspend 函数。suspend 和 resume 机制要求你从常规函数切换到协程。

有两种方法可以启动协程,它们有不同的用途:

  • launch 构建器将启动一个“点火就忘”的新协程 - 这意味着它不会将结果返回给调用者。
  • aysnc 构建器将启动一个新的协程,它允许你使用名为 await 的挂起函数返回结果。

几乎在所有情形种,如何从常规函数启动协程的正确答案是使用 launch。由于常规函数无法调用 await(请记住,它不能直接调用挂起函数)使用 async 作为协程的主入口没有多大意义。稍后我们将讨论何时使用 async 是有意义的。

你应该转而调用 launch 使用 coroutine scope 来启动协程。

1
2
3
4
5
6
7
scope.launch {
// This block starts a new coroutine
// "in" the scope.
//
// It can call suspend functions
fetchDocs()
}

你可以将 launch 视为将常规函数代码转换为协程世界的桥梁。在 launch 内部,你可以调用挂起函数并创建主线程安全,就像我们在上一篇文章中介绍的那样。

launch 是从常规函数到协程的桥梁。

警告:lauchasync 之间的一个很大区别是它们如何处理异常。 async 预期你最终会调用 await 来获取结果(或异常),因此默认情况下它不会抛出异常。这意味着如果你使用 async 来启动一个新的协程,它将默默地丢掉异常。

由于 launchasync 仅在 CoroutineScope 上可用,因此你知道你创建的任何协程将始终由 scope 跟踪。 Kotlin 只是不允许你创建一个未跟踪的协程,这对于避免工作泄漏来说还有很长的路要走。

开始在 ViewModel 中使用协程

因此,如果 CoroutineScope 跟踪在其中启动的所有协程,launch创建一个新的协程,那么你应该在何处调用 launch 和 放置你的 scope ?以及,什么时候取消 scope 内启动的所有协程是有意义的?

在 Android 上,将 CoroutineScope 与用户屏幕相关联通常是有意义的。这样可以避免泄露协程或为与用户不再相关的 Activity 或 Fragment 做额外的工作。当用户离开屏幕时,与屏幕关联的 CoroutineScope 可以取消所有工作。

结构化并发确保当 scope 取消时,其所有协程都会取消。

将协程与 Android 体系结构组件集成时,通常需要在 ViewModel 中启动协程。这是一个自然的地方,因为那是最重要的工作开始的地方 - 你不必担心旋转会杀死你所有的协程。

要在 ViewModel 中使用协程,你可以使用来自 lifecycle-viewmodel-ktx:2.10-alpha04viewModelScope 扩展属性 。viewModelScope 将在 AndroidX Lifecycle(v2.1.0)中发布并且当前处于 alpha 状态。你可以在 @manuelvicnt 的博客文章中阅读更多关于它如何工作的内容。由于库目前处于 alpha 状态,因此可能存在错误,API 可能会在最终版本发布之前发生变化。

看下这个例子:

1
2
3
4
5
6
7
8
9

class MyViewModel(): ViewModel() {
fun userNeedsDocs() {
// Start a new coroutine in a ViewModel
viewModelScope.launch {
fetchDocs()
}
}
}

ViewModel 被清除的时候,viewModelScope 将清除此 ViewModel 启动的任何协程(当 onCleared() 被调用时)。这通常是正确的行为 - 如果我们没有提取文档,并且用户关闭了应用程序,我们可能只是在浪费他们的电池来完成请求。

为了更加安全,CoroutineScope 将自行传播。所以,如果你开始的协程继续开始另一个协同程序,它们都会在同一 scope 内结束。这意味着即使你依赖的库从 viewModelScope 启动协程,你也可以取消它们!

警告:当协程挂起时,通过抛出 CancellationException 来协同取消协同程序。捕获像Throwable 这样的顶级异常的异常处理程序将捕获此异常。如果你在异常处理程序中使用异常,或者从不挂起,则协同程序将停留在半取消状态。

因此,当你需要协程运行与 ViewModel 一样长时间,请使用 viewModelScope 从常规函数切换到协同程序。然后,因为 viewModelScope 会自动为你取消协程,所以在这里写一个无限循环而没有泄露是完全没问题的。

1
2
3
4
5
6
7
8
9
10
fun runForever() {
// start a new coroutine in the ViewModel
viewModelScope.launch {
// cancelled when the ViewModel is cleared
while(true) {
delay(1_000)
// do something every second
}
}
}

通过使用 viewModelScope ,你可以确保在不再需要时取消所有工作,甚至是无限循环。

跟踪工作

启动一个协程是很好的,对于很多代码来说,这是你所需要做的全部工作。启动协同程序,发出网络请求,并将结果写入数据库。

但有时候,你需要更多的复杂性。假设你想在协程中同时(或同时)执行两个网络请求 - 为此,你需要启动更多协程!

为了获得更多的协同程序,任何挂起函数都可以通过使用另一个名为 coroutineScope 的构建器或其表兄 supervisorScope 来启动更多协同程序。老实说,这个API有点令人困惑。 coroutineScope 构建器和 CoroutineScope 是不同的东西,尽管它们的名称只有一个字符差异。

在任何地方启动新的协程是造成潜在工作泄漏的一种方法。调用者可能无法得知新的协程,如果不知道它怎么能跟踪工作?

要解决这个问题,结构化并发可以帮助我们解决。也就是说,它保证了当 suspned 函数返回时,它的所有工作都已完成。

结构化并发性保证了当 suspend 函数返回时,它的所有工作都已完成。

以下是使用 coroutineScope 获取两个文档的示例:

1
2
3
4
5
6
suspend fun fetchTwoDocs() {
coroutineScope {
launch { fetchDoc(1) }
async { fetchDoc(2) }
}
}

在此示例中,同时从网络中提取两个文档。第一个是在一个协同程序中获取的,这个协程是以launch启动,它是点火就忘的,这意味着它不会将结果返回给调用者。

第二个文档是使用 async 获取的,因此可以将文档返回给调用者。这个例子有点奇怪,因为通常你会对两个文件都使用async - 但是我想表明你可以根据你的需要混合和匹配 launchasync

使用 coroutineScope 和 supervisorScope 可以安全地从挂起函数启动协程。

但请注意,此代码从不显式等待任何新的协同程序!似乎 fetchTwoDocs返回时,协程还在跑!

为了实现结构化并发并避免工作泄漏,我们希望确保当像 fetchTwoDocs 这样的挂起函数返回时,它的所有工作都已完成。这意味着它启动的两个协同程序必须在 fetchTwoDocs 返回之前完成。

Kotlin 确保使用 coroutineScope 构建器不会从 fetchTwoDocs 泄漏工作。 coroutineScope 构建器将暂停,直到其内部的所有协程完成。因此,在 coroutineScope 构建器中启动的所有协同程序完成之前,无法从 fetchTwoDocs 返回。

很多很多工作

现在我们已经探索了跟踪一个和两个协同程序,现在是时候全押并尝试跟踪一千个协程!

看一下下面的动画:

此示例显示同时发出一千个网络请求。在真正的 Android 代码中不建议这样做 - 你的应用将使用大量资源。

在这段代码中,我们在 coroutineScope 构建器中启动了一千个协程。你可以看到事情是如何串联起来的。由于我们处于 suspend 函数中,某些代码必须使用 CoroutineScope来创建协程。我们对 CoroutineScope 一无所知,它可能是 viewModelScope 或其他地方定义的其他 CoroutineScope 。无论调用范围是什么,coroutineScope构建器都将使用它作为它创建的新 scope 的父级。

然后,在 coroutineScope 块内部,launch 将启动协程“进入”新的 scope。随着由launch启动的协程完成了,新的 scope 将跟踪它们。最后,一旦 coroutineScope 内部的所有协程都完成,loadLots 就可以返回了。

注意:scope 和协同程序之间的父子关系是使用 Job 对象创建的。但是你思考协程和 scope 之间的关系的时候不用深入到这个层面。

coroutineScope 和 supervisorScope 将等待子协同程序完成

在底层有很多内容 - 但重要的是使用 coroutineScopesupervisorScope 可以安全地从任何挂起功能启动协程。即使它会启动一个新的协同程序,你也不会意外泄漏工作,因为你总是挂起调用程序,直到新的协程完成。

真正酷的是 coroutineScope 将创建一个子范围。因此,如果父 scope 被取消,它会将取消传递给所有新的协程。如果调用者是 viewModelScope,当用户离开屏幕时,将自动取消所有一千个协程。相当巧妙!

在我们继续讨论错误之前,值得花点时间谈谈 supervisorScope 与 coroutineScope 。主要区别在于 coroutineScope 将在其任何一个孩子失败时取消。因此,如果一个网络请求失败,则立即取消所有其他请求。如果你想要继续其他请求,即使其中一个失败,你也可以使用supervisorScope。当其中一个孩子失败时,supervisorScope 不会取消其他孩子。

一个协程失败时的信号错误

在协程中,错误通过抛出异常来发出信号,就像常规函数一样。挂起函数的异常将通过 resume重新抛出给调用者。就像使用常规函数一样,你不仅可以使用try/catch来处理错误,而且如果您愿意,可以构建抽象以使用其他样式执行错误处理。

但是,有些情况下协程会丢失错误。

1
2
3
4
5
6
7
8
val unrelatedScope = MainScope()
// example of a lost error
suspend fun lostError() {
// async without structured concurrency
unrelatedScope.async {
throw InAsyncNoOneCanHearYou("except")
}
}

请注意,此代码声明了一个不相关的协程 scope,该 scope 将在没有结构化并发的情况下启动新的协同程序。记得刚开始我说结构化并发是类型和编程实践的结合,在挂起函数中引入不相关的协程范围并不遵循结构化并发的编程实践。

此代码中的错误会丢失,因为 async 会假定你最终会调用await,它将从调用的位置重新抛出异常。但是,如果你从未调用 await,异常会被一直保留着直到潜在的 await出现的时候被抛出。

结构化并发性保证在协同程序错误时,通知其调用者或作用域。

如果对上面的代码使用结构化并发,则会错误地将错误抛给调用者。

1
2
3
4
5
6
7
suspend fun foundError() {
coroutineScope {
async {
throw StructuredConcurrencyWill("throw")
}
}
}

由于 coroutineScope 将等待所有孩子完成,因此也可以在失败时收到通知。如果coroutineScope 启动的协程抛出异常,coroutineScope 可以将它抛给调用者。由于我们使用 coroutineScope 而不是 supervisorScope ,因此在抛出异常时它也会立即取消所有其他子节点。

使用结构化的并发

在这篇文章中,我介绍了结构化并发,并展示了它如何使我们的代码与 Android ViewModel很好地配合以避免工作泄漏。

我还谈到了它如何使挂起函数更容易推理。两者都可以确保他们在返回之前完成工作,并确保他们通过表面异常来表示错误。

相反,如果我们使用非结构化并发,则协同程序很容易意外泄漏调用者不知道的工作。这项工作不会被取消,也不能保证会重新抛出异常。这将使我们的代码更令人惊讶,并可能产生模糊的错误。

你可以通过引入新的无关 CoroutineScope(请注意大写C)或使用名为 GlobalScope的全局作用域来创建非结构化并发,但在极少数情况下,当你需要协同程序比调用作用域更长时,你应该只考虑非结构化并发性。然后自己添加结构以确保跟踪非结构化协程,处理错误,并且很好的取消。

如果你具有非结构化并发性的经验,那么结构化并发确实需要一些人习惯。结构和保证使它更安全,更容易与挂起函数交互。尽可能多地使用结构化并发是一个好主意,因为它有助于使代码更易于阅读,更不用说令人惊讶。

在这篇文章的开头,我列出了结构化并发性为我们解决的三件事

  1. 不再需要时取消工作。
  2. 在运行时跟踪工作。
  3. 当协程协程失败时抛出错误信号。

为了实现这种结构化并发性,我们对代码提供了一些保证。以下是结构化并发的保证。

  1. 当 scope 取消时,其所有协程都会取消。
  2. 当 suspend 函数 返回时,它的所有工作就完成了。
  3. 当协程发生错误时,通知其调用者或scope。

总而言之,结构化并发的保证使我们的代码更安全,更容易推理,并允许我们避免泄漏工作!

接下来是什么

在这篇文章中,我们探讨了如何在 ViewModel 中启动 Android 上的协程以及如何使用结构化并发来使我们的代码不那么令人惊讶。

在谷歌 I/O 之后的下一篇文章中,我们将详细讨论如何在实际情况下使用协程!

非典型前端coder wechat
想要随时Follow我的最新博客,可扫码关注我的公众号