axiom-display-performance

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Display Performance

显示性能优化

Systematic diagnosis for frame rate issues on variable refresh rate displays (ProMotion, iPad Pro, future devices). Covers render loop configuration, frame pacing, hitch mechanics, and production telemetry.
Key insight: "ProMotion available" does NOT mean your app automatically runs at 120Hz. You must configure it correctly, account for system caps, and ensure proper frame pacing.

针对可变刷新率显示器(ProMotion、iPad Pro及未来设备)的帧率问题进行系统性诊断。内容涵盖渲染循环配置、Frame Pacing、卡顿机制及生产环境遥测。
核心要点:“支持ProMotion”并不意味着你的应用会自动以120Hz运行。你必须正确配置它,考虑系统限制,并确保合适的Frame Pacing。

Part 1: Why You're Stuck at 60fps

第一部分:为何你的应用卡在60fps

Diagnostic Order

诊断步骤

Check these in order when stuck at 60fps on ProMotion:
  1. Info.plist key missing? (iPhone only) → Part 2
  2. Render loop configured for 60? (MTKView defaults, CADisplayLink) → Part 3
  3. System caps enabled? (Low Power Mode, Limit Frame Rate, Thermal) → Part 5
  4. Frame time > 8.33ms? (Can't sustain 120fps) → Part 6
  5. Frame pacing issues? (Micro-stuttering despite good FPS) → Part 7
  6. Measuring wrong thing? (UIScreen vs actual presentation) → Part 9

当ProMotion设备上应用卡在60fps时,请按以下顺序排查:
  1. 缺少Info.plist键?(仅iPhone)→ 第二部分
  2. 渲染循环配置为60fps?(MTKView默认值、CADisplayLink)→ 第三部分
  3. 系统限制开启?(低电量模式、限制帧率、过热降频)→ 第五部分
  4. 帧时间>8.33ms?(无法维持120fps)→ 第六部分
  5. Frame Pacing存在问题?(平均FPS正常但存在微卡顿)→ 第七部分
  6. 测量对象错误?(UIScreen数据与实际显示不符)→ 第九部分

Part 2: Enabling ProMotion on iPhone

第二部分:在iPhone上启用ProMotion

Critical: Core Animation won't access frame rates above 60Hz on iPhone unless you add this key.
xml
<!-- Info.plist -->
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
Without this key:
  • Your
    preferredFrameRateRange
    hints are ignored above 60Hz
  • Other animations may affect your CADisplayLink callback rate
  • iPad Pro does NOT require this key
When to add: Any iPhone app that needs >60Hz for games, animations, or smooth scrolling.

关键提示:在iPhone上,除非添加以下键,否则Core Animation不会允许帧率超过60Hz。
xml
<!-- Info.plist -->
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
如果缺少此键:
  • 你的
    preferredFrameRateRange
    设置在60Hz以上会被忽略
  • 其他动画可能会影响CADisplayLink的回调频率
  • iPad Pro不需要此键
添加场景:任何需要超过60Hz帧率的iPhone应用,如游戏、动画或流畅滚动场景。

Part 3: Render Loop Configuration

第三部分:渲染循环配置

MTKView Defaults to 60fps

MTKView默认帧率为60fps

This is the most common cause. MTKView's
preferredFramesPerSecond
defaults to 60.
swift
// ❌ WRONG: Implicit 60fps (default)
let mtkView = MTKView(frame: frame, device: device)
mtkView.delegate = self
// Running at 60fps even on ProMotion!

// ✅ CORRECT: Explicit 120fps request
let mtkView = MTKView(frame: frame, device: device)
mtkView.preferredFramesPerSecond = 120
mtkView.isPaused = false
mtkView.enableSetNeedsDisplay = false  // Continuous, not on-demand
mtkView.delegate = self
Critical settings for continuous high-rate rendering:
PropertyValueWhy
preferredFramesPerSecond
120
Request max rate
isPaused
false
Don't pause the render loop
enableSetNeedsDisplay
false
Continuous mode, not on-demand
这是最常见的原因。MTKView的
preferredFramesPerSecond
默认值为60。
swift
// ❌ 错误:隐式60fps(默认值)
let mtkView = MTKView(frame: frame, device: device)
mtkView.delegate = self
// 即使在ProMotion设备上也会以60fps运行!

// ✅ 正确:显式请求120fps
let mtkView = MTKView(frame: frame, device: device)
mtkView.preferredFramesPerSecond = 120
mtkView.isPaused = false
mtkView.enableSetNeedsDisplay = false  // 持续渲染,而非按需渲染
mtkView.delegate = self
持续高帧率渲染的关键设置
属性原因
preferredFramesPerSecond
120
请求最高帧率
isPaused
false
不要暂停渲染循环
enableSetNeedsDisplay
false
持续模式,而非按需模式

CADisplayLink Configuration (iOS 15+)

CADisplayLink配置(iOS 15+)

Apple explicitly recommends CADisplayLink (not timers) for custom render loops.
swift
// ❌ WRONG: Timer-based render loop (drifts, wastes frame time)
Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in
    self.render()
}

// ❌ WRONG: Default CADisplayLink (may hint 60)
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.add(to: .main, forMode: .common)

// ✅ CORRECT: Explicit frame rate range
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.preferredFrameRateRange = CAFrameRateRange(
    minimum: 80,      // Minimum acceptable
    maximum: 120,     // Preferred maximum
    preferred: 120    // What you want
)
displayLink.add(to: .main, forMode: .common)
Special priority for games: iOS 15+ gives 30Hz and 60Hz special priority. If targeting these rates:
swift
// 30Hz and 60Hz get priority scheduling
let prioritizedRange = CAFrameRateRange(
    minimum: 30,
    maximum: 60,
    preferred: 60
)
displayLink.preferredFrameRateRange = prioritizedRange
Apple明确推荐使用CADisplayLink(而非定时器)来实现自定义渲染循环。
swift
// ❌ 错误:基于定时器的渲染循环(会漂移,浪费帧时间)
Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in
    self.render()
}

// ❌ 错误:默认CADisplayLink(可能默认60fps)
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.add(to: .main, forMode: .common)

// ✅ 正确:显式设置帧率范围
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.preferredFrameRateRange = CAFrameRateRange(
    minimum: 80,      // 最低可接受帧率
    maximum: 120,     // 首选最高帧率
    preferred: 120    // 目标帧率
)
displayLink.add(to: .main, forMode: .common)
游戏的特殊优先级:iOS 15+为30Hz和60Hz提供特殊调度优先级。如果目标是这些帧率:
swift
// 30Hz和60Hz会获得优先级调度
let prioritizedRange = CAFrameRateRange(
    minimum: 30,
    maximum: 60,
    preferred: 60
)
displayLink.preferredFrameRateRange = prioritizedRange

Suggested Frame Rates by Content Type

按内容类型推荐的帧率

Content TypeSuggested RateNotes
Video playback24-30 HzMatch content frame rate
Scrolling UI60-120 HzHigher = smoother
Fast games60-120 HzMatch rendering capability
Slow animations30-60 HzSave power
Static content10-24 HzMinimal updates needed

内容类型推荐帧率说明
视频播放24-30 Hz匹配内容帧率
滚动UI60-120 Hz帧率越高越流畅
快节奏游戏60-120 Hz匹配渲染能力
慢动画30-60 Hz节省电量
静态内容10-24 Hz仅需最少更新

Part 4: CAMetalDisplayLink (iOS 17+)

第四部分:CAMetalDisplayLink(iOS 17+)

For Metal apps needing precise timing control,
CAMetalDisplayLink
provides more control than CADisplayLink.
swift
class MetalRenderer: NSObject, CAMetalDisplayLinkDelegate {
    var displayLink: CAMetalDisplayLink?
    var metalLayer: CAMetalLayer!

    func setupDisplayLink() {
        displayLink = CAMetalDisplayLink(metalLayer: metalLayer)
        displayLink?.delegate = self
        displayLink?.preferredFrameRateRange = CAFrameRateRange(
            minimum: 60,
            maximum: 120,
            preferred: 120
        )
        // Control render latency (in frames)
        displayLink?.preferredFrameLatency = 2
        displayLink?.add(to: .main, forMode: .common)
    }

    func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
        // update.drawable - The drawable to render to
        // update.targetTimestamp - Deadline to finish rendering
        // update.targetPresentationTimestamp - When frame will display

        guard let drawable = update.drawable else { return }

        let workingTime = update.targetTimestamp - CACurrentMediaTime()
        // workingTime = seconds available before deadline

        // Render to drawable...
        renderFrame(to: drawable)
    }
}
Key differences from CADisplayLink:
FeatureCADisplayLinkCAMetalDisplayLink
Drawable accessManual via layerProvided in callback
Latency controlNone
preferredFrameLatency
Target timingtimestamp/targetTimestamp+ targetPresentationTimestamp
Use caseGeneral animationMetal-specific rendering
When to use CAMetalDisplayLink:
  • Need precise control over render timing window
  • Want to minimize input latency
  • Building games or intensive Metal apps
  • iOS 17+ only deployment

对于需要精确时序控制的Metal应用,
CAMetalDisplayLink
比CADisplayLink提供更多控制。
swift
class MetalRenderer: NSObject, CAMetalDisplayLinkDelegate {
    var displayLink: CAMetalDisplayLink?
    var metalLayer: CAMetalLayer!

    func setupDisplayLink() {
        displayLink = CAMetalDisplayLink(metalLayer: metalLayer)
        displayLink?.delegate = self
        displayLink?.preferredFrameRateRange = CAFrameRateRange(
            minimum: 60,
            maximum: 120,
            preferred: 120
        )
        // 控制渲染延迟(以帧为单位)
        displayLink?.preferredFrameLatency = 2
        displayLink?.add(to: .main, forMode: .common)
    }

    func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
        // update.drawable - 要渲染的drawable
        // update.targetTimestamp - 完成渲染的截止时间
        // update.targetPresentationTimestamp - 帧将显示的时间

        guard let drawable = update.drawable else { return }

        let workingTime = update.targetTimestamp - CACurrentMediaTime()
        // workingTime = 截止时间前可用的秒数

        // 渲染到drawable...
        renderFrame(to: drawable)
    }
}
与CADisplayLink的主要区别
特性CADisplayLinkCAMetalDisplayLink
Drawable访问通过图层手动获取在回调中提供
延迟控制
preferredFrameLatency
目标时序timestamp/targetTimestamp+ targetPresentationTimestamp
使用场景通用动画特定Metal渲染
何时使用CAMetalDisplayLink:
  • 需要精确控制渲染时间窗口
  • 想要最小化输入延迟
  • 开发游戏或密集型Metal应用
  • 仅部署在iOS 17+设备

Part 5: System Caps

第五部分:系统限制

System states can force 60fps even when your code requests 120:
即使代码请求120fps,系统状态也可能强制帧率为60:

Low Power Mode

低电量模式

Caps ProMotion devices to 60fps.
swift
// Check programmatically
if ProcessInfo.processInfo.isLowPowerModeEnabled {
    // System caps display to 60Hz
}

// Observe changes
NotificationCenter.default.addObserver(
    forName: .NSProcessInfoPowerStateDidChange,
    object: nil,
    queue: .main
) { _ in
    let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
    self.adjustRenderingForPowerState(isLowPower)
}
将ProMotion设备限制为60fps
swift
// 程序化检查
if ProcessInfo.processInfo.isLowPowerModeEnabled {
    // 系统将显示限制为60Hz
}

// 监听变化
NotificationCenter.default.addObserver(
    forName: .NSProcessInfoPowerStateDidChange,
    object: nil,
    queue: .main
) { _ in
    let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
    self.adjustRenderingForPowerState(isLowPower)
}

Limit Frame Rate (Accessibility)

限制帧率(辅助功能)

Settings → Accessibility → Motion → Limit Frame Rate caps to 60fps.
No API to detect. If user reports 60fps despite configuration, have them check this setting.
设置→辅助功能→动态效果→限制帧率会将帧率限制为60fps。
没有API可以检测此设置。如果用户反馈配置正确但仍为60fps,请让他们检查此设置。

Thermal Throttling

过热降频

System restricts 120Hz when device overheats.
swift
// Check thermal state
switch ProcessInfo.processInfo.thermalState {
case .nominal, .fair:
    preferredFramesPerSecond = 120
case .serious, .critical:
    preferredFramesPerSecond = 60  // Reduce proactively
@unknown default:
    break
}

// Observe thermal changes
NotificationCenter.default.addObserver(
    forName: ProcessInfo.thermalStateDidChangeNotification,
    object: nil,
    queue: .main
) { _ in
    self.adjustForThermalState()
}
当设备过热时,系统会限制120Hz。
swift
// 检查热状态
switch ProcessInfo.processInfo.thermalState {
case .nominal, .fair:
    preferredFramesPerSecond = 120
case .serious, .critical:
    preferredFramesPerSecond = 60  // 主动降低帧率
@unknown default:
    break
}

// 监听热状态变化
NotificationCenter.default.addObserver(
    forName: ProcessInfo.thermalStateDidChangeNotification,
    object: nil,
    queue: .main
) { _ in
    self.adjustForThermalState()
}

Adaptive Power (iOS 26+, iPhone 17)

自适应功耗(iOS 26+,iPhone 17)

New in iOS 26: Adaptive Power is ON by default on iPhone 17/17 Pro. Can throttle even at 60% battery.
User action for testing: Settings → Battery → Power Mode → disable Adaptive Power.
No public API to detect Adaptive Power state.

iOS 26新增:iPhone 17/17 Pro默认开启自适应功耗。即使电量为60%也可能触发降频。
测试时的用户操作:设置→电池→功耗模式→关闭自适应功耗
没有公开API可以检测自适应功耗状态。

Part 6: Performance Budget

第六部分:性能预算

Frame Time Budgets

帧时间预算

Target FPSFrame BudgetVsync Interval
1208.33msEvery vsync
9011.11ms
6016.67msEvery 2nd vsync
3033.33msEvery 4th vsync
If you consistently exceed budget, system drops to next sustainable rate.
目标FPS帧时间预算Vsync间隔
1208.33ms每个Vsync
9011.11ms
6016.67ms每2个Vsync
3033.33ms每4个Vsync
如果持续超出预算,系统会降至下一个可维持的帧率。

Measuring GPU Frame Time

测量GPU帧时间

swift
func draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }

    // Your rendering code...

    commandBuffer.addCompletedHandler { buffer in
        let gpuTime = buffer.gpuEndTime - buffer.gpuStartTime
        let gpuMs = gpuTime * 1000

        if gpuMs > 8.33 {
            print("⚠️ GPU: \(String(format: "%.2f", gpuMs))ms exceeds 120Hz budget")
        }
    }

    commandBuffer.commit()
}
swift
func draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }

    // 你的渲染代码...

    commandBuffer.addCompletedHandler { buffer in
        let gpuTime = buffer.gpuEndTime - buffer.gpuStartTime
        let gpuMs = gpuTime * 1000

        if gpuMs > 8.33 {
            print("⚠️ GPU: \(String(format: "%.2f", gpuMs))ms 超出120Hz预算")
        }
    }

    commandBuffer.commit()
}

Can't Sustain 120? Target Lower Rate Evenly

无法维持120Hz?均匀降低目标帧率

Critical: Uneven frame pacing looks worse than consistent lower rate.
swift
// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
    mtkView.preferredFramesPerSecond = 60
}

关键提示:不一致的Frame Pacing比稳定的低帧率看起来更差。
swift
// 如果无法维持8.33ms,显式设置为60fps以获得流畅节奏
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
    mtkView.preferredFramesPerSecond = 60
}

Part 7: Frame Pacing

第七部分:Frame Pacing

The Micro-Stuttering Problem

微卡顿问题

Even with good average FPS, inconsistent frame timing causes visible jitter.
// BAD: Inconsistent intervals despite ~40 FPS average
Frame 1: 25ms
Frame 2: 40ms  ← stutter
Frame 3: 25ms
Frame 4: 40ms  ← stutter

// GOOD: Consistent intervals at 30 FPS
Frame 1: 33ms
Frame 2: 33ms
Frame 3: 33ms
Frame 4: 33ms
Presenting immediately after rendering causes this. Use explicit timing control.
即使平均FPS良好,不一致的帧时序也会导致可见的抖动。
// 糟糕:平均FPS约40但间隔不一致
Frame 1: 25ms
Frame 2: 40ms  ← 卡顿
Frame 3: 25ms
Frame 4: 40ms  ← 卡顿

// 良好:30FPS下间隔一致
Frame 1: 33ms
Frame 2: 33ms
Frame 3: 33ms
Frame 4: 33ms
渲染后立即显示会导致此问题。使用显式时序控制。

Frame Pacing APIs

Frame Pacing API

present(afterMinimumDuration:) — Recommended

present(afterMinimumDuration:) — 推荐使用

Ensures consistent spacing between frames:
swift
func draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer(),
          let drawable = view.currentDrawable else { return }

    // Render to drawable...

    // Present with minimum 33ms between frames (30 FPS target)
    commandBuffer.present(drawable, afterMinimumDuration: 0.033)
    commandBuffer.commit()
}
确保帧之间的间隔一致:
swift
func draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer(),
          let drawable = view.currentDrawable else { return }

    // 渲染到drawable...

    // 帧之间至少间隔33ms(目标30FPS)
    commandBuffer.present(drawable, afterMinimumDuration: 0.033)
    commandBuffer.commit()
}

present(at:) — Precise Timing

present(at:) — 精确时序

Schedule presentation at specific time:
swift
// Present at specific Mach absolute time
let presentTime = CACurrentMediaTime() + 0.033
commandBuffer.present(drawable, atTime: presentTime)
在特定时间安排显示:
swift
// 在特定Mach绝对时间显示
let presentTime = CACurrentMediaTime() + 0.033
commandBuffer.present(drawable, atTime: presentTime)

presentedTime — Verify Actual Presentation

presentedTime — 验证实际显示时间

Check when frames actually appeared:
swift
drawable.addPresentedHandler { drawable in
    let actualTime = drawable.presentedTime
    if actualTime == 0.0 {
        // Frame was dropped!
        print("⚠️ Frame dropped")
    } else {
        print("Frame presented at: \(actualTime)")
    }
}
检查帧实际显示的时间:
swift
drawable.addPresentedHandler { drawable in
    let actualTime = drawable.presentedTime
    if actualTime == 0.0 {
        // 帧被丢弃!
        print("⚠️ 帧被丢弃")
    } else {
        print("帧显示时间:\(actualTime)")
    }
}

Frame Pacing Pattern

Frame Pacing 实现范例

swift
class SmoothRenderer: NSObject, MTKViewDelegate {
    private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0  // 60 FPS target

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = view.currentDrawable else { return }

        renderScene(to: drawable)

        // Use frame pacing to ensure consistent intervals
        commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
        commandBuffer.commit()
    }

    func adjustTargetFrameRate(canSustain fps: Int) {
        switch fps {
        case 90...:
            targetFrameDuration = 1.0 / 120.0
        case 50...:
            targetFrameDuration = 1.0 / 60.0
        default:
            targetFrameDuration = 1.0 / 30.0
        }
    }
}

swift
class SmoothRenderer: NSObject, MTKViewDelegate {
    private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0  // 目标60FPS

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = view.currentDrawable else { return }

        renderScene(to: drawable)

        // 使用Frame Pacing确保间隔一致
        commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
        commandBuffer.commit()
    }

    func adjustTargetFrameRate(canSustain fps: Int) {
        switch fps {
        case 90...:
            targetFrameDuration = 1.0 / 120.0
        case 50...:
            targetFrameDuration = 1.0 / 60.0
        default:
            targetFrameDuration = 1.0 / 30.0
        }
    }
}

Part 8: Understanding Hitches

第八部分:理解卡顿(Hitches)

Render Loop Phases

渲染循环阶段

Frame lifecycle: Begin Time → Commit Deadline → Presentation Time
  1. App Process (CPU): Handle events, compute UI updates, Core Animation commit
  2. Render Server (CPU+GPU): Transform UI to bitmap, render to buffer
  3. Display Driver: Swap buffer to screen at vsync
At 120Hz, each phase has ~8.33ms. Miss any deadline = hitch.
帧生命周期:开始时间 → 提交截止时间 → 显示时间
  1. 应用进程(CPU):处理事件、计算UI更新、Core Animation提交
  2. 渲染服务器(CPU+GPU):将UI转换为位图、渲染到缓冲区
  3. 显示驱动:在Vsync时交换缓冲区到屏幕
在120Hz下,每个阶段约有8.33ms。错过任何截止时间都会导致卡顿。

Commit Hitch vs Render Hitch

提交卡顿 vs 渲染卡顿

Commit Hitch: App process misses commit deadline
  • Cause: Main thread work takes too long
  • Fix: Move work off main thread, reduce view complexity
Render Hitch: Render server misses presentation deadline
  • Cause: GPU work too complex (blur, shadows, layers)
  • Fix: Simplify visual effects, reduce overdraw
提交卡顿:应用进程错过提交截止时间
  • 原因:主线程任务耗时过长
  • 修复:将任务移到后台线程、降低视图复杂度
渲染卡顿:渲染服务器错过显示截止时间
  • 原因:GPU任务过于复杂(模糊、阴影、图层)
  • 修复:简化视觉效果、减少过度绘制

Double vs Triple Buffering

双缓冲 vs 三缓冲

Double Buffer (default):
  • Frame lifetime: 2 vsync intervals
  • Tighter deadlines
  • Lower latency
Triple Buffer (system may enable):
  • Frame lifetime: 3 vsync intervals
  • Render server gets 2 vsync intervals
  • Higher latency but more headroom
The system automatically switches to triple buffering to recover from render hitches.
双缓冲(默认)
  • 帧生命周期:2个Vsync间隔
  • 截止时间更严格
  • 延迟更低
三缓冲(系统可能启用)
  • 帧生命周期:3个Vsync间隔
  • 渲染服务器获得2个Vsync间隔
  • 延迟更高但有更多缓冲空间
系统会自动切换到三缓冲以从渲染卡顿中恢复。

Hitch Duration

卡顿持续时间

Expected Frame Lifetime = Begin Time → Presentation Time
Actual Frame Lifetime = Begin Time → Actual Vsync

Hitch Duration = Actual - Expected
If hitch duration > 0, the frame was late and previous frame stayed onscreen longer.

预期帧生命周期 = 开始时间 → 显示时间
实际帧生命周期 = 开始时间 → 实际Vsync时间

卡顿持续时间 = 实际时间 - 预期时间
如果卡顿持续时间>0,说明帧延迟,上一帧会在屏幕上停留更长时间。

Part 9: Measurement

第九部分:测量

UIScreen Lies, Actual Presentation Tells Truth

UIScreen数据不可靠,实际显示才是真相

swift
// ❌ This says 120 even when system caps you to 60
let maxFPS = UIScreen.main.maximumFramesPerSecond
// Reports capability, not actual rate!

// ✅ Measure from CADisplayLink timing
@objc func displayLinkCallback(_ link: CADisplayLink) {
    // Time available to prepare next frame
    let workingTime = link.targetTimestamp - CACurrentMediaTime()

    // Actual interval since last callback
    if lastTimestamp > 0 {
        let interval = link.timestamp - lastTimestamp
        let actualFPS = 1.0 / interval
    }
    lastTimestamp = link.timestamp
}
swift
// ❌ 即使系统限制为60fps,此值仍会显示120
let maxFPS = UIScreen.main.maximumFramesPerSecond
// 报告的是设备能力,而非实际帧率!

// ✅ 通过CADisplayLink时序测量
@objc func displayLinkCallback(_ link: CADisplayLink) {
    // 准备下一帧的可用时间
    let workingTime = link.targetTimestamp - CACurrentMediaTime()

    // 自上次回调以来的实际间隔
    if lastTimestamp > 0 {
        let interval = link.timestamp - lastTimestamp
        let actualFPS = 1.0 / interval
    }
    lastTimestamp = link.timestamp
}

Metal Performance HUD

Metal性能HUD

Enable on-device real-time performance overlay:
Via Xcode scheme:
  1. Edit Scheme → Run → Diagnostics
  2. Enable "Show Graphics Overview"
  3. Optionally enable "Log Graphics Overview"
Via environment variable:
bash
MTL_HUD_ENABLED=1
Via device settings: Settings → Developer → Graphics HUD → Show Graphics HUD
HUD shows:
  • FPS (average)
  • GPU time per frame
  • Frame interval chart (last 120 frames)
  • Memory usage
在设备上启用实时性能覆盖层:
通过Xcode scheme:
  1. 编辑Scheme → 运行 → 诊断
  2. 启用“显示图形概览”
  3. 可选启用“记录图形概览”
通过环境变量:
bash
MTL_HUD_ENABLED=1
通过设备设置: 设置 → 开发者 → 图形HUD → 显示图形HUD
HUD显示内容:
  • FPS(平均值)
  • 每帧GPU时间
  • 帧间隔图表(最近120帧)
  • 内存使用情况

Production Telemetry with MetricKit

使用MetricKit进行生产环境遥测

Monitor hitches in production:
swift
import MetricKit

class MetricsManager: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            if let animationMetrics = payload.animationMetrics {
                // Ratio of time spent hitching during scroll
                let scrollHitchRatio = animationMetrics.scrollHitchTimeRatio

                // Ratio of time spent hitching in all animations
                if #available(iOS 17.0, *) {
                    let hitchRatio = animationMetrics.hitchTimeRatio
                }

                analyzeHitchMetrics(scrollHitchRatio: scrollHitchRatio)
            }
        }
    }
}

// Register for metrics
MXMetricManager.shared.add(metricsManager)
What to track:
  • scrollHitchTimeRatio
    : Time spent hitching while scrolling (UIScrollView only)
  • hitchTimeRatio
    (iOS 17+): Time spent hitching in all tracked animations

在生产环境中监控卡顿:
swift
import MetricKit

class MetricsManager: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            if let animationMetrics = payload.animationMetrics {
                // 滚动期间卡顿时间占比
                let scrollHitchRatio = animationMetrics.scrollHitchTimeRatio

                // 所有跟踪动画中卡顿时间占比(iOS 17+)
                if #available(iOS 17.0, *) {
                    let hitchRatio = animationMetrics.hitchTimeRatio
                }

                analyzeHitchMetrics(scrollHitchRatio: scrollHitchRatio)
            }
        }
    }
}

// 注册指标监听
MXMetricManager.shared.add(metricsManager)
需要跟踪的指标:
  • scrollHitchTimeRatio
    :滚动期间卡顿时间占比(仅UIScrollView)
  • hitchTimeRatio
    (iOS 17+):所有跟踪动画中卡顿时间占比

Part 10: Quick Diagnostic Checklist

第十部分:快速诊断清单

When debugging frame rate issues:
StepCheckFix
1Info.plist key present? (iPhone)Add
CADisableMinimumFrameDurationOnPhone
2Limit Frame Rate off?Settings → Accessibility → Motion
3Low Power Mode off?Settings → Battery
4Adaptive Power off? (iPhone 17+)Settings → Battery → Power Mode
5preferredFramesPerSecond = 120?Set explicitly on MTKView
6preferredFrameRateRange set?Configure on CADisplayLink
7GPU frame time < 8.33ms?Profile with Metal HUD or Instruments
8Frame pacing consistent?Use present(afterMinimumDuration:)
9Hitches in production?Monitor with MetricKit

调试帧率问题时:
步骤检查项修复方法
1是否存在Info.plist键?(iPhone)添加
CADisableMinimumFrameDurationOnPhone
2是否关闭限制帧率?设置→辅助功能→动态效果
3是否关闭低电量模式?设置→电池
4是否关闭自适应功耗?(iPhone 17+)设置→电池→功耗模式
5preferredFramesPerSecond是否设为120?在MTKView上显式设置
6是否设置preferredFrameRateRange?在CADisplayLink上配置
7GPU帧时间是否<8.33ms?使用Metal HUD或Instruments分析
8Frame Pacing是否一致?使用present(afterMinimumDuration:)
9生产环境是否存在卡顿?使用MetricKit监控

Part 11: Common Patterns

第十一部分:常见实现范例

Pattern: Adaptive Frame Rate with Thermal Awareness

范例:带热状态感知的自适应帧率

swift
class AdaptiveRenderer: NSObject, MTKViewDelegate {
    private var recentFrameTimes: [Double] = []
    private let sampleCount = 30
    private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = view.currentDrawable else { return }

        let startTime = CACurrentMediaTime()
        renderScene(to: drawable)
        let frameTime = (CACurrentMediaTime() - startTime) * 1000

        updateTargetRate(frameTime: frameTime, view: view)

        commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
        commandBuffer.commit()
    }

    private func updateTargetRate(frameTime: Double, view: MTKView) {
        recentFrameTimes.append(frameTime)
        if recentFrameTimes.count > sampleCount {
            recentFrameTimes.removeFirst()
        }

        let avgFrameTime = recentFrameTimes.reduce(0, +) / Double(recentFrameTimes.count)
        let thermal = ProcessInfo.processInfo.thermalState
        let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled

        // Constrain based on what we can sustain AND system state
        if lowPower || thermal >= .serious {
            view.preferredFramesPerSecond = 30
            targetFrameDuration = 1.0 / 30.0
        } else if avgFrameTime < 7.0 && thermal == .nominal {
            view.preferredFramesPerSecond = 120
            targetFrameDuration = 1.0 / 120.0
        } else if avgFrameTime < 14.0 {
            view.preferredFramesPerSecond = 60
            targetFrameDuration = 1.0 / 60.0
        } else {
            view.preferredFramesPerSecond = 30
            targetFrameDuration = 1.0 / 30.0
        }
    }
}
swift
class AdaptiveRenderer: NSObject, MTKViewDelegate {
    private var recentFrameTimes: [Double] = []
    private let sampleCount = 30
    private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = view.currentDrawable else { return }

        let startTime = CACurrentMediaTime()
        renderScene(to: drawable)
        let frameTime = (CACurrentMediaTime() - startTime) * 1000

        updateTargetRate(frameTime: frameTime, view: view)

        commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
        commandBuffer.commit()
    }

    private func updateTargetRate(frameTime: Double, view: MTKView) {
        recentFrameTimes.append(frameTime)
        if recentFrameTimes.count > sampleCount {
            recentFrameTimes.removeFirst()
        }

        let avgFrameTime = recentFrameTimes.reduce(0, +) / Double(recentFrameTimes.count)
        let thermal = ProcessInfo.processInfo.thermalState
        let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled

        // 根据可维持的帧率和系统状态进行限制
        if lowPower || thermal >= .serious {
            view.preferredFramesPerSecond = 30
            targetFrameDuration = 1.0 / 30.0
        } else if avgFrameTime < 7.0 && thermal == .nominal {
            view.preferredFramesPerSecond = 120
            targetFrameDuration = 1.0 / 120.0
        } else if avgFrameTime < 14.0 {
            view.preferredFramesPerSecond = 60
            targetFrameDuration = 1.0 / 60.0
        } else {
            view.preferredFramesPerSecond = 30
            targetFrameDuration = 1.0 / 30.0
        }
    }
}

Pattern: Frame Drop Detection

范例:帧丢弃检测

swift
class FrameDropMonitor {
    private var expectedPresentTime: CFTimeInterval = 0
    private var dropCount = 0

    func trackFrame(drawable: MTLDrawable, expectedInterval: CFTimeInterval) {
        drawable.addPresentedHandler { [weak self] drawable in
            guard let self = self else { return }

            if drawable.presentedTime == 0.0 {
                self.dropCount += 1
                print("⚠️ Frame dropped (total: \(self.dropCount))")
            } else if self.expectedPresentTime > 0 {
                let actualInterval = drawable.presentedTime - self.expectedPresentTime
                let variance = abs(actualInterval - expectedInterval)

                if variance > expectedInterval * 0.5 {
                    print("⚠️ Frame timing variance: \(variance * 1000)ms")
                }
            }

            self.expectedPresentTime = drawable.presentedTime
        }
    }
}

swift
class FrameDropMonitor {
    private var expectedPresentTime: CFTimeInterval = 0
    private var dropCount = 0

    func trackFrame(drawable: MTLDrawable, expectedInterval: CFTimeInterval) {
        drawable.addPresentedHandler { [weak self] drawable in
            guard let self = self else { return }

            if drawable.presentedTime == 0.0 {
                self.dropCount += 1
                print("⚠️ 帧被丢弃(总数:\(self.dropCount))")
            } else if self.expectedPresentTime > 0 {
                let actualInterval = drawable.presentedTime - self.expectedPresentTime
                let variance = abs(actualInterval - expectedInterval)

                if variance > expectedInterval * 0.5 {
                    print("⚠️ 帧时序偏差:\(variance * 1000)ms")
                }
            }

            self.expectedPresentTime = drawable.presentedTime
        }
    }
}

Resources

资源

WWDC: 2021-10147, 2018-612, 2022-10083, 2023-10123
Tech Talks: 10855, 10856, 10857 (Hitch deep dives)
Docs: /quartzcore/cadisplaylink, /quartzcore/cametaldisplaylink, /quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays, /xcode/understanding-hitches-in-your-app, /metal/mtldrawable/present(afterminimumduration:), /metrickit/mxanimationmetric
Skills: axiom-energy, axiom-ios-graphics, axiom-metal-migration-ref, axiom-performance-profiling
WWDC:2021-10147, 2018-612, 2022-10083, 2023-10123
技术讲座:10855, 10856, 10857(卡顿深度解析)
文档:/quartzcore/cadisplaylink, /quartzcore/cametaldisplaylink, /quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays, /xcode/understanding-hitches-in-your-app, /metal/mtldrawable/present(afterminimumduration:), /metrickit/mxanimationmetric
技能:axiom-energy, axiom-ios-graphics, axiom-metal-migration-ref, axiom-performance-profiling