Macrobenchmark 测出来的启动时间,跟真实用户差多少

Macrobenchmark 测出来的启动时间,跟真实用户差多少

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-debugeng 设备上,而 Google 官方推荐用 user 设备的理由是避免 debug 开销。但我实际对比过同一台 Pixel 6,刷成 useruserdebug 两种系统镜像,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%,measureRepeatediterations 参数默认 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 依赖 WindowManagerreportDrawn 回调,或者 ChoreographerpostFrameCallback 来判定"第一帧"。对于常规 Activity,这是 onCreateonStartonResumeChoreographer 调度第一帧 → 帧实际绘制到 SurfaceFlinger 的完整链路。


但这里有几个被低估的盲区。


第一个是 reportDrawn 的调用时机。如果你的 Activity 在 onCreate 里启动了异步加载,主线程很快走到 onResume,但内容实际还没准备好,你会在代码里手动调 reportFullyDrawn() 来修正这个 metrics。Macrobenchmark 1.1.0 之前不支持 reportFullyDrawn() 的捕获,1.2.0 加了 StartupTimingMetricfullyDrawnTimeout 参数,但默认行为仍然是优先看 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.DUMPandroid.permission.PACKAGE_USAGE_STATS 读取 SurfaceFlingergfxinfo 的数据,普通应用拿不到这些权限。这意味着你的 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),基于 ChoreographerSurfaceFlingerFrameStats。这个 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 -WTotalTime,同时开 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 加了 FrameMetricsframeOverrunMs,用来衡量帧超过 deadline 的程度,比单纯的掉帧数更敏感。1.2 引入了 PowerMetricCpuUsageMetric,可以同步看能耗,这对启动场景有意义,因为高频 CPU 的启动快可能是用功耗换的。


但 1.2 也改了 BaselineProfileRule 的生成逻辑,以前需要手动跑 adb shell cmd package compile 来验证 profile 生效,现在 CompilationMode.Partial() 会自动检查。这个改动导致我之前的 benchmark 脚本在升级后行为变化,profile 没生效时直接抛异常而不是静默测一个慢数据,这本来是好事,但让我一度以为代码回退了——其实是工具变严格了。


还有一个未文档化的变化:MacrobenchmarkRulemeasureRepeated 在 1.2 里默认加了 2ssetupBlock 超时,如果 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 试纸去测海水——试纸没坏,是你测错了地方。

JetBrains 的新动作,Kotlin 生态要变天? 2026-05-24
Hilt 的编译时代码生成,到底生成了什么 2026-05-24

评论区