4. 计算实时帧率与单帧耗时:帧间隔计算、FPS 公式、毫秒级精度处理
帧率监控的核心是从相邻两帧的时间差中提取出两个关键指标:实时 FPS 和 单帧耗时。Choreographer 提供的回调时间戳精度为纳秒级,直接转换即可得到毫秒级帧间隔。本章从数学原理出发,逐步拆解时间戳采集、帧间隔计算、FPS 公式推导以及高精度转换的实现细节。
4.1 帧间隔与 FPS 的数学关系
帧间隔(Frame Interval)定义为连续两帧之间的时间差,单位通常为毫秒(ms)。FPS(Frames Per Second)与帧间隔互为倒数关系,计算公式为:
FPS = 1000 / Δt(ms)
下表列出常见帧间隔与对应 FPS,便于开发时快速对照评估性能等级:
| 帧间隔 Δt (ms) | FPS | 流畅度等级 | 典型场景 |
|---|---|---|---|
| 33.33 | 30 | 低 | 视频播放 / 入门设备 |
| 22.22 | 45 | 中等 | 中端手机日常使用 |
| 16.67 | 60 | 流畅 | 主流旗舰标准 |
| 11.11 | 90 | 高刷 | 90Hz 屏幕 |
| 8.33 | 120 | 电竞 | 120Hz 游戏 |
| 6.94 | 144 | 极速 | 144Hz 专业电竞 |
| 4.17 | 240 | 超高分 | 240Hz 触控采样屏 |
关键结论:帧间隔越短,FPS 越高;当帧间隔超过 16.67ms 时,用户肉眼即可感知卡顿。掉帧的本质就是帧间隔突然跳升到 33ms 甚至 50ms 以上。
4.2 基于 Choreographer 的帧间隔采集实现
通过 Choreographer.FrameCallback 的 doFrame(long frameTimeNanos) 可获取纳秒级时间戳。每次回调记录当前时间戳并与上一次相减即得帧间隔。首次回调需要丢弃,因为没有前序参考点。
下面是一个最简的帧间隔采集与 FPS 计算实现:
public class SimpleFpsTracker implements Choreographer.FrameCallback {
private static final String TAG = "FpsTracker";
private long lastFrameTimeNs = 0;
private long frameCount = 0;
@Override
public void doFrame(long frameTimeNanos) {
if (lastFrameTimeNs == 0) {
// 首次回调,仅记录时间戳,不计算
lastFrameTimeNs = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
return;
}
// 计算帧间隔(纳秒)
long intervalNs = frameTimeNanos - lastFrameTimeNs;
// 转换为毫秒(保留 1 位小数)
double intervalMs = intervalNs / 1_000_000.0;
// 计算瞬时 FPS
double fps = 1000.0 / intervalMs;
Log.d(TAG, String.format("帧间隔: %.2f ms | 瞬时 FPS: %.1f", intervalMs, fps));
lastFrameTimeNs = frameTimeNanos;
frameCount++;
Choreographer.getInstance().postFrameCallback(this);
}
public void start() {
Choreographer.getInstance().postFrameCallback(this);
}
public void stop() {
Choreographer.getInstance().removeFrameCallback(this);
}
}
注意:
frameTimeNanos 是 Choreographer 内部统一的时间基准(CLOCK_MONOTONIC),不受系统时间跳变影响,适合做高精度间隔测量。
4.3 毫秒级精度转换与滑动窗口 FPS
直接使用单帧间隔计算出的瞬时 FPS 波动大,不适合作为稳定性评判依据。实际工程中采用滑动窗口平均帧间隔来平滑噪声,窗口大小通常取 30~60 帧(对应 0.5s~1s)。
以下实现维护一个环形缓冲区,存储最近 N 帧的间隔(纳秒),过滤异常值后计算平均 FPS:
public class SmoothFpsMonitor {
private static final int WINDOW_SIZE = 60;
private final long[] intervalsNs = new long[WINDOW_SIZE];
private int index = 0;
private int count = 0;
private long lastFrameTimeNs = 0;
// 每帧回调时调用此方法
public void onFrame(long frameTimeNanos) {
if (lastFrameTimeNs == 0) {
lastFrameTimeNs = frameTimeNanos;
return;
}
long intervalNs = frameTimeNanos - lastFrameTimeNs;
lastFrameTimeNs = frameTimeNanos;
// 过滤异常:小于 1ms 或大于 200ms 的间隔直接丢弃
if (intervalNs < 1_000_000 || intervalNs > 200_000_000) {
return;
}
// 写入环形缓冲区
intervalsNs[index] = intervalNs;
index = (index + 1) % WINDOW_SIZE;
if (count < WINDOW_SIZE) count++;
}
// 获取当前平均帧间隔 (ms) 和 FPS
public double[] getAverage() {
if (count == 0) return new double[]{0, 0};
long sum = 0;
int valid = 0;
for (int i = 0; i < count; i++) {
if (intervalsNs[i] > 0) {
sum += intervalsNs[i];
valid++;
}
}
if (valid == 0) return new double[]{0, 0};
double avgIntervalMs = (sum / (double) valid) / 1_000_000.0;
double avgFps = 1000.0 / avgIntervalMs;
return new double[]{avgIntervalMs, avgFps};
}
}
毫秒级精度处理要点:
- 使用
double而非float做除法,保留小数点后 2 位有效数字。 - 纳秒转毫秒时除以
1_000_000.0而非1_000_000,避免整数截断。 - 输出用
String.format("%.2f", value)控制精度,便于日志对齐。
// 精度处理示例
long intervalNs = 16_666_666; // 约 16.67ms
double intervalMs = intervalNs / 1_000_000.0; // → 16.666666
double fps = 1000.0 / intervalMs; // → 60.000000
String report = String.format("间隔: %.2f ms, FPS: %.1f", intervalMs, fps);
// 输出: "间隔: 16.67 ms, FPS: 60.0"
4.4 常见精度陷阱与避坑指南
陷阱 1:首次回调不丢弃
第一次
第一次
doFrame 的 frameTimeNanos 与 0 相减会得到一个巨大的帧间隔,导致 FPS 趋近于 0。必须显式判断 lastFrameTimeNs == 0 跳过第一次计算。
陷阱 2:整数除法截断
long intervalMs = intervalNs / 1000000; 会丢失小数部分,使帧间隔偏小、FPS 虚高。始终使用 intervalNs / 1_000_000.0 保持 double 精度。
陷阱 3:异常值污染窗口
应用冷启动、Activity 切换、GC 停顿等场景会产生极端帧间隔(如 500ms)。滑动窗口必须加入阈值过滤,一般保留 1ms~200ms 范围内的间隔。
应用冷启动、Activity 切换、GC 停顿等场景会产生极端帧间隔(如 500ms)。滑动窗口必须加入阈值过滤,一般保留 1ms~200ms 范围内的间隔。
最佳实践:将帧间隔原始数据写入本地日志或 trace 文件,结合
Systrace 或 Perfetto 进行归因分析。仅凭 FPS 数字无法定位具体掉帧原因,必须配合单帧耗时的时间戳上下文。
完成本章后,你已经掌握了从 Choreographer 原始纳秒时间戳到实时 FPS 和单帧耗时的完整计算链路。下一环节将基于此链路构建掉帧检测器,并定义“掉帧阈值”与“卡顿分级标准”。