前面的连载做过 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/
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐