QUndoCommand的使用

引言

实现撤销重做(Undo/Redo)是编辑器的必备功能,诸如文本编辑器、电子表格、图像编辑器等。用户在编辑过程中是需要通过该方式修正错误的输入或者是不断调整编辑内容的,而且撤销重做不仅限于上一步,是很多时候可能是之前的几十步,这个在PS中非常常见,因此我们需要一个记录步骤的容器。

包含单个操作的撤销重做、步骤记录容器,可以直接使用Qt Undo Framework,本文主要描述如何使用以及在实际开发中非常重要的技巧。

基本实现

主要组成

在这里插入图片描述

上述视频为示例Demo,左侧为撤销重做列表,用于展示当前容器内的情况,右侧是模拟常见编辑器的内容,有选中框、滑动条。

此处的编辑是采用修改数据模型的方式,界面触发修改后调用对应的修改命令,修改命令去修改数据模型,模型更新后再同时界面刷新,这种更新方式主要考虑到一个数据模型对应多个UI控件的情况,如slider更新后修改skipBox更新,代码如下

class DataModel : public QObject {
    Q_OBJECT
    Q_PROPERTY(int fontSize READ fontSize WRITE setFontSize NOTIFY fontSizeChanged FINAL)

public:
    DataModel(QObject *parent = nullptr);
    ~DataModel();

public:
    int fontSize() const;
    void setFontSize(int value);

signals:
    void fontSizeChanged();

private:
    int font_size_ = 50;
};
MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
{
    ui->setupUi(this);
    
    // ...
    // do something
    // ...
    
    // 数据模型
    data_model_ = new DataModel(this);

    // 关联更新
    ui->sizeSlider->setValue(data_model_->fontSize());
    ui->sizeBox->setValue(data_model_->fontSize());
    
    connect(data_model_, &DataModel::fontSizeChanged, this, [this]{
        ui->sizeSlider->setValue(data_model_->fontSize());
        ui->sizeBox->setValue(data_model_->fontSize());
    });

    connect(ui->sizeSlider, &QSlider::sliderMoved, this, [this]{
        auto modify_command = new ModifyFontSizeCommand(data_model_, ui->sizeSlider->value());
        undo_stack_->push(modify_command);
    });
}

实现撤销重做主要涉及QUndoStack、QUndoCommand以及QUndoView。QUndoCommand负责单个操作的撤销重做,需要重写其undo/redo函数。QUndoStack则是用于记录步骤,也就是QUndoCommand的容器。QUndoView则是用来展示容器内的内容,方便Demo演示。

命令(QUndoCommand)

先看最简单的单属性更新命令,只更新数据模型内的lightStyle属性,代码如下:

class ModifyLightStyleCommand : public QUndoCommand {
public:
    ModifyLightStyleCommand(DataModel* target_model, bool enable, QUndoCommand *parent = nullptr);
    ~ModifyLightStyleCommand();

protected:
    void undo() override;
    void redo() override;

private:
    QPointer<DataModel> target_model_;
    bool ori_value_ = false;
    bool new_value_ = false;
};
ModifyLightStyleCommand::ModifyLightStyleCommand(DataModel *target_model, bool enable, QUndoCommand *parent)
    : QUndoCommand(parent)
    , target_model_(target_model)
    , new_value_(enable)
{
    setText("Modify Light Style Commond");
    if(!target_model_.isNull()){
        ori_value_ = target_model_->lightStyle();
    }
}

ModifyLightStyleCommand::~ModifyLightStyleCommand()
{

}

void ModifyLightStyleCommand::undo()
{
    if(target_model_.isNull())
        return;
    target_model_->setLightStyle(ori_value_);
}

void ModifyLightStyleCommand::redo()
{
    if(target_model_.isNull())
        return;
    target_model_->setLightStyle(new_value_);
}

如前文所述,ModifyLightStyleCommand继承QUndoCommand ,重写其undo/redo函数,使用时需要确认新值和旧值,undo时设置新值,redo时设置旧值。setText是用于在QUndoView进行显示。如此就完成了一条属性修改的命令,实际使用时只需要构造命令并入栈即可,代码如下:

connect(ui->lightBox, &QCheckBox::clicked, this, [this](bool checked) {
    auto modify_light = new ModifyLightStyleCommand(data_model_, checked);
    undo_stack_->push(modify_light);
});

当命令入栈时会调用命令的redo函数,源码如下,因此触发命令入栈后,并不需要对数据模型再进行额外的属性修改。

void QUndoStack::push(QUndoCommand *cmd)
{
    Q_D(QUndoStack);
    if (!cmd->isObsolete())
       cmd->redo();
       
    // ...
    // do something
    // ...
}

[since 5.9] bool QUndoCommand::isObsolete() const Returns whether the
command is obsolete. The boolean is used for the automatic removal of
commands that are not necessary in the stack anymore. The isObsolete
function is checked in the functions QUndoStack::push(),
QUndoStack::undo(), QUndoStack::redo(), and QUndoStack::setIndex().

解决完如何使用的问题,还有一个就是命令构造后如何析构,谁去触发析构,会不会存在还需要使用者进行内存管理的情况。这些问题还是看源码,如下所示:

QUndoStack::~QUndoStack()
{
    // ...
    // do something
    // ...
    clear();
}

void QUndoStack::clear()
{
    Q_D(QUndoStack);
    if (d->command_list.isEmpty())
        return;
    // ...
    qDeleteAll(d->command_list);
    d->command_list.clear();
    // ...
}

当QUndoStack析构时会调用clear函数,而clear函数内则会调用qDeleteAll析构command_list内记录的命令,再清空command_list。因此,当命令入栈后就不再需要人为管理命令的生命周期,已经托管给QUndoStack。

命令栈(QUndoStack)

解决单条命令的实现之后,接着就是命令的整体调度问题,这个是由QUndoStack完成的,代码如下:

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
{
    // ...
    undo_stack_ = new QUndoStack(this);
    ui->undoView->setStack(undo_stack_);
    // ...
    connect(ui->undoBtn, &QPushButton::clicked, undo_stack_, [this]{
        undo_stack_->undo();
    });
    connect(ui->redoBtn, &QPushButton::clicked, undo_stack_, [this]{
        undo_stack_->redo();
    });
    connect(ui->clearBtn, &QPushButton::clicked, undo_stack_, [this]{
        undo_stack_->clear();
    });
    // ...  
}

调用撤销重做并不需要直接操作QUndoCommand,操作QUndoStack即可,撤销则调用undo(),重做则调用redo()。当页面切换希望清空当前记录的命令队列时,也可以直接调用clear()。

优化技巧

基本的调用理解之后,在实际工作时还需要解决一些问题。第一种情况,属性的修改很多使用存在着联动,例如在当前例子中的lightStyle和darkStyle互斥。第二种情况,同一个操作间隔内相同的命令需要合并,例如滑动条按下、拖动到放下属于同一个操作间隔,当前间隔内产生的ModifyFontSizeCommand都应该合并为一条,这样一次撤销就能还原为按下时的数据。

组合命令

针对第一种情况,当然可以把互斥关系写在命令类内,但这会导致目前的两种命令类都需要修改,(ModifyLightStyleCommand、ModifyDarkStyleCommand),如果将这两个类合并成一个类则会让这个类随着业务的增加逐渐膨胀,以后再要增加功能都会去修改此复合类,而且undo/redo函数的实现也会变得复杂化。

那有没有在保证其独立性的情况下,实现复合命令呢。答案当然是有的,代码如下:

    connect(ui->lightBox, &QCheckBox::clicked, this, [this](bool checked) {
        auto composite_command = new QUndoCommand();
        composite_command->setText("Modify Light Style Composite Commond");
        new ModifyLightStyleCommand(data_model_, checked, composite_command);
        new ModifyDarkStyleCommand(data_model_, !checked, composite_command);
        undo_stack_->push(composite_command);
    });

为两条命令设置同样的父命令,再将父命令入栈即可。此处的父命令未重写undo/redo,使用的是原有的函数,源码如下:

void QUndoCommand::redo()
{
    for (int i = 0; i < d->child_list.size(); ++i)
        d->child_list.at(i)->redo();
}

void QUndoCommand::undo()
{
    for (int i = d->child_list.size() - 1; i >= 0; --i)
        d->child_list.at(i)->undo();
}

可以看到原有的函数会将子命令挨个执行,如此就完成组合命令的实现,还保持单条命令的原子性及简单化。对于后续业务的扩展也有非常强的适应能力,能够自由组合,不仅限于当前的功能。

合并命令

针对第二种情况,主要是因为使用数据模型,而数据的修改是通过命令完成,而在滑动的过程中,为了保证界面的刷新,就需要不断的调用命令,此时则需要使用到合并命令的功能。需要重写id()和mergeWith()函数,代码如下:

int ModifyFontSizeCommand::id() const
{
    return id_;
}

bool ModifyFontSizeCommand::mergeWith(const QUndoCommand *other)
{
    auto other_command =  dynamic_cast<const ModifyFontSizeCommand *>(other);
    if (!other_command)
        return false;
    new_value_ = other_command->new_value_;
    return true;
}

int QUndoCommand::id() const Returns the ID of this command. A command
ID is used in command compression. It must be an integer unique to
this command’s class, or -1 if the command doesn’t support
compression. If the command supports compression this function must be
overridden in the derived class to return the correct ID. The base
implementation returns -1. QUndoStack::push() will only try to merge
two commands if they have the same ID, and the ID is not -1.

id()默认为-1,只有当其不为-1,且当前栈内的命令和将要入栈的命令的id相同时,才会调用mergeWith()函数。mergeWith()函数的作用则是让当前命令继承新命令的值,如上述代码所示,将other_command的value赋给当前命令。

void QUndoStack::push(QUndoCommand *cmd)
{
    // ...
    bool try_merge = cur != nullptr
                     && cur->id() != -1
                     && cur->id() == cmd->id()
                     && (macro || d->index != d->clean_index);
    if (try_merge && cur->mergeWith(cmd)) {
        delete cmd;
        // ...
    }
}

为了更深入了解,这里再贴上源码片段,可以看到如前文所述,先判断id是否为-1,再判断id是否相同,最后调用mergeWith,如果成功则析构cmd。

外部调用需要为该操作间隔设置统一的id,可以使用当日的毫秒时间戳,例如在滑动条按下时记录当前的时间戳,在滑动条滑动的过程中通过构造函数初始化id,示例代码如下:

    connect(ui->sizeSlider, &QSlider::sliderPressed, this, [this]{
        ui->sizeSlider->setProperty("command", QTime::currentTime().msecsSinceStartOfDay());
    });

    connect(ui->sizeSlider, &QSlider::sliderMoved, this, [this]{
        int command_id = ui->sizeSlider->property("command").toInt();
        auto modify_command = new ModifyFontSizeCommand(data_model_, ui->sizeSlider->value(), command_id);
        undo_stack_->push(modify_command);
    });

完整代码

代码下载链接

  • 19
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
QUndoCommandQt 框架提供的一个用于实现撤销和重做操作的类,通常需要继承该类来实现具体的操作。下面是一个简单的例子: 假设我们有一个文本编辑器,可以在其中进行添加和删除文本的操作。我们想要实现撤销和重做这两个功能,就可以使用 QUndoCommand。 首先,我们需要创建一个继承自 QUndoCommand 的类,比如叫做 TextCommand。在这个类中,我们需要实现两个方法:undo() 和 redo(),分别用于撤销和重做操作。在这个例子中,我们可以将这两个操作实现为添加或删除文本。 ``` class TextCommand : public QUndoCommand { public: TextCommand(const QString& text, QTextEdit* editor, bool add = true) : m_text(text), m_editor(editor), m_add(add) {} void undo() override { if (m_add) m_editor->textCursor().deletePreviousChar(); else m_editor->textCursor().insertText(m_text); } void redo() override { if (m_add) m_editor->textCursor().insertText(m_text); else m_editor->textCursor().deletePreviousChar(); } private: QString m_text; QTextEdit* m_editor; bool m_add; }; ``` 在上面的代码中,我们可以看到在构造函数中传入了要添加或删除的文本、文本编辑器以及一个标志位 add,用于表示是添加还是删除操作。在 undo() 和 redo() 方法中,我们根据这个标志位来进行相应的操作。 接下来,在我们的主程序中,我们需要创建一个 QUndoStack 对象,该对象用于管理撤销和重做操作。我们还需要为添加和删除文本的操作创建两个槽函数: ``` void MainWindow::on_addButton_clicked() { QString text = ui->lineEdit->text(); TextCommand* command = new TextCommand(text, ui->textEdit, true); m_undoStack->push(command); } void MainWindow::on_deleteButton_clicked() { TextCommand* command = new TextCommand("", ui->textEdit, false); m_undoStack->push(command); } ``` 在上面的代码中,我们创建了两个 TextCommand 对象,分别用于添加和删除文本。然后将它们推入到我们创建的 QUndoStack 对象中。 最后,我们需要在我们的主程序中创建一个 QUndoView 对象,该对象用于显示撤销和重做操作的历史记录。我们还需要将我们创建的 QUndoStack 对象与该 QUndoView 对象关联起来: ``` m_undoStack = new QUndoStack(this); QUndoView* undoView = new QUndoView(m_undoStack); undoView->setWindowTitle("Undo View"); undoView->show(); ``` 现在,我们就可以运行我们的程序,并尝试添加和删除文本,并进行撤销和重做操作了。 以上就是一个简单的使用 QUndoCommand 的例子。在实际的应用中,我们可以根据具体的需求来进行更加复杂的操作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Arui丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值