Kodokon kodokon.com

为数据建模:类、JSON 与不可变性

借助 fromJson、toJson、copyWith 以及可靠的值相等,把 JSON 转换成不可变的 Dart 类。

9 分钟 · 3 题

在 Kodokon 中打开本课

到了这个阶段,真正的主题不再是类的语法,而是外部世界与你的代码之间的边界。只要一份 API 响应还以 Map<String, dynamic> 的形式四处传递,每一次读取字段都是一场运行时的赌博。专业的做法是:在收到 JSON 的那一刻就把它转换成类型化对象,之后只传递这些对象。再把这一点和不可变性结合起来 - final 字段、const 构造函数 - 你就能消除一整类 bug:再也没有人能在你背后修改一个共享对象,而两个相同的 const 实例还会被编译器规范化(合并为同一个)。

DART
class User {
  const User({
    required this.id,
    required this.name,
    this.email,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: (json['id'] as num).toInt(),
      name: json['name'] as String,
      email: json['email'] as String?,
    );
  }

  final int id;
  final String name;
  final String? email;
}
一个带有防御式 JSON 解析的不可变模型

有三个选择值得说清楚。首先是 (json['id'] as num).toInt():很多 API 会根据后端的不同,有时把一个整数序列化成 3,有时又序列化成 3.0;直接转换成 int 会在遇到 double 时崩溃。其次,如果字段缺失,as String 会立即抛出一个清晰的 TypeError:这是有意为之的。在解析时快速失败,胜过让一个 null 悄悄传播、直到三个屏幕之后才引发崩溃。最后,as String? 通过类型本身表明该字段确实是可选的。

DART
class User {
  const User({required this.id, this.email});

  final int id;
  final String? email;

  Map<String, dynamic> toJson() => {
        'id': id,
        'email': email,
      };

  User copyWith({int? id, String? email}) {
    return User(
      id: id ?? this.id,
      email: email ?? this.email,
    );
  }
}
在一个精简到只有两个字段的模型上实现 toJson 和 copyWith

不可变性要求按值相等。默认情况下,Dart 比较的是引用:两个逐字段完全相同的 User 会被认为是"不同的"。一旦你开始比较状态,这就成了一个实实在在的问题 - 无论是在测试中、在 Provider 的 select 里,还是在一次列表查找中。请始终把 ==hashCode 一起重新定义。

DART
class Point {
  const Point(this.x, this.y);

  final int x;
  final int y;

  @override
  bool operator ==(Object other) =>
      other is Point && other.x == x && other.y == y;

  @override
  int get hashCode => Object.hash(x, y);
}
按值相等:== 和 hashCode 总是成对出现

知识检测

确认你已牢记本课的重点内容。

  1. 为什么要写 (json['id'] as num).toInt(),而不是 json['id'] as int
    • 因为 as int 在运行时比 as num 慢。
    • 因为后端可能把一个整数序列化成 3.0:直接转换成 int 会在遇到 double 时失败。
    • 因为 json['id'] 总是一个字符串。
  2. 在经典的 copyWith 实现下,user.copyWith(email: null) 会产生什么结果?
    • email 字段如所要求的那样被重置为 null
    • 在运行时抛出一个异常。
    • 对象保留它当前的 emailnull ?? this.email 退回到原有的值。
    • 在严格模式下代码无法编译。
  3. 为什么要在一个不可变模型上重新定义 ==hashCode
    • 因为 Dart 默认比较引用:否则,两个逐字段完全相同的对象会被视为"不同的"。
    • 为了加快 const 实例的创建速度。
    • 只要有一个字段被声明为 final,这就是强制要求的。