上一篇 下一篇 回到顶部 目录 返回首页
目录

Jetpack Compose 性能优化最佳实践:从原理到实战

发表于
更新于
37 29.4~37.8 分钟 13218

前言

Jetpack Compose 自 1.9.0 版本起,在真实场景中的卡顿率已经与传统 View 系统持平(0.21% jank rate)。但"能用"不等于"用好" — 编写不当的 Compose 代码会导致不必要的重组、跳过失效和严重的 UI 卡顿。本文将从 Compose 的三阶段原理出发,系统梳理性能优化的完整知识体系:最佳实践、稳定性分析、诊断工具和编译优化。


第一部分:理解三阶段模型

1.1 Composition → Layout → Drawing

Compose 处理 UI 更新经过三个不同的阶段:

阶段

做什么

决定什么

Composition(组合)

执行可组合函数,构建 UI 树

显示什么

Layout(布局)

计算每个元素的精确尺寸和位置

在哪里、多大

Drawing(绘制)

将 UI 元素渲染到屏幕上

长什么样

Composition ──▶ Layout ──▶ Drawing
   (what)       (where)     (render)

1.2 阶段跳过是性能的核心

Compose 的优化核心在于智能跳过不需要的阶段

示例:如果一个图形只是在两个尺寸完全相同的图标之间切换,Compose 会识别到 UI 树结构和元素尺寸都没有变化,从而跳过组合和布局阶段,只执行绘制阶段。

但如果代码编写不当(如触发不必要的重组),Compose 会被迫每次都执行全部三个阶段,导致 UI 卡顿。

帮助 Compose 跳过阶段的关键策略

  • 将计算移出可组合函数体

  • 延迟状态读取(defer state reads)

  • 使用 lambda 修饰符


第二部分:六大最佳实践

2.1 用 remember 缓存昂贵计算

可组合函数可能每帧都执行(动画期间尤其频繁)。函数体内的重型计算会重复运行。

// ❌ 每次重组都重新排序
val sortedContacts = contacts.sortedWith(comparator)

// ✅ 只在 contacts 或 comparator 变化时重新计算
val sortedContacts = remember(contacts, comparator) {
    contacts.sortedWith(comparator)
}

黄金法则:将计算尽可能移到 UI 层之外(如 ViewModel)。remember 只是缓存,不是真正的性能优化。

2.2 在懒布局中提供稳定的 Key

没有 key 时,Compose 假设列表项在顺序变化时被删除/重建,触发完整重组。稳定唯一的 key 让 Compose 跟踪项身份,跳过未变项的重组。

// ❌ 无 key — 项顺序变化时全部重组
LazyColumn {
    items(notes) { note -> NoteRow(note) }
}

// ✅ 有 key — Compose 追踪项身份,跳过未变项
LazyColumn {
    items(notes, key = { note -> note.id }) { note ->
        NoteRow(note)
    }
}

Key 的要求

  • 稳定:同一数据项的 key 始终相同

  • 唯一:不同数据项的 key 不同

  • 可 Bundle 序列化:支持 rememberSaveable 状态恢复

2.3 用 derivedStateOf 节流快速变化的状态

频繁更新的状态(如滚动位置)会导致过度重组。derivedStateOf 告诉 Compose 只在派生结果真正变化时重组,而非底层状态每次变化都重组。

// ❌ 每次滚动偏移变化都触发重组
val showButton = listState.firstVisibleItemIndex > 0

// ✅ 只在 showButton 的布尔值变化时才重组
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

derivedStateOf 内部使用 == 比较新旧值,只有值真正改变时才触发重组。

2.4 延迟状态读取与 Lambda 修饰符

在子可组合函数中直接读取提升的状态会使最近的重组作用域失效,强制父元素重组。通过 lambda 传递状态可以延迟读取。

直接读取 — 触发父重组

// ❌ scroll.value 在组合阶段读取,导致父重组
Title(text, offsetY = scroll.value.dp)

Lambda 修饰符 — 跳过组合阶段

// ✅ 在布局阶段读取状态,跳过组合
Modifier.offset { IntOffset(x = 0, y = scrollProvider()) }

// ✅ 在绘制阶段读取状态,跳过组合和布局
Modifier.drawBehind { drawRect(color = state.value) }

Compose 提供了 lambda 版本的修饰符:Modifier.offset { }Modifier.drawBehind { }Modifier.graphicsLayer { }。这些修饰符将状态读取推迟到布局或绘制阶段,完全跳过了组合。

2.5 避免反向写入(Backwards Writes)

永远不要在组合阶段写入已经读取过的状态。Compose 会检测到陈旧读取,调度另一次重组,造成无限循环

// ❌ 无限重组:读取 count → 写入 count → 重新组合 → ...
Text("$count")
count++

// ✅ 仅在事件回调中更新状态
Button(onClick = { count++ }) { Text("Recompose") }

// ✅ 仅在副作用中更新状态
LaunchedEffect(condition) {
    if (condition) {
        state.value = newValue
    }
}

2.6 提取修饰符避免重复分配

将复杂的修饰符链提取为变量,避免在频繁重组时重复分配对象:

// 在 Compose 函数外部定义
val reusableModifier = Modifier
    .fillMaxWidth()
    .background(Color.Red)
    .padding(12.dp)
    .clip(RoundedCornerShape(8.dp))

@Composable
fun MyComponent() {
    Text("Hello", modifier = reusableModifier)  // 复用,无新分配
}

第三部分:稳定性(Stability)深度解析

3.1 什么是稳定性

稳定性决定 Compose 是否可以在重组期间安全跳过某个可组合函数。

类型

定义

例子

稳定(Stable)

不可变类型,或 Compose 能追踪变化的可变类型

IntStringMutableStateval 属性的 data class

不稳定(Unstable)

Compose 无法验证值是否在重组间变化的类型

var 属性、标准 List/Set/Map、外部模块类

3.2 稳定性对性能的影响

参数类型

重组行为

稳定且未变化

可组合函数被跳过

不稳定

可组合函数总是重组(即使父级状态未变)

// ✅ 稳定:不可变(val 属性)
data class Contact(val name: String, val number: String)

// ❌ 不稳定:可变(var 属性)
data class Contact(var name: String, var number: String)

传递不可变 Contact 时,当只有兄弟组件或内部状态变化时,子可组合函数可以跳过重组。使用 var 则强制重组,因为 Compose 无法保证对象状态没有发生变异。

3.3 常见不稳定触发器与修复

触发器

为什么不稳定

修复方式

var 属性

Compose 只追踪 MutableState,不追踪普通可变性

val + by remember { mutableStateOf() }

标准集合(ListSetMap

无法保证不可变性

使用 kotlinx.collections.immutable@Stable/@Immutable

外部/非 Compose 模块

Compose 编译器未分析其字节码

用 UI 模型类包装或添加注解

3.4 编译器元数据

Compose 编译器会附加元数据标记:

  • 函数skippable(参数匹配时可跳过)和 restartable(重组入口作用域)

  • 类型immutable(值永不变化,方法引用透明)和 stable(值可变,但 Compose 通过状态 API 显式通知)


第四部分:诊断稳定性问题

4.1 Android Studio Layout Inspector

可视化追踪每个可组合函数的重组次数与跳过次数。高重组次数配低跳过次数表明存在稳定性问题。

4.2 Compose 编译器报告

生成静态分析文件,详细列出哪些可组合函数和类是 restartableskippableunstable

配置

composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_compiler")
}

始终在 Release 构建上运行以获得准确结果。

关键输出文件

文件

内容

<module>-composables.txt/.csv

列出每个可组合函数的参数以及是否 restartable/skippable

<module>-classes.txt

报告类稳定性。ListSetMap 等接口通常标记为 unstable

metricsDestination

生成项目范围的统计信息(可组合函数数量和稳定性比例)

解读restartable skippable 是理想状态(参数未变时 Compose 跳过该函数)。如果缺少 skippable,很可能是不稳定的参数强制了不必要的重组。

4.3 Composition Tracing

在系统追踪(perfetto/systrace)中追踪可组合函数。这通常是性能调试的最佳起点,帮助你快速形成关于根本原因的假设,并精确缩小需要优化的可组合函数或阶段范围。


第五部分:修复稳定性问题

5.1 启用 Strong Skipping Mode(推荐第一步)

Strong Skipping 是 Compose 编译器优化,Kotlin 2.0.20+ 默认启用。它允许 Compose 跳过重组即使参数不稳定。

Kotlin 2.0.20 之前启用方式

android {
    composeCompiler {
        enableStrongSkippingMode = true
    }
}

Strong Skipping 的核心行为

行为

默认模式

Strong Skipping 模式

可跳过性

仅当所有参数 @Stable

所有可重启函数都可跳过

不稳定参数比较

不适用

实例相等性(===

稳定参数比较

.equals()

.equals()

Lambda 记忆化

手动 remember

自动包裹 remember

退出机制

@NonSkippableComposable

Lambda 退出机制

@DontMemoize

影响

  • 性能:显著增加重组期间跳过的可组合函数数量

  • APK 大小:影响可忽略(大型示例应用 Now In Android 约增加 4kB)

5.2 使类真正不可变

Compose 在类不可变时标记为稳定。确保:

  • 所有属性是 val(不是 var

  • 属性类型是原始类型(IntStringFloat)或其他不可变类型

  • 对于可变 UI 状态,使用 Compose state:val count by mutableStateOf(0)

5.3 使用不可变集合

标准 Kotlin 集合默认被标记为不稳定

// ❌ 不稳定
val tags: Set<String>

// ✅ 稳定
val tags: ImmutableSet<String> = persistentSetOf()

5.4 使用 @Stable@Immutable 注解

你可以覆盖编译器推断,但这是契约而非修复

@Immutable
data class Snack(val id: Long, val name: String)

警告:这些注解不会神奇地使类稳定。如果类发生变异或依赖外部状态,误用它们会导致重组静默出错。

5.5 处理不稳定的集合参数

即使 Snack@ImmutableList<Snack> 仍然不稳定。解决方案:

使用不可变集合

snacks: ImmutableList<Snack>

包装在稳定的类中

@Immutable
data class SnackCollection(val snacks: List<Snack>)

5.6 使用稳定性配置文件(Stability Configuration File)

Compose Compiler 1.5.5+ 支持在不修改源码的情况下将外部类标记为稳定:

# stability_config.conf
java.time.LocalDateTime
com.datalayer.*
com.datalayer.**
composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

5.7 解决多模块稳定性问题

编译器只为 Compose 编译器编译的模块推断稳定性。跨模块问题的修复方式:

  1. 将类添加到 stability_config.conf

  2. 在 data/domain 模块上启用 Compose 编译器(需要 Compose runtime 依赖)

  3. 用 UI 特定的 @Stable/@Immutable 包装器包装 data class

5.8 不要过度优化

不是每个可组合函数都需要可跳过。强制可跳过会增加开销并使维护复杂化。避免优化

  • 很少/从不重组的可组合函数

  • 只调用已经可跳过的子元素的函数

  • 参数很多且 equals() 检查成本超过重组本身的函数


第六部分:基准测试与工具

6.1 在 Release 模式下测试

Debug 构建携带大量开销。始终在 Release 模式下用 R8 优化和压缩进行基准测试。

6.2 Hero Benchmarks

Hero 基准测试测量高级真实用户旅程(启动和滚动),建立 Compose 对比传统 View 系统的官方性能基线。

测试设置

方面

配置

应用

Pokedex(View 版 vs Compose 版)

设备

Pixel 3a, Android 12 (API 31),锁定 CPU/GPU 时钟

构建

Release 模式,R8 启用,完全预编译

关键结果

  • 冷启动:Compose 1.11 比 View 慢 2.5%(TTID)/ 13.0%(TTFD)

  • 滚动卡顿率:自 Compose 1.9.0 起,Compose 与 View 完全一致,卡顿率 0.21%(约 485 帧出现 1 帧卡顿)

6.3 Baseline Profiles

Baseline Profiles 通过从首次启动起将代码执行速度提升约 30% 来优化性能。它们识别关键代码路径,允许 Android Runtime (ART) 在安装期间**提前编译(AOT)**这些路径,绕过运行时较慢的解释和即时编译(JIT)。

为什么对 Compose 很重要

  • Compose 作为独立库分发,而非内置于 Android 平台

  • 首次使用时需要加载和 JIT 编译,可能导致启动延迟和 UI 卡顿

  • Baseline Profile 通过预编译消除这个开销

默认 vs 自定义 Profile

  • 默认:Compose 自带内置的 baseline profile,优化核心 Compose 库代码

  • 自定义:Google 强烈建议生成针对应用特定关键用户旅程的自定义 profile

实现方式:使用 Macrobenchmark 库追踪最重要的流程并生成 profile。始终编写 Macrobenchmark 测试来验证 profile 确实减少了启动时间和卡顿。


第七部分:性能优化实战清单

7.1 代码层面

  • [ ] 用 remember 缓存昂贵计算,最好移到 ViewModel

  • [ ] LazyList/Grid 中始终提供稳定的 key

  • [ ] 快速变化状态用 derivedStateOf 节流

  • [ ] 动画/滚动值用 lambda 修饰符延迟读取

  • [ ] 避免反向写入 — 只在事件回调或副作用中更新状态

  • [ ] 提取修饰符链为变量,避免重复分配

  • [ ] 数据类使用 val 属性

7.2 稳定性层面

  • [ ] 启用 Strong Skipping Mode(Kotlin 2.0.20+ 默认开启)

  • [ ] 列表参数使用 ImmutableList 替代 List

  • [ ] 外部类通过 stability_config.conf 标记稳定

  • [ ] 用 @Stable/@Immutable 注解确保类型稳定

  • [ ] 用 Compose 编译器报告诊断不稳定的类

7.3 构建层面

  • [ ] 在 Release + R8 模式下进行性能测试

  • [ ] 配置 Baseline Profile 优化首启动和关键路径

  • [ ] 用 Macrobenchmark 验证优化效果

7.4 诊断层面

  • [ ] 用 Layout Inspector 查看重组/跳过计数

  • [ ] 用 Composition Tracing 定位性能瓶颈

  • [ ] 用编译器报告(reportsDestination)分析 skippable/restartable 状态

  • [ ] 只在出现性能瓶颈时诊断,避免过早优化


结语

Compose 性能优化的核心原则可以概括为一句话:

让 Compose 做它最擅长的事 — 跳过不需要的阶段。

具体策略:

  1. 减少重组rememberderivedStateOf、稳定 key、反向写入避免

  2. 启用跳过:稳定参数、Strong Skipping、不可变集合

  3. 延迟读取:lambda 修饰符、绘制阶段状态

  4. 预编译优化:Baseline Profiles、Release 构建测试

  5. 诊断先行:编译器报告、Layout Inspector、Composition Tracing

建议先在项目中启用 Strong Skipping Mode(如果还没有),然后逐步应用上述最佳实践,用诊断工具验证每一步的优化效果。性能优化不是一蹴而就的,而是持续的过程。