鸿蒙编辑器框架的撤销/重做:逆操作历史栈
一、安全网的成本模型:存什么,决定了你走多远
撤销功能有两个经典实现路线,选型本质是一道内存数学题:
路线 A:文档快照。 每次提交事务前,深拷贝整棵 Block 树存进栈。撤销 = 恢复上一份快照。实现直观、正确性显然,但成本是 O(文档大小 × 步数)。一份 5000 块的文档,用户编辑 500 步,理论上要维护 500 份全量副本——即便工程上用结构共享优化,长文档 + 重度编辑的组合在移动设备上依然是不可忽视的内存压力。而且每一步都要付全量拷贝的时间,无论这一步是"插入一个章节"还是"敲了一个字"。
路线 B:逆操作(ArkBlocks 的选择)。 每次提交事务时,只记录"如何反转这一步"的操作序列。成本是 O(编辑量)——敲一个字就存一个字的操作,删一个块就存那个块的快照。代价是每次撤销要做一次求逆运算,但求逆是上一篇讲过的纯数据变换,不碰渲染、不碰平台,便宜且可测。
一个量化感受:同样是"把标题 level 从 1 改成 2",快照方案存的是整个文档,逆操作方案存的是 updateProps(level: 1) 一个对象。差距在轻文档里无所谓,在"整个知识库一个文档"的场景里就是能否接受的分界线。
二、HistoryEntry 解剖:一组"往返票"
真实的数据结构只有四个字段(HistoryEntry.ts 全文核心):
export interface HistoryEntry {
/** 来源事务的 ID。 */
transactionId: string;
/** 按序应用即可撤销该事务的操作。提交时由 OperationInverter.invertAll() 计算。 */
inverseOperations: Operation[];
/** 产生该状态的原操作。重做时使用。 */
originalOperations: Operation[];
timestamp: number;
}
理解这个设计的正确心智模型是往返票:每个条目记录一次状态跃迁的两个方向——originalOperations 是"从状态 A 到状态 B",inverseOperations 是"从状态 B 回到状态 A"。两个方向都在提交时刻一次性算好、冻结进条目,之后不再依赖任何活数据。
注意"冻结"的分量:第 02 篇讲过逆操作必须捕获前状态(删掉的块要整棵快照进逆操作),第 03 篇讲过 SplitBlockOp.newBlockId 预分配。这些设计在这里兑现——历史条目是自包含的,撤销不依赖"文档现在长什么样",只依赖条目里封存的操作。这也让 HistoryStack 自身的依赖规则可以收得很紧:只许 import core/ / document/ / transaction/,与 command / renderer / adapter 彻底绝缘。
三、push 的语义:新编辑作废整个"未来"
入栈接口只有两行,但第二行是所有编辑器的通用契约:
push(entry: HistoryEntry): void {
this.undoStack.push(entry);
this.redoStack = []; // 新编辑使整条 redo 链失效
}
用户撤销了三步,然后敲了一个新字符——此刻"重做"必须永远消失。原因不是技术限制而是语义正确:重做恢复的是"被撤销的旧世界",而用户的最新编辑已经宣告旧世界作废,在旧世界上重做会产生逻辑上矛盾的状态。所以 push 即清空,没有例外。
谁负责调 push?看 Editor 外观层的 exec()(真实代码):
exec(command: Command): void {
const result = this.dispatcher.exec(command); // 命令 → 事务 → 提交
const inverseOps = OperationInverter.invertAll(result.operations, this.doc); // 求逆
const entry: HistoryEntry = {
transactionId: result.transactionId,
inverseOperations: inverseOps,
originalOperations: result.operations,
timestamp: Date.now(),
};
this.historyStack.push(entry); // 入史
this.notifyRenderer(result.diff); // 渲染
this.emit({ type: 'change', blocks: this.doc.getRoot() }); // 事件
}
顺序是硬约束:先求逆、再入史。求逆必须发生在文档被下一次修改之前(第 03 篇的铁律),而入史必须在渲染通知之前——否则渲染层先看到新状态、历史却还没记录,事件处理器里如果触发撤销就会撤销一个不存在的条目。
四、undo() 的完整闭环:把逆操作再反转一次
HistoryStack.undo() 是本模块的心脏。它做的事情概括起来是:弹出的条目告诉你怎么回去;照着做;再算好"怎么回来",封成镜像条目放上对面的栈:
undo(): RendererDiff {
if (this.undoStack.length === 0) return createEmptyDiff();
const entry = this.undoStack.pop()!;
this._isUndoRedoInProgress = true; // 拉起守卫(见第六节)
try {
const inverseOps = entry.inverseOperations; // ① 怎么回去
const redoInverse = OperationInverter.invertAll(inverseOps, this.document); // ② 怎么回来
const tx = this.txnManager.begin(); // ③ 以新事务身份回去
for (const op of inverseOps) tx.addOperation(op);
const result = this.txnManager.commit(tx);
this.redoStack.push({ // ④ 镜像条目上 redo 栈
transactionId: result.transactionId,
inverseOperations: redoInverse,
originalOperations: inverseOps,
timestamp: Date.now(),
});
return result.diff;
} catch (_err) {
this.undoStack.push(entry); // ⑤ 失败:条目退回,历史不丢
return createEmptyDiff();
} finally {
this._isUndoRedoInProgress = false; // ⑥ 守卫必然释放
}
}
四个设计决策值得展开:
① 撤销也是事务。 undo 没有任何特权通道——它把逆操作打包成一个新事务,走和普通编辑完全一样的 begin → addOperation → commit 流水线。于是第 03 篇的所有机制自动生效:五阶段校验、快照兜底、RendererDiff、事件。撤销失败会走校验失败路径抛错,而不是把文档推进非法状态。
② "再反转"为什么可行。 invertAll(inverseOps, document) 对逆操作再求一次逆,得到的就是"回到被撤销状态"的操作(即原始操作)。这一步发生在应用撤销之前,符合求逆的前状态契约。配合 invertAll 对 replaceBlock 的补删除逻辑,无论操作多复合,往返票总能配平。
⑤ 失败回压:保守到近乎执拗。 提交抛错时,弹出来的条目被原样塞回 undo 栈,返回空 diff。宁可这次撤销无效,也绝不丢历史条目——丢了就永远回不去了。finally 块保证守卫在任何路径(成功、失败、抛错)下都释放。
空栈返回空 diff 而非抛错。 撤销空栈不是错误,是用户的正常操作(谁没狂按过 Ctrl+Z)。返回空 diff 让上层渲染通知成为无害空转。
五、一个漂亮的对称性:undo 和 redo 是结构孪生
把 redo() 放在 undo() 旁边看,会发现一个精心设计(或精心演化)出的对称:
redo(): RendererDiff {
if (this.redoStack.length === 0) return createEmptyDiff();
const entry = this.redoStack.pop()!;
this._isUndoRedoInProgress = true;
try {
const ops = entry.inverseOperations; // ★ 和 undo 一模一样
const undoInverse = OperationInverter.invertAll(ops, this.document);
const tx = this.txnManager.begin();
for (const op of ops) tx.addOperation(op);
const result = this.txnManager.commit(tx);
this.undoStack.push({ // 镜像条目放回对侧栈
transactionId: result.transactionId,
inverseOperations: undoInverse,
originalOperations: ops,
timestamp: Date.now(),
});
return result.diff;
} catch (_err) {
this.redoStack.push(entry);
return createEmptyDiff();
} finally {
this._isUndoRedoInProgress = false;
}
}
两个方法逐行同构,唯一的区别是操作哪边的栈、条目放回哪边。玄机在条目字段的使用上:无论 undo 还是 redo,实际应用的永远是 entry.inverseOperations。因为 mirror 条目在入栈时被翻转过一次——undo 产生的 redo 条目里,inverseOperations 字段装的是"回到被撤销状态"的原方向操作;redo 产生的 undo 条目里,装的是"再次撤销"的逆方向操作。栈的方向翻转 + 字段语义翻转,两两抵消,于是两个方法可以共用同一副骨架。
这张状态表可以帮你验证:
| 栈 | 条目内容 | 应用 inverseOperations 的效果 |
|---|---|---|
| undoStack | 原 A→B,逆 B→A | B→A(撤销) |
| redoStack(由 undo 产生) | "原" B→A,逆 A→B | A→B(重做) |
每一次 undo/redo 都在两侧栈之间搬运镜像条目,任何一边弹空了就无事可做。对称性不是审美偏好——undo 和 redo 互为彼此的用例,实现上的孪生保证了行为上的严格互逆。
六、防重入:撤销不能产生新历史
Undo/redo 有个自指陷阱:撤销本身就是一次文档变更,而"每次变更都记入历史"是框架铁律——那么撤销会生成新的撤销条目,无限套娃。
ArkBlocks 用两层防御处理:
结构层(主防线):undo/redo 提交事务走的是 txnManager.commit() 直连,不经过 Editor.exec()——而入史只发生在 exec() 里。undo 的事务从结构上就不可能撞进入史逻辑。
约定层(对钩子):isUndoRedoInProgress 标志在 undo/redo 执行期间拉起、必然释放(finally)。它是一个公开 getter,类文档明确要求:如果将来把"记录历史"改成挂在事务事件上的钩子形式,钩子必须先检查这个标志再 push。标志此刻是给未来集成方式预留的断路器——防御先于需求存在,因为这类死循环一旦上线,用户看到的是"按一次撤销,内存涨一截"。
七、边界与诚实的现状
几个使用语义,一笔一笔说清楚:
- 历史不序列化(RFC-0001 Rule 9):撤销栈是会话产物。文档从 JSON 加载时
clear()清空双栈——新打开的文档没有"过去"可反悔。序列化历史不仅成本无界,还会在操作格式演进时制造迁移灾难。 - canUndo / canRedo / 栈大小:暴露给 UI 做按钮置灰与状态显示。
- 诚实披露一:
Editor.transact()(应用手动打包多个操作的底层入口)目前提交后尚未写入历史——代码里有明确的TODO(M4): Record the transaction in History after commit。所以当下"一次撤销单位"严格对应的是exec()的一条命令;transact 的入史是既定路线图上的待接线项,不是架构缺口。 - 诚实披露二:相邻同类操作的合并(连续敲字合成一个撤销步)、命名检查点、历史压缩,都在架构文档的"未来扩展"清单里,当前实现是"一个事务 = 一个撤销步"的最严格语义。
八、为什么这套设计在 AI 时代特别值钱
撤销单位是事务,这个决定在 M7(AI 集成)里程碑会加倍回报。AI 流式写入的物理形态是几十上百个增量片段陆续到达;如果以"每次到达"为撤销单位,用户按一次撤销只能消掉一个 token,按五十次才能清掉一段 AI 生成——不可用。正确的做法是:整个流式会话的所有 insertInline 操作进同一个事务,提交时产生一个历史条目。用户按一次撤销,整段 AI 内容一次消失;再按一次重做,整段回来。
第 02 篇的稳定 ID、第 03 篇的 newBlockId 预分配与本篇的往返票,三件事合起来保证了这条链路无需任何特设机制——AI 写入就是普通写入,撤销就是普通撤销。好的架构决策的特征,是让未来的功能看起来本来就该这么实现。
九、小结
- 存操作不存文档:历史条目是一组往返票(original + inverse),内存正比于编辑量而非文档大小 × 步数。
- push 即清空 redo 栈:新编辑作废整个"未来",语义正确性优先。
- 撤销也是事务:undo/redo 与普通编辑走同一条校验-提交-通知流水线,没有特权通道。
- undo 与 redo 结构孪生:永远应用
entry.inverseOperations,镜像条目在对侧栈之间搬运,行为严格互逆。 - 失败回压 + 必然释放的守卫:出错时条目退栈不丢历史,标志位 finally 兜底释放;防重入靠结构直连 + 钩子约定双保险。
至此,"变更"的生命周期完整闭环:命令构造它(03)、事务校验并应用它(03)、历史记住如何反悔它(本篇)。地基之下是第 02 篇的文档模型。从下一篇开始,我们转向框架的扩展面——先看所有类型化数据的守门人:Schema 体系。
下一篇:《鸿蒙编辑器框架的 Schema 体系:BlockSpec 注册与运行时校验》——一个 BlockSpec 如何同时承载类型契约与 UI 贡献,SchemaRegistry 的三张 Map 如何让"文档里出现未注册类型"成为不可能。
更多推荐



所有评论(0)