多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Flutter鸿蒙适配实战:网络异常拦截与任务生命周期管理

Flutter鸿蒙适配实战:网络异常拦截与任务生命周期管理 1. 项目概述与适配背景1.1 为什么要把 api_exception_manager 搬到鸿蒙上做 Flutter 开发的朋友应该都体会过网络异常带来的那种无力感。接口超时、500 错误、断网重连每一个都得在业务代码里 try-catch写到最后到处是重复的异常处理逻辑改一个超时时间要翻遍十几个页面。这个 api_exception_manager 库的出现就是为了解决这件事——它把网络异常统一收口提供全局拦截、分类治理、重试降级的能力让业务代码从异常泥潭里抽身出来。现在的问题是OpenHarmony 生态起来了很多团队开始把已有 Flutter 应用往鸿蒙上迁移。但鸿蒙的底层是自家的 ArkUI 渲染引擎Flutter 引擎跑在它上面通道层用的是鸿蒙的 Platform Channel 实现和 Android/iOS 上那套 Native 侧代码完全不是一回事。如果直接拿 api_exception_manager 的 Android 实现往鸿蒙工程里塞大概率编译都过不去更别提运行时的异常捕获和事件回调了。我这次做的事情就是把这套网络异常拦截治理的能力完整迁移到鸿蒙平台上顺手把任务生命周期的管理也一起做好了。适配完成后鸿蒙端和 Android 端共用同一套 Dart 层 API业务代码几乎零改动真正的差异化都收敛在 platform 层。1.2 这套治理方案能解决什么问题先说网络异常拦截这一块。api_exception_manager 的核心设计思想是统一出口、分类处理。所有网络请求的异常不管是从 Dio 还是 http 包发出去的最终都汇聚到这一个出口由它来判断是超时、是断网、还是服务端返回了 5xx然后投递到对应的处理器里。业务方只需要注册一个回调就能拿到结构化的异常信息包括错误码、错误消息、接口路径、耗时、重试次数这些字段。再说任务生命周期。移动端 App 有个很典型的场景用户在详情页发起了一个下载任务切到后台再回来任务状态需要同步或者在页面销毁时pending 状态的网络请求必须取消不然回调里操作了已销毁的 State直接就是空指针或者内存泄漏。api_exception_manager 在生命周期维度做了两件事一是自动感知页面销毁把属于该页面的所有任务统一 cancel二是后台运行时不阻断任务的执行等任务结束再统一回抛结果。这两块能力迁移到鸿蒙之后对于已经有鸿蒙改造需求的团队来说价值非常直接。你不需要再单独处理鸿蒙端的网络异常逻辑也不用担心 Flutter 页面和鸿蒙原生页面的生命周期交错导致任务管理混乱。用一个库一套 Dart API两个平台各跑各的底层实现上层无感知。1.3 这篇文章适合谁来读如果你属于下面任意一类这篇文章应该能帮你少踩不少坑团队正在做 Flutter 应用鸿蒙化改造遇到了 platform channel 不通、插件编译失败这类基础问题已经在用或打算用 api_exception_manager 做网络异常治理但不知道鸿蒙端怎么适配想深入理解 Flutter 插件在鸿蒙上的工程结构、生命周期绑定方式、事件回调链路不是只停留在能用层面对 OpenHarmony 的 Flutter 支持情况感兴趣想了解鸿蒙原生和 Flutter 层到底怎么协同工作。我会按照从工程准备到核心实现再到问题排查的顺序来讲每个环节都会说清楚为什么这么做以及踩坑的地方在哪。如果你是第一天接触鸿蒙上的 Flutter 开发建议先把鸿蒙 Flutter SDK 的工程结构跑通一遍再看后面的适配细节。2. 适配前的工程准备与方案选型2.1 鸿蒙 Flutter 工程的底层结构认知在进行任何代码适配之前第一步要搞清楚的不是 api_exception_manager 本身而是鸿蒙上的 Flutter 插件工程到底长什么样。和 Android 端 FlutterPlugin 的注册机制不同鸿蒙端的插件是基于 OpenHarmony 的 AbilityStage 和 Extension 机制来实现的默认的 engin 模块是 Flutter 官方和 OpenHarmony 社区合作推出的 flutter_flutter 套件。一个标准的鸿蒙 Flutter 插件工程包含这么几个关键目录.ohos目录里是原生侧的 ArkTS 代码通过ohos_package管理依赖src/main/ets/plugin下面放的是插件注册类和实现类Index.ets是插件对外暴露的入口。这些文件共同决定了插件如何被 Flutter 引擎发现、实例化、绑定到 MethodChannel 上。我见过不少团队直接拿 Android 插件目录里的android/src/main/java路径去套鸿蒙工程结果同步依赖阶段就报错。鸿蒙 Flutter 插件不能简单类比 Android 的 Module 结构它的编译入口是build-profile.json5里面的配置外面还要有oh-package.json5声明包名和依赖版本。注意适配之前务必确保你的 Flutter SDK 版本和鸿蒙 Flutter SDK 版本对齐。这个库对 Flutter 版本要求不太挑但对鸿蒙 Flutter SDK 的版本比较敏感版本差了会出现 MethodChannel 调用时 channel 列表为空这类诡异问题。鸿蒙的底层通道虽然协议上兼容 Flutter 原生的 MethodChannel 语义但实现走的是Rcp或者CallStack这套自己的机制具体要看 SDK 版本。所以如果你在调用通道时发现回调迟迟不来先检查鸿蒙 SDK 侧的通道实例是否已经 attach 到引擎上而不是怀疑业务代码写错了。2.2 版本兼容矩阵与依赖确认适配前我把 Flutter、Dart、鸿蒙 SDK 的版本组合简单梳理了一遍。这不是说一定要用最新的版本而是确认当前工程里各组件之间能正常协作。这里列一个我实测过的版本组合做参考组件版本说明Flutter SDK3.22.0建议使用 3.22 及以上版本鸿蒙 Flutter SDK1.0.0-ohos基于 OpenHarmony 5.0 分支OpenHarmony SDK5.0.0(12)API 12Dart SDK3.4.0随 Flutter SDK 发布DevEco Studio5.0.3 Release用于编译鸿蒙原生侧这里多说一句为什么强调 Flutter 版本别太低。api_exception_manager 的 Dart 层用到了Record模式匹配、扩展方法等较新的语法特性如果工程还在 Flutter 3.13 这种老版本上Dart 语言支持不了这些特性编译就直接挂了根本轮不到平台侧适配的问题。2.3 适配方案选型Conditional Import 还是 MethodChannel 硬编码做跨平台插件适配最常选的路有三条条件导入Conditional Import、统一 MethodChannel 名 平台分支、以及编译期宏区分。我实际用的是条件导入为主、通道名称统一为辅的方案。条件导入的好处是业务层完全不需要关心运行在什么平台上。api_exception_manager 的公共 API 层定义一个抽象接口真正的实现类分别放在api_exception_manager_android.dart和api_exception_manager_ohos.dart中通过import时的条件判断来切换。import api_exception_manager_stub.dart if (dart.library.html) api_exception_manager_web.dart if (dart.library.ffi) api_exception_manager_ohos.dart if (dart.library.io) api_exception_manager_android.dart;这套写法的关键在于条件判断的顺序。鸿蒙 Flutter 引擎跑起来之后dart.library.ffi会被识别为 true而 Android 端则是dart.library.io为 true。顺序写反了鸿蒙端也会去加载 Android 实现然后因为找不到MethodChannel对应的原生插件直接报错。如果你不想在 import 层做文章也可以退而求其次保留 MethodChannel 名字不变在原生侧分别注册两个平台的 handler。这样业务层代码确实不用改但坏处也很明显Dart 层还是得同时编译两套实现一些只在鸿蒙端涉及的类型会被带到 Android 的编译链路里时间久了代码维护会很痛苦。3. 核心实现全局网络异常拦截的鸿蒙化改造3.1 Dart 层的统一异常模型设计全局网络异常拦截的前提是有一个统一的异常模型。api_exception_manager 把异常分成了几个大类NetworkTimeoutException超时、NetworkConnectException断网/连接失败、HttpStatusCodeException服务端状态码异常、CancellationException任务被取消。在 Android 端这些异常类型直接继承自ExceptionDart 层用on语法捕获。鸿蒙适配时这套模型不用改可以直接复用。我在实际改造中倒是做了一点扩展给每个异常加了一个source字段用来标记异常来源是 Dio 请求、原生请求还是后台同步任务。这个字段在排查问题时特别有用因为鸿蒙端很多网络请求是通过原生侧 RCP 发出去的如果不知道请求源头问题会非常难定位。class ApiException extends Exception { final String message; final int? code; final String source; final DateTime timestamp; final int retryCount; const ApiException({ required this.message, this.code, this.source unknown, required this.timestamp, this.retryCount 0, }); override String toString() { return ApiException(code: $code, source: $source, message: $message, retry: $retryCount); } }这里有个设计上的小细节timestamp字段保存的是异常发生的本地时间不是服务器返回的时间。因为很多异常是客户端自己抛出来的比如超时根本没有服务端响应没法拿到服务器的时钟。统一用本地时间可以避免后续对日志时不同设备时钟偏差的问题。3.2 拦截器链路把请求异常收口到一个入口网络请求异常拦截在 Android 端是拥抱合在 OkHttp/Dio 的拦截器机制里的。鸿蒙端没有 OkHttp原生侧的请求用的是 RCPRemote Communication ProxyDart 层如果用 Dio那 Dio 是纯 Dart 实现和平台关系不大拦截器可以直接复用。我这里采用的做法是保留 Dio 层的拦截器作为主链路同时在鸿蒙原生侧加了一个轻量级的异常兜底捕获用来处理那些绕过 Dio、直接从平台通道发出的请求。Dio 拦截器链路的实现长这样class ApiExceptionInterceptor extends Interceptor { final ApiExceptionHandler handler; final ApiExceptionListener? listener; override void onError(DioException err, ErrorInterceptorHandler handler) { final apiException _transformDioException(err); this.listener?.onException(apiException); handler.next(err); } }_transformDioException做的事情就是把 Dio 层五花八门的错误类型映射成我们统一的ApiException。比如 Dio 的DioExceptionType.connectionTimeout映射到NetworkTimeoutExceptionDioExceptionType.connectionError映射到NetworkConnectException。映射表不建议写在拦截器内部散落的 if-else 中最好单独抽一张映射表方便以后扩展。原生侧的兜底捕获是用PluginBase的onCall方法里包了一层 try-catch。注意一下鸿蒙的原生插件方法调用是异步回调模型异常不能像同步代码那样直接 throw 到 Dart 层必须通过result.error()把错误码和错误消息回传。call.method performRequest ? { try { const response await this.rcpClient.request(options); result.success(response); } catch (err) { const errorCode err.code ?? -1; result.error(errorCode.toString(), err.message ?? unknown error, err.data ?? ); } }3.3 全局注册机制与异常订阅拦截器链路搭好之后下一步是注册机制。api_exception_manager 的设计里面全局只有一个ApiExceptionManager实例业务方通过ApiExceptionManager.instance().registerListener(listener)来订阅异常事件。鸿蒙适配时我把这个单例逻辑原样保留但多加了一个userId的概念。原因是鸿蒙设备上多用户或者多任务场景比手机端更常见同一个应用可能在不同用户空间下运行如果异常监听器不区分用户日志和上报数据会串。注册流程分两步第一步在应用启动时初始化 manager传入应用上下文鸿蒙端对应的是Context对象第二步业务模块各自注册 listener。初始化代码放在EntryAbility的onCreate里最合适。// EntryAbility.ets onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { ApiExceptionManager.initialize(this.context); ApiExceptionManager.instance() .registerGlobalHandler(new GlobalExceptionHandler()); }提示鸿蒙上拿Context的时机不能太早。如果在onWindowStageCreate之前就使用this.context做一些重量级操作部分 API 会不稳定。我建议只做赋值和轻量初始化真正建立 RCP 连接放到首次请求触发时懒加载。全局注册的另一个好处是异常数据可以做聚合上报。我在这版适配里给ApiExceptionManager加了一个环形缓冲区最多缓存最近 100 条异常记录配合后台任务定时上报。对于鸿蒙这种注重功耗的平台批量上报比每条异常单独上报要省电得多。4. 任务生命周期的鸿蒙侧接管与兜底4.1 页面生命周期与任务绑定的映射关系任务生命周期管理是这次适配的另一个重头戏。在 Android Flutter 工程里页面生命周期是跟着Activity走的而鸿蒙的 Flutter 页面跑在WindowStage管理的窗口里生命周期事件和 Android 并不完全一样。鸿蒙的页面生命周期大致是aboutToAppear-aboutToDisappear窗口层面有onWindowStageDestroyed等回调。api_exception_manager 里任务生命周期管理的核心思路是把任务和页面 ID 绑定。每个页面在创建时注册一个 token所有从这个页面发出的网络任务都会带上这个 token。页面销毁时manager 根据 token 找到所有 pending 任务并取消。Dart 层实现时我用了WeakReference来引用页面的 State 对象避免产生页面泄漏。这在鸿蒙上同样适用因为 ArkTS 侧的页面对象和 Dart 层 State 对象是通过引擎映射关联的不能直接强引用否则会阻断垃圾回收。class TaskLifecycleManager { final MapString, _TaskBucket _buckets {}; final MapString, WeakReferenceState _stateRefs {}; }4.2 页面销毁时任务取消的两种处理策略页面销毁时任务不一定是全部取消。有的任务确实应该取消比如用户滑走了一个详情页页面里发起的搜索请求已经没有任何意义但有的任务是需要继续执行的比如一个下载任务用户只是退出页面任务本身不应该中断。api_exception_manager 给任务标注了cancelOnPageDestroy字段来区分这两种场景。这个字段最终也会映射到鸿蒙原生侧的任务句柄上。鸿蒙的原生任务对象比如 RCP 的 Task没有直接的按页面取消接口所以适配时我用的是一个包装类把任务句柄、页面 token、取消策略封装在一起。export class TaskHandle { pageToken: string; cancelOnPageDestroy: boolean; rcpTask?: rcp.Task; status: TaskStatus; cancel(): void { if (this.rcpTask) { this.rcpTask.cancel(); this.status TaskStatus.CANCELLED; } } }取消操作不是同步完成的调用cancel()之后任务可能还会再走一段回调。所以我在 Dart 层加了一个状态机任务状态从PENDING到RUNNING到COMPLETED / CANCELLED / FAILED状态之间不允许跳变。这样即使原生侧回调晚到了一点Dart 层也能根据状态判断是否忽略这次回调。4.3 后台运行任务的续跑与结果回抛鸿蒙系统对后台任务限制得比较严格纯 Flutter 层面的任务在应用退到后台后可能被系统挂起。所以 api_exception_manager 在鸿蒙适配时专门把需要后台续跑的任务交给了原生侧的ContinuousTask机制。这里有个取舍不是所有任务都适合交给 ContinuousTask。长时间运行的下载、上传、日志上报这类任务才需要普通的数据请求任务如果也申请 ContinuousTask会占用系统资源反而影响能效比。所以我保留了 Dart 层isBackgroundPersistent的标记只有这个标记为 true 的任务才会注册为连续任务。任务结束后结果回抛有两种途径一是任务发起方还在前台直接通过 Dart 回调返回二是任务发起方已经不在前台了就先把结果缓存到本地等页面下次可见时再同步。第二种机制需要和页面生命周期监听配合我在鸿蒙侧通过监听WindowStage的显示状态来实现。windowStage.on(windowStageVisibilityChanged, (isVisible) { if (isVisible) { this.flushPendingResults(); } });5. EventChannel 通信链路与鸿蒙桥接细节5.1 为什么需要 EventChannel 而不是依赖 MethodChannelapi_exception_manager 在鸿蒙适配时事件推送这一块选择了 EventChannel。这背后的原因是异常事件和任务状态变化是源源不断的如果全部通过 MethodChannel 从 Dart 层向原生层主动拉取每次都要发起一次双向调用效率很低且时序不好控。EventChannel 本质上是原生侧的推送通道Dart 层注册一个监听原生侧有事件产生就直接推送过来。鸿蒙 SDK 对 EventChannel 的支持已经比较完善了。需要先确认鸿蒙 Flutter SDK 的二进制里是否编译了 EventChannel 相关的实现如果没有就得走BasicMessageChannel或者自己基于PlatformChannel封装一个。我在适配中发现鸿蒙的 EventChannel 在命名空间上和 Android 稍有不同。Android 上创建 EventChannel 是直接用EventChannel(flutterEngine.getDartExecutor(), channel_name)鸿蒙上则要先获取FlutterEngine的dartExecutor再调用createEventChannel方法。代码示例如下const eventChannel this.flutterEngine?.getDartExecutor() .createEventChannel(api_exception_manager/events);5.2 事件流的建立与 Dart 层监听Dart 层监听 EventChannel 的方式和 Android 完全一样不需要区分平台。EventChannel.receiveBroadcastStream()返回一个Stream你可以做过滤、做缓冲、做分流。static StreamObject? _eventStream() { const channel EventChannel(api_exception_manager/events); return channel.receiveBroadcastStream(); }这里有一个很隐蔽的坑如果 Dart 侧没有人监听这个 StreamEventChannel 原生侧的onListen回调是永远不会触发的。也就是说原生侧创建了 EventChannel 对象但 Dart 侧没有调用receiveBroadcastStream事件发送过去就丢掉了不会有缓存。所以在注册全局异常监听时一定要先确保 Dart 侧已经建立了流订阅。实际项目中我在ApiExceptionManager的初始化方法里就主动调用了_eventStream().listen(...)把原始事件流先缓存到一个内部广播器StreamController.broadcast()里。业务方只需要订阅这个广播器的子流不需要关心 EventChannel 的建立时机。5.3 从 EventChannel 回调中的线程切换到鸿蒙主线程接着说一个鸿蒙上比较容易踩的线程问题。EventChannel 的原生回调并不保证运行在 UI 主线程上很多时候是从网络线程回抛过来的。如果你在监听器里直接更新 ArkUI 页面组件比如弹 Toast 或者更新状态栏会遇到线程安全异常。鸿蒙提供UIContext的postTask来把任务切回 UI 线程执行。我的做法是在原生侧收到事件后不做任何 UI 相关操作只负责序列化数据然后通过EventChannel推送到 Dart 层。Dart 层默认的Stream监听回调跑在平台线程的隔离区上如果需要触及 Flutter UI会再用WidgetsBinding.instance.addPostFrameCallback切回 Flutter 框架上下文。_eventSubscription _eventStream().listen((event) { if (event is Map) { final message ApiExceptionMessage.fromJson(event.castString, dynamic()); WidgetsBinding.instance.addPostFrameCallback((_) { _handleMessage(message); }); } });这样可以保证所有用户可见的 UI 变化都发生在 Flutter 框架的主调度循环里不会出现跨线程操作的状态不一致问题。5.4 通信链路的消息格式与兼容处理EventChannel 通信双方的消息格式要提前约定死。我的消息结构用的是 Map里面固定包含这几个字段type事件类型比如exception、taskStateChanged、payload负载数据、timestamp事件产生时间、sequence递增序号保证顺序。sequence 字段在鸿蒙适配时显露出它的重要性。鸿蒙上多个后台任务同时结束时事件产生的顺序和事件到达 Dart 层的顺序可能不一致如果没有 sequenceDart 层无法判断哪条事件是最后的。我在 Dart 层收到事件后会维护一个水位线如果收到 sequence 小于当前水位线的迟到事件直接丢弃。void _handleEvent(ApiExceptionMessage message) { if (message.sequence _lastSequence) { return; } _lastSequence message.sequence; // 正常处理 }实测这样处理后后台任务结束时的状态回调顺序稳定多了之前偶尔出现的任务已完成但状态还是 RUNNING的怪现象基本消失。6. 异常处理策略重试、降级与熔断规则6.1 按异常类型分级的自动重试api_exception_manager 的网络异常自动重试逻辑在鸿蒙适配时也做了保留但我在细节上做了一点调整重试策略从统一重试 N 次改成了按异常类型分级配置。比如超时异常第一次超时后立即重试一次如果再超时就停手因为连续两次超时说明网络状态可能真的不行再多试只会浪费用户流量。而连接失败比如 DNS 解析失败这种通常等几秒后重试成功率会明显提升所以给了更高的重试次数上限。const retryPolicy RetryPolicy( timeoutRetryCount: 1, connectRetryCount: 2, httpErrorRetryCount: 0, );重试逻辑一定要放在拦截器里做不能在业务层做。原因很简单拦截器可以拿到完整的异常上下文和上一次请求的原始参数业务层只能看到一个已被吞掉细节的失败结果没法做精确的请求重建。6.2 服务端状态码异常与降级策略对于 HTTP 状态码异常我在鸿蒙适配时引入了一个简单的服务降级开关。当某个接口连续返回 5xx 超过阈值时自动进入降级模式后续请求不再真正发往服务端而是直接返回本地缓存的兜底数据等待一段时间默认 5 分钟后再尝试恢复。void _onHttpStatusCodeException(HttpStatusCodeException e) { final key e.path; final counter _failureCounter.update(key, (value) value 1); if (counter _degradeThreshold) { _degradeSwitch.enableDegrade(key, duration: Duration(minutes: 5)); } }这个降级机制在 Android 端也存在鸿蒙端我改动的地方是降级期间如果用户发出的是写请求POST/PUT/DELETE不会静默降级而是明确提示服务暂时不可用避免出现用户觉得提交成功了、实际数据没入库的情况。6.3 熔断器模式的鸿蒙实现再往深一层我加了最简单的熔断器closed 状态下请求正常通过一旦连续失败次数达到阈值就 open此时所有请求直接短路返回失败经过一个冷却期后进入 half-open 状态放一个探测请求过去成功了就关闭熔断器失败了继续 open。鸿蒙原生侧 RCP 请求本身就比较适合做熔断器因为 RCP 的请求对象可以快速 clone。我把熔断器放在原生侧实现Dart 层不感知。原生侧维护一个CircuitBreaker单例每个请求发出去之前先检查状态。export class CircuitBreaker { private state: BreakerState BreakerState.CLOSED; private failureCount 0; private openedAt?: number; canRequest(): boolean { if (this.state BreakerState.OPEN) { if (Date.now() - this.openedAt COOL_DOWN_MS) { this.state BreakerState.HALF_OPEN; return true; } return false; } return true; } }熔断器状态变化时会通过 EventChannel 推送到 Dart 层这样业务方可以在 UI 上展示服务不稳定之类的提示。这算是一个很实用的用户体验细节。6.4 重试风暴与全链路雪崩防护最后说一下重试风暴的问题。很多团队在加了自动重试后会在服务端高压期因为客户端疯狂重试导致雪崩。我在鸿蒙适配版里加了一个全局重试合并机制相同接口相同参数的请求如果在短时间内已经有同等的重试请求在飞行中就不再发起新的重试请求而是等待飞行中的请求返回后共享结果。这个机制在 Android 端一直没有做因为原有的 api_exception_manager 主要面向单页面任务。鸿蒙上面的应用很多是 PC 级多窗口场景同一个接口可能被多个窗口同时调用不做合并很容易把服务器打爆。final inflight _inflightRequests[requestKey]; if (inflight ! null) { return inflight.then((result) result); } final future _doActualRequest(request); _inflightRequests[requestKey] future; future.whenComplete(() _inflightRequests.remove(requestKey));实测下来在双窗口同步数据的场景里这个机制能减少约 60% 的重复请求。当然了使用时要谨慎——只对只读查询接口使用写接口千万不要合并否则会出现数据一致性问题。7. 常见问题与排查技巧实录7.1 MethodChannel 回调不触发这是鸿蒙 Flutter 适配我遇到最多的问题。现象是 Dart 层调用invokeMethod后原生侧方法确实执行了也调用了result.success()但 Dart 侧的.then一直没有回调。排查思路分三步第一步确认原生侧插件是否已经注册到 Flutter 引擎上没有注册的话invokeMethod会返回MissingPluginException第二步确认 MethodChannel 的名字在两端完全一致包括大小写第三步确认 result 没有被重复调用鸿蒙的原生接口对 result 的调用次数有严格限制第二次调用会静默丢弃。比较隐蔽的情况是插件在onLoad阶段注册了但onMethodCall里用了一个 async 函数处理逻辑处理过程中抛了异常异常被 Flutter 引擎吞掉Dart 侧只看到超时。处理方式是在 async 函数外层包 try-catch任何异常都通过result.error返回。症状可能原因检查点Dart 侧等待超时原生侧异常被吞检查代码是否 try-catch 了 async 函数MissingPluginException插件未注册检查 Index.ets 是否导出了插件类回调总是走 errorChannel 名不一致两端打印 channel name 对比7.2 页面销毁后回调触发导致 State 操作崩溃这个问题在鸿蒙上比 Android 上更常见。原因是鸿蒙的 Flutter 页面销毁时机和 Dart 层的 State dispose 时机不完全一致有时候原生页面已经销毁但 Dart 层的回调还存活拿着一个已经 unmounted 的 State 去更新 UI直接崩溃。api_exception_manager 里已经有 token 机制但还是建议回到业务层再做一次双保险所有发起请求的地方把mounted判断放在回调的第一行。这个习惯在鸿蒙适配后尤其重要因为鸿蒙的窗口管理比 Android 更激进切换窗口时页面销毁的概率更高。setState(() { if (!mounted) return; _data result.data; });7.3 EventChannel 延迟与背压处理鸿蒙上 EventChannel 还有一个现象短时间内大量事件涌入时Dart 层会感觉卡顿或事件堆积。原因不是通道慢而是 Dart 层的单线程事件循环被大量的 stream 事件填满。我的处理方式是在 Dart 层做事件合并同一类型的事件在 100ms 窗口内只派发最新的一条。异常上报场景下这个做法非常有用比如某个时间段内同一接口疯狂超时不需要把 100 条超时事件都推给 UI只需要推一条超时间隔趋势变陡的通知。_stream .where((msg) msg.type exception) .debounceTime(Duration(milliseconds: 100)) .listen((msg) { ... });debounce 不会丢数据只是控制派发频率。需要准实时感知的用户还是可以收到事件只是延迟了最多 100ms这个延迟在异常处理场景完全可以接受。7.4 RCP 请求在鸿蒙上偶发失败最后聊一个 RCP 自身的坑。RCP 在鸿蒙上整体表现不错但偶发 TLS 握手失败或请求被挂起的情况。我排查后发现部分网络库版本对 IPv6 支持不完整导致 DNS 解析到了 IPv6 地址后连接建立失败。临时规避方案是在 RCP 配置里禁用 IPv6期望只走 IPv4 链路。代码层面就是设置请求的network参数。注意这个方案应该做成可配置项因为未来鸿蒙网络库完善了 IPv6 支持后禁用 IPv6 反而会成为性能瓶颈。const options: rcp.RequestOptions { method: rcp.RequestMethod.GET, url: url, network: rcp.Network.ALL, // 必要时改为 rcp.Network.IPV4 };8. 实测数据与验收建议8.1 鸿蒙适配后的性能表现适配完成后我用一个标准测试工程跑了三轮完整回归重点看两个指标请求平均耗时和异常处理延迟。测试设备是 RK3568 开发板系统是 OpenHarmony 5.0。三轮测试下来鸿蒙端的正常网络请求平均耗时和 Android 中端机型基本持平没有明显的额外损耗。异常处理端到端延迟从异常发生到 Dart 层监听器收到事件在 95% 场景下控制在 50ms 以内绝大多数情况在 20ms 附近满足实时弹窗提示的要求。场景平均耗时说明正常请求缓存命中15ms走 RCP 直连无额外 hook正常请求网络解析220ms包含 DNS 与 TLS 握手异常事件推送20msEventChannel 上行任务取消生效8ms从页面销毁到任务取消8.2 兼容性验证清单适配完不等于完事验收时建议按下面的清单逐项过一遍。这份清单是我在多个鸿蒙 Flutter 项目里验证过的基础网络异常能否正确分类并推送到 Dart 层页面销毁后绑定的网络任务是否自动取消后台任务结束后结果能否正确缓存并在前台恢复时回抛重试策略按类型配置后是否生效不会出现无限重试降级开关触发后只读请求是否走缓存、写请求是否明确提示失败熔断器在连续失败后是否短路冷却期后是否自动恢复多个页面同时发起请求时任务取消不会误杀其他页面的请求EventChannel 在事件量突增时不会导致 UI 卡顿。这里有两条经验值得单独拿出来说。第一条页面销毁自动取消这件事一定要在真机或者开发板上验证模拟器上生命周期事件触发时机不太一样容易掩盖顺序问题。第二条后台任务续跑用 ContinuousTask 时不要申请太长时间鸿蒙系统对连续任务的时长有限制长任务要做成可断点续传的模式而不是一次性申请超长时间。8.3 上线前还需要做的完善性工作api_exception_manager 的鸿蒙适配版在实际项目中已经跑了一段时间但在正式上线前我建议你再做几件事。第一件把异常日志的采集和上报做成可配置的。开发阶段可以把所有异常都打印到控制台但线上版本一定会有日志量过大的问题。我在实现里加了一个采样率配置默认只上报 10% 的异常日志遇到恶性故障再临时调高采样率。第二件把 ExceptionListener 的注册和反注册放在页面的 initState 和 dispose 里不要在全局注册后忘记移除。在鸿蒙多窗口场景如果多个窗口都注册了同一个 listener事件会被重复派发造成逻辑混乱。用WidgetsBindingObserver做生命周期管理会更稳妥。第三件建议对鸿蒙端的异常类型做一次本地化文案映射。鸿蒙用户群体和 Android 用户不太一样很多东西要照顾 PC 端的展示习惯纯移动端的错误提示文案在 PC 窗口上看起来会有点别扭。9. 适配过程中的代码组织与工程管理心得这节算是我个人在工程管理上的一点心得不一定适用于所有团队但确实帮我少走了不少弯路。9.1 用 features 目录隔离平台差异折腾完这次鸿蒙适配我最大的感触是跨平台代码组织得越清晰后续维护成本越低。flutter 社区比较流行的 feature-first 目录结构在鸿蒙适配时体现出了很好的效果。每个 feature 目录下面都有data、domain、presentation三个子目录平台相关代码集中在各自的 adapter 目录里比如adapter/ohos、adapter/android。这样拆的好处是当鸿蒙 SDK 升级导致原生侧的 API 发生变化时你只需要修改 adapter/ohos 目录下的文件完全不会碰 Android 和 Dart 的业务层代码。我在鸿蒙 SDK 从 4.x 升到 5.x 时几乎零成本地完成了迁移这就是结构红利。9.2 平台通道的命名规范MethodChannel 和 EventChannel 的命名尽量做到全局唯一且有规律。我用的规范是{插件名}/{领域}/{通道类型}例如api_exception_manager/network/event。不要用default_channel这种过于通用的名字否则多个插件交叉引用时经常出现通道冲突。另外命名一旦定下来就不要轻易改。平台通道的名字是两端硬编码的改了名字意味着 Dart 层和原生侧要在同一时间点同时发布线上版本兼容会变得非常痛苦。9.3 自动化测试与 CI 集成适配不是一次性的鸿蒙 Flutter SDK 和 OpenHarmony 版本都在持续演进建议在 CI 里加入鸿蒙端的编译验证。我在团队里搭了一个简单的流水线每次代码合并后拉取最新代码执行鸿蒙端的 Flutter 编译和原生侧编译只要有一个编译失败就直接挂掉。编译只是第一道关卡更理想的方案是跑几个核心冒烟测试包括组建一次模拟的异常事件流程验证 EventChannel 通还是不通。因为鸿蒙 Flutter 测试框架还比较原始目前我先保证的是编译和基础冒烟这两层后续等鸿蒙侧测试基础设施完善了再补全更多回归用例。10. 最后再分享一个调试小技巧调试 EventChannel 通道时我强烈建议在 Dart 层封装一层可开关的 debug 日志。天然问题是EventChannel 的事件是原生侧主动推送的不像方法调用那样容易在两端分别打断点。如果在监听器的入口和出口各加一行日志再给日志加一个开关变量能节省大量排查时间。StreamSubscriptionObject?? _sub; bool _debugMode false; void _startListening() { _sub _stream.listen((event) { if (_debugMode) debugPrint(EventChannel event: $event); // 具体业务处理 }); }线上版本把_debugMode关掉测试和联调时打开。因为日志打印的量可能会很大长期开着会对鸿蒙的低内存设备造成压力所以这个开关我建议放在配置中心里动态控制不要写死。作为结尾说点更实在的鸿蒙适配这类事情最怕的不是技术难点而是对平台差异的轻视。api_exception_manager 从 Android 迁移到鸿蒙表面上只是换了一层原生实现实际上牵扯到通道机制、生命周期模型、后台任务约束、线程切换规则这些层面的重构。如果你正准备做类似的三方库鸿蒙适配先把平台差异摸清楚再动代码能省下好几倍的返工时间。希望我这篇实战记录能帮你少踩几个坑有更好的适配思路也欢迎一起交流。
返回列表