面向初学者的 iOS 开发完整学习路径:从第一次打开 Xcode,到独立负责企业级 App 的架构设计、测试、持续集成与 App Store 上架。
@Observable、Swift Testing、SwiftData)文中会标注最低系统要求。硬件前提:iOS 开发必须使用 macOS(真机或云 Mac 均可,详见第 1 章),需要一块 iPhone 真机用于后期调试与上架验证。
全书分五个阶段、17 章、2 个附录,建议总学习时长 10~16 周(每天 1.5~2 小时)。
| 阶段 | 章节 | 你将学会 | 建议时长 |
|---|---|---|---|
| 一、环境与语言基础 | 第 1 章 开发环境搭建 | Xcode、模拟器、真机调试、开发者账号 | 3~4 天 |
| 第 2 章 Swift 语言基础 | 类型、集合、控制流、函数、结构体与枚举 | 5~7 天 | |
| 第 3 章 Swift 进阶特性 | 可选值、闭包、协议、泛型、错误处理 | 5~7 天 | |
| 二、界面构建与交互 | 第 4 章 UIKit 基础 | 生命周期、Auto Layout、TableView、导航 | 7~10 天 |
| 第 5 章 SwiftUI 声明式界面 | 视图、状态管理、导航、与 UIKit 混编 | 7~10 天 | |
| 第 6 章 常用交互实现 | 表单键盘、手势、权限、通知、深链 | 5~7 天 | |
| 三、核心工程能力 | 第 7 章 网络请求 | URLSession、Codable、网络层封装、鉴权 | 7~10 天 |
| 第 8 章 数据持久化 | UserDefaults、Keychain、SwiftData/CoreData | 7~10 天 | |
| 第 9 章 多线程与并发 | GCD、async/await、actor、线程安全 | 7~10 天 | |
| 四、质量进阶 | 第 10 章 内存管理与 ARC | ARC、循环引用、内存排查工具 | 4~5 天 |
| 第 11 章 性能优化 | 启动优化、卡顿治理、包体积、监控 | 5~7 天 | |
| 五、企业级实践 | 第 12 章 项目架构设计 | MVC/MVVM/分层架构、依赖注入 | 5~7 天 |
| 第 13 章 模块化开发 | SPM、组件拆分、模块间通信 | 5~7 天 | |
| 第 14 章 单元测试 | XCTest、Swift Testing、Mock、UI 测试 | 5~7 天 | |
| 第 15 章 持续集成与交付 | GitHub Actions、fastlane、TestFlight | 4~6 天 | |
| 第 16 章 代码规范与团队协作 | SwiftLint、Code Review、协作流程 | 3~4 天 | |
| 第 17 章 App Store 上架流程 | 签名体系、提审、拒审应对、合规 | 4~5 天 | |
| 附录 | 附录 A 学习资源与进阶路线 | 官方资料、开源项目、后续方向 | 随用随查 |
| 附录 B 贯穿项目需求说明书 | 「轻记账」完整需求与里程碑 | 贯穿全程 |
每章都由以下板块组成,建议按顺序阅读:
| 板块 | 作用 |
|---|---|
| 本章学习目标 | 开工前先明确「学完你应该能做什么」,读完用它自检 |
| 核心知识点 | 正文主体,概念 + 代码示例 + 讲解 |
| 常见误区 | 初学者最容易踩的坑,每条都给出后果与正确做法 |
| 最佳实践 | 企业项目中沉淀下来的工程惯例,从第一天就照做 |
| 典型业务场景 | 用「轻记账」项目的真实需求演示知识点的落地方式 |
| 动手练习 | 必做。iOS 是练出来的,不做练习 = 没学 |
全书所有业务场景都基于这一个项目,它的功能刻意覆盖了企业 App 的典型技术面:
| 业务功能 | 对应知识点 |
|---|---|
| 记一笔账(分类、金额、备注) | 表单交互、键盘处理、数据模型 |
| 账单列表与月度统计 | 列表性能、图表绘制、日期处理 |
| 云端同步与登录 | 网络层封装、鉴权、Token 刷新 |
| 离线可用 | 持久化、本地与远端数据合并 |
| 汇率换算(多币种记账) | 并发请求、缓存、actor |
| 每月记账提醒 | 本地通知、权限 |
| 多人团队协作开发 | 架构、模块化、规范、CI |
| 上架 App Store | 签名、隐私合规、提审 |
如果以上六条你都能自信回答「能」,你就具备了企业级 iOS 开发者的入门水位,可以带着作品集去面试或承担真实项目了。
所属阶段:一、环境与语言基础 | 预计学习时长:3~4 天 | 前置章节:无
学完本章,你应该能够:
iOS 应用只能用 Apple 官方工具链构建,而官方工具链(Xcode)只能运行在 macOS 上。这不是「建议」,而是硬性要求:签名、打包、上架的完整链路都离不开 macOS。
| 方案 | 说明 | 适合谁 |
|---|---|---|
| Mac 真机(推荐) | MacBook / Mac mini / iMac 均可,8G 内存起步、16G 舒适 | 所有人 |
| 云 Mac(MacStadium、租用云主机等) | 远程桌面到 macOS 虚拟机 | 没有苹果硬件、预算有限的过渡方案 |
| Windows 上跑虚拟机(黑苹果) | 违反 Apple EULA,且 Xcode + 模拟器对显卡/CPU 要求高,体验差 | 仅试水,不建议长期使用 |
注意:本教程假设你在真机 Mac 上操作。云 Mac 方案在「真机调试」环节会遇到 USB 直连困难,需要额外手段(无线调试),初学阶段尽量用真机 Mac。
Xcode 是 iOS 开发的 IDE,包含编译器(Swift/Clang)、iOS SDK、模拟器、调试器(LLDB)、界面构建器等全套工具。
安装步骤:
# 查看 Xcode 版本
xcodebuild -version
# 若提示需要指定命令行工具路径,执行:
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
# 确认 Swift 编译器可用(应输出版本号)
swift --version
几个初学者必须知道的版本常识:
.xip 归档留档;新版 Xcode 解压后重命名为 Xcode 26.app 之类并存,用 xcode-select 切换:sudo xcode-select --switch /Applications/Xcode26.app
首次打开 Xcode,选择 Create New Project → iOS → App,先用默认选项创建一个项目(下一节详细解释每个选项),然后熟悉四大区域:
┌─────────────────────────────────────────────────────┐
│ ① Navigator(左侧,⌘0) │
│ 文件树 / 搜索 / 问题 / 断点 / 测试导航 │
├──────────────────────────────┬──────────────────────┤
│ ② Editor(中央) │ ③ Inspector(右侧,⌥⌘0)│
│ 代码 / Storyboard / UI 预览 │ 文件属性 / 快速帮助 │
├──────────────────────────────┴──────────────────────┤
│ ④ Debug Area(底部,⇧⌘Y) │
│ 控制台输出 / 变量监视 / LLDB 调试输入 │
└─────────────────────────────────────────────────────┘
创建项目时几个关键选项的含义(这是初学者第一个容易糊涂的地方):
| 选项 | 含义 | 填什么 |
|---|---|---|
| Product Name | App 名称,也是默认 Target 名 | LedgerBook |
| Organization Identifier | 反向域名,用于拼接 Bundle ID | 个人练手填 com.yourname |
| Bundle Identifier | App 在 Apple 体系内的全球唯一 ID = Org ID + Product Name | com.yourname.LedgerBook(自动生成) |
| Interface | 初始 UI 框架 | 本教程选 SwiftUI(第 4 章再学 UIKit,届时手动新建) |
| Language | Swift(唯一合理选择,Objective-C 已是遗留技术) | Swift |
| Storage | 是否使用 SwiftData 做持久化 | 选 None,第 8 章再引入 |
| Include Tests | 是否生成测试 Target | 勾选,第 14 章会用到 |
Project 与 Target 的关系:一个 Project(工程)可以包含多个 Target(构建目标)——比如同一个代码库打出「轻记账免费版」和「轻记账 Pro 版」两个 App,或者主 App + Widget + 测试 Target。每个 Target 有自己的 Bundle ID、图标、编译配置。
⌘R 编译并运行,默认跑在模拟器上。通过 ⌘⇧2(Devices and Simulators)或工具栏左侧的 scheme 选择器切换设备。xcrun simctl openurl booted "ledgerbook://bill/42")。免费 Apple ID 即可真机调试,步骤:
+ 登录你的 Apple ID。Automatically manage signing,Team 选你的 Personal Team,Bundle ID 保持唯一。⌘R 运行。首次会报错提示不受信任:到手机 设置 → 通用 → VPN与设备管理 里信任该开发者证书。⌘R,App 即安装并启动。免费账号的硬性限制(务必记住):
付费 Apple Developer Program(每年 99 美元 / 688 元)解锁:真机证书一年有效、TestFlight、App Store 上架、推送等全部能力。只有准备上架或企业分发时才需要付费,前三个阶段的学习免费账号完全够用。
企业内部分发还有一种 Apple Developer Enterprise Program(年费 299 美元),只能分发给公司员工、不能上 App Store,且申请门槛高,初学阶段不用纠结。
iOS 的安全模型核心是代码签名:任何能在 iPhone 上运行的二进制都必须被一个 Apple 认可的证书签名。涉及三件套:
初学阶段用 Automatically manage signing 让 Xcode 全自动处理即可;第 15、17 章会深入手动管理证书与团队协作场景(fastlane match)。
# Homebrew:macOS 包管理器,后面装各种工具都会用到
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Git(Xcode 自带,确认可用)
git --version
# 常用命令行工具(第 15、16 章会详细用)
brew install swiftlint fastlane
另外建议现在就配置好 Git 身份并在 GitHub/GitLab 创建远程仓库,把「轻记账」项目推上去——版本控制是团队协作的地基(第 16 章展开规范):
cd ~/Projects/LedgerBook
git init
git add . && git commit -m "chore: 初始化 LedgerBook 项目"
git remote add origin git@github.com:yourname/ledger-book.git
git push -u origin main
| 快捷键 | 功能 |
|---|---|
⌘R |
编译运行 |
⌘. |
停止运行 |
⌘B |
只编译 |
⌃⌘J / ⌃⌘← ⌃⌘→ |
跳到定义 / 前后跳转 |
⌘⇧O |
Open Quickly:按文件名/符号快速打开(最常用) |
⌃⌘↑ ⌃⌘↓ |
切换同名 .h/.m 或 related 文件 |
⌘\ |
当前行加/除断点 |
⌘6 |
断点导航;⌘5 |
⇧⌘Y |
显示/隐藏调试区 |
⌥⌘↩ |
打开 Canvas 预览(SwiftUI) |
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 以为 Windows + 虚拟机可以长期开发 | 工具链不稳定、无法真机调试、浪费时间 | 尽早安排真机 Mac 或正规云 Mac |
| 把个人 Apple ID 密码借给别人、或用别人的账号签名 | 证书互相覆盖,真机 App 互相被顶掉;存在安全风险 | 每人一个开发者账号;企业证书管理见第 15 章 |
| 用 Beta Xcode 开发正式项目 | API 变动导致代码无法编译,无法提审 | 正式项目用正式版 Xcode,Beta 环境单独装 |
| 随手改 Bundle ID | 签名失效、推送/内购配置丢失、TestFlight 数据错乱 | Bundle ID 定下来就不要改;多环境用不同后缀(如 .dev) |
| 只在模拟器上验证功能 | 性能、传感器、推送等真机问题上线后才暴露 | 每个迭代至少在真机上完整回归一次 |
| 项目文件随手放在桌面/iCloud 同步目录 | 编译路径含空格或中文导致诡异报错;iCloud 同步与 DerivedData 冲突 | 放在如 ~/Projects/LedgerBook 这类简单英文路径下 |
| Xcode 升级前不备份旧版 | 老项目无法编译、SDK 行为变化无对照 | 保留 .xip,多版本并存 |
.gitignore。iOS 常用 gitignore.io 生成 Xcode 模板,排除 DerivedData/、*.xcuserstate 等本地产物。LedgerBook/(源码)、Tests/、Tools/、docs/ 分区,后面章节的架构会在此基础上演进。按附录 B 的里程碑 M1,本章要完成的实际工作:
LedgerBook,Organization Identifier com.ledgerbook.demo(换成你自己的),Interface 选 SwiftUI,勾选 Include Tests。Signing & Capabilities 里真机签名通过,⌘R 在模拟器和真机上各运行一次。.gitignore,推送到远程仓库。docs/ 下新建 SETUP.md,记录:Xcode 版本、模拟器机型、签名 Team——这就是企业项目里「新人环境文档」的最小雏形。完成判据:手机上能看到默认的「Hello, world!」页面,且 git log 里有首次提交。
PlaygroundApp,Bundle ID 改成与 LedgerBook 不同的值,同时安装两个 App 到手机,观察它们如何共存。⌘R,观察签名报错信息长什么样,然后恢复。(体会:真遇到签名报错时不慌)⌘⇧O 分别搜索 AppDelegate、SceneDelegate、LedgerBookApp,看看 SwiftUI 项目里它们是否还存在(提示:默认没有,第 4 章讲 UIKit 时会解释)。所属阶段:一、环境与语言基础 | 预计学习时长:5~7 天 | 前置章节:第 1 章
学完本章,你应该能够:
if/switch/for 等控制流组织程序逻辑;本章代码均可在 Xcode 的 Playground(File → New → Playground)或命令行
swift file.swift中直接运行,建议边读边敲。
Swift 是类型安全的静态语言:每个值都有确定类型,编译期就检查错误。
let appName = "LedgerBook" // let = 常量,赋值后不可变(优先使用)
var balance = 128.50 // var = 变量,可以重新赋值
var version: Int = 1 // 显式声明类型
var remark = "早餐" // 类型推断为 String
balance += 20 // OK
// balance = 300.0 -> 编译错误?不,balance 是 var,OK;但 appName 改不了
规则一:能 let 就不用 var。 Xcode 会对从不修改的 var 给出警告,企业项目的 SwiftLint(第 16 章)也会强制这一点——不可变值让代码更可推理。
类型推断:balance = 128.50 被推断为 Double(不是 Float)。Swift 中浮点默认就是 Double。
基础类型一览:
let count: Int = 42 // 整数(64位平台即 Int64)
let price: Double = 9.9 // 浮点数
let isOpen: Bool = true // 布尔
let title: String = "咖啡 ☕️" // 字符串(Unicode 原生支持 emoji)
let initial: Character = "L"
数字类型之间不会隐式转换,必须显式:
let x: Int = 5
let y: Double = 2.0
// let z = x + y // ❌ 编译错误
let z = Double(x) + y // ✅ 7.0
let category = "餐饮"
let amount = 25.0
let summary = "\(category) 支出 \(amount) 元" // 字符串插值:餐饮 支出 25.0 元
let multiLine = """
多行字符串
保留换行与缩进
"""
summary.count // 字符数(按字素簇,emoji 算 1 个)
summary.hasPrefix("餐饮")
summary.replacingOccurrences(of: "支出", with: "花费")
⚠️ Swift 字符串不能用整数下标取字符(s[0] 不存在),因为 Unicode 字素的内存宽度不定。要用 String.Index:
let s = "LedgerBook"
let first = s[s.startIndex] // "L"
let third = s[s.index(s.startIndex, offsetBy: 2)] // "g"
// 常用替代:转成数组
let chars = Array(s) // ["L", "e", "d", ...]
// 数组:有序,元素同类型
var categories = ["餐饮", "交通", "购物"]
categories.append("娱乐")
categories[0] // "餐饮"
categories.first // "餐饮",注意:返回可选值
categories.firstIndex(of: "交通") // 1,同样可选
categories.filter { $0.contains("餐") }
categories.map { "分类:\($0)" }
// 字典:键值对
var monthlyTotal: [String: Double] = ["餐饮": 512.5, "交通": 88.0]
monthlyTotal["餐饮"] // Optional(512.5) —— 键可能不存在!
monthlyTotal["医疗", default: 0] // 0.0
monthlyTotal["购物"] = 199.9
for (name, total) in monthlyTotal {
print("\(name): \(total)")
}
// 集合:无序、唯一
var usedTags: Set<String> = ["必要", "日常"]
usedTags.insert("日常") // 重复插入无效果
usedTags.intersection(["日常", "旅行"]) // 交集 {"日常"}
三个最重要的特性(初学者高频踩坑点):
categories[99] 运行时崩溃,不像有些语言返回 nil。访问前用 first/last/count 判断,或用可选绑定。monthlyTotal["餐饮"] 的类型是 Double?,因为键可能不存在。// if / else if / else —— 条件必须是 Bool,没有「非零即真」
let amount = 75.0
if amount > 100 {
print("大额支出")
} else if amount > 50 {
print("中等支出")
} else {
print("小额支出")
}
// while / repeat-while
var i = 0
while i < 3 { i += 1 }
// for-in + 区间
for day in 1...31 { } // 闭区间 1~31
for day in 1..<31 { } // 半开区间 1~30
for category in categories where category.contains("餐") { } // 过滤
// switch —— 必须穷尽所有情况,不用写 break,默认不穿透
let level: Int = 4
switch level {
case 0:
print("空")
case 1...3:
print("轻度记账用户")
case 4...9 where amount < 100: // where 附加条件
print("中度,金额不大")
default:
print("重度用户")
}
// switch 也可以匹配元组
let point = (x: 0, y: 5)
switch point {
case (0, 0): print("原点")
case (0, _): print("Y 轴") // _ 忽略该值
case (_, 0): print("X 轴")
default: print("普通点")
}
// 基本定义:func 函数名(参数名: 类型) -> 返回类型
func addBill(category: String, amount: Double) -> Double {
return amount
}
// 参数标签(外部名)与参数名(内部名)分离
func transfer(from source: String, to target: String, amount: Double) -> Bool {
return amount > 0
}
transfer(from: "招行卡", to: "微信钱包", amount: 100) // 调用点可读性极高
// 省略标签:调用时不写参数名
func sum(_ a: Double, _ b: Double) -> Double { a + b }
sum(1, 2)
// 默认参数
func notify(title: String, sound: String = "default") { }
// 多返回值用元组
func stat(bills: [Double]) -> (count: Int, total: Double) {
return (bills.count, bills.reduce(0, +))
}
let (cnt, total) = stat(bills: [12.5, 30, 8]) // 元组解构
Swift 函数是一等公民:可以作为参数和返回值传递(这就是后面闭包的基础)。
企业 Swift 代码中,结构体是最常用的类型,不是类。先掌握用法,值语义的深入讨论见 2.8。
struct Bill {
let id: String
var category: String
var amount: Double
var note: String = "" // 默认值
// 计算属性:每次访问都求值,不存储
var isLarge: Bool { amount >= 100 }
// 方法:struct 的方法若修改自身属性,必须标记 mutating
mutating func discount(percent: Double) {
amount *= (1 - percent / 100)
}
}
var bill = Bill(id: "b001", category: "餐饮", amount: 35.0)
bill.isLarge // false
bill.discount(percent: 20)
bill.amount // 28.0
// 自动生成的成员逐一初始化器(Memberwise Initializer)
let bill2 = Bill(id: "b002", category: "交通", amount: 5, note: "地铁")
结构体免费获得的能力:成员初始化器、== 可通过遵循 Equatable 自动合成(Swift 4.1+)、let 实例完全不可变。
Swift 枚举可以携带关联值和方法,是建模「有限可能状态」的利器。
// 原始值枚举
enum Category: String, CaseIterable {
case food = "餐饮"
case transport = "交通"
case shopping = "购物"
case other = "其他"
}
Category.allCases.count // 4(CaseIterable 自动生成全部 case)
Category(rawValue: "餐饮") // Optional(.food) —— 原始值可能匹配不上,所以返回可选
// 关联值枚举:每个 case 可以携带不同形状的数据
enum SyncState {
case idle
case syncing(progress: Double)
case failed(reason: String)
case done(at: Date)
}
let state = SyncState.syncing(progress: 0.4)
// 用 switch 解构关联值 —— 这是 Swift 处理「状态」的惯用法
switch state {
case .idle:
print("未同步")
case .syncing(let progress):
print("同步中 \(Int(progress * 100))%")
case .failed(let reason):
print("失败:\(reason)")
case .done:
print("完成") // 不需要关联值时可以不解构
}
记住一个建模直觉:状态是有限的、各态携带的数据不同 → 用枚举;数据是开放的、字段固定 → 用结构体。 例如「网络请求结果」用 enum Result<Success, Failure>(成功带数据/失败带错误),而不是用「一个 Int 状态码 + 一堆可能为 nil 的字段」。
struct PointS { var x = 0 } // 值类型
class PointC { var x = 0 } // 引用类型
var a = PointS()
var b = a // 拷贝(Copy-on-Write,实际修改时才真正复制)
b.x = 10
a.x // 0 —— a 不受影响
var c = PointC()
var d = c // 拷贝的是「引用」,指向同一个对象
d.x = 10
c.x // 10 —— c 被影响了!
| struct / enum(值类型) | class(引用类型) | |
|---|---|---|
| 赋值 | 拷贝内容 | 拷贝引用(共享对象) |
| 继承 | ❌(用协议实现多态) | ✅ |
| 引用计数开销 | 无 | 有(ARC,第 10 章) |
| 线程安全 | 天然更安全(无共享) | 需要防护(第 9 章) |
| 典型用途 | 数据模型、配置、坐标 | ViewModel、控制器、需要共享可变状态的单例 |
Swift 官方 API 设计导向是「优先值类型」:String、Array、Dictionary、URL、Date 全是 struct。初学者只需先记住结论:建模数据用 struct,建模「需要共享身份的东西」(屏幕控制器、缓存管理器)才用 class。
| 误区 | 后果 | 正确做法 |
|---|---|---|
滥用强制解包和强制类型转换(!、as!) |
运行时崩溃 | 本章先熟悉现象,解包的正确姿势见第 3 章;原则是「能不用就不用」 |
以为 arr[i] 越界会返回 nil |
直接崩溃 | 用 arr.first、arr.indices.contains(i) 或先判 count |
| 忽略字典下标返回可选值,直接参与运算 | 编译报错或解包崩溃 | dict[k, default: 0] 或可选绑定后再运算 |
用 Double 存金额并在多处累加 |
浮点误差累积(0.1 + 0.2 != 0.3) | 金额用 Int 存最小单位(分)或 Decimal;展示时再格式化 |
以为 let 数组里的元素也可变 |
以为 let bills: [Bill] 里的 Bill 能改 |
let 集合完全不可变(连元素也不能改);需要可变元素时整个数组用 var |
| 把所有东西都写成 class(从其他 OO 语言带来的习惯) | 失去值语义的安全性,引入引用循环风险 | 默认 struct,有明确理由才 class |
字符串用 s[0]、s[i] 取字符 |
编译错误 | 用 String.Index、first/last 或转数组 |
switch 忘写 default 或以为会穿透 |
编译错误 / 行为不符合预期 | Swift switch 必须穷尽;每个 case 独立执行 |
let 优先:先用 let 写,编译器逼你改 var 时再改。Bill.amount 本教程为教学简化用 Double,真实企业项目用 Int(分)或 Decimal。这个区别请在项目练习里至少实现过一次 Int 分版本。BillCategory),方法/属性小驼峰;方法名读起来像句子(transfer(from:to:amount:))。if type == "1",定义 enum BillType: Int { case expense = 1, income }。enum 表达的状态就不要用 String + 注释约定。把本章知识直接落进贯穿项目。新建 Models/Bill.swift:
import Foundation
/// 账单分类(枚举消灭魔法字符串)
enum BillCategory: String, CaseIterable, Codable {
case food = "餐饮"
case transport = "交通"
case shopping = "购物"
case entertainment = "娱乐"
case medical = "医疗"
case other = "其他"
}
/// 账单方向
enum BillDirection: String, Codable {
case expense // 支出
case income // 收入
}
/// 一条账单记录(struct:纯数据,值语义)
struct Bill: Identifiable, Equatable {
let id: String // 唯一标识(UUID 字符串)
var title: String // 摘要,如「午饭」
var amount: Double // 教学简化;真实项目用 Int(分)
var direction: BillDirection
var category: BillCategory
var date: Date
var note: String
init(id: String = UUID().uuidString,
title: String,
amount: Double,
direction: BillDirection = .expense,
category: BillCategory,
date: Date = Date(),
note: String = "") {
self.id = id
self.title = title
self.amount = amount
self.direction = direction
self.category = category
self.date = date
self.note = note
}
/// 大额消费:用于列表标红提醒(计算属性)
var isLargeExpense: Bool {
direction == .expense && amount >= 200
}
}
/// 金额统计(函数 + 元组多返回值)
func summarize(_ bills: [Bill]) -> (expense: Double, income: Double, balance: Double) {
let expense = bills.filter { $0.direction == .expense }.map(\.amount).reduce(0, +)
let income = bills.filter { $0.direction == .income }.map(\.amount).reduce(0, +)
return (expense, income, income - expense)
}
// 使用
let bills = [
Bill(title: "午饭", amount: 28, category: .food),
Bill(title: "地铁", amount: 5, category: .transport),
Bill(title: "工资", amount: 12000, direction: .income, category: .other),
Bill(title: "球鞋", amount: 899, category: .shopping)
]
let s = summarize(bills)
print("支出 \(s.expense),结余 \(s.balance)") // 支出 932.0,结余 11068.0
print(bills.last!.isLargeExpense) // true(此处 ! 是因为字面量数组保证非空,正式代码应避免)
这段代码已经覆盖:枚举 + 关联语义建模、struct 数据模型、计算属性、默认参数、filter/map/reduce 函数式处理。后续章节会持续扩展它。
Bill 增加一个计算属性 monthKey: String,返回 "2026-09" 格式(提示:DateFormatter,dateFormat: "yyyy-MM")。func topCategories(_ bills: [Bill], top n: Int) -> [(category: BillCategory, total: Double)],返回支出金额最高的 n 个分类。Bill 和 summarize,并写一个 formatFen(_ fen: Int) -> String 显示为 ¥28.00。思考:改动波及了哪些地方?(体会封装的重要性)enum MonthFilter { case all, thisMonth, custom(start: Date, end: Date) },并写一个函数按它过滤账单——练习关联值与 switch 解构。0.1 + 0.2 == 0.3 的结果,再用 Decimal 或分单位重做,体会金额精度问题。first/last 返回可选值、数组越界崩溃——这三个行为必须形成肌肉记忆。Double/String。所属阶段:一、环境与语言基础 | 预计学习时长:5~7 天 | 前置章节:第 2 章
学完本章,你应该能够:
if let / guard let / 可选链 / 空合运算符的使用时机;throws/try/Result 做规范的错误处理;Codable 完成 JSON 与模型的互转——这是第 7 章网络层的直接基础。可选类型本质是一个枚举:
enum Optional<Wrapped> {
case none // nil
case some(Wrapped) // 有值
}
第 2 章已见过 ? 声明可选。本章重点是安全解包的四种姿势:
let input: String? = "128.5"
// ① if let:有值才进入分支(Swift 5.7+ 可直接写 if let input)
if let text = input, let amount = Double(text) {
print("金额:\(amount)")
}
// ② guard let:提前退出,让主逻辑不缩进 —— 方法内部首选
func parseAmount(_ input: String?) -> Double {
guard let text = input, let amount = Double(text), amount >= 0 else {
return 0 // guard 的 else 必须退出当前作用域
}
// 主逻辑平铺在此,无嵌套
return amount
}
// ③ 可选链:任一环节为 nil 则整体为 nil,不崩溃
struct User { var profile: Profile? }
struct Profile { var nickname: String }
let user: User? = nil
user?.profile?.nickname // nil(类型 String?),安全
// ④ 空合运算符 ??:提供默认值
let nickname = user?.profile?.nickname ?? "未登录用户"
guard let 与 if let 的选择经验:在函数开头校验参数/前置条件用 guard,在流程中间分支处理用 if let。guard 的核心价值是「失败路径短平快,成功路径零缩进」。
关于 ! 强制解包和隐式解包可选 String!:
IUO)主要出现在与 Interface Builder 关联的属性(@IBOutlet)等框架场景,自己的代码里不要声明。闭包是「可以捕获上下文变量的匿名函数」,Swift 中无处不在(map、动画回调、网络回调……)。
// 完整形态:{(参数) -> 返回类型 in 语句}
let add: (Int, Int) -> Int = { (a: Int, b: Int) -> Int in
return a + b
}
// 类型可推断 -> 逐步简化
let add2: (Int, Int) -> Int = { a, b in a + b } // 单表达式可省略 return
let add3 = { $0 + $1 } // $0/$1 位置参数简写
// 高阶函数实战
let amounts = [12.5, 88.0, 200.0, 6.4]
amounts.map { $0 * 2 } // 变换
amounts.filter { $0 > 10 } // 过滤
amounts.reduce(0, +) // 聚合
amounts.sorted { $0 > $1 } // 降序
逃逸闭包(@escaping):当闭包的生命周期超出函数调用(被存储起来、异步回调)时,必须标记 @escaping;非逃逸闭包在函数返回前执行,编译器可优化且无需考虑捕获引用循环。
// 非逃逸:函数返回前执行完毕
func compute(_ op: () -> Int) -> Int { op() }
// 逃逸:函数返回后才会被调用(异步回调的常态)—— 第 7 章网络回调全是这种
typealias Completion = (Result<Double, Error>) -> Void
func fetchRate(completion: @escaping Completion) {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
completion(.success(7.24)) // 存活到 1 秒后,逃逸
}
}
捕获语义:闭包捕获的是变量的引用(var 可被闭包修改)。这带来两个后果——var 捕获可在并发中引发数据竞争(第 9 章),以及捕获 self 可能造成循环引用(第 10 章):
class Ledger {
var total = 0.0
func load() {
// 逃逸闭包捕获了 self —— 第 10 章详析何时需要 [weak self]
fetchRate { result in
self.total = 100
}
}
}
现在只需记住:逃逸闭包里引用了 self,就要想一下「谁持有谁」。
协议定义「能力契约」,是 Swift 组织代码的第一工具。
// 协议:声明能力
protocol BillStorage {
func save(_ bill: Bill) throws
func allBills() -> [Bill]
mutating func delete(id: String)
}
// 遵循协议 + 实现默认行为(协议扩展 = 「鸭子类型」的编译期版本)
extension BillStorage {
func description() -> String {
"存储实现:\(String(describing: type(of: self))),共 \(allBills().count) 条"
}
}
// 任意类型遵循协议
struct InMemoryStorage: BillStorage {
private(set) var bills: [Bill] = []
func save(_ bill: Bill) throws { /* 内存实现 */ }
func allBills() -> [Bill] { bills }
mutating func delete(id: String) { }
}
struct FakeStorage: BillStorage { // 专门给测试用的假实现(第 14 章会大量用)
func save(_ bill: Bill) throws { }
func allBills() -> [Bill] { [] }
mutating func delete(id: String) { }
}
// 面向协议的函数签名:不关心具体实现,只要求能力
func printSummary(using storage: some BillStorage) {
print(storage.description())
}
要点:
some P(不透明类型):调用点固定为某一种实现,性能好、写法简单,SwiftUI 的 some View 同款。any P(存在类型):运行时可换不同实现,有动态派发开销;Swift 5.7+ 显式要求写 any。struct/class/enum 都可遵循协议;协议里可以声明 mutating(值类型需 mutating 实现的成员)。**扩展(extension)**可以为任何已有类型(包括系统类型)添加能力:
extension Double {
/// 转成人民币展示字符串:"¥28.50"
var rmb: String {
String(format: "¥%.2f", self)
}
}
28.5.rmb // "¥28.50"
extension BillCategory {
/// 分类对应的 SF Symbol 图标名(第 5、6 章界面会用)
var iconName: String {
switch self {
case .food: "fork.knife"
case .transport: "tram.fill"
case .shopping: "bag.fill"
case .entertainment: "gamecontroller.fill"
case .medical: "cross.case.fill"
case .other: "square.grid.2x2"
}
}
}
何时用继承,何时用协议?Swift 中几乎总是协议。class 继承只在确有「是一个」的层次关系且基类持有状态逻辑时使用(如自定义
UIViewController)。
// 泛型函数:同一逻辑适配所有类型
func firstNonEmpty<T: Collection>(_ a: T, _ b: T) -> T? {
a.isEmpty ? b : a
}
// 泛型 + 协议约束:只接受可比较类型
func maxBill<T: Comparable>(_ a: T, _ b: T) -> T { a > b ? a : b }
// 泛型类型 + where 子句
struct PagedResponse<Item: Decodable> {
let items: [Item]
let page: Int
let totalPages: Int
func merged(with other: PagedResponse<Item>) -> [Item] {
page <= other.page ? items + other.items : other.items + items
}
}
// 协议也可以带关联类型(PAT),约束更灵活但复杂度更高,初学少用
protocol Storable {
associatedtype Item
func save(_ item: Item) throws
}
初学阶段掌握「函数/结构体泛型 + 协议约束」即可;some/any 与 PAT 进阶用法遇到再学。泛型的最大收益:同一份代码,编译期保证类型正确——比如上面 PagedResponse<Item> 让「分页列表响应」的逻辑对账单、好友、笔记全部复用。
// ① 定义错误:通常用枚举遵循 Error 协议
enum LedgerError: Error, LocalizedError {
case invalidAmount(Double)
case categoryNotFound(String)
case storageFull
var errorDescription: String? { // LocalizedError 提供用户可读信息
switch self {
case .invalidAmount(let a): "金额不合法:\(a)"
case .categoryNotFound(let c): "分类不存在:\(c)"
case .storageFull: "本地存储已满"
}
}
}
// ② 抛出:throw
func addBill(amount: Double, category: BillCategory) throws -> Bill {
guard amount > 0, amount < 10_000_000 else {
throw LedgerError.invalidAmount(amount)
}
return Bill(title: "自动记账", amount: amount, category: category)
}
// ③ 处理:try 三兄弟
do {
let bill = try addBill(amount: -5, category: .food)
} catch LedgerError.invalidAmount(let amount) { // 精确捕获
print("金额错误:\(amount)")
} catch { // 兜底
print("其他错误:\(error.localizedDescription)")
}
// try? :出错转 nil(不在乎具体错误时)
let bill2 = try? addBill(amount: -5, category: .food) // nil
// try! :保证不抛(一旦抛出直接崩溃)—— 仅用于逻辑上绝对不抛的场景,慎用
Result<Success, Failure> 是错误的「值化」封装,主要用于异步回调场景(throws 只能沿同步调用栈传递):
func fetchRate(completion: (Result<Double, LedgerError>) -> Void) {
completion(.success(7.24))
// 失败时:completion(.failure(.storageFull))
}
fetchRate { result in
switch result {
case .success(let rate): print(rate)
case .failure(let error): print(error)
}
}
// 注:async/await 时代,网络回调可改用 throws(第 9 章),Result 主要留在回调式 API 中
把「包装逻辑」从字段声明处抽离,SwiftUI 状态管理(@State 等)全部基于它,先认识语法:
@propertyWrapper
struct Clamped { // 把数值限制在区间内
var value: Double
let range: ClosedRange<Double>
init(wrappedValue: Double, _ range: ClosedRange<Double>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Double {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
}
struct Budget {
@Clamped(0...100_000) var monthly: Double = 3000 // 声明时即应用约束
}
var b = Budget(monthly: 999_999)
b.monthly // 100000,写入被自动钳制
public // 对模块外可见(模块化开发,第 13 章的关键字)
internal // 默认:模块内可见
fileprivate / private // 文件内 / 作用域内
默认 internal 就够用;养成 private 收敛实现细节的习惯,依赖越少越可测。public 等到做 Swift Package(第 13 章)时再系统使用。
| 误区 | 后果 | 正确做法 |
|---|---|---|
到处用 ! 强制解包「我确定不为 nil」 |
需求一变就崩溃 | 用 guard let/??;用非可选类型把「不可能」编码进类型系统 |
guard let 之后还继续使用原可选值 |
逻辑混乱 | guard 解包后的常量(如 text)才是有效值,原可选值此时已被证明大概率 nil |
在非逃逸闭包上写 @escaping,或反过来 |
编译错误 / 悬垂引用风险 | 判断标准:闭包是否活得比函数久(异步回调必 escaping) |
| 以为闭包捕获的是值的拷贝 | 并发下数据竞争、与预期不符 | 闭包按引用捕获 var;跨线程共享必须加防护(第 9 章) |
| 协议当「万能基类」堆方法 | 协议膨胀、实现负担 | 协议保持小而聚焦(单一能力);默认实现放协议扩展 |
try? 吞掉所有错误细节 |
出了问题无从排查 | 需要排查的路径用 do-catch 记录错误;try? 仅用于「失败就当没发生」 |
滥用 try! |
上线即崩溃点 | 只在能用测试/逻辑证明不抛的场景使用,并写注释说明理由 |
| 泛型约束写得太宽或嵌套过深 | 可读性差、编译时间上升 | 先写具体类型,出现重复再抽象;约束一层足够 |
把 public 当 internal 用 |
模块边界形同虚设 | App 内默认 internal;只有跨模块 API 才 public(第 13 章) |
enum XxxError: Error, LocalizedError,不要向上抛裸 NSError/字符串错误;用户可见文案走 LocalizedError。Bill+Display.swift),避免单个巨型文件。Equatable、Hashable、Codable、CaseIterable、Identifiable 等协议成员变量字段结构一致即可自动合成,不要手写样板代码。把本章能力落进「轻记账」,重点完成存储抽象——这是全书第一次体现「面向协议 + 依赖注入」的架构雏形:
// Storage/BillStore.swift —— 业务代码只认协议
protocol BillStore {
func save(_ bill: Bill) throws
func delete(id: String) throws
func bills(inMonth month: String) throws -> [Bill] // month: "2026-09"
}
// 存储错误模型(统一错误实践)
enum BillStoreError: Error, LocalizedError {
case billNotFound(id: String)
case duplicate(id: String)
case underlying(reason: String)
var errorDescription: String? {
switch self {
case .billNotFound(let id): "账单不存在:\(id)"
case .duplicate(let id): "账单重复:\(id)"
case .underlying(let reason): reason
}
}
}
// ViewModel 面向协议编程(第 12 章会正式引入 ViewModel 概念,这里先看依赖注入)
final class LedgerService {
private let store: BillStore
/// 构造器注入:调用方决定用哪个实现(内存?SwiftData?测试假实现?)
init(store: BillStore) {
self.store = store
}
func addExpense(title: String, amount: Double, category: BillCategory) throws {
guard amount > 0 else { throw LedgerError.invalidAmount(amount) }
let bill = Bill(title: title, amount: amount, category: category)
try store.save(bill)
}
func monthSummary(month: String) throws -> (expense: Double, count: Int) {
let bills = try store.bills(inMonth: month)
let expenses = bills.filter { $0.direction == .expense }
return (expenses.map(\.amount).reduce(0, +), expenses.count)
}
}
// 临时内存实现(第 8 章换成 SwiftData,第 14 章换成测试假实现——接口不变)
struct InMemoryBillStore: BillStore {
private(set) var bills: [Bill] = []
mutating func save(_ bill: Bill) throws { bills.append(bill) }
mutating func delete(id: String) throws {
guard let idx = bills.firstIndex(where: { $0.id == id }) else {
throw BillStoreError.billNotFound(id: id)
}
bills.remove(at: idx)
}
func bills(inMonth month: String) throws -> [Bill] {
bills.filter { $0.monthKey == month } // monthKey 来自第 2 章练习
}
}
// 组装
let service = LedgerService(store: InMemoryBillStore())
try? service.addExpense(title: "午饭", amount: 28, category: .food)
print(try? service.monthSummary(month: "2026-09") as Any)
这段代码的价值在于:LedgerService 对存储实现零感知。第 8 章接 SwiftData、第 14 章接测试 Mock,只需换注入对象,业务代码一行不改——这就是企业代码「面向接口」的最小完整样例。
LedgerService 增加 func deleteExpense(id: String) throws,并处理 billNotFound 错误;故意删除不存在的 id,验证错误文案。func groupBy<T, K: Hashable>(_ items: [T], by key: (T) -> K) -> [K: [T]],用它把账单按分类分组、按月份分组(一次实现,两处使用)。RateProviding { func rate(of currency: String) async throws -> Double }(先照抄 async 语法,第 9 章细讲),写一个写死的实现和一个随机失败的实现;给 LedgerService 注入两种实现分别观察。@Truncated(max: Int):字符串写入自动截断(模拟备注字段长度限制)。Bill 的 Codable 合成打开(struct Bill: Codable),用 JSONEncoder 编码再 JSONDecoder 解码,打印两个 JSON 观察字段名——为第 7 章网络层做准备。guard let 提前退出 + ?? 默认值 + 可选链,让 ! 几乎没有生存空间。self,是并发(第 9 章)与内存(第 10 章)问题的源头,现在埋下意识。throws;异步回调用 Result;try?/try! 是特殊工具不是默认选项。Codable 与协议抽象将直接支撑第 7、8 章的网络与持久化。所属阶段:二、界面构建与交互 | 预计学习时长:7~10 天 | 前置章节:第 2、3 章
学完本章,你应该能够:
frame vs bounds);UITableView/UICollectionView 高效渲染长列表,理解 Cell 复用机制;本教程的策略:第 4 章建立 UIKit 心智模型(企业生存必备),第 5 章以 SwiftUI 为主线开发「轻记账」,两者互操作都掌握。
UIKit App 的骨架:
main.swift / @main
└─ AppDelegate —— App 级事件:启动、推送注册、后台切换
└─ SceneDelegate —— 每个「场景(窗口)」的生命周期(iPad 多窗口/分屏)
└─ UIWindow
└─ RootViewController
App 前后台状态机(面试高频,也是做后台任务的基础):
Not Running → Inactive → Active
↕ ↕
Background → Suspended(随时可能被系统杀掉)
关键回调:application(_:didFinishLaunchingWithOptions:)(启动入口)、sceneWillResignActive(即将失焦)、sceneDidEnterBackground(进后台,此刻要保存数据)、sceneWillEnterForeground。
UIViewController 生命周期(必须按序背下来):
loadView → viewDidLoad(只执行一次,做一次性配置)
→ viewWillAppear → viewDidAppear(进屏)
→ viewWillDisappear → viewDidDisappear(离屏)
→ dealloc(析构)
经验法则:viewDidLoad 里做「一次性、轻量」的事(注入依赖、绑定数据源);耗时工作绝不放在生命周期回调里做(启动/进屏卡顿的直接原因,第 11 章展开)。
UIView 是一切可见元素的基类;UIControl(按钮、开关)在 UIView 之上增加了事件响应;UIResponder 链处理触摸事件。addSubview / removeFromSuperview;UI 操作必须在主线程(第 9 章展开)。view.frame // 在「父视图坐标系」中的位置与大小 —— 用于布局
view.bounds // 在「自身坐标系」中的位置与大小 —— 用于内部绘制/内容布局
经典陷阱:旋转或 transform 后 frame 的值会变化/不可靠,此时用 bounds 与 center。滚动视图的 contentSize、contentOffset 也是 bounds 体系。
Auto Layout 用约束(constraints)描述关系,而非固定坐标,从而适配各种屏幕尺寸、动态字体、多语言。
现代做法是用 Anchor API(比 NSLayoutConstraint(item:...) 可读性高一个量级):
func setupUI() {
let titleLabel = UILabel()
titleLabel.text = "本月支出"
titleLabel.font = .preferredFont(forTextStyle: .title2)
titleLabel.translatesAutoresizingMaskIntoConstraints = false // 关键!否则与约束冲突
view.addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
])
}
四个核心概念:
| 概念 | 含义 |
|---|---|
safeAreaLayoutGuide |
避开刘海/圆角/Home 条的安全区,布局一律相对它 |
| 优先级(priority 1~1000) | 同轴约束冲突时谁让步;required(1000) 不可打破 |
| Content Hugging | 内容「不被拉伸」的意愿(越高越不容易被撑大) |
| Compression Resistance | 内容「不被压缩」的意愿(越高越不容易被压小) |
典型用法:两个 label 抢宽度时,给不重要的那个设 hugging priority 较低,让它被拉伸。
工程实践:手写约束啰嗦,企业常用 SnapKit(第三方 DSL,老项目极常见):
import SnapKit
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.equalToSuperview().offset(20)
}
新代码也可以用 iOS 14+ 的 UIStackView 减少约束数量——能用 StackView 布局的不手写约束:
let stack = UIStackView(arrangedSubviews: [titleLabel, amountLabel])
stack.axis = .vertical
stack.spacing = 8
列表是 App 的一半。UIKit 列表的核心设计是 Cell 复用池:屏幕外的 cell 不会销毁,滚动进来时从池里取出来重新配置——否则上万行账单会让内存爆炸。
final class BillListViewController: UIViewController {
private var bills: [Bill] = []
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
override func viewDidLoad() {
super.viewDidLoad()
title = "轻记账"
view.backgroundColor = .systemGroupedBackground
tableView.dataSource = self
tableView.register(BillCell.self, forCellReuseIdentifier: "BillCell") // 注册复用
tableView.rowHeight = UITableView.automaticDimension // 动态行高
view.addSubview(tableView)
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
}
}
extension BillListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
bills.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// 复用是 cellForRowAt 的铁律,never create cells by hand
guard let cell = tableView.dequeueReusableCell(
withIdentifier: "BillCell", for: indexPath) as? BillCell else {
return UITableViewCell()
}
let bill = bills[indexPath.row]
cell.configure(with: bill)
return cell
}
}
final class BillCell: UITableViewCell {
private let titleLabel = UILabel()
private let amountLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
// 一次性配置放 init;prepareForReuse 里清理状态
let stack = UIStackView(arrangedSubviews: [titleLabel, amountLabel])
stack.axis = .horizontal
contentView.addSubview(stack)
stack.snp.makeConstraints {
$0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16))
}
amountLabel.textColor = .systemRed
amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal) // 金额不许被压缩
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func prepareForReuse() {
super.prepareForReuse()
// 清理可变状态:图片、选中态、加载指示……否则滚动时出现「串数据」
amountLabel.textColor = .systemRed
}
func configure(with bill: Bill) {
titleLabel.text = bill.title
amountLabel.text = bill.amount.rmb // 第 3 章的扩展
amountLabel.textColor = bill.direction == .income ? .systemGreen : .systemRed
}
}
现代替代:Diffable Data Source + Snapshot(iOS 13+),声明式地提交数据差异,自动计算增删动画,还天然减少「reload 全列表」的低效刷新:
private var dataSource: UITableViewDiffableDataSource<Section, Bill>!
dataSource = UITableViewDiffableDataSource<Section, Bill>(tableView: tableView) { tv, ip, bill in
let cell = tv.dequeueReusableCell(withIdentifier: "BillCell", for: ip) as! BillCell
cell.configure(with: bill)
return cell
}
func applyBills(_ bills: [Bill]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Bill>()
snapshot.appendSections([.main])
snapshot.appendItems(bills)
dataSource.apply(snapshot, animatingDifferences: true)
}
// TabBar:底部标签页(首页 / 统计 / 我的)
let tabBarController = UITabBarController()
tabBarController.viewControllers = [
UINavigationController(rootViewController: BillListViewController()),
UINavigationController(rootViewController: StatsViewController()),
UINavigationController(rootViewController: ProfileViewController()),
]
// 页面内导航:push / pop(NavigationController 管理返回栈)
navigationController?.pushViewController(DetailViewController(bill: bill), animated: true)
navigationController?.popViewController(animated: true)
// Modal 模态:sheet 弹出(添加账单这类「完成即关」的流程)
let addVC = AddBillViewController()
let nav = UINavigationController(rootViewController: addVC)
if let sheet = nav.sheetPresentationController {
sheet.detents = [.medium()] // 半屏 sheet
}
present(nav, animated: true)
页面间传值的正规姿势:
DetailViewController(bill:)),简单直接;let alert = UIAlertController(title: "删除账单", message: "删除后不可恢复", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { _ in
self.confirmDelete()
})
present(alert, animated: true)
| 误区 | 后果 | 正确做法 |
|---|---|---|
忘记 translatesAutoresizingMaskIntoConstraints = false |
约束冲突满屏报错 | 手写约束的视图必设;用 SnapKit/StackView 则由库处理 |
| 约束冲突出现后无视控制台警告 | 不同机型上布局随机错乱 | 把 Unable to simultaneously satisfy constraints 当 error 修,至少断点在提示里给出的约束上 |
| 子线程刷新 UI(回调里直接改 label) | 偶发崩溃、渲染错乱 | 主线程更新(DispatchQueue.main.async / @MainActor,第 9 章) |
| 忘记 cell 复用导致的状态残留 | 滚动后图片/按钮状态串行 | prepareForReuse 重置一切可变状态 |
用 frame 定位又同时加约束 |
布局结果不可预测 | 一个视图要么 frame 布局,要么约束布局,不要混用 |
在 viewDidLoad 做重活(同步网络、大图解码) |
进页面卡顿 | 生命周期只做轻配置,重活异步做 |
| controller 之间用通知/单例传业务数据 | 数据流向失控,无法维护 | 构造注入(前进)、闭包/delegate(返回) |
delegate 属性声明成 strong |
循环引用(第 10 章重点) | delegate 一律 weak var |
| reload 整个列表来更新一条数据 | 滚动位置丢失、闪烁 | 局部 reload / Diffable Snapshot |
UIStackView,细节用 anchor;单元格高度交给自动布局(automaticDimension + estimatedRowHeight)。configure(with:),不掺业务判断——为第 12 章 MVVM 铺路。po view.value(forKey: "_autolayoutTrace")。.label、.systemBackground)与 preferredFont(forTextStyle:),不要硬编码黑白色和字号——这是 App Store 审核与体验的双重要求。对应「轻记账」里程碑 M3:一个支持分组、标红大额支出的账单列表(如上面 4.5 的代码),加上:
UITableViewCell 自定义 BillCell,收入绿色/支出红色,备注一行内省略;+ 按钮 present 添加页,添加完成后通过闭包把新账单传回并刷新 Snapshot;tableView.backgroundView)。BillCell 增加「删除」滑动操作(UISwipeActionsConfiguration),删除后用 Diffable Snapshot 局部刷新。UIScrollView?不行——正确做法是 tableHeaderView。实现它。(体会 UITableView 自滚动的特殊性)UICollectionViewCompositionalLayout 实现「顶部横滑分类条 + 下方账单流」的混合布局。所属阶段:二、界面构建与交互 | 预计学习时长:7~10 天 | 前置章节:第 3、4 章
学完本章,你应该能够:
@State、@Binding、@Observable、@Environment,搞清「数据放哪儿」;NavigationStack、sheet、TabView 组织页面导航;// 命令式(UIKit):你告诉系统「一步步怎么改」
amountLabel.text = "¥28.50" // 状态变化后,手动更新每个 UI
// 声明式(SwiftUI):你描述「UI 是状态的函数」,状态变了 UI 自动重算
Text("¥\(amount.rmb)") // amount 一变,这行自动重新求值
SwiftUI 的核心公式:UI = f(state)。你永远不「修改」界面,只修改状态,框架负责diff 与重绘。理解了这一点,@State、body 重算、Modifier 顺序等一切怪现象都会变得合理。
struct BillRow: View {
let title: String
let amount: Double
var body: some View { // body = 对当前状态的 UI 描述
HStack(spacing: 12) { // 水平排列
Image(systemName: "fork.knife") // SF Symbols 图标库
.foregroundStyle(.orange)
Text(title) // 文本
.font(.body)
Spacer() // 弹性空隙:把后面的推到右边
Text(amount.rmb)
.font(.body.monospacedDigit()) // 等宽数字:金额跳动不抖
.foregroundStyle(.red)
}
.padding(.vertical, 4) // 修饰符:作用于前面的视图
}
}
常用容器与语义:
| 容器 | 用途 |
|---|---|
VStack / HStack / ZStack |
纵向 / 横向 / 层叠 |
Spacer / Divider |
弹性空间 / 分隔线 |
ScrollView + LazyVStack |
滚动 + 懒加载长列表(Lazy 才会按需创建) |
List |
自带系统样式的列表(分隔线、分组、滑动操作) |
Form |
表单(设置页风格) |
Grid |
网格布局(iOS 16+) |
Modifier 顺序 = 布局语义(高频困惑点):
Text("Hello")
.padding() // 先加内边距
.background(.red) // 红底「包住」含 padding 的整体
Text("Hello")
.background(.red) // 红底只包住文字
.padding() // padding 在红底外面
SwiftUI 的状态工具有明确的分工,选错位置就会出现「界面不刷新」或「过度刷新」。
@State:视图私有、简单值的状态
struct AddBillSheet: View {
@State private var amountText = "" // 只属于这个视图的临时状态
@State private var showingError = false
var body: some View {
Form {
TextField("金额", text: $amountText) // $ 前缀:传「绑定」而非值
}
}
}
规则:@State 一律 private;放拥有它的最低层视图。
@Binding:子视图读写父视图的状态
struct AmountField: View {
@Binding var text: String // 我不拥有数据,只提供读写通道
var body: some View {
TextField("金额", text: $text)
.keyboardType(.decimalPad)
}
}
// 父视图调用:AmountField(text: $amountText)
@Observable + 类:跨视图共享的模型状态(iOS 17+,现代首选)
import Observation
@Observable
final class LedgerModel { // 模型/ViewModel 类
var bills: [Bill] = []
var isLoading = false
var monthExpense: Double {
bills.filter { $0.direction == .expense }.map(\.amount).reduce(0, +)
}
func add(_ bill: Bill) {
bills.append(bill)
}
}
struct HomeView: View {
@State private var model = LedgerModel() // 视图持有并注入
var body: some View {
SummaryCard(expense: model.monthExpense) // model.monthExpense 变 → 自动刷新
.onAppear { model.isLoading = true }
}
}
@Observable(Observation 框架)相比旧的 ObservableObject + @Published:按属性粒度追踪——视图只依赖它真正读取的属性,刷新范围更小(旧机制是整个对象任何 @Published 变了,所有订阅视图全刷新)。
@Environment:从环境注入(依赖注入的声明式版本)
struct SummaryCard: View {
@Environment(LedgerModel.self) private var model // 从上层环境取,不自己创建
var body: some View {
Text(model.monthExpense.rmb)
}
}
// 上层注入(通常是 App 根部)
@main
struct LedgerBookApp: App {
@State private var model = LedgerModel()
var body: some Scene {
WindowGroup {
HomeView()
.environment(model) // 整棵子树都能取到
}
}
}
选型口诀(背下来):
| 状态 | 工具 |
|---|---|
| 视图私有的临时状态(输入框、开关) | @State |
| 子视图要改父视图的状态 | @Binding |
| 多视图共享的模型 / ViewModel | @Observable 类 + @State(持有处)+ @Environment(消费处) |
| 需要兼容 iOS 16 及以下的存量写法 | ObservableObject + @Published + @StateObject/@ObservedObject |
struct HomeView: View {
@State private var path = NavigationPath() // 导航栈路径:可编程控制
var body: some View {
NavigationStack(path: $path) {
List(model.bills) { bill in
NavigationLink(value: bill) { // value 驱动:类型安全
BillRow(title: bill.title, amount: bill.amount)
}
}
.navigationTitle("轻记账")
.navigationDestination(for: Bill.self) { bill in
BillDetailView(bill: bill) // 声明「Bill 类型去哪个页面」
}
.toolbar {
Button("记一笔") {
showAddSheet = true
}
}
}
.sheet(isPresented: $showAddSheet) { // 模态:添加账单
AddBillSheet()
}
}
}
NavigationStack(iOS 16+)替代旧的 NavigationView;path 让你可以随时 path.append(bill) 编程跳转、path = .init() 一次清空栈。sheet / fullScreenCover 用于模态;TabView 用于底部标签。// List:自带复用优化(底层近似 UITableView)
List {
ForEach(bills) { bill in
BillRow(title: bill.title, amount: bill.amount)
}
.onDelete { indexSet in // 滑动删除
model.remove(atOffsets: indexSet)
}
.refreshable { // 下拉刷新(配合 async,第 9 章)
await model.syncFromCloud()
}
.searchable(text: $keyword) // 搜索框
}
// 表单
Form {
Section("分类") {
Picker("选择分类", selection: $category) {
ForEach(BillCategory.allCases, id: \.self) { c in
Text(c.rawValue).tag(c)
}
}
}
Section("金额") {
TextField("0.00", text: $amountText)
.keyboardType(.decimalPad)
.focused($focusField, equals: .amount) // 焦点控制(第 6 章展开)
}
}
// Alert / ConfirmationDialog
.alert("删除账单", isPresented: $showDeleteConfirm) {
Button("删除", role: .destructive) { model.delete(selectedBill!) }
Button("取消", role: .cancel) { }
} message: {
Text("删除后不可恢复")
}
声明式动画:状态改变本身带动画语义。
Button(isExpanded ? "收起" : "展开") {
withAnimation(.snappy) { // 包住状态修改,所有受影响 UI 自动动画
isExpanded.toggle()
}
}
// 隐式动画:视图绑定条件自动过渡
Circle()
.scaleEffect(syncing ? 1.2 : 1.0)
.animation(.easeInOut.repeatForever(), value: syncing)
原则:动画修饰 value: 一定要指定状态,否则动画行为不可控。
// ① SwiftUI 里包 UIKit 视图(UIViewRepresentable)
struct MapBoxView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView { MKMapView() }
func updateUIView(_ uiView: MKMapView, context: Context) { }
}
// ② SwiftUI 里包 UIKit 控制器(UIViewControllerRepresentable)
struct CameraPicker: UIViewControllerRepresentable { ... }
// ③ UIKit 里嵌 SwiftUI
let hostVC = UIHostingController(rootView: HomeView())
navigationController?.pushViewController(hostVC, animated: true)
这套机制是混合开发的全部基础:新页面用 SwiftUI 写,疑难杂症控件找 UIKit 兜底。
body 会在依赖状态变化时重新求值——这是设计使然,但两个纪律要守住:
body 里塞几百行 → 任何状态变化都重算全部;拆成小组件后,SwiftUI 只重算依赖变化状态的那部分。@Observable 类)的缓存属性里,body 只读结果。var monthExpense 放在 model 里而非 body 内联计算,就是这个纪律。| 误区 | 后果 | 正确做法 |
|---|---|---|
@State 声明成非 private 或在 init 里直接赋值外部数据 |
状态被意外篡改 / 初始化怪问题 | @State private;外部数据用构造参数或 @Observable 模型 |
把共享模型错放成每个视图自己的 @State |
各视图状态互不相干(「点了没反应」的头号原因) | 共享状态上提到模型类,用 @Environment 下发 |
在 body 里做重计算 / 网络请求 / 随机数 |
每次刷新都执行,卡顿且不可复现 | body 必须是「纯函数」:只读状态 → 返回视图 |
| Modifier 顺序随意 | padding/background 效果不符预期 | 顺序即包裹关系,先内后外想清楚 |
List 里套 ScrollView 或 ForEach 忘给 id |
布局异常 / 刷新异常 | ForEach(items) 让元素遵循 Identifiable,或 id: \.id |
用旧 NavigationView / ObservableObject 起新项目 |
踩已被替代 API 的坑 | 新代码一律 NavigationStack + @Observable(iOS 17+) |
认为 @State var model = ... 在视图重建时会保留 |
理解偏差 | SwiftUI 视图是值、随时重建,但 @State 由框架托管在视图身份上,重建后仍存活——正因如此重活和长期状态放模型类,不放视图 |
| Sheet 内容不刷新 | sheet 内没拿到环境的可观察对象 | sheet { } 闭包里的视图需要重新注入 .environment(model) |
@Observable 类持有状态与业务方法)→ Service/Store(第 3 章的 BillStore 协议)。View 里不写业务逻辑。amount.rmb + monospacedDigit(),全 App 一处定义。#Preview 宏写多状态预览(正常/空数据/超长文案/暗色模式),不用反复跑模拟器:#Preview("正常") {
BillRow(title: "午饭", amount: 28)
}
#Preview("暗色模式") {
BillRow(title: "午饭", amount: 28)
.preferredColorScheme(.dark)
}
UIHostingController 嵌入,存量页面不动,风险最小。里程碑 M4:三 Tab 结构 + 首页账单流 + 添加账单 sheet。
struct RootTabView: View {
@Environment(LedgerModel.self) private var model
var body: some View {
TabView {
NavigationStack {
HomeView()
}
.tabItem { Label("账单", systemImage: "list.bullet.rectangle") }
NavigationStack {
StatsView()
}
.tabItem { Label("统计", systemImage: "chart.pie") }
NavigationStack {
ProfileView()
}
.tabItem { Label("我的", systemImage: "person") }
}
}
}
struct HomeView: View {
@Environment(LedgerModel.self) private var model
@State private var showAddSheet = false
@State private var keyword = ""
var filteredBills: [Bill] { // 演示用;量大后应移入 model 缓存(第 11 章)
keyword.isEmpty ? model.bills
: model.bills.filter { $0.title.localizedCaseInsensitiveContains(keyword) }
}
var body: some View {
List {
Section {
SummaryHeader(expense: model.monthExpense) // 拆出的小组件
}
Section("明细") {
ForEach(filteredBills) { bill in
NavigationLink(value: bill) {
BillRowView(bill: bill) // 拆出的小组件
}
}
.onDelete { model.remove(atOffsets: $0) }
}
}
.searchable(text: $keyword, prompt: "搜索账单")
.navigationTitle("轻记账")
.toolbar {
Button { showAddSheet = true } label: {
Image(systemName: "plus")
}
}
.sheet(isPresented: $showAddSheet) {
AddBillSheet()
.environment(model) // sheet 是新的呈现层,需要重新注入
}
.navigationDestination(for: Bill.self) { bill in
BillDetailView(bill: bill)
}
}
}
配套的 LedgerModel(@Observable)把第 3 章的 LedgerService 包一层:View 只调 model.add(_:),LedgerModel 内部调 service 与 store。这样界面、业务、存储三层各自可替换、可测试。
AddBillSheet:金额输入(数字键盘)、分类 Picker、方向切换(支出/收入)、保存按钮校验金额后调 model.add 并 dismiss(@Environment(\.dismiss))。bills.isEmpty 时显示占位插画与引导文案(拆成 EmptyStateView)。SummaryHeader 的三种 #Preview:零支出、正常、超长金额(¥12,345,678.00)。NavigationStack(path:) 实现「从统计页点击某分类 → 直接 push 到已筛选好的账单列表」(练习编程式导航)。EmptyDemoViewController,在其中用 UIHostingController 嵌入你的 EmptyStateView。body 是状态的纯函数。@State、跨层共享 @Observable + @Environment、子改父 @Binding。NavigationStack 的 value/destination 模式让导航类型安全、可编程。所属阶段:二、界面构建与交互 | 预计学习时长:5~7 天 | 前置章节:第 5 章
学完本章,你应该能够:
焦点管理(SwiftUI @FocusState):控制哪个输入框获得焦点,是「自动聚焦到金额」和「回车跳下一项」的基础。
enum Field { case title, amount, note }
struct AddBillSheet: View {
@FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("标题", text: $title)
.focused($focusedField, equals: .title)
.submitLabel(.next) // 键盘回车键文案
.onSubmit { focusedField = .amount } // 点「下一项」跳金额
TextField("金额", text: $amountText)
.focused($focusedField, equals: .amount)
.keyboardType(.decimalPad)
.submitLabel(.done)
.onSubmit { save() }
}
.onAppear { focusedField = .title } // 进入页面自动聚焦标题
}
}
键盘避让:SwiftUI 的 Form/List/ScrollView 会自动避开键盘;自定义布局偶尔失灵时用 .ignoresSafeArea(.keyboard, edges: .bottom) 或 safeAreaInset 处理。UIKit 时代要监听 keyboardWillShowNotification 手动调 contentInset——读存量代码时认得即可。
表单校验模式:把校验逻辑放 ViewModel,UI 只显示结果:
@Observable
final class AddBillForm {
var title = ""
var amountText = ""
var amountError: String? {
guard let value = Double(amountText) else {
return amountText.isEmpty ? nil : "金额格式不正确"
}
return value > 0 ? nil : "金额必须大于 0"
}
var canSubmit: Bool { !title.isEmpty && amountError == nil && !amountText.isEmpty }
}
SwiftUI 内置手势通过 .gesture / .simultaneousGesture 添加:
Image("receipt")
.onTapGesture { showDetail = true } // 轻点(最常用,直接修饰符)
Circle()
.gesture(
DragGesture(minimumDistance: 10) // 拖拽
.onChanged { value in offset = value.translation }
.onEnded { _ in offset = .zero }
)
Text("长按复制")
.contextMenu { // 长按菜单(iOS 惯例)
Button("复制金额") { UIPasteboard.general.string = "28.50" }
Button("删除", role: .destructive) { }
}
经验:优先用系统语义组件(.swipeActions、.contextMenu、Button),自定义手势是最后手段——系统手势自带无障碍支持与 iOS 交互一致性。
iOS 权限三步走:Info.plist 声明 → 运行时请求 → 处理拒绝态。
第一步,Info.plist 必须写清楚用途描述(不写直接崩溃;写得含糊会被 App Store 拒审):
| 能力 | Info.plist Key |
|---|---|
| 相册(读取) | NSPhotoLibraryUsageDescription |
| 相机 | NSCameraUsageDescription |
| 定位(使用时) | NSLocationWhenInUseUsageDescription |
| 通知 | 无需 plist,运行时请求 |
| 麦克风 | NSMicrophoneUsageDescription |
第二步,按需请求(以相册导入小票截图为例):
import Photos
func pickReceiptPhoto() {
// 只读「 limited 有限访问」也允许时,用 requestAuthorization(for: .readWrite)
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
DispatchQueue.main.async {
switch status {
case .authorized, .limited:
showPicker = true
case .denied, .restricted:
showPermissionGuide = true // 引导去设置页,而不是装死
default: break
}
}
}
}
// 跳系统设置
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
第三步也是企业级的关键:处理「用户拒绝」的降级体验(明确告诉用户去哪里开启、功能如何受限),并在真正需要时才弹系统授权框(App 一启动就连环弹权限框是差评重灾区)。
import UserNotifications
func scheduleMonthlyReminder() async throws {
let center = UNUserNotificationCenter.current()
try await center.requestAuthorization(options: [.alert, .sound, .badge]) // 先请求
let content = UNMutableNotificationContent()
content.title = "记账时间到"
content.body = "别忘了记录今天的开销"
content.sound = .default
// 每月 1 日 20:30 重复
var comps = DateComponents()
comps.day = 1
comps.hour = 20
comps.minute = 30
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let request = UNNotificationRequest(identifier: "monthly-reminder",
content: content, trigger: trigger)
try await center.add(request)
}
// App 内清理:设置页关闭提醒时
// UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["monthly-reminder"])
注意:通知在 App 前台默认不展示横幅,需要实现 UNUserNotificationCenterDelegate 的 willPresent 返回 .banner;远程推送属于服务端范畴,本地通知学习已覆盖权限与调度的心智。
URL Scheme(App 自定义协议,同设备 App 间唤起):
// Info.plist → URL Types 注册 ledgerbook://
// SwiftUI 场景里处理:
WindowGroup {
RootTabView()
.onOpenURL { url in
// ledgerbook://bill/42
guard url.scheme == "ledgerbook", url.host == "bill",
let id = url.pathComponents.last else { return }
model.openBill(id: id)
}
}
Universal Links(https 链接直达 App,需网站放 apple-app-site-association 文件):正式产品对外的深链一律用 Universal Links(体验好、防劫持),URL Scheme 只用于开发调试与内部跳转。测试方法:
xcrun simctl openurl booted "ledgerbook://bill/42"
系统分享:
// SwiftUI
ShareLink(item: "我本月账单支出 \(model.monthExpense.rmb)") {
Label("分享月报", systemImage: "square.and.arrow.up")
}
页面多了以后,「谁能跳转到谁」会失控。企业级约定:
层级一:Tab 划分业务域(账单 / 统计 / 我的),Tab 之间不互相 push。
层级二:Tab 内 NavigationStack,页面间用 value 型 NavigationLink / 编程式 path.append。
层级三:跨域跳转与深层跳转用 Router——把「跳转意图」集中管理:
@Observable
final class Router {
enum Destination: Hashable {
case billDetail(String) // 关联值 = 参数,类型安全
case categoryBills(BillCategory)
case settings
}
var homePath = NavigationPath()
var statsPath = NavigationPath()
func openBill(_ id: String) { // 深链、通知点击都走这里
homePath.append(Destination.billDetail(id))
// 并切换 Tab(TabView 的 selection 绑定)
}
}
Modal 的使用分寸:全屏流程(登录、引导)用 fullScreenCover;轻量编辑用半屏 sheet;确认操作用 alert。模态里再套模态是反模式。
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 忘写 Info.plist 权限描述 | 调用即崩溃 | 每加一个权限能力,先写描述再写代码 |
| 权限描述写「需要使用您的权限」这类空话 | App Store 审核拒绝(Guideline 5.1.1) | 写具体业务原因:「用于拍摄小票自动识别金额」 |
| App 一启动就请求所有权限 | 授权率暴跌、差评 | 在功能触达时按需请求,拒绝后提供引导 |
| 通知 identifier 复用/不清理 | 通知重复或删不掉 | 用语义化唯一 id;关闭功能时移除 pending 请求 |
| 前台收不到通知就以为「通知坏了」 | 误判 | 前台需 delegate willPresent 返回 banner |
深链解析直接 url.absoluteString.split |
脆弱、难以维护 | 用 URL 的 scheme/host/path 结构化解析 + 枚举路由 |
| 键盘遮挡输入框 | 核心表单不可用 | 用 Form/List 自动避让;焦点管理 + submitLabel 打通输入流 |
| 手势层层叠加打架 | 点击无效、拖拽冲突 | 优先系统组件;自定义手势用 .simultaneousGesture/.highPriorityGesture 理清优先级 |
| 模态套模态、随意 pop 到任意页 | 导航栈混乱、返回体验破碎 | Router 集中管理;模态只一层 |
PermissionManager,集中管理「检查状态 → 请求 → 拒绝引导」,业务页面只调一个方法。拒绝态文案和跳设置按钮做成通用组件。ledgerbook:// 链接与参数,给运营、增长团队用——深链是增长活动的基础设施。UIAlertController、UNUserNotificationCenter 通用、application(_:open:) 等),混编项目里要能两栖。里程碑 M5:从「点击 +」到「保存成功」的完整体验。
+ → 半屏 sheet 弹出 AddBillSheet,标题栏自动聚焦;AddBillForm.amountError);Picker 前 3 个显示图标);ledgerbook://add?category=food 深链直达添加页并预选分类。这个闭环覆盖了本章全部知识点,也是面试时常考的「你负责过什么完整功能」的标准素材——把每一步的交互决策(为什么半屏、为什么实时校验、为什么软引导)说清楚,比堆功能更能体现工程素养。
AddBillSheet 全部校验逻辑:标题为空、金额非法、金额超限(>100 万)三种错误态,用 .alert 或行内提示两种方式各实现一遍。DatePicker),关闭时移除通知,修改时重排(提示:先 removeAllPendingNotificationRequests 再重建)。ledgerbook://bill/<id>:从冷启动打开 App 直达详情页(提示:onOpenURL + Router + path.append,注意冷启动时 Stack 尚未就绪的时序问题)。.contextMenu:复制金额、复制备注、跳转编辑;再用 .swipeActions 加「置顶」操作。@FocusState + Form 自动避让已覆盖大半。所属阶段:三、核心工程能力 | 预计学习时长:7~10 天 | 前置章节:第 3、5 章
学完本章,你应该能够:
URLSession(含 async/await API)完成 GET/POST 请求并正确处理错误;Codable 与服务端 JSON 结构互转,处理键名映射、日期、嵌套等真实问题;GET /bills?page=1 列表、POST /bills 新建、DELETE /bills/42 删除。国内业务后端常见「统一响应包」:{ "code": 0, "msg": "ok", "data": { ... } }——HTTP 200 但业务码非 0 表示业务失败,网络层必须同时处理这两种失败。调试接口用 Charles/Proxyman/抓包工具看真实请求与响应;开发期也可以直接问后端要接口文档(理想形态是 OpenAPI/Swagger 文档)。
现代写法(iOS 15+)直接 await,不再需要回调与手动错误包装:
struct APIUser: Codable { let id: Int; let nickname: String }
func fetchUser(id: Int) async throws -> APIUser {
let url = URL(string: "https://api.ledgerbook.example/v1/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw NetworkError.server(statusCode: http.statusCode)
}
return try JSONDecoder().decode(APIUser.self, from: data)
}
老项目回调式写法(读存量代码必备):
let task = URLSession.shared.dataTask(with: url) { data, response, error in
// 注意:回调在后台线程!更新 UI 必须 DispatchQueue.main.async
guard error == nil, let data else { return }
}
task.resume() // 忘记 resume = 请求永远不会发出去(经典 bug)
请求配置(URLSessionConfiguration):超时(timeoutIntervalForRequest 默认 60s,业务常收紧到 15~30s)、缓存策略、waitsForConnectivity 等。企业项目通常做一个共享的 URLSession 实例统一配置。
真实接口的 JSON 不会迁就你的 Swift 命名,常见问题与解法:
// 服务端返回:{ "user_id": 42, "nickname": "Mayn", "created_at": "2026-09-01T08:00:00Z", "vip": true }
struct APIUser: Codable {
let userId: Int
let nickname: String
let createdAt: Date
let isVip: Bool
enum CodingKeys: String, CodingKey { // ① 键名映射
case userId = "user_id"
case nickname, createdAt = "created_at", isVip = "vip"
}
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601 // ② 日期策略(或 .custom 处理时间戳)
decoder.keyDecodingStrategy = .convertFromSnakeCase // ③ 全局蛇形转驼峰(用了它就不用逐个 CodingKeys)
更多真实世界的坑:
let avatarURL: URL?,或用 decodeIfPresent 自定义 init(from:);Response<T: Decodable> 泛型结构(第 3 章 PagedResponse 的思路),网络层统一解包 code/msg/data;APIUser(网络模型,跟服务端走)≠ User(领域模型,跟业务走),在数据层做转换——第 12 章分层架构的基石。散落在各处的 URLSession 调用是灾难的种子。企业网络层四个组件:Endpoint(描述请求)→ APIClient(执行请求)→ 统一错误 → 响应解包。
// ① Endpoint:用类型描述「一个请求长什么样」
protocol Endpoint {
associatedtype Response: Decodable
var path: String { get }
var method: String { get }
var query: [String: String] { get }
var body: Data? { get }
}
extension Endpoint {
var method: String { "GET" }
var query: [String: String] { [:] }
var body: Data? { nil }
}
// ② 统一业务错误
enum NetworkError: Error, LocalizedError {
case invalidURL
case transport(URLError) // 网络层错误:断网、超时、DNS
case server(statusCode: Int) // HTTP 非 2xx
case business(code: Int, message: String) // 业务码非 0
case decoding(Error) // 解析失败
var errorDescription: String? {
switch self {
case .transport: "网络连接失败,请检查网络"
case .server(let code): "服务异常(\(code))"
case .business(_, let msg): msg
case .decoding: "数据解析异常"
case .invalidURL: "请求地址错误"
}
}
}
// ③ APIClient:唯一执行请求的地方
final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let baseURL = URL(string: "https://api.ledgerbook.example")!
init(session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20
config.httpAdditionalHeaders = ["Accept": "application/json"]
return URLSession(configuration: config)
}()) {
self.session = session
}
func send<E: Endpoint>(_ endpoint: E, token: String? = nil) async throws -> E.Response {
var request = URLRequest(url: baseURL.appending(path: endpoint.path))
request.httpMethod = endpoint.method
request.httpBody = endpoint.body
request.allHTTPHeaderFields = endpoint.body != nil
? ["Content-Type": "application/json"] : nil
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if !endpoint.query.isEmpty {
var comps = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
comps.queryItems = endpoint.query.map { URLQueryItem(name: $0.key, value: $0.value) }
request.url = comps.url
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw NetworkError.server(statusCode: http.statusCode)
}
// 统一响应包解包(结构按你们后端的来)
let envelope = try JSONDecoder().decode(Envelope<E.Response>.self, from: data)
guard envelope.code == 0 else {
throw NetworkError.business(code: envelope.code, message: envelope.msg)
}
return envelope.data
} catch let error as NetworkError {
throw error
} catch let error as URLError {
throw NetworkError.transport(error)
} catch {
throw NetworkError.decoding(error)
}
}
}
struct Envelope<T: Decodable>: Decodable {
let code: Int
let msg: String
let data: T
}
// ④ 业务调用:一个 Endpoint 就是一个轻量结构体,可单测、可复用
struct FetchBillsEndpoint: Endpoint {
typealias Response = [APIBill]
let path = "/v1/bills"
let page: Int
var query: [String: String] { ["page": String(page)] }
}
let bills = try await APIClient.shared.send(FetchBillsEndpoint(page: 1))
这套设计的收益:换底层实现(如加缓存层、换 mock)只动 APIClient;每个请求自带类型信息(Response 关联类型),调用处没有 Any 和手写解码。
第三方库怎么选:Alamofire(链式请求、上传下载封装好)与 Moya(抽象成 Target)在国内企业存量中常见。结论:原生 URLSession + async/await 已足够强,新项目优先原生;读懂一两个库的封装思路(其实就是本章这套模式)比背 API 重要。
完整链路:
POST /v1/login 拿到 accessToken(短期,如 2h)与 refreshToken(长期);Authorization: Bearer <token>(上面的 send(token:));刷新并重放的核心逻辑(用 actor 保证并发下只刷新一次,actor 概念第 9 章详述):
actor AuthManager {
private let tokenStore: TokenStore // Keychain 封装
private var refreshTask: Task<String, Error>?
func validAccessToken() async throws -> String { ... }
func refreshIfNeeded(after response: HTTPURLResponse,
retry: () async throws -> Void) async throws {
guard response.statusCode == 401 else { return }
// 并发的多个 401 只触发一次刷新(复用同一个 refreshTask)
if refreshTask == nil {
refreshTask = Task { try await performRefresh() }
}
_ = try await refreshTask!.value
refreshTask = nil
try await retry() // 重放原请求
}
}
// multipart 文件上传(小票图片)
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
let (data, _) = try await URLSession.shared.upload(for: request, fromFile: imageURL)
// 下载 + 后台会话(大文件、续传用 background configuration)
let config = URLSessionConfiguration.background(withIdentifier: "com.ledgerbook.download")
要点:上传大文件用 upload(for:fromFile:) 而不是 from:(避免整块读进内存);断点续传依赖 resumeData;后台传输完成后 App 被唤醒需处理 handleEventsForBackgroundURLSession(存量代码里见到不要慌)。
Idempotency-Key 头)防止重复提交;Task 生命周期管理,第 9 章);用户连点搜索时取消旧请求(防竞态:旧响应覆盖新响应);NWPathMonitor(Network 框架)替代已废弃的 Reachability,展示「当前离线,展示缓存数据」。| 误区 | 后果 | 正确做法 |
|---|---|---|
| 在 URL 字符串里拼接用户输入/参数 | 空格中文乱码、注入风险 | URLComponents + URLQueryItem 构造 |
| 把 AppSecret、签名密钥写进代码或 plist | 反编译即可提取,凭据泄漏 | 密钥类信息走服务端代理下发/环境变量注入,客户端只持有短期 token |
| Token 存 UserDefaults | 卸载前可被备份提取 | 存 Keychain |
| 回调里直接更新 UI | 偶发崩溃、界面不动 | 回调在后台线程,UI 回主线程(@MainActor,第 9 章) |
| 只处理 2xx,4xx/5xx 直接崩在 decode | 体验灾难 | 状态码 → 传输错误 → 业务码 三层判断后再解码 |
忘记 task.resume() |
请求石沉大海 | 回调式 API 必调;async API 无此问题 |
| 竞态:快速操作时旧请求后返回覆盖新数据 | 列表闪烁、显示错数据 | 取消旧 Task / 用请求序号丢弃过期响应 |
| 对所有接口统一「失败就 toast」 | 用户被无意义弹窗轰炸 | 错误分层处理:静默降级、局部提示、全局拦截(401)各归其位 |
| JSON 模型与领域模型混用一套 | 服务端字段变更直接打穿 UI 层 | API 模型与领域模型分离,边界处转换 |
URLProtocol mock 的标准做法)。里程碑 M6 为「轻记账」接入真实网络能力:
FetchBillsEndpoint + Envelope<[APIBill]>,翻页合并去重(按 bill.id);POST /v1/bills,失败进「待同步队列」(第 8 章本地持久化),App 启动时重试;/v1/login,AuthManager actor 管理 401 刷新与重放;Token 存 Keychain;GET /v1/rates?base=CNY 每 6 小时缓存一次(配合第 9 章并发),供多币种账单换算;https://httpbin.org(公开测试服务)练习:GET 带查询参数、POST JSON、模拟 500 错误(/status/500),打印各分支错误文案。APIClient 增加「请求耗时日志」:打印 path、状态码、毫秒数(不含任何头信息),体会统一出口的价值。RefreshTokenEndpoint + AuthManager 最小可运行版(刷新逻辑可以对着假的本地服务模拟),验证「并发 5 个 401 只刷新一次」。MockURLProtocol(参考第 14 章)让 FetchBillsEndpoint 返回固定 JSON,不联网完成整个列表页开发。Idempotency-Key(用 UUID().uuidString,同一账单重试复用同一 key),防止弱网下重复记账。所属阶段:三、核心工程能力 | 预计学习时长:7~10 天 | 前置章节:第 3、7 章
学完本章,你应该能够:
持久化决策的两个维度:敏感程度(要不要防提取)与 结构复杂度(键值对还是关系/图)。
| 方案 | 适合数据 | 不适合 | 加密 |
|---|---|---|---|
UserDefaults |
设置开关、上次选中分类、小配置 | 列表数据、大对象、敏感信息 | 明文 |
Keychain |
Token、密码、密钥 | 任何大数据 | 系统级加密 |
| 文件(Documents/Caches) | 图片缓存、导出的 CSV、日志 | 需要查询过滤的结构化数据 | 自理 |
| SwiftData(iOS 17+) | 业务模型:账单、笔记、草稿 | 超大规模数据(亿级行) | 自理 |
| Core Data | 同上(兼容 iOS 13-、存量项目标配) | 同上 | 自理 |
| 直接 SQLite(GRDB) | 高性能复杂查询、需要 SQL 控制力 | 简单场景(杀鸡用牛刀) | 自理 |
| Realm(第三方) | 跨平台团队、历史项目 | 新项目(SwiftData/GRDB 已覆盖) | 自理 |
口诀:敏感的进 Keychain,配置进 UserDefaults,业务数据进 SwiftData(新)/Core Data(存量),大文件进沙盒目录。
enum AppSettings {
@UserDefault(key: "default_category", defaultValue: BillCategory.food.rawValue)
static var defaultCategory: String
static var reminderEnabled: Bool {
get { UserDefaults.standard.bool(forKey: "reminder_enabled") }
set { UserDefaults.standard.set(newValue, forKey: "reminder_enabled") }
}
}
// 批量读写、立即落盘
UserDefaults.standard.set(["月薪": 12000.0], forKey: "cached_summary")
UserDefaults.standard.synchronize() // 极少需要;iOS 已自动定期落盘
性质必须记住:
Data(PropertyListEncoder),但几十 KB 以上的对象图就该换方案了;UserDefaults(suiteName: "group.xxx"))用于主 App 与 Widget/扩展共享数据(轻量场景)。Keychain 是系统级加密存储,App 卸载后数据仍在(与 UserDefaults 不同!)。系统 API 十分啰嗦,企业实践是封装(或用 KeychainAccess 等库)。最小可用封装:
enum KeychainStore {
static func set(_ data: Data, for key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary) // 简化实现:先删后增
var attrs = query
attrs[kSecValueData as String] = data
attrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(attrs as CFDictionary, nil) == errSecSuccess
}
static func data(for key: String) -> Data? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess ? result as? Data : nil
}
}
// Token 存取(配合第 7 章)
let token = Data("eyJhbGciOi...".utf8)
KeychainStore.set(token, for: "access_token")
进阶点(用到再查):kSecAttrService 区分服务、kSecAttrAccessGroup 让 App 与扩展共享(需要 Keychain Sharing capability)、数据保护等级(Accessible... 系列)对应设备锁定状态。
每个 App 有独立沙盒,目录规则:
| 目录 | 用途 | 是否备份到 iCloud |
|---|---|---|
Documents/ |
用户产生的、不能丢的数据 | ✅ |
Library/Application Support/ |
App 数据库、业务文件 | ✅ |
Library/Caches/ |
可再生的缓存(图片、接口缓存) | ❌(系统空间紧张时可清理) |
tmp/ |
临时文件,随时可能被清 | ❌ |
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fileURL = docs.appending(path: "export/2026-09.csv")
try? "日期,金额,分类\n2026-09-01,28,餐饮".write(to: fileURL, atomically: true, encoding: .utf8)
纪律:可重建的数据进 Caches(并主动清理),用户数据进 Documents/Application Support。把缓存写进 Documents 会撑爆用户 iCloud 备份,是审核与差评的隐形杀手。
SwiftData 是 Apple 对 Core Data 的 Swift 原生重铸,与 SwiftUI 深度集成。
建模与容器:
import SwiftData
@Model
final class BillRecord { // 注意:这是「存储模型」,与第 2 章的 Bill 领域模型分层
#Unique<BillRecord>([\.cloudId]) // 云同步 id 唯一索引,防重复
var cloudId: String
var title: String
var amountFen: Int // 金额用「分」(第 2 章的结论落地)
var categoryRaw: String
var directionRaw: String
var date: Date
var note: String
var needsSync: Bool = true // 同步标记:本地改动待上传
init(cloudId: String = UUID().uuidString, title: String, amountFen: Int,
category: BillCategory, direction: BillDirection,
date: Date = .now, note: String = "") {
self.cloudId = cloudId
self.title = title
self.amountFen = amountFen
self.categoryRaw = category.rawValue
self.directionRaw = direction.rawValue
self.date = date
self.note = note
}
}
@main
struct LedgerBookApp: App {
var body: some Scene {
WindowGroup { RootTabView() }
.modelContainer(for: BillRecord.self) // 注入容器
}
}
视图内 CRUD(SwiftUI 深度集成):
struct BillListView: View {
@Environment(\.modelContext) private var context
@Query(filter: #Predicate<BillRecord> { $0.directionRaw == "expense" },
sort: \BillRecord.date, order: .reverse)
private var expenses: [BillRecord] // 声明式查询,数据变了视图自动刷新
var body: some View {
List(expenses) { record in
Text("\(record.title) ¥\(record.amountFen / 100)")
}
}
func addRecord() {
context.insert(BillRecord(title: "午饭", amountFen: 2800, category: .food, direction: .expense))
try? context.save() // insert/delete 后 save 持久化
}
func delete(_ record: BillRecord) {
context.delete(record)
try? context.save()
}
}
服务层使用(脱离 SwiftUI 的通用 API)——业务代码更应该走这条路,保证可测试:
final class BillRepository {
private let context: ModelContext // 或 ModelContainer 每次取 context
init(container: ModelContainer) {
self.context = ModelContext(container) // 独立 context,别与视图共享
context.autosaveEnabled = false // 服务层手动 save,事务可控
}
func save(bill: Bill) throws {
let record = BillRecord(cloudId: bill.id, title: bill.title,
amountFen: Int(bill.amount * 100),
category: bill.category, direction: bill.direction,
date: bill.date, note: bill.note)
context.insert(record)
try context.save()
}
func unsyncedBills() throws -> [BillRecord] {
let descriptor = FetchDescriptor<BillRecord>(
predicate: #Predicate { $0.needsSync },
sortBy: [SortDescriptor(\.date)])
return try context.fetch(descriptor)
}
}
轻量迁移:改模型(加字段/改可选性)后运行,SwiftData 自动处理大多数变化;改名、改关系、删字段等破坏性变化需要 VersionedSchema + SchemaMigrationPlan 显式迁移——企业项目上线后模型变更必须走版本化流程(详见误区)。
Core Data 概念对位表,看懂老项目足矣:
| Core Data | SwiftData | 说明 |
|---|---|---|
NSPersistentContainer |
ModelContainer |
数据库容器 |
viewContext / newBackgroundContext() |
ModelContext |
工作区(托管对象上下文) |
NSManagedObject(.xcdatamodeld 定义) |
@Model 类 |
托管对象 |
NSFetchRequest |
FetchDescriptor |
查询 |
NSMergePolicy、NSFetchedResultsController |
— | 合并策略 / 数据驱动的列表 |
Core Data 铁律:context 不是线程安全,对象不能跨 context 传递(用 objectID);后台写用独立 backgroundContext,UI 读用 viewContext;perform 块内操作。
把「数据从哪来」彻底藏起来,业务只见接口:
protocol BillRepositoryProtocol {
func save(_ bill: Bill) throws // 本地立即写 + 标记待同步
func fetchLocal() throws -> [Bill]
func syncPending() async throws // 待同步队列上传(第 7 章 Endpoint)
func pullLatest() async throws // 服务端拉取,按 cloudId 合并
}
final class CloudBillRepository: BillRepositoryProtocol {
private let storage: BillStorage // 本地存储(SwiftData 实现)
private let api: APIClient
func save(_ bill: Bill) throws {
try storage.upsert(bill, needsSync: true) // 离线优先:先落本地
}
func syncPending() async throws {
for record in try storage.unsyncedBills() {
let endpoint = UploadBillEndpoint(bill: record.toDomain())
let serverId = try await APIClient.shared.send(endpoint)
try storage.markSynced(cloudId: record.cloudId, serverId: serverId)
}
}
func pullLatest() async throws {
let remote = try await APIClient.shared.send(FetchBillsEndpoint(page: 1))
try storage.merge(remoteBills: remote) // 以 cloudId 幂等合并,本地未同步数据优先
}
}
这就是「离线优先」的完整骨架:本地是事实源,网络是同步通道。记账 App 必须离线可用,这个模式也是大多数工具类 App 的标准架构。
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 用 UserDefaults 存账单列表/图片 | 性能差、明文可提取、内存爆 | UserDefaults 只放配置;业务数据进 SwiftData |
| Token 存 UserDefaults | 凭据可被提取 | Keychain |
| 缓存/导出文件写进 Documents | 用户 iCloud 备份膨胀、审核风险 | 可再生数据进 Caches 并制定清理策略 |
| 模型变更(加/删/改字段)直接改了就发版 | 老用户升级后打不开数据库,崩溃 | 模型版本化(VersionedSchema)+ 迁移测试;Core Data 用模型版本 + mapping |
| 主线程做大量 fetch / save | 卡顿掉帧 | 后台 context / Task.detached 中操作(注意 SwiftData context 非线程安全) |
try? 吞掉存储错误 |
数据静默丢失 | 存储失败要么重试要么上报,不能无声无息 |
| 本地与服务端合并无幂等键 | 同步重复、覆盖丢失 | 以业务唯一 id(cloudId)做 upsert;本地未同步数据优先级最高 |
把 @Model 对象直接传给 UI 全局使用 |
SwiftData 对象绑定 context,跨线程访问崩溃 | 转「领域模型 struct」再进 UI 层 |
| 忽略 Caches 清理 | 磁盘占用无限增长 | LRU/容量上限策略,App 启动或进后台时清理过期项 |
BillRecord(@Model,跟存储走)↔ Bill(struct,跟业务走),边界处转换。看似啰嗦,换来的是:换存储方案、做迁移、写测试都只动存储层。BillStore 协议在这里全面兑现——内存实现(测试)、SwiftData 实现(生产)随时替换。里程碑 M7 完整落地:
BillRecord(SwiftData)+ BillStorage 实现协议;syncPending() 上传 → 成功标记 needsSync=false;pullLatest() 合并服务端增量 → 刷新;updatedAt 新者胜(简单业务够用);账单删除走「软删除标记」避免合并歧义;InMemoryBillStorage 让同步逻辑可以脱离数据库测试(第 14 章直接复用)。@UserDefault 属性包装器(泛型 + PropertyListEncoder 支持自定义 Codable 类型),并给「默认记账分类」用上。BillStorage 的 SwiftData 实现(upsert 按 cloudId 幂等、软删除字段 deletedAt),补一个内存实现 InMemoryBillStorage。merge(remoteBills:) 合并逻辑:本地 needsSync 的记录不覆盖,其余以远端为准;构造「双端同时改同一条」的用例验证。BillRecord 加 paymentMethod 字段发「v2」,再用旧版本数据库文件启动验证不崩(提示:VersionedSchema,或先体验轻量迁移的自动行为)。@Model + ModelContainer + ModelContext)是新项目首选;Core Data 概念对位学习以读懂存量代码为目标。所属阶段:三、核心工程能力 | 预计学习时长:7~10 天 | 前置章节:第 3、7、8 章
学完本章,你应该能够:
进程(App)
└─ 线程(CPU 调度单位;线程数远多于核数时靠时间片轮转)
└─ 队列(GCD:任务的 FIFO 容器;串行队列一次一个,并发队列同时多个)
└─ 任务(block / Task)
两条铁律:
// ① 切换线程:耗时工作放全局并发队列(QoS 表示优先级),UI 回主队列
DispatchQueue.global(qos: .userInitiated).async {
let csv = generateReport(from: hugeData) // 重计算
DispatchQueue.main.async {
self.reportText = csv // UI 更新必须主线程
}
}
// ② 串行队列:让共享资源的访问天然排队(队列本身就是锁)
let serialQueue = DispatchQueue(label: "com.ledgerbook.sync")
serialQueue.async { sharedArray.append(x) } // 串行访问,无竞争
// ③ DispatchGroup:等待多个任务完成
let group = DispatchGroup()
for category in BillCategory.allCases {
group.enter()
fetchCategoryStat(category) { result in
defer { group.leave() } // 确保成对
// 处理 result
}
}
group.notify(queue: .main) { self.showSummary() } // 全部完成后回到主线程
// ④ 延时
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.hideToast() }
死锁经典案例(必须能一眼识别):
// 在主线程上同步等主队列 → 主队列正在等当前函数返回 → 互相等待,卡死
DispatchQueue.main.sync { } // 💀 deadlock
// 串行队列在自身队列上 sync → 同理死锁
serialQueue.sync { serialQueue.sync { } } // 💀
记住模式:sync 目标队列如果是「当前正在执行的队列」(直接或间接),必死锁。这也是「主线程上不要 .sync 主队列」「串行队列内部不要 sync 自己」的原因。
Swift Concurrency 普及后,GCD 的使用大幅收缩为:读存量代码、 bridging 场景(@preconcurrency 回调桥接)、个别精细调度。新代码一律 Swift Concurrency。
// 异步函数:可能挂起(suspend)的函数
func fetchRates() async throws -> [String: Double] {
let (data, _) = try await URLSession.shared.data(from: ratesURL)
return try JSONDecoder().decode(RatesResponse.self, from: data)
}
// Task:启动异步工作单元。视图里最常见:
.task { // SwiftUI .task:视图出现即启动,消失自动取消
await model.pullLatest()
}
Task { // 无结构上下文(如按钮点击):
do {
rates = try await fetchRates()
} catch is CancellationError {
// 用户离开页面,静默
} catch {
show(error)
}
}
关键认知:
@MainActor;ViewModel 应整体标注:@MainActor @Observable
final class StatsViewModel {
var isLoading = false
var categoryTotals: [CategoryStat] = []
private let repo: BillRepositoryProtocol
init(repo: BillRepositoryProtocol) { self.repo = repo }
func refresh() async {
isLoading = true
defer { isLoading = false }
// await 挂起期间主线程继续响应用户操作(不卡 UI!)
categoryTotals = await computeStats() // computeStats 标 @Sendable 在后台算
}
private nonisolated func computeStats() async -> [CategoryStat] {
// 纯计算:自动离开主线程池调度,完成后 await 处恢复
...
}
}
需求:6 个分类的统计要分别查询(每个查询耗时 200ms)。串行 = 1.2s,并发 = 200ms:
func fetchAllCategoryStats() async throws -> [CategoryStat] {
try await withThrowingTaskGroup(of: CategoryStat.self) { group in
for category in BillCategory.allCases {
group.addTask { // 并发添加子任务
try await fetchCategoryStat(category)
}
}
var results: [CategoryStat] = []
for try await stat in group { // 按完成顺序收集
results.append(stat)
}
return results
}
}
注意:并发的每个子任务必须「互不共享可变状态」(各自创建局部变量,最后合并),否则数据竞争。限制并发数(如最多 3 路)时用信号量或分批调度。
AsyncSequence / AsyncStream:异步版的序列,用于「持续到来的数据」(WebSocket 消息、进度回调桥接):
for try await update in syncStream {
apply(update)
}
数据竞争的两个条件:共享 + 可变 + 并发访问。Swift Concurrency 的回答:
① actor:隔离的可变状态——actor 内部的可变属性同一时刻只允许一个任务访问,其他访问自动排队:
actor RateCache {
private var cache: [String: (rate: Double, date: Date)] = [:]
private let ttl: TimeInterval = 6 * 3600
func rate(for currency: String) async throws -> Double {
if let hit = cache[currency], Date().timeIntervalSince(hit.date) < ttl {
return hit.rate // 缓存命中
}
let fresh = try await fetchRateFromServer(currency) // actor 内可自由 await
cache[currency] = (fresh, Date())
return fresh
}
}
let cache = RateCache()
// 六个页面同时请求汇率 → 服务器只收到必要次数的请求,缓存更新绝不竞争
这正是第 7 章 AuthManager 用 actor 的原因:并发的 401 刷新必须「只执行一次」,actor + 缓存中的 Task 让刷新天然串行化。
② Sendable:标记「类型可安全跨并发域传递」。值类型(struct/enum,全字段 Sendable)、不可变引用类型天然 Sendable;可变 class 需要 @unchecked Sendable(自己负责锁)或改造成 actor。Swift 6 的严格并发检查(Xcode 设置 Swift Language Version → 6)会在编译期直接拒绝不安全的跨域传递——新项目建议开 Swift 6 语言模式,让编译器当并发安全审查员。
③ 传统锁(存量代码 + @unchecked 场景):
final class SyncQueue { // 经典「并发队列 + barrier 写」
private var items: [Bill] = []
private let queue = DispatchQueue(label: "sync", attributes: .concurrent)
var snapshot: [Bill] { queue.sync { items } } // 读:并发
func append(_ bill: Bill) {
queue.sync(flags: .barrier) { items.append(bill) } // 写:独占
}
}
选型:能用 actor 用 actor;actor 化不了的遗留 class 用锁(NSLock 最通用)。不要在同一份数据上混用多种机制。
// 视图离开 → .task 自动取消 → cooperative cancellation
func syncAll() async throws {
for page in 1...100 {
try Task.checkCancellation() // 长循环中主动检查取消
try await uploadPage(page)
}
}
// 超时控制
try await withTimeout(seconds: 10) {
try await APIClient.shared.send(FetchBillsEndpoint(page: 1))
}
纪律:
checkCancellation() + 可取消的系统调用才会真正停下,纯 CPU 循环不会自己停;CancellationError 静默处理,别弹「操作失败」;deinit 时 task.cancel(),或遵守「Task 存在属性里、离开页面取消」。| 误区 | 后果 | 正确做法 |
|---|---|---|
主线程做网络/IO/大计算(或 Task { } 在主 actor 里默认继续跑主线程的重活) |
卡顿掉帧 | 重活放 nonisolated/后台 actor;UI 层只 await 结果 |
| 后台回调直接改 UI | 偶发崩溃、渲染异常 | @MainActor / DispatchQueue.main.async 收口 |
DispatchQueue.main.sync |
死锁(最经典的卡死现场) | 主线程只 async;sync 只在跨队列且确认无环时用 |
| 多线程直接读写共享 var | 数据竞争、偶发崩溃难复现 | actor 隔离 / 锁 / 串行队列;共享可变状态数清零 |
滥用 Task { } 发散任务不持有引用 |
任务泄漏、完成后更新已销毁页面 | 视图用 .task;对象持有 Task 并在 deinit cancel |
把每个函数都标 @MainActor |
主线程又被塞满重活 | 状态与 UI 相关才 MainActor;计算放后台 |
| 认为 await 会阻塞线程 | 不敢用/用法怪异 | await 挂起任务而非线程,线程可继续服务其他任务 |
| TaskGroup 子任务共享外部可变量 | 数据竞争 | 子任务自包含,结果经 group 收集合并 |
| 忽略 Swift 6 并发警告(用 @preconcurrency 压掉) | 问题推迟到运行时崩溃 | 认真处理每条 Sendable 警告;必要时改设计 |
| 取消后继续更新 UI | 崩溃或僵尸更新 | checkCancellation + CancellationError 静默分支 |
@MainActor,纯逻辑服务类不标注(由调用方调度),共享状态类做成 actor——三分法清晰可执行。里程碑 M8:把第 7、8 章的网络与存储用并发串成完整同步引擎。
@MainActor @Observable
final class SyncEngine {
enum Phase { case idle, syncing(done: Int, total: Int), done, failed(String) }
var phase: Phase = .idle
private let repo: BillRepositoryProtocol
private let rateCache: RateCache // actor:汇率缓存
private var syncTask: Task<Void, Never>?
func startSync() {
guard syncTask == nil else { return } // 防重复触发
syncTask = Task { [weak self] in // weak:引擎可能随页面销毁(第 10 章)
await self?.runSync()
self?.syncTask = nil
}
}
func cancel() { syncTask?.cancel() }
private func runSync() async {
phase = .syncing(done: 0, total: 2)
do {
// 两个独立阶段:拉取远端 与 上传待同步队列
async let pull = repo.pullLatest() // 并发发起
async let push = repo.syncPending()
_ = try await (pull, push) // 等两者完成
// 多币种汇率预热(actor 保护缓存)
_ = try await rateCache.rate(for: "USD")
phase = .done
} catch is CancellationError {
phase = .idle
} catch {
phase = .failed(error.localizedDescription)
}
}
}
设计要点回顾:async let 并发两个独立 IO;UI 状态 @MainActor 保护;RateCache 用 actor 防多页面并发请求竞争;任务可取消(用户切页面/网络切换时);[weak self] 防引擎被页面持有(下一章展开)。
DispatchQueue.main.sync(⚠️ 会卡死 Playground,保存好再跑),观察现象并解释原因。RateCache actor,加「并发去重」:同一币种 3 个并发请求只发一次网络(提示:缓存 in-flight Task)。验证方法:打印网络调用次数。fetchAllCategoryStats 改成「最多 3 路并发」的版本(提示:分批 addTask + 等待批次完成),对比总耗时。SyncEngine 加「重试与退避」:失败后 1s/2s/4s 重试三次,期间可取消。main.sync 死锁模式必须能一眼识别。checkCancellation,UI 生命周期结束静默收尾。weak self 背后的内存原理——并发与内存是同一个系统的两面。所属阶段:四、质量进阶 | 预计学习时长:4~5 天 | 前置章节:第 2、9 章
学完本章,你应该能够:
Swift 用 ARC(Automatic Reference Counting,自动引用计数) 管理 class 实例的生命周期:每创建一个强引用计数 +1,引用离开作用域/置 nil 计数 -1,归零即释放。
class BillDetail {
let id: String
init(id: String) { self.id = id; print("init \(id)") }
deinit { print("deinit \(id)") } // 析构器:验证释放的最直接手段
}
var detail: BillDetail? = BillDetail(id: "b001") // +1(count=1)
var copy = detail // +1(count=2)
copy = nil // -1(count=1)
detail = nil // -1(count=0)→ deinit 打印
关键认知:
class Bill {
var note: Note? // ① strong(默认):持有,计数 +1
weak var owner: LedgerService? // ② weak:不持有,所指对象释放后自动变 nil → 必须是可选
}
class Note {
unowned let bill: Bill // ③ unowned:不持有,但「假定永远有值」→ 访问已释放对象直接崩溃
init(bill: Bill) { self.bill = bill }
}
选择口诀:
| 场景 | 用法 |
|---|---|
| 默认 | strong |
| 「可能没有对方 / 生命周期可能短于我」 | weak(delegate、闭包捕获 self、父→子可空关系) |
| 「必然共生、生命周期不短于我」 | unowned(如 Note 必然属于某 Bill)——拿不准就用 weak,unowned 的崩溃代价不值得冒险 |
① delegate(UIKit 最经典)
protocol BillListDelegate: AnyObject { // AnyObject 限制类类型,才可用 weak
func listDidUpdate(_ list: [Bill])
}
class BillListModel {
weak var delegate: BillListDelegate? // ❌ 若声明 strong:Model 持 VC,VC 持 Model,双双泄漏
}
② 逃逸闭包捕获 self(Swift 时代最常见)
class ProfileViewModel {
var nickname: String = ""
func load() {
userService.fetchUser { [weak self] user in // 闭包被 service 持有,service 被 self 持有
guard let self else { return }
self.nickname = user.nickname // weak self 后:self 可能已释放,guard 提前退出
}
}
}
// 记忆模型:self → service → 闭包 → self,闭环。weak 打断任何一环即解开。
两种风格:[weak self] guard let self else { return }(一次性、self 短暂变强)与全部调用点写 self?.xxx(逐次判空)。推荐前者,代码更清晰。
什么时候不需要 weak self:非逃逸闭包(map/filter、非异步的局部操作)不会延长 self 生命周期,直接用 self;闭包不引用 self 更佳。
③ Timer / 观察者
class Countdown {
var timer: Timer?
func start() {
// Timer 强持有闭包(以及 block 形式下捕获的 self),self 又持有 timer → 循环
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
}
deinit { timer?.invalidate() } // 必须 invalidate,否则 timer 一直被 runloop 强持有
}
NotificationCenter 的现代 API(NotificationCenter.addObserver(forName:object:queue:using:))返回的 observer token 也持有闭包,需要 deinit 时移除;DispatchSourceTimer、CADisplayLink 同理——任何「被系统全局容器持有」的回调源都要显式销毁。
Memory Graph Debugger(首选,零配置):App 运行中点击调试栏的内存图按钮(⚠️ 图标类似三条折线):
BillDetailView),若列表里还躺着本应销毁的实例 → 选中它查看 backtrace of references(谁引用着它),闭环一目了然。Instruments → Leaks:定时扫描无法到达的对象(真泄漏);配合 Allocations 看对象增长趋势(内存持续上涨 = 可能的泄漏或缓存失控)。
日常快速手段:
deinit 里 print("deinit \(Self.self)"),关键控制器/ViewModel 全部加上,页面关闭看日志——最朴素的泄漏回归测试;VM: Tracker 区的虚拟内存是否随操作线性上涨。值类型减少引用管理:数据模型用 struct,传参即拷贝(CoW 保证实际不复制直到修改),无泄漏可能,还天然线程安全。
NSCache:自带驱逐策略的缓存(对比 Dictionary:NSCache 会在内存压力下自动逐出,且线程安全):
final class ReceiptImageCache {
static let shared = ReceiptImageCache()
private let cache = NSCache<NSString, UIImage>()
init() {
cache.countLimit = 100 // 条数上限
cache.totalCostLimit = 50 * 1024 * 1024 // 约 50MB
}
func image(for key: String) -> UIImage? { cache.object(forKey: key as NSString) }
func store(_ image: UIImage, for key: String) {
cache.setObject(image, forKey: key as NSString,
cost: Int(image.size.width * image.size.height * 4))
}
}
内存预算参考(iPhone 主流机型,App 稳态):
| 状态 | 健康水位 |
|---|---|
| 普通工具 App | < 100MB |
| 图片重 App | < 200MB |
| 收到 didReceiveMemoryWarning | 立即释放可重建资源(缓存、非当前页图片) |
图片内存账(初学者最意外的开销):一张 4000×3000 照片解码后 = 4000×3000×4 bytes ≈ 45MB!展示列表缩略图必须用 CGImageSourceCreateThumbnailAtIndex 或 ImageIO 下采样到显示尺寸,原始大图不进内存。
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 逃逸闭包一律不写 weak self | 控制器/ViewModel 泄漏,累积成卡顿与崩溃 | 逃逸闭包引用 self 时默认考虑 weak;非逃逸闭包放心用 self |
逃逸闭包一律 [weak self] |
self 提前释放导致回调静默丢失(「请求成功了界面没反应」) | 按生命周期判断:回调必须存活则强持有或让 Task 结构化管理 |
| unowned 到处用 | 对象释放后访问 → 必崩 | 拿不准就 weak |
| delegate 声明 strong | 最经典的 UIKit 泄漏 | weak var + 协议加 AnyObject 约束 |
| Timer/CADisplayLink 忘 invalidate | 控制器永不释放 | deinit / 离开页面时 invalidate |
| 认为块赋值 nil 就解了循环 | 闭环中任何一环活着整体就活着 | 用 Memory Graph 找闭环,打断「系统容器持有」的那一环 |
| 用 Dictionary 做图片/数据缓存 | 无上限、无驱逐,内存告警 | NSCache(countLimit/totalCostLimit) |
| 原图直接塞进 cell | 一屏几张图就上百 MB | ImageIO 下采样;列表用 thumbnail,详情页再加载大图 |
| 泄漏只修一次 | 新代码持续产生 | deinit 日志 + 定期 Memory Graph 走查纳入迭代流程 |
[weak self] guard let self;异步任务优先 .task/结构化并发,让框架管理生命周期。didReceiveMemoryWarning 释放非必需资源。autoreleasepool { } 分批释放,削峰。「轻记账」里程碑 M9 的真实排查剧本:
BillDetailViewModel(应该只有 1 个)。引用链显示:BillDetailViewModel → ReceiptLoader → imageCompletion 闭包 → BillDetailViewModel。ReceiptLoader 的完成回调改 [weak self],闭包捕获断链;deinit 日志开始成对出现。NSCache(上限 50MB)。这个过程就是企业排查泄漏的标准动作:现象 → Memory Graph 找闭环 → 修引用 → 管峰值 → 数据化回归。
ReceiptImageCache,并写「进出图片详情页 10 次」的内存曲线对比:原图 vs 下采样(Instruments Allocations 或 Xcode 内存仪表)。SyncEngine 的 Task { [weak self] in ... } 删掉 weak 再跑,思考:这里会泄漏吗?什么条件下会?(提示:Task 未完成前持有 self;任务短暂则影响小,但规范统一更安全)os_proc_available_memory)、缓存条数与占用,团队共享排查用。所属阶段:四、质量进阶 | 预计学习时长:5~7 天 | 前置章节:第 9、10 章
学完本章,你应该能够:
性能优化的错误姿势是「感觉慢就到处加缓存」。正确流程:
设定指标 → 建立基线 → 定位瓶颈 → 小步优化 → 复测对比 → 持续监控
核心指标与工具对照:
| 指标 | 工具 |
|---|---|
| 启动耗时 | Instruments App Launch 模板、MetricKit(launchTimeHistograms) |
| CPU 热点 | Instruments Time Profiler(按耗时排序的调用栈) |
| 帧率/渲染 | Instruments Core Animation(FPS、离屏渲染标记)、Xcode Debug 导航器 |
| 内存 | Instruments Allocations / Leaks(第 10 章) |
| 网络/耗电 | Instruments Network / Energy Log |
| 线上真实数据 | MetricKit + Xcode Organizer(真实用户聚合,开发期工具看不到的真相) |
铁律:优化只针对被测量证实的热点。模拟器性能数据完全不可信(Mac CPU ≠ iPhone),一切以真机 Release 构建为准。
启动耗时 = 冷启动(进程从无到 App 首帧可交互)的目标:业界普遍以 400~800ms 为优秀水位,超过 2s 用户流失显著。
启动链路拆解:
1. dyld 加载动态库、rebase/bind —— 减少 dylib 数量;SwiftUI 项目通常无需干预
2. main() 之前:+load、静态初始化 —— 移除不必要的 +load( objc runtime 时代残留)
3. application(didFinishLaunching) —— 前三个常见雷区都在这:同步 SDK 初始化、读大配置、首帧前请求
4. 首帧渲染(viewDidLoad 等) —— 首屏只做首屏的事
分级任务清单(启动优化的核心思想):
| 级别 | 内容 | 处理 |
|---|---|---|
| 必须 | 首屏 UI、登录态校验(本地)、崩溃上报初始化 | 启动时做 |
| 应该 | 数据库迁移检查、配置拉取 | 异步并发做,不阻塞首帧 |
| 可以 | 统计 SDK、推送注册、汇率预热、缓存清理 | 首帧后 / 用户触达时懒加载 |
实践示例(启动任务编排):
@main
struct LedgerBookApp: App {
init() {
// 只做同步且必须的:日志、崩溃上报
BootstrapLogger.install()
CrashReporter.install()
// 其余全部丢进首帧后的低优先级任务(Task 在 @MainActor app 上启动,注意调度到后台)
Task.detached(priority: .utility) {
await SyncEngine.shared.pullLatest() // 云同步不抢启动资源
await ConfigService.shared.refresh() // 远程配置
UNUserNotificationCenter.current() ... // 通知注册延后
}
}
}
度量方式:在启动各阶段打时间戳上报(CFAbsoluteTimeGetCurrent() 差值或 os_signpost),结合 Instruments App Launch 模板定位「main 之前 vs 之后」各占多少。没有打点的启动优化等于盲调。
卡顿的本质:一帧内主线程没干完活。三类病灶与药方:
① 主线程过载——Time Profiler 里找主线程上最重的调用栈:
Task.detached / actor);body 重算(第 5 章纪律)→ 拆视图、缓存计算结果;async let / TaskGroup 并发。② 列表性能(App 卡顿重灾区):
List/LazyVStack(不要 VStack 套 1000 行)、行视图拆小、行内不创建重对象;prepareForReuse(第 4 章)、行高预计算缓存、图片异步解码(decodingQueue + 下采样);③ 离屏渲染(UIKit 深水区):圆角+裁剪、阴影、蒙版等触发 GPU 额外离屏缓冲。Core Animation 模板勾选 Color Offscreen-Rendered 后黄色区域即问题点:
// ❌ 触发离屏渲染的组合
imageView.layer.cornerRadius = 12
imageView.layer.masksToBounds = true
// ✅ 静态图:预处理成圆角位图;或用 contentsRect/蒙版图;卡片圆角放容器层裁剪
SwiftUI 下这个问题被框架大幅缓解,但「滚动区域叠大量半透明与阴影」依然会掉帧——用 Debug 导航器的帧率监控验证。
App 包超过一定大小影响下载转化率(蜂窝网络下的「需 Wi-Fi 下载」门槛),也反映工程健康度:
-Osize 优化级别、strip 调试符号(Release 配置);度量:Xcode Organizer / App Store Connect 的 App Size 报告(下载大小 vs 安装大小),每次发版记录趋势。
耗电大头:定位、网络、后台唤醒。记账 App 场景:
// ① 定位:只申请「使用期间」,用完即停(不要 Always!除非业务必需并过审)
// ② 后台刷新:BGAppRefreshTask(系统调度,省电友好)
BGAppRefreshTask.requestHandler? ... // 注册任务 id(Info.plist: BGTaskSchedulerPermittedIdentifiers)
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.ledgerbook.sync", using: nil) { task in
scheduleNextSync() // 先排下一次,再干活(系统约定)
Task {
await SyncEngine.shared.startSync()
task.setTaskCompleted(success: true)
}
}
// ③ 网络合并:同步批量打包成一个请求,别每分钟一次心跳
原则:能系统调度的不自建轮询;能合并的请求不拆散;定位精度按需降级。Energy Log 工具核对「后台 CPU 时间」与「网络唤醒次数」。
MXMetricManagerSubscriber 接收系统每日汇总的真实用户性能数据:
import MetricKit
final class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
static let shared = MetricsSubscriber()
func start() { MXMetricManager.shared.add(self) }
func didReceive(_ payloads: [MXMetricPayload]) {
for p in payloads {
// 启动耗时分布、卡顿/挂起(hang rate)、内存峰值、耗电
report(launch: p.applicationLaunchMetrics,
hang: p.applicationResponsivenessMetrics,
memory: p.memoryMetrics)
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for p in payloads { // 崩溃、卡死、磁盘写入异常、CPU 异常的明细诊断
CrashReporter.upload(diagnostics: p)
}
}
}
企业标配是 MetricKit(Apple 官方)+ 商业 APM(Firebase Crashlytics、Sentry 等)组合:崩溃率、卡顿率、启动耗时分布三个指标进团队周报,性能回归一目了然。
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 在模拟器上做性能结论 | 数据严重失真 | 真机 + Release 构建度量 |
| 凭感觉优化、无基线 | 越优化越慢或白干 | 先打点建基线,优化后复测对比 |
| 把所有初始化塞进 didFinishLaunching / App.init | 启动慢的直接原因 | 任务分级:必须/异步/懒加载 |
| 滥用缓存「治卡顿」 | 内存膨胀、数据陈旧 | 先定位真瓶颈(多为 IO/主线程/布局),缓存是最后手段且有上限 |
| 列表用 VStack/LazyVStack 混用不当、行内重计算 | 滚动掉帧 | List/Lazy 容器 + 行拆分 + 预计算 |
| 上线后才第一次看性能 | 用户当测试员 | 版本发布前跑性能回归清单(启动/滚动/内存水位) |
| 只看本地指标不看 MetricKit/线上 | 真实低端机问题漏网 | 线上指标进周报,异常告警 |
| 后台疯狂轮询拉数据 | 耗电被系统降权、用户卸载 | BGTaskScheduler 系统调度 + 请求合并 |
里程碑 M10 剧本(延续第 10 章的方法论,从内存扩展到启动):
Task.detached 启动后异步预热(界面本来 6 小时才刷新一次);deferred 队列;App.init、首帧 onAppear、数据就绪三个时间点,输出到日志并计算总耗时(这是你后续所有优化的基线)。VStack 塞 2000 行(每行含图片解码),再改 List + 下采样缩略图,用 Core Animation 模板对比 FPS。RateCache 的网络请求加 OSSignposter 区间打点,Instruments 里观察「缓存命中 vs 真实请求」的耗时差异。所属阶段:五、企业级实践 | 预计学习时长:5~7 天 | 前置章节:第 3、5、7、8 章
学完本章,你应该能够:
没有架构约束的项目会自然腐化:ViewController 长到 3000 行(Massive View Controller)、业务逻辑散落在 UI 回调里、改一处崩三处、新人三个月不敢动代码。
架构的本质是三件事:
Apple 官方 MVC:Model(数据)— View(展示)— Controller(胶水)。问题在于 Controller 同时承担「页面生命周期 + 布局协调 + 业务编排 + 数据转换」,M 和 V 越轻,C 越重。MVC 不是错的——Apple 生态的控件体系就是围绕它设计的——但复杂业务页面用它必然失控。
MVVM 在 MVC 之上抽出 ViewModel:把「页面需要的状态与动作」从控制器中剥离,View 只做渲染与转发。
SwiftUI 形态(声明式绑定,最省力):
@MainActor @Observable
final class BillListViewModel {
enum ViewState {
case loading
case loaded(bills: [Bill], monthExpense: Double)
case empty
case error(String)
}
private(set) var state: ViewState = .loading // 用枚举建模页面状态(第 2 章的枚举哲学)
private let repo: BillRepositoryProtocol // 依赖注入:只认协议
init(repo: BillRepositoryProtocol) { self.repo = repo }
func load() async {
state = .loading
do {
let bills = try await repo.fetchAll()
state = bills.isEmpty ? .empty : .loaded(bills: bills, monthExpense: summarize(bills).expense)
} catch {
state = .error(error.localizedDescription)
}
}
func delete(_ bill: Bill) async {
try? await repo.delete(bill)
await load()
}
}
struct BillListView: View {
@State private var vm: BillListViewModel // SwiftUI 持有 ViewModel
@Environment(Router.self) private var router
var body: some View {
switch vm.state {
case .loading: ProgressView()
case .empty: EmptyStateView()
case .error(let msg): ErrorView(message: msg) { Task { await vm.load() } }
case .loaded(let bills, let expense):
List(bills) { bill in
Button { router.openBill(bill.id) } label: { BillRowView(bill: bill) }
}
Text("本月支出 \(expense.rmb)")
}
}
}
UIKit 形态:ViewModel 暴露状态(闭包/Combine/回调通知),ViewController 订阅并渲染——多一层手动绑定,原则相同。
MVVM 的边界纪律:ViewModel 不 import UIKit/SwiftUI、不持有 View——这是它可测试的全部前提。
页面规模上去后,MVVM 之上再分层(Clean Architecture 的简化实用版):
┌─ Presentation 层 ────────────── View + ViewModel + Router(SwiftUI/UIKit,可整体替换)
├─ Domain 层 ──────────────────── 领域模型(Bill)+ 业务服务(LedgerService)+ 协议(BillRepositoryProtocol)
└─ Data 层 ────────────────────── Repository 实现 + 网络模型/存储模型 + APIClient/SwiftData
依赖方向单向向下:Presentation → Domain ← Data(Domain 定义的协议由 Data 实现,依赖倒置)。价值兑现:
// ① 构造器注入(默认首选):依赖显式、必然、不可变
let vm = BillListViewModel(repo: CloudBillRepository(api: .shared, storage: .swiftData))
// ② 环境注入(跨层下发,SwiftUI 场景)
RootTabView().environment(repo as BillRepositoryProtocol)
// ③ 组装根:全 App 只有一处「知道所有具体实现」—— Composition Root
enum AppAssembly {
static func makeRootView() -> some View {
let api = APIClient.shared
let storage: BillStorage = SwiftDataBillStorage()
let repo = CloudBillRepository(api: api, storage: storage)
let router = Router()
return RootTabView()
.environment(router)
.environment(BillListModel(repo: repo))
}
}
大型项目可用 DI 框架(Swinject、Factory)管理对象图,但构造器注入 + 组装根已覆盖 90% 场景;框架解决的是「作用域生命周期」复杂问题,用不上时是负担。
第 6 章的 Router 就是 SwiftUI 时代的 Coordinator:把「页面间怎么跳」从页面里拿出来,好处:
BillListView 只发 router.openBill(id));UIKit 项目用经典 Coordinator 模式:每个流程一个 Coordinator 对象,持有 NavigationController,创建子页面并接线,返回值用闭包——存量代码里大量存在,要会读。
| 场景 | 推荐 | 理由 |
|---|---|---|
| 小工具 / 个人项目(< 20 页面) | SwiftUI + ViewModel + Repository | 不要过度设计 |
| 中型产品(20~100 页面) | 上表 + 分层 + Router | 平衡投入产出 |
| 大型多人项目(100+ 页面,多团队) | 分层 + 模块化(第 13 章)+ 约定化 DI | 编译速度、团队边界 |
| 存量 UIKit MVC 项目 | 渐进重构:新代码 MVVM,逐页迁移 | 大爆炸重构是事故之源 |
| 被提及的 VIPER / TCA | 了解概念即可 | VIPER 粒度过细学习成本高;TCA(函数式)理念先进但团队上手陡,选型需评估团队 |
反「为了架构而架构」:每加一层都要问「它让我能删掉/测试/替换什么」。无法回答,就不加。
| 误区 | 后果 | 正确做法 |
|---|---|---|
| ViewModel 变成新的 Massive(3000 行) | 问题原样搬家 | ViewModel 按页面/用例拆分;通用逻辑下沉 Domain 服务 |
| ViewModel import UIKit | 无法单元测试,层界失守 | Presentation 层 ViewModel 禁止 UIKit(lint 把关,第 16 章) |
| 双向依赖(Data 反过来调 Presentation) | 循环依赖、无法分层编译 | 协议定义在 Domain,实现在 Data(依赖倒置) |
| 绕过层直接调用(View 里 new APIClient) | 数据流失控 | 只有 Repository 碰网络/存储;违规用 lint + review 拦截 |
| 大爆炸式重构(一次换全套架构) | 业务停摆、回归风险爆炸 | 绞杀者模式:新功能新架构,老功能随迭代迁移 |
| 全公司统一一种「最先进」架构 | 忽略业务形态与团队现状 | 按规模/团队选,决策写 ADR(第 16 章) |
| 架构只存在于口头 | 三个月后没人记得为什么 | 目录结构 + README + ADR 文档化,新人按图施工 |
LedgerBook/
├── App/ 组装根、入口、路由
├── Features/ 按业务功能分组的 Presentation
│ ├── BillList/ BillListView.swift / BillListViewModel.swift / BillRowView.swift
│ ├── AddBill/ Stats/ Profile/
├── Domain/
│ ├── Models/ Bill.swift(纯 struct,无框架依赖)
│ ├── Services/ LedgerService.swift
│ └── Repositories/ BillRepositoryProtocol.swift(协议在这里!)
├── Data/
│ ├── Network/ APIClient / Endpoints / APIBill
│ └── Storage/ SwiftDataBillStorage / BillRecord
├── CoreKit/ 通用工具:格式化、日志、扩展
└── Tests/
enum 而不是散落的 Bool 组合(isLoading && !hasError && ... 的组合爆炸是 bug 温床)。里程碑 M11:把前面各章积累的代码正式重组为上面的目录结构,并完成一次「架构收益验证」:
Bill、LedgerService、BillRepositoryProtocol 移入 Domain,确认该目录零框架 import(用 lint 验证);APIClient/Endpoints、第 8 章 SwiftData 存储归位;APIBill → Bill 转换写进 Data 层;BillListViewModelTests(第 14 章):注入 InMemoryBillRepository,不启动任何 UI/数据库即可测试全部页面状态——30 秒跑完,以前要靠手点模拟器;grep -r "import SwiftUI" Domain/ 验证 Domain 层纯净。isLoading/showError/isEmpty)重构成单一 ViewState 枚举,处理所有分支。ErrorView(message:retry:) 通用组件并接入错误态(含「重试」回调链路)。AddBillViewModel:校验、保存、成功后关闭 sheet 的完整用例,保证它不 import 任何 UI 框架。@Observable ViewModel 天然落地;分层(Presentation/Domain/Data)提供纵深。所属阶段:五、企业级实践 | 预计学习时长:5~7 天 | 前置章节:第 12 章
学完本章,你应该能够:
单工程(monolithic target)的痛,团队规模上去后集中爆发:
| 症状 | 模块化的收益 |
|---|---|
| 全量编译 10 分钟起,改一行等半天 | 模块独立编译 + 增量缓存 |
| 100 人改同一个 App target,冲突不断 | 团队按模块认领,边界即代码 |
| 「工具类」互相 import 成毛线球 | 依赖图强制单向,违规编译失败 |
| 代码无法复用到另一个 App/扩展 | 模块即复用单元(Widget/手表/新 App 直接 import) |
但模块化有成本:接口设计与版本管理的心智负担、包依赖调试更绕、小团队强行拆分是负收益。经验阈值:3 人以下小项目不做;App target 编译 >2 分钟、>5 人并行、或出现第二个壳(Widget/扩展/多 App)时启动。
成熟企业的模块地图(两层划分法):
┌────────── App 壳(LedgerBook)─────── 组装根、启动配置
│
业务模块(横向,互不依赖)│
LedgerFeature ──── 首页/列表/添加
StatsFeature ────── 统计
ProfileFeature ──── 我的/设置
│ 各自依赖 ↓(只依赖中间件与基础层)
中间件(纵向服务)
NetworkKit APIClient、Endpoint 协议、鉴权
StorageKit SwiftData 封装、Repository 基建
DesignKit 设计系统:颜色、字体、通用组件(第 16 章与设计协作)
UtilityKit 日志、格式化、扩展
│ 依赖 ↓
基础层(几乎无依赖)
FoundationKit 纯 Swift 工具(金额格式化、日期)
铁律:
StatsFeature 不得 import LedgerFeature)——跳转通过路由(13.5);Apple 官方包管理器,Xcode 深度集成。创建本地模块:
cd ~/Projects/LedgerBook
swift package init --type library --name DesignKit
mv DesignKit Packages/ # 项目内约定:Packages/ 目录放所有本地模块
目录结构(monorepo 内多包模式,中小企业主流):
LedgerBook/
├── LedgerBook.xcodeproj
├── Packages/
│ ├── DesignKit/
│ │ ├── Package.swift
│ │ └── Sources/DesignKit/...
│ ├── NetworkKit/
│ └── StatsFeature/
└── App/
Package.swift 解读:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "StatsFeature",
platforms: [.iOS(.v17)], // 模块可单独声明最低系统
products: [
.library(name: "StatsFeature", targets: ["StatsFeature"]) // 对外暴露什么
],
dependencies: [
// 模块间依赖与版本约束
.package(path: "../DesignKit"),
.package(path: "../NetworkKit"),
],
targets: [
.target(name: "StatsFeature",
dependencies: ["DesignKit", "NetworkKit"]),
.testTarget(name: "StatsFeatureTests", dependencies: ["StatsFeature"]),
]
)
主工程接入:Xcode → File → Add Package Dependencies → Add Local…,选中 Packages/DesignKit,勾给需要的 target。之后 import DesignKit 即可。
外部依赖同理(GitHub 地址或私有 Git):.package(url: "git@github.com:yourorg/DesignKit.git", from: "1.2.0")。
版本策略:模块对外行为变化遵循语义化版本(SemVer):major 破坏性、minor 新功能、patch 修复。monorepo 本地包可暂用 path 直连,但接口纪律照旧——public API 的变更就是版本变更。
模块对外的全部信息都在它的 public API 上。设计准则:
// StatsFeature 的对外门面(Facade):调用方不需要知道内部页面结构
public enum StatsFeatureAPI {
/// 进入统计模块首页(由宿主决定放进哪个导航栈)
@MainActor public static func rootView() -> some View {
StatsHomeView()
}
}
// 依赖以协议注入,模块不知道宿主的具体实现
public struct StatsDependencies {
public let billRepository: any BillRepositoryProtocol // 协议定义在 FoundationKit/NetworkKit 这类公共层
public let currencyFormatter: (Int) -> String
public init(...) { ... }
}
准则:
业务模块互不依赖,但「账单页跳统计页」仍要发生:
① 协议注册(服务定位风格):公共层定义协议,各模块启动时注册实现,调用方按协议取:
// 公共层 FoundationKit
public protocol FeatureRegistry {
func resolve<T>(_ type: T.Type) -> T?
}
// LedgerFeature 启动注册自己提供的能力
public protocol BillQueryService {
func monthBills(of month: String) -> [Bill]
}
// StatsFeature 只 import 公共层,通过 resolve 取到能力——互不 import
② 路由(跨模块导航,第 6/12 章 Router 的模块化版):
// 公共层定义路由语义(枚举或字符串模式),各壳/路由器映射到具体页面
public enum AppRoute {
case stats
case billDetail(id: String)
}
// StatsFeature 发出 .billDetail(id) 意图,App 壳的路由器决定打开 LedgerFeature 的页面
③ 依赖注入(组装根集中接线,第 12 章):模块暴露「需要什么」,App 壳的 Composition Root 拼装——结构最清晰,推荐为默认;注册制适合「多团队松耦合、发布节奏不一」的大型组织。
三种方式对比:注入 > 路由 > 注册(可发现性、类型安全性递减)。避免用 NotificationCenter 传业务对象(不可追踪、类型不安全)。
.xcframework),两用途——保护核心代码(不随源码外发)、加速依赖方编译。SPM 支持 binaryTarget;配合 CI 在每次 tag 时自动出包;~/Library/Caches/org.swift.swiftpm),大仓库提速明显。| 误区 | 后果 | 正确做法 |
|---|---|---|
| 一上来拆 20 个模块 | 心智负担爆炸,改一个需求跨 5 个包 | 从 3~4 个包开始(Design/Network/Storage/Feature),按痛感演进 |
| 业务模块之间私下互相 import | 依赖图塌方,模块化名存实亡 | Package.swift 里就不声明该依赖——让编译器当守门员 |
| public 全量敞开 | 模块边界消失,任何改动都是破坏性变更 | 门面类收敛 public;API 变更走版本纪律 |
| 循环依赖用「复制一份代码」绕过 | 两份逻辑漂移,bug 成对出现 | 把公共部分下沉到中间件/基础层 |
| 模块内直接用主工程的资源/配置 | 模块不可独立编译与测试 | 资源、配置全部通过依赖注入或模块自带 |
| 用 NotificationCenter 传业务数据 | 数据流不可追踪 | 路由/协议/注入三种方案 |
| 本地包接口随意改不升版本 | 依赖方突然编译失败 | monorepo 也要守 SemVer 心态:public 变更 = 需要评估影响面 |
Packages/DesignKit/README.md 写清组件清单与用法;每个包至少有编译级测试目标,CI 分模块跑(见第 15 章)。Package.swift 提取依赖关系生成图,Code Review 时肉眼检查「有无向上依赖」。xcodebuild - dry-run 或 Xcode 报告),模块化收益用数字说话。里程碑 M12(对照第 12 章的目录结构,把目录升级为包):
FoundationKit(金额/日期格式化,零依赖)→ DesignKit(颜色、BillRowView 等通用组件)→ NetworkKit(APIClient、Endpoint 协议、Token 管理)→ StorageKit(SwiftData 封装);LedgerFeature(账单列表/添加)、StatsFeature(统计)、ProfileFeature;三包互不依赖,均依赖中间件;App/ 组装根注入 BillRepositoryProtocol 实现与 Router;验证 StatsFeature 通过路由打开账单详情;StatsFeature 并编译(Xcode 只重编该包及其依赖,从 40s 降到 8s);新增 Widget 壳直接 import LedgerFeature 复用列表组件。FoundationKit(纯函数工具)与 DesignKit(通用 UI),主工程改为包依赖,验证各自可独立编译。StatsFeatureAPI.rootView() 是 StatsFeature 唯一 public 入口,其余全部 internal。DesignKit 写 README(组件清单 + 用法示例)与一个快照/渲染测试目标。Package.swift 输出依赖图(Mermaid 格式),放进 docs/architecture.md 随仓库演进。Package.swift 声明产品/依赖/目标,monorepo 内 path 引用本地包。所属阶段:五、企业级实践 | 预计学习时长:5~7 天 | 前置章节:第 3、7、8、12 章
学完本章,你应该能够:
@Test/#expect)编写可读、可维护的单元测试;URLProtocol 注入假响应); / UI 测试 \ 少量:核心路径冒烟(慢、脆、贵)
/ 集成测试 \ 中量:模块边界(网络层、存储层真跑)
/ 单元测试 \ 大量:ViewModel/Service/纯函数(快、稳、便宜)
投入顺序(从回报最高开始):
反模式预警:追求 100% 覆盖率会催生大量「测实现细节」的僵尸测试——重构改个内部结构就红一片,最后团队干脆删测试。覆盖率是仪表不是目标(企业常见门槛:核心模块 70%+,整体不做硬性要求)。
Apple 官方新框架,Swift 原生宏,新项目默认选择(XCTest 继续可用,存量项目按需迁移):
import Testing
@testable import LedgerBook
@Suite("账单统计")
struct SummaryTests {
@Test("空账单:收支与结余均为 0")
func emptyBills() {
let result = summarize([])
#expect(result.expense == 0)
#expect(result.income == 0)
#expect(result.balance == 0)
}
@Test("混合收支:支出、收入、结余正确")
func mixedBills() {
let bills = [
Bill(title: "午饭", amount: 28, category: .food),
Bill(title: "工资", amount: 12000, direction: .income, category: .other),
]
let result = summarize(bills)
#expect(result.expense == 28)
#expect(result.balance == 11972)
}
// 参数化:一次声明,多组数据各跑一遍(等价老框架的多次测试)
@Test("非法金额被拒绝", arguments: [0, -1, -0.01, 10_000_001])
func invalidAmount(_ amount: Double) async throws {
#expect(throws: LedgerError.self) {
try addBill(amount: amount, category: .food)
}
}
}
XCTest 对照(读存量代码必备):
| Swift Testing | XCTest |
|---|---|
@Test func foo() |
func testFoo() |
#expect(a == b) / #require(try ...) |
XCTAssertEqual / XCTUnwrap |
@Suite |
class XCTestCase + setUp/tearDown |
arguments: 参数化 |
手写多个 test 方法 |
AAA 结构(两种框架通用):Arrange 准备(构造输入/替身)→ Act 执行(调用被测对象)→ Assert 断言(验证结果)。一个测试只验证一件事,失败即定位。
第 3 章埋的协议伏笔在此全面兑现——面向协议的代码天生可测:
// ① 手写 Mock(最常用:可控、可断言调用序列)
final class InMemoryBillRepository: BillRepositoryProtocol {
private(set) var savedBills: [Bill] = []
var stubbedBills: [Bill] = []
var stubbedError: Error?
func save(_ bill: Bill) throws {
if let e = stubbedError { throw e } // 模拟存储失败
savedBills.append(bill)
}
func delete(_ bill: Bill) async throws { }
func fetchAll() async throws -> [Bill] {
if let e = stubbedError { throw e }
return stubbedBills
}
func syncPending() async throws { }
func pullLatest() async throws { }
}
// ② 测 ViewModel 状态机(第 12 章 BillListViewModel)
@Suite("账单列表 ViewModel")
@MainActor
struct BillListViewModelTests {
@Test("加载成功 → loaded 状态")
func loadSuccess() async {
let repo = InMemoryBillRepository()
repo.stubbedBills = [Bill(title: "午饭", amount: 28, category: .food)]
let vm = BillListViewModel(repo: repo)
await vm.load()
guard case .loaded(let bills, let expense) = vm.state else {
Issue.record("应为 loaded 状态,实际 \(vm.state)")
return
}
#expect(bills.count == 1)
#expect(expense == 28)
}
@Test("加载失败 → error 状态")
func loadFailure() async {
let repo = InMemoryBillRepository()
repo.stubbedError = NetworkError.server(statusCode: 500)
let vm = BillListViewModel(repo: repo)
await vm.load()
guard case .error = vm.state else {
Issue.record("应为 error 状态")
return
}
}
@Test("空数据 → empty 状态")
func loadEmpty() async { /* 同型:stubbedBills = [],断言 .empty */ }
}
复杂对象图可用 Mock 框架(Mockolo/Sourcery 生成),但手写协议实现通常更快更可控——优先手写。
不联网验证 APIClient 的三种结局(成功/HTTP 错误/业务码错误):
final class MockURLProtocol: URLProtocol {
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.handler else { return }
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() { }
}
@Suite("APIClient")
struct APIClientTests {
let client: APIClient = {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self] // 偷梁换柱
return APIClient(session: URLSession(configuration: config))
}()
@Test("业务码非 0 抛 business 错误") async throws {
MockURLProtocol.handler = { _ in
let body = #"{"code":1001,"msg":"未登录","data":null}"#.data(using: .utf8)!
(HTTPURLResponse(url: URL(string: "https://t")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, body)
}
await #expect(throws: NetworkError.self) {
_ = try await client.send(FetchBillsEndpoint(page: 1))
}
}
}
存储层测试同构:InMemoryBillStorage 替换 SwiftData(第 8 章的内存实现直接复用);需要验证真实数据库行为(迁移、唯一索引)时才用内存 SQLite/临时容器跑集成测试。
@Test 函数直接标 async,await 被测方法即可——不需要老框架的 expectation 仪式(存量代码见 XCTAssertTrue(fulfilled) 那套即可);Date(),注入 now: () -> Date 闭包,测试时喂固定时间——「本月」「7 天前」类逻辑才能确定性验证;idGenerator: () -> String,快照对比与幂等合并测试都需要。XCUITest(XCTest UI Testing target):模拟真实点击流,慢且脆,只做关键路径:
func testAddBillFlow() {
let app = XCUIApplication()
app.launch()
app.buttons["plus"].tap() // accessibilityIdentifier 才是稳定选择器!
app.textFields["bill-title"].typeText("打车")
app.buttons["保存"].tap()
XCTAssertTrue(app.staticTexts["已保存"].waitForExistence(timeout: 3))
}
纪律:给关键控件加 accessibilityIdentifier(不要靠文案/层级定位);UI 测试数量克制(< 10 条冒烟级)。
快照测试(SnapshotTesting 等库):把组件渲染成图片与基准对比,适合 DesignKit 组件的视觉回归(明暗模式 × 常见尺寸矩阵);基准图更新要有明确的 review 流程。
CI 接入:测试必须在 CI 上跑(第 15 章流水线的核心门禁),本地「只测自己改的模块」(swift test --filter / scheme 过滤)保住迭代速度。
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 测实现细节(内部方法、调用顺序死板断言) | 重构即红,维护成本高于收益 | 测对外行为:给输入,断言输出/状态 |
| 共享可变状态的全局单例参与测试 | 测试顺序依赖、偶发失败 | 依赖注入替身;测试彼此独立(任意顺序可跑) |
| 追求覆盖率数字 | 僵尸测试淹没信号 | 覆盖率看趋势与核心模块,不设形式化 KPI |
| Mock 返回「永远成功」 | 错误分支从未被验证 | 每个 stub 都要有对应的失败态测试(错误传播是 bug 重灾区) |
异步测试靠 sleep |
慢且偶发失败 | async 测试 / 注入调度;绝不 sleep 等结果 |
| UI 测试用文案/层级定位控件 | 文案一改全红 | accessibilityIdentifier |
测试里用 Date()/UUID() |
断言不稳定 | 注入时间/ID 生成器 |
| 只在本地跑测试,CI 不跑或跑了不拦 | 「绿色幻觉」发布 | CI 强制跑测试 + 失败禁止合并(第 15 章门禁) |
测试名 = 场景 + 期望(「空账单收支为零」),失败信息直接可读;用 @Suite 按被测类型分组。makeBill(...))、fixture 工厂,避免复制粘贴式 setup。里程碑 M13 落地三层测试:
summarize、账单合并(merge(remoteBills:) 的冲突分支)、AuthManager 刷新去重(并发 5 请求只刷一次——用计数 stub 验证);BillListViewModel 四态全覆盖、AddBillForm 校验矩阵(参数化);APIClient 三结局(URLProtocol)+ SwiftDataBillStorage upsert/软删除(临时容器);AddBillForm 的全部校验分支(合法/空标题/非法金额/超限金额)。merge(remoteBills:) 写冲突测试:本地未同步 vs 远端更新,至少 4 个用例。MockURLProtocol 测试 APIClient 的 401 → 刷新 → 重放链路(stub 返回 401 再 200,断言重试成功)。BillRowView 写快照测试:支出/收入 × 明暗模式 4 组基准图,故意改样式观察 diff。xcodebuild test,失败 PR 不能合并。@Test/#expect/参数化)是新默认,XCTest 会读即可。所属阶段:五、企业级实践 | 预计学习时长:4~6 天 | 前置章节:第 14、13 章
学完本章,你应该能够:
开发者 push/PR
│
▼
CI 门禁(每次 PR):编译 → SwiftLint → 单元测试 → (可选)UI 冒烟
│ 全绿才允许合并
▼
主干(main)合并后:打 tag/手动触发 → Archive → 签名 → 上传 TestFlight
│
▼
TestFlight 内测 → 提审 App Store(第 17 章)
iOS CI 的特殊性(与后端不同的坑):
.github/workflows/ci.yml(PR 门禁流水线,可直接落地):
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_26.app
- name: Cache SPM
uses: actions/cache@v4
with:
path: ~/Library/Caches/org.swift.swiftpm
key: spm-${{ hashFiles('**/Package.resolved') }}
- name: Lint
run: swiftlint lint --strict --reporter github-actions-logging
- name: Run tests
run: |
xcodebuild test \
-project LedgerBook.xcodeproj \
-scheme LedgerBook \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-resultBundlePath TestResults.xcresult \
| xcbeautify
要点:
xcbeautify 美化 xcodebuild 海量输出;-resultBundlePath 保留结果供排查;Package.resolved 哈希命中,能省几分钟;swift test)。CI 里所有密钥(签名证书密码、App Store Connect API Key、第三方 SDK Key)一律走 GitHub Secrets / 环境变量,规则:
- name: Upload to TestFlight
env:
# App Store Connect API 的密钥材料全部来自 Secrets,绝不写进 yml/仓库
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
run: fastlane beta
fastlane 是 iOS 交付事实标准(Ruby DSL,配置入仓即「基础设施即代码」):
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
# ① 证书与描述文件:match 用私有 Git 仓库统一保管,全团队/CI 共享
desc "同步签名资产"
lane :certs do
match(type: "appstore", readonly: true) # CI 只读;本地开发可申请新证书
end
# ② 出包 + 上传 TestFlight
desc "内测包"
lane :beta do
setup_ci # CI 环境钥匙串准备
match(type: "appstore", readonly: true)
increment_build_number # build 号自增(见 15.5)
build_app(scheme: "LedgerBook", # gym:Archive + 导出 ipa
export_method: "app-store")
upload_to_testflight( # pilot:传 ASC + 分发测试员
api_key: app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_PRIVATE_KEY"], # .p8 内容,来自 Secrets
),
distribute_external: false,
changelog: last_git_commit[:message] # 更新日志取 commit message
)
end
end
match 的原理一句话:证书与描述文件加密存进私有 Git 仓库,任何机器一条命令同步全套签名资产——终结「只有老王的电脑能出包」的经典事故。
Xcode Cloud 是 Apple 官方替代品:与 ASC 深度集成、零签名配置负担,适合小团队快速起步;自定义能力(复杂步骤、私有依赖)弱于 GitHub Actions + fastlane,两者选一即可,概念互通。
2.4.0;发布时定,随 tag 生成;出包不是终点,流水线尾端接监控(第 11 章 MetricKit + Crashlytics/Sentry):
| 误区 | 后果 | 正确做法 |
|---|---|---|
| CI 只是摆设,本地测试过了就合并 | main 长期红着,CI 信用破产 | 分支保护强制 required check,红了所有人停下来先修 |
| 证书只在一台电脑上手动管理 | 人一休假如法出包;证书过期全团瘫痪 | match 等共享方案 + CI 只读同步 |
| 凭据写进仓库/流水线文件 | 泄漏即事故,历史提交里永远删不干净 | 一律 Secrets/环境变量;泄漏先轮换 |
| build 号手动填 | ASC 上传冲突,发版当天干着急 | CI 自增(API 取最大 +1) |
| 测试全量跑在单机串行 | PR 排队 1 小时 | 模块并行 job、SPM/模拟器缓存、按需 UI 测试 |
| 没有 changelog / 版本说明 | TestFlight 更新说明每次现编 | 约定 Conventional Commits(第 16 章),机器生成 |
| 一次只在真机手动打包发版 | 流程不可复制,节奏靠运气 | 一条命令(fastlane)出包,CI 定时/打 tag 触发 |
fastlane beta 出内测包——交付能力属于团队而非个人。里程碑 M14:
v1.0.0-beta.1)触发 fastlane beta → TestFlight 内测组(产品/测试)收到邮件;从此「发版」从半天的人工仪式变成 30 分钟的自动化流程,且每个版本可复现——这就是企业级交付的底线水位。
swift test 测你的 SPM 模块,跑通最小闭环)。lanes :tests(跑测试)与 :beta(先做到本地 Archive 成功);把 Fastfile 提交进仓库。所属阶段:五、企业级实践 | 预计学习时长:3~4 天 | 前置章节:第 12~15 章
学完本章,你应该能够:
规范的终极形态不是文档贴墙,而是自动化执法:
| 层级 | 内容 | 执法者 |
|---|---|---|
| 格式 | 缩进、换行、空格、import 排序 | SwiftFormat(保存时自动,零争论) |
| 风格 | 命名、行长度、强制解包、类型体量 | SwiftLint(CI 阻断 error 级) |
| 设计 | 架构分层、API 设计、抽象取舍 | Code Review + ADR(人管) |
.swiftlint.yml 实用起点(轻记账团队配置节选):
disabled_rules:
- trailing_whitespace # 交给 SwiftFormat
opt_in_rules:
- empty_count # array.isEmpty 而非 count == 0
- closure_spacing
- explicit_init
- first_where # filter().first → first(where:)
line_length:
warning: 120
error: 160
identifier_name:
min_length: 2
excluded: ["id"]
# 架构守护:自定义规则(第 12 章的约定变成机器执法)
custom_rules:
no_force_unwrap: # 禁止强制解包(IUO 出口除外)
name: "No Force Unwrap"
regex: '\w+\!'
severity: error
viewmodel_no_uikit: # ViewModel 不得 import UIKit
name: "ViewModel Must Not Import UIKit"
regex: 'ViewModel.*\nimport UIKit'
severity: error
excluded:
- Packages/*/Tests
- .build
团队实践:error 级进 CI(第 15 章门禁 --strict),warning 级本地提示;规则变更走 PR——规范本身也要走规范。
命名(重读第 2 章原则,补充团队约定):
BillRepositoryProtocol、Retryable);is/has/should 前缀;方法名是句子(transfer(from:to:amount:));calcAmt() → calculateAmount();DataManager/Manager/Utils 这类「垃圾桶名」尽量不出现。注释只写「为什么」,不写「是什么」:
/// ❌ 复读代码:设置标题标签的字体
/// ✅ 说明约束与原因:
titleLabel.font = .preferredFont(forTextStyle: .title2) // 跟随系统动态字体(无障碍要求)
// 金额用 Int(分)存储:Double 累加有精度误差,且后端契约以分为单位
let amountFen: Int
/// 向服务端同步待上传账单。
/// 离线优先策略:本地为事实源,上传失败保留 needsSync 标记,下次启动重试。
/// - Throws: `NetworkError` 当网络不可用或服务端拒绝(幂等键已内置于请求)
func syncPending() async throws { ... }
公开 API 用 /// 文档注释 + Xcode 快速帮助(⌥点击)可见;开源/组件库进一步用 DocC 生成文档站(swift package generate-documentation)。
作为提交者:
作为评审者(检查清单):
| 维度 | 问题 |
|---|---|
| 正确性 | 边界条件?错误分支?并发安全(weak self、主线程)? |
| 设计 | 放对层了吗?public 最小化了吗?有没有更简单的写法? |
| 测试 | 有没有测?测的是行为还是细节? |
| 安全 | 有没有凭据/敏感日志?权限用途描述更新了吗? |
| 可维护 | 命名达意?复杂度是否需要注释「为什么」? |
反馈话术:对事不对人——「这里用 weak self 会不会泄漏?」优于「你怎么又忘 weak self」;标明优先级(必须改 / 建议 / 随意)。流程纪律:不超过 24 小时响应;评审通过即合并,不留「再改改」的悬案。
feat/bill-sync、fix/crash-on-delete、chore/xcode-26;feat(sync): 支持账单云端同步与离线队列 # 新功能
fix(bill): 修复删除账单时偶发崩溃 # 修复
refactor(domain): 抽取 BillRepositoryProtocol # 重构(不改行为)
perf(startup): 启动耗时 2.4s → 0.8s # 性能
docs: 补充模块化依赖图 # 文档
chore(ci): 升级 Xcode 26 与依赖 # 工程
① 与设计:设计系统(Design System)
DesignKit 模块(第 13 章):色板(语义色 color(.danger) 而非硬编码 #FF3B30)、字阶、间距 token、通用组件(按钮/卡片/toast);② 与后端:接口契约先行
③ 与测试:提测标准
docs/adr/0007-choose-swiftdata.md——背景、选项、决策、后果,一页纸;新人读 ADR 就能理解「为什么代码长这样」;| 误区 | 后果 | 正确做法 |
|---|---|---|
| 规范靠口头与自觉 | 三个月后各自漂移 | 工具执法(Lint/Format)+ CI 门禁 |
| review 走形式(LGTM 秒批) | 质量门禁失效 | 检查清单 + 响应时限 + PR 足够小 |
| review 评人格不评代码 | 团队互信瓦解 | 对事不对人 + 明确优先级 |
| 巨型 PR(几千行) | 无人认真看,带病合并 | 拆分 PR / feature flag 渐进合并 |
| 注释写「是什么」且过期误导 | 比没注释更糟 | 只写「为什么」;改代码同步改注释 |
| 与后端「联调对赌」:字段靠猜 | 联调期返工内耗 | 契约文档 + mock 先行 |
| 文档只有开头写一次 | 快速过期成误导 | 文档随 PR 更新(改架构必须带 ADR/README diff) |
| 测试账号/密钥提交进仓库 | 凭据泄漏 | 密钥服务/一次性提供 + 轮换机制 |
.swiftlint.yml、.swiftformat、.editorconfig——克隆即生效,风格零争论。里程碑 M15(把前面章节的约定打包成「团队规范包」,这就是你未来带团队的雏形):
no_force_unwrap 与 ViewModel 架构规则)+ SwiftFormat + EditorConfig 入仓,CI --strict;docs/api-contract.md 记录响应包结构、错误码表、分页约定;Mock 先行开发流程图一页;api-contract.md(响应包、错误码、分页、幂等键),并设计一个 mock 数据源供前端先行。所属阶段:五、企业级实践 | 预计学习时长:4~5 天 | 前置章节:第 15 章
学完本章,你应该能够:
开发者账号(Apple Developer Program,$99/年)
│
① 构建:Xcode Archive / fastlane(第 15 章)→ .ipa
│
② 上传:Transporter / fastlane deliver / Xcode Organizer → App Store Connect
│
③ 商店页配置:元数据、截图、分级、隐私声明、定价与地区
│
④ TestFlight 内测/外测(真实设备全流程回归)
│
⑤ 提交审核(1~3 天常见)→ 通过 → 定时发布 / Phased Release 灰度
│
⑥ 上线后:崩溃监控、评价管理、版本迭代
第 1 章的概念在此全部落地。发布环节需要:
com.ledgerbook.demo),勾选所需 capabilities(推送、IAP 等);经验:本地 Xcode Automatically manage signing 适合个人与初学;团队环境用 fastlane match 统一管理(第 15 章)。
三种常用途径:
# ① Xcode:Product → Archive → Organizer → Distribute App → App Store Connect(新手友好)
# ② Transporter(独立 App,拖入 .ipa 上传,可看详细校验错误)
# ③ fastlane(自动化,第 15 章 beta lane 的 deliver 部分)
fastlane deliver --skip_screenshots --skip_metadata # 仅上传构建
上传后 ASC 会做自动处理(10 分钟~数小时):重新签名、病毒扫描、生成 TestFlight 可用性。期间状态为 Processing,完成会收到邮件。若校验失败(如缺图标尺寸、隐私清单缺失),Transporter 会给出具体错误——比 Xcode Organizer 的报错更可读。
基本信息:名称(30 字符,ASO 核心位)、副标题(30 字符)、关键词(100 字符,逗号分隔,不写空格)、描述(首三行决定用户是否展开)、支持 URL、隐私政策 URL(必填,哪怕个人开发者也要一个可访问的页面)。
截图与预览(审核必看,用户转化第一要素):
App 分级(Age Rating):问卷如实填写(含暴力/赌博/医疗等内容),决定分级与部分地区可用性。
定价与销售范围:免费/内购;首次上架常见免费 + 内购解锁高级功能(轻记账:免费记账 + Pro 订阅解锁统计导出)。
App 隐私(Privacy Nutrition Label)与隐私清单:
PrivacyInfo.xcprivacy(Required Reason API 声明:UserDefaults、文件时间戳、系统启动时间等的使用理由),第三方 SDK 也应提供各自清单——缺失是近年高频拒审点;提审配置:版本发布选项(手动发布 / 自动 / Phased Release 7 天灰度——大版本推荐)、审核联系信息、演示账号(App 有登录必须提供可用的演示账号,测试员打不开登录页直接拒审)、审核备注(说明特殊功能、测试指引、深链入口)。
常见拒审原因 Top 清单(结合 Guideline 编号记忆):
| Guideline | 问题 | 预防 |
|---|---|---|
| 2.1 性能 | 崩溃、白屏、主要功能不可用 | TestFlight 全流程回归 + 崩溃监控清零 P0 |
| 2.3 元数据 | 截图与实际不符、副标题堆竞品词 | 截图真实;关键词不写他人商标 |
| 3.1.1 支付 | 数字商品绕过 IAP(如引导微信买会员) | 数字内容/订阅必须走 StoreKit;实物/服务不受限 |
| 4.2 最小功能 | 只是网页壳/功能过于单薄 | 确保原生价值,避免「网站套壳」 |
| 5.1.1 数据 | 权限描述空泛、收集与声明不符 | 第 6、17.4 节三方一致性自检 |
| 5.1.2 | 未经许可使用用户数据、第三方内容侵权 | 隐私政策 + 授权链路完整 |
拒审应对:
中国区特殊项:App 需 ICP 备案号(2023 年起工信部要求,无备案号不上架中国区);名称/内容符合本地法规;主体为公司账号时资质文件齐全。
SKStoreReviewController / SwiftUI requestReview 在「完成一次记账」等正向时刻温和请求评价(一年 3 次上限),差评走反馈渠道;| 误区 | 后果 | 正确做法 |
|---|---|---|
| 首次提审才开始准备隐私政策、演示账号 | 反复打回,节奏全乱 | 提审前按 Checklist 全项预检(17.8) |
| 截图用「理想效果图」与实际不符 | 2.3 拒审 | 截图真实 + 展示图用官方工具做 |
| 数字会员引导外部支付 | 3.1.1 拒审甚至下架 | 数字内容一律 IAP;外部支付只限实物/服务 |
| 权限描述「为了更好的体验」 | 5.1.1 拒审 | 具体到功能场景(第 6 章) |
| 演示账号密码过期/余额耗尽 | 审核员进不去 → 拒审 | 专用审核账号 + 审核备注注明注意事项 |
| 用加急审核当常规通道 | 被降权,真加急时批不下来 | 留给 P0 事故 |
| 审核通过即全量发布 | 新版本重大问题影响全部用户 | 大版本用 Phased Release 7 天灰度 + 崩溃监控值守 |
| 忘记 ICP 备案 | 中国区无法上架 | 立项即启动备案(周期以周计) |
[ ] 构建:最新 commit 通过全部门禁,build 号正确
[ ] 真机矩阵回归:新旧 iPhone × 最新两个 iOS 版本
[ ] 崩溃监控:TestFlight 期间 P0/P1 清零
[ ] 元数据:截图/描述/关键词与功能一致,新增权限已更新描述
[ ] 隐私:App Privacy 声明与实际收集一致;PrivacyInfo.xcprivacy 已更新
[ ] 支付:IAP 商品状态可用,沙盒购买测试通过
[ ] 审核材料:演示账号有效、审核备注含深链/测试指引
[ ] 中国区:ICP 备案号已填入
[ ] 发布策略:Phased Release / 定时发布已选
[ ] 回滚预案:上个版本可紧急恢复(构建保留 + 移除销售流程演练)
store-assets/,随版本迭代更新;里程碑 M16(全书毕业项目):
PrivacyInfo.xcprivacy(声明 UserDefaults 使用理由);IAP 配置 Pro 订阅(月/年两档 + 免费试用);PrivacyInfo.xcprivacy,声明 UserDefaults 与文件时间戳的使用理由,随构建归档验证无告警。随用随查的资源索引。资源以官方与社区公认的高质量来源为主,注意甄别时效(Apple 技术一年一代)。
| 资源 | 用途 |
|---|---|
| Apple Developer 官网 | SDK 文档、下载中心(历史 Xcode .xip) |
| Swift.org | Swift 语言官方:语言指南、演进提案、SwiftPM 文档 |
| Swift Book(The Swift Programming Language) | 语言权威参考,第 2、3 章的深入阅读材料 |
| WWDC 视频(Apple Developer App / 官网) | 每年 6 月新特性第一手讲解;按 Session 编号检索经典场次 |
| Human Interface Guidelines (HIG) | 交互与视觉规范;第 6、17 章设计决策的依据 |
| App Review Guidelines | 第 17 章拒审条目的原始出处,提审前必读 |
| Xcode 内置文档(⌥点击符号 / ⌘⇧0 Developer Documentation) | 最快最准的 API 手册 |
| 项目 | 学习点 |
|---|---|
| SwiftLint / SwiftFormat | 规范工具本尊;大型 SPM 工程组织范本 |
| Alamofire | 网络层封装思想(第 7 章对照阅读) |
| GRDB.swift | SQLite 最佳实践;并发与数据库结合的典范 |
| Kingfisher | 图片库:下载/缓存/解码全链路(第 10、11 章实战参照) |
| swift-composable-architecture (TCA) | 函数式架构思想(第 12 章延伸) |
| Tencent/lemon-cleaner 等」国内团队开源 | 本土企业工程实践参考 |
读源码方法:先读 README 与核心目录结构 → 只读一条完整链路(如「发起请求到解析完成」)→ 带着「如果是我会怎么写」的问题对比。
| 方向 | 内容 | 前置 |
|---|---|---|
| SwiftUI 深水区 | 自定义布局(Layout 协议)、动画曲线、GraphicsContext 绘制、Metal 联动 | 第 5 章 |
| 跨 Apple 平台 | macOS/iPadOS(Catalyst 与原生)、watchOS、visionOS | 第 5、12 章 |
| 多媒体与图形 | AVFoundation 音视频、Core ML 端侧模型、ARKit | 第 9 章并发扎实后 |
| 安全与逆向防御 | 越狱检测、证书校验(SSL Pinning)、数据加密 | 第 7、8 章 |
| 工程效能 | Bazel/SPM 缓存体系、组件二进制化流水线、研发效能度量 | 第 13、15 章 |
| 面试与作品集 | 把轻记账写到「可以演示」的水位:上线链接 + 架构文档 + 性能数据(第 11/15/17 章的产物就是作品集素材) | 全书 |
Q:每天只有 1 小时,怎么学? 按章的「动手练习」拆天:一天读半章正文 + 逐日完成练习。宁可慢不可跳——第 3、9、10 章是地基,地基不牢后面全是空中楼阁。
Q:SwiftUI 还是 UIKit 先学? 本教程的路线(第 4 章 UIKit 心智 → 第 5 章 SwiftUI 主线)是为「进企业」设计的。纯兴趣向可以先 SwiftUI,但面试与存量维护绕不开 UIKit,建议按本教程顺序。
Q:需要学 Objective-C 吗? 读懂即可(存量代码、C/C++ 桥接会用到),不必作为主线。能在 UIKit 代码里看懂 OC 语法的 delegate/property 修饰符就够起步。
Q:没有 Mac 怎么开始? 云 Mac 过渡(第 1 章),边学边攒预算。语言基础部分(第 2、3 章)可用 swift.org 的 Linux/Windows 工具链先行,但界面部分必须 macOS。
Q:做完轻记账算什么水平? 完成全部 16 个里程碑并上架,约等于一年经验的初级开发者水位 + 完整的工程素养——足以在面试中展示「独立交付能力」,也足以在企业里快速接手真实项目。
全书的实战载体。每章的「典型业务场景」都对应本说明书的某个功能或里程碑;每完成一个里程碑做一次自查。
ledgerbook://bill/<id>、ledgerbook://add?category=<c>。App 壳(组装根 + Router)
├─ LedgerFeature / StatsFeature / ProfileFeature 业务模块(互不依赖)
├─ DesignKit / NetworkKit / StorageKit / FoundationKit 基建模块
└─ Domain:Bill / LedgerService / BillRepositoryProtocol(纯 Swift)
BillRecord,金额为分);Token 走 Keychain;@MainActor,共享状态 actor 化(RateCache、AuthManager)。| 里程碑 | 交付物 | 章节 |
|---|---|---|
| M1 | 项目初始化:仓库、环境文档、真机运行 | 第 1 章 |
| M2 | 领域模型:Bill/分类/统计函数(Playground 验证) | 第 2、3 章 |
| M3 | UIKit 版账单列表(列表/详情/复用) | 第 4 章 |
| M4 | SwiftUI 主框架:三 Tab + 首页 + 添加 sheet | 第 5 章 |
| M5 | 交互闭环:校验、通知提醒、深链 | 第 6 章 |
| M6 | 网络层:登录、账单分页、Token 刷新、汇率接口 | 第 7 章 |
| M7 | 持久化:SwiftData 存储 + 离线优先同步骨架 | 第 8 章 |
| M8 | 并发引擎:SyncEngine、RateCache、可取消 | 第 9 章 |
| M9 | 内存治理:图片下采样、NSCache、泄漏清零 | 第 10 章 |
| M10 | 性能:启动打点与优化至 <1s、帧率达标 | 第 11 章 |
| M11 | 架构重构:分层落位、Domain 纯净、ViewModel 可测 | 第 12 章 |
| M12 | 模块化:四个基建包 + 三个业务包 + 路由接线 | 第 13 章 |
| M13 | 测试体系:单元/集成/UI 三层 + 覆盖率看板 | 第 14 章 |
| M14 | CI/CD:PR 门禁 + tag 出包到 TestFlight | 第 15 章 |
| M15 | 规范包:Lint/PR 模板/ADR/契约文档 | 第 16 章 |
| M16 | 上架:商店页、TestFlight 外测、提审(或外测全流程) | 第 17 章 |
任一回答是「不能」,回到对应章节的动手练习补齐再前进。
| 阶段 | 每日 1.5h | 每日 3h |
|---|---|---|
| 一(M1~M2) | 2 周 | 1 周 |
| 二(M3~M5) | 3 周 | 1.5 周 |
| 三(M6~M8) | 3 周 | 1.5 周 |
| 四(M9~M10) | 2 周 | 1 周 |
| 五(M11~M16) | 4 周 | 2 周 |
| 合计 | 约 14 周 | 约 7 周 |
追求速度的正确方式不是跳过里程碑,而是压缩「重读」环节、把练习一次做扎实。
ios-dev-tutorial/
README.md
.mimosa/
hook-status/
sess_f72e1f0d-43b2-439d-bb72-885616d5037b-107567ee62.json
appendix/
A-learning-resources.md
B-capstone-project.md
stage1-foundation/
01-environment-setup.md
02-swift-basics.md
03-swift-advanced.md
stage2-ui/
04-uikit-fundamentals.md
05-swiftui.md
06-interactions-navigation.md
stage3-core-skills/
07-networking.md
08-persistence.md
09-concurrency.md
stage4-quality/
10-memory-management.md
11-performance-optimization.md
stage5-enterprise/
12-architecture.md
13-modularization.md
14-testing.md
15-ci-cd.md
16-code-standards-collaboration.md
17-app-store.md完整源码 Gitee 仓库浏览 →