HarmonyOS 7 QuickDock 闪控窗开发实录 05:floatView × Stage:重复创建、监听冲突与窗口资源治理【鸿蒙心迹】 04 结束时QuickDock 已经能处理两个任务、前后台切换和一次FLOAT_SURFACE_LOST恢复。看起来功能已经很完整我真正开始做长时间测试以后问题反而集中出现了。最明显的一次是连续显示 / 隐藏闪控窗十几轮再从闪控球恢复回来屏幕上明明只有一个窗口HiLog 里同一条进度却打印了两次。继续跑几轮以后同一条任务状态变化会触发三次 Adapter update。这类问题比“窗口打不开”更麻烦。因为用户看到的是正常 UI后台却已经悄悄积累重复 listener 重复 timer 失效 adapter 隐藏窗口实例 旧 ball 实例第五篇不再加新能力只解决资源归属。本轮固定数据taskId: float_20261002_05 activeJob: upload_release_02 progress: 76% cycles: 12 showCalls: 24 duplicateCreateBlocked: 11 floatWindowId: quickdock_float_01 ballId: quickdock_ball_01 销毁前: window1 ball0 listener1 timer1 adapter1 销毁后: window0 ball0 listener0 timer0 adapter0 disposeCost: 33ms status: RESOURCE_CLEAN一、屏幕上只有一个窗口不代表工程里只有一个资源04 的异常恢复让 Float View 可以重建。如果只看视觉结果旧窗口消失 新窗口出现很容易觉得资源已经替换完成。但真正需要检查的是旧 Window 对象 旧 Adapter 旧 Task listener 旧 UpdateScheduler timer 旧 Floating Ball有没有一起退出。我后来把 QuickDock 的显示层资源归成五类FLOAT_WINDOW FLOATING_BALL TASK_LISTENER UPDATE_TIMER ADAPTER任何一个遗漏都可能在后续切换里放大。所以 05 新增registry/ └── QuickDockResourceRegistry.ets不是为了做一个“万能资源管理器”而是给所有显示层资源一个统一可观测入口。二、谁创建资源谁负责注册第一条规则非常简单创建点和注册点必须在同一个 Owner 里。以前 FloatManager 创建 AdapterDisplayModeCoordinator 负责 listenerUpdateScheduler 又自己创建 timer。销毁时需要跨三个对象猜谁还活着。现在资源创建后立即登记exporttypeResourceTypeFLOAT_WINDOW|FLOATING_BALL|TASK_LISTENER|UPDATE_TIMER|ADAPTERexportclassQuickDockResourceRegistry{privateresources:MapResourceType,SetstringnewMap()register(type:ResourceType,id:string):void{letbucketthis.resources.get(type)if(bucketundefined){bucketnewSetstring()this.resources.set(type,bucket)}bucket.add(id)}unregister(type:ResourceType,id:string):void{this.resources.get(type)?.delete(id)}}这段代码不负责真正销毁系统资源。它只记录现在应该还有什么真正释放仍然由资源 Owner 完成。三、重复 show 必须在创建之前就被挡住本轮总共调用showCalls24其中很多来自主页面进入 从闪控球恢复 Surface 恢复 前台恢复 用户再次点显示如果每个入口都直接create()窗口实例一定会越来越多。所以QuickDockFloatManager的 create 逻辑现在有单实例守卫exportclassQuickDockFloatManager{privatecreated:booleanfalseprivatereadonlywindowId:stringquickdock_float_01asyncensureShown(snapshot:QuickTaskSnapshot):Promisevoid{if(this.created){awaitthis.update(snapshot)QuickDockMetrics.recordDuplicateBlocked()return}awaitthis.adapter.create(this.windowId)this.createdtrueQuickDockResourceRegistry.shared().register(FLOAT_WINDOW,this.windowId)awaitthis.adapter.show(snapshot)}}当前 12 轮压力测试里duplicateCreateBlocked11这不是 11 次失败。它说明 11 次“本来可能重复创建”的调用被正确降级成 update。四、listener 最容易被“异常恢复”悄悄复制窗口重建以后我曾经写过rebuild → bind listener却忘了旧 listener 是否已经解除。于是listenerCount 1 → 2页面仍然正常直到下一次 TaskStore 更新才暴露。所以 listener 现在也有显式 IDexportclassTaskSubscriptionOwner{privatereadonlylistenerId:stringquickdock_task_listenerprivatebound:booleanfalsebind():void{if(this.bound){return}FloatTaskStore.shared().on(this.listenerId,this.onSnapshot)QuickDockResourceRegistry.shared().register(TASK_LISTENER,this.listenerId)this.boundtrue}unbind():void{if(!this.bound){return}FloatTaskStore.shared().off(this.listenerId)QuickDockResourceRegistry.shared().unregister(TASK_LISTENER,this.listenerId)this.boundfalse}}这条写法的重点不是 ID 字符串而是bind / unbind 幂等同一个 Owner 重复 bind 不会多注册一份。五、Timer 不能因为 hide 就忘记QuickDock 的进度 UI 还有一个 250ms 合并 Timer。它属于显示更新调度而不是业务任务窗口隐藏以后如果 Scheduler 仍然留着 pending timer可能发生窗口已经 hide timer 到点 Adapter update轻则多一条错误日志重则访问已经 dispose 的 Adapter。所以 05 把 Timer Owner 也收进 RegistryexportclassUpdateScheduler{privatetimer:number-1schedule(callback:()void):void{if(this.timer0){return}this.timersetTimeout((){this.timer-1QuickDockResourceRegistry.shared().unregister(UPDATE_TIMER,progress_update)callback()},250)QuickDockResourceRegistry.shared().register(UPDATE_TIMER,progress_update)}cancel():void{if(this.timer0){return}clearTimeout(this.timer)this.timer-1QuickDockResourceRegistry.shared().unregister(UPDATE_TIMER,progress_update)}}现在 Timer 不再是“某个工具类内部看不见的状态”。六、窗口、球、Adapter 的释放顺序不能随便这一篇我把最终收口顺序固定成停止 UI Timer → 解除 Task listener → hide / dispose Floating Ball → hide / dispose Float Window → dispose Adapter → 清 Registry为什么 Adapter 最后因为 Window / Ball 的 hide 和 dispose 可能还需要 Adapter 提供系统调用。如果 Adapter 先销毁后面的释放动作就只能进入异常分支。所以最终disposeAll()asyncdisposeAll():Promisevoid{this.scheduler.cancel()this.subscriptionOwner.unbind()awaitthis.ballManager.dispose()awaitthis.floatManager.dispose()awaitthis.adapter.dispose()QuickDockResourceRegistry.shared().assertEmpty()}本轮销毁耗时33ms这只是当前测试基线。真正的验收是count0七、hide 和 dispose 必须继续保持两个语义第二篇为了闪控窗 / 闪控球切换已经明确hide ! dispose05 不能为了“释放彻底”把这条边界破坏掉。当前切换到 Floating Ball → Float Window hide → Float Window 仍登记为已创建资源 任务真正结束 / Ability 收口 → Float Window dispose → Registry unregister所以销毁前的资源计数window1 ball0 listener1 timer1 adapter1是合理状态。Ball 当前不显示所以ball0但 Float Window 仍然存在。只有最终 dispose 后才要求全部归零。八、资源 Registry 不是“看到 0 就完事”还要看归属如果所有资源都放进一个总数count4根本不知道是谁没释放。所以 Registry 必须按类型统计FLOAT_WINDOW: 1 FLOATING_BALL: 0 TASK_LISTENER: 1 UPDATE_TIMER: 1 ADAPTER: 1出现异常时日志直接能定位UPDATE_TIMER leak而不是只显示RESOURCE_LEAK这种粒度对窗口能力非常重要。九、Task 完成以后UI 资源必须自动进入销毁流程第五篇还测试upload_release_02 → COMPLETED任务进入终态以后显示完成状态 → 保留短时间结果 → cancel timer → unbind listener → dispose display resources不能等用户手工关应用才释放。Task Registry 可以保留任务历史但显示资源不应该跟着历史一直存在。这也是业务历史和活动 UI 资源的边界。十、前后台切换不会重复创建资源04 有onBackground onForeground如果每次 Foreground 都执行create Float Window连续 12 轮前后台切换一定会出问题。所以现在 Foreground 做Registry 查询 → Window 是否仍存在 → Adapter 是否可用 存在 → update Surface lost → RecoveryCoordinator rebuild 不存在 → create once创建、恢复和更新三个分支明确分开。十一、异常恢复后旧 Adapter 要进入 DISPOSEDFLOAT_SURFACE_LOST重建时我给 Adapter 自己也加状态CREATED ATTACHED LOST DISPOSING DISPOSED新的 Adapter 只有在旧 Adapter 进入 DISPOSED 后才登记为活动实例。这样adapterCount才能长期保持1而不是视觉上只有一份Registry 里却两份。十二、DevEco 图里只看“12 轮以后资源是否还是可解释的”开发图统一 HiLogtaskIdfloat_20261002_05 resource probe cycles12 showCalls24 duplicateCreateBlocked11 floatWindowId quickdock_float_01 ballId quickdock_ball_01 before dispose: window1 ball0 listener1 timer1 adapter1 dispose order: timer → listener → ball → window → adapter disposeCost33ms after dispose: all0 statusRESOURCE_CLEAN这比“没有崩溃”更接近工程验收。十三、运行图直接展示销毁前和销毁后最终运行图当前任务float_20261002_05 upload_release_02 76%压力数据cycles12 showCalls24 blocked11销毁前window 1 listener 1 timer 1 adapter 1销毁后全部 0最终RESOURCE_CLEAN这张图真正回答的是QuickDock 已经不仅会创建系统窗口也知道什么时候、按什么顺序把所有展示资源还回去。十四、为什么 05 还不直接做 25 轮最终回归因为资源治理刚刚完成。这一篇先把计数口径 归属规则 幂等规则 释放顺序定死。如果直接把 25 轮数据堆进来出现 leak 时还要反过来补 Registry很难定位。所以 05 只跑 12 轮资源压力测试专门验证资源模型。06 再把这个模型带进全场景回归。十五、资源 Registry 也不能拥有业务资源这里我再强调一个边界。Registry 管理的是Window Ball Listener Timer Adapter不管理Task Runner 上传任务 压缩任务 业务 Repository任务生命周期仍归 TaskRegistry。否则一次窗口 dispose 很容易顺手把业务任务也销毁回到第一篇就已经否定的错误模型。十六、生命周期冲突最终都要变成“可失败指标”第五篇给下一轮留下的验收指标已经明确duplicateWindow listenerLeak timerLeak adapterLeak ballLeak stateLoss这些都不能靠肉眼判断。如果第六篇 25 轮以后任何一个不是 0就不允许最终 PASS。这也是 05 结束时最重要的工程变化资源问题从“偶发感觉不对”变成了“有明确计数器”。十七、RESOURCE_CLEAN 的含义最终状态RESOURCE_CLEAN至少代表重复创建被阻止 单例窗口稳定 监听不会重复 Timer 可取消 Ball 可释放 Window 可释放 Adapter 最后释放 所有 Registry 计数归零下一篇不会再加新功能只会拿这套资源模型去跑完整 25 轮回归。十八、资源释放不能只发生在“任务正常结束”如果只在COMPLETED里调用disposeAll()工程看起来很干净但真正的异常路径还没覆盖。QuickDock 还可能从这些路径退出用户取消任务 UIAbility 销毁 Surface 丢失后恢复失败 应用主动退出 后台能力申请失败后回退主页面这些路径里任何一条忘记调用资源收口最后都会留下不一致状态。所以第五篇把资源回收触发点从“任务结果”提升成Display Session 结束只要当前这组 Float View / Ball 显示会话结束就必须进入同一套释放流程。业务任务是否保留历史是 TaskRegistry 的事。显示资源是否归零是 ResourceRegistry 的事。两者不再互相借生命周期。十九、disposeAll 本身也必须幂等资源治理里很容易出现另一个反直觉问题正常结束调用一次 disposeAll Ability destroy 又调用一次 disposeAll如果第二次释放已经销毁的 Window 或 Adapter可能反而产生新的异常。所以disposeAll()不只是“按顺序释放”还必须可以重复调用当前做法是每个资源 Owner 自己判断状态exportclassFloatOwner{privatestate:ACTIVE|DISPOSING|DISPOSEDACTIVEasyncdispose():Promisevoid{if(this.stateDISPOSED||this.stateDISPOSING){return}this.stateDISPOSINGtry{awaitthis.adapter.hide()awaitthis.adapter.dispose()}finally{this.stateDISPOSED}}}这样生命周期回调重复到达也不会把资源释放链重新执行一遍。二十、资源治理里最怕“Owner 不清楚”早期代码里最危险的一种写法是Page 创建 listener Manager 保存 listener Coordinator 负责 off每个人都知道一点但没有一个对象完整拥有生命周期。第五篇以后QuickDock 给每类资源都指定 OwnerFloat Window → QuickDockFloatManager Floating Ball → QuickDockBallManager Task Listener → TaskSubscriptionOwner Update Timer → UpdateScheduler Adapter → DisplayAdapterOwnerResourceRegistry 只做登记和验收不越权释放。这个规则看起来像普通代码整洁但对系统窗口尤其重要。因为系统资源通常是异步创建、异步销毁一旦 Owner 不明确就很容易出现“我以为别人会释放”。二十一、隐藏资源和活动资源要分开统计第五篇销毁前window1 ball0并不意味着 Floating Ball 从来没创建过。它只是当前不处于活动显示资源集合。所以我把资源计数分成created visible active disposed最终验收主要看active而不是历史创建次数。例如连续 12 轮切换以后Float Window historicalCreate1 Floating Ball historicalCreate1 active: window1 ball0这种状态是健康的。如果历史创建次数每轮都增加哪怕 active 只有 1也说明单实例守卫失效。二十二、重复创建被阻止以后日志不能刷成“错误”本轮duplicateCreateBlocked11这些其实是成功保护。所以日志级别不应该用 ERROR。QuickDock 现在区分INFO duplicate show converted to update WARN unexpected resource state ERROR resource cannot be disposed如果把 11 次保护行为全打成红色 ERROR最终日志会让人误以为系统非常不稳定。诊断日志本身也要表达正确语义。二十三、Surface Recovery 和 Resource Dispose 会竞争同一个 Adapter第四篇有恢复流程FLOAT_SURFACE_LOST → rebuild第五篇又有销毁流程dispose如果两者同时发生就可能出现Recovery 正在创建新 Adapter Dispose 正在销毁旧 Adapter甚至新 Adapter 刚注册就被 dispose。所以资源 Owner 增加一个会话序号displaySessionIdRecovery 只有在session still active时允许提交新 Adapter。如果会话已经进入 DISPOSINGrebuild result discard这条保护在普通测试里很难看到但一旦没有就会出现“应用退出时又闪一下窗口”的诡异现象。二十四、Timer 归零还不够pending Snapshot 也要清UpdateScheduler 即使timer0内部如果还保留pendingSnapshot下一次新的任务会话创建 Scheduler 时可能误用旧任务数据。所以 cancel 还需要timer-1 pendingnullResourceRegistry 统计资源数解决“有没有活动 Timer”Owner 自己还要保证内部缓存状态清空。资源治理不只是释放系统句柄也包括清掉跨会话不应该残留的内存状态。二十五、05 最后的 12 轮是“资源模型验收”不是性能跑分这 12 轮每轮都执行show hide switch show surface rebuild dispose目标不是得到更漂亮的耗时而是确认每个资源在每轮结束以后都回到可解释状态。最终稳定结果Float Window: 1 → 0 Listener: 1 → 0 Timer: 1 → 0 Adapter: 1 → 0 duplicate create: 被守卫拦截只要其中任何一项不能解释我宁可先停在第五篇也不会直接进入最终 25 轮回归。这也是 QuickDock 从“能跑”走向“能长期跑”的真正分界。二十六、资源诊断页也不能反过来持有业务对象为了看计数我做了一个 Resource Probe 页面。最开始这个页面直接持有 FloatManager 和 TaskStore结果测试页一打开反而多注册了一份监听。后来诊断页只读 Registry 的不可变快照windowCount ballCount listenerCount timerCount adapterCount它不创建资源也不拥有资源。这样测试工具本身不会污染被测试对象。这条约束很容易忽略监控代码如果改变了资源数量最终看到的“资源健康”就失去可信度。二十七、第五篇的结果要能被下一篇自动读取05 结束后我把这组基线固化成 Acceptance 前置条件duplicateCreateBlocked 可观察 disposeAll 幂等 Registry 支持快照 资源 Owner 可查询状态06 启动前会先跑一次resource preflight。只要窗口、listener、timer 或 adapter 在测试开始前就不是 0整轮回归直接停止。这样可以避免“带着上一轮残留资源进入下一轮测试”让最终 25 轮数据更可信。参考资料HarmonyOS 7 闪控窗开发指南https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/float-view-guideHarmonyOS 文档中心Window / Background Tasks Kithttps://developer.huawei.com/consumer/cn/doc/Stage 模型与 Ability 生命周期https://developer.huawei.com/consumer/cn/arkui/arkui-stage