很多开发者都会遇到这些困惑:
coroutineScope和supervisorScope到底应该怎么选择?- 异常应该在哪里处理?
- Data 层应该直接抛异常,还是返回
Result? - 为什么用了
Result后,发现coroutineScope和supervisorScope好像没有区别?
这些问题背后,其实是一种架构选择:
我们到底应该选择 Exception 驱动模型,还是 Result 驱动模型?
而 coroutineScope 和 supervisorScope,只是这个问题在协程层面的表现。
一、先理解协程异常传播机制
Kotlin 协程的异常传播,本质依赖:
markdown
CoroutineContext
↓
Job
↓
异常传播关系
例如:
kotlin
viewModelScope.launch {
loadUser()
}
如果:
kotlin
suspend fun loadUser() {
throw IOException()
}
异常传播:
markdown
loadUser()
↓
当前协程
↓
viewModelScope Job
↓
CoroutineExceptionHandler
↓
Crash
二、coroutineScope:所谓子任务失败,整个任务组失败
先看:
kotlin
viewModelScope.launch {
coroutineScope {
launch {
delay(500)
throw RuntimeException("任务A失败")
}
launch {
delay(2000)
println("任务B完成")
}
}
}
结构:
markdown
coroutineScope
|
----------------
| |
任务A 任务B
失败 运行
当任务 A 抛异常:
css
任务A失败
↓
coroutineScope 感知
↓
取消整个子协程树
↓
任务B收到 CancellationException
这就是 coroutineScope 的核心:
子任务失败,整个任务组失败。
但是注意:
如果异常没有捕获:
kotlin
viewModelScope.launch {
coroutineScope {
launch {
throw RuntimeException()
}
}
}
异常仍然会继续向上传播:
python
Child Coroutine
↓
Parent Job
↓
CoroutineExceptionHandler
↓
Crash !!!
coroutineScope 并不会自动帮你处理异常。
三、supervisorScope:所谓子任务失败,不自动取消兄弟任务
换成:
kotlin
viewModelScope.launch {
supervisorScope {
launch {
delay(500)
throw RuntimeException("任务A失败")
}
launch {
delay(2000)
println("任务B完成")
}
}
}
结果:
css
任务A失败
X
任务B继续执行(这里你看不到,想想为什么)
原因:
supervisorScope 改变了子协程之间的取消传播关系。
普通 Job:
Child失败
↓
取消兄弟
Supervisor:
Child失败
↓
不影响兄弟
但是:
如果:
kotlin
launch {
throw RuntimeException()
}
没有处理异常:
异常仍然会进入:
CoroutineExceptionHandler
没有处理时,Android 环境依然导致 Crash。
四、Exception 驱动模型的问题
到这里会发现:
直接使用 Exception 管理业务失败,会进入两个极端。
情况一:不捕获
kotlin
repository.getUser()
异常直接传播:
php
Exception
↓
Coroutine Job
↓
Crash
情况二:到处 try-catch
例如:
kotlin
viewModelScope.launch {
try {
repository.getUser()
} catch(e: Exception){
showError()
}
}
随着业务增加:
text
try-catch
try-catch
try-catch
最终:
业务代码被异常处理逻辑淹没。
如果我们选择 Exception 驱动模型,那么异常最终总要在某一层被捕获。
但在协程世界里,异常并不总是像普通函数调用那样,沿着调用栈直接进入外层的 try-catch。
尤其是当我们使用 launch 创建新的子协程后,异常传播就会受到 Job 父子关系的影响。
这也是很多开发者在实际开发中遇到的困惑:
明明外面已经写了
try-catch,为什么子协程里的异常还是捕获不到?
要回答这个问题,我们需要先理解 launch 创建的新协程,以及它背后的异常传播机制。
五、为什么 launch 外层 try-catch 捕获不到?
很多人会写:
kotlin
viewModelScope.launch {
try {
launch {
throw RuntimeException()
}
} catch(e: Exception){
println("捕获成功")
}
}
结果:
捕获不到。
原因:
launch 创建了新的协程。
异常传播:
python
Child Coroutine
↓
Child Job
↓
Parent Job
↓
CoroutineExceptionHandler
不是普通函数调用:
arduino
throw
↓
函数栈
↓
catch
所以:
外层 try-catch 不会因为 launch 创建了子协程,就自动捕获子协程抛出的异常。
六、什么时候 try-catch 可以生效?
如果异常发生在当前协程调用链:
kotlin
viewModelScope.launch {
try {
repository.getUser()
} catch(e: Exception){
println("捕获成功")
}
}
路径:
kotlin
当前协程
↓
suspend函数
↓
throw
↓
catch
可以捕获。
但是:
如果所有业务都依赖这种方式:
最终还是:
上层充满 try-catch。
七、Result 模型:让业务失败成为数据
前面我们看到,Exception 驱动模型有一个天然的问题:
业务失败一旦通过异常传播,就会进入协程的异常传播体系。
这意味着一个本质上属于业务层面的失败,可能进一步影响:
Job的状态;- 父子协程关系;
- 兄弟协程的取消;
- 异常处理边界。
因此,一个更值得思考的问题是:
业务失败,真的应该通过 Exception 来表达吗?
我的理解是:
业务失败应该尽量显式建模,不要让所有业务失败都依赖异常传播。
因此,我们可以让业务失败显式建模为 Result 或业务状态,而不是通过异常传播。 例如,可以在合适的架构层将底层技术异常转换为业务结果。
例如:
kotlin
interface UserRepository {
suspend fun getUser(): Result<User>
}
实现:
kotlin
class UserRepositoryImpl(
private val api: UserApi
): UserRepository {
override suspend fun getUser(): Result<User> {
return try {
val user = api.getUser()
Result.success(user)
} catch(e: CancellationException) {
throw e
} catch(e: Exception) {
Result.failure(e)
}
}
}
上层:
kotlin
viewModelScope.launch {
val result = repository.getUser()
result
.onSuccess {
showUser(it)
}
.onFailure {
showError()
}
}
业务代码:
不需要 try-catch。
八、Result 最大的变化:协程感知不到失败
这是整个设计的关键。
例如:
kotlin
coroutineScope {
launch {
repository.getUser()
}
launch {
repository.getBanner()
}
}
如果:
kotlin
getUser()
返回:
kotlin
Result.failure()
那么:
协程看到:
css
任务A
↓
正常返回 Result.failure()
↓
没有抛出异常
对协程的异常传播机制来说,Result.failure() 只是一个正常的返回值。
它不会触发 Job 的异常传播,也不会因为业务失败自动取消兄弟协程。
而不是:
php
任务A失败
↓
throw Exception
因此:
不会触发:
- Job 取消;
- 兄弟任务取消;
- 异常传播。
所以:
此时:
text
coroutineScope
和
supervisorScope
在业务失败场景下:
表现完全一致。
九、Result 模型下 Scope 的职责变化
| 异常处理方式 | coroutineScope | supervisorScope |
|---|---|---|
| 未捕获异常 throw | 取消兄弟,并继续传播 | 不取消兄弟,并继续传播 |
| 子协程内部捕获 | 没区别 | 没区别 |
| 返回 Result | 没区别 | 没区别 |
| CancellationException | 必须传播 | 必须传播 |
十、CancellationException:它不是业务异常,而是取消信号
错误:
kotlin
try {
api.getUser()
}catch(e: Exception){
Result.failure(e)
}
问题:
因为:
php
CancellationException
继承
Exception
所以会被捕获。
例如:
用户退出页面:
python
ViewModel销毁
↓
Coroutine取消
↓
CancellationException
↓
被转换成Result.failure
↓
协程继续执行
导致:
- 浪费资源;
- 生命周期失控。
正确:
kotlin
try {
api.getUser()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
原则:
CancellationException 永远向上传播。
十一、Android 架构最佳实践
markdown
UI / ViewModel
|
消费 UiState / Result
|
Coroutine Scope
|
生命周期 + 并发 + 取消管理
|
Repository
|
捕获技术异常,转换业务结果
|
Retrofit / Room
Data 层职责
负责:
- IOException
- 网络错误
- 数据解析错误
转换:
php
Exception
↓
Result.failure
不要:
arduino
Repository throw IOException
↓
ViewModel try-catch
Coroutine Scope 职责
负责:
- 生命周期
- 并发关系
- 取消传播
- 资源释放
不是负责:
- 网络错误展示;
- 用户提示;
- 业务失败。
十二、最后
Kotlin 协程异常设计的核心,并不是选择:
coroutineScope
还是
supervisorScope
而是先明确:
不同类型的失败,应该由不同机制负责。
业务失败属于数据流
例如:
- 网络失败;
- 服务端错误;
- 数据不存在。
应该:
rust
Result
或者
业务状态模型
传递。
协程取消属于控制流
例如:
- 页面销毁;
- ViewModel 清理;
- 超时取消。
必须:
kotlin
CancellationException
继续传播。