Flow数据模型:冷流、状态、事件与共享策略
Flow、StateFlow和SharedFlow的差异,不只是"冷"与"热"。更重要的是它们分别表达按需执行的数据链路、始终可读取的当前状态,以及向多个订阅者发布的数据或事件。
stateIn和shareIn则处理另一层问题:当一条冷流不应该为每个收集者重复执行时,如何把它变成由Scope管理、由启动策略控制的共享数据源。只有把数据语义、共享方式和生命周期放在一起,Flow选型才不会停留在类型对照表。
内容摘要
- 普通冷Flow由终止操作符启动,每个收集者通常拥有一份独立上游。
- 热流独立于某一次收集存在,多个订阅者共享同一个数据源。
StateFlow始终保存当前值,适合表达可重复读取的状态。SharedFlow面向广播和共享,通过replay决定新订阅者获得多少近期数据。stateIn和shareIn把冷流转换为共享热流,Scope与SharingStarted共同控制共享任务。

一、冷流与热流描述数据源如何存在
scss
val users: Flow<List<User>> = flow {
// 冷 Flow 只有被收集时才会执行这里的请求
val result = api.loadUsers()
// 将本次请求结果发送给当前收集者
emit(result)
}
定义这条Flow时,上游通常还没有执行。map、filter等中间操作符也只是在组装处理链,直到collect、first或toList等终止操作符出现,数据生产才真正启动。
sql
collect出现
→ 启动一份上游
→ 上游emit
→ 数据经过中间操作符
→ collect处理结果
如果对同一条冷Flow收集两次,通常会执行两份上游:
php
// 第一次收集会独立启动一份上游
users.collect(::renderList)
// 第二次收集通常会再次执行请求,而不是复用第一次结果
users.collect(::writeCache)
这可能意味着两次网络请求、两次数据库查询或两条设备连接。冷流的价值正是按需执行和收集者隔离;当重复执行不符合业务意图时,才需要进一步共享。
emit也不是普通return。一次Flow可以依次发出多个值,而每个值都沿当前收集链向下游传递。
冷与热是数据源和收集者之间的关系
冷流与热流描述的不是数据内容,而是数据源如何启动、是否被多个收集者共享,以及它的生命周期是否依赖某一次收集。
| 对比项 | 冷流 | 热流 |
|---|---|---|
| 数据源何时出现 | 收集时启动 | 独立于某一次收集存在 |
| 多个收集者 | 通常各自启动一份上游 | 订阅同一个共享数据源 |
| 没有收集者时 | 上游通常不执行 | 可以保存状态;是否继续生产由实现和启动策略决定 |
| 常见类型 | flow {}创建的普通Flow |
StateFlow、SharedFlow |
| 常见用途 | 查询、计算、按需加载 | UI状态、共享连接、事件广播 |
可以把两者理解成两种不同关系:
css
冷流
collect A → 启动上游 A → 数据交给 A
collect B → 启动上游 B → 数据交给 B
热流
┌→ 订阅者 A
共享数据源 ──┤
└→ 订阅者 B
用代码观察冷流时,最明显的现象是每次collect都会重新执行构建器:
scss
val coldCount = flow {
// 每次收集都会重新打印,并重新执行数据加载
println("load count")
emit(api.loadCount())
}
// 第一次收集:打印一次 load count
coldCount.collect { value ->
println("A receives $value")
}
// 第二次收集:再次打印 load count
coldCount.collect { value ->
println("B receives $value")
}
热流则只有一个共享对象,多个订阅者观察的是同一份状态:
scss
// 创建时已经拥有当前值,不需要由 collect 启动生产代码
val hotCount = MutableStateFlow(0)
scope.launch {
// A 订阅同一个 StateFlow,并立即获得当前值
hotCount.collect { value ->
println("A receives $value")
}
}
scope.launch {
// B 不会创建另一份上游,而是观察同一个状态源
hotCount.collect { value ->
println("B receives $value")
}
}
// 更新共享状态后,活跃的 A 和 B 都会收到新值
hotCount.value = 1
这两个示例的关键差异不是emit和value的语法,而是生产关系:冷流把生产过程交给每次收集,热流把数据放在独立存在的共享源中。
Flow本身是数据流接口,并不意味着所有Flow实例都是冷流;通过flow {}等常见构建器创建的Flow通常是冷流,而StateFlow和SharedFlow明确属于热流。
"热"也不等于"创建后永远在后台运行"。主动维护的MutableStateFlow即使没有收集者,也能保存当前状态;由stateIn或shareIn生成的热流,其上游是否运行还要看Scope和SharingStarted。热流的关键是共享数据源不从属于某一个collect,而不是永不停止。
还要注意,冷流/热流与状态/事件不是同一个判断维度:前者描述数据源如何启动和共享,后者描述数据表达"当前是什么"还是"发生了什么"。SharedFlow是热流,但它既可以广播持续数据,也可以表达一次性事件。
本节小结
冷流为每次收集建立独立的数据生产过程;热流让多个订阅者共享同一个数据源。两者的区别首先是数据源与收集者的关系,至于热流在没有订阅者时是否继续运行,还要结合具体实现和启动策略判断。
二、状态与事件需要不同数据语义
StateFlow保存当前状态
kotlin
data class TaskUiState(
// 一个不可变快照同时描述页面需要渲染的全部当前状态
val loading: Boolean = false,
val tasks: List<Task> = emptyList(),
val error: String? = null
)
// 内部持有可写状态,负责状态更新
private val _uiState = MutableStateFlow(TaskUiState())
// 外部只暴露只读类型,避免页面越权修改状态
val uiState: StateFlow<TaskUiState> = _uiState
StateFlow必须有初始值,并且始终存在一个可读取的value。新的收集者开始订阅时,会立即拿到当前状态,之后继续收到状态变化。
这条语义适合页面渲染:
页面此刻应该显示什么?
内部保留MutableStateFlow负责修改,外部只暴露StateFlow负责观察,可以把写权限收口到状态所有者。
状态更新还应尽量使用不可变快照:
ini
_uiState.update { old ->
// update 基于最新旧值原子计算新快照,适合并发状态更新
old.copy(
loading = false,
tasks = newTasks
)
}
页面可以依据任意时刻的最新快照完成重绘,而不依赖某次通知是否恰好被接收。
SharedFlow发布共享数据或事件
kotlin
// 内部事件源负责发布一次性通知
private val _events = MutableSharedFlow<UiEvent>()
// 外部只能订阅,不能直接发送事件
val events: SharedFlow<UiEvent> = _events
SharedFlow不要求初始值,也不强调一个始终可读的当前状态。它把每次emit视为一次发布,并把数据广播给活跃订阅者。
这条语义更接近:
刚刚发生了什么?
导航、提示等一次性事件通常设置replay = 0,避免新页面重新消费旧事件。但事件是否允许在没有订阅者时丢失、是否需要缓冲、是否必须持久化,仍然是业务可靠性问题。SharedFlow不是可靠消息队列。
状态与事件不能只按UI控件分类
同一份业务信息可能有不同语义:
支付当前处于成功状态 → StateFlow中的状态
弹出一次支付成功提示 → SharedFlow中的事件
前者需要页面重建后仍可恢复,后者通常不应重复执行。关键判断是信息表示"当前是什么"还是"发生过一次什么"。
本节小结
StateFlow保存可重复读取的当前状态,SharedFlow发布数据或事件。选择依据是信息语义,而不是页面使用了TextView、Toast还是导航组件。
三、stateIn与shareIn共享一份冷上游
stateIn把冷流共享为状态
ini
val uiState: StateFlow<TaskUiState> =
repository.observeTasks()
// 将仓库数据转换成页面状态
.map(TaskUiState::Content)
.stateIn(
// 共享任务跟随 ViewModel 生命周期
scope = viewModelScope,
// 最后一个订阅者离开后保留上游 5 秒
started = SharingStarted.WhileSubscribed(5_000),
// 首个真实数据到达前立即提供可渲染状态
initialValue = TaskUiState.Loading
)
三个参数分别定义三件事:
sql
scope → 共享任务由谁管理
started → 上游何时启动和停止
initialValue → 首个真实数据到达前的状态
转换后,多个订阅者共享同一份上游,并从StateFlow获得当前值。initialValue不是占位语法,它必须是业务上可解释的初始状态。
shareIn把冷流共享为广播
ini
val temperatures: SharedFlow<Float> =
device.temperatureFlow()
.shareIn(
// 多个页面订阅者共享同一份设备数据源
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
// 新订阅者立即获得最近一条温度数据
replay = 1
)
replay决定新订阅者可以立即获得最近多少条已发送数据:
ini
replay = 0 → 不重放
replay = 1 → 重放最近一条
replay = N → 重放最近N条
重放缓存保存在内存中,只适合少量近期数据。完整历史、关键业务记录或进程重启后仍需恢复的数据,应进入数据库等持久化存储。
共享不等于永久运行
热流表示数据源生命周期不从属于某一次collect,并不代表创建后必然持续生产。stateIn和shareIn的共享任务受Scope约束,上游运行时机又受SharingStarted控制。
本节小结
stateIn和shareIn解决的是多订阅者共享上游问题。前者生成当前状态,后者生成共享广播;Scope和初始值或重放数量共同定义其行为边界。
四、SharingStarted决定共享上游何时运行
三种常见策略对应不同成本模型:
| 策略 | 启动时机 | 停止时机 | 典型用途 |
|---|---|---|---|
Eagerly |
创建后立即启动 | Scope取消 | 必须提前准备的数据源 |
Lazily |
首个订阅者出现 | Scope取消 | 首次使用后持续保活 |
WhileSubscribed |
存在订阅者时启动 | 无订阅者后按配置停止 | 页面可见期间使用的数据 |
Android页面中常见:
ini
SharingStarted.WhileSubscribed(
// 无订阅者后延迟停止,减少短暂页面重建造成的上游抖动
stopTimeoutMillis = 5_000
)
其运行过程是:
第一个订阅者出现 → 立即启动上游
最后一个订阅者消失 → 开始5秒宽限期
宽限期内重新订阅 → 复用仍在运行的上游
宽限期结束仍无人订阅 → 停止上游
5秒不是固定规则,而是在资源消耗和重建抖动之间的经验折中。上游如果是昂贵硬件连接,可能需要更长保活;如果包含敏感定位或高耗电任务,则可能要求立即停止。
WhileSubscribed停止上游后,StateFlow当前值或SharedFlow重放缓存如何处理,还可通过更完整的策略参数控制。是否保留旧值应由数据新鲜度要求决定。
本节小结
SharingStarted是共享任务的运行策略,不是冷热流标签。选择策略时需要权衡启动成本、空闲资源消耗、数据新鲜度和页面短暂重建。
结语:类型、共享与生命周期是三层决策
Flow选型可以拆成三个连续问题:
sql
数据表达什么?
→ 按需链路 / 当前状态 / 广播事件
是否需要共享上游?
→ 保持冷流 / stateIn / shareIn
共享任务何时运行?
→ Scope + SharingStarted
只有同时回答这三层问题,才能避免重复执行上游、错把事件当状态,或让共享任务在没有订阅者时无边界运行。