原文链接:https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c
我们(开发人员)通常会花费大量时间来打磨我们的应用。 但同样重要的是在意外情况下给用户带来舒适的用户体验。 一方面来说,看到应用程序崩溃对于用户是一种糟糕的体验; 另一方面,在操作未成功时向用户显示正确的消息也是必不可少的。
正确处理异常会对用户如何评价您的应用程序产生巨大影响。 在本文中,我们将说明协程中如何传播异常以及如保持对协程的控制,包括处理异常的不同方法。
携程突然报错!现在怎么办?
当协程发生异常而失败时,这个协程会把该异常传播到它的父级! 然后,父级将1)取消其余的子协程,2)取消自身,3)将异常传播到其(这里指前面提到的父级)父级。
异常将到达层次结构的根,并且CoroutineScope启动的所有协程也将被取消。
图:协程中的异常将在整个协程层次结构中传播
尽管在某些情况下传播异常可能是有道理的,但在其他情况下则是不希望出现的。 想象一下一个与 UI 相关的 CoroutineScope,它可以处理用户交互。 如果子协程抛出异常,则 UI 协程作用域将被取消,并且整个 UI 组件将变得无响应,因为被取消的协程作用域无法启动更多协程。
如果您不想要这种行为怎么办? 或许,您可以在创建这些协程的 CoroutineScope 的 CoroutineContext 中使用 Job 的不同实现,即 SupervisorJob。
用 SupervisorJob 挽回局面
使用 SupervisorJob,一个子协程的错误不会影响其他子协程。 SupervisorJob 不会取消自己或其子协程。 此外,SupervisorJob 也不会传播异常,而是让子协程(出错的那个)对其进行处理。
您可以创建一个像这样的 val uiScope = CoroutineScope(SupervisorJob()) 的 CoroutineScope,以便在协程失败时不传播取消事件,如下图所示:
图:SupervisorJob 不会取消自己或其子协程
如果异常没有被处理,并且 CoroutineContext 没有 CoroutineExceptionHandler(我们将在后面看到),这个异常将抵达默认线程的 ExceptionHandler 。 在 JVM 中,异常将被记录到控制台。 在Android中,无论发生在那种 Dispatcher(主线程或者IO 线程等等) 中,都会使您的应用崩溃。
不论使用的是什么类型的Job, 没有被捕获的异常都会被抛出
相同的行为也适用于协程构建器 coroutineScope 和 supervisorScope。 这些构建器将创建一个子作用域(相应地具有 Job 或 SupervisorJob作为父级),您可以使用该子作用域对协程进行逻辑分组(例如,如果您要执行并行计算,也可能是希望它们相互影响或不相互影响)。
警告:仅当 SupervisorJob 属于协程作用域的一部分时,它才会按照我们前面所描述的那样工作:使用 supervisorScope 或 CoroutineScope(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 失败, child#2 和 scope 都不会被取消。
另一个例子:
// 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,则失败将传播并最终导致作用域被取消。
太奇怪了!谁是我爹?
给定以下代码段,您能否确定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,在这种情况下,这个Job 覆盖了 SupervisorJob。 SupervisorJob 是使用 scope.launch 创建的协程的父级; 因此,从字面上看,SupervisorJob 在该代码中不执行任何操作!(有点绕,但是请仔细体会)
图:child#1 and child#2 的父级类型是Job,而不是 SupervisorJob
因此,如果child#1或child#2失败,则该失败将抵达scope,并且该 scope 启动的所有 Job 都将被取消。
切记,仅当 SupervisorJob 属于协程作用域的一部分时,它才会按照我们前面所描述的那样工作:不论是使用 supervisorScope 还是 CoroutineScope(SupervisorJob())去创建。将S upervisorJob 作为协程生成器的参数传递不会达到您期望中的取消协程的效果。
如果有任何子协程抛出异常,则 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
}
}
启动后,异常一旦发生就会抛出
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) {
// 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 的直接子协程是 launch(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 将捕获异常:
何时:那些能够自动抛出异常的协程(例如:launch 启动的,async则不行)抛出异常时。
何地:当 CoroutineExceptionHandler 被装填在 CoroutineScope的CoroutineContext中,或者,被装填在根coroutine(CoroutineScope的直接子级或supervisorScope)中。
我们来看一些使用上面定义的 CoroutineExceptionHandler 的示例。 在以下示例中,异常将被处理程序捕获:
val scope = CoroutineScope(Job())
scope.launch(handler) {
launch {
throw Exception("Failed coroutine")
}
}
当 CoroutineExceptionHandler 被装填在内部协程中时,异常不会被捕获:
val scope = CoroutineScope(Job())
scope.launch {
launch(handler) {
throw Exception("Failed coroutine")
}
}
未捕获到异常,因为未在正确的 CoroutineContext 中装填 CoroutineExceptionHandler。 内部的 launch 会在异常发生后立即将异常传播到父级,因为父级对 CoroutineExceptionHandler 一无所知,因此将抛出异常。在应用程序中优雅地处理异常对于获得良好的用户体验来说是非常重要的,即使这些处理并没有达到预期效果。
使用 SupervisorJob 来避免在发生异常时传播取消事件,否则请使用Job。
未捕获的异常将被传播,捕获异常来提供出色的UX!