写到第五篇,“轻任务悬浮台”这套 Demo 已经不缺功能了。第一篇把任务窗跑起来,第二篇解决拖动、边缘约束和位置恢复,第三篇加入侧边栏暂存,第四篇又把闪控窗、闪控球和主应用的状态同步串起来。到了最后一篇,如果我还继续加新能力,反而会把整个系列写散。

所以第五篇我只做一件事:把前四篇留下来的状态、窗口、资源和生命周期全部收住,让这个 Demo 从“能演示”走到“能长期跑、能稳定退出、能再次恢复”的工程状态。

在这里插入图片描述

一、第五篇为什么不再加功能,而是开始做收尾

写 Demo 最容易有一种错觉:只要功能跑通了,项目就差不多完成了。

但真正连续跑几轮以后,你会发现很多问题都出现在“功能之外”。

比如:

  • 闪控窗已经关了,监听器还没解绑;
  • 闪控球已经切走,旧窗口对象还留在内存里;
  • 应用切后台以后,任务还在继续,但页面状态和任务状态开始出现分叉;
  • 多次打开、关闭悬浮窗以后,缓存越来越多;
  • 任务完成了,侧边栏、闪控球、主页面却还保留旧入口;
  • 用户再次进入应用时,系统不知道应该恢复什么、又该清掉什么。

这些问题不会在第一轮演示时马上暴露,但它们决定一个 Demo 到底是“一次性效果”,还是“可以反复运行的工程原型”。

HarmonyOS 当前的窗口生命周期文档也明确区分了主窗口与辅助窗口的生命周期关系:全局悬浮窗和闪控球并不会简单跟随主窗口的显示、隐藏,但在应用窗口销毁时仍需要正确收口。这一点其实特别适合拿来解释为什么第五篇一定要讲生命周期。

所以这一篇,我把目标收成四个词:

恢复
清理
释放
验收

这四件事一旦做好,“轻任务悬浮台”才算真正结束。

二、先回头看项目结构:第五篇开始,状态和资源必须有统一出口

前四篇为了让主线清楚,我一直在不断拆角色。第五篇里我没有再增加太多文件,而是把现有职责重新梳理了一遍。

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

第五篇真正新增的核心角色只有两个:

  • LifecycleService.ets
  • ResourceCleaner.ets

前者负责“什么时候该保存、什么时候该恢复、什么时候该终止”;后者负责“哪些资源已经没有意义,什么时候应该释放”。

这样做以后,页面不需要自己处理一大堆退出逻辑,窗口管理器也不需要顺手去清任务缓存,所有收尾行为都有明确入口。

三、我先定义了一个“任务会话”的概念

前四篇里,我们一直围绕单个 TaskInfo 工作。但第五篇开始,我更愿意把一次完整任务运行看成一个“会话”。

一个会话至少包含:

export interface TaskSession {
  sessionId: string
  taskId: string
  displayMode: 'window' | 'sidebar' | 'ball' | 'hidden'
  startedAt: number
  lastActiveAt: number
  restored: boolean
}

为什么要有这一层?

因为第五篇开始,真正要判断的已经不是“任务是不是存在”,而是:

  • 这条任务当前通过什么入口存在;
  • 它是不是从上一次会话恢复来的;
  • 用户最后一次操作发生在什么时候;
  • 如果当前入口消失了,应该恢复到哪一种形态。

会话一旦有了,很多生命周期判断会变得特别清楚。

比如:

任务还在跑
但没有任何可见入口

这就是一个明显异常。

又比如:

任务已经完成
但 displayMode 仍然是 ball

那就说明展示入口没有及时清理。

第五篇其实就是靠这种“把隐含状态显式化”的方式,去把前面的工程真正收紧。

四、生命周期服务不是监听越多越好,而是关键时机要统一

我一开始也想过把所有页面生命周期、窗口事件、任务事件全部监听一遍,但后来发现那样会让收尾逻辑越来越难读。

最后我只保留几个真正有意义的节点:

应用进入前台
应用进入后台
闪控窗创建
闪控窗关闭
闪控球显示
任务完成
应用销毁

然后把它们全部收进 LifecycleService。

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

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

  onForeground() {
    StateSyncService.getInstance().restoreLatestState()
    Logger.info('Lifecycle', 'application foreground')
  }

  onBackground() {
    StateSyncService.getInstance().saveSnapshot()
    Logger.info('Lifecycle', 'application background')
  }

  onFloatWindowClosed(taskId: string) {
    StateSyncService.getInstance().markWindowClosed(taskId)
  }

  onTaskCompleted(taskId: string) {
    ResourceCleaner.getInstance().cleanupTask(taskId)
  }

  async onDestroy() {
    await StateSyncService.getInstance().saveSnapshot()
    await ResourceCleaner.getInstance().releaseAll()
  }
}

这段代码本身并不复杂,但它建立了一条非常重要的工程规则:

页面、窗口、闪控球不再各自决定什么时候保存和释放,而是把生命周期统一交给一个服务管理。

在这里插入图片描述

五、恢复逻辑一定要区分“恢复任务”和“恢复窗口形态”

第五篇里,我遇到的一个典型问题是:应用从后台回到前台时,到底应该恢复什么?

如果一股脑恢复所有东西,很容易出现:

  • 上一次闪控窗已经手动关闭,这次却又自己弹出来;
  • 用户上一次把任务收进侧边栏,回来以后却恢复成完整悬浮窗;
  • 用户已经切成闪控球,回到应用时却被重置成主窗口状态。

所以我把恢复拆成两层。

第一层:恢复任务状态

这包括:

任务 ID
进度
暂停 / 运行状态
任务类型
最后更新时间

第二层:恢复展示形态

这包括:

window
sidebar
ball
hidden

代码上我会先恢复 TaskStore,再决定入口:

async restoreLatestState() {
  const snapshot = await this.loadSnapshot()
  if (!snapshot) {
    return
  }

  TaskStore.getInstance().restore(snapshot.tasks)

  for (const session of snapshot.sessions) {
    await this.restoreDisplayMode(session)
  }
}

真正重要的是 restoreDisplayMode():

private async restoreDisplayMode(session: TaskSession) {
  switch (session.displayMode) {
    case 'window':
      await FloatWindowManager.getInstance()
        .restore(session.taskId)
      break

    case 'sidebar':
      await SideBarManager.getInstance()
        .restoreEntry(session.taskId)
      break

    case 'ball':
      await FlashBallManager.getInstance()
        .restoreBall(session.taskId)
      break

    default:
      break
  }
}

这样,用户看到的才是“上一次真实状态的延续”,而不是应用自己猜出来的默认形态。

六、资源释放,真正要清的是四类东西

第五篇如果只写生命周期而不写资源清理,我觉得这个收尾还是不完整。

我把需要清理的东西分成四类。

1)窗口对象

完整闪控窗关闭以后,对应窗口实例不应该继续被管理器持有。

2)监听器

任务监听、窗口事件监听、进度监听,如果已经没有消费者,就应该及时解绑。

3)临时缓存

比如:

  • 任务图标缓存;
  • 预览图;
  • 临时文件;
  • 窗口恢复信息。

完成任务以后,不应该无限积累。

4)已完成入口

任务完成以后,闪控球和侧边栏入口不能永久存在。

于是我把清理动作收进 ResourceCleaner:

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

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

  async cleanupTask(taskId: string) {
    await FloatWindowManager.getInstance().remove(taskId)
    await SideBarManager.getInstance().remove(taskId)
    await FlashBallManager.getInstance().remove(taskId)

    TaskStore.getInstance().removeFinishedTask(taskId)
    Logger.info('ResourceCleaner', `cleanup task=${taskId}`)
  }

  async releaseAll() {
    FloatWindowManager.getInstance().release()
    FlashBallManager.getInstance().release()
    SideBarManager.getInstance().release()
    TaskStore.getInstance().clearTransientState()
  }
}

我很喜欢这种做法,因为它把“结束”变成一个明确动作,而不是等垃圾回收或页面销毁“顺便处理”。

七、第五篇我开始认真看性能,而不是只看功能

前几篇里,性能不是主角。但到了收尾篇,如果一个悬浮入口长期存在,性能就不能继续忽略。

我重点看三个指标:

1)内存是否随着反复打开窗口持续增长

如果连续打开 / 关闭十次闪控窗以后,内存只升不降,就说明还有资源没释放。

2)进度更新是否太频繁

任务进度没必要每 10ms 更新一次 UI。对于轻任务悬浮窗来说,合理节流会明显降低无意义刷新。

例如:

private shouldRender(next: number): boolean {
  const delta = Math.abs(next - this.lastRenderedProgress)
  return delta >= 1
}

只有进度变化超过 1% 才刷新。

3)不可见入口是否还在持续渲染

任务如果已经收进侧边栏,就不应该继续以完整闪控窗的渲染频率工作。

这些优化都很小,但它们正是“从 Demo 到成品原型”最容易被忽略的一步。

在这里插入图片描述

八、任务完成以后,入口清理必须有统一规则

在前四篇里,任务完成只是一个状态变化:

running → done

第五篇里,我会继续往后走一步:

done → notify → cleanup → archive

具体规则我定成:

  1. 闪控窗先显示一次“已完成”;
  2. 闪控球进入完成态;
  3. 侧边栏入口短暂保留;
  4. 2~5 秒后移除轻量入口;
  5. 任务中心保留完成记录。

也就是说:

展示入口可以消失,但任务历史仍然存在。

这和第三篇的产品边界保持一致:侧边栏、闪控窗、闪控球都是“高频入口”,真正的完整历史仍然归任务中心管理。

九、多任务场景下,收尾不能只清当前任务

第四篇已经开始做多任务状态同步,第五篇就必须面对一个新问题:

如果同时有三条任务,应用销毁时到底保存什么?

我不再只保存 currentTask,而是保存一份任务快照:

export interface AppSnapshot {
  activeTaskIds: string[]
  focusTaskId?: string
  sessions: TaskSession[]
  savedAt: number
}

恢复时:

  • 先恢复任务数据;
  • 再恢复会话;
  • 最后恢复焦点任务。

这三步顺序很重要。

如果先恢复闪控球,再恢复任务数据,闪控球就可能拿不到对应进度。

所以第五篇我反复强调一句话:

恢复不是“把值读出来”,而是按依赖关系重新组装现场。

十、无边框运行图里,我希望看到的是“整个系统已经稳定下来”

这篇的纯运行图不再突出某一个新功能,而是突出完整形态:

  • 主应用正常存在;
  • 闪控窗可以同时运行;
  • 闪控球作为快速入口存在;
  • 不同任务状态没有互相打架;
  • 界面看起来已经不像调试 Demo。

这也是第五篇最重要的视觉目标:

看起来像一个完整产品,而不是五篇文章拼出来的效果。

在这里插入图片描述

十一、类型二综合图,在最后一篇里更像一张“项目验收图”

前面四篇的类型二图更多是在解释某一段能力。

到了第五篇,我更愿意把它做成一张“总验收图”。

它应该能同时看出:

  • 项目是怎么写的;
  • 生命周期怎么走;
  • 性能优化做在哪里;
  • 真机效果是什么样;
  • 闪控窗和闪控球有没有形成完整闭环。

读者只看这一张图,也能大致理解第五篇到底在收什么。

在这里插入图片描述

十二、第五篇我最终怎么验收

这一次,我不再只看“能不能切换”。

我给整个项目定了七条验收线。

1)连续运行稳定

任务连续运行、暂停、恢复、切换入口,状态不能乱。

2)窗口反复打开关闭不泄漏

连续操作多轮以后,窗口对象和监听器数量应该回到稳定范围。

3)后台恢复正确

应用从后台回来以后,任务进度、展示入口、焦点任务都不能错误重置。

4)任务完成后入口能自动清理

不能出现“任务已经完成,闪控球还永远挂着”的情况。

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

同一个 taskId 的进度和状态必须一致。

6)侧边栏暂存与恢复仍然成立

第五篇的重构不能破坏第三篇已经完成的主链路。

7)退出应用时资源释放有日志证据

最终日志最好能看到:

[Lifecycle] application destroy
[StateSync] snapshot saved
[FloatWindow] release all
[SideBar] release all
[FlashBall] release all
[ResourceCleaner] transient state cleared

只有这些都成立,我才会认为整个五连载真正收住了。

十三、实机阶段,最能暴露“工程是否真的干净”

第五篇最后我还是会回到真机。

模拟器特别适合看代码和日志,但真机最容易暴露体验问题:

  • 闪控窗是不是太重;
  • 闪控球是否抢视觉;
  • 不同入口切换时有没有闪烁;
  • 任务完成以后入口清理够不够自然;
  • 长时间运行以后有没有明显卡顿。

如果真机上反复操作十几次依然自然,说明前面的生命周期和资源释放并不是只写在代码里,而是真的起作用了。

在这里插入图片描述

十四、我还专门补了一层“释放幂等”,避免重复关闭把状态搞乱

写收尾篇时,我特别容易警惕一种问题:同一个资源被重复释放。

因为闪控窗这种多入口结构里,关闭动作可能来自很多地方:

  • 用户点闪控窗关闭按钮;
  • 用户从闪控球切回主应用;
  • 任务完成以后自动清理;
  • 应用销毁时统一释放;
  • 异常恢复过程中主动重置。

如果这些入口最终都直接调用一次 release(),很容易出现第二次释放时对象已经不存在、监听器已经解绑,却又重复操作的情况。

所以第五篇我给所有清理动作都加了一层“幂等保护”。

export class ResourceCleaner {
  private released: boolean = false

  async releaseAll() {
    if (this.released) {
      Logger.info('ResourceCleaner', 'skip duplicated release')
      return
    }

    this.released = true

    await FloatWindowManager.getInstance().safeRelease()
    await SideBarManager.getInstance().safeRelease()
    await FlashBallManager.getInstance().safeRelease()

    TaskStore.getInstance().clearTransientState()
    Logger.info('ResourceCleaner', 'release all success')
  }
}

各个 Manager 内部也同样遵守“有就释放、没有就返回”的原则。

这类代码很不起眼,但它会明显减少收尾阶段的偶发异常。尤其是用户连续点关闭、系统同时触发生命周期回调时,幂等处理会让整个过程稳很多。

我越来越觉得,真正像工程的代码,往往不是主流程多复杂,而是对“同一件事重复发生”也有准备。

十五、状态恢复失败时,必须有一个明确的降级策略

恢复不是每次都一定成功。

本地快照可能损坏,任务已经过期,旧的窗口位置不适用于新设备,或者某个任务已经被用户手动删除。这个时候如果应用死磕“恢复原现场”,反而容易把系统带到更奇怪的状态。

所以第五篇里,我给恢复逻辑增加了一条很明确的降级路径:

完整恢复成功
    ↓
正常进入旧现场

恢复部分失败
    ↓
保留有效任务
    ↓
丢弃无效入口
    ↓
回到任务中心

快照整体不可用
    ↓
清理旧快照
    ↓
进入默认首页

代码里会像这样:

async restoreLatestState() {
  try {
    const snapshot = await this.loadSnapshot()

    if (!snapshot) {
      this.openDefaultHome()
      return
    }

    const validTasks = this.validateTasks(snapshot.tasks)
    TaskStore.getInstance().restore(validTasks)

    await this.restoreSessions(snapshot.sessions, validTasks)
  } catch (error) {
    Logger.error('StateSync', `restore failed: ${JSON.stringify(error)}`)
    await this.clearBrokenSnapshot()
    this.openDefaultHome()
  }
}

这种降级设计很重要,因为“恢复失败以后还能正常使用”,本身就是稳定性的一部分。

真正成熟的 Demo 不是每次都假设环境完美,而是即使旧状态坏了,应用也能退回到一个用户看得懂、还能继续操作的位置。

十六、最终性能验收,我不只看内存,还会看三类操作的耗时

第五篇既然把性能写进来了,我就不想只放一张内存曲线图结束。

我最终会固定测试三类操作:

1)打开闪控窗

从用户点击任务入口,到悬浮窗完成首帧展示,记录一次耗时。

2)闪控窗 ↔ 闪控球切换

切换期间要观察:

  • 状态是否丢失;
  • 是否出现双入口同时存在;
  • UI 有没有明显空白帧。

3)后台恢复

应用切到后台 10~30 秒再回来,看任务状态恢复到可操作界面需要多久。

我不会在文章里硬给一个“所有设备都必须低于多少毫秒”的绝对数字,因为设备和场景不同,意义不大。更重要的是做同设备、同场景下的前后对比。

比如优化前:

打开窗口:明显有延迟
切换闪控球:偶尔闪一下
后台恢复:先显示默认状态,再跳回旧任务

优化后:

打开窗口:首帧更快
切换闪控球:状态连续
后台恢复:直接进入正确任务形态

这种对比比单独报一个性能数字更适合社区文章,也更容易让读者复现。

十七、如果把这套 Demo 当成一个可交付样例,我会做一次最终检查

在正式收尾之前,我给“轻任务悬浮台”列了一份最终检查清单。

工程层

  • Manager 是否存在循环依赖;
  • TaskStore 是否仍然是唯一任务状态源;
  • 所有事件监听是否都有对应解绑;
  • 窗口与闪控球资源是否支持重复释放;
  • 临时缓存是否有统一目录和清理策略。

交互层

  • 闪控窗是否可以正常拖动;
  • 边缘吸附是否自然;
  • 侧边栏暂存是否继续保留任务进度;
  • 闪控球切换是否使用同一 taskId;
  • 任务完成以后轻量入口是否自动退出。

文章与演示层

  • DevEco 图中的项目目录和正文一致;
  • 模拟器、纯界面图和实机图使用同一套任务名称;
  • 日志里的任务 ID、进度和文章代码能对应;
  • 图片数量和 Markdown 引用完全一致;
  • 五篇之间的技术演进没有互相推翻。

我觉得这种最终检查特别适合放在收尾篇。因为读者看到这里,不只是知道“功能做完了”,还会知道一个技术 Demo 在真正交付前应该怎么自查。

除此之外,我还会额外做一次“连续十轮”回归:每一轮都包含创建任务、打开闪控窗、拖动、侧边栏暂存、恢复、切闪控球、再切回窗口、最后完成并清理。这个测试并不复杂,却特别容易暴露那些单次演示看不出来的问题,例如监听器重复注册、焦点任务没有更新、旧窗口位置残留、完成任务没有被彻底移出轻量入口。

如果十轮以后,任务中心记录仍然正确、当前任务状态没有串、闪控窗与闪控球都能正常创建和销毁,内存也没有持续向上爬,我才会把这套 Demo 视为真正“收尾完成”。这也会成为第五篇最重要的工程结论:最后一篇不是为了再加亮点,而是为了证明前四篇做出来的能力可以被反复使用。

我还会把这十轮回归的关键日志保留下来,尤其关注 taskId 是否连续、入口切换前后进度是否一致、最终清理日志是否每轮都完整出现。只要其中一轮出现“任务已完成但入口未释放”或者“闪控球恢复后进度回退”,就继续回头查生命周期和状态同步,而不是把它当成偶发问题跳过。

十八、五篇连载到这里,整个 Demo 的成长路径已经完整

回头看这五篇,我觉得“轻任务悬浮台”的演进路线非常清楚。

第一篇:能显示

创建任务 → 打开闪控窗 → 展示实时状态

第二篇:能移动

自由拖动 → 边缘约束 → 位置恢复

第三篇:能收纳

侧边栏暂存 → 状态继续 → 点击恢复

第四篇:能切换

主应用 ↔ 闪控窗 ↔ 闪控球

第五篇:能长期稳定运行

生命周期 → 状态恢复 → 性能优化 → 资源释放 → 最终验收

这五步连起来以后,闪控窗就不再只是“浮在界面上的一个小卡片”,而变成了一套完整的轻任务入口体系。

十九、本文小记

如果让我给第五篇、也给整个系列留一句话,我会写:

闪控窗真正成熟的标志,不是它一直存在,而是它在该出现时出现、该收起时收起、该恢复时恢复、该释放时彻底释放。

写到这一篇,我对 HarmonyOS 7 闪控窗的理解也比刚开始更具体了。

它的价值并不是简单增加一个悬浮窗口,而是让任务、窗口和用户注意力之间有了更灵活的关系:

  • 重要时完整展示;
  • 暂时不重要时收纳;
  • 需要快速触达时变成闪控球;
  • 任务完成以后又能自然退出。

对我来说,这就是“轻任务悬浮台”这套五连载最值得保留下来的工程思路。

这样做的好处是,最后的收尾不是一句“看起来没问题”,而是有完整的测试、日志和资源释放结果可以相互印证。

Logo

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

更多推荐