本文是 协程的取消和异常 系列的第三篇,往期目录如下:
在阅读本文之前,强烈建议回顾一下之前两篇文章。实在没有时间的话,至少读一下第一篇文章。
下面开始正文。
作为开发者,我们通常会花费大量时间来完善我们的应用。但是,当发生异常导致应用不按预期执行时尽可能的提供良好的用户体验也是同样重要的。一方面,应用 Crash 对用户来说是很糟糕的体验;另一方面,当用户操作失败时,提供正确的信息也是必不可少的。
优雅的异常处理对用户来说是很重要的。在这篇文章中,我会介绍在协程中异常是怎么传播的,以及如何使用各种方式控制异常的传播。
如果你更喜欢视频,可以观看 Florina Muntenescu 和我 在 KotlinConf'19 上的演讲,地址如下:
https://www.youtube.com/watch?v=w0kfnydnFWI&feature=emb_logo
为了帮你更好的理解本文的剩余内容,建议首先阅读该系列的第一篇文章 Coroutines: First things first
协程突然失败了?怎么办?😱
当一个协程发生了异常,它将把异常传播给它的父协程,父协程会做以下几件事:
- 取消其他子协程
- 取消自己
- 将异常传播给自己的父协程
异常最终将传播至继承结构的根部。通过该 CoroutineScope
创建的所有协程都将被取消。
在某些场景下,这样的异常传播是适用的。但是,也有一些场景并不合适。
想象一个 UI 相关的 CoroutineScope
,它负责处理用户交互。如果它的一个子协程抛出了异常,那么这个 UI Scope 将被取消。由于被取消的作用域无法启动更多协程,整个 UI 组件将无法响应用户交互。
如果你不想要这样怎么办?或许,在创建协程作用域的 CoroutineContext
时,你可以选择不一样的 Job
实现 —— SupervisorJob
。
让 SupervisorJob 拯救你
通过 SupervisorJob,子协程的失败不会影响其他的子协程。此外,SupervisorJob
也不会传播异常,而是让子协程自己处理。
你可以这样创建协程作用域 val uiScope = CoroutineScope(SupervisorJob())
,来保证不传播异常。
如果异常没有被处理,CoroutineContext
也没有提供异常处理器 CoroutineExceptionHandler (稍后会介绍),将会使用默认的异常处理器。在 JVM 上,异常会被打印到控制台;在 Android 上,无论发生在什么调度器上,你的应用都会崩溃。
💥 无论你使用哪种类型的 Job,未捕获异常最终都会被抛出。
同样的行为准则也适用于协程作用域构建器 coroutineScope 和 supervisorScope 。它们都会创建一个子作用域(以 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
失败了,scope
和 child#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 覆盖了 SupervisorJob
。SupervisorJob
是父协程通过 scope.launch
创建的。也就是说,在上面的例子中,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
文件中查看 childCancelled 和 notifyCancelling 这两个函数的实现。
对于 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/catch
。await()
方法将会抛出 async 内部发生的异常。
注意上面的代码中我们使用的是 supervisorScope
来调用 async
和 await
。就像之前说过的那样,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
等跟随生命周期自动取消的协程作用域。但是不想取消时,应该怎么做?下一篇将会为你解答。