第三篇结束以后,“轻任务悬浮台”已经不再只是一个会漂在屏幕上的小卡片了。

前面三篇一路走下来,我先把任务状态做出来,再把窗口拖动、边缘约束、位置恢复补上,第三篇又继续把“侧边栏暂存 + 状态保持”做完整。到这一步,任务已经可以在完整闪控窗和侧边栏暂存入口之间切换,而且进度不会因为 UI 形态变化而丢失。

但我在继续体验时,很快又遇到了一个新的问题:

如果用户不想一直保留完整闪控窗,也不想每次都去侧边栏找任务,有没有一个更轻、更快、更像“随手入口”的形态?

这就是第四篇真正要解决的事情:闪控球。

HarmonyOS 7 当前官方对闪控窗的定位里,已经明确提到标准悬浮窗可以常驻展示实时状态、支持自由拖动、侧边栏暂存,并且能够和闪控球一键切换。设计侧也把闪控球和闪控窗定义为悬浮于应用界面之上的轻量化窗口形态,用来展示信息、提供简易操作,并提升跨应用多任务体验。

所以第四篇的目标,不是“再加一个新控件”,而是让“轻任务悬浮台”的任务真正拥有多个入口:

主应用
  ↕
闪控窗
  ↕
闪控球

无论用户当前看到的是哪一种 UI,底层都必须是同一条任务、同一份进度、同一套状态。

这一篇最核心的关键词其实只有两个:

  • 切换:闪控窗和闪控球之间快速切换;
  • 同步:主应用、闪控窗、闪控球三处看到的任务状态必须一致。

在这里插入图片描述

一、第四篇真正难的不是“做一个球”,而是避免出现三份状态

如果只看视觉效果,闪控球很好理解:

  • 把完整任务卡片收起来;
  • 留一个更轻的小球;
  • 小球上显示一个图标或进度;
  • 点击以后再恢复完整任务窗。

但工程上真正危险的地方在于:

主应用里有一份任务状态
闪控窗里有一份任务状态
闪控球里又有一份任务状态

只要这三处各自维护自己的进度,就一定会出问题。

最典型的场景就是:

  • 主应用已经 68%;
  • 闪控窗还停在 66%;
  • 闪控球又显示 67%。

这类 Bug 很难看,而且非常影响用户对系统能力的信任。

所以第四篇一开始,我先把一个原则立死:

三端 UI 不保存任务真相,TaskStore 才是唯一状态源。

主应用、闪控窗、闪控球都只读取同一份状态,然后决定怎么展示。

这也是第四篇为什么会引入 StateSyncService 的原因。

二、第四篇的项目目录开始围绕“多入口”重新组织

这一篇我没有推倒前面的结构,而是在第三篇的基础上补了两个角色:StateSyncService 和 FlashBallManager。

FlashTaskDemo
├─ entry/src/main/ets
│  ├─ pages
│  │  ├─ Index.ets
│  │  ├─ FloatWindowPage.ets
│  │  └─ TaskCenterPage.ets
│  ├─ components
│  │  ├─ FloatTaskCard.ets
│  │  ├─ FlashBallView.ets
│  │  └─ SideBarTaskItem.ets
│  ├─ model
│  │  └─ TaskInfo.ets
│  ├─ store
│  │  └─ TaskStore.ets
│  ├─ service
│  │  ├─ TaskManager.ets
│  │  ├─ StateSyncService.ets
│  │  └─ FloatWindowController.ets
│  ├─ manager
│  │  ├─ SideBarManager.ets
│  │  └─ FlashBallManager.ets
│  └─ common
│     └─ Logger.ets
└─ resources

这几个角色的职责我会分得很清楚:

TaskStore

负责保存任务事实:

  • taskId
  • progress
  • status
  • displayMode
  • updatedAt

StateSyncService

负责把任务状态同步到不同展示入口。

FlashBallManager

负责“闪控窗 ↔ 闪控球”的形态切换。

FloatWindowController

负责完整闪控窗的显示、隐藏和恢复。

这样一来,第四篇虽然入口更多了,代码反而不会乱。

三、先把 displayMode 做成真正可扩展的窗口形态状态

第三篇里,我们已经有:

floating
stored
restoring
hidden

第四篇加入闪控球以后,我把它扩展成:

export type DisplayMode =
  | 'main'
  | 'floating'
  | 'sidebar'
  | 'ball'
  | 'restoring'
  | 'hidden'

注意,这里仍然只是“展示状态”。

任务本身的:

status: 'running' | 'paused' | 'done' | 'failed'

依然独立存在。

所以一条正在同步的任务完全可能是:

status = running
displayMode = ball
progress = 68%

这表示:

  • 任务仍在运行;
  • 当前只用闪控球展示;
  • 进度已经到 68%。

这个模型是第四篇整个状态同步逻辑的基础。

四、StateSyncService 要做的,不是“复制数据”,而是统一分发

我给 StateSyncService 的定位很明确:

所有任务状态变化,先进入 TaskStore,再由 StateSyncService 通知不同 UI 入口刷新。

代码结构大概这样:

export class StateSyncService {
  private static instance: StateSyncService =
    new StateSyncService()

  static getInstance(): StateSyncService {
    return StateSyncService.instance
  }

  updateTaskState(
    taskId: string,
    progress: number,
    status: TaskStatus,
    displayMode: DisplayMode
  ) {
    const store = TaskStore.getInstance()
    const task = store.getTaskById(taskId)

    if (!task) {
      Logger.error('StateSync', `task not found: ${taskId}`)
      return
    }

    task.progress = progress
    task.status = status
    task.displayMode = displayMode
    task.updatedAt = Date.now()

    store.updateTask(task)

    this.notifyMainApp(task)
    this.notifyFlashWindow(task)
    this.notifyFlashBall(task)

    Logger.info(
      'StateSync',
      `task=${taskId}, progress=${progress}, mode=${displayMode}`
    )
  }
}

这一层最重要的意义是:

  • 主应用不再自己猜进度;
  • 闪控窗不再自己维护一份进度;
  • 闪控球也不再自己维护状态。

它们只是订阅同一个 TaskStore。

在这里插入图片描述

五、闪控球里到底显示多少信息,必须克制

如果把完整任务卡片缩成一个圆形,那其实不叫“重新设计”,只是“硬塞”。

我给闪控球定的展示规则非常简单:

  • 一个任务图标;
  • 一个环形进度;
  • 必要时一个状态色;
  • 不展示长标题;
  • 不展示完整文件大小;
  • 不塞暂停、关闭等太多按钮。

例如资料同步任务,可以只显示:

[文档图标]
68%

用户看到它时,第一眼知道:

有任务
任务还在跑
已经到 68%

这就够了。

闪控球真正的价值不是承载所有操作,而是成为一个高频、低干扰的状态入口。

六、从闪控窗切到闪控球时,我不会重新创建任务

第四篇最重要的一个动作,就是:

Flash Window → Flash Ball

这里和第三篇“侧边栏暂存”一样,绝对不能重新创建 TaskInfo。

我会让 FlashBallManager 只保存当前 taskId,然后切换展示形态。

export class FlashBallManager {
  private static instance: FlashBallManager =
    new FlashBallManager()

  private currentTaskId: string = ''

  static getInstance(): FlashBallManager {
    return FlashBallManager.instance
  }

  async toFlashBall(taskId: string) {
    const store = TaskStore.getInstance()
    const task = store.getTaskById(taskId)

    if (!task) {
      return
    }

    this.currentTaskId = taskId

    await FloatWindowController
      .getInstance()
      .minimizeToBall(task)

    task.displayMode = 'ball'
    store.updateTask(task)

    Logger.info(
      'FlashBall',
      `switch window -> ball, task=${taskId}`
    )
  }
}

这个动作的关键不是“球出现了”,而是:

taskId 没变
progress 没变
status 没变

只有 displayMode 从 floating 变成了 ball。

七、从闪控球恢复闪控窗,顺序一定不能乱

反过来的路径:

Flash Ball → Flash Window

我会特别强调顺序。

正确顺序应该是:

  1. 根据 taskId 从 TaskStore 读取最新状态;
  2. 用最新状态创建 / 恢复完整闪控窗;
  3. 确认窗口已经显示;
  4. 再隐藏闪控球;
  5. 更新 displayMode。

代码大概这样:

async restoreFromBall(taskId: string) {
  const store = TaskStore.getInstance()
  const task = store.getTaskById(taskId)

  if (!task) {
    return
  }

  task.displayMode = 'restoring'
  store.updateTask(task)

  const success = await FloatWindowController
    .getInstance()
    .restoreFromBall(task)

  if (!success) {
    task.displayMode = 'ball'
    store.updateTask(task)
    return
  }

  await this.hideBall(taskId)

  task.displayMode = 'floating'
  store.updateTask(task)

  Logger.info(
    'FlashBall',
    `restore window success, task=${taskId}`
  )
}

这里我依然坚持第三篇的原则:

恢复失败时,原入口不能先消失。

也就是说,完整窗口没恢复成功之前,闪控球应该继续存在。

在这里插入图片描述

八、多任务状态同步,第四篇开始真正变得有意思

前面三篇大部分时间,我们都在围绕单任务讲解。

第四篇加入闪控球以后,多任务问题会越来越明显。

例如现在有三条任务:

资料同步 68%
视频转码 35%
音乐播放 03:12 / 04:36

我不会让三个任务全部变成闪控球。

因为那样屏幕很快就会变得特别乱。

我给 Demo 定的规则是:

当前高关注任务

可以进入闪控窗或闪控球。

其他后台任务

留在任务中心或侧边栏。

这样用户看到的仍然是一个“当前任务入口”,而不是满屏漂浮球。

这也是为什么我一直强调:

闪控球不是任务列表,它只是当前最重要任务的轻量入口。

九、任务中心、闪控窗、闪控球必须能互相校验

为了防止三端状态不同步,我在第四篇里专门做了一组一致性检查。

例如任务:

taskId = sync_01
progress = 68
status = running

我会检查:

主应用任务中心

资料同步 68%

闪控窗

资料同步 68%

闪控球

68%

三处必须一致。

如果其中一个入口出现旧值,就说明状态链已经断了。

这类问题在视觉上特别明显,所以第四篇我宁愿多写一点状态同步,也不愿意把篇幅都用在“球怎么画”上。

十、无设备边框运行图,这篇最适合展示“三端一致”

第四篇的纯运行截图,我最想表达的是:

  • 左侧任务中心里有完整任务列表;
  • 中间闪控窗显示当前任务;
  • 右侧闪控球显示同一个任务;
  • 三处都是 68%;
  • 页面上还有轻提示“状态已同步”。

这张图本身就能说明第四篇最重要的成果:

UI 形态不同,但任务状态是一份。

在这里插入图片描述

十一、类型二综合讲解图,这一篇我想让它更像“架构总览”

第四篇继续保留类型二综合图,但这一次它不只是流程图,而更像一张轻量架构总览。

我希望里面能同时看到:

  1. DevEco 中的 StateSyncService;
  2. 主应用;
  3. 闪控窗;
  4. 闪控球;
  5. TaskStore;
  6. 三者之间的状态流向;
  7. 真机上的实际效果。

这张图要让读者一眼看懂:

TaskStore
   ↓
StateSyncService
   ↓
Main App / Flash Window / Flash Ball

而不是:

Main App 自己一份
Flash Window 自己一份
Flash Ball 自己一份

这就是第四篇的核心架构变化。

在这里插入图片描述

十二、切换过程中,我还专门处理了“快速连点”问题

做真实交互时,用户不会等你的动画播完再点下一次。

例如:

点击闪控球
闪控窗正在恢复
用户又点了一次

如果没有保护,很容易同时发起两次 restore。

所以我加了一个非常简单的切换锁:

private switching: boolean = false

async restoreFromBall(taskId: string) {
  if (this.switching) {
    return
  }

  this.switching = true

  try {
    await this.doRestore(taskId)
  } finally {
    this.switching = false
  }
}

这个东西非常小,但很重要。

因为多入口切换类功能,最容易出现的问题往往不是逻辑写错,而是同一个逻辑被短时间触发两次。

技术文章里我很愿意保留这种“小保护”,因为它特别像真实工程。

十三、应用切后台时,闪控球和闪控窗的行为也要统一

官方窗口生命周期文档里明确提到,全局悬浮窗、闪控球这类辅助窗口并不跟随主窗口显示 / 隐藏,但会跟随主窗口销毁。这意味着,应用页面不可见时,辅助窗口仍然可能继续承担任务入口角色。

这正好符合我们的“轻任务悬浮台”场景。

所以我会把原则定成:

主页面隐藏
≠
任务入口消失

任务继续运行时:

  • 闪控窗可以继续展示;
  • 或者切成闪控球;
  • 主应用再次回前台时,从 TaskStore 恢复完整状态。

这样多任务体验才真正连续。

十四、日志依然是判断状态是否一致的最好工具

第四篇的日志,我会专门打印三端状态:

[StateSync] task=sync_01 progress=68 status=running
[TaskStore] update task=sync_01 progress=68
[FlashWindow] progress=68
[FlashBall] progress=68
[MainApp] progress=68

当从窗口切到球:

[FlashBall] switch window -> ball task=sync_01
[StateSync] displayMode=ball

恢复:

[FlashBall] restore window task=sync_01
[TaskStore] load progress=68
[FlashWindow] show progress=68
[StateSync] displayMode=floating

这组日志几乎能把整篇文章的主链路重新演一遍。

十五、TaskStore 这一次也要升级:除了任务状态,还要能判断“当前高关注任务”

第四篇加入多任务以后,我发现单纯保存任务列表已经不够了。

因为闪控球不能同时承载所有后台任务,它需要知道:现在到底哪一条任务最值得被放到球里。

所以我给 TaskStore 又加了一个概念:

activeTaskId

它不是“唯一运行任务”,而是“当前高关注任务”。

比如同时存在:

资料同步 running 68%
视频转码 running 35%
音乐播放 running 03:12

用户刚刚正在关注资料同步,那:

activeTaskId = sync_01

闪控窗和闪控球就围绕 sync_01 工作。

TaskStore 大致这样组织:

export class TaskStore {
  private static instance: TaskStore = new TaskStore()
  private tasks: Map<string, TaskInfo> = new Map()
  private activeTaskId: string = ''

  static getInstance(): TaskStore {
    return TaskStore.instance
  }

  setActiveTask(taskId: string) {
    if (!this.tasks.has(taskId)) {
      return
    }
    this.activeTaskId = taskId
  }

  getActiveTask(): TaskInfo | undefined {
    return this.tasks.get(this.activeTaskId)
  }

  getRunningTasks(): TaskInfo[] {
    return Array.from(this.tasks.values())
      .filter(task => task.status === 'running')
  }
}

这层设计让“当前球里显示谁”变成了明确规则,而不是 UI 自己临时决定。

我觉得这点特别重要。

因为多任务系统一旦没有“当前焦点”概念,所有入口都会开始抢资源:

  • 这个任务想上闪控球;
  • 那个任务也想上闪控球;
  • 第三个任务又想弹完整窗口。

最后用户看到的就不是“高效多任务”,而是“满屏都是任务”。

所以第四篇开始,我会强调:

系统允许多任务并行,UI 入口却必须有优先级。

十六、切换形态之前,我会先保存一份轻量状态快照

从闪控窗切到闪控球,看起来只是 UI 缩小,但我还是给它加了一层“快照意识”。

原因很简单。

如果切换瞬间任务状态刚好发生变化,例如:

68% → 69%

而窗口销毁和闪控球创建又不是完全同时发生,就有可能出现一帧状态不一致。

所以我会在切换前先读取 TaskStore 最新值,生成一份轻量快照:

export interface TaskSnapshot {
  taskId: string
  progress: number
  status: TaskStatus
  displayMode: DisplayMode
  updatedAt: number
}

然后切换流程使用同一份 snapshot:

const snapshot = TaskStore
  .getInstance()
  .createSnapshot(taskId)

await this.hideFlashWindow(taskId)
await this.showFlashBall(snapshot)

这不是为了做复杂事务,而是让一次 UI 形态切换有一个明确的“切换时刻状态”。

如果切换结束后任务又继续跑到 70%,StateSyncService 再继续正常分发即可。

这样做的好处是:

  • 切换过程更可追踪;
  • 日志更容易对齐;
  • 出现异常时能知道切换前最后确认的状态是什么。

十七、失败兜底必须双向成立:球打不开,窗不能先消失;窗恢复失败,球也不能先消失

第三篇我已经强调过“恢复失败时入口要保留”,第四篇里这个原则要变成双向规则。

从闪控窗切到闪控球

正确顺序:

准备闪控球
  ↓
闪控球创建成功
  ↓
隐藏完整闪控窗
  ↓
更新 displayMode=ball

而不是:

先关闪控窗
  ↓
再试着创建球

后者一旦创建失败,用户的任务入口会瞬间消失。

从闪控球恢复闪控窗

正确顺序:

读取任务状态
  ↓
创建完整闪控窗
  ↓
确认显示成功
  ↓
再隐藏闪控球

这两条规则其实是一件事:

新入口确认可用以后,旧入口才退出。

我觉得这是所有“窗口形态切换”里都很值得坚持的工程原则。

十八、第四篇的多任务验收,我会故意让三个任务同时跑

单任务下状态同步很容易看起来“没问题”。

真正能暴露设计缺陷的,是多任务同时存在。

所以我会同时创建:

任务 A:资料同步 68%
任务 B:视频转码 35%
任务 C:音乐播放 03:12

然后反复做:

  1. A 从闪控窗切到闪控球;
  2. B 留在任务中心继续运行;
  3. C 保持播放状态;
  4. 从任务中心切换高关注任务到 B;
  5. 再观察闪控球是否正确切换到 B 的状态;
  6. 回到 A 时,A 的真实进度不能丢。

理想日志应该能看出这种关系:

[Focus] activeTask=sync_01
[FlashBall] bind task=sync_01 progress=68
[Focus] activeTask=transcode_02
[FlashBall] rebind task=transcode_02 progress=35
[TaskStore] sync_01 progress=72 still running

这种测试一旦通过,就说明“闪控球”真的只是任务入口,而不是任务本身。

十九、实机效果里,我最在意的是“切换够快,但不打断当前工作”

第四篇跑到平板或大屏设备上以后,体验会特别直观。

完整闪控窗适合需要关注进度时使用;

闪控球则更适合:

  • 我知道任务还在;
  • 但我不想让完整卡片一直占着屏幕;
  • 需要时可以快速再打开。

这就是“轻任务悬浮台”到第四篇以后真正体现出的价值。

它不再只是一个窗口组件,而是开始变成一种任务入口形态管理。

二十、系统窗口生命周期也要考虑进去,不然多入口同步只在前台成立

这一篇还有一个不能回避的问题,就是生命周期。

HarmonyOS 当前窗口生命周期文档里,对全局悬浮窗和闪控球这类辅助窗口有比较明确的跟随策略:它们不跟随主窗口的显示和隐藏,但会跟随主窗口销毁。对“轻任务悬浮台”来说,这意味着一个很重要的设计判断:

主页面不可见时,任务入口仍然可能继续存在;但应用真正销毁时,相关窗口资源必须统一收尾。

所以我不会把页面的 aboutToDisappear() 直接理解成“关闭闪控窗 / 闪控球”。页面只是暂时离开,任务可能仍然在后台继续。

我会把生命周期处理分成两层:

页面层:
只保存当前阅读 / 操作状态,不主动终止任务入口

应用 / 窗口管理层:
在真正销毁时统一释放闪控窗、闪控球和监听器

应用侧可以增加一个简单的收尾方法:

async releaseAllTaskWindows() {
  const active = TaskStore.getInstance().getActiveTask()

  if (active) {
    Logger.info('Lifecycle', `release task ui=${active.id}`)
  }

  await FloatWindowController
    .getInstance()
    .closeAll()

  await FlashBallManager
    .getInstance()
    .release()

  StateSyncService
    .getInstance()
    .unsubscribeAll()
}

这段代码不负责结束业务任务,它只负责“把展示层资源清干净”。任务是否需要终止,要由 TaskManager 根据业务规则决定。

这个边界很重要。

否则很容易写成:页面没了 → 任务也被误杀;或者应用退出了 → 球还残留着监听和状态对象。

第四篇把这层关系先说清楚,第五篇做最终生命周期收口时就会顺很多。

二十一、系统能力接入点我会单独封装,不让业务代码依赖具体接口形态

因为闪控球和标准悬浮窗属于系统能力,具体系统接口、参数和支持范围会随着 SDK 演进。我在 Demo 里不会把系统 API 调用散落到业务页面,而是统一封装在 FlashBallManager 和 FloatWindowController 里。

这样做有两个好处:

  1. 文章里的业务代码可以稳定解释“任务状态如何同步”;
  2. 后续 SDK 接口变化时,只需要调整系统接入层,不需要把 TaskStore、StateSyncService 和页面一起重写。

所以这篇代码重点放在状态模型与切换链路,真正接系统能力时仍以当前 HarmonyOS 7 / API 26 官方文档为准。

这也是我认为技术文章里比较重要的一种克制:能确定的工程逻辑写实,系统接口则按官方版本边界来接,不为了“代码看起来完整”而把不确定的调用硬写死。

二十二、第四篇我会怎么验收

这一篇的验收标准,我会重点看下面六条。

1)闪控窗可以切成闪控球

切换过程中 taskId 不变。

2)闪控球可以恢复闪控窗

恢复以后进度、暂停状态不能丢。

3)主应用、闪控窗、闪控球状态一致

同一时刻看到的 progress 和 status 必须一致。

4)快速连点不会重复触发切换

切换锁要生效。

5)应用切后台时任务状态继续存在

辅助入口与任务状态不能因为主页面隐藏而丢失。

6)恢复失败时保留原入口

这是整个恢复链里最重要的兜底。

只要这六条全部成立,我才会认为第四篇真正完成了。

二十三、第四篇完成以后,五连载已经只差最后一口气

回头看目前四篇:

第一篇

解决“能不能显示任务”。

第二篇

解决“悬浮窗能不能自由移动”。

第三篇

解决“暂时不需要时能不能收纳到侧边栏”。

第四篇

解决“能不能在闪控窗和闪控球之间快速切换,并保持多入口状态一致”。

到这里,“轻任务悬浮台”的窗口形态已经非常完整了。

剩下第五篇最适合做的,就是收尾:

  • 生命周期;
  • 资源释放;
  • 已完成任务清理;
  • 多任务切换后的状态回收;
  • 页面销毁后的窗口处理;
  • 最终工程结构和验收。

二十四、本文小记

这一篇写完以后,我对“闪控球”最大的理解,不是它比闪控窗更小,而是:

它让任务入口可以继续存在,同时把屏幕占用压到更低。

这正是闪控窗体系真正有意思的地方。

任务本身是稳定的;

UI 形态却可以随着场景改变:

主应用
闪控窗
侧边栏
闪控球

它们不是四条任务,而是同一条任务的四种入口。

如果用一句话总结第四篇,我会写:

闪控窗真正进入多任务体验的标志,不是多了一个球,而是任务状态终于可以在多个入口之间无缝流动。

下一篇就该收尾了。

我会把前四篇留下来的状态、窗口、任务和资源统一收回来,让“轻任务悬浮台”真正从一个功能 Demo 走到一个更完整的工程样例。

到这里,第四篇的多入口切换链路就真正完整了。

Logo

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

更多推荐