[译] 如何优雅的处理协程的异常?

4,147 阅读8分钟

原文作者:Manuel Vivo

原文地址:Exceptions in Coroutines

译者:秉心说

本文是 协程的取消和异常 系列的第三篇,往期目录如下:

Coroutines: First things first

如何优雅的处理协程的取消?

在阅读本文之前,强烈建议回顾一下之前两篇文章。实在没有时间的话,至少读一下第一篇文章。

下面开始正文。


作为开发者,我们通常会花费大量时间来完善我们的应用。但是,当发生异常导致应用不按预期执行时尽可能的提供良好的用户体验也是同样重要的。一方面,应用 Crash 对用户来说是很糟糕的体验;另一方面,当用户操作失败时,提供正确的信息也是必不可少的。

优雅的异常处理对用户来说是很重要的。在这篇文章中,我会介绍在协程中异常是怎么传播的,以及如何使用各种方式控制异常的传播。

如果你更喜欢视频,可以观看 Florina Muntenescu 和我 在 KotlinConf'19 上的演讲,地址如下:

https://www.youtube.com/watch?v=w0kfnydnFWI&feature=emb_logo

为了帮你更好的理解本文的剩余内容,建议首先阅读该系列的第一篇文章 Coroutines: First things first

协程突然失败了?怎么办?😱

当一个协程发生了异常,它将把异常传播给它的父协程,父协程会做以下几件事:

  1. 取消其他子协程
  2. 取消自己
  3. 将异常传播给自己的父协程

异常最终将传播至继承结构的根部。通过该 CoroutineScope 创建的所有协程都将被取消。

在某些场景下,这样的异常传播是适用的。但是,也有一些场景并不合适。

想象一个 UI 相关的 CoroutineScope ,它负责处理用户交互。如果它的一个子协程抛出了异常,那么这个 UI Scope 将被取消。由于被取消的作用域无法启动更多协程,整个 UI 组件将无法响应用户交互。

如果你不想要这样怎么办?或许,在创建协程作用域的 CoroutineContext 时,你可以选择不一样的 Job 实现 —— SupervisorJob

让 SupervisorJob 拯救你

通过 SupervisorJob,子协程的失败不会影响其他的子协程。此外,SupervisorJob 也不会传播异常,而是让子协程自己处理。

你可以这样创建协程作用域 val uiScope = CoroutineScope(SupervisorJob()) ,来保证不传播异常。

如果异常没有被处理,CoroutineContext 也没有提供异常处理器 CoroutineExceptionHandler (稍后会介绍),将会使用默认的异常处理器。在 JVM 上,异常会被打印到控制台;在 Android 上,无论发生在什么调度器上,你的应用都会崩溃。

💥 无论你使用哪种类型的 Job,未捕获异常最终都会被抛出。

同样的行为准则也适用于协程作用域构建器 coroutineScopesupervisorScope 。它们都会创建一个子作用域(以 Job 或者 SupervisorJob 作为 Parent),来帮助你给协程从逻辑上分组(如果你想进行并行计算,或者它们是否会相互影响)。

警告:SupervisorJob 仅在属于下面两种作用域时才起作用:使用 supervisorScope 或者 CoroutineScope(SupervisorJob()) 创建的作用域。

Job 还是 SupervisorJob ?🤔

什么时候使用 Job ?什么时候使用 SupervisorJob

当你不想让异常导致父协程和兄弟协程被取消时,使用 SupervisorJob 或者 supervisorScope

看看下面这个示例:

// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(SupervisorJob())
scope.launch {
    // Child 1
}
scope.launch {
    // Child 2
}

在这样的情况下,child#1 失败了,scopechild#2 都不会被取消。

另一个示例:

// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(Job())
scope.launch {
    supervisorScope {
        launch {
            // Child 1
        }
        launch {
            // Child 2
        }
    }
}

在这种情况下,supervisorScope 创建了一个携带 SupervisorJob 的子作用域。如果 child#1 失败,child#2 也不会被取消。但是如果使用 coroutineScope 来代替 supervisorScope 的话,异常将会传播并取消作用域。

测试!谁是我的父亲 ?🎯

通过下面的代码段,你能确定 child#1 的父级是哪一种 Job 吗?

val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
    // new coroutine -> can suspend
   launch {
        // Child 1
    }
    launch {
        // Child 2
    }
}

child#1 的父 Job 是 Job 类型 !希望你回答正确!尽管第一眼看上去,你可能认为是 SupervisorJob,但并不是。因为在这种情况下,每个新的协程总是被分配一个新的 Job,这个新的 Job 覆盖了 SupervisorJobSupervisorJob 是父协程通过 scope.launch 创建的。也就是说,在上面的例子中,SupervisorJob 没有发挥任何作用。

The parent of child#1 and child#2 is of type Job, not SupervisorJob
The parent of child#1 and child#2 is of type Job, not SupervisorJob

所以,无论是 child#1 还是 child#2 发生了异常,都将传播到 scope,并导致所有由其启动的协程被取消。

记住 SupervisorJob 仅在属于下面两种作用域时才起作用:使用 supervisorScope 或者 CoroutineScope(SupervisorJob()) 创建的作用域。SupervisorJob 作为参数传递给协程构建器并不会产生你所预期的效果。

关于异常,如果子协程抛出了异常,SupervisorJob 不会进行传播并让子协程自己去处理。

原理

如果你好奇 Job 的工作原理,可以在 JobSupport.kt 文件中查看 childCancellednotifyCancelling 这两个函数的实现。

对于 SupervisorJob 的实现,childCancelled() 方法仅仅只是返回 false ,表示它不会传播异常,同时也不会处理异常。

异常的处理 👩‍🚒

在协程中,可以使用常规语法来处理异常:try/catch 或者内置的函数 runCatching (内部使用了 try/catch) 。

我们之前说过 未捕获的异常始终会被抛出 。但是不同的协程构建器对于异常有不同的处理方式。

Launch

在 launch 中,异常一旦发生就会立马被抛出 。因此,你可以使用 try/catch 包裹会发生异常的代码。如下所示:

scope.launch {
    try {
        codeThatCanThrowExceptions()
    } catch(e: Exception) {
        // Handle exception
    }
}

在 launch 中,异常一旦发生就会立马被抛出 。

Async

async 在根协程 (CoroutineScope 实例或者 supervisorJob 的直接子协程) 使用时,异常不会被自动抛出,而是直到你调用 .await() 时才抛出。

为了处理 async 抛出的异常,你可以在 try/catch 中调用 await

supervisorScope {
    val deferred = async {
        codeThatCanThrowExceptions()
    }
    try {
        deferred.await()
    } catch(e: Exception) {
        // Handle exception thrown in async
    }
}

在上面的例子中,async 的调用处永远不会抛出异常,所以这里并不需要包裹 try/catchawait() 方法将会抛出 async 内部发生的异常。

注意上面的代码中我们使用的是 supervisorScope 来调用 asyncawait 。就像之前说过的那样,SupervisorJob 让协程自己处理异常。与之相反的,Job 会传播异常,所以 catch 代码块不会被调用。

coroutineScope {
    try {
        val deferred = async {
            codeThatCanThrowExceptions()
        }
        deferred.await()
    } catch(e: Exception) {
        // Exception thrown in async WILL NOT be caught here 
        // but propagated up to the scope
    }
}

此外,由其他协程创建的协程如果发生了异常,也将会自动传播,无论你的协程构建器是什么。

举个例子:

val scope = CoroutineScope(Job())
scope.launch {
    async {
        // If async throws, launch throws without calling .await()
    }
}

在上面的例子中,如果 async 发生了异常,会立即被抛出。因为 scope 的直接子协程是由 scope.launch 启动的,async 继承了协程上下文中的 Job ,导致它会自动向父级传播异常。

⚠️ 通过 coroutineScope 构建器或者由其他协程启动的协程抛出的异常,不会被 try/catch 捕获!

SupervisorJob 那一节,我们提到了 CoroutineExceptionHandler 。现在让我们来深入了解它。

CoroutineExceptionHandler

协程异常处理器 CoroutineExceptionHandler 是 CoroutineContext 中的一个可选元素,它可以帮助你 处理未捕获异常

下面的代码展示了如何定义一个 CoroutineExceptionHandler 。无论异常何时被捕获,你都会得到关于发生异常的 CoroutineContext 的信息,和异常本身的信息。

val handler = CoroutineExceptionHandler {
    context, exception -> println("Caught $exception")
}

如果满足以下要求,异常将会被捕获:

  • 何时⏰ :是被可以自动抛异常的协程抛出的(launch,而不是 async
  • 何地🌍 :在 CoroutineScope 或者根协程的协程上下文中(CoroutineScope 的直接子协程或者 supervisorScope

让我们看两个 CoroutineExceptionHandler 的使用例子。

在下面的例子中,异常会被 handler 捕获:

val scope = CoroutineScope(Job())
scope.launch(handler) {
    launch {
        throw Exception("Failed coroutine")
    }
}

下面的另一个例子中,handler 在一个内部协程中使用,它不会捕获异常:

val scope = CoroutineScope(Job())
scope.launch {
    launch(handler) {
        throw Exception("Failed coroutine")
    }
}

由于 handler 没有在正确的协程上下文中使用,所以异常没有被捕获。内部 launch 启动的协程一旦发生异常会自动传播到父协程,而父协程并不知道 handler 的存在,所以异常会被直接抛出。


即使你的应用因为异常没有按照预期执行,优雅的异常处理对于良好的用户体验也是很重要的。

当你要避免因异常自动传播造成的协程取消时,记住使用 SupervisorJob ,否则请使用 Job

未捕获异常将会被传播,捕获它们,提供良好的用户体验!


这篇文章就到这里了,这个系列还剩最后一篇了。

在之前提到协程的取消时,介绍了 viewModelScope 等跟随生命周期自动取消的协程作用域。但是不想取消时,应该怎么做?下一篇将会为你解答。

我是秉心说,关注我,不迷路!

本文使用 mdnice 排版