mcpField
U
using-efficient-effects

using-efficient-effects

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

关于 using-efficient-effects

面向 Jetpack Compose 开发者的技能指南,帮助按副作用需求选择合适的 API,降低不必要的协程开销并避免资源清理遗漏。还说明如何处理过期回调和列表项独立的 ViewModel。

主要功能

  1. 01按需求选择 LaunchedEffect、DisposableEffect 或 SideEffect
  2. 02用 RememberedEffect 处理同步键变化
  3. 03通过 rememberUpdatedState 保持回调最新
  4. 04使用 ViewModelStoreScope 隔离列表项 ViewModel
  5. 05避免一次性操作和逐帧工作误用副作用 API

适用场景

  1. 01为监听器或订阅设置明确的清理逻辑
  2. 02修复长生命周期效果使用过期回调的问题
  3. 03为 LazyColumn 行维护独立 ViewModel
  4. 04选择无需协程的键变化处理方式

安装

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

$npx skills add https://github.com/skydoves/compose-performance-skills --skill using-efficient-effects

安装后在 Claude Code 中输入/using-efficient-effects确认已加载。

详细介绍

使用高效的 Effect——选择成本最低且正确的 Effect API

Compose 默认提供三种 Effect API:LaunchedEffect(协程,键变化时重启)、DisposableEffect(使用清理代码块进行设置和拆除)和 SideEffect(每次成功组合后运行)。skydoves/compose-effects 库还提供了 RememberedEffect——一种非协程版的 LaunchedEffect。选错 API 会浪费协程作用域、泄漏监听器,或悄悄丢弃回调的最新版本。本技能提供决策树。

何时使用此技能

  • 用户询问应选择哪种 Effect API,或为什么 LaunchedEffect 会意外重启。
  • 长时间运行的 LaunchedEffect 调用了过时版本的回调,而父级已更新该回调。
  • 非 Compose 订阅者(监听器、观察者、广播接收器、GL surface)需要在进入组合时设置,并在键变化或退出时拆除。
  • 可组合项只需同步响应键变化,使用协程作用域显得多余。
  • LazyColumn 行需要自己的 ViewModel 实例(例如每个聊天行一个 VM、每个信息流卡片一个 VM),而父级 VM 存储会导致行之间的状态泄漏。
  • 用户正在排查意外的 Effect 重启、泄漏的监听器,或 SideEffect 导致的逐帧分配。

何时不要使用此技能

  • 仅从一个 State<T> 推导另一个 State<T>——使用 derivedStateOf。参见 ../../recomposition/choosing-derivedstateof/SKILL.md。
  • 收集源自组合之外的 flow——使用 collectAsStateWithLifecycle。参见 ../collecting-flows-safely/SKILL.md。
  • 需要在重组后保留且不重复运行的一次性计算——使用 remember { ... },而不是 LaunchedEffect(Unit)。

先决条件

  • 熟悉 Compose 的组合生命周期:进入、重组、离开。Effect 在组合提交后运行。
  • Compose UI 1.4+。
  • 使用 RememberedEffect 时,添加 com.github.skydoves:compose-effects(最新 GA 版本——参见 https://github.com/skydoves/compose-effects)。
  • 使用 ViewModelStoreScope 时,还需依赖 com.github.skydoves:compose-effects-viewmodel。导入路径:com.skydoves.compose.effects.viewmodel.ViewModelStoreScope。
  • 在 Hilt 中使用 ViewModelStoreScope 时,还需添加 androidx.hilt:hilt-navigation-compose,以使用 hiltViewModel()。

工作流——决策树

  1. 需要协程吗? → LaunchedEffect(key1, key2, ...)。代码块会在组合的 CoroutineScope 中启动,并在任意键变化时取消后重新启动。用于网络调用、动画和挂起任务。
  2. 需要设置和清理,但不需要协程吗? → DisposableEffect(key) { onDispose { ... } }。onDispose 代码块会在键变化时以及可组合项离开组合时运行。用于监听器、观察者和手动订阅。
  3. 需要在键变化时同步响应,但不需要协程吗? → 使用 skydoves/compose-effects 中的 RememberedEffect(key) { ... }。相比仅为调用同步函数而启动 LaunchedEffect 作用域,开销更低。它没有 onDispose——需要拆除时,请配合 DisposableEffect 使用。
  4. 长时间运行的 LaunchedEffect 要使用可能变化的回调吗? → 使用 val latest by rememberUpdatedState(callback) 包装回调,并在 Effect 中引用 latest。Effect 会保持同一协程,回调引用则始终是最新的。
  5. 每个可组合项都需要一个 ViewModel(例如 LazyColumn 中的每一行各一个)吗? → 使用 com.github.skydoves:compose-effects-viewmodel 中的 ViewModelStoreScope(key = <stableId>) { ... } 包裹行内容,并在其中调用 hiltViewModel()(或 viewModel())。key 为必填项,且必须是稳定标识符(例如行的 id);每个键都有自己的存储。
  6. 副作用必须在每次成功组合后运行吗? → SideEffect { ... }。这种情况很少见。用于将 Compose 状态发布到无法通过 DisposableEffect 订阅的非 Compose 系统(例如每次提交时更新 View 的字段)。避免用于逐帧任务——它确实会在每次提交时运行。

模式

模式:长生命周期 LaunchedEffect 中的过期回调
// WRONG
@Composable
fun Timer(onTick: () -> Unit) {
    LaunchedEffect(Unit) {
        while (true) {
            delay(1_000)
            onTick()
        }
    }
}
// WRONG because: onTick is captured by the first composition's value; later parents pass an
// updated lambda but the running coroutine still holds the original reference -> the new
// onTick is silently ignored.
// RIGHT
@Composable
fun Timer(onTick: () -> Unit) {
    val latest by rememberUpdatedState(onTick)
    LaunchedEffect(Unit) {
        while (true) {
            delay(1_000)
            latest()
        }
    }
}
// rememberUpdatedState updates the ref on every recomposition; the coroutine reads the freshest
// reference each tick without restarting.
模式:为非协程订阅者使用 DisposableEffect
// WRONG
@Composable
fun LocationBadge(client: LocationClient) {
    LaunchedEffect(client) {
        try {
            val listener = LocationListener { /* ... */ }
            client.register(listener)
            awaitCancellation()
        } finally {
            client.unregisterAll()
        }
    }
}
// WRONG because: spawning a coroutine just to hold a listener wastes a scope and obscures the
// teardown contract; awaitCancellation is the wrong primitive for a non-suspending subscriber.
// RIGHT
@Composable
fun LocationBadge(client: LocationClient) {
    DisposableEffect(client) {
        val listener = LocationListener { /* ... */ }
        client.register(listener)
        onDispose { client.unregister(listener) }
    }
}
模式:同步响应键变化时,优先使用 RememberedEffect 而不是 LaunchedEffect
// WRONG (over-engineered)
LaunchedEffect(themeKey) {
    applyTheme(themeKey)
}
// WRONG because: spawns and cancels a coroutine scope just to call a synchronous function on
// key change. No suspension happens; the scope is pure overhead.
// RIGHT (with com.github.skydoves:compose-effects)
import com.skydoves.compose.effects.RememberedEffect

RememberedEffect(themeKey) {
    applyTheme(themeKey)
}
模式:在 LazyColumn 中为每一行的 ViewModel 使用 ViewModelStoreScope
// WRONG
@Composable
fun Feed(rows: List<Row>) {
    LazyColumn {
        items(rows, key = { it.id }) { row ->
            // hiltViewModel() here resolves to the parent screen's store -> every row shares
            // the same VM instance, leaking state across rows.
            val vm = hiltViewModel<RowVm>()
            RowContent(row, vm)
        }
    }
}
// WRONG because: every row's hiltViewModel call resolves against the parent NavBackStackEntry
// store -> one VM is shared across all rows -> per-row state collapses into one slot.
// RIGHT
import com.skydoves.compose.effects.viewmodel.ViewModelStoreScope

@Composable
fun Feed(rows: List<Row>) {
    LazyColumn {
        items(rows, key = { it.id }) { row ->
            ViewModelStoreScope(key = row.id) {
                val vm = hiltViewModel<RowVm>()
                RowContent(row, vm)
            }
        }
    }
}
// ViewModelStoreScope(key = ...) installs a composable-scoped ViewModelStore tied to the
// supplied key; each row gets its own VM and onCleared fires when the row leaves composition.
// The key MUST be stable across recompositions — the row's id is the canonical choice.
模式:一次性工作使用 remember { } 还是 LaunchedEffect(Unit)
// WRONG
@Composable
fun Greeter(name: String) {
    LaunchedEffect(Unit) {
        Log.d("Greeter", "First seen: $name")
    }
}
// WRONG because: LaunchedEffect(Unit) re-runs after configuration change recreation when the
// composable is re-attached to a new composition. For "do this once and never again across
// the lifetime of this composable instance", use remember.
// RIGHT
@Composable
fun Greeter(name: String) {
    remember { Log.d("Greeter", "First seen: $name") }
}
// remember { } evaluates its block once per slot and never again unless the slot is removed
// and re-created. No coroutine, no effect machinery.

强制规则

  • 必须在长生命周期 LaunchedEffect(其键集合不会随回调标识变化而变化)中使用外部传入的回调时,用 rememberUpdatedState 包装回调。否则,该 effect 会一直捕获过期的 lambda。
  • 必须对非协程订阅者优先使用 DisposableEffect,而不是 LaunchedEffect { try { } finally { } }。它明确了清理约定,并且在键变化和退出组合时都会运行。
  • 不得使用 LaunchedEffect(Unit) 执行必须在重组后继续存在且不重新运行的一次性操作。应改用 remember { ... }。
  • 不得将 SideEffect 用于逐帧工作或任何会分配资源的操作。它会在每次成功提交时运行。
  • 必须将 effect 所捕获且应触发其重启的每个值都设为键。对键集合声明不实,是“我的 effect 没有看到新值”这类错误最常见的来源。
  • 必须在 LazyColumn / LazyRow 中调用 hiltViewModel(),且列表项需要独立的 VM 实例时,使用 ViewModelStoreScope(key = <stableId>) { hiltViewModel<T>() }(来自 com.github.skydoves:compose-effects-viewmodel)——否则所有行都会共享父屏幕的存储空间。key 参数是必需的,且必须是稳定 ID(例如该行的领域 ID)。
  • **优先:**当代码块不会挂起时,优先使用 skydoves/compose-effects 中的 RememberedEffect(key) { ... },而不是 LaunchedEffect(key) { ... }。这样在每次键变化时都不必分配协程作用域,开销更低。
  • **优先:**消费 flow 时,不要编写 LaunchedEffect(viewModel) { viewModel.flow.collect { ... } },而应使用 collectAsStateWithLifecycle()。参见 ../collecting-flows-safely/SKILL.md。

验证

  • 在宿主可组合项上使用 @TraceRecomposition,确认每次键变化时 effect 运行次数符合预期(通常为:每次键变化时先拆除一次,再设置一次;无关重组时运行零次)。参见 ../../measurement/tracing-recompositions-at-runtime/SKILL.md。
  • 在每个 DisposableEffect.onDispose { ... } 中添加一行 logcat 日志,并确认它在键变化时以及宿主屏幕转入后台时都会触发。
  • 驱动父项快速更新回调(例如,使用状态中的计数器);确认长生命周期 LaunchedEffect 通过 rememberUpdatedState 获取最新值。
  • 对于 ViewModelStoreScope 中的行,记录行 VM 的 init { } 和 onCleared();它们应按行触发,并以该行的标识作为作用域,而不是为整个列表触发一次。
  • 代码库中没有任何 SideEffect { } 代码块执行分配开销大的工作;它应仅执行向非 Compose 订阅者写入指针这类轻量操作。
  • 不得残留 LaunchedEffect(Unit) { /* non-suspending work */ }——应替换为 remember { ... }(一次性操作)或 RememberedEffect(key) { ... }(由键驱动)。

参考资料

评论

支持 Markdown