这篇文章翻译自: Coroutines on Android (part II): getting started
1. 协程跟踪
在第一篇里,我们探究了协程善于解决的问题。作为回顾,协程可以很好地解决两个常见的编程问题:
- 长时间运行任务:需要花费太长时间,会阻塞主线程的任务;
- 主线程安全:确保能在主线程中调用任何挂起函数;
为了解决这些问题,协程在常规方法的基础上添加了 挂起
和 恢复
功能。当特定线程上的所有协程都被挂起时,该线程可以做其它工作。
但是,协程本身并不能帮助你跟踪正在进行的工作。拥有大量的协程——数以百计甚至数以千计——并且同时挂起它们都是OK的。并且,虽然协程是轻量级的,它们做的工作通常都是重量级的,比如读文件和发起网络请求。
使用代码手动跟踪数以千计的协程是非常困难的。你可以尝试手动跟踪协程以确保它们执行完成或取消,但是这样的代码繁琐且容易出错。如果代码不够完美,它就会跟丢协程,我称之为 工作泄漏(work leak)。
工作泄漏就像内存泄漏,但是更加糟糕。这是协程的丢失。除了使用内存,工作泄漏还可以恢复自身并继续使用CPU,磁盘,甚至发起一个网络请求。
一个泄漏的协程能浪费内存,CPU,磁盘,甚至发起一个不需要的网络请求。
为了帮助避免协程泄漏,Kotlin引入了 结构化并发
(Structured Concurrency)。结构化并发是语言特性和最佳实践的结合,当遵循这些最佳实践时,可以帮助你跟踪协程中运行的所有工作。
在Android中,我们可以使用结构化并发来做三件事:
- 取消任务,当任务不再被需要的时候。
- 跟踪任务,当任务正在运行的时候。
- 报错,当协程失败的时候。
让我们深入探究其中的每一项,看看结构化并发如何帮助我们确保永远不会跟丢协程和泄漏任务。
2. 使用 scope 取消任务
在Kotlin中,协程必须在 CoroutineScope
中运行。CoroutineScope
可以跟踪协程,即使是已经被挂起的协程。和第一篇里讲到的 Dispatchers
不同,CoroutineScope
并不真正执行协程,它只用来确保不会跟丢它们。
为了确保跟踪所有的协程,Kotlin不允许在没有 CoroutineScope
的情况下开启一个新的协程。你可以把CoroutineScope
想象成一种具有超能力的轻量级版本 ExecutorService
。它使你能启动新的协程,这些协程拥有我们在第一篇中探讨过的所有挂起和恢复的优势。
CoroutineScope
会跟踪所有的协程,并且可以取消在它里面启动的所有协程。这一特性非常适合Android开发,使你可以确保在用户离开时清理由屏幕启动的所有内容。
CoroutineScope
会跟踪所有的协程,并且可以取消(cancel
)在它里面启动的所有协程
2.1 启动新的协程
请务必注意,你不能在任意地方调用挂起方法。挂起和恢复的机制要求你从常规方法切换到协程。
有两种方式可以开启协程,它们有不同的用处:
- launch 构造器会启动一个没有返回结果的新协程。
- aysnc 构造器会启动一个新的协程,它允许挂起方法返回一个可以通过调用
await
方法获取的结果。
大多数情况下,在常规方法中开启一个协程的正确方式是使用 launch
。由于常规方法没办法调用 await
(常规方法无法直接调用挂起方法),使用 async
作为协程的主入口是没有太大意义的。我们会在后面讨论什么时候适合使用 async
。
你应该使用协程 scope
调用 launch
来启动一个协程。
scope.launch {
//This block starts a new coroutine "in" the scope.
//It can call suspend functions
fetchDocs()
}
你可以把 launch
想作把代码从常规方法带入协程世界的一个桥梁。在 launch
体里面,你可以调用挂起方法并创建主线程安全性,像我们在上一篇中讨论过的那样。
launch
是常规方法到协程的桥梁。
提醒:
launch
和async
之间有一个大的不同之处在于他们如何处理异常。async
默认你最终会调用await
来获取结果(或者异常),因此它默认不会抛异常。那意味着如果你使用async
来开启新协程,它将默默地丢弃异常。
由于launch
和 async
只在CoroutineScope
中可用,可知你创建的任何协程都会被 scope
跟踪。Kotlin不允许创建不可跟踪的协程,这样可以避免工作泄漏。
2.2 在 ViewModel 中开启
因此,如果一个 CoroutineScope
跟踪它里面启动的所有协程,并且 launch
能创建新协程,那么你应该在哪里调用 launch
并放置 scope
呢?并且,什么时候取消 scope
里面启动的所有协程合适呢?
在Android里,把 CoroutineScope
与用户屏幕关联起来通常是合理的。这能让你避免协程泄漏或者给与用户不再关联的 Activity
或 Fragment
做多余的工作。当用户离开屏幕的时候,与屏幕关联的 CoroutineScope
能取消所有的工作。
结构并发保证当
scope
取消时,其所有协程都会取消。
当把协程和Android结构组件整合使用,你通常想在 ViewModel
中启动协程。这是大多数工作开始的地方,并且你不必担心旋转(手机横竖排切换)会杀掉你所有的协程。
要在 ViewModel
中使用协程,你可以使用它的扩展属性 viewModelScope
。让我们来看一个例子:
class MyViewModel() : ViewModel() {
fun userNeedsDocs() {
//start a new coroutine in a ViewModel
viewModelScope.launch {
fetchDocs()
}
}
}
当 ViewModel
被清除的时候(ViewModel
的 onCleared()
回调方法被调用的时候), viewModelScope
会自动取消由 ViewModel
启动的所有协程。这通常是正确的行为——如果我们还没有获取到文档,而用户已经关掉app,继续完成请求可能只是在浪费电量。
更加安全的是,viewModelScope
可以自行传递。因此,如果在一个协程里启动了另一个协程,它们都会在同一个 scope
里。这意味着即使你依赖的库从viewModelScope
里启动了一个协程,你也可以取消它们!
注意:当协程挂起的时候,它是通过抛出
CancellationException
异常来协同取消的。捕获顶级异常(比如Throwable
)的异常处理器将会捕获该异常。如果你在异常处理器中消费该异常,或者从不挂起,协程将徘徊在一种半取消状态。
因此,当你需要一个和 ViewModel
运行周期一样长的协程,可以使用 viewModelScope
来从常规方法切换到协程。然后,由于 viewModelScope
会为你自动取消协程,即使在里面写一个无限循环也不会造成泄漏。
fun runForever() {
//start a new coroutine in the ViewModel
viewModelScope.launch {
//cancelled when the ViewModel is cleared
while(true){
delay(1000)
//do something every second
}
}
}
通过使用 viewModelScope
,你能确保所有的工作,即使是无限循环,都能在不需要的时候被取消。
3. 跟踪任务
启动一个协程就很好——而且对于很多代码来说,这就是你真正需要做的。 启动协程,发出网络请求,并将结果写入数据库。
但有时候,你会遇到更复杂的场景。比如说你想在一个协程里同时发起两个网络请求——要实现这一功能你需要启动更多的协程!
要创建更多的协程,任意挂起函数都可以通过 coroutineScope
构建器或与其相似的 supervisorScope
构建器来开启更多协程。老实说,这个API有点令人困惑,coroutineScope
构建器和 CoroutineScope
是不同的东西,尽管它们的名字只有一个字符的区别。
随意地创建协程会导致潜在的工作泄漏。调用者也许无法知道新建的协程,那么它如何跟踪该任务呢?
结构化并发(structured concurrency)能帮助我们解决这一问题。也就是说,它提供了一个保证,当 suspend
方法 return
时,表示它所有的任务都已经执行完成。
结构化并发保证,当一个
suspend
方法return
时,它里面的所有任务都已执行完成。
下面有一个使用 coroutineScope
获取两个文档的例子:
suspend fun fetchTwoDocs() {
coroutineScope {
launch{ fetchDoc(1) }
async{ fetchDoc(2) }
}
}
在这个例子中,同时通过网络获取两个文档。第一个在由 launch
发起的协程中获取,launch
发起的协程不会返回结果给调用者;第二个文档在由 async
发起的协程中获取,而 async
会返回结果给调用者。
这个例子有些奇怪,通常你会都使用 async
来获取这两个文档,但我想展示一下,你可以根据自己的需求,混合使用 launch
和 async
。
coroutineScope
和supervisorScope
让你可以安全地在suspend
方法中发起协程。
但是请注意,这段代码没有明确地等待两个新的协程的执行。看起来 fetchTwoDocs
方法似乎会在协程仍在运行的时候返回。
使用 coroutineScope
构建器可以确保 fetchTwoDocs
方法中的任务不会泄露。coroutineScope
构建器会挂起自身,直到它里面启动的所有协程都执行结束。因此,fetchTwoDocs
方法不可能会 return
,除非它里面启动的所有协程都已执行完毕。
3.1 跟踪海量任务
目前我们已经探索过跟踪一两个协程,是时候全力以赴来尝试跟踪1000个协程了!
请看下面的动画:
这个例子展示了同时发起1000个网络请求。但实际情况下不推荐在Android中采取这种做法,这将占用大量的资源。
在上面的代码里,我们在 coroutineScope
构建器中使用 launch
开启了1000个协程。你可以看见事情变得愈加诡异。某个地方的代码一定使用了 CoroutineScope
来创建一个协程,我们不知道关于这个 CoroutineScope
的任何情况,它可能是一个 viewModelScope
,也可能是在其它地方定义的 CoroutineScope
。但不管这个 scope
是什么,coroutineScope
构建器都将使用它作为 parent
来构建新的 scope
。
然后,在 coroutineScope
代码块中,launch
会在新的 scope
中启动协程。由 launch
发起的协程启动之后,新的 scope
就会跟踪它们。最终,一旦在这个 coroutineScope
里面启动的协程全部执行完毕,loadLots
方法就可以 return
了。
注意:scope
和协程之间的亲子(parent-child
)关系由 Job
对象创建。但通常你不必深究其细节。
coroutineScope
和supervisorScope
将等待其子协程执行完成。
协程在底层进行着大量的工作,但重要的是,使用 coroutineScope
和 supervisorScope
,你可以在任意 suspend
方法中安全地启动协程。虽然它们会开启一个新的协程,但可以确保不会意外地泄漏工作,因为它们总是会挂起调用者,直到新协程执行完成。
更酷的是 coroutineScope
会创建一个子 scope
。如果上一级 scope
被取消了,它将会把取消操作向下传递到新的协程。如果前面代码的调用者是 viewModelScope
,当用户退出界面之后,所有的1000个协程都会被自动取消掉。
在我们讨论异常之前,值得花一点时间讨论下 supervisorScope
和 coroutineScope
的区别。它们最主要的区别在于:当 coroutineScope
中任意一个子协程执行失败,整个 coroutineScope
都会被取消。 因此,如果其中某个网络请求失败,其它所有的请求都会立马被取消。如果你希望其它的请求仍然继续执行,那么就可以使用 supervisorScope
。在 supervisorScope
中,一个子协程执行失败不会导致其它子协程被取消。
补充:想进一步了解
coroutineScope
和supervisorScope
,可以查看这篇文章:coroutineScope和supervisorScope的区别
4. 协程失败时的报错
协程就和常规方法一样,通过抛异常的方式来报错。suspend
方法中的异常在 resume
时会抛给调用者。和常规方法一样,你不仅限于使用 try/catch
来处理错误,也可以构建抽象以使用其它方式来处理错误。
然而,在某些场景中,协程会丢失错误。
val unrelatedScope = MainScope()
//一个遗失错误的例子
suspend fun lostError() {
//不使用结构化并发的async
unrelatedScope.async {
throw InAsyncNoOneCanHearYou("except")
}
}
注意这段代码,声明了一个不相关的 scope
,在不使用结构化并发的情况下发起了一个新的协程。我在这篇文章开始时说过,结构化并发是语言特性和编程实践的结合,在 suspend
方法中引用不相干的 scope
没有遵循这一编程实践。
这段代码中的错误被丢失是因为 async
默认你最终会调用 await
方法,然后它才会抛异常。然而,如果你不调用 await
方法,异常就会被存起来一直等到该方法被调用。
结构化并发可以保证,当一个协程出错时,它的调用者或
scope
会收到通知。
如果你在上面的代码中使用结构化并发,错误将被正确地抛给调用者。
suspend fun foundError() {
croutineScope {
async {
throw StructuredConcurrencyWill("throw")
}
}
}
coroutineScope
会等待所有子协程完成,当子协程出错时,他也会收到通知。如果一个由 coroutineScope
启动的协程抛了异常,coroutineScope
会把异常抛给它的调用者。由于我们用的是 coroutineScope
而不是 supervisorScope
,当异常出现时,它也会立即取消所有的子协程。
5. 使用结构化并发
在这篇文章中,我介绍了结构化并发,并且展示了它如何使我们的代码与Android的 ViewModel
很好地搭配以避免工作泄漏。
我也讨论了它能保证在任务执行完成后才返回,并且遇到异常时能及时返回错误信息,使得 suspend
方法更易于理解和使用。
假如我们不使用结构化并发,协程很容易意外泄漏工作,而调用者无从知晓。并且任务无法被取消,也无法保证异常会被抛出。这会让我们的代码充满意外,而且可能造成令人费解的bug。
你可以通过不相关的 CoroutineScope
,或者是 GlobalScope
来创建一个非结构化并发,但是你只应该在极少数情况下使用它,比如你需要启动一个比调用者的 scope
存活更久的协程。在非结构化的协程中,自己添加结构以实现任务追踪、错误处理和任务取消是一个很好的主意。
如果你有非结构化并发的经验,结构化并发确实需要一些时间去适应。结构和保证确实使得 suspend
方法更加安全,更易于交互。尽量使用结构化并发是很好的主意,因为它使得代码更易于阅读并且能减少意外。
在文章的开始我列举了结构化并发能为我们解决的三件事情:
- 取消任务,当任务不再被需要的时候;
- 跟踪任务,当任务正在运行的时候;
- 报错,当协程失败的时候;
为了能达到这一目的结构化并发给了我们这些保证:
- 当一个
scope
取消的时候,它里面所有的协程都会被取消; - 当一个
suspend
方法return
的时候,它所有的工作都已完成; - 当一个协程出错的时候,他的调用者或
scope
会收到通知;
总之,结构化并发的保证使我们的代码更安全,更易于理解,并且能帮助我们避免泄漏工作!