Skip to content

SwiftUI 复习知识点

本文是一份「单文件可通读」的 SwiftUI 深度复习材料,面向已经熟悉 Swift 语言、有一定 UIKit 经验、准备面试或系统性复盘的 iOS 工程师。

阅读建议:

  • 第 0~2 章是心智模型与状态机制,决定你能否写出"对的"SwiftUI;
  • 第 3~7 章是UI 与交互子系统(导航/动画/手势/绘图/互操作),对应面试高频考点;
  • 第 8~10 章是工程化(Preview/测试/性能);
  • 第 11~12 章是项目实战(关键模块 + 完整架构示例);
  • 附录是面试速记与版本/API 对照表。

本文所有 API 行为以 iOS 17 / Xcode 15 为基线,并对 iOS 18 的新增做了必要标注。所有代码示例均经过整理,可直接放入 Xcode 工程编译(少量简写为了排版美观)。


0. 心智模型:理解 SwiftUI 的"内核"

0.1 声明式 ≠ 简化语法,而是"声明依赖 + 系统调度"

UIKit 是命令式 + 保留对象树:你手动创建视图对象、加入视图层级、手动改属性、手动刷新。 SwiftUI 是声明式 + 重建视图树:你用一个纯函数 body -> some View,根据当前状态描述 UI 长什么样;框架负责 diff、调度、刷新。

swift
// UIKit 思路(命令式)
label.text = "Hello"
label.textColor = .red
view.addSubview(label)

// SwiftUI 思路(声明式)
Text(name)
    .foregroundStyle(.red)
// name 变了 -> body 重新求值 -> 框架 diff -> 视图刷新

这个差异决定了 SwiftUI 的所有特性:不可变视图、单向数据流、状态驱动、属性包装器。

0.2 View 协议的真相

swift
public protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Self.Body { get }
}

要点:

  • View 只有一个要求 body,它是只读计算属性
  • associatedtype Body: View 配合 some View(不透明返回类型,Swift 5.1+),让 body 的具体类型在编译期就被推导出来,但对外隐藏。编译期已知类型意味着运行时没有类型擦除开销,diff 也更快
  • 不要用 AnyView 来"统一类型"。AnyView破坏 diff 性能(强制走类型擦除路径),只在确实需要动态分发时使用。

0.3 @ViewBuilder 与视图组合

@ViewBuilder 是一个 result builder(Swift 5.4 起),它把闭包内的多条表达式编译成 TupleView / _ConditionalContent 的嵌套结构,从而支持 if/elseForEachswitch

swift
VStack {
    if condition {
        Text("A")
    } else {
        Text("B")
        Image(systemName: "star")
    }
}
// 编译后等价于:
// VStack<TupleView<_ConditionalContent<Text, TupleView<(Text, Image)>>>>

由此带来的后果:

  • if/else改变视图树的具体类型,因此会被 SwiftUI 视为结构变化,触发整棵子树的销毁与重建。如果只是切换内容,优先用 if/else 改成 .opacity().offset()transaction 控制,或用 Group { if ... }
  • if let 同理,慎用。

0.4 视图树与 Attribute Graph

SwiftUI 内部维护两棵树:

  1. 视图树(View Tree):你的 body 求值得到的不可变值树。每次状态变化都生成一棵新的(值拷贝,开销很小)。
  2. 属性图(Attribute Graph, AG):持久化的依赖追踪图。视图、状态、属性之间形成 node → dependency 的有向图,状态变化时 AG 只重算依赖路径上的节点。

经典误解:"SwiftUI 每次都把整个 body 跑一遍,是不是很慢?" 实际上:body 求值本身很快(值类型拷贝),AG 决定的是哪些节点真正需要重渲染。优化 SwiftUI 性能的关键是减少不必要的依赖(详见第 10 章)。

0.5 Identity:视图身份决定 diff

SwiftUI 判断两棵视图树的差异,靠两层:

  1. 结构身份(Structural Identity):视图在树中的"位置"。if/else 分支不同 = 结构不同 = 不同节点;
  2. 显式身份(Explicit Identity)id()IdentifiableForEach(id:)
swift
ForEach(items) { item in     // items: [Item] where Item: Identifiable
    Row(item: item)
}

// 或显式提供 id
ForEach(0..<n, id: \.self) { i in Cell(i: i) }

陷阱id(\.self) 当数组顺序变化时(如排序、插入到中间),会因为 id 变化触发整列重建。对于可重排的列表,必须用稳定的业务 id(如 uuid),否则会看到动画错乱、状态丢失。

0.6 生命周期

swift
Text("hi")
    .onAppear { /* 视图首次进入屏幕 */ }
    .onDisappear { /* 视图被移除 */ }
    .onChange(of: value) { oldValue, newValue in /* iOS 17+ 二参数闭包 */ }
    .onChange(of: value) { /* 旧版单参数,iOS 14+ */ }
    .onReceive(timer) { date in /* Combine 订阅 */ }
    .task { /* 视图出现时启动 async 任务,离开自动 cancel */ }
    .task(id: someKey) { /* someKey 变化时重启任务 */ }

onAppear 在 lazy 容器(List、LazyVStack)的子项上才会按需触发,但在普通 VStack 的子项上会跟随父视图一起出现


1. 视图与 Modifier

1.1 modifier 顺序很重要

swift
Text("Hello")
    .padding()
    .background(.red)
// vs
Text("Hello")
    .background(.red)
    .padding()

每个 modifier 实际上返回一个包装原视图的新类型(如 _Padding<Text>_BackgroundModifier<Text>),所以顺序不同得到不同视图结构、不同视觉效果。

1.2 内置 modifier vs 环境值

很多 modifier 是"读取环境 + 写入环境"的语法糖:

  • .font(.title) → 写入 \.environment(\.font, .title)
  • .foregroundStyle(.red) → 写入 \.foregroundStyle
  • .tint(.blue) → 影响 Button 等控件强调色

如果想让某个子视图"屏蔽"父级环境,用 .environment(\.xxx, .default) 重置。

1.3 自定义 ViewModifier

swift
struct CardStyle: ViewModifier {
    var padding: CGFloat = 16
    func body(content: Content) -> some View {
        content
            .padding(padding)
            .background(.white, in: RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.1), radius: 8, y: 4)
    }
}

extension View {
    func cardStyle(padding: CGFloat = 16) -> some View {
        modifier(CardStyle(padding: padding))
    }
}

// 使用
VStack { /*...*/ }.cardStyle()

要点:

  • ViewModifier 适合"无状态或少量状态"的封装;带状态时优先抽出独立 View
  • 把自定义 modifier 写到 extension View 上,调用更自然。

1.4 ButtonStyle / LabelStyle / ToggleStyle

控件的外观定制走专用协议:

swift
struct ScaleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.96 : 1.0)
            .animation(.easeOut(duration: 0.15), value: configuration.isPressed)
    }
}

Button("Tap") { /*...*/ }.buttonStyle(ScaleButtonStyle())

PrimitiveButtonStyleButtonStyle 更底层,可完全自定义手势。


2. 布局系统

2.1 SwiftUI 布局协商机制

容器(HStack/VStack/…)会向子视图提一个问题:"在 (宽度, 高度) 范围内,你想多大?" 子视图返回一个尺寸 + 对齐方式。SwiftUI 的布局是双向协商

  1. 容器给子视图一个"建议尺寸"(proposal);
  2. 子视图根据自己的内容返回"实际尺寸";
  3. 容器根据子视图的返回值和 spacing/alignment 摆放它们。

理解这一点,就能解释:

  • Image 默认贪婪 → 总是用原始尺寸;
  • Text 在宽度受限时会自动换行
  • Color / Spacer / Rectangle 占满剩余空间;
  • fixedSize() 让视图"忽略父级提案,用理想尺寸"。

2.2 主要容器

容器用途关键参数
HStack / VStack / ZStack线性/层叠alignmentspacing
LazyVStack / LazyHStack懒加载(配合 ScrollView)同上
List列表(自带 Cell、分隔线、编辑模式)styleeditActions
ScrollView可滚动容器axesshowsIndicators
Grid / LazyVGrid / LazyHGrid网格columnsspacing
ViewThatFits自适应选择最合适子视图axes
GroupBox / ControlGroup分组容器

2.3 frame 与尺寸约束

swift
.frame(width: 200, height: 80)                       // 固定
.frame(maxWidth: .infinity)                           // 占满宽度
.frame(maxWidth: .infinity, alignment: .leading)      // 占满 + 左对齐
.frame(minWidth: 100, idealWidth: 200, maxWidth: 300) // 弹性
.fixedSize(horizontal: false, vertical: true)        // 在某个轴上忽略父约束
.ignoresSafeArea(edges: .bottom)                      // 忽略安全区

2.4 GeometryReader:能用、但慎用

swift
GeometryReader { geo in
    Text("\(geo.size.width)")
        .frame(width: geo.size.width)
}

特点:

  • 贪婪占满父容器提供的全部空间(默认 .topLeading 对齐);
  • 不会反向给父容器报告自己的尺寸 → 因此它不能"自适应内容";
  • 性能开销中等,但滥用会让布局退化为"手动算坐标",失去 SwiftUI 的优势。

替代方案:

  • background(GeometryReader { … }).onGeometryChange(iOS 18+)"偷偷测量"而不影响主布局;
  • Layout 协议(iOS 16+)自定义容器;
  • PreferenceKey 上报尺寸(旧方案)。

2.5 iOS 18+: onGeometryChange

swift
Text("Measure me")
    .onGeometryChange(for: CGSize.self) { proxy in
        proxy.size
    } action: { newSize in
        self.measuredSize = newSize
    }

GeometryReader + PreferenceKey 更轻量,也不会触发自身重新布局。

2.6 自定义 Layout 协议(iOS 16+)

swift
struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var totalWidth: CGFloat = 0
        var totalHeight: CGFloat = 0
        var lineWidth: CGFloat = 0
        var lineHeight: CGFloat = 0

        for view in subviews {
            let size = view.sizeThatFits(.unspecified)
            if lineWidth + size.width > maxWidth {
                totalWidth = max(totalWidth, lineWidth)
                totalHeight += lineHeight + spacing
                lineWidth = size.width
                lineHeight = size.height
            } else {
                lineWidth += size.width + spacing
                lineHeight = max(lineHeight, size.height)
            }
        }
        totalWidth = max(totalWidth, lineWidth)
        totalHeight += lineHeight
        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        var x = bounds.minX
        var y = bounds.minY
        var lineHeight: CGFloat = 0
        for view in subviews {
            let size = view.sizeThatFits(.unspecified)
            if x + size.width > bounds.maxX {
                x = bounds.minX
                y += lineHeight + spacing
                lineHeight = 0
            }
            view.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size))
            x += size.width + spacing
            lineHeight = max(lineHeight, size.height)
        }
    }
}

FlowLayout(spacing: 6) {
    ForEach(tags, id: \.self) { Text($0).padding(6).background(.blue.opacity(0.2), in: Capsule()) }
}

Layout 是真正"参与 SwiftUI 布局协商"的自定义容器,不像 GeometryReader 那样脱离系统。


3. 状态与数据流(重点章节)

这一章决定你能不能写出正确的 SwiftUI。90% 的 bug 来自对状态包装器的误用。

3.1 数据流总览

┌────────────┐     @Binding       ┌──────────────┐
│  @State    │ ─────────────────▶ │  Child View  │
│ (值/拥有)  │ ◀───────────────── │              │
└────────────┘                    └──────────────┘

       │ .environment(model)

┌────────────────────┐  @Environment(Model.self)
│  @Observable Model │ ──────────────▶ 任意后代视图
│   (引用/共享)      │
└────────────────────┘

核心三问(每次选包装器时都问自己):

  1. 谁拥有这个数据?(owner)
  2. 数据是值类型还是引用类型?
  3. 谁有权修改它?

3.2 @State:视图私有、值类型、拥有权归当前视图

swift
struct CounterView: View {
    @State private var count = 0   // 真正存储在 SwiftUI 的内部存储里,不在 struct 实例上
    var body: some View {
        Button("count: \(count)") { count += 1 }
    }
}

要点:

  • @State 实际上不存储在 View struct 上。View 是值类型且会被反复重建,状态必须存在 SwiftUI 的"长期存储"中,View 只持有 StateObject 的 key;
  • 因此 private 是惯例,不要把 @State 传给其他视图作为初始值——父视图重建时子视图会拿不到更新(因为父视图的 @State 值是上一次快照);
  • 不要用 @State 持有 class。引用类型用 @StateObject(旧)或 @State + @Observable(新)。

3.3 @Binding:引用上层状态

swift
struct VolumeSlider: View {
    @Binding var volume: Double        // 不是 owner,只是 borrower
    var body: some View {
        Slider(value: $volume, in: 0...100)
    }
}

// 父视图:
@State private var volume: Double = 50
VolumeSlider(volume: $volume)

$volume@State 投影出的 Binding<Double>,它是一个双向通道:子视图改了它,父视图的 volume 同步变化。

3.4 @Observable 宏(iOS 17+ Observation 框架)

swift
import Observation

@Observable
final class UserModel {
    var name: String = ""
    var avatarURL: URL?
    var isPremium: Bool = false

    @ObservationIgnored     // 不参与依赖追踪
    var cache: [String: Data] = [:]
}

行为:

  • 宏在编译期为每个 var 注入 access(key:) / withMutation(key:) 钩子;
  • SwiftUI 在 body读取 user.name 时,自动记录"当前视图依赖 name";
  • name 变化时,只有真正依赖 name 的视图才会重新求值——比旧的 ObservableObject + @Published(整个对象都触发 objectWillChange)精确得多。

与旧 ObservableObject 对比

维度ObservableObject + @Published@Observable
粒度整个对象(@Published 属性粒度但触发对象级 refresh)单个属性
代码class XYZ: ObservableObject + @Published只需 @Observable class
持有@StateObject / @ObservedObject / @EnvironmentObject普通属性 / @State / @Environment
绑定$model.foo@ObservedObject$model.foo@Bindable
性能更好(精确追踪)

3.5 @StateObject / @ObservedObject(iOS 13+ 旧 API)

swift
class OldModel: ObservableObject {
    @Published var count = 0
}

struct Owner: View {
    @StateObject var model = OldModel()    // 当前视图拥有 + 创建
}

struct Borrower: View {
    @ObservedObject var model: OldModel    // 外部传入,不拥有
}

经典陷阱:在子视图里用 @StateObject 接收外部传入的对象 = 每次重新创建 → 数据丢失。规则:只有"自己创建并拥有"的对象才用 @StateObject,外部传入的用 @ObservedObject

iOS 17+ 新代码统一用 @Observable + 普通属性 / @State / @Environment,旧 API 仍可用但建议迁移。

3.6 @Environment:环境注入(iOS 17+ 推荐)

swift
@Observable
final class AppModel { var theme: Theme = .light }

@main
struct MyApp: App {
    @State private var appModel = AppModel()
    var body: some Scene {
        WindowGroup {
            RootView().environment(appModel)   // 注入
        }
    }
}

struct RootView: View {
    @Environment(AppModel.self) private var appModel   // 读取
    var body: some View { Text(appModel.theme.rawValue) }
}

注意:

  • .environment(appModel) 是 iOS 17+ 的新方法,等价于旧的 .environmentObject(appModel)
  • 读取用 @Environment(AppModel.self),类型安全;
  • 环境值会沿视图树向下传递,任意层级都能取到;
  • 测试/预览时方便"注入不同实例"。

3.7 @Bindable:让 @Observable 对象支持 $ 绑定

swift
struct EditProfileView: View {
    @Bindable var user: UserModel      // 注意是 var,不是 let

    var body: some View {
        TextField("Name", text: $user.name)        // 现在可以用 $ 了
        Toggle("Premium", isOn: $user.isPremium)
    }
}

@Bindable 在 iOS 17+ 取代了旧 @ObservedObject$ 绑定能力,但它不持有、不管理生命周期——它只是给一个 @Observable 对象"加上绑定语法糖"。

3.8 @AppStorage / @SceneStorage:轻量持久化

swift
// UserDefaults 的语法糖
@AppStorage("username") private var username: String = "guest"
@AppStorage("volume") private var volume: Double = 0.5

// 场景级(多窗口隔离,系统恢复时还原)
@SceneStorage("draft.text") private var draft: String = ""

适合:小型配置、首选项。不适合:复杂模型、大量数据。复杂场景用 SwiftData / Core Data / 文件。

3.9 EnvironmentKey 自定义

swift
struct ThemeKey: EnvironmentKey {
    static let defaultValue: Theme = .light
}
extension EnvironmentValues {
    var theme: Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

// 注入与读取
ContentView().environment(\.theme, .dark)
@Environment(\.theme) var theme

iOS 17+ 推荐直接注入对象(@Environment(AppModel.self)),但 EnvironmentKey 仍然适合"值类型配置"(如颜色、字号、特性开关)。

3.10 数据流决策树

1) 是值类型吗?
   ├─ 是 → 当前视图拥有吗?
   │      ├─ 是 → @State
   │      └─ 否 → @Binding
   └─ 否(class)
       2) 整个 App 共享吗?
          ├─ 是 → @Observable + .environment() + @Environment(T.self)
          └─ 否 → 当前视图拥有吗?
                 ├─ 是 → @Observable + @State
                 └─ 否 → @Observable + @Bindable / 普通属性

3.11 常见反模式

  • ❌ 在子视图里 @State private var external = external:父视图刷新后值不同步;
  • ❌ 用 @StateObject var model: Model:丢失外部传入;
  • ❌ 用 @ObservedObject var model = Model():每次父刷新都会重建
  • ❌ 把整个大对象塞进 @Environment,让所有视图都依赖整个对象(应拆分);
  • ❌ 在 body 里创建 let model = Model():每次求值都新建实例。

4. 导航与列表

4.1 NavigationStack(iOS 16+)取代 NavigationView

NavigationView 在 iOS 16 被弃用,统一用 NavigationStack(栈式)或 NavigationSplitView(分栏)。

swift
NavigationStack {
    List(items) { item in
        NavigationLink(value: item) {      // 用 value 关联目标
            Row(item: item)
        }
    }
    .navigationDestination(for: Item.self) { item in
        DetailView(item: item)
    }
    .navigationDestination(for: String.self) { path in
        Text("Path: \(path)")
    }
}

Value-based Routing

swift
// 路径可以用 @State 完全控制
struct RootView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List { /* … */ }
                .navigationDestination(for: Item.self) { DetailView(item: $0) }
        }
        .onChange(of: pushTrigger) { _, _ in
            path.append(selectedItem)             // 编程式 push
        }
    }
}

NavigationPath 是类型擦除的路径栈,可以混装多种类型;如果类型固定,用 [Item] / [Route]

4.2 NavigationSplitView(分栏,iPad/Mac/大屏必备)

swift
NavigationSplitView {
    Sidebar()
} content: {
    MiddleColumn()
} detail: {
    DetailColumn()
}
.navigationSplitViewStyle(.balanced)   // .automatic / .balanced / .prominentDetail

iOS 上自动降级为 NavigationStack;iPad 横屏 / Mac 上呈现三栏。这是写"通用 App"的标准结构。

4.3 TabView

swift
TabView {
    HomeView().tabItem { Label("首页", systemImage: "house") }
    SearchView().tabItem { Label("搜索", systemImage: "magnifyingglass") }
    ProfileView().tabItem { Label("我的", systemImage: "person") }
}
// 选中索引可绑定
@State private var selection = 0
TabView(selection: $selection) { /* … */ }

// 翻页式(引导页、轮播)
TabView { ForEach(0..<3) { PageView(idx: $0) } }
    .tabViewStyle(.page)
    .indexViewStyle(.page(backgroundDisplayMode: .always))

iOS 18+ 新增 TabView 的可定制样式与 .tabViewStyle(.sidebarAdaptable)

4.4 List 详解

swift
// 普通
List(items) { item in Row(item: item) }

// 分节
List {
    Section("设置") {
        Toggle("通知", isOn: $notify)
        Toggle("深色模式", isOn: $dark)
    }
    Section("关于") {
        LabeledContent("版本", value: "1.0")
    }
}
.listStyle(.insetGrouped)   // .automatic / .plain / .grouped / .insetGrouped / .sidebar

// 编辑模式(滑动删除、拖拽排序)
List {
    ForEach(items) { item in Row(item: item) }
        .onMove { source, dest in items.move(fromOffsets: source, toOffset: dest) }
        .onDelete { idx in items.remove(atOffsets: idx) }
}
.environment(\.editMode, .constant(.active))

性能要点:

  • List 默认是懒加载的,类似 UITableView
  • LazyVStack / LazyHStack 也是懒的,但没有复用机制(一次性创建视图结构,只是延迟实例化)。对于超长列表,List 仍然更省内存;
  • ForEach 必须保证 id 稳定且唯一。

4.5 sheet / fullScreenCover / popover / confirmationDialog

swift
.sheet(isPresented: $showSheet) {
    SheetView().presentationDetents([.medium, .large])  // iOS 16+ 半屏
}
.sheet(item: $selectedItem) { item in        // item: Identifiable? → 非 nil 时弹出
    DetailView(item: item)
}
.fullScreenCover(isPresented: $showFull) { FullScreenView() }
.popover(isPresented: $showPopover) { PopView() }     // iPad 上浮窗,iPhone 退化成 sheet
.confirmationDialog("确认删除?", isPresented: $show) {
    Button("删除", role: .destructive) { delete() }
    Button("取消", role: .cancel) {}
}
.alert("错误", isPresented: $showError) { }       // alert 也用 isPresented
swift
.navigationDestination(item: $route) { route in
    switch route {
    case .detail(let id): DetailView(id: id)
    case .settings: SettingsView()
    }
}
.onOpenURL { url in
    // 解析 url → 更新 path 或 route
}

5. 表单与输入

5.1 Form

swift
Form {
    Section("账户") {
        TextField("邮箱", text: $email)
            .textInputAutocapitalization(.never)
            .keyboardType(.emailAddress)
        SecureField("密码", text: $password)
    }
    Section("偏好") {
        Toggle("通知", isOn: $notify)
        Picker("主题", selection: $theme) {
            ForEach(Theme.allCases) { Text($0.rawValue).tag($0) }
        }
        Stepper("数量:\(count)", value: $count, in: 0...10)
        Slider(value: $volume)
    }
}
.formStyle(.grouped)   // iOS 16+

5.2 焦点管理

swift
enum Field: Hashable { case email, password }

struct LoginView: View {
    @FocusState private var focus: Field?

    var body: some View {
        Form {
            TextField("邮箱", text: $email).focused($focus, equals: .email)
            SecureField("密码", text: $password).focused($focus, equals: .password)
            Button("登录") { focus = .email }   // 编程式切焦点
        }
        .onSubmit { focus = (focus == .email ? .password : nil) }
    }
}

5.3 键盘

  • .keyboardType(...) 决定键盘类型(.numberPad / .emailAddress / .default);
  • .submitLabel(.next) 决定回车键文案;
  • .onSubmit { … } 回车触发;
  • 键盘避让:iOS 14+ 默认启用 Form/List 自动避让;自定义视图用 .scrollDismissesKeyboard(.interactively)

6. 动画与过渡

6.1 隐式 vs 显式

swift
// 隐式:modifier 给所有可动画属性加默认动画
Text("\(count)").animation(.easeInOut, value: count)

// 显式:在闭包里改变状态,状态变化会自动用动画过渡
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
    expanded.toggle()
}

6.2 Animation

swift
.easeInOut(duration: 0.3)
.linear(duration: 0.3)
.spring(response: 0.4, dampingFraction: 0.7, blendDuration: 0.2)
.bouncy(duration: 0.5, extraBounce: 0.2)   // iOS 17+ 简化 API
.smooth(duration: 0.4)                       // iOS 17+
.interpolatingSpring(stiffness: 100, damping: 10)
.timingCurve(0.4, 0.0, 0.2, 1.0, duration: 0.4)
.animation(.easeInOut.speed(2))
.repeatForever(autoreverses: true)

6.3 transition:插入/移除时的过渡

swift
if show {
    Text("Hello")
        .transition(.move(edge: .top).combined(with: .opacity))
        // .scale.combined(with: .opacity)
        // .push(from: .leading)  // iOS 17+
}

Button("toggle") {
    withAnimation { show.toggle() }   // 必须在 withAnimation 内变化
}

.asymmetric(insertion:removal:) 可让出现和消失用不同动画。

6.4 matchedGeometryEffect:共享元素动画

swift
@Namespace private var ns

if !expanded {
    Text("Hello").matchedGeometryEffect(id: "title", in: ns)
} else {
    Text("Hello").font(.largeTitle).matchedGeometryEffect(id: "title", in: ns)
}
// 切换时框架自动做"形变过渡"

经典用途:列表 → 详情的 hero animation、tab 切换。

6.5 PhaseAnimator(iOS 17+)

swift
PhaseAnimator([false, true]) { content, phase in
    content.scaleEffect(phase ? 1.2 : 1.0)
} animation: { phase in
    phase ? .bouncy : .linear
}

无需手动驱动状态,按顺序循环动画。

6.6 KeyframeAnimator(iOS 17+)

swift
KeyframeAnimator(initialValue: CGFloat(0)) { value in
    Circle().offset(y: value)
} keyframes: { _ in
    KeyframeTrack(\.self) {
        CubicKeyframe(0, duration: 0.2)
        CubicKeyframe(50, duration: 0.3)
        SpringKeyframe(0, duration: 0.5)
    }
}

适合做"按时间轴精确控制"的复杂动画(如曲线运动、形变)。

6.7 自定义动画(iOS 17+)

swift
struct BounceAnimation: CustomAnimation {
    var amplitude: Double = 20
    func animate<V>(value: V, time: Double, context: AnimationContext) -> V? where V: VectorArithmetic {
        // 自定义曲线函数
        let decay = exp(-time * 3)
        let offset = sin(time * 10) * amplitude * decay
        return value.scaled(by: 1.0 - offset / 100)
    }
}

7. 手势

7.1 高级手势修饰符

swift
.onTapGesture(count: 2) { /* 双击 */ }
.onTapGesture { /* 单击 */ }            // 顺序:先注册更具体的(双击)
.onLongPressGesture(minimumDuration: 1.0) { /* 长按 */ }
    .onLongPressGesture(minimumDuration: 1.0, onPressingChanged: { isPressing in })

DragGesture(minimumDistance: 10)
    .onChanged { value in offset = value.translation }
    .onEnded { value in /* 拖拽结束 */ }

MagnificationGesture()   // 捏合缩放
RotationGesture()        // 双指旋转

7.2 组合手势

swift
let drag = DragGesture()
let tap = TapGesture()

// 串行:先完成 A,再开始 B
let seq = tap.sequenced(before: drag)

// 同时
let both = drag.simultaneously(with: MagnificationGesture())

// 互斥:谁先识别就用谁
let one = tap.exclusively(before: longPress)

7.3 与 UIKit 手势协调

嵌入 UIKit 视图(UIScrollViewMKMapView)后,SwiftUI 手势可能与 UIKit 手势冲突。处理方式:

  • UIViewRepresentable 内用 gestureRecognizerShouldBegin / shouldRecognizeSimultaneouslyWith
  • .simultaneousGesture(drag) 让 SwiftUI 不"独占"手势;
  • .highPriorityGesture(...) 优先某个手势。

8. 绘图与图形

8.1 Path / Shape

swift
Path { path in
    path.move(to: CGPoint(x: 0, y: 0))
    path.addLine(to: CGPoint(x: 100, y: 50))
    path.addCurve(to: CGPoint(x: 200, y: 0), control1: ..., control2: ...)
}
.stroke(.red, lineWidth: 2)
// .fill(.red)

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var p = Path()
        p.move(to: CGPoint(x: rect.midX, y: rect.minY))
        p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        p.closeSubpath()
        return p
    }
}
Triangle().fill(.orange)

8.2 Canvas(iOS 15+)

swift
Canvas { context, size in
    for i in 0..<100 {
        let rect = CGRect(x: i * 5, y: 0, width: 3, height: size.height)
        context.fill(Path(rect), with: .color(.blue.opacity(Double(i)/100)))
    }
}
.frame(width: 500, height: 200)

特点:命令式绘制、极高性能、不参与 SwiftUI 视图树(不能给 Canvas 内的元素加 .onTapGesture)。适合图表、特效、粒子。

8.3 渐变与材质

swift
LinearGradient(colors: [.red, .blue], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [.white, .black], center: .center, startRadius: 0, endRadius: 100)
AngularGradient(colors: [.red, .yellow, .green, .blue, .red], center: .center)

// 毛玻璃材质
.background(.ultraThinMaterial)
.background(.regularMaterial)
.background(.thinMaterial)

9. SwiftUI ↔ UIKit 互操作

9.1 UIViewRepresentable(SwiftUI 用 UIKit)

swift
struct MapView: UIViewRepresentable {
    @Binding var region: MKCoordinateRegion

    func makeUIView(context: Context) -> MKMapView {
        let map = MKMapView()
        map.delegate = context.coordinator
        return map
    }

    func updateUIView(_ uiView: MKMapView, context: Context) {
        uiView.setRegion(region, animated: true)
    }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    class Coordinator: NSObject, MKMapViewDelegate {
        let parent: MapView
        init(_ parent: MapView) { self.parent = parent }
        func mapView(_ mapView: MKMapView, regionDidChangeAnimated: Bool) {
            parent.region = mapView.region
        }
    }
}

模式记忆口诀:make → 创建一次,update → 每次 SwiftUI 状态变化调用,Coordinator → 处理 delegate 回调

9.2 UIViewControllerRepresentable

swift
struct ImagePicker: UIViewControllerRepresentable {
    @Binding var image: UIImage?
    @Environment(\.dismiss) var dismiss

    func makeUIViewController(context: Context) -> UIImagePickerController {
        let p = UIImagePickerController()
        p.delegate = context.coordinator
        return p
    }
    func updateUIViewController(_ vc: UIImagePickerController, context: Context) {}
    func makeCoordinator() -> Coordinator { Coordinator(self) }

    class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
        let parent: ImagePicker
        init(_ parent: ImagePicker) { self.parent = parent }
        func imagePickerController(_ p: UIImagePickerController, didFinishPickingMediaWithInfo info: ...) {
            parent.image = info[.originalImage] as? UIImage
            parent.dismiss()
        }
    }
}

9.3 UIHostingController(UIKit 用 SwiftUI)

swift
let hosting = UIHostingController(rootView: SwiftUIView())
navigationController?.pushViewController(hosting, animated: true)

// UITableViewCell / UICollectionViewCell 里嵌入 SwiftUI(iOS 16+ 推荐)
let registration = UICollectionView.CellRegistration<UICollectionViewCell, Item> { cell, _, item in
    cell.contentConfiguration = UIHostingConfiguration {
        RowView(item: item)
    }
    .margins(.horizontal, 16)
    .margins(.vertical, 8)
}

UIHostingConfiguration 是 Apple 在 iOS 16 引入、用于把 SwiftUI 视图作为 UIKit Cell 内容的官方方式,性能比"包一层 UIHostingController 当 cell"好得多。


10. Preview 与测试

10.1 #Preview 宏(iOS 17+)

swift
#Preview("默认") {
    CounterView()
}

#Preview("深色") {
    CounterView()
        .preferredColorScheme(.dark)
        .environment(AppModel(theme: .dark))
}

#Preview("不同尺寸") {
    CounterView()
        .previewLayout(.sizeThatFits)
}

支持多语言、多色盲模式、多尺寸、多 device:

swift
#Preview {
    ContentView()
}
.environment(\.locale, .init(identifier: "zh-Hans"))
.previewDevice("iPhone 15 Pro")

10.2 单元测试

视图层单元测试在 SwiftUI 里相对难(视图是声明式的、不可直接断言内部)。三种主流做法:

  1. 抽取纯逻辑:把业务逻辑(reducer、parser、calculator)抽到非 View 类型,用 XCTest 测试;
  2. ViewInspector:第三方库,可"打开"视图树做断言;
  3. 快照测试:用 swift-snapshot-testing 渲染视图截图比对。
swift
import ViewInspector
import XCTest

final class CounterViewTests: XCTestCase {
    func testInitialState() throws {
        let sut = CounterView()
        let text = try sut.inspect().find(text: "count: 0")
        XCTAssertNotNil(text)
    }
}

10.3 UI 测试(XCUITest)

swift
final class AppUITests: XCTestCase {
    func testTapIncrements() {
        let app = XCUIApplication()
        app.launch()
        let button = app.buttons["Increment"]
        button.tap()
        XCTAssertEqual(button.label, "count: 1")
    }
}

11. 性能优化与调试

这是面试加分题,也是中大型 SwiftUI 项目的关键。

11.1 Attribute Graph 与 Self._printChanges()

swift
struct SomeView: View {
    var body: some View {
        let _ = Self._printChanges()      // 打印"是谁导致这次重算"
        Text("...")
    }
}

控制台会输出类似:SomeView: @self changed.SomeView: @dependency "user.name" changed.。这是定位"为什么我的视图不停刷新"的最快方法。

11.2 主要性能反模式

  1. 过度依赖 GeometryReader → 引发整页重算;
  2. AnyView 滥用 → 类型擦除、diff 退化;
  3. ForEach(id: \.self) 配合可变数组 → 频繁重建;
  4. 大对象作为 @EnvironmentObject → 所有读取者都依赖整对象;
  5. body 内创建大对象let formatter = DateFormatter() / let url = URL(...)) → 每次重新求值都重建;
  6. if/else 切换复杂子树 → 结构变化导致重建;
  7. 嵌套 VStack 中大量非 lazy 子视图 → 启动慢;
  8. task 中重复发起网络请求 → 应在 task(id:) 中按依赖变化重启。

11.3 优化手段

  • Equatable 视图 + .equatable() modifier:
swift
struct Row: View, Equatable {
    let item: Item
    static func == (lhs: Row, rhs: Row) -> Bool { lhs.item == rhs.item }

    var body: some View { /* ... */ }
}

ForEach(items) { item in
    Row(item: item).equatable()
}
  • 拆分视图:把"很少变"和"经常变"的拆开,让 SwiftUI 精确刷新;
  • @Observable 自带精确追踪,天然优化;
  • Instruments → SwiftUI template:可直接看每帧的 body 求值次数与耗时;
  • 避免在 body 里读文件、解析 JSON、做 IO
  • drawingGroup():把视图树合成成 Metal 渲染(复杂图形、动画场景的优化利器)。

11.4 Accessibility

swift
Text("购买").accessibilityLabel("购买按钮")    // 读屏朗读文案
Image(systemName: "star").accessibilityHidden(true)   // 装饰性元素对 VoiceOver 隐藏
VStack {
    Text("$9.99")
    Text("月费")
}.accessibilityElement(children: .combine)             // 合并为单个可访问性元素
.accessibilityAddTraits(.isButton)
.accessibilityHint("点击进入订阅页")

可访问性不仅是规范要求,对自动化测试(XCUITest 的 label 定位)也至关重要。


12. 项目关键模块(实战代码)

这一章给出生产可用的模块代码骨架,覆盖中大型 App 的常见子系统。

12.1 网络层:APIClient + Repository + DTO

swift
// 1) API 客户端:统一发请求、错误处理、解耦
final class APIClient {
    let baseURL: URL
    let session: URLSession
    let decoder: JSONDecoder

    init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
    }

    func send<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        let request = try endpoint.urlRequest(baseURL: baseURL)
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }
        guard (200..<300).contains(http.statusCode) else {
            throw APIError.statusCode(http.statusCode, data)
        }
        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw APIError.decoding(error)
        }
    }
}

struct Endpoint {
    enum Method: String { case GET, POST, PUT, DELETE }
    var path: String
    var method: Method = .GET
    var query: [URLQueryItem] = []
    var body: Data?
    var headers: [String: String] = [:]

    func urlRequest(baseURL: URL) throws -> URLRequest {
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
        if !query.isEmpty { components?.queryItems = query }
        guard let url = components?.url else { throw APIError.invalidURL }
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.httpBody = body
        for (k, v) in headers { request.setValue(v, forHTTPHeaderField: k) }
        return request
    }
}

enum APIError: Error, LocalizedError {
    case invalidURL, invalidResponse
    case statusCode(Int, Data)
    case decoding(Error)
}

// 2) Repository:业务侧使用,对外暴露领域模型(不是 DTO)
final class FeedRepository {
    let client: APIClient
    init(client: APIClient) { self.client = client }

    struct FeedDTO: Decodable { let items: [ItemDTO]; let nextCursor: String? }
    struct ItemDTO: Decodable { let id: String; let title: String; let imageURL: URL? }

    func fetch(cursor: String?, pageSize: Int = 20) async throws -> ([FeedItem], String?) {
        let endpoint = Endpoint(
            path: "/feed",
            query: [
                .init(name: "limit", value: String(pageSize)),
                .init(name: "cursor", value: cursor)
            ].filter { $0.value != nil }
        )
        let dto: FeedDTO = try await client.send(endpoint)
        let items = dto.items.map { FeedItem(id: $0.id, title: $0.title, imageURL: $0.imageURL) }
        return (items, dto.nextCursor)
    }
}

// 3) 领域模型
struct FeedItem: Identifiable, Hashable {
    let id: String
    let title: String
    let imageURL: URL?
}

12.2 状态管理:Store + Reducer(单向流)

类似 TCA(The Composable Architecture)但更轻量。优点:状态变化可预测、易测试。

swift
@Observable
final class Store<State: Equatable, Action> {
    private(set) var state: State
    private let reducer: (inout State, Action) -> Effect<Action>
    private var effectTask: Task<Void, Never>?

    init(initial: State, reducer: @escaping (inout State, Action) -> Effect<Action>) {
        self.state = initial
        self.reducer = reducer
    }

    func send(_ action: Action) {
        let effect = reducer(&state, action)
        switch effect {
        case .none: break
        case .run(let work):
            effectTask?.cancel()
            effectTask = Task {
                await work(send)
            }
        }
    }
}

enum Effect<Action> {
    case none
    case run((@Sendable (Action) -> Void) async -> Void)
}

12.3 路由模块:Router + Value-based Routing

swift
// 路由枚举(App 级)
enum Route: Hashable {
    case feedDetail(FeedItem)
    case profile(String)            // userId
    case settings
    case web(URL)
}

// 各 Tab 内的 Router
@Observable
final class AppRouter {
    var homePath = NavigationPath()
    var profilePath = NavigationPath()
    var selectedTab: Tab = .home

    func push(_ route: Route, on tab: Tab? = nil) {
        let target = tab ?? selectedTab
        switch target {
        case .home: homePath.append(route)
        case .profile: profilePath.append(route)
        }
    }

    func handle(url: URL) {        // deep link
        guard let route = Route(url: url) else { return }
        push(route)
    }
}

extension Route {
    init?(url: URL) {
        // 解析自定义 scheme / universal link
        ...
    }
}

// View 层
struct RootView: View {
    @Environment(AppRouter.self) var router

    var body: some View {
        TabView(selection: Binding(get: { router.selectedTab },
                                    set: { router.selectedTab = $0 })) {
            NavigationStack(path: Binding(get: { router.homePath },
                                          set: { router.homePath = $0 })) {
                HomeView()
                    .navigationDestination(for: Route.self) { route in
                        RouteView(route: route)
                    }
            }
            .tabItem { Label("首页", systemImage: "house") }
            .tag(Tab.home)
        }
        .onOpenURL { router.handle(url: $0) }
    }
}

12.4 持久化层:SwiftData(iOS 17+)

swift
import SwiftData

@Model
final class Article {
    @Attribute(.unique) var id: String
    var title: String
    var bodyText: String
    var savedAt: Date

    init(id: String, title: String, bodyText: String, savedAt: Date = .now) {
        self.id = id; self.title = title; self.bodyText = bodyText; self.savedAt = savedAt
    }
}

// App 注入
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
        .modelContainer(for: Article.self)
    }
}

// 任意视图读取
struct SavedView: View {
    @Query(sort: \Article.savedAt, order: .reverse) var articles: [Article]
    @Environment(\.modelContext) var ctx

    var body: some View {
        List(articles) { a in
            VStack(alignment: .leading) {
                Text(a.title).bold()
                Text(a.savedAt.formatted()).foregroundStyle(.secondary)
            }
            .swipeActions {
                Button("删除", role: .destructive) { ctx.delete(a) }
            }
        }
    }
}

SwiftData 是 Core Data 的 Swift 原生封装,配合 @Query/@Model/@Environment(\.modelContext) 与 SwiftUI 天然集成。旧项目用 Core Data 时,仍可用 NSPersistentContainer + @FetchRequest

12.5 主题系统:Theme + Environment

swift
struct Theme {
    var primary: Color
    var background: Color
    var secondaryText: Color
    var cornerRadius: CGFloat

    static let light = Theme(primary: .blue, background: .white,
                             secondaryText: .gray, cornerRadius: 12)
    static let dark = Theme(primary: .orange, background: .black,
                            secondaryText: .lightGray, cornerRadius: 12)
}

private struct ThemeKey: EnvironmentKey { static let defaultValue = Theme.light }
extension EnvironmentValues {
    var theme: Theme {
        get { self[ThemeKey.self] } set { self[ThemeKey.self] = newValue }
    }
}

extension View {
    func theme(_ theme: Theme) -> some View { environment(\.theme, theme) }
}

// 使用
struct Card<Content: View>: View {
    @Environment(\.theme) var theme
    @ViewBuilder var content: Content
    var body: some View {
        content
            .padding()
            .background(theme.background)
            .clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius))
    }
}

12.6 加载状态与错误处理:泛型 Loadable

swift
enum Loadable<T> {
    case idle, loading, loaded(T), failed(Error)

    var value: T? { if case .loaded(let v) = self { return v }; return nil }
}

struct AsyncContentView<Source: LoadableSource, Content: View>: View where Source.Value: Equatable {
    @State var source: Source
    @ViewBuilder var content: (Source.Value) -> Content

    var body: some View {
        switch source.state {
        case .idle, .loading:
            ProgressView().task { await source.load() }
        case .loaded(let value):
            content(value)
        case .failed(let error):
            ErrorView(error: error) { Task { await source.load() } }
        }
    }
}

protocol LoadableSource {
    associatedtype Value
    var state: Loadable<Value> { get set }
    mutating func load() async
}

把"加载/错误/重试"封装成通用容器,UI 层只关心"成功内容"。

12.7 可访问性、本地化、动态字号

swift
Text("Hello")
    .font(.headline)
    .accessibilityLabel("问候")    // VoiceOver 朗读
    .dynamicTypeSize(...(.accessibility3))   // 限制最大动态字号(极端情况下不破坏布局)

13. 完整项目架构示例

给一个"内容流 App"的完整架构:Feed 列表 → 详情 → 收藏,包含网络、缓存、SwiftData、路由、错误处理。

13.1 整体分层

┌─────────────────────────────────────────────────┐
│                   App / Scene                   │
│  (依赖注入:APIClient, Repository, ModelContainer)│
└─────────────────────────────────────────────────┘

        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
   Router         AppState        Theme/Env
   (导航)         (全局状态)       (外观)
        │              │
        ▼              ▼
┌─────────────────────────────────────────────────┐
│                  Feature Layer                  │
│  ┌─────────┐ ┌──────────┐ ┌─────────┐           │
│  │  Feed   │ │  Detail  │ │ Profile │   ...     │
│  │ Feature │ │ Feature  │ │ Feature │           │
│  └─────────┘ └──────────┘ └─────────┘           │
└─────────────────────────────────────────────────┘
        │              │
        ▼              ▼
┌─────────────────────────────────────────────────┐
│                Domain / Data                    │
│   Repository · APIClient · SwiftData Models     │
└─────────────────────────────────────────────────┘

设计原则:

  1. 单向数据流:View → Action → Store/Reducer → State → View;
  2. 依赖注入:所有外部依赖(API、Database、Analytics)通过 @Environment 注入;
  3. Feature 模块化:每个 Feature 自包含 View / Store / Repository;Feature 之间通过 Router 与 AppState 解耦;
  4. 可测试性:Repository、Store 都是纯 Swift 类型,可脱离 UI 单测。

13.2 App 入口与依赖注入

swift
@main
struct MyApp: App {
    @State private var appState = AppState()
    @State private var router = AppRouter()
    private let apiClient = APIClient(baseURL: URL(string: "https://api.example.com")!)
    private let feedRepo: FeedRepository

    init() {
        let api = APIClient(baseURL: URL(string: "https://api.example.com")!)
        self.apiClient = api
        self.feedRepo = FeedRepository(client: api)
    }

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(appState)
                .environment(router)
                .environment(feedRepo)
        }
        .modelContainer(for: [Article.self])
    }
}

@Observable
final class AppState {
    var session: Session?          // 登录态
    var settings: Settings = .init()
}

13.3 Feature:Feed 列表

swift
// MARK: - State & Actions

@Observable
final class FeedStore {
    enum State: Equatable {
        case idle, loading, loaded([FeedItem], nextCursor: String?), failed(String)
    }

    var state: State = .idle
    var savedArticleIDs: Set<String> = []

    private let repo: FeedRepository
    private var bag: Task<Void, Never>?

    init(repo: FeedRepository) { self.repo = repo }

    func load() async {
        bag?.cancel()
        state = .loading
        do {
            let (items, cursor) = try await repo.fetch(cursor: nil)
            state = .loaded(items, nextCursor: cursor)
        } catch {
            state = .failed(error.localizedDescription)
        }
    }

    func loadMore() async {
        guard case .loaded(let items, let cursor?) = state else { return }
        do {
            let (newItems, nextCursor) = try await repo.fetch(cursor: cursor)
            state = .loaded(items + newItems, nextCursor: nextCursor)
        } catch {
            // 加载更多失败:保持原数据,提示 toast
        }
    }
}

// MARK: - View

struct FeedView: View {
    @Environment(FeedRepository.self) var repo
    @Environment(AppRouter.self) var router
    @State private var store: FeedStore?

    var body: some View {
        NavigationStack(path: Binding(
            get: { router.homePath },
            set: { router.homePath = $0 }
        )) {
            Group {
                if let store {
                    content(store: store)
                }
            }
            .navigationTitle("动态")
            .navigationDestination(for: Route.self) { route in
                RouteView(route: route)
            }
        }
        .task {
            if store == nil {
                let s = FeedStore(repo: repo)
                store = s
                await s.load()
            }
        }
    }

    @ViewBuilder
    private func content(store: FeedStore) -> some View {
        switch store.state {
        case .idle, .loading:
            ProgressView()
        case .failed(let msg):
            ErrorView(message: msg) { Task { await store.load() } }
        case .loaded(let items, _):
            List(items) { item in
                Button { router.push(.feedDetail(item)) } label: {
                    FeedRow(item: item)
                }
                .buttonStyle(.plain)
            }
            .listStyle(.plain)
            .refreshable { await store.load() }
        }
    }
}

13.4 Feature:详情 + 收藏

swift
struct DetailView: View {
    let item: FeedItem
    @Environment(\.modelContext) var ctx
    @Query var allArticles: [Article]

    private var isSaved: Bool { allArticles.contains { $0.id == item.id } }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 12) {
                Text(item.title).font(.title2).bold()
                if let url = item.imageURL {
                    AsyncImage(url: url) { img in img.resizable().aspectRatio(contentMode: .fill) }
                        placeholder: { Color.gray.opacity(0.2) }
                        .frame(height: 220)
                        .clipped()
                }
            }
            .padding()
        }
        .navigationTitle("详情")
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button {
                    toggleSaved()
                } label: {
                    Image(systemName: isSaved ? "star.fill" : "star")
                }
            }
        }
    }

    private func toggleSaved() {
        if let exist = allArticles.first(where: { $0.id == item.id }) {
            ctx.delete(exist)
        } else {
            ctx.insert(Article(id: item.id, title: item.title, bodyText: ""))
        }
    }
}

13.5 RouteView(集中分发)

swift
struct RouteView: View {
    let route: Route
    var body: some View {
        switch route {
        case .feedDetail(let item): DetailView(item: item)
        case .profile(let id): ProfileView(userId: id)
        case .settings: SettingsView()
        case .web(let url): WebView(url: url)
        }
    }
}

13.6 数据流回顾

用户下拉刷新


FeedView.refreshable


FeedStore.load()  (async)


FeedRepository.fetch()


APIClient.send(...) → URLSession


DTO → FeedItem(领域模型)


store.state = .loaded(...)         ← @Observable 触发


FeedView.body 自动重新求值


List 刷新(AG 精确定位到 state 变化)

每一步都可以单独替换或测试

  • 换 Mock APIClient → 测试 Repository;
  • 换 Mock FeedRepository → 测试 Store;
  • 用 ViewInspector → 测试 View。

13.7 架构选型对比(用作面试回答模板)

架构状态共享状态可预测性学习曲线适用场景
MVVM + CombineViewModel @Bindable中(双向绑定复杂时易乱)中小项目、迁移自 UIKit
MVI / Store + Reducer(本文方案)Store + Action(单向流)中大型项目
TCA全局 Store极高(含副作用管理)大型 App、可测试性优先
Architectures各 Feature 独立模块化团队
Coordinator协调器层级中(解决导航)复杂导航、多入口

选型要点:团队规模 → 业务复杂度 → 状态可预测性诉求 → 测试覆盖率诉求。SwiftUI 的声明式特性天然适合单向流,MVI/Store 模式与 SwiftUI 的契合度最高


附录 A:高频面试题精选(含要点回答)

Q1. @State / @StateObject / @ObservedObject / @EnvironmentObject / @Observable / @Bindable 的区别?

  • @State:值类型本地状态,当前视图拥有,存在 SwiftUI 内部存储;
  • @StateObject:引用类型 + 拥有 + 创建(旧);
  • @ObservedObject:引用类型 + 外部传入,不管理生命周期(旧);
  • @EnvironmentObject:跨视图树共享的引用类型(旧);
  • @Observable(iOS 17+):宏,给 class 自动加属性级依赖追踪;
  • @Bindable(iOS 17+):让 @Observable 对象支持 $ 绑定语法,不持有不创建。

Q2. SwiftUI 的 body 是怎么被触发的?

  • 任何被 SwiftUI 在 body读取@State / @Observable 属性会注册依赖;
  • 属性变化时,Attribute Graph 沿依赖路径只重算受影响节点
  • Self._printChanges() 可以打印"是谁变了"。

Q3. 为什么 modifier 顺序会影响效果?

每个 modifier 实际上是"包装原视图的新类型"。.padding().background(.red) 等价于"先 padding 再给 padding 后的视图铺红底";.background(.red).padding() 则是"先铺红底,再在外部加 padding"——视觉边界不同。

Q4. some ViewAnyView 区别?

  • some View 是不透明返回类型,编译期已知具体类型,性能最优;
  • AnyView 类型擦除,运行时动态分发,破坏 diff 性能
  • 仅在需要"动态切换不同具体类型的视图"时使用 AnyView,否则用 Group / @ViewBuilder 解决。

Q5. if/elsebody 里和 .opacity() 切换有什么区别?

if/else 改变视图树结构(结构身份),SwiftUI 会销毁旧分支、创建新分支;.opacity() 切换不改变结构,只是绘制属性变化。频繁切换的"内容显隐"用 opacity/transaction 更省;条件性"完全不同的内容"用 if/else

Q6. 如何优化 SwiftUI 列表性能?

  1. List 而不是 LazyVStack(List 有视图复用,Lazy 系列没有);
  2. ForEach 必须有稳定 id
  3. 行视图加 .equatable()
  4. @Observable 替代 ObservableObject 精确追踪;
  5. 避免在行视图内做 IO、解析;
  6. 用 Instruments → SwiftUI template 定位热点。

Q7. NavigationStack 与 NavigationView 区别?

  • NavigationView(弃用):API 模糊、iPad 行为不一致;
  • NavigationStack:明确栈语义、支持 path 编程式控制、支持 navigationDestination(for:) 类型路由;
  • NavigationSplitView:用于 iPad/Mac 多栏布局。

Q8. @AppStorage@SceneStorage、UserDefaults、SwiftData 各适用什么?

  • @AppStorage:UserDefaults 语法糖,少量配置;
  • @SceneStorage:场景级临时数据(每个窗口隔离),系统恢复时还原;
  • UserDefaults:直接访问、跨进程;
  • SwiftData / Core Data:结构化、关联关系、大量数据。

Q9. SwiftUI 中如何做 hero animation?

swift
@Namespace var ns

// 列表
Thumbnail().matchedGeometryEffect(id: item.id, in: ns)

// 详情(push 后)
Hero().matchedGeometryEffect(id: item.id, in: ns)

需在 withAnimation 触发的状态变化下生效(push 通常需要在自定义 transition 中协调,或用 matchedGeometryEffect 配合 sheet/overlay)。

Q10. 如何处理 SwiftUI 与 UIKit 手势冲突?

  • .simultaneousGesture(...):让两个手势并行识别;
  • .highPriorityGesture(...):优先某个;
  • .gesture(..., including: .all):限定手势类别;
  • UIViewRepresentableCoordinator 内实现 UIGestureRecognizerDelegate

附录 B:iOS 版本 API 速查

iOS 版本关键新增
13SwiftUI 首发,基础视图、NavigationView@State/@Binding/@ObservedObject
14App/Scene@StateObject@AppStorageLazyVStack/LazyHStackTextEditor、Widget
15AsyncImageCanvas.refreshableAttributedStringMaterial.task
16NavigationStackNavigationSplitViewLayout 协议、GridViewThatFitsAnyLayoutUIHostingConfiguration
17@Observable 宏、#Preview@BindableScrollView 改进、PhaseAnimator/KeyframeAnimatorSwiftDataCustomAnimationSymbol effects
18TabView 重做、onGeometryChangeMeshGradient、改进的 ScrollView、新的 widget 能力

附录 C:常用 modifier 速查

swift
// 尺寸与布局
.frame(width:height:alignment:)
.frame(maxWidth:maxHeight:)
.padding(.horizontal, 16)
.offset(x:y:)
.position(x:y:)
.fixedSize()
.ignoresSafeArea()
.layoutPriority(1)

// 文字
.font(.title)
.foregroundStyle(.red)
.bold().italic().underline()
.lineLimit(2).truncationMode(.tail)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.8)

// 形状
.clipShape(RoundedRectangle(cornerRadius: 12))
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.gray, lineWidth: 1))
.shadow(color:radius:x:y:)

// 交互
.onTapGesture {}.onLongPressGesture {}
.draggable("text").dropDestination(for: String.self) { }
.hoverEffect(.lift)              // iPadOS

// 列表
.listStyle(.insetGrouped)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.refreshable { }
.swipeActions { }

// 导航
.navigationTitle("标题")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarTrailing) { } }
.navigationBarBackButtonHidden()

// 弹窗
.sheet(isPresented:) { }
.fullScreenCover(isPresented:) { }
.confirmationDialog(_,isPresented:) { }
.alert(_,isPresented:) { }

// 状态依赖
.task { }
.task(id: someValue) { }
.onChange(of: initialValue) { oldValue, newValue in }
.onReceive(publisher) { }

附录 D:反模式速查(面试加分项)

反模式后果正确做法
@StateObject var model: T 接收外部传入数据被重置@ObservedObject@Observable + 普通属性
let _ = print(...) 在 body 中做重活每次重算都执行抽出到 onAppear
大量 AnyViewdiff 性能下降@ViewBuilder / Group
ForEach(0..<n, id: \.self) 用于动态数组顺序变化触发重建用业务 id
在 body 内创建 DateFormatter重复分配改为常量 / 静态属性
GeometryReader 包裹整个布局性能下降、布局退化仅在必要时使用
body 中调用网络请求每次重算都触发.task
全局 @StateObject 当作"全局变量"数据来源不明@Environment(AppState.self)
if/else 切换大量子树结构变化导致重建opacity / transaction

延伸阅读


结语:SwiftUI 的核心是状态驱动 + 单向数据流 + 编译期类型。掌握第 0、3、11 章的"心智模型 + 状态 + 性能",剩下的 API 都是查文档的事。架构选型没有银弹——团队规模、业务复杂度、可测试性诉求决定你该选 MVVM 还是 MVI;最重要的是把"状态变化的来源"写得清晰可追溯

基于 VitePress 构建 · 部署于 Cloudflare Pages