kotlin协程2025 通俗易懂三部曲之下篇 异常处理

用了kt已经3年多,协程也一直在用,但是并没有好好的整体盘一遍。现在整个完全学习版,总算是又通畅了不少,加深了理解。

计划分成3篇完成讲解。

上篇,kotlin协程2025 通俗易懂三部曲之上篇 用法全解

中篇, kotlin协程2025 通俗易懂三部曲之中篇 常用函数和并发处理

下篇,讲解异常处理,本文。

kotlin的异常处理是十分重要的,

简单来讲,通过CoroutineExceptionHandlertry-catch实现,但是不同的场景下体现的结果是有差别的。

梳理了一下以及项目中的心得。

顶级:通过GlobalScope创建,不向外传播异常。

协同:Job嵌套,coroutineScope创建,双向传播。

主从:supervisorScope创建,自上而下,单向传播。

try-catch

kotlin 复制代码
val topLevelScope = CoroutineScope(Job())
//正确捕获
topLevelScope.launch {
    try {
        throw RuntimeException("RuntimeException in coroutine")
    } catch (exception: Exception) {
        println("Handle $exception")
    }
}

//错误示范: 无法捕获
topLevelScope.launch {
    try {
        launch {
            throw RuntimeException("RuntimeException in nested coroutine")
        }
    } catch (exception: Exception) {
        println("Handle $exception")
    }
}

在协程中未捕获的异常会发生什么呢? 协程最创新的功能之一就是结构化并发

为了使结构化并发的所有功能成为可能,CoroutineScope的Job对象以及Coroutines和Child-Coroutines的Job对象形成了父子关系的层次结构。未传播的异常(而不是重新抛出)是"在工作层次结构中传播"。 这种异常传播会导致父Job的失败,进而导致其子级所有Job的取消。

如果协程本身不使用try-catch子句自行处理异常,则不会重新抛出该异常,因此无法通过外部try-catch子句进行处理。

异常会在"Job层次结构中传播",而是交由CoroutineExceptionHandler处理。 如果未设置,则交给线程的未捕获异常处理,进而APP崩溃。

coroutineScope() 异常

coroutineScope {}主要用于suspend函数中以实现"并行分解",这些suspend函数将重新抛出其失败的协程的异常。

创建一个协程作用域并且在里面执行协程代码块。这个Scope继承外面scope的coroutineContext,但是重写它的Job。

这个函数设计是为了并行分解工作。当任一协程失败,整个scope就失败,剩下的协程就会被cancel。当所有的子协程完成后才会返回。

举例lifecycleScope+coroutineScope的Demo1:

kotlin 复制代码
lifecycleScope.launch(Dispatchers.IO + CoroutineExceptionHandler{e,t->
    logt { "catched: ${t.message}" }
}) { //launch1
    logt { "run start" }
    coroutineScope {
        launch{
            logt { "run c1" }
            throw RuntimeException("error crash")
        }
        launch{
            delay(50)
            logt { "run c2" }
        }
    }
    delay(100)
    logt { "run end" }
}

lifecycleScope.launch {//launch2
    delay(200)
    logt { "other" }
}

执行结果为:

kotlin 复制代码
51.164 SubThread[59]: run start
51.166 SubThread[61]: run c1
51.168 SubThread[59]: catched: error crash
51.366 MainThread: other

由于lifecycleScope是supervisorScope,因此2个launch,第一个crash不影响第二个(前提是提供了异常处理handler)。

而由coroutineScope()开启的协程,runC1出现错误,导致整个协程launch1失败。

如果我们给coroutineScope {}加上try-catch:

kotlin 复制代码
try {
      coroutineScope {
          launch{
              logt { "run c1" }
              throw RuntimeException("error crash")
          }//协程C2
          launch{
              delay(50)
              logt { "run c2" }
          }//协程C3
      }
  } catch (e: Exception) {
      e.printStackTrace()
      logt { "inner catch: ${e.message}" }
  }

将会得到:

kotlin 复制代码
02.913 SubThread[59]: run start
02.915 SubThread[61]: run c1
02.920 SubThread[61]: inner catch: error crash
03.021 SubThread[61]: run end
03.114 MainThread: other

可以看出,范围函数coroutineScope {}会抛出其失败子项的异常,并没有直接往Job层次结构中传递。

因此我们使用try-catch。

supervisorScope() 异常

创建一个协程作用域并且在里面执行协程代码块。这个Scope继承外面scope的coroutineContext,但是重写它的Job为SupervisorJob。当所有的子协程完成后才会返回。与coroutineScope不同, 一个子协程失败,并不会让整个scope去失败而且不会影响其他子协程,所以你要自己处理错误。

常用的viewModelScope,MainScope,lifecycleScope默认都是这种行为。

我们换成supervisorScope(), Demo:

kotlin 复制代码
lifecycleScope.launch(Dispatchers.IO + CoroutineExceptionHandler{e,t->
    logt { "catched: ${t.message}" }
}) { //launch1
    logt { "run start" }
    supervisorScope {
        launch{
            logt { "run c1" }
            throw RuntimeException("error crash")
        }
        launch{
            delay(50)
            logt { "run c2" }
        }
    }
    delay(100)
    logt { "run end" }
}

lifecycleScope.launch {//launch2
    delay(200)
    logt { "other" }
}
kotlin 复制代码
23.608 SubThread[60]: run start
23.609 SubThread[62]: run c1
23.609 SubThread[62]: catched: error crash
23.664 SubThread[62]: run c2
23.765 SubThread[61]: run end
23.813 MainThread: other

可以看到,仅仅是runC1出错,不影响其他地方。

如果我们给supervisorScope{}加上try-catch:

会得到什么结果呢:

复制代码
40.155 SubThread[65]: run start
40.157 SubThread[61]: run c1
40.187 SubThread[61]: catched: error crash
40.207 SubThread[61]: run c2
40.308 SubThread[60]: run end
40.354 MainThread: anther

可以看到,supervisorScope() 加上try-catch没有用, 不能使用try-catch包裹。

还有一点:

kotlin 复制代码
subScope.launch { //subScope申明为SupervisorJob()
     launch(Dispatchers.IO) {
         launch(Dispatchers.Default) {
             delay(240)
             doSome1()
         }
 
         throw RuntimeException("Error Exception")
     }
     launch(Dispatchers.Default) {
         delay(140)
         doSome2()
     }
 }
 
 subScope.launch {
     delay(200)
   	doSome3()
 }

如上,这种嵌套代码,SupervisorJob()下, doSome1和doSome2都不能得到执行,doSome3()可以执行。

所以严格来讲,SupervisorJob()控制的是Scope直接启动的协程任务和其他的子任务,会被异常统一吃掉

这也是我接下来讲到的异常处理心得,不要乱七八糟去嵌套,避免出现不确定性。

launch多层嵌套不适用coroutineExceptionHandler

kotlin 复制代码
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
    println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(Job())

topLevelScope.launch {
    launch(coroutineExceptionHandler) {
        throw RuntimeException("RuntimeException in nested coroutine")
    }
}

程序还是crash了。

给launch第二层开始的子协程设置CoroutineExceptionHandler是没有效果的,我们必须给顶级协程设置, 或者初始化Scope时设置才有效。

async方式启动

前面讲的,异常都是launch,则异常将由CoroutineExceptionHandler处理或传递给线程的未捕获异常处理程序。

而async又有所不同,异常封装在Deferred返回类型中,并在调用.await() 时重新抛出。

kotlin 复制代码
val topLevelScope = CoroutineScope(SupervisorJob())

val deferredResult = topLevelScope.async {
    throw RuntimeException("RuntimeException in async coroutine")
}

topLevelScope.launch {
    try {
        deferredResult.await()
    } catch (exception: Exception) {
        println("Handle $exception in try/catch")
    }
}

可以通过针对async的await()函数捕获异常

看到这里是不是云里雾里了?这个可以try-catch,那个不能catch,这个可以handler那个不能handle?

异常处理心得

各种场景的异常都分析了一遍,到了现在回过头来,我自己都有点懵,到底该怎么做,摸不准呢?

因此个人给出如下建议:
优先使用try-catch为主,CoroutineExceptionHandler为辅。

这与其他人推荐CoroutineExceptionHandler为主不同。且看我的思路:

第1点,符合MVI思想,UIState的封装

参考我之前的文章:android MVC/MVP/MVVM/MVI架构发展历程和编写范式

理解UiState的概念。

给出状态机制和辅助请求函数:

kotlin 复制代码
sealed class StatusState<out T> {
    internal var index = 0

    object Loading : StatusState<Nothing>()

    //不使用data class,避免同内容equals
    class Success<T>(val data :T) : StatusState<T>()
    class Error(val message: String?) : StatusState<Nothing>()
}

//try-catch封装请求转变State
suspend fun <T> stateRequest(requestBlock: suspend () -> T) : StatusState<T>{
    try {
        val data = requestBlock()
        return StatusState.Success(data)
    } catch (e: Exception) {
        return StatusState.Error(e.message)
    }
}

个人建议编写的suspend函数,不处理异常,交给调用者维护异常。

比如Api的某个函数,他就应当抛出异常:

kotlin 复制代码
suspend fun signUp(...) : ApiBean {}

调用者通过stateRequest函数来保证不出异常和Success/Error状态结果:

kotlin 复制代码
viewModelScope.launch {
    withContext(Dispatchers.IO) {
        val st = stateRequest {
            Api.signUp(email, password, userName)
        }
    }
}

因为不论是正确,错误,异常都是一种请求后的状态结果。需要通知到UI层的。

第2点,重试机制

如果使用使用CoroutineExceptionHandler捕获,则不方便进行重试,因为scope往往是统一的,可以调用多个函数。而每次执行的业务是分散的,不同的。

第3点,团队开发

在团队开发中,不同的开发人员,如果对于协程异常处理理解的程度不够,则会导致异常不可控。所以要求2个点,

  • suspend函数:正常编写,异常会自动往调用者抛出;
  • scope开发协程块,使用try-catch包裹,确保scope的执行协程不抛出去异常,并产生的是UIState。
第4点,复杂嵌套情况

因为Job/supervisorJob的不同机制,当嵌套协程出现的时候,你将难以把控,不翻帖子你无法回忆起它具体抛异常的方式。

自行捕获异常并产生第一点中提到的Error的UIState是更好的选择。同时,

尽量不要在launch里面又launch(避免三层嵌套),减少带来的不确定性。这会导致异常难以把控,你的代码执行也会变得十分混乱。

其实我们这么做的主要目的是切线程 或者并发

其他
  • 对于自定义的MainScope,则建议申明一个CoroutineExceptionHandler用来承接无法处理的未知异常。
  • Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionHandlerObj) 可以考虑捕获,避免波及App crash。
相关推荐
华科云商xiao徐2 小时前
Kotlin动态代理池+无头浏览器协程化实战
爬虫·tcp/ip·kotlin
智江鹏3 小时前
Android 之 Kotlin中的符号
android·开发语言·kotlin
pengyu3 小时前
【Kotlin系统化精讲:叁】 | 变量与常量:自由与约束的代码博弈
android·kotlin
欲儿5 小时前
Kotlin Native调用C curl
c语言·开发语言·kotlin·语言调用
alexhilton16 小时前
初探Compose中的着色器RuntimeShader
android·kotlin·android jetpack
小墙程序员16 小时前
kotlin元编程(二)使用 Kotlin 来生成源代码
android·kotlin·android studio
小墙程序员16 小时前
kotlin元编程(一)一文理解 Kotlin 反射
android·kotlin·android studio
KotlinKUG贵州19 小时前
贪心算法:从“瞎蒙”到稳赚
算法·kotlin
Yang-Never1 天前
Kotlin -> object声明和object表达式
android·java·开发语言·kotlin·android studio