如何在Flutter上優雅地序列化一個物件(實用)

閒魚技術發表於2019-01-28

序列化一個物件才是正經事

物件的序列化反序列化是我們日常編碼中一個非常基礎的需求,尤其是對一個物件的json encode/decode操作。每一個平臺都會有相關的庫來幫助開發者方便得進行這兩個操作,比如Java平臺上赫赫有名的GSON,阿里巴巴開源的fastJson等等。

而在Flutter上,藉助官方提供的JsonCodec,只能對primitive/Map/List這三種型別進行json的encode/decode操作,對於複雜型別,JsonCodec提供了receiver/toEncodable兩個函式讓使用者手動“打包”和“解包”。

顯然,JsonCodec提供的功能看起來相當的原始,在閒魚app中存在著大量複雜物件序列化需求,如果使用這個類,就會出現集體“帶薪序列化”的盛況,而且還無法保證正確性。

官方推薦

機智如Google官方,當然不會坐視不理。json_serializable的出現就是官方給出的推薦,它藉助Dart Build System中的*buildrunner和json_annotation庫,來自動生成fromJson/toJson函式內容。(關於使用build_runner*生成程式碼的原理,之前興往同學的文章已經有所提及)

關於如何使用json_serializable網上已經有很多文章了,這裡只簡單提一些步驟:

  • Step 1 建立一個實體類。

  • Step 2 生成程式碼:

讓build runner生成序列化程式碼。執行完成後資料夾下會出現一個xxx.g.dart檔案,這個檔案就是生成後的檔案。

  • Step 3 代理實現:

把fromJson和toJson操作代理給上面生成出來的類。

我們為什麼不用它實現?

json_serializable完美實現了需求,但它也有不滿足需求的一面:

  • 使用起來有些繁瑣,多引入了一個類

  • 很重要的一點是,大量的使用"as"會給效能和最終產物大小產生不小的影響。實際上閒魚內部的《flutter編碼規範》中,是不建議使用"as"的。(對包大小的影響可以參見三笠同學的文章,同時dart linter也對as的效能影響有所描述)

一種正經的方式

基於上面的分析,很明顯的,需要一種新的方式來解決我們面臨的問題,我們暫且叫它fish-serializable

需要實現的功能

我們首先來梳理一下,一個序列化庫需要用到:

  1. 獲取可序列化物件的所有field以及它們的型別資訊

  2. 能夠構造出一個可序列化物件,並對它裡面的fields賦值,且型別正確

  3. 支援自定義型別

  4. 最好能夠解決泛型的問題,這會讓使用更加方便

  5. 最好能夠輕鬆得在不同的序列化/反序列化方式中切換,例如json和protobuf。

困難在哪

  1. flutter禁用了dart:mirrors,反射API無法使用,也就無法透過反射的方式new一個instance、掃描class的fields。

  2. 泛型的問題由於dart不進行型別擦出,可以獲取,但泛型巢狀後依然無法解開。

Let's rock

無法使用dart:mirrors是個“硬”問題,沒有反射的支援,類的內容就是一個黑盒。於是我們在邁出第一步的時候就卡殼了- -!

這個時候筆者腦子裡閃過了很多畫面,白駒過隙,烏飛兔走,啊,不是...是c++,c++作為一種無法使用反射的語言,它是如何實現物件的 序列化/反序列化 操作的呢?

一頓搜尋猛如虎之後,發現大神們使用建立類物件的回撥函式配合宏的方式來實現c++中類似反射這樣的操作。

這個時候,筆者又想到了曾經朝夕相處的Android(現在已經變成了flutter),Android中的Parcelable序列化協議就是一個很好的參照,它透過writeXXX APIs將類的資料寫入一箇中間儲存進行序列化,再透過readXXX APIs進行反序列化,這就解決了我們上面提到的第一個問題,既如何將一個類的“黑盒子”開啟。

同時,Parcelable協議中還需要使用者提供一個叫做Creator的靜態內部類,用來在反序列化的時候反射建立一個該類的物件或物件陣列,對於沒有反射可用的我們來說,用c++的那種回撥函式的方式就可以完美解決反序列化中物件建立的問題。

於是最終我們的基本設計就是:

如何在Flutter上優雅地序列化一個物件(實用)

  • ValueHolder

  1. 這是一個資料中轉儲存的基類,它內部的writeXXX APIs提供展開類內部的fields的能力,而readXXX則

  2. 用來將ValueHolder中的內容讀取賦值給類的fields。

  3. readList/readMap/readSerializable函式中的type argument,我們把它作為外部想要解釋資料的

  4. 方式,比如readSerializable<T>(key: 'object'),表示外部想要把key為object的值解釋為T類

  5. 型。

  • FishSerializable

  1. FishSerializable是一個interface,creator是個一個get函式,用來返回一個“建立類物件的回撥”,

  2. writeTo函式則用來在反序列化的時候放置ValueHoder->fields的程式碼。

  • JsonSerializer

  1. 它繼承於FishSerializer介面,實現了encode/decode函式,並額外提供encodeToMap和

  2. decodeFromMap功能。JsonSerializer類似JsonCodec,直接面向使用者用來json encode/decode

以上,我們已經基本做好了一個flutter上支援物件序列化/反序列化操作的庫的基本架構設計,物件的序列化過程可以簡化為:

如何在Flutter上優雅地序列化一個物件(實用)

由於ValueHolder中間儲存的存在,我們可以很方便得切換 序列化/反序列器,比如現有的JsonSerializer用來實現json的encode/decode,如果有類似protobuf的需求,我們則可以使用ProtoBufSerializer來將ValueHolder中的內容轉換成我們需要的格式。

困難是不存在的

如何匹配型別

為了能支援泛型容器的解析,我們需要類似下面這樣的邏輯

  1. List<SerializableObject> list

  2.    = holder.readList<SerializableObject>(key: 'list');

  3. List<E> readList<E>({String key}){

  4.    List<dynamic> list = _read(key);

  5. }

  6. E _flattenList<E>(List<dynamic> list){

  7.    list?.map<E>((dynamic item){

  8.        // 比較E是否屬於某個型別,然後進行對應型別的轉換      

  9.    });

  10. }

在Java中,可以使用Class#isAssignableFrom,而在flutter中,我們沒有發現類似功能的API提供。而且,如果做下面這個測試,你還會發現一些很有意思的細節:

  1. void main() {

  2.  print('int test');

  3.  test<int>(1);

  4.  print('\r\nint list test');

  5.  test<List<int>>(<int>[]);

  6.  print('\r\nobject test');

  7.  test<A<int>>(A<int>());

  8. }

  9. void test<T>(T t){

  10.  print(T);

  11.  print(t.runtimeType);

  12.  print(T == t.runtimeType);

  13.  print(identical(T, t.runtimeType));

  14. }

  15. class A<T>{

  16. }

輸出的結果是:

如何在Flutter上優雅地序列化一個物件(實用)

可以看到,對於List這樣的容器型別,函式的type argument與instance的runtimeType無法比較,當然如果使用t is T,是可以返回正確的值的,但需要構造大量的物件。所以基本上,我們無法進行型別匹配然後做型別轉換。

如何解析泛型巢狀

接下去就是如何分解泛型容器巢狀的問題,考慮如下場景:

  1. Map<String, List<int>> listMap;

  2. listMap = holder.readMap<String, List<int>>(key: 'listMap');

readMap中得到的value type是一個 List<int>,而我們沒有API去切割這個type argument。所以我們採用了一種比較“笨”也相對實用的方式。我們使用字串切割了type argument,比如:

  1. List<int> => <String>[List<int>, List, int]

然後在內部展開List或Map的時候,使用字串匹配的方式匹配型別,在目前的使用中,完美得支援了標準List和Map容器互相巢狀。但目前無法支援標準List和Map之外的其他容器型別。

What's more

IDE外掛輔助

寫過Android的Parcelable的同學應該有種很深刻的體會,Parcelable協議中有大量的“機械”程式碼需要寫,類似設計的fish-serializable也一樣。

為了不被老闆和使用庫的同學打死,同時開發了fish-serializable-intelij-plugin來自動生成這些“機械”程式碼。

與json_serializable的對比

  • fish-serializable在使用上配合IDE外掛,減少了大量的"as"運算子的使用,同時在步驟上也更加簡短方便。

  • 相比於 json_annotation生成的程式碼, fish-serializable生成的程式碼也更具可讀性,方便手動修改一些程式碼實現。

  • fish-serializable可以透過手動接管 序列化/反序列化 過程的方式完美相容 json_annotation等其他方案。

目前閒魚app中已經開始大量使用。

開源計劃

fish-serializablefish-serializable-intelij-plugin都在開源計劃中,相信不久就可以與大家見面~

相關文章