Day 1 装环境,Day 2 写页面,到了 Day 3 我想做点真实的东西——把一个接口返回的数据渲染到页面上。本以为只是调接口、拿数据、塞进 ListView 而已,结果盯着那串三层嵌套、数组里套对象、对象里又套数组的 JSON,我当场愣住。如果你也在学鸿蒙跨端框架 Flutter,并且正准备从静态页面跨入数据驱动页面,这篇笔记也许能帮你少走几段弯路。
所谓 JSON 嵌套逻辑,简单说就是 JSON 里继续套 JSON:对象嵌套对象,数组嵌套对象,对象里再挂数组。复杂数据解析的任务,则是把这种多层次结构,稳定地翻译成 Dart/Flutter 里可以放心使用的集合对象——List、Map,以及基于它们构建的模型类集合。今天这篇 Day 3 学习记录,我把自己的完整踩坑路径复盘了一遍:从看懂 JSON 结构、设计模型类、手写 fromJson,到处理各种意外字段,再到集合去重、排序、分组,最后放到鸿蒙设备上验证整条链路。内容不深,但每一步都是实际敲过代码、跑过真机之后沉淀下来的。
适合谁看?我说得直白一点:组件基础已经会了,但一接真实接口就懵的初学者;后端返回结构一变化就手忙脚乱的同学;还有正在做鸿蒙跨端项目、需要把数据层逻辑理清楚的前端开发者。
1. 为什么 Day 3 的功课恰好是 JSON 与集合
1.1 学习曲线的真实拐点其实在数据层
大部分 Flutter 教程会把前两三天花在 widget 上:按钮、卡片、列表、状态管理。你跟着写,每个 Demo 都能跑,页面也漂亮。但一旦想接入真实接口,问题立刻暴露:接口返回的是 JSON 字符串,Dart 里没有“JSON 对象”这个说法,你需要先解码,再放进一个集合容器里,最后才能喂给 UI。
我第一天的代码里,列表数据全是硬编码的字符串数组,第二天开始用 Map 手工组装列表项,第三天接口真的来了,我才发现自己连“把一个 List 变成 List ”这种基础操作都没形成肌肉记忆。数据层不熟,Ui 写得再顺也没用,因为每个页面都要从数据开始。这也是我坚持把第三天完全留给 JSON 与集合的原因。
这里有一个很常见的误解:觉得解析 JSON 就是调一下 jsonDecode,返回一个 Map 就完事了。对于单层 JSON 确实如此,但真实业务接口很少是单层的。一个带分页的列表接口,通常是外层包一个 data,data 里放 list,list 里每一项又是一个包含作者、标签、统计信息的对象。你要做的不是取一次值,而是在一棵数据结构树上连续下钻好多次。这个过程的每一步都可能出错,而且出错方式千奇百怪。
1.2 集合才是 Flutter 页面的“内容骨架”
用个生活化的类比:JSON 就像一棵圣诞树。Map 是树的分岔点,决定了“往哪边走能拿到什么”;List 是挂在分岔点上的礼物串,一个接一个地排列着。页面要展示的,从来不是整棵树的原始样子,而是把礼物串拆下来、按需重新组合后的有序集合。
具体到 Flutter 里,ListView.builder 吃的就是一个 List,GridView 吃的也是 List,下拉联动选择器吃的往往是一个嵌套 List。如果解析层不能稳定地输出这些集合,界面层再怎么写都是无源之水。
更关键的是,集合应用不只是“把 JSON 变成对象”这么简单。我在 Day 3 后半段发现,真正的难点在于解析完之后,你还要对这批数据做去重、排序、分组。比如列表页可能重复拉取到同一条帖子,比如热门列表需要按回复数重新排序,比如会话列表要按日期分组展示。这些操作全部发生在集合上,而不是 JSON 上。所以今天这篇的内容重心,一半在解析,另一半在集合如何承接解析结果。
2. 动手解析前,先把集合类型和 JSON 的对应关系盘清楚
2.1 JSON 语法与 Dart 集合的一一对应
很多人一上来就写jsonDecode(raw)['data']['list'],看起来挺顺,其实对底层容器类型缺乏意识。Dart 里常用的集合有三种:List、Map、Set,它们和 JSON 的关系完全不同。
JSON 里的对象{}对应 Dart 的Map<String, dynamic>,键一定是字符串,值可以是任意类型。JSON 里的数组[]对应 Dart 的List<dynamic>,这里要特别留意,数组中的每一项是什么类型,解码器并不会提前告诉你,需要你自己逐项判断。JSON 本身没有 Set 的概念,所以 Set 通常出现在解析之后的业务处理阶段,比如去重场景。
我用一个表格把对应关系列清楚,后面写代码时心里就有底了:
| JSON 语法 | Dart 接收类型 | 说明 |
|---|---|---|
{}对象 | Map<String, dynamic> | 键固定为 String,值需要逐层判型 |
[]数组 | List<dynamic> | 每一项的类型未知,需要遍历确认 |
| 字符串 | String | 直接可用 |
| 数字 | int或double | 可能因精度和写法产生歧义 |
| 布尔值 | bool | 直接用 |
null | Null | 需要配合可空类型处理 |
Set 不在表里,是因为 JSON 序列化格式不支持它。如果你想把一个 Set 用 jsonEncode 发出去,它会先被转成 List;反过来,解析 JSON 时也不会天然得到 Set。这个区分很重要,因为很多初学者会在接口返回的数组上直接调.toSet(),以为能保留某种结构,其实完全没有必要——Set 是业务层工具,不是传输层格式。
2.2 别一上来就 as,dynamic 需要的是逐层确认
dart:convert 里的jsonDecode返回类型是 dynamic。为什么是 dynamic?因为解码器读取字符串之前完全不知道结构,只能在运行时把 JSON 解析成 Dart 对象。这就意味着,IDE 不会帮你检查类型,所有类型判断都要靠你自己写。
我见过最多的新手写法是:
final data = jsonDecode(raw) as Map<String, dynamic>;这一行确实能应付“顶层是对象”的情况,可如果接口某天把顶层改成数组,这行代码会直接抛类型转换异常。更稳的做法是先用is判断类型,再进入对应分支,逐层确认。比如:
var decoded = jsonDecode(raw); if (decoded is Map<String, dynamic>) { var data = decoded['data']; if (data is Map<String, dynamic>) { var list = data['list']; if (list is List) { // 此时才安全地进入列表遍历 } } }有人觉得这么写啰嗦,但真实项目中,接口返回结构随时可能微调,逐层判型看起来多写几行,实际上把崩溃概率降到了最低。有一个折中心法:把每一层判断封装成独立函数,比如parseTopLevel、parseData、parseListItem,既保证安全,又让主逻辑清爽。
还要提醒一个边界:jsonDecode本身就可能抛异常。如果后端返回的不是合法 JSON,比如网关错误页面返回了一段 HTML,你直接 decode 会得到 FormatException。所以正式的工程里,建议在解码入口包一层 try-catch,这也是复杂数据解析中必须养成的基本习惯。
3. 一份多层 JSON 的完整实战:从字符串到模型集合
3.1 先看 JSON 骨架,再设计模型类
写代码之前,我习惯先把 JSON 从小到大看一遍,就像做菜之前先看食材。下面是一份模拟的问答社区接口数据,结构非常有代表性,我在 Day 3 拿它练了一整天:
{ "code": 0, "message": "ok", "data": { "list": [ { "question_id": 1001, "title": "Flutter 状态管理怎么选?", "author": { "user_id": 88, "nickname": "flutter_student", "level": 3 }, "tags": ["Flutter", "状态管理"], "stats": { "view_count": 1200, "reply_count": 30 } } ] } }这份 JSON 的层级是这样的:最外层是统一响应体,包含 code、message、data;data 里是列表;列表里每个元素是一道题;题目包含作者对象、标签数组、统计对象。数据结构一眼看下来,模型类应该设计三个:AuthorModel、StatsModel、QuestionModel,再加一个解析入口来处理最外层的响应体。
设计模型类的时候,不要先把代码写出来,而是先列出字段对照表,字段类型要跟 JSON 层级严格对应。我自己常用的表格是这样的:
| JSON 字段 | Dart 字段 | 类型 |
|---|---|---|
| question_id | questionId | int |
| title | title | String |
| author | author | AuthorModel |
| tags | tags | List |
| stats | stats | StatsModel |
表格写清楚之后,fromJson 就是照着表一项一项填。很多初学者喜欢偷懒,直接把模型类字段定义成跟后端下划线命名一致,比如question_id,这样确实省了映射,但 Dart 代码风格通常使用驼峰命名。混着下划线写代码,后面维护起来非常痛苦,团队的 lint 规则也会报警。
3.2 fromJson 的手写路径与字段容错
手写 fromJson 看起来很原始,但它是理解复杂数据解析的一条必经之路。用代码生成工具确实快,可如果你连手工映射都没写过,遇到 nested 结构就很难定位问题。我先分享手写版的核心代码。
AuthorModel 和 StatsModel 相对简单:
class AuthorModel { final int userId; final String nickname; final int level; AuthorModel({ required this.userId, required this.nickname, required this.level, }); factory AuthorModel.fromJson(Map<String, dynamic> json) { return AuthorModel( userId: int.tryParse('${json['user_id'] ?? 0}') ?? 0, nickname: json['nickname']?.toString() ?? '', level: int.tryParse('${json['level'] ?? 0}') ?? 0, ); } } class StatsModel { final int viewCount; final int replyCount; StatsModel({ required this.viewCount, required this.replyCount, }); factory StatsModel.fromJson(Map<String, dynamic> json) { return StatsModel( viewCount: int.tryParse('${json['view_count'] ?? 0}') ?? 0, replyCount: int.tryParse('${json['reply_count'] ?? 0}') ?? 0, ); } }注意我在取值时用了int.tryParse('${json['xxx'] ?? 0}')。为什么不用json['view_count'] as int?因为后端可能返回数字 1200,也可能返回字符串 "1200"。强转 int 在字符串面前直接崩溃,而 tryParse 加?? 0可以兼容两种写法。这是真实接口调出来的经验:不是所有字段都规规矩矩,越是和统计、计数相关的字段,越容易因为格式调整而出问题。
QuestionModel 是重头戏,它要处理的是嵌套对象和嵌套数组:
class QuestionModel { final int questionId; final String title; final AuthorModel author; final List<String> tags; final StatsModel stats; QuestionModel({ required this.questionId, required this.title, required this.author, required this.tags, required this.stats, }); factory QuestionModel.fromJson(Map<String, dynamic> json) { return QuestionModel( questionId: int.tryParse('${json['question_id'] ?? 0}') ?? 0, title: json['title']?.toString() ?? '', author: AuthorModel.fromJson( json['author'] is Map<String, dynamic> ? json['author'] as Map<String, dynamic> : {}, ), tags: (json['tags'] is List ? json['tags'] as List : const []) .map((e) => e.toString()) .toList(), stats: StatsModel.fromJson( json['stats'] is Map<String, dynamic> ? json['stats'] as Map<String, dynamic> : {}, ), ); } }这里有两个细节值得展开。
第一个细节是嵌套对象的容错。json['author']如果是 null,直接.fromJson(json['author'])会报错,所以我先判断它是不是Map<String, dynamic>,不是就给一个空 Map。这样一来,即使后端偶尔把 author 漏掉,模型仍能构造出来,页面也不会因为一个空字段整页崩溃。你可能会问,给了空 author,UI 上怎么判断?很简单,把nickname默认成空字符串,界面上该显示什么就显示什么,至少不会爆炸。
第二个细节是数组的处理。tags 在 JSON 里是["Flutter", "状态管理"],经过 jsonDecode 之后是List<dynamic>,每个元素的类型其实是 String。用.map((e) => e.toString()).toList(),可以安全地把它们都变成List<String>。就算某个元素不是 String,toString 也不会抛异常。
3.3 数组套对象、对象套数组:递归解析的拆解方法
上面这份 JSON 的嵌套深度其实只有三层,真正的复杂业务里会有更深的嵌套。比如评论列表,每条评论下面有回复,回复下面还有楼中楼:comments 数组里套 ReplyModel,ReplyModel 里又套 children 数组。
这种递归结构的拆解方法,和单层解析没有本质区别,只是从“一个工厂函数”变成“多个工厂函数互相调用”。以楼中楼为例:
class ReplyModel { final int id; final String content; final List<ReplyModel> children; ReplyModel({ required this.id, required this.content, required this.children, }); factory ReplyModel.fromJson(Map<String, dynamic> json) { return ReplyModel( id: int.tryParse('${json['id'] ?? 0}') ?? 0, content: json['content']?.toString() ?? '', children: (json['children'] is List ? json['children'] as List : const []) .whereType<Map<String, dynamic>>() .map(ReplyModel.fromJson) .toList(), ); } }你应该已经发现了:children 字段的类型是List<ReplyModel>,而它的解析方式是在 fromJson 内部再调用一次 fromJson。这就是递归解析的核心思路——每一层工厂函数只负责处理自己这一层的字段,遇到嵌套对象和数组,就继续委托给对应的模型类。
解析嵌套结构最怕的是“一层写到底”。比如json['data']['list'][0]['author']['nickname']这种连续下钻的写法,一旦中间某个字段是 null,整行代码直接崩掉。把它拆成多个模型类之后,每一层都有独立的容错和默认值,任何一个字段出问题,都只会影响当前层,不会波及整棵数据树。
把最外层的 list 转换成模型集合也很简单:
final listRaw = (data['list'] is List ? data['list'] as List : const []); final questions = listRaw .whereType<Map<String, dynamic>>() .map(QuestionModel.fromJson) .toList();这里的whereType<Map<String, dynamic>>()值得多看一眼。它会在遍历时把类型不符合的元素过滤掉,同时让后面的 map 安心处理 Map 类型。如果直接用map((e) => QuestionModel.fromJson(e as Map<String, dynamic>)),一旦某个元素不是 Map,又会来一次类型崩溃。先过滤再转换,是我写嵌套解析时雷打不动的顺序。
4. 复杂数据解析里我被真实接口反复捶打的五处细节
4.1 空值、缺字段与类型漂移
实际接口不像文档那么守规矩。我用一个表格把最容易出现的三种异常列出来,以及对应的处理方式:
| 异常场景 | 表现 | 处理策略 |
|---|---|---|
| key 不存在 | json['xxx']直接得到 null,后续调用方法会崩 | json['xxx']?.toString() ?? '' |
| 字段值为 null | 模型属性变成 null,UI 渲染异常 | 模型字段定义成可空类型,或者给默认值 |
| 数字字段变成字符串 | as int 强转失败 | 用int.tryParse('${json['xxx'] ?? 0}') ?? 0 |
第三种情况我遇到得最多,也是“类型漂移”的典型代表。某次后端调整序列化逻辑,把总数从 int 改成了 String,我前面用 as int 的代码立刻全部报错。从那之后,我对数字字段一律用 tryParse 兜底,宁可多写两行,也不赌接口格式不变。
4.2 命名风格不同导致的“明明有值却读不到”
后端常用的 snake_case(下划线命名)和 Dart 侧的 camelCase(驼峰命名)是两套系统。如果你在 fromJson 里写json['view_count'],模型字段却叫viewCount,这还是正常映射。但如果你直接把字段名写错,比如漏了一个下划线,代码不会报错,只会静默地读出一个 null,页面缺数据也找不到原因。
我的做法是,每个模型类都维护一张“字段对照表”,人肉检查一遍:后端给的 key 是什么,fromJson 里读的是什么,模型字段叫什么。三个地方必须完全对得上。尤其要注意user_id和userId这种差异极小的字段,手一抖就可能混。
4.3 as 强转嵌套结构时的类型崩溃
前面已经反复提到 as 的风险,这里单独拿出来说,是因为嵌套场景下它太容易崩了。你以为json['author'] as Map<String, dynamic>是安全的,但如果后端临时把 author 变成了一个空数组,强转直接抛type 'List<dynamic>' is not a subtype of type 'Map<String, dynamic>' in type cast。页面当场白屏。
所以我在解析层养成了一个习惯:先 is 判断,再 as 使用。而且干脆写两个工具函数,让每个模型类都不需要到处重复判断:
Map<String, dynamic> asJsonMap(dynamic value) => value is Map<String, dynamic> ? value : {}; List<dynamic> asJsonList(dynamic value) => value is List ? value : [];有了这两个函数,AuthorModel.fromJson(asJsonMap(json['author']))就永远不会因为类型问题抛异常。这算是我 Day 3 最大的收获之一。
4.4 时间戳和数字的边界情况
JSON 里时间最常见的两种表示方式是时间戳和日期字符串。时间戳又分两种:秒级和毫秒级。一个 13 位的数字,你除以 1000 才算得出秒;10 位的数字,直接乘 1000 才是毫秒。判断方法其实很简单:
final ts = int.tryParse('${json['created_at'] ?? 0}') ?? 0; final dateTime = ts > 10000000000 ? DateTime.fromMillisecondsSinceEpoch(ts) : DateTime.fromSecondsSinceEpoch(ts);10 位以上的时间戳按毫秒处理,否则按秒处理。这是后端格式常见的分野,不处理的话,展示出来的日期会差着数量级。数字的边界情况还包括:JSON 里的整数有时会被解码成 double,比如带小数点的计数;有些数字字段值过大,double 精度丢失。解析时如果用num类型接收再转 int,能减少一部分问题。
4.5 把解析放进 UI 层,是所有性能问题的开始
我见过有同学在 build 方法里直接写 jsonDecode,整个页面每次重建都要重新解析一遍 JSON。一个列表还好,如果是长列表加复杂嵌套,用户一滑动,解码就跟着重复执行,卡顿感会非常明显。
正规的做法是把网络请求、JSON 解码、模型转换全部放在数据层——比如 repository 或 service 类里完成。UI 层只接收已经组装好的List<QuestionModel>这类模型集合。UI 保持简单,数据层保持健壮,两边各司其职。这虽然是一个架构习惯问题,但和解析的稳定性、性能都直接相关。
5. 解析完成不算完,集合应用才是让页面真正的“活”
5.1 去重:用 Set 承接接口里的重复数据
列表接口偶尔会返回重复数据,尤其是分页和下拉刷新组合的时候。这时候 Set 就派上用场了。我通常用一个Set<int>记录已经加载过的 questionId,遍历时通过 add 方法的返回值来判断是否已经存在:
final loadedIds = <int>{}; final uniqueQuestions = <QuestionModel>[]; for (final question in questions) { if (loadedIds.add(question.questionId)) { uniqueQuestions.add(question); } }这里的技巧是,Set 的 add 方法会返回一个布尔值:如果元素已经存在,返回 false;不存在则添加并返回 true。一行代码同时完成了“判断”和“记录”。很多人忽略了这个特性,还会额外调用 contains 来判断,其实没有必要。这个模式在处理任何列表去重时都很实用。
5.2 排序:别让列表顺序由后端返回顺序决定
后端返回的顺序是按创建时间还是按热度,不一定符合你的页面需要。与其在 UI 里做逻辑,不如在数据层拿到模型集合后直接排序。比如按回复数从高到低排:
final hotList = [...questions] ..sort((a, b) => b.stats.replyCount.compareTo(a.stats.replyCount));这段代码有两个地方值得说明。第一,[...questions]会先生成一个新列表,避免 sort 原地修改原始数据,保留一份原始顺序备用;第二,比较器里用b.stats.replyCount.compareTo(a.stats.replyCount)而不是a.compareTo(b),因为前者表示降序。我有时候会搞混,所以现在习惯在注释里写明“这是降序”。
5.3 分组:把平铺集合变成 UI 可直接消费的分区
分组在 Flutter 里最常见的场景是:列表页把同一天或同一作者的数据折叠到一起。Dart 标准库没有内置 groupBy,但用循环加 Map 也能轻松实现。按作者等级分组:
final groupByLevel = <int, List<QuestionModel>>{}; for (final question in questions) { groupByLevel.putIfAbsent(question.author.level, () => []).add(question); }putIfAbsent的意思是,如果键不存在,就用后面的闭包创建一个空列表,然后立刻 add。这样一组代码下来,一个按 level 分组的 Map 就出来了。UI 层拿到这个 Map,可以先遍历 key,再渲染每个 key 下的列表,分组折叠效果几分钟就能搭好。
集合应用的核心价值就在这:解析只是把 JSON 变成对象,而排序、去重、分组这些操作,是把对象集合整理成 UI 真正需要的形状。后端给你的永远是最原始的数据,页面需要的往往是整理过的数据,中间这段差距就是集合操作来填。
5.4 性能心智:集合操作的位置比写法更重要
做集合操作时,代码写得好不好是次要的,关键是你在哪一层做。我建议所有排序、去重、分组逻辑都集中在数据层,页面拿到手就是可以直接用的集合。如果在 build 里做这些操作,每次重建都会重复执行,数据量一上来立刻卡顿。
另一个性能意识是:不要频繁把 List 转成其他类型。比如每次 build 都调用.toSet(),虽然代码短,但会复制整个集合。数据量大时,这种隐性的全量复制会拖慢渲染。如果确定的集合变化不频繁,就缓存起来,不要在 build 里重复计算。
6. 在鸿蒙设备上跑通链路:平台差异与调试建议
6.1 解析逻辑与平台无关,但运行环境不是
Day 3 结束时,我把整套代码迁移到了鸿蒙跨端工程的运行环境里做真机验证。有一个感受特别明显:凡是纯 Dart 的解析逻辑,在鸿蒙设备上运行完全不需要改。JSON 解码、模型转换、集合操作,这些都不依赖任何平台能力,写到哪都能跑。
需要留意的反而是数据链路的外围环境。首先是网络权限:Flutter 工程运行到鸿蒙设备上后,访问普通业务接口需要确认工程配置里已经声明了网络访问权限,不同系统版本的权限政策还有区别。其次是本地调试地址:模拟器里访问本机服务可能用 localhost 就够了,但真机调试时,localhost 指向的是手机自己,根本连不到开发机,必须换成局域网 IP 或者通过端口映射访问。我第一次真机调试就是卡在这里,页面请求一直失败,还以为是解析代码写错了。
6.2 真机调试时最常踩的三类环境问题
第一类是 HTTP 明文请求被系统安全策略拦截。开发阶段我们经常直接请求http://的本地服务,很多系统默认不允许明文流量,表现就是请求发出去了但没有任何返回。这个问题跟平台版本强相关,具体策略需要查当前系统版本的手册,不要想当然。
第二类是调试模式下的解码速度。Debug 模式下 Dart 代码执行效率远低于 Release,一个几百 KB 的 JSON 在 Debug 模式解起来会有明显延迟,页面可能闪烁一下才出数据。遇到这种情况,先别急着优化解析代码,切到 Release 模式跑一把,往往就好了。
第三类是响应体积过大。复杂嵌套 JSON 动辄几百 KB,如果还在 UI 线程里直接 jsonDecode,主线程可能被阻塞,界面卡顿。后续可以考虑用 isolate 或 compute 把解码放到后台执行,但 Day 3 阶段不急着上,先把结构理清更重要。
6.3 先打印、再格式化、最后解析的排错顺序
我想把最后一个实操技巧分享给所有从静态页面转数据驱动页面的同学:拿到接口响应之后,第一件事不是写模型,而是把原始 JSON 打印出来。
debugPrint(responseBody.length > 500 ? responseBody.substring(0, 500) : responseBody);打印前 500 个字符已经足够判断结构。如果发现返回的不是 JSON,而是一段报错文本,那问题在服务端,不在你的解析代码。确认是合法 JSON 之后,再复制到格式化工具里展开看层级,跟着层级写模型。解析代码写完,建议用 try-catch 包一层,捕获 FormatException 时能通过日志直接定位是哪段数据出了问题。
这个排错顺序看起来简单,但它帮我节约了大量时间。很多人拿到接口就一头扎进代码,写了半天才发现原始响应结构跟预期完全不一样。先打印、再格式化、最后解析,会让你从起点就站在正确的地基上。
Day 3 结束的时候,我再回头看那串让我头疼的嵌套 JSON,心态已经完全不同了。解析复杂数据并不神秘,无非是先看结构、再定模型、逐层转换、最后用集合操作整理成页面需要的形态。真正值钱的经验不是某一行巧妙的解析代码,而是养成“结构不能靠猜,每一层都要容错”的习惯。后面做鸿蒙跨端项目时,我会把所有接口响应统一收进数据层,向上抛出干净的模型集合,让页面少做一点容错,多花精力在真正的交互上。