HarmonyOS 7 QuickDock 闪控窗开发实录 02:floatView × floatingBall:形态切换、位置恢复与单一状态源【鸿蒙心迹】

第一篇把 QuickDock 的标准闪控窗跑起来以后,我没有立刻去做拖动。

我先做了一个更容易把状态搞乱的动作:

text 复制代码
闪控窗
→ 闪控球
→ 闪控窗

HarmonyOS 7 官方能力描述里,闪控窗和闪控球本来就是可以互相切换的两种多任务形态。当前 API 索引也同时列出了 @ohos.window.floatView 和 @ohos.window.floatingBall。

真正做进 Demo 以后,我发现最容易写错的地方不是"怎么显示球",而是切换形态时会不会顺手创建第二份业务状态。

所以 02 的主线非常明确:

Float View 和 Floating Ball 只是同一任务的两个显示出口,任务必须只有一份。

本轮继续第一篇的压缩任务。

进入时:

text 复制代码
42%
FLOAT_VIEW

中间切换到球形:

text 复制代码
68%
FLOATING_BALL

再恢复:

text 复制代码
68%
FLOAT_VIEW
x=732
y=128

本轮统一数据:

text 复制代码
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

第一版我差点写成:

text 复制代码
FloatViewSnapshot
FloatingBallSnapshot

然后切换时复制:

text 复制代码
42%
→ copy
→ Ball 42%

这种实现第一次没问题。

任务继续跑以后:

text 复制代码
Float View 已隐藏
Ball 继续 68%

FloatViewSnapshot
还停在 42%

恢复时就会出现进度倒退。

所以第二篇先把数据结构砍掉一半:

text 复制代码
只有 QuickTaskSnapshot

两个显示形态都订阅同一个 Store。

二、DisplayModeCoordinator 只负责"谁显示",不负责"任务是什么"

这一篇新增 DisplayModeCoordinator.ets。

代码里最核心的状态只有:

ts 复制代码
export type QuickDockMode =
  'FLOAT_VIEW' |
  'FLOATING_BALL'

export interface FloatPosition {
  x: number
  y: number
}

export class DisplayModeCoordinator {
  private currentMode:
    QuickDockMode =
      'FLOAT_VIEW'

  private savedPosition:
    FloatPosition = {
      x: 732,
      y: 128
    }

  mode(): QuickDockMode {
    return this.currentMode
  }
}

它不保存:

text 复制代码
progress
elapsed
jobName

这些仍然只在:

text 复制代码
FloatTaskStore

这样 Coordinator 就算销毁,任务也不会丢。

三、切到闪控球以前,要先记住闪控窗位置

这一轮虽然还不实现自由拖动,但位置恢复必须先打通。

当前闪控窗:

text 复制代码
x=732
y=128

切换前先保存:

ts 复制代码
async switchToBall():
  Promise<void> {
  if (
    this.currentMode ===
      'FLOATING_BALL'
  ) {
    return
  }

  this.savedPosition =
    await this.floatManager
      .currentPosition()

  await this.floatManager
    .hide()

  const snapshot =
    FloatTaskStore.shared()
      .current()

  await this.ballManager
    .show(snapshot)

  this.currentMode =
    'FLOATING_BALL'
}

这里顺序不能反。

如果先隐藏,再去读位置,某些实现里窗口状态已经不可用。

所以:

text 复制代码
记录位置
→ 隐藏 Float View
→ 显示 Ball

四、显示闪控球时,不能再创建一条 Task Runner

QuickDockBallManager 只拿 Snapshot:

ts 复制代码
export class QuickDockBallManager {
  private visible:
    boolean = false

  async show(
    snapshot:
      QuickTaskSnapshot
  ): Promise<void> {
    if (this.visible) {
      await this.update(
        snapshot
      )
      return
    }

    await FloatingBallAdapterFactory
      .create()
      .show(snapshot)

    this.visible = true
  }

  async update(
    snapshot:
      QuickTaskSnapshot
  ): Promise<void> {
    if (!this.visible) {
      return
    }

    await FloatingBallAdapterFactory
      .current()
      .update(snapshot)
  }
}

它不会启动压缩。

它也不会持有计时器。

压缩继续由原来的 Task Runner 执行。

这就是:

text 复制代码
singleStateSource=true

真正想表达的东西。

五、进度从 42% 到 68%,完全不经过"形态迁移"

我特意把任务继续跑到 68%。

TaskStore:

ts 复制代码
FloatTaskStore.shared()
  .update({
    taskId:
      'float_20261002_02',

    jobName:
      'Compress assets_20261002.zip',

    state:
      'RUNNING',

    progress:
      68,

    elapsedSec:
      124,

    remainSec:
      62,

    tickSeq:
      27
  })

此时当前模式是:

text 复制代码
FLOATING_BALL

所以 UI Subscriber 只更新球。

Float View 虽然隐藏,但不需要保存一个"68% 副本"。

恢复时直接重新读取:

text 复制代码
Store.current()

拿到的自然就是 68%。

六、恢复闪控窗时,先隐藏球,再复用旧窗口

恢复逻辑:

ts 复制代码
async switchToFloatView():
  Promise<void> {
  if (
    this.currentMode ===
      'FLOAT_VIEW'
  ) {
    return
  }

  await this.ballManager
    .hide()

  const snapshot =
    FloatTaskStore.shared()
      .current()

  await this.floatManager
    .showAt(
      snapshot,
      this.savedPosition
    )

  this.currentMode =
    'FLOAT_VIEW'
}

注意是:

text 复制代码
复用 quickdock_float_01

不是:

text 复制代码
新建 quickdock_float_02

这条如果写错,用户每切一次形态,就会多一个隐藏 Window 实例。

第五篇做资源治理时,这种错误会直接变成窗口泄漏。

七、为什么 Float View 不应该在切成球时立即 destroy

第一版我为了"释放干净":

text 复制代码
切球
→ destroy Float View

恢复时再创建。

数据是对的,但:

text 复制代码
toFloatCost

明显变大,而且位置恢复还要重新做一遍初始化。

当前 QuickDock 的取舍是:

text 复制代码
形态切换
→ hide

任务结束
→ dispose

这样更符合"同一个持续任务在两种显示形态之间切换"。

如果系统后续对隐藏窗口资源有更明确约束,再由 Adapter 层调整,不改变业务状态模型。

八、两个显示层不能各注册一份永久 TaskListener

另一个容易泄漏的地方是监听。

如果:

text 复制代码
FloatManager.on(snapshot)
BallManager.on(snapshot)

两边都永久注册,那么即使一个形态隐藏,仍然会做无意义更新。

现在改成 Coordinator 统一订阅:

ts 复制代码
export class DisplayModeCoordinator {
  private taskListener:
    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:

text 复制代码
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% 的数据。

十、运行图要同时展示三段状态

最终运行图:

第一段:

text 复制代码
Float View
42%
quickdock_float_01
x=732
y=128

第二段:

text 复制代码
Floating Ball
68%
quickdock_ball_01

第三段:

text 复制代码
Float View
68%
quickdock_float_01
x=732
y=128

底部统一状态:

text 复制代码
taskId:
float_20261002_02

tickSeq:
27

singleStateSource:
true

status:
CONTINUOUS

这张图比单独截一个闪控球更能证明工程问题解决了。

十一、切换成本单独统计,避免以后"越做越慢"

当前:

text 复制代码
toBallCost:
31ms

toFloatCost:
38ms

这两个数字是 QuickDock 当前设备和工程版本的观测值,不是 HarmonyOS 系统性能指标。

我把它们记下来,是为了后续版本能对比。

如果 05 加了更多状态以后:

text 复制代码
toFloatCost
38ms → 150ms

就知道恢复流程变重了。

性能基线应该从第一天就记录,而不是最后一篇才想起来测。

十二、任务暂停也必须从 Store 出发

闪控窗和闪控球都有"暂停"入口。

如果两边按钮分别维护自己的 paused 状态,就会立刻分裂。

现在按钮只调用:

ts 复制代码
FloatTaskController.shared()
  .pause(
    'float_20261002_02'
  )

Controller 更新 Store:

text 复制代码
RUNNING
→ PAUSED

当前可见形态收到 Snapshot,再刷新 UI。

也就是说:

text 复制代码
窗口不拥有业务动作结果
窗口只是发出命令

这和 01 的"窗口不拥有任务"保持一致。

十三、切换失败时,旧形态不能先消失

做异常测试时我发现一个顺序问题。

如果:

text 复制代码
hide Float View
→ show Floating Ball 失败

用户会突然什么都看不到。

所以正式切换必须带回滚:

text 复制代码
记录旧形态
→ 尝试创建新形态
→ 新形态 show 成功
→ 再隐藏旧形态

或者由 Adapter 支持原子化切换。

QuickDock 当前用的是保守策略:

text 复制代码
new show success
→ old hide

图里为了讲解顺序写得更直观,但实际 Manager 会确保失败时至少保留一种可见状态。

十四、为什么第二篇仍然没做侧边暂存

HarmonyOS 7 官方对闪控窗的描述还包括:

text 复制代码
自由拖动
侧边栏暂存

这两项都依赖位置和可见区域状态。

现在第二篇已经打通:

text 复制代码
位置保存
形态恢复
单一状态源

下一篇才有足够基础做:

text 复制代码
拖到屏幕边缘
进入暂存
再次拉出
位置重新约束

否则侧边暂存一上来,就会同时混入:

text 复制代码
窗口移动
Ball 切换
任务进度
拖动手势
位置持久化

很难排查。

十五、第二篇最后做了六组反向测试

第一组,从 42% 切到球,任务继续运行。

第二组,球形阶段进度更新到 68%,Float View 不保留旧 Snapshot。

第三组,从球恢复窗口,读取 Store 当前 68%。

第四组,窗口恢复到 732 / 128。

第五组,连续切换 10 次,窗口 ID 始终是:

text 复制代码
quickdock_float_01
quickdock_ball_01

没有 _02、_03。

第六组,Coordinator dispose 后 TaskListener 数量回到 0。

这六组通过以后,本轮状态才记成:

text 复制代码
CONTINUOUS

十六、这一篇真正完成的是"形态无关任务"

做到这里 QuickDock 的任务已经不再属于:

text 复制代码
主页面
闪控窗
闪控球

它只属于:

text 复制代码
FloatTaskStore

窗口和球只是不同输出。

这是后面自由拖动、侧边暂存、多任务切换都能继续复用的基础。

下一轮 03、04 会继续这个项目。

03 处理:

text 复制代码
自由拖动
侧边暂存
位置持久化
屏幕边界约束

04 再处理:

text 复制代码
应用退后台
任务继续
重新进入
多任务状态切换
异常恢复

不会重建 Demo,也不会重置编号。

十七、切换形态时,不能让两个 UI 同时长时间可见

第二篇为了避免失败后没有任何可见出口,实际切换会先尝试显示新形态,再隐藏旧形态。

但这不代表两个形态可以长期共存。

我给切换过程增加中间状态:

text 复制代码
FLOAT_ACTIVE
SWITCHING_TO_BALL
BALL_ACTIVE
SWITCHING_TO_FLOAT

只有 SWITCHING_* 阶段允许短时间两个适配层都处于准备状态。

一旦新形态确认可见,旧形态必须进入 hide。

否则用户可能看到:

text 复制代码
一边一个 Float View
旁边还有一个 Floating Ball

而且两边还同时接收进度更新。

这种问题在快速点击切换按钮时最容易出现,所以按钮在 SWITCHING_* 阶段会暂时禁用。

十八、位置恢复还要做屏幕边界校验

当前恢复位置:

text 复制代码
x=732
y=128

在同一台设备上没有问题。

但如果用户切换显示形态期间:

text 复制代码
发生旋转
进入分屏
窗口区域变化

原坐标可能已经超出可用区域。

所以 showAt() 之前还会经过:

ts 复制代码
export function clampPosition(
  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 很容易写成一个"迷你任务组件",然后在里面自己做:

text 复制代码
pause
resume
progress

QuickDock 不这么做。

球的点击事件只发:

text 复制代码
OPEN_FLOAT_VIEW
PAUSE_TASK
RESUME_TASK
OPEN_APP

真正状态变化仍然经过 Controller 和 Store。

例如暂停:

ts 复制代码
export class QuickTaskController {
  pause(taskId: string): void {
    const current =
      FloatTaskStore.shared()
        .current()

    if (
      current.taskId !== taskId ||
      current.state !== 'RUNNING'
    ) {
      return
    }

    FloatTaskStore.shared()
      .update({
        ...current,
        state: 'PAUSED'
      })
  }
}

这样用户从球暂停,再恢复闪控窗时看到的仍然是 PAUSED,不会因为 Window 自己没有收到暂停事件而显示 RUNNING。

二十、切换统计要把失败和成功分开

当前成功耗时:

text 复制代码
toBallCost=31ms
toFloatCost=38ms

但最终回归还需要记录:

text 复制代码
switchAttempt
switchSuccess
switchRollback

比如:

text 复制代码
attempt=10
success=10
rollback=0

如果只记录成功耗时,一次失败回滚可能完全不出现在性能报告里。

DisplayModeCoordinator 会为每次切换生成:

ts 复制代码
export interface ModeSwitchRecord {
  from: QuickDockMode
  to: QuickDockMode
  startAt: number
  endAt: number
  success: boolean
  rollback: boolean
}

第五篇做资源治理和第六篇做 25 轮回归时,会继续使用这份记录。

二十一、单一状态源不等于"所有状态塞进一个大对象"

这一篇一直强调 singleStateSource=true,但我不希望它被理解成:

text 复制代码
所有状态都放一个 Store

QuickDock 实际仍然分层:

text 复制代码
QuickTaskStore
业务任务

WindowSessionStore
窗口位置 / 尺寸

DisplayModeCoordinator
当前展示形态

Adapter
系统 API 调用

所谓"单一状态源"只针对同一个业务事实:

text 复制代码
任务进度
任务状态
剩余时间

它们不能在 Float View 和 Floating Ball 各有一份。

位置和模式本来就是另外的事实,应该继续放在自己的状态域里。

二十二、10 次来回切换以后,我会检查四个数量

最后一组压力测试:

text 复制代码
Float View
↔
Floating Ball

连续 10 次。

结束后检查:

text 复制代码
Float Task Runner = 1
Task Listener = 1
Float Window Instance = 1
Floating Ball Instance = 1

不是:

text 复制代码
10
10
10
10

然后 Coordinator dispose:

text 复制代码
Task Listener = 0
Visible UI = 0

这组数量检查比"肉眼看上去没有重复窗口"更可靠。

二十三、第二篇停在 CONTINUOUS,是为了给第三篇留下清晰起点

到这里,QuickDock 已经证明:

text 复制代码
Float View 隐藏
任务不停止

Floating Ball 显示
任务继续

恢复 Float View
进度不倒退

窗口位置还能找回来

因此 CONTINUOUS 表达的是:

同一个 QuickTask 在两种系统窗口形态之间切换时,业务时间线保持连续。

第三篇再加入拖动、侧边暂存和位置持久化时,不需要再重新讨论"进度是不是同一份",只解决新的工程问题。

二十四、切到闪控球以后,信息密度也应该主动下降

Float View 可以显示:

text 复制代码
文件名
进度
剩余时间
暂停按钮
返回应用

Floating Ball 的空间更小,如果继续尝试展示同样的信息,只会让 UI 变得拥挤。

所以球形态只保留:

text 复制代码
任务图标
进度环
异常状态点

用户点击球以后,才恢复标准闪控窗或回主应用。

这意味着同一个 Snapshot 在不同形态下会经过不同的 ViewModel:

ts 复制代码
export interface BallViewState {
  progress: number
  state: QuickTaskState
  hasError: boolean
}

export function toBallState(
  snapshot:
    QuickTaskSnapshot
): BallViewState {
  return {
    progress: snapshot.progress,
    state: snapshot.state,
    hasError:
      snapshot.state === 'FAILED'
  }
}

数据源只有一份,但展示模型可以不同。

这比"两个 UI 必须显示完全相同字段"更符合多形态产品设计。

二十五、形态切换还要考虑任务已经在切换过程中结束

极端情况下,用户在 99% 时切到闪控球。

切换过程 31ms 内任务可能直接变成 COMPLETED。

如果 Coordinator 只拿切换开始时的 Snapshot,球刚显示出来可能还是 99%。

所以新形态真正 show 之前,会再次读取:

text 复制代码
FloatTaskStore.current()

而不是一直使用旧参数。

这样即使任务在切换窗口里完成,最终显示的仍然是最新状态。

这条校验会继续保留到后面所有形态切换里。

二十六、第二篇的验收还包括"不可见形态不做无意义刷新"

切到闪控球以后,隐藏的 Float View 不再接收每一次进度渲染;恢复 Float View 后,隐藏的 Ball 也停止更新。任务仍然只有一条时间线,但 UI 更新只投递给当前可见形态。

这样既避免重复渲染,也让后续性能统计更可信。否则两个隐藏 / 可见形态同时刷新,最后看到的 updateCost 会混入无意义开销。

参考资料

相关推荐
企业数字化笔记44 分钟前
视频局部合成怎么做得自然?掩膜修复、边缘羽化、时序稳定与背景替换
java·javascript·音视频
仍然.1 小时前
Redis---主从复制
java·数据库·redis
李游Leo1 小时前
HarmonyOS 7 Spatial Recon Kit 开发实录 03:重建进度、暂停恢复与前后台状态机【鸿蒙心迹】
华为·harmonyos
vx_Biye_Design1 小时前
springboot咖啡厅顾客点单管理系统12080-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·课程设计·express
李游Leo1 小时前
HarmonyOS 7 QuickDock 闪控窗开发实录 03:floatView × ArkData:自由拖动、侧边暂存与位置持久化【鸿蒙心迹】
华为·harmonyos
QQ_21696290961 小时前
基于微信小程序的智能膳食分析系统
java·大数据·spring boot·微信小程序·小程序·旅游
海绵宝宝转agent1 小时前
Leetcode100 二叉树的中序遍历
java·算法
砚底藏山河1 小时前
量化实战:行情数据 Schema 演进与向后兼容
java·python·金融·maven
专业程序开发源1 小时前
flask动漫推荐系统32319-计算机课程设计、毕业设计
java·spring boot·后端·django·flask·php·课程设计