mcpField
A
axiom-swiftui-architecture

axiom-swiftui-architecture

github.com/charleswiltgen开发1.2k273 次安装今天更新
代码仓库

关于 axiom-swiftui-architecture

面向 SwiftUI 开发者的架构参考 Skill,帮助判断逻辑归属并构建可测试的应用。内容比较 Apple 原生模式、MVVM、TCA 与 Coordinator,并提供重构指导。

主要功能

  1. 01架构选择决策树
  2. 02Apple 原生 @Observable 模式
  3. 03MVVM 与 TCA 的取舍建议
  4. 04Coordinator 导航与深度链接
  5. 05从视图提取逻辑的工作流

适用场景

  1. 01为新的 SwiftUI 应用选择架构模式
  2. 02从视图文件中提取业务逻辑
  3. 03让导航逻辑具备可测试性
  4. 04为团队开发组织展示逻辑

安装

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

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

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

详细介绍

SwiftUI 架构

何时使用此技能

在以下情况下使用此技能:

  • SwiftUI 视图文件中包含逻辑,且你想将其提取出来
  • 在 MVVM、TCA、SwiftUI 原生模式或 Coordinator 之间进行选择
  • 重构视图以分离关注点
  • 让 SwiftUI 代码可测试
  • 思考“这段代码应该放在哪里?”
  • 决定使用哪个属性包装器(@State、@Environment、@Bindable)
  • 为团队开发整理 SwiftUI 代码库

示例提示词

你可能会问什么此技能有何帮助
“我的模型视图文件里有不少关于逻辑的代码。我该如何提取这些代码?”提供重构工作流,并通过决策树说明逻辑应放在哪里
“我应该使用 MVVM、TCA,还是 Apple 的原生模式?”根据应用复杂度、团队规模和可测试性需求提供决策标准
“如何让 SwiftUI 代码可测试?”展示可在不导入 SwiftUI 的情况下进行测试的分离模式
“格式化器和计算逻辑应该放在哪里?”反模式一节可避免在视图主体中编写逻辑
“我应该使用哪个属性包装器?”提供选择 @State、@Environment、@Bindable 或普通属性的决策树

架构快速决策树

What's driving your architecture choice?
│
├─ Starting fresh, small/medium app, want Apple's patterns?
│  └─ Use Apple's Native Patterns (Part 1)
│     - @Observable models for business logic
│     - State-as-Bridge for async boundaries
│     - Property wrapper decision tree
│
├─ Familiar with MVVM from UIKit?
│  └─ Use MVVM Pattern (Part 2)
│     - ViewModels as presentation adapters
│     - Clear View/ViewModel/Model separation
│     - Works well with @Observable
│
├─ Complex app, need rigorous testability, team consistency?
│  └─ Consider TCA (Part 3)
│     - State/Action/Reducer/Store architecture
│     - Excellent testing story
│     - Learning curve + boilerplate trade-off
│
└─ Complex navigation, deep linking, multiple entry points?
   └─ Add Coordinator Pattern (Part 4)
      - Can combine with any of the above
      - Extracts navigation logic from views
      - NavigationPath + Coordinator objects

第 1 部分:Apple 原生模式(iOS 26+)

核心原则

“数据模型将数据与和数据交互的视图分离开来。这种分离能提升模块化程度和可测试性,也让理解应用的工作方式变得更容易。” — Apple Developer Documentation

Apple 的现代 SwiftUI 模式(WWDC 2023-2025)围绕以下内容展开:

  1. @Observable 用于数据模型(替代 ObservableObject)
  2. 用于异步边界的 State-as-Bridge(WWDC 2025)
  3. 三个属性包装器:@State、@Environment、@Bindable
  4. 用于动画的同步 UI 更新

State-as-Bridge 模式

问题

异步函数会创建暂停点,可能导致动画异常:

// ❌ Problematic: Animation might miss frame deadline
struct ColorExtractorView: View {
    @State private var isLoading = false

    var body: some View {
        Button("Extract Colors") {
            Task {
                isLoading = true  // Synchronous ✅
                await extractColors()  // ⚠️ Suspension point!
                isLoading = false  // ❌ Might happen too late
            }
        }
        .scaleEffect(isLoading ? 1.5 : 1.0)  // ⚠️ Animation timing uncertain
    }
}
解决方案:将 State 用作桥梁

“找出需要及时变更的 UI 代码与长时间运行的异步逻辑之间的边界。”

// ✅ Correct: State bridges UI and async code
@Observable
class ColorExtractor {
    var isLoading = false
    var colors: [Color] = []

    func extract(from image: UIImage) async {
        // This method is async and can live in the model
        let extracted = await heavyComputation(image)
        // Synchronous mutation for UI update
        self.colors = extracted
    }
}

struct ColorExtractorView: View {
    let extractor: ColorExtractor

    var body: some View {
        Button("Extract Colors") {
            // Synchronous state change for animation
            withAnimation {
                extractor.isLoading = true
            }

            // Launch async work
            Task {
                await extractor.extract(from: currentImage)

                // Synchronous state change for animation
                withAnimation {
                    extractor.isLoading = false
                }
            }
        }
        .scaleEffect(extractor.isLoading ? 1.5 : 1.0)
    }
}

优点:

  • UI 逻辑保持同步(动画能正常运行)
  • 异步代码位于模型中(无需 SwiftUI 即可测试)
  • 对及时响应的 UI 与长时间运行的工作进行清晰划分

属性包装器决策树

只需回答三个问题:

Which property wrapper should I use?
│
├─ Does this model need to be STATE OF THE VIEW ITSELF?
│  └─ YES → Use @State
│     Examples: Form inputs, local toggles, sheet presentations
│     Lifetime: Managed by the view's lifetime
│
├─ Does this model need to be part of the GLOBAL ENVIRONMENT?
│  └─ YES → Use @Environment
│     Examples: User account, app settings, dependency injection
│     Lifetime: Lives at app/scene level
│
├─ Does this model JUST NEED BINDINGS?
│  └─ YES → Use @Bindable
│     Examples: Editing a model passed from parent
│     Lightweight: Only enables $ syntax for bindings
│
└─ NONE OF THE ABOVE?
   └─ Use as plain property
      Examples: Immutable data, parent-owned models
      No wrapper needed: @Observable handles observation
示例
// ✅ @State — View owns the model
struct DonutEditor: View {
    @State private var donutToAdd = Donut()  // View's own state

    var body: some View {
        TextField("Name", text: $donutToAdd.name)
    }
}

// ✅ @Environment — App-wide model
struct MenuView: View {
    @Environment(Account.self) private var account  // Global

    var body: some View {
        Text("Welcome, \(account.userName)")
    }
}

// ✅ @Bindable — Need bindings to parent-owned model
struct DonutRow: View {
    @Bindable var donut: Donut  // Parent owns it

    var body: some View {
        TextField("Name", text: $donut.name)  // Need binding
    }
}

// ✅ Plain property — Just reading
struct DonutRow: View {
    let donut: Donut  // Parent owns, no binding needed

    var body: some View {
        Text(donut.name)  // Just reading
    }
}

@Observable 模型模式

对于需要触发 UI 更新的业务逻辑,使用 @Observable:

// ✅ Domain model with business logic
@Observable
class FoodTruckModel {
    var orders: [Order] = []
    var donuts = Donut.all

    var orderCount: Int {
        orders.count  // Computed properties work automatically
    }

    func addDonut() {
        donuts.append(Donut())
    }
}

// ✅ View automatically tracks accessed properties
struct DonutMenu: View {
    let model: FoodTruckModel  // No wrapper needed!

    var body: some View {
        List {
            Section("Donuts") {
                ForEach(model.donuts) { donut in
                    Text(donut.name)  // Tracks model.donuts
                }
                Button("Add") {
                    model.addDonut()
                }
            }
            Section("Orders") {
                Text("Count: \(model.orderCount)")  // Tracks model.orders
            }
        }
    }
}

工作原理(WWDC 2023/10149):

  • SwiftUI 会追踪执行 body 时访问的属性
  • 只有这些属性发生变化时才会触发视图更新
  • 细粒度的依赖追踪 = 更好的性能

ViewModel 适配器模式

当你需要筛选、排序或视图专属逻辑时,将 ViewModel 用作表示层适配器:

// ✅ ViewModel as presentation adapter
@Observable
class PetStoreViewModel {
    let petStore: PetStore  // Domain model
    var searchText: String = ""

    // View-specific computed property
    var filteredPets: [Pet] {
        guard !searchText.isEmpty else { return petStore.myPets }
        return petStore.myPets.filter { $0.name.contains(searchText) }
    }
}

struct PetListView: View {
    @Bindable var viewModel: PetStoreViewModel

    var body: some View {
        List {
            ForEach(viewModel.filteredPets) { pet in
                PetRowView(pet: pet)
            }
        }
        .searchable(text: $viewModel.searchText)
    }
}

适合使用 ViewModel 适配器的情况:

  • 用于展示的筛选、排序和分组
  • 用于展示的格式化(但不包括繁重计算)
  • 不属于领域模型的视图专属状态
  • 在领域模型与 SwiftUI 约定之间进行衔接

不适合使用 ViewModel 的情况:

  • 只展示模型数据的简单视图
  • 应由领域模型处理的逻辑
  • 仅为追求“模式纯粹性”而过度提取

第 2 部分:MVVM 模式

何时使用 MVVM

在以下情况下,MVVM(模型-视图-视图模型)很合适:

✅ 你熟悉 UIKit 中的 MVVM — 团队更容易上手 ✅ 你希望明确分离 View 和 ViewModel — 契约清晰 ✅ 表示逻辑复杂 — 存在多种筛选或排序操作 ✅ 你正在从 UIKit 迁移 — 思维模式熟悉

❌ 以下情况应避免使用 MVVM:

  • 视图很简单(只展示数据)
  • 从零开始使用 SwiftUI(Apple 的模式更简单)
  • 创建不必要的抽象层

SwiftUI 的 MVVM 结构

// Model — Domain data and business logic
struct Pet: Identifiable {
    let id: UUID
    var name: String
    var kind: Kind
    var trick: String
    var hasAward: Bool = false

    mutating func giveAward() {
        hasAward = true
    }
}

// ViewModel — Presentation logic
@Observable
class PetListViewModel {
    private let petStore: PetStore

    var pets: [Pet] { petStore.myPets }
    var searchText: String = ""
    var selectedSort: SortOption = .name

    var filteredSortedPets: [Pet] {
        let filtered = pets.filter { pet in
            searchText.isEmpty || pet.name.contains(searchText)
        }
        return filtered.sorted { lhs, rhs in
            switch selectedSort {
            case .name: lhs.name < rhs.name
            case .kind: lhs.kind.rawValue < rhs.kind.rawValue
            }
        }
    }

    init(petStore: PetStore) {
        self.petStore = petStore
    }

    func awardPet(_ pet: Pet) {
        petStore.awardPet(pet.id)
    }
}

// View — UI only
struct PetListView: View {
    @Bindable var viewModel: PetListViewModel

    var body: some View {
        List {
            ForEach(viewModel.filteredSortedPets) { pet in
                PetRow(pet: pet) {
                    viewModel.awardPet(pet)
                }
            }
        }
        .searchable(text: $viewModel.searchText)
    }
}

SwiftUI 中常见的 MVVM 错误

❌ 错误 1:在 View 和 ViewModel 中重复使用 @Observable
// ❌ @State + @Observable is redundant — @State creates its own storage
struct MyView: View {
    @State private var viewModel = MyViewModel()  // ❌ Redundant wrapper
}

// ✅ Pass @Observable directly — use @State only if the view OWNS the lifecycle
struct MyView: View {
    let viewModel: MyViewModel  // ✅ Or @State if view creates it
}
❌ 错误 2:上帝 ViewModel
// ❌ Don't do this
@Observable
class AppViewModel {
    // Settings
    var isDarkMode = false
    var notificationsEnabled = true

    // User
    var userName = ""
    var userEmail = ""

    // Content
    var posts: [Post] = []
    var comments: [Comment] = []

    // ... 50 more properties
}
// ✅ Correct: Separate concerns
@Observable
class SettingsViewModel {
    var isDarkMode = false
    var notificationsEnabled = true
}

@Observable
class UserProfileViewModel {
    var user: User
}

@Observable
class FeedViewModel {
    var posts: [Post] = []
}
❌ 错误 3:在 ViewModel 中编写业务逻辑
// ❌ Business rules belong in the Model, not the ViewModel
@Observable class OrderViewModel {
    func calculateDiscount(for order: Order) -> Double { /* ... */ }  // ❌ Business logic
}

// ✅ Model owns business logic; ViewModel only formats for display
struct Order {
    func calculateDiscount() -> Double { /* business rules */ }
}
@Observable class OrderViewModel {
    let order: Order
    var displayDiscount: String {
        "$\(order.calculateDiscount(), specifier: "%.2f")"  // ✅ Just formatting
    }
}

第 3 部分:TCA(Composable Architecture)

何时考虑使用 TCA

TCA 是 Point-Free 推出的第三方架构。在以下情况下可以考虑使用:

✅ 严格的可测试性至关重要 — TestStore 可让测试具有确定性
✅ 大型团队需要保持一致 — 严格的模式可减少差异
✅ 状态管理复杂 — 涉及副作用、依赖项和组合
✅ 重视类似 Redux 的模式 — 单向数据流

❌ 以下情况应避免使用 TCA:

  • 小型应用或原型(开销过大)
  • 团队不熟悉函数式编程
  • 需要快速迭代(样板代码会拖慢开发)
  • 希望尽量减少依赖项

TCA 核心概念

TCA 有 4 个构建块——State(数据)、Action(事件)、Reducer(状态演变)和 Store(运行时引擎)。下面通过一个功能展示这些构建块:

@Reducer
struct CounterFeature {
    // STATE — Data your feature needs
    @ObservableState
    struct State {
        var count = 0
        var fact: String?
        var isLoading = false
    }

    // ACTION — All possible events
    enum Action {
        case incrementButtonTapped
        case decrementButtonTapped
        case factButtonTapped
        case factResponse(String)
    }

    // REDUCER — How state evolves in response to actions
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .incrementButtonTapped:
                state.count += 1
                return .none
            case .decrementButtonTapped:
                state.count -= 1
                return .none
            case .factButtonTapped:
                state.isLoading = true
                return .run { [count = state.count] send in
                    let fact = try await numberFact(count)
                    await send(.factResponse(fact))
                }
            case let .factResponse(fact):
                state.isLoading = false
                state.fact = fact
                return .none
            }
        }
    }
}

// STORE — Runtime that receives actions and executes reducer
struct CounterView: View {
    let store: StoreOf<CounterFeature>

    var body: some View {
        VStack {
            Text("\(store.count)")
            Button("Increment") { store.send(.incrementButtonTapped) }
        }
    }
}

TCA 的权衡

✅ 优点
优点描述
可测试性TestStore 可让测试具有确定性且覆盖全面
一致性所有功能都使用同一种模式,降低认知负担
组合性小型 reducer 可组合成更大的功能
副作用以结构化方式管理副作用(网络请求、计时器等)
❌ 成本
成本描述
样板代码每个功能都需要 State/Action/Reducer
学习曲线涉及函数式编程概念(副作用、依赖项)
依赖项第三方库,非 Apple 官方支持
迭代速度简单功能也需要编写更多代码

何时选择 TCA 而非 Apple 模式

场景建议
小型应用(< 10 个屏幕)Apple 模式(更简单)
中型应用,团队经验丰富如果可测试性是首要任务,则选择 TCA
大型应用,多个团队选择 TCA 以保持一致性
快速原型开发Apple 模式(更快)
关键任务应用(银行、医疗)选择 TCA 以进行严格测试

第 4 部分:Coordinator 模式

何时使用 Coordinator

Coordinator 将导航逻辑从视图中抽离。以下情况适合使用:

✅ 复杂导航 — 多条路径、条件流程
✅ 深度链接 — 通过 URL 导航到任意屏幕
✅ 多个入口点 — 从不同上下文进入同一屏幕
✅ 可测试的导航 — 将导航与 UI 隔离

SwiftUI Coordinator 实现

// Minimal coordinator — Route enum + @Observable coordinator + NavigationStack binding
enum Route: Hashable {
    case detail(Pet)
    case settings
}

@Observable
class AppCoordinator {
    var path: [Route] = []

    func showDetail(for pet: Pet) { path.append(.detail(pet)) }
    func popToRoot() { path.removeAll() }
}

// Root view binds NavigationStack to coordinator's path
struct AppView: View {
    @State private var coordinator = AppCoordinator()

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            PetListView(coordinator: coordinator)
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .detail(let pet): PetDetailView(pet: pet, coordinator: coordinator)
                    case .settings: SettingsView(coordinator: coordinator)
                    }
                }
        }
    }
}

使用 .onOpenURL 和 URL 到路由的解析来添加深度链接:

// Add to AppCoordinator
func handleDeepLink(_ url: URL) {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
    // Parse URL path into routes
    if components.path.hasPrefix("/pets/"), let id = components.path.split(separator: "/").last {
        path = [.detail(loadPet(id: String(id)))]
    }
}

// Add to AppView's body
.onOpenURL { url in coordinator.handleDeepLink(url) }

无需 SwiftUI 即可测试 Coordinator——直接断言路径状态:

func testDeepLink() {
    let coordinator = AppCoordinator()
    coordinator.handleDeepLink(URL(string: "myapp://pets/123")!)
    XCTAssertEqual(coordinator.path.count, 1)  // Navigated to detail
}

有关状态恢复、高级 URL 路由和基于标签页的协调,请参阅 axiom-swiftui-nav:Pattern 7(Coordinator)介绍结构,Pattern 1b 介绍基于 URL 的深度链接。

Coordinator 与架构的组合

你可以将 Coordinator 与任何架构组合使用:

模式Coordinator 的作用
Apple 原生Coordinator 管理路径,@Observable 模型管理数据
MVVMCoordinator 管理路径,ViewModel 负责呈现
TCACoordinator 管理路径,Reducer 管理功能

第 5 部分:重构工作流

步骤 1:识别 View 中的逻辑

使用此清单检查你的视图:

View body 中包含:

  • 创建 DateFormatter、NumberFormatter
  • 计算或数据转换
  • API 调用或异步操作
  • 业务规则(折扣、验证等)
  • 数据筛选或排序
  • 大量字符串处理
  • 内含复杂逻辑的 Task { }

如果存在其中任何一项,这些逻辑很可能应该移出去。

步骤 2:提取到适当的层

使用以下决策树:

Where does this logic belong?
│
├─ Pure domain logic (discounts, validation, business rules)?
│  └─ Extract to Model
│     Example: Order.calculateDiscount()
│
├─ Presentation logic (filtering, sorting, formatting)?
│  └─ Extract to ViewModel or computed property
│     Example: filteredItems, displayPrice
│
├─ External side effects (API, database, file system)?
│  └─ Extract to Service
│     Example: APIClient, DatabaseManager
│
└─ Just expensive computation?
   └─ Cache with @State or create once
      Example: let formatter = DateFormatter()
示例:从 View 中重构逻辑
// ❌ Before: Logic in view body
struct OrderListView: View {
    let orders: [Order]

    var body: some View {
        let formatter = NumberFormatter()  // ❌ Created every render
        formatter.numberStyle = .currency

        let discounted = orders.filter { order in  // ❌ Computed every render
            let discount = order.total * 0.1  // ❌ Business logic in view
            return discount > 10.0
        }

        return List(discounted) { order in
            Text(formatter.string(from: order.total)!)  // ❌ Force unwrap
        }
    }
}
// ✅ After: Logic extracted

// Model — Business logic
struct Order {
    let id: UUID
    let total: Decimal

    var discount: Decimal {
        total * 0.1
    }

    var qualifiesForDiscount: Bool {
        discount > 10.0
    }
}

// ViewModel — Presentation logic
@Observable
class OrderListViewModel {
    let orders: [Order]
    private let formatter: NumberFormatter  // ✅ Created once

    var discountedOrders: [Order] {  // ✅ Computed property
        orders.filter { $0.qualifiesForDiscount }
    }

    init(orders: [Order]) {
        self.orders = orders
        self.formatter = NumberFormatter()
        formatter.numberStyle = .currency
    }

    func formattedTotal(_ order: Order) -> String {
        formatter.string(from: order.total as NSNumber) ?? "$0.00"
    }
}

// View — UI only
struct OrderListView: View {
    let viewModel: OrderListViewModel

    var body: some View {
        List(viewModel.discountedOrders) { order in
            Text(viewModel.formattedTotal(order))
        }
    }
}

步骤 3:验证可测试性

如果满足以下条件,则重构成功:

// ✅ Can test without importing SwiftUI
import XCTest

final class OrderTests: XCTestCase {
    func testDiscountCalculation() {
        let order = Order(id: UUID(), total: 100)
        XCTAssertEqual(order.discount, 10)
    }

    func testQualifiesForDiscount() {
        let order = Order(id: UUID(), total: 100)
        XCTAssertTrue(order.qualifiesForDiscount)
    }
}

final class OrderViewModelTests: XCTestCase {
    func testFilteredOrders() {
        let orders = [
            Order(id: UUID(), total: 50),   // Discount: 5 ❌
            Order(id: UUID(), total: 200),  // Discount: 20 ✅
        ]
        let viewModel = OrderListViewModel(orders: orders)

        XCTAssertEqual(viewModel.discountedOrders.count, 1)
    }
}

第 4 步:更新视图绑定

提取后,更新属性包装器:

// Before refactoring
struct OrderListView: View {
    @State private var orders: [Order] = []  // View owned
    // ... logic in body
}

// After refactoring
struct OrderListView: View {
    @State private var viewModel: OrderListViewModel  // View owns ViewModel

    init(orders: [Order]) {
        _viewModel = State(initialValue: OrderListViewModel(orders: orders))
    }
}

// Or if parent owns it
struct OrderListView: View {
    let viewModel: OrderListViewModel  // Parent owns, just reading
}

// Or if need bindings
struct OrderListView: View {
    @Bindable var viewModel: OrderListViewModel  // Parent owns, need $
}

反模式(切勿这样做)

❌ 反模式 1:在视图主体中编写逻辑

// ❌ Don't do this
struct ProductListView: View {
    let products: [Product]

    var body: some View {
        let formatter = NumberFormatter()  // ❌ Created every render!
        formatter.numberStyle = .currency

        let sorted = products.sorted { $0.price > $1.price }  // ❌ Sorted every render!

        return List(sorted) { product in
            Text("\(product.name): \(formatter.string(from: product.price)!)")
        }
    }
}

错误原因:

  • 每次渲染都会创建 formatter(影响性能)
  • 每次渲染都会计算 sorted(影响性能)
  • 业务逻辑(sorted)位于视图中(无法测试)
  • 强制解包('!')可能导致崩溃
// ✅ Correct
@Observable
class ProductListViewModel {
    let products: [Product]
    private let formatter = NumberFormatter()

    var sortedProducts: [Product] {
        products.sorted { $0.price > $1.price }
    }

    init(products: [Product]) {
        self.products = products
        formatter.numberStyle = .currency
    }

    func formattedPrice(_ product: Product) -> String {
        formatter.string(from: product.price as NSNumber) ?? "$0.00"
    }
}

struct ProductListView: View {
    let viewModel: ProductListViewModel

    var body: some View {
        List(viewModel.sortedProducts) { product in
            Text("\(product.name): \(viewModel.formattedPrice(product))")
        }
    }
}

❌ 反模式 2:没有边界的异步代码

参见上文第 1 部分中的“状态即桥梁”模式——让 UI 状态变更保持同步(在 withAnimation 内),并通过 Task 单独启动异步工作。

❌ 反模式 3:属性包装器使用错误

// ❌ Don't use @State for passed-in models
struct DetailView: View {
    @State var item: Item  // ❌ Creates a copy, loses parent changes
}

// ✅ Correct: No wrapper for passed-in models
struct DetailView: View {
    let item: Item  // ✅ Or @Bindable if you need $item
}
// ❌ Don't use @Environment for view-local state
struct FormView: View {
    @Environment(FormData.self) var formData  // ❌ Overkill for local form
}

// ✅ Correct: @State for view-local
struct FormView: View {
    @State private var formData = FormData()  // ✅ View owns it
}

❌ 反模式 4:上帝 ViewModel

参见上文第 2 部分中的 MVVM 错误 2——按职责拆分为单独的 ViewModel。

❌ 反模式 5:在 @Observable 内使用 @AppStorage

切勿在 @Observable 类中使用 @AppStorage——这会悄无声息地破坏观察机制。@AppStorage 是为 SwiftUI 视图设计的属性包装器,不适用于模型类。

// ❌ BROKEN — @AppStorage silently breaks @Observable
@Observable
class Settings {
    @AppStorage("theme") var theme = "light"  // Changes won't trigger view updates
}

// ✅ Read @AppStorage in view, pass to model
struct SettingsView: View {
    @AppStorage("theme") private var theme = "light"
    // ...
}

❌ 反模式 6:在视图主体中使用 Binding(get:set:)

在视图主体中创建 Binding(get:set:) 会在每次求值时创建新的绑定,从而破坏 SwiftUI 的身份跟踪。

// ❌ New Binding created every body evaluation
var body: some View {
    TextField("Name", text: Binding(
        get: { model.name },
        set: { model.name = $0 }
    ))
}

// ✅ Use @Bindable or computed binding
var body: some View {
    @Bindable var model = model
    TextField("Name", text: $model.name)
}

❌ 反模式 7:闭包中的循环状态

任何 @ViewBuilder 闭包(.sheet、.fullScreenCover、NavigationStack 目标视图、.popover)都会在父视图状态变化时重新求值。如果子视图回调修改了传入子视图初始化参数的同一个父级 @State,子视图就会在生命周期中途使用变化后的值重新初始化。

// ❌ Callback mutates the same state passed as init param
.sheet(item: $sheetItem) { _ in
    ChildView(
        savedResponse: cachedResponse,      // ❌ Parent state as init param
        onSuccess: { cachedResponse = $0 }  // ❌ Mutates same state
    )
}

// ✅ Don't pass state that callbacks will mutate
.sheet(item: $sheetItem) { _ in
    ChildView(
        onSuccess: { cachedResponse = $0 }  // Update parent, but don't read it back
    )
}

错误原因:

  • 回调修改了闭包所依赖的父视图状态
  • 父视图重新求值,导致闭包使用修改后的值再次求值
  • 子视图悄无声息地跳过加载或动画状态——不会崩溃,只是行为错误

修复方法:(1)不要将被修改的状态作为初始化参数传回。(2)为子视图的显示逻辑使用单独的 @State。(3)让子视图查询自己的数据源。完整的诊断工作流请参见 axiom-swiftui-debugging 中的根本原因 5。


代码审查清单

合并 SwiftUI 代码前,请确认:

视图
  • 视图主体中只有 UI 代码(Text、Button、List 等)
  • 视图主体中没有创建格式化器
  • 视图主体中没有计算或转换
  • 视图主体中没有 API 调用或数据库查询
  • 视图主体中没有业务规则
逻辑分离
  • 业务逻辑位于模型中(无需 SwiftUI 即可测试)
  • 表现逻辑位于 ViewModel 或计算属性中
  • 副作用位于服务或模型方法中
  • 重型计算经过缓存或只计算一次
属性包装器
  • 视图拥有的模型使用 @State
  • 应用级模型使用 @Environment
  • 需要绑定时使用 @Bindable
  • 仅用于读取时不使用包装器
动画与异步
  • 动画相关的状态变更保持同步
  • 异步边界使用“状态即桥梁”模式
  • 不要在 withAnimation { } 块之间使用 await
可测试性
  • 无需导入 SwiftUI 即可测试业务逻辑
  • 无需渲染视图即可测试 ViewModel
  • 导航逻辑彼此隔离(使用 Coordinator 时)

压力场景

场景 1:“现在先放在视图里”

压力

经理:“我们需要在周五前交付这个功能。现在先把逻辑放在视图里,之后再重构。”

危险信号

如果你听到:

  • ❌ “之后再重构”(永远不会偿还的技术债)
  • ❌ “只是一个视图”(视图会不断增加)
  • ❌ “没时间做架构”(以后成本更高)
时间成本对比

方案 A:把逻辑放在视图中

  • 在视图中编写功能:2 小时
  • 发现无法测试:1 小时
  • 仍然尝试测试:2 小时
  • 放弃,依靠手动测试发布:0 小时
  • 合计:5 小时,0 个测试

方案 B:妥善提取逻辑

  • 创建模型/ViewModel:30 分钟
  • 采用分离方式编写功能:2 小时
  • 编写测试:1 小时
  • 合计:3.5 小时,完整测试覆盖率
如何专业地提出异议

步骤 1:确认截止日期

“我知道截止日期是周五。让我说明为什么做好职责分离反而更快。”

步骤 2:对比所需时间

“把逻辑放在视图中需要 5 小时,而且没有测试。正确地提取逻辑需要 3.5 小时,并且包含完整测试。这样既能节省 1.5 小时,又能获得测试。”

步骤 3:提出折中方案

“如果时间确实不够,我可以现在提取 80%,并将剩下的 20% 作为技术债务记录到一个工单中。但我们还是不要完全跳过提取。”

步骤 4:如果被施压继续推进,请做好记录

// TODO: TECH DEBT - Extract business logic to ViewModel
// Ticket: PROJ-123
// Added: 2025-12-14
// Reason: Deadline pressure from manager
// Estimated refactor time: 2 hours
何时可以接受

只有在以下情况下才跳过提取:

  1. 这是一个用完即弃的原型(下周就会删除)
  2. 你有明确的重构时间预算(已安排工单)
  3. 视图永远不会超过 20 行

场景 2:“TCA 太过了,直接用原生 SwiftUI 就好”

压力

技术负责人:“TCA 对这个项目来说太复杂了。直接使用带有 @Observable 的原生 SwiftUI 就好。”

决策标准

请问自己以下问题:

问题TCA原生
可测试性是否至关重要(医疗、金融)?✅❌
屏幕数量是否少于 5 个?❌✅
团队是否熟悉函数式编程?✅❌
是否需要快速制作原型?❌✅
大型团队是否需要保持一致性?✅❌
是否有复杂的副作用(套接字、计时器)?✅~

建议矩阵:

  • TCA 得分达到 4 项或以上 → 使用 TCA
  • 原生得分达到 4 项或以上 → 使用原生方案
  • 平局 → 先从原生方案开始,如有需要再迁移到 TCA
如何提出异议

如果你支持 TCA:

“我理解 TCA 感觉很笨重。但我们正在开发一款银行应用。TestStore 能让我们进行全面测试,在问题进入生产环境之前发现错误。花 2 周学习是值得的,因为我们要维护 2 年。”

如果你反对 TCA:

“我认同 TCA 功能强大,但我们每周都在制作功能原型。样板代码会拖慢我们的进度。我们先用 @Observable,如果证明这些功能值得开发,再迁移到 TCA。”

场景 3:“重构会花太长时间”

压力

项目经理:“这个月我们有 3 个功能要发布。我们不能花 2 周时间重构现有视图。”

渐进式提取策略

不必一次性重构所有内容:

第 1 周:提取 1 个视图

  • 选择最棘手的视图(逻辑很多)
  • 提取到 ViewModel
  • 编写测试
  • 耗时:4 小时

第 2 周:提取 2 个视图

  • 现在你有一个可供参考的模式
  • 比第 1 周更快
  • 耗时:6 小时

第 3 周:新功能采用正确的架构

  • 暂时不要重构旧代码
  • 所有新代码都遵循这一模式
  • 耗时:0 小时(与之前相同)

第 2 个月:修改文件时逐步重构

  • 修复旧视图中的错误时进行重构
  • 为旧视图添加功能时进行重构
  • 耗时:分摊到功能开发过程中
如何提出异议

“我不是建议暂停 2 周的功能开发,而是建议:

  1. 第 1 周:提取最糟糕的视图(有 500 行代码的 OrdersView)
  2. 第 2 周:再提取 2 个问题较多的视图
  3. 今后:所有新功能都采用正确的架构
  4. 修改旧视图时,我们顺便重构

这需要预先投入 10 小时,以后每开发一个功能就能节省 2 小时以上。”


现实影响

之前:逻辑放在视图中

// 😰 200 lines of pain
struct OrderListView: View {
    @State private var orders: [Order] = []
    @State private var searchText = ""
    @State private var selectedFilter: FilterType = .all

    var body: some View {
        // ❌ Formatters created every render
        let currencyFormatter = NumberFormatter()
        currencyFormatter.numberStyle = .currency

        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .medium

        // ❌ Business logic in view
        let filtered = orders.filter { order in
            if !searchText.isEmpty && !order.customerName.contains(searchText) {
                return false
            }

            switch selectedFilter {
            case .all: return true
            case .pending: return !order.isCompleted
            case .completed: return order.isCompleted
            case .highValue: return order.total > 1000
            }
        }

        // ❌ More business logic
        let sorted = filtered.sorted { lhs, rhs in
            if selectedFilter == .highValue {
                return lhs.total > rhs.total
            } else {
                return lhs.date > rhs.date
            }
        }

        return List(sorted) { order in
            VStack(alignment: .leading) {
                Text(order.customerName)
                Text(currencyFormatter.string(from: order.total as NSNumber)!)
                Text(dateFormatter.string(from: order.date))

                if order.isCompleted {
                    Image(systemName: "checkmark.circle.fill")
                } else {
                    Button("Complete") {
                        // ❌ Async logic in view
                        Task {
                            do {
                                try await completeOrder(order)
                                await loadOrders()
                            } catch {
                                print(error)  // ❌ No error handling
                            }
                        }
                    }
                }
            }
        }
        .searchable(text: $searchText)
        .task {
            await loadOrders()
        }
    }

    func loadOrders() async {
        // ❌ API call in view
        // ... 50 more lines
    }

    func completeOrder(_ order: Order) async throws {
        // ❌ API call in view
        // ... 30 more lines
    }
}

问题:

  • 一个文件中有 200 多行代码
  • 每次渲染都会创建格式化器(影响性能)
  • 业务逻辑无法测试
  • 没有错误处理
  • 难以理清逻辑

之后:采用正确的架构

// Model — 30 lines
struct Order {
    let id: UUID
    let customerName: String
    let total: Decimal
    let date: Date
    var isCompleted: Bool

    var isHighValue: Bool {
        total > 1000
    }
}

// ViewModel — 60 lines
@Observable
class OrderListViewModel {
    private let orderService: OrderService
    private let currencyFormatter = NumberFormatter()
    private let dateFormatter = DateFormatter()

    var orders: [Order] = []
    var searchText = ""
    var selectedFilter: FilterType = .all
    var error: Error?

    var filteredOrders: [Order] {
        orders
            .filter(matchesSearch)
            .filter(matchesFilter)
            .sorted(by: sortComparator)
    }

    init(orderService: OrderService) {
        self.orderService = orderService
        currencyFormatter.numberStyle = .currency
        dateFormatter.dateStyle = .medium
    }

    func loadOrders() async {
        do {
            orders = try await orderService.fetchOrders()
        } catch {
            self.error = error
        }
    }

    func completeOrder(_ order: Order) async {
        do {
            try await orderService.complete(order.id)
            await loadOrders()
        } catch {
            self.error = error
        }
    }

    func formattedTotal(_ order: Order) -> String {
        currencyFormatter.string(from: order.total as NSNumber) ?? "$0.00"
    }

    func formattedDate(_ order: Order) -> String {
        dateFormatter.string(from: order.date)
    }

    private func matchesSearch(_ order: Order) -> Bool {
        searchText.isEmpty || order.customerName.contains(searchText)
    }

    private func matchesFilter(_ order: Order) -> Bool {
        switch selectedFilter {
        case .all: true
        case .pending: !order.isCompleted
        case .completed: order.isCompleted
        case .highValue: order.isHighValue
        }
    }

    private func sortComparator(_ lhs: Order, _ rhs: Order) -> Bool {
        selectedFilter == .highValue
            ? lhs.total > rhs.total
            : lhs.date > rhs.date
    }
}

// View — 40 lines
struct OrderListView: View {
    @Bindable var viewModel: OrderListViewModel

    var body: some View {
        List(viewModel.filteredOrders) { order in
            OrderRow(order: order, viewModel: viewModel)
        }
        .searchable(text: $viewModel.searchText)
        .task {
            await viewModel.loadOrders()
        }
        .alert("Error", error: $viewModel.error) { }
    }
}

struct OrderRow: View {
    let order: Order
    let viewModel: OrderListViewModel

    var body: some View {
        VStack(alignment: .leading) {
            Text(order.customerName)
            Text(viewModel.formattedTotal(order))
            Text(viewModel.formattedDate(order))

            if order.isCompleted {
                Image(systemName: "checkmark.circle.fill")
            } else {
                Button("Complete") {
                    Task {
                        await viewModel.completeOrder(order)
                    }
                }
            }
        }
    }
}

// Tests — 100 lines
final class OrderViewModelTests: XCTestCase {
    func testFilterBySearch() async {
        let viewModel = OrderListViewModel(orderService: MockOrderService())
        await viewModel.loadOrders()

        viewModel.searchText = "John"
        XCTAssertEqual(viewModel.filteredOrders.count, 1)
    }

    func testFilterByHighValue() async {
        let viewModel = OrderListViewModel(orderService: MockOrderService())
        await viewModel.loadOrders()

        viewModel.selectedFilter = .highValue
        XCTAssertTrue(viewModel.filteredOrders.allSatisfy { $0.isHighValue })
    }

    // ... 10 more tests
}

优点:

  • 视图:40 行(原为 200 行)
  • ViewModel:无需 SwiftUI 即可全面测试
  • Model:纯业务逻辑
  • 格式化器:只创建一次,而不是每次渲染都创建
  • 错误处理:通过提示框妥善处理
  • 测试:10 多项测试覆盖所有逻辑

Resources

WWDC: 2025-266, 2024-10150, 2023-10149, 2023-10160

Docs: /swiftui/managing-model-data-in-your-app

External: github.com/pointfreeco/swift-composable-architecture


Platforms: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+ Xcode: 26+ Status: Production-ready (v1.0)

评论

支持 Markdown