状态管理:Compose的隐形炸弹?从重组陷阱到性能救赎

1,198 阅读14分钟

序言

在屏幕前的大家肯定见过这些画面:刚开始学习Compose的时候,我照着官方demo写Compose代码,突然报了一堆看不懂红字的错误,精心设计的动画在手机上抽风乱抖;改了个字体颜色,整个页面突然原地消失... 这个号称“新时代UI框架”的Jetpack Compose,总能用我想不到的方式教我做人。

别慌!本系列文章就是笔者为自己准备的避坑指南,我们不当文档的翻译机,只聊真刀真枪干项目时摔过的跟头(毕竟到目前为止Compose在国内也没有被完全广泛利用)——比如给按钮加个点击效果,结果触发二十次重组;写个下拉刷新布局,列表突然疯狂闪屏;用remember记数据,第二天回来发现全清零了... 这些都是经历过活生生的教训。

每篇解决一个具体主题优化,带着大家一起看看问题代码,探讨探讨Compose那些“看起来美好用起来头秃”的设计逻辑,等我们摸清重组机制的脾气,搞懂状态管理的套路等等,就会发现:原来那些让人砸键盘的报错提示,都是Compose在偷偷给大家划重点呢!

准备好降压茶,咱一起把Compose的刺儿头脾气捋顺了。踩坑不丢人,填坑才是真本事!

好了,回归正题,本篇主要带大家探讨探讨Compose重组机制的那些容易踩坑的槽点以及优化之路。

8c51cbd4-8486-4ab9-8023-4dafbe69e534.png

前言

各位同学在开始学习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 = 最终菜品(番茄炒蛋/蛋炒番茄)

食材重组.png

当状态变化时,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. 频繁重组会导致性能下降

比如动画每帧更新,或者我们在 onDrawLaunchedEffect 中频繁改变状态,都会造成组件反复重绘,这样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但没有配合 keyCompose 无法有效识别哪些需要保留哪些要更新。

//状态提到了太高的位置
var name by mutableStateOf("Alice") // 在外部定义,影响全局@Composable
fun Greeting() {
    Text("Hello, $name")
}
​

4. 不必要的计算和重绘

没有使用 rememberderivedStateOf 去缓存计算结果,因状态更新导致整个组件或计算逻辑重复执行。

@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只接收 namecount更新时它不会重组

@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

重组三板斧.png

1.性能监测工具

俗话说的好,工欲善其事必先利其器,重组优化不能凭感觉,得靠数据说话。这不,我们首先得学会使用性能检测工具来观看数据

  • Layout InspectorAndroid Studio自带工具,查看重组次数 Layout Inspector 会展示当前 UI 树,包括 Compose 组件。在右侧属性面板中,有专门的 Recomposition Counts(重组计数)字段。我们可以实时看到每个 Compose 节点的重组次数,找出哪些组件频繁重组。

li-recomposition-counts.png

这里直接拿官方的图,具体可看->布局检查器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 重置,形成多余重组链条

    状态提示后,整个数据更新路径变得清晰起来

状态提示.png

这样就避免了“状态更新带动整颗View树重组”的问题。

之所以将状态重组放到重组优化方案这里,是因为状态提升不仅让数据流更清晰,更关键的是它让重组变得可控、精准、高效,这不正是Compose优化重组性能的核心策略嘛

3. 精准控制重组范围的三剑客

首先我们要知道,Compose 重组性能优化的核心在于:控制“谁需要重组,谁不该重组” 。如何精准控制重组范围,这当然有部分的原因取决于如何用好Compose提供的三剑客。

  • remember(key): 缓存的第一道防线

rememberCompose中最常见的,也是用的最多的,我们可以减少一些列表或者复杂函数对象,变量的重复初始化。

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,各位同学,就到这里吧,我们下一篇不见不散!!