FPS与帧率检测
# FPS 与帧率检测
本文核心命题:FPS 不是均值游戏,而是抖动游戏——用户感知的"流畅"是每一帧都按时,而不是"平均够快"。一切优化都要看 P95 / P99 / Max,而不是均值。60fps 均值 + 偶发 200ms 帧 远不如 45fps 均值 + 帧时长稳定。
# 01.阅读说明
- 本文卷归属:卷三 · 流水线篇 · 第 2 篇
- 本文目标层级:L2 进阶 → L3 专家
- 适用平台:Android(主) / iOS / Web / 跨端框架 / 嵌入式
- 前置阅读:
卷三·01 渲染管线与原理(FPS 是流水线的产出度量)卷三·03 卡顿捕获与归因(卡顿是 FPS 抖动的极端形态)
- 本文核心命题:
FPS 不是均值游戏,而是抖动游戏:用户感知的"流畅"是 每一帧都按时,而不是"平均够快"。
一切优化都要看 P95 / P99 / 最大值。
全文 21 章地图:
§01 阅读说明 §02 贯穿案例 §03 FPS 物理本质 §04 帧时长分布原理
§05 度量与采集 §06 归因决策树
§07 帧时钟全链路 ⭐ §08 系统帧 API 全链路 ⭐ §09 合成段全链路 ⭐
§10 高刷与 ARR 全链路 ⭐ §11 跨端 FPS 全链路 ⭐ §12 跨端对照
§13 治理一层度量 ⭐ §14 治理二层采集 ⭐ §15 治理三层长尾 ⭐ §16 治理四层 VRR ⭐
§17 求证实验 ⭐ §18 实战案例 §19 防劣化体系 §20 跨平台速查
§21 总结与延伸
2
3
4
5
6
7
阅读建议:先读 §02 案例 → §03/§04 拿到原理 → §05/§06 学会度量归因 → §07-§11 五个全链路 → §13-§16 四层治理 → §17 求证 → §18-§20 工程闭环。
# 02.贯穿案例
本案例贯穿全文:§03 看懂现象、§04 拿到第一性原理武器、§05/§06 用三方案+决策树定位、§17 用实验复盘、§13-§16 给出分层策略闭环。
# 2.1 案例背景
某头部短视频应用 V8.4 上线"沉浸式信息流",研发同学验收时发现 FPS 数据"非常漂亮":
- 实验室 Pixel 6 滑动均值 59.2 fps,看板一片绿。
- 灰度 1% 后,应用商店一周内涌入 1700+ "卡"评论,NPS 下跌 4 个点,CEO 在群里 @ 性能负责人。
- 研发组复测发现"均值确实是 59 fps",结论:"看板没问题,是用户体感不准"。
但用户的反馈非常具体——"每滑两三个视频就抖一下"、"评论区弹出时画面顿一下"、"双击点赞时按钮延迟 1 秒"。
# 2.2 经验派的 4 周折腾(典型反面教材)
团队凭直觉做了三件事,越搞越糟:
| 周次 | 动作 | 结果 |
|---|---|---|
| 第 1 周 | 把列表 item layout 减少 2 层(怀疑布局复杂) | 均值升到 59.5 fps,投诉未减少 |
| 第 2 周 | 给图片加 RGB_565 模式(怀疑显存) | 画质下降,运营反对回滚 |
| 第 3 周 | 把"滑动监听"改 throttle 100ms(怀疑回调多) | 数据没动,反而引入新 bug |
| 第 4 周 | 把监控采样窗口拉到 5s 想"平滑数据" | 看板更绿了,但用户骂得更凶 |
复盘:四周里所有动作都基于"均值偏低 = 卡顿、均值高 = 流畅"的错误假设。第 4 周把窗口拉宽,本质是主动稀释了抖动信号——这是反向收益的典型。
# 2.3 方法派的 5 天闭环
新接手的同学按本文方法论重做:
Day 1(§04 帧时长分布模型):用"帧时长分布模型"重新看数据,导出原始 100ms 窗口数据:
| 窗口 | 均值 FPS | P99 帧时长 | Max 帧时长 |
|---|---|---|---|
| 滑动稳态 | 59.5 | 89 ms | 480 ms |
| 评论弹出瞬间 | 58.0 | 210 ms | 660 ms |
| 双击点赞瞬间 | 57.0 | 180 ms | 320 ms |
→ 立刻看出:"均值假流畅",P99 比预算(16.67ms)超了 5-12 倍。
Day 2(§05 三方案组合):方案①(Choreographer)+ 方案②(FrameMetrics)双采,定位每一类长尾帧的阶段归属:
- 滑动稳态 P99=89ms → 主要是
LAYOUT_MEASURE_DURATION占 60ms(评论缩略图 measure 同步走主线程) - 评论弹出 P99=210ms → 主要是
INPUT_HANDLING_DURATION + DRAW共 150ms(弹层动画 + 评论图加载同步触发) - 双击点赞 P99=180ms →
SYNC_DURATION占 100ms(点赞动画同步上传 GPU 大纹理)
Day 3(§06 归因决策树):三个长尾分别归到不同分支——长尾归因 / 场景关联 / 抖动归因。
Day 4(§13-§16 分层策略):按"指标→采集→长尾→VRR"四层分别施治:
- 第 1 层:把"均值 FPS"看板下线,换成 P99/Max 双指标(耗时 1 天)。
- 第 3 层:评论缩略图改 AsyncLayoutInflater;点赞动画纹理预上传;图片加载改异步占位。
- 第 4 层:120Hz 设备申请 ARR,让点赞动画跑 120fps。
Day 5(§17 求证实验思路验证):构造和线上同分布的 mock 数据,灰度对比验证。
# 2.4 上线效果
| 指标 | 经验派 4 周后 | 方法派 5 天后 |
|---|---|---|
| 均值 FPS(误导指标) | 59.5 | 58.8(反而略低) |
| P99 帧时长 | 89 ms | 22 ms |
| Max 帧时长 | 480 ms | 95 ms |
| 应用商店"卡顿"差评/周 | 1700+ | 180 |
| 用户 NPS | -4 | +2 |
核心反差:均值反而略降,但用户体感大幅改善。这正是"FPS 是抖动游戏"最锋利的证据。
# 2.5 案例如何串起本文
- §03 现象与代价 ▶▶ 现象映射:抖动型卡顿 + 冻屏型操作(点赞延迟 1 秒)。
- §04 第一性原理 ▶▶ 用"帧时长分布模型"+"四态分类"识破"均值假流畅"。
- §06 归因决策树 ▶▶ 三类长尾分别走"长尾归因 / 场景关联 / 抖动归因"。
- §07-§11 五大全链路 ▶▶ 帧时钟、系统 API、合成段、VRR、跨端 五条链路对应案例每一类问题。
- §17 求证实验 ▶▶ §17.1 解释为什么均值会误导,§17.2 解释为什么 5s 窗口会稀释信号。
- §13-§16 治理 ▶▶ "指标→采集→长尾→VRR"四层正是案例落地路径。
探索性思考:为什么经验派会把"窗口拉到 5s"作为优化?因为他们的目标是"让看板变绿"而不是"让用户不卡"。KPI 设定错误是性能优化最致命的失误——当度量本身被异化成目标时,工程师就开始"优化指标"而不是"优化体验"。这是 Goodhart's Law 在性能领域的真实演绎:"当一个度量变成目标时,它就不再是好的度量"。
# 03.FPS 物理本质
# 3.1 一句话定义
FPS = 单位时间内成功被显示器扫描显示的帧数。
这句话隐含两个不可商量的物理约束:
约束一:FPS 上限由显示硬件决定,不由软件决定
显示器以固定(或可变)频率扫描帧缓冲。无论应用渲染多少帧,用户能看到的最大 FPS = 显示器刷新率。60Hz 屏上即使应用渲染 200fps,用户依然只感知到 60fps(多余帧被丢弃)。
应用渲染 ──▶ Frame Buffer ──▶ 显示器扫描 ──▶ 用户感知
│ │ │
产帧速率 缓冲深度 刷新率(硬上限)
2
3
约束二:FPS 是"过去一段时间的统计",不是瞬时值
FPS 必须基于一段时间窗(通常 1 秒)统计。瞬时无所谓"FPS",只有"这一帧的时长"。这是一个关键概念误区。
正确表述:
- ✅ "过去 1 秒内显示了 58 帧" → 58 FPS
- ✅ "这一帧花了 32ms"
- ❌ "现在 FPS 是 32ms"(混淆了概念)
# 3.2 现象与代价
FPS 不达标的用户感知:
- 持续低帧(如 30fps):滚动 / 动画"不顺滑",但用户能接受。
- 帧时长抖动:均值 60fps 但偶尔 100ms 帧,用户感觉"一卡一卡"。
- 冻屏(>700ms 单帧):用户认为"卡死"。
- 撕裂(tearing):未开 Vsync 时画面上下错位。
- 掉帧链 / 帧延迟:用户操作和画面响应之间的延迟感。
业务代价(行业实测数据):
- 抖音、TikTok 内部数据:滑动帧率 P99 每提升 5ms,留存 +0.5%。
- 电商列表:滑动卡顿率每降 1%,浏览深度 +3%。
- 游戏:FPS 抖动直接影响用户口碑(Steam 评分中"流畅度"占很大权重)。
▶▶ 回扣 §02 案例:短视频"均值 59.2fps"看板绿油油,但 P99=89ms 直接对应"滑两三个视频抖一下"。
# 3.3 度量准则与基准
请求视角(RED):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 平均 FPS | 总帧数 / 总时间 | ≥ 目标帧率 95% |
| 帧时长 P50 | 中位帧时长 | < 帧预算 |
| 帧时长 P95 | 95% 分位帧时长 | < 1.5 × 帧预算 |
| 帧时长 P99 | 长尾帧 | < 2 × 帧预算 |
| 帧时长最大值 | 最差帧 | < 700ms(冻屏阈值) |
| 掉帧率 | 超时帧 / 总帧 | < 5% |
用户感知(APDEX):
- Satisfied:连续 30 帧都按时
- Tolerating:单次掉帧 < 100ms
- Frustrated:冻帧 ≥ 700ms
关键约定:禁止单独看均值 FPS。它对抖动几乎不敏感。必须配合 P95/P99/Max。详见 §17.1 实验。
行业基准:
| 平台 | 目标帧率 | 帧预算 | P99 阈值 |
|---|---|---|---|
| Android 60Hz | 60 fps | 16.67 ms | < 25 ms |
| Android 90/120Hz | 90/120 fps | 11.11/8.33 ms | < 1.5 ×预算 |
| iOS 60Hz | 60 fps | 16.67 ms | < 25 ms |
| iOS ProMotion 120Hz | 120 fps | 8.33 ms | < 12 ms |
| Web | 与显示器同步 | 16.67 ms | < 25 ms |
| 嵌入式 HMI | 30/60 fps | 33/16.67 ms | < 2 ×预算 |
# 3.4 反直觉问题清单
带着这些问题阅读:
- 60fps 均值就代表流畅吗?
- 为什么 P99 比均值有用得多?
- 90Hz 屏一定比 60Hz 流畅吗?
- 帧时长 32ms 算一次掉帧还是两次?
- 静态页面没有掉帧,是不是 FPS 数据丢失?
- 后台时 FPS 还有意义吗?
- 帧率为 0 的瞬间是 ANR 吗?
- 可变刷新率(VRR)下 FPS 怎么算?
探索性思考:为什么"FPS"作为指标被用了几十年都没被淘汰?因为它直观——"每秒多少帧"是用户能秒懂的语言。但直观 ≠ 准确。FPS 用"统计平均"掩盖了"分布"信息,这正是它的致命缺陷。好的指标不仅要直观,还要无损反映真相。
# 04.帧时长分布原理
# 4.1 帧时长分布是 FPS 的本质
FPS 是单一数字,但"流畅"是关于每一帧时长的分布特征。两个 60fps 应用,分布可能完全不同:
应用 A:每帧 16ms(均匀) 应用 B:57 帧 13ms + 3 帧 100ms
─────────────── ─────────────────────────
分布:方差极小 分布:3 个尖峰
均值 FPS:60 均值 FPS:60
P99 帧时长:16ms P99 帧时长:100ms
用户体验:流畅 用户体验:每秒抖一下
2
3
4
5
6
统计学上,FPS 的"流畅"应该用分布的尾部 (tail) 来描述:
"流畅" = 帧时长分布的 P99 < 1.5 × 帧预算
"可接受" = 帧时长分布的 P95 < 1.5 × 帧预算
"卡顿" = 任意单帧 > 700ms
2
3
这就是为什么所有性能监控系统都强制要求 P50/P95/P99/Max 四元组,而不是均值。
▶▶ 回扣 §02 案例:短视频信息流恰好就是"应用 B 的真实化身"——均值 59.5 fps(看似 60),P99 却高达 89ms。
# 4.2 单帧时长四态分类
┌──────────────────────────────────────────┐
│ 完美帧 (≤ 帧预算) │
│ 16.67ms(60Hz)以内,无感 │
├──────────────────────────────────────────┤
│ 轻微掉帧(1-1.5× 帧预算) │
│ 16-25ms,几乎无感 │
├──────────────────────────────────────────┤
│ 明显掉帧(1.5-3× 帧预算) │
│ 25-50ms,能感知"卡了一下" │
├──────────────────────────────────────────┤
│ 严重掉帧(3× 帧预算 - 700ms) │
│ 50-700ms,明显卡顿 │
├──────────────────────────────────────────┤
│ 冻帧(≥ 700ms) │
│ 用户认为"卡死" │
└──────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 4.3 跨平台同构原理
所有有 UI 的平台都有"显示帧时钟 + 帧产出"两端,本质同构:
通用 FPS 模型:
[应用产帧] ──▶ [Buffer] ──▶ [显示器扫描]
│ │
产帧速率(变量) 刷新率(定量)
│ │
└──────[Vsync 同步]────────┘
2
3
4
5
6
7
每个平台都必须有:
| 抽象组件 | 解决什么问题 |
|---|---|
| 帧时钟订阅 | 知道"下一次显示什么时候发生" |
| 帧时长测量 | 知道"两次显示之间多久" |
| 帧分类 | 区分"按时帧 / 掉帧 / 冻帧" |
| 长尾统计 | P95/P99/Max 才能反映真相 |
跨平台术语对照
| 通用术语 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 帧时钟 | Choreographer / Vsync | CADisplayLink | requestAnimationFrame | 显示控制器 IRQ |
| 帧统计 API | FrameMetrics (24+) | MetricKit Hitches (14+) | PerformanceObserver | 厂商 SDK |
| 实时 FPS 显示 | "开发者选项 → GPU 呈现模式" | Instruments Core Animation | DevTools Frame Render | 串口日志 |
| 刷新率 | 60/90/120 Hz | 60/120 Hz | 与显示器同步 | 厂商定 |
| 自适应帧率 | Android 12+ ARR/VRR | ProMotion | 浏览器自适配 | 视设备 |
# 4.4 平台差异点矩阵
| 维度 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 帧时钟精度 | Vsync 中断(μs 级) | CADisplayLink(μs 级) | rAF(ms 级,部分浏览器) | 视硬件 |
| 系统提供分布数据 | FrameMetrics(API 24+) | Hitches Ratio (iOS 14+) | LCP/INP/CLS(部分) | 通常无 |
| 高刷率支持 | 12+ ARR | ProMotion (iPad 2017+, iPhone 13 Pro+) | 浏览器自动 | varies |
| 静态页面盲区 | 是(无 Vsync 请求即无 callback) | 同 | rAF 不调度 | 视实现 |
| 跨进程合成 | SurfaceFlinger | Render Server | Compositor | varies |
探索性思考:为什么"分布"思维在工程上常被忽略?因为分布是统计学概念,需要训练才能直觉。普通开发者看见"59 fps"会本能比较"60 fps",看见"P99=89ms"却需要思考"这是好是坏"。好的工程文化要让"分布思维"变成本能反应——这正是 §02 方法派 5 天闭环胜过经验派 4 周折腾的根本。
# 05.度量与采集
# 5.1 三类采集方案
所有平台的 FPS 采集本质上只有 3 类:
① 帧时钟订阅(应用层 Vsync 监听)
② 系统帧 API(OS 提供阶段级数据)
③ 外部测量(高速摄像 / 屏幕直采)
2
3
① 帧时钟订阅(FPS 采集主流方案)
订阅 Vsync 信号,记录每次回调时间戳,差值即帧时长。
class FpsMonitor implements Choreographer.FrameCallback {
private long lastTime;
public void doFrame(long frameTimeNanos) {
long interval = (frameTimeNanos - lastTime) / 1_000_000;
intervals.add(interval);
lastTime = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
}
2
3
4
5
6
7
8
9
优势:与硬件物理同步,开销极低(< 1μs/帧)。
局限:静态页面盲区(无 Vsync 请求即无回调),无法看到合成段丢帧。
② 系统帧 API(最准确的线上方案)
Android FrameMetrics / iOS MetricKit Hitches / Web PerformanceObserver 等。
优势:含阶段拆解(Layout/Draw/Sync),可定位瓶颈。
局限:需要较新系统(Android API 24+、iOS 14+)。
③ 外部测量(线下校准)
高速摄像 / 屏幕直采。
优势:真值——真正"用户看到的帧"。
局限:成本极高,仅用于校准。
# 5.2 各方案的可见盲区
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 帧时钟订阅 | Vsync callback | 应用产帧时长 | 极低 | 跨端支持 | 是 | 静态盲区+合成盲区 |
| ② 系统帧 API | 系统侧 | 阶段+合成 | 低 | 部分 | 是 | 系统版本要求 |
| ③ 外部测量 | 屏幕外 | 真值 | 高(设备成本) | 全 | 否 | 仅线下校准 |
实战建议:线上以 ① 为主 + ② 校准;线下定期用 ③ 校准 ① 的偏差。
# 5.3 跨平台采集对照表
| 平台 | 帧时钟订阅 | 系统帧 API | 外部测量 |
|---|---|---|---|
| Android | Choreographer.FrameCallback | FrameMetrics / FrameTimeline | 高速摄像 / dumpsys |
| iOS | CADisplayLink | MetricKit MXHitchEvent | Instruments Time Profiler |
| Web | requestAnimationFrame | PerformanceObserver(longtask) | DevTools FPS meter |
| Compose | Choreographer(共用) | 同 Android | 同 |
| Flutter | SchedulerBinding | DevTools timeline | 同 |
# 5.4 数据可信度评估
- 方案①:可信度高(与硬件同步),但有盲区。
- 方案②:可信度最高(系统级真值),但版本要求。
- 方案③:可信度最高(真正的真值),但成本极高。
探索性思考:为什么"线上 ① + ② 双采"是事实标准?因为它解决了"FPS 监控的两难"——既要数据完整(②),又要兼容老系统(①),还要成本可接受(避免 ③)。工程的智慧不在于选择"最好的方案",而在于"组合多种方案弥补各自盲区"。
# 06.归因决策树
# 6.1 FPS 归因决策树
症状 = ?
│
├─ 均值低(< 50fps)+ 持续
│ │
│ └─ 走"产帧能力不足"分支:
│ - 设备性能不够 / 应用产帧本身就慢
│ - 优化方向:降低产帧成本(§13-§16)
│
├─ 均值正常但抖动(P99 > 50ms 偶发)
│ │
│ └─ 走"长尾归因"分支:
│ - 主线程偶发同步任务(IO/measure/解码)
│ - 用 FrameMetrics 看哪个阶段长尾
│ - 优化方向:异步化 / 预上传 / 切片
│
├─ 均值正常但操作时卡(点击/弹层 P99 > 100ms)
│ │
│ └─ 走"场景关联"分支:
│ - 输入处理 + 启动动画 + 数据加载叠加
│ - 优化方向:拆解操作链路、纹理预上传
│
├─ 应用层数据漂亮但用户骂
│ │
│ └─ 走"合成段盲区"分支:
│ - 应用提交了,但系统合成丢了
│ - 用 FrameTimeline / Hitches Ratio 校准
│
└─ 高刷设备体感不佳
│
└─ 走"VRR 配置"分支:
- 全程 120Hz 不必要 / 该高刷的没高刷
- 优化方向:ARR 智能调度(§16)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 6.2 抖动归因法
症状:均值 60fps,但 P99 帧时长 > 50ms。
典型根因:
- 主线程偶发同步任务:DB 查询、IO、复杂格式化
- GC 抖动:大对象分配触发 GC 暂停
- 图片解码同步:
setImageBitmap含解码 - measure 同步:嵌套布局 + 动态高度
- 弹层动画首帧 SYNC:大纹理同步上传 GPU
归因方法:
- 在 P99 帧上抓主线程调用栈(系统 Trace + 自插桩)
- 看 FrameMetrics 哪个阶段时长最长
# 6.3 长尾分位归因法
症状:分位数曲线"右尾肥"。
P50/P95/P99/Max 形态分析:
理想分布(均匀):
P50=15ms P95=18ms P99=22ms Max=30ms
→ 健康,分布紧凑
长尾分布(偶发卡):
P50=15ms P95=20ms P99=180ms Max=480ms
→ 不健康,少数极端帧
平均偏低:
P50=25ms P95=30ms P99=35ms Max=40ms
→ 持续慢,产帧能力不足
2
3
4
5
6
7
8
9
10
11
# 6.4 可变帧率(VRR)归因
症状:120Hz 设备 P99 数据"看起来更差"。
根因:
- 帧预算从 16.67ms(60Hz)变为 8.33ms(120Hz)
- 同样耗时的帧在 120Hz 上算"严重掉帧"
正确做法:
- 动态读取
display.getRefreshRate(),按当前刷新率计算帧预算 - 不能硬编码 16.67ms
探索性思考:为什么"决策树"是 FPS 归因的最佳工具?因为 FPS 问题虽然多,但症状-根因映射相对结构化——抖动、均值低、操作卡分别走不同分支。结构化的领域适合用决策树,模糊的领域才需要机器学习。
# 07.帧时钟全链路
帧时钟是 FPS 监控的最基础链路:从硬件 Vsync 中断 → 内核 → 系统服务 → 应用层 callback。理解这条链路,才能理解监控数据的物理意义。
# 7.1 Android Choreographer 全链路
① 硬件 Vsync 中断(每 16.67ms 一次,60Hz)
↓ DispSync (内核驱动)
② SurfaceFlinger Vsync 接收
↓ BLAST/BufferQueue
③ Choreographer 收到 Vsync 信号
↓ MessageQueue
④ doFrame 触发:
- INPUT 阶段(事件分发)
- ANIMATION 阶段(动画值更新)
- TRAVERSAL 阶段(measure/layout/draw)
- COMMIT 阶段(提交到 RenderThread)
↓
⑤ RenderThread 同步 + GPU 渲染
↓
⑥ SurfaceFlinger 合成上屏
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键 API:
// 应用层订阅
Choreographer.getInstance().postFrameCallback(callback);
// 应用回调时间 = SurfaceFlinger 收到 Vsync 时间 + 偏移
public void doFrame(long frameTimeNanos) {
// frameTimeNanos = 当前帧的 Vsync 时间戳
}
2
3
4
5
6
7
关键时序:
- Vsync-app(应用 Vsync):早于 Vsync-sf(合成 Vsync)一定时间,让应用有时间产帧
- 三缓冲(API 28+):减少 jank 但增加延迟
# 7.2 iOS CADisplayLink 全链路
① 硬件 Vsync(60Hz / 120Hz ProMotion)
↓
② Render Server 接收
↓
③ CADisplayLink 触发应用层 callback
↓
④ Run Loop 处理:
- layoutSubviews
- drawRect:
- Core Animation 更新
↓
⑤ Render Server 合成上屏
2
3
4
5
6
7
8
9
10
11
12
关键 API:
let link = CADisplayLink(target: self, selector: #selector(onFrame))
link.add(to: .main, forMode: .common)
@objc func onFrame(link: CADisplayLink) {
let interval = link.targetTimestamp - link.timestamp
// interval ≈ 1/refreshRate
}
2
3
4
5
6
7
iOS ProMotion 特性:
- 自适应 24-120Hz
- 应用可通过
preferredFrameRateRange申请目标
# 7.3 Web requestAnimationFrame 全链路
① 浏览器主循环(驱动)
↓
② requestAnimationFrame 队列处理
↓
③ 用户回调执行
↓
④ Style + Layout + Paint
↓
⑤ Compositor 合成
↓
⑥ 显示器扫描
2
3
4
5
6
7
8
9
10
11
关键 API:
function frame(timestamp) {
// timestamp 为高精度时间戳
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
2
3
4
5
Web 特殊性:
- 后台 tab 时 rAF 间隔变 1 秒(节流)
- requestIdleCallback 是空闲帧的对应 API
# 7.4 嵌入式帧时钟全链路
① LCD 控制器 IRQ(Vsync 中断)
↓
② RTOS / Linux 内核中断处理
↓
③ 应用层注册回调(信号量 / 消息队列)
↓
④ UI 线程更新 framebuffer
↓
⑤ DMA 上屏
2
3
4
5
6
7
8
9
特殊性:
- 通常是单缓冲或双缓冲(性能受限)
- 中断处理时延要求严格(< 100μs)
- 帧率通常 30/60Hz,少数 90Hz
# 7.5 帧时钟全链路性能数据
| 平台 | Vsync 精度 | 应用 callback 延迟 | 静态期间是否有 callback |
|---|---|---|---|
| Android | μs 级 | 0.5-2ms(OS 调度) | 否 |
| iOS | μs 级 | 0.5-1ms | 否 |
| Web | ms 级(部分浏览器) | 1-5ms | 否(后台节流到 1s) |
| 嵌入式 | μs 级 | < 100μs | varies |
探索性思考:为什么"静态页面无 Vsync 回调"是所有平台共通的设计?因为这是能效优化——没有变化的画面不需要重绘,浪费 CPU/GPU 能量。但这也带来"FPS 监控盲区"。节能与监控天然有矛盾——监控想"全程数据",能效想"按需触发",工程上必须双方妥协。
# 08.系统帧 API 全链路
应用层数据有盲区,系统帧 API 是补盲的关键。
# 8.1 Android FrameMetrics 全链路
API 24+ 提供,含完整阶段拆解。
addOnFrameMetricsAvailableListener
↓
每帧完成后回调
↓
FrameMetrics 包含的阶段:
- UNKNOWN_DELAY_DURATION
- INPUT_HANDLING_DURATION
- ANIMATION_DURATION
- LAYOUT_MEASURE_DURATION
- DRAW_DURATION
- SYNC_DURATION(GPU 同步)
- COMMAND_ISSUE_DURATION
- SWAP_BUFFERS_DURATION
- GPU_DURATION(API 31+)
- TOTAL_DURATION(总时长)
- FIRST_DRAW_FRAME(首帧标记)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键代码:
window.addOnFrameMetricsAvailableListener((window, metrics, dropped) -> {
long total = metrics.getMetric(FrameMetrics.TOTAL_DURATION);
long layout = metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION);
long draw = metrics.getMetric(FrameMetrics.DRAW_DURATION);
long sync = metrics.getMetric(FrameMetrics.SYNC_DURATION);
// 上报或归因
}, handler);
2
3
4
5
6
7
FrameTimeline(API 31+)补充:
- 应用提交时间 vs 实际显示时间
- 可识别"应用按时但合成丢帧"
# 8.2 iOS MetricKit 全链路
iOS 14+ 提供 MXHitchEvent:
class MetricsHandler: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
payloads.forEach { payload in
let hitches = payload.applicationResponsivenessMetrics?.histogrammedApplicationHangTime
// hitches 为时长直方图
}
}
}
2
3
4
5
6
7
8
iOS Hitches Ratio:
- 总 hitch 时长 / 总活跃时长
- 业界标准:< 5 ms/s 算流畅
# 8.3 Web PerformanceObserver 全链路
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.entryType === 'longtask') {
console.log(`Long task: ${entry.duration}ms`);
}
});
}).observe({ entryTypes: ['longtask'] });
2
3
4
5
6
7
Web Vitals:
- INP (Interaction to Next Paint):用户交互到下一帧的时间
- LCP / CLS:首屏 / 布局稳定性
# 8.4 Compose / SwiftUI 帧 API
- Compose:使用 Android 的 FrameMetrics(共用)
- SwiftUI:使用 iOS MetricKit(共用)
框架级补充:
- Compose
Modifier.composed:可监控 composition 时长 - SwiftUI
_printChanges():调试 view 重建
# 8.5 系统帧 API 选型对照
| 场景 | Android | iOS | Web |
|---|---|---|---|
| 阶段拆解 | FrameMetrics(24+) | MetricKit(14+) | PerformanceObserver |
| 合成盲区补盲 | FrameTimeline(31+) | MXHitch | Long Animation Frames API |
| 长尾详情 | systrace + 调用栈 | Instruments | DevTools Performance |
| 实时打点 | Choreographer + 自定义 | CADisplayLink + 自定义 | rAF + 自定义 |
探索性思考:为什么 Android FrameMetrics 设计了 9 个阶段?因为渲染流水线的每个阶段都可能成为瓶颈——只看"总时长"无法定位问题。好的 API 设计 = 反映底层物理结构 —— Android FrameMetrics 的阶段对应渲染流水线的真实阶段,这是它的工程价值。
# 09.合成段全链路
应用提交了帧 ≠ 用户看到了帧。中间还有一段"合成段"——SurfaceFlinger / Render Server / Compositor 把多个 Surface 合成上屏。这一段是应用层数据的盲区。
# 9.1 Android SurfaceFlinger 合成全链路
应用 RenderThread
↓ queueBuffer
SurfaceFlinger BufferQueue
↓ Vsync-sf 触发
合成处理:
- 收集所有可见 Layer
- 计算可见区域
- 决定使用 GPU 合成 or HWComposer
↓
HWComposer (HWC) 决策:
- 简单场景 → 硬件合成(零 GPU)
- 复杂场景 → 回退到 GPU 合成
↓
FBO 输出
↓
显示器扫描
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键观察:
- 应用 callback 在 Vsync-app
- 合成在 Vsync-sf(应用之后)
- 合成段如果耗时 > 帧预算,即使应用按时也会丢帧
# 9.2 iOS Render Server 合成全链路
应用 Core Animation Transaction
↓ commit
Render Server (separate process)
↓
合成处理:
- 整合所有 CALayer
- 应用 Layer 属性(圆角/阴影/合成模式)
- GPU 渲染
↓
显示器
2
3
4
5
6
7
8
9
10
iOS 特殊性:
- 合成在独立进程(沙箱隔离)
- Off-screen Rendering 在合成段触发(圆角 + maskToBounds、阴影)
- Off-screen Rendering 是合成段卡顿的主要根因
# 9.3 Web Compositor 合成全链路
主线程 paint 到 layer
↓
Compositor 线程 (separate)
↓
收集所有 layer
↓
合成 + GPU 渲染
↓
显示器
2
3
4
5
6
7
8
9
Web 关键设计:
- transform / opacity 动画走 Compositor 线程,不阻塞主线程
- will-change 提示创建独立 layer
# 9.4 合成段隐性丢帧
典型场景:
场景 1:应用按时,合成超时
应用 RenderThread: ✅ 按时(10ms)
合成段: ❌ 超时(25ms)
用户看到: ❌ 丢一帧
场景 2:应用预算超,合成补救(三缓冲)
应用 RenderThread: ❌ 超时(20ms)
合成段: ✅ 沿用旧帧
用户看到: ⚠️ 看到旧帧(视觉静帧)
2
3
4
5
6
7
8
9
# 9.5 合成段监控对照
| 平台 | 合成段监控方式 | 监控指标 |
|---|---|---|
| Android | FrameTimeline (31+) | jank type 分类 |
| iOS | MXHitch | Hitches Ratio |
| Web | Long Animation Frames (104+) | 长动画帧时长 |
▶▶ 回扣 §02 案例:双击点赞 P99=180ms 的 SYNC_DURATION 100ms,正是"合成段同步上传 GPU 大纹理"——这是应用层 Choreographer 看不全的部分,必须用 FrameMetrics + FrameTimeline 双采才能定位。
探索性思考:为什么"合成段独立"是现代 OS 的共同设计?因为它把"应用产帧"和"屏幕显示"解耦——应用慢一点没关系,合成段可以用旧帧补救(三缓冲);合成在独立进程/线程也避免应用 crash 影响显示。解耦是工程的智慧 —— 一个慢的子系统不应拖累整个系统。
# 10.高刷与 ARR 全链路
90Hz / 120Hz 屏越来越普及,可变刷新率(VRR / ARR)是充分利用高刷的关键。
# 10.1 高刷屏物理原理
60Hz:每 16.67ms 一帧
90Hz:每 11.11ms 一帧
120Hz:每 8.33ms 一帧
帧预算 = 1000ms / 刷新率
2
3
4
5
用户感知阈值:
- 60 → 90Hz:日常滑动可感知
- 90 → 120Hz:极快滑动可感知
- 120 → 240Hz:仅游戏/电竞可感知
# 10.2 Android 自适应刷新率(ARR)
API 30+ 支持。
// 设置首选刷新率
val params = window.attributes
params.preferredRefreshRate = 120f
window.attributes = params
// API 31+ 更精细:preferredFrameRate
params.preferredDisplayModeId = mode.modeId
2
3
4
5
6
7
ARR 切换逻辑:
- 应用申请的刷新率范围
- 系统根据当前内容动态选择
- 内容静止 → 降到 24/30Hz(省电)
- 内容滚动 → 升到 120Hz
# 10.3 iOS ProMotion 全链路
iPhone 13 Pro+ / iPad Pro 2017+ 支持。
let link = CADisplayLink(target: self, selector: #selector(frame))
link.preferredFrameRateRange = CAFrameRateRange(
minimum: 24,
maximum: 120,
preferred: 60
)
link.add(to: .main, forMode: .common)
2
3
4
5
6
7
ProMotion 切换逻辑:
- 系统根据 CADisplayLink 注册者中"最大需求"决定
- 滚动期间自动升到 120Hz
- 静止期间降到 24Hz
# 10.4 Web 自适配
浏览器自动按显示器刷新率运行 rAF:
- 60Hz 屏:rAF 每 16.67ms
- 120Hz 屏:rAF 每 8.33ms
应用无感:开发者只需用 rAF 即可自动支持。
# 10.5 高刷利用最佳实践
┌──────────────────────────────────┐
│ 是否有动画/滚动? │
│ 是 → 申请高刷(120Hz) │
│ 否 → 让系统降频(24/60Hz) │
├──────────────────────────────────┤
│ 应用产帧能力? │
│ 能稳定产 120fps → 申请 120Hz │
│ 仅能产 60fps → 申请 60Hz(避免高刷浪费)│
├──────────────────────────────────┤
│ 滞后切换(hysteresis) │
│ 升频立即生效 │
│ 降频延迟 500ms(避免毛刺) │
└──────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
探索性思考:为什么"全程 120Hz"是反模式?因为它是"vanity metric"——好看但代价大。120Hz 全程对比 ARR 多耗电 29%(§17.5 实验五),用户根本察觉不到差异。性能优化的常见陷阱:把"做得到"当成"应该做"。
# 11.跨端 FPS 全链路
# 11.1 Compose 帧产出全链路
State 变化
↓
Recomposition(智能 diff)
↓
Layout phase
↓
Draw phase(Skia Canvas)
↓
走 Android 标准的 Choreographer + RenderThread
↓
SurfaceFlinger 合成
2
3
4
5
6
7
8
9
10
11
Compose 性能特性:
- Smart Recomposition:只重组变化的 @Composable
- LazyColumn 内置复用(无需手动 RecyclerView)
- 主线程占用通常少于 View 体系(Skipping 机制)
# 11.2 SwiftUI 帧产出全链路
State 变化(@State / @Binding)
↓
ViewBuilder 重新构造 View 树(值类型)
↓
ViewGraph diff
↓
转换为 CALayer
↓
走 iOS 标准 CADisplayLink + Render Server
2
3
4
5
6
7
8
9
# 11.3 Flutter 帧产出全链路
setState
↓
Element 树 diff
↓
RenderObject layout / paint
↓
Skia 渲染(Flutter Engine)
↓
平台 Surface(Android: Surface / iOS: Layer)
↓
平台合成
2
3
4
5
6
7
8
9
10
11
Flutter 特性:
- 自带 Skia 引擎(不依赖平台 UI)
- 帧时钟用 SchedulerBinding
- 60fps 默认,可启用 120fps
# 11.4 React Native 帧产出全链路
State / setState
↓
Reconciler diff
↓
通过 Bridge 传递给 Native
↓
原生 View 更新
↓
走 Android / iOS 标准链路
2
3
4
5
6
7
8
9
RN 性能瓶颈:
- Bridge 序列化开销
- 主线程 + JS 线程双线程协调
- 新架构 (Fabric) 减少 Bridge 开销
# 11.5 跨端 FPS 对照
| 框架 | 主线程压力 | 合成方式 | 高刷支持 |
|---|---|---|---|
| Android Native | 中 | SurfaceFlinger | ARR (12+) |
| Compose | 中(Skip 机制减负) | SurfaceFlinger | ARR (12+) |
| iOS Native | 低 | Render Server | ProMotion |
| SwiftUI | 低 | Render Server | ProMotion |
| Flutter | 低 | Skia + 平台合成 | 60fps(120 可启) |
| React Native | 中(Bridge 开销) | 平台合成 | 平台默认 |
| Web | 中 | Compositor | 浏览器自适配 |
探索性思考:为什么 Flutter 的"自带 Skia 引擎"在性能上反而占优?因为它跳过了平台 UI 的所有抽象——
UIView/View都是层层封装,Flutter 直接画到 Skia 上,实质上少了一层中间转换。性能优化的极致路径:自己掌控完整链路——但代价是放弃平台原生体验(如系统控件无障碍)。
# 12.跨端对照
# 12.1 五个全链路总览
| 链路 | Android | iOS | Web | Flutter | Compose |
|---|---|---|---|---|---|
| 帧时钟 | Choreographer | CADisplayLink | rAF | SchedulerBinding | Choreographer |
| 系统帧 API | FrameMetrics | MetricKit Hitch | Long Tasks API | DevTools timeline | FrameMetrics |
| 合成段 | SurfaceFlinger | Render Server | Compositor | Skia + 平台 | SurfaceFlinger |
| 高刷 | ARR (12+) | ProMotion | 浏览器自适配 | 60/120 | ARR |
| 跨端组件 | View tree | UIView tree | DOM | Widget tree | LayoutNode |
# 12.2 各平台 FPS 优化优先级
Android:
- 主线程同步任务异步化
- 列表 RecyclerView prefetch
- 大纹理预上传
- ARR 智能调度
iOS:
- 避免 Off-screen Rendering(圆角/阴影)
- UICollectionView 复用
- estimatedRowHeight
- ProMotion 智能调度
Web:
- transform/opacity 做动画
- 虚拟滚动
- 避免 layout thrashing
- CSS containment
# 12.3 反直觉问题答疑
| 问题 | 答案 |
|---|---|
| 60fps 均值代表流畅吗? | 不是。必看 P95/P99/Max |
| P99 比均值有用吗? | 是。用户感知 r=-0.87(强相关)vs 均值 r=0.31 |
| 90Hz 屏一定流畅吗? | 不是。应用产帧能力是真正瓶颈 |
| 32ms 算几次掉帧? | 一次(持续到下一个 Vsync 才重画) |
| 静态页面没掉帧 = FPS 数据丢失? | 是采集盲区,不是真没帧 |
| 后台 FPS 还有意义? | 没意义,应停止采集 |
| 帧率为 0 是 ANR 吗? | 不一定,可能只是无更新;ANR 是主线程阻塞 5s+ |
| VRR 下 FPS 怎么算? | 按当前刷新率动态计算帧预算 |
▶▶ 回扣 §02 案例:经验派 100% 命中"反直觉问题清单"——把均值当真相、把窗口拉宽当优化。这正是写本章的意义:每一个常识都需要重新验证。
# 13.治理一层度量
核心命题:在度量错的前提下做任何优化都是反向收益。这一层成本极低、收益极高,应作为 Day 1 第一动作。
# 13.1 均值看板替换为 P50/P95/P99/Max
机理:均值对长尾不敏感(§17.1 实验 r=0.31),P99 才与用户感知强相关(r=-0.87)。
代码(Android Choreographer + 流式分位):
public class FpsAggregator {
// T-Digest 在线分位估算,内存固定
private final TDigest digest = TDigest.createMergingDigest(100.0);
private long maxFrameNs = 0;
public void onFrame(long frameNs) {
digest.add(frameNs / 1_000_000.0);
maxFrameNs = Math.max(maxFrameNs, frameNs);
}
public Stats snapshot() {
return new Stats(
digest.quantile(0.50),
digest.quantile(0.95),
digest.quantile(0.99),
maxFrameNs / 1_000_000.0
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
收益:§02 案例 Day 1 落地后,看板暴露真相 P99=89ms,定位耗时从"4 周不见底"降到"4 小时"。
边界:T-Digest 默认 100 centroids,内存约 4KB;如果设备 RAM < 1GB 可降到 50。
# 13.2 帧分类四态分桶
机理:单一 P99 仍是粗粒度,需把帧时长按 §04.2 四态分类(完美/轻微/明显/严重/冻屏)分桶上报。
代码:
enum FrameClass { PERFECT, SLIGHT, OBVIOUS, SEVERE, FROZEN }
FrameClass classify(long durMs, long budgetMs) {
if (durMs <= budgetMs) return PERFECT;
if (durMs <= budgetMs * 1.5) return SLIGHT;
if (durMs <= budgetMs * 3) return OBVIOUS;
if (durMs < 700) return SEVERE;
return FROZEN;
}
2
3
4
5
6
7
8
9
收益:业务方看到"FROZEN 占比 0.3%"比 "P99=89ms"更易懂;可直接对接 SLO(FROZEN < 0.05%)。
# 13.3 APDEX 化统一对外指标
机理:跨平台对外汇报必须统一口径。Satisfied/Tolerating/Frustrated 与用户语义对齐。
收益:管理层无需理解 P99,看 APDEX 0.92 即知道"92% 用户满意"。
# 13.4 给业务设"无可争议"的红线
红线指标(建议):
| 指标 | 红线 | 黄线 | 绿线 |
|---|---|---|---|
| P99 帧时长 | > 50ms | 25-50ms | < 25ms |
| FROZEN 帧占比 | > 0.5% | 0.1-0.5% | < 0.1% |
| Max 帧时长 | > 700ms | 200-700ms | < 200ms |
为什么要"红线":
- 业务方理解的语言不是 P99,而是"红/黄/绿"
- 一旦红线越界,自动告警 + 阻断发版
探索性思考:为什么"度量改对"是最高 ROI 的优化?因为它的成本极低(改看板配置 + 上报字段)但反馈极快——一旦数据真相暴露,所有后续优化都有了"罗盘"。优化的第一步永远是"看清真相"。
▶▶ 回扣 §02 案例:方法派 Day 1 第一件事就是把均值看板替换为分位——这一个动作就让 4 周走不出来的迷雾消散。
# 14.治理二层采集
核心命题:数据采集的"窗口"和"原始性"决定了能不能看见真相。
# 14.1 每帧记录 + ≤1s 聚合
机理:§17.2 实验证明 10s 窗口稀释抖动信号;1s 窗口 + 原始分位才完整。
反例(错误做法,常见于老 SDK):
// 反例:每秒只记录一次"当前 FPS",丢失帧级信息
new Handler().postDelayed(() -> {
int curFps = frameCount; // 上一秒的总帧数
report("fps_avg", curFps);
frameCount = 0;
}, 1000);
2
3
4
5
6
正例:每帧入 T-Digest,按 1s 上报分位 + Max。
收益:§02 案例第 4 周拉宽窗口翻车,正是反例的真实代价。
边界:每帧记录 ≠ 每帧上报。上报粒度可以 5-10s 一次,但带原始分位数而非重新聚合。
# 14.2 方案① + 方案② + FrameTimeline 三层校准
机理:§17.4 实验证明应用层数据有合成段盲区,需系统 API 校准。
代码(Android 12+):
// 同时订阅 Choreographer + FrameMetrics + FrameTimeline
Choreographer.getInstance().postFrameCallback(frameClock);
window.addOnFrameMetricsAvailableListener(frameMetricsListener);
SurfaceControl.OnJankDataListener jankListener = jankData -> {
for (SurfaceControl.JankData j : jankData) {
if (j.getJankType() != JANK_NONE) systemJankCount++;
}
};
2
3
4
5
6
7
8
收益:发现"应用层 P99=18ms 但系统侧 jank 11.8%"——这种盲区只有三层校准能看到。
边界:FrameTimeline 仅 12+;低版本只能依赖外部测量定期校准。
# 14.3 静态页面盲区兜底
机理:无 Vsync 请求时帧回调不触发,需配合卡顿监控(卷三·03)。
代码:
Looper.getMainLooper().setMessageLogging(msg -> {
if (msg.startsWith(">>>>> Dispatching")) startTs = SystemClock.uptimeMillis();
else if (msg.startsWith("<<<<< Finished")) {
long dur = SystemClock.uptimeMillis() - startTs;
if (dur > 100) reportLag(dur);
}
});
2
3
4
5
6
7
收益:补全"无 callback ≠ 无问题"的视角。
边界:LooperPrinter 自身有 ~3% 性能损耗,建议采样 10% 用户开启。
# 14.4 调用栈快照(仅极端帧)
机理:每帧抓栈开销巨大;只在 P99 帧(> 50ms)抓即可。
代码:
public void onFrame(long frameNs) {
if (frameNs > 50_000_000) { // > 50ms
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
report("slow_frame_stack", stack);
}
}
2
3
4
5
6
收益:P99 帧的根因可直接从堆栈定位,归因效率提升 10×。
探索性思考:为什么"采集精度"是个被低估的话题?因为它"不出错时"看不出价值——团队按部就班用 1s 平均,看似没问题。直到出现"均值漂亮但用户骂"的案例,才发现根本看不到真相。好的采集是"防御性工程"——平时不显眼,关键时刻救命。
# 15.治理三层长尾
核心命题:FPS 优化的本质是消灭长尾帧。本节给出通用可复用的长尾治理手段。
# 15.1 主线程同步任务异步化
机理:长尾帧 80% 来自主线程同步任务(DB 查询、IO、measure 复杂布局)。
代码示例(§02 案例真实修复):
// 反例:onBindViewHolder 同步加载评论缩略图导致 measure 60ms
holder.thumbView.setImageBitmap(loadThumb(commentId));
// 正例:异步占位 + 后台解码
holder.thumbView.setImageDrawable(placeholderDrawable);
asyncDecoder.submit(() -> {
Bitmap bm = decodeThumb(commentId);
holder.thumbView.post(() -> holder.thumbView.setImageBitmap(bm));
});
2
3
4
5
6
7
8
9
收益:滑动稳态 P99 89ms→22ms。
边界:异步化会引入闪烁;需配合占位图和 view recycle 检测(防止 view 复用时填错图)。
# 15.2 弹层/动画的 GPU 纹理预上传
机理:弹层动画首帧 SYNC 段同步上传 GPU 大纹理常达 80-150ms。预上传到空闲帧。
代码:
// 在动画启动前 50ms 调用,让纹理在空闲帧上传
view.post(() -> {
Bitmap bm = ((BitmapDrawable) heavyDrawable).getBitmap();
bm.prepareToDraw(); // 触发 GPU 预上传
});
handler.postDelayed(() -> startAnimation(), 50);
2
3
4
5
6
收益:§02 案例双击点赞 P99 180ms→25ms。
边界:prepareToDraw() 仅 Hint,不保证;不要把所有 Bitmap 都 prepare(浪费显存)。
# 15.3 长任务切片 + IdleHandler 推迟
机理:> 50ms 的任务必然制造长尾帧。切成 < 8ms 片段并推迟到 IdleHandler。
代码:
Looper.myQueue().addIdleHandler(() -> {
if (pendingTasks.isEmpty()) return false;
long deadline = SystemClock.uptimeMillis() + 4;
while (!pendingTasks.isEmpty() && SystemClock.uptimeMillis() < deadline) {
pendingTasks.poll().run();
}
return !pendingTasks.isEmpty();
});
2
3
4
5
6
7
8
收益:典型场景(启动后预加载、列表预取)P99 改善 30-60%。
边界:IdleHandler 在用户持续操作时不触发;需配 postDelayed 兜底。
# 15.4 GC 抖动治理
机理:大对象分配触发 GC 暂停,导致单帧时长 > 50ms。
手段:
- 对象池(频繁分配的对象,如 Rect / Paint)
- 避免 String 拼接(用 StringBuilder)
- 避免 lambda capture 引用(Compose 中常见)
# 15.5 跨端长尾治理对照
| 长尾根因 | Android | iOS | Web |
|---|---|---|---|
| 主线程 IO | 异步 + IO 调度 | dispatch_async | requestIdleCallback |
| 大纹理上传 | prepareToDraw | layer.shouldRasterize | will-change |
| GC 抖动 | 对象池 + StringBuilder | ARC 自动 | 避免 closure 大对象 |
| 长任务 | IdleHandler | dispatchSourceTimer | scheduler.postTask |
探索性思考:为什么"消灭长尾"比"提升均值"更重要?因为用户感知的卡顿是"事件性"的——一次 200ms 卡顿比 100 次 20ms 慢更让人记住。人脑对"异常"更敏感而非"平均" —— 这是认知心理学的事实,性能优化必须利用这一点。
▶▶ 回扣 §02 案例:方法派 Day 4 三招组合(异步缩略图 + 纹理预上传 + 异步图片)让 P99 从 89ms → 22ms,正是"消灭长尾"在工程上的标准答案。
# 16.治理四层 VRR
核心命题:120Hz 设备越来越多,但"全程 120Hz"是反模式。本层给出 ARR 智能调度策略。
# 16.1 动态读刷新率,禁止硬编码
机理:硬编码 16.67ms 在 120Hz 屏判定标准变形。
代码:
float refreshRate = display.getRefreshRate();
long budgetMs = (long) (1000.0f / refreshRate);
boolean isJank = frameDurMs > budgetMs * 1.5;
2
3
收益:高端机数据从"虚假绿"变真实。
边界:刷新率会动态变(ARR),需在每次帧回调时读取,不可缓存。
# 16.2 按内容场景申请目标刷新率
机理:§17.5 实验证明 ARR 节电 29%。
代码(Android 12+):
WindowManager.LayoutParams lp = window.getAttributes();
lp.preferredFrameRate = isAnimating ? 120f : (isReading ? 60f : 24f);
window.setAttributes(lp);
2
3
代码(iOS ProMotion):
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 24, maximum: 120, preferred: 60
)
2
3
收益:1 小时混合场景节电 29%,温度下降 3-4°C。
边界:必须做滞后切换(hysteresis);典型策略:升频立即生效,降频延迟 500ms。
# 16.3 高刷场景压力测试纳入 CI
机理:高刷预算更紧(120Hz=8.33ms vs 60Hz=16.67ms),原 60Hz 能过的代码可能不过。
代码(Macrobenchmark):
@Test fun scrollAt120Hz() = benchmarkRule.measureRepeated(
metrics = listOf(FrameTimingMetric()),
setupBlock = { device.executeShellCommand("settings put system peak_refresh_rate 120") }
) { /* 滑动场景 */ }
2
3
4
收益:在合并前拦住"60Hz 勉强过、120Hz 翻车"的代码。
边界:需有 120Hz 真机;模拟器无意义。
# 16.4 ROI 排序
| ROI | 优化项 | 收益 | 成本 | 风险 |
|---|---|---|---|---|
| 极高 | 均值看板换分位 | 真相暴露 | 1 天 | 零 |
| 极高 | 每帧记录 + 1s 聚合 | 数据精度大幅提升 | 2-3 天 | 低 |
| 高 | FrameTimeline 三层校准 | 闭环线上数据 | 1 周 | 低 |
| 高 | 主线程同步任务异步化 | P99 直接降一半 | 2 周 | 中 |
| 高 | 动态读刷新率 | 高端机数据准确 | 2-3 天 | 低 |
| 中 | GPU 纹理预上传 | 弹层动画提升 | 1-2 周 | 中 |
| 中 | ARR 智能调度 | 节电 ~30% | 2-3 周 | 中 |
| 中 | 静态页面 LooperPrinter 兜底 | 闭环监控 | 1-2 周 | 低 |
| 中 | 长任务切片 + IdleHandler | P99 改善 | 1-2 周 | 低 |
| 低 | 自己实现 hitch 算法 | 极少收益 | 高 | 中 |
# 16.5 避免反向收益
- 过度采集:每帧抓栈开销巨大;调用栈仅在 P99 帧抓。
- 过度上报:把每帧数据全上报,流量爆炸;上报应只送分位 + 异常帧详情。
- 窗口拉宽掩盖问题:§02 案例第 4 周翻车的根因。
- 全程 120Hz:vanity metric,电池骂声比卡顿骂声更可怕。
探索性思考:为什么"ARR 智能调度"是高刷设备的"必修课"?因为不做调度等于浪费用户的电——120Hz 全程比 60Hz 多耗 30% 电量,对续航敏感的用户立刻感知。性能优化不能只看"FPS"一个指标,必须综合"FPS + 电量 + 温度" —— 否则就是"按下葫芦起了瓢"。
# 17.求证实验 ⭐
本节是"为什么这些优化生效"的实证基础。每个实验严格遵循"6 步求证法"。
# 17.1 实验一:均值掩盖抖动
猜想:均值 FPS 与用户感知强相关。
假设:均值 FPS 与用户感知弱相关(r < 0.4);P99 帧时长强相关(r > 0.8)。
设计:
- 控制变量:相同应用场景,构造 10 种不同"FPS 分布"配置
- 用户主观打分:30 名测试用户对每种配置打"流畅度" 1-5 分
- 主指标:均值 FPS / P99 帧时长 与 用户评分的相关系数
执行:
| 配置 | 均值 FPS | P99 帧时长 | 用户均分 |
|---|---|---|---|
| (a) 60 fps 均匀 | 60 | 17 ms | 4.9 |
| (b) 55 fps 均匀 | 55 | 19 ms | 4.6 |
| (c) 60 fps + 1×100ms | 59 | 100 ms | 3.4 |
| (d) 60 fps + 5×50ms | 58 | 50 ms | 3.6 |
| (e) 45 fps 均匀 | 45 | 22 ms | 4.1 |
| (f) 60 fps + 1×300ms | 58 | 300 ms | 2.1 |
验证:
- 均值 FPS 与用户评分相关系数 r = 0.31(弱相关)。
- P99 帧时长与用户评分相关系数 r = -0.87(强负相关)。
- 配置 (e) 均值更低但更流畅;配置 (f) 均值高但用户最差。
思考:
- 删除所有"均值 FPS"为唯一指标的看板。
- 新指标体系必有 P95 / P99 / Max。
- 性能 review 重点看长尾。
▶▶ 回扣 §02 案例:方法派只用 1 天就用线上数据复现出 r=-0.87 的强负相关——本实验的结论"删除均值看板"在那个案例里直接落地为 Day 1 第一动作。
# 17.2 实验二:采样间隔精度
猜想:1 秒采样足以反映抖动。
假设:窗口 ≤ 1s 能反映瞬时抖动;10s 窗口几乎只看均值。
执行:
| 窗口 | 100ms 帧识别率 | 最大窗口 P99 |
|---|---|---|
| 100ms 窗口 | 100% | 100 ms |
| 1s 窗口 | 仍可识别 | 100 ms |
| 10s 窗口 | 抖动被严重稀释 | 30 ms(被均值化) |
验证:
- 1s 窗口能识别但需要看 P99 数据。
- 10s 窗口几乎丢失抖动信息。
思考:
- 采样窗口应 ≤ 1s。
- 每帧采样(实时记录)+ 1s 聚合是最佳实践。
- 不要用 5s / 10s 窗口聚合。
# 17.3 实验三:高刷增益
猜想:120Hz 屏总比 60Hz 流畅。
假设:在应用能稳定产出 120 fps 时,用户感知"明显更流畅";如果应用最高只能产 60 fps,120Hz 屏几乎无增益。
执行:
| 应用 | 60Hz 帧率 | 60Hz 评分 | 120Hz 帧率 | 120Hz 评分 |
|---|---|---|---|---|
| A (简单) | 60 fps | 4.4 | 118 fps | 4.8 |
| B (复杂) | 55 fps | 3.9 | 60 fps(被业务卡) | 4.0 |
验证:
- 应用 A 在 120Hz 下评分明显提升。
- 应用 B 因应用层卡 60 fps,高刷"白买"。
思考:
- 高刷屏的价值取决于应用自身能否产足够多帧。
- 应用层瓶颈不解决,高刷屏几乎无用。
# 17.4 实验四:FrameTimeline 揭露合成段隐性丢帧
猜想:应用层 Vsync 监控就足够。
假设:方案①只能看到"应用提交了多少帧",看不到"系统是否合成成功"。
执行:
| 状态 | 应用层 Choreographer P99 | FrameTimeline 系统侧丢帧率 | 用户感知 |
|---|---|---|---|
| 无 GPU 干扰 | 18 ms | 0% | 流畅 |
| 中等 GPU 干扰 | 18 ms(不变!) | 4.2% jank | 偶发卡顿 |
| 高 GPU 干扰 | 19 ms | 11.8% jank | 明显卡 |
验证:
- 应用层数据完全感知不到合成段丢帧。
- FrameTimeline 通过
expectedPresentationTimevsactualPresentationTime直接给出"到底有没有显示出去"。
思考:
- 方案①有合成段盲区。
- 线上需配合 FrameTimeline(Android 12+)/ MetricKit Hitches(iOS 14+)。
# 17.5 实验五:ARR 自适应能效收益
猜想:120Hz 全程开启功耗代价可忽略。
假设:ARR 按内容切换可在保持流畅的同时降功耗 15-30%。
执行:
| 模式 | 1 小时电池消耗 | 滑动 P99 | 阅读时刷新率 |
|---|---|---|---|
| (A) 固定 120Hz | 540 mAh | 12 ms | 120 Hz |
| (B) ARR 自适应 | 380 mAh (-29%) | 12 ms | 24 Hz / 60 Hz |
验证:
- 滑动场景两者一致(120 fps),用户感知无差。
- 阅读场景 ARR 切到 24Hz,节省 29% 电量且用户察觉不到。
思考:
- 120Hz 设备应主动用 ARR/preferredRefreshRate API 按内容切换。
- 收益:~30% 电池续航,且体感无差。
- 不要"为了好看"全程 120Hz——这是 vanity metric。
# 17.6 五大实验启示
均值掩盖抖动 → 必须用 P95/P99/Max ─┐
│
采样间隔精度 → 窗口 ≤ 1s,原始数据不聚合 │
│
高刷增益 → 应用层瓶颈不优化就无增益 ├─▶ FPS = 长尾游戏 + 系统协同
│
FrameTimeline 校准 → 方案①有合成盲区,需系统侧补充 │
│
ARR 自适应 → 高刷不等于全程开,电池才是隐藏指标 ─┘
2
3
4
5
6
7
8
9
统一启示:
- 流畅是分布问题,不是均值问题:所有数据看板必须改用分位。
- 数据采集要保留原始性:避免过早聚合,窗口 ≤ 1s。
- 高刷不是免费午餐:应用层必须配合优化才能发挥价值,且要考虑能效。
- 应用层数据有边界:合成段丢帧需系统 API 校准。
- FPS 监控的终极形态:分位指标 + 应用层订阅 + 系统侧校准 + ARR 智能调度。
▶▶ 回扣 §02 案例:方法派的胜利不是用了什么神奇技术,而是按 §17 的实验逻辑反复验证后才动手。实验是优化前的"必经之路" —— 跳过实验直接套招,就是经验派 4 周折腾的真实代价。
# 18.实战案例
# 18.1 跨端同构案例:直播应用三端"60fps 但卡"
背景:某直播应用 Android / iOS / Web 三端都报"60fps 但用户反馈卡"。
度量与归因:切换到 P95/P99 后发现:
| 平台 | 均值 FPS | P99 帧时长 | 主因 |
|---|---|---|---|
| Android | 59 | 180 ms | 弹幕重绘 + GC 抖动 |
| iOS | 60 | 95 ms | 图片加载偶发阻塞主线程 |
| Web | 58 | 220 ms | 大量 DOM 节点 + reflow |
治理:
- Android:弹幕用独立 SurfaceView 隔离 + 对象池减 GC
- iOS:图片改异步解码 + estimatedRowHeight
- Web:虚拟滚动 + transform 替代 top/left
效果:
| 平台 | P99 帧时长(before) | P99 帧时长(after) |
|---|---|---|
| Android | 180ms | 22ms |
| iOS | 95ms | 20ms |
| Web | 220ms | 30ms |
核心洞察:三端瓶颈完全不同,但用同一套度量体系(P99)发现问题——这就是统一指标的价值。
# 18.2 平台特异案例:Android ROM 厂商定制限制
背景:某 App 在某厂商 ROM 上滑动 FPS 持续低于其他厂商。
根因:
- 该厂商默认开启"多任务模式",对非前台应用 CPU 限频
- 应用滑动时主线程频繁被调度切走
治理:
- 申请前台保护(系统 API)
- 关键操作前请求 CPU boost(如有 SDK)
- 优化降低对 CPU 持续占用需求
效果:滑动 P99 从 65ms → 28ms。
# 18.3 反例案例:Compose 误用导致 FPS 倒退
背景:某 App 用 Compose 重写后,长列表 FPS 比原 RecyclerView 还差。
根因:
@Composable
fun ItemRow(item: Item, onClick: () -> Unit) {
Row(modifier = Modifier.clickable { onClick() }) { ... }
}
LazyColumn {
items(list) { item ->
ItemRow(item, onClick = { handleClick(item) }) // ❌ lambda 每次新建
}
}
2
3
4
5
6
7
8
9
10
每次重组都新建 lambda,导致 Skipping 失效。
治理:
LazyColumn {
items(list, key = { it.id }) { item -> // ✅ 加 key
ItemRow(item, onClick = onClickHandler) // ✅ 复用 lambda
}
}
2
3
4
5
效果:滚动 FPS 从 35 → 58。
洞察:Compose 不会自动给你"最优性能"——你必须理解 Skipping 机制(参数稳定 + 引用一致),否则 Compose 反而比传统 View 慢。
# 19.防劣化体系
优化不难,难的是"优化后不劣化"。需要"事前预防 + 事中拦截 + 事后回归"三道防线。
# 19.1 三道防线总览
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 编码期 Lint │ → │ CI 卡口 │ → │ 线上 SLO │
│ IDE 即时提示│ │ Macrobench │ │ 监控告警 │
└────────────┘ └────────────┘ └────────────┘
2
3
4
# 19.2 编码期 Lint
自定义规则:
- 检测 onBindViewHolder 中的同步 IO 操作
- 检测 Compose 中的 lambda capture(破坏 Skipping)
- 检测 invalidate() 调用(建议 invalidate(Rect))
- 检测硬编码的 16.67ms 帧预算(建议 dynamic)
# 19.3 CI 卡口
性能基线测试:
// Macrobenchmark FrameTimingMetric
@Test
fun scrollBenchmark() = benchmarkRule.measureRepeated(
metrics = listOf(FrameTimingMetric()),
iterations = 10
) {
startActivityAndWait()
val list = device.findObject(By.res("recyclerView"))
list.fling(Direction.DOWN)
}
2
3
4
5
6
7
8
9
10
卡口规则:
- P99 帧时长退化 ≥ 10% → 阻断 PR
- P50 帧时长退化 ≥ 5% → 警告
- FROZEN 帧出现 → 必须 review
# 19.4 线上 SLO
核心 SLO 指标:
| 指标 | 目标 | 告警阈值 |
|---|---|---|
| 滑动 P99 帧时长 | < 25ms | > 40ms |
| FROZEN 帧占比 | < 0.1% | > 0.3% |
| Max 帧时长 | < 200ms | > 500ms |
| ARR 高刷利用率 | > 70% | < 50% |
# 19.5 文化建设
- 性能预算:新页面必须申报"FPS 预算"
- 性能 Code Review:动画/列表改动必须有 perf reviewer
- 性能 OKR:列表 P99 进 OKR
探索性思考:为什么 FPS 防劣化比其他指标更难?因为 FPS 是"统计指标"——单次测试不可靠,必须看分布。这意味着 CI 上跑一次 Macrobench 不够,需要多次平均后才能判断退化。统计指标的防劣化需要"统计学严谨"——容易误判、容易漏判。
# 20.跨平台速查
# 20.1 工具速查
| 平台 | 帧时钟 | 系统帧 API | 离线分析 |
|---|---|---|---|
| Android | Choreographer | FrameMetrics / FrameTimeline | Perfetto / systrace |
| iOS | CADisplayLink | MetricKit MXHitchEvent | Instruments Time Profiler |
| Web | requestAnimationFrame | PerformanceObserver | Chrome DevTools |
| Compose | Choreographer | FrameMetrics | Compose Compiler Reports |
| Flutter | SchedulerBinding | DevTools Timeline | flutter_driver |
| 嵌入式 | LCD IRQ | 厂商 SDK | 串口日志 |
# 20.2 关键 API 速查
| 目的 | Android | iOS | Web |
|---|---|---|---|
| 订阅帧时钟 | Choreographer.postFrameCallback | CADisplayLink | rAF |
| 获取阶段数据 | FrameMetrics | MXHitchEvent | PerformanceObserver |
| 设置目标刷新率 | preferredRefreshRate | preferredFrameRateRange | 浏览器自适配 |
| 标记长任务 | Trace.beginSection | os_signpost | Performance.mark |
| 静态盲区兜底 | LooperPrinter | RunLoop observer | requestIdleCallback |
| 动态读刷新率 | display.getRefreshRate() | UIScreen.maximumFramesPerSecond | window.screen.refreshRate |
# 20.3 各平台 FPS 优化清单
Android:
- [ ] 均值看板换 P50/P95/P99/Max
- [ ] FrameMetrics + FrameTimeline 双采
- [ ] onBindViewHolder 异步化
- [ ] 大纹理 prepareToDraw
- [ ] 动态读取 refreshRate
- [ ] ARR 智能调度
iOS:
- [ ] MetricKit Hitches Ratio 监控
- [ ] cellForRowAtIndexPath 避免 IO
- [ ] estimatedRowHeight
- [ ] 避免 Off-screen Rendering
- [ ] ProMotion preferredFrameRateRange
Web:
- [ ] Long Tasks API 监控
- [ ] 虚拟滚动
- [ ] transform / opacity 做动画
- [ ] CSS Containment
- [ ] requestIdleCallback 推迟非紧急任务
# 21.总结与延伸
# 21.1 五条核心原则
- 分布先行:FPS 是抖动游戏,必看 P95/P99/Max,不看均值。
- 采集精度:每帧记录 + ≤1s 聚合,不要让窗口稀释信号。
- 三层校准:应用层 + 系统帧 API + 外部测量,弥补合成段盲区。
- 长尾治理:消灭 P99 比"提升均值"更影响用户感知。
- VRR 智能:高刷不是全程开,按内容切换是高端机的"必修课"。
# 21.2 五个常见误区
| 误区 | 真相 |
|---|---|
| "均值 60fps 就是流畅" | 错。P99 才与用户感知强相关 |
| "1 秒采样就够" | 错。需要每帧记录、避免窗口稀释 |
| "120Hz 屏自动让应用流畅" | 错。应用产帧能力是真正瓶颈 |
| "应用层 Vsync 监控完整" | 错。有合成段盲区 |
| "全程 120Hz 最好" | 错。耗电 30% 而用户察觉不到差异 |
# 21.3 一句话总结
FPS 是抖动游戏,不是均值游戏。用户感知的"流畅"取决于帧时长分布的尾部(P99/Max),而非平均值。所有 FPS 优化都要建立在"度量改对、采集可信"的基础上——度量错了,一切努力都是反向收益。
# 21.4 延伸阅读
卷三·01 渲染管线与原理:FPS 是流水线的产出度量卷三·03 卡顿捕获与归因:卡顿是 FPS 抖动的极端形态卷三·04 ANR 监控与治理:极端长帧(5s+)的处理卷三·05 页面 UI 与布局优化:UI 优化的应用层落地卷三·06 动画交互响应优化:交互动效专项卷四·05 功耗与电量优化:高刷的能耗代价
下一篇预告:
卷三·06 动画交互响应优化—— 把"用户操作到画面响应"的最后一公里做好。