gitbook/朱涛 · Kotlin编程第一课/docs/493666.md
2022-09-03 22:05:03 +08:00

854 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 23 | 异常try-catch居然会不起作用
你好我是朱涛。这节课我们来学习Kotlin协程的异常处理。
其实到这里我们就已经学完所有Kotlin协程的语法知识了。但在真正把Kotlin协程应用到生产环境之前我们还需要掌握一个重要知识点那就是异常处理。
比起Kotlin协程的语法知识点协程的异常处理其实更难掌握。在前面的课程中我们已经了解到**协程就是互相协作的程序,协程是结构化的**。正因为Kotlin协程有这两个特点这就导致它的异常处理机制与我们普通的程序完全不一样。
换句话说:**如果把Java里的那一套异常处理机制照搬到Kotlin协程里来你一定会四处碰壁**。因为在普通的程序当中你使用try-catch就能解决大部分的异常处理问题但是在协程当中根据不同的协程特性它的异常处理策略是随之变化的。
我自己在工作中就踩过很多这方面的坑遇到过各种匪夷所思的问题协程无法取消、try-catch不起作用导致线上崩溃率突然大增、软件功能错乱却追踪不到任何异常信息等等。说实话Kotlin协程的普及率之所以不高很大一部分原因也是因为它的异常处理机制太复杂了稍有不慎就可能会掉坑里去。
那么今天这节课我们就会来分析几个常见的协程代码模式通过解决这些异常我们可以总结出协程异常处理的6大准则。掌握了这些准则之后你在以后遇到异常问题时就能有所准备也知道该怎么处理了。
## 为什么cancel()不起作用?
在Kotlin协程当中我们通常把异常分为两大类一类是**取消异常**CancellationException另一类是**其他异常**。之所以要这么分类是因为在Kotlin协程当中这两种异常的处理方式是不一样的。或者说在Kotlin协程所有的异常当中我们需要把CancellationException单独拎出来特殊对待。
要知道当协程任务被取消的时候它的内部是会产生一个CancellationException的。而协程的结构化并发最大的优势就在于如果我们取消了父协程子协程也会跟着被取消。但是我们也知道很多初学者都会遇到一个问题那就是协程无法被取消。
这里,主要涉及了三个场景,我们一个个来分析下。
### 场景1cancel()不被响应
```plain
// 代码段1
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
while (true) {
Thread.sleep(500L)
i ++
println("i = $i")
}
}
delay(2000L)
job.cancel()
job.join()
println("End")
}
/*
输出结果
i = 1
i = 2
i = 3
i = 4
i = 5
// 永远停不下来
*/
```
在上面的代码中我们启动了一个协程在这个协程的内部我们一直对i进行自增。过了2000毫秒以后我们调用了job.cancel()。但通过运行的结果,我们可以看到协程并不会被取消。这是为什么呢?
其实前面课程里我们就讲过,协程是互相协作的程序。因此,对于协程任务的取消,也是需要互相协作的。协程外部取消,协程内部需要做出响应才行。具体来说,我们可以在协程体中加入状态判断:
```plain
// 代码段2
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
// 变化在这里
while (isActive) {
Thread.sleep(500L)
i ++
println("i = $i")
}
}
delay(2000L)
job.cancel()
job.join()
println("End")
}
/*
输出结果
i = 1
i = 2
i = 3
i = 4
i = 5
End
*/
```
在这段代码里我们把while循环的条件改成了while (isActive),这就意味着,只有协程处于活跃状态的时候,才会继续执行循环体内部的代码。
这里我们就可以进一步分析代码段1无法取消的原因了当我们调用job.cancel()以后协程任务已经不是活跃状态了但代码并没有把isActive作为循环条件因此协程无法真正取消。
所以到这里,我们就可以总结出协程异常处理的第一准则了:**协程的取消需要内部的配合**。
### 场景2结构被破坏
我们都知道,协程是结构化的,当我们取消父协程的时候,子协程也会跟着被取消。比如,我们在[第16讲](https://time.geekbang.org/column/article/487930)当中,就看到过这张图:
![图片](https://static001.geekbang.org/resource/image/9b/02/9bf8c808c91040e25fc62e468b7dfc02.gif?wh=1080x608)
但在某些情况下,我们嵌套创建的子协程并不会跟随父协程一起取消,比如下面这个案例:
```plain
// 代码段3
val fixedDispatcher = Executors.newFixedThreadPool(2) {
Thread(it, "MyFixedThread").apply { isDaemon = false }
}.asCoroutineDispatcher()
fun main() = runBlocking {
// 父协程
val parentJob = launch(fixedDispatcher) {
// 1注意这里
launch(Job()) { // 子协程1
var i = 0
while (isActive) {
Thread.sleep(500L)
i ++
println("First i = $i")
}
}
launch { // 子协程2
var i = 0
while (isActive) {
Thread.sleep(500L)
i ++
println("Second i = $i")
}
}
}
delay(2000L)
parentJob.cancel()
parentJob.join()
println("End")
}
/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
Second i = 3
First i = 3
First i = 4
Second i = 4
End
First i = 5
First i = 6
// 子协程1永远不会停下来
*/
```
以上代码中我们创建了一个fixedDispatcher它是由两个线程的线程池实现的。接着我们通过launch创建了三个协程其中parentJob是父协程随后我们等待2000毫秒然后取消父协程。
不过通过程序的运行结果我们发现虽然“子协程1”当中使用了while(isActive)作为判断条件它也仍然无法被取消。其实这里的主要原因还是在注释1处我们在创建子协程的时候**使用了launch(Job()){}**。而这种创建方式,就打破了原有的协程结构。
为了方便你理解,我画了一张图,描述它们之间的父子关系。
![](https://static001.geekbang.org/resource/image/19/c2/191c4ffcf783a14a4aef9ca934dffec2.jpg?wh=2000x983)
根据这张图可以看到“子协程1”已经不是parentJob的子协程了而对应的它的父Job是我们在launch当中传入的Job()对象。所以在这种情况下当我们调用parentJob.cancel()的时候自然也就无法取消“子协程1”了。
其实这个时候如果我们稍微改动一下上面的代码不传入Job(),程序就可以正常运行了。
```plain
// 代码段4
fun main() = runBlocking {
val parentJob = launch(fixedDispatcher) {
// 变化在这里
launch {
var i = 0
while (isActive) {
Thread.sleep(500L)
i ++
println("First i = $i")
}
}
launch {
var i = 0
while (isActive) {
Thread.sleep(500L)
i ++
println("Second i = $i")
}
}
}
delay(2000L)
parentJob.cancel()
parentJob.join()
println("End")
}
/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
First i = 4
Second i = 4
End
*/
```
在上面的代码中parentJob与它内部的子协程1、子协程2之间是父子关系因此它们两个都是会响应协程取消的事件的。这时候它们之间的关系就变成了下图这样
![](https://static001.geekbang.org/resource/image/fb/7d/fb2bf0b6f4307dcb9b678557cb1f027d.jpg?wh=2000x856)
那么到这里,我们其实就可以总结出第二条准则了:**不要轻易打破协程的父子结构**
### 场景3未正确处理CancellationException
其实对于Kotlin提供的挂起函数它们是可以自动响应协程的取消的比如说当我们把Thread.sleep(500)改为delay(500)以后我们就不需要在while循环当中判断isActive了。
```plain
// 代码段5
fun main() = runBlocking {
val parentJob = launch(Dispatchers.Default) {
launch {
var i = 0
while (true) {
// 变化在这里
delay(500L)
i ++
println("First i = $i")
}
}
launch {
var i = 0
while (true) {
// 变化在这里
delay(500L)
i ++
println("Second i = $i")
}
}
}
delay(2000L)
parentJob.cancel()
parentJob.join()
println("End")
}
/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
End
*/
```
实际上对于delay()函数来说它可以自动检测当前的协程是否已经被取消如果已经被取消的话它会抛出一个CancellationException从而终止当前的协程。
为了证明这一点我们可以在以上代码的基础上增加一个try-catch。
```plain
// 代码段6
fun main() = runBlocking {
val parentJob = launch(Dispatchers.Default) {
launch {
var i = 0
while (true) {
// 1
try {
delay(500L)
} catch (e: CancellationException) {
println("Catch CancellationException")
// 2
throw e
}
i ++
println("First i = $i")
}
}
launch {
var i = 0
while (true) {
delay(500L)
i ++
println("Second i = $i")
}
}
}
delay(2000L)
parentJob.cancel()
parentJob.join()
println("End")
}
/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
Second i = 4
Catch CancellationException
End
*/
```
请看注释1在用try-catch包裹了delay()以后我们就可以在输出结果中看到“Catch CancellationException”这就说明delay()确实可以自动响应协程的取消并且产生CancellationException异常。
不过以上代码中最重要的其实是注释2“throw e”。当我们捕获到CancellationException以后还要把它重新抛出去。而如果我们删去这行代码的话子协程将同样无法被取消。
```plain
// 代码段7
fun main() = runBlocking {
val parentJob = launch(Dispatchers.Default) {
launch {
var i = 0
while (true) {
try {
delay(500L)
} catch (e: CancellationException) {
println("Catch CancellationException")
// 1注意这里
// throw e
}
i ++
println("First i = $i")
}
}
launch {
var i = 0
while (true) {
delay(500L)
i ++
println("Second i = $i")
}
}
}
delay(2000L)
parentJob.cancel()
parentJob.join()
println("End")
}
/*
输出结果
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
Second i = 4
..
First i = 342825
Catch CancellationException
// 程序将永远无法终止
*/
```
可见在这段代码中我们把“throw e”这行代码注释掉重新运行之后程序就永远无法终止了。这主要是因为我们捕获了CancellationException以后没有重新抛出去就导致子协程无法正常取消。
所以到这里,我们就可以总结出第三条准则了:**捕获了CancellationException以后要考虑是否应该重新抛出来**。
> 题外话很多开发者喜欢在代码里捕获Exception这个父类比如这样catch(e: Exception){}这也是很危险的。平时写Demo为了方便这样写没问题但在生产环境则应该禁止。
到这里我们就通过协程取消异常的三个场景总结了三条准则来应对CancellationException这个特殊的异常。
那么接下来,我们再来看看如何在协程当中处理普通的异常。
## 为什么try-catch不起作用
如果你有Java经验那你一定会习惯性地把try-catch当做是解决所有异常的手段。但是在Kotlin协程当中try-catch并非万能的。有时候即使你用try-catch包裹了可能抛异常的代码软件仍然会崩溃。比如下面这个例子
```plain
// 代码段8
fun main() = runBlocking {
try {
launch {
delay(100L)
1 / 0 // 故意制造异常
}
} catch (e: ArithmeticException) {
println("Catch: $e")
}
delay(500L)
println("End")
}
/*
输出结果:
崩溃
Exception in thread "main" ArithmeticException: / by zero
*/
```
在这段代码中我们使用try-catch包裹了launch{}在协程体内部我们制造了一个异常。不过从运行结果这里我们可以看到try-catch并没有成功捕获异常程序等待了100毫秒左右最终还是崩溃了。
类似的如果我们把代码段8当中的launch换成async结果也是差不多的
```plain
// 代码段9
fun main() = runBlocking {
var deferred: Deferred<Unit>? = null
try {
deferred = async {
delay(100L)
1 / 0
}
} catch (e: ArithmeticException) {
println("Catch: $e")
}
deferred?.await()
delay(500L)
println("End")
}
/*
输出结果:
崩溃
Exception in thread "main" ArithmeticException: / by zero
*/
```
其实对于这种try-catch失效的问题如果你还记得在[第14讲](https://time.geekbang.org/column/article/486305)当中我提到的launch、async的**代码运行顺序**的问题那你就一定可以理解其中的原因。这主要就是因为当协程体当中的“1/0”执行的时候我们的程序已经跳出try-catch的作用域了。
当然要解决这两个问题也很容易。对于代码段8来说我们可以挪动一下try-catch的位置比如说这样
```plain
// 代码段10
fun main() = runBlocking {
launch {
try {
delay(100L)
1 / 0 // 故意制造异常
} catch (e: ArithmeticException) {
println("Catch: $e")
}
}
delay(500L)
println("End")
}
/*
输出结果:
Catch: java.lang.ArithmeticException: / by zero
End
*/
```
也就是说我们可以把try-catch挪到launch{} 协程体内部。这样一来它就可以正常捕获到ArithmeticException这个异常了。
而对于代码段9的async的这个例子我们其实有两种解决手段其中一种跟上面的做法一样我们把try-catch挪到了async{} 协程体内部,比如这样:
```plain
// 代码段11
fun main() = runBlocking {
var deferred: Deferred<Unit>? = null
deferred = async {
try {
delay(100L)
1 / 0
} catch (e: ArithmeticException) {
println("Catch: $e")
}
}
deferred?.await()
delay(500L)
println("End")
}
```
OK到这里我们就可以总结出第四条准则了**不要用try-catch直接包裹launch、async**。
接下来我们再看看async的另外一种手段其实这种方式网上有些博客也介绍过我们可以使用try-catch包裹“deferred.await()”。让我们来看看是否可行:
```plain
// 代码段12
fun main() = runBlocking {
val deferred = async {
delay(100L)
1 / 0
}
try {
deferred.await()
} catch (e: ArithmeticException) {
println("Catch: $e")
}
delay(500L)
println("End")
}
/*
输出结果
Catch: java.lang.ArithmeticException: / by zero
崩溃:
Exception in thread "main" ArithmeticException: / by zero
*/
```
那么根据以上程序的运行结果可以看到这样做其实是行不通的。如果你看过一些其他博客甚至还有种说法是await()如果不调用的话async当中的异常甚至不会发生。我们再来试试看
```plain
// 代码段13
fun main() = runBlocking {
val deferred = async {
delay(100L)
1 / 0
}
delay(500L)
println("End")
}
/*
输出结果
崩溃:
Exception in thread "main" ArithmeticException: / by zero
*/
```
可见async当中产生异常即使我们不调用await()同样是会导致程序崩溃的。那么,为什么会发生这样的情况?是不是我们忽略了什么?
### SupervisorJob
实际上如果我们要使用try-catch包裹“deferred.await()”的话,还需要配合**SupervisorJob**一起使用。也就是说借助SupervisorJob来改造代码段13的话我们就可以实现“不调用await()就不会产生异常而崩溃”。
```plain
// 代码段14
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob())
scope.async {
delay(100L)
1 / 0
}
delay(500L)
println("End")
}
/*
输出结果
End
*/
```
可以看到当我们使用SupervisorJob创建一个scope以后用scope.async{}启动协程后只要不调用“deferred.await()”,程序就不会因为异常而崩溃。
所以同样的我们也能用类似的办法来改造代码段12当中的逻辑
```plain
// 代码段15
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob())
// 变化在这里
val deferred = scope.async {
delay(100L)
1 / 0
}
try {
deferred.await()
} catch (e: ArithmeticException) {
println("Catch: $e")
}
delay(500L)
println("End")
}
/*
输出结果
Catch: java.lang.ArithmeticException: / by zero
End
*/
```
在上面的代码中我们仍然使用“scope.async {}”创建了协程同时也用try-catch包裹“deferred.await()”,这样一来,这个异常就成功地被我们捕获了。
那么,**SupervisorJob到底是何方神圣**?让我们来看看它的源码定义:
```plain
// 代码段16
public fun SupervisorJob(parent: Job? = null) : CompletableJob
= SupervisorJobImpl(parent)
public interface CompletableJob : Job {
public fun complete(): Boolean
public fun completeExceptionally(exception: Throwable): Boolean
}
```
根据以上代码我们可以看到SupervisorJob()其实不是构造函数,**它只是一个普通的顶层函数**。而这个方法返回的对象是Job的子类。
SupervisorJob与Job最大的区别就在于当它的子Job发生异常的时候其他的子Job不会受到牵连。我这么说你可能会有点懵下面我做了一个动图来演示普通Job与SupervisorJob之间的差异。
![图片](https://static001.geekbang.org/resource/image/c0/64/c0eeea3b0b8b016df76ae7b3d9620264.gif?wh=1080x608)
这个是普通Job对于子Job出现异常时的应对策略。可以看到由于parentJob是一个普通的Job对象当job1发生异常之后它会导致parentJob取消进而导致job2、job3也受到牵连。
而这时候如果我们把parentJob改为SupervisorJobjob1发生异常的的话就不会影响到其他的Job了。
![](https://static001.geekbang.org/resource/image/a4/cc/a482f7082d6d87dea51ffdb856e292cc.jpg?wh=2000x864)
所以到这里,我们就可以总结出第五条准则了:**灵活使用SupervisorJob控制异常传播的范围**。
> 提示并非所有情况下我们都应该使用SupervisorJob有时候Job会更合适这要结合实际场景分析。
到目前为止我们就已经了解了try-catch和SupervisorJob这两种处理异常的手段。但是由于协程是结构化的当我们的协程任务出现复杂的层级时这两种手段其实都无法很好的应对。所以这个时候我们就需要CoroutineExceptionHandler出场了。
### CoroutineExceptionHandler
对于CoroutineExceptionHandler我们其实在[第17讲](https://time.geekbang.org/column/article/488571)里也简单地提到过。它是CoroutineContext的元素之一我们在创建协程的时候可以指定对应的CoroutineExceptionHandler。
那么CoroutineExceptionHandler究竟适用于什么样的场景呢让我们来看一个例子
```plain
// 代码段17
fun main() = runBlocking {
val scope = CoroutineScope(coroutineContext)
scope.launch {
async {
delay(100L)
}
launch {
delay(100L)
launch {
delay(100L)
1 / 0 // 故意制造异常
}
}
delay(100L)
}
delay(1000L)
println("End")
}
/*
输出结果
Exception in thread "main" ArithmeticException: / by zero
*/
```
在上面的代码中我模拟了一个复杂的协程嵌套场景。对于这样的情况我们其实很难一个个在每个协程体里面去写try-catch。所以这时候为了捕获到异常我们就可以使用CoroutineExceptionHandler了。
```plain
// 代码段18
fun main() = runBlocking {
val myExceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("Catch exception: $throwable")
}
// 注意这里
val scope = CoroutineScope(coroutineContext + Job() + myExceptionHandler)
scope.launch {
async {
delay(100L)
}
launch {
delay(100L)
launch {
delay(100L)
1 / 0 // 故意制造异常
}
}
delay(100L)
}
delay(1000L)
println("End")
}
/*
Catch exception: ArithmeticException: / by zero
End
*/
```
以上代码中我们定义了一个CoroutineExceptionHandler然后把它传入了scope当中这样一来我们就可以捕获其中所有的异常了。
看到这里你也许松了一口气终于有了一个简单处理协程异常的方式了。不过你也别高兴得太早因为我曾经就踩过CoroutineExceptionHandler的一个坑最终导致App功能大面积异常。
而出现这个问题的原因就是CoroutineExceptionHandler不起作用了
### 为什么CoroutineExceptionHandler不起作用
为了模拟我当时的业务场景我把代码段18稍作改动。
```plain
// 代码段19
fun main() = runBlocking {
val myExceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("Catch exception: $throwable")
}
// 不再传入myExceptionHandler
val scope = CoroutineScope(coroutineContext)
scope.launch {
async {
delay(100L)
}
launch {
delay(100L)
// 变化在这里
launch(myExceptionHandler) {
delay(100L)
1 / 0
}
}
delay(100L)
}
delay(1000L)
println("End")
}
/*
输出结果
崩溃:
Exception in thread "main" ArithmeticException: / by zero
*/
```
请你留意上面的注释我们把自定义的myExceptionHandler放到出现异常的launch那里传了进去。按理说程序的执行结果是不会发生变化才对的。但实际上myExceptionHandler并不会起作用我们的异常不会被它捕获。
如果你对比代码段18和代码段19你会发现myExceptionHandler直接定义在发生异常的位置反而不生效而定义在最顶层却可以生效你说它的作用域是不是很古怪
![图片](https://static001.geekbang.org/resource/image/22/13/22fyya9f9de13c8580b4508e7eabe813.gif?wh=1080x608)
其实出现这种现象的原因就是因为CoroutineExceptionHandler只在顶层的协程当中才会起作用。也就是说当子协程当中出现异常以后它们都会统一上报给顶层的父协程然后顶层的父协程才会去调用CoroutineExceptionHandler来处理对应的异常。
那么到这里,我们就可以总结出第六条准则了:**使用CoroutineExceptionHandler处理复杂结构的协程异常它仅在顶层协程中起作用**。
## 小结
至此,这节课的内容就接近尾声了,我们来做一个简单的总结。
在Kotlin协程当中异常主要分为两大类一类是协程取消异常CancellationException另一类是其他异常。为了处理这两大类问题我们一共总结出了6大准则这些我们都要牢记在心。
![](https://static001.geekbang.org/resource/image/72/c5/72b96aa44f68fde40f626fe536eb36c5.jpg?wh=2000x763)
* 第一条准则:**协程的取消需要内部的配合**。
* 第二条准则:**不要轻易打破协程的父子结构**!这一点,其实不仅仅只是针对协程的取消异常,而是要贯穿于整个协程的使用过程中。我们知道,协程的优势在于结构化并发,它的许多特性都是建立在这个特性之上的,如果我们无意中打破了它的父子结构,就会导致协程无法按照预期执行。
* 第三条准则:**捕获了CancellationException以后要考虑是否应该重新抛出来**。在协程体内部协程是依赖于CancellationException来实现结构化取消的有的时候我们出于某些目的需要捕获CancellationException但捕获完以后我们还需要思考是否需要将其重新抛出来。
* 第四条准则:**不要用try-catch直接包裹launch、async**。这一点是很多初学者会犯的错误考虑到协程代码的执行顺序与普通程序不一样我们直接使用try-catch包裹launch、async是不会有任何效果的。
* 第五条准则:**灵活使用SupervisorJob控制异常传播的范围**。SupervisorJob是一种特殊的Job它可以控制异常的传播范围。普通的Job它会因为子协程当中的异常而取消自身而SupervisorJob则不会受到子协程异常的影响。在很多业务场景下我们都不希望子协程影响到父协程所以SupervisorJob的应用范围也非常广。比如说Android当中的viewModelScope它就使用了SupervisorJob这样一来我们的App就不会因为某个子协程的异常导致整个应用的功能出现紊乱。
* 第六条准则:**使用CoroutineExceptionHandler处理复杂结构的协程异常它仅在顶层协程中起作用**。我们都知道传统的try-catch在协程当中并不能解决所有问题尤其是在协程嵌套层级较深的情况下。这时候Kotlin官方为我们提供了CoroutineExceptionHandler作为补充。有了它我们可以轻松捕获整个作用域内的所有异常。
其实这节课里我提到的这些案例只是我平时工作中遇到的很小一部分。案例是讲不完的在协程中处理异常你将来肯定也会遇到千奇百怪的问题。但重要的是分析问题的思路还有解决问题的手段。这节课我给你总结的6大准则呢就是你将来遇到协程异常时可以用的6种处理手段。
当我们遇到问题的时候首先要分析是CancellationException导致的还是其他异常导致的。接着我们就可以根据实际情况去思考该用哪种处理手段了。
另外如果你足够细心的话你会发现这节课总结出的6大准则其实都跟协程的**结构化并发**有着密切联系。由于协程之间存在父子关系,因此它的异常处理也是遵循这一规律的。而协程的异常处理机制之所以这么复杂,也是因为它的结构化并发特性。
所以除了这6大准则以外我们还可以总结出一个核心理念**因为协程是“结构化的”,所以异常传播也是“结构化的”**。
如果你能理解协程异常处理的核心理念同时能够牢记前面的6大准则。我相信将来不论你遇到什么样的古怪问题你都可以分析出问题的根源找到解决方案
## 思考题
前面我们提到过CoroutineExceptionHandler可以一次性捕获整个作用域内所有协程的异常。那么我们是不是可以抛弃try-catch只使用CoroutineExceptionHandler呢为什么
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。