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状态、绑定与视图身份诊断
  5. 05使用 _printChanges() 检查更新

适用场景

  1. 01排查状态变化后视图未重绘
  2. 02诊断 Xcode 预览缺少依赖或崩溃
  3. 03查找子视图状态意外重置的原因
  4. 04调查视图位置、尺寸或重叠异常

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/axiom-swiftui-debugging确认已加载。

详细介绍

SwiftUI 调试

概述

SwiftUI 调试分为三类,每类都有不同的诊断方法:

  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,但更改属性时视图还是不会更新”

→ 本技能会说明何时使用 @State,何时将普通属性与 @Observable 对象搭配使用

5. “文本字段一开始输入就失去焦点,太让人烦了”

→ 本技能会指出 ForEach 的身份问题,并展示如何使用稳定的 ID

6. “我的工作表/模态视图总是跳过加载动画,直接显示完成状态”

→ 本技能会指出,父视图状态变更会导致 @ViewBuilder 闭包重新初始化,进而改变 init 参数

何时使用 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,但视图没有重绘。根本原因总是以下四种之一。

第 1 步:能否在最简预览中复现?
#Preview {
  YourView()
}

是 → 问题出在你的代码中。继续第 2 步。

否 → 很可能是 Xcode 状态或缓存损坏。跳转到“预览崩溃”部分。

第 2 步:诊断根本原因
根本原因 1:通过本地副本修改结构体

症状:你修改了一个值,但视图没有更新。

原因:你在本地变量中捕获了结构体值,或将其传递给会修改副本的函数。@State 属性包装器只能检测通过其自身 setter 进行的修改——对副本所做的更改对 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:Binding 身份丢失

症状:你将 binding 传递给子视图,但子视图中的更改没有更新父视图。

原因:你传递了 .constant(),或每次都创建新的 binding,破坏了双向连接。

// ❌ 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。如果要创建自定义 binding(iOS 17 之前),请在 init 中创建或将其缓存,不要在 body 中创建。


根本原因 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 包装器
  • 可与 @State 配合使用,而非 @StateObject
  • 可以作为普通属性传递,而非使用 @ObservedObject

另请参阅在应用中管理模型数据


根本原因 5:@ViewBuilder 闭包重新初始化

症状:.sheet、.fullScreenCover、.popover 或 NavigationStack 目标视图中的子视图始终不显示预期的加载状态或动画——它总是显示为“已完成”状态。回调更新了父视图状态,而子视图的行为就像数据已经加载一样。

原因:父视图的 body 每次重新求值时,任何 @ViewBuilder 闭包都会重新求值。如果你将父视图的 @State 作为子视图的 init 参数传入,而回调又修改了同一个状态,那么闭包会使用新值重新求值。子视图的 @State 会保留(其生命周期基于身份),但 init 参数会重新计算。

// ❌ 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()。如果子视图的 init 参数在其自身的回调触发后发生变化,这就是原因。

解决方法(按简便程度排序):

  1. 不要将值传回:从 init 参数中移除已修改的状态——让回调更新父视图,但不要再将该状态传回来
  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 }
    )
}

关键要点:这并非 sheet 特有的问题——它适用于任何 @ViewBuilder 闭包。子视图的 @State 会在重新求值期间保留,但 init 参数会根据当前父视图状态重新计算。


决策树总结
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"];
}

预览崩溃决策树

如果预览无法加载或立即崩溃,通常是由三个不同的根本原因导致的。

步骤 1:错误是什么?
错误类型 1:“Cannot find in scope”或“No such module”

根本原因:预览缺少必需的依赖项(@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

如果执行完以上四个步骤后问题仍未解决:这不是缓存问题,请参阅错误类型 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 使用视图标识来追踪视图随时间的变化、保留状态并为过渡添加动画。了解视图标识对于调试状态保留和动画问题至关重要。

两种标识类型
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()。

身份速查表
症状可能原因修复方法
状态重置身份变化改用 .opacity(),而非 if
没有动画身份变化移除 .id() 或使用稳定的 ID
ForEach 跳动ID 不唯一使用唯一且稳定的 ID
意外重建条件语句中的位置变化添加显式 .id()

另请参阅 WWDC21:揭开 SwiftUI 的奥秘


压力情境与现实约束

在截止日期的压力下,你可能会想走捷径,掩盖问题而不是解决问题。

情境 1:“Preview 一直崩溃,明天就要发布了”
你可能会听到的危险信号
  • “直接全部重新构建”
  • “删除派生数据,别管它”
  • “不必在 Preview 中验证,直接发布”
  • “我这边能运行就够了”

危险之处:你跳过了诊断,缓存问题会在生产环境中两周后复发,而你只能在用户遇到崩溃时调试。

应对方法(总共 5 分钟的流程):

  1. 重启 Preview Canvas:Cmd+Option+P(30 秒)
  2. 重启 Xcode(2 分钟)
  3. 清除派生数据:rm -rf ~/Library/Developer/Xcode/DerivedData(30 秒)
  4. 重新构建:Cmd+B(2 分钟)
  5. 仍未解决?使用上文的依赖项或初始化决策树

耗时:诊断 5 分钟 + 修复 2 分钟 = 总共 7 分钟

跳过的代价:发布耗时 30 分钟 + 调试周期 24 小时 = 总共 24 小时以上


情境 2:“视图不会更新,我直接用 @ObservedObject 包起来吧”
你可能会想到的危险信号
  • “到处加上 @ObservedObject 就能解决”
  • “用 ObservableObject 临时补救”
  • “给一些属性随便加上 @Published”
  • “可能是绑定的问题,我直接创建一个自定义绑定”

危险之处:你只是在处理症状,没有进行诊断。同一个视图在其他情境下也不会更新,你只是把 bug 藏了起来。

应对方法(2 分钟诊断):

  1. 能否在最小化的 Preview 中复现?如果不能 → 缓存损坏(见情境 1)
  2. 如果可以:按顺序测试每个根本原因:
    • 视图中是否有你正在直接修改的 @State?→ 结构体变更
    • 视图最近是否被移入条件语句?→ 视图重建
    • 你是否向已发生变化的子视图传递了绑定?→ 绑定身份丢失
    • 只有以上原因都不成立时,才考虑观察器缺失
  3. 修复真正的根本原因,而不是用 @ObservedObject 临时补救

决策原则:如果你说不出具体的根本原因,就说明还没有完成诊断。直到你能回答“问题出在结构体变更,因为……”,否则不要编写代码。


情境 2b:“更新时有时无——有时能用,有时不能”
你可能会想到的危险信号
  • “肯定是线程问题,我到处加上 @MainActor 试试”
  • “我试试 @ObservedObject、@State 和自定义 Binding,直到有一个管用”
  • “删除 DerivedData,指望缓存损坏的问题能因此修复”
  • “这根本修不好,干脆不带这个功能上线”

危险之处:你花了 2 小时猜测,已经筋疲力尽。距离提交到 App Store 只剩 17 小时。你开始慌了。每一分钟似乎都很紧迫,于是你不再诊断,而是开始乱试。

间歇性 Bug 是最需要正确诊断的 Bug。现在猜错一次,就会引入一个新 Bug。最后,你带着一个有问题的视图和一个新 Bug 上线。App Store 拒绝你的应用,你错过发布。

更好的做法(60 分钟系统化诊断):

步骤 1:在预览中复现(15 分钟)

  • 只为出问题的视图创建最小预览
  • 点击或交互 20 次
  • 问题是间歇性出现、稳定出现,还是从不出现?
    • 预览中也会失败:这是代码中的真实 Bug,请使用上面的决策树
    • 预览中正常,但在应用中失败:这是缓存或环境问题,请使用“预览崩溃”决策树
    • 完全无法复现:这是间歇性竞态条件,需要进一步排查

步骤 2:隔离变量(15 分钟)

  • 如果问题在预览中间歇性出现:很可能是视图重建
    • 视图最近是否被移入条件语句?移出来再测试
    • 你是否添加了可能导致父视图重建的 if 逻辑?移除后再测试
  • 如果预览中正常,但在应用中失败:很可能是环境或缓存问题
    • 尝试在其他设备或模拟器上运行
    • 尝试清除 DerivedData 后运行

步骤 3:应用针对性修复(30 分钟)

  • 如果已确认是视图重建:使用 .opacity(),不要使用条件语句
  • 如果已确认是结构体变更:使用完整重新赋值
  • 如果已确认是缓存问题:正确清除 DerivedData

步骤 4:验证 100% 可靠性(直到提交)

  • 将相同交互运行 30 次以上
  • 在多台设备或多个模拟器上测试
  • 请 QA 验证
  • 只有在结果 100% 可复现时才上线(可复现的不是 Bug,而是修复效果)

耗时:诊断 60 分钟 + 修复 30 分钟 + 增强信心 = 上午 9 点提交

猜测的代价:已经花了 2 小时 + 再猜 3 小时 + 引入新 Bug + 上线后收到崩溃报告 + 紧急补丁 + 声誉受损 = 错过发布 + 上线后陷入混乱

决策原则:间歇性 Bug 需要系统化诊断。诊断时越慢,找到修复方案就越快。猜测是最快通往灾难的路。

给建议靠猜测的共同负责人使用的专业话术

“感谢你的建议。到处添加 @ObservedObject 是在治标,而不是解决根本原因。这份技能指南指出,猜测会让间歇性 Bug 引入新 Bug。我需要 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 分钟

使用神奇数字的代价:带着错误结果上线,两周后收到报告,花 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 会检测到数组重新赋值,并触发重新绘制。列表中的任务随之更新。


示例 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. 错误是什么?“Cannot find in scope” → 缺少依赖项(错误类型 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:文本字段的值变化没有显示

场景:你有一个搜索框。输入字符后,界面中没有显示文本,但搜索结果确实会更新。

代码:

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
跳转到问题界面

如果 Bug 出现在应用深处,可使用调试深层链接直接跳转:

# 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 调试工作流)

评论

支持 Markdown