Jetpack Compose 性能瓶颈排查与优化实战

前言

Jetpack Compose 以声明式 UI 大幅提升了开发效率,但不合理的状态读取与组合方式极易触发非必要重组(Recomposition),导致 UI 卡顿或帧率下降。
了解 Compose 的重组机制是性能优化的第一步:每次状态(State)发生变化时,读取了该状态的 Composable 函数都会被重新执行。
如果一个父级 Composable 读取了频繁变化的状态,其所有子树都可能随之重组,即使子树的输入并未改变。
本文将从工具排查到代码优化,系统地介绍如何定位并解决 Compose 的性能瓶颈。

依赖

如果你只是想在 Android Studio 中使用 Layout Inspector 查看 Compose 组件树,应用本身首先需要是可调试的 debug 构建。
ui-tooling 通常推荐加入 debugImplementation,因为它能补齐不少 Compose 开发期调试能力,但并不是所有 Layout Inspector 场景下都绝对必需。
ui-test-manifest 则是 Compose UI 测试相关依赖,不是 Layout Inspector 的必需项。

以下是在 app/build.gradle.kts 中更稳妥的一组依赖配置(版本按项目实际调整):

1
2
3
4
5
6
7
8
9
dependencies {
// Compose 核心,版本按 BOM 统一管理
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")

// 调试构建中推荐加入,便于预览和开发期调试
debugImplementation("androidx.compose.ui:ui-tooling")
}

如果项目还会编写 Compose UI instrumentation test,再额外补充下面这项测试依赖即可:

1
debugImplementation("androidx.compose.ui:ui-test-manifest")

排查

使用 Layout Inspector 观察重组次数

Android Studio 内置的 Layout Inspector 是 Compose 性能排查里最实用、也最常用的方式。
它可以在运行时直接显示每个 Composable 的重组次数与跳过次数,足够覆盖大多数日常开发中的性能定位场景。
连接真机或模拟器后,打开顶部菜单 Tools > Layout InspectorApp Inspection,勾选 Show Recomposition Counts,即可在组件树旁看到 RecompositionsSkips 两列数字。

排查时优先看两类现象。
一类是某个列表项、顶部栏或卡片在用户没有明显操作时,Recompositions 还在持续递增。
另一类是 Skips 长时间为 0,说明这个节点几乎没有被成功跳过,通常意味着参数不稳定、状态读取范围过大,或者存在重复创建对象的问题。

最常见的实战步骤是先进入页面执行一次滚动、输入或点击操作,再观察哪一层 Composable 的计数增长最快。
如果发现一个父级节点一动就整片子树都在重组,通常就该优先检查状态是不是读得太靠上了。

优化

下移状态读取,缩小重组范围

将状态读取尽量下移到真正需要它的最小子 Composable,是减少重组范围最直接有效的方法。
以下示例展示了错误与正确写法的对比——错误写法中父节点读取了滚动偏移,导致整个父树随滚动高频重组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 错误:父 Composable 读取 scrollState.value,整个父树高频重组
@Composable
fun BadExample() {
val scrollState = rememberScrollState()
Column(modifier = Modifier.verticalScroll(scrollState)) {
HeaderWithOffset(offset = scrollState.value)
HeavyContent()
}
}

// 正确:将状态读取下移到 Header 内部,HeavyContent 不再参与重组
@Composable
fun GoodExample() {
val scrollState = rememberScrollState()
Column(modifier = Modifier.verticalScroll(scrollState)) {
Header(scrollState = scrollState)
HeavyContent()
}
}

@Composable
fun Header(scrollState: ScrollState) {
val offset = scrollState.value // 只有 Header 重组
Text(text = "Offset: $offset")
}

使用 derivedStateOf 避免冗余重组

当一个 Composable 只关心从状态派生出的某个结果(而非状态原始值的每次变化)时,应使用 derivedStateOf 包裹计算逻辑。
以下示例中,只有当 showButton 的布尔结果发生变化时,才触发重组,而不是每次滚动偏移改变都重组:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun ScrollToTopButton() {
val scrollState = rememberScrollState()
// derivedStateOf:只有 showButton 结果变化才触发重组
val showButton by remember {
derivedStateOf { scrollState.value > 300 }
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, contentDescription = null)
}
}
}

使用 key() 稳定列表项身份

LazyColumn / LazyRow 等懒加载列表中,如果不为每个列表项指定 key,Compose 会按位置而非内容来判断项目的身份,列表插入或删除时极易触发大规模重组。
为每个列表项提供稳定唯一的 key 后,Compose 能精确复用未变化的项:

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(
items = messages,
key = { message -> message.id } // 用唯一 ID 作为 key
) { message ->
MessageItem(message = message)
}
}
}

用 @Stable 与不可变类型减少不必要重组

Compose 编译器会对参数类型进行稳定性推断:若参数类型被标记为稳定(Stable),且参数值未变化,则该 Composable 可以被跳过(Skip)重组。
对于自定义数据类,推荐使用 data class 配合 @Immutable@Stable 注解,或使用 kotlinx.collections.immutable 的不可变集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList

// 标记为 Immutable,让 Compose 编译器信任该类型不会在外部被修改
@Immutable
data class UserProfile(
val id: String,
val name: String,
val avatarUrl: String
)

// 使用不可变列表,避免 List<T> 被推断为 Unstable
@Composable
fun UserList(users: ImmutableList<UserProfile>) {
LazyColumn {
items(users, key = { it.id }) { user ->
UserItem(profile = user)
}
}
}

避免在 Composable 内直接创建 Lambda

每次重组时,直接在 Composable 函数体内用 {} 创建的 lambda 都是新对象,会导致子 Composable 无法跳过重组。
使用 remember 包裹 lambda,或将其提升到 Composable 外部,可以保持引用稳定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun ItemList(onItemClick: (String) -> Unit) {
val items = listOf("A", "B", "C")
LazyColumn {
items(items) { item ->
// 错误:每次重组都创建新 lambda
// ItemCard(onClick = { onItemClick(item) })

// 正确:用 remember 缓存 lambda,key 为 item 本身
val onClick = remember(item) { { onItemClick(item) } }
ItemCard(onClick = onClick)
}
}
}

将大型 Composable 拆分为独立子函数

将 UI 拆分为职责单一的小 Composable,每个函数只读取自己所需的最小状态集合,是控制重组粒度的基础手段。
下面展示了如何将一个庞大的 ProfileScreen 拆分为独立的子模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 拆分前:所有状态变化都会导致整个 ProfileScreen 重组
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val uiState by viewModel.uiState.collectAsState()
Column {
Text(uiState.name)
Image(uiState.avatarUrl)
PostList(uiState.posts)
CommentSection(uiState.comments)
}
}

// 拆分后:各子 Composable 只在自己关心的数据变化时重组
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val uiState by viewModel.uiState.collectAsState()
Column {
ProfileHeader(name = uiState.name, avatarUrl = uiState.avatarUrl)
PostList(posts = uiState.posts)
CommentSection(comments = uiState.comments)
}
}

验证

完成优化后,通过以下方式确认效果。

  1. 重新打开 Layout Inspector,对比优化前后关键 Composable 的 Recompositions 计数是否明显下降。
  2. 观察 Skips 是否增加,这通常说明 Compose 已经能跳过更多没有实际变化的节点。
  3. 在页面执行一次典型操作,例如滚动列表、切换筛选、输入搜索词,再确认是否只剩下必要区域发生重组。
  4. 最后回到真机或模拟器上实际操作页面,确认卡顿、掉帧或输入延迟是否明显缓解。

总结

排查和优化 Compose 性能的核心思路可以归纳为以下步骤:

  1. 用 Layout Inspector 定位重组次数异常的 Composable。
  2. 通过下移状态读取,将高频变化的状态与稳定的 UI 树隔离。
  3. 对派生状态使用 derivedStateOf,避免因原始状态高频变化引发冗余重组。
  4. 为列表项指定稳定的 key,保证列表增删时的精准复用。
  5. @Immutable / @Stable 标注数据类,并使用不可变集合,提升编译器对类型稳定性的推断精度。
  6. remember 稳定 lambda 引用,避免每次重组创建新的函数对象。

注意事项:优化应以实测数据为依据,不要在没有性能问题的地方过度使用 rememberderivedStateOf,这些 API 自身也有一定开销;在真机(尤其是低端机)上进行测试,比模拟器更能反映真实情况。