В своих C# проектах при реализации GUI я часто использую фреймворк ReactiveUI.
ReactiveUI — полноценный MVVM-фреймворк: bindings, routing, message bus, commands и прочие слова, которые есть в описании почти любого MVVM-фреймворка, есть и тут. Применяться он может практически везде, где есть .NET: WPF, Windows Forms, UWP, Windows Phone 8, Windows Store, Xamarin.
Конечно, если у вас уже есть опыт работы с ним, то что-то новое для себя вы здесь вряд ли найдете. В этой статье мы познакомимся с его базовыми возможностями, касающимися работы со свойствами во ViewModel, а в будущем, надеюсь, доберемся и до других, более интересных и сложных фич.
Введение
ReactiveUI построен вокруг реактивной модели программирования и использует Reactive Extensions (Rx). Однако цели написать гайд по реактивному программированию я перед собой не ставлю, лишь при необходимости буду пояснять, как что устроено. Совсем скоро вы сами увидите, что для использования базовых возможностей даже не требуется особенно вникать в то, что это за зверь такой: реактивное программирование. Хотя вы с ним и так знакомы, events – это как раз оно. Обычно даже в тех местах, где проявляется «реактивность», код можно довольно легко прочитать и понять, что произойдет. Конечно, если использовать библиотеку (и Reactive Extensions) на полную катушку, то придется серьезно ознакомиться с реактивной моделью, но пока мы пойдем по основам.
Лично мне, помимо непосредственно возможностей ReactiveUI, нравится его ненавязчивость: можно использовать только какое-то подмножество его фич, не обращая внимания на другие и не подстраивая свое приложение под фреймворк. Даже, например, применять его бок-о-бок с другими фреймворками, не натыкаясь на несовместимости. Довольно удобно.
Есть и ложка дегтя. Имя ей – документация. С ней все очень плохо. Что-то есть тут, но многие страницы – просто заглушки, и все очень сухо. Есть документация здесь, но проблема та же: заглушки, какие-то копипасты из чата разработчиков, ссылки на примеры приложений в разных источниках, описания фич будущей версии и т.п. Разработчики довольно активно отвечают на вопросы на StackOverflow, но многих вопросов не было бы, будь нормальная документация. Однако, чего нет, того нет.
О чем пойдет речь
Перейдем к конкретике. В этой статье поговорим о типичной проблеме со свойствами в ViewModels, и как она решается в ReactiveUI. Конечно же, эта проблема – интерфейс INotifyPropertyChanged; проблема, которую так или иначе решают разными способами.
Посмотрим классическую реализацию:
private string _firstName;
public string FirstName
{
get { return _firstName; }
set
{
if (value == _firstName) return;
_firstName = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
Какие проблемы? Да вроде никаких. Ну три строки в сеттере, не беда. Я вообще обычно пишу автосвойство и делаю авторефакторинг решарпером в приведенную форму, минимум телодвижений.
Но проблемы все-таки есть. Что, если надо при изменении FirstName синхронизировать свойство FullName? Варианта два: либо это вычисляемое свойство и надо просто сгенерировать эвент об его изменении, либо оно должно быть реализовано аналогично FirstName, и его надо поменять. В первом варианте сеттер свойства FirstName сгенерирует нужное уведомление:
set
{
if (value == _firstName) return;
_firstName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(FullName));
}
Во втором вызовется обновление свойства, и оно само сгенерирует уведомление:
set
{
if (value == _firstName) return;
_firstName = value;
OnPropertyChanged();
UpdateFullName();
}
private void UpdateFullName()
{
FullName = $"{FirstName} {LastName}";
}
Пока еще выглядит относительно просто, но это дорога в ад. Есть еще LastName, который тоже должен менять FullName. Потом прикрутим поиск по введенному имени, и все станет еще сложнее. А потом еще, и еще… И мы оказываемся в ситуации, где в коде сплошные генерации эвентов, из сеттеров запускается множество действий, возникают какие-то ошибки из-за того, что учтены не все возможные пути исполнения или что-то вызывается не в том порядке, и прочие кошмары.
И вообще, почему свойство FirstName знает о том, что где-то есть FullName, и о том, что надо запускать поиск по имени? Это не его забота. Оно должно поменяться и сообщить об этом. Да, можно так и сделать, а для вызова дополнительных действий прицепиться к собственному эвенту PropertyChanged, но радости в этом мало – руками разбирать эти эвенты с приходящим в строке именем изменившегося свойства.
Да и приведенная в самом начале простая реализация все равно начинает раздражать: почти одинаковый код, который все равно приходится читать, в который может закрасться ошибка…
Что нам предлагает ReactiveUI?
Декларативность и приведение зависимостей в порядок.
Установим его из Nuget. Ищем по «reactiveui», я ставлю актуальную на данный момент версию 6.5.0. А теперь проследуем в список доступных обновлений и обновим появившийся там Splat до последней версии (сейчас 1.6.2). Без этого у меня в какой-то момент все валилось.
Теперь, когда мы установили фреймворк, попробуем немного улучшить наш первый пример. Для начала наследуемся от ReactiveObject и переписываем сеттеры свойств:
public class PersonViewModel : ReactiveObject
{
private string _firstName;
public string FirstName
{
get { return _firstName; }
set
{
this.RaiseAndSetIfChanged(ref _firstName, value);
UpdateFullName();
}
}
private string _lastName;
public string LastName
{
get { return _lastName; }
set
{
this.RaiseAndSetIfChanged(ref _lastName, value);
UpdateFullName();
}
}
private string _fullName;
public string FullName
{
get { return _fullName; }
private set
{
this.RaiseAndSetIfChanged(ref _fullName, value);
}
}
private void UpdateFullName()
{
FullName = $"{FirstName} {LastName}";
}
}
Пока не густо. Такой RaiseAndSetIfChanged можно было написать руками. Но стоит сразу сказать, что ReactiveObject реализует не только INPC:
Здесь мы видим, в частности, реализацию INotifyPropertyChanged, INotifyPropertyChanging и какие-то три IObservable<>.
Подробнее про реактивную модель
Здесь стоит сказать пару слов о том, что это за IObservable. Это реактивные (push-based) провайдеры уведомлений. Принцип довольно прост: в классической модели (pull-based) мы сами бегаем к провайдерам данных и опрашиваем их на наличие обновлений. В реактивной – мы подписываемся на такой вот канал уведомлений и не беспокоимся об опросе, все обновления придут к нам сами:
public interface IObservable<out T>
{
IDisposable Subscribe(IObserver<T> observer);
}
Мы выступаем в качестве IObserver<> — наблюдателя:
public interface IObserver<in T>
{
void OnNext(T value);
void OnError(Exception error);
void OnCompleted();
}
OnNext вызовется при появлении очередного уведомления. OnError – если возникнет ошибка. OnCompleted – когда уведомления закончились.
В любой момент можно отписаться от новых уведомлений: для этого метод Subscribe возвращает некий IDisposable. Вызываете Dispose – и новых уведомлений не поступит.
Теперь, если мы подпишемся на Changed и изменим FirstName, будет вызван метод OnNext, и в параметрах будет та же самая информация, что и в event PropertyChanged (т.е. ссылка на отправителя и имя свойства).
И также здесь у нас в распоряжении есть множество методов, часть из которых пришла из LINQ. Select мы уже попробовали. Что можно сделать еще? Отфильтровать поток уведомлений с помощью Where, сделать Distinct повторяющихся уведомлений или DistinctUntilChanged, чтобы избежать идущих подряд одинаковых уведомлений, использовать Take, Skip и прочие LINQ-методы.
var observable = Enumerable.Range(1, 4).ToObservable();
observable.Subscribe(Observer.Create<int>(
i => Console.WriteLine(i),
e => Console.WriteLine(e),
() => Console.WriteLine("Taking numbers: complete")
));
//1
//2
//3
//4
//Taking numbers: complete
observable.Select(i => i*i).Subscribe(Observer.Create<int>(
i => Console.WriteLine(i),
e => Console.WriteLine(e),
() => Console.WriteLine("Taking squares: complete")
));
//1
//4
//9
//16
//Taking squares: complete
observable.Take(2).Subscribe(Observer.Create<int>(
i => Console.WriteLine(i),
e => Console.WriteLine(e),
() => Console.WriteLine("Taking two items: complete")
));
//1
//2
//Taking two items: complete
observable.Where(i => i % 2 != 0).Subscribe(Observer.Create<int>(
i => Console.WriteLine(i),
e => Console.WriteLine(e),
() => Console.WriteLine("Taking odd numbers: complete")
));
//1
//3
//Taking odd numbers: complete
Вот здесь можно подвигать все эти уведомления во времени и посмотреть, что как работает.
Получилось довольно кратко, но думаю, пока этого достаточно. Подробнее можно почитать, например, здесь или здесь.
Свяжем свойства с использованием ReactiveUI
Вернемся к улучшению нашего проблемного кода. Приведем в порядок зависимости:
public class PersonViewModel : ReactiveObject
{
private string _firstName;
public string FirstName
{
get { return _firstName; }
set { this.RaiseAndSetIfChanged(ref _firstName, value); }
}
private string _lastName;
public string LastName
{
get { return _lastName; }
set { this.RaiseAndSetIfChanged(ref _lastName, value); }
}
private string _fullName;
public string FullName
{
get { return _fullName; }
private set { this.RaiseAndSetIfChanged(ref _fullName, value); }
}
public PersonViewModel(string firstName, string lastName)
{
_firstName = firstName;
_lastName = lastName;
this.WhenAnyValue(vm => vm.FirstName, vm => vm.LastName).Subscribe(_ => UpdateFullName());
}
private void UpdateFullName()
{
FullName = $"{FirstName} {LastName}";
}
}
Смотрите, свойства уже не содержат ничего лишнего, все зависимости описаны в одном месте: в конструкторе. Здесь мы говорим подписаться на изменения FirstName и LastName, и когда что-то изменится — вызвать UpdateFullName(). Кстати, можно и чуть иначе:
public PersonViewModel(...)
{
...
this.WhenAnyValue(vm => vm.FirstName, vm => vm.LastName).Subscribe(t => UpdateFullName(t));
}
private void UpdateFullName(Tuple<string, string> tuple)
{
FullName = $"{tuple.Item1} {tuple.Item2}";
}
Параметром уведомления является кортеж, в котором лежат текущие значения свойств. Их мы можем передать в наш метод обновления полного имени. Хотя метод обновления вообще можно убрать и сделать так:
this.WhenAnyValue(vm => vm.FirstName, vm => vm.LastName).Subscribe(t => { FullName = $"{t.Item1} {t.Item2}"; });
Теперь еще раз посмотрим на FullName:
private string _fullName;
public string FullName
{
get { return _fullName; }
private set { this.RaiseAndSetIfChanged(ref _fullName, value); }
}
Зачем нам якобы изменяемое свойство, когда фактически оно должно полностью зависеть от частей имени и быть доступных только для чтения? Исправим это:
private readonly ObservableAsPropertyHelper<string> _fullName;
public string FullName => _fullName.Value;
public PersonViewModel(...)
{
...
_fullName = this.WhenAnyValue(vm => vm.FirstName, vm => vm.LastName)
.Select(t => $"{t.Item1} {t.Item2}")
.ToProperty(this, vm => vm.FullName);
}
ObservableAsPropertyHelper<> помогает реализовать output properties. Внутри находится IObservable, свойство становится доступным только для чтения, но при изменениях генерируются уведомления.
Кстати, помимо того, что пришло из LINQ, есть и другие интересные методы для IObservable<>, например, Throttle:
_fullName = this.WhenAnyValue(vm => vm.FirstName, vm => vm.LastName)
.Select(t => $"{t.Item1} {t.Item2}")
.Throttle(TimeSpan.FromSeconds(1))
.ToProperty(this, vm => vm.FullName);
Здесь отбрасываются уведомления, в течение секунды после которых последовало следующее. То есть пока пользователь печатает что-то в поле ввода имени, FullName не будет меняться. Когда он хотя бы на секунду остановится – полное имя обновится.
Результат
using System.Reactive.Linq;
namespace ReactiveUI.Guide.ViewModel
{
public class PersonViewModel : ReactiveObject
{
private string _firstName;
public string FirstName
{
get { return _firstName; }
set { this.RaiseAndSetIfChanged(ref _firstName, value); }
}
private string _lastName;
public string LastName
{
get { return _lastName; }
set { this.RaiseAndSetIfChanged(ref _lastName, value); }
}
private readonly ObservableAsPropertyHelper<string> _fullName;
public string FullName => _fullName.Value;
public PersonViewModel(string firstName, string lastName)
{
_firstName = firstName;
_lastName = lastName;
_fullName = this.WhenAnyValue(vm => vm.FirstName, vm => vm.LastName)
.Select(t => $"{t.Item1} {t.Item2}")
.ToProperty(this, vm => vm.FullName);
}
}
}
Мы получили ViewModel, в которой связи между свойствами описываются декларативно, в одном месте. Мне это кажется крутой возможностью: не нужно перелопачивать весь код, пытаясь понять, что произойдет при изменении тех или иных свойств. Никаких сайд-эффектов – все довольно явно. Конечно, результат всех этих манипуляций — некоторое ухудшение производительности. Хотя всерьез эта проблема не должна встать: это не ядро системы, которое должно быть максимально производительным, а слой ViewModel.
Надеюсь, кому-то эта статья окажется полезной и интересной, и вы попробуете использовать описанные технологии в своих проектах. В дальнейшем я надеюсь описать такие вещи, как реактивные коллекции и команды, а потом дойти до более сложных примеров, в которых показано взаимодействие между слоями View и ViewModel, роутинг и взаимодействие с пользователем.
Спасибо за внимание!
Автор: INC_R