4. 启动性能的度量体系
聊启动优化,第一件事不是动手改代码,而是先搞清楚——怎么才算「快」?
我见过不少团队,上来就怼启动时间,结果优化了半天,用户反馈还是卡。为什么?因为启动性能是个多维度的东西,光看一个指标,很容易掉坑里。
这一章,我把度量体系拆成四个核心维度:启动时间、启动耗电、启动内存峰值、启动卡顿率。每个维度我都会讲清楚怎么测、怎么算、避坑点在哪。
核心观点:启动性能不是单一数字,而是一组相互制衡的指标。优化一个维度,可能牺牲另一个。度量体系的意义,就是让你在做决策时有据可依。
4.1 启动时间:TTI、FPS 与帧率
启动时间是最直观的指标。但「启动完成」到底怎么定义?是看到闪屏?还是首帧渲染?还是用户能操作?
我个人习惯用 TTI(Time To Interactive) 作为核心指标。TTI 指的是从进程创建到用户可交互的时间点。这个点通常比首帧显示晚几百毫秒,但更贴近真实体验。
| 指标 | 定义 | 测量方式 | 典型目标值 |
|---|---|---|---|
| TTI | 用户可交互时间点 | 手动埋点 / FrameCallback | < 2s(冷启动) |
| 首帧时间 | 第一帧渲染到屏幕 | SurfaceFlinger 日志 | < 1.5s |
| FPS | 启动过程中的帧率 | dumpsys gfxinfo / 工具 | > 30fps |
| 帧率稳定性 | 帧间隔标准差 | Choreographer 回调 | < 16ms 抖动 |
我的经验:TTI 埋点不要只放在 Activity.onCreate 结束。我踩过坑——onCreate 跑完了,但主线程还在排队等布局渲染。真正的 TTI 应该放在第一次 onWindowFocusChanged 为 true 之后,再等一个 idle 周期。
帧率这块,很多人只看平均 FPS。其实启动过程中的帧率抖动比平均值更重要。你想想看,用户点开 App,前 500ms 卡成 PPT,后面虽然流畅了,但第一印象已经坏了。
我建议用 FrameDrop 率 来量化:统计启动过程中掉帧超过 16ms 的帧数占比。超过 10% 就算严重。
// 伪代码:统计启动帧率抖动
long lastFrameTime = 0;
int dropCount = 0;
int totalFrames = 0;
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
totalFrames++;
if (lastFrameTime != 0) {
long delta = (frameTimeNanos - lastFrameTime) / 1_000_000;
if (delta > 16) {
dropCount++;
}
}
lastFrameTime = frameTimeNanos;
// 继续监听,直到启动完成
if (!isLaunchComplete) {
Choreographer.getInstance().postFrameCallback(this);
}
}
});
4.2 启动耗电:被忽视的隐形杀手
启动耗电这个维度,说实话,很多团队根本不测。但用户感知很强烈——点开 App 手机发烫,这就是启动阶段功耗过高。
我记得有一次优化一个视频 App,启动时间压到了 1.2s,结果用户反馈手机烫得厉害。一查,启动阶段 CPU 满频跑了 3 秒,功耗直接飙到 5W。后来加了降频策略,启动时间多了 200ms,但功耗降了 40%。
| 测量维度 | 工具 | 关键指标 | 优化方向 |
|---|---|---|---|
| CPU 功耗 | BatteryHistorian / Perfetto | CPU 频率 / 负载曲线 | 减少主线程密集计算 |
| 网络功耗 | Network Profiler | 启动阶段网络请求数 | 延迟非关键请求 |
| GPU 功耗 | GPU Profiler | 渲染负载 / 过度绘制 | 减少启动动画复杂度 |
| 整体功耗 | PowerMonitor / 电流计 | 平均电流 / 峰值电流 | 错峰加载 / 降频策略 |
避坑指南:我曾经在启动阶段并行初始化了 6 个 SDK,以为能加速。结果 CPU 多核全开,功耗直接翻倍,而且因为争抢锁,启动时间反而变长了。后来改成串行 + 延迟加载,功耗降了 60%,启动时间还快了 15%。
4.3 启动内存峰值:OOM 的导火索
启动阶段的内存峰值,是很多 App 崩溃的根源。为什么?因为启动时大量资源同时加载,内存瞬间冲高,如果设备内存紧张,直接被杀。
我习惯看三个指标:PSS(比例集大小)、Java Heap、Native Heap。PSS 最接近真实占用,Java Heap 反映 GC 压力,Native Heap 反映底层资源消耗。
// 通过 adb 获取启动内存峰值
adb shell dumpsys meminfo com.example.app | grep "PSS Total"
// 更精确的方式:在代码中埋点
Debug.MemoryInfo memoryInfo = new Debug.MemoryInfo();
Debug.getMemoryInfo(memoryInfo);
long pss = memoryInfo.getTotalPss(); // 单位 KB
long javaHeap = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
| 内存类型 | 典型峰值范围 | 危险阈值 | 优化手段 |
|---|---|---|---|
| PSS | 200MB - 500MB | > 设备总内存 40% | 延迟加载 / 资源压缩 |
| Java Heap | 64MB - 256MB | > 256MB(32位设备) | 对象复用 / 减少临时对象 |
| Native Heap | 50MB - 200MB | > 300MB | 图片缓存 / 纹理压缩 |
| GPU 内存 | 20MB - 100MB | > 150MB | 减少启动动画帧数 |
我的习惯:每次启动优化后,我都会在低端机(2GB 内存)上跑一轮内存峰值测试。如果 PSS 超过 400MB,基本可以断定会在后台被频繁杀掉。这时候就要考虑「分阶段加载」——先加载核心 UI,其他资源等用户操作时再加载。
4.4 启动卡顿率:用户感知的「温度计」
启动卡顿率,说白了就是用户觉得「卡」的概率。这个指标比帧率更直接——帧率 30fps 可能不卡,但如果有一次掉帧超过 100ms,用户就会觉得卡。
我通常用 Jank 率 来量化:统计启动过程中,单帧渲染时间超过 32ms(掉 1 帧)或 48ms(掉 2 帧)的帧数占比。
| 卡顿等级 | 单帧耗时 | 用户感知 | 可接受占比 |
|---|---|---|---|
| 轻微卡顿 | 32ms - 48ms | 偶尔感觉不流畅 | < 5% |
| 明显卡顿 | 48ms - 100ms | 能明显感觉到掉帧 | < 2% |
| 严重卡顿 | > 100ms | 画面冻结,体验极差 | 0% |
| 冻帧 | > 200ms | ANR 前兆 | 0% |
避坑指南:我曾经遇到一个案例,启动帧率 55fps,看起来很好。但用户反馈「点开 App 会闪一下」。后来用高速相机录屏才发现,启动过程中有一帧渲染了 180ms,虽然只有一帧,但用户肉眼捕捉到了。从那以后,我坚持用「最大单帧耗时」作为卡顿率的辅助指标。
4.5 综合度量:四个维度怎么权衡?
四个维度不是孤立的。你压启动时间,可能推高内存峰值;你降内存,可能增加卡顿率。怎么权衡?
我个人习惯用 加权评分法:
- 确定权重:根据产品定位分配权重。比如工具类 App,启动时间权重 50%,卡顿率 30%,内存 15%,耗电 5%。游戏类 App,卡顿率权重最高。
- 归一化打分:每个维度设定目标值,实际值除以目标值得出分数。超过目标值扣分,低于目标值加分。
- 计算总分:加权求和,得出综合评分。
// 加权评分示例
double score =
(ttiScore * 0.5) +
(jankScore * 0.3) +
(memoryScore * 0.15) +
(powerScore * 0.05);
// 每个维度的分数计算
double ttiScore = targetTti / actualTti; // 目标 2s,实际 1.5s,得分 1.33
double jankScore = 1 - (actualJankRate / targetJankRate); // 目标 5%,实际 3%,得分 0.4
总结:度量体系不是用来「证明优化有效」的,而是用来「发现优化方向」的。每次改动后,四个维度都跑一遍,你就能看到 trade-off 在哪里。嗯,这才是性能优化的科学方法。