
强跳过模式(Strong Skipping Mode)是 Jetpack Compose 中最容易被误解的特性之一。
之前我写过两篇文章:
里面均提到过 Strong Skipping Mode,很多开发者认为,启用它之后所有类型都会变稳定,从此就不必再关心稳定性。
很遗憾的是,这种理解是错误的。Strong Skipping Mode 并不是那颗蓝色药丸,它不会改变任何类型的稳定性,不稳定类型仍然是不稳定的。
它真正改变的是运行时在跳过检查中处理不稳定参数的方式:运行时不再总是重新执行 composable,而是使用引用相等性(===)比较不稳定值;如果传入的是完全相同的实例,就会跳过。
这确实是一项有实用价值的优化,但它不能取代对稳定性的理解,也无法在许多常见写法中避免不必要的重组。
本文会介绍如下内容:
- 强跳过模式在编译器和运行时层面究竟改变了什么;
- 稳定参数和不稳定参数的生成代码有什么差别;
- 为什么每次重组都创建新实例时,引用相等性无法带来收益;
- 强跳过下 lambda 记忆化的工作方式有何不同;
- 以及即使已经启用强跳过,稳定性在实践中依然重要的场景。
本文会根据我当前用的输入法状态混用"强跳过模式"与"Strong Skipping Mode",各位知道这两个是一个意思就行
你真的误会我了

强跳过模式的常见误解有:
- "强跳过模式会让所有东西都变稳定。"
- "现在不再需要
@Stable或@Immutable了。" - "稳定性问题已经解决了。"
实际上,这些说法背后都暴露了同一个问题:把"能够跳过"和"真正稳定"混为一谈。
当然,开发者总喜欢找到那颗解决问题的蓝色药丸(外国人称之为银弹,不过我说蓝色药丸,大家也应该知道什么意思吧!),这倒不是什么错。但是,作为开发者,你应该知道这片药到底能治什么吧!
稳定性是类型的一种属性。
它表示编译器可以保证:这个值的可观察状态不会在 Compose 不知情的情况下发生变化。Int、String 和标注了 @Immutable 的 data class 是稳定的。
List<T>(一个可能可变的接口)、带 var 属性的类,以及来自未经过 Compose 编译器处理的外部模块的类型是不稳定的。
强跳过模式不会改变这些分类。即使启用了强跳过,List<Item> 仍然是不稳定的。无论强跳过开启还是关闭,编译器为每个类生成的 $stable 位掩码都完全相同。
真正变化的是跳过决策。也就是说,面对这些不稳定类型时,运行时会改变判断是否可以跳过的方式。
没有强跳过时,只要 composable 有任何不稳定参数,它就永远不能跳过。启用强跳过后,如果这个不稳定参数和上一次组合中的参数是完全相同的实例,composable 就可以跳过。
你听我给你解释
要理解二者的差异,最直接的方式是看 Compose 编译器生成的代码。
假设有一个接收不稳定参数的 composable:
kotlin
@Composable
fun Test(x: Foo) {
A(x)
}
这里的 Foo 是一个不稳定类,例如它有一个 var 属性,或者它来自外部模块。
启用强跳过
如果你使用的是较新的 Compose Compiler,它通常已经默认启用了。
编译器会使用 changedInstance() 生成跳过检查:
Kotlin
@Composable
fun Test(x: Foo, %composer: Composer?, %changed: Int) {
%composer = %composer.startRestartGroup(<>)
val %dirty = %changed
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changedInstance(x)) 0b0100 else 0b0010
}
if (%dirty and 0b0011 != 0b0010) {
A(x, %composer, 0b1110 and %dirty)
} else {
%composer.skipToGroupEnd()
}
%composer.endRestartGroup()
}
关键就在这个 changedInstance(x) 方法,它会用 !==(引用相等性)比较当前的 x 和上一次的值。
如果传入的是同一个实例,composable 就会跳过。
如果传入的是不同实例,即便它们在结构上相等,composable 也会重新执行。
未启用强跳过
没有强跳过时,编译器会对任何带有不稳定必需参数的 composable 设置 mightSkip = false:
Kotlin
if (
!FeatureFlag.StrongSkipping.enabled &&
isUsed && isUnstable && isRequired
) {
mightSkip = false
}
当 mightSkip 为 false 时,编译器根本不会生成跳过检查。无论参数值有没有变化,这个 composable 都会重新执行。
稳定参数都一样
作为对照,接收 Int 这类稳定参数的 composable 始终会使用带结构相等性的 changed():
kotlin
@Composable
fun Test(x: Int, %composer: Composer?, %changed: Int) {
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changed(x)) 0b0100 else 0b0010
}
}
changed(x) 使用 !=(结构相等性)。也就是说,两个值为 42 的 Int 会被视为相等,即使它们是不同对象。这正是值类型应该具备的行为。
我俩确实有点差别

Composer 接口中的两个方法已经把差异说清楚了:
Kotlin
override fun changed(value: Any?): Boolean {
return if (nextSlot() != value) {
updateValue(value)
true
} else {
false
}
}
override fun changedInstance(value: Any?): Boolean {
return if (nextSlot() !== value) {
updateValue(value)
true
} else {
false
}
}
唯一的区别就是 != 和 !==。
结构相等性(!=)会调用 equals()。
引用相等性(!==)则检查两个引用是否指向内存中的同一个对象。
这个区别同时解释了强跳过的能力和局限。
对于稳定的 data class User(val name: String, val age: Int),两个姓名和年龄都相同的实例在结构上相等,因此 changed() 会返回 false,composable 会跳过。
对于强跳过下的不稳定类型,只有传入完全相同的对象时,changedInstance() 才会返回 false;如果传入的是副本,或者是值相同的新实例,它依然不会跳过,因为实例本身已经不一样了。
真不是万能药
理解了引用相等性检查之后,就很容易看出强跳过在哪些情况下没有收益。
每次重组都创建新实例
Kotlin
@Composable
fun Screen() {
val items = listOf(Item("A"), Item("B"), Item("C"))
ItemList(items = items)
}
每次 Screen 重组时,listOf() 都会创建一个新的 List 实例。
即使内容完全相同,items !== previousItems 仍然成立,因为它是一个新的实例。
changedInstance() 会返回 true,ItemList 会重新执行。
这里强跳过无法发挥作用,因为没有任何实例被复用。
解决方法和没有强跳过时一样:要么使用 remember 保留实例,要么改用稳定的不可变集合类型。
data class 副本
Kotlin
@Composable
fun UserCard(user: User) { }
@Composable
fun Screen(viewModel: MyViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
UserCard(user = state.user)
}
如果每次状态 emit 都会重新创建 UiState(这在 MVI 模式中使用 copy() 时很常见),那么即使用户数据没有变化,state.user 也可能会是一个新实例。
changedInstance() 会看到不同引用,从而触发重组。
如果 User 是稳定的,changed() 会使用 equals(),并正确地跳过。
Lambda 捕获
Kotlin
@Composable
fun Screen(items: List<Item>) {
ItemList(
items = items,
onClick = { item -> handleClick(item) }
)
}
lambda { item -> handleClick(item) } 没有捕获任何不稳定值,因此编译器可以记忆化它,并复用同一个实例。但如果 lambda 捕获了一个不稳定值:
Kotlin
@Composable
fun Screen(items: List<Item>, filter: Filter) {
ItemList(
items = items,
onClick = { item -> applyFilter(item, filter) }
)
}
这个 lambda 捕获了不稳定的 filter。编译器会使用 changedInstance(filter) 来决定是否创建新的 lambda 实例:
kotlin
val onClick = %composer.cache(%composer.changedInstance(filter)) {
{ item -> applyFilter(item, filter) }
}
如果每次重组时 filter 都是新实例,lambda 也会每次重新创建。
强跳过会基于捕获值的引用相等性来记忆化 lambda,但只有被捕获的值是同一个实例时,这种方式才有帮助。
混在一起
编译器的 golden test suite 清楚展示了混合捕获的处理方式。当一个 lambda 同时捕获稳定类型(Bar)和不稳定类型(Foo)时,编译器会为它们生成不同的比较调用:
Kotlin
@Composable
fun Test() {
val foo = Foo(0)
val bar = Bar(1)
val lambda = { foo; bar }
}
生成代码会对不稳定捕获使用 changedInstance,对稳定捕获使用 changed:
Kotlin
val lambda = %composer.cache(
%composer.changedInstance(foo) or %composer.changed(bar)
) {
{ foo; bar }
}
这说明强跳过并不会以同一种方式处理所有捕获。
稳定捕获仍然能享受结构相等性比较的收益。只有不稳定捕获会退回到引用相等性。
如果 foo 是一个新实例,但在结构上等同于上一次的实例,lambda 会被重新创建。如果 bar 是一个新实例,但通过 equals() 判断为结构相等,这个捕获就不会让缓存失效。
我的真正作用
强跳过确实能在两类场景中改善性能。
单例和对象引用
当某个参数在多次重组之间始终是同一个对象实例时,引用相等性就能捕捉到这一点:
Kotlin
val config = AppConfig.getInstance()
ConfigDisplay(config = config)
没有强跳过时,这个 composable 永远不会跳过,因为 AppConfig 是不稳定的外部类型。启用强跳过后,它会跳过,因为传入的是同一个实例。
未被重新创建的状态对象
kotlin
@Composable
fun Screen() {
val scrollState = rememberScrollState()
ScrollableContent(state = scrollState)
}
ScrollState 的类定义中没有标注 @Stable,但同一个实例会通过 remember 被复用。强跳过会检测到相同引用并跳过。
稳定性仍不可替代
稳定性会启用结构相等性比较。当你拥有内容等价但实例不同的值时,这一点非常重要:
- 通过
copy\(\)重新创建的 data class :稳定 data class 使用equals()比较,强跳过使用===比较 - 从相同数据重新构建的集合 :包含相同元素的
ImmutableList在结构上相等,而包含相同元素的新List在引用上不同 - 流经
StateFlow的值:每次发射都会创建一个新的值实例,稳定性让运行时能够检测内容是否实际上没有变化
强跳过是一张安全网,用来捕捉同一个实例恰好被复用的情况。
稳定性则是一种保证,它让等价的值无论实例身份如何,都能被识别为相等。
稳定性检查冗余的场景
对于强跳过模式的误解,还有另一个问题同样值得讨论。
强跳过不能替代稳定性,稳定性也并不总是有价值。有些场景中,让类型稳定会引入额外开销,却没有实质收益。
StateFlow 已经去重了
StateFlow 内部会执行结构相等性比较。当你调用 emit(newValue) 或更新 .value 时,StateFlow 会检查 newValue == currentValue,如果二者相等,就会抑制这次发射。
各位看官可能没有意识到,如果把事件也放进
StateFlow,这里会有一个潜在问题:当你发送两次值相同的事件(例如两次相同的Toast)时,第二次Toast可能无法被顺利收集。这个小问题有个简单的解决方案,给你的事件添加一个自增
id。
这意味着到达 collectAsStateWithLifecycle() 的值,在 Compose 看到之前已经通过了一次相等性检查。
如果你的 UI 状态通过 StateFlow 流动,并且相关类型正确实现了 equals(),那么 Compose 的稳定性检查就是对已经确定为不同的值再做一次比较(StateFlow 检查了一次,Compose 又检查了一次)。
在这种场景中,类型稳定只是在 Compose 层增加一次冗余的 equals() 调用。composable 一定会重新执行,因为 StateFlow 只会发射真正的新值。
这也倒不是说稳定性在 StateFlow 场景下没有用。
当父 composable 因为与 StateFlow 发射无关的原因重新执行时(例如兄弟状态发生变化),稳定性仍然有帮助,因为稳定的子参数即使父级运行了,也可以通过 equals() 跳过。
只不过对于 StateFlow 的直接消费者来说,去重已经发生过了。
结构相等性可能很昂贵
稳定性会启用 equals() 比较,而 equals() 不是无代价的。
对于一个 data class,如果它包含有数百个元素的 List<Item>,结构相等性就会遍历列表中的每个元素。
如果 composable 本来就会重新执行(因为数据确实发生了变化),这次 equals() 比较就是浪费的工作。
这种成本在大型、深层嵌套的数据结构中最明显。
一个包含多个列表、map 和嵌套对象的 UiState,它的 equals() 实现可能比直接重新运行 composable 主体还要昂贵。在这些情况下,使用带有定向 key 的 remember 往往比依赖稳定性更高效:
Kotlin
val processedItems = remember(items.size, filterKey) {
items.filter { it.matchesFilter(filterKey) }
}
这样可以通过轻量 key 记忆化派生值,同时避免结构相等性成本和重组成本。
不稳定类型也能记忆化
当 composable 转换或过滤数据时,无论输入类型是否稳定,都可以使用 remember 记忆化转换结果:
Kotlin
@Composable
fun FilteredList(items: List<Item>, query: String) {
val filtered = remember(items, query) {
items.filter { it.name.contains(query) }
}
LazyColumn {
items(filtered) { ItemRow(it) }
}
}
remember 调用会缓存过滤后的列表,并且只在 items 或 query 变化时重新计算(key 使用引用相等性)。
即使 List<Item> 是不稳定的,这也能正常工作。你不需要为了让记忆化生效而让列表变稳定。
remember 的 key 比较使用的引用相等性与强跳过相同,因此二者可以互补。
实践中的平衡

当然,真正应该问的问题不是"我该不该让这个类型稳定?",而是"在这个上下文中,稳定性能为这个参数带来价值吗?"
如果值流经 StateFlow,并且 composable 是它的直接消费者,稳定性会增加一次冗余检查,这就多一次负担。
如果类型的 equals() 很昂贵且变化频繁,稳定性会增加开销。
如果这个值可以用轻量 key 通过 remember 记忆化,那可能比对整个对象做结构相等性比较更高效。
当等价实例在组合树的不同位置被创建、父级重组把未变化的值传给子 composable,以及 equals() 成本相对于 composable 主体成本较低时,稳定性最有价值。
理解这些权衡,才能在有帮助的地方应用稳定性,在没有帮助的地方避免它。
如何理解两种机制
把稳定性和强跳过想成一棵决策树的两层,会更容易理解。
运行时提出的第一个问题是:"这个参数是同一个实例吗?"如果是,就跳过。这就是强跳过为不稳定类型启用的 changedInstance() 检查。
第二个问题只会对稳定类型提出:"这个参数与上一次的值在结构上相等吗?"如果是,就跳过。这就是稳定性启用的 changed() 检查。
没有强跳过时,不稳定参数连这两个问题都不会进入。composable 总是重新执行。启用强跳过后,不稳定参数会获得第一个问题,但不会获得第二个问题。只有稳定参数会获得两个问题,所以稳定性仍然提供更强的保证。
这个两层模型能帮助我们判断每种机制何时重要。强跳过捕捉的是简单情况:同一个对象再次被传入。稳定性捕捉的是更难的情况:内容相同,但对象不同。想要充分优化 UI,二者都需要。
总结
本文介绍了强跳过模式在编译器和运行时层面真正改变的内容,这有助于理解 Compose 在运行的时候,如何判定当前 composable 能否跳过。
编译器会为不稳定参数生成 changedInstance()(引用相等性),而不是完全禁用跳过;稳定参数仍然使用 changed()(结构相等性)。
Lambda 捕获也遵循同样的模式:不稳定捕获使用 changedInstance(),稳定捕获使用 changed(),混合捕获则同时使用两者。
类型本身不会发生变化。强跳过不会改变任何稳定性分类。
理解这个差异,有助于进一步理解 Compose。
如果你的 composable 接收一个在每次 StateFlow 发射中都重新构建的 List<Item>,强跳过无法阻止重组,因为每次发射都会产生一个新的列表实例。让类型稳定(通过 ImmutableList 或 @Stable 注解)可以让运行时按内容比较,并在条目没有变化时跳过。
通过 copy() 创建的 data class、内联构造的对象,以及捕获不稳定值的 lambda,也都适用同样的道理。
当然,理论与实际是需要互相妥协的。
稳定性并不总是必要的:StateFlow 去重、remember 记忆化,以及复杂类型上 equals() 的成本,都是在某些上下文中跳过稳定性注解的合理理由。
这也并不是说稳定性没啥用,它仍然是唯一能让 Compose 检测"结构相等但引用不同"的值的机制,而这在父级重组把未变化数据传给子 composable 时非常重要。
最后,强跳过是一张安全网,用来避免 composable 永远无法跳过的最坏情况;它不能替代你对每种工具何时、何地提供何种能力的理解,也不是解决问题的蓝色药丸。