HarmonyOS 7 闪控窗实战 03:侧边栏暂存 + 状态保持实现“轻任务悬浮台”收纳与恢复【鸿蒙心迹】
前两篇把“轻任务悬浮台”的基础打得差不多了。
第一篇,我先让任务窗真正跑起来:主页面创建任务,闪控窗展示进度,主页面和悬浮层共享同一份任务状态;第二篇继续把窗口变得更像一个真正能用的悬浮入口——支持拖动、边缘约束、吸附停靠,还能记住上一次停留的位置。
到了第三篇,我觉得终于可以往 HarmonyOS 7 闪控窗更有辨识度的体验上走一步了:侧边栏暂存。
官方当前对 HarmonyOS 7 闪控窗的描述很明确:标准悬浮窗可以常驻展示实时状态,支持自由拖动、侧边栏暂存,并能和闪控球进行切换。也就是说,闪控窗不只是“浮在屏幕上”,它还应该具备一种更克制的收纳方式:用户暂时不需要看完整卡片时,可以把任务收进侧边;等需要时,再快速把它恢复出来。
这件事看起来像“把窗口藏起来”,但真正开始做以后我发现,它其实比单纯隐藏窗口复杂得多。
因为侧边栏暂存以后,至少有三个东西不能丢:
- 任务本身不能停。
- 任务进度不能丢。
- 恢复时要回到正确状态。
所以第三篇的重点并不是“做一个侧边栏图标”,而是建立一条完整的状态链:
闪控窗运行 → 拖到边缘 → 暂存到侧边栏 → 后台任务继续 → 点击侧边栏入口 → 恢复闪控窗 → 继续显示原任务状态。
只要这条链跑通,“轻任务悬浮台”才真正有了“收纳与恢复”的感觉。

一、第三篇真正要解决的问题,不是隐藏窗口,而是隐藏 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}`
)
}
这段代码有三个步骤:
- 找回原来的任务;
- 用当前任务数据重新显示闪控窗;
- 把展示状态改回
floating。
这样用户恢复以后,看到的仍然是刚才那条任务。
如果它暂存前是 64%,恢复时已经跑到 71%,那用户就应该看到 71%。
这才叫“恢复”。

七、侧边栏入口应该展示多少信息
做第三篇时,我也纠结过一个 UI 问题:
侧边栏入口到底要不要显示进度?
如果做得太简单,只显示一个图标,用户可能不知道里面是什么任务。
如果把完整任务信息都塞进去,又违背了“暂存”的意义。
最后我选择了比较克制的方案。
侧边栏入口只显示:
- 任务类型图标;
- 非常短的任务标识;
- 一个轻量进度提示;
- 必要时显示暂停 / 异常状态。
比如:
[同步图标]
资料同步
64%
已经足够。
完整标题、总大小、剩余时间、暂停按钮这些信息,还是留到闪控窗恢复后再展示。
八、侧边栏里开始出现多个任务以后,顺序也要有规则
虽然第三篇的主线仍然以一条任务为主,但我还是提前考虑了“多任务暂存”的问题。
例如侧边栏里可能同时出现:
资料同步
视频转码
音乐下载
这个时候,排序规则不能完全随机。
我给第一版定的规则很简单:
- 正在运行的任务优先;
- 暂停任务其次;
- 最新暂存的任务靠前;
- 已完成任务不长期保留在侧边栏。
对应可以用一个简单的排序:
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
})
}
虽然这不是第三篇的核心,但把规则先讲清楚,后面第四篇做闪控球联动时会轻松很多。
九、无边框运行图里,我最想展示的是“任务真的没有丢”
第三篇的纯界面图,我特意让它同时出现:
- 正在运行的闪控窗;
- 右侧任务栏;
- 多条被管理的任务;
- “已从侧边栏恢复任务”的提示。
因为这一篇最重要的结果不是“侧边栏好看”,而是:
任务被收进去以后还能继续运行,恢复出来以后状态还是同一条。

这个画面其实就是第三篇最核心的验收结果。
十、类型二综合图里,要把“暂存”和“恢复”讲成同一条链
这一篇我继续保留类型二综合图,而且我觉得第三篇特别适合这种形式。
因为单独看一张闪控窗截图,很难理解侧边栏暂存到底做了什么。
所以这张图里我希望同时出现:
- DevEco 中的
SideBarManager; - 正在运行的任务;
- 拖到边缘;
- 侧边栏暂存;
- 点击侧边栏入口;
- 闪控窗恢复;
- 日志里还能看到原来的
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=storedstatus=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 可以收;
- 状态不能丢;
- 任务可以继续;
- 用户想看时还能回来。
如果用一句话给第三篇收尾,我会写:
闪控窗真正灵活的地方,不是它可以一直浮着,而是它知道什么时候该出现、什么时候该收起来,而且任务始终还在。
这也会成为下一篇做多入口状态同步时最重要的前置验证。
更多推荐


所有评论(0)