最近在把一套 Flutter 项目从 Android 往 OpenHarmony 上迁移时,第一个让我专门停下来重新设计的不是业务页面,而是已经维护了两年的三方库 api_exception_manager。它在原来 Android 和 iOS 双端项目里的职责很明确:全局网络异常拦截、统一错误归因、任务生命周期治理。说白了,App 里所有请求的超时、断网、业务报错,以及页面销毁后的异步任务清理,都归它管。到了鸿蒙上,这套逻辑不能直接从 Java 和 Kotlin 平移到 ArkTS,而且它恰好是一个把 Dart 层和原生层串起来的插件,所以适配难度比普通业务代码高出不少。
这篇文章不是 SDK 文档的翻译,而是我实际迁移过程中的完整记录。里面包括了哪些能力能直接复用、哪些必须重写、全局异常拦截在 OpenHarmony 上怎么做才不丢数据,以及任务生命周期管理从 Flutter 引擎侧到 UIAbility 侧的桥接方案。如果你也在做 Flutter 库的鸿蒙适配,或者准备把现有 App 迁到鸿蒙生态,这篇应该能帮你少踩几个坑。
1. 这个库在我的项目里到底管了哪些事
1.1 没有统一治理时的网络错误长什么样
做过 Flutter 网络层的同学应该都体会过这种场景:Dio 的 onError 回调分散在各个模块里,有人把超时错误提示成“网络异常”,有人把业务错误码直接抛给 UI,更常见的是页面已经销毁后,异步任务还在跑,最终回调访问了 dispose 之后的 State,控制台里刷出一串 Unhandled Exception。这些问题单独看都不致命,但累积到一定规模就会变成线上排查的灾难现场——同一个接口的错误,在十个模块里有十种表现形式。
我项目里实际出过一次比较典型的线上事故:服务端高峰期超时,客户端一个页面里有 6 个并发请求同时出错,因为每处都自己处理错误,结果弹了 6 个不同的错误提示框,而且其中一个页面已经切走了,错误回调还在往 ViewModel 里塞状态。当时排查了很久才发现是某个模块的 catch 分支没有判断 mounted。这个教训直接让我决定把网络异常统一收口。
治理前后的差别可以用一张表简单对照:
| 维度 | 治理前 | 治理后 |
|---|---|---|
| 超时提示 | 各模块自行弹窗,文案混乱 | 统一文案、统一 UI 行为 |
| 请求重试 | 部分模块有本地重试,大量重复代码 | 重试策略集中配置,全程可观测 |
| 失败归因 | 依赖开发翻日志,信息零散 | 统一 traceId 加阶段标记,一键溯源 |
| 页面销毁后回调 | 偶发 Unhandled Exception | 任务被取消,回调被安全拦截 |
| 业务错误码 | 各端映射逻辑不一致 | 单一错误码归因器 |
1.2 api_exception_manager 的核心抽象方式
api_exception_manager 这个库做的事,可以概括成三个层面。
第一是异常模型统一。它把 DioException、SocketException、业务错误码,甚至是本地缓存读写失败,全部包装成统一的 ApiException 对象,带上错误码、发生阶段、堆栈、请求上下文。业务层不需要关心底层是什么异常来源,只需要处理一种类型。
第二是拦截器链。在请求发出前和响应返回后各有一个拦截环节,前者处理鉴权、链路追踪 ID 注入,后者处理错误归一化、重试判断、降级策略。拦截器链不是简单的 List 遍历,每层拦截器都消费异常并返回一个 ActionResult,决定是放行、重试、降级还是终止。
第三是任务生命周期管理。它内部维护一个 TaskManager,每个网络请求都会被注册成一个 Task,关联到当前的页面或业务 scope。页面销毁时统一取消未完成的任务,并且对已经排队的回调做安全检查,避免销毁后还收到网络响应。
这套设计在 Android 上的实现依赖了 Activity 的 lifecycle 回调和网络状态监听,而这两块恰恰是迁移到 OpenHarmony 时最先要动刀的地方。我适配时定下的原则是:Dart 层只做协议和调度,不改任何对外 API;把“感知系统生命周期”“获取网络状态”“收集原生侧异常堆栈”这三类能力抽成 Platform 接口,分别写 Android 实现和 OpenHarmony 实现。这样上层业务代码在换平台后一行都不用改。
2. 鸿蒙适配的第一道坎:Flutter 插件的平台层改造
2.1 OpenHarmony 上 Flutter 插件的目录结构与脚手架
Flutter 插件在 OpenHarmony 上的工程结构,和标准 Flutter 插件相比多了一个 ohos 目录:
api_exception_manager/ ├─ lib/ # Dart 代码,跨端复用 ├─ android/ # Android 原生实现 ├─ ios/ # iOS 原生实现 ├─ ohos/ # OpenHarmony 原生实现(ArkTS) │ ├─ index.ets # 插件入口,需要实现 FlutterPlugin 接口 │ ├─ src/main/ets/ # 实际原生逻辑 │ └─ build-profile.json5 # 鸿蒙构建配置 └─ pubspec.yaml有一个细节值得注意:OpenHarmony 社区对 Flutter 插件的规范还在演进,不同版本的 SDK 对插件注册方式有细微差异。我在适配时锁定了社区维护的 flutter_flutter 分支版本,没有追最新主分支,因为稳定版的 API 文档和示例更全,遇到问题也更容易在社区找到同路人。这一点对库适配特别重要——库是给所有人用的,优先兼容稳定方案,而不是追新。
在 ohos/index.ets 中,插件需要注册原生的 MethodChannel handler。以我用的版本为例,核心结构大致是:
import { FlutterPlugin, MethodCall, MethodChannel } from '@flutter/engine'; export class ApiExceptionManagerPlugin implements FlutterPlugin { private channel?: MethodChannel; onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel = new MethodChannel( binding.getBinaryMessenger(), 'api_exception_manager' ); this.channel.setMethodCallHandler((call: MethodCall) => { // 处理来自 Dart 侧的方法调用 }); } onDetachedFromEngine(): void { this.channel?.setMethodCallHandler(null); this.channel = undefined; } }这里要特别强调 onDetachedFromEngine 里把 handler 置空。Android 插件开发时很多人会漏掉这一步,在鸿蒙上一样会踩雷——插件被 detach 之后如果 handler 还活着,很容易出现僵尸调用,Dart 侧随机收到一条来自旧通道的响应。
2.2 原生能力从 Kotlin 平移到 ArkTS
原来的 Android 实现里有几件事依赖系统 API,迁移到鸿蒙后对应关系如下:
| 能力 | Android 实现 | OpenHarmony 实现 |
|---|---|---|
| 网络状态感知 | ConnectivityManager + NetworkCallback | 网络管理 API 加系统广播监听 |
| 生命周期感知 | Application.registerActivityLifecycleCallbacks | EntryAbility.onForeground / onBackground / onDestroy |
| 原生线程堆栈 | Thread.getAllStackTraces | 目前获取受限,采用降级方案 |
| 崩溃捕获 | UncaughtExceptionHandler | 对接系统 crash 日志服务 |
实际写下来发现,前两类在鸿蒙上都有关键 API 可用,只是回调时机和 Android 不完全一样。而“原生线程堆栈”这一项,OpenHarmony 上目前能拿到的信息比 Android 少,我的处理是降级:只采集 Dart 层堆栈,加上请求启动时的时间戳和 Task 状态,足够定位绝大多数网络问题,不纠结于完整的原生线程栈。
另外还要注意 ArkTS 的语法风格和 Kotlin 差异很大,最明显的是空安全处理更严格。Kotlin 里一个可空类型用?.就带过去了,ArkTS 里对 nullable 的检查要求更细,稍不注意就会出现编译告警。我迁移时出现过一次比较典型的编译不过:一个成员变量可能在 onBackground 之前没被初始化,ArkTS 编译器要求必须显式判空才能使用,被迫把所有晚初始化字段都改成了undefined初始化加运行时校验。
2.3 方法通道的类型映射差异
Flutter 的 MethodChannel 在 Android 上和鸿蒙上的类型映射有细微差异。Android 标准映射里 Java 的 Map、List、Int 都有明确对应;鸿蒙 ArkTS 侧通过兼容层做转换,实际踩到的坑是:Dart 侧传 Int64 类型的 ID 给鸿蒙原生时,到达 ArkTS 侧后可能变成 number 或 string,取决于通道序列化方式。这会导致原生侧用严格相等比较时判断失败,进而引发回调匹配不到任务的 bug。
我的解决方案很土但很有效:在所有跨通道传递的 ID、时间戳、错误码上统一使用 String 类型,禁止传数字。虽然多了一次字符串转换的开销,但对通道消息来说完全可以忽略,而且彻底规避了类型不一致造成的隐性 bug。这个规范也写进了团队的三方库开发文档里,后续其它插件做鸿蒙适配时直接沿用。
3. 全局网络异常拦截的适配实操
3.1 异常模型统一与拦截链设计
适配第一件事是保证 Dart 侧对外 API 不变。库原来的用法大致是:
ApiExceptionManager.instance.configure( onException: (ApiException e) { // 统一处理:埋点、提示、上报 return ExceptionAction.retry; }, retryCount: 2, retryDelay: const Duration(milliseconds: 500), );底层拦截链我拆成了三段:请求拦截器、响应拦截器、异常归因器。请求拦截器负责把 Task 注册到 TaskManager,并注入 traceId;响应拦截器把各种底层异常翻译成 ApiException;异常归因器根据错误码决定重试、降级还是直接抛给业务层。
在鸿蒙适配中没有改这个模型,因为 Dart 层并不关心底层是哪个平台发的请求。核心代码结构大致是这样:
class ExceptionInterceptorChain { final List<ExceptionInterceptor> _interceptors = []; void process(ApiException exception, TaskContext context) { var current = exception; for (final interceptor in _interceptors) { final result = interceptor.handle(current, context); if (result.action == ExceptionAction.retry) { _scheduleRetry(result, context); return; } if (result.action == ExceptionAction.cancel) { context.task.cancel(); return; } current = result.exception ?? current; } } }这里有一个容易被忽略的设计点:拦截器链必须持有 TaskContext,而不是只持有异常本身。因为重试和降级都需要知道当前任务的剩余次数、所属 scope、是否已经被页面取消。如果没有 TaskContext,拦截器就只能做纯静态判断,无法感知动态的任务状态。
3.2 重试、降级与熔断的开关配置
对网络异常做重试不是无脑重试,我在库里的默认配置是:
- 超时、断网、连接重置:可重试,最多 2 次,指数退避(500ms 到 1000ms)
- 4xx 业务错误:不重试,直接进入业务提示
- 5xx 服务端错误:可重试 1 次,但如果连续 3 次 5xx,触发熔断,10 秒内不再发起新请求
重试逻辑放在异常归因器里,而不是放在 Dio 拦截器之外,是为了保证重试也经过异常模型统一归因,避免某次重试失败后错误信息风格突变。
鸿蒙适配中有一个和 Android 不同的点:OpenHarmony 的网络栈在部分设备上对弱网场景的表现不如 Android 稳定,DNS 解析偶尔会卡到 3 秒以上。所以我把超时配置从原来的 connectTimeout 10 秒、receiveTimeout 15 秒,调整为 connectTimeout 15 秒、receiveTimeout 20 秒,并且把 DNS 解析失败归入可重试集合。这个调整在实测中把弱网场景的请求成功率从 96.1% 提到了 98.7%,代价是错误提示晚出现几秒,但用户感知反而是变好的——因为多数情况下重试一次就成功了。
注意一个细节:熔断状态是全局共享的,不是单请求维度。我实现了一个简单的滑动窗口计数器,在原生层保持同步。这样即使同时有多个请求并发失败,熔断也能生效,而不是每一个请求都单独重试,把自己打成对服务端的二次攻击。
3.3 把异常上报做扎实,避免假阳性
异常模型接好、拦截链跑通之后,我发现线上上报的“断网异常”数量异常高,后来一查,不是真的断网,而是 OpenHarmony 上部分设备在冷启动后网络权限尚未完成初始化时,第一个请求就发出去,直接被底层判定为 no route to host。这些请求发生在网络能力就绪之前,属于典型的假阳性。
处理方案是在请求拦截器里加一个网络就绪闸门:调用一个由原生侧提供的 isNetworkReady 能力,如果返回 false,请求统一进入延时队列,等网络状态广播确认就绪后再发出。这个闸门只在 App 冷启动后的前 5 秒内启用,避免影响正常请求速度。我特意没有把闸门做成永久性的,否则每次请求都多一次原生调用,反而引入额外延迟。
同时,上报逻辑本身也要做缓冲。原来 Android 端异常上报是直接走 OkHttp 打点到统计服务,鸿蒙上我改成了先写入本地缓冲队列,每 10 秒批量 flush 一次。好处是避免异常风暴时打爆链路层,坏处是如果应用被强杀,最后几秒的缓冲数据会丢。但在实际场景里,丢几秒的统计数据和打爆网络栈两者之间,我选前者。
4. 任务生命周期管理:别等引擎告诉你,要自己感知
4.1 Flutter 自带生命周期的盲区
Flutter 里开发者最熟悉的是 WidgetsBindingObserver.didChangeAppLifecycleState,它能收到 resumed、inactive、paused、detached 这些状态。但这套机制感知的是 Flutter 引擎所在窗口的生命周期,在鸿蒙上有两个明显盲区。
第一,应用的 UIAbility 已经 onBackground,但 Flutter 引擎的 paused 可能延迟几百毫秒才到,这段时间内的异步任务可能在错误时机执行。第二,页面级的销毁,比如用户从最近任务里划掉应用,Flutter 侧可能直接走到 detached,但中间不会给业务层一个明确的“立即取消所有任务”的信号。
在 Android 上我可以用 LifecycleObserver 精确拿到 onStop、onDestroy;鸿蒙上对应的则是 EntryAbility 的 onBackground、onDestroy。这些回调需要桥接到 Dart 层,才能让 TaskManager 在正确的时间点做清理。无视这个盲区的后果是:任务取消时机不稳定,部分状态回调泄漏到销毁后的页面里,用户体感就是偶发闪退或者页面复用异常。
4.2 UIAbility 生命周期到 Dart 层的桥接
我在 ohos 侧使用 EventChannel 把生命周期事件推送到 Dart 侧:
// EntryAbility.ets import { UIAbility } from '@kit.AbilityKit'; export default class EntryAbility extends UIAbility { onBackground() { // 通过 EventChannel 广播给 Dart LifecycleBridge.instance?.sendEvent('onBackground', { timestamp: Date.now(), }); } onDestroy() { LifecycleBridge.instance?.sendEvent('onDestroy', { timestamp: Date.now(), }); } }Dart 侧在 TaskManager 里订阅:
_lifecycleChannel.receiveBroadcastStream().listen((event) { final name = event['name'] as String; if (name == 'onBackground' || name == 'onDestroy') { _taskManager.cancelAllTasks(reason: name); } });这里有一个值得细说的点:onBackground 时我并不是立即取消所有任务,而是标记任务进入“迟到风险”状态,给一个 5 秒的宽限期。因为后台立即取消任务,会导致用户切回 App 时那些本来马上能返回结果的请求全部需要重发。5 秒之后仍未完成的任务才强制取消,并触发错误归因器生成一个“任务被生命周期中断”的 ApiException。
这个宽限期设计来自一次真实事故:用户在看新闻详情页时切到微信回消息,不到 10 秒切回来,发现详情页空白,重新加载了一遍。原因就是 onBackground 后所有请求被立即取消。加了宽限期后,短后台切换的任务成功率明显提升。
4.3 在生命周期节点做任务清理的真实收益
库内置的 TaskManager 会记录每个任务的启动时间、绑定的业务 scope、当前状态。业务层在页面销毁时只需要调用:
ApiExceptionManager.instance.taskManager .cancelTasksByScope(pageScopeId);被取消的任务在回调层会收到一个 CancelledException,而不是静默消失。这样做的核心收益是杜绝 Unhandled Exception,以及避免回调在销毁后的 State 上执行。实测接入这套生命周期管理后,崩溃日志里的 LateInitializationError 和 Unhandled Exception 数量减少了大概八成。
另外要注意,鸿蒙侧 UIAbility 的 onDestroy 触发时机在部分设备上可能晚于 Flutter 引擎 detach,所以我在 TaskManager 里加了一道兜底:当 Flutter 引擎侧收到 detached 时,把该应用会话内所有 scope 的任务全部取消。两道清理逻辑是互补关系,不能互相替代。只依赖其中任何一边,都会有漏网之鱼。
生命周期事件在适配过程中实际上组成了这样一套处理矩阵:
| 系统事件 | Dart 收到时机 | TaskManager 动作 |
|---|---|---|
| onForeground | 引擎 resumed 前约 200ms | 解除后台暂停标记,允许新任务入队 |
| onBackground | 引擎 paused 前约 500ms | 启动 5 秒宽限期,不立即取消 |
| 宽限期超时 | 精确计时触发 | 强制取消未完成任务,生成中断异常 |
| onDestroy | 引擎 detach 前或后,设备差异 | 立即取消当前 scope 全部任务 |
| engine detached | 引擎生命周期终点 | 兜底取消全部任务 |
5. 实测中踩过的一组坑及其排查链路
5.1 任务取消事件滞后导致的回调泄漏
第一次跑通完整链路后,发现一个诡异问题:页面已经销毁,网络请求已经被 TaskManager 标记为 cancelled,但过了一会控制台还是打印出了网络回调。排查链路是这样的。
先怀疑取消逻辑没执行,打印日志确认 TaskManager 确实在 onDestroy 触发了 cancelTasksByScope。再怀疑任务没有真正中断,Dio 的 cancel 确实会让请求抛 CancelledException,这点也验证过了。最后才发现问题不在请求侧,而在响应侧:请求已经发出,原生层 hold 住的 HttpClient Future 并没有因为 cancel 而真正 abort,Dart 层 Future 提前完成后,回调链路上那个陈旧的 Future 仍然执行了 onSuccess 分支。
解决方法是在 Task 内部维护一个 cancelled 标志位,所有回调在分发前先检查标志位:
if (_taskCancelled || !scope.isActive) return;这个标志位检查发生在 Future.then 之前,而不是之后,保证已经排队回调的 Future 也过不了闸门。这个改动看起来简单,但确实需要回调分发和任务状态更新之间严格按照“先更新状态,再触发取消通知”的顺序执行,否则状态更新晚于通知,就会出现竞态。
5.2 网络状态监听的重复注册
另一个坑出现在鸿蒙侧网络状态监听上。Android 实现里我在 Application 初始化时注册一个全局网络回调,鸿蒙侧也照着做了,但忽略了 UIAbility 的 onForeground 和 onBackground 多次切换导致的监听器重复注册。第一次测试时只是多打印两行日志,没在意;直到线上出现“网络状态回调风暴”的告警,同一个广播在一秒内触发了 37 次,把拦截器里的降级逻辑反复触发,部分请求被重复重试。
修复方式很直接:在 onBackground 中注销网络监听,在 onForeground 中重新注册,并确保注册前注销旧实例。但这里又引出一个新问题:重新注册之间如果有几毫秒间隙,恰巧来了网络状态变化,就会丢失一次状态通知。所以我改成在 unregister 前先把当前状态缓存,register 后立刻用缓存补发一次状态。这套“先缓存,后重注册,再补发”的处理,既避免了重复监听,又不丢事件。
排查这类问题的经验是:不要只盯着实现代码看,先确认平台回调的注册和注销是不是成对出现。鸿蒙侧的 UIAbility 生命周期方法和 Android 的 Activity 生命周期非常相似,但应用模型下回调触发次数和嵌套关系不完全一致,最容易出的问题就是“只注册不注销”。
5.3 在 OpenHarmony 上验证稳定性的方法
库适配完,最怕的是“本机跑通了,但不敢保证线上稳”。我这边验证稳定性用的是一套组合拳。
第一,用 OpenHarmony 官方的 XTS 认证套件跑基础兼容性测试,重点看应用状态切换和网络异常注入这两类用例。XTS 认证测试对开发者来说不是可选项,鸿蒙生态分发时这是硬门槛,早跑早发现问题,别等适配全部完成后才去碰。
第二,在弱网环境下做真实场景模拟。用路由器限速和丢包工具,把上下行延迟调到 800ms 以上、丢包率 5%,重跑首页 20 个接口的全链路用例。主要观察三件事:超时后重试是否触发、任务取消后是否还有回调泄漏、降级方案是否在预期时间内生效。
第三,做小范围真机验证,覆盖不同 SoC 方案和系统裁剪程度的设备。OpenHarmony 的碎片化比 Android 好一些,但不同设备厂商对系统裁剪程度不同,生命周期回调时序会有差异。有的设备 onDestroy 会先于 Flutter engine detach,有的会反过来。
我建议在库的初始化阶段输出一份“生命周期时序诊断日志”,把 UIAbility 回调、Flutter 引擎状态、TaskManager 事件统一打点。上线后如果遇到生命周期相关异常,直接拉日志对照,不用再靠猜。这个诊断日志帮我定位了至少三次来自设备差异导致的任务取消顺序问题。
6. 一点个人经验与后续计划
这次适配最终没有改 Dart 层任何对外接口,所有平台差异都被压在 ohos 原生层和少量 Dart 内部适配代码里。这个结果符合我一开始定的原则:三方库做鸿蒙适配,优先保证 API 稳定,让上层业务迁移成本趋近于零。实际交付后,项目里其他模块接鸿蒙的过程也确实没有遇到因为异常库引发的额外改动。
如果接下来你还想在这个方向上继续深入,我个人建议优先研究两件事:一是 OpenHarmony 上 Flutter 引擎的渲染落地方式,它会直接影响页面销毁时生命周期回调的精确时机;二是库的自动化测试建设,把生命周期时序和异常拦截链路的用例用 flutter_test 和鸿蒙侧集成测试一起固化下来。稳定性的价值,往往在平台切换那一刻体现得最明显。
最后分享一个小技巧:做鸿蒙适配时,别急着把原生能力一次性配齐,先跑通一条最核心的链路,比如“请求失败、异常模型转换、任务取消”这一段,把基础打通后,再往上叠加网络监听、堆栈采集、冷启动闸门这些能力。每加一块,就回归一次之前的用例。整个适配过程会可控很多,出问题时也容易定位。这个节奏,比一次性重写全部逻辑再集中调试要高效得多。