Kotlin 协程的异常处理
协程会遇到各种异常情况,比如协程被取消、协程内部发生错误、协程之间的异常传播等。这些异常情况需要我们正确地处理,否则可能会导致程序崩溃、资源泄露或者逻辑错误。本文将介绍 Kotlin 协程的异常处理机制,包括以下几个方面:
- 协程的取消:介绍了协程中如何处理不同类型的异常,包括取消异常 和其他异常,以及一些注意事项和技巧。
- 协程的取消:需要协程内部配合,可以使用isActive 或者挂起函数来响应外部的**cancel()**方法,不要打破结构化的父子关系。
- CancellationException异常:是一种特殊的异常,用于实现协程的结构化取消,当捕获到这种异常时,要考虑是否需要重新抛出。
- 其他异常处理手段:介绍了几种常用的方法,如try-catch 、SupervisorJob 和CoroutineExceptionHandler,以及它们的使用场景和注意点。
协程的取消
协程的取消是一种协作机制,也就是说,协程需要主动检查自己是否被取消,并在合适的时候停止执行。这样做的好处是可以避免在不安全的状态下终止协程,比如在操作共享资源或者执行不可逆操作时。协程可以通过以下几种方式来检查自己是否被取消:
- 使用 isActive 属性:这是一个布尔值,表示当前协程是否处于活动状态(即未被取消)。如果为 false,则说明协程已经被取消或者完成了。我们可以在协程中定期检查这个属性,并在发现为 false 时退出循环或者返回结果。
- 使用 挂起函数 :所有 kotlinx.coroutines 中的挂起函数都是 可被取消的 。它们检查协程的取消,并在取消时抛出 CancellationException 异常。因此,我们可以在协程中调用任何挂起函数(比如 delay()、withTimeout() 等),来响应外部的取消请求。如果不想处理这个异常,可以直接让它抛出,协程会自动结束。
下面是一个简单的例子,演示了如何使用 isActive 和 delay() 来实现可被取消的协程:
java
import kotlinx.coroutines.*
fun main() = runBlocking {
// 创建一个 Job 对象
val job = launch {
// 在一个循环中执行一些工作
var i = 0
while (isActive) { // 检查协程是否被取消
println("job: I'm working...${i++}")
// 模拟耗时操作
delay(500L)
}
}
// 等待一段时间
delay(1300L)
println("main: I'm tired of waiting!")
// 取消协程
job.cancel()
println("main: Now I can quit.")
}
输出结果:
vbnet
job: I'm working...0
job: I'm working...1
job: I'm working...2
main: I'm tired of waiting!
main: Now I can quit.
从输出结果可以看出,在调用 job.cancel() 后,循环就停止了,并没有继续打印 "job: I'm working..."。这是因为 delay() 函数在检测到协程被取消时,抛出了 CancellationException 异常,导致协程结束。如果我们不使用 delay(),而是使用 Thread.sleep(),那么协程就不会响应取消,而是继续执行,直到循环结束。这是因为 Thread.sleep() 是一个阻塞函数,它不会检查协程的取消状态,也不会抛出任何异常。因此,我们应该尽量避免在协程中使用阻塞函数,而是使用挂起函数。
CancellationException 异常
CancellationException 是一种特殊的异常,它用于表示协程的正常取消。它继承自 IllegalStateException,但是有以下几个特点:
- 它不会打印堆栈信息,也不会被默认的异常处理器捕获,因为它不代表程序的错误,而是协程的协作方式。
- 它可以被 catch 语句捕获,但是一般不需要这样做,除非我们想在协程取消时执行一些额外的操作,比如释放资源、关闭连接等。如果我们只是想在协程取消时退出循环或者返回结果,那么不需要捕获这个异常,直接让它抛出即可。
- 它可以被 throw 语句抛出,用于主动取消协程。我们可以在协程中调用 cancel() 方法来取消自己或者父协程,也可以直接抛出 CancellationException 来达到同样的效果。不过,前者更加优雅和明确,后者更加灵活和隐晦。
下面是一个例子,演示了如何在协程取消时捕获和抛出 CancellationException 异常:
java
import kotlinx.coroutines.*
fun main() = runBlocking {
// 创建一个 Job 对象
val job = launch {
try {
// 在一个循环中执行一些工作
var i = 0
while (isActive) { // 检查协程是否被取消
println("job: I'm working...${i++}")
// 模拟耗时操作
delay(500L)
}
} catch (e: CancellationException) {
// 捕获取消异常
println("job: I'm cancelled, reason: ${e.message}")
} finally {
// 在 finally 块中执行一些操作
println("job: I'm in the finally block")
// 抛出取消异常
throw CancellationException("I don't want to finish normally")
}
}
// 等待一段时间
delay(1300L)
println("main: I'm tired of waiting!")
// 取消协程,并传递一个原因
job.cancel(CancellationException("Too slow"))
println("main: Now I can quit.")
}
输出结果:
java
job: I'm working...0
job: I'm working...1
job: I'm working...2
main: I'm tired of waiting!
job: I'm cancelled, reason: Too slow
job: I'm in the finally block
main: Now I can quit.
从输出结果可以看出,在调用 job.cancel() 后,协程进入了 catch 语句,并打印了取消的原因。然后进入了 finally 块,并打印了一条信息。最后,在 finally 块中抛出了一个新的 CancellationException 异常,并传递了一个自定义的消息。这个异常并没有被打印或者捕获,而是被忽略了。这是因为当一个协程被取消时,它只关心第一个 CancellationException 异常,并以它作为结束的原因。后续的任何 CancellationException 异常都会被忽略。
其他异常处理手段
除了使用 try-catch 语句来处理协程中的异常外
- 使用 SupervisorJob:这是一种特殊的 Job,它可以让协程的子协程在发生异常时不影响父协程和其他兄弟协程的运行。这样,我们可以在父协程中创建多个子协程,分别执行不同的任务,而不用担心其中一个任务失败导致其他任务也被取消。这种方法适用于那些子协程之间没有依赖关系,且不需要统一的异常处理逻辑的场景。
- 使用 CoroutineExceptionHandler :这是一种 CoroutineContext 的元素,它可以定义一个函数,用于处理协程中未捕获的异常我们可以在创建协程时,将这个元素添加到协程的上下文中,或者使用 coroutineScope 或者 supervisorScope 函数来创建一个新的作用域,并将这个元素添加到作用域的上下文中。这样,当作用域内的任何协程发生未捕获的异常时,都会调用这个函数来处理。这种方法适用于那些需要统一的异常处理逻辑,或者需要在异常发生时执行一些操作(比如日志、通知等)的场景。
下面是一个例子,演示了如何使用 SupervisorJob 和 CoroutineExceptionHandler 来处理协程中的异常:
java
import kotlinx.coroutines.*
fun main() = runBlocking {
// 创建一个 SupervisorJob 对象
val supervisor = SupervisorJob()
// 创建一个 CoroutineExceptionHandler 对象
val handler = CoroutineExceptionHandler { context, exception ->
// 处理未捕获的异常
println("Caught $exception in ${context[CoroutineName]}")
}
// 使用 supervisor 和 handler 创建一个新的作用域
supervisorScope {
// 在作用域内创建三个子协程
val child1 = launch(supervisor + CoroutineName("child1")) {
println("child1: I'm working...")
delay(500L)
println("child1: I'm done.")
}
val child2 = launch(supervisor + CoroutineName("child2")) {
println("child2: I'm working...")
delay(1000L)
// 抛出一个异常
throw ArithmeticException("Oops!")
}
val child3 = launch(supervisor + handler + CoroutineName("child3")) {
println("child3: I'm working...")
delay(1500L)
println("child3: I'm done.")
}
}
// 等待作用域结束
println("main: The scope is over.")
}
输出结果:
java
child1: I'm working...
child2: I'm working...
child3: I'm working...
child1: I'm done.
Caught java.lang.ArithmeticException: Oops! in child2
child3: I'm done.
main: The scope is over.
从输出结果可以看出,在 child2 抛出异常后,并没有影响 child1 和 child3 的运行,它们都正常地完成了自己的任务。这是因为使用了 SupervisorJob 来创建作用域,使得子协程之间互不影响。同时,我们也可以看到,在 child2 抛出异常后,调用了 CoroutineExceptionHandler 来处理这个异常,并打印了相关信息。这是因为使用了 handler 来定义一个统一的异常处理函数,并将它添加到 child3 的上下文中。注意,handler 并没有添加到 child2 的上下文中,因为如果这样做,那么 child2 的异常就会被捕获并处理,而不会传播到父协程和其他兄弟协程中。这样就会打破 SupervisorJob 的语义,使得父协程和其他兄弟协程无法感知到 child2 的异常。因此,在使用 SupervisorJob 时,我们应该避免在子协程中使用 CoroutineExceptionHandler,而是在父协程或者其他兄弟协程中使用。
👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀