网络层是 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?吞掉解码错误:线上出问题无法定位是哪个字段。
相关阅读
- Swift 并发与 Combine async/await — 结构化并发与网络请求的配合
- iOS 测试与 CI 流水线 — 网络层的 Mock 与集成测试
- 网络基础 — HTTP、TLS、DNS 等底层机制
小结
URLSession 是 iOS 网络的地基,default/ephemeral/background 三种配置决定了缓存与后台能力的边界。async/await 的 data(for:) 让请求代码清爽,但它不校验状态码,这一条必须记牢。
往上搭分层设计:传输层、拦截器层、解码层、端点层四层各司其职,鉴权、日志、重试都做成可插拔的拦截器。认证的关键是 Token 刷新的并发去重,缓存交给 URLCache 并尊重响应头。
可测试性靠协议抽象——把 URLSession 藏在 HTTPSession 协议后面,测试注入 Mock,断言请求构造与解码结果。做到这一步,网络层才算真正工程化,而不是一堆散落的 dataTask。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。