各位 Compose 刘德华,早上好!
去年我曾写过一篇关于 Compose 性能优化的文章,那篇文章更多是从宏观的重组范围、具体场景(如 LazyColumn、动画)以及强跳过模式(Strong Skipping Mode)等角度切入。
而本文则换了一个视角,更加聚焦于 Compose 性能模型中最核心的稳定性(Stability)系统。我们将深入探讨编译器是如何推断类型的稳定性、破坏稳定性的常见陷阱,并结合实用的检测工具(如 Compose Stability Analyzer),手把手教你如何将这些隐患扼杀在摇篮中。
两篇文章侧重点不同,互为补充,各位刘德华可以任意采摘。
在传统的 Android View 体系中,性能优化往往聚焦于减少布局层级(如避免过度嵌套)、优化 onMeasure 与 onDraw 的执行耗时,以及减少过度绘制(Overdraw)。
而 Compose 性能模型的核心理念非常直观:跳过不必要的工作。
当 Compose 运行时能够确认一个组合函数(Composable)的输入没有发生变化时,它就会完全跳过该函数的执行过程。
正是这种被称为“跳过(Skipping)”的优化机制,赋予了 Compose 默认的高性能。
然而,一些不起眼的代码模式可能会在庞大的 UI 树中悄然使跳过机制失效;如果没有合适的工具辅助,这些性能退化往往隐藏得很深,直到你看到应用掉帧(Jank)才暴露出来。
本文中,我们将深入探讨支撑跳过机制的稳定性(Stability)系统,了解编译器是如何推断类型的稳定性的。
你将看到破坏稳定性的常见陷阱(如可变集合、var 属性、Lambda 捕获以及在错误的阶段读取状态),并学习带有代码前后对比的实用修复技巧。
此外,我们还将介绍如何利用检测工具发现不稳定性,将性能隐患扼杀在摇篮中。
看不见的重组浪费
在 Compose 中,每当组合函数读取的状态发生变化时,该函数就有可能被重新执行。状态一旦更新,Compose 会遍历 UI 树,并让所有依赖该状态的组合函数重新执行(即重组)。
要让这个过程保持高效,关键就在于跳过机制:如果一个组合函数的参数自上次执行以来毫无变化,Compose 就会直接跳过它,并复用上次的输出结果。
在常规的跳过模型中,跳过机制的生效主要依赖两个条件。
- 参数的类型必须是稳定的(Stable),这意味着编译器可以确信,该值的任何可观察状态的改变都会主动通知 Compose。
- 参数的当前值必须通过
equals()判断为与之前的值相等。当这两个条件同时满足时,该组合函数就会被标记为“可跳过(Skippable)”,编译器便会在每次尝试重新执行它之前,先插入一段比较逻辑。
此时,你会面临一个真正的麻烦——不稳定(Unstable)的参数类型。
如果编译器无法保证某个参数的稳定性,它就只能保守行事:每次都重新执行该组合函数,无论其实际值是否真的发生了变化。哪怕只有一个参数不稳定,也会彻底破坏跳过优化。
更糟糕的是,这种惩罚是连锁的:父组件的重新执行会向其所有子组件传递新的参数实例,从而引发整个子树的级联重组(Cascade Recomposition)。
这是一种非常稳妥的做法。Compose 必须保证 UI 显示的正确性,所以一旦无法确定某个参数的稳定性,就会倾向于重新执行该组合函数。
我们来看一个以 List 传递数据的列表界面:
@Composable
fun ItemList(items: List<Item>) {
LazyColumn {
items(items) { item ->
ItemCard(item)
}
}
}
在 Kotlin 中,List 只是一个接口。因为 MutableList 同样实现了 List,编译器无法确信其底层实现绝对是不可变的。这就导致 items 参数被判定为不稳定。
如果没有开启强跳过模式(Strong Skipping Mode),编译器就无法为 ItemList 生成跳过逻辑。一旦父组件状态改变,整个列表都可能跟着重组——哪怕列表的数据内容没有任何变动。
注:在较新的 Compose Compiler 版本中,Strong Skipping Mode 已经默认开启。不稳定参数也可以参与跳过判断,但比较策略会更偏向引用相等,因此仍然不能把它当作忽略稳定性问题的理由。
如何判定稳定性
Compose 编译器在工作时,会分析所有作为组合函数参数的类型,并为它们逐一打上稳定性标签。理解这些分类规则,我们就能明白某些写法为何会引发性能危机,并知道该如何修正。
- 天生稳定的类型:基本数据类型(如
Int、Boolean、Float等)、String、Unit、函数类型以及枚举类,它们本身就是稳定的。编译器无需借助任何额外注解就能直接识别它们。 - 推断稳定的类型:如果一个数据类的所有属性都是声明为
val的稳定类型,编译器就会自动将该类标记为稳定:data class User(val name: String, val age: Int) // 稳定:全是 val 且全是基本类型或 String - 默认不稳定的类型:任何包含
var属性的类会立刻被判定为不稳定,因为其属性值可以在不通知 Compose 的情况下被修改。Kotlin 标准库中的集合接口(List、Set、Map)同样默认不稳定,因为它们背后极有可能隐藏着可变集合。此外,来自未经 Compose 编译器处理的外部模块的类型,默认也会被视作不稳定。
编译器会将分析出的稳定性信息编码,并生成一个名为 $stable 的静态字段附加到相应的类中。这是一个位掩码字段(Bitmask),Compose 运行时正是通过读取它来获知该类的稳定性:
// 编译器生成的代码示例
@StabilityInferred(parameters = 0)
data class User(val name: String, val age: Int) {
companion object {
val `$stable`: Int = 0 // 示例:这里表示稳定性信息
}
}
对于泛型而言,位掩码会记录究竟是哪个类型参数影响了最终的稳定性。比如 Wrapper<T> 类只有在 T 稳定时才稳定。有了这层依赖记录,编译器就能在调用处明确传入的具体类型后,准确推断出最终的稳定性结果。
稳定性检测工具
在一个大型项目中,纯靠肉眼去梳理所有参数类型的稳定性无异于大海捞针。
这里,我推荐一个工具,帮助开发者更好地检查 Compose 的稳定性。
Compose Stability Analyzer 提供了一整套可视化的分析工具,可以帮助你在编写代码、运行时测试乃至 CI 合并前发现不稳定性问题。
IDE 插件
安装该 Android Studio 插件后,你会在编辑器中每个组合函数旁看到侧边栏图标。绿点表示该组合函数可跳过(参数全部稳定)。黄点意味着稳定性依赖于泛型,需在运行时确定。而红点则在提醒你:该组合函数不可跳过,并且更容易发生不必要的重组。
将鼠标悬停在图标上,插件会弹出详细提示,明确列出每个参数的稳定性状态及原因:
UserCard(user: User)
skippable: false
restartable: true
params:
- user: UNSTABLE (has mutable property: 'address')
有了这种实时反馈,你便能在编写代码的第一时间将不稳定的隐患排除,而不是等应用在生产环境中卡顿后才后知后觉。
插件自带的稳定性资源管理器(Stability Explorer)窗口提供了一个基于模块、包、文件和组合函数层级的项目全景图。
你可以通过过滤功能只查看那些不可跳过的组合函数,并一键跳转到源码位置。对于包含数百个组件的庞大工程,这是排查稳定性问题最有效率的手段。
不仅如此,重组级联可视化工具(Recomposition Cascade Visualizer)还能帮你评估下游影响。右键任意组合函数并选择“Analyze Recomposition Cascade”,即可直观查看:一旦当前组件重组,究竟有多少下游组件可能会被迫跟着重组。
可视化工具会详细报告受影响的组件总数、可跳过与不可跳过组件的比例,甚至最大级联深度。它将级联重组惩罚具象化,让你清晰地看到一个小小的不稳定参数是如何引发一场重组灾难的。
@TraceRecomposition
静态分析告诉你“可能会怎样”,而运行时追踪则揭示“究竟发生了什么”。@TraceRecomposition 注解通过对组合函数进行插桩(Instrumentation),能将每次重组及其详细参数记录下来:
@TraceRecomposition(tag = "products", threshold = 2)
@Composable
fun ProductList(items: List<Product>, onItemClick: (Product) -> Unit) {
// ...
}
当该组合函数的重组次数达到阈值(如上例的 threshold = 2)时,就会在 Logcat 中输出类似如下信息:
D/Recomposition: [Recomposition #5] ProductList (tag: products)
D/Recomposition: ├─ [param] items: List<Product> unstable (List@abc)
D/Recomposition: ├─ [param] onItemClick: Function1 stable
D/Recomposition: └─ Unstable parameters: [items]
日志清楚地交代了是谁在作祟,以及哪些参数发生了改变。threshold 属性巧妙地过滤掉了正常的初次组合,只将那些暗示性能瓶颈的频繁重组暴露出来。如果再搭配上 traceStates = true 参数,你甚至能跟踪组件内部 mutableStateOf 的变化情况。
此外,IDE 插件还包含实时热力图(Live Heatmap)功能。连接真机或模拟器后,它能直接在代码编辑器上以颜色遮罩的形式展现重组频率。
重组次数低于 10 次的组件呈现健康的绿色;10 到 50 次之间显示警告的黄色;而超过 50 次则标红警告(此处多半存在性能问题)。热力图可以把代码分析与实际运行表现衔接起来。
检测报告
当然,如果你实在不喜欢安装插件,可以参考这篇文章手动生成编译器报告,获取你编写的 Compose 函数的稳定性报告。
对,就是我去年写的文章,实际上这个报告非常有用,习惯之后你基本上很少写出不稳定的代码了。
如何让类型变得稳定
一旦找到了不稳定的类型,修复它们的套路通常是非常固定的。
不可变集合
日常开发中最常见的不稳定因素莫过于 Kotlin 标准库的集合接口。前面提到,List、Set 和 Map 作为接口,编译器根本无法信任它们。
最直接的解法是引入 kotlinx.collections.immutable 库,换用其中真正的不可变集合,如 ImmutableList、ImmutableSet 或 ImmutableMap。
// 修复前:List 只是接口,不稳定
data class UiState(
val items: List<Item>,
val tags: Set<String>,
)
// 修复后:ImmutableList/ImmutableSet 保证了绝对不可变,稳定
data class UiState(
val items: ImmutableList<Item>,
val tags: ImmutableSet<String>,
)
对编译器而言,ImmutableList 是其稳定类型白名单里的一员大将。只要集合内的元素类型稳定,该集合便顺理成章地被推断为稳定。
如果你的代码库过于庞大,全面替换集合类型的成本太高,也可以选择退而求其次——通过稳定性配置文件,强行将标准集合声明为稳定。你可以创建一个 stability-config.conf 文件并在 Gradle 中配置:
kotlin.collections.List
kotlin.collections.Set
kotlin.collections.Map
这种做法相当于对编译器做出了妥协式的承诺。你告诉它别操心了,这些类型没问题。
那么,代价是什么呢?
一旦你不小心传入了一个可变的 MutableList,UI 根本不会在内容修改时触发重组,你将面临数据更新而界面却无动于衷的 Bug。
坚守 val 属性底线
类中出现任何 var 属性,都会彻底破坏该类的稳定性。编译器的担忧很合理:既然属性允许被修改,那它的值自然可能在两次重组的间隙被悄然改变,而 Compose 对此毫不知情。
// 修复前:存在 var 属性,不稳定
data class UserState(var name: String, var age: Int)
// 修复后:全部改为 val,稳定
data class UserState(val name: String, val age: Int)
这条原则同样适用于类的继承结构。即使子类循规蹈矩,一旦其父类里混入了一个 var 属性,所有继承者都将背负不稳定的骂名。
善用 @Stable 和 @Immutable
当编译器实在推断不出稳定性时,我们可以通过注解手动进行担保。这两个注解虽然相似,但语义却大相径庭:
@Immutable 是一份极其严苛的契约,它保证对象在创建后,其任何可观察的状态都绝对不会再被修改。
对于被 @Immutable 标记的类,编译器会将其视为稳定,从而更放心地应用跳过优化。
相较之下,@Stable 则显得要温和许多。
它允许对象状态改变,但要求一切变化都必须通过 Compose 的快照系统(如 mutableStateOf)进行,以确保 Compose 能够收到状态更新的通知。对于那些包装了响应式状态的类来说,@Stable 再合适不过了:
@Stable
class CounterState {
var count by mutableStateOf(0)
private set
fun increment() { count++ }
}
切记,这两个注解本质上只是你向编译器做出的单方面承诺,目前并没有运行时校验。
如果你将一个实际上会变动的类错误地标记为 @Immutable,Compose 就会盲目跳过它本该处理的重组,最终把陈旧的数据展示给用户。此时,你的 UI 上就会出现难以排查的 Bug。
搞定第三方类型
对于那些不是由 Compose 编译器生成的外部代码(如纯 Kotlin/Java 库、未做特殊处理的 Protobuf 类或跨平台模型类),由于缺少稳定性元数据,只能默认被当成不稳定类型。
解决思路有两条。第一条是利用支持通配符的稳定性配置文件,将其加入白名单:
com.example.network.models.**
com.squareup.moshi.JsonAdapter
第二条是将这些“外来户”包装在一个我们自己控制的稳定数据类中,并在边界处进行转换:
@Immutable
data class StableTimestamp(val millis: Long)
// 在数据边界进行转换
fun Instant.toStable() = StableTimestamp(toEpochMilli())
稳定性配置文件同样支持针对泛型的精细控制。在诸如 com.example.Container<*,_> 的配置中,* 意味着不关心该参数的实际类型,直接放行;而 _ 则表示该参数的稳定性必须被纳入整体的考量。
稳定 Lambda 捕获参数
理论上,Compose 中的函数类型(如 () -> Unit)都是天生稳定的。
然而,一旦 Lambda 闭包中捕获了不稳定的外部变量,编译器就需要更保守地处理这个 Lambda 的复用问题,从而更容易在重组时创建新的 Lambda 实例:
// 修复前:因为 items 参数不稳定,导致每次都重新创建 Lambda
@Composable
fun Screen(items: List<Item>) {
val viewModel = viewModel<MyViewModel>()
ItemList(
items = items,
onClick = { item -> viewModel.select(item) }
)
}
破局之道就是确保所有被捕获的引用都必须是稳定的。
由于 ViewModel 本身稳定,此时我们只要将 items 参数类型也搞定,编译器就更容易对 Lambda 进行记忆化(Memoize)处理:
// 修复后:捕获的所有引用均稳定,Lambda 可被复用
@Composable
fun Screen(items: ImmutableList<Item>) {
val viewModel = viewModel<MyViewModel>()
ItemList(
items = items,
onClick = { item -> viewModel.select(item) }
)
}
如果实在无法提供稳定的捕获项,不妨使用 remember 强行固化引用:
val onClick = remember { { item: Item -> viewModel.select(item) } }
好消息是,较新的 Compose Compiler 版本已经默认开启强跳过模式(Strong Skipping Mode),刚好为上述代码提供了兜底机制。
它会更积极地记忆化 Lambda,并在处理不稳定捕获项时偏向使用引用比较(===)。只要传入的是同一个对象实例,哪怕缺乏完整的稳定性证明,也有机会跳过重组。
不过,我们不能总是依赖这种权宜之计,掩盖潜在的不稳定性往往会在其他场景引发更棘手的麻烦。
在合适的阶段读取状态
Compose 渲染每一帧分为三个阶段:组合(Composition)、布局(Layout)与绘制(Drawing)。你在哪个阶段读取状态,就决定了状态更新时系统需要付出多大的代价。
在组合阶段读取状态,一旦数据改变就会触发重组,这是开销最大的操作,因为这不但会重新执行函数,还极有可能引起向下级联的重组。
若在布局阶段读取,则只会触发重新布局。
而在绘制阶段读取,就只需轻量级的重绘即可完成更新,同时避开前两个阶段的昂贵开销。
以一个常见的横向偏移(offset)动画为例:
// 修复前:在组合阶段读取状态,高频触发重组
val offsetDp by animateDpAsState(targetValue)
Box(modifier = Modifier.offset(x = offsetDp))
这里 Modifier.offset(x = ...) 在组合阶段就读取了动画值。这会导致动画运行的每一帧都强迫整个组件执行昂贵的重组操作。
// 修复后:将读取推迟至绘制阶段,只触发重绘
val offsetPx by animateFloatAsState(targetValuePx)
Box(modifier = Modifier.graphicsLayer { translationX = offsetPx })
修改后,graphicsLayer 传入的是一个 Lambda。该 Lambda 被设计为在绘制阶段才执行,并读取 offsetPx 的值。视觉效果丝毫未减,但因为动画不再频繁搅动组合与布局阶段,性能表现会更稳。
类似的思路同样适用于 Modifier.offset 的 Lambda 重载。写法 Modifier.offset { IntOffset(offsetPx.roundToInt(), 0) } 将偏移量的计算推迟到了布局阶段;这通常好过直接在组合阶段传值的 Modifier.offset(x = offsetDp, y = 0.dp) 写法。对于任何高频变动的值,能推迟读取就尽量推迟。
此法亦在此处有所记载!
derivedStateOf 过滤
很多时候,组合函数需要依赖一个由源状态派生出的结果,但这个结果发生改变的频率其实远低于源状态本身。如果不作任何处理,源状态的每一次细微波动都会拉着组件一起重组:
// 修复前:每一次滚动位移都会触发重组
@Composable
fun Header(scrollState: ScrollState) {
val showElevation = scrollState.value > 0
Surface(shadowElevation = if (showElevation) 4.dp else 0.dp) {
// ...
}
}
在上例中,页面滚动的每一个像素都会导致 scrollState.value 发生变化,但 UI 其实只关心 showElevation(滚动距离是否大于零)状态在临界点的那一瞬间切换。借助 derivedStateOf,我们可以精准拦截多余的重组:
// 修复后:只有当 showElevation 逻辑翻转时才触发重组
@Composable
fun Header(scrollState: ScrollState) {
val showElevation by remember {
derivedStateOf { scrollState.value > 0 }
}
Surface(shadowElevation = if (showElevation) 4.dp else 0.dp) {
// ...
}
}
derivedStateOf 负责缓存闭包内的派生计算结果,并通过结构相等性比较(Structural Equality)拦截无效变动。
只要新算出的结果没变,它就会保持沉默,下游组件也就安然无恙。无论是这种临界值判断、列表过滤,还是任何输出频率低于输入频率的场景,derivedStateOf 都是不可或缺的利器。
需要警惕的是,千万不要将 derivedStateOf 滥用于那些与源状态变化频率一致且本身计算又极快的场景。
比如 derivedStateOf { count + 1 } 纯属画蛇添足:既然 count 变一次结果就变一次,那派生状态不仅过滤不掉任何重组,反而平白增加了缓存的性能损耗。
总结
在本文中,我们以稳定性为切入点,系统地盘点了 Jetpack Compose 性能优化的核心脉络。Compose 高效的跳过机制离不开编译器对参数稳定性的严苛审查。
从避免盲目使用可变集合与 var 属性,到使用 @Stable 或 @Immutable 向编译器担保,再到驯服野蛮生长的 Lambda 捕获,每一步修复都紧扣“避免冗余重组”这一核心。结合延迟状态读取阶段与 derivedStateOf 拦截,我们有了足够多的武器来打磨界面的流畅度。
但光懂原理是不够的,只有配上靠谱的工具才能事半功倍。
Compose Stability Analyzer 在 IDE 层面提供了实时反馈,结合 @TraceRecomposition 追踪与可视化面板,让那些潜伏在代码深处的性能隐患更容易被发现。
不论是面对新功能的开发,还是收拾遗留的老代码,将这套敏锐的性能嗅觉、标准的修复手段以及强有力的自动化防线组合起来,都将成为支撑你的 Compose 页面在业务扩张中依然保持流畅的重要保障。