FPS 采集的 8 个坑——为什么你的帧率数据不可信

68 阅读10分钟

压测 2 小时,导出 FPS 数据一看:

  • 整段压测平均 FPS 只有 28,但用户说"用起来很流畅"
  • 接入采集工具后,卡顿率从 2% 飙升到 8%——拔掉工具卡顿就消失了
  • 列表滑动时 FPS 是 58,看起来"接近满帧",但用户反馈"明显卡"

以下是做 FPS 采集时排查出的 8 个问题,按杀伤力排序。


坑 1:静态页面 FPS = 0,拉低整体均值

现象:2 小时压测,平均 FPS 只有 28。但用户反馈"用起来挺流畅"。

翻看原始数据发现,大量时段 FPS = 0 或个位数。这些时段用户在看一个静态页面——没有滑动、没有动画、没有数据刷新。

原因:Android 的渲染是按需触发的。界面没有变化时,Choreographer 不会触发绘制,gfxinfo 不会产生新帧。FPS = 0 不是卡顿,是正常的省电行为

杀伤力:把 0 FPS 混入平均值,算出的 FPS 毫无意义。

正确做法

区分 "有绘制时段""静止时段"

1. 每个采集周期检查 gfxinfo 的 Total frames rendered
2. 如果帧数增量 = 0 → 标记为"静止",不参与 FPS 计算
3. 只统计有绘制时段的 FPS

有效 FPS = 仅计算"有帧产出"时段的帧率
统计方式2 小时均值含义
混入静止时段28 FPS毫无意义
仅统计有绘制时段57 FPS真实流畅度

实践建议:在报告中同时给出"有效 FPS"和"有绘制时段占比"。如果有绘制时段只占 30%,说明大部分时间界面是静止的。


坑 2:刷新率动态切换,基准线在漂移

现象:数据显示 FPS 一直在 55~60 之间,看起来"接近满帧"。但用户说在 120Hz 模式下明显感觉卡。

原因:设备是 120Hz 屏幕,满帧应该是 120 FPS。55~60 FPS 只有一半帧率,当然卡。

现代 Android 设备支持自适应刷新率

用户阅读静态内容 → 系统降到 60Hz
用户开始滑动列表 → 系统拉到 120Hz
视频播放 → 系统锁定 30Hz 或 60Hz

如果你的采集工具只看 FPS 数值,不知道当前刷新率是多少,就无法判断"58 FPS 是好还是坏"。

正确做法

每轮采集都要同时获取当前刷新率,计算帧率达成比:

帧率达成率 = 实际 FPS / 当前刷新率 × 100%
场景实际 FPS刷新率达成率结论
列表滑动5860Hz97%正常
列表滑动58120Hz48%严重掉帧

读取刷新率的方式:

# 推荐:从 SurfaceFlinger 获取当前活跃刷新率
adb shell dumpsys SurfaceFlinger | grep "active config"

# 备选:从 display 服务获取
adb shell dumpsys display | grep -i "mActiveMode"

坑 3:gfxinfo 的 buffer 只有 ~120 帧

现象:120Hz 设备上采集 FPS,每 5 秒取一次 dumpsys gfxinfo,发现帧数据不对。

原因:gfxinfo 的 PROFILEDATA 是一个环形缓冲区,最多保留约 120 帧

120Hz 设备:120 帧/秒
buffer 容量:~120 帧
buffer 填满时间:~1

如果你每 5 秒采集一次,前 4 秒的帧数据已经被覆盖了。你拿到的只是最后 ~1 秒的数据。

杀伤力:帧数据丢失,FPS 和卡顿统计不完整。120Hz 设备上最严重。

正确做法

60Hz 设备:采集间隔 ≤ 2 秒
90Hz 设备:采集间隔 ≤ 1.3 秒
120Hz 设备:采集间隔 ≤ 1 秒

或者使用 framestats 之外的汇总统计(Total frames rendered / Janky frames),这些计数器不受 buffer 限制,但你就拿不到每帧的详细耗时了。


坑 4:SurfaceFlinger layer 名不稳定

现象:采集脚本用硬编码的 layer 名去查 SurfaceFlinger,换了台设备就取不到数据。

原因:SurfaceFlinger 的 layer 命名格式随 Android 版本不断变化:

Android 版本layer 名格式
10 及以下SurfaceView - com.example.app/MainActivity
11com.example.app/MainActivity#0
12+com.example.app/com.example.app.MainActivity#0
13+可能含 Task ID 前缀

而且,同一个 App 可能有多个 layer:

  • 主 Activity 一个 layer
  • Dialog 弹窗一个 layer
  • PopupWindow 一个 layer
  • SurfaceView(视频/游戏)一个 layer

正确做法

不要硬编码 layer 名,用模糊匹配

# 先列出所有 layer
adb shell dumpsys SurfaceFlinger --list

# 用包名过滤
adb shell dumpsys SurfaceFlinger --list | grep "com.example.app"

# 取匹配到的第一个 layer

采集脚本中应该定期动态查找 layer(建议每 10 轮重新匹配一次),不要永久缓存。每轮都查反而会增加采集开销(详见坑 8)。


坑 5:gfxinfo reset 被别人踩了

现象:两个采集工具同时跑,数据都不准。

原因dumpsys gfxinfo <包名> reset 会清空帧计数器。如果 A 工具 reset 了,B 工具读到的增量就断了。

常见场景:

  • 开发者选项里的"GPU 呈现模式"也会读 gfxinfo
  • 多个性能采集 SDK 并行运行
  • CI 流水线上有多个测试任务并行

正确做法

  1. 只用一个工具做 reset:在你的采集工具里统一管理 reset 时机
  2. 不 reset,用差值法:记录上一次的 Total frames rendered,本次减去上一次得到增量
  3. 采集前检查:如果 Total frames 比上次小,说明被别人 reset 了,跳过本轮

坑 6:后台 App 无帧可采

现象:App 切到后台后,FPS 数据断崖式降到 0。

原因:App 在后台时,其 Surface 不会被 SurfaceFlinger 合成到屏幕上。Choreographer 也不会触发绘制。FPS 自然是 0。

杀伤力:如果不做前后台判断,后台的 0 FPS 会污染数据。

正确做法

每轮采集都检查目标 App 是否在前台:

方式 1(推荐,轻量):读取 /proc/<pid>/oom_score_adj
  oom_score_adj == 0 → 前台
  oom_score_adj > 0  → 后台
  耗时 < 5ms

方式 2(备选,重量级):dumpsys activity activities | grep topResumedActivity
  耗时 50~200ms,仅在 PID 未知时使用

如果目标包名不在前台:
  → 标记本轮为"后台",不参与 FPS 统计

方式 1 的效率比方式 2 快 10~40 倍,坑 8 会详细讲为什么减少 dumpsys 调用至关重要。


坑 7:gfxinfo 采不到游戏和视频的帧

现象:App 内嵌了一个视频播放器,gfxinfo 显示 FPS 正常,但用户说视频卡。

原因dumpsys gfxinfo 只统计通过 View 系统(Choreographer → RenderThread)渲染的帧。以下场景绕过了 View 系统:

场景渲染方式gfxinfo 能采吗
普通 UI(列表、按钮)View → RenderThread
游戏(SurfaceView)GL 线程 → Surface不能
视频播放(MediaCodec)解码器 → Surface不能
WebView 内的动画部分走 RenderThread部分能

正确做法

  • 普通 UI:用 gfxinfo
  • 游戏/SurfaceView:用 SurfaceFlinger --latency
  • 视频播放:用 SurfaceFlinger --latency(通用性最高,详见第 13 篇),辅以 dumpsys media.codec 获取解码性能数据。注意:dumpsys media.player 对抖音、快手、爱奇艺等主流 App 无效,它们都绕过了原生 MediaPlayer API
  • 混合场景:同时采集 gfxinfo 和 SurfaceFlinger

坑 8:采集工具本身拖慢帧率(观察者效应)

现象:接入 FPS 采集工具后,列表滑动的卡顿率从 2% 飙升到 8%。拔掉工具,卡顿消失。

原因:一轮完整的 FPS 采集,可能涉及多个 dumpsys 命令:

命令作用单次耗时是否每轮必须
dumpsys gfxinfo <pkg> framestats帧耗时数据50~150ms
/proc/<pid>/oom_score_adj前台判断< 5ms每 5~10 轮
dumpsys display刷新率20~50ms每 30 秒
dumpsys SurfaceFlinger --latency视频帧时间戳50~100ms仅视频场景
dumpsys SurfaceFlinger --listlayer 查找30~80ms每 10 轮

每个 dumpsys 都是一次 Binder IPC 调用,由 system_server 处理。如果每轮全部执行,消耗 150~380ms 的 system_server CPU 时间

具体干扰路径:

采集命令 → Binder 调用 → system_server 线程池忙 → 竞争 CPU 时间片
                                                    ↓
                                          App 的 RenderThread / UI 线程被抢占
                                                    ↓
                                              帧耗时增加 → 掉帧

在 120Hz 设备上尤为严重:

  • 一帧的预算只有 8.33ms
  • 如果每轮所有命令都顺序执行,可能占用 150~380ms
  • 如果每 1 秒采一次,设备有 15~38% 的时间在"为你的工具打工"

杀伤力:你测出来的卡顿,可能是采集工具自己造成的。数据失去参考意义。

正确做法

1. 合并 ADB 命令,减少 Binder 往返

# 错误:每个命令单独执行(3 次 USB/TCP 往返 + 3 次 Binder IPC)
adb shell dumpsys gfxinfo com.example.app framestats
adb shell dumpsys display
adb shell "dumpsys SurfaceFlinger --latency 'SurfaceView[...]'"

# 正确:合并为一次 ADB 调用(1 次 USB/TCP 往返)
adb shell "
  dumpsys gfxinfo com.example.app framestats;
  cat /proc/12345/oom_score_adj;
  dumpsys display
"

单次 ADB 调用比多次分开调用节省 100~300ms 的 USB/TCP 开销。

2. 按频率分级,非必要不采集

不是每个命令都需要每轮执行:

命令变化频率建议采集频率
dumpsys gfxinfo每帧都变每轮
/proc/<pid>/oom_score_adj(前台)切页面才变每 5~10 轮
dumpsys display(刷新率)很少变每 30 秒
dumpsys SurfaceFlinger --latency(视频帧)每帧都变每轮(仅视频模式)
dumpsys SurfaceFlinger --list(layer 查找)切页面才变每 10 轮

实际中大多数采集周期只需要执行 1~2 条命令(gfxinfo + 视频模式下的 SurfaceFlinger),其他结果复用缓存值。

3. 用轻量命令替代重量命令

# 前台判断:重量级
dumpsys activity activities | grep topResumedActivity
# 执行时间:50~200ms

# 前台判断:轻量级替代
cat /proc/<pid>/oom_score_adj
# oom_score_adj == 0 → 前台;> 0 → 后台
# 执行时间:< 5ms
# 注:旧版内核用 /proc/<pid>/oom_adj,新版内核(Android 9+)用 oom_score_adj

4. 量化工具自身开销

在报告中增加工具开销指标,让数据使用者知道"这份数据的噪声有多大":

工具开销统计:
  每轮采集平均耗时:85ms
  每轮 dumpsys 命令数:1.3(均值)
  采集周期占比:4.3%(85ms / 2000ms)
  system_server CPU 占比:2.1%

经验值:采集周期占比 < 5% 时,对帧率的干扰可忽略不计。

5. 终极方案:设备端 Agent

如果对干扰要求极高(如发布前的基准测试),可以把采集逻辑部署到设备端:

传统方案:PC ←USB→ adb ←Binder→ system_server
                 ↑
         每次都有 USB 往返 + Binder IPC

Agent 方案:设备端 shell 脚本定时采集 → 输出到 /sdcard
            PC 端定时 adb pull 结果文件
                 ↑
         采集过程无 USB 往返,Binder 开销不变但更可控

一张图看全貌

采集 FPS 前,先回答 5 个问题:

┌─ 采集工具开销可控吗?── 否 → 合并命令 / 降频 / 设备端 Agent
│
├─ App 在前台吗? ──── 否 → 跳过本轮
│
├─ 当前有绘制吗? ──── 否 → 标记"静止",不算入 FPS
│
├─ 当前刷新率多少? ── 动态获取,作为基准线
│
└─ 渲染走哪条管线?
     │
     ├─ View 系统(普通 UI)→ dumpsys gfxinfo
     │
     ├─ SurfaceView(游戏)→ SurfaceFlinger --latency
     │
     └─ 视频播放 → SurfaceFlinger --latency(通用)+ media.codec(辅助)

小结

杀伤力一句话
静态页面 FPS=0致命混入均值让数据毫无意义
刷新率动态切换严重不知道基准线,无法判断好坏
gfxinfo buffer 溢出严重120Hz 下 1 秒就溢出
采集工具拖慢帧率严重测出来的卡顿可能是工具自己造成的
layer 名不稳定中等换设备/版本就挂
gfxinfo reset 冲突中等多工具并行数据互相干扰
后台 FPS=0中等污染前台数据
gfxinfo 采不到游戏/视频中等选错数据源等于白采

掌握了这些坑,你的 FPS 数据才"能信"。下一篇我们讲UI 卡顿量化——帧率看起来没问题,但用户说"卡",问题出在哪。


系列目录

  • 第 1~4 篇:内存泄漏检测 & 内存采集避坑
  • 第 5~9 篇:CPU 采集系列(入门 → 避坑 → 降频 → 单核 → 落地)
  • 第 10 篇:FPS 帧率采集入门
  • 第 11 篇(本篇):FPS 采集的 8 个坑
  • 第 12 篇(下一篇):UI 卡顿量化——用数据回答"到底有多卡"
  • 第 13 篇:视频播放卡顿检测
  • 第 14 篇:FPS 采集落地方案

我是测试工坊,专注 Android 系统级性能工程。 如果你也在做帧率相关的性能测试,欢迎评论区交流 👇 关注我,后续更新不迷路。