Реализация Undo/Redo модели для сложного документа

от автора

Привет Хабр! В данной статье я хочу показать, как можно организовать модель редактирование документа со сложной структурой с возможностью отмены/возврата действий.

Предыстория и проблематика

Все началось с того, что я писал узкоспециализированный outline-софт, где основная идея заключается в оперировании кучей виртуальных бумажных карточек на разных сценах в разных редакторах.

Получилось похоже на MS Visio с определенной степенью кастомизации и плагинизации. Никаких технических сложностей здесь нету, однако есть ряд особенностей.

Во-первых, сцен несколько. А значит и оконных редакторов нужно несколько, каждый из которых работает по своим правилам.

Во-вторых, т.к. набор карточек один, а одна и та же карточка может быть использована в разных местах, то это рождает определенные зависимости между разными частями документа. И, если карточка удаляется, то это влечет за собой устранение этой карточки из всех мест, где она задействована.

В-третьих, когда я сделал все, что хотел, и показал результаты другу (который даже не программист), то он потыкал и сказал, что неплохо бы сделать Ctrl+Z. Я загорелся идеей, но вот реализовать это оказалось не такой тривиальной задачей. В этой статье я опишу, к чему пришел в итоге.

Существующие решения

Конечно, прежде чем делать что-то свое, я надеялся найти что-то готовое. Достаточно подробный анализ проблематики приводится в Undo и Redo — анализ и реализации. Однако, как оказалось, кроме общих принципов и слов найти что-то типа библиотеки сложно.

Первое, и самое очевидное решение — это делать версию документа на каждое изменение. Конечно, это надежно, но занимает много места и лишних операций. Поэтому такой вариант был отброшен сразу же.

Более интересным кажется memento pattern. Здесь уже можно немного сэкономить ресурсы за счет использования состояния документа, а не самого документа. Но это опять же, зависит от конкретной ситуации. А т.к. писал я все на C++, то здесь я бы не получил никакого выигрыша. При этом, даже существует C++ template проект undoredo-cpp, реализующий данный паттерн.

Command patter в принципе то что нужно, но, к сожалению, можно найти только принципы, но не универсальные реализации. Поэтому он и был взят за основу. И, конечно же, хотелось достичь максимальной производительности, из чего вытекала минимизация хранения данных.

Таким образом, стало примерно понятно, как и что хочется получить на уровне реализации. И получилось выделить конкретные цели:

  1. Система содержит набор редакторов, каждый из которых может редактировать свою сцену.
  2. Любое изменение документа, которое отразится на открытом редакторе, должно быть донесено до него, и сам редактор должен отреагировать на него максимально эффективно (исключая полное перестроение сцены документа).
  3. Все изменения глобальные, т.е. независимо в каком редакторе мы сейчас, стэк изменений общий.
  4. Должна быть возможность как отмены последнего действия, так и возврат (Undo/Redo).
  5. Размер буфера изменений не должен быть ограничен ничем, кроме настроек и аппаратных ресурсов.

Также следует отметить, что все писалось на QT5/C++11.

Модель документа

Основной сущность, над которой совершаются действия — это документ. К документу могут применяться различные атомарные действия, назовем их примитивами. Атомарность предполагает, что до и после применения примитива документ находится в консистентном состоянии.

В моем документе я выделил следующие сущности (следует отметить, что мой софт предназначался для наброски плана сценария, отсюда и специфика): карточка, персонаж, сюжетная карточка (ссылается на карточку), карточка персонажа (ссылается на карточку), точка сюжетной линии (ссылается на карточку), сюжетная линия (содержит цепь сюжетных карточек) и др. Таким образом, сущности могут ссылаться друг на друга, и это может стать источником проблем в дальнейшем, если мы захотим вернуть действие по созданию сюжетной карточки, которая ссылается на карточку, создание которой мы уже откатили. Т.е. напрашивается некий механизм управления ссылками, но о нем позже.

При выделении примитивов получается примерно следующий набор: создать карточку, поменять текст карточки, удалить карточку, создать сюжетную карточку, создать сюжетную линию, поменять текст сюжетной линии, добавить карточку в сюжетную линию и др. Концептуально любой примитив явно относится по смыслу к какой-то сущности, значит есть смысл ввести типизацию примитивов по адресованной сущности (карточка, сюжетная линия, персонаж и т.д.).

class outline_primitive { public:     enum class entity_t { card, plot, act_break, outline_card, ...};     ...     entity_t entity;      document_t * pDoc;      using ref_t = referenced_entity<outline_primitive>;     std::vector<ref_t*> dependencies; };

Следует обратить внимание на атрибут dependencies — это как раз зависимости, на которые примитив ссылается, но его назначение будет рассмотрено чуть позже. Также, примитивы можно классифицировать по типу: создание; модификация; удаление.

enum class operation_t { create, modify, remove }; operation_t operation; 

При этом, модифицирующие примитивы могут порождать целое дерево, в зависимости от допустимых модификаций — например подвинуть карточку, добавить карточку в сюжетную линию и др.

Примитив может быть применен либо в прямом направлении, либо в обратном. Более того, для удаляющих примитивов и для assert-ов полезно хранить, в каком состоянии примитив — примененном или откатанном.

virtual void outline_primitive::apply() {     perform_check(!applied);     applied = true;     pDoc->unsavedChanges = true; }  virtual void outline_primitive::revert() {     perform_check(applied);     applied = false;     pDoc->unsavedChanges = true; }  bool applied; 

Далее, рассмотрим реализацию простейшего примитива — добавления карточки.

Реализация простейшего примитива

Примерно вот так выглядит реализация примитива создания карточки. Я не буду приводить очевидные рутинные операции, такие как инициализация pDoc и др.

class OUTLINE_DOC_API card_create_primitive : public outline_primitive {     index_card * pCard;     index_card::data_t cardData;    //Данные для создания карточки      card_create_primitive::card_create_primitive(const index_card::data_t & _data);      void apply()     {         _Base::apply();          auto p_card = new index_card;   //Создаем новую карточку         p_card->data = cardData;         pDoc->cards.push_back(p_card);  //Добавляем в документ         pCard = p_card; //И запоминаем указатель     }      void revert()     {         _Base::revert();          auto it = std::find(pdoc->cards.begin(), pdoc->cards.end(), pCard);         perform_check(it != pdoc->cards.end()); //assert         pDoc->cards.erase(it);  //Удалим карточку из документа         delete pCard;   //И очистим сам объект         pCard = nullptr;    //Теперь карточки как будто никогда не создавалось     } } 

В код специально добавлены несколько assert-ов, которые подтверждают консистентное состояние документа до и после применения примитива.

Ссылочная целостность

Теперь рассмотрим примитив создание сюжетной карточки. Фактически, это та же карточка, но находящаяся на сюжетном листе и имеющая координату. Т.е. она ссылается на сюжетную карточку и содержит дополнительные атрибуты (координаты).

Таким образом, предположим у нас есть последовательность примитивов — создать карточку, создать сюжетную карточку на ее основе. Тогда 2й примитив надо сослать на первый, при этом обеспечив возможность обновления ссылки, в случае если он будет отменен и восстановлен (с попутным удалением/пересозданием самой карточки).

Для этого и вводится специальная сущность referenced_entity, которую вы уже встречали раньше в списке зависимостей.

template<typename T> struct referenced_entity {     using primitive_t = T;     using entity_ptr = void;      referenced_entity(primitive_t * prim, entity_ptr * p_ent)     {         ...         prim->dependencies.push_back(this); //Поместим себя в список зависимостей примитива     }      entity_ptr * get() const     {         if (!parent)             return entity;         else         {             auto cur_ref = this;             while (cur_ref->parent)                 cur_ref = &(cur_ref->parent->baseEntity);             return cur_ref->entity;         }     }      primitive_t * parent;     entity_ptr * entity; }; 

Здесь важным моментом является помещение себя в список зависимостей примитива. Таким образом, если на содержимое referenced_entity уже кто-то ссылается, то можно восстановить связь в момент помещения примитива в буфер, и потом на основе этой связи получать указатель на текущий адрес объекта с помощью метода get().

Обработка примитивов

Для обработки примитива вводится специальная сущность — command_buffer. В ее задачи входит:

  • хранение последовательности примитивов;
  • обеспечение ссылочных связей;
  • прямое и обратное применение примитивов;
  • отброс хвоста при превышении длины;
  • генерация событий.

class command_buffer {     using primitive_id_sequence_t = std::vector<unsigned>;      std::vector<primitive_t*> data;     std::map<void*, primitive_id_sequence_t> front; }; 

В data хранятся примитивы в последовательности их создания пользователем. А в front — так называемый фронт ссылочных объектов. Когда новый примитив попадает в буфер, то он попадает в последний элемент цепи объекта, который хранится в baseEntity. И затем происходит проставление ссылок.

void command_buffer::submit(primitive_t * new_prim) {     discard_horizon();  //На случай если висят отмененные команды      //Проставить ссылки, если надо     for (auto & dep : new_prim->dependencies)     {         auto front_it = front.find(dep->entity);         if (front_it != front.end())             dep->reset_parent(data[*front_it->second.rbegin()]);     }      unsigned new_id = add_action(new_prim); //Кладем в data новый примититв      //Создание ни на что ссылаться не может     if (new_prim->operation == primitive_t::operation_t::create)     {         new_prim->apply(pDoc);         primitive_id_sequence_t new_seq;         new_seq.push_back(new_id);         front.insert(make_pair(new_prim->baseEntity.get(), new_seq));     }     else //А вот удаление и модификация - могут, значит надо сославться на фронт     {         auto front_it = front.find(new_prim->baseEntity.get());         if (front_it == front.end())         {             primitive_id_sequence_t new_seq;             new_seq.push_back(new_id);             front.insert(make_pair(new_prim->baseEntity.get(), new_seq));             new_prim->apply(pDoc);         }         else         {             auto & seq = front_it->second;             perform_check(!seq.empty());             seq.push_back(new_id);             new_prim->apply(pDoc);         }     } } 

Все остальные методы буфера достаточно тривиальны, и они также содержат undo() и redo(). Таким образом, command_buffer обеспечивает консистентное состояние документа, и остается вопрос, как же поддерживать в корректном состоянии представления, формируемые соответствующими редакторами.

Модель взаимодействия

Для этого необходимо ввести новую сущность — событие, и каждый открытый редактор должен правильно реагировать на соответствующий тип событий. Событие связано с применением примитива — до применения, после применения, до отката, после отката. Например, после применения можно делать реакцию на примитивы создания (т.к. до применения объекта еще нету), перед откатом — на те же примитивы создания, т.к. после отката ссылка будет потеряна.

template<typename T> struct primitive_event {     using primitive_t = T;     enum class kind_t {pre_applied, post_applied, pre_reverted, post_reverted};      kind_t kind;     primitive_t * primitive; }; 

Вот такие события будут рассылаться после каждой из 4х операций над примитивом. Соответственно, в каждом редакторе нужно сделать обработчик, который на эти события будет реагировать, и, соответствнно, миниально перестраивать сцену.

void my_editor::event_occured(event_t * event) {     switch..case } 

Здесь нужно делать трехэтажный switch..case по сущности, операции и событию, и смотрится это ужасно. Для этого воспользуемся хитростью, основываясь на том, что каждый из элементов можно преобразовать к целому числу, и введем такой макрос.

#define PRIMITIVE_EVENT_ID(entity, operation, event) ((unsigned char)entity << 16) | ((unsigned char)operation << 8) | (unsigned char)event 

Тогда тело данного метода примет вот такой вид, и его можно будет дописывать по мере появления новых примитивов без ущерба для удобства восприятия.

switch (PRIMITIVE_EVENT_ID(event->primitive->entity, event->primitive->operation, event->kind)) {     case PRIMITIVE_EVENT_ID(outline_primitive::entity_t::collision, outline_primitive::operation_t::create, event_t::kind_t::post_applied):     case PRIMITIVE_EVENT_ID(outline_primitive::entity_t::collision, outline_primitive::operation_t::remove, event_t::kind_t::post_reverted):     {         auto p_collision = static_cast<collision_t*>(event->primitive->baseEntity.get());         pScene->create_image(p_collision);         break;     }     ... } 

Правда стоит отметить, что если иерархия типов модифицирующего примитива для какой-то сущности разрастается, то внутри придется делать новые ветвления.

И это действительно работает

Описанный метод не ограничен моей моделью документа, и может быть использован в различных моделях документах. Если кому-то интересно посмотреть это в действии, то само скомпиленное приложение можно скачать на странице ultra_outliner.

Заключение

В рамках предложенного метода остался непроработанным один немаловажный вопрос. Большинство действий пользователя над документами действительно являются атомарными, однако часть из них производят сразу несколько примитивов. Например, если пользователь двигает карточку — это один примитив. А если он удаляет карточку, которая находится в 3х путях — то это 3 примитива по исключению карточки из цепи, исключение карточки с поля, и потом удаление самой карточки. Если такую цепь откатить, то за одно действие отката будет откачен только один примитив, в то время как логичным было бы откатить сразу все. Это требует определенной доработки метода, однако рассмотрим данную проблему в следующей статье.

ссылка на оригинал статьи https://habrahabr.ru/post/313654/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *