Swift 4 踩坑之 Codable 協議

Harley-xk發表於2017-12-19

WWDC 過去有一段時間了,最近終於有時間空閒,可以靜下心來仔細研究一下相關內容。對於開發者來說,本屆WWDC 最重要的訊息還是得屬 Swift 4 的推出。

Swift 經過三年的發展,終於在 API 層面趨於穩定。從 Swift 3 遷移程式碼到 Swift 4 終於不用像 2 到 3 那樣痛苦了。這對開發者來說實在是個重大利好,應該會吸引一大批對 Swift 仍然處於觀望狀態的開發者加入。

另外 Swift 4 引入了許多新的特性,像是 fileprivate 關鍵字的限制範圍更加精確了;宣告屬性終於可以同時限制型別和協議了;新的 KeyPath API 等等,從這些改進我們可以看到,Swift 的生態越來越完善,Swift 本身也越來越強大。

而 Swift 4 帶來的新特性中,最讓人眼前一亮的,我覺得非 Codable 協議莫屬,下面就來介紹下我自己對 Codable 協議踩坑的經驗總結。

簡單介紹

Swift 由於型別安全的特性,對於像 JSON 這類弱型別的資料處理一直是一個比較頭疼的問題,雖然市面上許多優秀的第三方庫在這方面做了不少努力,但是依然存在著很多難以克服的缺陷,所以 Codable 協議的推出,一來打破了這樣的僵局,二來也給我們解決類似問題提供了新的思路。

通過檢視定義可以看到,Codable 其實是一個組合協議,由 DecodableEncodable 兩個協議組成:

/// A type that can convert itself into and out of an external representation.
public typealias Codable = Decodable & Encodable

/// A type that can encode itself to an external representation.
public protocol Encodable {
    public func encode(to encoder: Encoder) throws
}

/// A type that can decode itself from an external representation.
public protocol Decodable {
    public init(from decoder: Decoder) throws
}
複製程式碼

EncodableDecodable 分別定義了 encode(to:)init(from:) 兩個協議函式,分別用來實現資料模型的歸檔和外部資料的解析和例項化。最常用的場景就是介面 JSON 資料解析和模型建立。但是 Codable 的能力並不止於此,這個後面會說。

解析 JSON 物件

先來看 Decodable 對 JSON 資料物件的解析。Swift 為我們做了絕大部分的工作,Swift 中的基本資料型別比如 StringIntFloat 等都已經實現了 Codable 協議,因此如果你的資料型別只包含這些基本資料型別的屬性,只需要在型別宣告中加上 Codable 協議就可以了,不需要寫任何實際實現的程式碼,這也是 Codable 最大的優勢所在。

比如我們有下面這樣一個學生資訊的 JSON 字串:

let jsonString =
"""
{
    "name": "小明",
    "age": 12,
    "weight": 43.2
}
"""
複製程式碼

這時候,只需要定義一個 Student 型別,宣告實現 Decodable 協議即可,Swift 4 已經為我們提供了預設的實現:

struct Student: Decodable {   
    var name: String
    var age: Int
    var weight: Float
}
複製程式碼

然後,只需要一行程式碼就可以將 小明 解析出來了:

let xiaoming = try JSONDecoder().decode(Student.self, from: jsonString.data(using: .utf8)!)
複製程式碼

這裡需要注意的是, decode 函式需要外部資料型別為 Data 型別,如果是字串需要先轉換為 Data 之後操作,不過像 Alamofire 之類的網路框架,返回資料原本就是 Data 型別的。 另外 decode 函式是標記為 throws 的,如果解析失敗,會丟擲一個異常,為了保證程式的健壯性,需要使用 do-catch 對異常情況進行處理:

do {
    let xiaoming = try JSONDecoder().decode(Student.self, from: data)
} catch {
    // 異常處理
}
複製程式碼

特殊資料型別

很多時候光靠基本資料型別並不能完成工作,往往我們需要用到一些特殊的資料型別。Swift 對許多特殊資料型別也提供了預設的 Codable 實現,但是有一些限制。

列舉
{
    ...
    "gender": "male"
    ...
}
複製程式碼

性別是一個很常用的資訊,我們經常會把它定義成列舉:

enum Gender {
    case male
    case female
    case other
}
複製程式碼

列舉型別也預設實現了 Codable 協議,但是如果我們直接宣告 Gender 列舉支援 Codable 協議,編譯器會提示沒有提供實現:

Swift 4 踩坑之 Codable 協議

其實這裡有一個限制:列舉型別要預設支援 Codable 協議,需要宣告為具有原始值的形式,並且原始值的型別需要支援 Codable 協議:

enum Gender: String, Decodable {
    case male
    case female
    case other
}
複製程式碼

由於列舉型別原始值隱式賦值特性的存在,如果列舉值的名稱和對應的 JSON 中的值一致,不需要顯式指定原始值即可完成解析。

Bool

我們的資料模型現在新增了一個欄位,用來表示某個學生是否是少先隊員:

{
    ...
    "isYoungPioneer": true
    ...
}
複製程式碼

這時候,直接宣告對應的屬性就可以了:

var isYoungPioneer: Bool
複製程式碼

Bool 型別原本沒什麼好講的,不過因為踩到了坑,所以還是得說一說: 目前發現的坑是:Bool 型別預設只支援 true/false 形式的 Bool 值解析。對於一些使用 0/1 形式來表示 Bool 值的後端框架,只能通過 Int 型別解析之後再做轉換了,或者可以自定義實現 Codable 協議。

日期解析策略

說了列舉和 Bool,另外一個常用的特殊型別就是 Date 了,Date 型別的特殊性在於它有著各種各樣的格式標準和表示方式,從數字到字串可以說是五花八門,解析 Date 型別是任何一個同型別的框架都必須面對的課題。

對此,Codable 給出的解決方案是:定義解析策略。JSONDecoder 類宣告瞭一個 DateDecodingStrategy 型別的屬性,用來制定 Date 型別的解析策略,同樣先看定義:

/// The strategy to use for decoding `Date` values.
public enum DateDecodingStrategy {
    
    /// Defer to `Date` for decoding. This is the default strategy.
    case deferredToDate
    
    /// Decode the `Date` as a UNIX timestamp from a JSON number.
    case secondsSince1970
    
    /// Decode the `Date` as UNIX millisecond timestamp from a JSON number.
    case millisecondsSince1970
    
    /// Decode the `Date` as an ISO-8601-formatted string (in RFC 3339 format).
    case iso8601
    
    /// Decode the `Date` as a string parsed by the given formatter.
    case formatted(DateFormatter)
    
    /// Decode the `Date` as a custom value decoded by the given closure.
    case custom((Decoder) throws -> Date)
}
複製程式碼

Codable 對幾種常用格式標準進行了支援,預設啟用的策略是 deferredToDate,即從 **UTC 時間2001年1月1日 **開始的秒數,對應 Date 型別中 timeIntervalSinceReferenceDate 這個屬性。比如 519751611.125429 這個數字解析後的結果是 2017-06-21 15:26:51 +0000

另外可選的格式標準有 secondsSince1970millisecondsSince1970iso8601 等,這些都是有詳細說明的通用標準,不清楚的自行谷歌吧 :)

同時 Codable 提供了兩種方自定義 Date 格式的策略:

  • formatted(DateFormatter) 這種策略通過設定 DateFormatter 來指定 Date 格式
  • custom((Decoder) throws -> Date) custom 策略接受一個 (Decoder) -> Date 的閉包,基本上是把解析任務完全丟給我們自己去實現了,具有較高的自由度
小數解析策略

小數型別(FloatDouble) 預設也實現了 Codable 協議,但是小數型別在 Swift 中有許多特殊值,比如圓周率(Float.pi)等。這裡要說的是另外兩個屬性,先看定義:

/// Positive infinity.
///
/// Infinity compares greater than all finite numbers and equal to other
/// infinite values.
public static var infinity: Double { get }

/// A quiet NaN ("not a number").
///
/// A NaN compares not equal, not greater than, and not less than every
/// value, including itself. Passing a NaN to an operation generally results
/// in NaN.
public static var nan: Double { get }
複製程式碼

infinity 表示正無窮(負無窮寫作:-infinity),nan 表示沒有值,這些特殊值沒有辦法使用數字進行表示,但是在 Swift 中它們是確確實實的值,可以參與計算、比較等。 不同的語言、框架對此會有類似的實現,但是表達方式可能不完全相同,因此如果在某些場景下需要解析這樣的值,就需要做特殊轉換了。

Codable 的實現方式比較簡單粗暴,JSONDecoder 型別有一個屬性 nonConformingFloatDecodingStrategy ,用來指定不一致的小數轉換策略,預設值為 throw, 即直接丟擲異常,解析失敗。另外一個選擇就是自己指定 infinity-infinitynan 三個特殊值的表示方式:

let decoder = JSONDecoder()
decoder.nonConformingFloatDecodingStrategy = .convertFromString(positiveInfinity: "infinity", negativeInfinity: "-infinity", nan: "nan")
// 另外一種表示方式
// decoder.nonConformingFloatDecodingStrategy = .convertFromString(positiveInfinity: "∞", negativeInfinity: "-∞", nan: "n/a")
複製程式碼

目前看來只支援這三個特殊值的轉換,不過這種特殊值的使用場景應該非常有限,至少在我自己五六年的開發生涯中還沒有遇到過。

自定義資料型別

純粹的基本資料型別依然不能很好地工作,實際專案的資料結構往往是很複雜的,一個資料型別經常會包含另一個資料型別的屬性。比如說我們這個例子中,每個學生資訊中還包含了所在學校的資訊:

{
    "name": "小明",
    "age": 12,
    "weight": 43.2
    "school": {
      "name": "市第一中學",
      "address": "XX市人民中路 66 號"
    }
}
複製程式碼

這時候就需要 Student 和 School 兩個型別來組合表示:

struct School: Decodable {
	var name: String
	var address: String
}
struct Student: Decodable {   
    var name: String
    var age: Int
    var weight: Float
    var school: School
}
複製程式碼

由於所有基本型別都實現了 Codable 協議,因此 SchoolStudent 一樣,只要所有屬性都實現了 Codable 協議,就不需要手動提供任何實現即可獲得預設的 Codable 實現。由於 School 支援了 Codable 協議,保證了 Student 依然能夠獲得預設的 Codable 實現,因此,巢狀型別的解析同樣不需要額外的程式碼了。

自定義欄位

很多時候前後端不一定能完全步調一致,觀念相同。所以往往後端給出的資料結構中會有一些比較個性的欄位名,當然有時候是我們自己。另外有一些框架(比如我正在用的 Laravel)習慣使用蛇形命名法,而 iOS 的程式碼規範推薦使用駝峰命名法,為了保證程式碼風格和平臺特色,這時候就必須要自行指定欄位名了。

在研究自定義欄位之前我們需要深入底層,瞭解下 Codable 預設是怎麼實現屬性的名稱識別及賦值的。通過研究底層的 C++ 原始碼可以發現,Codable 通過巧(kai)妙(guà)的方式,在編譯程式碼時根據型別的屬性,自動生成了一個 CodingKeys 的列舉型別定義,這是一個以 String 型別作為原始值的列舉型別,對應每一個屬性的名稱。然後再給每一個宣告實現 Codable 協議的型別自動生成 init(from:)encode(to:) 兩個函式的具體實現,最終完成了整個協議的實現。

所以我們可以自己實現 CodingKeys 的型別定義,並且給屬性指定不同的原始值來實現自定義欄位的解析。這樣編譯器會直接採用我們已經實現好的方案而不再重新生成一個預設的。

比如 Student 需要增加一個出生日期的屬性,後端介面使用蛇形命名,JSON 資料如下:

{
    "name": "小明",
    "age": 12,
    "weight": 43.2
    "birth_date": "1992-12-25"
}
複製程式碼

這時候在 Student 型別宣告中需要增加 CodingKeys 定義,並且將 birthday 的原始值設定為 birth_date

struct Student: Codable {
	...
	var birthday: Date
	
	enum CodingKeys: String, CodingKey {
        case name
        case age
        case weight
        case birthday = "birth_date"
    }
}
複製程式碼

需要注意的是,即使屬性名稱與 JSON 中的欄位名稱一致,如果自定義了 CodingKeys,這些屬性也是無法省略的,否則會得到一個 Type 'Student' does not conform to protocol 'Codable' 的編譯錯誤,這一點還是有點坑的。不過在編譯時給 CodingKeys 補全其他預設的屬性的宣告在理論上是可行的,期待蘋果後續的優化了。

可選值

有些欄位有可能會是空值。還是用學生的出生日期來舉例,假設有些學生的出生日期沒有統計到,這時候後臺返回資料格式有兩種選擇,一種是對於沒有出生日期的資料,直接不包含 birth_date 欄位,另一種是指定為空值:"birth_date": null

對於這兩種形式,都只需要將 birthday 屬性宣告為可選值即可正常解析:

...
var birthday: Date?
...
複製程式碼

解析 JSON 陣列

Codable 協議同樣支援陣列型別,只需要滿足一個前提:只要陣列中的元素實現了 Codable 協議,陣列將自動獲得 Codable 協議的實現。

使用 JSONDecoder 解析時只需要指定型別為對應的陣列即可:

do {
    let students = try JSONDecoder().decode([Student].self, from: data)
} catch {
    // 異常處理
}
複製程式碼

歸檔資料

歸檔資料使用 Encodable 協議,使用方式與 Decodable 一致。

匯出為 JSON

將資料模型轉換為 JSON 與解析過程類似,將 JSONDecoder 更換為 JSONEncoder 即可:

let data = try JSONEncoder().encode(xiaomin)
let json = String(data: data, encoding: .utf8)
複製程式碼

JSONEncoder 有一個 outputFormatting 的屬性,可以指定輸出 JSON 的排版風格,看定義:

public enum OutputFormatting {
    
    /// Produce JSON compacted by removing whitespace. This is the default formatting.
    case compact
    
    /// Produce human-readable JSON with indented output.
    case prettyPrinted
}
複製程式碼
  • compact

    預設的 compact 風格會移除 JSON 資料中的所有格式資訊,比如換行、空格和縮緊等,以減小 JSON 資料所佔的空間。如果匯出的 JSON 資料使用者程式間的通訊,對閱讀要求不高時,推薦使用這個設定。

  • prettyPrinted

    如果輸出的 JSON 資料是用來閱讀檢視的,那麼可以選擇 prettyPrinted,這時候輸出的 JSON 會自動進行格式化,新增換行、空格和縮排,以便於閱讀。類似於上面文中使用的 JSON 排版風格。

屬性列表(PropertyList)

Codable 協議並非只支援 JSON 格式的資料,它同樣支援屬性列表,即 mac 上常用的 plist 檔案格式。這在我們做一些系統配置之類的工作時會很有用。

屬性列表的解析和歸檔秉承了蘋果API一貫的簡潔易用的特點,使用方式 JSON 格式一致,並不需要對已經實現的 Codable 協議作任何修改,只需要將 JSONEncoderJSONDecoder 替換成對應的 PropertyListEncoderPropertyListDecoder 即可。

屬性列表本質上是特殊格式標準的 XML 文件,所以理論上來說,我們可以參照系統提供的 Decoder/Encoder 自己實現任意格式的資料序列化與反序列化方案。同時蘋果也隨時可能通過實現新的 Decoder/Encoder 類來擴充套件其他資料格式的處理能力。這也正是文章開頭所說的,Codable 的能力並不止於此,它具有很大的可擴充套件空間。

結語

到此 Codable 的核心用法基本講完了。相比目前比較常用的幾個框架:

ObjectMapper 使用範型機制進行模型解析,但是需要手動對每一個屬性寫對映關係,比較繁瑣。我自己專案中也是用的這個框架,後來自己對其做了些優化,利用反射機制對基本資料型別實現了自動解析,但是自定義型別仍然需要手動寫對映,並且必須繼承實現了自動解析的 Model 基類,限制較多。

SwiftyJSON 簡單瞭解過,其本質其實只是將 JSON 解析成了字典型別的資料,而實際使用時依然需要使用下標方式去取值,非常繁瑣且容易出錯,不易閱讀和維護,個人認為這是很糟糕的設計。

HandyJSON 是阿里推出的框架,思路與 Codable 殊途同歸,之前也用過一陣,當時因為對列舉和 Date 等型別的支援還不夠完善,最終還是用回了ObjectMapper。不過目前看來完善程度已經很高了,或許可以再次嘗試踩下坑。

總體來說,Codable 作為語言層面對模型解析的支援方案,有其自身的優勢。不過在靈活性上稍有欠缺,對自定義欄位的支援也還不夠人性化,期待後續的完善。

對於第三方庫來說,Codable 的推出既是一種挑戰,但同時也是一個機遇,相信這些框架的作者們都會從 Codaable 獲得許多靈感來優化提升自己的框架,在不久的將來製造一個百家爭鳴的局面。

相關文章