第十章:iOS架构设计与工程化

本章讲解 iOS 大型项目的架构设计(MVVM、Clean Architecture、TCA)、工程化实践(SPM依赖管理、多环境配置、日志系统)、以及 Keychain 安全存储。


10.1 MVVM 架构

分层结构

复制代码
View(SwiftUI)
  ↕ @Observable
ViewModel(业务逻辑、状态管理)
  ↕ Repository Protocol
Repository(数据访问抽象层)
  ↙           ↘
RemoteDataSource   LocalDataSource
(网络请求)         (本地存储)
swift 复制代码
// ======== Model 层 ========
// 纯粹的数据模型,不依赖任何框架
struct ArticleEntity: Identifiable, Hashable {
    let id: String
    let title: String
    let summary: String
    let content: String
    let authorName: String
    let coverURL: URL?
    let publishedAt: Date
    var isBookmarked: Bool
    var viewCount: Int
}

// ======== Repository 层 ========
// 协议:定义数据访问接口
protocol ArticleRepositoryProtocol {
    func fetchAll(page: Int, pageSize: Int) async throws -> [ArticleEntity]
    func fetchById(_ id: String) async throws -> ArticleEntity
    func bookmark(_ id: String) async throws
    func search(query: String) async throws -> [ArticleEntity]
}

// 实现:组合 Remote 和 Local 数据源
final class ArticleRepository: ArticleRepositoryProtocol {
    private let remote: ArticleRemoteDataSource
    private let local: ArticleLocalDataSource
    private let cache: ArticleCacheManager
    
    init(remote: ArticleRemoteDataSource = .shared,
         local: ArticleLocalDataSource = .shared,
         cache: ArticleCacheManager = .shared) {
        self.remote = remote
        self.local = local
        self.cache = cache
    }
    
    func fetchAll(page: Int, pageSize: Int) async throws -> [ArticleEntity] {
        // Strategy: Cache First
        if page == 1, let cached = await cache.getCachedList() {
            Task { try? await refreshFromRemote(page: page) }  // 后台刷新
            return cached
        }
        return try await refreshFromRemote(page: page)
    }
    
    @discardableResult
    private func refreshFromRemote(page: Int) async throws -> [ArticleEntity] {
        let dtos = try await remote.fetchArticles(page: page)
        let entities = dtos.map { ArticleMapper.toEntity($0) }
        if page == 1 { await cache.save(entities) }
        return entities
    }
    
    func fetchById(_ id: String) async throws -> ArticleEntity {
        if let cached = await cache.get(id: id) { return cached }
        let dto = try await remote.fetchArticle(id: id)
        return ArticleMapper.toEntity(dto)
    }
    
    func bookmark(_ id: String) async throws {
        try await remote.bookmark(articleId: id)
        await local.toggleBookmark(articleId: id)
    }
    
    func search(query: String) async throws -> [ArticleEntity] {
        let dtos = try await remote.search(query: query)
        return dtos.map { ArticleMapper.toEntity($0) }
    }
}

// ======== ViewModel 层 ========
@Observable
@MainActor
final class ArticleListViewModel {
    // State
    var articles: [ArticleEntity] = []
    var isLoading = false
    var isLoadingMore = false
    var errorMessage: String?
    var searchText = ""
    var currentPage = 1
    var hasMore = true
    
    var filteredArticles: [ArticleEntity] {
        searchText.isEmpty ? articles
            : articles.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
    }
    
    // Dependency
    private let repository: ArticleRepositoryProtocol
    
    init(repository: ArticleRepositoryProtocol = ArticleRepository()) {
        self.repository = repository
    }
    
    // Actions
    func onAppear() async {
        guard articles.isEmpty else { return }
        await loadFirstPage()
    }
    
    func loadFirstPage() async {
        isLoading = true
        currentPage = 1
        errorMessage = nil
        
        do {
            articles = try await repository.fetchAll(page: 1, pageSize: 20)
            hasMore = articles.count == 20
            currentPage = 1
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
    
    func loadMore() async {
        guard !isLoadingMore && hasMore else { return }
        isLoadingMore = true
        
        do {
            let newArticles = try await repository.fetchAll(
                page: currentPage + 1,
                pageSize: 20
            )
            articles += newArticles
            hasMore = newArticles.count == 20
            currentPage += 1
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoadingMore = false
    }
    
    func bookmark(_ article: ArticleEntity) async {
        try? await repository.bookmark(article.id)
        if let index = articles.firstIndex(where: { $0.id == article.id }) {
            articles[index].isBookmarked.toggle()
        }
    }
}

// ======== View 层 ========
struct ArticleListView: View {
    @Environment(ArticleListViewModel.self) var vm
    
    var body: some View {
        List {
            ForEach(vm.filteredArticles) { article in
                NavigationLink(value: article) {
                    ArticleRow(article: article)
                        .onAppear {
                            // 触底加载更多
                            if article.id == vm.filteredArticles.last?.id {
                                Task { await vm.loadMore() }
                            }
                        }
                }
                .swipeActions {
                    Button {
                        Task { await vm.bookmark(article) }
                    } label: {
                        Label(article.isBookmarked ? "取消收藏" : "收藏",
                              systemImage: article.isBookmarked ? "bookmark.slash" : "bookmark")
                    }
                    .tint(.orange)
                }
            }
            
            if vm.isLoadingMore {
                HStack { Spacer(); ProgressView(); Spacer() }
            }
        }
        .searchable(text: Bindable(vm).searchText)
        .refreshable { await vm.loadFirstPage() }
        .task { await vm.onAppear() }
        .alert("加载失败", isPresented: .constant(vm.errorMessage != nil)) {
            Button("重试") { Task { await vm.loadFirstPage() } }
            Button("取消", role: .cancel) { vm.errorMessage = nil }
        } message: {
            Text(vm.errorMessage ?? "")
        }
    }
}

10.2 Swift Package Manager 依赖管理

swift 复制代码
// ① 在 Xcode 中添加依赖
// File → Add Package Dependencies → 输入 GitHub URL

// ② Package.swift(开发私有库/工具时使用)
let package = Package(
    name: "MyApp",
    platforms: [.iOS(.v17), .macOS(.v14)],
    dependencies: [
        .package(url: "https://github.com/pointfreeco/swift-composable-architecture",
                 from: "1.0.0"),
        .package(url: "https://github.com/onevcat/Kingfisher",
                 from: "7.0.0"),
        .package(url: "https://github.com/Alamofire/Alamofire",
                 from: "5.9.0"),
        .package(url: "https://github.com/getsentry/sentry-cocoa",
                 from: "8.0.0"),
    ]
)

// ③ 常用依赖清单
类别 GitHub
架构 ComposableArchitecture pointfreeco/swift-composable-architecture
网络 Alamofire Alamofire/Alamofire
图片 Kingfisher onevcat/Kingfisher
动画 Lottie airbnb/lottie-ios
数据库 GRDB.swift groue/GRDB.swift
日志 CocoaLumberjack CocoaLumberjack/CocoaLumberjack
崩溃监控 Sentry getsentry/sentry-cocoa
二维码 CodeScanner twostraws/CodeScanner

10.3 多环境配置

swift 复制代码
// xcconfig 文件(分环境配置)
// Config/Debug.xcconfig
// APP_NAME = MyApp_Dev
// API_BASE_URL = https://dev-api.example.com
// ENABLE_ANALYTICS = NO

// Config/Release.xcconfig
// APP_NAME = MyApp
// API_BASE_URL = https://api.example.com
// ENABLE_ANALYTICS = YES

// 在 Info.plist 中引用 xcconfig 变量
// <key>APIBaseURL</key>
// <string>$(API_BASE_URL)</string>

// Swift 中读取
enum AppConfig {
    static var apiBaseURL: String {
        Bundle.main.infoDictionary?["APIBaseURL"] as? String
            ?? "https://api.example.com"
    }
    
    static var enableAnalytics: Bool {
        (Bundle.main.infoDictionary?["ENABLE_ANALYTICS"] as? String) == "YES"
    }
    
    static var appName: String {
        Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "App"
    }
    
    static var isDebug: Bool {
        #if DEBUG
        return true
        #else
        return false
        #endif
    }
    
    static var environment: Environment {
        #if DEBUG
        return .development
        #elseif STAGING
        return .staging
        #else
        return .production
        #endif
    }
    
    enum Environment: String {
        case development = "开发"
        case staging     = "测试"
        case production  = "生产"
    }
}

10.4 日志系统(OSLog)

swift 复制代码
import OSLog

// 定义分类 Logger(在 Extension 中集中管理)
extension Logger {
    private static let subsystem = Bundle.main.bundleIdentifier!
    
    static let network  = Logger(subsystem: subsystem, category: "🌐 Network")
    static let ui       = Logger(subsystem: subsystem, category: "🎨 UI")
    static let data     = Logger(subsystem: subsystem, category: "💾 Data")
    static let auth     = Logger(subsystem: subsystem, category: "🔐 Auth")
    static let general  = Logger(subsystem: subsystem, category: "📱 App")
}

// 使用
class NetworkService {
    func request(url: URL) async throws -> Data {
        Logger.network.info("▶️ 请求开始: \(url.path)")
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            Logger.network.info("✅ 请求成功: \(data.count) bytes")
            return data
        } catch {
            Logger.network.error("❌ 请求失败: \(error.localizedDescription, privacy: .public)")
            throw error
        }
    }
}

// 日志隐私(防止敏感信息出现在日志)
// private: 默认,日志中显示为 <private>
// public: 明文显示

// 在 Console.app 中查看日志
// 支持按 category 过滤、按时间过滤

10.5 Keychain 安全存储

swift 复制代码
import Security

// Keychain 封装
final class KeychainManager {
    static let shared = KeychainManager()
    private let service = Bundle.main.bundleIdentifier ?? "com.app"
    
    func save(_ value: String, for key: String) -> Bool {
        guard let data = value.data(using: .utf8) else { return false }
        
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecValueData: data,
            kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        ]
        
        SecItemDelete(query as CFDictionary)  // 删除旧值
        return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
    }
    
    func get(_ key: String) -> String? {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne,
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess,
              let data = result as? Data,
              let value = String(data: data, encoding: .utf8) else {
            return nil
        }
        return value
    }
    
    func delete(_ key: String) {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
        ]
        SecItemDelete(query as CFDictionary)
    }
    
    func clearAll() {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
        ]
        SecItemDelete(query as CFDictionary)
    }
}

// 使用:存储 Token
class AuthManager {
    static let shared = AuthManager()
    
    var accessToken: String? {
        get { KeychainManager.shared.get("access_token") }
        set {
            if let token = newValue {
                KeychainManager.shared.save(token, for: "access_token")
            } else {
                KeychainManager.shared.delete("access_token")
            }
        }
    }
    
    var isLoggedIn: Bool { accessToken != nil }
}

章节总结

架构/工程化 核心价值 推荐场景
MVVM 视图与业务逻辑分离 大多数项目
Clean Architecture 分层解耦、可测试 中大型项目
TCA 单向数据流、完全可测试 高要求项目
SPM 依赖管理标准化 所有项目
xcconfig 多环境 环境配置分离 有多环境需求
OSLog 结构化日志 替代 print()
Keychain 安全存储敏感数据 Token/密码

Demo 说明

文件 演示内容
MVVMDemo.swift MVVM 分层完整实现
CleanArchDemo.swift Repository + UseCase + ViewModel
MultiEnvDemo.swift xcconfig 多环境配置读取
LoggingDemo.swift OSLog 分级日志演示
KeychainDemo.swift Token 存储/读取/清除

📎 扩展内容补充

来源:第十章_测试与发布.md
本章概述:掌握 XCTest 单元测试、UI 测试,以及 TestFlight 测试分发、App Store 发布流程和代码签名。


10.1 单元测试(XCTest)

概念讲解

swift 复制代码
import XCTest
@testable import iOS_demos  // 测试 target 访问内部类型

// 测试 ViewModel(类比 Flutter 的 test 包)
final class ArticleViewModelTests: XCTestCase {
    
    var viewModel: ArticleViewModel!
    var mockService: MockArticleService!
    
    // setUp - 每个测试前执行(类比 Flutter 的 setUp)
    override func setUp() {
        super.setUp()
        mockService = MockArticleService()
        viewModel = ArticleViewModel(service: mockService)
    }
    
    // tearDown - 每个测试后清理
    override func tearDown() {
        viewModel = nil
        mockService = nil
        super.tearDown()
    }
    
    // 测试正常加载
    func testLoadArticlesSuccess() async throws {
        // Given(准备数据)
        mockService.mockArticles = [
            Article(id: 1, title: "SwiftUI入门"),
            Article(id: 2, title: "Combine进阶"),
        ]
        
        // When(执行操作)
        await viewModel.loadArticles()
        
        // Then(断言结果)
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNil(viewModel.errorMessage)
        XCTAssertEqual(viewModel.articles.count, 2)
        XCTAssertEqual(viewModel.articles.first?.title, "SwiftUI入门")
    }
    
    // 测试错误处理
    func testLoadArticlesFailure() async {
        // Given
        mockService.shouldThrowError = true
        
        // When
        await viewModel.loadArticles()
        
        // Then
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNotNil(viewModel.errorMessage)
        XCTAssertTrue(viewModel.articles.isEmpty)
    }
    
    // 测试搜索防抖
    func testSearchDebounce() async throws {
        // 快速输入多次,只触发一次搜索
        await viewModel.updateSearch("S")
        await viewModel.updateSearch("Sw")
        await viewModel.updateSearch("Swift")
        
        try await Task.sleep(for: .milliseconds(600))  // 等待防抖
        
        XCTAssertEqual(mockService.searchCallCount, 1)
        XCTAssertEqual(mockService.lastSearchQuery, "Swift")
    }
}

// Mock 服务(依赖注入 + Protocol 测试)
protocol ArticleServiceProtocol {
    func fetchArticles() async throws -> [Article]
    func search(query: String) async throws -> [Article]
}

class MockArticleService: ArticleServiceProtocol {
    var mockArticles: [Article] = []
    var shouldThrowError = false
    var searchCallCount = 0
    var lastSearchQuery: String?
    
    func fetchArticles() async throws -> [Article] {
        if shouldThrowError { throw NetworkError.noData }
        return mockArticles
    }
    
    func search(query: String) async throws -> [Article] {
        searchCallCount += 1
        lastSearchQuery = query
        return mockArticles.filter { $0.title.contains(query) }
    }
}

// TCA 测试(见第三章)
// 测试 Codable
func testArticleDecoding() throws {
    let json = """
    {"id": 1, "title": "测试文章", "published_at": "2024-01-01T00:00:00Z"}
    """.data(using: .utf8)!
    
    let article = try JSONDecoder.apiDecoder.decode(Article.self, from: json)
    
    XCTAssertEqual(article.id, 1)
    XCTAssertEqual(article.title, "测试文章")
}

10.2 UI 测试(XCUITest)

概念讲解

swift 复制代码
import XCTest

// UI 测试在独立进程中运行,操作真实 UI 元素
final class LoginUITests: XCTestCase {
    
    var app: XCUIApplication!
    
    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        // 通过环境变量设置测试模式
        app.launchArguments = ["--uitesting"]
        app.launchEnvironment = ["USE_MOCK_DATA": "true"]
        app.launch()
    }
    
    // 测试登录流程
    func testLoginFlow() throws {
        // 1. 确认在登录页
        let loginTitle = app.navigationBars["登录"].firstMatch
        XCTAssertTrue(loginTitle.exists)
        
        // 2. 输入用户名
        let usernameField = app.textFields["username_textfield"]
        usernameField.tap()
        usernameField.typeText("testuser@example.com")
        
        // 3. 输入密码
        let passwordField = app.secureTextFields["password_textfield"]
        passwordField.tap()
        passwordField.typeText("password123")
        
        // 4. 点击登录按钮
        let loginButton = app.buttons["login_button"]
        XCTAssertTrue(loginButton.isEnabled)
        loginButton.tap()
        
        // 5. 等待导航到首页
        let homeTitle = app.navigationBars["首页"].firstMatch
        XCTAssertTrue(homeTitle.waitForExistence(timeout: 5))
    }
    
    // 测试空状态
    func testEmptyState() {
        let noDataLabel = app.staticTexts["暂无数据"]
        // ...
    }
    
    // 测试截图
    func testScreenshot() {
        let screenshot = app.screenshot()
        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.name = "登录页截图"
        attachment.lifetime = .keepAlways
        add(attachment)
    }
}
UI 元素 Accessibility Identifier
swift 复制代码
// 在 View 中为元素设置 accessibilityIdentifier
struct LoginView: View {
    @State private var username = ""
    @State private var password = ""
    
    var body: some View {
        VStack {
            TextField("邮箱", text: $username)
                .accessibilityIdentifier("username_textfield")
            
            SecureField("密码", text: $password)
                .accessibilityIdentifier("password_textfield")
            
            Button("登录") { /* ... */ }
                .accessibilityIdentifier("login_button")
        }
    }
}

10.3 App Store 发布流程

概念讲解

代码签名
复制代码
证书(Certificate)+ 描述文件(Provisioning Profile)= 签名

证书类型:
- Development: 开发调试(真机运行)
- Distribution: 上架发行(TestFlight + App Store)

描述文件类型:
- Development: 包含设备UDID列表
- Ad Hoc: 指定设备分发测试
- App Store: App Store 分发
发布检查清单
markdown 复制代码
□ 版本号(CFBundleShortVersionString):x.x.x 格式
□ Build 号(CFBundleVersion):每次上传必须递增
□ App 图标:1024x1024 PNG(无 Alpha 通道)
□ 隐私权限说明(Info.plist中对应 NSXxx 字段)
□ 审查指南合规性检查
□ 所有必要权限已在 App Privacy Report 声明
□ 支持最新 iOS 版本
□ 运行 Archive → Validate → Distribute

在 Xcode 中:
Product → Archive → Distribute App → App Store Connect
TestFlight 内测分发
swift 复制代码
// 检测当前运行环境
extension Bundle {
    // 是否通过 TestFlight 安装
    var isTestFlight: Bool {
        guard let receiptURL = appStoreReceiptURL else { return false }
        return receiptURL.lastPathComponent == "sandboxReceipt"
    }
    
    // 是否是 Debug 构建
    var isDebug: Bool {
        #if DEBUG
        return true
        #else
        return false
        #endif
    }
}

// 根据环境显示不同内容
struct AppVersionView: View {
    var environmentLabel: String {
        if Bundle.main.isDebug {
            return "开发版"
        } else if Bundle.main.isTestFlight {
            return "测试版 (TestFlight)"
        } else {
            return ""
        }
    }
    
    var body: some View {
        VStack {
            Text("版本 \(Bundle.main.version) (\(Bundle.main.buildNumber))")
                .font(.caption)
                .foregroundStyle(.secondary)
            
            if !environmentLabel.isEmpty {
                Text(environmentLabel)
                    .font(.caption2)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 2)
                    .background(.orange.opacity(0.2))
                    .foregroundStyle(.orange)
                    .cornerRadius(6)
            }
        }
    }
}

章节总结

测试类型 框架 用途
单元测试 XCTest 测试 ViewModel/Service/Model 逻辑
UI 测试 XCUITest 测试用户操作流程
TCA 测试 TestStore 精准测试单向数据流
性能测试 XCTMetric 测试启动时间/内存占用

Demo 说明

Demo 文件 演示内容
UnitTestDemo.swift XCTest 单元测试 + Mock 依赖
UITestDemo.swift XCUITest 登录/列表 UI 流程测试

📎 扩展内容补充

来源:第十二章_最佳实践.md
本章概述:系统学习 iOS 架构最佳实践,包含 MVVM + Clean Architecture、TCA 大型项目架构、无障碍(Accessibility)适配,以及架构选型与演进策略。


12.1 MVVM + Clean Architecture

概念讲解

分层架构
复制代码
┌──────────────────────────────────────────┐
│                Presentation 层            │
│  View (SwiftUI) ←→ ViewModel (@Observable)│
├──────────────────────────────────────────┤
│                  Domain 层                │
│  UseCase(业务逻辑)+ Entity(业务模型)   │
├──────────────────────────────────────────┤
│                   Data 层                 │
│  Repository ← RemoteDataSource           │
│             ← LocalDataSource            │
└──────────────────────────────────────────┘
swift 复制代码
// ============ Domain 层 ============
// Entity(业务模型,纯 Swift,不依赖任何框架)
struct Article {
    let id: String
    let title: String
    let content: String
    let authorName: String
    let publishedAt: Date
    var isBookmarked: Bool
}

// UseCase(单一业务)
protocol GetArticleListUseCaseProtocol {
    func execute(page: Int) async throws -> [Article]
}

struct GetArticleListUseCase: GetArticleListUseCaseProtocol {
    private let repository: ArticleRepositoryProtocol
    
    init(repository: ArticleRepositoryProtocol) {
        self.repository = repository
    }
    
    func execute(page: Int) async throws -> [Article] {
        // 1. 先读本地缓存
        if let cached = try? await repository.getCachedArticles(page: page) {
            return cached
        }
        
        // 2. 请求网络
        let articles = try await repository.fetchRemoteArticles(page: page)
        
        // 3. 更新本地缓存
        try? await repository.saveArticles(articles, page: page)
        
        return articles
    }
}

// ============ Data 层 ============
// Repository 协议
protocol ArticleRepositoryProtocol {
    func fetchRemoteArticles(page: Int) async throws -> [Article]
    func getCachedArticles(page: Int) async throws -> [Article]?
    func saveArticles(_ articles: [Article], page: Int) async throws
}

// Repository 实现
class ArticleRepository: ArticleRepositoryProtocol {
    private let remoteDataSource: ArticleRemoteDataSource
    private let localDataSource: ArticleLocalDataSource
    
    init(remote: ArticleRemoteDataSource, local: ArticleLocalDataSource) {
        self.remoteDataSource = remote
        self.localDataSource = local
    }
    
    func fetchRemoteArticles(page: Int) async throws -> [Article] {
        let dtos = try await remoteDataSource.fetchArticles(page: page)
        return dtos.map { ArticleMapper.toDomain($0) }  // DTO → Domain
    }
    
    func getCachedArticles(page: Int) async throws -> [Article]? {
        return await localDataSource.loadArticles(page: page)
    }
    
    func saveArticles(_ articles: [Article], page: Int) async throws {
        await localDataSource.saveArticles(articles, page: page)
    }
}

// ============ Presentation 层 ============
@Observable
@MainActor
class ArticleListViewModel {
    // State
    var articles: [Article] = []
    var isLoading = false
    var errorMessage: String?
    var currentPage = 1
    var hasNextPage = true
    
    // Dependencies(通过依赖注入)
    private let getArticlesUseCase: GetArticleListUseCaseProtocol
    
    init(getArticlesUseCase: GetArticleListUseCaseProtocol) {
        self.getArticlesUseCase = getArticlesUseCase
    }
    
    func loadFirstPage() async {
        currentPage = 1
        hasNextPage = true
        isLoading = true
        articles = []
        
        do {
            articles = try await getArticlesUseCase.execute(page: 1)
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
    
    func loadNextPage() async {
        guard !isLoading && hasNextPage else { return }
        isLoading = true
        
        do {
            let newArticles = try await getArticlesUseCase.execute(page: currentPage + 1)
            if newArticles.isEmpty {
                hasNextPage = false
            } else {
                articles += newArticles
                currentPage += 1
            }
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
}
依赖注入容器
swift 复制代码
// 简单的依赖注入容器(类比 Flutter 的 get_it)
final class DIContainer {
    static let shared = DIContainer()
    
    // Data Layer
    lazy var articleRepository: ArticleRepositoryProtocol = {
        ArticleRepository(
            remote: ArticleRemoteDataSource(),
            local: ArticleLocalDataSource()
        )
    }()
    
    // Domain Layer
    lazy var getArticleListUseCase: GetArticleListUseCaseProtocol = {
        GetArticleListUseCase(repository: articleRepository)
    }()
    
    // Presentation Layer(ViewModels)
    func makeArticleListViewModel() -> ArticleListViewModel {
        ArticleListViewModel(getArticlesUseCase: getArticleListUseCase)
    }
}

12.2 TCA 大型项目架构

概念讲解

swift 复制代码
// TCA 大型项目的 Feature 组织结构
// App Feature → Tab Feature → 各功能 Feature

@Reducer
struct AppFeature {
    
    @ObservableState
    struct State: Equatable {
        var tab = TabFeature.State()
        var isOnboarding = true
    }
    
    enum Action {
        case tab(TabFeature.Action)
        case onboardingCompleted
        case appLaunched
    }
    
    var body: some ReducerOf<Self> {
        Scope(state: \.tab, action: \.tab) {
            TabFeature()
        }
        
        Reduce { state, action in
            switch action {
            case .appLaunched:
                return .run { send in
                    let isOnboarding = UserDefaults.standard.bool(forKey: "isFirstLaunch")
                    if !isOnboarding {
                        await send(.onboardingCompleted)
                    }
                }
            case .onboardingCompleted:
                state.isOnboarding = false
                UserDefaults.standard.set(false, forKey: "isFirstLaunch")
                return .none
            case .tab:
                return .none
            }
        }
    }
}

// 项目目录结构
/*
iOS_demos/
├── Core/
│   ├── Networking/               # 网络层
│   ├── Storage/                  # 本地存储
│   └── DI/                       # 依赖注入
├── Domain/
│   ├── Entities/                 # 业务模型
│   ├── UseCases/                 # 业务用例
│   └── Repositories/             # 仓库协议
├── Data/
│   ├── Repositories/             # 仓库实现
│   ├── RemoteDatasources/        # 网络数据源
│   └── LocalDatasources/         # 本地数据源
└── Features/
    ├── ArticleList/              # 文章列表 Feature
    │   ├── ArticleListFeature.swift    # TCA Reducer
    │   ├── ArticleListView.swift       # SwiftUI View
    │   └── ArticleRowView.swift        # 子视图
    ├── ArticleDetail/
    ├── Profile/
    └── Settings/
*/

12.3 无障碍(Accessibility)

概念讲解

swift 复制代码
// VoiceOver(类比 Flutter 的 Semantics)
struct AccessibleButton: View {
    let title: String
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            HStack {
                Image(systemName: "star.fill")
                    .accessibilityHidden(true)  // 对辅助功能隐藏无意义图标
                Text(title)
            }
        }
        // 自定义 VoiceOver 描述
        .accessibilityLabel("收藏 \(title)")      // 标签
        .accessibilityHint("双击执行收藏操作")     // 提示
        .accessibilityRole(.button)               // 角色
    }
}

// 复杂控件的无障碍
struct RatingView: View {
    @Binding var rating: Int
    let maxRating = 5
    
    var body: some View {
        HStack {
            ForEach(1...maxRating, id: \.self) { star in
                Image(systemName: star <= rating ? "star.fill" : "star")
                    .foregroundStyle(star <= rating ? .yellow : .gray)
                    .onTapGesture { rating = star }
            }
        }
        // 将整组作为一个控件
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("评分")
        .accessibilityValue("\(rating) 星,共 \(maxRating) 星")
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment: if rating < maxRating { rating += 1 }
            case .decrement: if rating > 0 { rating -= 1 }
            @unknown default: break
            }
        }
    }
}

// 动态文字大小(Dynamic Type)
struct DynamicTypeDemo: View {
    @Environment(\.dynamicTypeSize) var typeSize
    
    var body: some View {
        HStack {
            if typeSize >= .xLarge {
                // 大文字时垂直排列
                VStack {
                    Icon()
                    Description()
                }
            } else {
                // 正常文字时水平排列
                Icon()
                Description()
            }
        }
    }
}

12.4 架构选型指南

概念讲解

复制代码
项目规模 vs 架构复杂度:

小型(1-3人):
  @State + @Observable + NavigationStack
  ✅ 快速开发,代码简洁
  ❌ 测试覆盖率低,状态难追踪

中型(3-8人):
  @Observable + MVVM + Clean Architecture
  ✅ 分层清晰,可测试性好
  ❌ 需要较多样板代码

大型(8人+):
  TCA + Clean Architecture + SPM 模块化
  ✅ 完全可测试,状态可预测,团队协作友好
  ❌ 学习曲线陡,初始成本高

章节总结

最佳实践 核心价值 推荐度
Clean Architecture 分层解耦,易测试 ⭐⭐⭐⭐⭐
MVVM 视图与业务分离 ⭐⭐⭐⭐⭐
TCA 单向数据流,状态可追踪 ⭐⭐⭐⭐
依赖注入 模块解耦,可测试 ⭐⭐⭐⭐⭐
无障碍 VoiceOver适配 ⭐⭐⭐

Demo 说明

Demo 文件 演示内容
MVVMCleanDemo.swift Clean Architecture 分层实现
TCAArchitectureDemo.swift TCA 大型 Feature 组合示例
AccessibilityDemo.swift VoiceOver / Dynamic Type 适配
ArchitectureEvolutionDemo.swift 从简单状态到 Clean Arch 的演进
相关推荐
90后的晨仔9 小时前
《SwiftUI 进阶第7章:导航系统》
ios
90后的晨仔9 小时前
《swiftUI进阶 第9章SwiftUI 状态管理完全指南》
ios
90后的晨仔9 小时前
《 SwiftUI 进阶第8章:表单与设置界面》
ios
90后的晨仔10 小时前
《SwiftUI 进阶第5章:数据处理与网络请求》
ios
香蕉鼠片11 小时前
跨平台开发到底是什么
linux·windows·macos
90后的晨仔11 小时前
《SwiftUI 进阶第4章:响应式布局》
ios
平淡风云11 小时前
IOS开发:如何获取苹果手机的uuid
ios·iphone·uuid
90后的晨仔11 小时前
《SwiftUI 进阶学习第3章:手势与交互》
ios
90后的晨仔11 小时前
《SwiftUI 进阶学习第2章:动画与过渡》
ios