Android 上的异步代码,今天几乎都写成协程:网络请求用 viewModelScope.launch,数据库查询用 withContext(Dispatchers.IO),UI 状态用 StateFlow。API 用起来确实简单,但线上问题也集中在这里——页面退出后请求还在跑、async 里的异常被静默吞掉、collect 在后台继续更新已经不存在的 View、取消操作被当成异常上报。
这些问题的根因不是 API 记不住,而是对「结构化并发」和「异常传播」两条规则理解不到位。本文按协程的实现原理到工程实践的顺序梳理一遍。
一句话总结: 协程的父子关系决定了取消与异常都会沿作用域向上传播;
CoroutineScope活多久、异常谁负责,是设计每个协程时首先要回答的两个问题。
一、suspend 与 CPS 变换
1.1 suspend 函数的本质
suspend 只是一个编译器标记,表示这个函数「可能挂起」。它不产生新的线程,也不代表函数一定异步。
suspend fun loadUser(id: Long): User {
delay(100) // 挂起点
return User(id, "leeting")
}
// fun bad() { loadUser(1) } // 编译错误:普通函数不能调用挂起函数
fun good(scope: CoroutineScope) {
scope.launch { loadUser(1) } // 放进协程里才能调用
}
1.2 续体与状态机
编译器把挂起函数改写为「状态机 + 续体(Continuation)」。每个挂起点对应一个状态,挂起时保存局部变量并返回 COROUTINE_SUSPENDED,恢复时从上次的状态继续。
// 源码
suspend fun fetch(): String {
val a = step1()
val b = step2(a)
return b
}
// 编译器大致改写为(伪代码):每个挂起点对应一个 label
fun fetch(cont: Continuation<String>): Any? {
when (cont.label) {
0 -> { cont.label = 1; val r = step1(cont); if (r == SUSPENDED) return SUSPENDED }
1 -> { /* 恢复:拿到 step1 结果,进入下一步 */ }
2 -> return b
}
}
这条改写解释了三个现象:挂起点的局部变量会被打包进续体对象(因此大对象在挂起期间会延长生命周期)、协程可以恢复执行(续体保留了状态)、suspend 函数本身不分配线程。
1.3 suspend 不等于切换线程
suspend fun compute(): Int {
var sum = 0
repeat(1_000_000) { sum += it } // 纯 CPU 计算,没有挂起点
return sum
}
compute() 虽然标了 suspend,但如果调用方在 Dispatchers.Main 上,这段循环仍然阻塞主线程——因为它没有挂起点,不会让出线程。要切换线程必须显式使用 withContext(Dispatchers.Default)。
二、CoroutineScope 与 Job
2.1 结构化并发的三件套
一个协程的完整描述由三部分组成:
| 组件 | 作用 | 常用取值 |
|---|---|---|
CoroutineScope | 协程的生命周期边界 | viewModelScope、lifecycleScope、CoroutineScope(SupervisorJob()) |
CoroutineContext | 运行环境,含 Job、Dispatcher、CoroutineName、CoroutineExceptionHandler | 通过 + 组合 |
Job | 单个协程的句柄,负责取消与状态 | launch/async 的返回值 |
结构化并发的核心规则:父协程会等待所有子协程结束才结束;父协程被取消时,所有子协程一并取消;子协程异常会向上传播给父协程。
fun main() = runBlocking {
val job = launch {
launch { delay(1000); println("child A") }
launch { delay(500); println("child B") }
}
job.join()
println("all done") // 必然在 child A 之后
}
2.2 Job 与 SupervisorJob
普通 Job 下,任意子协程失败会取消父协程,进而取消其余兄弟协程。SupervisorJob 切断了「子失败 → 父失败」这条链,兄弟之间互不影响。
fun main() = runBlocking {
// 普通 Job:一个失败,兄弟全挂
val normal = launch {
launch { delay(100); error("boom") }
launch { delay(500); println("sibling of normal") } // 不会打印
}
normal.join()
// SupervisorJob:互不影响
val supervised = launch(SupervisorJob()) {
launch { delay(100); error("boom") }
launch { delay(500); println("sibling of supervised") } // 会打印
}
supervised.join()
}
Android 的 viewModelScope 内部就是 SupervisorJob + Dispatchers.Main.immediate,因此一个请求失败不会连累其他正在进行的请求。
2.3 launch 与 async 的取舍
| 维度 | launch | async |
|---|---|---|
| 返回值 | Job(无结果) | Deferred<T>(可 await()) |
| 异常时机 | 立即抛给父协程/异常处理器 | 在 await() 时抛出(CoroutineStart.DEFAULT 下仍会立即传播) |
| 典型用途 | 发请求、写日志、UI 副作用 | 并行计算、并行请求后合并结果 |
| 并发合并 | 不适合 | awaitAll() 天然支持 |
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { api.profile() }
val orders = async { api.orders() }
Dashboard(profile.await(), orders.await()) // 两个请求并行
}
关键陷阱:async 的异常在 await() 之前就已经传播给父协程(默认 CoroutineStart.DEFAULT)。如果只想在 await() 处拿到异常,需要 async(SupervisorJob()) 或 CoroutineStart.LAZY,后者配合 runCatching { d.await() } 可把异常收敛到调用点。
三、作用域函数与调度器
3.1 coroutineScope 与 supervisorScope
两者都会挂起当前协程直到内部全部完成,区别在异常处理。
suspend fun withCoroutineScope() = coroutineScope {
launch { delay(50); error("A failed") }
launch { delay(200); println("B finished") } // 被取消
}
suspend fun withSupervisorScope() = supervisorScope {
launch { delay(50); error("A failed") }
launch { delay(200); println("B finished") } // 正常完成
}
| 函数 | 子协程失败时 | 是否抛出异常给调用方 | 适用场景 |
|---|---|---|---|
coroutineScope | 取消其余子协程 | 是 | 一组强关联的任务,任一失败即整体失败 |
supervisorScope | 其余子协程继续 | 否(需自己处理) | 一组相互独立的任务,失败要隔离 |
3.2 Dispatchers 的选择
viewModelScope.launch(Dispatchers.Main.immediate) {
val data = withContext(Dispatchers.IO) { repository.load() } // 切到 IO
val parsed = withContext(Dispatchers.Default) { parse(data) } // 切到 Default
render(parsed) // 回到 Main
}
| 调度器 | 线程池 | 适用 | 注意 |
|---|---|---|---|
Dispatchers.Main | 主线程(Android 上基于 Looper) | 更新 UI | 阻塞会 ANR |
Dispatchers.Main.immediate | 主线程,已在主线程时直接执行 | UI 更新、状态赋值 | 避免一次不必要的 post |
Dispatchers.IO | 最多 64 线程(kotlinx.coroutines.io.parallelism) | 网络、磁盘、数据库 | 不要用于 CPU 密集计算 |
Dispatchers.Default | CPU 核数(最少 2) | 解析、排序、加密 | 长任务会挤占其他协程 |
Dispatchers.Unconfined | 不切换 | 特殊场景 | 生产代码慎用 |
Main.immediate 的价值在于:如果当前已经在主线程,它不会把任务再 post 一次到消息队列,从而避免「状态更新比下一帧慢一拍」的问题。
3.3 withContext 的线程切换
withContext 是挂起函数,会挂起当前协程、切换到目标调度器执行、完成后回到原调度器。
suspend fun readFromDb(): List<Item> = withContext(Dispatchers.IO) {
dao.queryAll()
}
切换有成本:一次调度 + 一次续体恢复。高频小操作(如单次 SharedPreferences 读取)不值得单独切换,应合并成一次批量执行:withContext(Dispatchers.IO) { readA() to readB() },而不是连续写两次 withContext。
四、异常处理
4.1 异常传播规则
launch:异常立即抛出,走CoroutineExceptionHandler或交给父协程,最终可能导致应用崩溃。async:异常被封装进Deferred,但默认仍会传播给父协程;只有在await()时才重新抛出给调用者。supervisorScope/SupervisorJob:切断向上传播,异常需要自行处理。CancellationException:不会被当作错误处理,它是取消信号。
val handler = CoroutineExceptionHandler { _, e ->
Log.e("App", "未捕获的协程异常", e)
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
scope.launch { error("会被 handler 捕获") }
4.2 CoroutineExceptionHandler 的边界
CoroutineExceptionHandler 只在「根协程」上生效:它是安装到 CoroutineScope 上下文里的,子协程异常向上冒泡到根时才会调用。在 async 上安装它无效,因为 async 的异常由 Deferred 承载,只会在 await() 处抛出。
4.3 try/catch 与 runCatching
对可预期的业务失败,推荐在挂起函数内部用 try/catch 转换成一个结果类型,而不是让它冒泡:withContext(Dispatchers.IO) { runCatching { api.article(id) } }。
注意两点:
catch (e: Exception)会把CancellationException一并吞掉,破坏取消语义。正确写法是catch (e: CancellationException) { throw e }之后再 catch 其他异常。runCatching同样会捕获CancellationException,在协程里直接用它包裹挂起调用是有风险的。
suspend fun safeCall() {
try {
doWork()
} catch (e: CancellationException) {
throw e // 必须重新抛出,保持取消传播
} catch (e: Exception) {
report(e)
}
}
五、取消与 CancellationException
协程的取消是协作式的:cancel() 只是把 Job 置为 cancelling 状态,正在执行的代码需要检查取消状态或调用挂起函数才会真正停止。
val job = launch(Dispatchers.Default) {
var i = 0
while (i < 1_000_000) {
// 没有挂起点,cancel 无法中断这个循环
i++
}
}
job.cancel()
// 正确写法:显式检查取消状态
val cancellable = launch(Dispatchers.Default) {
var i = 0
while (i < 1_000_000) {
ensureActive() // 或 isActive 判断
i++
}
}
cancellable.cancel()
三条实践规则:
- 不要捕获
CancellationException(除非重新抛出),否则取消会失效。 finally里的清理代码用withContext(NonCancellable),否则清理本身也会被取消,例如finally { withContext(NonCancellable) { cleanup(file) } }。suspend函数必须在挂起点可被取消,用delay()、yield()替代Thread.sleep()。
六、Flow
6.1 冷流与热流
Flow 是冷流:每个收集者(collector)触发一次独立的生产过程。StateFlow 与 SharedFlow 是热流:生产者独立于收集者存在。
fun ticker(): Flow<Int> = flow { // 冷流:每个 collect 都从 0 开始
var i = 0
while (true) { emit(i++); delay(1000) }
}
| 维度 | Flow(冷) | StateFlow(热) | SharedFlow(热) |
|---|---|---|---|
| 数据生产 | 每个收集者独立触发 | 独立于收集者 | 独立于收集者 |
| 当前值 | 无 | 有(value) | 无 |
| 初始值 | 无 | 必须有 | 可配置 |
| 去重 | 无 | 相同值不重复发射 | 无 |
| 背压 | 挂起 | 覆盖(只保留最新) | 按 replay/extraBufferCapacity |
| 典型用途 | 数据库查询、网络请求 | UI 状态 | 一次性事件、广播 |
6.2 stateIn 与 shareIn
把冷流转换成热流,让多个收集者共享同一次上游执行。
class ArticleViewModel(private val repo: ArticleRepository) : ViewModel() {
val query = MutableStateFlow("")
// stateIn:转成 StateFlow,始终持有最新值
val results: StateFlow<List<Article>> = query
.debounce(300)
.flatMapLatest { q -> repo.search(q) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 停止订阅 5 秒后停上游
initialValue = emptyList(),
)
}
| 参数 | 取值 | 含义 |
|---|---|---|
scope | viewModelScope | 上游执行的生命周期 |
started | SharingStarted.WhileSubscribed(5_000) | 有订阅才启动,配置变更时有 5 秒缓冲 |
started | SharingStarted.Eagerly | 立即启动,永不停止 |
started | SharingStarted.Lazily | 首次订阅后启动,之后不停止 |
initialValue | 任意 | stateIn 必填,shareIn 用 replay 代替 |
WhileSubscribed(5_000) 是 Android 上的推荐配置:屏幕旋转导致的短暂取消订阅不会重启网络请求,同时又能保证退到后台后停止上游。
6.3 flatMapLatest 与 collectLatest
flatMapLatest 会在新值到来时取消上一个内层流的执行,天然适合搜索框场景。collectLatest 则在收集端做同样的事。
viewModelScope.launch {
query
.flatMapLatest { q -> repo.search(q) } // 新查询取消旧查询
.catch { e -> emit(emptyList()) } // 兜住上游异常
.collect { list -> adapter.submitList(list) }
}
两者的区别:flatMapLatest 取消的是上游的生产过程,collectLatest 取消的是下游的处理过程,例如 flow.collectLatest { delay(100); println(it) } 中若期间来了新值,delay 之后的代码会被跳过。另外 catch 只能捕获上游异常,不能捕获 collect 内部的异常,后者需要用 try/catch 包住 collect。
6.4 Flow 的 Android 收集
在 Android 中收集 Flow 必须与生命周期绑定,否则会泄漏。正确的写法是 repeatOnLifecycle。
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.results.collect { list -> render(list) }
}
}
repeatOnLifecycle(STARTED) 会在 STARTED 时启动收集、STOPPED 时取消收集,并在重新可见时重启,是官方推荐的唯一写法。flowWithLifecycle 是它的单流版本。
七、Android 集成与版本
7.1 内建作用域
| 作用域 | 所属 | 生命周期 | 调度器 |
|---|---|---|---|
viewModelScope | androidx.lifecycle:lifecycle-viewmodel-ktx | ViewModel.onCleared() | Main.immediate + SupervisorJob |
lifecycleScope | androidx.lifecycle:lifecycle-runtime-ktx | Lifecycle 销毁 | Main.immediate + SupervisorJob |
repeatOnLifecycle | 同上 | 指定 Lifecycle.State | 需自行 launch 包裹 |
在 ViewModel 里启动的协程会在 onCleared() 时全部取消,这正是结构化并发在 Android 上的落点——不需要手动管理 Disposable。
7.2 Gradle 依赖与版本
// build.gradle.kts(AGP 8.x + Kotlin 2.0/2.1)
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
kotlinx-coroutines-android 提供 Dispatchers.Main 的 Android 实现(基于 Handler/Looper),缺少它会报 Module with the Main dispatcher had failed to initialize。测试里必须用 kotlinx-coroutines-test 的 runTest,否则 Dispatchers.Main 在 JVM 上不可用;runTest 默认使用虚拟时间,delay(1000) 不会真的等一秒,advanceUntilIdle() 可推进到所有挂起任务完成。
八、常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
用 GlobalScope | 协程泄漏、无法取消 | 用 viewModelScope/lifecycleScope |
catch (e: Exception) 吞掉取消 | 取消失效、任务继续跑 | 先 catch (e: CancellationException) { throw e } |
async 异常无人 await | 异常静默或直接崩溃 | 用 supervisorScope 或 awaitAll |
在 async 上装 handler | 不生效 | handler 只对根协程生效 |
withContext 里做 CPU 重活 | 阻塞 IO 线程池 | CPU 密集用 Dispatchers.Default |
Dispatchers.IO 里做长任务 | 线程池被占满、请求排队 | 区分 IO 与计算任务 |
collect 未绑定生命周期 | Fragment 泄漏、后台更新 UI | 用 repeatOnLifecycle |
stateIn 用 Eagerly | 后台仍持续请求 | 用 WhileSubscribed(5_000) |
catch 写在 collect 之后 | 捕获不到下游异常 | catch 只能捕上游,下游用 try/catch |
忘记 SupervisorJob | 一个请求失败取消全部 | 根作用域用 SupervisorJob |
Thread.sleep 替代 delay | 无法取消、阻塞线程 | 一律用 delay |
finally 清理被取消 | 资源未释放 | 包进 withContext(NonCancellable) |
取消与异常的边界问题在 Java 世界里对应 Future.cancel 与 InterruptedException 的语义,两套模型可以对照理解,细节可参考 Java 并发与 JUC
;而协程中大量出现的可空返回值与 ?./?: 处理,沿用 Kotlin 语言基础与空安全
里的运算符层级即可。
小结
协程的 API 很少,但规则很硬。落到工程上记住五条:
- 结构化并发是地基:父子协程共享生命周期,父取消则子取消,子异常则父取消(除非
SupervisorJob)。GlobalScope应视为禁用。 - launch 与 async 分工明确:不需要返回值就用
launch,需要并发结果就用async+await/awaitAll,并留意async的异常时机。 - 异常有层级:
CoroutineExceptionHandler只处理根协程的未捕获异常,业务失败在函数内部转成Result,取消异常必须重新抛出。 - 取消是协作式的:用
ensureActive()检查、用delay替代sleep、用NonCancellable保护清理逻辑。 - Flow 要绑定生命周期:冷流转热流用
stateIn/shareIn配WhileSubscribed,收集端一律走repeatOnLifecycle,搜索类场景用flatMapLatest取消旧请求。
把这五条变成 Code Review 检查项,绝大多数「内存泄漏、UI 更新已销毁页面、请求无法取消」的问题都能在合入前拦下来。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。