Swift 與 Objective-C

[Swift 中級 #6] Swift Codable 深入解析,4 大地雷處方箋

從 Codable 自動合成實際產生的內容,到實務必遇到的鍵名不一致、日期格式、巢狀結構、部分失敗四種情境的標準解法,以及 DecodingError 偵錯,一次整理。

閱讀 7 分鐘
[Swift 中級 #6] Swift Codable 深入解析,4 大地雷處方箋 封面圖

Codable 初看像魔法。在 struct 加上: Codable五個字母,JSON 轉換就免費出現了。但接上實際 API 後,魔法便消失:伺服器使用 snake_case,Swift 使用 camelCase;日期格式依 API 而異;清單中只要有一筆異常,整個解碼就會失敗,畫面也會整片空白。

中級系列第 6 篇介紹 Codable 深入應用,從自動合成實際產生什麼,到實務必遇到的四種情境(鍵名、日期、巢狀、部分失敗)的標準解法,一次整理。

魔法的真相 — 編譯器替你撰寫的程式碼

Codable 是Encodable & Decodable的 typealias,分別要求「如何將自己寫入編碼器」與「如何從解碼器建立自己」的協定。看似魔法,其實是編譯器自動合成(synthesis)的結果。只要所有儲存屬性都符合 Codable,編譯器就會替你產生兩項內容。

第一是 CodingKeys enum:以屬性名稱作為 case 的鍵名清單。第二是透過這些鍵逐一讀寫屬性的init(from:)encode(to:)實作。也就是說,Codable 客製化就是決定要手動取代哪一項產物。只有鍵名不同,就寫 CodingKeys;結構本身不同,則連 init(from:) 都要實作。掌握這個框架後,後續內容都能整理成「要手寫到什麼程度」。

另一個重要事實:Codable 並非 JSON 專用。由於編碼器與解碼器可以替換,你可以用 PropertyListEncoder 處理 plist,也能透過第三方套件處理 XML 或 YAML。型別只需要知道「如何表示自己」,格式則由編碼器決定,這就是關注點分離。

情境 1. 鍵名不同 — CodingKeys 與鍵名策略

伺服器提供user_name,但你想將屬性命名為 userName 時,處方分成兩個層次。

若全域規則一致,只需設定解碼器的一行:decoder.keyDecodingStrategy = .convertFromSnakeCase。所有鍵都會從 snake_case 自動轉成 camelCase。若整個 API 都遵循這項慣例,這就是正解。

若規則不一致,或想直接改名,就手動撰寫 CodingKeys。

struct User: Codable {
    let userName: String
    let signupDate: Date

    enum CodingKeys: String, CodingKey {
        case userName = "user_nm"     // 伺服器的舊版鍵名
        case signupDate = "created"
    }
}

還有一個副作用要記住:從 CodingKeys 移除 case,該屬性就會被排除在編碼與解碼之外。當你想在回應模型中放入快取旗標等僅限本機的狀態時,可以使用這項技巧;但被排除的屬性必須有預設值。

標示 snake case、日期、巢狀、部分失敗四個區段的 JSON 管線示意圖
實務 Codable 的 4 大地雷:鍵名、日期、巢狀、部分失敗

情境 2. 日期 — 格式的地雷區

Date 是 Codable 實務中最常出問題的地雷。JSON 沒有統一的日期標準,因此各伺服器可能使用 Unix 時間戳記(1720000000)、ISO 8601(“2026-07-15T09:30:00Z”)或自訂格式(“2026-07-15 09:30”)。

處方是將解碼器的 dateDecodingStrategy 設為符合伺服器格式:.secondsSince1970.iso8601,自訂格式則使用.formatted(formatter)。使用自訂 DateFormatter 有兩個陷阱。locale 若不固定為en_US_POSIX,解析可能受使用者 12/24 小時設定影響而失敗。此外,伺服器傳回含毫秒的 ISO 8601 時,預設的.iso8601會失敗;開啟 ISO8601DateFormatter 的毫秒選項即可解決。「只有特定使用者日期解析失敗」這類神秘錯誤,八成就是這兩個陷阱。

若同一個 API 中每個欄位的格式都不同,實際做法是只將該欄位以 String 接收,再透過計算屬性轉換,或使用.custom策略分支處理。

情境 3. 巢狀與結構不一致 — 伺服器的形狀 vs 我的形狀

這是伺服器 JSON 深度包裝的情況,例如{"data": {"user": {...}}}。最簡單的處方是照原樣建立外層型別:用struct Envelope: Codable { let data: DataBox }鏡像伺服器結構,再在呼叫端透過envelope.data.user取出內容。明確又容易偵錯,因此實務上以此作為基本做法較好。

如果想讓模型形狀不同於伺服器,例如使用扁平的 User,就手動實作 init(from:),透過 nestedContainer 深入階層。程式碼會增加,但模型會更乾淨、更以領域為中心。判斷標準是模型的使用範圍:全 App 使用的核心模型值得手寫 init(from:);只供單一畫面的回應,鏡像外層結構更經濟。

情境 4. 部分失敗 — 因為一筆資料讓全部失敗

這是實務中最痛苦的情況。100 筆商品的清單中,只要一筆必要欄位是 null,整個陣列解碼就會 throw,畫面也會空白。就像錯誤處理篇提到的,拋出的錯誤會向上傳播。

第一道防線是 Optional。伺服器可能省略的欄位,就誠實宣告為let thumbnail: URL?。把「可能不存在」刻進型別的 Optional 原則,同樣適用於模型設計。

結構上的防禦是容許失敗的包裝器。建立泛型包裝器,將元素解碼失敗吞成 nil,是實務上常見的標準模式。

struct FailableItem<T: Decodable>: Decodable {
    let value: T?
    init(from decoder: Decoder) throws {
        value = try? T(from: decoder)   // 失敗時變成 nil nil
    }
}

let items = try decoder.decode([FailableItem<Product>].self, from: data)
    .compactMap(\.value)   // 只保留成功的項目

這就是 try? 與 compactMap 這兩個前文工具的組合。它把「丟掉一筆壞資料,保留其餘資料」的政策表達成一個型別。但要注意,這個模式會安靜地吞掉失敗。因此在正式環境應記錄失敗數量,避免伺服器資料問題被掩蓋。

只將一筆壞資料篩進 nil 箱,其餘資料通過的分類設備插圖
丟掉一筆壞資料、保留其餘資料,但要用日誌留下被丟棄的項目

偵錯 — DecodingError 早已知道答案

最後介紹解碼失敗時的調查方法。catch try decoder.decode(...)後會捕捉 DecodingError,而這個錯誤比想像中更有幫助。四種情況(keyNotFound、typeMismatch、valueNotFound、dataCorrupted)都包含是哪個鍵、位於哪條 codingPath、原本期待什麼以及實際收到什麼。

因此解碼失敗時,請養成檢查print(error)而不是print(error as? DecodingError)的習慣;至少在開發期間,應在 catch 區塊中依情況印出 codingPath。這能大幅縮短偵錯時間。「JSON 無法解析」的大多數答案,其實已經寫在錯誤裡。

總結

  • Codable 的魔法就是編譯器合成。它替你撰寫 CodingKeys enum 與 init(from:)/encode(to:);客製化的問題,只在於要手動取代其中多少內容。
  • 鍵名:一致的 snake_case 使用一行 keyDecodingStrategy,不規則時手動宣告 CodingKeys。移除 case 就會將該欄位排除在轉換之外。
  • 日期:明確指定符合伺服器格式的 dateDecodingStrategy,並小心 en_US_POSIX locale 與含毫秒 ISO 8601 的陷阱。
  • 巢狀:預設鏡像外層結構,只有核心模型才用 init(from:) + nestedContainer 攤平。
  • 部分失敗:誠實宣告 Optional + FailableItem 模式(try? + compactMap),並透過日誌讓被吞掉的失敗可見。
  • 偵錯:答案就在 DecodingError 的 codingPath 中。

下一篇介紹 @State、@Published 等 at-sign 語法的原理,以及屬性包裝器。實作屬性篇預告的「包住儲存屬性的語法」。

延伸閱讀