文章

[译] Coroutines on Android(二)起步

本系列文章主要翻译自 medium-AndroidDevelopers.

本篇将开始整合协程与 Android,探索如何启动并跟踪协程,以便适配 UI 生命周期。

为何跟踪协程

在第一篇中,我们探索了协程能解决的问题。总结一下,协程是解决这两个问题的优秀方案:

  1. 在主线程运行长时间任务导致阻塞。
  2. 从主线程上安全地调用一切 suspend 函数。也即主线程安全(Main-safety)

为了解决这些问题,协程通过给常规函数添加 suspendresume 操作,使得协程被挂起时在单独线程执行,主线程可以继续执行其他工作。

但是,协程本身不能跟踪正在执行的工作。协程本身很轻量,你完全可以同时启动大量的协程,甚至成百上千个。

如果要手动编写代码跟踪这几千个协程的进度将非常困难。也许能够追踪他们,确保他们完成或者取消,但这非常枯燥且容易出错。稍有不注意,就会失去对协程的跟踪,也就是协程泄露

协程泄露就像内存泄露,但是更严重。如果协程泄露,除了占用内存,还会占用 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. 捕获协程中的异常

让我们深入研究每一个,看看结构化并发如何帮助我们确保始终跟踪所有协程。

使用 scopes 取消任务

在 Kotlin,协程必须在一个叫做 CoroutineScope 内执行。CoroutineScope 保持了对所有协程的跟踪,尽管被挂起也是这样。不像之前我们讨论的 Dispatchers,Scope 实际上不执行协程,只是确保你不会跟丢或忘记它们。

为了保证所有协程都被跟踪,Kotlin 不允许在没有 CoroutineScope 的情况下启动协程。Scope 能够启动具备我们上一节讨论的挂起与恢复能力的协程。

CoroutineScope 跟踪了所有协程,所以它可以取消它所启动的协程。这非常适合 Android,因为你希望当用户离开界面时终止数据加载操作。

CoroutineScope 跟踪所有它启动的线程,并可以取消他们。

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

启动新协程

需要注意的是,suspend 函数不是任何地方都能调用的。挂起和恢复机制需要你从常规的环境切换到协程环境。

有两个方式启动协程,他们有不同的用处:

  • launch 构建器类似甩手掌柜,启动协程后就随他去,不会取得返回值。(但会保持跟踪)
  • async 构建器在启动协程后,允许通过调用 await 取得返回值。

在大多数情况下都使用 launch 启动协程。因为常规函数不能直接调用 suspend 函数,因此返回值没有太大意义。当然,后面我们会讨论它是适用场景。

我们可以利用 launch 来这样启动一个协程:

scope.launch {
    // This block starts a new coroutine 
    // "in" the scope.
    // 
    // It can call suspend functions
   fetchDocs()
}

可以把 launch 看做是连接常规环境与协程环境的桥梁。在 launch 内部可以调用 suspend 函数。

Launch 是常规函数与协程的桥梁。

Launch is a bridge from regular functions into coroutines.

注意: launchasync 很大的区别是如何处理异常。async 假设你一定会调用 await 来获取返回值(以及异常),所以默认不会抛出。这意味着如果你使用 async 启动协程,他会「吞掉异常」。

由于 launchasync 只能在 CoroutineScope 上使用,所以任何你启动的协程都会被 scope 跟踪。Kotlin 禁止你创建未被跟踪的协程,从而避免协程泄露。

在 ViewModel 中启动

那么既然 CoroutineScope 跟踪了协程,launch 启动了协程。那么我们应该在哪调用 launch 又怎么样设置 scope 呢?又该在何时取消他们呢?

在 Android 中,经常把 CoroutineScope 与 UI 相关联。这可以在 ActivityFragment 销毁是避免协程泄露或执行额外的工作。当用户离开应用屏幕时,应该取消协程。

结构化并发保证当 scope 取消时,所以协程都被取消。

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

当使用 Android 架构组件时,你一定想在 ViewModel 中启动协程。这是非常自然的,因为 ViewModel 往往是工作开始的地方,而且不用担心屏幕旋转会意外地取消协程。

ViewModel 中使用协程,可以利用 lifecycle-viewmodel-ktx:2.1.0-alpha04 或更高版本中提供的 viewModelScope。其工作原理可以参见这篇文章

来看一个例子:

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

ViewModel 销毁(onCleared)时,viewModelScope 会自动取消,这正是我们所期望的行为——如果用户已经关闭界面,那么继续获取文档实际上是一种浪费。

除此之外,CoroutineScope 会进行传播。如果在一个协程中启动了新的协程,他们都会受到顶层 scope 的控制。这意味着即使我们使用的依赖库在 viewModelScope 中启动了协程,我们也不必关系,在必要的时候总可以自动取消。

警告: 当被挂起的协程被取消时,会抛出一个 CancellationException 异常。Top-level 级别的异常处理器会捕获这个异常。如果使用自定义异常处理消耗了此异常,或者协程没有被挂起,那么协程将停留在半取消状态。

所以,当需要协程的生命周期与 ViewModel 保持一致时,应当使用 viewModelScope 来从常规环境切换到协程环境。既然 viewModelScope 会在必要的时候自动取消协程,因此完全可以在这里创建一个死循环而无需 break 或其他终止条件。比如下面这样:

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,能够确保包括死循环在内的一切工作在不再需要时自动终止。

保持跟踪协程

协程中的协程

启动协程非常方便,而且很多情况下适合启动协程,例如一个网络请求或者写入数据库。

不过有时候,需求会更加复杂。假如现在想在协程中同时进行两个网络请求,所以不得不启动更多的协程。

任何 suspend 函数都可以使用一个叫做 coroutineScopesupervisorScope 的构建器启动更多的协程。这两个 API 说实话有点迷惑。尽管 coroutineScopeCoroutineScope 尽管只有一个大小写的差异,但却是两个完全不同的东西。

在任何地方随意的启动协程有可能导致协程泄露。这些函数的调用者也许根本不知道新协程的存在,哪又何谈跟踪他们?

幸运的是结构化并发帮我们解决了这个问题。也就是,它能保证,当一个 suspend 函数返回时,他所有的工作(包括协程)已经确实完成了。

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

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,这里仅仅是展示,在需要的时候,可以混用 launchasync

coroutineScopesupervisorScope 让你可以安全地从 suspend 函数中启动新的协程。

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

注意一点,这段代码没有显示地等待协程结束!看起来 fetchTwoDocs 会直接返回,而此时内部的协程正在运行!

为了实现结构化并发避免协程泄露,我们希望确保一个 suspend 函数返回时,它所有的工作已经完成了,就像 fetchTwoDocs 那样。这意味着新启动的两个协程必须先完成,然后函数才可以返回。

通过 coroutineScope 构建器,Kotlin 做到了这一点。coroutineScope 会挂起自己,直到它内部启动的协程全部完成。因此,在 coroutineScope 内部的协程完全之前,函数是不会返回的。

许多许多的工作

现在我们已经探索了如何跟踪一个、两个协程,是时候尝试跟踪上千个协程了。

看看下面的动画:

展示了 coroutineScope 如何跟踪上千个协程

这个动画展示了同时发出1000个网络请求。仅仅是一个例子,实际工作中不推荐这么做,这样会消耗大量的资源。

这段代码中,我们使用 launchcoroutineScope 构建器中启动了1000个协程。你可以看到这些协程是如何被追踪的。既然我们已经处于一个 suspend 函数中,那么在某个地方,肯定已经使用 CoroutineScope 创建了一个协程(因为 suspend 函数只能在协程环境中调用)。对于这个 CoroutineScope 我们一无所知,它也许是 viewModelScope 或者是其他类型的 scope,不管它是什么,coroutineScope 会把他作为父 scope 并创建一个新的 scope。

然后,在 coroutineScope 块内部,launch 会在这个新的 scope 中启动协程,因此这个新的 scope 会保持跟踪这些协程。最后,当所有 coroutineScope 内部的协程都完成后,这个函数就可以返回了。

注意: scope 与协程之间的父子关系是 Job 对象所维系的。但是大多数情况下你不需要深究这层问题。

coroutineScopesupervisorScope 会等待内部的协程结束。

coroutineScope and supervisorScope will wait for child coroutines to complete.

其实这背后非常复杂但无需关心,重要的是记住利用 coroutineScopesupervisorScope 可以安全地在 suspend 函数中启动新协程。而且不用担心协程泄露,因为在他们完成之前函数会被挂起,不会返回。

最酷的是 coroutineScope 会创建一个子 scope。所以如果上层 scope 被取消了,将会传递到子 scope。假设上层是 viewModelScope,这1000个协程在用户关闭界面时会自动取消!

在我们讨论异常处理之前,有必要花点时间比较一下 supervisorScopecoroutineScope。最主要的区别就是当任何一个子协程出现异常时,coroutineScope 就会整体取消。因此如果一个网络请求失败了,那么所有的请求都会被立即取消。相反,如果你想继续其他请求,可以使用 supervisorScope

协程的异常传递

在协程中,错误通过抛出异常来传递,就像普通的函数那样。来自 suspend 函数的异常将会通过 resume 再次抛出给调用者。就像处理普通函数的异常,除了 try/catch,你也可以使用其他方式来执行异常捕获。

当然,有时协程中的异常会丢失「被吞掉」。

val unrelatedScope = MainScope()
// 异常丢失的例子
suspend fun lostError() {
    // 没有使用结构化并发
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}

注意,这个代码创建了一个全新的与上层 scope 完全不相关的 scope,没有使用结构化并发。记得一开始就说过,结构化并发是协程特性与最佳实践的结合,在 suspend 函数中引入独立的 scope 是完全违背这一原则的——请不要这么做。

这个错误将会丢失,因为 async 假设你一定会调用 await,异常将在此时抛出。当然,如果你永远不调用 await,异常将保存起来,耐心地等待被触发。

结构化并发保证了当协程出错时,它的调用者或 scope 会得到通知。

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

如果你使用了结构化并发,那么异常就会正确地传播给上层:

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

因为 coroutineScope 会等待所有子协程结束,所以当发生异常时会收到通知。如果 coroutineScope 内的协程抛出了异常,coroutineScope 会把它传递给调用者(上层),并且自动取消其他子协程。

使用结构化并发

这一篇中我介绍了结构化并发,如何使用它来整合 ViewModel 避免协程泄露。

我们还讨论了它如何使 suspend 函数逻辑更加清晰可控。它既保证函数返回前完成所有工作,也保证了异常的正确传播。

想法,如果我们不遵守这一原则,那么很容易出现协程的泄露,这些协程不能被及时取消,也无法正确传递异常。这将使代码执行逻辑混乱,造成很多诡异的 bug。

你仍然可以通过引入一个完全不相关的 scope CoroutineScope(注意首字母大写)或使用全局作用域 GlobalScope 来创建非结构化的并发,只有在非常罕见的情况下才有必要这么做——你希望协程的生命周期长于其他组件。且尽量自己维护好结构,确保协程可以被跟踪与取消,并正确处理异常。

如果你经常使用非结构化并发,那么可能需要一段时间来适应。但结构化并发确实使得与 suspend 函数的交互更加安全与简单。强烈推荐你使用这套方案,这样代码更简洁,逻辑更清晰而不会有出人意料的行为。

在文章的开始,列举了结构化并发帮助我们解决的三个问题:

  1. 当不再需要时取消任务
  2. 对运行中的任务保持追踪
  3. 捕获协程中的异常

为了实现这一目标,结构化并发提供了下面这些保证:

  1. 当 scope 取消时,它所有的协程全部取消。
  2. 当 suspend 函数返回时,它所有的工作(包括协程)都已经完成。
  3. 当协程出现异常,它的调用者会收到通知。

综合这些特性,结构化并发使得我们代码更安全,更简单,更易懂,并且避免协程泄露!

总结

这一章中我们探索了如果在 Android ViewModel 中启动协程,以及结构化并发如何大幅优化我们的代码逻辑。

下一章中我们将更多地讨论实际项目中如何使用协程。