【译】Kotlin协程中的异常

2,144 阅读8分钟

作为开发者,我们通常会花费大量的时间来完善我们的应用程序。然而,当事情不尽如人意时,提供合适的用户体验也同样重要。一方面,看到应用程序崩溃对用户来说是一种很糟糕的体验;另一方面,当一个操作失败时,向用户显示正确的提示信息也是必不可少的。 正确的处理异常对用户如何看待我们的App尤为重要。在本文中,我们将解释异常时如何在协程中传播的,以及如何始终进行控制,包括处理异常的不同方法。

⚠️ 为了无障碍的阅读本文,请阅读和理解本系列的第一部分:协程基础第二部分:协程的取消

一个协程突然失败了!现在该怎么办?😱

当协程因出现异常失败时,它会将异常传播到它的父级,然后,父级将进行如下三步:1)取消其余的子协程,2)取消自身,3)将异常在传播给它的父级。

异常将最终到达当前层次结构的根,在当前协程作用域启动的所有协程都将被取消。

协程中的异常将在整个协程层次结构中传播

虽然在某些情况下传播异常是有意义的,但是大多数情况下这样做是不可取的。设想一个处理用户交互的与UI相关的协程作用域,如果一个子协程抛出了异常,UI的作用域将被取消,整个UI组件将变得无响应,因为已经取消的协程作用域无法再此启动协程。

如果这不是我们的预期行为我们该怎么办呢?作为一种选择,我们可以在当前协程作用域的上下文中使用Job 的另一种实现:SupervisorJob

使用SupervisorJob处理

使用SupervisorJob ,子协程的失败不会影响到其他子协程。SupervisorJob 不会取消自身或它的其他子协程,而且SupervisorJob 不会传播异常而是让它的协程处理。

你可以像这样val uiScope = CoroutineScope(SupervisorJob()) 创建一个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

未捕获的异常将被传播,捕获它们以提供更好的用户体验!