axiom-uikit-animation-debugging by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-uikit-animation-debuggingCAAnimation 问题通常表现为在特定条件下完成处理程序缺失、时序错误或卡顿。核心原则:90% 的 CAAnimation 问题源于 CATransaction 时序、图层状态或帧率假设,而非 Core Animation 本身的错误。
如果你看到以下任何情况,请怀疑是动画逻辑问题而非设备行为:
[weak self] 但不确定原因关键区别:模拟器经常隐藏时序问题(仅 60Hz,无节流)。真实设备会暴露这些问题(可变帧率、CPU 节流、后台压力)。强制要求:发布前必须在真实设备(支持的最旧型号)上测试。
在更改代码之前,务必先运行这些步骤:
// 1. 检查完成处理程序是否触发
animation.completion = { [weak self] finished in
print("🔥 COMPLETION FIRED: finished=\(finished)")
guard let self = self else {
print("🔥 SELF WAS NIL")
return
}
// 原始代码
}
// 2. 检查实际持续时间与声明的对比
let startTime = Date()
let anim = CABasicAnimation(keyPath: "position.x")
anim.duration = 0.5 // 声明值
layer.add(anim, forKey: "test")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.51) {
print("Elapsed: \(Date().timeIntervalSince(startTime))") // 实际值
}
// 3. 检查哪些动画处于活动状态
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. 检查图层状态
print("Layer speed: \(layer.speed)") // != 1.0 表示时序被缩放
print("Layer timeOffset: \(layer.timeOffset)") // != 0 表示动画有偏移
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
在更改任何代码之前,你必须确定哪一个诊断是根本原因:
CAAnimation 问题?
├─ 完成处理程序从不触发?
│ ├─ 仅在模拟器上?
│ │ └─ 模拟器时序不同(60Hz)。在真实设备上测试。
│ ├─ 仅在真实设备上?
│ │ ├─ 检查:isRemovedOnCompletion 和 fillMode
│ │ ├─ 检查:CATransaction 包装
│ │ └─ 检查:应用在动画期间进入后台
│ └─ 在模拟器和设备上都发生?
│ ├─ 检查:完成处理程序在添加动画**之前**设置
│ └─ 检查:[weak self] 确实被捕获(在完成前不为 nil)
│
├─ 持续时间不匹配(声明值 != 视觉值)?
│ ├─ layer.speed != 1.0?
│ │ └─ 某些东西缩放动画持续时间。找到并修复。
│ ├─ 动画是否包装在 CATransaction 中?
│ │ └─ CATransaction.setAnimationDuration() 会覆盖 animation.duration
│ └─ 视觉持续时间是否**长于**声明时间?
│ └─ 模拟器(60Hz)与设备帧率(120Hz)。为真实硬件重新计算。
│
├─ 弹性物理在设备上错误?
│ ├─ 值是否针对某一设备硬编码?
│ │ └─ 使用设备性能等级,而非型号
│ ├─ 阻尼/刚度值是否与质量/刚度值混淆?
│ │ └─ 检查 CASpringAnimation 参数含义
│ └─ 在模拟器上工作但在设备上不工作?
│ └─ 模拟器使用 60Hz。设备可能使用 120Hz。重新计算。
│
└─ 手势 + 动画卡顿?
├─ 动画是否在竞争(相同 keyPath)?
│ └─ 在添加新动画前移除旧动画
├─ 手势是否在动画运行时更新图层?
│ └─ 使用 CADisplayLink 进行同步更新
└─ 手势是否阻塞主线程?
└─ 使用 Instruments > Core Animation 进行性能分析
layer.add(animation, forKey: "myAnimation")
animation.completion = { finished in // ❌ 太晚了!
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")
原因:完成处理程序必须在动画添加到图层之前设置。之后设置无效。
CATransaction.begin()
CATransaction.setAnimationDuration(2.0) // ❌ 覆盖所有动画!
let anim = CABasicAnimation(keyPath: "position")
anim.duration = 0.5 // 这个被忽略
layer.add(anim, forKey: nil)
CATransaction.commit() // 动画耗时 2.0 秒,而非 0.5
let anim = CABasicAnimation(keyPath: "position")
anim.duration = 0.5
layer.add(anim, forKey: nil)
// 无 CATransaction 包装
原因:CATransaction.setAnimationDuration() 影响事务块内的所有动画。仅当你想要统一更改所有动画时才使用它。
let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = 1.0
anim.toValue = 0.0
anim.duration = 0.5
layer.add(anim, forKey: nil)
// 0.5秒后,动画被移除**且**图层恢复到原始状态
anim.isRemovedOnCompletion = false
anim.fillMode = .forwards // 动画后保持最终状态
layer.add(anim, forKey: nil)
// 0.5秒后,动画状态被保留
原因:默认情况下,动画被移除且图层恢复。对于永久性状态更改,设置 isRemovedOnCompletion = false 和 fillMode = .forwards。
anim.completion = { finished in
self.property = "value" // ❌ 保证导致循环引用
}
anim.completion = { [weak self] finished in
guard let self = self else { return }
self.property = "value" // 安全访问
}
// 添加动画 1
let anim1 = CABasicAnimation(keyPath: "position.x")
anim1.toValue = 100
layer.add(anim1, forKey: "slide")
// 稍后,添加动画 2
let anim2 = CABasicAnimation(keyPath: "position.x")
anim2.toValue = 200
layer.add(anim2, forKey: "slide") // ❌ 相同 key,替换了 anim1!
layer.removeAnimation(forKey: "slide") // 先移除旧的
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") // 不同的 key
原因:使用相同 key 添加动画会替换先前的动画。要么移除旧动画,要么使用唯一键。
func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
view.layer.position.x = translation.x // ❌ 同步问题
}
// 单独地:
let anim = CABasicAnimation(keyPath: "position.x")
view.layer.add(anim, forKey: nil) // 因不同步导致卡顿
var displayLink: CADisplayLink?
func startSyncedAnimation() {
displayLink = CADisplayLink(
target: self,
selector: #selector(updateAnimation)
)
displayLink?.add(to: .main, forMode: .common)
}
@objc func updateAnimation() {
// 在同一帧中更新手势和动画
let gesture = currentGesture
let position = calculatePosition(from: gesture)
layer.position = position // 同步更新
}
原因:手势识别器和 CAAnimation 可能以不同的帧率运行。CADisplayLink 将两者同步到屏幕刷新率。
let springAnim = CASpringAnimation()
springAnim.damping = 0.7 // 针对 iPhone 15 Pro 硬编码
springAnim.stiffness = 100
layer.add(springAnim, forKey: nil) // 在 iPhone 12 上卡顿
let springAnim = CASpringAnimation()
// 使用设备性能等级,而非型号
if ProcessInfo.processInfo.processorCount >= 6 {
// 现代 A 系列(A14+)
springAnim.damping = 0.7
springAnim.stiffness = 100
} else {
// 旧款 A 系列
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 |
| 多个动画冲突 | 是否使用相同 key? | 使用唯一键或先 removeAnimation() |
| 处理程序中的 Weak self | 完成处理程序是否正确捕获? | 在完成处理程序中始终使用 [weak self] |
如果你花费了 >30 分钟且动画仍然有问题:
❌ 在添加动画之后设置完成处理程序
layer.add() 之前设置完成处理程序❌ 假设模拟器时序 = 设备时序
❌ 硬编码设备特定值
ProcessInfo.processInfo.processorCount 或测试等级❌ 将动画包装在 CATransaction.setAnimationDuration() 中
❌ 禁止:在完成处理程序中使用强引用 self
[weak self] 配合 guard❌ 在添加新动画前不移除旧动画
layer.removeAnimation(forKey:) 或使用唯一键❌ 忽略 layer.speed 和 layer.timeOffset
之前:CAAnimation 调试每个问题 2-4 小时
之后:通过系统化诊断 15-30 分钟
关键见解:CAAnimation 问题几乎总是 CATransaction、图层状态或帧率假设问题,从来不是 Core Animation 的错误。
最后更新 : 2025-11-30 状态 : 已通过压力场景进行 TDD 测试 框架 : UIKit CAAnimation
每周安装量
108
代码仓库
GitHub 星标数
601
首次出现
Jan 21, 2026
安全审计
安装于
opencode91
codex86
gemini-cli82
claude-code82
cursor79
github-copilot78
CAAnimation issues manifest as missing completion handlers, wrong timing, or jank under specific conditions. Core principle 90% of CAAnimation problems are CATransaction timing, layer state, or frame rate assumptions, not Core Animation bugs.
If you see ANY of these, suspect animation logic not device behavior:
[weak self] in completion handler and you're not sure whyCritical distinction Simulator often hides timing issues (60Hz only, no throttling). Real devices expose them (variable frame rate, CPU throttling, background pressure). MANDATORY: Test on real device (oldest supported model) before shipping.
ALWAYS run these FIRST (before changing code):
// 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
Before changing ANY code, you must identify which ONE diagnostic is the root cause:
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
Always start with Pattern 1 (Completion Handler Basics)
Then Pattern 2 (CATransaction duration mismatch)
Then Pattern 3 (isRemovedOnCompletion)
Patterns 4-7 Apply based on specific symptom (see Decision Tree line 91+)
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")
Why Completion handler must be set before animation is added to layer. Setting after does nothing.
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
Why CATransaction.setAnimationDuration() affects ALL animations in the transaction block. Use it only if you want to change all animations uniformly.
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
Why By default, animations are removed and layer reverts. For permanent state changes, set isRemovedOnCompletion = false and fillMode = .forwards.
anim.completion = { finished in
self.property = "value" // ❌ GUARANTEED retain cycle
}
anim.completion = { [weak self] finished in
guard let self = self else { return }
self.property = "value" // Safe to access
}
// 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")
Or use unique keys:
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
Why Adding animation with same key replaces previous animation. Either remove old animation or use unique keys.
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
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
}
Why Gesture recognizer and CAAnimation may run at different frame rates. CADisplayLink syncs both to screen refresh rate.
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)
Why Spring physics feel different at 60Hz vs 120Hz. Use device class (core count, GPU) not model.
| Issue | Check | Fix |
|---|---|---|
| Completion never fires | Set handler BEFORE add() | Move completion = before add() |
| Duration mismatch | Is CATransaction wrapping? | Remove CATransaction or remove animation from it |
| Jank on older devices | Is value hardcoded? | Use ProcessInfo for device class |
| Animation disappears | isRemovedOnCompletion? | Set to false, use |
If you've spent >30 minutes and the animation is still broken:
❌ Setting completion handler AFTER adding animation
layer.add()❌ Assuming simulator timing = device timing
❌ Hardcoding device-specific values
ProcessInfo.processInfo.processorCount or test class❌ Wrapping animation in CATransaction.setAnimationDuration()
❌ FORBIDDEN: Using strong self in completion handler
[weak self] with guard❌ Not removing old animation before adding new
layer.removeAnimation(forKey:) first or use unique keys❌ Ignoring layer.speed and layer.timeOffset
Before CAAnimation debugging 2-4 hours per issue
After 15-30 minutes with systematic diagnosis
Key insight CAAnimation issues are almost always CATransaction, layer state, or frame rate assumptions, never Core Animation bugs.
Last Updated : 2025-11-30 Status : TDD-tested with pressure scenarios Framework : UIKit CAAnimation
Weekly Installs
108
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode91
codex86
gemini-cli82
claude-code82
cursor79
github-copilot78
TanStack Query v5 完全指南:React 数据管理、乐观更新、离线支持
2,500 周安装
fillMode = .forwards| Gesture + animation jank | Synced updates? | Use CADisplayLink |
| Multiple animations conflict | Same key? | Use unique keys or removeAnimation() first |
| Weak self in handler | Completion captured correctly? | Always use [weak self] in completion |