压测 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 | 刷新率 | 达成率 | 结论 |
|---|---|---|---|---|
| 列表滑动 | 58 | 60Hz | 97% | 正常 |
| 列表滑动 | 58 | 120Hz | 48% | 严重掉帧 |
读取刷新率的方式:
# 推荐:从 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 |
| 11 | com.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 流水线上有多个测试任务并行
正确做法
- 只用一个工具做 reset:在你的采集工具里统一管理 reset 时机
- 不 reset,用差值法:记录上一次的
Total frames rendered,本次减去上一次得到增量 - 采集前检查:如果 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 --list | layer 查找 | 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 系统级性能工程。 如果你也在做帧率相关的性能测试,欢迎评论区交流 👇 关注我,后续更新不迷路。