ArkUI + HarmonyOS 购物车批量编辑:可撤销快照与草稿提交边界【购物车系列 04】【共创稿事节】
购物车的“全选”按钮很容易写成一行数组映射,真正让维护成本上升的却是按钮后面那段时间:用户先批量勾选三件商品,发现误操作后撤销;接着点击“保存草稿”,保存接口尚未返回时又修改了数量。界面此刻代表的是最新编辑结果,磁盘里记录的可能还是上一次快照。如果把“保存成功”的文案直接挂在 putSync 后面,用户会误以为所有刚才看到的变化都已经持久化。对 BasketFlow 而言,这比一次计算错误更难解释。
前面三篇已经把项目的边界划定得很清楚:商品以 CartLine 数组作为事实来源,金额以整数分数派生;Preferences 通过 cart_draft_v1 / cart_draft_v2 保存轻量草稿;新版顶层结构固定为 schema: 2、revision、items,迁移不销毁旧版原文。本篇沿用 entry/src/main/ets/pages/Index.ets 与原先三条商品,专门讨论批量操作、单步撤销和异步保存之间的时序边界。这里没有真实下单、支付或库存同步,按钮“保存”的对象始终是应用本地草稿。

一、批量操作的真正单位:一份业务快照
先把测试条件固定下来。机械键盘 id=1,单价25900分,初始选中;无线鼠标 id=2,单价12900分,初始不选;便携支架 id=3,单价7900分,初始选中;三行数量均为1。页面启动时已选两种、合计33800分,即 ¥338.00。“全选可用商品”后应显示三种与 ¥467.00,单击撤销立即回到两种与 ¥338.00。如果最后再点击“只保留键盘”,则是单种、¥259.00。这些金额都是由相同单价表直接算出的确定结果,不依赖服务器或测试仪器。
单个商品的勾选事件是一次小修改,但批量勾选可能同时影响几十条行数据。如果把每条行变更分别写进撤销栈,用户点击一次“全选”却要点几十次撤销才能恢复,交互语义已经破坏了。因此这里的撤销单位不是数组元素,也不是一次 @State 通知,而是用户发起的一次完整业务动作。执行前先克隆整个 CartLine[],操作完成后用新数组替换当前状态,把操作前快照保存在 undoSnapshot。撤销时再交换数组内容,确保列表、已选种类与合计金额同时回到原先状态。
结构依旧保持五个字段:id、title、priceFen、count、checked。不要为“全选”引入独立的 @State allChecked,因为它可以从 lines.every() 推导;也不要独立记录 @State totalFen,否则撤销要额外记住两份可能不一致的数据。对于这类轻量示例,完整复制三行数据的成本可忽略;面对上千行或复杂对象时,应改用有边界的操作日志,并评估引用共享与持久化策略。
interface CartLine {
id: number;
title: string;
priceFen: number;
count: number;
checked: boolean;
}
interface DraftV2 { schema: number; revision: number; items: CartLine[] }
function cloneLines(source: CartLine[]): CartLine[] {
return source.map((item: CartLine): CartLine => ({ ...item }));
}
function defaultLines(): CartLine[] {
return [
{ id: 1, title: '机械键盘', priceFen: 25900, count: 1, checked: true },
{ id: 2, title: '无线鼠标', priceFen: 12900, count: 1, checked: false },
{ id: 3, title: '便携支架', priceFen: 7900, count: 1, checked: true }
];
}
这些类型与连载第三篇完全相容;cloneLines 只做浅复制是有意的,因为当前每个字段都是数字、字符串或布尔值。如果以后在 CartLine 内加入嵌套优惠对象,仅复制最外层会共享内部引用,需要同步升级复制策略和校验逻辑。更重要的是,撤销快照只是本次页面会话内的交互能力,不是一份永久历史记录;页面重建后只读取已经提交的 V2 草稿,不尝试从内存变量恢复操作栈。
二、让一次按钮点击只产生一次状态提交
下方采用 @State lines 驱动列表,普通私有字段保留 undoSnapshot。批量命令先持有 before,之后再生成 next,最后将整个数组赋回状态。这样 ArkUI 可以从新的列表引用触发更新,也避免在 ForEach 绘制期间逐项就地修改产生不确定的刷新时机。batchMode 只接受两个已定义操作,不让页面按钮任意传入筛选表达式。
@Entry
@Component
struct BasketPage {
@State private lines: CartLine[] = defaultLines();
@State private draftHint: string = '草稿待保存';
@State private revision: number = 0;
@State private saving: boolean = false;
private undoSnapshot: CartLine[] | null = null;
private editEpoch: number = 0;
private store: preferences.Preferences | null = null;
private batchMode(mode: 'all' | 'keyboard'): void {
const before: CartLine[] = cloneLines(this.lines);
const next: CartLine[] = this.lines.map((item: CartLine): CartLine => ({
...item, checked: mode === 'all' || item.id === 1
}));
this.undoSnapshot = before;
this.lines = next;
this.editEpoch += 1;
this.draftHint = '内容已修改,尚未保存';
}
private undoBatch(): void {
if (this.undoSnapshot === null) { return; }
this.lines = cloneLines(this.undoSnapshot);
this.undoSnapshot = null;
this.editEpoch += 1;
this.draftHint = '已撤销批量操作,尚未保存';
}
get selectedKinds(): number {
return this.lines.filter((item: CartLine): boolean => item.checked).length;
}
get totalFen(): number {
return this.lines.reduce((sum: number, item: CartLine): number =>
sum + (item.checked ? item.priceFen * item.count : 0), 0);
}
}
editEpoch 是页面编辑次数的单调标记,和磁盘 revision 不是一回事:前者表示“界面是否又被改过”,后者表示“已成功提交的草稿版本”。不能在按钮点击时就让 revision 自增,也不能在撤销按钮里把旧磁盘版本号减回来。用户完全可能保存过新版之后再进行新的撤销;版本号描述提交历史,不应等同于某一时刻的购物车内容。

三、保存按钮要记住提交时的那个画面
第三篇迁移完成后,Preferences 文件名称仍然是 basketflow_draft,V2 键仍为 cart_draft_v2。这里增加 saving 门禁,避免同一个页面同时启动两次写盘。为什么还需要 editEpoch?因为保存开始后用户仍可修改当前清单:flush() 成功返回只证明它对应的捕获快照已完成写入,不代表后来发生的编辑也一起保存了。我们把“捕获版本号”和“当前编辑号”分别记录,返回时再比较。
private async saveDraft(): Promise<void> {
if (this.saving || this.store === null) { return; }
if (!validLines(this.lines)) {
this.draftHint = '草稿字段校验未通过';
return;
}
const submittedEpoch: number = this.editEpoch;
const nextRevision: number = this.revision + 1;
const snapshot: DraftV2 = {
schema: 2, revision: nextRevision, items: cloneLines(this.lines)
};
this.saving = true;
this.draftHint = '正在保存第' + nextRevision + '版草稿';
try {
this.store.putSync('cart_draft_v2', JSON.stringify(snapshot));
await this.store.flush();
this.revision = nextRevision;
this.draftHint = submittedEpoch === this.editEpoch
? '草稿已保存 · 第' + this.revision + '版'
: '较早编辑已保存,当前修改仍待保存';
} catch (error) {
this.draftHint = '保存失败,当前页面编辑仍保留';
} finally {
this.saving = false;
}
}
这里沿用第三篇的 validLines():要求数组长度有界、ID不重复、价格为非负安全整数、数量在1~99之间、标题非空、勾选字段为布尔。它是应用层校验,不替代 Preferences 本身的文件写入保证。调用 putSync() 只把数值放进实例缓存,使用默认 XML 存储时仍需等待 flush() 完成才能提示持久化成功。这一点由华为开发者的用户首选项持久化指南和Preferences API 说明明确支持。
需要说明一个工程取舍:这个示例只采用单页面、单写入者约束。在 putSync 到 flush 期间,不允许另一个进程或另一个页面通过同一实例写入同名键,否则可能污染刚捕获的快照。多进程共享、支付订单或强事务要求不适合用这种轻量首选项构造数据库。发生保存异常时,不应立刻清空 UI,也不应假装磁盘内容一定没有变化;正确做法是保留编辑、显示失败并允许用户重试,必要时重新读取并验证磁盘快照。
四、页面数据与插图如何逐项对齐
以下界面布局保留上一篇的“选购清单”标题与 ForEach 商品展示,用三行数字保持示例可复现。列表展示“已选”或“未选”;底部金额直接从 totalFen 派生,不将金额写入 V2 数据。两个批量按钮表达不同操作,“撤销”只恢复上次操作前的状态,另一个按钮则单独完成本地草稿提交。Button 的 onClick 处理器没有把支付、库存 API 或未授权系统能力混进来。
build() {
Column({ space: 14 }) {
Text('选购清单').fontSize(28).fontWeight(FontWeight.Bold)
Text('批量编辑 · 本地草稿').fontSize(14).fontColor('#697386')
ForEach(this.lines, (item: CartLine) => {
Row({ space: 12 }) {
Text(item.checked ? '☑' : '□').fontSize(21)
Text(item.title).layoutWeight(1)
Text('×' + item.count.toString())
Text('¥' + (item.priceFen / 100).toFixed(2))
}.padding(16).width('100%')
}, (item: CartLine): string => item.id.toString())
Text('已选 ' + this.selectedKinds + ' 种')
Text('合计 ¥' + (this.totalFen / 100).toFixed(2)).fontSize(24)
Button('全选可用商品').onClick(() => this.batchMode('all'))
Button('只保留键盘').onClick(() => this.batchMode('keyboard'))
Button('撤销上一次批量操作').onClick(() => this.undoBatch())
Button(this.saving ? '保存中…' : '保存草稿')
.enabled(!this.saving).onClick(() => { this.saveDraft(); })
Text(this.draftHint).fontSize(14).fontColor('#536A86')
}.width('100%').height('100%').padding(24)
}

本节代码展示的是连载页面的关键替换片段,仍需与第三篇的 preferences、common 导入、validLines()、aboutToAppear() 中的 V2 解码/旧版回退逻辑组合,不能将不同章节中重复的 struct BasketPage 声明原样粘贴两次。页面创建时读取合法 V2 快照,并初始化 revision;用户触发新编辑之后才产生可撤销快照。本地草稿写入的是业务结构而不是界面树,这个选择让后续更换 UI 布局仍能继续读取旧数据。
还要注意按钮可用性与页面生命周期之间的关系。saving 为真时,保存按钮应禁用,但批量按钮仍可允许编辑,这正是“保存了较早版本”提示存在的原因。若产品希望保存期间彻底锁定输入,也可以禁用全部修改入口,前提是页面中的每一个操作控件都执行同一门禁逻辑,不能只锁住某个按钮。保存失败后不要自动调用撤销,因为“操作撤销”是用户行为,而“持久化异常”是存储行为;混为一谈会让用户在不知情时丢失正在编辑的数据。此外,实际工程应对已卸载页面的异步回调做生命周期保护,避免页面离开后再更新无效视图。本文展示的单页流程不承担跨页面存储协调,后续若多个入口都能改同一份购物车,应把保存队列迁移到共享业务服务中,并明确并发写入的冲突策略。
五、用固定动作证明撤销与提交不是同一件事
可以在 DevEco Studio 使用匹配目标 SDK 的 Stage 应用工程,先清理本示例的首选项测试数据,再按以下顺序观察。初次进入应是键盘和支架选中、金额338元;点击“全选可用商品”,三个选中标记及金额同步变成467元;点击“撤销”,应恢复338元;点击“只保留键盘”,显示259元;最后点击“保存草稿”,等待第1版保存完成提示。退出并重新打开页面后,列表应从 V2 草稿恢复,仍是键盘选中、259元,而“撤销”不会跨页面实例自动保留。

为了观察“保存期间仍有编辑”的分支,可以在测试环境中使用可控制的存储适配器人为延迟保存返回,而不是靠猜测真实闪存写盘速度。保存发起时记住的 submittedEpoch 不变;用户若又执行“全选”,editEpoch 增加,前一个 flush() 返回时文案应提示“较早编辑已保存,当前修改仍待保存”。重新提交后才能显示当前快照保存完成。还应测试新版 JSON 损坏、单价异常、数量越界和读取首选项失败等路径,保证第三篇建立的 V1 回退与默认清单策略没有被本轮 UI 改造破坏。
这一改造带来两条可以长期沿用的约束。第一,业务操作边界由用户意图决定:一次批量勾选必须能作为一次操作整体撤销。第二,本地保存边界由实际捕获的快照决定:保存完成只属于某一版数据,不自动替后来修改背书。BasketFlow 下一步才适合讨论订单确认前的价格复核、操作幂等及页面恢复期间的数据一致性;这些能力都必须建立在目前这份可校验、可追踪的本地状态基础上。
更多推荐

所有评论(0)