总有人以为,把一个 Flutter 应用从 Android/iOS 搬到 Web 端,再把 Web 端搬到鸿蒙设备上,就是“重新 build 一下”的事。真这么简单的话,就不会有那么多人在跨域报错和 Cookie 丢失面前怀疑人生了。我最近就在做一件听起来特别“钻牛角尖”的事:把 dio_web_adapter 这个三方库完整地鸿蒙化,让 Dio 的网络请求能在鸿蒙的 Flutter 运行环境里稳定跑通,同时把 Web 平台最折磨人的跨域拦截问题一并收拾利索。
这篇博文不是官方文档的复述,也不是什么高大上的架构宣讲。它是我在真实项目里一点点踩坑、改代码、验证方案后的记录。我会从适配器存在的意义讲起,把跨域拦截、网络穿透这些概念拉回工程实操层面,再给你看我最终落地的一套配置方式和排查方法。如果你恰好也要做 Flutter 应用的鸿蒙化迁移,或者正在被 Web 端的 CORS 问题折磨,这篇内容应该能帮你省下好几个晚上的排查时间。
1. 项目核心:dio_web_adapter 到底解决了什么
1.1 为什么 Web 端需要一个专属的 Dio 适配器
先说个很多人的误解:Dio 不是一个“发请求的库”,它更像一个“请求调度中心”。真正帮你把数据包发到服务器、再把响应包收回来的是它底层的 HttpClientAdapter。在移动端,Dio 默认走的是 dart:io 的 HttpClient;但在 Web 端,dart:io 根本不存在,你只能用浏览器的 XMLHttpRequest 或 fetch。问题来了:Dio 的核心代码是平台无关的,它只负责拦截器、超时、取消、重试这些编排逻辑,而真正跟平台网络栈打交道的那一层必须换掉。dio_web_adapter 干的就是这件事——把 Dio 发起的每一个请求转换成浏览器环境下的 HTTP 调用,再把响应转回 Dio 能识别的格式。
打个比方,Dio 是外卖平台的调度系统,适配器是骑手。你在哪个平台点外卖,就得有对应平台的骑手去取餐。App 端骑手是 dart:io,Web 端骑手就是 XMLHttpRequest/fetch。dio_web_adapter 就是那个专门为 Web 平台定制的骑手,它知道怎么在浏览器沙箱里帮你把请求送出去。
那鸿蒙化适配又是什么意思?鸿蒙设备上的 Flutter 运行环境,尤其是基于 OpenHarmony 打造的 Flutter SDK,跟标准 Flutter 在 Web 端的行为并不是完全一致的。它可能跑在 ArkWeb 组件上,也可能跑在鸿蒙自己的网络框架上。问题在于,原本在浏览器里工作的适配器,到了鸿蒙环境里未必能被正确识别,或者识别了但网络栈的行为不一致,导致请求直接失败。所以我们要做的工作,就是让 dio_web_adapter 在鸿蒙 Flutter 环境里重新“对位”。
1.2 鸿蒙化适配的定位与核心挑战
做鸿蒙化适配,最怕的不是代码复杂,而是你以为它简单。当你把 dio_web_adapter 原封不动塞进鸿蒙工程,编译大概率能过,但一跑起来就是各种奇怪问题。我遇到过的几类核心挑战:
- 请求协议映射:Dio 的 RequestOptions 里有 queryParameters、data、headers、responseType 等一堆字段,适配器必须把它们完整翻译成鸿蒙 Web 环境能理解的形式。少了任何一个字段,服务端可能就返回 400。
- Cookie 与凭证:Web 端的 Cookies 由浏览器管理,鸿蒙的 ArkWeb 组件也有自己的 Cookie 策略。适配器如果不去主动同步 Cookie 状态,登录态说丢就丢。
- 跨域行为:这是最大的一块。浏览器和 ArkWeb 都有同源策略,跨域请求要么被预检拦截,要么响应被藏起来。适配器必须能正确处理 preflight 请求,并且让开发者有机会对请求头做干预。
- 线程模型:Flutter 的 UI 线程和鸿蒙网络回调线程不一样。适配器如果忽略了线程切换,轻则丢回调,重则直接崩。
理解了这些挑战,后面所有实操步骤就都有了方向。太技术化的概念先不铺开,接下来我先把跨域拦截这件事讲透,因为几乎 80% 的适配问题都是从这里冒出来的。
2. 跨域拦截:把安全边界变成可控的开发能力
2.1 CORS 到底拦的是什么
跨域拦截的正式名字叫 CORS(Cross-Origin Resource Sharing),它是浏览器安全模型的一部分。简单说,当你的页面运行在 http://localhost:8080,而接口地址是 https://api.example.com,浏览器就认为这是一个“跨源请求”。为了安全,浏览器默认不允许页面读取跨源响应。
但 CORS 并不是一刀切。浏览器把请求分成两类:简单请求和非简单请求。简单请求只允许 GET/POST/HEAD 方法,并且只允许使用几个有限的请求头(比如 Content-Type 只能是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain)。这种请求会直接发出,但浏览器检查响应头里有没有 Access-Control-Allow-Origin,如果没有,响应就会被拦截。非简单请求则更严格——浏览器会先发一个 OPTIONS 预检请求,问服务器“我这个跨域请求你允许吗?”,服务器通过 Access-Control-Allow-Methods 和 Access-Control-Allow-Headers 回答,通过了才发真正的请求。
在实际开发里,绝大多数接口都不是简单请求。你只要设置了 Authorization 请求头,或者 Content-Type 用了 application/json,预检请求就跑不掉了。这就是你为什么经常在控制台看到 “Request header field authorization is not allowed by Access-Control-Allow-Headers” 这类报错。
2.2 “网络穿透”的工程含义:代理、通道与凭证
标题里提到的“网络穿透”,在这个场景下其实不是什么玄学,它指的是让请求穿透默认的安全限制,走一条你期望的通道到达服务器。工程实现上主要有三个手段:
- 代理转发:把 API 路径指向本地或网关的代理服务,由代理去访问真正的目标接口。这样浏览器看到的始终是同源请求,从根上绕开 CORS。
- 自定义拦截器:在 Dio 层面对请求做改写。比如动态补上 Origin、Referer,或者把 Authorization 从配置中心注入进去。这属于“通道改造”。
- 凭证传递:带 Cookies 的跨域请求必须显式开启 withCredentials,否则即使服务器返回了 Access-Control-Allow-Origin,Cookies 也不会被携带。这是穿透过程中最容易被忽略的一环。
我在鸿蒙化适配里,把“穿透”落到了三个具体目标:第一,让请求能顺利穿过 ArkWeb 的安全检查;第二,让 Cookies 能跨页面跨会话稳定保存;第三,让开发者能在不修改业务代码的前提下,通过配置改变请求的路径和行为。只要这三个目标达成,跨域拦截就不再是阻碍,而成了一种可管理的工程能力。
3. 鸿蒙化适配实操:从依赖引入到请求打通
3.1 工程环境准备与依赖接入
先交代一下我使用的环境:DevEco Studio 配合鸿蒙 Flutter SDK,项目里同时使用了 Flutter 的移动端和 Web 端工程结构。开始改造前,务必确认三点:
- Flutter SDK 版本与鸿蒙 SDK 版本要匹配。我一开始用了较新的 Flutter 版本,鸿蒙的 Flutter 引擎分支跟不上,导致编译时报了一堆符号找不到。
- 鸿蒙工程的网络权限必须声明。在 module.json5 里检查有没有 ohos.permission.INTERNET,这个不配,所有请求都会以网络异常失败。
- 三方库依赖要统一走鸿蒙仓库。标准 pub.dev 上的 dio_web_adapter 可能依赖了鸿蒙环境不支持的传递包,我建议直接从支持 OpenHarmony 的仓库拉取。
在 pubspec.yaml 里添加依赖时,我倾向于这样写:
dependencies: flutter: sdk: flutter dio: ^5.4.0 dio_web_adapter: ^1.0.0然后执行flutter pub get。这里有个细节:如果你发现拉下来的包里有 platform 条件不包含 ohos 的文件,比如只写了 web 条件,那你需要手动在对应包的 pubspec.yaml 里补上 ohos 平台声明,或者用 dependency_overrides 强制使用本地修改后的副本。这一步不做,后面的代码根本走不到鸿蒙分支。
3.2 Adapter 集成与核心代码
环境准备好后,核心就是把 adapter 挂到 Dio 实例上。我的做法是写一个统一初始化的工具类,让所有页面共享同一个 Dio 配置:
import 'package:dio/dio.dart'; import 'package:dio_web_adapter/dio_web_adapter.dart'; Dio createHarmonyDio() { final options = BaseOptions( baseUrl: 'https://api.example.com', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: { 'Content-Type': 'application/json', }, ); final dio = Dio(options); dio.httpClientAdapter = HarmonyWebAdapter( withCredentials: true, ); return dio; }这里HarmonyWebAdapter是我对 dio_web_adapter 做鸿蒙化封装后的类名。它内部做了几件关键的事:把 Dio 的请求头转换成 ArkWeb 能识别的 Map、维护 Cookies 的读写、区分 GET 和 POST 的 body 格式、以及处理重定向。封装的核心代码如下:
class HarmonyWebAdapter implements HttpClientAdapter { final bool withCredentials; HarmonyWebAdapter({this.withCredentials = false}); @override Future<ResponseBody> fetch( RequestOptions options, Stream<Uint8List>? requestStream, Future<void>? cancelFuture, ) async { // 1. 合并默认头和请求头 final headers = Map<String, String>.from(options.headers); // 2. 根据 method 构造请求体 // 3. 使用鸿蒙 Web 客户端发出请求 // 4. 将响应流转回 Dio 的 ResponseBody } }这里面最容易写错的地方是requestStream。Dio 在发送 POST 请求时,body 是以流的形式传给 adapter 的。假如你的请求体是 FormData,那流里可能是 multipart 格式;假如是普通 Map,流的编码方式取决于 Content-Type。我在第一次实现时直接把流塞给了鸿蒙 Web 客户端的 body 参数,结果发现所有的 POST 请求服务端都收到了空 body。后来我改成先把流读成字符串,再根据 Content-Type 做一次编码转换,问题才消失。
3.3 跨域处理策略:预检请求、请求头白名单与拦截器
跨域问题不是配好 adapter 就能自动解决的。我在项目里遇到的实际场景是:接口需要 Authorization 头,Content-Type 是 application/json,还有自定义的 X-Request-Id。这套配置扔到 ArkWeb 里,预检请求直接失败,控制台里报的是 “Access-Control-Allow-Headers 不包含 x-request-id”。
这种问题的根源是服务端没在 CORS 响应里把自定义请求头加入白名单。但很多时候你改不了服务端,只能在前端想办法。我的方案是写一个拦截器,把跨域请求统一收口处理:
class CorsInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { options.headers['Origin'] = 'https://app.example.com'; options.headers['X-Request-Id'] = generateRequestId(); options.extra['withCredentials'] = true; handler.next(options); } @override void onResponse(Response response, ResponseInterceptorHandler handler) { // 如果响应头里没有 CORS 头,在这里做一次补丁 handler.next(response); } }你可能会问,客户端补 Origin 有用吗?说实话,浏览器环境里你是改不了 Origin 的,浏览器说了算。但在鸿蒙的 ArkWeb 或自研运行时里,部分场景允许应用层修改请求头,所以这个方案在鸿蒙环境是可行的。不过我不建议把希望全押在拦截器上,更稳妥的做法是在开发阶段就配置好代理或网关,用环境维度解决问题。
关于预检请求,还有一个小细节:dio_web_adapter 本身不会自动拦截 OPTIONS 请求,但鸿蒙 Web 客户端可能会。我在适配器里加了判断,如果是 OPTIONS 方法,直接返回空响应体,让上层逻辑继续走。这样可以避免预检请求被业务代码误伤。
3.4 Cookie 与凭证传递的坑
Cookies 在 Web 平台的归属非常特殊:浏览器负责存,开发者只能读写 document.cookie,而到了鸿蒙 ArkWeb 环境,Cookie 管理是通过 WebCookieManager 这类 API 来做的。Dio 默认是不管 Cookies 的,如果你不做处理,会发现登录接口成功后,后续请求都没有携带 session。
之前看到一个很常见的错误:开发者想当然地用 Dio 的拦截器把 Cookies 手动塞进 headers。这在大方向上行得通,但坑在于 Cookies 是会过期、会变的,手动塞等于自己维护状态机,迟早出错。我的做法是在 adapter 里集成一个 CookieManager,每次请求前从管理器读 Cookie,响应后用 Set-Cookie 头更新管理器:
class HarmonyCookieManager { final Map<String, String> _cookies = {}; void saveFromResponse(ResponseBody response) { final setCookie = response.headers['set-cookie']; // 解析 Set-Cookie 并存入 _cookies } void applyToRequest(RequestOptions options) { final cookieStr = _cookies.entries .map((e) => '${e.key}=${e.value}') .join('; '); if (cookieStr.isNotEmpty) { options.headers['Cookie'] = cookieStr; } } }用这个方案后,我的登录态在 PC Web 端和鸿蒙端都能保持一致。唯一要注意的是 Cookie 的 domain 和 path 规则,如果你只按 key-value 存,域名不匹配的 Cookie 会串号。我在实现里加入了 domain 匹配逻辑,只有当请求的 host 跟 Cookie 的 domain 匹配时才带过去。
4. 问题排查:我在鸿蒙化过程中踩过的坑
4.1 典型症状与定位思路
鸿蒙化适配过程中,我遇到了一堆看起来毫无头绪的问题。下面这些是我觉得最有代表性的,直接列成速查表,你大概率也会碰到。
| 症状 | 可能原因 | 定位思路 |
|---|---|---|
| 所有请求都超时 | 鸿蒙工程缺少 INTERNET 权限 | 检查 module.json5 的权限声明 |
| GET 正常,POST 服务端收不到 body | Adapter 没有正确读取 requestStream | 在 fetch 里先读流再编码 |
| 请求发出去了,但响应头全为空 | ArkWeb 限制了响应头读取 | 用代理抓包确认服务端实际响应 |
| 总是收到 CORS 预检失败 | 自定义请求头不在服务端白名单 | 收敛请求头,或走代理同源方案 |
| 登录成功但后续请求全部 401 | Cookie 没有保存或跨域未携带 | 检查 withCredentials 和 CookieManager |
| 图片上传报错 | FormData 的 content-type 被覆盖 | 手动为文件部分设置 multipart 头 |
以“POST 收不到 body”为例,我第一次排查时怀疑是鸿蒙网络框架的问题,折腾了两天才意识到是 Dio 的请求流没有被 adapter 消费。调试方法也很简单:在 fetch 方法里打日志,把读取到的流长度打印出来,跟服务端日志里的 Content-Length 对比,很快就能定位。
4.2 调试工具与验证方法
鸿蒙环境下的网络调试,不能用浏览器那一套 DevTools 一招鲜。我最终的调试组合是:
- 抓包工具:reqable 这类支持 HTTPS 解密的本地抓包工具,用来确认客户端真正发出的请求长什么样。尤其是跨域场景,看预检请求和实际请求的 headers 差异。
- DevEco Studio 的日志输出:在 adapter 和拦截器里加 debugPrint,把关键节点的请求信息打印出来。别嫌日志丑,出问题的时候它最直接。
- 临时降级验证:当鸿蒙端的 CORS 行为跟标准 Web 不一致时,我会临时把 baseUrl 指向本地代理,用同源请求排除跨域因素,确认业务逻辑本身没有问题。
这里有一个非常重要的排查思路:把平台问题跟业务问题分开。CORS 报错看起来是网络问题,但有时候根子在请求头格式;Cookie 丢失看起来是存储问题,但有时候根子在 withCredentials 没开。不要一上来就怀疑 adapter,先打印日志确定请求真实发出去了、服务端真实收到了,再往下查。
4.3 几条独家建议
踩了这么多坑,我总结了几条在别处不太容易看到的建议。
第一条,鸿蒙化适配不要一上来就搞“完美方案”。先把请求跑通,再谈效率。我最初的版本连 Cookie 管理都没有,先保证了 GET 和 POST 能通,然后逐项加功能,这样每次定位问题都只有一个变量在变化。
第二条,如果服务端在你控制范围内,直接把跨域白名单配好,比前端折腾拦截器不知道省多少事。前端绕过 CORS 的手段多是为开发调试服务的,生产环境最好还是正向解决。
第三条,请求头的全局收敛比局部打补丁靠谱。我后来把所有自定义请求头都收敛到了一个配置文件里,adapter 和拦截器都从同一个配置源读取,减少了很多莫名其妙的不一致问题。
5. 从适配到交付:沉淀下来的一些经验
项目做到这里,我对鸿蒙化适配的整体判断是:它不算难,但细节密度特别高,比单纯做 Web 适配多了一层平台不确定性的考验。
我最深的体会是,适配器的价值不在于“让请求发出去”,而在于“让请求像一个受到良好管理的公民一样发出去”。什么算良好管理?Cookie 有来有回、超时传得到位、预检请求不崩、失败时有可供定位的错误信息。这几点做到了,业务层根本感知不到底层是 dart:io 还是鸿蒙网络栈。
最后再分享一个小技巧:别忘了把 dio_web_adapter 的版本锁死。这类适配层库的更新频率可能不高,但它依赖的底层接口一升级,行为就可能变。我就在一次升级后遇到过 Cookie 管理器失效的问题,最后发现是新版本改了初始化顺序。遇到这种情况,最快的方法不是读 changelog,而是直接对比两个版本的源码 diff,注意看 httpClientAdapter 的默认实现有没有变化。
适配层的代码注定是“小步快跑、持续迭代”的。你现在照着这篇内容搭出来的版本,可能只覆盖了我列出的 80% 场景,但只要你把日志、异常、请求链路这三样东西留好了,剩下那 20% 来了也不慌。