深入 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 次,冷启动前几次受系统缓存预热影响明显,去掉前两次跑更能反映稳态数据。startupMode:COLD模式下,框架在每次迭代前 kill 进程、清缓存,模拟用户首次打开。WARM和HOT也有各自用途,但冷启动数据最值得盯——这是用户体感最敏感的维度。compilationMode:默认Partial()。如果你想知道 AOT 全编译后的极限性能,用Full(),但这个数字别当基线——线上用户根本跑不到这个程度。
一个踩过的坑:startActivityAndWait 在部分定制 ROM 上会提前返回,导致测量值偏小。解决办法是手动加一个 timeToFullDisplay 的 reportFullyDrawn() 调用,或者在页面的 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_0、image_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
四个关键设计点:
- 固定设备:不同机型的基准不可比。CI 里用一台专用的 Pixel 6(或模拟器固定配置),不跟其他任务混用。
- 基线存储:每次跑完后把 JSON 结果存入 Git 仓库的
benchmark/baselines/目录,MR 时自动对比。 - 阈值策略:不用”一超过就挂”的硬阻断,太容易误报。我的做法是:超过 5% 发 Warning 评论,超过 15% 直接 block merge。
- 环境隔离: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 升级,被这套流程拦了三次才修好。