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

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

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

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

内容摘要

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

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

参考资料

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui