Compose 性能分析:Layout Inspector 之外还有什么
Compose 性能分析:Layout Inspector 之外还有什么
Layout Inspector 在 Android Studio 里挂了太多年了,从传统 View 时代就是性能分析的标配。Compose 出来以后,Google 给它加了 Compose 节点树的支持,能看到重组次数、Modifier 链这些新东西。但真到了线上性能问题排查,或者复杂列表卡顿的场景,Layout Inspector 的局限性很快就暴露出来。它是个静态快照工具,抓的是某一帧的界面状态,对动态的性能波动、帧率抖动几乎无能为力。这篇文章想聊的是,过去一年里我在 Compose 性能调优中实际用到的其他手段,有些是官方工具链的新成员,有些是老工具的新用法,还有些是不得不自己造的轮子。
Layout Inspector 的盲区:重组计数到底准不准
先说说 Layout Inspector 里那个最唬人的数字——重组次数(Recomposition counts)。Android Studio Flamingo 版本开始,Layout Inspector 会在 Compose 节点旁边显示重组计数,红色高亮表示频繁重组。这个功能看起来直观,实际用起来有几个坑。
第一个坑是采样粒度。Layout Inspector 的重组计数基于 Composition 的局部重计算,但它不会告诉你这次重组是 skippable 还是 mandatory。Compose 编译器生成的代码里,很多函数会被标记为 @Composable contract(pure=true),理论上参数不变就能跳过重组。但 Layout Inspector 只是粗暴地计数,不区分跳过和实际执行。我曾经遇到过一个场景:一个复杂的 Dashboard 卡片,Layout Inspector 显示重组了 50 多次,但用自定义的 RecompositionHighlighter 一测,实际执行 body 的只有 3 次,其余都是编译器生成的 equality check 快速返回。这个数字误导性很强,如果按 Layout Inspector 的指示去优化,可能会把精力浪费在根本不存在的问题上。
第二个坑是跨进程的延迟。Layout Inspector 通过 ADB 抓 dump,本身就有几百毫秒的开销。在抓取的瞬间,Compose 可能正处于一次重组的中间状态,或者刚刚完成一次 measure/layout 周期。你看到的数字和实际用户看到的帧,时间轴上并不对齐。对于 60fps 来说,16ms 就是一帧,Layout Inspector 的时间分辨率远远不够。
所以我的习惯是,Layout Inspector 只看结构,不看数字。结构信息还是有价值的,比如 Modifier 链的展开、intrinsic size 的计算结果、自定义 Layout 的约束传递。但涉及到性能量化,必须上别的工具。
Composition Tracing:编译器插桩的真相
Android Studio Hedgehog 开始,Compose 编译器支持生成 composition tracing 信息,配合 Perfetto 可以拿到完整的重组时间线。这是目前官方工具链里,对重组分析最精确的手段。
开启方式是在模块级 build.gradle 里加:
android {
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
buildFeatures {
compose true
}
}然后需要显式开启 tracing:
// debug 包默认开启,release 需要加这个
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.runtime.LocalInspectionMode provides false
) {
// 实际 UI
}等等,这里有个版本差异。Compose Compiler 1.5.0 之前,tracing 是实验性功能,需要 androidx.compose.compiler.plugins.kotlin:compiler:1.4.8 配合 ComposeFeatureFlag.OptimizeNonSkippingGroups 才能稳定输出。1.5.0 以后才正式转正。我去年维护的一个项目卡在 Compose Compiler 1.4.7,因为 Kotlin 版本升级牵扯太多,为了用这个 tracing 功能,单独开了一个分支做编译器升级,花了两天时间处理由此引发的 KSP 版本不兼容问题。这是真实的时间成本。
Perfetto 抓出来的 trace 里,Compose 相关的 slice 会挂在 Choreographer#doFrame 下面,能看到每个 RecomposeScopeImpl 的 performRecompose 耗时。关键看两个指标:一是 performRecompose 本身的执行时间,二是它和 measure/layout/draw 的相对位置。如果 performRecompose 很长但 measure 很短,说明问题在业务逻辑或者状态计算;如果 measure 跟着暴涨,那得看是不是重组触发了过多的布局计算。
一个具体的案例:我们有个聊天界面,消息列表用 LazyColumn,每条消息里有富文本解析后的 AnnotatedString。Perfetto 里看到 performRecompose 只有 2ms,但紧接着的 measure 花了 12ms,直接掉帧。追下去发现是 Text 的 InlineTextContent 在每次重组时都重新 measure 了占位元素,而占位元素是个带圆角的头像,用了 GenericShape 做自定义裁剪。GenericShape 的 createOutline 不走缓存,每次 measure 都重新计算 Path。把 GenericShape 换成预计算的 RoundedCornerShape 后,measure 降到 1ms 以内。这个问题 Layout Inspector 完全看不出来,因为它不展示 measure 阶段的细分耗时。
Systrace 的残余价值:不是完全被 Perfetto 取代
Google 官方说 Perfetto 取代了 Systrace,但 Systrace 在特定场景下还有不可替代的作用。主要是两个点:一是 Systrace 的 atrace 标签可以很方便地从 Native 代码或者 Shell 脚本里打,不需要改 App 代码;二是 Systrace 的 HTML 报告可以离线查看,不需要上传大文件到 ui.perfetto.dev。
我在一个低端机(Redmi 9A,Android 10,2GB RAM)上调试 Compose 性能时,发现 Perfetto 的录制本身就会导致严重的性能回退,抓出来的 trace 失真。换成 Systrace 的轻量模式(systrace.py --time=5 -o trace.html gfx view sched),对系统干扰小很多。虽然看不到 Compose 的详细重组信息,但能看到 RenderThread 的 drawFrame 是否超时、GPU 是否出现 waitForFences 阻塞。这些信息对于判断卡顿是 CPU bound 还是 GPU bound 很关键。
Compose 的 draw 阶段如果用了大量的 Canvas 自定义绘制,比如粒子效果或者波形图,GPU 侧的耗时在 Systrace 里看得很清楚。有一次用 DrawScope.drawPoints 画实时音频波形,在 Perfetto 里 draw slice 只有 3ms,但 Systrace 里看到 RenderThread 的 drawFrame 周期性跳到 20ms。深入发现是 drawPoints 生成的 Point 数组太大,导致 GPU 顶点着色器瓶颈。最后改用 drawLine 批量绘制,把顶点数从几千降到几百,问题解决。这个案例说明,不同工具的视角互补,不能迷信某一个。
Macrobenchmark 和 Baseline Profile:实验室数据怎么解读
Jetpack Macrobenchmark 1.2.0 以后对 Compose 的支持好了很多,特别是 FrameTimingMetric 和 CustomTimingMetric 可以精确测量特定 Compose 场景的帧率。但实验室环境和真实用户的数据差距,是个老问题,在 Compose 上尤其明显。
我们写了一个 Macrobenchmark 测试聊天列表的滚动性能:
@LargeTest
@RunWith(AndroidJUnit4::class)
class ChatScrollBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun scrollChatList() = benchmarkRule.measureRepeated(
packageName = "com.example.chat",
metrics = listOf(FrameTimingMetric(), CpuUsageMetric()),
iterations = 10,
setupBlock = {
pressHome()
startActivityAndWait()
// 等待列表加载
device.wait(Until.hasObject(By.res("chat_list")), 5000)
}
) {
val list = device.findObject(By.res("chat_list"))
list.setGestureMargin(device.displayWidth / 5)
list.fling(Direction.DOWN)
device.waitForIdle()
}
}这个测试在 Pixel 7 上跑,帧率稳定在 58-60fps,看起来很好。但 Firebase Performance Monitoring 的线上数据显示,同一代码在 Samsung A12 上滚动掉帧率(jank rate)达到 15%。差距来自几个方面:Macrobenchmark 默认关闭系统动画、关闭电池优化、CPU 锁定在高频,而真实用户的设备可能正在发热降频、后台有应用竞争、电池处于低电量模式。
更隐蔽的是,Macrobenchmark 的 CompilationMode.Partial() 模拟的是 Baseline Profile 优化后的状态,但 Baseline Profile 的实际覆盖率很难保证 100%。我们用 BaselineProfileGenerator 生成了 profile:
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(
packageName = "com.example.chat",
profileBlock = {
startActivityAndWait()
// 覆盖主要场景
device.findObject(By.res("chat_list")).fling(Direction.DOWN)
device.findObject(By.res("input_box")).click()
device.pressBack()
}
)
}生成的 baseline-prof.txt 有 8000 多行,但实际 Cloud Profile 下发到用户设备时,ART 只会选择性地编译其中一部分,取决于设备的存储空间、电量、使用频率。我们在内部测试机上用 adb shell cmd package compile --reset 和 adb shell cmd package compile --compile-layouts 对比过,完全 AOT 编译后的启动速度比 Cloud Profile 快 40%,但 Cloud Profile 是用户实际能拿到的优化水平。Macrobenchmark 的 CompilationMode.Partial() 更接近后者,但仍然是理想情况。
我的做法是,Macrobenchmark 数据只看相对趋势,比如优化前后的对比,或者两个实现方案的 A/B 测试。绝对数值参考意义有限。
自定义 Recomposition Logger:侵入式但必要
当官方工具链都够不到的时候,只能自己写埋点。Compose 的 Recomposer 和 Composition 有内部 API 可以反射访问,但稳定的做法是用 SnapshotFlow 和自定义的 Modifier 组合。
一个实用的技巧是给关键组件包一个调试用的 Modifier:
@Composable
fun Modifier.recompositionLogger(tag: String): Modifier {
if (!LocalInspectionMode.current && !BuildConfig.DEBUG) return this
val count = remember { AtomicInteger(0) }
return this.composed {
val scope = currentRecomposeScope
SideEffect {
Log.d("Recomposition", "$tag #${count.incrementAndGet()} at ${SystemClock.elapsedRealtime()}")
}
scope
this
}
}这个实现有个细节:currentRecomposeScope 是 Compose runtime 的内部 API,在 androidx.compose.runtime 包里,需要 @OptIn(InternalComposeApi::class)。它获取的是当前重组作用域,如果把它作为 remember 的 key,可以确保每次重组都触发 SideEffect。但这个做法在 Compose 1.6.0 以后有变化,currentRecomposeScope 的返回类型从 RecomposeScope 变成了 RecomposeScopeImpl,包名也变了。升级时需要跟着改。
更轻量的做法是用 DisposableEffect 跟踪生命周期:
@Composable
fun TrackRecomposition(tag: String) {
DisposableEffect(Unit) {
val start = SystemClock.elapsedRealtime()
Log.d("Lifecycle", "$tag composition started")
onDispose {
Log.d("Lifecycle", "$tag composition disposed after ${SystemClock.elapsedRealtime() - start}ms")
}
}
}这个只能看到 composition 的创建和销毁,看不到中间的重组。但对于检测内存泄漏很有用,比如 LazyColumn 的 item 在滑出屏幕后是否及时 dispose。我们曾经有个自定义的 VideoPlayer Composable,用了 AndroidView 嵌套 ExoPlayer,发现快速滑动时视频实例没有释放,内存暴涨。TrackRecomposition 加上 onDispose 里显式调用 exoPlayer.release() 才解决。Layout Inspector 能看到视图树里有多个 PlayerView,但不知道哪些是泄漏的、哪些是正常的缓存。
GPU 过度绘制:Compose 的新问题
传统 View 系统的过度绘制(Overdraw)在 Compose 里表现形式不同。Compose 的绘制是扁平化的,没有 View 的多层嵌套,但 Modifier.background、Modifier.border、Modifier.shadow 叠加起来,同样会造成同一个像素多次着色。
Android 的 GPU 过度绘制调试模式(开发者选项 -> 调试 GPU 过度绘制)对 Compose 仍然有效,但解读方式要调整。Compose 的 Canvas 绘制是记录在 DisplayList 里的,最终合并成一个 RenderNode,所以过度绘制的层级不会直接对应 Composable 的嵌套深度,而是对应 drawBehind、drawWithContent、drawOver 这些 Modifier 的叠加顺序。
一个典型的过度绘制陷阱是 Card 里面套 Box 再套 Surface:
Card(
modifier = Modifier.padding(8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.background(Color.White)
.padding(16.dp)
) {
// content
}
}
}这三层各自有背景绘制,叠加起来就是 3x overdraw。用 GPU 过度绘制模式一看,整个卡片区域都是红色(3x 以上)。优化很简单,去掉冗余的 Surface 和 Box.background,把背景统一提到最外层。但问题是,Compose Material 组件的默认样式经常隐式地加背景,不像传统 View 那样一目了然。需要仔细看源码,比如 Card 的 colors.containerColor、Surface 的 tonalElevation 对颜色的影响。
Systrace 里有个不太为人知的指标 hwc_overdraw,在 SurfaceFlinger 的 trace 里,能看到硬件合成器处理的过度绘制层数。但这个指标在 Android 12 以后的部分设备上被移除了,因为 HWC 2.4 规范改了。可以用 dumpsys SurfaceFlinger 替代,看 Visible layers 的 compositionType 是 DEVICE 还是 CLIENT,CLIENT 合成的层数越多,GPU 负担越重。
内存分析:Compose 的 Allocation Tracker 陷阱
Compose 的重组和副作用管理涉及大量临时对象:StateObject 的变更记录、CompositionLocal 的查找路径、RememberObserver 的回调包装。这些在 Allocation Tracker 里会表现为频繁的 androidx.compose.runtime.* 包下的对象分配。
Android Studio 的 Memory Profiler 有个 "Record Java/Kotlin allocations" 功能,但对 Compose 不太友好。Compose runtime 的很多对象是匿名内部类或者 lambda 转换来的,在堆栈里看到的类名是 ComposableLambdaImpl 或者 SnapshotStateObserver$ObservedScopeMap,很难对应到业务代码。
我的 workaround 是在关键路径上显式命名:
// 不要这样
val state = remember { mutableStateOf(0) }
// 这样
val state = remember(key = "counter_state") { mutableStateOf(0) }remember 的 key 参数在调试时会出现在对象的 toString 里,Memory Profiler 里能看到 MutableState(value=0)@counter_state,定位快很多。这个 key 参数在 Compose 1.4.0 之前是实验性的,1.4.0 以后稳定。
另一个内存相关的坑是 SnapshotStateList 和 mutableStateListOf。它的 add/remove 操作会触发 StateRecord 的链表复制,对于大量数据的频繁变更,分配开销很高。我们有个场景是实时接收传感器数据,每秒 100 个点,用 SnapshotStateList 做环形缓冲区,Memory Profiler 里看到 StateRecord 对象每秒分配几千个,GC 频繁触发。最后换成普通的 ArrayDeque 外加一个 mutableStateOf<Int> 做版本号通知更新,内存占用降了 80%。这里牺牲了一点 Compose 的响应式语义,换来了性能,是个务实的 trade-off。
平台差异:Samsung 和 Xiaomi 的 Compose 兼容性
最后说一个工具链覆盖不到的问题:OEM 定制 ROM 对 Compose 的影响。这不是性能分析工具能直接发现的,但会反映在性能数据里。
Samsung One UI 5.1(Android 13)上,我们观察到 LazyColumn 的 fling 动画在手指抬起后会有明显的减速不一致,相同代码在 Pixel 上很顺滑。根因是 Samsung 修改了 OverScroller 的 fling 参数,摩擦系数比 AOSP 大 30%。Compose 的 LazyListState 底层依赖 AndroidComposeView 的 scrollBy,最终调到系统 OverScroller,被 OEM 定制影响了。这个没有通用的检测工具,只能针对特定机型做适配,用 Build.MANUFACTURER 判断后调整 flingBehavior。
Xiaomi MIUI 14 上有个更隐蔽的问题:Recomposer 的调度在息屏再亮屏后,偶尔会丢失一帧的重组通知,导致界面状态和数据不同步。Compose 1.5.4 的 release note 里提到修复了一个 Recomposer 在生命周期恢复时的 race condition,但 Xiaomi 的电池优化策略会提前冻结进程,触发条件比 AOSP 更频繁。我们最后是在 onResume 里手动触发了一次 snapshotFlow {}.collect {} 强制刷新,这是个 dirty fix,但有效。
这些平台差异的经验,来自 Firebase Crashlytics 的非致命异常上报、用户反馈的视频录屏、以及内部测试机型的长期积累。没有捷径,就是堆设备、堆测试时间。
工具选择的个人优先级
写到这里,可以整理一下我实际工作中的工具优先级。这个不是绝对正确的,只是我的习惯:
Layout Inspector 只在开发阶段看结构,从不用于性能量化。Composition Tracing + Perfetto 是重组问题的主战工具,但需要编译器版本配合,升级成本要考虑。Systrace 作为补充,特别是在低端机和 GPU 相关场景。Macrobenchmark 做回归测试和优化验证,但不迷信绝对数值。自定义 Logger 和 Memory Profiler 处理特定模块的精细分析。平台差异问题没有工具,靠机型覆盖和经验积累。
Compose 的性能分析还在快速演进,Compose Compiler 1.5.x 引入了更激进的跳过优化,1.6.0 重构了 Snapshot 的并发策略,这些变化都会让工具的行为和解读方式随之改变。保持对 release note 的关注,比掌握某个固定工具更重要。