本章讲解 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 的演进 |