本课目标:把网络请求从"能用"提升到"生产级"——理解网络层为什么要分层,掌握拦截器、重试策略、缓存机制、离线优先的完整设计,能写出扛得住真实环境(弱网、断网、token 过期、服务端抖动)的网络代码。

前置知识:入门第8课 URLSession + async/await、第2课依赖注入、第10课 SwiftData。

本课产出:一个分层、健壮、可缓存、可离线、可测试的网络层。

一、从一个真实的故事说起

1.1 一个"能跑"的网络请求

入门第8课,你学会了这样写网络请求:

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 为什么必须分层

初学者容易把所有逻辑塞在一起:

// ❌ 一个大函数包办所有
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组装、拦截、重试、解码不关心业务逻辑
URLSessionHTTP 传输不关心业务

关键理解:每层只做一件事,通过"接口"通信。

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:

// ❌ 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 的定义

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"
methodHTTP 方法.get
query查询参数["page": "1"]
headers额外头["X-Custom": "value"]
body请求体JSON 数据

3.3 URL 构造:用 URLComponents 而不是字符串拼接

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✅ 自动编码✅ 自动编码✅ 自动编码

示例:

// ❌ 字符串拼接
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 便捷构造器

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

使用:

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 为什么需要错误分层

初学者把所有错误当成一个:

// ❌ 所有错误一视同仁
catch {
    print("出错了:\(error)")
}

问题:

  • 网络错误和服务器错误应该不同处理。
  • 4xx 不应该重试,5xx 应该重试。
  • 401 需要刷新 token,其他错误不需要。

4.2 完整的错误枚举

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 错误分类:哪些可重试

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 错误映射

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。

如果散落在每个请求里:

// ❌ 每个请求都重复
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 两种拦截器

// 请求拦截器:请求发出前修改
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 拦截器

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?

// ❌ 直接传 token:token 变了拦截器不知道
struct AuthTokenInterceptor: RequestInterceptor {
    let token: String   // 固定值
}

// ✅ 传闭包:每次请求时获取最新 token
struct AuthTokenInterceptor: RequestInterceptor {
    let tokenProvider: @Sendable () async -> String?   // 动态获取
}

5.4 公共头拦截器

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 刷新拦截器

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 日志拦截器

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 拦截器的组合

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 实现

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

延迟计算示例:

尝试基础延迟加抖动后
00.5s0.5-0.65s
11.0s1.0-1.3s
22.0s2.0-2.6s
34.0s4.0-5.2s
58.0s(上限)8.0-10.4s

6.5 重试的注意事项

注意 1:只重试可恢复错误。

guard error.isRetryable else { throw error }

注意 2:限制最大次数。

guard attempt < retryPolicy.maxRetries else { throw error }

注意 3:用 Task.sleep 而不是 Thread.sleep。

// ✅ Task.sleep 可被取消
try await Task.sleep(for: .seconds(delay))

// ❌ Thread.sleep 阻塞线程
Thread.sleep(forTimeInterval: delay)

注意 4:重试要响应取消。

try await Task.sleep(for: .seconds(delay))  // 如果任务被取消,会抛 CancellationError

七、APIClient:核心实现

7.1 为什么用 actor

actor APIClient {
    // ...
}

原因:

  • APIClient 被多个 Task 并发调用。
  • actor 保证内部状态安全。
  • 避免数据竞争。

7.2 完整实现

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:单次请求。

关键顺序:

  1. 构建请求。
  2. 应用 Endpoint 头(如 Content-Type)。
  3. 应用请求拦截器(如 token)。
  4. 发送。
  5. 应用响应拦截器(如 401 刷新)。
  6. 检查状态码。

八、缓存:减少重复请求

8.1 URLCache 的工作原理

URLCache 是系统提供的 HTTP 缓存,遵循 HTTP 缓存头:

第一次请求:
    GET /books
        ↓
    服务器返回:
        Cache-Control: max-age=3600
        ETag: "abc123"
        ↓
    URLCache 缓存响应

第二次请求(1 小时内):
    GET /books
        ↓
    URLCache 直接返回缓存(不发请求)

8.2 缓存策略

let config = URLSessionConfiguration.default
config.requestCachePolicy = .useProtocolCachePolicy   // 遵循服务器缓存头
config.urlCache = URLCache.shared

requestCachePolicy 的选项:

策略行为
.useProtocolCachePolicy遵循 HTTP 缓存头(推荐)
.reloadIgnoringLocalCacheData忽略缓存,总是请求
.returnCacheDataElseLoad有缓存用缓存,没缓存请求
.returnCacheDataDontLoad只用缓存,不请求(离线模式)

8.3 配置 URLCache

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 数据(如解码后的对象),用内存缓存:

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 协议

protocol BookRepository: Sendable {
    func fetchBooks() async throws -> [Book]
    func add(_ book: Book) async throws
    func delete(_ book: Book) async throws
}

9.3 离线优先实现

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:本地是"唯一真相"。

// ✅ 先读本地
let local = try fetchLocal()

// 然后请求网络
// 网络成功 → 更新本地
// 网络失败 → 用本地

关键点 2:写操作"乐观更新"。

// ✅ 先写本地(用户立即看到效果)
modelContext.insert(book)

// 再异步同步网络
_ = try? await apiClient.requestData(endpoint)  // 失败不阻塞

关键点 3:网络失败不报错(有本地数据时)。

catch {
    if !local.isEmpty {
        return local   // 有本地数据,用户无感
    }
    throw error        // 完全没有数据,才报错
}

十、组装:依赖注入

10.1 App 入口

@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

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

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 测试用例

@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:检查拦截器是否添加。

requestInterceptors: [
    AuthTokenInterceptor(tokenProvider: tokenProvider)  // 确认加了
]

Step 2:打印 token。

let tokenProvider: @Sendable () async -> String? = {
    let token = try? KeychainStore.shared.readString(for: "authToken")
    print("🔑 Token: \(token ?? "nil")")
    return token
}

12.2 重试无限循环

原因:没限制重试次数。

排查:

guard error.isRetryable, attempt < retryPolicy.maxRetries else {
    throw error
}

12.3 4xx 也重试

原因:isRetryable 判断错误。

排查:

case .httpError(let code, _):
    return (500..<600).contains(code)  // 只重试 5xx

12.4 解码失败

Step 1:打印原始 JSON。

let json = String(data: data, encoding: .utf8)
print("📦 原始数据:\(json ?? "")")

Step 2:检查字段名。

decoder.keyDecodingStrategy = .convertFromSnakeCase

Step 3:检查类型。

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 实现了离线优先。

func fetchBooks() async throws -> [Book] {
    let local = try fetchLocal()   // 先读本地
    // ...
}

Step 2:确认本地有数据。

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:

func fetch() async throws -> [Book] {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode([Book].self, from: data)
}

要求:找出缺少的错误处理。
答案:

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:

while true {
    do {
        return try await performRequest(endpoint, url: url)
    } catch {
        // 直接重试
    }
}

要求:找出问题。
答案:

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:

struct AuthInterceptor: RequestInterceptor {
    var token: String

    func intercept(_ request: URLRequest) async throws -> URLRequest {
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return request
    }
}

要求:找出错误。
答案:

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:

func fetchBooks() async throws -> [Book] {
    let remote = try await fetchRemote()
    return remote
}

要求:改为离线优先。
答案:

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:

func add(_ book: Book) async throws {
    try await syncToServer(book)  // 先同步网络
    modelContext.insert(book)     // 再写本地
}

要求:改为乐观更新。
答案:

func add(_ book: Book) async throws {
    modelContext.insert(book)     // 先写本地
    try modelContext.save()
    _ = try? await syncToServer(book)  // 异步同步,失败不阻塞
}

代码预测题

题1:

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:

let client = APIClient(
    baseURL: baseURL,
    requestInterceptors: [AuthTokenInterceptor(tokenProvider: { "token" })]
)

问题:发出的请求会带什么头?
答案:Authorization: Bearer token。

题3:

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:

error.isRetryable
// 对于 .httpError(404, _) 返回什么?

问题:404 错误可重试吗?
答案:不可重试(4xx 不在 500-599 范围内)。

题5:

var request = URLRequest(url: url)
request.setValue("Bearer token", forHTTPHeaderField: "Authorization")

问题:为什么要用 var?
答案:URLRequest 是值类型,修改需要可变副本。

编程题

题1:写一个 Endpoint 构造器。
参考代码:

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:写一个日志拦截器。
参考代码:

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 刷新器。
参考代码:

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 保护用户数据。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐