axiom-memory-debugging について
axiom-memory-debuggingは、アプリのメモリリークやメモリプレッシャーの診断と修正を支援する実践的なスキルです。Instruments、Memory Graph Debugger、よくある保持パターン、コマンドラインでの確認方法を案内します。
主な機能
- 01リーク診断の段階的な手順
- 02Memory Graph DebuggerとInstrumentsのガイド
- 03タイマー、オブザーバー、クロージャ循環の修正
- 04Jetsamとバックグラウンドメモリの指針
- 05コマンドラインでのメモリ診断
活用シーン
- 01アプリのメモリが増え続ける原因の調査
- 02閉じた画面のView Controllerが保持される理由の特定
- 03タイマーや通知の後処理の確認
- 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 → クラッシュ。
必須の最初の手順
コードを読む前に、必ず最初に診断してください:
- デバイスログで「Memory pressure critical」、「Jetsam killed」、「Low Memory」を確認する
- Memory Graph Debugger(以下を参照)を使用する — オブジェクト数の増加を確認できます
- Xcode → Product → Profile → Memory の順に選択する。操作を5回実行し、メモリが増え続けるか確認する
診断結果の見方:横ばい = リークではありません。直線的な増加 = 典型的なリークです。急増後に横ばい = 正常なキャッシュです。急増が積み重なる = 複合的なリークです。
診断を先に行う理由:Instruments でリークを見つける場合:5〜15分。推測で探す場合:45分以上。
リークの検出 — 手順
ステップ1:Memory Graph Debugger(最速)
- シミュレーターでアプリを開く
- Debug → Memory Graph Debugger(またはツールバーアイコン)を選択する
- 「⚠」バッジが付いた紫/赤の円を探す
- それらをクリックすると、Xcode に保持サイクルの連鎖が表示されます
ステップ2:Instruments(詳細な分析)
- Product → Profile(Cmd+I)→「Memory」テンプレートを選択する
- 操作を5〜10回実行する
- 操作のたびにメモリの線が上昇しますか? = リークが確定です
主要な 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 をアタッチできない場合):
- 主要な診断としての deinit ログ — 疑わしいすべてのクラスに
deinit { print("✅ ClassName deallocated") }を追加します。20 回以上セッションを実行します。リークが発生したとき(例: 5 回に 1 回)、deinit メッセージの欠落からどのオブジェクトが保持されているかがわかります。 - トリガーの切り分け — 各ナビゲーションパスを個別にテストします。タイミングに依存する場合は、バックグラウンド/フォアグラウンドを素早く切り替えます。リークする特定のパスまで絞り込みます。
- フィールド診断のための 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(保持サイクルを対話的に調査)