文件整理工作台已经建立任务队列,也设计了紧凑和展开两种信息密度。接下来的问题出现在切换那一瞬间:用户点击球态入口,展开任务窗的过程中恰好有一项归档任务完成;如果球态与窗态各自维护进度,很容易出现球上还显示“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 的结果映射为对应的显示、收起或关闭动作,并把承载层的关闭回调归一化为控制器事件。映射层应确保一个业务状态只对应一个有效窗口实例;若窗口接口要求权限、能力检查或者生命周期注册,应在映射层处理,不把相关分支散布在每张任务卡片中。

本节解决的是切换过程中状态不分叉。下一节将进一步处理触控操作与进度反馈:用户在球态点开任务、在窗态暂停或重试时,如何确保可点击操作与业务状态转换互相匹配,避免双击和迟到回调改变任务事实。

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐