Android FPS 帧率采集入门:从渲染管线到数据源

34 阅读11分钟

做 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 信号,触发本帧回调                        │
│  · 分发 InputAnimation → Traversal 回调             │
└──────────────────────────────────────────────────────┘
    │
    ▼
┌──────────────────────────────────────────────────────┐
│  阶段 2:UI 线程(主线程)                              │
│  · measure → layout → draw                           │
│  · 构建 DisplayList(绘制指令列表)                     │
└──────────────────────────────────────────────────────┘
    │
    ▼
┌──────────────────────────────────────────────────────┐
│  阶段 3:RenderThread(渲染线程)                       │
│  · 接收 DisplayList                                   │
│  · 执行 GPU 绘制命令(OpenGL / Vulkan)                 │
│  · 输出 Buffer                                        │
└──────────────────────────────────────────────────────┘
    │
    ▼
┌──────────────────────────────────────────────────────┐
│  阶段 4:SurfaceFlinger(系统合成器)                    │
│  · 收集所有 App 的 Buffer                              │
│  · 合成最终画面(Hardware Composer / GPU Composition)   │
└──────────────────────────────────────────────────────┘
    │
    ▼
┌──────────────────────────────────────────────────────┐
│  阶段 5Display(屏幕显示)                            │
│  · 按刷新率(60/90/120Hz)扫描输出                      │
└──────────────────────────────────────────────────────┘

关键理解

  • VSYNC 是整个管线的节拍器。屏幕每次刷新前发出一个 VSYNC 信号,刷新率 60Hz 意味着每 16.67ms 一个信号
  • 帧耗时 = 从收到 VSYNC 到帧最终上屏的总时间。如果帧耗时 > 1 个 VSYNC 周期,用户就会感知到卡顿
  • App 的渲染发生在 UI 线程 + RenderThread,采集 FPS 就是在度量这两个线程的产出效率

三、刷新率:FPS 的天花板

FPS 有一个物理上限——屏幕刷新率

刷新率VSYNC 周期最大 FPS常见设备
60Hz16.67ms60中低端机、老旗舰
90Hz11.11ms90部分中端机
120Hz8.33ms120旗舰机
144Hz6.94ms144游戏手机

现代 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 中每一行就是一帧,从 IntendedVsyncFrameCompleted 的时间差就是帧耗时。

字段含义
IntendedVsync本帧预期的 VSYNC 时间戳(纳秒)
Vsync实际收到 VSYNC 的时间
HandleInputStart开始处理输入事件
AnimationStart开始执行动画回调
PerformTraversalsStart开始 measure/layout
DrawStart开始绘制
SyncQueued / SyncStartRenderThread 同步
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 gfxinfoSurfaceFlinger --latencyChoreographer 回调
数据来源RenderThread 帧统计SurfaceFlinger layerApp 进程内
是否需要侵入 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 帧数据(单位:纳秒):

帧序号IntendedVsyncFrameCompleted帧耗时
140,966,236,49840,966,248,20411.7ms
240,982,903,16540,982,914,87111.7ms
340,999,569,83241,100,769,832101.2ms
441,016,236,49941,016,251,20514.7ms
541,032,903,16641,032,914,87211.7ms
641,049,569,83341,049,581,53911.7ms
741,066,236,50041,066,248,20611.7ms
841,082,903,16741,082,914,87311.7ms
941,099,569,83441,099,581,54011.7ms
1041,116,236,50141,116,248,20711.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 系统级性能工程。 如果你也在做帧率相关的性能测试,欢迎评论区交流 👇 关注我,后续更新不迷路。