序言
在屏幕前的大家肯定见过这些画面:刚开始学习Compose的时候,我照着官方demo写Compose代码,突然报了一堆看不懂红字的错误,精心设计的动画在手机上抽风乱抖;改了个字体颜色,整个页面突然原地消失... 这个号称“新时代UI框架”的Jetpack Compose,总能用我想不到的方式教我做人。
别慌!本系列文章就是笔者为自己准备的避坑指南,我们不当文档的翻译机,只聊真刀真枪干项目时摔过的跟头(毕竟到目前为止Compose在国内也没有被完全广泛利用)——比如给按钮加个点击效果,结果触发二十次重组;写个下拉刷新布局,列表突然疯狂闪屏;用remember记数据,第二天回来发现全清零了... 这些都是经历过活生生的教训。
每篇解决一个具体主题优化,带着大家一起看看问题代码,探讨探讨Compose那些“看起来美好用起来头秃”的设计逻辑,等我们摸清重组机制的脾气,搞懂状态管理的套路等等,就会发现:原来那些让人砸键盘的报错提示,都是Compose在偷偷给大家划重点呢!
准备好降压茶,咱一起把Compose的刺儿头脾气捋顺了。踩坑不丢人,填坑才是真本事!
好了,回归正题,本篇主要带大家探讨探讨Compose重组机制的那些容易踩坑的槽点以及优化之路。
前言
各位同学在开始学习Compose的那段时间里,我们是不是总能遇到以下这些问题:
- “为什么我的Compose页面一滑动就开始掉帧,卡的像PPT一样?”
- “为什么这个按钮点击后UI状态不更新?"
其实在随着学习的深入,你会发现这些问题的罪魁祸首,80%的原因是因为项目的状态管理不当导致的。
一. 先捋一下Compose的响应式模型
我们先来简单回顾一下Compose背后的响应式模型
传统View的“手动挡” vs Compose的“自动驾驶”
- 传统View:基于命令式编程,每次数据变化后,都要手动操作 UI,例如调用 setText() 、notifyDataSetChanged() 等方法来同步界面
- Compose:声明式编程,UI自动跟随状态变化(State → UI自动映射)
想象一下,传统View开发像驾驶手动挡汽车,你得亲自换挡、打方向、踩离合。 每一次状态变化(比如数据刷新),都得你“手动操控 UI”;而Compose则是特斯拉的Autopilot,你只需告诉它目的地(目标状态),系统自动调整路线。
// 传统View:手动更新计数器
textView.text = "Count: $count"
// Compose:声明状态与UI的关系
var count by remember { mutableStateOf(0) }
Text(text = "Count: $count")
另外,现在想象你在厨房做菜:
- 状态 = 食材(西红柿、鸡蛋)
- 重组 = 根据食材调整烹饪步骤
- UI = 最终菜品(番茄炒蛋/蛋炒番茄)
当状态变化时,Compose会自动重新"烹饪"UI:
var dish by remember { mutableStateOf("番茄炒蛋") }
// 点击按钮切换菜品
Button(onClick = { dish = "蛋炒番茄" }) {
Text(dish)
}
这种响应式的能力,确实让我们写出来的 UI代码更加简洁、易维护。但也正因为Compose是“自动”的,我们就需要更好地理解它什么时候重组,重组了哪些组件,是否合理,否则就可能带来性能开销,甚至卡顿。
二、重组机制:藏在优雅API下的性能陷阱
官方明确说明,当状态变化时,Compose会重新执行相关代码块(重组Scope),生成新的UI树并与旧树对比,仅更新变化部分。下面提供了些例子可以直观感受下
重组范围实验(直观揭秘Compose智能优化)
@Composable
fun CounterDemo() {
val count = remember { mutableStateOf(0) }
// 点击按钮触发重组
Button(onClick = { count.value++ }) {
// 这个Text会被重组
Text("Increment")
}
// 思考下,这个Text在父组件状态变化时也会重组吗?
Text("Unrelated Text")
}
Compose通过定位状态调用链,智能缩小重组范围。另外,上面例子中的Unrelated Text不会参与重组!
这时候就有同学会问了,既然Compose重组机制这么智能,我们还需要关心它吗?那肯定是毋庸质疑的,非常有必要。这里我们可以看看Compose的重组机制下几个必须要知道的“坑点”(ps:以下提供的代码都是错误的,小伙伴不要重蹈我的覆辙哦)
1. 频繁重组会导致性能下降
比如动画每帧更新,或者我们在 onDraw、LaunchedEffect 中频繁改变状态,都会造成组件反复重绘,这样UI 卡顿会非常明显
@Composable
fun BaseAnimation() {
var angle by remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
angle += 1f // 每次加1都会触发重组
//大概60fps
delay(16)
}
Box(Modifier.rotate(angle)) {
// 组件会每帧重组 → 性能差
}
}
2. 重组粒度虽然智能,但也容易被破坏
比如我们把一个大块逻辑都写在一个 Composable 里,那么哪怕只改了很小一部分状态,整个函数都会被重组。
@Composable
fun BigComposable() {
var count by remember { mutableStateOf(0) }
Column {
Text("Header") // 不变的也会重组
Button(onClick = { count++ }) {
Text("Click me")
}
Text("You clicked $count times") // 其实只有这个需要更新
}
}
3. 错误使用状态会放大重组范围
比如我们把状态写在 Composable 外部,或者每次都用 remember但没有配合 key,Compose 无法有效识别哪些需要保留哪些要更新。
//状态提到了太高的位置
var name by mutableStateOf("Alice") // 在外部定义,影响全局
@Composable
fun Greeting() {
Text("Hello, $name")
}
4. 不必要的计算和重绘
没有使用 remember 或 derivedStateOf 去缓存计算结果,因状态更新导致整个组件或计算逻辑重复执行。
@Composable
fun ExpensiveComputation(count: Int) {
val doubled = count * 2 // 每次重组都重新计算
Text("Doubled: $doubled")
}
当然针对这几个坑点也挺好解决,没啥难度,更多的是一些细节,这里笔者总结了以下建议
- 把状态尽量往靠近使用的组件放
- 多使用 remember 缓存不会变的值
- 用 derivedStateOf 派生新状态,避免无意义的重复计算
- 拆小composable,减少重组范围
三、针对常见的六大状态管理陷阱与破解之道
上文只是简单说了下Compose重组机制的几个坑点,提了下解决方式。诚然Compose用声明式API隐藏了底层diff算法的复杂性,简化了很多代码,但这也确实带来了隐患,俗话说的好,往往“简洁”的背后其实隐藏着不少容易踩坑的地方;下面笔者整理了在项目中最容易遇到的六个状态管理陷阱,逐个击破它们,Compose 的性能与体验才能稳如老狗。
陷阱1:在重组中创建状态导致数据丢失
有时候在函数里写顺手了,直接在重组中创建变量/状态,导致每次重组后都会被重置。
@Composable
fun BadCounter() {
var count = 0 // 每次重组都会重置
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
这里的 count 是普通变量,只存在于当前函数执行时。每次重组,这个函数会重新执行一遍,count 也会变回 0,导致按钮永远显示 “Clicked 0 times”。有同学说这不是小问题么,但千万不要小瞧它,千万别因小失大
正确的做法 是使用 remember 可以让Compose保持状态,在重组时自动“记住”上一次的值。
@Composable
fun GoodCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
陷阱2:lambda中直接修改状态引发无限重组
我们先来看一段代码
@Composable
fun BadExample() {
var flag by remember { mutableStateOf(false) }
LaunchedEffect(flag) {
flag = !flag // 每次都会触发重组
}
Text("Flag is $flag")
}
嗯,是不是看起来好像没有太大毛病,但仔细观察会发现每次进入LaunchedEffect都会去修改状态变量flag,从而引发重组,那重组之后又去执行LaunchedEffect,这样以来是不是进入到无限循环当中了嘛。
正确做法,笔者这里只是提供参考,就是我们不要在里面去修改状态,尽量做执行一次的逻辑,或者确保LaunchedEffect的key是合理变化,而不是Unit
LaunchedEffect(Unit) {
delay(1000)
// 做一些一次性的逻辑,而不是修改状态引发循环
}
陷阱3:未使用remember导致状态无法持久化
有时候我们直接创建了一个状态对象,但是没有使用remember,这样会导致每次重组都会创建新的状态对象 , 看下以下例子
@Composable
fun InputExample() {
val text = mutableStateOf("")
TextField(value = text.value, onValueChange = { text.value = it })
}
这里每次重组都会创建新的状态对象,输入框内容会被清空
正确做法就是加上remember啦,特别需要记住的是,凡是我们希望在多次重组之间保留的状态,一定一定要使用remember
@Composable
fun InputExample() {
val text = remember { mutableStateOf("") }
//或者用 var text by remember { mutableStateOf("") } 随意
TextField(value = text.value, onValueChange = { text.value = it })
}
陷阱4:复杂对象的状态更新未使用拷贝
看到这个有同学就会问了,这是啥意思呢?别急,我们来看一个例子
data class User(val name: String, var age: Int)
@Composable
fun UserInfo() {
var user by remember { mutableStateOf(User("Tom", 20)) }
Button(onClick = {
user.age = 21 // 错误:不会触发重组
}) {
Text("Set age")
}
Text("Age: ${user.age}")
}
这里我创建了一个User对象,并且去修改User对象中age参数,会发现数据变了,但是UI却没更新?这是因为User 是不可变对象,修改 age 并不会改变 User 引用,Compose 认为它没变,重组就不会发生。
正确做法我们去使用拷贝copy
user = user.copy(age = 21) // 这样 Compose 才知道“user”变了
记住一点,状态更新必须要创建新的对象,这样才能触发状态引用变化
陷阱5:跨组件状态共享导致过度重组
有时候我们需要各个组件进行状态共享,这本来是一件耗时,但如果设计不当,会让 UI大片区域“无意义重组”,导致卡顿、浪费性能,比如说我们多个组件共同依赖一个状态对象,其中一个更新状态次数,另一个组件中仅仅使用对象中的名字参数,但是我直接传了整个状态对象进去,这样ComponentB
并没有使用 count,但每次 count 变,它都被重新重组,由于uiState是一个状态对象,引用变了,全部组件都中招,即便它们中有些组件对状态对象中另外那些变化的参数毫不关心。
data class UiState(val count: Int, val name: String)
@Composable
fun App() {
var uiState by remember { mutableStateOf(UiState(0, "Compose")) }
Column {
// ComponentA 改变 count
ComponentA(uiState, onUpdate = { uiState = it })
// ComponentB 仅使用 name,但被强制重组
ComponentB(uiState)
}
}
@Composable
fun ComponentA(state: UiState, onUpdate: (UiState) -> Unit) {
Button(onClick = {
onUpdate(state.copy(count = state.count + 1))
}) {
Text("Count: ${state.count}")
}
}
@Composable
fun ComponentB(state: UiState) {
// 实际上这里只用了 name
Text("Name: ${state.name}")
}
在Compose开发中,千万不要为了图省事,直接传一个状态对象进行,哪怕只用了一个字段,让所有人都陪你重组
优化的解法我们需要拆字段 + 精准传参,ComponentB只接收 name,count更新时它不会重组
@Composable
fun App() {
var uiState by remember { mutableStateOf(UiState(0, "Compose")) }
Column {
ComponentA(uiState.count) {
uiState = uiState.copy(count = it)
}
ComponentB(uiState.name)
}
}
@Composable
fun ComponentA(count: Int, onCountChange: (Int) -> Unit) {
Button(onClick = { onCountChange(count + 1) }) {
Text("Count: $count")
}
}
@Composable
fun ComponentB(name: String) {
Text("Name: $name")
}
如此以来,就避免了由于错误共享导致全组件重组的陷阱。
陷阱6:忘记使用derivedStateOf导致无效计算
比如说我们在做一些分页筛选计算的时候,看下这个例子
@Composable
fun ExpensiveCalculation(count: Int) {
val doubled = count * 2 // 每次重组都算一次
Text("Doubled: $doubled")
}
这样以来是不是即使count不变,每次重组也会计算一次, 就浪费性能了,当然这里逻辑比较简单不会有太大的性能问题,但如果计算逻辑特别复杂呢?有没有什么方案可以解决这个问题呢? 当然有,Compose提供了derivedStateOf派生状态,只有状态变化的时候才去重新计算。
@Composable
fun ExpensiveCalculation(count: Int) {
val doubled by remember(count) {
derivedStateOf { count * 2 }
}
Text("Doubled: $doubled")
}
只有当 count 变化时,doubled才会重新计算。
这在处理筛选、排序、大数据分页、动画变化等场景时,特别有用!
四、性能救赎:一些重组优化的实战方案
上文说了这么多重组陷阱的破解之道,下面我们来简单谈谈一些重组优化的方案吧,这里我愿称之为Compose重组性能优化三板斧
- 先看哪里重组(用工具Layout Inspector / 自定义计数器)
- 再查为啥重组(是否参数引用变了、状态共用过度等)
- 最后控重组范围(拆字段 + 合理使用 remember、 @Stable、 @Immutable)
1.性能监测工具
俗话说的好,工欲善其事必先利其器,重组优化不能凭感觉,得靠数据说话。这不,我们首先得学会使用性能检测工具来观看数据
- Layout Inspector:Android Studio自带工具,查看重组次数 Layout Inspector 会展示当前 UI 树,包括 Compose 组件。在右侧属性面板中,有专门的 Recomposition Counts(重组计数)字段。我们可以实时看到每个 Compose 节点的重组次数,找出哪些组件频繁重组。
这里直接拿官方的图,具体可看->布局检查器Layout Inspector
-
重组计数器:自定义Modifier统计重组
这个方式推荐数据排查的时候用,可以自己定义个简单得重组计数器
@Composable fun DebugBox(name: String) { var count by remember { mutableStateOf(0) } SideEffect { count++ println("Recomposition [$name]: $count 次") } Text("重组次数: $count") }
将它嵌入到任何组件中,就可以实时打印它得重组频率,仅供参考
2.状态提升(State Hoisting)
有同学说为啥要进行状态提升,组件自己单独管理状态不是更方便吗? 方便只是短期的,保证项目的灵活性和可控性才是长远的。而且这也是官方推荐的实践方式之一,将状态从子组件中“提升”到它的父组件,由父组件统一持有和控制。进行状态提升有以下好处
-
统一控制数据源
- 多个组件如果依赖同一份状态,把它放在上层更容易协调它们之间的数据同步。
- 比如:多个输入框共用一个验证状态、表单数据统一校验等。
-
避免子组件内部无意义的状态创建
- 如果状态定义在子组件内部,组件每次使用都持有自己的状态副本,不利于共享与复用。
- 不利于测试、调试,也不利于数据在组件间的流转。
先用个表格总结下
状态提升 | 子组件自持状态 | |
---|---|---|
状态来源 | 明确、单一 | 分散、不清晰 |
组件复用 | 易复用 | 被状态耦合 |
UI一致性 | 易保证 | 难同步 |
可测试性 | 方便测试 | 状态不透明 |
数据同步 | 控制统一 | 容易混乱 |
还是老规矩,举个🌰看看吧
@Composable
fun Parent() {
var name by remember { mutableStateOf("Compose") }
NameEditor(name, onNameChange = { name = it })
}
@Composable
fun NameEditor(name: String, onNameChange: (String) -> Unit) {
TextField(value = name, onValueChange = onNameChange)
}
可以看下代码TextField组件本身 不持有状态,NameEditor 也不持有状态,只“接受状态并反馈变更”,状态只保存在 Parent
→ 这样就保证了所有变更都集中在一处,这就是所谓的状态提升。
看到这,有朋友就会问了,是不是所有状态都可以提升,那肯定不是,我们一般只提升影响到多个组件间协作的组件,内部单独的UI状态仍然可以放在组件内 ;那又有朋友会问了,老铁你这写的也没有表明状态提升对重组优化起到啥作用,稍等,笔者这就解释一下蛤
-
1.错误地在子组件持有状态,容易造成不可控的重组
试想下如果状态都定义在子组件的内部:
@Composable fun MyInput() { var text by remember { mutableStateOf("") } TextField(value = text, onValueChange = { text = it }) }
这看起来好像没毛病,如果只是单独这个组件用到的话,但一旦这个组件频繁被组合,其他地方也调用这个状态,它的状态会不断丢失或者重建,造成不必要的重建,也无法进行跨组件共享状态。
-
2.状态提升能有效“定向重组”,只更新必要的子组件
@Composable fun Parent() { var name by remember { mutableStateOf("张三") } NameInput(name, onChange = { name = it }) Greeting(name) // 依赖 name 的组件 }
通过把状态提升到上层,我们可以做到: NameInput更新时,Greeting 也能响应,但不会重组无关组件,重组只会发生在读取该状态的组件中,这符合Compose的重组优化思想:“按需重组,定向更新”
-
3.状态提升避免“子组件状态更新 → 父组件重组 → 子组件又被重组”的死循环
如果我们把状态留在子组件内部时,父组件重组(比如传入参数变了)会导致子组件的 remember 重置,形成多余重组链条。
状态提示后,整个数据更新路径变得清晰起来
这样就避免了“状态更新带动整颗View树重组”的问题。
之所以将状态重组放到重组优化方案这里,是因为状态提升不仅让数据流更清晰,更关键的是它让重组变得可控、精准、高效,这不正是Compose优化重组性能的核心策略嘛
3. 精准控制重组范围的三剑客
首先我们要知道,Compose 重组性能优化的核心在于:控制“谁需要重组,谁不该重组” 。如何精准控制重组范围,这当然有部分的原因取决于如何用好Compose提供的三剑客。
- remember(key): 缓存的第一道防线
remember在Compose中最常见的,也是用的最多的,我们可以减少一些列表或者复杂函数对象,变量的重复初始化。
val userInput by rememberUpdatedState(newValue = inputText) // 不触发重组
val derivedState = remember(userId) {
getUserInfo(userId)
}
val list = remember { mutableListOf<Int>() } // 缓存 list 引用
list.add(...) //需要注意的是,修改后 UI 不会感知,除非触发手动更新
其中remember {} 在首次组合时初始化一次,之后保持状态不变。remember(key) 是加了“条件缓存”,只有当 key 变了,才会重新执 行lambda。所以remember常用于避免在每次重组时重新初始化耗时变量、lambda、对象。
需要注意一下,如果key写错或者过多,会导致缓存形同虚设
-
@Stable 注解:标记“结构上不变”的数据类
@Stable data class UiState(val count: Int, val name: String) @Stable class Counter { var count by mutableStateOf(0) }
告诉 Compose 这个类是“可追踪变更但不乱动”的,Compose 将在属性不变的前提下跳过不必要的重组, 适用于内部状态会变,但属性结构稳定的类, 上面的例子Compose会对 count 做依赖跟踪,只在它变更时重组使用它的地方。这样以来,可以有效避免每次都整体重组对象,提高性能。
-
@Immutable 注解:标记“完全不可变”的类
@Immutable
data class UserProfile(val id: Int, val name: String)
这个注解和 @Stable的主要区别在于,它告诉 Compose 这个类完全不可变,有着更强的保证:对象不可变 + 所有字段也不可变。可以大幅减少不必要的重组。特别特别注意的是一旦使用了该注解,对象中所有的字段必须保证是val, 虽然写var编译器不会报错,但是Compose 根本不会优化这个对象的重组,相当于没有。这样以来Compose可以只比较引用,不用深度检查字段,它适用于不变的数据模型(比如说UI显示用的DTO)
总结下,这里笔者概括了一句口诀方便理解, "remember缓结果,Stable缓字段,Immutable缓对象" ,合理利用Compose提供的三剑客,采用针对性优化方案,控制重组发生的频率和范围,让Compose重组更高效。
工具 | 作用 | 场景 | 注意点 |
---|---|---|---|
remember | 缓计算/表达式 | 避免不必要初始化 | key 控制更新时机,避免写错/过多 |
@Stable | 缓字段变化 | 避免小改大动 | 字段需用 mutableStateOf 等 |
@Immutable | 缓对象引用 | 不看字段、只看引用 | 所有字段必须不可变 |
小结
状态,是Compose的核心驱动力;而重组,是性能的“放大镜”。
回顾全文,我们分析了常见的状态管理陷阱,并归纳出如下几类“高频误区”与破解策略:
- 状态作用域太大:让无关组件也跟着重组,应该通过字段拆分、状态提升(Hoisting)或 remember 精准控制范围。
- 状态不持久:漏掉 remember导致状态重置,要善用 remember(key) 。
- 对象变了 UI 不动:需要为数据类标注 @Stable / @Immutable,让Compose能“识别变化”。
- 重复计算浪费资源:用 derivedStateOf 缓存逻辑输出,只在真正依赖的值变动时才重新计算。
再配合上性能检测工具,希望我们对Compose有了更加高效和丝滑的UI架构开发使用经验。
记住:Compose的状态管理不是魔法,理解并合理运用重组机制才能避免被炸伤!
未完待续,下一篇预告《布局与测量性能优化:从”嵌套地狱“到”扁平化管理“》:
- 触目惊心的性能数据:20层嵌套Row导致测量耗时增加400%
- 一个神秘武器SubcomposeLayout:实现异步加载的魔法布局等等
OK,各位同学,就到这里吧,我们下一篇不见不散!!