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。
相关推荐
FunnySaltyFish6 小时前
什么?Compose 把 GapBuffer 换成了 LinkBuffer?
算法·kotlin·android jetpack
Kapaseker12 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
A0微声z3 天前
Kotlin Multiplatform (KMP) 中使用 Protobuf
kotlin
alexhilton4 天前
使用FunctionGemma进行设备端函数调用
android·kotlin·android jetpack
lhDream4 天前
Kotlin 开发者必看!JetBrains 开源 LLM 框架 Koog 快速上手指南(含示例)
kotlin
RdoZam4 天前
Android-封装基类Activity\Fragment,从0到1记录
android·kotlin
Kapaseker5 天前
研究表明,开发者对Kotlin集合的了解不到 20%
android·kotlin
糖猫猫cc5 天前
Kite:两种方式实现动态表名
java·kotlin·orm·kite
如此风景5 天前
kotlin协程学习小计
android·kotlin