Flutter开发鸿蒙应用聊到网络,十个群里九个会问dio怎么配。前面两篇我们把开发环境、工程骨架和基础组件都过了一遍,这篇直接进入正题:用dio把网络请求跑通,并且能应对鉴权、超时、取消、上传下载这些真实场景。内容不光是贴代码,我会把鸿蒙上特有的权限配置、明文HTTP限制、证书校验问题一起讲清楚,照着敲完,你App里的网络层就算立住了。
先给结论:dio在鸿蒙的Flutter运行时里可以直接用,不用为ohos单独写一套原生请求代码。原因是OpenHarmony的Flutter引擎把dart:io的socket和http能力映射到了系统网络栈,dio默认的IO适配器能正常工作。但这不代表没有坑——鸿蒙的权限模型、网络安全策略和Android、iOS都不一样,很多写好的网络代码一跑到真机上就报奇奇怪怪的错误。这篇指南的核心价值,就是把这些坑提前给你填上。如果你已经看过前两篇,可以直接照着抄;哪怕你是第一次做鸿蒙Flutter开发,只要把每一步的原因看懂,也能顺顺利利跑通。
1. 先搞明白:dio在鸿蒙上到底是怎么干活的
1.1 不需要为鸿蒙重写一套网络层
很多从Android转过来做鸿蒙Flutter开发的朋友,第一反应是:鸿蒙的网络请求是不是要用自家的@ohos.net.http?其实不用。Flutter应用跑在鸿蒙上时,Dart虚拟机里的dart:io网络层已经由OpenHarmony的Flutter引擎重新实现并映射到系统网络能力上。也就是说,你在Dart侧写的HttpClient、Socket、dio这些代码,最终会走鸿蒙系统的网络栈,但对外API完全一致。
这意味着什么?意味着你可以把Android和iOS上已经调通的dio封装直接搬到鸿蒙工程里,业务层代码一行不用改。我最早就是从现有Flutter项目复制network层代码到鸿蒙工程,只改了module.json5和两处安全配置,其余全部原样跑通。这个跨端复用能力,是Flutter做鸿蒙开发最大的价值——同样一套登录、拉列表、上传图片的代码,维护一套就够了,不用每个平台各写一份。
dio这个库在Dart生态里做网络请求,地位基本相当于Android里的OkHttp加Retrofit的组合。它自带拦截器、取消请求、表单上传、文件下载、请求进度这些高频能力,配合Flutter自身不提供重量级网络库的现状,dio几乎成了Flutter网络层的默认选择。在鸿蒙的Flutter运行时里,dio底层走dart:io的adapter,所以dio 4.x和5.x在适配版Flutter上都能正常解析、连接、收发数据。
1.2 为什么选dio,而不是http或原生HttpClient
如果你只写一个几行代码的demo,用官方轻量级http包确实够用。但一旦进入真实项目,很快会遇到几个痛点:每个请求都要手动拼Header;Token过期时每个页面的请求都要处理一遍401;上传大文件时UI进度条无从下手;用户切走了还不想浪费流量,得支持取消。这几个需求,http包和Dart自带的HttpClient都做得不够彻底,dio却是一等公民。
| 能力 | dio | http | dart:io HttpClient |
|---|---|---|---|
| 拦截器 | 有,支持异步链式处理 | 无 | 无 |
| 请求取消 | CancelToken,可批量取消 | 无 | 有但使用繁琐 |
| 上传/下载进度 | 原生回调 | 需手写Stream | 需手写Stream |
| BaseUrl等全局配置 | 支持 | 需自己封装 | 需自己封装 |
| 响应自动转换 | json/formData/bytes | 需手写 | 需手写 |
| 社区生态 | 非常活跃,问题好搜 | 一般 | 官方底层 |
dio还有一个对我很有吸引力的点:它在5.x版本把错误类型统一成了DioException,超时、连接失败、证书错误、取消操作都做了清晰的枚举分类,配合拦截器可以集中处理。做鸿蒙开发时,真机环境千奇百怪,比如网络切换导致的连接重置、证书校验失败、明文流量被拦截,这类错误分类能让线上问题的定位效率高很多。基本上,一个稍微认真一点的Flutter鸿蒙项目,第一版网络层就值得直接上dio,而不是先用http包再中途迁移。
1.3 项目里什么时候需要复杂的网络封装
我说一个简单的判断标准。如果你的应用只有两三个接口、只做GET请求、不求长期维护,那随便写写都行。但只要出现下面任何一种情况,就该把网络层认真封装起来:
- 需要登录,所有接口都要带Token;
- 后端有统一返回结构,例如{code, message, data};
- 用户可能断网、弱网、换网络,需要超时与重试;
- 有文件上传、下载或图片加载的需求。
对照一下,绝大多数商业项目至少命中两条。所以接下来的封装方案,我按一个中等复杂度业务App的标准来做:全局Dio实例管配置,拦截器管鉴权和统一解包,CancelToken管页面级取消,这样写到第四个页面时你就知道这些设计有多值钱了。而且这套结构从第一行代码就固定下来,后面每个人照着写,不会出现一个项目里十种网络请求写法。
2. 工程准备:三步让dio在鸿蒙真机上跑出第一个请求
2.1 确认你的Flutter SDK支持ohos平台
先说个很多新手踩过的坑:官方Flutter SDK现在还没有ohos这个target,必须使用OpenHarmony社区维护的适配版Flutter SDK。判断方法很简单,跑一下:
flutter --version flutter devices如果当前SDK支持鸿蒙,flutter devices在连接鸿蒙设备或打开模拟器后,会列出对应设备;创建工程时也能用到--platforms ohos参数:
flutter create --platforms ohos .执行完这条命令,工程根目录会出现一个ohos/目录,里面是HAP工程的结构,后续鸿蒙侧的module.json5、资源配置都在这里改。如果你的工程是在旧版本上创建的,也可以用同样的命令把ohos平台补进来。这一步没做对,后面dio配置得再完美也装不到设备上。这一篇是网络请求专题,SDK怎么装、hdc怎么连设备就不展开了,但你至少要能通过flutter run把空页面跑上鸿蒙真机或模拟器,环境不通,谈网络层没有意义。
2.2 添加dio依赖
在pubspec.yaml的dependencies区域加一行即可:
dependencies: flutter: sdk: flutter dio: ^5.4.0然后执行:
flutter pub get为什么建议用5.x而不是4.x?因为5.x的DioException命名、Duration类型的超时参数,和Dart 3的新语法配合更顺,官方也早已停更4.x。如果你是从老项目迁移,注意4.x用的DioError在5.x里已经统一改为DioException,不替换会导致编译报错。这个迁移坑,后面会专门讲。
2.3 给鸿蒙应用声明网络权限
这是鸿蒙最容易忽略的一步。鸿蒙应用打包成HAP后,对系统能力的访问必须显式声明。网络请求需要INTERNET权限,打开ohos/entry/src/main/module.json5,在module节点下添加requestPermissions:
{ "module": { "name": "entry", "type": "entry", "srcEntry": "src/main/ets/entryability/EntryAbility.ts", "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }添加位置一定是module节点的直接子节点,不是abilities里面。改完module.json5后,需要重新构建HAP才能生效,单纯flutter pub get不会帮你处理原生侧配置。如果你在真机上发现请求一直超时,但同一个WiFi下电脑上接口秒开,十有八九就是权限没加或者加错了位置。
提示:INTERNET属于normal级别权限,不需要用户在运行时授权,声明即生效。但如果你后续要读取网络状态做弱网提示,还需要额外声明ohos.permission.GET_NETWORK_INFO,这里先记住。
2.4 跑通第一个GET请求
最简场景不用做任何封装,先验证环境通不通。下面这段代码,我建议你放进一个测试页面里跑:
import 'package:dio/dio.dart'; final dio = Dio(); Future<void> fetchTest() async { try { final response = await dio.get('https://httpbin.org/get'); // ignore: avoid_print print(response.data); } on DioException catch (e) { // ignore: avoid_print print('请求失败: ${e.type} -> ${e.message}'); } }这段代码的逻辑很简单:创建一个Dio实例,发一个GET请求,成功打印响应,失败打印DioException的类型和消息。重点在失败分支——不要只打印message,打印e.type更容易定位问题。比如它告诉你的是connectionTimeout还是connectionError,排查方向完全不同。如果这一步能在鸿蒙真机上打印出响应数据,说明dart:io的socket通道在鸿蒙运行时里没问题,权限也正确,接下来可以放心做架构层的事情。如果失败了,对照文末的排查表,绝大多数问题都出在网络权限、明文HTTP限制、或者设备本身没连上网络三件事上。
3. 脱离demo:把dio封装成生产可用的网络层
3.1 全局单例与BaseOptions统一配置
项目稍微一复杂,绝对不要在每个页面new一个Dio。原因有三:连接池没法复用,性能差;每个实例都要重新配一遍BaseOptions,容易漏;拦截器逻辑散落各处,后来的人维护成本极高。正确做法是建一个全局Dio单例,集中管理配置。
final Dio dio = Dio( BaseOptions( baseUrl: 'https://your-api.example.com', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), sendTimeout: const Duration(seconds: 10), headers: { 'Content-Type': 'application/json', }, ), );几个配置项的取值逻辑我说一下。connectTimeout是建立TCP连接的超时,一般10秒足够,弱网环境再长用户体验也很差;receiveTimeout是两次数据包间隔的超时,注意不是整个请求的总时长,比如下载大文件时这个值可以设大;sendTimeout对普通小请求影响不大,上传文件时有必要单独设置。如果你有某个请求需要特殊超时,可以在这个Dio实例的基础上,通过Options临时覆盖,不用改全局配置。
3.2 拦截器:请求头、响应解包、错误处理一网打尽
dio的拦截器是它最核心的竞争力,几乎就是为生产环境设计的。三个钩子分别处理请求前、响应后、出错后三个阶段。我通常按职责拆成多个拦截器,而不是写成一个巨无霸。
第一个是请求头拦截器,负责在每次请求前把Token塞进Header:
class AuthInterceptor extends InterceptorsWrapper { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { final token = TokenManager.getToken(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } }Token从哪来不是这里的关键,关键是你要理解handler.next(options)这一步:调用next表示放行,请求继续往下走;如果调用handler.reject(...),请求会被终止并进入错误链路。对Token为空的场景,你可以next,让后端去拒绝;也可以在本地直接reject,省一次网络往返。
第二个是响应解包拦截器。很多后端返回格式是{code: 0, message: "ok", data: {...}}。与其每个页面都判一遍code,不如在拦截器里统一处理:
dio.interceptors.add( InterceptorsWrapper( onResponse: (response, handler) { final body = response.data; if (body is Map && body['code'] != null && body['code'] != 0) { handler.reject( DioException( requestOptions: response.requestOptions, message: body['message'] ?? '业务错误', ), ); return; } response.data = body is Map ? body['data'] : body; handler.next(response); }, onError: (e, handler) { if (e.type == DioExceptionType.badResponse) { // 这里可以做401跳登录、弹Toast等操作 } handler.next(e); }, ), );这个拦截器的价值在于:业务层拿到的response.data已经是干净的data字段,不用每个页面重复判断code;而业务错误被转换成了DioException,异常链路统一管理。做鸿蒙项目时,尤其是团队从别的平台带过来的,这种统一规范能极大减少各写各的代码风格。
提示:如果用handler.reject抛业务错误,一定要把response.data带进DioException,否则后续排查时看不到实际返回内容。另外,onError里最后一个handler.next(e)必须调用,否则错误不会传递到调用方的catch块,页面会一直卡在await上。
3.3 超时、取消和请求去重
超时配置前面讲过了,这里重点说取消。Flutter页面在返回时,如果还在等网络响应,不做任何处理的话,最常见的问题就是请求回来之后setState一个已经dispose的State,直接抛异常。用CancelToken可以优雅解决:
class SearchPage extends StatefulWidget { @override State<SearchPage> createState() => _SearchPageState(); } class _SearchPageState extends State<SearchPage> { final CancelToken _cancelToken = CancelToken(); @override void dispose() { _cancelToken.cancel('页面销毁,取消请求'); super.dispose(); } Future<void> _search(String keyword) async { try { final resp = await dio.get( '/search', queryParameters: {'q': keyword}, cancelToken: _cancelToken, ); // 更新UI } on DioException catch (e) { if (CancelToken.isCancel(e)) { return; // 是主动取消,不用处理 } // 真正异常的处理 } } }还有一个高频场景是搜索框输入防抖加取消上一次请求。用户在文本框里连续输入,每次都发请求,前面的慢响应回来反而覆盖了后面新的结果。我的做法是:每次输入变化时,先用cancelToken.cancel掉上一次请求,再发新请求。旧请求的catch分支通过CancelToken.isCancel判断直接return,页面逻辑非常干净。这套组合在鸿蒙上实测稳定,推荐直接抄。
3.4 文件上传与下载的鸿蒙注意事项
上传文件是网络层里比较烦的部分,dio的FormData把它简化了不少:
final formData = FormData.fromMap({ 'description': '封面图', 'file': await MultipartFile.fromFile('/path/to/your/file.jpg'), }); final response = await dio.post('/upload', data: formData);这里真正的问题不在dio,而在“文件路径从哪来”。鸿蒙的应用沙箱隔离比Android更严格,你随便写一个/sdcard/xxx.jpg在真机上大概率拿不到。我踩过的坑是:用系统相册或者文件选择器拿到的是一个uri,而不是Dart侧能直接打开的文件路径,需要先通过鸿蒙提供的文件访问能力把uri转换成沙箱内的可读路径。具体转换方式会随API版本变化,但你只要记住“不要硬编码绝对路径”这个大原则,就少踩一半的坑。
下载文件的代码也很直接:
await dio.download( 'https://your-api.example.com/file.zip', '/data/storage/el2/base/haps/entry/files/download.zip', onReceiveProgress: (received, total) { // 更新下载进度条 }, );下载路径同样要在应用沙箱目录内。鸿蒙应用获取沙箱路径,可以在原生侧通过getContext().filesDir之类的接口拿到再传给Flutter,或者用支持鸿蒙的path_provider适配版。下载要比上传多考虑一件事:断点续传。dio的download本身不内置断点续传,需要结合Range头自己实现。如果你的产品有下载大文件需求,这块建议单独开一篇来讲,别把下载代码和普通请求堆在一起。上传方面尤其要提醒:如果是大文件,记得看返回的错误类型是连接超时还是服务端拒绝,前者要调大sendTimeout,后者往往是后端对文件大小有限制。
4. 鸿蒙上最容易踩的网络坑
4.1 明文HTTP请求被安全策略拦截
这是我在鸿蒙调试时遇到过最迷惑的问题。接口地址是http://,电脑浏览器打开正常,安卓上跑也正常,一到鸿蒙真机就报错,日志里写着CLEARTEXT communication to xxx not permitted。原因很简单:鸿蒙的网络安全配置默认禁止明文HTTP流量,强制要求应用使用HTTPS,这是从系统层面提升安全性。
开发阶段要验证内网接口,可以临时放开明文限制。鸿蒙提供了网络安全配置文件,在module.json5的metadata节点里指定networkSecurityConfig指向一个json文件,在里面把base-config的cleartext-traffic-permitted设为true。不同API版本的配置字段名略有差异,动手前先查一下你当前SDK版本的官方文档。我给你的建议是:调通后立刻改回HTTPS。明文HTTP在公网环境下等于裸奔,用户数据随便被中间人抓取,任何负责任的应用都不该为了开发省事把它留在生产包。如果是因为后端没有HTTPS证书,推到生产前把证书配上才是正路。
4.2 自签名证书与内网测试环境
内网开发经常用自签名HTTPS证书,dio默认会验证证书链,遇到不受信任的证书直接抛出badCertificate错误。很多人的第一反应是全局关闭证书校验,我强烈不建议这么做——这等于把应用的传输层安全全部关掉,一旦线上包也带着这个配置,后果不堪设想。如果你只是在联调阶段访问内网测试机,可以用一个临时的HttpClient适配器,只对指定host放行:
import 'dart:io'; import 'package:dio/io.dart'; (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { final client = HttpClient(); client.badCertificateCallback = (cert, host, port) { return host == 'your.internal.host'; }; return client; };这段代码的意思非常明确:只有证书校验失败的host是内网测试机时才放行,其他host一概严格校验。等到正式环境时把这段配置去掉,代码里不留任何后门。如果你连badCertificateCallback都用不惯,另一个思路是直接把内网证书安装到鸿蒙设备信任区,但这种方式在团队协作时比较难管理,不如上面的方案可控。
4.3 页面销毁后的setState炸弹
Flutter里的经典问题,网络请求在返回后调setState,但页面已经pop掉了。鸿蒙上有一个更隐蔽的触发场景:用户把应用切到后台,系统可能对网络连接做一些限制或挂起,等恢复前台时请求才回来,此时页面状态可能已经重建。我遇到过一次,日志报“setState() called after dispose()”,排查了半天,最后发现是页面销毁时没取消请求。
解决方案就是不依赖玄学,统一规矩:所有页面级请求必须持有自己的CancelToken,在dispose()里cancel。不要图省事直接用全局CancelToken,因为A页面的取消会把B页面的请求也带崩。再稳一点的方案是在页面里定义一个_mounted标识,await返回后先判断再setState,双保险。对于鸿蒙这种多任务切换比较频繁的系统,这层防护不是可选项,是必选项。
4.4 弱网、断网和网络切换时的表现
鸿蒙设备在WiFi和蜂窝网络之间切换,或者从弱网恢复到正常网络时,已经建立的TCP连接经常不能复用,dio会报SocketException或者connectionError。这时候如果直接给用户弹“网络错误”,体验很不好。我建议对幂等请求(比如GET列表)做一层自动重试:
dio.interceptors.add( InterceptorsWrapper( onError: (e, handler) async { if (e.type == DioExceptionType.connectionError && e.requestOptions.extra['retryCount'] == null) { await Future.delayed(const Duration(milliseconds: 500)); e.requestOptions.extra['retryCount'] = 1; try { final response = await dio.fetch(e.requestOptions); handler.resolve(response); return; } catch (_) {} } handler.next(e); }, ), );重试不是越多越好。我一般只重试一次,间隔500毫秒到1秒,目的就是应对网络切换瞬间的抖动。POST这种非幂等请求,重试前一定要想清楚会不会导致重复下单、重复扣款,宁可让用户手动再点一次按钮,也不能拿业务安全换体验。在鸿蒙的Flutter开发里,这个“网络切换重试”的场景确实比Android更容易触发,值得在架构期就预留好。
5. 问题排查速查与我的调试习惯
5.1 常见错误类型对照表
把dio的DioException类型和实际含义做成了一张速查表,遇到问题先看它属于哪一类,再决定怎么处理。
| DioException类型 | 常见原因 | 处理思路 |
|---|---|---|
| connectionTimeout | TCP连接建立超时 | 检查网络权限、设备联网状态;适当调大connectTimeout |
| sendTimeout | 数据发送超时 | 常见于上传大文件;调大sendTimeout或检查服务端接收能力 |
| receiveTimeout | 响应间隔超时 | 服务端响应太慢;对于下载任务可单独调大 |
| connectionError | 连接被重置、网络切换、socket异常 | 检查网络稳定性;幂等请求可做一次重试 |
| badCertificate | 证书过期、自签名、证书链不完整 | 测试环境按host放行,生产环境必须修复证书 |
| badResponse | 服务端返回4xx/5xx | 打印statusCode和响应体,和后端联调 |
| cancel | 主动取消或页面销毁取消 | 用CancelToken.isCancel判断,静默处理 |
| unknown | 其他未知错误 | 打全日志,先复现再定位 |
这张表看着简单,但解决了我一堆实际问题。以前遇到网络错误,都是瞎试,不是改后端就是重启设备。后来改成先判断e.type再行动,效率高了几倍。你可以把它贴在项目Wiki里,团队新人排查问题时直接按图索骥。
5.2 定位网络问题的标准步骤
我排查鸿蒙Flutter网络问题的固定流程,按顺序执行,基本没有漏过问题的:
- 先用设备自带浏览器或电脑在同一网络下访问目标接口,确认服务端本身没挂;
- 打开dio的LogInterceptor,或者在onError里打印e.type和完整message;
- 看日志区分是DNS解析失败、TCP连接失败、TLS握手失败还是不合理的HTTP响应;
- 对应检查:DNS问题查域名配置,TCP问题查网络权限和防火墙,TLS问题查证书,HTTP响应问题查后端逻辑;
- 如果权限和网络都正常,再检查是不是鸿蒙的网络安全策略拦了明文HTTP。
第2步是关键。很多人上来就翻业务代码,其实网络层错误最值钱的信息都在DioException类型和底层message里。我在鸿蒙上遇到过好几次“上传失败”的报错,点开message才看到原因是sendTimeout,根本不是业务问题,是接口在大文件面前太慢了。这个习惯帮我省了无数次跟后端吵架的机会。
5.3 两个让我少走很多弯路的小习惯
最后分享两个我从项目里沉淀下来的习惯。第一个:debug模式必开LogInterceptor。在Dio实例上加上dio.interceptors.add(LogInterceptor(responseBody: true)),上线前再在构建配置里关掉。开发阶段它能帮你看到完整的请求URL、Header、响应体和耗时,遇到问题根本不需要瞎猜。鸿蒙的日志输出位置和Android不太一样,但LogInterceptor在Flutter侧输出,DevTools和命令行都能看到。
第二个:封装一个统一的请求入口函数,而不是在页面里直接调用dio.get。比如叫apiGet<T>(String path, {Map<String, dynamic>? query}),统一处理loading、成功、失败三种状态,页面里代码量最少,出错的概率也最低。自己封装的时候记住一件事:不要在函数里循环调DioException打印日志,日志会打得很乱;把关键信息返回给页面,页面决定怎么展示。这两个习惯都不复杂,但它们把网络层的使用规范固定在了代码结构里,团队里新手来了照着写就行,这也算是我在被无数个混乱的请求代码折磨之后总结出来的教训。