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. 02CATransaction とレイヤー状態の確認
  3. 03アニメーション競合への対処パターン
  4. 04CADisplayLink によるジェスチャー同期
  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 の問題は、完了ハンドラーが呼び出されない、タイミングが正しくない、特定の条件下でカクつくといった形で現れます。基本原則 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 つ特定してください。

  1. 完了ハンドラーは呼び出されるが、経過時間 != 宣言された時間の場合 → パターン 2(CATransaction)を適用
  2. 完了ハンドラーが呼び出されず、かつ isRemovedOnCompletion が true の場合 → パターン 3 を適用
  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(完了ハンドラーの基本)から始めてください

    • 完了ハンドラーが一度も呼び出されない場合 → パターン 1
    • add() の前に完了ハンドラーが設定されていることを、print 文で確認してください(33 行目)
    • 完了ハンドラーが呼び出されるがタイミングが正しくない場合に限り、パターン 2 に進んでください
  2. 次にパターン 2(CATransaction と animation.duration の不一致)

    • 完了ハンドラーは呼び出されるが、経過時間 != 宣言された時間の場合に限ります
    • 必須の最初の手順(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(必須)
❌ 禁止(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 分以上費やしてもアニメーションがまだ壊れている場合:

停止してください。次のいずれかに該当します
  1. 必須の手順を飛ばした(最もよくある原因です)
  2. 診断結果を誤って解釈した
  3. 症状に合わないパターンを適用した
  4. 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

コメント