1. 从"我的"页面出发:先想清楚网络层要解决什么问题
做 Flutter 开源项目,尤其是涉及到"我的"(My)这种业务模块时,很多人第一反应是先把 UI 堆出来,头像、昵称、设置项列表往上一排,看起来像个样子了,再考虑接口怎么接。这种开发顺序本身没问题,等到真正接网络请求的时候,问题就像雨后春笋一样冒出来:服务器返回的 code 是不是 200?如果业务 code 是 500 但 HTTP 状态码是 200,前端要不要弹错误提示?数据里的字段是 null,页面到底能不能展示?这些看似零散的问题,归根结底就两个——请求校验和数据处理。而你不把这两件事在项目层面统一处理好,就会陷入每个页面各写一套逻辑、遇到问题只能临时打补丁的被动局面。
这个系列走到第 04 篇,前面的基础架构和主界面框架已经搭好,现在轮到这个项目里最容易被低估的部分:网络层。这篇博文不讲怎么调一个简单的 GET 接口,而是聚焦在"我的"页面这个具体场景下,把网络请求的完整链路拆开来看——发出去的请求如何被校验,回来的数据如何被解包、校验、转换成页面真正需要的模型。这个过程做好了对后续所有页面的开发都是受益的,做不好后面会持续返工。
适合谁来参考?如果你正准备给某开源项目的个人中心模块接接口,或者你想把自己的 Flutter 项目里 Http 请求这块做得更规范,这篇内容可以给你一套可以直接落地的思路和代码框架。有 Flutter 和 Dio 基础最好,没有也没关系,我会把每个环节为什么这样做讲清楚。
2. 请求校验的三层含义:别只盯着状态码
很多人理解"网络请求校验",第一反应就是判断返回的 code 是否等于 200。真实项目里这个判断远远不够,尤其在一个模块要展示用户信息、订单记录、设置选项这类混合数据的"我的"页面,请求校验至少要拆成三个层次来思考。
2.1 第一层:请求是否真的发出去了
这一层最容易忽略。用户点击"刷新"按钮,并没有触发真正的网络请求,可能是网络不可用,可能在飞行模式下,也可能是上一步页面传参异常导致根本没走到发起请求的代码。你需要在请求入口统一判断前置条件,而不是等 Dio 抛异常再处理。比如"我的"页面里,用户信息接口通常需要携带 token,token 为空时甚至不需要发请求,直接跳转登录并给出提示更合理。
Future<bool> prepareRequest() async { if (!await hasNetwork()) { showToast('网络似乎不太可用,请检查后重试'); return false; } final token = storage.read('token'); if (token == null || token.isEmpty) { goLoginPage(); return false; } return true; }这个前置校验看似简单,实际项目中能省下大量无意义的错误日志。你总不希望看到后台记录着成百上千条因为没有 token 而报 401 的请求,其实那些用户根本没有登录。
2.2 第二层:HTTP 状态码与业务状态码要分开
拿到请求响应以后,第一道关卡是 HTTP 状态码。这个由 Dio 默认就能判断,200 系、300 系重定向、400 系客户端错误、500 系服务端错误,Dio 默认会在非 2xx 时抛出 DioException。但这里有个关键点:很多后端接口设计不规范,无论业务成功失败,HTTP 状态码一律返回 200,真正的结果放在响应体里的 code 字段里。
在这个项目的实际对接中,我遇到过好几次这种情况。"我的"页面里展示用户积分或等级信息时,如果后端这个时候挂了,返回的 HTTP 状态码依然是 200,但响应体里的 code 是 50000,data 为 null,message 是 "internal error"。这时候你要是只判断 Dio 有没有抛异常,就会把一个空数据当成正常数据渲染进页面,用户看到的可能是个空白积分卡。
所以校验必须分两层:
| 校验阶段 | 判断对象 | 含义 | 处理方式 |
|---|---|---|---|
| 传输层 | HTTP status | 连接和数据传输过程是否成功 | 非 200-299 直接进入 Dio 异常处理流程 |
| 业务层 | code / msg | 后端业务逻辑是否执行成功 | code 匹配约定值才放行,否则按业务错误处理 |
2.3 第三层:数据本身的内容校验
即使业务 code 正常,数据内容也不能直接拿来用。服务端返回的某个字段偶尔会缺、类型也可能变。比如用户头像字段,正常是一个 URL 字符串,某次接口误传了一个空对象 {},如果你不做校验就把它交给 Image.network 处理,轻则显示空白,重则抛异常。
这一层的校验位置不在拦截器里,而是在数据封装环节——也就是数据解析成模型时就应该做容错。
class UserProfile { final String nickName; final String avatarUrl; UserProfile({this.nickName = '', this.avatarUrl = ''}); factory UserProfile.fromJson(Map<String, dynamic> json) { return UserProfile( nickName: json['nick_name'] is String ? json['nick_name'] as String : '', avatarUrl: json['avatar_url'] is String ? json['avatar_url'] as String : '', ); } }每层校验各管一段,拦截器管传输和业务状态码,模型管数据类型和默认值,页面管渲染逻辑。这样划分后,三层各司其职,出问题也能快速定位在哪一层。
3. 数据封装处理:把"后端返回的东西"变成"页面用得上的东西"
如果说请求校验是入口把关,那数据封装就是出口整理。页面不应该直接面对一个 Map<String, dynamic>,更不应该到处写 json['key'] 这种魔法字符串。这也是代码整洁度的分水岭。
3.1 统一响应模型的意义
常规做法是定义一个响应基类,业务数据放在泛型 T 里。这样所有接口返回的结构统一,解析流程也统一。
class ApiResponse<T> { final int code; final String message; final T? data; ApiResponse({ required this.code, required this.message, this.data, }); bool get isSuccess => code == 0; factory ApiResponse.fromJson( Map<String, dynamic> json, { required T Function(Object? data)? dataParser, }) { return ApiResponse( code: json['code'] is int ? json['code'] as int : -1, message: json['message'] is String ? json['message'] as String : '', data: dataParser != null ? dataParser(json['data']) : null, ); } }在这个项目里,我们约定的成功码是 0(有的项目喜欢 200,这个完全看后端约定,没有唯一标准)。重要经验:从第一天就把成功码常量确认下来,做成一个 AppConstant 类的静态常量,不要散落在各个文件里写魔法数字。
abstract class AppConstants { static const int successCode = 0; static const int needLoginCode = 40100; static const int requestFrequentCode = 42900; }3.2 泛型解析的两种风格
统一响应模型确定后,具体的业务数据怎么传给泛型 T,这里有两种实践路径,我都用过,各有适用场景。
第一种是边解析边回调,也就是从 json 里解析出 ApiResponse 的时候,同时把 data 解析成对应的业务模型。这种方式代码写起来直观,但是当项目越来越大、接口越来越多时,你需要在每个接口请求处重复写 dataParser,稍微有点冗余。
第二种是模型自带 fromJson 后,在接口层统一调用。把解析逻辑封装到 BaseRepository 或者 ApiClient 里,业务页面调用时只需要传接口路径和期望的数据类型:
Future<ApiResponse<UserProfile>> fetchUserProfile() async { final response = await dio.get('/user/profile'); return ApiResponse.fromJson( response.data as Map<String, dynamic>, dataParser: (data) => UserProfile.fromJson(data as Map<String, dynamic>), ); }在"我的"页面这个场景,用户信息接口、订单统计接口、消息数量接口都走这套统一封装。页面拿到的直接就是已经校验通过的 ApiResponse 、ApiResponse 这类类型安全的对象,不需要关心 json 的具体结构。
3.3 序列化方案选型:手写、JsonSerializable 还是动态映射
这个项目最开始我尝试过手写 fromJson/toJson,对小规模模型来说完全够用。但"我的"页面涉及的模型不少——用户信息、等级信息、订单状态、消息计数,这些模型嵌套层级比较深,手写就容易出错,而且维护成本不低。
后来切到了 JsonSerializable 配合 build_runner 自动生成代码的方案。个人建议:项目模型超过 5 个,或者嵌套超过两层,就别再手写了。自动生成虽然多了一步编译,但带来的收益是实打实的——字段缺失、类型不匹配这类运行时错误,在开发期就能通过生成代码提前暴露问题。
不过有一点必须注意:JsonSerializable 默认要求字段类型严格匹配,比如你声明了一个 double 类型的字段,服务端返回字符串 "3.5",默认是会解析失败的。你需要给这些不稳定的字段做特殊处理,要么后端修数据,要么你在生成模型上用 @JsonKey(fromJson:) 写好自定义转换函数。
4. 主界面"我的"页面接入网络请求的实操复盘
有了统一校验和封装,接下来就是真正把这个网络层接入到"我的"页面,这里我用"某开源项目"的个人中心模块演进过程来做一次完整复盘。
4.1 页面模块化:把请求和状态解耦
一开始我图省事,把请求方法直接写在了"我的"页面 State 里,十几个方法堆在一起,页面又臭又长。后来项目重构时,我强制自己拆成三层:ApiClient(网络基础能力)、UserRepository(用户相关接口)、MyPageState(页面状态与 UI)。
这种拆分带来的好处非常直接:页面不再直接关心 Dio 实例、拦截器、缓存这些底层细节,它只需要调用 UserRepository.fetchUserProfile(),拿到结果后更新 Loading、Success、Error 三态界面。如果你用的状态管理方案是 Provider、Riverpod 或者 GetX,可以把这些 Repository 放进依赖注入容器里,测试的时候直接 mock Repository 就行,非常方便。
4.2 三态加载模式:Loading、Success、Error
"我的"页面比较特殊的是,它不是一整页的数据,而是多个区块:最上方用户信息卡,中间菜单列表,右下角可能还有版本信息。有的接口是聚合返回的,有的需要并发请求多个接口。针对这种布局,我采用的是单接口请求按区块拆分状态,整页统一控制刷新的思路。
class MyPageState extends State<MyPage> { bool isFirstLoading = true; ApiResponse<UserProfile>? userProfileResp; ApiResponse<HomeStats>? statsResp; String? loadError; Future<void> refreshAll() async { final results = await Future.wait([ userRepository.fetchUserProfile(), homeRepository.fetchStats(), ]); // 分别处理每个结果 } }用 Future.wait 把各接口并发请求,同时等所有结果回来再统一次刷新 UI。这里有个细节:如果用户信息接口失败了,其他接口即使成功也不应该被丢弃,你需要把每个接口的结果独立保存,局部出错局部降级,而不是一错全错。
4.3 下拉刷新和登录失效的处理闭环
"我的"页面一定会做下拉刷新。下拉刷新的触发逻辑比普通的按钮刷新要多考虑一个场景——用户停留在"我的"页面,但有可能已登录状态失效(token 过期)。这时候刷新应该走到统一的 token 过期处理逻辑中去:清除本地登录态,弹出登录窗口,而不只是简单刷新一个接口失败。
在这个开源项目里,我用了一个轻量的方案:在 Dio 拦截器里统一判断业务错误码,如果命中 needLoginCode 这个约定值,就抛异常并触发一个全局的 AuthExpiredEvent。页面收到这个事件后,清空页面数据、跳转登录页。这样"我的"页面本身不需要关心 token 何时过期,它只需要响应页面事件流里的认证失效信号,保持单独职责。
这就是分层处理的好处,分散在每个页面的登录过期判断代码,全部收拢到一处,项目越大收益越明显。
5. 常见问题与排查技巧实录
接入网络请求和数据封装这件事,理论上说再多不如实际踩几个坑印象深刻。分享一下我在"我的"页面开发过程中真正遇到过的问题,以及排查思路。
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 请求一直失败,报 Connection failed | 本地网络不通 / 服务器域名错误 | 先用任意浏览器访问目标接口看通不通 | 检查 Dio 的 baseUrl 配置和本机网络代理设置 |
| 返回 200 但页面没数据 | 业务 code 判断写错了 | 打印完整 response body | 确认后端约定的成功 code 到底是 0 还是 200 |
| 后台日志有响应但页面还是加载中 | 数据解析抛异常,被 finally 误调用 | 给 Dio 加 enableLogs,看异常堆栈定位到哪个模型 | 多半是 JsonSerializable 类型不匹配,给字段加容错处理 |
| data 为 null 导致页面闪崩 | 接口返回异常但 code 正常 | 看响应里的 data 字段是不是 null | Model 层字段全部给默认值,data 判空后再渲染 |
| token 失效后接口报 401 | 拦截器没有统一处理 | 断点看拦截器 response 分支 | 在拦截器里提前判断 401 和业务登录失效码 |
| 下拉刷新偶尔白屏 | 刷新期间未处理异常 | 检查刷新方法的 error 是否被捕获 | refresh 过程用 try-catch 包裹,失败保持页面原状态 |
5.2 两个特别值得注意的细节
第一个是Future.wait 的异常隔离。"我的"页面如果多个接口并发,最怕的就是一个接口崩了导致 Future.wait 直接走 catch。解决办法是把每个接口调用包一层 fail-safe 函数,让它返回结果或默认值,而不是抛异常:
Future<ApiResponse<T>?> failSafe<T>(Future<ApiResponse<T>> Function() request) async { try { return await request(); } catch (e) { return null; } }有了 failSafe,页面的加载和刷新逻辑才能做到真正稳定。
第二个是接口返回字段务必注意类型变化。某一次"我的"页面新增了一个"会员等级"展示,后端接口在无会员信息时返回 data: {},在有会员信息时返回 data: { level: 5 },结果模型里 level 设计成了 int 类型,空对象一解析直接抛 type error。这类问题在开发期很难通过编译发现,必须靠运行时日志和测试用例兜底。我的建议是:凡是服务端可能为 null 的字段,模型里全部声明可空类型,页面渲染时用空安全操作符处理。宁可多写几个判空,也别等到线上用户反馈"页面打不开"。
5.3 排查网络层问题的三板斧
遇到"我的"页面网络问题,我个人的排查顺序是固定的:
- 先看 Dio 的请求和响应日志,确认请求 URL、请求头、响应体是否和预期一致。整个项目里 Dio Log 拦截器的 verbose 必须打开,至少开发构建要开。
- 在没有 Dio 日志的情况下,直接在浏览器或某个接口测试工具里手动跑一下这个接口,排查是不是服务和后端问题,把前端和后端问题先分开。
- 再看数据解析层——拿响应体里的 json 片段,手写一个 dart 文件在本地跑
UserProfile.fromJson(jsonData),看看是哪一行报错,基本能定位到模型的字段类型问题。
这三板斧用好,绝大多数网络异常都能在十分钟内定位。搞不定的,再把错误堆栈和相关响应体直接发给后端,让对方排查接口端问题,沟通效率也会高很多。
6. 重构过程中的两条硬性建议
关于"我的"页面的网络请求校验和数据封装,最后还想再强调两条经历过多次教训后的硬性建议。
第一条:写网络层代码时,不要想着每个页面都去定制一套解析逻辑。你花三天时间给当前页面做好看的数据模型,下一个页面又手忙脚乱地复制一份。正确做法是,花三四天把统一模型、拦截器、Repository 基类搭好,后续每一个新页面的开发成本都大幅降低。前期稍慢,后期极快。
第二条:虽然代码用了泛型和统一封装,但一定保留单独某个请求能够绕过某些校验的后门。比如某些文件上传接口、某些第三方回调接口,它们返回的数据结构甚至不一定遵循 ApiResponse 结构。我见过不少项目过度追求"统一",强制所有接口都套同一模板,第三方接口接进来反而处处别扭。校验和封装要为少数特殊case留一条逃生通道——定义一个 RawDataRequest 方法,允许指定不自动解包,拿到原始响应体自己处理,这样就不会被框架困住。
个人中心这类页面是目前移动端几乎每个项目都有的模块,做得好不好直接影响整体体验口碑。网络请求校验和数据封装看起来都是底层技术细节,但决定了一个页面在弱网、异常数据和后端故障时是否依旧能给用户稳定反馈。实操过程中你会发现,真的做到位的其实就两个词:流程统一、考虑异常。把这个理念落实下去,"我的"页面的开发效率和代码质量都会有明显提升。