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格式非法时抛 FormatExceptionDateTime.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 简介

freezedjson_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,再解析 datacode != 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 → 最后逐项容错"的顺序。

笔记加载中…