深入 Android Macrobenchmark 性能基准测试全链路

去年在做一个首页改版时,Compose 重构后的页面肉眼看着挺流畅,QA 也过了。上线两周后,用户反馈页面”变卡了”——数据一看,冷启动 P99 涨了 400ms。那次之后我就认了一个死理:性能优化不能靠感觉,得有数据说话

Android Jetpack 里的 Macrobenchmark 库就是干这个的。它跟 Systrace/Perfetto 不同:后者是事后追踪工具,Macrobenchmark 是事前度量工具——在 CI 里跑、跟基线比、劣化就拦截。这篇文章聊聊我在项目里落地的完整链路。

BenchmarkRule 的启停控制

Macrobenchmark 用 MacrobenchmarkRule 管理测试生命周期。核心方法是 measureRepeated

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun coldStartup() = benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(StartupTimingMetric()),
        iterations = 10,
        startupMode = StartupMode.COLD
    ) {
        pressHome()
        val intent = Intent(Intent.ACTION_MAIN).apply {
            addCategory(Intent.CATEGORY_LAUNCHER)
            setPackage("com.example.app")
        }
        startActivityAndWait(intent)
    }
}

几个参数的设定直接决定了测试结果能不能反映真实情况:

  • iterations:官方建议至少 10 次。次数太少方差大,中位数不稳。我在实际项目里设 15-20 次,冷启动前几次受系统缓存预热影响明显,去掉前两次跑更能反映稳态数据。
  • startupModeCOLD 模式下,框架在每次迭代前 kill 进程、清缓存,模拟用户首次打开。WARMHOT 也有各自用途,但冷启动数据最值得盯——这是用户体感最敏感的维度。
  • compilationMode:默认 Partial()。如果你想知道 AOT 全编译后的极限性能,用 Full(),但这个数字别当基线——线上用户根本跑不到这个程度。

一个踩过的坑:startActivityAndWait 在部分定制 ROM 上会提前返回,导致测量值偏小。解决办法是手动加一个 timeToFullDisplayreportFullyDrawn() 调用,或者在页面的 onResume 里打点校验实际渲染完成时间。

冷启动度量的三个关键指标

Macrobenchmark 的 StartupTimingMetric 从 systrace 里提取三个时间点:

指标含义参考值
timeToInitialDisplay首帧显示时间< 500ms
timeToFullDisplay首帧 + 数据加载完成< 1.5s
timeToInteractive (API 34+)可交互时间< 2s

timeToFullDisplay 依赖你主动调用 reportFullyDrawn()。很多团队漏了这一步,导致这个指标一直是 0。在 Activity 里加一行:

override fun onResume() {
    super.onResume()
    if (isDataReady) {
        reportFullyDrawn()
    }
}

测试结果会被汇总成中位数、最小/最大值和标准差。我更关注 P95——平均值对异常值不敏感,而用户感受到的恰好是那些长尾请求。可以通过 Measurements 对象解析原始数据自行计算分位值。

帧流畅度:FrameTimingMetric

启动快不等于用着流畅。滑动列表时的掉帧问题,需要 FrameTimingMetric 来度量:

@Test
fun scrollList() = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(FrameTimingMetric()),
    iterations = 5,
    setupBlock = {
        // 先启动并导航到目标页面
        startActivityAndWait()
        device.findObject(By.res("list_page")).waitForExists(3000)
    }
) {
    val list = device.findObject(By.res("recycler_view"))
    list.setGestureMargin(device.displayWidth / 5)
    list.fling(Direction.DOWN)  // 模拟快速滑动
    device.waitForIdle()
}

宏基准输出的帧数据包含 frameOverrunMs——超过 16.67ms 阈值的部分。累积超时量(总超时/总帧数)比单纯统计丢帧次数更能反映真实流畅度。我在项目里设的告警阈值是:累积超时超过 120ms丢帧率超过 8% 时触发拦截。

fling 的速度和方向要跟真实用户行为对齐。Feed 流场景下,两次 fling 再跟一次慢滑,更贴近用户「刷两下然后仔细看」的模式。

自定义 TraceSection 指标

系统指标覆盖不了业务关键路径。比如你有自定义图片加载器,或者一段复杂的数据解析逻辑——这些地方慢了对用户体验影响很大,StartupTimingMetric 测不到。

TraceSectionMetric 把自定义 trace 段变成可度量的指标:

// 在业务代码中埋点
Trace.beginSection("image_decode_pipeline")
val bitmap = customDecoder.decode(inputStream)
Trace.endSection()

Trace.beginSection("json_parse_large_list")
val data = Gson().fromJson<List<Item>>(response)
Trace.endSection()

测试侧捕获这些自定义段:

@Test
fun customMetrics() = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(
        TraceSectionMetric("image_decode_pipeline%"),
        TraceSectionMetric("json_parse_large_list%")
    ),
    iterations = 10,
    startupMode = StartupMode.COLD
) {
    startActivityAndWait(intent)
    device.waitForIdle()
}

% 后缀表示匹配所有以 image_decode_pipeline 开头的 trace 段,避免因循环调用生成的 image_decode_pipeline_0image_decode_pipeline_1 漏掉。

实际项目中,我把业务关键路径拆成了 12 个 trace 段,每周跑一次全量测试,任何一段耗时增加超过 15% 就自动建单。一开始团队觉得 12 个太多,跑了两个月后发现,有 3 个段的数据几乎没波动,就精简掉了——度量本身也需要持续优化。

CI 集成与防劣化流水线

单次跑基准测试意义不大,真正的价值在于持续比较。在 CI 里集成的方式:

# 在 release build 上跑,因为 profile 模式不够贴近线上
./gradlew :benchmark:pixel6Api33BenchmarkAndroidTest \
    -Pandroid.testInstrumentationRunnerArguments.class=\
        com.example.benchmark.ColdStartBenchmark

# 输出 JSON 结果
adb pull /sdcard/Android/media/com.example.benchmark/benchmarkData.json

四个关键设计点:

  1. 固定设备:不同机型的基准不可比。CI 里用一台专用的 Pixel 6(或模拟器固定配置),不跟其他任务混用。
  2. 基线存储:每次跑完后把 JSON 结果存入 Git 仓库的 benchmark/baselines/ 目录,MR 时自动对比。
  3. 阈值策略:不用”一超过就挂”的硬阻断,太容易误报。我的做法是:超过 5% 发 Warning 评论,超过 15% 直接 block merge。
  4. 环境隔离:CI 跑之前关掉蓝牙、WiFi、同步服务,亮度固定 50%。一个 setupBlock 搞定:
setupBlock = {
    device.executeShellCommand("cmd batterymanager set status 1")  // 模拟充电
    device.executeShellCommand("settings put system screen_brightness 128")
    // 关掉可能会干扰的后台服务
    device.executeShellCommand("cmd activity idle-maintenance")
}

跑了一年多,一个体会是:别太信任模拟器的数据。同一段代码在模拟器上 P50 是 380ms,到 Pixel 6 真机上是 520ms。模拟器可以做快速验证,但基线和告警一定要用真机数据。


把 Macrobenchmark 用起来,核心就三步:选对指标(启动、帧率、自定义 trace)、固定环境跑(专用设备 + 标准化 setup)、建基线做对比(CI 自动 diff)。数据比你想象中诚实——一个「无关紧要」的 SDK 升级,被这套流程拦了三次才修好。

延伸阅读