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

时间:2017-01-18 18:45:41

标签: c++

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

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

上述简单方法的问题在于它需要很多容易出错的样板工作。

我知道工作的序列化(保存/加载)部分可以通过使用Google的协议缓冲区或Apache Thrift来解决,它会为您生成样板序列化代码,但它没有&# 39;解决undo + redo问题。我知道对于Objective C和Swift,Apple提供了解决序列化和撤销的核心数据,但我对C ++的类似内容并不熟悉。

是否有一种很容易解决save + load + undo + redo并且只需很少样板的错误?

5 个答案:

答案 0 :(得分:11)

  

上述简单方法的问题在于它需要很多容易出错的样板工作。

我不相信这是事实。您的方法听起来很合理,使用Modern C ++功能和抽象,您可以为它实现安全而优雅的界面。

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

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,
    // ...
>;

现在您的所有可能的&#34;可撤消操作&#34;都有总和类型,您可以使用模式匹配来定义撤消行为 即可。我在variant&#34;模式匹配&#34;上写了两篇文章。通过重载你会感兴趣的lambda:

以下是您的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也是一个好主意:如果您忘记实现特定&#34的行为,将产生编译时错误;可撤销的行动&#34;。

几乎就是这样!如果要保存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;
                },
                // ...

如果您遵循此模式,则可以redo实施undo!只需从undo而不是redo_stack拨打undo_stack即可:因为您&#34;倒置&#34; 它将执行所需操作的操作

编辑:这里是一个minimal wandbox example,它实现了一个match函数,它接受一个变量并返回一个变体。

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

  • 访问者包含在lambda f中,它将返回类型统一到变体的类型:这是必要的,因为std::visit要求访问者始终返回相同的类型。

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

答案 1 :(得分:7)

在框架FlipODB中实施了解决此问题的两种合理方法。

代码生成/ 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 ++代码,而是要求您的程序调用{​​{1}}来重新声明您的结构,如下所示:

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可以为您实现序列化。

答案 2 :(得分:6)

  

上述简单方法的问题在于它需要很多容易出错的样板工作。

我想说实际的问题是你的撤销/重做逻辑是一个组件的一部分,它应该只将一堆数据作为一个位置,一个内容等等。

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

另一种可以帮助您设计自定义序列化实用程序或使用最常用的OOP模式的常见OOP模式是visitor design pattern。 这里的基本思想是你的图表不应该关心它包含的组件类型。无论何时您想要序列化它,您都需要提供一个序列化程序,组件在查询时会将它们提升为正确的类型(有关此技术的更多详细信息,请参阅 double dispatching )。

话虽这么说,minimal example的价值超过千言万语:

#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);
}

我没有实现重做动作,代码远不是生产就绪的软件,但它作为创建更复杂的东西的起点非常好。

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

更复杂的撤销/重做系统可以包含一个循环的命令缓冲区和一些索引,指示用下一个替换的那个,一个在前进时有效,一个在返回时有效。 它确实很容易实现。

这种方法可以帮助您将逻辑与数据分离,并且在处理用户界面时非常常见 说实话,这不是突然出现在我脑海里的东西。我在查看开源软件如何解决这个问题时发现了类似的东西,几年前我在我的软件中使用过它。生成的代码非常容易维护。

答案 3 :(得分:2)

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

优点:

  • 简单撤消/重做
  • 多线程友好
  • “结构”和瞬态(例如当前选择)的清洁分离
  • 可以简化序列化
  • 缓存/ memoization /预计算友好(例如边界框,gpu缓冲区)

缺点:

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

答案 4 :(得分:1)

假设您在图表的每次编辑时都在临时文件上调用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");
}

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

如果时间和空间的性能是大图的问题,请考虑实现一些策略,例如在std :: vector中为图的每个元素保持增量差异(如果对象很大则将其限制为3/5)使用当前状态调用渲染器。我不是OpenGL专家,但我认为这是它在那里所做的。实际上你可以“偷”&#39;这个策略来自游戏开发最佳实践,或者通常与图形相关的实践。

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

A structure for efficient update, incremental redisplay and undo in graphical editors