Сообщений 0    Оценка 720        Оценить  
Система Orphus

Back/Forward и Undo/Redo в .NET-приложениях

Автор: Андрей Мартынов
The RSDN Group

Источник: RSDN Magazine #2-2003
Опубликовано: 06.12.2002
Исправлено: 13.03.2005
Версия текста: 1.0
Back/Forward
Логика
Проектируем
Реализуем
Используем
Undo/Redo
Логика
Опять проектируем
Снова реализуем
Наконец используем
Симметричные операции
Несимметричные операции.
Литература
Подводим итоги

Исходные тексты классов
Демонстрационное приложение

Back/Forward

Несмотря на то, что компьютеры становятся всё мощнее, а программы – всё изощрённее, жизнь конечных пользователей наших программ не становится проще. Дело в том, что, создавая свои программы, мы постоянно балансируем на грани возможной сложности пользовательского интерфейса. Стараясь полнее и точнее отразить сложную структуру данных и все возможные операции с ними, мы неизбежно усложняем пользовательский интерфейс. Единственное, что останавливает нас на этом пути – сомнения в том, разберётся ли пользователь в хитросплетении окошек, control-ов и диалогов? Будет ли ему удобно всем этим хозяйством пользоваться?

Обычная ситуация: пользователь, пробравшись через несколько диалоговых окон и побродив в ветвях довольно сложного дерева объектов, находит нужный элемент в тысячеэлементном списке (наконец-то) и видит необходимые ему данные. Затем он выполняет (возможно, ненамеренно) некоторую операцию, после чего программа переключается в совершенно другое место структуры данных. Что, придётся снова повторять путь в поисках исходных данных? Нет, только не это!

Справедливости ради надо сказать, что не все программы так суровы к своим пользователям. Вспомним хотя бы, как ведёт себя любой Web-броузер. Куда бы нас ни перенес выбор ссылки на html-странице (хотя бы и в глубину какого-нибудь огромного, совершенно незнакомого нам сайта), мы всегда знаем, что одного щелчка мыши будет достаточно, чтобы вернуться на исходную страницу. Исключительно полезная кнопочка Back всегда в нашем распоряжении!

Вы поняли, к чему я клоню? Я к тому, что если добавить в свою программу такую возможность, благодарность пользователей не будет знать границ (в пределах разумного, конечно). Тем более, что сделать это так просто! С того и начнём.

Логика

Позвольте вам напомнить логику работы операций Back/Forward на примере того, как она работает в Web-броузерах. Работа этих операций основана на данных, организованных в два стека – стек Back и стек Forward (см. рис 1.).


Рисунок 1. Сохранение состояния в стеке Back

Каждый раз при посещении новой html-страницы адрес предыдущей страницы добавляется в стек Back (push). Cтек Forward остаётся при этом пустым. Наполнение стека Back происходит до того момента, когда пользователь решит воспользоваться операцией Back.

При выполнении операции Back адрес текущей страницы помещается в стек Forward, а адрес с вершины стека Back извлекается (pop) и становится текущим (navigate). При выполнении следующих операций Back стек Forward все растёт и растёт за счет адресов, перекладываемых в него из стека Back (см. рис 2).


Рисунок 2. Команда Back.

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


Рисунок 3. Операция Forward.

Операция Forward продолжает быть доступной, пока не исчерпается соответствующий стек, после чего всё будет происходить ровно так же, как и в той ситуации, с которой мы начали (см. рисунок 1).

Обращу ваше внимание на то, что операция Forward доступна, только если ей предшествовали операции Back или Forward. Пока пользователь не сходит с «протоптанной тропинки», он может «вернуться вперёд». Но как только он перейдёт на новый адрес без помощи операций Back/Forward, дорожка Forward становится для него недоступной. Итак, при навигации без помощи Back/Forward стек Forward очищается.

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


Рисунок 4. Дерево состояний.

Ветка B1-B2 отсохла, потому что шаг B-A – это шаг в сторону.

Это, собственно, вся логика. Как видите, она довольно проста, и нам не составит большого труда реализовать её практически в любой программе. Состояния программ будут описываться не теми параметрами, что адреса html-страничек, но логика работы будет всегда одной и той же. Поэтому разумно написать небольшой класс, реализующий те части операций Back/Forward, которые едины для любых приложений.

Проектируем

В процессе проектирования попробуем исходить из желаемого, т.е. из того, какая функциональность и какой интерфейс был бы нам удобен. Попробуем смоделировать то, как будет использоваться будущий класс-помощник – BackMan (имя класса-помощника должно напоминать вам одного благородного киногероя. :)

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

public class MainForm : INavClient
{
  private BackMan backman; 
public  MainForm(string filePath)
  {
    InitializeComponent();
    // Передаём клиент и задаём макс. размер стеков
    this.backman = new BackMan(this, 20); 
  }

  // Обработчик команды меню и/или кнопки 
  // на панели инструментов
  public void Backward(Command cmd)
  {
    this.backman.Backward();
  }

  //  Метод, определяющий доступность операции
  public void BackwardUpdate(Command cmd)
  {
    cmd.Enabled = this.backman.CanBackward();
  }

  // Тоже самое для операции Forward
  public void Forward(Command cmd)
  {
    this.backman.Forward();
  }

  public void ForwardUpdate(Command cmd)
  {
    cmd.Enabled = this.backman.CanForward();
  }
}

Фантазируя на тему «как бы сделать так, чтобы всё само собой заработало», будем помнить, что чудес не бывает, и BackMan’у, для работы нужен способ получения и установки состояния системы. Для этого предусмотрим интерфейс IBackManClient, который должен быть реализован в классе-клиенте. В нашем случае его реализует главная форма приложения.

public interface IBackManClient
{
  object State { get; set; } 
  bool IsEqual(object state1, object state2);
}

Интерфейс лаконичен: методы получения и установки состояния и способ сравнения состояний. Т.к. мы не можем знать заранее параметры состояния программы, то состоянию мы приписываем максимально общий тип – object. Каждое приложение, конечно, определит свой класс состояния, и будет возвращать именно его в свойстве State{get}. Именно это состояние в неприкосновенном виде оно будет получать назад при установке свойства State{set} и при сравнении состояний. Сам класс-помощник ничего не знает о смысле и структуре класса состояния. Это позволяет реализовать его в максимально общем виде и, следовательно, максимально широко использовать.

Реализуем

Ниже приведен полный текст реализации класса-помощника. Его чтение не вызовет у вас трудностей. В комментариях нуждается только один момент. Необходимо пояснить, почему потребовалась обработка события Application.Idle.

public class BackMan
{
  public BackMan (INavClient client, int stackCapacity)
  {
    SetSize(stackCapacity);
    this.client = client;
    Application.Idle += new EventHandler(this.OnIdle);
  }

  public void Backward()
  {
    this.stackForward.Push(this.stateCurrent);
    this.stateCurrent  = this.stackBackward.Pop();
    this.whileNavigate = true;
    this.client.State  = this.stateCurrent;
  }

  public bool CanBackward()
  {
    return this.stackBackward.Count > 0;
  }

  public void Forward()
  {
    this.stackBackward.Push(this.stateCurrent);
    this.stateCurrent  = this.stackForward.Pop();
    this.whileNavigate = true;
    this.client.State  = this.stateCurrent;
  }

  public bool CanForward()
  {
    return this.stackForward.Count > 0;
  }

  public void Reset()
  {
    this.stackBackward.Clear();
    this.stackForward .Clear();
    this.stateCurrent = null;
  }

  public void SetSize(int stackCapacity)
  {
    this.stackBackward = new Stack(stackCapacity);
    this.stackForward  = new Stack(stackCapacity);
  }

  protected void OnIdle(object sender, EventArgs e)
  {
    if (this.whileNavigate)
    {
      this.whileNavigate = false;
      return;
    }

    if (this.stateCurrent == null)
      this.stateCurrent = this.client.State;
    else
    {
      object stateNow = this.client.State;
      if (! this.client.IsEqual(stateNow,
                                this.stateCurrent))
      {
        this.stackBackward.Push(this.stateCurrent);
        this.stateCurrent = stateNow;
        this.stackForward.Clear();
      }
    }
  }

  protected IBackManClient  client;
  protected bool   whileNavigate;
  protected object stateCurrent;
  protected Stack  stackBackward;
  protected Stack  stackForward;
}

Об Application.Idle: конечно, можно отследить все места программы, в которых происходит изменение состояния, и сообщить об этом классу-помощнику (для этого можно было бы предусмотреть специальный метод). Но таких мест может быть много, и разбросаны они могут быть по всей программе. Находить и править их все не очень удобно. Тем более что есть ещё одна неприятность.

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

Достаточно принять точку зрения конечного пользователя. Он наблюдает программу именно в состоянии Idle, все промежуточные перемигивания окошек для него несущественны. Поэтому достаточно проверять, не изменилось ли состояние программы только в состоянии Idle. И только эти изменения считать значимыми для пользователя. (Я уже не говорю о том, насколько такой приём упрощает программирование!)

ПРЕДУПРЕЖДЕНИЕ

В программе могут быть другие обработчики события Application.Idle. Часто такие обработчики используются для обновления пользовательского интерфейса в соответствии с текущим состоянием (например, менеджер команд, занимающийся изменением состояния меню и кнопок тулбара в зависимости от доступности команд). Нужно позаботиться, чтобы обработчик BackMan.OnIdle вызывался раньше других, т.к. он изменяет состояние доступности команд Back/Forward. Для этого экземпляр класса BackMan следует создавать как можно раньше по ходу выполнения программы.

Используем

Данный класс-помощник был применён в проекте .Net Explorer (см. RSDN Magazine #2'2002). Напомню, что эта программа позволяет в интерактивном режиме создавать объекты и вызывать их методы. Программа имеет два основных окна (см. рисунок 5), каждое из которых может быть связано с конкретным элементом класса (его методом, свойством, событием, и т.д. и т.п.).

Навигация в структуре данных, содержащей большое количество классов и типов, имеющих в своём составе большое количество элементов, является делом непростым. Ситуация осложняется тем, что при выполнении операций (например, при вызове метода) программа автоматически открывает страницу свойств объекта, который был возвращён методом. В результате для повторного вызова метода пользователю приходилось заново вручную отыскивать метод в дереве. Операция Back/Forward сильно упростила работу с программой.


Рисунок 5. Скриншот программы .Net Explorer.

Посмотрим на код, который пришлось реализовать для этого. Так как состояние программы задаётся тем, какие элементы выбраны в двух её окнах, то класс состояния DneState хранит ссылки на эти два элемента. Реализация интерфейса IBackManClient при этом элементарна.

internal class DneState
{
  public DneState(Element inTree, Element inPage)
  {
    this.elementInTree = inTree;
    this.elementInPage = inPage;
  }

  public Element elementInTree;
  public Element elementInPage;
}

public object State 
{ 
  get
  {
    return new DneState(this.treeView.SelectedElement,
                        this.tabControl.SelectedElement);
  } 
  set
  {
    DneState state = value as DneState;
    Debug.Assert(state != null);
    SelectNode(state.elementInTree);
    OpenPage(state.elementInPage);
  }
}

public bool IsEqual(object state1, object state2)
{
  DneState s1 = state1 as DneState;
  DneState s2 = state2 as DneState;

  return s1.elementInTree == s2.elementInTree
      && s1.elementInPage == s2.elementInPage;
}

Согласитесь, не слишком сложный код ради такой полезной операции как Back/Forward!

Undo/Redo

Теперь переходим к другой очень близкой теме, которую нельзя не затронуть. Наверное, вы заметили, что логика и реализация операции Back/Forward очень похожи на то, что требуется для операции Undo/Redo. Нет, они не просто похожи. Это они самые и есть!

Наш класс-помощник можно применить для восстановления состояния документа (данных) и получить, таким образом, реализацию Undo/Redo. К сожалению, без изменения этот класс можно использовать лишь в случае, если данные вашей программы имеют небольшой размер. Ведь они будут многократно копироваться, и храниться в стеках Back/Forward!

Что же делать, если данные имеют большой размер? Тут нет рецепта на все случаи жизни. Но общий принцип таков – в стеках надо хранить не сами данные, а их изменения или, другими словами, надо хранить в стеках сами операции (команды). А состояния при этом будут восстанавливаться в результате повторного применения сохранённых операций.

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

ПРИМЕЧАНИЕ

Вот пример. Тот же .Net Explorer. Операция вызова метода объекта – пример необращаемой операции. Откатить вызов метода объекта назад, вообще говоря, не представляется возможным. Как, например, догадаться, что обратным методу Show является метод Hide, а обращением метода Move – тот же самый метод, но с другими параметрами?

Хотя, может быть попробовать отразить эти отношения между методами с помощью атрибутов?

Логика

Давайте рассмотрим операции Undo/Redo поподробнее.


Рисунок 6. Редактирование документа с сохранением команды в стеке Back.

При выполнении операции редактирования данных в стеке Undo сохраняется сама операция и все данные, необходимые для её обращения (класс-команда).


Рисунок 7. Выполнение операции Undo.

При выполнении операции Undo мы перекладываем команду с вершины стека Undo в стек Redo и после этого выполняем команду, обратную только что переложенной (reverse execute).


Рисунок 8. Выполнение операции Redo.

Выполнение операции Redo намного проще, чем Undo. Надо всего лишь переложить команду обратно в стек Undo, а затем исполнить её. Обращения команды не требуется.

При выполнении «свободной» операции стек Redo должен очищаться – всё абсолютно так же, как и в случае с операцией Back/Forward.

Как видите, в самой логике операции Undo/Redo нет ничего сложного. Чего нельзя сказать об алгоритмах реализации команд, хранящихся в стеках Undo/Redo. При их проектировании и реализации необходимо обеспечить возможность обращения команды, не забывая при этом, что объём сохраненных данных должен быть по возможности минимальным. Это требует определённой изворотливости и применения изощрённых алгоритмов.

ПРИМЕЧАНИЕ

Кстати, вот думаю: а ведь Undo/Redo не помешало бы реализовать в .Net Explorer’e? Ну и что, что я не могу восстановить состояния всех объектов? Но ведь могу восстановить их ассортимент и порядок расположения в дереве - это тоже не мало. Пользователь, сгоряча удаливший объект или тип, будет обрадован, узнав, что он может отменить свои действия. Итак, решено.

Опять проектируем

Перед тем, как начать проектировать, сообщу вам один принципиальный момент, касающийся обращения операций. Чем операция проще, тем легче её обратить, тем больше вероятность, что обращение реализуемо. Поэтому имеет смысл разбивать операцию, которую видит пользователь, на последовательность более мелких, внутренних операций (микро-операций). Для того чтобы обозначать границы пользовательских операций, в стеки помещаются специальные команды – контрольные точки. Эти специальные команды не исполняются, они лишь обозначают, что пользовательская операция завершилась. Операция Undo будет отматывать последовательность микрокоманд до первой встретившийся в стеке Undo контрольной точки. Аналогично будет работать команда Redo.

Поэтому при работе с Undo/Redo у приложения (документа) теперь намного больше обязанностей. Необходимо не только сообщать о каждой микрокоманде, но ещё и расставлять контрольные точки.

Чтобы перейти ближе к делу, начнём писать небольшое приложение-пример. Это будет тривиальная рисовалка каракулей на экране (см. рисунок 9). Пользователь нажимает левую кнопку мыши и рисует линию, пока не отпустит левую кнопку.


Рисунок 9. Скриншот приложения-примера.

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

public class Form1 : System.Windows.Forms.Form
{
  private Document  doc;
  private UndoMan   undoMan;
  . . .
  private void OnMouseDown(object sender,
                 System.Windows.Forms.MouseEventArgs e)
  {
    this.panelDraw.Capture = true;
    this.pointPrev = new Point(e.X, e.Y);
  }

  private void OnMouseMove(object sender, MouseEventArgs e)
  {
    if (this.panelDraw.Capture)
    {
      this.undoMan.Do(this.doc, new DrawSegmentCommand(
        this.pointPrev, new Point(e.X, e.Y))))
      this.pointPrev = new Point(e.X, e.Y);
    }
  }

  private void OnMouseUp(object sender, MouseEventArgs e)
  {
    if (this.panelDraw.Capture)
    {
      this.undoMan.Do(this.doc, new DrawSegmentCommand(
        this.pointPrev, new Point(e.X, e.Y)));
      this.undoMan.Commit();
      this.panelDraw.Capture = false;
    }
  }
}

Как видите, все операции реализуются не прямыми вызовами методов документа, а созданием объекта-команды с передачей его в методы класса-помощника. Кроме того, по завершении штриха вызывается метод, проставляющий контрольную точку – Commit. Остальной код не приведён, т.к. он такой же, как в прошлый раз – вызовы перенаправляются соответствующим методам класса-помощника.

Для команды определён интерфейс, который она должна реализовать.

public interface ICommand
{
  bool Do(object document);
  void Undo(object document);
}

И примерно вот так она это делает:

class DrawSegmentCommand : ICommand
{
  public virtual bool Do(object document)
  {
    Document doc = document as Document;
    doc.ink.Add(this.segment);
    doc.views.Update ();
    return true;
  }

  public virtual void Undo(object document)
  {
    Document doc = document as Document;
    doc.ink.Remove(this.segment);
    doc.views.Update ();
  }

  Segment  segment;
}

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

Снова реализуем

Вот исходный код класса, реализующего операции Undo/Redo (классу дано французское имя UndoMan – ударение на последнем слоге :)).

public enum TransactionEventArgs
{
  Commit, CommitUndo, CommitRedo, Rollback
}

public enum UndoManType
{
  autoCommit, manualCommit
}

public class UndoMan
{
  public delegate void TransactionEventHandler(
                         object sender,
                         TransactionEventArgs args);

  public UndoMan(int stackCapacity, UndoManType type)
  {
    SetSize(stackCapacity);

    this.type   = type;

    if (type == UndoManType.autoCommit)
      Application.Idle += new EventHandler(this.OnIdle);
  }
  
  public void Undo(object document)
  {
    if (this.stackUndo.Count < 2) 
      return;

    //Если транзакция завершена, снимаем контр. точку
    if (this.stackUndo.Peek() == null)
      this.stackUndo.Pop(); 

    this.stackRedo.Push(null);
    while (this.stackUndo.Peek() != null)
    {
      ICommand cmd = this.stackUndo.Pop() as ICommand;
      this.stackRedo.Push(cmd);
      cmd.Undo(document);
    }
    Transaction(this, TransactionEventArgs.CommitUndo);
  }

  public bool CanUndo()
  {
    return this.stackUndo.Count > 1;.
  }

  public void Redo(object document)
  {
    if (this.stackRedo.Count == 0)
      return;

    //Если транзакция не завершена, завершаем её
    if (this.stackUndo.Peek() != null)
      this.stackUndo.Push(null); 

    while (this.stackRedo.Peek() != null)
    {
      ICommand cmd = this.stackRedo.Pop() as ICommand;
      this.stackUndo.Push(cmd);
      if (! cmd.Do(document))
      {
        Rollback(document); 
        return;
      }
    }
    this.stackRedo.Pop();      
    this.stackUndo.Push(null); 
    Transaction(this, TransactionEventArgs.CommitRedo);
  }

  public bool CanRedo()
  {
    return this.stackRedo.Count > 1;
  }

  public bool Do(object document, ICommand cmd)
  {
    if (! cmd.Do(document))
    {
      Rollback(document);
      return false;
    }
    this.stackUndo.Push(cmd);
    this.stackRedo.Clear();      
    return true;
  }

  public void Commit()
  {
    object cmd = this.stackUndo.Peek();
    if (cmd != null)
    {
      this.stackUndo.Push(null);
      Transaction(this, TransactionEventArgs.Commit);
    }
  }

  public void Rollback(object document)
  {
    while (this.stackUndo.Peek() != null)
    {
      ICommand cmd = this.stackUndo.Pop() as ICommand;
      cmd.Undo(document); 
    }
    Transaction(this, TransactionEventArgs.Rollback);
  }

  protected void OnIdle(object sender, EventArgs e)
  {
    Commit();
  }

  public void SetSize(int stackCapacity)
  {
    this.stackUndo = new Stack(stackCapacity);
    this.stackUndo.Push(null);
    this.stackRedo = new Stack(stackCapacity);
  }

  public void Reset()
  {
    this.stackUndo.Clear();
    this.stackUndo.Push(null); // Initial Check Point
    this.stackRedo.Clear();
  }

  public event TransactionEventHandler Transaction;
  protected UndoManType type;    
  protected Stack       stackUndo;
  protected Stack       stackRedo;
}

Как видите, в качестве контрольной точки в стеки пишется просто null. Ну и конечно, применён излюбленный способ с использованием события Application.Idle для автоматического завершения транзакции.

Что? Вопросы? При чем здесь транзакции?

Да. Вот такой приятный сюрприз. Оказывается, реализовав механизм Undo/Redo, мы заодно получаем такую серьёзную вещь, как транзакционность (может быть это сильно сказано, но тем не менее…).

Если случится неудача, и микрокоманда не выполнится, можно аккуратно откатить состояние документа до ближайшей контрольной точки. Это получилось само собой – ведь «у нас все ходы записаны».

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

ПРИМЕЧАНИЕ

Чтобы UndoMan действительно превратился в некое подобие машины транзакций (Transaction Engine), необходимо предъявить более строгие требования к операциям (к командам). Раньше было рекомендовано делать их «минимальными», теперь же придётся потребовать «атомарности» операций. Атомарность в данном случае означает, что операция не должна иметь возможности завершиться наполовину, частично. «Или всё, или ничего».

Наконец используем

Об использовании класса мы уже немного говорили – рассмотрели реализацию команд. Продолжим.

Есть два способа отражения изменений в документе (update views): отражать изменения, сделанные каждой микрокомандой – это первый способ, или отражать изменения только по завершении транзакции – это второй. Каждый способ хорош или плох в зависимости от решаемой задачи. В примере-рисовалке выбран первый, в .Net Explorer’e – второй.

Если вы решите обновлять форму только по завершении транзакции, вам пригодится событие UndoMan.Transaction, на которое можно подписаться, и обработать его примерно таким способом:

this.undoMan.Transaction += 
  new TransactionEventHandler(this.doc.OnTransaction);
. . . 
public void OnTransaction(object sender, TransactionEventArgs args)
{
  if (args == TransactionEventArgs.CommitUndo)
    UpdateViews();
}

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

В отношении использования режима работы с автоматическим завершением транзакции можно сказать всё то же, что было сказано о работе BackMan. Надо обеспечить, чтобы обработчик UndoMan.OnIdle() вызывался раньше других обработчиков события Application.Idle, обновляющих интерфейс пользователя. Потому UndoMan должен быть создан раньше всех других обработчиков события Idle. Другой вариант – ручное завершение транзакции. Совершенно не подходит режим работы с автоматическим завершением транзакции в тех случаях, когда имеются асинхронные операции, создание дополнительных циклов обработки сообщений или захват сообщений мыши (Capture). В этих случаях нужно применять только ручное завершение транзакции.

Симметричные операции

Ещё одна подробность. Опыт применения UndoMan’а в примере-рисовалке и в .Net Explorer’e показал, в общем-то, очевидную вещь. Классы-команды стали собираться парами (ShowCommand и HideCommand, AddХХХCommand и RemoveХХХCommand…). Эти классы отличались лишь тем, что содержимое методов Do и Undo у них было переставлено местами. Поэтому, имея одну операцию, легко получить функциональность другой с помощью простой перестановки методов Do и Undo. Что ж, значит можно не создавать "обратных" операций, а использовать только "прямые", на лету преобразуя их в обратные.

ПРИМЕЧАНИЕ

Слова «прямые» и «обратные» взяты в кавычки потому, что невозможно сказать, какая из двух операций – прямая, а какая – обратная. Это дело исключительно соглашения. Отсюда название – симметричные операции.

Чтобы разобраться с тем, какая операция на самом деле лежит в стеке (прямая или обратная), будем помечать обратные операции флажком, значение которого будет определяться в момент первого исполнения команды. Для этого добавим к интерфейсу UndoMan ещё один метод DoReversed(ICommand cmd), который и будем применять для исполнения «обратных» операций.

Вот небольшая иллюстрация из .Net Explorer’а:

public void AddNewScript()
{
  ScriptElement script = new ScriptElement(this,
                                DefaultScriptName());
  AddScriptCommand cmd = new AddScriptCommand(script, -1);
  if (this.undoMan.Do(this, cmd))
    this.undoMan.Commit();
}

public void DeleteScript(ScriptElement script)
{
  int idx = this.scripts.IndexOf(script);
  AddScriptCommand  cmd = new AddScriptCommand(script, idx);
  if(this.undoMan.DoReversed(this, cmd))
    this.undoMan.Commit();
}

Обратите внимание, операция удаления сценария реализуется через операцию его добавления(!) и через метод DoReversed().

Изменения, связанные с DoReversed(), не носят принципиального характера. Самую последнюю версию исходных текстов класса UndoMan можно найти в прилагающихся к статье исходных текстах.

Несимметричные операции.

Симметричные операции возникают тогда, когда прямая и обратная операция требуют хранения в стеках одной и той же структуры данных. Но так бывает далеко не всегда. Например, мы разрабатываем текстовый редактор. Операция добавления нового слова в текст для своего обращения (undo) требует сохранения только позиции и размера вставленного слова – этих данных достаточно для удаления слова. Само слово сохранять не нужно. А вот операция удаления слова из текста для отмены требует сохранения того слова, которое удаляется (ведь его предстоит восстановить). Таким образом, мы видим, что две взаимно обратные операции требуют для работы данных, существенно различающихся не только по структуре, но и (что очень важно) по размеру. Использование несимметричных команд позволяет обеспечить существенную экономию оперативной памяти.

Использование несимметричных команд изменяет логику работы. Команды в этом случае не перекладываются из стека в стек. Теперь каждый раз в стек кладётся новая команда, которая создаётся на основе данных из старой команды и данных из документа. Рассмотрим это подробнее:


Рисунок 10. Undo несимметричной операции.

При выполнении операции Undo (см. рисунок 10) в процессе исполнения команды создаётся новая команда Redo, которая затем помещается в стек Redo.


Рисунок 11. Redo несимметричной операции.

При выполнении операции Redo производятся симметричные действия (см. рис 11).

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

Интерфейс несимметричной команды стал ещё короче.

public interface ICommand2
{
  ICommand2 Do(object document);
}

Единственный метод интерфейса предназначен для исполнения команды и создания обратной команды. Метод Do не претерпел больших изменений:

public bool Do(object document, ICommand2 cmd)
{
  ICommand2 cmdUndo = cmd.Do(document);
  if (cmdUndo == null)
  {
    Rollback(document);
    return false;
  }
  this.stackUndo.Push(cmdUndo);
  this.stackRedo.Clear();      
  return true;
}

Так же, как и методы Undo и Redo:

public new void Undo(object document)
{
  if (this.stackUndo.Count < 2)
    return;

  //  Если транзакция завершена, снимаем контр. точку
  if (this.stackUndo.Peek() == null)
    this.stackUndo.Pop(); 

  this.stackRedo.Push(null);
  while (this.stackUndo.Peek() != null)
  {
    ICommand2 cmdUndo = this.stackUndo.Pop() as ICommand2;
    ICommand2 cmdRedo = cmdUndo.Do(document);
    this.stackRedo.Push(cmdRedo);
  }
  FireTransaction(TransactionEventArgs.CommitUndo);
}

Применение несимметричных операций в примере-рисовалке выявило, что операция добавления отрезка линии вообще не нуждается для обращения в хранении данных. Никаких!

internal class DeleteSegmentCommand : ICommand
{
  public virtual ICommand Do(object document)
  {
    Document doc = document as Document;
    Segment segment = doc.ink[doc.ink.Count - 1] as Segment;
    doc.ink.RemoveAt(doc.ink.Count -1);
    doc.views.Update();
    return new AddSegmentCommand(segment);
  }
}

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

Еще один вид ухищрений, применяемых при реализации Undo/Redo – это объединение (слияние) команд. Этот простой и эффективный способ можно пояснить на следующем примере. Если подряд идут две операции удаления фрагментов из теста, причём удаляемые фрагменты расположены последовательно (один за другим), то можно считать, что произошло не два удаления, а одно, только более «крупное». Это не сильно изменяет реализацию Undo/Redo. Достаточно при добавлении в стеки очередных команд рассмотреть ту команду, которая лежит на вершине стека и попробовать объединить эти две команды.

Литература

Надеюсь, мне удалось показать, что решение задачи Undo/Redo – занятие довольно интересное, и каждый программист, решая свою уникальную задачу, обязательно найдёт свои особенные методы, свои подходы. Опыт разработок на тему Undo/Redo широко освещается в Сети. Особенно богат материалами на эту тему сайт CodeProject:

  1. Simple and Easy Undo/Redo. By Keith Rule. Вариант реализации c полным копированием данных документа в стеки Undo/Redo. Для MFC-приложений архитектуры document/view. Подход приводит к большому объёму хранимой информации, поэтому здесь применяются файлы, проецируемые в память.
  2. Implementing Undo / Redo - The DocVars Method. By Tom Morris. Вариант реализации с хранением в стеках операций по изменению отдельных полей документа – «DocVars’ов». Тоже MFC.
  3. A Basic Undo/Redo Framework For C++. By Yingle Jia. Здесь реализован подход на основе команд.
  4. Undoand Redo the “Easy” Way. By compiler. Интересный подход к уменьшению размеров необходимой памяти для хранения стеков Undo/Redo – данные сжимаются на лету алгоритмом RLE.
  5. Undo Manager. By Jens Nilson. Среди многочисленных стандартных интерфейсов OLE имеются интерфейсы, предназначенные для реализации операций Undo/Redo. IOleUndoManager – интерфейс менеджера Undo/Redo-команд. Каждая команда должна реализовать интерфейс IOleUndoUnit. Интерфейс IOleUndoUnit имеет специальный метод позволяющий объединять (сливать) команды. Пример ATL-реализации интерфейсов вы найдёте в этой статье.

При этом авторы всех статей не устают напоминать читателям, что при копировании в стеки указателей необходимо копировать (клонировать) данные, на которые эти указатели указывают, или использовать другие механизмы, гарантирующие доступность данных в течение всего времени жизни команд, хранящихся в стеках. Для решения этой задачи очень популярно использование механизма подсчёта ссылок. При этом авторы упоминают, что для решения этой задачи очень подошёл бы механизм сборки мусора, но за его отсутствием в С++ применяют другие методы.

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

Подводим итоги

Подводя итоги, надо признаться, что слова о том, что у нас получился маленький Тransaction Engine – это, конечно шутка. :) До этого ещё очень и очень далеко. Ведь известно, что полноценный механизм транзакций должен удовлетворять принципам «ACID» – атомарность (Atomicity), согласованность (Consistency), изоляция (Isolation), устойчивость (Durability). Первые два принципа (атомарность и согласованность) мы как бы реализовали без слишком больших усилий, а вот задача изоляции одновременных действий нескольких пользователей в условиях распределённого приложения не решена. Есть ещё принцип устойчивости. Этот принцип требует, чтобы данные оставались в согласованном (действительном) состоянии «при любых условиях».

ПРИМЕЧАНИЕ

Когда говорят «при любых условиях», это означает, что данные не повредятся, даже если в процессе работы внезапно пропала сеть, или даже если кто-то выдернул шнур питания компьютера. Часть изменений при этом может пропасть (самые последние из их), но состояние данных на момент завершения последней транзакции будет сохранено.

Думаю, понятно, что выполнение принципа ACID при высоком уровне производительности и низком расходе ресурсов – задача нетривиальная.

А если серьёзно, то лучше не пренебрегать возможностями Undo/Redo и широко применять их в своих программах. Ведь в операциях Undo/Redo (Back/Forward) есть то, что вызывает у пользователей ощущение доброжелательного поведения программы. А это особенно высоко ценится теми, для кого мы работаем.

Это всё. Счастливо!


Эта статья опубликована в журнале RSDN Magazine #2-2003. Информацию о журнале можно найти здесь
    Сообщений 0    Оценка 720        Оценить