有段时间我一直被一个诡异的问题折磨页面明明不大状态就改了一个数字整屏的 UI 却肉眼可见地闪了一下。一开始我怀疑是动画抖动后来用 Layout Inspector 一看好家伙半个页面的 composable 都在重组。那一刻我才意识到Jetpack Compose 的声明式写法虽然爽但重组这套机制如果没摸透性能坑是一个接一个。这篇就专门聊 Compose 的重组优化。我会从重组到底干了什么讲起然后说清楚重组作用域和稳定性这两个核心概念再拆几个最容易踩的过度重组场景最后给出可落地的优化手段和量化工具。适合已经能熟练写 Compose 界面、但性能调优还停留在感觉有点卡阶段的开发者。看完你至少能回答三个问题重组为什么发生、怎么才能让它不发生、以及怎样证明它确实没发生。1. 重组到底在重什么先把更新机制翻个底朝天1.1 从命令式改 UI到声明式描述 UI传统 View 体系的写法是命令式的你要改一个 TextView得先findViewById拿到引用再调用setText把新值塞进去。UI 的每一步变化都需要你手动指挥。这套模式本身没问题但一旦界面层级变深、状态变多命令的排列组合会爆炸漏掉某条更新命令就会导致界面和状态不同步。Compose 换了个思路UI 不再是改出来的而是描述出来的。你写一个Composable函数函数里描述当前状态下 UI 应该长什么样状态一旦变化Compose 会重新执行这个函数生成新的 UI 描述再把差异部分更新到界面上。Composable fun Counter() { var count by remember { mutableStateOf(0) } Column { Text(当前计数$count) Button(onClick { count }) { Text(加一) } } }这段代码里count变化时Text和Button所在的作用域会重新执行。你不用手动告诉 Compose 请更新那个显示数字的控件你只需要改变count的值剩下的交给框架。这种状态驱动 UI的模型就是理解重组的第一块基石。1.2 重组不是重绘跳过才是关键很多人第一次接触重组时会下意识地把它等同于重新绘制或重新运行整个界面。这是最大的误解。Compose 的 UI 更新分三个阶段组合Composition、布局Layout、绘制Drawing。重组只发生在组合阶段本质上是在内存里重新生成 UI 对象的描述并不等于每个像素都重新画一遍。布局和绘制是否发生取决于重组后 UI 结构有没有变化、尺寸有没有变化。一个Text换了字符串内容通常只触发组合和绘制布局阶段如果尺寸不变也会被跳过。更关键的是Compose 不会傻乎乎地把整个Composable函数体从头到尾执行一遍。它有非常精细的跳过机制以调用点为单位如果一个 composable 的输入参数没变编译器生成的代码会直接跳过它的执行沿用上一次生成的结果。Composable fun Parent(name: String) { Text(名字$name) Divider() // 参数固定几乎每次都会被跳过 }Divider没有参数没有读取任何状态所以在name变化时它根本不会重新执行。这种能跳就跳的设计才是 Compose 性能的命根子。你后面做的所有优化本质上都是在帮助这个跳过机制更准确地工作。2. 优化前必知重组作用域与稳定性2.1 重组的最小单位作用域Compose 编译器在编译Composable函数时会把函数体拆成若干个小片段每个片段就是一个重组作用域。作用域划分的依据是代码里读取了哪些状态。Composable fun Profile(user: User, onClick: () - Unit) { Column { Text(你好${user.name}) // 作用域 A读取 user.name Text(等级 ${user.level}) // 作用域 B读取 user.level Button(onClick onClick) { Text(详情) } } }如果user.level变了只有作用域 B 会重组作用域 A 会被跳过。这就是为什么 Compose 官方总说重组是插桩级别的、最小单位的。编译器能在代码里精确标记哪个地方读了哪个状态状态变了就只重跑对应的作用域。理解了作用域你就知道一个反直觉的结论Composable函数的函数体行数并不等于重组成本。一个几百行的函数如果内部状态读取很分散可能每次只重跑几十行一个二十行的函数如果开头就读了变化的状态可能整段都重跑。所以优化重组不是去压缩函数行数而是去控制作用域的大小和参数的稳定性。2.2 参数不相等才重组的判断稳定性作用域划分是怎么跳过的骨架真正决定能不能跳过的是参数比较。Compose 编译器做的是参数相等性比较如果这次传入的参数和上次equals相等就跳过重组。这里有个暗坑编译器无法对所有类型做相等性判断因为它不可能替你去调用每个类的equals。它靠的是稳定性标记。一个类型被判定为稳定编译器才敢在参数没变时放心跳过。稳定类型的定义有两类来源编译器天然信任的基本类型Int、Boolean等、String、函数类型lambda、可变状态MutableState本身。开发者主动标记的标了Stable或Immutable的类型。Immutable data class User(val name: String, val age: Int) data class Product(val id: String, val price: Double) // 未被标记默认不稳定未被标记的普通data class即便你心里清楚它不可变编译器依然会当成不稳定处理因为编译器无法保证类的某个属性会不会在别处被修改。不稳定的参数会导致跳过失败哪怕两次传入的内容一模一样Compose 也得保守地重新执行一次。2.3 为什么稳定性是重组的命门拿上面的Product举例。假如你有一个商品列表页每次刷新都创建一批新的Product对象传给子项子项又依赖了其中的字段Composable fun ProductItem(product: Product) { Text(${product.id} - ¥${product.price}) }由于Product不稳定父项重组时即使product的内容完全没变编译器也会认为这个参数可能变了于是老老实实重跑ProductItem。如果列表有二十个商品就是二十次无意义的重组。有人会问那我给Product加上Immutable不就解决了没错这也是最简单的做法。但要小心Immutable的语义它要求所有属性都是不可变的一旦你用var声明了可变属性或者里面有MutableList标记就名不副实了。这时应该拆开处理——要么把可变部分单独抽出来用MutableState管理要么改成Stable。Stable的语义更宽松它表示当该对象的某个公开属性变化时Compose 能被通知到适合内部有MutableState的场景。从 Compose 编译器 1.5.4 开始Google 引入了强跳过模式strong skipping mode默认开启。这个模式做了两件事一是不稳定参数也参与相等性比较只要equals相等就跳过二是 lambda 参数不用remember包裹也能稳定跳过。这大大降低了稳定性心智负担但依赖equals的正确实现后面第四章我会展开讲。3. 手写代码时最容易触发的过度重组场景3.1 Lambda 捕获变化状态引发连锁反应这是我在实际项目里遇到最多的一类问题而且很隐蔽。看这个例子Composable fun ItemList(items: ListItem, onDelete: (Item) - Unit) { Column { items.forEach { item - ItemRow(item, onClick { onDelete(item) }) // 注意这里 } } }onClick这个 lambda 捕获了循环变量item。items一变化父级重组时编译器会认为这个 lambda 的捕获范围变了于是每个ItemRow的参数都被判定为可能不一样全部重新重组。哪怕只有第 3 个商品更新了另外 19 个也得陪着跑一遍。解决办法是把 lambda 提升到 composable 外部消除捕获Composable fun ItemList(items: ListItem, onDelete: (Item) - Unit) { Column { items.forEach { item - ItemRow(item, onClick remember(item) { { onDelete(item) } }) } } }remember(item)保证只有item变化时才重建 lambda未变化的 item 稳定复用。这招看着简单但能砍掉列表场景里绝大部分无谓重组。3.2 状态提升位置不当状态地震Compose 官方一直提倡状态提升但提升到什么位置有讲究。一个典型的反例是把输入框的文本状态提升到整个页面级。Composable fun SearchScreen() { var keyword by remember { mutableStateOf() } Column { SearchInput(keyword) { keyword it } SearchResultList(keyword) CategoryFilter() // 跟 keyword 没关系但被卷入重组 BannerAd() // 同样无辜 } }keyword每次输入变化SearchScreen都会重组进而导致CategoryFilter和BannerAd这些完全不相关的子组件跟着重组。这就是状态地震一次小输入震动了整棵子树。正确的做法是把状态下沉到真正需要它的最小作用域。SearchInput内部自己持有输入状态只在输入完成或防抖后把结果上报给父级SearchResultList只管接收最终关键词。或者用derivedStateOf把高频变化锁在局部Composable fun SearchInput(onSearch: (String) - Unit) { var text by remember { mutableStateOf() } TextField(value text, onValueChange { text it }) // 点击搜索或回车时才回调 onSearch(text) }这样每次敲键盘只重组SearchInput自己地震范围被锁死在源头。状态提升的正确层级永远是能少提升就少提升。3.3 集合类型和对象引用的误判我见过不少开发者栽在集合类型上。mutableListOf修改后你往列表里add了一个元素但list这个引用本身没变。如果你把list直接传给子组件Compose 的相等性比较会认为参数和之前一样于是跳过重组——但这回它跳错了因为列表内容确实变了界面却没有更新。反过来还有一种误判为了触发更新每次修改集合你都copy()一个新对象传下去。引用确实变了但内容可能完全一样结果就是每个依赖它的子组件都被迫重组。处理集合的正确姿势是对外暴露只读的List比如items.toList()或ImmutableList。结构变化时确实创建新引用同时利用key精确控制哪一项需要重组。集合数据模型尽量用 Kotlin 标准库的listOf、mapOf等不可变类型配合Immutable注解。集合是引用语义和内容语义最容易混淆的地方多花点心思设计数据流向比事后加remember修补有效得多。4. 实测可用的优化手段与代码改造4.1 让 lambda 尽量稳定提升与包装前面提过remember(item) { { onDelete(item) } }的写法这里再补一个更彻底的思路很多项目里列表回调根本不依赖单个 item而是通过 id 传递信息。// 优化前直接调用 onItemClick(item) ItemRow(item, onClick { onItemClick(item) }) // 优化后回调只传 iditem 由上层自行查找 ItemRow(item, onClick { onItemClick(item.id) })让 lambda 捕获的内容从整个对象降级为一个基本类型 id捕获范围更小重组判断更精准。基本类型天然稳定编译器不会为了一个String id的相等性而焦虑。这是我在列表类页面中用的最多的优化几乎没有副作用。4.2 remember / derivedStateOf / rememberUpdatedState 的分工这三个 API 看着像用错的人不少。我用一个列表过滤的场景把它们的适用边界理清楚。remember缓存昂贵计算结果避免每次重组重复计算。val filtered remember(items, keyword) { items.filter { it.name.contains(keyword) } }items或keyword不变时filtered直接复用缓存结果。注意remember的 key 必须列全漏掉依赖项会导致缓存过期拿到脏数据。derivedStateOf把多个状态推导成一个新状态并且只在结果变化时通知重组。val isListEmpty by remember { derivedStateOf { items.isEmpty() } }items即使频繁增删只要结果始终是非空isListEmpty就不会变依赖它的组件不会重组。这个 API 特别适合那种输入变化很频繁、但派生结果偶尔才变的场景比如滚动位置判断是否显示返回顶部按钮。rememberUpdatedState解决lambda 捕获过时状态的难题同时保持 lambda 自身稳定。Composable fun AutoRefresh(onTimeout: () - Unit) { val currentOnTimeout by rememberUpdatedState(onTimeout) LaunchedEffect(Unit) { while (true) { delay(1000) currentOnTimeout() } } }如果不包rememberUpdatedStateLaunchedEffect(Unit)里的闭包会一直持有最初那个onTimeout父级更新回调后你拿到的还是旧函数。包一层之后闭包不用重建但每次调用都能读到最新回调。适用于长生命周期协程 需要最新回调的组合。4.3 key 的正确使用与误用key在 Compose 里有两个截然相反的效果用错了会适得其反。正确用法一强制重置状态。比如LazyColumn里你希望 item 的输入框状态跟随 item id 变化key(item.id) { EditableRow(item) }当item.id变化时Compose 会丢弃旧的EditableRow状态创建全新的。正确用法二LazyColumn的items列表本身自带 key 参数指定后能帮助列表精准定位哪些项需要重组、哪些项可以直接复用LazyColumn { items(items products, key { it.id }) { product - ProductItem(product) } }错误用法给无状态、参数稳定的组件随手加key。比如给Text(name)加key(name)只会让名字变化时强制重组而它本来就会因为参数变化而重组加了等于没加纯属给自己添乱。key是干预手段不是性能优化手段只在需要重置状态或需要稳定列表项身份时使用。4.4 开启强跳过模式Compose 编译器 1.5.4 之后强跳过模式默认开启但很多老项目是从旧版本升级来的需要在构建配置里显式确认// build.gradle.kts composeCompiler { strongSkippingMode true }开启后有两个明显收益。第一不稳定参数也会参与相等性比较只要equals相等就跳过。这等于给上一章说的稳定性问题开了一个兜底机制。第二lambda 不再需要手动remember包裹编译器会识别出 lambda 是否捕获了不稳定的对象没捕获的直接稳定跳过。但注意强跳过模式不是万能药。它依赖类型的equals实现如果某个数据类重写了equals但实现有 bug或者对象内部有可变字段没参与比较就可能出现跳过时界面没更新的假象。所以开启强跳过模式后数据类的equals和hashCode一定要可靠最好保持默认实现或用不可变属性。我见过有人为了优化手写了性能很差的equals结果每次重组前比较本身就成了新瓶颈。5. 如何量化重组次数让优化不再靠感觉5.1 编译期输出重组计数光靠肉眼判断界面卡不卡太主观。Compose 编译器提供了一个非常实用的功能在构建时输出每个 composable 的重组统计报告。在模块的build.gradle.kts里配置composeCompiler { reportsDestination layout.buildDirectory.dir(compose_compiler) metricsDestination layout.buildDirectory.dir(compose_compiler) }构建后build/compose_compiler目录下会生成多个文件其中app-module_recomposition_debug.txt就是重组计数的核心报告。打开后你会看到类似这样的内容Recomposer: 3 recompositions, 2 skipped ProductItem: 20 invocations, 19 skipped, 1 recomposed ProductItem: 20 invocations, 19 skipped, 1 recomposed这行数据的含义是ProductItem被调用了 20 次其中 19 次被跳过真正重组的只有 1 次。如果数字反过来了——20 次调用 20 次重组——说明你的稳定性设计或参数传递出了问题。这个报告是静态分析产物基于编译器对代码的推导适合在开发阶段快速发现哪些 composable 该跳没跳。5.2 用 Layout Inspector 观察重组帧如果嫌编译期报告不够直观Android Studio 的 Layout Inspector 提供了实时可视化方案。打开方式Tools - Layout Inspector选择正在运行的模拟器或设备然后点击工具栏上的Show recompositions开关图标像一个小圆点加波浪线。开启后界面上的 composable 会被绿色高亮高亮的区域就是最近重组过的位置。亮度越高、绿块越大说明重组越频繁。这个工具非常适合定位某个手势/某个状态变化后到底哪些组件被波及。我第一次用它发现卡顿页面里一个完全无关的背景组件也在重组时那种震撼至今忘不了——代码里它明明什么都没依赖却在每一次状态变化时都被执行。Layout Inspector 是运行时真实行为的观测工具和编译期报告互补一个看推导一个看实况。5.3 结合 Systrace 定位卡顿帧重组次数高不一定导致掉帧掉帧也不一定全是重组的问题。当你需要确认重组到底是不是卡顿元凶时用 Android Studio 自带的 CPU Profiler 或 Systrace 抓一段滚动操作的 trace。关注三个指标Frame rendering 时间单帧耗时是否超过 16ms60fps 标准。ComposeRecompose 区块的耗时看重组占整帧的比例。Layout / Draw 阶段耗时验证是不是布局或绘制阶段更慢。我遇到过一种情况重组计数看着不高但某个Canvas组件的绘制极慢每次滚动都卡。如果只盯重组会走很多弯路。所以正确流程是先确认掉帧发生在哪个阶段再针对性地做重组优化而不是无脑给所有组件加remember。6. 从一次列表卡顿实战看完整优化流程6.1 场景还原商品列表的四重罪去年我维护一个电商项目商品列表页在低端机上滚动明显掉帧。页面结构很简单顶部搜索框、中间商品列表、底部推荐位。商品项由四部分构成图片、标题、价格、数量增减按钮。初步症状每次点击数量增减按钮整个列表都有肉眼可见的闪动。用 Layout Inspector 的 Show recompositions 一看绿色高亮覆盖了整个列表区域包括那些数量完全没变的商品项。也就是说一次数量变更全部商品项都做了无意义的重组。6.2 定位与逐步修复第一板斧看编译期报告。recomposition.txt里ProductItem的 skip 率只有 5%几乎每次都在重组。原因很明显ProductItem(product: Product)的Product是普通 data class没有Immutable注解编译器判定为不稳定。第二板斧查点击回调。数量增减的回调 lambda 直接写在了items循环里捕获了变化中的count状态造成所有 item 的回调参数都被判定为差异。修复分三步给Product加Immutable要求价格、标题、数量等属性全部用val声明。回调改成remember(product.id) { { onCountChange(product.id, newCount) } }让 lambda 捕获从整个对象降级为 id 加变化后的数量。数量增减按钮的当前数量从product.count读取但按钮点击后的确认逻辑交给上层处理避免按钮自身持有全局可变状态。改完后同样的操作再看 Layout Inspector绿色高亮只剩被点击的那个商品项其他项全部跳过。编译期报告里ProductItem的 skip 率从 5% 升到 85% 以上。滚动测试也从明显的掉帧变成了稳定 60fps。6.3 优化的边界什么时候应该停止这次优化做到这里按理说已经够了但我当时又陷入了一个误区看到还有 15% 的重组率我想把它压到 0%。于是开始给各种中间层加remember、拆分成更小的可组合函数代码结构越来越碎可读性急剧下降最后同事 code review 时直言看不出这代码在干什么。后来我想明白了一件事重组本身就是 Compose 的正常机制不是洪水猛兽。优化的目标是消除无意义的重组而不是消除一切重组。有 15% 的重组是合理的——其中包含真正需要响应的状态变化。与其追求极致的跳过率不如把代码写清楚、把数据模型设计稳定让 Compose 的跳过机制自然生效。那次实战给我留下的最大教训是重组优化其实 70% 靠的是状态设计工具只是帮你把问题照出来。如果你在项目初期就把数据模型设计成不可变、把状态提升层级控制在最小作用域、把回调设计成只传递 id 而非整个对象后面根本不需要为重组优化加班。最后再分享一个私藏技巧每次写完一个 Compose 页面我都会顺手看一眼编译期的 recomposition 报告养成写完即检查的习惯。这个动作成本极低但能让你在状态设计犯错的第一时间就发现而不是等性能问题爆发后再回头补窟窿。重组优化的本质不是记住一堆 API而是建立一种状态变化会波及哪里的直觉。有了这种直觉你的 Compose 代码会越写越稳。