本系列文章
Android 上的协程(第一部分):了解背景
Android 上的协程(第二部分):入门
Android上的协程 (第三部分): 实际应用
在第一部分中,我们探讨了协程擅长解决的问题。回顾一下,协程是解决两个常见编程问题的好方法:
长时间运行的任务是花费太长时间阻塞主线程的任务。
Main-safety允许您确保可以从主线程调用任何挂起函数。
为了解决这些问题,协程通过添加suspend
和resume
建立在常规函数之上。当一个特定线程上的所有协程都被挂起时,该线程就可以自由地做其他工作。
但是,协程本身并不能帮助您跟踪正在完成的工作。拥有大量协程(数百甚至数千)并同时暂停所有协程是完全没问题的。而且,虽然协程很轻量级,但它们执行的工作通常很重要,例如读取文件或发出网络请求。
使用代码手动跟踪一千个协程是相当困难的。您可以尝试跟踪所有这些并手动确保它们完成或取消,但这样的代码很乏味且容易出错。如果代码不完美,它就会失去协程的踪迹,这就是我所说的工作泄漏。
任务泄漏就像内存泄漏,但更糟。这是一个丢失的协程。除了使用内存之外,任务泄漏还可以自行恢复以使用 CPU、磁盘,甚至发起网络请求。
泄漏的协程会浪费内存、CPU、磁盘,甚至会启动不需要的网络请求。
为了帮助避免协程泄漏,Kotlin 引入了结构化并发。结构化并发是语言特性和最佳实践的结合,遵循这些实践可以帮助您跟踪协程中运行的所有工作。
在 Android 上,我们可以使用结构化并发来做三件事:
让我们深入研究其中的每一个,看看结构化并发如何帮助我们确保我们永远不会失去对协程和泄漏任务的跟踪。
在 Kotlin 中,协程必须运行在一个称为CoroutineScope
的组件中。一个CoroutineScope
跟踪您的协程,甚至是挂起的协程。与我们在第一部分中讨论的不同Dispatchers
,它实际上并不执行您的协程 — 它只是确保您不会忘记它们。
为确保跟踪所有协程,Kotlin 不允许您在没有CoroutineScope
的地方运行协程. 您可以将一个CoroutineScope
视为具有超能力的轻量级版本ExecutorService
。它使您能够启动新的协程,这些协程具有我们在第一部分中探讨的所有suspend
和resume
优点。
一个CoroutineScope
跟踪所有协程,它可以取消其中启动的所有协程。这非常适合 Android 开发,您希望确保在用户离开时清除屏幕启动的所有内容。
CoroutineScope
会跟踪所有协程,并且它可以取消其中启动的所有协程。
重要的是要注意你不能从任何地方调用一个suspend
函数。挂起和恢复机制要求您必须使用协程。
有两种启动协程的方式launch
和async
,它们有不同的用途:
launch
将启动一个“即发即弃”的新协程——这意味着它不会将结果返回给调用者。async
将启动一个新的协程,它允许您返回一个带有名为await
的结果。协程通常由launch
发起,常规函数无法调用挂起函数,launch
无结果返回。我们稍后会讨论什么时候使用async
。
在协程左右域上使用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
上可用,您知道您创建的任何协程将始终由作用域跟踪。Kotlin 只是不允许您创建未跟踪的协程,这对避免任务泄漏大有帮助。
因此,如果 一个CoroutineScope
跟踪其中启动的所有协程,并launch创建一个新的协程,那么您究竟应该在哪里调用launch
和放置您的作用域呢?而且,什么时候取消在一个作用域内启动的所有协程才有意义?
CoroutineScope
在 Android 上,与用户交互相关联通常很有意义。这使您可以避免泄漏协程或为用户做额外的工作Activities或Fragments不再与用户相关。当用户离开Screen时,CoroutineScope
与Screen相关联的工作都将取消。
结构化并发保证当一个范围 取消时,它的所有协程都 取消。
将协程与 Android 架构组件集成时,您通常希望launch在ViewModel上,因为这是工作开始的地方——你不必担心屏幕旋转会取消你所有的协程。我们可以使用lifecycle-viewmodel-ktx
来在viewModel上启动协程。
看下面这个例子:
class MyViewModel(): ViewModel() {
fun userNeedsDocs() {
// Start a new coroutine in a ViewModel
viewModelScope.launch {
fetchDocs()
}
}
}
viewModelScope
将在viewModel
销毁时,取消其内的所有协程,在onCleared()
回调内完成。CoroutineScope
会自动传播,因此,如果您启动的一个协程后又继续启动了另一个协程,那么它们最终都会在同一作用域viewModelScope
内。
警告:当协程挂起时,通过抛出 aCancellationException协程取消的异常。捕获顶级异常的异常处理程序Throwable将捕获此异常。如果您在异常处理程序中使用异常,或者从不挂起,协程将停留在半取消状态。
所以,当你需要在ViewModel
中运行一个协程时,只需使用viewModelScope
从常规函数切换到协程。然后,因为viewModelScope
会自动为你取消协程,所以在这里写一个无限循环而不会造成泄漏是完全没问题的。
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
是2个不同的东西,尽管他们的名字只有一个字符不同,coroutineScope
是一个协程构建器,而CoroutineScope
是协程作用域类。
在任何地方启动新协程是造成潜在任务泄漏的一种方式。调用者可能不知道新协程,如果不知道,它如何跟踪工作?
为了解决这个问题,结构化并发可以帮助我们解决这个问题。也就是说,它提供了一个保证,即当suspend
函数返回时,它的所有工作都已完成。
结构化并发保证当挂起函数返回时,它的所有工作都已完成。
coroutineScope
下面是一个用于获取两个文档的示例:
suspend fun fetchTwoDocs() {
coroutineScope {
launch { fetchDoc(1) }
async { fetchDoc(2) }
}
}
在此示例中,同时从网络中获取了两个文档。第一个是在一个以launch
“即发即弃”开始的协程中获取的——这意味着它不会将结果返回给调用者。
第二个文档是用 获取的async
,所以文档可以返回给调用者。这个例子有点奇怪,因为通常你会同时使用async
同时获取这两个文档——但我想表明你可以根据需要混合launch搭配。
coroutineScope
和supervisorScope
让您可以从挂起函数中安全地启动协程。
需要注意的是,这段代码从未显式等待任何新协程!看起来像是fetchTwoDocs
将在协程运行时返回!
为了实现结构化并发并避免任务泄漏,我们要确保当fetchTwoDocs
返回时,它的所有工作都已完成。这意味着它启动的两个协程都必须在fetchTwoDocs
返回之前完成。
使用coroutineScope
构建器能确保任务不会从fetchTwoDocs
泄漏出来。coroutineScope
构建器将暂停自身,直到其内部启动的所有协程都完成。因此,在coroutineScope
构建器中启动的所有协程完成之前,无法从fetchTwoDocs
返回。
现在我们已经探索了跟踪一个和两个协程,是时候全力以赴并尝试跟踪一千个协程了!
看看下面的动画:
这个例子展示了同时进行一千个网络请求。这在实际的Android代码中是不推荐的——您的应用将会使用大量资源。
在这段代码中,我们使用coroutineScope
构建器在其中启动了一千个协程。您可以看到这些东西是如何连接起来的。由于我们在一个挂起函数中,某个地方的代码必须使用CoroutineScope
来创建协程。我们不知道这个CoroutineScope
的任何信息,它可能是一个viewModelScope
或定义在其他地方的其他CoroutineScope
。不管是哪个调用范围,coroutineScope
构建器都会将其用作新范围的父范围。
然后,在coroutineScope
块内,launch将在新范围内启动协程。当由launch启动的协程完成时,新范围将跟踪它们。最后,一旦在coroutineScope
内部启动的所有协程都完成,loadLots
就可以自由返回了。
注意:作用域和协程之间的父子关系是使用Job对象创建的。但是,您通常可以在不深入到那个级别的情况下考虑协程和作用域之间的关系。
coroutineScope
和 supervisorScope
将等待子协程完成。
在这里,底层有很多事情要处理,但重要的是,使用coroutineScope
或supervisorScope
,您可以从任何挂起函数安全地启动协程。即使它启动一个新协程,您也不会意外地泄漏工作,因为您总是会挂起调用者,直到新协程完成。
非常酷的是,coroutineScope
会创建一个子作用域。因此,如果父作用域被取消,它将向所有新协程传递取消。如果调用者是viewModelScope
,当用户从屏幕导航离开时,所有一千个协程将自动取消。非常棒!
在我们继续讨论错误之前,值得花一点时间来谈谈supervisorScope
和coroutineScope
之间的区别。主要区别在于,coroutineScope
将在其任何子协程失败时取消。因此,如果一个网络请求失败了,所有其他请求将立即被取消。如果您希望即使有一个请求失败,也要继续处理其他请求,则可以使用supervisorScope
。supervisorScope
不会在一个子协程失败时取消其他子协程。
在协程中,错误通过抛出异常来处理,就像常规函数一样。函数的异常suspend
将由 resume
重新抛出给调用者。就像常规函数一样,您不限于try/catch
来处理错误,如果您愿意,您可以构建抽象以使用其他样式执行错误处理。
但是,在某些情况下,协程中可能会丢失异常。
val unrelatedScope = MainScope()
// example of a lost error
suspend fun lostError() {
// async without structured concurrency
unrelatedScope.async {
throw InAsyncNoOneCanHearYou("except")
}
}
请注意,此代码声明了一个无关的协程作用域,将启动一个没有结构化并发的新协程。请记住,我在开始时说过,结构化并发是类型和编程实践的组合,而在挂起函数中引入无关的协程范围不遵循结构化并发的编程实践。
在这个代码中,错误被忽略了,因为async
假定您最终会调用await
,它会重新抛出异常。但是,如果您从未调用await,异常将永远存储下来,耐心地等待被引发。
结构化并发保证当协程出错时,它的调用者或作用域会得到通知。
如果您确实对上述代码使用结构化并发,错误将正确地抛给调用者。
suspend fun foundError() {
coroutineScope {
async {
throw StructuredConcurrencyWill("throw")
}
}
}
由于coroutineScope
将等待所有子协程完成,因此它也可以在它们失败时得到通知。如果coroutineScope
启动的协程抛出异常,coroutineScope
可以将其抛给调用者。由于我们使用的是coroutineScope
而不是supervisorScope
,在抛出异常时它还会立即取消所有其他子协程。
在本文中,我介绍了结构化并发,展示了它如何使我们的代码与Android ViewModel
完美匹配,避免工作泄漏。
我还谈到了如何使挂起函数更容易理解。通过确保它们在返回之前完成工作,以及确保它们通过抛出异常来表明错误。
如果我们使用非结构化并发,协程很容易意外泄漏调用者不知道的工作。这些工作是不可取消的,也不能保证异常会被重新抛出。这会使我们的代码更加出人意料,并可能创建难以理解的错误。
你可以通过引入一个新的无关的CoroutineScope
(注意大写的C),或者使用一个名为GlobalScope
的全局作用域来创建非结构化并发,但是只有在需要协程比调用作用域更长寿的罕见情况下才应该考虑非结构化并发。在这种情况下,最好添加结构来确保跟踪非结构化协程,处理错误并具有良好的取消策略。
如果您有使用非结构化并发的经验,那么结构化并发确实需要一些时间来适应。这种结构和保证使与挂起函数交互更安全、更容易。因此,尽可能使用结构化并发是一个好主意,因为它有助于使代码更易于阅读,更少出现令人惊讶的情况。
在本文开头,我列出了三个结构化并发解决的问题。
结构化并发能够给我们带来以下保证,以实现
以下是结构化并发的保证:
在这篇文章中,我们探讨了如何在 ViewModel
中启动 Android 上的协程,以及如何使用结构化并发来使我们的代码容易让你接受。
在下一篇文章中,我们将更多地讨论如何在实际情况中使用协程!
https://medium.com/androiddevelopers/coroutines-on-android-part-ii-getting-started-3bff117176dd