[译] 在 Android 使用协程(part II) - 入门指南

1,228 阅读16分钟

这篇文章是「怎么在 Android 上使用协程」系列文章的第二篇。这篇文章的重点是启动工作和跟踪已经启动的工作。

上一篇内容 :协程的背景知识

跟踪协程(Keeping track of coroutines)

在上一篇中,我们探讨了协程擅长解决的问题。总结一下协程是解决两个常见编程问题的好方法:

  1. 防止耗时任务在主线程运行过久,阻塞主线程
  2. 可以从主线程上安全地去调用网络或磁盘操作

为了解决这些问题,协程在常规函数的基础上添加了 suspendresume。当一个特定线程上的所有协程被挂起时,该线程可以自由地执行其他工作。

然而,协程本身并不能帮你跟踪正在进行的工作。创建大量协程(数百个甚至数千个)并同时挂起它们是完全没问题的。而且,虽然协程很成本很低,但是它们通常执行的都是花费比较大的工作,像读取文件或者发出网络请求。

如果用代码手动管理一千个协程是相当困难的。你可以尝试跟踪它们,并且手动确保它们完成或取消,但是像这样的代码很单调,而且容易出错。如果代码不完美,它将失去对协程的追踪,这就是我所说的工作泄露(a work leak)

工作泄露像内存泄露,但是更糟糕。这是一个丢失的协程。除了使用内存外,工作泄露还可以恢复自身以使用 CPU、磁盘甚至网络请求。

一个协程泄露会消耗内存、CPU、硬盘或发送一个不需要的网络请求。

A leaked coroutine can waste memory, CPU, disk, or even launch a network request that’s not needed.

Kotlin 引入了 结构化并发来帮助避免协程泄露。结构化并发是语言特性和最佳实践的结合,遵循这些特性和最佳实践可以帮助你跟踪程序中运行的所有工作。

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

  1. 当不再需要的时候 取消工作
  2. 在工作运行的时候 跟踪
  3. 协程失败的时候 发出错误

让我们深入研究它们,看看结构化并发如何帮助我们永远不会漏掉协程。

使用作用域取消工作

在 Kotlin 中,协程必须运行在一些叫做协程作用域 (CoroutineScope) 的东西里。一个协程作用域会跟踪你的协程,即使协程是被挂起的。和第一篇文章里讲的 Dispatchers 不一样的是,它实际上不会执行你的协程——它只是确保你不会把协程搞丢。

为了确保所有的协程都能追踪到,Kotlin 不允许你在协程作用域之外创建新的协程。你可以把协程作用域想象成一个有超能力(superpowers?)的轻量版线程池。它赋予你启动新协程的能力,这些协程具有暂停和恢复的能力,我们在第一篇的时候讲过。

协程作用域会跟踪所有的协程,它可以把在里面运行的所有协程都取消。这非常适合 Android 开发,当你需要确保用户在离开时清除由打开界面而启动的所有东西。

协程作用域会跟踪所有的协程,它可以把在里面运行的所有协程都取消。

A CoroutineScope keeps track of all your coroutines, and it can cancel all of the coroutines started in it.

启动新协程

需要注意的是,你不能在任何地方调用挂起函数。挂起和恢复机制要求你从一个普通方法切换到一个协程。

下面有两种不同的启动协程的方式:

  1. launch 将启动一个新的"发射后不管(fire-and-forget)"的协程——也就是说它不会返回结果给调用者
  2. async 将启动一个新的协程,它允许你用 await 来返回一个挂起函数的结果

几乎所有情况下,在常规函数里都是用 launch 启动协程。由于常规函数无法调用 awiat(记住它不能直接调用 挂起函数),所以使用 async 作为协程的入口没什么意义,我接下来会讲什么时候用 async 有意义。

调用 launch 创建一个作用域用来启动一个协程。

scope.launch {
    // 这个代码块创建在"作用域里"创建了一个新协程
    // 
    // 这里可以调用挂起函数
   fetchDocs()
}

你可以将 launch 看成是将代码从常规函数带到协程世界的桥梁。在 launch 代码块内,你可以调用挂起函数,我们在上篇内容中讲过。

Launch 是一个将常规函数化做协程的桥梁。

Launch is a bridge from regular functions into coroutines.

注意:launchasync 最大的不同是它们处理异常的方式。async 会在你最终调用 await 的时候来获得一个结果(或异常),调用之前不会抛出异常。这意味着,如果你使用async启动一个新的协程,它不会直接抛出异常。

由于luanchasync 只能在协程作用域上使用,所以,你懂得,你常见的所有协程都是中由一个作用域来跟踪。Kotlin 不允许你创建一个没被跟踪的协程,这对于避免泄露有很大帮助。

在 ViewModel 启动

所以,如果协程作用域可以跟踪所有在它里面启动的协程,并且launch 创建了一个新的协程,那么应该在哪里调用launch 并且放置作用域呢?再者,在什么时候取消一个作用域中所有已经启动的协程才是有意义的呢?

在 Android 上,将协程作用域与用户界面关联起来通常是有意义的。这可以让你避免泄露协程,或者为已经不在前台的 Activity 或 Fragment 继续打工。当用户从界面离开时,与界面相关的协程作用域就可以取消全部工作。

结构化并发保证当前作用域取消时,它的所有协程都将取消。

Structured concurrency guarantees when a scope cancels , all of its coroutines cancel .

当将协程和 Android 架构体系组件集成时,你通常希望在 ViewModel 中启动协程。把工作放在这里是一个比较合适的地方——你不用担心转屏会杀死所有的协程。

你可以用 lifecycle-viewmodel-ktx:2.1.0-alpha04.viewModelScope 里面的扩展属性,在ViewModel 中使用协程。viewModelScope 即将在 AndroidX Lifecycle(v2.1.0) 中发布,目前处于 alpha 版。你可以在 @manuelvicnt 这篇博客阅读更多关于它是怎么工作的。由于该库目前处于 alpha 版,可能会有一些 bug。并且 api 可能会在最终的 release 发布之前发生改变。如果发现任何 bug,可以在这反馈

来看这个例子:

class MyViewModel(): ViewModel() {
    fun userNeedsDocs() {
        // 在 ViewModel 中启动一个新协程
        viewModelScope.launch {
            fetchDocs()
        }
    }
}

viewModelScope 会在对应的 ViewModel 清除的时候(onCleared() 被回调时)自动的清空所有的已启动的协程。这是一个典型的好习惯——我们还没有获取到文档的时候,而用户已经把应用关了了,这时候还等待请求完成就是在浪费他们的电量(wasting their battery)。

为了更加安全,协程作用域会自动传递。所以,如果你先开启了一个协程,然后又开启另一个协程,它们最终会在同一个范围里。这意味着,即使你所依赖的库从 viewModelScop 启动了一个协程,你也有办法取消它们!

注意:协程在被挂起时取消会抛出 CancellationException 异常。这个异常可以被捕获顶级异常(如 Throwable)的操作捕获。如果你在捕获后消费了异常,或者协程从来没挂起,则协程将处于半取消状态。

因此,当你需要一个协程与 ViewModel 生命周期一样长时,可以使用viewModelScope 从常规函数切换到协程。然后,由于viewModelScope 将自动为你取消协程,所以在这里写一个死循环,而不会造成协程泄露。

fun runForever() {
    // 在 ViewModel 中开启一个新协程
    viewModelScope.launch {
				// 当 ViewModel 清除时取消
        while(true) {
            delay(1_000)
            // do something every second
        }
    }
}

通过使用 viewModelScope ,你可以确保在不需要时取消所有工作,即使是这个死循环。

跟踪工作(Keep track of work)

对于很多代码来说,启动一个协程来处理是个好办法。启动协程,发送网络请求,并且将结果写入数据库。

不过,有时候你的需求会更复杂一些。假如你想在一个协程中同时执行两个网络请求——这需要你启动更多的协程来完成。

为了生成更多的协程,任何挂起函数都可以通过使用另一个名为coroutineScope 的构建器或它的同级的监管作用域(supervisorScope)启动更多的协程。老实说,这个 API 有点让人昏惑。coroutineScope 构建器和 CoroutineScope 是不同的东西,尽管它们的名称中只有一个字符不同。

到处启动新的协程有导致工作泄露的隐患。调用者可能不知道会有新的协程,如果不知道,它又怎么跟踪工作呢?

结构化并发帮助我们解决了这个问题。也就是说,它提供了一个保证,当挂起函数返回时,它的所有工作都完成了。

结构化并发保证当一个挂起函数返回时,它的所有工作都已经完成了。

Structured concurrency guarantees that when a suspend function returns, all of its work is done.

下面是一个使用coroutineScope 获取两个文档的例子:

suspend fun fetchTwoDocs() {
    coroutineScope {
        launch { fetchDoc(1) }
        async { fetchDoc(2) }
    }
}

在这个例子中,同时从网络上请求了两个文档。第一个请求使用了launch 的方式,它属于"发射后不管"的——也就是说它不会把返回值给调用者。

第二个请求使用了async,所以把得到的文档返回给调用者。这个例子有点奇怪,因为正常情况你会用async请求所有文档——但是这是我想演示给你看,你可以根据需求,混合使用luanchasync

协程作用域和监管作用域让你可以安全地调用挂起函数启动协程。

coroutineScope and supervisorScope let you safely launch coroutines from suspend functions.

但是请注意,这段代码没有显式地等待任何一个新的协程!看起来fetchTwoDocs 会在协程运行时立马返回!

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

Kotlin 使用协程作用域构建器确保工作不会从 fetchTwoDocs泄露。协程作用域构建器将挂起自己,直到它内部启动的所有协程完成为止。因此在协程作用域构建器中启动的所有协程都完成之前,不会从fetchTwoDocs返回。

茫茫多的工作(Lots and lots of work)

限制我们已经知道了跟踪一个协程和跟踪两个协程,是时候展示真正的技术了,跟踪一千个协程!

先看一眼下面的动画:

通过动画显示一个协程怎么跟踪一千个协程

这个例子展示了同时发出了一千次网络请求。当然,实际我们不会在 Android 里这么做——这样会消耗大量资源。

在这段代码中,我们在协程作用域构建器中启动了 1000 个协程。你可以看到事情是怎么连接起来的。因为我们在一个挂起函数中,所以某个地方的代码一定使用了一个协程作用域来创建一个协程。我们对这个协程作用域一无所知,它可以是 viewModelScope,也可以是在其他地方定义的其他协程作用域。无论调用的是什么作用域,协程作用域构建器都将使用它作为它创建的新作用域的父作用域。

然后在协程作用域块中,launch将在新的作用域"中"启动协程。随着协程的启动到结束,新的作用域会跟踪它们。最后一旦协程作用域中所有启动的协程完成,loadLots 就可以自由的返回了。

注意:作用域和协程之间的父-子关系是使用 Job 对象创建的。但是通常你不需要深入到这一层来考虑协程和范围之间的关系。

协程作用域和监管作用域将等待子协程完成。

coroutineScope and supervisorScope will wait for child coroutines to complete.

底层发生了很多事——但重要的是,使用协程作用域或者监管作用域你可以安全的从任何挂起函数启动一个协程。即使它将启动一个新的协程,也不会意外的产生泄露,因为你总是挂起调用者,知道新的协程完成。

真正酷的是协程作用域将创建子作用域。因此,如果父作用域被取消,它将把取消操作传递给所有新的协程。如果调用者是 viewModelScope ,那么当用户离开界面时,所有的 1000 个协程都会自动取消,非常简洁!

在外面继续讨论错误之前,有必要花点时间讨论一下监管作用域(supervisorScope) 和协程作用域。主要的区别就是,每当协程作用域的任何子作用域失败时,它就会取消。因此如果一个网络请求失败,所有的请求都会立即被取消。相反,如果你想继续其他请求,即其中一个请求失败了,你也可以使用监管作用域。当其中某个子作用域失败时,监管作用域不会将另外的子作用域也取消。

协程失败时发送错误(Signal errors when a coroutine fails)

在协程中,通过抛出异常来发出错误,就像常规函数一样。挂起函数中的异常将通过 恢复 重新跑出给调用者。就像使用常规函数一样,你不受限于try/catch 来处理错误,如果你愿意,还可以构建抽象来用其他方式来执行错误处理。

然而,在协程中也有可能丢失错误的情况。

val unrelatedScope = MainScope()
// 丢失错误的示例
suspend fun lostError() {
    // 不处于结构化并发的 async
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}

注意,这段代码声明了一个不相关的协程作用域,它将启动一个脱离结构化并发的新协程。请记住,在开始时我说过,结构化并发是语言特性和最佳实践的结合,在挂起函数中,引入不相关的协程范围并不符合结构化并发最佳实践。

这个错误在这段代码中丢失了,因为 async 假设你最终将调用 await ,它将在那重新抛出异常。但是如果你从来没有调用await,那么异常将永远存储在 await 中,直到被触发。

结构化并发确保一个协程发生错误时,它的调用者或作用域会得到通知。

Structured concurrency guarantees that when a coroutine errors, its caller or scope is notified.

如果在上面的代码使用结构化并发,这个错误会正确抛出给调用者。

suspend fun foundError() {
    coroutineScope {
        async { 
            throw StructuredConcurrencyWill("throw")
        }
    }
}

因为协程作用域会等待所有子(协程)完成,所以当它们失败时,它也可以得到通知。如果由协程作用域启动的协程抛出异常,协程作用域可以将异常抛出给调用者。因为我们使用的是协程作用域而不是监管作用域,所以当抛出异常时,它还会立即取消所有其他子协程。

使用结构化并发

在这篇文章中,我介绍了结构化并发并且展示了它如何让我们的代码与 Android 的 ViewModel 更好的配合,以避免泄露。

还讨论了如何使挂起函数更容易理解。既要确保它们在返回之前完成工作,又要确保它们通过显式的异常抛出错误。

相反,如果我们使用非结构化并发,协程很容易意外的泄露调用者不知道的工作。该工作不能取消,也不能保证会重新抛出异常。这将让我们的代码更诡异,并可能产生模糊的 Bug。

你可以通过引入一个新的不相关的协程作用域或使用一个名为GlobalScope 的全局作用域来创建非结构化并发,但是你应该只在极少数情况下考虑非结构化并发,因为你需要协程比调用作用域的生命周期更长。然后自己添加结构是个好方法,以确保跟踪非结构化协程,处理错误,并且能够很好的取消。

如果你有非结构化编程的经验,那么结构化并发确实需要一些时间来适应。它的结构和保证让它更安全,更容易与挂起功能交互。尽可能多地使用结构化并发是一个好主意,因为它有主语使代码更容易阅读,而且更不令人奇怪。

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

  1. 当不再需要的时候 取消工作
  2. 在工作运行的时候 跟踪
  3. 协程失败的时候 发出错误

要完成这种结构化并发,我们需要对代码提供一些保证。下面是结构化并发的保证。

  1. 当一个作用域取消时,它所有的协程都被取消
  2. 当一个挂起函数返回时,所有的工作都已经完成
  3. 当一个协程发生错误时,它的调用者或作用域会得到通知

总之,结构化并发的保证使我们的代码更安全、更容易理解,并允许我们避免泄露。

What's next?

在这篇文章中,我们探讨了如何在 Android 的 ViewModel 中启动协程,以及如何处理结构化并发,以让我们的代码不会很诡异。

在下一篇文章中,我们将更多地讨论如何在实际情况中使用协程!