关于 axiom-display-performance
面向 Apple 平台开发者的显示性能诊断参考,覆盖可变刷新率设备上的渲染循环、帧节奏、卡顿和生产环境指标。提供从 60 fps 限制排查到实际帧呈现测量的系统指导。
主要功能
- 01诊断 ProMotion 刷新率受限
- 02配置 MTKView 与 CADisplayLink
- 03使用 CAMetalDisplayLink 控制时序
- 04改善帧节奏并分析卡顿
- 05测量实际呈现帧率
- 06通过 MetricKit 监控卡顿
适用场景
- 01排查 ProMotion 设备停留在 60 fps
- 02为 Metal 应用调整渲染时序
- 03解决动画或滚动中的微卡顿
- 04监控生产环境的动画卡顿
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-display-performance安装后在 Claude Code 中输入/axiom-display-performance确认已加载。
详细介绍
显示性能
系统性诊断可变刷新率显示屏(ProMotion、iPad Pro、未来设备)的帧率问题。涵盖渲染循环配置、帧节奏、卡顿机制和生产环境遥测。
关键要点:“支持 ProMotion”并不意味着你的应用会自动以 120Hz 运行。你必须正确配置、考虑系统限制,并确保帧节奏合适。
第 1 部分:为什么帧率卡在 60fps
诊断顺序
在 ProMotion 设备上帧率卡在 60fps 时,请按以下顺序检查:
- 缺少 Info.plist 键?(仅限 iPhone)→ 第 2 部分
- 渲染循环配置为 60?(MTKView 默认设置、CADisplayLink)→ 第 3 部分
- 启用了系统限制?(低电量模式、限制帧速率、温控)→ 第 5 部分
- 帧耗时 > 8.33ms?(无法持续达到 120fps)→ 第 6 部分
- 帧节奏问题?(尽管 FPS 良好,仍有微卡顿)→ 第 7 部分
- 测量对象不对?(UIScreen 与实际呈现效果)→ 第 9 部分
第 2 部分:在 iPhone 上启用 ProMotion
重要:在 iPhone 上,除非添加此键,否则 Core Animation 无法访问 60Hz 以上的帧率。
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
如果没有此键:
- 你的
preferredFrameRateRange提示在 60Hz 以上会被忽略 - 其他动画可能会影响你的 CADisplayLink 回调速率
- iPad Pro 不需要此键
何时添加:任何需要 >60Hz 来运行游戏、动画或实现流畅滚动的 iPhone 应用。
第 3 部分:渲染循环配置
MTKView 默认帧率为 60fps
这是最常见的原因。 MTKView 的 preferredFramesPerSecond 默认值为 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
持续高帧率渲染的关键设置:
| 属性 | 值 | 原因 |
|---|---|---|
preferredFramesPerSecond | 120 | 请求最高帧率 |
isPaused | false | 不要暂停渲染循环 |
enableSetNeedsDisplay | false | 使用连续模式,而非按需模式 |
CADisplayLink 配置(iOS 15+)
Apple 明确建议使用 CADisplayLink(而非计时器)来实现自定义渲染循环。
// ❌ 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)
游戏的特殊优先级:iOS 15+ 会对 30Hz 和 60Hz 赋予特殊优先级。如果目标帧率是这些值:
// 30Hz and 60Hz get priority scheduling
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 | 只需极少更新 |
第 4 部分:CAMetalDisplayLink(iOS 17+)
对于需要精确控制时序的 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
)
// 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)
}
}
与 CADisplayLink 的主要区别:
| 功能 | CADisplayLink | CAMetalDisplayLink |
|---|---|---|
| Drawable 访问方式 | 通过图层手动获取 | 在回调中提供 |
| 延迟控制 | 无 | preferredFrameLatency |
| 目标时序 | timestamp/targetTimestamp | + targetPresentationTimestamp |
| 使用场景 | 通用动画 | Metal 专用渲染 |
适合使用 CAMetalDisplayLink 的情况:
- 需要精确控制渲染时序窗口
- 希望尽量降低输入延迟
- 正在开发游戏或高负载 Metal 应用
- 仅部署到 iOS 17+
第 5 部分:系统限制
即使代码请求 120fps,系统状态也可能强制将帧率限制为 60fps:
低电量模式
会将 ProMotion 设备的帧率限制为 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)
}
限制帧速率(辅助功能)
设置 → 辅助功能 → 动态效果 → 限制帧速率会将帧率限制为 60fps。
没有 API 可用于检测此设置。如果用户报告帧率仍为 60fps,请让他们检查此设置。
温控降频
设备过热时,系统会限制 120Hz。
// 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()
}
自适应电源(iOS 26+、iPhone 17)
iOS 26 新增:iPhone 17/17 Pro 默认开启自适应电源。即使电量为 60%,也可能会降频。
测试时的用户操作:设置 → 电池 → 电源模式 → 关闭自适应电源。
没有公开 API 可用于检测自适应电源状态。
第 6 部分:性能预算
帧时间预算
| 目标 FPS | 帧预算 | Vsync 间隔 |
|---|---|---|
| 120 | 8.33ms | 每次 vsync |
| 90 | 11.11ms | — |
| 60 | 16.67ms | 每隔 2 次 vsync |
| 30 | 33.33ms | 每隔 4 次 vsync |
如果持续超出预算,系统会降至下一个可持续的帧率。
测量 GPU 帧时间
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()
}
无法维持 120?请选择更低且稳定的帧率
重要:不均匀的帧节奏比稳定的较低帧率观感更差。
// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
mtkView.preferredFramesPerSecond = 60
}
第 7 部分:帧节奏
微卡顿问题
即使平均 FPS 表现良好,不一致的帧时间也会导致明显的抖动。
// 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
**渲染后立即呈现会导致这种情况。**请使用显式的时间控制。
帧节奏 API
present(afterMinimumDuration:) — 推荐
确保帧与帧之间的间隔保持一致:
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()
}
present(at:) — 精确计时
安排在指定时间呈现:
// Present at specific Mach absolute time
let presentTime = CACurrentMediaTime() + 0.033
commandBuffer.present(drawable, atTime: presentTime)
presentedTime — 验证实际呈现时间
检查帧实际显示的时间:
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
}
}
}
第 8 部分:了解卡顿
渲染循环阶段
帧生命周期:开始时间 → 提交截止时间 → 呈现时间
- 应用进程(CPU):处理事件、计算 UI 更新、提交 Core Animation
- 渲染服务(CPU+GPU):将 UI 转换为位图,渲染到缓冲区
- 显示驱动程序:在 vsync 时将缓冲区切换到屏幕
在 120Hz 下,每个阶段约有 8.33ms。错过任一截止时间都会导致卡顿。
提交卡顿与渲染卡顿
提交卡顿:应用进程错过提交截止时间
- 原因:主线程工作耗时过长
- 修复:将工作移出主线程,降低视图复杂度
渲染卡顿:渲染服务错过呈现截止时间
- 原因:GPU 工作过于复杂(模糊、阴影、图层)
- 修复:简化视觉效果,减少过度绘制
双缓冲与三缓冲
双缓冲(默认):
- 帧生命周期:2 个 vsync 间隔
- 截止时间更紧
- 延迟更低
三缓冲(系统可能启用):
- 帧生命周期:3 个 vsync 间隔
- 渲染服务有 2 个 vsync 间隔
- 延迟更高,但余量更大
系统会自动切换到三缓冲,以从渲染卡顿中恢复。
卡顿时长
Expected Frame Lifetime = Begin Time → Presentation Time
Actual Frame Lifetime = Begin Time → Actual Vsync
Hitch Duration = Actual - Expected
如果卡顿时长 > 0,说明该帧迟到了,上一帧在屏幕上停留的时间更长。
第 9 部分:测量
UIScreen 会说谎,实际呈现情况才说明真相
// ❌ 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
}
Metal 性能 HUD
启用设备端实时性能叠加层:
通过 Xcode 方案:
- 编辑方案 → 运行 → 诊断
- 启用 "Show Graphics Overview"
- 也可启用 "Log Graphics Overview"
通过环境变量:
MTL_HUD_ENABLED=1
通过设备设置: 设置 → 开发者 → Graphics HUD → Show Graphics HUD
HUD 显示:
- FPS(平均值)
- 每帧 GPU 时间
- 帧间隔图表(最近 120 帧)
- 内存用量
使用 MetricKit 进行生产环境遥测
监控生产环境中的卡顿:
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)
要跟踪的指标:
scrollHitchTimeRatio:滚动期间卡顿所占的时间(仅限 UIScrollView)hitchTimeRatio(iOS 17+):所有受跟踪动画中的卡顿时间
第 10 部分:快速诊断清单
调试帧率问题时:
| 步骤 | 检查项 | 修复方法 |
|---|---|---|
| 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 监控 |
第 11 部分:常见模式
模式:结合温度状态的自适应帧率
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(卡顿深入解析)
文档:/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