1

Я создаю приложение, которое должно общаться с микроконтроллером ESP32 по bluetooth low energy. Класс ble должен отправлять данные на esp и получать их назад. Внутри приложения должны быть классы режимов работы, выбираемые пользователем. Например: класс, запрашивающий данные о погоде по http запросу и отправляющий их на микроконтроллер и т.д. Также должен быть класс "распределитель", вызывающий эти режимы в зависимости от полученных от микроконтроллера данных.

Как я вижу этот проект:

Имеется класс-синглтон BleController, отвечающий за взаимодействие с esp. Он имеет событие, которое срабатывает при поступлении новых данных, и метод, отправляющий новые данные на esp. Класс-распределитель (тоже синглтон) имеет у себя лист режимов, которые являются наследниками интерфейса. Он подписан на событие получения новых данных и вызывает нужный режим в зависимости от этих данных.

Также имеется примитивный графический интерфейс с кнопками "подключить", "отключить", списком доступных режимов и списком устроиств, доступных для подключения.

Вопросы:

  • Является ли BleController сервисом и как его правильно создать?
  • Прямое обращение к нему от режимов является нарушением mvvm?
  • Чем должен быть класс-распределитель (сервис, модель, синглтон и т.д.)?
  • Являются ли режимы моделями или чем-то другим?
  • Правильно ли мое видение программы с точки зрения паттерна?
  • В вопросе не хватает вашей попытки это сделать. А как бы вы поступили в данной ситуации? – aepot May 06 '21 at 08:16
  • @aepot Я бы сделал класс-синглтон ble, который бы имел событие, срабатывающее при поступлении новых данных и обычный метод, отправляющий данные на esp. Класс-распределитель имел бы лист режимов-наследников одного интерфейса, которые бы вызывались при обработке входящих данных. В разных примерах я вижу папку "services", но не могу найти информацию о том, что в ней должно располагаться и как с этими сервисами работать. Я и хотел уточнить, является ли класс ble и распределитель сервисами, можно ли к ним напрямую обращаться и к чему относятся классы-режимы? – Andrew Pstvt May 06 '21 at 08:34
  • Допишите лучше эту информацию в сам вопрос. – aepot May 06 '21 at 08:38
  • 1
    @aepot добавил. Спасибо за подсказку – Andrew Pstvt May 06 '21 at 08:58
  • А почему синглтон? Неужели недостаточно, чтобы единственность объекта определялась бизнес-логикой? – VladD May 06 '21 at 10:30

2 Answers2

5

По сути вам уже дали ответ на ваши вопросы, я лишь этим ответом хочу показать то, как можно поступить, некий подход проектирования, который имеет мало связанные друг с другом объекты, использует сервисы и 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 FileLogger(); // new ServerLogger() // new ConsoleLogger() ...

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<SomeClass>(); 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.

  1. Создаем новый проект (у меня WPF).

  2. Устанавливаем из NuGet один из DI контейнеров под Prism, я возьму Prism.DryIoc, он также подтянет Prism.Wpf, DryIoc.dll и ряд других зависимостей.

  3. Создаем две папки Views и ViewModels.

  4. Переносим MainWindow в Views, попутно поправляя в ней все namespace.

  5. Переписываем стартовую точку проекта:

    • Открываем 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)
          {
              // Регистрируем сервисы и другие типы.
          }
        

        }

  6. В классе окна прописываем xmlns:prism="http://prismlibrary.com/" и prism:ViewModelLocator.AutoWireViewModel="True". Эта позволит автоматически находить ViewModel для окна.

  7. Сделаем вывод текста, простой TextBlock по центру окна и привязанный к свойству Text

    <TextBlock Text="{Binding Text}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="30"/>
    
  8. В папке ViewModels сделаем класс *ViewModel, где * это название наше View. То есть делаем MainWindowViewModel. Если View зовется SomeView, то VM должна называться SomeViewModel, а не SomeViewViewModel!.

  9. В созданном VM классе делаем то самое свойство, которое мы привязали:

    public string Text { get; set; } = "Привет мир!";
    

В итоге на данном этапе наш проект выглядит так:

Project

Можем уже запускать

Project result

Я думаю, вы уже заметили, что в архитектуре нашего проекта нету каких-либо = new .. (), все мало связанно друг с другом и работа идет через контейнер зависимостей. Теперь давайте добавим еще больше магии...

  1. Добавим в наше решение новый проект, библиотеку классов, назовем [названиеПроекта].Services.Interfaces, чтоб не запутаться и поместим его в директорию Services. Этот проект пусть отвечает только за интерфейсы.

  2. Создадим в этом проекте интерфейс, не будем отходить от "традиций", пусть будет, как и ранее ILogger, некий интерфейс, который отвечает за логирование проекта.

     public interface ILogger
     {
         event Action<string> OnNewLog;
         void Send(string message);
     }
    
  3. Для наглядности, сделаем еще один интерфейс, просто чтобы вы поняли суть, назовем его ITime, пусть отвечает за получение времени:

     public interface ITime
     {
         DateTime GetTime();
     }
    
  4. Создадим еще один проект, также в директории Service и назовем его LoggerService, добавим ему в зависимости ранее созданный проект интерфейсов, а в нем класс, который будет реализовывать интерфейс ILogger, к примеру так:

     public class FileLoggerService : ILogger
     {
         public event Action<string> OnNewLog;
         public ITime time;
    
     public FileLoggerService(ITime time)
         =&gt; this.time = time;
    
     public void Send(string message)
     {
         // Делаем нечто с сообщением.
         OnNewLog?.Invoke($&quot;[{time.GetTime().ToShortTimeString()}] {message}&quot;);
     }
    

    }

    Заметьте, мы не знаем чего-либо про реализацию ITime, мы даже не можем написать time = new ...(), ибо попросту нету этого класса, мы знаем только то, что есть некий интерфейс, не более. Это отлично показывает то, как слабо связаны друг с другом слои и проекты в целом, ведь реализация ITime может быть где угодно, хоть также, в отдельном проекте.

  5. К основному проекту подключаем SOProject.Services.Interfaces и SOProject.Services.LoggerService.

  6. Делаем реализацию ITIme:

     public class TimeNow : ITime
     {
         public DateTime GetTime()
             => DateTime.Now;
     }
    
  7. Регистрируем в контейнер все типы:

     protected override void RegisterTypes(IContainerRegistry containerRegistry)
     {
         // Регистрируем сервисы и другие типы.
         containerRegistry.Register<ILogger, FileLoggerService>();
         containerRegistry.Register<ITime, TimeNow>();
     }
    
  8. Переписываем 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&lt;string&gt; Logs { get; } = new();
     public ICommand SendLogCommand { get; }        
    

    }

    Тут все просто, мы получаем логгер через DI, а также делаем коллекцию, куда будем писать сообщения и команду, которая будет отправлять в логгер сообщение, а также мы подписываемся на событие, которое добавит нам в коллекцию сообщение.

  9. Осталось реализовать 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>
    

Запускаем, смотрим

Service Result

Заметьте, это все идет через наш сервис, который мы получили через все тот же интерфейс. Сам проект в итоге выглядит так:

Project

Вот вам и реализация проекта по правилам MVVM, с DI контейнерами, ну и с сервисами, где все мало связано друг с другом, что дает максимальные возможности для тестирования и дальнейшего развития, ведь если нам нужна будет новая логика, мы ее можем сделать также в новой DLL, которую потом достаточно будет зарегистрировать в контейнере и использовать там, где нам надо.

Prism я тут взял как хорошего помощника, который позволяет очень многое, хоть делайте модули, которые будут автоматически подключаться в проект, находясь с ним в одной директории, хоть делите View на слои. Но тут должны сами понимать, все зависит от проекта и ваших желаний, вы можете вовсе все это делать в одном проекте и использовать чисто MVVM подход, вам не запрещают. Главное, поймите суть этого всего и знайте, что такая возможность существует.

EvgeniyZ
  • 15,694
  • 1
    Спасибо, очень хорошее объяснение. – Andrew Pstvt May 06 '21 at 18:46
  • @AndrewPstvt Главное, чтобы вы это поняли, а то мои старания были напрасны) Ну а так, да не за что, удачи в изучении, если что, спрашивайте. – EvgeniyZ May 06 '21 at 20:22
  • 1
    Спасибо! Не напрасно) Узнал о DI, так ещё и по сервисам которые меня интересовали) – Xzizz Jun 02 '23 at 20:15
  • @EvgeniyZ как быть с View ? При внедрении зависимости во ViewModel - View ругается что нет открытого конструктора( А точнее BindingContext у View <viewModels:NotesViewModel/> – Xzizz Jun 15 '23 at 19:36
  • 1
    @Xzizz Это нарушение MVVM. Указав в XAML так DataContext, ваша View 1. Знает про ViewModel (когда не должна). 2. Отвечает за ViewModel (когда не должна). Об этом есть хороший ответ. – EvgeniyZ Jun 15 '23 at 20:21
  • @EvgeniyZ мдеее... Это же прям из Learn.Microsoft по MVVM https://learn.microsoft.com/ru-ru/dotnet/maui/tutorials/notes-mvvm/?tutorial-step=6 – Xzizz Jun 15 '23 at 20:27
  • 1
    @Xzizz Ну, MVVM это набор рекомендаций, которым каждый следует по своему. Кто-то мешает слои, кто-то нет; кто-то использует события по типу Click, а другие нет. Тут стоит смотреть на реалии конкретного проекта, как он изначально базируется, на чем, и так далее. В "чистом" MVVM, у вас должно быть все разделено на мало связанные друг с другом слои, где View не знает про VM и M, ViewModel не знает про View, а Model как и View не знает ничего про VM и V, ну а установка VM в V слое сами понимаете, нарушение этого подхода. – EvgeniyZ Jun 15 '23 at 20:31
  • 1
    @Xzizz Как пример, могу вон дать WPFUI - это некая библиотека для WPF, которая позволяет писать приложения под стилистику современной Win11. Автор там повсеместно мешает все, где идет явное нарушение связи, где View слой (страница) знает вдруг про собственный VM слой. Я например сейчас разрабатываю свой проект с ее использованием, и не стал отходить от предложенного подхода, ибо удобно и фиг с ним, мне эта связь не помешает, но это нарушение, да, которое я осознаю. Вот также и вы, думайте..) – EvgeniyZ Jun 15 '23 at 20:34
  • @EvgeniyZ понял - я просто пытался придерживать и не сходить с пути))) хотя у меня получалось решить костылями... короче нужно лучше понять весь синтаксис, ознакомиться с разными подходами, а дальше просто делать всё как считаю нужным... – Xzizz Jun 15 '23 at 20:38
  • 1
    @Xzizz Раз напишите на голом MVVM, без лишних библиотек и прочего, а потом решайте как и что делать. Как связать V и VM, я вам дал первым комментарием ссылку, там есть пример, где страница и VM создается за пределами, в неком событии запуска приложения. Тем самым мы не нарушаем MVVM, ибо в создаваемых классах нет информации про друг друга, про них знает лишь стартовая точка приложения и ими руководит. Ну а уже потом, из VM можно смело делать другую VM, и так далее. Правда я не знаю как в MAUI (или что там у вас) подобное реализовать, но думаю +- тож самое будет. – EvgeniyZ Jun 15 '23 at 20:44
  • @EvgeniyZ Спасибо, буду разбираться) – Xzizz Jun 15 '23 at 20:47
  • @EvgeniyZ Всё как всегда оказалось просто, всё так же через контейнер внедрения зависимостей и запускается по отделбности V, VM и зависимости их. https://github.com/dotnet-architecture/eshop-mobile-client/blob/main/eShopOnContainers/MauiProgram.cs – Xzizz Jun 16 '23 at 10:25
  • 1
    @Xzizz Тоже самое нарушение, где View знает про VM. – EvgeniyZ Jun 16 '23 at 11:07
  • @EvgeniyZ оххх блин) – Xzizz Jun 16 '23 at 12:30
  • @EvgeniyZ ну да, тоже самое только в профиль( – Xzizz Jun 16 '23 at 12:31
  • @Xzizz View не должен знать про VM как минимум по той причине, что View могут делать другие люди, которым не нужны данные, они лишь проектируют UI в виде отдельного проекта. Вон на ответ гляньте, я без проблем могу там вынести все VM слои в отдельный проект, и это все будет без проблем работать, ибо у меня нет связей между слоями. Чтоб была возможность открывать/закрывать окна например, это надо делегировать отдельному "сервису", который будет знать всю информацию, и на основе входного VM открывать нужное (пример). – EvgeniyZ Jun 16 '23 at 12:42
  • 1
    @Xzizz Если речь про "страницы", то там проще все, ибо вам достаточно один раз указать DataContext на главную VM, а дальше, в этой VM вызывать другие VM, на основе которых View подставит нужный вид (пример). Но VM слоя внутри View вообще быть не должно, это нарушение MVVM. Но повторю, у каждого "свой MVVM", смотрите свой проект. – EvgeniyZ Jun 16 '23 at 12:44
  • @EvgeniyZ у меня Views и есть Pages), и получается что Pages создают себе VM, с наскока не догнал как сделать иначе... попробую позже перечитать вашим ссылки ещё раз. Спасибо! – Xzizz Jun 16 '23 at 12:55
  • Хочу уточнить для понимания: 1) В первом фрагменте кода "ILogger logger = new Logger();" не должно лы быть "ILogger logger = new FileLogger();"? 2) В третьем фрагменте кода мне кажется подозрительным 2 х "container.Register<ILogger, Logger>();". 3) "Здесь мы зарегистрировали нужные нам типы в контейнере и дальше попросили из него лишь один объект (Program), в который он сам внедрил все необходимые зависимости" - это, вообще, как устроенно? 4) В чём смысл контейнера? Стащить все new в одно место? – rotabor Jul 25 '23 at 18:46
  • @rotabor 1 - псевдокод, в котором я пытался показать, что может быть любая реализация, поэтому упростил простым new Logger(), но в целом да, можно там переписать на FileLogger. 2. Очепятка. Я когда пишу ответы, могу раз 10 переписать код в студии, но не поменять до конца в самом ответе. 3. Контейнер автоматически реализует, а также следит за сроком жизни всех объектов, которые требуются для создания запрашиваемого объекта. То есть, при получение класса Program, контейнер реализует ILogger, а также SomeClass (если те есть у него), также реализует и их зависимости тоже. – EvgeniyZ Jul 25 '23 at 19:09
  • Базовую реализацию самого простого контейнера можно посмотреть тут. Ну а на вопрос "Зачем" - это позволяет снизить связанность объектов, ибо почти все объекты, что регистрируются в контейнере пишутся с инверсией (IoC), а это значит, что мы можем удобно их попросить в любом месте приложения, без какой либо инициализации, просто указав свойство, или в конструкторе с нужным типом, контейнер позаботится о сроке жизни, инициализации, и обо всем другом за нас. – EvgeniyZ Jul 25 '23 at 19:12
  • Я для понимания спрашиваю, поймите правильно. В качестве отправной точки. Мне вот это слово"автоматически" не нравится, чувствую в нём подвох. Автоматически - это значит нужно самому ручками эту автоматику запрограммировать. Как контейнер узнаёт, какие зависимости у класса? – rotabor Jul 25 '23 at 19:14
  • Посмотрел "там". То есть контейнер - это чуть ли не целый фрейворк. Наверняка рефлексию использует. – rotabor Jul 25 '23 at 19:18
  • 1
    @rotabor Я выше дал ссылку на пример самого простейшего контейнера, внимание на метод Resolve. Если в двух словах, то контейнер - это некий словарь, которых хранит в себе просто типы, по запросу этот тип достается, получается его конструктор, параметры конструктора, потом эти параметры достаются опять из словаря и инициализируются через Activator.CreateInstance(), далее инициализируется основной объект и возвращается. У полноценных контейнеров там есть уйма чего еще, включая срок жизни, и так далее, но в целом, это основной подход. – EvgeniyZ Jul 25 '23 at 19:21
1

С точки зрения MVVM всё, что вы описываете — классы работающие прямо или непрямо с аппаратурой — является частью уровня модели.

У вас ещё должна быть бизнес логика (на уровне VM или между VM и M), и собственно UI.

Поскольку и BleController, и режимы являются частью модели, поэтому то, как именно они общаются между собой, паттерн MVVM не предписывает.

Для такой простой программы, как ваша, я не вижу необходимости выделения отдельных сервисов, но при усложнении программы такая необходимость вполне может появиться. Не стоит усложнять до тех пор, пока это не является необходимостью.

Ответы на ваши вопросы:

  1. Зависит от вас, паттерн MVVM тут стоит в стороне и ничего не говорит. Если вы имеете в виду микросервис, который бежит отдельно и коммуницирует с основной программой по сети, мне это кажется не нужным в вашей ситуации. Создавать имеет смысл либо в начале программы (если данные для создания известны), либо когда будет необходимая информация (если данные вводятся юзером). В любом случае, это вопрос бизнес-логики.
  2. Нет, паттерну MVVM всё равно, что делает модель у себя под капотом. Прямое обращение может приводить к слишком сильной связности (а может и не приводить), но это соображение вне паттерна MVVM
  3. По идее, это часть бизнес-логики.
  4. Модель, плюс вероятно должно быть ещё представление в VM и V
  5. Невозможно ответить, необходимо видеть, какое разделение на слои у вас, и как ваши сущности коммуницируют.
VladD
  • 206,799