HarmonyOS 7 QuickDock 闪控窗开发实录 03:floatView × ArkData:自由拖动、侧边暂存与位置持久化【鸿蒙心迹】

前两篇把 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。

任务和窗口并不是一一对应关系。

这一层如果现在不分开,下一篇多任务会立刻出现模型冲突。

参考资料

相关推荐
resh_people14 小时前
开源鸿蒙平台 KMP_CMP 三方库「Kermit」适配全流程
华为·开源·harmonyos
李游Leo16 小时前
HarmonyOS 7 EasyGo + ArkUI Navigation:平行视界主副页筛选事务与跨栏撤销一致性【鸿蒙心迹】
华为·harmonyos
HwJack2017 小时前
【共创稿事节】HarmonyOS 7空间应用的可用性测试和体验评估小方法分享
华为·harmonyos·可用性测试
m0_7381858217 小时前
Flutter 鸿蒙化实战:flutter_scankit 适配 OpenHarmony,华为 ScanKit 扫码
flutter·华为·harmonyos·鸿蒙
zhangfeng113317 小时前
Ag / Cu / Au / Ru / Ir / Rh银 铜 金 钌铱 铑 纳米尺度下的 导电性 + 扩散/迁移缺点
人工智能·华为·ai编程·npu
用户1179104883319 小时前
别让AI随便启动你的LSP:hmharness给语言服务器加SHA256门禁 GitHub:swsgbl/hmharness
ai编程·harmonyos
李游Leo1 天前
HarmonyOS 7 + Share Kit-Window Kit:精准碰一碰屏幕坐标的窗口换算与落点拒绝【鸿蒙心迹】
华为·harmonyos
翼辉cto1 天前
Compose Multiplatform KV 存储库 multiplatform-settings 的 OpenHarmony 鸿蒙化适配实战
华为·harmonyos
事圆则缓1 天前
Flutter 开发鸿蒙实战:从 OpenHarmony 适配版到 HAP 构建与插件接入
flutter·华为·harmonyos