第一篇把 QuickDock 的标准闪控窗跑起来以后,我没有立刻去做拖动。
我先做了一个更容易把状态搞乱的动作:
闪控窗 → 闪控球 → 闪控窗HarmonyOS 7 官方能力描述里,闪控窗和闪控球本来就是可以互相切换的两种多任务形态。当前 API 索引也同时列出了@ohos.window.floatView和@ohos.window.floatingBall。
真正做进 Demo 以后,我发现最容易写错的地方不是“怎么显示球”,而是切换形态时会不会顺手创建第二份业务状态。
所以 02 的主线非常明确:
Float View 和 Floating Ball 只是同一任务的两个显示出口,任务必须只有一份。
本轮继续第一篇的压缩任务。
进入时:
42% FLOAT_VIEW中间切换到球形:
68% FLOATING_BALL再恢复:
68% FLOAT_VIEW x=732 y=128本轮统一数据:
taskId: float_20261002_02 job: Compress assets_20261002.zip progress: 42% → 68% state: RUNNING floatWindowId: quickdock_float_01 ballId: quickdock_ball_01 switch: FLOAT_VIEW → FLOATING_BALL → FLOAT_VIEW toBallCost: 31ms toFloatCost: 38ms restoredPosition: x=732 y=128 tickSeq: 27 singleStateSource: true status: CONTINUOUS一、切换形态时,最差的实现是复制一份 Snapshot
第一版我差点写成:
FloatViewSnapshot FloatingBallSnapshot然后切换时复制:
42% → copy → Ball 42%这种实现第一次没问题。
任务继续跑以后:
Float View 已隐藏 Ball 继续 68% FloatViewSnapshot 还停在 42%恢复时就会出现进度倒退。
所以第二篇先把数据结构砍掉一半:
只有 QuickTaskSnapshot两个显示形态都订阅同一个 Store。
二、DisplayModeCoordinator 只负责“谁显示”,不负责“任务是什么”
这一篇新增DisplayModeCoordinator.ets。
代码里最核心的状态只有:
exporttypeQuickDockMode='FLOAT_VIEW'|'FLOATING_BALL'exportinterfaceFloatPosition{x:numbery:number}exportclassDisplayModeCoordinator{privatecurrentMode:QuickDockMode='FLOAT_VIEW'privatesavedPosition:FloatPosition={x:732,y:128}mode():QuickDockMode{returnthis.currentMode}}它不保存:
progress elapsed jobName这些仍然只在:
FloatTaskStore这样 Coordinator 就算销毁,任务也不会丢。
三、切到闪控球以前,要先记住闪控窗位置
这一轮虽然还不实现自由拖动,但位置恢复必须先打通。
当前闪控窗:
x=732 y=128切换前先保存:
asyncswitchToBall():Promise<void>{if(this.currentMode==='FLOATING_BALL'){return}this.savedPosition=awaitthis.floatManager.currentPosition()awaitthis.floatManager.hide()constsnapshot=FloatTaskStore.shared().current()awaitthis.ballManager.show(snapshot)this.currentMode='FLOATING_BALL'}这里顺序不能反。
如果先隐藏,再去读位置,某些实现里窗口状态已经不可用。
所以:
记录位置 → 隐藏 Float View → 显示 Ball四、显示闪控球时,不能再创建一条 Task Runner
QuickDockBallManager只拿 Snapshot:
exportclassQuickDockBallManager{privatevisible:boolean=falseasyncshow(snapshot:QuickTaskSnapshot):Promise<void>{if(this.visible){awaitthis.update(snapshot)return}awaitFloatingBallAdapterFactory.create().show(snapshot)this.visible=true}asyncupdate(snapshot:QuickTaskSnapshot):Promise<void>{if(!this.visible){return}awaitFloatingBallAdapterFactory.current().update(snapshot)}}它不会启动压缩。
它也不会持有计时器。
压缩继续由原来的 Task Runner 执行。
这就是:
singleStateSource=true真正想表达的东西。
五、进度从 42% 到 68%,完全不经过“形态迁移”
我特意把任务继续跑到 68%。
TaskStore:
FloatTaskStore.shared().update({taskId:'float_20261002_02',jobName:'Compress assets_20261002.zip',state:'RUNNING',progress:68,elapsedSec:124,remainSec:62,tickSeq:27})此时当前模式是:
FLOATING_BALL所以 UI Subscriber 只更新球。
Float View 虽然隐藏,但不需要保存一个“68% 副本”。
恢复时直接重新读取:
Store.current()拿到的自然就是 68%。
六、恢复闪控窗时,先隐藏球,再复用旧窗口
恢复逻辑:
asyncswitchToFloatView():Promise<void>{if(this.currentMode==='FLOAT_VIEW'){return}awaitthis.ballManager.hide()constsnapshot=FloatTaskStore.shared().current()awaitthis.floatManager.showAt(snapshot,this.savedPosition)this.currentMode='FLOAT_VIEW'}注意是:
复用 quickdock_float_01不是:
新建 quickdock_float_02这条如果写错,用户每切一次形态,就会多一个隐藏 Window 实例。
第五篇做资源治理时,这种错误会直接变成窗口泄漏。
七、为什么 Float View 不应该在切成球时立即 destroy
第一版我为了“释放干净”:
切球 → destroy Float View恢复时再创建。
数据是对的,但:
toFloatCost明显变大,而且位置恢复还要重新做一遍初始化。
当前 QuickDock 的取舍是:
形态切换 → hide 任务结束 → dispose这样更符合“同一个持续任务在两种显示形态之间切换”。
如果系统后续对隐藏窗口资源有更明确约束,再由 Adapter 层调整,不改变业务状态模型。
八、两个显示层不能各注册一份永久 TaskListener
另一个容易泄漏的地方是监听。
如果:
FloatManager.on(snapshot) BallManager.on(snapshot)两边都永久注册,那么即使一个形态隐藏,仍然会做无意义更新。
现在改成 Coordinator 统一订阅:
exportclassDisplayModeCoordinator{privatetaskListener:TaskListener=(snapshot:QuickTaskSnapshot)=>{if(this.currentMode==='FLOAT_VIEW'){this.floatManager.update(snapshot)return}this.ballManager.update(snapshot)}bind():void{FloatTaskStore.shared().on(this.taskListener)}dispose():void{FloatTaskStore.shared().off(this.taskListener)}}只有一份 listener。
当前 mode 决定更新哪个 UI。
九、DevEco 图里重点看 tickSeq,而不是只看 68%
开发图:
本轮 HiLog:
taskId= float_20261002_02 switch FLOAT_VIEW → FLOATING_BALL save position x=732 y=128 show ball quickdock_ball_01 progress=68 tickSeq=27 switch FLOATING_BALL → FLOAT_VIEW restore x=732 y=128 toBallCost=31ms toFloatCost=38ms status=CONTINUOUStickSeq=27很重要。
它证明切换前后看到的是同一条任务时间线,不是两份巧合都显示 68% 的数据。
十、运行图要同时展示三段状态
最终运行图:
第一段:
Float View 42% quickdock_float_01 x=732 y=128第二段:
Floating Ball 68% quickdock_ball_01第三段:
Float View 68% quickdock_float_01 x=732 y=128底部统一状态:
taskId: float_20261002_02 tickSeq: 27 singleStateSource: true status: CONTINUOUS这张图比单独截一个闪控球更能证明工程问题解决了。
十一、切换成本单独统计,避免以后“越做越慢”
当前:
toBallCost: 31ms toFloatCost: 38ms这两个数字是 QuickDock 当前设备和工程版本的观测值,不是 HarmonyOS 系统性能指标。
我把它们记下来,是为了后续版本能对比。
如果 05 加了更多状态以后:
toFloatCost 38ms → 150ms就知道恢复流程变重了。
性能基线应该从第一天就记录,而不是最后一篇才想起来测。
十二、任务暂停也必须从 Store 出发
闪控窗和闪控球都有“暂停”入口。
如果两边按钮分别维护自己的 paused 状态,就会立刻分裂。
现在按钮只调用:
FloatTaskController.shared().pause('float_20261002_02')Controller 更新 Store:
RUNNING → PAUSED当前可见形态收到 Snapshot,再刷新 UI。
也就是说:
窗口不拥有业务动作结果 窗口只是发出命令这和 01 的“窗口不拥有任务”保持一致。
十三、切换失败时,旧形态不能先消失
做异常测试时我发现一个顺序问题。
如果:
hide Float View → show Floating Ball 失败用户会突然什么都看不到。
所以正式切换必须带回滚:
记录旧形态 → 尝试创建新形态 → 新形态 show 成功 → 再隐藏旧形态或者由 Adapter 支持原子化切换。
QuickDock 当前用的是保守策略:
new show success → old hide图里为了讲解顺序写得更直观,但实际 Manager 会确保失败时至少保留一种可见状态。
十四、为什么第二篇仍然没做侧边暂存
HarmonyOS 7 官方对闪控窗的描述还包括:
自由拖动 侧边栏暂存这两项都依赖位置和可见区域状态。
现在第二篇已经打通:
位置保存 形态恢复 单一状态源下一篇才有足够基础做:
拖到屏幕边缘 进入暂存 再次拉出 位置重新约束否则侧边暂存一上来,就会同时混入:
窗口移动 Ball 切换 任务进度 拖动手势 位置持久化很难排查。
十五、第二篇最后做了六组反向测试
第一组,从 42% 切到球,任务继续运行。
第二组,球形阶段进度更新到 68%,Float View 不保留旧 Snapshot。
第三组,从球恢复窗口,读取 Store 当前 68%。
第四组,窗口恢复到732 / 128。
第五组,连续切换 10 次,窗口 ID 始终是:
quickdock_float_01 quickdock_ball_01没有_02、_03。
第六组,Coordinator dispose 后 TaskListener 数量回到 0。
这六组通过以后,本轮状态才记成:
CONTINUOUS十六、这一篇真正完成的是“形态无关任务”
做到这里 QuickDock 的任务已经不再属于:
主页面 闪控窗 闪控球它只属于:
FloatTaskStore窗口和球只是不同输出。
这是后面自由拖动、侧边暂存、多任务切换都能继续复用的基础。
下一轮 03、04 会继续这个项目。
03 处理:
自由拖动 侧边暂存 位置持久化 屏幕边界约束04 再处理:
应用退后台 任务继续 重新进入 多任务状态切换 异常恢复不会重建 Demo,也不会重置编号。
十七、切换形态时,不能让两个 UI 同时长时间可见
第二篇为了避免失败后没有任何可见出口,实际切换会先尝试显示新形态,再隐藏旧形态。
但这不代表两个形态可以长期共存。
我给切换过程增加中间状态:
FLOAT_ACTIVE SWITCHING_TO_BALL BALL_ACTIVE SWITCHING_TO_FLOAT只有SWITCHING_*阶段允许短时间两个适配层都处于准备状态。
一旦新形态确认可见,旧形态必须进入 hide。
否则用户可能看到:
一边一个 Float View 旁边还有一个 Floating Ball而且两边还同时接收进度更新。
这种问题在快速点击切换按钮时最容易出现,所以按钮在SWITCHING_*阶段会暂时禁用。
十八、位置恢复还要做屏幕边界校验
当前恢复位置:
x=732 y=128在同一台设备上没有问题。
但如果用户切换显示形态期间:
发生旋转 进入分屏 窗口区域变化原坐标可能已经超出可用区域。
所以showAt()之前还会经过:
exportfunctionclampPosition(pos:FloatPosition,maxX:number,maxY:number):FloatPosition{return{x:Math.max(0,Math.min(pos.x,maxX)),y:Math.max(0,Math.min(pos.y,maxY))}}第二篇当前没有触发越界,最终仍恢复到 732 / 128。
但这条校验必须先准备好,否则第三篇一加入自由拖动,旧位置恢复马上会变成新的边界问题。
十九、Floating Ball 的点击动作也只发命令,不直接改进度
球形 UI 很容易写成一个“迷你任务组件”,然后在里面自己做:
pause resume progressQuickDock 不这么做。
球的点击事件只发:
OPEN_FLOAT_VIEW PAUSE_TASK RESUME_TASK OPEN_APP真正状态变化仍然经过 Controller 和 Store。
例如暂停:
exportclassQuickTaskController{pause(taskId:string):void{constcurrent=FloatTaskStore.shared().current()if(current.taskId!==taskId||current.state!=='RUNNING'){return}FloatTaskStore.shared().update({...current,state:'PAUSED'})}}这样用户从球暂停,再恢复闪控窗时看到的仍然是PAUSED,不会因为 Window 自己没有收到暂停事件而显示 RUNNING。
二十、切换统计要把失败和成功分开
当前成功耗时:
toBallCost=31ms toFloatCost=38ms但最终回归还需要记录:
switchAttempt switchSuccess switchRollback比如:
attempt=10 success=10 rollback=0如果只记录成功耗时,一次失败回滚可能完全不出现在性能报告里。
DisplayModeCoordinator 会为每次切换生成:
exportinterfaceModeSwitchRecord{from:QuickDockMode to:QuickDockMode startAt:numberendAt:numbersuccess:booleanrollback:boolean}第五篇做资源治理和第六篇做 25 轮回归时,会继续使用这份记录。
二十一、单一状态源不等于“所有状态塞进一个大对象”
这一篇一直强调singleStateSource=true,但我不希望它被理解成:
所有状态都放一个 StoreQuickDock 实际仍然分层:
QuickTaskStore 业务任务 WindowSessionStore 窗口位置 / 尺寸 DisplayModeCoordinator 当前展示形态 Adapter 系统 API 调用所谓“单一状态源”只针对同一个业务事实:
任务进度 任务状态 剩余时间它们不能在 Float View 和 Floating Ball 各有一份。
位置和模式本来就是另外的事实,应该继续放在自己的状态域里。
二十二、10 次来回切换以后,我会检查四个数量
最后一组压力测试:
Float View ↔ Floating Ball连续 10 次。
结束后检查:
Float Task Runner = 1 Task Listener = 1 Float Window Instance = 1 Floating Ball Instance = 1不是:
10 10 10 10然后 Coordinator dispose:
Task Listener = 0 Visible UI = 0这组数量检查比“肉眼看上去没有重复窗口”更可靠。
二十三、第二篇停在 CONTINUOUS,是为了给第三篇留下清晰起点
到这里,QuickDock 已经证明:
Float View 隐藏 任务不停止 Floating Ball 显示 任务继续 恢复 Float View 进度不倒退 窗口位置还能找回来因此CONTINUOUS表达的是:
同一个 QuickTask 在两种系统窗口形态之间切换时,业务时间线保持连续。
第三篇再加入拖动、侧边暂存和位置持久化时,不需要再重新讨论“进度是不是同一份”,只解决新的工程问题。
二十四、切到闪控球以后,信息密度也应该主动下降
Float View 可以显示:
文件名 进度 剩余时间 暂停按钮 返回应用Floating Ball 的空间更小,如果继续尝试展示同样的信息,只会让 UI 变得拥挤。
所以球形态只保留:
任务图标 进度环 异常状态点用户点击球以后,才恢复标准闪控窗或回主应用。
这意味着同一个 Snapshot 在不同形态下会经过不同的 ViewModel:
exportinterfaceBallViewState{progress:numberstate:QuickTaskState hasError:boolean}exportfunctiontoBallState(snapshot:QuickTaskSnapshot):BallViewState{return{progress:snapshot.progress,state:snapshot.state,hasError:snapshot.state==='FAILED'}}数据源只有一份,但展示模型可以不同。
这比“两个 UI 必须显示完全相同字段”更符合多形态产品设计。
二十五、形态切换还要考虑任务已经在切换过程中结束
极端情况下,用户在 99% 时切到闪控球。
切换过程 31ms 内任务可能直接变成COMPLETED。
如果 Coordinator 只拿切换开始时的 Snapshot,球刚显示出来可能还是 99%。
所以新形态真正 show 之前,会再次读取:
FloatTaskStore.current()而不是一直使用旧参数。
这样即使任务在切换窗口里完成,最终显示的仍然是最新状态。
这条校验会继续保留到后面所有形态切换里。
二十六、第二篇的验收还包括“不可见形态不做无意义刷新”
切到闪控球以后,隐藏的 Float View 不再接收每一次进度渲染;恢复 Float View 后,隐藏的 Ball 也停止更新。任务仍然只有一条时间线,但 UI 更新只投递给当前可见形态。
这样既避免重复渲染,也让后续性能统计更可信。否则两个隐藏 / 可见形态同时刷新,最后看到的 updateCost 会混入无意义开销。
参考资料
- HarmonyOS 7 新能力一览:闪控窗:https://developer.huawei.com/consumer/cn/features/
- ArkTS API 索引:@ohos.window.floatView / @ohos.window.floatingBall:https://developer.huawei.com/consumer/cn/doc/
- HarmonyOS 设计资源:闪控球和闪控窗:https://developer.huawei.com/consumer/cn/design/resource/