mcpField
U
using-strong-skipping-correctly

using-strong-skipping-correctly

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

关于 using-strong-skipping-correctly

面向使用 Kotlin 2.0.20+ 与 Compose 的开发者,说明强跳过模式如何影响可跳过性和 Lambda 记忆化,并提供验证与排查方法。内容还涵盖自动记忆化的适用边界及显式退出机制。

主要功能

  1. 01验证强跳过模式是否启用
  2. 02审查可组合函数中的捕获 Lambda
  3. 03识别自动 remember 不覆盖的作用域
  4. 04按需使用 @DontMemoize 等退出注解
  5. 05用 @TraceRecomposition 验证运行时行为

适用场景

  1. 01迁移至 Kotlin 2.0.20+ 后检查编译器报告
  2. 02排查父级更新时可组合函数仍重组
  3. 03定位列表或 Modifier 回调的 Lambda 分配
  4. 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.colorScheme getter 使用了它。
// 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 失败。

参考资料

若要诊断可组合项最初为何不稳定,请参阅 ../../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。

评论

支持 Markdown