4. 启动性能的度量体系

聊启动优化,第一件事不是动手改代码,而是先搞清楚——怎么才算「快」?

我见过不少团队,上来就怼启动时间,结果优化了半天,用户反馈还是卡。为什么?因为启动性能是个多维度的东西,光看一个指标,很容易掉坑里。

这一章,我把度量体系拆成四个核心维度:启动时间、启动耗电、启动内存峰值、启动卡顿率。每个维度我都会讲清楚怎么测、怎么算、避坑点在哪。

核心观点:启动性能不是单一数字,而是一组相互制衡的指标。优化一个维度,可能牺牲另一个。度量体系的意义,就是让你在做决策时有据可依。

启动性能度量体系 启动性能度量 启动时间 启动耗电 内存峰值 卡顿率 TTI / FPS / 帧率 冷启动 / 热启动 电流采样 / 功耗曲线 BatteryHistorian PSS / Java Heap Native / GPU 内存 FrameDrop / Jank 冻帧 / 丢帧率 四个维度相互制衡,需要综合评估

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 综合度量:四个维度怎么权衡?

四个维度不是孤立的。你压启动时间,可能推高内存峰值;你降内存,可能增加卡顿率。怎么权衡?

我个人习惯用 加权评分法:

  1. 确定权重:根据产品定位分配权重。比如工具类 App,启动时间权重 50%,卡顿率 30%,内存 15%,耗电 5%。游戏类 App,卡顿率权重最高。
  2. 归一化打分:每个维度设定目标值,实际值除以目标值得出分数。超过目标值扣分,低于目标值加分。
  3. 计算总分:加权求和,得出综合评分。
// 加权评分示例
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 在哪里。嗯,这才是性能优化的科学方法。