Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/app-architecture.md
2025-11-29 18:28:37 +08:00

11 KiB

App Architecture

State management, dependency injection, and architectural patterns for iOS apps.

State Management

@Observable (iOS 17+)

The modern approach for shared state:

@Observable
class AppState {
    var items: [Item] = []
    var selectedItemID: UUID?
    var isLoading = false
    var error: AppError?

    // Computed properties work naturally
    var selectedItem: Item? {
        items.first { $0.id == selectedItemID }
    }

    var hasItems: Bool { !items.isEmpty }
}

// In views - only re-renders when used properties change
struct ContentView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        if appState.isLoading {
            ProgressView()
        } else {
            ItemList(items: appState.items)
        }
    }
}

Two-Way Bindings

For binding to @Observable properties:

struct SettingsView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        @Bindable var appState = appState

        Form {
            TextField("Username", text: $appState.username)
            Toggle("Notifications", isOn: $appState.notificationsEnabled)
        }
    }
}

State Decision Tree

@State - View-local UI state

  • Toggle expanded/collapsed
  • Text field content
  • Sheet presentation
struct ItemRow: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            // ...
        }
    }
}

@Observable in Environment - Shared app state

  • User session
  • Navigation state
  • Feature flags
@main
struct MyApp: App {
    @State private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
        }
    }
}

@Query - SwiftData persistence

  • Database entities
  • Filtered/sorted queries
struct ItemList: View {
    @Query(sort: \Item.createdAt, order: .reverse)
    private var items: [Item]

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
    }
}

Dependency Injection

Environment Keys

Define environment keys for testable dependencies:

// Protocol for testability
protocol NetworkServiceProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

// Live implementation
class LiveNetworkService: NetworkServiceProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        // Real implementation
    }
}

// Mock for testing
class MockNetworkService: NetworkServiceProtocol {
    var mockResult: Any?
    var mockError: Error?

    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        if let error = mockError { throw error }
        return mockResult as! T
    }
}

// Environment key
struct NetworkServiceKey: EnvironmentKey {
    static let defaultValue: NetworkServiceProtocol = LiveNetworkService()
}

extension EnvironmentValues {
    var networkService: NetworkServiceProtocol {
        get { self[NetworkServiceKey.self] }
        set { self[NetworkServiceKey.self] = newValue }
    }
}

// Inject at app level
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.networkService, LiveNetworkService())
        }
    }
}

// Use in views
struct ItemList: View {
    @Environment(\.networkService) private var networkService

    var body: some View {
        // ...
    }

    func loadItems() async {
        let items: [Item] = try await networkService.fetch(.items)
    }
}

Dependency Container

For complex apps with many dependencies:

@Observable
class AppDependencies {
    let network: NetworkServiceProtocol
    let storage: StorageServiceProtocol
    let purchases: PurchaseServiceProtocol
    let analytics: AnalyticsServiceProtocol

    init(
        network: NetworkServiceProtocol = LiveNetworkService(),
        storage: StorageServiceProtocol = LiveStorageService(),
        purchases: PurchaseServiceProtocol = LivePurchaseService(),
        analytics: AnalyticsServiceProtocol = LiveAnalyticsService()
    ) {
        self.network = network
        self.storage = storage
        self.purchases = purchases
        self.analytics = analytics
    }

    // Convenience for testing
    static func mock() -> AppDependencies {
        AppDependencies(
            network: MockNetworkService(),
            storage: MockStorageService(),
            purchases: MockPurchaseService(),
            analytics: MockAnalyticsService()
        )
    }
}

// Inject as single environment object
@main
struct MyApp: App {
    @State private var dependencies = AppDependencies()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(dependencies)
        }
    }
}

View Models (When Needed)

For views with significant logic, use a view-local model:

struct ItemDetailScreen: View {
    let itemID: UUID
    @State private var viewModel: ItemDetailViewModel

    init(itemID: UUID) {
        self.itemID = itemID
        self._viewModel = State(initialValue: ItemDetailViewModel(itemID: itemID))
    }

    var body: some View {
        Form {
            if viewModel.isLoading {
                ProgressView()
            } else if let item = viewModel.item {
                ItemContent(item: item)
            }
        }
        .task {
            await viewModel.load()
        }
    }
}

@Observable
class ItemDetailViewModel {
    let itemID: UUID
    var item: Item?
    var isLoading = false
    var error: Error?

    init(itemID: UUID) {
        self.itemID = itemID
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }

        do {
            item = try await fetchItem(id: itemID)
        } catch {
            self.error = error
        }
    }

    func save() async {
        // Save logic
    }
}

Coordinator Pattern

For complex navigation flows:

@Observable
class OnboardingCoordinator {
    var currentStep: OnboardingStep = .welcome
    var isComplete = false

    enum OnboardingStep {
        case welcome
        case permissions
        case personalInfo
        case complete
    }

    func next() {
        switch currentStep {
        case .welcome:
            currentStep = .permissions
        case .permissions:
            currentStep = .personalInfo
        case .personalInfo:
            currentStep = .complete
            isComplete = true
        case .complete:
            break
        }
    }

    func back() {
        switch currentStep {
        case .welcome:
            break
        case .permissions:
            currentStep = .welcome
        case .personalInfo:
            currentStep = .permissions
        case .complete:
            currentStep = .personalInfo
        }
    }
}

struct OnboardingFlow: View {
    @State private var coordinator = OnboardingCoordinator()

    var body: some View {
        Group {
            switch coordinator.currentStep {
            case .welcome:
                WelcomeView(onContinue: coordinator.next)
            case .permissions:
                PermissionsView(onContinue: coordinator.next, onBack: coordinator.back)
            case .personalInfo:
                PersonalInfoView(onContinue: coordinator.next, onBack: coordinator.back)
            case .complete:
                CompletionView()
            }
        }
        .animation(.default, value: coordinator.currentStep)
    }
}

Error Handling

Structured Error Types

enum AppError: LocalizedError {
    case networkError(NetworkError)
    case storageError(StorageError)
    case validationError(String)
    case unauthorized
    case unknown(Error)

    var errorDescription: String? {
        switch self {
        case .networkError(let error):
            return error.localizedDescription
        case .storageError(let error):
            return error.localizedDescription
        case .validationError(let message):
            return message
        case .unauthorized:
            return "Please sign in to continue"
        case .unknown(let error):
            return error.localizedDescription
        }
    }

    var recoverySuggestion: String? {
        switch self {
        case .networkError:
            return "Check your internet connection and try again"
        case .unauthorized:
            return "Tap to sign in"
        default:
            return nil
        }
    }
}

enum NetworkError: LocalizedError {
    case noConnection
    case timeout
    case serverError(Int)
    case decodingError

    var errorDescription: String? {
        switch self {
        case .noConnection:
            return "No internet connection"
        case .timeout:
            return "Request timed out"
        case .serverError(let code):
            return "Server error (\(code))"
        case .decodingError:
            return "Invalid response from server"
        }
    }
}

Error Presentation

struct ContentView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        NavigationStack {
            // Content
        }
        .alert(
            "Error",
            isPresented: Binding(
                get: { appState.error != nil },
                set: { if !$0 { appState.error = nil } }
            ),
            presenting: appState.error
        ) { error in
            Button("OK") { }
            if error.recoverySuggestion != nil {
                Button("Retry") {
                    Task { await retry() }
                }
            }
        } message: { error in
            VStack {
                Text(error.localizedDescription)
                if let suggestion = error.recoverySuggestion {
                    Text(suggestion)
                        .font(.caption)
                }
            }
        }
    }
}

Testing Architecture

Unit Testing with Mocks

@Test
func testLoadItems() async throws {
    // Arrange
    let mockNetwork = MockNetworkService()
    mockNetwork.mockResult = [Item(name: "Test")]

    let viewModel = ItemListViewModel(networkService: mockNetwork)

    // Act
    await viewModel.load()

    // Assert
    #expect(viewModel.items.count == 1)
    #expect(viewModel.items[0].name == "Test")
    #expect(viewModel.isLoading == false)
}

@Test
func testLoadItemsError() async throws {
    // Arrange
    let mockNetwork = MockNetworkService()
    mockNetwork.mockError = NetworkError.noConnection

    let viewModel = ItemListViewModel(networkService: mockNetwork)

    // Act
    await viewModel.load()

    // Assert
    #expect(viewModel.items.isEmpty)
    #expect(viewModel.error != nil)
}

Preview with Dependencies

#Preview {
    ContentView()
        .environment(AppDependencies.mock())
        .environment(AppState())
}