mcpField
C
collecting-flows-safely

collecting-flows-safely

github.com/skydoves开发520139 次安装19 周前更新
代码仓库

关于 collecting-flows-safely

面向 Claude 的技能,指导在 Jetpack Compose 中根据 UI 生命周期安全收集 Kotlin Flow。内容涵盖生命周期感知收集、高频发射过滤,以及避免将 Flow 作为可组合项参数。

主要功能

  1. 01使用生命周期感知的 Flow 收集
  2. 02用 conflate 和 distinctUntilChanged 调节高频发射
  3. 03在调用方收集 Flow 并传递解析后的值
  4. 04通过 snapshotFlow 将 Compose 状态桥接为 Flow
  5. 05验证应用进入后台时的收集行为

适用场景

  1. 01UI 不活跃时暂停 ViewModel 或仓库 Flow
  2. 02减少传感器或位置更新引起的重组
  3. 03将可见列表状态发送给分析工具

安装

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

$npx skills add https://github.com/skydoves/compose-performance-skills --skill collecting-flows-safely

安装后在 Claude Code 中输入/collecting-flows-safely确认已加载。

详细介绍

安全收集 Flow — 让上游工作与 UI 生命周期保持同步

collectAsState() 会在可组合项处于组合中时持续收集,包括应用进入后台时——这会浪费 CPU 和电量。collectAsStateWithLifecycle() 会将收集与 Lifecycle.State(默认为 STARTED)绑定,从而在应用进入后台时暂停上游工作。对于频繁发出数据的 Flow,可让消费者配合使用 .conflate() 和 .distinctUntilChanged(),这样 Compose 只会收到有意义的变化。Skydoves 的第 4 条热门观点:可组合项的 Flow<T> 参数是不稳定的,会阻止整个可组合项跳过重组——应在调用方收集 Flow,并传入解析后的值。

何时使用此技能

  • ViewModel 或 Repository 向 Compose 屏幕公开 StateFlow<T>、SharedFlow<T> 或冷 Flow<T>。
  • 用户反馈后台耗电,称“应用在最近使用的应用列表中时,屏幕还在持续工作”,或在 logcat 中看到唤醒锁相关噪声。
  • 可组合项消费来自 Compose 之外的传感器数据、位置、GPS、websocket 或动画帧。
  • 可组合项的签名为 fun MyScreen(state: Flow<State>)(将 Flow 作为参数的反模式)。
  • 用户询问 collectAsState 与 collectAsStateWithLifecycle 的区别,或询问 Lifecycle.State.STARTED / RESUMED 的语义。
  • 用户希望让 Compose 状态驱动非 Compose 订阅者(例如根据可见项索引执行分析等)——这属于由 snapshotFlow 支持的 State→Flow 方向。

何时不使用此技能

  • Flow 完全在一个可组合项的 remember { ... } 块内构建和消费——这是组合内部状态,优先直接使用 mutableStateOf。有关如何选择合适的 effect API,请参阅 ../using-efficient-effects/SKILL.md。
  • 数据源已经是 State<T>(例如 mutableStateOf、Animatable.asState())——不要仅仅为了调用 collectAsStateWithLifecycle() 而将其包装成 Flow。
  • 真正需要始终运行、且必须在 Activity 处于 STOPPED 状态时继续工作的后台监听器。请将该工作移至 Service / WorkManager / Activity 中的 repeatOnLifecycle,不要放进 Compose。

前提条件

  • Compose UI 1.4+(任意现代版本)。
  • 添加 androidx.lifecycle:lifecycle-runtime-compose(2.6+;该依赖提供 collectAsStateWithLifecycle)。Maven 坐标:androidx.lifecycle:lifecycle-runtime-compose:<version>。
  • Kotlin 协程基础知识(StateFlow、SharedFlow、conflate、distinctUntilChanged)。
  • 如果高频发射正在驱动动画值,请阅读 ../../recomposition/deferring-state-reads/SKILL.md——相比 .conflate(),延迟到特定阶段读取状态可能是更好的解决方案。

工作流

  1. 审查每个 collectAsState() 调用。 在模块中搜索 \.collectAsState\(。除非生成该流的代码位于同一可组合项作用域内,否则将每个调用替换为 collectAsStateWithLifecycle()。
  2. 当流不是 StateFlow(冷 Flow<T> 或 SharedFlow<T>)时,提供 initialValue。 对于 StateFlow<T>,此重载会读取 .value,无需初始值。
  3. 选择合适的 minActiveState。 默认值 STARTED 与框架的 repeatOnLifecycle 默认值一致。对于仅应在 Activity 持有输入焦点时才收集数据的小部件(例如使用高频传感器数据源、始终可见的前台 HUD),请使用 RESUMED。不要使用 CREATED,否则会违背此操作的目的。
  4. 对于高频数据源(每约 100 ms 发出多次数据),在 collectAsStateWithLifecycle() 上游添加 .conflate(),使消费者在每一帧期间只保留最新值。如果连续发出的数据可能值相等,也添加 .distinctUntilChanged(),并确保发出类型的 equals() 正确。
  5. 将 Flow<T> 参数提至可组合项之外。 将 fun Foo(prices: Flow<Price>) 替换为 fun Foo(price: Price)。在调用方收集数据。如果数据源必须保留为父级私有,则公开一个 () -> Price lambda 提供器,而不是原始 Flow。
  6. **对于 State → Flow 的转换,**在 LaunchedEffect 内使用 snapshotFlow { ... }。这是 Compose 快照系统与协程流之间受支持的桥接方式;搭配 .distinctUntilChanged() 可避免无意义的数据发出。
  7. **验证:**使用 @TraceRecomposition(参见 ../../measurement/tracing-recompositions-at-runtime/SKILL.md),并在应用切到后台时检查 logcat,确认上游不再发出数据。

模式

模式:将 collectAsState 替换为 collectAsStateWithLifecycle
// WRONG
import androidx.compose.runtime.collectAsState

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val state by viewModel.uiState.collectAsState()
    HomeContent(state)
}
// WRONG because: collection continues while the app is backgrounded -> wasted CPU and battery,
// and any upstream operators (network polling, db queries) keep running.
// RIGHT
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    HomeContent(state)
}
模式:为始终可见的小部件自定义 minActiveState
// RIGHT
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun TopBar(viewModel: HomeViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle(
        minActiveState = Lifecycle.State.RESUMED,
    )
    TopBarContent(state)
}
模式:不要将 Flow<T> 作为可组合项参数传递(skydoves 热门观点 #4)
// WRONG
@Composable
fun PriceTicker(prices: Flow<Price>) {
    val price by prices.collectAsState(initial = Price.ZERO)
    Text(price.formatted)
}
// WRONG because: Flow is unstable -> blocks skipping for the whole composable; the collection
// lifecycle is also unclear (recreated on every recomposition unless the caller remembers it).
// RIGHT
@Composable
fun PriceTicker(price: Price) {
    Text(price.formatted)
}

// Caller collects once and passes the value:
@Composable
fun TickerScreen(viewModel: TickerViewModel) {
    val price by viewModel.price.collectAsStateWithLifecycle()
    PriceTicker(price)
}
模式:高频流需要 .conflate() + .distinctUntilChanged()
// WRONG
@Composable
fun TiltIndicator(repository: SensorRepository) {
    val tilt by repository.tilt.collectAsStateWithLifecycle(initialValue = 0f)
    TiltUi(tilt)
}
// WRONG because: hundreds of emissions per second invalidate the consuming composable per emission;
// most are dropped frames worth of work.
// RIGHT
@Composable
fun TiltIndicator(repository: SensorRepository) {
    val flow = remember(repository) {
        repository.tilt.conflate().distinctUntilChanged()
    }
    val tilt by flow.collectAsStateWithLifecycle(initialValue = 0f)
    TiltUi(tilt)
}
模式:使用 snapshotFlow 将 State 转为 Flow
// RIGHT
@Composable
fun FeedAnalytics(listState: LazyListState, analytics: Analytics) {
    LaunchedEffect(listState, analytics) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .collect { index -> analytics.logFirstVisibleIndex(index) }
    }
}
// snapshotFlow bridges snapshot State to a cold Flow without paying the cost of recomposing
// every time firstVisibleItemIndex changes — the read happens inside the LaunchedEffect's coroutine,
// not in the composition phase.
模式:对于即发即弃的响应,优先使用 snapshotFlow,而不是 derivedStateOf
// LESS PREFERRED for an effect that only emits side effects
val isAtTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
LaunchedEffect(isAtTop) { if (isAtTop) reportTop() }
// PREFERRED — no derived state slot in composition; reads happen in the coroutine
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex == 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect { reportTop() }
}

强制规则

  • 必须对所有源自组合之外的 Flow(ViewModel、repository、传感器、网络、websocket、位置)调用 collectAsStateWithLifecycle()(来自 androidx.lifecycle:lifecycle-runtime-compose)。
  • 禁止将 Flow<T> 作为可组合项参数传递。在调用方收集数据,并传入解析后的 T(对于非常热的数据源,可传入 () -> T lambda 提供器)。Flow 不稳定,会阻止整个可组合项跳过重组。
  • 必须在 collectAsStateWithLifecycle() 上游为每个发出频率高于每约 100 ms 一次的流添加 .conflate() 和/或 .distinctUntilChanged()。使用 distinctUntilChanged 时,确保发出类型的 equals() 正确。
  • 必须在内联应用 .conflate() / .distinctUntilChanged() 时,使用 remember(key) 包裹运算符链,避免每次重组都创建新的链。
  • 禁止将 Lifecycle.State.CREATED 用作 minActiveState,因为这会导致 Activity 不可见时仍继续收集,从而违背迁移目的。
  • 禁止仅为了调用 collectAsStateWithLifecycle() 而将现有的 State<T> 再包装为流。直接保留 State。
  • **优先选择:**当唯一消费者是即发即弃的副作用(分析、日志记录、侧信道发送)而非 UI 时,使用 snapshotFlow { ... },而不是 derivedStateOf { ... }。

验证

  • grep -R "collectAsState(" src/ 返回 0 个匹配项,或者每个剩余匹配项都收集在同一个可组合项内部创建的流。
  • 将应用切到后台并观察 logcat:上游运算符(Repository 日志行、传感器回调)会在一帧内停止,并在应用切回前台时恢复。
  • 在使用该数据的可组合项上使用 @TraceRecomposition(traceStates = true),每次 有意义的 发射只会触发一次重组,而不是每次原始上游发射都触发重组。参见 ../../measurement/tracing-recompositions-at-runtime/SKILL.md。
  • Battery Historian / Studio Energy Profiler 显示,应用在后台时,屏幕没有被归因于任何仅在前台运行的工作。
  • 模块中没有可组合项声明 Flow<*> 类型的参数(grep -R ": Flow<" src/)。
  • 使用 minActiveState = RESUMED 时,代码中会记录其理由(需要焦点的控件)。

参考资料

评论

支持 Markdown