2. 帧率监控核心指标:FPS、帧耗时、掉帧数、卡顿率
帧率监控是 UI 性能评估的起点,但不能只盯着 FPS(Frames Per Second)一个数字。真正稳定、可量化的监控需要同时采集 帧耗时、掉帧数 和 卡顿率 三个衍生指标。本章逐一拆解每个指标的定义、计算方式、典型阈值,并用 Choreographer 回调原始数据演示如何推导它们。
2.1 FPS —— 帧率瞬时值与平均值
定义:每秒内屏幕刷新的帧数,单位 fps。Android 系统默认 60fps,高刷屏(90/120/144Hz)对应更高目标。
计算方式:在 Choreographer 回调中记录两次 doFrame 的时间戳,用 1000 / (tn - tn-1) 得到瞬时帧率。为避免毛刺,通常取 1 秒窗口内的平均帧数。
| 场景 | 目标 FPS | 可接受下限 | 说明 |
|---|---|---|---|
| 静态界面 | 60 | 50 | 无动画时不应掉帧 |
| 列表滑动 | 60 | 45 | 允许少量掉帧,但必须平滑 |
| 动画/转场 | 60 | 40 | 复杂动画可接受短暂低帧 |
| 120Hz 高刷 | 120 | 90 | 需配合刷新率判断 |
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% | 严重影响体验,必须介入优化 |
2.5 指标推导流程图
下图展示从 Choreographer 回调到四个核心指标的完整推导路径。
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 |
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.]+'