04 元编程与代码生成

C 程序员熟悉两种”生成代码”的手段:#define 宏在预处理期做文本替换,C++ 模板在编译期做类型实例化。Java 则用注解处理器(APT)在编译期生成源码。Dart 走的是第三条路:注解 + 独立的代码生成器(build_runner)。你在源码里写一个 @JsonSerializable(),构建工具扫描源码、生成 xxx.g.dart,再由编译器正常编译。这看起来不如宏优雅,却解决了一个根本矛盾:Dart 要把代码 AOT 编译成紧凑的原生产物,就必须能静态分析出完整的调用图,而运行时反射会打破这个前提。本章讲清这套机制的原理、实战与工程取舍。

前置知识:11 包管理与模块(pubspec 与依赖)。对照阅读:Java 注解与反射


一、元编程的三种层次

“让程序生成程序”在不同语言里落在不同层次:

层次代表技术发生时机Dart 现状
文本替换C 宏 #define预处理无(语言刻意不提供)
编译期展开C++ 模板、Rust 宏编译无(用泛型 + 代码生成替代)
注解 + 源码生成Java APT、Dart build_runner构建期主流方案
编译器插件Kotlin 编译器插件编译分析器插件(lint 层面)
语言级宏Scala 3 macros编译曾实验,2025 年停止推进

Dart 的选择可以总结为一句话:把”生成”从编译过程中剥离出来,变成一次可检查、可缓存、可调试的独立构建步骤。生成结果是普通 Dart 源码,任何工具(IDE、调试器、AOT 编译器)都能正常处理它。


二、注解:给代码附加元数据

2.1 语法与内置注解

注解是 @ 开头的表达式,可加在类、字段、方法、参数、库等声明上:

class UserService {
  @deprecated           // 内置:调用处会产生警告
  void loginLegacy() {}
 
  @override             // 内置:要求确实重写了父类成员
  String toString() => 'UserService';
 
  @pragma('vm:prefer-inline')   // 给 VM 的优化提示
  int add(int a, int b) => a + b;
}

2.2 自定义注解

任何类只要构造器是 const,就可以当注解用:

class Route {
  final String path;
  final String method;
  const Route(this.path, {this.method = 'GET'});
}
 
class UserController {
  @Route('/users', method: 'POST')
  void createUser() {}
}

注解本身不产生任何行为,它只是挂在声明上的常量对象。真正读取它的是代码生成器或分析器插件。这一点与 Java 相同,但 Dart 的读取途径更窄:dart:mirrors 只存在于 VM/JIT,AOT 与 Web 均不可用,Flutter 默认关闭(见第七节),所以主流方案是构建期读取——build_runner 在编译前扫描源码里的注解。


三、build_runner:代码生成的调度器

3.1 工作原理

build_runner 是 Dart 官方的构建框架。它把”读源码 → 生成新源码”抽象成一个个 Builder,并按依赖关系调度:

flowchart LR
    SRC["源码 .dart<br/>含注解"] --> RES["资源图<br/>Asset Graph"]
    RES --> B1["Builder A<br/>如 json_serializable"]
    B1 --> GEN["生成文件<br/>xxx.g.dart"]
    GEN --> B2["Builder B<br/>可消费上游产物"]
    B2 --> GEN2["更多生成文件"]
    GEN2 --> OUT[".dart_tool/build/<br/>缓存与产物"]
    CACHE[".dart_tool/build/<br/>增量缓存"] --> B1

关键机制:

  • 增量构建:build_runner 记录每个文件的输入哈希,只重建受影响的节点;缓存放在 .dart_tool/build/
  • 输入是源码文本:Builder 通过 BuildStep 读取源码与注解,不依赖运行时反射
  • 产物是源码:生成的文件与手写代码没有区别,可被 dart analyze 检查

3.2 常用命令

命令作用
dart run build_runner build一次性构建
dart run build_runner build --delete-conflicting-outputs构建前删除冲突产物(最常用)
dart run build_runner watch监听文件变化,自动重建
dart run build_runner clean清理缓存与产物
dart run build_runner build --build-filter='lib/**.g.dart'只构建匹配路径

第一次引入生成器时,通常执行:

dart pub get
dart run build_runner build --delete-conflicting-outputs

四、json_serializable 实战

4.1 配置依赖

# pubspec.yaml
dependencies:
  json_annotation: ^4.9.0
 
dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.8.0

4.2 定义模型

import 'package:json_annotation/json_annotation.dart';
 
part 'user.g.dart';          // 生成文件的挂载点,文件名固定为 源文件名.g.dart
 
@JsonSerializable()
class User {
  final String name;
 
  @JsonKey(name: 'email_address')   // JSON 键与字段名不一致时映射
  final String email;
 
  final int age;
 
  const User({required this.name, required this.email, required this.age});
 
  // 生成器会补全这两个工厂/方法
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

运行生成命令后得到 user.g.dart(由工具生成,不要手改):

dart run build_runner build --delete-conflicting-outputs

使用:

void main() {
  final json = {
    'name': '小明',
    'email_address': 'ming@example.com',
    'age': 18,
  };
 
  final user = User.fromJson(json);
  print(user.email);            // ming@example.com
 
  final back = user.toJson();
  print(back['email_address']); // ming@example.com
}

4.3 嵌套与选项

嵌套对象只要也标注了 @JsonSerializable() 就能自动递归;集合、枚举、DateTime 都有默认处理。全局选项写在 build.yaml

targets:
  $default:
    builders:
      json_serializable:
        options:
          explicit_to_json: true     # toJson 递归调用子对象的 toJson
          field_rename: snake        # 字段名自动转 snake_case
          create_to_json: true
          include_if_null: false     # 值为 null 时省略键

五、freezed:不可变数据类与联合类型

json_serializable 解决序列化,freezed 解决另一类样板:不可变类(copyWith==hashCodetoString)与联合类型。

5.1 不可变数据类

import 'package:freezed_annotation/freezed_annotation.dart';
 
part 'user.freezed.dart';
 
@freezed
abstract class User with _$User {          // freezed 3 要求 abstract 或 sealed
  const factory User({
    required String name,
    required int age,
  }) = _User;
}

生成后即可获得值语义:

void main() {
  const a = User(name: '小明', age: 18);
  final b = a.copyWith(age: 19);          // 生成器提供的 copyWith
 
  print(a == const User(name: '小明', age: 18)); // true,值相等
  print(b.age);                                  // 19
  print(a);                                      // User(name: 小明, age: 18)
}

需要 JSON 序列化时,freezed 可与 json_serializable 联动:同时声明 part 'user.freezed.dart';part 'user.g.dart';,再补一个 factory User.fromJson(...) => _$UserFromJson(...),两个生成器会协同产出。

5.2 联合类型与模式匹配

freezed 2 时代的 when/map 在 freezed 3 中已废弃,改用 Dart 3 的 sealed + 模式匹配:

@freezed
sealed class ApiResult with _$ApiResult {
  const factory ApiResult.success(String data) = ApiSuccess;
  const factory ApiResult.failure(String message, int code) = ApiFailure;
}
 
// 使用方必须处理全部分支,漏一个就编译报错
String render(ApiResult result) => switch (result) {
      ApiSuccess(:final data) => '成功: $data',
      ApiFailure(:final message, :final code) => '失败($code): $message',
    };
 
void main() {
  print(render(const ApiResult.success('已加载')));        // 成功: 已加载
  print(render(const ApiResult.failure('超时', 504)));     // 失败(504): 超时
}
dart run build_runner build --delete-conflicting-outputs

sealed 的穷尽性检查是编译期的,这与手写一堆 if (x is ...) 有本质区别:新增一个子类型时,所有 switch 处会立刻报错提醒你补分支。


六、source_gen:自定义生成器

内置生成器不满足需求时,可以用 source_gen 写自己的 Builder。核心是继承 GeneratorForAnnotation<T> 并实现 generateForAnnotatedElement

import 'package:source_gen/source_gen.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
 
class Greeting {
  final String name;
  const Greeting(this.name);
}
 
class GreetingGenerator extends GeneratorForAnnotation<Greeting> {
  @override
  String generateForAnnotatedElement(
    Element element,
    ConstantReader annotation,
    BuildStep buildStep,
  ) {
    final name = annotation.read('name').stringValue;
    return "const greeting = '你好,$name';";
  }
}
 
Builder greetingBuilder(BuilderOptions options) =>
    LibraryBuilder(GreetingGenerator(), generatedExtension: '.greeting.dart');

再在 build.yaml 里注册 Builder,运行 build_runner 后,被 @Greeting('Dart') 标注的库旁边就会生成 .greeting.dart。要点:生成器运行在构建期,拿到的 Element 是分析器解析出的静态结构,因此无法读取运行时才有的值。


七、为什么 Dart 没有(可用的)运行时反射

dart:mirrors 曾是 Dart 的反射库,可以运行时枚举类成员、动态调用方法。如今它在主流场景不可用:

场景dart:mirrors 支持
Dart VM / JIT(dart run支持
AOT(dart compile exe、Flutter Release)不支持
dart2js / Wasm(Web)不支持
Flutter Debug不支持(Flutter 从未接入)

根本原因是树摇(tree shaking):AOT 编译器要从 main 出发做可达性分析,删掉所有不可达的类与方法,才能把产物压到几 MB。反射按名字动态查找成员,编译器无法静态判断哪些成员”可能被用到”,只能保守地全部保留——这会让树摇失效。Dart 的选择是:牺牲反射,换取可预测的产物体积与启动速度;需要元数据驱动的逻辑,就在构建期把信息”烧”进代码里(代码生成)。

Java 恰好相反:JVM 有完整的类元数据与反射,代价是启动慢、需依赖 GraalVM Native Image 等工具做封闭世界假设才能 AOT。两者是同一权衡的两端。


八、宏的现状与未来

Dart 团队曾推进语言级宏(macros):让 @JsonSerializable() 在编译期直接生成代码,无需 build_runner。该特性长期停留在实验阶段,2025 年初官方宣布停止推进,理由是复杂度、工具链负担与收益不成正比。目前社区共识是:

  • 代码生成继续以 build_runner 为主,增量构建与缓存机制持续优化
  • 分析器插件(analyzer plugin)承担 lint 与代码辅助
  • 语言层面通过 sealed、模式匹配、Records 等特性减少对生成的依赖

对使用者的建议:不要等待宏,把 build_runner 作为标准工程实践掌握。


九、与 Java APT、C 宏、C++ 模板对比

维度C 宏C++ 模板Java APTDart build_runner
触发时机预处理编译期实例化编译期独立构建步骤
输入文本类型注解 + 源码注解 + 源码
输出文本机器码Java 源码Dart 源码
类型安全
调试体验好(生成源码可读)
增量构建按实例编译器负责build_runner 缓存
读取运行时信息
典型用途常量、条件编译泛型容器、元编程Dagger、Lombok、MapStructjson_serializable、freezed

十、工程取舍:生成文件与构建时间

生成文件要不要提交到版本库? 两种策略各有拥趸:

策略优点缺点
提交 .g.dart/.freezed.dart克隆即可编译;CI 无需生成步骤;代码审查能看到生成结果仓库膨胀;合并冲突频繁;容易与源文件不同步
不提交(加进 .gitignore仓库干净;生成物永远最新每次拉取后需手动生成;IDE 首次打开可能报红

团队实践建议:默认不提交,但在 CI 中固定执行生成并检查 diff。同时在 .gitignore 与 README 里写清生成命令,避免新人踩坑。

构建时间方面:生成器越多,build_runner build 越慢,但增量构建通常只重算受影响的文件。控制手段:缩小 build.yaml 的 target 范围、用 --build-filter 限定路径、把生成器依赖与业务依赖分开。


十一、常见坑

坑 1:忘记运行 build_runner。 报错形如 The getter '_$UserFromJson' isn't defined,说明生成文件缺失或过期。先执行 dart run build_runner build --delete-conflicting-outputs

坑 2:part 指令路径写错。 生成文件名固定是 源文件名.g.dart / .freezed.dart,且必须与源文件同目录、partimport 之后。

坑 3:改了模型没重建。 字段名或类型修改后旧产物不会自动失效的场景(如手工删除部分文件)会导致运行期 NoSuchMethodError。用 watch 模式或 CI 里强制重建。

坑 4:手动修改生成文件。 下次构建会被覆盖。所有定制逻辑应通过注解参数、build.yaml 选项或手写扩展方法实现。

坑 5:在 AOT/Web 里使用 dart:mirrors 编译直接失败或运行时抛 UnsupportedError。需要”按名字查成员”的能力时,改用代码生成产出查找表(如路由表、注册表)。

坑 6:生成物与 SDK 版本不匹配。 升级 Dart SDK 后,旧生成代码可能使用已废弃 API。升级流程:dart pub upgradedart run build_runner clean → 重新构建。


本章小结

知识点一句话
元编程层次Dart 把生成从编译中剥离为独立构建步骤
注解const 构造器即可作注解,本身不产生行为
build_runner增量调度 Builder,产物是普通 Dart 源码
json_serializable注解模型 + part,生成 fromJson/toJson
freezed生成不可变值语义与联合类型,配合 sealed 模式匹配
source_gen继承 GeneratorForAnnotation 自定义生成器
反射缺失树摇需要静态调用图,dart:mirrors 仅 JIT 可用
2025 年停止推进,build_runner 是长期方案
工程取舍生成文件默认不提交,CI 固定生成并校验
常见坑忘构建、part 路径错、手改产物、AOT 用反射

练习

题号题目链接知识点
297二叉树的序列化与反序列化https://leetcode.cn/problems/serialize-and-deserialize-binary-tree/序列化、递归