2. 帧率监控核心指标:FPS、帧耗时、掉帧数、卡顿率

帧率监控是 UI 性能评估的起点,但不能只盯着 FPS(Frames Per Second)一个数字。真正稳定、可量化的监控需要同时采集 帧耗时、掉帧数 和 卡顿率 三个衍生指标。本章逐一拆解每个指标的定义、计算方式、典型阈值,并用 Choreographer 回调原始数据演示如何推导它们。

核心原则:FPS 是结果,掉帧数是现象,卡顿率是体验。监控系统应同时输出四个值,才能定位是“持续低帧”还是“偶发跳帧”。

2.1 FPS —— 帧率瞬时值与平均值

定义:每秒内屏幕刷新的帧数,单位 fps。Android 系统默认 60fps,高刷屏(90/120/144Hz)对应更高目标。

计算方式:在 Choreographer 回调中记录两次 doFrame 的时间戳,用 1000 / (tn - tn-1) 得到瞬时帧率。为避免毛刺,通常取 1 秒窗口内的平均帧数。

场景目标 FPS可接受下限说明
静态界面6050无动画时不应掉帧
列表滑动6045允许少量掉帧,但必须平滑
动画/转场6040复杂动画可接受短暂低帧
120Hz 高刷12090需配合刷新率判断

2.2 帧耗时 —— 单帧渲染总时长

定义:从 Choreographer.doFrame 回调开始到 onDraw / onLayout / onMeasure 完成的时间差。Android 的 FrameMetrics 更精确,但自定义监控通常只取 doFrame 时间戳之差。

关键点:帧耗时 ≥ 16.67ms(60Hz)或 ≥ 8.33ms(120Hz)时即发生掉帧。监控脚本中应记录每一帧的耗时分布。

// 伪代码:帧耗时采集
private long frameStartTime;
public void doFrame(long frameTimeNanos) {
    if (frameStartTime != 0) {
        long frameDurationMs = (frameTimeNanos - frameStartTime) / 1_000_000L;
        // 上报到监控队列
    }
    frameStartTime = frameTimeNanos;
}

2.3 掉帧数 —— 超过阈值的帧数

定义:在一段时间内,帧耗时超过目标刷新间隔的帧数。计算公式:

掉帧数 = Σ (frameDurationMs > targetIntervalMs ? 1 : 0)

阈值:

  • 60Hz 下 targetIntervalMs = 16.67ms,超过即记 1 掉帧。
  • 建议同时统计 严重掉帧(> 32ms 即超过 2 帧)以捕捉卡顿。

2.4 卡顿率 —— 体验量化指标

定义:掉帧帧数占总帧数的比例。常用指标有 Janky Frames %(掉帧帧占比)和 Big Jank Frames %(严重掉帧占比)。

卡顿率 = (掉帧帧数 / 总帧数) × 100%

行业推荐标准:

体验等级卡顿率建议
优秀< 2%无感知
良好2% – 5%可接受,优化后一般可以做到
一般5% – 10%用户能感觉到闪烁或顿挫
差≥ 10%严重影响体验,必须介入优化
注意:卡顿率不能代替单帧耗时分析。即便卡顿率只有 3%,如果掉帧发生时连续出现 400ms 以上的卡顿,依然会造成非常差的用户体验。建议同时监控“单次卡顿时长”。

2.5 指标推导流程图

下图展示从 Choreographer 回调到四个核心指标的完整推导路径。

帧率监控核心指标推导流程 Choreographer.doFrame 记录时间戳 & 帧计数器 +1 计算帧耗时 (tₙ - tₙ₋₁) FPS 1000ms / 帧耗时 掉帧数 计数 >16.67ms 的帧 卡顿率 掉帧数 / 总帧数 四个指标联动分析,定位问题根因

2.6 代码实现:从 doFrame 到指标输出

以下片段展示完整的数据采集与指标计算。注意使用 ArrayList 暂存最近 1000 帧以便统计卡顿率。

public class FrameMonitor implements Choreographer.FrameCallback {
    private static final int WINDOW_SIZE = 1000;
    private final long[] frameTimes = new long[WINDOW_SIZE];
    private int index = 0;
    private long previousFrameTimeNanos;
    private int totalFrames;
    private int jankyFrames;

    @Override
    public void doFrame(long frameTimeNanos) {
        if (previousFrameTimeNanos == 0) {
            previousFrameTimeNanos = frameTimeNanos;
            Choreographer.getInstance().postFrameCallback(this);
            return;
        }
        long diffMs = (frameTimeNanos - previousFrameTimeNanos) / 1_000_000L;
        previousFrameTimeNanos = frameTimeNanos;

        // 存储帧耗时(单位 ms)
        frameTimes[index % WINDOW_SIZE] = diffMs;
        index++;
        totalFrames++;

        // 判断掉帧(60Hz 下目标 16.67ms)
        final long targetMs = 16_666_666L / 1_000_000L; // 约 16.67ms
        if (diffMs > targetMs) {
            jankyFrames++;
        }

        // 每采集 60 帧上报一次
        if (totalFrames % 60 == 0) {
            float fps = 1000f / (float)(diffMs == 0 ? 1 : diffMs);
            float jankRate = (float)jankyFrames / (float)totalFrames * 100f;
            Log.i("FrameMonitor", "FPS=" + fps + ", 帧耗时=" + diffMs
                + "ms, 掉帧数=" + jankyFrames + ", 卡顿率=" + jankRate + "%");
        }

        Choreographer.getInstance().postFrameCallback(this);
    }
}

2.7 部署与常见错误

部署建议:

  • 只在 Debug 包或线上采样(1% 用户)中开启,避免 Choreographer 回调额外开销影响 UI 本身。
  • 采样周期建议 5~10 分钟,或触发滑动/动画时自动开启 30 秒窗口。

常见错误及处理:

错误现象原因解决方案
FPS 持续显示个位数未正确获取 System.nanoTime(),时间戳溢出使用 frameTimeNanos 而非 local 时间
卡顿率始终为 0%比较阈值使用整数 16,忽略了舍入使用 long 类型的纳秒比较,或 16_666_666L
掉帧数不准确忽略了 Choreographer 跳过回调的情况同时检测 skip 帧:visitedCount 与 expected 不一致
高刷屏 FPS 被误判固定 16.67ms 阈值通过 Display.getRefreshRate() 动态计算 targetMs
性能优化提示:避免在 doFrame 回调内做同步日志或网络上报。使用异步队列(如 HandlerThread)写入 SQLite 或上报到后台,防止回调阻塞导致下一帧被推迟。

2.8 稳定性和自动化

在自动化测试中,通过 UiAutomator 或 Espresso 触发 UI 操作,然后拉取 Logcat 中的 “FrameMonitor” 标签,解析 FPS、掉帧数、卡顿率。可结合 CI/CD 设置阈值卡顿率 > 5% 时标记构建失败。

# Shell 示例:从 logcat 提取卡顿率
adb logcat -d | grep "FrameMonitor" | tail -n 1 | grep -oP '卡顿率=\K[\d.]+'