我最近在看 HarmonyOS 7 的一些新能力时,闪控窗给我的感觉特别直接。

它不像某些能力那样需要先讲很多抽象概念,闪控窗几乎一眼就能让人明白它的价值:当用户已经离开当前页面,某个轻量但又持续发生的任务,能不能继续被看到、被控制、被快速找回。

这种能力背后其实有很强的产品意味。很多应用里,真正会被用户反复查看的,并不一定是大而全的主页面,反而常常是“正在发生”的那一点状态:文件下载到了多少、音乐是不是还在播、转码是不是还没结束、同步是不是卡住了。

如果这些状态只能留在应用主界面里,用户就必须频繁切回去看;但如果它们能以一种轻量、克制、不过度打扰的方式持续存在,整个多任务体验会立刻不一样。

所以这次我给自己定了一个五连载的新主题:闪控窗。

而第一篇,我不打算一上来就把事情做复杂。我给自己设的目标很明确:

用 ArkUI 先把一个最小可跑通的“轻任务悬浮台”做出来,完成“创建任务 → 打开闪控窗 → 在窗里展示任务状态 → 继续返回应用查看”的第一版闭环。

这篇文章对应的 Demo 名字,就叫:轻任务悬浮台。

它会先聚焦一个非常容易理解的场景:下载任务。

用户在主页面上点击“下载文件”后,应用内部会创建一条模拟任务;随后会打开一个闪控窗样式的小卡片,里面显示任务名称、总大小、当前进度,并且带有暂停、关闭等最基础的操作入口。这样就算用户返回到主应用的其他区域,甚至后续切到别的任务场景,他仍然能通过这个轻量浮层看到正在发生的事情。

对我来说,第一篇最重要的不是“功能很多”,而是把这条最小主链路走通,然后给后面四篇留下一个可以继续演进的工程底座。

在这里插入图片描述

一、第一篇我为什么选“轻任务悬浮台”这个 Demo

写技术连载时,我越来越在意一件事:示例场景是不是天然契合能力本身。

如果场景不贴合,就会出现一种很奇怪的情况:API 是新的,界面也能做,但文章写出来总让人觉得是在为了演示而演示。

闪控窗就特别不适合那种“把整个主页面缩小放进去”的写法。因为它本身强调的不是“再来一个完整页面”,而是“保留一个关键状态入口”。

所以我最后选了“轻任务悬浮台”,原因主要有三个。

1)它天然适合持续状态展示

下载、转码、同步这类任务都有一个共同点:

  • 会持续一段时间;
  • 用户离开当前界面后仍然关心;
  • 状态变化可以被压缩成一个小卡片。

这正好符合闪控窗最适合承载的信息密度。

2)它能自然承接多任务交互

第一篇只做下载任务,但这个架子一旦搭起来,后面几篇就可以继续往里放:

  • 播放音乐
  • 视频转码
  • 资料同步
  • 倒计时提醒

也就是说,这个 Demo 有明显的扩展空间,不会写完第一篇就变成一次性示例。

3)它很适合写成“工程渐进式”连载

第一篇可以只搭最小任务窗;第二篇写拖动与位置约束;第三篇写侧边栏暂存与恢复;第四篇写闪控球联动;第五篇写生命周期与资源释放。

这个演进路径非常顺。

所以从选题角度看,“轻任务悬浮台”不是为了凑一个 Demo 名字,而是为了让整套五连载有一条足够稳定的工程主线。

二、第一版我想先做成什么样

我在动手之前,先把第一篇要达到的结果限定得很清楚,尽量不让自己在一开始就把范围铺得太大。

第一版我只想解决下面四件事:

  1. 主页面展示任务入口;
  2. 点击入口后创建一条任务;
  3. 打开一个轻量悬浮卡片展示当前任务状态;
  4. 任务进度能在主页面和悬浮卡片里同步变化。

这里面最关键的是第四点。

因为第一篇如果只是把一个漂亮的悬浮小窗画出来,其实没太大价值。真正能说明“已经开始形成能力”的,是主页面和悬浮卡片看到的是同一条任务状态。

换句话说,第一篇虽然看起来只是一个“第一版任务窗”,但它的本质已经在做一件很工程化的事:让两个不同 UI 入口共同消费同一份任务状态。

为了让这个边界更清楚,我把第一版的结构先画成下面这样:

主页面 Index.ets
   ├─ 展示任务列表 / 最近任务
   ├─ 点击任务入口
   └─ 调用 TaskManager 创建任务

TaskManager
   ├─ 保存当前任务数据
   ├─ 模拟进度更新
   └─ 通知 UI 刷新

FloatWindowManager
   ├─ 打开闪控窗
   ├─ 更新闪控窗内容
   └─ 关闭闪控窗

FloatTaskCard.ets
   ├─ 显示任务名
   ├─ 显示总量与当前进度
   └─ 提供暂停 / 关闭入口

这套拆分虽然还很轻,但已经足够支撑第一篇。

三、项目目录我会先这样搭

为了后面四篇能继续写下去,我没有把代码全部塞进一个文件里,而是从第一篇开始就尽量把目录搭规整。

LightTaskDemo
├─ entry
│  ├─ src/main/ets
│  │  ├─ pages
│  │  │  ├─ Index.ets
│  │  │  └─ FloatWindowPage.ets
│  │  ├─ model
│  │  │  └─ TaskInfo.ets
│  │  ├─ service
│  │  │  ├─ TaskManager.ets
│  │  │  └─ FloatWindowManager.ets
│  │  ├─ components
│  │  │  └─ FloatTaskCard.ets
│  │  └─ common
│  │     └─ Logger.ets
│  └─ resources
└─ module.json5

这个目录里,有三个我认为第一篇就值得单独拆出来的角色:

1)TaskInfo

任务的数据结构要明确,至少得包含:

  • id
  • title
  • type
  • progress
  • totalText
  • status

2)TaskManager

它不是页面,而是任务状态的单一来源。后面主页面和闪控窗都要依赖它。

3)FloatWindowManager

它负责“打开 / 更新 / 关闭”闪控窗,目的是避免页面层直接处理过多窗口细节。

我越来越觉得,连载第一篇就把目录立住,后面会轻松很多。因为后面几篇要扩展时,不需要推倒重来,只要沿着已经存在的边界继续长内容就行。

四、先把任务模型定下来,不然后面状态会很乱

第一篇里,我先做了一个最简单但够用的任务模型。

export type TaskType = 'download' | 'music' | 'transcode' | 'sync'
export type TaskStatus = 'idle' | 'running' | 'paused' | 'done'

export interface TaskInfo {
  id: string
  title: string
  type: TaskType
  progress: number
  totalText: string
  status: TaskStatus
}

这段代码看起来很普通,但它解决了一个很重要的问题:

主页面和闪控窗以后都不再各自拼字符串,而是围绕同一份结构化任务数据工作。

比如“文件下载中…”这个标题,不该在一个页面里写死一遍、在另一个页面里再写死一遍;进度值也不该一处用 0.7,另一处又自己转成 70% 却没有统一规则。

所以第一篇我从一开始就坚持一个原则:先抽象任务,再去画 UI。

这样做的好处是,等到后面第三篇、第四篇要做状态保持、侧边栏暂存、闪控球联动时,任务本身已经是稳定的,不需要再回头改底层数据结构。

五、TaskManager 先负责最小任务主链路

第一篇的“任务系统”当然不需要很重,我只是先做一个能模拟真实过程的最小版。

import { TaskInfo } from '../model/TaskInfo'

export class TaskManager {
  private static instance: TaskManager = new TaskManager()
  private currentTask?: TaskInfo
  private listeners: Array<(task: TaskInfo) => void> = []
  private timer?: number

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

  createDownloadTask(): TaskInfo {
    const task: TaskInfo = {
      id: 'task_' + Date.now(),
      title: '文件下载中…',
      type: 'download',
      progress: 0,
      totalText: '0.0 GB / 3.0 GB',
      status: 'running'
    }
    this.currentTask = task
    this.notify(task)
    this.startMockProgress()
    return task
  }

  getCurrentTask(): TaskInfo | undefined {
    return this.currentTask
  }

  subscribe(listener: (task: TaskInfo) => void) {
    this.listeners.push(listener)
  }
}

上面这段只是骨架,但逻辑方向已经很清楚了:

  • 页面点击创建任务;
  • TaskManager 生成一条任务;
  • 任务变成当前活动任务;
  • 所有订阅者收到更新;
  • 然后开始模拟进度增长。

进度模拟我写得也比较克制,目标不是造一个完整下载器,而是让读者看懂“状态持续变化”这件事是怎么被驱动的。

private startMockProgress() {
  if (!this.currentTask) {
    return
  }
  let count: number = 0
  this.timer = setInterval(() => {
    if (!this.currentTask || this.currentTask.status !== 'running') {
      return
    }
    count += 10
    this.currentTask.progress = Math.min(count, 100)
    this.currentTask.totalText = `${(this.currentTask.progress * 0.03).toFixed(1)} GB / 3.0 GB`
    if (this.currentTask.progress >= 100) {
      this.currentTask.status = 'done'
      this.currentTask.title = '下载已完成'
    }
    this.notify(this.currentTask)
  }, 1000)
}

有了这一段以后,闪控窗和主页面就不再只是静态演示,而开始像一个真的“活着的任务”。

在这里插入图片描述

六、主页面怎么组织,我更关心“入口明确”而不是“内容复杂”

第一篇的主页面我刻意做得比较清楚、比较宽松。

因为这一篇的重点不是复杂的页面编排,而是让读者一眼看明白:

  • 这里可以创建任务;
  • 这里能看到任务说明;
  • 这里能看到最近任务;
  • 任务启动以后会出现闪控窗。

主页面 Index.ets 我大概会这样写:

@Entry
@Component
struct Index {
  @State currentTask?: TaskInfo
  private manager: TaskManager = TaskManager.getInstance()

  aboutToAppear() {
    this.manager.subscribe((task: TaskInfo) => {
      this.currentTask = { ...task }
      FloatWindowManager.getInstance().update(task)
    })
  }

  private createDownloadTask() {
    const task = this.manager.createDownloadTask()
    this.currentTask = task
    FloatWindowManager.getInstance().open(task)
  }

  build() {
    Column() {
      Text('轻任务悬浮台')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)

      Text('通过闪控窗显示当前任务状态,让重要进度随时可见。')
        .fontSize(16)
        .margin({ top: 8, bottom: 24 })

      this.buildTaskEntrance('下载文件', () => this.createDownloadTask())
      this.buildTaskEntrance('播放音乐')
      this.buildTaskEntrance('视频转码')
      this.buildTaskEntrance('资料同步')

      if (this.currentTask) {
        this.buildRecentTask(this.currentTask)
      }
    }
  }
}

这里有一个非常关键的点:主页面并不直接操作悬浮窗细节。

它只做两件事:

  1. 调用 TaskManager 创建任务;
  2. 调用 FloatWindowManager 打开悬浮窗。

这种边界看起来朴素,但它让第一篇的工程感一下就出来了。

七、FloatWindowManager 是第一篇真正把“闪控窗”串起来的角色

到了这里,真正和“闪控窗”直接相关的角色就该出现了。

我给它取名叫 FloatWindowManager,本质上就是一个窗口协调器。

第一篇里,它主要只负责下面三件事:

  • open(task):打开闪控窗;
  • update(task):任务有变化时刷新闪控窗;
  • close():关闭闪控窗。

伪代码大致像这样:

export class FloatWindowManager {
  private static instance: FloatWindowManager = new FloatWindowManager()
  private isOpen: boolean = false

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

  open(task: TaskInfo) {
    this.isOpen = true
    Logger.info(`open float window, task=${task.id}`)
    // 这里挂接闪控窗能力,加载 FloatWindowPage 或轻量卡片内容
  }

  update(task: TaskInfo) {
    if (!this.isOpen) {
      return
    }
    Logger.info(`update float window, progress=${task.progress}`)
    // 刷新悬浮内容
  }

  close() {
    this.isOpen = false
    Logger.info('close float window')
  }
}

我很喜欢这种写法,因为到了第二篇、第三篇,我们完全可以继续往它里面加:

  • 拖动位置记录;
  • 边缘吸附;
  • 侧边栏暂存;
  • 从闪控球恢复。

也就是说,第一篇虽然只是在做“第一版任务窗”,但 FloatWindowManager 已经把后面几篇的主入口先留好了。

八、闪控窗里的任务卡片,我只保留最必要的信息

真正的闪控窗 UI,我想做得尽量干净,不把它设计成一个小号应用页面。

因为闪控窗最怕的就是信息过量。一旦内容过多,它就会失去“扫一眼就明白”的价值。

所以第一版 FloatTaskCard.ets 我只保留这几个核心元素:

  • 下载图标;
  • 标题:文件下载中…;
  • 总量文本:2.1 GB / 3.0 GB;
  • 进度条;
  • 暂停按钮;
  • 关闭按钮。

代码大概像这样:

@Component
export struct FloatTaskCard {
  @Prop title: string = ''
  @Prop progress: number = 0
  @Prop totalText: string = ''
  onPause: () => void = () => {}
  onClose: () => void = () => {}

  build() {
    Column() {
      Row() {
        Image($r('app.media.ic_download')).width(24).height(24)
        Column() {
          Text(this.title).fontSize(16).fontWeight(FontWeight.Medium)
          Text(this.totalText).fontSize(12).fontColor('#667085')
        }.layoutWeight(1).margin({ left: 8 })

        Button() { Text('Ⅱ') }.onClick(() => this.onPause())
        Button() { Text('×') }.onClick(() => this.onClose())
      }

      Progress({ value: this.progress, total: 100 })
        .margin({ top: 12 })
    }
    .padding(16)
    .borderRadius(20)
    .backgroundColor('#F8FBFF')
  }
}

到这一步,第一篇最重要的视觉骨架其实就已经成形了。

在这里插入图片描述

九、为什么第一篇我特意让主页面和悬浮窗保持同一套视觉语言

这是我这次写 Demo 时很在意的一个细节。

很多示例工程里,主页面和悬浮窗像是两个完全不属于同一个应用的东西。主页面一个风格,悬浮卡片又是另一套风格,最后虽然功能勉强接上了,但整体感会很弱。

所以第一篇我刻意做了统一:

  • 同样的浅色背景;
  • 同样的低饱和蓝色高亮;
  • 同样的圆角卡片语言;
  • 同样的任务标题命名方式。

这样做不是为了“好看”本身,而是为了让读者一眼就觉得:闪控窗不是临时贴上去的额外部件,而是应用界面的一部分延伸。

这一点在技术文章里其实特别重要。因为很多时候,配图会先于代码给读者留下印象。如果视觉语言连续,读者会更容易接受后面讲到的架构设计与状态同步。

十、无边框运行图里,第一篇最该展示的就是“主页面 + 当前悬浮状态”

第一篇的无设备边框运行图,我想表达的不是“这页面很丰富”,而是一个非常清晰的关系:

  • 左边是应用主页面;
  • 右上角是已经打开的闪控窗;
  • 页面主体还能看到“最近任务”;
  • 也就是说,任务状态既存在于主页面里,也存在于悬浮层里。

这张图放进文章里,其实相当于在替我说一句话:

第一篇已经不再只是静态原型,而是有了“双入口共享任务状态”的雏形。

在这里插入图片描述

十一、类型二综合讲解图为什么很适合放在第一篇

你会发现,闪控窗这种主题特别适合“类型二综合讲解图”。

因为它天然需要把几种信息同时放在一起:

  1. 主页面任务入口;
  2. 点击后的闪控窗状态;
  3. DevEco 中的关键代码;
  4. 任务流程关系;
  5. 真机或真实运行效果。

如果把这些信息完全拆开,读者当然能一点点看懂,但阅读节奏会慢很多。相反,用一张综合图把“创建任务 → 打开闪控窗 → 持续展示状态 → 返回应用”的过程串起来,第一篇的逻辑会一下清楚很多。

所以我会把这张图放在文章中段,专门承担“总览复盘”的作用。

在这里插入图片描述

十二、第一篇做到这里,我会怎么验收

第一篇写完以后,我不会拿“有没有很多功能”来验收,而是看下面这几条是不是已经成立。

1)任务能不能被成功创建

点击“下载文件”后,要能立即生成一条新的任务数据。

2)闪控窗能不能及时打开

创建任务以后,闪控窗要及时出现,而不是状态已经变了、窗口还没开。

3)主页面和闪控窗看到的是不是同一份状态

主页面里的最近任务进度变了,闪控窗里也要同步变;反过来如果任务被暂停,两边都应该一致。

4)闪控窗信息是不是足够精简

能看懂,但不过载;有控制入口,但不抢焦点。

5)代码结构是否具备继续扩展的空间

至少后面想继续加拖动、暂存、侧边栏恢复、闪控球联动时,不需要把第一篇全部拆掉重写。

如果这几条都成立,我就会认为第一篇达到了预期:它是一个真正的“第一版任务窗”,而不是一张静态效果图。

十三、实机效果里,我最在意的是“轻”和“稳”

第一篇最后的实机图,我最在意两件事:

第一,轻

闪控窗的存在感要刚刚好。用户一眼能看到它,但不会觉得它压住了主界面。

第二,稳

悬浮窗本身的排版、圆角、间距、按钮位置,都要让人感觉它是一个稳定的系统能力展示窗口,而不是一块临时拼装的小面板。

如果实机上看起来已经具备这种“轻”和“稳”,那说明第一篇在视觉与信息密度控制上就已经站住了。

十四、我还专门在这里插入图片描述

补了一轮日志与调试视角,这会让第一篇更像工程文章

如果这篇只是把界面和代码贴出来,其实也能成立。但我自己在整理时,还是习惯再补一层“调试视角”。

因为真正做这种 Demo 时,最容易出问题的往往不是界面本身,而是几个状态之间有没有及时对齐:

  • 任务创建以后,主页面是不是已经拿到新任务;
  • 闪控窗打开时,用的是不是当前这条任务,而不是旧状态;
  • 进度每秒更新时,主页面和悬浮窗是不是都收到了通知;
  • 点击关闭后,窗口状态是不是已经回收;
  • 点击暂停后,定时器是否真的停下来了。

所以我给第一篇专门留了一组比较克制的日志输出。日志不需要很多,但要能一眼看清楚主链路有没有跑顺。

export class Logger {
  static info(tag: string, message: string) {
    console.info(`[${tag}] ${message}`)
  }

  static error(tag: string, message: string) {
    console.error(`[${tag}] ${message}`)
  }
}

在 TaskManager 和 FloatWindowManager 里,我会保留类似下面这样的输出:

Logger.info('TaskManager', `create task: ${task.id}`)
Logger.info('TaskManager', `progress update: ${task.progress}`)
Logger.info('FloatWindowManager', `open float window: ${task.title}`)
Logger.info('FloatWindowManager', `update float window: ${task.progress}`)
Logger.info('FloatWindowManager', 'close float window')

这些日志的作用,不是为了把控制台刷满,而是为了在第一篇就把链路建立起来。只要这几行信息稳定出现,你在排查问题时会特别轻松。比如某次点击下载以后,如果主页面更新了但悬浮窗没动,你就能顺着日志快速判断:是任务管理器没通知到位,还是窗口管理器没有正确刷新。

我越来越觉得,社区文章里如果只讲 UI 和 API,读者虽然能看懂,但不一定能真复现;如果能适当补一点“调试视角”,文章的实操价值会立刻上一个台阶。

十五、第一篇还有两个容易忽略的小问题,我选择现在就先处理思路

虽然这是第一篇,但有两个小问题如果完全不提,后面文章会显得有点断。

1)如果重复点击“下载文件”怎么办

第一版里我不会马上支持很多并发任务,因为那会把窗口策略一下子变复杂。但我至少会先定一条规则:

  • 如果当前已经有活动下载任务,再次点击时,可以选择直接聚焦当前任务;
  • 或者给出“已有正在进行的任务”的提示;
  • 暂时不在第一篇里展开多任务队列。

这条规则的意义在于,第一篇要先把“单任务悬浮”讲透,而不是把复杂度提前摊平。

2)关闭闪控窗是不是等于终止任务

我在第一篇里会把这两个动作分开理解:

  • 关闭闪控窗:只是关闭当前的轻量展示入口;
  • 终止任务:是任务状态本身结束。

这两者在后面几篇会越来越重要。因为一旦要做侧边栏暂存、闪控球恢复、跨场景返回,就不能把“关掉窗口”和“任务结束”混成一件事。

所以哪怕第一篇暂时只做一个简单的关闭按钮,我也会在文中把语义提前说清楚。这样后续往下写时,整套设计会更连续。

十六、第一篇解决了什么,也给第二篇留下了什么

写完这一篇,我觉得“轻任务悬浮台”已经完成了一个很明确的起步动作:

  • 主页面有了任务入口;
  • 任务状态有了统一模型;
  • 任务管理器开始承担状态源角色;
  • 闪控窗管理器把窗口入口留了出来;
  • 悬浮卡片已经能展示核心状态;
  • 主页面与闪控窗之间建立了第一条共享状态链路。

这意味着:第一篇已经把“能不能做”变成了“已经可以跑”。

但它也很自然地留下了第二篇的问题:

  • 窗口能不能自由拖动?
  • 如果拖到屏幕边缘,是否需要位置约束?
  • 下次再打开时,能不能记住上一次停留的位置?
  • 悬浮窗是不是可以根据不同任务类型做更细的布局差异?

这些问题,正好就是第二篇可以继续往前推进的方向。

十七、本文小记

第一篇写到这里,我自己最满意的并不是多写了几段 ArkTS,而是终于把“闪控窗”这个主题拉到了一个比较具体、比较好继续写下去的工程场景里。

以前提到悬浮类能力,很多时候容易停在一句话概括:可以显示一个浮窗。

但真正开始动手时,你会发现它值得思考的地方其实很多:

  • 这个窗里到底该放什么信息;
  • 信息从哪里来;
  • 主页面和浮窗之间如何共享状态;
  • 窗口本身如何作为一个可演进的能力入口存在下去。

而“轻任务悬浮台”这个 Demo,正好让我能把这些问题一点点拆开。

如果用一句话总结第一篇,我会这样说:

HarmonyOS 7 闪控窗的第一步,不是先把窗做大,而是先把“持续被关注的那一点状态”做对。

对我来说,这就是这篇最重要的收获。

下一篇,我会继续沿着这条主线往前走,把“打开了一个任务窗”这件事,进一步做成“它真的可以自由悬浮、被约束、被恢复位置”的更完整体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐