在 Compose 声明式 UI 中,性能优化的核心理念是:尽可能地跳过不必要的重组。如果优化不当,微小的状态改变可能引起整个页面的大规模重构,从而导致卡顿。本文将介绍在业务开发中如何进行Compose性能优化。
1、缩小重组范围:抽取独立组件
Compose 的重组范围是基于读取状态的最小作用域(通常是最近的 @Composable 函数)。如果在一个庞大的组件中读取了某个频繁变化的状态,会导致整个大组件发生重组。将读取状态的部分抽取成独立的 @Composable 函数,可以有效缩小重组范围。
反面教材:
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
// 假设 name 是一个很少变化的状态,而 scrollOffset 是一个高频变化的状态
val name by viewModel.name.collectAsState()
val scrollOffset by viewModel.scrollOffset.collectAsState()
Column {
Text("Hello, $name") // 静态内容
// ... 大量其他静态 UI ...
// 仅仅因为这里读取了 scrollOffset,当它变化时,整个 ProfileScreen 都会重组!
Text("Scroll Offset: $scrollOffset")
}
}
正确做法:
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val name by viewModel.name.collectAsState()
Column {
Text("Hello, $name")
// ... 大量其他静态 UI ...
// 将读取高频状态的 UI 抽取为独立的 Composable
ScrollOffsetDisplay(viewModel)
}
}
@Composable
fun ScrollOffsetDisplay(viewModel: ProfileViewModel) {
// 状态读取被限制在这个小组件内部
// 当 scrollOffset 变化时,只有 ScrollOffsetDisplay 会重组,ProfileScreen 不会!
val scrollOffset by viewModel.scrollOffset.collectAsState()
Text("Scroll Offset: $scrollOffset")
}
- 内联函数不能作为最小重组范围,内联函数在编译期间会在调用处展开,重组的时候无法找到合适的调用入口。
- 有返回值的函数不能作为最小重组范围,返回值需要在调用处使用,必须连同调用方一起重组。
- 为了提升重组性能,可以将重组的组件放到一个空的非内联函数中。
2、驯服 "不稳定" 的状态
Compose 跳过重组的前提是:所有的参数都必须是稳定(Stable)的。
什么是稳定的?基本类型(Int, String)、被 @Stable 或 @Immutable 标记的类。
什么是不稳定的?所有的集合接口(List, Map,因为 Compose 无法确定实现类是否可变)、var 属性暴露的普通类。
优化方案:
- 使用只读集合:不要传
List<T>,使用 Kotlinx Immutable 库的ImmutableList<T>。 - 给数据类打标签:如果你确定一个 UI State 类在创建后不会内部修改,给它加上
@Immutable。@Immutable // 告诉编译器:信任我,里面的数据不会变! data class UserState(val name: String, val tags: List<String>) - 解构传递:不要把整个巨大的
ViewModel或Context传给底层的叶子节点。只要你需要其中的一个id,那就只传id: String。
3、状态下放
这是 Compose 优化中最重要、最有效的一条规则! 如果一个状态仅仅是为了改变 UI 的绘制阶段(比如背景颜色、位移、透明度、滚动偏移量),千万不要在重组阶段读取它!
反面教材:
// scrollState.value 改变时,会导致整个 Column 重组!
val offset = scrollState.value
Column(Modifier.offset(y = offset.dp)) { ... }
正确做法 (Lambda 延迟读取):
// Modifier.offset 接受一个 Lambda。
// state 的读取被推迟到了 Layout 阶段。
// scrollState 变化时,Column 不会发生重组 (Recomposition),只执行 Layout/Draw!性能提升巨大!
Column(Modifier.offset { IntOffset(0, scrollState.value.roundToInt()) }) { ... }
4、derivedStateOf:过滤高频状态
当你从一个高频变化的状态(比如列表滚动距离 scrollOffset)派生出一个低频变化的状态(比如是否显示“回到顶部”按钮 showFab)时,必须使用 derivedStateOf。
反面教材:
// scrollOffset 每次微小的改变(每秒几十次),都会导致重组
val showFab = scrollState.value > 1000
if (showFab) { Fab() }
正确做法:
// 只有当 showFab 的布尔值发生真正的反转(false -> true 或 true -> false)时,才会触发重组
val showFab by remember {
derivedStateOf { scrollState.value > 1000 }
}
if (showFab) { Fab() }
5、列表优化:LazyColumn & key
长列表是卡顿的重灾区。
- 必须提供
key:如果不提供key,当在列表顶部插入一条数据时,Compose 会把下面所有的数据全部推倒重做。提供了唯一key,Compose 就能聪明地只是将已有节点向下平移。LazyColumn { items(items = userList, key = { user -> user.id }) { user -> UserRow(user) } } contentType(Compose 1.2+):如果列表中有多种样式的卡片,指定contentType可以让 Compose 更加高效地在底层的组件池中复用相同类型的节点,极大地减少滑动时的创建开销。
6、remember 缓存昂贵的计算
如果必须在 Composable 中进行一些计算(比如过滤列表、格式化时间),一定要用 remember 把结果缓存起来。如果不包裹 remember,每次重组都会重新执行这部分计算逻辑。
反面教材:
@Composable
fun FilteredList(users: List<User>, keyword: String) {
// 每次上层重组,或者仅仅是动画发生,都会重新遍历过滤整个 List!
val filteredUsers = users.filter { it.name.contains(keyword) }
LazyColumn { ... }
}
正确做法:
@Composable
fun FilteredList(users: List<User>, keyword: String) {
// 只有当 users 或 keyword 发生变化时,才会重新执行过滤计算
val filteredUsers = remember(users, keyword) {
users.filter { it.name.contains(keyword) }
}
LazyColumn { ... }
}
7、避免频繁改变 Modifier 实例
在老版本的 Compose 中,Modifier 的创建和比较是有开销的,特别是在动画中。虽然现在的 Compose 编译器(Modifier Node 架构)优化了这部分,但在写动画时,依然应该优先使用基于 Lambda 的 Modifier 动画,而不是基于值的动画来重建 Modifier。
反面教材:
// 每一帧都会产生一个新的 Modifier 实例,导致不必要的 Recomposition 或 Layout
val padding by animateDpAsState(if (selected) 16.dp else 0.dp)
Box(modifier = Modifier.padding(padding))
推荐做法 (针对复杂/高频动画):
如果这是一个非常高频的自定义 Layout 或 Graphics 动画,尽量使用 Modifier.layout { ... } 或 Modifier.drawBehind { ... },将状态读取放在 Lambda 内部,避免重建 Modifier 链。
8、避免在 Composable 中执行耗时操作
Composable 函数必须是无副作用且极速的。 绝对不要在组件中直接进行:
- 解析大型 JSON
- 读写 SharedPreferences / DataStore
- 复杂的过滤和排序计算(应该放在 ViewModel 或放在
remember(xxx) { ... }中)