Pull to refresh

Автоматизация Undo/Redo функциональности с помощью .NET Generics

Reading time11 min
Views5.7K
Original author: Sergey Arhipenko
Перевод статьи Automating Undo/Redo with .NET Generics Сергея Архипенко.

Введение

Эта статья описывает библиотеку, которая предоставляет undo/redo функциональность для каждого действия в вашем приложении. Вы можете использовать сложные структуры данных и сложные алгоритмы не задумываясь о том, как они будут переведены в предыдущее состояния по запросу пользователя или в результате возникновения ошибки.

Предпосылки

Если вы когда-нибудь разрабатывали графический редактор или дизайнер для сложных данных, вы сталкивались с трудоёмкой задачей реализации undo/redo функциональности, которая бы поддерживалась во всём приложении. Реализация парных Do и Undo методов для каждой операции скучный и подверженный ошибкам процесс, когда вы разрабытываете что-либо более серьёзное, чем калькулятор. В результате моих экспериментов я нашёл способ сделать поддержку undo/redo прозрачной для бизнес логики. Чтобы этого добиться, мы будем использовать магию generics.
Этот проект опубликован на CodePlex, чтобы каждый мог его использовать или внести свой вклад.

Использование кода


Есть две хороших новости. Во-первых, public свойства ваших классов данных изменять не нужно. Мы всего лишь объявим private поля по-другому, чем обычно. Во-вторых, бизнес логику изменять тоже не нужно. Всё, что нужно, — это отметить начало и конец этого кода подобно транзакции. Таким образом, код будет выглядеть так:

UndoRedoManager.Start("My Command"); // начало

myData1.Name = "Name1";
myData2.Weight = 33;
myData3.MyList.Add(myData2);

UndoRedoManager.Commit(); // конец


Можно сделать откат всех изменений в этом блоке с помощью одной строки кода:

UndoRedoManager.Undo();

Следующая строка применяет снова отменённые изменения:

UndoRedoManager.Redo();

Позвольте мне обратить ваше внимание на то, что независимо от того, сколько объектов принимало участие в операции и какие типы данных использовались, все изменения могут быть применены/отменены в результате одной транзакции. Можно работать и со ссылочными, и с типами данных по значению. UndoRedoFramework поддерживает также типы данных List и Dictionary. Давайте теперь рассмотрим, как объявить класс данных и реализовать эту функциональность. Суть в том, что private поля должны быть обёрнуты в специальный generic тип UndoRedo<>:

class MyData
{
 private readonly UndoRedo name = new UndoRedo<string />("");

 public string string Name
 {
  get { return name.Value; }
  set { name.Value = value; }
 }
 //...

}


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

class MyData
{
 private string name = "";

 public string string Name
 {
  get { return name; }
  set { name = value; }
 }
 //...

}


В этих фрагментах кода есть три ключевых отличия:
  • Я использую UndoRedo<> generic тип для вспомогательного поля. Само поле не хранит значений, а ссылается на контейнер, который хранит значение для нас.
  • Когда нам нужно обратиться к вспомогательному полю, мы используем name.Value вместо name. Вы видите, что name.Value используется без приведения типов. Это потому, что Value всегда имеет тип, который заключен в UndoRedo<...> скобках.
  • Private поле объявлено только для чтения. Это сделано потому, что поле представляет собой контейнер, который отвечает за хранение информации об изменениях. Он должен существовать столько же, сколько и родительская сущность. Нам не нужно изменять сам контейнер хотя, конечно, мы можем изменять Value.

Это решение работает и для ссылочных типов, и для типов по значению.

Реализация

Если вас не интересуют детали реализации, вы можете смело перейти к следующему разделу. В этой статье я обращу ваше внимание только на пару основных деталей реализации. Надеюсь, вы заглянете в исходный код для более детальной информации. Он достаточно краток и прост. Основные классы во фреймворке — это UndoRedoManager и UndoRedo<>. UndoRedoManager — это класс-фасад, который содержит статические методы для манипуляции командами. Ниже представлен неполный список методов:

public static class UndoRedoManager
{
 public static IDisposable Start(string commandCaption) { ... }

 public static void Commit() { ... }
 public static void Cancel() { ... }

 public static void Undo() { ... }
 public static void Redo() { ... }

 public static bool CanUndo { get { ... } }
 public static bool CanRedo { get { ... } }

 public static void FlushHistory() { ... }
}


Кроме класса UndoRedoManager, работу фреймворка обеспечивают следующие объекты:
  • UndoRedoManager
  • History
  • Command
  • Change
  • OldValue,NewValue

Другими словами, UndoRedoManager хранит историю команд. В каждой команде есть свой список модификаций. Каждый объект Change хранит старое и новое значения. Объект Change создаётся классом UndoRedo(), когда пользователь производит модификации. Как вы помните, мы использовали класс UndoRedo(), когда объявляли вспомогательные поля в примерах выше. Этот класс отвечает за создание объекта Change и заполнения его старым и новым значениями. Ниже представлена основная часть этого класса:

public class UndoRedo : IUndoRedoMember
{
 //...

 TValue tValue;

 public TValue Value
 {
  get { return tValue; }
  set
  {
   if (!UndoRedoManager.CurrentCommand.ContainsKey(this))
   {
    Change change = new Change();
    change.OldState = tValue;
    UndoRedoManager.CurrentCommand[this] = change;
   }
   tValue = value;
  }
 }
 //...

}


Приведённый код является ключевой частью всего фреймворка. Он показывает, как изменения перехватываются внутри пользовательского свойства, которое мы объявили в предыдущем разделе. Благодаря generics, мы можем избежать преобразования типов в свойстве Value. Объект Change создаётся внутри данной команды при самой первой попытке установить свойство. Т.о. мы имеем минимально возможную потерю производительности. Когда пользователь вызывает команду, каждый объект Change заполняется новым значением. Фреймворк автоматически вызывает метод OnCommit для каждого изменённого свойства:

public class UndoRedo : IUndoRedoMember
{
 //...

 void IUndoRedoMember.OnCommit(object change)
 {
  ((Change)change).NewState = tValue;
 }
 //...

}


Старое и новое значения полученные выше используются фреймворком для выполнения undo/redo операций. Дальше, в разделе Производительность, вы увидите, что все эти действия создают очень небольшую потерю производительности. В реальном приложении она может быть меньше 1%.

Коллекции

Как я упоминал раньше, изменения в списках и словарях могут быть применены/отменены так же, как и в простых свойствах. Для этих целей библиотека предоставляет классы UndoRedoList<> и UndoRedoDictionary<>, которые имеют такие же интерфейсы, как и стандартные классы List<> и Dictionary<>. Однако, несмотря на это сходство, внутренняя реализация этих классов дополнена возможностью undo/redo. Рассмотрим, как объект данных может объявить список:

class MyData
{
 private readonly UndoRedoList myList= new UndoRedoList();

 public UndoRedoList MyList
 {
  get { return myList; }
 }
}


Здесь есть уловка в том, что список поддерживает транзакции, а ссылка на список — нет. Иначе говоря, мы можем добавлять, удалять и сортировать элементы и все эти изменения могут быть корректно отменены. Но мы не можем изменить ссылку на список на ссылку на другой список, потому что она readonly.

В действительности, из своего опыта я могу сказать, что это не создаёт трудностей, потому что, в большинстве случаев, список, который хранится в поле класса, существует столько же, сколько и родительский объект данных. Если вы привыкли к другому дизайну и хотели бы изменить ссылку на список, это можно сделать немного более хитрым способом. Объедините два generic'а UndoRedo<> и UndoRedoList<>, как показано ниже:

private readonly UndoRedo<UndoRedoList> myList ...

Dictionary можно использовать так же, как и список, поэтому я не буду повторяться.

Защищённость от отказов


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

try
{
 UndoRedoManager.Start("My Command");
 // здесь может находится код вызывающий исключение

 //...

 UndoRedoManager.Commit();
}
catch (Exception)
{
 UndoRedoManager.Cancel();
}


Кроме того, с тем же успехом можно использовать более изящную запись этого кода:

using (UndoRedoManager.Start("My Command"))
{
 // здесь может находится код вызывающий исключение

 //...

 UndoRedoManager.Commit();
}


Как видите, в последнем примере нет отката неудачных изменений. Это возможно благодаря тому, что откат будет произведён автоматически, если выполнения кода не дойдёт до вызова метода Commit, т.е. в случае ошибки. Такое поведение предоставляет высокую степень надёжности, даже если вам не нужна сама undo/redo функциональность. Приложение восстановится после любой ошибки и сохранит рабочее состояние.

UI и синхронизация данных


Сложный UI часто реализуют с помощью шаблона Model-View-Controller. В простом Windows-приложении есть уровни только данных и представления. Однако, в обоих случаях разработчик должен написать определённый код для синхронизации между UI и данными. В демо-проекте находится главная форма с тремя UI контролами:

image

  • EditCityControl
  • CitiesChartControl
  • UndoRedoControl, отображает два списка команд, которые могут применены/отменены для данных о городах.


Эти контролы показывают разные представления одних и тех же данных о городах. Реальное приложение, т.е. дизайнер или редактор, состоит из десятков подобных контролов. Возникает проблема синхронизации данных: если один из контролов изменяет данные, ВСЕ контролы должны обновить свои данные, так как изменение одной сущности может в соответствии с бизнес-логикой изменить другие сущности.

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

Эта задача может быть решена разными способами — плохими и хорошими. Я стремлюсь к тому, чтобы код был несвязным. Форма не должна много знать о компонентах, также компоненты не должны знать о существовании друг друга. Честно говоря, мне лень писать код синхронизации каждый раз, когда я добавляю на форму новый компонент. Взгляните на демо-проект в этой статье и вы увидите очень простую форму:

public partial class DemoForm : Form
{
 public DemoForm()
 {
  InitializeComponent();

  // init data

  CitiesList cities = CitiesList.Load();
  chartControl.SetData(cities);
  editCityControl.SetData(cities);
 }
}


Форма просто загружает в контролы начальные данные. Т.о., форма не выполняет синхронизацию. Обработчики событий контролов тоже не выполняют синхронизацию, например, у EditCityControl есть обработчик события кнопки 'Remove City':

private void removeCity_Click(object sender, EventArgs e)
{
 if (CurrentCity != null)
 {
  UndoRedoManager.Start("Remove " + CurrentCity.Name);
  cities.Remove(CurrentCity);
  UndoRedoManager.Commit();
 }
}


Несмотря на это, все контролы на форме обновляются при изменении данных. Это происходит благодаря специальному событию фреймворка, которое срабатывает, когда проиходит изменение/отмена данных. Это позволяет нам поместить весь код обновления UI в одном месте контрола:

public EditCityControl()
{
 //...

 UndoRedoManager.CommandDone += delegate { ReloadData(); };
 //...

}


Т.о., просто подписавшись на событие CommandDone, контрол решает целый ряд проблем. Контрол всегда отображает обновлённые данные, когда в каком-то другом компоненте происходит их изменение. Кроме того, будет выполнено обновление, когда пользователь выполнит undo или redo операцию.

Производительность

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

  • Чтение данных не приводит к потере производительности. Благодаря generic'ам мы избегаем преобразования типов и boxing/unboxing. Значения возвращаются сразу, в этом случае нет необходимости выполнять поиск в истории изменений.
  • Любая попытка изменить свойство вызывает один поиск во внутренней хеш-таблице. Кроме этого, первая попытка изменить свойство копирует его значение в историю изменений.
  • Любая попытка изменить список вызывает один поиск во внутренней хеш-таблице. Кроме этого, при первой попытке изменить список фреймворк создаёт копию списка и записывает её в историю изменений списка.
  • Любая попытка изменить словарь вызывает один поиск во внутренней хеш-таблице и создаёт два небольших объекта. Фреймворк никогда не создаёт полную копию словаря. Вместо этого, он сохраняет в истории только непосредственно изменения.
  • Методы Undo и Redo восстанавливают только изменённые значения. Они не выпоняют никаких действий с неизменёнными значениями.


Иначе говоря, без значительной потери производительности вы можете:

  • Читать и изменять любые свойства.
  • Читать и изменять списки среднего размера.
  • Читать и умеренно изменять словари большого размера.

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

Ниже я привожу данные теста на производительность для моего реального приложения. В этом примере пользователь изменяет в дизайнере размеры графического объекта. Средняя операция выполняет 5500 чтений, 70 изменений свойств и 4 изменения словарей. Вся дополнительная работа связанная с undo/redo занимает менее 0.7 милисекунды. Ниже представлены результаты теста:
Кол-во вызовов Всего, мс Всего, %
Изменение размеров и перерисовка изображения 159.328 100%
Выполнение Undo/Redo операций 0.677 0.425%
Инициализация команды 1 0.008 0.005%
Чтение свойства 5461 0.114 0.071%
Запись свойства 71 0.026 0.017%
Изменение словаря 4 0.065 0.041%
Завершение команды 1 0.463 0.291%

Память

UndoRedoFramework использует память только для сохранения изменений. Потребление памяти не зависит от общего размера данных, а только от того как много данных было изменено. Т.е. размер вашей истории изменений будет несколько килобайт, даже если общий размер данных составляет несколько мегабайт. Размер истории не ограничен по умолчанию, но его можно ограничить с помощью статического свойства UndoRedoManager.MaxHistorySize. Это свойство определяет количество операций, которые хранятся в истории. Более старые операции будут удалены из истории, когда будет достигнуто указанное количество операций.

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

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

Generics или Прокси


Альтернативный подход реализации undo/redo функциональности — использование прокси. Прокси используют особенность .NET «контекст вызова» для перехвата обращений к свойствам. Такой подход позволяет сохранять информацию об изменениях и впоследствии отменять изменения. Вы можете найти статьи реализующие такой подход на этом сайте. Это хорошие статьи написанные профессионалами в этой области. Сейчас я хотел бы описать различия этих подходов.

Прокси подразумевают, что изменения перехватываются каким-то «внешним» способом. Они перехватывают вызов setter'а свойства до того, как он будет выполнен свойством. Отмена изменнеия — это тоже внешняя операция. Для того, чтобы восстановить предыдущее значение свойства, прокси вызывает setter свойства по своему усмотрению. В этом случае возможны различные побочные эффекты: что если изменение свойства влияет на другие свойства? Что если свойство генерирует событие нотификации? Что если это свойство использует бизнес логику связанную с другим свойством, значение которого ещё не было восстановлено? Т.о., если вы используете «богатые» свойства, undo/redo может привести к непредсказуемым последствиям.

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

Дальнейшая работа


Я занимаюсь разработкой и прототипированием дополнительной функциональности:

  • Реализация «изолированных хранилищ изменений», где изменения данных хранятся независимо. Это может быть использовано в многодокументных приложениях, где пользователь может изменять/отменять изменения в каждом документе отдельно.
  • Поддержка файлов в транзакциях. В некоторых случаях, обработка данных может использовать промежуточные файлы, например, файлы ресурсов или просто временнные файлы. В этом случае, версии этих файлов должны быть совместимы с данными в памяти, изменения в которых могут быть отменены. Фреймворк будет иметь возможность отменять изменения в файлах наряду с изменениями данных в памяти.
  • Поддержка многопоточности для предыдущих двух задач.


Этот проект опубликован на CodePlex, чтобы каждый мог его использовать или внести свой вклад.
Tags:
Hubs:
+34
Comments20

Articles