重拾 Kotlin 协程——异常剖析(4)

241 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

前言

我们通过上一篇文章知道,在 Android 中,假如线程发生了异常,会导致 App 直接退出,那我们来看看,协程发生异常后,现象会怎么样。

示例代码:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        GlobalScope.launch {
            throw NullPointerException()
        }
    }

日志输出:

2022-05-09 10:48:34.529 3946-3978/com.bjsdm.testkotlin E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-1
    Process: com.bjsdm.testkotlin, PID: 3946
    java.lang.NullPointerException
        at com.bjsdm.testkotlin.MainActivity$onCreate$1.invokeSuspend(MainActivity.kt:17)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

em...也是会导致 App 崩溃。

那我们接下来就分析下,对于协程发生了异常,我们该如何进行处理。

(说句题外话,其实,相对于 Kotlin,个人还是比较建议先熟悉下 Java,所以,由于协程是基于线程,针对于协程的异常处理,其实我们可以先看看线程异常该怎么样处理

异常捕获

CoroutineExceptionHandler

最先打头阵的是 CoroutineExceptionHandler,CoroutineExceptionHandler 其实相当于 UncaughtExceptionHandler,可以针对性的给某个或某些协程进行异常捕获。具体用法如下:

    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("捕获到异常: $throwable")
    }

    GlobalScope.launch(exceptionHandler){
        throw NullPointerException()
    }

日志输出:

I/System.out: 捕获到异常: java.lang.NullPointerException

这样就能够捕获到相应的协程异常了,并且捕获异常后,App 不会崩溃。与此相应的,还可以设置一个全局的协程异常捕获。

全局 CoroutineExceptionHandler

首先,我们需要写个异常的集成类,这里我命名为 GlobalCoroutineExceptionHandler:

class GlobalCoroutineExceptionHandler: CoroutineExceptionHandler {
    override val key: CoroutineContext.Key<*> = CoroutineExceptionHandler

    override fun handleException(context: CoroutineContext, throwable: Throwable) {
        println("全局异常捕获: $throwable")
    }
}

在 main 的目录下新建目录 resources/META-INF/services,然后创建 kotlinx.coroutines.CoroutineExceptionHandler 文件,再在文件里面填写刚刚新建的 GlobalCoroutineExceptionHandler 类的全路径。具体可以看下图:

异常1.png

写个异常试试:

    GlobalScope.launch{
        throw NullPointerException()
    }

日志输出:

I/System.out: 全局异常捕获: java.lang.NullPointerException

不过,这里有一点要注意,这里的全局异常捕获,其实并不算捕获,App 还是会崩溃的,只不过可以在发生协程异常的时候,可以进行一些日志记录等。

try catch

没想到吧?协程异常也是可以 try catch 的,不过,这个只针对于具有返回值的协程操作,具体的实现可以看看 这篇文章,若你对于为什么能够 try catch 有疑惑,可以看看 这篇文章 作为参考。

另外,我们也可以利用扩展函数进行 try catch 操作:

fun <T> CoroutineScope.catchLaunch(
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
    block: suspend CoroutineScope.() -> T
) = launch(dispatcher) {
    try {
        block()
    } catch (e: Exception) {
        if (e !is CancellationException) {
            println("捕获到异常:$e")
        }
    }
}

使用方式:

    GlobalScope.catchLaunch {
        throw NullPointerException()
    }

日志输出:

I/System.out: 捕获到异常:java.lang.NullPointerException

在这里,做了一个小优化,就是 e !is CancellationException 的时候才进行异常输出,这是因为当协程在调用挂起函数的时候,若已调用 cancel(),就会抛出 CancellationException,只是协程对于该异常静默处理了。

🌰:

    val job = GlobalScope.catchLaunch {
        try {
            delay(10000)
        } catch (e: Exception) {
            println("捕获到异常:$e")
        }
    }
    job.cancel()

DefaultUncaughtExceptionHandler

相信大家都知道,可以使用 DefaultUncaughtExceptionHandler 捕获全部线程的异常,而协程是基于线程的,所以, DefaultUncaughtExceptionHandler 也是可以捕获协程的异常:

    Thread.setDefaultUncaughtExceptionHandler { t, e -> println("Thread名称: ${t.name} ,Throwable: $e ") }

    GlobalScope.launch {
        throw NullPointerException()
    }

日志输出:

I/System.out: Thread名称: DefaultDispatcher-worker-1 ,Throwable: java.lang.NullPointerException

以上内容就是对于异常捕获处理的几种方式,但是,与线程不同的是,每个线程的运行都是相对独立的,一个线程发生异常,并不会影响其它线程的运行,但是,协程就不太一样,在协程中,假如有一个协程发生了异常,就有可能会影响到其它协程的运行,请注意,“有可能”,所以,我们还需要分情况进行处理。

异常传播

默认情况

首先,我们还是先看看个栗子:

    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("捕获到异常: $throwable")
    }

    GlobalScope.launch(exceptionHandler){
        launch {
            println("launch1 正在运行")
            delay(1000)
            throw NullPointerException()
        }
        launch {
            println("launch2 正在运行")
            delay(2000)
            println("launch2 运行完成")
        }
    }

日志输出:

I/System.out: launch1 正在运行
I/System.out: launch2 正在运行
I/System.out: 捕获到异常: java.lang.NullPointerException

这里很明显地看出,launch1 的崩溃会导致 launch2 的退出。

coroutineScope

    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("捕获到异常: $throwable")
    }

    GlobalScope.launch(exceptionHandler){
        coroutineScope {
            launch {
                println("launch1 正在运行")
                delay(1000)
                throw NullPointerException()
            }
            launch {
                println("launch2 正在运行")
                delay(2000)
                println("launch2 运行完成")
            }
        }
    }

结论跟默认的情况一致。

supervisorScope

将 coroutineScope 替换成 supervisorScope。

日志输出:

I/System.out: launch1 正在运行
I/System.out: launch2 正在运行
I/System.out: 捕获到异常: java.lang.NullPointerException
I/System.out: launch2 运行完成

在 supervisorScope 中,假如一个协程发生了异常,并不会影响到其它协程的运行。

上下传递

刚刚我们探究的协程基本上都是同级的,那我们来看看,上下级的协程的异常又是怎么传播的:

    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("exceptionHandler: 捕获到异常: $throwable")
    }
    val childExceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("childExceptionHandler: 捕获到异常: $throwable")
    }

    GlobalScope.launch(exceptionHandler) {
        println("launch1 正在执行")
        launch (childExceptionHandler){
            println("launch2 正在执行")
            throw NullPointerException()
        }
        delay(2000)
        println("launch1 执行完成")
    }

日志输出:

I/System.out: launch1 正在执行
I/System.out: launch2 正在执行
I/System.out: exceptionHandler: 捕获到异常: java.lang.NullPointerException

我们初步得到以下结论:

  • 子协程的异常崩溃会影响到父协程的运行。
  • 当子线程发生异常的时候,其异常不会被子协程的 CoroutineExceptionHandler 进行捕获,而是会被父协程的 CoroutineExceptionHandler 捕获。