Flow数据模型:冷流、状态、事件与共享策略

Flow数据模型:冷流、状态、事件与共享策略

FlowStateFlowSharedFlow的差异,不只是"冷"与"热"。更重要的是它们分别表达按需执行的数据链路、始终可读取的当前状态,以及向多个订阅者发布的数据或事件。

stateInshareIn则处理另一层问题:当一条冷流不应该为每个收集者重复执行时,如何把它变成由Scope管理、由启动策略控制的共享数据源。只有把数据语义、共享方式和生命周期放在一起,Flow选型才不会停留在类型对照表。

内容摘要

  1. 普通冷Flow由终止操作符启动,每个收集者通常拥有一份独立上游。
  2. 热流独立于某一次收集存在,多个订阅者共享同一个数据源。
  3. StateFlow始终保存当前值,适合表达可重复读取的状态。
  4. SharedFlow面向广播和共享,通过replay决定新订阅者获得多少近期数据。
  5. stateInshareIn把冷流转换为共享热流,Scope与SharingStarted共同控制共享任务。

一、冷流与热流描述数据源如何存在

scss 复制代码
val users: Flow<List<User>> = flow {
    // 冷 Flow 只有被收集时才会执行这里的请求
    val result = api.loadUsers()
​
    // 将本次请求结果发送给当前收集者
    emit(result)
}

定义这条Flow时,上游通常还没有执行。mapfilter等中间操作符也只是在组装处理链,直到collectfirsttoList等终止操作符出现,数据生产才真正启动。

sql 复制代码
collect出现
→ 启动一份上游
→ 上游emit
→ 数据经过中间操作符
→ collect处理结果

如果对同一条冷Flow收集两次,通常会执行两份上游:

php 复制代码
// 第一次收集会独立启动一份上游
users.collect(::renderList)
​
// 第二次收集通常会再次执行请求,而不是复用第一次结果
users.collect(::writeCache)

这可能意味着两次网络请求、两次数据库查询或两条设备连接。冷流的价值正是按需执行和收集者隔离;当重复执行不符合业务意图时,才需要进一步共享。

emit也不是普通return。一次Flow可以依次发出多个值,而每个值都沿当前收集链向下游传递。

冷与热是数据源和收集者之间的关系

冷流与热流描述的不是数据内容,而是数据源如何启动、是否被多个收集者共享,以及它的生命周期是否依赖某一次收集。

对比项 冷流 热流
数据源何时出现 收集时启动 独立于某一次收集存在
多个收集者 通常各自启动一份上游 订阅同一个共享数据源
没有收集者时 上游通常不执行 可以保存状态;是否继续生产由实现和启动策略决定
常见类型 flow {}创建的普通Flow StateFlowSharedFlow
常见用途 查询、计算、按需加载 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

这两个示例的关键差异不是emitvalue的语法,而是生产关系:冷流把生产过程交给每次收集,热流把数据放在独立存在的共享源中。

Flow本身是数据流接口,并不意味着所有Flow实例都是冷流;通过flow {}等常见构建器创建的Flow通常是冷流,而StateFlowSharedFlow明确属于热流。

"热"也不等于"创建后永远在后台运行"。主动维护的MutableStateFlow即使没有收集者,也能保存当前状态;由stateInshareIn生成的热流,其上游是否运行还要看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还是导航组件。

三、stateInshareIn共享一份冷上游

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,并不代表创建后必然持续生产。stateInshareIn的共享任务受Scope约束,上游运行时机又受SharingStarted控制。

本节小结

stateInshareIn解决的是多订阅者共享上游问题。前者生成当前状态,后者生成共享广播;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

只有同时回答这三层问题,才能避免重复执行上游、错把事件当状态,或让共享任务在没有订阅者时无边界运行。

参考资料

相关推荐
WAsbry1 小时前
深入理解 Kotlin suspend:挂起语义、状态机与恢复调度
android
WAsbry1 小时前
Flow在Android中的完整落地:异常、重试与生命周期
android
WAsbry1 小时前
协程共享状态:Atomic、Mutex、Semaphore与状态封闭
android
WAsbry1 小时前
Flow执行模型:上下文保持与生产消费节奏
android
WAsbry1 小时前
协程任务的组织方式:launch、async、withContext与结构化并发
android
WAsbry1 小时前
本文区分原子变量、复合操作、并发数量与状态所有权四类问题,比较Atomic、Mutex、Semaphore和状态封闭的适用边界,并结合计数、缓存和限流场景,建立
android
2501_915909062 小时前
iOS test 测试怎么做?功能、性能、兼容、稳定与安全五类测试指南
android·ios·小程序·https·uni-app·iphone·webview
赵广陆2 小时前
企业实战:Markdown图片检索
android·java·开发语言
alexhilton3 小时前
千万别误用Android Skills
android·kotlin·android jetpack