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_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 中选用。

BuilderAdds capabilitiesNotes
auto_route_generatorFlutter navigation
built_value_generatordata classes with JSON serializationFlutter Favourite by Google
chopper_generatorREST HTTP clientFlutter Favourite
copy_with_extension_gencopyWith extension methods
dart_mappable_builderdata classes with JSON serialization
drift_devreactive data binding and SQL
envied_generatorenvironment variable bindings
flutter_gen_runnerFlutter asset bindings
freezeddata classes, tagged unions, nested classes, cloningFlutter Favourite
go_router_builderFlutter navigationby Google
hive_ce_generatorkey-value database
injectable_generatordependency injecton
json_serializableJSON serializationFlutter Favourite by Google
mockitomocks and fakes for testingby Google
retrofit_generatorREST HTTP client
riverpod_generatorreactive caching and data bindingFlutter Favourite
slang_build_runnertype-safe i18n
swagger_dart_code_generatordart types from Swagger/OpenAPI schemas
theme_tailorFlutter themes and extensions
webdevcompilation to javascriptby Google

但当我们面对一些极其特殊、甚至需要改变代码结构的生成逻辑时,现成的工具或许难以应对。此时我们可以自定义 Builder 或 Generator —— 这正是下一节将要讨论的内容。

Reference