启动耗时测量方法:四种主流手段

说到启动速度监控,第一步肯定是「怎么量」。

我刚开始做性能优化时,拿个秒表在那点,后来被同事笑话了……其实测量方法不少,但每种都有自己的脾气。今天我就把我常用的四种方式掰开揉碎讲清楚。

核心思路: 只有准确测量,才能有效优化。误差控制比想象中难。
启动耗时测量方法 · 知识体系 四种启动耗时测量方法 手动埋点 System.currentTimeMillis() Process.getElapsedCpuTime() 启动日志时间戳 精确到代码行级 侵入性强 受系统时间调整影响 含线程等待时间 纯CPU执行耗时 不受调度影响 系统级起始点 粒度较粗 建议组合使用:埋点 + 日志时间戳锁定总耗时,CPU时间辅助分析 我曾经只用一种方法,被坑得很惨……

1. 手动埋点 — 最原始也最灵活

说白了就是在代码里插桩。比如 Application.onCreate() 开头记个时间,Activity.onCreate() 开头再记一个,一减就是差值。

// 手动埋点示例
public class MyApp extends Application {
    @Override
    public void onCreate() {
        long start = System.nanoTime();
        super.onCreate();
        // … 各种初始化
        long end = System.nanoTime();
        Log.d("AppStart", "Application.onCreate() 耗时: " + (end - start) / 1_000_000 + " ms");
    }
}
我个人的小习惯: 用 nanoTime() 而不是 currentTimeMillis() 做差值计算,前者的单调时钟特性更适合计时段。

手动埋点的好处是「想测哪里测哪里」。我在项目中遇到过第三方 SDK 初始化特别慢的问题,就是靠逐行埋点锁定的。但缺点也明显——代码侵入性强,线上版本还得想办法关掉。

我曾经踩过的坑: 在子线程里埋点,结果不同线程的时间精度不一致,算出来的差值负了几百毫秒。记住——起始和结束必须在同一条线程。

2. System.currentTimeMillis() — 小心时钟回拨

这个方法很多人拿来记启动总耗时。但你知道吗?系统时间可能被 NTP 同步、用户手动修改甚至回拨。你想想看,如果中间时间回退了,差值变成负数,你找谁哭去?

所以我的建议是:只用来做粗略的「墙上时间」统计,千万别用它做精确性能对比。

// 不推荐的做法
long start = System.currentTimeMillis();
// 业务逻辑
long end = System.currentTimeMillis();
long cost = end - start;  // 假如中间时间被改了?

为什么说它包含线程等待时间?因为 currentTimeMillis 反映的是真实世界流逝,你的线程被调度出去干等了 200 ms,这个时间也算进去了。这其实是好事——用户感知的「慢」就是这个。

3. Process.getElapsedCpuTime() — 真实CPU消耗

这是 Android(API 1 起就有)的一个隐藏技能。它返回进程占用的 CPU 时间,单位是纳秒。注意:仅仅是进程自己的 CPU 执行时间,不包括线程挂起、IO 等待、GC 暂停等。

// 获取CPU耗时
long cpuStart = Process.getElapsedCpuTime();
// 需要测量的工作
long cpuEnd = Process.getElapsedCpuTime();
Log.d("CPU_trace", "CPU实际运行: " + (cpuEnd - cpuStart) / 1_000_000 + " ms");

这个值在线上很好用,因为它不依赖系统时间。我记得有个 Case:启动总耗时 800 ms,用 CPU 时间一算只有 200 ms,说明剩下 600 ms 都是在等 IO 或锁。一下子就找到了方向。

黄金组合: 总耗时用 currentTimeMillis 衡量用户体验,CPU 耗时用 getElapsedCpuTime 分析代码执行效率。两者对比,差值就是「等待时间」。

4. 启动日志时间戳 — 系统级基准线

Android 系统会在启动时输出很多日志,例如:

01-01 12:00:00.123 I/ActivityManager: Start proc 10086:com.example/u0a123 for activity ...
01-01 12:00:01.456 I/ActivityManager: Displayed com.example/.MainActivity: +1s332ms

「Displayed」这个日志就是官方钦定的启动完成时刻。你可以在代码里用 adb logcat -b events | grep "am_activity_launch_time" 拿到结构化的时间戳。也可以直接在 logcat 里 grep 出来。

我经常拿它当基准:Displayed 时间减去进程创建时间(Start proc 那一行)就是用户感知的冷启动总耗时。然后跟我手动埋点的分段时间做 cross check,对不上就说明有地方漏了或者多了。

四种方法对比表

方法 精度 是否受系统时间影响 线上可用性 推荐场景
手动埋点 纳秒级 否(用nanoTime) 需剥离 细化定位瓶颈
System.currentTimeMillis() 毫秒级 是 可以 粗略总耗时
Process.getElapsedCpuTime() 纳秒级 否 可以 CPU开销分析
启动日志时间戳 毫秒级 是(受系统time影响) 天然可用 总耗时基准线

实操建议

  1. 先搭日志框架: 在 Application 启动时开启 Log.isLoggable 开关,避免线上日志污染。
  2. 多仪联测: 同时记录 currentTimeMillis 和 getElapsedCpuTime,比值能告诉你「到底是在等还是真的在跑代码」。
  3. 注意线程安全: 埋点的起始和结束点一定在同一个线程内,否则差值没意义。
  4. 统计多轮: 冷启动建议连续采集 10~20 次数据,取中位数或 P50、P95,单次偶然性太大。
我再多说一句: 别把测量代码写在性能敏感的循环里,比如每 1 ms 调用一次 getElapsedCpuTime 本身也会消耗 CPU 时间,造成测量结果偏大。嗯,无侵入性原则别忘了。

好了,四种方法都过了一遍。你想想看,实际开发中可能不止用一种。我个人习惯是:线上用日志时间戳 + CPU time 做大盘监控,线下用埋点做精确定位。