第一篇把 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 会混入无意义开销。
参考资料
- HarmonyOS 7 新能力一览:闪控窗:https://developer.huawei.com/consumer/cn/features/
- ArkTS API 索引:@ohos.window.floatView / @ohos.window.floatingBall:https://developer.huawei.com/consumer/cn/doc/
- HarmonyOS 设计资源:闪控球和闪控窗:https://developer.huawei.com/consumer/cn/design/resource/