准确检测卡顿是优化的前提。本文介绍多种卡顿检测方案,从开发调试到线上监控都有对应的方案。


检测方案概览

方案原理优点缺点适用场景
CADisplayLink监控帧节奏、FPS、慢帧/掉帧简单直接、适合趋势观察无法直接给出堆栈,FPS 只能辅助判断开发调试/线上辅助指标
RunLoop Observer监控RunLoop状态可获取堆栈有一定开销开发/线上
子线程Ping定时检测主线程响应实现简单精度有限线上监控
Instruments系统级分析信息详细只能开发时用性能分析
MetricKit系统数据收集无额外开销iOS 13+线上监控
Sentry ANR V2帧延迟分析区分阻塞类型需集成SDK线上监控

1. CADisplayLink监控

基本原理

CADisplayLink 是一个和屏幕显示刷新节奏同步的回调。它适合回答“当前 UI 刷新是否顺畅、是否出现慢帧、掉帧或冻结帧”,但 FPS 本身只是辅助参考,不能单独作为卡顿结论。

原因是:

  1. FPS 只描述结果,不描述原因:FPS 下降只能说明某段时间显示帧变少了,不能告诉你是主线程计算、布局、图片解码、锁等待、I/O、GPU 渲染还是系统主动降刷新率导致的。
  2. ProMotion 会动态调整刷新率:在支持 120Hz 的机型上,系统可能根据内容和功耗策略把刷新率降到 10Hz、24Hz、30Hz、60Hz、80Hz、120Hz 等。静止页面低 FPS 可能只是系统主动省电,不是卡顿。
  3. 主线程阻塞会让回调延迟:如果主线程真的被阻塞,CADisplayLink 回调本身也会延后,此时需要结合实际回调间隔、RunLoop 状态和堆栈采样判断。

因此线上卡顿监控更推荐记录 帧间隔 / 慢帧 / 冻结帧 / 交互场景,而不是只显示一个“当前 FPS”。

几个概念要分清:

含义典型用途
UIScreen.main.maximumFramesPerSecond屏幕最大刷新能力,例如 60 或 120判断设备上限,不能代表当前实际刷新率
link.targetTimestamp - link.timestamp系统当前调度的目标帧间隔估算当前调度帧率
link.timestamp - lastFrameTimestamp两次 DisplayLink 回调关联显示时间的实际间隔判断这一帧是否延迟
Date/CACurrentMediaTime 的回调间隔回调函数真实到达 App 的时间间隔判断主线程是否被阻塞导致回调延后

在 ProMotion 机型上,UIScreen.main.maximumFramesPerSecond 通常仍返回最大能力 120。即使当前系统把刷新节奏降到 10Hz,它也不会变成 10。当前调度帧率应通过 targetTimestamp - timestamp 估算:

let scheduledInterval = link.targetTimestamp - link.timestamp
let scheduledFPS = 1.0 / scheduledInterval

如果系统当前按 10Hz 调度,则:

scheduledInterval ≈ 0.1s
scheduledFPS ≈ 10
UIScreen.main.maximumFramesPerSecond 仍可能是 120

一个更适合监控的实现,不是只按 1 秒聚合 FPS,而是同时记录当前调度帧率、实际帧间隔、慢帧和冻结帧:

import UIKit
import QuartzCore

final class FrameMonitor {
    
    static let shared = FrameMonitor()
    
    private var displayLink: CADisplayLink?
    private var lastFrameTimestamp: CFTimeInterval = 0
    private var windowStartTimestamp: CFTimeInterval = 0
    private var frameCount = 0
    private var slowFrameCount = 0
    private var frozenFrameCount = 0
    
    var onUpdate: ((_ scheduledFPS: Double,
                    _ windowFPS: Double,
                    _ slowFrames: Int,
                    _ frozenFrames: Int) -> Void)?
    
    private init() {}
    
    func start() {
        displayLink = CADisplayLink(target: self, selector: #selector(tick))

        if #available(iOS 15.0, *) {
            let maxFPS = Float(UIScreen.main.maximumFramesPerSecond)
            displayLink?.preferredFrameRateRange = CAFrameRateRange(
                minimum: 30,
                maximum: maxFPS,
                preferred: maxFPS
            )
        } else {
            displayLink?.preferredFramesPerSecond = UIScreen.main.maximumFramesPerSecond
        }

        displayLink?.add(to: .main, forMode: .common)
    }
    
    func stop() {
        displayLink?.invalidate()
        displayLink = nil
    }
    
    @objc private func tick(_ link: CADisplayLink) {
        if lastFrameTimestamp == 0 {
            lastFrameTimestamp = link.timestamp
            windowStartTimestamp = link.timestamp
            return
        }

        // 系统当前给 CADisplayLink 的目标调度间隔。
        // ProMotion 下它可能是 1/120、1/80、1/60、1/30、1/10 等,
        // 比 UIScreen.maximumFramesPerSecond 更接近当前实际调度节奏。
        let scheduledInterval = link.targetTimestamp - link.timestamp
        let scheduledFPS = scheduledInterval > 0 ? 1.0 / scheduledInterval : 60

        // 两次显示时间戳之间的实际间隔,用来判断这一帧是否超出当前帧预算。
        let frameDuration = link.timestamp - lastFrameTimestamp

        // 当前调度频率下的理论单帧预算。120Hz 约 8.33ms,60Hz 约 16.67ms。
        let expectedFrameDuration = 1.0 / scheduledFPS

        // 通常给一点容忍,避免时间戳抖动造成误报。
        if frameDuration > 0.7 {
            frozenFrameCount += 1
        } else if frameDuration > expectedFrameDuration * 1.5 {
            slowFrameCount += 1
        }

        frameCount += 1

        let windowElapsed = link.timestamp - windowStartTimestamp
        if windowElapsed >= 1.0 {
            // 当前统计窗口内真实收到的帧回调频率。它适合看趋势,
            // 但线上卡顿判定更应该看 slowFrameCount / frozenFrameCount。
            let windowFPS = Double(frameCount) / windowElapsed
            onUpdate?(scheduledFPS, windowFPS, slowFrameCount, frozenFrameCount)
            windowStartTimestamp = link.timestamp
            frameCount = 0
            slowFrameCount = 0
            frozenFrameCount = 0
        }

        lastFrameTimestamp = link.timestamp
    }
}

preferredFrameRateRange 只是告诉系统“我希望的刷新范围和偏好”,不是强制命令。低电量、热状态、系统策略、当前内容是否静止、主线程负载等因素都可能让实际回调频率低于 preferred

FPS显示控件

开发期可以做一个 FPSLabel,但它只适合观察趋势,不能用“低于 55 就一定卡顿”这种固定阈值。尤其在 ProMotion 机型上,静止页面可能被系统降到 10Hz 或 30Hz,此时显示 10 FPS / 30 FPS 并不代表 App 性能差。

class FPSLabel: UILabel {
    
    private var displayLink: CADisplayLink?
    private var lastTimestamp: CFTimeInterval = 0
    private var frameCount: Int = 0
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    private func setup() {
        layer.cornerRadius = 5
        clipsToBounds = true
        backgroundColor = UIColor.black.withAlphaComponent(0.7)
        textColor = .green
        font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
        textAlignment = .center
        
        displayLink = CADisplayLink(target: self, selector: #selector(tick))
        displayLink?.add(to: .main, forMode: .common)
    }
    
    @objc private func tick(_ link: CADisplayLink) {
        guard lastTimestamp != 0 else {
            lastTimestamp = link.timestamp
            return
        }
        
        frameCount += 1
        let elapsed = link.timestamp - lastTimestamp
        
        if elapsed >= 1.0 {
            let fps = Double(frameCount) / elapsed
            frameCount = 0
            lastTimestamp = link.timestamp
            
            updateDisplay(fps: fps)
        }
    }
    
    private func updateDisplay(fps: Double) {
        let fpsText = String(format: "%.0f FPS", fps)
        text = fpsText
        
        // 只作为开发期趋势提示。线上卡顿判定不能只依赖这个颜色。
        if fps >= 55 {
            textColor = .green
        } else if fps >= 45 {
            textColor = .yellow
        } else {
            textColor = .red
        }
    }
    
    deinit {
        displayLink?.invalidate()
    }
}

使用示例

// 在AppDelegate中添加FPS显示
func application(_ application: UIApplication, 
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    #if DEBUG
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        let fpsLabel = FPSLabel(frame: CGRect(x: 20, y: 50, width: 60, height: 24))
        if #available(iOS 13.0, *) {
            if let windowScene = UIApplication.shared.connectedScenes
                .compactMap({ $0 as? UIWindowScene })
                .first,
               let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
                window.addSubview(fpsLabel)
            }
        } else if let window = UIApplication.shared.keyWindow {
            window.addSubview(fpsLabel)
        }
    }
    #endif
    
    return true
}

CADisplayLink的局限性

CADisplayLink方案的局限:

  1. FPS 只是辅助指标:FPS 低不一定是卡顿,可能是 ProMotion 动态降刷新率、低电量、热状态或静止页面省电策略。
  2. 无法获取堆栈:只能知道帧延迟或掉帧了,不知道是哪段代码导致的。
  3. 回调被阻塞:如果主线程阻塞,CADisplayLink 回调本身也会被延迟。
  4. 场景敏感:滚动、动画、手势交互时的低 FPS 更有价值;静止页面低 FPS 诊断价值很低。
  5. 需要动态阈值:60Hz 下单帧预算约 16.67ms,120Hz 下约 8.33ms,不能写死 16.67ms 或 60 FPS。

更合理的使用方式:

场景判断方式
开发调试显示 FPS 趋势,辅助发现明显抖动
线上监控统计慢帧、冻结帧、滚动掉帧、帧延迟总量
根因定位结合 RunLoop Observer、子线程 Ping、周期性堆栈采样、MetricKit
ProMotion 设备使用 targetTimestamp - timestamp 计算当前调度帧率,按动态帧预算判断慢帧

2. RunLoop监控方案

基本原理

通过监控RunLoop的状态变化,检测主线程是否长时间处于某个状态:

flowchart TD
    A[kCFRunLoopEntry] --> B[kCFRunLoopBeforeTimers]
    B --> C[kCFRunLoopBeforeSources]
    C --> D[处理Source0]
    D --> E[kCFRunLoopBeforeWaiting]
    E --> F[休眠等待]
    F -->|被唤醒| G[kCFRunLoopAfterWaiting]
    G --> H[处理被唤醒的事件]
    H -->|继续循环| C
    H --> I[kCFRunLoopExit]
    
    style C fill:#c62828,color:#ffffff
    style G fill:#c62828,color:#ffffff

卡顿检测关键点:BeforeSources → AfterWaiting 之间耗时过长 = 主线程阻塞

完整实现

import Foundation

class RunLoopMonitor {
    
    static let shared = RunLoopMonitor()
    
    /// 卡顿阈值(毫秒)
    var threshold: TimeInterval = 100
    
    /// 卡顿回调
    var onJankDetected: ((_ duration: TimeInterval, _ callStack: String) -> Void)?
    
    private var observer: CFRunLoopObserver?
    private var semaphore: DispatchSemaphore?
    private var activity: CFRunLoopActivity = .entry
    private var isMonitoring = false
    
    private init() {}
    
    func start() {
        guard !isMonitoring else { return }
        isMonitoring = true
        
        // 创建信号量
        semaphore = DispatchSemaphore(value: 0)
        
        // 创建RunLoop观察者
        let callback: CFRunLoopObserverCallBack = { _, activity, info in
            guard let info = info else { return }
            let monitor = Unmanaged<RunLoopMonitor>.fromOpaque(info).takeUnretainedValue()
            monitor.activity = activity
            monitor.semaphore?.signal()
        }
        
        var observerContext = CFRunLoopObserverContext(
            version: 0,
            info: Unmanaged.passUnretained(self).toOpaque(),
            retain: nil,
            release: nil,
            copyDescription: nil
        )

        observer = CFRunLoopObserverCreate(
            kCFAllocatorDefault,
            CFRunLoopActivity.allActivities.rawValue,
            true,  // 重复
            0,     // 优先级
            callback,
            &observerContext
        )
        
        if let observer = observer {
            CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
        }
        
        // 在子线程中监控
        startMonitorThread()
    }
    
    func stop() {
        guard isMonitoring else { return }
        isMonitoring = false
        
        if let observer = observer {
            CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, .commonModes)
        }
        observer = nil
        semaphore = nil
    }
    
    private func startMonitorThread() {
        DispatchQueue.global(qos: .userInteractive).async { [weak self] in
            guard let self = self else { return }
            
            while self.isMonitoring {
                guard let semaphore = self.semaphore else { break }
                
                // 等待信号量,超时时间为阈值
                let result = semaphore.wait(timeout: .now() + self.threshold / 1000.0)
                
                if result == .timedOut {
                    // 检查是否在关键状态
                    let activity = self.activity
                    if activity == .beforeSources || activity == .afterWaiting {
                        // 检测到卡顿,获取堆栈
                        let callStack = self.captureMainThreadCallStack()
                        let duration = self.threshold
                        
                        DispatchQueue.main.async {
                            self.onJankDetected?(duration, callStack)
                        }
                    }
                }
            }
        }
    }
    
    private func captureMainThreadCallStack() -> String {
        // 不能直接使用 Thread.callStackSymbols(这里只会拿到当前监控线程)
        // 生产环境应使用信号机制采集主线程堆栈
        return captureMainThreadCallStackAccurate()
    }
}

更精确的堆栈采集

上面的实现获取的是子线程的堆栈,要获取主线程堆栈需要使用信号机制:

// JankStackCapture.m
#import <Foundation/Foundation.h>
#import <pthread.h>
#import <signal.h>
#import <execinfo.h>

static pthread_t g_mainThread;
static void *g_callStack[128];
static int g_callStackCount = 0;
static dispatch_semaphore_t g_semaphore;

// 信号处理函数
static void SignalHandler(int signal) {
    // 在信号处理函数中获取当前线程(主线程)的堆栈
    g_callStackCount = backtrace(g_callStack, 128);
    dispatch_semaphore_signal(g_semaphore);
}

@interface JankStackCapture : NSObject
+ (NSArray<NSString *> *)captureMainThreadStack;
@end

@implementation JankStackCapture

+ (void)initialize {
    g_mainThread = pthread_self();
    g_semaphore = dispatch_semaphore_create(0);
    
    // 注册信号处理
    signal(SIGURG, SignalHandler);
}

+ (NSArray<NSString *> *)captureMainThreadStack {
    // 向主线程发送信号
    pthread_kill(g_mainThread, SIGURG);
    
    // 等待信号处理完成
    dispatch_semaphore_wait(g_semaphore, dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC));
    
    // 符号化堆栈
    char **symbols = backtrace_symbols(g_callStack, g_callStackCount);
    NSMutableArray *result = [NSMutableArray array];
    
    for (int i = 0; i < g_callStackCount; i++) {
        [result addObject:@(symbols[i])];
    }
    
    free(symbols);
    return result;
}

@end

Swift封装

// JankStackCapture.swift
import Foundation

@objc class JankStackCapture: NSObject {
    
    @objc static func captureMainThreadStack() -> [String] {
        // 这里仅示意桥接点,真实实现应调用 ObjC 的 signal + backtrace 逻辑
        return []
    }
}

// 更新RunLoopMonitor使用
extension RunLoopMonitor {
    
    private func captureMainThreadCallStackAccurate() -> String {
        // 使用信号机制获取主线程堆栈
        let stack = JankStackCapture.captureMainThreadStack()
        return stack.joined(separator: "\n")
    }
}

BeforeWaiting阶段的监控盲区

上面的 RunLoop 方案是网上流传最广的实现,但它存在一个重要的盲区:无法检测 kCFRunLoopBeforeWaiting 阶段的卡顿

回顾 RunLoop 的执行流程,当 kCFRunLoopBeforeWaiting 被通知时,系统注册的多个 Observer 会依次执行,其中包括手势识别回调(_UIGestureRecognizerUpdate)、UI 布局与绘制(CA::Transaction::commit)、AutoreleasePool 释放重建等。这些操作恰恰是容易产生卡顿的地方。

问题在于:上面代码的 Observer 优先级(order)为 0,而 Core Animation 注册的 kCFRunLoopBeforeWaiting Observer 的 order 为 2000000(可通过打印 [NSRunLoop mainRunLoop].description 验证)。RunLoop 按 order 从小到大依次调用同一 activity 阶段的 Observer,因此 order=0 的 Observer 一定比 Core Animation 的 Observer 更早执行。此时 activity 被更新为 .beforeWaiting,信号量被 signal。但子线程的判断条件只检查了 .beforeSources.afterWaiting,不包含 .beforeWaiting,因此后续系统 Observer 回调(如 layoutSubviewsdrawRect:)中发生的卡顿,都会被直接跳过。

微信 Matrix 的解决方案:双Observer + 极端优先级

微信开源的 APM 框架 Matrix 通过注册两个 Observer 来彻底解决这个问题:

// 最先执行的 Observer(order = LONG_MIN)
CFRunLoopObserverRef beginObserver = CFRunLoopObserverCreate(
    kCFAllocatorDefault, kCFRunLoopAllActivities, YES,
    LONG_MIN,  // 优先级最高,同一 activity 中最先执行
    &myRunLoopBeginCallback, &context);

// 最后执行的 Observer(order = LONG_MAX)
CFRunLoopObserverRef endObserver = CFRunLoopObserverCreate(
    kCFAllocatorDefault, kCFRunLoopAllActivities, YES,
    LONG_MAX,  // 优先级最低,同一 activity 中最后执行
    &myRunLoopEndCallback, &context);

CFRunLoopAddObserver(runloop, beginObserver, kCFRunLoopCommonModes);
CFRunLoopAddObserver(runloop, endObserver, kCFRunLoopCommonModes);

kCFRunLoopBeforeWaiting 触发时,执行顺序变为:

beginCallback(LONG_MIN)→ 记录开始时间,标记 bRun = YES
系统 Observer:手势识别回调、UI 布局绘制、AutoreleasePool 管理 ...
endCallback(LONG_MAX)→ 检查耗时是否超过阈值,标记 bRun = NO

endCallbackkCFRunLoopBeforeWaiting 阶段的所有 Observer 都执行完毕后才被调用,此时可以准确计算从 beginCallback 到自己之间经过的时间,从而覆盖 BeforeWaiting 阶段所有系统 Observer 的耗时。

Matrix 的 endCallback 中,在 kCFRunLoopBeforeWaiting 状态下会主动调用 checkRunloopDuration 进行耗时检查:

void myRunLoopEndCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    g_runLoopActivity = activity;
    switch (activity) {
    case kCFRunLoopBeforeWaiting:
        if (g_bSensitiveRunloopHangDetection && g_bRun) {
            [WCBlockMonitorMgr checkRunloopDuration];
        }
        g_bRun = NO;
        break;
    case kCFRunLoopExit:
        g_bRun = NO;
        break;
    default:
        break;
    }
}

3. 子线程Ping方案

基本原理

子线程定时向主线程发送Ping,如果主线程长时间未响应则判定为卡顿:

sequenceDiagram
    participant 子线程
    participant 主线程
    
    子线程->>主线程: Ping
    Note right of 主线程: 处理任务
    主线程-->>子线程: Pong
    
    子线程->>主线程: Ping
    Note right of 主线程: 阻塞中...
    Note left of 子线程: 超时,检测到卡顿

实现代码

class PingMonitor {
    
    static let shared = PingMonitor()
    
    /// 检测间隔(秒)
    var interval: TimeInterval = 1.0
    
    /// 超时阈值(秒)
    var timeout: TimeInterval = 2.0
    
    /// 卡顿回调
    var onJankDetected: ((_ duration: TimeInterval) -> Void)?
    
    private var isMonitoring = false
    private var isPonged = false
    private var pingTime: CFAbsoluteTime = 0
    
    private init() {}
    
    func start() {
        guard !isMonitoring else { return }
        isMonitoring = true
        
        startPingLoop()
    }
    
    func stop() {
        isMonitoring = false
    }
    
    private func startPingLoop() {
        DispatchQueue.global(qos: .userInteractive).async { [weak self] in
            guard let self = self else { return }
            
            while self.isMonitoring {
                self.isPonged = false
                self.pingTime = CFAbsoluteTimeGetCurrent()
                
                // 向主线程发送Ping
                DispatchQueue.main.async {
                    self.isPonged = true
                }
                
                // 等待一段时间后检查
                Thread.sleep(forTimeInterval: self.timeout)
                
                if !self.isPonged {
                    // 主线程未响应,检测到卡顿
                    let duration = CFAbsoluteTimeGetCurrent() - self.pingTime
                    
                    DispatchQueue.main.async {
                        self.onJankDetected?(duration)
                    }
                }
                
                // 等待下一次检测
                Thread.sleep(forTimeInterval: self.interval)
            }
        }
    }
}

带堆栈采集的版本

class PingMonitorWithStack {
    
    static let shared = PingMonitorWithStack()
    
    var interval: TimeInterval = 1.0
    var timeout: TimeInterval = 2.0
    var onJankDetected: ((_ duration: TimeInterval, _ stack: String) -> Void)?
    
    private var isMonitoring = false
    private var isPonged = false
    private var pingTime: CFAbsoluteTime = 0
    private var monitorQueue: DispatchQueue?
    
    private init() {}
    
    func start() {
        guard !isMonitoring else { return }
        isMonitoring = true
        
        monitorQueue = DispatchQueue(label: "com.jank.ping", qos: .userInteractive)
        startPingLoop()
    }
    
    func stop() {
        isMonitoring = false
        monitorQueue = nil
    }
    
    private func startPingLoop() {
        monitorQueue?.async { [weak self] in
            guard let self = self else { return }
            
            while self.isMonitoring {
                self.isPonged = false
                self.pingTime = CFAbsoluteTimeGetCurrent()
                
                // 发送Ping
                DispatchQueue.main.async {
                    self.isPonged = true
                }
                
                // 等待超时时间
                Thread.sleep(forTimeInterval: self.timeout)
                
                if !self.isPonged {
                    // 采集主线程堆栈
                    let stack = self.captureMainThreadStack()
                    let duration = CFAbsoluteTimeGetCurrent() - self.pingTime
                    
                    // 继续等待直到主线程恢复
                    while !self.isPonged && self.isMonitoring {
                        Thread.sleep(forTimeInterval: 0.1)
                    }
                    
                    let totalDuration = CFAbsoluteTimeGetCurrent() - self.pingTime
                    
                    DispatchQueue.main.async {
                        self.onJankDetected?(totalDuration, stack)
                    }
                }
                
                Thread.sleep(forTimeInterval: self.interval)
            }
        }
    }
    
    private func captureMainThreadStack() -> String {
        // 使用信号机制获取主线程堆栈
        // 实际实现需要ObjC代码
        return "Main thread stack capture requires ObjC implementation"
    }
}

4. Instruments工具

Time Profiler

Time Profiler是分析CPU性能的首选工具:

使用步骤:
1. Product → Profile (Cmd+I)
2. 选择 Time Profiler
3. 点击 Record 开始录制
4. 执行会卡顿的操作
5. 停止录制,分析结果

关键设置:
- Record Waiting Threads: 记录等待中的线程
- Record Kernel Callstacks: 记录内核调用栈
- High Frequency: 高频采样模式

Time Profiler分析技巧

分析视图:

Call Tree(调用树):
┌─────────────────────────────────────────────────────┐
│  Weight    Self Weight    Symbol Name               │
├─────────────────────────────────────────────────────┤
│  100%      0%            main                       │
│   └─ 95%   0%            UIApplicationMain          │
│       └─ 90%  0%         -[UIApplication _run]      │
│           └─ 85%  0%     CFRunLoopRun               │
│               └─ 80%  75%  -[MyVC heavyWork]  ← 瓶颈│
└─────────────────────────────────────────────────────┘

重要选项:
- Separate by Thread: 按线程分离
- Invert Call Tree: 反转调用树(从叶子节点开始)
- Hide System Libraries: 隐藏系统库
- Flatten Recursion: 展平递归调用

Core Animation

检测GPU渲染问题:

使用步骤:
1. Product → Profile
2. 选择 Core Animation
3. 录制并操作App

关键指标:
- FPS: 帧率
- GPU Time: GPU渲染时间
- Offscreen Rendered: 离屏渲染次数

System Trace

分析系统级性能问题:

System Trace 可以显示:
- 线程调度
- 系统调用
- 虚拟内存操作
- 中断和异常

适用场景:
- 分析线程阻塞原因
- 检测锁竞争
- 分析I/O性能

5. MetricKit(iOS 13+)

基本使用

MetricKit可以收集线上用户的性能数据:

import MetricKit

class MetricsManager: NSObject, MXMetricManagerSubscriber {
    
    static let shared = MetricsManager()
    
    func startCollecting() {
        MXMetricManager.shared.add(self)
    }
    
    func stopCollecting() {
        MXMetricManager.shared.remove(self)
    }
    
    // iOS 13: 每日回调
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            processPayload(payload)
        }
    }
    
    // iOS 14+: 诊断数据回调
    @available(iOS 14.0, *)
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            processDiagnostic(payload)
        }
    }
    
    private func processPayload(_ payload: MXMetricPayload) {
        // 应用响应性指标
        if let responsiveness = payload.applicationResponsivenessMetrics {
            let hangTime = responsiveness.histogrammedApplicationHangTime
            analyzeHangTime(hangTime)
        }
        
        // 动画指标(iOS 14+)
        if #available(iOS 14.0, *) {
            if let animation = payload.animationMetrics {
                let scrollHitch = animation.scrollHitchTimeRatio
                print("Scroll hitch ratio: \(scrollHitch)")
            }
        }
    }
    
    private func analyzeHangTime(_ histogram: MXHistogram<UnitDuration>) {
        // 分析卡顿时间分布
        for bucket in histogram.bucketEnumerator {
            guard let bucket = bucket as? MXHistogramBucket<UnitDuration> else { continue }
            
            let start = bucket.bucketStart.value
            let end = bucket.bucketEnd.value
            let count = bucket.bucketCount
            
            print("Hang \(start)-\(end)ms: \(count) times")
        }
    }
    
    @available(iOS 14.0, *)
    private func processDiagnostic(_ payload: MXDiagnosticPayload) {
        // 处理卡顿诊断
        if let hangDiagnostics = payload.hangDiagnostics {
            for diagnostic in hangDiagnostics {
                let duration = diagnostic.hangDuration
                let callStack = diagnostic.callStackTree
                
                print("Hang duration: \(duration)")
                print("Call stack: \(callStack)")
            }
        }
    }
}

MetricKit的优势

特性说明
零开销系统级数据收集,不影响性能
真实数据来自真实用户的设备
隐私保护数据聚合后回调,无隐私问题
堆栈信息iOS 14+支持卡顿堆栈

数据解析示例

@available(iOS 14.0, *)
extension MetricsManager {
    
    func parseCallStackTree(_ tree: MXCallStackTree) -> [String] {
        // 解析调用栈树
        let jsonData = tree.jsonRepresentation()
        
        do {
            if let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
               let callStacks = json["callStacks"] as? [[String: Any]] {
                
                var result: [String] = []
                for stack in callStacks {
                    if let frames = stack["callStackRootFrames"] as? [[String: Any]] {
                        for frame in frames {
                            if let address = frame["address"] as? UInt64,
                               let offsetIntoBinaryTextSegment = frame["offsetIntoBinaryTextSegment"] as? UInt64 {
                                result.append("0x\(String(address, radix: 16)) + \(offsetIntoBinaryTextSegment)")
                            }
                        }
                    }
                }
                return result
            }
        } catch {
            print("Failed to parse call stack: \(error)")
        }
        
        return []
    }
}

6. Sentry ANR检测方案(App Hangs V2)

Sentry SDK提供了一种更精细的卡顿检测方案,能够区分**完全阻塞(Fully Blocking)非完全阻塞(Non-Fully Blocking)**两种类型的卡顿,这对于问题定位和优先级排序非常有价值。

为什么要区分阻塞类型

所有基于"超时采集堆栈"的方案都存在堆栈时机问题(详见本文第7章"堆栈采集时机问题")。Sentry的创新在于:通过区分阻塞类型,告知开发者堆栈的可信度

类型特征堆栈可信度说明
Fully Blocking主线程完全卡住,无法渲染任何帧整个检测周期只有1帧,堆栈大概率准确
Non-Fully Blocking应用看起来卡顿,但仍能渲染少量帧多个任务累积导致,堆栈可能指向"最后一个"而非"最耗时的"
Fatal App Hang卡顿期间应用被用户或系统终止取决于阻塞类型最严重,需优先处理

关键洞察:Sentry并没有解决堆栈采集时机问题,而是通过framesContributingToDelayCount指标让开发者知道:

  • 值为1 → 堆栈可信,可以直接定位问题
  • 值大于1 → 堆栈仅供参考,需要结合其他手段分析

核心原理

Sentry的ANR检测基于**帧延迟(Frame Delay)**分析:

flowchart TD
    A[Watchdog线程启动] --> B[周期性检查帧延迟]
    B --> C{检查帧延迟}
    C -->|延迟100%且只有1帧贡献| D[Fully Blocking]
    C -->|延迟大于99%但非100%| E[Non-Fully Blocking]
    C -->|延迟小于99%| F[正常,继续监控]
    D --> G[触发ANRDetected回调]
    E --> G
    F --> B
    G --> H[持续监控直到恢复]
    H --> I[ANRStopped回调并记录持续时间]

核心判定逻辑

  • 完全阻塞framesContributingToDelayCount == 1 且帧延迟 >= 超时阈值(默认2秒)
  • 非完全阻塞:帧延迟 > 超时阈值的99%,但不是完全阻塞

源码分析

以下是Sentry SentryANRTrackerV2的核心实现(源码来自 sentry-cocoa):

类定义与初始化

@interface SentryANRTrackerV2 : NSObject

// 初始化,默认超时时间为2秒
- (instancetype)initWithTimeoutInterval:(NSTimeInterval)timeoutInterval;

// 添加/移除监听器
- (void)addListener:(id<SentryANRTrackerInternalDelegate>)listener;
- (void)removeListener:(id<SentryANRTrackerInternalDelegate>)listener;

@end

Watchdog线程实现

- (void)detectANRs {
    // 设置线程名称,便于调试
    NSThread.currentThread.name = @"io.sentry.app-hang-tracker";
    
    BOOL reported = NO;
    NSInteger reportThreshold = 5;
    // 将超时时间分成5份,每份检查一次
    NSTimeInterval sleepInterval = self.timeoutInterval / reportThreshold;
    
    while (YES) {
        // 检查是否应该停止
        @synchronized(threadLock) {
            if (state != kSentryANRTrackerRunning) {
                break;
            }
        }
        
        // 休眠一个检查间隔
        [self.threadWrapper sleepForTimeInterval:sleepInterval];
        
        // 忽略后台状态
        if (![self.crashWrapper isApplicationInForeground]) {
            continue;
        }
        
        // 获取帧延迟信息
        SentryFramesDelayResultSPI *framesDelayForTimeInterval =
            [self.framesTracker getFramesDelaySPI:frameDelayStartSystemTime
                               endSystemTimestamp:nowSystemTime];
        
        // 核心判定逻辑
        BOOL isFullyBlocking = 
            framesDelayForTimeInterval.framesContributingToDelayCount == 1;
        
        // 完全阻塞检测
        if (isFullyBlocking && 
            framesDelayForTimeIntervalInNanos >= timeoutIntervalInNanos) {
            reported = YES;
            [self ANRDetected:kSentryANRTypeFullyBlocking];
        }
        
        // 非完全阻塞检测(延迟超过99%阈值)
        NSTimeInterval nonFullyBlockingFramesDelayThreshold = 
            self.timeoutInterval * 0.99;
        if (!isFullyBlocking && 
            framesDelayForTimeInterval.delayDuration > nonFullyBlockingFramesDelayThreshold) {
            reported = YES;
            [self ANRDetected:kSentryANRTypeNonFullyBlocking];
        }
    }
}

帧延迟计算

SentryFramesTracker通过CADisplayLink监控帧渲染。CADisplayLink是一个与屏幕刷新同步的定时器,每次屏幕刷新时都会触发回调。

CADisplayLink监控原理

sequenceDiagram
    participant DL as CADisplayLink
    participant FT as FramesTracker
    participant DFT as DelayedFramesTracker
    
    DL->>FT: displayLinkCallback
    FT->>FT: 计算frameDuration = 当前时间戳 - 上一帧时间戳
    alt frameDuration > slowThreshold
        FT->>FT: slowFrames += 1
        FT->>DFT: recordDelayedFrame
    end
    alt frameDuration > frozenThreshold
        FT->>FT: frozenFrames += 1
    end
    FT->>FT: 更新previousFrameTimestamp

核心回调实现

@objc private func displayLinkCallback() {
    let thisFrameTimestamp = displayLinkWrapper.timestamp
    let thisFrameSystemTimestamp = dateProvider.systemTime()
    
    // 计算实际帧率(支持ProMotion动态刷新率)
    currentFrameRate = 60
    if displayLinkWrapper.targetTimestamp != displayLinkWrapper.timestamp {
        currentFrameRate = UInt64(round(
            1 / (displayLinkWrapper.targetTimestamp - displayLinkWrapper.timestamp)
        ))
    }
    
    // 计算两帧之间的实际耗时
    let frameDuration = thisFrameTimestamp - previousFrameTimestamp
    let slowThreshold = Self.slowFrameThreshold(currentFrameRate)
    
    // 判断帧类型
    if frameDuration > slowThreshold && frameDuration <= Self.frozenFrameThreshold {
        slowFrames += 1  // 慢帧:超过期望但小于0.7秒
    } else if frameDuration > Self.frozenFrameThreshold {
        frozenFrames += 1  // 冻结帧:超过0.7秒
    }
    
    // 记录延迟帧信息(供ANR检测使用)
    if frameDuration > slowThreshold {
        delayedFramesTracker.recordDelayedFrame(
            previousFrameSystemTimestamp,
            thisFrameSystemTimestamp: thisFrameSystemTimestamp,
            expectedDuration: slowThreshold,
            actualDuration: frameDuration
        )
    }
    
    previousFrameTimestamp = thisFrameTimestamp
}

帧类型判定阈值

帧类型判定条件说明
正常帧duration <= slowThreshold在期望时间内完成渲染
慢帧(Slow)slowThreshold < duration <= 0.7s超过期望但用户可接受
冻结帧(Frozen)duration > 0.7s明显卡顿,用户可感知

其中slowThreshold根据当前帧率动态计算,60Hz时约为16.67ms,120Hz时约为8.33ms。

获取帧延迟API

@objc public func getFramesDelaySPI(
    _ startSystemTimestamp: UInt64,
    endSystemTimestamp: UInt64
) -> SentryFramesDelayResultSPI {
    let result = getFramesDelay(
        startSystemTimestamp, 
        endSystemTimestamp: endSystemTimestamp
    )
    return .init(
        delayDuration: result.delayDuration,
        framesContributingToDelayCount: result.framesContributingToDelayCount
    )
}

返回结果包含:

  • delayDuration:帧延迟总时长(所有延迟帧的超时部分累加)
  • framesContributingToDelayCount:贡献延迟的帧数量
    • 如果为1,说明只有一帧在整个检测周期内都没有完成渲染 → 完全阻塞
    • 如果大于1,说明有多帧贡献了延迟 → 非完全阻塞

卡顿恢复检测

// 检测卡顿是否已恢复
BOOL appHangStopped = framesDelay.delayDuration < appHangStoppedFrameDelayThreshold;

if (appHangStopped) {
    // 计算卡顿持续时间(有一定误差范围)
    NSTimeInterval appHangDurationMinimum = /* 最小持续时间 */;
    NSTimeInterval appHangDurationMaximum = /* 最大持续时间 */;
    
    // 异步通知监听器
    [self.dispatchQueueWrapper dispatchAsyncWithBlock:^{
        [self ANRStopped:appHangDurationMinimum to:appHangDurationMaximum];
    }];
}

99%阈值的设计考量

为什么选择99%作为非完全阻塞的阈值?官方注释解释:

即使应用卡顿0.5秒,然后渲染约5帧,再卡顿0.5秒,用户仍然可以响应输入(如导航到其他页面)。这种情况下帧延迟约为97%。只有当帧延迟超过99%时,应用才真正"看起来卡住了"。

使用建议

  1. 优先处理Fully Blocking:这类卡顿堆栈准确,问题定位更容易
  2. 可选禁用Non-Fully Blocking报告:如果告警过多,可以禁用
  3. Widgets和Live Activities:建议禁用检测,可能产生误报
  4. 系统权限弹窗:在显示系统弹窗时暂停检测

7. 堆栈采集时机问题

基于"超时采集堆栈"的检测方案(RunLoop Observer、子线程Ping、Sentry ANR等)都存在一个共同的问题:堆栈采集时机可能不准确

问题场景

假设卡顿阈值设置为2秒:

gantt
    title 卡顿堆栈采集时机问题(阈值2秒)
    dateFormat x
    axisFormat %L ms
    
    section 主线程
    函数A耗时1.9s :a, 0, 1900
    函数B耗时0.2s :b, 1900, 2100
    
    section 检测线程
    2s超时触发采集 :crit, m1, 2000, 2001

在这个场景中:

  • 函数A执行了1.9秒(未超过2秒阈值)
  • 函数B执行了0.2秒
  • 总耗时2.1秒,超过阈值触发卡顿检测
  • 但此时采集到的堆栈是函数B,而真正的"元凶"是函数A

检测机制是"定时检查"而非"持续监控",只能在超时那一刻采集堆栈,无法回溯之前的执行情况。

解决方案

方案1:周期性采样

在检测期间多次采集堆栈,而不是只在超时时采集一次:

class SamplingMonitor {
    var samplingInterval: TimeInterval = 0.1  // 100ms采样一次
    var threshold: TimeInterval = 2.0
    var stackSamples: [[String]] = []
    
    func startSampling() {
        DispatchQueue.global().async {
            while self.isMonitoring {
                // 每100ms采集一次主线程堆栈
                let stack = self.captureMainThreadStack()
                self.stackSamples.append(stack)
                
                // 保留最近N个样本
                if self.stackSamples.count > 50 {
                    self.stackSamples.removeFirst()
                }
                
                Thread.sleep(forTimeInterval: self.samplingInterval)
            }
        }
    }
    
    func onJankDetected() {
        // 分析所有采样的堆栈,找出出现频率最高的调用
        let analysis = analyzeStackSamples(stackSamples)
        // 高频出现的函数更可能是卡顿原因
    }
}

方案2:堆栈聚合

将多次采样的堆栈进行聚合分析,找出出现频率最高的调用路径:

class StackAggregator {
    
    /// 聚合多个堆栈样本,返回按频率排序的调用路径
    func aggregate(samples: [[String]]) -> [(stack: [String], count: Int)] {
        var stackCounts: [String: Int] = [:]
        
        for sample in samples {
            // 将堆栈转为字符串作为key
            let key = sample.joined(separator: "\n")
            stackCounts[key, default: 0] += 1
        }
        
        // 按出现次数排序
        return stackCounts
            .map { (stack: $0.key.split(separator: "\n").map(String.init), count: $0.value) }
            .sorted { $0.count > $1.count }
    }
    
    /// 分析卡顿时的堆栈
    func analyzeJank(samples: [[String]]) {
        let aggregated = aggregate(samples: samples)
        
        // 出现频率最高的堆栈最可能是卡顿原因
        if let topStack = aggregated.first {
            let percentage = Double(topStack.count) / Double(samples.count) * 100
            print("最可能的卡顿原因(出现\(percentage)%):")
            print(topStack.stack.joined(separator: "\n"))
        }
    }
}

聚合效果示例

采样20次,聚合结果:
┌──────────────────────────────────────┬───────┬────────┐
│ 堆栈                                  │ 次数  │ 占比    │
├──────────────────────────────────────┼───────┼────────┤
│ main → viewDidLoad → 函数A → 耗时操作  │ 18    │ 90%    │
│ main → viewDidLoad → 函数B            │ 2     │ 10%    │
└──────────────────────────────────────┴───────┴────────┘

结论:函数A是真正的卡顿原因

方案3:退火算法

退火算法是业界常用的卡顿检测优化策略,主要目的是降低检测带来的性能损耗。核心思想是:当检测到相同堆栈时,逐步递增检测间隔(使用斐波那契数列),避免对同一个卡顿问题重复采集堆栈。

退火算法的核心逻辑

flowchart TD
    A[检测到卡顿] --> B[获取主线程堆栈]
    B --> C{与上次堆栈比较}
    C -->|不同| D[写入文件/上报]
    C -->|相同| E[跳过写入]
    D --> F[重置间隔为1]
    E --> G[按斐波那契数列递增间隔]
    F --> H[继续监控]
    G --> H

实现代码

// 退火算法核心逻辑
@interface JankMonitor : NSObject {
    NSUInteger intervalTime;      // 当前检测间隔(斐波那契数列)
    NSUInteger lastTimeInterval;  // 上一个间隔值
    NSString *lastStackHash;      // 上次卡顿堆栈的哈希
}
@end

@implementation JankMonitor

- (void)onJankDetected:(NSString *)currentStack {
    BOOL isSame = [currentStack isEqualToString:lastStackHash];
    
    if (isSame) {
        // 堆栈相同,按斐波那契数列递增间隔
        NSUInteger lastTimeInterval_t = intervalTime;
        intervalTime = lastTimeInterval + intervalTime;  // 1, 1, 2, 3, 5, 8, 13...
        lastTimeInterval = lastTimeInterval_t;
        
        // 跳过写入,减少性能损耗
        return;
    } else {
        // 堆栈不同,重置间隔并写入
        intervalTime = 1;
        lastTimeInterval = 1;
        lastStackHash = currentStack;
        
        [self writeStackToFile:currentStack];
    }
}

// 检测循环中使用intervalTime控制检测频率
- (void)monitorLoop {
    for (int nCnt = 0; nCnt < intervalTime; nCnt++) {
        // periodTime = 1秒, perStackInterval = 50毫秒
        int intervalCount = periodTime / perStackInterval;  // = 20
        
        for (int index = 0; index < intervalCount; index++) {
            usleep(perStackInterval * 1000);  // 休眠50ms
            // 获取主线程堆栈并保存到循环队列
            [self captureAndSaveStack];
        }
    }
}

@end

退火算法的优势

  • 避免重复写入:同一个卡顿问题只记录一次,减少存储和上报开销
  • 降低CPU负担:主线程卡死时,子线程不会频繁采集堆栈,避免加剧卡顿
  • 斐波那契递增:间隔快速增长,长时间卡顿时检测开销趋近于零

方案4:火焰图可视化

将聚合后的堆栈数据可视化为火焰图,直观展示各函数的CPU占用:

火焰图示例:
┌─────────────────────────────────────────────┐
│                   main                      │
├─────────────────────────────────────────────┤
│              viewDidLoad                    │
├───────────────────────────────┬─────────────┤
│         函数A (占比90%)        │  函数B (10%) │
├───────────────────────────────┴─────────────┤

通过占比可以看出函数A才是主要耗时点

方案5:区分堆栈可信度(Sentry的思路)

Sentry ANR V2通过帧延迟分析,区分卡顿类型并告知开发者堆栈的可信度:

类型判定条件堆栈可信度
Fully BlockingframesContributingToDelayCount == 1高,整个周期只有1帧,堆栈准确
Non-Fully Blocking多帧贡献延迟低,可能是多个函数累积导致
完全阻塞场景:
0s                              2s
|-------------------------------|
|        函数A一直在执行          |
                                └── 采集堆栈,准确指向函数A

非完全阻塞场景:
0s          1.9s        2s
|-----------|-----------|
|  函数A执行  |  函数B执行 |
                        └── 采集堆栈,指向函数B(不准确)

Sentry的价值在于:让开发者知道哪些堆栈是可信的,哪些需要进一步分析

性能开销提醒

需要注意的是,卡顿检测本身也会消耗性能。上述方案中,周期性采样、堆栈聚合等都需要更频繁地采集主线程堆栈,这会带来额外的CPU和内存开销。

方案采样频率性能开销建议
单次采集仅超时时适合线上大规模使用
周期性采样持续高频配合退火算法可用于线上
堆栈聚合持续高频同上
退火算法动态递增线上推荐,有效降低开销
Sentry基于帧回调适合线上使用

实践建议

  1. 线上环境:优先使用低开销方案(单次采集、Sentry),关注Fully Blocking类型
  2. 灰度/测试环境:可开启周期性采样,获取更精确的堆栈
  3. 采样率控制:线上可通过采样率(如1%用户)来降低整体开销
  4. 按需开启:可根据设备性能、电量等条件动态开启/关闭高频检测

8. CPU监控辅助检测

除了主线程卡顿检测,CPU占用过高也可能导致应用卡顿。可以在卡顿检测的同时监控CPU使用率,当CPU过高时也捕获堆栈信息。

CPU使用率获取

import Darwin

class CPUMonitor {
    
    /// 获取当前App的CPU使用率(所有线程累加)
    static func getAppCPUUsage() -> Double {
        var threadsList: thread_act_array_t?
        var threadsCount = mach_msg_type_number_t()
        
        let result = task_threads(mach_task_self_, &threadsList, &threadsCount)
        guard result == KERN_SUCCESS, let threads = threadsList else {
            return 0
        }
        
        var totalUsage: Double = 0
        
        for i in 0..<Int(threadsCount) {
            var threadInfo = thread_basic_info()
            var threadInfoCount = mach_msg_type_number_t(THREAD_INFO_MAX)
            
            let infoResult = withUnsafeMutablePointer(to: &threadInfo) {
                $0.withMemoryRebound(to: integer_t.self, capacity: Int(threadInfoCount)) {
                    thread_info(threads[i], thread_flavor_t(THREAD_BASIC_INFO), $0, &threadInfoCount)
                }
            }
            
            if infoResult == KERN_SUCCESS {
                if threadInfo.flags & TH_FLAGS_IDLE == 0 {
                    totalUsage += Double(threadInfo.cpu_usage) / Double(TH_USAGE_SCALE) * 100
                }
            }
        }
        
        // 释放线程列表
        let size = vm_size_t(Int(threadsCount) * MemoryLayout<thread_t>.stride)
        vm_deallocate(mach_task_self_, vm_address_t(bitPattern: threads), size)
        
        return totalUsage
    }
    
    /// 获取单核CPU占用阈值(考虑多核情况)
    static func getSingleCoreCPUUsage() -> Double {
        let totalUsage = getAppCPUUsage()
        let coreCount = ProcessInfo.processInfo.activeProcessorCount
        return totalUsage / Double(coreCount)
    }
}

9. 卡顿检测方案总结

1. CADisplayLink帧率监控

CADisplayLink 是一个与屏幕刷新节奏同步的回调。它可以记录 FPS、单帧耗时、慢帧、冻结帧和帧延迟,是卡顿检测的重要辅助信号,但 FPS 低不等于一定卡顿

在 ProMotion 机型上,系统会动态调整刷新率。静止页面可能只有 10Hz 或 30Hz,此时 FPS 低是系统主动省电;滚动、动画、手势交互时出现慢帧、冻结帧或回调延迟,才更接近用户可感知卡顿。判断时应通过 targetTimestamp - timestamp 获取当前调度帧率,并按动态帧预算计算慢帧,而不是写死 60 FPS 或 16.67ms。

局限性

  • 只能告诉你"帧延迟了",无法告诉你"哪段代码导致的"
  • 主线程阻塞时CADisplayLink的回调本身也会被延迟
  • 受动态刷新率、低电量、热状态、静止页面省电策略影响,不能单独作为卡顿结论

2. RunLoop Observer监控

RunLoop在每次循环中经历多个状态:BeforeTimers → BeforeSources → 处理Source0 → BeforeWaiting → 休眠 → AfterWaiting → 处理事件。如果主线程在 BeforeSourcesAfterWaiting 状态停留过久,说明正在执行耗时任务。

检测方式是在子线程用信号量等待RunLoop状态变化,如果超过阈值(如100ms)仍未收到信号,则判定为卡顿,并通过向主线程发送SIGURG信号触发backtrace来采集主线程的真实堆栈。

3. 子线程Ping方案

子线程每隔一段时间(如1秒)通过DispatchQueue.main.async向主线程派发一个任务,任务内容是将一个标志位设为true。子线程等待超时时间(如2秒)后检查标志位,如果主线程未能在超时时间内执行这个简单任务,说明主线程被阻塞了。

这种方案实现简单,但精度受限于超时阈值的设置,且需要额外实现堆栈采集。

4. Instruments系统工具

Instruments提供了三个与卡顿分析相关的核心工具:

  • Time Profiler:CPU采样分析,通过调用树(Call Tree)定位耗时函数。关键技巧是开启"Invert Call Tree"(从叶子节点看起)和"Hide System Libraries"(隐藏系统库)
  • Core Animation:检测GPU渲染问题,包括FPS、离屏渲染次数等
  • System Trace:分析线程调度、锁竞争、I/O等系统级问题

Instruments只能在开发阶段使用,无法部署到线上。

5. MetricKit

MetricKit是苹果提供的系统级性能收集框架,特点是零额外开销(数据由系统底层收集)。iOS 13支持每日回调性能指标(如卡顿时间分布直方图),iOS 14+增加了诊断数据回调,可以获取卡顿堆栈(MXCallStackTree)和滚动卡顿率(scrollHitchTimeRatio)。

MetricKit的数据来自真实用户设备,具有很高的参考价值,但不是实时的(每日回调一次),适合用于长期趋势监控而非实时问题定位。

6. Sentry ANR V2

Sentry ANR V2 是一种基于帧延迟(Frame Delay)分析的卡顿检测方案,其核心创新是能够区分完全阻塞非完全阻塞两种卡顿类型,从而标注堆栈的可信度。

检测架构

Sentry 的检测由三个组件协作完成:

  1. SentryFramesTracker:通过 CADisplayLink 回调,持续记录每一帧的实际渲染耗时。当帧耗时超过当前调度帧率下的慢帧阈值时,将该帧标记为延迟帧,并记录其延迟时长和时间戳。比如 60Hz 下单帧预算约 16.67ms,120Hz 下约 8.33ms。
  2. SentryDelayedFramesTracker:存储所有延迟帧的信息,提供按时间区间查询帧延迟的能力。核心 API getFramesDelay(startTimestamp, endTimestamp) 返回两个关键值:
    • delayDuration:时间区间内所有延迟帧的超时部分累加(即每帧实际耗时减去期望耗时的总和)
    • framesContributingToDelayCount:贡献了延迟的帧数量
  3. SentryANRTrackerV2:Watchdog 线程,将超时时间(默认2秒)分成5份,每份检查一次帧延迟。根据帧延迟数据判定卡顿类型。

判定逻辑

Watchdog 线程每 0.4秒(2秒/5)检查一次:

1. 调用 getFramesDelay 获取最近一个检测周期内的帧延迟数据
2. 判定规则:
   - framesContributingToDelayCount == 1 且 delayDuration >= 2秒
     → Fully Blocking(完全阻塞)
   - framesContributingToDelayCount > 1 且 delayDuration > 2秒 × 99%
     → Non-Fully Blocking(非完全阻塞)
   - 其他情况 → 正常,继续监控

两种卡顿类型的区别

Fully Blocking(完全阻塞):
0s                                    2s
|-------------------------------------|
|     一个函数A一直占据主线程             |
|     CADisplayLink回调无法触发         |
|     整个周期只产生了1帧延迟             |
                                      └── 采集堆栈 → 指向函数A → 可信

Non-Fully Blocking(非完全阻塞):
0s        0.5s    0.8s   1.2s   1.5s   2s
|---------|-------|------|------|-------|
|  函数A   |函数B  |函数C  |函数D  |函数E  |
|  慢帧    |慢帧   |慢帧   |慢帧   |慢帧   |
|  5帧都贡献了延迟,累加超过99%阈值         |
                                       └── 采集堆栈 → 指向函数E → 不可信

Sentry的核心价值:它并没有解决堆栈采集时机问题(所有基于超时采集的方案都无法完美解决),而是通过 framesContributingToDelayCount 这个指标让开发者知道:

  • 值为1 → Fully Blocking → 堆栈可信,可以直接定位问题
  • 值大于1 → Non-Fully Blocking → 堆栈仅供参考,需要结合周期性采样或火焰图进一步分析

堆栈采集时机问题与优化

所有基于"超时采集堆栈"的方案(RunLoop Observer、子线程Ping)都面临一个共性问题:超时那一刻采集的堆栈不一定是真正的卡顿元凶

检测机制是"定时检查"而非"持续监控",只能在超时那一刻拍一张堆栈快照,无法回溯之前的执行情况。针对这个问题,有以下优化策略:

策略1:周期性采样

不再只在超时时采集一次堆栈,而是在整个检测期间持续高频采集。例如每100ms采集一次主线程堆栈,保留最近N个样本(如50个,覆盖5秒窗口)。当检测到卡顿时,分析这些样本中出现频率最高的调用路径,频率越高越可能是真正的卡顿原因。

适用环境:灰度/测试环境。由于需要持续高频采集堆栈(通过SIGURG信号+backtrace),CPU开销较大,不适合线上全量开启。

策略2:堆栈聚合分析

周期性采样会产生大量堆栈数据,直接上报既浪费带宽又难以阅读。堆栈聚合是对采样数据的二次处理:将多个堆栈样本按调用路径去重,统计每条路径的出现次数,按频率降序排列。

聚合后的数据量大大减少,可以直接上报服务端,也可以进一步生成火焰图进行可视化分析。

策略3:退火算法

退火算法解决的是另一个问题:降低检测本身带来的性能损耗。当主线程持续卡顿时(如死锁或超长计算),子线程的检测循环会不断触发堆栈采集,而每次采集的堆栈都是相同的,这些重复采集毫无价值,反而会加剧性能问题。

退火算法的核心思想:当连续检测到相同堆栈时,按斐波那契数列递增检测间隔;当堆栈发生变化时,重置间隔为1。

退火算法的三个核心优势:

  • 避免重复写入:同一个卡顿问题只记录一次,减少存储和上报开销
  • 降低CPU负担:主线程已经卡死的情况下,子线程不再频繁采集堆栈,避免雪上加霜
  • 线上友好:配合周期性采样使用,可以在保持检测精度的同时将性能开销控制在可接受范围内

策略4:火焰图可视化

火焰图是聚合数据的可视化形式。横轴表示时间占比(或采样次数占比),纵轴表示调用栈深度。函数在横轴上越宽,说明它的采样频率越高,也就越可能是性能瓶颈。

火焰图示例(横轴 = 采样占比):

┌──────────────────────────────────────────────────┐
│                     main (100%)                  │
├──────────────────────────────────────────────────┤
│                  viewDidLoad (100%)              │
├──────────────────────────────────┬───────────────┤
│       loadData (90%)             │ setupUI (10%) │
├──────────────────────────────────┼───────────────┤
│     parseJSON (90%)              │ layout (10%)  │
└──────────────────────────────────┴───────────────┘

一眼看出 loadData → parseJSON 占了90%的时间

火焰图本身不产生运行时开销,它只是对已有采样数据的后处理。通常在服务端将上报的聚合堆栈数据转换为火焰图格式,供开发者在Web平台上查看。

策略5:区分堆栈可信度(Sentry思路)

通过帧延迟分析中的 framesContributingToDelayCount 指标,将卡顿报告标记为"堆栈可信"或"堆栈仅供参考":

  • Fully Blocking(贡献延迟帧数 = 1):整个检测周期只有一帧没完成渲染,堆栈采集时主线程大概率还在执行同一个函数,堆栈准确
  • Non-Fully Blocking(贡献延迟帧数 > 1):多个函数累积导致的卡顿,超时时刻采集的堆栈只是其中一个,不一定是最耗时的

这种策略的好处是不增加额外的采样开销(基于已有的 CADisplayLink 帧回调数据),同时让开发者对每份卡顿报告有一个预期:Fully Blocking 类型可以直接根据堆栈修复问题,Non-Fully Blocking 类型需要结合周期性采样、火焰图等手段做进一步分析。