mcpField
A
axiom-swiftui-debugging

axiom-swiftui-debugging

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

axiom-swiftui-debugging について

SwiftUI 開発者向けのデバッグスキルです。ビューの更新停止、プレビューのクラッシュ、レイアウトの問題ごとに診断方法を整理し、原因を段階的に絞り込む判断フローと修正例を示します。

主な機能

  1. 01ビュー更新問題の判断フロー
  2. 02プレビュークラッシュの診断
  3. 03よくあるレイアウト問題の参照
  4. 04状態、バインディング、ビュー ID の診断
  5. 05_printChanges() による更新確認

活用シーン

  1. 01状態変更後にビューが再描画されない問題の調査
  2. 02Xcode プレビューの依存関係不足やクラッシュの診断
  3. 03子ビューの状態が予期せずリセットされる原因の特定
  4. 04ビューの位置、サイズ、重なりの不具合の調査

インストール

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

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

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

詳細

SwiftUI のデバッグ

概要

SwiftUI のデバッグは 3 つのカテゴリに分かれ、それぞれ異なる診断方法を使います。

  1. ビューが更新されない – 変更したのにビューが再描画されません。構造体の変更、バインディングのアイデンティティの喪失、意図しないビューの再生成、オブザーバーパターンの不足のどれが原因かを特定するための判断ツリーです。
  2. プレビューがクラッシュする – プレビューをコンパイルできない、またはすぐにクラッシュします。依存関係の不足、状態の初期化の失敗、Xcode キャッシュの破損を見分けるための判断ツリーです。
  3. レイアウトの問題 – ビューが誤った位置やサイズで表示されたり、予期せず重なったりします。よくある状況に対応するパターンのクイックリファレンスです。

基本原則:観察可能な症状から始め、体系的にテストし、原因を一つずつ排除します。推測しないでください。

必要条件:Xcode 26 以降、iOS 17 以降(iOS 14〜16 のパターンも引き続き有効です。注を参照) 関連スキル:axiom-xcode-debugging(キャッシュ破損の診断)、axiom-swift-concurrency(オブザーバーパターン)、axiom-swiftui-performance(Instruments を使ったプロファイリング)、axiom-swiftui-layout(アダプティブレイアウトのパターン)

プロンプトの例

このスキルが回答するよう設計されている、開発者から実際によく寄せられる質問を紹介します。

1. 「お気に入りボタンをタップするとデータは変わるのに、リスト項目が更新されません」

→ このスキルは判断ツリーを順にたどり、構造体の変更、バインディングの喪失、オブザーバーの不足のどれが原因かを特定します

2. 「プレビューで『Cannot find AppModel in scope』と表示されてクラッシュしますが、コンパイルは問題なく通ります」

→ このスキルでは、.environment() または .environmentObject() を使って不足している依存関係を渡す方法を説明します

3. 「ブール値を切り替えるたびにカウンターが 0 にリセットされます。なぜですか?」

→ このスキルでは、条件分岐による意図しないビューの再生成を特定し、.opacity() を使った修正方法を示します

4. 「@Observable を使っていますが、プロパティを変更してもビューが更新されません」

→ このスキルでは、@Observable オブジェクトで @State を使う場合と通常のプロパティを使う場合について説明します

5. 「テキストフィールドで入力を始めるとフォーカスが外れてしまい、とても困っています」

→ このスキルでは、ForEach のアイデンティティに関する問題を特定し、安定した ID の使い方を示します

6. 「シート/モーダルでローディングアニメーションがいつもスキップされ、完了状態が表示されます」

→ このスキルでは、親の状態変更によって init パラメーターが変わり、@ViewBuilder クロージャーが再初期化される問題を特定します

SwiftUI のデバッグを使用する場面

このスキルを使用する場面
  • ✅ 期待どおりにビューが更新されない
  • ✅ プレビューがクラッシュする、または読み込まれない
  • ✅ 特定のデバイスでレイアウトがおかしく見える
  • ✅ あらゆる場所で @ObservedObject を使って場当たり的に対処したくなる
代わりに axiom-xcode-debugging を使用する場面
  • アプリが実行時にクラッシュする(プレビューではない)
  • ビルドがまったく通らない
  • 環境の診断が必要
代わりに axiom-swift-concurrency を使用する場面
  • async/await または MainActor に関する質問
  • データ競合の警告

デバッグツール

Self._printChanges()

SwiftUI には、ビューの body が呼び出された理由を把握するための、デバッグ専用メソッドが用意されています。

LLDB での使用方法:

// Set breakpoint in view's body
// In LLDB console:
(lldb) expression Self._printChanges()

コード内で一時的に使用(リリース前に削除してください):

var body: some View {
    let _ = Self._printChanges() // Debug only

    Text("Hello")
}

出力の解釈:

MyView: @self changed
  - Means the view value itself changed (parameters passed to view)

MyView: count changed
  - Means @State property "count" triggered the update

MyView: (no output)
  - Body not being called; view not updating at all

⚠️ 重要:

  • アンダースコアが先頭に付いているため、今後のリリースで削除される可能性があります
  • _printChanges の呼び出しを含む状態で App Store に提出しないでください
  • パフォーマンスに影響するため、デバッグ中のみ使用してください

使用する場面:

  • ビュー更新の正確なトリガーを把握したい
  • 予期しない更新を調査している
  • リファクタリング後に依存関係を確認したい

関連項目:複雑な更新パターンについては、SwiftUI Instrument を使用してください。axiom-swiftui-performance スキルを参照してください


ビューが更新されない場合の判断ツリー

よくある悩みは、@State を変更したのにビューが再描画されないことです。根本原因は必ず 4 つのうちのいずれかです。

ステップ 1:最小限のプレビューで再現できますか?
#Preview {
  YourView()
}

はい → 問題はコードにあります。ステップ 2 に進んでください。

いいえ → Xcode の状態またはキャッシュが破損している可能性があります。「プレビューがクラッシュする」セクションに進んでください。

ステップ 2:根本原因を診断する
根本原因 1:ローカルコピーを介した構造体の変更

症状:値を変更しても、ビューが更新されません。

原因:構造体の値をローカル変数に取り込んだか、コピーを変更する関数に渡しています。@State プロパティラッパーが認識するのは、自身のセッターを介した変更のみです。コピーへの変更は SwiftUI から見えません。

// ❌ WRONG: Mutating a local copy — SwiftUI doesn't see it
@State var settings = AppSettings()

func resetTheme() {
    var copy = settings
    copy.theme = .default  // Mutates the copy, not the @State
    // View never updates
}

// ✅ RIGHT: Mutate through the @State property directly
func resetTheme() {
    settings.theme = .default  // @State sees this mutation
}

// ❌ ALSO WRONG: Nested struct mutation through a captured reference
func updateNestedValue(_ s: inout AppSettings) {
    s.theme = .dark
}
// Calling updateNestedValue(&localCopy) won't update the view

修正方法:ローカルコピーや関数パラメーターを介さず、必ず @State プロパティ自体を変更してください。


根本原因 2:バインディングの同一性の喪失

症状:バインディングを子ビューに渡しても、子ビューでの変更が親ビューに反映されません。

原因:.constant() を渡しているか、呼び出すたびに新しいバインディングを作成しているため、双方向の接続が切れています。

// ❌ WRONG: Constant binding is read-only
@State var isOn = false

ToggleChild(value: .constant(isOn))  // Changes ignored

// ❌ WRONG: New binding created each render
@State var name = ""

TextField("Name", text: Binding(
    get: { name },
    set: { name = $0 }
))  // New binding object each time parent renders

// ✅ RIGHT: Pass the actual binding
@State var isOn = false

ToggleChild(value: $isOn)

// ✅ RIGHT (iOS 17+): Use @Bindable for @Observable objects
@Observable class Book {
    var title = "Sample"
    var isAvailable = true
}

struct EditView: View {
    @Bindable var book: Book  // Enables $book.title syntax

    var body: some View {
        TextField("Title", text: $book.title)
        Toggle("Available", isOn: $book.isAvailable)
    }
}

// ✅ ALSO RIGHT (iOS 17+): @Bindable as local variable
struct ListView: View {
    @State private var books = [Book(), Book()]

    var body: some View {
        List(books) { book in
            @Bindable var book = book  // Inline binding
            TextField("Title", text: $book.title)
        }
    }
}

// ✅ RIGHT (pre-iOS 17): Create binding once, not in body
@State var name = ""
@State var nameBinding: Binding<String>?

var body: some View {
    if nameBinding == nil {
        nameBinding = Binding(
            get: { name },
            set: { name = $0 }
        )
    }
    return TextField("Name", text: nameBinding!)
}

修正方法:可能な場合は $state を直接渡してください。@Observable オブジェクト(iOS 17 以降)には @Bindable を使用します。カスタムバインディングを作成する場合(iOS 17 より前)は、body ではなく init で作成するか、キャッシュしてください。


根本原因 3:ビューの意図しない再作成

症状:ビューは更新されますが、@State の値が初期状態にリセットされます。初期値が一瞬表示されます。

原因:ビューの同一性が変わりました(条件分岐によって削除された、コンテナ内で移動した、またはコンテナ自体が再作成されたなど)。その結果、SwiftUI は新しいビューとして扱います。

// ❌ WRONG: View identity changes when condition flips
@State var count = 0

var body: some View {
    VStack {
        if showCounter {
            Counter()  // Gets new identity each time showCounter changes
        }
        Button("Toggle") {
            showCounter.toggle()
        }
    }
}

// Counter gets recreated, @State count resets to 0

// ✅ RIGHT: Preserve identity with opacity or hidden
@State var count = 0

var body: some View {
    VStack {
        Counter()
            .opacity(showCounter ? 1 : 0)
        Button("Toggle") {
            showCounter.toggle()
        }
    }
}

// ✅ ALSO RIGHT: Use id() if you must conditionally show
@State var count = 0

var body: some View {
    VStack {
        if showCounter {
            Counter()
                .id("counter")  // Stable identity
        }
        Button("Toggle") {
            showCounter.toggle()
        }
    }
}

修正方法:条件分岐の代わりに .opacity() を使用するか、安定した識別子を指定して .id() を適用し、ビューの同一性を維持してください。


根本原因 4:オブザーバーパターンの欠落

症状:オブジェクトが変更されても、それを監視しているビューが更新されません。

原因:SwiftUI がオブジェクトの変更を監視する方法を認識していません。

// ❌ WRONG: Property changes don't trigger update
class Model {
    var count = 0  // Not observable
}

struct ContentView: View {
    let model = Model()  // New instance each render, not observable

    var body: some View {
        Text("\(model.count)")
        Button("Increment") {
            model.count += 1  // View doesn't update
        }
    }
}

// ✅ RIGHT (iOS 17+): Use @Observable with @State
@Observable class Model {
    var count = 0  // No @Published needed
}

struct ContentView: View {
    @State private var model = Model()  // @State, not @StateObject

    var body: some View {
        Text("\(model.count)")
        Button("Increment") {
            model.count += 1  // View updates
        }
    }
}

// ✅ RIGHT (iOS 17+): Injected @Observable objects
struct ContentView: View {
    var model: Model  // Just a plain property

    var body: some View {
        Text("\(model.count)")  // View updates when count changes
    }
}

// ✅ RIGHT (iOS 17+): @Observable with environment
@Observable class AppModel {
    var count = 0
}

@main
struct MyApp: App {
    @State private var model = AppModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(model)  // Add to environment
        }
    }
}

struct ContentView: View {
    @Environment(AppModel.self) private var model  // Read from environment

    var body: some View {
        Text("\(model.count)")
    }
}

// ✅ RIGHT (pre-iOS 17): Use @StateObject/ObservableObject
class Model: ObservableObject {
    @Published var count = 0
}

struct ContentView: View {
    @StateObject var model = Model()  // For owned instances

    var body: some View {
        Text("\(model.count)")
        Button("Increment") {
            model.count += 1  // View updates
        }
    }
}

// ✅ RIGHT (pre-iOS 17): Use @ObservedObject for injected instances
struct ContentView: View {
    @ObservedObject var model: Model  // Passed in from parent

    var body: some View {
        Text("\(model.count)")
    }
}

修正方法(iOS 17 以降):クラスに @Observable マクロを使用し、それを @State で保持してください。ビューは読み取ったプロパティへの依存関係を自動的に追跡します。

修正方法(iOS 17 より前):オブジェクトを所有する場合は @StateObject、注入される場合は @ObservedObject、ビュー階層全体で共有する場合は @EnvironmentObject を使用してください。

@Observable が優れている理由(iOS 17 以降):

  • 依存関係を自動的に追跡します(読み取りが発生した場合にのみ更新されます)
  • @Published ラッパーが不要です
  • @StateObject の代わりに @State と組み合わせて使用できます
  • @ObservedObject の代わりに通常のプロパティとして渡せます

関連項目:アプリでのモデルデータの管理


根本原因 5:@ViewBuilder クロージャーの再初期化

症状:.sheet、.fullScreenCover、.popover、または NavigationStack の遷移先にある子ビューで、期待する読み込み状態やアニメーションが表示されず、常に「完了」状態で表示されます。コールバックが親ビューの状態を更新すると、子ビューはデータがすでに読み込まれていたかのように動作します。

原因:親の body が再評価されるたびに、@ViewBuilder クロージャーは再評価されます。親の @State を子ビューの初期化パラメーターとして渡し、コールバックがその同じ状態を変更すると、クロージャーは新しい値で再評価されます。子の @State は維持されます(同一性に基づくライフタイム)が、初期化パラメーターは再計算されます。

// ❌ WRONG: Parent state passed to child, then mutated by child callback
.sheet(item: $sheetData) { _ in
    ChildView(
        savedResponse: cachedResponse,      // Parent state as init param
        onSuccess: { cachedResponse = $0 }  // Callback mutates same state
    )
}
// 1. Sheet opens → ChildView(savedResponse: nil)
// 2. Async completes → onSuccess fires → cachedResponse set
// 3. Parent body re-evaluates → closure re-evaluates
// 4. New ChildView(savedResponse: cachedResponse) — now non-nil
// 5. Child takes "pre-loaded" path → animations always skipped

原因の特定:親ビューと子ビューの両方に let _ = Self._printChanges() を追加してください。子ビュー自身のコールバックが実行された後に子ビューの初期化パラメーターが変わる場合、これが原因です。

修正方法(簡単なものから順に):

  1. 値を子から親に戻さない:変更される状態を初期化パラメーターから削除し、コールバックで親を更新しても、その値を子に戻さないようにします
  2. 状態を分ける:子ビューの表示ロジックと親ビューのキャッシュに、それぞれ別の @State を使用します
  3. 子ビュー側で取得する:親の状態を受け取る代わりに、子ビューが独自のデータソースからデータを取得します
// ✅ Fix 1: Don't pass the mutated state back as init param
.sheet(item: $sheetData) { _ in
    ChildView(
        onSuccess: { cachedResponse = $0 }  // Updates parent, doesn't flow back
    )
}

// ✅ Fix 2: Separate state for display vs cache
@State private var cachedResponse: Response?    // Parent's cache
@State private var responseSnapshot: Response?   // Frozen at sheet open time

Button("Open") {
    responseSnapshot = cachedResponse  // Snapshot once
    sheetData = SheetData()
}
.sheet(item: $sheetData) { _ in
    ChildView(
        savedResponse: responseSnapshot,          // Frozen at open time — not mutated by callback
        onSuccess: { cachedResponse = $0 }
    )
}

重要なポイント:これはシート固有の問題ではなく、あらゆる @ViewBuilder クロージャーに当てはまります。子の @State は再評価後も維持されますが、初期化パラメーターは現在の親の状態に基づいて再計算されます。


決定木の概要
digraph view_not_updating {
    start [label="View not updating?" shape=diamond];
    reproduce [label="Can reproduce in preview?" shape=diamond];
    cause [label="What changed?" shape=diamond];

    start -> reproduce;
    reproduce -> cause [label="yes: bug in code"];
    reproduce -> "Cache/Xcode state → Preview Crashes" [label="no"];

    cause -> "Struct Mutation" [label="modified struct directly"];
    cause -> "Lost Binding Identity" [label="passed binding to child"];
    cause -> "Accidental Recreation" [label="view inside conditional"];
    cause -> "Missing Observer" [label="object changed, view didn't"];
    cause -> "Sheet/Closure Re-Init" [label="closure re-evaluated with changed parent state"];
}

プレビューのクラッシュに関する決定木

プレビューが読み込まれない、またはすぐにクラッシュする場合、根本原因は 3 つに分けられます。

ステップ 1:エラーの内容を確認する
エラータイプ 1:「スコープ内に見つかりません」または「そのようなモジュールはありません」

根本原因:プレビューに必要な依存関係(@EnvironmentObject、@Environment、インポートされたモジュール)が不足しています。

// ❌ WRONG: ContentView needs a model, preview doesn't provide it
struct ContentView: View {
    @EnvironmentObject var model: AppModel

    var body: some View {
        Text(model.title)
    }
}

#Preview {
    ContentView()  // Crashes: model not found
}

// ✅ RIGHT: Provide the dependency
#Preview {
    ContentView()
        .environmentObject(AppModel())
}

// ✅ ALSO RIGHT: Check for missing imports
// If using custom types, make sure they're imported in preview file

#Preview {
    MyCustomView()  // Make sure MyCustomView is defined or imported
}

修正方法:エラーをたどって不足しているものを見つけ、プレビューに渡します。


エラータイプ 2:致命的なエラーまたは無言のクラッシュ(エラーメッセージなし)

根本原因:実行時に状態の初期化に失敗しました。ビューが存在しないデータにアクセスしようとしました。

// ❌ WRONG: Index out of bounds at runtime
struct ListView: View {
    @State var selectedIndex = 10
    let items = ["a", "b", "c"]

    var body: some View {
        Text(items[selectedIndex])  // Crashes: index 10 doesn't exist
    }
}

// ❌ WRONG: Optional forced unwrap fails
struct DetailView: View {
    @State var data: Data?

    var body: some View {
        Text(data!.title)  // Crashes if data is nil
    }
}

// ✅ RIGHT: Safe defaults
struct ListView: View {
    @State var selectedIndex = 0  // Valid index
    let items = ["a", "b", "c"]

    var body: some View {
        if selectedIndex < items.count {
            Text(items[selectedIndex])
        }
    }
}

// ✅ RIGHT: Handle optionals
struct DetailView: View {
    @State var data: Data?

    var body: some View {
        if let data = data {
            Text(data.title)
        } else {
            Text("No data")
        }
    }
}

修正方法:@State の初期化子を確認します。配列の境界、オプショナルのアンラップ、デフォルト値をチェックしてください。


エラータイプ 3:ローカルでは問題なく動作するのにプレビューが読み込まれない

根本原因:Xcode キャッシュの破損です。プレビュープロセスに、コードの古い情報が残っています。

診断チェックリスト:

  • 昨日まではプレビューが動作していて、コードも変更していない → キャッシュの可能性が高い
  • Xcode を再起動すると一時的に直るが、再発する → キャッシュが原因です
  • 同じコードがシミュレーターでは問題なくビルドできるのに、プレビューは失敗する → キャッシュ
  • 無関係な複数のプレビューが同時に失敗する → キャッシュ

修正方法(順番に実行):

  1. プレビューキャンバスを再起動します:Cmd+Option+P
  2. Xcode を完全に再起動します(File → Close Window を選択し、プロジェクトを再度開きます)
  3. 派生データを削除します:rm -rf ~/Library/Developer/Xcode/DerivedData
  4. 再ビルドします:Cmd+B

4 つの手順をすべて実行しても解決しない場合は、キャッシュが原因ではありません。エラータイプ 1 または 2 を確認してください。


決定木のまとめ
digraph preview_crashes {
    start [label="Preview crashes?" shape=diamond];
    error [label="Error message visible?" shape=diamond];

    start -> error;
    error -> "Missing Dependency" [label="'Cannot find in scope'"];
    error -> "State Init Failure" [label="'Fatal error' or silent crash"];
    error -> "Cache Corruption" [label="no error"];
    "Cache Corruption" -> "Restart Preview → Restart Xcode → Nuke DerivedData";
}

レイアウト問題のクイックリファレンス

レイアウトの問題は、通常、見た目からすぐにわかります。症状に合ったパターンを確認してください。

パターン 1:ZStack 内のビューが重なる

症状:ビューが互いに重なり、一部が見えません。

根本原因:Z 軸の順序が正しくないか、表示状態を制御していません。

// ❌ WRONG: Can't see the blue view
ZStack {
    Rectangle().fill(.blue)
    Rectangle().fill(.red)
}

// ✅ RIGHT: Use zIndex to control layer order
ZStack {
    Rectangle().fill(.blue).zIndex(0)
    Rectangle().fill(.red).zIndex(1)
}

// ✅ ALSO RIGHT: Hide instead of removing from hierarchy
ZStack {
    Rectangle().fill(.blue)
    Rectangle().fill(.red).opacity(0.5)
}

パターン 2:GeometryReader のサイズが不自然

症状:ビューが極端に小さくなる、または予想外に画面全体を占めます。

根本原因:GeometryReader は利用可能なスペースに合わせてサイズを決めますが、親ビューがサイズを制約していません。

// ❌ WRONG: GeometryReader expands to fill all available space
VStack {
    GeometryReader { geo in
        Text("Size: \(geo.size)")
    }
    Button("Next") { }
}
// Text takes entire remaining space

// ✅ RIGHT: Constrain the geometry reader
VStack {
    GeometryReader { geo in
        Text("Size: \(geo.size)")
    }
    .frame(height: 100)

    Button("Next") { }
}

パターン 3:セーフエリアの問題

症状:コンテンツがノッチの背後に隠れる、または画面全体のスペースが使われません。

根本原因:.ignoresSafeArea() が誤ったビューに適用されています。

// ❌ WRONG: Only the background ignores safe area
ZStack {
    Color.blue.ignoresSafeArea()
    VStack {
        Text("Still respects safe area")
    }
}

// ✅ RIGHT: Container ignores, children position themselves
ZStack {
    Color.blue
    VStack {
        Text("Can now use full space")
    }
}
.ignoresSafeArea()

// ✅ ALSO RIGHT: Be selective about which edges
ZStack {
    Color.blue
    VStack { ... }
}
.ignoresSafeArea(edges: .horizontal)  // Only horizontal

パターン 4:frame() と fixedSize() の混同

症状:テキストが切り詰められる、ボタンがテキストより大きい、またはサイズの挙動が予測できません。

根本原因:制約を加える frame() と、コンテンツに合わせて拡張する fixedSize() を併用しています。

// ❌ WRONG: fixedSize() overrides frame()
Text("Long text here")
    .frame(width: 100)
    .fixedSize()  // Overrides the frame constraint

// ✅ RIGHT: Use frame() to constrain
Text("Long text here")
    .frame(width: 100, alignment: .leading)
    .lineLimit(1)

// ✅ RIGHT: Use fixedSize() only for natural sizing
VStack(spacing: 0) {
    Text("Small")
        .fixedSize()  // Sizes to text
    Text("Large")
        .fixedSize()
}

パターン 5:モディファイアの順序が重要

症状:パディング、角丸、またはシャドウが意図しない場所に表示されます。

根本原因:各モディファイアは、その上にあるビューをラップして外側へ構築します。.padding().background(.red) は先にパディングを追加し、その後、パディングを含む領域を赤で塗りつぶします。.background(.red).padding() はテキスト領域を赤で塗りつぶした後、外側に透明なパディングを追加します。

// Background INSIDE padding (red fills only text area)
Text("Hello")
    .background(.red)
    .padding()

// Background OUTSIDE padding (red fills padded area too)
Text("Hello")
    .padding()
    .background(.red)

// Shadow on content, then framed
Text("Hello")
    .shadow(radius: 4)  // Shadow on text
    .frame(width: 200)

// Shadow on frame bounds
Text("Hello")
    .frame(width: 200)
    .shadow(radius: 4)  // Shadow on the 200pt frame

ビューのアイデンティティ

ビューのアイデンティティを理解する

SwiftUI はビューのアイデンティティを使って、時間の経過に伴うビューを追跡し、状態を保持し、トランジションをアニメーション化します。状態の保持やアニメーションの問題をデバッグするうえで、アイデンティティを理解することは重要です。

2 種類のアイデンティティ
1. 構造的アイデンティティ(暗黙的)

ビュー階層内の位置によってアイデンティティが決まります。

VStack {
    Text("First")   // Identity: VStack.child[0]
    Text("Second")  // Identity: VStack.child[1]
}

構造的アイデンティティが変わる場合:

if showDetails {
    DetailView()  // Identity changes when condition changes
    SummaryView()
} else {
    SummaryView()  // Same type, different position = different identity
}

問題:SummaryView は毎回再作成されるため、@State の値が失われます。

2. 明示的アイデンティティ

.id() モディファイアを使ってアイデンティティを制御します。

DetailView()
    .id(item.id)  // Explicit identity tied to item

// When item.id changes → SwiftUI treats as different view
// → @State resets
// → Animates transition
よくあるアイデンティティの問題
問題 1:状態が予期せずリセットされる

症状:予期していないタイミングで、@State の値が初期値にリセットされます。

原因:ビューのアイデンティティが変わりました(階層内の位置、または .id() の値が変わりました)。

// ❌ PROBLEM: Identity changes when showDetails toggles
@State private var count = 0

var body: some View {
    VStack {
        if showDetails {
            CounterView(count: $count)  // Position changes
        }
        Button("Toggle") {
            showDetails.toggle()
        }
    }
}

// ✅ FIX: Stable identity with .opacity()
var body: some View {
    VStack {
        CounterView(count: $count)
            .opacity(showDetails ? 1 : 0)  // Same identity always
        Button("Toggle") {
            showDetails.toggle()
        }
    }
}

// ✅ ALSO FIX: Explicit stable ID
var body: some View {
    VStack {
        if showDetails {
            CounterView(count: $count)
                .id("counter")  // Stable ID
        }
        Button("Toggle") {
            showDetails.toggle()
        }
    }
}
問題 2:アニメーションが動作しない

症状:ビューが変化してもアニメーションしません。

原因:アイデンティティが変わったため、SwiftUI は更新ではなく、削除して追加したものとして扱います。

// ❌ PROBLEM: Identity changes with selection
ForEach(items) { item in
    ItemView(item: item)
        .id(item.id + "-\(selectedID)")  // ID changes when selection changes
}

// ✅ FIX: Stable identity
ForEach(items) { item in
    ItemView(item: item, isSelected: item.id == selectedID)
        .id(item.id)  // Stable ID
}
問題 3:データが変わる ForEach

症状:リスト項目が飛び回る、または正しくアニメーションしません。

原因:識別子が一意でない、または変化しています。

// ❌ WRONG: Index-based ID changes when array changes
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
    Text(item.name)
}

// ❌ WRONG: Non-unique IDs
ForEach(items, id: \.category) { item in  // Multiple items per category
    Text(item.name)
}

// ✅ RIGHT: Stable, unique IDs
ForEach(items, id: \.id) { item in
    Text(item.name)
}

// ✅ RIGHT: Make type Identifiable
struct Item: Identifiable {
    let id = UUID()
    var name: String
}

ForEach(items) { item in  // id: \.id implicit
    Text(item.name)
}
.id() を使うべき場合

.id() を使うべきケース:

  • データが根本的に変化したときにビューの再生成を強制する
  • 異なる状態間のトランジションをアニメーション化する
  • 外部依存関係が変化したときに @State をリセットする

例: データ変更時に再生成を強制する:

DetailView(item: item)
    .id(item.id)  // New item → new view → @State resets

.id() を使うべきでないケース:

  • ビューのコンテンツを更新するだけでよい場合(代わりにバインディングを使用してください)
  • 更新の問題を修正しようとしている場合(代わりに根本原因を調査してください)
  • アイデンティティがすでに安定している場合
アイデンティティの問題のデバッグ
1. Self._printChanges()
var body: some View {
    let _ = Self._printChanges()
    // Check if "@self changed" appears when you don't expect
}
2. .id() 修飾子を確認する

コードベースで .id() を検索してください - ID が予期せず変化していませんか?

3. 条件分岐を確認する

if/else 内のビューは位置が変わる → アイデンティティが異なります。

修正: 代わりに .opacity() または安定した .id() を使用してください。

アイデンティティ早見表
症状考えられる原因修正
状態がリセットされるアイデンティティの変化if の代わりに .opacity() を使用する
アニメーションしないアイデンティティの変化.id() を削除するか、安定した ID を使用する
ForEach が飛ぶID が一意でない一意で安定した ID を使用する
予期しない再生成条件分岐による位置の変化明示的な .id() を追加する

関連項目 WWDC21: Demystify SwiftUI


プレッシャーのかかるシナリオと現実世界の制約

締め切りのプレッシャーにさらされていると、問題を修正するのではなく隠すようなショートカットに誘惑されます。

シナリオ 1: 「プレビューがクラッシュし続ける、明日出荷だ」
耳にするかもしれない危険信号
  • 「とにかく全部リビルドしろ」
  • 「DerivedData を削除して気にするな」
  • 「プレビューで検証せずに出荷しろ」
  • 「自分のマシンでは動く、それで十分だ」

危険性: 診断をスキップすると、キャッシュの問題が本番環境で 2 週間後に再発し、ユーザーがクラッシュに遭遇する中でデバッグすることになります。

代わりに行うべきこと(合計 5 分のプロトコル):

  1. Preview Canvas を再起動: Cmd+Option+P(30 秒)
  2. Xcode を再起動(2 分)
  3. DerivedData を削除: rm -rf ~/Library/Developer/Xcode/DerivedData(30 秒)
  4. リビルド: Cmd+B(2 分)
  5. まだ壊れている場合は、上記の依存関係または初期化の決定ツリーを使用してください

所要時間: 5 分の診断 + 2 分の修正 = 合計 7 分

スキップした場合のコスト: 30 分の出荷 + 24 時間のデバッグサイクル = 合計 24 時間以上


シナリオ 2: 「ビューが更新されない、@ObservedObject でラップしてしまおう」
考えるかもしれない危険信号
  • 「どこにでも @ObservedObject を追加すれば直る」
  • 「ObservableObject を応急処置として使う」
  • 「ランダムなプロパティに @Published を追加する」
  • 「おそらくバインディングの問題だ、カスタムバインディングを作ればいい」

危険性: 症状を治療しているだけで、診断していません。同じビューは他のコンテキストでも更新されません。バグを隠しただけです。

代わりに行うべきこと(2 分の診断):

  1. 最小限のプレビューで再現できますか? いいえ → キャッシュの破損(シナリオ 1 を参照)
  2. はいの場合: 各根本原因を順番にテストします:
    • ビューに直接変更している @State がありますか? → 構造体のミューテーション
    • 最近ビューが条件分岐内に移動しましたか? → ビューの再生成
    • 変更された子にバインディングを渡していますか? → バインディングアイデンティティの喪失
    • 上記のいずれにも当てはまらない場合のみ: オブザーバーの欠如
  3. @ObservedObject の応急処置ではなく、実際の根本原因を修正してください

判断原則: 具体的な根本原因を特定できないなら、まだ診断できていません。「問題は構造体のミューテーションである。なぜなら…」と答えられるようになるまでコードを書かないでください。


シナリオ 2b: 「断続的な更新 - 動くときと動かないときがある」
考えがちな危険な兆候
  • 「スレッドの問題に違いないので、あちこちに @MainActor を付けてみよう」
  • 「何かうまくいくまで、@ObservedObject、@State、カスタム Binding を試してみよう」
  • 「DerivedData を削除して、キャッシュの破損が直ることを期待しよう」
  • 「これは直せない。機能なしでリリースしよう」

危険性:推測を2時間続けて、もう疲れ切っています。App Store への提出まであと17時間です。パニックになっています。1分ごとに焦りを感じるあまり、原因の診断をやめて手当たり次第に試し始めてしまいます。

断続的に発生するバグは、正しく診断することが最も重要です。ここで間違った推測をすると、新たなバグが生まれます。壊れたビューに加えて、新たなバグも抱えたままリリースすることになります。App Store に却下され、ローンチに間に合わなくなります。

代わりに行うこと(60分で体系的に診断):

ステップ 1:プレビューで再現する(15分)

  • 問題のあるビューだけを表示する最小限のプレビューを作成します
  • 20回タップまたは操作します
  • 断続的に失敗しますか、一貫して失敗しますか、それともまったく失敗しませんか?
    • プレビューで失敗する:コードに実際のバグがあります。上記の決定木を使います
    • プレビューでは動作するが、アプリでは失敗する:キャッシュまたは環境の問題です。「プレビューのクラッシュ」の決定木を使います
    • まったく再現できない:断続的な競合状態です。さらに調査します

ステップ 2:変数を切り分ける(15分)

  • プレビューで断続的に発生する場合:ビューの再生成が原因である可能性が高いです
    • 最近ビューを条件分岐の中に移動しましたか? 取り除いてテストします
    • 親ビューを再生成する可能性のある if ロジックを追加しましたか? 取り除いてテストします
  • プレビューでは動作するが、アプリでは失敗する場合:環境またはキャッシュの問題である可能性が高いです
    • 別のデバイスまたはシミュレーターで試します
    • DerivedData を消去してから試します

ステップ 3:具体的な修正を適用する(30分)

  • ビューの再生成が原因だと特定できたら、条件分岐の代わりに .opacity() を使います
  • 構造体の変更が原因だと特定できたら、全体を再代入します
  • キャッシュが原因だと確認できたら、DerivedData を適切に削除します

ステップ 4:信頼性を100%検証する(提出まで)

  • 同じ操作を30回以上実行します
  • 複数のデバイスまたはシミュレーターでテストします
  • QA に検証してもらいます
  • 100%再現できる状態になってからリリースします(バグではなく、修正が再現できること)

所要時間:60分の診断+30分の修正+確信を得る時間=午前9時に提出

推測する場合のコスト:すでに2時間+推測にさらに3時間+新しいバグの発生+リリース後のクラッシュレポート+緊急パッチ+評判の低下=ローンチに間に合わず、リリース後も混乱

判断の原則:断続的なバグには、体系的な診断が必要です。診断に時間をかけるほど、修正に早くたどり着けます。推測するのは、災難への最短ルートです。

推測を提案する共同リードへの専門的な伝え方

「ご提案ありがとうございます。あちこちに @ObservedObject を追加するのは、根本原因ではなく症状への対処です。このスキルでは、推測によって断続的なバグが新たなバグを生むとされています。体系的な診断に60分必要です。それまでに根本原因を特定できなければ、その機能を無効にして、問題のない v1.1 をリリースしましょう。計算上は時間があります。期限までに診断、修正、検証をすべて完了できます。」


シナリオ 3:「iPad でレイアウトがおかしいのに、もう時間がない」
考えがちな危険な兆候
  • 「パディングとマジックナンバーを追加しよう」
  • 「たぶんセーフエリアの問題だから、無視してしまおう」
  • 「iPhone だけに限定しよう」
  • 「GeometryReader で解決できるはず」

危険性:マジックナンバーはほかのサイズでは崩れます。SafeArea の無視は、多くの場合間違いです。iPhone に限定すると、iPad では壊れた状態のままリリースすることになります。

代わりに行うこと(3分で診断):

  1. シミュレーターまたはデバイスで実行します
  2. Debug View Hierarchy を使います:Debug メニュー → View Hierarchy(読み込みに30秒かかります)
  3. 問題が SafeArea、ZStack の順序、GeometryReader のサイズ設定のどれかを確認します
  4. 上記のクイックリファレンスから適切なパターンを使います

所要時間:3分の診断+5分の修正=合計8分

マジックナンバーを使う場合のコスト:不具合のある状態でリリースし、2週間後に報告を受け、デバッグに4時間かけ、アップデートでパッチを適用=2週間以上の遅延


クイックリファレンス

よくあるビュー更新の修正
// Fix 1: Reassign the full struct
@State var items: [String] = []
var newItems = items
newItems.append("new")
self.items = newItems

// Fix 2: Pass binding correctly
@State var value = ""
ChildView(text: $value)  // Pass binding, not value

// Fix 3: Preserve view identity
View().opacity(isVisible ? 1 : 0)  // Not: if isVisible { View() }

// Fix 4: Observe the object
@StateObject var model = MyModel()
@ObservedObject var model: MyModel
よくあるプレビューの修正
// Fix 1: Provide dependencies
#Preview {
    ContentView()
        .environmentObject(AppModel())
}

// Fix 2: Safe defaults
@State var index = 0  // Not 10, if array has 3 items

// Fix 3: Nuke cache
// Terminal: rm -rf ~/Library/Developer/Xcode/DerivedData
よくあるレイアウトの修正
// Fix 1: Z-order
Rectangle().zIndex(1)

// Fix 2: Constrain GeometryReader
GeometryReader { geo in ... }.frame(height: 100)

// Fix 3: SafeArea
ZStack { ... }.ignoresSafeArea()

// Fix 4: Modifier order
Text().cornerRadius(8).padding()  // Corners first

実際の使用例

例 1:タップしてもリスト項目が更新されない

シナリオ:タスクのリストがあります。タスクをタップして完了にしても、チェックマークが表示されるはずなのに表示されません。

コード:

struct TaskListView: View {
    @State var tasks: [Task] = [...]

    var body: some View {
        List {
            ForEach(tasks, id: \.id) { task in
                HStack {
                    Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
                    Text(task.title)
                    Spacer()
                    Button("Done") {
                        // ❌ WRONG: Direct mutation
                        task.isComplete.toggle()
                    }
                }
            }
        }
    }
}

このスキルを使った診断:

  1. プレビューで再現できますか? はい
  2. 構造体を直接変更していますか? はい → 構造体の変更(根本原因 1)

修正:

Button("Done") {
    // ✅ RIGHT: Full reassignment
    if let index = tasks.firstIndex(where: { $0.id == task.id }) {
        tasks[index].isComplete.toggle()
    }
}

これで解決する理由:SwiftUI は配列の再代入を検知して再描画を実行します。List 内のタスクが更新されます。


例 2:「No Such Module」でプレビューがクラッシュする

シナリオ:カスタムデータモデルを作成しました。アプリでは問題なく動作しますが、「Cannot find 'CustomModel' in scope」というエラーでプレビューがクラッシュします。

コード:

import SwiftUI

// ❌ WRONG: Preview missing the dependency
#Preview {
    TaskDetailView(task: Task(...))
}

struct TaskDetailView: View {
    @Environment(\.modelContext) var modelContext
    let task: Task  // Custom model

    var body: some View {
        Text(task.title)
    }
}

このスキルを使った診断:

  1. エラーは何ですか?「スコープ内で見つからない」→ 依存関係の不足(エラーの種類 1)
  2. TaskDetailView に必要なものは何ですか?Task モデルと modelContext です

修正:

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: Task.self, configurations: config)

    return TaskDetailView(task: Task(title: "Sample"))
        .modelContainer(container)
}

これで解決する理由:環境オブジェクトとモデルコンテナを渡すことで、ビューの依存関係を満たします。プレビューが正常に読み込まれます。


例 3:テキストフィールドの値を変更しても表示されない

シナリオ:検索フィールドがあります。文字を入力しても、UI にテキストが表示されません。ただし、検索結果は更新されます。

コード:

struct SearchView: View {
    @State var searchText = ""

    var body: some View {
        VStack {
            // ❌ WRONG: Passing constant binding
            TextField("Search", text: .constant(searchText))

            Text("Results for: \(searchText)")  // This updates
            List {
                ForEach(results(for: searchText), id: \.self) { result in
                    Text(result)
                }
            }
        }
    }

    func results(for text: String) -> [String] {
        // Returns filtered results
    }
}

このスキルを使った診断:

  1. プレビューで再現できますか? はい
  2. 子ビューにバインディングを渡していますか? はい(TextField)
  3. 定数バインディングですか? はい → バインディングの同一性の喪失(根本原因 2)

修正:

// ✅ RIGHT: Pass the actual binding
TextField("Search", text: $searchText)

これで解決する理由:$searchText は双方向バインディングを渡します。TextField は変更を @State に書き戻し、再描画を実行します。これで、入力した文字がテキストフィールドに表示されます。


シミュレーターでの検証

SwiftUI の問題を修正したら、シミュレーターで視覚的に確認して検証します。

シミュレーターでの検証が重要な理由

SwiftUI のプレビューは、シミュレーターでの動作と常に一致するとは限りません。

  • レンダリングの違い — デバイスやシミュレーターでしか機能しない視覚効果があります
  • タイミングの違い — アニメーションの動作が異なる場合があります
  • 状態の違い — 独立したプレビューと、アプリ全体のライフサイクルとの違いがあります

次の検証にはシミュレーターを使用します:

  • レイアウトの修正(間隔、配置、サイズ)
  • ビュー更新の修正(状態の変更、バインディング)
  • アニメーションやジェスチャーの問題
  • 修正前後の視覚的な比較
手早く検証するワークフロー
# 1. Take "before" screenshot
/axiom:screenshot

# 2. Apply your fix

# 3. Rebuild and relaunch
xcodebuild build -scheme YourScheme

# 4. Take "after" screenshot
/axiom:screenshot

# 5. Compare screenshots to verify fix
問題のある画面への移動

アプリの奥深くにある不具合の場合は、デバッグ用のディープリンクを使って直接移動します。

# 1. Add debug deep links (see deep-link-debugging skill)
# Example: debug://settings, debug://recipe-detail?id=123

# 2. Navigate and capture
xcrun simctl openurl booted "debug://problem-screen"
sleep 1
/axiom:screenshot
シミュレーターでの完全なテスト

複雑なシナリオ(状態の設定、複数の手順、ログの分析)については、次を実行します。

/axiom:test-simulator

次に、テストしたい内容を説明します。

  • 「レシピエディターに移動して、レイアウトの修正を確認する」
  • 「空の状態でプロフィール画面をテストする」
  • 「アニメーションがもうカクつかないことを確認する」
修正前と修正後の例

修正前(ビューが更新されない):

# 1. Reproduce bug
xcrun simctl openurl booted "debug://recipe-list"
sleep 1
xcrun simctl io booted screenshot /tmp/before-fix.png
# Screenshot shows: Tapping star doesn't update UI

修正後(@State バインディングを追加):

# 2. Test fix
xcrun simctl openurl booted "debug://recipe-list"
sleep 1
xcrun simctl io booted screenshot /tmp/after-fix.png
# Screenshot shows: Star updates immediately when tapped

短縮できる時間:手動で移動する場合と比べ、視覚的な検証により反復作業を 60% 以上高速化


リソース

WWDC:2025-256, 2025-306, 2023-10160, 2023-10149, 2021-10022

ドキュメント:/swiftui/managing-model-data-in-your-app, /swiftui, /swiftui/state-and-data-flow, /xcode/previews, /observation

スキル:axiom-swiftui-performance, axiom-swiftui-debugging-diag, axiom-xcode-debugging, axiom-swift-concurrency, axiom-lldb(Self._printChanges 以外の LLDB デバッグワークフロー)

コメント