HarmonyOS 7闪控窗与闪控球连载01(共08篇):轻任务队列与状态模型【共创稿事节】
用户在阅读资料、处理订单或上传文件时,往往不愿意一直留在同一个业务页面等待进度。轻量入口可以把“当前还有什么正在处理”放在更容易触达的位置:收起时只留下任务数量与最重要的状态,展开时再显示具体任务、进度和可操作项。HarmonyOS SDK 26 提到了闪控窗 ArkUI 悬浮窗与闪控球的新交互形态。对应用开发而言,窗口外观之外最先需要解决的是同一个任务在球态、窗态和业务页之间始终只有一份可信状态。

本文先设计轻任务队列和状态转换,采用应用内部的 ArkUI 组件构建交互视图,并保持业务模型与具体窗口承载层解耦。这样在后续接入对应窗口形态时,显示层只需订阅状态,任务执行逻辑不必搬进窗口组件。整个系列围绕一个“文件整理工作台”继续推进:用户提交整理请求、查看进度、处理失败并恢复未结束事项。
不要让窗口组件保存业务任务
设想用户启动三个文件整理操作:文档归档、图片分类、报告生成。若三个页面分别保存一个 progress,再让悬浮球维护自己的数字,切换页面时就需要同步四处状态。更稳妥的约束是:
TaskRecord描述单个任务的事实:唯一 ID、标题、进度、阶段和更新时间。TaskQueue控制合法状态转换;页面只发送业务动作,不直接编辑任务内部字段。SurfaceMode仅描述展示形态:隐藏、球态或窗态,不改变任务是否完成。- 任务执行器负责真实耗时工作,显示组件从统一快照中读取状态。
任务与窗口是两个正交维度。例如用户把窗态收成球态,后台工作仍在继续;用户重进应用,任务状态仍由队列确定。把“收起窗口”当作“停止任务”,是最常见的生命周期耦合问题。
用显式状态定义进度边界
只使用 isRunning: boolean 很难表达“等待、处理中、成功、失败”。尤其失败任务如果仍保留 80% 进度,用户需要知道它可以重试,而不是误以为即将完成。可以为任务引入四种阶段并定义相应的操作集合。
| 阶段 | 允许的主要动作 | 视图提示 |
|---|---|---|
| pending | 启动、取消 | 排队中 |
| running | 更新进度、完成、失败 | 当前进度与任务名 |
| succeeded | 查看结果、移出列表 | 已完成 |
| failed | 重试、移出列表 | 失败原因与重试入口 |
先建立 entry/src/main/ets/model/TaskQueue.ets。以下代码将每次状态变化都变成一个新的 TaskRecord,页面层再替换数组引用,避免状态管理 V1 对复杂对象内部字段变化的观察问题。
export enum TaskPhase {
PENDING = 'pending',
RUNNING = 'running',
SUCCEEDED = 'succeeded',
FAILED = 'failed'
}
export enum TaskAction {
START = 'start',
ADVANCE = 'advance',
COMPLETE = 'complete',
FAIL = 'fail',
RETRY = 'retry'
}
export enum SurfaceMode {
HIDDEN = 'hidden',
BALL = 'ball',
WINDOW = 'window'
}
export class TaskRecord {
id: string;
title: string;
phase: TaskPhase;
progress: number;
errorText: string;
constructor(id: string, title: string, phase: TaskPhase,
progress: number, errorText: string = '') {
this.id = id;
this.title = title;
this.phase = phase;
this.progress = progress;
this.errorText = errorText;
}
}
export class TaskQueue {
transition(task: TaskRecord, action: TaskAction,
increment: number = 0, reason: string = ''): TaskRecord {
if (action === TaskAction.START && task.phase === TaskPhase.PENDING) {
return new TaskRecord(task.id, task.title, TaskPhase.RUNNING, 0);
}
if (action === TaskAction.ADVANCE && task.phase === TaskPhase.RUNNING) {
const value: number = Math.min(99, Math.max(0, task.progress + increment));
return new TaskRecord(task.id, task.title, TaskPhase.RUNNING, value);
}
if (action === TaskAction.COMPLETE && task.phase === TaskPhase.RUNNING) {
return new TaskRecord(task.id, task.title, TaskPhase.SUCCEEDED, 100);
}
if (action === TaskAction.FAIL && task.phase === TaskPhase.RUNNING) {
return new TaskRecord(task.id, task.title, TaskPhase.FAILED,
task.progress, reason);
}
if (action === TaskAction.RETRY && task.phase === TaskPhase.FAILED) {
return new TaskRecord(task.id, task.title, TaskPhase.PENDING, 0);
}
return task;
}
}
进度上限设为 99 的原因,是进度回调与业务提交成功并不完全等价。只有任务执行器明确返回成功后才切换到 SUCCEEDED 并显示 100%。同样,RETRY 先回到 PENDING,由执行器重新启动,不能仅修改页面文本就把失败变成进行中。transition() 对不合法动作返回原对象,调用方可以比较引用判断是否发生了有效变化。
一个状态源驱动两种轻量外观
在 entry/src/main/ets/pages/Index.ets 中,页面持有队列快照及外观模式。这里使用 Stack 放置工作台与应用内轻量交互层,重点展示球态到窗态的展开、收起及队列反馈。SurfaceMode 以后可以与窗口承载模块对接,但业务代码无需知道具体窗口实例。
import { TaskAction, TaskPhase, TaskQueue, TaskRecord,
SurfaceMode } from '../model/TaskQueue';
@Entry
@Component
struct Index {
private readonly reducer: TaskQueue = new TaskQueue();
@State tasks: TaskRecord[] = [
new TaskRecord('archive', '文档归档', TaskPhase.RUNNING, 36),
new TaskRecord('images', '图片分类', TaskPhase.PENDING, 0),
new TaskRecord('report', '报告生成', TaskPhase.FAILED, 62, '源文件读取失败')
];
@State surface: SurfaceMode = SurfaceMode.BALL;
private change(id: string, action: TaskAction,
increment: number = 0): void {
this.tasks = this.tasks.map((item: TaskRecord) => {
if (item.id !== id) {
return item;
}
return this.reducer.transition(item, action, increment);
});
}
private activeCount(): number {
return this.tasks.filter((item: TaskRecord) =>
item.phase === TaskPhase.RUNNING ||
item.phase === TaskPhase.PENDING).length;
}
@Builder
private ballContent() {
Button(`${this.activeCount()} 项`)
.height(56).borderRadius(28)
.backgroundColor('#266BF1')
.onClick(() => { this.surface = SurfaceMode.WINDOW; })
}
@Builder
private windowContent() {
Column({ space: 12 }) {
Row() {
Text('轻任务').fontSize(20).fontWeight(FontWeight.Bold)
Blank()
Button('收起').fontSize(13)
.onClick(() => { this.surface = SurfaceMode.BALL; })
}.width('100%')
ForEach(this.tasks, (item: TaskRecord) => {
Column({ space: 7 }) {
Row() {
Text(item.title).fontSize(16)
Blank()
Text(item.phase === TaskPhase.SUCCEEDED ? '完成' :
item.phase === TaskPhase.FAILED ? '失败' : `${item.progress}%`)
.fontSize(13)
}.width('100%')
Progress({ value: item.progress, total: 100,
type: ProgressType.Linear }).width('100%')
if (item.phase === TaskPhase.FAILED) {
Text(item.errorText).fontSize(12).fontColor('#C44E4E')
Button('重试').onClick(() => this.change(item.id, TaskAction.RETRY))
} else if (item.phase === TaskPhase.PENDING) {
Button('开始').onClick(() => this.change(item.id, TaskAction.START))
} else if (item.phase === TaskPhase.RUNNING) {
Button('推进进度').onClick(() =>
this.change(item.id, TaskAction.ADVANCE, 10))
}
}.padding(10).borderRadius(12).backgroundColor('#F7F9FC')
}, (item: TaskRecord) => item.id)
}
.padding(18).width(320)
.backgroundColor(Color.White).borderRadius(22)
.shadow({ radius: 16, color: '#22000000', offsetY: 4 })
}
build() {
Stack({ alignContent: Alignment.BottomEnd }) {
Column({ space: 14 }) {
Text('文件整理工作台').fontSize(26).fontWeight(FontWeight.Bold)
Text('任务执行进度与轻量窗口状态分离')
.fontSize(15).fontColor('#64748B')
Button('展开任务').onClick(() => {
this.surface = SurfaceMode.WINDOW;
})
}.padding(24).width('100%').height('100%')
if (this.surface === SurfaceMode.BALL) {
this.ballContent();
} else if (this.surface === SurfaceMode.WINDOW) {
this.windowContent();
}
}
.padding(16).width('100%').height('100%')
}
}
这段代码把三个重要动作拆开:change() 只修改任务记录,surface 只负责展示,activeCount() 从任务快照计算得出。即使窗口收起,队列也不会被清空。实际项目中,应把 TaskQueue 放入共享的业务服务,通过显式订阅或统一状态容器将快照推送到不同页面;这里先在一个页面里呈现完整状态流,便于观察状态约束。

把业务事件与进度反馈分开
页面上的“推进进度”是一个便于观察状态流转的交互入口。接入真实文件整理流程时,进度只能由执行器更新,而不能靠定时增加一个固定百分比。执行器应报告任务 ID、已完成工作量和总工作量,再由业务层计算百分比,同时去重已经处理过的事件。
export class ProgressEvent {
taskId: string;
completedUnits: number;
totalUnits: number;
sequence: number;
constructor(taskId: string, completedUnits: number,
totalUnits: number, sequence: number) {
this.taskId = taskId;
this.completedUnits = completedUnits;
this.totalUnits = totalUnits;
this.sequence = sequence;
}
}
export function percentOf(event: ProgressEvent): number {
if (event.totalUnits <= 0) {
return 0;
}
return Math.min(99, Math.max(0,
Math.floor(event.completedUnits * 100 / event.totalUnits)));
}
建议为每个任务维护最后一次处理的 sequence。当网络重连、后台恢复或多个进度来源同时回调时,忽略序号小于等于最新记录的事件,避免进度从 65% 回退到 40%。如果工作量总数会动态变化,不应依赖百分比绝对单调,可以把“已处理数量 / 当前总量”一起展示,说明进度变化原因。

让更新顺序成为显式契约
执行器发送事件时,要区分“收到一条进度信息”和“确认业务状态已经提交”。一个实用做法是每项工作都维护递增事件序号,在队列入口检查序号,再依次更新任务状态、生成新的记录快照、通知视图刷新。失败后的重试应创建新的执行世代:旧世代迟到的成功回调即使序号更大,也应被忽略。这样既可以防止窗口切换期间出现反复跳动的数字,也能避免用户点击重试后被上一轮执行结果覆盖。
当任务数越来越多时,球态可以展示待处理总量,窗态仅展示最近几项,完整列表继续留在业务页。三处界面共享 ID 和阶段语义,而不是分别维护“完成数量”“窗态状态”和“页面进度”。日志排查也应以任务 ID 与执行世代为关联字段,把状态从哪里变化、被哪个界面展示区分清楚,便于定位跨页面的数据更新问题。
窗口切换之前先排除这四种故障
重复点击造成任务启动两次。 PENDING 进入 RUNNING 的状态转换必须由业务层统一控制。按钮的禁用态只是视觉反馈,不足以代替执行器的幂等判断。任务 ID 可以作为执行操作的幂等键,重复请求应返回同一执行实例的状态。
收起后进度消失。 如果任务记录保存在窗体组件内部,收起导致组件销毁时会丢失状态。本设计由工作台持有队列,窗体只消费快照。后续跨页面和跨窗口时再把状态源提到业务服务,并设计重订阅机制。
失败后立即显示成功。 检查是否把进度 100 作为完成条件。应当把执行结果与进度事件拆开,只有完成事件能触发 COMPLETE。失败事件要包含可操作的错误类别,例如读取权限、网络中断或文件校验错误。
任务列表顺序更新时组件跳动。 ForEach 的键使用稳定 id;同时控制任务列表排序策略。比如处理中任务按开始时间排序,完成任务在底部暂留一段时间,不要每来一次进度事件就重排全部卡片。
后续如何接入窗口承载层
HarmonyOS 7 的闪控交互值得关注,但业务可用性首先取决于队列状态是否可靠。这个阶段已经把 TaskQueue、SurfaceMode 和展示组件分开。下一篇会围绕窗口尺寸与布局反馈完善轻量界面:在球态只显示摘要,在窗态显示可操作列表,随后逐步处理窗口切换一致性、触控反馈、前后台恢复、异常降级及高频刷新。每次扩展都让一个新的界面行为依赖既有状态源,而不是增加另一份独立任务副本。
参考资料
更多推荐

所有评论(0)