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

В своих 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-методы.

Посмотрим на пример использования 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, роутинг и взаимодействие с пользователем.

Спасибо за внимание!
Поделиться публикацией

Комментарии 14

    +1
    А можно как-то упростить до
    [SomeoneAttribute]
    public string Something {get;set;}
    

    Чтобы автоматом потом скомпилировался нужный геттер и сеттер с RaiseAndSetIfChanged.
    Очень уж много однообразного кода.
      +1
      https://github.com/demigor/kindofmagic
        0
        Если подобные объекты создавать фабриками, то есть довольно простое решение — динамические прокси-классы. Свойства делаем виртуальными, в прокси-классе переопределяем с нужным поведением.
          +1
          Есть Fody, на его основе сделана реализация
          — для классического INPC: https://github.com/Fody/PropertyChanged
          — для ReactiveUI: https://github.com/kswoll/ReactiveUI.Fody
          Но я не пользовался ни той, ни другой, не могу сказать, насколько это удобно и есть ли какие-то подводные камни.
            0
            Подводных камней нет, оно даже зависимости между свойствами видит и триггерит по цепочке несколько событий, когда свойство меняется
              0
              То, что можно не писать геттеры-сеттеры и оставить просто автосвойство — действительно здорово. Надо будет попробовать… А вот что касается магии цепочек эвентов: полагаю, это работает только в самых простых случаях, когда я использую первое свойство в геттере второго?
              Вы не испытываете проблем с тем, что если надо немного усложнить логику изменения Output Property, приходится отказываться от автосвойсв и начинать руками триггерить эвенты? И не получается ли в итоге непонимания, что в коде происходит автоматически, что делается руками и как оно вообще работает в целом?
                0
                Более того, оно ваши сеттеры переписывает, если не стриггерили событие, то вставляется его вызов после логики сеттера.
                +1
                Технически есть один небольшой подводный камень:

                ReactiveUI.Fody не добавляет свой weaver автоматически в FodyWeavers.xml. <ReactiveUI/> туда надо добавлять ручками. И если банально забыть это сделать, то приложение спокойно скомпилится, но не будет правильно работать, а разработчик будет голову ломать в чем же проблема, когда вроде бы все правильно написано. Сам несколько раз так попадался :)

                Еще раньше была проблема, если в FodyWeavers.xml добавить weaver для ReactiveUI, но при этом ни в одном из файлов проекта не использовать аттрибуты вивера ([Reactive], [ObservableAsProperty]). Такое бывает когда добавляется сразу несколько проектов-заготовок в новый солюшн. Тогда проект не компилился из-за какой-то ошибки. Точно ошибку не помню, но она была, по-моему, не слишком очевидной. Сейчас, кажется, проблема исправлена.
                0
                Я сейчас использую Fody PropertyChanged (первая ссылка выше) на паре проектов, один на Caliburn Micro, второй на Prism. Получается очень удобно, никаких проблем нет. А то, что он автоматически обрабатывает зависимости между свойствами, очень сильно упрощает код. Кстати, работает этот плагин в том числе и с Reactive UI.
                0
                del
                  +1
                  Попробовал ReactiveUI.Fody, описал подробнее в следующей части: https://habrahabr.ru/post/303898/
                  Получилось как раз как в вашем примере.
                  0
                  Документацию может заменить этот сайт www.introtorx.com
                    0
                    Это, безусловно, полезный сайт, но он про Reactive Extensions. ReactiveUI — сторонняя библиотека с кучей своих функций, и вот по ней с документацией все плохо.
                    0
                    Как раз сегодня начал разбираться с ReactiveUI, захожу на хабр и вижу вашу статью. Очень жду продолжения!

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое