MVVM в .NET MAUI: создание базовой архитектуры

Автор:

blog.pieeatingninjas.be

Основная идея MVVM в .NET MAUI

Идея состоит в том, чтобы создать свой собственный небольшой «MVVM-фреймворк» со следующими возможностями:

  • наличие работающего Dependency Injection (DI);
  • привязка ViewModels к Views;
  • простая навигация из ViewModels (только «линейная» навигация, вперед, назад, без вкладок или ящиков);
  • передача параметров при навигации;
  • запуск событий или вызов функций при навигации.

Инъекция зависимостей

Одна из новых замечательных вещей в MAUI заключается в том, что теперь у нас есть контейнер DI в нашем распоряжении из коробки. Как и в приложениях ASP.NET Core теперь есть «Builder», который позволяет вам конфигурировать ваше приложение и который расширяет DI контейнер через свойство Builder.Services.

Если мы хотим, например, добавить наш класс MainPage в контейнер DI, мы можем сделать это следующим образом:

 public static class MauiProgram
{
  public static MauiApp CreateMauiApp()
  {
      var builder = MauiApp.CreateBuilder();
      builder
          .UseMauiApp()
          .ConfigureFonts(fonts =>
          {
              fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
          });
      builder.Services.AddTransient();
      return builder.Build();
  }
}

Если мы теперь добавим параметр типа MainPage в конструктор класса App, контейнер DI будет внедрять экземпляр, когда Builder создаст экземпляр класса App:

public App(MainPage mainPage)
{
  InitializeComponent();
  MainPage = new NavigationPage(mainPage);
}

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

Привязка ViewModel к представлениям

Мы также можем использовать этот механизм, чтобы внедрить ViewModel в наше представление. Единственное, что нам нужно сделать, это добавить параметр конструктора к нашей MainPage, и убедиться, что мы зарегистрировали такой тип (например, MainPageViewModel) в нашем DI контейнере.

public MainPage(MainPageViewModel viewModel)
{
  BindingContext = viewModel;
  InitializeComponent();
}
public static MauiApp CreateMauiApp()
{
  var builder = MauiApp.CreateBuilder();
  builder
      .UseMauiApp()
      .ConfigureFonts(fonts =>
      {
          fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
      });
  builder.Services.AddTransient();
  builder.Services.AddTransient();
  return builder.Build();
}

Это означает, что, когда создается экземпляр App, необходимо создать MainPage и все его зависимости, как в данном примере MainPageViewModel. Если эта ViewModel будет иметь зависимость (например, Service или Repository, которую необходимо внедрить через конструктор, DI-контейнер будет пытаться создать их все. Если вы не зарегистрировали определенный тип, который является зависимостью другого типа, то, как и следовало ожидать, будет выброшен System.InvalidOperationException.

Следующий код демонстрирует, как вы можете добавить зависимость интерфейса IDataService к MainPageViewModel, и как зарегистрировать конкретную реализацию этого интерфейса.

public class MainPageViewModel
{
  readonly IDataService _dataService;
  public MainPageViewModel(IDataService dataService)
  {
      _dataService = dataService;
  }
}
 
 builder.Services.AddSingleton<_IDataService2c_ DataService="">();

Благодаря встроенному контейнеру DI в MAUI, мы можем заставить все это работать без использования сторонних библиотек!

Итак, как мы можем сделать простую навигацию?

NavigationService

NavigationService, конечно же, должен отвечать за навигацию. Он должен предоставлять методы, которые могут быть вызваны для перехода с одной страницы на другую, и передавать параметры от одной ViewModel к другой. Я бы хотел, чтобы NavigationService внедрялся в мои ViewModel, чтобы я мог выполнять навигацию из команды, которая была вызвана, когда пользователь нажал на кнопку, например. Внедрить такой NavigationService в наши ViewModels проще простого, благодаря встроенному DI-контейнеру MAUI.

Как мы можем реализовать саму навигацию? Ну, навигация в MAUI осуществляется через интерфейс INavigation. Как только мы получим реализацию этого интерфейса, мы сможем делать такие вещи, как PushAsync, PopAync, PushModalAsync. Каждая страница в MAUI имеет свойство Navigation, которое имеет тип INavigation. Но в нашем «фреймворке» ViewModel не знает о Page, у нее нет ссылки на Page. К счастью, мы можем получить доступ к главной странице приложения и получить ее свойство Navigation:

INavigation navigation = App.Current.MainPage.Navigation;

Конечно, доступ к нему возможен только после установки свойства MainPage, что обычно делается в конструкторе класса App.

Давайте посмотрим, как выглядит первая реализация моего NavigationService:

public class NavigationService : INavigationService
{
  readonly IServiceProvider _services;
  protected INavigation Navigation
  {
      get
      {
          INavigation? navigation = Application.Current?.MainPage?.Navigation;
          if (navigation is not null)
              return navigation;
          else
          {
             //This is not good!
              if (Debugger.IsAttached)
                  Debugger.Break();
              throw new Exception();
          }
      }
  }
  public NavigationService(IServiceProvider services)
      => _services = services;
  public Task NavigateToSecondPage()
  {
      var page = _services.GetService();
      if (page is not null)
          return Navigation.PushAsync(page, true);
      throw new InvalidOperationException($"Unable to resolve type SecondPage");
  }
}

Во-первых, свойство Navigation предоставляет доступ к свойству Navigation главной страницы приложения. Это просто для удобства внутри NavigationService.

Во-вторых, вы заметите, что NavigationService имеет конструктор с параметром типа IServiceProvider. Мы можем использовать этот ServiceProvider для предоставления классов или экземпляров, которые мы зарегистрировали в классе MauiProgram. Этот ServiceProvider будет внедрен, когда мы предоставим экземпляр NavigationService, и мы установим этот ServiceProvider как поле readonly в нашем классе.

Наконец, есть метод NavigateToSecondPage. Это тот метод, который должен быть вызван из ViewModel, в которую внедрен этот NavigationService. Используя инжектированный IServiceProvider, мы попытаемся определить тип страницы, на которую мы хотим перейти. Мы используем свойство Navigation типа INavigation для перехода к разрешенному экземпляру. Пока мы резолвим запрошенную страницу, контейнер DI предоставляет все зависимости, например, ViewModel и ее зависимости, поскольку все было зарегистрировано в нашем контейнере.

Мы можем даже очистить NavigateToSecondPage, так как большая часть кода в нем может быть использована повторно, когда мы добавим методы для перехода на другие страницы:

public Task NavigateToSecondPage()
  => NavigateToPage();
private Task NavigateToPage() where T : Page
{
  var page = ResolvePage();
  if(page is not null)
      return Navigation.PushAsync(page, true);
  throw new InvalidOperationException($"Unable to resolve type {typeof(T).FullName}");
}
private T? ResolvePage() where T : Page
  => _services.GetService();

Мы могли бы даже пойти дальше и перенести эти методы (NavigateToPage и ResolvePage) вместе с полем IServiceProvider и свойством Navigation в базовый класс и даже поместить это в отдельную библиотеку. Но это выходит за рамки данного поста.

Теперь, когда у нас есть NavigationService, мы можем зарегистрировать его в нашем DI контейнере, вместе с нашей новой страницей и ее ViewModel:

builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddSingleton<_INavigationService2c_ NavigationService="">();

Далее мы можем обновить MainPageViewModel так, чтобы экземпляр типа INavigationService был внедрен и вызвать его метод NavigateToSecondPage, например, в команде «NavigateCommand»:

public class MainPageViewModel
{
  readonly IDataService _dataService;
  readonly INavigationService _navigationService;
  public Command NavigateCommand
      => new Command(async () => await _navigationService.NavigateToSecondPage());
  public MainPageViewModel(IDataService dataService, INavigationService navigationService)
  {
      _dataService = dataService;
      _navigationService = navigationService;
  }
}

Осталось только привязать эту команду «NavigateCommand» к кнопке в XAML:

<Button
  Text="Click me"
  FontAttributes="Bold"
  Grid.Row="3"
  Command="{Binding NavigateCommand}"
  HorizontalOptions="Center" />

Нажатие этой кнопки вызовет команду NavigateCommand MainPageViewModel (наш BindingContext), которая вызовет метод NavigateToSecondPage сервиса NavigationService, который выполнит фактическую навигацию путем создания экземпляра типа SecondPage и перехода к нему через свойство Navigation главной страницы приложения.

Можем ли мы также перемещаться обратно?

Конечно, можем! Поскольку у нас есть доступ к INavigation в нашем NavigationService, мы можем реализовать здесь и другие вещи: открытие модальных страниц, манипулирование NavigationStack, навигация назад.

Все, что нам нужно сделать, это создать метод в NavigationService (и интерфейс INavigationService), который реализует то, чего вы хотите достичь. Затем вы можете вызвать этот метод из вашей ViewModel.

NavigationService:

public Task NavigateBack()
{
  if (Navigation.NavigationStack.Count > 1)
      return Navigation.PopAsync();
  throw new InvalidOperationException("No pages to navigate back to!");
}

INavigationService:

public interface INavigationService
{
  Task NavigateToSecondPage();
  Task NavigateBack();
}

SecondPageViewModel:

public class SecondPageViewModel
{
  readonly INavigationService _navigationService;
  public Command GoBackCommand
      => new Command(async () => await _navigationService.NavigateBack());
  public SecondPageViewModel(INavigationService navigationService)
  {
      _navigationService = navigationService;
  }
}

SecondPage:

<Button
  Text="Go back!"
  FontAttributes="Bold"
  Grid.Row="3"
  Command="{Binding GoBackCommand}"
  HorizontalOptions="Center" />
 

Передача параметров и многое другое

Все это весело и интересно, но без возможности правильно передавать параметры от одной страницы к другой это не очень полезно. Кроме того, при переходе на страницу или со страницы, в идеале, должны вызываться некоторые методы ваших ViewModel для выполнения таких действий, как получение или очистка данных.

Давайте добавим класс ViewModelBase с тремя методами: OnNavigatingTo, OnNavigatedFrom и OnNavigatedTo.

public virtual Task OnNavigatingTo(object? parameter)
  => Task.CompletedTask;
public virtual Task OnNavigatedFrom(bool isForwardNavigation)
  => Task.CompletedTask;
public virtual Task OnNavigatedTo()
  => Task.CompletedTask;

Эти методы означают следующее:

  • OnNavigatingTo вызывается при переходе «вперед» к View (Model). Он принимает параметр типа object, что позволяет нам передавать параметр от одной ViewModel к другой.
  • OnNavigatedFrom вызывается при навигации от View (Model). Параметр isForwardNavigation указывает, переходим ли мы вперед от этого представления к другому (true), или мы переходим назад от этого представления к предыдущему (false). Последнее особенно интересно для очистки данных, так как страница больше не находится в NavigationStack.
  • OnNavigatedTo вызывается, когда мы перешли к View (Model). Этот метод вызывается, когда мы переходим к новому представлению (навигация вперед), а также когда мы переходим к представлению назад (навигация назад).

Чтобы вызвать эти методы, нам нужно добавить немного дополнительного кода в NavigationService и убедиться, что наши ViewModels наследуют ViewModelBase.

Сначала давайте рассмотрим обновленный метод NavigateToPage:

 private async Task NavigateToPage(object? parameter = null) where T : Page
{
  var toPage = ResolvePage();
  if (toPage is not null)
  {
     //Subscribe to the toPage's NavigatedTo event
      toPage.NavigatedTo += Page_NavigatedTo;
     //Get VM of the toPage
      var toViewModel = GetPageViewModelBase(toPage);
     //Call navigatingTo on VM, passing in the paramter
      if (toViewModel is not null)
          await toViewModel.OnNavigatingTo(parameter);
     //Navigate to requested page
      await Navigation.PushAsync(toPage, true);
     //Subscribe to the toPage's NavigatedFrom event
      toPage.NavigatedFrom += Page_NavigatedFrom;
  }
  else
      throw new InvalidOperationException($"Unable to resolve type {typeof(T).FullName}");
}

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

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

Еще одна вещь, которую мы добавили в этот метод, это то, что мы хотим получить ViewModel страницы, типа ViewModelBase. В этом методе GetPageViewModelBase нет ничего причудливого:

private ViewModelBase? GetPageViewModelBase(Page? p)
  => p?.BindingContext as ViewModelBase;

Но вернемся к методу NavigateToPage, как только у нас появится ViewModelBase, мы вызовем наш недавно созданный метод OnNavigatingTo, передав параметр.

После этого мы перейдем на нашу страницу и, наконец, подпишемся на событие NavigatedFrom страницы.

Давайте посмотрим, как выглядит обработчик события NavigatedTo, о котором я говорил ранее:

private async void Page_NavigatedTo(object? sender, NavigatedToEventArgs e)
  => await CallNavigatedTo(sender as Page);
private Task CallNavigatedTo(Page? p)
{
  var fromViewModel = GetPageViewModelBase(p);
  if (fromViewModel is not null)
      return fromViewModel.OnNavigatedTo();
  return Task.CompletedTask;

Довольно просто, не так ли? При его вызове мы проверим, что BindingContext у страницы является ViewModelBase. Если это так, мы вызовем метод OnNavigatedTo у ViewModel.

Последнее, на что нам нужно обратить внимание, - это обработчик событий NavigatedFrom:

private async void Page_NavigatedFrom(object? sender, NavigatedFromEventArgs e)
{
 //To determine forward navigation, we look at the 2nd to last item on the NavigationStack
 //If that entry equals the sender, it means we navigated forward from the sender to another page
  bool isForwardNavigation = Navigation.NavigationStack.Count > 1
      && Navigation.NavigationStack[^2] == sender;
  if (sender is Page thisPage)
  {
      if (!isForwardNavigation)
      {
          thisPage.NavigatedTo -= Page_NavigatedTo;
          thisPage.NavigatedFrom -= Page_NavigatedFrom;
      }
      await CallNavigatedFrom(thisPage, isForwardNavigation);
  }
}
private Task CallNavigatedFrom(Page p, bool isForward)
{
  var fromViewModel = GetPageViewModelBase(p);
  if (fromViewModel is not null)
      return fromViewModel.OnNavigatedFrom(isForward);
  return Task.CompletedTask;
}

Событие NavigatedFrom запускается MAUI, когда мы переходим с определенной страницы. Это может происходить одним из двух способов: либо вы переходите вперед от этой страницы к другой, либо вы переходите назад от этой страницы к предыдущей. Тот факт, что навигация осуществляется вперед или назад, является очень ценной информацией, поэтому мы хотим передать ее ViewModel, с которой мы перешли.

Посмотрев на NavigationStack, мы можем определить, в какую сторону (вперед или назад) мы перешли. Если мы перешли вперед с определенной страницы, то эта страница должна быть предпоследней в NavigationStack (так как страница, на которую мы перешли, будет последней в стеке). Если это не так, значит, мы перешли назад. Логично?

Важно отметить, что при навигации назад мы хотим отписаться от событий страницы! Таким образом, страница и ViewModel могут быть уничтожены GC, поскольку больше нет ссылок на страницу.

Итак, как только мы определили, является ли навигация прямой или обратной, отписались от событий (если необходимо), мы можем найти Page’s ViewModelBase и вызвать ее метод OnNavigatedFrom.

Теперь, когда все это готово, мы можем начать передавать параметры от одной ViewModel к другой. Если, например, SecondPageViewModel необходимо получить id для правильной инициализации, нам нужно сделать следующее:

INavigationService:

Task NavigateToSecondPage(string id);

NavigationService:

public Task NavigateToSecondPage(string id)
  => NavigateToPage(id);

MainPageViewModel:

public Command NavigateCommand
  => new Command(async () => await _navigationService.NavigateToSecondPage("some id"));
 

SecondPageViewModel:

public override Task OnNavigatingTo(object? parameter)
{
  Console.WriteLine($"On navigating to SecondPage with parameter {parameter}");
  return base.OnNavigatingTo(parameter);
}

И последнее

После того, как все эти навигационные штуки установлены, мы не должны забывать об одной последней вещи. Помните, как мы изначально устанавливали свойство MainPage в нашем приложении? Мы ввели экземпляр MainPage в конструктор приложения. Хотя это хорошо работает, но таким образом мы полностью обходим всю вышеописанную логику для нашей первой страницы. Давайте убедимся, что эти методы навигации также вызываются на нашей начальной ViewModel. Для этого нам нужно лишь немного подправить класс App:

public App(INavigationService navigationService)
{
  InitializeComponent();
  MainPage = new NavigationPage();
  navigationService.NavigateToMainPage();
}

Вместо того чтобы внедрять экземпляр MainPage, мы внедрим экземпляр INavigationService. Мы присвоим свойство MainPage новой NavigationPage, а затем вызовем NavigateToMainPage на нашем NavigationService:

public Task NavigateToMainPage()
  => NavigateToPage();

Материалы по теме