本节摘要:网络返回的 JSON 在 Dart 里只是一坨 Map,直接在界面里取键取值是类型事故的温床。本节建立轻记账的模型层:手写健壮的 fromMap 与 toMap,处理嵌套对象、枚举与数字类型坑,并给出代码生成方案的取舍账。模型层是网络层与状态层之间的翻译官,翻译官靠谱,上下游才能各自安睡。
直接用 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 章的单元测试会把它们收编进自动流水线。
报文能进能出了,但移动网络的真相是"随时会断"。下一节设计离线优先的同步:断网照常记账,联网自动补传。