地图编辑页上线前的最后一轮压测里,业务逻辑没有崩,内存曲线却像台阶一样只升不降。更怪的是,从屏幕左缘返回时偶尔会拖动地图,而从地图内部滑动时又可能直接退页。最终修掉的不是一个按钮,而是两套 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的验证轨迹。
typeGestureOwner='NONE'|'ARKUI'|'FLUTTER'classGestureArbiter{privateowner:GestureOwner='NONE'privatepointerId:number=-1privatereadonlyedgeVp:number=24begin(id:number,xVp:number,flutterEditing:boolean):GestureOwner{if(this.owner!=='NONE')returnthis.ownerthis.pointerId=idthis.owner=xVp<=this.edgeVp&&!flutterEditing?'ARKUI':'FLUTTER'returnthis.owner}current(id:number):GestureOwner{returnid===this.pointerId?this.owner:'NONE'}end(id:number):void{if(id===this.pointerId){this.owner='NONE'this.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:boolean=falseprivategeneration:number=0attach():number{this.generation++returnthis.generation}asyncrequest(canFlutterPop:()=>Promise<boolean>,popArkUI:()=>void,popFlutter:()=>void):Promise<void>{if(this.inFlight)returnthis.inFlight=trueconsttoken=this.generationtry{constcanPop=awaitPromise.race([canFlutterPop(),newPromise<boolean>((resolve)=>setTimeout(()=>resolve(false),80))])if(token!==this.generation)returncanPop?popFlutter():popArkUI()}finally{if(token===this.generation)this.inFlight=false}}detach():void{this.generation++this.inFlight=false}}这里的超时不是为了掩盖通道故障,而是保证系统返回不能永久挂起。正式项目应另外记录BACK_QUERY_TIMEOUT,并在下一次进入页面时检查 Flutter 引擎状态。detach()后旧回调即使到达,也会因 token 不同而退出;不能把inFlight=false当作唯一保护,因为新页面可能已经开始另一笔请求。
20 轮压测后,所有权轨迹按操作稳定为ARKUI → FLUTTER → ARKUI,误返回从7降为0。这个指标比“手感正常”更有意义:它来自自动化手势脚本,每轮分别覆盖左缘返回、地图平移和二次返回。
四、纹理释放必须从注册表消失
第二个问题更像资源账本错误。插件的dispose()确实调用了原生销毁接口,却还在消息通道闭包和一个全局Map中保留 PlatformView。纹理对象已经无法使用,引用仍然存在;热重载后新实例又用同一个业务 key 注册,旧回调继续接收帧完成消息。
我把所有句柄收进TextureLeaseRegistry。创建返回一个带代际的 lease,所有异步消息都必须携带leaseId与generation;释放动作先把状态改为RELEASING,再停止生产者、注销帧回调、释放纹理,最后从表中删除。任何一步重复执行都只返回已有结果。
当前问题是页面退出、Flutterdispose、热重载和原生异常可能同时触发释放,因此下面代码把释放做成单飞 Promise。后来者等待同一笔释放,不会再次碰已经失效的纹理。
interfaceNativeTexture{stopProducer():Promise<void>unregisterFrameCallback():voidrelease():Promise<void>}classTextureLeaseRegistry{privateleases:Map<number,NativeTexture>=newMap()privatereleasing:Map<number,Promise<void>>=newMap()register(leaseId:number,texture:NativeTexture):void{if(this.leases.has(leaseId))thrownewError('LEASE_DUPLICATED')this.leases.set(leaseId,texture)}releaseOnce(leaseId:number):Promise<void>{construnning=this.releasing.get(leaseId)if(running)returnrunningconsttexture=this.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并通过初始化消息发给 Flutter;Flutter 创建纹理时回传同一代际。帧完成、返回查询、释放确认都要带它。收到旧代际消息只记一条STALE_MESSAGE_DROPPED,绝不更新 UI,也不注册回句柄表。这样即便开发工具改变了销毁顺序,陈旧对象也无法重新获得控制权。
代际号并不等于时间戳。它只在同一宿主进程内单调增加,进程重启后可以从 1 重新开始;真正区分会话的是hybrid_20261001_08。跨端消息因此同时携带会话 ID 和代际号,前者挡住旧进程恢复出来的消息,后者挡住同一进程里旧页面的迟到回调。只放其中一个,在快速退出又进入相同业务路由时都会留下碰撞机会。
我把onDisappear和最终aboutToDisappear分开:前者只暂停帧生产,允许短暂遮挡后恢复;后者才执行detach()和releaseOnce()。如果在onDisappear就永久释放,系统弹窗、半屏遮挡或短路由切换会导致回到页面时黑屏;如果只在进程退出释放,又会让导航栈中的每次离开都积累一个纹理。
恢复路径也做了对称设计:短暂遮挡回来时只调用resumeProducer(),不会重新注册回调;真正重新 attach 才创建新 lease。这个区分让restore=41ms有稳定含义,否则有的恢复复用纹理、有的恢复重建纹理,同一个指标会混入两种完全不同的成本。压测脚本会先读取页面显示的代际与 lease,再执行下一轮,避免仅凭等待固定时长误以为渲染已经恢复。
六、08:43 的账本终于对上了
最终验证会话是hybrid_20261001_08,时间 08:43,路由/editor/map。自动化脚本执行 20 次进入、拖动、返回和热重载组合;手势所有权轨迹ARKUI → FLUTTER → ARKUI,返回冲突7 → 0,纹理创建与释放12 / 12,活跃句柄0,页面恢复耗时41 ms,最终状态CLEAN。
这里的12 / 12不只是两边都打印过十二次。释放计数只在原生纹理真正从注册表删除后增加,因此能和liveHandles=0互相校验。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 接口说明