天天看點

Swift 4 踩坑之 Codable 協定

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 其實是一個組合協定,由

Decodable

Encodable

兩個協定組成:

/// 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
}
複制代碼
           

Encodable

Decodable

分别定義了

encode(to:)

init(from:)

兩個協定函數,分别用來實作資料模型的歸檔和外部資料的解析和執行個體化。最常用的場景就是接口 JSON 資料解析和模型建立。但是 Codable 的能力并不止于此,這個後面會說。

解析 JSON 對象

先來看

Decodable

對 JSON 資料對象的解析。Swift 為我們做了絕大部分的工作,Swift 中的基本資料類型比如

String

Int

Float

等都已經實作了 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 協定,編譯器會提示沒有提供實作:

其實這裡有一個限制:枚舉類型要預設支援 Codable 協定,需要聲明為具有原始值的形式,并且原始值的類型需要支援 Codable 協定:

enum Gender: String, Decodable {
    case male
    case female
    case other
}
複制代碼
           

由于枚舉類型原始值隐式指派特性的存在,如果枚舉值的名稱和對應的 JSON 中的值一緻,不需要顯式指定原始值即可完成解析。

Bool

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

{
    ...
    "isYoungPioneer": true
    ...
}
複制代碼
           

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

var isYoungPioneer: Bool
複制代碼
           

Bool

類型原本沒什麼好講的,不過因為踩到了坑,是以還是得說一說: 目前發現的坑是:

Bool

類型預設隻支援

true

/

false

形式的

Bool

值解析。對于一些使用

/

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

另外可選的格式标準有

secondsSince1970

millisecondsSince1970

iso8601

等,這些都是有詳細說明的通用标準,不清楚的自行谷歌吧 :)

同時 Codable 提供了兩種方自定義

Date

格式的政策:

  • formatted(DateFormatter)

    這種政策通過設定

    DateFormatter

    來指定

    Date

    格式
  • custom((Decoder) throws -> Date)

    custom

    政策接受一個

    (Decoder) -> Date

    的閉包,基本上是把解析任務完全丢給我們自己去實作了,具有較高的自由度
小數解析政策

小數類型(

Float

Double

) 預設也實作了 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

-infinity

nan

三個特殊值的表示方式:

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

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

自定義資料類型

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

{
    "name": "小明",
    "age": ,
    "weight": 
    "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 協定,是以

School

Student

一樣,隻要所有屬性都實作了 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": ,
    "weight": 
    "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 協定作任何修改,隻需要将

JSONEncoder

JSONDecoder

替換成對應的

PropertyListEncoder

PropertyListDecoder

即可。

屬性清單本質上是特殊格式标準的

XML

文檔,是以理論上來說,我們可以參照系統提供的 Decoder/Encoder 自己實作任意格式的資料序列化與反序列化方案。同時蘋果也随時可能通過實作新的 Decoder/Encoder 類來擴充其他資料格式的處理能力。這也正是文章開頭所說的,Codable 的能力并不止于此,它具有很大的可擴充空間。

結語

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

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

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

HandyJSON 是阿裡推出的架構,思路與 Codable 殊途同歸,之前也用過一陣,當時因為對枚舉和

Date

等類型的支援還不夠完善,最終還是用回了ObjectMapper。不過目前看來完善程度已經很高了,或許可以再次嘗試踩下坑。

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

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