Kotlin协程异常捕获:别让try-catch“翻车”了!

4 阅读3分钟

Kotlin协程异常捕获:别让try-catch“翻车”了!

开篇引入

在 Kotlin 协程的使用过程中,我们常常会依赖 try-catch 语句来捕获异常,以确保程序的稳定性和健壮性。但你是否遇到过这样的情况:明明写好了 try-catch,异常却依然没有被捕获,程序直接崩溃或出现意料之外的错误?今天,我们就来深入探讨一下 Kotlin 协程中 try-catch 捕获异常失败的问题。

先来看一段简单的代码示例:


import kotlinx.coroutines.*

fun main() {
    val scope = CoroutineScope(Dispatchers.Default)
    try {
        scope.launch {
            println("Coroutine started")
            throw RuntimeException("Something went wrong!")
        }
    } catch (e: RuntimeException) {
        println("Caught exception: $e")
    }
    Thread.sleep(2000)
}

在这段代码中,我们创建了一个协程作用域scope,并在try块中启动了一个协程。在协程内部,我们故意抛出了一个RuntimeException,然后在catch块中尝试捕获这个异常。然而,当你运行这段代码时,你会惊讶地发现,catch块并没有执行,异常信息直接打印在了控制台上,就好像try-catch完全失效了一样 。这是为什么呢?按理说,try-catch应该能够捕获到launch协程内部抛出的异常呀?带着这个疑问,我们深入 Kotlin 协程的异常处理机制,一探究竟。

协程异常处理基础

在深入探讨 Kotlin 协程中 try-catch 捕获异常失败的原因之前,我们先来回顾一下 Kotlin 协程异常处理的基础知识,并与我们熟悉的 Java 异常处理机制做个对比 。

在 Java 中,异常处理是基于方法调用栈的。当一个方法抛出异常时,如果该方法内部没有捕获这个异常,异常就会沿着调用栈向上传播,直到被某个try-catch块捕获或者导致程序崩溃。例如:


public class JavaExceptionExample {
    public static void main(String[] args) {
        try {
            method1();
        } catch (Exception e) {
            System.out.println("Caught exception in main: " + e.getMessage());
        }
    }

    private static void method1() {
        method2();
    }

    private static void method2() {
        throw new RuntimeException("Something went wrong in Java!");
    }
}

在这个 Java 示例中,method2抛出了一个RuntimeException,由于method2method1内部都没有捕获这个异常,异常最终被main方法中的try-catch块捕获。

而在 Kotlin 协程中,异常处理机制与 Java 有所不同。Kotlin 协程引入了结构化并发的概念,协程之间通过CoroutineScopeJob建立起父子关系 ,形成了一个层次结构。异常在这个层次结构中传播,遵循特定的规则 。比如,当一个子协程抛出异常时,它会先尝试取消它的父协程,然后父协程会取消它的所有子协程,最后异常会向上传播到根协程。

Kotlin 协程中的异常分为两类:取消异常(CancellationException)和非取消异常 。取消异常通常是协程主动发起的取消操作导致的,比如调用Jobcancel()方法,这类异常通常会被协程内部处理,不会向上传播 。而非取消异常,如空指针异常、网络请求失败等由代码错误或外部环境问题引起的异常,会按照上述规则向上传播 。

对比 Java 和 Kotlin 协程的异常处理机制,主要的不同点在于:

  • 传播路径:Java 基于方法调用栈传播异常,而 Kotlin 协程基于协程的父子结构传播异常。

  • 异常分类处理:Kotlin 协程对取消异常和非取消异常有不同的处理方式,Java 则没有这种区分。

  • 结构化并发:Kotlin 协程的结构化并发特性使得异常处理与协程的生命周期和层次结构紧密相关,而 Java 的异常处理与线程的生命周期和调用关系没有直接关联。

try-catch 在协程中的表现

(一)普通函数与协程函数对比

我们先来看普通函数中 try-catch 的表现 。


fun main() {
    try {
        throw RuntimeException("This is a normal exception")
    } catch (e: RuntimeException) {
        println("Caught in normal function: $e")
    }
}

运行这段代码,控制台会输出:Caught in normal function: java.lang.RuntimeException: This is a normal exception ,说明 try-catch 成功捕获了异常。

再看之前的协程函数示例:


import kotlinx.coroutines.*

fun main() {
    val scope = CoroutineScope(Dispatchers.Default)
    try {
        scope.launch {
            println("Coroutine started")
            throw RuntimeException("Something went wrong!")
        }
    } catch (e: RuntimeException) {
        println("Caught exception: $e")
    }
    Thread.sleep(2000)
}

这里的 try-catch 并没有捕获到协程内部抛出的异常 。为什么会出现这样的差异呢?关键在于协程的异步特性 。在普通函数中,代码是顺序执行的,异常抛出后立即被 try-catch 捕获 。而在协程中,launch函数启动协程后,会立即返回,不会等待协程内部代码执行完毕 。协程内部的代码在另一个线程(由Dispatchers.Default指定的线程池中的线程)中异步执行,所以外层的 try-catch 无法捕获到协程内部异步抛出的异常 。

(二)深入剖析失败原因

从协程的异步、并发特性和异常传播机制角度来看,try-catch 捕获异常失败主要有以下原因:

  1. 异步执行:协程是异步执行的,launch函数只是启动了一个协程,并不会阻塞当前线程等待协程执行完成 。当协程内部抛出异常时,外层的 try-catch 代码可能已经执行完毕,自然无法捕获到异常 。这就好比你在主线程中启动了一个新线程去执行某个任务,新线程内部抛出的异常,主线程中的 try-catch 是无法捕获的 。

  2. 异常传播规则:在 Kotlin 协程中,异常是按照协程的父子关系进行传播的 。当一个子协程抛出异常时,它会先尝试取消它的父协程,然后父协程会取消它的所有子协程,最后异常会向上传播到根协程 。如果在这个传播过程中没有被捕获,异常最终会导致整个协程作用域被取消,并抛出给当前线程 。在我们最初的例子中,外层的 try-catch 并没有在异常传播的路径上,所以无法捕获到异常 。

常见场景下的异常捕获陷阱

(一)launch 启动的协程

在 Kotlin 协程中,使用launch启动协程是非常常见的操作,但在这种场景下,try-catch的使用存在一些容易让人犯错的地方。我们来看下面这个例子:


import kotlinx.coroutines.*

fun main() {
    val scope = CoroutineScope(Dispatchers.Default)
    try {
        scope.launch {
            val data = getData()
            println("Processed data: $data")
        }
    } catch (e: Exception) {
        println("Caught exception: $e")
    }
}

suspend fun getData(): String {
    delay(1000)
    throw RuntimeException("Failed to fetch data")
}

在这个例子中,我们在try块中使用scope.launch启动了一个协程,协程内部调用了getData函数,该函数会抛出一个RuntimeException 。按照我们常规的思维,外层的try-catch应该能够捕获这个异常,但实际运行时,你会发现catch块并没有执行,异常直接打印在了控制台上。

这是因为launch启动的协程是异步执行的 。launch函数会立即返回,不会等待协程内部的代码执行完毕 。当协程内部抛出异常时,外层的try-catch代码可能已经执行结束,所以无法捕获到异常 。这种情况下,异常会按照协程的异常传播规则向上传播,如果在传播过程中没有被捕获,最终会导致整个协程作用域被取消,并抛出给当前线程 。

(二)async 启动的协程

async启动的协程与launch有所不同,它返回一个Deferred对象,代表一个异步计算的结果,通过await方法可以获取这个结果 。在使用asyncawait时,异常捕获也有一些需要注意的地方 。


import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        val deferred = async {
            val result = performTask()
            result * 2
        }
        val finalResult = deferred.await()
        println("Final result: $finalResult")
    } catch (e: Exception) {
        println("Caught exception: $e")
    }
}

suspend fun performTask(): Int {
    delay(1000)
    throw RuntimeException("Task failed")
}

在这个例子中,我们使用async启动了一个协程,协程内部调用performTask函数,该函数会抛出异常 。我们在try块中调用了deferred.await()来获取协程的执行结果 。这里看起来try-catch能够捕获到异常,但实际上,如果async启动的协程是一个子协程(即在另一个协程内部启动),并且这个子协程抛出异常,而外层的协程没有捕获这个异常,那么这个异常会导致外层协程也抛出异常 。

例如:


import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        try {
            val deferred = async {
                val result = performTask()
                result * 2
            }
            val finalResult = deferred.await()
            println("Final result: $finalResult")
        } catch (e: Exception) {
            println("Caught exception in inner: $e")
        }
    }
}

suspend fun performTask(): Int {
    delay(1000)
    throw RuntimeException("Task failed")
}

在这个例子中,async启动的协程是launch协程的子协程 。虽然我们在async协程内部使用try-catch捕获了异常,但由于launch协程没有捕获异常,最终这个异常还是会导致整个launch协程失败 。这是因为在 Kotlin 协程中,子协程的异常会向上传播给父协程 ,如果父协程没有处理这个异常,就会导致父协程也失败 。所以在使用async启动协程时,要特别注意协程的父子关系和异常传播路径,确保异常能够被正确捕获和处理 。

解决方法与最佳实践

(一)使用 CoroutineExceptionHandler

CoroutineExceptionHandler是 Kotlin 协程提供的一种全局异常处理机制,它可以捕获协程树中未被处理的异常 。使用CoroutineExceptionHandler非常简单,我们只需要创建一个CoroutineExceptionHandler对象,并将其添加到协程的上下文中 。


import kotlinx.coroutines.*

fun main() = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, exception ->
        println("Caught by CoroutineExceptionHandler: $exception")
    }
    val scope = CoroutineScope(Dispatchers.Default + exceptionHandler)
    scope.launch {
        println("Coroutine started")
        throw RuntimeException("An unhandled exception")
    }
}

在这个例子中,我们创建了一个CoroutineExceptionHandler,当协程中抛出未被捕获的异常时,这个处理器就会被调用 。通过将exceptionHandler添加到CoroutineScope的上下文中,我们确保了这个处理器可以捕获到该作用域内所有协程抛出的未处理异常 。这种方式的优势在于它可以全局捕获异常,并且不会影响协程的正常逻辑 ,非常适合在应用程序的顶层(如 ViewModel 或 Repository 层)设置,以便统一处理所有未被捕获的异常 ,比如记录日志、报告错误等 。

(二)利用 SupervisorJob 实现任务隔离

SupervisorJob是一种特殊的Job,它改变了协程的异常传播方式 。在普通的Job中,任何一个子协程的失败(抛出异常未处理)都会导致整个作用域以及所有其他子协程被取消 ,这就是所谓的 “一损俱损” 。而SupervisorJob的行为则不同,一个子协程的失败不会牵连父协程和兄弟协程 ,实现了失败隔离 。这非常适用于执行多个独立任务的场景,例如同时下载多张图片,你希望即使是一张下载失败,其他的下载任务也能继续 。


import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisorJob = SupervisorJob()
    val scope = CoroutineScope(Dispatchers.Default + supervisorJob)
    scope.launch {
        try {
            println("Task 1 started")
            throw RuntimeException("Task 1 failed")
        } catch (e: Exception) {
            println("Caught in Task 1: $e")
        }
    }
    scope.launch {
        println("Task 2 started")
        delay(1000)
        println("Task 2 completed")
    }
    delay(2000)
}

在这个例子中,Task 1抛出了异常,但由于使用了SupervisorJobTask 2并没有受到影响,仍然可以正常执行 。通过SupervisorJob,我们可以将不同的任务隔离开来,避免一个任务的失败影响其他任务 ,提高了程序的健壮性和稳定性 。在实际应用中,比如在一个数据加载模块中,可能同时需要从多个数据源获取数据,使用SupervisorJob可以确保即使某个数据源获取失败,其他数据源的数据依然能够正常获取和处理 。

(三)合理的代码结构与异常处理位置

在编写协程代码时,合理的代码结构和异常处理位置至关重要 。

  1. 对于 launch 启动的协程:如果希望捕获launch协程内部的异常,应该将try-catch放在协程内部,而不是在启动launch的外部 。例如:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)
    scope.launch {
        try {
            val data = getData()
            println("Processed data: $data")
        } catch (e: Exception) {
            println("Caught exception in coroutine: $e")
        }
    }
}

suspend fun getData(): String {
    delay(1000)
    throw RuntimeException("Failed to fetch data")
}

这样,当getData函数抛出异常时,协程内部的try-catch可以捕获到异常,避免异常传播导致整个协程作用域失败 。 2. 对于 async 启动的协程:在使用await获取结果时,应该将try-catch放在调用await的地方 。如果async协程是子协程,并且希望处理子协程的异常,同时避免影响父协程,除了在子协程内部捕获异常外,父协程也可以进行适当的异常处理 。例如:


import kotlinx.coroutines.*

fun main() = runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)
    scope.launch {
        try {
            val deferred = async {
                try {
                    performTask()
                } catch (e: Exception) {
                    // 子协程内部捕获异常
                    -1
                }
            }
            val result = deferred.await()
            println("Final result: $result")
        } catch (e: Exception) {
            // 父协程捕获异常
            println("Caught exception in parent: $e")
        }
    }
}

suspend fun performTask(): Int {
    delay(1000)
    throw RuntimeException("Task failed")
}

在这个例子中,子协程内部先捕获了异常,并返回一个默认值 。父协程在调用await时也进行了异常捕获,确保即使子协程内部的异常处理出现问题,父协程也能进行兜底处理 ,避免异常导致整个父协程失败 。

在不同的协程启动方式下,我们要根据具体的业务需求和异常处理策略,合理放置try-catch和其他异常处理逻辑 ,确保异常能够被及时捕获和处理,同时不影响程序的正常运行 。