JSON 和序列化資料

大多數移動應用都需要與 web 伺服器通訊,同時在某些時候輕鬆地儲存結構化資料。當創造需要網路連線的應用時,它遲早需要處理一些常見的 JSON。

本指南介紹瞭如何在 Flutter 中使用 JSON。包括瞭如何在不同場景中使用相應的 JSON 解決方案,以及為什麼要這麼做。

我需要哪一種 JSON 序列化資料方法?

本文涵蓋了兩種常規的 JSON 使用策略:

  • 手動序列化資料

  • 利用程式碼產生器進行自動序列化資料

不同的專案複雜度不同,使用案例也不一樣。對於較小的概念驗證專案或者快速原型,使用程式碼產生器器可能有些過於繁雜。對於具有很多更加複雜的 JSON 模型的應用,手動編碼可能很快變得無聊、重複並且出現很多小錯誤。

為較小的專案使用手動序列化資料

手動 JSON 解碼是指在 dart:convert 中使用內建的 JSON 解碼器。它包括將原始 JSON 字串傳遞給 jsonDecode() 方法,然後在產生的 Map<String, dynamic> 計算結果中尋找你需要的值。它沒有外部依賴或者特定的設定過程,這有利於快速證明概念。

當你的專案變大時,手動解碼錶現得並不理想。手動編寫解碼邏輯會變得難以管理並容易出錯。如果你產生了筆誤去獲取一個不存在的 JSON 欄位,你的程式碼會在執行時丟擲一個錯誤。

如果你的專案沒有很多的 JSON 模型並且你正在尋找一個快速測試概念的方法,手動序列化資料可能是你要的開始的方式。關於手動編碼的範例,請參閱 使用 dart:convert 手動序列化 JSON 資料

為中大型專案使用程式碼產生器

利用程式碼產生器的 JSON 序列化資料,意味著可以透過外部的函式庫產生編碼範本。在一些初始化設定後,你可以執行檔案監聽程式,來從你的模型類產生程式碼。例如,json_serializablebuilt_value 就是這類別的庫。

這種方法適用於大型專案。不需要手動編寫範本,並且一些試圖去獲取不存在的 JSON 欄位的筆誤,會在編譯階段被發現。程式碼產生器的麻煩之處,在於它需要一些初始化設定。並且,產生的原始檔可能在你的專案導航中產生一些視覺上的混亂。

當你有一箇中大型專案時,你可能想要使用產生的程式碼來進行 JSON 序列化。要看基於程式碼產生器的 JSON 編碼,見 使用程式碼產生器庫序列化 JSON 資料

Flutter 中是否有 GSON/Jackson/Moshi 的等價物?

簡單來說,沒有。

這樣的函式庫需要使用執行時進行 反射,這在 Flutter 中是被禁用的。執行時反射會影響 Dart 支援了相當久的 搖樹最佳化。透過 tree shaking,你可以從你的釋出版本中“抖掉”不需要使用的程式碼。這會顯著最佳化 App 的體積。

由於反射會預設讓所有的程式碼被隱含使用,這讓 tree shaking 變得困難。工具不知道哪一部分在執行時不會被用到,所以冗餘的程式碼很難被清除。當使用反射時,App 的體積不能被輕易最佳化。

儘管你不能在 Flutter 中使用執行時反射,還是有一些函式庫提供了基於程式碼產生器的方便使用的 API,這個方法的更多細節在 程式碼產生器庫 部分。

使用 dart:convert 手動序列化 JSON 資料

在 Flutter 中基礎的序列化 JSON 十分容易的。 Flutter 有一個內建的 dart:convert 的函式庫,這個庫包含了一個簡單的 JSON 編碼器和解碼器。

下面的範例實現了一個簡單使用者模型。

{
  "name": "John Smith",
  "email": "john@example.com"
}

透過 dart:convert,你可以用兩種方法編碼這個 JSON 模型。

內聯序列化 JSON 資料

透過查閱 dart:convert 文件,你會看到你可以將 JSON 字串作為方法的引數,呼叫 jsonDecode() 方法來解碼 JSON。

Map<String, dynamic> user = jsonDecode(jsonString);

print('Howdy, ${user['name']}!');
print('We sent the verification link to ${user['email']}.');

不幸的是,jsonDecode() 返回一個 Map<String, dynamic>,這意味著你在執行時以前都不知道值的型別。使用這個方法,你失去了大部分的靜態型別語言特性:型別安全、自動自動完成以及最重要的編譯時例外。你的程式碼會立即變得更加容易出錯。

例如,當你獲取 name 或者 email 欄位,你可能很快引入一個筆誤。然而編譯器卻無法知道對映中是否有 JSON 筆誤。

在模型類中序列化 JSON 資料

透過引入一個簡單的模型 User 類來解決上面提到的問題。在 User 類中,你會發現:

  • 一個 User.fromJson() 建構函式,用於從對映中構造一個新的 User 例項。

  • 一個 toJson() 方法,這個方法會將 User 例項轉換為一個對映。

透過這種方法,呼叫程式碼 可以擁有型別安全、 nameemail 欄位的自動完成以及編譯時例外檢測。如果你不小心寫錯了,或者把 String 型別的欄位看成了 int 型別,應用將無法編譯,而不是在執行時崩潰。

user.dart

class User {
  final String name;
  final String email;

  User(this.name, this.email);

  User.fromJson(Map<String, dynamic> json)
      : name = json['name'],
        email = json['email'];

  Map<String, dynamic> toJson() => {
        'name': name,
        'email': email,
      };
}

解碼邏輯的責任現在轉移到了模型內部。透過這個新方法,你可以很容易地解碼獲得一個 user 例項。

Map<String, dynamic> userMap = jsonDecode(jsonString);
var user = User.fromJson(userMap);

print('Howdy, ${user.name}!');
print('We sent the verification link to ${user.email}.');

要對 user 例項進行編碼,將 User 物件傳到 jsonEncode() 函式中。你不需要呼叫 toJson() 方法,因為 jsonEncode() 已經幫你做了這件事。

String json = jsonEncode(user);

透過這種方法,被呼叫的程式碼根本不需要擔心序列化 JSON 資料的問題。然而,你仍然需要模型類別。你當然會希望序列化資料在一個生產環境的應用裡能奏效。在實踐中,User.fromJson()User.toJson() 方法都需要單元測試以便驗證正確的行為。

然而,現實場景通常不是那麼簡單,有時候響應的 JSON API 會更加複雜,例如它可能會包含一些相鄰的 JSON 物件,而這些物件同樣需要使用它的 model 類進行解析。

如果有一些東西可以幫你處理 JSON 編碼和解碼就好了。幸運的是,已經有了!

使用程式碼產生器庫序列化 JSON 資料

儘管有其它庫可以使用,但是本指南使用了 json_serializable,一個自動化源程式碼產生器器來為你產生 JSON 序列化資料範本。

由於序列化資料程式碼不再需要手動編寫或者維護,你可以將序列化 JSON 資料在執行時的例外風險降到最低。

在專案中設定 json_serializable

要在你的專案中包含 json_serializable,你需要一個常規依賴,以及兩個 dev 依賴。簡單來說,dev 依賴 是不包括在我們的 App 原始碼中的依賴——它們只會被用在開發環境中。

在序列化 JSON 資料的例子中,這些需要的依賴的最新版本可以在 pubspec 檔案 中檢視。

pubspec.yaml

dependencies:
  # Your other regular dependencies here
  json_annotation: <latest_version>

dev_dependencies:
  # Your other dev_dependencies here
  build_runner: <latest_version>
  json_serializable: <latest_version>

在你的專案根資料夾下執行 flutter pub get (或者在你的編輯器中點選 Packages Get)以確保在你的專案中可以使用這些新的依賴。

以 json_serializable 的方式建立模型類

下面顯示了怎樣將 User 類轉換為 json_serializable 後的類別。簡單起見,該程式碼使用了前面的例子中的簡化的 JSON 模型。

user.dart

import 'package:json_annotation/json_annotation.dart';

/// This allows the `User` class to access private members in
/// the generated file. The value for this is *.g.dart, where
/// the star denotes the source file name.
part 'user.g.dart';

/// An annotation for the code generator to know that this class needs the
/// JSON serialization logic to be generated.
@JsonSerializable()
class User {
  User(this.name, this.email);

  String name;
  String email;

  /// A necessary factory constructor for creating a new User instance
  /// from a map. Pass the map to the generated `_$UserFromJson()` constructor.
  /// The constructor is named after the source class, in this case, User.
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  /// `toJson` is the convention for a class to declare support for serialization
  /// to JSON. The implementation simply calls the private, generated
  /// helper method `_$UserToJson`.
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

透過這個設定,源程式碼產生器器將產生用於 JSON 編碼及解碼 name 以及 email 欄位的程式碼。

如果需要,你可以很輕易地自訂命名策略。例如,如果 API 返回帶有 蛇形命名方式 的物件,並且你想要在你的模型裡使用 小駝峰 的命名方式,你可以使用帶有一個 name 引數的 @JsonKey 註解。

/// Tell json_serializable that "registration_date_millis" should be
/// mapped to this property.
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;

客戶端和伺服器端最好保持同樣的命名規則。 @JsonSerializable() 提供了 fieldRename 列舉,用於將 dart 欄位完整轉換為 JSON 鍵值。

定義 @JsonSerializable(fieldRename: FieldRename.snake) 與新增 @JsonKey(name: '<snake_case>') 到每一個欄位是同樣的效果。

伺服器端的資料有時無法確認,所以在客戶端很有必要進行資料校驗和保護。其他常見的 @JsonKey 宣告方法包括:

/// Tell json_serializable to use "defaultValue" if the JSON doesn't
/// contain this key or if the value is `null`.
@JsonKey(defaultValue: false)
final bool isAdult;

/// When `true` tell json_serializable that JSON must contain the key, 
/// If the key doesn't exist, an exception is thrown.
@JsonKey(required: true)
final String id;

/// When `true` tell json_serializable that generated code should 
/// ignore this field completely. 
@JsonKey(ignore: true)
final String verificationCode;

執行程式碼產生器工具

當你首次建立 json_serializable 類時,你會得到類似下圖的錯誤。

IDE warning when the generated code for a model class does not exist
yet.

這些錯誤完全正常,很簡單,因為這些模型類別的產生程式碼並不存在。要解決這個問題,你需要執行程式碼產生器器來產生序列化資料範本。

有兩種方式執行程式碼產生器器。

一次性程式碼產生器

透過在專案根目錄執行命令 flutter pub run build_runner build --delete-conflicting-outputs,你可以在任何需要的時候為你的模型產生 JSON 序列化資料程式碼。這會觸發一次建構,遍歷原始檔,選擇相關的檔案,然後為它們產生必須的序列化資料程式碼。

雖然這樣很方便,但是如果你不需要在每次修改了你的模型類後都要手動建構那將會很棒。

持續產生程式碼

監聽器 讓我們的源程式碼產生器過程更加方便。它會監聽我們專案中的檔案變化,並且會在需要的時候自動建構必要的檔案。你可以在專案根目錄執行 flutter pub run build_runner watch 啟動監聽。

啟動監聽並讓它留在後台執行是安全的。

使用 json_serializable 模型

為了以 json_serializable 的方式解碼 JSON 字串,你不必對以前的程式碼做任何的改動。

Map<String, dynamic> userMap = jsonDecode(jsonString);
var user = User.fromJson(userMap);

編碼也是如此。呼叫 API 和以前一樣。

String json = jsonEncode(user);

在使用了 json_serializable 後,你可以立馬忘掉 User 類中所有手動序列化的 JSON 資料。源程式碼產生器器會建立一個名為 user.g.dart 的檔案,它包含了所有必須的序列化資料邏輯。你不必再編寫自動化測試來確保序列化資料奏效。現在 由庫來負責 確保序列化資料能正確地被轉換。

為巢狀(Nesting)類 (Nested Classes) 產生程式碼

你可能類在程式碼中用了巢狀(Nesting)類,在你把類作為引數傳遞給一些服務(比如 Firebase)的時候,你可能會遇到 Invalid argument 錯誤。

比如下面的這個 Address 類:

import 'package:json_annotation/json_annotation.dart';
part 'address.g.dart';

@JsonSerializable()
class Address {
  String street;
  String city;

  Address(this.street, this.city);

  factory Address.fromJson(Map<String, dynamic> json) =>
      _$AddressFromJson(json);
  Map<String, dynamic> toJson() => _$AddressToJson(this);
}

一個 Address 類被巢狀(Nesting)在 User 類中使用:

import 'package:json_annotation/json_annotation.dart';

import 'address.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
  User(this.name, this.address);

  String name;
  Address address;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

在終端中執行 flutter pub run build_runner build 建立 *.g.dart檔案,但私有函式 _$UserToJson() 看起來會像下面這樣:

Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
  'name': instance.name,
  'address': instance.address,
};

現在看起來並沒有什麼問題,但當你想要列印 (print()) 這個使用者物件時:

Address address = Address('My st.', 'New York');
User user = User('John', address);
print(user.toJson());

結果會是:

{name: John, address: Instance of 'address'}

而你期望的輸出結果是這樣的:

{name: John, address: {street: My st., city: New York}}

為了得到正常的輸出,你需要在類宣告之前在 @JsonSerializable 方法加入 explicitToJson: true 引數, User 類現在看起來是這樣的:

import 'package:json_annotation/json_annotation.dart';

import 'address.dart';

part 'user.g.dart';

@JsonSerializable(explicitToJson: true)
class User {
  User(this.name, this.address);

  String name;
  Address address;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

瞭解更多資訊,請查閱 json_annotation 這個 package 裡的 JsonSerializable 類別的 explicitToJson 引數等相關文件。

進一步參考

更多資訊,請檢視以下資源: