8、Flow的线程切换与背压:flowOn切换上下文、buffer、conflate、collectLatest处理背压问题
好,咱们今天来聊聊 Flow 里两个非常核心的实操话题——线程切换和背压。
说实话,我早期用 RxJava 的时候,背压就是个让人头疼的东西。到了 Kotlin Flow,Google 的设计思路变了——它默认是冷流、顺序执行,但实际项目中你不可能永远在同一个线程里跑。所以,flowOn、buffer、conflate、collectLatest 这几个操作符,就成了我们手里的利器。
下面我一个个拆开讲,结合我踩过的坑,给你说明白。
8.1 flowOn:切换上游的调度器
flowOn 的作用很简单——改变 Flow 上游(即数据发射端)所在的协程上下文。下游的 collect 默认跑在调用 collect 的协程里,而 flowOn 只影响它前面的操作。
来看个例子:
flow {
for (i in 1..5) {
delay(100) // 模拟耗时操作
emit(i)
}
}
.flowOn(Dispatchers.IO) // 上游切到 IO 线程
.collect { value ->
// 这里跑在调用 collect 的线程(比如 Main)
Log.d("Flow", "收到: $value")
}
我个人习惯把 flowOn 放在靠近 flow {} 构建器的地方,这样一眼就能看出数据是从哪个线程发射的。你想想看,如果上游有多个操作符,flowOn 只影响它之前的那一段。
关键点:flowOn 可以多次调用,每次都会切换上游的上下文。但要注意,频繁切换线程会带来额外的开销。
8.2 buffer:引入缓冲区,让上下游并行跑
默认情况下,Flow 是顺序执行的——上游发射一个,下游处理一个,处理完再发射下一个。如果下游处理很慢,上游就得等着。这就是典型的背压场景。
buffer 操作符会在上下游之间加一个缓冲区。上游可以继续发射数据到缓冲区,下游从缓冲区取数据。这样上下游就能并行执行了。
flow {
for (i in 1..10) {
delay(100) // 每 100ms 发射一个
emit(i)
}
}
.buffer(10) // 缓冲区容量为 10
.collect { value ->
delay(300) // 下游处理需要 300ms
println("处理: $value")
}
没有 buffer 时,总耗时是 100ms + 300ms = 400ms 每个元素。加了 buffer 后,上游可以提前发射,下游慢慢消费,整体耗时大幅降低。
避坑指南:我曾经在项目中把 buffer 设得特别大(比如 1000),结果内存飙升。缓冲区不是越大越好,要根据你的数据量和处理速度来定。一般 10~50 就够用了。
8.3 conflate:只保留最新的数据
有些场景下,你并不关心中间值,只想要最新的。比如 UI 更新——用户快速滑动列表,你只需要展示最终位置,中间跳过的那些帧丢了也无所谓。
conflate 就是干这个的。它本质上是一个容量为 1 的缓冲区,但有个特殊行为:如果下游还在处理上一个数据,上游新发射的数据会直接覆盖缓冲区里的旧数据。
flow {
for (i in 1..10) {
delay(50)
emit(i)
}
}
.conflate()
.collect { value ->
delay(200)
println("最终处理: $value")
}
运行后你会发现,中间很多值被跳过了,只处理了最后一个。嗯,这里要注意:conflate 不会丢数据,它只是丢弃了那些「来不及处理」的旧数据。
注意:conflate 适用于「结果覆盖型」场景,比如进度更新、位置刷新。如果你需要处理每一个值(比如日志记录),千万别用 conflate。
8.4 collectLatest:取消旧任务,执行最新
collectLatest 和 conflate 有点像,但行为完全不同。
collectLatest 的逻辑是:当下游开始处理一个值时,如果上游又发射了新值,那么当前正在执行的下游处理会被取消,然后立即开始处理新值。
flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
.collectLatest { value ->
delay(300) // 模拟耗时处理
println("处理完成: $value")
}
运行结果:只有最后一个值被完整处理了。因为每次新值到来时,上一个 delay(300) 还没跑完就被取消了。
我个人觉得 collectLatest 最适合的场景是搜索联想——用户每输入一个字符,就发起一次网络请求。如果前一次请求还没回来,用户又输入了新字符,那前一次请求的结果就没意义了,直接取消掉,只处理最新的。
对比总结:
buffer:并行处理,不丢数据,但可能积压。conflate:只保留最新,丢弃旧数据。collectLatest:取消旧任务,执行最新任务。
8.5 知识体系图:Flow 线程切换与背压
下面这张图帮你理清这几个操作符的关系和适用场景:
8.6 实际项目中的组合用法
真实项目里,这几个操作符经常组合使用。我举个例子:
// 网络请求 + UI 更新
flow {
// 从数据库或网络获取数据列表
val list = api.fetchDataList()
emit(list)
}
.flowOn(Dispatchers.IO) // 网络请求在 IO 线程
.buffer(5) // 缓冲区,防止下游卡住上游
.conflate() // 如果 UI 更新慢,只保留最新数据
.flowOn(Dispatchers.Main) // 下游 collect 在 Main 线程
.collect { list ->
adapter.submitList(list)
}
你看,这里用了两次 flowOn。第一次把上游切到 IO,第二次又把下游切回 Main。中间用 buffer 和 conflate 处理背压。这种写法在 MVVM 的 Repository 层很常见。
我的经验:如果你不确定用哪个,先问自己两个问题——「我需要处理每一个值吗?」和「下游处理速度比上游慢多少?」。需要全部处理就用 buffer,不需要就用 conflate 或 collectLatest。
8.7 避坑指南
- 不要滥用 flowOn:每次切换线程都有开销。我见过有人每个操作符后面都加 flowOn,结果性能反而更差。
- buffer 的容量要合理:设太大容易 OOM,设太小又起不到并行效果。建议根据数据峰值来定。
- collectLatest 的取消是协程级别的:如果下游处理里有不可取消的操作(比如文件写入),collectLatest 可能不会生效。
- conflate 和 collectLatest 不要混用:它们解决的是类似的问题,但机制不同。混用会导致行为难以预测。
好了,关于 Flow 的线程切换和背压处理,就聊到这儿。这几个操作符用好了,你的 Flow 代码会既高效又优雅。