HarmonyOS 7 QuickDock 闪控窗开发实录 02:floatView × floatingBall:形态切换、位置恢复与单一状态源【鸿蒙心迹】
发布时间:2026/10/5 15:48:53 锦皓数字建站

第一篇把 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 x732 y128本轮统一数据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: x732 y128 tickSeq: 27 singleStateSource: true status: CONTINUOUS一、切换形态时最差的实现是复制一份 Snapshot第一版我差点写成FloatViewSnapshot FloatingBallSnapshot然后切换时复制42% → copy → Ball 42%这种实现第一次没问题。任务继续跑以后Float View 已隐藏 Ball 继续 68% FloatViewSnapshot 还停在 42%恢复时就会出现进度倒退。所以第二篇先把数据结构砍掉一半只有 QuickTaskSnapshot两个显示形态都订阅同一个 Store。二、DisplayModeCoordinator 只负责“谁显示”不负责“任务是什么”这一篇新增DisplayModeCoordinator.ets。代码里最核心的状态只有exporttypeQuickDockModeFLOAT_VIEW|FLOATING_BALLexportinterfaceFloatPosition{x:numbery:number}exportclassDisplayModeCoordinator{privatecurrentMode:QuickDockModeFLOAT_VIEWprivatesavedPosition:FloatPosition{x:732,y:128}mode():QuickDockMode{returnthis.currentMode}}它不保存progress elapsed jobName这些仍然只在FloatTaskStore这样 Coordinator 就算销毁任务也不会丢。三、切到闪控球以前要先记住闪控窗位置这一轮虽然还不实现自由拖动但位置恢复必须先打通。当前闪控窗x732 y128切换前先保存asyncswitchToBall():Promisevoid{if(this.currentModeFLOATING_BALL){return}this.savedPositionawaitthis.floatManager.currentPosition()awaitthis.floatManager.hide()constsnapshotFloatTaskStore.shared().current()awaitthis.ballManager.show(snapshot)this.currentModeFLOATING_BALL}这里顺序不能反。如果先隐藏再去读位置某些实现里窗口状态已经不可用。所以记录位置 → 隐藏 Float View → 显示 Ball四、显示闪控球时不能再创建一条 Task RunnerQuickDockBallManager只拿 SnapshotexportclassQuickDockBallManager{privatevisible:booleanfalseasyncshow(snapshot:QuickTaskSnapshot):Promisevoid{if(this.visible){awaitthis.update(snapshot)return}awaitFloatingBallAdapterFactory.create().show(snapshot)this.visibletrue}asyncupdate(snapshot:QuickTaskSnapshot):Promisevoid{if(!this.visible){return}awaitFloatingBallAdapterFactory.current().update(snapshot)}}它不会启动压缩。它也不会持有计时器。压缩继续由原来的 Task Runner 执行。这就是singleStateSourcetrue真正想表达的东西。五、进度从 42% 到 68%完全不经过“形态迁移”我特意把任务继续跑到 68%。TaskStoreFloatTaskStore.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():Promisevoid{if(this.currentModeFLOAT_VIEW){return}awaitthis.ballManager.hide()constsnapshotFloatTaskStore.shared().current()awaitthis.floatManager.showAt(snapshot,this.savedPosition)this.currentModeFLOAT_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.currentModeFLOAT_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%开发图本轮 HiLogtaskId float_20261002_02 switch FLOAT_VIEW → FLOATING_BALL save position x732 y128 show ball quickdock_ball_01 progress68 tickSeq27 switch FLOATING_BALL → FLOAT_VIEW restore x732 y128 toBallCost31ms toFloatCost38ms statusCONTINUOUStickSeq27很重要。它证明切换前后看到的是同一条任务时间线不是两份巧合都显示 68% 的数据。十、运行图要同时展示三段状态最终运行图第一段Float View 42% quickdock_float_01 x732 y128第二段Floating Ball 68% quickdock_ball_01第三段Float View 68% quickdock_float_01 x732 y128底部统一状态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 更新 StoreRUNNING → 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_*阶段会暂时禁用。十八、位置恢复还要做屏幕边界校验当前恢复位置x732 y128在同一台设备上没有问题。但如果用户切换显示形态期间发生旋转 进入分屏 窗口区域变化原坐标可能已经超出可用区域。所以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{constcurrentFloatTaskStore.shared().current()if(current.taskId!taskId||current.state!RUNNING){return}FloatTaskStore.shared().update({...current,state:PAUSED})}}这样用户从球暂停再恢复闪控窗时看到的仍然是PAUSED不会因为 Window 自己没有收到暂停事件而显示 RUNNING。二十、切换统计要把失败和成功分开当前成功耗时toBallCost31ms toFloatCost38ms但最终回归还需要记录switchAttempt switchSuccess switchRollback比如attempt10 success10 rollback0如果只记录成功耗时一次失败回滚可能完全不出现在性能报告里。DisplayModeCoordinator 会为每次切换生成exportinterfaceModeSwitchRecord{from:QuickDockMode to:QuickDockMode startAt:numberendAt:numbersuccess:booleanrollback:boolean}第五篇做资源治理和第六篇做 25 轮回归时会继续使用这份记录。二十一、单一状态源不等于“所有状态塞进一个大对象”这一篇一直强调singleStateSourcetrue但我不希望它被理解成所有状态都放一个 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 disposeTask Listener 0 Visible UI 0这组数量检查比“肉眼看上去没有重复窗口”更可靠。二十三、第二篇停在 CONTINUOUS是为了给第三篇留下清晰起点到这里QuickDock 已经证明Float View 隐藏 任务不停止 Floating Ball 显示 任务继续 恢复 Float View 进度不倒退 窗口位置还能找回来因此CONTINUOUS表达的是同一个 QuickTask 在两种系统窗口形态之间切换时业务时间线保持连续。第三篇再加入拖动、侧边暂存和位置持久化时不需要再重新讨论“进度是不是同一份”只解决新的工程问题。二十四、切到闪控球以后信息密度也应该主动下降Float View 可以显示文件名 进度 剩余时间 暂停按钮 返回应用Floating Ball 的空间更小如果继续尝试展示同样的信息只会让 UI 变得拥挤。所以球形态只保留任务图标 进度环 异常状态点用户点击球以后才恢复标准闪控窗或回主应用。这意味着同一个 Snapshot 在不同形态下会经过不同的 ViewModelexportinterfaceBallViewState{progress:numberstate:QuickTaskState hasError:boolean}exportfunctiontoBallState(snapshot:QuickTaskSnapshot):BallViewState{return{progress:snapshot.progress,state:snapshot.state,hasError:snapshot.stateFAILED}}数据源只有一份但展示模型可以不同。这比“两个 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.floatingBallhttps://developer.huawei.com/consumer/cn/doc/HarmonyOS 设计资源闪控球和闪控窗https://developer.huawei.com/consumer/cn/design/resource/
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。