目录
- 引言
- 基本实现
- 主要组成
- 命令(QUndoCommand)
- 命令栈(QUndoStack)
- 优化技巧
- 组合命令
- 合并命令
- 完整代码
引言
实现撤销重做(Undo/Redo)是编辑器的必备功能,诸如文本编辑器、电子表格、图像编辑器等。用户在编辑过程中是需要通过该方式修正错误的输入或者是不断调整编辑内容的,而且撤销重做不仅限于上一步,是很多时候可能是之前的几十步,这个在PS中非常常见,因此我们需要一个记录步骤的容器。
包含单个操作的撤销重做、步骤记录容器,可以直接使用Qt Undo Framework,本文主要描述如何使用以及在实际开发中非常重要的技巧。
基本实现
主要组成
上述视频为示例Demo,左侧为撤销重做列表,用于展示当前容器内的情况,右侧是模拟常见编辑器的内容,有选中框、滑动条。
此处的编辑是采用修改数据模型的方式,界面触发修改后调用对应的修改命令,修改命令去修改数据模型,模型更新后再同时界面刷新,这种更新方式主要考虑到一个数据模型对应多个UI控件的情况,如slider更新后修改skipBox更新,代码如下
class DataModel : public QObject {Q_OBJECTQ_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);});
完整代码
代码下载链接