JSON 序列化与数据模型
网络返回的是 Map<String, dynamic>,业务要的是强类型对象。转换层写不好,就会在运行期冒出 type 'int' is not a subtype of type 'String' 这类崩溃,而且常常在真机弱网时才暴露。本章讲手写 fromJson/toJson 的容错姿势、json_serializable 的自动化流程,以及后端包装结构的统一处理。
手写 fromJson/toJson
class Article {
const Article({required this.id, required this.title, this.summary, this.tags = const []});
final int id;
final String title;
final String? summary;
final List<String> tags;
factory Article.fromJson(Map<String, dynamic> json) => Article(
id: (json['id'] as num?)?.toInt() ?? 0, // 兼容 "12" 与 12
title: json['title'] as String? ?? '未命名',
summary: json['summary'] as String?,
// 列表字段必须判空并逐项转换
tags: (json['tags'] as List?)?.map((e) => e.toString()).toList() ?? const [],
);
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'summary': summary, 'tags': tags};
}
| 常见错误 | 后果 | 正确写法 |
|---|---|---|
json['id'] as int | 返回 null 或字符串就崩 | (json['id'] as num?)?.toInt() ?? 0 |
json['tags'] as List<String> | 元素类型不符直接抛异常 | (json['tags'] as List?)?.map((e) => e.toString()).toList() ?? [] |
直接用 DateTime.parse | 格式非法时抛 FormatException | 用 DateTime.tryParse 并接受 null |
json_serializable + build_runner
字段一多手写就是负担,用注解生成代码能在编译期发现不一致。
flutter pub add json_annotation
flutter pub add --dev build_runner json_serializable
flutter pub get
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart'; // 生成文件,需与源文件同名同目录
@JsonSerializable(fieldRename: FieldRename.snake) // 全局驼峰 ↔ 蛇形
class User {
const User({required this.id, required this.name, this.avatarUrl});
final int id;
final String name;
@JsonKey(name: 'avatar_url', defaultValue: '') // 单独指定字段名与默认值
final String? avatarUrl;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
| 注解 | 作用 |
|---|---|
@JsonSerializable() | 标记类生成 fromJson/toJson |
fieldRename: FieldRename.snake | 字段名自动蛇形转换 |
@JsonKey(name: 'xxx') | 单个字段改名,可配 defaultValue |
explicitToJson: true | 嵌套对象也调用 toJson |
flutter pub run build_runner build --delete-conflicting-outputs # 一次性生成
flutter pub run build_runner watch --delete-conflicting-outputs # 开发期持续监听
freezed 简介
freezed 在 json_serializable 之上补两件事:不可变数据类(自带 copyWith、==、hashCode)与联合类型。
flutter pub add freezed_annotation
flutter pub add --dev build_runner freezed json_serializable
@freezed
class LoadState with _$LoadState {
// 联合类型:一次定义三种可能,调用处 switch 必须全部处理
const factory LoadState.loading() = _Loading;
const factory LoadState.success(List<String> data) = _Success;
const factory LoadState.failure(String message) = _Failure;
}
只有两三个简单模型时不必上 freezed,多一个代码生成器只会拖慢构建。
列表解析与分页模型
class PageResult<T> {
const PageResult({required this.list, required this.page, required this.hasMore});
final List<T> list;
final int page;
final bool hasMore;
factory PageResult.fromJson(Map<String, dynamic> json, T Function(Map<String, dynamic>) parse) {
final raw = (json['list'] as List<dynamic>?) ?? const [];
final page = (json['page'] as num?)?.toInt() ?? 1;
final total = (json['total'] as num?)?.toInt() ?? 0;
return PageResult(
// 过滤结构不符的元素,避免一条脏数据崩掉整页
list: raw.whereType<Map<String, dynamic>>().map(parse).toList(),
page: page,
hasMore: page * 20 < total,
);
}
}
// 解析入口:先确认顶层确实是 Map,再往下走
PageResult<Article> parseArticles(Object? raw) {
if (raw is! Map<String, dynamic>) throw const FormatException('返回结构不是对象');
return PageResult.fromJson(raw, Article.fromJson);
}
API 返回包装与统一解包
| 后端结构 | 解析方式 | 注意点 |
|---|---|---|
{ code, message, data } | 先判 code,再解析 data | code != 0 走业务异常分支 |
| 直接返回对象 | 直接 fromJson | 依赖 HTTP 状态码判成败 |
{ data: { list, total } } | 在 data 里再取列表 | 分页字段名要与后端对齐 |
T unwrap<T>(Map<String, dynamic> body, T Function(dynamic data) parse) {
final code = (body['code'] as num?)?.toInt() ?? 0;
if (code != 0) throw Exception(body['message'] as String? ?? '业务异常');
return parse(body['data']); // data 为 null 时由 parse 内部兜底
}
小结:手写 fromJson 必须给每个字段兜底(as num?、?? 默认值、tryParse);字段多了就上 json_serializable + build_runner,改完模型重新生成;需要不可变与联合类型再加 freezed;解析统一走"先判顶层类型 → 再判 code → 最后逐项容错"的顺序。