面试备战 Flutter 24:Freezed、copyWith、Union 与 build_runner

面试备战 Flutter 24:Freezed、copyWith、Union 与 build_runner

Freezed 面试不是问“会不会写注解”,而是在问你是否理解不可变数据建模、值相等、状态合法性和代码生成边界。

一句话结论:

手写 Model 最大的问题不是麻烦,而是容易不一致。Freezed 用代码生成保证不可变字段、值相等、copyWith、toString、JSON 和 Union 状态的一致性;build_runner 则负责扫描源码、执行 builder、生成 .freezed.dart.g.dart

1. 为什么不用手写 Model?

一个靠谱的 Model 不只是字段和构造函数。

通常要写:

  • 构造函数。
  • final 字段。
  • toString
  • operator ==
  • hashCode
  • copyWith
  • fromJson
  • toJson
  • 默认值。
  • 可空字段处理。
  • 嵌套对象 copy。
  • sealed/union 状态。

字段一多,手写就容易漏。

比如新增一个字段 avatar,你可能忘了:

  • == 没加。
  • hashCode 没加。
  • copyWith 没加。
  • fromJson 没加。
  • toJson 没加。

这类问题编译器不一定能帮你发现,但线上会变成缓存错误、UI 不刷新、序列化丢字段。

2. Freezed 生成什么?

典型写法:

import 'package:freezed_annotation/freezed_annotation.dart';

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


abstract class User with _$User {
  const factory User({
    required String id,
    required String name,
    (false) bool isVip,
  }) = _User;

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

Freezed 会生成:

  • 不可变字段实现。
  • copyWith
  • toString
  • ==
  • hashCode
  • JSON 入口配合 json_serializable。
  • 集合不可变包装。
  • Union 类型支持。

3. 为什么 immutable Model 重要?

不可变 Model 的好处:

  • 状态变化更可追踪。
  • 值相等更可靠。
  • 可以安全缓存。
  • Riverpod family 参数更稳定。
  • Widget diff 和状态比较更简单。
  • 避免“对象引用没变但内部字段变了”的刷新问题。

错误倾向:

user.name = 'new name';
notifyListeners();

更推荐:

state = state.copyWith(name: 'new name');

这样状态变化是一次明确的新对象替换。

4. copyWith 怎么实现?

最简单的手写版本:

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

  final String id;
  final String name;

  User copyWith({
    String? id,
    String? name,
  }) {
    return User(
      id: id ?? this.id,
      name: name ?? this.name,
    );
  }
}

这个版本对非空字段可用,但遇到可空字段会出问题。

5. copyWith 如何处理 null?

假设:

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

  final String id;
  final String? nickname;
}

你需要表达两件事:

没传 nickname -> 保留旧值
传 nickname: null -> 清空旧值

普通参数:

User copyWith({String? nickname})

无法区分“没传”和“传 null”。

Freezed 会用内部哨兵值处理:

参数未传 -> sentinel -> 使用旧值
参数传 null -> 值就是 null -> 覆盖旧值

这就是为什么 Freezed 的 copyWith(age: null) 可以正确把可空字段设为 null。

6. Union 是什么?

Union 表示一个类型有多个互斥形态。

页面异步状态最典型:

loading | data | error

不用 Union 时,很多人会写:

class PageState {
  const PageState({
    required this.loading,
    this.data,
    this.error,
  });

  final bool loading;
  final User? data;
  final Object? error;
}

这个模型会允许非法状态:

loading = true
data != null
error != null

Union 让非法状态不可表示:


sealed class UserState with _$UserState {
  const factory UserState.loading() = UserLoading;
  const factory UserState.data(User user) = UserData;
  const factory UserState.error(Object error) = UserError;
}

消费:

switch (state) {
  case UserLoading():
    return const CircularProgressIndicator();
  case UserData(:final user):
    return Text(user.name);
  case UserError(:final error):
    return Text('$error');
}

7. Union 和 Dart sealed class 的关系

Dart 3 提供了 sealed class 和 pattern matching。Freezed 可以和这些语言能力结合:

  • sealed 限制子类集合。
  • switch 可以做穷尽检查。
  • Freezed 生成样板代码。
  • 多构造函数表达不同 case。

所以现在更推荐使用 Dart 原生 pattern matching 消费 Union,而不是老版本的 when/map 风格。

8. JSON 怎么配合?

Freezed 本身负责数据类样板;JSON 通常由 json_serializable 生成。

需要:

part 'user.g.dart';

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

构建后会生成:

user.freezed.dart
user.g.dart

常见注意点:

  • part 文件名必须和源文件匹配。
  • fromJson 参数类型建议用 Map<String, Object?>
  • 默认值用 @Default
  • 字段重命名用 @JsonKey(name: 'xxx')
  • 嵌套对象也要支持 fromJson。

9. build_runner 做了什么?

build_runner 是 Dart 生态的代码生成执行器。

它的流程:

读取 package graph
-> 扫描输入文件
-> 找到 builder 配置
-> 分析注解和源码
-> 生成中间资产和输出文件
-> 维护增量缓存

常用命令:

dart run build_runner build --delete-conflicting-outputs

监听:

dart run build_runner watch --delete-conflicting-outputs

Flutter 项目也常用:

flutter pub run build_runner build --delete-conflicting-outputs

10. 为什么生成文件不要手改?

.freezed.dart.g.dart 是构建产物。

手改的问题:

  • 下次生成会覆盖。
  • 逻辑和源文件不一致。
  • code review 噪音大。
  • 难以定位真实源头。

应该改源文件和注解,再重新生成。

11. 常见错误

忘记 part

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

文件名不一致

user_model.dart 对应:

part 'user_model.freezed.dart';
part 'user_model.g.dart';

依赖版本不匹配

需要同时关注:

  • freezed
  • freezed_annotation
  • json_serializable
  • json_annotation
  • build_runner

有冲突输出

使用:

dart run build_runner build --delete-conflicting-outputs

可空 copyWith 手写错误

可空字段复杂时,不要自己用 ?? 硬写。

12. 什么时候不用 Freezed?

Freezed 不是所有地方都必须用。

可以不用的场景:

  • 很简单的一次性 DTO。
  • 不需要值相等。
  • 不需要 copyWith。
  • 不需要 JSON。
  • 使用 Dart record 足够。
  • 团队不想引入代码生成。

但对于核心业务 Model、复杂状态、缓存 key、接口 DTO,Freezed 的一致性收益通常大于生成成本。

13. 面试怎么回答?

可以这样答:

我不用手写 Model 的主要原因是可靠性。一个 Model 不只是字段,还要维护 ==hashCodecopyWith、JSON、默认值和可空字段。字段变多后手写很容易漏。Freezed 用代码生成保证不可变数据类和值相等的一致性,还能生成 copyWith 和 Union 状态。

copyWith 的难点是可空字段。手写 param ?? oldValue 无法区分“没传”和“传 null”,Freezed 用内部哨兵值解决这个问题。

Union 的价值是让非法状态不可表示,比如 loading/data/error 三种状态互斥。配合 Dart sealed class 和 switch pattern,可以让编译器检查所有状态是否处理完整。

build_runner 是代码生成执行器,负责扫描注解、执行 builder、生成 .freezed.dart.g.dart。生成文件不手改,改源文件和注解后重新生成。

参考资料