关于 using-strong-skipping-correctly
面向使用 Kotlin 2.0.20+ 与 Compose 的开发者,说明强跳过模式如何影响可跳过性和 Lambda 记忆化,并提供验证与排查方法。内容还涵盖自动记忆化的适用边界及显式退出机制。
主要功能
- 01验证强跳过模式是否启用
- 02审查可组合函数中的捕获 Lambda
- 03识别自动 remember 不覆盖的作用域
- 04按需使用 @DontMemoize 等退出注解
- 05用 @TraceRecomposition 验证运行时行为
适用场景
- 01迁移至 Kotlin 2.0.20+ 后检查编译器报告
- 02排查父级更新时可组合函数仍重组
- 03定位列表或 Modifier 回调的 Lambda 分配
- 04判断是否需要禁用跳过或 Lambda 记忆化
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill using-strong-skipping-correctly安装后在 Claude Code 中输入/using-strong-skipping-correctly确认已加载。
附带文件references/escape-hatches.md
详细介绍
正确使用强跳过——有意让每个可重启的可组合项都可跳过
强跳过模式是 Compose 编译器的一种行为,随 Kotlin 2.0.20 中发布的 Kotlin Compose 编译器插件一起成为默认设置。它同时改变两件事:每个可重启的可组合项都会变为可跳过,不受参数稳定性影响(不稳定参数使用 === 比较,稳定参数使用 equals 比较);并且,在 @Composable 函数中编写的每个捕获 lambda 字面量都会自动包装在 remember(captures) { ... } 中。这两种行为默认都已启用;在 Kotlin 2.0.20 及更高版本中,两者都不需要选择启用标志。不捕获任何内容的 lambda 已由编译器生成为单例,不会被包装——只有捕获 lambda 需要并会自动应用 remember 处理。
这听起来像是“稳定性不再重要”,对许多 UI 代码来说也确实差不多。但这项变更仍留下三个棘手之处:稳定类型仍需具备正确的 equals 语义,否则 === 回退比较会导致每次重组都失效;非 @Composable 作用域中的 lambda(LazyListScope.items { }、Modifier.pointerInput { }、普通对象表达式)不会自动记忆化;还有少数有意为之的情况需要通过 @DontMemoize 或 @NonSkippableComposable 显式退出。本技能将介绍在强跳过模式下工作时,如何进行验证、审查并决定是否使用这些逃生口。
何时使用此技能
- 开发者询问“我还需要
@Stable吗?”或“这个可组合项现在会跳过吗?”。 - 尽管开发者“以为强跳过已经解决了这个问题”,某个可组合项仍会在父项每次更新时重组。
- 有人提出 lambda 分配问题——“
onClick = { vm.add(it) }会在每次重组时分配吗?”。 - 将项目从 Kotlin 1.9.x / Compose Compiler 1.5.x 迁移到 Kotlin 2.0.20+,并需要解释报告现在为何到处显示
restartable skippable。 - 开发者在代码或编译器输出中遇到
@DontMemoize或@NonSkippableComposable,并询问它们的作用。 - 用户提到“强跳过”、“自动记忆化”、“@DontMemoize”、“@NonSkippableComposable”或“到处都可跳过”。
何时不使用此技能
- 问题是“类型最初如何才算稳定?”——使用
../../stability/understanding-stability-inference/SKILL.md。 - 稳定性悄然退化,团队需要 CI 门禁——使用
../../stability/enforcing-stability-in-ci/SKILL.md。 - 即使启用了强跳过,类型层面的稳定性仍关系到诊断是否清晰,以及基于
equals的跳过能否生效——使用../../stability/diagnosing-compose-stability/SKILL.md修复底层类型。 - 正在发生重组,但开发者无法看到——先使用
../debugging-recompositions/SKILL.md添加检测。
前置条件
- 使用 Kotlin 2.0.20+,并通过
org.jetbrains.kotlin.plugin.compose应用 Compose 编译器 Gradle 插件。强跳过默认启用。在 Kotlin 2.0.20 及更高版本中,如需将其关闭,请使用composeCompiler { featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) }(旧版属性enableStrongSkippingMode = true已标记为@Deprecated("Use the featureFlags option instead"),并且在较旧的 Kotlin 版本中仍可使用)。 - 已为待审查模块启用 Compose 编译器报告(参见
../../stability/diagnosing-compose-stability/SKILL.md)。 - 阅读报告时使用 release 构建——debug 构建会加入 Live Literals,导致跳过行为失真(参见
../../measurement/testing-compose-in-release-mode/SKILL.md)。 - 熟悉
*-composables.txt中每个可组合项的行格式(restartable skippable fun Foo(...))。
工作流
1. 确认强跳过确实已启用
在声称某种行为「是由强跳过导致的」之前,请先确认工具链使用的版本已将其设为默认,或者已设置旧版标志。
./gradlew :app:dependencies --configuration kotlinCompilerPluginClasspathRelease | grep -i compose
请确保 Compose 编译器插件来自 Kotlin 2.0.20 或更高版本。在 build.gradle.kts 中:
plugins {
alias(libs.plugins.kotlin.android) // Kotlin 2.0.20+
alias(libs.plugins.kotlin.compose) // org.jetbrains.kotlin.plugin.compose
}
在 Kotlin 2.0.20+ 中禁用强跳过(现代功能标志 DSL):
import androidx.compose.compiler.plugins.kotlin.ComposeFeatureFlag
composeCompiler {
featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled())
}
旧版属性 composeCompiler { enableStrongSkippingMode = true } 在现代 Compose Compiler Gradle 插件版本中已标记为 @Deprecated("Use the featureFlags option instead")。它仍然有效(并且仍是较旧 Kotlin 1.9.x / 2.0.0–2.0.10 版本中唯一的开关),但新代码在选择禁用时应优先使用 featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled())。
然后重新生成 Compose Compiler 报告,并搜索之前已知的不稳定签名:
./gradlew :app:assembleRelease
grep -A1 "restartable" app/build/compose_compiler/app-release-composables.txt | head -40
启用强跳过后,每个可重启的可组合函数都应显示 restartable skippable,包括接收 List<Foo> 或其他不稳定参数的函数。如果某个可组合函数仍显示为 restartable,但没有 skippable,则它使用了 @NonSkippableComposable(有意如此),或者该模式已关闭(例如有人添加了 featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) 或将旧版 enableStrongSkippingMode = false 设为 false)。
2. 检查 @Composable 作用域内的 Lambda 捕获位置
强跳过会将 @Composable 函数内编写的每个捕获型 Lambda 字面量包装在 remember(captures) { ... } 中。捕获值列表是编译器生成的 remember 键。不捕获任何值的 Lambda(例如 { vm.refresh() },其中 vm 是稳定的文件作用域引用,或 { /* no-op */ })已经由编译器生成为单例,不会自动使用 remember——因为没有可用于键控的值。由此产生三个结果:
- 不捕获任何值的 Lambda 是一个单一共享实例,会在外围函数的所有重组中复用(由编译器生成的单例)。
- 仅捕获稳定值的 Lambda 会通过
remember(captures)记忆化一次,并在捕获值比较相等时于重组中复用。 - 捕获不稳定值的 Lambda(例如未提取的
List<Foo>字面量)会在每次重组时重新捕获,因为不稳定捕获会用===比较,而新建的字面量是一个新引用。
检查可疑的调用位置。思考方式是:「这个 Lambda 捕获了什么?每个捕获值是否通过 equals 判定为稳定,并且在重组之间保持相同的身份?」
// RIGHT — captures stable id + stable callback; auto-remembered by strong skipping
@Composable
fun Row(snack: Snack, onClick: (Long) -> Unit) {
Button(onClick = { onClick(snack.id) }) { Text(snack.name) }
}
// WRONG — captures a freshly allocated list literal; new reference each recompose
@Composable
fun Header(title: String) {
Toolbar(actions = listOf(Action.Share, Action.Save)) { /* ... */ }
}
// WRONG because: `listOf(...)` allocates a new List every recompose. Even with strong
// skipping the unstable param compares with ===, fails, and Toolbar recomposes every time.
// RIGHT — hoist the unstable literal so its identity is stable
@Composable
fun Header(title: String) {
val actions = remember { listOf(Action.Share, Action.Save) }
Toolbar(actions = actions) { /* ... */ }
}
3. 找出强跳过不会记忆化的情况
自动使用 remember 仅适用于**@Composable** 函数上下文中的 Lambda。以下位置不属于 @Composable 作用域;其中的 Lambda 不会自动使用 remember,如果分配开销很重要,就需要手动使用 remember(key):
LazyListScope.items { ... }、LazyGridScope.items { ... }、LazyVerticalGrid.items { ... }等——items代码块是普通的 Kotlin DSL 构建器。Modifier.pointerInput { ... }、Modifier.draggable { ... }、Modifier.scrollable { ... }——非 @Composable Lambda。- 对象表达式和 SAM 转换(例如
object : DefaultLifecycleObserver { })。 Modifier.drawBehind { ... }、Modifier.drawWithCache { ... }——Lambda 参数不是 @Composable。- 非 @Composable 作用域中的协程构建器。
对于这些情况,请自行提取稳定的 Lambda,并以可能变化的值作为键:
// WRONG — onClick allocated per item per recompose; strong skipping does not reach here
LazyColumn {
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, onClick = { vm.select(snack.id) })
}
}
// WRONG because: `items { snack -> ... }` is LazyListScope, not a @Composable function.
// Strong skipping does not auto-remember the onClick lambda — a new instance allocates per row,
// per recompose, and SnackRow's `===` comparison on the lambda param fails every time.
// RIGHT — hoist a stable handler keyed on the stable callback
LazyColumn {
items(snacks, key = { it.id }) { snack ->
val onClick = remember(snack.id) { { vm.select(snack.id) } }
SnackRow(snack, onClick = onClick)
}
}
// RIGHT — even better: pass the id-aware callback once
LazyColumn {
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, onClick = vm::select) // method reference is stable
}
}
4. 决定是否使用逃生机制
强跳过有明确的设计取向。少数情况下确实需要相反的行为。完整参考表见 references/escape-hatches.md;以下是高层决策树:
@DontMemoize用在 lambda 表达式位置(该注解仅适用于EXPRESSION,不适用于TYPE)——此 lambda 不得自动执行remember。当 lambda 有意在每次调用时捕获一个新值时使用(例如,记录器应记录最新计数,而不是首次组合时捕获的计数)。在调用位置应用:Button(onClick = @DontMemoize { ... }),绝不要放在(@DontMemoize () -> Unit)这样的参数类型位置。@NonSkippableComposable标注在函数上——让该函数完全不参与跳过。用于仅产生副作用的可组合项(如记录器、调试叠加层),无论参数是否相等,都必须在每次重组时运行。@NonRestartableComposable标注在函数上——完全移除重启作用域。仅用于非常小的可组合项,因为重启管理的成本高于函数体本身。应用代码几乎不需要它;Compose UI 内部会使用。@ReadOnlyComposable标注在函数上——声明该函数只读取组合值且不生成节点,因此可以在初始化上下文中运行(例如,计算一个值并传入另一个可组合项)。Compose 的MaterialTheme.colorSchemegetter 使用了它。
// RIGHT — logger composable that must always emit
@NonSkippableComposable
@Composable
fun RecompositionLogger(label: String) {
SideEffect { Log.d("Recomp", "tick: $label @ ${System.currentTimeMillis()}") }
}
// RIGHT — annotate the lambda expression that intentionally captures fresh per-call state.
// @DontMemoize is @Target(EXPRESSION) — apply it on the lambda literal, NOT on the type.
@Composable
fun TraceClicks(counter: Int, content: @Composable (() -> Unit) -> Unit) {
content(@DontMemoize { Log.d("Click", "counter at click time = $counter") })
}
5. 使用 @TraceRecomposition 验证行为
阅读报告只能了解函数边界处的情况。要确认特定可组合项在运行时是否被跳过,可使用 compose-stability-analyzer 中的 @TraceRecomposition 进行检测。完整设置请参阅交叉链接 ../../measurement/tracing-recompositions-at-runtime/SKILL.md。两行摘要如下:
@TraceRecomposition(traceStates = true)
@Composable
fun SnackRow(snack: Snack, onClick: (Long) -> Unit) { /* ... */ }
Logcat 会在每次重组时输出一行,列出发生变化的参数,从而直接回答“强跳过是否跳过了这次调用?”
模式
模式:即使启用了强跳过,稳定性仍然重要
强跳过并未消除对 @Immutable / @Stable 的需求。对于不稳定参数,它会将比较方式从 equals 改为 ===。=== 是引用相等。如果热路径需要使用稳定类型,请将其标记为 @Immutable,这样会使用 equals,结构相等的实例就能被跳过。
// WRONG
data class Filter(val tags: List<String>, val sort: SortOrder)
@Composable
fun FilterBar(filter: Filter) { /* ... */ }
// WRONG because: Filter is unstable (List<String> is unstable). Strong skipping makes
// FilterBar skippable, but only when `prevFilter === newFilter`. A copy() with the same
// content fails identity, recomposes the bar, every time.
// RIGHT
@Immutable
data class Filter(val tags: ImmutableList<String>, val sort: SortOrder)
@Composable
fun FilterBar(filter: Filter) { /* ... */ }
// RIGHT because: @Immutable + ImmutableList makes Filter stable. Strong skipping uses
// equals(), and structurally-equal copies skip.
模式:热路径可组合参数中的字面量集合
// WRONG
@Composable
fun Toolbar(title: String) {
ActionRow(actions = listOf(Action.Share, Action.Save))
}
// WRONG because: a fresh List allocates per recompose. Strong skipping makes ActionRow
// skippable, but the List param fails === every time and the body re-runs.
// RIGHT
private val ToolbarActions = persistentListOf(Action.Share, Action.Save)
@Composable
fun Toolbar(title: String) {
ActionRow(actions = ToolbarActions)
}
模式:LazyListScope.items 中的 lambda
参见工作流步骤 3。简而言之:items { } 不是 @Composable,强跳过无法深入其中;应提取稳定回调,或使用方法引用。
模式:对有意保持新鲜的 lambda 使用 @DontMemoize
// WRONG — auto-remembered lambda captures `counter` from the FIRST composition
@Composable
fun LoggingButton(counter: Int) {
Button(onClick = { Log.d("Btn", "counter = $counter") }) { Text("hit") }
}
// WRONG because: under strong skipping the lambda is auto-remembered, but `counter` is a
// stable Int capture, so the remember key is the int value. This is actually fine here.
// Where it bites is when the developer EXPECTS each invocation to capture fresh state but
// the lambda is being passed across a memoization boundary that hides the capture.
// RIGHT — explicit opt-out where freshness is the contract.
// @DontMemoize is @Target(EXPRESSION) — apply on the lambda literal, never on the type.
@Composable
fun TraceContent(latest: Int, body: @Composable (() -> Unit) -> Unit) {
body(@DontMemoize { sendTelemetry("snapshot=$latest") })
}
@DontMemoize 在应用代码中很少需要。只有经过测量后才考虑使用;默认行为对几乎所有 UI 都是正确的。
模式:对仅产生副作用的可组合项使用 @NonSkippableComposable
// RIGHT
@NonSkippableComposable
@Composable
fun FrameTicker(onTick: () -> Unit) {
SideEffect { onTick() }
}
该注解表示:“此可组合项每次重组时都有工作要做;不要让跳过保护提前结束它。”请谨慎使用——大多数“必须每次运行”的可组合项,实际上是状态读取位置不当;应先修正读取位置。
模式:结合 CI 防止强跳过回归
强跳过会让报告中的几乎所有可组合项都变成 skippable,因此仅凭肉眼查看报告更难发现回归。结合 ../../stability/enforcing-stability-in-ci/SKILL.md 使用,让基线差异检测出从可跳过变为不可跳过的可组合项。
强制规则
- 必须先验证 Kotlin / Compose 编译器插件版本,再声称强跳过已启用。运行
./gradlew :app:dependencies并确认 Kotlin 2.0.20+(默认启用)。对于较旧的 Kotlin,确认composeCompiler { }中设置了旧版enableStrongSkippingMode = true标志。如果项目通过featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled())明确禁用了强跳过,则本技能的其余内容不适用。 - 不得在未检查实际
*-composables.txt报告的情况下,声称某个可组合项「现在因强跳过而可跳过」。@NonSkippableComposable等注解以及LazyListScope.items { }中的 lambda 仍是容易踩坑之处,报告会将其显示出来。 - 必须优先使用
@Immutable/@Stable,而不是依赖传入热点路径的不稳定类型的引用相等性。若值的生产方(copy()、map { }、构建器 DSL)不保留对象标识,===就不是结构化equals的好替代方案。 - 不得在没有一行代码注释说明原因的情况下使用
@DontMemoize或@NonSkippableComposable。这些是高级选择退出方式;若未在代码旁注明理由,后续读者会以为这是错误。 - 不得把强跳过当作提取不稳定字面量的替代方案。在可组合函数参数中分配的
listOf(...)无论是否启用强跳过,都会导致===比较失败。应将其提取到remember { ... }或顶层的persistentListOf(...)中。 - **优先:**将本技能与
../../stability/enforcing-stability-in-ci/SKILL.md结合使用,这样可组合项从skippable退化为not skippable(例如有人在没有说明理由的情况下添加了@NonSkippableComposable)时,CI 会失败。 - **优先:**开发者询问「我的 lambda 是否会在每次重组时分配?」时,先确认调用点是否位于
@Composable函数内,再回答是或否。答案会因是否处于@Composable边界内而完全不同。
可跳过性是一种诊断指标,而不是 KPI:启用强跳过后,报告中的每个可组合项都是「可跳过的」,但只有参数实际比较相等的可组合项才会在运行时跳过。现在值得关注的问题不再是「它是否可跳过?」,而是「在开发者关心的这次重组中,它的参数是否比较相等?」。
验证
- 已确认 Kotlin / Compose 编译器版本为 2.0.20+(默认启用),且没有通过
featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled())选择退出;或者,对于较旧的 Kotlin,已设置旧版enableStrongSkippingMode = true标志。 -
*-composables.txt显示,以前带有不稳定参数的可组合项标记为restartable skippable。 - 已检查可疑调用点是否捕获了不稳定字面量(
listOf、mapOf、非@Composable作用域中的临时 lambda)。 - 更改的代码中,任何
@DontMemoize/@NonSkippableComposable/@NonRestartableComposable/@ReadOnlyComposable都有在代码旁说明原因的注释。 - 已通过
@TraceRecomposition在运行时确认:输入相等时可组合项会跳过(参见../../measurement/tracing-recompositions-at-runtime/SKILL.md)。 - 稳定性基线(参见
../../stability/enforcing-stability-in-ci/SKILL.md)涵盖了更改的模块,因此将来的跳过行为退化会导致 CI 失败。
参考资料
- Strong Skipping(Android Developers)— https://developer.android.com/develop/ui/compose/performance/stability/strongskipping
- Ben Trengrove,《Jetpack Compose Strong Skipping Mode explained》— https://medium.com/androiddevelopers/jetpack-compose-strong-skipping-mode-explained-cbdb2aa4b900
- Ben Trengrove,《New ways of optimizing stability in Jetpack Compose》— https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc
- Compose 编译器发行说明 — https://developer.android.com/jetpack/androidx/releases/compose-compiler
- Compose 稳定性概览 — https://developer.android.com/develop/ui/compose/performance/stability
- Chris Banes,《Composable metrics》— https://chrisbanes.me/posts/composable-metrics/
- skydoves,《Optimize App Performance by Mastering Stability》— https://medium.com/proandroiddev/optimize-app-performance-by-mastering-stability-in-jetpack-compose-69f40a8c785d
- skydoves/compose-stability-analyzer — https://github.com/skydoves/compose-stability-analyzer
references/escape-hatches.md—@NonRestartableComposable、@NonSkippableComposable、@DontMemoize、@ReadOnlyComposable的完整参考表,包含代码示例和决策矩阵。
若要诊断可组合项最初为何不稳定,请参阅 ../../stability/diagnosing-compose-stability/SKILL.md。若要为特定可组合项添加检测,查看它在运行时是否确实跳过,请参阅 ../debugging-recompositions/SKILL.md 和 ../../measurement/tracing-recompositions-at-runtime/SKILL.md。若要防止强跳过回归问题进入 CI,请参阅 ../../stability/enforcing-stability-in-ci/SKILL.md。