Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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):

  1. 编译器给每个 suspend 函数隐式加一个参数 Continuation(回调),返回值变成 Any?
  2. 函数体被编译成一个状态机:每个挂起点是一个状态。
  3. 挂起时函数返回特殊标记 COROUTINE_SUSPENDED,线程被释放。
  4. 当结果就绪,调用 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(线程)、CoroutineNameCoroutineExceptionHandler
  • 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.MainUI 操作主线程
Dispatchers.IO网络 / 磁盘 IO共享弹性池(默认上限 64)
Dispatchers.DefaultCPU 密集(排序/解析)核数大小
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(给新订阅者重放几个值)、extraBufferCapacityonBufferOverflow
  • 无初始值,不去重。
  • 适合一次性事件(导航、Toast、SnackBar)—— 用 replay=0 避免事件重放。

对比表

维度LiveDataStateFlowSharedFlow
Jetpack协程协程
初始值必须
生命周期感知自带repeatOnLifecyclerepeatOnLifecycle
去重
粘性可配 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。

协程测试

使用 runTestStandardTestDispatcher 和虚拟时间,不要在测试里真实 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)时启动收集,离开时取消,再次进入重启,是官方推荐的安全收集方式。