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
销毁前:
window=1
ball=0
listener=1
timer=1
adapter=1
销毁后:
window=0
ball=0
listener=0
timer=0
adapter=0
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 创建 Adapter,DisplayModeCoordinator 负责 listener,UpdateScheduler 又自己创建 timer。销毁时需要跨三个对象猜谁还活着。
现在资源创建后立即登记:
export type ResourceType =
'FLOAT_WINDOW' |
'FLOATING_BALL' |
'TASK_LISTENER' |
'UPDATE_TIMER' |
'ADAPTER'
export class QuickDockResourceRegistry {
private resources:
Map<
ResourceType,
Set<string>
> = new Map()
register(
type: ResourceType,
id: string
): void {
let bucket =
this.resources.get(type)
if (bucket === undefined) {
bucket =
new Set<string>()
this.resources.set(
type,
bucket
)
}
bucket.add(id)
}
unregister(
type: ResourceType,
id: string
): void {
this.resources
.get(type)
?.delete(id)
}
}
这段代码不负责真正销毁系统资源。
它只记录:
现在应该还有什么
真正释放仍然由资源 Owner 完成。
三、重复 show 必须在创建之前就被挡住
本轮总共调用:
showCalls=24
其中很多来自:
主页面进入
从闪控球恢复
Surface 恢复
前台恢复
用户再次点显示
如果每个入口都直接 create(),窗口实例一定会越来越多。
所以 QuickDockFloatManager 的 create 逻辑现在有单实例守卫:
export class QuickDockFloatManager {
private created:
boolean = false
private readonly windowId:
string =
'quickdock_float_01'
async ensureShown(
snapshot:
QuickTaskSnapshot
): Promise<void> {
if (this.created) {
await this.update(
snapshot
)
QuickDockMetrics
.recordDuplicateBlocked()
return
}
await this.adapter.create(
this.windowId
)
this.created = true
QuickDockResourceRegistry
.shared()
.register(
'FLOAT_WINDOW',
this.windowId
)
await this.adapter.show(
snapshot
)
}
}
当前 12 轮压力测试里:
duplicateCreateBlocked=11
这不是 11 次失败。
它说明 11 次“本来可能重复创建”的调用被正确降级成 update。
四、listener 最容易被“异常恢复”悄悄复制
窗口重建以后,我曾经写过:
rebuild
→ bind listener
却忘了旧 listener 是否已经解除。
于是:
listenerCount
1 → 2
页面仍然正常,直到下一次 TaskStore 更新才暴露。
所以 listener 现在也有显式 ID:
export class TaskSubscriptionOwner {
private readonly listenerId:
string =
'quickdock_task_listener'
private bound:
boolean = false
bind(): void {
if (this.bound) {
return
}
FloatTaskStore.shared()
.on(
this.listenerId,
this.onSnapshot
)
QuickDockResourceRegistry
.shared()
.register(
'TASK_LISTENER',
this.listenerId
)
this.bound = true
}
unbind(): void {
if (!this.bound) {
return
}
FloatTaskStore.shared()
.off(
this.listenerId
)
QuickDockResourceRegistry
.shared()
.unregister(
'TASK_LISTENER',
this.listenerId
)
this.bound = false
}
}
这条写法的重点不是 ID 字符串,而是:
bind / unbind 幂等
同一个 Owner 重复 bind 不会多注册一份。
五、Timer 不能因为 hide 就忘记
QuickDock 的进度 UI 还有一个 250ms 合并 Timer。
它属于:
显示更新调度
而不是:
业务任务
窗口隐藏以后,如果 Scheduler 仍然留着 pending timer,可能发生:
窗口已经 hide
timer 到点
Adapter update
轻则多一条错误日志,重则访问已经 dispose 的 Adapter。
所以 05 把 Timer Owner 也收进 Registry:
export class UpdateScheduler {
private timer:
number = -1
schedule(
callback:
() => void
): void {
if (
this.timer >= 0
) {
return
}
this.timer =
setTimeout(
() => {
this.timer = -1
QuickDockResourceRegistry
.shared()
.unregister(
'UPDATE_TIMER',
'progress_update'
)
callback()
},
250
)
QuickDockResourceRegistry
.shared()
.register(
'UPDATE_TIMER',
'progress_update'
)
}
cancel(): void {
if (
this.timer < 0
) {
return
}
clearTimeout(
this.timer
)
this.timer = -1
QuickDockResourceRegistry
.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():
async disposeAll():
Promise<void> {
this.scheduler.cancel()
this.subscriptionOwner
.unbind()
await this.ballManager
.dispose()
await this.floatManager
.dispose()
await this.adapter
.dispose()
QuickDockResourceRegistry
.shared()
.assertEmpty()
}
本轮销毁耗时:
33ms
这只是当前测试基线。
真正的验收是:
count=0
七、hide 和 dispose 必须继续保持两个语义
第二篇为了闪控窗 / 闪控球切换,已经明确:
hide
!=
dispose
05 不能为了“释放彻底”把这条边界破坏掉。
当前:
切换到 Floating Ball
→ Float Window hide
→ Float Window 仍登记为已创建资源
任务真正结束 / Ability 收口
→ Float Window dispose
→ Registry unregister
所以销毁前的资源计数:
window=1
ball=0
listener=1
timer=1
adapter=1
是合理状态。
Ball 当前不显示,所以:
ball=0
但 Float Window 仍然存在。
只有最终 dispose 后才要求全部归零。
八、资源 Registry 不是“看到 0 就完事”,还要看归属
如果所有资源都放进一个总数:
count=4
根本不知道是谁没释放。
所以 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 要进入 DISPOSED
FLOAT_SURFACE_LOST 重建时,我给 Adapter 自己也加状态:
CREATED
ATTACHED
LOST
DISPOSING
DISPOSED
新的 Adapter 只有在旧 Adapter 进入 DISPOSED 后才登记为活动实例。
这样 adapterCount 才能长期保持:
1
而不是视觉上只有一份,Registry 里却两份。
十二、DevEco 图里只看“12 轮以后资源是否还是可解释的”
开发图:

统一 HiLog:
taskId=float_20261002_05
resource probe
cycles=12
showCalls=24
duplicateCreateBlocked=11
floatWindowId=
quickdock_float_01
ballId=
quickdock_ball_01
before dispose:
window=1
ball=0
listener=1
timer=1
adapter=1
dispose order:
timer
→ listener
→ ball
→ window
→ adapter
disposeCost=33ms
after dispose:
all=0
status=RESOURCE_CLEAN
这比“没有崩溃”更接近工程验收。
十三、运行图直接展示销毁前和销毁后
最终运行图:

当前任务:
float_20261002_05
upload_release_02
76%
压力数据:
cycles=12
showCalls=24
blocked=11
销毁前:
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 自己判断状态:
export class FloatOwner {
private state:
'ACTIVE' |
'DISPOSING' |
'DISPOSED' =
'ACTIVE'
async dispose():
Promise<void> {
if (
this.state ===
'DISPOSED' ||
this.state ===
'DISPOSING'
) {
return
}
this.state =
'DISPOSING'
try {
await this.adapter.hide()
await this.adapter.dispose()
} finally {
this.state =
'DISPOSED'
}
}
}
这样生命周期回调重复到达,也不会把资源释放链重新执行一遍。
二十、资源治理里最怕“Owner 不清楚”
早期代码里最危险的一种写法是:
Page 创建 listener
Manager 保存 listener
Coordinator 负责 off
每个人都知道一点,但没有一个对象完整拥有生命周期。
第五篇以后,QuickDock 给每类资源都指定 Owner:
Float Window
→ QuickDockFloatManager
Floating Ball
→ QuickDockBallManager
Task Listener
→ TaskSubscriptionOwner
Update Timer
→ UpdateScheduler
Adapter
→ DisplayAdapterOwner
ResourceRegistry 只做登记和验收,不越权释放。
这个规则看起来像普通代码整洁,但对系统窗口尤其重要。因为系统资源通常是异步创建、异步销毁,一旦 Owner 不明确,就很容易出现“我以为别人会释放”。
二十一、隐藏资源和活动资源要分开统计
第五篇销毁前:
window=1
ball=0
并不意味着 Floating Ball 从来没创建过。
它只是当前不处于活动显示资源集合。
所以我把资源计数分成:
created
visible
active
disposed
最终验收主要看:
active
而不是历史创建次数。
例如连续 12 轮切换以后:
Float Window historicalCreate=1
Floating Ball historicalCreate=1
active:
window=1
ball=0
这种状态是健康的。
如果历史创建次数每轮都增加,哪怕 active 只有 1,也说明单实例守卫失效。
二十二、重复创建被阻止以后,日志不能刷成“错误”
本轮:
duplicateCreateBlocked=11
这些其实是成功保护。
所以日志级别不应该用 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 增加一个会话序号:
displaySessionId
Recovery 只有在:
session still active
时允许提交新 Adapter。
如果会话已经进入 DISPOSING:
rebuild result discard
这条保护在普通测试里很难看到,但一旦没有,就会出现“应用退出时又闪一下窗口”的诡异现象。
二十四、Timer 归零还不够,pending Snapshot 也要清
UpdateScheduler 即使:
timer=0
内部如果还保留:
pendingSnapshot
下一次新的任务会话创建 Scheduler 时,可能误用旧任务数据。
所以 cancel 还需要:
timer=-1
pending=null
ResourceRegistry 统计资源数解决“有没有活动 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-guide
- HarmonyOS 文档中心:Window / Background Tasks Kit:https://developer.huawei.com/consumer/cn/doc/
- Stage 模型与 Ability 生命周期:https://developer.huawei.com/consumer/cn/arkui/arkui-stage
更多推荐



所有评论(0)