5.2 JSON 模型层:从报文到 Dart 对象


5.2 JSON 模型层:从报文到 Dart 对象

本节摘要:网络返回的 JSON 在 Dart 里只是一坨 Map,直接在界面里取键取值是类型事故的温床。本节建立轻记账的模型层:手写健壮的 fromMap 与 toMap,处理嵌套对象、枚举与数字类型坑,并给出代码生成方案的取舍账。模型层是网络层与状态层之间的翻译官,翻译官靠谱,上下游才能各自安睡。

从一坨 Map 到一类对象

直接用 Map 的三宗罪:拼错键名编译器不管(运行时才炸);取出来的值是 dynamic(类型检查形同虚设);没有语义(阅读代码的人不知道 map 里装的是什么)。模型层的做法是在系统边界翻译一次:进系统的报文翻成对象,出系统的对象翻成报文,系统内部只流传类型安全的模型——第 1 章的 Expense 类与第 4 章的 Bill 类已经反复示范了这个形态。

服务端下发的账单报文长这样:

{ "id": "b_20260907_001", "title": "地铁月票", "amount": 180, "category": "transport", "createdAt": "2026-09-07T08:12:00Z", "tag": {"name": "通勤", "priority": 2}, "remark": null }

对应的 Dart 模型要处理四类现实问题:嵌套对象(tag)、可空字段(remark)、数字类型(JSON 解析器把整数量给成 int,而你声明的是 double)、枚举映射(category 是字符串枚举)。完整实现:

enum BillCategory { food('餐饮'), transport('交通'), shopping('购物'); const BillCategory(this.label); final String label; static BillCategory parse(String raw) => BillCategory.values .firstWhere((v) => v.name == raw, orElse: () => BillCategory.food); } class RemoteBill { final String id; final String title; final double amount; // 统一 double:int 与 num 都能安全转入 final BillCategory category; final DateTime createdAt; final BillTag? tag; final String? remark; const RemoteBill({ required this.id, required this.title, required this.amount, required this.category, required this.createdAt, this.tag, this.remark, }); factory RemoteBill.fromMap(Map<String, Object?> map) { return RemoteBill( id: map['id']! as String, title: map['title']! as String, amount: (map['amount']! as num).toDouble(), category: BillCategory.parse(map['category']! as String), createdAt: DateTime.parse(map['createdAt']! as String), tag: map['tag'] == null ? null : BillTag.fromMap(map['tag']! as Map<String, Object?>), remark: map['remark'] as String?, ); } Map<String, Object?> toMap() => { 'id': id, 'title': title, 'amount': amount, 'category': category.name, 'createdAt': createdAt.toUtc().toIso8601String(), 'tag': tag?.toMap(), 'remark': remark, }; } class BillTag { final String name; final int priority; const BillTag({required this.name, required this.priority}); factory BillTag.fromMap(Map<String, Object?> map) => BillTag( name: map['name']! as String, priority: map['priority'] as int? ?? 0, ); Map<String, Object?> toMap() => {'name': name, 'priority': priority}; }

四个坑位逐一说明。数字类型是最高频事故:JSON 里的 180 解析后是 int,直接 as double 会抛类型异常,(x as num).toDouble() 是唯一稳的写法——num 是 int 与 double 的共同父类,什么数都接得住。可空字段as String? 而非 !,服务端少给一个字段不该成为崩溃理由;必填字段才用 !(它崩溃得早、信息明确,好过带着 null 流进系统深处)。时间统一转 UTC 存储,显示时再转本地时区,跨设备同步才不会差出时区。枚举解析带 orElse 兜底,服务端新增的枚举值不应让旧版本客户端崩溃。

列表报文与解析的分批

真实接口常包一层分页壳:数据在 data 字段里,还带总数与游标。壳与内容分层解析:

class BillPage { final List<RemoteBill> items; final String? nextCursor; // null 即没有下一页 const BillPage({required this.items, this.nextCursor}); factory BillPage.fromMap(Map<String, Object?> map) => BillPage( items: ((map['data'] ?? const []) as List) .map((e) => RemoteBill.fromMap(e as Map<String, Object?>)) .toList(), nextCursor: map['next'] as String?, ); }

大报文的解析性能在第 2 章立过规矩,这里落到固定代码位:整页报文超过几十 KB,解析就搬进 Isolate,把 parseBills 整个函数交给 compute——模型层函数写成"顶层纯函数、只依赖入参",此刻正好原样搬运,这是第 2 章埋的"可搬运"纪律的回报。

代码生成:什么时候值得引入

手写 fromMap 的成本随字段数线性增长,几十个模型的工程会开始动摇。Dart 生态的标准答案是代码生成:在模型上标注,用构建器生成序列化代码。取舍账要算清楚:

维度 手写 代码生成
起步成本 零依赖,当场能写 引入构建器与生成命令
维护成本 加字段手改两处 加一行标注,重新生成
出错方式 编译期大多能查出 生成的代码可信,标注写错要查生成产物
构建时间影响 每次改动多一步生成

选型判断:模型少于十个、字段稳定,手写;模型成规模或字段频繁变动,上代码生成。轻记账目前四个模型,手写保持零依赖;生成方案的学习成本不低,等字段变动的维护痛真实出现再上,别为"先进"预付学费。无论哪条路,本节的类型纪律不变:边界翻译一次、必填用 !、可空用 ?、数字过 num、枚举带兜底。

模型层自测:三分钟验收

模型写完不接界面就能测,这是它作为纯类的红利。三个用例覆盖最高发事故:

void main() { // 用例一:正常报文往返无损 final m = { 'id': 'b1', 'title': '地铁月票', 'amount': 180, 'category': 'transport', 'createdAt': '2026-09-07T08:12:00Z', 'tag': {'name': '通勤', 'priority': 2}, 'remark': null, }; final bill = RemoteBill.fromMap(m); assert(bill.amount == 180.0); // int 转 double 成功 assert(bill.category == BillCategory.transport); assert(bill.tag?.priority == 2); // 用例二:未知枚举不崩溃 final odd = {...m, 'category': 'yacht_club'}; assert(RemoteBill.fromMap(odd).category == BillCategory.food); // 兜底生效 // 用例三:可空字段缺失不崩溃 final bare = {...m}..remove('tag')..remove('remark'); final loose = RemoteBill.fromMap(bare); assert(loose.tag == null && loose.remark == null); print('模型层自测通过'); }

三个 assert 全绿,说明模型层能扛住服务端的常见任性。把这三类用例固化下来,每个新模型出生时就跑一遍——第 6 章的单元测试会把它们收编进自动流水线。

本节要点回顾

  • 系统边界翻译一次,系统内部只流传类型安全的模型;
  • 数字必须过 num 再 toDouble,可空字段用 ? 取,必填字段用 ! 崩得早崩得明白;
  • 时间存 UTC、枚举解析带兜底、嵌套对象单独建类;
  • 大报文解析搬进 Isolate,模型层写成顶层纯函数正好直接搬;
  • 代码生成在模型成规模后再上,取舍看维护成本而非时髦程度。

报文能进能出了,但移动网络的真相是"随时会断"。下一节设计离线优先的同步:断网照常记账,联网自动补传。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U