mcpField
C
compose-side-effects

compose-side-effects

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

关于 compose-side-effects

面向 Android Compose 开发者的参考技能,帮助选择副作用 API 并管理其生命周期。内容涵盖键、清理、最新回调、Flow 收集及组合阶段的常见问题。

主要功能

  1. 01Compose 副作用 API 选择指南
  2. 02副作用键与重启行为
  3. 03避免捕获过期回调
  4. 04Flow 与 snapshotFlow 收集
  5. 05注册与清理模式

适用场景

  1. 01审查可组合函数中的副作用代码
  2. 02为带键任务选择副作用 API
  3. 03修复长期运行任务中的过期回调
  4. 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 快照读取转换为 FlowLaunchedEffect 内的 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 / onStop lambda。
  • 长时间运行的收集器应保持其收集生命周期,但应调用最新的事件处理程序。

不要用 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 委托——该值只会捕获一次,之后不会更新。

评论

支持 Markdown