为什么你写的 Compose 性能不好?

各位 Compose 刘德华,早上好!

去年我曾写过一篇关于 Compose 性能优化的文章,那篇文章更多是从宏观的重组范围、具体场景(如 LazyColumn、动画)以及强跳过模式(Strong Skipping Mode)等角度切入。

而本文则换了一个视角,更加聚焦于 Compose 性能模型中最核心的稳定性(Stability)系统。我们将深入探讨编译器是如何推断类型的稳定性、破坏稳定性的常见陷阱,并结合实用的检测工具(如 Compose Stability Analyzer),手把手教你如何将这些隐患扼杀在摇篮中。

两篇文章侧重点不同,互为补充,各位刘德华可以任意采摘。

在传统的 Android View 体系中,性能优化往往聚焦于减少布局层级(如避免过度嵌套)、优化 onMeasureonDraw 的执行耗时,以及减少过度绘制(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 传递数据的列表界面:

kotlin 复制代码
@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 编译器在工作时,会分析所有作为组合函数参数的类型,并为它们逐一打上稳定性标签。理解这些分类规则,我们就能明白某些写法为何会引发性能危机,并知道该如何修正。

  • 天生稳定的类型 :基本数据类型(如 IntBooleanFloat 等)、StringUnit、函数类型以及枚举类,它们本身就是稳定的。编译器无需借助任何额外注解就能直接识别它们。

  • 推断稳定的类型 :如果一个数据类的所有属性都是声明为 val 的稳定类型,编译器就会自动将该类标记为稳定:

    kotlin 复制代码
    data class User(val name: String, val age: Int) // 稳定:全是 val 且全是基本类型或 String
  • 默认不稳定的类型 :任何包含 var 属性的类会立刻被判定为不稳定,因为其属性值可以在不通知 Compose 的情况下被修改。Kotlin 标准库中的集合接口(ListSetMap)同样默认不稳定,因为它们背后极有可能隐藏着可变集合。此外,来自未经 Compose 编译器处理的外部模块的类型,默认也会被视作不稳定。

编译器会将分析出的稳定性信息编码,并生成一个名为 $stable 的静态字段附加到相应的类中。这是一个位掩码字段(Bitmask),Compose 运行时正是通过读取它来获知该类的稳定性:

kotlin 复制代码
// 编译器生成的代码示例
@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 插件后,你会在编辑器中每个组合函数旁看到侧边栏图标。绿点表示该组合函数可跳过(参数全部稳定)。黄点意味着稳定性依赖于泛型,需在运行时确定。而红点则在提醒你:该组合函数不可跳过,并且更容易发生不必要的重组。

将鼠标悬停在图标上,插件会弹出详细提示,明确列出每个参数的稳定性状态及原因:

text 复制代码
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),能将每次重组及其详细参数记录下来:

kotlin 复制代码
@TraceRecomposition(tag = "products", threshold = 2)
@Composable
fun ProductList(items: List<Product>, onItemClick: (Product) -> Unit) {
    // ...
}

当该组合函数的重组次数达到阈值(如上例的 threshold = 2)时,就会在 Logcat 中输出类似如下信息:

text 复制代码
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 标准库的集合接口。前面提到,ListSetMap 作为接口,编译器根本无法信任它们。

最直接的解法是引入 kotlinx.collections.immutable 库,换用其中真正的不可变集合,如 ImmutableListImmutableSetImmutableMap

kotlin 复制代码
// 修复前: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 中配置:

text 复制代码
kotlin.collections.List
kotlin.collections.Set
kotlin.collections.Map

这种做法相当于对编译器做出了妥协式的承诺。你告诉它别操心了,这些类型没问题。

那么,代价是什么呢?

一旦你不小心传入了一个可变的 MutableList,UI 根本不会在内容修改时触发重组,你将面临数据更新而界面却无动于衷的 Bug。

坚守 val 属性底线

类中出现任何 var 属性,都会彻底破坏该类的稳定性。编译器的担忧很合理:既然属性允许被修改,那它的值自然可能在两次重组的间隙被悄然改变,而 Compose 对此毫不知情。

kotlin 复制代码
// 修复前:存在 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 再合适不过了:

kotlin 复制代码
@Stable
class CounterState {
    var count by mutableStateOf(0)
        private set
    fun increment() { count++ }
}

切记,这两个注解本质上只是你向编译器做出的单方面承诺,目前并没有运行时校验。

如果你将一个实际上会变动的类错误地标记为 @Immutable,Compose 就会盲目跳过它本该处理的重组,最终把陈旧的数据展示给用户。此时,你的 UI 上就会出现难以排查的 Bug。

搞定第三方类型

对于那些不是由 Compose 编译器生成的外部代码(如纯 Kotlin/Java 库、未做特殊处理的 Protobuf 类或跨平台模型类),由于缺少稳定性元数据,只能默认被当成不稳定类型。

解决思路有两条。第一条是利用支持通配符的稳定性配置文件,将其加入白名单:

text 复制代码
com.example.network.models.**
com.squareup.moshi.JsonAdapter

第二条是将这些"外来户"包装在一个我们自己控制的稳定数据类中,并在边界处进行转换:

kotlin 复制代码
@Immutable
data class StableTimestamp(val millis: Long)

// 在数据边界进行转换
fun Instant.toStable() = StableTimestamp(toEpochMilli())

稳定性配置文件同样支持针对泛型的精细控制。在诸如 com.example.Container<*,_> 的配置中,* 意味着不关心该参数的实际类型,直接放行;而 _ 则表示该参数的稳定性必须被纳入整体的考量。

稳定 Lambda 捕获参数

理论上,Compose 中的函数类型(如 () -> Unit)都是天生稳定的。

然而,一旦 Lambda 闭包中捕获了不稳定的外部变量,编译器就需要更保守地处理这个 Lambda 的复用问题,从而更容易在重组时创建新的 Lambda 实例:

kotlin 复制代码
// 修复前:因为 items 参数不稳定,导致每次都重新创建 Lambda
@Composable
fun Screen(items: List<Item>) {
    val viewModel = viewModel<MyViewModel>()
    ItemList(
        items = items,
        onClick = { item -> viewModel.select(item) }
    )
}

破局之道就是确保所有被捕获的引用都必须是稳定的。

由于 ViewModel 本身稳定,此时我们只要将 items 参数类型也搞定,编译器就更容易对 Lambda 进行记忆化(Memoize)处理:

kotlin 复制代码
// 修复后:捕获的所有引用均稳定,Lambda 可被复用
@Composable
fun Screen(items: ImmutableList<Item>) {
    val viewModel = viewModel<MyViewModel>()
    ItemList(
        items = items,
        onClick = { item -> viewModel.select(item) }
    )
}

如果实在无法提供稳定的捕获项,不妨使用 remember 强行固化引用:

kotlin 复制代码
val onClick = remember { { item: Item -> viewModel.select(item) } }

好消息是,较新的 Compose Compiler 版本已经默认开启强跳过模式(Strong Skipping Mode),刚好为上述代码提供了兜底机制。

它会更积极地记忆化 Lambda,并在处理不稳定捕获项时偏向使用引用比较(===)。只要传入的是同一个对象实例,哪怕缺乏完整的稳定性证明,也有机会跳过重组。

不过,我们不能总是依赖这种权宜之计,掩盖潜在的不稳定性往往会在其他场景引发更棘手的麻烦。

在合适的阶段读取状态

Compose 渲染每一帧分为三个阶段:组合(Composition)、布局(Layout)与绘制(Drawing)。你在哪个阶段读取状态,就决定了状态更新时系统需要付出多大的代价。

在组合阶段读取状态,一旦数据改变就会触发重组,这是开销最大的操作,因为这不但会重新执行函数,还极有可能引起向下级联的重组。

若在布局阶段读取,则只会触发重新布局。

而在绘制阶段读取,就只需轻量级的重绘即可完成更新,同时避开前两个阶段的昂贵开销。

以一个常见的横向偏移(offset)动画为例:

kotlin 复制代码
// 修复前:在组合阶段读取状态,高频触发重组
val offsetDp by animateDpAsState(targetValue)
Box(modifier = Modifier.offset(x = offsetDp))

这里 Modifier.offset(x = ...) 在组合阶段就读取了动画值。这会导致动画运行的每一帧都强迫整个组件执行昂贵的重组操作。

kotlin 复制代码
// 修复后:将读取推迟至绘制阶段,只触发重绘
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 过滤

很多时候,组合函数需要依赖一个由源状态派生出的结果,但这个结果发生改变的频率其实远低于源状态本身。如果不作任何处理,源状态的每一次细微波动都会拉着组件一起重组:

kotlin 复制代码
// 修复前:每一次滚动位移都会触发重组
@Composable
fun Header(scrollState: ScrollState) {
    val showElevation = scrollState.value > 0
    Surface(shadowElevation = if (showElevation) 4.dp else 0.dp) {
        // ...
    }
}

在上例中,页面滚动的每一个像素都会导致 scrollState.value 发生变化,但 UI 其实只关心 showElevation(滚动距离是否大于零)状态在临界点的那一瞬间切换。借助 derivedStateOf,我们可以精准拦截多余的重组:

kotlin 复制代码
// 修复后:只有当 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 页面在业务扩张中依然保持流畅的重要保障。

相关推荐
帅次2 小时前
Android 高级工程师面试速记版
android·java·面试·kotlin·binder·zygote·android runtime
AI玫瑰助手2 小时前
Python基础:字典的键值对结构与增删改查操作
android·开发语言·python
我命由我123452 小时前
Android 开发问题:Raw use of parameterized class ‘Class‘
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
黄林晴2 小时前
根治协程陋习!官方级协程Skill发布
android·kotlin
浪客川3 小时前
UniFFI 跨平台开发Rust 与 Android (Kotlin) 集成
android·rust·kotlin
AirDroid_cn3 小时前
荣耀MagicOS 10系统设备查找:关机后如何通过附近荣耀设备定位?
android·智能手机·荣耀手机
iwS2o90XT3 小时前
Kotlin标准库:实用函数
android·开发语言·kotlin
0pen13 小时前
ZygiskNext 源码解析(一):总体架构与启动链路
android·开源·zygote