开头先给个结论:我做了三年Flutter开发,参与过日活百万级App的网络层建设,如果现在让我在新项目里选网络库,我会毫不犹豫选Dio。不是因为它最流行,而是因为Dio把拦截器、取消请求、文件上传下载、并发控制这些高频需求全都做成了开箱即用的能力,而且源码质量足够稳定,在GitHub上长期保持高星和维护活跃度,社区踩坑记录也齐全。这篇博文不打算复述官方文档,我按自己实际项目的使用路径来写:从为什么选Dio、怎么搭一个能直接上线的请求层、拦截器怎么玩、和后端联调时遇到的各种真实问题,到文件上传下载与并发控制这些容易翻车的场景,最后附一份生产环境配置清单。无论你是刚接触Flutter的新手,还是已经写了一段时间想系统化网络层的开发者,这篇都能给你点能直接"抄作业"的东西。
1. 为什么是Dio:Flutter网络请求的选型逻辑
1.1 Flutter原生HttpClient到底差在哪
Dart SDK自带的HttpClient在底层实现上一点不弱,它跑在独立的IO线程池上,不走UI线程,性能上没有问题。但真正用起来你会很快发现,它给你的只是一个"能做网络通信"的底层工具,所有工程化的东西都得自己造轮子。
举个例子。一个正常的业务请求往往需要这些步骤:拼接baseUrl和path、自动附加公共参数或Token、统一打印请求和响应日志、根据后端返回的code判断业务成功还是失败、遇到401自动跳登录、请求超时重试。这些用原生HttpClient写一遍,每个请求都要手动处理,代码重复率极高。而且HttpClient没有拦截器体系,你没法在请求发出之前或响应返回之后插入统一逻辑,只能靠工具函数硬生生包一层,SpringBoot后端那种"拦截器做鉴权、切面做日志"的优雅思路在原生层完全实现不了。
另一个很现实的点是HttpClient对JSON的处理。它默认返回的是HttpClientResponse流对象,你需要自己utf8.decode转字符串,再jsonDecode转Map,再手动转成Model。Dio直接返回已经解码的Map<String, dynamic>或List<dynamic>(配合jsonDecode处理),这套流程至少替你省掉一半的样板代码。
1.2 主流三方库横向对比
Dart生态里能用的HTTP库其实不少,除了Dio外,http包是官方维护的轻量级选择,chopper和retrofit则走的是"接口注解生成代码"的路子。我做了一个对比表,方便你直观判断:
| 特性 | Dio | http | chopper | retrofit |
|---|---|---|---|---|
| 拦截器 | 内置且非常强大 | 无,需自己封装 | 有但用法较绕 | 依赖Dio,本质是其封装 |
| 文件上传下载 | 原生支持进度监听 | 只支持基础上传 | 需额外实现 | 需额外实现 |
| 请求取消 | CancelToken一键取消 | 需用StreamSubscription手动处理 | 支持但配置繁琐 | 依赖Dio |
| 并发控制 | 支持批量请求与Future组合 | 基础支持 | 支持 | 依赖Dio |
| 学习曲线 | 平缓,概念少 | 平缓但功能太少 | 较陡,需学注解语法 | 较陡,需代码生成 |
| 生产稳定性 | 高,支撑大量线上项目 | 适合简单场景 | 一般,社区用的人少 | 高,但要处理build_runner问题 |
retrofit其实是基于Dio的封装,它用注解生成代码,适合团队有Java背景、喜欢强制接口规范的场景。但代价是引入了build_runner代码生成,模型字段一变就要重新生成,多人协作时容易产生代码冲突。chopper也类似,写法上接近Retrofit。我个人建议是:业务复杂、需要灵活控制的,直接用Dio裸写,反而比二次封装更可控。
1.3 Dio核心特性拆解:不是"多了一个库"而是"少写了一堆代码"
Dio能解决的不只是"发请求、收响应"这一层。它真正值钱的是Interceptor(拦截器)架构,这个设计借鉴了OkHttp,把网络请求的生命周期切成了onRequest、onResponse、onError三个环节。你可以在这三个环节里塞任意逻辑:请求前统一加Token、请求中打印日志、响应后统一解析错误码、遇到特定状态码自动重试。拦截器可以多个叠加,形成一个处理链,每个环节都能决定是否中断链路或修改数据。
再就是Dio对文件上传下载的优化。FormData封装了MultipartFile,配合onSendProgress和onReceiveProgress回调,可以拿到实时的进度百分比,这在做App里的头像上传、安装包下载场景时极其方便。原生HttpClient要实现同样的下载进度,必须自己监听Stream的字节数,代码量不是一个量级。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与基础封装:搭一个能直接上线的请求层
2.1 引入Dio对应的Flutter SDK版本
Dio目前稳定版本在5.x,对Dart SDK有最低版本要求。在pubspec.yaml里添加依赖后执行flutter pub get就能完成安装:
yaml复制dependencies:
dio: ^5.4.0
有两点需要提前注意:
- Dio 5.x要求Dart 2.18以上的SDK版本,如果你的Flutter版本太老,建议先升级Flutter而不是强行降Dio版本。反正老版本迟早要淘汰,早升早省心。
- 不要直接给dio版本号写
any,那会造成不同开发机拉到的版本不一致,构建出来的产物行为有差异。锁版本号或使用^限定兼容范围才是正确做法。
2.2 BaseOptions配置:每个参数背后都有讲究
创建一个Dio实例后,强烈建议通过BaseOptions统一配置基础参数,而不是每个请求单独传。下面是实际项目里一份比较成熟的配置:
dart复制final dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
contentType: Headers.jsonContentType,
responseType: ResponseType.json,
queryParameters: {
'platform': 'android',
'appVersion': '1.2.0',
},
),
);
几个容易被忽略的参数我展开说说:
connectTimeout、receiveTimeout、sendTimeout从Dio 5.0开始接受Duration对象,老版本是int毫秒。connectTimeout管的是TCP建连时间,receiveTimeout管的是两个数据包之间的间隔超时,不是整个响应总超时。理解这点很重要,弱网环境下如果只设一个值,很容易出现"明明设了超时却等了很久"的错觉。这个超时指的是从服务器读取一个数据块的间隔时间,如果你设置10秒,但服务器每9秒发一个数据块过来,就算整体响应花了5分钟,也不会被判定超时。contentType默认是application/json。如果后端接口不支持JSON而只支持表单提交,可以设为application/x-www-form-urlencoded,也可以在做具体请求时单独覆盖。我见过很多新手在这里踩坑,后台上报403/400,一查是Content-Type不对,服务端解析不了body,白白定位几个小时。responseType建议保持ResponseType.json,Dio会自动把响应体解码成JSON对象。如果做文件下载,记得在下载请求的Options里改成ResponseType.stream,否则文件会被当作JSON解析。
queryParameters是请求URL的公共参数,每次请求都会自动追加在?后面。
2.3 统一封装:把网络层从业务里剥离
直接到处创建Dio实例是灾难性的,你会面临"改一个header加字段要搜遍全项目"的窘境。我的做法是做一个单例封装,所有业务层只面向这个封装暴露的方法,不直接操作Dio对象。
dart复制class ApiClient {
ApiClient._internal() {
dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
dio.interceptors.add(TokenInterceptor(dio));
dio.interceptors.add(LogInterceptor(responseBody: false));
}
static final ApiClient instance = ApiClient._internal();
late final Dio dio;
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? query}) async {
final Response response = await dio.get<Map<String, dynamic>>(
path,
queryParameters: query,
);
return response.data ?? {};
}
Future<Map<String, dynamic>> post(
String path, {
Map<String, dynamic>? data,
}) async {
final Response response = await dio.post<Map<String, dynamic>>(
path,
data: data,
);
return response.data ?? {};
}
}
这里用了Dart的工厂构造函数单例模式,确保整个App生命周期内只有一个Dio实例。不要每次请求都new Dio(),这会浪费连接池、无法复用底层HTTP连接。Dio内部维护连接池,复用实例对弱网环境的性能提升非常明显。
注意:不要试图自己封装"统一处理所有后端返回格式"的逻辑到
post方法里。每个业务模块的后端返回结构可能都不一样,真正统一的应该是"网络层协议",而不是"业务映射"。
3. 拦截器机制:让每个请求自动带上Token和日志
3.1 拦截器的执行顺序为什么是"先加后执行"
Dio的拦截器按添加顺序形成一个队列,请求发出前按添加顺序依次执行onRequest,响应返回后按逆序执行onResponse。也就是说,你先添加了日志拦截器,再添加Token拦截器,那么请求时会先打日志再附加Token,响应时则先经过Token拦截器再打日志。清晰理解这个执行链,才能设计出合理的拦截器顺序。
我项目里的标准顺序是:
- TokenInterceptor:给请求头附加认证信息
- LogInterceptor:打印请求与响应信息
- ErrorInterceptor:统一错误转换与提示
这样的好处是日志拦截器能看到带Token的完整请求头,排查问题直接看日志就能确认Token有没有带上去。
3.2 请求拦截器:自动附加Token与签名
实际项目中,除了每个接口都要带Token,某些敏感接口还要带签名参数。用请求拦截器可以一次性解决:
dart复制class TokenInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = AuthStorage.instance.token;
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
options.headers['X-Device-Id'] = DeviceInfo.instance.deviceId;
options.queryParameters['timestamp'] = DateTime.now().millisecondsSinceEpoch.toString();
handler.next(options);
}
}
记得一定要调用handler.next(options)把请求放行。如果你不调用,请求就会被卡在这个拦截器里,既不发出去也不报错,特别难排查。同样,onResponse和onError里也要在对应逻辑结束后调用handler.next()或者handler.resolve()、handler.reject()。这是Dio拦截器最容易被新手踩的坑。
3.3 响应拦截器:统一错误码处理与数据脱壳
大多数后端接口格式是{"code": 200, "message": "ok", "data": {...}}。如果每个请求都去取data.response['data'],代码会非常啰嗦。在响应拦截器里统一"脱壳"是最优雅的解法:
dart复制class ResponseInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
if (data is Map<String, dynamic>) {
final code = data['code'];
final message = data['message'];
final body = data['data'];
if (code == 200) {
response.data = body;
handler.next(response);
} else {
handler.reject(
DioException.badResponse(
statusCode: code,
requestOptions: response.requestOptions,
response: response,
),
);
}
} else {
handler.next(response);
}
}
}
这里要注意,拦截器里用handler.reject()会走onError分支,这样可以统一收集业务错误码,比如"登录过期"可以在这里一听到401就触发全局登出逻辑,避免每个页面试着写一遍。
3.4 日志拦截器:本地调试放开,生产环境关闭body
Dio官方提供的LogInterceptor非常好用,但直接把它引进生产环境会有两个问题:一是敏感信息泄露,用户的Token、手机号、地址全被打印到了控制台;二是性能损耗,响应体很大时打印日志本身会产生不小的内存开销。我一般这么处理:
dart复制if (kDebugMode) {
dio.interceptors.add(
LogInterceptor(
request: true,
requestBody: true,
responseBody: true,
responseHeader: false,
),
);
}
kDebugMode是Flutter内置的编译期常量,在release模式下编译器会把这个分支直接优化掉,不会留下任何判断成本。另外LogInterceptor还支持自定义logPrint函数,可以把日志输出到自己的文件中,方便灰度用户回传日志分析。
4. 与SpringBoot后端联调:一次真实请求的全链路复盘
4.1 请求从Flutter到后端发生了什么
很多前端同学对网络请求的理解停留在"发出去就完了",可一旦和后端联调出问题,定位起来特别费劲。我画个文字流程来还原一次完整的请求链:
Flutter业务层调用dio.get('/user/info') → Dio包裹成RequestOptions → 经过拦截器链(加Token、加公共参数) → 生成HTTP请求 → 由Dart IO层发起TCP连接 → HTTPS握手 → 发送HTTP报文到Nginx → Nginx反向代理到SpringBoot → DispatcherServlet根据URL映射到Controller → Controller调用Service → 返回JSON → 响应再原路返回Flutter → 经过响应拦截器 →
response.data交给业务层
联调时如果要定位"接口为什么报错",你要先确认问题出在哪一段。最简单的方式是让后端看他的access log,你这边看Dio的请求日志,两边一对就知道请求到底有没有到达服务端、请求头是否完整。
4.2 Content-Type和数据格式:最常见的联调矛盾
Flutter端Dio默认发Content-Type: application/json,body是JSON字符串。SpringBoot的Controller如果方法是这样的,就能直接接收:
java复制@PostMapping("/user/update")
public Result update(@RequestBody UserUpdateRequest request) {
// ...
}
但后端如果写的是@RequestParam或直接用HttpServletRequest读参数,那需要表单格式application/x-www-form-urlencoded:
dart复制dio.post('/user/update', data: {
'nickname': nickname,
'avatar': avatarUrl,
}, options: Options(contentType: Headers.formUrlEncodedContentType));
这里有一个非常重要的注意点:Dio 5.x在传Map时,如果你指定contentType为formUrlEncoded,会自动做urlencoding编码;传String类型的话则按原样发送。所以和后端联调时,第一个要确认的就是接口到底能接收什么格式,这个确认动作往往省掉一晚上的排查时间。
4.3 中文乱码、超时、Cookies与代理问题
- 中文乱码:Dio 5.x底层用
utf8解码response body,StackOverflow上很多老答案讲的中文乱码问题在3.x之后基本不存在了。但如果你手动设置过responseType: ResponseType.bytes,那一定要自己utf8.decode(response.data),否则你会看到一个全是数字的数组。 - 超时:本地联调时后端断点调试,前端这边容易触发10秒超时报错。建议debug模式下把超时时间调长一点,release模式再用正式超时。我甚至会在Dio的BaseOptions里用
kDebugMode ? const Duration(seconds: 30) : const Duration(seconds: 10)这种条件表达式。 - Cookie与CSRF:SpringBoot的后端如果启用了Spring Security,往往会校验CSRF Token或Cookie。Flutter端需要持久化Cookie并手动在请求头带上。Dio社区有一个
dio_cookie_manager三方包可以配合cookie_jar完成Cookie的自动持久化,后面章节我会专门写。 - 代理问题:本地联调时很多人用Charles抓包。注意Dio默认不走系统代理,需要在创建Dio时手动设置
HttpOverrides.global或使用dio.httpClientAdapter自定义代理配置。抓不到包先别怀疑Dio的Bug,大概率是代理没配上。
4.4 状态码与错误类型:别把所有非200都当成异常
Dio对HTTP状态码的处理很明确:2xx和304会走onResponse,其他状态码全部走onError。这个设计初看没问题,但实际业务里很多后端返回200的情况,业务code可能不是200。上一节响应拦截器已经处理了这种"HTTP 200但业务失败"的情况。
我建议在项目里统一封装一个ApiException,把DioException转换成业务可识别的错误对象:
dart复制class ApiException implements Exception {
final int code;
final String message;
final dynamic data;
ApiException(this.code, this.message, {this.data});
factory ApiException.fromDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
return ApiException(-1, '网络连接超时,请稍后重试');
case DioExceptionType.receiveTimeout:
return ApiException(-2, '服务器响应超时,请稍后重试');
case DioExceptionType.connectionError:
return ApiException(-3, '网络异常,请检查网络连接');
case DioExceptionType.badResponse:
return ApiException(
e.response?.statusCode ?? -4,
'服务器异常(${e.response?.statusCode})',
data: e.response?.data,
);
default:
return ApiException(-5, '未知错误,请稍后重试');
}
}
}
这一点尤其重要:Dio的DioExceptionType从4.x到5.x有过一次重构,老博客里的DioErrorType.CONNECT_TIMEOUT写法在新版会报错,新代码一定要用大写开头的枚举名,比如DioExceptionType.connectionTimeout。
5. 文件上传下载:容易翻车的两个场景
5.1 上传失败排查链路:从"message:error: 上传失败:网络请求错误"说起
我群里不止一次看到有人贴这种报错日志:
code复制message:error: 上传失败:网络请求错误
(async upload fail error: 系统错误
光看这行字,客户端这边很难判断是后端返回的业务错误还是底层网络错误。我总结了一套排查链路,直接背下来用:
- 打开Dio的LogInterceptor,看请求是否发出、响应码是多少。
- 如果请求根本没发出,检查
baseUrl是否拼错、网络权限是否配置。 - 如果请求发出但响应报4xx/5xx,让后端看服务端日志,重点排查
MultipartFile的字段名是否匹配。 - 如果后端报"系统错误",先把请求体大小降下来试,往往是大文件请求把服务端的内存打爆了。
- 如果是HTTPS证书问题,错误会带
HandshakeException字样,这时候查证书链配置。
Android端还有一个高频问题:上传时遇到Cleartext HTTP traffic not permitted。这是因为Android 9+默认禁止明文HTTP请求,如果你的后端地址是http://开头,需要配置网络安全或使用HTTPS。
5.2 FormData与MultipartFile的正确姿势
文件上传的核心是构造FormData:
dart复制Future<void> uploadImage(File file) async {
final formData = FormData.fromMap({
'uploadType': 'avatar',
'description': '用户头像',
'file': await MultipartFile.fromFile(
file.path,
filename: 'avatar.jpg',
contentType: DioMediaType('image', 'jpeg'),
),
});
final response = await dio.post(
'/file/upload',
data: formData,
onSendProgress: (sent, total) {
if (total > 0) {
final progress = (sent / total * 100).toStringAsFixed(1);
debugPrint('上传进度: $progress%');
}
},
);
}
这里有个细节很多人不知道:MultipartFile.fromFile是可以传contentType的。如果后端按照文件MIME类型做校验(比如只允许image/jpeg),你不传这个参数,后端可能收到application/octet-stream后被拒绝。
onSendProgress回调里的sent单位是字节,total在特殊情况下可能是0。这时候要做除零保护,否则算出Infinity,UI展示就会出问题。
5.3 下载大文件与断点续传的落地做法
下载文件时,Dio官方建议把ResponseType设为stream,然后手动写入本地文件。原因是如果直接用默认的JSON解析,Dio会把整个响应体放到内存里,下载1GB的文件就能让App内存暴涨甚至OOM。Stream方式则是一边读一边写盘,内存占用极小:
dart复制Future<void> downloadFile(String url, String savePath) async {
final response = await dio.download(
url,
savePath,
onReceiveProgress: (received, total) {
final progress = total > 0 ? received / total * 100 : 0;
debugPrint('下载进度: ${progress.toStringAsFixed(1)}%');
},
);
}
如果要做断点续传,需要设置Options(headers: {'range': 'bytes=xxx-'}),同时后端要支持Range请求。SpringBoot里用ResponseEntity<Resource>配合HttpRange可以实现。这个功能实现起来不复杂,但对后端配合要求高,不适合所有项目。
5.4 Flutter多线程:网络请求需不需要开Isolate
很多刚学Flutter的人会问:网络请求是不是应该放到Isolate里做,免得卡UI?这里澄清一下:Dio的底层I/O本身就是异步非阻塞的,不会卡UI线程。真正需要开Isolate的是"网络返回之后的数据处理",比如下载一个几MB的JSON字符串,然后jsonDecode成几千个对象列表,这个过程是CPU密集型操作,会阻塞UI。
解决办法是用compute函数把解析工作扔到后台Isolate:
dart复制final decoded = await compute(parseUserList, response.data);
如果你的项目用Dio请求返回数组,配合compute解析大列表,这个组合拳在实际项目中能显著降低页面掉帧率。注意compute的入参和返回值都必须是可跨Isolate传递的类型,Dio的Response对象不能直接传进去,要传response.data这种基础类型。
6. 并发请求与取消操作:被低估的请求控制能力
6.1 一次发多个请求的两种正确姿势
页面首屏往往需要同时拉取多个接口的数据。Dio支持传入多个RequestOptions批量请求,也可以直接用Dart的Future.wait做组合:
dart复制// 方式一:Dio的并发请求
final responses = await Future.wait([
dio.get('/user/info'),
dio.get('/banner/list'),
dio.get('/notice/list'),
]);
// 方式二:Future.wait配合解析
final userFuture = dio.get<Map<String, dynamic>>('/user/info');
final bannerFuture = dio.get<List<dynamic>>('/banner/list');
final results = await Future.wait([userFuture, bannerFuture]);
两种方式在性能上差别不大,本质上都并发发出请求。重点要注意的是:如果其中一个请求失败,Future.wait会直接抛异常,其他请求虽然仍会返回,但你已经拿不到它们的结果。如果业务上需要"部分成功部分失败",建议用Future.wait带上eagerError: false参数,或者在每个请求单独catch错误后返回占位值。
另外,如果多个请求需要按顺序执行(比如先上传文件拿URL,再提交表单),千万不要用Future.wait,直接await第一个再发第二个就行。Future.wait是并行的,不是串行的,顺序依赖的场景用它必然出问题。
6.2 CancelToken取消请求的原理
页面销毁时,正在进行的网络请求如果不取消,会带着UI上下文继续执行,轻则浪费资源,重则因为回调里访问了已经被dispose掉的State导致崩溃。Dio的CancelToken就是专门解决这个问题的:
dart复制final cancelToken = CancelToken();
dio.get('/user/info', cancelToken: cancelToken);
// 页面dispose时取消
cancelToken.cancel('页面已销毁,取消请求');
被取消的请求会抛出DioException,它的type是DioExceptionType.cancel。在错误拦截器里要单独处理这种情况,不要弹toast提示"网络异常",因为这是用户主动取消而不是网络问题:
dart复制if (e.type == DioExceptionType.cancel) {
return;
}
一个实用建议:在StatefulWidget里把CancelToken存到State中,dispose时统一取消。如果用的是ChangeNotifier或Bloc,把CancelToken绑定到生命周期,避免"异步回调操作已释放对象"的经典崩溃。
6.3 请求去重与竞态处理
移动端还有一个容易被忽略的场景:用户快速点击两次"登录"按钮,就会发出两个一模一样的请求。后端如果没有做幂等性处理,就会产生两条重复的订单或重复的注册记录。这个问题的常规解法有两种:
第一种是按钮级防抖,用一个bool _isSubmitting标志位,请求期间禁用按钮。第二种是Dio层面的请求去重,用Map记录进行中的请求:
dart复制final _pendingRequests = <String, CancelToken>{};
Future<T> deduplicateRequest<T>(
String key,
Future<T> Function() createRequest,
) async {
if (_pendingRequests.containsKey(key)) {
throw ApiException(-100, '请勿重复提交');
}
final cancelToken = CancelToken();
_pendingRequests[key] = cancelToken;
try {
return await createRequest();
} finally {
_pendingRequests.remove(key);
}
}
这是我踩坑之后总结出来的方案。如果只是简单加按钮防抖,遇到Flutter的页面重建、事件穿透等场景还是会漏,请求层去重要可靠得多。
7. 生产环境还要处理的几件事:Cookie、证书与性能
7.1 Cookie持久化:登录状态别一重启就丢
Dio本身不存Cookie,每次请求相当于一个全新的会话。如果你的后端没有用Token机制而是用Session,那就必须实现Cookie持久化。
我推荐用dio_cookie_manager配合cookie_jar:
dart复制final cookieJar = CookieJar();
dio.interceptors.add(CookieManager(cookieJar));
CookieManager会自动从响应头里解析Set-Cookie,并在后续请求中带上。cookie_jar还支持FileStorage持久化,把Cookie写入文件,App重启后登录态还在。
7.2 HTTPS证书校验:别把App的安全边界拱手相让
默认情况下,Dio不做证书校验,这等于给中间人攻击留了后门。在安全要求高的项目里,比如金融类、支付类App,需要配置证书校验。
Dio提供了createHttpClient回调,你可以基于SecurityContext来自定义证书信任逻辑:
dart复制dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final context = SecurityContext(withTrustedRoots: false);
final certBytes = File('assets/certificate.pem').readAsBytesSync();
context.setTrustedCertificatesBytes(certBytes);
return IOHttpClient(context: context);
},
);
到这里要泼一盆冷水:证书校验配置不对,线上大概率遇到"证书验证失败"的报错。原因是证书过期、证书链不完整、用了自签名证书等,都会导致握手失败。建议先在测试环境充分验证,再做灰度发布。如果对证书体系不熟,可以先从WebSocket升级或合规的HTTPS证书开始,不要一上来就搞自定义CA。
7.3 性能优化:连接池、数据压缩与缓存
Dio底层基于HttpClient,天然支持HTTP连接池。同一个Dio实例发出的请求默认复用连接,这也是前面强调"全局单例"的原因。如果每个请求都新建Dio实例,连接池就形同虚设,握手开销会让弱网环境的体验雪上加霜。
开启GZip压缩也是常见的优化手段,前提是后端支持并返回Content-Encoding: gzip:
dart复制dio.options.headers['Accept-Encoding'] = 'gzip';
如果后端没有开启压缩,可以自己用dio_http_cache之类的缓存库做接口缓存。不过要注意,缓存请求不适合写操作和实时性要求高的接口。我的做法是只在GET类型的列表页接口上做15分钟内存缓存,下拉刷新时强制走网络。
8. 常见报错快查清单
总结一下高频报错和对应的处理思路,直接收藏这张表就行:
| 报错信息 | 可能原因 | 处理方案 |
|---|---|---|
HandshakeException |
HTTPS证书校验失败 | 检查证书有效性或配置SecurityContext |
Cleartext HTTP traffic not permitted |
Android 9+禁止明文HTTP | 改用HTTPS或配置networkSecurityConfig |
DioExceptionType.connectionTimeout |
网络连接超时 | 检查后端地址、网络环境、connectTimeout设置 |
DioExceptionType.receiveTimeout |
服务器响应慢或断了 | 检查后端耗时、调整receiveTimeout |
type 'String' is not a subtype of type 'Map<String, dynamic>' |
响应体不是JSON对象 | 检查响应拦截器脱壳逻辑或responseType配置 |
Content-Type mismatch类问题 |
提交的数据格式与后端不一致 | 确认后端是@RequestBody还是@RequestParam |
SocketException: Connection refused |
后端服务未启动 | 确认服务运行状态及端口号 |
Gradle构建报apply相关错误 |
老项目升级Flutter版本导致插件配置方式变化 | 按提示改用新的plugins DSL写法 |
9. 写在最后的经验
Dio用久了你会发现,它真正牛的地方不是"能发请求",而是把整套请求生命周期都管了起来。从拦截器到取消、进度、并发、Cookie,每个能力都刚好落在痒点上。最后分享几个我踩过坑之后养成的习惯:一,网络层永远保留LogInterceptor,release环境关闭body输出即可,排查问题时重启开关比重新打版本快太多;二,所有接口方法必须显式声明返回类型,不要到处用dynamic,否则响应拦截器重新赋值后,类型混乱会让你调到头大;三,每次升级Dio版本后先跑一遍全量回归,重点看拦截器的handler相关API有没有变动——Dio 4到5的迁移里,很多老项目就挂在Response.data类型变化上。
如果这篇文章能帮你少加几个小时的班、少挠几次头,我觉得就没有白写。后续还有兴趣的话,我可以再写一篇Dio搭配Riverpod或Bloc做优雅状态管理的实战方案,那种"网络层+状态层"的组合拳打出来,整个项目的代码结构会舒服很多。
