news 2026/10/17 5:11:06

Flutter网络层封装实践:用Dio拦截器实现自动加载框与并发控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter网络层封装实践:用Dio拦截器实现自动加载框与并发控制

很多人刚接触Flutter的时候,第一个困惑不是Widget怎么摆,而是网络请求怎么写。用自带的HttpClient写两下就烦了,于是换到Dio,先是每个页面里new一个Dio实例,然后在请求前手动show一个loading弹窗,请求结束后再手动关掉。代码不复杂,但一个请求还好,等页面里出现三五个并发请求、互相之间有依赖关系的时候,这套写法会迅速把你拖入回调地狱。

我自己在某个App项目里的网络层就是这样被逼出来的。从最初一百多行的裸请求代码,到后来一个方法搞定、加载框自动显示自动消失、错误提示自动弹出,中间踩了不少坑。这篇文章就把这套封装思路完整写下来,包括为什么要把加载框交给拦截器管理、并发请求时怎么避免loading闪动、超时和错误码怎么统一处理。代码是基于Dio开发版支持的常用写法,适合已经能用Dio写简单请求、但还没系统整理过网络层的开发者参考。

1. 先搞明白:为什么不能每个页面都自己弹loading

先说痛点。很多项目的网络层代码长这样:页面A发一个请求,showDialog弹一个加载框,然后await请求结果,finally里Navigator.pop关掉弹窗。页面B、C、D也是这个操作。看起来没什么问题,但等你写多了就会发现,这套“复制粘贴”式的写法有非常明显的边界成本。

1.1 裸写网络请求时的三重尴尬

第一重是重复代码。每个页面的请求都要写loading的展示逻辑、关闭逻辑、错误处理逻辑。这些逻辑本质上跟业务毫无关系,却散落在各个页面里。一旦交互规范变了——比如loading风格从转圈变成带文字的遮罩——你就得全局搜索然后一处一处改。

第二重是弹窗关闭时机容易出错。请求发出后用户按返回键,页面被销毁,但请求还在跑。这时候如果请求回调里还调用了Navigator.pop或者context相关操作,轻则报错,重则闪退。我见过不少项目因为这种请求回调里的BuildContext使用不当,出现“Looking up a deactivated widget's ancestor”的报错,排查起来很费劲。

第三重是并发请求和loading冲突。页面同时发起两个请求,第一个请求先回来,执行了关闭loading的代码,第二个请求还在跑,结果用户看到loading闪了一下就没了,体验很差。如果恰好第一个请求失败而第二个请求成功,弹窗关闭的顺序还会彻底乱掉。这不是偶发问题,在没有统一管理的情况下几乎是必然出现的。

1.2 封装的本质是收口

把这些问题放到一起看,你会发现核心矛盾在于:加载框的显示和关闭根本上是网络请求生命周期的附属品,不该由业务页面管理。业务页面只关心“发起请求、拿结果”,loading怎么出现、怎么消失、出现多久,都应该由网络层自动决策。

这就是“收口”的思路。把所有请求统一经过一层封装,由这层封装负责加载框的开启关闭、请求成功的响应解析、请求失败的异常提示。业务侧只调用一个方法,传进method、url、params,然后等待返回结果。

封装的好处不只是少写代码,更重要的是它把容易出错的细节收敛到了一处。后续如果要在请求头里统一加签名、要处理Token过期自动刷新、要统计埋点,都只需要改动网络层,业务页面完全无感。这个单点维护的优势,在项目迭代到中期以后会体现得越来越明显。

2. 加载框的生命周期,为什么要交给Dio拦截器接管

如果只是做一个工具类,在请求方法内部show loading、dismiss loading,那确实也能用,但还不够“自动”。真正顺手的方式是借Dio的拦截器机制,让加载框和请求过程彻底绑定:请求开始的时候显示,请求结束的时候关闭,不用在每次调用的地方写任何额外代码。

2.1 Dio拦截器的触发链路

Dio的拦截器分三类:请求拦截器(onRequest)、响应拦截器(onResponse)、错误拦截器(onError)。它们的关系很直观:

  • onRequest:请求发出之前触发,在这里可以修改请求头、拼接公共参数、打印日志。
  • onResponse:收到服务端响应之后触发,在这里可以统一校验返回结构。
  • onError:请求失败或网络异常时触发,在这里可以统一处理错误提示。

加载框挂在拦截器上再合适不过。请求拦截器负责打开loading,响应拦截器和错误拦截器负责关闭loading。这样业务代码里既看不到showDialog,也看不到Navigator.pop,整个loading的开关全部收敛在拦截器内部。

2.2 loading开与关的正确位置

先看显示。打开loading的动作放在onRequest里,因为它能覆盖所有请求,不管是get、post还是上传下载,只要发出去就自动弹loading。这里需要注意的一点是:不该在onRequest里无脑调用showDialog。原因是onRequest本身是异步的,而且Dio对同一个实例的并发请求会走多个onRequest,如果不加控制,并发场景会闪出多个loading弹窗。

关闭的动作放在onResponse和onError里。这里要处理的不是“每收到一个响应就关”,而是“所有请求都结束才关”。听起来有点绕,实际靠一个简单的计数器就能解决:每个请求开始时计数器加一,请求结束时计数器减一,只有计数器归零才真正关闭loading。后面会有专门一节讲这个细节。

2.3 不用拦截器和用拦截器的差距

不用拦截器的做法通常是把loading逻辑写在请求方法里,类似:

Future<T> getData<T>() async { showLoading(); try { final response = await dio.get('/api/data'); hideLoading(); return response.data; } catch (e) { hideLoading(); throw e; } }

这种写法比页面里手写要强一点,但还是有个隐患:如果dio.get抛出的异常发生在响应解析阶段,hideLoading会被调用两次;如果请求内部有重试机制,loading的开关次数会变得不可控。挂在拦截器里以后,展示和关闭就完全跟随Dio的生命周期,业务方法里不需要再碰loading相关代码,连hideLoading少写一次的风险都不存在了。

这也是整篇文章的核心思路:Dio拦截器不光是用来改请求头的,它是网络层统一管理UI状态的抓手。

3. 封装实战:一个带自动加载框的网络请求模块是怎么长出来的

既然思路清楚了,接下来就是动手实现。我建议把网络层拆成四个文件来做,而不是全塞在一个类里:Dio单例负责配置和能力扩展,LoadingManager负责弹窗控制,拦截器负责生命周期绑定,对外API负责业务调用。这样每个文件职责单一,后期加需求好维护。

3.1 目录结构

实际项目里我习惯这样组织:

lib/ core/ network/ dio_instance.dart loading_manager.dart request_interceptor.dart api_client.dart

dio_instance.dart负责创建Dio实例和BaseOptions配置;loading_manager.dart维护loading计数和显示关闭;request_interceptor.dart是一个实现了Dio拦截器接口的类;api_client.dart是业务侧真正调用的入口。这个分层很小,但逻辑边界很干净。

3.2 先写Dio实例的公共配置

Dio的配置集中在BaseOptions里。以下是一份我在生产环境实际用过的配置:

import 'package:dio/dio.dart'; Dio createDioInstance({ String baseUrl = 'https://api.example.com', Duration connectTimeout = const Duration(seconds: 15), Duration receiveTimeout = const Duration(seconds: 15), Map<String, String>? extraHeaders, }) { final options = BaseOptions( baseUrl: baseUrl, connectTimeout: connectTimeout, receiveTimeout: receiveTimeout, contentType: Headers.jsonContentType, responseType: ResponseType.json, headers: { 'X-Client-Platform': 'flutter', ...?extraHeaders, }, ); final dio = Dio(options); return dio; }

connectTimeout和receiveTimeout值得单独提一下。移动端网络环境不稳定,15秒是我试下来比较合适的值:既不会让用户在弱网下等待太久,也不会因为超时设得太短导致正常请求被误杀。如果你做的是一些数据上报类的接口,还可以给单个请求单独设置更长的超时,Dio允许在调用时通过Options覆盖默认值。

这里我把baseUrl做成了参数而不是常量,是因为同一个App可能会对接多个环境(测试环境、预发布环境、正式环境)。用一个全局变量或者环境配置文件去控制baseUrl,比写死在Dio实例里灵活得多。

3.3 LoadingManager:不要让showDialog裸奔

很多人写loading会直接调showDialog,然后配一个CircularProgressIndicator。这种做法本身没问题,但它管理不了计数,也没法统一loading的样式和交互规范。所以我习惯单独写一个LoadingManager,内部用计数器控制显示和关闭。

import 'package:flutter/material.dart'; class LoadingManager { LoadingManager._(); static final LoadingManager instance = LoadingManager._(); int _count = 0; bool _isShowing = false; void show({String? text}) { _count++; if (_isShowing) return; _isShowing = true; // 这里使用全局 navigatorKey 来获取 context final context = navigatorKey.currentState?.overlayContext; if (context == null) return; showDialog( context: context, barrierDismissible: false, barrierColor: Colors.black.withOpacity(0.25), builder: (_) => _LoadingView(text: text), ); } void hide() { if (_count > 0) _count--; if (_count > 0 || !_isShowing) return; _isShowing = false; final context = navigatorKey.currentState?.overlayContext; if (context == null) return; Navigator.of(context, rootNavigator: true).pop(); } }

这个类里最关键的就是_count计数器。每次show都会加一,每次hide都会减一,计数减到0的时候才真正把弹窗关掉。比如并发三个请求,计数会变成3、2、1,等最后一个请求结束时归零,这时候才pop弹窗,用户看到的loading就不会中途闪没了。

navigatorKey是一个全局的GlobalKey ,通常在MaterialApp初始化时绑定。用它是为了不在loading管理器和页面BuildContext之间纠缠太多。弹loading时页面可能已经切换,但全局overlay的context永远可用,不会出现“deactivated widget”的崩溃。

_LoadingView是我常用的自定义弹窗组件,中间一个转圈加一行文字,背景用半透明黑色遮罩。具体长什么样可以根据UI规范改,核心是保持统一的视觉效果。

3.4 拦截器:把loading和请求绑定

拦截器是这个封装里最核心的一环。我建议把业务拦截器和loading逻辑拆开管理,不要写在一个巨大无比的类里。下面是一个专门的LoadingInterceptor:

import 'package:dio/dio.dart'; class LoadingInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (options.extra['showLoading'] == true) { LoadingManager.instance.show(); } handler.next(options); } @override void onResponse(Response response, ResponseInterceptorHandler handler) { if (response.requestOptions.extra['showLoading'] == true) { LoadingManager.instance.hide(); } handler.next(response); } @override void onError(DioException err, ErrorInterceptorHandler handler) { if (err.requestOptions.extra['showLoading'] == true) { LoadingManager.instance.hide(); } handler.next(err); } }

这里有个细节是我试过很多次才确定的:是否弹loading应该由调用方通过extra参数控制,而不是无脑对每个请求都弹。因为有些请求是后台静默刷新的,比如首页进入后自动拉取用户消息数,这时候弹loading反而干扰用户。默认可以设置成false,只有调用方明确传了showLoading: true才显示加载框。

Response和Error拦截器里都用requestOptions.extra去判断,而不是直接用options.extra,是因为拦截器之间流转时,onResponse拿到的是response.requestOptions,而不是原始的options。这个容易踩坑,我一开始就是照着onRequest的写法去读options,结果一直拿不到值。

3.5 对外API:让业务调用无限接近“一个方法”

业务层入口我一般封装成一个ApiClient类,提供get、post这些语义化方法。调用方传url、params,配合一个showLoading开关,不用关心Dio实例和拦截器细节。

import 'package:dio/dio.dart'; class ApiClient { ApiClient({required Dio dio}) : _dio = dio; final Dio _dio; Future<dynamic> get( String path, { Map<String, dynamic>? query, bool showLoading = false, }) async { final response = await _dio.get( path, queryParameters: query, options: Options(extra: {'showLoading': showLoading}), ); return response.data; } Future<dynamic> post( String path, { Map<String, dynamic>? data, bool showLoading = false, }) async { final response = await _dio.post( path, data: data, options: Options(extra: {'showLoading': showLoading}), ); return response.data; } }

这个返回的response.data是整个body。如果你后端接口有统一的包裹格式,比如{code: 0, message: 'ok', data: {...}},建议再套一层写一个统一的响应解析器,把data字段拆出来返回。这一步可以放在响应拦截器里做,也可以在ApiClient里做。我更倾向于在拦截器里做,因为这样ApiClient永远只返回业务数据,调用方拿到的就是干净的Model或Map。

业务侧的调用就变成了:

final data = await apiClient.get( '/api/user/info', query: {'id': userId}, showLoading: true, );

而页面里再也不需要手动去render loading控件、也不需要管Navigator.pop。loading出现的时机只有一个:请求发出到最后一刻结束之间。

4. 并发、超时和错误提示:网络层最容易翻车的三个场景

封装写完之后,真正考验它的是实际运行中的边界情况。下面这几个场景,是我在项目里真实遇到并且反复调整过的,每一件如果不处理,都会在用户那里变成偶发的bug。

4.1 并发请求导致loading提前关闭:计数器解法

如果你只在拦截器里简单地show和hide,很可能遇到这个现象:页面进入时同时发两个请求,第一个回来的很快,loading刚显示就没了,而第二个请求还在继续。用户感受是loading闪了一下,如果恰好页面内容还没渲染出来,看起来就像卡顿了一下。

解决办法就是前面LoadingManager里的计数器。这里再展开说一下计数器的机制和应用场景。

进入页面的流程可能是这样的:调用方执行两个请求,都带了showLoading:

  1. 请求A发出,onRequest里计数从0变1,创建loading弹窗。
  2. 请求B发出,onRequest里计数从1变2,因为_isShowing已经是true,所以不再新建弹窗,只更新计数。
  3. 请求A先返回,onResponse里计数从2变1,此时loading仍然保持显示。
  4. 请求B返回,onResponse里计数从1变0,此时才真正关闭loading。

代码里用_count > 0这个条件判断是否关闭,实际是执行了hide()方法内的递减逻辑。这个方案在请求数量大于2、且请求返回顺序不确定的情况下稳如磐石。

但要注意一点:如果某个请求永远不返回,比如服务端一直不响应,计数就不会归零,loading会一直挂在那里。所以一定要给请求设置超时时间,也就是前面BaseOptions里配的connectTimeout和receiveTimeout。超时后Dio会抛异常,错误拦截器会执行hide,计数器随之递减,避免loading永久残留。

4.2 用户点击遮罩或者按返回键关掉loading怎么办

这是另一个很容易被忽略的细节。showDialog默认情况下barrierDismissible是true,也就是说用户点击弹窗之外的遮罩区域,弹窗就会被关闭。对loading弹窗来说这是灾难:加载框被用户手动关掉了,计数器的计数还停留在1,请求结束后hide()执行到Navigator.pop时已经没有弹窗可关,甚至可能误关掉底下的页面。

处理办法有两个层面。第一,showDialog时要明确定义barrierDismissible: false,从根上禁止点击遮罩关闭。同时用PopScope禁止Android返回键关闭loading弹窗。

showDialog( context: context, barrierDismissible: false, barrierColor: Colors.black.withOpacity(0.25), builder: (_) => PopScope( canPop: false, child: _LoadingView(text: text), ), );

第二,在hide()里加一个_isShowing的标记。只有确实显示了弹窗才去执行pop,避免误关操作。这个标记在弹窗被意外销毁时也能起到兜底作用,防止计数和UI状态不同步。

这类问题有一个共性:弹窗的显示状态和业务逻辑是两套状态机,必须谨慎地同步。代码写的时候会觉得“这个场景怎么可能出现”,但真实用户的操作习惯总能超出你的预期,该拦住的一定要拦住。

4.3 超时、错误提示和业务码的区分

请求失败的时候,用户必须得到一个明确的反馈——到底为什么失败了。Dio的异常体系里有好几种类型:连接超时、接收超时、连接被拒绝、DNS解析失败、响应码不是2xx等等。不同异常类型对应的用户提示语是不一样的。

我在错误拦截器里统一做了一层错误分类:

@override void onError(DioException err, ErrorInterceptorHandler handler) { if (err.requestOptions.extra['showLoading'] == true) { LoadingManager.instance.hide(); } String message = '网络异常,请稍后重试'; switch (err.type) { case DioExceptionType.connectionTimeout: message = '连接超时,请检查网络'; break; case DioExceptionType.receiveTimeout: message = '响应超时,请稍后重试'; break; case DioExceptionType.connectionError: message = '网络连接失败'; break; default: if (err.response != null) { message = '服务器异常(${err.response?.statusCode})'; } } // 用全局toast封装统一弹出错误提示 showErrorToast(message); handler.next(err); }

这里需要区分“网络层错误”和“业务层错误”。网络层错误由DioException抛出,业务层错误通常是服务端返回了一个非0的code,比如登录token过期、参数错误。业务层错误不应该走到DioException里,而是在响应拦截器里根据code做判断。

比较稳妥的做法是,在响应拦截器里解析后端统一包裹结构,如果code不是预期值,抛出一个自定义的BusinessException。这样业务调用方只需要catch住异常,就能根据异常类型决定是提示用户还是跳转登录页。

4.4 更进一步:登录态失效自动续期

封装做到上一步,其实已经能覆盖绝大多数网络请求场景了。但还有一个隐藏需求:token过期后的自动重新登录。这个也可以放在拦截器里做,思路是响应拦截器判断code为401,然后调用refreshToken接口拿新token,重新发起原请求。这一步属于进阶优化,加上去之后整个网络层会更“耐揍”,不过篇幅所限这里就先不展开细节了。如果你在项目里遇到频繁的登录过期断请求,值得深入研究一下这个模式。

5. 封装完成之后,别忘了这几个容易被忽略的细节

代码能跑起来之后,并不是万事大吉。我总结几个容易忽略的细节,希望你在集成时注意。

5.1 拦截器注册顺序有讲究

Dio的拦截器是按注册顺序依次执行的。如果我的项目里既有LoadingInterceptor又有AuthInterceptor,建议把AuthInterceptor放在最前,保证请求头、token刷新的逻辑最先执行,LoadingInterceptor其次。这样做的原因是:如果token刷新失败或者请求被拦截器提前终止,loading根本不应该显示。顺序反了的话,loading弹出来一闪就被错误拦截器关闭,体验很怪。

5.2 loading弹窗的层级和样式统一

用Overlay展示loading,层级天然就在所有页面之上。但如果你用了showDialog,要注意rootNavigator: true参数。不带上这个参数,在某些嵌套Navigator的场景下,弹窗可能出现在子Navigator里,导致页面切换后loading被连带销毁,而计数器中还认为它在显示。

样式方面,我建议一个App里只保留一套loading视觉,不要A页面圆形转圈,B页面文字转圈,C页面又是个全屏遮罩。这些差异用户不会称赞,反而会觉得App不成熟。LoadingManager里如果以后要支持不同样式,可以用一个枚举参数区分,但默认保持单一风格。

5.3 不要忘了关闭加载框之后再处理导航

如果你的请求回调里包含了Navigator操作,比如请求成功后push到新页面,注意顺序:先确认loading已经关闭,再执行导航。因为loading是用Navigator.pop关闭的,如果你在同样的NavigationContext上先push了新页面再pop,很容易把刚push的页面弹掉。

在实际项目里,我见过一个非常隐蔽的bug:请求成功之后立刻push详情页,但loading的pop因为异步时序延后到了push之后执行,结果新页面刚打开就被loading的pop关掉了。排查了很久才发现,最后通过把loading关闭操作放到导航之后、并且使用rootNavigator,才彻底解决。

个人体验总结

用Dio做带加载框的网络封装,最难的地方不是代码写出来,而是把各种边界情况一次想清楚。LoadingManager的计数器、拦截器对showLoading的开关、错误拦截器的异常分类,这三者配合起来之后,网络层的体验会有一个质的提升。我后续在几个不同类型的App里复用这套结构,基本只改BaseOptions的baseUrl和拦截器里的业务逻辑,其他部分几乎没有动过。如果你现在项目里的网络层还处在“每个页面都自己showDialog”的阶段,强烈建议花一天时间把这套封装补上,后面省下来的调试时间绝对远超写代码的花费。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 5:10:58

浏览器桌面通知Notification API实战:权限、封装与用户体验

1. 桌面通知弹窗到底是个什么东西第一次接触 Notification API 是在做一个后台管理系统的时候。当时的需求很朴素&#xff1a;用户提交了一个耗时任务&#xff0c;比如批量导出数据、生成报表&#xff0c;处理可能要跑十几秒甚至几分钟。如果用户切到别的标签页去干别的事了&am…

作者头像 李华
网站建设 2026/10/17 5:10:08

C# WinForms接口测试工具:生产级API调试与自动化验证

简介&#xff1a;这是一款基于C#开发的轻量级Windows桌面接口测试工具&#xff0c;面向.NET初学者、后端开发者及API调试人员&#xff0c;解决日常HTTP接口快速验证与调试需求。工具采用WinForm框架构建图形界面&#xff0c;支持GET、POST、PUT、DELETE四大标准请求方法&#x…

作者头像 李华
网站建设 2026/10/17 5:10:04

基于数据驱动与兰姆波的结构健康监测损伤识别方法

1. 项目背景与核心思路结构健康监测(SHM)这个方向&#xff0c;说直白点就是给飞行器、桥梁这类大件装备装上一套“神经系统”&#xff0c;让它们能随时向维护人员汇报自己哪儿不舒服。尤其对于航空航天结构&#xff0c;疲劳裂纹、冲击损伤、连接松动这些问题&#xff0c;早期发…

作者头像 李华
网站建设 2026/10/17 5:09:44

神经网络稀疏性实战指南:从原理到端侧落地

1. 项目概述&#xff1a;为什么“神经网络稀疏性”正在成为模型落地的分水岭最近三个月&#xff0c;我在给某高校实验室做模型轻量化咨询时&#xff0c;连续遇到三类典型问题&#xff1a;边缘设备上部署ResNet-50推理延迟超300ms、医疗影像分割模型在Jetson Nano上显存溢出、某…

作者头像 李华
网站建设 2026/10/17 5:09:42

Linux ISO镜像作为本地YUM源:离线软件供应链构建指南

1. 为什么非得把 ISO 当本地源&#xff1f;——从一次凌晨三点的服务器部署说起凌晨两点四十七分&#xff0c;某公司运维团队正在为一批新采购的物理服务器批量装机。网络出口带宽被CI/CD流水线占满&#xff0c;YUM仓库镜像站响应延迟飙到8秒以上&#xff0c;yum install -y gc…

作者头像 李华
网站建设 2026/10/17 5:08:55

SSM家庭健康管理系统:轻量级健康指标管理与阈值告警平台

简介&#xff1a;这是一套基于SSM&#xff08;SpringSpringMVCMyBatis&#xff09;框架开发的家庭健康管理系统实战项目&#xff0c;面向Java初学者与Web开发入门者&#xff0c;聚焦家庭场景下的健康指标数字化管理需求。系统涵盖家庭管理、成员档案维护、健康数据上报、指标动…

作者头像 李华