Kotlin | 关于协程异常处理,你想知道的都在这里(上)

简介: 关于协程的异常处理,一直以来都不是一个简单问题。因为涉及到了很多方面,包括 异常的传递 ,结构化并发下的异常处理 ,异常的传播方式 ,不同的Job 等,所以常常让很多(特别是刚使用协程的,也不乏老手)同学摸不着头脑。

引言

关于协程的异常处理,一直以来都不是一个简单问题。因为涉及到了很多方面,包括 异常的传递结构化并发下的异常处理 ,异常的传播方式 ,不同的Job 等,所以常常让很多(特别是刚使用协程的,也不乏老手)同学摸不着头脑。

常见有如下两种处理方式:

  • try catch
  • CoroutineExceptionHandler

但这两种方式(特别是第二种)到底该什么时候用,用在哪里,却是一个问题?

比如虽然知道 CoroutineExceptionHandler ,但为什么增加了却还是崩溃?到底应该加在哪里? 尝试半天发现无解,最终又只能直接 try catch ,粗暴并有效,最终遇到此类问题,直接下意识 try 住。

try catch 虽然直接,一定程度上也帮我们规避了很多使用方面的问题,但同时也埋下了很多坑,也就是说,并不是所有协程的异常都可以 try 住(取决于使用位置),其也不是任何场景的最优解。

鉴于此,本篇将从头到尾,帮助你理清以下问题:

  • 什么是结构化并发?
  • 协程的异常传播流程与形式
  • 协程的异常处理方式
  • 为什么有些异常处理了却还是崩了
  • SupervisorJob 的使用场景
  • supervisorScope 与 coroutineScope
  • 异常处理方式的场景推荐

本文尽可能会用大白话与你分享理解,如有遗漏或理解不当,也欢迎评论区反馈。

好了,让我们开始吧!


结构化并发

在最开始前,我们先搞清楚什么是 结构化并发,这对我们理解协程异常的传递将非常有帮助。

让我们先将思路转为日常业务开发中,比如在某某业务中,可能存在好几个需要同时处理的逻辑,比如同时请求两个网络接口,同时操作两个子任务等。我们暂且称上述学术化概念为 多个并发操作。

而每个并发操作其实都是在处理一个单独的任务,这个 任务 中,可能还存在 子任务 ; 同样对于这个子任务来说,它又是其父任务的子单元。每个任务都有自己的生命周期,子任务的生命周期会继承父任务的生命周期,比如如果父任务关闭,子任务也会被取消。而如果满足这样特性,我们就称其就是 结构化并发。

在协程中,我们常用的 CoroutineScope,正是基于这样的特性,即其也有自己的作用域与层级概念。

比如当我们每次调用其扩展方法 launch() 时,这个内部又是一个新的协程作用域,新的作用域又会与父协程保持着层级关系,当我们 取消 CoroutineScope 时,其所有子协程也都会被关闭。

如下代码片段:

val scope = CoroutineScope(Job())
val jobA = scope.launch(CoroutineName("A")) {
   val jobChildA = launch(CoroutineName("child-A")) {
        delay(1000)
        println("xxx")
   }
    // jobChildA.cancel()
}
val jobB = scope.launch(CoroutineName("B")) {
         delay(500)
        println("xxx")
}
// scope.cancel()

我们定义了一个名为 scope 的作用域, 其中有两个子协程 jobA,B,同时 jobA 又有一个子协程 jobChildA。

如果我们要取消jobB,并不会影响jobA,其依然会继续执行;

但如果我们要取消整个作用域时 scope.cancel(),jobA,jobB都会被取消,相应jobA被取消时, 因为其也有自己的作用域,所以 jobChildA 也会被取消,以此类推。而这就是协程的 结构化并发特性。


异常传播流程

默认情况下,任意一个协程发生异常时都会影响到整个协程树,而异常的传递通常是双向的,也即协程会向子协程与父协程共同传递,如下方所示:

整体流程如下:

  • 先 cancel 子协程
  • 取消自己
  • 将异常传递给父协程
  • (重复上述过程,直到根协程关闭)

举个例子,比如下面这段代码:

在上图中,我们创建了 两个子协程A,B,并在 A中 抛出异常,查看结果如右图所示, 当子协程A异常被终止时,我们的子协程B与父协程都受到影响被终止。

当然如果不想在协程异常时,同级别子协程或者父协程受到影响,此时就可以使用 SupervisorJob ,这个我们放在下面再谈。


异常传播形式

在协程中,异常的传播形式有两种,一种是自动传播( launchactor),一种是向用户暴漏该异常( asyncproduce ),这两种的区别在于,前者的异常传递过程是层层向上传递(如果异常没有被捕获),而后者将不会向上传递,会在调用处直接暴漏。

记住上述思路对我们处理协程的异常将会很有帮助。

异常处理方式

tryCatch

一般而言, tryCath 是我们最常见的处理异常方式,如下所示:

fun main() = runBlocking {
    launch {
        try {
            throw NullPointerException()
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
    println("嘿害哈")
}

当异常发生时,我们底部的输出依然能正常打印,这也不难理解,就像我们在 Android 或者 Java 中的使用一样。但有些时候这种方式并不一定能有效,我们在下面中会专门提到。但大多数情况下,tryCatch 依然如万金油一般,稳定且可靠


CoroutineExceptionHandler

其是用于在协程中全局捕获异常行为的最后一种机制,你可以理解为,类似 Thread.uncaughtExceptionHandler 一样。

但需要注意的是,CoroutineExceptionHandler 仅在未捕获的异常上调用,也即这个异常没有任何方式处理时(比如在源头tryCatch了),由于协程是结构化的,当子协程发生异常时,它会优先将异常委托给父协程区处理,以此类推 直到根协程作用域或者顶级协程 。因此其永远不会使用我们子协程 CoroutineContext 传递的 CoroutineExceptionHandler(SupervisorJob 除外),对于 async 这种,而是直接向用户直接暴漏该异常,所以我们在具体调用处直接处理就行。

如下示例所示:

 val scope = CoroutineScope(Job())
 scope.launch() {
     launch(CoroutineExceptionHandler { _, _ -> }) {
         delay(10)
         throw RuntimeException()
     }
 }

不难发现异常了,原因就是我们的 CoroutineExceptionHandler 位置不是根协程或者 CoroutineScope 初始化时。

如果我们改成下述方式,就可以正常处理该异常:

// 1. 初始化scope时
val scope = CoroutineScope(Job() + CoroutineExceptionHandler { _, _ -> })
// 2. 根协程
scope.launch(CoroutineExceptionHandler { _, _ -> }) { }

SupervisorJob

supervisorJob 是一个特殊的Job,其会改变异常的传递方式,当使用它时,我们子协程的失败不会影响到其他子协程与父协程,通俗点理解就是:子协程会自己处理异常,并不会影响其兄弟协程或者父协程,如下图所示:

举个简单的例子:

val scope = CoroutineScope(SupervisorJob() + CoroutineExceptionHandler { _, _ -> })
scope.launch(CoroutineName("A")) {
    delay(10)
    throw RuntimeException()
}
scope.launch(CoroutineName("B")) {
    delay(100)
    Log.e("petterp", "正常执行,我不会收到影响")
}

当协程A失败时,协程B依然可以正常打印。

如果我们将上述的示例改一下,会发生什么情况?如下所示:

val scope = CoroutineScope(CoroutineExceptionHandler { _, _ -> })
scope.launch(SupervisorJob()) {
    launch(CoroutineName("A")) {
        delay(10)
        throw RuntimeException()
    }
    launch(CoroutineName("B")) {
        delay(100)
        Log.e("petterp", "正常执行,我不会收到影响")
    }
}

猜一猜B协程内部的log能否正常打印?

结果是不能

为什么? 我不是已经使用了 SupervisorJob() 吗?我们用一张图来看一下:

如上图所示,我们在 scope.launch 时传递了 SupervisorJob ,看着似乎没什么问题😕,我们期望的是 SupervisorJob 也会传递到子协程。但实则不会,因为子协程在 launch 时会创建新的协程作用域,其会使用默认新的 Job 替代我们传递 SupervisorJob ,所以导致我们传递的 SupervisorJob 被覆盖。所以如果我们想让子协程不影响父协程或者其他子协程,此时就必须再显示添加 SupervisorJob。

正确的打开方式如下所示:

  scope.launch {
     launch(CoroutineName("A") + SupervisorJob()) {
         delay(10)
         throw RuntimeException()
     }
     launch(CoroutineName("B")) {
         delay(200)
         Log.e("petterp", "猜猜我还能不能打印")
     }
   }

总结如下:

SupervisorJob 可以用来改变我们的协程异常传递方式,从而让子协程自行处理异常。但需要注意的是,因为协程具有结构化的特点,SupervisorJob 仅只能用于同一级别的子协程。如果我们在初始化 scope 时添加了 SupervisorJob ,那么整个scope对应的所有 根协程 都将默认携带 SupervisorJob ,否则就必须在 CoroutineContext 显示携带 SupervisorJob。


小测试

try不住的异常

如下的代码,可以 try 住吗?

 val scope = CoroutineScope(Job())
 try {
     //A
     scope.launch {
         throw NullPointerException()
     }
 } catch (e: Exception) {
     e.printStackTrace()
 }

答案是不会

为什么呢?我不是已经在 A 外面try了吗?

默认情况下,如果 异常没有被处理,而且顶级协程 CoroutineContext 中没有携带 CoroutineExceptionHandler ,则异常会传递给默认线程的 ExceptionHandler 。在 Android 中,如果没有设置 Thread.setDefaultUncaughtExceptionHandler , 这个异常将立即被抛出,从而导致引发App崩溃。

我们在 launch 时,因为启动了一个新的协程作用域,而新的作用域内部已经是新的线程(可以理解为),因为内部发生异常时因为没有被直接捕获 , 再加上其Job不是 SupervisorJob ,所以异常将向上开始传递,因为其本身已经是根协程,此时根协程的 CoroutineContext 也没有携带 CoroutineExceptionHandler, 从而导致了直接异常。


CoroutinexxHandler 不生效?

下列代码中,添加的 CoroutineExceptionHandler 会生效吗?

val scope = CoroutineScope(Job())
scope.launch {
    val asyncA = async(SupervisorJob()+CoroutineExceptionHandler { _, _ -> }) {
        throw RuntimeException()
    }
    val asyncB = async(SupervisorJob()+CoroutineExceptionHandler { _, _ -> }) {
        throw RuntimeException()
    }
    asyncA.await()
    asyncB.await()
}


目录
相关文章
|
24天前
|
Java 编译器 测试技术
Kotlin31 协程如何与 Java 进行混编?
Kotlin31 协程如何与 Java 进行混编?
23 2
Kotlin31 协程如何与 Java 进行混编?
|
2月前
|
JSON 调度 数据库
Android面试之5个Kotlin深度面试题:协程、密封类和高阶函数
本文首发于公众号“AntDream”,欢迎微信搜索“AntDream”或扫描文章底部二维码关注,和我一起每天进步一点点。文章详细解析了Kotlin中的协程、扩展函数、高阶函数、密封类及`inline`和`reified`关键字在Android开发中的应用,帮助读者更好地理解和使用这些特性。
34 1
|
4月前
|
调度 开发者 UED
Kotlin 中的协程是什么?
【8月更文挑战第31天】
322 0
|
6月前
|
Java Serverless Kotlin
Kotlin中的异常处理
Kotlin中的异常处理
228 1
|
6月前
|
存储 Java 调度
Android面试题之Kotlin 协程的挂起、执行和恢复过程
了解Kotlin协程的挂起、执行和恢复机制。挂起时,状态和上下文(局部变量、调用栈、调度器等)被保存;挂起点通过`Continuation`对象处理,释放线程控制权。当恢复条件满足,调度器重新分配线程,调用`resumeWith`恢复执行。关注公众号“AntDream”获取更多并发知识。
137 2
|
7月前
|
移动开发 Android开发 开发者
构建高效Android应用:Kotlin与协程的完美融合
【5月更文挑战第25天】 在移动开发的世界中,性能和响应性是衡量应用质量的关键指标。随着Kotlin的流行和协程的引入,Android开发者现在有了更强大的工具来提升应用的性能和用户体验。本文深入探讨了Kotlin语言如何与协程相结合,为Android应用开发带来异步处理能力的同时,保持代码的简洁性和可读性。我们将通过实际案例分析,展示如何在Android项目中实现协程,以及它们如何帮助开发者更有效地管理后台任务和用户界面的流畅交互。
|
7月前
|
移动开发 数据库 Android开发
构建高效Android应用:探究Kotlin的协程优势
【5月更文挑战第22天】随着移动开发技术的不断进步,Android平台的性能优化已经成为开发者关注的焦点。在众多提升应用性能的手段中,Kotlin语言提供的协程概念因其轻量级线程管理和异步编程能力而受到广泛关注。本文将深入探讨Kotlin协程在Android开发中的应用,以及它如何帮助开发者构建出更高效、响应更快的应用,同时保持代码的简洁性和可读性。
|
6月前
|
XML 存储 数据格式
Kotlin Fuel库:图像下载过程中的异常处理
Kotlin Fuel库:图像下载过程中的异常处理
|
7月前
|
移动开发 数据处理 Android开发
构建高效Android应用:Kotlin的协程与Flow的使用
【5月更文挑战第23天】 在移动开发领域,性能优化和异步编程一直是核心议题。随着Kotlin语言在Android开发中的普及,其提供的协程(coroutines)和流式编程(Flow)功能为开发者带来了革命性的工具,以更简洁、高效的方式处理异步任务和数据流。本文将深入探讨Kotlin协程和Flow在Android应用中的实际应用,以及它们如何帮助开发者编写更加响应迅速且不阻塞用户界面的应用程序。我们将通过具体案例分析这两种技术的优势,并展示如何在现有项目中实现这些功能。
|
3月前
|
Android开发 开发者 Kotlin
告别AsyncTask:一招教你用Kotlin协程重构Android应用,流畅度飙升的秘密武器
【9月更文挑战第13天】随着Android应用复杂度的增加,有效管理异步任务成为关键。Kotlin协程提供了一种优雅的并发操作处理方式,使异步编程更简单直观。本文通过具体示例介绍如何使用Kotlin协程优化Android应用性能,包括网络数据加载和UI更新。首先需在`build.gradle`中添加coroutines依赖。接着,通过定义挂起函数执行网络请求,并在`ViewModel`中使用`viewModelScope`启动协程,结合`Dispatchers.Main`更新UI,避免内存泄漏。使用协程不仅简化代码,还提升了程序健壮性。
96 1