mcpField
A
axiom-display-performance

axiom-display-performance

github.com/charleswiltgen开发1.2k172 次安装今天更新
代码仓库

关于 axiom-display-performance

面向 Apple 平台开发者的显示性能诊断参考,覆盖可变刷新率设备上的渲染循环、帧节奏、卡顿和生产环境指标。提供从 60 fps 限制排查到实际帧呈现测量的系统指导。

主要功能

  1. 01诊断 ProMotion 刷新率受限
  2. 02配置 MTKView 与 CADisplayLink
  3. 03使用 CAMetalDisplayLink 控制时序
  4. 04改善帧节奏并分析卡顿
  5. 05测量实际呈现帧率
  6. 06通过 MetricKit 监控卡顿

适用场景

  1. 01排查 ProMotion 设备停留在 60 fps
  2. 02为 Metal 应用调整渲染时序
  3. 03解决动画或滚动中的微卡顿
  4. 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 时,请按以下顺序检查:

  1. 缺少 Info.plist 键?(仅限 iPhone)→ 第 2 部分
  2. 渲染循环配置为 60?(MTKView 默认设置、CADisplayLink)→ 第 3 部分
  3. 启用了系统限制?(低电量模式、限制帧速率、温控)→ 第 5 部分
  4. 帧耗时 > 8.33ms?(无法持续达到 120fps)→ 第 6 部分
  5. 帧节奏问题?(尽管 FPS 良好,仍有微卡顿)→ 第 7 部分
  6. 测量对象不对?(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

持续高帧率渲染的关键设置:

属性值原因
preferredFramesPerSecond120请求最高帧率
isPausedfalse不要暂停渲染循环
enableSetNeedsDisplayfalse使用连续模式,而非按需模式
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与内容帧率匹配
滚动 UI60-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 的主要区别:

功能CADisplayLinkCAMetalDisplayLink
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 间隔
1208.33ms每次 vsync
9011.11ms—
6016.67ms每隔 2 次 vsync
3033.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 部分:了解卡顿

渲染循环阶段

帧生命周期:开始时间 → 提交截止时间 → 呈现时间

  1. 应用进程(CPU):处理事件、计算 UI 更新、提交 Core Animation
  2. 渲染服务(CPU+GPU):将 UI 转换为位图,渲染到缓冲区
  3. 显示驱动程序:在 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 方案:

  1. 编辑方案 → 运行 → 诊断
  2. 启用 "Show Graphics Overview"
  3. 也可启用 "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+)设置 → 电池 → 电源模式
5preferredFramesPerSecond 是否为 120?在 MTKView 上显式设置
6是否设置了 preferredFrameRateRange?在 CADisplayLink 上进行配置
7GPU 帧时间是否 < 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

评论

支持 Markdown