课堂上老师圈出一个公式,觉得圈的位置不对,按一下撤销;紧接着又把三角形的一条辅助线删掉,发现删除过头,再按重做。看似只有两个按钮,背后却有一个必须明确的问题:撤销的到底是哪一次业务操作,而不是哪一帧画布图像? 如果只把 Canvas 上一瞬间的像素保存成位图,缩放、局部清理、颜色调整和重新打开页面都会让历史变得难以维护。画布不等于文档,文档也不等于历史。

前几篇已经形成一条链:TouchEvent.changedTouches 根据触点 ID 收集笔迹,几何层输出 StrokePiece,ViewportTransform 把触点归入文档坐标,BoardRenderer 依据 DirtyRect 进行局部重绘。接下来不需要推翻绘制链,只要在它的上游放一份可逆的命令日志,让最终画面始终由文档中的有效笔迹决定。这样撤销、重做与局部重绘才能彼此独立。

课堂白板的命令历史与撤销重做

操作历史是文档行为的顺序记录,画布则始终根据当前有效笔迹重新呈现。

先决定“撤销一次”意味着什么

手指在屏幕上移动时,一条曲线可能包含几十个采样点、数十个贝塞尔片段。如果把每一次 Move 当成独立命令,按一下撤销只擦掉末端很短一截,用户还要连续点很多次才能撤掉完整笔迹。所以,采样与提交要分成两个层次:笔尖移动阶段可以实时绘制预览线;触点抬起、取消或者书写动作明确结束后,才把完整笔迹作为一条 AddStroke 命令写入文档。若多根手指同时绘制,每根手指各有独立的临时笔迹,结束时各提交一次,命令顺序由完成顺序决定。

另一种命令是 RemoveStroke,用于橡皮擦选中一整条笔迹或通过选择工具删除对象。这里暂时不做像素橡皮的局部擦除,因为它通常涉及曲线分割、交点求解和多个新片段的替换;那可以在以后扩展成复合命令。当前要把“添加”和“删除”两种动作做好,它们足够揭示撤销系统的多数设计问题。

笔迹实体至少需要稳定 id、线条颜色、宽度、几何片段和原始叠放顺序。注意坐标继续使用文档空间,不要把已经经过缩放的屏幕点写进历史,否则把缩放倍率从 1.5 调回 1.0 时,旧笔迹会出现位置错误。

// model/BoardCommand.ets
import { StrokePiece } from './StrokeGeometry';

export interface StrokeRecord {
  id: string;
  color: string;
  width: number;
  order: number;
  pieces: StrokePiece[];
}

export type BoardOperation = 'add' | 'remove';

export interface BoardCommand {
  operation: BoardOperation;
  record: StrokeRecord;
}

export function copyRecord(source: StrokeRecord): StrokeRecord {
  return {
    id: source.id,
    color: source.color,
    width: source.width,
    order: source.order,
    pieces: source.pieces.map((piece: StrokePiece) => ({
      kind: piece.kind,
      from: { x: piece.from.x, y: piece.from.y, seq: piece.from.seq },
      to: { x: piece.to.x, y: piece.to.y, seq: piece.to.seq },
      control: piece.control ? { x: piece.control.x, y: piece.control.y,
        seq: piece.control.seq } : undefined
    }))
  };
}

copyRecord 值得认真保留。若命令直接引用正在编辑的 pieces 数组,用户改颜色、移动控制点或继续补充采样时,就会改变已经写入历史的内容。撤销依赖过去的状态,一旦过去也会随着当前操作变化,就不再是真正的历史。这里做了逐个几何点的复制;复杂图形需要对其业务字段采用同样原则。order 用来恢复删除前的叠放位置,避免撤销删除后对象跑到最上层,遮住原本位于前面的图形。

命令表、游标和有效文档是三个不同概念

最容易出错的实现,是准备两个数组 undoStack、redoStack,每次操作都随手压入或弹出,却没有统一约定“已经执行到哪里”。这里采用一份 commands 与一个 cursor:数组前 cursor 条命令代表已执行历史,后半段代表尚可重做的未来。执行新命令时,如果游标不在末尾,先截断未来,再追加新命令。这就是常见的分支规则。

例如顺序是“画 A、画 B、画 C”,撤销两次后剩下 A。若此时新画 D,历史应该成为“A、D”,旧的 B、C 不再可重做。否则点重做会把用户已经放弃的两条线重新带回来。这个行为和视觉上是否只重绘一个局部矩形毫无关系,它是文档操作的因果规则。

下列实现中,Map 保存当前有效笔迹,orderIds 保存叠放顺序。命令应用和逆向应用只更改文档,并返回受影响的笔迹供渲染器计算脏区。增加、删除、撤销和重做都从一个入口改变状态,不让页面按钮直接接触 Map。

// model/BoardHistory.ets
import { BoardCommand, StrokeRecord, copyRecord } from './BoardCommand';

export class BoardHistory {
  private records: Map<string, StrokeRecord> = new Map();
  private orderIds: string[] = [];
  private commands: BoardCommand[] = [];
  private cursor: number = 0;
  private readonly limit: number = 256;

  private insert(record: StrokeRecord): void {
    if (this.records.has(record.id)) {
      throw new Error('笔迹标识已存在');
    }
    const saved = copyRecord(record);
    this.records.set(saved.id, saved);
    const pos = Math.max(0, Math.min(saved.order, this.orderIds.length));
    this.orderIds.splice(pos, 0, saved.id);
  }

  private erase(id: string): void {
    this.records.delete(id);
    const index = this.orderIds.indexOf(id);
    if (index >= 0) { this.orderIds.splice(index, 1); }
  }

  private apply(command: BoardCommand, forward: boolean): void {
    const shouldAdd = forward ? command.operation === 'add' :
      command.operation === 'remove';
    if (shouldAdd) { this.insert(command.record); }
    else { this.erase(command.record.id); }
  }

  execute(command: BoardCommand): StrokeRecord {
    if (command.operation === 'remove' &&
        !this.records.has(command.record.id)) {
      throw new Error('待删除的笔迹不存在');
    }
    const fixed: BoardCommand = {
      operation: command.operation,
      record: copyRecord(command.record)
    };
    this.apply(fixed, true);
    this.commands = this.commands.slice(0, this.cursor);
    this.commands.push(fixed);
    this.cursor++;
    if (this.commands.length > this.limit) {
      const drop = this.commands.length - this.limit;
      this.commands.splice(0, drop);
      this.cursor -= drop;
    }
    return copyRecord(fixed.record);
  }

  undo(): StrokeRecord | undefined {
    if (this.cursor === 0) { return undefined; }
    const command = this.commands[--this.cursor];
    this.apply(command, false);
    return copyRecord(command.record);
  }

  redo(): StrokeRecord | undefined {
    if (this.cursor >= this.commands.length) { return undefined; }
    const command = this.commands[this.cursor];
    this.apply(command, true);
    this.cursor++;
    return copyRecord(command.record);
  }

  get canUndo(): boolean { return this.cursor > 0; }
  get canRedo(): boolean { return this.cursor < this.commands.length; }
  get position(): number { return this.cursor; }
  get total(): number { return this.commands.length; }
  all(): StrokeRecord[] {
    return this.orderIds.map((id: string) => copyRecord(this.records.get(id)!));
  }
  find(id: string): StrokeRecord | undefined {
    const value = this.records.get(id);
    return value ? copyRecord(value) : undefined;
  }
}

有两个实际边界需要补充。代码给历史设置了 256 条的内存上限,达到上限时删除最早命令,但当前有效文档仍完整保留。所以用户无法无限向前撤销,却不会因为裁剪日志而丢失画布内容。第二,remove 命令所携带的 record 应当来自 history.find(id),而不是选择工具中残留的旧对象:这样反向添加时才拥有正确颜色、线宽与顺序。若连续删除多个对象,最好包装成一次复合命令,保证一次撤销恢复整个选择动作。

把命令历史等同于持久化文件仍然是不够的。这里处理的是当前页面内的撤销体验;后续需要恢复文档时,可以保存当前有效文档与必要的历史窗口,配合文件版本管理。不要把页面状态变量直接当成长期存储格式。

撤销以后,为什么还要经过脏区计算

文档正确了,画面还没必然正确。比如一条粗蓝线穿过一条细黑线,删除蓝线后如果只把蓝线覆盖的像素擦成背景色,底下细黑线的交叉部分也会被擦掉。上一节的 BoardRenderer.redraw() 已经提供正确方向:计算被修改对象在当前视口的边界,扩展线宽与抗锯齿余量,清理这个矩形,再重画所有与矩形相交的有效片段。脏区只是限制需要重画的位置,不是要重画哪几条线的唯一依据。

StrokePiece 具有 from、to 和可选 control。二次贝塞尔曲线被三个控制点的凸包包含,因此将这些点映射到视口坐标后求包围盒,再扩展线宽,是一个安全且容易理解的粗边界。更精确的极值边界可以减少面积,但会增加实现复杂度;在课堂画布初期,优先保证局部还原没有缺口。

// render/HistoryRepaint.ets
import { StrokePiece } from '../model/StrokeGeometry';
import { StrokeRecord } from '../model/BoardCommand';
import { BoardHistory } from '../model/BoardHistory';
import { ViewportTransform } from '../model/ViewportTransform';
import { expandedRect } from './DirtyRect';
import { BoardRenderer } from './BoardRenderer';

export class HistoryRepaint {
  private renderer: BoardRenderer;
  constructor(private view: ViewportTransform,
    private history: BoardHistory) {
    this.renderer = new BoardRenderer(view);
  }

  repaint(ctx: CanvasRenderingContext2D, changed: StrokeRecord): void {
    const positions: { x: number; y: number }[] = [];
    changed.pieces.forEach((piece: StrokePiece) => {
      positions.push(this.view.toScreen(piece.from));
      positions.push(this.view.toScreen(piece.to));
      if (piece.control) { positions.push(this.view.toScreen(piece.control)); }
    });
    if (positions.length === 0) { return; }
    const padding = changed.width * this.view.scale / 2 + 4;
    const dirty = expandedRect(positions, padding);
    const active: StrokePiece[] = [];
    this.history.all().forEach((record: StrokeRecord) => {
      record.pieces.forEach((piece: StrokePiece) => active.push(piece));
    });
    this.renderer.redraw(ctx, dirty, active);
  }
}

这里为了复用上一节的 BoardRenderer,把有效笔迹展开为片段列表。真正需要逐笔颜色、宽度和不透明度时,可以让渲染器消费 StrokeRecord[],在每次绘制前切换画笔参数;但无论采用哪种结构,都不应把撤销命令交给 Canvas 自己去理解。工具栏控制器只负责调用 history.undo() 或 history.redo() 并把返回的变化对象交给 repaint()。

开发环境中的命令游标与画布重绘流程

历史游标负责选择有效命令;渲染器负责把更新后的有效文档恢复到画布上。

给工具栏接线时,别漏掉一次完整提交

为了让撤销行为符合用户预期,要把“临时笔迹”和“已经完成的笔迹”彻底分开。手指滑动期间仍可沿用第二、三篇的动态笔迹预览;当本次触控结束时,把已经完成的 StrokePiece[] 封成 StrokeRecord,由 history.execute() 一次提交。选择删除则从 history.find() 取对象,再提交 remove。按钮逻辑不应直接操作渲染器的内部片段数组。

// pages/BoardActions.ets
import { StrokePiece } from '../model/StrokeGeometry';
import { StrokeRecord } from '../model/BoardCommand';
import { BoardHistory } from '../model/BoardHistory';
import { HistoryRepaint } from '../render/HistoryRepaint';

export class BoardActions {
  private nextId: number = 1;
  constructor(private history: BoardHistory,
    private painter: HistoryRepaint,
    private ctx: CanvasRenderingContext2D) {}

  finishStroke(pieces: StrokePiece[], color: string,
    width: number): void {
    if (pieces.length === 0) { return; }
    const record: StrokeRecord = {
      id: `stroke-${this.nextId++}`, color: color,
      width: width, order: this.history.all().length, pieces: pieces
    };
    const changed = this.history.execute({ operation: 'add', record: record });
    this.painter.repaint(this.ctx, changed);
  }

  deleteStroke(id: string): void {
    const old = this.history.find(id);
    if (!old) { return; }
    const changed = this.history.execute({ operation: 'remove', record: old });
    this.painter.repaint(this.ctx, changed);
  }

  undo(): void {
    const changed = this.history.undo();
    if (changed) { this.painter.repaint(this.ctx, changed); }
  }
  redo(): void {
    const changed = this.history.redo();
    if (changed) { this.painter.repaint(this.ctx, changed); }
  }
}

历史数组的最大长度并不决定笔迹 ID 的安全性。若工程重建页面时让 nextId 又从 1 开始,而旧文档仍有相同标识,新增操作将触发重复冲突。实际接入持久化时,可以使用每份文档独立的单调序号加文档标识;跨设备共同编辑则还要采用可以合并的全局唯一标识。这里的简单计数器用于说明操作链路,不应直接用于跨设备冲突消解。

下图的应用界面把“撤销”“重做”和历史游标分开放置,便于观察执行新笔迹后重做按钮何时变为不可用。界面呈现的历史位置始终取自 BoardHistory,不另维护一个容易过期的计数器。

带撤销与重做操作历史的课堂白板界面

界面状态和有效笔迹都读取同一份文档历史,不把工具栏计数当成独立事实来源。

用几个反例检查设计是否稳固

可以用四组交互验证状态转移,不需要任何外部服务。其一,画 A、画 B、撤销、重做,最终应回到两条笔迹;其二,画 A、画 B、撤销、画 C,重做应处于不可用状态,页面保留 A 和 C;其三,添加与删除同一条笔迹后连续撤销两次,文档应经历“恢复旧笔迹→清除新增笔迹”的逆序变化;其四,画布放大后删除一条斜线,局部区域内其他笔迹必须保持原先层次。若只有历史游标正确而局部图像缺失,排查 DirtyRect 与重绘候选筛选;若图像看似正确而再次撤销失败,排查命令对象是否被后续编辑改写。

性能上不宜把一次 Move 产生的每个贝塞尔片段都复制进全量历史。以整条已完成笔迹为提交单位,既降低数组增长速度,也符合用户撤销的直觉。局部重绘负责降低每次画面更新的代价,历史上限负责控制命令内存,两种优化处于不同层次。等到需要笔迹导出与跨页面恢复时,能够持续演进的是已经分离的“有效文档、命令日志、画布呈现”三层,而不是某个撤销按钮的事件处理代码。

至此,课堂批注板已经拥有独立的触点采集、曲线几何、坐标映射、局部重绘以及可逆编辑命令。下一篇会把有效笔迹写入持久化文件并考虑图片导出,重点检查数据格式版本、保存原子性和图片尺寸与坐标的对应关系,让一块课堂白板从“当前可以操作”进一步走向“下次打开仍能继续编辑”。

参考文档:华为开发者:ArkUI Canvas 自定义绘制;华为开发者:Canvas 实时更新实践。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐