《游戏引擎实现原理》9.2 事务、合并与撤销栈

一次「多选后一起改属性」必须整体撤销,一次拖拽滑条刷出的几十条微编辑却该合并成一步。本节实现带原子性的事务(任一子命令抛错就补偿回滚)、按时间窗合并的连续编辑判据,并讨论撤销栈的条数上限与字节预算,全部代码在本机真跑并附 9 条 vitest 用例。

9.2 事务、合并与撤销栈

9.1 的 CommandStack 一次只处理一条命令。可编辑器里真正的操作很少是「一条」:多选十个节点后一起改材质,是一次编辑;拖拽滑条时手柄每移动一像素就产生一次赋值,语义上也是一次编辑。前者要整体撤销,后者要合并成一步——这正是本节要补的两件事:事务与合并窗口。

同时,栈不能无限长。一次会话可能产生几万条命令,历史全留会吃光内存。本节最后讨论栈的条数上限与字节预算如何与事务协作。

批量事务:把一批子命令当成一条

「整体撤销」的直觉实现是把一批命令依次 push,但那样按一次 Ctrl+Z 只退一条,用户得按十次。正确的抽象是复合命令:Transaction 本身就是一个 Command,它内部持有一串子命令,do 时正向依次执行,undo 时逆序回滚。对外它只是「一条命令」,进栈后一次撤销就带走整批。

事务真正的难点是原子性。如果 do 执行到第三个子命令时抛了错,前两个已经生效的改动必须被补偿回滚,否则文档会停在一个半新半旧的脏状态里——用户既撤销不了,也说不清发生了什么。Transaction 用「已处理列表」记录进度:正向执行时把成功的子命令记下来,一旦抛错就逆序对它们调 undo;undo 过程同理,任一子命令回滚失败时把已回滚的重新 do 回去。这套「失败即补偿」是数据库事务在编辑器里的最小翻版。

下面 src/editor/command/transaction.ts 层级 L1,完整可编译。它同时给出 MergeWindow——连续编辑的时间窗判据。

// file: src/editor/command/transaction.ts
// tier: L1
// verify: npx vitest run src/editor/command/transaction.test.ts
//
// helio 编辑器:事务(复合命令)与合并策略。一次「批量操作」——比如多选后
// 一起改属性、拖拽时一帧内移动一批节点——必须整体撤销。Transaction 把若干子命令
// 包成一条命令,并保证原子性:任一子命令抛错,已生效的部分会被回滚。
// MergeWindow 负责「连续微编辑」的时间窗合并判定。

import type { Command } from './command.ts';

/**
 * 复合命令:把若干子命令当成一条来 do / undo。
 * do 正向依次执行;undo 逆序回滚。任一子命令抛错时,已处理的部分会被反向补偿,
 * 保证「要么全部生效,要么全部不生效」。
 */
export class Transaction implements Command {
  readonly label: string;
  readonly #children: Command[] = [];

  constructor(label: string) {
    this.label = label;
  }

  /** 追加一条子命令,返回 this 以便链式构造。 */
  add(cmd: Command): this {
    this.#children.push(cmd);
    return this;
  }

  get size(): number { return this.#children.length; }

  do(): void {
    const applied: Command[] = [];
    try {
      for (const c of this.#children) {
        c.do();
        applied.push(c);
      }
    } catch (err) {
      // 原子性:把已成功的子命令逆序撤销回去
      for (let i = applied.length - 1; i >= 0; i--) applied[i]!.undo();
      throw err;
    }
  }

  undo(): void {
    const undone: Command[] = [];
    try {
      for (let i = this.#children.length - 1; i >= 0; i--) {
        const c = this.#children[i]!;
        c.undo();
        undone.push(c);
      }
    } catch (err) {
      // 原子性:把已撤销的子命令正向重做回去
      for (let i = undone.length - 1; i >= 0; i--) undone[i]!.do();
      throw err;
    }
  }

  byteSize(): number {
    let n = 0;
    for (const c of this.#children) n += c.byteSize ? c.byteSize() : 0;
    return n;
  }
}

/**
 * 连续编辑的合并窗口。判据是「同一条 label + 距上次编辑不超过 windowMs」。
 * 用于属性面板的拖拽滑条:一次拖动会刷出几十条 SetProperty,但它们语义上是一次编辑。
 */
export class MergeWindow {
  readonly #windowMs: number;
  #lastLabel: string | null = null;
  #lastTime = -Infinity;

  constructor(windowMs = 400) {
    this.#windowMs = windowMs;
  }

  /**
   * 登记一次编辑,返回这次是否应并入上一条。
   * 无论是否合并,都会把「上次编辑时刻」推进到 nowMs,使窗口随连续输入延展。
   */
  accept(label: string, nowMs: number): boolean {
    const merge = label === this.#lastLabel && nowMs - this.#lastTime <= this.#windowMs;
    if (!merge) this.#lastLabel = label;
    this.#lastTime = nowMs;
    return merge;
  }

  /** 主动断开窗口:例如鼠标松开、切换选中目标时调用。 */
  reset(): void {
    this.#lastLabel = null;
    this.#lastTime = -Infinity;
  }
}

注意 undo 是逆序执行的。子命令之间常有隐式依赖:先创建节点、再给它设属性,撤销时必须先撤属性、再删节点,顺序反了就会「往一个已经不存在的节点上写值」。逆序回滚保证依赖关系天然成立,这也是所有 undo 系统的默认约定。

连续编辑的合并:时间窗判定

9.1 里 mergeWith 的判据是「同一条命令、同一目标」。这还不够:用户上午把 x 从 1 改成 2,下午又把它从 2 改成 3,两次是完全独立的编辑,不该合并。所以真正的合并判据要再加一个时间维度。

MergeWindow 把这件事抽成一个纯时间逻辑:登记 (label, nowMs),若与上次同 label 且间隔不超过 windowMs(默认 400 ms),就返回「应合并」;否则开启新的一组。它和 CommandStack.push 里的 mergeWith 是两个层次的判据——MergeWindow 决定「这一次编辑该不该并进上一次」,mergeWith 决定「两条命令合并后长什么样」。前者管时机,后者管数据。reset() 用于主动断窗:鼠标松开、切换选中目标、切换工具时调用,下一次编辑必然另起一组。

撤销栈的上限与内存

历史不能无限长。9.1 的 CommandStack 已经内建了 limit(条数上限,默认 256)与 byteBudget(字节预算,默认 8 MiB),每次 push 后从最旧的命令开始裁剪。这里有三点取舍:

  • 裁最旧,不裁最新。被丢弃的一定是历史深处那条,用户最不可能再去撤销它。
  • 字节预算靠 byteSize() 自报。Transaction 的 byteSize 是子命令之和,所以一个「批量移动 200 个节点」的事务会一次性吃掉可观的预算,触发更激进的裁剪——这正是我们想要的:大操作本来就该压缩历史长度。
  • 至少保留一条。字节预算的循环带 length > 1 条件,避免预算设得过小时把栈清空,让用户连一步都撤不了。

条数上限与字节预算是或的关系:谁先触发就按谁裁。limit 防「条数爆炸」,byteBudget 防「单条超肥」。两者都不设时,栈会随会话无限增长。

可运行产物:事务原子性与合并窗口的真跑

下面 src/editor/command/transaction.test.ts 层级 L1,用一个可注入故障的 Step 命令覆盖事务的两条原子性路径、合并窗口的三种情形,以及事务进栈后的整体撤销与内存裁剪。命令 npx vitest run src/editor/command/transaction.test.ts。

// file: src/editor/command/transaction.test.ts
// tier: L1
// verify: npx vitest run src/editor/command/transaction.test.ts
import { describe, expect, it } from 'vitest';
import { CommandStack, type Command } from './command.ts';
import { MergeWindow, Transaction } from './transaction.ts';

interface Cell { v: number }

/** 可注入故障的命令:throwOnDo / throwOnUndo 为真时对应方法抛错。 */
class Step implements Command {
  readonly label: string;
  #cell: Cell;
  #from: number;
  #to: number;
  #throwOnDo = false;
  #throwOnUndo = false;
  #size = 0;

  constructor(cell: Cell, from: number, to: number, label = 'step') {
    this.#cell = cell;
    this.#from = from;
    this.#to = to;
    this.label = label;
  }

  failOnDo(): this { this.#throwOnDo = true; return this; }
  failOnUndo(): this { this.#throwOnUndo = true; return this; }
  withSize(n: number): this { this.#size = n; return this; }
  byteSize(): number { return this.#size; }

  do(): void {
    if (this.#throwOnDo) throw new Error(`do failed: ${this.label}`);
    this.#cell.v = this.#to;
  }

  undo(): void {
    if (this.#throwOnUndo) throw new Error(`undo failed: ${this.label}`);
    this.#cell.v = this.#from;
  }
}

describe('事务的批量 do / undo', () => {
  it('一次撤销回滚整批子命令', () => {
    const cells = [0, 1, 2].map(() => ({ v: 0 }));
    const tx = new Transaction('batch move')
      .add(new Step(cells[0]!, 0, 10))
      .add(new Step(cells[1]!, 0, 20))
      .add(new Step(cells[2]!, 0, 30));
    expect(tx.size).toBe(3);
    tx.do();
    expect(cells.map((c) => c.v)).toEqual([10, 20, 30]);
    tx.undo();
    expect(cells.map((c) => c.v)).toEqual([0, 0, 0]);
  });

  it('do 中途抛错时,已生效的子命令被回滚(原子性)', () => {
    const cells = [0, 1, 2].map(() => ({ v: 0 }));
    const tx = new Transaction('partial')
      .add(new Step(cells[0]!, 0, 1))
      .add(new Step(cells[1]!, 0, 2))
      .add(new Step(cells[2]!, 0, 3).failOnDo());
    expect(() => tx.do()).toThrow('do failed');
    // 前两条已生效 → 必须被补偿回滚
    expect(cells.map((c) => c.v)).toEqual([0, 0, 0]);
  });

  it('undo 中途抛错时,已撤销的子命令被重做(原子性)', () => {
    const cells = [0, 1, 2].map(() => ({ v: 0 }));
    const tx = new Transaction('undo-break')
      .add(new Step(cells[0]!, 0, 1))
      .add(new Step(cells[1]!, 0, 2).failOnUndo())
      .add(new Step(cells[2]!, 0, 3));
    tx.do();
    expect(() => tx.undo()).toThrow('undo failed');
    // 逆序 undo:cells[2] 先回滚,再轮到 cells[1] 抛错 → cells[2] 被重做回去
    expect(cells[2]!.v).toBe(3);
    expect(cells[1]!.v).toBe(2);
  });

  it('byteSize 汇总子命令占用', () => {
    const tx = new Transaction('size')
      .add({ label: 'a', do() {}, undo() {}, byteSize: () => 100 })
      .add({ label: 'b', do() {}, undo() {}, byteSize: () => 50 });
    expect(tx.byteSize()).toBe(150);
  });
});

describe('合并窗口', () => {
  it('同 label 且在窗口内 → 合并', () => {
    const w = new MergeWindow(400);
    expect(w.accept('drag', 1000)).toBe(false); // 第一条,无上一条可并
    expect(w.accept('drag', 1200)).toBe(true); // 同 label,距上次 200ms
    expect(w.accept('drag', 1500)).toBe(true); // 窗口随输入延展
  });

  it('超出窗口或换 label → 不合并', () => {
    const w = new MergeWindow(400);
    w.accept('drag', 1000);
    expect(w.accept('drag', 2000)).toBe(false); // 距上次 1000ms > 400
    expect(w.accept('resize', 2100)).toBe(false); // 换 label
    expect(w.accept('resize', 2200)).toBe(true);
  });

  it('reset 断开窗口', () => {
    const w = new MergeWindow(400);
    w.accept('drag', 1000);
    w.reset();
    expect(w.accept('drag', 1100)).toBe(false);
  });
});

describe('事务进栈', () => {
  it('事务作为一条命令进栈,undo 一次撤销整批', () => {
    const cells = [0, 1].map(() => ({ v: 0 }));
    const stack = new CommandStack();
    const tx = new Transaction('multi')
      .add(new Step(cells[0]!, 0, 7))
      .add(new Step(cells[1]!, 0, 8));
    stack.push(tx);
    expect(stack.depth).toBe(1);
    expect(cells.map((c) => c.v)).toEqual([7, 8]);
    stack.undo();
    expect(cells.map((c) => c.v)).toEqual([0, 0]);
  });
});

describe('撤销栈的上限与内存', () => {
  it('事务的 byteSize 汇总后计入栈预算,按字节裁剪最旧记录', () => {
    const stack = new CommandStack();
    stack.byteBudget = 250; // 每个事务 100 字节 → 预算内最多 2 条
    const cells = [0, 1, 2].map(() => ({ v: 0 }));
    for (const c of cells) {
      stack.push(new Transaction('t').add(new Step(c, 0, 1).withSize(100)));
    }
    expect(stack.depth).toBe(2);
    expect(stack.bytes).toBeLessThanOrEqual(250);
    // 被裁掉的是最旧的一条:撤销两次只能回滚后两条
    stack.undo();
    stack.undo();
    expect(stack.canUndo).toBe(false);
    expect(cells[0]!.v).toBe(1); // 第 0 条已不可撤销
  });
});

本机实跑:Test Files 1 passed、Tests 9 passed;tsc --noEmit 零错误。两条原子性用例是最有价值的:do 中途抛错后三个 cell 全回到 0(证明前两条被补偿回滚),undo 中途抛错后 cells[2] 仍是 3(证明它被重做回去,而不是留在半撤销态)。

小结

  • Transaction 把一批子命令包成一条命令,对外一次撤销即带走整批。
  • 事务的原子性靠「已处理列表 + 失败补偿」实现:do 抛错就逆序 undo 已成功者,undo 抛错就逆序 do 已回滚者。
  • undo 必须逆序执行,以尊重子命令之间的依赖(先建的节点后删)。
  • MergeWindow 在 9.1 的 mergeWith 之上补了时间维度:同 label 且间隔在窗口内才合并,reset() 主动断窗。
  • 撤销栈的 limit 与 byteBudget 是或关系,从最旧端裁剪且至少保留一条;事务的 byteSize 汇总子命令,使大操作更早触发裁剪。

下一节把命令栈真正接到第 8 章的文档模型上:命令怎么落到节点与属性上,又怎么触发脏标记与重绘。

阅读导航:上一节:9.1 命令模式与可撤销操作 · 下一节:9.3 与数据模型的接缝 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径