axiom-display-performance
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDisplay 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:
- Info.plist key missing? (iPhone only) → Part 2
- Render loop configured for 60? (MTKView defaults, CADisplayLink) → Part 3
- System caps enabled? (Low Power Mode, Limit Frame Rate, Thermal) → Part 5
- Frame time > 8.33ms? (Can't sustain 120fps) → Part 6
- Frame pacing issues? (Micro-stuttering despite good FPS) → Part 7
- Measuring wrong thing? (UIScreen vs actual presentation) → Part 9
当ProMotion设备上应用卡在60fps时,请按以下顺序排查:
- 缺少Info.plist键?(仅iPhone)→ 第二部分
- 渲染循环配置为60fps?(MTKView默认值、CADisplayLink)→ 第三部分
- 系统限制开启?(低电量模式、限制帧率、过热降频)→ 第五部分
- 帧时间>8.33ms?(无法维持120fps)→ 第六部分
- Frame Pacing存在问题?(平均FPS正常但存在微卡顿)→ 第七部分
- 测量对象错误?(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 hints are ignored above 60Hz
preferredFrameRateRange - 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/>如果缺少此键:
- 你的设置在60Hz以上会被忽略
preferredFrameRateRange - 其他动画可能会影响CADisplayLink的回调频率
- iPad Pro不需要此键
添加场景:任何需要超过60Hz帧率的iPhone应用,如游戏、动画或流畅滚动场景。
Part 3: Render Loop Configuration
第三部分:渲染循环配置
MTKView Defaults to 60fps
MTKView默认帧率为60fps
This is the most common cause. MTKView's defaults to 60.
preferredFramesPerSecondswift
// ❌ 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 = selfCritical settings for continuous high-rate rendering:
| Property | Value | Why |
|---|---|---|
| | Request max rate |
| | Don't pause the render loop |
| | Continuous mode, not on-demand |
这是最常见的原因。MTKView的默认值为60。
preferredFramesPerSecondswift
// ❌ 错误:隐式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持续高帧率渲染的关键设置:
| 属性 | 值 | 原因 |
|---|---|---|
| | 请求最高帧率 |
| | 不要暂停渲染循环 |
| | 持续模式,而非按需模式 |
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 = prioritizedRangeApple明确推荐使用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 = prioritizedRangeSuggested Frame Rates by Content Type
按内容类型推荐的帧率
| Content Type | Suggested Rate | Notes |
|---|---|---|
| Video playback | 24-30 Hz | Match content frame rate |
| Scrolling UI | 60-120 Hz | Higher = smoother |
| Fast games | 60-120 Hz | Match rendering capability |
| Slow animations | 30-60 Hz | Save power |
| Static content | 10-24 Hz | Minimal updates needed |
| 内容类型 | 推荐帧率 | 说明 |
|---|---|---|
| 视频播放 | 24-30 Hz | 匹配内容帧率 |
| 滚动UI | 60-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, provides more control than CADisplayLink.
CAMetalDisplayLinkswift
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:
| Feature | CADisplayLink | CAMetalDisplayLink |
|---|---|---|
| Drawable access | Manual via layer | Provided in callback |
| Latency control | None | |
| Target timing | timestamp/targetTimestamp | + targetPresentationTimestamp |
| Use case | General animation | Metal-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应用,比CADisplayLink提供更多控制。
CAMetalDisplayLinkswift
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的主要区别:
| 特性 | CADisplayLink | CAMetalDisplayLink |
|---|---|---|
| Drawable访问 | 通过图层手动获取 | 在回调中提供 |
| 延迟控制 | 无 | |
| 目标时序 | 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 FPS | Frame Budget | Vsync Interval |
|---|---|---|
| 120 | 8.33ms | Every vsync |
| 90 | 11.11ms | — |
| 60 | 16.67ms | Every 2nd vsync |
| 30 | 33.33ms | Every 4th vsync |
If you consistently exceed budget, system drops to next sustainable rate.
| 目标FPS | 帧时间预算 | Vsync间隔 |
|---|---|---|
| 120 | 8.33ms | 每个Vsync |
| 90 | 11.11ms | — |
| 60 | 16.67ms | 每2个Vsync |
| 30 | 33.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: 33msPresenting 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
- App Process (CPU): Handle events, compute UI updates, Core Animation commit
- Render Server (CPU+GPU): Transform UI to bitmap, render to buffer
- Display Driver: Swap buffer to screen at vsync
At 120Hz, each phase has ~8.33ms. Miss any deadline = hitch.
帧生命周期:开始时间 → 提交截止时间 → 显示时间
- 应用进程(CPU):处理事件、计算UI更新、Core Animation提交
- 渲染服务器(CPU+GPU):将UI转换为位图、渲染到缓冲区
- 显示驱动:在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 - ExpectedIf 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:
- Edit Scheme → Run → Diagnostics
- Enable "Show Graphics Overview"
- Optionally enable "Log Graphics Overview"
Via environment variable:
bash
MTL_HUD_ENABLED=1Via 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:
- 编辑Scheme → 运行 → 诊断
- 启用“显示图形概览”
- 可选启用“记录图形概览”
通过环境变量:
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:
- : Time spent hitching while scrolling (UIScrollView only)
scrollHitchTimeRatio - (iOS 17+): Time spent hitching in all tracked animations
hitchTimeRatio
在生产环境中监控卡顿:
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)需要跟踪的指标:
- :滚动期间卡顿时间占比(仅UIScrollView)
scrollHitchTimeRatio - (iOS 17+):所有跟踪动画中卡顿时间占比
hitchTimeRatio
Part 10: Quick Diagnostic Checklist
第十部分:快速诊断清单
When debugging frame rate issues:
| Step | Check | Fix |
|---|---|---|
| 1 | Info.plist key present? (iPhone) | Add |
| 2 | Limit Frame Rate off? | Settings → Accessibility → Motion |
| 3 | Low Power Mode off? | Settings → Battery |
| 4 | Adaptive Power off? (iPhone 17+) | Settings → Battery → Power Mode |
| 5 | preferredFramesPerSecond = 120? | Set explicitly on MTKView |
| 6 | preferredFrameRateRange set? | Configure on CADisplayLink |
| 7 | GPU frame time < 8.33ms? | Profile with Metal HUD or Instruments |
| 8 | Frame pacing consistent? | Use present(afterMinimumDuration:) |
| 9 | Hitches in production? | Monitor with MetricKit |
调试帧率问题时:
| 步骤 | 检查项 | 修复方法 |
|---|---|---|
| 1 | 是否存在Info.plist键?(iPhone) | 添加 |
| 2 | 是否关闭限制帧率? | 设置→辅助功能→动态效果 |
| 3 | 是否关闭低电量模式? | 设置→电池 |
| 4 | 是否关闭自适应功耗?(iPhone 17+) | 设置→电池→功耗模式 |
| 5 | preferredFramesPerSecond是否设为120? | 在MTKView上显式设置 |
| 6 | 是否设置preferredFrameRateRange? | 在CADisplayLink上配置 |
| 7 | GPU帧时间是否<8.33ms? | 使用Metal HUD或Instruments分析 |
| 8 | Frame 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