【SwiftUI提高练中学】第8课 网络层架构:拦截器、重试、缓存与离线优先

本课目标:把网络请求从"能用"提升到"生产级"------理解网络层为什么要分层,掌握拦截器、重试策略、缓存机制、离线优先的完整设计,能写出扛得住真实环境(弱网、断网、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:单次请求。

关键顺序:

  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 缓存策略

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

相关推荐
一条破秋裤40 分钟前
内核驱动的意义与课程学习规划
学习
Bug收容所1 小时前
学习LangChain day1
学习·langchain·llm·agent
传奇开心果编程1 小时前
【现代声明式UI学与练】第7课 组件通信——父传子、子传父、跨层级通信、状态提升与 Context
学习·flutter·react native·ui·swiftui·android jetpack
我命由我123453 小时前
Photoshop - Photoshop 矢量蒙版
学习·职场和发展·产品运营·求职招聘·职场发展·产品经理·photoshop
大圣编蚕3 小时前
Rancher证书轮换过期导致不能访问UI问题处理
ui·rancher
熊猫钓鱼>_>4 小时前
越顺,越空:当 AI 把学习 “优化“ 到消失
人工智能·学习·ai·llm·agent·ai编程·metaai
可乐ea4 小时前
Agent 评测换判据:用终态数据库状态判定任务是否完成
数据库·ui·oracle·mcp工具·agent评测·终态断言·智能体可靠性
一条破秋裤4 小时前
02_注册设备号_从主次设备号到成对注销
学习
@codercjw4 小时前
企业实战电机和传感器选型
学习