Joe's Blog

iOS 開發筆記

Swift 網路層生存指南 (1)

發佈於 2026-01-01
2026-06-14 更新

這套架構的核心,是我在無數個被 API 炸掉的午夜,與一個「非人類助手」共同參悟出來的生存法則。

身為 iOS 工程師,我們最大的壓力來源通常不是複雜的 UI,而是**「後端不按牌理出牌的 API」**。當髒資料導致 App 出錯,面對老闆的連環追問:「為什麼別人的 App 沒事,我們的會閃退?」、「這不是昨天才修過嗎?」,你需要的不是更多的 try?,而是這套強大的「救災架構」。


🚩 BadBackend 奇葩行為大賞 (血汗處刑清單)

在給出解決方案前,我們先看看這些讓開發者血壓飆升的真實案例。這不是虛構,這是我們的日常:

  1. 【薛丁格的 ID】:有資料時 id: "123",沒資料時欄位直接失蹤,或是給 id: null
  2. 【型別人格分裂】price 這一秒是 Double (99.0),下一秒變 String ("99.0")
  3. 【Bool 的創意大賽】true 有時是 1,有時是 "Y",有時是 "on",甚至還給過 "checked"
  4. 【外殼變色龍】:今天資料包在 data,明天改叫 items,後天直接吐 Array 不包殼。

🏛️ 救災架構視覺化

為了讓你理解這套系統是如何在混亂中維持秩序,我們來看這張資料流向圖:

graph TD
    subgraph JSON_Source [原始髒資料]
        A[Missing Keys / Nulls]
        B[Type Mismatch: '123' vs 123]
        C[Corrupted Array Elements]
    end

    subgraph Defense_Layer [DTO 防禦層 - SafeBox / SafeArray]
        D{SafeBox Decoder}
        E{SafeArray Recovery}
        D -->|Type Rescue| F[Normalizing Types]
        D -->|Key Missing| G[Inject Default Value]
        E -->|Element Fail| H[Insert Default Instance]
    end

    subgraph Domain_Layer [Domain 轉換層]
        I[toDomain Mapping]
        J{關鍵欄位驗證策略}
        I --> J
        J -->|情境一| K[給予隨機 ID / 保證渲染]
        J -->|情境二| L[回傳 nil / 直接過濾]
    end

    JSON_Source --> Defense_Layer
    Defense_Layer --> Domain_Layer
    Domain_Layer --> M[ViewModel / UI]

    style Defense_Layer fill:#f96,stroke:#333,stroke-width:2px
    style Domain_Layer fill:#bbf,stroke:#333,stroke-width:2px
  

🛠️ 核心救災工具包

SafeBox 是整套架構的基石,處理三種情況:null 補預設值、型別錯置嘗試轉換、欄位缺失補預設值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
protocol SafeValue: Codable, Sendable {
    static var defaultValue: Self { get }
}

extension String: SafeValue { static var defaultValue: String { "" } }
extension Int:    SafeValue { static var defaultValue: Int    { 0  } }
extension Double: SafeValue { static var defaultValue: Double { 0.0 } }
extension Bool:   SafeValue { static var defaultValue: Bool   { false } }

@propertyWrapper
struct SafeBox<T: SafeValue>: Codable, Sendable {
    var wrappedValue: T

    init(wrappedValue: T) { self.wrappedValue = wrappedValue }

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        if container.decodeNil() {
            wrappedValue = T.defaultValue; return
        }
        if let value = try? container.decode(T.self) {
            wrappedValue = value; return
        }
        // 型別錯置:嘗試救援,失敗則補預設值
        wrappedValue = Self.rescue(from: container) ?? T.defaultValue
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.singleValueContainer()
        try c.encode(wrappedValue)
    }

    private static func rescue(from container: SingleValueDecodingContainer) -> T? {
        switch T.self {
        case is String.Type:
            if let v = try? container.decode(Int.self)    { return "\(v)" as? T }
            if let v = try? container.decode(Double.self)  { return "\(v)" as? T }
        case is Int.Type:
            if let v = try? container.decode(String.self), let i = Int(v)    { return i as? T }
            if let v = try? container.decode(Double.self)                     { return Int(v) as? T }
        case is Double.Type:
            if let v = try? container.decode(String.self), let d = Double(v) { return d as? T }
            if let v = try? container.decode(Int.self)                        { return Double(v) as? T }
        case is Bool.Type:
            if let v = try? container.decode(String.self) {
                return ["1", "y", "yes", "true", "on", "checked"].contains(v.lowercased()) as? T
            }
            if let v = try? container.decode(Int.self) { return (v == 1) as? T }
        default: break
        }
        return nil
    }
}

// 欄位缺失時補 defaultValue,不拋錯
extension KeyedDecodingContainer {
    func decode<T: SafeValue>(_ type: SafeBox<T>.Type, forKey key: Key) throws -> SafeBox<T> {
        (try? decodeIfPresent(type, forKey: key)) ?? SafeBox(wrappedValue: T.defaultValue)
    }
}

🏛️ 實戰:關鍵欄位的處置策略

toDomain() 階段,身為開發者的你需要決定如何處理「核心缺失(如 ID 遺失)」的情況。這裡有兩種主流實踐:

解決方案:ID 缺失處置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ✨ 乾淨的 Domain (對應 UI)
struct ProductItem: Identifiable, Sendable {
  let id: String
  let title: String
}

// 💩 髒髒的 DTO (對應 API)
struct ProductDTO: Codable, DomainConvertible {
  @SafeBox var product_id: String
  @SafeBox var product_name: String

  static func defaultInstance() -> ProductDTO {
    .init(product_id: "", product_name: "")
  }

  func toDomain() -> ProductItem? {

    // --- 策略 A:保命派 (給予臨時身分證) ---
    // 即使 ID 缺失也強制渲染,適用於「必須顯示資料」的情境。
    // 優點:App 絕對不會空屏。缺點:可能導致重複點擊無效或 UI 狀態錯亂。
    /*
    let finalID = product_id.isEmpty ? "TEMP-ID-\(UUID().uuidString)" : product_id
    return ProductItem(id: finalID, title: product_name)
    */

    // --- 策略 B:潔癖派 (直接過濾無效資料) ---
    // ID 缺失代表這筆資料無法跳轉詳情或操作,直接回傳 nil,
    // 讓 ViewModel 透過 compactMap 過濾,確保 UI 上每筆都是可操作的。
    guard !product_id.isEmpty else {
      Logger.badBackend.error("🔥 [關鍵缺失] 商品「\(product_name)」ID 遺失,已自動過濾。")
      return nil
    }

    return ProductItem(id: product_id, title: product_name.isEmpty ? "未命名商品" : product_name)
  }
}

📋 附錄:SafeBox 的代價

雖然這套架構能擋下 90% 的背刺,但在使用前,你必須清楚它的局限性:

  1. 語義錯誤無法偵測SafeBox 只能保證型別不崩潰,無法偵測邏輯錯誤。
  2. 效能開銷:內部多次嘗試 decode 與動態轉型(Dynamic Casting)在處理大數據時會稍慢。
  3. 掩蓋溝通契機:App 太穩了,你可能會懶得叫後端修 API。請記住,救災是為了爭取時間,不是為了縱容錯誤。

💡 總結:架構即尊嚴

這套架構的核心哲學是**「嚴以律己,寬以待人」**:

這不只是為了寫 Code,更是為了守護你的下班時間與職涯尊嚴。不要期待後端會改 API,你的架構穩定才是永遠的。


Demo


本文使用 Claude 共同完成