上一篇把网络层收拢之后,我手上剩下一个更难看的角落:model/contact.dart 那个文件。十二个字段,一个类里三段——成员声明、fromJson 里十二行 json['x'] as T、toJson 里十二行 'x': instance.x。我给通讯录加「备注」字段的那天,改的是这三段加两个调用点,一共动了五行才让一个字段落地;第二天后端把 user_name 统一成 username,我又全文搜了一遍,把散在三个模型里的十处 key 挨个换掉。
真正让我下决心换写法的是一次线上崩溃。后端有个接口在字段为空时干脆不返回这个 key,而不是给 null,而我这边写的是 json['email'] as String。缺 key 取到的是 null,as String 当场炸:
type 'Null' is not a subtype of type 'String' in type cast
我盯着这行报错想了很久一件事:这个错误编译期为什么一点信号都没有?后来才把原因想明白——Map<String, dynamic> 是个类型黑洞,取值表达式的类型天然是 dynamic,我写在它后面的每一个 as 都是给运行时下的赌注,字段名拼错、可空性变化、嵌套层级调整,编译器全都旁观。这一篇就记录我把这层映射交给生成器之后,剩下的问题、新增的问题,以及各自的代价。
手写映射腐烂的具体位置
我复盘了一下那批文件的坏法,集中在三个方向,它们共同点是改动发生在两个地方,但正确性只体现在运行时。
字段改名。 声明处一次、fromJson 的 key 一次、toJson 的 key 一次。三处只要有一处漏掉,编译照样通过:漏在 fromJson 就是那条 Null is not a subtype 崩溃,漏在 toJson 更阴——请求发出去,后端按缺省值处理,数据被静默改写,我这边过了两天才从对不上的记录里发现。
可空性变化。 后端把 remark 从必填改成可空,我这边改 String?;反过来把可空改成必填时,如果我忘了把 json['remark'] as String? 的 ? 拿掉,模型里就留了一个永远不会为 null 但类型允许为 null 的字段,空检查在调用点被绕开,判空代码变成死分支。这种不一致没有任何工具会报。
嵌套结构。 手写 fromJson 里嵌套数组那一行,是我抄得最多也错得最多的一处:
// 第一版:编译通过,运行时炸
groups: (json['groups'] as List<Group>).map((e) => Group.fromJson(e)).toList(),jsonDecode 给出来的数组是 List<dynamic>,它不是 List<Group> 的子类型,这一行在进入 map 之前就抛:
type 'List
这一条我一开始理解成了「Dart 的泛型和 Java 一样是编译期擦除的近似」,其实方向相反:Dart 的泛型参数在运行时是具体存在的(reified generics),一个 List 到底装的是什么元素类型是它自己带着的属性,所以 List<dynamic> 和 List<Group> 是两个真不同的类型,as 挡不住。能绕过去的写法有两种:逐元素转换,或者 .cast<Group>()。后者我用过一次,它只是给同一个 list 套一层「按 Group 读」的视图,读取时才逐个检查,元素里混进一个 Map 的话,异常照样在后面的消费点上炸出来,只是换了个地方——我后来就固定在逐元素转换这一种,因为失败的位置离数据更近。
json_serializable 的最小可用形态
我把它跑通只用了三步:加依赖、加注解和 part、跑一次 build。
# pubspec.yaml
dependencies:
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.8.0// lib/model/contact.dart
import 'package:json_annotation/json_annotation.dart';
part 'contact.g.dart'; // 生成代码落在同一个包路径下,由 build_runner 写出来
@JsonSerializable()
class Contact {
const Contact({
required this.id,
required this.name,
this.email,
this.remark,
});
factory Contact.fromJson(Map<String, dynamic> json) => _$ContactFromJson(json);
final int id;
final String name;
final String? email;
final String? remark;
// 生成器的 `_$ContactToJson` 只在实例上有一个同名 `toJson` 方法时才会接上
Map<String, dynamic> toJson() => _$ContactToJson(this);
}# 一次性生成(我第一次跑的时候加了 --delete-conflicting-outputs,
# 因为旧版本残留的同名 .g.dart 会报冲突)
dart run build_runner build --delete-conflicting-outputs
# 开发期常驻:改一次注解自动重生成,我这边是开着它写模型的
dart run build_runner watch -d生成出来的 contact.g.dart 大致长这样,看懂它比会写注解更重要,因为出问题就在这里:
Contact _$ContactFromJson(Map<String, dynamic> json) => Contact(
id: json['id'] as int,
name: json['name'] as String,
// 可空字段生成的是 `as String?`,缺 key 和显式 null 都归到 null
email: json['email'] as String?,
remark: json['remark'] as String?,
);
Map<String, dynamic> _$ContactToJson(Contact instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'email': instance.email,
'remark': instance.remark,
};我注意到的一件事情是:它并没有帮我省掉字段声明,省掉的是「key 字符串」和「取值/赋值」这两份重复。生成的代码和我手写的形状一模一样,json['name'] as String 那条赌注还在,只是由工具替我下,且每次改字段都会重新下对的那一次。这一点是这套工具价值的全部来源——不是让映射变聪明,是让映射不再和声明脱节。
我自己常用的几个注解
@JsonKey 的可用参数我核对过一遍(name、defaultValue、disallowNullValue、includeIfNull、required、ignore、fromJson、toJson、readValue、unknownEnumValue、includeFromJson/includeToJson、nullable),类级注解上是另一批(explicitToJson、fieldRename、anyMap、checked、disallowUnrecognizedKeys、dateTimeUtc、createFactory/createToJson、ignoreUnannotated、constructor、converters)。下面这几个是我实际留在工程里的,各自的后果我先写清楚,要不要用哪一条取决于接口长什么样。
| 我的用法 | 生成代码里的行为 | 我这边的适用场景 |
|---|---|---|
@JsonKey(name: 'user_name') | 只换 key 字符串,类型判断不变 | 后端下划线、我这边驼峰,个别字段混用时 |
类上 fieldRename: FieldRename.snake | 全部字段按驼峰转下划线 | 接口整体统一是 snake_case 时;混用命名的接口反而更麻烦,见下文 |
@JsonKey(includeIfNull: false) | toJson 里这个 key 整个不出现(走 writeNotNull) | 后端把「字段缺失」和「字段为 null」当两回事的 patch 接口 |
@JsonKey(disallowNullValue: true) | 取值那一步先显式判空,命中 null 就直接抛出 | 后端确认永远必填、我却希望脏数据尽早炸在门口 |
@JsonKey(defaultValue: 1) | 生成 json['page'] as int? ?? 1 | 分页参数、后端偶尔省略的开关 |
@JsonKey(required: true) | 缺 key 时报错,而不是当作 null | 必填字段,让失败发生在解析而不是后面用到的地方 |
@JsonKey(ignore: true) | 双向都不生成这个字段 | 只在内存里有意义的派生值 |
@JsonKey(readValue: _pickCreated) | 取 key 之前先调我给的函数,从 json 里捞出值 | 一个字段有两个候选 key、或值藏在 fields.x 里 |
@JsonKey(fromJson: ..., toJson: ...) | 换掉那一对 as / 直接写入,改成调用我的函数 | 类型转换:int 秒 ↔ DateTime、字符串 ↔ bool |
类上 explicitToJson: true | 嵌套对象生成 instance.group?.toJson(),而不是把对象本身放进 map | 见下面那段,我用它踩过一次原生桥的坑 |
类上 anyMap: true | fromJson 参数变成 Map(不是 Map<String, dynamic>) | 键不一定是字符串的配置类;后端返回的 Map<Object?, Object?> |
类上 disallowUnrecognizedKeys: true | 多出来的 key 会报错 | 默认不报错:多余 key 是被忽略的。我只有在校验后端契约时才开 |
类上 dateTimeUtc: true | 写出去时走 DateTime.toUtc().toIso8601String() | 时区那段专门讲 |
fieldRename 和 name 同时出现时,name 优先——这一条是我在混用命名的接口上撞出来的:整个响应是 snake_case,只有 createdAt 和 groupName 两个字段是驼峰,我加了 fieldRename: FieldRename.snake 之后这两个永远解不出来,且失败形态是静默的 null,不是报错。后来我在字段上写 @JsonKey(name: 'createdAt') 显式覆盖掉,读到 created_at 的生成结果和后端给的 createdAt 对不上,才把两个字段救回来。混用命名的接口我更倾向于不开全局 rename,逐字段写 name,让「后端这条返回不规范」这件事在模型里留下痕迹。
嵌套与 List:为什么需要 _$ContactFromJson
这是我用得最多、也最容易写错的一块。同一个类的生成函数是逐元素接上的,List<T> 这种类型没法在 JSON 里表达泛型参数,所以生成器只能把「怎么解一个 Group」这件事变成一次具体的函数调用:
@JsonSerializable()
class ContactPage {
const ContactPage({required this.items, required this.total});
factory ContactPage.fromJson(Map<String, dynamic> json) =>
_$ContactPageFromJson(json);
final List<Contact> items;
final int total;
Map<String, dynamic> toJson() => _$ContactToJson(this);
}// 生成结果(节选):外层拿到的是 List<dynamic>,逐个调用元素类型的 fromJson
ContactPage _$ContactPageFromJson(Map<String, dynamic> json) => ContactPage(
items: (json['items'] as List<dynamic>)
.map((e) => Contact.fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int,
);explicitToJson 为什么单独存在。默认值是 false,生成的 toJson 把嵌套对象原样放进返回的 map,不在这里调它的 toJson。我一度觉得这没问题:jsonEncode(map) 的时候编码器会去找每个对象的 toJson,事实也确实如此,所以走网络那条路径一直是好的。我踩到的场景是把这个 map 直接塞进 MethodChannel 传给原生层——StandardMessageCodec 只认基本类型、List、Map,遇到一个 Contact 实例就抛 Unsupported value,报错里连字段名都没有。加上 explicitToJson: true 之后,map 里从第一层开始就是纯 JSON 值。要不要全局开这个取决于 map 会不会离开 Dart 的 JSON 编码器;我自己是只要模型里有嵌套就开,因为「这个 map 后面会被谁编码」这件事我一般记不住。
递归类型我遇到过一次卡住。模型里有一个自引用字段(联系人分组有父分组),toJson 无限递归的问题不在生成器,在我:我把一个带环的对象树整个 toJson 了出去。生成器不会帮你发现环,_ 前缀的函数照样一层层调下去,最后是栈溢出。我的处理是在模型之外用一个纯函数把需要的那一层拍平,模型里只放拍平后的结果。
枚举:和后端字符串常量对齐
枚举默认按 name 序列化,ContactStatus.pending 出去就是 "pending"。后端给的是另一套常量(我这边是数据库里的 PENDING、ARCHIVED)时,我用 @JsonValue 把它们钉在枚举值上:
enum ContactStatus {
@JsonValue('PENDING')
pending,
@JsonValue('SHIPPED')
shipped,
// 后端将来加新状态时,我这边不至于整页打不开
@JsonValue('UNKNOWN')
unknown,
}生成器会做一张 _$ContactStatusEnumMap,读的方向靠 $enumDecode 去查表。查不到的值不是 null 也不是默认值,是抛异常。我遇到的第一次是后端加了一个我这边没有的状态:
"archived" is not one of the supported values: pending, shipped, unknown
这行来自 ArgumentError,$enumDecode 里抛的就是它。症状是列表页整个空白,控制台只有一行和状态有关的文本;一条数据带新状态就能让整批加载失败,因为异常是在 .map() 里面抛的,前面的元素也一起没了。我的兜底是在字段上写 @JsonKey(unknownEnumValue: ContactStatus.unknown),让不认识的值落到我自己定义的那个值上——这一条要不要用取决于「一条脏数据打死整页」和「显示成未知状态」哪个更难接受。我这边是展示型列表,前者更不能接受。
另一条路径是不落枚举、只存字符串,把状态判断留给一层自己写的映射。我在字段种类还在改的阶段用过这种做法,代价是拼写检查从编译期彻底消失了,等于把手写映射的问题换了个位置放回来。
时间字段:ISO8601 的双向不对称
这块是我最近才修干净的一个 bug,起因是列表里「今天 09:00 加的联系人」显示成「今天 17:00」,差八小时,而且只在部分记录上出现。
先看两端各自做了什么。
Dart 的 DateTime.parse 接受带时区标记也接受不带的串,toIso8601String() 在带不带 Z 上是不对称的,API 文档里那句话说得很直接:
The format isyyyy-MM-ddTHH:mm:ss.mmmuuuZfor UTC time, andyyyy-MM-ddTHH:mm:ss.mmmuuu(no trailing "Z") for local/non-UTC time.
也就是说,一个本地的 DateTime 写出去不带任何时区信息。这就是我那条记录坏掉的机制:后端 PHP 那边存的是 UTC,但输出时给了不带标记的字符串(2026-10-21T01:00:00),我这边解出来按本地时间在解释,于是 UTC 的 01:00 变成了本地的 01:00;再往后我把它写回去,后端收到的还是一个裸串,它按自己那套解释,来回一次偏移八小时。差值只在部分记录上出现,是因为后端有两处写时间用了不同函数,一处带 Z 一处不带。
我的处理分两层。字段层面用 readValue 把裸串补上时区标记再交给解析:
@JsonSerializable()
class Contact {
// 读的时候先补时区,避免把服务端的 UTC 当本地时间
@JsonKey(readValue: _readCreatedAt, toJson: _writeCreatedAt)
final DateTime? createdAt;
static Object? _readCreatedAt(Map json, String key) {
final raw = json['createdAt'] ?? json['created_at'];
if (raw is! String || raw.isEmpty) return null;
// 后端有时给空格分隔("2026-10-21 01:00:00"),parse 认这种写法,
// 但补标记之前要先把它归一成 T 分隔
var value = raw.replaceFirst(' ', 'T');
// 已经带 Z 或带 +08:00 之类的偏移,就按服务端给的解释,不动它
final hasZone = value.endsWith('Z') ||
RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value);
return hasZone ? value : '${value}Z';
}
static String? _writeCreatedAt(DateTime? v) =>
// 写出去一律带 Z:让服务端只看这一个约定
v?.toUtc().toIso8601String();
}类层面则是 @JsonSerializable(dateTimeUtc: true)——它是类级开关,作用在写出的方向,命中时生成的代码走 toUtc().toIso8601String()。我没全局打开它,因为「这个类的时刻要不要转成 UTC」和「后端给的是哪种约定」这两件事在不同模块里答案不一样,全局开反而把本地时区记录(我这边有纯本地的提醒时间)也转掉了。
兜底的一条:如果接口里时间字段的约定我确认不了,我把类型留成 String? 加一个 getter 去解析,解析失败返回 null 而不是抛。这样时间坏掉的时候页面至少还能显示名字。要不要接受这种降级取决于时间在这个页面是不是主角。
freezed 在我这里解决的两件事
我先说清楚一件事:下面这两块我都只用 freezed 一个注解,没有把它和 @JsonSerializable 叠在同一个类上。两个生成器互相 part 的接法我这边没验证干净(改一个注解要跑两次 build、报错信息在中间转了一层),所以序列化仍然走上面那套手写映射的生成器。混用是不是划算,取决于团队里对生成链路的熟悉程度。
第一件是不可变 + copyWith。 手写模型的 copyWith 是第四个「字段改一次要动三个地方」的位置——参数列表、构造函数调用、默认值各一份,十几个字段时那个方法能占四十行。freezed 把它生成了:
@freezed
class Contact with _$Contact {
const factory Contact({
required int id,
required String name,
String? email,
String? remark,
@Default(<String>[]) List<String> tags,
}) = _Contact;
}
// 用起来
final updated = contact.copyWith(remark: '换了手机号');copyWith 有一个行为我踩过一次:参数传 null 等于没传。生成的方法里所有参数都是可空且默认 null,实现拿 ?? 兜住原值,所以我写 copyWith(email: null) 的时候 email 保持原值不变。症状是「清空邮箱」点了没反应,接口返回成功,重新拉一次还是旧的。原因是这个方法在语义上区分不了「调用方没写这个参数」和「调用方显式给了 null」。我的做法是把「清空」这类操作做成模型上的一个显式构造函数(Contact.withoutEmail(...)),或者把可空字段换成一个带「未设置」状态的包装类型——这两个都比我原来的假设复杂,所以我现在的约定是:需要能置空的字段不进 copyWith 的路径,改走整体重新构造。
第二件是联合类型,也就是我上一篇那个 Result 的正式版本。 上一篇为了把网络错误收成业务语义,我手写了一组 sealed class ApiResult<T>:
// 上一篇里的写法:sealed + 两个 final class,switch 靠编译器穷尽
sealed class ApiResult<T> { const ApiResult(); }
final class Ok<T> extends ApiResult<T> { const Ok(this.data); final T data; }
final class Failure<T> extends ApiResult<T> {
const Failure(this.kind, this.message);
final FailureKind kind;
final String message;
}它已经能用,我把手写版本换成 freezed 的部分只有页面状态,因为状态种类从 2 涨到 5 之后,我每加一个都要重抄一遍三处:
@freezed
class ContactListState<T> with _$ContactListState<T> {
const factory ContactListState.idle() = Idle<T>;
const factory ContactListState.loading() = Loading<T>;
const factory ContactListState.ready(List<T> items) = Ready<T>;
const factory ContactListState.failed(String message) = Failed<T>;
// 加载失败但本地还有一批旧数据:这一种以前靠两个 bool 字段表达
const factory ContactListState.stale(List<T> items, String message) = Stale<T>;
}右边那几个 = Idle<T> 是「这个工厂构造对应哪个生成类」,我是靠报错才理解这行的必要性的:漏写的时候生成器给出一条和构造函数有关的抱怨,读起来完全看不出少了什么。
freezed 给每个联合分支生成一对 when/maybeWhen,when 要求所有分支都写:
Widget build(BuildContext context) => switch (state) {
// 我这边最终用的是 Dart 3 的 switch 表达式,
// 穷尽性检查由编译器负责,和 when 是同一件事的两种写法
Idle() => const SizedBox.shrink(),
Loading() => const Center(child: CircularProgressIndicator()),
Ready(:final items) => _list(items),
Failed(:final message) => _errorBanner(message),
Stale(:final items, :final message) => Stack(
children: [_list(items), _errorBanner(message)],
),
};when 和 switch 我最后留的是后者,理由不是性能:when 的分派函数在生成文件里,出编译错误时我要在两个文件之间跳;switch 的穷尽性报错直接指在我写的那一行上。要不要用 when 取决于自己更愿意读哪种报错。
这里有一个我需要提前说明的行为:when 只在联合类上生成,也就是类里有两个以上工厂构造函数的时候。我一开始把它加在一个单构造的模型上,IDE 补全里没有这个方法,还以为是自己注解写错了。单构造的 freezed 类给的价值在不可变、copyWith、== 和 hashCode 这一批,模式匹配那部分交给 Dart 3 本身就够了。
代价:编译时间、IDE、和接手的人
这三样我都实测过,写下来是因为它们在选型时通常没人说。
build_runner 的时间。 我这工程二十来个模型,从零跑一次完整 build 是一分多钟;watch 起来之后,改一个字段的增量重生成大体在 5 到 10 秒这个量级,本机冷启动的那一次要更长。体感上这个成本不是均摊在每次保存,而是集中在「我要看一眼效果之前」那一段。改一个字段本来只要两秒,现在多等六秒,写单个模型时很烦;但我上次漏改 key 导致线上崩的那次,代价是一次回滚,这个比较对我是有决定性的。
IDE 索引的滞后。 生成文件不在版本库里的时候,新克隆下来第一次打开,模型类上会有一大片红——_$Contact 这类符号要等 build 跑完才存在。这一点对新开项目的人和 CI 都一样。我现在的做法是把 .g.dart 提交进仓库:换来的是克隆即可编译、review 时能直接看到生成的 diff,付出去的是合并冲突——两个人同时改一个模型,冲突会发生在 .g.dart 里,而那个文件没人愿意手动解。我自己解过一次之后选了「删掉重新生成」这条路,但这是个人省事,不代表团队适用。
行为可读性。 出问题时我看的是 contact.g.dart 里那几行,而不是我的类。这本身不算坏事(生成的代码很短),但它要求接手的人知道这套链路存在、知道改注解之后要重跑生成。我带过一次新同事改这个模块,他加了一个字段就直接提交,编译在 CI 才失败——因为本地没跑 build_runner,.g.dart 里的函数没有覆盖新字段,报错出现在构造函数缺参数那一行,而不是他真正改过的字段声明上。这类「改了模型但没重新生成」的失败,症状是报错信息离改动位置很远,判断方法很简单:看那个类的 part 文件的修改时间,或者直接在 CI 里跑一次 build_runner build 加 git diff --exit-code,有 diff 就说明提交里漏了生成产物。后者我加进了流水线,一次配置之后没再出过这种问题。
顺带一提,build_runner 跑失败时报的是「某个 builder 输出冲突」这种话,我第一次看到时完全不知道去哪查。加 --delete-conflicting-outputs 是常见处理方式,但它掩盖的是「两个生成器都要写同一个文件」这个真实冲突——我遇到过一次是自己在两个包名相近的文件里都写了 part 'contact.g.dart',那种情况下删输出只是把问题推到下一次。
学习时要特别注意的几处
字段加完编译通过、运行时才炸。 症状:新增 String avatar 之后本地一切正常,测试环境某些账号一进详情页就抛 type 'Null' is not a subtype of type 'String' in type cast。原因:avatar 声明成非空,而生成出来的 json['avatar'] as String 在缺 key 时拿到 null,as String 就地失败;工具不会替我判断后端到底可不可空。怎么判断:把异常堆栈往下翻一帧,落在 _$XxxFromJson 上就是这一类;再看抛之前那行的 key 名,和接口实际返回对比一次。我选哪一条看后端那个字段的可空性确不确定:确实可空就把类型改成可空,确认必填的话我在字段上加 required: true,让失败停在解析这一步而不是等到后面被用到——区别是后者给出的信息里连字段名都没有。
列表整体加载失败,只有一条脏数据。 症状:分页接口里 20 条中有 1 条的状态值是我这边没有的枚举,页面什么都不显示。原因:items.map(...) 是逐个解析的,任何一条抛异常整批就没了,异常发生在解析阶段而不是渲染阶段,所以「跳过坏数据」这个动作没有位置。怎么判断:日志里看 _$ContactFromJson 抛的第一条数据的 id,如果坏数据只占少数而列表页照样空白,就是这个机制。要不要 per-item 兜底取决于这批数据的性质——展示型列表我会在 Contact.fromJson 外面包一层 try,失败返回 null 再过滤;涉及金额或写入的我不想让它静默少一条。
List<T> 字段在测试里挂、在真机上不挂。 症状:单元测试里我手工构造的 Map 一切正常,真机上偶发 type 'List<dynamic>' is not a subtype of type 'List<Contact>' in type cast。原因:测试里我传的是已经是 List<Contact> 的对象(写测试时直接放进了模型),而 jsonDecode 出来的永远是 List<dynamic>;类型检查发生在运行时,测试数据绕过了 jsonDecode 就绕过了这条路径。怎么判断:把测试数据改成一段字符串再 jsonDecode 一次,如果立刻复现,就是这个差异。我现在的接口测试一律存原始 JSON 字符串,不存构造好的 map。
嵌套对象传给原生层报 Unsupported value。 症状:模型 toJson 出来的 map 发给后端没问题,同一个 map 通过 MethodChannel 传给原生时抛异常,报错里出现的是 Instance of 'ContactGroup'。原因:默认 explicitToJson: false,嵌套对象原样进 map,jsonEncode 会兜住(它找得到 toJson),而 codec 不做这件事。怎么判断:打印 map['group'].runtimeType,如果它不是 String/num/Map/List,就是这个开关没开。
时间显示差整八小时,或部分记录差。 症状:列表里时间列在 UTC 那条约定上偏移,且不是所有记录都偏。原因:本地时间的 toIso8601String() 不带时区标记(文档原话是 (no trailing "Z") for local/non-UTC time),裸串在另一端被按本地时间解析;同一接口里两处写法不同时,偏移就只在部分记录出现。怎么判断:把原始字符串打到日志里看结尾有没有 Z 或偏移量,再看解析出来的 DateTime 的 isUtc 是不是 false——两者组合起来能确定偏在哪一侧。
改了注解,行为没变。 症状:加了 @JsonKey(includeIfNull: false) 之后请求体里还是带着 "email": null。原因:生成文件没重跑,.g.dart 里还是旧的映射函数,编译器不会报,因为它和类定义本来就不冲突。怎么判断:在 IDE 里点开 _$XxxToJson,看那一行有没有 writeNotNull;没有就是生成没跑。我的兜底是上面那条 CI 检查。
一个完整例子:一段混用命名的返回
最后把上面几块合起来。这是上一篇那套网络层拿到的一份真实形状的响应,UnwrapInterceptor 已经剥掉了 code/data 外壳,进模型的是 payload 本体:
{
"page_num": 2,
"total_count": 128,
"items": [
{
"id": 9527,
"user_name": "陈默",
"phone_number": "13800001234",
"email": null,
"tags": ["老同学", "深圳"],
"remark": null,
"status": "PENDING",
"createdAt": "2026-10-21T01:00:00",
"groups": [
{ "group_id": 3, "groupName": "同学" }
]
}
]
}page_num、user_name 是下划线,createdAt、groupName 是驼峰,createdAt 还是一个不带时区标记的裸串。我建的是这样:
import 'package:json_annotation/json_annotation.dart';
part 'contact_page.g.dart';
enum ContactStatus {
@JsonValue('PENDING')
pending,
@JsonValue('SHIPPED')
shipped,
@JsonValue('UNKNOWN')
unknown;
}
@JsonSerializable(explicitToJson: true)
class Group {
const Group({required this.id, required this.name});
factory Group.fromJson(Map<String, dynamic> json) => _$GroupFromJson(json);
// 这个接口的组 id 是 group_id,名字却叫 groupName——逐字段写死,
// 不开全局 fieldRename,因为整类只有两个字段,规则反而更难读
@JsonKey(name: 'group_id')
final int id;
@JsonKey(name: 'groupName')
final String name;
Map<String, dynamic> toJson() => _$GroupToJson(this);
}
@JsonSerializable(explicitToJson: true)
class Contact {
const Contact({
required this.id,
required this.name,
required this.phone,
this.email,
this.remark,
required this.status,
this.tags = const [],
this.groups = const [],
this.createdAt,
});
factory Contact.fromJson(Map<String, dynamic> json) => _$ContactFromJson(json);
final int id;
@JsonKey(name: 'user_name')
final String name;
@JsonKey(name: 'phone_number')
final String phone;
// 后端在这里给的是显式 null,不是缺 key;类型可空就都能接住
final String? email;
@JsonKey(includeIfNull: false)
final String? remark;
// 用 unknownEnumValue 兜住后端新增状态,避免一条数据打挂整页
@JsonKey(unknownEnumValue: ContactStatus.unknown)
final ContactStatus status;
final List<String> tags;
final List<Group> groups;
@JsonKey(readValue: _readCreatedAt, toJson: _writeCreatedAt)
final DateTime? createdAt;
static Object? _readCreatedAt(Map json, String key) {
final raw = json['createdAt'] ?? json['created_at'];
if (raw is! String || raw.isEmpty) return null;
final value = raw.replaceFirst(' ', 'T');
final hasZone =
value.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value);
return hasZone ? value : '${value}Z';
}
static String? _writeCreatedAt(DateTime? v) => v?.toUtc().toIso8601String();
Map<String, dynamic> toJson() => _$ContactToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ContactPage {
const ContactPage({
required this.page,
required this.total,
required this.items,
});
factory ContactPage.fromJson(Map<String, dynamic> json) =>
_$ContactPageFromJson(json);
@JsonKey(name: 'page_num')
final int page;
@JsonKey(name: 'total_count')
final int total;
final List<Contact> items;
Map<String, dynamic> toJson() => _$ContactToJson(this);
}调用点变成一行,剩下的是网络层那一天的事:
final page = ContactPage.fromJson(resp.data as Map<String, dynamic>);
// 状态判断在 Dart 3 里可以写成穷尽的 switch,
// 加了新枚举值而漏改这里的话,编译器会停下来
final label = switch (page.items.first.status) {
ContactStatus.pending => '待确认',
ContactStatus.shipped => '已发货',
ContactStatus.unknown => '未知状态',
};这一层写完,我把模型文件的行数对了一遍:Contact 从手写的 68 行降到 46 行,且字段声明是这里唯一需要维护的位置。多出来的是 contact_page.g.dart 那个两百多行、我从不打开的文件,和每次改动的 5 到 10 秒等待。加字段这件事我后来做过一次:新增 String? company,改一行、跑一次 build、git diff 里多两处,没有第三处要动。
评论
暂未收到