Pull to refresh

JUndo — undo библиотека для Java

Reading time13 min
Views6.4K


Введение


В конце прошлого года мне потребовался undo/redo инструмент для Java-проекта, который, помимо стандартных для этого концепта задач, умел бы сохранять историю команд и корректно обрабатывать привязку к меняющемуся адресному контексту (это с прицелом на мой готовящийся проект для Android и его регулярное пересоздание вьюшек). Поискал, не нашел, взялся.


Результатом стала библиотека JUndo.


Возможности


  • Сохранение истории команд для использования где-то в другое время, в другом месте
  • Поддержание версионности субъектов для миграции при восстановлении
  • Локальные контексты — механизм использования команд в другом адресном пространстве
  • Создание макросов для автоматизации выполнения цепочек команд
  • Создание clean state — "точки сохранения" (к примеру на диск), для быстрого возврата к этому состоянию
  • Дополнительные события для ручной настройки сохранения и восстановления в случае необходимости

Под термином "субъект" в контексте библиотеки понимается элемент приложения, все без исключения изменения которого производятся посредством команд. Невыполнение этого условия однозначно приведет к непредсказуемому поведению методов undo/redo и, вполне возможно, к краху приложения.>

Честно говоря, сделать слишком просто не получилось, в-основном, из-за поддержания концепции переноса команд в другой адресный контекст.


Само сохранение базируется на стандартном механизме сериализации Java, поэтому все автоматически сохраняемые классы библиотеки имплементируют Serialize. Те же, которые не имплементируют, требуется настраивать ручками при сохранении/восстановлении. Но, хоть это и звучит громоздко, на самом деле не так уж и сложно.




Важные моменты


  • При проектировании стека следует продумать, что будет являться субъектом стека, что — локальными контекстами.
  • При проектировании команд — какая информация будет частью класса (его полями), какая же — динамически подключаться в момент исполнения.

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



Как это работает - простой пример

Как это работает — простой пример


В качестве субъекта выступает вымышленный класс NonTrivialClass, со списком двух типов объектов — RECT & CIRCLE.


0. Дизайн


  • Нет никакой привязки к объектам локального контекста, не отражаем их в стеке
  • Экземпляр NonTrivialClass не зависит от адресного контекста, поэтому может быть субъектом стека
  • По этой же причине можем сохранять ссылки на экземпляр NonTrivialClass в командах

1. Создаем экземпляр, стек, и наблюдателя за событиями


NonTrivialClass ntc = new NonTrivialClass();
UndoStack stack = new UndoStack(ntc, null); // Экземпляр NonTrivialClass назначен субъектом стека
stack.setWatcher(new SimpleUndoWatcher()); // Обработчик события. Отвяжется автоматически при `UndoPacket...store()`

2. Работаем над субъектом


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


stack.push(new NonTrivialClass.AddCommand(stack, CIRCLE, ntc, null));
stack.push(new NonTrivialClass.AddCommand(stack, RECT, ntc, null));
stack.undo();
stack.redo();
stack.push(new NonTrivialClass.MovedCommand(stack, item, oldPos, null));
stack.undo();
stack.redo();
stack.push(new NonTrivialClass.DeleteCommand(stack, ntc, null));
stack.undo();
stack.undo();
stack.redo();

3. Сохраняем историю


String store = UndoPacket
    // Хорошей практикой является всегда сохранять идентификатор субъекта.
    // Им может быть полное имя класса.
    .make(stack, "some.NonTrivialClass", 1)
    // Упаковка строки в gzip
    .zipped(true)
    // NonTrivialClass имплементирует Serializable, ручной упаковки не требуется.
    //.onStore(...)
    .store();

SimpleUndoWatcher отвязывается автоматически.


4. Восстанавливаем где-то в другом адресном контексте, пользуемся далее


UndoStack stackBack = UndoPacket
    // Указываем явно, что не реализуем обработчики
    .peek(store, null)
    .restore(null)
    .stack(null);
// Привязываем заново обработчик.
stack.setWatcher(new SimpleUndoWatcher());
// Вся история сохранилась, можно пользоваться.
stack.undo();
stack.redo();
stack.push(new NonTrivialClass.MovedCommand(stack, item, oldPos, null));
stack.undo();
stack.redo();

Ничего сложного, но и ничего особенно интересного. Самый сок в том, как при переносе истории обрабатываются адресные контексты, как реализована поддержка миграции, и прочие вкусняшки.


Как это работает - сложный пример

Как это работает — сложный пример


Это часть кода из примера на JavaFx.


Пример дополнительно иллюстрирует миграцию субъекта на другую версию и применение локальных контекстов, в том числе строковых ресурсов.


0. Дизайн...


… команд

Мы управляем свойствами экземпляра класса javafx.scene.shape.Circle.


  • класс не имплементирует Serializable и даже не сериализуется в JSON без бубна, поэтому мы не будем использовать его в полях команд. Вместо этого будем сохранять свойства, которыми конкретно управляем: ColorUndo будет хранить color, RadiusUndoradius и так далее
  • команды имеют свойство Caption, которое может зависеть от контекста, ведь не исключено, что восстановление произойдет в приложении с другой локализацией, и с другой версией строковых ресурсов. Поэтому будем хранить идентификаторы ресурсов, а сами строки запрашивать динамически через локальные контексты стека
  • элементы управления javafx.scene.control.Slider, при помощи которых меняются свойства x, y и radius имеют особенность генерить события при изменении хоть на пиксель. Нам совсем ни к чему 100 команд при переносе субъекта на 100 пикселей, нужна лишь команда на конечное положение. Поэтому используем свойство склейки команд при помощи UndoCommand#id

// resId - идентификатор строкового ресурса для Caption.
public ColorUndo(@NotNull UndoStack owner, UndoCommand parent, int resId, Color oldV, Color newV) {
    super(owner, parent, resId,
        // Color тоже не Serializable, зато его строковые представления очень даже сериализуются.
        FxGson.createWithExtras().toJson(oldV),
        FxGson.createWithExtras().toJson(newV));
    }

@Override
protected void doRedo() {
    // Техника получения элемента локального контекста.
    // В реальности, конечно, следует проверять на наличие.
    ColorPicker cp = (ColorPicker) owner.getLocalContexts().get(IDS_COLOR_PICKER);
    Color cl = FxGson.createWithExtras().fromJson(newV, Color.class);
    cp.setValue(cl);
}

@Override
protected void doUndo() {
    // Техника получения элемента локального контекста.
    // В реальности, конечно, следует проверять на наличие.
    ColorPicker cp = (ColorPicker) owner.getLocalContexts().get(IDS_COLOR_PICKER);
    Color cl = FxGson.createWithExtras().fromJson(oldV, Color.class);
    cp.setValue(cl);
}

@Override
public int id() {
    return 1001; // аналогично для XUndo (return 1002) и YUndo (return 1003)
}

/**
* Склейка тут необходима, так как в момент движения ползунка радиуса события изменения свойства льются
* непрерывно, и вместо одной команды, как для {@link ColorUndo} мы получаем великое множество, что логически неверно.
* <p>Поэтому все непрерывные команды этого типа записываются в одну, что дает нам одно redo и одно undo на одно изменение.
*/
@Override
public boolean mergeWith(@NotNull UndoCommand cmd) {
    if(cmd instanceof RadiusUndo) {
        RadiusUndo ruCmd = (RadiusUndo)cmd;
        newV = ruCmd.newV;
        return true;
    }
    return false;
}

@Override
public String getCaption() {
    // Техника получения элемента локального контекста.
    // В реальности, конечно, следует проверять на наличие.
    Resources res = (Resources) owner.getLocalContexts().get(IDS_RES);
    return res.getString(resId);
}

… стека

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


1. Создаем стек и наблюдателя за событиями


stack = new UndoStack(tab.shape, null);
// Назначение локальных контекстов
stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RES, new Resources_V1());
stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_COLOR_PICKER, tab.colorPicker);
stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RADIUS_SLIDER, tab.radius);
stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_X_SLIDER, tab.centerX);
stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_Y_SLIDER, tab.centerY);
// Назначение обработчика событий UndoStack
stack.setWatcher(this);

2. Работаем над субъектом


Работа осуществляется автоматически привязкой к событиям виджетов и к событиям стека


// Привязка создания команд к событиям пропертей
tab.shape.fillProperty().addListener(
    (observable, oldValue, newValue)
        -> stack.push(new BaseTab.UndoBulk.ColorUndo(
            stack, null, 0, (Color)oldValue, (Color)newValue)
));

// Настройка undo/redo команд стека
tab.undoBtn.setOnAction(event -> stack.undo());
tab.redoBtn.setOnAction(event -> stack.redo());
tab.saveBtn.setOnAction(event -> stack.setClean());

/**
* Обработчик одного из событий стека {@link UndoWatcher}
* @param idx
*/
@Override
public void indexChanged(int idx) {
    tab.undoBtn.setDisable(!stack.canUndo());
    tab.redoBtn.setDisable(!stack.canRedo());
    tab.saveBtn.setDisable(stack.isClean());
    tab.undoBtn.setText("undo: " + stack.undoCaption());
    tab.redoBtn.setText("redo: " + stack.redoCaption());
}

3. Сохраняем историю


/**
* Сериализация демонстрирует технику работы с несериализуемым субъектом стека.
* Мы просто сохраняем нужные нам свойства в виде карты.
* Для чего вообще нужно сохранять свойства?
* А дело в том, что стек команд хранит последовательность от начального до конечного состояния субъекта на данный момент.
* Если не сохранить само это конечное состояние и не применить к новому субъекту в новом контексте, то получится рассогласование цепочки команд и состояния субъекта.
*/
private void serialize() throws IOException {
    try {
        String store = UndoPacket
            .make(stack, IDS_STACK, 1)
            .onStore(new UndoPacket.OnStore() {
                @Override
                public Serializable handle(Object subj) {
                    Map<String, Object> props = new HashMap<>();
                        Gson fxGson = FxGson.createWithExtras();
                        props.put("color", FxGson.createWithExtras().toJson(tab.shape.getFill()));
                        props.put("radius", FxGson.createWithExtras().toJson(tab.shape.getRadius()));
                        props.put("x", FxGson.createWithExtras().toJson(tab.shape.getCenterX()));
                        props.put("y", FxGson.createWithExtras().toJson(tab.shape.getCenterY()));
                        return fxGson.toJson(props);
                    }
            })
            .zipped(true)
            .store();
        // Для простоты стек сохраняется в файле в корне проекта.
        Files.write(Paths.get("./undo.txt"), store.getBytes());
    } catch (Exception e) {
        System.err.println(e.getLocalizedMessage());
    }
}

4. Восстанавливаем где-то в другом адресном контексте, пользуемся далее


В нашей ситуации мы восстанавливаем историю для нового экземпляра субъекта, на другой вкладке и более того — новый субъект имеет новый тип Circle_V2.
Вот как это обрабатывается.


// Получили строку
String store = new String(Files.readAllBytes(Paths.get("./undo.txt")));

stack = UndoPacket
        // Полезный шаг, устраняющий дальнейшую работу, если в строке оказался ненужный нам тип стека.
        .peek(store, subjInfo -> IDS_STACK.equals(subjInfo.id))
        // Во-первых, ручное восстановление свойств субъекта из строки (так как сохранение тоже было ручное)
        // Во-вторых, миграция свойств на новый субъект
        .restore((processedSubj, subjInfo) -> {
            // 1.
            Type type = new TypeToken<HashMap<String, Object>>(){}.getType();
            HashMap<String, Object> map = new Gson().fromJson((String) processedSubj, type);
            if(subjInfo.version == 1) {
                // 2.
                Gson fxGson = FxGson.createWithExtras();
                Color c = fxGson.fromJson(map.get("color").toString(), Color.class);
                tab.colorPicker.setValue(c);
                Double r = fxGson.fromJson(map.get("radius").toString(), Double.class);
                tab.radius.setValue(r);
                Double x = fxGson.fromJson(map.get("x").toString(), Double.class);
                tab.centerX.setValue(x);
                Double y = fxGson.fromJson(map.get("y").toString(), Double.class);
                tab.centerY.setValue(y);
            }
            return map;
        }).stack((stack, subjInfo) -> {
            // Подключение локальных контекстов на нужные места
            stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RES, new Resources_V2());
            stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_COLOR_PICKER, tab.colorPicker);
            stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RADIUS_SLIDER, tab.radius);
            stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_X_SLIDER, tab.centerX);
            stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_Y_SLIDER, tab.centerY);
        });

if(null == stack)
    stack = new UndoStack(tab.shape, null);

// Восстановление обработчика событий. 
stack.setWatcher(this);

Остальные действия по подключению событий стека и для стека аналогичны тем, что на шаге "2.Работа над субъектом", описанном ранее.


Как видно, при правильном проектировании дизайна конкретного проекта работа с библиотекой достаточно логична и несложна.


О дизайне библиотеки


JUndo это реализация паттерна Команда для создания в приложении цепочек Undo/Redo.


Паттерн Команда базируется на предположении, что все изменения субъектов совершаются посредством создания объектов соответствующих команд. Объекты команд сохраняют состояние субъекта, совершают необходимые изменения и последовательно сохраняются в стеке команд. Соответственно, каждая команда знает, как вернуть субъект в предыдущее состояние. До тех пор, пока модель "изменений через команды" не нарушается, всегда имеется возможность откатиться на любой момент, просто последовательно выполняя Undo, команда за командой в стеке, и вернуться обратно, выполняя Redo в направлении "вперед".


Классы


Библиотека состоит из следующих основных классов и интерфейсов:


  • class UndoCommand: базовый класс для команд, хранимых в стеке. Применяет команду redo/undo для атомарного изменения в субъекте
  • class UndoStack: стек команд. Содержит лист последовательно добавленных объектов команд, выполненных над определенным субъектом и может "откатывать" состояние субъекта до любого момента вперед и назад
  • class UndoGroup: группа стеков. Группировка стеков удобна, когда приложение имеет больше, чем один субъект (к примеру открытые документы), и необходимо для каждого хранить индивидуальное состояние undo/redo. UndoGroup имеет свойство "активный стек", позволяющий бесшовно переключаться между субъектами и выполнять undo/redo над каждым из них
  • class UndoPacket: класс управления сохранением и восстановлением стека. Дополнительная информация, которую можно сохранить при сохранении, позволяет узнать, как правильно интерпретировать субъекты на стороне восстановления, в частности версию субъекта (что полезно при миграции данных)
  • interface UndoWatcher: набор событий для подписчиков, нуждающихся в информации о событиях стека команд. Все методы дефолтные, так что подписчик не обязан реализовывать то, что ему не надо

Дополнительные классы и интерфейсы:


  • class RefCmd<V>: Удобная шаблонная команда, которая позволяет реализовать простое изменение данных без создания дополнительных классов
  • interface Getter<V>: Вспомогательный интерфейс для свойства-геттера команды RefCmd<V>
  • interface Setter<V>: Вспомогательный интерфейс для свойства-сеттера команды RefCmd<V>

Концепции


  • Один субъект — один стек: хранить изменения над одним субъектом в разных стеках не только нелогично, но и опасно с точки зрения устойчивости приложения при выполнении undo/redo; возможны самые разные коллизии. Поэтому в группе невозможно разместить 2 стека с одинаковым субъектом. Хотя это не поможет, если разработчик решит использовать такую потенциально опасную ситуацию для несгруппированных стеков
  • Чистое состояние (clean state): может использоваться для сигнализации, что субъект пересекает момент, когда происходило сохранение на диск. Полезно для отражения в состоянии зависимых визуальных контролов приложения (доступность кнопки "Сохранить", и т.п.) или быстрого возврата в состояние, когда субъект был сохранен
  • Слияние команд: используется для объединения однотипных последовательностей в единую команду. В текстовом документе можно воспользоваться для объединения печатания одиночных символов в команду, печатающую целое слово. В графическом можно объединить многократное перемещение субъекта от стартовой до конечной точки в одно перемещение
  • Макрокоманды: последовательность команд, выполняемых undo/redo за один раз. Это упрощает создание задач для приложений, когда совокупность взаимосвязанных команд должна быть выполнена одномоментно. К примеру, удаление/восстановление множества выделенных объектов графической сцены можно оформить как макрос "Удаление выделенных объектов".
  • Умное сохранение: позволяет
    • сохранять идентификатор и версию субъекта для правильной интерпретации при восстановлении
    • обрабатывать несериализуемые субъекты посредством ручного управления в настраиваемых событиях
    • сохранять полезную информацию в формате ключ-значение
  • Локальные контексты: незаменимый механизм в случае зависимости команд от объектов ссылочного типа, имеющих локальную адресацию; такие объекты нельзя сохранять, так как неизвестно, в каком адресном контексте будет использоваться субъект и его UndoStack после распаковки. Локальные контексты позволяют привязывать аналогичные объекты по идентификаторам на стороне восстановления


Правила


Один субъект — один стек команд


Это правило описано выше и является достаточно очевидным исходя из элементарной логики единственности цепочки изменений для субъекта.


Любое изменение свойства субъекта — только через команды


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


Все что не Serializable — через события OnStore/OnRestore


Под капотом библиотеки — работа с методами ObjectOutputStream, причем не только при сохранении/восстановлении. При работе стека с макросами тоже применяется сериализация.
Поэтому при проектировании команд не следует сохранять в полях несериализуемые типы, так как нет способа их обработать — следует использовать динамическое обращение к ним в методах doUndo/doRedo и так далее через обращения к локальным контекстам стека (свойство UndoCommand#owner).
Несериализуемые субъекты следует вручную преобразовывать в строки или другой Serializable тип в методах onStore()/restore().


Все что привязано к адресам в памяти — через локальные контексты


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


Небольшое HowTo по дополнительным опциям

HowTo


Создать макрос


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


stack.beginMacro("new line");
AddLineCmd(stack, "add line", null)
AddSymbolCmd(stack, "add char", ":", null)
AddSymbolCmd(stack, "add char", "~", null)
AddSymbolCmd(stack, "add char", "$", null)
AddSymbolCmd(stack, "add char", " ", null)
stack.endMacro();

вызванный в любой момент должен добавить новую строку и символы в ней


// Было
:~$ String 1
:~$ String 2
:~$ String 3|

// Макрос
stack.push(some_macro);

// Стало
:~$ String 1
:~$ String 2
:~$ String 3
:~$ |

UndoStackTest содержит тест testRealMacros(), описывающий как это работает:


    ...

    // Начало и запись последовательности команд
    stack.beginMacro("macro 1");
    stack.push(new TextSampleCommands.AddString(stack, "new string", "Hello", null));
    stack.push(new TextSampleCommands.AddString(stack, "new string", ", ", null));
    stack.push(new TextSampleCommands.AddString(stack, "new string", "world!", null));
    // После завершения в стеке создается макрос в списке макросов, печатающий "Hello, world!"
    stack.endMacro();

    ...

    // В какой-то момент используем макрос
    UndoCommand macro = stack.clone(stack.getMacros().get(0));
    stack.push(macro); // напечатается строка "Hello, world!"
    stack.undo(); // удалится строка "Hello, world!"
    stack.redo(); // снова напечатается строка "Hello, world!"

   ...

}

Естественно, макросы также сохраняются и восстанавливаются вместе со стеком.


Создать цепочку команд без использования макросов


Создать базовую команду типа UndoCommand, а для всех последующих указать ее, как парент. В стек добавлять только первую команду. Таким образом все последующие команды окажутся не в стеке, а в листе субкоманд первой команды, и автоматически выполнятся при вызове UndoStack.undo/redo:


UndoCommand parent = new UndoCommand("Add robot");
new AddShapeCommand(doc, ShapeRectangle, parent);
new AddShapeCommand(doc, ShapeRectangle, parent);
new AddShapeCommand(doc, ShapeRectangle, parent);
new AddShapeCommand(doc, ShapeRectangle, parent);
doc.undoStack().push(parent);



  • Сама библиотека тут
  • Пример использования в приложении с JavaFx тут
Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments9

Articles