
前两篇把 QuickDock 的两条基础链路稳定下来标准闪控窗可以作为任务展示层独立存在闪控窗和闪控球之间切换时业务任务始终只有一份状态。做到这里以后第三篇才适合处理“拖动”。因为真正做自由拖动时问题绝不是把一个窗口从 A 点移动到 B 点这么简单。用户会把窗口拖到屏幕右边缘系统可能进入侧边暂存窗口再次出现时还要知道上一次停在哪里如果中间发生旋转、分屏、窗口区域变化旧坐标甚至可能已经超出当前可视区域。所以这一篇只解决一条工程主线拖动开始 → 拖动结束 → 识别边缘 → 侧边暂存 → 保存位置 → 下次恢复 → 屏幕边界校验本轮继续沿用同一个 QuickDock 压缩任务统一数据固定为taskId: float_20261002_03 windowId: quickdock_float_01 job: Compress assets_20261002.zip progress: 73% state: RUNNING dragStart: x732, y128 dragEnd: x920, y356 edge: RIGHT dockState: STOWED normalized: x0.84, y0.31 persistCost: 11ms restoreCost: 24ms restoredPosition: x732, y128 status: POSITION_READY一、第三篇先改一个观念窗口位置不是“绝对像素历史”前两篇里QuickDock 的位置一直写成x732 y128同一台设备、同一方向下这个值看起来很稳定。真正把窗口拖到右边再切一次横竖屏以后旧坐标马上暴露问题。因为同样的 732 / 128在新的可用窗口区域里可能已经偏得很远甚至超出边界。所以第三篇不直接持久化绝对坐标而是同时保存absolute: 920 / 356 normalized: 0.84 / 0.31绝对值用来做本次会话内即时恢复归一化坐标用于跨窗口尺寸恢复。这和普通页面保存 ScrollOffset 很像真正想保存的是“相对位置语义”而不是死记某一块屏幕上的像素。二、拖动事件只写入 Drag Session不直接改业务任务这一篇新增managers/ └── FloatDragCoordinator.ets persistence/ └── FloatPositionRepository.ets第一段代码解决的是“拖动过程把 TaskSnapshot 也改掉”的问题exportinterfaceDragSession{taskId:stringwindowId:stringstartX:numberstartY:numberendX:numberendY:numberedge:LEFT|RIGHT|NONEdockState:FLOATING|STOWED}exportclassFloatDragCoordinator{privatesession:DragSession|nullnullbegin(taskId:string,windowId:string,x:number,y:number):void{this.session{taskId,windowId,startX:x,startY:y,endX:x,endY:y,edge:NONE,dockState:FLOATING}}}这里没有progress state elapsed remain因为这些仍然属于FloatTaskStore。拖动窗口不会让任务从 RUNNING 变成别的业务状态。这条边界一旦写清后面侧边暂存也只是窗口形态变化不会误伤任务。三、拖动结束后先做边界 clamp再判断侧边暂存如果用户把窗口拖到x9999 y-300不能把这个坐标直接持久化。所以拖动结束时先经过可视区域限制exportinterfaceDisplayBounds{width:numberheight:numbermarginVp:number}exportfunctionclampFloatPosition(x:number,y:number,windowWidth:number,windowHeight:number,bounds:DisplayBounds):{x:number,y:number}{constminXbounds.marginVpconstminYbounds.marginVpconstmaxXbounds.width-windowWidth-bounds.marginVpconstmaxYbounds.height-windowHeight-bounds.marginVpreturn{x:Math.max(minX,Math.min(x,maxX)),y:Math.max(minY,Math.min(y,maxY))}}当前 QuickDock 的安全边距使用 16vp。这是项目自己的工程参数不是系统固定要求。这一层做完以后再判断离左边足够近 → LEFT 离右边足够近 → RIGHT 否则 → NONE当前本轮拖动到920 / 356最终识别edgeRIGHT dockStateSTOWED四、侧边暂存不是把窗口坐标改成屏幕外一开始我做过最简单的“暂存”x displayWidth - 20让窗口大部分跑到屏幕外。效果看起来像侧边收起但工程上非常脆弱。因为屏幕尺寸变化 系统 Insets 变化 窗口宽度变化都会让这个“假暂存坐标”失效。现在 QuickDock 不自己模拟系统暂存。应用侧只记录语义dockStateSTOWED edgeRIGHT真正的显示行为交给FloatViewAdapter对接当前 HarmonyOS 7floatView能力。这种写法刻意避免在业务代码里硬编码 Beta API 的具体方法名。适配层暴露的是稳定业务接口exportinterfaceFloatViewAdapter{moveTo(x:number,y:number):PromisevoidstowToEdge(edge:LEFT|RIGHT):PromisevoidrestoreFromEdge():Promisevoid}如果后续 SDK 方法名变化只改 Adapter。五、位置持久化用归一化坐标不直接写 920 / 356这一段代码解决的是“下次窗口大小不同旧坐标失效”的问题exportinterfacePersistedFloatPosition{taskId:stringwindowId:stringnormalizedX:numbernormalizedY:numberedge:stringdockState:stringsavedAt:number}exportfunctionnormalizePosition(x:number,y:number,maxX:number,maxY:number):{normalizedX:numbernormalizedY:number}{return{normalizedX:maxX0?0:Number((x/maxX).toFixed(2)),normalizedY:maxY0?0:Number((y/maxY).toFixed(2))}}本轮最终保存normalizedX0.84 normalizedY0.31这些数字和截图一致。六、ArkData Preferences 只保存窗口会话数据不保存 Task Runner位置需要跨页面、甚至跨一次重新进入应用恢复所以这一篇引入 ArkData Preferences。FloatPositionRepository只保存小对象import{preferences}fromkit.ArkDataimport{common}fromkit.AbilityKitexportclassFloatPositionRepository{privatereadonlyname:stringquickdock_float_positionasyncsave(context:common.UIAbilityContext,value:PersistedFloatPosition):Promisevoid{conststoreawaitpreferences.getPreferences(context,{name:this.name})awaitstore.put(last_position,JSON.stringify(value))awaitstore.flush()}}这里只保存位置 边缘 暂存状态 保存时间不会保存压缩线程 Task Runner Window 对象 Adapter 对象和前两篇一样持久化的是可重建数据不是运行时对象。七、恢复时必须重新计算“当前屏幕里的合法位置”恢复位置不是读出 0.84 / 0.31 → 直接乘 → 完成还要再做一次 clamp。完整恢复顺序读取 normalized → 获取当前 DisplayBounds → 根据当前窗口尺寸计算 maxX / maxY → 转回绝对坐标 → clamp → moveTo当前本轮虽然拖动结束在920 / 356但我专门做了一次“恢复到保存前标准位置”的测试最终restoredPosition x732 y128恢复耗时24ms这组数据在正文、HiLog 和手机图里完全一致。八、为什么恢复目标是 732 / 128而不是 920 / 356这个点很容易看起来矛盾。03 里同时有两套位置dragEnd: 920 / 356 restored: 732 / 128原因是测试流程不是“保存拖动终点后立刻还原到终点”。完整流程是初始 732 / 128 拖动 920 / 356 进入 RIGHT / STOWED 保存归一化暂存信息 用户点击“恢复位置” 恢复到 QuickDock 上一次正常浮动位置 732 / 128也就是说stowed position和last floating position是两个概念。QuickDock 分开保存lastFloatPosition lastDockEdge这样从侧边暂存回来时窗口不会直接贴在右边缘。九、窗口拖动过程中不要高频 flush PreferencesDragMove 事件可能非常密集。如果每个位置都Preferences.put flushI/O 没有任何必要。所以当前规则DragMove → 只更新内存 DragEnd → 计算最终位置 → 判断 edge → 持久化一次本轮持久化耗时11ms这是当前 Demo 的一次工程观测不是系统指标。真正重要的是一次拖动 只写一次位置十、DevEco 图重点看“归一化”和“恢复前 clamp”开发图当前 HiLogtaskIdfloat_20261002_03 dragStart x732 y128 dragEnd x920 y356 edgeRIGHT dockStateSTOWED persist normalized x0.84 y0.31 persistCost11ms restore clamp x732 y128 restoreCost24ms statusPOSITION_READY如果只记录一个“拖动成功”真正出问题时完全不知道是边缘识别错 还是保存错 还是恢复越界所以第三篇把每一步都拆成了可观察证据。十一、运行图把三种位置状态放在一张屏里最终运行图三段状态1. FLOATING 732 / 128 2. RIGHT / STOWED 920 / 356 3. RESTORED 732 / 128业务任务始终Compress assets_20261002.zip RUNNING 73%也就是说窗口怎么动不影响任务时间线。这是 03 最重要的验收结论。十二、侧边暂存以后任务更新仍然继续进入 STOWED 后QuickDock 不会暂停任务也不会停止 TaskStore 更新。只不过显示层处于侧边暂存形态。这也是官方对闪控窗的产品定位所暗示的能力边界它用于持续展示实时状态支持自由拖动、侧边暂存再需要时恢复。如果一暂存就停止业务任务那这个形态失去意义。十三、位置数据还需要版本号第三篇给持久化位置加schemaVersion1原因很现实。现在只保存normalizedX normalizedY edge dockState后面可能会再加displayId rotation windowWidth windowHeight如果旧 JSON 没版本号未来字段变化很难迁移。位置恢复不能因为一份旧数据就阻止窗口创建。旧版本不兼容时直接回退到defaultPosition 732 / 128比抛异常更合理。十四、不同显示区域切换时不复用旧 displayId 假设如果设备有不同显示区域或窗口环境位置数据必须重新投影到当前区域。所以恢复数据里真正长期保留的是normalized而不是displayWidth旧值当前 03 没有做跨屏显示但这条边界先写好后面 PC / 2in1 或外接显示场景不会重新推倒位置模型。十五、拖动过程中任务完成怎么办我专门测了一个边界progress99% 用户开始拖动 任务完成 用户松手结果必须是TaskStore: COMPLETED DragSession: 仍然可以保存最终位置 FloatView: 显示完成态不能因为拖动 session 还没结束就丢掉 COMPLETED。换句话说DragSession 和 TaskSession完全独立。这是前两篇把状态分层以后带来的直接收益。十六、POSITION_READY 代表什么最终状态POSITION_READY不是“窗口可以拖”。它代表五个条件都成立拖动终点已 clamp 边缘语义已识别 STOWED 不污染业务状态 位置只在 DragEnd 持久化 恢复时能重新投影并回到合法区域做到这一层以后QuickDock 才真正可以在下一篇讨论应用退后台 多个任务 异常恢复而不需要再担心窗口位置本身会成为新的不确定因素。十七、下一篇后台运行不是“什么都继续跑”04 会进入前后台和多任务。我不会写成应用进后台 所有任务无限继续HarmonyOS 对后台任务有明确的系统策略和受约束模型。所以下一篇会区分数据传输任务 允许申请合适的后台任务模式 本地计算任务 如果当前设备 / 场景不满足策略 就进入 SUSPENDED_POLICY与此同时闪控窗显示层如果发生异常丢失也必须能从 TaskStore 重建而不是重新创建业务任务。这会是 04 的核心工程问题。十八、跨方向恢复时不能只用 normalized 坐标“机械还原”归一化坐标解决了“屏幕尺寸变化”这个问题但它不是万能的。假设原来窗口是竖屏 1080 × 1920 normalized: 0.84 / 0.31切成横屏后1920 × 1080如果仍然机械套x 0.84 × maxX y 0.31 × maxY理论上合法但视觉上可能跳到一个完全不自然的位置。所以 QuickDock 在恢复时还会考虑rotation edge lastFloatPosition当前策略同方向 → 优先 normalized 方向变化 → 如果之前有 edge 优先保持 edge 语义 没有 edge → normalized 后再 clamp也就是说RIGHT这种语义比绝对坐标更稳定。用户把窗口收在右侧本质上表达的是我希望它靠右。而不是我希望它永远处在某个固定 x。第三篇虽然只展示竖屏测试但数据模型已经为这条规则留出空间。十九、恢复位置时还要区分“正常浮动位置”和“暂存位置”前面已经提到dragEnd920/356和restored732/128不是同一个位置。最终WindowSessionStore里真正保存的是两层exportinterfaceWindowSessionState{lastFloatingPosition:FloatPosition dock:{state:FLOATING|STOWEDedge:LEFT|RIGHT|NONE}}lastFloatingPosition只在普通 Float View 状态下更新。进入 STOWED 后只改 dock 不覆盖 lastFloatingPosition这就避免了“从侧边恢复以后仍然贴着边”的问题。后面如果闪控球也支持位置记忆可以继续扩展自己的 session不需要把所有形态的位置硬塞在一个 x/y 里。二十、拖动中的进度更新要保证窗口不卡顿这次任务仍然在 73% RUNNING。拖动过程中 TaskStore 还会更新73.1 73.2 73.3如果 UI 同时处理高频 drag move 高频 progress render就容易出现“窗口跟手感变差”。所以第三篇把两类更新分开Drag Gesture → 位置更新优先 Task Progress → 继续进入 250ms UI 合并拖动期间不暂停业务任务但进度 UI 可以稍微降低刷新频率。用户更在意拖动是否跟手而不是拖动的 300ms 里进度数字有没有多跳一次。这是一种明确的产品取舍。二十一、Preferences 写入失败时当前会话仍然要能继续位置持久化是增强能力。如果Preferences.flush()失败不能让闪控窗瞬间跳回默认位置。QuickDock 当前处理内存中的 WindowSession 仍保留最新位置 持久化失败 → 记录 PERSIST_FAILED → 本次会话继续使用 下一次 DragEnd 再尝试持久化也就是说持久化失败 ! 位置立即失效只影响跨重启恢复不影响当前 Window。这和前面“Float View 失败不等于 Task 失败”是一脉相承的。二十二、侧边暂存的入口和恢复入口都要幂等用户快速操作时可能连续触发STOW STOW RESTORE RESTORE如果 Adapter 每次都真正执行一次系统调用状态机会出现竞争。所以FloatDragCoordinator增加当前 dockState 已经 STOWED → 重复 stow 直接返回 当前已经 FLOATING → 重复 restore 直接返回幂等处理看起来简单但对窗口类能力非常重要。因为窗口状态变化通常不是纯内存赋值而是跨系统边界的异步操作。重复调用越少越容易保持日志和真实 UI 一致。二十三、位置恢复验收不能只测一次成功路径这一篇最后固定做七组位置测试。第一组正常拖动到右侧并暂存。第二组暂存后恢复回到 732 / 128。第三组保存 normalized 以后模拟窗口尺寸缩小确认恢复前会 clamp。第四组模拟横竖屏变化优先保持 RIGHT 语义。第五组Preferences 写入失败本次会话位置不回退。第六组连续 20 次 DragEnd持久化次数严格等于 20不在 DragMove 里高频写。第七组任务从 72% 更新到 73% 的过程中拖动窗口TaskStore 时间线不断。这七组都通过以后POSITION_READY才真正有意义。二十四、为什么第三篇不把“拖动坐标”写进 TaskStore做到这一篇以后这个分层已经很清楚TaskStore 业务事实 WindowSessionStore 窗口会话 PositionRepository 跨会话位置 FloatViewAdapter 系统接口如果把 x/y 放回 TaskStore后面 04 两个任务同时存在时就会出现task A 有位置 task B 也有位置而实际上当前 QuickDock 只有一个活动 Float View。任务和窗口并不是一一对应关系。这一层如果现在不分开下一篇多任务会立刻出现模型冲突。参考资料HarmonyOS 7 闪控窗开发指南https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/float-view-guideHarmonyOS 7 新能力一览https://developer.huawei.com/consumer/cn/features/HarmonyOS ArkData / Preferenceshttps://developer.huawei.com/consumer/cn/doc/