这两年做 Flutter 跨端,筛过不少轮子,最让我纠结的永远是网络层。尤其是项目要落到鸿蒙上跑 Flutter,dio 的三方适配器dio_web_adapter一下就变成了绕不开的实战重点。很多朋友第一反应是“鸿蒙不是兼容 Android 吗?直接上dio_http_adapter不就行了?”但真的在真机上跑一遍,你会发现 WebView 环境、跨域策略、Cookie 透传、请求拦截这几座大山,每座都能卡掉一两天工期。这篇内容就是把我自己从零适配dio_web_adapter到鸿蒙的全过程、踩坑记录和最终解决方案整理出来,适合正在做 Flutter 鸿蒙化、或者被浏览器/WebView 环境网络问题折磨的移动端同学参考,尤其是涉及跨域拦截、请求穿透这类细节的场景,可以直接照着改。
1. 为什么鸿蒙上要单独聊 dio_web_adapter
1.1 dio 适配器的分层逻辑——先搞清楚 adapter 到底做了什么
很多 Flutter 开发者天天用 dio,却很少打开它的源码看一次。dio 本身并不是网络请求的真正执行者,它只负责把BaseOptions、RequestOptions、拦截器、取消令牌这些上层逻辑整理好,真正发出 HTTP 请求的动作,全部委托给httpClientAdapter完成。这个 adapter 就是真正的“运输队”,而 dio 只是“调度中心”。
默认情况下,dio 在移动端用的是IOHttpClientAdapter,它背后是dart:io的HttpClient,走的是标准 Socket 链路。在 Android、iOS 上这套链路非常成熟,几乎不用操心。但在鸿蒙场景下,问题来了:dart:io的HttpClient在部分鸿蒙 Flutter 运行时里并不是完整的实现,或者因为权限、代理设置、证书校验策略等原因表现得很不稳定。我实测过的表现包括:请求发出后长时间不回调、onHttpClientCreate里自定义的证书校验不生效、偶发直接抛出UnimplementedError。这些故障还不是每次必现,排查起来极其折磨。
dio_web_adapter提供的BrowserHttpClientAdapter则完全是另一条链路。它不依赖dart:io,而是把请求转换成 Web 环境下的HttpRequest(底层是 XHR/fetch),由 WebView 或浏览器的网络栈真正发出请求。如果鸿蒙上的 Flutter 容器本身提供了可用的 Web 运行时能力,那么这条路比硬啃dart:io要稳得多。
1.2 鸿蒙环境的特殊性:网络栈、WebView 与沙箱限制
鸿蒙不是 Android,也不是 iOS,它是一个独立的操作系统。虽然早期版本通过兼容层可以跑 APK,但如果你做的是 HarmonyOS NEXT 或基于 OpenHarmony 的 Flutter 应用,就要面对一套全新的运行时约束。最直接影响网络层的有三点。
第一,系统网络权限管控严格。普通 Android 需要在AndroidManifest.xml里加INTERNET权限,鸿蒙则要在module.json5里声明ohos.permission.INTERNET,漏了就直接断网,而且有时候编译不报错,运行到真机上请求全失败,非常隐蔽。
第二,WebView 环境的同源策略和 CORS 约束依然生效。如果你在 Flutter 页面内嵌了 ArkWeb 组件,或者你的 Flutter 容器本身活动在 Web 兼容层上,那么发出去的请求必须遵守跨域规则。dio 的拦截器可以对业务层做管理,但浏览器内核层面对跨域请求的拦截不受 Dart 代码控制,只能通过服务端响应头、withCredentials配置、预检请求(preflight)等方式去“对齐规则”。
第三,Cookie 管理机制不同。在原生 Android 上,Cookie 由系统级CookieManager统一管理。在鸿蒙的 Web 环境下,Cookie 的存取行为和浏览器保持一致,但跨会话持久化未必自动完成。稍后我会给出针对性的持久化方案。
1.3 判断依据:什么时候必须切换到 Web 适配器
不是所有鸿蒙项目都需要切到dio_web_adapter,但下面这些信号只要命中任意一条,你就应该认真考虑切换:
- 项目在鸿蒙真机调试时出现了
UnimplementedError、HttpClient is not implemented这类运行时异常; - 你的页面以内嵌 WebView 为主,Flutter 侧的请求需要和 H5 页面共享 Cookie、鉴权体系;
- 网络请求需要借助 Web 运行时来“穿透”某些沙箱限制,走通标准链路;
- 需要精确控制跨域请求的凭据携带、预检行为,而原生 adapter 暴露不了这层能力。
简单说,当dart:io链路在鸿蒙上“使不上劲”时,dio_web_adapter就成为了那个最贴近底层、最可控的替代方案。
2. 适配前的工程准备:依赖、权限与运行时判断
2.1 梳理依赖:不是简单替换一个包
如果你以为把dio_http_adapter换成dio_web_adapter就完事了,那后面会踩很多坑。dio_web_adapter的BrowserHttpClientAdapter依赖 Web 标准 API,在纯 Dart VM 环境(比如鸿蒙原生线程)中直接使用会出问题。所以第一步是确认自己的项目是否满足使用 Web adapter 的前置条件。
在实际项目中,我的做法是把可运行平台明确写进条件判断里,而不是硬编码。依赖方面,在pubspec.yaml里确保有这些:
dependencies: dio: ^5.4.0 dio_web_adapter: ^1.0.0然后创建一个统一的适配器工厂,让不同平台各取所需。这里注意:鸿蒙设备上不要简单粗暴地通过Platform.isAndroid之类的判断走原逻辑,因为鸿蒙系统也会在某些场景下让这个判断结果变得模棱两可。更稳妥的做法是用kIsWeb结合运行时能力检测,再加上一个可手动覆盖的开关。
import 'package:flutter/foundation.dart'; import 'package:dio/dio.dart'; import 'package:dio_web_adapter/dio_web_adapter.dart'; HttpClientAdapter createAdapter() { // 鸿蒙项目里如果 Web 运行时可用,优先走 Browser adapter // 其他原生平台继续走 IOHttpClientAdapter,保证稳定性 if (kIsWeb || isHarmonyWebRuntime) { return BrowserHttpClientAdapter(withCredentials: true); } return IOHttpClientAdapter(); }这里的isHarmonyWebRuntime不是官方 API,而是我在入口处做的一个运行时探针。比如通过状态通道从原生侧读一次系统版本号,或者尝试访问 Web 运行时提供的对象来判断。核心原则是“先探测,再决策”,不要在还不确定环境时就初始化 adapter。
2.2 module.json5 里的网络权限与安全策略
鸿蒙的权限声明位置和 Android 不一样。找到你的工程的entry/src/main/module.json5,在module节点下增加请求权限:
{ "module": { "name": "entry", "type": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET", "reason": "需要访问网络数据", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } } ] } }如果漏了这一步,dio 发出的请求会在最底层直接失败,而 Dart 侧拿到的错误往往是泛化的 SocketException 或网络异常,很难一眼定位是权限问题。我在第一次真机调试时就白白花了半天排查这个问题。
还要注意明文流量。鸿蒙跟 Android 9 之后的策略类似,默认情况下对明文 HTTP 请求是限制的。如果你的后端接口还有http://的,需要在网络安全配置里放行。具体位置可能在resources/rawfile/network_security_config.json,然后在module.json5的安全配置里引用它,或者在调试阶段临时允许明文流量。这个点虽然不是dio_web_adapter本身的问题,但不提前处理,适配做完依然还是连不通。
2.3 运行环境自检:把故障提前暴露在启动阶段
适配器切换完之后,我建议把网络链路的自检放在应用启动阶段,而不是等用户点击某个功能时才慢慢报错。一个很实用的做法是:启动后用一个最小化的 dio 实例请求一个静态接口,比如GET https://www.example.com/health,把耗时、状态码、响应头打印到日志里。这一步能快速区分“适配层问题”和“业务层问题”。
我在日志里额外打印了 adapter 的类型、是否走 Web 链路、浏览器内核的 userAgent 信息。这些信息一旦出现问题,能帮你快速缩小范围。另外,Flutter 鸿蒙化环境下日志输出不一定走标准debugPrint,有时需要配合hdc shell hilog才能看到完整 Dart 日志。提前把这些检查项固化下来,后面排障会舒服很多。
3. 鸿蒙化适配实战:从挂载 adapter 到跨域拦截
3.1 第一步:最小化跑通一个 GET 请求
不要一上来就铺开所有接口。先用一个最简单的 dio 实例做验证,把复杂拦截器和业务逻辑全部去掉。代码大致长这样:
import 'package:dio/dio.dart'; import 'package:dio_web_adapter/dio_web_adapter.dart'; Future<void> smokeTest() async { final dio = Dio(BaseOptions( baseUrl: 'https://api.example.com', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )); dio.httpClientAdapter = BrowserHttpClientAdapter(withCredentials: true); try { final response = await dio.get('/ping'); debugPrint('smoke success: ${response.statusCode}'); } catch (e) { debugPrint('smoke failed: $e'); } }这里有一个容易被忽略的关键参数:withCredentials: true。它决定了跨域请求是否携带 Cookie、TLS 证书等凭据信息。如果你在鸿蒙 Web 环境里要复用 H5 登录态,这一项必须设为true。但它也带来一个副作用:服务端的Access-Control-Allow-Origin不能再返回*,必须返回具体的源,否则浏览器会因为安全策略直接拦截响应。也就是说,withCredentials打开了跨域凭据通道,那服务端 CORS 策略也必须跟着精调。
用最小请求确认整条链路通之后,才好进入下一步。
3.2 第二步:把 adapter 注入正式网络层,用拦截器统一处理请求与响应
在实际项目里,网络层一般都有一个单例 Dio 封装,类似下面这种结构。这里建议借鉴 Flutter 里使用 Provider 管理状态时的思路:把依赖统一在入口处注入,而不是在业务页面里到处 new Dio。adapter 也一样,只初始化一次,全局共用。
class ApiClient { ApiClient._() { dio = Dio(BaseOptions( baseUrl: 'https://api.example.com', headers: {'Content-Type': 'application/json'}, )); dio.httpClientAdapter = createAdapter(); dio.interceptors.add(_buildInterceptors()); } static final ApiClient instance = ApiClient._(); late final Dio dio; Interceptor _buildInterceptors() { return InterceptorsWrapper( onRequest: (options, handler) { final token = storage.read('token'); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } options.headers['X-Platform'] = 'harmony'; handler.next(options); }, onResponse: (response, handler) { handler.next(response); }, onError: (DioException e, handler) { handler.next(e); }, ); } }拦截器是理解 dio 体系的关键。onRequest阶段可以用来统一加签名、加 token、改写请求头;onResponse阶段用来做全局响应解包;onError阶段做统一错误上报。跨域拦截的“拦截”二字,在这个阶段体现为:你可以拦截请求、修改它、放行或终止它,但要注意这属于应用层的拦截,浏览器内核层面的 CORS 拦截只能靠配置去对齐,不能靠 Dart 代码强行绕过。
3.3 第三步:Cookie 与登录态跨会话持久化
鸿蒙 Web 运行时环境下,Cookie 的存取行为和浏览器内核强相关。但一个很现实的问题是:BrowserHttpClientAdapter并不会自动把响应里的Set-Cookie帮你持久化到磁盘。如果你的 App 重启后希望登录态还在,就得自己处理。
有一个比较可落地的手动方案:用shared_preferences或者鸿蒙侧的 Preferences 能力,在响应拦截器里读取set-cookie头,解析出关键 Cookie 字段,然后持久化;下次启动时在请求拦截器里拼装进Cookie请求头。
onResponse: (response, handler) async { final setCookies = response.headers['set-cookie']; if (setCookies != null) { for (final rawCookie in setCookies) { // 只保存 name=value 部分,Expires、Path、Secure 等属性单独按需处理 final cookiePair = rawCookie.split(';').first; await cookieStorage.save(cookiePair); } } handler.next(response); }这一步看起来很绕,但实际价值非常大。因为当你的 Flutter 页面和 H5 页面共存在鸿蒙容器里时,只要 Cookie 能够统一读写,两边登录态就是打通的。用这个方案,我成功让 Flutter 业务和 H5 业务做到了免二次登录,体验和原生一模一样。至于 Cookie 里的HttpOnly字段,虽然从 Dart 侧读不到,但 WebView 内核自己会维护,前提是请求确实通过 Web 链路发出。
3.4 第四步:跨域拦截与 preflight 预检请求的精细控制
这是标题里“跨域拦截实战”真正要展开的地方。在鸿蒙 Web 环境里,只要请求的目标源和当前页面源不一致,就可能触发 CORS 预检。预检通常是浏览器自动发起的OPTIONS请求,Dart 代码里看不到这个请求,但服务端必须正确处理。
跨域请求能否成功,取决于三件事:
- 请求头里有没有自定义头、Content-Type 是否是非简单类型;
- 服务端是否正确返回
Access-Control-Allow-Origin、Access-Control-Allow-Headers、Access-Control-Allow-Methods; withCredentials为true时,服务端是否允许携带凭据并返回具体源。
实际操作中,我见过太多的后端同学只配了一个Access-Control-Allow-Origin: *,结果前端一旦加上 Authorization 头就开始报 CORS 错误。正确做法是后端根据请求头动态返回允许的源,或者至少在预检请求里准确返回Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With。
如果你自己掌控服务端,可以用下面这个精简示例作为跨域策略的参考:
Access-Control-Allow-Origin: https://your-harmony-app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, X-Platform Access-Control-Allow-Credentials: true Access-Control-Max-Age: 86400Access-Control-Max-Age是减少预检次数的重要参数。如果在鸿蒙 Web 环境里发现每一次 POST 请求都要多一次OPTIONS请求,那就是这个缓存时间没设置好,合理设置之后能明显减少网络往返。
3.5 第五步:上传下载与 FormData 在 Web 链路上的差异
文件上传和下载也是适配中的重灾区。在原生 adapter 下,FormData走的是标准 HTTP multipart 协议,到了 Web 链路,BrowserHttpClientAdapter内部会把FormData转成 Web 环境能识别的格式。大体上能用,但有几个细节要注意。
上传文件时,如果你传入的是文件路径字符串,在原生环境没问题,在 Web 环境可能会直接失败。因为 Web 运行时根本没有“本地文件路径”这个概念,你需要先把文件转成blob或字节数组。Flutter 侧用http_parser包里的MultipartFile.fromBytes会更稳妥。
下载文件也一样。Web 链路拿到的ResponseBody可能是以内存流形式存在的,你不能像原生环境那样直接落盘到一个路径。需要把字节取出来,再通过鸿蒙的文件管理能力写入应用沙盒。我在项目里的做法是:统一封装一个saveBytesToHarmony(bytes, filename)方法,底层调用系统的文件保存 API,这样上层业务不用关心当前跑在哪条链路上。
4. 常见问题与排障实录
4.1 高频报错与解决方案速查
这里我把适配鸿蒙过程中出现频率最高的几个问题整理成了速查表,每一条都是我至少踩过一次、真实解决了之后才敢写进来的。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 启动后所有请求立刻失败,错误为 SocketException | module.json5缺少网络权限,或明文流量被拦截 | 检查ohos.permission.INTERNET,配置网络安全策略放行调试域名 |
| 请求能发出去,但响应永远被浏览器拦截,报 CORS error | 服务端Access-Control-Allow-Origin配置不完整,或和withCredentials冲突 | 服务端改返回具体源,补齐 Allow-Headers、Allow-Methods、Credentials |
每次 POST 都多出现一个OPTIONS请求 | 自定义请求头触发 preflight,且未设置缓存 | 服务端设置Access-Control-Max-Age,减少预检次数 |
| Cookie 登录态在 App 重启后丢失 | BrowserHttpClientAdapter不负责持久化 | 自行解析set-cookie,写入 Preferences,下次请求手动拼装 |
| 上传文件失败,或报“文件路径不存在” | Web 链路不能直接读取本地文件路径 | 改用MultipartFile.fromBytes或先读取为字节数据 |
偶发UnimplementedError | dart:io相关能力在鸿蒙 Flutter 运行时未完整实现 | 切换到BrowserHttpClientAdapter,确保 Web 运行时可用 |
| 某些接口在 Android 正常,鸿蒙上 statusCode 为 0 | 大多是跨域拦截被内核直接阻断,Dart 层拿不到响应 | 用抓包工具确认是否 preflight 失败,优先排查响应头 |
表格里出现最多的是 CORS 相关的问题,因为它在鸿蒙上表现得最像“网络错误”,但实际并没有走到服务器。定位时别只盯 Dart 报错,要结合浏览器内核日志一起看。
4.2 抓包工具与日志定位技巧
鸿蒙上抓包不像 Android 那么顺手,但也不是没办法。我在项目里常用两种方式。
第一种是在 Flutter 侧开启 dio 的日志拦截器,把LogInterceptor放到所有拦截器最前面,记录请求方法、路径、请求头、响应状态码和耗时。注意响应体别全量打印,线上环境数据量大,只打印前几百字节就够定位问题了。
第二种是抓网络层完整请求。如果你用的是 DevEco Studio,可以配合网络抓包工具或鸿蒙侧的网络日志能力,看请求是不是真的发出了,服务端有没有返回,响应头带的是什么。我遇到过一个极其隐蔽的问题:服务端其实已经返回了正确的 JSON,但就因为响应头里少了Access-Control-Allow-Origin,请求在 Dart 侧表现为“网络错误”。如果只看业务层日志,永远别想定位到原因。
4.3 独家避坑经验:适配顺序比适配本身更重要
适配dio_web_adapter到鸿蒙,我个人体会最深的一点是:不要急着处理所有接口,也不要急着写一堆兼容代码。正确顺序应该是先跑通最小请求,再检查 CORS 策略,再处理 Cookie,最后才上复杂业务。
另外,建议在代码里保留一个手动开关,比如通过环境变量或者bool.fromEnvironment('USE_WEB_ADAPTER')控制是否启用 Web adapter。这样万一遇到某些业务场景确实需要原生链路,随时能切换,不用重新发版。这个开关救过我一次:有一次鸿蒙的 Web 运行时在某个系统版本上出现适配问题,我远程把开关关掉,App 立刻切回原生链路,服务不受影响,然后才有时间慢慢排查。
最后说几句
整套方案在我手头的鸿蒙 Flutter 项目里已经稳定运行了三个迭代版本。最开始我也觉得直接沿用现成的dio_http_adapter是天经地义的事,直到被UnimplementedError和 CORS 反复摩擦,才彻底理解“平台适配”这四个字有多重。如果你也在鸿蒙上做 Flutter,我的建议是从最小请求开始,一层层放行跨域、Cookie、上传下载这些阻碍,每做一步就验证一步,别指望能一口气吃成胖子。把dio_web_adapter的机制吃透,你其实就掌握了 Web 环境下网络请求的底层脉搏,以后再遇到类似的平台适配,思路都会清晰很多。