HarmonyOS 7 QuickDock 闪控窗开发实录 01:@ohos.window.floatView × ArkTS:标准闪控窗接入与任务状态边界【鸿蒙心迹】
前面的连载做过 3DGS、Native 三方库、多形态和大屏路由,这一轮我换到 HarmonyOS 7 的另一条系统级多任务能力:闪控窗。
项目叫 QuickDock。它不是播放器,也不是聊天悬浮球,而是一个“长任务状态面板”。我给 Demo 设计的第一条任务很简单:压缩一组素材文件。用户在应用里点击开始后,可以离开主页面,QuickDock 继续把进度、剩余时间和暂停入口放到一个标准闪控窗里。
HarmonyOS 7 官方能力页对闪控窗的描述很直接:接入标准悬浮窗后,可以常驻展示实时状态,支持自由拖动、侧边栏暂存,并能和闪控球一键切换。当前 API 索引里也已经列出 @ohos.window.floatView 和 @ohos.window.floatingBall,其中 floatView 仍带 Beta 标识。
所以这个系列固定 6 篇,不把第一篇写成“把一个 Window 画出来”:
01 标准闪控窗接入与任务状态边界
02 闪控窗 / 闪控球切换与单一状态源
03 自由拖动、侧边暂存与位置恢复
04 多任务切换、前后台状态与异常恢复
05 重复创建、生命周期冲突与窗口资源治理
06 25 轮回归、性能基线与工程验收
本轮 01 的统一数据:
taskId:
float_20261002_01
job:
Compress assets_20261002.zip
state:
RUNNING
progress:
42%
elapsed:
00:01:18
remain:
00:01:48
mode:
FLOAT_VIEW
windowId:
quickdock_float_01
size:
320 × 184vp
position:
x=732, y=128
createCost:
46ms
updateCost:
7ms
status:
FLOAT_READY

一、第一步先把“任务”从“窗口”里拿出来
我最开始写 QuickDock 时,直觉上把状态全部塞进闪控窗页面:
progress
elapsed
remain
paused
页面消失,状态就跟着页面生命周期走。
这和一个真正的长任务冲突很明显:窗口只是任务的一个展示形态,任务本身不应该属于窗口。
所以第一期先固定三层:
QuickTaskStore
负责业务任务
QuickDockFloatManager
负责窗口形态
FloatTaskPage
负责展示
当前任务模型:
export type QuickTaskState =
'IDLE' |
'RUNNING' |
'PAUSED' |
'COMPLETED' |
'FAILED'
export interface QuickTaskSnapshot {
taskId: string
jobName: string
state: QuickTaskState
progress: number
elapsedSec: number
remainSec: number
tickSeq: number
}
export const initialSnapshot:
QuickTaskSnapshot = {
taskId:
'float_20261002_01',
jobName:
'Compress assets_20261002.zip',
state:
'RUNNING',
progress:
42,
elapsedSec:
78,
remainSec:
108,
tickSeq:
18
}
这个 Snapshot 不包含:
windowId
x / y
width / height
因为那是窗口状态,不是任务状态。
二、floatView 适配层单独包起来,不让 Beta 接口散进业务代码
当前 API 索引已经把 @ohos.window.floatView 列入窗口管理能力,而且仍标记为 Beta。
这类接口我不希望页面里到处直接调用。
所以 QuickDock 增加一个适配层:
export interface FloatViewAdapter {
create(
windowId: string
): Promise<void>
show(
snapshot:
QuickTaskSnapshot
): Promise<void>
update(
snapshot:
QuickTaskSnapshot
): Promise<void>
hide(): Promise<void>
dispose(): Promise<void>
}
真正和 @ohos.window.floatView 对接的 SDK 调用只放到:
adapters/
└── HarmonyFloatViewAdapter.ets
业务 Manager 只依赖这个接口。
这样做有两个原因。
一个是 Beta API 后续签名变化时,修改范围只有 Adapter;另一个是后面做自动化回归时,可以用 FakeAdapter 验证状态机,不需要每次都创建真实窗口。
三、重复点击“显示闪控窗”,不能重复创建窗口实例
01 最容易复现的 bug 是:
用户连续点两次“显示闪控窗”
第一版会创建两份窗口。
更麻烦的是两份窗口都订阅同一个 TaskStore,进度更新一次,两个窗口一起变化。
现在 QuickDockFloatManager 明确做单例保护:
export class QuickDockFloatManager {
private adapter:
FloatViewAdapter | null = null
private readonly windowId:
string = 'quickdock_float_01'
private created:
boolean = false
async show(
snapshot:
QuickTaskSnapshot
): Promise<boolean> {
if (this.created) {
await this.adapter
?.update(snapshot)
return false
}
this.adapter =
FloatViewAdapterFactory
.create()
await this.adapter.create(
this.windowId
)
await this.adapter.show(
snapshot
)
this.created = true
return true
}
}
这里 false 不是失败。
它表示:
窗口已经存在
本次只做 update
这比第二次静默创建一份窗口更容易在日志里验收。
四、窗口生命周期和任务生命周期必须分开
这一期我反复强调的一点是:
hide FloatView
!=
cancel task
用户可能只是:
临时收起窗口
回主应用
等任务继续跑
如果 hide() 顺手把 TaskStore 改成 IDLE,那 02 切到闪控球时根本无状态可接。
所以当前事件边界:
关闭显示
→ hide window
暂停按钮
→ pause task
结束任务
→ completed / failed
→ 再决定是否销毁 window
只有业务任务真正结束,Manager 才进入回收路径。
这和普通页面“aboutToDisappear 就清全部资源”的思路完全不同。
五、TaskStore 只允许一份活动 Snapshot
当前 Demo 只有一条活动压缩任务。
所以 Store 先保持简单:
export type TaskListener =
(
snapshot:
QuickTaskSnapshot
) => void
export class FloatTaskStore {
private snapshot:
QuickTaskSnapshot =
initialSnapshot
private listeners:
Set<TaskListener> =
new Set<TaskListener>()
current():
QuickTaskSnapshot {
return {
...this.snapshot
}
}
update(
next:
QuickTaskSnapshot
): void {
this.snapshot = next
this.listeners.forEach(
(listener) => {
listener(
this.current()
)
}
)
}
on(
listener:
TaskListener
): void {
this.listeners.add(
listener
)
}
off(
listener:
TaskListener
): void {
this.listeners.delete(
listener
)
}
}
第一期还不做多任务队列。
原因很简单:窗口状态、任务状态和监听生命周期还没收稳,过早加多个任务只会把问题放大。
六、进度更新不能直接驱动窗口 API 高频调用
压缩任务内部可能每几十毫秒就有进度变化。
如果每次都调用 Window Adapter:
41.01%
41.02%
41.03%
...
闪控窗会被高频刷新。
QuickDock 当前 UI 更新做 250ms 合并:
export class FloatUpdateScheduler {
private timer:
number = -1
private pending:
QuickTaskSnapshot | null =
null
schedule(
snapshot:
QuickTaskSnapshot,
apply:
(
value:
QuickTaskSnapshot
) => Promise<void>
): void {
this.pending = snapshot
if (this.timer >= 0) {
return
}
this.timer =
setTimeout(
async () => {
this.timer = -1
const value =
this.pending
this.pending = null
if (value !== null) {
await apply(value)
}
},
250
)
}
}
250ms 是 QuickDock 当前工程值,不是系统要求。
真正目的只是把:
业务更新频率
和:
窗口渲染频率
分开。
七、任务页面只渲染 Snapshot,不再持有 Task Runner
FloatTaskPage 现在很薄:
@Entry
@Component
struct FloatTaskPage {
@State
progress: number = 42
@State
state: string = 'RUNNING'
@State
elapsedText:
string = '00:01:18'
@State
remainText:
string = '00:01:48'
build() {
Column() {
Text(
'Compress assets_20261002.zip'
)
Progress({
value: this.progress,
total: 100
})
Row() {
Text(this.state)
Text(
`${this.progress}%`
)
}
Text(
`${this.elapsedText} / ${this.remainText}`
)
}
}
}
它不知道任务到底是:
压缩
上传
导出
下载
页面只知道 Snapshot。
后面换业务任务时,不需要重新写闪控窗生命周期。
八、DevEco 里真正要看的是“第二次 show 没有第二个窗口”
开发图:

当前 HiLog:
taskId=float_20261002_01
create float window
quickdock_float_01
adapter show
mode=FLOAT_VIEW
size=320x184vp
x=732
y=128
task update
progress=42
state=RUNNING
createCost=46ms
updateCost=7ms
status=FLOAT_READY
我还会主动再点一次“显示闪控窗”。
预期:
adapter already created
use update
而不是:
quickdock_float_02
这个验证比一张“窗口已经出现”的截图更重要。
九、运行图必须把任务和窗口两层数据同时展示出来
最终运行图:

业务层:
taskId:
float_20261002_01
job:
Compress assets_20261002.zip
state:
RUNNING
progress:
42%
elapsed:
00:01:18
remain:
00:01:48
窗口层:
windowId:
quickdock_float_01
mode:
FLOAT_VIEW
size:
320 × 184vp
position:
732 / 128
createCost:
46ms
updateCost:
7ms
status:
FLOAT_READY
两层能同时看到,后面遇到问题就容易判断:
任务没更新
还是窗口没刷新
十、为什么第一期没有直接做自由拖动
HarmonyOS 7 对闪控窗的整体能力介绍里已经包含:
自由拖动
侧边栏暂存
与闪控球切换
但我没有把所有能力塞进 01。
第一篇先解决:
创建
显示
更新
隐藏
销毁
单实例
这一层如果不稳,拖动位置和闪控球只会继续增加状态维度。
03 会专门处理拖动、侧边暂存和位置恢复。
十一、官方能力描述给了我们一个很重要的产品边界
闪控窗不是普通 Popup。
HarmonyOS 7 官方能力页对它的定位是:
标准悬浮窗
常驻展示实时状态
自由拖动
侧边栏暂存
和闪控球切换
这意味着适合它的业务通常具备:
任务持续时间较长
用户不需要一直盯着完整页面
但仍希望看到实时状态
QuickDock 选择“压缩任务”就是为了符合这个边界。
如果只是一个 500ms 的接口请求,根本没必要创建闪控窗。
十二、第一期最后做了五组反向测试
第一组,任务先 RUNNING,再创建闪控窗,窗口拿到最新 Snapshot。
第二组,连续点两次显示,只保留 quickdock_float_01。
第三组,隐藏窗口后任务继续从 40% 更新到 42%。
第四组,重新显示窗口,读取当前 42%,而不是旧的 40%。
第五组,任务结束后调用 dispose,listener 数量回到 0。
这五组都通过以后,我才把状态记成:
FLOAT_READY
十三、下一篇真正要解决的是“两个形态是不是一条任务”
HarmonyOS 7 官方描述里,闪控窗可以和闪控球一键切换。
这意味着 02 不能写成:
创建一个 Float View
再创建一个 Floating Ball
两边各跑自己的进度
而应该是:
同一个 TaskStore
同一个 taskId
同一个 progress
Float View
↔
Floating Ball
切换形态,不切换业务任务。
下一篇会继续当前压缩任务,从 42% 跑到 68%,专门验证:
切成闪控球
恢复闪控窗
位置恢复
进度连续
监听不重复
十四、页面退出以后,窗口不能再依赖 Page 的 @State
把闪控窗真正放到主应用外面以后,还有一个非常典型的问题。
最早的进度来源是 HomePage:
@State
private progress: number = 0
页面每次变化就把值推给浮窗。
只要用户停留在首页,一切正常;一旦切到别的页面,甚至页面树被系统回收,这个状态源就不可靠了。
所以 QuickDock 后来明确规定:
Page @State
只负责页面自己显示
QuickTaskStore
才是长任务事实来源
页面需要进度时,从 Store 订阅;闪控窗需要进度时,也从同一个 Store 订阅。
这样 HomePage 是否存在,不再影响任务。
这也是“常驻展示实时状态”真正落到工程里的前提——实时状态必须来自一个比页面生命周期更长的业务对象。
十五、任务真正结束以后,销毁顺序也不能反
完成状态看起来很简单:
42%
→ 100%
→ COMPLETED
但窗口收口时仍然有顺序。
如果先:
dispose window
再更新 Store:
COMPLETED
最终一帧“已完成”根本没有机会显示。
QuickDock 当前流程:
Task Runner 完成
→ Store 写入 COMPLETED / 100%
→ Float View 显示完成态
→ 保留 800ms
→ hide
→ dispose
→ unsubscribe
其中 800ms 仍然是当前 Demo 的工程参数,只是为了让用户看到任务已经结束。
异常失败也类似:
FAILED
→ 展示错误摘要
→ 给用户返回应用入口
而不是一失败就把窗口销毁,让用户完全不知道发生了什么。
十六、窗口位置也属于 SessionState,不属于任务本身
第一篇虽然还不做拖动,但已经有:
x=732
y=128
我没有把它放进 QuickTaskSnapshot。
因为同一条压缩任务可以:
在 732 / 128 显示
也可以拖到 120 / 500
任务没有发生任何变化。
因此 QuickDock 还有一份轻量 WindowSession:
export interface FloatWindowSession {
windowId: string
x: number
y: number
widthVp: number
heightVp: number
visible: boolean
}
export const initialWindowSession:
FloatWindowSession = {
windowId: 'quickdock_float_01',
x: 732,
y: 128,
widthVp: 320,
heightVp: 184,
visible: true
}
第二篇做形态切换时复用这份位置。
第三篇做拖动时继续演进。
把 TaskSnapshot 和 WindowSession 分开以后,后续很多状态都更容易判断“到底属于哪一层”。
十七、Adapter 错误必须转换成业务能理解的结果
SDK 层抛出来的错误,不应该直接一路显示给页面。
QuickDock Adapter 统一转成:
export type FloatViewResult =
| {
ok: true
}
| {
ok: false
code:
'UNSUPPORTED' |
'CREATE_FAILED' |
'SHOW_FAILED' |
'UPDATE_FAILED'
message: string
}
Manager 根据结果决定:
UNSUPPORTED
→ 回主应用普通任务页
CREATE_FAILED
→ 不改变任务运行状态
SHOW_FAILED
→ 允许稍后重试
UPDATE_FAILED
→ Store 继续更新
→ 下一次显示重新读最新 Snapshot
这条边界很重要。
窗口失败不等于任务失败。
如果压缩已经跑到 42%,只是闪控窗显示失败,不能把整个压缩任务标成 FAILED。
十八、首篇验收里我还会检查支持能力和降级路径
闪控窗属于 HarmonyOS 7 的新多窗能力,API 索引里当前仍标有 Beta。
真实产品不能只在一台支持设备上测通过就结束。
启动阶段我会先经过:
CapabilityCheck
→ supported
→ create Float View
不支持时:
继续执行任务
→ 使用主应用任务页
→ 不创建闪控窗
也就是说,闪控窗是 QuickDock 的“增强展示能力”,不是压缩任务运行的必要条件。
这条降级路径能避免“系统能力不可用,业务也完全不可用”的耦合。
十九、为什么我把第一篇停在 FLOAT_READY
这篇最终没有展示:
拖动成功
侧边暂存成功
球形切换成功
因为第一篇真正需要验收的是这五件事:
窗口只创建一次
任务只有一份状态
页面退出任务仍继续
窗口隐藏不等于任务停止
窗口销毁监听能释放
这些全部成立以后,后面的系统交互才有基础继续叠加。
所以 FLOAT_READY 不是“窗口出现了”。
它代表的是:
QuickDock 已经把标准闪控窗接入任务生命周期,而且窗口和任务两个层级没有混在一起。
二十、第一篇最后还要验证“业务完成”和“窗口销毁”不是同一时刻
压缩任务到 100% 时,业务层先进入 COMPLETED,窗口层仍然保留一小段完成态展示时间。这段时间里用户如果点击“返回应用”,主页面应该直接进入任务结果页,而不是重新创建一个 RUNNING 任务。
因此 QuickDock 会给任务增加不可逆终态:
RUNNING
→ PAUSED
→ RUNNING
→ COMPLETED
COMPLETED
不能再回 RUNNING
窗口层只消费这个终态,不拥有修改权。
如果用户在完成后再次点击“显示闪控窗”,Manager 读取到 COMPLETED,会显示结果摘要,而不会重新启动 Task Runner。
这条看似很小的规则,能避免“任务已经完成,窗口却因为页面重建又开始跑”的生命周期错乱。
另外我把 windowId=quickdock_float_01 和 taskId=float_20261002_01 分开,也是为了后面任务完成后复用窗口管理器时更清晰:窗口实例可以回收,任务历史仍然可以保留。下一条任务会获得新的 taskId,而不是拿旧任务 ID 继续覆盖。
二十一、正式产品里还要关注可访问性和可操作面积
闪控窗尺寸更紧凑,按钮更容易被做得过小。
QuickDock 当前 320×184vp 的卡片只保留:
进度
暂停
返回应用
没有把所有主页面操作都塞进去。
原因并不只是视觉简洁。小窗里按钮过多会让点击热区拥挤,也会增加无障碍焦点顺序的复杂度。
真正需要复杂配置时,我更倾向于:
闪控窗显示关键状态
→ 点击返回应用
→ 在完整页面处理
这也符合闪控窗“高频状态、轻操作”的定位,而不是把主页面缩小后硬塞进一个悬浮窗。
参考资料
- HarmonyOS 7 新能力一览:闪控窗:https://developer.huawei.com/consumer/cn/features/
- ArkTS API 索引:@ohos.window.floatView:https://developer.huawei.com/consumer/cn/doc/
- HarmonyOS 设计资源:闪控球和闪控窗:https://developer.huawei.com/consumer/cn/design/resource/
更多推荐



所有评论(0)