ios-networking

ios-networking

熱門

在 iOS/macOS 應用程式中,使用 URLSession 搭配 async/await、結構化並行與現代 Swift 模式來建置、審查或改進網路程式碼。適用於處理 REST API、下載檔案、上傳資料、WebSocket 連線、分頁、重試邏輯、請求中介層、快取、背景傳輸或網路連線狀態監控。也適用於處理 HTTP 請求、API 客戶端、網路錯誤處理或在 Swift 應用程式中擷取資料。

927星標
46分支
更新於 2026/7/15
SKILL.md
唯讀
名稱
ios-networking
描述

在 iOS/macOS 應用程式中,使用 URLSession 搭配 async/await、結構化並行與現代 Swift 模式來建置、審查或改進網路程式碼。適用於處理 REST API、下載檔案、上傳資料、WebSocket 連線、分頁、重試邏輯、請求中介層、快取、背景傳輸或網路連線狀態監控。也適用於處理 HTTP 請求、API 客戶端、網路錯誤處理或在 Swift 應用程式中擷取資料。

iOS 網路程式設計

對於一般的 HTTP、REST、上傳、下載與串流,使用 URLSession 搭配 async/await 與結構化並行。對於較低階的協定與可持久的背景傳輸,使用 Network.framework 以及 delegate/task API。

目錄

核心 URLSession async/await

URLSession 在 iOS 15 中加入了原生的 async/await 多載。在前景的資料、上傳、下載與串流工作中,優先使用這些多載。背景 URLSession 傳輸是主要的例外:它們仍然使用 task/delegate API,以便系統在暫停或重新啟動後仍能傳遞事件。

使用 URLProtocol 測試裝置來驗證網路政策,涵蓋有效的 2xx、格式錯誤的 2xx、一次性 401 重新整理、有限度的 429/5xx 重試、逾時/離線、取消以及不可重試的 4xx。檢查標頭、狀態與錯誤分類;修正政策後重新執行。僅重試安全/冪等或明確可重播的請求,且絕不無窮迴圈重新整理 token。

資料請求

// 基本 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:) 回傳之前移動或開啟暫存檔案。

背景 session 是由 delegate 驅動的傳輸佇列。使用任務建立 API,例如 downloadTask(with:) 與基於檔案的 uploadTask(with:fromFile:),然後處理 URLSessionDelegate / 任務 delegate 回呼。請勿使用非同步便利 API,例如 data(for:)download(for:)upload(for:),因為它們不適用於可持久的背景 session 模式。

// 上傳資料
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、可選的自訂 URLSessionJSONDecoder 以及一個 RequestMiddleware 攔截器陣列。每個方法從端點建構 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
    }
}

Token 重新整理流程

處理 401 回應的方式是重新整理 token 並重試一次。

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 時,注入已設定的 session。僅在簡單的一次性工作使用 URLSession.shared。完整的設定與測試設定,請參閱 URLSession patterns

App Transport Security (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;背景 session 使用 delegate/task API
  • [ ] 錯誤處理涵蓋 URLError 案例(.notConnectedToInternet、.timedOut、.cancelled)
  • [ ] 請求可取消(透過 .task 修飾詞或儲存的 Task 參考尊重 Task 取消)
  • [ ] 認證 token 透過中介層注入,而非硬編碼
  • [ ] 回應 HTTP 狀態碼在解碼前已驗證
  • [ ] 大型下載使用 download(for:) 而非 data(for:)
  • [ ] 網路呼叫在 @MainActor 之外進行(僅 UI 更新在主執行緒)
  • [ ] URLSession 已設定適當的逾時與快取
  • [ ] 正式環境客戶端注入已設定的 session,而非使用 URLSession.shared
  • [ ] 背景傳輸使用 task/delegate API,而非非同步便利 API
  • [ ] 重試邏輯排除取消與 4xx 用戶端錯誤
  • [ ] 分頁在頁面之間檢查 Task.isCancelled
  • [ ] 敏感 token 儲存在 Keychain 中(而非 UserDefaults 或純文字檔案)
  • [ ] 沒有從動態輸入強制解開的 URL
  • [ ] 伺服器錯誤回應已解碼並呈現給使用者
  • [ ] Network.framework 程式碼明確設定 TLS/信任,並將深度綁定工作保留在 swift-security
  • [ ] NetworkConnection<QUIC> 串流 API 被視為非同步拋出
  • [ ] 確保網路回應模型類型符合 Sendable;對 UI 更新的完成路徑使用 @MainActor

參考資料