Jetpack Compose-性能优化、重组的机制

前言

Jetpack Compose 在使用的过程中,我感觉性能不如传统 View。

主要有以下几方面原因:

  • 编译和构建时间:Compose 编译器会处理应用中的可组合项,这会增加应用的构建时间。平均构建时间从 299 毫秒增加到 399 毫秒。虽然完成迁移后构建时间有所缩短,但在开发过程中,较长的构建时间可能会让开发者觉得性能不佳。

  • 内存占用:Jetpack Compose 需要增加依赖,这会增加包的占用空间和运行时的内存使用。而 View 系统是 Android 系统的一部分,不需要额外的导入,在内存占用方面可能更有优势。

  • 重组开销:当界面数据发生变更时,Compose 需要重新组合受影响的项目。

    在处理大规模列表或复杂 UI 结构时,更多的项目需要重组,从而带来较大的 CPU 和内存开销。

    例如,Compose 中的 LazyColumn 在渲染大量数据时,其重组开销可能会比传统 View 中的 RecyclerView 大。

  • 绘制效率:Compose 需要在低层次 API 上反复执行测量和布局操作,这可能会影响最终渲染速度。

    而传统 View 系统中的 RecyclerView 的 ViewHolder 模型在绘制效率上有一定优势,尤其是在复杂布局场景下,ViewHolder 的扁平视图层级结构可以避免一些内部的测量和布局开销。

  • 版本和优化程度:View 系统已经存在多年,经过了大量的优化,对于复杂布局的性能表现通常较为稳定。

    而 Jetpack Compose 相对较新,虽然每次迭代都在改进,但在某些特定场景下,可能还没有达到 View 系统的优化程度。

优化方式

延迟加载数据(效果显著)

加载数据后页面就要开始渲染,这时候可能页面的跳转动画还没完成,就会导致卡顿。

添加延迟是最有效的优化手段。

1
2
3
4
LaunchedEffect(Unit) {
delay(300)
vm.actionInit()
}

稳定的类

不要在状态持有类中使用 var 修饰属性

当属性是可变的,但不通知composition ,这会导致使用它们的可组合项变得不稳定。

应该这样写:

1
data class InherentlyStableClass(val text: String)

不应该这样写:

1
data class InherentlyUnstableClass(var text: String)

尽可能拆分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun ProfileScreen(user: User) {
ProfileHeader(user = user)
ProfileContent(user = user)
}

@Composable
fun ProfileHeader(user: User) {
Text("Hello, ${user.name}")
}

@Composable
fun ProfileContent(user: User) {
Text("Your email is ${user.email}")
}

在这个例子中,ProfileScreenProfileHeaderProfileContent 组件拆分开来。

当用户数据更新时,Compose 仅重组更新的组件,而不会影响其他不相关的组件。

如果写在一个组件中会触发整体的重组。

使用remember

remember 是 Compose 中非常重要的一个工具,它可以帮助开发者在重组期间保存组件的状态。

通过将状态存储在 remember 中,我们可以避免在每次重组时重新创建不必要的对象。

示例代码:

1
2
3
4
5
6
7
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}

状态优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 缺点:每次重新组合时都重新计算
val filteredItems = items.filter { it.userId == user.id }

// 好:使用记忆缓存计算
val cachedItems = remember(items, user.id) {
items.filter { it.userId == user.id }
}

// 更好的方式:使用 derivedStateOf 进行反应式计算
val reactiveItems by remember(items) {
derivedStateOf {
items.filter { it.userId == user.id }
}
}

key

key 用于在列表、循环或动态组件中标记组件的唯一性,类似 RecyclerView 中的itemId

key 参数值未变化,即使父组件重组,子组件也会被尽可能复用(保留状态和内部状态)。

key() 的值变化时:

  • 直接子组件会被销毁并重建(必然重组) (执行其 onDispose 逻辑)。
  • 该子组件的所有后代组件(子组件的子组件)也会随之重组,无论其参数是否变化。

key() 的核心作用是 “控制其包裹范围是否因 key 变化而整体重组”,但无法阻止组件因 “自身输入参数变化” 或 “依赖的 State 变化” 而触发的重组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
fun ProductList(products: List<Product>) {
LazyColumn {
items(products, key = { it.id }) { product ->
ProductItem(product = product)
}
}
}

@Composable
fun ProductItem(product: Product) {
var isInCart by remember { mutableStateOf(false) }

Column {
Image(painter = rememberImagePainter(product.imageUrl), contentDescription = null)
Text(product.name)
Text("Price: ${product.price}")
Button(onClick = { isInCart = !isInCart }) {
Text(if (isInCart) "Remove from Cart" else "Add to Cart")
}
}
}

预编译 ProfileInstaller

https://developer.android.google.cn/jetpack/androidx/releases/profileinstaller?hl=zh-cn

这种方式能大幅提升程序的运行性能,效果非常明显。

ART 默认情况下,并没有把 Compose 的核心代码进行AOT(预编译),而是JIT(即时编译)执行。这就要命了,像 Compose 底层的 Snapshot 系统、Slot Table,都是热点代码,短时间内会被频繁调用,JIT 根本无法满足 Compose 的性能要求。

ProfileInstaller为什么会提升性能

本来应用在启动的时候系统会进行预编译,但是在国内修改的系统中,不会进行预编译。

ProfileInstaller能触发程序的预编译,并且在应用安装后、首次启动前,提前将预编译好的 Profile 注入到应用中,跳过动态生成步骤,直接复用预编译配置。

ProfileInstaller 依赖 Android 8.0 以上的私有目录权限和文件系统特性,低于 API 26 的设备无法使用。

添加依赖

1
implementation("androidx.profileinstaller:profileinstaller:1.4.1")

引用初始化的时候添加

在Application中添加

1
2
3
if (!BuildConfig.DEBUG) {
ProfileInstaller.writeProfile(this)
}

注意

要在release环境中调用,debug调用程序会崩溃。

状态和稳定性

在 Jetpack Compose 中,“状态变化” 是触发重组的直接原因,但 “稳定性” 决定了重组的范围和效率—— 两者并非孤立,而是协同影响 Compose 的渲染行为。

状态变化是重组的 “触发器”

Compose 的核心是 “状态驱动 UI”,只有当可观察状态(如 mutableStateOfStateFlow 等)发生变化时,才会主动触发重组流程

稳定性的作用:决定 “重组会影响哪些子组件”

当状态变化触发重组后,Compose 需要判断:哪些子组件需要跟着重组?
这正是 “稳定性” 发挥作用的地方 —— 它决定了 Compose 对 “参数是否真的变化” 的判断,从而避免不必要的重组扩散。

重组的前提

Compose 之所以能实现 “状态驱动的局部更新”,本质是通过对比组件的 “输入参数” 和 “依赖状态” 来判断是否需要重组:

  • 当组件的输入参数(如函数参数、remember 缓存的值)或依赖的 State(如 mutableStateOf)发生变化时,Compose 会认为 “组件需要更新”,触发重组;
  • 若输入参数和依赖状态未变,Compose 会跳过重组,直接复用之前的渲染结果。

Compose 都是函数,是否重组是由参数决定的。

在 Jetpack Compose 中,函数参数为数字(如 IntLong)或字符串(String)、或者对象时,

其值变化是否触发重组,取决于两个核心条件:

  • 参数是否为可跟踪的状态 或者是可跟踪状态的委托
  • 值的实际内容是否发生变化

核心结论

  • 若参数是“普通变量”(非状态类型)
    即使值变化,也不会触发重组。因为 Compose 不会跟踪普通变量的变化,仅依赖“状态驱动”机制。

  • 若参数是“可跟踪的状态”(如 State<T> 包装)
    当值的实际内容发生变化时,会触发重组;若值未变化(如从 5 变为 5),则不会触发重组。

详细分析

参数是普通变量(非状态)

普通的数字/字符串变量(未用 mutableStateOf 等包装)属于“不可跟踪”的参数。

即使值变化,Compose 感知不到,因此不会触发重组

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun NumberDemo(count: Int) {
Text("计数:$count")
}

// 调用处
var normalCount = 0
NumberDemo(count = normalCount)

// 普通变量值变化,不会触发 NumberDemo 重组
normalCount = 1 // 界面无更新

参数是可跟踪的状态

参数是可跟踪的状态(State<T>

当参数是 State<Int>State<String> 等可跟踪类型时,Compose 会自动监听其变化:

  • 值的内容确实变化(如 34"a""b"),则触发重组;
  • 值未变化(如 55"test""test"),则不触发重组(优化机制)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun StateNumberDemo(count: State<Int>) {
Text("计数:${count.value}")
}

// 调用处
var stateCount by remember { mutableStateOf(0) } // 可跟踪的状态
StateNumberDemo(count = stateCount)

// 情况1:值变化 → 触发重组
stateCount = 1 // 界面更新为"计数:1"

// 情况2:值不变 → 不触发重组
stateCount = 1 // 无变化,不重组

参数是状态的“解包值”

在 Compose 中,当使用 by 关键字解包 State(如 var count by mutableStateOf(0)),变量 count 本质仍是可跟踪状态的“代理”。

此时将其作为参数传递,效果与传递 State<T> 一致:值变化时触发重组。

1
2
3
4
5
6
7
8
9
10
@Composable
fun UnpackedDemo(count: Int) { // 参数是解包后的 Int,但来源是 State
Text("计数:$count")
}

// 调用处
var unpackedCount by remember { mutableStateOf(0) } // 解包的状态
UnpackedDemo(count = unpackedCount)

unpackedCount = 2 // 触发重组,界面更新

稳定性

@Immutable和@Stable

Jetpack Compose 中的稳定性(Stability)

Compose 编译器通过分析对象的稳定性来决定是否跳过重组(Recomposition)。

如果一个对象被认为是“稳定”的,那么当它作为参数传入到 Composable 函数中时,只要引用没有变化,Compose 就可以跳过对该函数的重组。

Compose 编译器会根据以下规则判断一个类是否稳定:

判断条件 是否稳定
所有属性是 val,且类型稳定 ✅ 是(默认 @Immutable)
使用了 @Stable 注解 ✅ 视为稳定
包含 mutableStateOf 或其他可观察类型 ✅ 可追踪变化
属性是 var,未使用任何注解 ❌ 不稳定

@Immutable

表示这个类是完全不可变的(immutable),所有属性都是 val 且不会改变。

Compose 编译器会认为这样的对象在整个生命周期内都不会发生变化。

特点: 所有字段必须是 val。 推荐用于数据模型类(如:用户信息、配置等)。 适用于纯数据类,比如 Kotlin 的 data class。 可以安全地跳过重组,提高性能。

1
2
@Immutable 
data class User(val id: Int, val name: String)

@Stable

表示这个类是“稳定的”,但允许包含可变字段(如 var),前提是这些字段的变化不会影响 UI 的状态,或者你已经通过 Compose 的响应式机制(如 mutableStateOf)管理了其变化。

特点: 允许使用 var。 需要开发者自己保证类的行为符合“稳定性”要求。 如果某个字段确实会变化但你不希望触发重组,可以用 @Stable 告诉 Compose 忽略它。

如果你想让某些字段变化也能触发重组,可以在这些字段中使用 mutableStateOf()。

1
2
3
4
5
6
@Stable 
class Counter {
var name:String ="abc"
var count by mutableStateOf(0)
private fun increment() { count++ }
}

countvar,但它是一个 mutableStateOf,Compose 能追踪它的变化并触发重组。
整个 Counter 类被标记为 @Stable
Compose 认为它是稳定的,不会因为 name 改变而进行重组,因为 Compose 不会追踪它的变化,
除非 count 改变,否则不会重组。