HarmonyOS 7 闪控窗实战 01:ArkUI + 标准悬浮窗搭建“轻任务悬浮台”第一版任务窗【鸿蒙心迹】
我最近在看 HarmonyOS 7 的一些新能力时,闪控窗给我的感觉特别直接。
它不像某些能力那样需要先讲很多抽象概念,闪控窗几乎一眼就能让人明白它的价值:当用户已经离开当前页面,某个轻量但又持续发生的任务,能不能继续被看到、被控制、被快速找回。
这种能力背后其实有很强的产品意味。很多应用里,真正会被用户反复查看的,并不一定是大而全的主页面,反而常常是“正在发生”的那一点状态:文件下载到了多少、音乐是不是还在播、转码是不是还没结束、同步是不是卡住了。
如果这些状态只能留在应用主界面里,用户就必须频繁切回去看;但如果它们能以一种轻量、克制、不过度打扰的方式持续存在,整个多任务体验会立刻不一样。
所以这次我给自己定了一个五连载的新主题:闪控窗。
而第一篇,我不打算一上来就把事情做复杂。我给自己设的目标很明确:
用 ArkUI 先把一个最小可跑通的“轻任务悬浮台”做出来,完成“创建任务 → 打开闪控窗 → 在窗里展示任务状态 → 继续返回应用查看”的第一版闭环。
这篇文章对应的 Demo 名字,就叫:轻任务悬浮台。
它会先聚焦一个非常容易理解的场景:下载任务。
用户在主页面上点击“下载文件”后,应用内部会创建一条模拟任务;随后会打开一个闪控窗样式的小卡片,里面显示任务名称、总大小、当前进度,并且带有暂停、关闭等最基础的操作入口。这样就算用户返回到主应用的其他区域,甚至后续切到别的任务场景,他仍然能通过这个轻量浮层看到正在发生的事情。
对我来说,第一篇最重要的不是“功能很多”,而是把这条最小主链路走通,然后给后面四篇留下一个可以继续演进的工程底座。

一、第一篇我为什么选“轻任务悬浮台”这个 Demo
写技术连载时,我越来越在意一件事:示例场景是不是天然契合能力本身。
如果场景不贴合,就会出现一种很奇怪的情况:API 是新的,界面也能做,但文章写出来总让人觉得是在为了演示而演示。
闪控窗就特别不适合那种“把整个主页面缩小放进去”的写法。因为它本身强调的不是“再来一个完整页面”,而是“保留一个关键状态入口”。
所以我最后选了“轻任务悬浮台”,原因主要有三个。
1)它天然适合持续状态展示
下载、转码、同步这类任务都有一个共同点:
- 会持续一段时间;
- 用户离开当前界面后仍然关心;
- 状态变化可以被压缩成一个小卡片。
这正好符合闪控窗最适合承载的信息密度。
2)它能自然承接多任务交互
第一篇只做下载任务,但这个架子一旦搭起来,后面几篇就可以继续往里放:
- 播放音乐
- 视频转码
- 资料同步
- 倒计时提醒
也就是说,这个 Demo 有明显的扩展空间,不会写完第一篇就变成一次性示例。
3)它很适合写成“工程渐进式”连载
第一篇可以只搭最小任务窗;第二篇写拖动与位置约束;第三篇写侧边栏暂存与恢复;第四篇写闪控球联动;第五篇写生命周期与资源释放。
这个演进路径非常顺。
所以从选题角度看,“轻任务悬浮台”不是为了凑一个 Demo 名字,而是为了让整套五连载有一条足够稳定的工程主线。
二、第一版我想先做成什么样
我在动手之前,先把第一篇要达到的结果限定得很清楚,尽量不让自己在一开始就把范围铺得太大。
第一版我只想解决下面四件事:
- 主页面展示任务入口;
- 点击入口后创建一条任务;
- 打开一个轻量悬浮卡片展示当前任务状态;
- 任务进度能在主页面和悬浮卡片里同步变化。
这里面最关键的是第四点。
因为第一篇如果只是把一个漂亮的悬浮小窗画出来,其实没太大价值。真正能说明“已经开始形成能力”的,是主页面和悬浮卡片看到的是同一条任务状态。
换句话说,第一篇虽然看起来只是一个“第一版任务窗”,但它的本质已经在做一件很工程化的事:让两个不同 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)
}
}
}
}
这里有一个非常关键的点:主页面并不直接操作悬浮窗细节。
它只做两件事:
- 调用
TaskManager创建任务; - 调用
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 时很在意的一个细节。
很多示例工程里,主页面和悬浮窗像是两个完全不属于同一个应用的东西。主页面一个风格,悬浮卡片又是另一套风格,最后虽然功能勉强接上了,但整体感会很弱。
所以第一篇我刻意做了统一:
- 同样的浅色背景;
- 同样的低饱和蓝色高亮;
- 同样的圆角卡片语言;
- 同样的任务标题命名方式。
这样做不是为了“好看”本身,而是为了让读者一眼就觉得:闪控窗不是临时贴上去的额外部件,而是应用界面的一部分延伸。
这一点在技术文章里其实特别重要。因为很多时候,配图会先于代码给读者留下印象。如果视觉语言连续,读者会更容易接受后面讲到的架构设计与状态同步。
十、无边框运行图里,第一篇最该展示的就是“主页面 + 当前悬浮状态”
第一篇的无设备边框运行图,我想表达的不是“这页面很丰富”,而是一个非常清晰的关系:
- 左边是应用主页面;
- 右上角是已经打开的闪控窗;
- 页面主体还能看到“最近任务”;
- 也就是说,任务状态既存在于主页面里,也存在于悬浮层里。
这张图放进文章里,其实相当于在替我说一句话:
第一篇已经不再只是静态原型,而是有了“双入口共享任务状态”的雏形。

十一、类型二综合讲解图为什么很适合放在第一篇
你会发现,闪控窗这种主题特别适合“类型二综合讲解图”。
因为它天然需要把几种信息同时放在一起:
- 主页面任务入口;
- 点击后的闪控窗状态;
- DevEco 中的关键代码;
- 任务流程关系;
- 真机或真实运行效果。
如果把这些信息完全拆开,读者当然能一点点看懂,但阅读节奏会慢很多。相反,用一张综合图把“创建任务 → 打开闪控窗 → 持续展示状态 → 返回应用”的过程串起来,第一篇的逻辑会一下清楚很多。
所以我会把这张图放在文章中段,专门承担“总览复盘”的作用。

十二、第一篇做到这里,我会怎么验收
第一篇写完以后,我不会拿“有没有很多功能”来验收,而是看下面这几条是不是已经成立。
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 闪控窗的第一步,不是先把窗做大,而是先把“持续被关注的那一点状态”做对。
对我来说,这就是这篇最重要的收获。
下一篇,我会继续沿着这条主线往前走,把“打开了一个任务窗”这件事,进一步做成“它真的可以自由悬浮、被约束、被恢复位置”的更完整体验。
更多推荐



所有评论(0)