axiom-uikit-animation-debugging について
UIKit 開発者向けに、CAAnimation のタイミング、完了コールバック、描画問題を診断するトラブルシューティングスキルです。症状に沿った手順と対処パターンを示し、実機での確認や Instruments による分析も案内します。
主な機能
- 01アニメーション完了とタイミングの診断
- 02CATransaction とレイヤー状態の確認
- 03アニメーション競合への対処パターン
- 04CADisplayLink によるジェスチャー同期
- 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 の問題は、完了ハンドラーが呼び出されない、タイミングが正しくない、特定の条件下でカクつくといった形で現れます。基本原則 CAAnimation の問題の 90%は、Core Animation のバグではなく、CATransaction のタイミング、レイヤーの状態、またはフレームレートに関する想定が原因です。
危険信号 — CAAnimation の問題を疑う
次のいずれかが見られる場合、デバイスの動作ではなく、アニメーションのロジックを疑ってください。
- 完了ハンドラーがシミュレーターでは呼び出されるが、デバイスでは呼び出されない
- アニメーションの時間(0.5s)と視覚的な時間(1.2s)が一致しない
- iPhone 15 Pro では正しく見えるスプリングアニメーションが、古いデバイスではカクつく
- ジェスチャーとアニメーションを同時に使うと途切れる(個別なら問題ない)
- 完了ハンドラー内の
[weak self]がなぜ必要なのかわからない - ❌ 禁止 実際の動作に「合わせる」ために時間や値をハードコードする
- これを行うと、異なるハードウェアを使うユーザーにデバイス固有のバグを提供することになります
- これを「一時的な修正」や「これで十分」と正当化しないでください
重要な違い シミュレーターではタイミングの問題が見えにくいことがよくあります(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)を適用
- 完了ハンドラーが呼び出されず、かつ isRemovedOnCompletion が true の場合 → パターン 3 を適用
- 完了ハンドラーは呼び出されるが、視覚的にカクつく場合 → まず Instruments でプロファイルすること
- 「おそらくフレームが原因」と推測してはいけません。データで証明してください
- Profile > Core Animation インストゥルメントで、フレームドロップを確実に確認できます
- Instruments を使わない場合は、推測に頼ることになります
診断結果に矛盾がある、または不明瞭な場合
- 停止してください。まだパターンに進んではいけません
- 原因を絞り込むため、print 文を追加してください
- 「診断結果では 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() の前に完了ハンドラーが設定されていることを、print 文で確認してください(33 行目)
- 完了ハンドラーが呼び出されるがタイミングが正しくない場合に限り、パターン 2 に進んでください
-
次にパターン 2(CATransaction と animation.duration の不一致)
- 完了ハンドラーは呼び出されるが、経過時間 != 宣言された時間の場合に限ります
- 必須の最初の手順(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(必須)
❌ 禁止(Strong 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 は、アニメーションが完了するまで完了ハンドラーを保持します
- 完了ハンドラーは、明示的に weak にしない限り、self を強参照します
- 保持サイクルが作成されます: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
理由 同じキーのアニメーションを追加すると、前のアニメーションが置き換えられます。古いアニメーションを削除するか、一意のキーを使用してください。
パターン 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:デバイスによるスプリングアニメーションの違い
❌ 間違い(1 台のデバイス向けにハードコードされています)
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% の例外ケースに該当している
「スキルが機能しなかった」と主張する前に、必ず次の項目を確認してください
- 必須の最初の手順(28〜63 行目)にある 4 つの診断ブロックをすべて実行した
- 診断結果を正確に(ログ、print 文を含めて)貼り付けた
- 「これから分かること」(66〜72 行目)から根本原因を 1 つ特定した
- 判断ツリー(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 時間
- あらゆる場所に print を追加し、シミュレーターでテストし、値をハードコーディングして、リリース後に様子を見る
- 「デバイスのバグかもしれない?」
- フォールバックタイマーとして DispatchQueue.asyncAfter を使用
変更後 体系的な診断で 15~30 分
- 完了ハンドラーの設定を確認(2 分)
- CATransaction によるラップを確認(3 分)
- レイヤーの状態と再生時間の不一致を確認(5 分)
- 根本原因を特定し、パターンを適用(5 分)
- 実機でテスト(状況による)
重要なポイント CAAnimation の問題は、ほぼ常に CATransaction、レイヤーの状態、またはフレームレートに関する思い込みが原因であり、Core Animation のバグが原因ではありません。
最終更新日:2025-11-30 ステータス:プレッシャーシナリオで TDD テスト済み フレームワーク:UIKit CAAnimation