Joe's Blog

iOS 開發筆記

Swift 網路層生存指南 (Final) —— JPNetworking

發佈於 2026-06-14

前三篇(123)講完了救災概念——SafeBox 處理型別錯置、ShieldedResponse 導航巢狀 JSON、BaseResponse 統一外殼解析。

這篇把這套思路打包成 Swift Package:JPNetworking


🏛️ 架構概覽

flowchart TD
    A([URLSession.request]) --> B[Build URLRequest]
    B --> C[networkLogger .request]
    C --> D[URLSession.data]
    D --> E{statusCode}
    E -->|401| F{refresher & 首次 401?}
    F -->|no| ERR([throw APIError])
    F -->|yes| G[TokenRefresher.refresh]
    G --> D
    E -->|other| H[networkLogger .response]
    H --> I[EndPoint.validate]
    I -->|throws| ERR
    I -->|ok| J[decode]
    J -->|throws| ERR
    J -->|ok| K([Return T])
    D -->|network error| L{retriesLeft > 0?}
    L -->|yes| D
    L -->|no| ERR
  

📦 安裝

1
2
3
4
// Package.swift
dependencies: [
    .package(url: "https://github.com/shinrenpan/JPNetworking", from: "0.1.0")
]

或在 Xcode:File → Add Package Dependencies


🛠️ 核心設計:EndPoint 協議

整套架構的核心是 EndPoint 協議,每個 API 對應一個 struct:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public protocol EndPoint: Sendable {
    var baseURL: String { get }
    var path: String { get }
    var method: APIMethod { get }
    var headers: [String: String] { get }
    var body: Data? { get }
    var needToken: Bool { get }
    var retryCount: Int { get }
    var decodePath: [String]? { get }

    func validate(_ data: Data, _ response: HTTPURLResponse) throws -> Data
}

validate() 是關鍵——讓每個專案自己定義「什麼叫成功」,而不是硬編在框架裡。

一個 struct 對應一個 endpoint

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct ProfileEndPoint: EndPoint {
    let id: String
    var path: String { "/users/\(id)" }
    var method: APIMethod { .get }
}

struct LoginEndPoint: EndPoint {
    let email: String
    let password: String
    var path: String { "/auth/login" }
    var method: APIMethod { .post }
    var needToken: Bool { false }
    var body: Data? {
        try? JSONEncoder().encode(["email": email, "password": password])
    }
}

⚙️ Setup

1. 專案層級的 EndPoint extension

只需要寫一次,所有 EndPoint 都繼承:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// HTTP 狀態碼後端(成功 = 2xx)
extension EndPoint {
    var baseURL: String { "https://api.example.com" }
    var decodePath: [String]? { ["data"] }
    var headers: [String: String] {
        var h = ["Content-Type": "application/json"]
        if needToken { h["Authorization"] = "Bearer \(TokenManager.shared.token)" }
        return h
    }

    func validate(_ data: Data, _ response: HTTPURLResponse) throws -> Data {
        guard (200..<300).contains(response.statusCode) else {
            throw APIError.serverError(code: response.statusCode, message: "HTTP \(response.statusCode)")
        }
        return data
    }
}

如果後端用自訂 code 欄位判斷成功(code == 0),validate() 改成解析 JSON 欄位即可,不需要動框架本身。

2. TokenRefresher

App 啟動時設定一次:

1
2
3
4
URLSession.shared.tokenRefresher = TokenRefresher {
    let token: TokenDTO = try await URLSession.shared.request(RefreshEndPoint())
    TokenManager.shared.save(token.accessToken)
}

🚀 常見情境

基本 request

1
2
let profile: ProfileDTO = try await URLSession.shared.request(ProfileEndPoint(id: "123"))
let token: TokenDTO = try await URLSession.shared.request(LoginEndPoint(email: "joe@example.com", password: "secret"))

Token 自動刷新(401)

收到 401 時,TokenRefresher 執行 refresh handler 並自動重試,call site 不需要額外處理:

1
let profile: ProfileDTO = try await URLSession.shared.request(ProfileEndPoint(id: "123"))

並發 401

多個 request 同時收到 401 時,TokenRefresheractor,確保 refresh 只執行一次,其他 request 等待完成後一起重試:

1
2
3
4
5
async let api1: ProfileDTO = URLSession.shared.request(ProfileEndPoint(id: "123"))
async let api2: FeedDTO    = URLSession.shared.request(FeedEndPoint())

// api1 觸發 refresh,api2 等待,兩個都用新 token 重試
let (profile, feed) = try await (api1, api2)

髒資料:SafeBox

1
2
3
4
5
6
struct UserDTO: Decodable {
    @SafeBox var age: Int?      // 後端可能給 "30" 或 null
    @SafeBox var name: String?  // 後端可能給 0 或 null
    @SafeBox var score: Double? // 後端可能給 "9.5"
    @SafeBox var active: Bool?  // 後端可能給 "true"、"1"、1
}

與前三篇的 SafeBox 不同,這裡的 wrappedValueT?——解析失敗回傳 nil,讓 domain 層明確決定如何處理,而不是悄悄補上預設值。

轉換到 domain model 時:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 嚴格派:nil 就過濾
func toDomain() -> User? {
    guard let age, let name else { return nil }
    return User(age: age, name: name)
}

// 寬鬆派:nil 補預設值
func toDomain() -> User? {
    User(age: age ?? 0, name: name ?? "Unknown")
}

巢狀 JSON(decodePath)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 預設 decodePath = ["data"],對應 { "data": { ... } }

// 覆寫:對應 { "data": { "list": [...] } }
struct FeedEndPoint: EndPoint {
    var decodePath: [String]? { ["data", "list"] }
}

// 無巢狀:直接解析 root
struct PingEndPoint: EndPoint {
    var decodePath: [String]? { nil }
}

無回應 body(204)

1
let _: EmptyResponse = try await URLSession.shared.request(DeletePostEndPoint(id: "42"))

檔案上傳

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct UploadAvatarEndPoint: EndPoint {
    private let builder: MultipartBuilder

    init(image: Data) {
        var b = MultipartBuilder()
        b.addFile(name: "avatar", filename: "avatar.jpg", mimeType: "image/jpeg", data: image)
        self.builder = b
    }

    var path: String { "/user/avatar" }
    var method: APIMethod { .post }
    var headers: [String: String] { ["Content-Type": builder.contentType] }
    var body: Data? { builder.build() }
}

Retry

1
2
3
struct WeatherEndPoint: EndPoint {
    var retryCount: Int { 2 }  // 網路錯誤時最多重試 2 次
}

401、validate 失敗、decode 失敗不會 retry,只有真正的網路錯誤(timeout、connection lost)才會。


🚨 錯誤處理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
do {
    let profile: ProfileDTO = try await URLSession.shared.request(ProfileEndPoint(id: "123"))
} catch APIError.unAuthorized {
    // refresh 失敗或沒有設定 refresher → 導向登入頁
} catch APIError.serverError(let code, let message) {
    // 後端回傳業務錯誤 → 顯示 message
} catch APIError.dataQualityError {
    // toDomain() 回傳 nil → 記錄 log,顯示 fallback UI
} catch APIError.someError(let error) {
    // 網路失敗、timeout、decode 錯誤 → 顯示重試提示
}

💡 與前三篇的差異

BadBackendDemoJPNetworking
SafeBox wrappedValueT(補預設值)T?(明確 nil)
成功/失敗判斷BaseResponseProtocolEndPoint.validate()
Token 刷新TokenRefresher (actor)
並發 401自動 coalesce
MultipartMultipartBuilder
RetryretryCount

BadBackendDemo 用於展示救災行為(swift run 看 log 輸出),JPNetworking 是把這套思路帶進實際專案的生產工具。


GitHub


本文使用 Claude 共同完成