Android 中coroutineScope 和 supervisorScope 到底怎么选?先搞清楚 Exception 和 Result

很多开发者都会遇到这些困惑:

  • 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

继续传播。

源码:SupervisorScopeVsCoroutineScope

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui