Qt源码中的设计模式:撤销/重做框架与命令模式

命令模式

命令模式是一种行为设计模式,它将请求封装成一个对象,从而使我们可以将不同的请求、队列或日志请求等参数化,同时支持可撤销的操作。该模式的核心思想是将请求发送者和接收者解耦,让它们不直接交互,而是通过命令对象进行交互,即将请求封装成类对象。命令模式通常用于以下场景:

  1. 需要将请求发送者和接收者解耦的场景,以便于适应变化。

  2. 需要支持撤销和恢复操作的场景。

  3. 需要将一组操作组合在一起执行的场景,也称为批处理。

命令模式包含以下角色:

  1. 命令(Command):定义命令的接口,通常包含执行和撤销两个方法。

  2. 具体命令(ConcreteCommand):实现命令接口,包含了对应的操作。

  3. 命令接收者(Receiver):执行命令的对象。

  4. 命令发起者(Invoker):调用命令的对象,负责将命令发送给命令接收者。

  5. 客户端(Client):创建命令对象并将其发送给命令发起者。

命令模式UML类图

下面给出一个命令模式的C++示例:

class Command {
public:
    virtual ~Command() {}
    virtual void execute() = 0;
    virtual void undo() = 0;
};

class ConcreteCommand : public Command {
public:
    ConcreteCommand(std::shared_ptr<Receiver> receiver) : m_receiver(receiver) {}
    virtual void execute() {
        m_receiver->action();
    }
    virtual void undo() {
        m_receiver->undoAction();
    }
private:
    std::shared_ptr<Receiver> m_receiver;
};

class Receiver {
public:
    void action() {
        // 执行操作
    }
    void undoAction() {
        // 撤销操作
    }
};

class Invoker {
public:
    void setCommand(std::shared_ptr<Command> command) {
        m_command = command;
    }
    void executeCommand() {
        m_command->execute();
    }
    void undoCommand() {
        m_command->undo();
    }
private:
    std::shared_ptr<Command> m_command;
};

int main() {
    auto receiver = std::make_shared<Receiver>();
    auto command = std::make_shared<ConcreteCommand>(receiver);
    auto invoker = std::make_shared<Invoker>();
    invoker->setCommand(command);
    invoker->executeCommand();
    invoker->undoCommand();
    return 0;
}

在上面的示例中,Command类是命令接口,定义了execute和undo方法。ConcreteCommand类是具体命令类,实现了Command接口,包含了对应的操作。Receiver类是命令接收者,执行命令的对象。Invoker类是命令发起者,调用命令的对象,负责将命令发送给命令接收者。在客户端代码中,我们创建了一个Receiver对象和一个ConcreteCommand对象,并将其传递给Invoker对象。然后,我们调用Invoker对象的executeCommand方法来执行ConcreteCommand对象的操作。如果需要撤销操作,我们可以调用Invoker对象的undoCommand方法来执行ConcreteCommand对象的undo操作。

撤销/重做框架(QUndoStack、QUndoCommand等类)

Qt的撤销/重做框架(QUndoStackQUndoCommand等类)是命令模式的一种实现。在Qt的撤销/重做框架中,每个操作都被封装为一个QUndoCommand的子类对象。

以下是一个使用Qt的撤销/重做框架的程序示例:

#include <QApplication>
#include <QTextEdit>
#include <QUndoStack>
#include <QPushButton>
#include <QVBoxLayout>
#include <QUndoCommand>

class MyCommand : public QUndoCommand
{
public:
    MyCommand(QTextEdit *editor, const QString &text, QUndoCommand *parent = nullptr)
        : QUndoCommand(parent), m_editor(editor), m_text(text), m_oldText(editor->toPlainText()) {}

    void undo() override
    {
        m_editor->setPlainText(m_oldText);
    }

    void redo() override
    {
        m_editor->setPlainText(m_text);
    }

private:
    QTextEdit *m_editor;
    QString m_text;
    QString m_oldText;
};

int main(int argc, char **argv)
{
    QApplication app(argc, argv);

    QUndoStack stack;

    QTextEdit editor;
    QPushButton undoButton("Undo");
    QPushButton redoButton("Redo");

    QObject::connect(&undoButton, &QPushButton::clicked, &stack, &QUndoStack::undo);
    QObject::connect(&redoButton, &QPushButton::clicked, &stack, &QUndoStack::redo);

    QObject::connect(&editor, &QTextEdit::textChanged, [&]() {
        stack.push(new MyCommand(&editor, editor.toPlainText()));
    });

    QVBoxLayout layout;
    layout.addWidget(&editor);
    layout.addWidget(&undoButton);
    layout.addWidget(&redoButton);

    QWidget window;
    window.setLayout(&layout);
    window.show();

    return app.exec();
}

在上述示例中,MyCommandQUndoCommand 的子类。每次 QTextEdit 的文本改变时,就会创建一个新的 MyCommand 对象并将其压入 QUndoStack。当点击 "Undo" 按钮时,就会撤销栈顶的命令;当点击 "Redo" 按钮时,就会重做栈顶的命令。

在这个例子中,相关的类扮演的角色如下:

  1. 命令(Command)QUndoCommand是命令接口。它定义了执行(redo)和撤销(undo)的方法。

  2. 具体命令(ConcreteCommand)MyCommand是具体的命令。它是 QUndoCommand的子类,实现了 redoundo方法。

  3. 命令接收者(Receiver)QTextEdit是命令的接收者。它是执行命令的对象,MyCommandredoundo方法都是操作 QTextEdit

  4. 命令发起者(Invoker)QUndoStack是命令的发起者。它负责调用和存储命令。QPushButton 实际上也扮演了命令发起者的角色,因为它们是触发执行或撤销命令的实际用户界面元素。

  5. 客户端(Client):在这个例子中,main 函数就是客户端。它创建了应用程序,包括命令接收者(QTextEdit)、命令发起者(QUndoStackQPushButton)、并在 QTextEdit的文本变化时创建具体命令(MyCommand)。

下面给出相关的类在Qt源码中的实现。同样,这里隐去了很多与命令模式无关的细节,但对理解命令模式应该是足够的。

class QUndoCommand
{
public:
    virtual ~QUndoCommand() {}
    virtual void undo() = 0;
    virtual void redo() = 0;
};

class QUndoStack
{
public:
    void push(QUndoCommand *cmd)
    {
        m_stack.push(cmd);
        cmd->redo();
    }

    void undo()
    {
        if (!m_stack.isEmpty()) {
            QUndoCommand *cmd = m_stack.pop();
            cmd->undo();
            m_undoStack.push(cmd);
        }
    }

    void redo()
    {
        if (!m_undoStack.isEmpty()) {
            QUndoCommand *cmd = m_undoStack.pop();
            cmd->redo();
            m_stack.push(cmd);
        }
    }

private:
    QStack<QUndoCommand*> m_stack;
    QStack<QUndoCommand*> m_undoStack;
};

MyCommand类我们已经在前面实现了,这里不再重复。对于命令的接收者QTextEdit,我们也不需要关注它的源码,因为它的功能就是一个文本编辑器,我们只需要知道它提供了setPlainText()toPlainText()等函数供我们在MyCommand中使用就可以了。

需要注意的是,在标准的命令模式中,通常只有一个存储命令对象的容器,可以是是队列或栈,也可以仅仅是只是单个命令对象(如我们一开始给出的命令模式的示例)。然而,在实现撤销/重做功能时,通常需要两个栈结构。

在Qt的QUndoStack中,主栈用于存储执行过的命令,当调用undo()方法时,会从主栈中弹出命令并执行其undo操作,同时该命令会被压入撤销栈。撤销栈用于存储撤销过的命令,当调用redo()方法时,会从撤销栈中弹出命令并执行其redo操作,同时该命令会被压回主栈。这样的设计使得QUndoStack能够按正确的顺序执行和撤销命令,同时还能在撤销命令后重新执行它们。

总结

Qt的撤销/重做框架,实现了命令模式,并使用了两个栈的方式维护了操作的历史记录,确实是很精妙的设计。除此之外,Qt的撤销/重做框架是支持多个命令的合并的,这在文字编辑或者其他需要撤销/重做框架的需求中,都是很有用的。QUndoCommand类提供了一个可重写的mergeWith方法,可以用来合并连续的、类似的操作,使其在撤销/重做时被视为一个单一的操作。这里由于篇幅问题,不展开讨论。总的来说,Qt的撤销/重做框架,很好地实现了命令模式。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容