ios swift json decodable

Swift JSON Decode with @propertyWrapper

方歡霆 2020/12/18 13:24:00
1957

前言

在App開發中,時常需要與API溝通解析JSON資料,而Swift中的Codable協定,使得日常的JSON解析變得更方便了,它讓我們可以簡單地讓一個符合Codable協定的物件,去做JSON encode、decode,但使用上仍有些小狀況很惱人,本文將闡述如何使用 @propertyWrapper 來解決 JSON Decode 預設值的問題。

先來一些小狀況

設置optional參數避免JSON無key值

若物件內的某個參數,其對應API回傳的JSON可能不會包含該參數資料,為了避免JSON decode時發生key值遺失錯誤的窘境,可以簡單的將其設置為optional,表示解碼時,若該key值不存在,則設定為nil。

比如一個使用者資訊,一個參數表達該會員是否驗證通過:

解析JSON:

會得到:

即使JSON回傳的資料中沒有包含isAuthorized,仍然可以解析成功,但若要在這樣的物件中使用optional參數,常常得用 if let, guard let 或是 isAuthorized ?? false 這類的方式使用,在操作上並不是那麼的方便。

另外如果希望給予其初始值,比如預設使用者是沒有被認證的,一般來說可能會在物件內這樣設定:

但使用decode解析上述所示的JSON資料,isAuthorized仍然會是nil!

要解決這樣的問題,可以實作 init(from:),在isAuthorized key值不存在時指定初始值:

雖然實作 init(from:)可以解決,但在多個物件內都要重複這樣做的話,那真的是勞心勞力啊······

JSON參數值不在列舉(Enum)範圍內

有時為了使用方便,會將參數設置為列舉,除了方便以外,也能避免開發中在進行條件判斷時誤用的情形,現在假設使用者物件多了一個成員分類屬性(normal, vip):

列舉結構如下:

解析JSON:

會得到:

如此一來,decode的物件在使用memberClass就可以直接 .normal 或 .vip,不用再手動輸入字串"normal", "vip",或是另外再為這兩種情形創建常數MEMBER_CLASS_NORMAL, MEMBER_CLASS_VIP之類的,開發過程中著實方便許多!

遇到key值可能遺失的情況,也可以將參數設置為optional:

到目前為止沒什麼太大的問題,但是有個問題卻是設置optional沒辦法解決的:「當回傳值並不在列舉的範圍內時,會解析錯誤」,雖然已經將memberClass設置為optional,不過這樣的設置實質上的意義是:「當key值不存在時,設置為nil」,而非:「當解析錯誤時,設置為nil」。

接著嘗試解析以下JSON:

將memberClass回傳"vvip",由於此回傳值並非包含在MemberClass的範圍內(normal, vip),所以在解析時並非會取得一個正常的UserInfo物件,而是會造成解析錯誤:

這樣情境可能發生在後端資料新增了資料,API已更新但App端卻還未更新的情況,相信這樣的風險是大家都不樂見的,接下來看看怎麼使用@propertyWrapper來簡化處理這些問題吧!

Decode with @propertyWrapper

@propertyWrapper是用來將property存取邏輯程式碼與定義物件程式碼分開,可以想成是一個特別的包裝盒,在對property進行存取時,都會進行@propertyWrapper的包裝、拆封邏輯,詳細的說明可以參考官方文件

首先試著來處理Bool的部分,最後希望的成果如下:

定義一個 @DecodableDefaultFalse property wrapper,往後只需在同類型的Bool參數上方加上即可,不需再另外寫CodingKeys列舉與實作init(from:)!

第一步先初始一個@DecodableDefaultFalse property wrapper,並將wrappedValue初始設為false:

接著讓它符合Decodable protocol:

在此自定義解碼的init(from:),如此一來在做decode時就會透過這個解碼器做解碼了,這樣可以預防JSON回傳值非Bool的情況,比如:

即使isAuthorized回傳非Bool型態,仍然可以將物件正確解析出來,並給予初始值 false。

但還需要解決decode key值遺失的情況,接著再擴充KeyedDecodingContainer:

大功告成!不論是JSON內的參數值類型錯誤,或是key值遺失的情況,都可以避免decode錯誤,正常解析物件出來囉~

使用泛型(Generic)

雖然說是大功告成了,不過上述的解決方案是針對Bool,如果要讓 MemberClass 列舉也有相同的效果,上述步驟都要再來一次,而且如果希望設定一個decode預設值為true的Bool property,也必須如法炮製@DecodableDefaultFalse再做一個@DecodableDefaultTrue,似乎好像感覺也沒有那麼方便嘛?

為了讓使用過程簡單化,只好請出"泛型"大神來幫忙解決這個問題,希望最後的成果如下:

首先我們把@DecodableDefaultFalse改名為@DecodableDefault,並將wrappedValue的型別換成泛型:

此時 T 符合Decodable protocol,一切似乎順利進行,但接下來要實作解碼器的時候便出現了問題:

再給予初始值的時候會報錯,由於 T 不像先前的Bool可以直接給一個false,為了要讓 T 本身擁有初始值,所以只好在把@DecodableDefault中的這個 T 進行改造。

先另外新增一個DefaultValue的protocol如下:

這個協定的用意是讓遵循此protocol的型別,提供所要的初始值defaultValue,且ValueType需遵守Decodable。

接著把@DecodableDefault稍微調整一下,讓原先的 T 改符合DefaultValue protocol:

再來就是實作剛剛碰壁的解碼器:

終於完成了泛型的@propertyWrapper!!!

因為DecodableDefault中的 T 是必須被指定的,而這個指定的型別必須符合 DefaultValue protocol,沿著這個思路,來看看Decode預設值為false的Bool該怎麼做。

首先這個型別必須是Bool(廢話),那是否直接讓Bool去符合DefaultValue protocol就好了呢?

看起來好像有點問題,defaultValue給true或false好像都不對,因為一但定義了其中一種,另外一種就不能用了;還是說要在Bool裡設置另一個參數或是方法,透過條件判斷來賦予defaultValue值呢?

其實不用這麼複雜,因為defaultValue是透過 associatedtype去決定型別的,也就是說只要讓某個enum, struct, class符合DefaultValue protocol,再讓defaultValue = true(or false)就可以了,associatedtype就會自動辨別為Bool型別,也就是說根本不用直接讓Bool型別親自去做這件事情,不過為了分類以及容易閱讀,還是將此兩種情況擴充在Bool裡:

然後複製貼上也把String, MemberClass Enum設定好:

這邊MemberClass新加了unknown,用來表示接受到了不明的值,這邊是用了偷懶的做法只預設一種情境,當然也可以像圖中Bool, String的做法依樣設置各種不同情境。

終於達成了我們期望的結果!

以後再做到需要接API JSON Decode的物件時,還要實作CodingKeys?實作囉唆的init(from:)?

再也不用了!只要在定義參數的同時在上面加個@DecodableDefault<XXX>,就一切好輕鬆、好順利、好愉快。

BUT!!!

每次打@DecodableDefault<XXX>這一串,DecodableDefault XCode是會幫我們找出來,但之後都還要打"<",指定完XXX型別還要再打">",打起來很煩很卡怎麼辦?

沒關係,既然都走到這一步了,為了日後的方便,再稍微努力擴充一下@DecodableDefault吧:

來看看產生了什麼變化:

太好啦!以後要用到的時候,只要打幾個關鍵字,XCode自動就會找出來推薦給我們了!

結語

Decodable的出現讓Swift在解析JSON的時候變得更方便,可是在某些小地方仍然有使用上的麻煩,透過@propertyWrapper的解碼邏輯包裝以及泛型應用的結合,可以將繁瑣的處理大幅簡化,也讓定義物件的程式碼乾淨許多!

方歡霆