双端 FPS 全景解析:Android 与 iOS 的渲染机制、监控与优化
双端 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)。
3.2 双缓存(Double Buffering)
为避免撕裂,引入两个 Buffer:
- Front Buffer:屏幕正在显示。
- Back Buffer:GPU 正在绘制。
- 在 VSync 信号 到来时,交换 Front/Back Buffer 的指针。
📌 优点
- 避免撕裂,屏幕总是显示完整帧。
📌 问题
- 如果 GPU 没能在 16.6ms 内画完一帧,就会错过 VSync 信号,导致 掉帧(Jank)。
- 在双缓冲机制下,GPU 渲染完一帧画面后,如果屏幕还没刷新到下一次,它就只能停下来等。这段时间 GPU 什么都干不了,相当于“坐在那儿发呆”。这就是所谓的“闲置”,会让 GPU 的工作效率变低。换句话说,并不是 GPU 算力不够,而是它画得太快,结果被屏幕刷新节奏卡住了。
3.3 三缓存(Triple Buffering)
为缓解 GPU “等待”的问题,再引入一个 Buffer:
- GPU 绘制时,如果 Back Buffer 已满,可以先写入第三个 Buffer。
- 这样 GPU 几乎不会阻塞,提高吞吐量。
📌 优点
- 避免 GPU 空转,提高整体流畅度。
三缓冲的两个问题
1. 延迟更高
- 因为多了一个缓冲区,用户的操作要经过更多“排队”,画面响应会比双缓冲慢大约 1 帧(8–16ms)。
- 这会让操作灵敏度下降,尤其是在需要快速反应的游戏里更容易被感知。
2. 显存占用更大
- 每多一个缓冲区就要额外存一整帧画面,意味着更多的显存开销。
- 在高分辨率或显存有限的设备上,这会增加内存压力。
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 或监控掉帧。
Choreographer.getInstance().postFrameCallback { frameTimeNanos ->// 在 VSync 驱动下被调用// 可用于 FPS 监控
}
5.2 iOS - CADisplayLink
在 iOS 中,CADisplayLink 提供了类似的能力:
- 本质是一个 基于 VSync 的计时器。
- 每次屏幕准备刷新时,CADisplayLink 会回调一次。
- 开发者可以在回调中执行动画步进或 FPS 统计。
典型用法
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。
核心方案
// 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() {@Overridepublic 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 + ")");}}
}
使用示例
// 启动阶段 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) / 1000Log.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 里到底掉了多少帧、掉在何时”。
时序流程(旧方案)
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 = (有效帧数 / (有效帧数 + 掉帧数)) * 刷新率
核心代码示例(简化版)
// RealFpsTracer.kt —— Android 新方案:逐帧耗时 + 掉帧换算
@file:Suppress("PrivateApi", "DiscouragedPrivateApi")package your.pkgimport 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.floorclass 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 = 0private var isRunning = falseprivate var startUptime = 0L// 刷新率 & vsync 周期private val refreshRate: Floatprivate val vsyncPeriodMs: Float// 反射:FrameInfo.mFrameInfo[INTENDED_VSYNC = 1]private var frameInfoArrayField: Field? = nullprivate var frameInfoObjField: Field? = nullprivate var frameInfoArray: LongArray? = null// 当前帧的 “VSync 开始时间(ms)”@Volatile private var currentVsyncBeginMs: Long = 0L// Looper 打印机private var oldPrinter: Printer? = nullprivate val printer: Printer = Printer { line -> handleLooperLog(line) }// 标记:是否正在分发 FrameDisplayEventReceiver(即 doFrame 调度所在 message)@Volatile private var inFrameDispatch = falseinit {// 读取刷新率val rr = runCatching {val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManagerif (Build.VERSION.SDK_INT >= 30) {// Android 11+ 支持多模式,选用当前 display 的 refreshRatecontext.display?.refreshRate ?: wm.defaultDisplay.refreshRate} else {@Suppress("DEPRECATION")wm.defaultDisplay.refreshRate}}.getOrElse { 60f }refreshRate = if (rr > 0f) rr else 60fvsyncPeriodMs = 1000f / refreshRate// 预备反射 FrameInfoprepareFrameInfoReflection()}// ------------------- Public API -------------------fun start() {if (isRunning) returnisRunning = truestartUptime = SystemClock.uptimeMillis()costs.clear()sumDropped = 0// 安装 Looper 打印机(识别 FrameDisplayEventReceiver 起止)oldPrinter = mainLooper.messageLoggingmainLooper.messageLogging = printer// 注册帧回调:用于拿到 VSync 开始时间choreographer.postFrameCallback(frameCallback)// 到期停止并汇报choreographer.postFrameCallbackDelayed(stopCallback, windowMs)}fun stop() {if (!isRunning) returnisRunning = 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 = truereturn}if (isDispatchEnd && isFrameReceiver && inFrameDispatch) {// doFrame 对应消息执行完毕(即该帧的主线程任务完成)inFrameDispatch = falseif (!isRunning) returnval 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 += costsumDropped += dropped}}private fun reportNow() {val frames = costs.sizeval fps = if (frames == 0) 0felse frames.toFloat() / (frames + sumDropped).toFloat() * refreshRateval 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) returnrunCatching {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 {// 反射失败不致命,退化使用 frameTimeNanosframeInfoArray = 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 = 1if (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) 帧: - 单帧掉帧数计算公式
dropped = max(0, floor(Δt / vsync) - 1)
其中:
Δt
= 相邻两帧的时间差(秒)vsync
= 单个刷新周期(1 / refreshRate,秒)
统计窗口(例如 10s)内的 FPS 计算公式:
有效帧数 = 回调次数
FPS = (有效帧数 / (有效帧数 + 掉帧数)) * refreshRate
这样口径与 Android 新方案一致,便于跨端对比。
代码(拷贝即用)
import UIKitstruct IOSFpsReport {let fps: Float // 口径对齐:有效帧/(有效帧+掉帧)*刷新率let frames: Int // 窗口内有效帧(回调次数)let dropped: Int // 窗口内推算的掉帧数let refreshRate: Int // 目标刷新率(Hz)let costsMs: [Double] // 每帧 Δt(毫秒)
}final class IOSRealFpsTracer {typealias Report = (IOSFpsReport) -> Voidprivate var link: CADisplayLink?private var lastTs: CFTimeInterval = 0private var frames = 0private var dropped = 0private var costs: [Double] = []private let window: CFTimeIntervalprivate let onReport: Report// 目标刷新率(与 Android 的 refreshRate 含义一致)private let refreshRate: Intprivate var vsyncSec: Double { 1.0 / Double(refreshRate) }private var windowStart: CFTimeInterval = 0init(windowSeconds: CFTimeInterval = 10,refreshRate: Int = UIScreen.main.maximumFramesPerSecond,onReport: @escaping Report) {self.window = windowSecondsself.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 = nilreportNow() // 结束时补一次}private func resetWindow() {frames = 0; dropped = 0; costs.removeAll(keepingCapacity: true)lastTs = 0; windowStart = CACurrentMediaTime()}@objc private func tick(_ link: CADisplayLink) {frames += 1let ts = link.timestampif lastTs == 0 { lastTs = ts; return }let dt = ts - lastTslastTs = ts// 记录每帧 Δt(毫秒)let dtMs = dt * 1000.0costs.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:
FPS = (有效帧数 / (有效帧数 + 掉帧数)) * refreshRate
📌 这意味着:
- 双端口径统一 —— 无论 Android 还是 iOS,都能用同样的方法来统计 FPS。
- 指标更准确 —— 不再只是粗略的平均值,而是结合掉帧情况,更贴近用户实际体验。
- 优化有抓手 —— 掉帧数、耗时分布能帮助开发者找到卡顿原因,指导性能优化。
随着 120Hz 高刷屏 逐渐普及,帧间预算时间从 16.6ms 压缩到 8.3ms,性能优化的门槛更高。
这也是为什么 FPS 统计和跨端统一如此重要:只有先准确地“量”出来,才能有针对性地“改”进去。
👉 一句话总结:
FPS 不是一个孤立的数字,而是一面镜子,映照出应用的流畅度和系统的调度效率。先准确度量,再精细优化,才能真正带来用户侧的丝滑体验。