HarmonyOS 7 + ArkUI-Window Kit:闪控窗任务恢复与形态切换【鸿蒙心迹】

一、68% 不是一个可以随便显示的数字
下午 13:57,我在 QuickTask 里启动一段 5 分钟的录音转写任务,把它切成闪控窗后继续查看其他应用。窗口里显示进度 68%,已处理 183 秒,一切正常。随后我把闪控窗暂存到侧边,再从任务入口恢复,页面仍显示 68%,但后台实际任务已经推进到 72%。几秒后 UI 又突然跳回 66%。
三个数字都“有来源”:68% 是窗口切换前的组件状态,72% 是后台任务的实时进度,66% 是旧页面恢复时从本地缓存读到的上一个检查点。问题不是某个接口返回错误,而是窗口形态变化后,多个状态源同时争夺页面解释权。
闪控窗是轻量窗口,不是把普通页面缩小后就结束。它可能在标准悬浮窗、闪控球、侧边暂存与恢复之间切换,窗口可见性和业务任务生命周期也不相同。窗口隐藏不代表任务停止,页面销毁不代表任务失败,窗口重新出现更不代表应该从零创建任务。
本次工程统一使用任务 ID FT-20260930-1357-03,检查点序号 42,恢复位置 183 秒,稳定进度 68%。状态链定义为 ACTIVE → DOCKED → RESTORING → ACTIVE。这篇不讨论如何做一个漂亮小窗,而是复盘状态如何在窗口反复变化后仍保持可信。
二、先把窗口状态和任务状态拆开
第一版只有一个 @State status,值包括运行、暂停、隐藏、完成。它同时描述页面是否可见和后台任务是否执行,结果是任何窗口事件都可能覆盖业务状态。例如收到“窗口不可见”后写成 PAUSED,后台转写却仍在继续;再次出现时页面会错误地显示暂停按钮。
我们最终维护两套正交状态。窗口状态是 EXPANDED / FLOAT / BALL / DOCKED / HIDDEN,任务状态是 CREATED / ACTIVE / PAUSED / COMPLETED / FAILED。二者通过一个展示映射器组合:窗口可以是 DOCKED,任务仍然是 ACTIVE;任务可以 COMPLETED,窗口暂时 HIDDEN,恢复后直接展示结果。
这段代码解决什么问题。 它把窗口形态事件与任务执行事件分别归约,避免一个枚举承担两种生命周期。
type WindowMode = 'EXPANDED' | 'FLOAT' | 'BALL' | 'DOCKED' | 'HIDDEN'
type TaskState = 'CREATED' | 'ACTIVE' | 'PAUSED' | 'COMPLETED' | 'FAILED'
interface QuickTaskSnapshot {
taskId: string
taskState: TaskState
windowMode: WindowMode
progress: number
processedSeconds: number
checkpointSeq: number
updatedAt: number
}
type QuickTaskEvent =
| { kind: 'WINDOW_MODE_CHANGED', mode: WindowMode }
| { kind: 'TASK_PROGRESS', progress: number, seconds: number, seq: number }
| { kind: 'TASK_FINISHED', seq: number }
export function reduceSnapshot(
old: QuickTaskSnapshot,
event: QuickTaskEvent
): QuickTaskSnapshot {
if (event.kind === 'WINDOW_MODE_CHANGED') {
return { ...old, windowMode: event.mode, updatedAt: Date.now() }
}
if (event.seq <= old.checkpointSeq) return old
if (event.kind === 'TASK_FINISHED') {
return { ...old, taskState: 'COMPLETED', progress: 100,
checkpointSeq: event.seq, updatedAt: Date.now() }
}
return { ...old, taskState: 'ACTIVE', progress: event.progress,
processedSeconds: event.seconds, checkpointSeq: event.seq, updatedAt: Date.now() }
}
核心判断是 event.seq <= old.checkpointSeq。旧页面带来的 66% 对应序号 41,当前检查点已经是 42,因此直接丢弃。窗口事件没有序号也不会改变进度,它只更新 windowMode。这让状态变化变得可推理,而不是依赖回调到达顺序。
序号必须由任务服务生成,不能由每个页面实例自己递增。多窗口或页面重建时,本地计数器会重新从零开始,无法比较新旧。实际项目还应把任务实例 ID 与序号组合,防止用户启动新任务后收到上一任务的迟到事件。
三、窗口适配器只翻译系统事件
闪控窗相关接入应遵循当前 HarmonyOS SDK 的能力声明、生命周期与设计规范。为了让业务代码不依赖具体回调名称,我们在工程中加了一层 FloatWindowAdapter。它把系统侧的形态变化统一翻译成领域事件,不保存业务进度,也不直接操作转写任务。
这段代码解决什么问题。 它把系统窗口回调收口为项目内部事件,并对短时间内重复的同形态通知进行去抖。
export class FloatWindowAdapter {
private lastMode: WindowMode = 'HIDDEN'
private lastChangedAt: number = 0
constructor(private dispatch: (event: QuickTaskEvent) => void) {}
onSystemWindowChanged(rawMode: string): void {
const mode = this.mapMode(rawMode)
const now = Date.now()
if (mode === this.lastMode && now - this.lastChangedAt < 300) return
this.lastMode = mode
this.lastChangedAt = now
this.dispatch({ kind: 'WINDOW_MODE_CHANGED', mode })
console.info(`[QuickTask] windowMode=${mode}`)
}
private mapMode(raw: string): WindowMode {
if (raw === 'standard_float') return 'FLOAT'
if (raw === 'control_ball') return 'BALL'
if (raw === 'side_docked') return 'DOCKED'
if (raw === 'expanded') return 'EXPANDED'
return 'HIDDEN'
}
}
示例中的原始字符串是项目适配层占位,真实接入必须按当前官方文档与 SDK 类型实现,不能直接复制这些名字。重要的是架构位置:系统变化在适配器终止,领域层只认识 WindowMode。未来 SDK 调整、产品增加新形态时,修改映射即可。
300 ms 去抖只针对“相同模式重复通知”,不能吞掉 FLOAT → DOCKED → FLOAT 这样的有效序列。窗口交互本身很快,按固定延迟合并所有事件会让 UI 看起来迟钝,也可能错过暂存动作。

开发截图中,左侧工程目录包含 FloatWindowAdapter、QuickTaskStore 与 TaskRecoveryService;中间代码显示序号 42 的状态归约;右侧模拟器固定在右边,闪控窗显示 68%;底部 HiLog 打印 ACTIVE → DOCKED → RESTORING。红圈只标注检查点序号和旧事件丢弃结果。
四、恢复时不要相信页面带回来的状态
窗口恢复最容易写成“页面出现时读取 Preferences,然后继续”。问题是页面缓存可能落后,任务服务可能已经完成,甚至任务进程已被系统回收。可靠的恢复需要按权威性排序:先查询仍在运行的任务服务,再查持久化检查点,最后才使用页面导航参数作为展示提示。
本项目采用三步恢复:读取 FT-20260930-1357-03 的持久化快照;向任务服务请求当前状态;比较 checkpointSeq,保留序号更大的记录。若任务服务不存在但快照状态仍是 ACTIVE,页面进入 RESTORING,尝试按检查点重建任务;重建失败才进入 FAILED,不能直接把旧的 68% 当成仍在运行。
这段代码解决什么问题。 它按检查点序号合并持久化与实时状态,并在 UI 订阅前完成一次权威快照选择。
export class TaskRecoveryService {
async restore(taskId: string): Promise<QuickTaskSnapshot> {
const persisted = await QuickTaskStore.load(taskId)
const live = await QuickTaskEngine.query(taskId)
if (live && (!persisted || live.checkpointSeq >= persisted.checkpointSeq)) {
await QuickTaskStore.save(live)
return { ...live, windowMode: 'FLOAT' }
}
if (!persisted) throw new Error('SNAPSHOT_NOT_FOUND')
if (persisted.taskState !== 'ACTIVE') return persisted
const resumed = await QuickTaskEngine.resume({
taskId: persisted.taskId,
fromSeconds: persisted.processedSeconds,
checkpointSeq: persisted.checkpointSeq
})
return { ...resumed, windowMode: 'FLOAT' }
}
}
恢复期间页面显示 RESTORING,进度条保持 68% 但使用弱化颜色,按钮暂时不可操作。只有服务返回序号不小于 42 的快照后,状态才重新进入 ACTIVE。这样用户看到的 68% 表示“上次确认进度”,而不是伪装成实时进度。
如果任务已经完成,live.taskState=COMPLETED 且序号 47,恢复服务直接返回 100%,不会从 183 秒重启。若服务返回序号 41,则说明它比持久化记录旧,不能覆盖序号 42;项目会记录 STALE_LIVE_SNAPSHOT 并进入诊断流程。

手机运行图展示标准闪控窗形态:任务 FT-20260930-1357-03 正在执行,进度 68%,已处理 183 秒,检查点 42。完整状态栏包含时间 13:57、Wi‑Fi、5G、信号和 79% 电量,红色箭头指出“任务继续,窗口已暂存”这一组并存状态。
五、持久化不是每一帧都写一次
转写引擎每秒都可能产生进度,如果每次事件都落盘,闪存写入和序列化会变成新的性能问题。我们采用“序号必增、时间节流、关键事件强制”的策略:普通进度每 5 秒或累计变化 2% 保存一次;窗口进入 DOCKED/HIDDEN、任务暂停、任务完成时立即保存。
持久化内容不保存音频正文,只保存恢复任务所需的最小字段:任务 ID、媒体授权引用、处理位置、进度、检查点序号、算法版本和校验和。敏感内容仍由原业务存储管理。窗口状态可以保存,但只作为上次 UI 形态参考,恢复时最终形态由系统当前环境决定。
检查点 42 的数据校验和为 CP-42-9C7A。我们在写入时采用临时记录加活动指针,避免写到一半应用退出留下半条 JSON。读取时若校验失败,不尝试猜测缺失字段,而是回退到序号 41 并向任务服务查询最新状态。
六、一次刻意制造的恢复故障
为了验证边界,我们在 68% 时把窗口暂存,再主动终止 UIAbility,但保留后台任务。重新进入闪控窗后,页面经历 DOCKED → RESTORING → ACTIVE。持久化快照序号 42、进度 68%;任务服务返回序号 43、进度 69%,因此实时记录胜出。
随后又模拟服务一并被回收。恢复服务读取序号 42,从 183 秒重新创建任务;引擎完成媒体授权校验和模型版本校验后返回序号 43。整个过程中没有出现 66% 的旧页面状态,也没有重新处理前 183 秒。
13:57:31.402 I QuickTask: task=FT-20260930-1357-03 mode=DOCKED seq=42 progress=68
13:57:33.018 I QuickTask: state=RESTORING checkpoint=42 from=183s
13:57:33.244 W QuickTask: stale_event_dropped seq=41 current=42
13:57:33.688 I QuickTask: state=ACTIVE seq=43 progress=69 checksum=CP-42-9C7A

诊断截图与运行页明显不同:它显示恢复来源、检查点 42、旧事件 41 已丢弃、校验和通过,以及最终 ACTIVE / 69%。红圈标在 seq 41 < 42,箭头指向“未重复处理 183 秒”,把状态恢复的关键判断直接呈现出来。
七、窗口体验里的几个产品取舍
闪控窗空间有限,不适合复制完整页面。QuickTask 在浮窗中只保留任务名、进度、暂停按钮和返回主页面入口;错误详情、文件选择和历史记录回到主窗口处理。窗口越小,越要减少会改变任务语义的操作。
侧边暂存后是否继续执行取决于业务。录音转写、导航、计时适合继续;包含摄像头预览、强交互或高耗电计算的任务,可能需要暂停或降级。这个决定应由任务策略给出,不能由 DOCKED 状态自动推导。窗口状态只是环境信息,不是业务命令。
用户也需要清楚知道任务是否仍在运行。窗口暂存前给出短提示,恢复后显示“任务持续运行 2 分 17 秒”,比单纯展示进度更可信。如果系统或权限限制导致任务不能继续,应明确进入暂停或失败态,而不是让数字停在那里。
八、最终留下的工程规则
这次修正后,我们不再把闪控窗当成一张缩小页面,而把它看成后台任务的一个可替换观察面。窗口形态和任务状态分开;任务服务拥有进度权威;检查点序号处理乱序;恢复服务在页面订阅前选择最新快照;关键事件强制持久化,普通进度节流写入。
复测结果固定为:暂存时 68% / 183s / seq 42,恢复后 69% / seq 43,序号 41 的旧事件被丢弃,校验和 CP-42-9C7A 通过。窗口来回变化五十次,任务没有重复创建,已处理时长没有回退。
闪控窗带来的价值是跨应用多任务效率,但工程质量取决于窗口消失时业务是否仍然说得清。只要状态来源、版本顺序和恢复责任明确,形态变化就只是展示变化,而不会演变成任务状态混乱。
参考资料:
更多推荐



所有评论(0)