之前一直在做某跨平台系统的接口联调,电脑离身就寸步难行。后端一改接口,我得先找电脑、再开桌面调试工具、复制报文、切到手机上看表现。后来项目迁移到 OpenHarmony 设备上,这套流程更别扭了:市面上大多数 API 调试工具都是面向 Android/iOS 的,OpenHarmony 平台几乎没有能直接用的成品;退一步开 Web 版调试页面,又会被移动端浏览器的跨域限制卡住。
后来我用 Flutter 做了一款 Web 开发助手 App,第一个落地的核心模块就是 API 测试。这套工具现在已经成了我日常联调的主力:跑在 OpenHarmony 设备上,能构造任意 HTTP 请求、管理历史记录、批量测试接口。这篇文章把从环境搭建到核心实现的完整路径拆开讲,重点写 API 测试模块的架构设计和踩坑过程。适合正在做 OpenHarmony 应用开发、或者想在 Flutter 工程里集成完整调试能力的朋友参考。
1. 为什么要在 OpenHarmony 上做这个 Web 开发助手 App
1.1 项目缘起:移动端接口联调的真实缺口
先说痛点。我之前做某跨平台系统的接口联调,后端接口改得很勤。移动端验证新接口时,要么在电脑上的桌面调试工具里点半天,要么在手机浏览器里访问某个 Web 调试页,然后把参数一个个搬过去。桌面调试和移动端实际表现总有偏差,弱网、缓存、UA 这些场景,桌面工具根本模拟不出来。
换到 OpenHarmony 平台之后,这个问题变得更尖锐。OpenHarmony 上的应用生态还在成长期,常见的 API 调试工具基本没有适配版本。有的调试器是原生应用,厂商只维护 Android/iOS 版本;有的 Web 版工具虽然号称跨平台,但移动端浏览器的跨域限制导致内网 API 根本调不通。就算绕过跨域,在手机上缩放页面、填 JSON、看报文,操作体验也是灾难级的。
所以我的判断是:与其等工具厂商适配 OpenHarmony,不如自己做一款。核心需求非常明确:一个能装进 OpenHarmony 设备、能灵活构造 HTTP 请求、能保存请求历史、能格式化展示响应结果的开发助手 App。当时命名就直接叫"Web 开发助手",API 测试作为第一个核心模块。
1.2 选 Flutter 而不是 ArkTS 的决策复盘
OpenHarmony 系统应用开发的主流语言是 ArkTS,配合 ArkUI 声明式 UI 写界面确实很方便。但这个项目的目标平台不只有 OpenHarmony,我手上还有 Android 和 iOS 测试机,如果全部用 ArkTS 写,每换一个平台就要重写一遍,三套代码的维护成本我接受不了。
Flutter 的优势在于:
- 一套 Dart 代码同时出多端产物,OpenHarmony 社区分支已经能跑通。
- 第三方包生态比 ArkTS 成熟,网络库、JSON 解析、UI 组件都有大量现成可用的。
- 热重载在界面联调阶段的效率优势很明显,改样式不用反复编译。
ArkTS 也不是没有优点:它更贴近 OpenHarmony 原生能力,调用系统 API 的链路更短,官方后续演进也更快。但对工具型 App 来说,核心复杂度在网络层和状态管理,而不是系统能力调用,Flutter 的生态优势更值钱。
这里还想多说一句:ArkTS 和 Flutter 不是非此即彼的关系。我在 OpenHarmony 上跑 Flutter,实际是通过社区适配的 Flutter 引擎分支,把 Dart 代码编译成 OpenHarmony 应用壳可调用的产物。Flutter 负责界面和业务逻辑,ArkTS 工程只是个壳,两者在同一个 App 里共存。做技术选型时,没必要被"OpenHarmony 开发必须用 ArkTS"这种说法绑住。
当时我做了个对比表格,帮助决策:
| 维度 | Flutter 方案 | ArkTS 方案 |
|---|---|---|
| 多端覆盖 | 一套代码多端出包 | 仅 OpenHarmony |
| 第三方库成熟度 | 高 | 中等 |
| 热重载 | 支持 | 部分支持 |
| 系统能力调用 | 需桥接 | 原生直连 |
| 团队学习成本 | 需学 Dart | 需学 ArkTS/ArkUI |
综合下来,Flutter 方案更适合"团队已有 Dart 基础、需要多端调试工具"的场景。
2. 环境准备:Flutter for OpenHarmony 的工程落地
2.1 工具链版本匹配,这一步最容易翻车
直接说结论:默认的 flutter create 创建的工程只能在 Android/iOS 上跑,要在 OpenHarmony 上构建,必须使用带 OpenHarmony 引擎的 Flutter SDK 分支。这不是官方默认能力,属于社区方案。我第一次搭环境就踩了坑:随手装了最新版 Flutter SDK,结果 OpenHarmony 的 ArkTS 壳工程编译时一直报引擎符号找不到。后来查了版本兼容说明才发现,最新 Flutter 版本对应的 OpenHarmony 适配还没跟上。
我的建议是先锁版本再动手。参考我用的这套组合:
| 组件 | 版本要求 |
|---|---|
| Flutter SDK | 3.x 稳定分支(带 OpenHarmony 引擎补丁) |
| OpenHarmony SDK | 4.x API Level |
| Dart | 跟随 Flutter 版本锁定 |
| 构建工具 | 命令行构建链或对应 IDE |
工程模板也要用专门适配过的。拉取模板后,工程结构里会有一个 OpenHarmony 侧的壳目录,里面是 ArkTS 工程,负责拉起 Flutter 引擎并承载页面容器。Dart 代码会编译成 so 和资源包,由 ArkTS 壳加载运行。
我在这里多花了半天时间,原因很简单:第一次跑空壳工程时,频繁在"Flutter 侧编译报错"和"壳工程编译报错"之间来回切换,最后把两端日志分开看,才定位到是 OpenHarmony SDK 版本和壳工程模板不匹配。
实际操作中,你大概率也会遇到 Flutter 新建项目后跑不起来的情况。别急,按这四步排查:
- 确认 Flutter SDK 版本和 OpenHarmony 引擎分支版本匹配。
- 确认 OpenHarmony SDK 版本在模板要求的范围内。
- 跑 demo 前先执行一遍依赖检查和环境诊断。
- 分侧看日志:Dart 编译问题看 Flutter 日志,ArkTS 壳问题看工程日志。
2.2 目录结构设计,给后续扩展留余地
工具型 App 最容易犯的错是"所有功能写进一个文件"。API 测试模块只是起点,后面我还计划加 WebSocket 测试、环境管理、Web 预览,如果目录结构不提前分层,后面全是重构成本。
我的目录结构长这样:
lib/ main.dart core/ network/ // 网络请求内核 storage/ // 本地持久化 settings/ // 配置项 features/ api_test/ // 核心 API 测试页面 history/ // 请求历史 ws_test/ // WebSocket 测试(后续扩展) shared/ widgets/ // 通用 UI 组件 utils/ // 格式化、高亮、解析工具分层的逻辑是:core 层不依赖任何具体业务页面;features 层每个业务模块独立成目录;shared 层放大家都能用的公共能力。以后要砍掉某个功能模块,直接删掉对应目录就行,不会影响其他模块。
工程创建好之后,第一件事不是写业务页面,而是先跑一个空壳 App,确认整条链路是通的:Dart 层编译、ArkTS 壳编译、设备安装、Flutter 引擎启动。这一步顺了,后面才敢往工程里继续堆功能。
提示:Flutter for OpenHarmony 的构建链路比纯 Android 开发多了一层"Dart 编译产物 -> ArkTS 壳加载"。编译报错时,先分清是 Dart 侧问题还是壳工程侧问题,再搜错误信息,排查效率会高很多。
3. API 测试核心功能实现:请求构造与响应解析
3.1 HTTP 请求层:为什么选 Dio 而不是 HttpClient
网络层我选了 Dio,没有用 Flutter 自带的 HttpClient。说实话 HttpClient 也能发请求,但 API 测试工具对网络层的要求比普通业务 App 高很多:要能随时改 Header、要能灵活处理自签名证书、要支持多个请求并发且能单独取消。这些如果靠 HttpClient 实现,得自己造不少轮子。
Dio 直接提供的能力:
- 拦截器机制:统一加日志、统一兜底错误。
- 灵活的 BaseOptions:超时、内容类型全局配置。
- 丰富的事件回调:上传/下载进度、取消事件。
- 对自签名证书的适配更容易,调试内网 API 时价值很大。
核心封装如下:
class ApiClient { static final ApiClient _instance = ApiClient._(); factory ApiClient() => _instance; late final Dio _dio; ApiClient._() { _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 15), ), ); _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { // 在这里做环境变量替换、Token 注入 handler.next(options); }, onError: (error, handler) { // 统一转换异常,UI 层不感知 DioException handler.next(error); }, ), ); } Future<Response> send(RequestSpec spec) async { return _dio.request( spec.url, method: spec.method, queryParameters: spec.query, data: spec.body, options: Options(headers: spec.headers), ); } }注意,ApiClient 是单例,所有请求共用同一个 Dio 实例。对 API 测试工具来说,好处是连接池复用、Cookie 管理统一;坏处是并发请求多了之后,超时配置会互相干扰。后来我做了改进:在 RequestSpec 里允许覆盖单次请求的超时时间,构造 Options 时传入。批量调试场景下,某个慢接口就不会拖垮其他请求。
3.2 参数构造:Query、Header、Body 三种输入的组织方式
API 测试工具好不好用,很大程度看参数构造界面。桌面版调试器的样式不能直接照搬,移动端屏幕小,表单不能太密。我做了三个页签:Query 字段、Headers 字段、Body 内容区。
Query 和 Headers 都采用动态键值表的形式:每一行两个输入框,左边键名,右边键值;每行尾部有启用开关和删除按钮。启用开关用来临时禁用某项参数,而不是删除它。这个设计做调试很实用——经常要对比"带这个参数"和"不带这个参数"的差异,禁用比删除高效得多。
对应的数据模型:
class ParamItem { final String key; final String value; bool enabled; } class ParamTable { final List<ParamItem> items; Map<String, String> toMap() { return { for (final item in items) if (item.enabled && item.key.isNotEmpty) item.key: item.value, }; } }Body 区支持三种模式:none(无请求体)、form-data(表单)、raw JSON。raw JSON 模式下我加了一个"格式化/压缩"按钮。格式化是把用户粘贴的 JSON 字符串解析后重新按层级缩进输出;压缩则是去掉所有空白字符。这个功能看着不起眼,但用起来非常顺手:从浏览器 F12 复制报文到 App 里时,格式化能快速看清层级;从 App 复制报文出去时,压缩能减小粘贴体积。
响应区我做了状态码颜色化:2xx 绿色、3xx 蓝色、4xx 橙色、5xx 红色。同时用 Stopwatch 统计每次请求耗时,精确到毫秒,显示在状态码旁边。接口性能优化时,这个数字比浏览器 Network 面板直观得多。整个构造请求到展示响应的流程,各个环节看似简单,组合起来就是一款调试工具的骨架。
3.3 响应展示:JSON 高亮与大数据量处理的取舍
响应展示区我分成了两个页签:Headers 和 Body。Headers 用键值列表展示;Body 里如果是 JSON,就直接渲染成可展开的树形结构;如果是纯文本或 HTML,按纯文本显示。
JSON 树形渲染我没有引入重量级第三方包,而是自己写递归组件。核心思路:
Widget renderJsonNode(Object? value, String? key, int depth) { if (value is Map) { return JsonObjectView(entries: value.entries, depth: depth); } if (value is List) { return JsonArrayView(items: value, depth: depth); } return JsonValueView(value: value, key: key); }key、string、number、boolean 分别用不同颜色渲染,嵌套的 Map 和 List 默认收起,点击后展开。
这里有一个性能关键的坑:如果响应体非常大,比如几千行的 JSON,一次性构建所有 Widget,内存开销很可观。我的做法是懒折叠——初始只渲染第一层节点,用户点击展开某层时,才构建该层下的 Widget。实测一个 4000 行的 JSON,从初始渲染到逐层展开,全程没有明显卡顿。
另一个容易忽略的点是格式化的执行位置。一次 JSON 格式化是毫秒级,但如果放在 UI 线程里,列表滚动时还是会掉帧。我把格式化和高亮解析都放到了 Isolate 里执行,UI 层只接收格式化后的字符串或树形数据。这个优化用户感知不强,但对长时间使用 App 的体验提升很明显。
4. 组件通信与状态管理:Provider 的实战用法
4.1 为什么选 Provider 而不是 setState
刚接触 Flutter 时习惯用 setState,页面简单没问题。但 API 测试模块有两个典型的共享状态场景,setState 很快就不够用。
第一是请求历史列表的跨页面同步。详情页完成一次请求后,返回列表页,列表要立刻显示新记录,并插到最顶部。用 setState 做,必须手动把状态层层往回传,页面层级一超过两层,代码就很绕。
第二是请求记录详情页需要从列表页拿到完整请求快照和响应快照。这时候正确的做法是监听一个共享数据模型,而不是通过构造函数层层传递。
我最终选了 Provider,而不是 Bloc 或 Riverpod。理由很朴素:工具型 App 的状态复杂度算中等,ChangeNotifier 机制完全够用;Provider 的 context 查找模式是 Flutter 原生思路,调试成本低;Bloc/Riverpod 虽然更强,但对团队来说学习成本偏高,收益不明显。
4.2 请求记录模型与跨页面刷新
核心数据模型是 RequestRecord 和 HistoryStore。RequestRecord 保存请求快照和响应快照,HistoryStore 维护整个历史列表。两个类都继承 ChangeNotifier:
class RequestRecord extends ChangeNotifier { final RequestSpec request; ResponseSpec? response; final DateTime createdAt; void updateResponse(ResponseSpec resp) { response = resp; notifyListeners(); } } class HistoryStore extends ChangeNotifier { final List<RequestRecord> _records = []; List<RequestRecord> get records => List.unmodifiable(_records); void add(RequestRecord record) { _records.insert(0, record); notifyListeners(); } }列表页的构建方式:
Consumer<HistoryStore>( builder: (context, store, child) { return ListView.builder( itemCount: store.records.length, itemBuilder: (context, index) { final record = store.records[index]; return RequestListTile(record: record); }, ); }, )详情页通过 Provider.of (context) 拿到 store,再查找到对应记录。关键点是 RequestRecord 本身也是 ChangeNotifier——响应回来后调用 updateResponse,详情页里用 Consumer 监听 record 对象,状态码和时间就能自动刷新。
这里有一个新手容易踩的坑:不要把 Provider 放在整个 App 根部,把什么都往里塞。我的做法是只在列表页所在区域放置 HistoryStore 的 Provider,详情页单独接收 record 对象。状态作用域越小,越不容易出现"某个页面莫名其妙被刷新"的问题。
4.3 Provider 的 context 查找时机与异步刷新
Provider 在使用中遇到过两个隐蔽问题,值得单独记录。
第一个是 Provider.of(context) 拿不到上层 Provider。最开始我把 MultiProvider 放在 MaterialApp 之前,理论上所有页面都应该能拿到。但实际运行时却报找不到 Provider。排查了很久,发现是在一个异步回调里用了 BuildContext——那个 context 早已脱离了 Provider 所在的作用域。解决办法是不要在异步回调里直接用 context,先在 build 方法里把 Provider 实例取出来,再在回调里使用。
第二个是 notifyListeners 在页面销毁后触发导致的异常。场景是这样的:用户在详情页发了一个请求,不等响应回来就返回列表页,此时详情页 Widget 已销毁,但请求回调还是会把 response 写回 record,触发 notifyListeners,进而通知到已销毁的监听者。处理方案是更新前统一判断:
if (mounted) { _updateResponse(resp); }更好的做法是把状态更新封装到 store 层,UI 层不直接触发状态变更。这也是我后来重构的方向:UI 只调用 store 的方法,store 内部管理所有变更,监听者只关心你关注的那部分数据。
5. 踩坑实录:WebView 调试、证书校验与并发限制
5.1 WebView 调试预览与 Flutter 层的数据互通
这个 App 里有一个"Web 预览"入口,用来在 WebView 中打开正在调试的前端页面。既然是 Web 开发助手,只能发 API 请求、不能预览网页,总觉得少了点意思。但 WebView 和 Flutter 层天然隔离,要互通必须靠桥接。
我的做法:在 WebView 加载页面后注入一段 JavaScript,拦截页面里所有 fetch 和 XMLHttpRequest 请求,把请求参数和结果通过 WebView 的 JavaScript 通道抛给 Flutter 侧。Flutter 侧注册回调接收这些事件,统一写入历史记录。
这里有一个大坑:页面还在加载中时,如果提前注册 JavaScript 通道,部分页面会白屏且无法通信。一开始我在 onPageStarted 里注册,结果页面一复杂就出问题。后来干脆改成 onPageFinished 之后再注入 JS 和注册 channel,问题基本解决。但页面只要一刷新,就需要重新注入一次。
WebViewController controller = WebViewController(); controller.setNavigationDelegate( NavigationDelegate( onPageFinished: (url) { controller.runJavaScript(_buildInjectScript()); }, ), ); controller.addJavaScriptChannel( name: 'ApiDebugger', onMessageReceived: (message) { _handleWebEvent(message.message); }, );这个桥接层,未来还能扩展成"把 WebView 里的 cookie 同步到请求模块"。OpenHarmony 上 WebView 组件的能力和 Android WebView 不完全一致,社区适配了不少差异点,做的时候多看对应平台文档能少走弯路。
5.2 内网环境下自签名证书与 HTTP 明文流量
调内网 API,躲不开两个坎:HTTP 明文流量、自签名证书。OpenHarmony 对 HTTP 明文流量默认是禁止的,Dio 默认也会校验服务器证书。两个都是安全策略,但都会卡住内网调试。
处理分两步。
第一步,在 OpenHarmony 壳工程中允许调试模式下的明文流量。这里我折腾了很久——配置位置和 Android 工程不一样,一开始凭经验去改网络配置文件,改了多次才找对地方。经验是:把官方文档里"明文流量"和"调试模式"两个关键词一起搜,比单独搜问题描述高效得多。
第二步,在 Dio 的证书校验逻辑里放行自签名证书:
_dio = Dio( BaseOptions( validateCertificate: (cert, host, port) { return isDebugMode || cert.trusted; }, ), );isDebugMode 是编译期常量,Release 构建时自动为 false。这个开关必须设计成"调试模式专属",否则外发的测试包会带上安全隐患。我在代码里加了注释,也做了 CI 检查,保证 Release 包不会包含放宽证书校验的代码路径。
注意:放宽证书校验和明文流量,只应该出现在开发调试工具中,绝不能带到生产环境。把 isDebugMode 做成独立构建变体,是防止手滑开启的安全底线。
5.3 批量请求并发与取消机制的边界
批量测试一组接口时,需要同时发出多个请求。Dio 支持并发,但 UI 层的进度反馈和"取消全部"按钮要自己设计。
实现思路:批量请求前生成一组 CancelToken,每个请求绑定自己的 token。同时维护一个计数器,每完成一个请求就更新进度条。用户点击取消时,对未完成请求的 CancelToken 逐一发起取消,已完成的请求不做处理。
这里有几个边界必须注意:
- 已收到响应的请求不能被误标记为 canceled。
- 取消操作要幂等,重复点击取消不能抛异常。
- 所有请求结束后,无论成功、失败还是取消,都要走统一通知,刷新历史列表。
实际踩过的最隐蔽坑是:Dio 取消后回调依然会触发,CancelToken 抛出的异常会被 onError 拦截器捕获。如果不区分,UI 层会把"取消"显示成"失败"。我的解决方案是在错误处理里判断异常类型是不是 cancel,是就标记为 canceled 状态,否则才算失败。
6. 性能优化与后续扩展
6.1 Impeller 渲染引擎与列表性能的取舍
Flutter 新版本默认启用 Impeller 渲染引擎。在 OpenHarmony 设备上,我的实测感受是:列表滚动更丝滑,JSON 高亮树展开时的卡顿减少,历史列表几百条记录滚动的帧率明显更稳定。
但 Impeller 也有代价:Debug 模式下编译速度比 Skia 慢,热重载要等更久才能看到修改。所以开发期间我在工程配置里关闭 Impeller,用 Skia 跑热重载;发布 Release 版本时再打开 Impeller,拿到更流畅的渲染效果。
这个取舍建议同样适用于在 OpenHarmony 上做 Flutter 开发的场景:调试效率和发布质量,各取一边。
列表性能另一项优化是懒构建。请求历史列表用 ListView.builder,只构建视口内的列表项;JSON 高亮树同样遵循懒渲染,超过 500 个节点的响应体,只渲染当前展开层级的节点。配合 Isolate 格式化,整体响应体验维持在一个不错的水平。
6.2 从 API 测试到完整开发助手:后续扩展路径
API 测试模块做完后,我陆续加了三个扩展,都是在现有架构上顺理成章接上去的。
第一个是 WebSocket 测试。复用 core/network 的连接管理,增加消息收发面板和自动重连开关,这样在 OpenHarmony 设备上也能验证实时通道。
第二个是环境变量管理。把 Base URL、Token、公共 Header 抽成变量,在请求构造阶段统一替换。这个功能对团队联调帮助很大:不同环境(dev、test、prod)的切换,从改一堆参数变成选一个配置。
第三个是请求历史导出。把记录导出成标准 cURL 命令,方便复制到 PC 端排查。这个需求来自我自己的实际体会:在 OpenHarmony 设备上抓到问题请求,想放到电脑上复现,cURL 格式是最通用的交接方式。
后续还有两个计划方向:一个是在 WebView 里接入实时视频流,做远程设备画面预览;另一个是把 API 测试模块拆成独立的 Flutter 组件包,其他 OpenHarmony 项目可以直接依赖集成。
整个项目做下来,我最大的体会是:工具型 App 的开发重点不在功能花哨,而在开发链路要短、排错路径要清晰。选型时不要被平台绑住,Flutter 和 ArkTS 可以共存,网络层、状态管理、证书调试这些地基打牢了,后面接什么扩展都顺。