架构

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 env

init 参数默认为 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)
}

视图对 stateswitch,从而选出 LoadingStateErrorStateEmptyState 或 内容——见 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框架说明
SoarStarterSwiftTestsSwift Testing@Test#expect单元测试。在仓库或远程接缝处注入伪实现。
SoarStarterSwiftUITestsXCUITest一个冒烟测试:以 -UITestingResetState 启动,点按走过引导与同意页,断言主界面渲染出来。

两个 target 都使用 PBXFileSystemSynchronizedRootGroup:放进目录的任意 *.swift 文件都会自动加入 target——无需改动 project.pbxproj。纯 Swift 接缝 (AuthValidationReferralLinkProvider、各目录)被直接单元测试。

测试务必带 -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

相关页面

On this page