在监控画面是否卡顿时,直接使用主线程 RunLoop 的运行耗时来判断并不直观。业界通常使用屏幕刷新率(FPS)来衡量流畅度,下面就来讨论基于 CADisplayLink 的帧率监控方案。
一、CADisplayLink 原理
CADisplayLink 是一个与屏幕刷新率同步的定时器,本质上它是一个特殊的 RunLoop 事件源(CFRunLoopSource),使用时需要添加到 RunLoop 中:
objective-c
CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(update:)];
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
实现原理:
CADisplayLink 内部封装了一个 CFRunLoopSource,注册到 RunLoop 后,会等待 VSync 信号的到来。每次垂直同步信号产生时,系统会把这个 Source 标记为待处理,RunLoop 被唤醒后分发回调。
与 NSTimer 相比,CADisplayLink 更精准:
- Timer 基于时间触发,依赖 RunLoop 分发。如果 RunLoop 正在执行耗时任务,定时器可能被延迟甚至跳过,无法保证与屏幕刷新同步。
- CADisplayLink 由硬件 VSync 驱动,能保证每帧都被调用(只要回调执行时间不超过一帧)。此外,它还支持
preferredFramesPerSecond属性,可以指定期望的帧率,内部通过有规律地跳帧来实现。
当然,如果回调本身的执行时间超过一帧的时长,下一次 VSync 到来时上一次回调可能尚未结束,就会导致实际丢帧。
scss
┌─────────────────────────────────────────────────────────────┐
│ 硬件层 │
│ Display Controller → 产生 VSync 中断信号 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 内核层 (iOS) │
│ IOKit.framework → 接收 VSync 中断 → 转换为 Mach 消息 │
│ CoreAnimation Server 进程接收并分发 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 用户层 (App) │
│ CADisplayLink → 注册到 RunLoop → 接收回调 → 执行方法 │
└─────────────────────────────────────────────────────────────┘
CADisplayLink 与 Core Animation 共享同一个 VSync 源,因此它的回调时机和屏幕刷新严格对齐。
二、监控帧率、掉帧与冻帧
CADisplayLink 提供了两个关键属性:
duration:每帧的理论时间间隔(例如 60 Hz 下约为 0.0167 秒)timestamp:当前回调对应的时间戳
利用这些属性就可以实现帧率、掉帧数和冻帧检测。
1. 帧率(FPS)
每次回调时让计数器 fpsCount++,同时记录第一帧的 timestamp。当累积时长 delta = 当前timestamp - 第一帧timestamp ≥ 1秒 时,计算:
ini
FPS = fpsCount / delta
然后重置计数器,并将"第一帧时间戳"更新为当前帧,继续下一轮统计。这样得到的是一段时间内的平均帧率,更平滑。
2. 掉帧计算
通过计算相邻两次回调的实际时间间隔,可以知道中间丢了多少帧:
ini
实际间隔 duration = 当前帧timestamp - 上一帧timestamp
掉帧数 = (duration - displayLink.duration) / displayLink.duration
为避免单次微小的抖动产生噪声,通常只关注连续掉帧的情况,并可以按严重程度分级统计:
- drop3:单次掉帧 ≥ 3 帧(轻度卡顿)
- drop7:单次掉帧 ≥ 7 帧(严重卡顿)
3. 冻帧(Freeze)检测
当两帧之间的时间差 ≥ 700 ms (kFreezeFrameLimitValue)时,判定为主线程"冻死",画面完全无响应。
此时需要:
- 采集当前主线程的调用栈,用于定位卡死位置。
- 避免重复采集:一次长时间的冻帧可能连续触发多次判定条件,因此一旦抓到调用栈,就暂停冻帧检测(例如标记
isFreezeCaptured = YES),直到帧率恢复后再重新开启。
一个简化版冻帧检测逻辑示例:
objective-c
- (void)update:(CADisplayLink *)link {
if (_lastTimestamp == 0) {
_lastTimestamp = link.timestamp;
return;
}
CFTimeInterval interval = link.timestamp - _lastTimestamp;
_lastTimestamp = link.timestamp;
// 掉帧数
NSInteger dropped = round((interval - link.duration) / link.duration);
if (dropped >= 7) {
// 记录严重掉帧
}
// 冻帧
if (interval >= 0.7 && !_isFreezeCaptured) {
_isFreezeCaptured = YES;
// 采集主线程堆栈,上报冻帧事件
[self captureMainThreadStack];
}
// FPS 统计
_fpsCount++;
CFTimeInterval elapsed = link.timestamp - _firstTimestamp;
if (elapsed >= 1.0) {
CGFloat fps = _fpsCount / elapsed;
_fpsCount = 0;
_firstTimestamp = link.timestamp;
// 上报 FPS
// 如果之前因为冻帧暂停了,此处可以恢复
_isFreezeCaptured = NO;
}
}