如何在Dart/Flutter中用代码生成解析JSON

1,313 阅读7分钟

在这篇文章中,我们将学习如何使用Freezed包用代码生成来解析JSON数据。而且我还会分享一个VS Code扩展,让这个过程更加简单。 🙂

准备好了吗?开始吧!

安装 codegen 依赖项

为了让事情顺利进行,我们需要在我们的pubspec.yaml 文件中添加一些依赖项。

dependencies:
  flutter:
    sdk: flutter
  freezed_annotation: ^0.14.2
  json_annotation: ^4.0.1

dev_dependencies:
  build_runner: ^2.0.6
  freezed: ^0.14.2
  json_serializable: ^4.1.4

5个新的依赖项?真的吗?

是的,它们的作用是这样的。

  • json_serializable:为Dart构建系统提供了一些处理JSON的构建器
  • json_annotation: 定义了Dart构建系统所使用的注解json_serializable
  • freezed:一个强大的代码生成器,可以用简单的API处理复杂的用例。
  • freezed_annotation定义了Dart构建系统所使用的注解。freezed
  • build_runner:这是一个独立的构建包,可以为我们生成Dart文件。

听起来很复杂?别担心:只要你导入所有需要的包,就可以了。👍

你可以单独用json_serializable 来生成JSON解析代码(不用freezed )。然而,freezed 更加强大,可以用一个简单的API处理复杂的用例。

一个JSON文档的例子

为了与之前的文章保持一致,我们将重复使用相同的JSON样本。

{
  "name": "Pizza da Mario",
  "cuisine": "Italian",
  "year_opened": 1990,
  "reviews": [
    {
      "score": 4.5,
      "review": "The pizza was amazing!"
    },
    {
      "score": 5.0,
      "review": "Very friendly staff, excellent service!"
    }
  ]
}

作为参考,这里是我们之前写的RestaurantReview 模型类。

class Restaurant {
  Restaurant({
    required this.name,
    required this.cuisine,
    this.yearOpened,
    required this.reviews,
  });
  final String name;
  final String cuisine;
  final int? yearOpened;
  final List<Review> reviews;

  factory Restaurant.fromMap(Map<String, dynamic> data) {
    final name = data['name'] as String;
    final cuisine = data['cuisine'] as String;
    final yearOpened = data['year_opened'] as int?;
    final reviewsData = data['reviews'] as List<dynamic>?;
    final reviews = reviewsData != null
        ? reviewsData.map((reviewData) => Review.fromMap(reviewData)).toList()
        : <Review>[];
    return Restaurant(
      name: name,
      cuisine: cuisine,
      yearOpened: yearOpened,
      reviews: reviews,
    );
  }

  Map<String, dynamic> toMap() {
    return {
      'name': name,
      'cuisine': cuisine,
      if (yearOpened != null) 'year_opened': yearOpened,
      'reviews': reviews.map((review) => review.toMap()).toList(),
    };
  }
}
class Review {
  Review({required this.score, this.review});
  final double score;
  // nullable - assuming the review may be missing
  final String? review;

  factory Review.fromMap(Map<String, dynamic> data) {
    final score = data['score'] as double;
    final review = data['review'] as String?;
    return Review(score: score, review: review);
  }

  Map<String, dynamic> toMap() {
    return {
      'score': score,
      if (review != null) 'review': review,
    };
  }
}

我们可以看到,这是一个很大的代码,如果我们有很多不同的模型,这种方法就无法扩展。

带有Freezed的模型类

为了使我们的生活更轻松,让我们使用Freezed来定义我们的RestaurantReview 模型类。

由于Restaurant 依赖于 Review ,让我们从Review 类开始。

// review.dart
// 1. import freezed_annotation
import 'package:freezed_annotation/freezed_annotation.dart';

// 2. add 'part' files
part 'review.freezed.dart';
part 'review.g.dart';

// 3. add @freezed annotation
@freezed
// 4. define a class with a mixin
class Review with _$Review {
  // 5. define a factory constructor
  factory Review({
    // 6. list all the arguments/properties
    required double score,
    String? review,
  // 7. assign it with the `_Review` class constructor
  }) = _Review;

  // 8. define another factory constructor to parse from json
  factory Review.fromJson(Map<String, dynamic> json) => _$ReviewFromJson(json);
}

在这里使用正确的语法是非常重要的。如果我们遗漏了什么或添加了一个错别字,我们的代码生成器将产生一些错误。

让我们对Restaurant 类进行同样的处理。

// restaurant.dart
import 'package:freezed_annotation/freezed_annotation.dart';
// import any other models we depend on
import 'review.dart';

part 'restaurant.freezed.dart';
part 'restaurant.g.dart';

@freezed
class Restaurant with _$Restaurant {
  factory Restaurant({
    required String name,
    required String cuisine,
    // note: using a JsonKey to map our JSON key that uses
    // *snake_case* to our Dart variable that uses *camelCase*
    @JsonKey(name: 'year_opened') int? yearOpened,
    // note: using an empty list as a default value
    @Default([]) List<Review> reviews,
  }) = _Restaurant;

  factory Restaurant.fromJson(Map<String, dynamic> json) =>
      _$RestaurantFromJson(json);
}

注意RestaurantReview 类都有一个工厂构造函数,列出了我们需要的所有参数,但我们还没有声明相应的属性

事实上,我们的代码是不完整的,会产生像这样的错误。

Target of URI doesn't exist: 'restaurant.freezed.dart'.
Try creating the file referenced by the URI, or Try using a URI for a file that does exist.

The name '_Restaurant' isn't a type and can't be used in a redirected constructor.
Try redirecting to a different constructor.

The method '_$RestaurantFromJson' isn't defined for the type 'Restaurant'.
Try correcting the name to the name of an existing method, or defining a method named '_$RestaurantFromJson'.

让我们来处理一下这个问题。

运行代码生成器

为了生成缺失的代码,我们可以在控制台运行这个。

flutter pub run build_runner build --delete-conflicting-outputs

这将产生以下输出。

[INFO] Generating build script...
[INFO] Generating build script completed, took 419ms

[INFO] Initializing inputs
[INFO] Reading cached asset graph...
[INFO] Reading cached asset graph completed, took 55ms

[INFO] Checking for updates since last build...
[INFO] Checking for updates since last build completed, took 428ms

[INFO] Running build...
[INFO] 1.3s elapsed, 0/2 actions completed.
[INFO] Running build completed, took 2.1s

[INFO] Caching finalized dependency graph...
[INFO] Caching finalized dependency graph completed, took 27ms

[INFO] Succeeded after 2.1s with 5 outputs (5 actions)

如果我们在项目资源管理器中查看,我们可以发现一些新文件。

restaurant.dart
restaurant.freezed.dart
restaurant.g.dart
review.dart
review.freezed.dart
review.g.dart

.freezed.dart 文件包含大量的代码。如果你想看所有生成的代码,你可以查看这个gist

重要的是,对于每个模型类,代码生成器都添加了。

  • 所有我们需要的存储属性(并使它们成为final )。
  • toString() 方法
  • == 操作符
  • hashCode getter变量
  • copyWith() 方法
  • toJson() 方法

相当方便!

如果我们需要修改我们模型类中的任何属性,我们只需要更新它们的工厂构造函数。

@freezed
class Review with _$Review {
  factory Review({
    // update any properties as needed
    required double score,
    String? review,
  }) = _Review;

  factory Review.fromJson(Map<String, dynamic> json) => _$ReviewFromJson(json);
}
@freezed
class Restaurant with _$Restaurant {
  factory Restaurant({
    // update any properties as needed
    required String name,
    required String cuisine,
    @JsonKey(name: 'year_opened') int? yearOpened,
    @Default([]) List<Review> reviews,
  }) = _Restaurant;

  factory Restaurant.fromJson(Map<String, dynamic> json) =>
      _$RestaurantFromJson(json);
}

然后我们可以再次运行代码生成器,Freezed会处理剩下的事情。

flutter pub run build_runner build --delete-conflicting-outputs

Viola!我们现在可以在几行代码中定义类型安全不可变的模型类,并通过运行一个命令生成所有的JSON序列化代码。

基本的JSON注释

Freezed支持许多注释,让我们自定义代码生成器如何处理我们的模型。

最有用的是@JsonKey@Default

下面是我在GitHub上的电影应用中使用它们的例子。

@freezed
class TMDBMovieBasic with _$TMDBMovieBasic {
  factory TMDBMovieBasic({
    @JsonKey(name: 'vote_count') int? voteCount,
    required int id,
    @Default(false) bool video,
    @JsonKey(name: 'vote_average') double? voteAverage,
    required String title,
    double? popularity,
    @JsonKey(name: 'poster_path') required String posterPath,
    @JsonKey(name: 'original_language') String? originalLanguage,
    @JsonKey(name: 'original_title') String? originalTitle,
    @JsonKey(name: 'genre_ids') List<int>? genreIds,
    @JsonKey(name: 'backdrop_path') String? backdropPath,
    bool? adult,
    String? overview,
    @JsonKey(name: 'release_date') String? releaseDate,
  }) = _TMDBMovieBasic;

  factory TMDBMovieBasic.fromJson(Map<String, dynamic> json) =>
      _$TMDBMovieBasicFromJson(json);
}

在这种情况下,JSON响应中的键使用了snake_case 命名惯例,我们可以使用@JsonKey 注解来告诉Freezed哪些键被映射到哪些属性。

如果我们想为一个给定的非空值属性指定一个默认值,我们可以使用@Default 注释。

非空值参数需要一个required 关键字或者一个默认值。如果指定了@Default 注释,如果缺少相应的JSON键值对,将使用其值。

使用Freezed的高级JSON序列化功能

到目前为止,我们所涉及的已经足够在大多数情况下处理JSON序列化。

但是Freezed是一个强大的包,我们可以做一些很酷的事情,比如。

  • 通过指定多个构造函数来生成联合类型
  • 指定自定义的JSON转换器

要了解最先进的功能,请阅读文档中的这一部分。

代码生成的弊端

代码生成有一些明显的好处,如果你有很多模型类,这是一个好办法。

但也有一些弊端。

大量的额外代码

我们的RestaurantReview 模型类非常简单,但是生成的代码已经占用了450行的代码。如果你有很多模型类,这很快就会增加。

代码生成很慢

Dart的代码生成是相当慢的

尽管有一些方法可以缓解这个问题,但在大项目中,codegen会大大减慢你的开发工作流程。

生成的文件应该被添加到git中吗?

如果你在一个团队中工作,并将生成的文件提交到git,那么拉动请求就会变得更难审查。

但如果你不这样做,项目默认就不处于可运行状态,而且。

  • 每个团队成员都需要记住运行codegen步骤(有可能导致不一致)。
  • 需要一个自定义的CI构建步骤来构建该应用

而根据这个投票,对于是否应该将生成的文件添加到git中,甚至没有达成共识。

Dart语言的局限性

代码生成可以提供帮助,但它不是银弹。

根本的问题是Dart(还)没有任何语言特性可以使JSON序列化更容易。

数据类的引入--以及Dart中更广泛的静态元编程--有可能解决这些问题。所以我们可以希望在未来,JSON序列化在Dart中会变得更加容易。

但鉴于目前的语言限制,用Freezed生成代码是我们最好的选择。

VSCode扩展。Json到Dart模型

正如我们所了解的,我们可以用工厂构造函数声明一些模型类,并让代码生成器来处理其他的事情。

但是如果我们可以直接从JSON样本文件中生成一切,那不是很酷吗?

那么,Json to Dart ModelVSCode扩展正是这样做的。

试过之后,我可以说,它的工作做得很好,而且它支持Freezed。因此,如果你想节省一些额外的时间,可以考虑使用它。

还有一个叫QuickType的在线工具,可以在任何语言中生成类型安全的模型类。但它现在还不支持Dart Null Safety。

总结

我们现在已经探索了JSON序列化的各种选项。

这里有一些指导原则来帮助你选择最好的方法。

  • 如果你有几个小的模型类,你可以手工编写JSON解析代码。
  • 如果你有很多模型类,Freezed可以为你完成繁重的工作。
  • 为了进一步加快速度,可以考虑使用VS Code的Json to Dart Model扩展。

编码愉快!