关于 axiom-app-composition
面向 SwiftUI 开发者的应用架构技能,涵盖应用状态、根视图、生命周期、状态恢复和功能模块。重点是显式状态机与精简的 @main 入口。
主要功能
- 01用枚举建模应用状态
- 02集中管理并验证状态转换
- 03按应用状态组合根视图
- 04处理场景生命周期事件
- 05恢复轻量场景与导航状态
- 06判断何时拆分功能模块
适用场景
- 01组织登录、引导和主界面流程
- 02避免应用状态切换时闪屏
- 03场景激活时重新验证会话
- 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 部分:应用级状态机
核心原则
“应用有明确的状态。应使用枚举显式建模,而不是使用分散的布尔值。”
每个非简单应用都有不同的状态:加载中、未通过身份验证、引导、已通过身份验证、错误恢复。这些状态应当:
- 明确 — 使用枚举,而不是多个布尔值
- 经过验证 — 检查并记录状态转换
- 集中管理 — 只有一个事实来源
- 可观察 — 视图会对状态变化做出响应
布尔值杂烩问题
// ❌ 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 时:
- 先迁移叶子页面——设置、关于、详情视图(没有导航复杂性)
- 用 SwiftUI 开发新功能——不要重写现有页面,而是用 SwiftUI 构建新页面
- 共享组件——构建可复用的 SwiftUI 组件,并用
UIHostingController包装 - 最后处理导航——不要在同一流程中混用
UINavigationController和NavigationStack;迁移整个导航子树
不要:只为应用的一半将 UINavigationController 替换为 NavigationStack。一个流程要么完全采用 SwiftUI 导航,要么完全采用 UIKit 导航。
第 6 部分:压力场景
场景 1:“现在先把根视图硬编码”
压力
“我们现在只有一个流程。直接显示 MainView 就行,之后再加身份验证。”
危险信号
- “之后再加 X” → 会不断累积的技术债务
- “现在只有一个流程” → 流程会不断增加
- “保持简单” → 现在简单,以后复杂
时间成本对比
| 方案 | 初始投入 | 添加身份验证时 | 总计 |
|---|---|---|---|
| 硬编码 MainView | 0 分钟 | 重构 2-4 小时 | 2-4 小时 |
| AppStateController | 30 分钟 | 添加状态需 30 分钟 | 1 小时 |
反对话术
“AppStateController 模式现在只需 30 分钟。之后我们添加身份验证时——而且肯定会添加——再花 30 分钟增加状态即可。现在硬编码并不能节省时间,因为需要身份验证时,我们会花 2-4 小时重构。现在投入 30 分钟吧。”
操作步骤
-
创建仅包含两个状态的最简 AppStateController:
enum AppState { case loading case ready } -
需要身份验证时,添加状态:
enum AppState { case loading case unauthenticated // Added case authenticated(User) // Added } -
总投入为 1 小时,而不是 4 小时
场景 2:“我们现在还不需要模块”
压力
“把所有内容都放在一个 target 里吧。模块属于过度设计。”
决策框架
| 代码库 | 团队 | 建议 |
|---|---|---|
| < 5,000 行 | 1-2 名开发者 | 使用单个 target 即可 |
| 5,000-20,000 行 | 3 名及以上开发者 | 考虑使用模块 |
| > 20,000 行 | 任意规模 | 模块必不可少 |
反驳话术
“我同意模块会增加开销。我们用下面的决策树来决定:我们有 [X] 行代码和 [Y] 位开发者。根据这些情况,我们[应该/不应该]现在进行模块化。如果达到 [threshold],我们再重新评估。这样可以吗?”
要做的事
- 检查代码库规模:
find . -name "*.swift" | xargs wc -l - 如果低于阈值,记录决策以及重新评估的阈值
- 如果超过阈值,先找出自然的边界
场景 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