架构模式 命令栈 QUndoStack 命令合并 脏标记
一句话定义:撤销/重做架构是指把「用户的一次改动」从「直接改数据」升级为一个可入栈的对象(命令),对象里同时携带这次改动的正向动作与逆动作;所有命令按发生顺序排在一个线性历史里,再用一个游标指向「当前所在的位置」,撤销就是游标后退并执行逆动作,重做就是游标前进并执行正向动作——数据的每一种状态都不需要被保存,只需要「改动链条」被保存。
它不是 GoF 23 里的一个类级模式,而是架构级的组合套路:命令模式(Day 14)提供「把动作对象化」的能力 + 栈与游标提供「历史与时间旅行」的能力 + 观察者(Day 18)把「历史变了」广播给所有 UI + 备忘录(Day 17)的思想被压缩进每条命令内部(只存逆操作需要的最小状态,而不是整份文档快照)。
本篇定位:Day 24/25/26/27 一路把「模块之间怎么分工、怎么说话、怎么被装进来」梳理完了。今天回答的是桌面客户端最无法回避的一道题:用户点错了,怎么退回去?而且要能退到任意一步、能再前进、能一眼看出「文档有没有被改过」、连续拖动鼠标 60 次不能变成 60 条历史。这是「编辑器类软件」的骨架,也是把 Day 14 命令模式真正落地成产品级能力的分水岭。
先看一段「能跑但没有架构」的写法。这是一个画板程序,每次用户操作直接改数据,撤销用「记录上一步做了什么」的方式硬凑:
// ❌ 反例:数据 + 撤销逻辑 + 用户操作 全部糊在一起
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_;
};
这段代码的问题不是「写得丑」,而是它在架构上不可扩展、不可测试、不可组合:
shapes_)、又管业务动作(addShape)、又管历史(lastAction_)、又管逆操作算法(undo 里的 switch)。这四件事的变化原因完全不同,却粘在一个类里。case、加一个枚举值。而开关闭原则要求「新增功能 = 新增代码,而不是修改既有代码」。lastOldX_ 是谁的旧值?只有 Move 知道)。高层策略(历史管理)依赖了低层细节,导致历史管理无法复用。moveShape,历史立刻被垃圾塞满;「一次性改十个对象的颜色」这种批量操作也没法表达成「一次撤销」。shapes_[i] 没有边界检查,且若对象已被删除,旧下标变成悬空引用——这是真实项目里最常见的崩溃来源之一。一句话:它把「怎么改」和「改了什么」写成同一件事,于是「回到过去」这件事就无处安放。
通俗解释:不要把「历史」理解成「一堆过去的照片」(整份文档快照),而要把历史理解成一本流水账。账本里不记录「账户现在有多少钱」,只记录「谁给谁转了多少钱」。想知道余额,就从头累加;想撤销一笔转账,就反向记一笔冲正——而不是去翻拍一张旧照片。
生活类比:复式记账(⌐ 借贷必相等)。会计做账有个铁律:每一笔业务都要记两条:一条借方、一条贷方。这使得任何一笔记错都能被「冲正」抹掉,而账本本身永远是一串可审计的记录,不是一张随时被覆盖的余额表。撤销/重做架构就是把这个铁律搬进软件:任何一次改动都必须同时定义一个正向动作与一个逆动作,两者互为冲正。
再换一个更好懂的画面:Photoshop / Figma 的「历史记录」面板。你画了 20 笔,面板上就是 20 行;点回第 7 行,后面 13 行变灰(不是被删掉,只是「现在不在那条时间线上」);此时你再画一笔,那 13 行灰条才真正被丢弃。这个「灰条」的存在,正是撤销/重做架构里最关键也最容易被忽略的东西——游标(index)。
三条设计要点,记住这三条就抓住了本模式:
MoveShapeCommand),它自己知道怎么做(redo())和怎么反着做(undo())。历史管理只认识「命令」这个抽象,不认识形状、颜色、文本。oldPos 和 newPos 两个点,而不是整份文档。这正是它与备忘录模式(Day 17)的本质区别:Memento 保存状态快照,Undo 架构保存状态差量。index 指向「下一个将被重做的位置」。撤销 = --index 后执行 undo();重做 = 执行 redo() 后 ++index;在中间位置压入新命令 = 截断游标之后的所有命令(灰条消失)。整体结构如下(文字版类图,方框内为角色职责):
┌──────────────┐ 产生命令 ┌─────────────────────┐
│ 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+文案 / │
│ 窗口标题上的"已修改"星号 │
└──────────────────────────────────────────────────────────┘
各角色职责与约束:
redo() / undo() / id() / mergeWith() 四件事。关键契约:redo() 必须既能表达「第一次执行」也能表达「重做」——两者的效果必须完全一致,否则时间旅行会出现状态漂移。这是新手最容易写错的地方。redo() 时写入新值,undo() 时写回旧值。命令对象本身是短命但必须自洽的:它的生命周期由栈掌管(栈用 std::unique_ptr / 父子 QObject 语义销毁)。push() 时先执行命令的 redo(),然后尝试与栈顶命令合并(若两者 id() 相同且 mergeWith() 返回 true,则新命令被吸收,历史项数不变)。canUndoChanged / cleanChanged / indexChanged),不轮询。在碰 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 (灰条被丢弃)
这段代码里值得盯住的四行:
cmd->redo() 出现在 push() 的开头而不是 push_back() 之后——这保证了「第一次执行」和「重做」永远走同一条代码路径,历史与数据不可能不同步。cmds_.resize(index_) 是「重做分支失效」的实现:一次 resize 就把游标之后的命令全部析构(unique_ptr 自动释放,无内存泄漏)。undo() 里 --index_ 写在 undo() 调用之前,redo() 里 index_++ 写在调用之后——两个方向的「顺序感」正好相反,是刻意的:撤销是"先退格再回滚",重做是"先前进再重放"(其实只影响 undoText() 的取值时机,但顺序写错会导致显示文案差一位)。cleanIndex_ 用 kInvalid 哨兵而不是「0」,是为了表达「保存点已经不存在了」这个第三种状态——它和「干净」「脏」是三件事,很多项目把它写成 bool 就埋下了 bug。好消息是:撤销/重做是 Qt 官方内置框架能力,不需要自己写栈。Qt 提供三件套(外加一个多文档用的 QUndoGroup):
QUndoCommand(模块 QtGui,Qt6 起从 QtWidgets 迁到 QtGui):抽象命令,派生类实现 redo() / undo(),可选实现 id() + mergeWith()。QUndoStack(QtGui):命令栈 + 游标 + setClean()/isClean()/cleanChanged 脏标记 + undoLimit + createUndoAction()/createRedoAction()(自动生成带 Ctrl+Z 快捷键、自动更新文案与可用性的 QAction)+ beginMacro()/endMacro() 宏命令。QUndoView(QtWidgets,继承 QListView):开箱即用的历史记录面板,直接显示栈里的命令文本,并能点击任意一行「跳转」到那个时间点。我们做一个「矢量画板」小工程:双击画布新增形状、拖动合并成一条历史、一键宏命令批量高亮、保存后星号消失。工程共 6 个文件。
// 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 —— 把"一次原子改动 + 它的逆操作"打包成可入栈对象
#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
#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
#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
#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
跑起来之后你会看到三件事同时成立:
push,但每条新命令都带同一个 id()=1001 且针对同一个下标,被栈顶命令 mergeWith 吸收——200 次 push 只对应 1 条历史。beginMacro/endMacro 的效果:一次 Ctrl+Z 把整组改动全部回滚。setClean() 只是把「干净点」标在游标当前位置,而 isClean() 的所有计算就是 index == cleanIndex——零状态比对,零快照。一个必踩的坑(提前说明):如果画布里放的是 QPlainTextEdit/QTextEdit,它们自己带一套撤销栈(QTextDocument 内部维护),与你的 QUndoStack 毫无关系。两套栈同时记录同一个动作,会出现「按一次 Ctrl+Z 撤销两步」或「撤销后控件内容与文档不一致」。正确做法是二选一:要么 setUndoRedoEnabled(false) 彻底关闭控件自带历史、只走你的 QUndoStack;要么不用 QUndoStack,直接用控件自带的 undo()/redo()。
把上面工程里「按住鼠标拖动形状」到「按下 Ctrl+Z」的全过程摊开,共 9 步。注意前半段是命令入栈,后半段是时间旅行。
阶段一:改动落地(正向)
ShapeCanvas::mouseMoveEvent 触发。它不修改任何数据——这是本架构最重要的纪律:UI 层只会「翻译意图」,绝不「执行改动」。new MoveShapeCommand(doc_, hit_, target)。构造函数里做了一件关键的事——抓取旧值 oldPos_。此刻文档里还是旧坐标,抓到的必然是「拖动前的位置」;一旦拖完再去抓就晚了。同时把命令文案设为「移动形状」(会显示在历史面板里)。QUndoStack::push(cmd)。栈内部先调用 cmd->redo() → doc_->setShapePos(index_, newPos_),数据被改,画布 update() 后重绘。id()」与「新命令的 id()」。若栈顶也是 MoveShapeCommand(id()=1001)且 mergeWith() 返回 true(同一文档、同一下标),则新命令被吸收:栈顶命令的 newPos_ 更新为最新值,新命令被 delete,历史项数不变、游标不动。拖动 200 帧 → 只留 1 条历史。QUndoStack::push 在压栈时保证 index 等于栈元素数,之后的分支被丢弃并析构)。这就是 Figma 里「点了历史中间那行,再画一笔,后面的灰条消失」。canUndoChanged / undoTextChanged / indexChanged / cleanChanged 等信号。createUndoAction() 生成的 QAction 自动把自己的 enabled 与文本更新为「撤销 移动形状」;QUndoView 刷新列表并高亮当前行;cleanChanged 触发 refreshDirtyState(),标题栏出现 ●。阶段二:时间旅行(逆向)
QUndoStack 自动生成的 undo QAction,最终调用 QUndoStack::undo()——注意调用方根本不是具体的命令类,UI 完全不知道「形状」「坐标」这些概念。index 减 1,取出那一条命令,调用它的 undo() → doc_->setShapePos(index_, oldPos_)。因为该命令是合并后的,oldPos_ 是整段拖动的起点,所以一次撤销直接回到拖动之前,而不是退到拖动过程中的某一帧。canRedo() 变 true(重做动作可用了);isClean() 重新计算 index == cleanIndex,标题星号出现或消失;QUndoView 把光标行上移一格,后面那一条变灰待命。此时若 push 一个新命令,第 5 步的截断逻辑会把这些「灰条」彻底丢弃(析构、释放内存)。流程的本质:整个链路里只有三个动作在流动——「构造命令(抓旧值)」→「push(正向落地 + 合并 + 截断)」→「undo/redo(游标 + 逆/正操作)」。数据层、命令层、栈层、视图层四层之间只有单向依赖:视图 → 栈 → 命令 → 数据。反向一条依赖都没有,所以任何一层都可以被单独替换或单独测试。
把架构拆解开看,本模式一共创造了 5 个解耦点。每一个都对应一条可以独立替换、独立测试的边界:
ShapeDocument 里没有 QUndoStack、没有 undo(),只有 insertShape / setShapePos 这种原子方法。收益是:数据层可以被命令行工具、单元测试、序列化模块复用,完全不背历史包袱。对比反例:BadEditor 的数据和 lastAction_ 长在一起,任何不想要撤销功能的调用方都得忍受这份耦合。createUndoAction()。新增「旋转形状」功能时,UI 层一行都不用改(除了加一个触发入口),因为撤销按钮的文案和可用性是由栈根据命令文本自动算出来的。XxxCommand : QUndoCommand 并实现 redo()/undo()。历史管理、脏标记、历史面板、撤销按钮全部零改动。这是最教科书式的开闭原则落地。QUndoStack 里,只依赖 QUndoCommand 这个抽象。把 QUndoStack 换成 QUndoGroup、换成自己写的 UndoStack(如 Qt Creator 的 Core::UndoStack),命令类完全不用动——这就是依赖倒置:高层(历史策略)与低层(具体改动)都依赖抽象。组合优于继承在这里的体现:我们没有为每种操作写一个 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% 的回归风险。
恶化一:if-else / switch 膨胀成「操作登记表」。撤销逻辑必须枚举所有操作类型,每加一个功能就加一个 case。当操作类型超过 15 个(这在图形编辑器里只是起步量),这段 switch 会变成 200 行、没人敢碰、每个新人都要改一次的「公共高危区」。更糟的是:它无法靠编译器帮你检查遗漏——忘了加分支,只在用户点到那个操作时才暴露(而且往往是崩溃而非无效)。
恶化二:类字段膨胀(散落的旧值)。每加一种操作,就要在编辑器类上增加「该操作的旧值字段」:lastOldX_/lastOldY_/lastOldName_/lastOldColor_/lastOldFont_……类从 8 个字段涨到 40 个字段,其中 90% 在任意时刻都是无效值(「上次移动的旧坐标」在用户改颜色之后就是脏数据)。本质问题:撤销所需的状态属于「某一次操作」,却被挂在了「整个对象」上。
恶化三:耦合与不可复用。历史管理逻辑被硬编码进编辑器,任何新界面(比如「批量处理脚本」界面)想复用这段历史都不行;想加「宏/批量操作」「撤销栈上限」「脏标记」「历史面板」这四项产品功能,每一项都要重新设计一遍数据结构——而这些恰恰是 QUndoStack 已经免费给了你的能力。
最后一层代价最昂贵:数据与历史不一致的 bug 无法被定位。当「直接改数据」的代码路径散落在 20 个地方,就必然会有 3 处忘了走 push。用户看到的现象是「撤销后画面错乱」,而你只能靠猜——因为不存在一个「所有改动都必须经过的入口」可供审查。撤销/重做架构的真正价值,很大一部分是把「所有写操作」收敛成一个单点入口,使正确性可以被审查。
适合使用的场景(5 类):
setClean()/isClean() 实现是行业标准做法,比手工维护 dirty 布尔正确得多。不适合(或需要权衡)的场景(4 类):
undo() 无法「收回」一个已发出的网络请求——这时需要的是「补偿事务(Saga),包含人工确认」,而不是简单的撤销栈。过度设计提醒(三条红线):① 不要为「未来可能会有的功能」预先抽象——先有第二、第三个操作,再抽命令基类;只有一个操作时直接改数据。② 不要把所有东西都做成命令:视图状态(缩放、滚动位置、面板开关)通常不该进撤销栈,否则用户按 Ctrl+Z 只是想撤销一步编辑,画面却开始乱跳。③ 命令的粒度要匹配用户心智:一次 Ctrl+Z 应该对应「用户心里的一件事」,而不是「一次函数调用」——拖动是「一次移动」,批量改色是「一次高亮」,打字是「一段输入」。粒度错了,功能就是残废的。
最容易被混淆的三个:命令模式(Day 14)、备忘录模式(Day 17)、状态模式(Day 19)。一句话区分:命令模式是“零件”,撤销/重做架构是“由这个零件装成的机器”;备忘录保存“状态快照”,撤销架构保存“状态差量”;状态模式关心“在不同模式下行为不同”,撤销架构关心“怎么回到过去”。
| 对比项 | 撤销/重做架构 | 命令模式(Day 14) | 备忘录(Day 17) | 状态模式(Day 19) |
|---|---|---|---|---|
| 层次 | 架构级(模式组合 + 基础设施) | 类级(把动作对象化) | 类级(把状态快照对象化) | 类级(把状态机对象化) |
| 核心问题 | 怎么回到任意一个历史状态 | 怎么让「请求」本身成为一等公民(可排队、可记录、可撤销) | 怎么在不破坏封装的前提下保存/恢复对象内部状态 | 怎么让对象在不同状态下表现不同行为 |
| 数据的保存方式 | 存「改动的差值」(old/new 两个值) | 不负责保存历史(只管执行一次) | 存「整份状态快照」(可能包含大量数据) | 不保存历史(只管当前状态与转移) |
| 内存成本 | 与「改动次数 × 每次改动的字段数」成正比 | 近似于 0(命令执行完即可释放) | 与「快照数 × 对象总大小」成正比(昂贵) | 与状态数量成正比(很小) |
| 能否重做 / 时间旅行 | 可以,且支持任意步跳转(游标) | 单独一个命令做不到;需要栈来补 | 可以恢复到某个快照,但无法表达「撤销一步」的语义 | 不能 |
| 典型实现 | QUndoStack + QUndoCommand + QUndoView |
std::function 队列 / 任务对象 / 菜单动作 |
Memento(快照)+ Caretaker(保管者) | 状态对象 + 上下文转发 |
| 关系 | 使用命令模式实现 undo()/redo();局部需要时可内嵌备忘录 |
被撤销架构当作基础零件复用 | 是撤销架构的「备选方案」(大改动的兜底策略) | 与撤销架构互补:常在编辑器中用状态机管理「工具模式」(画矩形/画线/选区) |
还有一个容易混的:「事务 / 数据库回滚」。数据库事务是在存储层保证原子性(要么全成功要么全不做),而撤销栈是在应用层提供用户级的可逆操作。二者可以叠加:一条命令内部可能包含一个数据库事务,但事务提交后依然可以「业务撤销」(再写一条反向记录)。
这是本主题「Qt 含量」最高的部分——Qt 不只是提供了类,它自己的工具链就是这套架构的重度用户:
QUndoStack / QUndoCommand / QUndoGroup 位于 QtGui,QUndoView 位于 QtWidgets。一个撤销框架被放进 GUI 基础模块(而不是某个插件里),说明 Qt 官方把它当作「GUI 应用的基础设施」而非可选组件。Qt6 把 QUndoStack 从 QtWidgets 移到 QtGui,也意味着「撤销」被视为与具体控件无关的通用能力。examples/tools/undoframework:这是 Qt 自带的「撤销框架」教学工程:它把 Diagram 上的「新增/删除/上下移动」封装成 QUndoCommand 子类,用 QUndoView 做历史面板,并用 QUndoGroup 同时管理多个文档的栈。它几乎是本篇文章工程的生产版(差别在于我们的画布更简单)。想读「Qt 官方认为正确的写法」,直接读这个示例。QDesignerFormWindowInterface::commandHistory() 暴露给插件系统的 QUndoStack* 实现的:每次属性/几何改动都是一条命令,进入同一个栈。这意味着任何 Qt Designer 插件(自定义控件)都能把自己的改动接进设计器的全局撤销栈,与前一篇讲的插件架构(Day 27)正好串起来:插件只依赖「命令 + 栈」这套抽象契约。Core::UndoStack 是 QUndoStack 的子类(因此能自定义文案与合并策略),编辑器侧统一通过类似 Core::ActionManager 生成的 undo/redo 动作接入菜单,快捷键与启用状态由栈信号自动驱动。多文档编辑器各自持有自己的栈,切换文件时框架切换「当前栈」——这正是 QUndoGroup 要解决的问题。QTextDocument(QTextEdit/QPlainTextEdit 背后的文档模型)自己维护了一套内部撤销栈,QLineEdit 内部也有自己的编辑历史。它们为什么不复用 QUndoStack?因为文本编辑的历史粒度极细(每次插入/删除一个字符甚至一次光标移动),需要更激进的压缩策略与更深的栈,且必须在文档编辑器内部与布局/排版逻辑紧密协作。这给我们的架构启示是:撤销架构是「策略」而非「唯一实现」——历史存储策略应该跟着数据的粒度与访问模式走,而命令契约保持稳定即可。QAbstractItemModel/QTableView、QGraphicsScene、QSettings 都不带撤销能力——它们是「数据与视图」的基础设施,是否可撤销由上层应用决定(Qt 的做法是把可撤销性做成「命令层」,而不是塞进模型里)。这也再次印证:把撤销能力放在「操作层」而不是「数据层」,是 Qt 官方的一贯选择,和本篇 08 节的解耦点 1 完全一致。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 上,自动跟随活动栈。
需求(在本文示例工程上继续做):
RemoveShapeCommand:支持删除形状,删除后可以用 Ctrl+Z 精确恢复(恢复到原来的下标位置,而不是追加到末尾)。cleanIndex_ 为 0,撤销回空文档时应为「干净」)。提示(只给思路,不写答案):
Shape 加一个稳定的 id(自增序号),删除命令保存「形状副本 + 当时的 id」,恢复时按 id 找到正确插入点;这样命令之间不会因为互相改变下标而错位。这是真实编辑器(Qt Designer、Qt Creator)里普遍采用的做法:命令引用对象的身份,而不是位置。QUndoStack 本身没有「时间窗口」概念,你需要自己造一个断链条件。最少改动的两种写法:(a) 在 mergeWith() 里比较「距离上一次合并的时间戳」,超过阈值就返回 false;(b) 更简单也更好测——每完成一次拖动(mouseReleaseEvent)就把命令的 id() 换成一个自增的新值(例如 2000 + ++dragGeneration),于是下一次拖动天然与上一次「不同族」,不可能被合并。两种写法都请自测第 3 条需求,因为合并与脏标记是会互相干扰的。代码特征信号(看到这 5 条,基本可以断定项目里用的是撤销/重做架构):
stack->push(new XxxCommand(...)),任何地方都找不到「直接 setXxx 改数据」的旁路(这是最重要的信号,也是最容易破的纪律)。redo()/undo(),且构造时抓旧值:命令成员里带着 oldValue_/newValue_ 这种成对的字段,而 redo() 里没有任何 if 分支判断「这是第一次还是重做」。id() + mergeWith():出现 static constexpr int kId = 1001; 这类常量,以及只在「同一目标 + 同一属性」时才返回 true 的窄条件——说明这是生产级而非教学级实现。createUndoAction()/cleanChanged/beginMacro():撤销按钮没有手写 setEnabled 逻辑,标题栏「已修改」来自信号而非布尔字段。ShapeDocument/Document 数据层,找不到任何历史相关字段——可撤销性被完整地封在操作层。明日预告:Day 29 —— 状态机架构(State Machine / QStateMachine)。今天解决了「怎么回到过去」,但我们还没回答桌面客户端的另一个必答题:「同一个按钮在‘画矩形’模式下按下要画矩形,在‘选择’模式下按下要选中对象,在‘删除’模式下按下要删东西」——这种「行为随模式变化」的复杂度,如果继续用 if (mode == ...) 铺开会迅速失控。明天我们把它提升到架构层:把「模式」建模成状态(State)、把「用户事件」建模成转移(Transition)、把「进入/退出某状态时该做什么」建模成动作(Entry/Exit Action),并用 Qt 官方状态机框架 QStateMachine/QState/QFinalState 实现一个画板工具切换器,含「分层状态」「历史状态(回到上次模式)」「守卫条件(Transition Guard)」三个实战点,同时和 Day 19 的状态模式(面向对象的实现)做架构对比。