☰
Jetpack Compose 重组优化实战:稳定性、状态设计与性能调优
2026/9/26 6:16:45 网站建设 项目流程

用 Jetpack Compose 写界面半年之后,我发现自己最怕的不是布局逻辑写不出来,而是列表一滑就掉帧、输入一打字就整屏闪烁。这些问题的根源,几乎都指向同一个词:重组优化。Compose 的重组(recomposition)是声明式 UI 的核心机制,它决定了界面在状态变化时“哪些代码需要重新跑一遍”。很多人能熟练写布局,却搞不清楚重组到底什么时候发生、为什么发生、怎么让它少发生,于是性能问题一个接一个。这篇文章把我近期做 Compose 项目时整理的重组机制、稳定性分析、状态设计方法和实测调优套路全部摊开来讲,适合已经能流畅写 Compose 布局、但想进一步把应用做流畅的同学参考。

1. 先看懂重组:Compose 凭什么“智能”地只更新该更新的部分

1.1 重组不是重绘,它发生在“代码执行”层面

刚接触 Compose 的同学最容易混淆的两个概念是重组(Recomposition)和重绘(Redraw)。我最初也以为重组就是“把界面重新画一遍”,这个理解错得离谱。

Compose 的界面并不是传统 View 系统那样直接操作 View 对象,而是通过组合函数(@Composable 函数)描述出一棵 UI 树。每次状态变化,Compose 会在“代码执行”层面重新调用相关组合函数,生成新的 UI 描述,然后通过内部的 diff 机制计算出需要更新的节点,最后才触发真正的布局和绘制。重组发生在逻辑层,重绘发生在渲染层。换句话说,重组好比餐厅里顾客改了一次口味,厨房重新下了一张单;重绘则是把这盘菜重新端到桌上。

这个区别非常重要,因为“重组”的执行成本往往被低估。你以为只是跑了几行 lambda,实际上它要重新创建对象、做参数比较、进入编译器生成的 group 逻辑,这些都会消耗 CPU。真正健康的 Compose 应用,应该让重组次数尽可能少、重组范围尽可能小。

理解重组的第一步,是知道它是可以跳过的。Compose 编译器会在编译阶段为每个组合函数调用点生成跳过逻辑,如果函数的输入参数和上一次完全相同,就直接跳过函数体。这也是 Compose 官方强调“可组合函数应当是纯函数”的原因——只有当函数的输出完全由参数决定,编译器才敢安心跳过。

1.2 触发重组的三个入口:状态、参数、父组件

重组不是无缘无故发生的。概括起来,有三个触发条件。

第一个是状态读取。当组合阶段代码读取了一个 MutableState 的值,Compose 会自动记录这个读取位置,状态变化时,只有那些读取了该状态的作用域会被标记为“需要重组”。这一点是声明式 UI 的基础,但实践中最容易被滥用。看这个例子:

@Composable fun Counter() { var count by remember { mutableIntStateOf(0) } Text("Count: $count") // 这里读取了 count Button(onClick = { count++ }) { Text("Add") } }

点击按钮后 count 变化,Text 组合函数读取了 count,所以 Text 所在的作用域会重组;而 Button 内容 Text("Add") 不依赖 count,按钮本身的 onClick 闭包虽然捕获了 count,但那段代码是在点击事件里执行的,不属于组合阶段,所以不会因为 count 变化而触发按钮内容的重组。把状态读取绑定在最小作用域上,是重组优化的第一课。

第二个触发条件是参数变化。某个组合函数的父级重组后,如果传进来的参数和上一次不同,子组合函数就会重新执行,哪怕它内部根本没有读取任何状态。比如父级持有一个普通 Int 变量,从 1 变成 2,再传给子组件,子组件不会因为“你传了不同值”就无条件重画,但它的组合函数确实会执行一遍。这里 Compose 会用 equals 比较判断是否真的变了,如果没变则跳过。

第三个触发条件是父级重组本身。这里要特别说明:父级重组不等于子级一定重组。Compose 的“智能重组”机制允许跳过那些参数未变的子组件。举个例子:

@Composable fun Screen(viewModel: MyViewModel) { val list = viewModel.list Column { Header(viewModel.userName) LazyColumn { items(list) { item -> ItemRow(item) } } } }

当 list 变化时,Screen 函数体会重新执行,Header 接收的 userName 参数没变就会跳过,LazyColumn 接收的 list 变了会执行,但 ItemRow 会逐个比较 item,没变的条目依然能跳过。这种逐层比较的能力,是 Compose 性能的根基,但它的前提是参数类型“可信”。

1.3 重组作用域:Compose 怎么决定谁“跑一遍”

很多优化教程会告诉你“把代码拆成小函数”,但没说清楚为什么。核心就在重组作用域(Recomposition Scope)。

Compose 编译器会在编译时为每个组合函数调用点创建独立的作用域节点。当状态读取发生时,受影响的最小单元是“读取位置所在的组合函数调用点”,而不是整个页面。打个比方,重组作用域就像是电路里的保险丝,状态变化只会烧断最近的那根保险丝,不会让整栋楼停电。

但有个陷阱:如果你把状态读取放在一个很大的组合函数里,比如整个页面的根布局执行了viewModel.loading的判断,那么 loading 变化时,整个根布局组合函数都会重新执行,哪怕它下面挂着一百个子组件。拆函数的核心目的就是缩小作用域。

举一个实际例子。我写过一段“用户列表 + 顶栏 + 底部导航”的结构,最初在 Activity 的 setContent 顶层读取了viewModel.isLoading,结果数据加载完成那一下,整个页面全部重组,布局瞬间卡顿。改法很简单:把 isLoading 的判断下沉到一个独立的 Loading 组件里,顶层只传状态值给需要它的子组件,重组范围立刻缩小。这个优化思路贯穿整个重组优化的始终:状态读取离展示的地方越近,重组范围越小。

2. 稳定性:决定 Compose 敢不敢“跳过”的那张通行证

2.1 稳定类型和“不稳定”类型的区别

跳过机制听起来很美,但有个前提:Compose 编译器必须能信任参数比较的结果。这里就引出了稳定性(Stability)的概念。

Compose 编译器在编译期会分析每个类型。如果一个类型被判定为“稳定”(stable),编译器就认为它的实例变化能被可靠检测:要么实例本身不可变,要么它的变化会通过 State 机制被 Compose 观察到。判定为“不稳定”(unstable)的类型,编译器不敢依赖 equals 判断结果,于是每次父级重组时,都会假定它的参数可能变了,强制子组件重新执行。

换句话说,稳定性是 Compose 的“通行证”。拿到通行证的类型,才有资格享受跳过优化;没拿到的,只能每次老老实实跑一遍。

哪些类型默认不稳定?常见的有:普通接口类型、List、MutableList、没有标注且字段包含 var 的普通类、字段里含有不稳定类型的 data class。相反,原始类型、String、枚举、Lambda(如果捕获列表稳定)、以及字段全部稳定且为 val 的 data class,编译器可以推断为稳定。

这里我要强调一个很多人踩过的坑:data class 不一定稳定。比如:

data class BookList(val books: List<Book>)

编译器会认为它不稳定,因为 List 本身是不稳定类型,即使你没有修改 books 的引用,外部依然可能修改 List 内部的内容,equals 结果可能骗人。要让 Compose 敢跳过,你得保证 List 类型也是稳定的,或者用注解明确告知编译器。

2.2 @Stable / @Immutable 的正确标注姿势

既然编译器倾向于保守,开发者就能通过注解“说服”它。两类注解:@Immutable 和 @Stable。

@Immutable 表示类型构造之后,所有公开属性永远不会变化。最典型的是不可变数据类:

@Immutable data class User(val name: String, val avatar: String)

这个 User 只有 val 字段,字段类型也是稳定的,标注 @Immutable 是安全的。编译器看到这个注解后,会放心地在参数比较时使用 equals 判断,如果两个 User 内容相同就跳过重组。

@Stable 比 @Immutable 宽松一些,它允许公开属性变化,但要求变化必须能被 Compose 观察。典型场景是类内部用 mutableStateOf 保存字段:

@Stable class ProgressUiState { var progress by mutableStateOf(0f) }

progress 变化时,因为背后是 State,Compose 能感知到,所以标注 @Stable 是合理的。

但注解是把双刃剑。我见过最严重的 bug,是有人给一个内部持有 MutableList 的类标了 @Immutable,列表内容被外部修改后界面纹丝不动,排查了半天才发现是注解骗了编译器。所以这里给出三条经验:

  • 只有确定构造后不会再变的数据才标 @Immutable。
  • 类里有 var 字段,但字段变化依赖 State,可以标 @Stable。
  • 拿不准的时候宁可不标,等编译器报告告诉你哪里不稳定,再针对性处理。

2.3 lambda 的稳定性:藏在每个括号里的“隐形炸弹”

组合函数里最常见的参数类型其实是函数类型(lambda),而 lambda 恰恰是稳定性问题的高发区。

你可以这样理解:lambda 本质上是一个“捕获了周边变量”的对象。每次父级重组,组合函数都会重新创建一个新的 lambda 实例。如果这个 lambda 捕获了不稳定变量,编译器会把它判定为不稳定;就算它捕获了稳定变量,新实例和旧实例也不是同一个引用,参数比较依然很难通过。

最典型的场景是 LazyColumn 的 item 内容:

@Composable fun ListScreen(items: List<Item>, onItemClick: (Item) -> Unit) { LazyColumn { items(items) { item -> ItemRow(item, onClick = { onItemClick(item) }) } } }

这里每个 item 的 onClick lambda 都捕获了 item 和 onItemClick。父级 ListScreen 重组时,即使 items 没有变化,所有 item 的 lambda 都会被重新创建,ItemRow 收到的 onClick 参数引用全部变了,于是整列全部重组。列表越长,这个问题越致命。

解决思路有几种。最常用的是用 remember 缓存 lambda:

@Composable fun ListScreen(items: List<Item>, onItemClick: (Item) -> Unit) { LazyColumn { items(items) { item -> val itemClick = remember(item) { { onItemClick(item) } } ItemRow(item, onClick = itemClick) } } }

注意 remember(item) 的 key 是 item,item 不变时 lambda 引用保持不变,item 变化时才重新创建。这样能把“必须变化的”限制在真正变化的条目上。

另一个经典方案是配合 rememberUpdatedState。当 lambda 需要读取“最新值”但不想让调用方每次重建 lambda 时,可以这样写:

val currentItem by rememberUpdatedState(item) val itemClick = remember { { onItemClick(currentItem) } }

currentItem 是一个 State,lambda 里读取的是 State 的值,lambda 本身引用恒定。这样既拿到了最新 item,又让 lambda 保持稳定。

这条经验在做复杂列表时价值极高。我自己的习惯是:只要 item 里出现 onClick、onLongClick 这类回调参数,都先想想它会不会导致同列表其他 item 被误伤。

3. 状态设计与读取范围:把重组“圈”在最小笼子里

3.1 状态提升与状态下沉:位置决定影响面

重组优化里有一个看似矛盾但实际统一的原则:状态该提升的还是要提升,但读取状态的位置要尽可能下沉。

很多人一听“状态提升”就以为要把所有状态放到页面最顶层,这是误解。状态提升解决的是“多个组件共享同一份状态”的问题,比如购物车的数量、用户登录态;但提升之后,哪个组件读取它,它就影响哪个组件的作用域。如果整个根布局读取了一个顶层状态,那这个状态一变,整个根布局都要重组。

正确的做法是:共享状态放在合理的层级,然后用参数把数据传给需要它的子组件;子组件内部再决定在哪个位置读取。看一个我改过的例子。

原来我写商品详情页,ViewModel 里有一个isFavorite状态,页面顶层收藏按钮和商品图片区域都用它。结果点击收藏按钮时,整个商品详情区域全部重组,连图片都闪一下。后来我把 isFavorite 直接传给收藏按钮子组件,让按钮自己读取状态,商品图片区域只接收不依赖该状态的参数,重组范围立刻缩小到按钮本身。这个改动没有改变状态层级,只是挪了“读取位置”。

还有一个容易被忽视的点:状态读取如果在 lambda 内部而不是组合函数体内部,是不会触发重组的。比如onClick = { favorite.toggle() },这个 lambda 里的读取发生在点击事件中,不在组合阶段。反过来,如果写成if (favorite.isFavorite) Button(...) else Text(...),这个判断读取了状态,判断所在的组件就会重组。所以写代码时要分清“组合期读取”和“事件期读取”。

3.2 remember / rememberSaveable / derivedStateOf:各司其职别滥用

状态读取会触发重组,但状态本身需要用对工具。我先梳理三个最常用的 API 的定位。

remember 用于在组合期间缓存值,记住的是“计算结果”,不是“计算逻辑”。最常见的场景是缓存一个耗时计算结果:

val filtered = remember(items) { items.filter { it.isVisible } }

注意 key 是 items,items 引用变化时缓存失效重新计算;如果 items 是 MutableList 原地修改了内容,引用没变,remember 不会重算。所以配合不可变数据使用才安全。

rememberSaveable 比 remember 多了一层进程与配置变更保护,适合需要状态恢复的场景,比如 TextField 的文本。但注意它保存的数据类型有限制,复杂对象要自己写 Saver。

derivedStateOf 是重组优化里容易被低估的利器。它的核心价值是:从其他 State 派生新 State,只有“派生结果”变化时才通知重组。典型场景是滚动偏移到阈值:

val scrollState = rememberScrollState() val isScrolled by remember { derivedStateOf { scrollState.value > 0 } }

滚动时 scrollState.value 几乎每一帧都在变。如果直接在组合函数里写if (scrollState.value > 0),那滚动整个过程中,这个判断所在的组件会跟着每帧重组,非常浪费。用 derivedStateOf 包装后,isScrolled 只在值从 false 变成 true 或从 true 变成 false 的那一刻变化,滚动过程中其余帧全部跳过。

用 derivedStateOf 有个原则:只有原始状态变化频率远高于派生结果变化频率时才值得用。如果派生结果和原状态一样每帧都变,加一层包装反而多一次计算。

3.3 key 的正确使用:别让状态“串位”

重组优化除了让代码少跑,还要让状态待在正确的位置。key 就是干这个的。

Compose 中的 key() 函数和 LazyColumn 的 item key 是两个层面。先说 LazyColumn:

LazyColumn { items(items = list, key = { it.id }) { item -> ... } }

不指定 key 时,列表项默认按 position 关联。比如删除列表第一个元素后,原本第二个元素会顶替第一个位置,它的内部状态(比如 TextField 的输入内容、Collapse 的展开状态)会被复用,于是用户会看到“输入框内容跑到了另一行”。这是状态串位的典型问题。指定稳定的 key(比如 id)后,Compose 会按 key 关联节点,删除、插入、排序都能保持状态跟对对象。

key() 函数则更多用在同一个组合位置需要切换不同对象的场景。比如:

key(item.id) { when (item.type) { Type.A -> TextA(item) Type.B -> TextB(item) } }

key 的存在让 Compose 明白“同一个位置可能是不同的对象”,切换时状态不会串。

这里要提醒一句:不要用 index 当 key。列表一旦发生插入或删除,index 会变,key 随之变化,状态照样串位。key 的要求是稳定且唯一,业务 id 通常是首选。

4. 实战调优:找到真正拖垮性能的重组热点

4.1 Layout Inspector 查看重组计数

纸上谈兵再多,不如先看数据。Android Studio 自带的 Layout Inspector 自带重组计数功能,是我排查 Compose 性能问题的第一站。

操作流程很简单:在 Debug 模式下运行应用,打开 Tools > Layout Inspector 选择当前进程,进入 Compose 页面后,点击工具栏上的 “Show recomposition counts” 按钮。开启后,组件框角会显示一个数字,代表该组件自页面打开以来的重组次数。重组次数高的组件会被标成红色,颜色越深说明越频繁。

我的习惯是这么测:先正常打开页面,清空计数,然后做一个典型操作(比如滚动列表、点击按钮、输入文字),再回来看哪些组件红了。如果某个列表 item 被标红且数字巨大,基本可以断定问题出在 item 的参数稳定性或 lambda 创建上。

需要注意的是,Debug 模式下 Layout Inspector 本身有额外开销,看到的是相对热点而不是绝对性能。我会用它在“有没有在乱重组”这个维度上判断问题,而真正的帧率测量仍然要依赖 Release 包和 Profile。

4.2 Compose 编译器报告:给类型做稳定性体检

Layout Inspector 看到的是运行结果,编译器报告则是从源头告诉你哪些类型不可信。这个工具很多人不用,但它能节省大量排查时间。

启用方式是在模块的 build.gradle.kts 里,引入 Compose 编译器插件后配置:

plugins { id("org.jetbrains.kotlin.plugin.compose") } composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler_reports") metricsDestination = layout.buildDirectory.dir("compose_compiler_metrics") }

重新构建后,在 build/compose_compiler_reports 目录下会生成模块对应的报告文件。打开搜索 unstable,你能看到所有被编译器判定为不稳定的类。我通常会重点关注以下几类:

  • 列表条目类型:如果 item 类型不稳定,列表优化基本无从谈起。
  • 频繁出现在组合函数参数里的普通类:像 UiState、配置对象这类。
  • 你打算放进 remember 缓存 key 里的类型。

拿到报告后,优先处理“高频出现 + 不稳定”的类:改成不可变字段、填充注解、或者用 State 包装可变字段。改完重新构建再看报告,直到高频类型基本稳定。

有同学问过:报告里显示 stable 就一定没问题吗?也不一定。编译器报告基于静态分析,它只判断“类型层面试图支持”,不等于运行时每次都跳过了。比如 lambda 引用每次新创建的问题,报告就可能不会体现。所以报告和 Layout Inspector 要配合使用,一个管静态、一个管动态。

4.3 常见卡顿场景与排查方案速查表

最后把实战中遇到的高频问题整理成一个速查表,方便你对照排查。

现象常见原因解决方案
LazyColumn 滑动掉帧item 类型不稳定 / item lambda 每次新建 / 没设 keyitem 改不可变数据类,lambda 用 remember 缓存,key 传稳定 id
点击按钮后整页重组状态在根布局读取把状态读取下沉到真正展示它的叶子组件
输入框打字卡顿过滤列表在组合中直接计算,每次生成新 List用 derivedStateOf 包装过滤结果,或使用 remember(key) 缓存
UI 该更新却不更新@Immutable 标注错误,字段被外部修改移除错误注解,改为不可变数据 + copy,可变字段用 State
滚动时标题阴影频繁闪动滚动偏移值在组件中直接读取,每帧触发重组用 derivedStateOf 把偏移量转换成布尔状态
数据列表刷新后状态错乱列表项没有用稳定 key给 items 指定 key = { it.id }

这些场景排查起来不复杂,但背后都指向同一个思维习惯:每次写组合函数前,先问自己三个问题——这个组件的参数稳定吗?状态在哪一层被读取?变化会不会被一个 lambda 意外扩大影响范围?

我个人在实际操作中的体会是:重组优化不是上线前的急救措施,而是一种编码习惯。写完一个列表,顺手开 Layout Inspector 看一眼重组计数;定义一个新的数据类,顺手看一眼编译器报告。坚持一段时间之后,你对哪些类型稳定、哪些位置会大面积重组、lambda 什么时候会坑人,会形成非常准确的直觉。到最后你会发现,大部分性能问题在写代码的那一刻就已经被规避掉了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询