
这篇记录的是一个很容易被 UI 误导的问题闪控窗被关掉以后任务到底算“结束”还是“还在跑”我一开始把窗口生命周期和任务生命周期绑在一起结果用户只是收起窗口导出任务就被取消。后来拆开才发现真正需要管理的是三份状态窗口是否可见、TaskPool 任务是否仍在执行、页面重新出现时该怎么恢复进度。一、关掉窗口不等于业务应该取消Demo 叫Float Task Relay模拟一个视频片段导出任务。任务 ID 固定为export_20261001_05输出文件clip_20261001_05.mp4为了把问题做得明显我让任务执行过程中持续回传进度。跑到72%时关闭闪控窗此时预期状态是Window: HIDDEN Worker: RUNNING Progress: 72% Checkpoint: 72% State: CHECKPOINTED Restore Count: 0最开始的代码却是在“窗口不可见”回调里直接取消 TaskPool 任务。页面逻辑很顺窗口没了就释放页面引用、取消异步任务。但产品语义不对——用户只是把一个轻量窗口收起来并没有点“取消导出”。这让我重新拆了一次生命周期窗口生命周期VISIBLE / HIDDEN任务生命周期RUNNING / COMPLETED / FAILED / CANCELED恢复生命周期CHECKPOINTED / RESTORED。只有“用户明确取消任务”时才允许任务进入 CANCELED。窗口从 VISIBLE 变成 HIDDEN只影响 UI不改变任务所有权。这一点尤其要说清楚TaskPool 不是后台保活服务。本文说的“续跑”指的是应用进程仍存活、任务仍由 TaskPool 调度时关闭闪控窗不主动取消任务。如果进程被系统终止Preferences 只能帮助恢复业务快照不能把已经消失的执行线程原地复活。真正需要跨进程、长时间、可恢复的后台任务应根据业务选择 Background Tasks、WorkScheduler、服务端任务或可断点续做的业务方案。把边界先定清楚后面的代码才不会给用户制造错误承诺。二、TaskPool 只负责执行页面不持有“任务生命”这段代码解决的问题是耗时导出在 TaskPool 中执行并把阶段进度主动发回宿主线程而不是让页面定时猜进度。import { taskpool } from kit.ArkTS; interface ExportPayload { taskId: string; segmentCount: number; outputName: string; } interface ExportResult { taskId: string; outputName: string; success: boolean; } Concurrent export function exportClip(payload: ExportPayload): ExportResult { for (let index 1; index payload.segmentCount; index) { // Demo 中用分段计算模拟编码正式项目应替换为真实处理逻辑。 let checksum 0; for (let i 0; i 400000; i) { checksum (checksum i index) % 1000003; } const progress Math.floor(index * 100 / payload.segmentCount); taskpool.Task.sendData({ taskId: payload.taskId, progress: progress, checkpoint: progress }); } return { taskId: payload.taskId, outputName: payload.outputName, success: true }; }这里没有把页面对象、State对象或者窗口对象传进子线程。TaskPool 线程间传输要遵守可序列化规则页面状态对象本身不应该成为并发任务参数。进度通过Task.sendData()传回主线程最终结果再由execute()返回的 Promise 接收。正式项目还要根据任务类型控制并发。短时 CPU 密集任务适合 TaskPool如果是长期驻留、频繁回调、复杂流式处理就要重新评估 Worker、LongTask 或后台任务能力不应该因为“TaskPool 能跑”就把所有任务都塞进去。三、进度回传以后先写检查点再刷新窗口接下来要解决的是 72% 这个状态怎么留下来。我没有让闪控窗组件自己维护唯一进度而是让ExportTaskService作为宿主侧任务管理器。窗口只是订阅它的状态。这样窗口销毁以后服务对象仍然可以接收 TaskPool 回传的数据。这段代码解决的问题是主线程收到进度后同时更新内存态和 Preferences 检查点窗口是否存在不影响这个流程。import { taskpool } from kit.ArkTS; import { common } from kit.AbilityKit; export class ExportTaskService { private currentTask?: taskpool.Task; private progress: number 0; private workerState: string IDLE; constructor( private context: common.UIAbilityContext, private checkpointStore: TaskCheckpointStore ) {} start(): void { const payload: ExportPayload { taskId: export_20261001_05, segmentCount: 25, outputName: clip_20261001_05.mp4 }; const task new taskpool.Task(exportClip, payload); this.currentTask task; this.workerState RUNNING; task.onReceiveData((data: Object) { const message data as Recordstring, number | string; const progress Number(message[progress] ?? 0); this.progress progress; this.checkpointStore.save( export_20261001_05, progress, CHECKPOINTED ); }); taskpool.execute(task).then((result: Object) { const exportResult result as ExportResult; this.progress 100; this.workerState exportResult.success ? COMPLETED : FAILED; }).catch(() { this.workerState FAILED; }); } hideFloatWindow(): void { // 这里只改变窗口状态不调用 taskpool.cancel。 AppStorage.setOrCreate(floatWindowState, HIDDEN); } }这段代码最重要的变化其实是没有做什么hideFloatWindow()不再调用取消逻辑。以前窗口关闭后日志是“Window HIDDEN → task cancel”现在变成Window: VISIBLE - HIDDEN Worker remains RUNNING progress72 checkpoint72 State: RUNNING - CHECKPOINTED页面和任务从这一刻真正解耦。需要注意onReceiveData里的回调应该保持轻量。不要每 1% 都做一次大文件写入也不要在回调里堆复杂业务。Demo 只有 25 个阶段实际项目可以按时间或增量做节流例如每增长 5% 或每 500ms 落一次检查点。四、Preferences 保存的是“可恢复事实”不是执行线程这段代码解决的问题是把任务 ID、进度、状态和输出文件名写成一个可重新读取的业务快照。import { preferences } from kit.ArkData; import { common } from kit.AbilityKit; export class TaskCheckpointStore { private store?: preferences.Preferences; constructor(private context: common.UIAbilityContext) {} private async ensureStore(): Promisepreferences.Preferences { if (!this.store) { this.store await preferences.getPreferences( this.context, float_task_checkpoint ); } return this.store; } async save(taskId: string, progress: number, state: string): Promisevoid { const store await this.ensureStore(); await store.put(taskId, taskId); await store.put(progress, progress); await store.put(state, state); await store.put(output, clip_20261001_05.mp4); await store.flush(); } async read(): PromiseRecordstring, preferences.ValueType { const store await this.ensureStore(); return { taskId: await store.get(taskId, ), progress: await store.get(progress, 0), state: await store.get(state, IDLE), output: await store.get(output, ) }; } }这里我特意把“进度 72%”叫检查点而不是“任务已保存到 72%”。如果业务处理本身没有可断点恢复能力Preferences 里的 72 只表示最后一次已知进度。进程被杀之后重新启动不能从这个数字推断底层编码器就能从第 73% 继续。真正的断点续做需要业务层把中间产物、已完成分段、输入版本、校验值等也设计成可恢复状态。也就是说检查点分两种UI 检查点重新打开窗口时告诉用户“上次看到 72%”业务检查点真的能从某个分片继续计算。本文 Demo 做的是前者并在任务仍存活时持续接收后续进度。DevEco Studio 图里可以看到最关键的中间态窗口已经HIDDENWorker 仍然RUNNINGProgress 和 Checkpoint 都是72%状态进入CHECKPOINTED。这比只看一个进度条更容易证明“窗口关闭没有把任务杀掉”。五、窗口重新出现时先判断任务真实状态重新打开闪控窗时最危险的做法是直接拿 Preferences 里的 72% 当当前进度。因为窗口隐藏期间任务可能已经跑完。此时如果页面先渲染 72%过一秒才收到完成态就会出现明显的“进度倒退/闪跳”。所以恢复顺序应该是读取内存中的任务管理器状态如果任务仍在 RUNNING使用实时进度如果任务已经 COMPLETED直接展示 100% 和结果文件只有没有活动任务时才用 Preferences 快照作为历史恢复依据。这段代码解决的问题是把“恢复页面”与“恢复任务”分开判断避免把旧检查点覆盖到新状态上。export class FloatTaskRuntime { restoreCount: number 0; constructor( private service: ExportTaskService, private store: TaskCheckpointStore ) {} async restoreWindow(): Promisevoid { AppStorage.setOrCreate(floatWindowState, VISIBLE); const live this.service.getRuntimeSnapshot?.(); if (live live.workerState COMPLETED) { AppStorage.setOrCreate(taskProgress, 100); AppStorage.setOrCreate(taskState, RESTORED); this.restoreCount; return; } const checkpoint await this.store.read(); AppStorage.setOrCreate( taskProgress, Number(checkpoint[progress] ?? 0) ); AppStorage.setOrCreate(taskState, RESTORED); this.restoreCount; } }Demo 最终重新打开窗口时任务已经完成因此手机页展示的是Task ID: export_20261001_05 Window: VISIBLE Worker: COMPLETED Progress: 100% Checkpoint: 72% Restore Count: 1 Result File: clip_20261001_05.mp4 State: RESTORED这里Checkpoint 72%没有被改成 100%是故意保留的。它表示“窗口隐藏期间最后一次落盘的检查点”Progress 100%才是当前实时结果。两个值并存比把检查点也强行覆盖成 100% 更容易解释发生过什么。六、这次最容易写错的几个边界第一个边界是把窗口关闭当作任务取消。窗口是展示容器任务是否取消应该由业务操作决定。除非产品明确规定“关闭窗口就是停止任务”否则不要在 UI 生命周期里顺手cancel()。第二个边界是把 TaskPool 当后台保活。TaskPool 解决的是并发执行和主线程减负不保证应用进程退出以后任务继续存在。如果业务需要跨进程、跨重启继续必须设计真正的持久任务协议。第三个边界是把 Preferences 当任务引擎。Preferences 很适合存小体量键值状态但它不负责保存执行上下文。要断点续做就要额外保存可重放的输入、分片进度、临时文件或服务端任务 ID。第四个边界是频繁持久化。进度回调可能很密put flush也有成本。真实项目应该设置阈值不能每一帧、每一个百分比都落盘。第五个边界是页面重建重复启动任务。重新打开闪控窗时要先查现有 taskId 和任务状态再决定是绑定旧任务还是新建任务。否则同一个export_20261001_05可能被执行两次最后谁覆盖输出文件都说不清。七、窗口只是入口任务状态才是主线这次改完之后我没有再让闪控窗组件持有“任务真相”。组件只显示状态真正的状态由ExportTaskService TaskCheckpointStore维护。这样做以后窗口从 VISIBLE 变 HIDDEN再回到 VISIBLE只是 UI 订阅关系发生变化任务从 RUNNING 到 COMPLETED是另一条独立状态线。最终运行过程可以写成VISIBLE → HIDDEN → RUNNING → CHECKPOINTED → COMPLETED → RESTORED这条链路比“关闭窗口后继续跑”更准确因为它同时说明了窗口隐藏时任务还在执行72% 被记录成检查点任务最终完成后窗口再次出现时恢复的是 100% 的真实结果而不是旧的 72%。如果后面要把这个 Demo 扩成正式产品我会继续补三块任务唯一性表、可断点的业务分片、应用异常退出后的恢复策略。做到这一步闪控窗才不只是一个漂亮的小窗口而是真正能承载长流程任务状态的入口。八、我专门测了三个“看起来像续跑其实不是”的场景第一个场景是窗口隐藏以后用户再次点击“开始导出”。如果页面只根据按钮状态判断很容易创建第二个 TaskPool Task。于是我给ExportTaskService加了 taskId 级别的唯一性判断当前存在 RUNNING 任务时同一个 taskId 的start()直接返回已有状态不再创建新任务。这段代码解决的是窗口重建后重复启动同一业务任务private runningTaskId: string ; start(taskId: string): void { if (this.workerState RUNNING this.runningTaskId taskId) { hilog.info(0x0000, FloatTask, reuse running task: ${taskId}); return; } this.runningTaskId taskId; this.workerState RUNNING; // 后续再创建 taskpool.Task 并执行 }第二个场景是任务在窗口隐藏期间失败。Preferences 里仍然可能停留在 72%如果恢复页面只看到这个数字就会继续画一个“任务正在运行”的进度条。正确做法是失败状态优先级高于检查点只要任务管理器已经拿到 FAILED就必须展示错误并允许重试不能继续把 72% 当成活跃状态。第三个场景是输出文件已经生成但页面还没来得及收到最终回调就被重建。这个时候单看workerState也不够我会再检查结果文件或任务结果记录。业务上真正的完成条件应该是“任务状态完成 结果可用”而不是某一个布尔值变成 true。这些场景让我意识到状态重建不是简单的“把上一次 UI 原样画回来”而是一次重新判断真实世界。页面回来时需要综合内存任务、持久化检查点、结果文件和错误状态重新计算应该展示什么。九、检查点也要有版本不能永久相信旧数据Preferences 很轻量也因此很容易被滥用。Demo 里只有一个任务直接写taskId/progress/state/output没问题正式项目如果应用升级、输出格式变化、任务参数变化旧检查点可能已经不再兼容。我会给快照增加schemaVersion、输入文件指纹、任务创建时间和业务参数摘要。恢复时先校验版本再决定是否使用。例如interface TaskCheckpoint { schemaVersion: number; taskId: string; progress: number; output: string; inputSignature: string; updatedAt: number; } function canRestore( cp: TaskCheckpoint, currentInputSignature: string ): boolean { return cp.schemaVersion 2 cp.inputSignature currentInputSignature cp.progress 0 cp.progress 100; }这段代码解决的是旧版本快照误导新任务。比如用户替换了输入视频但 taskId 被业务层错误复用了如果没有输入指纹校验页面可能直接显示旧任务的 72%。对用户来说这比“没有恢复”更糟因为它会给出一个看起来可信的错误状态。检查点还要有清理策略。任务完成并确认结果可用后可以保留一段时间用于历史展示但不应该无限累积。取消任务时也要明确是删除检查点还是保留“已取消”的记录。不同产品选择不同但必须是有意识的设计。十、如果任务真的很长我不会继续硬套这套方案这篇 Demo 的导出任务是短时、可拆分的计算流程所以 TaskPool 合适。假设换成十几分钟的视频转码、持续上传、后台同步我不会仅仅因为已经写好了进度回传就继续沿用。长任务要重新考虑几个问题应用退到后台以后还能运行多久进程被回收以后如何恢复是否需要系统调度是否需要网络约束、电量约束任务结果能否由服务端承接。换句话说这篇文章真正解决的是闪控窗生命周期和并发任务生命周期解耦不是“用 TaskPool 实现永不停止的后台任务”。把适用范围说清楚以后这套实现反而更容易落地短任务用 TaskPool页面可随时隐藏和重建进度通过消息回传Preferences 保存轻量检查点真正的取消只由用户动作触发超出这个边界的业务再换更合适的系统能力。这也是我这次最想留下的工程结论UI 消失只是一个事件不应该自动推导出业务任务结束。生命周期要按职责拆开状态恢复要以真实任务为准而不是以最后一次画出来的界面为准。参考资料HarmonyOS TaskPool 任务与宿主线程通信https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V13/taskpool-communicates-with-mainthread-V13HarmonyOS TaskPool 使用规范https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/task-pool-usage-guidelinesHarmonyOS ArkData Preferences可参考当前 ArkData / Preferences API 文档与官方示例