Kotlin 协程与 Flow ★
这是你的头号短板,也是中级面试的高频核心。 协程是现代 Android 异步编程的基石,几乎必问。本篇从原理到实战到面试题完整覆盖。
一、协程是什么(先建立心智模型)
协程不是线程。它是一种可挂起/恢复的计算,由编译器 + 运行时在用户态调度。
- 挂起(suspend):不阻塞线程。协程挂起时,它所在的线程被释放去做别的事,等条件满足再恢复执行。
- 核心价值:用同步顺序的写法表达异步逻辑,消灭回调地狱。
// 回调写法
api.login(user) { token ->
api.getProfile(token) { profile -> updateUI(profile) }
}
// 协程写法
val token = api.login(user) // suspend,挂起不阻塞
val profile = api.getProfile(token) // suspend
updateUI(profile)
二、suspend 原理(高频追问:协程为什么不阻塞线程?)
suspend 函数由 Kotlin 编译器做 CPS 变换(Continuation-Passing Style):
- 编译器给每个 suspend 函数隐式加一个参数
Continuation(回调),返回值变成Any?。 - 函数体被编译成一个状态机:每个挂起点是一个状态。
- 挂起时函数返回特殊标记
COROUTINE_SUSPENDED,线程被释放。 - 当结果就绪,调用
continuation.resumeWith(result),状态机从上次挂起点恢复执行。
所以协程“挂起不阻塞“的本质:把后续代码包装成回调,挂起时退出函数让出线程,恢复时再回来。这是面试最爱的深挖点。
// 你写的:
suspend fun login(user: User): Token
// 编译后近似:
fun login(user: User, cont: Continuation<Token>): Any?
三、结构化并发(Structured Concurrency)
协程必须在 CoroutineScope 中启动。核心:协程有父子层级,父协程等待所有子协程完成,父被取消则子全部取消,避免协程泄漏。
- CoroutineScope:协程作用域,持有
CoroutineContext。 - CoroutineContext:元素集合,关键有
Job(生命周期)、CoroutineDispatcher(线程)、CoroutineName、CoroutineExceptionHandler。 - Job:协程生命周期句柄,可
cancel()/join(),状态有 Active/Completing/Cancelled/Completed。 - 父子关系:协程内启动的子协程,其 Job 是父 Job 的子节点。
Android 常用现成 scope:
viewModelScope:绑定 ViewModel,onCleared 自动取消。lifecycleScope:绑定 Lifecycle。GlobalScope:不推荐,脱离结构化并发,易泄漏。
class MyViewModel : ViewModel() {
fun load() = viewModelScope.launch { // ViewModel 销毁自动取消
val data = repo.fetch() // suspend
_state.value = data
}
}
四、Dispatchers 与 withContext
| Dispatcher | 用途 | 线程池 |
|---|---|---|
Dispatchers.Main | UI 操作 | 主线程 |
Dispatchers.IO | 网络 / 磁盘 IO | 共享弹性池(默认上限 64) |
Dispatchers.Default | CPU 密集(排序/解析) | 核数大小 |
Dispatchers.Unconfined | 不限定(测试/特殊) | 当前线程,恢复后随挂起点 |
viewModelScope.launch { // Main
val data = withContext(Dispatchers.IO) { // 切到 IO
api.fetch()
}
textView.text = data // 自动回到 Main
}
withContext 切换上下文并挂起等待结果返回,是最常用的线程切换方式。IO 与 Default 共享底层线程池,两者间切换不一定真正换线程(优化)。
五、launch vs async
launch:返回Job,不返回结果,适合“发射后不管“的副作用;异常立即传播给父。async:返回Deferred<T>,await()取结果,适合并发计算多个值;异常在await()时抛出。
// 并发请求,总耗时 = max 而非 sum
val a = async { api.getA() }
val b = async { api.getB() }
val result = a.await() + b.await()
六、取消机制(协作式)
协程取消是协作式的:取消只是把 Job 标记为 Cancelling,真正停止需要协程代码配合检查。
- 所有
kotlinx.coroutines的 suspend 函数(delay 等)在恢复时会检查取消状态,抛出CancellationException。 - 纯 CPU 循环不会自动响应取消,需手动检查
isActive/ensureActive()/yield()。
val job = scope.launch {
while (isActive) { // 配合检查,否则取消无效
doHeavyWork()
}
}
job.cancel() // 标记取消
易错点:
CancellationException是正常的取消信号,不要在 catch 中吞掉它,否则破坏取消。try/catch (e: Exception)会误捕获它,应catch (e: CancellationException) { throw e }或只 catch 具体异常。- 取消后想做清理用
try/finally,但 finally 中若要再调挂起函数,需用withContext(NonCancellable)。
七、异常处理
- launch:异常会立即向上传播给父 Job,触发父及所有兄弟取消。
- async:异常被封装在 Deferred,
await()时才抛出。 - CoroutineExceptionHandler:只对 launch 的根协程生效,作为“兜底“处理未捕获异常。
- SupervisorJob / supervisorScope:子协程失败不影响兄弟和父协程(单向传播)。适合多个独立任务的场景(如同时加载多个卡片,一个失败不拖垮其他)。
supervisorScope {
launch { riskyA() } // A 失败不影响 B
launch { riskyB() }
}
对比记忆:普通 Job 一个子失败全家取消;SupervisorJob 子失败各自负责。
八、Flow(冷流)
Flow 是协程版的“异步数据流“,可以发射多个值(协程的 suspend 函数只返回一个值)。
- 冷流(Cold):Flow 默认是冷的——没有收集者就不执行,每个 collect 都重新触发 上游。类比“按需播放的录像“。
- 构建:
flow { emit(x) }、flowOf()、asFlow()。 - 操作符:
map/filter/transform(中间操作,惰性),collect/first/toList(末端操作,触发执行)。 - 线程切换:
flowOn(Dispatchers.IO)只影响上游;收集所在线程由 collect 处的 scope 决定。不要在 flow{} 里用 withContext 切线程(会报错),要用 flowOn。 - 背压:
buffer()(并发缓冲,让上游发射和下游收集可在缓冲容量内解耦)、conflate()(下游慢时只保留最新值,适合 UI 进度/状态)、collectLatest(新值到来取消上个收集块,适合搜索联想/列表刷新)。边界是:这些操作符优化的是“生产快、消费慢“的处理方式,不应掩盖下游耗时任务本身;被取消的收集块仍要遵守协作式取消。
flow {
emit(fetchFromNetwork()) // 上游在 IO
}.flowOn(Dispatchers.IO)
.map { it.toUiModel() }
.collect { render(it) } // 收集在调用方线程(如 Main)
九、StateFlow 与 SharedFlow(热流)
热流(Hot):不管有没有收集者都“活着“,发射独立于收集者。用于状态/事件,是 Android MVVM 中替代 LiveData 的主力。
StateFlow
- 持有一个最新值,新收集者立即拿到当前值(类似 LiveData)。
- 必须有初始值,
value可读可写(MutableStateFlow)。 - 去重:值相等(equals)时不发射(conflate 语义)。
- 适合表达 UI State。
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
_uiState.value = UiState.Success(data)
SharedFlow
- 可配置
replay(给新订阅者重放几个值)、extraBufferCapacity、onBufferOverflow。 - 无初始值,不去重。
- 适合一次性事件(导航、Toast、SnackBar)—— 用 replay=0 避免事件重放。
对比表
| 维度 | LiveData | StateFlow | SharedFlow |
|---|---|---|---|
| 库 | Jetpack | 协程 | 协程 |
| 初始值 | 否 | 必须 | 否 |
| 生命周期感知 | 自带 | 需 repeatOnLifecycle | 需 repeatOnLifecycle |
| 去重 | 否 | 是 | 否 |
| 粘性 | 是 | 是 | 可配 replay |
| 适用 | 老项目状态 | 新项目状态 | 事件 |
Android 正确收集姿势(避免后台浪费):
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
进阶补充:Channel、callbackFlow 与测试
Channel 与 callbackFlow
Channel 更像协程里的阻塞队列,适合一对一事件传递;callbackFlow 用于把回调式 API 包装成 Flow。
fun locationFlow(): Flow<Location> = callbackFlow {
val listener = LocationListener { location -> trySend(location) }
locationClient.addListener(listener)
awaitClose { locationClient.removeListener(listener) }
}
关键点:awaitClose 必须释放监听器,否则会泄漏。
combine / zip / flatMapLatest
| 操作符 | 行为 | 场景 |
|---|---|---|
| combine | 任一上游变化就用最新值组合 | 表单状态、多个配置源 |
| zip | 等两边一一配对 | 两个一次性结果合并 |
| flatMapLatest | 新请求来时取消旧请求 | 搜索框、筛选条件变化 |
stateIn / shareIn
stateIn 把冷 Flow 转成有当前值的 StateFlow;shareIn 把冷 Flow 共享给多个订阅者。面试要说明 scope、started 策略和 replay。
协程测试
使用 runTest、StandardTestDispatcher 和虚拟时间,不要在测试里真实 delay。
**追问:**为什么 callbackFlow 里要写 awaitClose?因为 Flow 被取消时需要注销外部回调,否则回调继续持有对象导致泄漏。
高频面试题
Q1:协程和线程的区别? 线程是 OS 调度的重量级资源;协程是用户态的轻量“可挂起计算“,多个协程可复用少量线程。协程挂起不阻塞线程。一个线程能跑成千上万协程。
Q2:suspend 关键字做了什么?协程凭什么不阻塞线程? 编译器对 suspend 函数做 CPS 变换,加 Continuation 参数,函数体编译成状态机。挂起时返回 COROUTINE_SUSPENDED 并释放线程,结果就绪后通过 resumeWith 从挂起点恢复。所以是“挂起协程“而非“阻塞线程“。
Q3:launch 和 async 区别?async 不 await 会怎样? launch 返回 Job 无结果,async 返回 Deferred 有结果。async 启动后若不 await,异常不会抛出(被封装),可能静默丢失。
Q4:协程的取消是怎样的?为什么有时 cancel 无效? 协作式取消。cancel 只标记状态,suspend 函数恢复时检查并抛 CancellationException。纯 CPU 循环不调用 suspend 函数,不会响应取消,需手动 isActive/ensureActive/yield。
Q5:Job 和 SupervisorJob 区别? 普通 Job:子协程异常会取消父和所有兄弟。SupervisorJob:子异常只影响自己,不向上传播取消兄弟。多个独立任务用 supervisorScope。
Q6:CoroutineScope、CoroutineContext、Job 的关系? Scope 持有 Context;Context 是元素集合(Job、Dispatcher 等);Job 管理生命周期与父子层级。三者共同实现结构化并发。
Q7:Flow 冷热的区别?StateFlow 和 SharedFlow 怎么选? 冷流无收集者不执行、每次 collect 重新触发;热流独立于收集者。状态用 StateFlow(有初值、去重),事件用 SharedFlow(replay=0 防重放)。
Q8:为什么用 StateFlow 替代 LiveData?LiveData 有什么坑? StateFlow 不依赖 Android、可在纯 Kotlin 层用、操作符丰富、与协程统一。LiveData 的坑:粘性事件(新观察者收到旧值)、只能主线程 setValue、observeForever 易泄漏。但 StateFlow 不感知生命周期,需 repeatOnLifecycle。
Q9:flowOn 和 withContext 区别?能在 flow{} 里 withContext 切线程吗? 不能。flow{} 内 emit 必须在收集协程的上下文,直接 withContext 会抛异常(违反上下文保留)。要切上游线程用 flowOn,它只影响上游操作符。
Q10:repeatOnLifecycle 解决什么问题? 解决“App 退后台时仍在收集 Flow 浪费资源/可能崩溃“。它在进入指定状态(如 STARTED)时启动收集,离开时取消,再次进入重启,是官方推荐的安全收集方式。