本课目标:把网络请求从"能用"提升到"生产级"------理解网络层为什么要分层,掌握拦截器、重试策略、缓存机制、离线优先的完整设计,能写出扛得住真实环境(弱网、断网、token 过期、服务端抖动)的网络代码。
前置知识 :入门第8课
URLSession+async/await、第2课依赖注入、第10课 SwiftData。本课产出:一个分层、健壮、可缓存、可离线、可测试的网络层。
一、从一个真实的故事说起
1.1 一个"能跑"的网络请求
入门第8课,你学会了这样写网络请求:
swift
func loadPosts() async throws -> [Post] {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
运行起来,没问题。列表能加载,数据能显示。
但这个代码在真实环境里会出问题。
1.2 上线后暴露的 7 个问题
用户量上来后,这段代码暴露出 7 个问题:
| 问题 | 现象 | 根本原因 |
|---|---|---|
| 1. Token 到处加 | 每个请求都要手动加 Authorization |
没有统一注入 |
| 2. Token 过期后失效 | 401 后所有请求失败,用户只能重启 App | 没有自动刷新 |
| 3. 弱网体验差 | 一次失败就报错,用户要手动重试 | 没有重试策略 |
| 4. 重试雪崩 | 多用户同时重试,服务器被打挂 | 没有退避和抖动 |
| 5. 每次都要等网络 | 打开 App 必须等请求返回 | 没有缓存 |
| 6. 断网完全不能用 | 地铁上打开 App 一片空白 | 没有离线优先 |
| 7. 无法测试 | 想测"token 过期"要真的等 token 过期 | 依赖真实网络 |
关键理解 :"能跑"和"生产级"之间,隔着一整套架构。
1.3 本课要解决的三个核心问题
问题一:怎么统一处理横切关注点?
Token、日志、错误映射------这些逻辑不该散落在每个请求里。
问题二:怎么让请求"扛得住"?
弱网、抖动、超时------网络不可靠是常态,代码要能应对。
问题三:怎么让 App"断网也能用"?
网络是"同步通道",不是"数据源"------本地才是唯一的真相。
二、分层架构:每层解决一个问题
2.1 为什么必须分层
初学者容易把所有逻辑塞在一起:
swift
// ❌ 一个大函数包办所有
func fetchBooks() async throws -> [Book] {
// 1. 加 token
// 2. 拼 URL
// 3. 发请求
// 4. 检查状态码
// 5. 重试
// 6. 解码
// 7. 缓存
// 8. 更新本地
// ... 100 行
}
问题:
- 每个接口都要重复这些逻辑。
- 加新功能要改所有接口。
- 无法单独测试某一层。
解决 :分层,每层只做一件事。
2.2 四层架构
┌─────────────────────────────────────────────┐
│ 第 4 层:View / ViewModel │
│ 只关心展示和用户交互 │
└───────────────────┬─────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────┐
│ 第 3 层:Repository │
│ 数据来源抽象:先读本地?还是请求网络? │
│ 离线优先策略在这里实现 │
└───────────────────┬─────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────┐
│ 第 2 层:APIClient │
│ 组装请求、应用拦截器、重试、解码 │
└───────────────────┬─────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────┐
│ 第 1 层:URLSession │
│ 实际的 HTTP 传输 │
└─────────────────────────────────────────────┘
每层的职责:
| 层 | 职责 | 不应该做 |
|---|---|---|
| View | 展示 UI | 不直接发请求 |
| ViewModel | 管理状态 | 不关心请求细节 |
| Repository | 数据来源抽象 | 不关心 UI |
| APIClient | 组装、拦截、重试、解码 | 不关心业务逻辑 |
| URLSession | HTTP 传输 | 不关心业务 |
关键理解 :每层只做一件事,通过"接口"通信。
2.3 完整的数据流
1. View 调用 viewModel.load()
2. ViewModel 调用 repository.fetchBooks()
3. Repository 决定:先读本地?还是请求网络?
4. 如果请求网络:APIClient.request(Endpoint)
5. APIClient 应用 RequestInterceptor(加 token)
6. URLSession 发起请求
7. ResponseInterceptor 处理响应(401 刷新)
8. APIClient 解码 JSON
9. Repository 更新本地缓存
10. ViewModel 更新状态,View 刷新
关键理解 :理解这条数据流,调试时就能快速定位问题在哪一层。
三、Endpoint:描述请求,而不是发送请求
3.1 为什么需要 Endpoint
初学者直接拼 URL:
swift
// ❌ URL 拼接散落各处
let url = URL(string: "https://api.example.com/books?page=\(page)&size=20")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(body)
问题:
- URL 拼接容易出错(特殊字符、编码)。
- 请求配置散落,难以统一处理。
- 无法复用和测试。
解决 :用 Endpoint 描述 请求,不发送请求。
3.2 Endpoint 的定义
swift
import Foundation
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
struct Endpoint {
var path: String // 路径,如 "/books"
var method: HTTPMethod = .get // 方法
var query: [String: String] = [:] // 查询参数
var headers: [String: String] = [:] // 额外请求头
var body: Data? // 请求体
}
逐行讲解:
| 字段 | 作用 | 示例 |
|---|---|---|
path |
路径 | "/books" |
method |
HTTP 方法 | .get |
query |
查询参数 | ["page": "1"] |
headers |
额外头 | ["X-Custom": "value"] |
body |
请求体 | JSON 数据 |
3.3 URL 构造:用 URLComponents 而不是字符串拼接
swift
extension Endpoint {
func url(baseURL: URL) -> URL? {
guard var components = URLComponents(
url: baseURL.appendingPathComponent(path),
resolvingAgainstBaseURL: false
) else { return nil }
if !query.isEmpty {
components.queryItems = query.map {
URLQueryItem(name: $0.key, value: $0.value)
}
}
return components.url
}
}
为什么用 URLComponents?
| 方式 | 处理中文 | 处理特殊字符 | 处理空格 |
|---|---|---|---|
| 字符串拼接 | ❌ 出错 | ❌ 出错 | ❌ 出错 |
URLComponents |
✅ 自动编码 | ✅ 自动编码 | ✅ 自动编码 |
示例:
swift
// ❌ 字符串拼接
let url = URL(string: "https://api.com/search?q=你好 世界") // nil 或错误 URL
// ✅ URLComponents
var components = URLComponents(string: "https://api.com/search")!
components.queryItems = [URLQueryItem(name: "q", value: "你好 世界")]
let url = components.url! // 自动编码为 %E4%BD%A0%E5%A5%BD%20%E4%B8%96%E7%95%8C
3.4 便捷构造器
swift
extension Endpoint {
// GET 带查询
static func get(_ path: String, query: [String: String] = [:]) -> Endpoint {
Endpoint(path: path, method: .get, query: query)
}
// POST 带 JSON body
static func post<T: Encodable>(_ path: String, body: T) throws -> Endpoint {
var endpoint = Endpoint(path: path, method: .post)
endpoint.body = try JSONEncoder().encode(body)
endpoint.headers["Content-Type"] = "application/json"
return endpoint
}
// DELETE
static func delete(_ path: String) -> Endpoint {
Endpoint(path: path, method: .delete)
}
}
使用:
swift
let listEndpoint = Endpoint.get("/books", query: ["page": "1"])
let createEndpoint = try Endpoint.post("/books", body: newBook)
let deleteEndpoint = Endpoint.delete("/books/123")
关键理解 :Endpoint 是"请求的描述",与实际发送解耦。这让测试变得容易------你可以断言"生成了正确的 Endpoint",而不需要真的发请求。
四、错误分层:不同错误,不同处理
4.1 为什么需要错误分层
初学者把所有错误当成一个:
swift
// ❌ 所有错误一视同仁
catch {
print("出错了:\(error)")
}
问题:
- 网络错误和服务器错误应该不同处理。
- 4xx 不应该重试,5xx 应该重试。
- 401 需要刷新 token,其他错误不需要。
4.2 完整的错误枚举
swift
enum APIError: Error, LocalizedError {
case invalidURL // URL 无效
case noResponse // 没有响应
case httpError(Int, Data?) // HTTP 状态码错误
case decodingError(Error) // 解码失败
case transportError(Error) // 传输错误(断网、超时)
case unauthorized // 401,未授权
case cancelled // 请求被取消
case timeout // 超时
var errorDescription: String? {
switch self {
case .invalidURL: return "URL 无效"
case .noResponse: return "服务器没有响应"
case .httpError(let code, _): return "服务器错误(\(code))"
case .decodingError: return "数据解析失败"
case .transportError: return "网络连接失败"
case .unauthorized: return "登录已过期"
case .cancelled: return "请求已取消"
case .timeout: return "请求超时"
}
}
}
4.3 错误分类:哪些可重试
swift
extension APIError {
// 是否可重试
var isRetryable: Bool {
switch self {
case .transportError, .timeout:
return true // 网络问题,可重试
case .httpError(let code, _):
return (500..<600).contains(code) // 5xx 服务端错误,可重试
case .httpError(429, _):
return true // 限流,可重试
default:
return false // 4xx、解码错误、取消,不重试
}
}
}
为什么 4xx 不重试?
- 400:请求格式错误------重试还是错。
- 401:未授权------需要刷新 token,不是重试。
- 403:禁止访问------重试没用。
- 404:资源不存在------重试不存在的东西没意义。
为什么 5xx 重试?
- 500:服务端内部错误------可能是临时的。
- 502:网关错误------可能是临时的。
- 503:服务不可用------可能是临时的。
- 504:网关超时------可能是临时的。
4.4 错误映射
swift
extension APIError {
// 从 URLError 映射
static func from(_ error: URLError) -> APIError {
switch error.code {
case .cancelled:
return .cancelled
case .timedOut:
return .timeout
case .notConnectedToInternet, .networkConnectionLost:
return .transportError(error)
default:
return .transportError(error)
}
}
}
关键理解 :把底层错误"翻译"成业务错误,让上层能精确处理。
五、拦截器:横切关注点的统一处理
5.1 为什么需要拦截器
问题:每个请求都需要:
- 加 token。
- 加公共头(
User-Agent、Accept)。 - 记录日志。
- 处理 401。
如果散落在每个请求里:
swift
// ❌ 每个请求都重复
func fetchBooks() async throws -> [Book] {
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
print("→ \(request.url)")
let (data, response) = try await session.data(for: request)
if (response as? HTTPURLResponse)?.statusCode == 401 {
// 刷新 token
}
print("← \(response)")
// ...
}
解决 :拦截器把"横切关注点"抽离出来。
5.2 两种拦截器
swift
// 请求拦截器:请求发出前修改
protocol RequestInterceptor: Sendable {
func intercept(_ request: URLRequest) async throws -> URLRequest
}
// 响应拦截器:响应返回后处理
protocol ResponseInterceptor: Sendable {
func intercept(_ response: URLResponse, data: Data) async throws
}
关键理解:
RequestInterceptor修改请求------加 token、加头。ResponseInterceptor处理响应------401 刷新、错误映射。
5.3 Token 拦截器
swift
struct AuthTokenInterceptor: RequestInterceptor {
let tokenProvider: @Sendable () async -> String?
func intercept(_ request: URLRequest) async throws -> URLRequest {
var request = request // URLRequest 是值类型,必须用 var 副本
if let token = await tokenProvider() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
}
关键点:
URLRequest是值类型 ------参数是let,必须创建var副本。tokenProvider是闭包------让拦截器不依赖具体的 token 存储。@Sendable------保证闭包跨并发域安全。
为什么用闭包而不是直接传 token?
swift
// ❌ 直接传 token:token 变了拦截器不知道
struct AuthTokenInterceptor: RequestInterceptor {
let token: String // 固定值
}
// ✅ 传闭包:每次请求时获取最新 token
struct AuthTokenInterceptor: RequestInterceptor {
let tokenProvider: @Sendable () async -> String? // 动态获取
}
5.4 公共头拦截器
swift
struct CommonHeadersInterceptor: RequestInterceptor {
func intercept(_ request: URLRequest) async throws -> URLRequest {
var request = request
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("MyApp/1.0", forHTTPHeaderField: "User-Agent")
request.setValue(Locale.current.identifier, forHTTPHeaderField: "Accept-Language")
return request
}
}
5.5 Token 刷新拦截器
swift
struct TokenRefreshInterceptor: ResponseInterceptor {
let refresh: @Sendable () async throws -> Void
func intercept(_ response: URLResponse, data: Data) async throws {
guard let http = response as? HTTPURLResponse else { return }
if http.statusCode == 401 {
try await refresh() // 尝试刷新 token
throw APIError.unauthorized // 让上层决定是否重试
}
}
}
关键理解 :拦截器抛出 .unauthorized,APIClient 收到后决定是否重试。
5.6 日志拦截器
swift
struct LoggingInterceptor: RequestInterceptor, ResponseInterceptor {
func intercept(_ request: URLRequest) async throws -> URLRequest {
#if DEBUG
print("→ \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
if let body = request.httpBody {
print(" Body: \(String(data: body, encoding: .utf8) ?? "")")
}
#endif
return request
}
func intercept(_ response: URLResponse, data: Data) async throws {
#if DEBUG
if let http = response as? HTTPURLResponse {
print("← \(http.statusCode) \(http.url?.absoluteString ?? "")")
}
#endif
}
}
关键理解 :用 #if DEBUG 包裹,生产环境不打印日志。
5.7 拦截器的组合
swift
let client = APIClient(
baseURL: URL(string: "https://api.example.com")!,
requestInterceptors: [
AuthTokenInterceptor(tokenProvider: { ... }),
CommonHeadersInterceptor(),
LoggingInterceptor()
],
responseInterceptors: [
TokenRefreshInterceptor(refresh: { ... }),
LoggingInterceptor()
]
)
拦截器按顺序执行:
请求拦截器:Auth → Common → Logging → 发送
响应拦截器:Logging → TokenRefresh → 返回
关键理解 :顺序很重要。Auth 要在 Common 之前(因为 Auth 要覆盖默认头)。
六、重试策略:让请求"扛得住"
6.1 为什么需要重试
网络是不可靠的:
- 地铁里信号时断时续。
- 服务器偶尔抖动。
- 4G 切 WiFi 时短暂断连。
没有重试:
用户在地铁里打开 App → 请求失败 → 显示"加载失败"
用户手动重试 → 还是失败 → 用户放弃
有重试:
用户在地铁里打开 App → 请求失败 → 自动等待 0.5 秒 → 重试
→ 还是失败 → 等待 1 秒 → 重试
→ 网络恢复 → 成功
6.2 指数退避:为什么要"越等越久"
问题:如果重试间隔固定(比如每次都等 0.5 秒):
第 1 次失败 → 等 0.5 秒 → 重试
第 2 次失败 → 等 0.5 秒 → 重试
第 3 次失败 → 等 0.5 秒 → 重试
问题:如果服务器正在重启,0.5 秒内不可能恢复。重试是无用功。
解决 :指数退避------每次等待时间翻倍。
第 1 次失败 → 等 0.5 秒 → 重试
第 2 次失败 → 等 1 秒 → 重试
第 3 次失败 → 等 2 秒 → 重试
第 4 次失败 → 等 4 秒 → 重试
关键理解 :给服务器"恢复的时间",避免无效重试。
6.3 抖动:为什么要"加点随机"
问题:如果有 1000 个用户同时失败:
1000 个用户 → 都等 0.5 秒 → 同时重试 → 服务器被打挂
解决 :抖动------加随机值。
1000 个用户 → 等 0.5~0.65 秒(随机) → 分散重试 → 服务器能扛住
关键理解 :抖动避免"惊群效应",让重试分散。
6.4 RetryPolicy 实现
swift
struct RetryPolicy: Sendable {
var maxRetries: Int = 3 // 最大重试次数
var baseDelay: TimeInterval = 0.5 // 基础延迟
var maxDelay: TimeInterval = 8.0 // 最大延迟
// 指数退避 + 抖动
func delay(for attempt: Int) -> TimeInterval {
let exponential = baseDelay * pow(2.0, Double(attempt))
let capped = min(exponential, maxDelay)
let jitter = Double.random(in: 0...0.3) * capped
return capped + jitter
}
}
延迟计算示例:
| 尝试 | 基础延迟 | 加抖动后 |
|---|---|---|
| 0 | 0.5s | 0.5-0.65s |
| 1 | 1.0s | 1.0-1.3s |
| 2 | 2.0s | 2.0-2.6s |
| 3 | 4.0s | 4.0-5.2s |
| 5 | 8.0s(上限) | 8.0-10.4s |
6.5 重试的注意事项
注意 1:只重试可恢复错误。
swift
guard error.isRetryable else { throw error }
注意 2:限制最大次数。
swift
guard attempt < retryPolicy.maxRetries else { throw error }
注意 3:用 Task.sleep 而不是 Thread.sleep。
swift
// ✅ Task.sleep 可被取消
try await Task.sleep(for: .seconds(delay))
// ❌ Thread.sleep 阻塞线程
Thread.sleep(forTimeInterval: delay)
注意 4:重试要响应取消。
swift
try await Task.sleep(for: .seconds(delay)) // 如果任务被取消,会抛 CancellationError
七、APIClient:核心实现
7.1 为什么用 actor
swift
actor APIClient {
// ...
}
原因:
- APIClient 被多个 Task 并发调用。
actor保证内部状态安全。- 避免数据竞争。
7.2 完整实现
swift
import Foundation
actor APIClient {
private let baseURL: URL
private let session: URLSession
private let requestInterceptors: [RequestInterceptor]
private let responseInterceptors: [ResponseInterceptor]
private let retryPolicy: RetryPolicy
private let decoder: JSONDecoder
init(
baseURL: URL,
session: URLSession = .shared,
requestInterceptors: [RequestInterceptor] = [],
responseInterceptors: [ResponseInterceptor] = [],
retryPolicy: RetryPolicy = RetryPolicy(),
decoder: JSONDecoder = JSONDecoder()
) {
self.baseURL = baseURL
self.session = session
self.requestInterceptors = requestInterceptors
self.responseInterceptors = responseInterceptors
self.retryPolicy = retryPolicy
self.decoder = decoder
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
// MARK: - 发起请求并解码
func request<T: Decodable>(_ endpoint: Endpoint, as type: T.Type) async throws -> T {
let data = try await requestData(endpoint)
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
// MARK: - 发起请求,返回原始数据(带重试)
func requestData(_ endpoint: Endpoint) async throws -> Data {
guard let url = endpoint.url(baseURL: baseURL) else {
throw APIError.invalidURL
}
var attempt = 0
while true {
do {
return try await performRequest(endpoint, url: url)
} catch let error as APIError {
guard error.isRetryable, attempt < retryPolicy.maxRetries else {
throw error
}
let delay = retryPolicy.delay(for: attempt)
try await Task.sleep(for: .seconds(delay))
attempt += 1
}
}
}
// MARK: - 执行单次请求
private func performRequest(_ endpoint: Endpoint, url: URL) async throws -> Data {
// 1. 构建请求
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.httpBody = endpoint.body
request.timeoutInterval = 30
// 2. 应用 Endpoint 头
for (key, value) in endpoint.headers {
request.setValue(value, forHTTPHeaderField: key)
}
// 3. 应用请求拦截器
for interceptor in requestInterceptors {
request = try await interceptor.intercept(request)
}
// 4. 发送请求
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch let error as URLError {
throw APIError.from(error)
}
// 5. 检查响应
guard let http = response as? HTTPURLResponse else {
throw APIError.noResponse
}
// 6. 应用响应拦截器
for interceptor in responseInterceptors {
try await interceptor.intercept(response, data: data)
}
// 7. 检查状态码
guard (200..<300).contains(http.statusCode) else {
throw APIError.httpError(http.statusCode, data)
}
return data
}
}
7.3 逐段解读
actor:保证并发安全。
request<T: Decodable>:类型安全的解码。
requestData:带重试的原始数据请求。
performRequest:单次请求。
关键顺序:
- 构建请求。
- 应用 Endpoint 头(如
Content-Type)。 - 应用请求拦截器(如 token)。
- 发送。
- 应用响应拦截器(如 401 刷新)。
- 检查状态码。
八、缓存:减少重复请求
8.1 URLCache 的工作原理
URLCache 是系统提供的 HTTP 缓存,遵循 HTTP 缓存头:
第一次请求:
GET /books
↓
服务器返回:
Cache-Control: max-age=3600
ETag: "abc123"
↓
URLCache 缓存响应
第二次请求(1 小时内):
GET /books
↓
URLCache 直接返回缓存(不发请求)
8.2 缓存策略
swift
let config = URLSessionConfiguration.default
config.requestCachePolicy = .useProtocolCachePolicy // 遵循服务器缓存头
config.urlCache = URLCache.shared
requestCachePolicy 的选项:
| 策略 | 行为 |
|---|---|
.useProtocolCachePolicy |
遵循 HTTP 缓存头(推荐) |
.reloadIgnoringLocalCacheData |
忽略缓存,总是请求 |
.returnCacheDataElseLoad |
有缓存用缓存,没缓存请求 |
.returnCacheDataDontLoad |
只用缓存,不请求(离线模式) |
8.3 配置 URLCache
swift
func configureURLCache() {
let memoryCapacity = 20 * 1024 * 1024 // 20 MB 内存
let diskCapacity = 100 * 1024 * 1024 // 100 MB 磁盘
let cache = URLCache(
memoryCapacity: memoryCapacity,
diskCapacity: diskCapacity,
diskPath: "myapp_cache"
)
URLCache.shared = cache
}
8.4 ETag 条件请求
服务器返回 ETag,客户端下次请求带上 If-None-Match:
第一次请求:
GET /books
↓
服务器返回:
ETag: "abc123"
↓
URLCache 缓存
第二次请求:
GET /books
If-None-Match: "abc123"
↓
服务器判断:没变化
↓
返回 304 Not Modified(不返回数据)
↓
URLCache 用缓存
关键理解 :304 响应不返回数据,节省带宽。
8.5 内存缓存
对于非 HTTP 数据(如解码后的对象),用内存缓存:
swift
actor MemoryCache {
private var storage: [String: Data] = [:]
private let maxSize: Int
init(maxSize: Int = 50) {
self.maxSize = maxSize
}
func value(for key: String) -> Data? {
storage[key]
}
func set(_ value: Data, for key: String) {
if storage.count >= maxSize {
// 简单淘汰:移除第一个
storage.removeValue(forKey: storage.keys.first!)
}
storage[key] = value
}
func clear() {
storage.removeAll()
}
}
九、离线优先:本地是唯一真相
9.1 核心原则
传统方式:网络是数据源。
View → 请求网络 → 显示数据
↓ 网络失败
显示错误
离线优先:本地是数据源,网络只是同步通道。
View → 读本地 → 显示数据
↓ 同时
请求网络 → 成功后更新本地 → 界面自动刷新
↓ 网络失败
继续用本地数据(用户无感)
关键理解 :用户永远先看到本地数据,网络只是"后台同步"。
9.2 Repository 协议
swift
protocol BookRepository: Sendable {
func fetchBooks() async throws -> [Book]
func add(_ book: Book) async throws
func delete(_ book: Book) async throws
}
9.3 离线优先实现
swift
actor OfflineFirstBookRepository: BookRepository {
private let apiClient: APIClient
private let modelContext: ModelContext
init(apiClient: APIClient, modelContext: ModelContext) {
self.apiClient = apiClient
self.modelContext = modelContext
}
func fetchBooks() async throws -> [Book] {
// 1. 先读本地
let local = try fetchLocal()
do {
// 2. 请求网络
let endpoint = Endpoint.get("/books")
let remote: [BookDTO] = try await apiClient.request(endpoint, as: [BookDTO].self)
// 3. 更新本地
try updateLocal(remote)
// 4. 返回最新
return try fetchLocal()
} catch {
// 5. 网络失败,有本地数据就用本地
if !local.isEmpty {
return local
}
throw error
}
}
func add(_ book: Book) async throws {
// 乐观更新:先写本地
modelContext.insert(book)
try modelContext.save()
// 异步同步网络(失败不阻塞用户)
let endpoint = try Endpoint.post("/books", body: BookDTO(book: book))
_ = try? await apiClient.requestData(endpoint)
}
func delete(_ book: Book) async throws {
// 乐观更新:先删本地
modelContext.delete(book)
try modelContext.save()
// 异步同步网络
let endpoint = Endpoint.delete("/books/\(book.id)")
_ = try? await apiClient.requestData(endpoint)
}
private func fetchLocal() throws -> [Book] {
try modelContext.fetch(FetchDescriptor<Book>())
}
private func updateLocal(_ dtos: [BookDTO]) throws {
let existing = try modelContext.fetch(FetchDescriptor<Book>())
existing.forEach { modelContext.delete($0) }
dtos.forEach { modelContext.insert(Book(title: $0.title, author: $0.author)) }
try modelContext.save()
}
}
struct BookDTO: Codable {
let id: UUID
let title: String
let author: String
init(book: Book) {
self.id = book.id
self.title = book.title
self.author = book.author
}
}
9.4 离线优先的三个关键点
关键点 1:本地是"唯一真相"。
swift
// ✅ 先读本地
let local = try fetchLocal()
// 然后请求网络
// 网络成功 → 更新本地
// 网络失败 → 用本地
关键点 2:写操作"乐观更新"。
swift
// ✅ 先写本地(用户立即看到效果)
modelContext.insert(book)
// 再异步同步网络
_ = try? await apiClient.requestData(endpoint) // 失败不阻塞
关键点 3:网络失败不报错(有本地数据时)。
swift
catch {
if !local.isEmpty {
return local // 有本地数据,用户无感
}
throw error // 完全没有数据,才报错
}
十、组装:依赖注入
10.1 App 入口
swift
@main
struct MyApp: App {
let container: ModelContainer
let apiClient: APIClient
let repository: any BookRepository
init() {
// 1. 配置缓存
configureURLCache()
// 2. 创建容器
container = try! ModelContainer(for: Book.self)
// 3. 创建 token 提供者
let tokenProvider: @Sendable () async -> String? = {
try? KeychainStore.shared.readString(for: "authToken")
}
// 4. 创建 APIClient
apiClient = APIClient(
baseURL: URL(string: "https://api.example.com")!,
session: makeSession(),
requestInterceptors: [
AuthTokenInterceptor(tokenProvider: tokenProvider),
CommonHeadersInterceptor(),
LoggingInterceptor()
],
responseInterceptors: [
LoggingInterceptor()
],
retryPolicy: RetryPolicy(maxRetries: 3)
)
// 5. 创建 Repository
repository = OfflineFirstBookRepository(
apiClient: apiClient,
modelContext: container.mainContext
)
}
var body: some Scene {
WindowGroup {
ContentView(repository: repository)
}
.modelContainer(container)
}
}
10.2 配置 Session
swift
func makeSession() -> URLSession {
let config = URLSessionConfiguration.default
config.requestCachePolicy = .useProtocolCachePolicy
config.urlCache = URLCache.shared
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
config.waitsForConnectivity = true // 断网时等待网络恢复
return URLSession(configuration: config)
}
waitsForConnectivity 的关键作用:
- 断网时不立即失败,而是等待网络恢复。
- 适合地铁、电梯等短时断网场景。
十一、测试:URLProtocol 拦截
11.1 MockURLProtocol
swift
import Foundation
import Testing
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 {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
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() {}
}
11.2 测试用例
swift
@Suite("APIClient 测试")
struct APIClientTests {
func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
@Test func 成功解码() async throws {
MockURLProtocol.handler = { request in
let json = #"[{"id":"1","title":"SwiftUI","author":"小明"}]"#.data(using: .utf8)!
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, json)
}
let client = APIClient(
baseURL: URL(string: "https://example.com")!,
session: makeSession()
)
let books: [BookDTO] = try await client.request(Endpoint.get("/books"), as: [BookDTO].self)
#expect(books.count == 1)
#expect(books.first?.title == "SwiftUI")
}
@Test func 服务器错误抛出httpError() async throws {
MockURLProtocol.handler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
let client = APIClient(
baseURL: URL(string: "https://example.com")!,
session: makeSession(),
retryPolicy: RetryPolicy(maxRetries: 0) // 关闭重试
)
await #expect(throws: APIError.self) {
_ = try await client.requestData(Endpoint.get("/books"))
}
}
@Test func 拦截器添加token() async throws {
MockURLProtocol.handler = { request in
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test_token")
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
let client = APIClient(
baseURL: URL(string: "https://example.com")!,
session: makeSession(),
requestInterceptors: [
AuthTokenInterceptor(tokenProvider: { "test_token" })
]
)
_ = try await client.requestData(Endpoint.get("/books"))
}
@Test func 重试后成功() async throws {
nonisolated(unsafe) var attemptCount = 0
MockURLProtocol.handler = { request in
attemptCount += 1
if attemptCount < 3 {
// 前两次返回 500
let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
// 第三次成功
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
let client = APIClient(
baseURL: URL(string: "https://example.com")!,
session: makeSession(),
retryPolicy: RetryPolicy(maxRetries: 3, baseDelay: 0.01) // 加快测试
)
_ = try await client.requestData(Endpoint.get("/books"))
#expect(attemptCount == 3)
}
}
十二、调试排查指南
12.1 请求无 token
Step 1:检查拦截器是否添加。
swift
requestInterceptors: [
AuthTokenInterceptor(tokenProvider: tokenProvider) // 确认加了
]
Step 2:打印 token。
swift
let tokenProvider: @Sendable () async -> String? = {
let token = try? KeychainStore.shared.readString(for: "authToken")
print("🔑 Token: \(token ?? "nil")")
return token
}
12.2 重试无限循环
原因:没限制重试次数。
排查:
swift
guard error.isRetryable, attempt < retryPolicy.maxRetries else {
throw error
}
12.3 4xx 也重试
原因 :isRetryable 判断错误。
排查:
swift
case .httpError(let code, _):
return (500..<600).contains(code) // 只重试 5xx
12.4 解码失败
Step 1:打印原始 JSON。
swift
let json = String(data: data, encoding: .utf8)
print("📦 原始数据:\(json ?? "")")
Step 2:检查字段名。
swift
decoder.keyDecodingStrategy = .convertFromSnakeCase
Step 3:检查类型。
swift
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
switch error {
case .keyNotFound(let key, _):
print("❌ 缺少字段:\(key)")
case .typeMismatch(let type, _):
print("❌ 类型不匹配:\(type)")
default:
print("❌ 解码错误:\(error)")
}
throw APIError.decodingError(error)
}
12.5 离线不可用
Step 1:确认 Repository 实现了离线优先。
swift
func fetchBooks() async throws -> [Book] {
let local = try fetchLocal() // 先读本地
// ...
}
Step 2:确认本地有数据。
swift
print("📚 本地书籍数量:\(local.count)")
12.6 调试清单速查
| 现象 | 排查方向 |
|---|---|
| 请求无 token | 拦截器是否加、token 是否有效 |
| 无限重试 | maxRetries、isRetryable |
| 4xx 也重试 | isRetryable 判断 |
| 解码失败 | 字段名、类型、原始 JSON |
| 断网崩溃 | URLError 处理 |
| 缓存不生效 | URLCache 配置 |
| 离线不可用 | Repository 策略 |
| 主线程卡顿 | body 中请求 |
| 循环引用 | [weak self] |
| 测试依赖网络 | 用 URLProtocol |
十三、习题、答案与解读
选择题
题1 :描述一个请求应该使用?
A. Endpoint B. URLSession C. URLRequest D. APIClient
答案:A
解读:Endpoint 描述路径、方法、参数等。
题2 :统一加 token 应该放在哪里?
A. RequestInterceptor B. ResponseInterceptor C. Endpoint D. View
答案:A
解读:RequestInterceptor 在请求发出前修改。
题3 :指数退避的作用是什么?
A. 避免重试风暴 B. 加快请求 C. 减少内存 D. 增加并发
答案:A
解读:指数退避 + 抖动让重试分散。
题4 :离线优先的核心是什么?
A. 本地是唯一数据源 B. 只用网络 C. 只用内存 D. 不用缓存
答案:A
解读:先读本地,网络成功后更新本地。
题5 :测试网络层常用哪个类拦截请求?
A. URLProtocol B. URLSession C. URLRequest D. URLCache
答案:A
解读:自定义 URLProtocol 返回 Mock 数据。
题6 :APIClient 用 actor 声明的作用是什么?
A. 保证并发安全 B. 提高性能 C. 自动缓存 D. 自动重试
答案:A
解读:actor 保护内部状态。
题7 :waitsForConnectivity 的作用是什么?
A. 断网时等待网络恢复 B. 加快请求 C. 自动重试 D. 自动缓存
答案:A
解读:让断网时等待而不是立即失败。
题8 :URLRequest 是值类型还是引用类型?
A. 值类型 B. 引用类型 C. 两者都是 D. 都不是
答案:A
解读:URLRequest 是值类型,修改需要 var 副本。
判断题
题1 :APIClient 用 actor 声明可以保证并发安全。
答案:对
解读:actor 保护内部状态。
题2 :所有错误都应该重试。
答案:错
解读:4xx 客户端错误重试无意义。
题3 :URLCache 遵循 HTTP 缓存头。
答案:对
解读:服务器通过 Cache-Control、ETag 控制缓存。
题4 :离线优先的写操作应该先写网络再写本地。
答案:错
解读:应该乐观更新,先写本地。
题5 :MockURLProtocol 可以在不访问真实网络的情况下测试请求流程。
答案:对
解读:它拦截请求,返回预设响应。
题6 :拦截器可以同时是 RequestInterceptor 和 ResponseInterceptor。
答案:对
解读:日志拦截器就是。
题7 :URLRequest 是引用类型,可以直接修改参数。
答案:错
解读:它是值类型,参数是 let,需要 var 副本。
题8 :缓存可以减少重复请求。
答案:对
解读:URLCache 遵循 HTTP 缓存头。
填空题
题1 :描述请求使用 ______。
答案:Endpoint
题2 :请求前修改使用 ______Interceptor。
答案:Request
题3 :重试延迟使用指数退避 + ______。
答案:抖动
题4 :离线优先先读 ______。
答案:本地
题5 :测试网络层使用 ______ 拦截请求。
答案:URLProtocol
题6 :APIClient 用 ______ 保证并发安全。
答案:actor
题7 :判断错误是否可重试用 ______。
答案:isRetryable
题8 :URL 构造用 ______ 处理编码。
答案:URLComponents
改错题
题1:
swift
func fetch() async throws -> [Book] {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Book].self, from: data)
}
要求:找出缺少的错误处理。
答案:
swift
func fetch() async throws -> [Book] {
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw APIError.httpError((response as? HTTPURLResponse)?.statusCode ?? -1, data)
}
return try JSONDecoder().decode([Book].self, from: data)
} catch let error as DecodingError {
throw APIError.decodingError(error)
} catch {
throw APIError.transportError(error)
}
}
题2:
swift
while true {
do {
return try await performRequest(endpoint, url: url)
} catch {
// 直接重试
}
}
要求:找出问题。
答案:
swift
var attempt = 0
while true {
do {
return try await performRequest(endpoint, url: url)
} catch let error as APIError {
guard error.isRetryable, attempt < retryPolicy.maxRetries else {
throw error
}
try await Task.sleep(for: .seconds(retryPolicy.delay(for: attempt)))
attempt += 1
}
}
题3:
swift
struct AuthInterceptor: RequestInterceptor {
var token: String
func intercept(_ request: URLRequest) async throws -> URLRequest {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return request
}
}
要求:找出错误。
答案:
swift
struct AuthInterceptor: RequestInterceptor {
var token: String
func intercept(_ request: URLRequest) async throws -> URLRequest {
var request = request // URLRequest 是值类型
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return request
}
}
题4:
swift
func fetchBooks() async throws -> [Book] {
let remote = try await fetchRemote()
return remote
}
要求:改为离线优先。
答案:
swift
func fetchBooks() async throws -> [Book] {
let local = try fetchLocal()
do {
let remote = try await fetchRemote()
try updateLocal(remote)
return try fetchLocal()
} catch {
if !local.isEmpty { return local }
throw error
}
}
题5:
swift
func add(_ book: Book) async throws {
try await syncToServer(book) // 先同步网络
modelContext.insert(book) // 再写本地
}
要求:改为乐观更新。
答案:
swift
func add(_ book: Book) async throws {
modelContext.insert(book) // 先写本地
try modelContext.save()
_ = try? await syncToServer(book) // 异步同步,失败不阻塞
}
代码预测题
题1:
swift
let policy = RetryPolicy(maxRetries: 3, baseDelay: 0.5, maxDelay: 8)
let d0 = policy.delay(for: 0) // 约 0.5
let d1 = policy.delay(for: 1) // 约 1.0
let d2 = policy.delay(for: 2) // 约 2.0
问题:delay(for: 5) 大约是多少?
答案:约 8 秒(被 maxDelay 限制)。
题2:
swift
let client = APIClient(
baseURL: baseURL,
requestInterceptors: [AuthTokenInterceptor(tokenProvider: { "token" })]
)
问题:发出的请求会带什么头?
答案:Authorization: Bearer token。
题3:
swift
func fetchBooks() async throws -> [Book] {
let local = try fetchLocal()
do {
let remote = try await fetchRemote()
try updateLocal(remote)
return try fetchLocal()
} catch {
if !local.isEmpty { return local }
throw error
}
}
问题:网络失败且有本地数据时会怎样?
答案:返回本地数据,不抛错。
题4:
swift
error.isRetryable
// 对于 .httpError(404, _) 返回什么?
问题:404 错误可重试吗?
答案:不可重试(4xx 不在 500-599 范围内)。
题5:
swift
var request = URLRequest(url: url)
request.setValue("Bearer token", forHTTPHeaderField: "Authorization")
问题:为什么要用 var?
答案:URLRequest 是值类型,修改需要可变副本。
编程题
题1 :写一个 Endpoint 构造器。
参考代码:
swift
let endpoint = Endpoint.get("/books", query: ["page": "1", "size": "20"])
let url = endpoint.url(baseURL: URL(string: "https://api.example.com")!)
// https://api.example.com/books?page=1&size=20
题2 :写一个日志拦截器。
参考代码:
swift
struct LoggingInterceptor: RequestInterceptor, ResponseInterceptor {
func intercept(_ request: URLRequest) async throws -> URLRequest {
print("→ \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
return request
}
func intercept(_ response: URLResponse, data: Data) async throws {
if let http = response as? HTTPURLResponse {
print("← \(http.statusCode) \(http.url?.absoluteString ?? "")")
}
}
}
题3 :实现一个线程安全的 token 刷新器。
参考代码:
swift
actor TokenRefresher {
private var refreshTask: Task<String, Error>?
func refresh() async throws -> String {
if let task = refreshTask {
return try await task.value
}
let task = Task<String, Error> {
try await Task.sleep(for: .seconds(1))
return "new_token"
}
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
}
题4 :实现离线优先的 Repository。
参考代码:见 9.3 节。
题5 :用 URLProtocol 写网络层测试。
参考代码:见 11 节。
开放思考题
题1 :为什么要把网络层分层?
参考:职责单一,便于替换、测试和维护。
题2 :离线优先适合什么场景?
参考:网络不稳定、需要快速响应的场景,如阅读、笔记、待办。
题3 :为什么重试要加抖动?
参考:避免大量客户端同时重试,造成"惊群效应"。
题4 :URLCache 和自定义缓存有什么区别?
参考:URLCache 遵循 HTTP 缓存头,适合 GET;自定义缓存可控制策略和过期时间。
题5 :actor 在网络层中解决什么问题?
参考:保护 APIClient 的内部状态,避免并发数据竞争。
题6 :为什么 URLRequest 是值类型?
参考:值类型避免共享可变状态,让并发更安全。
题7 :为什么离线优先的写操作要"乐观更新"?
参考:用户立即看到效果,体验更好。网络同步失败可以后台重试。
十四、全课知识点总结
分层架构
- View → ViewModel → Repository → APIClient → URLSession。
- 每层职责单一,便于替换和测试。
Endpoint
- 描述请求:路径、方法、查询、头、body。
- 用
URLComponents构造 URL,自动处理编码。
错误分层
invalidURL、noResponse、httpError、decodingError、transportError、unauthorized、cancelled、timeout。isRetryable判断是否可重试。
拦截器
RequestInterceptor:请求前修改。ResponseInterceptor:响应后处理。- 一个拦截器只做一件事。
重试策略
- 指数退避 + 抖动。
- 只重试传输错误和 5xx。
maxRetries限制次数。maxDelay限制最大延迟。
APIClient
actor保证并发安全。- 应用拦截器、重试、解码。
- 返回类型安全的模型。
缓存
URLCache:遵循 HTTP 缓存头。waitsForConnectivity:断网时等待。- 内存缓存:快速访问。
离线优先
- 本地是唯一数据源。
- 网络成功后更新本地。
- 失败就用本地。
- 写操作乐观更新。
测试
URLProtocol拦截请求。- 返回 Mock 数据。
- 不依赖真实网络。
常见错误
- 没检查状态码。
- 无限重试。
- 4xx 也重试。
- 拦截器忘了
var副本。 - 测试依赖真实网络。
本课达标标准 :能定义 Endpoint 描述请求,能用 APIClient 统一发送并解码,能用拦截器加 token 和处理 401,能实现指数退避重试,能配置 URLCache,能实现离线优先的 Repository,能用 URLProtocol 写网络层测试,能用 actor 保证并发安全,并形成"分层、健壮、可缓存、可离线、可测试"的网络层。
下一课预告:第9课学习安全与隐私,用 Keychain、LocalAuthentication、CryptoKit 保护用户数据。