上一节把 StateFlow 与 SharedFlow 的分工讲清了,这一节继续往下走操作符层。面试里 Flow 操作符的追问有一个共同特点:代码能跑,所以"看起来没问题",但线上会出现延迟、丢数据、重复请求这类症状。能答出"为什么这个操作符放在这里会出问题"的候选人,比能背出操作符清单的候选人高一个档次。这一节按"分类 → 位置 → 语义"三条线把常用操作符过一遍。
先把结论放在前面:Flow 操作符按作用分四类——转换类(map/filter/flatMapLatest)、组合类(combine/zip/merge)、副作用类(onEach/onStart/onCompletion/catch)、控制类(debounce/sample/retryWhen/takeWhile)。最容易被问倒的三条原则是:①flowOn 只影响上游;②catch 只能捕获上游异常,onEach 里的异常它管不到;③flatMapLatest 取消旧、flatMapMerge 并行、flatMapConcat 排队,三者的区别一句话说清就是"新旧任务的关系"。
机制拆解
先拿最常被问的 flatMap 三兄弟举例。假设搜索场景输入 "a"、"ab"、"abc":
flatMapLatest:取消 "a" 和 "ab" 的处理,只保留 "abc" 的结果。适合"只关心最新输入",比如搜索建议。flatMapMerge:三个请求并发发出,结果到达顺序不确定。适合"都要结果,顺序不重要",比如三路 Banner 加载。flatMapConcat:三个请求排队,前一个完成才处理下一个。适合"顺序敏感且不能并发",比如往有序数据库写日志。
再看 combine 与 zip 的差别。zip(a, b) 按索引配对,两条流长度不一致时以短的为准;combine(a, b) 按最新值组合,某条流没新值时会复用上一个值,所以长度不需要一致。需要"等两边的最新值都更新过再算"时,combine 是首选。
然后是副作用类的位置语义,这是最容易出错的地方。catch 的作用范围是它上面的所有上游算子:
flow {
emit(1)
throw IOException()
}.map {
risky(it) } // map 里的异常能被下面的 catch 捕获
.catch {
emit(-1) } // ✅ 兜住了
.map {
risky2(it) } // 这个 map 在 catch 下游,异常不会被上面那个 catch 兜住
结论记一句:catch 只能兜上游,兜不住下游。下游异常要靠 retry 或把 catch 放到更靠下的位置。
工程落地:真实项目里怎么用
场景一:搜索框输入防抖。最标准的解法是 debounce(300) + distinctUntilChanged() + flatMapLatest + retryWhen。四个缺一不可:只有 debounce 时,"搜索结果页返回后再输入相同词"会重复请求;只有 distinctUntilChanged 时不解决连续打字;只有 flatMapLatest 而无 debounce,每个字符都打一次接口;无 retryWhen 则一次 500 就彻底放弃,后续输入也不再请求。
场景二:轮询。flow { while (true) { emit(fetch()); delay(5000) } } + retryWhen { _, attempt -> emit(delay(min(attempt * 1000, 30_000L))) } + takeWhile { it.code == 0 }。注意 retryWhen 的 lambda 里要 emit 才能产生延迟副作用,直接 delay() 也能编译但语义不同——这是高频追问点。
场景三:onEach 里做耗时操作导致上游被 flowOn 之外的线程卡住。规范是 onEach 只做轻量动作(日志、埋点),重活交给下游独立协程或 flowOn(Dispatchers.IO)。
场景四:collect 里的异常导致整个 Flow 终止。catch 能兜住上游,但兜不住 collect 自己的逻辑——所以 UI 层要在 collect 里做 runCatching,而不是指望上游的 catch 保护自己。
这些坑的正确绕法
搜索场景忘加 debounce,每个字符都发请求,接口被打爆。 这是标题里"能跑不代表能上线"最典型的例子:功能完全正常,压测下 QPS 飙升十几倍。修法:debounce + distinctUntilChanged + flatMapLatest 三件套,并在 retryWhen 里做退避。
其次是把 catch 放在下游却指望它兜住上游异常,或反过来在 onEach 里抛异常指望 catch 兜住。修法:记住 catch 的作用范围是它上游,onEach 的异常属于下游。
还有一个更隐蔽的坑:flowOn 用错位置。它只影响它上面的算子,把 flowOn(Dispatchers.IO) 写在最后一行,对前面的耗时操作毫无作用。修法:把 flowOn 放在耗时算子之前,或对多段用不同调度器时多次串联。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) 搜索:防抖 + 去重 + 最新优先 + 退避重试
fun search(q: Flow<String>) = q
.debounce(300) // 停止输入 300ms 后才发射
.distinctUntilChanged() // 相同词不重复触发
.flatMapLatest {
key -> // 新输入取消旧请求
flow {
emit(repo.search(key))
}.retryWhen {
_, attempt -> // 指数退避,最多重试
val delayMs = min(1000L * (attempt + 1), 30_000L)
if (attempt < 3) {
emit(delay(delayMs)); true } else false
}
}
.flowOn(Dispatchers.IO) // 位于所有耗时算子之前
// 2) 三路独立加载:并行,结果互不取消
fun home() = merge(
flow {
emit(api.banner()) }.catch {
emit(null) },
flow {
emit(api.flash()) }.catch {
emit(null) },
flow {
emit(api.feed()) }.catch {
emit(null) }
)
// 3) 顺序敏感:排队写日志
val audit = actions.flatMapConcat {
act -> flow {
writeLog(act) } }
// 4) 副作用的边界:onEach 只做轻量动作
val ui = repo.observe()
.onStart {
emit(UiState.Loading) } // 每次收集都触发
.onEach {
log("db emit ${it.size}") } // 轻量:仅打点
.catch {
e -> emit(UiState.Error(e.message ?: "")) }
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Idle)
关键行解读:flatMapLatest 保证只处理最新 key;retryWhen 的 lambda 返回 true 表示继续重试,emit(delay(...)) 是推荐的退避写法;flowOn 位置决定影响范围;merge 里每条流各自 catch,某一路失败不拖垮另两路;onStart 每次收集都发 Loading,onCompletion 则在正常结束与异常结束都触发。
这题在面试里怎么问、怎么答
"flatMapLatest / merge / concat 区别?" 答:Latest 取消未完成的旧任务、Merge 并发执行、Concat 串行排队;选择依据是"新旧任务之间要不要互相取消、能不能并发、顺序是否重要"。
"catch 能捕获下游异常吗?" 答:不能,它只捕获自己上游的异常;下游异常由下游自己或更靠下的 catch 处理。
"flowOn 到底影响哪一段?" 答:影响它上面的算子;多次 flowOn 会把流水线切成多段,每段各自调度。
"debounce 和 sample 怎么选?" 答:debounce 是"静默期结束才发最后一个值",适合搜索输入;sample 是"固定时间窗取最新值",适合高频数据(传感器、行情)的节流显示。
给正在准备面试的你
1. 项目里沉淀一份 FlowOps.kt 常用链式封装(搜索、轮询、下载进度三类),统一参数并写清注释,避免每人现拼导致 debounce 时长不一致。 2. review checklist 增加两条:链上是否同时有 flowOn(位置正确吗)、是否需要 retryWhen(网络类上游缺了就等于放弃)。 3. 性能问题排查先看操作符顺序与副作用位置,而不是先加线程池。 4. 自动化预防:用 Turbine 对每条链写单测,断言发射序列与错误恢复次数;把 debounce 相关的时序测试也写进去,参数改动就能被 CI 拦住。
把这四类操作符画成一张四象限图:横轴"是否产生副作用"、纵轴"是否改变数据"。左上(改数据 + 产生副作用)放 onEach/onStart;右上(不改数据 + 产生副作用)放 catch/retryWhen/onCompletion;左下(改数据 + 纯)放 map/filter/flatMapLatest;右下(纯 + 不改)几乎空着。象限外围再标三个"作用域"标注:flowOn 作用在上游、catch 兜上游、retryWhen 只重试上游。这张图讲完,操作符题基本不会散。
再补一个高价值的延伸:transform 与 map 的区别。map 是 1→1,transform 是任意变换(可发射多个、可改类型、还能 emit 前后做副作用),像 flatMap 这种 1→N 就该用 transform 实现——写 transform { emit(it); emit(it) } 和 transformWhile { emit(it); it > 0 }(返回 false 终止)都属于基本功,面试常随手考。
复习时别孤立刷题:StateFlow 与 SharedFlow——上一节讲流的状态与事件,本节讲如何在流上做转换与控制,最终目标都是把上游产出安全地送到 UI。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:StateFlow-与-SharedFlow:UI-状态分发的标配
下一篇预告:协程与生命周期:repeatOnLifecycle-的正确用法
有任何问题欢迎在评论区留言交流。