架构
MVVM、仓库模式、AppEnvironment 依赖注入与导航模型。
模板采用 MVVM + 仓库(Repository) 模式,配合手写的依赖注入,完全构建在 iOS 17
的 Observation 框架和 Swift Concurrency 之上。没有 Combine,没有
ObservableObject,也没有第三方 DI 容器。本页讲清你在每个功能里都会用到的四个模式:
依赖注入、视图模型、导航、并发——以及保证它们靠谱的测试约定。
通过 AppEnvironment 做依赖注入
一个 @MainActor @Observable final class 在其 init 中构建每个控制器和仓库,再由
SwiftUI 沿视图树向下传递。
@MainActor @Observable
final class AppEnvironment {
let session: SessionController
let theme: ThemeController
let todosRepository: any TodosRepositoryProtocol
// …每个全应用级服务对应一个属性
init(todosRepository: (any TodosRepositoryProtocol)? = nil, /* … */) {
self.session = SessionController(authRepository: authRepository)
self.todosRepository = todosRepository ?? TodosRepository(supabase: supabase)
// …
}
}SoarStarterSwiftApp 构建唯一实例并注入它(外加几个需要自己 @Environment 身份的
控制器):
RootView()
.environment(env)
.environment(env.session)
.environment(env.theme)
// …视图按类型解析依赖:
@Environment(AppEnvironment.self) private var envinit 参数默认为 nil 并回退到真实实现——因此测试(或 SwiftUI #Preview)可以传入
伪仓库,而生产环境什么都不传。没有 Swinject,没有 Factory。
@Observable 类型不能用作 @EnvironmentObject。 那个 API 是给旧的
ObservableObject 协议用的。始终用 .environment(x) 注入、用 @Environment(X.self)
读取。两者混用会静默地不刷新视图。
用 @Observable 视图模型实现 MVVM
每个有逻辑的功能都把视图与一个存在 @State 中的视图模型配对:
struct TodosView: View {
@State private var viewModel: TodosViewModel
// …
}
@MainActor @Observable
final class TodosViewModel {
var state: UiState<[Todo]> = .idle
// …
}屏幕状态是一个 UiState<T> 枚举,而非一堆布尔标志:
enum UiState<Value: Sendable>: Sendable {
case idle // 加载前的骨架
case loading // 首次拉取或重新加载
case data(Value) // .data([]) 是合法的空成功态
case error(String)
}视图对 state 做 switch,从而选出 LoadingState、ErrorState、EmptyState 或
内容——见 UI 组件。视图模型是 @MainActor 的,所以在
async 方法里修改 state 是安全的,无需手动切回主线程。
仓库边界
视图模型从不直接调用 Supabase。它们依赖一个 *RepositoryProtocol;仓库负责重试策略
与错误映射,并置于一个封装了实际网络调用的 *RemoteDataSource 之前:
View → ViewModel → RepositoryProtocol → Repository → RemoteDataSource → Supabase两个可注入伪实现的接缝:视图模型测试注入伪仓库;仓库测试注入伪远程数据源。
仓库以 AppResult<T> / AppError 暴露失败(Auth 与 Subscription 仓库),或抛出一个
映射后的 AppError(Todos、Profile)——无论哪种,视图模型都会把结果转成 UiState。
完整走查见 数据层。
导航:根部用门闸,各标签页用栈
两种机制刻意分开:
门闸(Gate) 决定你是否能进入应用。RootView 由外向内嵌套它们;每个都读取
持久化状态,要么显示子视图,要么打断。它彻底取代了顶层路由枚举。见
应用门闸。
栈(Stack) 决定你在应用内部的位置。MainShellView 持有一个含四个标签页的
TabView,而每个标签页拥有自己的 @State 导航路径:
@State private var homePath: [HomeRoute] = []
@State private var componentPath: [ComponentRoute] = []
@State private var mePath: [MeRoute] = []每个都喂给它自己的 NavigationStack(path:)。因为这些路径是各自独立的 @State,
每个标签页的返回栈在切换标签时得以保留(在半路钻取时切走、再切回,你仍停在原处)。
路由目的地是 Navigation/MainRoute.swift 中的 Hashable 枚举;Auth 流程在
App/AuthGate.swift 内有它自己的私有路由枚举。
这也是深链接会解析成一个 (标签页, 路径) 组合的原因——见
深链接。MainShellView 通过设置 selectedTab 并替换该
标签页的路径来消费待处理的链接。
并发:默认 MainActor
项目以 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 构建——除非你显式声明,否则
每个类型都被隔离到 @MainActor。这让 UI 代码(视图模型、控制器)零注解就消除了大多数
数据竞争警告。
代价是:后台工作必须显式退出隔离。对必须离开主 actor 运行的函数用 nonisolated,
对真正并发的工作用 Task.detached。跨 actor 边界的模型是 Sendable 的(那些 Codable
结构体已经满足)。全程使用 async/await;没有 Combine。
测试约定
| Target | 框架 | 说明 |
|---|---|---|
SoarStarterSwiftTests | Swift Testing(@Test、#expect) | 单元测试。在仓库或远程接缝处注入伪实现。 |
SoarStarterSwiftUITests | XCUITest | 一个冒烟测试:以 -UITestingResetState 启动,点按走过引导与同意页,断言主界面渲染出来。 |
两个 target 都使用 PBXFileSystemSynchronizedRootGroup:放进目录的任意 *.swift
文件都会自动加入 target——无需改动 project.pbxproj。纯 Swift 接缝
(AuthValidation、ReferralLinkProvider、各目录)被直接单元测试。
测试务必带 -parallel-testing-enabled NO。 xcodebuild 默认会克隆模拟器并
并发运行单元 target 与 UI 冒烟 target;CPU 密集的单元套件会饿死冒烟测试的首个
waitForExistence,导致其不稳定。串行执行是受支持的模式:
xcodebuild -project SoarStarterSwift.xcodeproj -scheme SoarStarterSwift \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-parallel-testing-enabled NO test