mcpField
A
axiom-app-composition

axiom-app-composition

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

关于 axiom-app-composition

面向 SwiftUI 开发者的应用架构技能,涵盖应用状态、根视图、生命周期、状态恢复和功能模块。重点是显式状态机与精简的 @main 入口。

主要功能

  1. 01用枚举建模应用状态
  2. 02集中管理并验证状态转换
  3. 03按应用状态组合根视图
  4. 04处理场景生命周期事件
  5. 05恢复轻量场景与导航状态
  6. 06判断何时拆分功能模块

适用场景

  1. 01组织登录、引导和主界面流程
  2. 02避免应用状态切换时闪屏
  3. 03场景激活时重新验证会话
  4. 04应用终止后恢复导航状态

安装

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

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-app-composition

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

详细介绍

应用组合

何时使用此技能

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

  • 组织 @main 入口点和根视图
  • 管理身份验证状态(登录 → 引导 → 主界面)
  • 在应用级状态之间切换,避免闪烁
  • 处理场景生命周期事件(scenePhase)
  • 终止后恢复应用状态
  • 决定何时拆分功能模块
  • 协调多个窗口(iPad、axiom-visionOS)

示例提示词

你可能会问什么此技能的帮助
“如何在登录界面和主界面之间切换?”使用经过验证的状态转换实现 AppStateController 模式
“我的应用从启动画面切换到主界面时会闪烁”通过协调动画防止闪烁
“身份验证状态应该放在哪里?”使用应用级状态机,而不是分散的布尔值
“如何处理应用进入后台?”scenePhase 生命周期模式
“什么时候应该将应用拆分为模块?”根据代码库规模和团队情况进行决策
“如何在应用被终止后恢复状态?”SceneStorage 和状态验证模式

快速决策树

What app-level architecture question are you solving?
│
├─ How do I manage app states (loading, auth, main)?
│  └─ Part 1: App-Level State Machines
│     - Enum-based state with validated transitions
│     - AppStateController pattern
│     - Prevents "boolean soup" anti-pattern
│
├─ How do I structure @main and root view switching?
│  └─ Part 2: Root View Switching Patterns
│     - Delegate to AppStateController (no logic in @main)
│     - Flicker prevention with animation
│     - Coordinator integration
│
├─ How do I handle scene lifecycle?
│  └─ Part 3: Scene Lifecycle Integration
│     - scenePhase for session validation
│     - SceneStorage for restoration
│     - Multi-window coordination
│
├─ When should I modularize?
│  └─ Part 4: Feature Module Basics
│     - Decision tree by size/team
│     - Module boundaries and DI
│     - Navigation coordination
│
└─ What mistakes should I avoid?
   └─ Part 5: Anti-Patterns + Part 6: Pressure Scenarios
      - Boolean-based state
      - Logic in @main
      - Missing restoration validation

第 1 部分:应用级状态机

核心原则

“应用有明确的状态。应使用枚举显式建模,而不是使用分散的布尔值。”

每个非简单应用都有不同的状态:加载中、未通过身份验证、引导、已通过身份验证、错误恢复。这些状态应当:

  1. 明确 — 使用枚举,而不是多个布尔值
  2. 经过验证 — 检查并记录状态转换
  3. 集中管理 — 只有一个事实来源
  4. 可观察 — 视图会对状态变化做出响应

布尔值杂烩问题

// ❌ Boolean soup — impossible to validate, prone to invalid states
class AppState {
    var isLoading = true
    var isLoggedIn = false
    var hasCompletedOnboarding = false
    var hasError = false
    var user: User?

    // What if isLoading && isLoggedIn && hasError are all true?
    // Invalid state, but nothing prevents it
}

问题

  • 无法在编译时保证状态有效
  • 容易忘记更新某个布尔值
  • 测试需要检查所有组合
  • 竞争条件会产生不可能的状态

AppStateController 模式

步骤 1:定义明确的状态
enum AppState: Equatable {
    case loading
    case unauthenticated
    case onboarding(OnboardingStep)
    case authenticated(User)
    case error(AppError)
}

enum OnboardingStep: Equatable {
    case welcome
    case permissions
    case profileSetup
    case complete
}

enum AppError: Equatable {
    case networkUnavailable
    case sessionExpired
    case maintenanceMode
}
步骤 2:创建控制器
@Observable
@MainActor
class AppStateController {
    private(set) var state: AppState = .loading

    // MARK: - State Transitions

    func transition(to newState: AppState) {
        guard isValidTransition(from: state, to: newState) else {
            assertionFailure("Invalid transition: \(state) → \(newState)")
            logInvalidTransition(from: state, to: newState)
            return
        }

        let oldState = state
        state = newState
        logTransition(from: oldState, to: newState)
    }

    // MARK: - Validation

    private func isValidTransition(from: AppState, to: AppState) -> Bool {
        switch (from, to) {
        // From loading
        case (.loading, .unauthenticated): return true
        case (.loading, .authenticated): return true
        case (.loading, .error): return true

        // From unauthenticated
        case (.unauthenticated, .onboarding): return true
        case (.unauthenticated, .authenticated): return true
        case (.unauthenticated, .error): return true

        // From onboarding
        case (.onboarding, .onboarding): return true  // Step changes
        case (.onboarding, .authenticated): return true
        case (.onboarding, .unauthenticated): return true  // Cancelled

        // From authenticated
        case (.authenticated, .unauthenticated): return true  // Logout
        case (.authenticated, .error): return true

        // From error
        case (.error, .loading): return true  // Retry
        case (.error, .unauthenticated): return true

        default: return false
        }
    }

    // MARK: - Logging

    private func logTransition(from: AppState, to: AppState) {
        #if DEBUG
        print("AppState: \(from) → \(to)")
        #endif
    }

    private func logInvalidTransition(from: AppState, to: AppState) {
        // Log to analytics for debugging
        Analytics.log("InvalidStateTransition", properties: [
            "from": String(describing: from),
            "to": String(describing: to)
        ])
    }
}
步骤 3:从存储中初始化
extension AppStateController {
    func initialize() async {
        // Check for stored session
        if let session = await SessionStorage.loadSession() {
            // Validate session is still valid
            do {
                let user = try await AuthService.validateSession(session)
                transition(to: .authenticated(user))
            } catch {
                // Session expired or invalid
                await SessionStorage.clearSession()
                transition(to: .unauthenticated)
            }
        } else {
            transition(to: .unauthenticated)
        }
    }
}

状态机图

┌─────────────────────────────────────────────────────────────┐
│                        .loading                              │
└────────────┬───────────────┬────────────────┬───────────────┘
             │               │                │
             ▼               ▼                ▼
    .unauthenticated   .authenticated     .error
             │               │                │
             ▼               │                │
       .onboarding ─────────►│◄───────────────┘
             │               │
             └───────────────┘

测试状态机

@Test func testValidTransitions() async {
    let controller = AppStateController()

    // Loading → Unauthenticated (valid)
    controller.transition(to: .unauthenticated)
    #expect(controller.state == .unauthenticated)

    // Unauthenticated → Authenticated (valid)
    let user = User(id: "1", name: "Test")
    controller.transition(to: .authenticated(user))
    #expect(controller.state == .authenticated(user))
}

@Test func testInvalidTransitionRejected() async {
    let controller = AppStateController()

    // Loading → Onboarding (invalid — must go through unauthenticated)
    controller.transition(to: .onboarding(.welcome))
    #expect(controller.state == .loading)  // Unchanged
}

@Test func testSessionExpiredTransition() async {
    let controller = AppStateController()
    let user = User(id: "1", name: "Test")
    controller.transition(to: .authenticated(user))

    // Authenticated → Error (session expired)
    controller.transition(to: .error(.sessionExpired))
    #expect(controller.state == .error(.sessionExpired))

    // Error → Unauthenticated (force re-login)
    controller.transition(to: .unauthenticated)
    #expect(controller.state == .unauthenticated)
}

状态桥接模式(WWDC 2025/266)

内容摘自 WWDC 2025 的“探索 SwiftUI 中的并发”:

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

关键要点:同步状态变更驱动 UI(用于动画),异步代码位于模型中(无需 SwiftUI 即可测试),而状态在两者之间起桥接作用。

// ✅ State-as-Bridge: UI triggers state, model does async work
struct ColorExtractorView: View {
    @State private var model = ColorExtractor()

    var body: some View {
        Button("Extract Colors") {
            // ✅ Synchronous state change triggers animation
            withAnimation { model.isExtracting = true }

            // Async work happens in Task
            Task {
                await model.extractColors()

                // ✅ Synchronous state change ends animation
                withAnimation { model.isExtracting = false }
            }
        }
        .scaleEffect(model.isExtracting ? 1.5 : 1.0)
    }
}

@Observable
class ColorExtractor {
    var isExtracting = false
    var colors: [Color] = []

    func extractColors() async {
        // Heavy computation happens here, testable without SwiftUI
        let extracted = await heavyComputation()
        colors = extracted
    }
}

这对应用组合的重要性

  • 应用级状态变更(加载中 → 已通过身份验证)应当是同步的
  • 耗时工作(会话验证、数据加载)应当在模型中异步执行
  • 这种分离使状态机无需导入 SwiftUI 即可测试

第 2 部分:根视图切换模式

核心原则

“@main 入口点应当是一个轻量外壳。所有逻辑都应放在 AppStateController 中。”

简洁的 @main 模式

@main
struct MyApp: App {
    @State private var appState = AppStateController()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(appState)
                .task {
                    await appState.initialize()
                }
        }
    }
}

@main 的职责

  • 创建 AppStateController
  • 通过环境注入它
  • 触发初始化

@main 不负责

  • 业务逻辑
  • 身份验证检查
  • 条件渲染
  • 导航决策

RootView:状态切换

struct RootView: View {
    @Environment(AppStateController.self) private var appState

    var body: some View {
        Group {
            switch appState.state {
            case .loading:
                LaunchView()
            case .unauthenticated:
                AuthenticationFlow()
            case .onboarding(let step):
                OnboardingFlow(step: step)
            case .authenticated(let user):
                MainTabView(user: user)
            case .error(let error):
                ErrorRecoveryView(error: error)
            }
        }
    }
}

可测试性优势

轻量外壳模式可以让测试速度提升至多 60 倍。当应用逻辑位于 Swift Package 而不是应用目标中时,测试使用 swift test(约 0.4 秒)运行,而非 xcodebuild test(约 25 秒)——无需模拟器,也无需启动应用。

组件位置测试方式
业务逻辑、模型、服务Swift Package(MyAppCore)swift test(0.4 秒)
根视图组合应用目标(轻量外壳)xcodebuild test(25 秒)

完整的包提取流程请参见 axiom-swift-testing 策略 1。

防止转换期间闪烁

问题:错误内容闪现

应用状态发生变化时,可能会先闪现旧屏幕,然后才显示新屏幕。出现这种情况的原因包括:

  • 状态在视图就绪之前发生变化
  • 没有过渡动画
  • 加载状态持续时间太短,难以察觉
解决方案:添加动画过渡
struct RootView: View {
    @Environment(AppStateController.self) private var appState

    var body: some View {
        ZStack {
            switch appState.state {
            case .loading:
                LaunchView()
                    .transition(.opacity)
            case .unauthenticated:
                AuthenticationFlow()
                    .transition(.opacity)
            case .onboarding(let step):
                OnboardingFlow(step: step)
                    .transition(.opacity)
            case .authenticated(let user):
                MainTabView(user: user)
                    .transition(.opacity)
            case .error(let error):
                ErrorRecoveryView(error: error)
                    .transition(.opacity)
            }
        }
        .animation(.easeInOut(duration: 0.3), value: appState.state)
    }
}
最短加载时长

为了提供精致的体验,请确保加载屏幕的显示时间足够长:

extension AppStateController {
    func initialize() async {
        let startTime = Date()

        // Do actual initialization
        await performInitialization()

        // Ensure minimum display time for loading screen
        let elapsed = Date().timeIntervalSince(startTime)
        let minimumDuration: TimeInterval = 0.5
        if elapsed < minimumDuration {
            try? await Task.sleep(for: .seconds(minimumDuration - elapsed))
        }
    }
}

与 Coordinator 集成

如果使用 Coordinator,请在根级别集成:

struct RootView: View {
    @Environment(AppStateController.self) private var appState
    @State private var authCoordinator = AuthCoordinator()
    @State private var mainCoordinator = MainCoordinator()

    var body: some View {
        Group {
            switch appState.state {
            case .loading:
                LaunchView()
            case .unauthenticated, .onboarding:
                AuthenticationFlow()
                    .environment(authCoordinator)
            case .authenticated(let user):
                MainTabView(user: user)
                    .environment(mainCoordinator)
            case .error(let error):
                ErrorRecoveryView(error: error)
            }
        }
        .animation(.easeInOut(duration: 0.3), value: appState.state)
    }
}

第 3 部分:场景生命周期集成

核心原则

“场景生命周期事件是整个应用级别的关注点,应集中处理,而不是分散在各个功能中。”

了解 ScenePhase(Apple 文档)

ScenePhase 表示场景的运行状态。如何解读其值取决于读取位置。

从 View 读取 → 返回其所属场景的阶段
从 App 读取 → 返回反映所有场景的聚合值

阶段描述
.active场景位于前台且可交互
.inactive场景位于前台,但应暂停工作
.background场景不可见;应用可能很快终止

**Apple 文档中的重要提示:**在 App 级别读取时,.active 表示任意场景处于活动状态,而 .background 表示所有场景都在后台。

scenePhase 处理

@main
struct MyApp: App {
    @State private var appState = AppStateController()
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(appState)
                .task {
                    await appState.initialize()
                }
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            handleScenePhaseChange(from: oldPhase, to: newPhase)
        }
    }

    private func handleScenePhaseChange(from: ScenePhase, to: ScenePhase) {
        switch to {
        case .active:
            // App became active — validate session, refresh data
            Task {
                await appState.validateSession()
                await appState.refreshIfNeeded()
            }

        case .inactive:
            // App about to go inactive — save state
            appState.prepareForBackground()

        case .background:
            // App in background — release resources
            appState.releaseResources()

        @unknown default:
            break
        }
    }
}

激活时验证会话

extension AppStateController {
    func validateSession() async {
        guard case .authenticated(let user) = state else { return }

        do {
            // Check if token is still valid
            let isValid = try await AuthService.validateToken(user.token)
            if !isValid {
                transition(to: .error(.sessionExpired))
            }
        } catch {
            // Network error — keep authenticated but show warning
            // Don't immediately log out on transient network issues
        }
    }

    func prepareForBackground() {
        // Save any pending data
        // Cancel non-essential network requests
        // Prepare for potential termination
    }

    func releaseResources() {
        // Release cached images
        // Stop location updates if not essential
        // Reduce memory footprint
    }
}

使用 SceneStorage 恢复状态

根据 Apple 文档:SceneStorage 可自动恢复状态。系统会代为管理保存和恢复。

主要限制

  • 数据应保持轻量(不要存储完整模型)
  • 每个 Scene 都有自己的存储空间(不共享)
  • 场景被显式销毁时,数据也会销毁
struct MainTabView: View {
    @SceneStorage("selectedTab") private var selectedTab = 0
    @SceneStorage("lastViewedItemID") private var lastViewedItemID: String?

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeTab()
                .tag(0)
            SearchTab()
                .tag(1)
            ProfileTab()
                .tag(2)
        }
        .onAppear {
            if let itemID = lastViewedItemID {
                // Restore to last viewed item
                navigateToItem(itemID)
            }
        }
    }
}
导航状态恢复(WWDC 2022/10054)

对于复杂导航,请使用 Codable NavigationModel:

// Encapsulate navigation state with Codable conformance
class NavigationModel: ObservableObject, Codable {
    @Published var selectedCategory: Category?
    @Published var recipePath: [Recipe] = []

    enum CodingKeys: String, CodingKey {
        case selectedCategory
        case recipePathIds
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
        // Store only IDs, not full models
        try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.selectedCategory = try container.decodeIfPresent(
            Category.self, forKey: .selectedCategory)

        let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
        // compactMap discards deleted items gracefully
        self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
    }

    var jsonData: Data? {
        get { try? JSONEncoder().encode(self) }
        set {
            guard let data = newValue,
                  let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
            else { return }
            self.selectedCategory = model.selectedCategory
            self.recipePath = model.recipePath
        }
    }
}

// Use with SceneStorage
struct ContentView: View {
    @StateObject private var navModel = NavigationModel()
    @SceneStorage("navigation") private var data: Data?

    var body: some View {
        NavigationSplitView { /* ... */ }
        .task {
            if let data = data {
                navModel.jsonData = data
            }
            for await _ in navModel.objectWillChangeSequence {
                data = navModel.jsonData
            }
        }
    }
}

WWDC 中的主要模式

  • 仅存储 ID,不要存储完整模型对象
  • 使用 compactMap 妥善处理已删除的条目
  • 每次 objectWillChange 时都保存,以实现实时持久化
验证恢复的状态

切勿盲目信任恢复的状态:

struct DetailView: View {
    @SceneStorage("detailItemID") private var restoredItemID: String?
    @State private var item: Item?

    var body: some View {
        Group {
            if let item {
                ItemContent(item: item)
            } else {
                ProgressView()
            }
        }
        .task {
            if let itemID = restoredItemID {
                // Validate item still exists
                item = await ItemService.fetch(itemID)
                if item == nil {
                    // Item was deleted — clear restoration
                    restoredItemID = nil
                }
            }
        }
    }
}

多窗口协调(iPad、axiom-visionOS)

根据 Apple 文档:WindowGroup 中的每个窗口都维护独立状态。系统会为每个窗口分配新的 @State 和 @StateObject 存储空间。

@main
struct MyApp: App {
    @State private var appState = AppStateController()

    var body: some Scene {
        // Primary window
        WindowGroup {
            MainView()
                .environment(appState)
        }

        // Data-presenting window (iPad)
        // Prefer lightweight data (IDs, not full models)
        WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in
            if let itemID {
                DetailView(itemID: itemID)
                    .environment(appState)
            }
        }

        #if os(visionOS)
        // Immersive space
        ImmersiveSpace(id: "immersive") {
            ImmersiveView()
                .environment(appState)
        }
        #endif
    }
}

Apple 文档中的主要行为

  • 如果已存在具有相同值的窗口,系统会将其置于前台,而不是打开新窗口
  • SwiftUI 会持久化绑定值以恢复状态
  • 为每个窗口组使用唯一标识符字符串
打开其他窗口
struct ItemRow: View {
    let item: Item
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button(item.title) {
            // Open in new window on iPad
            // Use ID to match window group, value to pass data
            openWindow(id: "detail", value: item.id)
        }
    }
}
以编程方式关闭窗口
struct DetailView: View {
    var itemID: Item.ID?
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            // ...
            Button("Done") {
                dismiss()  // Closes this window
            }
        }
    }
}

第 4 部分:功能模块基础

核心原则

“功能边界明确时再拆分模块,不要过早拆分。”

过早模块化会增加开销,过晚模块化则会带来麻烦。请使用以下决策树。

何时进行模块化:决策树

Should I extract this feature into a module?
│
├─ Is the codebase under 5,000 lines with 1-2 developers?
│  └─ NO modularization needed yet
│     Single target is fine, revisit at 10,000 lines
│
├─ Is the codebase 5,000-20,000 lines with 3+ developers?
│  └─ CONSIDER modularization
│     Look for natural boundaries
│
├─ Is the codebase over 20,000 lines?
│  └─ MODULARIZE for build times
│     Parallel compilation essential
│
├─ Could this feature be used in multiple apps?
│  └─ EXTRACT to reusable module
│     Shared authentication, analytics, axiom-networking
│
├─ Do multiple developers work on this feature daily?
│  └─ EXTRACT for merge conflict reduction
│     Isolated codebases = parallel work
│
└─ Does the feature have clear input/output boundaries?
   ├─ YES → Good candidate for module
   └─ NO → Refactor boundaries first, then extract

模块边界模式

定义公共 API
// FeatureModule/Sources/FeatureModule/FeatureAPI.swift

/// Public interface for the feature module
public protocol FeatureAPI {
    /// Show the feature's main view
    @MainActor
    func makeMainView() -> AnyView

    /// Handle deep link into feature
    @MainActor
    func handleDeepLink(_ url: URL) -> Bool
}

/// Factory to create feature with dependencies
public struct FeatureFactory {
    public static func create(
        analytics: AnalyticsProtocol,
        networking: NetworkingProtocol
    ) -> FeatureAPI {
        FeatureImplementation(
            analytics: analytics,
            networking: axiom-networking
        )
    }
}
内部实现
// FeatureModule/Sources/FeatureModule/Internal/FeatureImplementation.swift

internal class FeatureImplementation: FeatureAPI {
    private let analytics: AnalyticsProtocol
    private let networking: NetworkingProtocol

    internal init(
        analytics: AnalyticsProtocol,
        networking: NetworkingProtocol
    ) {
        self.analytics = analytics
        self.networking = networking
    }

    @MainActor
    public func makeMainView() -> AnyView {
        AnyView(FeatureMainView(viewModel: makeViewModel()))
    }

    public func handleDeepLink(_ url: URL) -> Bool {
        // Handle feature-specific deep links
        return false
    }

    private func makeViewModel() -> FeatureViewModel {
        FeatureViewModel(analytics: analytics, axiom-networking: axiom-networking)
    }
}
在主应用中使用
// MainApp/Sources/App/AppDependencies.swift

@Observable
class AppDependencies {
    let analytics: AnalyticsProtocol
    let networking: NetworkingProtocol

    // Lazy-created feature modules
    lazy var profileFeature: FeatureAPI = {
        ProfileFeatureFactory.create(
            analytics: analytics,
            networking: axiom-networking
        )
    }()

    lazy var settingsFeature: FeatureAPI = {
        SettingsFeatureFactory.create(
            analytics: analytics,
            networking: axiom-networking
        )
    }()
}

// MainApp/Sources/App/MainTabView.swift

struct MainTabView: View {
    @Environment(AppDependencies.self) private var dependencies

    var body: some View {
        TabView {
            dependencies.profileFeature.makeMainView()
                .tabItem { Label("Profile", systemImage: "person") }

            dependencies.settingsFeature.makeMainView()
                .tabItem { Label("Settings", systemImage: "gear") }
        }
    }
}

模块间的导航协调

功能不应直接了解彼此:

// ❌ Feature knows about other features
struct ProfileView: View {
    func showSettings() {
        // ProfileView imports SettingsFeature — circular dependency risk
        NavigationLink(value: SettingsDestination())
    }
}

// ✅ Feature delegates navigation to coordinator
struct ProfileView: View {
    let onShowSettings: () -> Void

    func showSettings() {
        onShowSettings()  // ProfileView doesn't know what happens
    }
}

// Coordinator wires features together
class MainCoordinator {
    func showSettings(from profile: ProfileFeatureAPI) {
        // Coordinator knows about both features
        navigationPath.append(SettingsRoute())
    }
}

模块文件夹结构

MyApp/
├── App/                          # Main app target
│   ├── MyApp.swift               # @main entry point
│   ├── AppDependencies.swift     # Dependency container
│   ├── AppStateController.swift  # App state machine
│   └── Coordinators/             # Navigation coordinators
│
├── Packages/
│   ├── Core/                     # Shared utilities
│   │   ├── Networking/
│   │   ├── Analytics/
│   │   └── Design/               # Design system
│   │
│   ├── Features/                 # Feature modules
│   │   ├── Profile/
│   │   ├── Settings/
│   │   └── Onboarding/
│   │
│   └── Domain/                   # Business logic
│       ├── Models/
│       └── Services/

第 5 部分:反模式

反模式 1:基于布尔值的状态

// ❌ Boolean soup — impossible to validate
class AppState {
    var isLoading = true
    var isLoggedIn = false
    var hasCompletedOnboarding = false
    var hasError = false
}

// What if isLoading && isLoggedIn && hasError are all true?

**修复方法:**使用基于 enum 的状态(第 1 部分)

// ✅ Explicit states — compiler prevents invalid combinations
enum AppState {
    case loading
    case unauthenticated
    case onboarding(OnboardingStep)
    case authenticated(User)
    case error(AppError)
}

反模式 2:在 @main 中编写逻辑

// ❌ Business logic in App entry point
@main
struct MyApp: App {
    @State private var user: User?
    @State private var isLoading = true

    var body: some Scene {
        WindowGroup {
            if isLoading {
                LoadingView()
            } else if let user {
                MainView(user: user)
            } else {
                LoginView(onLogin: { self.user = $0 })
            }
        }
        .task {
            user = await AuthService.getCurrentUser()
            isLoading = false
        }
    }
}

问题

  • @main 中的逻辑变得臃肿
  • 不启动应用就难以测试
  • 状态分散在多个 @State 中

**修复方法:**委托给 AppStateController(第 2 部分)

// ✅ @main is a thin shell
@main
struct MyApp: App {
    @State private var appState = AppStateController()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(appState)
                .task { await appState.initialize() }
        }
    }
}

反模式 3:恢复时缺少状态验证

// ❌ Trusts restored state blindly
.onAppear {
    if let savedState = SceneStorage.appState {
        appState.state = savedState  // Token might be expired!
    }
}

问题

  • 会话可能已过期
  • 用户可能已在另一台设备上退出登录
  • 数据可能已被删除

**修复方法:**应用之前先验证(第 3 部分)

// ✅ Validates restored state
.task {
    if let savedSession = await SessionStorage.loadSession() {
        do {
            let user = try await AuthService.validateSession(savedSession)
            appState.transition(to: .authenticated(user))
        } catch {
            // Session invalid — force re-login
            await SessionStorage.clearSession()
            appState.transition(to: .unauthenticated)
        }
    }
}

反模式 4:导航逻辑分散在各个功能中

// ❌ Every feature knows about every other feature
struct ProfileView: View {
    @Environment(\.navigationPath) private var path

    func showSettings() {
        path.append(SettingsDestination())  // ProfileView imports Settings
    }

    func showOrderHistory() {
        path.append(OrderHistoryDestination())  // ProfileView imports Orders
    }
}

问题

  • 循环依赖
  • 导航难以测试
  • 变更会波及多个模块

修复 委托给协调器(第 4 部分)

// ✅ Feature delegates navigation decisions
struct ProfileView: View {
    let onShowSettings: () -> Void
    let onShowOrderHistory: () -> Void

    // ProfileView doesn't know what these do
}

反模式 5:万能协调器

// ❌ Single coordinator knows all features
class AppCoordinator {
    func showProfile() { }
    func showSettings() { }
    func showOnboarding() { }
    func showPayment() { }
    func showChat() { }
    func showOrderHistory() { }
    func showNotifications() { }
    // ... 50 more methods
}

问题

  • 文件庞大,所有人都会修改
  • 合并冲突
  • 单点故障

修复 使用职责范围明确的协调器

// ✅ Scoped coordinators for each domain
class AuthCoordinator { }      // Login, signup, forgot password
class MainCoordinator { }      // Tab navigation, main flows
class SettingsCoordinator { }  // Settings navigation tree
class OrderCoordinator { }     // Order flow, history, details

第 5b 部分:UIKit 集成(增量采用)

有关全面的桥接模式(UIViewRepresentable、UIViewControllerRepresentable、UIHostingConfiguration、协调器、生命周期和注意事项),请参阅 /skill axiom-uikit-bridging。本节仅介绍应用级集成策略。

适用场景

大多数用于生产环境的 iOS 应用都已有 UIKit 代码。用 SwiftUI 重写所有内容很少切实可行。增量采用时可使用以下模式。

UIHostingController — 在 UIKit 中使用 SwiftUI

将 SwiftUI 视图嵌入现有 UIKit 导航层级中:

// Present a SwiftUI view from a UIKit view controller
let settingsView = SettingsView(store: store)
let hostingController = UIHostingController(rootView: settingsView)
navigationController?.pushViewController(hostingController, animated: true)

要点:

  • UIHostingController 管理 SwiftUI 视图的生命周期——不要单独存储根视图
  • 将其作为子视图嵌入时,使用 sizingOptions: .intrinsicContentSize,以确保正确的 Auto Layout 尺寸
  • 对于表单,hostingController.modalPresentationStyle = .pageSheet 可直接使用
  • SwiftUI 环境不会自动桥接——通过根视图的初始化器注入依赖项

UIViewControllerRepresentable — 在 SwiftUI 中使用 UIKit

包装现有 UIKit 视图控制器,以便在 SwiftUI 中使用:

struct DocumentPickerView: UIViewControllerRepresentable {
    @Binding var selectedURL: URL?

    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    class Coordinator: NSObject, UIDocumentPickerDelegate {
        let parent: DocumentPickerView
        init(_ parent: DocumentPickerView) { self.parent = parent }

        func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
            parent.selectedURL = urls.first
        }
    }
}

适用场景:相机 UI、文档选择器、邮件撰写,以及任何没有对应 SwiftUI 实现的 UIKit 控制器。

AppDelegate + SwiftUI @main

将 UIApplicationDelegate 回调桥接到 SwiftUI 应用中:

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Push notification registration, third-party SDK init, etc.
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        // Forward to push notification service
    }
}

适用场景:推送通知、需要 AppDelegate 的第三方 SDK、后台 URL 会话、Handoff。

迁移优先级

在 UIKit 应用中增量采用 SwiftUI 时:

  1. 先迁移叶子页面——设置、关于、详情视图(没有导航复杂性)
  2. 用 SwiftUI 开发新功能——不要重写现有页面,而是用 SwiftUI 构建新页面
  3. 共享组件——构建可复用的 SwiftUI 组件,并用 UIHostingController 包装
  4. 最后处理导航——不要在同一流程中混用 UINavigationController 和 NavigationStack;迁移整个导航子树

不要:只为应用的一半将 UINavigationController 替换为 NavigationStack。一个流程要么完全采用 SwiftUI 导航,要么完全采用 UIKit 导航。


第 6 部分:压力场景

场景 1:“现在先把根视图硬编码”

压力

“我们现在只有一个流程。直接显示 MainView 就行,之后再加身份验证。”

危险信号
  • “之后再加 X” → 会不断累积的技术债务
  • “现在只有一个流程” → 流程会不断增加
  • “保持简单” → 现在简单,以后复杂
时间成本对比
方案初始投入添加身份验证时总计
硬编码 MainView0 分钟重构 2-4 小时2-4 小时
AppStateController30 分钟添加状态需 30 分钟1 小时
反对话术

“AppStateController 模式现在只需 30 分钟。之后我们添加身份验证时——而且肯定会添加——再花 30 分钟增加状态即可。现在硬编码并不能节省时间,因为需要身份验证时,我们会花 2-4 小时重构。现在投入 30 分钟吧。”

操作步骤
  1. 创建仅包含两个状态的最简 AppStateController:

    enum AppState {
        case loading
        case ready
    }
    
  2. 需要身份验证时,添加状态:

    enum AppState {
        case loading
        case unauthenticated  // Added
        case authenticated(User)  // Added
    }
    
  3. 总投入为 1 小时,而不是 4 小时


场景 2:“我们现在还不需要模块”

压力

“把所有内容都放在一个 target 里吧。模块属于过度设计。”

决策框架
代码库团队建议
< 5,000 行1-2 名开发者使用单个 target 即可
5,000-20,000 行3 名及以上开发者考虑使用模块
> 20,000 行任意规模模块必不可少
反驳话术

“我同意模块会增加开销。我们用下面的决策树来决定:我们有 [X] 行代码和 [Y] 位开发者。根据这些情况,我们[应该/不应该]现在进行模块化。如果达到 [threshold],我们再重新评估。这样可以吗?”

要做的事
  1. 检查代码库规模:find . -name "*.swift" | xargs wc -l
  2. 如果低于阈值,记录决策以及重新评估的阈值
  3. 如果超过阈值,先找出自然的边界

场景 3:“导航太复杂,无法测试”

压力

“测试导航状态太难了。我们还是手动 QA 吧。”

为什么这行不通
  • 导航错误是“只在我的机器上能运行”的首要原因
  • 深度链接需要自动化验证
  • 状态恢复需要回归测试
  • 手动 QA 会漏掉边缘情况
解决方案:测试状态机
// ✅ Test navigation state without UI
@Test func testLoginCompletesOnboarding() async {
    let controller = AppStateController()
    controller.transition(to: .unauthenticated)

    // Simulate login
    await controller.handleLogin(user: mockUser)

    // First-time user goes to onboarding
    #expect(controller.state == .onboarding(.welcome))
}

@Test func testDeepLinkWhileUnauthenticated() async {
    let controller = AppStateController()
    controller.transition(to: .unauthenticated)

    // Deep link to order
    let handled = controller.handleDeepLink(URL(string: "app://order/123")!)

    // Should not navigate — requires auth
    #expect(handled == false)
    #expect(controller.state == .unauthenticated)
}
反驳话术

“正因为导航复杂,我们才更需要自动化测试。AppStateController 模式让我们无需启动 UI 就能测试状态转换。我们可以在几秒内验证深度链接、身份验证流程和状态恢复。手动 QA 无法覆盖所有组合。”


第 7 部分:代码审查清单

应用状态

  • 应用状态使用枚举,而非布尔值
  • 明确定义所有有效状态
  • 在 isValidTransition 中验证状态转换
  • 捕获无效的状态转换(调试模式下触发断言,生产环境中记录日志)
  • 记录状态变更,以便调试

根视图

  • @main 委托给 AppStateController
  • @main 中没有业务逻辑
  • RootView 根据唯一事实来源进行切换
  • 状态转换带有动画(无闪烁)
  • 加载状态有最短显示时长

场景生命周期

  • 在同一处处理 scenePhase 变更
  • 在 .active 时验证会话(而非盲目信任)
  • 在 .background 时释放资源
  • 使用 SceneStorage 保存标签页选择/导航状态
  • 应用恢复的状态前先进行验证

模块边界

  • 功能模块具有公开的 API 协议
  • 模块之间没有循环依赖
  • 导航委托给协调器处理
  • 通过依赖注入提供依赖,而非使用单例
  • 记录模块化决策(为何拆分/不拆分)

测试

  • 无需 UI 即可测试状态转换
  • 测试无效的状态转换
  • 测试深度链接处理
  • 测试状态恢复验证

资源

WWDC:2025-266、2024-10150、2023-10149、2025-256、2022-10054

文档:/swiftui/scenephase、/swiftui/scene、/swiftui/scenestorage、/swiftui/windowgroup、/observation/observable()

技能:axiom-swiftui-architecture、axiom-swiftui-nav、axiom-swift-concurrency

评论

支持 Markdown