Flutter 基础体系 · 第 32/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 代码生成:build_runner、序列化、不可变模型和冲突治理

在 Flutter 项目中,“代码生成”通常不是 Dart 编译器自动完成的语言特性,而是由一个独立的构建工具读取注解、源文件和配置,再生成普通的 Dart 源代码。生成结果随后与手写代码一起参与 Dart/Flutter 编译。

本文围绕四个相互关联的概念展开:

  • build_runner:什么时候运行生成器,以及生成器如何管理输入和输出;
  • 序列化:如何在 JSON 等外部数据与 Dart 对象之间建立可验证的转换;
  • 不可变模型:如何让模型对象的状态在创建后不能被随意改变;
  • 冲突治理:如何处理生成文件被修改、多个生成器输出重叠、缓存失效和团队协作问题。

示例基于 Dart 3 的空安全能力,命令适用于当前稳定 Flutter SDK 中的 Dart 工具链。代码生成发生在开发机或 CI 的 Dart 环境中,不依赖 Android、iOS、桌面或 Web 运行时。


一、先区分三个阶段:生成、编译和运行

一个带有 JSON 模型的 Flutter 项目,实际经过三个不同阶段:

flowchart LR
    A[手写 Dart 源文件与注解] --> B[build_runner 扫描并生成 Dart 文件]
    B --> C[Flutter/Dart 编译器编译所有 Dart 文件]
    C --> D[Android/iOS/桌面/Web 运行时]
    E[网络 JSON 或本地 JSON] --> F[运行时 jsonDecode]
    F --> G[生成的 fromJson/toJson 代码]
    G --> D

这几个阶段不能混为一谈。

1. 代码生成阶段

例如:

dart run build_runner build

这个命令运行在开发机、CI 或构建服务器上。它读取:

  • lib/ 下的 Dart 源代码;
  • @JsonSerializable()@freezed 等注解;
  • build.yaml
  • 生成器包注册的构建规则。

然后写出 .g.dart.freezed.dart 等 Dart 文件。

2. 编译阶段

例如:

flutter build apk
flutter build ios
flutter build web

Flutter 编译器不会在此时重新理解 @JsonSerializable() 的含义。对编译器而言,生成文件已经是普通 Dart 源代码。只要生成文件存在且内容正确,它与手写的 Dart 文件没有本质区别。

3. 运行阶段

运行时才会处理真正的 JSON 数据:

final dynamic value = jsonDecode(responseBody);

jsonDecode 只负责把 JSON 文本转换为 Dart 的基础值:

  • JSON 对象对应 Map<String, dynamic>
  • JSON 数组对应 List<dynamic>
  • 字符串、数字、布尔值和 null 对应相应的 Dart 值。

它不会自动把 Map<String, dynamic> 转换为 User。对象转换由 User.fromJson 及其生成实现完成。

因此,下面两件事是不同的:

final map = jsonDecode(body);       // 文本 -> 基础 Dart 值
final user = User.fromJson(map);    // Map -> 领域模型

二、为什么 Dart 项目需要显式生成序列化代码

2.1 序列化的输入和输出

序列化是把内存中的对象转换为可传输或可持久化格式;反序列化是把外部格式恢复为内存对象。

对于 JSON 模型,可以形式化为:

E:MJE: M \rightarrow J

D:JMD: J \rightarrow M

其中:

  • MM 表示 Dart 模型对象;
  • JJ 表示由 JSON 支持的值集合;
  • EEtoJson
  • DDfromJson

如果一个模型包含 DateTime,那么 DateTime 不是 JSON 原生类型,通常需要约定:

E(DateTime)=ISO-8601 字符串E(\text{DateTime}) = \text{ISO-8601 字符串}

D(ISO-8601 字符串)=DateTime.parse(字符串)D(\text{ISO-8601 字符串}) = \text{DateTime.parse(字符串)}

序列化正确的最低要求不是“代码能运行”,而是对于合法模型值 mm,反序列化序列化结果后能够恢复等价值:

D(E(m))mD(E(m)) \equiv m

这里的 \equiv 是模型定义的等价关系,不一定是对象身份相等。例如,序列化和反序列化后得到的是新对象,但字段值相同。

2.2 dart:convert 不负责领域对象映射

下面的代码可以运行:

import 'dart:convert';

void main() {
  final value = jsonDecode('{"id":"u-1","name":"Ada"}');

  print(value.runtimeType); // 通常是 _Map<String, dynamic> 的具体实现
  print(value['name']);     // Ada
}

但下面的代码不会自动成立:

// 错误思路:jsonDecode 不会推断 User 类型
final User user = jsonDecode(body);

原因是 Dart 的 JSON 解码器只知道 JSON 的结构,不知道业务类型。即使 JSON 中存在 "name",也没有通用规则可以决定:

  • 应该调用哪个构造函数;
  • 缺少字段时使用什么默认值;
  • "created_at" 应该映射到哪个 Dart 字段;
  • 字符串是否应该解析为 DateTime
  • JSON 数字是否允许转换为 int
  • 未知字段应该忽略还是报错。

Dart 的类型系统不会根据字段名自动构造对象。因此项目需要手写映射代码,或通过代码生成器产生这些映射代码。


三、build_runner 的核心机制

3.1 build_runner 是构建编排器,不是某一个序列化库

build_runner 本身通常不理解 @JsonSerializable()@freezed 的业务含义。它负责:

  1. 发现项目中的输入文件;
  2. 加载并执行注册的 builder;
  3. 计算哪些输出需要重新生成;
  4. 管理生成文件和构建缓存;
  5. 处理不同 builder 的执行顺序;
  6. 在输出文件已存在时执行冲突检查。

真正生成 JSON 方法的是 json_serializable,生成不可变模型、copyWith、相等性和模式匹配代码的通常是 freezed

常见依赖关系如下:

build_runner
├── 调度生成任务
├── 管理构建缓存
└── 管理生成输出

json_serializable
├── 读取 @JsonSerializable、@JsonKey
└── 生成 _$TypeFromJson / _$TypeToJson

freezed
├── 读取 @freezed
├── 生成不可变模型辅助代码
└── 通常委托 json_serializable 生成 JSON 映射

3.2 part 是生成文件接入 Dart 库的关键

一个典型文件如下:

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

part 的意思不是“导入另一个独立库”,而是把多个文件组成同一个 Dart library。user.dartuser.g.dart 可以共享同一个 library 的私有标识符。

因此生成文件可以写出:

User _$UserFromJson(Map<String, dynamic> json) => User(
      id: json['id'] as String,
      name: json['name'] as String,
    );

而手写文件可以调用私有名称:

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

如果缺少:

part 'user.g.dart';

常见结果是:

The method '_$UserFromJson' isn't defined for the type 'User'

如果 part 文件名写错,也会出现类似问题。part 字符串通常必须与生成器约定的输出文件名一致。

3.3 生成器的输入、输出和所有权

User 为例:

lib/user.dart
    ├── 输入:@JsonSerializable()、字段声明、构造函数
    └── 输出:lib/user.g.dart

lib/profile.dart
    ├── 输入:@freezed、字段声明
    ├── 输出:lib/profile.freezed.dart
    └── 输出:lib/profile.g.dart

生成文件具有明确的“所有权”:

  • user.g.dartjson_serializable 负责;
  • profile.freezed.dartfreezed 负责;
  • profile.g.dart 通常由 JSON 生成链负责。

手动修改这些文件,相当于修改编译产物而不是修改源代码。下一次生成时修改通常会被覆盖,或者因为文件内容不符合构建系统预期而触发冲突。


四、使用 json_serializable 完成端到端 JSON 映射

4.1 安装依赖

在 Flutter 项目根目录执行:

flutter pub add json_annotation
flutter pub add --dev build_runner json_serializable

这里:

  • json_annotation 是运行时和源码侧使用的注解、类型定义;
  • json_serializable 是生成器;
  • build_runner 是运行生成器的构建工具。

版本号由 flutter pub add 根据当前环境解析。生产项目应提交 pubspec.lock(应用项目通常如此),并通过 CI 固定依赖解析结果,避免开发者之间使用不同版本生成出不同结果。

4.2 编写模型源文件

创建 lib/models/user.dart

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
  final String id;
  final String name;

  @JsonKey(name: 'created_at')
  final DateTime createdAt;

  /// List.unmodifiable 只保护这个字段不被当前对象的 API 修改。
  /// 它同时避免调用者通过传入的原始 List 改变模型内部状态。
  final List<String> roles;

  User({
    required this.id,
    required this.name,
    required this.createdAt,
    required List<String> roles,
  }) : roles = List.unmodifiable(roles);

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

  Map<String, dynamic> toJson() => _$UserToJson(this);
}

这个模型包含几个有意展示的映射规则:

  1. idname 使用同名字段;
  2. createdAt 通过 @JsonKey(name: 'created_at') 映射到下划线命名;
  3. DateTime 由生成器按默认规则转换为 JSON 字符串;
  4. roles 在构造时复制并包装为不可修改列表;
  5. fromJsontoJson 的主体不手写,而由生成器产生。

执行:

dart run build_runner build

如果项目使用 Flutter,也可以执行:

flutter pub run build_runner build

dart run 是当前 Dart 工具链中更直接的写法;flutter pub run 在许多项目中仍可用,但新项目通常优先使用 dart run

成功后通常会得到:

lib/models/user.g.dart

生成代码的具体格式和变量命名属于生成器实现细节,不应依赖其排版。逻辑上,它会接近:

User _$UserFromJson(Map<String, dynamic> json) => User(
      id: json['id'] as String,
      name: json['name'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
      roles: (json['roles'] as List<dynamic>)
          .map((e) => e as String)
          .toList(),
    );

Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
      'id': instance.id,
      'name': instance.name,
      'created_at': instance.createdAt.toIso8601String(),
      'roles': instance.roles,
    };

具体生成内容可能因依赖版本、注解参数和字段类型不同而不同。上面的代码用于说明转换路径,不应手动复制到 .g.dart

4.3 编写可运行调用代码

创建 bin/example.dart

import 'dart:convert';

import 'package:your_app_name/models/user.dart';

void main() {
  const body = '''
  {
    "id": "u-1",
    "name": "Ada",
    "created_at": "2024-01-02T03:04:05.000Z",
    "roles": ["admin", "author"]
  }
  ''';

  final decoded = jsonDecode(body);

  if (decoded is! Map<String, dynamic>) {
    throw const FormatException('用户响应必须是 JSON 对象');
  }

  final user = User.fromJson(decoded);

  print(user.id); // u-1
  print(user.createdAt.toUtc()); // 2024-01-02 03:04:05.000Z
  print(user.roles); // [admin, author]

  final encoded = jsonEncode(user.toJson());
  print(encoded);
}

需要把 your_app_name 替换为 pubspec.yaml 中的实际包名。运行:

dart run bin/example.dart

前置条件是先完成代码生成,否则 _$UserFromJson_$UserToJson 不存在,编译会失败。

数据流是:

JSON 文本
  -> jsonDecode
Map<String, dynamic>
  -> User.fromJson
User 对象
  -> User.toJson
Map<String, dynamic>
  -> jsonEncode
JSON 文本

其中任何一层出错,都应该在对应层诊断,而不是笼统地称为“JSON 解析失败”。


五、字段类型、默认值和错误边界

5.1 必填字段的失败方式

对于:

final String id;

生成代码通常会把 JSON 值断言为 String。如果响应中没有 id,或者 id 是数字,可能出现:

type 'Null' is not a subtype of type 'String' in type cast

这不是生成器随机失败,而是模型声明和服务端数据不满足同一契约:

JSON 中的 idString\text{JSON 中的 id} \in \text{String}

如果这个字段确实可能缺失,应明确写成:

final String? id;

如果缺失时应该使用业务默认值,应显式声明默认值:

@JsonKey(defaultValue: <String>[])
final List<String> roles;

默认值的语义是“字段缺失或得到可按规则处理的空值时使用约定值”,但它不能把任意错误类型安全地转换成目标类型。例如字符串 "unknown" 不会自动变成列表。

5.2 null、缺失和空值不是一回事

以下三个 JSON 输入不应默认视为相同:

{}
{"roles": null}
{"roles": []}

它们分别表示:

  • 字段不存在;
  • 字段明确为空;
  • 字段存在但集合为空。

业务 API 如果区分这些状态,就不能简单把字段声明为非空列表并期待生成器自动解决。应通过可空类型、默认值或自定义转换器明确表达契约。

5.3 未知字段的处理

服务端新增字段时,客户端旧版本可能收到:

{
  "id": "u-1",
  "name": "Ada",
  "created_at": "...",
  "roles": [],
  "avatar_url": "https://example.com/a.png"
}

默认情况下,json_serializable 通常只读取模型声明的字段,未知字段会被忽略。这有利于服务端向后兼容,但也意味着客户端可能无法发现字段拼写错误。

如果希望对未知字段报错,可以在注解中配置:

@JsonSerializable(disallowUnrecognizedKeys: true)
class User {
  // ...
}

这会提高输入校验严格性,但降低对服务端字段扩展的容忍度。面向外部 API 时,是否启用取决于协议治理方式;面向本地配置文件或内部严格协议时,严格校验通常更容易发现错误。

还可以使用:

@JsonSerializable(checked: true)

让生成代码使用更明确的检查包装,在类型不匹配时提供字段上下文。checked 主要改善类型转换错误的诊断,并不等同于“自动拒绝所有未知字段”;未知字段控制应使用 disallowUnrecognizedKeys


六、自定义类型转换:当 JSON 与 Dart 类型不一致时

6.1 DateTime 的默认约定

如果服务端使用 ISO-8601 字符串,DateTime 通常可以直接生成转换代码:

@JsonSerializable()
class Event {
  final String title;
  final DateTime startAt;

  const Event({
    required this.title,
    required this.startAt,
  });

  factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);

  Map<String, dynamic> toJson() => _$EventToJson(this);
}

如果服务端使用 Unix 时间戳,就不能继续假设字符串格式。可以定义转换器:

class UnixMillisecondsDateTimeConverter
    implements JsonConverter<DateTime, int> {
  const UnixMillisecondsDateTimeConverter();

  @override
  DateTime fromJson(int json) {
    return DateTime.fromMillisecondsSinceEpoch(json, isUtc: true);
  }

  @override
  int toJson(DateTime object) {
    return object.toUtc().millisecondsSinceEpoch;
  }
}

应用到字段:

@JsonSerializable()
class Event {
  final String title;

  @UnixMillisecondsDateTimeConverter()
  final DateTime startAt;

  const Event({
    required this.title,
    required this.startAt,
  });

  factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);

  Map<String, dynamic> toJson() => _$EventToJson(this);
}

此时映射关系变为:

E(t)=tUTC.millisecondsSinceEpochE(t) = t_{\text{UTC}}.\text{millisecondsSinceEpoch}

D(n)=DateTime.fromMillisecondsSinceEpoch(n,isUtc=true)D(n) = \text{DateTime.fromMillisecondsSinceEpoch}(n,\text{isUtc}=true)

转换器必须保证单位和时区约定一致。把秒误当成毫秒会产生数量级错误;把本地时间当 UTC 发送则会产生时区偏移。

6.2 枚举值不能只依赖 Dart 名称

enum UserStatus {
  active,
  disabled,
}

如果服务端协议使用 "active""disabled",默认映射可能正好符合预期。但如果协议使用 "ACTIVE""DISABLED""deactivated",必须显式映射:

enum UserStatus {
  @JsonValue('ACTIVE')
  active,

  @JsonValue('DISABLED')
  disabled,
}

这类注解依赖 json_annotation,并由 json_serializable 读取。协议字符串属于外部契约,不应因为 Dart 枚举重命名而意外改变。

6.3 自定义 fromJsontoJson

对于简单字段,可以使用顶层转换函数:

int scoreFromJson(Object? value) {
  if (value is int) return value;
  if (value is String) {
    final parsed = int.tryParse(value);
    if (parsed != null) return parsed;
  }
  throw FormatException('score 必须是整数或数字字符串: $value');
}

String scoreToJson(int value) => value.toString();

字段上使用:

@JsonKey(
  fromJson: scoreFromJson,
  toJson: scoreToJson,
)
final int score;

这里的错误处理比简单类型转换更明确:服务端返回 3.5"N/A" 时,不会静默转换为错误的业务值,而是立刻在边界处失败。


七、不可变模型:final 只是第一层

7.1 什么是不可变对象

不可变对象是指:对象完成构造后,其可观察状态不能再发生改变。

对于字段集合 F={f1,f2,...,fn}F = \{f_1, f_2, ..., f_n\},对象 mm 创建后,如果任意有效操作 opop 都满足:

op(m)=mfiF,  valuem(fi)=valuem(fi)op(m) = m' \Rightarrow \forall f_i \in F,\; value_{m'}(f_i) = value_m(f_i)

那么这些字段在对象语义上保持不变。

Dart 的 final 只保证字段引用不能重新赋值:

class User {
  final List<String> roles;

  User(this.roles);
}

下面的代码仍然可以改变列表内容:

final roles = <String>['admin'];
final user = User(roles);

roles.add('author');
print(user.roles); // [admin, author]

字段引用没有改变,但引用指向的可变列表改变了。因此:

final

不等于:

深层不可变

7.2 防御性复制和只读包装

前面的 User 构造函数使用了:

User({
  required List<String> roles,
}) : roles = List.unmodifiable(roles);

这一步包含两个动作:

  1. List.unmodifiable(roles) 创建一个不允许修改的列表视图或副本;
  2. 模型不再持有调用者可以直接修改的原始列表。

于是:

final source = <String>['admin'];
final user = User(
  id: 'u-1',
  name: 'Ada',
  createdAt: DateTime.utc(2024),
  roles: source,
);

source.add('author');
print(user.roles); // [admin]

尝试:

user.roles.add('editor');

会抛出不可修改列表相关异常。

这里有一个边界:List.unmodifiable 只保证列表结构不能修改。如果列表元素本身是可变对象,例如 List<MutableRole>,元素内部仍可能改变。真正的深层不可变需要递归地使用不可变类型或不可变值对象。

7.3 copyWith 的必要性

不可变对象不能通过赋值修改,因此更新操作必须创建新对象:

final renamed = User(
  id: user.id,
  name: 'Grace',
  createdAt: user.createdAt,
  roles: user.roles,
);

这段代码可行,但字段多时容易遗漏字段。不可变模型生成库通常提供 copyWith

final renamed = user.copyWith(name: 'Grace');

copyWith 的语义是:

copyWith(m,name=x)=mcopyWith(m, name=x) = m'

其中:

  • m'namex
  • 其他字段与 m 相同;
  • m 本身不改变。

json_serializable 只负责 JSON 映射,不会自动生成 copyWith、值相等或模式匹配。要获得这些能力,通常使用 freezed 或手写实现。


八、使用 Freezed 生成不可变模型

8.1 Freezed 与 JSON 生成的关系

安装:

flutter pub add freezed_annotation
flutter pub add --dev freezed

如果模型还需要 JSON:

flutter pub add json_annotation
flutter pub add --dev json_serializable

创建 lib/models/profile.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'profile.freezed.dart';
part 'profile.g.dart';

@freezed
class Profile with _$Profile {
  const factory Profile({
    required String id,
    required String displayName,
    @Default(<String>[]) List<String> tags,
  }) = _Profile;

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

执行:

dart run build_runner build --delete-conflicting-outputs

通常会生成两个文件:

  • profile.freezed.dart:不可变实现、copyWith==hashCode 等;
  • profile.g.dartfromJsontoJson 相关实现。

对于这个模型,可以这样使用:

final profile = Profile.fromJson(<String, dynamic>{
  'id': 'p-1',
  'displayName': 'Ada',
});

final renamed = profile.copyWith(displayName: 'Grace');

print(profile.displayName); // Ada
print(renamed.displayName); // Grace
print(profile.tags);        // []

profile 没有被修改,renamed 是新对象。

8.2 集合字段的边界

Freezed 对集合字段通常会提供不可修改语义,但具体行为和配置取决于 Freezed 版本及其配置。不要仅因为类使用了 @freezed,就假设所有嵌套对象都深层不可变。

例如:

@freezed
class Document with _$Document {
  const factory Document({
    required List<Map<String, dynamic>> metadata,
  }) = _Document;
}

即使外层列表不能直接修改,Map<String, dynamic> 仍然是可变结构。对于具有严格不变性要求的模型,应:

  • 用专门的不可变值对象替代 Map<String, dynamic>
  • 在边界处把嵌套结构转换为不可变模型;
  • 不把任意动态 Map 作为长期持有的领域状态。

8.3 Freezed 联合类型与 JSON

Dart 3 的 sealed class 可以表达受限继承层次;Freezed 还可以生成带多构造函数的联合模型。例如:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'result.freezed.dart';
part 'result.g.dart';

@Freezed(unionKey: 'type')
sealed class ApiResult with _$ApiResult {
  const factory ApiResult.success({
    required String value,
  }) = ApiSuccess;

  const factory ApiResult.failure({
    required String message,
  }) = ApiFailure;

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

对应的 JSON 可以是:

{"type":"success","value":"done"}

或:

{"type":"failure","message":"timeout"}

这里的 type 是协议中的判别字段。客户端和服务端必须对判别值保持一致,否则反序列化无法选择具体分支。

联合类型的核心条件是:对任意输入 JSON,必须能够根据判别字段唯一选择一个构造分支:

type(j)=k!  Cktype(j) = k \Rightarrow \exists! \; C_k

符号 \exists! 表示“存在且只有一个”。如果两个分支使用同一个判别值,或者输入缺少判别字段,模型定义就不再具有确定性。


九、build_runner 的常用命令和生命周期

9.1 一次性生成

dart run build_runner build

适合:

  • CI;
  • 提交前生成;
  • 修改少量模型后手动验证。

命令只会处理需要更新的输出,构建系统会使用缓存判断哪些输入发生变化。

9.2 监听文件变化

dart run build_runner watch

watch 会持续运行:

  1. 等待源文件变化;
  2. 重新计算受影响的生成输出;
  3. 生成新文件;
  4. 报告错误;
  5. 继续等待下一次变化。

它不会替代 Flutter 的热重载。常见开发方式是一个终端运行 watch,另一个终端运行:

flutter run

生成代码更新后,Flutter 编译器仍可能需要重新编译;如果变化影响了类结构、常量或初始化流程,热重载不一定足够,可能需要热重启甚至重新启动应用。

9.3 清理缓存

dart run build_runner clean

该命令用于清理 build_runner 相关缓存,而不是删除所有源代码或任意生成文件。

适合在以下情况使用:

  • 生成器版本升级后缓存行为异常;
  • 修改 build.yaml 后输出与预期不一致;
  • 项目目录被移动或重命名;
  • 错误信息明显指向旧构建状态。

清理后应重新生成:

dart run build_runner build --delete-conflicting-outputs

clean 不是修复模型错误的工具。如果源文件中的字段类型本身错误,清理缓存不会解决问题。

9.4 --delete-conflicting-outputs 的含义与风险

dart run build_runner build --delete-conflicting-outputs

当生成器想写入某个输出文件,而该文件已存在且内容不符合当前构建状态时,build_runner 可能拒绝覆盖,以避免误删用户文件。

加入该参数后,构建系统会删除冲突输出,再重新生成。

它适合处理:

  • .g.dart.freezed.dart 被旧版本生成;
  • 生成文件从其他分支合并进来;
  • 本地构建缓存和工作区状态不一致;
  • 生成文件曾被手动编辑。

风险是:如果一个本应手写的文件错误地使用了生成器约定的输出路径,该参数可能删除它。因此执行前应确认:

被删除的是生成文件,而不是业务源文件

可先查看 Git 状态:

git status --short

再检查冲突文件:

git diff -- lib/models/user.g.dart

如果生成文件有未提交的手工修改,不应直接删除,应先确认这些修改不包含必须保留的业务逻辑。


十、冲突治理:把生成文件当作构建产物管理

10.1 什么情况会产生冲突

常见冲突来源包括:

  1. 手动编辑 .g.dart.freezed.dart
  2. 两个分支分别生成了不同版本的文件后合并;
  3. 生成器版本升级改变了输出;
  4. part 指向的文件名被重命名,但旧文件没有删除;
  5. 两个 builder 试图写同一个输出路径;
  6. 生成器配置改变,但旧缓存仍被使用;
  7. 同一模型重复声明了不兼容注解。

冲突不一定表现为 Git 冲突,也可能表现为构建时错误:

Conflicting outputs

或 Dart 编译错误:

The method '_$UserFromJson' is already defined

10.2 生成文件是否提交到 Git

这是团队策略问题,不是 Dart 语言规范强制规定的问题。

提交生成文件

优点:

  • CI 可以直接编译,不必额外执行生成步骤;
  • 代码审查能够看到生成结果变化;
  • 某些发布流程更简单。

缺点:

  • Pull Request 中会出现大量机械变更;
  • 生成器版本升级可能造成大范围 diff;
  • 合并冲突更常见。

不提交生成文件

优点:

  • Git 中只保留源模型和配置;
  • 减少生成产物冲突;
  • 生成器升级时仓库噪声较少。

缺点:

  • CI 必须先执行生成;
  • 开发者忘记生成时,本地可能无法编译;
  • 必须保证依赖版本和生成命令稳定。

无论选择哪种策略,CI 都应该验证生成流程。一个常见的可靠流程是:

flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter analyze
flutter test

如果提交生成文件,还可以在 CI 中重新生成后检查工作区是否出现差异:

git diff --exit-code

这可以发现“源代码已改变但生成文件未更新”的提交。

10.3 Git 合并冲突的处理顺序

对于生成文件,不应尝试手工拼接两份生成结果。推荐顺序是:

1. 解决手写源文件冲突
2. 确认注解、字段和 part 声明正确
3. 删除或还原冲突的生成文件
4. 重新运行 build_runner
5. 运行 analyze 和 test

例如:

git checkout -- lib/models/user.g.dart
dart run build_runner build --delete-conflicting-outputs
flutter analyze
flutter test

git checkout -- 会丢弃该生成文件的本地修改,因此只能用于确认它确实是可重建产物的情况。新版 Git 也可以使用:

git restore -- lib/models/user.g.dart

不要把生成文件中的某一段手动复制到另一分支。正确的合并对象是模型源文件和生成器配置,生成结果应由当前源代码重新产生。


十一、多个生成器的顺序和 build.yaml

当一个文件同时使用多个生成器时,例如 Freezed 生成模型实现、JSON 生成器生成序列化代码,必须保证:

Freezed 先生成模型结构
JSON 生成器再根据可见结构生成映射

在常见的 Freezed + json_serializable 使用方式中,相关包已经提供了协作配置。只有在自定义 builder 或特殊项目布局下,才需要显式调整 build.yaml

一个简化的配置示例:

targets:
  $default:
    builders:
      json_serializable:
        options:
          checked: true
          explicit_to_json: true

这里:

  • checked: true 增强反序列化错误上下文;
  • explicit_to_json: true 要求嵌套对象显式调用 toJson,使嵌套转换更容易观察和诊断。

配置名和可用选项由具体 builder 定义,不是 build_runner 的通用参数。修改配置后,若输出没有按预期变化,可以执行:

dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs

不要随意为每个项目复制一份复杂的 build.yaml。配置过多会增加升级成本,也会让“为什么这个文件这样生成”变得难以追踪。


十二、失败表现和诊断路径

12.1 找不到生成方法

错误:

The method '_$UserFromJson' isn't defined

按以下顺序检查:

1. 是否声明了 part 'user.g.dart';
2. part 文件名是否与当前文件名一致;
3. 是否安装了 json_serializable 和 build_runner;
4. 是否执行过 dart run build_runner build;
5. 生成器是否在生成过程中报错;
6. user.g.dart 是否实际存在;
7. User 的注解和构造函数是否满足生成器要求。

可以直接检查:

ls lib/models

Windows PowerShell 可以使用:

Get-ChildItem lib\models

12.2 生成成功但编译失败

这通常表示生成阶段已经完成,错误转移到了 Dart 类型系统。例如:

  • fromJson 返回值类型不匹配;
  • 同一个类被重复声明;
  • part 文件不属于同一个 library;
  • Freezed 的构造函数签名不符合要求;
  • 手写代码调用了不存在的 copyWith

此时不要继续反复执行生成命令,应直接运行:

flutter analyze

先解决首个类型错误,因为后续错误可能只是级联结果。

12.3 运行时类型转换失败

例如:

type 'String' is not a subtype of type 'int' in type cast

诊断重点不是 .g.dart 的某一行,而是比较三份契约:

服务端实际 JSON
模型字段声明
生成器使用的转换规则

例如模型声明:

final int count;

但服务端返回:

{"count":"42"}

这三者不一致。解决方式应是:

  • 修复服务端协议;
  • 改成正确的 Dart 类型;
  • 或编写明确的 fromJson 转换函数。

不应在业务代码中到处写:

int.parse(json['count'].toString())

因为这会把协议兼容逻辑分散到多个调用点。

12.4 生成命令成功但文件没有变化

可能原因:

  • 注解文件不在当前 target 的扫描范围;
  • 文件没有正确使用 part
  • 生成文件实际在其他目录;
  • 生成器认为输入未发生变化;
  • build.yaml 排除了该文件;
  • 使用了错误的命令或错误的项目根目录。

可以尝试:

dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs

然后检查命令输出和文件时间戳。不要仅根据“命令退出码为 0”判断生成结果正确;还要确认目标文件存在,并执行分析和测试。


十三、不可变模型与 Flutter 状态管理的关系

不可变模型本身不是状态管理框架,但它适合与 Flutter 的状态更新模式配合。

错误模式:

class UserController {
  User user;

  UserController(this.user);

  void rename(String name) {
    user.name = name; // 如果 User 是不可变模型,这段代码不能成立
  }
}

正确模式是替换引用:

class UserController {
  User user;

  UserController(this.user);

  void rename(String name) {
    user = user.copyWith(name: name);
  }
}

在 Flutter 中,状态通知机制通常会观察“状态引用是否被替换”或由控制器主动通知。不可变模型让每次状态变化都形成明确的新值:

旧 User
  --copyWith(name: ...)-->
新 User
  --通知监听者-->
Widget 重建

这带来两个直接收益:

  1. 不会有多个组件持有同一对象并偷偷修改其字段;
  2. 调试时可以比较更新前后的完整值。

但不可变模型不会自动解决并发问题。例如两个异步请求同时更新状态:

final first = controller.user.copyWith(name: 'A');
final second = controller.user.copyWith(name: 'B');

如果两个结果基于同一个旧快照,后写入者可能覆盖先写入者。这是状态更新的并发策略问题,不是 copyWith 的错误。需要根据业务选择:

  • 以最后完成的请求为准;
  • 使用请求版本号;
  • 取消过期请求;
  • 在控制器中按当前状态重新计算更新;
  • 使用事务或事件序列化。

十四、移动端、桌面和 Web 的差异

14.1 代码生成阶段

Android、iOS、macOS、Windows、Linux 和 Web 都不会改变 build_runner 的基本使用方式。生成命令通常都在项目根目录通过 Dart 工具执行。

代码生成阶段的要求是:

  • 开发机或 CI 安装可用的 Dart/Flutter SDK;
  • 能解析 pubspec.yaml 依赖;
  • 具有写入项目目录的权限。

14.2 运行时平台差异

生成出的 Dart 代码通常是纯 Dart,因此可以参与所有 Flutter 目标平台的编译。但模型字段涉及平台类型时,差异会出现。

例如:

  • File 依赖 dart:io,不能直接用于 Web;
  • Uint8List 可以跨平台,但具体传输格式需要约定;
  • 时区和本地时间在不同设备环境中可能不同;
  • Web 的 JavaScript 数字语义与 Dart VM 不完全相同,超大整数协议需要谨慎;
  • 平台插件返回的 JSON 结构可能因原生实现不同而变化。

因此,序列化代码的跨平台性不等于外部数据协议天然一致。应让模型尽量使用跨平台的数据类型,例如 StringintdoubleboolDateTime、列表、Map 和自定义值对象,并把 dart:io 等平台能力隔离在基础设施层。

14.3 Web 构建不会恢复运行时反射

某些语言或框架可以在运行时扫描类型并自动完成 JSON 映射。Flutter/Dart 项目中使用 json_serializable 的主要价值之一,就是把映射逻辑在构建时生成,运行时执行显式 Dart 代码。

这意味着:

  • 生成代码不会依赖运行时反射;
  • Web 编译器可以像处理其他 Dart 代码一样处理它;
  • 编译器更容易发现字段类型错误;
  • 生成器不会在用户设备上运行。

十五、代码生成的取舍与真实边界

15.1 生成代码不是协议验证的替代品

代码生成能保证“模型声明和生成映射一致”,但不能保证服务器永远返回正确数据。

如果服务端突然把:

{"created_at": 1710000000}

替换成:

{"created_at": "2024-03-09T00:00:00Z"}

而客户端仍按一种类型解析,运行时仍会失败。生成器无法预测外部系统未来的错误。

因此边界处应考虑:

  • 捕获 FormatException 和类型错误;
  • 记录接口名、字段名和请求上下文;
  • 不把原始响应中的敏感数据直接写入日志;
  • 对关键协议添加契约测试;
  • 对兼容格式编写显式转换器。

15.2 不可变不代表没有性能成本

防御性复制、不可修改包装和 copyWith 都可能创建新对象或新集合。对普通接口模型,这通常换来了更清晰的状态语义;但对极大的频繁更新集合,需要观察:

  • 是否每次更新都复制整个列表;
  • 是否应该使用分页或增量状态;
  • 是否需要专门的数据结构;
  • 是否在 UI 层重复创建了大量派生对象。

不能脱离数据规模和更新频率宣称某种模型方案一定更快或更慢。

15.3 生成器升级是源码行为变更

虽然生成文件是机械产物,但生成器版本升级可能改变:

  • 空值处理;
  • 枚举处理;
  • 默认值生成;
  • 错误包装;
  • Freezed 的集合包装;
  • 输出文件的稳定性;
  • 对 Dart 新语法的要求。

升级后应执行完整流程:

flutter pub upgrade
dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs
flutter analyze
flutter test

并重点检查:

  • 旧 JSON 是否仍能反序列化;
  • 新生成的 toJson 是否改变了字段名或空值策略;
  • Freezed 模型的相等性和 copyWith 行为;
  • 生成文件 diff 是否只包含预期变化。

十六、一个可落地的项目目录和脚本

可以将模型按职责组织:

lib/
  models/
    user.dart
    user.g.dart
    profile.dart
    profile.freezed.dart
    profile.g.dart
  data/
    user_api.dart
  state/
    user_controller.dart

tool 或项目脚本中统一命令,例如 tool/generate.sh

#!/usr/bin/env bash
set -euo pipefail

dart run build_runner build --delete-conflicting-outputs
flutter analyze
flutter test

set -euo pipefail 的作用是:生成、分析或测试任一步骤失败时脚本立即退出,避免 CI 在生成失败后继续使用旧产物构建。

如果团队决定不提交生成文件,那么 CI 至少应保证:

flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter analyze
flutter test

如果团队决定提交生成文件,还应增加:

git diff --exit-code

其因果关系是:

源模型改变
  -> 必须重新生成
  -> 生成结果应与提交内容一致
  -> git diff 不应出现未提交变化

这比依赖开发者“记得运行命令”更可靠。


十七、最终检查:从源文件到线上数据的完整闭环

一个序列化不可变模型能够稳定工作,至少要满足以下条件:

  1. 模型字段准确表达协议中的可空性和类型;
  2. JSON 字段名通过 @JsonKey 或统一命名策略明确映射;
  3. DateTime、枚举和特殊数字格式有明确转换规则;
  4. part 文件声明正确;
  5. 生成文件由对应 builder 独占;
  6. 生成命令在本地和 CI 可重复执行;
  7. 冲突文件不会被手工合并;
  8. 外部输入错误能在边界处被发现并诊断;
  9. 不可变模型对集合字段进行了足够的防御;
  10. 并发状态更新不会因为旧快照覆盖新状态;
  11. Android、iOS、桌面和 Web 使用的模型类型不依赖错误的平台库;
  12. 生成器升级经过分析、测试和协议回归验证。

可以用下面的最小闭环验证一个模型:

dart run build_runner build --delete-conflicting-outputs
flutter analyze
flutter test
flutter run

其中:

  • 第一条验证构建输入、生成器和输出关系;
  • 第二条验证生成代码与 Dart 类型系统一致;
  • 第三条验证序列化、默认值、异常和不可变更新行为;
  • 第四条验证真实 Flutter 平台中的状态展示和交互。

代码生成的价值不在于减少几行手写代码,而在于把“模型声明”和“重复映射逻辑”绑定到同一个构建过程。build_runner 负责让这个过程可重复,json_serializable 负责让外部数据转换显式可检查,Freezed 等工具负责让不可变模型的结构性代码保持一致,而冲突治理则保证生成结果不会成为团队协作和发布流程中的隐性风险。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。