воскресенье, 12 декабря 2010 г.

Проблема "глубокой" подписки (часть 3)

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

Компактная запись тривиального случая

Рассмотренный в самой первой статье пример решения для одного свойства может быть записан компактнее:
public IDisposable Observe(Page page, Action<Rectangle> invalidateHandler)
{
 var pageSizeHandler = new PropertyChanged((sender, args) =>
 {
  if (args.PropertyName == "Size")
  {
   invalidateHandler(new Rectangle(Point.Empty, (Size)args.OldValue));
   invalidateHandler(new Rectangle(Point.Empty, (Size)args.NewValue));
  }
 });
 page.PropertyChanged += pageSizeHandler;

 return new DisposeDelegate(() => page.PropertyChanged -= pageSizeHandler);
}

где DisposeDelegate - вспомогательный класс, реализующий IDisposable, цель которого состоит в выполнении указанного действия при удалении объекта вызовом Dispose.
Идея и смысл состоят в том, что если где-то мы подписались, то в другом месте надо будет отписаться. Полагаться на сборщик мусора не стоит, потому что нас интересует детерминированное поведение кода.

Обобщение для произвольного свойства и коллекции

Приведенный выше пример можно обобщить для произвольного класса, реализующего контракт INotifyPropertyChanged, а также позаботиться о будущем рефакторинге и устранить использование имени свойства:
public static IDisposable Observe<T, TI>(T obj, Expression<Func<T, TI>> getterExpr, Action<TI> action)
 where T : ObjectRoot
{
 var memberAssignment = (MemberExpression)getterExpr.Body;
 var propertyName = memberAssignment.Member.Name;
 var getter = getterExpr.Compile();

 var lastValue = getter(obj);

 var handler = new PropertyChanged((sender, args) =>
 {
  if (args.PropertyName == propertyName)
  {
   action(lastValue);
   action(lastValue = (TI)args.NewValue);
  }
 });
 obj.PropertyChanged += handler;

 return new DisposeDelegate(() => obj.PropertyChanged -= handler);
}

В случае коллекции нас интересует не само изменение коллекции, а возможность подписаться/отписаться на изменения элементов коллекции. Код очевиден:

public static IDisposable ObserveList<TI>(IObservableList<TI> list, Func<TI, IDisposable> subscribeItem)
{
 var subscribed = list.Select(subscribeItem).ToList();

 var handler = new CollectionChanged((sender, args) =>
 {
  switch (args.Action)
  {
   case ActionType.Clear:
    subscribed.All(d => { d.Dispose(); return true; });
    subscribed.Clear();
    break;
   case ActionType.Insert:
    subscribed.Insert(args.Index, subscribeItem(list[args.Index]));
    break;
   case ActionType.Remove:
    subscribed[args.Index].Dispose();
    subscribed.RemoveAt(args.Index);
    break;
   case ActionType.Set:
    subscribed[args.Index].Dispose();
    subscribed[args.Index] = subscribeItem(list[args.Index]);
    break;
  }
 });

 list.Changed += handler;

 return new DisposeDelegate(
  () => list.Changed -= handler,
  () => subscribed.All(d => { d.Dispose(); return true; })
  );
}

Переменная subscribed хранит список "подписок", код внутри switch заботится о синхронизации подписок и наблюдаемой коллекции. Функция отписывания просто отписывается от всех членов коллекции.

Собираем все вместе

Используя приведенные методы можно решить исходную задачу, а именно представить метод Observe в виде:
public IDisposable Observe(Page page, Action<Rectangle> invalidateHandler)
{
 return new DisposeDelegate(new[] {
  Observe(page, p => p.Size, value => invalidateHandler(new Rectangle(Point.Empty, value))),
  Observe(page, p => p.Items, list =>
   ObserveList(list, block => new DisposeDelegate(new[] {
    Observe(block, i => i.Rect, invalidateHandler),
    Observe(block, i => i.Items, items =>
     ObserveList(items, text => Observe(text, t => t.Rect, invalidateHandler)))
    })
   )
  )});
}

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

Эту неприятность можно обойти так: подписывание/отписывание будем считать изменением и будем вызывать "действие":
public static IDisposable Observe<T, TI>(T obj, Expression<Func<T, TI>> getterExpr, Action<TI> action)
 where T : ObjectRoot
{
...
 var lastValue = getter(obj);
 action(lastValue);

...

 return new DisposeDelegate(
  () => obj.PropertyChanged -= handler,
  () => action(lastValue));
}

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

Заключение

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

Приложение: исходный код

Для сборки проекта понадобятся библиотеки NUnit и Moq.

Исходный код: DeepSubscribe-step3.zip

Решение содержит три последовательные реализации в модулях Step1, Step2 и Step3. Тесты для всех трех решений содержатся в файле ObserverTests (набор тестов один, но применяется ко всем решениям).

суббота, 11 декабря 2010 г.

Проблема "глубокой" подписки (часть 2)

Коллекции всегда осложняют жизнь.

Определим следующее событие (с аргументами):
public delegate void CollectionChanged(object sender, CollectionChangedEventArgs args);

public class CollectionChangedEventArgs:EventArgs
{
 public CollectionChangedEventArgs(ActionType action, int itemIndex)
 {
  Action = action;
  Index = itemIndex;
 }

 public ActionType Action { get; private set; }
 public int Index { get; private set; }
}

public enum ActionType { Clear, Insert, Remove, Set }

Назначение свойств очевидно. Структура намеренно упрощена и не подразумевает одновременного изменения нескольких членов коллекции (не считая очистки всех).

Определим также следующий обобщенный класс коллекций, которые уведомляют о своих изменениях:
public interface IObservableCollection
{
 event CollectionChanged Changed;
}

public interface IObservableList<T>:IList<T>, IObservableCollection
{}

public class ObservableCollection<T> : Collection<T>, IObservableList<T>
{
 public event CollectionChanged Changed;

 public ObservableCollection()
 {}

 public ObservableCollection(IList<T> list):base(list)
 {}

 protected void OnChanged(CollectionChangedEventArgs args)
 {
  var handler = Changed;
  if (handler != null)
   handler(this, args);
 }

 #region change notification

 protected override void ClearItems()
 {
  base.ClearItems();
  OnChanged(new CollectionChangedEventArgs(ActionType.Clear, -1));
 }

 protected override void InsertItem(int index, T item)
 {
  base.InsertItem(index, item);
  OnChanged(new CollectionChangedEventArgs(ActionType.Insert, index));
 }

 protected override void SetItem(int index, T item)
 {
  base.SetItem(index, item);
  OnChanged(new CollectionChangedEventArgs(ActionType.Set, index));
 }

 protected override void RemoveItem(int index)
 {
  base.RemoveItem(index);
  OnChanged(new CollectionChangedEventArgs(ActionType.Remove, index));
 }

 #endregion
}

Соответственно изменим определение объектной модели в классах Page.cs и Block.cs:
// Page.cs
private IObservableList _items = new ObservableCollection();
public IObservableList Items...

// Block.cs
private IObservableList _items = new ObservableCollection();
public IObservableList Items...

Еще одно упрощение состоит в отсутствии проверки на null для коллекций _items. Я стараюсь избегать использования null, если есть возможность использовать пустой массив или шаблон Null object.

Как мы убедимся что решение работает

Мы напишем много тестов следующего вида:
[TestFixture]
public class ObserverTests
{
 [Test]
 public void WatchTextItems()
 {
  var testee = new Page { Size = new Size(10, 10) };
  var observer = new Mock<IRelocateObserver>();

  testee.Items.Add(new Block());
  testee.Items[0].Items.Add(new TextItem { Rect = new Rectangle(1, 2, 3, 4) });

  Observe(testee, observer.Object.Invalidate);

  // act
  testee.Items[0].Items[0].Rect = new Rectangle(3, 4, 5, 6);

  // verify
  observer.Verify(o => o.Invalidate(new Rectangle(1, 2, 3, 4)));
  observer.Verify(o => o.Invalidate(new Rectangle(3, 4, 5, 6)));
 }
}
Здесь Observe - функция которую мы собираемся тестировать. Вторым аргументом функции является Action, в качестве которого мы передадим мок-объект.

В следующей (последней) статье я рассмотрю проблему еще раз и решение.

четверг, 9 декабря 2010 г.

Проблема "глубокой" подписки (часть 1)

Итак, проблема в упрощенной формулировке: есть объектная модель страницы с текстом, есть метод отрисовки страницы на экране. Модель уведомляет об изменении своих свойств через событие PropertyChanged.
Задача состоит в перерисовке только той части части страницы, которую затронули изменения модели. Проблема же состоит в том, что мы не можем изменить код модели и внедрить в нее механизм уведомления, структура модели является иерархической, а коллекции могут быть изменены или заменены целиком.
Например мы подписаны на изменение размера всех элементов первого параграфа, а параграф затем заменяется другим. Нам необходимо отписаться от всех элементов "старого" параграфа, и подписаться на все элементы "нового" параграфа, а также инвалидировать области всех элементов как старого, так и нового параграфа.

Итак модель:
class Page:ObjectRoot
{
 private Size _pageSize;
 private IList<Block> _items;

 public Size Size
 {
  get { return _pageSize; }
  set { UpdateProperty("Size", ref _pageSize, value); }
 }

 public IList<Block> Items
 {
  get { return _items; }
  set { UpdateProperty("Items", ref _items, value); }
 }
}

class Block:ObjectRoot
{
 private Rectangle _rect;
 private IList<TextItem> _items;

 public Rectangle Rect
 {
  get { return _rect; }
  set { UpdateProperty("Rect", ref _rect, value); }
 }

 public IList<TextItem> Items
 {
  get { return _items; }
  set { UpdateProperty("Items", ref _items, value); }
 }
}

class TextItem : ObjectRoot
{
 private Rectangle _rect;
 private string _text;

 public Rectangle Rect
 {
  get { return _rect; }
  set { UpdateProperty("Rect", ref _rect, value); }
 }

 public string Text
 {
  get { return _text; }
  set { UpdateProperty("Text", ref _text, value); }
 }
}


В этой модели ObjectRoot предоставляет обобщенный механизм уведомлений об изменении значений свойств:
class ObjectRoot
{
 public event PropertyChanged PropertyChanged;

 protected void UpdateProperty<T>(string name, ref T holder, T newValue)
 {
  if (Equals(newValue, holder)) return;
  var oldValue = holder;
  holder = newValue;

  RaisePropertyChanged(new PropertyChangedArgs(name, oldValue, newValue));
 }

 protected void RaisePropertyChanged(PropertyChangedArgs args)
 {
  OnPropertyChanged(args);
 }

 protected virtual void OnPropertyChanged(PropertyChangedArgs args)
 {
  var handler = PropertyChanged;
  if(handler != null)
   handler(this, args);
 }
}

public delegate void PropertyChanged(object sender, PropertyChangedArgs args);

public class PropertyChangedArgs : EventArgs
{
 public PropertyChangedArgs(string name, object oldValue, object newValue)
 {
  PropertyName = name;
  OldValue = oldValue;
  NewValue = newValue;
 }

 public string PropertyName { get; private set; }
 public object OldValue { get; private set; }
 public object NewValue { get; private set; }
}


Наиболее очевидное, "наивное" решение проблемы состоит в создании обертки для каждого класса модели следующего вида:

class NaiveObserver
{
 private readonly Page _page;
 private readonly Action<Rectangle> _invalidateHandler;

 public NaiveObserver(Page page, Action<Rectangle> invalidateHandler)
 {
  _page = page;
  _invalidateHandler = invalidateHandler;

  page.PropertyChanged += PageSizeHandler;
 }

 private void PageSizeHandler(object sender, PropertyChangedArgs args)
 {
  if (args.PropertyName == "Size")
  {
   _invalidateHandler(new Rectangle(Point.Empty, (Size) args.OldValue));
   _invalidateHandler(new Rectangle(Point.Empty, (Size) args.NewValue));
  }
 }

 public void Stop()
 {
  _page.PropertyChanged -= PageSizeHandler;
 }
}


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

Далее мы определим протокол уведомления об изменении коллекций и приступим к построению решения.