
地图编辑页上线前的最后一轮压测里业务逻辑没有崩内存曲线却像台阶一样只升不降。更怪的是从屏幕左缘返回时偶尔会拖动地图而从地图内部滑动时又可能直接退页。最终修掉的不是一个按钮而是两套 UI 栈对“这个手势归谁、这个纹理何时死亡”的不同理解。一、七次误返回和十二个活着的旧纹理Demo 叫HybridCanvas Lab路由是/editor/map。外层使用 HarmonyOS ArkUI 承载页面与系统返回手势内部嵌入 Flutter 的地图画布Flutter 又通过 PlatformView 接入原生渲染面。这个组合能复用现有地图编辑器但也把一次触摸和一次退出横跨了三层。问题最初有两个表象。连续做“进入编辑页—拖动地图—侧滑返回”二十轮有 7 次手势被错误消费有时想退页却移动了地图有时只是从边缘选取一个控制点页面就退出。与此同时热重载和反复进出页面创建了 12 个纹理句柄日志显示释放回调也走了原生侧的活跃句柄却仍是 3。开发机上还能继续跑真实长会话里已经出现预览变黑。把这两个问题分开修会很别扭因为根因都在所有权。手势开始时谁拥有序列必须在这一序列结束前保持不变页面销毁时谁拥有纹理也必须有唯一的释放路径。我的改造目标因此很明确用一次命中测试确定手势所有者用代际号挡住迟到消息用幂等释放把 PlatformView、纹理和回调一起解绑。二、别让每一个 move 事件重新投票旧实现把触摸点同时发给 ArkUI 和 Flutter再根据双方是否消费决定下一步。看似灵活实际造成了竞态Flutter 在第一帧识别为水平拖动ArkUI 在第三帧越过返回阈值两边都认为自己赢了。手指还没抬起所有者已经换了两次。我把规则缩成一次决策。触点落在左侧 24 vp 的系统返回热区且 Flutter 当前没有正在编辑的控制点时交给 ArkUI触点在地图内容区则交给 Flutter若 Flutter 打开弹层或处于绘制模式先由 Flutter 消化返回意图。所有权确定后保存pointerId直到UP或CANCEL才清空。当前问题是 ArkUI 页面既要接收原始指针又不能在序列中途改变所有者。下面的协调器只在DOWN计算一次后续事件必须命中同一个pointerId。它还把所有权变化写进可观察状态方便页面显示ARKUI → FLUTTER → ARKUI的验证轨迹。typeGestureOwnerNONE|ARKUI|FLUTTERclassGestureArbiter{privateowner:GestureOwnerNONEprivatepointerId:number-1privatereadonlyedgeVp:number24begin(id:number,xVp:number,flutterEditing:boolean):GestureOwner{if(this.owner!NONE)returnthis.ownerthis.pointerIdidthis.ownerxVpthis.edgeVp!flutterEditing?ARKUI:FLUTTERreturnthis.owner}current(id:number):GestureOwner{returnidthis.pointerId?this.owner:NONE}end(id:number):void{if(idthis.pointerId){this.ownerNONEthis.pointerId-1}}}这个类故意不知道导航栈也不直接调用 Flutter 通道。它只回答“谁拥有当前序列”动作由页面执行。这样做避免协调器变成另一个生命周期中心。多指场景是当前 Demo 的边界第一个有效指针获得所有权第二个指针若属于 Flutter 内容区则由 Flutter 内部处理缩放若产品需要跨层双指交互单pointerId方案就要升级为集合。CANCEL与UP同等重要。窗口失焦、系统手势接管或组件销毁都可能只收到取消不清空就会让下一次触摸沿用旧所有者。页面onDisappear里也会执行兜底cancelAll()这里不能只依赖正常抬手。三、返回不是事件广播而是一笔带确认的事务手势归属稳定后仍有一个时序洞ArkUI 判断返回向 Flutter 查询“是否有内部路由可弹出”Flutter 回复期间页面可能已经被系统关闭。迟到的回复再去更新状态就会触发无效页面写入更糟的是重复请求可能先弹 Flutter 子路由随后又弹 ArkUI 页面。为此我给每次返回分配requestId在 80 ms 内等待 Flutter 的canPop。返回true时只弹 Flutter 内部路由返回false或超时时才由 ArkUI 弹/editor/map。页面代际变化或已有请求进行中时新请求直接拒绝保证一次手势只有一个提交者。下面的代码解决返回决策的去重和迟到结果隔离。generation在页面挂载时递增销毁时再次递增回调只有在代际仍一致时才能提交导航。classBackTransaction{privateinFlight:booleanfalseprivategeneration:number0attach():number{this.generationreturnthis.generation}asyncrequest(canFlutterPop:()Promiseboolean,popArkUI:()void,popFlutter:()void):Promisevoid{if(this.inFlight)returnthis.inFlighttrueconsttokenthis.generationtry{constcanPopawaitPromise.race([canFlutterPop(),newPromiseboolean((resolve)setTimeout(()resolve(false),80))])if(token!this.generation)returncanPop?popFlutter():popArkUI()}finally{if(tokenthis.generation)this.inFlightfalse}}detach():void{this.generationthis.inFlightfalse}}这里的超时不是为了掩盖通道故障而是保证系统返回不能永久挂起。正式项目应另外记录BACK_QUERY_TIMEOUT并在下一次进入页面时检查 Flutter 引擎状态。detach()后旧回调即使到达也会因 token 不同而退出不能把inFlightfalse当作唯一保护因为新页面可能已经开始另一笔请求。20 轮压测后所有权轨迹按操作稳定为ARKUI → FLUTTER → ARKUI误返回从7降为0。这个指标比“手感正常”更有意义它来自自动化手势脚本每轮分别覆盖左缘返回、地图平移和二次返回。四、纹理释放必须从注册表消失第二个问题更像资源账本错误。插件的dispose()确实调用了原生销毁接口却还在消息通道闭包和一个全局Map中保留 PlatformView。纹理对象已经无法使用引用仍然存在热重载后新实例又用同一个业务 key 注册旧回调继续接收帧完成消息。我把所有句柄收进TextureLeaseRegistry。创建返回一个带代际的 lease所有异步消息都必须携带leaseId与generation释放动作先把状态改为RELEASING再停止生产者、注销帧回调、释放纹理最后从表中删除。任何一步重复执行都只返回已有结果。当前问题是页面退出、Flutterdispose、热重载和原生异常可能同时触发释放因此下面代码把释放做成单飞 Promise。后来者等待同一笔释放不会再次碰已经失效的纹理。interfaceNativeTexture{stopProducer():PromisevoidunregisterFrameCallback():voidrelease():Promisevoid}classTextureLeaseRegistry{privateleases:Mapnumber,NativeTexturenewMap()privatereleasing:Mapnumber,PromisevoidnewMap()register(leaseId:number,texture:NativeTexture):void{if(this.leases.has(leaseId))thrownewError(LEASE_DUPLICATED)this.leases.set(leaseId,texture)}releaseOnce(leaseId:number):Promisevoid{construnningthis.releasing.get(leaseId)if(running)returnrunningconsttexturethis.leases.get(leaseId)if(!texture)returnPromise.resolve()constjob(async(){awaittexture.stopProducer()texture.unregisterFrameCallback()awaittexture.release()this.leases.delete(leaseId)this.releasing.delete(leaseId)})()this.releasing.set(leaseId,job)returnjob}liveCount():number{returnthis.leases.size}}顺序不能随便换。如果先释放纹理、后停止生产者正在渲染的线程可能继续写入已失效表面如果只释放、不注销回调闭包依旧抓住 PlatformView。releaseOnce()也不能吞掉真实释放异常正式工程应在finally中将 lease 标记为BROKEN并上报同时阻止复用这个 Demo 为了让状态明确异常会让页面显示DIRTY不会伪装成CLEAN。工程结构也和第一篇完全不同pages/HybridCanvasPage.ets承载 ArkUI 外壳bridge/BackChannel.ets管返回事务gesture/GestureArbiter.ets管指针所有权platform/TextureLeaseRegistry.ets管原生资源Flutter 侧的map_editor.dart只处理内部路由与绘制状态。三条职责线互不越权。五、热重载不是正式生命周期却最容易暴露残留热重载只用于开发但它很擅长把资源边界的含糊放大。旧代码假设“页面离开才释放”热重载后 Dart 状态更新、PlatformView 宿主未必按照同样顺序销毁于是旧通道监听器和新监听器短时间并存。我的处理不是为热重载写特殊清理而是让每次 attach 都有新代际让旧代际消息自动失效。定位残留时常规内存快照一开始帮不上太多忙。Flutter 堆里能看到旧State已经消失ArkTS 堆里也只剩一个页面对象可 GPU 内存仍然不降。后来我把创建、绑定、停产、解绑回调、释放和删除注册表分别编号才发现第 9 次循环少了“删除注册表”这一步。原生释放成功只代表底层对象停止工作不代表 JavaScript 闭包、消息通道和业务索引已经断开。资源账本必须记录完整链路不能用最后一个 API 返回成功替代。为避免调试日志本身制造噪声我没有逐帧打印而是在状态边界打印一行结构化记录会话 ID、lease ID、代际、旧状态、新状态和原因。压测结束再输出汇总。如果某个 lease 停留在RELEASING超过两秒监控才附带最近五条事件。这样日志既能重建顺序又不会在高帧率下把真正的生命周期消息淹没。正式工程还应对 lease ID 做脱敏或会话内编号避免把底层句柄值写进线上日志。页面重新 attach 时ArkUI 生成新的hostGeneration并通过初始化消息发给 FlutterFlutter 创建纹理时回传同一代际。帧完成、返回查询、释放确认都要带它。收到旧代际消息只记一条STALE_MESSAGE_DROPPED绝不更新 UI也不注册回句柄表。这样即便开发工具改变了销毁顺序陈旧对象也无法重新获得控制权。代际号并不等于时间戳。它只在同一宿主进程内单调增加进程重启后可以从 1 重新开始真正区分会话的是hybrid_20261001_08。跨端消息因此同时携带会话 ID 和代际号前者挡住旧进程恢复出来的消息后者挡住同一进程里旧页面的迟到回调。只放其中一个在快速退出又进入相同业务路由时都会留下碰撞机会。我把onDisappear和最终aboutToDisappear分开前者只暂停帧生产允许短暂遮挡后恢复后者才执行detach()和releaseOnce()。如果在onDisappear就永久释放系统弹窗、半屏遮挡或短路由切换会导致回到页面时黑屏如果只在进程退出释放又会让导航栈中的每次离开都积累一个纹理。恢复路径也做了对称设计短暂遮挡回来时只调用resumeProducer()不会重新注册回调真正重新 attach 才创建新 lease。这个区分让restore41ms有稳定含义否则有的恢复复用纹理、有的恢复重建纹理同一个指标会混入两种完全不同的成本。压测脚本会先读取页面显示的代际与 lease再执行下一轮避免仅凭等待固定时长误以为渲染已经恢复。六、08:43 的账本终于对上了最终验证会话是hybrid_20261001_08时间 08:43路由/editor/map。自动化脚本执行 20 次进入、拖动、返回和热重载组合手势所有权轨迹ARKUI → FLUTTER → ARKUI返回冲突7 → 0纹理创建与释放12 / 12活跃句柄0页面恢复耗时41 ms最终状态CLEAN。这里的12 / 12不只是两边都打印过十二次。释放计数只在原生纹理真正从注册表删除后增加因此能和liveHandles0互相校验。41 ms从宿主 attach 到 Flutter 第一帧确认不包含编辑数据网络加载正式产品应该把冷启动、热恢复与纯页面返回分开统计。我还保留了一个故障注入开关让第 6 次纹理释放延迟 300 ms然后立刻重新进入页面。新实例创建成功旧释放完成后不会误删新实例因为 lease 带代际且 ID 不复用。若注册表只按业务 key 保存这个场景很容易出现“旧页面替新页面收尸”。七、几条比代码更值得留下的约束第一PlatformView 销毁后就是不可复用对象。无论 Flutter、ArkUI 还是原生侧都不应该保留一个“以后也许能接着用”的引用。第二手势仲裁必须以完整指针序列为单位不能让每个 move 事件重新竞争。第三跨运行时的异步请求都需要身份会话 ID、代际号、请求号缺一不可否则迟到消息和当前状态长得完全一样。这套方案没有解决所有混合栈问题。例如输入法焦点、无障碍节点合并、PlatformView 上的透明叠层仍需要单独验证纹理渲染的性能也受合成路径影响。它解决的是更基础的边界返回只能提交一次资源只能释放一次旧实例永远不能修改新页面。混合开发最容易让人陷入“哪一端有 bug”的争论。实际工程里两端单独看都可能正确错误发生在交接处。把所有权、代际和释放账本做成显式状态以后问题不再依赖复现者的手势描述也不再靠内存曲线猜测日志可以直接回答这次输入归谁这条消息属于哪一代这个句柄是否真的从系统里消失。参考资料Flutter 官方 Platform Views 指南Flutter State.dispose 生命周期说明Flutter PlatformView.dispose 接口说明