Как правильно структурировать приложение согласно паттерну MVVM? (Xamarin.forms)
Я создаю приложение, которое должно общаться с микроконтроллером ESP32 по bluetooth low energy. Класс ble должен отправлять данные на esp и получать их назад. Внутри приложения должны быть классы режимов работы, выбираемые пользователем. Например: класс, запрашивающий данные о погоде по http запросу и отправляющий их на микроконтроллер и т.д. Также должен быть класс "распределитель", вызывающий эти режимы в зависимости от полученных от микроконтроллера данных.
Как я вижу этот проект:
Имеется класс-синглтон BleController, отвечающий за взаимодействие с esp. Он имеет событие, которое срабатывает при поступлении новых данных, и метод, отправляющий новые данные на esp.
Класс-распределитель (тоже синглтон) имеет у себя лист режимов, которые являются наследниками интерфейса. Он подписан на событие получения новых данных и вызывает нужный режим в зависимости от этих данных.
Также имеется примитивный графический интерфейс с кнопками "подключить", "отключить", списком доступных режимов и списком устроиств, доступных для подключения.
Вопросы:
- Является ли
BleControllerсервисом и как его правильно создать? - Прямое обращение к нему от режимов является нарушением mvvm?
- Чем должен быть класс-распределитель (сервис, модель, синглтон и т.д.)?
- Являются ли режимы моделями или чем-то другим?
- Правильно ли мое видение программы с точки зрения паттерна?
Ответы (2 шт):
С точки зрения MVVM всё, что вы описываете — классы работающие прямо или непрямо с аппаратурой — является частью уровня модели.
У вас ещё должна быть бизнес логика (на уровне VM или между VM и M), и собственно UI.
Поскольку и BleController, и режимы являются частью модели, поэтому то, как именно они общаются между собой, паттерн MVVM не предписывает.
Для такой простой программы, как ваша, я не вижу необходимости выделения отдельных сервисов, но при усложнении программы такая необходимость вполне может появиться. Не стоит усложнять до тех пор, пока это не является необходимостью.
Ответы на ваши вопросы:
- Зависит от вас, паттерн MVVM тут стоит в стороне и ничего не говорит. Если вы имеете в виду микросервис, который бежит отдельно и коммуницирует с основной программой по сети, мне это кажется не нужным в вашей ситуации. Создавать имеет смысл либо в начале программы (если данные для создания известны), либо когда будет необходимая информация (если данные вводятся юзером). В любом случае, это вопрос бизнес-логики.
- Нет, паттерну MVVM всё равно, что делает модель у себя под капотом. Прямое обращение может приводить к слишком сильной связности (а может и не приводить), но это соображение вне паттерна MVVM
- По идее, это часть бизнес-логики.
- Модель, плюс вероятно должно быть ещё представление в VM и V
- Невозможно ответить, необходимо видеть, какое разделение на слои у вас, и как ваши сущности коммуницируют.
По сути вам уже дали ответ на ваши вопросы, я лишь этим ответом хочу показать то, как можно поступить, некий подход проектирования, который имеет мало связанные друг с другом объекты, использует сервисы и DI контейнер.
И так, MVVM, это когда все разделено на мало связанные друг с другом слои, где
- Model - это слой с вашими данными. Конкретно в вашем случае
BleController, это Model. - ViewModel - слой, который общается с Model и предоставляет для UI необходимые публичные свойства для привязки.
- View - слой с нашим UI, то есть вся XAML разметка и все то, что видит пользователь.
То есть, все наше приложение становится максимально удобно для внедрения чего-либо нового или изменения старого, а если все это еще и завязывать на абстракции, то мы вовсе можем сделать проект, который состоит из кучи маленьких.
Что такое сервис: Создавать объекты самостоятельно, это порой муторно и зачастую портит ту самую "слабую связь" между объектами, ведь мы явно указываем = new SomeClass();. Небольшой пример:
ILogger
{
void Send(string msg);
}
class FileLogger : ILogger
{
public void Send(string msg)
{
// Записываем в файл наш лог.
}
}
class SomeClass
{
ILogger logger = new Logger();
void Method() => logger.Send("Данные в лог");
}
Предположим, что SomeClass, это некий класс, который находится у нас в библиотеке, ну и тут сразу думаю становится очевидно то, что он имеет зависимость от класса Logger и от интерфейса ILogger, что не есть хорошо. Ок, перепишем его следующим образом:
class SomeClass
{
ILogger logger;
public SomeClass(ILogger logger)
=> this.logger = logger;
public void Method() => logger.Send("Данные в лог");
}
Мы с вами сделали Dependency Injection (DI), то есть внедрили зависимость в наш класс за его пределами, что позволяет теперь ему существовать только ссылаясь на интерфейс.
Имея это, мы можем теперь полностью убрать из нашего проекта все = new ... ();, как? Да все просто, при помощи контейнеров.
Контейнер - это некая реализация, которая в себе хранит зарегистрированные типы и объекты и в случае необходимости, отдает их при помощи DI или прямого запроса. Если очень грубо объяснять, то контейнер, это словарь объектов/типов, который обычно только раз настраивается и больше про него не вспоминают. Давайте приведу простой псевдокод:
Предположим, что у нас есть те классы, что выше и есть контейнер, в который мы добавляем эти зависимости:
class Program
{
ILogger logger;
SomeClass someClass;
public Program(ILogger logger, SomeClass someClass)
=> (this.logger, this.someClass) = (logger, someClass);
public void SomeLogic()
{
var result = "Некие данные";
someClass.Method();
logger.Send(result); // логируем их.
}
}
var container = new SomeContainer();
container.Register<ILogger, Logger>();
container.Register<ILogger, Logger>();
container.Register<Program>();
var program = container.Resolve<Program>();
program.SomeLogic();
Здесь мы зарегистрировали нужные нам типы в контейнере и дальше попросили из него лишь один объект (Program), в который он сам внедрил все необходимые зависимости, нам не нужно хардкодить = new Logger(); .. = new SomeClass(logger);, нет, это все сделает за нас контейнер, добавляй только нужные типы, да и все.
Но ок, как это все связано с сервисами? Честно, могу ошибаться, но понятие "сервис" пошло от ASP.NET, где весь проект и строится на DI и контейнере, а все зависимости, которые регистрируются в контейнере и называются "сервис". То есть, сервис, это некий класс, который имеет нужную нам логику, допустим, работа с базой, ну и сам этот сервис лежит в контейнере.
Ничего не понятно, можно пример?
Знаете, в WPF и Xamarin проектах, всю эту схему очень классно раскрывает один весьма популярный пакет, зовется Prism. С вашего позволения, я сделаю WPF проект на его основе, а там вам не составит труда подставить свои компоненты и адаптировать все под Xamarin.
Создаем новый проект (у меня WPF).
Устанавливаем из NuGet один из DI контейнеров под Prism, я возьму
Prism.DryIoc, он также подтянетPrism.Wpf,DryIoc.dllи ряд других зависимостей.Создаем две папки
ViewsиViewModels.Переносим
MainWindowвViews, попутно поправляя в ней всеnamespace.Переписываем стартовую точку проекта:
Открываем
App.xamlи добавляем туда пространство именxmlns:prism="http://prismlibrary.com/"Удаляем
StartupUriМеняем
<Applicationна<prism:PrismApplicationОткрываем
App.xaml.csи убираем там наследование от: ApplicationРеализуем все необходимое, следуя подсказкам студии. У нас будет два переписанных метода:
CreateShell()- он отвечает за создание основного окна нашего проекта, некий аналогStartupUriизApp.xaml, только окно берется из контейнера, со всеми зависимостями. Нам достаточно просто написать в немreturn Container.Resolve<MainWindow>();RegisterTypes(IContainerRegistry containerRegistry)- метод, в котором мы регистрируем все необходимые нам типы для контейнера.В итоге получим нечто такое:
public partial class App { protected override Window CreateShell() => Container.Resolve<MainWindow>(); protected override void RegisterTypes(IContainerRegistry containerRegistry) { // Регистрируем сервисы и другие типы. } }
В классе окна прописываем
xmlns:prism="http://prismlibrary.com/"иprism:ViewModelLocator.AutoWireViewModel="True". Эта позволит автоматически находить ViewModel для окна.Сделаем вывод текста, простой
TextBlockпо центру окна и привязанный к свойствуText<TextBlock Text="{Binding Text}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="30"/>В папке
ViewModelsсделаем класс*ViewModel, где*это название наше View. То есть делаемMainWindowViewModel. Если View зоветсяSomeView, то VM должна называтьсяSomeViewModel, а неSomeViewViewModel!.В созданном VM классе делаем то самое свойство, которое мы привязали:
public string Text { get; set; } = "Привет мир!";
В итоге на данном этапе наш проект выглядит так:
Можем уже запускать
Я думаю, вы уже заметили, что в архитектуре нашего проекта нету каких-либо = new .. (), все мало связанно друг с другом и работа идет через контейнер зависимостей. Теперь давайте добавим еще больше магии...
Добавим в наше решение новый проект, библиотеку классов, назовем
[названиеПроекта].Services.Interfaces, чтоб не запутаться и поместим его в директориюServices. Этот проект пусть отвечает только за интерфейсы.Создадим в этом проекте интерфейс, не будем отходить от "традиций", пусть будет, как и ранее
ILogger, некий интерфейс, который отвечает за логирование проекта.public interface ILogger { event Action<string> OnNewLog; void Send(string message); }Для наглядности, сделаем еще один интерфейс, просто чтобы вы поняли суть, назовем его
ITime, пусть отвечает за получение времени:public interface ITime { DateTime GetTime(); }Создадим еще один проект, также в директории
Serviceи назовем егоLoggerService, добавим ему в зависимости ранее созданный проект интерфейсов, а в нем класс, который будет реализовывать интерфейсILogger, к примеру так:public class FileLoggerService : ILogger { public event Action<string> OnNewLog; public ITime time; public FileLoggerService(ITime time) => this.time = time; public void Send(string message) { // Делаем нечто с сообщением. OnNewLog?.Invoke($"[{time.GetTime().ToShortTimeString()}] {message}"); } }Заметьте, мы не знаем чего-либо про реализацию
ITime, мы даже не можем написатьtime = new ...(), ибо попросту нету этого класса, мы знаем только то, что есть некий интерфейс, не более. Это отлично показывает то, как слабо связаны друг с другом слои и проекты в целом, ведь реализацияITimeможет быть где угодно, хоть также, в отдельном проекте.К основному проекту подключаем
SOProject.Services.InterfacesиSOProject.Services.LoggerService.Делаем реализацию
ITIme:public class TimeNow : ITime { public DateTime GetTime() => DateTime.Now; }Регистрируем в контейнер все типы:
protected override void RegisterTypes(IContainerRegistry containerRegistry) { // Регистрируем сервисы и другие типы. containerRegistry.Register<ILogger, FileLoggerService>(); containerRegistry.Register<ITime, TimeNow>(); }Переписываем
MainWindowViewModel:public class MainWindowViewModel { private ILogger logger; public MainWindowViewModel(ILogger logger) { this.logger = logger; SendLogCommand = new DelegateCommand<string>(log => logger.Send(log)); this.logger.OnNewLog += log => Logs.Add(log); } public ObservableCollection<string> Logs { get; } = new(); public ICommand SendLogCommand { get; } }Тут все просто, мы получаем логгер через DI, а также делаем коллекцию, куда будем писать сообщения и команду, которая будет отправлять в логгер сообщение, а также мы подписываемся на событие, которое добавит нам в коллекцию сообщение.
Осталось реализовать View:
<Grid> <Grid.RowDefinitions> <RowDefinition/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <ListBox ItemsSource="{Binding Logs}"/> <Grid Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition/> <ColumnDefinition/> </Grid.ColumnDefinitions> <TextBox Grid.Column="0" x:Name="logText"/> <Button Grid.Column="1" Command="{Binding SendLogCommand}" CommandParameter="{Binding Text, ElementName=logText}" Content="Отправить"/> </Grid> </Grid>
Запускаем, смотрим
Заметьте, это все идет через наш сервис, который мы получили через все тот же интерфейс. Сам проект в итоге выглядит так:
Вот вам и реализация проекта по правилам MVVM, с DI контейнерами, ну и с сервисами, где все мало связано друг с другом, что дает максимальные возможности для тестирования и дальнейшего развития, ведь если нам нужна будет новая логика, мы ее можем сделать также в новой DLL, которую потом достаточно будет зарегистрировать в контейнере и использовать там, где нам надо.
Prism я тут взял как хорошего помощника, который позволяет очень многое, хоть делайте модули, которые будут автоматически подключаться в проект, находясь с ним в одной директории, хоть делите View на слои. Но тут должны сами понимать, все зависит от проекта и ваших желаний, вы можете вовсе все это делать в одном проекте и использовать чисто MVVM подход, вам не запрещают. Главное, поймите суть этого всего и знайте, что такая возможность существует.



