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

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

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

跟踪协程(Keeping track of coroutines)

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

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

为了解决这些问题,协程在常规函数的基础上添加了 suspendresume。当一个特定线程上的全部协程被挂起时,该线程能够自由地执行其余工做。github

然而,协程自己并不能帮你跟踪正在进行的工做。建立大量协程(数百个甚至数千个)并同时挂起它们是彻底没问题的。并且,虽然协程很成本很低,可是它们一般执行的都是花费比较大的工做,像读取文件或者发出网络请求。数据库

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

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

一个协程泄露会消耗内存、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 中启动协程,以及如何处理结构化并发,以让咱们的代码不会很诡异。

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

相关文章
相关标签/搜索