mcpField
A
axiom-uikit-animation-debugging

axiom-uikit-animation-debugging

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

关于 axiom-uikit-animation-debugging

面向 UIKit 开发者的故障排查技能,用于诊断 CAAnimation 的时序、完成回调和渲染问题。它按症状提供诊断步骤与处理模式,并强调在真机验证及使用 Instruments 分析卡顿。

主要功能

  1. 01诊断动画完成回调与时序
  2. 02检查 CATransaction 和图层状态
  3. 03提供动画冲突处理模式
  4. 04涵盖 CADisplayLink 手势同步
  5. 05建议真机测试与 Instruments 分析

适用场景

  1. 01排查动画完成回调未触发
  2. 02解决声明时长与视觉时长不符
  3. 03调试旧设备上的动画卡顿
  4. 04查找图层动画冲突

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-uikit-animation-debugging

安装后在 Claude Code 中输入/axiom-uikit-animation-debugging确认已加载。

详细介绍

UIKit 动画调试

概述

CAAnimation 问题表现为完成处理程序缺失、时序错误,或在特定条件下出现卡顿。核心原则 90% 的 CAAnimation 问题源于 CATransaction 时序、图层状态或帧率假设,而不是 Core Animation 的 bug。

警示信号 — 怀疑 CAAnimation 问题

如果出现以下任何情况,请怀疑是动画逻辑问题,而不是设备行为:

  • 完成处理程序在模拟器上触发,但在设备上不触发
  • 动画时长(0.5s)与视觉时长(1.2s)不符
  • 弹簧动画在 iPhone 15 Pro 上看起来正常,但在较旧设备上卡顿
  • 手势与动画同时使用时会导致卡顿(单独使用时正常)
  • 完成处理程序中有 [weak self],而你不确定原因
  • ❌ 禁止 将时长/数值硬编码为“与实际表现一致”
    • 这会将设备特定的 bug 带给使用不同硬件的用户
    • 不要将其合理化为“临时修复”或“够用了”

关键区别 模拟器通常会掩盖时序问题(仅 60Hz,不会降频)。真实设备会暴露这些问题(可变帧率、CPU 降频、后台负载)。必须:发布前在真实设备(所支持的最旧机型)上测试。

必须首先执行的步骤

务必先运行以下内容(再更改代码):

// 1. Check if completion is firing at all
animation.completion = { [weak self] finished in
    print("🔥 COMPLETION FIRED: finished=\(finished)")
    guard let self = self else {
        print("🔥 SELF WAS NIL")
        return
    }
    // original code
}

// 2. Check actual duration vs declared
let startTime = Date()
let anim = CABasicAnimation(keyPath: "position.x")
anim.duration = 0.5  // Declared
layer.add(anim, forKey: "test")

DispatchQueue.main.asyncAfter(deadline: .now() + 0.51) {
    print("Elapsed: \(Date().timeIntervalSince(startTime))")  // Actual
}

// 3. Check what animations are active
if let keys = layer.animationKeys() {
    print("Active animations: \(keys)")
    for key in keys {
        if let anim = layer.animation(forKey: key) {
            print("\(key): duration=\(anim.duration), removed=\(anim.isRemovedOnCompletion)")
        }
    }
}

// 4. Check layer state
print("Layer speed: \(layer.speed)")  // != 1.0 means timing is scaled
print("Layer timeOffset: \(layer.timeOffset)")  // != 0 means animation is offset
这能告诉你什么
  • 出现完成日志 → 处理程序已触发,问题在回调代码中
  • 缺少完成日志 → 处理程序未触发,检查 CATransaction/图层状态
  • 经过时间 == 声明的时长 → 时长正确,视觉卡顿来自帧
  • 经过时间 != 声明的时长 → CATransaction 封装正在改变时长
  • layer.speed != 1.0 → 有某些因素在减慢动画
  • 活动动画列表很长 → 多个动画正在竞争
必须进行的判断

更改任何代码之前,必须确定以下哪一项诊断是根本原因:

  1. 如果完成处理程序已触发,但经过时间 != 声明的时长 → 应用模式 2(CATransaction)
  2. 如果完成处理程序未触发,且 isRemovedOnCompletion 为 true → 应用模式 3
  3. 如果完成处理程序已触发,但视觉效果卡顿 → 必须先使用 Instruments 进行性能分析
    • 不能猜测“可能是帧的问题”——必须用数据证明
    • 使用 Profile > Core Animation 工具可确切显示丢帧情况
    • 如果跳过 Instruments,你就是在猜
如果诊断结果相互矛盾或不明确
  • 停止。暂时不要继续尝试这些模式
  • 添加更多打印语句以缩小原因范围
  • 提问:“诊断结果显示 X 和 Y,但 Z 对不上。我遗漏了什么?”
  • 如果不确定,使用 Instruments > Core Animation 进行性能分析

决策树

CAAnimation problem?
├─ Completion handler never fires?
│  ├─ On simulator only?
│  │  └─ Simulator timing is different (60Hz). Test on real device.
│  ├─ On real device only?
│  │  ├─ Check: isRemovedOnCompletion and fillMode
│  │  ├─ Check: CATransaction wrapping
│  │  └─ Check: app goes to background during animation
│  └─ On both simulator and device?
│     ├─ Check: completion handler is set BEFORE adding animation
│     └─ Check: [weak self] is actually captured (not nil before completion)
│
├─ Duration mismatch (declared != visual)?
│  ├─ Is layer.speed != 1.0?
│  │  └─ Something scaled animation duration. Find and fix.
│  ├─ Is animation wrapped in CATransaction?
│  │  └─ CATransaction.setAnimationDuration() overrides animation.duration
│  └─ Is visual duration LONGER than declared?
│     └─ Simulator (60Hz) vs device frame rate (120Hz). Recalculate for real hardware.
│
├─ Spring physics wrong on device?
│  ├─ Are values hardcoded for one device?
│  │  └─ Use device performance class, not model
│  ├─ Are damping/stiffness values swapped with mass/stiffness?
│  │  └─ Check CASpringAnimation parameter meanings
│  └─ Does it work on simulator but not device?
│     └─ Simulator uses 60Hz. Device may use 120Hz. Recalculate.
│
└─ Gesture + animation jank?
   ├─ Are animations competing (same keyPath)?
   │  └─ Remove old animation before adding new
   ├─ Is gesture updating layer while animation runs?
   │  └─ Use CADisplayLink for synchronized updates
   └─ Is gesture blocking the main thread?
      └─ Profile with Instruments > Core Animation

常见模式

模式选择规则(必须遵守)
每次只应用一个模式,并按以下顺序进行
  1. 始终从模式 1 开始(完成处理程序基础知识)

    • 如果完成处理程序始终不触发 → 模式 1
    • 通过打印语句确认在 add() 之前设置了完成处理程序(第 33 行)
    • 只有在完成处理程序已触发但时序不正确时,才继续应用模式 2
  2. 然后应用模式 2(CATransaction 时长不匹配)

    • 仅在完成处理程序已触发,但经过时间 != 声明的时长时应用
    • 检查“必须首先执行的步骤”中的日志(第 40-47 行)
  3. 然后应用模式 3(isRemovedOnCompletion)

    • 仅在动画完成但视觉状态恢复原状时应用
  4. 根据具体症状应用模式 4-7(参见决策树第 91 行及后续内容)

禁止
  • ❌ 同时应用多个模式(“我来试试模式 2 和模式 4”)
  • ❌ 因为“我已经知道不是这个问题”而跳过模式 1
  • ❌ 不理解每个模式的必要性就将它们组合使用
  • ❌ 随机尝试各种模式,指望其中一个能奏效
模式 1:完成处理程序基础知识
❌ 错误(添加动画后才设置处理程序)
layer.add(animation, forKey: "myAnimation")
animation.completion = { finished in  // ❌ Too late!
    print("Done")
}
✅ 正确(添加动画前设置处理程序)
animation.completion = { [weak self] finished in
    print("🔥 Animation finished: \(finished)")
    guard let self = self else { return }
    self.doNextStep()
}
layer.add(animation, forKey: "myAnimation")

原因 必须先设置完成处理程序,再将动画添加到图层。之后再设置不起作用。


模式 2:CATransaction 与 animation.duration
❌ 错误(CATransaction 覆盖动画时长)
CATransaction.begin()
CATransaction.setAnimationDuration(2.0)  // ❌ Overrides all animations!
let anim = CABasicAnimation(keyPath: "position")
anim.duration = 0.5  // This is ignored
layer.add(anim, forKey: nil)
CATransaction.commit()  // Animation takes 2.0 seconds, not 0.5
✅ 正确(在动画上设置时长,而非事务)
let anim = CABasicAnimation(keyPath: "position")
anim.duration = 0.5
layer.add(anim, forKey: nil)
// No CATransaction wrapping

原因: CATransaction.setAnimationDuration() 会影响事务块中的所有动画。只有在你想统一更改所有动画时才使用它。


模式 3:isRemovedOnCompletion 和 fillMode
❌ 错误(动画完成后消失)
let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = 1.0
anim.toValue = 0.0
anim.duration = 0.5
layer.add(anim, forKey: nil)
// After 0.5s, animation is removed AND layer reverts to original state
✅ 正确(保留动画状态)
anim.isRemovedOnCompletion = false
anim.fillMode = .forwards  // Keep final state after animation
layer.add(anim, forKey: nil)
// After 0.5s, animation state is preserved

原因: 默认情况下,动画会被移除,图层也会恢复原状。若要永久更改状态,请设置 isRemovedOnCompletion = false 和 fillMode = .forwards。


模式 4:在完成回调中使用 Weak Self(强制要求)
❌ 禁止(强引用 self 会造成循环引用)
anim.completion = { finished in
    self.property = "value"  // ❌ GUARANTEED retain cycle
}
✅ 强制要求(始终使用 weak self)
anim.completion = { [weak self] finished in
    guard let self = self else { return }
    self.property = "value"  // Safe to access
}
为什么这是强制要求,而非可选项
  • CAAnimation 会一直保留完成处理程序,直到动画结束
  • 完成处理程序会强引用 self(除非显式使用 weak)
  • 这会形成循环引用:self → animation → completion → self
  • 即使动画生命周期很短(0.3s 也无法避免),仍会发生内存泄漏
禁止使用以下理由
  • ❌ “动画很短,所以不存在循环引用风险”
  • ❌ “我会手动移除动画,所以没问题”
  • ❌ “这段代码路径只会运行一次”
始终在完成处理程序中使用 [weak self]。没有例外。

模式 5:多个动画(相同的 keyPath)
❌ 错误(动画相互冲突)
// Add animation 1
let anim1 = CABasicAnimation(keyPath: "position.x")
anim1.toValue = 100
layer.add(anim1, forKey: "slide")

// Later, add animation 2
let anim2 = CABasicAnimation(keyPath: "position.x")
anim2.toValue = 200
layer.add(anim2, forKey: "slide")  // ❌ Same key, replaces anim1!
✅ 正确(添加前先移除)
layer.removeAnimation(forKey: "slide")  // Remove old first

let anim2 = CABasicAnimation(keyPath: "position.x")
anim2.toValue = 200
layer.add(anim2, forKey: "slide")

或者使用唯一键:

let anim1 = CABasicAnimation(keyPath: "position.x")
layer.add(anim1, forKey: "slide_1")

let anim2 = CABasicAnimation(keyPath: "position.x")
layer.add(anim2, forKey: "slide_2")  // Different key

原因: 添加具有相同 key 的动画会替换之前的动画。请移除旧动画,或使用唯一键。


模式 6:使用 CADisplayLink 同步手势与动画
❌ 错误(手势直接更新,动画以不同速率更新)
func handlePan(_ gesture: UIPanGestureRecognizer) {
    let translation = gesture.translation(in: view)
    view.layer.position.x = translation.x  // ❌ Syncing issue
}

// Separately:
let anim = CABasicAnimation(keyPath: "position.x")
view.layer.add(anim, forKey: nil)  // Jank from desync
✅ 正确(使用 CADisplayLink 进行同步)
var displayLink: CADisplayLink?

func startSyncedAnimation() {
    displayLink = CADisplayLink(
        target: self,
        selector: #selector(updateAnimation)
    )
    displayLink?.add(to: .main, forMode: .common)
}

@objc func updateAnimation() {
    // Update gesture AND animation in same frame
    let gesture = currentGesture
    let position = calculatePosition(from: gesture)
    layer.position = position  // Synchronized update
}

原因: 手势识别器和 CAAnimation 可能以不同的帧率运行。CADisplayLink 会将两者同步到屏幕刷新率。


模式 7:弹簧动画在不同设备上的差异
❌ 错误(针对单一设备硬编码)
let springAnim = CASpringAnimation()
springAnim.damping = 0.7  // Hardcoded for iPhone 15 Pro
springAnim.stiffness = 100
layer.add(springAnim, forKey: nil)  // Janky on iPhone 12
✅ 正确(根据设备性能调整)
let springAnim = CASpringAnimation()

// Use device performance class, not model
if ProcessInfo.processInfo.processorCount >= 6 {
    // Modern A-series (A14+)
    springAnim.damping = 0.7
    springAnim.stiffness = 100
} else {
    // Older A-series
    springAnim.damping = 0.85
    springAnim.stiffness = 80
}

layer.add(springAnim, forKey: nil)

原因: 在 60Hz 和 120Hz 下,弹簧物理效果会有所不同。请使用设备类别(核心数、GPU),而非型号。


快速参考表

问题检查项修复方法
完成回调从未触发是否在 add() 之前设置处理程序?将 completion = 移到 add() 之前
时长不匹配是否使用 CATransaction 包装?移除 CATransaction,或将动画移出其中
较旧设备上出现卡顿值是否硬编码?使用 ProcessInfo 判断设备类别
动画消失isRemovedOnCompletion?设置为 false,并使用 fillMode = .forwards
手势与动画卡顿更新是否同步?使用 CADisplayLink
多个动画相互冲突是否使用相同的键?使用唯一键,或先调用 removeAnimation()
处理程序中的 Weak Self是否正确捕获完成回调?始终在完成回调中使用 [weak self]

卡住超过 30 分钟时

如果你已经花费超过 30 分钟,而动画仍然无法正常工作:

停下。原因可能是
  1. 跳过了某个强制步骤(最常见)
  2. 误解了诊断输出
  3. 针对当前症状套用了错误模式
  4. 属于需要使用 Instruments 进行性能分析的 5% 边缘情况
声称“技能不起作用”之前的强制检查清单
  • 我运行了“强制执行的第一步”中的全部 4 个诊断代码块(第 28–63 行)
  • 我粘贴了诊断的确切输出(日志、print 语句)
  • 我从“这些信息告诉你什么”(第 66–72 行)中确定了一个根本原因
  • 我应用了决策树(第 91 行起)中第一个匹配的模式
  • 我在真实设备上测试了该模式,而不只是模拟器
  • 我通过 print 语句/日志确认该模式已生效
如果所有选项都已勾选但问题仍未解决
  • 必须使用 Instruments > Core Animation 进行性能分析
  • 耗时:30-60 分钟(边缘情况无法避免)
  • 禁止硬编码、asyncAfter 或“先发布再说”
  • 添加任何变通方案前,请先寻求指导
透明说明耗时
  • 模式 1:2-5 分钟
  • 模式 2:3-5 分钟
  • Instruments 性能分析:30-60 分钟(仅适用于边缘情况)
  • 不做性能分析、盲目尝试各种修复:2-4 小时 + 发布有问题版本的风险

常见错误

❌ 添加动画后才设置完成处理程序

  • 设置完成处理程序时机已晚
  • 修复:在 layer.add() 之前设置完成处理程序

❌ 认为模拟器的时序 = 设备的时序

  • 模拟器以 60Hz 运行,设备以 60Hz-120Hz 运行
  • 修复:调整时长前先在真实设备上测试

❌ 硬编码特定设备的值

  • “这个值在 iPhone 15 Pro 上有效” → 在 iPhone 12 上会失败
  • 修复:使用 ProcessInfo.processInfo.processorCount 或测试类

❌ 将动画包装在 CATransaction.setAnimationDuration() 中

  • 会覆盖该事务中所有动画的时长
  • 修复:在动画上设置时长,而不是在事务上设置

❌ 禁止:在完成处理程序中使用强引用 self

  • 必然导致循环引用:self → animation → completion → self
  • 修复:始终使用 [weak self] 并搭配 guard

❌ 添加新动画前未移除旧动画

  • 相同的 keyPath 会替换之前的动画
  • 修复:先调用 layer.removeAnimation(forKey:),或使用唯一键

❌ 忽略 layer.speed 和 layer.timeOffset

  • 它们会在不易察觉的情况下缩放动画时序
  • 修复:如果时序不对,请检查这些值

实际影响

之前:每个问题的 CAAnimation 调试耗时 2-4 小时

  • 到处打印日志、在模拟器上测试、硬编码值,然后发布并祈祷
  • “会不会是设备问题?”
  • 用 DispatchQueue.asyncAfter 作为备用计时器

之后:通过系统化诊断,耗时 15-30 分钟

  • 检查完成处理程序设置(2 分钟)
  • 检查 CATransaction 包装(3 分钟)
  • 检查图层状态和时长不匹配(5 分钟)
  • 找出根本原因,应用相应模式(5 分钟)
  • 在真实设备上测试(耗时不定)

关键见解:CAAnimation 问题几乎总是由 CATransaction、图层状态或帧率假设导致,而不是 Core Animation 的错误。


最后更新:2025-11-30 状态:已通过压力场景的 TDD 测试 框架:UIKit CAAnimation

评论

支持 Markdown