
一、页面已经退出结果却刚刚回来BridgeScope是一个 Flutter 页面调用 HarmonyOS 原生插件选择归档文件。用户点击“选择报告”Dart 侧通过MethodChannel发起请求原生侧打开选择器再把结果返回 Flutter。正常路径很简单真正的故障发生在用户动作更快的时候。20:57:13.806请求REQ-2057-018发出20:57:14.311用户返回上一页页面 generationG-14被销毁20:57:15.044原生选择器返回文件。旧实现仍然执行setState()调试环境报出组件已经 dispose部分版本还会把结果写进新打开的同名页面。这不是 HarmonyOS 文件选择器的问题也不完全是 Fluttermounted判断的问题。异步调用横跨 Dart 页面、通道、插件实例和系统能力任何一层生命周期结束后迟到结果都需要有明确归属。只在最后一行判断mounted可以避免 UI 报错却挡不住旧请求覆盖仓库状态、占用缓存或触发埋点。本次改造把请求定义为一段可取消的会话请求 IDREQ-2057-018页面代次G-14通道名com.example.bridgescope/archive。状态链为IDLE → INVOKING → DETACHED → IGNORED随后新页面G-15发起的请求正常进入COMPLETED。两条链互不干扰。二、MethodChannel 的 FIFO 不是生命周期保证Flutter 的 MethodChannel 是具名异步方法通道同名通道在逻辑上属于同一通信端点框架保证调用顺序。但“按顺序传输”不等于“页面仍然存在”也不等于一个长操作可以自动取消。平台返回结果时Dart Future 会继续完成除非应用自己建立请求身份和取消语义。我们先给每次调用增加三项上下文requestId唯一标识业务请求generation标识页面实例deadlineMs限制最长有效时间。插件侧回传时原样带回Dart 层只有在三项条件仍有效时才提交结果。这段代码解决什么问题。它把裸invokeMethod()封装成带身份、超时和状态的请求防止页面直接消费无归属 Future。enumBridgeState{idle,invoking,completed,failed,ignored}classBridgeRequestT{BridgeRequest({requiredthis.requestId,requiredthis.generation,requiredthis.deadlineMs,});finalStringrequestId;finalStringgeneration;finalint deadlineMs;BridgeStatestateBridgeState.idle;bool detachedfalse;}classArchiveBridge{staticconstMethodChannel_channelMethodChannel(com.example.bridgescope/archive);FutureMapString,dynamic?pick(BridgeRequestrequest)async{request.stateBridgeState.invoking;finalrawawait_channel.invokeMapMethodString,dynamic(pickArchive,String,dynamic{requestId:request.requestId,generation:request.generation,deadlineMs:request.deadlineMs,},);if(request.detached||DateTime.now().millisecondsSinceEpochrequest.deadlineMs){request.stateBridgeState.ignored;returnnull;}request.stateBridgeState.completed;returnraw;}}detached不只是 UI 标记它代表这个请求已经失去消费者。结果回来后不会写页面也不会进入业务仓库。超时采用绝对时间而不是Future.timeout()单独抛错因为原生操作可能仍然完成即使 Dart 侧超时迟到结果也必须被识别为无效。易错点是复用一个全局BridgeRequest。并发两次调用会互相覆盖状态因此每次请求都要有独立对象管理器只保存活动请求映射。三、取消不是强行终止系统选择器用户退出页面时我们希望“取消请求”但实际存在两种取消。软取消表示 Dart 不再接收结果原生选择器可以自然结束硬取消表示插件调用系统能力关闭当前操作。不同能力对硬取消支持不同不能假设每个系统页面都可以由插件远程关闭。BridgeScope默认使用软取消页面 dispose 时把请求标记为DETACHED同时通过通道发送detachRequest。原生侧收到后清理插件上下文如果系统能力仍返回只记录一次忽略日志不再创建大对象或复制文件。这段代码解决什么问题。它集中管理活动请求并在页面销毁时把 generation 对应的全部请求转为 detached。classBridgeScopeManager{finalMapString,BridgeRequest_active{};BridgeRequestcreate(StringrequestId,Stringgeneration){finalrequestBridgeRequestMapString,dynamic(requestId:requestId,generation:generation,deadlineMs:DateTime.now().millisecondsSinceEpoch15000,);_active[requestId]request;returnrequest;}FuturevoiddetachGeneration(Stringgeneration)async{finaltargets_active.values.where((request)request.generationgeneration).toList();for(finalrequestintargets){request.detachedtrue;request.stateBridgeState.ignored;awaitArchiveBridge.detach(request.requestId,generation);_active.remove(request.requestId);}}}循环里逐个发送是为了让日志顺序可读不代表所有业务都必须串行。页面有大量并发请求时可以批量传递 ID但仍要在 Dart 内先同步标记 detached不能等待通道返回后才失效。四、插件侧也必须认识 requestId如果只有 Dart 层保存 requestId原生插件仍可能在页面退出后执行文件复制、缩略图解析和权限查询。我们让 ArkTS 侧建立RequestContext只保存最小状态请求 ID、generation、是否 detached、创建时间。系统回调进入后先检查上下文再做重操作。这段代码解决什么问题。它在 HarmonyOS 插件适配层阻断迟到回调并保证同一个请求只能完成一次。interfaceRequestContext{requestId:stringgeneration:stringdetached:booleancompleted:booleancreatedAt:number}exportclassArchivePluginSession{privaterequests:Mapstring,RequestContextnewMap()register(requestId:string,generation:string):RequestContext{constcontext:RequestContext{requestId,generation,detached:false,completed:false,createdAt:Date.now()}this.requests.set(requestId,context)returncontext}detach(requestId:string,generation:string):void{constcontextthis.requests.get(requestId)if(!context||context.generation!generation)returncontext.detachedtrue}complete(requestId:string,path:string):PluginResult|undefined{constcontextthis.requests.get(requestId)if(!context||context.detached||context.completed){console.warn([BridgeScope] ignored request${requestId})this.requests.delete(requestId)returnundefined}context.completedtruethis.requests.delete(requestId)return{requestId,generation:context.generation,path,state:COMPLETED}}}插件不能只用 channel 名区分请求。同名通道允许连续调用甚至多个页面共享如果把“最后一个回调”默认交给“当前页面”旧结果就会污染新页面。generation让G-14与G-15即使使用同一通道也可以清楚分离。释放顺序同样重要。结果已经编码并交给通道后插件才删除上下文失败和取消路径也必须删除。项目增加 30 秒兜底清理防止系统能力永不回调时映射持续增长。DevEco Studio 图中左侧工程同时展示 Flutter 的archive_bridge.dart与 HarmonyOS 的ArchivePluginSession.ets中间代码圈出requestId和detached右侧模拟器显示请求正在等待底部日志记录G-14页面退出与迟到结果被忽略。模拟器统一放在右侧。五、mounted 仍然需要但它是最后一道门页面层还要使用mounted只是不能把全部责任交给它。业务仓库更新、临时文件复制和统计记录都应在 manager 确认请求有效后完成页面拿到的已经是“属于当前 generation 的结果”再用mounted保护setState()。这段代码解决什么问题。它展示页面如何创建代次、发起请求并在 dispose 时注销整代消费者。class_ArchivePageStateextendsStateArchivePage{finalBridgeScopeManagermanagerBridgeScopeManager();finalArchiveBridgebridgeArchiveBridge();finalStringgenerationG-14;StringstatusIDLE;FuturevoidpickArchive()async{finalrequestmanager.create(REQ-2057-018,generation);if(mounted)setState(()statusINVOKING);finalresultawaitbridge.pick(request);if(!mounted||resultnull||request.generation!generation)return;setState(()statusCOMPLETED);awaitArchiveRepository.commit(result);}overridevoiddispose(){manager.detachGeneration(generation);super.dispose();}}dispose()不能await所以注销动作先同步修改本地状态再异步通知插件。即使通道通知失败Dart 侧也已经不会接受旧结果。插件侧还有自己的超时与 generation 检查形成两层边界。另一个细节是ArchiveRepository.commit()的位置。它必须在有效性判断之后否则 UI 虽然没更新旧请求仍可能改写业务数据。生命周期隔离不是为了消灭一条异常日志而是阻止失效请求产生任何可见副作用。六、用时间线复现而不是靠快速点按这类问题如果只靠测试人员“快点返回”很难稳定复现。我们给调试页增加可控延迟原生结果固定延后 1238 ms页面在 505 ms 时自动退出。测试日志如下20:57:13.806 I BridgeScope: requestREQ-2057-018 generationG-14 stateINVOKING 20:57:14.311 I BridgeScope: generationG-14 stateDETACHED 20:57:15.044 W BridgeScope: requestREQ-2057-018 callbackIGNORED reasonDETACHED 20:57:15.045 I BridgeScope: repository_writefalse temp_copyfalse之后重新打开页面生成G-15发起REQ-2057-019。该请求在页面存活期间返回状态进入COMPLETED文件名report_019.pdf耗时 742 ms。旧请求没有写仓库也没有覆盖新请求。运行图展示G-15的正常路径请求REQ-2057-019状态COMPLETED文件report_019.pdf耗时 742 ms。完整状态栏显示 20:57、Wi‑Fi、5G、信号和 76% 电量红色箭头强调“结果属于当前页面代次”。诊断图专门展示失败复现G-14在 20:57:14.311 已 detached20:57:15.044 的回调进入IGNORED仓库写入和临时复制均为 false。红圈放在时间差 733 ms 与reasonDETACHED上说明问题是如何被隔离的。七、热重载、引擎重建和同名通道开发阶段还有三个容易混淆的边界。热重载通常保留 Dart 状态不能用它验证完整插件重建热重启或引擎重建会重新注册插件旧上下文应清空。测试生命周期时要明确当前动作到底重建了 Widget、页面路由、Flutter Engine 还是整个应用。同名通道会互相干扰因此通道名要带稳定业务域不能由页面随机生成。请求隔离依靠 requestId 和 generation而不是创建无数通道。通道 codec 支持的数据类型也有限文件句柄、Native 对象和自定义类不能直接塞进参数跨边界传递应使用字符串、数值、列表和映射等兼容结构。如果插件返回大文件不要通过通道传全部字节。更合适的做法是返回受控 URI、沙箱路径或任务 ID再由业务层按权限和生命周期读取。否则页面退出后一个迟到的大消息仍会占用内存和解码时间。八、这次改造真正隔离了什么修复前我们只看到一句“组件已销毁后调用 setState”。修复后边界被拆成了四层页面 generation 决定消费者身份manager 负责请求状态与注销MethodChannel 只传输可编码消息HarmonyOS 插件适配器在重操作前检查请求是否仍有效。复测 200 次G-14的迟到结果全部进入IGNORED仓库写入 0 次临时文件复制 0 次G-15的正常请求按预期完成。页面退出不再意味着“祈祷回调别回来”而是一次明确的状态迁移。这套方法也适用于权限申请、扫码、分享、支付确认和长时 Native 计算。只要异步结果可能晚于页面生命周期就应该给它一个独立身份、一个消费者代次和一个可观察的失效状态。参考资料HarmonyOS 三方开发框架接入说明HarmonyOS 特性在 Flutter 侧的适配Flutter MethodChannel API