做 Android 性能测试,CPU 和内存看完了,下一个绕不开的指标就是 FPS(Frames Per Second)。 但很多人只会看 "开发者选项 → GPU 呈现模式"里的柱状图,对帧率的数据源、计算方式、适用场景一知半解。 本篇从渲染管线讲起,先看一帧是怎么画出来的,再给三种数据源对比,最后用真实数据手算一遍 FPS。
一、帧率到底在度量什么
先明确一个概念:FPS 衡量的是"屏幕上每秒实际刷新了多少帧画面",不是"App 每秒请求了多少帧"。
和 CPU 的 tick 不同,帧不是匀速产生的。Android 的渲染是按需触发的——只有界面发生了变化(动画、滑动、数据刷新),系统才会画一帧。如果界面静止不动,FPS 就是 0,但用户感受不到卡顿。
一句话定义:
FPS = 一段时间内,实际被渲染并上屏的帧数 / 时间窗口
这里面有两个要点:
- "实际被渲染"——不是所有 VSYNC 信号都会产生帧,只有有绘制任务的才算
- "上屏"——帧必须最终到达 SurfaceFlinger 被合成到屏幕上,才算一帧
二、一帧是怎么画出来的
要理解 FPS 从哪采、怎么算,必须先了解 Android 的渲染管线。一帧从触发到上屏,经过 5 个阶段:
VSYNC 信号
│
▼
┌──────────────────────────────────────────────────────┐
│ 阶段 1:Choreographer 调度 │
│ · 收到 VSYNC 信号,触发本帧回调 │
│ · 分发 Input → Animation → Traversal 回调 │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 阶段 2:UI 线程(主线程) │
│ · measure → layout → draw │
│ · 构建 DisplayList(绘制指令列表) │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 阶段 3:RenderThread(渲染线程) │
│ · 接收 DisplayList │
│ · 执行 GPU 绘制命令(OpenGL / Vulkan) │
│ · 输出 Buffer │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 阶段 4:SurfaceFlinger(系统合成器) │
│ · 收集所有 App 的 Buffer │
│ · 合成最终画面(Hardware Composer / GPU Composition) │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 阶段 5:Display(屏幕显示) │
│ · 按刷新率(60/90/120Hz)扫描输出 │
└──────────────────────────────────────────────────────┘
关键理解:
- VSYNC 是整个管线的节拍器。屏幕每次刷新前发出一个 VSYNC 信号,刷新率 60Hz 意味着每 16.67ms 一个信号
- 帧耗时 = 从收到 VSYNC 到帧最终上屏的总时间。如果帧耗时 > 1 个 VSYNC 周期,用户就会感知到卡顿
- App 的渲染发生在 UI 线程 + RenderThread,采集 FPS 就是在度量这两个线程的产出效率
三、刷新率:FPS 的天花板
FPS 有一个物理上限——屏幕刷新率。
| 刷新率 | VSYNC 周期 | 最大 FPS | 常见设备 |
|---|---|---|---|
| 60Hz | 16.67ms | 60 | 中低端机、老旗舰 |
| 90Hz | 11.11ms | 90 | 部分中端机 |
| 120Hz | 8.33ms | 120 | 旗舰机 |
| 144Hz | 6.94ms | 144 | 游戏手机 |
现代 Android 设备支持动态刷新率(Adaptive Refresh Rate),同一台设备在不同场景下可能切换刷新率:
- 静态页面 → 降到 60Hz 省电
- 列表滑动 → 拉到 120Hz 保流畅
- 视频播放 → 锁定 30/60Hz 匹配视频帧率
这意味着:同一段数据里,"满帧"的基准线可能在变。后面的坑篇(#11)会详细讲这个问题。
查看设备当前刷新率:
# 方式 1:查看当前刷新率
adb shell dumpsys display | grep -i "DisplayDeviceInfo"
# 方式 2:查看用户设置的最高刷新率
adb shell settings get system peak_refresh_rate
# 方式 3:直接看 SurfaceFlinger
adb shell dumpsys SurfaceFlinger | grep -i "refresh"
四、三大数据源
和 CPU 只有 /proc/stat 一个权威数据源不同,FPS 有三种主流采集方式,各有优劣。
4.1 dumpsys gfxinfo(推荐)
这是 Google 官方推荐的帧率统计工具,直接对接 RenderThread 的帧数据。
# 获取帧统计(含详细的每帧耗时)
adb shell dumpsys gfxinfo <包名>
# 获取后立即重置计数器(推荐,避免累积数据)
adb shell dumpsys gfxinfo <包名> reset
输出示例(关键部分):
Total frames rendered: 4523
Janky frames: 127 (2.81%)
50th percentile: 6ms
90th percentile: 12ms
95th percentile: 18ms
99th percentile: 32ms
---PROFILEDATA---
Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssuedDrawCommandsStart,SwapBuffers,FrameCompleted,DequeueBufferDuration,QueueBufferDuration,GpuCompleted,
0,40966236498,40966236498,9223372036854775807,0,40966237871,40966238037,40966238204,40966240537,40966242871,40966243204,40966244037,40966245871,40966248204,260000,
0,40982903165,40982903165,9223372036854775807,0,40982904538,40982904704,40982904871,40982907204,40982909538,40982909871,40982910704,40982912538,40982914871,186000,
---PROFILEDATA---
PROFILEDATA 中每一行就是一帧,从 IntendedVsync 到 FrameCompleted 的时间差就是帧耗时。
| 字段 | 含义 |
|---|---|
| IntendedVsync | 本帧预期的 VSYNC 时间戳(纳秒) |
| Vsync | 实际收到 VSYNC 的时间 |
| HandleInputStart | 开始处理输入事件 |
| AnimationStart | 开始执行动画回调 |
| PerformTraversalsStart | 开始 measure/layout |
| DrawStart | 开始绘制 |
| SyncQueued / SyncStart | RenderThread 同步 |
| FrameCompleted | 帧完成时间戳 |
帧耗时计算:
帧耗时 = FrameCompleted − IntendedVsync
以第一帧为例:
帧耗时 = 40966248204 − 40966236498 = 11,706 ns ≈ 11.7ms
11.7ms < 16.67ms → 这帧在 60Hz 下是正常帧。
4.2 dumpsys SurfaceFlinger --latency
SurfaceFlinger 是系统级合成器,它记录了每个 layer(App 窗口)的帧提交时间戳。
# 需要先找到目标 App 的 layer 名
adb shell dumpsys SurfaceFlinger --list
# 然后获取该 layer 的帧时间戳
adb shell "dumpsys SurfaceFlinger --latency 'SurfaceView - com.example.app/com.example.app.MainActivity#0'"
输出示例:
16666666
7657
40966236498 40966248204 40966264871
40982903165 40982914871 40982931538
40999569832 40999581538 40999598204
第一行 16666666 是 VSYNC 周期(纳秒),对应 60Hz。
后续每行 3 个时间戳:
| 列 | 含义 |
|---|---|
| 第 1 列 | App 提交 Buffer 的时间 |
| 第 2 列 | SurfaceFlinger 获取 Buffer 的时间 |
| 第 3 列 | Buffer 上屏的时间 |
FPS 计算方法:取第 3 列(上屏时间),计算相邻帧的时间差,统计 1 秒内有多少帧上屏。
4.3 Choreographer.FrameCallback(代码埋点)
在 App 代码中注册回调,每帧调用一次,可以精确拿到帧间隔:
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
long lastFrameTimeNanos = 0;
@Override
public void doFrame(long frameTimeNanos) {
if (lastFrameTimeNanos != 0) {
long frameDuration = frameTimeNanos - lastFrameTimeNanos;
// frameDuration 就是帧间隔(纳秒)
// FPS = 1_000_000_000 / frameDuration
}
lastFrameTimeNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});
4.4 三种方式对比
| 维度 | dumpsys gfxinfo | SurfaceFlinger --latency | Choreographer 回调 |
|---|---|---|---|
| 数据来源 | RenderThread 帧统计 | SurfaceFlinger layer | App 进程内 |
| 是否需要侵入 App | 否 | 否 | 是(需改代码) |
| 帧耗时拆解 | 支持(6 个阶段) | 不支持 | 不支持 |
| 卡顿统计 | 自带 Janky frames | 需自己算 | 需自己算 |
| buffer 上限 | ~120 帧 | ~128 帧 | 无限制 |
| 游戏场景支持 | 不支持 SurfaceView | 支持 | 不支持 |
| Android 版本兼容 | 4.1+ | 各版本 layer 名变化大 | 4.1+ |
| 采集开销 | 中(~100ms) | 中(~80ms) | 低 |
| 适合场景 | 普通 App 帧率 + 卡顿分析 | 游戏/视频 layer 帧率 | 精确帧间隔监控 |
建议:
- 普通 App → gfxinfo(数据最丰富,不需要改代码)
- 游戏/SurfaceView → SurfaceFlinger(gfxinfo 采不到)
- 需要实时帧间隔 → Choreographer(最精确)
五、完整计算实例
用 dumpsys gfxinfo 的 PROFILEDATA 数据,从头到尾走一遍 FPS 计算。
5.1 原始数据
假设采集到以下 10 帧数据(单位:纳秒):
| 帧序号 | IntendedVsync | FrameCompleted | 帧耗时 |
|---|---|---|---|
| 1 | 40,966,236,498 | 40,966,248,204 | 11.7ms |
| 2 | 40,982,903,165 | 40,982,914,871 | 11.7ms |
| 3 | 40,999,569,832 | 41,100,769,832 | 101.2ms |
| 4 | 41,016,236,499 | 41,016,251,205 | 14.7ms |
| 5 | 41,032,903,166 | 41,032,914,872 | 11.7ms |
| 6 | 41,049,569,833 | 41,049,581,539 | 11.7ms |
| 7 | 41,066,236,500 | 41,066,248,206 | 11.7ms |
| 8 | 41,082,903,167 | 41,082,914,873 | 11.7ms |
| 9 | 41,099,569,834 | 41,099,581,540 | 11.7ms |
| 10 | 41,116,236,501 | 41,116,248,207 | 11.7ms |
5.2 计算 FPS
总时间 = 最后一帧 IntendedVsync − 第一帧 IntendedVsync
= 41,116,236,501 − 40,966,236,498
= 150,000,003 ns
≈ 150ms
帧数 = 10 − 1 = 9(帧间隔数)
FPS = 9 / 0.150 = 60 FPS
注意:FPS 用的是帧间隔数(N-1),不是帧数。10 帧之间有 9 个间隔。
5.3 识别卡顿帧
假设当前刷新率 60Hz,VSYNC 周期 = 16.67ms。
正常帧阈值 = 16.67ms × 1 = 16.67ms
帧 1:11.7ms → 正常 ✓
帧 2:11.7ms → 正常 ✓
帧 3:101.2ms → 101.2 / 16.67 ≈ 6.1x VSYNC → 严重卡顿 ✗
帧 4:14.7ms → 正常 ✓
...
帧 3 耗时 101.2ms,相当于跳过了 6 个 VSYNC 周期——用户会感受到明显的冻帧。
5.4 统计指标
总帧数:10
卡顿帧数(> 1x VSYNC):1
卡顿率 = 1 / 10 = 10%
帧耗时排序:11.7, 11.7, 11.7, 11.7, 11.7, 11.7, 11.7, 11.7, 14.7, 101.2
P50 帧耗时 = 11.7ms
P90 帧耗时 = 101.2ms(仅 10 帧,P90 已触及极端值)
P99 帧耗时 = 101.2ms
注意:样本量只有 10 帧时,P90/P99 几乎等于最大值,参考意义有限。实际采集中一个周期通常有 60~120 帧,百分位数据才有统计意义。
六、游戏场景的差异(简要)
普通 App 的 UI 通过 View 系统渲染,帧数据被 gfxinfo 统计。但游戏通常使用 SurfaceView 或 GLSurfaceView,有自己独立的渲染线程和 Surface,绕过了 View 系统。
| 维度 | 普通 App | 游戏(SurfaceView) |
|---|---|---|
| 渲染方式 | View → RenderThread | 自建 GL 线程 → Surface |
| gfxinfo 能采到吗 | 能 | 不能 |
| SurfaceFlinger 能采到吗 | 能 | 能 |
| 帧率上限 | 屏幕刷新率 | 可以不锁帧 |
如果你的采集对象是游戏,需要走 SurfaceFlinger 路径。本系列以普通 App 为主,游戏场景的差异会在需要时标注。
七、完整采集流程
把前面的内容串起来,一次完整的 FPS 采集分三步:
┌───────────────────────────────────────────────────────┐
│ Step 1:获取设备刷新率 │
│ │
│ · dumpsys display → 当前刷新率(60/90/120Hz) │
│ · 计算 VSYNC 周期 = 1,000,000,000 / 刷新率(纳秒) │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ Step 2:采集帧数据 │
│ │
│ · dumpsys gfxinfo <包名> reset │
│ · 等待 N 秒(推荐 1~2 秒,避免 buffer 溢出) │
│ · dumpsys gfxinfo <包名> │
│ · 解析 PROFILEDATA 中的每帧时间戳 │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ Step 3:计算指标 │
│ │
│ · FPS = (帧数 − 1) / 总时间窗口 │
│ · 帧耗时 = FrameCompleted − IntendedVsync │
│ · 卡顿帧 = 帧耗时 > VSYNC 周期的帧 │
│ · 卡顿率 = 卡顿帧数 / 总帧数 │
│ · P90/P99 帧耗时 │
└───────────────────────────────────────────────────────┘
小结
| 知识点 | 一句话 |
|---|---|
| FPS 的本质 | 每秒实际被渲染并上屏的帧数 |
| 渲染管线 | VSYNC → Choreographer → UI 线程 → RenderThread → SurfaceFlinger → 屏幕 |
| FPS 上限 | 取决于屏幕刷新率(60/90/120Hz) |
| 推荐数据源 | 普通 App 用 dumpsys gfxinfo,游戏用 SurfaceFlinger |
| 帧耗时计算 | FrameCompleted − IntendedVsync |
| 卡顿判断 | 帧耗时 > VSYNC 周期 |
| 和 CPU 的关系 | CPU 降频 → 渲染变慢 → 帧耗时增大 → FPS 下降 |
掌握了这些基础,你就知道 FPS 的数据从哪来、怎么算了。下一篇我们讲采集 FPS 时踩过的 8 个坑——每个都可能让你的帧率数据严重失真。
系列目录
- 第 1~4 篇:内存泄漏检测 & 内存采集避坑
- 第 5~9 篇:CPU 采集系列(入门 → 避坑 → 降频 → 单核 → 落地)
- 第 10 篇(本篇):FPS 帧率采集入门
- 第 11 篇(下一篇):FPS 采集的 8 个坑
- 第 12 篇:UI 卡顿量化——从帧耗时到卡顿分级
- 第 13 篇:视频播放卡顿检测
- 第 14 篇:FPS 采集落地方案
我是测试工坊,专注 Android 系统级性能工程。 如果你也在做帧率相关的性能测试,欢迎评论区交流 👇 关注我,后续更新不迷路。