HarmonyOS应用实战-启示散页-49-多窗口同时打开别写乱 Preferences:给写入操作加队列和版本校验

两个窗口同时打开同一题库:A 修改了答案,B 调整了顺序。两边都基于同一份旧对象保存时,后写入者会无声覆盖前者。单窗口下不明显的问题,在多窗口、快捷入口和恢复页并存时会变成数据丢失。

在这里插入图片描述

这篇文章解决四件事:

  1. 还原这个问题在答案之书这类离线应用里如何出现。
  2. 明确页面、Service、Repository、AppStorage 或发布清单各自的责任。
  3. 给出可迁移的 ArkTS/工程代码片段,并说明反例为什么会留下隐患。
  4. 用验证清单和排障表把方案收成可执行检查项。

在这里插入图片描述
在这里插入图片描述

局部按钮锁挡不住跨窗口写入

很多页面会在保存按钮上加 submitting,防止连点。但多窗口问题不是同一个按钮被连点,而是两个入口基于同一个 baseVersion 各自提交。解决点要放在仓储或服务边界,而不是每个页面自己加一把局部锁。

已核对的现状是:PreferencesStore 按 store name 打开实例,当前工程没有多窗口写入编排。本文的 DeckWriteCoordinator 是并发写入设计建议,不是当前已交付能力。

写入链路需要队列和版本两个维度

先写 owner 表,再写代码。否则代码能跑起来,却很难说明失败时该由谁回滚、重启后该由谁恢复、其他页面该根据什么信号刷新。

Owner 负责什么 不负责什么
DeckEditPage 提交草稿和展示冲突结果 决定覆盖策略
DeckWriteCoordinator 串行化同一 deckId 的写入 隐藏冲突
DeckRepository 读取最新版本并持久化 吞掉 baseVersion 不一致
ConflictResolverPage 让用户选择覆盖、合并或另存 直接改 Preferences

保存 payload 必须携带 baseVersion

模型要表达本链路需要的稳定事实,不要把页面临时状态或底层存储细节暴露出去。这样后续迁移 Preferences schema、拆模块或增加发布检查时,调用方不必跟着重写。

interface VersionedDeck {
  id: string;
  name: string;
  answers: Answer[];
  version: number;
  updatedAt: number;
}

interface SaveDeckPayload {
  deckId: string;
  baseVersion: number;
  nextAnswers: Answer[];
  operationId: string;
}

interface DeckSaveResult {
  saved: boolean;
  conflict: boolean;
  latestVersion: number;
  message: string;
}

这段模型的重点有三点:字段命名贴近业务;输入输出能覆盖失败分支;没有携带 ArkUI 组件状态。页面拿它展示,Service 拿它做判断,Repository 不需要知道页面长什么样。

同一个 deckId 的写入串行执行

Service 是规则 owner。凡是涉及校验、回滚、冲突、恢复、隐私或发布证据的逻辑,都不要散落在组件回调里。

class DeckWriteCoordinator {
  private tails: Map<string, Promise<void>> = new Map();

  async enqueue(payload: SaveDeckPayload): Promise<DeckSaveResult> {
    const previous = this.tails.get(payload.deckId) ?? Promise.resolve();
    let release: () => void = () => {};
    const current = new Promise<void>((resolve) => {
      release = resolve;
    });
    this.tails.set(payload.deckId, previous.then(() => current));
    await previous;
    try {
      return await this.saveWithVersionCheck(payload);
    } finally {
      release();
      if (this.tails.get(payload.deckId) === current) {
        this.tails.delete(payload.deckId);
      }
    }
  }

  private async saveWithVersionCheck(payload: SaveDeckPayload): Promise<DeckSaveResult> {
    const latest = await DeckRepository.loadVersionedDeck(payload.deckId);
    if (latest.version !== payload.baseVersion) {
      return { saved: false, conflict: true, latestVersion: latest.version, message: '题库已被其他窗口修改' };
    }
    await DeckRepository.saveVersionedDeck({ ...latest, answers: payload.nextAnswers, version: latest.version + 1, updatedAt: Date.now() });
    return { saved: true, conflict: false, latestVersion: latest.version + 1, message: '保存成功' };
  }
}

这里的 Service 不追求复杂抽象,只做一件事:把输入转成可解释结果。页面可以做乐观交互,但最终事实必须从 Service 返回。

Repository 维护版本,不让页面猜

Repository 负责稳定读写、默认值和 schema 兼容。它不弹 Toast,不决定按钮状态,也不拼页面文案。

class DeckRepository {
  static async loadVersionedDeck(deckId: string): Promise<VersionedDeck> {
    const deck = await PreferencesStore.getJson<VersionedDeck>('deck_store', deckId);
    if (!deck) {
      throw new Error(`题库不存在:${deckId}`);
    }
    return { ...deck, version: deck.version ?? 1 };
  }

  static async saveVersionedDeck(deck: VersionedDeck): Promise<void> {
    await PreferencesStore.setJson('deck_store', deck.id, deck);
    AppStorage.setOrCreate('deck.changedAt', deck.updatedAt);
  }
}

如果这一层缺失,页面会被迫知道 store name、key、默认值和异常处理细节。写到后面,所有页面都会变成半个仓储层。

冲突结果要回到页面,让用户选择

页面只消费结果、展示状态、触发动作。跨页面刷新用轻量信号,完整业务对象继续由 Service 重新读取。

@Component
struct DeckEditPage {
  @State private conflictMessage: string = '';

  private async saveDraft(): Promise<void> {
    const result = await new DeckWriteCoordinator().enqueue(this.toPayload());
    if (result.conflict) {
      this.conflictMessage = result.message;
      this.openConflictSheet(result.latestVersion);
      return;
    }
    this.backToDeckList();
  }
}

这类写法的好处是:入口可以扩展,页面可以重进,数据可以迁移。只要 Service 和 Repository 边界稳定,页面不需要关心底层怎么保存。

反例:短期省事,长期失控

反例是保存前只判断 this.saving,然后直接 PreferencesStore.setJson(deckId, nextDeck)。这个锁只保护当前页面实例,无法保护另一个窗口、恢复页或快捷入口正在提交的同一份数据。

更具体地说,反例通常有三个共同点:直接写持久化、没有失败结果、没有刷新 owner。它们在单次手测里很难暴露,但在重启、返回、跨入口或发布复查时会变成真实问题。

排查顺序:\n1. 先找唯一写入 owner。\n2. 再看失败是否返回可展示结果。\n3. 再看刷新信号是否只通知相关页面。\n4. 最后才检查 UI 展示。

验证路径不要只走正常操作

  • 打开两个编辑页,A 保存后 B 再保存,确认 B 得到冲突提示而非覆盖 A。
  • 连续快速点击同一页面保存按钮,确认队列不会让 Promise 卡死。
  • 模拟保存失败,确认队列 tail 仍释放,后续保存可以继续。
  • 重启后读取题库,确认 version 已递增且 updatedAt 正确。

验证时建议把“正常路径、异常输入、重启恢复、跨入口刷新、发布态检查”分开记录。构建通过只能证明语法和资源能打包,不能证明这些运行链路都已经被真机验证。

rg -n "PreferencesStore|AppStorage.setOrCreate|Repository|Service" D:\\ProgramData\\huawei\\lesson\\The_Book_of_Answers\nrg -n "question|answerText|deckName|hilog" D:\\ProgramData\\huawei\\lesson\\The_Book_of_Answers

常见问题与处理

现象 先看哪里 处理
后保存覆盖前保存 payload 是否带 baseVersion 保存前比较 latest.version
一次失败后永远保存中 队列 release 是否在 finally 成功失败都释放 tail
冲突只能 Toast DeckSaveResult 是否回页面 展示冲突选择而非静默覆盖

处理这些问题时不要先改 UI 文案。先确认写入 owner、读取 owner 和刷新信号是否一致,再看页面是否正确消费结果。若只在页面补一个 Toast,用户当次可能看到了提示,但重启、返回、跨入口和发布复查仍然会暴露同一个根因。

落地取舍

这套方案不是为了把轻量应用写重,而是为了把真正会跨页面、跨启动、跨发布阶段的事实收住。只影响当前展示节奏的变量可以留在页面;会改变用户内容、持久结构、隐私口径或发布证据的逻辑,必须进入 Service、Repository 或发布清单。

判断点 建议位置 原因
只影响当前按钮、弹层或动画 页面 @State 不需要跨入口复用
会写本地数据或读持久事实 Service + Repository 需要校验、回滚和恢复
会影响其他页面刷新 AppStorage 时间戳 通知变化,不共享完整对象
会影响发布、截图、隐私或诊断 发布清单或运行账本 后续复查需要证据

真正落地时,可以先从一条最容易复现的路径开始:找出唯一写入点,补上结果模型,再把页面里的直接读写替换成 Service 调用。这个顺序比一次性重构全部页面更稳,也更容易在评审时说明每一行代码解决了哪个故障链。评审记录里最好保留对应的命令、截图或复现步骤,避免方案只停留在口头约定。

小结

多窗口写入不是 UI 连点问题,而是数据版本问题。同一 deckId 的写入要串行,提交要比较 baseVersion,冲突要回到页面让用户选择。只在按钮上加锁会给人安全感,但挡不住跨入口覆盖。
ervice 调用。这个顺序比一次性重构全部页面更稳,也更容易在评审时说明每一行代码解决了哪个故障链。评审记录里最好保留对应的命令、截图或复现步骤,避免方案只停留在口头约定。

小结

多窗口写入不是 UI 连点问题,而是数据版本问题。同一 deckId 的写入要串行,提交要比较 baseVersion,冲突要回到页面让用户选择。只在按钮上加锁会给人安全感,但挡不住跨入口覆盖。

Logo

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

更多推荐