03 把 QuickDock 的拖动、侧边暂存和位置持久化做稳定以后,窗口这条线终于没有太多悬念。
第四篇开始处理真正复杂的业务状态:
应用进入后台 两个任务并存 不同任务后台策略不同 当前闪控窗切换展示任务 Float Surface 中途丢失 回前台后恢复这一篇我故意没有写成“应用后台后任务继续跑”。
因为 HarmonyOS 的后台任务是受系统调度和场景约束的。当前 Background Tasks Kit 提供短时任务、长时任务等机制,不同业务要选择匹配的后台模式;官方最佳实践里也明确用长时任务保障视频导出、上传、下载等长耗时业务在后台持续执行。
所以 QuickDock 04 把两个任务拆开:
compress_assets_01 本地计算 88% SUSPENDED_POLICY upload_release_02 数据传输 61% RUNNING_BACKGROUND当前闪控窗显示的是:
upload_release_02本轮统一数据:
taskId: float_20261002_04 taskCount: 2 job0: compress_assets_01 job0Progress: 88% job0State: SUSPENDED_POLICY job1: upload_release_02 job1Progress: 61% job1State: RUNNING_BACKGROUND activeJob: upload_release_02 mode: FLOAT_VIEW backgroundTaskMode: DATA_TRANSFER backgroundDuration: 107s switchCount: 3 recoveryReason: FLOAT_SURFACE_LOST rebuildCount: 1 rebuildCost: 29ms listenerCount: 1 restoredPosition: x=732, y=128 status: RECOVERED一、前后台切换以后,最不该做的是“所有任务一视同仁”
如果 TaskRegistry 里有两个任务:
本地压缩 数据上传应用进入后台时不能简单:
全部继续也不能:
全部暂停应该由任务类型决定策略。
QuickDock 增加:
policy/ └── BackgroundTaskPolicy.ets先把业务类型抽象出来:
exporttypeQuickTaskKind='LOCAL_COMPUTE'|'DATA_TRANSFER'exporttypeBackgroundDecision='KEEP_RUNNING'|'SUSPEND_POLICY'exportclassBackgroundTaskPolicy{resolve(kind:QuickTaskKind):BackgroundDecision{if(kind==='DATA_TRANSFER'){return'KEEP_RUNNING'}return'SUSPEND_POLICY'}}这是项目自己的决策层。
真正申请 HarmonyOS Background Tasks Kit 能力的代码仍然放在 Adapter / Service 里,不把系统调用散到页面。
二、为什么上传任务可以继续,压缩任务这里选择暂停
当前 Background Tasks Kit 的长时任务模式包含数据传输等明确场景;官方文档也强调后台任务需要按实际业务选择合适机制,避免无限制占用资源。
所以 QuickDock 本轮对:
upload_release_02采用:
DATA_TRANSFER后台策略。
而本地压缩:
compress_assets_01在当前 Phone Demo 里没有强行声明“后台无限执行”,而是:
RUNNING → SUSPENDED_POLICY回前台再恢复。
这个设计比文章里一句“后台继续压缩”更符合系统约束。
如果未来切到符合其他后台模式的设备与场景,再由 Policy 决定是否允许继续。
三、TaskRegistry 从单任务升级成两任务,但 Float View 仍然只显示一个 activeJob
这一篇正式把 Store 从单任务升级成 Registry:
exportinterfaceRegistryTask{taskId:stringjobId:stringtitle:stringkind:QuickTaskKind progress:numberstate:'RUNNING'|'RUNNING_BACKGROUND'|'SUSPENDED_POLICY'|'COMPLETED'}exportclassTaskRegistry{privatetasks:Map<string,RegistryTask>=newMap()privateactiveJobId:string=''setActive(jobId:string):void{if(!this.tasks.has(jobId)){return}this.activeJobId=jobId}}两个任务可以同时存在。
但标准闪控窗当前只显示:
activeJob否则 320×184vp 的小窗很快会变成完整任务管理器。
四、进入后台时,activeJob 也可能切换
进入后台前:
activeJob: compress_assets_01但压缩任务被策略挂起以后,继续显示一个:
88% SUSPENDED_POLICY意义不大。
所以 QuickDock 自动切到:
upload_release_02 61% RUNNING_BACKGROUND这次:
switchCount=3包含:
用户手动切一次 后台策略切一次 前台恢复切一次显示任务切换,不等于业务任务迁移。
只是 Float View 选择不同 Snapshot 显示。
五、Stage 生命周期只触发策略,不直接改任务内部实现
UIAbility:
onBackground onForeground不应该直接:
upload.start() compress.pause()而是通知 Coordinator:
exportclassForegroundBackgroundCoordinator{constructor(privateregistry:TaskRegistry,privatepolicy:BackgroundTaskPolicy){}onBackground():void{this.registry.all().forEach((task:RegistryTask)=>{constdecision=this.policy.resolve(task.kind)if(decision==='KEEP_RUNNING'){this.registry.markBackgroundRunning(task.jobId)}else{this.registry.suspendByPolicy(task.jobId)}})}}生命周期只是“系统环境发生变化”的信号。
真正任务怎么处理,由业务 Policy 和对应 Service 决定。
六、后台数据传输使用系统能力,Window 只是状态出口
upload_release_02进入:
RUNNING_BACKGROUND以后,QuickDock 会通过 Background Tasks Kit 的适配层申请匹配的数据传输能力。
这里最重要的一条仍然是:
Background Task != Float View即使闪控窗短暂不可见,上传任务仍然由后台任务机制管理。
反过来,即使 Float View 还显示,系统也不意味着允许任意本地计算无限在后台跑。
这两个能力不能互相替代。
七、这一篇专门模拟一次 FLOAT_SURFACE_LOST
前两篇都是正常显示路径。
04 我主动做一个异常:
任务仍在 Store 正常 Float View Surface 丢失异常标记:
FLOAT_SURFACE_LOST关键规则:
重建显示层,不重建 TaskRegistry。
所以FloatRecoveryCoordinator:
exportclassFloatRecoveryCoordinator{privaterebuildCount:number=0asyncrebuild(reason:string):Promise<void>{constsnapshot=TaskRegistry.shared().activeSnapshot()constposition=WindowSessionStore.shared().lastPosition()awaitFloatViewAdapterFactory.current().rebuildFromSnapshot(snapshot,position)this.rebuildCount++}}任务不会重新开始。
上传仍然是:
61%窗口只是重新读取当前 Snapshot。
八、异常恢复后 listener 必须仍然只有 1
窗口重建最容易带来的副作用是:
旧 listener 没解绑 新窗口又注册一个下一次进度变化就会收到两份 UI Update。
所以恢复以后检查:
listenerCount=1本轮:
rebuildCount=1 listenerCount=1如果变成 2,恢复不能记成成功。
九、位置恢复继续复用第三篇数据
窗口 Surface 重建后:
x=732 y=128不是重新回默认左上角。
第三篇做的WindowSessionStore在这里直接复用。
恢复顺序:
TaskRegistry 当前 activeJob → WindowSessionStore lastPosition → Adapter rebuild → bind single listener → show因此 03 的位置工程并不是独立小功能,而是 04 异常恢复真正依赖的基础。
十、前后台期间的 107 秒,到底发生了什么
当前回归数据:
backgroundDuration=107s在这段时间里:
upload_release_02 36% → 61% RUNNING_BACKGROUND compress_assets_01 88% SUSPENDED_POLICY回前台后:
compress_assets_01 允许重新 RUNNING如果用户切回压缩任务,窗口继续从 88% 显示,不会从头开始。
这就是 TaskRegistry 独立存在的价值。
十一、DevEco 图里要同时看到策略和恢复
开发图:
本轮 HiLog:
taskId= float_20261002_04 onBackground tasks=2 policy upload_release_02 DATA_TRANSFER RUNNING_BACKGROUND policy compress_assets_01 SUSPENDED_POLICY FLOAT_SURFACE_LOST rebuild from TaskStore position x=732 y=128 rebuildCount=1 rebuildCost=29ms listenerCount=1 status=RECOVERED这套日志能明确区分:
任务策略问题和:
窗口恢复问题十二、运行图第一次出现两个任务
最终运行图:
任务列表:
upload_release_02 61% RUNNING_BACKGROUND compress_assets_01 88% SUSPENDED_POLICY当前 Float View:
activeJob: upload_release_02异常恢复:
FLOAT_SURFACE_LOST → rebuild → RECOVERED位置:
732 / 128所有数据与 DevEco、日志保持一致。
十三、为什么本地压缩不偷偷放 Worker 就宣称“后台无限继续”
HarmonyOS 当前并发指导确实建议长耗时、常驻计算把工作放到 Worker,避免阻塞 UI 主线程。
但:
Worker解决的是:
不要阻塞 UI不是:
应用进后台后系统一定允许无限执行后台生命周期和系统调度仍然要遵守 Background Tasks Kit 的规则。
所以 QuickDock 把两个问题分开:
线程执行模型 和 后台运行资格这条边界对技术文章很重要,否则很容易把“子线程”误写成“后台保活”。
十四、多任务切换也不能复制任务对象
现在 Registry 有两个 Task。
Float View 切换显示时,只改变:
activeJobId不会把任务内容复制进 WindowState。
否则:
TaskRegistry 61% WindowState 58%又会重回第二篇解决过的状态分裂。
所以多任务以后仍然保持:
TaskRegistry = 业务事实源 Window = 当前 activeJob 的投影十五、应用回前台后,恢复顺序也必须固定
前台恢复流程:
onForeground → 重新评估 BackgroundTaskPolicy → compress_assets_01 SUSPENDED_POLICY → RUNNING → upload_release_02 继续 RUNNING → 检查 Float Surface → 如果已丢失 rebuild → 恢复 activeJob不能一回前台就先新建窗口。
因为窗口真正需要展示的是评估后的最新 TaskRegistry。
十六、后台任务失败和 Float Surface 丢失是两类异常
这一篇还专门区分:
TASK_FAILED与:
FLOAT_SURFACE_LOST前者:
任务业务失败 → Store = FAILED → UI 显示失败后者:
任务仍正常 → 只重建窗口如果把两者都叫:
ERROR后面根本无法判断到底要不要重启任务。
十七、RECOVERED 代表什么
最终状态:
RECOVERED至少代表:
后台数据任务继续 本地计算遵守策略挂起 两个任务状态没有丢 Float Surface 可以重建 位置恢复正确 listener 仍然只有 1不只是“窗口又出现了”。
十八、第四篇最后做了六组压力测试
第一组,进入后台 107 秒,上传继续、压缩挂起。
第二组,回前台以后压缩恢复,不重新生成 taskId。
第三组,切换 activeJob 三次,进度分别保持。
第四组,模拟一次FLOAT_SURFACE_LOST,只重建 UI。
第五组,重建后位置仍是 732 / 128。
第六组,恢复后 listenerCount=1,没有重复订阅。
六组通过以后,本轮才记成:
RECOVERED十九、下一轮 05 会把“偶发异常”升级成资源治理
做到 04,QuickDock 已经拥有:
Float View Floating Ball Drag Edge Stow Preferences Background Task Multi Task Recovery能力开始多起来以后,新的问题会变成:
重复创建 重复 listener Window 没释放 Ball 没释放 Timer 没释放 TaskRegistry 任务已经结束但 UI 还在05 会专门做生命周期冲突和资源治理。
06 最后再跑 25 轮完整回归,检查:
窗口实例 listener timer 内存 切换耗时 任务状态不再加新功能。
二十、任务 Registry 必须保存“为什么被挂起”,不能只有一个 PAUSED
compress_assets_01在后台变成:
SUSPENDED_POLICY我没有写成普通:
PAUSED因为这两个语义不同。
用户点击暂停:
PAUSED_BY_USER系统策略不允许当前场景继续:
SUSPENDED_POLICY两者恢复条件也不同。
用户暂停的任务不能因为回前台就自动恢复;策略挂起的任务则可以在环境允许后恢复。
所以最终状态枚举更细:
exporttypeRegistryTaskState='RUNNING'|'RUNNING_BACKGROUND'|'PAUSED_BY_USER'|'SUSPENDED_POLICY'|'COMPLETED'|'FAILED'04 当前压缩任务明确是:
SUSPENDED_POLICY而不是用户主动暂停。
这条区别会继续影响 05 的生命周期治理。
二十一、后台任务申请失败时,要回退,而不是伪装成 RUNNING_BACKGROUND
数据上传任务计划申请:
DATA_TRANSFER但系统能力申请仍然可能失败。
比如:
参数不合法 系统调度拒绝 能力不可用这时绝对不能:
UI 仍显示 RUNNING_BACKGROUNDQuickDock 的处理是:
request background capability → success → RUNNING_BACKGROUND request failed → BACKGROUND_DENIED → 根据业务决定暂停或回主应用UI 会显示明确的“后台能力不可用”,而不是继续画一个假的 61% 进度。
当前本轮是成功路径,所以最终是:
RUNNING_BACKGROUND但失败分支已经存在。
二十二、activeJob 切换不能改变 TaskRegistry 的排序和生命周期
闪控窗当前只显示一个任务。
从:
compress_assets_01切到:
upload_release_02只是改变:
activeJobId不会:
暂停原任务 重排 Registry 重建 Task Runner因此switchCount=3只表示显示焦点切换次数。
这和“任务切换”这个词很容易混淆。
更准确地说:
QuickDock 切换的是“当前展示任务”,不是“系统只允许一条任务活着”。
多任务模型如果没有这条边界,用户每点一次任务卡片就可能意外影响业务运行。
二十三、Float Surface 异常恢复还要防止重复 rebuild
如果FLOAT_SURFACE_LOST连续上报两次,第一次 rebuild 还没完成,第二次又进来,会出现:
rebuildCount=2甚至重复订阅 listener。
所以 Coordinator 增加恢复锁:
exportclassFloatRecoveryCoordinator{privaterebuilding:boolean=falseasyncrebuild():Promise<void>{if(this.rebuilding){return}this.rebuilding=truetry{constsnapshot=TaskRegistry.shared().activeSnapshot()constpos=WindowSessionStore.shared().lastPosition()awaitFloatViewAdapterFactory.current().rebuildFromSnapshot(snapshot,pos)}finally{this.rebuilding=false}}}当前最终:
rebuildCount=1不是因为系统只触发了一次,而是 Manager 保证一次恢复流程只有一份。
二十四、回前台时先恢复业务状态,再恢复 UI
应用重新进入前台以后,最自然的写法是:
onForeground → show Float View → resume tasks但这样窗口第一帧可能显示旧状态。
QuickDock 改成:
onForeground → 重新评估 task policy → 更新 TaskRegistry → 恢复 activeJob → 再检查 / 重建 Float View所以窗口看到的第一帧就是最新状态。
例如本地压缩:
SUSPENDED_POLICY → RUNNING如果 Float View 当前 activeJob 切回它,页面会直接显示 RUNNING,而不是先闪一下 SUSPENDED。
二十五、UIAbility 生命周期和 Background Task 生命周期不能互相替代
Stage 生命周期提供:
onForeground onBackground它告诉应用:
前后台环境变化了Background Tasks Kit 解决的是:
某类业务是否可以在后台受约束地继续这两个能力层级不同。
所以:
onBackground并不等于:
开始一个后台任务而只是触发 Policy 评估。
同样:
onForeground也不应该直接把所有任务恢复 RUNNING。
只有状态和策略都允许的任务才恢复。
这种分层能避免生命周期回调里堆一大坨业务分支。
二十六、异常恢复以后,要检查旧 Adapter 有没有真正释放
FLOAT_SURFACE_LOST重建新显示层后,如果旧 Adapter 还持有:
timer listener surface reference即使用户只看到一个窗口,资源也已经重复。
所以恢复完成后的检查不仅是:
listenerCount=1还包括:
activeAdapterCount=1当前 04 先记录 listenerCount。
05 会把:
Adapter Window Ball Timer Listener全部纳入统一资源 Registry。
第四篇先把异常重建的边界写清楚,为下一篇治理做准备。
二十七、多任务后台场景最后做了八组验收
第一组,两个任务同时存在,Registry 数量=2。
第二组,进入后台,上传进入 RUNNING_BACKGROUND。
第三组,本地压缩进入 SUSPENDED_POLICY。
第四组,后台 107 秒后上传从 36% 到 61%。
第五组,回前台压缩从 88% 继续,不生成新 taskId。
第六组,activeJob 切换 3 次,两个任务进度各自保持。
第七组,模拟 FLOAT_SURFACE_LOST,rebuildCount=1。
第八组,重建后:
listenerCount=1 position=732/128 status=RECOVERED所有条件都满足,04 才正式结束。
二十八、这一篇最后留下的是“后台策略”和“显示恢复”两条独立链路
回头看第四篇,最重要的不是窗口异常本身。
而是把两条链彻底分开:
后台策略链: Stage → BackgroundTaskPolicy → TaskRegistry 显示恢复链: FLOAT_SURFACE_LOST → FloatRecoveryCoordinator → Adapter rebuild两条链最后都读同一个 TaskRegistry,但互相不替代。
这让 QuickDock 后面即使换成:
下载 上传 视频导出 模型处理仍然可以复用相同的窗口恢复架构。
参考资料
- HarmonyOS 7 闪控窗开发指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/float-view-guide
- Background Tasks Kit:https://developer.huawei.com/consumer/cn/doc/
- 常驻任务并发场景:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/resident-task-overview
- 后台视频导出最佳实践:https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-video-background-export