HarmonyOS 7 Immer:多页面草稿可逆补丁与崩溃恢复
一个跨三四个页面的表单,最容易被低估的并不是字段数量,而是“返回”和“撤销”到底是不是同一件事。用户从商品信息页改了标题,去规格页删了一行,又到发布页补了备注。此时点击系统返回,应当保留刚才的编辑;点击撤销,只应回退最后一次业务动作;应用被系统回收后重新进入,则既要恢复草稿,又不能把已经撤销的动作重新演一遍。
如果把这些语义都塞进一个可变对象,再靠深拷贝保存历史,很快会遇到三个问题:历史快照膨胀、页面间引用互相污染、持久化时机无法解释。本文用一个演示项目 DraftPatchDesk 拆解另一种做法:使用 Immer 生成正向补丁与逆向补丁,运行期维护有限深度的撤销栈,再用 HarmonyOS Preferences 落盘“基线快照 + 已确认补丁”。
本文中的任务 DPT-1422-308、修订号和进度均为演示数据,用于保证代码、日志与配图一致,不代表真实设备跑分或线上事故结论。

一、先把三个“回去”分开
草稿编辑里至少存在三种回退语义。
第一种是页面返回。它改变的是导航位置,不应该碰业务数据。第二种是撤销。它针对最近一次已接纳的业务动作,要把状态恢复到动作发生前。第三种是崩溃恢复。它并不是无限重放所有动作,而是从一个可信检查点开始,接上该检查点之后仍然有效的补丁。
很多实现把三者混在一起:页面 aboutToDisappear 时把整个对象写盘;下一页用同一个引用继续改;撤销时再拿上一份 JSON 覆盖。这样做看似简单,但“上一份”到底是哪一份没有清楚定义。只要保存与编辑并发,磁盘快照可能比内存新,也可能比内存旧。一个迟到的 flush() 甚至会覆盖已经提交的新版本。
DraftPatchDesk 采用四层状态:base 是最近确认的检查点;current 是界面正在展示的不可变状态;undoStack 保存逆向补丁;pendingPatches 保存检查点之后尚未压实的正向补丁。页面只订阅 current,持久化层只接收带修订号的检查点请求。这样,导航、撤销与恢复各自只操作自己负责的层。
演示页叫 DraftWorkbench。固定数据为:当前修订从 12 进入 13,本轮累计 17 个补丁操作,撤销深度 5,重做深度 0,检查点写入进度 34/50 = 68%,状态为 CHECKPOINT_PENDING。这些字段会同时出现在日志和手机诊断页里。
二、补丁比快照更有解释力,但不是免费午餐
Immer 的 produceWithPatches() 会返回新状态、正向补丁和逆向补丁;applyPatches() 可以把补丁应用到等价基线。官方文档同时提醒:补丁从 Immer 6 开始需要显式调用 enablePatches(),而且生成的补丁保证可正确重放,并不保证数量最少。
这点很关键。补丁适合表达“这次动作改了什么”,却不适合无限累计。输入框每键入一个字符就保存一组补丁,历史仍然会膨胀;数组排序可能产生大量 replace;两个动作若依赖不同基线,也不能不加判断地交换顺序。因此工程上需要业务动作边界和检查点,而不是把库当作自动时光机。
下面这段代码解决“动作怎样原子进入撤销栈”的问题。示例只使用普通对象和数组,避免把 UI 组件、Context、PixelMap 等带生命周期的对象放进可序列化草稿。
import {
enablePatches, produceWithPatches, applyPatches,
Patch, Draft
} from 'immer';
enablePatches();
interface SkuRow { id: string; name: string; priceFen: number }
interface DraftState {
revision: number;
title: string;
note: string;
skus: SkuRow[];
}
interface HistoryEntry {
actionId: string;
forward: Patch[];
inverse: Patch[];
}
export class DraftPatchStore {
private current: DraftState;
private undoStack: HistoryEntry[] = [];
private redoStack: HistoryEntry[] = [];
private pendingPatches: Patch[] = [];
constructor(initial: DraftState) { this.current = initial; }
dispatch(actionId: string, recipe: (draft: Draft<DraftState>) => void): void {
const [next, forward, inverse] = produceWithPatches(this.current, recipe);
if (forward.length === 0) return;
this.current = next;
this.undoStack.push({ actionId, forward, inverse });
this.pendingPatches.push(...forward);
this.redoStack = [];
}
undo(): boolean {
const entry = this.undoStack.pop();
if (!entry) return false;
this.current = applyPatches(this.current, entry.inverse);
this.redoStack.push(entry);
return true;
}
snapshot(): DraftState { return this.current; }
}
dispatch() 的边界是一次业务动作,而不是一次属性赋值。比如“删除规格并重新选择默认项”应在一个 recipe 中完成,否则用户撤销一次只恢复规格,却没有恢复默认项。redoStack 在新动作到来时必须清空,因为旧重做路径依赖的是撤销后的分叉基线。
还要注意,pendingPatches 不能在 undo() 后原样落盘。更稳妥的实现是:撤销会让当前工作分支失效,下一次检查点直接把 current 压成新基线并清空增量,或者重新根据上一个基线计算待提交动作。本文采用前者,用一次稍大的检查点换取恢复逻辑的确定性。
三、检查点不是定时器,而是一项带代次的提交
每隔几秒保存一次只是调度策略,不是正确性协议。真正需要回答的是:保存请求发出后,用户又改了字段,旧请求完成时可不可以宣告“已保存”?答案显然是否定的。
这里为每次检查点分配 checkpointGeneration。发起保存时冻结 revision、基线和补丁摘要;完成后只有 generation 仍等于当前值,UI 才能把 CHECKPOINT_PENDING 改为 CLEAN。旧请求即便成功写盘,也只能记为 STALE_FLUSH,随后由新一代检查点覆盖。
下面的代码解决 Preferences 写入与恢复的配对问题。Preferences 适合较小的配置和状态文本,官方说明其数据会加载到内存,不适合存放大量业务数据。因此演示把单条检查点限制在 256 KiB;超过阈值应迁移到关系型数据库或文件,并保留原子替换协议。
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
interface CheckpointEnvelope {
schema: 1;
revision: number;
generation: number;
state: DraftState;
savedAt: number;
}
export class DraftCheckpointRepo {
private readonly key = 'draft_checkpoint_v1';
constructor(private context: common.UIAbilityContext) {}
async save(envelope: CheckpointEnvelope): Promise<void> {
const payload = JSON.stringify(envelope);
if (payload.length > 256 * 1024) throw new Error('CHECKPOINT_TOO_LARGE');
const store = await preferences.getPreferences(this.context, {
name: 'draft_patch_desk'
});
await store.put(this.key, payload);
await store.flush();
}
async load(): Promise<CheckpointEnvelope | undefined> {
const store = await preferences.getPreferences(this.context, {
name: 'draft_patch_desk'
});
const raw = await store.get(this.key, '');
if (typeof raw !== 'string' || raw.length === 0) return undefined;
const value = JSON.parse(raw) as CheckpointEnvelope;
if (value.schema !== 1 || value.revision < 0) return undefined;
return value;
}
}
put() 修改内存中的 Preferences 对象,flush() 才把修改同步到文件。两者都可能失败,所以状态不能在 put() 返回后就改成已保存。恢复端也不应把 JSON.parse() 成功等同于业务合法,还要检查 schema、revision、数组数量、字符串长度和数值范围。
文章配套的 DevEco 图是示意画面,不冒充真实 IDE 截图。左侧展示 DraftPatchStore.ets 与 DraftCheckpointRepo.ets,中间标出 generation 判断,右侧模拟器显示 68% 检查点,底部日志固定为 DPT-1422-308 rev=12->13 ops=17 state=CHECKPOINT_PENDING。

四、页面状态只读投影,写操作回到单一入口
另一个常见问题,是把 Immer 返回的新对象直接塞进多个 @State 字段。页面 A 持有标题,页面 B 持有规格数组,页面 C 又缓存完整对象,最后出现三个局部真相。更清楚的做法是让 Store 持有唯一 current,UI 收到新快照后整体替换只读投影;所有写操作仍回到 dispatch()。
示例没有把 Immer 对象直接交给 AppStorageV2。原因不是二者一定冲突,而是职责不同:AppStorageV2 适合跨页面共享 UI 状态;Immer 补丁负责描述业务动作;Preferences 负责小体量检查点。把三层合成一个对象,会让序列化、观察和撤销边界再次纠缠。
下面这段页面代码解决“生命周期回调与保存请求重复”的问题。它把手动保存、后台切换和页面退出都合流到同一个门闩,并用 generation 丢弃迟到完成通知。
@Entry
@Component
struct DraftWorkbench {
@State viewState: DraftState = draftStore.snapshot();
@State saveState: string = 'CLEAN';
@State progress: number = 0;
private saveGeneration: number = 0;
private async requestCheckpoint(reason: string): Promise<void> {
const mine = ++this.saveGeneration;
const frozen = draftStore.snapshot();
this.saveState = 'CHECKPOINT_PENDING';
this.progress = 68;
try {
await checkpointRepo.save({
schema: 1, revision: frozen.revision,
generation: mine, state: frozen, savedAt: Date.now()
});
if (mine !== this.saveGeneration) return;
this.saveState = 'CLEAN';
this.progress = 100;
hilog.info(0xD017, 'DraftPatchDesk',
`DPT-1422-308 checkpoint=${mine} reason=${reason} CLEAN`);
} catch (e) {
if (mine !== this.saveGeneration) return;
this.saveState = 'SAVE_FAILED';
}
}
aboutToDisappear(): void {
void this.requestCheckpoint('PAGE_DISAPPEAR');
}
}
这里没有 await 生命周期回调,因为页面离开不应被一次磁盘写入阻塞;但这也意味着保存任务可能晚于页面完成。generation 只防止旧任务回写 UI,并不能保证进程被立即终止时写入一定完成。重要草稿要把检查点前移到稳定业务动作之后,并用数据库事务或文件原子替换增强耐久性,不能只依赖 aboutToDisappear()。
五、68% 页面表达的是“正在保存”,不是“已经安全”
运行页把修订号、补丁数量、撤销深度和保存进度放在同一张状态卡上。34/50 = 68% 表示演示保存管线完成了序列化与校验,正在等待持久化确认;它不是内容完成度,也不应在失败后继续增长。

进度条容易制造一种错觉:只要到 100% 就万事大吉。实际产品至少还要区分“内存状态已接纳”“检查点已写入”“检查点已验证”“服务端已同步”。本地写入完成不等于云端同步完成;同样,撤销成功也不等于旧检查点已被替换。
诊断页因此记录检查点代次、基线修订、当前修订、正向补丁数和恢复路径。固定演示结果是:revision 12 -> 13,patchOps=17,undoDepth=5,redoDepth=0,当前 generation 为 308。若出现 STALE_FLUSH,界面保留黄色提示但不回退当前状态;若 schema 不支持,则进入只读恢复页,不擅自丢弃原始文本。

六、恢复时先验证基线,再谈重放
恢复流程建议按四步走。先读取 envelope 并检查 schema;再校验内容约束;然后建立新的 Store;最后才把导航带到草稿页。不能先让页面展示半成品,随后再异步替换,因为用户可能在替换前产生新动作,导致补丁基线错位。
如果保存的是“基线 + 补丁”,每组补丁都要绑定 baseRevision。只有磁盘基线修订与补丁声明一致,才能应用。否则宁可展示基线并提示部分编辑未恢复,也不要猜测重放。Immer 官方说明补丁适用于等价基线,这个限定在恢复场景里比 API 名称更重要。
还要限制历史深度。演示把撤销栈上限设为 20 个业务动作,超过后把当前状态压成新基线。数组大改、富文本和图片标注等高密度场景应按估算字节而不是条数控制。补丁里如果包含个人信息,落盘前还要遵循业务合规要求;Preferences 不是加密保险箱,敏感秘密应使用合适的安全存储能力。
七、验收不看“能撤销”,而看边界是否可证明
这类功能的验收用例应围绕失败边界组织:连续编辑后撤销五次,再新建动作,确认重做栈清空;保存进行到 68% 时继续编辑,确认旧完成通知不把新状态标记为已保存;写入后杀进程,确认恢复 revision 和界面一致;修改 schema 为未知值,确认进入受控降级;构造超大草稿,确认系统拒绝写入而不是卡住主线程。
还应记录每次动作的 actionId、修订号、generation 和补丁数量,但不要把完整用户内容打进 HiLog。诊断日志的价值是还原状态路径,而不是复制业务数据。
最后的判断很朴素:Immer 解决的是“怎样描述一次可逆状态变化”,并不自动解决持久化、并发和生命周期。HarmonyOS 的状态共享与 Preferences 也各有边界。把业务动作、UI 投影和耐久检查点分层后,撤销与恢复才不再依赖偶然的执行顺序。
八、参考资料与适用范围
本文代码用于说明工程协议,未声称已经在所有 HarmonyOS 7 设备与所有 Immer 包装版本上实测通过。引入三方库前,应确认包来源、ArkTS 编译兼容性、许可证和最终产物;无法直接使用官方包时,可在受控的 TS/JS 互操作层或自维护 HAR 中封装,并以当前工程构建结果为准。
更多推荐

所有评论(0)