☰
Kotlin协程Flow冷流与热流全解析:StateFlow/SharedFlow原理与实战避坑
2026/10/15 7:16:35 网站建设 项目流程

Kotlin协程里的Flow,冷流和热流这两个词,估计每个用协程的同学都听过,但真到了代码里,很多人其实是懵的:为什么同一个Flow被订阅两次,结果执行了两次?为什么用StateFlow做事件通知,事件丢了?为什么明明在页面上刷新了,UI却没有变化?这些问题的根源,几乎都落在冷流和热流的行为差异上。这篇梳理,我打算把这套知识完整捋一遍,从概念到源码层面的行为,再到实操中的转换和避坑,把我自己的理解和踩坑记录都摊开来讲。

这篇内容适合所有写过协程Flow的开发者,不管你是刚入门想搞清楚基本概念,还是已经写了不少代码但总觉得哪里没想透,都可以对照着自己项目里的使用方式重新审视一遍。搞懂了冷热流,你会发现很多以前写起来别扭的代码,其实都是因为选错了流类型。

1. 冷流:先搞清楚最原始的Flow

1.1 冷流的行为逻辑:每次collect都从零开始

Flow这个API在Kotlin协程里有一个非常鲜明的性格:它默认是冷流。所谓冷,意思是这个Flow本身不干活,它只是保存了一段生产数据的代码,就像一根水管,你不开水龙头,里面永远是空的。打开水龙头这个动作,在协程世界里就是调用collect。

冷流最核心的行为特征有两个。第一个特征是惰性,没有collect就没有任何执行动作,代码块里的逻辑不会自己跑起来;第二个特征是重复执行,每来一个collector,Flow的生产代码块就会从头到尾重新执行一遍,每个collector拿到的是独立生成的完整数据序列。

我举个例子,假设你写了一个flow { emit(1); emit(2); emit(3) },然后在代码里调用了两次collect。写的时候你可能会想,这不就是同一个流吗,那第二个人来订阅的时候,应该接着第一个人看完之后的进度继续吧?实际结果是:第一次collect打印1、2、3,第二次collect还会从头打印1、2、3。每个订阅者之间完全隔离,互不共享。

这个特征往深了说,是从FlowCollector的接口设计上就决定了。冷流的每次collect都会重新执行Flow的block,整个执行链路上的协程上下文、缓冲区、状态都是新创建的。所以你不管订阅多少次,每次都是一条独立的生产线。

1.2 冷流适合什么场景,不适合什么场景

理解了冷流的行为,你就能判断什么场景该用它了。

适合用冷流的,是那些每次都需要拿到"新鲜完整结果"的任务。最典型的就是网络请求接口调用,你点击一个按钮,发起一次请求,拿到完整响应。流程就是一次Cold的:点击时创建Flow,collect时执行网络请求,请求结束后emit结果,collector收到结果后通知UI。这个场景里没人会要求多个页面共享同一次请求的响应,每个页面自己发起,自己拿数据,独立得很。

数据密集型计算也适合冷流,比如一个白名单过滤、一个批量文件扫描,每次调用collect都重新在指定线程池里跑一遍,天然支持多次独立调用。

不适合用冷流的场景也一目了然:凡是需要多个人看到同一份数据流的地方,或者生产端和消费端不在同一时间线的场景。比如应用里的全局登录状态,登录成功这个事件发送过去的时候,页面可能还没创建好,等页面创建好了去collect,冷流早就执行完了,数据已经丢了,页面拿到的只会是空。这种场景,需要的是后面要说的热流。

注意:这里很多人有个误区,以为用StateFlow或者SharedFlow就不用理解冷流了。实际上StateFlow和SharedFlow本质上是冷流通过某种机制被"热化"后的结果,要理解热流的行为边界,冷流的行为是地基。地基都没踩实,后面所有判断都会出错。

2. 热流的两员大将:StateFlow与SharedFlow

2.1 StateFlow:带状态、带去重的热流

StateFlow是大家最熟悉的热流,你可能没用过SharedFlow,但几乎不可能没用过StateFlow,它在Android里配合ViewModel用得非常频繁。

StateFlow的几个关键特性我需要一个个掰开讲。

第一,它永远持有一个当前值。这个值可以通过value属性随时读取,所以它适合表达"状态"。什么状态?登录状态、加载状态、列表内容、用户名。这类数据的特点是:不管有没有人订阅,它总有一个最新值在那里等你,你late来,late到,都能直接读到当前的一杯热水。

第二,它有去重机制。StateFlow内部用equals来判断新值是否跟旧值一致,如果一致,就不向下游发射。这个特性是双刃剑,当你要表达"重复的但是确确实实发生了的事件"时,就会被它坑到。我见过不少人把StateFlow当事件总线用,结果同一个事件发第二次时UI不动了,排查半天定位到这个去重上。

第三,它是SharedFlow的一个特化版本,内部实现是replay = 1、没有extraBuffer。也就是说,它只缓存最新一个值,而且replay这个缓存会随着新值的到来不断被覆盖。这跟普通SharedFlow的replay缓存逻辑有差别,后面细说。

StateFlow适合用来做UI状态持有,在MVVM架构里,ViewModel暴露给View层的状态用StateFlow或者StateFlow的只读版本是个标准操作。你的LiveData基本上可以平滑替换成StateFlow,配合repeatOnLifecycle在合适的时机收集就行。

2.2 SharedFlow:更底层的热流,事件订阅的正确打开方式

SharedFlow是比StateFlow更底层也更灵活的选手。它不持有所谓"当前值",它只负责把发射出来的值广播给所有订阅者,至于要不要缓存、缓存多少、缓存满了怎么办,都通过构造参数来控制。

SharedFlow有一个replay参数,表示新订阅者能收到最近几个已发射的值。replay = 0的时候,新订阅者只能收到订阅之后发射的值,这就是地地道道的事件广播。如果你把replay设为1、2、5,那新订阅者一进来就能收到最近的历史数据,这算是向StateFlow行为靠拢了一步。

这里有一个关键配置需要理解清楚:extraBufferCapacity和onBufferOverflow。当发射速度大于收集速度时,数据需要一个地方暂存。replay负责给新订阅者缓存,extraBufferCapacity则是在此之外给慢消费者留的缓冲空间。当这两个空间都用完了,新事件来了怎么办?onBufferOverflow有两种主要策略:SUSPEND让发射方挂起等待消费者腾出空间,DROP_OLDEST丢掉最旧的数据。

我举个例子帮你理解。假设你的replay = 0,extraBufferCapacity = 1,onBufferOverflow = DROP_OLDEST,这其实是一个很多团队用的"最近事件通知"配置。某个瞬时事件发出时,如果此时没有订阅者,且缓冲区里已经有别的待处理事件,那么新事件会留下,旧事件被丢掉。订阅者将来collect时,能收到最近一次事件。

SharedFlow的核心价值,就是解耦生产者和消费者之间"必须在场"的要求。生产者发消息不需要等消费者来,消费者来晚了也能通过replay配置补一点,或者干脆就不补。

注意:StateFlow和SharedFlow虽然都是热流,但设计哲学完全不同。StateFlow的思想是"我永远保存最新状态,你来读就行";SharedFlow的思想是"我广播事件,但我不主动保存状态,你可以配置让我留几个历史的"。所以如果你要表达一次性事件(比如点击开关、显示Toast、路由跳转),优先考虑SharedFlow;表达持续状态(加载中、已登录、dataList),用StateFlow。

3. 冷流与热流的核心对比

3.1 行为差异对照表

说了这么多,我干脆用表格把这些差异直观列出来。

维度冷流(flow构建器创建的Flow)热流(StateFlow / SharedFlow)
数据生产时机每个collector订阅时才开始,且每个订阅者触发一次新生产生产不依赖订阅,发射方主动发射
是否有独立数据序列是,每个collector看到从头开始的完整序列否,所有collector共享同一份发射序列
订阅前数据无法获取,没有上游生产可通过replay参数配置,最多能看到replay个历史数据
是否有当前值没有StateFlow有,SharedFlow没有value概念
去重机制无StateFlow自带去重,SharedFlow默认不去重
典型场景网络请求、文件读取、数据库查询状态持有、事件广播、跨页面共享

这张表不是让你背的,是让你在选型的时候对着查的。你在动手写一个Flow之前,先花一分钟想一想:数据是谁产生的?产生产出了之后还需要不需要让晚到的订阅者看到?答案是流内部生产的且要求完整独立,用冷流;答案是外部主动发射且希望大家都看到,用热流。

3.2 背压与慢消费者:冷热流都要面对的问题

不管冷流热流,都会遇到一个问题:如果生产者的速度比消费者快,数据会积累起来吗?积累的机制是什么?

在冷流的原始flow {}构建器里,发射端每一次emit跟接收端的collect是串行协作的。发射端emit一个值,然后挂起等待接收端处理完这个值,再继续生产下一个。这天然是一种背压机制:接收端处理慢,生产端的emit就会等待。这个叫"流式拉取",消费者拉一下,生产者吐一点。

如果你希望生产者提前做一些工作,不需要每个值都等消费者处理完,可以用buffer操作符。比如flow { ... }.buffer(10),相当于在生产者和消费者之间放了一个可以容纳10个元素的队列。生产者不会因为消费者慢而立刻挂起,而是先往队列里丢,满了才挂起。如果你不关心旧值,只想拿最新的,还可以用conflate,它只保留最新值,来了新的就把旧的丢弃。

热流这边,SharedFlow自己就带缓冲区,参数就是replay和extraBufferCapacity,缓冲满了的丢策略用onBufferOverflow控制。StateFlow因为只retain最新一个值,缓冲基本可以忽略,但它的去重和覆盖更新机制其实也是一种"丢数据"策略,只是它丢得让人无感。

关于背压这里,我特别想提醒一个常见问题:很多人用SharedFlow的时候,以为设置了extraBufferCapacity = 100就能避免所有丢事件问题,结果发现丢得更奇怪了。原因往往是没意识到,缓冲满了之后的DROP_OLDEST策略只会丢弃缓冲里的旧数据,但复播时新订阅者看到的是replay区加当前缓冲区的数据,这个组合行为跟直觉差异很大。

如果你需要的是严格不丢事件的场景,比如缓存写盘、上报统计日志这种,SharedFlow并不是最优方案,你可能需要一个Channel加上对应的桥接。所以选型这件事,还是得回到场景本质上想,而不是拿着一个概念到处套。

4. 冷热转换:shareIn和stateIn的实战用法

4.1 参数拆解:scope、started、replay怎么配合

在实际业务里,直接创建MutableSharedFlow或者MutableStateFlow的情况有,但更常见的是从一个冷流出发,通过shareIn或stateIn把它转换成热流,从而让多个订阅者共享同一条数据管线。这两个操作符用得对不对,决定了好几个层面的问题:上游网络请求会不会重复发起、资源会不会泄漏、页面状态切换时数据是否保留。

shareIn的完整签名是shareIn(scope, started, replay),三个参数每个都必须理解到位。

scope是协程作用域,用于启动一个共享的收集循环,让热流背后有一个独立的上游协程在跑。这个scope必须足够"长命",通常用ViewModel的viewModelScope或者应用级的某个单例Scope。如果传错scope,比如传了页面的lifecycleScope,页面销毁了上游也会被取消,那共享就失去了意义。

started是最容易让人纠结的参数,它决定上游冷流什么时候开始运行。SharingStarted.Eagerly表示scope一创建就立刻开始收集上游,不管有没有订阅者;SharingStarted.Lazily表示等待第一个订阅者出现时才开始收集,订阅者全部消失后没有被特殊策略保护就会停止;WhileSubscribed()表示有订阅者时开始,订阅者全部消失后经过一个超时时间停止。

第三个参数replay,作用是当有一个新的订阅者加入时,能收到多少个上游已经发射的历史值。replay设置为0,新订阅者只看得到加入之后的数据;设置为1,新订阅者能拿到最近的一个值。

4.2 started策略的选择:别再把Eagerly当成默认选项

我来展开谈谈started策略,这是实际开发中大量踩坑的点。

SharingStarted.Eagerly解决的问题是"不管有没有人订阅,数据都得在后台跑着",比如股票行情、传感器数据流,你希望一进页面就能拿到最新快照。但它有一个明显代价:如果页面退出了,订阅者都消失了,上游还是不会停,除非scope取消。这会导致资源的持续消耗。在MVP型页面、日志上报这类不依赖频繁创建销毁的场景里,Eagerly勉强可用,但在普通页面里用它,基本就是在给自己的内存和电量埋雷。

SharingStarted.WhileSubscribed()是大多数UI场景的正确选择。它的逻辑很朴素:用户来看我时我才拉数据,用户走了我就停。这里有个隐藏细节:WhileSubscribed(5000)里的数字是停止超时时间。你可以在没订阅者之后让上游再等5秒,5秒内如果有新订阅者,可以直接复用当前已经热着的流量,不用重新启动。

我记得之前在一个即时通讯相关的功能里,消息列表的轮询流就是用WhileSubscribed(5000)来做的。用户切到后台,页面的订阅取消了,但上游轮询没有马上停,而是在5秒后才停。从后台切回来时发现还在热着,就直接拿缓存数据展示,体验很顺。如果你设成0,切后台再切回来就会重启轮询,每次都要重新连,体验差一截。

SharingStarted.Lazily介于两者之间,它跟WhileSubscribed()的区别在于:它不会在最后一个订阅者消失后立刻停止,而是继续运行,直到scope取消。这适合那种数据源本身不是密集型的,比如配置拉取、周期不频繁的事务,你希望它老实生产但不必刻意控制生命周期。

最后说stateIn。stateIn其实本质上是shareIn+ StateFlow的效果。它的一个显著特点是可以直接设置初始值,比如stateIn(scope, WhileSubscribed(5000), initialValue),这个initialValue就是StateFlow的初始value。所以如果你需要把一个冷流结果转换成可观察状态,还希望它有默认值,用stateIn特别方便。

注意:stateIn默认replay是1,而且不可配置,所以它永远保留最新值。这个特性也决定了它不能用来做事件通知,它只能表达状态。

5. 常见问题与排查技巧实录

5.1 问题速查表

我自己在实际项目中,以及看团队其他同事的代码时,积累了不少关于冷流热流的翻车案例。挑几个高频的,整理成速查表。

症状原因解决方向
同一个Flow被两个页面订阅后,网络请求执行了两次用了冷流,每个collector触发一次冷启动若想共享,换成shareIn/stateIn,或者在外层手动缓存Flow实例
用StateFlow触发一次性事件,第二次触发没有效果StateFlow自带去重,重复值会被过滤事件用MutableSharedFlow(replay=0);状态才用StateFlow
SharedFlow发射后订阅者完全收不到replay=0且订阅时机晚于发射将replay调大,或重新设计"先订阅再发送"的时序
页面从后台回前台,页面数据不是最新的WhileSubscribed()停止超时设0,上游已取消给停止超时留缓冲时间,如WhileSubscribed(5000)
ViewModel被销毁后,热流继续在工作共享用的scope是全局但未正确取消用ViewModel的viewModelScope,确保随VM销毁取消
状态UI不刷新,但stateFlow值确实变了在非UI线程发射数据,没有回到主线程收集在collect的时候切换到dispatcher,或者保证发射线程是主线程

这张表只覆盖了最常见的一部分。实际情况里,冷热流问题往往是嵌套在复杂的业务逻辑里的,排查的时候要一层层剥开。

5.2 工程上的几个建议性规范

最后给几个我实际用过且觉得有效的工程规范,可以直接抄到你的团队规范文档里。

第一个规范:暴露给UI层时,用只读类型。ViewModel内部持有MutableStateFlow,对外暴露时使用StateFlow的只读接口。这能有效避免外部代码直接往状态流里塞数据。同理,SharedFlow也建议暴露只读版本,用asSharedFlow()把可变类型隐藏起来。

第二个规范:UI层收集热流时,放在repeatOnLifecycle里。在Android里,直接在onCreate里无条件启动一个协程去collect一个热流,很容易造成生命周期泄漏。建议在onStart到onStop这个区间里进行收集,配合repeatOnLifecycle(Lifecycle.State.STARTED),能保证页面不可见时不会收到无意义的状态更新,也可以避免状态更新导致的崩溃。

第三个规范:任何热流的创建,都要想清楚replay的配置。不要图省事统一用MutableSharedFlow(),这个默认构造是replay=0、无缓冲、SUSPEND,它会让生产端的emit在无订阅者且缓冲满时无限挂起,可能卡住业务线程。我见过这种坑不止一次。

第四个规范:在ViewModel层,尽量用冷流封装网络请求,在靠近UI层的地方才用热流适配。冷流天然适合请求重试,热流天然适合状态分享。这个分层思想如果你实现一次,后面写代码会舒服很多。

5.3 排查思路:从症状倒推流的类型

遇到一个"行为不符合预期"的Flow问题,我建议按这个顺序排查。

先看流是谁创建的:如果创建处是flow { }、channelFlow、callbackFlow这类冷构建器,那问题的核心大概率在生命周期或者多次收集上。如果是MutableSharedFlow或MutableStateFlow,重点看replay和去重。

再看数据从哪来:如果是上游主动发射,下游只是被动收集,那要回看发射点是否执行了、执行频率、执行时机三个阶段。发射点没执行,再多的replay也白搭;发射时机不对,比如在页面onCreate里collect之前就已经发射了,就会有一个空档。

最后看协程作用域:一个冷流被shareIn共享之后,它的上游生命周期由传给shareIn的scope决定。这个scope如果比预期的短,上游会过早cancel;如果比预期长,可能在页面销毁后还在跑。这是许多隐性问题的温床。

结束前的几句大实话

做冷热流知识梳理,我最大的感受是:概念本身并不复杂,复杂的是在各种真实场景里做选型和配置。我早期写过不少代码,用StateFlow做事件通知,事件一丢就是排查一整天;也用过WhileSubscribed(0),页面每次回前台都要重新拉数据,慢吞吞的体验被用户骂了好几次。后来把这些原理彻底搞清楚了,才慢慢形成了一套自己的判断标准。

如果你现在还在被冷流热流困扰,建议先别急着背结论,打开自己项目里正在用的Flow,对着本文的维度和问题速查表,逐个场景分析一遍:它是冷流还是热流、数据谁生产的、replay是多少、生命周期怎么管理的、去重是否会误伤。

分析完两三个场景,你会发现自己已经有能力判断新场景该怎么写了。之后写代码遇到类似问题时,脑子里的那张对照表会帮你快速定位问题该往哪个方向查。这套思维方式,比记住任何配置参数都要值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询