使用最小样板保存/加载+撤消/重做机制

Mechanism for Save/Load+Undo/Redo with minimum boilerplate

本文关键字:加载 重做 机制 撤消 保存      更新时间:2023-10-16

我想创建一个用户可以编辑图表的应用程序(例如),它将提供以下标准机制:保存,加载,撤消和重做。

实现此目的的一种简单方法是为图表及其中的各种形状提供类,这些类通过保存和加载方法实现序列化,并且所有编辑它们的方法都返回可以添加到调用其perform方法并将它们添加到撤消堆栈的UndoManagerUndoableAction

上面描述的简单方法的问题在于它需要大量容易出错的样板工作。

我知道工作的序列化(保存/加载)部分可以通过使用 Google 的协议缓冲区或 Apache Thrift 之类的东西来解决,它会为您生成样板序列化代码,但它不能解决撤消 + 重做问题。我知道对于 Objective C 和 Swift,Apple 提供了 Core Data 来解决序列化 + 撤消问题,但我不熟悉类似C++。

有没有一种不容易出错的好方法来解决保存+加载+撤消+重做的问题?

上面描述的简单方法的问题在于它需要大量容易出错的样板工作。

我不相信情况是这样。您的方法听起来很合理,并且使用现代C++功能和抽象,您可以为其实现一个安全优雅的界面。

对于初学者来说,您可以使用std::variant作为"可撤消操作">的总类型 - 这将为每个操作提供一个类型安全的标记联合(如果您无法访问 C++17),请考虑使用可以在 Google 上轻松找到的boost::variant或其他实现。例:

namespace action
{
// User dragged the shape to a separate position.
struct move_shape
{
shape_id _id;
offset _offset;
};
// User changed the color of a shape.
struct change_shape_color
{
shape_id _id;
color _previous;
color _new;
};
// ...more actions...
}
using undoable_action = std::variant<
action::move_shape,
action::change_shape_color,
// ...
>;

现在,您已经为所有可能的"可撤消操作"提供了总和类型,可以使用模式匹配来定义撤消行为。我写了两篇关于通过重载 lambdavariant"模式匹配"的文章,你可能会觉得很有趣:

  • "使用 lambda 访问变体 - 第 1 部分">

  • "使用 lambda 访问变体 - 第 2 部分">

下面是undo函数的外观示例:

void undo()
{
auto action = undo_stack.pop_and_get();
match(action, [&shapes](const move_shape& y)
{
// Revert shape movement.
shapes[y._id].move(-y._offset);
},
[&shapes](const change_shape_color& y)
{
// Revert shape color change.
shapes[y._id].set_color(y._previous);
},
[](auto)
{
// Produce a compile-time error.
struct undo_not_implemented;
undo_not_implemented{};
});
}

如果match的每个分支都变大,则可以将其移动到自己的函数以提高可读性。尝试实例化undo_not_implemented或使用依赖static_assert也是一个好主意:如果您忘记实现特定"可撤消操作"的行为,将产生编译时错误。

差不多就是这样!如果要保存undo_stack以便将操作的历史记录保留在保存的文档中,则可以实现一个auto serialize(const undoable_action&),该再次使用模式匹配来序列化各种操作。然后,您可以实现一个deserialize函数,该函数在文件加载时重新填充undo_stack

如果发现为每个操作实现序列化/反序列化过于繁琐,请考虑使用BOOST_HANA_DEFINE_STRUCT或类似的解决方案自动生成序列化/反序列化代码。

由于您关心电池和性能,我还想提一下,与多态层次结构相比,使用std::variant或类似的标记联合构造平均更快、更轻量级,因为不需要堆分配,并且没有运行时virtual调度。


关于redo功能:您可以有一个redo_stack并实现一个反转操作行为的auto invert(const undoable_action&)函数。 示例:

void undo()
{
auto action = undo_stack.pop_and_get();
match(action, [&](const move_shape& y)
{
// Revert shape movement.
shapes[y._id].move(-y._offset);
redo_stack.push(invert(y));  
},
// ...

auto invert(const undoable_action& x)
{
return match(x, [&](move_shape y)
{
y._offset *= -1;
return y;
},
// ...

如果遵循此模式,则可以在undo方面实现redo!只需通过从redo_stack而不是undo_stack弹出来调用undo:由于您"反转"了操作,它将执行所需的操作。


编辑:这是一个最小的wandbox示例,它实现了一个match函数,该函数接受一个变体并返回一个变体。

  • 该示例使用boost::hana::overload生成访问者。

  • 访问者被包装在一个 lambdaf中,该 lambda 将返回类型统一为变体的类型:这是必需的,因为std::visit要求访问者始终返回相同的类型。

    • 如果需要返回与变体不同的类型,则可以使用std::common_type_t,否则用户可以显式将其指定为match的第一个模板参数。

在框架 Flip 和 ODB 中实现了两种合理的方法来解决这个问题。

代码生成/ODB

使用 ODB,您需要将#pragma声明添加到代码中,并让它的工具生成用于保存/加载和编辑模型的方法,如下所示:

#pragma db object
class person
{
public:
void setName (string);
string getName();
...
private:
friend class odb::access;
person () {}
#pragma db id
string email_;
string name_;
};

其中类中声明的访问器由 ODB 自动生成,以便可以捕获对模型的所有更改,并且可以为它们进行撤消事务。

使用最少的样板/翻转进行反射

与ODB不同,Flip不会为您生成C++代码,而是要求您的程序调用Model::declare以重新声明您的结构,如下所示:

class Song : public flip::Object
{
public:
static void declare ();
flip::Float tempo;
flip::Array <Track> tracks;
};
void Song::declare ()
{
Model::declare <Song> ()
.name ("acme.product.Song")
.member <flip::Float, &Song::tempo> ("tempo");
.member <flip::Array <Track>, &Song::tracks> ("tracks");
}
int main()
{
Song::declare();
...
}

像这样声明结构化后,flip::Object的构造函数可以初始化所有字段,以便它们可以指向撤消堆栈,并记录对它们的所有编辑。它还具有所有成员的列表,以便flip::Object可以为您实现序列化。

上面描述的简单方法的问题在于它需要大量容易出错的样板工作。

我会说实际问题是您的撤消/重做逻辑是组件的一部分,它应该只将一堆数据作为位置、内容等提供。

将撤消/重做逻辑与数据分离的常见 OOP 方法是命令设计模式。
基本思想是将所有用户交互转换为命令,这些命令在图表本身上执行。它们包含执行操作和回滚操作所需的所有信息,只要您维护命令的排序列表并按顺序撤消/重做它们(这通常是用户的期望)。

另一个常见的 OOP 模式可以帮助您设计自定义序列化实用程序或使用最常见的序列化实用程序,它是访问者设计模式。
这里的基本思想是,你的关系图不应该关心它所包含的组件类型。每当要序列化它时,您都会提供一个序列化程序,组件会在查询时将自身提升为正确的类型(有关此技术的更多详细信息,请参阅双重调度)。

话虽如此,一个最小的例子胜过千言万语:

#include <memory>
#include <stack>
#include <vector>
#include <utility>
#include <iostream>
#include <algorithm>
#include <string>
struct Serializer;
struct Part {
virtual void accept(Serializer &) = 0;
virtual void draw() = 0;
};
struct Node: Part {
void accept(Serializer &serializer) override;
void draw() override;
std::string label;
unsigned int x;
unsigned int y;
};
struct Link: Part {
void accept(Serializer &serializer) override;
void draw() override;
std::weak_ptr<Node> from;
std::weak_ptr<Node> to;
};
struct Serializer {
void visit(Node &node) {
std::cout << "serializing node " << node.label << " - x: " << node.x << ", y: " << node.y << std::endl;
}
void visit(Link &link) {
auto pfrom = link.from.lock();
auto pto = link.to.lock();
std::cout << "serializing link between " << (pfrom ? pfrom->label : "-none-") << " and " << (pto ? pto->label : "-none-") << std::endl;
}
};
void Node::accept(Serializer &serializer) {
serializer.visit(*this);
}
void Node::draw() {
std::cout << "drawing node " << label << " - x: " << x << ", y: " << y << std::endl;
}
void Link::accept(Serializer &serializer) {
serializer.visit(*this);
}
void Link::draw() {
auto pfrom = from.lock();
auto pto = to.lock();
std::cout << "drawing link between " << (pfrom ? pfrom->label : "-none-") << " and " << (pto ? pto->label : "-none-") << std::endl;
}
struct TreeDiagram;
struct Command {
virtual void execute(TreeDiagram &) = 0;
virtual void undo(TreeDiagram &) = 0;
};
struct TreeDiagram {
std::vector<std::shared_ptr<Part>> parts;
std::stack<std::unique_ptr<Command>> commands;
void execute(std::unique_ptr<Command> command) {
command->execute(*this);
commands.push(std::move(command));
}
void undo() {
if(!commands.empty()) {
commands.top()->undo(*this);
commands.pop();
}
}
void draw() {
std::cout << "draw..." << std::endl;
for(auto &part: parts) {
part->draw();
}
}
void serialize(Serializer &serializer) {
std::cout << "serialize..." << std::endl;
for(auto &part: parts) {
part->accept(serializer);
}
}
};
struct AddNode: Command {
AddNode(std::string label, unsigned int x, unsigned int y):
label{label}, x{x}, y{y}, node{std::make_shared<Node>()}
{
node->label = label;
node->x = x;
node->y = y;
}
void execute(TreeDiagram &diagram) override {
diagram.parts.push_back(node);
}
void undo(TreeDiagram &diagram) override {
auto &parts = diagram.parts;
parts.erase(std::remove(parts.begin(), parts.end(), node), parts.end());
}
std::string label;
unsigned int x;
unsigned int y;
std::shared_ptr<Node> node;
};
struct AddLink: Command {
AddLink(std::shared_ptr<Node> from, std::shared_ptr<Node> to):
link{std::make_shared<Link>()}
{
link->from = from;
link->to = to;
}
void execute(TreeDiagram &diagram) override {
diagram.parts.push_back(link);
}
void undo(TreeDiagram &diagram) override {
auto &parts = diagram.parts;
parts.erase(std::remove(parts.begin(), parts.end(), link), parts.end());
}
std::shared_ptr<Link> link;
};
struct MoveNode: Command {
MoveNode(unsigned int x, unsigned int y, std::shared_ptr<Node> node):
px{node->x}, py{node->y}, x{x}, y{y}, node{node}
{}
void execute(TreeDiagram &) override {
node->x = x;
node->y = y;
}
void undo(TreeDiagram &) override {
node->x = px;
node->y = py;
}
unsigned int px;
unsigned int py;
unsigned int x;
unsigned int y;
std::shared_ptr<Node> node;
};
int main() {
TreeDiagram diagram;
Serializer serializer;
auto addNode1 = std::make_unique<AddNode>("foo", 0, 0);
auto addNode2 = std::make_unique<AddNode>("bar", 100, 50);
auto moveNode2 = std::make_unique<MoveNode>(10, 10, addNode2->node);
auto addLink = std::make_unique<AddLink>(addNode1->node, addNode2->node);
diagram.serialize(serializer);    
diagram.execute(std::move(addNode1));
diagram.execute(std::move(addNode2));
diagram.execute(std::move(addLink));
diagram.serialize(serializer);
diagram.execute(std::move(moveNode2));
diagram.draw();
diagram.undo();
diagram.undo();
diagram.serialize(serializer);
}

我还没有实现重做操作,代码远非生产就绪的软件,但它可以很好地作为创建更复杂的东西的起点。

如您所见,目标是创建一个包含两个节点和链接的树形图。组件包含一堆数据,并且知道如何绘制自己。此外,正如预期的那样,组件接受序列化程序,以防您想将其写在文件或其他任何东西上。
所有逻辑都包含在所谓的命令中。在示例中有三个命令:添加节点、添加链接和移动节点。图表和组件都不知道引擎盖下发生了什么。图表所知道的只是它正在执行一组命令,并且这些命令可以一次执行一步。

更复杂的撤消/重做系统可以包含一个循环缓冲区的命令和一些索引,这些索引指示要替换为下一个索引的索引,一个在继续时有效,一个在返回时有效。
这确实很容易实现。

此方法将帮助您将逻辑与数据分离,这在处理用户界面时很常见。
老实说,这不是突然出现在我脑海中的事情。我在研究开源软件如何解决这个问题时发现了类似的东西,几年前我在我的软件中使用它。生成的代码非常易于维护。

您可能要考虑的另一种方法是使用不可变的数据结构和对象。然后,撤消/重做堆栈可以实现为场景/图表/文档版本的堆栈。Undo() 将当前版本替换为堆栈中的旧版本,依此类推。因为所有数据都是不可变的,所以你可以保留引用而不是副本,所以它既快速又(相对)便宜。

优点:

  • 简单的撤消/重做
  • 多线程友好
  • "结构"和瞬态的干净分离(例如电流选择)
  • 可以简化序列化
  • 缓存/记忆/预计算友好(例如边界框、GPU 缓冲区)

缺点:

  • 消耗更多内存
  • 强制分离"结构"和瞬态
  • 可能更困难:例如,对于典型的树状场景图,要更改节点,您还需要更改路径上到根的所有节点;旧版本和新版本可以共享其余节点。

假设您每次编辑图表时都在临时文件上调用 save()(即使用户没有显式调用save操作),并且您只撤消了最新的操作,您可以执行以下操作:

LastDiagram load(const std::string &path)
{
/* Check for valid path (e.g. boost::filesystem here) */
if(!found)
{
throw std::runtime_exception{"No diagram found"};
} 
//read LastDiagram
return LastDiagram;
}
LastDiagram undoLastAction()
{
return loadLastDiagram("/tmp/tmp_diagram_file");
}

在您的主应用程序中,如果抛出异常,您可以处理异常。如果你想允许更多的undo,那么你应该考虑像sqlite这样的解决方案或具有更多条目的tmp文件。

如果时间和空间性能是由于大型图表而导致的问题,请考虑实现一些策略,例如在 std::vector 中为图表的每个元素保留增量差异(如果对象很大,则将其限制为 3/5),并使用当前状态调用渲染器。我不是OpenGL专家,但我认为这是在那里完成的方式。实际上,您可以从游戏开发最佳实践或通常与图形相关的最佳实践中"窃取"此策略。

其中一个策略可能是这样的:

用于在图形编辑器中高效更新、增量重新显示和撤消的结构