关于 compose-side-effects
面向 Android Compose 开发者的参考技能,帮助选择副作用 API 并管理其生命周期。内容涵盖键、清理、最新回调、Flow 收集及组合阶段的常见问题。
主要功能
- 01Compose 副作用 API 选择指南
- 02副作用键与重启行为
- 03避免捕获过期回调
- 04Flow 与 snapshotFlow 收集
- 05注册与清理模式
适用场景
- 01审查可组合函数中的副作用代码
- 02为带键任务选择副作用 API
- 03修复长期运行任务中的过期回调
- 04观察 Compose 状态以处理事件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-side-effects安装后在 Claude Code 中输入/compose-side-effects确认已加载。
详细介绍
Compose:副作用
核心原则
可组合函数体用于描述 UI。它们可能会被重新组合、跳过或放弃。会改变外部世界的工作应放在生命周期与该工作相匹配的 effect API 中。
选择最小的 effect
| 需求 | API |
|---|---|
| 在每次成功重新组合后,将 Compose 状态发布到非 Compose 代码 | SideEffect |
| 注册/注销监听器、回调、观察者或资源 | DisposableEffect(keys...) |
| 运行挂起、延迟或由键控的一次性工作 | LaunchedEffect(keys...) |
| 从用户事件回调中启动挂起工作 | rememberCoroutineScope() |
| 在协程中将 Compose 快照读取转换为 Flow | LaunchedEffect 内的 snapshotFlow { ... } |
Effect 键
键用于定义重启标识。当任一键发生变化时,旧 effect 会被取消/清理,并启动新的 effect。
// ✅ Restart collection when userId changes
LaunchedEffect(userId) {
repository.events(userId).collect { event -> handle(event) }
}
// ❌ Unit hides a changing input; collection keeps using the first userId
LaunchedEffect(Unit) {
repository.events(userId).collect { event -> handle(event) }
}
使用稳定且有明确语义的键:
- 使用 effect 生命周期所依赖的对象:
userId、screenId、lifecycleOwner、focusRequester。 - 仅当某个属性重要时,不要使用范围过大的对象(
state、viewModel)。 - 除非确实希望每次 lambda 变化时都重启,否则不要将会变化的 lambda 添加为键。
避免捕获过期值
对于不应重启、但需要获取最新回调或值的长时间运行 effect,请使用 rememberUpdatedState。
@Composable
fun Timeout(onTimeout: () -> Unit) {
val latestOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(1_000)
latestOnTimeout()
}
}
当生命周期是“只启动一次”,但调用的 lambda 应保持最新时,使用此方式。常见情况:
- 超时或启动画面 effect 不应因
onTimeout变化而重启,但应调用最新的回调。 - 生命周期观察者应继续注册到同一个 owner,但应调用最新的
onStart/onStoplambda。 - 长时间运行的收集器应保持其收集生命周期,但应调用最新的事件处理程序。
不要用 rememberUpdatedState 来回避正确选择键。如果值变化后应重启工作,就应将其设为键:
// BAD: userId changes should restart the collection, not update a captured value.
val latestUserId by rememberUpdatedState(userId)
LaunchedEffect(Unit) {
repository.events(latestUserId).collect { event -> handle(event) }
}
// GOOD: the collection lifecycle follows userId.
LaunchedEffect(userId) {
repository.events(userId).collect { event -> handle(event) }
}
rememberUpdatedState 的值在 remember {} 块中会过期
rememberUpdatedState 会返回一个 State 对象,其 .value 会在每次重新组合时更新。“获取最新值”的行为只有在延迟读取 State 时才有效——例如在 effect 函数体内,或在稍后执行的 lambda 中——而不是在提前捕获该值时。
在 remember {} 块中,生产者 lambda 只会运行一次。在其中读取委托属性,会将当前的 .value 快照到记忆对象中;后续 State 更新都不会传递给它:
val latestChannelId by rememberUpdatedState(channelId)
// ❌ BAD — channelId is read once when remember's lambda executes;
// the destination holds the initial value forever
val destination = remember {
Destination(channelId = latestChannelId)
}
// ✅ GOOD — skip rememberUpdatedState; key remember on the changing value
val destination = remember(channelId) {
Destination(channelId = channelId)
}
// ✅ ALSO GOOD — wrapping lambda defers the read to each invocation
val destination = remember {
Destination(channelId = { latestChannelId })
}
凡是 rememberUpdatedState 委托属性被提前读取,而非延迟到 lambda 或 effect 函数体中读取的地方,同样会遇到这个问题:在 remember 中构造的数据类、在 DisposableEffect 的设置块中一次性创建的对象,或任何在创建时求值的表达式。
如果捕获的值发生变化时应重新创建记忆对象,就将其设为 remember 的键,完全不要使用 rememberUpdatedState。仅当值必须在长生命周期作用域(effect 协程、事件回调)中保持最新,且不应导致该作用域重启时,才使用 rememberUpdatedState。
rememberUpdatedState 也不会让渲染状态变成“无需重新组合”。如果 UI 需要显示变化的值,请在组合中读取普通 State;对于帧率级别的值,则使用 compose-state-deferred-reads 中的延迟读取模式。
收集流
对于副作用/事件流,请使用 LaunchedEffect:例如 Snackbar、导航事件、分析事件、焦点命令,或其他每次发射都会触发命令式操作的流。
LaunchedEffect(events) {
events.collect { event ->
snackbarHostState.showSnackbar(event.message)
}
}
不要仅为了修改本地状态而以命令式方式收集渲染状态。对于 UI 状态,请在状态持有者附近收集,并将普通值传入 UI 可组合项——compose-state-hoisting 中介绍了状态持有者与 UI 的拆分、collectAsStateWithLifecycle() / collectAsState() 以及便于预览的接线方式。此处不要重复这套架构。
在 Android 上,若有生命周期感知的收集方式,请优先使用;在不支持生命周期感知 API 的目标平台上,请使用 collectAsState()。
对于 Compose 状态读取,请使用 snapshotFlow:
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> analytics.visibleIndex(index) }
}
没有终端 collect 的 snapshotFlow { ... }.map { ... } 不会执行任何操作。
用户事件
当点击或手势启动挂起操作时,请使用 rememberCoroutineScope():
@Composable
fun SaveButton(snackbarHostState: SnackbarHostState) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
snackbarHostState.showSnackbar("Saved")
}
},
) {
Text("Save")
}
}
避免仅为触发 LaunchedEffect 而使用“事件标记”状态。点击本身就是事件。
注册和清理
对于成对的设置/清理操作,请使用 DisposableEffect:
@Composable
fun ObserveLifecycle(owner: LifecycleOwner, observer: LifecycleObserver) {
DisposableEffect(owner, observer) {
owner.lifecycle.addObserver(observer)
onDispose {
owner.lifecycle.removeObserver(observer)
}
}
}
每条注册路径都应有对应的 onDispose 清理路径。
常见错误
| 错误 | 诊断 | 修复 |
|---|---|---|
| 在可组合项主体中直接发起网络请求 | 在组合过程中执行副作用 | 通常应移至 ViewModel/状态持有者;仅在 UI 所有的带键操作中使用 LaunchedEffect |
| 在可组合项主体中写入分析属性 | 在组合过程中执行副作用 | 如果应在每次成功重组后发布,请使用 SideEffect |
| 在可组合项主体中记录展示/事件 | 在组合过程中执行副作用 | 如果应针对该键仅运行一次,请使用 LaunchedEffect(key) |
LaunchedEffect(Unit) 捕获了变化的 id | 缺少键 | 以 id 为键;如果不应重启,则使用 rememberUpdatedState |
使用 rememberUpdatedState(id),使 LaunchedEffect(Unit) 在 id 变化后继续运行 | 隐藏的生命周期错误 | 将 id 设为该 effect 的键 |
| 长期运行的 effect 在重组后调用了旧回调 | 捕获值过期 | 使用 rememberUpdatedState 包装回调,并在 effect 中调用该包装器 |
在 remember {} 中直接读取 rememberUpdatedState 委托(例如 Destination(id = latestId)) | 值只捕获一次,之后不会刷新 | 将该值设为 remember 的键:remember(id) { Destination(id = id) } |
LaunchedEffect(state) { ... } 重启过于频繁 | 键范围过宽 | 使用特定属性作为键 |
LaunchedEffect(...) { nonSuspendSetter() } | effect 类型错误 | 通常应使用 SideEffect;仅对带键的一次性/延迟操作使用 LaunchedEffect |
在 LaunchedEffect 中添加监听器,但未清理 | 缺少释放逻辑 | 使用 DisposableEffect |
点击时通过设置 shouldShowSnackbar = true 来启动操作 | 事件标记反模式 | 在点击回调中使用 rememberCoroutineScope() |
为执行副作用,在可组合项主体中使用 if (isFocused) { … } 或读取焦点状态 | 在组合过程中执行副作用 | 使用 LaunchedEffect(focused) { … } 或 snapshotFlow |
在已测量的可组合项上使用 onSizeChanged { heightState = it.height } | 如果兄弟项在组合过程中读取 heightState,就会发生布局 → 组合反向写入 | 兄弟项必须在测量阶段使用高度,而不是在组合过程中使用 Modifier.height(state.dp) |
焦点与测量
焦点: 在可组合项主体中读取焦点,以驱动辅助工作(预加载、分析、Toast),会让这些工作在组合期间运行。应改为在副作用中观察焦点:
// ❌ BAD — side work runs during composition every time `focused` is true,
// including transient focus passes; `SideEffect` re-runs after every successful recomposition
@Composable
fun Preloader(interactionSource: MutableInteractionSource) {
val focused by interactionSource.collectIsFocusedAsState()
if (focused) {
preloadImages()
}
}
// ✅ GOOD — side work in a keyed effect
@Composable
fun Preloader(interactionSource: MutableInteractionSource) {
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) {
if (focused) preloadImages()
}
}
如果需要采样多个快照读取,或在不让副作用以每个派生值为键的情况下对快速变化进行防抖,请在 LaunchedEffect 中使用 snapshotFlow { … }。有关 TV/D-pad 焦点导航语义,请参阅 compose-focus-navigation。
测量: onSizeChanged / onGloballyPositioned 是有效的回调,但它们会在布局阶段触发。只有在没有更早阶段读取状态快照的情况下,在这里写入状态才安全。如果同级项在组合阶段读取该状态,布局阶段就会反向写入组合阶段,导致同级项在每次测量时都重新组合。应在 Modifier.layout 中应用捕获的尺寸(参见 compose-modifier-and-layout-style §7 和 compose-state-deferred-reads)。
评审中的危险信号
- 对可组合项主体中的代码声称“这只会运行一次”。
- 在参数会变化的函数中使用
LaunchedEffect(Unit)。 - 副作用中的流链没有终止收集。
- 选择副作用的键只是为了消除 lint 提示,而不是为了建模生命周期。
- 长期运行的副作用使用回调 lambda,却既没有设置键,也没有使用
rememberUpdatedState。 - 在
remember {}代码块或对象构造函数中急切读取rememberUpdatedState委托——该值只会捕获一次,之后不会更新。