axiom-swiftui-debugging について
SwiftUI 開発者向けのデバッグスキルです。ビューの更新停止、プレビューのクラッシュ、レイアウトの問題ごとに診断方法を整理し、原因を段階的に絞り込む判断フローと修正例を示します。
主な機能
- 01ビュー更新問題の判断フロー
- 02プレビュークラッシュの診断
- 03よくあるレイアウト問題の参照
- 04状態、バインディング、ビュー ID の診断
- 05_printChanges() による更新確認
活用シーン
- 01状態変更後にビューが再描画されない問題の調査
- 02Xcode プレビューの依存関係不足やクラッシュの診断
- 03子ビューの状態が予期せずリセットされる原因の特定
- 04ビューの位置、サイズ、重なりの不具合の調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftui-debuggingインストール後、Claude Code で/axiom-swiftui-debuggingを入力して読み込まれたか確認します。
詳細
SwiftUI のデバッグ
概要
SwiftUI のデバッグは 3 つのカテゴリに分かれ、それぞれ異なる診断方法を使います。
- ビューが更新されない – 変更したのにビューが再描画されません。構造体の変更、バインディングのアイデンティティの喪失、意図しないビューの再生成、オブザーバーパターンの不足のどれが原因かを特定するための判断ツリーです。
- プレビューがクラッシュする – プレビューをコンパイルできない、またはすぐにクラッシュします。依存関係の不足、状態の初期化の失敗、Xcode キャッシュの破損を見分けるための判断ツリーです。
- レイアウトの問題 – ビューが誤った位置やサイズで表示されたり、予期せず重なったりします。よくある状況に対応するパターンのクイックリファレンスです。
基本原則:観察可能な症状から始め、体系的にテストし、原因を一つずつ排除します。推測しないでください。
必要条件: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() を追加してください。子ビュー自身のコールバックが実行された後に子ビューの初期化パラメーターが変わる場合、これが原因です。
修正方法(簡単なものから順に):
- 値を子から親に戻さない:変更される状態を初期化パラメーターから削除し、コールバックで親を更新しても、その値を子に戻さないようにします
- 状態を分ける:子ビューの表示ロジックと親ビューのキャッシュに、それぞれ別の
@Stateを使用します - 子ビュー側で取得する:親の状態を受け取る代わりに、子ビューが独自のデータソースからデータを取得します
// ✅ 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 を再起動すると一時的に直るが、再発する → キャッシュが原因です
- 同じコードがシミュレーターでは問題なくビルドできるのに、プレビューは失敗する → キャッシュ
- 無関係な複数のプレビューが同時に失敗する → キャッシュ
修正方法(順番に実行):
- プレビューキャンバスを再起動します:
Cmd+Option+P - Xcode を完全に再起動します(File → Close Window を選択し、プロジェクトを再度開きます)
- 派生データを削除します:
rm -rf ~/Library/Developer/Xcode/DerivedData - 再ビルドします:
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 分のプロトコル):
- Preview Canvas を再起動:
Cmd+Option+P(30 秒) - Xcode を再起動(2 分)
- DerivedData を削除:
rm -rf ~/Library/Developer/Xcode/DerivedData(30 秒) - リビルド:
Cmd+B(2 分) - まだ壊れている場合は、上記の依存関係または初期化の決定ツリーを使用してください
所要時間: 5 分の診断 + 2 分の修正 = 合計 7 分
スキップした場合のコスト: 30 分の出荷 + 24 時間のデバッグサイクル = 合計 24 時間以上
シナリオ 2: 「ビューが更新されない、@ObservedObject でラップしてしまおう」
考えるかもしれない危険信号
- 「どこにでも @ObservedObject を追加すれば直る」
- 「ObservableObject を応急処置として使う」
- 「ランダムなプロパティに @Published を追加する」
- 「おそらくバインディングの問題だ、カスタムバインディングを作ればいい」
危険性: 症状を治療しているだけで、診断していません。同じビューは他のコンテキストでも更新されません。バグを隠しただけです。
代わりに行うべきこと(2 分の診断):
- 最小限のプレビューで再現できますか? いいえ → キャッシュの破損(シナリオ 1 を参照)
- はいの場合: 各根本原因を順番にテストします:
- ビューに直接変更している @State がありますか? → 構造体のミューテーション
- 最近ビューが条件分岐内に移動しましたか? → ビューの再生成
- 変更された子にバインディングを渡していますか? → バインディングアイデンティティの喪失
- 上記のいずれにも当てはまらない場合のみ: オブザーバーの欠如
- @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分で診断):
- シミュレーターまたはデバイスで実行します
- Debug View Hierarchy を使います:Debug メニュー → View Hierarchy(読み込みに30秒かかります)
- 問題が SafeArea、ZStack の順序、GeometryReader のサイズ設定のどれかを確認します
- 上記のクイックリファレンスから適切なパターンを使います
所要時間: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)
修正:
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)
- 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
}
}
このスキルを使った診断:
- プレビューで再現できますか? はい
- 子ビューにバインディングを渡していますか? はい(TextField)
- 定数バインディングですか? はい → バインディングの同一性の喪失(根本原因 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 デバッグワークフロー)