双端 FPS 全景解析:Android 与 iOS 的渲染机制、监控与优化
本文结合 Android 与 iOS 渲染机制,深入解析 FPS(Frames Per Second)原理、系统实现差异、监控技术演进与优化实践,帮助开发者建立跨平台的性能视角。
1. FPS 与刷新率的概念与关系
屏幕刷新率(Refresh Rate)
- 定义:硬件属性,指屏幕每秒能够刷新的次数,单位 Hz(赫兹)。
- 例子:常见的 60Hz 意味着屏幕每秒最多刷新 60 次,即每 16.6ms 会刷新一次画面。
- 固定性:由屏幕硬件决定,应用无法修改。
FPS(Frames Per Second)
- 定义:软件渲染输出,指 GPU 每秒绘制完成的帧数。
- 本质:反映 CPU/GPU 的处理速度,是否能及时准备好一帧画面供屏幕显示。
- 常见值:60fps、90fps、120fps 等。
两者的关系
- FPS > 刷新率:实际显示受限于刷新率。例如在 60Hz 屏幕上,哪怕 GPU 渲染能力达到 120fps,用户仍只能看到 60fps,并且可能出现画面撕裂。
- FPS = 刷新率:理想状态,渲染与刷新匹配,画面最流畅。
- FPS < 刷新率:渲染端供给不足,屏幕会重复显示旧帧,导致掉帧和卡顿。
关于 120Hz 高刷新率
- 定义:现在高端手机普遍支持 90Hz 或 120Hz 屏幕,意味着屏幕每秒可以刷新 90/120 次。
- 意义:相比 60Hz,高刷新率让画面滚动更丝滑,响应更跟手,尤其在游戏和滑动场景中明显。
- 限制 :
- 如果应用渲染能力不足(例如 FPS 只有 60fps),即使屏幕支持 120Hz,用户体验也不会提升。
- 反之,如果渲染能稳定维持 120fps,高刷屏才能真正发挥优势。
📌 总结:
- 刷新率是天花板,由硬件决定;
- FPS 是地板,由软件渲染决定;
- 用户实际体验取决于两者的匹配程度。
2. 渲染管线全景对比
在理解 FPS 之前,需要先弄清楚 一帧画面是如何从应用层产生,最终显示到屏幕上的 。
虽然 Android 和 iOS 在实现细节和命名上差别很大,但整体的渲染管线高度一致:从 应用层提交绘制请求 → 系统调度 → 合成 → GPU 渲染 → 显示。
下表给出了两端的核心组件对照:
阶段 | Android | iOS | 共性 |
---|---|---|---|
应用层 | View / RenderThread | UIKit / CALayer | 接收输入事件、生成绘制指令 |
渲染调度 | Choreographer | CADisplayLink | 基于 VSync 驱动帧循环,决定何时渲染 |
合成器 | SurfaceFlinger | Render Server | 将多层内容合成为最终画面 |
GPU | OpenGL/Skia/Vulkan | Metal/OpenGL ES | 负责图形光栅化和绘制 |
显示 | FrameBuffer | FrameBuffer | 最终送入屏幕显示 |
为什么要对照这张表?
- 打通认知:Android 和 iOS 的术语不同,但职责几乎一一对应。比如 Choreographer ≈ CADisplayLink,它们都负责在 VSync 信号到来时调度渲染。
- 抓住共性:不论平台,掉帧的根本原因都是"某个阶段没能在 16.6ms 内完成"。所以优化思路具有跨平台通用性。
- 承上启下:理解了这条流水线,后面再讲 VSync、掉帧诊断、FPS 监控时,就能迅速定位到问题是出在调度、合成还是 GPU 渲染。
📌 可以把这张表理解为 渲染管线的双语对照表,它让我们在跨平台性能分析时不至于"各说各话"。
3. 缓冲机制演进
屏幕渲染并不是实时显示 GPU 的绘制结果,而是通过 缓冲区(Buffer) 来解耦"绘制"和"显示"两个过程。
缓冲机制的演进,正是为了在 效率、撕裂、延迟 之间寻找平衡。
3.1 单缓存(Single Buffer)
最原始的方式:
- 只有一个 Buffer。
- GPU 不断往 Buffer 里写入数据,屏幕同时从 Buffer 里读取。
📌 问题:
- 如果 GPU 正在写入,而屏幕同时读取,就可能出现 部分显示新帧、部分显示旧帧 的情况,造成 撕裂(Tearing)。
写入 读取 CPU/GPU 绘制 Single Buffer 屏幕显示
3.2 双缓存(Double Buffering)
为避免撕裂,引入两个 Buffer:
- Front Buffer:屏幕正在显示。
- Back Buffer:GPU 正在绘制。
- 在 VSync 信号 到来时,交换 Front/Back Buffer 的指针。
📌 优点
- 避免撕裂,屏幕总是显示完整帧。
📌 问题
- 如果 GPU 没能在 16.6ms 内画完一帧,就会错过 VSync 信号,导致 掉帧(Jank)。
- 在双缓冲机制下,GPU 渲染完一帧画面后,如果屏幕还没刷新到下一次,它就只能停下来等。这段时间 GPU 什么都干不了,相当于"坐在那儿发呆"。这就是所谓的"闲置",会让 GPU 的工作效率变低。换句话说,并不是 GPU 算力不够,而是它画得太快,结果被屏幕刷新节奏卡住了。
显示 VSync 时交换 GPU 绘制 Back Buffer Front Buffer 屏幕
3.3 三缓存(Triple Buffering)
为缓解 GPU "等待"的问题,再引入一个 Buffer:
- GPU 绘制时,如果 Back Buffer 已满,可以先写入第三个 Buffer。
- 这样 GPU 几乎不会阻塞,提高吞吐量。
📌 优点
- 避免 GPU 空转,提高整体流畅度。
三缓冲的两个问题
1. 延迟更高
- 因为多了一个缓冲区,用户的操作要经过更多"排队",画面响应会比双缓冲慢大约 1 帧(8--16ms)。
- 这会让操作灵敏度下降,尤其是在需要快速反应的游戏里更容易被感知。
2. 显存占用更大
- 每多一个缓冲区就要额外存一整帧画面,意味着更多的显存开销。
- 在高分辨率或显存有限的设备上,这会增加内存压力。
显示 VSync 时交换 VSync 时交换 GPU 绘制 Back Buffer1 Back Buffer2 Front Buffer 屏幕
3.4 iOS 的演进类比
-
早期 iOS(Quartz/CoreGraphics 时代)
- 类似双缓冲,Core Animation 将 Layer 渲染后提交给显示服务器(WindowServer)。
-
现代 iOS(Core Animation + Metal)
- 默认使用 Triple Buffering,保证滚动和动画的平滑。
- Render Server 类似 Android 的 SurfaceFlinger,负责合成多层内容。
- Apple 在 WWDC 文档中明确提出,高刷新率(120Hz ProMotion)下 Triple Buffering 是保持丝滑体验的核心。
📌 总结
- Android 和 iOS 都从单缓存 → 双缓存 → 三缓存 演进。
- 区别在于:iOS 更早在系统级别强制采用 Triple Buffering,而 Android 允许设备厂商/驱动层灵活选择。
3.5 对比总结
机制 | 原理 | 优点 | 缺点 | Android 实现 | iOS 实现 |
---|---|---|---|---|---|
单缓存 | 一个 Buffer 同时读写 | 简单、延迟最低 | 容易撕裂 | 早期图形栈 | 早期 Quartz |
双缓存 | 前台显示 + 后台绘制 | 避免撕裂 | 容易掉帧,GPU 等待 | Android 早期默认 | iOS 早期 |
三缓存 | 再加一个后台缓冲 | 提高吞吐、平滑 | 增加一帧延迟 | Android 可选 | iOS Metal 默认 |
3.6 高刷新率下的意义
- 在 120Hz 屏幕 下,每帧的预算时间缩短到 8.3ms(相比 60Hz 的 16.6ms)。
- 如果只有 双缓冲,GPU 容易因错过 VSync 而产生大面积掉帧。
- 三缓冲 能在一定程度上缓冲 GPU 压力,保证帧率稳定。
- 因此,高刷新率设备几乎都会依赖 Triple Buffering 来支撑丝滑体验。
4. VSync 驱动下的帧调度机制
在 Android 和 iOS 上,屏幕刷新都以 VSync 信号 作为"节拍器",驱动一帧的渲染流水线。
60Hz 下每 16.6ms 就会发出一次 VSync 信号,120Hz 下则是 8.3ms。
如果某一帧的流水线执行超过了这个时间预算,就会触发掉帧。
Android 的调度链路
VSync 信号到来后,由 Choreographer 统一调度,依次执行四类回调队列:
- Input:分发输入事件,例如触摸、点击、手势等。
- Animation:执行系统和开发者定义的动画。
- Traversal:进行 UI 树的 measure、layout、draw。
- Commit :开发者注册的自定义回调(
postCallback
)。
随后由 RenderThread 负责发起 OpenGL/Skia 指令,交给 GPU 渲染。
iOS 的调度链路
iOS 通过 CADisplayLink 将 VSync 信号传递到 RunLoop,在一次循环中完成:
- 执行输入事件分发。
- 执行动画回调(Core Animation)。
- 提交图层树到 Render Server。
- Render Server 汇总后交由 GPU 渲染。
📌 共性问题
- 整条流水线上任一环节(输入 → 动画 → 布局 → 渲染 → GPU)超过刷新间隔时间,就会导致掉帧。
- 例如:
- 输入事件分发过慢 → 手势响应卡顿;
- 动画逻辑复杂 → 播放不流畅;
- 布局层级过深 → 测量和绘制时间超标;
- GPU 过载 → 渲染无法赶在下一个 VSync 完成。
5. 系统内核实现
在理解 FPS 的过程中,必须要认识两个核心组件:
- Android 的 Choreographer
- iOS 的 CADisplayLink
它们的作用类似,都是基于 VSync 信号 驱动渲染的"帧调度器",决定了应用何时处理输入、动画和绘制。
5.1 Android - Choreographer
Choreographer 出现在 Android 4.1 (Project Butter) 中,解决了早期掉帧严重的问题。
它的核心职责是:
- 统一接收 VSync 信号 (由
DisplayEventReceiver
下发)。 - 将一帧内的任务分阶段调度 :
- Input:处理输入事件。
- Animation:执行动画回调。
- Traversal :视图树遍历,触发
measure/layout/draw
。
- 最终把数据交给 RenderThread + GPU 渲染。
关键点:
- 所有操作都在 VSync 的 16.6ms(或 8.3ms@120Hz)预算内执行。
- 任意阶段阻塞,都会导致掉帧。
- 我们常用
Choreographer.postFrameCallback()
注册帧回调,来统计 FPS 或监控掉帧。
kotlin
Choreographer.getInstance().postFrameCallback { frameTimeNanos ->
// 在 VSync 驱动下被调用
// 可用于 FPS 监控
}
5.2 iOS - CADisplayLink
在 iOS 中,CADisplayLink 提供了类似的能力:
- 本质是一个 基于 VSync 的计时器。
- 每次屏幕准备刷新时,CADisplayLink 会回调一次。
- 开发者可以在回调中执行动画步进或 FPS 统计。
典型用法
swift
let displayLink = CADisplayLink(target: self, selector: #selector(step))
displayLink.add(to: .main, forMode: .common)
@objc func step(link: CADisplayLink) {
// 每次 VSync 调用一次,可用来更新动画或统计 FPS
}
关键点
- CADisplayLink 不直接渲染,而是通知开发者"下一帧要来了"。
- 最终绘制由 Core Animation 提交 Layer 树,再交给 Render Server 合成并交给 GPU。
- Instruments 中的 Core Animation FPS 面板 就是基于 CADisplayLink 机制采样的。
📌 Swift 语法解释
-
to: .main
- 表示将 CADisplayLink 添加到 主运行循环(RunLoop.main)。
- 因为 UI 更新必须在主线程完成,所以这里选择
.main
。
-
forMode: .common
- 指定运行循环模式。
.common
是一个集合模式,包含了常见的 RunLoop 模式(如默认模式、UI 跟踪模式)。- 使用
.common
可以确保 CADisplayLink 在 滚动、动画、交互 等多种情况下都能继续触发,而不会因为 RunLoop 切换模式而被暂停。
📌 如果使用 .default
:
- 当用户滑动 ScrollView 时,RunLoop 模式会切换到 UITrackingRunLoopMode,此时定时器/DisplayLink 会被"卡住"。
- 使用
.common
就是为了解决这个问题,保证动画和 FPS 统计不中断。
6. FPS 监控方案
Android FPS - 旧方案
- 旧方案:单纯统计 frameCallback 次数 → 易误判。
- 新方案:统计每帧耗时,换算掉帧数 → 精确反映 FPS。
核心代码示例解读(旧方案)
旧方案的 FPS 统计完全依赖 Choreographer
的帧回调:
- 每次 VSync 信号触发时回调
doFrame()
; - 在回调中把计数器
counter++
; - 在统计结束时,用
(总帧数 - 1) / 时间间隔
算出平均 FPS。
核心方案
kotlin
// FpsTracer.java ------ 旧方案(基于 Choreographer 计数的窗口平均 FPS)
package your.pkg;
import android.annotation.TargetApi;
import android.os.Build;
import android.util.Log;
import android.view.Choreographer;
public class FpsTracer {
public interface IFPSCallBack {
void fpsCallBack(long fps);
}
/** 可选:逐帧时间回调(毫秒),便于打点或联动其他监控 */
public interface IFrameCallBack {
void onFrame(long frameTimeMs);
}
private final String mTag;
// 运行态
private volatile boolean mFPSState = false;
private Choreographer.FrameCallback mFrameCallback;
// 统计变量(纳秒)
private long mStartTimeNanos = -1L;
private long mLastFrameNanos = -1L;
private int mCounter = 0;
// 回调
private IFPSCallBack mIFPSCallBack;
private IFrameCallBack mIFrameCallBack;
public FpsTracer(String tag) {
this.mTag = tag;
}
public void setIFPSCallBack(IFPSCallBack cb) {
this.mIFPSCallBack = cb;
}
public void setIFrameCallBack(IFrameCallBack cb) {
this.mIFrameCallBack = cb;
}
/** 对外:开始采样(需在主线程调用) */
public synchronized void start() {
if (mFPSState) return;
mFPSState = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
startJellyBean();
} else {
// Pre-16 设备极少,旧方案不做适配(也可用 Handler 16ms 模拟)
Log.w(mTag, "FpsTracer unsupported on API < 16");
mFPSState = false;
}
}
/** 对外:停止采样并计算/回调(需在主线程调用) */
public synchronized void stop() {
if (!mFPSState) return;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
endHighJellyBean();
} else {
mFPSState = false;
}
}
// ---------------- 内部:API 16+ 核心实现 ----------------
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private void startJellyBean() {
// 复位统计
mStartTimeNanos = -1L;
mLastFrameNanos = -1L;
mCounter = 0;
mFrameCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (mStartTimeNanos == -1L) {
mStartTimeNanos = frameTimeNanos;
}
// 可选:逐帧时间回调(转毫秒)
if (mIFrameCallBack != null) {
mIFrameCallBack.onFrame(frameTimeNanos / 1_000_000L);
}
// 累计帧数
++mCounter;
// 循环注册下一帧
if (mFPSState) {
Choreographer.getInstance().postFrameCallback(this);
}
mLastFrameNanos = frameTimeNanos;
}
};
// 注册首次回调
try {
Choreographer.getInstance().postFrameCallback(mFrameCallback);
} catch (Throwable t) {
Log.w(mTag, "postFrameCallback error: " + t.getMessage());
mFPSState = false;
}
}
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private void endHighJellyBean() {
// 计算/上报
calculateFps();
// 移除回调,结束态
if (mFrameCallback != null) {
try {
Choreographer.getInstance().removeFrameCallback(mFrameCallback);
} catch (Throwable ignore) {
}
mFrameCallback = null;
}
mFPSState = false;
}
/** 窗口平均 FPS = (帧数 - 1) / (时间间隔秒) */
private void calculateFps() {
final long intervalNanos = mLastFrameNanos - mStartTimeNanos;
if (intervalNanos <= 0 || mCounter <= 1) return;
long fps = (mCounter - 1) * 1_000_000_000L / intervalNanos;
if (mIFPSCallBack != null) {
try {
mIFPSCallBack.fpsCallBack(fps);
} catch (Throwable t) {
Log.w(mTag, "fps callback error: " + t.getMessage());
}
}
if (BuildConfig.DEBUG) {
Log.d(mTag, "avg fps = " + fps + " (frames=" + mCounter + ", windowNs=" + intervalNanos + ")");
}
}
}
使用示例
kotlin
// 启动阶段 FPS 监控
private val mFpsTracer = FpsTracer("FPS_LAUNCH").apply {
this.setIFPSCallBack { fps ->
Log.d("fps_launch", "fps = $fps")
}
}
fun startLaunchFpsMonitor() {
val startTime = System.currentTimeMillis()
Log.d("fps_launch", "fps monitor start (20s)")
mFpsTracer.start()
handler.postDelayed({
mFpsTracer.stop()
val duration = (System.currentTimeMillis() - startTime) / 1000
Log.d("fps_launch", "fps monitor stop after ${duration}s")
}, 20_000) // 统计 20 秒
}
这段代码的目标是:在应用启动阶段,利用 Choreographer
的帧回调,在一个固定窗口(20s)内统计平均 FPS,并把结果输出到日志 。它属于"旧方案"的实现范式------按回调次数/时间窗口估算 FPS。
代码做了什么
-
创建监控器并设置回调
FpsTracer("FPS_LAUNCH")
:声明一次"启动阶段"的 FPS 采样任务。setIFPSCallBack { fps -> ... }
:当FpsTracer
计算出 FPS(平均值)后,通过回调打印日志。
-
开启 20 秒的采样窗口
mFpsTracer.start()
:内部注册Choreographer.postFrameCallback
,每到一帧就累加计数。handler.postDelayed({ ... }, 20_000)
:在 20s 后 自动stop()
,结束采样并触发一次计算与上报。
-
计算与上报
- 结束时,
FpsTracer.calculateFps()
用(帧数-1) / (时长)
得到窗口内的平均 FPS ,通过setIFPSCallBack
回传并打印。
- 结束时,
关键点 & 注意事项
- 统计口径 :这是"窗口内平均 FPS ",不是逐帧实时 FPS,也不包含"掉了几帧、掉在第几秒"的细粒度信息。
- 线程与生命周期
FpsTracer.start()
/stop()
应在 主线程 调用,因为Choreographer
依赖主线程Looper
。- 建议在 冷启动首屏渲染前后调用,避免拉长采样窗口覆盖到用户停留/跳转等行为导致偏差。
- 刷新率敏感 :在 90Hz/120Hz 设备上也能工作,但只是平均值,无法反映高刷下"间歇性掉帧"的细节。
- 性能开销 :旧方案本身开销极低(只做计数),适合粗粒度巡检,不适合精细诊断。
为什么它"容易误判"
- 旧方案仅按回调次数 估算 FPS,默认认为"两次回调之间 >16.6ms 就掉帧 "。
但现实是:即便相邻两次回调时间差较大,只要 每次绘制都在各自的 VSync 周期内完成,用户不一定能感知到"掉帧"。 - 因此它无法区分 :
- "VSync 周期内的绘制完成(未掉帧)" vs "跨 VSync 的超时(真掉帧)";
- 也无法累计"这 20s 里到底掉了多少帧、掉在何时"。
时序流程(旧方案)
App/业务代码 FpsTracer Choreographer Handler(20s) start() postFrameCallback(callback) doFrame(frameTimeNanos) counter++(累计帧数) postFrameCallback(callback)(注册下一帧) loop [每一帧] postDelayed(20s, stop) stop() calculateFps() = (counter-1)/窗口时长 IFPSCallBack(fps) App/业务代码 FpsTracer Choreographer Handler(20s)
Android - FPS 监控新方案
旧方案的问题在于:只统计了回调次数,容易误判掉帧 。
例如:两次回调间隔大于 16.6ms 就会被算作掉帧,但实际上只要绘制在 VSync 周期内完成,用户并不会感知卡顿。
为了解决这个问题,新方案引入了"逐帧耗时统计 + 掉帧换算"。
实现思路
-
获取 VSync 开始时间
- 在
Choreographer
内部,VSync 时间会写入FrameInfo
。 - 可以通过反射或者内部 Hook 拿到
mFrameInfo
中的INTENDED_VSYNC
时间戳。
- 在
-
获取帧完成时间
doFrame()
回调最终在主线程消息队列里执行。- 可以通过
Looper.setMessageLogging()
拦截日志,拿到消息结束时间。
-
计算单帧耗时
- 单帧耗时 =
doFrame 完成时间 - VSync 开始时间
。 - 如果耗时 > 16.6ms(60Hz 下),就说明错过了 VSync。
- 单帧耗时 =
-
统计掉帧数
- 掉帧数 =
单帧耗时 / VSync 周期
(向下取整)。 - 例如:一帧耗时 40ms,周期 16.6ms,则掉帧数 = 40 / 16.6 ≈ 2。
- 掉帧数 =
-
计算 FPS
- 公式: FPS = (有效帧数 / (有效帧数 + 掉帧数)) * 刷新率
核心代码示例(简化版)
kotlin
// RealFpsTracer.kt ------ Android 新方案:逐帧耗时 + 掉帧换算
@file:Suppress("PrivateApi", "DiscouragedPrivateApi")
package your.pkg
import android.content.Context
import android.os.Build
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.Choreographer
import android.view.Display
import android.view.WindowManager
import android.util.Printer
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.floor
class RealFpsTracer(
context: Context,
private val windowMs: Long = 10_000L, // 汇报窗口
private val onReport: (FpsReport) -> Unit // 窗口结束回调
) {
data class FpsReport(
val fps: Float,
val frames: Int,
val dropped: Int,
val refreshRate: Float,
val vsyncPeriodMs: Float,
val costsMs: List<Long> // 每帧耗时分布(毫秒)
)
private val tag = "RealFpsTracer"
private val choreographer = Choreographer.getInstance()
private val mainLooper = Looper.getMainLooper()
private val costs = CopyOnWriteArrayList<Long>()
private var sumDropped = 0
private var isRunning = false
private var startUptime = 0L
// 刷新率 & vsync 周期
private val refreshRate: Float
private val vsyncPeriodMs: Float
// 反射:FrameInfo.mFrameInfo[INTENDED_VSYNC = 1]
private var frameInfoArrayField: Field? = null
private var frameInfoObjField: Field? = null
private var frameInfoArray: LongArray? = null
// 当前帧的 "VSync 开始时间(ms)"
@Volatile private var currentVsyncBeginMs: Long = 0L
// Looper 打印机
private var oldPrinter: Printer? = null
private val printer: Printer = Printer { line -> handleLooperLog(line) }
// 标记:是否正在分发 FrameDisplayEventReceiver(即 doFrame 调度所在 message)
@Volatile private var inFrameDispatch = false
init {
// 读取刷新率
val rr = runCatching {
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
if (Build.VERSION.SDK_INT >= 30) {
// Android 11+ 支持多模式,选用当前 display 的 refreshRate
context.display?.refreshRate ?: wm.defaultDisplay.refreshRate
} else {
@Suppress("DEPRECATION")
wm.defaultDisplay.refreshRate
}
}.getOrElse { 60f }
refreshRate = if (rr > 0f) rr else 60f
vsyncPeriodMs = 1000f / refreshRate
// 预备反射 FrameInfo
prepareFrameInfoReflection()
}
// ------------------- Public API -------------------
fun start() {
if (isRunning) return
isRunning = true
startUptime = SystemClock.uptimeMillis()
costs.clear()
sumDropped = 0
// 安装 Looper 打印机(识别 FrameDisplayEventReceiver 起止)
oldPrinter = mainLooper.messageLogging
mainLooper.messageLogging = printer
// 注册帧回调:用于拿到 VSync 开始时间
choreographer.postFrameCallback(frameCallback)
// 到期停止并汇报
choreographer.postFrameCallbackDelayed(stopCallback, windowMs)
}
fun stop() {
if (!isRunning) return
isRunning = false
// 卸载打印机
if (mainLooper.messageLogging === printer) {
mainLooper.messageLogging = oldPrinter
}
// 移除帧回调
choreographer.removeFrameCallback(frameCallback)
choreographer.removeFrameCallback(stopCallback)
reportNow()
}
// ------------------- Core -------------------
private val frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
// 1) 获取 VSync 开始时间(优先 FrameInfo.INTENDED_VSYNC;失败则退化为 frameTimeNanos)
currentVsyncBeginMs = fetchIntendedVsyncMs(frameTimeNanos)
// 2) 继续监听下一帧
if (isRunning) {
choreographer.postFrameCallback(frameCallback)
}
}
private val stopCallback = Choreographer.FrameCallback {
stop() // 调用 stop() 会负责 report
}
private fun handleLooperLog(line: String) {
// AOSP 打印格式:
// ">>>>> Dispatching to ... com.android.internal.view.Choreographer$FrameDisplayEventReceiver ..."
// "<<<<< Finished to ... com.android.internal.view.Choreographer$FrameDisplayEventReceiver ..."
val isDispatchStart = line.startsWith(">>>>> Dispatching to")
val isDispatchEnd = line.startsWith("<<<<< Finished to")
val isFrameReceiver = line.contains("Choreographer\$FrameDisplayEventReceiver")
if (isDispatchStart && isFrameReceiver) {
inFrameDispatch = true
return
}
if (isDispatchEnd && isFrameReceiver && inFrameDispatch) {
// doFrame 对应消息执行完毕(即该帧的主线程任务完成)
inFrameDispatch = false
if (!isRunning) return
val end = SystemClock.uptimeMillis()
val begin = currentVsyncBeginMs.takeIf { it > 0 } ?: (end - vsyncPeriodMs.toLong())
val cost = (end - begin).coerceAtLeast(0L)
val dropped = floor(cost.toDouble() / vsyncPeriodMs).toInt()
costs += cost
sumDropped += dropped
}
}
private fun reportNow() {
val frames = costs.size
val fps = if (frames == 0) 0f
else frames.toFloat() / (frames + sumDropped).toFloat() * refreshRate
val report = FpsReport(
fps = fps,
frames = frames,
dropped = sumDropped,
refreshRate = refreshRate,
vsyncPeriodMs = vsyncPeriodMs,
costsMs = costs.toList()
)
runCatching { onReport(report) }.onFailure {
Log.w(tag, "report callback error: ${it.message}")
}
}
// ------------------- FrameInfo Reflection -------------------
private fun prepareFrameInfoReflection() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) return
runCatching {
val choreoCls = Class.forName("android.view.Choreographer")
frameInfoObjField = choreoCls.getDeclaredField("mFrameInfo").apply { isAccessible = true }
val frameInfoObj = frameInfoObjField!!.get(choreographer)
val frameInfoCls = Class.forName("android.view.FrameInfo")
frameInfoArrayField = frameInfoCls.getDeclaredField("mFrameInfo").apply { isAccessible = true }
@Suppress("UNCHECKED_CAST")
frameInfoArray = frameInfoArrayField!!.get(frameInfoObj) as LongArray
}.onFailure {
// 反射失败不致命,退化使用 frameTimeNanos
frameInfoArray = null
}
}
/** 优先取 INTENDED_VSYNC(index=1);失败则退化为 frameTimeNanos */
private fun fetchIntendedVsyncMs(frameTimeNanos: Long): Long {
frameInfoArray?.let { arr ->
if (arr.size > 1) {
val ns = arr[1] // INTENDED_VSYNC = 1
if (ns > 0) return ns / 1_000_000L
}
}
return frameTimeNanos / 1_000_000L
}
}
iOS 的 FPS 统计(对齐 Android 新方案)
思路(与 Android 新方案一致)
-
用
CADisplayLink
拿到每帧回调时间戳timestamp
。 -
计算相邻两次回调的时间差
Δt
(秒)。 -
取设备目标刷新率
refreshRate
(60/120 等),得到单个 VSync 周期vsync = 1 / refreshRate
(秒)。 -
若
Δt
跨过了N
个周期,则认为 掉了 (N-1) 帧: -
单帧掉帧数计算公式
textdropped = max(0, floor(Δt / vsync) - 1)
其中:
Δt
= 相邻两帧的时间差(秒)vsync
= 单个刷新周期(1 / refreshRate,秒)
统计窗口(例如 10s)内的 FPS 计算公式:
text
有效帧数 = 回调次数
FPS = (有效帧数 / (有效帧数 + 掉帧数)) * refreshRate
这样口径与 Android 新方案一致,便于跨端对比。
代码(拷贝即用)
swift
import UIKit
struct IOSFpsReport {
let fps: Float // 口径对齐:有效帧/(有效帧+掉帧)*刷新率
let frames: Int // 窗口内有效帧(回调次数)
let dropped: Int // 窗口内推算的掉帧数
let refreshRate: Int // 目标刷新率(Hz)
let costsMs: [Double] // 每帧 Δt(毫秒)
}
final class IOSRealFpsTracer {
typealias Report = (IOSFpsReport) -> Void
private var link: CADisplayLink?
private var lastTs: CFTimeInterval = 0
private var frames = 0
private var dropped = 0
private var costs: [Double] = []
private let window: CFTimeInterval
private let onReport: Report
// 目标刷新率(与 Android 的 refreshRate 含义一致)
private let refreshRate: Int
private var vsyncSec: Double { 1.0 / Double(refreshRate) }
private var windowStart: CFTimeInterval = 0
init(windowSeconds: CFTimeInterval = 10,
refreshRate: Int = UIScreen.main.maximumFramesPerSecond,
onReport: @escaping Report) {
self.window = windowSeconds
self.refreshRate = max(30, refreshRate) // 兜底
self.onReport = onReport
}
func start() {
guard link == nil else { return }
resetWindow()
let l = CADisplayLink(target: self, selector: #selector(tick))
if #available(iOS 15.0, *) {
l.preferredFrameRateRange = .init(minimum: 30,
maximum: Float(refreshRate),
preferred: Float(refreshRate))
} else {
l.preferredFramesPerSecond = refreshRate
}
l.add(to: .main, forMode: .common) // .common 防止滚动时被暂停
link = l
}
func stop() {
link?.invalidate()
link = nil
reportNow() // 结束时补一次
}
private func resetWindow() {
frames = 0; dropped = 0; costs.removeAll(keepingCapacity: true)
lastTs = 0; windowStart = CACurrentMediaTime()
}
@objc private func tick(_ link: CADisplayLink) {
frames += 1
let ts = link.timestamp
if lastTs == 0 { lastTs = ts; return }
let dt = ts - lastTs
lastTs = ts
// 记录每帧 Δt(毫秒)
let dtMs = dt * 1000.0
costs.append(dtMs)
// 计算跨过多少个 vsync 周期,推算掉帧数
let cycles = floor(dt / vsyncSec)
if cycles >= 2 { // 跨 2 个周期以上才算掉帧
dropped += Int(cycles) - 1
}
// 窗口到期出报表
if (ts - windowStart) >= window {
reportNow()
resetWindow()
}
}
private func reportNow() {
guard frames > 0 else { return }
// 对齐口径:FPS = (有效帧 / (有效帧 + 掉帧)) * 刷新率
let fps = Float(Double(frames) / Double(frames + dropped) * Double(refreshRate))
let report = IOSFpsReport(fps: fps,
frames: frames,
dropped: dropped,
refreshRate: refreshRate,
costsMs: costs)
onReport(report)
}
}
总结
FPS(帧率)是衡量应用流畅度的直观指标,它背后关联的是 CPU、GPU、渲染管线、缓冲机制、系统调度 的协作效率。
- 刷新率(Hz) 是硬件的上限;
- FPS 则体现软件能否跟上硬件的节奏。
在实现层面,Android 的 Choreographer
与 iOS 的 CADisplayLink
本质相同,都是 VSync 驱动的帧调度器。我们可以通过它们:
- 在每一帧回调里统计相邻帧时间差;
- 推算掉帧数;
- 最终得到统一口径的 FPS:
text
FPS = (有效帧数 / (有效帧数 + 掉帧数)) * refreshRate
📌 这意味着:
- 双端口径统一 ------ 无论 Android 还是 iOS,都能用同样的方法来统计 FPS。
- 指标更准确 ------ 不再只是粗略的平均值,而是结合掉帧情况,更贴近用户实际体验。
- 优化有抓手 ------ 掉帧数、耗时分布能帮助开发者找到卡顿原因,指导性能优化。
随着 120Hz 高刷屏 逐渐普及,帧间预算时间从 16.6ms 压缩到 8.3ms ,性能优化的门槛更高。
这也是为什么 FPS 统计和跨端统一如此重要:只有先准确地"量"出来,才能有针对性地"改"进去。
👉 一句话总结 :
FPS 不是一个孤立的数字,而是一面镜子,映照出应用的流畅度和系统的调度效率。先准确度量,再精细优化,才能真正带来用户侧的丝滑体验。