Avalonia — это?
Avalonia – это кроссплатформенный XAML фреймворк для платформы .NET. Для многих разработчиков на WPF/UWP/Xamarin данный фреймворк будет интуитивно понятен и прост в освоении. Avalonia поддерживает Windows, Linux, macOS, также заявлена экспериментальная поддержка Android и iOS. Развивается проект при поддержке сообщества и является open-source.
Подробнее про фреймворк можно почитать по следующим ссылкам:
- Релиз первой беты кросс-платформенного XAML UI-тулкита Avalonia;
- Релиз кросс-платформенного .NET UI-тулкита AvaloniaUI 0.9;
- Никита Цуканов — AvaloniaUI — первый кроссплатформенный XAML UI-фреймворк с поддержкой .NET Core.
Мотивация для статьи
Автору нравится разработка с помощью WPF/UWP и есть опыт использования в реальных проектах. У данных платформ есть свои плюсы и минусы, но самым главным недостатком было то, что они не являются кроссплатформенными. Долгое время, сосредоточенность компании Microsoft на Windows среде, даже с наличием Mono и выходом Net Core, для многих разработчиков вне мира .net – сохранило стереотип, что C# это только для Windows. И если для back-end – платформа .Net Core, стала действительно решением, то с точки зрения кроссплатформенного десктопа изменений не было. Портирование WinForms и WPF для запуска с под .Net Core, является оптимизацией C# кода, но с точки зрения поддержки Linux/MacOS изменений не было.
Как только появилось свободное время решил попробовать разобраться в фреймворке. Конкретно поводом для статьи стал issue к официальной документации Авалонии на Github.
Сейчас в документации пример сделан непосредственно с помощью библиотеки ReactiveUI и в issue поднимается вопрос о необходимости реализации MVVM паттерна на чистом шаблоне проекта без подключения сторонних библиотек. Именно данную цель преследует эта статья.
MVVM в теории
Про данный шаблон написано достаточно на Хабре (1,2) и в документации Xamarin.Forms, Avalonia, поэтому данный раздел вынесен в спойлер.
MVVM (Model-View-ViewModel) – шаблон проектирования, концентрирующий внимание на разделении бизнес-логики и интерфейса программы. Данный шаблон широко используется в приложениях на платформах WPF/UWP/Xamarin.
“Что” от “чего” отделяет?
При использовании вышеприведенных фреймворков, приложение делится на два слоя:
- Бизнес логика приложения, в паттерне первая буква “M” (Model). В данном слое описывается логика и основные задачи перед приложения. Взаимодействие с файловой системой, базой данных, API, описание сущностей системы и т.п. Часто общение с различными источниками данных, выделяют в отдельную под-часть (Services).
- Интерфейс – в паттерне буква V (View) описывается с помощью языка разметки XAML.
Именно эти два слоя и призван разделить паттерн с помощью добавления еще одного – модель представления (ViewModel).
ViewModel — связывающий слой между Model и View с помощью технологии привязки (Binding). Для понятия Binding, введем понятие свойства (Property) – изменяемое поле данных во ViewModel. Простыми словами, с помощью binding, все property, описанные в ViewModel доступны для View. Важным, также является изменяемость property – под этим следует, что любые изменения во View или Model о которых "узнает" ViewModel будут автоматически изменены в зависимости от того, откуда пришли изменение (ввод текстового поля, получение ответа от API и т.п)
Классическая схема выглядит следующим образом:

Примечание: в дальнейшем, в статье будут использоваться английские значения (View. ViewModel, Model, binding, property). Субъективное мнение автора, о том, что так удобнее, сформировано привычкой использования и коммуникацией с другими разработчиками.
Зачем разделять?
Для создания независимых частей приложения. Используя MVVM, вы гарантируете, что слой Model ничего не знает о View, это же применимо для ViewModel. В свою очередь это дает следующие преимущества:
- Использовать единожды написанную логику в других проектах, изменяя только View. Например, вы разрабатываете приложение для Android / iOS на платформе Xamarin и вам необходимо сделать десктоп версию. Используя MVVM, бизнес логика приложения не изменится и достаточно будет ее добавить в проект с View, написанным на платформах WPF/UWP. Наглядный пример описан тут с использованием реактивной реализации MVVM.
- Unit-тесты. Независимость Model и ViewModel позволяет писать Unit-тесты, не обращая внимание на особенности интерфейса.
- В случае редизайна приложения, необходимость изменения логики – минимальна или отсутствует.
Практика
За реализацию приложения возьмем идею отображения популярных фильмов. Сформируем следующие задачи:
- Отобразить фильмы;
- Перемещаться между разными наборами фильмов;
- Использовать подход MVVM.
Задачи выбраны достаточно простыми, чтобы разработчикам не знакомым с WPF/UWP/Xamarin было просто попробовать и запустить приложение. В первом варианте задумки было намного больше функциональности, но каждая из них добавляла новые понятие и охватывало куда больше нюансов и подходов, и цель статьи размывалась.
Инструменты разработки
Для разработки Вам понадобится Visual Studio и плагин Avalonia для нее.
По умолчанию, согласно документации, Avalonia поддерживает .Net Framework и Net Core 2.0+. Для кроссплатформенной разработки нужно выбрать Net Core. Для написания статьи плагин был установлен в Visual Studio 2019, платформой запуска является Net Core 3.0. Выбор проекта Avalonia в Visual Studio 2019:

Начальный вид проекта, добавление стилей и данных
Вот так выглядит проект Avalonia сразу после его создания:

Program.cs – в нем находится функция Main и описываются конфигурации для запуска проекта. В нем указывается непосредственно Application класс App. Данный класс имплементируется в двух файлах:
App.xaml в котором описываются ресурсы доступные всему проекту – формируют класс Application.
App.xaml.cs – code-behind файл, в котором происходит инициализация xaml компонентов и пользовательского интерфейса, с него вызывается MainWindow.
MainWindow представляет собой реализацию класса Window, в code-behind файле можно добавлять обработчики событий (клики на кнопки, выбор из списков и т.п) на элементы интерфейса описанных в MainWindow.xaml.
При запуске приложения, у нас должно появится окно с текстовым сообщение “Welcome to Avalonia”.
Добавление стилей
В статье описание самих стилей опускается, но чтобы выглядело красиво, нужно добавить библиотеку (авторская) в сборку и подключить в проект Авалонии. Для использования в самом проекте, нужно указать стили в файле MainWindow.xaml вот таким образом:
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Moviewer.MainWindow" Title="Moviewer"> <Window.Styles> <StyleInclude Source="avares://Moviewer.Styling/AppStyle.xaml"/> </Window.Styles> Welcome to Avalonia! </Window>
Для проверки стилей, создадим хедер приложения:
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Moviewer.MainWindow" Title="Moviewer"> <Window.Styles> <StyleInclude Source="avares://Moviewer.Styling/AppStyle.xaml"/> </Window.Styles> <Grid RowDefinitions="Auto, *" Classes="mainContainer"> <Border Classes="header" Grid.Row="0"> <StackPanel Classes="title"> <TextBlock Classes="title"> MO </TextBlock> <TextBlock Classes="titleYellow"> VIEW </TextBlock> <TextBlock Classes="title"> ER </TextBlock> </StackPanel> </Border> </Grid> </Window>
Вот так оно должно выглядеть после запуска:

Добавляем данные
Данные подготовлены заранее и скачать их можно с репозитория на GitHub. Дальше нужно скопировать в папку проекта Moviewer и в файле Moviewer.csproj добавить следующее:
<ItemGroup> <None Update="Data\**\*\*"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </None> </ItemGroup>
Это необходимо, чтобы при сборке данные добавлялись в исполняемую папку. Проверить себя, можно собрав проект и после найти папку в сборке по такому пути: project-folder/bin/netcoreapp3.0/Data.

Формат данных выглядит так:

Реализуем MVVM
Добавим папку ViewModels и классы ViewModelBase, MainWindowViewModel.cs Для реализации ViewModel необходимо реализовать интерфейс INotifyPropertyChanged. Данный интерфейс используется для отслеживания изменений в Property, определенных в ViewModel. В приложении может быть множество ViewModel, для различных элементов интерфейса, поэтому удобно реализовать интерфейс в классе ViewModelBase, а в ViewModel использовать имплементацию ViewModelBase.
Примечание: существует достаточное количество библиотек (MVVMLight, Prism, ReactiveUI, MVVM Cross), где шаги приведенные выше уже реализованы. В данной статье описывается реализация паттерна в целом. Для Авалонии существует шаблон с использованием ReactiveUI.
public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }
В MainWindowViewModel.cs создадим текстовое поле, чтобы использовать binding и проверить, связь между View и ViewModel.
public class MainWindowViewModel : ViewModelBase { public string Text => "Welcome to Avalonia"; }
Для отображения сообщения, в MainWindow.xaml добавим следующую разметку перед закрывающимся тэгом Grid.
<StackPanel Grid.Row="1"> <TextBlock Text="{Binding Text, Mode=OneWay}"/> </StackPanel>
Последний шаг, связываем нашу форму с ViewModel. В файле App.xaml.cs, указываем DataContext для MainWindow.
public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow() { DataContext = new MainWindowViewModel() }; } base.OnFrameworkInitializationCompleted(); }
DataContext – используется для реализации привязки данных, источником данных выступает MainWindowViewModel. В упрощенном понимании, можно сформулировать так: “property описанные в классе MainWindowViewModel, доступны для привязки в MainWindow.xaml”
В MainWindow.xaml с помощью binding мы можем указать текстовому полю наше свойство из MainWindowViewModel. Запуская приложение, вы увидите “Welcome to Avalonia” в левом верхнем углу.
О преимуществах данного подхода можно прочесть в спойлере выше, а о DataContext можно прочитать в следующем блоге.
Реализация Model
На уровне модели мы реализуем: класс фильма
public class Movie { public int Id { get; set; } public int VoteCount { get; set; } public string PosterPath { get; set; } public string Title { get; set; } public double VoteAverage { get; set; } public string Overview { get; set; } public string ReleaseDate { get; set; } [JsonIgnore] public Bitmap Poster { get; set; } }
Сервис для получения и обработки данных о фильмах. Создадим папку Services и реализуем загрузку данных. Для работы с json используется Newtonsoft.Json
public class MovieService { readonly string _workingDirectory = Environment.CurrentDirectory; public async Task<IEnumerable<Movie>> GetMovies(int pageIndex) { var folderPath = $"{_workingDirectory}\\Data\\Page{pageIndex}"; var dataFile = $"page{pageIndex}.json"; var imageFolder = Path.Combine(folderPath, "Images"); List<Movie> items; //read data using (var r = new StreamReader(Path.Combine(folderPath, dataFile))) { var json = r.ReadToEnd(); items = JsonConvert.DeserializeObject<List<Movie>>(json); } //load images foreach (var item in items) { var imagePath = Path.Combine(imageFolder, $"{item.Title}.png"); item.Poster = await GetPoster(imagePath); } return items; } private Task<Bitmap> GetPoster(string posterUrl) { return Task.Run(() => { using var fileStream = new FileStream(posterUrl, FileMode.Open, FileAccess.Read) {Position = 0}; var bitmap = new Bitmap(fileStream); return bitmap; }); } }
В GetMovies загружаются данные о фильмах, для загрузки картинок используется GetPoster. На этом логика завершена, дальше обновим ViewModel и View и покажем список фильмов.
Обновление ViewModel и View
GetMovie возвращает Task, поэтому будем NotifyTaskCompletion из Nito.AsyncEx 3.0.1 (Важно использовать именно версию 3.0.1). О том как правильно реализовывать загрузку в ViewModel описано детально в блоге Стивена Клери.
Логика загрузки весьма проста: пока грузятся данные показываем ProgressBar, после завершения загрузки выводим на экран.
Примечание: поскольку данные загружаются локально, то задержки почти нет, поэтому поставим ее сами (на 1 секунду) с помощью Task.Delay(1000).
public class MainWindowViewModel : ViewModelBase { private MovieService _movieService; public MainWindowViewModel() { InitializationNotifier = NotifyTaskCompletion.Create(InitializeAsync()); } public INotifyTaskCompletion InitializationNotifier { get; private set; } private async Task InitializeAsync() { _movieService = new MovieService(); var data = await _movieService.GetMovies(1); await Task.Delay(1000); MyItems = new ObservableCollection<Movie>(data); } private ObservableCollection<Movie> _myItems; public ObservableCollection<Movie> MyItems { get => _myItems; set { if (value != null) { _myItems = value; OnPropertyChanged(); } } } } }
Выведем на экран фильмы, заменив StackPanel в MainWindows.xaml на следующую разметку:
<Grid Classes="contentContainer" Grid.Row="1"> <ProgressBar VerticalAlignment="Center" HorizontalAlignment="Center" IsVisible="{Binding InitializationNotifier.IsNotCompleted}" Classes="progressBar" IsIndeterminate="True"/> <ListBox Classes="movies" Grid.Column="1" Grid.Row="1" IsVisible="{Binding InitializationNotifier.IsCompleted, Mode=TwoWay}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" Items="{Binding MyItems}"> <ListBox.ItemTemplate> <DataTemplate> <Grid> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="Auto" MinHeight="48"/> </Grid.RowDefinitions> <Image Grid.Row="0" Stretch="UniformToFill" Source="{Binding Poster}"/> <Border Grid.Row="1" Classes="title"> <Grid ColumnDefinitions="*, 0.4*" Margin="4"> <TextBlock FontSize="18" Text="{Binding Title}" /> <TextBlock FontSize="24" Grid.Column="1" Text="{Binding VoteAverage}"/> </Grid> </Border> </Grid> </DataTemplate> </ListBox.ItemTemplate> <ListBox.ItemsPanel> <ItemsPanelTemplate> <WrapPanel ItemWidth="340" ItemHeight="480" Orientation="Horizontal"/> </ItemsPanelTemplate> </ListBox.ItemsPanel> </ListBox> </Grid>
После этого, при запуске приложения в начале появится ProgressBar, а затем набор фильмов.

Переключаем наборы фильмов
В ViewModel нам нужно добавить параметр страницы Page, который выведем вверху страницы. Интерфейс подписывается на событие PropertyChange и “прослушивает” изменения впоследствии обновляя нужный property:
private int _page; public int Page { get => _page; set { _page = value; OnPropertyChanged(); } }
Примечание: в классическом виде выглядит достаточно громоздко, с помощью различный библиотек (Fody, ReactiveUI) можно выразить это лаконичнее.
Для обработки переключения: вперед, назад необходимо добавить две функции. В них мы будем менять счетчик и вызывать GetMovie с новым его значением. Логика будет идентично той, что и при загрузке, только добавится вычисление страницы. Для удобства вынесем загрузку в отдельный метод, который будет принимать параметр страницы.
public async Task LoadData(int page) { var data = await _movieService.GetMovies(page); await Task.Delay(1000); MyItems = new ObservableCollection<Movie>(data); }
Инициализация ViewModel преобразуется в следующий вид:
private async Task InitializeAsync() { Page = 1; _movieService = new MovieService(); await LoadData(Page); }
Сами функции будут выглядеть так:
public void NextPage() { if (_page+1 > 10) Page = 1; else Page = _page + 1; InitializationNotifier = NotifyTaskCompletion.Create(LoadData(Page)); } public void PrevPage() { if (1 > _page - 1) Page = 10; else Page = _page - 1; InitializationNotifier = NotifyTaskCompletion.Create(LoadData(Page)); }
Условия в начале каждой из функций зацикливает переключение. Дойдя до последней страницы, следующей будет 1 и в обратную сторону. Нажимая на кнопку Prev на первой, мы перейдем на последнюю. Стоит отметить, что в классическом варианте MVVM для обработки действий используются команды (Commands). В Авалонии в отличии от других XAML фреймворков есть возможность привязки не только к командам, но и к методам (как в нашем случае), что порой удобнее, чем создание команд.

Получение новой коллекции остается асинхронной операцией, поэтому мы используем тот же подход, что при инициализации ViewModel, а именно NotifyTaskCompletion. Однако для того, чтобы ProgressBar появлялся при переключении и автоматически обновлялось поле, необходимо InitializationNotifier сделать property, добавив OnPropertyChange():
private INotifyTaskCompletion _initializationNotifier; public INotifyTaskCompletion InitializationNotifier { get => _initializationNotifier; private set { _initializationNotifier = value; OnPropertyChanged(); } }
Для позиционирования элементов используем Grid с 2-мя строками 3-мя колонками. После Grid.Row = “1” добавьте следующее (Важно, чтобы закрывающий тэг > был после новых строчек):
ColumnDefinitions="Auto, *, Auto" RowDefinitions="Auto, *" >
Расположим ProgressBar по центру
Grid.Column="1" Grid.Row="1"
В MainWindow выведем номер страницы и свяжем кнопки и функциями.
<TextBlock Grid.Column="1" HorizontalAlignment="Center" Margin="4" FontSize="18" FontWeight="Bold" Foreground="#819FFF" Text="{Binding Path=Page}"/> <Button Command="{Binding PrevPage}" Grid.Row="1" Grid.Column="0" Content="PREV" Classes="navigation"> </Button> <Button Command="{Binding NextPage}" Grid.Row="1" Grid.Column="2" Content="NEXT" Classes="navigation"> </Button>
Приложение готово! Автором приложение запускалось на Windows 10, Mint.


Как запустить на Linux
Достаточно установить Net Core, скопировать собранный проект на систему и внутри папки вызвать из консоли dotnet Moviewer.dll (или то название, которое Вы укажете в создании проекта)
Мнение автора про Avalonia
Во время реализации проекта было небольшое опасение, что запуск на разных платформах будет «особенным» и что-то «поедет, сломается». Необходимость разных вариантов сборок, имплементации разных форматов интерфейса ну и слегка стереотипное “ UI разработка на Net только технология Windows”. Приятным удивлением стал запуск приложения в два шага и в том, что абсолютно ничего не нужно было менять. Возможно, если усложнить проект и добавить больше функциональности – разница будет заметна: придется придумывать велосипеды, обходные пути, а с некоторыми проблемами Вы можете столкнутся первыми, но в данном приложении все было гладко.
Отдельной особенностью Avalonia является реализация стилей подобных css. В статье данный аспект не рассматривался. В действительности, это очень удобная реализация для стилей приложение в сравнении с классической в WPF/UWP. Если интересно больше прочитать по стили можно обратится к следующим статьям:
- Стильная Авалония;
- Citrus: Набор стилей для Avalonia;
- A Cross-Platform GUI Theme for Desktop .NET Core Applications
Из минусов можно выделить, что на сегодняшний момент полноценная разработка удобнее с плагином для Visual Studio, так как в ней доступен превьюер. Вы также можете разрабатывать на Linux, но без него. Сейчас усилиями сообщества (ForNeVeR) к Rider ведется разработка плагина к которой может присоединиться любой желающий.
На этом все, надеюсь данная статьи будем Вам полезна, и Вы найдете что-то новое для себя в ней. В конце хочется выразить благодарности:
- друзьям и коллегам, как первым редакторам;
- сообществу Авалонии в телеграм.
- пользователям Larymar и worldbeater за советы и подсказки.
