axiom-display-performance by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-display-performance针对可变刷新率显示器(ProMotion、iPad Pro、未来设备)上帧率问题的系统性诊断。涵盖渲染循环配置、帧节奏、卡顿机制和生产环境遥测。
关键洞察:"支持 ProMotion" 并不意味着你的应用会自动以 120Hz 运行。你必须正确配置它,考虑系统限制,并确保适当的帧节奏。
在 ProMotion 设备上卡在 60fps 时,按顺序检查以下项目:
关键:除非你添加此键,否则 Core Animation 在 iPhone 上无法访问高于 60Hz 的帧率。
<!-- Info.plist -->
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
没有此键:
preferredFrameRateRange 提示将被忽略广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
何时添加:任何需要 >60Hz 进行游戏、动画或平滑滚动的 iPhone 应用。
这是最常见的原因。 MTKView 的 preferredFramesPerSecond 默认为 60。
// ❌ 错误:隐式 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 | 连续模式,非按需 |
Apple 明确推荐使用 CADisplayLink(而非计时器)进行自定义渲染循环。
// ❌ 错误:基于计时器的渲染循环(漂移,浪费帧时间)
Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in
self.render()
}
// ❌ 错误:默认 CADisplayLink(可能提示 60)
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 提供特殊优先级。如果以这些帧率为目标:
// 30Hz 和 60Hz 获得优先级调度
let prioritizedRange = CAFrameRateRange(
minimum: 30,
maximum: 60,
preferred: 60
)
displayLink.preferredFrameRateRange = prioritizedRange
| 内容类型 | 建议帧率 | 备注 |
|---|---|---|
| 视频播放 | 24-30 Hz | 匹配内容帧率 |
| 滚动 UI | 60-120 Hz | 越高越平滑 |
| 快速游戏 | 60-120 Hz | 匹配渲染能力 |
| 慢速动画 | 30-60 Hz | 节省电量 |
| 静态内容 | 10-24 Hz | 只需最小更新 |
对于需要精确时序控制的 Metal 应用,CAMetalDisplayLink 提供了比 CADisplayLink 更多的控制。
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 - 要渲染到的可绘制对象
// update.targetTimestamp - 完成渲染的截止时间
// update.targetPresentationTimestamp - 帧将显示的时间
guard let drawable = update.drawable else { return }
let workingTime = update.targetTimestamp - CACurrentMediaTime()
// workingTime = 截止时间前可用的秒数
// 渲染到可绘制对象...
renderFrame(to: drawable)
}
}
与 CADisplayLink 的主要区别:
| 特性 | CADisplayLink | CAMetalDisplayLink |
|---|---|---|
| 可绘制对象访问 | 通过图层手动获取 | 在回调中提供 |
| 延迟控制 | 无 | preferredFrameLatency |
| 目标时序 | timestamp/targetTimestamp | + targetPresentationTimestamp |
| 使用场景 | 通用动画 | Metal 专用渲染 |
何时使用 CAMetalDisplayLink:
即使你的代码请求 120fps,系统状态也可能强制限制为 60fps:
将 ProMotion 设备限制为 60fps。
// 以编程方式检查
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// 系统将显示限制为 60Hz
}
// 观察变化
NotificationCenter.default.addObserver(
forName: .NSProcessInfoPowerStateDidChange,
object: nil,
queue: .main
) { _ in
let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
self.adjustRenderingForPowerState(isLowPower)
}
设置 → 辅助功能 → 动态效果 → 限制帧率 将帧率限制为 60fps。
没有 API 可以检测。如果用户报告配置正确但仍为 60fps,请让他们检查此设置。
当设备过热时,系统会限制 120Hz。
// 检查热状态
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()
}
iOS 26 新增:自适应电源在 iPhone 17/17 Pro 上默认开启。即使在 60% 电量时也可能降频。
用于测试的用户操作:设置 → 电池 → 电源模式 → 禁用自适应电源。
没有公开 API 可以检测自适应电源状态。
| 目标 FPS | 帧预算 | 垂直同步间隔 |
|---|---|---|
| 120 | 8.33ms | 每次垂直同步 |
| 90 | 11.11ms | — |
| 60 | 16.67ms | 每第 2 次垂直同步 |
| 30 | 33.33ms | 每第 4 次垂直同步 |
如果你持续超出预算,系统会下降到下一个可持续的帧率。
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()
}
关键:不均匀的帧节奏看起来比一致的较低帧率更糟糕。
// 如果你无法维持 8.33ms,则显式以 60 为目标以获得平滑节奏
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
mtkView.preferredFramesPerSecond = 60
}
即使平均 FPS 良好,不一致的帧时序也会导致可见的抖动。
// 差:尽管平均约 40 FPS,但间隔不一致
帧 1: 25ms
帧 2: 40ms ← 卡顿
帧 3: 25ms
帧 4: 40ms ← 卡顿
// 好:30 FPS 下一致的间隔
帧 1: 33ms
帧 2: 33ms
帧 3: 33ms
帧 4: 33ms
渲染后立即呈现会导致此问题。 使用显式的时序控制。
确保帧之间间隔一致:
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let drawable = view.currentDrawable else { return }
// 渲染到可绘制对象...
// 以帧之间最小 33ms 的间隔呈现(30 FPS 目标)
commandBuffer.present(drawable, afterMinimumDuration: 0.033)
commandBuffer.commit()
}
在特定时间安排呈现:
// 在特定的 Mach 绝对时间呈现
let presentTime = CACurrentMediaTime() + 0.033
commandBuffer.present(drawable, atTime: presentTime)
检查帧实际出现的时间:
drawable.addPresentedHandler { drawable in
let actualTime = drawable.presentedTime
if actualTime == 0.0 {
// 帧被丢弃了!
print("⚠️ 帧被丢弃")
} else {
print("帧呈现于:\(actualTime)")
}
}
class SmoothRenderer: NSObject, MTKViewDelegate {
private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0 // 60 FPS 目标
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let drawable = view.currentDrawable else { return }
renderScene(to: drawable)
// 使用帧节奏确保间隔一致
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
}
}
}
帧生命周期:开始时间 → 提交截止时间 → 呈现时间
在 120Hz 下,每个阶段约有 ~8.33ms。错过任何截止时间 = 卡顿。
提交卡顿:应用进程错过提交截止时间
渲染卡顿:渲染服务器错过呈现截止时间
双缓冲(默认):
三缓冲(系统可能启用):
系统会自动切换到三缓冲以从渲染卡顿中恢复。
预期帧生命周期 = 开始时间 → 呈现时间
实际帧生命周期 = 开始时间 → 实际垂直同步
卡顿持续时间 = 实际 - 预期
如果卡顿持续时间 > 0,则该帧延迟,并且前一帧在屏幕上停留了更长时间。
// ❌ 即使系统将你限制在 60,这也显示 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
}
在设备上启用实时性能叠加层:
通过 Xcode 方案:
通过环境变量:
MTL_HUD_ENABLED=1
通过设备设置: 设置 → 开发者 → 图形 HUD → 显示图形 HUD
HUD 显示:
在生产环境中监控卡顿:
import MetricKit
class MetricsManager: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let animationMetrics = payload.animationMetrics {
// 滚动期间花费在卡顿上的时间比例
let scrollHitchRatio = animationMetrics.scrollHitchTimeRatio
// 所有动画中花费在卡顿上的时间比例
if #available(iOS 17.0, *) {
let hitchRatio = animationMetrics.hitchTimeRatio
}
analyzeHitchMetrics(scrollHitchRatio: scrollHitchRatio)
}
}
}
}
// 注册以接收指标
MXMetricManager.shared.add(metricsManager)
需要跟踪的内容:
scrollHitchTimeRatio:滚动时花费在卡顿上的时间(仅限 UIScrollView)hitchTimeRatio(iOS 17+):所有跟踪动画中花费在卡顿上的时间调试帧率问题时:
| 步骤 | 检查 | 修复 |
|---|---|---|
| 1 | Info.plist 键是否存在?(iPhone) | 添加 CADisableMinimumFrameDurationOnPhone |
| 2 | 限制帧率是否关闭? | 设置 → 辅助功能 → 动态效果 |
| 3 | 低电量模式是否关闭? | 设置 → 电池 |
| 4 | 自适应电源是否关闭?(iPhone 17+) | 设置 → 电池 → 电源模式 |
| 5 | preferredFramesPerSecond = 120? | 在 MTKView 上显式设置 |
| 6 | preferredFrameRateRange 是否设置? | 在 CADisplayLink 上配置 |
| 7 | GPU 帧时间 < 8.33ms? | 使用 Metal HUD 或 Instruments 进行分析 |
| 8 | 帧节奏是否一致? | 使用 present(afterMinimumDuration:) |
| 9 | 生产环境中是否存在卡顿? | 使用 MetricKit 监控 |
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
}
}
}
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
}
}
}
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
每周安装量
94
仓库
GitHub 星标数
601
首次出现
2026年1月21日
安全审计
安装于
opencode80
codex74
claude-code74
gemini-cli73
cursor71
github-copilot70
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.
Check these in order when stuck at 60fps on ProMotion:
Critical : Core Animation won't access frame rates above 60Hz on iPhone unless you add this key.
<!-- Info.plist -->
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
Without this key:
preferredFrameRateRange hints are ignored above 60HzWhen to add : Any iPhone app that needs >60Hz for games, animations, or smooth scrolling.
This is the most common cause. MTKView's preferredFramesPerSecond defaults to 60.
// ❌ 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:
| Property | Value | Why |
|---|---|---|
preferredFramesPerSecond | 120 | Request max rate |
isPaused | false | Don't pause the render loop |
enableSetNeedsDisplay | false | Continuous mode, not on-demand |
Apple explicitly recommends CADisplayLink (not timers) for custom render loops.
// ❌ 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:
// 30Hz and 60Hz get priority scheduling
let prioritizedRange = CAFrameRateRange(
minimum: 30,
maximum: 60,
preferred: 60
)
displayLink.preferredFrameRateRange = prioritizedRange
| 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 |
For Metal apps needing precise timing control, CAMetalDisplayLink provides more control than CADisplayLink.
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 | preferredFrameLatency |
| Target timing | timestamp/targetTimestamp | + targetPresentationTimestamp |
| Use case | General animation | Metal-specific rendering |
When to use CAMetalDisplayLink:
System states can force 60fps even when your code requests 120:
Caps ProMotion devices to 60fps.
// 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)
}
Settings → Accessibility → Motion → Limit Frame Rate caps to 60fps.
No API to detect. If user reports 60fps despite configuration, have them check this setting.
System restricts 120Hz when device overheats.
// 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()
}
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.
| 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.
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()
}
Critical : Uneven frame pacing looks worse than consistent lower rate.
// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
mtkView.preferredFramesPerSecond = 60
}
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.
Ensures consistent spacing between frames:
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()
}
Schedule presentation at specific time:
// Present at specific Mach absolute time
let presentTime = CACurrentMediaTime() + 0.033
commandBuffer.present(drawable, atTime: presentTime)
Check when frames actually appeared:
drawable.addPresentedHandler { drawable in
let actualTime = drawable.presentedTime
if actualTime == 0.0 {
// Frame was dropped!
print("⚠️ Frame dropped")
} else {
print("Frame presented at: \(actualTime)")
}
}
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
}
}
}
Frame lifecycle: Begin Time → Commit Deadline → Presentation Time
At 120Hz, each phase has ~8.33ms. Miss any deadline = hitch.
Commit Hitch : App process misses commit deadline
Render Hitch : Render server misses presentation deadline
Double Buffer (default) :
Triple Buffer (system may enable) :
The system automatically switches to triple buffering to recover from render hitches.
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.
// ❌ 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
}
Enable on-device real-time performance overlay:
Via Xcode scheme:
Via environment variable:
MTL_HUD_ENABLED=1
Via device settings: Settings → Developer → Graphics HUD → Show Graphics HUD
HUD shows:
Monitor hitches in production:
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 animationsWhen debugging frame rate issues:
| Step | Check | Fix |
|---|---|---|
| 1 | Info.plist key present? (iPhone) | Add CADisableMinimumFrameDurationOnPhone |
| 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:) |
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
}
}
}
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
}
}
}
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
Weekly Installs
94
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode80
codex74
claude-code74
gemini-cli73
cursor71
github-copilot70
Swift Actor 线程安全持久化:构建离线优先应用的编译器强制安全数据层
1,700 周安装
| 9 | Hitches in production? | Monitor with MetricKit |