关于 using-efficient-effects
面向 Jetpack Compose 开发者的技能指南,帮助按副作用需求选择合适的 API,降低不必要的协程开销并避免资源清理遗漏。还说明如何处理过期回调和列表项独立的 ViewModel。
主要功能
- 01按需求选择 LaunchedEffect、DisposableEffect 或 SideEffect
- 02用 RememberedEffect 处理同步键变化
- 03通过 rememberUpdatedState 保持回调最新
- 04使用 ViewModelStoreScope 隔离列表项 ViewModel
- 05避免一次性操作和逐帧工作误用副作用 API
适用场景
- 01为监听器或订阅设置明确的清理逻辑
- 02修复长生命周期效果使用过期回调的问题
- 03为 LazyColumn 行维护独立 ViewModel
- 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()。
工作流——决策树
- 需要协程吗? →
LaunchedEffect(key1, key2, ...)。代码块会在组合的CoroutineScope中启动,并在任意键变化时取消后重新启动。用于网络调用、动画和挂起任务。 - 需要设置和清理,但不需要协程吗? →
DisposableEffect(key) { onDispose { ... } }。onDispose代码块会在键变化时以及可组合项离开组合时运行。用于监听器、观察者和手动订阅。 - 需要在键变化时同步响应,但不需要协程吗? → 使用
skydoves/compose-effects中的RememberedEffect(key) { ... }。相比仅为调用同步函数而启动LaunchedEffect作用域,开销更低。它没有onDispose——需要拆除时,请配合DisposableEffect使用。 - 长时间运行的
LaunchedEffect要使用可能变化的回调吗? → 使用val latest by rememberUpdatedState(callback)包装回调,并在 Effect 中引用latest。Effect 会保持同一协程,回调引用则始终是最新的。 - 每个可组合项都需要一个
ViewModel(例如LazyColumn中的每一行各一个)吗? → 使用com.github.skydoves:compose-effects-viewmodel中的ViewModelStoreScope(key = <stableId>) { ... }包裹行内容,并在其中调用hiltViewModel()(或viewModel())。key为必填项,且必须是稳定标识符(例如行的 id);每个键都有自己的存储。 - 副作用必须在每次成功组合后运行吗? →
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) { ... }(由键驱动)。
参考资料
- Android Developers — Compose 中的副作用:https://developer.android.com/develop/ui/compose/side-effects
- Android Developers — 可组合项的生命周期:https://developer.android.com/develop/ui/compose/lifecycle
- Skydoves — compose-effects(
RememberedEffect、ViewModelStoreScope):https://github.com/skydoves/compose-effects - Skydoves — Jetpack Compose 的 6 条指南:https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- Ben Trengrove — 调试重组:https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
- 相关技能:
../collecting-flows-safely/SKILL.md,介绍collectAsStateWithLifecycle和将 Flow 作为参数的反模式。 - 相关技能:
../../recomposition/choosing-derivedstateof/SKILL.md,介绍 State→State 派生场景(不是副作用)。 - 相关技能:
../../measurement/tracing-recompositions-at-runtime/SKILL.md,介绍@TraceRecomposition的设置。
评论
支持 Markdown