Day 28:撤销/重做架构(Undo/Redo Architecture)

C++/Qt 每日设计模式 · 架构模式阶段 · 2026-09-19

架构模式 命令栈 QUndoStack 命令合并 脏标记

01

今日主题

一句话定义:撤销/重做架构是指把「用户的一次改动」从「直接改数据」升级为一个可入栈的对象(命令),对象里同时携带这次改动的正向动作与逆动作;所有命令按发生顺序排在一个线性历史里,再用一个游标指向「当前所在的位置」,撤销就是游标后退并执行逆动作,重做就是游标前进并执行正向动作——数据的每一种状态都不需要被保存,只需要「改动链条」被保存。

它不是 GoF 23 里的一个类级模式,而是架构级的组合套路:命令模式(Day 14)提供「把动作对象化」的能力 + 栈与游标提供「历史与时间旅行」的能力 + 观察者(Day 18)把「历史变了」广播给所有 UI + 备忘录(Day 17)的思想被压缩进每条命令内部(只存逆操作需要的最小状态,而不是整份文档快照)。

本篇定位:Day 24/25/26/27 一路把「模块之间怎么分工、怎么说话、怎么被装进来」梳理完了。今天回答的是桌面客户端最无法回避的一道题:用户点错了,怎么退回去?而且要能退到任意一步、能再前进、能一眼看出「文档有没有被改过」、连续拖动鼠标 60 次不能变成 60 条历史。这是「编辑器类软件」的骨架,也是把 Day 14 命令模式真正落地成产品级能力的分水岭。

02

为什么需要这个模式(无模式坏代码)

先看一段「能跑但没有架构」的写法。这是一个画板程序,每次用户操作直接改数据,撤销用「记录上一步做了什么」的方式硬凑:

// ❌ 反例:数据 + 撤销逻辑 + 用户操作 全部糊在一起
class BadEditor {
public:
    void addShape(const std::string& name) {
        shapes_.push_back({name, 10, 10});
        lastAction_ = Action::Add;      // 只能记住"最后一步是什么"
        lastAddedIndex_ = shapes_.size() - 1;
    }

    void moveShape(int i, int x, int y) {
        lastOldX_ = shapes_[i].x;       // 想记住旧值,就得为每个动作
        lastOldY_ = shapes_[i].y;       // 各准备一组成员变量
        shapes_[i].x = x;
        shapes_[i].y = y;
        lastAction_ = Action::Move;
        lastMovedIndex_ = i;
    }

    void renameShape(int i, const std::string& n) {
        lastOldName_ = shapes_[i].name;
        shapes_[i].name = n;
        lastAction_ = Action::Rename;
    }

    void undo() {
        switch (lastAction_) {          // ❌ 每次加新功能,这里必须改
        case Action::Add:    shapes_.pop_back(); break;
        case Action::Move:   shapes_[lastMovedIndex_].x = lastOldX_;
                             shapes_[lastMovedIndex_].y = lastOldY_; break;
        case Action::Rename: shapes_[lastMovedIndex_].name = lastOldName_; break;
        case Action::None:   break;
        }
        lastAction_ = Action::None;     // ❌ 只能撤销一次,再点就没反应
    }

private:
    struct Shape { std::string name; int x, y; };
    std::vector<Shape> shapes_;
    enum class Action { None, Add, Move, Rename };
    Action lastAction_ = Action::None;
    std::size_t lastAddedIndex_ = 0;
    int lastMovedIndex_ = -1, lastOldX_ = 0, lastOldY_ = 0;
    std::string lastOldName_;
};

这段代码的问题不是「写得丑」,而是它在架构上不可扩展、不可测试、不可组合:

一句话:它把「怎么改」和「改了什么」写成同一件事,于是「回到过去」这件事就无处安放。

03

核心思想

通俗解释:不要把「历史」理解成「一堆过去的照片」(整份文档快照),而要把历史理解成一本流水账。账本里不记录「账户现在有多少钱」,只记录「谁给谁转了多少钱」。想知道余额,就从头累加;想撤销一笔转账,就反向记一笔冲正——而不是去翻拍一张旧照片。

生活类比:复式记账(⌐ 借贷必相等)。会计做账有个铁律:每一笔业务都要记两条:一条借方、一条贷方。这使得任何一笔记错都能被「冲正」抹掉,而账本本身永远是一串可审计的记录,不是一张随时被覆盖的余额表。撤销/重做架构就是把这个铁律搬进软件:任何一次改动都必须同时定义一个正向动作与一个逆动作,两者互为冲正。

再换一个更好懂的画面:Photoshop / Figma 的「历史记录」面板。你画了 20 笔,面板上就是 20 行;点回第 7 行,后面 13 行变灰(不是被删掉,只是「现在不在那条时间线上」);此时你再画一笔,那 13 行灰条才真正被丢弃。这个「灰条」的存在,正是撤销/重做架构里最关键也最容易被忽略的东西——游标(index)。

三条设计要点,记住这三条就抓住了本模式:

  • ① 动作对象化:把「移动形状」变成一个对象(MoveShapeCommand),它自己知道怎么做(redo())和怎么反着做(undo())。历史管理只认识「命令」这个抽象,不认识形状、颜色、文本。
  • ② 命令只存「差值」,不存「全量」:移动命令只存 oldPos 和 newPos 两个点,而不是整份文档。这正是它与备忘录模式(Day 17)的本质区别:Memento 保存状态快照,Undo 架构保存状态差量。
  • ③ 栈 + 游标 = 时间轴:命令按顺序排在容器里,index 指向「下一个将被重做的位置」。撤销 = --index 后执行 undo();重做 = 执行 redo() 后 ++index;在中间位置压入新命令 = 截断游标之后的所有命令(灰条消失)。
04

UML / 角色关系

整体结构如下(文字版类图,方框内为角色职责):

   ┌──────────────┐  产生命令   ┌─────────────────────┐
   │  Client      │────────────▶│  Command (抽象)      │
   │  UI 手势/菜单 │   new +     │  + redo()  = 0       │
   └──────────────┘   push()    │  + undo()  = 0       │
                                │  + id()             │
                                │  + mergeWith(c)     │
                                └──────────┬──────────┘
                                           │ 继承
                    ┌──────────────────────┼──────────────────────┐
                    ▼                      ▼                      ▼
        ┌───────────────────┐  ┌────────────────────┐  ┌──────────────────┐
        │ AddShapeCommand   │  │ MoveShapeCommand   │  │ SetColorCommand  │
        │ -doc, shape, index│  │ -doc,index,old,new │  │ -doc,index,old,new│
        │ redo()=插入       │  │ redo()=设新坐标    │  │ redo()=设新颜色  │
        │ undo()=按下标移除 │  │ undo()=回旧坐标    │  │ undo()=回旧颜色  │
        └─────────┬─────────┘  └─────────┬──────────┘  └────────┬─────────┘
                  │  调用接收者的原子方法(setShapePos/insertShape…)
                  ▼                      ▼                      ▼
             ┌────────────────────────────────────────────────────────┐
             │  Receiver:ShapeDocument(纯数据,不认识"撤销"二字)   │
             └────────────────────────────────────────────────────────┘

   ┌──────────────────────────────────────────────────────────┐
   │  Invoker:UndoStack / QUndoStack                         │
   │  - commands: [C1][C2][C3][C4][C5]      ← 有序历史         │
   │  - index:             ▲                                  │
   │                    游标(=3):左边已做、右边已撤销         │
   │  + push(cmd)  加命令(并截断右边的"灰条")               │
   │  + undo()/redo()  游标后退/前进                          │
   │  + canUndo()/canRedo()/undoText()  给 UI 用              │
   │  + setClean()/isClean()/cleanChanged  脏标记             │
   └──────────────────────────────────────────────────────────┘
             │ 通知(观察者)
             ▼
   ┌──────────────────────────────────────────────────────────┐
   │  View:QUndoView 历史面板 / 撤销动作的 enabled+文案 /    │
   │        窗口标题上的"已修改"星号                          │
   └──────────────────────────────────────────────────────────┘

各角色职责与约束:

05

最小 C++17 示例(手写命令栈)

在碰 Qt 之前,先用标准库把骨架写一遍:抽象命令 + 接收者(文档)+ 具体命令(带合并)+ 命令栈(带游标与脏标记)。这段代码是自包含的,可直接编译运行。

// minimal_undo.cpp —— C++17 手写撤销/重做栈
// 编译: g++ -std=c++17 -Wall -Wextra -o minimal_undo minimal_undo.cpp
#include <algorithm>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

// ---------- 抽象命令:撤销/重做的唯一契约 ----------
class Command {
public:
    virtual ~Command() = default;                 // 多态删除必须是 virtual
    virtual void redo() = 0;                      // 第一次执行与"重做"共用同一路径
    virtual void undo() = 0;                      // 逆操作
    virtual std::string text() const = 0;         // 供 UI 显示(QUndoView 的等价物)
    virtual bool mergeWith(const Command&) { return false; }   // 默认不合并
};

// ---------- Receiver:纯数据,不认识"撤销"二字 ----------
class Document {
public:
    void insert(std::size_t pos, const std::string& s) {
        if (pos > text_.size()) pos = text_.size();      // 边界保护,杜绝越界
        text_.insert(pos, s);
    }
    void erase(std::size_t pos, std::size_t n) {
        if (pos >= text_.size()) return;
        text_.erase(pos, std::min(n, text_.size() - pos));
    }
    const std::string& text() const { return text_; }
    std::size_t size() const { return text_.size(); }

private:
    std::string text_;
};

// ---------- ConcreteCommand:插入文本,支持连续输入合并 ----------
class InsertCommand : public Command {
public:
    InsertCommand(Document& doc, std::size_t pos, std::string str)
        : doc_(doc), pos_(pos), str_(std::move(str)) {}

    void redo() override { doc_.insert(pos_, str_); }        // 正向:整段插到起点
    void undo() override { doc_.erase(pos_, str_.size()); }  // 逆向:按长度删掉

    std::string text() const override { return "插入「" + str_ + "」"; }

    // 合并条件:新插入紧接在本命令末尾(== 用户在末尾连续打字)
    bool mergeWith(const Command& other) override {
        if (auto* o = dynamic_cast<const InsertCommand*>(&other)) {
            if (o->pos_ != pos_ + str_.size()) return false;
            str_ += o->str_;      // 起点不变,内容变长 → 一次撤销撤掉整段输入
            return true;
        }
        return false;
    }

private:
    Document& doc_;              // 引用:命令不拥有文档
    std::size_t pos_;             // 首次插入的起点(合并后仍是它)
    std::string str_;             // 累积内容
};

// ---------- Invoker:命令栈 + 游标 + 脏标记 ----------
class UndoStack {
public:
    static constexpr std::size_t kInvalid = static_cast<std::size_t>(-1);

    explicit UndoStack(std::size_t limit = 100) : limit_(limit) {}

    void push(std::unique_ptr<Command> cmd) {
        cmd->redo();                       // ① 先落地改动(与 Qt 的 push 语义一致)
        cmds_.resize(index_);              // ② 截断"重做分支":此处之后的历史作废
        if (cleanIndex_ != kInvalid && cleanIndex_ > index_)
            cleanIndex_ = kInvalid;        //    保存点被截断掉了 → 脏标记永远为"脏"

        // ③ 尝试与栈顶命令合并(连续同类操作折叠成一条历史)
        if (index_ > 0 && cmds_.back()->mergeWith(*cmd)) {
            return;                        //    合并成功:不新增历史项
        }
        cmds_.push_back(std::move(cmd));
        ++index_;

        if (cmds_.size() > limit_) {       // ④ 历史上限:丢弃最老的一条
            cmds_.erase(cmds_.begin());
            --index_;
            if (cleanIndex_ != kInvalid && cleanIndex_ > 0) --cleanIndex_;
        }
    }

    bool canUndo() const { return index_ > 0; }
    bool canRedo() const { return index_ < cmds_.size(); }
    void undo() { if (canUndo()) cmds_[--index_]->undo(); }
    void redo() { if (canRedo()) cmds_[index_++]->redo(); }

    bool isClean() const { return cleanIndex_ != kInvalid && index_ == cleanIndex_; }
    void setClean() { cleanIndex_ = index_; }      // "保存"后调用:当前点即干净点

    std::string undoText() const {
        return canUndo() ? cmds_[index_ - 1]->text() : std::string{};
    }
    std::size_t depth() const { return cmds_.size(); }

private:
    std::vector<std::unique_ptr<Command>> cmds_;   // 有序历史,独享所有权
    std::size_t index_ = 0;                        // 游标:下一个将被重做的位置
    std::size_t cleanIndex_ = 0;                   // 保存点(脏标记的基准)
    std::size_t limit_ = 100;
};

int main() {
    Document doc;
    UndoStack stack;

    stack.push(std::make_unique<InsertCommand>(doc, 0, "Hello"));
    std::cout << "1) text = \"" << doc.text() << "\"\n";

    stack.push(std::make_unique<InsertCommand>(doc, doc.size(), ", Qt"));
    stack.push(std::make_unique<InsertCommand>(doc, doc.size(), "6"));    // 会被合并
    std::cout << "2) text = \"" << doc.text()
              << "\"  历史项数 = " << stack.depth() << "   (3 次输入 → 1 条历史)\n";
    std::cout << "   可撤销 = " << std::boolalpha << stack.canUndo()
              << "  撤销将执行: " << stack.undoText() << "\n";

    std::cout << "3) 脏标记 isClean = " << std::boolalpha << stack.isClean()
              << "  (有改动 → 脏)\n";
    stack.setClean();                                  // 模拟"保存"
    std::cout << "   保存后 isClean = " << stack.isClean() << "\n";

    stack.undo();
    std::cout << "4) undo 后 text = \"" << doc.text()
              << "\"  isClean = " << stack.isClean() << "\n";
    stack.redo();
    std::cout << "5) redo 后 text = \"" << doc.text()
              << "\"  isClean = " << stack.isClean() << "  (回到保存点 → 干净)\n";

    stack.undo();
    stack.undo();                                      // 第二次已是空栈,安全无操作
    std::cout << "6) 两次 undo 后 text = \"" << doc.text()
              << "\"  可重做 = " << std::boolalpha << stack.canRedo() << "\n";

    stack.push(std::make_unique<InsertCommand>(doc, doc.size(), "World"));  // 新分支
    std::cout << "7) 新操作后 text = \"" << doc.text()
              << "\"  可重做 = " << stack.canRedo() << "  (灰条被丢弃)\n";
    return 0;
}

程序输出:

1) text = "Hello"
2) text = "Hello, Qt6"  历史项数 = 1   (3 次输入 → 1 条历史)
   可撤销 = true  撤销将执行: 插入「Hello, Qt6」
3) 脏标记 isClean = false  (有改动 → 脏)
   保存后 isClean = true
4) undo 后 text = ""  isClean = false
5) redo 后 text = "Hello, Qt6"  isClean = true  (回到保存点 → 干净)
6) 两次 undo 后 text = ""  可重做 = true
7) 新操作后 text = "World"  可重做 = false  (灰条被丢弃)

这段代码里值得盯住的四行:

06

Qt 实战:QUndoCommand / QUndoStack / QUndoView

好消息是:撤销/重做是 Qt 官方内置框架能力,不需要自己写栈。Qt 提供三件套(外加一个多文档用的 QUndoGroup):

我们做一个「矢量画板」小工程:双击画布新增形状、拖动合并成一条历史、一键宏命令批量高亮、保存后星号消失。工程共 6 个文件。

① shapedocument.h —— 数据层(接收者)

// shapedocument.h —— 纯数据:不认识 QUndoStack,只提供"最小原子操作"
#pragma once
#include <QColor>
#include <QList>
#include <QPointF>
#include <QSizeF>
#include <QString>

struct Shape {
    QString name;
    QString type;                       // "rect" 或 "ellipse"
    QPointF pos;                        // 左上角(逻辑坐标)
    QSizeF  size{80.0, 60.0};
    QColor  color{0x2E, 0x86, 0xAB};
};

class ShapeDocument {
public:
    int count() const { return shapes_.size(); }
    const QList<Shape> &shapes() const { return shapes_; }

    // 每个方法都是一次不可再分的原子改动,全部带边界保护
    void insertShape(int index, const Shape &s) {
        if (index < 0) index = 0;
        if (index > shapes_.size()) index = shapes_.size();
        shapes_.insert(index, s);
    }
    void removeShape(int index) {
        if (index >= 0 && index < shapes_.size()) shapes_.removeAt(index);
    }
    void setShapePos(int index, const QPointF &p) {
        if (index >= 0 && index < shapes_.size()) shapes_[index].pos = p;
    }
    void setShapeColor(int index, const QColor &c) {
        if (index >= 0 && index < shapes_.size()) shapes_[index].color = c;
    }

private:
    QList<Shape> shapes_;               // 数据层不知道"撤销"这件事存在
};

② commands.h —— 命令层(声明)

// commands.h —— 把"一次原子改动 + 它的逆操作"打包成可入栈对象
#pragma once
#include <QColor>
#include <QPointF>
#include <QUndoCommand>
#include "shapedocument.h"

// 新增形状:redo() 插入,undo() 按"实际记录下来的下标"移除
class AddShapeCommand : public QUndoCommand {
public:
    AddShapeCommand(ShapeDocument *doc, const Shape &shape, QUndoCommand *parent = nullptr);
    void undo() override;
    void redo() override;
private:
    ShapeDocument *doc_;
    Shape shape_;
    int index_ = -1;            // 首次 redo() 之后才知道的确切插入位置
    bool inserted_ = false;     // 幂等保护:防止 redo/undo 被多调用导致状态漂移
};

// 移动形状:id() 相同且针对同一个形状的连续移动会被栈合并成一条历史
class MoveShapeCommand : public QUndoCommand {
public:
    static constexpr int kId = 1001;    // 稳定的合并标识(QUndoCommand 默认 id() 为 -1,永不合并)

    MoveShapeCommand(ShapeDocument *doc, int index, const QPointF &newPos,
                     QUndoCommand *parent = nullptr);
    void undo() override;
    void redo() override;
    int id() const override { return kId; }
    bool mergeWith(const QUndoCommand *other) override;
private:
    ShapeDocument *doc_;
    int index_;
    QPointF newPos_;
    QPointF oldPos_;            // 构造时抓取的旧值 —— 逆操作的全部依据
};

// 改颜色:同样支持合并,让"连续拖色条"只留一条历史
class SetColorCommand : public QUndoCommand {
public:
    static constexpr int kId = 1002;
    SetColorCommand(ShapeDocument *doc, int index, const QColor &color,
                    QUndoCommand *parent = nullptr);
    void undo() override;
    void redo() override;
    int id() const override { return kId; }
    bool mergeWith(const QUndoCommand *other) override;
private:
    ShapeDocument *doc_;
    int index_;
    QColor newColor_;
    QColor oldColor_;
};

③ commands.cpp —— 命令层(实现)

// commands.cpp
#include "commands.h"

// ================= AddShapeCommand =================
AddShapeCommand::AddShapeCommand(ShapeDocument *doc, const Shape &shape, QUndoCommand *parent)
    : QUndoCommand(QObject::tr("添加形状「%1」").arg(shape.name), parent)
    , doc_(doc), shape_(shape) {}

void AddShapeCommand::redo() {
    if (inserted_) return;                  // 幂等:同一条命令重复 redo 不会重复插入
    index_ = doc_->count();                 // 追加到末尾
    doc_->insertShape(index_, shape_);
    inserted_ = true;
}

void AddShapeCommand::undo() {
    if (!inserted_) return;
    doc_->removeShape(index_);              // 用记录下来的下标精确回滚
    inserted_ = false;                      // 之后若再 redo,会重新插入并刷新 index_
}

// ================= MoveShapeCommand =================
MoveShapeCommand::MoveShapeCommand(ShapeDocument *doc, int index, const QPointF &newPos,
                                   QUndoCommand *parent)
    : QUndoCommand(QObject::tr("移动形状"), parent)
    , doc_(doc), index_(index), newPos_(newPos)
{
    // 构造发生在改动之前 —— 这正是"抓旧值"的唯一正确时机
    oldPos_ = (index >= 0 && index < doc->count()) ? doc->shapes().at(index).pos : QPointF();
}

void MoveShapeCommand::redo() { doc_->setShapePos(index_, newPos_); }
void MoveShapeCommand::undo() { doc_->setShapePos(index_, oldPos_); }

// QUndoStack::push() 会先执行新命令的 redo() 让改动落地,再询问栈顶命令能否合并。
// 因此合并的语义是:栈顶命令要"代表两次改动之和"——起点仍是最初的 oldPos_,
// 终点换成最新的 newPos_。若这里写成 oldPos_ = o->oldPos_,合并不可能失败,
// 但时间旅行会漂移。
bool MoveShapeCommand::mergeWith(const QUndoCommand *other) {
    const auto *o = dynamic_cast<const MoveShapeCommand *>(other);
    if (!o || o->doc_ != doc_ || o->index_ != index_) return false;
    newPos_ = o->newPos_;
    return true;
}

// ================= SetColorCommand =================
SetColorCommand::SetColorCommand(ShapeDocument *doc, int index, const QColor &color,
                                 QUndoCommand *parent)
    : QUndoCommand(QObject::tr("修改颜色"), parent)
    , doc_(doc), index_(index), newColor_(color)
{
    oldColor_ = (index >= 0 && index < doc->count()) ? doc->shapes().at(index).color : QColor();
}

void SetColorCommand::redo() { doc_->setShapeColor(index_, newColor_); }
void SetColorCommand::undo() { doc_->setShapeColor(index_, oldColor_); }

bool SetColorCommand::mergeWith(const QUndoCommand *other) {
    const auto *o = dynamic_cast<const SetColorCommand *>(other);
    if (!o || o->doc_ != doc_ || o->index_ != index_) return false;
    newColor_ = o->newColor_;
    return true;
}

④ mainwindow.h / mainwindow.cpp —— 视图与手势翻译

// mainwindow.h
#pragma once
#include <QMainWindow>

class ShapeCanvas;
class ShapeDocument;
class QUndoStack;
class QUndoView;
class QLabel;

class MainWindow : public QMainWindow {
    Q_OBJECT
public:
    explicit MainWindow(QWidget *parent = nullptr);

private slots:
    void addRandomShape();          // 工具栏:添加形状
    void highlightRects();          // 工具栏:宏命令,一组改动一次撤销
    void refreshDirtyState(bool clean);

private:
    ShapeDocument *doc_ = nullptr;
    QUndoStack *stack_ = nullptr;
    QUndoView *view_ = nullptr;
    ShapeCanvas *canvas_ = nullptr;
    QLabel *dirtyLabel_ = nullptr;
};
// mainwindow.cpp
#include "mainwindow.h"
#include "commands.h"
#include "shapedocument.h"

#include <QAction>
#include <QColor>
#include <QDockWidget>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QStatusBar>
#include <QToolBar>
#include <QUndoStack>
#include <QUndoView>

// ============ 画布:View + "用户手势 → 命令"的翻译器 ============
class ShapeCanvas : public QWidget {
public:
    ShapeCanvas(ShapeDocument *doc, QUndoStack *stack, QWidget *parent = nullptr)
        : QWidget(parent), doc_(doc), stack_(stack) {
        setMinimumSize(560, 360);
        setMouseTracking(true);
    }

protected:
    void paintEvent(QPaintEvent *) override {
        QPainter p(this);
        p.setRenderHint(QPainter::Antialiasing);
        p.fillRect(rect(), QColor(0xFA, 0xFA, 0xF7));
        for (const Shape &s : doc_->shapes()) {
            const QRectF r(s.pos, s.size);
            p.setPen(QPen(QColor(0x33, 0x33, 0x33), 1));
            p.setBrush(s.color);
            (s.type == QLatin1String("ellipse")) ? p.drawEllipse(r) : p.drawRoundedRect(r, 6, 6);
            p.setPen(Qt::black);
            p.drawText(r, Qt::AlignCenter, s.name);
        }
        if (doc_->count() == 0)
            p.drawText(rect(), Qt::AlignCenter, tr("双击画布添加形状;拖动形状后会合并成一条历史"));
    }

    void mouseDoubleClickEvent(QMouseEvent *e) override {
        Shape s;
        s.name = tr("形状 %1").arg(++nextName_);
        s.type = (nextName_ % 2) ? QStringLiteral("rect") : QStringLiteral("ellipse");
        s.pos  = e->position() - QPointF(40, 30);
        s.color = (nextName_ % 3 == 0) ? QColor(0xE0, 0x7A, 0x5F) : QColor(0x2E, 0x86, 0xAB);
        stack_->push(new AddShapeCommand(doc_, s));   // 唯一写入口:push
        update();
    }

    void mousePressEvent(QMouseEvent *e) override {
        hit_ = indexAt(e->position());
        if (hit_ >= 0) dragOffset_ = e->position() - doc_->shapes().at(hit_).pos;
    }

    void mouseMoveEvent(QMouseEvent *e) override {
        if (hit_ < 0 || !(e->buttons() & Qt::LeftButton)) return;
        // 拖动过程中每帧压一条命令:靠 id() + mergeWith() 折叠成一条历史
        stack_->push(new MoveShapeCommand(doc_, hit_, e->position() - dragOffset_));
        update();
    }

    void mouseReleaseEvent(QMouseEvent *) override { hit_ = -1; }

private:
    int indexAt(const QPointF &p) const {
        const QList<Shape> &list = doc_->shapes();
        for (int i = list.size() - 1; i >= 0; --i)          // 后画的在上面,倒序命中
            if (QRectF(list.at(i).pos, list.at(i).size).contains(p)) return i;
        return -1;
    }

    ShapeDocument *doc_;
    QUndoStack *stack_;
    int hit_ = -1;
    QPointF dragOffset_;
    int nextName_ = 0;
};

// ============ MainWindow:装配栈、动作、历史面板 ============
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
    doc_   = new ShapeDocument(this);
    stack_ = new QUndoStack(this);
    stack_->setUndoLimit(200);                  // 历史上限,防止长会话内存无限增长

    canvas_ = new ShapeCanvas(doc_, stack_, this);
    setCentralWidget(canvas_);

    QToolBar *tb = addToolBar(tr("编辑"));
    // createUndoAction/createRedoAction:文案("撤销 移动形状")、快捷键、可用性全部自动同步
    tb->addAction(stack_->createUndoAction(this, tr("撤销")));
    tb->addAction(stack_->createRedoAction(this, tr("重做")));
    tb->addSeparator();

    QAction *actAdd = tb->addAction(tr("添加形状"));
    connect(actAdd, &QAction::triggered, this, &MainWindow::addRandomShape);

    QAction *actHi = tb->addAction(tr("高亮全部矩形"));
    connect(actHi, &QAction::triggered, this, &MainWindow::highlightRects);

    QAction *actSave = tb->addAction(tr("保存"));
    connect(actSave, &QAction::triggered, this, [this] {
        stack_->setClean();                     // 保存后当前历史位置就是"干净点"
        dirtyLabel_->setText(tr("已保存"));
    });

    view_ = new QUndoView(stack_, this);        // 开箱即用的历史面板
    view_->setEmptyLabel(tr("还没有可撤销的操作"));
    auto *dock = new QDockWidget(tr("历史记录"), this);
    dock->setWidget(view_);
    addDockWidget(Qt::RightDockWidgetArea, dock);

    dirtyLabel_ = new QLabel(this);
    statusBar()->addPermanentWidget(dirtyLabel_);
    connect(stack_, &QUndoStack::cleanChanged, this, &MainWindow::refreshDirtyState);

    setWindowTitle(tr("画板 —— 撤销/重做演示[*]"));
    refreshDirtyState(stack_->isClean());
}

void MainWindow::addRandomShape() {
    static int n = 0;
    Shape s;
    s.name = tr("形状 %1").arg(++n);
    s.type = (n % 2) ? QStringLiteral("rect") : QStringLiteral("ellipse");
    s.pos  = QPointF(20 + (n * 37) % 380, 20 + (n * 53) % 240);
    s.color = (n % 3 == 0) ? QColor(0xE0, 0x7A, 0x5F) : QColor(0x2E, 0x86, 0xAB);
    stack_->push(new AddShapeCommand(doc_, s));
    canvas_->update();
}

void MainWindow::highlightRects() {
    const QList<Shape> shapes = doc_->shapes();       // 先拷贝:push 期间文档会变化
    int rectCount = 0;
    for (const Shape &s : shapes)
        if (s.type == QLatin1String("rect")) ++rectCount;
    if (rectCount == 0) return;                       // 空宏没有意义,直接返回更安全

    stack_->beginMacro(tr("高亮全部矩形"));             // 一组改动 = 一次撤销
    for (int i = 0; i < shapes.size(); ++i) {
        if (shapes.at(i).type == QLatin1String("rect"))
            stack_->push(new SetColorCommand(doc_, i, QColor(0xFF, 0x8C, 0x00)));
    }
    stack_->endMacro();
    canvas_->update();
}

void MainWindow::refreshDirtyState(bool clean) {
    setWindowModified(!clean);                        // 标题栏 [*] 变成 ●
    dirtyLabel_->setText(clean ? tr("已保存") : tr("未保存的修改"));
}

⑤ main.cpp 与 CMakeLists.txt

// main.cpp
#include <QApplication>
#include "mainwindow.h"

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    MainWindow w;
    w.resize(960, 600);
    w.show();
    return app.exec();
}
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(ShapeUndoDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Widgets)   # QUndoStack 在 QtGui 中,随 Widgets 一并链入
qt_standard_project_setup()                     # 打开 AUTOMOC / AUTOUIC / AUTORCC

qt_add_executable(shapeundo
    main.cpp
    mainwindow.cpp
    mainwindow.h
    commands.cpp
    commands.h
    shapedocument.h
)
target_link_libraries(shapeundo PRIVATE Qt6::Widgets)
# 构建与运行(Linux,Qt 6.5+ 均可)
cmake -S . -B build -DCMAKE_PREFIX_PATH=~/Qt/6.7.0/gcc_64
cmake --build build -j"$(nproc)"
./build/shapeundo

跑起来之后你会看到三件事同时成立:

一个必踩的坑(提前说明):如果画布里放的是 QPlainTextEdit/QTextEdit,它们自己带一套撤销栈(QTextDocument 内部维护),与你的 QUndoStack 毫无关系。两套栈同时记录同一个动作,会出现「按一次 Ctrl+Z 撤销两步」或「撤销后控件内容与文档不一致」。正确做法是二选一:要么 setUndoRedoEnabled(false) 彻底关闭控件自带历史、只走你的 QUndoStack;要么不用 QUndoStack,直接用控件自带的 undo()/redo()。

07

代码执行流程(一次拖动 + 一次撤销)

把上面工程里「按住鼠标拖动形状」到「按下 Ctrl+Z」的全过程摊开,共 9 步。注意前半段是命令入栈,后半段是时间旅行。

阶段一:改动落地(正向)

  1. 用户手势进入 Client:ShapeCanvas::mouseMoveEvent 触发。它不修改任何数据——这是本架构最重要的纪律:UI 层只会「翻译意图」,绝不「执行改动」。
  2. 构造命令对象:new MoveShapeCommand(doc_, hit_, target)。构造函数里做了一件关键的事——抓取旧值 oldPos_。此刻文档里还是旧坐标,抓到的必然是「拖动前的位置」;一旦拖完再去抓就晚了。同时把命令文案设为「移动形状」(会显示在历史面板里)。
  3. 入栈:QUndoStack::push(cmd)。栈内部先调用 cmd->redo() → doc_->setShapePos(index_, newPos_),数据被改,画布 update() 后重绘。
  4. 合并判定:栈检查「栈顶命令的 id()」与「新命令的 id()」。若栈顶也是 MoveShapeCommand(id()=1001)且 mergeWith() 返回 true(同一文档、同一下标),则新命令被吸收:栈顶命令的 newPos_ 更新为最新值,新命令被 delete,历史项数不变、游标不动。拖动 200 帧 → 只留 1 条历史。
  5. 截断重做分支(只在未合并、真正入栈时发生):栈把「游标之后的所有命令」清空(Qt 内部 QUndoStack::push 在压栈时保证 index 等于栈元素数,之后的分支被丢弃并析构)。这就是 Figma 里「点了历史中间那行,再画一笔,后面的灰条消失」。
  6. 广播变化(观察者):栈发出 canUndoChanged / undoTextChanged / indexChanged / cleanChanged 等信号。createUndoAction() 生成的 QAction 自动把自己的 enabled 与文本更新为「撤销 移动形状」;QUndoView 刷新列表并高亮当前行;cleanChanged 触发 refreshDirtyState(),标题栏出现 ●。

阶段二:时间旅行(逆向)

  1. 用户按 Ctrl+Z:触发的是 QUndoStack 自动生成的 undo QAction,最终调用 QUndoStack::undo()——注意调用方根本不是具体的命令类,UI 完全不知道「形状」「坐标」这些概念。
  2. 游标后退 + 执行逆操作:栈把 index 减 1,取出那一条命令,调用它的 undo() → doc_->setShapePos(index_, oldPos_)。因为该命令是合并后的,oldPos_ 是整段拖动的起点,所以一次撤销直接回到拖动之前,而不是退到拖动过程中的某一帧。
  3. 再次广播:canRedo() 变 true(重做动作可用了);isClean() 重新计算 index == cleanIndex,标题星号出现或消失;QUndoView 把光标行上移一格,后面那一条变灰待命。此时若 push 一个新命令,第 5 步的截断逻辑会把这些「灰条」彻底丢弃(析构、释放内存)。

流程的本质:整个链路里只有三个动作在流动——「构造命令(抓旧值)」→「push(正向落地 + 合并 + 截断)」→「undo/redo(游标 + 逆/正操作)」。数据层、命令层、栈层、视图层四层之间只有单向依赖:视图 → 栈 → 命令 → 数据。反向一条依赖都没有,所以任何一层都可以被单独替换或单独测试。

08

为什么这样设计(解耦点与扩展点)

把架构拆解开看,本模式一共创造了 5 个解耦点。每一个都对应一条可以独立替换、独立测试的边界:

组合优于继承在这里的体现:我们没有为每种操作写一个 UndoableShape / UndoableText 子类(那会导致类爆炸:N 种数据 × M 种操作),而是把「可撤销性」组合成命令对象。命令与它是「谁的历史、什么数据」无关,只与「能不能执行与回滚」有关——一个 MoveShapeCommand 同时服务所有形状。

可测试性:单条可逆性是最好测的。给定文档初始状态 s0,执行 cmd->redo() 得到 s1,再 cmd->undo(),断言 s0 == s1 回滚后(对象相等);这一条「redo → undo → 状态相等」的不变式,就是整个架构的测试基石。栈层面再测三条:(a)push 后 canUndo 为真;(b)undo 到 0 后继续 undo 不崩;(c)在非栈顶位置 push 会丢弃重做分支。三组测试加起来不到 50 行,却覆盖了 90% 的回归风险。

09

不使用会怎样(三类真实恶化)

恶化一:if-else / switch 膨胀成「操作登记表」。撤销逻辑必须枚举所有操作类型,每加一个功能就加一个 case。当操作类型超过 15 个(这在图形编辑器里只是起步量),这段 switch 会变成 200 行、没人敢碰、每个新人都要改一次的「公共高危区」。更糟的是:它无法靠编译器帮你检查遗漏——忘了加分支,只在用户点到那个操作时才暴露(而且往往是崩溃而非无效)。

恶化二:类字段膨胀(散落的旧值)。每加一种操作,就要在编辑器类上增加「该操作的旧值字段」:lastOldX_/lastOldY_/lastOldName_/lastOldColor_/lastOldFont_……类从 8 个字段涨到 40 个字段,其中 90% 在任意时刻都是无效值(「上次移动的旧坐标」在用户改颜色之后就是脏数据)。本质问题:撤销所需的状态属于「某一次操作」,却被挂在了「整个对象」上。

恶化三:耦合与不可复用。历史管理逻辑被硬编码进编辑器,任何新界面(比如「批量处理脚本」界面)想复用这段历史都不行;想加「宏/批量操作」「撤销栈上限」「脏标记」「历史面板」这四项产品功能,每一项都要重新设计一遍数据结构——而这些恰恰是 QUndoStack 已经免费给了你的能力。

最后一层代价最昂贵:数据与历史不一致的 bug 无法被定位。当「直接改数据」的代码路径散落在 20 个地方,就必然会有 3 处忘了走 push。用户看到的现象是「撤销后画面错乱」,而你只能靠猜——因为不存在一个「所有改动都必须经过的入口」可供审查。撤销/重做架构的真正价值,很大一部分是把「所有写操作」收敛成一个单点入口,使正确性可以被审查。

10

何时使用 / 何时别用

适合使用的场景(5 类):

  1. 编辑器类桌面应用:图形画板、矢量编辑器、代码编辑器、表格编辑器、参数配置器。特征是「用户会连续做很多次可回退的微操作」。
  2. 需要「审计轨迹」的业务工具:报表设计器、工作流编排器、PLC/HMI 组态软件。这类软件往往还需要把「操作日志」导出成可回放的脚本——命令对象天然就是可序列化的操作记录。
  3. 用户信心成本高的场景:批量改动的工具(一次改 500 个对象的属性)。如果改错了没法退,用户就不敢按那个按钮,功能等于废掉。
  4. 需要「文档脏标记 + 保存点」的场景:任何桌面应用都要在关闭时问「是否保存?」。用 setClean()/isClean() 实现是行业标准做法,比手工维护 dirty 布尔正确得多。
  5. 微操作密集的场景:拖动、连续输入、数值微调。这些场景如果不做命令合并,历史会被几十倍的噪声淹没——合并本身只有几行代码,收益极大。

不适合(或需要权衡)的场景(4 类):

  1. 没有真实用户交互的批处理/服务端程序:全自动流水线里没人按 Ctrl+Z,白付一份内存与复杂度。
  2. 状态巨大且改动无法表达成差量的场景:例如视频转码的进度、实时通信会话、随机数驱动的模拟。这类「状态」没有稳定的逆操作,硬套命令栈只能靠快照,得不偿失。
  3. 不可逆的外部副作用:发送邮件、调用支付接口、写远程数据库。命令的 undo() 无法「收回」一个已发出的网络请求——这时需要的是「补偿事务(Saga),包含人工确认」,而不是简单的撤销栈。
  4. 一次性脚本、内部小工具:整个程序 200 行、用户是自己。引入完整命令体系属于典型过度设计。

过度设计提醒(三条红线):① 不要为「未来可能会有的功能」预先抽象——先有第二、第三个操作,再抽命令基类;只有一个操作时直接改数据。② 不要把所有东西都做成命令:视图状态(缩放、滚动位置、面板开关)通常不该进撤销栈,否则用户按 Ctrl+Z 只是想撤销一步编辑,画面却开始乱跳。③ 命令的粒度要匹配用户心智:一次 Ctrl+Z 应该对应「用户心里的一件事」,而不是「一次函数调用」——拖动是「一次移动」,批量改色是「一次高亮」,打字是「一段输入」。粒度错了,功能就是残废的。

11

与其他模式的区别

最容易被混淆的三个:命令模式(Day 14)、备忘录模式(Day 17)、状态模式(Day 19)。一句话区分:命令模式是“零件”,撤销/重做架构是“由这个零件装成的机器”;备忘录保存“状态快照”,撤销架构保存“状态差量”;状态模式关心“在不同模式下行为不同”,撤销架构关心“怎么回到过去”。

对比项撤销/重做架构命令模式(Day 14)备忘录(Day 17)状态模式(Day 19)
层次 架构级(模式组合 + 基础设施) 类级(把动作对象化) 类级(把状态快照对象化) 类级(把状态机对象化)
核心问题 怎么回到任意一个历史状态 怎么让「请求」本身成为一等公民(可排队、可记录、可撤销) 怎么在不破坏封装的前提下保存/恢复对象内部状态 怎么让对象在不同状态下表现不同行为
数据的保存方式 存「改动的差值」(old/new 两个值) 不负责保存历史(只管执行一次) 存「整份状态快照」(可能包含大量数据) 不保存历史(只管当前状态与转移)
内存成本 与「改动次数 × 每次改动的字段数」成正比 近似于 0(命令执行完即可释放) 与「快照数 × 对象总大小」成正比(昂贵) 与状态数量成正比(很小)
能否重做 / 时间旅行 可以,且支持任意步跳转(游标) 单独一个命令做不到;需要栈来补 可以恢复到某个快照,但无法表达「撤销一步」的语义 不能
典型实现 QUndoStack + QUndoCommand + QUndoView std::function 队列 / 任务对象 / 菜单动作 Memento(快照)+ Caretaker(保管者) 状态对象 + 上下文转发
关系 使用命令模式实现 undo()/redo();局部需要时可内嵌备忘录 被撤销架构当作基础零件复用 是撤销架构的「备选方案」(大改动的兜底策略) 与撤销架构互补:常在编辑器中用状态机管理「工具模式」(画矩形/画线/选区)

还有一个容易混的:「事务 / 数据库回滚」。数据库事务是在存储层保证原子性(要么全成功要么全不做),而撤销栈是在应用层提供用户级的可逆操作。二者可以叠加:一条命令内部可能包含一个数据库事务,但事务提交后依然可以「业务撤销」(再写一条反向记录)。

12

Qt 源码与官方工具中的体现

这是本主题「Qt 含量」最高的部分——Qt 不只是提供了类,它自己的工具链就是这套架构的重度用户:

13

面试常见问题

Q1:为什么 QUndoCommand 只有 redo()/undo(),而没有单独的 execute()?第一次执行时该怎么办?

A:因为 Qt 让 QUndoStack::push() 在入栈时就调用新命令的 redo()。「第一次执行」和「重做」共用同一条代码路径,从根本上杜绝了「execute 与 redo 两份实现逐渐不一致导致状态漂移」这一类最难查的 bug。代价是:命令对象在构造时就必须抓齐所有逆向所需的状态(旧坐标、旧颜色、旧下标),因为 redo() 被调用的那一刻数据已经在变了。这也解释了为什么命令构造函数的签名通常是 (doc, index, newValue) 而不是 (doc, index, oldValue, newValue)——旧值必须由命令自己从文档里读。

Q2:mergeWith() 和 id() 的关系是什么?为什么必须比对 id()?

A:id() 是「合并族」的身份证:只有栈顶命令与新命令 id() 相同且不为 -1(默认值表示「永不合并」)时,栈才会调用 mergeWith()。这是性能与安全的双重优化——绝大多数命令不值得合并,用 id() 一次性挡掉;同时它避免了「不同类型的命令被误合并」:如果一条「改颜色」被合并进一条「移动」,撤销时会发生「坐标回退但颜色没恢复」的状态错误。mergeWith() 内部还需要自己再校验目标是否相同(本篇代码里比对了 doc_ 与 index_),因为同一个 id() 可能被多个不同形状的操作共用。合并后栈顶命令必须等价于「两个操作都做过」,所以写法是「保留最早的 old 值,接受最新的 new 值」。

Q3:拖动一下形状产生几百条历史,你会怎么处理?

A:两条路线,按需求选。①合并(推荐):拖动期间每帧照常 push,靠 id() + mergeWith() 折叠成一条。优点是用户在拖动中途也能按 Ctrl+Z 退到上一格,体验顺滑;缺点是每个命令对象都要构造(对象分配有成本,但现代内存分配器下可接受)。②延迟提交:拖动期间只改内存里的一份「预览状态」并重绘,直到 mouseReleaseEvent 才真正 push 一条命令。优点是命令对象最少、历史最干净;缺点是拖动期间无法逐步撤销,且需要额外维护预览状态与真实状态的一致性(多了一套容易出错的影子状态)。工程上常用「方案 ①+ 额外合并窗口」:在 mergeWith() 里加时间戳判定,超过 300ms 的两次拖动不再合并,让「停一下再拖」自然断成两条历史。

Q4:「文件已被修改」的提示怎么实现?为什么不能用 dirty 布尔?

A:正确答案是 QUndoStack::setClean() + isClean() + cleanChanged(bool) 信号。因为「有没有修改」的真实定义是「当前历史位置是否等于最后一次保存时的历史位置」,而不是「保存之后有没有发生过操作」。用一个 dirty 布尔会在两个场景出错:(a) 用户改了又撤销回保存点——此时文档内容与已保存版本完全一致,布尔仍是 true,程序会错误地提示「是否保存」;(b) 撤销栈前进了又后退,布尔无法回退。QUndoStack 用 index == cleanIndex 一个等式解决所有情况,且能正确处理「历史上限淘汰导致保存点失效」(cleanIndex 被淘汰时置为非法值,永远为脏)。

Q5:一个应用里打开了 3 个文档,每个都有自己的撤销栈,菜单栏的「撤销」按钮该怎么工作?

A:用 QUndoGroup:每个文档一个 QUndoStack,全部注册进 QUndoGroup;切换当前文档时调用 group->setActiveStack(stack),然后 group->createUndoAction() 生成的动作只作用于当前活动栈,并且会把文案自动变成「撤销 移动形状」这类具体内容。核心要点:每个文档的历史必须彼此隔离(不能共用一个栈,否则撤销会跨越文档、语义混乱);而UI 只有一套(一套动作、一个历史面板实例),由 Group 负责把「当前是谁」这个路由问题接管。QUndoView 甚至可以直接构造在 QUndoGroup 上,自动跟随活动栈。

14

今日练习(15–30 分钟)

需求(在本文示例工程上继续做):

  1. 新增 RemoveShapeCommand:支持删除形状,删除后可以用 Ctrl+Z 精确恢复(恢复到原来的下标位置,而不是追加到末尾)。
  2. 给它加上「合并窗口」:同一个形状的连续拖动在 300ms 内合并成一条历史,停顿超过 300ms 后再拖必须断成两条独立历史(用户在历史面板里能看到两行「移动形状」)。
  3. 验证脏标记:新建 3 个形状 → 保存 → 撤销 3 次到空 → 断言标题栏星号消失(提示:初始 cleanIndex_ 为 0,撤销回空文档时应为「干净」)。

提示(只给思路,不写答案):

15

今日总结

一句话记忆:把每次改动打包成「能自己回头的命令」,用「栈 + 游标」保存历史,于是撤销、重做、脏标记、历史面板全都只是这条命令链的副产品——数据层永远不需要知道「过去」的存在。

代码特征信号(看到这 5 条,基本可以断定项目里用的是撤销/重做架构):

  1. 存在一个只做一件事的写入口:所有数据修改都经过 stack->push(new XxxCommand(...)),任何地方都找不到「直接 setXxx 改数据」的旁路(这是最重要的信号,也是最容易破的纪律)。
  2. 命令类成对出现 redo()/undo(),且构造时抓旧值:命令成员里带着 oldValue_/newValue_ 这种成对的字段,而 redo() 里没有任何 if 分支判断「这是第一次还是重做」。
  3. 类里有 id() + mergeWith():出现 static constexpr int kId = 1001; 这类常量,以及只在「同一目标 + 同一属性」时才返回 true 的窄条件——说明这是生产级而非教学级实现。
  4. UI 层引用了 createUndoAction()/cleanChanged/beginMacro():撤销按钮没有手写 setEnabled 逻辑,标题栏「已修改」来自信号而非布尔字段。
  5. 数据模型里搜不到「undo」这个词:点开 ShapeDocument/Document 数据层,找不到任何历史相关字段——可撤销性被完整地封在操作层。

明日预告:Day 29 —— 状态机架构(State Machine / QStateMachine)。今天解决了「怎么回到过去」,但我们还没回答桌面客户端的另一个必答题:「同一个按钮在‘画矩形’模式下按下要画矩形,在‘选择’模式下按下要选中对象,在‘删除’模式下按下要删东西」——这种「行为随模式变化」的复杂度,如果继续用 if (mode == ...) 铺开会迅速失控。明天我们把它提升到架构层:把「模式」建模成状态(State)、把「用户事件」建模成转移(Transition)、把「进入/退出某状态时该做什么」建模成动作(Entry/Exit Action),并用 Qt 官方状态机框架 QStateMachine/QState/QFinalState 实现一个画板工具切换器,含「分层状态」「历史状态(回到上次模式)」「守卫条件(Transition Guard)」三个实战点,同时和 Day 19 的状态模式(面向对象的实现)做架构对比。