Flutter 查缺补漏:@JsonKey 属性全解,让模型层不再被后端字段牵着走

39 阅读4分钟

关键词:Flutter、json_serializable、JsonKey、json_annotation、fromJson、toJson、build_runner、模型层

一、需求来源

「开发 Flutter 数据模型时遇到一个痛点:后端字段和 Dart 字段对不上,手写 fromJson 又臭又长,还总在 null 和新增枚举值上翻车。」

factory User.fromJson(Map<String, dynamic> json) {
  return User(
    userName: json['user_name'] as String,   // 后端是下划线命名
    age: int.parse(json['age'].toString()),  // 后端给的是字符串
    avatar: json['avatar'] as String?,       // 可能整个 key 都没有
  );
}

Map<String, dynamic> toJson() => {
      'user_name': userName,
      'age': age.toString(),
      'avatar': avatar,                      // 为 null 时还要不要发?
    };

痛点有三:

  1. 字段名不一致得手写映射 —— 后端 user_name、uid、create_time 满天飞,字段一多就漏。
  2. 类型不一致得手写转换 —— 时间戳、字符串数字、嵌套对象,每个模型重写一遍。
  3. 边界情况直接崩 —— key 缺失、值为 null、后端新增枚举值,全是线上事故。

json_serializable 能消掉重复劳动,但生成器默认只认「字段名 == JSON key」。本项目模型就是典型:

// lib/models/living_info_entity.dart:14
@JsonSerializable()
class LivingInfoEntity {
  late int prepareId;          // JSON 里就是 prepareId,零配置可用
  late LivingInfoAnchor anchor;

  factory LivingInfoEntity.fromJson(Map<String, dynamic> json) => $LivingInfoEntityFromJson(json);
  Map<String, dynamic> toJson() => $LivingInfoEntityToJson(this);
}

字段名一旦对不上、类型对不上或需要容错,就必须请出 @JsonKey。

需求很朴素:把「字段名映射、取值位置、类型转换、缺省兜底」这些脏活,从手写代码改成声明式注解。

二、使用示例

2.1 name:字段名映射(最常用,推荐)

@JsonSerializable()
class User {
  @JsonKey(name: 'user_name')
  final String? userName;

  User({this.userName});

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

JSON {"user_name": "Alex"} → Dart user.userName,生成代码等价于:

userName: json['user_name'] as String?,

2.2 fromJson / toJson:类型转换

DateTime parseDate(Object? value) => DateTime.parse(value.toString());
String dateToJson(DateTime value) => value.toUtc().toIso8601String();

@JsonKey(fromJson: parseDate, toJson: dateToJson)
final DateTime createdAt;

2.3 defaultValue + includeIfNull:缺省与空值

@JsonKey(defaultValue: 0)
final int age;              // JSON 里没有 age → 0

@JsonKey(includeIfNull: false)
final String? avatar;       // avatar 为 null → toJson 时不带这个 key

2.4 综合示例:一个模型覆盖所有常见场景

enum UserStatus { active, disabled, unknown }

Object? readUserId(Map map, String key) => map['user_id'] ?? map['userId'] ?? map['uid'];

@JsonSerializable()
class User {
  /// 兼容后端多个字段名
  @JsonKey(readValue: readUserId)
  final String id;

  /// JSON 字段名不同
  @JsonKey(name: 'user_name')
  final String? userName;

  /// JSON → Dart 自定义转换
  @JsonKey(fromJson: parseDate)
  final DateTime createdAt;

  /// Dart → JSON 自定义转换
  @JsonKey(toJson: dateToJson)
  final DateTime updatedAt;

  /// null 时不发送
  @JsonKey(includeIfNull: false)
  final String? avatar;

  /// 后端新增枚举值不崩
  @JsonKey(unknownEnumValue: UserStatus.unknown)
  final UserStatus status;

  /// 只读取不回传
  @JsonKey(includeToJson: false)
  final String? serverToken;

  /// 纯客户端字段,完全不参与序列化
  @JsonKey(ignore: true)
  final bool isSelected;

  User({
    required this.id,
    this.userName,
    required this.createdAt,
    required this.updatedAt,
    this.avatar,
    required this.status,
    this.serverToken,
    this.isSelected = false,
  });

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

三、源码讲解

按职责分类,后面就不会混:

@JsonKey
├── 字段映射      name、readValue
├── JSON → Dart   fromJson、includeFromJson、required、defaultValue、disallowNullValue、unknownEnumValue
├── Dart → JSON   toJson、includeToJson、includeIfNull
└── 字段控制      ignore

3.1 name 改变字段名,readValue 改变取值位置

@JsonKey(name: 'user_name')
final String? userName;
// 生成:userName: json['user_name'] as String?

readValue 可同时兼容多个后端字段名,用来兜住历史包袱:

static Object? readUserId(Map map, String key) {
  return map['user_id'] ?? map['userId'] ?? map['uid'];
}

@JsonKey(name: 'user_id', readValue: readUserId)
final String id;

易忽略的细节: readValue 第二个参数收到的是 name 的值('user_id'),不是 Dart 字段名。

3.2 readValue 和 fromJson 的分工

readValue 决定从哪拿,fromJson 决定拿到后怎么转。

@JsonKey(readValue: readUserId, fromJson: parseInt)
final int userId;
{"uid": "123"} → readUserId() → "123" → parseInt() → 123

3.3 required 和 disallowNullValue 不是一回事

两者的边界完全不同:

配置Key 不存在Key 存在但值为 null
required: true抛异常允许
disallowNullValue: true允许抛异常
两者都为 true抛异常抛异常
@JsonKey(required: true)
final String id;              // 协议里 id 必须存在

@JsonKey(disallowNullValue: true)
final String? name;           // name 可以不出现,但出现了就不能是 null

3.4 defaultValue 只兜底「key 缺失」,不管非法值

@JsonKey(defaultValue: 0)
final int age;

它只解决 {} 这种情况。指望它把 "abc" 变成 0 是想多了,类型不对照样抛异常,那得靠 fromJson。

3.5 unknownEnumValue:服务端加枚举值不再崩客户端

长期维护的 App API 里最有价值的属性:

enum UserStatus { active, disabled, unknown }

@JsonKey(unknownEnumValue: UserStatus.unknown)
final UserStatus status;

后端返回 "deleted" 而客户端未发版时,默认行为是解析失败、整个接口炸;加了兜底只会退化成 UserStatus.unknown。

关键坑: 用了 unknownEnumValue 就别再自己写 fromJson 处理枚举,容易把兜底覆盖掉。

3.6 ignore / includeFromJson / includeToJson:三个方向的开关

配置JSON → DartDart → JSON
默认✅✅
ignore: true❌❌
includeFromJson: false❌✅
includeToJson: false✅❌
@JsonKey(ignore: true)
final String? localToken;        // 双向都忽略:本地缓存、UI 状态

@JsonKey(includeToJson: false)
final String? serverGenerated;   // 只读不回传:服务端生成的字段

易忽略的细节: ignore 是双向的,includeFromJson / includeToJson 是单向的。想做「只读」用 includeToJson: false,写成 ignore 就连读都读不到了。

3.7 includeIfNull:只影响 toJson

@JsonKey(includeIfNull: false)
final String? nickname;

值为 null 时生成 {} 而非 {"nickname": null}。只作用于序列化输出,不影响反序列化,最适合用在请求参数上。

四、总结

@JsonKey 做的事很朴素:把「一个 Dart 字段如何与 JSON 字段对应」的全部规则,声明式地写在字段上。

核心价值:

  1. name / readValue 解决字段对不上:命名风格差异、后端多套字段名,一个注解搞定。
  2. fromJson / toJson 解决类型对不上:时间、数字字符串、嵌套结构,转换逻辑就在字段旁边。
  3. required / disallowNullValue / defaultValue 划清空值边界:key 缺失和值为 null 是两件事,别混着配。
  4. unknownEnumValue 是长期维护的保险丝:后端新增枚举值不该让老版本客户端崩溃。
  5. ignore / includeFromJson / includeToJson 控制数据流向:区分「双向忽略」和「单向只读」,能避开一半的序列化怪问题。

判断口诀:「我是在改字段名、改取值位置、改类型转换,还是改字段是否参与序列化?」

注解不是银弹,但它把模型层从「跟着后端字段跑」变成了「按约定描述映射」—— 这才是模型层该有的样子。