Macrobenchmark 测出来的启动时间,跟真实用户差多少
「Macrobenchmark 测出来的启动时间,跟真实用户差多少」
从一次离谱的数据差异说起
去年我在优化一个电商 App 的冷启动,Macrobenchmark 跑出来的数据漂亮得让我怀疑人生。Median 280ms,P99 320ms,帧时间分布图一条平稳的绿线。但同一周 Firebase Performance Monitoring 上报的冷启动中位数是 1.8 秒,部分低端机型超过 5 秒。差距不是 20%、30%,是将近 7 倍。
这个落差迫使我重新审视 Macrobenchmark 到底在测什么,以及它测不到的是什么。这篇文章不是否定这个工具,而是想厘清它的边界——什么时候信它,什么时候必须走出去看真实数据。
Macrobenchmark 的运行机制:理想得像个实验室
Macrobenchmark 1.2.0 的文档写得清楚,它通过 UiAutomation 连接设备,在测试包内启动被测应用的独立进程。关键点在于"独立进程"和"测试包隔离"这两个设计。
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
setupBlock = {
pressHome()
}
) {
startActivityAndWait()
}pressHome() 把测试应用切到后台,startActivityAndWait() 通过 am start 或者 Context.startActivity 触发冷启动。这里的冷启动定义是进程已死、Activity 需要完整走 onCreate 到第一帧绘制。听起来合理,但魔鬼在细节里。
第一个被忽略的细节是 ART 的 profile 状态。Macrobenchmark 默认运行在 user-debug 或 eng 设备上,而 Google 官方推荐用 user 设备的理由是避免 debug 开销。但我实际对比过同一台 Pixel 6,刷成 user 和 userdebug 两种系统镜像,Macrobenchmark 的冷启动中位数相差 40ms 左右。这 40ms 对 280ms 的基线来说是 14% 的偏差,对真实用户 1.8 秒的场景来说可能被其他噪音淹没,但它说明测试环境本身就在"作弊"。
更隐蔽的是 Cloud Profile。Android 12 引入的 Cloud Profile 会在应用安装后从 Play 商店拉取热启动 profile,ART 据此做预编译。Macrobenchmark 的测试包通常通过 adb install 本地安装,不走 Play 商店流程,Cloud Profile 大概率缺失。这意味着你的测试在测一个"没有 profile 优化"的冷启动,而真实用户可能享受到 profile 带来的 20%-50% 的 dex 优化收益。反过来,如果你在本地反复跑测试,ART 的 JIT 会逐渐积累 profile,后几次 iteration 可能比第一次快 30%,measureRepeated 的 iterations 参数默认 10 次,文档建议最少 5 次,但很少人注意第一次和第十次的差异。我做过统计,同一个 benchmark 连续跑 20 次,前 5 次的中位数比后 15 次高 15%-25%。
`CompilationMode` 的陷阱:你在测哪个 ART?
Macrobenchmark 提供了 CompilationMode 来控制 ART 的编译策略,这是很多人踩坑的地方。
measureRepeated(
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require
),
// ...
)CompilationMode.None() 表示纯解释执行,几乎没人会选这个测生产环境。CompilationMode.SpeedProfile() 模拟"运行一段时间后积累足够 profile 再全量编译"的状态。CompilationMode.Partial() 要求 baseline profile 存在并应用。CompilationMode.Full() 强制全量 AOT 编译,测的是最理想的机器码执行。
问题出在大部分人默认用 SpeedProfile(),认为它最接近真实。但 SpeedProfile() 的行为高度依赖设备上的 profile 记录器状态。如果这台设备之前跑过你的应用,profile 可能已经累积;如果是新刷机的设备,profile 为空,行为接近 None()。我在三星 Galaxy S21 上遇到过更诡异的情况:One UI 的电池优化策略会在后台冻结应用的 profile 守护进程,导致 SpeedProfile() 连续 10 次 iteration 都没有触发全量编译,测出来的数据比 Full() 慢 3 倍,但跟真实用户用了一周后的体验完全不符。
Baseline Profile 的引入本是为了解决这个问题。Android 13 开始,BaselineProfileRule 可以生成 .baseline 文件,打包进 APK 后 Play 商店会在安装时做预编译。但这里又有版本差异:Android 9-10 的 Play 商店不支持 Cloud Profile,Android 11 支持但需要设备有 Google Play 服务 21.8 以上,Android 12+ 才默认开启。你的 Macrobenchmark 如果跑在 Android 11 的测试机上,即使正确配置了 baseline profile,也可能因为 Play 服务版本旧而没有生效,测出一个"假阴性"的慢启动。
我个人现在会固定两种配置跑对比:一组 CompilationMode.Full() 看代码本身的理论上限,一组 CompilationMode.Partial(BaselineProfileMode.Require) 看 profile 生效后的预期表现。如果两者差距超过 50%,说明代码里有大量未 profile 到的路径,值得用 ProfileInstaller 库强制注入 profile 再测一次。
`StartupTimingMetric` 的计时边界
StartupTimingMetric 的文档定义是"从 startActivity 到第一帧绘制完成的时间"。但这个定义在 API 层面具体对应什么?
追溯 Macrobenchmark 1.2.0 的源码,StartupTimingMetric 依赖 WindowManager 的 reportDrawn 回调,或者 Choreographer 的 postFrameCallback 来判定"第一帧"。对于常规 Activity,这是 onCreate → onStart → onResume → Choreographer 调度第一帧 → 帧实际绘制到 SurfaceFlinger 的完整链路。
但这里有几个被低估的盲区。
第一个是 reportDrawn 的调用时机。如果你的 Activity 在 onCreate 里启动了异步加载,主线程很快走到 onResume,但内容实际还没准备好,你会在代码里手动调 reportFullyDrawn() 来修正这个 metrics。Macrobenchmark 1.1.0 之前不支持 reportFullyDrawn() 的捕获,1.2.0 加了 StartupTimingMetric 的 fullyDrawnTimeout 参数,但默认行为仍然是优先看 Choreographer 第一帧。这意味着如果你的启动流程是"先显示骨架屏,再异步填数据",Macrobenchmark 测到的是骨架屏出现的时间,而用户感知的"能用"是数据填完的时间。这个 gap 在真实用户那可能高达数百毫秒,但 benchmark 报表里一片祥和。
第二个盲区是 SplashScreen API。Android 12 强制引入的 SplashScreen 会在 Application.onCreate 之前显示系统提供的启动窗口。StartupTimingMetric 的计时起点是 startActivity,但用户看到的起点是点击图标后 SplashScreen 出现的那一刻。如果 SplashScreen 的 windowSplashScreenAnimationDuration 设了 300ms,你的 Activity 实际 onCreate 只用了 200ms,Macrobenchmark 报 200ms,用户体感是 500ms 以上。这个差异在低端机上更夸张,因为 SplashScreen 的图标缩放动画可能掉帧,但 Macrobenchmark 不 capture 系统窗口的绘制性能。
第三个是 startActivityAndWait() 的实现。它内部用 Instrumentation.startActivitySync() 或者轮询 ActivityManager 的 running tasks,这个"等待"的精度是 50ms 级别的轮询间隔。对于 280ms 的启动时间,50ms 的量化误差是 18%。如果你测的是 100ms 以内的热启动,这个误差可能主导结果。Macrobenchmark 1.2.0 的 release note 提到优化了等待逻辑,但没有公开具体的精度数字。我自己用 systrace 对比过,startActivityAndWait() 返回的时刻比 Choreographer 实际第一帧早 20-80ms 不等,取决于系统负载。
系统状态:测试包测不到的真实世界
Macrobenchmark 的测试包有系统级特权,这是它能量化帧时间的前提,也是它与真实环境割裂的根源。
测试包通过 android.permission.DUMP 和 android.permission.PACKAGE_USAGE_STATS 读取 SurfaceFlinger 和 gfxinfo 的数据,普通应用拿不到这些权限。这意味着你的 benchmark 代码运行在"特权沙盒"里,而真实应用运行在"普通沙盒"里。两个沙盒的系统资源调度策略不同。
最明显的例子是 CPU 频率。Macrobenchmark 1.2.0 引入了 DeviceConfiguration.override 来锁定 CPU 频率,但默认不开启。大部分人的测试在"schedutil" governor 下跑,启动瞬间 CPU 可能正好在低频省电状态,也可能被测试包的活跃行为提前唤醒到高频。我抓过 Pixel 6 的 CPU freq 日志,同样的 benchmark 连续跑,大核频率在 500MHz 到 1800MHz 之间跳动,对应的启动时间波动范围是 220ms 到 410ms。这个 86% 的波动被 10 次 iteration 平均掉了,但真实用户不会启动 10 次取平均,他启动一次,碰上低频就是慢。
内存压力是另一个黑洞。Macrobenchmark 要求设备"idle"状态,通常意味着刚重启、后台干净。真实用户的设备后台有微信、抖音、系统服务,可用内存可能只剩 1GB,触发 kswapd 频繁换页。我做过一个残酷的实验:在 6GB 内存的中端机上,先打开 10 个常见应用再测冷启动,Macrobenchmark 测出来的数据比干净状态慢 2-4 倍,但 Macrobenchmark 的"idle"要求让你根本测不到这个场景。Firebase Performance Monitoring 和 Android Vitals 能抓到,因为它们在真实用户设备上采样。
还有更隐蔽的 I/O 抖动。应用冷启动需要读 dex、读资源、读 SharedPreferences、读数据库。测试设备的存储通常是 UFS 3.1 甚至 UFS 4.0,空闲状态。真实用户的设备可能是 eMMC,存储碎片化,或者正在后台下载更新。Macrobenchmark 不模拟这些,它的 setupBlock 里能做的是 pressHome() 和 dropShaderCache() 之类,没有"模拟后台内存压力"或"模拟存储竞争"的 API。
dropShaderCache() 本身也是个坑。这个 API 在 setup 里调用是为了清掉 GPU shader 缓存,模拟"首次启动"状态。但清缓存的行为在 Android 13 和 14 上有差异:13 上是同步清,14 上改成了异步任务,benchmark 可能在前几次 iteration 里测到"半清不清"的状态。Macrobenchmark 1.2.0 的 release note 里提了一句 "Improve dropShaderCache reliability on Android 14",但没有细节。我实际遇到过 Android 14 上连续 10 次 iteration 的前 3 次明显偏快,怀疑就是 shader cache 没清干净。
帧时间 vs 用户体感:两个不同的世界
Macrobenchmark 的核心输出是帧时间(frame duration)和帧间隔(frame interval),基于 Choreographer 和 SurfaceFlinger 的 FrameStats。这个 metrics 假设"帧按时提交 = 流畅",但用户体感不是这么算的。
Android 的显示流水线有三缓冲(triple buffering),SurfaceFlinger 的合成调度有 1-2 帧的延迟。你的应用在第 N 帧提交了内容,SurfaceFlinger 可能在第 N+1 或 N+2 帧才把它送到屏幕。Macrobenchmark 的 FrameTimingMetric capture 的是应用侧的提交时间,不是屏幕实际点亮像素的时间。这个差异在 120Hz 设备上是 8-16ms,在 60Hz 设备上是 16-33ms,对启动场景来说可能占 5%-10%。
更关键的是掉帧的判定阈值。Macrobenchmark 用 expectedDuration 基于刷新率计算,60Hz 下 16.67ms 为一帧,120Hz 下 8.33ms。但真实用户的屏幕可能是动态刷新率,从 30Hz 跳到 120Hz,或者游戏场景固定 90Hz。Macrobenchmark 默认按设备声明的默认刷新率算,如果测试过程中系统切了刷新率,metrics 会错乱。我在一加设备上遇到过,系统根据内容自动切 60/90/120,benchmark 报表里出现大量"负帧时间"的异常数据,因为 expectedDuration 和实际刷新率不匹配。
还有输入延迟。用户点击图标到 startActivity 真正执行,中间有触摸驱动上报、系统服务处理、Launcher 应用的响应。Macrobenchmark 的计时从 startActivity 开始,跳过了前面这段。这段延迟在高端机上 20-50ms,在低端机上可能 100-200ms,而且跟 Launcher 的实现强相关。MIUI 的 Launcher 有动画过渡,Pixel 的 Launcher 直接切,三星 One UI 中间还有 haptic feedback 的时序。这些都不会出现在你的 benchmark 报表里,但用户会算进"启动慢"的体感。
我现在的混合策略:benchmark 当显微镜,真实数据当地图
经过这些踩坑,我对 Macrobenchmark 的定位变了。它不再是"我的启动性能是多少"的答案,而是"这个代码变更对启动路径的影响方向是什么"的探测器。
具体做法分三层。
第一层是 Macrobenchmark 的受控测试。固定设备(我主要用 Pixel 6 和一台小米 12X 做低端代表),固定系统版本(Android 14 QPR2),固定 CompilationMode.Partial(BaselineProfileMode.Require),iteration 拉到 20 次,去掉前 5 次 warmup,看后 15 次的分布。同时跑 Full() 和 Partial() 对比,确认 profile 覆盖度。这个环境只用来 A/B 对比代码变更,不看绝对数值。
第二层是本地模拟真实压力。写一个"压力生成器"辅助应用,在 benchmark 之前吃掉 2GB 内存、触发 50% CPU 负载、后台写 100MB 文件制造 I/O 竞争。然后手动测冷启动,用 adb shell am start -W 抓 TotalTime,同时开 systrace。这个不自动化,但每次发版前做一次,用来验证 benchmark 的"理想结果"在压力下会不会崩塌。
第三层是线上真实数据。Firebase Performance Monitoring 的冷启动定义是 Application.onCreate 到第一个 Activity 的 onResume,这个定义和我们的业务埋点对齐。Android Vitals 的启动时间是系统侧统计,包含前面说的输入延迟和 SplashScreen,更接近用户体感。两个数据源交叉看:如果 benchmark 说快了 20%,Firebase 也说快了 15%,方向一致,可以信。如果 benchmark 说快了 20%,Firebase 没变甚至变慢,大概率是 benchmark 的测试路径没覆盖到真实用户的设备分布或场景分布。
一个具体的案例是我们去年做的一个"启动优化":把主线程的初始化从 12 个串行任务改成 6 个并行(用 CoroutineScope + async)。Macrobenchmark 在 Pixel 6 上显示冷启动从 280ms 降到 220ms,降幅 21%。但 Firebase 上中低端机(Android 10-11,4GB 内存)的数据反而恶化了 10%。追查发现,并行初始化增加了内存峰值,触发了这些设备的 low memory killer 提前回收后台进程,下次用户切回来要重新初始化,整体体验更差。这个教训让我彻底放弃"用一个数字代表启动性能"的想法。
版本差异:Macrobenchmark 自己的进化也在制造噪音
Macrobenchmark 的版本迭代很快,1.0 到 1.2 的行为变化不小,这也是数据可比性的隐患。
1.0 时代的 StartupTimingMetric 不支持 fullyDrawn,很多人测的是"第一帧"而非"内容就绪"。1.1 加了 FrameMetrics 的 frameOverrunMs,用来衡量帧超过 deadline 的程度,比单纯的掉帧数更敏感。1.2 引入了 PowerMetric 和 CpuUsageMetric,可以同步看能耗,这对启动场景有意义,因为高频 CPU 的启动快可能是用功耗换的。
但 1.2 也改了 BaselineProfileRule 的生成逻辑,以前需要手动跑 adb shell cmd package compile 来验证 profile 生效,现在 CompilationMode.Partial() 会自动检查。这个改动导致我之前的 benchmark 脚本在升级后行为变化,profile 没生效时直接抛异常而不是静默测一个慢数据,这本来是好事,但让我一度以为代码回退了——其实是工具变严格了。
还有一个未文档化的变化:MacrobenchmarkRule 的 measureRepeated 在 1.2 里默认加了 2s 的 setupBlock 超时,如果 pressHome() 后系统 Launcher 响应慢,整个测试会 abort。我在一台装了第三方 Launcher 的测试机上频繁遇到这个超时,错误信息是 java.util.concurrent.TimeoutException: Setup block timed out,Google 的 issue tracker 上有人报过(issue #293423695), workaround 是换官方 Launcher 或加大超时。这种工具链的坑消耗的时间,有时候比优化代码本身还长。
一个具体的技术细节:`TraceSectionMetric` 的采样率
最后想提一个很少有人注意的点:自定义 TraceSectionMetric 的精度依赖 Trace.beginSection() 的调用,而 systrace/atrace 的缓冲区大小有限。
val traceMetric = TraceSectionMetric("AppOnCreate", TraceSectionMetric.Mode.Sum)如果你在 Application.onCreate 里打了 20 个 trace section,每个 5ms,理论上 TraceSectionMetric 应该能 capture。但实际上 atrace 的 ring buffer 默认 4MB,高频率的 trace 事件可能把 buffer 打满,早期的 section 被覆盖。Macrobenchmark 没有暴露 buffer 大小的配置,你只能通过 adb shell atrace --buffer_size 全局改。我在一个初始化路径很长的应用上遇到过,AppOnCreate 的 trace 只 capture 到后半段,前半段被覆盖,算出来的 sum 比实际短 30%。这个坑没有错误提示,数据就是安静地 wrong。
解决方法是减少 trace section 的粒度,或者在关键路径用 Trace.beginAsyncSection 配合固定 tag,避开 ring buffer 的竞争。但这也说明,benchmark 工具链的每一个环节都有假设,假设不成立时数据就失真。
回到标题的问题
Macrobenchmark 测出来的启动时间,跟真实用户差多少?
我的答案是:在受控条件下,方向性差异可以控制在 20% 以内,绝对数值差异通常 2-5 倍,极端场景(低端机、内存压力、旧系统版本)下 10 倍也不稀奇。它不是"错"的,它是"特定条件下的精确测量",而真实用户活在"所有条件的叠加态"里。
用 Macrobenchmark 做回归防护、A/B 对比、profile 验证,它是极好的显微镜。但把它当成性能的唯一真相,就会像拿着实验室的 pH 试纸去测海水——试纸没坏,是你测错了地方。