前两篇把 QuickDock 的两条基础链路稳定下来:标准闪控窗可以作为任务展示层独立存在,闪控窗和闪控球之间切换时,业务任务始终只有一份状态。
做到这里以后,第三篇才适合处理"拖动"。
因为真正做自由拖动时,问题绝不是把一个窗口从 A 点移动到 B 点这么简单。用户会把窗口拖到屏幕右边缘,系统可能进入侧边暂存;窗口再次出现时,还要知道上一次停在哪里;如果中间发生旋转、分屏、窗口区域变化,旧坐标甚至可能已经超出当前可视区域。
所以这一篇只解决一条工程主线:
text
拖动开始
→ 拖动结束
→ 识别边缘
→ 侧边暂存
→ 保存位置
→ 下次恢复
→ 屏幕边界校验
本轮继续沿用同一个 QuickDock 压缩任务,统一数据固定为:
text
taskId:
float_20261002_03
windowId:
quickdock_float_01
job:
Compress assets_20261002.zip
progress:
73%
state:
RUNNING
dragStart:
x=732, y=128
dragEnd:
x=920, y=356
edge:
RIGHT
dockState:
STOWED
normalized:
x=0.84, y=0.31
persistCost:
11ms
restoreCost:
24ms
restoredPosition:
x=732, y=128
status:
POSITION_READY

一、第三篇先改一个观念:窗口位置不是"绝对像素历史"
前两篇里,QuickDock 的位置一直写成:
text
x=732
y=128
同一台设备、同一方向下,这个值看起来很稳定。
真正把窗口拖到右边,再切一次横竖屏以后,旧坐标马上暴露问题。因为同样的 732 / 128,在新的可用窗口区域里可能已经偏得很远,甚至超出边界。
所以第三篇不直接持久化绝对坐标,而是同时保存:
text
absolute:
920 / 356
normalized:
0.84 / 0.31
绝对值用来做本次会话内即时恢复,归一化坐标用于跨窗口尺寸恢复。
这和普通页面保存 ScrollOffset 很像:真正想保存的是"相对位置语义",而不是死记某一块屏幕上的像素。
二、拖动事件只写入 Drag Session,不直接改业务任务
这一篇新增:
text
managers/
└── FloatDragCoordinator.ets
persistence/
└── FloatPositionRepository.ets
第一段代码解决的是"拖动过程把 TaskSnapshot 也改掉"的问题:
ts
export interface DragSession {
taskId: string
windowId: string
startX: number
startY: number
endX: number
endY: number
edge:
'LEFT' |
'RIGHT' |
'NONE'
dockState:
'FLOATING' |
'STOWED'
}
export class FloatDragCoordinator {
private session:
DragSession | null = null
begin(
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'
}
}
}
这里没有:
text
progress
state
elapsed
remain
因为这些仍然属于 FloatTaskStore。
拖动窗口不会让任务从 RUNNING 变成别的业务状态。
这条边界一旦写清,后面侧边暂存也只是窗口形态变化,不会误伤任务。
三、拖动结束后先做边界 clamp,再判断侧边暂存
如果用户把窗口拖到:
text
x=9999
y=-300
不能把这个坐标直接持久化。
所以拖动结束时先经过可视区域限制:
ts
export interface DisplayBounds {
width: number
height: number
marginVp: number
}
export function clampFloatPosition(
x: number,
y: number,
windowWidth: number,
windowHeight: number,
bounds: DisplayBounds
): { x: number, y: number } {
const minX =
bounds.marginVp
const minY =
bounds.marginVp
const maxX =
bounds.width -
windowWidth -
bounds.marginVp
const maxY =
bounds.height -
windowHeight -
bounds.marginVp
return {
x: Math.max(
minX,
Math.min(x, maxX)
),
y: Math.max(
minY,
Math.min(y, maxY)
)
}
}
当前 QuickDock 的安全边距使用 16vp。
这是项目自己的工程参数,不是系统固定要求。
这一层做完以后,再判断:
text
离左边足够近
→ LEFT
离右边足够近
→ RIGHT
否则
→ NONE
当前本轮拖动到:
text
920 / 356
最终识别:
text
edge=RIGHT
dockState=STOWED
四、侧边暂存不是把窗口坐标改成屏幕外
一开始我做过最简单的"暂存":
text
x = displayWidth - 20
让窗口大部分跑到屏幕外。
效果看起来像侧边收起,但工程上非常脆弱。
因为:
text
屏幕尺寸变化
系统 Insets 变化
窗口宽度变化
都会让这个"假暂存坐标"失效。
现在 QuickDock 不自己模拟系统暂存。
应用侧只记录语义:
text
dockState=STOWED
edge=RIGHT
真正的显示行为交给 FloatViewAdapter 对接当前 HarmonyOS 7 floatView 能力。
这种写法刻意避免在业务代码里硬编码 Beta API 的具体方法名。
适配层暴露的是稳定业务接口:
ts
export interface FloatViewAdapter {
moveTo(
x: number,
y: number
): Promise<void>
stowToEdge(
edge:
'LEFT' |
'RIGHT'
): Promise<void>
restoreFromEdge():
Promise<void>
}
如果后续 SDK 方法名变化,只改 Adapter。
五、位置持久化用归一化坐标,不直接写 920 / 356
这一段代码解决的是"下次窗口大小不同,旧坐标失效"的问题:
ts
export interface PersistedFloatPosition {
taskId: string
windowId: string
normalizedX: number
normalizedY: number
edge: string
dockState: string
savedAt: number
}
export function normalizePosition(
x: number,
y: number,
maxX: number,
maxY: number
): {
normalizedX: number
normalizedY: number
} {
return {
normalizedX:
maxX <= 0
? 0
: Number(
(x / maxX)
.toFixed(2)
),
normalizedY:
maxY <= 0
? 0
: Number(
(y / maxY)
.toFixed(2)
)
}
}
本轮最终保存:
text
normalizedX=0.84
normalizedY=0.31
这些数字和截图一致。
六、ArkData Preferences 只保存窗口会话数据,不保存 Task Runner
位置需要跨页面、甚至跨一次重新进入应用恢复,所以这一篇引入 ArkData Preferences。
FloatPositionRepository 只保存小对象:
ts
import {
preferences
} from '@kit.ArkData'
import {
common
} from '@kit.AbilityKit'
export class FloatPositionRepository {
private readonly name:
string =
'quickdock_float_position'
async save(
context:
common.UIAbilityContext,
value:
PersistedFloatPosition
): Promise<void> {
const store =
await preferences
.getPreferences(
context,
{ name: this.name }
)
await store.put(
'last_position',
JSON.stringify(value)
)
await store.flush()
}
}
这里只保存:
text
位置
边缘
暂存状态
保存时间
不会保存:
text
压缩线程
Task Runner
Window 对象
Adapter 对象
和前两篇一样,持久化的是可重建数据,不是运行时对象。
七、恢复时必须重新计算"当前屏幕里的合法位置"
恢复位置不是:
text
读出 0.84 / 0.31
→ 直接乘
→ 完成
还要再做一次 clamp。
完整恢复顺序:
text
读取 normalized
→ 获取当前 DisplayBounds
→ 根据当前窗口尺寸计算 maxX / maxY
→ 转回绝对坐标
→ clamp
→ moveTo
当前本轮虽然拖动结束在:
text
920 / 356
但我专门做了一次"恢复到保存前标准位置"的测试,最终:
text
restoredPosition
x=732
y=128
恢复耗时:
text
24ms
这组数据在正文、HiLog 和手机图里完全一致。
八、为什么恢复目标是 732 / 128,而不是 920 / 356
这个点很容易看起来矛盾。
03 里同时有两套位置:
text
dragEnd:
920 / 356
restored:
732 / 128
原因是测试流程不是"保存拖动终点后立刻还原到终点"。
完整流程是:
text
初始:
732 / 128
拖动:
920 / 356
进入 RIGHT / STOWED
保存归一化暂存信息
用户点击"恢复位置"
恢复到 QuickDock
上一次正常浮动位置:
732 / 128
也就是说:
text
stowed position
和:
text
last floating position
是两个概念。
QuickDock 分开保存:
text
lastFloatPosition
lastDockEdge
这样从侧边暂存回来时,窗口不会直接贴在右边缘。
九、窗口拖动过程中不要高频 flush Preferences
DragMove 事件可能非常密集。
如果每个位置都:
text
Preferences.put
flush
I/O 没有任何必要。
所以当前规则:
text
DragMove
→ 只更新内存
DragEnd
→ 计算最终位置
→ 判断 edge
→ 持久化一次
本轮持久化耗时:
text
11ms
这是当前 Demo 的一次工程观测,不是系统指标。
真正重要的是:
text
一次拖动
只写一次位置
十、DevEco 图重点看"归一化"和"恢复前 clamp"
开发图:

当前 HiLog:
text
taskId=float_20261002_03
dragStart
x=732 y=128
dragEnd
x=920 y=356
edge=RIGHT
dockState=STOWED
persist normalized
x=0.84
y=0.31
persistCost=11ms
restore clamp
x=732
y=128
restoreCost=24ms
status=POSITION_READY
如果只记录一个"拖动成功",真正出问题时完全不知道:
text
是边缘识别错
还是保存错
还是恢复越界
所以第三篇把每一步都拆成了可观察证据。
十一、运行图把三种位置状态放在一张屏里
最终运行图:

三段状态:
text
1.
FLOATING
732 / 128
2.
RIGHT / STOWED
920 / 356
3.
RESTORED
732 / 128
业务任务始终:
text
Compress assets_20261002.zip
RUNNING
73%
也就是说:
窗口怎么动,不影响任务时间线。
这是 03 最重要的验收结论。
十二、侧边暂存以后,任务更新仍然继续
进入 STOWED 后,QuickDock 不会暂停任务,也不会停止 TaskStore 更新。
只不过显示层处于侧边暂存形态。
这也是官方对闪控窗的产品定位所暗示的能力边界:它用于持续展示实时状态,支持自由拖动、侧边暂存,再需要时恢复。
如果一暂存就停止业务任务,那这个形态失去意义。
十三、位置数据还需要版本号
第三篇给持久化位置加:
text
schemaVersion=1
原因很现实。
现在只保存:
text
normalizedX
normalizedY
edge
dockState
后面可能会再加:
text
displayId
rotation
windowWidth
windowHeight
如果旧 JSON 没版本号,未来字段变化很难迁移。
位置恢复不能因为一份旧数据就阻止窗口创建。
旧版本不兼容时,直接回退到:
text
defaultPosition
732 / 128
比抛异常更合理。
十四、不同显示区域切换时,不复用旧 displayId 假设
如果设备有不同显示区域或窗口环境,位置数据必须重新投影到当前区域。
所以恢复数据里真正长期保留的是:
text
normalized
而不是:
text
displayWidth=旧值
当前 03 没有做跨屏显示,但这条边界先写好,后面 PC / 2in1 或外接显示场景不会重新推倒位置模型。
十五、拖动过程中任务完成怎么办
我专门测了一个边界:
text
progress=99%
用户开始拖动
任务完成
用户松手
结果必须是:
text
TaskStore:
COMPLETED
DragSession:
仍然可以保存最终位置
FloatView:
显示完成态
不能因为拖动 session 还没结束,就丢掉 COMPLETED。
换句话说:
text
DragSession
和
TaskSession
完全独立。
这是前两篇把状态分层以后带来的直接收益。
十六、POSITION_READY 代表什么
最终状态:
text
POSITION_READY
不是"窗口可以拖"。
它代表五个条件都成立:
text
拖动终点已 clamp
边缘语义已识别
STOWED 不污染业务状态
位置只在 DragEnd 持久化
恢复时能重新投影并回到合法区域
做到这一层以后,QuickDock 才真正可以在下一篇讨论:
text
应用退后台
多个任务
异常恢复
而不需要再担心窗口位置本身会成为新的不确定因素。
十七、下一篇:后台运行不是"什么都继续跑"
04 会进入前后台和多任务。
我不会写成:
text
应用进后台
所有任务无限继续
HarmonyOS 对后台任务有明确的系统策略和受约束模型。
所以下一篇会区分:
text
数据传输任务
允许申请合适的后台任务模式
本地计算任务
如果当前设备 / 场景不满足策略
就进入 SUSPENDED_POLICY
与此同时,闪控窗显示层如果发生异常丢失,也必须能从 TaskStore 重建,而不是重新创建业务任务。
这会是 04 的核心工程问题。
十八、跨方向恢复时,不能只用 normalized 坐标"机械还原"
归一化坐标解决了"屏幕尺寸变化"这个问题,但它不是万能的。
假设原来窗口是:
text
竖屏:
1080 × 1920
normalized:
0.84 / 0.31
切成横屏后:
text
1920 × 1080
如果仍然机械套:
text
x = 0.84 × maxX
y = 0.31 × maxY
理论上合法,但视觉上可能跳到一个完全不自然的位置。
所以 QuickDock 在恢复时还会考虑:
text
rotation
edge
lastFloatPosition
当前策略:
text
同方向
→ 优先 normalized
方向变化
→ 如果之前有 edge
优先保持 edge 语义
没有 edge
→ normalized 后再 clamp
也就是说:
text
RIGHT
这种语义比绝对坐标更稳定。
用户把窗口收在右侧,本质上表达的是:
我希望它靠右。
而不是:
我希望它永远处在某个固定 x。
第三篇虽然只展示竖屏测试,但数据模型已经为这条规则留出空间。
十九、恢复位置时还要区分"正常浮动位置"和"暂存位置"
前面已经提到:
text
dragEnd=920/356
和:
text
restored=732/128
不是同一个位置。
最终 WindowSessionStore 里真正保存的是两层:
ts
export interface WindowSessionState {
lastFloatingPosition:
FloatPosition
dock: {
state:
'FLOATING' |
'STOWED'
edge:
'LEFT' |
'RIGHT' |
'NONE'
}
}
lastFloatingPosition 只在普通 Float View 状态下更新。
进入 STOWED 后:
text
只改 dock
不覆盖 lastFloatingPosition
这就避免了"从侧边恢复以后仍然贴着边"的问题。
后面如果闪控球也支持位置记忆,可以继续扩展自己的 session,不需要把所有形态的位置硬塞在一个 x/y 里。
二十、拖动中的进度更新要保证窗口不卡顿
这次任务仍然在 73% RUNNING。
拖动过程中 TaskStore 还会更新:
text
73.1
73.2
73.3
如果 UI 同时处理:
text
高频 drag move
+
高频 progress render
就容易出现"窗口跟手感变差"。
所以第三篇把两类更新分开:
text
Drag Gesture
→ 位置更新优先
Task Progress
→ 继续进入 250ms UI 合并
拖动期间不暂停业务任务,但进度 UI 可以稍微降低刷新频率。
用户更在意:
text
拖动是否跟手
而不是拖动的 300ms 里进度数字有没有多跳一次。
这是一种明确的产品取舍。
二十一、Preferences 写入失败时,当前会话仍然要能继续
位置持久化是增强能力。
如果:
text
Preferences.flush()
失败,不能让闪控窗瞬间跳回默认位置。
QuickDock 当前处理:
text
内存中的 WindowSession
仍保留最新位置
持久化失败
→ 记录 PERSIST_FAILED
→ 本次会话继续使用
下一次 DragEnd
再尝试持久化
也就是说:
text
持久化失败
!=
位置立即失效
只影响:
text
跨重启恢复
不影响当前 Window。
这和前面"Float View 失败不等于 Task 失败"是一脉相承的。
二十二、侧边暂存的入口和恢复入口都要幂等
用户快速操作时可能连续触发:
text
STOW
STOW
RESTORE
RESTORE
如果 Adapter 每次都真正执行一次系统调用,状态机会出现竞争。
所以 FloatDragCoordinator 增加:
text
当前 dockState 已经 STOWED
→ 重复 stow 直接返回
当前已经 FLOATING
→ 重复 restore 直接返回
幂等处理看起来简单,但对窗口类能力非常重要。
因为窗口状态变化通常不是纯内存赋值,而是跨系统边界的异步操作。重复调用越少,越容易保持日志和真实 UI 一致。
二十三、位置恢复验收不能只测一次成功路径
这一篇最后固定做七组位置测试。
第一组,正常拖动到右侧并暂存。
第二组,暂存后恢复,回到 732 / 128。
第三组,保存 normalized 以后模拟窗口尺寸缩小,确认恢复前会 clamp。
第四组,模拟横竖屏变化,优先保持 RIGHT 语义。
第五组,Preferences 写入失败,本次会话位置不回退。
第六组,连续 20 次 DragEnd,持久化次数严格等于 20,不在 DragMove 里高频写。
第七组,任务从 72% 更新到 73% 的过程中拖动窗口,TaskStore 时间线不断。
这七组都通过以后:
text
POSITION_READY
才真正有意义。
二十四、为什么第三篇不把"拖动坐标"写进 TaskStore
做到这一篇以后,这个分层已经很清楚:
text
TaskStore
业务事实
WindowSessionStore
窗口会话
PositionRepository
跨会话位置
FloatViewAdapter
系统接口
如果把 x/y 放回 TaskStore,后面 04 两个任务同时存在时,就会出现:
text
task A 有位置
task B 也有位置
而实际上当前 QuickDock 只有一个活动 Float View。
任务和窗口并不是一一对应关系。
这一层如果现在不分开,下一篇多任务会立刻出现模型冲突。
参考资料
- HarmonyOS 7 闪控窗开发指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/float-view-guide
- HarmonyOS 7 新能力一览:https://developer.huawei.com/consumer/cn/features/
- HarmonyOS ArkData / Preferences:https://developer.huawei.com/consumer/cn/doc/