mcpField
A
axiom-memory-debugging

axiom-memory-debugging

github.com/charleswiltgen開発1.2k192 回インストール今日更新
リポジトリ

axiom-memory-debugging について

axiom-memory-debuggingは、アプリのメモリリークやメモリプレッシャーの診断と修正を支援する実践的なスキルです。Instruments、Memory Graph Debugger、よくある保持パターン、コマンドラインでの確認方法を案内します。

主な機能

  1. 01リーク診断の段階的な手順
  2. 02Memory Graph DebuggerとInstrumentsのガイド
  3. 03タイマー、オブザーバー、クロージャ循環の修正
  4. 04Jetsamとバックグラウンドメモリの指針
  5. 05コマンドラインでのメモリ診断

活用シーン

  1. 01アプリのメモリが増え続ける原因の調査
  2. 02閉じた画面のView Controllerが保持される理由の特定
  3. 03タイマーや通知の後処理の確認
  4. 04メモリプレッシャーによるバックグラウンド終了の診断

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/axiom-memory-debuggingを入力して読み込まれたか確認します。

詳細

メモリのデバッグ

概要

メモリの問題は、長時間使用した後にクラッシュとして現れます。基本原則 メモリリークの90%は、3つのパターン(保持サイクル、タイマー/オブザーバーのリーク、コレクションの肥大化)に当てはまります。Instruments を使って体系的に診断し、決して推測しないでください。

プロンプトの例

  • 「エラーメッセージがないのに、アプリが10〜15分後にクラッシュします」
  • 「特定の操作でメモリ使用量が50MBから200MB以上に跳ね上がります。リークですか、それともキャッシュですか?」
  • 「ビューコントローラーが閉じた後も解放されません」
  • 「タイマー/オブザーバーがメモリリークを引き起こしているか、どう確認できますか?」
  • 「アプリが200MB使っていますが、これが正常かどうかわかりません」

危険信号 — メモリリークの可能性が高い

  • メモリが段階的に増加する:50MB → 100MB → 200MB(横ばいにならない)
  • Xcode コンソールにエラーが表示されないまま、アプリが10〜15分後にクラッシュする
  • デバイスログにメモリ警告が繰り返し表示される
  • ビューコントローラーが閉じた後も解放されない(Memory Graph Debugger で確認できます)
  • 同じ操作を複数回実行すると、メモリが直線的に増加する

リークと正常な状態の違い:正常 = 100MBで維持。リーク = 50MB → 100MB → 150MB → 200MB → クラッシュ。

必須の最初の手順

コードを読む前に、必ず最初に診断してください:

  1. デバイスログで「Memory pressure critical」、「Jetsam killed」、「Low Memory」を確認する
  2. Memory Graph Debugger(以下を参照)を使用する — オブジェクト数の増加を確認できます
  3. Xcode → Product → Profile → Memory の順に選択する。操作を5回実行し、メモリが増え続けるか確認する

診断結果の見方:横ばい = リークではありません。直線的な増加 = 典型的なリークです。急増後に横ばい = 正常なキャッシュです。急増が積み重なる = 複合的なリークです。

診断を先に行う理由:Instruments でリークを見つける場合:5〜15分。推測で探す場合:45分以上。

リークの検出 — 手順

ステップ1:Memory Graph Debugger(最速)
  1. シミュレーターでアプリを開く
  2. Debug → Memory Graph Debugger(またはツールバーアイコン)を選択する
  3. 「⚠」バッジが付いた紫/赤の円を探す
  4. それらをクリックすると、Xcode に保持サイクルの連鎖が表示されます
ステップ2:Instruments(詳細な分析)
  1. Product → Profile(Cmd+I)→「Memory」テンプレートを選択する
  2. 操作を5〜10回実行する
  3. 操作のたびにメモリの線が上昇しますか? = リークが確定です

主要な Instruments:Heap Allocations(オブジェクト数)、Leaked Objects(直接検出)、VM Tracker(種類別)。

ステップ3:解放の確認
// Add deinit logging to suspect classes
class MyViewController: UIViewController {
    deinit { print("✅ MyViewController deallocated") }
}

@MainActor
class ViewModel: ObservableObject {
    deinit { print("✅ ViewModel deallocated") }
}

ビューに移動し、別の画面に移動します。「✅ deallocated」と表示されますか? はい = リークなし。いいえ = どこかで保持されています。

Jetsam(メモリ不足による終了)

Jetsam はバグではありません — iOS はメモリを解放するため、バックグラウンドのアプリを終了します。クラッシュではありません(クラッシュログはありません)が、頻繁に終了すると UX が損なわれます。

終了理由原因解決策
Memory Limit Exceededアプリがメモリを使いすぎています最大メモリ使用量を減らす
Jetsamシステムがほかのアプリのためにメモリを必要としましたバックグラウンドのメモリ使用量を<50MBに減らす
Jetsam の発生率を下げる

バックグラウンドに移行したときにキャッシュをクリアします:

// SwiftUI
.onChange(of: scenePhase) { _, newPhase in
    if newPhase == .background {
        imageCache.clearAll()
        URLCache.shared.removeAllCachedResponses()
    }
}
状態の復元

ユーザーが Jetsam の発生に気づかないようにしてください。@SceneStorage(SwiftUI)または stateRestorationActivity(UIKit)を使って、ナビゲーション位置、下書き、スクロール位置を復元します。

MetricKit による監視
class JetsamMonitor: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            guard let exitData = payload.applicationExitMetrics else { continue }
            let bgData = exitData.backgroundExitData
            if bgData.cumulativeMemoryPressureExitCount > 0 {
                // Send to analytics
            }
        }
    }
}
App memory grows while in USE? → Memory leak (fix retention)
App killed in BACKGROUND? → Jetsam (reduce bg memory)

よくあるメモリリークのパターン(修正方法付き)

パターン1:タイマーのリーク(最多 — リークの50%)

[weak self] だけではタイマーリークを修正できない理由:RunLoop はスケジュールされたタイマーを保持します。[weak self] はクロージャーが self を保持するのを防ぐだけです。Timer オブジェクト自体は存在し続け、発火します。RunLoop による保持を解除するには、明示的に invalidate() を呼び出す必要があります。

❌ リーク — タイマーが無効化されない
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
    self?.updateProgress()
}
// Timer never stopped → RunLoop keeps it alive and firing forever
✅ 最善の修正方法:Combine(自動クリーンアップ)
cancellable = Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
    .autoconnect()
    .sink { [weak self] _ in self?.updateProgress() }
// No deinit needed — cancellable auto-cleans when released

別の方法:適切なティアダウンメソッド(viewWillDisappear、停止メソッドなど)と deinit の両方で、timer?.invalidate(); timer = nil を呼び出します。

タイマーによるクラッシュのパターン(EXC_BAD_INSTRUCTION)と RunLoop モードの問題については、axiom-timer-patterns を参照してください。

パターン2:オブザーバー/通知のリーク(リークの25%)
❌ リーク — removeObserver がない
NotificationCenter.default.addObserver(self, selector: #selector(handle),
    name: AVAudioSession.routeChangeNotification, object: nil)
// No matching removeObserver → accumulates listeners
✅ 最善の修正方法:Combine パブリッシャー
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
    .sink { [weak self] _ in self?.handleChange() }
    .store(in: &cancellables)  // Auto-cleanup with viewModel

別の方法:deinit で NotificationCenter.default.removeObserver(self) を呼び出します。

パターン3:クロージャーのキャプチャによるリーク(リークの15%)
❌ リーク — 配列内のクロージャが self をキャプチャ
updateCallbacks.append { [self] track in
    self.refreshUI(with: track)  // Strong capture → cycle
}
✅ 修正: [weak self] を使用する
updateCallbacks.append { [weak self] track in
    self?.refreshUI(with: track)
}

コールバック配列は deinit でクリアしてください。[unowned self] は、self がクロージャより長生きすることが確実な場合にのみ使用します。

パターン 4: 強い参照サイクル
❌ リーク — 相互の強い参照
player?.onPlaybackEnd = { [self] in self.playNextTrack() }
// self → player → closure → self (cycle)
✅ 修正: クロージャ内で [weak self]
player?.onPlaybackEnd = { [weak self] in self?.playNextTrack() }
パターン 5: View/レイアウトのコールバックリーク

ビューコントローラをキャプチャするクロージャの代わりに、AnyObject プロトコルを用いた委譲パターン(weak 参照が可能になります)を使用してください。

パターン 6: PhotoKit の画像リクエストリーク

PHImageManager.requestImage() は、キャンセルが必要な PHImageRequestID を返します。キャンセルしないと、保留中のリクエストがキューに溜まり、スクロール時にメモリを保持し続けます。

class PhotoCell: UICollectionViewCell {
    private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID

    func configure(with asset: PHAsset, imageManager: PHImageManager) {
        if imageRequestID != PHInvalidImageRequestID {
            imageManager.cancelImageRequest(imageRequestID)
        }
        imageRequestID = imageManager.requestImage(for: asset, targetSize: PHImageManagerMaximumSize,
            contentMode: .aspectFill, options: nil) { [weak self] image, _ in
            self?.imageView.image = image
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        if imageRequestID != PHInvalidImageRequestID {
            PHImageManager.default().cancelImageRequest(imageRequestID)
            imageRequestID = PHInvalidImageRequestID
        }
        imageView.image = nil
    }
}

類似のパターン: AVAssetImageGenerator → cancelAllCGImageGeneration()、URLSession.dataTask() → cancel()。

体系的なデバッグワークフロー

フェーズ 1: リークの確認(5 分)

Memory テンプレートでプロファイリングし、操作を 10 回繰り返します。横ばい = リークではない(終了)。着実な増加 = リーク(続行)。

フェーズ 2: リークの特定(10〜15 分)

Memory Graph Debugger → 紫/赤の円 → クリック → 保持サイクルの連鎖を確認します。

よくある箇所: Timer(50%)、Notification/KVO(25%)、コレクション内のクロージャ(15%)、Delegate サイクル(10%)。

フェーズ 3: 修正と検証(5 分)

上記のパターンから修正を適用します。deinit { print("✅ deallocated") } を追加します。Instruments を再度実行します — メモリは横ばいのままであるはずです。

複合リーク

実際のアプリでは、2〜3 個のリークが積み重なっていることがよくあります。最も大きいものから修正し、Instruments を再実行し、横ばいになるまで繰り返します。

再現しない / 断続的なリーク

Instruments が再現を妨げる場合(ハイゼンバグ)、または特定のユーザーデータでのみリークが発生する場合:

軽量な診断(Instruments をアタッチできない場合):

  1. 主要な診断としての deinit ログ — 疑わしいすべてのクラスに deinit { print("✅ ClassName deallocated") } を追加します。20 回以上セッションを実行します。リークが発生したとき(例: 5 回に 1 回)、deinit メッセージの欠落からどのオブジェクトが保持されているかがわかります。
  2. トリガーの切り分け — 各ナビゲーションパスを個別にテストします。タイミングに依存する場合は、バックグラウンド/フォアグラウンドを素早く切り替えます。リークする特定のパスまで絞り込みます。
  3. フィールド診断のための MetricKit — MXMetricPayload.memoryMetrics.peakMemoryUsage で本番環境のピークメモリを監視します。しきい値(例: 400MB)を超えたら警告します。これにより、実際のユーザーデータ量でのみ発生するリークを捕捉できます。

断続的なリークのよくある原因: 重複を先に削除せずに、ライフサイクルイベント(viewWillAppear、applicationDidBecomeActive)で追加された Notification オブザーバー。再登録のたびにリスナーが蓄積されます — 重複が発火するかどうかはタイミング次第です。

TestFlight での検証: 影響を受けるユーザーに診断ビルドを配布します。os_log でメモリのマイルストーンを追加します。修正のデプロイ後、24〜48 時間 MetricKit を監視します。

よくある間違い

  • invalidate() を伴わない [weak self] — Timer が動き続け、CPU を消費します。必ず invalidate() または cancel() を呼び出してください
  • nil 代入を伴わない invalidate — timer?.invalidate() は発火を止めますが、参照は残ります。必ず timer = nil を続けてください
  • ローカルの AnyCancellable — すぐにスコープを抜け、サブスクリプションが破棄されます。Set<AnyCancellable> プロパティに保持してください
  • ログだけの deinit — print 文だけでなく、実際のクリーンアップ(Timer の invalidate、オブザーバーの削除)を追加してください
  • 誤った Instruments テンプレート — Memory は使用量を表示します。Leaks は実際のリークを検出します。両方を使用してください

Instruments クイックリファレンス

シナリオツール注目するポイント
徐々に増加するメモリMemory着実に上昇する線 = リーク
特定のオブジェクトのリークMemory Graph紫/赤の円 = リークしているオブジェクト
直接的なリーク検出Leaks赤い "! Leak" バッジ = リーク確定
種類別のメモリVM Tracker最もメモリを消費しているオブジェクト
キャッシュの挙動Allocations確保されたが解放されていないオブジェクト

CLI によるクイックチェック(Instruments 不使用)

Xcode には、Instruments を開かずにメモリをすばやく診断できる CLI ツールが付属しています。開発中のクイックチェックに使用してください。

leaks — 実行中のプロセスのリークを検出
# Check running app by name (positional argument, not --process)
xcrun leaks MyApp

# Check by PID
xcrun leaks 12345

# Show full stack traces for each leak
xcrun leaks --fullStacks MyApp

# Analyze a memgraph file (from Xcode's Debug Memory Graph)
xcrun leaks MyApp.memgraph

使用するタイミング:Instruments のトレースを記録せずに、リークをすばやくチェックします。疑わしいコードパスを実行した後に実行してください。

heap — 稼働中のヒープ割り当てを調査
# Show heap summary by class (process name is positional)
xcrun heap MyApp

# Show all instances of a specific class
xcrun heap --addresses=MyViewController MyApp

# Sort by size (find biggest consumers)
xcrun heap -s MyApp

# Analyze a memgraph
xcrun heap MyApp.memgraph

使用するタイミング:現在メモリを消費しているものを特定します。Instruments を使わずに、「MyViewController のインスタンスはいくつ存在するか?」を確認できます。

vmmap — 仮想メモリマップ
# Summary view (dirty, clean, swapped)
xcrun vmmap --summary MyApp.memgraph

# Full memory regions
xcrun vmmap MyApp.memgraph

使用するタイミング:メモリの構成を把握します。ダーティページ(データ)、クリーンページ(マップされたファイル)、圧縮メモリを表示します。

stringdups — 重複する文字列を検出
# Find duplicate strings in running process (positional argument)
xcrun stringdups MyApp

# Analyze a memgraph
xcrun stringdups MyApp.memgraph

使用するタイミング:文字列の繰り返し割り当てによるメモリ使用量を削減します。GUI による同等の機能はありません。

malloc_history — 割り当て元を追跡
# Enable malloc logging first: set MallocStackLogging=1 in scheme env vars
# Then query a specific address
xcrun malloc_history <pid> <address>

# Show all allocations sorted by size
xcrun malloc_history <pid> -allBySize

使用するタイミング:リークしたオブジェクトがどこで割り当てられたかを追跡します。スキームで MallocStackLogging=1 環境変数を設定する必要があります。

クイック診断ワークフロー
# 1. Is there a leak? (30 seconds)
xcrun leaks MyApp

# 2. What's on the heap? (30 seconds)
xcrun heap -s MyApp

# 3. Any duplicate strings wasting memory? (30 seconds)
xcrun stringdups MyApp

# 4. Where is memory allocated? (requires memgraph)
xcrun vmmap --summary MyApp.memgraph

所要時間:CLI によるメモリチェック全体で 2 分、Instruments の起動には 10 分以上かかります。

xctrace(ヘッドレス Instruments)
# Record memory trace without GUI
xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output memory.trace

# Record leak detection
xcrun xctrace record --instrument 'Leaks' --attach 'MyApp' --time-limit 30s --output leaks.trace

実際の効果

変更前:タイマーがクリアされていない PlayerViewModel インスタンスが 50 個以上 → 50MB → 200MB → クラッシュ(13 分) 変更後:タイマーを適切に無効化 → 数時間にわたって 50MB で安定

重要なポイント:リークの 90%は、タイマー、オブザーバー、サブスクリプションの停止忘れが原因です。必ず deinit でクリーンアップするか、自動でクリーンアップするリアクティブパターンを使用してください。


リソース

WWDC:2021-10180、2020-10078、2018-416

ドキュメント:/xcode/gathering-information-about-memory-use、/metrickit/mxbackgroundexitdata

スキル:axiom-performance-profiling、axiom-objc-block-retain-cycles、axiom-metrickit-ref、axiom-lldb(保持サイクルを対話的に調査)

コメント