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

в 8:43, , рубрики: .net, C#, inotifypropertychanged, reactive extensions, reactive programming, reactiveui

В своих 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:

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

Здесь мы видим, в частности, реализацию 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-методы.

Посмотрим на пример использования Rx

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js