Compose 性能分析:Layout Inspector 之外还有什么

Compose 性能分析:Layout Inspector 之外还有什么

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 下面,能看到每个 RecomposeScopeImplperformRecompose 耗时。关键看两个指标:一是 performRecompose 本身的执行时间,二是它和 measure/layout/draw 的相对位置。如果 performRecompose 很长但 measure 很短,说明问题在业务逻辑或者状态计算;如果 measure 跟着暴涨,那得看是不是重组触发了过多的布局计算。


一个具体的案例:我们有个聊天界面,消息列表用 LazyColumn,每条消息里有富文本解析后的 AnnotatedString。Perfetto 里看到 performRecompose 只有 2ms,但紧接着的 measure 花了 12ms,直接掉帧。追下去发现是 TextInlineTextContent 在每次重组时都重新 measure 了占位元素,而占位元素是个带圆角的头像,用了 GenericShape 做自定义裁剪。GenericShapecreateOutline 不走缓存,每次 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 的详细重组信息,但能看到 RenderThreaddrawFrame 是否超时、GPU 是否出现 waitForFences 阻塞。这些信息对于判断卡顿是 CPU bound 还是 GPU bound 很关键。


Compose 的 draw 阶段如果用了大量的 Canvas 自定义绘制,比如粒子效果或者波形图,GPU 侧的耗时在 Systrace 里看得很清楚。有一次用 DrawScope.drawPoints 画实时音频波形,在 Perfetto 里 draw slice 只有 3ms,但 Systrace 里看到 RenderThreaddrawFrame 周期性跳到 20ms。深入发现是 drawPoints 生成的 Point 数组太大,导致 GPU 顶点着色器瓶颈。最后改用 drawLine 批量绘制,把顶点数从几千降到几百,问题解决。这个案例说明,不同工具的视角互补,不能迷信某一个。


Macrobenchmark 和 Baseline Profile:实验室数据怎么解读


Jetpack Macrobenchmark 1.2.0 以后对 Compose 的支持好了很多,特别是 FrameTimingMetricCustomTimingMetric 可以精确测量特定 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 --resetadb shell cmd package compile --compile-layouts 对比过,完全 AOT 编译后的启动速度比 Cloud Profile 快 40%,但 Cloud Profile 是用户实际能拿到的优化水平。Macrobenchmark 的 CompilationMode.Partial() 更接近后者,但仍然是理想情况。


我的做法是,Macrobenchmark 数据只看相对趋势,比如优化前后的对比,或者两个实现方案的 A/B 测试。绝对数值参考意义有限。


自定义 Recomposition Logger:侵入式但必要


当官方工具链都够不到的时候,只能自己写埋点。Compose 的 RecomposerComposition 有内部 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.backgroundModifier.borderModifier.shadow 叠加起来,同样会造成同一个像素多次着色。


Android 的 GPU 过度绘制调试模式(开发者选项 -> 调试 GPU 过度绘制)对 Compose 仍然有效,但解读方式要调整。Compose 的 Canvas 绘制是记录在 DisplayList 里的,最终合并成一个 RenderNode,所以过度绘制的层级不会直接对应 Composable 的嵌套深度,而是对应 drawBehinddrawWithContentdrawOver 这些 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 以上)。优化很简单,去掉冗余的 SurfaceBox.background,把背景统一提到最外层。但问题是,Compose Material 组件的默认样式经常隐式地加背景,不像传统 View 那样一目了然。需要仔细看源码,比如 Cardcolors.containerColorSurfacetonalElevation 对颜色的影响。


Systrace 里有个不太为人知的指标 hwc_overdraw,在 SurfaceFlinger 的 trace 里,能看到硬件合成器处理的过度绘制层数。但这个指标在 Android 12 以后的部分设备上被移除了,因为 HWC 2.4 规范改了。可以用 dumpsys SurfaceFlinger 替代,看 Visible layerscompositionTypeDEVICE 还是 CLIENTCLIENT 合成的层数越多,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) }

rememberkey 参数在调试时会出现在对象的 toString 里,Memory Profiler 里能看到 MutableState(value=0)@counter_state,定位快很多。这个 key 参数在 Compose 1.4.0 之前是实验性的,1.4.0 以后稳定。


另一个内存相关的坑是 SnapshotStateListmutableStateListOf。它的 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 修改了 OverScrollerfling 参数,摩擦系数比 AOSP 大 30%。Compose 的 LazyListState 底层依赖 AndroidComposeViewscrollBy,最终调到系统 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 的关注,比掌握某个固定工具更重要。

Google 的官方 Codelab 合集,哪些值得刷 2026-06-29
Android Emulator 的快照功能,保存测试状态 2026-06-29

评论区