Kotlin Flow 深入解析:`stateIn()` 的真正核心,其实是 SharingStarted

1 阅读5分钟

很多 Android 开发者使用 stateIn() 时,代码几乎都是以下这种:

stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000),
    initialValue = UiState.Loading
)

这其实已经是标准写法了。

实际上:

stateIn 做的事情只有两件:

1、把冷流升级为热流
2、通过 SharingStarted 控制上游生命周期

这里最关键的其实是

SharingStarted

它决定:

  • 上游什么时候启动
  • 上游什么时候停止
  • 缓存什么时候失效
  • 页面退出后资源是否释放
  • 页面重建时是否重新请求数据

某种意义上:

SharingStarted 才是 Flow 世界里的生命周期管理器。


一、stateIn 的本质

假设有这样一个 Repository:

fun userFlow(): Flow<User> = flow {
    println("开始请求用户信息")
    emit(api.getUser())
}

这是一个标准冷流。

意味着:

collect 一次
执行一次

项目中经常会出现这种情况:

页面顶部头像需要用户信息:

页面中间的用户卡片也需要用户信息:

此时:

Avatar collect 一次
Profile collect 一次

日志:

开始请求用户信息
开始请求用户信息

因为:

Flow 默认是冷流

每次 collect
都会重新执行整个上游

于是:

val user = repository.userFlow()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(),
        initialValue = User.Empty
    )

现在:

多个 Collector
共享同一个上游

日志:

开始请求用户信息

无论:

  • 1 个 Collector
  • 2 个 Collector
  • 10 个 Collector

都共享同一个上游执行结果。


实际上, 在Room的场景:

val messages = dao.observeMessages()

可能同时存在:

  • 聊天列表 collect
  • 未读角标 collect
  • 消息统计 collect

如果没有 stateIn()

Room 注册 3 次 Observer
SQLite 执行 3 次查询

使用 stateIn() 后:

Room 只监听一次
多个消费者共享结果

这其实才是 stateIn() 最核心的价值:

把昂贵的上游操作共享给多个消费者。


二、真正的核心:SharingStarted

官方提供了三种策略:

策略启动时机停止时机适用场景
Eagerly立即启动Scope 销毁全局数据
Lazily首次订阅启动Scope 销毁懒加载缓存
WhileSubscribed有订阅启动无订阅超时后停止UI 数据

三、Eagerly:项目启动立即工作

val config = repository.configFlow()
    .stateIn(
        viewModelScope,
        SharingStarted.Eagerly,
        Config.Empty
    )

特点:

ViewModel 创建
立即启动上游

即使:

没有任何 Collector

上游仍然持续运行。

例如:

Application 启动
↓
ViewModel 创建
↓
网络开始请求
↓
页面甚至还没打开

适用于:

  • 登录状态
  • 用户信息
  • 配置中心

本质上:

数据比 UI 更重要

四、Lazily:第一次使用时启动

val userInfo = repository.userFlow()
    .stateIn(
        viewModelScope,
        SharingStarted.Lazily,
        User.Empty
    )

特点:

没人订阅
不上班

有人订阅
开始工作

订阅结束
继续工作

只会启动一次。

以后永不停止。

生命周期:

第一次 collect
    ↓
启动上游
    ↓
页面退出
    ↓
继续运行
    ↓
ViewModel 销毁
    ↓
结束

适合:

  • 大缓存数据
  • 初始化较重的数据
  • 不希望重复初始化的数据源

例如:

  • 地图 SDK
  • 播放器状态
  • 大型配置数据

五、WhileSubscribed:UI 场景最优解

这是官方最推荐的方案。

val uiState = repository.dataFlow()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(),
        initialValue = UiState.Loading
    )

行为:

有页面观察
    ↓
启动上游

页面退出
    ↓
停止上游

非常符合:

UI 生命周期

因此:

大部分 ViewModel 都应该优先考虑 WhileSubscribed。


六、容易被忽略的两个参数

很多人用了几年:

SharingStarted.WhileSubscribed(5000)

却不知道这两个参数到底干什么。

完整定义:

WhileSubscribed(
    stopTimeoutMillis,
    replayExpirationMillis
)

1、stopTimeoutMillis

例如:

SharingStarted.WhileSubscribed(
    stopTimeoutMillis = 5000
)

意思:

最后一个订阅者消失后
继续存活 5 秒

生命周期:

Activity A collect
    ↓
用户旋转屏幕
    ↓
旧 Activity 销毁
    ↓
没有订阅者
    ↓
等待 5 秒
    ↓
新 Activity 建立
    ↓
重新订阅

由于:

5 秒内重新订阅

所以上游不会停止。

避免了:

  • 网络重新请求
  • 数据库重新监听
  • Flow 重新启动

这实际上是一种:

防抖机制

如果设置:

WhileSubscribed(0)

那么:

页面退出
立即停止
页面重建
重新启动

旋转一次屏幕:

启动
停止
启动
停止
启动

日志可能会非常夸张。


2、replayExpirationMillis

例如:

WhileSubscribed(
    stopTimeoutMillis = 5000,
    replayExpirationMillis = 0
)

它控制的是:

缓存什么时候失效

默认值:

Long.MAX_VALUE

意味着:

缓存永不过期

假设:

val flow = flow {
    delay(2000)
    emit(Random.nextInt())
}

第一次进入页面:

Loading
↓
42

退出页面。

重新进入:

42
↓
重新请求
↓
88

因为:

42 被缓存下来了

所以用户会先看到旧值。

如果:

WhileSubscribed(
    stopTimeoutMillis = 5000,
    replayExpirationMillis = 0
)

行为变成:

页面退出
↓
缓存立即清空
↓
重新进入页面
↓
显示 initialValue
↓
重新请求
↓
88

日志:

0
↓
88

旧值不会再出现。


七、什么时候应该设置 replayExpirationMillis = 0?

适合:

  • 实时股票
  • 实时价格
  • 订单状态
  • 设备状态
  • 在线人数
  • 倒计时

这些数据:

旧值没有意义

用户看到旧数据反而是一种错误。

而下面这些场景:

  • 用户资料
  • 配置中心
  • 主题模式
  • 设置项
  • 登录信息

缓存反而是好事。

因为:

用户不希望看到闪屏和 Loading

八、推荐配置

绝大多数 ViewModel:

stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(
        stopTimeoutMillis = 5000
    ),
    initialValue = UiState.Loading
)

实时数据:

stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(
        stopTimeoutMillis = 5000,
        replayExpirationMillis = 0
    ),
    initialValue = UiState.Empty
)

全局状态:

stateIn(
    scope = applicationScope,
    started = SharingStarted.Eagerly,
    initialValue = InitialState
)

Screen_recording_20260712_091033.gif

源码: Github