Home 寫程式[iOS] Codable 有關的那些小事情

[iOS] Codable 有關的那些小事情

by 艾普利
Photo by Markus Spiske on Unsplash

Photo by Markus Spiske on Unsplash

說到 Codable 已經推出很久了,也用了好幾年了,但是對它還是不算是很了解,該怎麼說呢,因為Codable 最常運用在解析 API Response,基本上寫完之後,除非API 回傳值要改變,大概到離職的那一刻都不會再去修改它了,所以即使它推出了很多年了,實際上接觸的時間意外地少。

最近正好有機會再次寫不少 Codable 的部分,發現了一些過去不知道的東西,把它們記錄在這裡。

CodingKey

最常使用在API 回傳值的Key 風格 不是 Swift 習慣的風格,做為對應解析之用

enum CodingKeys: String, CodingKey {
    case id = "product_id"
    case name
    case description
    case expiryDate = "expiry_date"
}

在API 回傳值裡的 product_id 就會被放到 id裡,這是非常好用的地方。

另一個好用的地方在於也可以讓decoder只解析寫在 CodingKeys 裡的那些參數,舉個例

struct SubItem: Decodable,Identifiable {
   var id = UUID().string
   var name: String
   var description: String
   var isActive: Bool
}

SubItem conform Identifiable protocol ,因此有一個參數是id 且還是直接使用UUID,並不希望它被拿來當解析用的參數時,就可以使用 CodingKey來避開 id ,就不需要再寫 init(from decoder: any Decoder)

struct SubItem: Decodable,Identifiable {
   var id = UUID().string
   var name: String
   var description: String
   var isActive: Bool

   enum CodingKeys: String, CodingKey {
    case name
    case description
    case isActive 
  }
}

Try? & decodeIfPresent()

基於因為不知道API 會回傳什麼,是否真的會依照文件回傳,為了讓App 不會因為回傳值問題需導至 Crash,一般而言,除非100%保証一定會有值的情況下,大多數參數都會被設定為 Optional,在解析的時候就會用上 decodeIfPresent ,當回傳值完全沒有這個參數,或是參數是nil(null)的時候,它會回傳 nil。

一般情況下,這樣就幾乎不會有什麼問題,但是,就是這個但是,當回傳值是有值的時候,但偏偏回傳的資料型態是錯誤的,這樣的寫法就還是會解析失敗,導致整個物件都會是nil。

如果你是覺得「為什麼會回傳資料型態不同的資料來啊? 這不合理啊」,沒錯,理解上不應該出現回傳值的資料有誤的情況,多數情況下都會要求修正。因為App 端對於資料型態相對來說是嚴格的,解析失敗最後就可能造成 Crash ,所以最好是大家都遵守好訂好的資料型態,不應該回傳不正確的型態。

在有點歷史的產品裡或是一開始就沒有 App 端產品就可能會發生,回傳值會變成不預期的資料型態問題,且可能也不是馬上可以修正的情況,這種時候就需要先確認有問題的資料是不是需要用的,若是需要用的就必須要再另外處理,若是不需要用的資料,就是try? 出馬的時候了!

try? 本來就是在解析失敗的時候會回傳nil ,本來比較常會和 decode() 一起使用,如果和 decodeIfPresent 一起使用,所有可以會造成問題的情況都會包含到,這樣就比較不用怕因為資料型態解析失敗問題,當然,前提是有問題的資料並不是會使用到的資料

以下就是合體技的寫法

init(from decoder: any Decoder) throws {
   let container = try decoder.container(keyedBy: CodingKeys.self)
   id = try? container.decodeIfPresent(String.self, forKey: .id)
   name = try? container.decodeIfPresent(String.self, forKey: .name)
}

假如運氣特好遇到…

萬一真的運氣特好遇到同一支API 回傳值在不同情況下某一個參數會回傳完全不同的型態,偏偏你又必須要使用這些參數,那只好自定義解析方式了,方法上網Google 一下就會找得到了,不然也可以詢問 Claude AI.

其方式是 extenstion KeyedDecodingContainer 這個Struct ,寫一個通用的 decode,之後就可以利用這個 decode 為它加上一些自定義的解析方式

func decodeAny<T>(_: T.Type, forKey key: K, transform: ((Any) -> T?)? = nil) throws -> T? {
        guard contains(key) else { return nil }
        
        //預設轉換邏輯
        let defaultTransform: (Any) -> T? = { value in
            if let typedValue = value as? T {
                return typedValue
            }
            
            if let stringConvertible = T.self as? LosslessStringConvertible.Type {
                let stringValue = String(describing: value)
                return stringConvertible.init(stringValue) as? T
            }
            
            return nil
        }
        
        let transformer = transform ?? defaultTransform
        
        //不同型別的解法
        let attempts: [(Any.Type, (K) throws -> Any)] = [
            (String.self, {try decode(String.self, forKey: $0) as Any}),
            (Int.self, {try decode(Int.self, forKey: $0) as Any}),
            (Double.self, {try decode(Double.self, forKey: $0) as Any}),
            (Bool.self, {try decode(Bool.self, forKey: $0) as Any})
        ]
        
        // value: 取得API 資料的型態,transformed: 利用transformer 解析成需要的型態
        for(_, decoder) in attempts {
            if let value = try? decoder(key),
               let transformed = transformer(value) {
                return transformed
            }
        }
    
        return nil
    }

例如 Bool 是最常遇到擁有各種不同型態的表示法的,就可以為它來寫一個方便的解析方式

func decodeBool(forKey key: K) throws -> Bool? {
        try decodeAny(Bool.self, forKey: key) { value in
            switch value {
            case let boolValue as Bool:
                return boolValue
            case let intValue as Int:
                return intValue != 0
            case let doubleValue as Double:
                return doubleValue != 0
            case let stringValue as String:
                let lowercased = stringValue.lowercased()
                if ["true", "t", "yes", "1"].contains(lowercased) { return true }
                if ["false", "f", "no", "0"].contains(lowercased) { return false }
                return nil
            default nil
            }
        }
    }

說真的,最好是不要遇到需要這樣寫的情況,為什麼呢? 主要的原因是萬一真的是 API 的錯誤,但因為App 端有自定義解析的方式,所以就解析成功了,那麼就可能完全不會有人知道可能真的是資料錯誤的問題,當這樣資料數量多了之後,也許就會造成後續資料分析有誤,或有些神奇的情況。

最後,祝大家 Coding 愉快!!

You may also like