全网最详细的Kotlin协程-异常篇讲解与踩坑
前言
协程的使用中对异常的处理是非常抽象的一个过程,google了很多文档,在官方文档中对异常的处理并没有讲的很详细,编写过程中踩的坑似乎也没有官方文档的说明与解释,网上也有很对对异常的处理文献,但是看过之后发现都是零零散散,而且很多案例都是没经过代码推敲的,甚至有些文献里面的理解是错误的,所以奔着开发的理念仔细研究了一下协程的异常处理,以便更多的朋友看到这篇文章能带来更好的理解,也对封装框架设计有很大的帮助,以下案例均可以拷贝到编译器进行自行验证,如有理解不对的地方欢迎私信我进行交流学习并改正
概念
Try Catch能捕获所有的异常吗?
答案是不能,简单的举例说明:
- 情况一:如果程序发生了异常并没有进行抛出,这个时候会捕获不到异常
- 情况二:在java中如果程序抛出的是错误,而不是异常这种情况视捕获的代码形态决定能否捕获到异常
- 情况三:比如动态链接库的加载错误,以及部分系统错误引起的异常不一定能捕获到
协程异常了怎么办?
当一个协程发生了异常,它将把异常传播给它的父协程,父协程会做以下几件事:
- 取消其他子协程
- 取消自己
- 将异常传播给自己的父协程
所以要理解协程异常的处理需要弄清楚下面几个关键点:
- try-catch捕获异常
- CoroutineExceptionHandler
- supervisorScope 和SupervisorJob
程序示例
看下面的代码
fun test() {
try {
Thread() {
throw NullPointerException()
}.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
结果是:运行崩溃
这里如果有朋友觉得很不可思议的话可以进行自我测试,为什么try-catch中开启代码还是会崩溃呢?
==答案是try-catch 只能捕捉当前线程的堆栈信息。对于非当前线程无法实现捕捉==
既然这样下面代码应该会被捕捉到:
fun test() = runBlocking(Dispatchers.IO) {
try {
launch {
throw NullPointerException()
}
} catch (e: Exception) {
e.printStackTrace()
Log.d("wangxuyang", "" + e.message)
}
}
结果是:运行崩溃
what f***?,这个协程是在当前线程开启的,并进行了try-catch为什么还是会崩溃呢?
这里直接告诉结论是:==launch启动的根协程,是不会传播异常的==
什么叫传播异常?
传播异常,是指能够将异常主动往外抛到启动顶层协程所在的线程。因为launch启动的协程,是不会将异常抛到线程,所以try-catch无法捕捉,为了让这种异常能够捕捉到。协程引入了CoroutineExceptionHandler
启动协程还有一种方式是async,那这种会不会向线程抛出异常呢?代码运行如下:
private val job: Job = Job()
private val scope = CoroutineScope(Dispatchers.Default + job)
private fun doWork(): Deferred<String> = scope.async { throw NullPointerException("自定义空指针异常") }
private fun loadData() = scope.launch {
try {
doWork().await()
} catch (e: Exception) {
Log.d("try catch捕获的异常:", e.toString())
}
}
结果是:运行不会崩溃
代码中try-catch住的代码是:
doWork().await()
结论:==虽然向外抛出了异常,但是是在调用await()方法后抛出的,并且当async作为根协程时,被封装到deferred对象中的异常才会在调用await时抛出,并且这个异常是可以被try-catch捕获住的==
上面说到根协程并且这个根协程是调用了await()抛出异常,其实这里是一个大坑,笔者在测试过程中感到也很神奇,接下来看这段代码:
private val job0: Job = Job()
private val scope0 = CoroutineScope(Dispatchers.Default + job0)
private fun loadData0() = scope0.launch {
val asy = async {
Log.d("async 异常:", "开始准备抛出异常")
delay(1000)
throw NullPointerException("自定义空指针异常")
}
try {
asy.await()
} catch (e: Exception) {
Log.d("async 异常: 捕获的异常-", e.toString())
}
Log.d("async 异常:", "继续执行后续代码")
}
运行结果是:程序崩溃
2022-03-22 19:51:02.074 25864-25903/com.example.coroutinestest D/async 异常:: 开始准备抛出异常
2022-03-22 19:51:03.085 25864-25905/com.example.coroutinestest D/async 异常: 捕获的异常-: java.lang.NullPointerException: 自定义空指针异常
2022-03-22 19:51:03.085 25864-25905/com.example.coroutinestest D/async 异常:: 继续执行后续代码
乍一看,跟上面代码的逻辑走势一样,也是调用了await方法,也是try-catch了这个方法 ,打的日志也是捕获到了,是正常的流程啊
但是我告诉大家这里并不是调用await方法后才抛出的异常,只是崩溃后这个异常被捕获到了而已,是不是大家要觉得我很菜?可以这样来印证这个猜想,讲await方法屏蔽掉,再运行这个方法:
try {
// asy.await()
} catch (e: Exception) {
Log.d("async 异常: 捕获的异常-", e.toString())
}
Log.d("async 异常:", "继续执行后续代码")
结果是:程序崩溃,日志如下
//2022-03-22 19:55:05.460 26378-26415/com.example.coroutinestest D/async 异常:: 继续执行后续代码
//2022-03-22 19:55:05.461 26378-26415/com.example.coroutinestest D/async 异常:: 开始准备抛出异常
这里是不是印证了前面的猜想,崩溃原因其实不是在调用await方法之后引起的崩溃,是代码执行到 throw NullPointerException("自定义空指针异常")就抛出异常了,所以前面的结论是成立的
结论是:==async开启一个根协程或者子协程,异常都会被抛出给线程,并且可以被try-catch捕获到。async开启一个根协程,在调用await方法时候会抛出异常,这个异常可以用try-catch捕获不引起崩溃,如果这个协程不是根协程,那么是代码执行到 throw 异常的时候就抛出了异常与是否调用await方法无关这个异常可以用try-catch捕获但是会引起崩溃,可以用CoroutineExceptionHandler进行捕获解决崩溃问题==
CoroutineExceptionHandler的应用
上面印证了程序的崩溃与异常的抛出,但是这个异常怎么处理呢?这里就用到了官方提供的CoroutineExceptionHandler了
/**
* Creates a [CoroutineExceptionHandler] instance.
* @param handler a function which handles exception thrown by a coroutine
*/
==CoroutineExceptionHandler的官方解释是:处理协程抛出的异常的函数,官方又一个隐藏点没说就是这个CoroutineExceptionHandler只能处理当前域内开启的子协程或者当前协程抛出的异常==
所以解决上诉不是根协程引起的崩溃问题可以采用这样的方式:
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, _ ->
Log.d("async 异常:", "异常被内部CoroutineExceptionHandler处理掉了")
}
private fun loadData0() = scope0.launch(coroutineExceptionHandler) {
val asy = async {
Log.d("async 异常:", "开始准备抛出异常")
delay(1000)
throw NullPointerException("自定义空指针异常")
}
try {
asy.await()
} catch (e: Exception) {
Log.d("async 异常: 捕获的异常-", e.toString())
}
Log.d("async 异常:", "继续执行后续代码")
}
运行结果:不会崩溃,日志如下
2022-03-22 20:02:31.121 27083-27166/com.example.coroutinestest D/async 异常:: 开始准备抛出异常
2022-03-22 20:02:32.134 27083-27167/com.example.coroutinestest D/async 异常: 捕获的异常-: java.lang.NullPointerException: 自定义空指针异常
2022-03-22 20:02:32.134 27083-27167/com.example.coroutinestest D/async 异常:: 继续执行后续代码
2022-03-22 20:02:32.135 27083-27166/com.example.coroutinestest D/async 异常:: 异常被内部CoroutineExceptionHandler处理掉了
看到了代码即使是抛出了异常,但是被内部消耗了,并缺不会引起程序崩溃
前面提到了launch启动的根协程,是不会传播异常的
这里我们继续印证这个结论:
例子1:
private fun loadData1() = scope1.launch {
try {
throw NullPointerException("自定义空指针异常")
} catch (e: Exception) {
Log.d("try catch捕获的异常:", e.toString())
}
}
结果:不会崩溃
例子2:
private fun loadData1() = try {
scope1.launch {
throw NullPointerException("自定义空指针异常")
}
} catch (e: Exception) {
Log.d("try catch捕获的异常:", e.toString())
}
结果:会崩溃
例子3:
private fun doWork1() = scope1.launch { throw NullPointerException("自定义空指针异常") }
private fun loadData1() = scope1.launch {
try {
doWork1()
} catch (e: Exception) {
Log.d("try catch捕获的异常:", e.toString())
}
}
结果:会崩溃
==从例1与例2可以看出异常在协程内部可以被捕获,但是在外部不能被捕获,这里印证了launch不向外抛出异常的结论,再从例3与例1对比可以看出这个协程,这个协程并不是只有根协程才不向线程抛出异常,而是只要launch开启的协程,无论是根还是子都不会向线程中抛出异常==
同样可以使用上诉方法来解决这个崩溃问题:
private val job2: Job = Job()
private val scope2 = CoroutineScope(Dispatchers.Default + job2)
private fun loadData2() = scope2.launch(CoroutineExceptionHandler { _, exception ->
{
Log.d("Handler捕获的异常", exception.toString())
}
}) {
try {
//无论launch有几层都不会崩溃
launch { launch { throw NullPointerException("自定义空指针异常") } }
} catch (e: Exception) {
Log.d("try catch捕获的异常:", e.toString())
}
}
再来印证前面所说的:CoroutineExceptionHandler只能处理当前域内开启的子协程或者当前协程抛出的异常
运行下面的代码:
private val job3: Job = Job()
private val scope3 = CoroutineScope(Dispatchers.Default + job3)
private fun doWork3() = scope3.launch { throw NullPointerException("自定义空指针异常") }
private fun loadData3() = scope3.launch(CoroutineExceptionHandler { _, exception ->
{
Log.d("Handler捕获的异常", exception.toString())
}
}) {
try {
doWork3()
} catch (e: Exception) {
Log.d("try catch捕获的异常:", e.toString())
}
}
结果是:崩溃
因为doWork3方法开启的协程不是在当前域下开启的协程而是scope3开启的,只是在当前域下运行而已,这里就印证了上面的说法
但是可以通过增加一个CoroutineExceptionHandler来解决上面的问题,代码如下:
private val job4: Job = Job()
private val scope4 =
CoroutineScope(Dispatchers.Default + job4 + CoroutineExceptionHandler { _, exception ->
{
Log.d("Handler捕获的异常", exception.toString())
}
})
//无论launch有几层都不会崩溃
private fun doWork4() = scope4.launch { launch { throw NullPointerException("自定义空指针异常") } }
private fun loadData4() = scope4.launch {
try {
doWork4()
} catch (e: Exception) {
Log.d("try catch捕获的异常:", e.toString())
}
}
结果是:不会崩溃
supervisorScope 和 SupervisorJob
前面讲到了CoroutineExceptionHandler可以捕获异常并且处理掉异常,程序不会崩溃,这里还有一种方式就是使用supervisorScope 和 SupervisorJob
supervisorScope 和 SupervisorJob的原理是:将异常不传播给自己的父协程
首先我们来看一个例子:
private val handler7 = CoroutineExceptionHandler { _, _ ->
Log.d("kobe", "CoroutineExceptionHandler")
}
private fun coroutineBuildRunBlock7() = runBlocking(Dispatchers.IO) {
CoroutineScope(Job() + handler7)
.launch {
launch {
Log.d("kobe", "start job1 delay")
delay(1000)
Log.d("kobe", "end job1 delay")
}
launch {
Log.d("kobe", "job2 throw execption")
throw NullPointerException()
}
}
}
结果是:不崩溃,日志如下
2022-03-22 15:24:34.022 20373-20411/com.example.coroutinestest D/kobe: start job1 delay
2022-03-22 15:24:34.025 20373-20412/com.example.coroutinestest D/kobe: job2 throw execption
2022-03-22 15:24:34.029 20373-20412/com.example.coroutinestest D/kobe: CoroutineExceptionHandler
看到一个现象就是:子协程崩溃会引起兄弟协程的执行错误,这就是文章前面所说的取消其他子协程,这当然不是我们想看到的情况,互不影响才是最优解,所以有了下面的方法:
private val handler8 = CoroutineExceptionHandler { _, _ ->
Log.d("kobe", "CoroutineExceptionHandler")
}
private fun coroutineBuildRunBlock8() = runBlocking(Dispatchers.IO) {
CoroutineScope(Job() + handler8)
.launch {
launch {
delay(2000)
Log.d("kobe", "start job3 delay")
}
supervisorScope {
launch {
Log.d("kobe", "start job1 delay")
delay(1000)
Log.d("kobe", "end job1 delay")
}
launch {
Log.d("kobe", "job2 throw execption")
throw NullPointerException()
}
}
}
}
结果是:不崩溃,日志如下
2022-03-22 15:48:07.384 21777-21818/com.example.coroutinestest D/kobe: start job1 delay
2022-03-22 15:48:07.384 21777-21820/com.example.coroutinestest D/kobe: job2 throw execption
2022-03-22 15:48:07.385 21777-21820/com.example.coroutinestest D/kobe: CoroutineExceptionHandler
2022-03-22 15:48:08.391 21777-21818/com.example.coroutinestest D/kobe: end job1 delay
2022-03-22 15:48:09.389 21777-21818/com.example.coroutinestest D/kobe: start job3 delay
按照前面的逻辑异常捕获了,使用了supervisorScope所以一个子协程的异常不会会影响另一个子协程的运行,并且不会影响这个域外的兄弟协程,所以日志全
所以supervisorScope中开启协程,无论多少个子协程都互不影响,这是我们想要的处理情况
那我们再来看下SupervisorJob,运行下面代码:
private val supervisorJob9 = SupervisorJob()
private val handler9 = CoroutineExceptionHandler { _, _ ->
Log.d("kobe", "CoroutineExceptionHandler")
}
private val handler99 = CoroutineExceptionHandler { _, _ ->
Log.d("kobe", "顶层异常处理")
}
private fun coroutineBuildRunBlock9() = runBlocking(Dispatchers.IO) {
CoroutineScope(handler99 ).launch {
CoroutineScope( handler9+supervisorJob9)
.launch {
launch {
Log.d("kobe", "start job1 delay")
delay(1000)
Log.d("kobe", "end job1 delay")
}
launch {
Log.d("kobe", "job2 throw execption")
throw NullPointerException()
}
}
}
}
结果是:不会崩溃,日志如下
2022-03-23 17:32:25.771 8593-8638/com.example.coroutinestest D/kobe: job2 throw execption
2022-03-23 17:32:25.772 8593-8642/com.example.coroutinestest D/kobe: start job1 delay
2022-03-23 17:32:25.785 8593-8642/com.example.coroutinestest D/kobe: CoroutineExceptionHandler
我们这次来分析日志,日志中没有“顶层异常处理”所以这个异常肯定就没有传播出去,也没有打出“end job1 delay”来表示影响了这个协程内部的兄弟协程
所以结论是: ==SupervisorJob这个任务是阻止异常不会向外传播,因此不会影响其父亲/兄弟协程,也不会被其兄弟协程抛出的异常影响,但是他内部生成的各种协程是依然会像job一样互相影响,并且这个异常必须使用CoroutineExceptionHandler处理掉,不然会引起程序崩溃==
看到这里可能又有人会问这个很正常,因为异常被handler9处理掉了,所以就没有传递到父亲协程,那这里我们可以这样处理,我们去掉这个handler9:
private fun coroutineBuildRunBlock9() = runBlocking(Dispatchers.IO) {
CoroutineScope(handler99 ).launch {
CoroutineScope(supervisorJob9)
.launch {
launch {
Log.d("kobe", "start job1 delay")
delay(1000)
Log.d("kobe", "end job1 delay")
}
launch {
Log.d("kobe", "job2 throw execption")
throw NullPointerException()
}
}
}
}
结果:程序崩溃,并且没有打印出“顶层异常处理”,所以前面的结论是正确的
我们再来印证以下兄弟协程是否被影响,运行代码:
private val supervisorJob10 = SupervisorJob()
private val handler10 = CoroutineExceptionHandler { _, _ ->
Log.d("kobe", "CoroutineExceptionHandler")
}
private val coroutineContext10 = handler10 + supervisorJob10
private fun coroutineBuildRunBlock10() = runBlocking(Dispatchers.IO) {
CoroutineScope(coroutineContext10)
.launch {
launch {
Log.d("kobe", "start job1 delay")
delay(1000)
Log.d("kobe", "end job1 delay")
}
launch {
Log.d("kobe", "start job2 delay")
delay(1000)
Log.d("kobe", "end job2 delay")
}
CoroutineScope(coroutineContext10).launch {
launch {
Log.d("kobe", "start job3 delay")
delay(1000)
Log.d("kobe", "end job3 delay")
}
launch {
Log.d("kobe", "job4 throw execption")
throw NullPointerException()
}
}
}
}
结果是:不会崩溃,日志如下
2022-03-22 15:45:20.807 21611-21653/com.example.coroutinestest D/kobe: start job1 delay
2022-03-22 15:45:20.809 21611-21652/com.example.coroutinestest D/kobe: start job2 delay
2022-03-22 15:45:20.814 21611-21651/com.example.coroutinestest D/kobe: start job3 delay
2022-03-22 15:45:20.815 21611-21654/com.example.coroutinestest D/kobe: job4 throw execption
2022-03-22 15:45:20.817 21611-21654/com.example.coroutinestest D/kobe: CoroutineExceptionHandler
2022-03-22 15:45:21.820 21611-21654/com.example.coroutinestest D/kobe: end job1 delay
2022-03-22 15:45:21.820 21611-21651/com.example.coroutinestest D/kobe: end job2 delay
结果是:兄弟协程并不影响,前面的结论正确
结论
**1. try-catch 只能捕捉当前线程的堆栈信息。对于非当前线程无法实现捕捉
- launch启动的根协程,是不会传播异常的
- async开启一个根协程或者子协程,异常都会被抛出给线程,并且可以被try-catch捕获到。async开启一个根协程,在调用await方法时候会抛出异常,这个异常可以用try-catch捕获不引起崩溃,如果这个协程不是根协程,那么是代码执行到 throw 异常的时候就抛出了异常与是否调用await方法无关这个异常可以用try-catch捕获但是会引起崩溃,可以用CoroutineExceptionHandler进行捕获解决崩溃问题
- CoroutineExceptionHandler的官方解释是:处理协程抛出的异常的函数,官方又一个隐藏点没说就是这个CoroutineExceptionHandler只能处理当前域内开启的子协程或者当前协程抛出的异常
- SupervisorJob这个任务是阻止异常不会向外传播,因此不会影响其父亲/兄弟协程,也不会被其兄弟协程抛出的异常影响,但是他内部生成的各种协程是依然会像job一样互相影响,并且这个异常必须使用CoroutineExceptionHandler处理掉,不然会引起程序崩溃**
最后
协程的异常处理是很复杂的一个过程,里面融合了结构化并发的思想,这个开发思想伴随了kotlin的后续开发,并且协程的异常处理中有很多坑需要一一去踩,在官方文档与网上的零散碎片知识中很难找到这些坑点,如果能认真看完上诉的讲解,肯定对协程的异常有了一个新的认知,更希望读者将上面的案例放在自己的代码中去运行总结,若有不对的地方欢迎指出改正