HarmonyOS应用实战-启示散页-49-多窗口同时打开别写乱 Preferences:给写入操作加队列和版本校验
HarmonyOS应用实战-启示散页-49-多窗口同时打开别写乱 Preferences:给写入操作加队列和版本校验
两个窗口同时打开同一题库:A 修改了答案,B 调整了顺序。两边都基于同一份旧对象保存时,后写入者会无声覆盖前者。单窗口下不明显的问题,在多窗口、快捷入口和恢复页并存时会变成数据丢失。

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


局部按钮锁挡不住跨窗口写入
很多页面会在保存按钮上加 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,冲突要回到页面让用户选择。只在按钮上加锁会给人安全感,但挡不住跨入口覆盖。
更多推荐


所有评论(0)