在现代 Flutter 开发中,构建一个健壮、可维护且类型安全的数据层至关重要。freezed
与 json_serializable
的组合,是实现这一目标的黄金标准。本文将摒弃过时的 BaseModel
思维,全面拥抱基于不可变性 (Immutability) 和编译时安全的现代最佳实践。
一、核心原则:为什么这套技术栈是最佳选择?
- 不可变性 (Immutability) :这是最重要的基石。
freezed
生成的类一旦创建即不可修改,任何变更都通过copyWith
创建新实例。这从根本上杜绝了因状态被意外修改而导致的 bug,与 Flutter 的声明式 UI 范式完美契合。 - 代码即文档,编译即保障 :通过
freezed
的联合类型(Sealed Classes),你可以用代码清晰地定义所有可能的状态。编译器会强制你在使用时处理所有情况,将潜在的运行时错误转化为编译时检查。 - 告别样板代码 :你只需定义模型的核心属性,
build_runner
会自动为你生成equals
,hashCode
,toString
,copyWith
以及序列化所需的fromJson
/toJson
方法,让你专注于业务逻辑。
二、模型定义的最佳实践:从基础到高级
一个专业级的模型定义应该清晰、健壮且能应对现实世界中不完美的 API 数据。
1. 标准模型定义
让我们从一个 ProductModel
的例子开始,它涵盖了大多数常见场景。
dart
// lib/data/models/product_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'converters/date_time_converter.dart'; // 自定义类型转换器
part 'product_model.freezed.dart';
part 'product_model.g.dart';
@freezed
class ProductModel with _$ProductModel {
const factory ProductModel({
required int id,
// 实践 1: 使用 @JsonKey 处理命名差异
@JsonKey(name: 'product_name')
required String name,
// 实践 2: 优雅处理 null 和缺失值
@JsonKey(defaultValue: 'No description available')
required String description,
// 实践 3: 为非必需字段提供默认值
@Default(0.0)
double price,
String? imageUrl, // 真正允许为空的字段,使用可空类型 `?`
// 实践 4: 使用 JsonConverter 处理特殊类型
@DateTimeConverter()
required DateTime createdAt,
// 实践 5: 安全地处理枚举类型
@JsonKey(unknownEnumValue: ProductStatus.unknown)
@Default(ProductStatus.available)
ProductStatus status,
}) = _ProductModel;
factory ProductModel.fromJson(Map<String, dynamic> json) => _$ProductModelFromJson(json);
}
// 相关的枚举定义
enum ProductStatus {
available,
outOfStock,
discontinued,
unknown, // <--- 关键的兜底值
}
关键实践点详解:
-
@JsonKey
: 你的瑞士军刀。name
: 映射 JSON 字段名(如product_name
)到 Dart 的驼峰命名(name
)。defaultValue
: 当 JSON 中缺少 某个字段或其值为null
时,提供一个默认值。这比构造函数中的@Default
更强大,因为它能处理null
。unknownEnumValue
: 在反序列化枚举时,如果 API 返回了一个客户端未知的枚举值(例如后端新增了状态),程序不会崩溃,而是会使用你指定的兜底值 (Product-Status.unknown
)。这是增强应用向前兼容性的关键。
-
处理空值 (
null
) 与默认值:- 原则: 永远不要信任 API 的数据总是完美的。
- 可空类型
?
: 用于业务逻辑上确实允许不存在的字段(如imageUrl
)。 @Default
: 用于为非required
字段提供一个编译时的默认值。- 组合使用 :
required
+@JsonKey(defaultValue: ...)
是处理"理论上必须有,但要防止后端出错"的字段的最佳方式。
-
处理特殊类型(如日期、自定义对象):
- 问题 : JSON 本身只支持字符串、数字、布尔值、数组和对象。
DateTime
等复杂类型需要转换。 - 解决方案 : 实现一个
JsonConverter
。这是处理任何非原生 JSON 类型的标准、可复用的方法。
dart// lib/data/models/converters/date_time_converter.dart import 'package:json_annotation/json_annotation.dart'; class DateTimeConverter implements JsonConverter<DateTime, String> { const DateTimeConverter(); @override DateTime fromJson(String json) => DateTime.parse(json).toLocal(); @override String toJson(DateTime object) => object.toUtc().toIso8601String(); }
- 问题 : JSON 本身只支持字符串、数字、布尔值、数组和对象。
三、网络响应处理:拥抱联合类型,告别旧式 BaseModel
这是从传统开发思维转向现代 Flutter 开发思维最重要的一步。
反模式 (应被彻底摒弃): 不要再定义一个包含 code
, message
, data
的 BaseModel
,然后通过 if (model.code == 200)
来判断业务成功与否。这种运行时检查的方式,容易出错、不直观,且容易遗漏错误处理。
现代最佳实践 (使用 freezed
的联合类型/Sealed Class): 我们定义一个 ApiResponse<T>
,它本身就包含了所有可能的结果状态。
dart
// lib/data/models/api_response.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'api_response.freezed.dart';
part 'api_response.g.dart';
@freezed
// 使用 sealed 关键字(Dart 3+)增强密封性
sealed class ApiResponse<T> with _$ApiResponse<T> {
const ApiResponse._();
/// 成功,并携带数据 T
@JsonSerializable(genericArgumentFactories: true) // 仅在需要序列化泛型数据时添加
const factory ApiResponse.success(T data) = Success<T>;
/// 业务失败 (例如:API 返回 4xx 或 5xx,但有明确的错误结构体)
const factory ApiResponse.failure({
required int statusCode,
required String message,
}) = Failure<T>;
/// 网络层或未知错误 (例如:无网络连接、DNS 解析失败、Dio 抛出的其他异常)
const factory ApiResponse.error(Object error, StackTrace stackTrace) = Error<T>;
// 你可以添加方便的辅助方法
bool get isSuccess => this is Success<T>;
T? get data => whenOrNull(success: (data) => data);
}
// 专门用于无数据返回的接口,语义更清晰
// 如果你不想创建额外类,也可以在 Repository 层直接返回 ApiResponse<void> 或 ApiResponse<dynamic>
// 但独立类是更严谨的做法
@freezed
class EmptyResponse with _$EmptyResponse {
const factory EmptyResponse({
required int statusCode,
required String message,
}) = _EmptyResponse;
factory EmptyResponse.fromJson(Map<String, dynamic> json) => _$EmptyResponseFromJson(json);
}
注意 ApiResponse.success
的定义: @JsonSerializable(genericArgumentFactories: true)
注解需要放在具体会携带泛型数据的工厂构造函数 上,freezed
会智能地处理。但为了简化,很多时候开发者会将它放在主类 @freezed
注解的下方,两者效果类似,后者更通用。
在 Repository 和 Retrofit 中应用
你的 Repository 层职责是调用 API 并将各种结果(成功、可预见的失败、意外错误)包装成 ApiResponse
。
dart
// lib/data/repositories/product_repository.dart
class ProductRepository {
final ApiService _api;
Future<ApiResponse<List<ProductModel>>> getProducts() async {
try {
final products = await _api.fetchProducts(); // 假设 Retrofit 返回 List<ProductModel>
return ApiResponse.success(products);
} on DioException catch (e) {
if (e.response != null) {
// API 返回了错误响应
return ApiResponse.failure(
statusCode: e.response!.statusCode!,
message: e.response!.data['message'] ?? 'API Error',
);
} else {
// 网络问题或其他 Dio 错误
return ApiResponse.error(e, e.stackTrace!);
}
} catch (e, s) {
// 未知错误
return ApiResponse.error(e, s);
}
}
Future<ApiResponse<EmptyResponse>> deleteProduct(int id) async {
// ... 类似逻辑
}
}
在 UI/ViewModel 层消费
freezed
的 when
方法让 UI 层的逻辑变得极其清晰和安全,因为编译器会强制你处理所有可能的状态。
dart
// 在 Widget 的 build 方法中
productApiResponse.when(
success: (products) => ListView.builder(...),
failure: (statusCode, message) => ErrorMessageWidget(message: message),
error: (error, stack) => GenericErrorWidget(onRetry: () => provider.refetch()),
);
四、常见陷阱与高级技巧
-
build_runner
性能与工作流- 命令 : 始终在终端开启
flutter pub run build_runner watch --delete-conflicting-outputs
。它会监控文件变化并进行快速的增量构建。 - 错误排查 : 如果遇到奇怪的编译错误,第一反应是查看
.g.dart
和.freezed.dart
文件内容是否符合预期。如果不是,flutter clean
后再重新watch
。
- 命令 : 始终在终端开启
-
泛型处理的深层剖析
- 如果你需要一个可序列化的泛型包装类(例如,后端返回
{ "data": T, ... }
结构),你的fromJson
必须包含一个函数参数来处理T
的反序列化。这是最容易出错的地方。
dart// 适用于 { "data": T, ... } 结构的泛型包装类 @freezed @JsonSerializable(genericArgumentFactories: true) class DataWrapper<T> with _$DataWrapper<T> { const factory DataWrapper({required T data}) = _DataWrapper; factory DataWrapper.fromJson( Map<String, dynamic> json, T Function(Object? json) fromJsonT, // <--- 这个函数是关键 ) => _$DataWrapperFromJson(json, fromJsonT); }
当你在 Retrofit 中使用
Future<DataWrapper<UserModel>>
时,retrofit_generator
会自动将UserModel.fromJson
作为fromJsonT
传入。 - 如果你需要一个可序列化的泛型包装类(例如,后端返回
-
copyWith
是浅拷贝- 当模型嵌套时(
User
包含Address
),user.copyWith(...)
只会替换User
的顶层属性。如果你想更新地址的街道,必须深度拷贝:
dartfinal updatedUser = user.copyWith( address: user.address.copyWith(street: '123 New Ave'), );
- 当模型嵌套时(
结论
从传统的 BaseModel
迁移到以 freezed
联合类型为核心的现代数据层架构,是从命令式错误处理 到声明式状态管理的巨大飞跃。它强迫开发者在编译时就思考并处理所有可能的结果,极大地提升了应用的健壮性和可维护性。