iOS 应用开发教程

Swift · UIKit · SwiftUI · 环境搭建、Swift 语言基础与进阶、UIKit 与 SwiftUI 双栈、数据存储、网络与并发,直至毕业项目与学习资源
← 返回教程馆
6阶段
20章节
20源文档
12 周计划配套生态

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
stage1 found0/0
stage2 ui0/0
stage3 core 0/0
stage4 quali0/0
stage5 enter0/0
appendix0/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

📖 iOS 开发从零到企业级 · 系统自学教程 README.md

面向初学者的 iOS 开发完整学习路径:从第一次打开 Xcode,到独立负责企业级 App 的架构设计、测试、持续集成与 App Store 上架。

  • 写作基准:macOS + Xcode 26 / iOS 26 / Swift 6.x(2026 年 9 月)。较低版本的 Xcode 与 iOS 同样适用,个别新 API(如 @Observable、Swift Testing、SwiftData)文中会标注最低系统要求。
  • 学习方式:按阶段顺序自学,每章都配有「学习目标 → 核心知识点 → 常见误区 → 最佳实践 → 典型业务场景 → 动手练习」六个板块。
  • 贯穿实战项目:全书围绕一个完整的真实业务项目 —— 「轻记账 LedgerBook」(个人记账 App)展开。每一章都会把这个项目往前推进一步,从空项目一直做到提审上架。项目需求说明书见 附录 B。

适合谁读

  • 有任意一门编程语言基础(哪怕是学校里学过的 C/Python),想转做 iOS 开发的初学者。
  • 已经「会写几个页面」,但想补齐工程化能力(架构、测试、CI、上架)的初级开发者。
  • 目标是达到企业级开发水平——即能在真实团队中独立交付、维护一个 App,而不只是能跑通 Demo。

硬件前提: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 贯穿项目需求说明书 「轻记账」完整需求与里程碑 贯穿全程

如何使用本教程

1. 每章的固定结构

每章都由以下板块组成,建议按顺序阅读:

板块 作用
本章学习目标 开工前先明确「学完你应该能做什么」,读完用它自检
核心知识点 正文主体,概念 + 代码示例 + 讲解
常见误区 初学者最容易踩的坑,每条都给出后果与正确做法
最佳实践 企业项目中沉淀下来的工程惯例,从第一天就照做
典型业务场景 用「轻记账」项目的真实需求演示知识点的落地方式
动手练习 必做。iOS 是练出来的,不做练习 = 没学

2. 推荐学习节奏

  • 不要跳章。第 3、9、10 章是全书的地基(Swift 进阶、并发、内存),后面所有内容都建立在其上。
  • 代码必须亲手敲。每章的业务场景代码建议在一个真实 Xcode 项目里跟着写,而不是复制粘贴。
  • 每个阶段结束做一次「里程碑自查」(见附录 B),达不到就回炉,再往后走。
  • 学完阶段三后,可以并行推进「重读 + 重构」:用新学的架构/测试知识回头重构之前写的代码,这是从「会写」到「写好」的关键动作。

3. 贯穿项目:轻记账 LedgerBook

全书所有业务场景都基于这一个项目,它的功能刻意覆盖了企业 App 的典型技术面:

业务功能 对应知识点
记一笔账(分类、金额、备注) 表单交互、键盘处理、数据模型
账单列表与月度统计 列表性能、图表绘制、日期处理
云端同步与登录 网络层封装、鉴权、Token 刷新
离线可用 持久化、本地与远端数据合并
汇率换算(多币种记账) 并发请求、缓存、actor
每月记账提醒 本地通知、权限
多人团队协作开发 架构、模块化、规范、CI
上架 App Store 签名、隐私合规、提审

学习成果自检(学完全书你应该能够)

  1. 独立从零搭建开发环境、创建项目并在真机运行。
  2. 熟练使用 Swift 与 SwiftUI/UIKit 构建具备真实复杂度的界面。
  3. 设计并封装网络层、持久层,正确处理多线程与线程安全。
  4. 用 Instruments 定位内存泄漏与性能瓶颈并给出优化方案。
  5. 主持一个项目的架构设计,完成模块化拆分并建立测试与 CI 流水线。
  6. 独立走完证书管理、TestFlight 分发、App Store 提审与拒审应对全流程。

如果以上六条你都能自信回答「能」,你就具备了企业级 iOS 开发者的入门水位,可以带着作品集去面试或承担真实项目了。

📄 第 1 章 开发环境搭建 01-environment-setup.md

所属阶段:一、环境与语言基础 | 预计学习时长:3~4 天 | 前置章节:无

本章学习目标

学完本章,你应该能够:

  • 独立完成 macOS + Xcode + Apple ID 的完整开发环境搭建;
  • 说清楚 Xcode 工程的基本概念:Project、Target、Bundle ID、Scheme;
  • 在模拟器上运行第一个 App,并用免费开发者账号把 App 装到自己的 iPhone 上;
  • 理解免费账号与付费开发者账号(Apple Developer Program)的区别,知道什么时候必须付费;
  • 掌握日常开发最常用的 Xcode 快捷键与调试面板。

核心知识点

1.1 iOS 开发的硬件与系统前提

iOS 应用只能用 Apple 官方工具链构建,而官方工具链(Xcode)只能运行在 macOS 上。这不是「建议」,而是硬性要求:签名、打包、上架的完整链路都离不开 macOS。

方案 说明 适合谁
Mac 真机(推荐) MacBook / Mac mini / iMac 均可,8G 内存起步、16G 舒适 所有人
云 Mac(MacStadium、租用云主机等) 远程桌面到 macOS 虚拟机 没有苹果硬件、预算有限的过渡方案
Windows 上跑虚拟机(黑苹果) 违反 Apple EULA,且 Xcode + 模拟器对显卡/CPU 要求高,体验差 仅试水,不建议长期使用

注意:本教程假设你在真机 Mac 上操作。云 Mac 方案在「真机调试」环节会遇到 USB 直连困难,需要额外手段(无线调试),初学阶段尽量用真机 Mac。

1.2 安装 Xcode

Xcode 是 iOS 开发的 IDE,包含编译器(Swift/Clang)、iOS SDK、模拟器、调试器(LLDB)、界面构建器等全套工具。

安装步骤:

  1. 打开 App Store,搜索 Xcode,点击安装(约 10GB+,下载和解压都比较久,耐心等待)。
  2. 首次启动会提示安装 additional components(命令行工具、模拟器运行时等),同意并输入密码。
  3. 打开终端(Terminal.app)确认安装成功:
# 查看 Xcode 版本
xcodebuild -version

# 若提示需要指定命令行工具路径,执行:
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer

# 确认 Swift 编译器可用(应输出版本号)
swift --version

几个初学者必须知道的版本常识:

  • Xcode 版本与 iOS 系统版本绑定:要在 iOS 26 设备上调试,必须用支持 iOS 26 的 Xcode(对应 Xcode 26 系列)。Apple 每年 6 月 WWDC 发布新系统、9 月正式推送,老 Xcode 无法真机调试新系统。
  • 保留旧版 Xcode:企业项目往往依赖特定 Xcode 版本。建议从 developer.apple.com/download/all(需免费 Apple ID)下载 .xip 归档留档;新版 Xcode 解压后重命名为 Xcode 26.app 之类并存,用 xcode-select 切换:
    sudo xcode-select --switch /Applications/Xcode26.app
    
  • 不要在正式项目上使用 Beta 版 Xcode:Beta 语法/API 随时可能变动,上架要求也可能不匹配。

1.3 Xcode 界面导览

首次打开 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、图标、编译配置。

1.4 模拟器(Simulator)

  • 快捷键 ⌘R 编译并运行,默认跑在模拟器上。通过 ⌘⇧2(Devices and Simulators)或工具栏左侧的 scheme 选择器切换设备。
  • 模拟器支持:旋转、暗色模式切换(Features → Toggle Appearance)、位置模拟(Features → Location)、语言切换(自定义 scheme 的 App Language)、深链(xcrun simctl openurl booted "ledgerbook://bill/42")。
  • 模拟器能覆盖 90% 的日常开发,但有明显的能力差异,不能完全替代真机:
    • 无真实摄像头、无法准确测 GPS/蓝牙/运动传感器行为;
    • 性能数据(帧率、内存、耗电)不可信——Mac 性能远强于 iPhone;
    • 推送通知的完整链路、StoreKit 真实购买、蜂窝网络场景必须在真机验证。

1.5 真机调试

免费 Apple ID 即可真机调试,步骤:

  1. iPhone 用数据线连 Mac,手机上点「信任此电脑」。
  2. Xcode → Settings → Accounts → 左下角 + 登录你的 Apple ID。
  3. 项目 → Target → Signing & Capabilities:勾选 Automatically manage signing,Team 选你的 Personal Team,Bundle ID 保持唯一。
  4. 顶部设备选择器选你的 iPhone,⌘R 运行。首次会报错提示不受信任:到手机 设置 → 通用 → VPN与设备管理 里信任该开发者证书。
  5. 再次 ⌘R,App 即安装并启动。

免费账号的硬性限制(务必记住):

  • 证书 7 天过期,过期后 App 打不开,需要重新连 Xcode 运行一次;
  • 同时最多安装 3 个 App;
  • 不能使用推送(APNs)、CloudKit、App Groups、StoreKit 真实内购等高级能力。

付费 Apple Developer Program(每年 99 美元 / 688 元)解锁:真机证书一年有效、TestFlight、App Store 上架、推送等全部能力。只有准备上架或企业分发时才需要付费,前三个阶段的学习免费账号完全够用。

企业内部分发还有一种 Apple Developer Enterprise Program(年费 299 美元),只能分发给公司员工、不能上 App Store,且申请门槛高,初学阶段不用纠结。

1.6 开发者账号与签名体系(概念铺垫)

iOS 的安全模型核心是代码签名:任何能在 iPhone 上运行的二进制都必须被一个 Apple 认可的证书签名。涉及三件套:

  • Certificate(证书):证明「你是被 Apple 授权的开发者」,存于钥匙串;
  • App ID:标识「哪个 App」,由 Bundle ID(或通配符)+ 能力(capabilities)组成;
  • Provisioning Profile(描述文件):把证书、App ID、设备列表绑在一起的可安装「许可证」。

初学阶段用 Automatically manage signing 让 Xcode 全自动处理即可;第 15、17 章会深入手动管理证书与团队协作场景(fastlane match)。

1.7 必备辅助工具

# 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

1.8 日常高频快捷键

快捷键 功能
⌘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,多版本并存

最佳实践

  • 第一天就配置好代码仓库:本地 git + 远程仓库 + .gitignore。iOS 常用 gitignore.io 生成 Xcode 模板,排除 DerivedData/、*.xcuserstate 等本地产物。
  • 项目结构先定骨架:在根目录建 LedgerBook/(源码)、Tests/、Tools/、docs/ 分区,后面章节的架构会在此基础上演进。
  • 统一团队 Xcode 设置:Settings → Text Editing 里勾选「Line numbers」「Trim trailing whitespace」「Convert to spaces (4)」,避免团队 diff 噪音。
  • 学会看报错而不是背报错:编译错误先看 Build 报告里的第一条 error(⌘9),而不是最后一条;运行崩溃先看底部控制台的异常栈。
  • 版本策略:跟随正式版 iOS 大版本更新(每年 9 月),但项目 deployment target(最低支持版本)按业务定——企业项目常见保守值(如支持到前两代系统),个人项目可激进些。

典型业务场景:初始化「轻记账」项目

按附录 B 的里程碑 M1,本章要完成的实际工作:

  1. 创建 Xcode 项目:名称 LedgerBook,Organization Identifier com.ledgerbook.demo(换成你自己的),Interface 选 SwiftUI,勾选 Include Tests。
  2. 确认 Signing & Capabilities 里真机签名通过,⌘R 在模拟器和真机上各运行一次。
  3. 添加 .gitignore,推送到远程仓库。
  4. 在 docs/ 下新建 SETUP.md,记录:Xcode 版本、模拟器机型、签名 Team——这就是企业项目里「新人环境文档」的最小雏形。

完成判据:手机上能看到默认的「Hello, world!」页面,且 git log 里有首次提交。

动手练习

  1. 创建一个新项目 PlaygroundApp,Bundle ID 改成与 LedgerBook 不同的值,同时安装两个 App 到手机,观察它们如何共存。
  2. 把模拟器切换成暗色模式、法文语言环境,运行项目观察默认页面的变化。
  3. 故意把 Bundle ID 删掉一个字符再 ⌘R,观察签名报错信息长什么样,然后恢复。(体会:真遇到签名报错时不慌)
  4. 用 ⌘⇧O 分别搜索 AppDelegate、SceneDelegate、LedgerBookApp,看看 SwiftUI 项目里它们是否还存在(提示:默认没有,第 4 章讲 UIKit 时会解释)。

小结

  • iOS 开发绕不开 macOS + Xcode;环境问题(签名、证书、版本)是初学者第一周的主要时间消耗,属于正常现象。
  • Project/Target/Bundle ID/Scheme 四个概念贯穿全书,现在先记住含义,后面反复用。
  • 模拟器开发快、真机验证准;免费账号 7 天限制不影响学习,上架才需要付费账号。
  • 代码仓库和环境文档从项目第一天就建立——这是企业开发的肌肉记忆。

📄 第 2 章 Swift 语言基础 02-swift-basics.md

所属阶段:一、环境与语言基础 | 预计学习时长:5~7 天 | 前置章节:第 1 章

本章学习目标

学完本章,你应该能够:

  • 熟练使用变量/常量、基本数据类型、字符串、集合(数组/字典/集合);
  • 用 if/switch/for 等控制流组织程序逻辑;
  • 定义和调用带参数标签、默认值的函数;
  • 理解 Swift 最核心的两种自定义类型——结构体与枚举,并能区分值类型与引用类型;
  • 用 Swift 写出「轻记账」的账单数据模型与简单业务逻辑。

核心知识点

本章代码均可在 Xcode 的 Playground(File → New → Playground)或命令行 swift file.swift 中直接运行,建议边读边敲。

2.1 变量、常量与类型推断

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

2.2 字符串与插值

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", ...]

2.3 集合:Array / Dictionary / Set

// 数组:有序,元素同类型
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(["日常", "旅行"])  // 交集 {"日常"}

三个最重要的特性(初学者高频踩坑点):

  1. 越界直接崩溃:categories[99] 运行时崩溃,不像有些语言返回 nil。访问前用 first/last/count 判断,或用可选绑定。
  2. 字典下标返回可选值:monthlyTotal["餐饮"] 的类型是 Double?,因为键可能不存在。
  3. 集合默认是值类型(下一节详述):赋值即拷贝,这与多数语言的引用语义不同。

2.4 控制流

// 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("普通点")
}

2.5 函数

// 基本定义: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 函数是一等公民:可以作为参数和返回值传递(这就是后面闭包的基础)。

2.6 结构体(struct):值类型

企业 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 实例完全不可变。

2.7 枚举(enum):比你想的强大得多

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 的字段」。

2.8 值类型 vs 引用类型(本章最重要的一节)

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 时再改。
  • 金额永远不用 Double 承担业务正确性:轻记账项目的 Bill.amount 本教程为教学简化用 Double,真实企业项目用 Int(分)或 Decimal。这个区别请在项目练习里至少实现过一次 Int 分版本。
  • 命名跟随 Apple API 风格:类型名大驼峰(BillCategory),方法/属性小驼峰;方法名读起来像句子(transfer(from:to:amount:))。
  • 用枚举消灭「魔法字符串/数字」:不要出现 if type == "1",定义 enum BillType: Int { case expense = 1, income }。
  • 函数保持单一职责与短小:一个函数超过一屏就该考虑拆分;参数超过 3~4 个考虑用 struct 封装。
  • 尽早让编译器替你工作:Swift 的类型系统是免费的测试——能用 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 函数式处理。后续章节会持续扩展它。

动手练习

  1. 给 Bill 增加一个计算属性 monthKey: String,返回 "2026-09" 格式(提示:DateFormatter,dateFormat: "yyyy-MM")。
  2. 实现 func topCategories(_ bills: [Bill], top n: Int) -> [(category: BillCategory, total: Double)],返回支出金额最高的 n 个分类。
  3. 把「金额用 Double」改成「金额用 Int(分)」,改造 Bill 和 summarize,并写一个 formatFen(_ fen: Int) -> String 显示为 ¥28.00。思考:改动波及了哪些地方?(体会封装的重要性)
  4. 定义枚举 enum MonthFilter { case all, thisMonth, custom(start: Date, end: Date) },并写一个函数按它过滤账单——练习关联值与 switch 解构。
  5. 在 Playground 里验证 0.1 + 0.2 == 0.3 的结果,再用 Decimal 或分单位重做,体会金额精度问题。

小结

  • Swift 类型安全、值语义优先:struct/enum 是建模主力,class 只在需要共享引用时使用。
  • 字典下标与 first/last 返回可选值、数组越界崩溃——这三个行为必须形成肌肉记忆。
  • 枚举 + 关联值是 Swift 表达「状态」的惯用法,好过魔法字符串。
  • 金额、日期这类业务关键数据要选对类型(分/Decimal、Date + Formatter),不要默认随手 Double/String。

📄 第 3 章 Swift 进阶特性 03-swift-advanced.md

所属阶段:一、环境与语言基础 | 预计学习时长:5~7 天 | 前置章节:第 2 章

本章学习目标

学完本章,你应该能够:

  • 安全地处理可选值,理解 if let / guard let / 可选链 / 空合运算符的使用时机;
  • 读懂并书写各类闭包,理解逃逸闭包与捕获语义(为第 9、10 章的并发与内存打地基);
  • 用协议 + 扩展组织代码,理解「面向协议编程」;
  • 编写泛型函数与带约束的泛型类型;
  • 用 throws/try/Result 做规范的错误处理;
  • 熟练使用 Codable 完成 JSON 与模型的互转——这是第 7 章网络层的直接基础。

核心知识点

3.1 可选类型(Optional)深入

可选类型本质是一个枚举:

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!:

  • 强制解包只在逻辑上保证不可能为 nil 时使用(如刚初始化的资源),且更推荐的写法是把「不可能」变成编译期保证(用非可选类型传递)。
  • 隐式解包可选(IUO)主要出现在与 Interface Builder 关联的属性(@IBOutlet)等框架场景,自己的代码里不要声明。

3.2 闭包(Closure)

闭包是「可以捕获上下文变量的匿名函数」,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,就要想一下「谁持有谁」。

3.3 协议与扩展:面向协议编程

协议定义「能力契约」,是 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)。

3.4 泛型

// 泛型函数:同一逻辑适配所有类型
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> 让「分页列表响应」的逻辑对账单、好友、笔记全部复用。

3.5 错误处理

// ① 定义错误:通常用枚举遵循 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 中

3.6 属性包装器(Property Wrapper)

把「包装逻辑」从字段声明处抽离,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,写入被自动钳制

3.7 访问控制与代码组织

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。
  • 协议驱动可测试性:凡是要「换实现」的地方(存储、网络、时间、随机数),先定义小协议。第 14 章写单元测试时你会回来感谢这一条。
  • 扩展放对文件:给类型加的扩展按「能力」分文件(如 Bill+Display.swift),避免单个巨型文件。
  • 闭包参数超过 2 个、或嵌套超过 2 层时改用具名函数/方法,可读性优先。
  • 善用编译器合成: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,只需换注入对象,业务代码一行不改——这就是企业代码「面向接口」的最小完整样例。

动手练习

  1. 给 LedgerService 增加 func deleteExpense(id: String) throws,并处理 billNotFound 错误;故意删除不存在的 id,验证错误文案。
  2. 写泛型函数 func groupBy<T, K: Hashable>(_ items: [T], by key: (T) -> K) -> [K: [T]],用它把账单按分类分组、按月份分组(一次实现,两处使用)。
  3. 定义协议 RateProviding { func rate(of currency: String) async throws -> Double }(先照抄 async 语法,第 9 章细讲),写一个写死的实现和一个随机失败的实现;给 LedgerService 注入两种实现分别观察。
  4. 用属性包装器实现 @Truncated(max: Int):字符串写入自动截断(模拟备注字段长度限制)。
  5. 把 Bill 的 Codable 合成打开(struct Bill: Codable),用 JSONEncoder 编码再 JSONDecoder 解码,打印两个 JSON 观察字段名——为第 7 章网络层做准备。

小结

  • 可选值是 Swift 安全性的第一道防线:guard let 提前退出 + ?? 默认值 + 可选链,让 ! 几乎没有生存空间。
  • 闭包是 Swift 回调的载体;逃逸闭包会延长生命周期并捕获 self,是并发(第 9 章)与内存(第 10 章)问题的源头,现在埋下意识。
  • 协议 + 扩展 + 泛型是 Swift 组织复杂度的三件套;「面向协议 + 构造器注入」是可测试架构的地基。
  • 错误处理三板斧:业务错误用枚举 + throws;异步回调用 Result;try?/try! 是特殊工具不是默认选项。
  • Codable 与协议抽象将直接支撑第 7、8 章的网络与持久化。

📄 第 4 章 UIKit 基础 04-uikit-fundamentals.md

所属阶段:二、界面构建与交互 | 预计学习时长:7~10 天 | 前置章节:第 2、3 章

本章学习目标

学完本章,你应该能够:

  • 理解 App 与界面的生命周期(AppDelegate / SceneDelegate / UIViewController);
  • 掌握视图体系与坐标系统(frame vs bounds);
  • 用代码约束(Auto Layout)搭建自适应布局,理解约束冲突与歧义;
  • 用 UITableView/UICollectionView 高效渲染长列表,理解 Cell 复用机制;
  • 组织导航结构(NavigationController / TabBarController / Modal);
  • 能读懂并维护存量 UIKit 代码——这是企业招聘的真实日常。

核心知识点

4.1 为什么 SwiftUI 时代还要学 UIKit

  • 存量项目:市场上大量企业 App(尤其国内大厂)核心代码是 UIKit 写的,维护与迭代需求巨大;
  • 深度能力:动画精细控制、复杂手势、转场自定义、底层性能调优(离屏渲染等),UIKit 表达力更强;
  • 混合开发:SwiftUI 项目也经常嵌 UIKit 视图兜底(第 5 章讲互操作)。

本教程的策略:第 4 章建立 UIKit 心智模型(企业生存必备),第 5 章以 SwiftUI 为主线开发「轻记账」,两者互操作都掌握。

4.2 App 与场景生命周期

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 章展开)。

4.3 视图体系与坐标系

  • UIView 是一切可见元素的基类;UIControl(按钮、开关)在 UIView 之上增加了事件响应;UIResponder 链处理触摸事件。
  • 视图树:addSubview / removeFromSuperview;UI 操作必须在主线程(第 9 章展开)。
  • 坐标系:
view.frame   // 在「父视图坐标系」中的位置与大小 —— 用于布局
view.bounds  // 在「自身坐标系」中的位置与大小 —— 用于内部绘制/内容布局

经典陷阱:旋转或 transform 后 frame 的值会变化/不可靠,此时用 bounds 与 center。滚动视图的 contentSize、contentOffset 也是 bounds 体系。

4.4 Auto Layout 与代码约束

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

4.5 UITableView 与 Cell 复用(UIKit 性能的命门)

列表是 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)
}

4.6 导航结构

// 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)

页面间传值的正规姿势:

  • 前进(push/present)传数据:目标页构造参数注入(DetailViewController(bill:)),简单直接;
  • 返回带数据:闭包回调或 delegate 协议,不要用通知、更不要用全局单例塞数据。

4.7 提醒与弹窗

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

最佳实践

  • 新项目 UI 技术选型:SwiftUI 为主(开发效率高),复杂自定义控件用 UIKit 实现;存量 UIKit 项目逐步混入 SwiftUI。两套都要会读。
  • 布局分层策略:容器用 UIStackView,细节用 anchor;单元格高度交给自动布局(automaticDimension + estimatedRowHeight)。
  • 把「配置 UI」与「配置数据」分离:Cell 只提供 configure(with:),不掺业务判断——为第 12 章 MVVM 铺路。
  • 调试布局三板斧:控制台约束冲突日志(给出冲突约束链)、Xcode Debug → View Debugging → Capture View Hierarchy(3D 视图树)、po view.value(forKey: "_autolayoutTrace")。
  • 暗色模式与动态字体从第一天支持:用系统语义色(.label、.systemBackground)与 preferredFont(forTextStyle:),不要硬编码黑白色和字号——这是 App Store 审核与体验的双重要求。

典型业务场景:UIKit 版账单列表页

对应「轻记账」里程碑 M3:一个支持分组、标红大额支出的账单列表(如上面 4.5 的代码),加上:

  1. 顶部「本月支出」汇总卡(用 StackView + 两个 label 实现);
  2. UITableViewCell 自定义 BillCell,收入绿色/支出红色,备注一行内省略;
  3. 右上角 + 按钮 present 添加页,添加完成后通过闭包把新账单传回并刷新 Snapshot;
  4. 空列表时展示占位视图(tableView.backgroundView)。

动手练习

  1. 用纯代码(不用 storyboard)实现:一个 NavigationController + 3 行的简单列表,点击任意一行 push 到详情页显示「第 N 行」。
  2. 给 BillCell 增加「删除」滑动操作(UISwipeActionsConfiguration),删除后用 Diffable Snapshot 局部刷新。
  3. 故意制造一个约束冲突(两个视图互相矛盾),观察控制台输出,再按日志修复——练习读冲突日志。
  4. 把列表页顶部汇总卡与列表一起放进 UIScrollView?不行——正确做法是 tableHeaderView。实现它。(体会 UITableView 自滚动的特殊性)
  5. (进阶)用 UICollectionViewCompositionalLayout 实现「顶部横滑分类条 + 下方账单流」的混合布局。

小结

  • UIKit 心智模型:生命周期(vc 四段式)、视图树 + frame/bounds、约束系统、Cell 复用、导航栈——这五块构成读懂任何存量代码的字典。
  • Cell 复用与主线程刷新是 UIKit 性能两大地基;Diffable Data Source 是现代列表数据流。
  • 传值用「构造注入 + 闭包/delegate」,不要通知满天飞。
  • 即使主攻 SwiftUI,UIKit 的约束/复用/生命周期思维也会迁移过去(很多概念 SwiftUI 用更声明式的方式重新表达)。

📄 第 5 章 SwiftUI 声明式界面 05-swiftui.md

所属阶段:二、界面构建与交互 | 预计学习时长:7~10 天 | 前置章节:第 3、4 章

本章学习目标

学完本章,你应该能够:

  • 理解声明式 UI 与命令式 UI 的本质区别;
  • 熟练使用 SwiftUI 基础组件、布局容器与修饰符;
  • 正确使用状态管理工具:@State、@Binding、@Observable、@Environment,搞清「数据放哪儿」;
  • 用 NavigationStack、sheet、TabView 组织页面导航;
  • 编写 SwiftUI 与 UIKit 的互操作代码;
  • 用 SwiftUI 实现轻记账的核心页面。

核心知识点

5.1 两种范式:声明式 vs 命令式

// 命令式(UIKit):你告诉系统「一步步怎么改」
amountLabel.text = "¥28.50"          // 状态变化后,手动更新每个 UI

// 声明式(SwiftUI):你描述「UI 是状态的函数」,状态变了 UI 自动重算
Text("¥\(amount.rmb)")               // amount 一变,这行自动重新求值

SwiftUI 的核心公式:UI = f(state)。你永远不「修改」界面,只修改状态,框架负责diff 与重绘。理解了这一点,@State、body 重算、Modifier 顺序等一切怪现象都会变得合理。

5.2 基础组件与布局

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 在红底外面

5.3 状态管理:最重要的章节

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

5.4 导航与页面组织

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 用于底部标签。

5.5 列表、表单与常用交互组件

// 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("删除后不可恢复")
}

5.6 动画与转场

声明式动画:状态改变本身带动画语义。

Button(isExpanded ? "收起" : "展开") {
    withAnimation(.snappy) {          // 包住状态修改,所有受影响 UI 自动动画
        isExpanded.toggle()
    }
}

// 隐式动画:视图绑定条件自动过渡
Circle()
    .scaleEffect(syncing ? 1.2 : 1.0)
    .animation(.easeInOut.repeatForever(), value: syncing)

原则:动画修饰 value: 一定要指定状态,否则动画行为不可控。

5.7 与 UIKit 互操作

// ① 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 兜底。

5.8 视图拆分与性能意识

body 会在依赖状态变化时重新求值——这是设计使然,但两个纪律要守住:

  • 拆小视图:body 里塞几百行 → 任何状态变化都重算全部;拆成小组件后,SwiftUI 只重算依赖变化状态的那部分。
  • 重计算移出 body:排序、过滤、统计放 ViewModel(@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)

最佳实践

  • 分层:View(只描述 UI)→ ViewModel/Model(@Observable 类持有状态与业务方法)→ Service/Store(第 3 章的 BillStore 协议)。View 里不写业务逻辑。
  • 组件最小化原则:能拆的视图都拆出来命名——既是性能手段,也是复用手段。
  • 金额展示统一走扩展:amount.rmb + monospacedDigit(),全 App 一处定义。
  • 预览(Preview)驱动开发:#Preview 宏写多状态预览(正常/空数据/超长文案/暗色模式),不用反复跑模拟器:
#Preview("正常") {
    BillRow(title: "午饭", amount: 28)
}
#Preview("暗色模式") {
    BillRow(title: "午饭", amount: 28)
        .preferredColorScheme(.dark)
}
  • 迁移策略:接手老 UIKit 项目时,新页面 SwiftUI + UIHostingController 嵌入,存量页面不动,风险最小。

典型业务场景:用 SwiftUI 重写轻记账首页

里程碑 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。这样界面、业务、存储三层各自可替换、可测试。

动手练习

  1. 实现 AddBillSheet:金额输入(数字键盘)、分类 Picker、方向切换(支出/收入)、保存按钮校验金额后调 model.add 并 dismiss(@Environment(\.dismiss))。
  2. 给首页加「空状态」:bills.isEmpty 时显示占位插画与引导文案(拆成 EmptyStateView)。
  3. 写 SummaryHeader 的三种 #Preview:零支出、正常、超长金额(¥12,345,678.00)。
  4. 用 NavigationStack(path:) 实现「从统计页点击某分类 → 直接 push 到已筛选好的账单列表」(练习编程式导航)。
  5. (混合开发练习)新建一个 UIKit 的 EmptyDemoViewController,在其中用 UIHostingController 嵌入你的 EmptyStateView。

小结

  • 声明式 UI 的心智:修改状态,不操作界面;body 是状态的纯函数。
  • 状态工具的分工口诀:私有临时 @State、跨层共享 @Observable + @Environment、子改父 @Binding。
  • NavigationStack 的 value/destination 模式让导航类型安全、可编程。
  • SwiftUI 不替代 UIKit,而是与它互操作共存;企业开发者两套范式都要熟。
  • 拆小视图 + 重计算移出 body,是 SwiftUI 性能的两条铁律。

📄 第 6 章 常用交互实现 06-interactions-navigation.md

所属阶段:二、界面构建与交互 | 预计学习时长:5~7 天 | 前置章节:第 5 章

本章学习目标

学完本章,你应该能够:

  • 处理表单交互的完整闭环:输入焦点、键盘避让、校验与提交;
  • 实现常用手势与下拉刷新、搜索等列表交互;
  • 正确申请与处理系统权限(相册、相机、定位、通知),写出合规的用途描述;
  • 发送本地通知(记账提醒场景);
  • 实现深链(URL Scheme / Universal Links)与 App 间分享;
  • 理解复杂 App 的导航组织方式(Tab + Stack + Modal 的组合拳与路由思想)。

核心知识点

6.1 表单与键盘:交互质量的基本盘

焦点管理(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 }
}

6.2 手势

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 交互一致性。

6.3 权限系统:每一步都是审核红线

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 一启动就连环弹权限框是差评重灾区)。

6.4 本地通知:记账提醒

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;远程推送属于服务端范畴,本地通知学习已覆盖权限与调度的心智。

6.5 深链与分享

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")
}

6.6 导航组织:复杂 App 的路由思想

页面多了以后,「谁能跳转到谁」会失控。企业级约定:

层级一: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,集中管理「检查状态 → 请求 → 拒绝引导」,业务页面只调一个方法。拒绝态文案和跳设置按钮做成通用组件。
  • 表单校验三态:未输入(不禁用但可提示)、非法(即时提示)、可提交(按钮激活)。别用「提交后弹 alert 报错」这种最低级交互。
  • 通知走配置中心:提醒时间、开关做成用户可配置,存 UserDefaults(第 8 章),App 启动时按配置重排通知。
  • 深链路由表文档化:列出所有 ledgerbook:// 链接与参数,给运营、增长团队用——深链是增长活动的基础设施。
  • UIKit 对照学习:本节每个 SwiftUI API 都有 UIKit 对应物(UIAlertController、UNUserNotificationCenter 通用、application(_:open:) 等),混编项目里要能两栖。

典型业务场景:记账的完整交互闭环

里程碑 M5:从「点击 +」到「保存成功」的完整体验。

  1. 点击 + → 半屏 sheet 弹出 AddBillSheet,标题栏自动聚焦;
  2. 金额键盘为数字键盘;输入非法金额时下方红字实时提示(AddBillForm.amountError);
  3. 「保存分类」常用项置顶(Picker 前 3 个显示图标);
  4. 保存成功 → sheet 自动关闭 → 列表局部刷新 → 顶部 toast「已保存」(自研小组件或第三方库);
  5. 首次使用时在「我的」页引导开启「每月记账提醒」通知,拒绝后下次进入显示软引导卡片;
  6. 支持 ledgerbook://add?category=food 深链直达添加页并预选分类。

这个闭环覆盖了本章全部知识点,也是面试时常考的「你负责过什么完整功能」的标准素材——把每一步的交互决策(为什么半屏、为什么实时校验、为什么软引导)说清楚,比堆功能更能体现工程素养。

动手练习

  1. 完成 AddBillSheet 全部校验逻辑:标题为空、金额非法、金额超限(>100 万)三种错误态,用 .alert 或行内提示两种方式各实现一遍。
  2. 实现通知设置页:开关 + 时间选择(DatePicker),关闭时移除通知,修改时重排(提示:先 removeAllPendingNotificationRequests 再重建)。
  3. 处理深链 ledgerbook://bill/<id>:从冷启动打开 App 直达详情页(提示:onOpenURL + Router + path.append,注意冷启动时 Stack 尚未就绪的时序问题)。
  4. 给账单行加 .contextMenu:复制金额、复制备注、跳转编辑;再用 .swipeActions 加「置顶」操作。
  5. (挑战)实现「连续记账打卡」:本地通知每天 21 点检查当日是否已记账,未记账才提醒——需要结合第 8 章持久化后回做。

小结

  • 表单交互 = 焦点 + 键盘 + 校验三件事,SwiftUI 的 @FocusState + Form 自动避让已覆盖大半。
  • 权限是审核与体验的双重红线:描述具体、按需请求、拒绝态有出路。
  • 本地通知与深链是「运营触达」与「增长」的基础设施,实现不难,规范(id 管理、路由表)才是企业级差距。
  • 导航组织靠分层约定(Tab → Stack → Router),不要让页面之间随意认识彼此。

📄 第 7 章 网络请求 07-networking.md

所属阶段:三、核心工程能力 | 预计学习时长:7~10 天 | 前置章节:第 3、5 章

本章学习目标

学完本章,你应该能够:

  • 用 URLSession(含 async/await API)完成 GET/POST 请求并正确处理错误;
  • 用 Codable 与服务端 JSON 结构互转,处理键名映射、日期、嵌套等真实问题;
  • 理解 HTTP 基础(方法、状态码、头、RESTful 约定),能与服务端排查接口问题;
  • 按企业标准封装一个类型安全的网络层(Endpoint 设计、统一错误、Token 鉴权、请求取消);
  • 处理鉴权全流程:登录、Token 存储(Keychain)、401 自动刷新重试;
  • 掌握文件上传下载与弱网策略的基本思路。

核心知识点

7.1 HTTP 基础速通(与服务端说同一种话)

  • 请求:方法(GET 查、POST 增改、PUT/PATCH 改、DELETE 删)+ URL + 头(Content-Type、Authorization…)+ 体。
  • 状态码:2xx 成功;4xx 客户端错(401 未认证、403 无权限、404 不存在、422 参数错);5xx 服务端错。
  • RESTful 约定:GET /bills?page=1 列表、POST /bills 新建、DELETE /bills/42 删除。国内业务后端常见「统一响应包」:{ "code": 0, "msg": "ok", "data": { ... } }——HTTP 200 但业务码非 0 表示业务失败,网络层必须同时处理这两种失败。

调试接口用 Charles/Proxyman/抓包工具看真实请求与响应;开发期也可以直接问后端要接口文档(理想形态是 OpenAPI/Swagger 文档)。

7.2 URLSession 基础与 async/await

现代写法(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 实例统一配置。

7.3 Codable 实战:与服务端 JSON 博弈

真实接口的 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 章分层架构的基石。

7.4 封装企业级网络层

散落在各处的 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 重要。

7.5 鉴权:登录、Token 与 401 自动刷新

完整链路:

  1. 登录:POST /v1/login 拿到 accessToken(短期,如 2h)与 refreshToken(长期);
  2. 存储:Token 是敏感凭据,存 Keychain 而非 UserDefaults(第 8 章给出封装)。任何接口的 AppKey/Secret 只从环境变量、xcconfig 或密钥服务注入,绝不硬编码在源码里(仓库泄漏 = 生产事故);
  3. 携带:请求头 Authorization: Bearer <token>(上面的 send(token:));
  4. 401 自动刷新:收到 401 → 用 refreshToken 换新 accessToken → 重放原请求;刷新也失败 → 登出并跳登录页。

刷新并重放的核心逻辑(用 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()                          // 重放原请求
    }
}

7.6 上传与下载

// 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(存量代码里见到不要慌)。

7.7 弱网与健壮性策略

  • 超时与重试:读请求可重试 1~2 次(指数退避);写请求(提交账单)绝不盲目重试,需幂等键(Idempotency-Key 头)防止重复提交;
  • 请求取消:页面离开时取消未完成请求(SwiftUI 里 Task 生命周期管理,第 9 章);用户连点搜索时取消旧请求(防竞态:旧响应覆盖新响应);
  • 离线策略:请求失败读本地缓存兜底(与第 8 章持久化配合),恢复后同步;
  • 网络状态监听:用 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 模型与领域模型分离,边界处转换

最佳实践

  • 网络层四件套(Endpoint / APIClient / NetworkError / Envelope)作为团队标准模板,新接口 = 新增一个 Endpoint struct,审查成本极低。
  • 所有请求经过唯一出口,便于统一打日志(脱敏后)、埋点、注入公共头(版本号、设备信息)。
  • 日志脱敏:请求日志不打印 Authorization、身份证、手机号等字段——安全审计的硬要求。
  • 环境切换:debug/release/预发环境用 xcconfig + 编译条件切换 baseURL,禁止上线前手改代码切环境。
  • Mock 优先开发:前后端并行时,先用本地 mock 数据开发 UI(第 14 章展示用 URLProtocol mock 的标准做法)。

典型业务场景:账单云同步与多币种汇率

里程碑 M6 为「轻记账」接入真实网络能力:

  1. 拉取账单分页:FetchBillsEndpoint + Envelope<[APIBill]>,翻页合并去重(按 bill.id);
  2. 上传新账单:POST /v1/bills,失败进「待同步队列」(第 8 章本地持久化),App 启动时重试;
  3. 登录与 Token:登录页调 /v1/login,AuthManager actor 管理 401 刷新与重放;Token 存 Keychain;
  4. 汇率接口:GET /v1/rates?base=CNY 每 6 小时缓存一次(配合第 9 章并发),供多币种账单换算;
  5. 弱网演示:断网状态下列表展示本地缓存 + 顶部「离线模式」横幅,联网后自动同步。

动手练习

  1. 用 https://httpbin.org(公开测试服务)练习:GET 带查询参数、POST JSON、模拟 500 错误(/status/500),打印各分支错误文案。
  2. 给 APIClient 增加「请求耗时日志」:打印 path、状态码、毫秒数(不含任何头信息),体会统一出口的价值。
  3. 实现 RefreshTokenEndpoint + AuthManager 最小可运行版(刷新逻辑可以对着假的本地服务模拟),验证「并发 5 个 401 只刷新一次」。
  4. 写一个 MockURLProtocol(参考第 14 章)让 FetchBillsEndpoint 返回固定 JSON,不联网完成整个列表页开发。
  5. (挑战)给上传接口加 Idempotency-Key(用 UUID().uuidString,同一账单重试复用同一 key),防止弱网下重复记账。

小结

  • 现代网络栈 = URLSession async/await + Codable;回调式 API 只需会读。
  • 企业网络层的形态:Endpoint 描述请求、APIClient 唯一出口、错误三层(传输/HTTP/业务码)、统一响应包解包。
  • 鉴权链路:Keychain 存 Token、401 actor 化单飞刷新、重放请求。
  • 模型分层(API 模型 ≠ 领域模型)与统一出口,是网络层「可测试、可维护」的两大支柱。
  • 安全红线:凭据不进源码、日志脱敏、幂等防重。

📄 第 8 章 数据持久化 08-persistence.md

所属阶段:三、核心工程能力 | 预计学习时长:7~10 天 | 前置章节:第 3、7 章

本章学习目标

学完本章,你应该能够:

  • 按「数据的安全级别与体量」正确选择持久化方案(UserDefaults / Keychain / 文件 / SwiftData / Core Data);
  • 熟练使用 SwiftData 完成建模、增删改查、排序过滤与轻量迁移;
  • 理解 Core Data 的概念模型(存量企业代码必备);
  • 掌握沙盒目录规则与备份策略;
  • 用 Repository 模式把存储细节隔离在业务之外,实现「离线优先」的同步架构。

核心知识点

8.1 选型总览:先分类,再选型

持久化决策的两个维度:敏感程度(要不要防提取)与 结构复杂度(键值对还是关系/图)。

方案 适合数据 不适合 加密
UserDefaults 设置开关、上次选中分类、小配置 列表数据、大对象、敏感信息 明文
Keychain Token、密码、密钥 任何大数据 系统级加密
文件(Documents/Caches) 图片缓存、导出的 CSV、日志 需要查询过滤的结构化数据 自理
SwiftData(iOS 17+) 业务模型:账单、笔记、草稿 超大规模数据(亿级行) 自理
Core Data 同上(兼容 iOS 13-、存量项目标配) 同上 自理
直接 SQLite(GRDB) 高性能复杂查询、需要 SQL 控制力 简单场景(杀鸡用牛刀) 自理
Realm(第三方) 跨平台团队、历史项目 新项目(SwiftData/GRDB 已覆盖) 自理

口诀:敏感的进 Keychain,配置进 UserDefaults,业务数据进 SwiftData(新)/Core Data(存量),大文件进沙盒目录。

8.2 UserDefaults:别把它当数据库

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 已自动定期落盘

性质必须记住:

  • 明文存储,用户可通过备份/iCloud 提取——只放非敏感、小体量数据(官方建议 KB 级);
  • 支持类型有限(Bool/Int/Double/String/Date/Array/Dictionary/Data);
  • 对象需先转 Data(PropertyListEncoder),但几十 KB 以上的对象图就该换方案了;
  • App Group(UserDefaults(suiteName: "group.xxx"))用于主 App 与 Widget/扩展共享数据(轻量场景)。

8.3 Keychain:敏感数据的唯一去处

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... 系列)对应设备锁定状态。

8.4 沙盒目录与文件存储

每个 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 备份,是审核与差评的隐形杀手。

8.5 SwiftData:现代业务数据首选(iOS 17+)

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 显式迁移——企业项目上线后模型变更必须走版本化流程(详见误区)。

8.6 Core Data 速览(读懂存量代码)

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 块内操作。

8.7 Repository 模式与离线优先架构

把「数据从哪来」彻底藏起来,业务只见接口:

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,跟业务走),边界处转换。看似啰嗦,换来的是:换存储方案、做迁移、写测试都只动存储层。
  • Repository + 协议:第 3 章的 BillStore 协议在这里全面兑现——内存实现(测试)、SwiftData 实现(生产)随时替换。
  • 金额存分(Int):存储、网络、计算全程用分,仅在展示层格式化。
  • 时间存 UTC 的 Date,展示时按用户时区格式化;避免存「2026-09-01 08:00」这种字符串。
  • 启动时轻量自检:数据库能否打开、迁移是否成功,失败上报埋点——存储问题在灰度期就该被发现。

典型业务场景:离线记账 + 云同步

里程碑 M7 完整落地:

  1. 本地事实源:BillRecord(SwiftData)+ BillStorage 实现协议;
  2. 写入路径:记账 → 本地 upsert(needsSync=true)→ UI 立即刷新(用户无感等待)→ 后台 syncPending() 上传 → 成功标记 needsSync=false;
  3. 读取路径:启动直接展示本地数据 → 后台 pullLatest() 合并服务端增量 → 刷新;
  4. 冲突策略:同 cloudId 双端都改 → 以 updatedAt 新者胜(简单业务够用);账单删除走「软删除标记」避免合并歧义;
  5. 可测试性:InMemoryBillStorage 让同步逻辑可以脱离数据库测试(第 14 章直接复用)。

动手练习

  1. 实现 @UserDefault 属性包装器(泛型 + PropertyListEncoder 支持自定义 Codable 类型),并给「默认记账分类」用上。
  2. 完成 BillStorage 的 SwiftData 实现(upsert 按 cloudId 幂等、软删除字段 deletedAt),补一个内存实现 InMemoryBillStorage。
  3. 写 merge(remoteBills:) 合并逻辑:本地 needsSync 的记录不覆盖,其余以远端为准;构造「双端同时改同一条」的用例验证。
  4. 把「待同步队列」做成可视化的调试页:列表显示每条记录的 needsSync 状态与最后同步时间(做同步功能没有可观测性等于盲飞)。
  5. (挑战)模型迁移演练:给 BillRecord 加 paymentMethod 字段发「v2」,再用旧版本数据库文件启动验证不崩(提示:VersionedSchema,或先体验轻量迁移的自动行为)。

小结

  • 选型口诀:敏感进 Keychain、配置进 UserDefaults、业务数据进 SwiftData/Core Data、大文件进沙盒对应目录。
  • SwiftData(@Model + ModelContainer + ModelContext)是新项目首选;Core Data 概念对位学习以读懂存量代码为目标。
  • 模型版本化迁移与 context 线程规则,是持久化最易翻车的两处。
  • Repository + 离线优先(本地事实源 + 同步通道)是工具类 App 的标准架构,为下一章的并发调度提供舞台。

📄 第 9 章 多线程与并发 09-concurrency.md

所属阶段:三、核心工程能力 | 预计学习时长:7~10 天 | 前置章节:第 3、7、8 章

本章学习目标

学完本章,你应该能够:

  • 理解进程/线程/队列的关系,以及「为什么 UI 必须在主线程刷新」;
  • 使用 GCD 管理队列(串行/并发、sync/async、DispatchGroup),并识别死锁;
  • 以 Swift Concurrency(async/await、Task、TaskGroup、actor、@MainActor、Sendable)为现代主力写并发代码;
  • 识别与修复数据竞争,掌握锁与 actor 两种防护手段;
  • 正确处理任务取消与生命周期(页面销毁后回调不再执行);
  • 用 TaskGroup 实现批量并发同步这类真实业务。

核心知识点

9.1 概念地图

进程(App)
 └─ 线程(CPU 调度单位;线程数远多于核数时靠时间片轮转)
     └─ 队列(GCD:任务的 FIFO 容器;串行队列一次一个,并发队列同时多个)
         └─ 任务(block / Task)

两条铁律:

  • 主线程(main thread)负责 UI 与用户事件。主线程被占 >100ms 用户就能感到掉帧(60Hz 每帧 16.7ms)。耗时工作(网络、解码、大计算、IO)一律离开主线程。
  • 共享可变状态是万恶之源。两个线程同时读写同一变量 → 数据竞争(结果不可预测、偶发崩溃)。并发的本质工作是:让该并行的并行,让共享的状态安全。

9.2 GCD:既懂原理也要会读存量代码

// ① 切换线程:耗时工作放全局并发队列(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。

9.3 async/await 与 Task

// 异步函数:可能挂起(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)
    }
}

关键认知:

  • await 是「挂起点」:函数在此让出线程,线程去干别的,完成后恢复执行。挂起 ≠ 新开线程,Swift 会高效调度线程池。
  • 结构化并发:子任务生命周期挂在父任务上——父取消则子取消,父等所有子完成。这与 GCD 的「发出去就不管」本质不同,是防泄漏的机制保障。
  • @MainActor:标注「这段代码必须在主线程执行」。SwiftUI 的 View body 天然是 @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 处恢复
        ...
    }
}

9.4 TaskGroup:批量并发

需求: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)
}

9.5 actor 与 Sendable:数据竞争的系统级解法

数据竞争的两个条件:共享 + 可变 + 并发访问。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 最通用)。不要在同一份数据上混用多种机制。

9.6 任务取消与生命周期

// 视图离开 → .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 静默处理,别弹「操作失败」;
  • UIKit 场景手动管理: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、哪些 lock 保护」,团队按图施工(第 16 章规范文档的一部分)。
  • ViewModel 一律 @MainActor,纯逻辑服务类不标注(由调用方调度),共享状态类做成 actor——三分法清晰可执行。
  • 打开严格并发检查:新项目直接 Swift 6 语言模式;存量项目先开「Targeted/Complete Checking」逐 Target 清理。
  • 并发度控制:批量任务限制并发路数(避免 100 个并发请求打挂服务器/被限流)。
  • 主线程哨兵:Debug 构建开启 Main Thread Checker(Xcode 默认开启),线程违规立即报错。

典型业务场景:账单云同步的并发设计

里程碑 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] 防引擎被页面持有(下一章展开)。

动手练习

  1. 用 Playground 复现死锁:主线程 DispatchQueue.main.sync(⚠️ 会卡死 Playground,保存好再跑),观察现象并解释原因。
  2. 写 RateCache actor,加「并发去重」:同一币种 3 个并发请求只发一次网络(提示:缓存 in-flight Task)。验证方法:打印网络调用次数。
  3. 把 fetchAllCategoryStats 改成「最多 3 路并发」的版本(提示:分批 addTask + 等待批次完成),对比总耗时。
  4. 给 SyncEngine 加「重试与退避」:失败后 1s/2s/4s 重试三次,期间可取消。
  5. 打开 Xcode 的 Swift 6 语言模式编译一个含共享 var 的小 demo,逐条读编译器报错并修复——把编译器当老师。

小结

  • 并发的两条铁律:UI 在主线程;共享可变状态需要防护(actor/锁/串行队列)。
  • GCD 是「读懂存量 + 桥接」技能,main.sync 死锁模式必须能一眼识别。
  • Swift Concurrency 是新代码主力:async/await 表达流程、结构化并发防泄漏、actor 隔离状态、Sendable 让编译器审查并发安全。
  • 取消是协作式的:长任务主动 checkCancellation,UI 生命周期结束静默收尾。
  • 下一章你将看到本章 weak self 背后的内存原理——并发与内存是同一个系统的两面。

📄 第 10 章 内存管理与 ARC 10-memory-management.md

所属阶段:四、质量进阶 | 预计学习时长:4~5 天 | 前置章节:第 2、9 章

本章学习目标

学完本章,你应该能够:

  • 说清 ARC 的工作原理,以及 strong / weak / unowned 三种引用的区别与适用场景;
  • 识别三类最常见的循环引用(delegate、逃逸闭包、Timer)并修复;
  • 使用 Xcode Memory Graph Debugger 与 Instruments Leaks 定位内存泄漏;
  • 理解值类型与内存的关系,用 NSCache 做有上限的缓存;
  • 建立「内存预算」意识:什么规模的 App 内存占用算健康。

核心知识点

10.1 ARC:引用计数的自动管理

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 打印

关键认知:

  • ARC 只管 class(引用类型);struct/enum 是值类型,随栈帧/容器直接释放,无计数开销(这是第 2 章「优先 struct」的内存侧理由)。
  • ARC 不是垃圾回收(GC):没有扫描全堆的停顿,释放时机确定、性能可预测,代价是循环引用不会被自动回收——这正是本章要解决的核心问题。
  • 每次引用计数操作都有开销,热点路径上频繁拷贝引用(如循环内传参)编译器会优化,但大对象图的反复强引用仍值得留意。

10.2 三种引用:strong / weak / unowned

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 的崩溃代价不值得冒险

10.3 三大循环引用现场

① 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 同理——任何「被系统全局容器持有」的回调源都要显式销毁。

10.4 定位泄漏:Memory Graph 与 Instruments

Memory Graph Debugger(首选,零配置):App 运行中点击调试栏的内存图按钮(⚠️ 图标类似三条折线):

  1. 触发「应该释放」的操作(如关闭详情页);
  2. 暂停 App,点 Memory Graph;
  3. 搜索泄漏类的名字(如 BillDetailView),若列表里还躺着本应销毁的实例 → 选中它查看 backtrace of references(谁引用着它),闭环一目了然。

Instruments → Leaks:定时扫描无法到达的对象(真泄漏);配合 Allocations 看对象增长趋势(内存持续上涨 = 可能的泄漏或缓存失控)。

日常快速手段:

  • deinit 里 print("deinit \(Self.self)"),关键控制器/ViewModel 全部加上,页面关闭看日志——最朴素的泄漏回归测试;
  • Debug Memory Graph 里观察 VM: Tracker 区的虚拟内存是否随操作线性上涨。

10.5 值类型、缓存与内存预算

值类型减少引用管理:数据模型用 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 走查纳入迭代流程

最佳实践

  • 「deinit 必达」约定:控制器、ViewModel、长生命周期服务类统一在 deinit 打日志;每次迭代验收跑一遍「进出页面 × 10」看日志是否成对。
  • 闭包捕获规范(写进团队规范,第 16 章):非逃逸直接用 self;逃逸闭包 [weak self] guard let self;异步任务优先 .task/结构化并发,让框架管理生命周期。
  • 大资源显式管理:图片下采样、NSCache 上限、didReceiveMemoryWarning 释放非必需资源。
  • ** autoreleasepool**:一次循环创建大量临时对象时(如批量导出 CSV)用 autoreleasepool { } 分批释放,削峰。
  • 上线监控:MetricKit 有内存告警数据(第 11 章),把「内存超标」当作与崩溃同级的线上事故。

典型业务场景:详情页泄漏排查实战

「轻记账」里程碑 M9 的真实排查剧本:

  1. 症状:反复进出「账单详情页」(带大图展示),Xcode 内存仪表从 80MB 涨到 500MB 不回落。
  2. 第一步(Memory Graph):发现 23 个存活的 BillDetailViewModel(应该只有 1 个)。引用链显示:BillDetailViewModel → ReceiptLoader → imageCompletion 闭包 → BillDetailViewModel。
  3. 修复一(循环引用):ReceiptLoader 的完成回调改 [weak self],闭包捕获断链;deinit 日志开始成对出现。
  4. 修复二(内存峰值):详情页大图仍 45MB/张,改用 ImageIO 下采样到屏幕尺寸 3 倍像素,单张降到约 1.2MB;缩略图走 NSCache(上限 50MB)。
  5. 回归:进出 30 次,内存稳定在 120±10MB(缓存命中率起效),符合预算。

这个过程就是企业排查泄漏的标准动作:现象 → Memory Graph 找闭环 → 修引用 → 管峰值 → 数据化回归。

动手练习

  1. 故意写一个「ViewModel 持有 service、service 逃逸闭包持有 ViewModel」的泄漏 demo,用 deinit 日志确认泄漏,再用 weak self 修复并验证。
  2. 用 Memory Graph Debugger 观察练习 1 泄漏时的引用链,截图引用关系(面试可讲)。
  3. 实现 ReceiptImageCache,并写「进出图片详情页 10 次」的内存曲线对比:原图 vs 下采样(Instruments Allocations 或 Xcode 内存仪表)。
  4. 把第 9 章 SyncEngine 的 Task { [weak self] in ... } 删掉 weak 再跑,思考:这里会泄漏吗?什么条件下会?(提示:Task 未完成前持有 self;任务短暂则影响小,但规范统一更安全)
  5. (挑战)给 App 增加「内存自检」调试页:实时显示 footprint(os_proc_available_memory)、缓存条数与占用,团队共享排查用。

小结

  • ARC 确定性强、无 GC 停顿,但循环引用要人管——delegate、逃逸闭包、Timer 是三大现场。
  • weak 是默认答案,unowned 是专家工具;「deinit 必达」让泄漏无处藏身。
  • Memory Graph Debugger 找闭环、Instruments 看趋势,两件工具覆盖 95% 排查。
  • 值类型建模 + NSCache 上限 + 图片下采样,是内存预算的三大工程抓手。

📄 第 11 章 性能优化 11-performance-optimization.md

所属阶段:四、质量进阶 | 预计学习时长:5~7 天 | 前置章节:第 9、10 章

本章学习目标

学完本章,你应该能够:

  • 树立「度量先行」的性能工作方法,会用 Instruments(Time Profiler / Core Animation / Allocations)定位问题;
  • 掌握启动优化的系统方法(启动阶段拆解、任务分级、延迟初始化);
  • 识别并治理卡顿(主线程过载、列表性能、离屏渲染);
  • 掌握包体积、耗电优化的常见手段;
  • 用 MetricKit 建立线上性能监控,让优化效果可度量、可回归。

核心知识点

11.1 方法论:没有度量就没有优化

性能优化的错误姿势是「感觉慢就到处加缓存」。正确流程:

设定指标 → 建立基线 → 定位瓶颈 → 小步优化 → 复测对比 → 持续监控

核心指标与工具对照:

指标 工具
启动耗时 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 构建为准。

11.2 启动优化:第一印象工程

启动耗时 = 冷启动(进程从无到 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 之后」各占多少。没有打点的启动优化等于盲调。

11.3 卡顿治理:主线程与渲染

卡顿的本质:一帧内主线程没干完活。三类病灶与药方:

① 主线程过载——Time Profiler 里找主线程上最重的调用栈:

  • 同步磁盘 IO(大 JSON 解析、图片写盘)→ 移到后台(Task.detached / actor);
  • 过度复杂的 body 重算(第 5 章纪律)→ 拆视图、缓存计算结果;
  • 串行的多次网络等待 → async let / TaskGroup 并发。

② 列表性能(App 卡顿重灾区):

  • SwiftUI:List/LazyVStack(不要 VStack 套 1000 行)、行视图拆小、行内不创建重对象;
  • UIKit:cell 复用 + prepareForReuse(第 4 章)、行高预计算缓存、图片异步解码(decodingQueue + 下采样);
  • 通用:避免在滚动路径上做同步数据库查询(提前预取)、避免滚动时触发布局抖动(固定尺寸的骨架)。

③ 离屏渲染(UIKit 深水区):圆角+裁剪、阴影、蒙版等触发 GPU 额外离屏缓冲。Core Animation 模板勾选 Color Offscreen-Rendered 后黄色区域即问题点:

// ❌ 触发离屏渲染的组合
imageView.layer.cornerRadius = 12
imageView.layer.masksToBounds = true
// ✅ 静态图:预处理成圆角位图;或用 contentsRect/蒙版图;卡片圆角放容器层裁剪

SwiftUI 下这个问题被框架大幅缓解,但「滚动区域叠大量半透明与阴影」依然会掉帧——用 Debug 导航器的帧率监控验证。

11.4 包体积优化

App 包超过一定大小影响下载转化率(蜂窝网络下的「需 Wi-Fi 下载」门槛),也反映工程健康度:

  • 资源瘦身:未使用图片扫描(开源工具或脚本比对资源名引用)、大图改 WebP(兼容处理)/按需下载、音频压缩、去掉重复切图;
  • 字体按需:全量中文字体动辄 10MB+,只打包子集;
  • 架构层:模块二进制化(XCFramework,第 13 章)降低编译产物膨胀、-Osize 优化级别、strip 调试符号(Release 配置);
  • 按需资源(ODR):大资源打标签按需下载(教程类 App 的课程素材等)。

度量:Xcode Organizer / App Store Connect 的 App Size 报告(下载大小 vs 安装大小),每次发版记录趋势。

11.5 耗电与后台任务

耗电大头:定位、网络、后台唤醒。记账 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 时间」与「网络唤醒次数」。

11.6 MetricKit:线上性能监控

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 系统调度 + 请求合并

最佳实践

  • 性能预算写进需求:「启动 < 1s」「列表滚动 55fps+」「内存 < 150MB」,超预算即缺陷,而不是「优化愿望」。
  • 每个版本发布前的性能回归清单(固定动作):冷启动 3 次取中位、核心列表滚动观察帧率、进出页面 10 次看内存、后台挂起 1 小时看耗电告警。
  • os_signpost / OSSignposter 打点代替裸打印:可被 Instruments 直读,零侵入常驻线上。
  • 低端机思维:拿性能最弱的在售机型(如 SE 系列)当基准测试机——你的用户里有相当比例。
  • 优化 PR 附对比数据(Before/After 截图):让性能工作可见、可评审、可积累。

典型业务场景:轻记账启动优化实录

里程碑 M10 剧本(延续第 10 章的方法论,从内存扩展到启动):

  1. 基线:真机(iPhone SE 3)冷启动 P50 = 2.4s(打点分布:main 之前 0.3s / didFinishLaunching 1.1s / 首帧 1.0s)。
  2. 定位:Time Profiler 显示 didFinishLaunching 里同步做了:SwiftData 迁移检查(0.4s)、汇率全量拉取(0.5s,网络串行)、统计 SDK 初始化(0.2s)。
  3. 优化:
    • 迁移检查挪到后台 actor,首帧只校验「库可打开」;
    • 汇率改为 Task.detached 启动后异步预热(界面本来 6 小时才刷新一次);
    • SDK 初始化移到首帧后的 deferred 队列;
    • 首页列表加骨架屏(体感优化)+ 本地缓存先行渲染(网络回来再刷新)。
  4. 复测:P50 = 0.8s(main 前 0.3 / launch 0.2 / 首帧 0.3),改善 3 倍。
  5. 守护:启动打点常驻上报 MetricKit + 自建后端,周报看 P50/P90;CI 里加启动时间冒烟(第 15 章流水线)。

动手练习

  1. 给轻记账加启动打点:App.init、首帧 onAppear、数据就绪三个时间点,输出到日志并计算总耗时(这是你后续所有优化的基线)。
  2. 用 Instruments Time Profiler 找出练习 1 中的热点函数,截图火焰图,解释最重的栈在做什么。
  3. 制造一个列表卡顿:VStack 塞 2000 行(每行含图片解码),再改 List + 下采样缩略图,用 Core Animation 模板对比 FPS。
  4. 给 RateCache 的网络请求加 OSSignposter 区间打点,Instruments 里观察「缓存命中 vs 真实请求」的耗时差异。
  5. (挑战)接入 MetricKit(真机 + Ad Hoc/TestFlight 构建才有数据),跑一周后导出启动耗时直方图,与本地基线对比。

小结

  • 性能工作 = 度量驱动:指标 → 基线 → 定位 → 优化 → 复测 → 线上监控,六步缺一不可。
  • 启动优化核心是任务分级(必须 / 异步 / 懒加载),把首帧前的时间压到最小。
  • 卡顿三病灶:主线程过载、列表低效、离屏渲染;工具是 Time Profiler 与 Core Animation 模板。
  • 包体积与耗电是「隐性口碑」,瘦身靠资源治理,省电靠系统调度。
  • MetricKit 让优化从「一次性运动」变成「可持续的团队指标」——这是个人项目与企业项目的分水岭。

📄 第 12 章 项目架构设计 12-architecture.md

所属阶段:五、企业级实践 | 预计学习时长:5~7 天 | 前置章节:第 3、5、7、8 章

本章学习目标

学完本章,你应该能够:

  • 说清架构要解决的真实问题(可测试、可维护、可协作),而不只是背名词;
  • 理解 MVC 的缺陷,掌握 MVVM 及其在 SwiftUI/UIKit 两种形态下的落法;
  • 设计分层架构(Presentation / Domain / Data)并用依赖注入串联;
  • 用 Coordinator/Router 把导航从页面中解耦;
  • 给一个真实项目做出「够用就好」的架构决策并文档化。

核心知识点

12.1 架构解决什么问题

没有架构约束的项目会自然腐化:ViewController 长到 3000 行(Massive View Controller)、业务逻辑散落在 UI 回调里、改一处崩三处、新人三个月不敢动代码。

架构的本质是三件事:

  1. 职责分离:UI 展示、业务规则、数据获取各自归位;
  2. 依赖方向:外层依赖内层(UI → 业务 → 数据),内层不知道外层存在——这保证业务逻辑可以脱离界面测试;
  3. 约定大于讨论:新代码放哪里、怎么命名、怎么传依赖,团队不再争论。

12.2 从 MVC 说起

Apple 官方 MVC:Model(数据)— View(展示)— Controller(胶水)。问题在于 Controller 同时承担「页面生命周期 + 布局协调 + 业务编排 + 数据转换」,M 和 V 越轻,C 越重。MVC 不是错的——Apple 生态的控件体系就是围绕它设计的——但复杂业务页面用它必然失控。

12.3 MVVM:当前主流

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——这是它可测试的全部前提。

12.4 分层架构:给 MVVM 加上纵深

页面规模上去后,MVVM 之上再分层(Clean Architecture 的简化实用版):

┌─ Presentation 层 ────────────── View + ViewModel + Router(SwiftUI/UIKit,可整体替换)
├─ Domain 层 ──────────────────── 领域模型(Bill)+ 业务服务(LedgerService)+ 协议(BillRepositoryProtocol)
└─ Data 层 ────────────────────── Repository 实现 + 网络模型/存储模型 + APIClient/SwiftData

依赖方向单向向下:Presentation → Domain ← Data(Domain 定义的协议由 Data 实现,依赖倒置)。价值兑现:

  • 换数据库(SwiftData → GRDB):只动 Data 层;
  • 换 UI 框架(UIKit → SwiftUI):只动 Presentation 层;
  • 业务规则(「单日支出超 500 提醒」)只活在 Domain 层,测试它不需要任何 UI 与数据库。

12.5 依赖注入(DI)

// ① 构造器注入(默认首选):依赖显式、必然、不可变
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% 场景;框架解决的是「作用域生命周期」复杂问题,用不上时是负担。

12.6 Coordinator / Router:导航也是架构

第 6 章的 Router 就是 SwiftUI 时代的 Coordinator:把「页面间怎么跳」从页面里拿出来,好处:

  • 页面不知道彼此存在(BillListView 只发 router.openBill(id));
  • 深链/推送点击/通知跳转天然复用同一路由;
  • 页面流可整体重构(比如把 push 改 sheet)不动页面代码。

UIKit 项目用经典 Coordinator 模式:每个流程一个 Coordinator 对象,持有 NavigationController,创建子页面并接线,返回值用闭包——存量代码里大量存在,要会读。

12.7 模式怎么选(决策表)

场景 推荐 理由
小工具 / 个人项目(< 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/
  • 每个 ViewModel 一个文件、每页一个状态枚举:页面状态用 enum 而不是散落的 Bool 组合(isLoading && !hasError && ... 的组合爆炸是 bug 温床)。
  • ADR(架构决策记录):重大选型(如「用 SwiftData 而非 Core Data」)写半页纸:背景、选项、决策、后果——第 16 章协作规范的一部分。
  • 架构守护自动化:SwiftLint 自定义规则 + 代码评审 checklist 检查「ViewModel 不得 import UIKit」「View 不得直接调用 APIClient」。

典型业务场景:轻记账架构重构

里程碑 M11:把前面各章积累的代码正式重组为上面的目录结构,并完成一次「架构收益验证」:

  1. 抽取 Domain:Bill、LedgerService、BillRepositoryProtocol 移入 Domain,确认该目录零框架 import(用 lint 验证);
  2. Data 层收编:第 7 章 APIClient/Endpoints、第 8 章 SwiftData 存储归位;APIBill → Bill 转换写进 Data 层;
  3. Presentation 重组:三个 Tab 的 View + ViewModel + Router 就位,页面状态全部枚举化;
  4. 收益验证(这是关键动作,证明架构不是纸面文章):
    • 写 BillListViewModelTests(第 14 章):注入 InMemoryBillRepository,不启动任何 UI/数据库即可测试全部页面状态——30 秒跑完,以前要靠手点模拟器;
    • 演示「换存储」:把 SwiftData 实现换成 InMemory,App 一行业务代码不改地跑起来。

动手练习

  1. 按目录结构重组你的轻记账工程,用 grep -r "import SwiftUI" Domain/ 验证 Domain 层纯净。
  2. 把某页面的 Bool 状态(isLoading/showError/isEmpty)重构成单一 ViewState 枚举,处理所有分支。
  3. 实现 ErrorView(message:retry:) 通用组件并接入错误态(含「重试」回调链路)。
  4. 写 AddBillViewModel:校验、保存、成功后关闭 sheet 的完整用例,保证它不 import 任何 UI 框架。
  5. (挑战)为「从通知点进某账单」写通路由:通知点击 → Router.openBill(id) → 首页 Tab 切换 → push 详情——体会导航收口的价值。

小结

  • 架构的目的是分离职责、约束依赖、固化约定,一切选型以「可测试、可替换、可协作」为检验标准。
  • MVVM 是当前主流:SwiftUI 用 @Observable ViewModel 天然落地;分层(Presentation/Domain/Data)提供纵深。
  • 依赖注入靠构造器 + 组装根;导航靠 Router/Coordinator 收口。
  • 渐进重构 + ADR 文档化 + lint 守护,让架构活在代码里而不是 PPT 里。

📄 第 13 章 模块化开发 13-modularization.md

所属阶段:五、企业级实践 | 预计学习时长:5~7 天 | 前置章节:第 12 章

本章学习目标

学完本章,你应该能够:

  • 判断一个项目什么时候需要模块化,以及模块怎么划分;
  • 用 Swift Package Manager(SPM)创建、组织与发布模块;
  • 设计模块间的依赖关系(分层依赖图),避免循环依赖;
  • 实现模块间通信的三种主流方式(协议注册 / 路由 / 依赖注入容器)并知道取舍;
  • 理解二进制化(XCFramework)与私有源在企业中的用途。

核心知识点

13.1 什么时候需要模块化

单工程(monolithic target)的痛,团队规模上去后集中爆发:

症状 模块化的收益
全量编译 10 分钟起,改一行等半天 模块独立编译 + 增量缓存
100 人改同一个 App target,冲突不断 团队按模块认领,边界即代码
「工具类」互相 import 成毛线球 依赖图强制单向,违规编译失败
代码无法复用到另一个 App/扩展 模块即复用单元(Widget/手表/新 App 直接 import)

但模块化有成本:接口设计与版本管理的心智负担、包依赖调试更绕、小团队强行拆分是负收益。经验阈值:3 人以下小项目不做;App target 编译 >2 分钟、>5 人并行、或出现第二个壳(Widget/扩展/多 App)时启动。

13.2 划分策略:按层 + 按业务域

成熟企业的模块地图(两层划分法):

                      ┌────────── App 壳(LedgerBook)─────── 组装根、启动配置
                      │
业务模块(横向,互不依赖)│
  LedgerFeature ──── 首页/列表/添加
  StatsFeature ────── 统计
  ProfileFeature ──── 我的/设置
        │ 各自依赖 ↓(只依赖中间件与基础层)
中间件(纵向服务)
  NetworkKit   APIClient、Endpoint 协议、鉴权
  StorageKit   SwiftData 封装、Repository 基建
  DesignKit    设计系统:颜色、字体、通用组件(第 16 章与设计协作)
  UtilityKit   日志、格式化、扩展
        │ 依赖 ↓
基础层(几乎无依赖)
  FoundationKit  纯 Swift 工具(金额格式化、日期)

铁律:

  • 业务模块之间不互相依赖(StatsFeature 不得 import LedgerFeature)——跳转通过路由(13.5);
  • 依赖只能向下,基础层不得 import 上层任何东西;
  • 每个模块自带的 README 说明「提供什么、怎么接入」。

13.3 SPM:Swift 生态的标准答案

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 的变更就是版本变更。

13.4 接口设计:模块的「门面」

模块对外的全部信息都在它的 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(...) { ... }
}

准则:

  • public 最小化:能 internal 的绝不 public;改 public API = 升 major 版本 = 全体依赖方评估;
  • 依赖以协议进入:模块声明「我需要什么能力」,不关心谁提供;
  • 资源本地化:模块内的图片/文案/本地化文件跟随模块打包,不共享主工程资源(避免资源名冲突)。

13.5 模块间通信:三种主流方案

业务模块互不依赖,但「账单页跳统计页」仍要发生:

① 协议注册(服务定位风格):公共层定义协议,各模块启动时注册实现,调用方按协议取:

// 公共层 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 传业务对象(不可追踪、类型不安全)。

13.6 二进制化与私有源(企业进阶)

  • XCFramework:模块编译成二进制分发(.xcframework),两用途——保护核心代码(不随源码外发)、加速依赖方编译。SPM 支持 binaryTarget;配合 CI 在每次 tag 时自动出包;
  • 私有源:企业自有模块放私有 Git 仓库,CI 用 SSH key/Token 拉取(凭据走 CI 环境变量,不进仓库);Xcode Cloud/GitHub Actions 配置见第 15 章;
  • 缓存:CI 与开发机对 SPM 依赖做缓存(~/Library/Caches/org.swift.swiftpm),大仓库提速明显。

常见误区

误区 后果 正确做法
一上来拆 20 个模块 心智负担爆炸,改一个需求跨 5 个包 从 3~4 个包开始(Design/Network/Storage/Feature),按痛感演进
业务模块之间私下互相 import 依赖图塌方,模块化名存实亡 Package.swift 里就不声明该依赖——让编译器当守门员
public 全量敞开 模块边界消失,任何改动都是破坏性变更 门面类收敛 public;API 变更走版本纪律
循环依赖用「复制一份代码」绕过 两份逻辑漂移,bug 成对出现 把公共部分下沉到中间件/基础层
模块内直接用主工程的资源/配置 模块不可独立编译与测试 资源、配置全部通过依赖注入或模块自带
用 NotificationCenter 传业务数据 数据流不可追踪 路由/协议/注入三种方案
本地包接口随意改不升版本 依赖方突然编译失败 monorepo 也要守 SemVer 心态:public 变更 = 需要评估影响面

最佳实践

  • 先分层、后拆包:先在第 12 章目录结构里把「逻辑分层」做实(Domain/Data/Presentation),拆包只是把目录变成编译边界——顺序颠倒会拆出错误边界。
  • 模块自带 README + 单测:Packages/DesignKit/README.md 写清组件清单与用法;每个包至少有编译级测试目标,CI 分模块跑(见第 15 章)。
  • 依赖图可视化:用脚本从各 Package.swift 提取依赖关系生成图,Code Review 时肉眼检查「有无向上依赖」。
  • 编译时间看板:记录全量构建与单模块构建耗时(xcodebuild - dry-run 或 Xcode 报告),模块化收益用数字说话。
  • 平台能力隔离:Widget/Watch/扩展 App 各自是小壳,只依赖基础层与需要的业务模块——这也是模块化的直接验证场。

典型业务场景:轻记账模块化拆分

里程碑 M12(对照第 12 章的目录结构,把目录升级为包):

  1. 第一刀(基建):FoundationKit(金额/日期格式化,零依赖)→ DesignKit(颜色、BillRowView 等通用组件)→ NetworkKit(APIClient、Endpoint 协议、Token 管理)→ StorageKit(SwiftData 封装);
  2. 第二刀(业务):LedgerFeature(账单列表/添加)、StatsFeature(统计)、ProfileFeature;三包互不依赖,均依赖中间件;
  3. 接线:App/ 组装根注入 BillRepositoryProtocol 实现与 Router;验证 StatsFeature 通过路由打开账单详情;
  4. 收益验证:单独修改 StatsFeature 并编译(Xcode 只重编该包及其依赖,从 40s 降到 8s);新增 Widget 壳直接 import LedgerFeature 复用列表组件。

动手练习

  1. 把第 12 章工程拆出 FoundationKit(纯函数工具)与 DesignKit(通用 UI),主工程改为包依赖,验证各自可独立编译。
  2. 实现「模块门面」:StatsFeatureAPI.rootView() 是 StatsFeature 唯一 public 入口,其余全部 internal。
  3. 用路由方案打通「统计页 → 某分类账单列表」跨模块跳转(两个模块各自定义,App 壳接线)。
  4. 给 DesignKit 写 README(组件清单 + 用法示例)与一个快照/渲染测试目标。
  5. (挑战)写脚本解析所有 Package.swift 输出依赖图(Mermaid 格式),放进 docs/architecture.md 随仓库演进。

小结

  • 模块化解决编译时间、团队边界与代码复用三个问题,但有实施成本——按痛感渐进推进,先分层后拆包。
  • SPM 是标准载体:Package.swift 声明产品/依赖/目标,monorepo 内 path 引用本地包。
  • 模块边界靠「public 最小化 + 依赖只向下 + 业务互不依赖」三条铁律维持,让编译器执法。
  • 通信选型:依赖注入(默认)> 路由(跨模块导航)> 协议注册(松耦合大组织);业务数据不走路由/通知。
  • XCFramework 与私有源是大型企业的标配延伸,初学者了解概念即可。

📄 第 14 章 单元测试 14-testing.md

所属阶段:五、企业级实践 | 预计学习时长:5~7 天 | 前置章节:第 3、7、8、12 章

本章学习目标

学完本章,你应该能够:

  • 理解测试金字塔与「测什么、不测什么」的取舍;
  • 用 XCTest 与新一代 Swift Testing(@Test/#expect)编写可读、可维护的单元测试;
  • 用协议 Mock 测试 ViewModel、Repository 与网络层(URLProtocol 注入假响应);
  • 编写可靠的异步测试(Swift Concurrency 环境下);
  • 知道 UI 测试与快照测试的定位,建立团队测试策略与覆盖率纪律。

核心知识点

14.1 测试金字塔与投入策略

        /  UI 测试 \          少量:核心路径冒烟(慢、脆、贵)
      /  集成测试     \       中量:模块边界(网络层、存储层真跑)
    /   单元测试        \     大量:ViewModel/Service/纯函数(快、稳、便宜)

投入顺序(从回报最高开始):

  1. 纯业务逻辑:金额统计、账单合并、Token 刷新——bug 后果重且回归频繁;
  2. ViewModel 状态机:页面状态枚举的全分支(第 12 章的架构红利就在这里兑现);
  3. 网络/存储边界:协议 Mock 掉外部依赖后,验证调用序列与错误传播;
  4. UI 测试:只测「登录 → 记账 → 查看统计」这类不能坏的关键路径。

反模式预警:追求 100% 覆盖率会催生大量「测实现细节」的僵尸测试——重构改个内部结构就红一片,最后团队干脆删测试。覆盖率是仪表不是目标(企业常见门槛:核心模块 70%+,整体不做硬性要求)。

14.2 Swift Testing:现代测试框架(Xcode 16+)

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 断言(验证结果)。一个测试只验证一件事,失败即定位。

14.3 Mock:让依赖可替换

第 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 生成),但手写协议实现通常更快更可控——优先手写。

14.4 网络层测试:URLProtocol 注入

不联网验证 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/临时容器跑集成测试。

14.5 异步与时间相关测试

  • async 测试:@Test 函数直接标 async,await 被测方法即可——不需要老框架的 expectation 仪式(存量代码见 XCTAssertTrue(fulfilled) 那套即可);
  • 可注入时间:业务里别直接 Date(),注入 now: () -> Date 闭包,测试时喂固定时间——「本月」「7 天前」类逻辑才能确定性验证;
  • 可注入随机/UUID:同理,idGenerator: () -> String,快照对比与幂等合并测试都需要。

14.6 UI 测试与快照测试(定位与分寸)

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。
  • bug 修复先写失败测试:复现 bug 的测试先红、修复后绿、永久留档——这是团队最值钱的资产积累方式。
  • 新功能 PR 必须带测试(团队约定,第 15 章 CI 门禁 + 第 16 章 review checklist 强制执行)。
  • 确定性三件套:注入时间、注入随机源、测试间无共享状态——偶发失败(flaky)测试比没有测试更伤团队信任,出现即修或即删。

典型业务场景:轻记账的测试体系

里程碑 M13 落地三层测试:

  1. 单元层:summarize、账单合并(merge(remoteBills:) 的冲突分支)、AuthManager 刷新去重(并发 5 请求只刷一次——用计数 stub 验证);
  2. ViewModel 层:BillListViewModel 四态全覆盖、AddBillForm 校验矩阵(参数化);
  3. 集成层:APIClient 三结局(URLProtocol)+ SwiftDataBillStorage upsert/软删除(临时容器);
  4. UI 冒烟:「记一笔账 → 列表出现」一条链路;
  5. 看板:CI 输出覆盖率(核心模块 Domain/ViewModels ≥ 70%),周会看趋势。

动手练习

  1. 用 Swift Testing 参数化测试 AddBillForm 的全部校验分支(合法/空标题/非法金额/超限金额)。
  2. 为第 8 章 merge(remoteBills:) 写冲突测试:本地未同步 vs 远端更新,至少 4 个用例。
  3. 用 MockURLProtocol 测试 APIClient 的 401 → 刷新 → 重放链路(stub 返回 401 再 200,断言重试成功)。
  4. 给 BillRowView 写快照测试:支出/收入 × 明暗模式 4 组基准图,故意改样式观察 diff。
  5. (挑战)把你的测试接入 CI(预告第 15 章):GitHub Actions 上跑 xcodebuild test,失败 PR 不能合并。

小结

  • 测试金字塔:单元为主、集成居中、UI 冒烟封顶;投入顺序按「业务后果 × 回归频率」排。
  • Swift Testing(@Test/#expect/参数化)是新默认,XCTest 会读即可。
  • 可测试性 = 面向协议 + 依赖注入 + 可注入时间/随机——全部是第 3、12 章架构决策的回报。
  • 错误分支与失败态必须被测试;flaky 测试零容忍。
  • 测试的价值在 CI 里兑现:下一章把「测试 + 编译 + lint」变成合并门禁。

📄 第 15 章 持续集成与交付 15-ci-cd.md

所属阶段:五、企业级实践 | 预计学习时长:4~6 天 | 前置章节:第 14、13 章

本章学习目标

学完本章,你应该能够:

  • 理解 CI/CD 概念与 iOS 工程的特殊性(签名、模拟器、构建耗时);
  • 搭建一条完整流水线:PR 触发编译 + Lint + 测试,主干出包上传 TestFlight;
  • 用 GitHub Actions(或 Xcode Cloud)自动化 iOS 构建,正确管理密钥与证书;
  • 用 fastlane 自动化打包、证书管理(match)、TestFlight 分发;
  • 建立版本号管理与「一条命令出包」的团队纪律。

核心知识点

15.1 CI/CD 在 iOS 工程中的形态

开发者 push/PR
   │
   ▼
CI 门禁(每次 PR):编译 → SwiftLint → 单元测试 → (可选)UI 冒烟
   │  全绿才允许合并
   ▼
主干(main)合并后:打 tag/手动触发 → Archive → 签名 → 上传 TestFlight
   │
   ▼
TestFlight 内测 → 提审 App Store(第 17 章)

iOS CI 的特殊性(与后端不同的坑):

  • 必须 macOS runner(iOS SDK/模拟器),比 Linux runner 贵且慢;
  • 签名是核心难题:证书在钥匙串、描述文件要匹配,自动化必须系统化管理(match 方案,见 15.4);
  • 构建慢:模拟器启动 + 编译 + 测试,动辄 20~40 分钟——缓存与并行是必修课。

15.2 GitHub Actions 实战

.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

要点:

  • PR 必须过这条流水线才能合并(GitHub 分支保护规则设置 required status check)——CI 的价值一半在自动化,一半在「不容商量」;
  • xcbeautify 美化 xcodebuild 海量输出;-resultBundlePath 保留结果供排查;
  • SPM 缓存按 Package.resolved 哈希命中,能省几分钟;
  • 大仓库可按第 13 章模块拆 job 并行(每个 Package 一个 swift test)。

15.3 secrets 与凭据管理(安全红线)

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
  • 仓库里出现任何可用凭据字面量 = 安全事故(安全扫描与 review 都会拦截);
  • 客户端工程同理:第三方 SDK 的 AppKey 通过 xcconfig + CI 注入环境变量,不提交真实值;
  • Secrets 泄漏的处置:立即轮换(revoke + 重新生成),而不是「下次小心」。

15.4 fastlane:打包与证书自动化

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,两者选一即可,概念互通。

15.5 版本号管理与发布节奏

  • 版本号(CFBundleShortVersionString):面向用户,语义化 2.4.0;发布时定,随 tag 生成;
  • build 号(CFBundleVersion):单调递增(ASC 要求同版本号下 build 不重复),CI 里用 ASC API 取最大值 +1 或用提交计数;
  • 分支模型(中小团队推荐 GitHub Flow):main 保护 + 短生命周期 feature 分支 + PR 门禁;发布直接从 main 出 TestFlight(配合第 17 章的 Phased Release 灰度)。需要多版本并行维护(线上 hotfix)时才引入 release 分支;
  • 发布节奏:固定双周/每月一个 TestFlight 节奏,提交进 main 即可预期地被发走——可预期的发布比快的发布更重要。

15.6 发布后的监控闭环

出包不是终点,流水线尾端接监控(第 11 章 MetricKit + Crashlytics/Sentry):

  • 上传后自动触发符号表(dSYM)上传,保证崩溃可解析;
  • TestFlight 反馈与崩溃自动建 issue(Webhook);
  • 「Crash-free 率 < 99.5% 触发告警」等规则写进团队 SLA。

常见误区

误区 后果 正确做法
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 出内测包——交付能力属于团队而非个人。
  • Fastfile/CI yml 全部入仓,改动走 PR review——基础设施变更与业务代码同标准。
  • 门禁四件套:编译 + Lint + 单元测试 + 关键 UI 冒烟;再按需加性能冒烟(第 11 章的启动时间对比)。
  • PR 小而频繁(< 400 行 diff):CI 快、review 真实、回滚容易。
  • 演练灾难:每季度在干净环境跑一遍「clone → build → 出包」,文档与脚本失效早发现。

典型业务场景:轻记账的完整流水线

里程碑 M14:

  1. PR 门禁:macOS runner + Lint + 单元测试(第 14 章全套)+ SPM 缓存,目标 15 分钟内出结果;
  2. 主干出包:打 tag(v1.0.0-beta.1)触发 fastlane beta → TestFlight 内测组(产品/测试)收到邮件;
  3. 证书:match 私有仓库存证书;ASC API Key 走 Secrets(key_id/issuer/p8 三件套);
  4. 版本:tag 即版本号,build 号 CI 自增;changelog 由 Conventional Commits 生成;
  5. 发布后:dSYM 自动上传、Crash-free 率进周报。

从此「发版」从半天的人工仪式变成 30 分钟的自动化流程,且每个版本可复现——这就是企业级交付的底线水位。

动手练习

  1. 给轻记账仓库配 GitHub Actions:PR 触发 Lint + 单测(可先用 swift test 测你的 SPM 模块,跑通最小闭环)。
  2. 在仓库设置里开启分支保护:main 禁止直接 push、必须过 required check——亲手体验「门禁不容商量」。
  3. 本地安装 fastlane,写 lanes :tests(跑测试)与 :beta(先做到本地 Archive 成功);把 Fastfile 提交进仓库。
  4. 配置一个只依赖 Secrets 的脚本(如打印 ASC_KEY_ID 长度),验证「凭据不落仓库」的注入方式。
  5. (挑战)加一个 nightly job:每天自动跑 UI 冒烟 + 输出启动耗时(第 11 章打点),趋势图贴进团队周报。

小结

  • CI/CD = 门禁(PR 编译/Lint/测试)+ 交付(主干自动出包到 TestFlight)+ 监控(崩溃与性能闭环)。
  • iOS 特有难点是签名与构建耗时:match 共享证书、缓存与并行提效。
  • 凭据一律 Secrets/环境变量注入,泄漏先轮换——这是不可逾越的安全红线。
  • fastlane 把「打包、签名、上传、分发」固化成代码;Xcode Cloud 是官方轻量替代。
  • 发布的可预期性(固定节奏、可复现流程)比单次速度更重要。

📄 第 16 章 代码规范与团队协作 16-code-standards-collaboration.md

所属阶段:五、企业级实践 | 预计学习时长:3~4 天 | 前置章节:第 12~15 章

本章学习目标

学完本章,你应该能够:

  • 用 SwiftLint/SwiftFormat 让代码风格自动化,理解哪些规则该工具管、哪些靠人;
  • 写出符合团队标准的注释与文档(DocC);
  • 进行高质量的 Code Review(作为提交者与评审者两种角色);
  • 遵守团队 Git 工作流与提交规范(Conventional Commits);
  • 理解跨职能协作的接口:与设计(设计系统)、与后端(接口契约)、与测试(提测标准);
  • 用 ADR、README 等手段沉淀团队知识。

核心知识点

16.1 规范的分层:工具管格式,人管设计

规范的终极形态不是文档贴墙,而是自动化执法:

层级 内容 执法者
格式 缩进、换行、空格、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——规范本身也要走规范。

16.2 命名与注释

命名(重读第 2 章原则,补充团队约定):

  • 类型 UpperCamelCase;方法/变量 lowerCamelCase;协议名读起来像能力(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)。

16.3 Code Review:质量与文化的交汇点

作为提交者:

  • PR 粒度 < 400 行 diff,标题一句话说清做了什么,描述里给「动机 + 方案 + 测试证据 + 截图(UI 改动)」;
  • 自查清单先行:Lint 过了吗?新逻辑有测试吗?有没有调试残留(print、注释代码)?
  • 把 review 当「提前读自己的代码」:提交前用 review 视角再读一遍 diff。

作为评审者(检查清单):

维度 问题
正确性 边界条件?错误分支?并发安全(weak self、主线程)?
设计 放对层了吗?public 最小化了吗?有没有更简单的写法?
测试 有没有测?测的是行为还是细节?
安全 有没有凭据/敏感日志?权限用途描述更新了吗?
可维护 命名达意?复杂度是否需要注释「为什么」?

反馈话术:对事不对人——「这里用 weak self 会不会泄漏?」优于「你怎么又忘 weak self」;标明优先级(必须改 / 建议 / 随意)。流程纪律:不超过 24 小时响应;评审通过即合并,不留「再改改」的悬案。

16.4 Git 工作流与提交规范

  • 分支命名:feat/bill-sync、fix/crash-on-delete、chore/xcode-26;
  • Conventional Commits(第 15 章 changelog 自动化的地基):
feat(sync): 支持账单云端同步与离线队列        # 新功能
fix(bill): 修复删除账单时偶发崩溃              # 修复
refactor(domain): 抽取 BillRepositoryProtocol  # 重构(不改行为)
perf(startup): 启动耗时 2.4s → 0.8s            # 性能
docs: 补充模块化依赖图                          # 文档
chore(ci): 升级 Xcode 26 与依赖                # 工程
  • main 保护(第 15 章分支保护)+ 禁止 force push + PR 模板(checklist 内置);
  • 大功能用 feature flag(远程配置开关)合并进 main 再灰度——避免长期分叉的巨型分支。

16.5 跨职能协作的三个接口

① 与设计:设计系统(Design System)

  • 设计语言落成 DesignKit 模块(第 13 章):色板(语义色 color(.danger) 而非硬编码 #FF3B30)、字阶、间距 token、通用组件(按钮/卡片/toast);
  • 与设计师对齐「组件 ↔ 代码」一一对应,新增 UI 先问「DesignKit 有没有现成的」;
  • Figma 的 Token/变量与代码同步(手工对齐或插件导出),暗色模式与动态字体必须两边同时验收。

② 与后端:接口契约先行

  • 接口文档(OpenAPI/Swagger)为准,字段、错误码、分页约定文档化;改动必须提前一个迭代同步客户端;
  • 客户端基于契约先行开发(mock 数据,第 7 章 MockURLProtocol),联调只验「接线」不验「理解」;
  • 灰度与兼容:后端加字段不删字段(向后兼容),客户端对未知字段宽容(Codable 可选 + 自定义解码)。

③ 与测试:提测标准

  • 提测单含:需求范围、自测清单、已知问题、TestFlight 安装链接与测试账号(测试账号也走密钥服务/一次性提供,不入仓);
  • bug 报告模板:复现步骤 + 期望/实际 + 环境(系统版本/机型/版本号)+ 日志;
  • 回归策略:核心路径每次提测必跑(UI 冒烟自动化,第 14 章)。

16.6 知识沉淀:让团队不依赖个人记忆

  • README 分层:根 README(怎么跑起来)→ 模块 README(第 13 章)→ 关键链路文档(同步引擎、鉴权链路各一页时序图);
  • ADR(架构决策记录):docs/adr/0007-choose-swiftdata.md——背景、选项、决策、后果,一页纸;新人读 ADR 就能理解「为什么代码长这样」;
  • 技术债台账:已知债务(如「StatsFeature 仍是 MVC」)登记在 issue 并打标,随迭代消化而非遗忘;
  • 新人路径:环境文档(第 1 章 SETUP.md)→ 第一个 good-first-issue → 一周内出第一个 PR。入职顺畅度是工程文化的试金石。

常见误区

误区 后果 正确做法
规范靠口头与自觉 三个月后各自漂移 工具执法(Lint/Format)+ CI 门禁
review 走形式(LGTM 秒批) 质量门禁失效 检查清单 + 响应时限 + PR 足够小
review 评人格不评代码 团队互信瓦解 对事不对人 + 明确优先级
巨型 PR(几千行) 无人认真看,带病合并 拆分 PR / feature flag 渐进合并
注释写「是什么」且过期误导 比没注释更糟 只写「为什么」;改代码同步改注释
与后端「联调对赌」:字段靠猜 联调期返工内耗 契约文档 + mock 先行
文档只有开头写一次 快速过期成误导 文档随 PR 更新(改架构必须带 ADR/README diff)
测试账号/密钥提交进仓库 凭据泄漏 密钥服务/一次性提供 + 轮换机制

最佳实践

  • 三件套入仓:.swiftlint.yml、.swiftformat、.editorconfig——克隆即生效,风格零争论。
  • PR 模板:动机 / 改动点 / 测试证据 / 截图 / checklist 五段式,GitHub 设置为默认模板。
  • 每周规范回顾(30 分钟):把本周 review 中反复出现的问题沉淀成新规则或新文档——规范是活的。
  • 与设计对齐会固定在迭代第一天的固定议程;接口变更走「契约评审」而非群里一句话。
  • 发言权与知情权分离:新人可以对任何模块提 review 意见(发言权),架构变更需 ADR 评审(知情权与决策权)。

典型业务场景:轻记账团队的协作规范包

里程碑 M15(把前面章节的约定打包成「团队规范包」,这就是你未来带团队的雏形):

  1. 工具链:SwiftLint(含 no_force_unwrap 与 ViewModel 架构规则)+ SwiftFormat + EditorConfig 入仓,CI --strict;
  2. 流程:PR 模板(动机/改动/测试证据/截图/checklist)+ 分支保护 + Conventional Commits + 24h 响应 SLA;
  3. 设计接口:DesignKit README 列出全部组件与语义色 token;与 Figma 变量名一一对应;
  4. 后端接口:docs/api-contract.md 记录响应包结构、错误码表、分页约定;Mock 先行开发流程图一页;
  5. 知识库:ADR 3 篇(选 SwiftData / 选 MVVM+分层 / 选 SPM 模块化)+ 同步引擎时序图 + 新人上手文档。

动手练习

  1. 给轻记账配置 SwiftLint + SwiftFormat,故意提交一个超长行与强制解包,观察 CI 报错,再修复。
  2. 写轻记账的 PR 模板与 CODEOWNERS(指定各目录 reviewer),体验「评审指派自动化」。
  3. 写一篇 ADR:《选择 SwiftData 而非 Core Data》,按背景/选项/决策/后果四段(把你第 8 章的思考文档化)。
  4. 把第 7 章的接口约定整理成一页 api-contract.md(响应包、错误码、分页、幂等键),并设计一个 mock 数据源供前端先行。
  5. (挑战)给自己定一条「本周改进一条协作流程」的循环:本周试行 PR checklists 自动注入,下周试行 ADR 评审——体会规范是迭代出来的。

小结

  • 规范执行靠自动化:SwiftFormat 管格式、SwiftLint 管风格与架构红线、CI 门禁不容商量。
  • 注释写「为什么」,review 写「建议与依据」,commit 写「类型与动机」——每一种文字都有纪律。
  • 协作接口三件套:设计系统(DesignKit)、接口契约(mock 先行)、提测标准(自测清单 + 冒烟)。
  • 知识沉淀(ADR/README/技术债台账)决定团队的上限:把「为什么这样做」留给下一代维护者。
  • 下一章走完最后一公里:把轻记账送上 App Store。

📄 第 17 章 App Store 上架流程 17-app-store.md

所属阶段:五、企业级实践 | 预计学习时长:4~5 天 | 前置章节:第 15 章

本章学习目标

学完本章,你应该能够:

  • 走通从「构建产物」到「App Store 商店页」的完整上架链路;
  • 理解签名体系(App ID / 证书 / 描述文件)在发布环节的角色;
  • 完成 App Store Connect 的元数据、截图、隐私声明等商店页配置;
  • 用 TestFlight 完成内部/外部测试,理解其在审核前的把关作用;
  • 预判常见拒审原因并准备应对(含申诉与加急审核);
  • 建立团队提审 Checklist,把「上架」变成可重复的流程。

核心知识点

17.1 上架全景图

开发者账号(Apple Developer Program,$99/年)
        │
   ① 构建:Xcode Archive / fastlane(第 15 章)→ .ipa
        │
   ② 上传:Transporter / fastlane deliver / Xcode Organizer → App Store Connect
        │
   ③ 商店页配置:元数据、截图、分级、隐私声明、定价与地区
        │
   ④ TestFlight 内测/外测(真实设备全流程回归)
        │
   ⑤ 提交审核(1~3 天常见)→ 通过 → 定时发布 / Phased Release 灰度
        │
   ⑥ 上线后:崩溃监控、评价管理、版本迭代

17.2 签名体系回顾与发布证书

第 1 章的概念在此全部落地。发布环节需要:

  • App ID:显式 Bundle ID(com.ledgerbook.demo),勾选所需 capabilities(推送、IAP 等);
  • Distribution Certificate(发布证书):App Store 分发用,每个账号最多 2~3 个(个人/公司账号数量限制不同),团队共享管理(match,第 15 章);
  • Provisioning Profile(App Store 类型):绑定 App ID + 发布证书;Xcode 自动签名时透明处理;
  • 上传后 ASC 会重新签名(App Store 签名),与本地ipa的签名不同属正常现象。

经验:本地 Xcode Automatically manage signing 适合个人与初学;团队环境用 fastlane match 统一管理(第 15 章)。

17.3 上传构建版本

三种常用途径:

# ① 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 的报错更可读。

17.4 App Store Connect 商店页配置

基本信息:名称(30 字符,ASO 核心位)、副标题(30 字符)、关键词(100 字符,逗号分隔,不写空格)、描述(首三行决定用户是否展开)、支持 URL、隐私政策 URL(必填,哪怕个人开发者也要一个可访问的页面)。

截图与预览(审核必看,用户转化第一要素):

  • 6.9 英寸(iPhone 16 Pro Max 级)与 6.5 英寸规格各至少 1 张(新版 ASC 通常 1 套大尺寸自动缩放,以控制台提示为准);
  • 内容必须真实反映 App(用 Marketing 工具做带设备框的展示图,但不得功能造假——「截图与实际不符」是稳定拒审项);
  • 涉及他人商标/明星肖像/排行榜数据截图是雷区。

App 分级(Age Rating):问卷如实填写(含暴力/赌博/医疗等内容),决定分级与部分地区可用性。

定价与销售范围:免费/内购;首次上架常见免费 + 内购解锁高级功能(轻记账:免费记账 + Pro 订阅解锁统计导出)。

App 隐私(Privacy Nutrition Label)与隐私清单:

  • 如实声明收集的数据类型与用途(如「标识符 - 分析」「财务信息 - App 功能」)——与代码实际行为不一致会被拒审甚至下架;
  • iOS 17+ 需在 Xcode 生成 PrivacyInfo.xcprivacy(Required Reason API 声明:UserDefaults、文件时间戳、系统启动时间等的使用理由),第三方 SDK 也应提供各自清单——缺失是近年高频拒审点;
  • 上架前自检:权限申请(第 6 章)与隐私声明、用途描述、实际行为三方一致。

17.5 TestFlight:审核前的真实验证

  • 内部测试(最多 100 人,ASC 团队成员角色):上传即测,无需审核,快速冒烟;
  • 外部测试(最多 1 万人):需简易审核(通常 1 天),可用公开链接邀请;内购需先在沙盒/外测中验证;
  • TestFlight 版本 90 天有效,过期需重新上传;
  • 用途纪律:把 TestFlight 当作「全设备、全流程」回归平台——真机矩阵(新旧机型/系统版本)、深链、推送、支付、弱网全过一遍,问题修在审核之前。

17.6 提交审核与常见拒审

提审配置:版本发布选项(手动发布 / 自动 / 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 未经许可使用用户数据、第三方内容侵权 隐私政策 + 授权链路完整

拒审应对:

  1. 读 Resolution Center 的审核意见,逐条回应而不是重新提审赌运气;
  2. 确属误判(审核员未按说明操作):礼貌澄清 + 提供复现步骤/演示视频,可要求重新审核或电话沟通;
  3. 真有问题:修复后走「重新提交」,正常 1~2 天内二审;
  4. 加急审核(Expedited Review):P0 崩溃等紧急修复可申请,附崩溃证据,每季度别超过 2~3 次(滥用会被降权);
  5. 上架后紧急下架选项:ASC 可「移除销售」(同步下线所有地区),线上事故预案的一部分。

中国区特殊项:App 需 ICP 备案号(2023 年起工信部要求,无备案号不上架中国区);名称/内容符合本地法规;主体为公司账号时资质文件齐全。

17.7 上架后:运营与迭代

  • Crash SLA:Crash-free 率周报(第 11 章监控闭环),P0 崩溃 24h 内出 hotfix 并走加急;
  • 评价管理:用 SKStoreReviewController / SwiftUI requestReview 在「完成一次记账」等正向时刻温和请求评价(一年 3 次上限),差评走反馈渠道;
  • 版本节奏:双周 TestFlight + 月度商店版本(第 15 章节奏);每次版本记录「提审耗时/拒审次数」做团队 KPI 观测;
  • 下架与停服:功能停更的 App 也要响应系统兼容性崩溃(老 App 不维护照样因系统升级被拒/崩溃),这是合规义务不是情怀。

常见误区

误区 后果 正确做法
首次提审才开始准备隐私政策、演示账号 反复打回,节奏全乱 提审前按 Checklist 全项预检(17.8)
截图用「理想效果图」与实际不符 2.3 拒审 截图真实 + 展示图用官方工具做
数字会员引导外部支付 3.1.1 拒审甚至下架 数字内容一律 IAP;外部支付只限实物/服务
权限描述「为了更好的体验」 5.1.1 拒审 具体到功能场景(第 6 章)
演示账号密码过期/余额耗尽 审核员进不去 → 拒审 专用审核账号 + 审核备注注明注意事项
用加急审核当常规通道 被降权,真加急时批不下来 留给 P0 事故
审核通过即全量发布 新版本重大问题影响全部用户 大版本用 Phased Release 7 天灰度 + 崩溃监控值守
忘记 ICP 备案 中国区无法上架 立项即启动备案(周期以周计)

最佳实践

  • 提审 Checklist(团队模板):
[ ] 构建:最新 commit 通过全部门禁,build 号正确
[ ] 真机矩阵回归:新旧 iPhone × 最新两个 iOS 版本
[ ] 崩溃监控:TestFlight 期间 P0/P1 清零
[ ] 元数据:截图/描述/关键词与功能一致,新增权限已更新描述
[ ] 隐私:App Privacy 声明与实际收集一致;PrivacyInfo.xcprivacy 已更新
[ ] 支付:IAP 商品状态可用,沙盒购买测试通过
[ ] 审核材料:演示账号有效、审核备注含深链/测试指引
[ ] 中国区:ICP 备案号已填入
[ ] 发布策略:Phased Release / 定时发布已选
[ ] 回滚预案:上个版本可紧急恢复(构建保留 + 移除销售流程演练)
  • 把审核当「黑盒测试员」设计:审核备注写清「如何触达核心功能」,演示账号预置样例数据——通过率立涨;
  • 素材版本化:截图源文件(Figma)与文案进仓库 store-assets/,随版本迭代更新;
  • 数据驱动迭代:上架后的转化率(商品页浏览 → 下载)在 ASC 分析里看,截图 A/B 优化是增长基本功。

典型业务场景:轻记账 v1.0 提审实录

里程碑 M16(全书毕业项目):

  1. 准备:隐私政策页(GitHub Pages);PrivacyInfo.xcprivacy(声明 UserDefaults 使用理由);IAP 配置 Pro 订阅(月/年两档 + 免费试用);
  2. 商店页:名称「轻记账 - 简单记账」;6 张截图(首页/统计/添加/多币种/深色模式/Pro 介绍);关键词「记账 支出 预算 汇率」;
  3. TestFlight:内部 5 人 3 天回归(发现深链冷启动 bug,修复)→ 外部 50 人 1 周(收集 2 个体验反馈进 v1.1);
  4. 提审:演示账号(预置 30 天账单数据)、审核备注说明「订阅在设置页入口 + 无外部支付链接」、Phased Release 开启;
  5. 结果:首轮 2.1 拒审一次——审核设备上 IAP 商品未生效(sandbox 配置疏漏),按意见修复 24h 后过审,7 天灰度全量。

动手练习

  1. 为轻记账申请/使用你的开发者账号(无付费账号则完整走一遍 ASC 后台模拟:创建 App 记录、填写元数据),产出商店页配置清单。
  2. 生成 PrivacyInfo.xcprivacy,声明 UserDefaults 与文件时间戳的使用理由,随构建归档验证无告警。
  3. 编写你的「提审 Checklist」并试用一次:用 fastlane 把一个 TestFlight 构建发到内部测试组,记录全程耗时与卡点。
  4. 模拟拒审应对:针对「2.3 元数据不符」写一份 Resolution Center 回复(含整改说明与截图对照)。
  5. (毕业挑战)完成附录 B 的 M16:轻记账 v1.0 上架(或 TestFlight 外测全流程走通),拿到属于你的第一个 App 上架链接。

小结

  • 上架链路六步:构建 → 上传 → 商店页 → TestFlight → 审核 → 上线运营;每步都有可预检的清单。
  • 签名三件套(App ID/证书/Profile)+ match 让发布环节的签名问题可预防。
  • 隐私合规(Nutrition Label、PrivacyInfo、权限描述、ICP)与支付合规(IAP 边界)是审核两大雷区。
  • TestFlight 是审核前的最后防线:全设备全流程回归 + 崩溃清零。
  • 团队化上架 = Checklist + 灰度发布 + 崩溃 SLA——至此,你已经具备把一个 App 从想法带到商店的完整能力。

全书完。 回到 README 查看进阶路线(附录 A),或从 附录 B 复盘贯穿项目。

📄 附录 A 学习资源与进阶路线 A-learning-resources.md

随用随查的资源索引。资源以官方与社区公认的高质量来源为主,注意甄别时效(Apple 技术一年一代)。

A.1 官方一手资料(优先级最高)

资源 用途
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 手册

A.2 系统性进阶阅读

  • Swift 语言进阶:Swift Evolution 提案(了解语言为什么这样设计);《Swift Programming: The Big Nerd Ranch Guide》(体系化教材)。
  • 并发与架构:Apple《Adopting Swift Concurrency》系列 Session;《 objc.io 》书籍与文章(架构、测试专题经典)。
  • 性能:WWDC 性能专题(Instruments / MetricKit 各 Session);Apple 官方《Reducing your app's launch time》等文档。
  • 企业工程:objc.io《App Architecture》、Point-Free(TCA 与函数式思想,选学)。

A.3 开源项目(读代码学工程)

项目 学习点
SwiftLint / SwiftFormat 规范工具本尊;大型 SPM 工程组织范本
Alamofire 网络层封装思想(第 7 章对照阅读)
GRDB.swift SQLite 最佳实践;并发与数据库结合的典范
Kingfisher 图片库:下载/缓存/解码全链路(第 10、11 章实战参照)
swift-composable-architecture (TCA) 函数式架构思想(第 12 章延伸)
Tencent/lemon-cleaner 等」国内团队开源 本土企业工程实践参考

读源码方法:先读 README 与核心目录结构 → 只读一条完整链路(如「发起请求到解析完成」)→ 带着「如果是我会怎么写」的问题对比。

A.4 社区与信息源

  • 中文社区:SwiftGG(翻译组)、掘金/博客园 iOS 标签、知识小集(公众号)。
  • 英文社区:Swift by Sundell、Donny Wals、Hacking with Swift(初学者友好教程站)、NSHipster(细节冷知识)、Swift.org 论坛。
  • 招聘风向:看目标公司 JD 里 SwiftUI/UIKit 比例、模块化/性能要求——反向校准自己的学习清单。

A.5 学完本教程后的进阶方向

方向 内容 前置
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 章的产物就是作品集素材) 全书

A.6 常见问题 FAQ

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 个里程碑并上架,约等于一年经验的初级开发者水位 + 完整的工程素养——足以在面试中展示「独立交付能力」,也足以在企业里快速接手真实项目。

📄 附录 B 贯穿项目「轻记账 LedgerBook」需求说明书 B-capstone-project.md

全书的实战载体。每章的「典型业务场景」都对应本说明书的某个功能或里程碑;每完成一个里程碑做一次自查。

B.1 产品定位

  • 一句话:面向个人用户的离线优先记账 App,支持云同步、月度统计与多币种。
  • 目标用户:有记账习惯的个人;对数据隐私与离线可用敏感。
  • 平台:iOS 17+(SwiftUI 主线,UIKit 心智);支持暗色模式与动态字体。
  • 商业模式:免费记账 + Pro 订阅(统计导出、多币种、云同步扩容)。

B.2 功能需求(按模块)

F1 记账(核心)

  • 记一笔支出/收入:标题、金额(分存储)、分类(6 类可扩展)、日期、备注;
  • 金额校验(>0、<100 万);备注长度截断;
  • 保存后列表即时可见(本地写入,无网络等待)。

F2 账单列表

  • 按月分组倒序;本月支出汇总卡置顶;
  • 搜索(标题)、滑动删除、长按菜单(复制金额/备注);
  • 空状态与错误态(重试)。

F3 统计

  • 分类占比(支出 Top N)、月度趋势;
  • 点击分类直达筛选后的账单列表(跨页导航)。

F4 云同步与登录

  • 手机号/邮箱登录;Token 鉴权(Keychain 存储 + 401 自动刷新);
  • 离线优先:本地为事实源,待同步队列上传,服务端增量拉取合并(幂等);
  • 同步状态可视化(调试页)。

F5 多币种与汇率

  • 账单可选币种;按当日汇率换算人民币展示;
  • 汇率缓存 6 小时(actor 保护,并发去重)。

F6 提醒与运营触点

  • 每月 1 日记账提醒(本地通知,用户可配置时间/开关);
  • 深链:ledgerbook://bill/<id>、ledgerbook://add?category=<c>。

F7 Pro 订阅

  • StoreKit 内购(月/年订阅 + 免费试用);功能门控:导出 CSV、多币种、云同步扩容。

F8 非功能需求

  • 性能:冷启动 P50 < 1s(低端机)、列表滚动 55fps+、内存稳态 < 150MB;
  • 质量:核心模块单测覆盖 ≥ 70%、关键路径 UI 冒烟、Crash-free ≥ 99.5%;
  • 合规:权限描述具体化、隐私清单、隐私政策页、中国区 ICP 备案;
  • 工程:模块化(SPM)、CI 门禁、双周 TestFlight / 月度商店版节奏。

B.3 技术架构约定(与第 12、13 章一致)

App 壳(组装根 + Router)
 ├─ LedgerFeature / StatsFeature / ProfileFeature   业务模块(互不依赖)
 ├─ DesignKit / NetworkKit / StorageKit / FoundationKit  基建模块
 └─ Domain:Bill / LedgerService / BillRepositoryProtocol(纯 Swift)
  • 架构:MVVM + 分层(Presentation/Domain/Data);页面状态一律枚举;
  • 持久化:SwiftData(BillRecord,金额为分);Token 走 Keychain;
  • 并发:ViewModel @MainActor,共享状态 actor 化(RateCache、AuthManager)。

B.4 里程碑(M1~M16)与对应章节

里程碑 交付物 章节
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 章

B.5 每阶段自检问题

  • 阶段一后:能否不看资料写出 struct/enum 建模一个新实体?可选值与闭包捕获说清楚了吗?
  • 阶段二后:SwiftUI 状态工具选型口诀能背吗?UIKit 的复用与生命周期讲得清吗?
  • 阶段三后:网络层四件套画得出吗?「离线优先」的读写路径能口述吗?死锁案例能识别吗?
  • 阶段四后:循环引用三大现场能当场写出来吗?启动优化的六步方法论走完过一遍吗?
  • 阶段五后:架构决策能说出「为什么」吗?CI 全绿流程亲手跑通了吗?拒审清单预检过吗?

任一回答是「不能」,回到对应章节的动手练习补齐再前进。

B.6 时间投入参考

阶段 每日 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 仓库浏览 →