iOS 网络层设计与 URLSession

URLSession 是 iOS 网络请求的唯一官方底座。本文从 Session 配置(default/ephemeral/background)、任务类型、async/await 版 data(for:) 讲到请求构建与 URLComponents、Codable 解码与 CodingKeys、错误分类与指数退避重试、超时与 ATS、Token 刷新与认证、中间件式网络层分层设计、URLCache 缓存策略、上传下载与进度回调,并给出协议抽象加依赖注入的可测试方案与常见坑清单。

网络层是 App 里最容易被写乱的一层:一开始几行 URLSession.shared.dataTask 就够了,随着鉴权、重试、缓存、错误提示一个个加进来,代码散落在几十个 ViewController 里,改一个接口要翻半个工程。本文讲的是怎么从零设计一个能撑住中大型 App 的网络层。

开篇

先立个判断:能用 URLSession 就别引入 Alamofire。iOS 15 之后 URLSession 有了 async/await 的 data(for:),再加上 Codable、URLComponents、URLCache,官方 API 已能覆盖绝大多数需求。第三方库的价值主要在 multipart 上传、请求适配器等细节,代价是多一个依赖和一层抽象。

本文先讲清 URLSession 的底层能力,再往上搭分层设计,最后落到可测试性和常见坑。

一、URLSession 配置

URLSession 有三种开箱配置,选择决定了缓存、Cookie、后台能力的行为。

配置缓存/Cookie适用场景
.default用磁盘缓存和共享 Cookie 存储常规业务请求
.ephemeral全内存,不落盘,不共享 Cookie隐私模式、一次性请求
.background由系统守护进程接管,App 被杀也能传大文件上传下载、离线队列
import Foundation

let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15          // 单次请求空闲超时
config.timeoutIntervalForResource = 60         // 整个资源传输总时限
config.waitsForConnectivity = true             // 无网时等待而非立即失败
config.httpAdditionalHeaders = ["Accept": "application/json"]

// 后台传输:identifier 必须唯一且与 App 绑定
let background = URLSessionConfiguration.background(withIdentifier: "com.example.app.upload")
background.isDiscretionary = false             // 关掉系统调度,立即传
background.sessionSendsLaunchEvents = true     // 传完唤醒 App

let session = URLSession(configuration: config)

waitsForConnectivity 值得单独说:它让请求在无网时挂起等待网络恢复,而不是立刻抛 NSURLErrorNotConnectedToInternet,对移动端体验提升明显。但等待时间不计入 timeoutIntervalForRequest。后台 Session 必须配 URLSessionDelegate 处理 handleEventsForBackgroundURLSession,否则 App 被唤醒后拿不到结果。

二、任务类型与 async/await

三种任务各司其职:URLSessionDataTask 在内存中收发,适合 API 调用;URLSessionDownloadTask 直接写文件,内存占用恒定,适合大文件下载;URLSessionUploadTask 从文件或数据流上传,适合大文件上传。

iOS 15 起提供了 async 版本,彻底摆脱回调地狱:

func fetchUser(id: String) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse }
    guard (200..<300).contains(http.statusCode) else {
        throw NetworkError.httpStatus(http.statusCode)
    }
    return try JSONDecoder().decode(User.self, from: data)
}

带请求体和方法的版本:

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await session.data(for: request)

需要注意:data(for:) 不自动校验状态码,4xx/5xx 一样正常返回,必须自己检查 HTTPURLResponse.statusCode。这是新手最常犯的错。

三、请求构建与 URLComponents

拼接 URL 字符串是 bug 之源,URLComponents 负责正确转义和编码。

var components = URLComponents(string: "https://api.example.com/search")!
components.queryItems = [
    URLQueryItem(name: "q", value: "swift 并发"),      // 自动百分号编码
    URLQueryItem(name: "page", value: "1"),
    URLQueryItem(name: "limit", value: "20")
]
let url = components.url!

var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.cachePolicy = .reloadIgnoringLocalCacheData   // 强制走网络
request.timeoutInterval = 20
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

路径参数要单独设置 comps.path,别直接字符串插值——URLQueryItem 只管 query,管不了路径里的特殊字符。

四、Codable 解码与 CodingKeys

Codable 是 Swift 原生序列化方案,配合 JSONDecoder 一行搞定。但真实 API 的字段名往往不符合 Swift 命名规范,用 CodingKeys 映射。

struct User: Codable {
    let id: String
    let displayName: String
    let createdAt: Date
    let avatarURL: URL?

    enum CodingKeys: String, CodingKey {
        case id
        case displayName = "display_name"       // 下划线转驼峰
        case createdAt = "created_at"
        case avatarURL = "avatar_url"
    }
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase   // 全局转换
decoder.dateDecodingStrategy = .iso8601               // ISO 8601 日期
let user = try decoder.decode(User.self, from: data)

几个关键点:

  • convertFromSnakeCase 与显式 CodingKeys 会冲突,用了全局策略就别再手写 snake_case 的 key。
  • iso8601 策略不支持带毫秒的格式,服务端返回 2026-10-02T15:00:00.123Z 时要自定义。
decoder.dateDecodingStrategy = .custom { decoder in
    let container = try decoder.singleValueContainer()
    let str = try container.decode(String.self)
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    guard let date = formatter.date(from: str) else {
        throw DecodingError.dataCorruptedError(in: container, debugDescription: "日期格式错误: \(str)")
    }
    return date
}

解码失败要能定位:DecodingError 会告诉你具体哪个 key 出问题,别用 try? 把错误吞了。

五、错误分类与重试退避

网络错误必须先分类,才能决定是提示用户、重试还是静默忽略。

enum NetworkError: Error {
    case invalidURL
    case invalidResponse
    case httpStatus(Int)          // 服务器返回非 2xx
    case decoding(Error)          // 解析失败
    case transport(URLError)      // 连接层错误(超时、断网)
    case unauthorized             // 401,需要刷新 Token
}

func mapError(_ error: Error) -> NetworkError {
    if let urlError = error as? URLError { return .transport(urlError) }
    if let decodingError = error as? DecodingError { return .decoding(decodingError) }
    return .invalidResponse
}

哪些错误该重试:timedOut、networkConnectionLost、cannotConnectToHost、5xx 状态码可以重试;4xx(除 429)重试没意义;解析错误重试也不会变好。

指数退避 + 抖动:

func withRetry<T>(maxAttempts: Int = 3, operation: () async throws -> T) async throws -> T {
    var attempt = 0
    while true {
        do {
            return try await operation()
        } catch let error as URLError where isRetryable(error) {
            attempt += 1
            guard attempt < maxAttempts else { throw error }
            // 指数退避 + 随机抖动,避免惊群
            let base = pow(2.0, Double(attempt)) * 0.5
            let jitter = Double.random(in: 0...0.3)
            try await Task.sleep(for: .seconds(base + jitter))
        }
    }
}

抖动的意义:服务端故障时大量客户端同时重试会形成雪崩,随机抖动把重试打散。

六、超时与 App Transport Security

ATS 是 iOS 9 引入的强制安全策略:默认只允许 HTTPS,且必须满足 TLS 1.2+、前向保密加密套件。明文 HTTP 请求会被系统直接拒绝。

<!-- Info.plist:仅为兼容特定域名而放宽,不要全局关闭 -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>legacy.example.com</key>
        <dict><key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict>
    </dict>
</dict>

原则:能升级服务端到 HTTPS 就不要开例外。全局 NSAllowsArbitraryLoads 会在 App Store 审核时被要求说明理由。

超时有两层:timeoutIntervalForRequest 是「无数据到达」的空闲超时,timeoutIntervalForResource 是整个传输的总时限。上传大文件时后者要放大,否则传到一半就被掐断。

七、认证与 Token 刷新

Bearer Token 场景的难点是并发请求同时遇到 401 时的刷新去重:不能五个请求各刷一次,应该只刷一次,其余等待。

actor TokenRefresher {
    private var refreshTask: Task<String, Error>?

    func validToken() async throws -> String {
        if let token = KeychainStore.shared.accessToken, !isExpired(token) { return token }
        // 已有刷新在跑,复用它,避免并发重复刷新
        if let existing = refreshTask { return try await existing.value }
        let task = Task<String, Error> {
            defer { refreshTask = nil }
            let newToken = try await AuthAPI.refresh()
            KeychainStore.shared.accessToken = newToken
            return newToken
        }
        refreshTask = task
        return try await task.value
    }
}

把 Token 存 Keychain 而不是 UserDefaults,前者有系统级加密保护。

八、中间件式网络层分层设计

一个可维护的网络层应该分成四层,职责单一、可组合:

层职责典型类型
传输层真正发请求URLSession 封装
拦截器层统一改请求/响应Token 注入、日志、重试
解码层JSON 转模型JSONDecoder + Codable
端点层描述接口Endpoint 枚举/结构

拦截器用协议表达:

protocol RequestInterceptor {
    func adapt(_ request: URLRequest) async throws -> URLRequest
}

struct AuthInterceptor: RequestInterceptor {
    let refresher: TokenRefresher
    func adapt(_ request: URLRequest) async throws -> URLRequest {
        var request = request
        let token = try await refresher.validToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return request
    }
}

端点用类型描述,把 URL、方法、参数聚在一处:

protocol Endpoint {
    var path: String { get }
    var method: String { get }
    var headers: [String: String] { get }
    var body: Encodable? { get }
}

enum UserEndpoint: Endpoint {
    case detail(id: String)
    case update(id: String, payload: UserPayload)

    var path: String {
        switch self {
        case .detail(let id), .update(let id, _): return "/users/\(id)"
        }
    }
    var method: String {
        switch self {
        case .detail: return "GET"
        case .update: return "POST"
        }
    }
    var headers: [String: String] { ["Content-Type": "application/json"] }
    var body: Encodable? {
        if case .update(_, let payload) = self { return payload }
        return nil
    }
}

客户端把上面拼起来:

final class APIClient {
    private let session: HTTPSession
    private let interceptors: [RequestInterceptor]
    private let decoder: JSONDecoder

    init(session: HTTPSession = URLSession.shared,
         interceptors: [RequestInterceptor] = [],
         decoder: JSONDecoder = JSONDecoder()) {
        self.session = session
        self.interceptors = interceptors
        self.decoder = decoder
    }

    func send<T: Decodable>(_ endpoint: Endpoint, as type: T.Type) async throws -> T {
        var request = try buildRequest(from: endpoint)
        for interceptor in interceptors { request = try await interceptor.adapt(request) }
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse }
        if http.statusCode == 401 { throw NetworkError.unauthorized }
        guard (200..<300).contains(http.statusCode) else {
            throw NetworkError.httpStatus(http.statusCode)
        }
        do { return try decoder.decode(T.self, from: data) }
        catch { throw NetworkError.decoding(error) }
    }

    private func buildRequest(from endpoint: Endpoint) throws -> URLRequest {
        var components = URLComponents(string: "https://api.example.com")!
        components.path = endpoint.path
        guard let url = components.url else { throw NetworkError.invalidURL }
        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method
        endpoint.headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
        if let body = endpoint.body { request.httpBody = try JSONEncoder().encode(body) }
        return request
    }
}

这样加日志、加缓存、加签名都只是插一个新的拦截器,不用改业务代码。

九、缓存策略与 URLCache

URLCache 是 HTTP 层的缓存,遵守 Cache-Control、ETag、Last-Modified 等响应头。

let cache = URLCache(memoryCapacity: 20 * 1024 * 1024,
                     diskCapacity: 100 * 1024 * 1024,
                     diskPath: "api_cache")
config.urlCache = cache
config.requestCachePolicy = .useProtocolCachePolicy   // 默认,按响应头决定

常用策略:.useProtocolCachePolicy 尊重服务端响应头,最常用;.reloadIgnoringLocalCacheData 忽略缓存强制刷新;.returnCacheDataElseLoad 有缓存先用;.returnCacheDataDontLoad 只用缓存,离线模式用。

坑:服务端不返回 Cache-Control 时,URLCache 可能不缓存 POST 响应,需要自己处理。图片这类大响应,URLCache 未必比基于 NSCache 的自建缓存更合适。

十、上传下载与进度

URLSession 的进度回调通过 delegate 实现,async API 不提供进度。

final class DownloadManager: NSObject, URLSessionDownloadDelegate {
    private var session: URLSession!
    private var progressHandler: ((Double) -> Void)?

    override init() {
        super.init()
        session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
    }

    func download(url: URL, onProgress: @escaping (Double) -> Void) {
        progressHandler = onProgress
        session.downloadTask(with: url).resume()
    }

    func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        guard totalBytesExpectedToWrite > 0 else { return }
        let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        DispatchQueue.main.async { self.progressHandler?(progress) }
    }

    func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        // location 是临时文件,必须在此回调内同步搬走,否则会被系统删除
        let dest = FileManager.default.temporaryDirectory.appendingPathComponent("file.dat")
        try? FileManager.default.moveItem(at: location, to: dest)
    }
}

关键坑:didFinishDownloadingTo 的 location 在回调返回后立即失效,必须当场 move/copy,不能异步处理。上传进度用 URLSessionTaskDelegate 的 didSendBodyData。大文件用 background 配置的 Session,App 进后台甚至被终止后仍能继续传输。

十一、可测试性:协议抽象与 Mock

网络层要能测,就不能直接依赖 URLSession 这个具体类型。抽一个协议:

protocol HTTPSession {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: HTTPSession {}

注入与 Mock:

final class MockSession: HTTPSession {
    var stubbedData = Data()
    var stubbedResponse: URLResponse = URLResponse()
    var error: Error?

    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        if let error { throw error }
        return (stubbedData, stubbedResponse)
    }
}

func testFetchUserDecodesCorrectly() async throws {
    let mock = MockSession()
    mock.stubbedData = #"{"id":"1","display_name":"Leeting"}"#.data(using: .utf8)!
    mock.stubbedResponse = HTTPURLResponse(
        url: URL(string: "https://api.example.com")!,
        statusCode: 200, httpVersion: nil, headerFields: nil)!

    let client = APIClient(session: mock)
    let user = try await client.send(UserEndpoint.detail(id: "1"), as: User.self)
    XCTAssertEqual(user.displayName, "Leeting")
}

测试里断言请求的 URL、方法、Header 是否正确,以及解码结果是否符合预期。更彻底的做法是用 URLProtocol 子类拦截,无需改动 URLSession 的类型。

十二、常见坑清单

  • 不检查 HTTP 状态码:data(for:) 对 4xx/5xx 不报错,必须自己校验 HTTPURLResponse.statusCode。
  • URLSession.shared 硬编码:导致无法注入 Mock、无法统一配置超时和 Header。
  • Token 并发刷新:多个 401 同时触发刷新,用 actor 去重。
  • convertFromSnakeCase 与 CodingKeys 混用:key 映射冲突,解码静默失败。
  • ISO8601 带毫秒解析失败:默认 .iso8601 不支持小数秒,需自定义策略。
  • didFinishDownloadingTo 里异步搬文件:临时文件已被删除,移动失败。
  • 后台 Session 的 identifier 不唯一:同一 identifier 重复创建会崩溃或行为异常。
  • ATS 全局关闭:审核被拒,且失去 HTTPS 保护。
  • 重试没有抖动:服务端故障时客户端雪崩。
  • try? 吞掉解码错误:线上出问题无法定位是哪个字段。

相关阅读

小结

URLSession 是 iOS 网络的地基,default/ephemeral/background 三种配置决定了缓存与后台能力的边界。async/await 的 data(for:) 让请求代码清爽,但它不校验状态码,这一条必须记牢。

往上搭分层设计:传输层、拦截器层、解码层、端点层四层各司其职,鉴权、日志、重试都做成可插拔的拦截器。认证的关键是 Token 刷新的并发去重,缓存交给 URLCache 并尊重响应头。

可测试性靠协议抽象——把 URLSession 藏在 HTTPSession 协议后面,测试注入 Mock,断言请求构造与解码结果。做到这一步,网络层才算真正工程化,而不是一堆散落的 dataTask。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全