把一套跑了好几年的 Flutter 业务框架往鸿蒙设备上迁移的时候,我第一个改崩的模块不是 UI,反而是平时最不受重视的网络层。页面能渲染、路由能跳转,但线上请求的所有拦截都像被静音一样消失了。原本靠 http_client_interceptor 做的请求统一打点、动态 Headers 注入、网络流量审计,在鸿蒙环境里全都变成空操作,控制台里干净得让人发慌。
这篇文章就是这次鸿蒙化适配的完整复盘。我会从拦截器为什么失效讲起,到怎么让原生 HttpClient 成为跨端请求的统一出口,再到端侧动态 Headers 注入和流量审计的具体实现,最后把实测中遇到的边界情况一并列出来。如果你正在做 Flutter 到鸿蒙的迁移,或者想给应用的网络层加一层全局控制能力,这篇应该能帮你省掉不少试错时间。
1. 鸿蒙化之前,先理解拦截器为什么失效
1.1 拦截为什么要分层次
Flutter 侧的网络栈看起来很简单:dart:io的HttpClient是绝大多数请求的最终底层出口,http、dio、graphql这些库最终都会走到它。所以只要全局替换了HttpClient,理论上就能拦截到所有请求。HttpOverrides.global就是干这件事的,这也是http_client_interceptor在 Dart 侧能够工作的基础:它拦截的是 Dart 进程内的请求对象,在请求发出去之前插入一层有序的拦截器链。
这个模型在 iOS 和 Android 上很成熟,因为 Flutter 引擎在这两个平台上默认就走dart:io的 socket 实现。可一旦到了鸿蒙环境,这个前提就不那么可靠了。我在实际迁移中的观察是,部分鸿蒙 Flutter 运行时会走引擎侧的网络适配,业务代码通过dart:io发出的请求,在经过引擎翻译之后,未必经过我们自定义的那个HttpClient工厂。
换句话说,HttpOverrides不是不生效,而是能管住的请求范围变窄了。拦截器链仍然能拦截"业务层显式调用"的请求,但对引擎内部的一些资源加载、连接探测、重定向等场景完全无感。这也是我整套改造的起点:不是往现有拦截器里加更多的逻辑,而是先把请求出口收口,让所有请求都从一个地方过。
1.2 全量拦截的关键不是拦截器数量,而是出口数量
要做"全量请求拦截",首先要回答一个问题:全量到底以什么边界为准?如果只盯着业务代码里那些http.get()、dio.post(),那拦截器做得再优雅,也只能算是"业务请求拦截"。
真正意义上的全量,要把请求的出口数收敛到一。我在适配时做了一个很直接的决定:把鸿蒙侧的原生网络能力作为唯一的真实网络出口,所有请求,不管来自业务层、拦截器层、还是引擎资源加载,最终都统一交给它发起。这样一来,Dart 侧那套http_client_interceptor的拦截器链,就从"直接拦请求"变成了"在原生网络出口之前兜底把关"。
这个模型的优势非常明显:只要原生出口只有一个,拦截器就天然覆盖所有请求;只要覆盖所有请求,动态 Headers 注入和流量审计就有了统一的数据源。反过来,如果某个请求走了别的网络路径,就算拦截器写得再完美,也拦不到它。所以我在改造时反复跟团队强调一句话:不要先加拦截器,先找出所有出口,把出口堵到只剩一个,再谈拦截。
1.3 http_client_interceptor 的本体不需要动
很多同学一听到"鸿蒙化适配",第一反应是把这个库 fork 出来重新实现一遍。这里我要泼一盆冷水:http_client_interceptor本身是纯 Dart 实现,它做的事情就是接收请求对象、按顺序跑拦截器、放行或改写,跟具体跑在哪个平台上没有直接关系。它依赖的只是 Dart 侧的HttpClient抽象,而不是某个平台的具体实现。
所以真正的鸿蒙化适配,不是重写这个库,而是把它的"执行底座"换掉。在迁移项目里,我的做法是保留http_client_interceptor的拦截器接口和链路模型,让业务侧现有的拦截器代码一行不改地继续使用,只是在最底层加了一个"原生转发器"。这个转发器把经过拦截器链处理的请求对象序列化,交给鸿蒙原生侧去发送,再把响应结果传回 Dart 侧,交还给拦截器链做响应阶段的处理。
这样既保住了业务代码的兼容性,又让全量拦截的能力真正落到了原生层。你甚至可以理解成:http_client_interceptor从"直接发请求的人"降级成了"站在原生网络出口前面的安全检查员"。
2. 鸿蒙侧原生网络能力的接入:控制权转移的边界
2.1 用 Pigeon 定义协议,别手写 MethodChannel
Dart 侧和鸿蒙原生侧要通信,最简单的方式是MethodChannel,但我在这个项目里没有直接用,而是用 Pigeon 生成了双向接口定义。原因很实际:请求拦截的数据结构太复杂,有 URL、方法、Headers、Body、超时时间、重定向策略,用手写字符串传参拼接容易出错,调试起来也痛苦。
Pigeon 的好处是类型安全。两边各生成一套强类型接口,字段名、类型在编译期就能对齐,不用等运行时才发现"哦原来 key 拼错了"。我在工程里定义的核心数据结构是这样的:
import 'package:pigeon/pigeon.dart'; class NativeRequestData { int? requestId; String? url; String? method; Map<String, String>? headers; Uint8List? bodyBytes; int? timeoutMillis; bool? followRedirects; } class NativeResponseData { int? requestId; int? statusCode; Map<String, String>? headers; Uint8List? bodyBytes; String? errorMessage; int? costMillis; } @HostApi() abstract class NativeHttpGateway { void sendRequest(NativeRequestData request); void cancelRequest(int requestId); } @FlutterApi() abstract class InterceptorCallback { void onRequest(int requestId, NativeRequestData request); void onResponse(int requestId, NativeResponseData response); }这里有一个设计细节值得多说一句:sendRequest没有返回值,响应也不是同步返回的,而是走另一个回调通道。这是因为网络请求天然是异步的,如果用invokeMethod直接等返回值,等于把一个异步操作硬生生包装成同步,一旦请求超时或挂起,Dart 侧会一直阻塞在那里,非常危险。
2.2 原生侧网络客户端的最小接法
鸿蒙侧的网络模块,在当前 SDK 里通过系统网络能力提供。我的原生实现只做三件事:接收 Dart 侧传来的NativeRequestData,配置请求参数,发起真正的网络请求,拿到响应后回调onResponse。
这里有一个工程判断:不要去封装一个大而全的 API 请求库,只封装一个薄的网络转发器就够了。厚封装会带来两个问题,一是 Dart 侧http_client_interceptor的拦截能力会被削弱,二是很多底层状态(比如连接复用、DNS 解析、重定向)会藏在封装层里,审计时拿不到真实数据。
原生侧的核心逻辑大致长这样,我用的是偏 TypeScript 的写法,方便说明流程:
import http from '@ohos.net.http'; class NativeHttpGatewayImpl { private readonly registry = new InterceptorRegistry(); sendRequest(data: NativeRequestData): void { const request = new http.HttpRequest(); request.setUrl(data.url); request.setMethod(data.method); request.setHeaders(data.headers); if (data.bodyBytes) { request.setBody(data.bodyBytes.buffer); } if (data.timeoutMillis > 0) { request.setTimeout(data.timeoutMillis); } request.on('headersReceive', (header) => { this.registry.emitResponseHeader(data.requestId, header); }); request.on('dataReceive', (chunk) => { this.registry.emitResponseBody(data.requestId, chunk); }); request.on('dataEnd', () => { this.registry.emitResponseEnd(data.requestId); }); } }上面代码是按流程写的示意版本,不同 SDK 版本的事件名和回调方式会有差异,真机调试时以当前版本的接口文档为准。核心逻辑是稳定的:把 Dart 侧给的 requestId 一路透传,原生侧的所有事件都带着这个 id 回调,Dart 侧收到后就能准确匹配到是哪个请求。
2.3 双向通道模型:拦截器为什么可以两头跑
很多人在做这类适配时会习惯性地把所有拦截逻辑都挪到原生侧去写,结果写着写着就变成了一个原生网络库,Dart 侧完全失去控制力。我的做法刚好相反:拦截业务逻辑保持在 Dart 侧,原生侧只管网络 IO 和数据转发。
具体流程是这样的:业务请求进入http_client_interceptor的拦截器链,经过一系列 Dart 拦截器处理后,被转发器包装成NativeRequestData发到原生侧;原生侧并不是立刻发请求,而是先回调onRequest通知 Dart 侧"我准备发起一个请求了",Dart 侧记录时间点、补上动态 Headers,再通过一个completeHeaders接口把最终确认后的请求参数送回原生侧,原生侧这才真正发出请求。
这里的关键是"先握手再发送"。如果不握手就直接发,动态 Headers 注入就没有机会执行;如果所有逻辑都在原生侧跑,很多现成的 Dart 拦截器(比如日志脱敏、签名计算、token 刷新)都没法复用。双向通道模型让两头各干各擅长的事:Dart 侧做策略,原生侧做执行。
3. 全量请求拦截的拦截器注册表与链式转发实现
3.1 原生侧需要一张注册表,而不是一堆 if-else
拦截逻辑分布在 Dart 侧之后,原生侧并不需要知道拦截器具体做了什么,但它必须知道一件事:当前有哪些拦截器在生效、优先级怎么排、哪些请求需要被拦截。这就是拦截器注册表的职责。
注册表本质上是一个有顺序的条目列表,每个条目包含拦截器 id、优先级、是否启用、所属请求域(哪些 URL 匹配)。优先级高的先执行,如果某个拦截器决定"短路"(比如直接返回 Mock 响应或抛出错误),后面的拦截器就不再执行,请求也不会真正发出去。
我实现注册表时用了一个很简单的接口:
interface InterceptorEntry { interceptorId: string; priority: number; enabled: boolean; matchPattern?: string; } class InterceptorRegistry { private entries: InterceptorEntry[] = []; register(entry: InterceptorEntry): void { this.entries.push(entry); this.entries.sort((a, b) => b.priority - a.priority); } activeEntries(): InterceptorEntry[] { // 支持运行时禁用,而不是删条目 return this.entries.filter(e => e.enabled); } }不要小看这个注册表。没有它,拦截器之间的顺序完全是不可控的,一旦出现两个拦截器修改同一个 Header,结果就是看运气。有了注册表,每次请求进来后按优先级跑一遍activeEntries(),顺序稳定、行为可预期,出问题也能一眼定位是哪个拦截器干的事。
3.2 一进一出的转发模型
请求的生命周期可以抽象成"出"和"进"两个阶段。出阶段,请求离开业务层,经过拦截器链,到达原生出口;进阶段,响应从原生出口返回,再次经过拦截器链,回到业务层。
我在 Dart 侧实现了一个NativeForwarder,它实现了库要求的拦截器接口,但内部不做任何业务判断,只负责转发:把请求转成NativeRequestData,调用原生网关发送,收到响应后再转回http_client_interceptor能够识别的结果对象,继续走响应阶段的拦截器链。
这个转发的代码层面并不复杂,真正麻烦的是要保证 requestId 正确串联。我使用的是单调递增的 Long 类型 id,从 Dart 侧生成,随请求一起传下去,原生侧所有回调都带着它返回,Dart 侧通过一个 Map 维护requestId -> Completer的映射。响应到达时,用 requestId 找到对应的 Completer,把结果填进去,整个请求就算闭环了。
3.3 请求体只能读一次的坑,我在第一个版本就栽过
做审计的同学都知道,请求体不是一个可以反复读取的数据流。在 Dart 侧,"读走"一个流意味着它被消费了,后面真正的网络发送就会拿到一个空流。如果只是记录日志,这个问题还能忍;但如果要在拦截器里对请求体做签名、加密、脱敏,就必须在请求刚进入拦截器链时把 body 完整拷贝一份。
我当时的处理是,在NativeRequestData里显式地把 body 分成两个字段:bodyBytes是发送时用的原始内容,bodySnapshot是审计和计算用的拷贝副本。原本的流在第一层就被完整读到字节数组,后续所有消费方都从字节数组拿数据,谁也不会影响谁。
这里的代价是内存翻倍,所以还得主动加一个开关:只有开启审计时才做快照,正常业务请求不复制 body,性能开销可以忽略。这个取舍非常重要,尤其是你打算在低内存设备上跑,不加开关的话,一张大图上传请求就能把内存顶到警戒线。
4. 端侧动态 Headers 注入:让上下文决定注入什么
4.1 动态的前提取决于 HeaderProvider 抽象
业务里常见的需求是往请求里塞 token、traceId、AB 实验参数。直接在每个拦截器里写死获取逻辑,短期能跑,长期就是维护噩梦。我推荐的做法是把"注入什么"和"怎么取"彻底分开:拦截器只定义一份"待注入的 Header 清单",而每个 Header 的值由一个独立的 Provider 在运行时提供。
abstract class HeaderProvider { String get name; Future<Map<String, String>> resolveHeader(RequestContext context); }比如TokenHeaderProvider只负责从内存缓存里读 token;TraceIdHeaderProvider只负责生成或取出当前请求的链路 id;ABTestHeaderProvider读灰度配置。拦截器链只是把它们串起来,不关心这些值怎么来的。
这样做的好处是,替换数据源的时候根本不用改拦截器。token 存储从本地文件换到安全存储,只需要改一个 Provider 的实现,其他部分全都不动。这还是这套设计的核心:注入动作与数据来源解耦,动态才有意义。
4.2 合并顺序与冲突规则
多个 Provider 可能同时想注入同一个 Header 名,这时必须有一个确定的合并顺序,否则每次结果都可能不一样。我在项目里定义了四层优先级,从低到高:
| 优先级 | 层级 | 说明 |
|---|---|---|
| 1 | 基础头 | User-Agent、Accept、Accept-Language,来自设备配置 |
| 2 | 认证头 | Authorization、X-Access-Token,来自账号模块 |
| 3 | 业务头 | 渠道 ID、版本号、平台标识,来自业务配置 |
| 4 | 链路追踪头 | X-Request-Id、X-Trace-Id、时间戳,来自审计模块 |
后面的覆盖前面的。比如业务层故意传了一个X-Trace-Id,链路追踪层会把它覆盖成当前请求真正的 trace id。这个"覆盖"动作本身也被记录在审计日志里,方便排查为什么线上的某个 Header 跟客户端不一致。
4.3 有些 Headers 永远不要注入
动态注入不是什么都往里塞。系统级头部,比如Host、Content-Length、Transfer-Encoding、Connection,一旦你手动设置,很容易和底层网络栈的行为冲突,轻则请求失败,重则触发边界问题。我在适配说明里专门列了一条红线:只允许自定义的X-前缀头和标准的认证头参与动态注入,其余头部一律拦截。
另外一个容易忽略的点是大小写。HTTP Header 名义上不区分大小写,但鸿蒙原生侧解析逻辑未必宽容。统一在注入时把 key 按首字母大写、后续小写的规范整理一遍,能让问题少一大半。比如authorization和Authorization就可能产生两个不同的条目,白白增加调试成本。
5. 网络流量审计:数据模型、落盘与性能
5.1 一条审计记录应该长什么样
审计数据的核心价值在于事后可还原。我设计的审计记录不是简单地存一个 URL 和状态码,而是尽量保存足够多的上下文。你可以理解成把一次请求从头到尾的"体检报告"完整留下来。
| 字段 | 说明 | 示例 |
|---|---|---|
| requestId | 全局唯一请求 ID | 1024 |
| url | 完整请求地址(不含敏感 query 可脱敏) | https://example.com/api |
| method | HTTP 方法 | POST |
| reqHeaders | 请求头快照(注入后终值) | 只保留关键项 |
| reqBodySize | 请求体字节数 | 2048 |
| statusCode | 响应状态码 | 200 |
| respBodySize | 响应体字节数 | 4096 |
| costMillis | 总耗时(毫秒) | 235 |
| dnsMillis | DNS 解析耗时 | 12 |
| connectMillis | 建连耗时 | 45 |
| errorMessage | 异常信息 | timeout |
| timestamp | 请求发生时间 | 2025-01-01 12:00 |
这里要留意一个细节:dnsMillis、connectMillis这类数据,Dart 侧是拿不到的,只能在原生侧拿。所以审计数据模型必须在原生侧就打好点,然后在回调里合并到审计记录上,不能等响应回到 Dart 侧再补。补就意味着丢数据。
5.2 落盘方案:内存环形队列加批量写文件
直接每条审计记录都写文件,在请求量稍大的场景下就是灾难。磁盘 IO 的耗时远高于网络请求本身,日志写入还会卡住主线程。我的方案是两层缓冲:内存里维护一个固定大小的环形队列,队列满后先覆盖最旧的数据;异步后台任务每攒够 N 条或每秒钟批量写一次文件。
这里我额外加了一个"请求快照 dump"开关。平时线上只落审计摘要,不开快照;一旦需要排查疑难问题,动态打开开关,下一批请求的完整 Header 和 Body 快照就会一起落盘,问题定位完再关掉。这样既控制了磁盘占用,又保留了关键时候的排查能力。
5.3 审计数据的裁剪与脱敏不能后置
做审计最容易踩的坑是:所有数据先原样记录,等展示的时候再脱敏。这个思路在客户端是反的,因为日志文件一旦落地、同步、被其他模块读取,脱敏就再也来不及了。正确做法是,在NativeRequestData被序列化之前就裁剪、脱敏,保证进入日志的永远都是安全内容。
我维护了一份敏感字段清单,覆盖Authorization、X-Access-Token、密文 body 里的关键字段等,以及一份 URL query 参数脱敏规则。脱敏策略很简单:保留字段的前 4 位和后 4 位,中间用****填充。这个妥协方案既满足了定位问题时不至于啥都看不到,也避免把完整凭据落进磁盘文件。
6. 实测中容易踩的边界情况与排查建议
6.1 重定向请求到底算几次
HTTP 重定向是审计里最容易被忽略的坑。一个 302 请求,实际联网可能发了两次:第一次收到 302,第二次跟随 Location 重新发起。如果审计只记录第一次的响应,那你看到的 statusCode 是 302,但用户实际体验的 content 来自第二次请求。
我最初把 "是否跟随重定向" 的决定权放在原生侧,默认跟随。后来发现,审计时想要的是最终那次请求的完整链路。于是做了一个调整:原生侧把每一次重定向都作为一条独立记录回调回来,并带上同一个父 requestId 和不同的子序号。这样下游既能知道"最初用户点了什么",也知道"最终命中了什么"。
6.2 并发高时回调乱序:requestId 是唯一线索
当同时有几十个请求在飞,原生侧回调的到达顺序和请求发起顺序完全无关。响应的回调可能比后发的请求还要先到,这个时候依赖"调用顺序"来匹配就是给自己挖坑。唯一的锚点是 requestId,它在 Dart 侧生成后贯穿整个生命周期,所有原生侧事件都必须带上它。
我们在联调时遇到过一个问题:某个请求的响应一直不回来,排查半天发现是原生侧某个回调里把 requestId 解析成了字符串,和 Dart 侧的 int 类型对不上,匹配永远失败。从那以后我就立了一个规矩:requestId 类型两侧必须完全一致,序列化时明确标注"整数类型,禁止转字符串"。
6.3 超时之后,Dart 侧拦截器不能一直等着
原生侧有超时机制,Dart 侧不一定有。我遇到过的一种情况是:原生侧网络已超时并回调了错误,但 Dart 侧那个Completer没有被 complete,导致拦截器链永远挂在一个"等响应"的状态,内存里泄漏了一堆挂起任务。
修复方法是在原生侧无论成功、失败、超时、取消,都必须回调一个终结事件。在 Dart 侧我再加了一层保护:completeFuture统一走超时守卫,超过预设时间仍未完成就强制失败。这样就算原生侧出现异常没有回调,Dart 侧也不会无限等下去。
6.4 自己搭一个 Mock 服务端远比真机日志高效
适配完这套链路后,调试最大的瓶颈其实不在客户端,而在没有一个可控的服务端配合。我强烈建议搞一个本地的 Mock 服务端,专门用来模拟:正常响应、超时、断连、大响应体、重定向、错误状态码。很多问题只有这些边界场景才能逼出来。
调试时我还习惯在onRequest回调里打一条简短的日志,包含 requestId、url、method、最终 Headers,不要打完整 Body。这样每次请求经过拦截器时,你都能在控制台看到流水,一旦发现某个请求没有出现这条日志,问题就基本定位在拦截器链之前,排查范围缩小一大截。
网关卡在弱网模拟上也比较重要。真正在做动态 Headers 注入和审计性能评估时,正常网速下很难暴露问题,把带宽限制到几百 KB,延迟拉高到几百毫秒,那些并发乱序、超时泄漏的问题才会稳定复现。你甚至可以定期用这种环境跑一轮全链路回归,效果好过于人肉翻日志。
做完这轮鸿蒙化适配,我最大的体会是:网络层改造的成功与否,不取决于大量精巧的拦截代码,而在于边界收得是否干净。把出口收敛到原生,把审计点埋到底层,把 Header 注入的时机统一在握手里,整个链路就变得非常清晰、非常可控。后续如果你要把这套能力扩展到上传下载任务、WebSocket 长连接,同样可以沿这套"注册表加双向通道"的模型去延伸,不需要推倒重来。