Введення в ReactiveUI: колекції

Привіт, Хабр!

Частина 1: Введення в ReactiveUI: прокачуємо властивості під ViewModel

У попередній статті ми поговорили про властивості під ViewModel, і що ми можемо з ними зробити, використовуючи ReactiveUI. У нас вийшло привести в порядок залежності між властивостями і зібрати в одному місці вьюмодели інформацію про те, які в ній існують залежності між властивостями.
В цей раз ще трохи поговоримо про властивості, а потім перейдемо до колекцій. Спробуємо зрозуміти, які проблеми є з звичайними колекціями, і навіщо було створювати нові, з повідомленнями про зміни. І, звичайно, спробуємо їх використовувати.

Кілька слів про властивості

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

Витрачати 6 рядків коду на кожне властивість — досить марнотратно, особливо якщо таких властивостей багато і реалізація завжди однакова. У мові 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.... Щоб перевірити спрацьовування эвента, необов'язково підписуватися на нього руками, кудись зберігати дані при спрацьовуванні і потім обробляти їх.
Нам допоможе Вами.FromEventPattern(), який перетворить спрацьовування эвента в IObservable<> з усією необхідною інформацією. А щоб перетворити IObservable<> у список подій і перевірити його на правильність, зручно використовувати метод розширення .CreateCollection(). Він створює колекцію, у яку то тих пір, поки джерело не викличе OnComplete() або ми не викличемо Dispose(), будуть додаватися прийшли через IObservable<> елементи, в нашому випадку — інформація про спрацьованих эвентах.
Зауважте, що колекція нам повертається відразу, а елементи в неї додаються вже потім, асинхронно. Це поведінка відрізняється від, наприклад, .ToList(), який не поверне керування і, отже, саму колекцію до OnComplete(), що загрожує вічним чеканням у випадку звичайної передплати на евент.
[Test]
public void FirstName_WhenChanged_RaisesPropertychangedeventforfirstnameandfullnameproperties()
{
var vm = new PersonViewModel("FirstName", "LastName");

var evendObservable = Вами.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&amp;lt;T&amp;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($"removed Before: {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
Removed Before: 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(Перечіслімого.Range(0, 10));
list.RemoveAll(Перечіслімого.Range(0, 5).Select(i => i * 2));

Успішно. Здається, проблем немає. Хоча стійте…
list.AddRange(Перечіслімого.Range(0, 100));
list.RemoveAll(Перечіслімого.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(Перечіслімого.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(Перечіслімого.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 і спробуємо реалізувати невеликий додаток з графічним інтерфейсом, що використовує вже описані засоби.

До зустрічі!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.