软件设计 · Qt6 · 客户端架构

命令模式与撤销重做:从 QUndoStack 到大型编辑器架构

2026-08-20 · 每日技术学习 · 第 22 期

① 今日主题:为什么学它

撤销/重做是桌面软件里最习以为常、又最容易被做砸的能力。它看起来只是"一个栈加两个按钮",但真实编辑器里它牵涉命令粒度设计、内存权衡、合并策略、崩溃恢复乃至协作语义。作为从 Web 转向 Qt 的架构师,命令模式(GoF Command)是理解 Qt Creator、JetBrains 全家桶、Photoshop 这类大型客户端内部结构的钥匙,也是你在 Web 端写过的 Redux action 心智模型在 C++ 里的升华版。今天以 QUndoStack 为主线,把"操作对象化"这一客户端架构的核心思想讲透。

② 核心知识:命令模式与 QUndoStack 机制

命令模式的本质:把"动作"变成"对象"。GoF 的 Command 模式有四个角色:Command(命令对象,封装"正向操作 + 逆向操作")、Receiver(真正执行逻辑的对象)、Invoker(触发者,如菜单、快捷键、按钮)、Client(负责组装命令)。它解决的核心问题是把"做什么"与"怎么做"解耦:界面层只负责发出一个命令对象,业务层只负责按命令执行。撤销之所以能成立,正因为命令对象同时携带正逆两个操作——执行与回退天然对称,这也解释了为什么"能撤销"的系统几乎都是命令式的。

两种撤销策略:快照 vs 命令。快照式(Snapshot)在每一步保存整个文档状态,撤销就是整体回滚,实现极简单、与"状态驱动"的心智天然对齐;代价是内存随文档体积线性增长,而且难以合并连续输入。命令式(Incremental)只记录"这一步改了什么、以及如何改回去",内存小、可合并、可组合成宏;代价是每个命令都必须有精确的逆操作,实现成本高。前端不可变数据 + 结构共享让快照近乎免费,所以 React/Redux 生态普遍选快照式;C++ 里深拷贝昂贵,所以 Qt 生态普遍选命令式。大型桌面编辑器几乎都是混合策略:命令栈负责日常撤销,周期性 checkpoint 快照负责崩溃恢复与"撤销得很远"(Office 自动恢复、Photoshop 历史快照都是这个思路)。

维度快照式撤销命令式撤销(QUndoStack)
存储内容每步整份文档状态每步最小增量 + 逆操作
内存成本随文档体积线性增长与操作规模成正比
合并连续操作困难(需 diff)原生支持(mergeWith)
崩溃恢复天然可恢复需配合 checkpoint 快照
典型生态Redux / 不可变数据前端Qt / 大型桌面编辑器

QUndoStack 工作机制(Qt6)。QUndoStack 内部维护一个命令列表与当前索引:push(cmd) 会先调用 cmd->redo() 执行命令,再加入栈,同时把当前索引之后的全部命令销毁——新操作一旦发生,旧的重做分支就不再合法。undo() 撤销当前索引之前的命令并回退索引,redo() 重放当前索引处的命令并前进;索引之上的命令可反复重放,保证"任意步撤销后仍能重做"。命令合并:push 时若栈顶命令与新命令的 id() 相等,QUndoStack 会调用栈顶的 mergeWith(newCmd),返回 true 则吞并新命令而不入栈——连续打字、连续拖动因此能合并成一步。宏命令:QUndoCommand 可携带子命令,redo 时按添加顺序、undo 时按逆序递归执行,实现"一个用户操作 = 多个原子步骤"。状态跟踪:setClean()/isClean() 标记"已保存",驱动窗口标题的 * 号与关闭确认;undoText()/redoText() 供菜单动态显示"撤销 输入"这类文案。集成:createUndoAction()/createRedoAction() 返回已绑定 Ctrl+Z / Ctrl+Y 的 QAction;多文档场景用 QUndoGroup 把每文档一个栈组织起来,随激活文档切换当前栈。

命令粒度:命令模式最重要的工程决策。粒度应等于"用户在撤销时能理解的最小原子动作":一次粘贴、一次拖动、一次属性修改。太粗(整文档快照)则合并、审计、内存全部失控;太细(每个字符一条命令)则栈爆炸、按一次 Ctrl+Z 只退一个字符。Qt 的 QTextEdit 会把一段连续输入合并为一次"键入"——这就是粒度设计与合并机制配合的范例。

③ 实际案例:大厂产品为什么这么设计

JetBrains IDEA / VSCode:命令栈 + 本地历史快照双轨制。日常 Ctrl+Z 走命令栈,且会按"用户意图"合并——连续输入、输入法 IME 组合输入(一整段拼音组合作为一次撤销单位)都被合并,避免按一下只退一个字。而 IDEA 的 Local History、VSCode 的 Timeline 是定时快照,解决"昨天误删了、今天没法 undo"的远期恢复需求。设计原因:命令栈解决"撤销快且准",快照解决"撤销远",两种成本曲线互补,缺一个都不完整。

Photoshop:历史记录面板把命令与快照并列。每步操作是一个可回退节点,同时允许用户随时"拍快照"固定某个中间状态(比如修图到满意的一步),之后无论怎么改都能一键回到快照。这是混合策略最直观的产品化表达,也印证了"快照是命令的兜底"。

git:把撤销对象化推到分布式规模。commit 不可变,revert 生成逆操作提交,reflog 记录所有引用变动(作用类似 redo 栈)。你每天用的 git,本质就是命令模式思想在版本管理上的延伸——只有操作可对象化,才能回放、审计、跨人合并。

Chrome 与微信/QQ:轻量场景的原生能力。Chrome 地址栏、微信/QQ 输入框依赖系统编辑控件自带的撤销栈就够了——引入自定义命令栈是过度设计。反例是 Chrome DevTools 的源码编辑器(CodeMirror)仍要实现自己的命令式撤销栈:判断标准很简单——你的操作是否需要合并、回放、跨会话持久化?不需要,就别上命令模式。

④ 常见错误:为什么撤销总是做砸

1. push 导致操作执行两次。QUndoStack::push() 内部会调用 redo(),如果你在 push 前已经手动执行过操作,效果就会叠加(典型症状:方块被移动了两倍距离)。避免:执行权统一交给栈——先构造命令对象再 push,让栈来执行;绝不"手动执行 + push 已执行命令"。

2. 命令对象捕获大快照,内存爆炸。把整个文档深拷贝进命令,undo 100 步 = 100 份文档。避免:命令只存最小增量(坐标、diff、被修改的字段);确需共享大对象时用写时复制或共享不可变数据。

3. 逆操作不对称,undo 无法恢复原状。只写了 redo 没写精确的 undo,或 undo 里依赖了已被外部改动的状态(典型:撤销时想"恢复选中态",但选中的是另一个对象)。避免:写命令时先写逆操作,把 undo 当第一公民;undo/redo 只依赖命令内部保存的数据,不读外部可变状态——这是命令可测试、可回放的前提。

4. 合并条件不严谨,乱合并。mergeWith 里只比了类型没比作用对象与连续性,导致两个不同对象的拖动被错误合并成一步,或相隔很久的操作被吞并。避免:合并必须同时校验 id() 相同、操作对象相同、操作连续(同一次手势/时间窗内),宁可少合并,不可错合并。

5. 手写撤销栈忘记清空 redo 分支。QUndoStack 在 push 时自动销毁 redo 分支;自己实现栈时,undo 之后再 push 新命令,旧 redo 分支没删,用户重做时会跳回"已废弃的历史",状态错乱。避免:记住这是语义要求而非优化——push 时无条件丢弃当前索引之后的全部命令。

6. 忽略崩溃与持久化。纯内存命令栈在崩溃时全部丢失,也无法跨会话恢复。避免:checkpoint 快照 + 命令日志(或序列化命令),启动时从快照 + 日志重建——这也是 Word 自动恢复的原理。

⑤ 最佳实践:何时用、何时不用与 Trade-off

1. 命令粒度 = 用户认知的最小原子动作。一条命令对应一次"用户能说清楚的操作"(粘贴一段、拖动一次、改一个属性),连续同类操作用合并解决,不要拆碎。何时不用:只读工具、纯展示页、一次性表单——没有"可逆编辑"就没有命令栈的立足点。

2. 命令式为主、快照兜底。日常撤销用增量命令(内存小、可合并、可审计);周期性 checkpoint 快照用于崩溃恢复与远期回退。Trade-off:快照实现简单但内存贵、无法合并、无法表达"撤销一步"的语义;命令实现复杂但精准可控。两者是互补不是互斥。

3. 合并用 id() + mergeWith(),条件从严。必须同时满足:id 相同、作用对象相同、操作在时间/手势上连续。合并是用户体验优化,错合并是功能 bug——写 mergeWith 时把"什么情况下不能合并"列清楚。

4. 多步操作用宏命令组合。一个用户操作拆成多个原子步骤时,用 QUndoCommand 的子命令树包成一个命令,保证整体撤销、整体重做,UI 上只显示一步。

5. 执行权统一交给栈,状态跟踪跟上。只构造命令再 push,由栈执行;用 isClean() 驱动"未保存更改"提示与关闭确认,把撤销栈与保存/关闭流程做成一个闭环。

⑥ 与 Web 技术的联系:把已有心智迁移过来

前端同行对命令模式其实不陌生,只是换了名字:Redux 的 action 就是命令对象的 JS 形态——type + payload 描述"做什么",reducer 是 Receiver,dispatch 是 Invoker,middleware 是 Invoker 的拦截层。Redux 官方文档甚至专门有一篇 Implementing Undo History 教程,把 state 包成 {past, present, future} 就是标准撤销栈。区别在于:前端不可变数据 + 结构共享让"快照式"撤销几乎免费,所以 Redux/Vue 生态倾向快照(Vue DevTools 的 time-travel debugging 本质就是快照回放);而 C++ 深拷贝昂贵,必须用增量命令。这是语言特性塑造架构风格的典型例子——理解了这一点,你就不会再问"为什么 Qt 不直接存文档快照"。

浏览器侧:contenteditable 的 document.execCommand('undo') 与系统撤销栈在复杂应用中脆弱不可控,现代做法是用 beforeinput/InputEvent 拦截输入、自己维护撤销栈——你在 Web 端写过的"自定义 undo 栈",到 Qt 里就是 QUndoStack 的工程化版本,概念完全平移。Node 侧的 git 同理:操作对象化之后才能回放、审计、协作合并——命令模式的价值在分布式场景下反而更明显。

⑦ 管理者视角:Team Leader 怎么看撤销

评审"编辑类"需求时,撤销是隐藏需求:产品默认用户会按 Ctrl+Z,需求评审阶段就要定义清楚"撤销粒度"——一次粘贴算一步?一次拖动算一步?批量操作算几步?Code Review 关注四点:命令是否原子、逆操作是否精确对称;mergeWith 条件是否严谨(对象 + 连续性);命令是否持有大资源、生命周期是否随栈销毁(QUndoStack 会 delete 被丢弃的命令,别让命令留下悬垂指针);undo/redo 是否可单元测试。架构上明确撤销栈的实例化粒度(按文档?按视图?全局?),并让"执行→撤销→重做"三态一致性测试进入 CI 常规用例——撤销逻辑是回归重灾区,值得专门投入。

⑧ 延伸阅读

1. Qt 官方文档:QUndoStack / QUndoCommand——合并、宏命令、QUndoGroup 的权威机制说明,配合本文把细节吃透。
2. 《设计模式:可复用面向对象软件的基础》(GoF)第 5 章 Command——模式动机与参与者的原典,值得精读。
3. Redux 官方教程 Implementing Undo History——同一问题的前端快照式解法,与本文命令式对照阅读,迁移收益最大。
4. git 官方文档:git-revert / git-reflog——把"撤销对象化"推到分布式规模的工程实践。
5. Adobe Photoshop 帮助文档 · 历史记录面板——命令历史 + 快照混合策略的真实产品形态。

⑨ 今日思考题

1. 为什么前端生态(Redux/immer)普遍选择快照式撤销,而 Qt 生态普遍选择命令式?如果 Qt 里也用"整文档快照",什么场景下反而更合适?

2. QUndoStack::push 会销毁 redo 分支中的命令对象;如果那些命令持有大纹理或大缓存,会有什么风险?命令的析构成本应该怎么设计?

3. 多文档标签页(每文档一个 QUndoStack + QUndoGroup)与全局单一撤销栈,分别适合什么产品形态?为什么?

4. 连续拖动合并(mergeWith)的边界问题:拖动中途按 Esc 取消、或拖到一半程序异常,撤销栈会处于什么状态?如何保证一致性?

5. 协作编辑(多人同文档)下"撤销"的语义是什么?为什么 OT/CRDT 系统里的 undo 远比单机复杂?

⑩ 今日实践任务

目标:用 Qt6 Widgets 实现"可拖动方块 + 撤销/重做",亲手验证命令对象、对称逆操作与 mergeWith 合并。预计 30-60 分钟。

CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(undo_demo)
find_package(Qt6 REQUIRED COMPONENTS Widgets)
qt_standard_project_setup()
qt_add_executable(undo_demo main.cpp)
target_link_libraries(undo_demo Qt6::Widgets)

main.cpp:

#include <QApplication>
#include <QMainWindow>
#include <QMenuBar>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QGraphicsRectItem>
#include <QGraphicsSceneMouseEvent>
#include <QUndoStack>
#include <QUndoCommand>

class MoveCommand : public QUndoCommand {
public:
    MoveCommand(QGraphicsItem* item, const QPointF& from, const QPointF& to,
                QUndoCommand* parent = nullptr)
        : QUndoCommand(QStringLiteral("移动图形"), parent),
          item_(item), from_(from), to_(to) {}
    void undo() override { item_->setPos(from_); }
    void redo() override { item_->setPos(to_); }
    int id() const override { return 1; }
    bool mergeWith(const QUndoCommand* other) override {
        auto* c = static_cast<const MoveCommand*>(other);
        if (c->item_ != item_) return false;  // 只合并同一对象
        to_ = c->to_;                          // 连续拖动合并为一步
        return true;
    }
private:
    QGraphicsItem* item_;
    QPointF from_, to_;
};

class MovableRect : public QGraphicsRectItem {
public:
    explicit MovableRect(QUndoStack* stack)
        : QGraphicsRectItem(0, 0, 80, 60), stack_(stack) {
        setPos(40, 40);
        setFlag(ItemIsMovable);
    }
protected:
    void mousePressEvent(QGraphicsSceneMouseEvent* e) override {
        from_ = pos();
        QGraphicsRectItem::mousePressEvent(e);
    }
    void mouseReleaseEvent(QGraphicsSceneMouseEvent* e) override {
        QGraphicsRectItem::mouseReleaseEvent(e);
        if (pos() != from_)
            stack_->push(new MoveCommand(this, from_, pos()));
    }
private:
    QUndoStack* stack_;
    QPointF from_;
};

int main(int argc, char** argv) {
    QApplication app(argc, argv);
    QMainWindow w;
    QUndoStack stack(&w);
    w.menuBar()->addAction(stack.createUndoAction(&w, QStringLiteral("撤销"))); // Ctrl+Z
    w.menuBar()->addAction(stack.createRedoAction(&w, QStringLiteral("重做"))); // Ctrl+Y
    auto* scene = new QGraphicsScene(&w);
    scene->addItem(new MovableRect(&stack));
    w.setCentralWidget(new QGraphicsView(scene, &w));
    w.resize(520, 420);
    w.show();
    return app.exec();
}

验证点:① 拖动方块后 Ctrl+Z 精确回到起点、Ctrl+Y 回到终点;② 连续拖动多次,按一次 Ctrl+Z 整段退回(mergeWith 生效);③ 把 mergeWith 删掉重新编译,观察一次拖动产生多条命令;④ 先 undo 几步再拖新位置,确认旧的重做分支被丢弃、重做菜单置灰。

⑪ 一句话总结

把"操作"变成携带逆操作的对象(Command),你就同时拥有了撤销、合并、宏、审计与协作的能力——这是从 Web 的"状态驱动"跃迁到桌面"操作驱动"架构的关键一步。