优秀的编程知识分享平台

网站首页 > 技术文章 正文

SwiftUI 本质论:声明式、可组合、状态驱动

nanyue 2025-09-18 23:49:44 技术文章 1 ℃

SwiftUI 本质论:声明式、可组合、状态驱动


引言:为什么是 SwiftUI,为什么是现在?

SwiftUI 的目标非常直接:用最短路径把你带到“优秀 App”。这个最短路径并不是偷工减料,而是让系统“替你干对的事”

  • o 原生控件 + 自适配(暗黑模式、动态字体、不同输入方式)。
  • o 一套声明式语法表达你想要的结果,由框架完成如何实现
  • o 把你从“增删 cell / 手写状态同步 / 兼容多平台差异”的琐事里解放出来,把更多精力投入到独特功能体验打磨

要点回顾

o SwiftUI 提供控件(Button/Toggle/Picker)、容器(Stack/List/Form)、绘制、动画、手势。o 把平台特性语义化(macOS 菜单、watchOS 数字表冠、tvOS Siri Remote)。o 不是“Write Once, Run Anywhere”,而是“Learn Once, Apply Anywhere”。


目录

  1. 1. SwiftUI 的三根柱子:声明式 / 可组合 / 状态驱动
  2. 2. 从容器开始:VStack/HStack/ZStack/List/FormViewBuilder
  3. 3. 修饰符(Modifier)链:顺序即层级,语义即结果
  4. 4. 数据流入门:@State@Binding 的双人舞
  5. 5. 控件的“语义化适配”:Button / Toggle / Picker 深入
  6. 6. 无障碍与环境(Accessibility & Environment):把“上下文”变成一等公民
  7. 7. 导航与结构:NavigationStack / NavigationSplitView / TabView(以及旧 API 兼容)
  8. 8. 跨平台要点:iOS / macOS / watchOS 的差异与统一
  9. 9. 完整实战:牛油果吐司订购 App(表单 + 历史 + 放蛋位拖拽 + 导航)
  10. 10. 最佳实践十条 & 常见坑排查
  11. 11. 思考题
  12. 12. 知识小结(TL;DR)

1. SwiftUI 的三根柱子:声明式 / 可组合 / 状态驱动

1.1 声明式(Declarative)

描述 UI 的“是什么”,由 SwiftUI 负责**“怎么做”**。不再增/删 subview、不再手动 diff 列表,也不用为默认动画操心;你只需要保证状态正确

类比:做牛油果吐司

  • o 命令式:电话指导朋友每一步(取面包→烤→切牛油果…稍有失误全盘皆输)。
  • o 声明式:找加州的牛油果工匠,说出期望偏好,专家搞定细节。

1.2 可组合(Compositional)

小视图大界面:容器(Stack/List/Form)+ 修饰符(Modifier)叠加。视图是值类型 struct,仅是“描述”。SwiftUI 内部有高效结构负责渲染与手势,拆分更多小组件几乎没有性能负担

1.3 状态驱动(State-Driven)

UI = f(state)。谁被读取,谁是依赖;依赖改变,SwiftUI 重算 body 并最小化更新输出(屏幕/手势/可访问性)。


2. 从容器开始:ViewBuilder 与层级结构

容器视图(如 VStack/HStack/ZStackListForm)通过 @ViewBuilder 闭包声明孩子视图;你写出来的层级直观映射为 UI 结构。

# Swift
import SwiftUI

struct SimpleForm: View {
    @State private var includeSalt = true
    @State private var quantity = 1

    var body: some View {
        Form {                       // 与 VStack 同为容器,但对“表单”语义更友好
            Section("基本配置") {
                Toggle("加入盐", isOn: $includeSalt)
                Stepper("数量:\(quantity)", value: $quantity, in: 1...6)
            }

            Section {
                Button("下单") { /* 提交 */ }
            }
        }
        .navigationTitle("牛油果吐司")
    }
}

注意:容器切换(VStackForm无需改变内部控件定义;系统会按上下文自动适配背景、分隔线、按压态等细节。


3. 修饰符(Modifier)链:顺序就是层级

修饰符是“返回新视图的函数”,围绕基础视图包裹一层层“外衣”。
顺序决定层级与效果

  • o Text("Hi").padding().background(.green) → 背景包住文字+内边距
  • o Text("Hi").background(.green).padding() → 绿色仅包住文字,外面再加空白
# Swift
struct ModifierOrderDemo: View {
    var body: some View {
        VStack(spacing: 16) {
            Text("背景包住 padding")
                .padding()
                .background(.green.opacity(0.2))

            Text("背景只包住文字")
                .background(.green.opacity(0.2))
                .padding()
        }
        .padding()
    }
}

实践建议

  • o 把“可复用的修饰符串”封装为自定义 ViewModifier,统一风格、减少重复。
  • o 需要连贯动画时,尽量“把条件放进修饰符的参数”,而不是 if 切换不同视图类型(避免不必要的淡入淡出)。

4. 数据流入门:@State与@Binding的双人舞

4.1@State:视图的内部状态

由 SwiftUI 持有存储;本视图读写;改变即重算 body。

# Swift
struct Counter: View {
    @State private var count = 0
    var body: some View {
        VStack {
            Text("\(count)").font(.largeTitle).contentTransition(.numericText())
            HStack {
                Button("-") { withAnimation { count -= 1 } }
                Button("+") { withAnimation { count += 1 } }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

4.2@Binding:双向引用外部状态

父视图拥有“事实来源”,子视图通过 Binding 读写。

# Swift
struct StepperEditor: View {
    @Binding var value: Int
    var body: some View {
        Stepper("数量:\(value)", value: $value, in: 1...6)
    }
}

struct Host: View {
    @State private var qty = 1
    var body: some View {
        StepperEditor(value: $qty) // 传入 Binding
    }
}

记忆本地数据用 @State父子共享用 @Binding;跨层共享再上升到 ObservableObject 家族(详见延伸阅读)。


5. 控件的“语义化适配”:Button / Toggle / Picker

5.1 Button:动作 + 标签

语义是“带标签的动作”,因此在不同上下文(滑动、菜单、表单)自动“换装”,但含义不变。

# Swift
Button {
    // 动作:提交订单
} label: {
    Label("下单", systemImage: "cart")
}
.buttonStyle(.borderedProminent) // 可换 .borderless / .bordered / .plain / 自定义 style
.tint(.green)

5.2 Toggle:开/关 + 标签(带Binding)

不仅外观可适配(开关/复选/切换按钮),而且自动接入辅助功能(VoiceOver 会读出标签和状态)。

# Swift
@State private var includeSalt = true
Toggle("加入盐", isOn: $includeSalt)

5.3 Picker:选项集合 + 选择值 + 标签

selectionBinding;选项用 ForEach 数据驱动;样式可在不同平台/上下文自适配或手动指定。

# Swift
enum Bread: String, CaseIterable, Identifiable { case sourdough, bagel, brioche
    var id: Self { self }
}
@State private var bread: Bread = .sourdough

Picker("面包", selection: $bread) {
    ForEach(Bread.allCases) { kind in
        Text(kind.rawValue.capitalized).tag(kind)
    }
}
// iOS 表单中默认“导航式”选择;也可强制 wheel 或 segmented
.pickerStyle(.menu) // .segmented / .inline / .wheel / .navigationLink 等

为什么 SwiftUI 控件“少而精”?
控件围绕“目的/角色”定义,而不是“外观”。在语义不变的前提下,自适配不同平台与场景,API 面更小更稳定,你也更容易迁移复用。


6. 无障碍(Accessibility)与环境(Environment)

6.1 无障碍基础

  • o 为非文本标签提供 .accessibilityLabel 或使用 Label
  • o VoiceOver / Voice Control 会自动读出控件的目的与状态(因其语义化定义)。
# Swift
Image("egg")
    .accessibilityLabel("水波蛋")

6.2 环境(Environment):把上下文注入为“一等公民”

环境值描述“视图所处环境”(布局方向、色彩方案、是否启用、地区语言等)。

  • o 视图继承父环境;
  • o 你可 .environment(\.xxx, value) 覆写子树
  • o 自定义视图也可读环境,自动响应变化。
# Swift
struct EggPlacement: View {
    @Environment(\.isEnabled) private var isEnabled  // 读取是否可交互
    @State private var offset: CGSize = .zero

    var body: some View {
        ZStack {
            Image("toast")
            Image("egg")
                .offset(offset)
                .gesture(
                    DragGesture().onChanged { offset = $0.translation }
                )
                .saturation(isEnabled ? 1.0 : 0.0) // 禁用时去饱和
        }
        .frame(height: 220)
    }
}

// 一键禁用子树(含手势)
Form { /* ... */ }
.disabled(true)

7. 导航与结构:NavigationStack/NavigationSplitView/TabView

API 更新提示

o 早年示例里出现的 NavigationViewNavigationLink(字幕里甚至有旧称“Navigationbutton”)在 iOS 16+ 逐步过渡到新的**NavigationStack/NavigationSplitView** 架构与值类型导航o 若你的 minOS 较新,优先学习并使用 NavigationStack

7.1 单栈导航:NavigationStack + NavigationLink

# Swift
struct Root: View {
    var body: some View {
        NavigationStack {
            List(0..<10) { i in
                NavigationLink("订单 #\(i)") {
                    OrderDetail(id: i)
                }
            }
            .navigationTitle("订单历史")
        }
    }
}

7.2 分栏导航:NavigationSplitView(iPad/macOS 最佳)

# Swift
struct SplitRoot: View {
    @State private var selection: Int?
    var body: some View {
        NavigationSplitView {
            List(0..<50, selection: $selection) { i in
                Text("订单 #\(i)")
            }
        } detail: {
            if let id = selection {
                OrderDetail(id: id)
            } else {
                Text("请选择订单")
            }
        }
    }
}

7.3 Tab 结构

# Swift
TabView {
    OrderFormView()
        .tabItem { Label("下单", systemImage: "cart") }
    OrderHistoryView()
        .tabItem { Label("历史", systemImage: "clock") }
}

8. 跨平台要点:iOS / macOS / watchOS 的差异与统一

  • o iOS:表单风格(Form)+ 导航行式 Picker。
  • o macOS:信息密度更高;Picker 常见 menu/radioGroup;窗口与菜单特性丰富。
  • o watchOS:旋转表冠 digitalCrownRotation 交互、密度极简。
  • o 共同点:控件“语义化”定义,保证“学一次、到处用”。
# Swift (watchOS 示例)
struct CrownStepper: View {
    @State private var value: Double = 3
    var body: some View {
        Text("分数:\(Int(value))")
            .digitalCrownRotation($value, from: 0, through: 10, by: 1)
            .focusable()   // Watch 交互聚焦
    }
}

9. 完整实战:牛油果吐司订购 App

目标:

o 表单下单(面包/抹酱/加盐/数量);o 选择“是否加蛋”与拖拽放置位置o 订单历史列表 & 详情;o Tab 结构 + 栈式导航;o 可访问性/禁用态/环境值;o 现代 API(NavigationStack)。

9.1 模型与枚举

# Swift
import SwiftUI

enum Bread: String, CaseIterable, Identifiable {
    case sourdough, bagel, brioche
    var id: Self { self }
    var label: String { switch self {
        case .sourdough: return "酸面包"
        case .bagel:     return "贝果"
        case .brioche:   return "布里欧修"
    }}
}

enum Spread: String, CaseIterable, Identifiable {
    case butter, hummus, creamCheese, peanut
    var id: Self { self }
    var label: String { ["黄油","鹰嘴豆泥","奶油奶酪","花生酱"][Self.allCases.firstIndex(of: self)!] }
}

struct Order: Identifiable, Hashable {
    let id: UUID = .init()
    var bread: Bread
    var spread: Spread
    var includeSalt: Bool
    var includeEgg: Bool
    var eggOffset: CGSize? // 蛋位置(可选)
    var quantity: Int
    var time: Date = .init()
    var summary: String {
        "\(bread.label) + \(spread.label) \(includeSalt ? "加盐" : "无盐") ×\(quantity)" + (includeEgg ? " + 鸡蛋" : "")
    }
}

9.2 订单状态 Store(演示用@State,可扩展ObservableObject)

# Swift
@MainActor
final class OrderStore: ObservableObject {
    @Published var current = Order(
        bread: .sourdough,
        spread: .butter,
        includeSalt: true,
        includeEgg: false,
        eggOffset: nil,
        quantity: 1
    )
    @Published var history: [Order] = []

    func submit() {
        history.insert(current, at: 0)
        // 重置当前订单(保留部分偏好)
        current.quantity = 1
        current.includeEgg = false
        current.eggOffset = nil
    }
}

9.3 放蛋位拖拽视图

# Swift
struct EggPlacementView: View {
    @Environment(\.isEnabled) private var isEnabled
    @Binding var offset: CGSize?

    var body: some View {
        ZStack {
            Image("toast").resizable().scaledToFit()
            Image("egg")
                .resizable().scaledToFit()
                .frame(width: 60, height: 60)
                .offset(offset ?? .zero)
                .gesture(
                    DragGesture().onChanged { gesture in
                        offset = gesture.translation
                    }
                )
                .saturation(isEnabled ? 1 : 0)   // 被禁用时视觉降饱和
                .accessibilityLabel("水波蛋位置")
        }
        .frame(height: 220)
        .padding(.vertical)
    }
}

提示

o .disabled(true) 能“一键”禁用手势与控件;o 自定义视图可通过环境读取 isEnabled 生成视觉反馈。

9.4 下单表单

# Swift
struct OrderFormView: View {
    @EnvironmentObject var store: OrderStore
    @State private var networkOK = true  // 演示禁用态:假设网络状态

    var body: some View {
        Form {
            Section("基本配置") {
                Picker("面包", selection: $store.current.bread) {
                    ForEach(Bread.allCases) { b in Text(b.label).tag(b) }
                }
                Picker("抹酱", selection: $store.current.spread) {
                    ForEach(Spread.allCases) { s in Text(s.label).tag(s) }
                }
                Toggle("加入盐", isOn: $store.current.includeSalt)
                Stepper("数量:\(store.current.quantity)", value: $store.current.quantity, in: 1...6)
            }

            Section("加鸡蛋") {
                Toggle("需要鸡蛋", isOn: $store.current.includeEgg.animation()) // 动画插入行
                if store.current.includeEgg {
                    NavigationLink("放置位置") {
                        EggPlacementView(offset: $store.current.eggOffset)
                            .navigationTitle("放置鸡蛋")
                            .toolbar {
                                Button("归位") { store.current.eggOffset = .zero }
                            }
                    }
                }
            }

            Section {
                Button {
                    store.submit()
                } label: {
                    Label("下单", systemImage: "cart.fill")
                }
                .buttonStyle(.borderedProminent)
                .tint(.green)
                .disabled(!networkOK || store.current.quantity == 0)
            } footer: {
                Text(networkOK ? "下单将保存到历史" : "网络不可用,表单已禁用")
            }
        }
        .navigationTitle("牛油果吐司")
        .accentColor(.green)  // 可作用于整棵子树
        .disabled(!networkOK && true) // 演示:一键禁用整棵表单(含手势)
        .toolbar {
            Button {
                networkOK.toggle()
            } label: {
                Label(networkOK ? "断网" : "联网", systemImage: networkOK ? "wifi.slash" : "wifi")
            }
        }
    }
}

9.5 历史列表与详情

# Swift
struct OrderHistoryView: View {
    @EnvironmentObject var store: OrderStore

    var body: some View {
        List {
            ForEach(store.history) { order in
                NavigationLink(value: order) {
                    HStack {
                        VStack(alignment: .leading) {
                            Text(order.summary)
                            Text(order.time, style: .time).font(.caption).foregroundStyle(.secondary)
                        }
                        Spacer()
                        if order.includeEgg { Image("egg").resizable().frame(width: 20, height: 20) }
                    }
                    .accessibilityElement(children: .combine)
                    .accessibilityLabel("订单 \(order.summary)")
                }
            }
        }
        .navigationTitle("订单历史")
    }
}

struct OrderDetail: View {
    let order: Order
    var body: some View {
        VStack(spacing: 16) {
            Text(order.summary).font(.title3)
            EggPlacementView(offset: .constant(order.eggOffset))
                .disabled(true)
            Spacer()
        }
        .padding()
        .navigationTitle("订单详情")
    }
}

9.6 应用入口:Tab + Stack 导航

# Swift
@main
struct AvocadoToastApp: App {
    @StateObject private var store = OrderStore()

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                TabView {
                    OrderFormView()
                        .tabItem { Label("下单", systemImage: "cart") }

                    OrderHistoryView()
                        .tabItem { Label("历史", systemImage: "clock") }
                }
                .navigationDestination(for: Order.self) { OrderDetail(order: $0) }
            }
            .environmentObject(store)
        }
    }
}

运行提示:将 Assets 添加 toastegg 两张图片;无图也可将图片换成 SF Symbols(例如 Image(systemName: "frying.pan"))以快速运行。


10. 最佳实践十条 & 常见坑排查

10.1 最佳实践

  1. 1. 小组件化OrderRowEggPlacementViewStepperEditor 等拆小,复用、预览、测试都更顺手。
  2. 2. 把条件放进修饰符参数:需要连续动画/过渡时,尽量不要用 if 切视图类型
  3. 3. 单一事实来源:局部 @State → 父子 @Binding → 跨层 ObservableObject。避免状态复制。
  4. 4. 预览先行:为关键视图写多种 #Preview(深色/大字/RTL),减少真机反复跑。
  5. 5. 列表 id 稳定Identifiable.id 保证稳定,避免闪烁与错误动画。
  6. 6. 动画粒度withAnimation{} 包裹改变状态的地方,小步快跑,避免整页抖动。
  7. 7. 环境驱动:优先用 Environment.disabled/.tint/.accentColor向下渗透策略。
  8. 8. 导航用新栈:能用 NavigationStack/NavigationSplitView 就不要新项目里继续 NavigationView
  9. 9. 平台分支:必要时用 #if os(macOS) 等分支,逻辑与 UI 解耦
  10. 10. 互操作边界清晰:需要 UIKit 控件时用 UIViewRepresentable 包装,生命周期集中在 Coordinator

10.2 常见坑

  • o “改了数据 UI 不刷”:确认该数据是否在 body 被读取;ObservableObject 记得 @Published;拥有者用 @StateObject
  • o “Binding 传错”:子视图参数是 @Binding,父级需传 $state,而非拷贝值。
  • o “动画变成淡入淡出”:你很可能 if 切换了两种不同视图类型;把条件移入 modifier 的参数。
  • o “列表刷新错位”id 不稳定或重复;或对数据做了非等价替换。
  • o “预览总编不过”:缺资源/条件编译;先用最小依赖跑通预览,再逐项打开。

11. 知识小结

  • o 声明式:描述“结果”,由框架“实现过程”;UI = f(state)。
  • o 可组合:小视图 + 容器 + 修饰符;视图是值类型,拆分不损性能
  • o 状态驱动@State(本地)→ @Binding(父子)→ ObservableObject(跨层)。
  • o 修饰符链:顺序即层级;善用自定义 ViewModifier 复用风格。
  • o 控件语义化:Button/Toggle/Picker 在不同上下文自适配;你专注“目的”,系统负责“外观”。
  • o 无障碍与环境:标签/状态天然可被 VoiceOver 等读取;Environment 让上下文传递与覆写变容易。
  • o 导航现代化NavigationStack / NavigationSplitView + TabView 组合,覆盖 iPhone/iPad/macOS。
  • o 跨平台:Learn Once, Apply Anywhere;必要处做平台分支与样式覆写。
  • o 迁移策略:从新功能或单页开始引入;UIKit 控件可互嵌;保持所有权与状态边界清晰。

最近发表
标签列表