HarmonyOS 7闪控窗与闪控球连载03(共08篇):球窗切换的状态一致性与幂等控制【共创稿事节】
文件整理工作台已经建立任务队列,也设计了紧凑和展开两种信息密度。接下来的问题出现在切换那一瞬间:用户点击球态入口,展开任务窗的过程中恰好有一项归档任务完成;如果球态与窗态各自维护进度,很容易出现球上还显示“3项进行中”,展开后却只看到2项。反复点击、返回以及页面重建也可能造成窗口叠加、任务进度回退或按钮被重复响应。

与其把“球变成窗”当作一次组件创建,不如把它当作展示状态的一次受控迁移。本节延续 TaskRecord、TaskQueue、TaskPhase 和 SurfaceMode 四个概念:队列持有任务事实,展示层只决定当前画面是隐藏、球态还是窗态。本节讨论的控制逻辑位于应用可复用的业务层;接入具体系统窗口承载时,仍以当前公开接口和对应开发套件的要求为准。
一个任务队列,两条相互独立的状态轴
任务执行状态与轻量窗口形态不能合成一个枚举。任务可能处于等待、运行、成功或失败;窗口可能隐藏、折叠或展开。二者组合后得到的画面不应该反向改变任务是否已经成功。
| 事件 | 展示状态变更 | 任务队列变更 | 必须保持的约束 |
|---|---|---|---|
| 用户点击球态 | BALL → WINDOW | 无 | 当前任务 ID 不变 |
| 用户收起任务窗 | WINDOW → BALL | 无 | 活跃任务继续运行 |
| 整理任务完成 | 无 | RUNNING → SUCCEEDED | 两种形态读取相同快照 |
| 用户快速连点 | 合并重复切换请求 | 无 | 不创建多个窗口实例 |
| 窗口被关闭 | → HIDDEN | 无 | 执行器与显示层解耦 |
TaskQueue 在前两篇已经定义了合法的任务阶段迁移,这里不再另建第二个任务容器,而是增加一个负责展示行为的 SurfaceController。控制器只接受意图,不接受来自子组件的“自行改写状态”。这样球态和窗态的入口都可以调用同一处转换函数。
把切换命令设计成幂等操作
一个常见实现是球态点击时 surfaceMode = WINDOW,窗态返回时 surfaceMode = BALL。但业务快速收到两次点击会怎样?如果事件来自动画结束、手势返回和父页面按钮,执行次序可能不同。可为展示状态添加递增版本,所有动作都在控制器中处理,并把“设置为某个状态”与“翻转当前状态”明确区分。
export enum SurfaceMode {
HIDDEN = 'hidden',
BALL = 'ball',
WINDOW = 'window'
}
export interface SurfaceSnapshot {
mode: SurfaceMode;
version: number;
activeTaskId: string;
}
export class SurfaceController {
private value: SurfaceSnapshot = {
mode: SurfaceMode.BALL, version: 0, activeTaskId: ''
};
private listeners: Array<(state: SurfaceSnapshot) => void> = [];
snapshot(): SurfaceSnapshot { return { ...this.value }; }
subscribe(fn: (state: SurfaceSnapshot) => void): () => void {
this.listeners.push(fn);
fn(this.snapshot());
return () => { this.listeners = this.listeners.filter((x) => x !== fn); };
}
setMode(mode: SurfaceMode): void {
if (mode === this.value.mode) { return; }
this.value = { ...this.value, mode, version: this.value.version + 1 };
this.listeners.forEach((fn) => fn(this.snapshot()));
}
focusTask(id: string): void {
if (id === this.value.activeTaskId) { return; }
this.value = { ...this.value, activeTaskId: id,
version: this.value.version + 1 };
this.listeners.forEach((fn) => fn(this.snapshot()));
}
}
这里 setMode(WINDOW) 被多次调用也只产生一次有效状态变化。与直接写 toggle() 相比,显式目标状态更适合来自异步任务、页面恢复和点击事件的多来源调用。version 描述展示状态的更新顺序,可用于排查“旧窗口回调覆盖新选择”的竞态,但它并非任务执行序号,不能替代 TaskRecord 的进度版本。
从任务快照派生球态摘要
球态需要一个简短数字,窗态需要完整任务列表。若球态维护 badgeCount,窗态维护 visibleTasks,两者分别接收异步通知,迟早会出现短时分歧。更稳定的方式是让它们从同一份 TaskRecord[] 推导展示数据,而不是让摘要再次成为可写状态。
export enum TaskPhase {
WAITING = 'waiting', RUNNING = 'running',
SUCCEEDED = 'succeeded', FAILED = 'failed'
}
export interface TaskRecord {
id: string;
title: string;
phase: TaskPhase;
progress: number;
revision: number;
}
export interface BallSummary {
activeCount: number;
failedCount: number;
leadingProgress: number;
}
export function buildBallSummary(tasks: TaskRecord[]): BallSummary {
const active = tasks.filter((t) =>
t.phase === TaskPhase.WAITING || t.phase === TaskPhase.RUNNING);
const failed = tasks.filter((t) => t.phase === TaskPhase.FAILED);
const leading = active.find((t) => t.phase === TaskPhase.RUNNING);
return {
activeCount: active.length,
failedCount: failed.length,
leadingProgress: leading ? leading.progress : 0
};
}
当三项任务中有一项刚完成时,下一次队列快照生成后,两种展示都基于相同数组计算。无需在完成回调中同时执行“更新球上的数字”和“删除窗内的条目”。这样也避免了状态重复写入引起的额外重绘。
展示模型需要由页面统一订阅
下面使用普通 ArkUI @State 保存视图可感知的快照,并在组件生命周期内建立、解除订阅。核心原则是:订阅回调只接收不可共享修改的值对象,子组件不要直接操作控制器的内部数组。SurfaceController 可由应用层以单例服务方式管理,页面销毁时解除监听,避免重复进入页面后同一个状态被分发多次。
const surfaceController: SurfaceController = new SurfaceController();
@Component
struct SurfaceSwitcher {
@State surface: SurfaceSnapshot = surfaceController.snapshot();
@State tasks: TaskRecord[] = [];
private stopSurface?: () => void;
aboutToAppear(): void {
this.stopSurface = surfaceController.subscribe((next: SurfaceSnapshot) => {
this.surface = { ...next };
});
}
aboutToDisappear(): void {
this.stopSurface?.();
this.stopSurface = undefined;
}
build() {
Column() {
if (this.surface.mode === SurfaceMode.BALL) {
Button(`任务 ${buildBallSummary(this.tasks).activeCount}`)
.onClick(() => surfaceController.setMode(SurfaceMode.WINDOW))
} else if (this.surface.mode === SurfaceMode.WINDOW) {
Column({ space: 12 }) {
Text('文件整理任务').fontSize(20).fontWeight(FontWeight.Bold)
ForEach(this.tasks, (task: TaskRecord) => {
Row() {
Text(task.title).layoutWeight(1)
Text(`${task.progress}%`)
}
}, (task: TaskRecord) => task.id)
Button('收起').onClick(() => surfaceController.setMode(SurfaceMode.BALL))
}.padding(16)
}
}
}
}
这里的 tasks 要从前两篇的 TaskQueue 订阅获得,不能长期保留为空数组;它作为界面快照的一个可观察入口,在任务队列发生变化时整体替换为新数组。实际开发建议在入口组件统一管理任务订阅,再把当前列表作为只读数据传给球态和窗态组件,避免它们各自连接执行器。ForEach 使用任务 ID 作为键,防止排序或任务完成后复用错误的卡片。

使用版本控制隔离过期任务事件
展示形态可以切换,任务进度的顺序则必须单调推进。假设文件整理服务先后产生 revision=6 的80%和 revision=7 的100%,网络或消息队列使二者乱序到达,旧消息不能覆盖成功结果。上一节的 TaskQueue 应在任务合并时校验每个 ID 的事件版本。
export interface TaskUpdate {
id: string;
revision: number;
progress: number;
phase: TaskPhase;
}
export function applyTaskUpdate(tasks: TaskRecord[], event: TaskUpdate): TaskRecord[] {
return tasks.map((task: TaskRecord) => {
if (task.id !== event.id || event.revision <= task.revision) {
return task;
}
if (task.phase === TaskPhase.SUCCEEDED) { return task; }
const progress = Math.max(task.progress, Math.min(100, Math.max(0, event.progress)));
return { ...task, phase: event.phase,
progress: event.phase === TaskPhase.SUCCEEDED ? 100 : progress,
revision: event.revision };
});
}
该函数采用新的数组返回值,便于 @State 接收变化;成功态作为终态处理,后续失败或进度事件不会使其回退。如果任务支持重试,应生成新的执行尝试标识,或者对任务阶段实施显式的重试状态转换,不能把旧的成功任务直接改写为运行中。此处 revision 来自任务事件源,不能借用 SurfaceController.version。
交互竞态的复盘顺序
可用以下顺序检查球窗切换:在一项任务运行中连续点击展开入口,确认没有重复窗口;在窗态完成任务后收起,确认球态摘要使用最新任务列表;从其他业务页面返回,确认订阅数量没有增加;在 WINDOW 状态收到隐藏请求后,确认更新任务的事件仍可进入队列,但不触发无关窗口重建。
遇到“窗态是100%,球态是80%”,优先核对两个组件拿到的 revision 是否一致;如果一致而显示不同,检查它们的摘要计算与绑定对象;如果版本不同,检查订阅是否被及时释放或重新建立。遇到“快速连点出现两层窗”,检查是否把按钮事件直接写成了创建窗口的命令,而没有经过 setMode 的幂等判断。遇到“切回球态就停止处理”,则检查任务执行器是否被错误绑定在窗态组件生命周期上。

与后续系统窗口承载的连接点
这一套代码把任务事实、展示状态与可视组件分开,不依赖某个特定窗口创建接口。实际对接窗口承载层时,可将 setMode 的结果映射为对应的显示、收起或关闭动作,并把承载层的关闭回调归一化为控制器事件。映射层应确保一个业务状态只对应一个有效窗口实例;若窗口接口要求权限、能力检查或者生命周期注册,应在映射层处理,不把相关分支散布在每张任务卡片中。
本节解决的是切换过程中状态不分叉。下一节将进一步处理触控操作与进度反馈:用户在球态点开任务、在窗态暂停或重试时,如何确保可点击操作与业务状态转换互相匹配,避免双击和迟到回调改变任务事实。
参考资料
更多推荐

所有评论(0)