В настоящей статье рассмотрим архитектуру кроссплатформенных .NET приложений с использованием шаблона проектирования MVVM и реактивного программирования. Познакомимся с библиотеками ReactiveUI и Fody, научимся реализовывать интерфейс INotifyPropertyChanged с помощью атрибутов, затронем основы AvaloniaUI, Xamarin Forms, Universal Windows Platform, Windows Presentation Foundation и .NET Standard, изучим эффективные инструменты для модульного тестирования слоёв модели и модели представления приложения.
Материал является адаптацией статей "Reactive MVVM For The .NET Platform" и "Cross-Platform .NET Apps Via Reactive MVVM Approach", опубликованных автором ранее на ресурсе Medium. Примеры кода доступны на GitHub.
Введение. Архитектура MVVM и кроссплатформенный .NET
При разработке кроссплатформенных приложений на платформе .NET необходимо писать переносимый и поддерживаемый код. В случае работы с фреймворками, использующими диалекты XAML, такими, как UWP, WPF, Xamarin Forms и AvaloniaUI, этого можно достичь с помощью шаблона проектирования MVVM, реактивного программирования и стратегии разделения кода .NET Standard. Данный подход улучшает переносимость приложений, позволяя разработчикам использовать общую кодовую базу и общие программные библиотеки на различных операционных системах.
Подробнее рассмотрим каждый из слоёв приложения, построенного на основе архитектуры MVVM – модель (Model), представление (View) и модель представления (ViewModel). Слой модели представляет собой доменные сервисы, объекты передачи данных, сущности баз данных, репозитории – всю бизнес-логику нашей программы. Представление отвечает за отображение элементов пользовательского интерфейса на экран и зависит от конкретной операционной системы, а модель представления позволяет двум описанным выше слоям взаимодействовать, адаптируя слой модели для взаимодействия с пользователем – человеком.
Архитектура MVVM предполагает разделение ответственности между тремя программными слоями приложения, поэтому эти слои могут быть вынесены в отдельные сборки, нацеленные на .NET Standard. Формальная спецификация .NET Standard позволяет разработчикам создавать переносимые библиотеки, которые могут использоваться в различных реализациях .NET, с помощью одного унифицированного набора API-интерфейсов. Строго следуя архитектуре MVVM и стратегии разделения кода .NET Standard, мы сможем использовать уже готовые слои модели и модели представления при разработке пользовательского интерфейса для различных платформ и операционных систем.
Если мы написали приложение для операционной системы Windows с помощью Windows Presentation Foundation, мы с лёгкостью сможем портировать его на другие фреймворки, такие, как, например, Avalonia UI или Xamarin Forms – и наше приложение будет работать на таких платформах, как iOS, Android, Linux, OSX, причём пользовательский интерфейс станет единственной вещью, которую нужно будет написать с нуля.
Традиционная реализация MVVM
Модели представления, как правило, включают в себя свойства и команды, к которым могут быть привязаны элементы разметки XAML. Для того, чтобы привязки данных заработали, модель представления должна реализовывать интерфейс INotifyPropertyChanged и публиковать событие PropertyChanged всякий раз, когда какие-либо свойства модели представления изменяются. Простейшая реализация может выглядеть следующим образом:
public class ViewModel : INotifyPropertyChanged
{
public ViewModel() => Clear = new Command(() => Name = string.Empty);
public ICommand Clear { get; }
public string Greeting => $"Hello, {Name}!";
private string name = string.Empty;
public string Name
{
get => name;
set
{
if (name == value) return;
name = value;
OnPropertyChanged(nameof(Name));
OnPropertyChanged(nameof(Greeting));
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string name)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
XAML, описывающий UI приложения:
<StackPanel>
<TextBox Text="{Binding Name, Mode=TwoWay}"/>
<TextBlock Text="{Binding Greeting, Mode=OneWay}"/>
<Button Content="Clear" Command="{Binding Clear}"/>
</StackPanel>
И это работает! Когда пользователь вводит своё имя в текстовое поле, текст ниже мгновенно меняется, приветствуя пользователя.
Но постойте! Нашему UI нужны всего два синхронизированных свойства и одна команда, почему мы должны написать более двадцати строк кода, чтобы наше приложение заработало корректно? Что случится, если мы решим добавить больше свойств, отражающих состояние нашей модели представления? Кода станет больше, код станет запутаннее и сложнее. А нам его ещё поддерживать!
Рецепт #1. Шаблон «Наблюдатель». Короткие геттеры и сеттеры. ReactiveUI
На самом деле, проблема многословной и запутанной реализации интерфейса INotifyPropertyChanged не нова, и существует несколько решений. Первым делом стоит обратить внимание на ReactiveUI. Это кроссплатформенный, функциональный, реактивный MVVM фреймворк, позволяющий .NET разработчикам использовать реактивные расширения при разработке моделей представления.
Реактивные расширения являются реализацией шаблона проектирования «Наблюдатель», определяемого интерфейсами стандартной библиотеки .NET – «IObserver» и «IObservable». Библиотека также включает в себя более пятидесяти операторов, позволяющих преобразовывать потоки событий – фильтровать, объединять, группировать их – с помощью синтаксиса, похожего на язык структурированных запросов LINQ. Подробнее о реактивных расширениях можно почитать здесь.
ReactiveUI также предоставляет базовый класс, реализующий INotifyPropertyChanged – ReactiveObject. Давайте перепишем наш образец кода, используя предоставленные фреймворком возможности.
public class ReactiveViewModel : ReactiveObject
{
public ReactiveViewModel()
{
Clear = ReactiveCommand.Create(() => Name = string.Empty);
this.WhenAnyValue(x => x.Name)
.Select(x => $"Hello, {x}!")
.ToProperty(this, x => x.Greeting);
}
public ReactiveCommand Clear { get; }
private ObservableAsPropertyHelper<string> greeting;
public string Greeting => greeting.Value;
private string name = string.Empty;
public string Name
{
get => name;
set => this.RaiseAndSetIfChanged(ref name, value);
}
}
Такая модель представления делает абсолютно то же самое, что и предыдущая, но кода в ней поменьше, он более предсказуем, а все связи между свойствами модели представления описаны в одном месте, с помощью синтаксиса LINQ to Observable. На этом, конечно, можно было бы и остановиться, но кода по-прежнему довольно много – нам приходится явно реализовывать геттеры, сеттеры и поля.
Рецепт #2. Инкапсуляция INotifyPropertyChanged. ReactiveProperty
Альтернативным решением может стать использование библиотеки ReactiveProperty, предоставляющей классы-обёртки, ответственные за отправку уведомлений пользовательскому интерфейсу. С ReactiveProperty модель представления не должна реализовывать каких-либо интерфейсов, вместо этого, каждое свойство реализует INotifyPropertyChanged само. Такие реактивные свойства также реализуют IObservable, а это значит, что мы можем подписываться на их изменения, как если бы мы использовали ReactiveUI. Изменим нашу модель представления, используя ReactiveProperty.
public class ReactivePropertyViewModel
{
public ReadOnlyReactiveProperty<string> Greeting { get; }
public ReactiveProperty<string> Name { get; }
public ReactiveCommand Clear { get; }
public ReactivePropertyViewModel()
{
Clear = new ReactiveCommand();
Name = new ReactiveProperty<string>(string.Empty);
Clear.Subscribe(() => Name.Value = string.Empty);
Greeting = Name
.Select(name => $"Hello, {name}!")
.ToReadOnlyReactiveProperty();
}
}
Нам всего лишь нужно объявить и инициализировать реактивные свойства и описать связи между ними. Никакого шаблонного кода писать не нужно, не считая инициализаторов свойств. Но у этого подхода есть недостаток – мы должны изменить наш XAML, чтобы привязки данных заработали. Реактивные свойства являются обёртками, поэтому UI должен быть привязан к собственному свойству каждой такой обёртки!
<StackPanel>
<TextBox Text="{Binding Name.Value, Mode=TwoWay}"/>
<TextBlock Text="{Binding Greeting.Value, Mode=OneWay}"/>
<Button Content="Clear" Command="{Binding Clear}"/>
</StackPanel>
Рецепт #3. Изменение сборки во время компиляции. PropertyChanged.Fody + ReactiveUI
В типичной модели представления каждое публичное свойство должно уметь отправлять уведомления пользовательскому интерфейсу, когда его значение изменяется. С пакетом PropertyChanged.Fody волноваться об этом не придётся. Единственное, что требуется от разработчика – пометить класс модели представления атрибутом AddINotifyPropertyChangedInterface – и код, ответственный за публикацию события PropertyChanged, будет дописан в сеттеры автоматически после сборки проекта, вместе с реализацией интерфейса INotifyPropertyChanged, если таковая отсутствует. В случае необходимости превратить наши свойства в потоки изменяющихся значений, мы всегда сможем использовать метод расширения WhenAnyValue из библиотеки ReactiveUI. Давайте перепишем наш образец в третий раз, и увидим, насколько лаконичнее станет наша модель представления!
[AddINotifyPropertyChangedInterface]
public class FodyReactiveViewModel
{
public ReactiveCommand Clear { get; }
public string Greeting { get; private set; }
public string Name { get; set; } = string.Empty;
public FodyReactiveViewModel()
{
Clear = ReactiveCommand.Create(() => Name = string.Empty);
this.WhenAnyValue(x => x.Name, name => $"Hello, {name}!")
.Subscribe(x => Greeting = x);
}
}
Fody изменяет IL-код проекта во время компиляции. Дополнение PropertyChanged.Fody ищет все классы, помеченные атрибутом AddINotifyPropertyChangedInterface или реализующие интерфейс INotifyPropertyChanged, и редактирует сеттеры таких классов. Подробнее о том, как работает кодогенерация и какие ещё задачи она позволяет решать, можно узнать из доклада Андрея Курoша "Reflection.Emit. Практика использования".
Хотя PropertyChanged.Fody и позволяет нам писать чистый и выразительный код, устаревшие версии .NET Framework, включая 4.5.1 и старше, более не поддерживаются. Это означает, что вы, на самом деле, можете попробовать использовать ReactiveUI и Fody в своём проекте, но на свой страх и риск, и учитывая, что все найденные ошибки никогда не будут исправлены! Версии для .NET Core поддерживаются согласно политике поддержки Microsoft.
Oт теории к практике. Валидация форм с ReactiveUI и PropertyChanged.Fody
Теперь мы готовы написать нашу первую реактивную модель представления. Давайте вообразим, будто мы разрабатываем сложную многопользовательскую систему, при этом думаем об UX и хотим собрать отзывы от наших клиентов. Когда пользователь отправляет нам сообщение, мы должны знать, является ли оно баг-репортом или предложением по улучшению системы, также мы хотим группировать отзывы по категориям. Пользователи не должны отправлять письма, пока не заполнят всю необходимую информацию корректно. Модель представления, удовлетворяющая перечисленным выше условиям, может выглядеть следующим образом:
[AddINotifyPropertyChangedInterface]
public sealed class FeedbackViewModel
{
public ReactiveCommand<Unit, Unit> Submit { get; }
public bool HasErrors { get; private set; }
public string Title { get; set; } = string.Empty;
public int TitleLength => Title.Length;
public int TitleLengthMax => 15;
public string Message { get; set; } = string.Empty;
public int MessageLength => Message.Length;
public int MessageLengthMax => 30;
public int Section { get; set; }
public bool Issue { get; set; }
public bool Idea { get; set; }
public FeedbackViewModel(IService service)
{
this.WhenAnyValue(x => x.Idea)
.Where(selected => selected)
.Subscribe(x => Issue = false);
this.WhenAnyValue(x => x.Issue)
.Where(selected => selected)
.Subscribe(x => Idea = false);
var valid = this.WhenAnyValue(
x => x.Title, x => x.Message,
x => x.Issue, x => x.Idea,
x => x.Section,
(title, message, issue, idea, section) =>
!string.IsNullOrWhiteSpace(message) &&
!string.IsNullOrWhiteSpace(title) &&
(idea || issue) && section >= 0);
valid.Subscribe(x => HasErrors = !x);
Submit = ReactiveCommand.Create(
() => service.Send(Title, Message), valid
);
}
}
Мы маркируем нашу модель представления с помощью атрибута AddINotifyPropertyChangedInterface – таким образом, все свойства будут оповещать UI об изменении их значений. С помощью метода WhenAnyValue, мы подпишемся на изменения этих свойств и будем обновлять другие свойства. Команда, ответственная за отправку формы, будет оставаться выключенной, пока пользователь не заполнит форму корректно. Сохраним наш код в библиотеку классов, нацеленную на .NET Standard, и перейдём к тестированию.
Модульное тестирование моделей представления
Тестирование – это важная часть процесса разработки программного обеспечения. С тестами мы сможем доверять нашему коду и перестать бояться его рефакторить – ведь для проверки корректности работы программы достаточно будет запустить тесты и убедиться в их успешном завершении. Приложение, использующее архитектуру MVVM, состоит из трёх слоёв, два из которых содержат платформонезависимую логику – и именно её мы сможем протестировать с помощью .NET Core и фреймворка XUnit.
Для создания моков и стабов нам пригодится библиотека NSubstitute, предоставляющая удобный API для описания реакций на действия системы и значений, возвращаемых «поддельными объектами».
var sumService = Substitute.For<ISumService>();
sumService.Sum(2, 2).Returns(4);
Для улучшения читаемости как кода, так и сообщений об ошибках в наших тестах, используем библиотеку FluentAssertions. С ней нам не только не придётся запоминать, каким по счёту аргументом в Assert.Equal идёт фактическое значение, а каким – ожидаемое, но и код за нас будет писать наша IDE!
var fibs = fibService.GetFibs(10);
fibs.Should().NotBeEmpty("because we've requested ten fibs");
fibs.First().Should().Be(1);
Давайте напишем тест для нашей модели представления.
[Fact]
public void ShouldValidateFormAndSendFeedback()
{
// Создадим экземпляр модели представления,
// предоставим все необходимые зависимости.
var service = Substitute.For<IService>();
var feedback = new FeedbackViewModel(service);
feedback.HasErrors.Should().BeTrue();
// Имитируем пользовательский ввод.
feedback.Message = "Message!";
feedback.Title = "Title!";
feedback.Section = 0;
feedback.Idea = true;
feedback.HasErrors.Should().BeFalse();
// После вызова команды удостоверимся,
// что метод Send() объекта IService был
// вызван ровно один раз.
feedback.Submit.Execute().Subscribe();
service.Received(1).Send("Title!", "Message!");
}
UI для Универсальной платформы Windows
Хорошо, теперь наша модель представления протестирована и мы уверены, что всё работает, как ожидается. Процесс разработки слоя представления нашего приложения довольно прост – нам необходимо создать новый платформозависимый проект Universal Windows Platform и добавить в него ссылку на библиотеку .NET Standard, содержащую платформонезависимую логику нашего приложения. Далее дело за малым – объявить элементы управления в XAML, привязать их свойства к свойствам модели представления и не забыть указать контекст данных любым удобным способом. Сделаем это!
<StackPanel Width="300" VerticalAlignment="Center">
<TextBlock Text="Feedback" Style="{StaticResource TitleTextBlockStyle}"/>
<TextBox PlaceholderText="Title"
MaxLength="{Binding TitleLengthMax}"
Text="{Binding Title, Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{Binding TitleLength, Mode=OneWay}"/>
<Run Text="letters used from"/>
<Run Text="{Binding TitleLengthMax}"/>
</TextBlock>
<TextBox PlaceholderText="Message"
MaxLength="{Binding MessageLengthMax}"
Text="{Binding Message, Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{Binding MessageLength, Mode=OneWay}"/>
<Run Text="letters used from"/>
<Run Text="{Binding MessageLengthMax}"/>
</TextBlock>
<ComboBox SelectedIndex="{Binding Section, Mode=TwoWay}">
<ComboBoxItem Content="User Interface"/>
<ComboBoxItem Content="Audio"/>
<ComboBoxItem Content="Video"/>
<ComboBoxItem Content="Voice"/>
</ComboBox>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0" Content="Idea"
IsChecked="{Binding Idea, Mode=TwoWay}"/>
<CheckBox Grid.Column="1" Content="Issue"
IsChecked="{Binding Issue, Mode=TwoWay}"/>
</Grid>
<TextBlock Visibility="{Binding HasErrors}"
Text="Please, fill in all the form fields."
Foreground="{ThemeResource AccentBrush}"/>
<Button Content="Send Feedback"
Command="{Binding Submit}"/>
</StackPanel>
Наконец, наша форма готова.
UI для Xamarin.Forms
Чтобы приложение заработало на мобильных устройствах под управлением операционных систем Android и iOS, необходимо создать новый проект Xamarin.Forms и описать UI, используя элементы управления Xamarin, адаптированные для мобильных устройств.
UI для Avalonia
Avalonia – это кроссплатформенный фреймворк для .NET, использующий диалект XAML, привычный для разработчиков WPF, UWP или Xamarin.Forms. Avalonia поддерживает Windows, Linux и OSX и разрабатывается сообществом энтузиастов на GitHub. Для работы с ReactiveUI необходимо установить пакет Avalonia.ReactiveUI. Oпишем слой представления на Avalonia XAML!
Заключение
Как мы видим, .NET в 2018 году позволяет нам писать по-настоящему кроссплатформенный софт – используя UWP, Xamarin.Forms, WPF и AvaloniaUI мы можем обеспечить поддержку нашим приложением операционных систем Android, iOS, Windows, Linux, OSX. Шаблон проектирования MVVM и такие библиотеки, как ReactiveUI и Fody, могут упростить и ускорить процесс разработки, позволяя писать понятный, поддерживаемый и переносимый код. Развитая инфраструктура, подробная документация и хорошая поддержка в редакторах кoда делают платформу .NET всё более привлекательной для разработчикoв программного обеспечения.
Если вы пишете настольные или мобильные приложения на .NET и ещё не знакомы с ReactiveUI, обязательно обратите на него внимание — фреймворк использует один из наиболее популярных клиентов GitHub для iOS, расширение Visual Studio для GitHub и Slack для Windows 10 Mobile. Цикл статей о ReactiveUI на Хабре может стать отличной точкой старта. Разработчикам на Xamarin наверняка пригодится курс "Building an iOS app with C#" от одного из авторов ReactiveUI. Об опыте разработки на AvaloniaUI можно узнать больше из статьи об Egram — альтернативном клиенте для Telegram на .NET Core.
Исходники кроссплатформенного приложения, описанного в статье и демонстрирующего возможности валидации форм с ReactiveUI и Fody, можно найти на GitHub. Пример приложения для Universal Windows Platform, демонстрирующий использование ReactiveUI, PropertyChanged.Fody и DryIoc также доступен на GitHub.
Комментарии (7)
UnclShura
25.07.2018 13:21fibs.First().Should().Be(1);
Как по мне, так вот это не добавляет читаемости а наооборот вводит в язык магические конструкции.
Вместе с builder паттерн считаю антипаттерном и вообще кривым поделием. Против этого только LINQ, но там хоть «между точками» всегда одно и то-же.
vyatsek
25.07.2018 19:41+1Экономия на строчках кода выливается в его усложнение.
И лучше 20 строк понятного и простого кода, чем отладка кода с какими-то подписками и событиями стороннего фреймворка.
Вообще Event модель подразумевает, что клинты подписываются, отписываются, ветвятся и складываются в цепочки. Для UI общая event модель в общем случае оверхед, для этого MS ввели во фреймворк InotifyPropertyChanged, который вполне себе решает эту задачу.
Да и в предложении обычно сложность не в том, что много кода, а в том, что надо понять что он делает, а главное каким образом адаптировать его под новые требования. Чем проще код, тем проще оценить время переделки.
В вашей статье не доказано, что стандартные средства не подходят и вы вынуждены использовать сторонний фреймворк, который кстати сказать хрен знает как и кем протестирован и какие у него есть подводные камни.
IL_Agent
Разве fody умеет uwp с native toulchain?
worldbeater Автор
В случае, если AddINotifyPropertyChangedInterface используется в библиотеке .NET Standard, на которую ссылается платформозависимый UWP-проект, проблем с работой PropertyChanged.Fody на моём опыте не возникало. Судя по некоторым обсуждениям на Github, другие плагины тоже хорошо дружат с UWP!
IL_Agent
А вы точно собирали свой проект с native toulchain? Потому что с ним вроде бы IL не генерится — сразу нативная сборка на выходе.
worldbeater Автор
Да, кoнечнo с .NET Native. Нашёл чуть бoльше пoдрoбнoстей в дoкументации:
Истoчник: .NET Native and just-in-time compilation
IL_Agent
Спасибо, это очень здорово!