ios-networking

ios-networking

热门

使用 async/await 和结构化并发,在 iOS/macOS 应用中构建、审查或改进基于 URLSession 的网络代码。适用于 REST API、文件下载、数据上传、WebSocket 连接、分页、重试逻辑、请求中间件、缓存、后台传输或网络可达性监控。也适用于处理 Swift 应用中的 HTTP 请求、API 客户端、网络错误处理或数据获取。

927Star
46Fork
更新于 2026/7/15
SKILL.md
readonly只读
name
ios-networking
description

使用 async/await 和结构化并发,在 iOS/macOS 应用中构建、审查或改进基于 URLSession 的网络代码。适用于 REST API、文件下载、数据上传、WebSocket 连接、分页、重试逻辑、请求中间件、缓存、后台传输或网络可达性监控。也适用于处理 Swift 应用中的 HTTP 请求、API 客户端、网络错误处理或数据获取。

iOS 网络编程

对于普通的 HTTP、REST、上传、下载和流式传输,使用带有 async/await 和结构化并发的 URLSession。对于底层协议和持久化后台传输,使用 Network.framework 以及 delegate/task API。

目录

核心 URLSession async/await

URLSession 在 iOS 15 中增加了原生 async/await 重载。对于前台数据、上传、下载和流式传输,优先使用这些方法。后台 URLSession 传输是主要例外:它们仍然使用 task/delegate API,以便系统在挂起或重启后传递事件。

使用 URLProtocol 固定测试数据在本地验证网络策略,包括有效 2xx、格式错误 2xx、一次性 401 刷新、有限 429/5xx 重试、超时/离线、取消和不可重试 4xx。检查头部、状态和错误分类;修复策略并重新运行。仅重试安全/幂等或明确可重放的请求,且绝不循环令牌刷新。

数据请求

// 基本 GET
let (data, response) = try await URLSession.shared.data(from: url)

// 使用配置的 URLRequest
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(payload)
request.timeoutInterval = 30
request.cachePolicy = .reloadIgnoringLocalCacheData

let (data, response) = try await URLSession.shared.data(for: request)

响应验证

在解码前始终验证 HTTP 状态码。URLSession 不会为 4xx/5xx 响应抛出异常——它仅在传输层失败时抛出。

guard let httpResponse = response as? HTTPURLResponse else {
    throw NetworkError.invalidResponse
}

guard (200..<300).contains(httpResponse.statusCode) else {
    throw NetworkError.httpError(
        statusCode: httpResponse.statusCode,
        data: data
    )
}

使用 Codable 进行 JSON 解码

func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse,
          (200..<300).contains(httpResponse.statusCode) else {
        throw NetworkError.invalidResponse
    }

    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    return try decoder.decode(T.self, from: data)
}

下载和上传

对于大文件,使用 download(for:)——它会流式传输到磁盘,而不是将整个负载加载到内存中。

// 下载到临时文件
let (localURL, response) = try await URLSession.shared.download(for: request)

// 立即移动或复制返回的临时文件。
let destination = documentsDirectory.appendingPathComponent("file.zip")
try FileManager.default.moveItem(at: localURL, to: destination)

对于基于 delegate 的 URLSessionDownloadDelegate,在 urlSession(_:downloadTask:didFinishDownloadingTo:) 返回之前移动或打开临时文件。

后台会话是基于 delegate 的传输队列。使用任务创建 API,例如 downloadTask(with:) 和基于文件的 uploadTask(with:fromFile:),然后处理 URLSessionDelegate / 任务 delegate 回调。不要使用异步便捷 API,例如 data(for:)download(for:)upload(for:),因为这是持久化后台会话模式。

// 上传数据
let (data, response) = try await URLSession.shared.upload(for: request, from: bodyData)

// 从文件上传
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL)

使用 AsyncBytes 进行流式传输

对于流式响应、进度跟踪或行分隔数据(例如服务器发送事件),使用 bytes(for:)

let (bytes, response) = try await URLSession.shared.bytes(for: request)

for try await line in bytes.lines {
    // 处理到达的每一行(例如 SSE 流)
    handleEvent(line)
}

API 客户端架构

基于协议的客户端

为可测试性定义协议。这允许你在测试中交换实现,而无需直接模拟 URLSession。

protocol APIClientProtocol: Sendable {
    func fetch<T: Decodable & Sendable>(
        _ type: T.Type,
        endpoint: Endpoint
    ) async throws -> T

    func send<T: Decodable & Sendable>(
        _ type: T.Type,
        endpoint: Endpoint,
        body: some Encodable & Sendable
    ) async throws -> T
}
struct Endpoint: Sendable {
    let path: String
    var method: String = "GET"
    var queryItems: [URLQueryItem] = []
    var headers: [String: String] = [:]

    func url(relativeTo baseURL: URL) -> URL {
        guard let components = URLComponents(
            url: baseURL.appendingPathComponent(path),
            resolvingAgainstBaseURL: true
        ) else {
            preconditionFailure("路径 \(path) 的 URL 组件无效")
        }
        var mutableComponents = components
        if !queryItems.isEmpty {
            mutableComponents.queryItems = queryItems
        }
        guard let url = mutableComponents.url else {
            preconditionFailure("无法从组件构造 URL")
        }
        return url
    }
}

客户端接受 baseURL、可选的 URLSessionJSONDecoderRequestMiddleware 拦截器数组。每个方法从端点构建 URLRequest,应用中间件,执行请求,验证状态码并解码结果。完整的 APIClient 实现(包括便捷方法、请求构建器和测试设置)请参见 references/urlsession-patterns.md

生产客户端应接收注入的、配置好的 URLSession,而不是内部调用 URLSession.shared。配置 URLSessionConfiguration,包括请求/资源超时、缓存策略或 URLCachewaitsForConnectivity、数据成本策略,以及在需要处理身份验证挑战、重定向、指标、证书固定或后台传输时的 delegate。

轻量级闭包客户端

对于使用 MV 模式的应用程序,使用基于闭包的客户端以实现可测试性和 SwiftUI 预览支持。完整模式(异步闭包结构体,通过 init 注入)请参见 references/lightweight-clients.md

请求中间件 / 拦截器

中间件在请求发送前对其进行转换。用于身份验证、日志记录、分析标头等横切关注点。

protocol RequestMiddleware: Sendable {
    func prepare(_ request: URLRequest) async throws -> URLRequest
}
struct AuthMiddleware: RequestMiddleware {
    let tokenProvider: @Sendable () async throws -> String

    func prepare(_ request: URLRequest) async throws -> URLRequest {
        var request = request
        let token = try await tokenProvider()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return request
    }
}

令牌刷新流程

通过刷新令牌并重试一次来处理 401 响应。

func fetchWithTokenRefresh<T: Decodable & Sendable>(
    _ type: T.Type,
    endpoint: Endpoint,
    tokenStore: TokenStore
) async throws -> T {
    do {
        return try await fetch(type, endpoint: endpoint)
    } catch NetworkError.httpError(statusCode: 401, _) {
        try await tokenStore.refreshToken()
        return try await fetch(type, endpoint: endpoint)
    }
}

错误处理

结构化错误类型

enum NetworkError: Error, Sendable {
    case invalidResponse
    case httpError(statusCode: Int, data: Data)
    case decodingFailed(Error)
    case noConnection
    case timedOut
    case cancelled

    /// 将 URLError 映射为类型化的 NetworkError
    static func from(_ urlError: URLError) -> NetworkError {
        switch urlError.code {
        case .notConnectedToInternet, .networkConnectionLost:
            return .noConnection
        case .timedOut:
            return .timedOut
        case .cancelled:
            return .cancelled
        default:
            return .httpError(statusCode: -1, data: Data())
        }
    }
}

关键 URLError 情况

URLError Code 含义 操作
.notConnectedToInternet 设备离线 显示离线 UI,排队等待重试
.networkConnectionLost 请求中途连接断开 带退避重试
.timedOut 服务器未及时响应 重试一次,然后显示错误
.cancelled 任务被取消 无需操作;不显示错误
.cannotFindHost DNS 失败 检查 URL,显示错误
.secureConnectionFailed TLS 握手失败 检查证书固定、ATS 配置
.userAuthenticationRequired 需要身份验证才能访问资源 触发认证流程

解码服务器错误体

struct APIErrorResponse: Decodable, Sendable {
    let code: String
    let message: String
}

func decodeAPIError(from data: Data) -> APIErrorResponse? {
    try? JSONDecoder().decode(APIErrorResponse.self, from: data)
}

// 在 catch 块中使用
catch NetworkError.httpError(let statusCode, let data) {
    if let apiError = decodeAPIError(from: data) {
        showError("服务器错误:\(apiError.message)")
    } else {
        showError("HTTP \(statusCode)")
    }
}

带指数退避的重试

使用结构化并发进行重试。在尝试之间尊重任务取消。跳过取消和 4xx 客户端错误(429 除外)的重试。

func withRetry<T: Sendable>(
    maxAttempts: Int = 3,
    initialDelay: Duration = .seconds(1),
    operation: @Sendable () async throws -> T
) async throws -> T {
    var lastError: Error?
    for attempt in 0..<maxAttempts {
        do {
            return try await operation()
        } catch {
            lastError = error
            if error is CancellationError { throw error }
            if case NetworkError.httpError(let code, _) = error,
               (400..<500).contains(code), code != 429 { throw error }
            if attempt < maxAttempts - 1 {
                try await Task.sleep(for: initialDelay * Int(pow(2.0, Double(attempt))))
            }
        }
    }
    throw lastError!
}

分页

使用 AsyncSequence 构建基于游标或偏移量的分页。始终在页面之间检查 Task.isCancelled。完整的 CursorPaginator 和基于偏移量的实现请参见 references/urlsession-patterns.md

网络可达性

使用 Network 框架的 NWPathMonitor——而不是第三方 Reachability 库。在当前 OS 目标上,它符合 AsyncSequence;仅为了兼容性或自定义投影才包装 pathUpdateHandler

import Network

func observeNetworkStatus() async {
    let monitor = NWPathMonitor()

    for await path in monitor {
        handle(path.status)
    }
}

检查 path.isExpensive(蜂窝网络)和 path.isConstrained(低数据模式)以调整行为(降低图像质量,跳过预取)。

对于底层 TCP、UDP、监听器、Bonjour、路径监控或 WebSocket 协议工作,使用 Network.framework——而不是普通的 REST API。对于 iOS 26 的 NetworkConnection<QUIC>openStream(...)inboundStreams(...) 是异步抛出 API;请参见 references/network-framework.md#quic-multiplexed-streams

配置 URLSession

当生产代码需要超时、缓存、连接等待、数据成本策略、身份验证挑战、重定向、指标或后台 delegate 时,注入一个配置好的会话。仅对简单的一次性工作使用 URLSession.shared。完整的配置和测试设置请参见 URLSession patterns

应用传输安全 (ATS)

ATS 使 HTTPS 成为 URL 加载系统的默认设置。不要启用全局任意加载;使用最窄的合理域/本地网络异常。为 Network.framework 显式配置 TLS。将深度信任和 SPKI 固定设计保留在 swift-security 中。

常见错误

不要: 对动态输入强制解包 URL(string:)
应该: 使用 URL(string:) 并妥善处理错误。仅对编译时常量字符串可以强制解包。

不要: 在主线程上解码大型 JSON 负载。
应该: 将解码保持在 URLSession 调用的上下文中,默认情况下不在主线程。仅在需要更新 UI 状态时切换到 @MainActor

不要: 在长时间运行的网络任务中忽略取消。
应该: 在循环(分页、流式传输、重试)中检查 Task.isCancelled 或调用 try Task.checkCancellation()。在 SwiftUI 中使用 .task 实现自动取消。

不要: 在 URLSession async/await 可以满足需求时使用 Alamofire 或 Moya。
应该: 直接使用 URLSession。有了 async/await,证明第三方库合理的人体工程学差距已不复存在。将第三方库保留用于真正缺失的功能(例如图像缓存)。

不要: 在测试中直接模拟 URLSession。
应该: 使用 URLProtocol 子类进行传输层模拟,或使用接受测试替身的基于协议的客户端。

不要:body 或视图初始化器中发起网络请求。
应该: 使用 .task.task(id:) 触发网络调用。

审查清单

  • [ ] 前台传输使用 async/await;后台会话使用 delegate/task API
  • [ ] 错误处理涵盖 URLError 情况(.notConnectedToInternet、.timedOut、.cancelled)
  • [ ] 请求可取消(通过 .task 修饰符或存储的 Task 引用尊重 Task 取消)
  • [ ] 身份验证令牌通过中间件注入,而非硬编码
  • [ ] 在解码前验证响应 HTTP 状态码
  • [ ] 大文件下载使用 download(for:) 而非 data(for:)
  • [ ] 网络调用在 @MainActor 之外进行(仅 UI 更新在主线程)
  • [ ] URLSession 配置了适当的超时和缓存
  • [ ] 生产客户端注入配置好的会话,而非使用 URLSession.shared
  • [ ] 后台传输使用 task/delegate API,而非异步便捷 API
  • [ ] 重试逻辑排除取消和 4xx 客户端错误
  • [ ] 分页在页面之间检查 Task.isCancelled
  • [ ] 敏感令牌存储在钥匙串中(而非 UserDefaults 或纯文本文件)
  • [ ] 没有对动态输入强制解包 URL
  • [ ] 服务器错误响应被解码并呈现给用户
  • [ ] Network.framework 代码显式配置 TLS/信任,并将深度固定工作保留在 swift-security
  • [ ] NetworkConnection<QUIC> 流 API 被视为异步抛出
  • [ ] 确保网络响应模型类型符合 Sendable;对更新 UI 的完成路径使用 @MainActor

参考资料