☰
HarmonyOS 7 QuickDock 闪控窗开发实录 02:floatView × floatingBall:形态切换、位置恢复与单一状态源【鸿蒙心迹】
2026/10/5 15:48:17 网站建设 项目流程

第一篇把 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=CONTINUOUS

tickSeq=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 progress

QuickDock 不这么做。

球的点击事件只发:

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,但我不希望它被理解成:

所有状态都放一个 Store

QuickDock 实际仍然分层:

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/

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询