前两篇把“轻任务悬浮台”的基础打得差不多了。

第一篇,我先让任务窗真正跑起来:主页面创建任务,闪控窗展示进度,主页面和悬浮层共享同一份任务状态;第二篇继续把窗口变得更像一个真正能用的悬浮入口——支持拖动、边缘约束、吸附停靠,还能记住上一次停留的位置。

到了第三篇,我觉得终于可以往 HarmonyOS 7 闪控窗更有辨识度的体验上走一步了:侧边栏暂存。

官方当前对 HarmonyOS 7 闪控窗的描述很明确:标准悬浮窗可以常驻展示实时状态,支持自由拖动、侧边栏暂存,并能和闪控球进行切换。也就是说,闪控窗不只是“浮在屏幕上”,它还应该具备一种更克制的收纳方式:用户暂时不需要看完整卡片时,可以把任务收进侧边;等需要时,再快速把它恢复出来。

这件事看起来像“把窗口藏起来”,但真正开始做以后我发现,它其实比单纯隐藏窗口复杂得多。

因为侧边栏暂存以后,至少有三个东西不能丢:

  1. 任务本身不能停。
  2. 任务进度不能丢。
  3. 恢复时要回到正确状态。

所以第三篇的重点并不是“做一个侧边栏图标”,而是建立一条完整的状态链:

闪控窗运行 → 拖到边缘 → 暂存到侧边栏 → 后台任务继续 → 点击侧边栏入口 → 恢复闪控窗 → 继续显示原任务状态。

只要这条链跑通,“轻任务悬浮台”才真正有了“收纳与恢复”的感觉。

在这里插入图片描述

一、第三篇真正要解决的问题,不是隐藏窗口,而是隐藏 UI、不隐藏任务

如果只从视觉上看,侧边栏暂存很容易被理解成一句话:

把悬浮窗收起来。

但我觉得这句话非常容易把工程方向带偏。

因为“收起来”如果被实现成“销毁窗口 + 清空当前页面状态”,那用户再次点击侧边栏入口时,实际上看到的是一个新窗口,而不是“刚才那个任务恢复了”。

对一个下载、转码、资料同步类任务来说,这种体验会很怪。

用户明明只是想让卡片先别挡着,结果回来以后:

  • 进度从 64% 变成 0;
  • 暂停状态丢了;
  • 当前任务 ID 换了;
  • 甚至任务直接不见了。

所以第三篇一开始,我就先给自己定了一条最重要的原则:

侧边栏暂存的是窗口表现,不是任务本身。

也就是说,我会把“任务状态”和“窗口状态”彻底分开。

任务状态可以是:

running
paused
done
failed

窗口状态则是:

floating
stored
hidden
restoring

两者不能混。

二、我先把任务模型补了一层“展示状态”

第二篇里的 TaskInfo 已经能表达任务本身,但到了第三篇,我需要让它知道“当前任务以什么形式展示”。

所以我给模型补了一个展示状态:

export type TaskStatus =
  'running' | 'paused' | 'done' | 'failed'

export type DisplayState =
  'floating' | 'stored' | 'restoring' | 'hidden'

export interface TaskInfo {
  id: string
  title: string
  progress: number
  totalText: string
  status: TaskStatus
  displayState: DisplayState
}

这个变化看起来不大,但第三篇几乎所有逻辑都靠它串起来。

比如当用户把任务拖到边缘时:

status = running
displayState = stored

这表示任务仍然在跑,只是完整闪控窗不再显示。

用户点击侧边栏入口以后:

status = running
displayState = restoring

窗口重新出现后:

status = running
displayState = floating

你会发现,一旦任务状态和展示状态拆开,很多逻辑突然就好解释了。

三、第三篇的工程目录开始增加 SideBarManager

这一篇我没有大改前面的目录,只是把“侧边栏暂存”独立成一个角色。

LightTaskDemo
├─ entry/src/main/ets
│  ├─ pages
│  │  ├─ Index.ets
│  │  ├─ FloatWindowPage.ets
│  │  └─ TaskCenterPage.ets
│  ├─ components
│  │  ├─ FloatTaskCard.ets
│  │  └─ SideBarTaskItem.ets
│  ├─ model
│  │  └─ TaskInfo.ets
│  ├─ service
│  │  ├─ TaskManager.ets
│  │  ├─ FloatWindowManager.ets
│  │  └─ SideBarManager.ets
│  ├─ store
│  │  └─ TaskStore.ets
│  └─ common
│     └─ Logger.ets
└─ resources

这次最关键的新文件就是:

SideBarManager.ets

它的职责很明确:

  • 把任务暂存到侧边栏;
  • 记录当前被暂存的任务;
  • 从侧边栏恢复任务;
  • 恢复以后通知 FloatWindowManager 重新显示窗口。

而 TaskStore 则负责真正保存任务数据。

我特别不想让 SideBarManager 自己偷偷维护一份任务进度,因为那样迟早会出现两个来源。

第三篇依然坚持一个原则:

任务状态只有一份,其他模块只是消费它。

在这里插入图片描述

四、暂存到侧边栏时,到底要做哪些动作

我后来把“暂存”拆成了四个明确动作:

1. 保存当前任务引用
2. 标记 displayState = stored
3. 收起完整闪控窗
4. 在侧边栏保留任务入口

代码大概这样写:

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

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

  async saveToSidebar(taskId: string): Promise<void> {
    const task = TaskStore.getInstance()
      .getTaskById(taskId)

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

    task.displayState = 'stored'
    TaskStore.getInstance().updateTask(task)

    await FloatWindowManager.getInstance()
      .hideFloatingWindow()

    Logger.info(
      'SideBar',
      `save task=${task.id}, progress=${task.progress}`
    )
  }
}

这里最关键的一点是:

task.status

完全没变。

任务如果原来是 running,暂存以后仍然是 running。

这就保证了一个重要事实:

UI 收起来了,任务没有停。

五、任务暂存以后,TaskManager 继续正常推进

为了验证这件事不是“嘴上说说”,我专门让任务在侧边栏暂存期间继续更新进度。

例如用户在 64% 时把资料同步任务收进侧边栏,几秒后它可能已经变成 71%。

此时完整闪控窗虽然不在屏幕中央,但侧边栏入口如果展示简化状态,就应该跟着更新。

我在 TaskManager 的更新里不判断“当前有没有完整悬浮窗”,而只判断任务是不是还在运行:

private updateProgress(taskId: string) {
  const task = this.store.getTaskById(taskId)

  if (!task || task.status !== 'running') {
    return
  }

  task.progress = Math.min(
    task.progress + 0.02,
    1
  )

  this.store.updateTask(task)
  this.notify(task)
}

这段逻辑其实再次说明了第三篇的核心设计:

任务运行
≠
窗口正在显示

只有把这两件事拆开,侧边栏暂存才不会变成“假收纳”。

六、从侧边栏恢复时,不能重新创建任务

第三篇最容易犯的另一个错误,是用户点击侧边栏入口以后,直接重新调用:

createTask()

这样看起来窗口是恢复了,但本质上任务已经换了一条。

所以恢复逻辑一定要基于原来的 taskId。

async restoreFromSidebar(
  taskId: string
): Promise<void> {
  const store = TaskStore.getInstance()
  const task = store.getTaskById(taskId)

  if (!task) {
    Logger.error(
      'SideBar',
      `restore failed, missing task=${taskId}`
    )
    return
  }

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

  await FloatWindowManager.getInstance()
    .showFloatingWindow(task)

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

  Logger.info(
    'SideBar',
    `restore task=${task.id}, progress=${task.progress}`
  )
}

这段代码有三个步骤:

  1. 找回原来的任务;
  2. 用当前任务数据重新显示闪控窗;
  3. 把展示状态改回 floating。

这样用户恢复以后,看到的仍然是刚才那条任务。

如果它暂存前是 64%,恢复时已经跑到 71%,那用户就应该看到 71%。

这才叫“恢复”。

在这里插入图片描述

七、侧边栏入口应该展示多少信息

做第三篇时,我也纠结过一个 UI 问题:

侧边栏入口到底要不要显示进度?

如果做得太简单,只显示一个图标,用户可能不知道里面是什么任务。

如果把完整任务信息都塞进去,又违背了“暂存”的意义。

最后我选择了比较克制的方案。

侧边栏入口只显示:

  • 任务类型图标;
  • 非常短的任务标识;
  • 一个轻量进度提示;
  • 必要时显示暂停 / 异常状态。

比如:

[同步图标]
资料同步
64%

已经足够。

完整标题、总大小、剩余时间、暂停按钮这些信息,还是留到闪控窗恢复后再展示。

八、侧边栏里开始出现多个任务以后,顺序也要有规则

虽然第三篇的主线仍然以一条任务为主,但我还是提前考虑了“多任务暂存”的问题。

例如侧边栏里可能同时出现:

资料同步
视频转码
音乐下载

这个时候,排序规则不能完全随机。

我给第一版定的规则很简单:

  1. 正在运行的任务优先;
  2. 暂停任务其次;
  3. 最新暂存的任务靠前;
  4. 已完成任务不长期保留在侧边栏。

对应可以用一个简单的排序:

private sortStoredTasks(
  tasks: TaskInfo[]
): TaskInfo[] {
  return tasks.sort((a, b) => {
    if (a.status === 'running' &&
        b.status !== 'running') {
      return -1
    }

    if (b.status === 'running' &&
        a.status !== 'running') {
      return 1
    }

    return b.updatedAt - a.updatedAt
  })
}

虽然这不是第三篇的核心,但把规则先讲清楚,后面第四篇做闪控球联动时会轻松很多。

九、无边框运行图里,我最想展示的是“任务真的没有丢”

第三篇的纯界面图,我特意让它同时出现:

  • 正在运行的闪控窗;
  • 右侧任务栏;
  • 多条被管理的任务;
  • “已从侧边栏恢复任务”的提示。

因为这一篇最重要的结果不是“侧边栏好看”,而是:

任务被收进去以后还能继续运行,恢复出来以后状态还是同一条。

在这里插入图片描述

这个画面其实就是第三篇最核心的验收结果。

十、类型二综合图里,要把“暂存”和“恢复”讲成同一条链

这一篇我继续保留类型二综合图,而且我觉得第三篇特别适合这种形式。

因为单独看一张闪控窗截图,很难理解侧边栏暂存到底做了什么。

所以这张图里我希望同时出现:

  1. DevEco 中的 SideBarManager;
  2. 正在运行的任务;
  3. 拖到边缘;
  4. 侧边栏暂存;
  5. 点击侧边栏入口;
  6. 闪控窗恢复;
  7. 日志里还能看到原来的 taskId 和进度。

这样读者不用看完所有代码,就能先建立一条很清楚的认知:

任务没销毁
只是窗口形态变化

在这里插入图片描述

十一、第三篇我专门补了一套“状态一致性”检查

因为这一篇真正难的是状态,所以我给自己做了一组测试。

用例 1:运行中暂存

progress = 64%
status = running
displayState = floating

拖到侧边栏以后应该变成:

progress = 64%
status = running
displayState = stored

用例 2:暂存期间继续运行

几秒后:

progress = 71%
status = running
displayState = stored

用例 3:点击恢复

恢复以后:

progress = 71%
status = running
displayState = floating

用例 4:暂停任务再暂存

任务如果是:

status = paused

那暂存以后也应该保持:

status = paused

而不是自动变成运行中。

用例 5:任务已完成

任务完成以后,侧边栏入口应该自动弱化或移除,避免长期堆积无意义条目。

我觉得这些测试特别重要,因为第三篇如果只看视觉效果,非常容易误判“已经成功”。

真正要验的是:状态有没有串。

十二、日志是第三篇判断“是不是同一条任务”的最好证据

我这一篇保留了非常明确的日志。

比如暂存:

[SideBar] save task=sync_01
[State] progress=64%
[Window] hide floating card

恢复:

[SideBar] restore task=sync_01
[State] progress=71%
[Window] show floating card

读这几行日志时,最值得看的不是文字本身,而是:

taskId 一样
progress 连续

只要这两个条件成立,就说明恢复逻辑至少在主链上是对的。

十三、实机里最明显的感受,是“屏幕突然干净很多”

第三篇跑到真实大屏设备上以后,我最明显的感觉其实不是“又多了一个功能”,而是:

屏幕干净了。

完整闪控窗适合“正在关注任务”的时候。

但当用户要看文章、聊天、处理其他内容时,完整任务卡片再小,也会占空间。

侧边栏暂存以后,它会变成一个非常克制的入口。

我觉得这正是闪控窗和普通悬浮窗体验差异开始被拉开的地方。

在这里插入图片描述

十四、我把“暂存”和“恢复”又拆成了一个小状态机

写到这里以后,我发现只用 displayState 虽然已经能表达基本状态,但如果想让第三篇更稳,还需要把“动作顺序”也固定下来。

因为用户拖到侧边栏时,真正发生的不是一个动作,而是一串动作:

floating
  ↓
storing
  ↓
stored

点击恢复时又是:

stored
  ↓
restoring
  ↓
floating

如果中间某一步失败,比如任务已经写入侧边栏,但完整闪控窗没有隐藏成功,就会出现“双份 UI”同时存在的奇怪状态。

所以我会把展示状态再细分一点:

export type DisplayState =
  'floating'
  | 'storing'
  | 'stored'
  | 'restoring'
  | 'hidden'

然后在暂存过程中显式推进:

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

  if (!task) {
    return
  }

  task.displayState = 'storing'
  store.updateTask(task)

  const saved = await this.createSidebarEntry(task)

  if (!saved) {
    task.displayState = 'floating'
    store.updateTask(task)
    return
  }

  await FloatWindowManager
    .getInstance()
    .hideFloatingWindow()

  task.displayState = 'stored'
  store.updateTask(task)
}

这个写法比“直接把状态设成 stored”稍微啰嗦一点,但它更像真实工程。

因为失败是有位置的。

如果以后日志里看到:

displayState=storing

一直没有进入 stored,就能判断问题发生在“创建侧边栏入口”或者“隐藏窗口”之间。

第三篇开始,我越来越愿意让状态本身承担调试信息,而不是把所有判断都藏在 if 里。

十五、SideBarTaskItem 也要单独做,不然侧边栏 UI 很快会变乱

第一篇和第二篇里,完整闪控窗本身就是主要 UI,所以 FloatTaskCard 是最核心的组件。

第三篇以后,侧边栏也成了长期存在的入口,所以我不想直接在页面里临时拼图标和文字,而是单独做一个 SideBarTaskItem。

@Component
export struct SideBarTaskItem {
  @Prop task: TaskInfo
  onRestore: (taskId: string) => void = () => {}

  build() {
    Column({ space: 4 }) {
      Image(this.resolveIcon(this.task.type))
        .width(32)
        .height(32)

      Text(this.shortTitle(this.task.title))
        .fontSize(11)
        .maxLines(1)

      Progress({
        value: this.task.progress * 100,
        total: 100
      })
        .width(34)
        .height(3)
    }
    .padding(8)
    .borderRadius(14)
    .backgroundColor('#F4F8FF')
    .onClick(() => {
      this.onRestore(this.task.id)
    })
  }
}

我给这个组件定的原则很简单:

  • 信息少;
  • 点击区域明确;
  • 状态可辨认;
  • 不和完整闪控窗抢视觉。

这也是我觉得“暂存”最重要的产品语义。

暂存不是换一个地方继续完整展示,而是把信息压缩成一个更轻量的入口。

十六、恢复顺序也不能乱:先拿任务,再开窗,最后移除侧边栏入口

恢复这件事看起来很直白,但顺序错了会非常容易出现闪烁。

我最开始试过一种写法:

先移除侧边栏入口
再创建闪控窗

结果如果闪控窗创建失败,用户会发现任务入口直接消失了。

所以第三篇最终采用的顺序是:

1. 从 TaskStore 读取原任务
2. 标记 restoring
3. 打开完整闪控窗
4. 确认窗口已经显示
5. 从侧边栏移除入口
6. 标记 floating

代码里我会保留结果判断:

async restoreFromSidebar(taskId: string) {
  const task = this.store.getTaskById(taskId)

  if (!task) {
    return
  }

  task.displayState = 'restoring'
  this.store.updateTask(task)

  const shown = await this.floatManager
    .showFloatingWindow(task)

  if (!shown) {
    task.displayState = 'stored'
    this.store.updateTask(task)
    return
  }

  await this.removeFromSidebar(taskId)

  task.displayState = 'floating'
  this.store.updateTask(task)
}

这个顺序有一个很直接的好处:

恢复失败时,用户至少还保留侧边栏入口。

在多任务场景里,这种“失败以后入口还在”的体验,比简单弹一个错误提示更重要。

十七、我还专门测试了应用切后台以后,暂存任务是不是还在

第三篇如果只测“同一页面里暂存再恢复”,其实还不够。

因为官方对闪控窗的定位本来就是多任务体验:用户会离开当前应用,会切到其他界面,会过一段时间再回来。

所以我专门做了一组测试:

场景 1:闪控窗显示时切后台

预期:

  • 任务继续;
  • 进度继续;
  • 回来以后完整闪控窗仍能保持当前状态。

场景 2:侧边栏暂存后切后台

预期:

  • displayState=stored
  • status=running
  • 任务进度继续变化
  • 回到前台以后侧边栏入口仍然存在

场景 3:暂存状态下任务完成

这是最容易被忽略的一条。

如果任务在侧边栏里已经跑到 100%,系统不应该等用户点击恢复以后才知道“已经完成”。

我会在任务完成时主动处理:

private handleTaskDone(task: TaskInfo) {
  if (task.displayState === 'stored') {
    SideBarManager
      .getInstance()
      .updateStoredTask(task)
  }

  Logger.info(
    'TaskManager',
    `task done id=${task.id}`
  )
}

侧边栏入口可以变成“完成”状态,并在一段时间后自动清理。

这让暂存状态真正成为任务生命周期的一部分,而不是一个暂停的 UI 快照。

十八、侧边栏容量也应该有边界

做到第三篇,我已经不太愿意假设“用户永远只会有一条任务”。

一旦侧边栏里可以暂存多条任务,就必须考虑容量。

如果无限塞:

任务 1
任务 2
任务 3
任务 4
任务 5
任务 6
……

侧边栏很快就会从“轻量入口”变成另一个任务列表。

所以我给 Demo 定了一个很克制的规则:

侧边栏最多显示 4 个高优先级任务

超出的任务仍然保留在任务中心,但不继续挤进侧边栏。

private readonly MAX_STORED = 4

getVisibleStoredTasks(): TaskInfo[] {
  return this.getStoredTasks()
    .filter(item => item.status !== 'done')
    .slice(0, this.MAX_STORED)
}

这不是官方规则,而是我给“轻任务悬浮台”这个 Demo 定的产品边界。

我觉得这类规则非常值得在技术文章里讲清楚,因为它能帮助读者理解:

系统能力给了你入口,真正的产品体验仍然需要应用自己做取舍。

十九、侧边栏不是任务中心的替代品,这个边界我也提前定了下来

做到这里,我还专门想清楚了一件事:侧边栏和任务中心到底是什么关系。

我的结论很明确:

侧边栏是“高频入口”,任务中心才是“完整管理页”。

所以侧边栏里不会塞进:

  • 完整任务历史;
  • 复杂筛选;
  • 失败详情;
  • 大量操作按钮;
  • 所有已完成任务。

它只负责承载“现在最值得被快速找回的那几条任务”。

而任务中心则负责:

查看全部任务
查看已完成
查看失败记录
重新执行
删除历史
查看详细信息

这种分工能避免后面功能越做越重。

技术文章里我觉得特别值得把这种产品边界讲清楚。因为很多时候,开发者真正缺的不是“这个控件怎么写”,而是“这个入口应该承担到什么程度”。

第三篇如果把侧边栏写成一个缩小版任务中心,后面第四篇做闪控球时一定会变得特别乱。

现在先把边界卡住,后续每种入口都只做自己最擅长的事,整个 Demo 会更容易继续成长。

我最后还会做一个很简单的检查:把应用连续切换前后台几次,再分别从任务中心和侧边栏恢复同一条任务,确认两处入口最终指向的是同一个 taskId。如果任务 ID、进度和暂停状态都能保持一致,我才会认为第三篇这条“收纳—恢复”链路真正站住了。

二十、第三篇解决了什么

如果把前三篇放在一起看:

第一篇

解决:

闪控窗能不能显示任务

第二篇

解决:

窗口能不能自由移动并记住位置

第三篇

解决:

窗口暂时不需要时,能不能收起来,
同时任务继续存在,并且随时恢复

这三个阶段连起来以后,“轻任务悬浮台”已经开始具备真正的完整性。

它不只是一个悬浮卡片 Demo,而是开始有:

  • 任务状态;
  • 窗口状态;
  • 位置状态;
  • 暂存状态;
  • 恢复状态。

而且这些状态之间已经有了比较清楚的边界。

二十一、第三篇给第四篇留下的入口

做到这里,下一个问题就很自然了。

现在任务可以:

  • 正常显示;
  • 自由拖动;
  • 收到侧边栏;
  • 点击以后恢复。

但 HarmonyOS 7 当前的闪控窗体验还有另一个非常有辨识度的方向:和闪控球进行切换。

也就是说,第四篇不再只是“窗口怎么收纳”,而会开始处理:

  • 闪控窗;
  • 闪控球;
  • 主应用;

三者之间的状态一致性。

这个问题比第三篇更复杂,因为那时我们不只是在同一种窗口形态之间切换,而是在不同入口之间共享同一条任务。

这正好适合作为第四篇的主线。

二十二、本文小记

第三篇写完以后,我对“闪控窗”这个能力的理解比第一篇明显更具体了。

第一篇时,我觉得它最大的价值是:

任务状态可以一直被看到。

第二篇时,我觉得它还应该:

跟随用户习惯自由停靠。

到了第三篇,我更愿意把它理解成:

任务本身持续存在,而窗口形态可以根据当前场景自由变化。

这句话对我来说特别重要。

因为它说明闪控窗真正值得写的地方,并不是“窗”,而是任务与窗口之间被解耦之后,带来的多任务体验。

侧边栏暂存看起来只是一个小交互,但它把这个思路表达得特别清楚:

  • UI 可以收;
  • 状态不能丢;
  • 任务可以继续;
  • 用户想看时还能回来。

如果用一句话给第三篇收尾,我会写:

闪控窗真正灵活的地方,不是它可以一直浮着,而是它知道什么时候该出现、什么时候该收起来,而且任务始终还在。

这也会成为下一篇做多入口状态同步时最重要的前置验证。

Logo

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

更多推荐