Pull to refresh

Введение в ReactiveUI: коллекции

Reading time 11 min
Views 19K
Привет, Хабр!

Часть 1: Введение в ReactiveUI: прокачиваем свойства во ViewModel

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

Несколько слов о свойствах


Прежде чем перейти к основной теме, скажу еще пару слов по поводу свойств. В прошлый раз мы пришли к следующему синтаксису:
private string _firstName;
public string FirstName
{
    get { return _firstName; }
    set { this.RaiseAndSetIfChanged(ref _firstName, value); }
}

Тратить 6 строк кода на каждое свойство — довольно расточительно, особенно если таких свойств много и реализация всегда одинаковая. В языкe C# для решения этой проблемы в свое время добавили автосвойства, и жить стато легче. Что мы можем сделать в нашем случае?
В комментариях был упомянут Fody — средство, которое может изменять IL-код после сборки проекта. Например, в реализацию автосвойства добавить уведомление об изменении. И для ReactiveUI даже есть соответствующее расширение: ReactiveUI.Fody. Попробуем использовать его. Кстати, для классической реализации тоже есть расширение: PropertyChanged, но нам оно не подходит, поскольку нужно вызывать именно RaiseAndSetIfChanged().

Установим из NuGet: > Install-Package ReactiveUI.Fody
В проекте появится FodyWeavers.xml. Добавим в него установленное расширение:
<?xml version="1.0" encoding="utf-8" ?>
<Weavers>
  <ReactiveUI />
</Weavers>

И изменим наши свойства:
[Reactive] public string FirstName { get; set; }

С помощью данного инструмента можно также реализовать свойство FullName на базе ObservableAsPropertyHelper<>. Способ описан в документации на GitHub, здесь же опустим его. Я думаю, что две строки — вполне приемлимый вариант, и не очень хочу использовать вместо ToProperty() сторонний метод, позволяющий ReactiveUI.Fody реализовать это свойство правильно.

Проверим, что ничего не сломалось. Как? Я проверяю юнит-тестами. ReactiveUI к ним дружелюбен, не зря он A MVVM framework <...> to create elegant, testable User Interfaces.... Чтобы проверить срабатывание эвента, необязательно подписываться на него руками, куда-то сохранять данные при срабатывании и потом обрабатывать их.
Нам поможет Observable.FromEventPattern(), который превратит срабатывания эвента в IObservable<> со всей необходимой информацией. А чтобы превратить IObservable<> в список событий и проверить его на правильность, удобно использовать метод расширения .CreateCollection(). Он создает коллекцию, в которую то тех пор, пока источник не вызовет OnComplete() или мы не вызовем Dispose(), будут добавляться пришедшие через IObservable<> элементы, в нашем случае — информация о сработавших эвентах.
Заметьте, что коллекция нам возвращается сразу, а элементы в нее добавляются уже потом, асинхронно. Это поведение отличается от, например, .ToList(), который не вернет управление и, следовательно, саму коллекцию до OnComplete(), что чревато вечным ожиданием в случае обычной подписки на эвент.
[Test]
public void FirstName_WhenChanged_RaisesPropertyChangedEventForFirstNameAndFullNameProperties()
{
    var vm = new PersonViewModel("FirstName", "LastName");

    var evendObservable = Observable.FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(
        a => vm.PropertyChanged += a,
        a => vm.PropertyChanged -= a);

    var raisedEvents = evendObservable.CreateCollection();
    using (raisedEvents)
    {
        vm.FirstName = "NewFirstName";
    }

    Assert.That(vm.FullName, Is.EqualTo("NewFirstName LastName"));

    Assert.That(raisedEvents, Has.Count.EqualTo(2));
    Assert.That(raisedEvents[0].Sender, Is.SameAs(vm));
    Assert.That(raisedEvents[0].EventArgs.PropertyName, Is.EqualTo(nameof(PersonViewModel.FirstName)));
    Assert.That(raisedEvents[1].Sender, Is.SameAs(vm));
    Assert.That(raisedEvents[1].EventArgs.PropertyName, Is.EqualTo(nameof(PersonViewModel.FullName)));
}  

Сам тестируемый сценарий (задание нового значения свойству) выполняется внутри using, а проверки — после. Это нужно для того, чтобы при проверке случайно не сработал какой-то эвент и не испортил нам коллекцию. Конечно, делать это зачастую необязательно, но иногда может быть важно.

А теперь давайте проверим, что IObservable<> Changed вернет то же самое.
[Test]
public void FirstName_WhenChanged_PushesToPropertyChangedObservableForFirstNameAndFullNameProperties()
{
    var vm = new PersonViewModel("FirstName", "LastName");

    var notifications = vm.Changed.CreateCollection();
    using (notifications)
    {
        vm.FirstName = "NewFirstName";
    }

    Assert.That(vm.FullName, Is.EqualTo("NewFirstName LastName"));

    Assert.That(notifications, Has.Count.EqualTo(2));
    Assert.That(notifications[0].Sender, Is.SameAs(vm));
    Assert.That(notifications[0].PropertyName, Is.EqualTo(nameof(PersonViewModel.FirstName)));
    Assert.That(notifications[1].Sender, Is.SameAs(vm));
    Assert.That(notifications[1].PropertyName, Is.EqualTo(nameof(PersonViewModel.FullName)));
}

И… Тест упал. Но мы же только поменяли источник информации об измении свойства! Попробуем понять, почему тест не прошел:
vm.Changed.Subscribe(n => Console.WriteLine(n.PropertyName));
vm.FirstName = "OMG";

И получаем:
FullName
FirstName

Вот так. Не похоже, что это ошибка фреймворка, скорее, деталь реализации. Это можно понять: оба свойсва уже изменились и порядок уведомления неважен. С другой стороны — он несогласован с порядком эвентов и не соответствует ожиданиям, что может быть чревато. Конечно, строить логику приложения, опираясь на порядок уведомлений — заведомо плохая идея. Но, например, при чтении лога приложения мы увидим, что зависимое свойство уведомило об измении ДО изменения его зависимости, что может сбить с толку. Так что обязательно запомним такую особенность.
Итак, мы убедились, что ReactiveUI.Fody работает исправно и существенно уменьшает количество кода. Дальше будем использовать его.



А теперь перейдем к коллекциям


Интерфейс INotifyPropertyChanged, как мы знаем, используется при изменении свойств вьюмодели, например, для уведомления визуального элемента о том, что что-то изменилось и надо перерисовать интерфейс. Но что делать, когда во вьюмодели есть коллекция из множества элементов (например, лента новостей), и нам надо добавить свежие записи к уже показанным? Уведомлять о том, что свойство, в котором лежит коллекция, изменилось? Можно, но это приведет к перестроению всего списка в интерфейсе, а это может быть небыстрой операцией, особенно если речь идет о мобильных устройствах. Нет, так дело не пойдет. Нужно, чтобы коллекция сама сообщала, что в ней что-то поменялось. К счастью, есть замечательный интерфейс:
public interface INotifyCollectionChanged
{
  /// <summary>Occurs when the collection changes.</summary>
  event NotifyCollectionChangedEventHandler CollectionChanged;
}

Если коллекция его реализует, то при добавлении/удалении/замене и т.п. событиях срабатывает CollectionChanged. И теперь не надо перестраивать список новостей заново и вообще заглядывать в коллекцию записей, достаточно просто дополнить его новыми элементами, которые пришли через эвент. В .NET есть реализующие его коллекции, но мы говорим про ReactiveUI. Что есть в нем?
Целый набор интерфейсов: IReactiveList<T>, IReadOnlyReactiveList<T>, IReadOnlyReactiveCollection<T>, IReactiveCollection<T>, IReactiveNotifyCollectionChanged<T>, IReactiveNotifyCollectionItemChanged<T>. Не буду приводить здесь описание каждого, думаю, по названиям должно быть ясно, что они из себя представляют.
А вот на реализацию посмотрим подробнее. Знакомьтесь: ReactiveList<T>. Он реализует их всех и много чего другого. Так как нас интересует отслеживание изменений в коллекции, посмотрим на соответствующие свойства этого класса.
Свойства для отслеживания изменений в IReactiveList&lt;T&gt;
Довольно много! Отслеживается добавление, удаление, перемещение элементов, количество элементов, пустота коллекции и необходимость сделать сброс. Рассмотрим это все подробнее. Конечно, реализованы также эвенты из INotifyCollectionChanged, INotifyPropertyChanged и парных им *Changind, но про них говорить не будем, они работают бок-о-бок с «наблюдаемыми» свойствами, показанными на картинке, и чего-то уникального там нет.
Для начала простой пример. Подпишемся на некоторые источники уведомлений и немного поработаем с коллекцией:
var list = new ReactiveList<string>();

list.BeforeItemsAdded.Subscribe(e => Console.WriteLine($"Before added: {e}"));
list.ItemsAdded.Subscribe(e => Console.WriteLine($"Added: {e}"));
list.BeforeItemsRemoved.Subscribe(e => Console.WriteLine($"Before removed: {e}"));
list.ItemsRemoved.Subscribe(e => Console.WriteLine($"Removed: {e}"));
list.CountChanging.Subscribe(e => Console.WriteLine($"Count changing: {e}"));
list.CountChanged.Subscribe(e => Console.WriteLine($"Count changed: {e}"));
list.IsEmptyChanged.Subscribe(e => Console.WriteLine($"IsEmpty changed: {e}"));

Console.WriteLine("# Add 'first'");
list.Add("first");

Console.WriteLine("\n# Add 'second'");
list.Add("second");

Console.WriteLine("\n# Remove 'first'");
list.Remove("first");


Получаем результат:
#Add 'first'
Count changing: 0
Before added: first
Count changed: 1
IsEmpty changed: False
Added: first

#Add 'second'
Count changing: 1
Before added: second
Count changed: 2
Added: second

#Remove 'first'
Count changing: 2
Before removed: first
Count changed: 1
Removed: first

Нас уведомляют о том, что добавлено или удалено, а также об изменении количества элементов и признака пустоты коллекции.
Притом:
— ItemsAdded/ItemsRemoved/BeforeItemsAdded/BeforeItemsRemoved возвращают сам добавленый/удаленный элемент
— CountChanging/CountChanged возвращают количество элементов до и после изменения
— IsEmptyChanged возвращает новое значение признака пустоты коллекции

Есть одна тонкость


Пока все предсказуемо. Теперь представим, что мы хотим только на основе уведомлений о добавлении и удалении считать количество записей в коллекции. Что может быть проще?
var count = 0;
var list = new ReactiveList<int>();
list.ItemsAdded.Subscribe(e => count++);
list.ItemsRemoved.Subscribe(e => count--);

for (int i = 0; i < 100; i++)
{
    list.Add(i);
}            
for (int i = 0; i < 100; i+=2)
{
    list.Remove(i);
}            

Assert.That(count, Is.EqualTo(list.Count));

Тест прошел успешно. Изменим принцип заполнения коллекции, добавим сразу много элементов:
list.AddRange(Enumerable.Range(0, 10));
list.RemoveAll(Enumerable.Range(0, 5).Select(i => i * 2));

Успешно. Кажется, подвохов нет. Хотя стойте…
list.AddRange(Enumerable.Range(0, 100));
list.RemoveAll(Enumerable.Range(0, 50).Select(i => i * 2));

Ой! Тест не прошел и count == 0. Кажется, мы что-то не учли. Давайте разбираться.

Все дело в том, что ReactiveList<T> реализован не так примитивно, как может показаться. Когда коллекция меняется существенно, он отключает уведомления, делает все изменения, включает уведомления обратно и посылает сигнал сброса:
list.ShouldReset.Subscribe(_ => Console.WriteLine("ShouldReset"));

Зачем так сделано? Иногда коллекция меняется существенно: например, в пустую коллекцию добавляется 100 элементов, из большой коллекции удаляется половина элементов или происходит ее полная очистка. В таком случае реагировать на каждое мелкое изменение нет смысла — это будет затратнее, чем дождаться конца серии изменений и отреагировать так, будто коллекция полностью новая.
В последнем примере так и происходит. ShouldReset имеет тип IObservable<Unit>. Unit — это по сути void, только в форме объекта. Он используется в ситуациях, когда нужно уведомить подписчика о неком событии, и важно только то, что оно произошло, передавать какие-то дополнительные данные не требуется. Как раз наш случай. Если бы мы на него подписались, то увидели бы, что после операций вставки и удаления нам пришел сигнал сброса. Соответственно, чтобы обновлять счетчик правильно, надо чуть-чуть изменить наш пример:
list.ItemsAdded.Subscribe(e => count++);
list.ItemsRemoved.Subscribe(e => count--);
list.ShouldReset.Subscribe(_ => count = list.Count);

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

Правила подавления уведомлений об изменениях

Мы увидели, что при сильном изменении коллекции возникает сигнал сброса. Как можно контролировать этот процесс?
В конструкторе ReactiveList<T> есть один необязательный аргумент: double resetChangeThreshold = 0.3. И уже после создания списка его можно изменить через свойство ResetChangeThreshold. Как он используется? Уведомления об изменениях будут подавлены, если результат деления количества добавляемых/удаляемых элементов на количество элементов в самой коллекции больше этого значения, и если при этом количество добавляемых/удаляемых элементов строго больше 10. Это видно по исходному коду и никто не гарантирует, что эти правила не поменяются в будущем.
В нашем примере 100/0 > 0.3 и 50/100 > 0.3, поэтому уведомления были подавлены оба раза. Естественно, можно варьировать ResetChangeThreshold и подставивать коллекцию под конкретное место использования.

Как нам самим подавить уведомления?

В первом примере со счетчиком мы видели такой код:
for (int i = 0; i < 100; i++)
{
    list.Add(i);
}

Здесь элементы добавляются по-одному, поэтому уведомления об изменении всегда отправляются. Но мы добавляем много элементов и хотим на время подавить уведомления. Как? Использовав SuppressChangeNotifications(). Все, что находится внутри using, не будет вызывать уведомлений об изменении:
using (list.SuppressChangeNotifications())
{
    for (int i = 0; i < 100; i++)
    {
        list.Add(i);
    }
}


А что насчет изменений самих элементов коллекции?


Мы видели, что в ReactiveList<T> есть источники уведомлений ItemChanged и ItemChanging — изменения самих элементов. Попробуем их использовать:
var list = new ReactiveList<PersonViewModel>();
list.ItemChanged.Subscribe(e => Console.WriteLine(e.PropertyName));

var vm = new PersonViewModel("Name", "Surname");
list.Add(vm);
vm.FirstName = "NewName";

Ничего не произошло. Нас обманули, и ReactiveList на самом деле не следит за изменением элементов? Да, но только по-умолчанию. Чтобы он отслеживал изменения внутри своих элементов, надо просто включить эту фичу:
var list = new ReactiveList<PersonViewModel>() { ChangeTrackingEnabled = true };

Теперь все работает:
FullName
FirstName

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



Наследуемые коллекции


Как часто возникают ситуации, когда нужно из существующей коллекции выбрать только часть элементов, или отсортировать их, или преобразовать? И при изменении исходной коллекции поменять зависимую. Такие ситуации нередки, и в ReactiveUI есть средство, которое позволяет это легко сделать. Имя ему — DerivedCollection. Они наследуются от ReactiveList и, следовательно, возможности имеют те же самые, за исключением того, что при попытках изменить такую коллекцию будет выбрасываться исключение. Коллекция может поменяться только тогда, когда меняется ее базовая коллекция.
Не будем снова рассматривать уведомления об изменениях, там все как и было. Посмотрим, какие преобразования можно применить к базовой коллекции.
var list = new ReactiveList<int>();
list.AddRange(Enumerable.Range(1, 5));

var derived = list.CreateDerivedCollection(
    selector: i => i*2, 
    filter: i => i % 2 != 0, 
    orderer:(a, b) => b.CompareTo(a));

Console.WriteLine(string.Join(", ", list));
Console.WriteLine(string.Join(", ", derived));

list.AddRange(Enumerable.Range(2, 3));

Console.WriteLine(string.Join(", ", list));
Console.WriteLine(string.Join(", ", derived));

Видим, что можно преобразовать значение, отфильтровать исходные элементы (до преобразования!) и передать компаратор для преобразованных элементов. Притом обязателен только селектор, остальное — по желанию.
Также есть перегрузки метода, которые позволяют использовать в качестве базовой коллекции не только INotifyCollectionChanged, но и даже IEnumerable<>. Но тогда надо предоставить наследуемой коллекции способ получить сигнал сброса.
Здесь наследуемая коллекция берет из исходной нечетные элементы, удваивает их значение и сортирует от большего к меньшему. В консоли будет:
1, 2, 3, 4, 5
10, 6, 2

1, 2, 3, 4, 5, 2, 3, 4
10, 6, 6, 2


Stay tuned


В этот раз мы обсудили некоторые подробности работы со свойствами, не описанные в прошлой части. Добились того, чтобы реализация свойства занимала одну строку, и выяснили, что нельзя верить порядку уведомлений об их изменении. Основной же темой были коллекции. Мы разобрались, какие уведомления можно получить от ReactiveList при его изменении. Выяснили, зачем и при каких условиях уведомления подавляются автоматически, а также как подавить их собственными руками. Наконец, мы попробовали использовать наследуемые коллекции и убедились, что они умеют фильтровать, преобразовывать и сортировать данные базовой коллекции, реагируя на ее изменения.
В следующей части поговорим про команды и рассмотрим вопрос тестирования вьюмодели. Выясним, какие проблемы с этим связаны и как они решаются. А потом перейдем к связке View + ViewModel и попробуем реализовать небольшое приложение с GUI, использующее уже описанные средства.

До встречи!
Tags:
Hubs:
+10
Comments 2
Comments Comments 2

Articles