全网最详细的Kotlin协程-异常篇讲解与踩坑

前言

协程的使用中对异常的处理是非常抽象的一个过程,google了很多文档,在官方文档中对异常的处理并没有讲的很详细,编写过程中踩的坑似乎也没有官方文档的说明与解释,网上也有很对对异常的处理文献,但是看过之后发现都是零零散散,而且很多案例都是没经过代码推敲的,甚至有些文献里面的理解是错误的,所以奔着开发的理念仔细研究了一下协程的异常处理,以便更多的朋友看到这篇文章能带来更好的理解,也对封装框架设计有很大的帮助,以下案例均可以拷贝到编译器进行自行验证,如有理解不对的地方欢迎私信我进行交流学习并改正

概念

Try Catch能捕获所有的异常吗?

答案是不能,简单的举例说明:

  • 情况一:如果程序发生了异常并没有进行抛出,这个时候会捕获不到异常
  • 情况二:在java中如果程序抛出的是错误,而不是异常这种情况视捕获的代码形态决定能否捕获到异常
  • 情况三:比如动态链接库的加载错误,以及部分系统错误引起的异常不一定能捕获到

协程异常了怎么办?

当一个协程发生了异常,它将把异常传播给它的父协程,父协程会做以下几件事:

  1. 取消其他子协程
  2. 取消自己
  3. 将异常传播给自己的父协程

所以要理解协程异常的处理需要弄清楚下面几个关键点:

  • 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 = 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 只能捕捉当前线程的堆栈信息。对于非当前线程无法实现捕捉

  1. launch启动的根协程,是不会传播异常的
  2. async开启一个根协程或者子协程,异常都会被抛出给线程,并且可以被try-catch捕获到。async开启一个根协程,在调用await方法时候会抛出异常,这个异常可以用try-catch捕获不引起崩溃,如果这个协程不是根协程,那么是代码执行到 throw 异常的时候就抛出了异常与是否调用await方法无关这个异常可以用try-catch捕获但是会引起崩溃,可以用CoroutineExceptionHandler进行捕获解决崩溃问题
  3. CoroutineExceptionHandler的官方解释是:处理协程抛出的异常的函数,官方又一个隐藏点没说就是这个CoroutineExceptionHandler只能处理当前域内开启的子协程或者当前协程抛出的异常
  4. SupervisorJob这个任务是阻止异常不会向外传播,因此不会影响其父亲/兄弟协程,也不会被其兄弟协程抛出的异常影响,但是他内部生成的各种协程是依然会像job一样互相影响,并且这个异常必须使用CoroutineExceptionHandler处理掉,不然会引起程序崩溃**

最后

协程的异常处理是很复杂的一个过程,里面融合了结构化并发的思想,这个开发思想伴随了kotlin的后续开发,并且协程的异常处理中有很多坑需要一一去踩,在官方文档与网上的零散碎片知识中很难找到这些坑点,如果能认真看完上诉的讲解,肯定对协程的异常有了一个新的认知,更希望读者将上面的案例放在自己的代码中去运行总结,若有不对的地方欢迎指出改正

你可能感兴趣的:(全网最详细的Kotlin协程-异常篇讲解与踩坑)