关于 collecting-flows-safely
面向 Claude 的技能,指导在 Jetpack Compose 中根据 UI 生命周期安全收集 Kotlin Flow。内容涵盖生命周期感知收集、高频发射过滤,以及避免将 Flow 作为可组合项参数。
主要功能
- 01使用生命周期感知的 Flow 收集
- 02用 conflate 和 distinctUntilChanged 调节高频发射
- 03在调用方收集 Flow 并传递解析后的值
- 04通过 snapshotFlow 将 Compose 状态桥接为 Flow
- 05验证应用进入后台时的收集行为
适用场景
- 01UI 不活跃时暂停 ViewModel 或仓库 Flow
- 02减少传感器或位置更新引起的重组
- 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(),延迟到特定阶段读取状态可能是更好的解决方案。
工作流
- 审查每个
collectAsState()调用。 在模块中搜索\.collectAsState\(。除非生成该流的代码位于同一可组合项作用域内,否则将每个调用替换为collectAsStateWithLifecycle()。 - 当流不是
StateFlow(冷Flow<T>或SharedFlow<T>)时,提供initialValue。 对于StateFlow<T>,此重载会读取.value,无需初始值。 - 选择合适的
minActiveState。 默认值STARTED与框架的repeatOnLifecycle默认值一致。对于仅应在 Activity 持有输入焦点时才收集数据的小部件(例如使用高频传感器数据源、始终可见的前台 HUD),请使用RESUMED。不要使用CREATED,否则会违背此操作的目的。 - 对于高频数据源(每约 100 ms 发出多次数据),在
collectAsStateWithLifecycle()上游添加.conflate(),使消费者在每一帧期间只保留最新值。如果连续发出的数据可能值相等,也添加.distinctUntilChanged(),并确保发出类型的equals()正确。 - 将
Flow<T>参数提至可组合项之外。 将fun Foo(prices: Flow<Price>)替换为fun Foo(price: Price)。在调用方收集数据。如果数据源必须保留为父级私有,则公开一个() -> Pricelambda 提供器,而不是原始Flow。 - **对于 State → Flow 的转换,**在
LaunchedEffect内使用snapshotFlow { ... }。这是 Compose 快照系统与协程流之间受支持的桥接方式;搭配.distinctUntilChanged()可避免无意义的数据发出。 - **验证:**使用
@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(对于非常热的数据源,可传入() -> Tlambda 提供器)。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时,代码中会记录其理由(需要焦点的控件)。
参考资料
- Manuel Vivo — 在 Jetpack Compose 中安全地消费流:https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3
- Android Developers — 感知生命周期的协程(
repeatOnLifecycle):https://developer.android.com/topic/libraries/architecture/coroutines androidx.lifecycle:lifecycle-runtime-compose发行说明:https://developer.android.com/jetpack/androidx/releases/lifecycle- Android Developers — Compose 副作用(
snapshotFlow、LaunchedEffect):https://developer.android.com/develop/ui/compose/side-effects - Skydoves — 6 条 Jetpack Compose 指南(将 Flow 作为参数的反模式):https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- Ben Trengrove — 为什么要在 release 版本中测试性能:https://medium.com/androiddevelopers/why-should-you-always-test-compose-performance-in-release-4168dd0f2c71
- 相关技能:
../using-efficient-effects/SKILL.md,用于选择LaunchedEffect/DisposableEffect/RememberedEffect。 - 相关技能:
../../measurement/tracing-recompositions-at-runtime/SKILL.md,用于在运行时验证发射次数。
评论
支持 Markdown