工单页面有三个“完成”按钮,单独点每一个都能保存。现场人员却常常一次巡检多台设备:勾选“配电柜温度复核”“应急灯回路确认”和“网络机柜标签核对”,连续点两次“批量完成”,或者在第一次提交尚未结束时修改其中一条标题。单次操作路径看起来没有错误,重启后却可能只有部分工单被改动,页面仍说“全部成功”。这不只是按钮防抖问题,而是多个写盘意图之间缺少确定的执行顺序与失败边界。

“巡点台”前三篇保留了一组刻意简单的事实:Ticket 仅包含 id、title、done,初始 T001—T003 使用稳定 ID;Preferences 文件名是 patrol_records,快照键为 tickets_v1,待恢复日志键为 pending_ops_v1。第三篇的 LocalJournal.commit() 实现了单个 setDone 或 setTitle 的日志先行、快照写入和幂等恢复。现在新增的是串行处理多个用户意图,不是把三条工单包装成一个不存在的数据库事务。

巡点台串行写盘工程主题封面

一、两条 flush 并行,错误不一定当场出现

先设想两次点击同时进入 commit():操作 A 把 T001.done 设为 true,操作 B 把 T002.done 设为 true。A 和 B 可能先后读出同一份 tickets_v1,分别算出自己的新数组,最后一次覆盖整个 JSON 字符串。即使两次 flush() 都返回成功,最终快照也可能只包含 B 的修改。await 只限制当前函数的后续语句,不会自动阻止另一条事件处理路径在等待期间进入同一 Preferences 实例。

第三篇已经解释过两个键不构成跨键原子事务。本篇再补上一层:应用必须有一个写入负责人,同时只准一个工单日志操作进入 LocalJournal.commit()。其他提交要么明确拒绝,要么进入进程内 FIFO 队列。这次选后者,便于保留用户连续点选的顺序;但界面上“已排队”不能翻译成“已写入磁盘”。队列中的意图尚未持久化,如果进程终止,它们不具备恢复承诺。只有开始执行并成功写入 pending_ops_v1 的当前操作,才受前文的本地日志保护。

批量操作还需要产品语义:比如三个工单中 T001 原本就已完成,重新设置 done=true 不应再次制造“完成次数”;如果某条 ID 不存在,也不应悄悄创建一张新工单。实现选择按选中顺序逐条设为完成,遇错误立刻停止。这样可解释、容易复现,但不是“要么全部完成,要么一条都不完成”。如果业务要求批次原子性,应转向支持事务的存储设计。

二、让写入队列只处理确定目标值

工单列表使用 @State tickets 供界面显示,选中集合单独存在 UI 层。不能把 @State 数组直接交给并发操作反复修改;真实数据的更新必须来自每次 journal.commit() 成功返回的快照。为了让同一批次可读,我们用普通数组保存待处理的 ID。事件触发时复制勾选 ID,防止用户在排队时继续勾选,导致已经排队的提交目标发生变化。

新增 entry/src/main/ets/common/BatchCommitter.ts。下列控制器直接复用上篇 LocalJournal 与 PatrolOp,没有更换 Ticket 字段或 Preferences 键名。它负责排队、串行和故障封锁;具体的 UI 响应通过回调交还页面。

import { LocalJournal } from './LocalJournal';
import { Ticket, PatrolOp } from '../model/PatrolTypes';

export class BatchIntent {
  ids: string[];
  constructor(ids: string[]) { this.ids = [...ids]; }
}

export class BatchCommitter {
  private queue: BatchIntent[] = [];
  private draining: boolean = false;
  private blocked: boolean = false;
  private seq: number = 0;
  private journal: LocalJournal;
  private onSaved: (rows: Ticket[]) => void;
  private onStatus: (message: string) => void;

  constructor(journal: LocalJournal,
    onSaved: (rows: Ticket[]) => void,
    onStatus: (message: string) => void) {
    this.journal = journal;
    this.onSaved = onSaved;
    this.onStatus = onStatus;
  }

  enqueue(ids: string[]): void {
    if (this.blocked || ids.length === 0) { return; }
    const unique: string[] = [...new Set(ids)];
    this.queue.push(new BatchIntent(unique));
    this.onStatus(`已排队 ${this.queue.length} 批,尚未全部保存`);
    void this.drain();
  }

  private async drain(): Promise<void> {
    if (this.draining || this.blocked) { return; }
    this.draining = true;
    try {
      while (this.queue.length > 0) {
        const batch = this.queue.shift() as BatchIntent;
        for (const id of batch.ids) {
          const op: PatrolOp = {
            opId: `batch-${Date.now()}-${++this.seq}`,
            ticketId: id, kind: 'setDone', done: true
          };
          const rows = await this.journal.commit(op);
          this.onSaved(rows);
        }
        this.onStatus('本批工单已逐条保存');
      }
    } catch (err) {
      this.blocked = true;
      this.queue = []; // 未落盘的排队意图不冒充可恢复日志
      this.onStatus('保存中断:请重新打开并完成日志恢复');
    } finally {
      this.draining = false;
    }
  }
}

这里 Set 仅用于去掉当前批次重复的 ID,并没有把所有批次合并掉。不同批次可重复提交同一条记录,但提交的是 done:true 这一确定值,重复执行仍得到相同业务结果。opId 是当前实例的诊断标识,不能用时间戳当作跨设备严格唯一键。队列的 draining 和 blocked 是非响应式内部控制状态;不要在它们上面随意叠加 @State,否则容易混淆业务写入序列与 UI 绘制的职责。

巡点台 DevEco Studio 白色主题中的 BatchCommitter 串行控制

三、页面如何处理勾选、成功和失败

页面上展示的“已选 2 项”来自勾选集合,而不是从 done 字段推导。为了避免把勾选集合修改与提交混在一起,WorkOrderPage.ets 使用 selectedIds: string[] 表示当前选择。旧列表项仍按 ticket.id 生成 ForEach 键;行组件继续走 @Observed / @ObjectLink,不把索引当稳定身份。

// 以下增量放入已有 WorkOrderPage;journal 在 boot() 恢复后创建
@State private selectedIds: string[] = [];
@State private savingNotice: string = '选择要处理的工单';
private batcher?: BatchCommitter;

private readyForBatch(journal: LocalJournal): void {
  this.batcher = new BatchCommitter(journal,
    (rows: Ticket[]) => {
      this.tickets = rows.map((x: Ticket) => new TicketModel(x));
    },
    (message: string) => { this.savingNotice = message; });
}

private toggleSelect(id: string): void {
  if (this.selectedIds.includes(id)) {
    this.selectedIds = this.selectedIds.filter((x: string) => x !== id);
  } else {
    this.selectedIds = [...this.selectedIds, id];
  }
}

private submitSelected(): void {
  if (!this.batcher || this.selectedIds.length === 0) { return; }
  const ids: string[] = [...this.selectedIds];
  this.selectedIds = [];
  this.batcher.enqueue(ids);
}

以上是加入既有 WorkOrderPage 的关键字段及方法,需保留原来的 TicketModel、boot()、渲染组件和 LocalJournal。进入页面前,先等待 recover() 正常完成,之后调用 readyForBatch();恢复失败不能继续创建提交器。如果标题编辑仍使用前篇的直接 saveDone 路径,必须让它也经过相同的串行入口,或在批处理期间禁用编辑入口。否则“批量”已串行,编辑动作却仍可以绕开队列,竞争问题并没有根治。

按钮文案也有严格含义:点击“批量完成”后,可以显示“已排队”,不能立即显示“全部保存成功”。每完成一个 commit,回调才更新屏幕。为了减少误操作,还应让排队失败时的提示保持在页面显眼位置;不能因下一次滚动或页面重建就自动把“写盘失败”替换成绿色成功。旧的未持久化队列在失败时被清空,操作者需要核对已经落盘的工单,再提交剩余项。

巡点台批量工单的 9:16 页面与已选状态

四、四类交错操作必须单独验证

第一类是连续提交两个不同批次:先选 T001、T002,再选 T003。预期执行顺序为 T001 → T002 → T003;不论写盘完成的时点如何变化,第二批都不能抢在第一批中间。第二类是重复 ID:同一批中出现两次 T001 时,只形成一个实际操作;不同批次的 setDone(true) 不会把已完成记录切回未完成。第三类是在第二条写入期间注入 flush 异常:第一条可能已落盘,第二条需要通过第三篇日志恢复,第三条尚未开始,界面不得宣称“三条全部成功”。第四类是重启后先执行 recover(),再开放批量按钮;如果日志损坏,要保留原始数据和明确错误,不能以空列表覆盖。

这些用例的通过标准不是某张漂亮截图,而是读取持久化键后检查内容,同时确认列表与提示的语义一致。可以通过开发版注入故障,并分别观察 pending_ops_v1 和 tickets_v1;这些步骤属于可复现的验证方案,不等于已经获得真机实测结果。还要在“批量提交中快速退出页面”场景下避免销毁组件时触发第二次独立提交,写入负责人应有清楚的生命周期归属。

两批工单逐条写入、失败封锁和恢复关系图

本轮的设计取舍是明确的:用单进程 FIFO 队列换来可证明的提交顺序,用操作日志尽量处理当前落盘动作的中断窗口,代价是批量操作可能部分成功,而且排队中的尚未执行操作并未持久化。它适用于轻量、单人单进程的巡检工具;若需要多端同时编辑、事务性批量提交或审计留存,就必须引入带事务/版本管理的数据层。下一篇可以围绕数据导入、版本迁移和故障诊断继续升级,而不是把更多业务塞进一个 JSON 键。

另一个适合代码评审的检查点,是把“用户想做的事”与“系统已经承诺的事”分开展示:enqueue() 返回并不表示 commit() 已完成,刷新 UI 的成功回调只能发生在每条操作写盘之后。即使界面要显示进度条,也应该分成“排队数”“当前处理项”“已确认项”,而不是拿 queue.length === 0 当作全部持久化成功;因为最后一项可能已经从数组移除,却还在等待 flush()。此外,如果操作者打开多个页面实例同时写同一个 Preferences 文件,单个页面里的队列也挡不住另一实例,存储协调仍应提升到统一的数据服务对象。本方案的适用前提必须随着工程规模改变而重新评估。

参考华为开发者文档:Preferences API、ForEach 循环渲染、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-observed-and-objectlink/@Observed 与 @ObjectLink。

Logo

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

更多推荐