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

Flutter JSON 模型:手写、json_serializable、Freezed 和版本演进

JSON 模型解决的是同一个问题:把网络、文件或平台接口中的 JSON 数据,转换为 Dart 对象,并在对象发生变化时重新编码为 JSON。

这个问题通常包含两个方向:

JSON 文本解析Dart 数据建模领域对象\text{JSON 文本} \xrightarrow{\text{解析}} \text{Dart 数据} \xrightarrow{\text{建模}} \text{领域对象}

以及:

领域对象序列化Dart Map编码JSON 文本\text{领域对象} \xrightarrow{\text{序列化}} \text{Dart Map} \xrightarrow{\text{编码}} \text{JSON 文本}

其中,“解析”通常指把 JSON 字符串转换为 Map<String, dynamic>List<dynamic> 等基础结构;“反序列化”指把基础结构转换成具体 Dart 模型;“序列化”指把 Dart 模型转换成 JSON 可表示的结构。

Flutter 本身不规定必须使用哪一种 JSON 模型方案。Dart 标准库提供 JSON 文本解析能力,模型转换可以手写,也可以通过 json_serializable 或 Freezed 生成代码。


1. Dart 中 JSON 的真实形态

JSON 只有以下几类值:

  • 对象:键和值组成的集合;
  • 数组:有序值集合;
  • 字符串;
  • 数字;
  • 布尔值;
  • null

Dart 的 dart:convert 将它们映射为:

JSON Dart
object Map<String, dynamic>
array List<dynamic>
string String
number 通常为 intdouble
boolean bool
null null

例如:

import 'dart:convert';

void main() {
  const source = '''
  {
    "id": 7,
    "name": "Ada",
    "tags": ["flutter", "dart"],
    "active": true
  }
  ''';

  final value = jsonDecode(source);

  print(value.runtimeType); // _Map<String, dynamic>
  print(value['id']);       // 7
  print(value['tags'][0]);  // flutter
}

jsonDecode 的返回类型是 dynamic,这意味着编译器无法替你保证字段存在、字段类型正确或嵌套结构正确。下面的代码虽然能够编译,但可能在运行时失败:

final value = jsonDecode('{"id": "not-an-int"}');
final int id = value['id']; // 运行时类型错误

因此,JSON 解析通常分为两个阶段:

final decoded = jsonDecode(source) as Map<String, dynamic>;
final user = User.fromJson(decoded);

第一步只完成“文本到基础容器”的转换,第二步才完成“基础容器到模型”的转换。


2. JSON 模型的职责边界

一个 JSON 模型至少需要回答四个问题:

  1. JSON 字段叫什么;
  2. Dart 属性叫什么;
  3. 字段是否允许缺失或为 null
  4. 嵌套对象、数组、枚举和日期如何转换。

例如服务器返回:

{
  "user_id": 7,
  "display_name": "Ada",
  "created_at": "2025-01-01T12:00:00Z"
}

Dart 模型可以设计为:

class User {
  final int id;
  final String displayName;
  final DateTime createdAt;

  const User({
    required this.id,
    required this.displayName,
    required this.createdAt,
  });
}

这里存在三个不同层次的名称:

  • JSON 名称:user_iddisplay_namecreated_at
  • Dart 属性名:iddisplayNamecreatedAt
  • 业务含义:用户标识、展示名称、创建时间。

模型层负责完成 JSON 结构和 Dart 类型之间的转换,但通常不应承担 HTTP 请求、缓存、数据库事务等职责。网络客户端获取响应,JSON 模型负责转换,业务层再使用转换后的对象。


3. 手写 JSON 模型

手写方式不依赖代码生成,适合模型数量较少、结构简单或需要完全控制错误处理的场景。

3.1 一个完整的手写模型

import 'dart:convert';

class User {
  final int id;
  final String displayName;
  final DateTime? createdAt;
  final List<String> roles;

  const User({
    required this.id,
    required this.displayName,
    required this.createdAt,
    required this.roles,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    final rawRoles = json['roles'];

    if (rawRoles is! List) {
      throw const FormatException('字段 roles 必须是数组');
    }

    return User(
      id: _requiredInt(json, 'user_id'),
      displayName: _requiredString(json, 'display_name'),
      createdAt: _nullableDateTime(json, 'created_at'),
      roles: rawRoles.map((value) {
        if (value is! String) {
          throw const FormatException('字段 roles 必须全部是字符串');
        }
        return value;
      }).toList(growable: false),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'user_id': id,
      'display_name': displayName,
      'created_at': createdAt?.toUtc().toIso8601String(),
      'roles': roles,
    };
  }

  static int _requiredInt(Map<String, dynamic> json, String key) {
    final value = json[key];
    if (value is! int) {
      throw FormatException('字段 $key 必须是 int,实际为 ${value.runtimeType}');
    }
    return value;
  }

  static String _requiredString(Map<String, dynamic> json, String key) {
    final value = json[key];
    if (value is! String) {
      throw FormatException('字段 $key 必须是 String,实际为 ${value.runtimeType}');
    }
    return value;
  }

  static DateTime? _nullableDateTime(
    Map<String, dynamic> json,
    String key,
  ) {
    final value = json[key];

    if (value == null) {
      return null;
    }

    if (value is! String) {
      throw FormatException('字段 $key 必须是日期字符串');
    }

    return DateTime.parse(value);
  }
}

void main() {
  const source = '''
  {
    "user_id": 7,
    "display_name": "Ada",
    "created_at": "2025-01-01T12:00:00Z",
    "roles": ["admin", "author"]
  }
  ''';

  final json = jsonDecode(source) as Map<String, dynamic>;
  final user = User.fromJson(json);

  print(user.displayName); // Ada
  print(user.toJson());
}

这个例子明确展示了几个重要规则:

  • jsonDecode 之后不能假设 dynamic 的类型;
  • required 只约束 Dart 构造函数调用,不会自动验证 JSON;
  • DateTime.parse 是显式的字符串到日期转换;
  • JSON 中的 null 与字段缺失都可能映射为 Dart 的 null
  • toUtc().toIso8601String() 统一了日期输出格式。

3.2 手写模型的优点和代价

手写方式的主要优点是控制力强。可以在 fromJson 中实现:

  • 字段别名;
  • 兼容多个服务端版本;
  • 自定义错误信息;
  • 数据清洗;
  • 跨字段校验;
  • 非标准 JSON 结构转换。

例如,某接口可能把数字返回成字符串:

{"id": "7"}

如果业务明确允许这种格式,可以写出有意的兼容逻辑:

int parseIntLike(Object? value, String fieldName) {
  if (value is int) {
    return value;
  }

  if (value is String) {
    final parsed = int.tryParse(value);
    if (parsed != null) {
      return parsed;
    }
  }

  throw FormatException('$fieldName 不是有效整数');
}

但手写代码也会产生重复劳动。模型字段增加后,fromJsontoJson 必须同步修改;嵌套列表、枚举、可空字段越多,遗漏字段的概率越高。更危险的是,手写代码通常可以编译通过,但运行时才发现某个字段没有被序列化。


4. json_serializable:只生成 JSON 转换代码

json_serializable 是 Dart 代码生成工具。它根据模型上的注解生成 fromJsontoJson 实现,模型类本身仍然由开发者定义。

它不负责:

  • HTTP 请求;
  • 状态管理;
  • 不可变对象;
  • copyWith
  • 相等性判断;
  • 联合类型。

它主要负责 JSON 映射。

4.1 安装依赖

pubspec.yaml 中加入:

dependencies:
  flutter:
    sdk: flutter
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.9.0

版本号应以项目实际解析到的兼容版本为准,不应机械复制示例中的版本。json_annotation 是运行时依赖,因为应用运行时会使用注解相关类型;json_serializablebuild_runner 只在生成阶段使用,因此通常放在 dev_dependencies

4.2 一个可生成的模型

文件 lib/models/user.dart

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
  @JsonKey(name: 'user_id')
  final int id;

  @JsonKey(name: 'display_name')
  final String displayName;

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

  final List<String> roles;

  const User({
    required this.id,
    required this.displayName,
    required this.createdAt,
    required this.roles,
  });

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

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

这里的 part 'user.g.dart'; 表示当前库的一部分由另一个 Dart 文件提供。_$UserFromJson_$UserToJson 不是手写 API,而是生成文件中的函数。

执行:

dart run build_runner build

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

dart run build_runner build --delete-conflicting-outputs

命令的含义是:

  1. build_runner 扫描项目中的注解;
  2. 找到 @JsonSerializable()
  3. 生成 user.g.dart
  4. 如果已有生成文件与本次结果冲突,--delete-conflicting-outputs 允许工具删除冲突输出后重建。

开发时可以持续监听:

dart run build_runner watch --delete-conflicting-outputs

生成后,应用源码实际调用的仍然是:

final user = User.fromJson(json);
final body = user.toJson();

生成代码只是把重复实现放到了构建阶段,业务代码不需要直接调用生成函数。

4.3 嵌套对象和列表

import 'package:json_annotation/json_annotation.dart';

part 'article.g.dart';

@JsonSerializable()
class Article {
  final int id;
  final String title;
  final Author author;
  final List<Comment> comments;

  const Article({
    required this.id,
    required this.title,
    required this.author,
    required this.comments,
  });

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

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

@JsonSerializable()
class Author {
  final int id;
  final String name;

  const Author({
    required this.id,
    required this.name,
  });

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

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

@JsonSerializable()
class Comment {
  final int id;
  final String content;

  const Comment({
    required this.id,
    required this.content,
  });

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

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

当生成器看到 Author authorList<Comment> comments 时,会调用对应类型的 fromJson。因此,嵌套类型也必须提供可识别的 JSON 转换能力。

输入:

{
  "id": 1,
  "title": "Dart",
  "author": {"id": 2, "name": "Ada"},
  "comments": [
    {"id": 10, "content": "good"}
  ]
}

转换路径是:

Map
├── id              -> int
├── title           -> String
├── author          -> Author.fromJson(...)
└── comments        -> Comment.fromJson(...) 的列表

如果 author 实际返回 null,但 Dart 属性声明为非空的 Author,转换会失败。这不是代码生成器的缺陷,而是 JSON 合同与 Dart 模型约束不一致。

4.4 字段缺失、null 和默认值

以下三种情况必须区分:

{}
{"name": null}
{"name": ""}

它们分别表示:

  • 字段不存在;
  • 字段存在但值为 null
  • 字段存在且值为空字符串。

对应模型:

@JsonSerializable()
class Settings {
  @JsonKey(defaultValue: false)
  final bool enabled;

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

  final String? nickname;

  const Settings({
    required this.enabled,
    required this.tags,
    required this.nickname,
  });

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

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

这里:

  • enabled 缺失时使用 false
  • tags 缺失或值为 null 时,是否完全按默认值处理,应结合生成器版本和注解配置验证;对于合同严格的场景,不应仅依赖默认值掩盖服务端错误;
  • nickname 明确允许 null

defaultValue 是兼容策略,不等于字段天然可选。它会把“服务端没有提供字段”转换为业务默认值,因此必须确认该默认值不会改变业务语义。

4.5 JSON 名称和枚举

enum UserStatus {
  active,
  disabled,
}

@JsonSerializable()
class Account {
  final UserStatus status;

  const Account({required this.status});

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

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

默认枚举字符串通常依据枚举值名称生成,例如 activedisabled。如果服务端名称不同,可以使用 @JsonValue

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

  @JsonValue('blocked')
  disabled,
}

此时:

{"status": "enabled"}

会映射为 UserStatus.active

未知枚举值是版本演进中的高风险点。新服务端可能增加 "pending",旧客户端没有对应枚举值。可以设计一个未知值:

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

  @JsonValue('disabled')
  disabled,

  @JsonValue('unknown')
  unknown,
}

也可以通过 JsonKey 的未知枚举值配置将未知值映射为指定枚举成员。具体配置方式应与当前 json_annotation 版本匹配;不能假设所有版本都支持相同的枚举策略。

4.6 自定义转换器

日期是常见的自定义转换边界。DateTime 可以直接被 json_serializable 处理常见 ISO 8601 字符串,但如果接口使用 Unix 秒或毫秒时间戳,就应明确指定转换逻辑。

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

  @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 name;

  @UnixMillisecondsConverter()
  final DateTime occurredAt;

  const Event({
    required this.name,
    required this.occurredAt,
  });

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

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

输入:

{
  "name": "build-finished",
  "occurredAt": 1735732800000
}

转换器将毫秒数转换为 UTC DateTime。如果把秒误当成毫秒,日期会落在完全错误的时间范围;这类错误通常不会触发类型异常,因此字段单位必须写入接口合同或测试。


5. Freezed:不可变模型、复制和联合类型

Freezed 建立在代码生成之上,但目标不只是 JSON 转换。它主要提供:

  • 不可变数据类;
  • 值相等;
  • copyWith
  • toString
  • 联合类型,也称代数数据类型;
  • json_serializable 集成的 JSON 转换。

因此,Freezed 和 json_serializable 不是完全竞争关系。常见组合是:

Freezed 负责模型结构、不可变性和联合类型
json_serializable 负责 JSON 映射
build_runner 负责生成代码

5.1 安装依赖

dependencies:
  flutter:
    sdk: flutter
  freezed_annotation: ^2.4.0
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^2.5.0
  json_serializable: ^6.9.0

具体版本应根据当前稳定生态和项目依赖约束解析。Freezed、freezed_annotationjson_serializable 之间需要保持兼容。

5.2 Freezed JSON 模型

文件 lib/models/user.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,

    @JsonKey(name: 'display_name')
    required String displayName,

    @JsonKey(name: 'created_at')
    DateTime? createdAt,

    @Default(<String>[])
    List<String> roles,
  }) = _User;

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

执行:

dart run build_runner build --delete-conflicting-outputs

使用:

final user = User.fromJson({
  'id': 7,
  'display_name': 'Ada',
  'created_at': '2025-01-01T12:00:00Z',
});

final changed = user.copyWith(displayName: 'Grace');

print(user == changed); // false
print(user.roles);      // []
print(changed.toJson());

这里的 @Default(<String>[]) 既为构造函数提供默认值,也会让生成模型具有默认的 roles。它不是对服务端数据正确性的全面验证;如果服务端传入错误类型,仍然可能在反序列化时失败。

Freezed 生成的对象通常不可变。所谓不可变,指对象创建后不能直接修改字段:

// user.displayName = 'Grace'; // 编译错误
final changed = user.copyWith(displayName: 'Grace');

不可变性对状态管理尤其有用,因为状态更新可以通过“旧对象”和“新对象”的引用或值变化来表达,而不是在原对象上隐式修改。

5.3 Freezed 与 JSON 的关系

如果只有:

@freezed
class User with _$User {
  const factory User({required int id}) = _User;
}

这只能表达 Freezed 数据类,不一定自动提供 JSON 工厂。要获得 JSON 转换,通常需要:

part 'user.g.dart';

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

并确保 json_serializable 在开发依赖中可用。user.freezed.dartuser.g.dart 的职责不同:

  • user.freezed.dart:构造实现、相等性、copyWith 等;
  • user.g.dartfromJsontoJson 相关实现。

缺少任意一个 part,或没有运行生成命令,都会导致类似以下错误:

The method '_$UserFromJson' isn't defined

或者:

Target of URI hasn't been generated

诊断顺序应是:

  1. 文件名是否与 part 完全一致;
  2. 两个生成文件是否确实存在;
  3. pubspec.yaml 是否包含生成依赖;
  4. 是否在项目根目录执行了 build_runner
  5. 是否存在生成冲突;
  6. 删除过期生成文件后重新生成。

6. Freezed 联合类型和 JSON 多态

接口返回的 JSON 有时不是一种固定结构,而是由某个字段决定具体类型:

{"type": "success", "data": {"id": 7}}
{"type": "failure", "message": "timeout"}

这类结构适合用联合类型表达,而不是让一个模型包含大量互斥的可空字段。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'api_result.freezed.dart';
part 'api_result.g.dart';

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

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

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

这里使用了 Dart 3 的 sealed 类。sealed 表示类型层次的继承范围受限制,编译器能够更好地检查模式匹配是否覆盖所有已知分支。

使用时:

final result = ApiResult.fromJson({
  'type': 'success',
  'data': {
    'id': 7,
    'display_name': 'Ada',
  },
});

final text = switch (result) {
  ApiSuccess(:final data) => '用户:${data.displayName}',
  ApiFailure(:final message) => '失败:$message',
};

print(text);

对应的 JSON 分发依赖 type

type == "success" -> ApiSuccess
type == "failure" -> ApiFailure

如果服务端新增 "partial",旧客户端的处理方式取决于生成配置和模型设计。未知联合分支可能导致反序列化失败,因此多态协议必须提前约定:

  • 是否允许未知分支;
  • 是否提供 unknown 分支;
  • 是否将原始 JSON 保留下来;
  • 新分支发布时是否要求客户端先升级。

联合类型不是简单的枚举替代物。枚举只表示一个值,联合类型还携带不同分支各自的数据结构。


7. 手写、json_serializable 和 Freezed 的机制差异

三种方式可以按“控制权”和“自动化程度”理解:

方案 JSON 转换 不可变性 copyWith 值相等 联合类型
手写 手动 手动 手动 手动 手动
json_serializable 自动生成 不负责 不负责 不负责 不负责
Freezed 通常集成生成 提供 提供 提供 提供

json_serializable 适合这样的模型:

@JsonSerializable()
class Token {
  final String accessToken;
  final int expiresIn;

  const Token({
    required this.accessToken,
    required this.expiresIn,
  });

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

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

Freezed 更适合需要状态值语义的模型:

@freezed
class Cart with _$Cart {
  const factory Cart({
    @Default(<CartItem>[]) List<CartItem> items,
  }) = _Cart;
}

手写方式更适合转换规则本身复杂的场景,例如:

  • 同一字段可能有多个历史格式;
  • 需要按多个字段计算一个属性;
  • 需要保留未知字段;
  • 协议不是标准对象映射;
  • 需要详细指出嵌套路径和错误位置。

生成代码并不会消除协议复杂性。它减少的是重复代码,不是消除数据契约中的歧义。


8. JSON 解析的错误路径

一个端到端的数据流通常如下:

flowchart LR
    A[HTTP 响应文本] --> B[jsonDecode]
    B --> C{基础结构是否符合预期}
    C -- 否 --> E[FormatException 或类型错误]
    C -- 是 --> D[Model.fromJson]
    D --> F{字段和嵌套类型是否符合模型}
    F -- 否 --> G[模型转换异常]
    F -- 是 --> H[领域对象]
    H --> I[业务状态或 UI]

例如接口返回:

{"id": 7, "display_name": 123}

jsonDecode 可以成功,因为这仍然是合法 JSON;失败发生在 User.fromJson,因为 display_name 不符合 String 类型。

因此不能只捕获网络异常:

try {
  final response = await client.get(...);
  final json = jsonDecode(response.body);
  return User.fromJson(json);
} on SocketException {
  // 这里只处理网络连接类错误
}

应当区分至少三类错误:

Future<User> loadUser() async {
  try {
    final response = await fetchUserResponse();

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw HttpException('HTTP ${response.statusCode}');
    }

    final decoded = jsonDecode(response.body);

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

    return User.fromJson(decoded);
  } on FormatException {
    rethrow; // 响应格式错误
  } on HttpException {
    rethrow; // HTTP 状态码错误
  }
}

在实际项目中,可以把异常包装为应用层错误,同时保留原始异常和请求上下文。不要把所有失败都显示成“网络错误”,否则服务端字段变更会被错误地诊断为网络不稳定。

对于 json_serializable,可以考虑启用更严格的检查配置,例如检查字段转换过程中的错误。严格检查通常更容易定位问题,但可能降低对非关键未知字段的容忍度。是否启用应依据接口合同,而不是一概而论。


9. JSON 模型与状态管理中的数据流

JSON 模型通常不应直接等同于 UI 状态。

例如:

HTTP JSON
  -> UserDto
  -> User 领域对象
  -> UserState
  -> Widget

简单应用可以直接使用一个模型;复杂应用则常区分:

  • DTO:与远端 JSON 结构一致;
  • 领域模型:表达业务概念;
  • 状态模型:表达加载、成功、失败等运行状态。

Freezed 可以表示状态:

@freezed
class UserState with _$UserState {
  const factory UserState.initial() = UserInitial;
  const factory UserState.loading() = UserLoading;
  const factory UserState.data(User user) = UserData;
  const factory UserState.error(String message) = UserError;
}

这里的 UserState 不是服务器 JSON 模型。它表示客户端运行过程中的状态。把 HTTP 响应直接设计成 loadingerror 等状态,往往会混淆远端数据和本地控制状态。

异步并发时还要注意过期响应:

请求 A 发出
请求 B 发出
请求 B 先返回 -> 状态更新为 B
请求 A 后返回 -> 如果无请求标识,可能错误覆盖为 A

JSON 模型无法自动解决这个问题。请求序列号、取消请求或状态层的版本检查,属于并发控制职责。


10. 版本演进:JSON 契约为什么会破坏客户端

假设客户端模型为:

class User {
  final int id;
  final String name;
}

服务端版本演进不是单纯“增加字段”这么简单。必须分别讨论读取方向和写入方向。

10.1 读取新数据的兼容性

旧客户端读取新服务端响应:

{
  "id": 7,
  "name": "Ada",
  "avatar_url": "https://example.com/a.png"
}

如果旧模型只读取 idname,而生成代码忽略未知字段,那么通常可以继续工作。未知字段被丢弃:

JSON: id, name, avatar_url
模型: id, name
结果: avatar_url 不进入模型

这是一种常见实现行为,但不应自动当作所有解析器、所有自定义代码都保证的协议规则。手写解析器可以主动拒绝未知字段,某些严格配置也会改变行为。

新增字段通常具有较好的向后读取兼容性,前提是:

  • 旧客户端不要求该字段;
  • 服务端没有改变旧字段的类型;
  • 新字段不会改变旧字段的语义。

10.2 新客户端读取旧数据

新客户端新增非空必需字段:

final String phoneNumber;

但旧服务端仍返回:

{"id": 7, "name": "Ada"}

如果没有默认值或可空声明,反序列化会失败。形式上,若模型要求字段集合:

R={id,name,phoneNumber}R = \{id, name, phoneNumber\}

而旧响应提供:

P={id,name}P = \{id, name\}

当:

RPR \nsubseteq P

时,新客户端无法构造完整对象。

兼容做法包括:

@JsonKey(defaultValue: '')
final String phoneNumber;

或:

final String? phoneNumber;

二者语义不同:

  • String? 表示“没有号码”是业务上可表达的状态;
  • 默认空字符串表示“缺失时用某个替代值”。

如果空字符串并不等于没有号码,使用默认空字符串会把数据缺失和真实空值混为一谈。

10.3 字段重命名

把:

{"name": "Ada"}

改为:

{"display_name": "Ada"}

不是普通新增字段,而是协议重命名。只改 Dart 属性名并增加:

@JsonKey(name: 'display_name')
final String displayName;

会让旧客户端或旧服务端无法互通。

常见迁移方案是服务端在过渡期同时返回两个字段:

{
  "name": "Ada",
  "display_name": "Ada"
}

客户端可以优先读取新字段,旧字段作为回退。手写解析在这里更灵活:

final name = json['display_name'] ?? json['name'];

但这种兼容代码必须设置退出条件,否则旧字段会永久存在于模型中,增加测试和维护成本。

10.4 字段类型变化

以下变化通常是破坏性的:

{"count": 7}

变为:

{"count": "7"}

或:

{"items": []}

变为:

{"items": {}}

即使业务含义“看起来相同”,JSON 类型已经变化。自动生成代码通常不会隐式承担这种宽松转换。若确实需要兼容,应使用明确的转换器或手写解析,并通过测试覆盖两种输入。

10.5 删除字段

服务端删除字段对旧客户端的影响取决于旧客户端是否将它声明为必需字段。客户端删除属性对写请求的影响则取决于服务端是否仍要求该字段。

因此,兼容性需要分别检查:

服务端响应 -> 客户端读取
客户端请求 -> 服务端写入

读兼容和写兼容不是同一件事。一个新增字段可能不影响读取,却会因为客户端 toJson() 开始发送不被旧服务端识别的内容而影响写入。


11. 版本化和迁移策略

当协议已经存在多个版本时,可以显式携带版本:

{
  "schema_version": 2,
  "user_id": 7,
  "display_name": "Ada"
}

然后在边界层分发:

User parseUser(Map<String, dynamic> json) {
  final version = json['schema_version'];

  return switch (version) {
    1 => User.fromLegacyJson(json),
    2 => User.fromJson(json),
    _ => throw FormatException('不支持的用户模型版本:$version'),
  };
}

另一种方式是在进入领域模型前先迁移成统一形状:

版本 1 JSON ─┐
             ├─> 统一 User JSON ─> User.fromJson
版本 2 JSON ─┘

这种方式把历史兼容逻辑集中在迁移层,领域模型不需要知道所有历史版本。

对于本地持久化尤其重要。应用升级后,磁盘中可能仍然保存旧 JSON:

旧版本应用写入 v1
新版本应用读取 v1
新版本将数据迁移到 v2
新版本只使用 v2 模型

不要只测试最新服务端响应,还应保存代表性的历史 JSON fixture,验证:

  • 旧数据仍能读取;
  • 迁移后关键字段不丢失;
  • 再次写出时格式符合当前版本;
  • 不支持的版本能产生明确错误。

12. toJson 并不等于完整数据备份

模型的 toJson() 通常只输出模型声明的字段。假设服务器返回:

{
  "id": 7,
  "name": "Ada",
  "server_only_flag": true
}

模型没有 serverOnlyFlag,经过:

final user = User.fromJson(json);
final encoded = user.toJson();

再次编码后可能变为:

{
  "id": 7,
  "name": "Ada"
}

因此:

toJson(fromJson(x))=x\text{toJson(fromJson(x))} = x

通常并不成立。更准确的目标是:

toJson(fromJson(x))\text{toJson(fromJson(x))}

保留模型关心的语义字段,而不是保留 JSON 的每一个字节或每一个未知字段。

如果必须无损转发未知字段,需要显式设计,例如在模型中保存扩展字段:

class Envelope {
  final int id;
  final Map<String, dynamic> extra;

  const Envelope({
    required this.id,
    required this.extra,
  });
}

这会增加复制、相等性、隐私和序列化风险,不能在没有需求时默认采用。


13. 测试模型的关键性质

JSON 模型测试不应只验证一个“正常样例”。至少应覆盖三类性质。

13.1 反序列化

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('User can be decoded from server JSON', () {
    final user = User.fromJson({
      'user_id': 7,
      'display_name': 'Ada',
      'roles': ['admin'],
    });

    expect(user.id, 7);
    expect(user.displayName, 'Ada');
    expect(user.roles, ['admin']);
  });
}

13.2 序列化字段名

test('User uses wire field names when encoding', () {
  const user = User(
    id: 7,
    displayName: 'Ada',
    createdAt: null,
    roles: ['admin'],
  );

  final json = user.toJson();

  expect(json['user_id'], 7);
  expect(json['display_name'], 'Ada');
  expect(json.containsKey('id'), isFalse);
});

这个测试可以防止开发者误以为 Dart 属性名会自动等于服务器字段名。

13.3 边界和失败输入

test('invalid field type throws', () {
  expect(
    () => User.fromJson({
      'user_id': '7',
      'display_name': 'Ada',
      'roles': <String>[],
    }),
    throwsA(isA<TypeError>()),
  );
});

具体异常类型可能受到手写实现、生成器配置和转换器的影响。生产测试更应关注“确实失败”和错误上下文,而不要过度依赖某一个内部异常类。

还应测试:

  • 缺失非空字段;
  • null 与缺失的区别;
  • 空数组;
  • 未知枚举值;
  • 日期格式错误;
  • 嵌套对象为 null
  • 服务端增加未知字段;
  • 历史版本 JSON。

14. Android、iOS、桌面和 Web 的差异

JSON 转换本身属于 Dart 代码,dart:convert、手写模型、json_serializable 和 Freezed 生成的模型在 Android、iOS、Windows、macOS、Linux 和 Web 上原则上使用相同。

真正的差异通常出现在 JSON 数据的来源和构建环境:

网络和文件访问

  • Android、iOS、桌面可以使用各自支持的网络和文件能力;
  • Web 不能直接使用 dart:io
  • Web 文件访问受浏览器安全模型、用户授权和沙箱限制;
  • http 等跨平台库通常会根据目标平台选择不同实现。

因此,模型层不应直接依赖 dart:io。更合理的结构是:

平台相关数据源 -> Map<String, dynamic> -> 跨平台模型

代码生成

build_runner 在开发机构建阶段运行,不是在手机或浏览器中运行。发布 APK、IPA、桌面程序或 Web 资源时,应把生成后的 Dart 文件纳入构建输入。

如果 CI 没有执行生成命令,常见失败表现是:

Target of URI hasn't been generated

可以在 CI 中执行:

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

是否将生成文件提交到版本库属于团队约定。无论选择提交还是 CI 生成,都必须保证干净环境能够稳定重建,并避免本地生成结果与 CI 工具版本不一致。

性能和隔离

JSON 模型转换不受 Android 或 iOS UI 框架特殊 API 约束,但大型 JSON 的解析和模型构造会占用 CPU 并分配内存。Web、低端移动设备和桌面设备的性能特征不同,不能用单一平台的测试结果推断所有平台。

对于很大的 JSON 响应,可考虑分页、减少字段、服务端压缩或将解析工作移出 UI 线程。是否使用 isolate 需要根据实际数据规模和测量结果决定;普通小型接口不应为了理论上的并发而增加复杂度。


15. 选择方案时的实际判断

可以从模型的真实需求倒推工具:

选择手写

适合:

  • 模型很少;
  • 转换逻辑明显非标准;
  • 需要精细的字段回退和错误信息;
  • 需要保留未知字段;
  • 不希望引入生成流程。

代价是重复代码和人工同步风险。

选择 json_serializable

适合:

  • 模型是普通对象;
  • 主要问题是字段映射和嵌套转换;
  • 希望保留普通 Dart 类的写法;
  • 不需要 copyWith、联合类型和自动值相等。

它只解决 JSON 映射,不会自动改善对象设计。

选择 Freezed 加 json_serializable

适合:

  • 状态模型较多;
  • 需要不可变对象;
  • 需要 copyWith
  • 需要值相等;
  • 接口包含成功、失败、加载结果等多种结构;
  • 希望用 Dart 3 的模式匹配处理联合类型。

代价是更多生成文件、注解规则和构建依赖。团队需要统一文件命名、生成命令、版本升级和冲突处理方式。


16. 一个完整的最小调用路径

无论选择哪种模型,应用边界通常可以保持类似:

Future<User> decodeUserResponse(String body) async {
  final decoded = jsonDecode(body);

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

  return User.fromJson(decoded);
}

调用方只关心:

try {
  final user = await decodeUserResponse(responseBody);
  print(user.displayName);
} on FormatException catch (error) {
  print('响应格式错误:$error');
}

完整链路的关键约束是:

响应文本必须是合法 JSON
根结构必须符合接口约定
字段类型必须符合模型声明
嵌套模型必须提供转换能力
生成文件必须与源码同步
版本兼容策略必须经过测试

手写、json_serializable 和 Freezed 的区别,主要在于这些约束由谁实现、重复代码由谁维护,以及模型是否需要不可变性和联合类型。工具可以生成转换代码,却不能替团队决定字段缺失的业务含义、版本升级的兼容边界或错误发生后的恢复策略。


系列导航与关联阅读

官方资料

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