我将翻译三篇介绍协程的 取消 和 异常处理 相关的文章,三篇文章是层层递进的关系。翻译过程中我将尽量忠实于原文。当然,由于水平有限,不能保证完全的翻译正确。如果您发现翻译有错误之处,欢迎在评论里指出。我也将贴出每篇翻译的原文。
- 第一篇:《协程:第一件事》(原文: Coroutines: first things first)
- 第二篇:《协程的取消》(原文:Cancellation in coroutines)
- 第三篇:《协程的异常》(原文:Exceptions in coroutines)
这是第三篇。
文章目录
我们开发者通常花费大量时间打磨应用的正常使用场景。然而,当应用发生意外时,合适的用户体验也同样重要。一方面,对用户来说应用发生崩溃是糟糕的体验;另一方面,当操作执行失败时,给用户提示适当的信息是必不可少的。
合理的异常处理将对用户如何看待你的应用产生很大的影响。在这篇文章中,我们将解释协程中的异常如何传播,以及如何通过多种方式控制它们。
协程突然失败了!怎么办?😱
当一个协程因为异常失败时,它会把异常传播给它的父节点!然后,这个父节点会 1) 取消其余的子协程,2) 取消自己, 3) 把异常传播给自己的父节点。
异常将被传播到层次结构的根节点,所有由这个根节点 CoroutineScope
发起的协程都将被取消。
(协程中发生的异常将通过协程的层次结构向上传播)
有些情况下这种异常传播是合理的,然而也有些情况不需要这样。试想,有一个与UI相关的 CoroutineScope
,用来处理用户交互。如果其中一个子协程抛了异常,那么这个UI scope
将被取消,并且整个UI模块将变得无法响应,因为一个被取消的 scope
不能再启动协程。
如果你不想要这样的行为该怎么办?作为替代,你可以在创建协程的 CoroutineScope
的 CoroutineContext
中用一个不同的 Job
实现,名叫 SuervisorJob
。
拯救者 SupervisorJob
使用 SupervisorJob
,子协程的失败不会影响其余子协程。一个 SupervisorJob
不会取消它自己或它的子协程。而且,SupervisorJob
也不会传播异常,它会让子协程自己处理异常。
你可以这样创建一个 CoroutineScope
:
val uiScope = CoroutineScope(SupervisorJob())
当一个协程失败时,它不会传播取消,如下图所示:
(一个 SupervisorJob
不会因为异常取消自身或其余的子协程)
如果异常没有被处理,并且 CoroutineContext
没有 CoroutineExceptionHandler
,异常将到达默认线程的 ExceptionHandler
。在JVM中,异常将被记录到控制台;在Android中,不管异常发生在哪个线程,都将导致app崩溃。
💥未捕获的异常总会被抛出,不管你使用哪种
Job
同样的行为也适用于 scope
构建器 coroutineScope
和 supervisorScope
。这两种构建器将创建一个子 scope
(使用 Job
或 SupervisorJob
),你可以用它们来组织协程(比如说你想做并行计算,或者你希望它们相互影响or相互不影响)。
提醒:SupervisorJob
只有在其作为 scope
的一部分时才起作用,要么使用 supervisorScope
创建,要么使用 CoroutineScope(SuperVisorJob())
创建。
Job 还是 SupervisorJob ?🤔
什么时候该使用 Job
或者 SupervisorJob
?当你不希望失败导致父节点或同级节点被取消时,就使用 SupervisorJob
或者 supervisorScope
。
一个例子:
val scope = CoroutineScope(SupervisorJob())
scope.launch {
// 子协程1
}
scope.launch {
// 子协程2
}
在这个例子中,如果 子协程1
失败了,scope
将不会被取消,子协程2
也不会被取消。
另一个例子:
val scope = CoroutineScope(Job())
scope.launch {
supervisorScope {
launch {
// 子协程1
}
launch {
// 子协程2
}
}
}
在这个例子中,由于 supervisorScope
创建了一个使用 SupervisorJob
的子 scope
,如果 子协程1
失败了,子协程2
将不会被取消。相反,如果你在该实现中使用 coroutineScope
,那么失败将被传播并且整个 scope
都将被取消。
测试题!父节点是谁?🎯
给出下面的代码片段,你能确定 子协程1
的父节点是哪种 Job
类型吗?
val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
//新协程
launch {
// 子协程1
}
launch {
// 子协程2
}
}
子协程1
的 parent Job
是 Job
类型!希望你答对了!虽然第一眼看上去,你可能会觉得是一个 SupervisorJob
,然而并不是,因为一个新的协程总是会被赋予一个新的 Job()
,它在这个例子中覆盖了 SupervisorJob
。SupervisorJob
是通过 scope.launch
创建的协程的 parent job
;因此,可以说 SupervisorJob
在这段代码中没起任何作用。
因此,如果 子协程1
或者 子协程2
失败,失败会到达 scope
,所有在这个 scope
中开始的工作都将被取消。
记住
SupervisorJob
只有在其作为scope
的一部分时才起作用,要么使用supervisorScope
创建,要么使用CoroutineScope(SuperVisorJob())
创建。
把 SupervisorJob
传递给协程构建器作为参数,不会产生你认为的取消效果。
关于异常,如何任何一个子协程抛出异常,SupervisorJob
不会在层次结构中传播该异常,而会让协程来处理它。
底层实现
如果你好奇 Job
在底层是如何工作的,可以查看 JobSupport.kt
文件中的 childCancelled
方法和 notifyCancelling
方法。
在 SupervisorJob
的实现中,childCancelled
方法只返回 false
,意味着它不会传播取消,也不会处理异常。
处理异常 👩🚒
协程使用Kotlin的常规语法来处理异常:try/catch
,或者内建的帮助方法像 runCatching
(其内部也是用 try/catch
)。
我们前面说过未捕获的异常总是会被抛出,然而,不同的协程构建器处理异常的方式不一样。
Launch
使用 launch
,异常一发生立马就会被抛出。因此,你可以用 try/catch
把可能发生异常的代码包住,如下:
scope.launch {
try {
codeThatCanThrowExceptions()
} catch(e: Exception) {
// Handle exception
}
}
使用
launch
,异常一发生立马就会被抛出。
Async
当 async
作为 根协程
(CoroutineScope
或者 supervisorScope
的直接子协程),异常不会自动抛出,相反,当你调用 .await()
方法时它们才被抛出。
要处理 async
作为根协程时里面抛出的异常,你可以把 .await()
调用包裹到 try/catch
里面:
supervisorScope {
val deferred = async {
codeThatCanThrowExceptions()
}
try {
deferred.await()
} catch(e: Exception) {
// Handle exception thrown in async
}
}
在这个例子中,调用 async
永远不会抛异常,所以不必包裹它。await
会抛出 async
协程中发生的异常。
当
async
作为根协程,调用.await
的时候异常才会被抛出。
也要注意我们是在 supervisorScope
中调用 async
和 await
。我们前面说过,SupervisorJob
会让协程自己处理异常;而 Job
会自动向上传播异常,因此 catch
代码块将不会被调用:
coroutineScope {
try {
val deferred = async {
codeThatCanThrowExceptions()
}
deferred.await()
} catch(e: Exception) {
// async 中发生的异常不会在这里被捕获
// 异常会传给scope
}
}
而且,在协程中创建的协程,不管其协程构建器是怎样的,其中的异常总是会传播。例如:
val scope = CoroutineScope(Job())
scope.launch {
async {
// 如果asyn发生异常,launch将会抛异常,不管有没有调用.awiat()
}
}
在这个例子中,如果 async
发生了异常,它将立马被抛出,因为 scope
的直接子协程是 launch
。原因是 async
(其 CoroutineContext
里包含的是 Job
)会自动把异常传播给其父节点(launch
),然后抛出异常。
⚠️
coroutineScope
构建器中抛出的异常,或由协程创建的协程中抛出的异常,不会被try/catch
捕获!
在 SupervisorJob
章节中,我们提到了 CoroutineExceptionHandler
。让我们深入了解一下吧!
CoroutineExceptionHandler
CoroutineExceptionHandler
是 CoroutineContext
的一个可选元素,用来处理未被捕获的异常。
下面是如何定义一个 CoroutineExceptionHandler
,当一个异常被捕获,你可以获取发生异常所在的 CoroutineContext
的信息,以及异常本身:
val handler = CoroutineExceptionHandler { context, exception ->
println("Caught $exception")
}
如果 CoroutineExceptionHandler
满足以下条件,异常就会被捕获:
- 当 ⏰:异常是被协程自动抛出的(
handler
配合launch
使用,而不是async
) - 在 🌍:如果
handler
存在于在CoroutineScope
或者根协程(CoroutineScope
或supervisorScope
的直接子协程)的CoroutineContext
。
让我们使用上面定义的 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
没有初始化于正确的 CoroutineContext
。在内部的 launch
中,异常一旦发生就会立马向上传播到父节点,由于父节点并不知道 handler
,所以就会被抛出。
应用中合理的异常处理对于良好的用户体验非常重要,即使事情没有按照预期发展。
当异常发生的时候,如果想避免取消的传播,记得使用 SupervisorJob
;相反,则使用 Job
。
未捕获的异常将被传播,捕获它们以提供好的用户体验!