mcpField
K
kotlin-flow-state-event-modeling

kotlin-flow-state-event-modeling

github.com/chrisbanes开发1.1k1.0k 次安装1 天前更新
代码仓库

关于 kotlin-flow-state-event-modeling

帮助 Kotlin 开发者选择 Flow 原语来建模状态与事件,依据重放、投递和同步读取需求避开常见的共享状态问题。

主要功能

  1. 01说明 StateFlow、SharedFlow、Channel 和 Flow 的语义
  2. 02指导建模缺失与加载状态
  3. 03展示原子化的 StateFlow 更新
  4. 04介绍 stateIn 共享及启动策略
  5. 05说明如何保留同步 value 访问

适用场景

  1. 01建模一次性导航或 Snackbar 事件
  2. 02为同步读取选择状态流
  3. 03避免并发协程造成更新丢失
  4. 04审查 Flow 共享方式与占位状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/chrisbanes/skills --skill kotlin-flow-state-event-modeling

安装后在 Claude Code 中输入/kotlin-flow-state-event-modeling确认已加载。

详细介绍

Kotlin Flow:状态与事件建模

核心原则

选择符合重放、扇出和同步读取需求的基本类型。 StateFlow、SharedFlow、由 Channel 支撑的 Flow 和冷 Flow 在缓冲方式、每次发射的接收者,以及是否提供 .value 方面各不相同。选择不当会导致事件丢失、共享协程泄漏,或不得不把虚假的领域哨兵值塞进状态中。

何时使用此技能

当你编写或审查涉及以下内容的 Kotlin 代码时:

  • MutableStateFlow<T>(SomeSentinel)——例如 NoUser、Empty、Loading 等,因为真实值需要异步获取
  • 在函数内部调用 .stateIn(...),而不是将其赋给属性
  • 在某个 Flow 上使用 SharingStarted.WhileSubscribed(...),但该 Flow 的 .value 会被同步读取且必须保持最新
  • 使用 MutableSharedFlow 处理导航事件、Snackbar 或其他一次性发射,而丢失这些事件会构成 bug
  • 在 StateFlow 上使用 .map { },但消费者仍然需要同步读取 .value
  • 使用 MutableStateFlow.value = _state.value.copy(...),或在 update { ... } 中创建开销较大的对象

使用 SharedFlow 处理只由单个消费者接收的一次性事件

SharedFlow 默认没有重放缓冲区。如果发射的那一刻没有任何协程正在收集,事件就会丢失。对于处理导航或 Snackbar 等严格要求恰好一次的事件的单个 UI 消费者,将带缓冲的 Channel 作为 Flow 暴露通常更符合所需语义:

// ❌ BAD
private val _navEvents = MutableSharedFlow<NavigationEvent>()
val navEvents: SharedFlow<NavigationEvent> = _navEvents.asSharedFlow()

// ✅ GOOD
private val _navEvents = Channel<NavigationEvent>(Channel.BUFFERED)
val navEvents: Flow<NavigationEvent> = _navEvents.receiveAsFlow()

Channel.receiveAsFlow() 实现的是扇出,而非广播:有多个收集者时,每个事件只会传递给其中一个收集者。Channel.BUFFERED 的容量有限,因此发送可能会挂起,trySend 也可能失败。如果多个观察者都必须看到同一个事件,请使用显式状态、持久化存储,或经过有意配置的 SharedFlow。

StateFlow 被无效的哨兵默认值污染

StateFlow 强制要求初始值。当真实值需要异步获取时,开发者有时会虚构领域值,例如 NoUser、EmptyUser、占位 ID,导致每个消费者都不得不把这个哨兵值当作真实数据处理。

// ❌ BAD — sentinel leaks into the type
class UserSession(private val db: Db) {
    private val _user = MutableStateFlow<User>(NoUser)
    val user: StateFlow<User> = _user.asStateFlow()
    init { scope.launch { _user.value = db.load() } }
}

一种解决方法是分阶段暴露:在真实值存在之前,不暴露 StateFlow。

// ✅ GOOD — bootstrap suspends; observers only see real users
class UserSession(private val db: Db) {
    private var _user: MutableStateFlow<User>? = null
    val user: StateFlow<User>
        get() = checkNotNull(_user) { "Call login() first" }

    suspend fun login() {
        _user = MutableStateFlow(db.load())
    }
}

如果缺失、加载或错误本身就是实际状态,请显式建模(例如 User?、sealed interface UserUiState、Result 等)。真正的问题在于虚假的领域值伪装成了真实数据,而不是每个初始值都有问题。

使用 update { ... } 修改 MutableStateFlow

优先使用 MutableStateFlow.update { current -> ... },而不是读取 .value 再写回。update 会基于最新状态原子地应用转换,避免多个协程修改同一状态时发生更新丢失。

// BAD — read/modify/write can lose concurrent updates.
_state.value = _state.value.copy(
    selectedId = id,
    details = details,
)

// GOOD — transform starts from the latest state.
_state.update { current ->
    current.copy(
        selectedId = id,
        details = details,
    )
}

除非创建对象需要依赖当前状态,否则请在 update 代码块外创建对象。更新 lambda 可能会重试,因此其中的高开销操作或副作用可能执行多次:

// GOOD — details does not depend on current state, so build it once.
val details = Details.from(response)
_state.update { current ->
    current.copy(details = details)
}

// GOOD — derived value depends on current state, so compute it inside.
_state.update { current ->
    val nextItems = current.items.replaceById(updatedItem)
    current.copy(items = nextItems)
}

代码块应当是纯粹、快速的状态转换:不要在其中调用网络、写入数据库、执行带副作用的日志操作、生成随机 ID 或读取时间,除非这些值已在代码块外提前获取。

在函数内部调用 stateIn()

// ❌ BAD — new sharing coroutine every call
fun getPreferences(): StateFlow<Prefs> =
    repo.prefsFlow.stateIn(scope, SharingStarted.Eagerly, Prefs.Default)

每次调用 getPreferences(),都会在 scope 上启动一个永不结束的新协程。反复读取很快就会导致性能急剧下降。

// ✅ GOOD — one shared instance, computed once
val preferences: StateFlow<Prefs> =
    repo.prefsFlow.stateIn(viewModelScope, SharingStarted.Eagerly, Prefs.Default)

WhileSubscribed 与同步读取 .value

当没有活跃的收集者时,SharingStarted.WhileSubscribed(timeout) 会断开上游连接。断开期间,.value 返回最后缓存的值,这个值可能已过时,也可能仍是初始值。

**规则:**如果 .value 必须保持最新,或必须在没有活跃收集者时完成初始化,请使用 SharingStarted.Eagerly 或显式初始化。如果可以接受过时或缓存值,且消费者主要以异步方式收集,那么使用 WhileSubscribed 没有问题。

StateFlow 上的 .map 会丢失 .value

// ❌ BAD — `name.value` won't compile; it's now a plain Flow
val name: Flow<String> = userState.map { it.name }

如果需要同步读取 .value,请以 .stateIn(...) 结束链式调用:

// ✅ GOOD
val name: StateFlow<String> = userState
    .map { it.name }
    .stateIn(viewModelScope, SharingStarted.Eagerly, userState.value.name)

社区提供的“派生状态流”工具会在每次读取 .value 时执行转换——只有转换速度快且具备幂等性时才适用。默认使用 .stateIn(...)。

决策:选择哪种 Flow 类型?

需求基础类型
始终有值的状态,既由异步收集器读取,也由同步代码读取StateFlow;如果 .value 很重要,通常使用 SharingStarted.Eagerly
热流,有多个订阅者,不要求同步读取 .valueSharedFlow
面向单个使用者的离散事件,要求恰好交付一次可考虑 Channel(BUFFERED).receiveAsFlow()
冷流,每次收集对应一个使用者普通 Flow

如果你想使用 SharedFlow,不妨先问问自己:丢弃一次发射会不会造成错误?有多少个使用者必须看到它?如果必须由一个使用者恰好处理一次,Channel 可能更合适。如果所有观察者都必须看到它,请将其建模为持久状态,或有意配置广播流。

快速参考

症状问题修复方案
MutableStateFlow<X>(FakeDomainValue)无效的占位默认值明确建模“无值”状态,或使用分阶段初始化
将 MutableSharedFlow<Event> 用于单个使用者的导航/Snackbar默认事件流可能丢失事件可考虑 Channel(BUFFERED).receiveAsFlow()
fun foo() = flow.stateIn(...)每次调用都会创建共享协程将其设为 val/共享实例
WhileSubscribed + 必须保持 .value 最新/已初始化数据过时或只有初始数据使用 SharingStarted.Eagerly 或显式初始化
将 stateFlow.map { ... } 作为状态使用丢失 .value以 .stateIn(...) 结束链式调用
_state.value = _state.value.copy(...)非原子读取/修改/写入_state.update { it.copy(...) }
在不使用当前状态的 update { ... } 中创建开销较大的对象如果更新重试,相关操作可能重复在 update 之前创建对象;只在其中保留依赖当前状态的转换

审查时的危险信号

想法实际情况
“有多个订阅者,所以我们需要 SharedFlow”多个订阅者会改变语义。Channel.receiveAsFlow() 不是广播;应有意选择事件模型。
“我们会用 WhileSubscribed 来节省资源”只有在可以接受读取到过时或初始 .value 时才这样做。应用前请先确认。
“我会用哨兵值,直到加载到真实数据”使用者会把它当作真实领域数据;最好显式建模 UI/状态,或采用分阶段处理。
“在 update 里创建新对象比较方便”Lambda 可能会重试。除非对象依赖当前状态,否则应在外部创建。

相关内容

评论

支持 Markdown