巡检现场的网络往往不像办公室那样稳定。值班员走到配电间,勾选“备用电源”后进入没有信号的通道;等他回到有网络的位置,页面里的勾选还在,却无法判断这笔操作究竟有没有交到确认端。此时最危险的交互不是“提示失败”,而是把本地操作完成写成远端确认成功。ShiftCheck 前三篇依次解决了任务状态更新、草稿可靠落盘和版本冲突,本篇将两者之间最后一段空白补上:确认请求先落入本机待办,再由一个受控的串行流程补交。

巡检断线待办与补交机制封面

本篇仍沿用 CheckTask { id, title, location, done } 和三条巡检任务:“机柜温控”“交换机端口”“备用电源”;tasks_v1 仍是用户当前勾选情况的本地草稿键,存储文件仍叫 shiftcheck_draft_v1。上一章的接收端 RevisionGateway 保存版本号,从 7 开始;ConfirmResult 返回 accepted、serverVersion 和权威 tasks。要特别明确:此前的 RevisionGateway 是内存示例接收端,不是已接通的服务器。本章同样不会凭空声称存在远端 API 或网络成功记录。

一、不要让“请求失败”和“请求未处理”成为同一个状态

假设用户确认一项任务,接收端已经更新了版本,但响应在传回设备时丢失。客户端收到超时,只能确定“没有拿到结果”,并不能确定“接收端没有执行”。如果简单再次发送,接收端可能把同一笔操作当作新命令,重复递增版本。反过来,在真正断网时根本没有请求发出,应该安全保留待办,待恢复后再交。两种情况对 UI 都显示“待确认”,但补交协议必须理解区别。

因此给每次人工确认分配一个稳定的 requestId。它是业务操作的身份,与巡检任务 taskId 不同:用户两次修改同一任务属于两次操作,不能共享请求号;一次操作的网络重试则必须一直使用同一个号。示例中使用“启动时刻 + 页面内递增序号”生成示例 ID,真实生产环境必须采用跨重启、跨设备足够唯一的标识,推荐由服务端或可靠的 UUID 方案统一确定,而不能把数组下标当作主键。

// entry/src/main/ets/common/PendingConfirm.ets
export interface PendingConfirm {
  requestId: string;
  taskId: string;
  wantedDone: boolean;
  createdAtMs: number;
}

export interface PendingEnvelope {
  schema: number;
  commands: PendingConfirm[];
}

export function readPending(json: string): PendingConfirm[] {
  if (json.length === 0) { return []; }
  const value: PendingEnvelope = JSON.parse(json) as PendingEnvelope;
  if (value.schema !== 1 || !Array.isArray(value.commands) ||
      value.commands.length > 30) {
    throw new Error('待确认队列格式不受支持');
  }
  for (const item of value.commands) {
    if (typeof item.requestId !== 'string' ||
        typeof item.taskId !== 'string' ||
        typeof item.wantedDone !== 'boolean' ||
        !Number.isFinite(item.createdAtMs)) {
      throw new Error('待确认记录字段损坏');
    }
  }
  return value.commands;
}

把待办队列限制在 30 条,是个人示例工程的防护阈值,不是 Preferences 的系统限制。超过上限时应拒绝继续收取新勾选并引导先处理历史待办,而不是静默删除最早的请求。JSON.parse 的异常必须被捕获并展示“记录需要检查”,不能用空数组覆盖损坏数据,否则用户尚未提交的操作就被抹掉了。

二、先写入待办,再给用户明确的反馈

我们把保存顺序改为:生成命令 → 待办持久化 → 更新页面草稿 → 等待确认。关键之处是,点击时先保存包含该命令的队列快照,等 flush() 成功才把当前操作显示为“可补交”。如果持久化失败,保留原页面和原任务状态,防止用户被错误的成功提示误导。

继续使用前篇已经取得的 preferences.Preferences 实例 store;以下代码作为 Index.ets 的新增成员与替换方法,与先前的 @State tasks: CheckTask[]、ready 和 queueDraft() 配合。示例采用同一时刻只处理一个待确认操作的交互策略,避免本地排队期间用户反复切换同一个任务产生含义相反的命令;这并非业务上必须单条排队,而是有意收窄可讲清楚的边界。

// Index.ets:新增状态和本地保存操作
@State private pending: PendingConfirm[] = [];
@State private pendingText: string = '无待确认操作';
@State private replayBusy: boolean = false;
@State private demoOnline: boolean = false;
private requestSerial: number = 0;
private pendingWrite: Promise<void> = Promise.resolve();

private savePending(next: PendingConfirm[]): Promise<void> {
  if (this.store === null) {
    return Promise.reject(new Error('Preferences 未就绪'));
  }
  const target: preferences.Preferences = this.store;
  const body: string = JSON.stringify({ schema: 1, commands: next });
  const write: Promise<void> = this.pendingWrite.catch(() => {}).then(async () => {
    target.putSync('pending_confirm_v1', body);
    await target.flush();
  });
  this.pendingWrite = write;
  return write;
}

private async enqueueConfirm(id: string): Promise<void> {
  if (!this.ready || this.replayBusy || this.pending.length > 0) { return; }
  const oldTask: CheckTask | undefined = this.tasks.find(
    (item: CheckTask) => item.id === id);
  if (!oldTask) { return; }
  const nextDone: boolean = !oldTask.done;
  const command: PendingConfirm = {
    requestId: `sc-${Date.now()}-${++this.requestSerial}`,
    taskId: id, wantedDone: nextDone, createdAtMs: Date.now()
  };
  const next: PendingConfirm[] = [...this.pending, command];
  try {
    await this.savePending(next);
    this.pending = next;
    this.tasks = this.tasks.map((item: CheckTask): CheckTask =>
      item.id === id ? { ...item, done: nextDone } : item);
    this.queueDraft();
    this.pendingText = '已保存本机待办,尚未确认';
  } catch (_) {
    this.pendingText = '待办写入失败,本次操作未生效';
  }
}

这里的 putSync() 只把键值提交给 Preferences 对象,后续 flush() 才完成持久写入流程,因而不能在调用 putSync() 之后立刻报“本机已保存”。官方的 Preferences API 参考 明确提供键值读写和持久化能力;该组件适合这种少量本地操作,不是多进程消息队列或跨设备数据库。

DevEco Studio 中的 PendingConfirm.ets 与巡检待办页面设计

三、重放按原请求号进行,绝不能“盲目加版本”

补交时只处理队首命令。接收端首先检查 requestId 是否已有回执,如果有就返回同一份回执;否则按上一章的 baseVersion 进行条件更新,并把新的结果连同请求号写入回执记录。注意:回执记账与版本更新必须是接收端同一原子事务。下面的 DemoCommandGateway 仅用于单进程交互说明,其 Map 会在进程结束后清空,不能替代真实服务端的幂等存储。

// entry/src/main/ets/common/DemoCommandGateway.ets
import { ConfirmResult, RevisionGateway } from './RevisionGateway';
import { PendingConfirm } from './PendingConfirm';

export class DemoCommandGateway {
  private backend: RevisionGateway = new RevisionGateway();
  private receipts: Map<string, ConfirmResult> = new Map();

  async send(item: PendingConfirm, baseVersion: number): Promise<ConfirmResult> {
    const old: ConfirmResult | undefined = this.receipts.get(item.requestId);
    if (old) { return old; }
    const result: ConfirmResult = await this.backend.confirm(
      item.taskId, item.wantedDone, baseVersion);
    if (result.accepted) { this.receipts.set(item.requestId, result); }
    return result;
  }

  simulateOtherOperator(): void { this.backend.simulateOtherOperator(); }
}

实际产品还必须处理一种棘手窗口:接收端已经确认,客户端在移除待办前崩溃。重新启动后,队首仍在,新的请求应沿用旧 requestId;如果服务端仍能查到原回执,才可以安全认领确认结果并删除本机队首。不能随手生成一个新请求号去“再试一次”。

// Index.ets:依次尝试队首,链路开关不是真实网络检测
private demoGateway: DemoCommandGateway = new DemoCommandGateway();
private async replayPending(): Promise<void> {
  if (this.replayBusy || !this.demoOnline || this.pending.length === 0) { return; }
  this.replayBusy = true;
  try {
    const item: PendingConfirm = this.pending[0];
    const result: ConfirmResult = await this.demoGateway.send(item, this.serverVersion);
    this.serverVersion = result.serverVersion;
    if (!result.accepted) {
      this.conflictTaskId = item.taskId;
      this.pendingText = '版本冲突,请人工处理';
      return;
    }
    const rest: PendingConfirm[] = this.pending.slice(1);
    await this.savePending(rest);
    this.pending = rest;
    this.pendingText = `示例接收端已确认 · 版本 ${this.serverVersion}`;
  } catch (_) {
    this.pendingText = '补交失败,队列仍在本机';
  } finally {
    this.replayBusy = false;
  }
}

demoOnline 只是一个受控链路开关:关闭时不发送,打开后由用户点击“尝试补交”调用 replayPending()。真实网络监听属于另外一个工程任务,不能把受控开关截图写成“断线自动重连已通过验证”。为了避免版本冲突时陷入无休止重试,代码在 accepted=false 时停住,把选择权留给用户。第 03 篇的“采用当前版本”“按新版本重试”逻辑可接在这个分支,但必须先确定如何处理原请求号及旧待办,不能自动按最新版本覆盖他人的巡检结果。

四、用本地场景验证状态,而不是把构造响应当作实测

先从上一章的 restoreDraft() 恢复 tasks_v1,再读取 pending_confirm_v1 形成待办队列。若队列里存在本机未交操作,页面优先显示“本机待确认”,而不是“已确认”;尤其是在应用重启之后,不应把本机 Preferences 的内容直接解释成服务端状态。测试时还要检查保存顺序造成的短暂状态不一致:队列已经写成功而草稿尚未写成功时,可根据队列重建对应任务的可见勾选,避免丢失用户意图。

ShiftCheck 本地待办、确认回执与冲突处理的完整竖屏效果设计

复现时,首先保持“受控链路离线”,勾选“备用电源”;应看到“待确认 1 条”和“已保存本机待办”。再次点击其他任务应被禁止,防止示例版发生多个未定义顺序的修改。其次切换“受控链路在线”,点击“尝试补交”;仅此时才会请求本进程的示例接收端。第三步在确认前调用 simulateOtherOperator(),制造版本变化;此时应该看到冲突并保留待办,不能吞掉。第四步在 savePending(rest) 前人为抛错,验证已经得到确认但无法移除队首时,页面仍保留待办与提示。恢复后同请求号的重放应由接收端去重。

页面可以保留上一章的三项任务、位置、完成状态和版本提示,再增加一条顶部状态横幅、待办数量、一个“受控链路在线”开关和“尝试补交”按钮。用真实部署服务时,再将 DemoCommandGateway 换成 HTTP 接口封装,接入连接超时、账号鉴权、服务端回执表及幂等键的有效期管理。系统权限与网络参数须按实际服务定义配置,不能把示例工程解释为已经具有 INTERNET 权限和联网能力。

从本地保存到幂等回执的串行补交流程

五、交付边界:待确认是业务事实,不是页面装饰

最容易犯的错误是在 UI 上只区分“成功”和“失败”。巡检应用至少需要“本地待办”“传输中”“接收端确认”“版本冲突”“本地存储失败”这五种不同含义,否则用户无法判断是否需要补做。对于可追责的现场业务,还必须保存操作员身份、接收端回执与审计轨迹;本篇的 Preferences 和内存接收端只能说明交互协议,不具备可靠审计属性。不要在 UIAbility.onBackground() 中临时执行耗时保存,官方 UIAbility 生命周期指南 说明后台回调应做轻量操作;关键待办应在用户动作发生时就开始持久化。

这次形成了从“本机操作”到“确认回执”的明确边界。下一篇可以在不改变 requestId 的原则下扩展批量同步、冲突汇总和队列压缩策略,重点研究大量任务下如何避免旧命令阻塞新命令。协议先正确,自动化才有意义。

Logo

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

更多推荐