用户投诉:"列表滑的时候卡了一下"。 你打开性能数据:FPS = 58,接近满帧 60。你说"没问题啊"。 用户说:"就是卡了"。
谁对?都对。FPS 确实是 58,但 FPS 看不到那一帧 200ms 的冻帧。
本篇解决一个问题:怎么用数据量化"到底有多卡"------不是靠主观感受,而是给出具体的数字和判定标准。
一、先看全貌:5 个指标各管什么
量化 UI 卡顿不是一个指标能搞定的。不同指标回答不同的问题:
arduino
用户说"卡了"
│
├─ 整体帧率够不够? → 看 FPS 和帧率达成率
│
├─ 有多少帧是卡的? → 看 卡顿率
│
├─ 最严重那次卡多久? → 看 P99 帧耗时 和 最大帧耗时
│
├─ 帧率正常但感觉不流畅?→ 看 帧间隔 CV
│
└─ 卡顿瓶颈在哪个环节? → 看 帧耗时阶段拆解
下面逐个讲清楚。
二、为什么 FPS 不够用
先回顾 FPS 的计算公式(#10 详述):
ini
FPS = (帧数 − 1) / 总时间窗口
现在用两个真实场景做一次完整计算,看看 FPS 到底"藏"了什么。
2.1 场景 A:流畅
60Hz 设备,采集 1 秒,共 60 帧,每帧帧耗时都是 16ms:
ini
帧耗时序列(ms):
16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
... (60 帧,全部 16ms)
总时间 = 59 × 16ms = 944ms ≈ 0.944s
FPS = 59 / 0.944 ≈ 62.5
用户感受:丝滑,每帧都在 VSYNC 周期内。
2.2 场景 B:有一帧冻了 200ms
同样 60Hz 设备,采集 1 秒,共 60 帧。但其中第 30 帧耗时 200ms,其余 59 帧都是 8ms:
ini
帧耗时序列(ms):
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 200, ← 第 30 帧冻了 200ms
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8
总时间 = 59 × 8ms + 200ms = 472ms + 200ms = 672ms ≈ 0.672s
FPS = 59 / 0.672 ≈ 87.8
2.3 对比结果
| 场景 A(全部 16ms) | 场景 B(1 帧 200ms + 59 帧 8ms) | |
|---|---|---|
| FPS | 62.5 | 87.8 |
| 用户感受 | 丝滑 | 第 30 帧明显冻了一下 |
场景 B 的 FPS 甚至比 A 还高! 因为那 59 帧只用了 8ms,拉高了整体均值。但用户在第 30 帧的位置,画面停顿了 200ms(跳过了 12 个 VSYNC 周期),这就是用户说的"卡了一下"。
2.4 FPS 的根本问题
FPS 公式做的是除法------把所有帧的时间"摊平"。一帧 200ms 的冻帧,被 59 帧 8ms 的正常帧"稀释"了:
arduino
把 60 帧的帧耗时从小到大排序:
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, ← 这些帧很好
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 200 ← 用户投诉的就是这帧
FPS 看到的:均值 = 11.2ms/帧 → FPS 高 → "没问题"
用户看到的:第 30 帧画面停了 200ms → "卡了"
FPS 是均值,均值天然会掩盖极端值。 这就是为什么 FPS 报告说"流畅",用户却说"卡了"------两边都没撒谎,只是 FPS 看不到那一帧的冻帧。
所以我们需要更细的指标:逐帧的帧耗时数据。不看均值,直接看每一帧花了多久------200ms 的冻帧一眼就能看到。
三、指标 1:帧耗时------每一帧花了多久
3.1 什么是帧耗时
帧耗时(Frame Time)是 一帧从触发到渲染完成的总时间 ,从 dumpsys gfxinfo 的 PROFILEDATA 中获取:
帧耗时 = FrameCompleted − IntendedVsync(纳秒 → 毫秒)
帧耗时是逐帧的数据,不是均值。1000 帧就有 1000 个帧耗时,每一帧好不好一目了然。
3.2 帧耗时怎么判断好坏
帧耗时的标准是 VSYNC 周期------屏幕刷新一次的时间间隔:
| 刷新率 | VSYNC 周期 | 含义 |
|---|---|---|
| 60Hz | 16.67ms | 每帧必须在 16.67ms 内完成 |
| 90Hz | 11.11ms | 每帧必须在 11.11ms 内完成 |
| 120Hz | 8.33ms | 每帧必须在 8.33ms 内完成 |
帧耗时 ≤ VSYNC 周期 → 正常帧。超过了就意味着这帧没赶上屏幕刷新,用户会看到上一帧"多显示了一会儿",产生卡顿感。
3.3 帧耗时看什么数字
单看一帧的帧耗时没意义,要看统计分布:
| 指标 | 含义 | 怎么理解 |
|---|---|---|
| P50 帧耗时 | 一半帧比它快,一半帧比它慢 | 典型帧的表现------"大部分时候多快" |
| P90 帧耗时 | 90% 的帧比它快 | 较差帧的表现------"偶尔慢到多少" |
| P99 帧耗时 | 99% 的帧比它快 | 极端帧的表现------"最差的那几帧有多差" |
| 最大帧耗时 | 最慢的一帧 | 最严重的那次卡顿 |
实际例子:
ini
采集 1000 帧:
P50 = 8.2ms → 大部分帧都很快,远低于 VSYNC
P90 = 14.5ms → 90% 的帧在 VSYNC 内,正常
P99 = 85ms → 最差的 10 帧里有帧耗时 85ms → 冻帧!
最大 = 320ms → 最严重一次冻帧 320ms → 用户一定感知到了
关键判断 :P50 和 P90 正常不代表没问题。P99 和最大帧耗时才是用户投诉的来源------那几帧极端值就是"卡了一下"。
四、指标 2:卡顿率------每 100 帧有几帧卡了
4.1 卡顿分级标准
不是所有超标帧都一样严重。按帧耗时超过 VSYNC 周期的倍数,分为 4 级:
| 等级 | 条件 | 60Hz 下的毫秒数 | 120Hz 下的毫秒数 | 用户感知 |
|---|---|---|---|---|
| 正常帧 | ≤ 1× VSYNC | ≤ 16.67ms | ≤ 8.33ms | 流畅,无感知 |
| 轻微卡顿 | 1× ~ 2× VSYNC | 16.67 ~ 33.3ms | 8.33 ~ 16.67ms | 丢 1 帧,敏感用户可感知 |
| 中度卡顿 | 2× ~ 4× VSYNC | 33.3 ~ 66.7ms | 16.67 ~ 33.3ms | 明显卡顿 |
| 严重卡顿 | > 4× VSYNC | > 66.7ms | > 33.3ms | 冻帧,用户会说"卡了" |
同样一帧 40ms------在 60Hz 设备上跳过 2 帧(中度),在 120Hz 设备上跳过 4 帧(严重)。阈值跟着刷新率走,不是固定毫秒数。
4.2 卡顿率和严重卡顿率
| 指标 | 公式 | 含义 |
|---|---|---|
| 卡顿率 | (轻微 + 中度 + 严重)帧数 / 总帧数 × 100% | 每 100 帧有几帧超标 |
| 严重卡顿率 | 严重卡顿帧数 / 总帧数 × 100% | 每 100 帧有几帧冻帧 |
| 最大连续卡顿帧数 | 连续超标帧的最大长度 | 最长一次"持续卡"卡了几帧 |
什么是好的:
| 指标 | 优秀 | 可接受 | 需优化 |
|---|---|---|---|
| 卡顿率 | ≤ 3% | ≤ 5% | > 5% |
| 严重卡顿率 | ≤ 0.5% | ≤ 1% | > 1% |
| 最大连续卡顿 | ≤ 1 帧 | ≤ 3 帧 | > 3 帧 |
4.3 FPS + 卡顿率 = 更完整的画面
ini
情况 1:FPS = 58,卡顿率 = 0%
→ 帧率略低但没超标帧,可能是低端机算力不够但渲染均匀
→ 结论:不完美但可接受
情况 2:FPS = 58,卡顿率 = 2%,严重卡顿率 = 0.5%
→ 帧率看起来差不多,但每 200 帧就有 1 帧冻帧
→ 结论:用户会说"偶尔卡一下"
情况 3:FPS = 45,卡顿率 = 15%
→ 帧率低且频繁卡顿
→ 结论:严重性能问题
五、指标 3:帧间隔 CV------FPS 正常但用户说"不流畅"
5.1 一个 FPS 和卡顿率解释不了的问题
两台设备跑同一个场景,FPS 都是 55。但一台"用起来流畅",另一台"滑着滑着突然卡一下"。为什么?
erlang
设备 A:帧间隔序列
18.2, 18.1, 18.3, 18.0, 18.2, 18.1, 18.3, 18.0, ...
→ 每帧都差不多,节奏均匀
→ 用户感受:稳定的"不满帧",能接受
设备 B:帧间隔序列
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 180, 8, 8, ...
→ 大部分帧极快,但突然冒出一帧 180ms
→ 用户感受:"滑着滑着突然卡一下",很难受
FPS 一样,但用户感受完全不同。差别在于帧间隔是否均匀。
5.2 帧间隔变异系数 CV
ini
CV = 帧耗时的标准差 / 帧耗时的均值
| CV 值 | 含义 | 用户感受 |
|---|---|---|
| < 0.1 | 帧间隔几乎一致 | 流畅 |
| 0.1 ~ 0.3 | 轻微波动 | 可接受 |
| 0.3 ~ 0.6 | 明显波动 | 能感知到"一顿一顿" |
| > 0.6 | 严重不稳定 | 明显卡顿 |
上面的例子:设备 A 的 CV = 0.05(极稳定),设备 B 的 CV = 1.2(严重不稳定)。
5.3 CV 和其他指标的组合判断
| FPS | 卡顿率 | CV | 结论 | 典型原因 |
|---|---|---|---|---|
| 高 | 低 | 低 | 流畅 | 最优状态 |
| 高 | 低 | 高 | 帧间隔抖动 | GC 暂停、偶发主线程阻塞 |
| 低 | 低 | 低 | 设备算力不足但均匀 | 低端机常见 |
| 低 | 高 | 高 | 性能瓶颈 | 需要优化 |
CV 的核心价值 :解释"FPS 和卡顿率看起来都还行,但用户就是说不流畅"的原因。它衡量的是节奏是否均匀,和 P99(最差帧有多差)互补。
六、指标 4:PerfDog 风格的 Jank 和 Stutter
前面的卡顿分级是绝对阈值 ------帧耗时超过 VSYNC 就算卡。但有个问题:游戏锁 30fps 时,帧耗时 33ms 是正常的,却会被判为卡顿(因为 33ms > 16.67ms VSYNC)。
行业标杆工具 PerfDog(腾讯)用了一套更聪明的算法。
6.1 PerfDog 的 Jank 判定(双条件)
yaml
一次卡顿(Jank),必须同时满足:
① 当前帧耗时 > 前 3 帧平均耗时 × 2(相对于"最近的节奏"突然变慢)
② 当前帧耗时 > ≈83.3ms(2 帧电影帧 = 1000÷24×2,绝对下限)
一次严重卡顿(BigJank),必须同时满足:
① 同上
② 当前帧耗时 > 125ms(3 帧电影帧 = 1000÷24×3)
注意:Jank 与 BigJank 互斥计数
→ 一帧若被判定为 BigJank,不再重复计入 Jank
→ 即:pd_jank 只包含 83.3~125ms 区间的卡顿帧
→ pd_big_jank 只包含 > 125ms 的卡顿帧
→ 但两者的帧耗时都会被计入 Stutter 的分子
为什么要两个条件:
- 条件 ① 用"前 3 帧均值"做动态基线------游戏锁 30fps 时前 3 帧都是 33ms,此时 33ms 不算卡。但如果突然变成 70ms(> 33×2=66ms),就算卡
- 条件 ② 用 83.3ms 做固定下限------低于 24fps 电影帧率人眼就能感知不连续。注意:83.3ms 和 125ms 是固定的绝对阈值,不随设备刷新率变化
6.2 Stutter 卡顿率
ini
Stutter = 所有 Jank + BigJank 帧的帧耗时之和 / 采集时间窗口 × 100%
其中:
分子 = Σ(Jank帧耗时) + Σ(BigJank帧耗时)
分母 = 最后一帧 IntendedVsync − 第一帧 IntendedVsync(采集时间跨度)
分母说明 :Stutter 的分母是采集时间窗口(第一帧到最后一帧的时间跨度),不是所有帧耗时之和。两者在连续渲染时接近,但有空闲间隙时差异明显。采用时间窗口更贴合"用户在这段时间内感受到的卡顿时间占比"的物理含义。
和我们的"卡顿帧数占比"不同,Stutter 反映的是用户感受到卡顿的时间比例:
css
场景 A:1000 帧中 5 帧超标,每帧 20ms → 卡顿帧占比 = 0.5%
场景 B:1000 帧中 1 帧超标,这帧 500ms → 卡顿帧占比 = 0.1%
按卡顿帧数:A 比 B 严重 5 倍
按 Stutter:B 比 A 严重 5 倍(500ms vs 100ms 的卡顿时长)
用户感受:B 远比 A 严重------一次 500ms 冻帧比 5 次 20ms 超标难受得多
6.3 什么时候用哪套
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| App UI 交互(列表滑动、页面切换) | VSYNC 倍数分级 | App 预期满帧渲染,超过 VSYNC 就是掉帧 |
| 游戏(可能锁 30/45fps) | PerfDog Jank + Stutter | 帧率可能主动低于刷新率,VSYNC 倍数会误判 |
| 视频播放 | PerfDog Jank 或视频专属指标(#13) | 视频帧率独立于屏幕刷新率 |
我们的采集工具同时输出两套指标,不需要二选一。
七、帧耗时阶段拆解------卡在哪个环节
7.1 数据从哪来:不需要额外命令
阶段拆解的数据已经在 PROFILEDATA 里了------和你算 FPS、帧耗时用的是同一条命令、同一份数据。
回顾 dumpsys gfxinfo 输出的 PROFILEDATA(#10 第四节):
css
---PROFILEDATA---
Flags,IntendedVsync,Vsync,...,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssuedDrawCommandsStart,SwapBuffers,FrameCompleted,...
0,687838000000,687838000000,...,687838200000,687838400000,687838600000,687841000000,687843000000,687843400000,687844200000,687846000000,687849700000,...
---PROFILEDATA---
每一行就是一帧,每一列就是一个阶段的时间戳(纳秒)。你不需要再跑一次命令------只需要对这一行里的相邻列做减法。
7.2 怎么拆:逐列做减法
一帧的生命周期被 PROFILEDATA 的列切成了 6 个阶段:
markdown
时间轴 ──────────────────────────────────────────────────────────→
IntendedVsync HandleInputStart AnimationStart PerformTraversalsStart DrawStart SyncStart IssuedDrawCommands FrameCompleted
│ │ │ │ │ │ │ │
├── 输入延迟 ───┤ │ │ │ │ │ │
├── 动画耗时 ───┤ │ │ │ │ │
├── 遍历准备 ─────┤ │ │ │ │
├── 布局+绘制 ────┤ │ │ │
├── 同步 ──┤ │ │
├── GPU 渲染 ─┤ │
| 阶段 | 计算方式 | 含义 | 如果这里慢 |
|---|---|---|---|
| 输入延迟 | HandleInputStart − IntendedVsync | VSYNC 到主线程开始处理触摸 | 主线程被阻塞(IO、锁、GC) |
| 动画耗时 | AnimationStart − HandleInputStart | 处理输入事件 | 触摸事件处理逻辑太重 |
| 遍历准备 | PerformTraversalsStart − AnimationStart | 执行动画回调 | 动画计算复杂 |
| 布局+绘制 | SyncStart − PerformTraversalsStart | measure → layout → draw | 最常见瓶颈:布局层级深、onDraw 复杂 |
| 同步 | IssuedDrawCommandsStart − SyncStart | DisplayList 同步到 RenderThread | Bitmap 上传到 GPU |
| GPU 渲染 | FrameCompleted − IssuedDrawCommandsStart | GPU 执行绘制命令 | 过度绘制、GPU 瓶颈 |
7.3 实战演示:从一行 PROFILEDATA 到定位瓶颈
假设你在前面的分析中发现帧 #304 耗时 78ms(严重卡顿),现在来拆解这帧。
Step 1:找到这帧在 PROFILEDATA 中的那一行
erlang
0,700000000000,700000000000,...,700000200000,700000400000,700000600000,700065600000,700067000000,700067400000,700068200000,700070000000,700078000000,...
Step 2:逐列做减法,换算成毫秒
ini
输入延迟 = (700000200000 − 700000000000) / 1,000,000 = 0.2ms
动画耗时 = (700000400000 − 700000200000) / 1,000,000 = 0.2ms
遍历准备 = (700000600000 − 700000400000) / 1,000,000 = 0.2ms
布局+绘制 = (700067000000 − 700000600000) / 1,000,000 = 66.4ms ← 这里!
同步 = (700067400000 − 700067000000) / 1,000,000 = 0.4ms
GPU 渲染 = (700078000000 − 700067400000) / 1,000,000 = 10.6ms
总帧耗时 = 0.2 + 0.2 + 0.2 + 66.4 + 0.4 + 10.6 = 78.0ms
Step 3:画出耗时占比,一眼看出瓶颈
scss
输入延迟 ▏ 0.2ms (0.3%)
动画耗时 ▏ 0.2ms (0.3%)
遍历准备 ▏ 0.2ms (0.3%)
布局+绘制 ████████████████████████████████████████ 66.4ms (85.1%) ← 瓶颈
同步 ▏ 0.4ms (0.5%)
GPU 渲染 █████ 10.6ms (13.6%)
总计:78.0ms
结论 :85% 的时间花在"布局+绘制"阶段。再结合业务场景(RecyclerView 滑动),大概率是 onBindViewHolder 里做了耗时操作(同步图片解码、复杂布局 inflate 等)。
7.4 实际分析流程:两遍分析法
阶段拆解不是每帧都要做的。实际工作流程是两遍:
less
第一遍:粗筛(自动化)
┌─────────────────────────────────────────┐
│ 从 PROFILEDATA 提取每帧帧耗时 │
│ → 计算 FPS、卡顿率、P99 │
│ → 标记出严重卡顿帧(帧耗时 > 4× VSYNC) │
│ → 产出:帧 #127、#304、#491 是严重卡顿帧 │
└─────────────────────────────────────────┘
│
▼
第二遍:定位瓶颈(手动 / 脚本辅助)
┌─────────────────────────────────────────┐
│ 只看那几帧严重卡顿帧的 PROFILEDATA 行 │
│ → 逐列做减法,算出各阶段耗时 │
│ → 找到耗时最长的阶段 = 瓶颈 │
│ → 结合业务场景定位根因 │
└─────────────────────────────────────────┘
第一遍可以完全自动化 (我们的 fps_collector.py 已经在做)。第二遍在自动化报告里标出各阶段耗时,但根因分析需要人工结合业务判断。
7.5 各阶段慢的常见原因速查
| 阶段 | 正常范围 | 超标常见原因 | 排查方向 |
|---|---|---|---|
| 输入延迟 | < 5ms | 主线程被阻塞 | 检查主线程是否有 IO/锁/GC |
| 动画耗时 | < 2ms | 触摸事件处理重 | 检查 onTouch / onClick 回调 |
| 遍历准备 | < 2ms | 动画回调重 | 检查 ValueAnimator / ObjectAnimator 回调 |
| 布局+绘制 | < 8ms | 最常见:布局深、onDraw 重 | Layout Inspector 看层级;onDraw 里不要 new 对象 |
| 同步 | < 2ms | 大图上传 GPU | 检查是否有大 Bitmap 首次显示 |
| GPU 渲染 | < 4ms | 过度绘制 | 开发者选项 → 调试 GPU 过度绘制 |
八、真实案例
案例 1:列表快速滑动------P99 暴露问题
场景:RecyclerView 快速滑动 10 秒(60Hz 设备)。
ini
FPS = 58(看起来正常)
卡顿率 = 4.0%(23 帧超标,其中 3 帧严重卡顿)
P50 = 9.2ms,P90 = 15.8ms(大部分帧很好)
P99 = 48.7ms,最大 = 112ms(极端帧暴露问题)
阶段拆解那 3 帧严重卡顿帧:
| 帧 | 布局耗时 | 绘制耗时 | GPU 耗时 | 总耗时 |
|---|---|---|---|---|
| #127 | 42ms | 3ms | 5ms | 52ms |
| #304 | 65ms | 4ms | 6ms | 78ms |
| #491 | 82ms | 5ms | 8ms | 112ms |
结论 :瓶颈在布局阶段,滑动中特定 item 的 onBindViewHolder 做了同步网络图片解码。
如果只看 FPS = 58,你会说"没问题"。P99 = 48.7ms 和帧耗时拆解帮你找到了真正的 bug。
案例 2:FPS 一样,体验天差地别------CV 揭示真相
场景:两台设备跑同一个列表滑动场景,FPS 都是 55。
ini
设备 A(低端 60Hz):
FPS = 55,CV = 0.05
帧耗时:18.2, 18.1, 18.3, 18.0, 18.2, ...(均匀偏慢)
→ 每帧都略超 VSYNC,但节奏均匀
→ 用户感受:"不算丝滑,但能接受"
设备 B(高端 120Hz):
FPS = 55,CV = 1.2
帧耗时:8, 8, 8, 8, 8, 8, 8, 8, 8, 180, 8, 8, ...(偶发冻帧)
→ 大部分帧极快,但偶尔冒出 180ms 冻帧
→ 用户感受:"滑着滑着突然卡一下"
| 指标 | 设备 A | 设备 B | 谁更好? |
|---|---|---|---|
| FPS | 55 | 55 | 平手 |
| 卡顿率 | 100%(全部轻微超标) | 2%(多数正常) | 看似 B 好 |
| CV | 0.05 | 1.2 | A 远好于 B |
| 严重卡顿率 | 0% | 1.5% | A 好 |
单看 FPS 和卡顿率,会觉得两台设备差不多甚至 B 更好。加上 CV,真相浮出水面:设备 A 均匀偏慢(用户可接受),设备 B 帧间隔剧烈波动(用户投诉"卡了一下")。
案例 3:越用越卡------CPU 降频导致帧耗时飙升
场景:长时间压测 30 分钟,用户反馈"用着用着变卡了"。
ini
前 5 分钟:
CPU 频率比 = 0.95(满血)
FPS = 58,P90 = 12ms,CV = 0.08(极稳定)
第 25~30 分钟:
CPU 频率比 = 0.52(严重降频)
FPS = 35,P90 = 38ms,CV = 0.65(严重不稳定)
结论:不是 App 的 bug,是设备发热后 CPU 降频导致的。CPU 降频不只让帧率下降,还让帧间隔变得不稳定(CV 从 0.08 飙到 0.65),因为 CPU 在节能和性能模式之间反复切换。
这种场景需要交叉分析 CPU 数据和 FPS 数据------CPU 系列 #7 讲的"频率比"在这里直接关联。
案例 4:页面跳转动画掉帧
场景:从列表页跳转到详情页,转场动画 300ms(120Hz 设备)。
ini
期望帧数 = 300ms × 120Hz = 36 帧
实际帧数 = 18 帧 → 帧率达成率 50%
前 5 帧的帧耗时:
帧 1:85ms(首帧,详情页 inflate + measure)
帧 2:42ms
帧 3:18ms
帧 4:8ms
帧 5:8ms
结论 :第一帧 85ms 是动画首帧,需要 inflate 复杂布局。后续帧快速收敛到正常。优化方向:预加载详情页布局 或使用 ViewStub 延迟加载非核心内容。
九、指标速查表
9.1 完整指标一览
| 指标 | 公式 | 回答什么问题 | 阈值参考(标准场景) |
|---|---|---|---|
| 有效 FPS | 有绘制时段帧数 / 时长 | 整体帧率够不够 | ≥ 刷新率 × 88% |
| 帧率达成率 | FPS / 刷新率 × 100% | 离满帧还差多少 | ≥ 88% |
| 卡顿率 | 超标帧数 / 总帧数 × 100% | 每 100 帧有几帧卡了 | ≤ 5% |
| 严重卡顿率 | 冻帧数 / 总帧数 × 100% | 冻帧有多频繁 | ≤ 1% |
| P90 帧耗时 | 排序取第 90% 位 | 较差帧有多差 | ≤ 1.5× VSYNC |
| P99 帧耗时 | 排序取第 99% 位 | 极端帧有多差 | ≤ 3× VSYNC |
| 最大帧耗时 | max(帧耗时) | 最严重的一次卡顿 | --- |
| 帧间隔 CV | 标准差 / 均值 | 帧率节奏是否均匀 | ≤ 0.35 |
| 最大连续卡顿 | 连续超标帧最大长度 | 最长一次持续卡顿 | ≤ 3 帧 |
| Stutter(PerfDog) | (Jank+BigJank)帧耗时之和 / 采集时间窗口 × 100% | 用户感受到卡顿的时间占比 | ≤ 2% |
| Jank/10min(PerfDog) | Jank 次数 / 分钟 × 10 | 平均每 10 分钟卡几次 | 看场景 |
| FPS 衰减比 | (基线-实测) / 基线 × 100% | 性能是否退化 | < 15% |
9.2 阈值怎么适配不同设备
不硬编码设备档位,用两层自动适配:
第一层:VSYNC 倍数------帧时间阈值自动跟刷新率走
P90 阈值 = 1.5 × VSYNC 周期
60Hz → 25.0ms
120Hz → 12.5ms(自动更严)
P99 阈值 = 3.0 × VSYNC 周期
60Hz → 50.0ms
120Hz → 25.0ms
不需要知道设备型号,只要读到刷新率,阈值就确定了。
第二层:基线对比------和自己比,消除设备差异
erlang
Step 1:在设备上跑标准场景(如标准列表匀速滑动 10 秒),记录基线 FPS 和 CV
Step 2:跑被测场景,采集实际数据
Step 3:计算衰减
FPS 衰减 = (基线 FPS − 实测 FPS) / 基线 FPS × 100%
< 5% → 正常
5~15% → 关注
> 15% → 需要排查
基线对比解决的问题:低端机 FPS 只有 48,但基线也只有 52 → 衰减 7.7% → App 没浪费设备算力,属于正常。不需要硬编码"低端机阈值更宽"。
9.3 分场景配置严格度
不同页面对流畅度的要求不同:
| 严格度 | 适用场景 | 帧率达成率 | 卡顿率 | 严重卡顿率 | CV |
|---|---|---|---|---|---|
| 严格 | 首页、列表滑动、支付流程 | ≥ 93% | ≤ 3% | ≤ 0.5% | ≤ 0.2 |
| 标准 | 详情页、搜索结果 | ≥ 88% | ≤ 5% | ≤ 1% | ≤ 0.35 |
| 宽松 | 设置页、关于页 | ≥ 80% | ≤ 8% | ≤ 2% | ≤ 0.5 |
小结
| 问题 | 该看什么指标 |
|---|---|
| 整体帧率够不够 | FPS + 帧率达成率 |
| 有多少帧卡了 | 卡顿率 + 严重卡顿率 |
| 最严重那次有多差 | P99 帧耗时 + 最大帧耗时 |
| FPS 正常但用户说不流畅 | 帧间隔 CV |
| 用户实际感受到的卡顿时间 | Stutter(PerfDog 标准) |
| 卡在哪个渲染环节 | 帧耗时阶段拆解(布局 / 绘制 / GPU) |
| 越用越卡 | FPS 衰减比 + CPU 频率比交叉分析 |
一句话:FPS 做粗筛,卡顿率看频率,P99 看极端,CV 看节奏,Stutter 看体感。五个维度组合,才能完整回答"到底有多卡"。
下一篇我们讲一种和 UI 帧率完全不同的场景------视频播放卡顿检测。
系列目录
- 第 1~4 篇:内存泄漏检测 & 内存采集避坑
- 第 5~9 篇:CPU 采集系列(入门 → 避坑 → 降频 → 单核 → 落地)
- 第 10 篇:FPS 帧率采集入门
- 第 11 篇:FPS 采集的 8 个坑
- 第 12 篇(本篇):UI 卡顿量化------用数据回答"到底有多卡"
- 第 13 篇(下一篇):视频播放卡顿检测
- 第 14 篇:FPS 采集落地方案
我是测试工坊,专注 Android 系统级性能工程。 如果你也在做帧率相关的性能测试,欢迎评论区交流 👇 关注我,后续更新不迷路。