关于 axiom-uikit-animation-debugging
面向 UIKit 开发者的故障排查技能,用于诊断 CAAnimation 的时序、完成回调和渲染问题。它按症状提供诊断步骤与处理模式,并强调在真机验证及使用 Instruments 分析卡顿。
主要功能
- 01诊断动画完成回调与时序
- 02检查 CATransaction 和图层状态
- 03提供动画冲突处理模式
- 04涵盖 CADisplayLink 手势同步
- 05建议真机测试与 Instruments 分析
适用场景
- 01排查动画完成回调未触发
- 02解决声明时长与视觉时长不符
- 03调试旧设备上的动画卡顿
- 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 → 有某些因素在减慢动画
- 活动动画列表很长 → 多个动画正在竞争
必须进行的判断
更改任何代码之前,必须确定以下哪一项诊断是根本原因:
- 如果完成处理程序已触发,但经过时间 != 声明的时长 → 应用模式 2(CATransaction)
- 如果完成处理程序未触发,且 isRemovedOnCompletion 为 true → 应用模式 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
- 通过打印语句确认在 add() 之前设置了完成处理程序(第 33 行)
- 只有在完成处理程序已触发但时序不正确时,才继续应用模式 2
-
然后应用模式 2(CATransaction 时长不匹配)
- 仅在完成处理程序已触发,但经过时间 != 声明的时长时应用
- 检查“必须首先执行的步骤”中的日志(第 40-47 行)
-
然后应用模式 3(isRemovedOnCompletion)
- 仅在动画完成但视觉状态恢复原状时应用
-
根据具体症状应用模式 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 分钟,而动画仍然无法正常工作:
停下。原因可能是
- 跳过了某个强制步骤(最常见)
- 误解了诊断输出
- 针对当前症状套用了错误模式
- 属于需要使用 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