1. 先弄清楚 Compose 到底改变了什么
如果你正从传统 View 体系转向 Jetpack Compose,或者已经写了一段时间 Compose 但总觉得它像个黑盒——UI 为什么自动更新?为什么某些组件明明没变还是被频繁重组?为什么列表滚动起来帧率就是上不去?——这篇 Compose 原理剖析应该能补上那块最关键的拼图。文章不绕弯子,我会从底层机制讲起,讲重组、状态、布局这三大核心,最后全部落到可以照着做的实战优化方案上。
这个方向的内容适合三类人:刚从 View 迁移过来的开发者、写过 Compose Demo 但说不清运行机制的进阶者、以及线上页面出现性能问题想系统性排查的人。Compose 不是“写个界面就行”的框架,它背后是一套完整的编译期计划和运行时时序机制,理解了这些,你手头那些莫名其妙的卡顿和重组问题,基本都能自己定位。
先说一个基础认知:Compose 是声明式 UI,但它不是“运行时魔法”。它由两层组成——编译期的编译器负责改写代码、注入重组逻辑;运行时的 Composer 负责记录组合位置、管理状态、调度重组。很多性能优化技巧,本质上都是在跟这两层的规则打交道。
1.1 声明式 UI 为何能少写一半“同步代码”
传统 View 体系是命令式的。开发者要自己管理控件实例:findViewById 拿到引用,写监听器,在回调里手动改文本、改可见性、改列表数据。界面上的每一个变化,都需要开发者显式告诉控件“你该变成什么样”了。这种方式在小页面里很直接,但页面复杂以后问题就来了:业务状态分散在多个控件里,改一处漏一处,逻辑越堆越难维护。
Compose 把思路反转过来:UI 是“状态到界面的映射函数”。你只需要描述“数据长什么样,界面就长什么样”,框架负责在状态变化时重新执行这段描述、对比差异、更新最终渲染结果。写计数器就是声明一个状态,然后声明按钮和文本:
@Composable fun Counter() { var count by remember { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text("点击了 $count 次") } }count一变,Text就会跟着变,开发者不需要手动去 setText。这个模型的好处是少了大量“同步 UI”的胶水代码,状态和界面天然一致。但代价也很明显:框架必须极其聪明地处理“状态变化后哪些代码要重新执行”,否则一个点击触发整棵界面树重跑,性能直接崩盘。Compose 给出的答案就是“重组”。
1.2 编译期计划与运行时插槽表的分工
“状态变了自动更新 UI”听起来像黑魔法,但拆开看其实很朴素。Compose 编译器在编译阶段会把每个@Composable函数改写成携带Composer参数的普通函数,并在函数体内插入大量分组和检查逻辑。运行时再靠Composer这张“施工图纸”决定哪些 group 该执行、哪些 group 该跳过。
用伪代码粗看一个可组合函数的结构是这样:
fun Text(text: String, composer: Composer, ...) { if (composer.startRestartGroup()) { // 这里真正执行文案内容创建逻辑 } composer.endRestartGroup()?.updateScope { ... } }实际生成的代码复杂得多,但调用骨架是固定的:每次组合调用都会先检查这个 group 是否需要更新,不需要就整体跳过。这个“生成分组 + 条件跳过”的机制,是后面所有优化技巧的底层支撑。理解了它,你就知道为什么“组件拆得越小越容易跳过”,为什么要关注“参数稳定性”,为什么有些状态读法会让性能雪崩。
2. 重组机制:框架怎么知道“该重跑谁”
2.1 重组作用域按函数边界切分
Compose 重组的基本单位是“可组合函数的调用作用域”。编译器为每个@Composable函数调用生成一个重组作用域,当函数内部读取的某个状态变化时,这个作用域会被标记为无效,下一次帧更新时框架会重新执行该函数,而不是从根节点全部重跑。
这个机制直接推导出一条实用结论:函数写得越粗,状态变化的影响面就越大。看一个常见反面例子:
@Composable fun Screen(viewModel: MyViewModel) { val progress by viewModel.progressState Column { Header() Body(viewModel) Footer() } }这里progress被Screen函数体读取了,它一变,Screen这个完整作用域就要重新执行。即使Header和Footer参数完全没变,它们在组合调用点的代码也会被重新遍历一遍,只是可能被更深层的检查跳过。这个开销在简单页面里无所谓,但在高频更新的复杂页面里会被放大。
更合适的做法是让状态读取下沉到真正使用它的叶子组件:
@Composable fun Screen(viewModel: MyViewModel) { Column { Header() Body(viewModel) Footer() } } @Composable fun Body(viewModel: MyViewModel) { val progress by viewModel.progressState ProgressBar(progress) }这样progress变化只会让Body作用域重新执行,Header 和 Footer 的组合调用点根本不会被波及。这也是社区一直强调“把页面拆成小组件”的真正原因——不是代码美观问题,是重组范围问题。
2.2 参数比较与跳过重组的判定
编译器怎么知道一个可组合函数该不该跳过?核心依据是参数比较。Compose 编译器会把“本次参数和上次参数是否有变化”的信息折叠成一个Int掩码值,交给运行时判断。如果函数的所有参数在编译器眼里都“没变”,整个函数体可以直接跳过去。
这里有个关键概念叫“稳定性”。编译器只能对“稳定类型”的参数做可靠比较:
- 全部字段用
val声明的数据类,编译器有较大把握认为它稳定,参数没变就能跳过; - 带
var字段的类、普通接口类型(比如List<T>)、从外部接口拿来的类型,编译器不信任它们,即使内容真的没变,也不会放心跳过。
所以数据模型会直接影响重组开销。你把data class里某个字段从val改成var,可能一个列表页的整体性能就变了。这个知识点后面实战优化部分还会展开,这里先建立观念:重组的跳过率,很大程度上是编译期静态分析决定的。
2.3 位置记忆:插槽表与 key 的底层逻辑
Compose 运行时在组合过程中维护着一张插槽表,每个可组合调用在表里有一组插槽,用来记录它创建的对象、remember 的状态、子组合的位置。这些插槽按“调用顺序”排列,所以源码里 if、for 等控制流会改变插槽位置,状态就可能对不上号。
这就是为什么循环和条件分支里要尽量用key():
items.forEachIndexed { index, item -> key(item.id) { OrderCard(item) } }key把位置记忆从“下标”提升为“业务标识”。列表做插入、删除、排序时,状态才能跟随正确的 item 迁移,而不是因为下标变化全部错位。如果你在 LazyColumn 里发现 item 状态错乱、动画复用异常,十有八九是 key 没有给对。
3. 状态与快照:性能优化的总开关
3.1 MutableState 读写时的“通知链”
mutableStateOf返回的对象不只是装了一个值,它内部维护着读取方和写入方的通知关系。组合函数在读取某个 State 时,会向框架注册“我依赖这个状态”;写入方给 State 赋新值时,框架会找出所有依赖它的组合作用域并标记为无效,等待下一帧重组。
理解“读的位置决定失效范围”很重要。在组合作用域里读取,它就会成为该作用域的重组触发源;在 lambda 回调里读取,状态变化不会直接触发当前组合的重组。很多性能优化策略本质上都在调整“状态在哪里被读”:
- 把状态读取下沉到叶子组件,影响面最小;
- 把状态读取放进事件回调里,和组合逻辑解耦;
- 用
derivedStateOf聚合中间状态,降低触发频率。
3.2 快照系统与事务式提交
Compose 的状态读取不是无脑读最新值的,它建立在快照(Snapshot)系统之上。全局有一个版本号,写操作先记录在“待提交差异”里,提交时一次性生效。这个机制保证了组合阶段读取到的状态是版本一致的,不会出现一个界面里一部分是新值、一部分是旧值的撕裂感。
快照系统在日常开发中影响最大的是异步场景。在普通协程里直接读state.value,你拿到的可能是旧快照里的值,行为和预想不一致。更稳的写法是改用snapshotFlow在协程作用域里观察状态变化,或者把状态读取保持在组合作用域内。这个坑在对接数据库、网络回调、第三方 SDK 时很容易踩到。
3.3 derivedStateOf 与 snapshotFlow 的正确用法
derivedStateOf是高频优化的利器。它解决的是“状态来源很多,但对外只关心推导结果”的场景。比如列表滚动位置映射成是否显示悬浮按钮:
val shouldShowFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }滚动过程中firstVisibleItemIndex几乎每帧都在变,但shouldShowFab只会在 0 和非 0 之间切换。使用派生状态后,下游重组触发频率从“每帧”降为“每次阈值跨越”,这个收益在长列表里非常可观。
snapshotFlow则是把多个状态的变化转成 Flow 流,方便在协程里做复杂观察:
snapshotFlow { listState.firstVisibleItemIndex to listState.layoutInfo.totalItemsCount }.distinctUntilChanged().collect { ... }它能让你在非组合作用域里安全地观察 Compose 状态,适合跟 ViewModel 或其他架构组件对接。
4. 布局与绘制:理解三阶段管线
4.1 组合、布局、绘制各自管什么
Compose 每一帧的工作分为三个阶段:组合阶段执行可组合函数、生成并更新 UI 树描述;布局阶段测量每个节点的尺寸和位置;绘制阶段由渲染线程把内容画到屏幕上。三个阶段层层依赖:组合结果影响布局,布局结果影响绘制。
大多数优化文章都在讲组合阶段的重组问题,但实际卡顿也常出在布局和绘制。反过来想:只要组合产生的节点结构没变,布局阶段就有跳过的可能;只要布局的位置尺寸没变,绘制阶段也有局部更新空间。所以尽量让状态变化只影响“叶子级参数”,就能减少对上层布局的冲击,这是从三阶段模型推导出的通用准则。
4.2 测量约束与自定义 Layout 的实现
Compose 布局阶段的核心输入是Constraints,它包含最小和最大宽高约束。自定义 Layout 的测量代码必须在这个约束空间里工作,大致结构如下:
Layout(content = content, modifier = modifier) { measurables, constraints -> // 1. 先测量子项,传入子项应遵守的约束 val placeables = measurables.map { it.measure(constraints) } // 2. 根据子项尺寸计算整体宽高 val width = placeables.maxOfOrNull { it.width } ?: 0 val height = placeables.sumOf { it.height } // 3. 计算完成后,调用 layout 确定整体位置区域 layout(width, height) { var y = 0 placeables.forEach { it.place(0, y) y += it.height } } }实际写自定义布局时,最容易出错的地方在于:把父级传给自己的constraints原封不动传给了子项,没有减去自身占用的空间。正确做法是根据需求把约束拆成“外层约束”和“子项约束”,子项测量完后再反推自身尺寸。另一个常见需求是“让子项撑满容器”,那要复制约束并提高最小值:
val childConstraints = constraints.copy( minWidth = parentMinWidth, minHeight = parentMinHeight )这些细节直接决定了自定义布局是否符合预期。原理层面掌握约束,比背 API 更管用。
4.3 跳过布局与局部更新的边界
和传统 View 的measure/layout两遍穿透不同,Compose 的测量模型是“父级给约束、子级返回尺寸、父级决定位置”,每轮测量理论上只执行一遍。但“跳过”是有边界的:如果某个节点的尺寸相对父级约束发生变化,父级依然需要重新布局;如果只是绘制属性变了,布局可以跳过。
这里有个经验:高频动画如果作用于size、offset等尺寸位置属性,会触发布局阶段重新计算;而作用于graphicsLayer的透明度、旋转、缩放则只影响绘制阶段,开销小得多。所以想在动画场景里压榨性能,优先把效果放到Modifier.graphicsLayer或绘制级参数上,而不是用布局级属性。
5. 实战优化:把理论变成可操作的清单
5.1 状态读取下沉:重组范围的第一道防线
优化重组的第一板斧永远是“状态读取下沉”。具体操作:每次写完一个 Composable,都去检查它读取了哪些 State,这些读取是否发生在真正需要变化的最小作用域里。如果一个页面级函数读了 List 数据、再把它传给一堆子函数,那么 List 一变,整个页面作用域都可能重跑。
实际项目里我见过太多类似写法:
@Composable fun ProfileScreen(viewModel: ProfileViewModel) { val profile by viewModel.profileState // 全页面都在这个作用域里 Header() InfoCard(profile) Timeline(profile.records) // 也许只有这里需要这数据 Footer() }优化方向不是把所有状态都传下去,而是拆掉这层“中转站”。让Timeline自己去读取它关心的数据,Header、Footer保持完全无状态,变化影响面自然收敛。
5.2 稳定性设计:编译器眼里的“没变”
前面反复提“稳定类型”,这里给出一套可直接照做的判断标准。编译器认为稳定的大致是:
- 所有属性都是
val且属性本身稳定的类; - 用
@Immutable显式标记、且确实不可变的类; - 用
@Stable标记、内部可变性被合理管理的类。
常见不稳定类型包括:带var字段的模型类、接口类型如List<T>、包含可变集合包装的容器。针对高频列表项,最直接的做法是把数据类做成全val并打上注解:
@Immutable data class User( val id: String, val name: String, val avatarUrl: String )注意:绝不要对可变对象标@Immutable。编译器会信任你的注解,但运行时实际行为会错乱,该跳过时没跳过,或者更糟——不该跳过的被错误跳过了。稳定性注解是“承诺”,不是“伪装”,必须和实际可变性严格一致。
| 类型特征 | 编译器判断 | 是否可标注解 |
|---|---|---|
| 全部 val 字段的 data class | 可推断为稳定 | 建议标 @Immutable |
| 含 var 字段的普通类 | 不稳定 | 绝对不能标 |
| 传入的接口类型(如 List) | 不稳定 | 用不可变集合包装并标 @Immutable |
| 包含自定义状态对象的类 | 不确定 | 谨慎标 @Stable |
5.3 remember、derivedStateOf、key 的常见误区
remember的典型误用是把“会随外部变化的变量”捕获进计算块。remember { }只在首次组合时执行一次,块内捕获的变量如果后续变了,remember 不会自动重算。需要跟着某个键更新时,应该用remember(key) { ... }。
derivedStateOf的误用是把它当成普通状态包装器到处套。记住它的设计意图:从多个状态推导出一个结果、且这个结果的变化频率远低于输入状态。如果推导结果几乎和输入同步变化,用它反而增加中间层开销。
key的误用是传了不可靠的 key 值,比如把index当 key,那等于没设;或者传一个每次都会新生成的对象实例,同样无法稳定位置记忆。key 应该选语义稳定的业务标识,最好是字符串或数值 id。
5.4 Lazy 列表专项优化
LazyColumn、LazyRow是性能敏感区,专项优化思路可以按优先级排列:
- 每个 item 给稳定 key;
- item 的数据模型做成稳定类型;
- item 内容里尽量不读外层动态状态;
- 回调 lambda 尽量复用或保持稳定。
一个典型写法:
@Composable fun LazyUsers(users: List<User>, onUserClick: (User) -> Unit) { LazyColumn { items(users, key = { it.id }) { user -> UserRow(user, onClick = { onUserClick(user) }) } } }这里如果上层每次重组都会生成新的onUserClicklambda,LazyUsers的参数就会被视为变化,列表全部 item 的重组率会上升。一个常用的应对方案是用rememberUpdatedState保存最新回调:
val currentOnClick by rememberUpdatedState(onUserClick)这样即使外层传入的是新 lambda,内部持有的引用依然稳定,item 不会因为这个参数变化而全部重跑。
5.5 动画与高频更新的谨慎处理
动画每帧都在改状态,如果动画对象被读在一个大作用域顶层,等于每帧都让大范围重组。基本法则是:动画值只作用在需要变化的叶节点上,尽量用Modifier.graphicsLayer或绘制级属性承载动画效果。
比如卡片进场动画,把animateFloatAsState的结果只传给卡片的graphicsLayer { scaleX = scale; scaleY = scale },而不是把动画值和整个卡片的组合逻辑搅在一起。这样动画每帧只触发绘制层更新,组合和布局阶段都能安稳跳过。在高密度列表里,这个区别就是流畅和掉帧的分界线。
6. 定位卡顿的调试实录
6.1 先选对工具:重组计数与组合日志
遇到性能问题,第一步是确认卡顿发生在组合、布局还是绘制。开发 IDE 里的布局检查器可以直接看到 Compose 层每个可组合节点的重组次数和参数变化情况,这是最直接的定位手段。
用布局检查器的思路很简单:让页面滚动或反复触发某个操作,观察目标 Composable 的重组次数。如果某个不该频繁重建的组件计数涨得飞快,要么它的作用域读取了高频状态,要么它的参数不稳定导致无法跳过。检查器能直观地显示“是谁在重组”,比靠肉眼猜靠谱得多。
临时日志也是一个有效手段,在可疑的组合函数入口打印日志,确认它是否被反复执行:
@Composable fun DebugRow() { println("Recompose DebugRow") // 定位完记得删除 Text(...) }如果父级没重跑、子级频繁重跑,说明作用域切分有效但子级参数有问题;如果父级整体高频重跑,优先排查它读取的状态。
6.2 稳定性报告怎么看
Compose 编译器支持输出稳定性报告,告诉你哪些类型被判定为稳定、哪些不稳定。在每个模块的输出目录里搜你的数据类名,如果被标记为不可跳过,按第 5 节的规则修正模型定义。
稳定性报告的阅读重点是列表 item 对应的数据类型。一个常见情况是:明明所有字段都是val,但类里嵌了一个自定义接口或某种集合类型,导致编译器认为整体不稳定。这时候就需要把字段类型换成不可变集合的具体实现,或者用@Immutable显式声明。
这个操作在大型项目里特别有价值:几十个字段的列表模型,靠人工判断稳定性很容易出错,让编译器告诉你答案最准确。
6.3 一次典型列表卡顿的排查过程
这里记录一种常见的排查节奏,步骤可以复用:
第一,打开布局检查器的重组计数,滚动列表,发现每个 item 的重组次数远高于预期。第二,用稳定性报告查 item 的数据模型,发现里面有一个字段是接口类型,编译器标记为不稳定。第三,把所有字段改成不可变集合并标注@Immutable,再次滚动,item 重组次数明显下降。第四,用 CPU 分析确认组合阶段耗时缩短,帧率恢复平稳。
如果你的场景也符合“列表滚动就掉帧”“item 明明没变却反复重组”,按照这个流程走一遍,大概率能定位到模型稳定性或 key 缺失的问题。
6.4 常见故障速查表
| 现象 | 主要原因 | 解决方向 |
|---|---|---|
| 滚动列表卡顿 | 数据类含 var 或接口类型不稳 | 全 val + @Immutable |
| 输入框每次按键整页重排 | 状态读在父级大作用域 | 状态下沉到输入组件 |
| 动画过程卡顿 | 动画状态读入高层布局 | 只让叶子节点读取动画值 |
| 列表项状态错乱 | key 未设置或使用下标 | 使用业务 id 作为 key |
| 异步回调读到旧值 | 直接读 state.value | 用 snapshotFlow 或最新状态回调 |
| 备忘值不更新 | remember 块捕获了变化变量 | 改为 remember(key) 或 derivedStateOf |
这张表基本覆盖了 Compose 性能排查的主流路径,实际项目中遇到同类问题时可以直接拿来对照。
7. 分享几条不会写在文档里的实战经验
写完前面这些,我最想强调的是:Compose 性能优化不是靠堆 API,而是靠理解机制之后做出的设计决策。状态读取下沉、数据类型稳定、重组作用域切分,这三件事其实都和“代码结构”强相关,而不是某个神秘调优开关。
第一,把可组合函数的拆分当作接口设计来做。一个组件能否独立重组,取决于它的参数是否稳定、它是否只读取自己关心的状态。写的时候多花几秒想这个边界,后期排查能省大量时间。
第二,先看数据模型,再谈优化。我遇到过不少项目,页面卡顿不是组件写得烂,而是data class里混入var或List接口。把模型定义理清,性能问题至少解决一半。
第三,derivedStateOf和snapshotFlow不只是性能工具,它们能让代码的“变化语义”更清楚。什么东西是必须响应的“事件”,什么东西只是中间过程,写清楚了对维护性帮助巨大。
第四,卡顿出现时先定位阶段,再对症下药。组合、布局、绘制三个阶段各有各的耗时来源,盲目加remember或改结构,可能只是掩盖问题。工具上布局检查器和稳定性报告是最靠谱的起点。
如果让我从这篇文章里挑一句话带走,那就是:每次写完一个 Composable,问自己一句——它读的某一个状态变了,重组的范围会扩散到哪一层。这个问题想清楚了,Compose 原理基本就等于长在你身上了。