关于 axiom-swiftui-architecture
面向 SwiftUI 开发者的架构参考 Skill,帮助判断逻辑归属并构建可测试的应用。内容比较 Apple 原生模式、MVVM、TCA 与 Coordinator,并提供重构指导。
主要功能
- 01架构选择决策树
- 02Apple 原生 @Observable 模式
- 03MVVM 与 TCA 的取舍建议
- 04Coordinator 导航与深度链接
- 05从视图提取逻辑的工作流
适用场景
- 01为新的 SwiftUI 应用选择架构模式
- 02从视图文件中提取业务逻辑
- 03让导航逻辑具备可测试性
- 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)围绕以下内容展开:
- @Observable 用于数据模型(替代 ObservableObject)
- 用于异步边界的 State-as-Bridge(WWDC 2025)
- 三个属性包装器:@State、@Environment、@Bindable
- 用于动画的同步 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 模型管理数据 |
| MVVM | Coordinator 管理路径,ViewModel 负责呈现 |
| TCA | Coordinator 管理路径,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
何时可以接受
只有在以下情况下才跳过提取:
- 这是一个用完即弃的原型(下周就会删除)
- 你有明确的重构时间预算(已安排工单)
- 视图永远不会超过 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 周:提取最糟糕的视图(有 500 行代码的 OrdersView)
- 第 2 周:再提取 2 个问题较多的视图
- 今后:所有新功能都采用正确的架构
- 修改旧视图时,我们顺便重构
这需要预先投入 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)