作为开发者,我们通常会花费大量的时间来完善我们的应用程序。然而,当事情不尽如人意时,提供合适的用户体验也同样重要。一方面,看到应用程序崩溃对用户来说是一种很糟糕的体验;另一方面,当一个操作失败时,向用户显示正确的提示信息也是必不可少的。 正确的处理异常对用户如何看待我们的App尤为重要。在本文中,我们将解释异常时如何在协程中传播的,以及如何始终进行控制,包括处理异常的不同方法。
⚠️ 为了无障碍的阅读本文,请阅读和理解本系列的第一部分:协程基础和第二部分:协程的取消
一个协程突然失败了!现在该怎么办?😱
当协程因出现异常失败时,它会将异常传播到它的父级,然后,父级将进行如下三步:1)取消其余的子协程,2)取消自身,3)将异常在传播给它的父级。
异常将最终到达当前层次结构的根,在当前协程作用域启动的所有协程都将被取消。
虽然在某些情况下传播异常是有意义的,但是大多数情况下这样做是不可取的。设想一个处理用户交互的与UI相关的协程作用域,如果一个子协程抛出了异常,UI的作用域将被取消,整个UI组件将变得无响应,因为已经取消的协程作用域无法再此启动协程。
如果这不是我们的预期行为我们该怎么办呢?作为一种选择,我们可以在当前协程作用域的上下文中使用Job 的另一种实现:SupervisorJob 。
使用SupervisorJob处理
使用SupervisorJob ,子协程的失败不会影响到其他子协程。SupervisorJob 不会取消自身或它的其他子协程,而且SupervisorJob 不会传播异常而是让它的协程处理。
你可以像这样val uiScope = CoroutineScope(SupervisorJob())
创建一个SupervisorJob ,以便在协程因异常失败时不会传播,如下图所示;
如果异常没有被处理,并且协程上下文(CoroutineContext)中没有CoroutineExceptionHandler (我们将在后面讲它),那么它将到达默认线程的ExceptionHandler 。在JVM中,异常将被记录到控制台;在Android中,不论它发生在哪个调度器中都会使App崩溃。
无论我们使用何种类型的Job,未捕获的异常最终都会被抛出。
同样的行为也适用于作用域构建器 coroutineScope{...}
和supervisorScope{...}
,它们将创建一个子范围(分别使用Job 或SupervisorJob 作为参数),我们可以使用它在逻辑上对协程进行分组(例如,在执行并行任务时,我们希望它们是相互影响的还是互不影响)。
警告:SupervisorJob只有在以下两种作用域中才会起作用:使用supervisorScope{...}或CoroutineScope(SupervisorJob())创建的作用域
Job还是SupervisorJob
什么时候应该使用Job 或SupervisorJob ?当我们不想因异常取消父级或同级协程时,使用SupervisorJob 或supervisorScope{...}
。
一些例子:
val scope = CoroutineScope(SupervisorJob())
scope.launch {
// Child 1
}
scope.launch {
// Child 2
}
在这种情况下,如果Child 1失败,scope和Child 2都不会被取消。
另一个例子:
val scope = CoroutineScope(Job())
scope.launch {
supervisorScope {
launch {
// Child 1
}
launch {
// Child 2
}
}
}
在这种情况下,由于supervisorScope{...}
创建了一个带有SupervisorJob 的作用域,如果Child 1失败,Child 2不会被取消。如果使用coroutineScope{...}
,则会传播异常并最终取消scope
。
注意测试!谁是我的父级?🎯
给定如下代码,你能确定Child 1的父级是哪个Job 吗?
val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
launch {
// Child 1
}
launch {
// Child 2
}
}
Child 1的父Job是Job !希望你做对了,乍一看你可能认为它的父级是SupervisorJob 。在这种情况下,并不是因为新协程总会被分配一个新Job 而覆盖了SupervisorJob ,该SupervisorJob 是使用scope.launch
创建的协程的父级,还记得上面的警告吗?所以字面上来看SupervisorJob 在该代码中什么也没有做。
因此,如果Child 1或Child 2因异常失败,该作用域启动的所有协程都将被取消。
请记住,SupervisorJob 只有在supervisorScope{...}
或CoroutineScope(SupervisorJob())
创建的作用域中才会有效,将SupervisorJob 作为协程构建器的参数时将无法产生我们的预期行为。
关于异常,如果任何子协程抛出异常,那么SupervisorJob 不会将异常向上传播到层次结构中,而是让协程自己对其进行处理。
译者注:为什么在这种情况下SupervisorJob未生效原文描述的有些模糊,在SupervisorJob源码的注释中有说,当父级指定Job时,它的子作用域再次指定的SupervisorJob会成为此Job的一个子级,父级的取消也会取消此SupervisorJob。而为什么在之前的例子中协程作用域指定了Job,在作用域内使用supervisorScope{...}会生效,是因为supervisorScope{...}虽然会继承父级协程上下文,但是它会重写上下文中的Job元素。
底层原理
如果你对Job 的工作原理感兴趣,请查看JobSupport.kt文件中childCancelled
和notifyCancelling
函数的实现。
在SupervisorJob 的实现中,childCancelled()
方法只返回false,这意味着它不会传播取消,但也不会处理异常。
处理异常 👩🚒
协程使用常规的Koltin语法来处理异常:try/catch
或内置的辅助函数如runCatching
(它在内部也使用try/catch
)
我们之前说过,未捕获的异常总是会被抛出。然而不同的协程构建器会以不同的方式来处理异常。
Launch
使用launch,异常一旦发生就会被抛出,所以我们可以将可能发生异常的代码包装在try/catch
内,如以下示例所示:
scope.launch {
try {
codeThatCanThrowExceptions()
} catch(e: Exception) {
// Handle exception
}
}
使用launch构建协程,异常一旦发生就会被抛出。
Async
当async被用作构建根协程(由协程作用域直接管理的协程)时,异常不会主动抛出,而是在调用.await()时抛出。
为了处理使用async启动的根协程抛出的异常,我们可以将.await()
包装在一个try/catch
代码块中:
supervisorScope {
val deferred = async {
codeThatCanThrowExceptions()
}
try {
deferred.await()
} catch(e: Exception) {
// 处理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) {
// async中抛出的异常不会在这里被捕获而是向上传播给scope
}
}
此外,在由其他协程构建器创建的协程发生的异常也会始终被传播,例如:
val scope = CoroutineScope(Job())
scope.launch {
async {
// 如果async抛出异常,那么无需调用.await() launch也会抛出
}
}
在这个例子中,如果async会抛出一个异常,将在异常发生时立即被抛出,因为scope的直接子协程由launch构建,而async的协程上下文中的Job实例为Job ,所以async构建的协程发生异常时将自动向上级传播。
由coroutineScope{...}或其他协程构建的协程抛出异常时,将不会在try/catch中捕获!
在本文介绍SupervisorJob 时提到CoroutineExceptionHandler ,让我们正式开始介绍它吧
CoroutineExceptionHandler
CoroutineExceptionHandler 是CoroutineContext 的一个可选元素,允许我们处理未捕获的异常。
以下是定义CoroutineExceptionHandler 的方法,每当捕获到异常时,我们就会得到发生异常的CoroutineContext 和异常本身的信息:
val handler = CoroutineExceptionHandler {context, exception ->
println("Caught $exception")
}
注意当满足以下要求,才会捕获异常:
- When ⏰ :发生异常的协程会自动抛出异常,适用于使用launch构建的协程
- Where 🌍 : 发生异常的位置为协程作用域(CoroutineScope)的上下文(CoroutineContext)中或者根协程(协程作用域直接管理的协程)
让我们来看一些使用上面定义的CoroutineExceptionHandler 的例子,在下面的这个例子中,异常将被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 ,内部协程会在异常发生时立即向上级抛出异常,而父级不知道任何关于CoroutineExceptionHandler 的信息,所以异常将被抛出。
· · ·在应用程序中优雅的处理异常对于获得良好的用户体验非常重要,即使事情并没有按照预期进行。
请记住,当我们希望避免发生异常时异常被传播使用SupervisorJob ,否则用Job 。
未捕获的异常将被传播,捕获它们以提供更好的用户体验!