[Из песочницы] Avalonia Tutorial: Реализация MVVM по шагам с примерами

Avalonia это?

Avalonia — это кроссплатформенный XAML фреймворк для платформы .Net. Для многих разработчиков на WPF/UWP/Xamarin данный фреймворк будет интуитивно понятен и прост в освоении. Avalonia поддерживает Windows, Linux, macOS, также заявлена экспериментальная поддержка Android и iOS. Развивается проект при поддержке сообщества и является open-source.

Подробнее про фреймворк можно почитать по следующим ссылкам:


Мотивация для статьи

Автору нравится разработка с помощью 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

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 и т.п)
Классическая схема выглядит следующим образом:

arffx7y_xq1g8tjmp2micomhuja.png

Примечание: в дальнейшем, в статье будут использоваться английские значения (View. ViewModel, Model, binding, property). Субъективное мнение автора, о том, что так удобнее, сформировано привычкой использования и коммуникацией с другими разработчиками.


Зачем разделять?

Для создания независимых частей приложения. Используя MVVM, вы гарантируете, что слой Model ничего не знает о View, это же применимо для ViewModel. В свою очередь это дает следующие преимущества:


  • Использовать единожды написанную логику в друг/их проектах, изменяя только View. Например, вы разрабатываете приложение для Android / iOS на платформе Xamarin и вам необходимо сделать десктоп версию. Используя MVVM, бизнес логика приложения не изменится и достаточно будет ее добавить в проект с View, написанным на платформах WPF/UWP. Наглядный пример описан тут с использованием реактивной реализации MVVM.
  • Unit-тесты. Независимость Model и ViewModel позволяет писать Unit-тесты, не обращая внимание на особенности интерфейса.
  • В случае редизайна приложения, необходимость изменения логики — минимальна или отсутствует.


Практика

За реализацию приложения возьмем идею отображения популярных фильмов. Сформируем следующие задачи:


  1. Отобразить фильмы;
  2. Перемещаться между разными наборами фильмов;
  3. Использовать подход 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:

ubs4sgu98tiv5n2k0tezpsnjtmi.png


Начальный вид проекта, добавление стилей и данных

Вот так выглядит проект Avalonia сразу после его создания:

gj-abkzub5fxb8iitykofh2u7ay.png

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 вот таким образом:


  
    
  
   Welcome to Avalonia!

Для проверки стилей, создадим хедер приложения:


  
    
  

  
    
      
         MO 
         VIEW 
         ER 
      
    
  

Вот так оно должно выглядеть после запуска:

vzgquxoehnn8zhpxyec6g51dcck.png


Добавляем данные

Данные подготовлены заранее и скачать их можно с репозитория на GitHub. Дальше нужно скопировать в папку проекта Moviewer и в файле Moviewer.csproj добавить следующее:

  
    
      Always
    
  

Это необходимо, чтобы при сборке данные добавлялись в исполняемую папку. Проверить себя, можно собрав проект и после найти папку в сборке по такому пути: project-folder/bin/netcoreapp3.0/Data.

glhmcsmjhbej20gqk4wntbdm3nm.png

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


Реализуем 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.

  
      
  

Последний шаг, связываем нашу форму с 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> GetMovies(int pageIndex)
        {
            var folderPath = $"{_workingDirectory}\\Data\\Page{pageIndex}";
            var dataFile = $"page{pageIndex}.json";
            var imageFolder = Path.Combine(folderPath, "Images");
            List items;

            //read data
            using (var r = new StreamReader(Path.Combine(folderPath, dataFile)))
            {
                var json = r.ReadToEnd();
                items = JsonConvert.DeserializeObject>(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 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(data);
        }

        private ObservableCollection _myItems;
        public ObservableCollection MyItems
        {
            get => _myItems;
            set
            {
                if (value != null)
                {
                    _myItems = value;
                    OnPropertyChanged();
                }
            }
        }
    }
}

Выведем на экран фильмы, заменив StackPanel в MainWindows.xaml на следующую разметку:


    
      

      
        
          
            
              
                
                
              
              
              
                
                  
                  
                
              
            
          
        
        
          
            
          
        
      
    

После этого, при запуске приложения в начале появится ProgressBar, а затем набор фильмов.

i7rotksfpdehy1_0ekodjgu6edy.gif


Переключаем наборы фильмов

В 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(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 фреймворков есть возможность привязки не только к командам, но и к методам (как в нашем случае), что порой удобнее, чем создание команд.

a6vxp-jkeefdcs0frk7rhdyhz-g.gif

Получение новой коллекции остается асинхронной операцией, поэтому мы используем тот же подход, что при инициализации 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 выведем номер страницы и свяжем кнопки и функциями.

      

      

      

Приложение готово! Автором приложение запускалось на Windows 10, Mint.

m1l0khsdkqdhwns-ppnxs5jwq_w.jpeg

stvsnu3rnwr4jii95utubzpl4n0.jpeg


Как запустить на Linux

Достаточно установить Net Core, скопировать собранный проект на систему и внутри папки вызвать из консоли dotnet Moviewer.dll (или то название, которое Вы укажете в создании проекта)


Мнение автора про Avalonia

Во время реализации проекта было небольшое опасение, что запуск на разных платформах будет «особенным» и что-то «поедет, сломается». Необходимость разных вариантов сборок, имплементации разных форматов интерфейса ну и слегка стереотипное » UI разработка на Net только технология Windows». Приятным удивлением стал запуск приложения в два шага и в том, что абсолютно ничего не нужно было менять. Возможно, если усложнить проект и добавить больше функциональности — разница будет заметна: придется придумывать велосипеды, обходные пути, а с некоторыми проблемами Вы можете столкнутся первыми, но в данном приложении все было гладко.

Отдельной особенностью Avalonia является реализация стилей подобных css. В статье данный аспект не рассматривался. В действительности, это очень удобная реализация для стилей приложение в сравнении с классической в WPF/UWP. Если интересно больше прочитать по стили можно обратится к следующим статьям:

Из минусов можно выделить, что на сегодняшний момент полноценная разработка удобнее с плагином для Visual Studio, так как в ней доступен превьюер. Вы также можете разрабатывать на Linux, но без него. Сейчас усилиями сообщества (ForNeVeR) к Rider ведется разработка плагина к которой может присоединиться любой желающий.

На этом все, надеюсь данная статьи будем Вам полезна, и Вы найдете что-то новое для себя в ней. В конце хочется выразить благодарности:


© Habrahabr.ru