关于 understanding-stability-inference
面向 Compose 开发者的技能,逐步解释编译器如何推断类型稳定性,帮助分析报告中意外的分类结果。重点涵盖泛型、跨模块类型、接口和递归类型。
主要功能
- 01按 12 个阶段讲解稳定性推断
- 02分析泛型参数与类型替换
- 03解释跨模块的 runtime 稳定性
- 04辨析接口、Java 与递归类型的分类
- 05说明稳定性注解和已知类型注册表
适用场景
- 01解释类型为何被判为 runtime stable 或 unknown
- 02排查泛型类型的稳定性结果
- 03分析依赖模块中的类型分类
- 04理解递归类型为何导致不稳定
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill understanding-stability-inference安装后在 Claude Code 中输入/understanding-stability-inference确认已加载。
附带文件references/bitmask-encoding.mdreferences/twelve-phase-algorithm.md
详细介绍
理解 Stability 推断——读懂编译器的思路
Stability 由 Compose 编译器内置的 12 阶段算法决定。本技能将教会 Claude 如何沿着类型逐步执行算法,从而解释报告为何会给出这样的结论,并在报告生成前预测分类结果。请将本技能与 ../diagnosing-compose-stability/SKILL.md(生成报告)和 ../stabilizing-compose-types/SKILL.md(修复明显不稳定的类型)配合使用。当更简单的技能给出的结论让开发者感到意外时,请使用本技能。
何时使用本技能
- 开发者询问“为什么
Foo被分类为runtime stable,而不是stable?” - 报告对一个“看起来没问题”的类显示
runtime或unknown。 - 涉及泛型:
Box<T>、Wrapper<A, B>、Pair<String, Int>、ImmutableList<User>。 - 类位于其他模块中,并以
.class/.kotlin_metadata制品形式发布。 - 开发者询问
$stable: Int字段、@StabilityInferred或跨模块分类。 - 自引用类型(
class Node(val children: List<Node>))因不明显的原因而不稳定。 - 参数列表中出现 Java 类型、接口或抽象基类,令开发者感到意外。
何时不要使用本技能
- 修复方式很直接(
var→val、List→ImmutableList、移除Flow参数)。请使用../stabilizing-compose-types/SKILL.md。 - 尚无报告。请先运行
../diagnosing-compose-stability/SKILL.md。 - 开发者希望在 CI 中强制执行 stability 检查。请使用
../enforcing-stability-in-ci/SKILL.md。
前提条件
- 已生成 Compose Compiler 报告,或至少在
build/compose_compiler/中有一个<module>-classes.txt和一个<module>-composables.txt。 - 开发者了解 stability 的基本术语:
stable、unstable、skippable、restartable、@Stable、@Immutable。 - 使用搭载 Compose 编译器插件的 Kotlin 2.0+(Strong Skipping 默认开启)。
工作流 — 诊断问答树
按照编译器执行的相同 12 个阶段检查类型。对于每个调用点,按顺序提出问题;第一个匹配的阶段优先。
规范阶段顺序(本技能中处处采用,并与 references/twelve-phase-algorithm.md 一致):
- 阶段 1 — 基本类型 /
String/ 函数 /Unit快速路径 →Stable。 编译器会立即返回,不会执行字段分析。请说明这是快速路径,让开发者知道没有检查其他内容。 - 阶段 2 — 类型参数替换。
裸类型变量
T会变为Stability.Parameter(T);推断会延迟到替换它的调用点进行。 - 阶段 3 — 去除可空类型(
Int?→ 分析Int)。 可空性不会改变稳定性;算法会去掉?并递归处理。 - 阶段 4 — 内联类 — 检查底层类型。
value class Wrapper(val raw: T)的稳定性与T完全相同。 - 阶段 5 — 循环检测(递归树 → 保守判定为 UNSTABLE)。
算法会在循环处停止,以保证终止。逃逸方式是在递归类上使用
@Stable/@Immutable;这会在到达阶段 5 前的阶段 6 生效。 - 阶段 6 — 注解检查(
@Stable、@Immutable、@StableMarker)。 是 →Stability.Certain(稳定)。@Immutable启用的额外优化多于@Stable,因为编译器可以将属性读取提升为静态表达式,并省略相等性检查;而@Stable仅承诺变更通知。 - 阶段 7 — 命中已知稳定结构注册表(
Pair/Triple/Result/ImmutableList/dagger.Lazy/ClosedRange/ 等)。 返回带有注册表位掩码的Stability.Parameter。完整注册表请参见references/bitmask-encoding.md。 - 阶段 8 — 命中外部配置(
stability_config.conf)。 返回带有配置文件中声明的位掩码的Stability.Parameter。 - 阶段 9 — 外部模块(由单独编译生成的
@StabilityInferred注解)。 返回Stability.Runtime。编译器会在 JVM 上生成$stable: Int字段(在 Native/JS 上生成经过名称改编的顶层属性),供运行时通过Composer.changed查询。请告诉开发者,这不是 bug——runtime表示编译器在说:“我无法在编译时证明这一点,因此会在运行时检查。” - 阶段 10 — Java 类型(默认 UNSTABLE — 通过配置文件修复)。
对推断算法而言,Java
final字段看起来像var,因为算法没有可读取的 Kotlin 元数据。请通过stabilityConfigurationFiles修复,而不是编辑 Java 源代码。 - 阶段 11 — 接口(UNKNOWN;运行时使用
===)。 编译器无法从单个调用点枚举所有实现;运行时会在相等性探测时回退到身份比较(===)。 - 阶段 12 — 逐字段分析(慢速路径):
- 遍历线性化的类层次结构,因此继承的字段也会参与分析。没有单独的“继承”阶段——继承在这里处理。
- 任何
var属性 →Unstable(变更未通过 Snapshot 集成进行观察)。 - 任何类型为
Unstable的属性 →Unstable(Combined 以 Unstable 为准)。 - 否则,该类为
Stable(所有字段均稳定时,Combined 会折叠为 Stable)。
所有 12 个阶段的完整伪代码以及逐字段循环,请参见 references/twelve-phase-algorithm.md。
模式
模式:“为什么 Box<String> 显示为 runtime stable?”
// Source — the developer's class, in a library module
class Box<T>(val value: T)
// Call site, in app module
@Composable fun BoxRow(box: Box<String>) { Text(box.value) }
编译器会遍历 Box<T>:
- 阶段 12(逐字段分析)在定义模块内部找到一个字段
value: T;对T的递归分析进入阶段 2(类型参数)→Stability.Parameter。合并后的结果归并为Stability.Parameter,位掩码为0b1(这一个类型参数会影响稳定性)。 - 由于
Box在另一个模块中被使用,编译器会在Box上生成@StabilityInferred(parameters = 0b1),并生成一个$stable: Int字段,在运行时根据T的稳定性初始化。下游调用点会通过阶段 9 获取此信息。 - 调用点替换 →
T = String→ String 是 Certain Stable → 满足第 0 位。 - 最终报告行:
runtime stable class Box<T>;在调用点,BoxRow是skippable。
// WRONG mental model
// "runtime stable means there is a runtime cost on every recomposition" — partly true but misleading
// WRONG because: the cost is one Int field load and a bitwise AND, performed once when the runtime
// computes the call-site stability. It is far cheaper than the unskipped recomposition it prevents.
// RIGHT mental model
// runtime stable = "the compiler proved stability conditional on the type arguments, and emitted
// a $stable: Int field whose bits the runtime ANDs against the substituted argument stabilities".
// The skip decision is still made; it is just made at runtime instead of compile time.
模式:“为什么 @Immutable data class Person(val name: String) 比 @Stable 启用更多优化?”
@Stable 是一项契约:“我会将更改通知 Compose。”@Immutable 是更强的契约:“我永远不会更改。”使用 @Immutable 时,编译器可以将对 Person.name 的读取提升为静态表达式,并省去嵌套用法中的相等性探测;使用 @Stable 时,仍必须生成相等性检查。两者都归类为 Stability.Certain,但下游优化器对 @Immutable 的处理更加激进。
// WRONG
@Stable data class Coordinates(val lat: Double, val lng: Double)
// WRONG because: Coordinates never mutates after construction. @Stable understates the contract
// and forfeits static-expression promotion at every read site.
// RIGHT
@Immutable data class Coordinates(val lat: Double, val lng: Double)
模式:“为什么我的递归树不稳定,尽管看起来没问题?”
data class Node(val id: String, val children: List<Node>)
阶段 5(循环检测)会中止。编译器不会尝试定点分析,因为那样就必须先假定结论,才能证明结论。保守的判定结果是 Unstable。即使每个字段本身都稳定,通过 children 的递归也会向父调用返回 Unstable。
// WRONG — adds @Stable to "force" stability
@Stable data class Node(val id: String, val children: List<Node>)
// WRONG because: List<Node> is a mutable interface backed by ArrayList in practice. The @Stable
// annotation tells the compiler to trust the contract, but the actual List instance can mutate
// between recompositions without notifying Compose, producing silent missed recompositions.
// RIGHT
import kotlinx.collections.immutable.ImmutableList
@Immutable data class Node(val id: String, val children: ImmutableList<Node>)
ImmutableList 位于已知稳定构造注册表(阶段 7)中,位掩码为 0b1,因此允许通过 children 递归:阶段 5 仍会触发循环检测,但注册表命中会短路保守判定。
模式:“为什么 Set<String> 会阻止跳过,而 ImmutableSet<String> 不会?”
kotlin.collections.Set 是一个接口(阶段 11 → Unknown),实际上由会发生变更的 LinkedHashSet 支持。kotlinx.collections.immutable.ImmutableSet 位于已知稳定构造注册表中,位掩码为 0b1,因此它属于 Stability.Parameter,并会在元素类型稳定时解析为稳定。
// WRONG
@Composable fun TagRow(tags: Set<String>) { /* ... */ }
// WRONG because: Set is an interface — phase 11 returns Unknown, the call site is non-skippable.
// RIGHT
@Composable fun TagRow(tags: ImmutableSet<String>) { /* ... */ }
模式:“为什么来自其他模块的类会显示为 runtime stable,即使它只有 val?”
独立编译。在编译调用点时,编译器没有依赖项的完整源代码 AST,只有其 .class 文件和 @StabilityInferred(parameters = ...) 中的元数据。阶段 9 会读取该注解;运行时则通过生成的 $stable: Int 字段,根据实际类型实参解析位掩码。分类结果是正确的——无需执行额外工作——但由于没有源代码就无法进行跨模块编译期分析,因此必须延迟到运行时处理。
五种编译器级稳定性类型
回答“为什么”类问题时,请直接使用这些名称。编译器将稳定性存储为以下类型之一:
- Stability.Certain — 原始类型、String、Unit、函数类型、枚举,以及带有
@Stable/@Immutable注解的类。判定结果在编译期即可确定。 - Stability.Runtime — 单独编译的类。编译期会生成
$stable: Int字段和@StabilityInferred;运行时会将位与实际类型实参进行 AND 运算。 - Stability.Unknown — 接口、未经具体分析的抽象类,或任何编译器不愿作出确定判定的类型。运行时会在相等性探测时回退到
===身份比较。 - Stability.Parameter — 泛型。稳定性通过位掩码取决于类型实参。
- Stability.Combined — 多个组成部分的聚合结果(类的字段,或多个类型实参)。Unstable 优先——任意一个 Unstable 组成部分都会使整体不稳定。
位掩码编码(预览)
Container<T1, T2, T3> 使用 Int 位掩码,其中位 i 置位表示 Ti 会参与稳定性判断:
| 类型 | 位掩码 | 解读 |
|---|---|---|
kotlin.Pair<A, B> | 0b11 | A 和 B 都会影响稳定性 |
kotlin.Triple<A, B, C> | 0b111 | 三者都会影响稳定性 |
kotlinx.collections.immutable.ImmutableList<E> | 0b1 | 只有 E 会影响稳定性 |
java.math.BigInteger | 0b0 | 没有参数;无论类型参数被擦除为何,都会被归类为稳定 |
完整规则——包括如何为单独编译的类型生成 @StabilityInferred(parameters = ...),以及 $stable: Int 字段在 JVM 上的布局与 Kotlin/Native 和 Kotlin/JS 上使用的改名顶层属性之间的区别——请参阅 references/bitmask-encoding.md。
强制规则
- 必须让开发者明白,
runtime stable不是 Bug,也不是不稳定的判定结果——这是编译器表示“稳定性取决于类型参数,并会在运行时通过$stable: Int字段进行检查”的方式。 - 不得在解释当前结构为何不稳定之前,建议进行结构性更改(将
var改为val、替换集合类型)。先诊断,再处理。 - 必须在回答中区分
Stability.Unknown和Stability.Unstable——Unknown表示“无法判断”,会回退到标识相等性;Unstable表示“已证明不稳定”,会彻底禁用跳过。 - 不得建议开发者为无法保证其契约的类型添加
@Stable。稳定性注解是一种契约;违反契约会导致静默漏掉重组,这比无法跳过的可组合项更糟。 - 建议: 在有助于深入理解时,引用 Kotlin 编译器源码。具体文件:
Stability.kt(代数数据类型)、KnownStableConstructs.kt(注册表)、ClassStabilityTransformer.kt($stable字段的生成),以及StabilityConfigParser.kt(配置文件读取器)。 - 建议: 解释泛型时,明确说明位掩码的每一步:“
Pair的位掩码中位 0 已置位,A=String 是 Certain Stable,满足要求;位 1 已置位,B=List 是 Unstable,不满足——Combined 最终归为 Unstable”。
验证
- Claude 能在运行报告前预测候选类型会被归类为
Certain、Runtime、Unknown、Parameter还是Combined。 - Claude 能指出是 12 个阶段中的哪一个阶段得出了该判定。
- 对于
runtime stable类,Claude 能解释编译器在类声明上生成了@StabilityInferred(parameters = ...),并生成了$stable: Int字段;运行时会将该字段与替换后的类型参数稳定性进行按位与运算。 - 对于无法保证其变更契约的类型,Claude 会拒绝建议添加
@Stable或@Immutable。 - 在解释
Set<T>、List<T>、Map<K, V>为何会阻止跳过时,Claude 能正确指出这些是Unknown接口(而不是Unstable)。
参考资料
- Compose 稳定性概览 — https://developer.android.com/develop/ui/compose/performance/stability
- 稳定性 — 诊断 — https://developer.android.com/develop/ui/compose/performance/stability/diagnose
- 强跳过 — https://developer.android.com/develop/ui/compose/performance/stability/strongskipping
- Ben Trengrove,《Jetpack Compose 稳定性详解》— https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
- Ben Trengrove,《稳定性优化的新方法》— https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc
- Chris Banes,《可组合项指标》— https://chrisbanes.me/posts/composable-metrics/
- skydoves,《掌握稳定性,优化应用性能》— https://medium.com/proandroiddev/optimize-app-performance-by-mastering-stability-in-jetpack-compose-69f40a8c785d
- skydoves/compose-stability-inference — https://github.com/skydoves/compose-stability-inference
- skydoves/compose-stable-marker — https://github.com/skydoves/compose-stable-marker
参考文件
references/twelve-phase-algorithm.md— 通过伪代码逐步讲解全部 12 个阶段,以及逐字段分析的伪代码。references/bitmask-encoding.md— 通用稳定性位掩码规则、已知稳定结构注册表、编译器生成的@StabilityInferred(parameters = 0b1)注解、JVM 上的运行时$stable: Int字段,以及 Native 和 JS 上的名称改编顶层属性方案。