build_runner
build_runner 是Dart 生态中处理代码生成任务的标准执行引擎。
引子
设想我们正在使用 OMDb API的 "Search By Title" (通过标题查找电影),访问 https://www.omdbapi.com/?apikey=<APIKEY>&t=tiger 得到一个JSON字符串:
{
"Title": "Tiger",
"Year": "2021",
"Rated": "TV-MA",
"Released": "10 Jan 2021",
"Runtime": "192 min",
"Genre": "Documentary, Biography, Sport",
"Director": "Matthew Hamachek, Matthew Heineman",
"Writer": "N/A",
"Actors": "Tiger Woods, Pete McDaniel, Steve Williams",
"Plot": "A look at the life, success and scandals of golf legend Tiger Woods.",
"Language": "English",
"Country": "United States",
"Awards": "1 win & 2 nominations total",
"Poster": "https://m.media-amazon.com/images/M/MV5BNDRiN2I0NTktOGZkNy00ZTBhLWFjMzYtZjM5M2QxMzA2YjkzXkEyXkFqcGc@._V1_SX300.jpg",
"Ratings": [
{
"Source": "Internet Movie Database",
"Value": "7.8/10"
}
],
"Metascore": "N/A",
"imdbRating": "7.8",
"imdbVotes": "5,132",
"imdbID": "tt12688688",
"Type": "series",
"totalSeasons": "N/A",
"Response": "True"
}
我们的目标是实现 JSON与Dart类间的双向转换。
JSON与Dart类间的双向转换
手工转换
我们可以手工实现转换操作,像下面这样:
// movie1.dart
class Movie {
String? title;
String? year;
String? rated;
// ... (omited for brevity)
List<Ratings>? ratings;
Movie({
title,
year,
rated,
// ... (omited for brevity)
ratings,
});
Movie.fromJson(Map<String, dynamic> json) {
title = json['Title'];
year = json['Year'];
rated = json['Rated'];
// ... (omited for brevity)
if (json['Ratings'] != null) {
ratings = <Ratings>[];
json['Ratings'].forEach((v) {
ratings!.add(Ratings.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['Title'] = title;
data['Year'] = year;
data['Rated'] = rated;
// ... (omited for brevity)
if (ratings != null) {
data['Ratings'] = ratings!.map((v) => v.toJson()).toList();
}
return data;
}
}
class Ratings {
// ... (omited for brevity)
}
但是写这样的代码实在枯燥乏味,又容易出错——比如一不小心弄错了字母的大小写或拼错了某个单词,将来更新或新增Movie的字段时,又必须小心翼翼地去更新 fromJson/toJson 方法。
在线工具
幸好已经有现成的工具去处理像 Movie 类这样的样板代码。比如
- json_to_dart (https://javiercbk.github.io/json_to_dart/): 这个工具比较纯粹,我们将 OMDb 的 Responese 扔给它就好,它会递归地处理
Ratings这种嵌套对象。 - quicktype (https://app.quicktype.io/): 该工具提供了很多选项,例如后面我们会用到的
Generate annotations for json_serializable。
json_serializable
目前Dart官方推荐的做法是使用 json_serializable 实现 JSON 与 Dart类的双向转换。json_serializable 是一个被广泛使用的 Builder。在 Flutter 和 Dart 生态中,json_serializable 几乎是处理结构化数据的事实标准。
下面演示其用法。
a. 添加下面依赖.
dart pub add json_annotation
dart pub add dev:build_runner
dart pub add dev:json_serializable
b. 利用 quicktype 在线工具(或IDE插件)生成 Movie 类(注意开启Generate annotations for json_serializable).
// movie.dart
import 'package:json_annotation/json_annotation.dart';
part 'movie.g.dart';
@JsonSerializable()
class Movie {
// ... (omited for brevity)
}
注意 movie.dart 中的这两行:
part 'movie.g.dart';
@JsonSerializable()
build_runner 将根据注解 @JsonSerializable 来生成 movie.g.dart 文件。
注: part 指令就像是一条脐带。它让你的主业务文件保持整洁,同时源源不断地从生成的附属文件中吸取复杂的 JSON 解析能力。
c. 生成 movie.g.dart 文件.
dart run build_runner build
d. 如果要持续监听 movie.dart文件的变化,使用 watch 指令:
dart run build_runner watch
这样每当我们向 movie.dart 里增加/修改一个 OMDb 字段,对应的 .g.dart 就会在后台自动更新,fromJson/toJson 逻辑始终保持正确。
build_runner 的高级用法
想必读者已经对 build_runner 有了初步印象。它功能强大且上手简单,确实是处理代码生成的利器。然而,在面对复杂的企业级项目时,仅仅‘简单会用’是不够的。接下来我们将探讨 build_runner 的一些高级用法。
定制 build.yaml
精细化扫描范围
默认情况下,build_runner 会检查项目里所有的 .dart 文件。通过配置 targets,我们可以强制它只看特定的目录。例如下面的 build.yaml:
targets:
$default:
builders:
# 针对 json_serializable 的配置
json_serializable:
generate_for:
- bin/ch09/*.dart # 只扫描ch09文件夹
- bin/ch09/**.dart # 支持递归扫描
排除不必要的文件
中大型项目通常会有大量的生成文件或测试文件,排除它们可以极大减轻 build_runner 的负担。
targets:
$default:
sources:
include:
- lib/**
- pubspec.yaml
exclude:
- lib/generated/** # 排除已生成的冗余文件
- test/** # 排除测试代码,除非你需要为测试生成 Mock
全局控制
有些时候,你希望所有的模型都具备某些特性(比如 explicit_to_json),而不想在每个类上都手动输入 @JsonSerializable(explicitToJson: true)。
builders:
json_serializable:
options:
any_map: true # 允许解析非 String 键的 Map
explicit_to_json: true # 嵌套对象自动调用 toJson()
create_factory: true # 自动创建 fromJson 工厂方法
条件化构建
对于中大型项目,一个常见的需求是:在开发环境生成带有调试信息的代码,在生产环境生成极简、高性能的代码。可以通过 build.yaml 变体(如 build.debug.yaml)来实现条件构建。
# 使用特定的配置文件进行构建
dart run build_runner build --config debug
此时 build_runner 会自动寻找项目根目录下的 build.debug.yaml。
缓存与冲突
build_runner 并不是每次都从零开始。为了提速,它将所有的中间产物、文件快照和构建图(Build Graph)都存储在项目根目录下的 .dart_tool/build 文件夹中。当我们再次构建时,它只处理发生变化的文件,实现“秒级更新”。当我们运行构建时,如果 build_runner 发现它要生成的文件(如 movie.g.dart)已经存在,并且不是由当前的构建流程生成的,它会报 Conflicting outputs 错误。
使用以下命令它告诉 build_runner:如果是旧的生成文件,直接删了重写。
dart run build_runner build --delete-conflicting-outputs
如果 --delete-conflicting-outputs 依然无法解决问题(例如报错信息莫名其妙),那就需要执行 clean:
dart run build_runner clean
该指令会清空整个 .dart_tool/build 缓存。这意味着下一次构建将是全量构建。清理后的第一次构建可能会耗时较久,但它能百分百保证生成代码的正确性。
注:为了避免团队成员之间的缓存冲突,绝对不要把 .dart_tool/ 提交到 Git。
# .gitignore
.dart_tool/
结束语
build_runner 官网上列出了许多开箱即用的常用 Builder,足以应对绝大多数的代码生成场景。我们应该优先从这些成熟的 Builder 中选用。
| Builder | Adds capabilities | Notes |
|---|---|---|
| auto_route_generator | Flutter navigation | |
| built_value_generator | data classes with JSON serialization | Flutter Favourite by Google |
| chopper_generator | REST HTTP client | Flutter Favourite |
| copy_with_extension_gen | copyWith extension methods | |
| dart_mappable_builder | data classes with JSON serialization | |
| drift_dev | reactive data binding and SQL | |
| envied_generator | environment variable bindings | |
| flutter_gen_runner | Flutter asset bindings | |
| freezed | data classes, tagged unions, nested classes, cloning | Flutter Favourite |
| go_router_builder | Flutter navigation | by Google |
| hive_ce_generator | key-value database | |
| injectable_generator | dependency injecton | |
| json_serializable | JSON serialization | Flutter Favourite by Google |
| mockito | mocks and fakes for testing | by Google |
| retrofit_generator | REST HTTP client | |
| riverpod_generator | reactive caching and data binding | Flutter Favourite |
| slang_build_runner | type-safe i18n | |
| swagger_dart_code_generator | dart types from Swagger/OpenAPI schemas | |
| theme_tailor | Flutter themes and extensions | |
| webdev | compilation to javascript | by Google |
但当我们面对一些极其特殊、甚至需要改变代码结构的生成逻辑时,现成的工具或许难以应对。此时我们可以自定义 Builder 或 Generator —— 这正是下一节将要讨论的内容。