用了kt已经3年多,协程也一直在用,但是并没有好好的整体盘一遍。现在整个完全学习版,总算是又通畅了不少,加深了理解。
计划分成3篇完成讲解。
上篇,kotlin协程2025 通俗易懂三部曲之上篇 用法全解。
中篇, kotlin协程2025 通俗易懂三部曲之中篇 常用函数和并发处理。
下篇,讲解异常处理,本文。
kotlin的异常处理是十分重要的,
简单来讲,通过CoroutineExceptionHandler
和try-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(避免三层嵌套),减少带来的不确定性。这会导致异常难以把控,你的代码执行也会变得十分混乱。
其实我们这么做的主要目的是切线程 或者并发:
- 切线程
根据前面提到的,launch嵌套可能会导致异常无法捕获,所以还是老规则,try-catch自身执行代码是最保险的。
同时,使用scope.launch或者withContext,而不是在launch里面直接launch。 - 并发
详情参考kotlin协程2025 通俗易懂三部曲之中篇 常用函数和并发处理。
其他
- 对于自定义的MainScope,则建议申明一个CoroutineExceptionHandler用来承接无法处理的未知异常。
- Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionHandlerObj) 可以考虑捕获,避免波及App crash。