原文:https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c
文章目录
我们开发人员通常会花费大量时间来完善我们的应用程序的快乐路径。然而,当事情没有按预期进行时,提供适当的用户体验同样重要。一方面,看到应用程序崩溃对用户来说是一种糟糕的体验;另一方面,当一个操作没有成功时向用户显示正确的信息是必不可少的。
正确处理异常会对用户如何看待您的应用程序产生巨大影响。在本文中,我们将解释异常如何在协程中传播以及您如何始终处于控制之中,包括处理它们的不同方法。
一个协程突然失败了!现在怎么办?😱
当协程因异常而失败时,它会将该异常传播到其父级!然后,父级将
- 取消其其余子级,
- 取消自身,
- 将异常传播到其父级。
异常将到达层次结构的根,所有 CoroutineScope 启动的协程也将被取消。
协程中的异常将在整个协程层次结构中传播
虽然在某些情况下传播异常是有意义的,但在其他情况下这是不期望的。想象一个处理用户交互的 UI 相关的 CoroutineScope。如果子协程抛出异常,UI scope 将被取消,整个 UI 组件将变得无响应,因为已被取消的 scope 无法启动更多的协程。
如果你不想要这种行为怎么办?作为选择,您也可以在创建这些协程的 CoroutineScope 的 CoroutineContext 中,使用不一样的 Job 实现,即 SupervisorJob。
SupervisorJob 来拯救
有了 SupervisorJob,子项的失败不会影响其他子项。一个 SupervisorJob 不会取消自己或它的其他子项。此外,SupervisorJob 也不会传播异常,并让子协程自己处理它。
您可以像这样 val uiScope = CoroutineScope(SupervisorJob()) 创建一个 CoroutineScope,用于在协程失败时不传播取消。下图形象描绘了:
SupervisorJob 不会取消自己或它的其他子项
如果异常没有被处理并且 CoroutineContext 没有 CoroutineExceptionHandler(我们稍后会看到),它将到达默认的线程 ExceptionHandler。在 JVM 中,异常会被记录到控制台;而在 Android 中,无论它是发生在哪个 Dispatcher,它都会使您的应用程序崩溃。
💥 无论你使用哪种 Job,未捕获的异常都会被抛出
相同的行为适用于 scope 构建器 coroutineScope 和 supervisorScope。这些将创建一个 sub-scope(以 Job 或 SupervisorJob 相应地作为父项),您可以使用该 sub-scope 对协程进行逻辑分组(例如,如果您想进行并行计算或希望它们相互影响或不相互影响)。
警告: SupervisorJob 仅在它是通过 supervisorScope 或 CoroutineScope(SupervisorJob()) 创建的 scope 的一部分时才按描述工作。
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 的 sub-scope,如果 child#1 失败,child#2 将不会被取消。相反,如果您在实现中使用 coroutineScope,则失败将被传播并最终取消 scope。
当心测验!谁是我的父母?🎯
给定以下代码片段,您能识别出 Job child#1 的父级的类型吗?
val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
// new coroutine -> can suspend
launch {
// Child 1
}
launch {
// Child 2
}
}
child#1 的 parentJob 是类型 Job!希望你做对了!尽管在第一印象中,您可能认为它可能是 SupervisorJob,但这并不是,因为一个新的协程总是被分配一个新的 Job(),在这种情况下会覆盖 SupervisorJob。SupervisorJob 是用 scope.launch 创建的协程的父级;所以从字面上看,SupervisorJob 在该代码中什么都不做!
child#1 和 child#2 的父级的类型是 Job,而不是 SupervisorJob
因此,如果 child#1 或 child#2 失败,则失败将到达 scope 并且该 scope 开始的所有工作都将被取消。
(此处可能有误,参见原文评论,但本人测试确实是都被取消)
请记住,SupervisorJob 仅在它是通过 supervisorScope 或 CoroutineScope(SupervisorJob()) 创建的 scope 的一部分时才按描述工作。
将 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 将永远不会抛出异常,这就是为什么没有必要也把它包装在 try/catch 块中。await 将抛出发生在 async 协程内部的异常。
当 async 用作根协程时,调用 .await 时抛出异常
另外,请注意我们使用 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
**(注意这个结论是错的,实际上 await 和 coroutineScope 都会抛出异常,参见原文评论,本人测试也是如此,都收到了异常)**
}
}
此外,无论协程构建器如何,在其他协程创建的协程中发生的异常将始终传播。例如:
val scope = CoroutineScope(Job())
scope.launch {
async {
// If async throws, launch throws without calling .await()
**(异常传播到了launch,但是try-catch launch无效,要直到父协程的handler来处理)**
}
}
在这种情况下,如果 async 抛出异常,它将在发生时立即抛出,因为它的直接父协程是 launch。原因是 async(其 CoroutineContext 带有 Job)将自动将异常向上传播到它的会抛出异常的父级(launch)。
⚠️ 在 coroutineScope 构建器或其他协程创建的协程中抛出的异常不会在 try/catch 中被捕获!
在 SupervisorJob 节中,我们提到了 CoroutineExceptionHandler 的存在。让我们深入了解吧!
协程异常处理器 CoroutineExceptionHandler
CoroutineExceptionHandler 是 CoroutineContext 的一个可选的让您处理未捕获的异常的元素。
以下是定义 CoroutineExceptionHandler 的方法,无论何时捕获异常,您都会获得有关异常发生所在的 CoroutineContext 和异常本身的信息:
val handler = CoroutineExceptionHandler {
context, exception -> println("Caught $exception")
}
如果满足这些要求,将捕获异常:
- When ⏰: 异常由自动抛出异常的协程抛出(launch 可以,而 async 不行)。
- Where 🌍:如果它在 CoroutineScope 或根协程(CoroutineScope 或 supervisorScope 的直接子项)的 CoroutineContext 中。
让我们看一些使用上面例子定义的 CoroutineExceptionHandler。在以下示例中,异常将被异常处理器捕获:
val scope = CoroutineScope(Job())
scope.launch(handler) {
launch {
throw Exception("Failed coroutine")
}
}
在异常处理器安装在内部协程中的另一种情况下,它不会被捕获:
val scope = CoroutineScope(Job())
scope.launch {
launch(handler) {
throw Exception("Failed coroutine")
}
}
未捕获异常是因为异常处理器未安装在正确的 CoroutineContext。内部 launch 将在异常发生后立即将异常传播给父级,因为父级对异常处理器一无所知,因此将抛出异常。
在您的应用程序中优雅地处理异常对于获得良好的用户体验很重要,即使事情没有按预期进行。
请记住在您想避免在发生异常时传播取消时使用 SupervisorJob,否则使用 Job。
未捕获的异常将被传播,捕获它们以提供出色的用户体验!