用户在阅读资料、处理订单或上传文件时,往往不愿意一直留在同一个业务页面等待进度。轻量入口可以把“当前还有什么正在处理”放在更容易触达的位置:收起时只留下任务数量与最重要的状态,展开时再显示具体任务、进度和可操作项。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 放入共享的业务服务,通过显式订阅或统一状态容器将快照推送到不同页面;这里先在一个页面里呈现完整状态流,便于观察状态约束。

DevEco Studio 中的轻任务队列状态实现

把业务事件与进度反馈分开

页面上的“推进进度”是一个便于观察状态流转的交互入口。接入真实文件整理流程时,进度只能由执行器更新,而不能靠定时增加一个固定百分比。执行器应报告任务 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 和展示组件分开。下一篇会围绕窗口尺寸与布局反馈完善轻量界面:在球态只显示摘要,在窗态显示可操作列表,随后逐步处理窗口切换一致性、触控反馈、前后台恢复、异常降级及高频刷新。每次扩展都让一个新的界面行为依赖既有状态源,而不是增加另一份独立任务副本。

参考资料

Logo

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

更多推荐