在Kotlin协程中处理异常的方式分析

283 阅读3分钟

在 Kotlin 协程中,异常处理是确保程序健壮性的关键。
以下是 5 种核心异常处理方式及其示例代码,涵盖不同场景和最佳实践。
1, 使用try catch捕获异常

fun main() {
    // 自定义异常处理器
    // exceptionHandler 是一个 CoroutineExceptionHandler,用于处理未在协程内部捕获的异常。
    val exceptionHandler = CoroutineExceptionHandler { _, exception ->
        println("全局异常处理器捕获异常: ${exception.message}")
    }
    // 自定义协程作用域
    // customScope 结合了 Dispatchers.Default 调度器和 exceptionHandler,确保协程在默认调度器上执行,并能处理未捕获的异常。
    val customScope = CoroutineScope(Dispatchers.Default + exceptionHandler)
    // 使用 runBlocking 作为主协程作用域,在其中启动子协程。这样可以保证在 runBlocking 结束时,所有子协程也会结束。
    runBlocking {
        customScope.launch {
            // 在协程内部使用 try-catch 捕获并处理异常,同时如果有未捕获的异常,会被 exceptionHandler 捕获。
            try {
                delay(100)
                throw ArithmeticException("计算错误")
            } catch (e: Exception) {
                println("协程内捕获异常: ${e.message}")
            }
        }

        // 等待协程执行完毕
        delay(200)

        // 取消协程作用域,释放资源
        customScope.cancel()
    }
}

上面的代码输出如下:
协程内捕获异常: 计算错误

2, 使用 CoroutineExceptionHandler 全局处理,适用于 根协程(顶层协程) 的未捕获异常处理。

fun main() {
    // 示例:定义全局异常处理器
    // CoroutineExceptionHandler:这是 Kotlin 协程库提供的一个接口,用于处理协程中未被捕获的异常。
    // 它接受一个 Lambda 表达式作为参数,该 Lambda 表达式有两个参数,
    // 第一个参数是 CoroutineContext,代表协程的上下文,这里使用 _ 表示忽略该参数。
    // 第二个参数是 Throwable,表示抛出的异常对象。
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("全局捕获: ${throwable.message}")
    }

   runBlocking {
       // launch(exceptionHandler):将之前定义的 exceptionHandler 作为参数传递给 launch 函数,
       // 这样该协程中未被捕获的异常就会由这个异常处理器来处理。
       // 在构建协程上下文时,采用 SupervisorJob 来管理协程的生命周期。
       // SupervisorJob 的特点是,当一个子协程出现异常时,不会影响其他子协程的执行。
       // SupervisorJob 与普通的 Job 不同,它不会让一个子协程的异常影响其他子协程。这在需要独立处理每个子协程异常的场景中非常有用。
       val job = launch(Dispatchers.Default + SupervisorJob() + exceptionHandler) { // 附加处理器
           throw NullPointerException("空指针异常")
       }
       // join 是 Job 接口的一个方法,用于等待协程执行完毕。调用 job.join() 会阻塞当前协程,直到 job 所代表的协程执行结束。
       job.join()
   }
}

上面的代码在构建协程上下文时,采用 SupervisorJob 来管理协程的生命周期,就可以正确地捕获异常,输出如下:
全局捕获: 空指针异常

3, 使用 coroutineScope 和 onCompletion 回调
coroutineScope 用于启动多个协程并等待它们完成,而 onCompletion 回调可以在协程完成后(无论正常完成还是异常终止)执行某些操作。

fun main() {
    runBlocking {
        // coroutineScope 是一个挂起函数,它会创建一个新的协程作用域,该作用域会等待其内部所有子协程完成后才会结束。
        // 这体现了结构化并发的思想,有助于管理协程的生命周期和资源。
        coroutineScope {
            val job = launch {
                delay(1000L)
                throw Exception("Something went wrong in coroutineScope!")
            }

            // 为协程设置一个完成回调,当协程完成(正常完成或异常完成)时会触发该回调。
            job.invokeOnCompletion { cause ->
                // 如果 cause 不为 null,表示协程异常完成,会打印异常信息;否则,表示协程正常完成,会打印相应信息。
                if (cause != null) {
                    println("Coroutine completed exceptionally: ${cause.message}")
                } else {
                    println("Coroutine completed normally")
                }
            }
        }
    }
}

上面代码的输出如下:

Coroutine completed exceptionally: Something went wrong in coroutineScope!
Exception in thread "main" java.lang.Exception: Something went wrong in coroutineScope! 

当协程抛出异常时,coroutineScope 会捕获这个异常,并且协程的 invokeOnCompletion 回调会被触发,因为 coroutineScope 内的异常不会阻止回调的执行,所以能正常输出异常信息。

当在 launch 后面加上 Dispatchers.Default + SupervisorJob() 后,SupervisorJob 的特性是它不会让一个子协程的异常影响其他子协程,并且异常不会向上传播到父协程作用域(这里是 coroutineScope)。当子协程抛出异常时,SupervisorJob 会取消该子协程,但不会取消整个 coroutineScope。
然而,runBlocking 本身没有对 coroutineScope 内的子协程异常进行捕获和处理。当子协程抛出异常时,这个异常没有被正确捕获,可能会导致程序崩溃或者输出不符合预期。而且,由于 runBlocking 没有处理异常,它可能会提前结束,从而使得 invokeOnCompletion 回调没有机会执行。
相应的解决方案是:在 runBlocking 中添加异常处理逻辑,确保能捕获并处理子协程抛出的异常

fun main() {

    runBlocking {
        try {
            coroutineScope {
                val job = launch {
                    delay(1000L)
                    throw Exception("Something went wrong in coroutineScope!")
                }

                job.invokeOnCompletion { cause ->
                    if (cause != null) {
                        println("Coroutine completed exceptionally: ${cause.message}")
                    } else {
                        println("Coroutine completed normally")
                    }
                }
            }
        } catch (e: Exception) {
            println("Caught exception in runBlocking: ${e.message}")
        }
    }
}

上面的代码中使用 try-catch 块包裹 coroutineScope,当子协程抛出异常时,runBlocking 能够捕获这个异常并进行处理,同时 invokeOnCompletion 回调也能正常执行,输出协程的完成情况。
上面的代码输出如下:
Coroutine completed exceptionally: Something went wrong in coroutineScope! Caught exception in runBlocking: Something went wrong in coroutineScope!

4,使用 supervisorScope
supervisorScope 与 coroutineScope 类似,但它们在处理子协程异常时有所不同。在 supervisorScope 中,一个子协程的异常不会导致其他子协程被取消。

fun main() {

    runBlocking {
        // supervisorScope 是一个挂起函数,它会创建一个新的协程作用域,并且使用 SupervisorJob 来管理该作用域内的协程。
        // SupervisorJob 的特点是,当一个子协程抛出异常时,不会影响其他子协程的执行,只会取消抛出异常的子协程本身。
        supervisorScope {
            val job1 = launch {
                delay(1000L)
                println("Job 1 completed")
            }

            val job2 = launch {
                delay(500L)
                throw Exception("Exception in Job 2")
            }

            job1.join()
            job2.join() // 这行代码实际上不会被执行,因为 job2 已经抛出异常,但它不会影响 job1
        }
    }
}

上面的代码输出如下:
Exception in thread "main" java.lang.Exception: Exception in Job 2
...
Job 1 completed
从上面的输出可以看出,job2的异常不会影响job1.

5, 处理 async 协程的异常
async 返回的 Deferred 对象需在 await() 时处理异常。

fun main() {
    runBlocking {
        val deferred = async {
            delay(100)
            throw IllegalStateException("异步任务失败")
        }
        try {
            deferred.await()
        } catch (e: Exception) {
            println("异步异常: ${e.message}") // 输出:异步异常: 异步任务失败
        }
    }
}

上面的代码输出如下:
异步异常: 异步任务失败
Exception in thread "main" java.lang.IllegalStateException: 异步任务失败