Blazing.Mvvm – Blazor Server, WebAssembly и гибридные приложения с использованием Mvvm Community Toolkit

Простое использование MVVM с помощью библиотеки Blazing.Mvvm и Microsoft Community Toolkit

Содержание

Обзор

MVVM не требуется для разработки приложений Blazor. Система привязки проще, чем в других фреймворках приложений, таких как WinForms и WPF.

Однако у MVVM есть много преимуществ, таких как разделение логики отображения, возможность проведения тестирования, снижение риска и сотрудничество.

Существует несколько библиотек для Blazor, которые пытаются поддерживать шаблон проектирования MVVM и не являются самыми простыми в использовании. В то же время есть CommunityToolkit.Mvvm, который поддерживает фреймворки WPF, Xamarin и MAUI.

Почему бы не использовать Blazor? В этой статье представлена реализация MVVM для Blazor, использующая CommunityToolkit.Mvvm через библиотеку под названием Blazing.MVVM. Если вы знакомы с CommunityToolkit.Mvvm, то уже знаете, как использовать эту реализацию.

Картинка 1

Загрузки

Исходный код (через GitHub)

** Если вам понравилась эта библиотека, пожалуйста, поставьте звездочку репозиторию GitHub.

Nuget:

Часть 1 – Библиотека Blazing.Mvvm

Это развитие репозитория blazor-mvvm от Kelly Adams, которое реализует полную поддержку шаблона MVVM с помощью CommunityToolkit.Mvvm. Были внесены незначительные изменения для предотвращения исключений между потоками, добавлены дополнительные типы базовых классов, реализована навигация в стиле MVVM и преобразован в практический инструментарий.

Начало работы

  1. Добавьте пакет Blazing.Mvvm Nuget в свой проект.

  2. Включите поддержку MvvmNavigation в файле Program.cs:

    • Blazor Server App:

      builder.Services.AddMvvmNavigation(options =>{     options.HostingModel = BlazorHostingModel.Server;});
    • Blazor WebAssembly App:

      builder.Services.AddMvvmNavigation();
    • Blazor Web App (новое в .NET 8.0)

      builder.Services.AddMvvmNavigation(options =>{     options.HostingModel = BlazorHostingModel.WebApp;});
    • Blazor Hybrid App (WinForm, WPF, Avalonia, MAUI):

      builder.Services.AddMvvmNavigation(options =>{     options.HostingModel = BlazorHostingModel.Hybrid;});
  3. Создайте ViewModel, наследующий класс ViewModelBase:
    public partial class FetchDataViewModel : ViewModelBase{    [ObservableProperty]    private ObservableCollection<WeatherForecast> _weatherForecasts = new();    public override async Task Loaded()        => WeatherForecasts = new ObservableCollection<WeatherForecast>(Get());    private static readonly string[] Summaries =    {        "Замораживание", "Защита", "Холодно", "Прохладно", "Мягко", "Тепло",         "Мягко", "Жарко", "Пекло", "Зажигание"    };    public IEnumerable<WeatherForecast> Get()        => Enumerable.Range(1, 5).Select(index => new WeatherForecast            {                Date = DateTime.Now.AddDays(index),                TemperatureC = Random.Shared.Next(-20, 55),                Summary = Summaries[Random.Shared.Next(Summaries.Length)]            })            .ToArray();}
  4. Зарегистрируйте ViewModel в файле Program.cs:
    builder.Services.AddTransient<FetchDataViewModel>();
  5. Создайте страницу, наследующую компонент MvvmComponentBase<TViewModel>:
    @page "/fetchdata"@inherits MvvmComponentBase<FetchDataViewModel><PageTitle>Прогноз погоды</PageTitle><h1>Прогноз погоды</h1><p>Этот компонент демонстрирует получение данных с сервера.</p>@if (!ViewModel.WeatherForecasts.Any()){    <p><em>Загрузка...</em></p>}else{    <table class="table">        <thead>            <tr>                <th>Дата</th>                <th>Темп. (C)</th>                <th>Темп. (F)</th>                <th>Сводка</th>            </tr>        </thead>        <tbody>            @foreach (var forecast in ViewModel.WeatherForecasts)            {                <tr>                    <td>@forecast.Date.ToShortDateString()</td>                    <td>@forecast.TemperatureC</td>                    <td>@forecast.TemperatureF</td>                    <td>@forecast.Summary</td>                </tr>            }        </tbody>    </table>}
  6. По желанию, измените файл NavMenu.razor для использования MvvmNavLink для навигации по ViewModel:
    <div class="nav-item px-3">    <MvvmNavLink class="nav-link" TViewModel=FetchDataViewModel>        <span class="oi oi-list-rich" aria-hidden="true"></span> Получить данные    </MvvmNavLink></div>

Запустите приложение.

Навигация с использованием ViewModel и MvvmNavigationManager из кода. Внедрите класс в вашу страницу или ViewModel, затем используйте метод NavigateTo:

mvvmNavigationManager.NavigateTo<FetchDataViewModel>();

Метод NavigateTo работает так же, как стандартный NavigationManager Blazor и также поддерживает передачу относительного URL и/или строки запроса.

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

mvvmNavigationManager.NavigateTo<ITestNavigationViewModel>();

Такой же принцип работает с компонентом MvvmNavLink:

<div class="nav-item px-3">    <MvvmNavLink class="nav-link"                 TViewModel=ITestNavigationViewModel                 Match="NavLinkMatch.All">        <span class="oi oi-calculator" aria-hidden="true"></span>Test    </MvvmNavLink></div><div class="nav-item px-3">    <MvvmNavLink class="nav-link"                 TViewModel=ITestNavigationViewModel                 RelativeUri="this is a MvvmNavLink test"                 Match="NavLinkMatch.All">        <span class="oi oi-calculator" aria-hidden="true"></span>Test + Params    </MvvmNavLink></div><div class="nav-item px-3">    <MvvmNavLink class="nav-link"                 TViewModel=ITestNavigationViewModel                 RelativeUri="?test=this%20is%20a%20MvvmNavLink%20querystring%20test"                 Match="NavLinkMatch.All">        <span class="oi oi-calculator" aria-hidden="true"></span>Test + QueryString    </MvvmNavLink></div><div class="nav-item px-3">    <MvvmNavLink class="nav-link"                 TViewModel=ITestNavigationViewModel                 RelativeUri="this is a MvvmNvLink test/?                     test=this%20is%20a%20MvvmNavLink%20querystring%20test"                 Match="NavLinkMatch.All">        <span class="oi oi-calculator" aria-hidden="true"></span>Test + Both    </MvvmNavLink></div>

Как работает MVVM

Есть две части:

  1. ViewModelBase
  2. MvvmComponentBase

MvvmComponentBase обрабатывает связывание ViewModel с компонентом.

public abstract class MvvmComponentBase<TViewModel>    : ComponentBase, IView<TViewModel>    where TViewModel : IViewModelBase{    [Inject]    protected TViewModel? ViewModel { get; set; }    protected override void OnInitialized()    {        // Приведите изменения в ViewModel для перерисовки Blazor        ViewModel!.PropertyChanged += (_, _) => InvokeAsync(StateHasChanged);        base.OnInitialized();    }    protected override Task OnInitializedAsync()        => ViewModel!.OnInitializedAsync();}

Вот класс ViewModelBase, который оборачивает ObservableObject:

using CommunityToolkit.Mvvm.ComponentModel;using CommunityToolkit.Mvvm.Input;namespace Blazing.Mvvm.ComponentModel;public abstract partial class ViewModelBase : ObservableObject, IViewModelBase{    public virtual async Task OnInitializedAsync()        => await Loaded().ConfigureAwait(true);    protected virtual void NotifyStateChanged() => OnPropertyChanged((string?)null);    [RelayCommand]    public virtual async Task Loaded()        => await Task.CompletedTask.ConfigureAwait(false);}

Так как MvvmComponentBase прослушивает события PropertyChanged от реализации ViewModelBase, MvvmComponentBase автоматически обновляет интерфейс пользователя при изменении свойств в реализации ViewModelBase или при вызове NotifyStateChanged.

EditForm Валидация и сообщения также поддерживаются. Смотрите примеры кода для примеров использования в большинстве случаев.

Как работает навигация в MVVM

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

Класс MvvmNavigationManager

Когда MvvmNavigationManager инициализируется контейнером IOC в виде Singleton, класс будет изучать все сборки и внутренне кэшировать все ViewModel (классы и интерфейсы) и связанную с ними страницу. Затем, когда наступает время для навигации, выполняется быстрый поиск, и затем используется Blazor NavigationManager, чтобы перейти на правильную страницу. Если был передан относительный URI и/или QueryString через вызов метода NavigateTo, это также передается.

Примечание: Класс MvvmNavigationManager не является полной заменой класса Blazor NavigationManager, реализована только поддержка MVVM. Для обычной навигации “магическими строками” используйте класс NavigationManager.

/// <summary>/// Предоставляет абстракцию для запроса и управления навигацией через ViewModel (класс/интерфейс)./// </summary>public class MvvmNavigationManager : IMvvmNavigationManager{    private readonly NavigationManager _navigationManager;    private readonly ILogger<MvvmNavigationManager> _logger;    private readonly Dictionary<Type, string> _references = new();    public MvvmNavigationManager(NavigationManager navigationManager,                                 ILogger<MvvmNavigationManager> logger)    {        _navigationManager = navigationManager;        _logger = logger;        GenerateReferenceCache();    }    /// <summary>    /// Переходит на указанный связанный URI.    /// </summary>    /// <typeparam name="TViewModel">Тип <see cref="IViewModelBase"/>     /// для определения URI для перехода.</typeparam>    /// <param name="forceLoad">Если true, обходит клиентскую маршрутизацию     /// и заставляет браузер загружать    ///  новую страницу со сервера, неважно, будет ли URI обычно обрабатываться     /// клиентским маршрутизатором.</param>    /// <param name="replace">Если true, заменяет текущую запись в стеке     /// истории. Если false,    ///  добавляет новую запись в стек истории.</param>    public void NavigateTo<TViewModel>(bool? forceLoad = false, bool? replace = false)        where TViewModel : IViewModelBase    {        if (!_references.TryGetValue(typeof(TViewModel), out string? uri))            throw new ArgumentException($"{typeof(TViewModel)} не имеет связанной страницы");        if (_logger.IsEnabled(LogLevel.Debug))            _logger.LogDebug($"Переход '{typeof(TViewModel).FullName}'             к uri '{uri}'");        _navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);    }    /// <summary>    /// Переходит на указанный связанный URI.    /// </summary>    /// <typeparam name="TViewModel">Тип <see cref="IViewModelBase"/>     /// для определения URI для перехода.</typeparam>    /// <param name="options">Предоставляет дополнительные     /// <see cref="NavigationOptions"/>.</param>    public void NavigateTo<TViewModel>(NavigationOptions options)        where TViewModel : IViewModelBase    {        if (!_references.TryGetValue(typeof(TViewModel), out string? uri))            throw new ArgumentException($"{typeof(TViewModel)} не имеет связанной страницы");        if (_logger.IsEnabled(LogLevel.Debug))            _logger.LogDebug($"Переход '{typeof(TViewModel).FullName}'                              к uri '{uri}'");        _navigationManager.NavigateTo(uri, options);    }    /// <summary>    /// Переходит на указанный связанный URI.    /// </summary>    /// <typeparam name="TViewModel">Тип <see cref="IViewModelBase"/>     /// для определения URI для перехода.</typeparam>    /// <param name="relativeUri">относительный URI и/или QueryString, добавленный к     ///  URI навигации    ///  .</param>    /// <param name="forceLoad">Если true, обходит клиентскую маршрутизацию и     /// заставляет браузер загружать    ///  новую страницу со сервера, неважно, будет ли URI обычно обрабатываться     ///  клиентским маршрутизатором.</param>     /// <param name="replace">Если true, заменяет текущую запись     /// в стеке истории. Если false,    ///  добавляет новую запись в стек истории.</param>    public void NavigateTo<TViewModel>(string? relativeUri = null,        bool? forceLoad = false, bool? replace = false)        where TViewModel : I

Примечание: Если вы включаете режим регистрации уровня Debug, MvvmNavigationManager будет выводить связи, созданные при построении кеша. Например:

dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]      Начало генерации нового ссылочного кэшадbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]      Кеширование ссылки навигации       'Blazing.Mvvm.Sample.Wasm.ViewModels.FetchDataViewModel'       с uri '/fetchdata' для 'Blazing.Mvvm.Sample.Wasm.Pages.FetchData'dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]      Кеширование ссылки навигации       'Blazing.Mvvm.Sample.Wasm.ViewModels.EditContactViewModel'       с uri '/form' для 'Blazing.Mvvm.Sample.Wasm.Pages.Form'dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]      Кеширование ссылки навигации       'Blazing.Mvvm.Sample.Wasm.ViewModels.HexTranslateViewModel'       с uri '/hextranslate' для 'Blazing.Mvvm.Sample.Wasm.Pages.HexTranslate'dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]      Кеширование ссылки навигации       'Blazing.Mvvm.Sample.Wasm.ViewModels.ITestNavigationViewModel'       с uri '/test' для 'Blazing.Mvvm.Sample.Wasm.Pages.TestNavigation'dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]      Завершение генерации ссылочного кэша 

Компонент MvvmNavLink базируется на компоненте Navlink Blazor и имеет дополнительные свойства TViewModel и RelativeUri. Внутренне использует MvvmNavigationManager для навигации.

/// <summary>/// Компонент, который отображает якорную ссылку, автоматически переключающую свой класс 'active'/// в зависимости от того, соответствует ли его 'href' текущему URI. Навигация базируется на/// ViewModel (класс/интерфейс)./// </summary>public class MvvmNavLink<TViewModel> : ComponentBase, IDisposable                          where TViewModel : IViewModelBase{    private const string DefaultActiveClass = "active";    private bool _isActive;    private string? _hrefAbsolute;    private string? _class;    [Inject]    private IMvvmNavigationManager MvvmNavigationManager { get; set; } = default!;    [Inject]    private NavigationManager NavigationManager { get; set; } = default!;    /// <summary>    /// Получает или устанавливает CSS-класс, применяемый к NavLink при    /// совпадении текущего маршрута с href NavLink.    /// </summary>    [Parameter]    public string? ActiveClass { get; set; }    /// <summary>    /// Получает или устанавливает коллекцию дополнительных атрибутов     /// , которые будут добавлены к сгенерированному    /// элементу <c>a</c>.    /// </summary>    [Parameter(CaptureUnmatchedValues = true)]    public IDictionary<string, object>? AdditionalAttributes { get; set; }    /// <summary>    /// Получает или устанавливает вычисленный CSS-класс в зависимости от того, активна ли ссылка.    /// </summary>    protected string? CssClass { get; set; }    /// <summary>    /// Получает или устанавливает содержимое компонента.    /// </summary>    [Parameter]    public RenderFragment? ChildContent { get; set; }    /// <summary>    /// Получает или устанавливает значение, представляющее поведение соответствия URL.    /// </summary>    [Parameter]    public NavLinkMatch Match { get; set; }    /// <summary>    /// Относительный URI и/или параметры строки запроса, добавленные к ассоциированному URI.    /// </summary>    [Parameter]    public string? RelativeUri { get; set; }    /// <inheritdoc />    protected override void OnInitialized()    {        // Мы будем перерисовывать компонент при каждом изменении местоположения        NavigationManager.LocationChanged += OnLocationChanged;    }    /// <inheritdoc />    protected override void OnParametersSet()    {        _hrefAbsolute = BuildUri(NavigationManager.ToAbsoluteUri(            MvvmNavigationManager.GetUri<TViewModel>()).AbsoluteUri, RelativeUri);        AdditionalAttributes?.Add("href", _hrefAbsolute);        _isActive = ShouldMatch(NavigationManager.Uri);        _class = null;        if (AdditionalAttributes != null &&            AdditionalAttributes.TryGetValue("class", out object? obj))            _class = Convert.ToString(obj, CultureInfo.InvariantCulture);        UpdateCssClass();    }    /// <inheritdoc />    public void Dispose()    {        // Для предотвращения утечки памяти важно отсоединить        // любые методы обработки событий в Dispose()        NavigationManager.LocationChanged -= OnLocationChanged;    }    private static string BuildUri(string uri, string? relativeUri)    {        if (string.IsNullOrWhiteSpace(relativeUri))            return uri;        UriBuilder builder = new(uri);        if (relativeUri.StartsWith('?'))            builder.Query = relativeUri.TrimStart('?');        else if (relativeUri.Contains('?'))        {            string[] parts = relativeUri.Split('?');            builder.Path = builder.Path.TrimEnd('/') + "/" + parts[0].TrimStart('/');            builder.Query =  parts[1];        }        else            builder.Path = builder.Path.TrimEnd('/') + "/" + relativeUri.TrimStart('/');        return builder.ToString();    }    private void UpdateCssClass()        => CssClass = _isActive            ? CombineWithSpace(_class, ActiveClass ?? DefaultActiveClass)            : _class;    private void OnLocationChanged(object? sender, LocationChangedEventArgs args)    {        // Мы могли бы просто всегда перерисовывать, но для этого компонента мы знаем, что        // единственное релевантное изменение состояния - свойство _isActive.        bool shouldBeActiveNow = ShouldMatch(args.Location);        if (shouldBeActiveNow != _isActive)        {            _isActive = shouldBeActiveNow;            UpdateCssClass();            StateHasChanged();        }    }    private bool ShouldMatch(string currentUriAbsolute)    {        if (_hrefAbsolute == null)            return false;        if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute))            return true;        return Match == NavLinkMatch.Prefix               && IsStrictlyPrefixWithSeparator(currentUriAbsolute, _hrefAbsolute);    }    private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)    {        Debug.Assert(_hrefAbsolute != null);        if (string.Equals(currentUriAbsolute, _hrefAbsolute,                          StringComparison.OrdinalIgnoreCase))            return true;        if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1)        {            // Частный случай: подсвечиваем ссылки на http://host/path/, даже если вы находитесь на http://host/path (без косой черты в конце)            //            // Это происходит потому, что маршрутизатор принимает абсолютное значение URI "то же            // самое, что базовый URI, но без косой черты в конце" как эквивалентно "базовому URI",            // что обусловлено тем, что серверы часто возвращают одну и ту же страницу            // для http://host/vdir так же, как для host://host/vdir/, поскольку            // нельзя выводить пустую страницу в этом случае.            if (_hrefAbsolute[^1] == '/'                && _hrefAbsolute.StartsWith(currentUriAbsolute,                    StringComparison.OrdinalIgnoreCase))                return true;        }        return false;    }    /// <inheritdoc/>    protected override void BuildRenderTree(RenderTreeBuilder builder)    {        builder.OpenElement(0, "a");        builder.AddMultipleAttributes(1, AdditionalAttributes);        builder.AddAttribute(2, "class", CssClass

Тесты

Тесты включают навигацию и обмен сообщениями.

Часть 2 - Преобразование существующего приложения

В то время, как репозиторий содержит базовый пример проекта, показывающий, как использовать библиотеку, я хотел включить пример, который принимает существующий проект для другого типа приложения и, с минимальными изменениями, заставляет его работать в Blazor. Так что я взял пример проекта Xamarin от Microsoft и преобразовал его в Blazor.

Изменения в примере Xamarin для Blazor

Проект MvvmSample.Core в основном остался неизменным, я добавил базовые классы в ViewModel для обновления привязок Blazor.

Так, в качестве примера, класс SamplePageViewModel был изменен следующим образом:

public class MyPageViewModel : ObservableObject{    // код здесь}

на:

public class MyPageViewModel : ViewModelBase{    // код здесь}

Класс ViewModelBase обертывает класс ObservableObject. Никаких других изменений не требуется.

Для страниц Xamarin привязка DataContext осуществляется с помощью:

BindingContext = Ioc.Default.GetRequiredService<MyPageViewModel>();

Вместе с Blazing.MVVM это просто:

@inherits MvvmComponentBase<MyPageViewModel>

Наконец, я обновил всю документацию, использованную в примере приложения, с Xamarin-специфического на Blazor. Если я упустил какие-либо изменения, пожалуйста, дайте мне знать, и я их обновлю.

Компоненты

Xamarin поставляется с богатым набором элементов управления. Blazor под этим отношением значительно ослаблен. Чтобы удержать этот проект в легком весе, я включил свои собственные элементы управления ListBox и Tab - наслаждайтесь! Когда у меня будет время, я постараюсь завершить и выпустить библиотеку элементов управления для Blazor.

Примеры гибридных приложений WASM + New WPF & Avalonia Blazor

Я добавил новые гибридные приложения WPF/Avalonia, чтобы показать, как вызывать Blazor из WPF/Avalonia с использованием MVVM. Для этого я:

  • Перенес базовые общие части из приложения BlazorSample в новую RCL (Razor Class Library)
  • Перенес ресурсы в стандартную папку Content, так как папка wwwroot больше недоступна. Контрол BlazorWebView использует IP-адрес 0.0.0.0, который недопустим для httpClient.
  • Добавил новый класс FileService в приложение WPF/Avalonia для использования класса File, а не HttpClient.
  • Добавил новый файл App.Razor в приложение WPF/Avalonia для настраиваемого макета Blazor и привязки общего состояния для обработки запросов навигации из WPF/Avalonia.
  • Для возможности вызова в Blazor-приложение я использовал статический класс состояния, чтобы хранить ссылку на классы NavigationManager и MvvvmNavigationManager.

Blazor Wasm Пример приложения

Так как я перенес основу Blazor-приложения в общий проект MvvmSampleBlazor.Core, нам нужно просто добавить ссылку.

Program.cs

Необходимо инициализировать и связать приложение вместе:

WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);builder.RootComponents.Add<App>("#app");builder.RootComponents.Add<HeadOutlet>("head::after");builder.Services    .AddScoped(sp => new HttpClient     { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) })    .AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))    .AddViewModels()    .AddServices()    .AddMvvmNavigation();#if DEBUGbuilder.Logging.SetMinimumLevel(LogLevel.Trace);#endifawait builder.Build().RunAsync();
App.razor

В следующем шаге нам нужно указать расположение страниц в app.razor:

<Router AppAssembly="@typeof(MvvmSampleBlazor.Core.Root).Assembly">    <Found Context="routeData">        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />        <FocusOnNavigate RouteData="@routeData" Selector="h1" />    </Found>    <NotFound>        <PageTitle>Not found</PageTitle>        <LayoutView Layout="@typeof(MainLayout)">            <p role="alert">Извините, по этому адресу ничего нет.</p>        </LayoutView>    </NotFound></Router>
NavMenu.razor

Наконец, свяжем Blazor-навигацию:

<div class="top-row ps-3 navbar navbar-dark">    <div class="container-fluid">        @*<a class="navbar-brand" href="">Blazor Mvvm Sample</a>*@        <button title="Меню навигации" class="navbar-toggler" @onclick="ToggleNavMenu">            <span class="navbar-toggler-icon"></span>        </button>    </div></div><div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">    <nav class="flex-column">        <div class="nav-item px-3">            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">                <i class="bi bi-play" aria-hidden="true"></i> Введение            </NavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=ObservableObjectPageViewModel>                <i class="bi bi-arrow-down-up" aria-hidden="true"></i> ObservableObject            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=RelayCommandPageViewModel>                <i class="bi bi-layer-backward" aria-hidden="true"></i> Команды Relay            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=AsyncRelayCommandPageViewModel>                <i class="bi bi-flag" aria-hidden="true"></i> Асинхронные команды            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=MessengerPageViewModel>                <i class="bi bi-chat-left" aria-hidden="true"></i> Messenger            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=MessengerSendPageViewModel>                <i class="bi bi-send" aria-hidden="true"></i> Отправка сообщений            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=MessengerRequestPageViewModel>                <i class="bi bi-arrow-left-right" aria-hidden="true"></i>                  Запрос сообщений            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=IocPageViewModel>                <i class="bi bi-box-arrow-in-down-right" aria-hidden="true"></i>                  Инверсия контроля            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=ISettingUpTheViewModelsPageViewModel>                <i class="bi bi-bounding-box" aria-hidden="true"></i> Настройка ViewModel            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=ISettingsServicePageViewModel>                <i class="bi bi-wrench" aria-hidden="true"></i> Сервис настроек            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=IRedditServicePageViewModel>                <i class="bi bi-globe-americas" aria-hidden="true"></i> Сервис Reddit            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=IBuildingTheUIPageViewModel>                <i class="bi bi-rulers" aria-hidden="true"></i> Создание интерфейса            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=IRedditBrowserPageViewModel>        <i class="bi bi-reddit" aria-hidden="true"></i> Окончательный результат            </MvvmNavLink>        </div>    </nav></div>
@code {
    private bool collapseNavMenu = true;
    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

Blazor Hybrid Apps - Гибридные приложения Blazor

We can embed a Blazor app into a standard Desktop or MAUI application. Following we will look at two examples - WPF and Avalonia. The same principles apply to WinForms and MAUI.

Класс AppState

Для гибридных приложений Blazor нам нужен способ общения между двумя фреймворками приложений. Этот класс действует как связь между нативным приложением и приложением Blazor. Он предоставляет навигацию по страницам.

public static class AppState{
    public static INavigation Navigation { get; set; } = null!;
}

Определение контракта для делегатов действий навигации:

public interface INavigation{
    void NavigateTo(string page);
    void NavigateTo() where TViewModel : IViewModelBase;
}

Wpf Blazor Гибридное приложение

Что, если мы хотим разместить приложение Blazor внутри нативного приложения Windows, гибридное приложение Blazor. Возможно, мы хотим использовать нативные элементы управления WPF с содержимым Blazor. В следующем примере показано, как это сделать.

MainWindow.Xaml

Теперь мы можем использовать элемент управления BlazorWebView для размещения приложения Blazor. Для навигации я использую элементы управления WPF Button. Я привязываю Button к записи из словаря, хранящейся в MainWindowViewModel.

<Window x:Class="MvvmSampleBlazor.Wpf.MainWindow"        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"        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"        xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;                      assembly=Microsoft.AspNetCore.Components.WebView.Wpf"        xmlns:shared="clr-namespace:MvvmSampleBlazor.Wpf.Shared"        Title="WPF MVVM Blazor Hybrid Sample Application"        Height="800" Width="1000" WindowStartupLocation="CenterScreen">    <Grid>        <Grid.RowDefinitions>            <RowDefinition />            <RowDefinition Height="Auto"/>        </Grid.RowDefinitions>        <Grid.ColumnDefinitions>            <ColumnDefinition Width="Auto"/>            <ColumnDefinition/>        </Grid.ColumnDefinitions>        <ItemsControl x:Name="ButtonsList"                      Grid.Column="0" Grid.Row="0" Padding="20"                      ItemsSource="{Binding NavigationActions}">            <ItemsControl.ItemTemplate>                <DataTemplate>                    <Button Content="{Binding Value.Title}" Padding="10 5"                             Margin="0 0 0 10"                            Command="{Binding ElementName=ButtonsList,                                     Path=DataContext.NavigateToCommand}"                            CommandParameter="{Binding Key}"/>                </DataTemplate>            </ItemsControl.ItemTemplate>        </ItemsControl>        <blazor:BlazorWebView Grid.Column="1" Grid.Row="0"                              HostPage="wwwroot\index.html"                              Services="{DynamicResource services}">            <blazor:BlazorWebView.RootComponents>                <blazor:RootComponent Selector="#app"                  ComponentType="{x:Type shared:App}" />            </blazor:BlazorWebView.RootComponents>        </blazor:BlazorWebView>        <TextBlock Grid.Row="1"  Grid.ColumnSpan="2"                   HorizontalAlignment="Stretch"                   TextAlignment="Center"                   Padding="0 10"                   Background="LightGray"                   FontWeight="Bold"                   Text="Click on the BlazorWebView control, then CTRL-SHIFT-I or                         F12 to open the Browser DevTools window..." />    </Grid></Window>
Класс MainWindowViewModel

Этот класс определяет и управляет навигацией командами через класс AppState. Когда команда выполняется, выполняется быстрый поиск и соответствующее действие - не требуется switch или логика if ... else.

внутренний класс MainWindowViewModel : ViewModelBase {    public MainWindowViewModel()        => NavigateToCommand = new RelayCommand<string>(arg =>            NavigationActions[arg!].Action.Invoke());    public IRelayCommand<string> NavigateToCommand { get; set; }    public Dictionary<string, NavigationAction> NavigationActions { get; } = new()    {        ["home"] = new("Introduction", () => NavigateTo("/")),        ["observeObj"] = new("ObservableObject", NavigateTo<ObservableObjectPageViewModel>),        ["relayCommand"] = new("Relay Commands", NavigateTo<RelayCommandPageViewModel>),        ["asyncCommand"] = new("Async Commands", NavigateTo<AsyncRelayCommandPageViewModel>),        ["msg"] = new("Messenger", NavigateTo<MessengerPageViewModel>),        ["sendMsg"] = new("Sending Messages", NavigateTo<MessengerSendPageViewModel>),        ["ReqMsg"] = new("Request Messages", NavigateTo<MessengerRequestPageViewModel>),        ["ioc"] = new("Inversion of Control", NavigateTo<IocPageViewModel>),        ["vmSetup"] = new("ViewModel Setup", NavigateTo<ISettingUpTheViewModelsPageViewModel>),        ["SettingsSvc"] = new("Settings Service", NavigateTo<ISettingsServicePageViewModel>),        ["redditSvc"] = new("Reddit Service", NavigateTo<IRedditServicePageViewModel>),        ["buildUI"] = new("Building the UI", NavigateTo<IBuildingTheUIPageViewModel>),        ["reddit"] = new("The Final Result", NavigateTo<IRedditBrowserPageViewModel>),    };    private static void NavigateTo(string url)        => AppState.Navigation.NavigateTo(url);    private static void NavigateTo<TViewModel>() where TViewModel : IViewModelBase        => AppState.Navigation.NavigateTo<TViewModel>();}

Оберточный класс записи:

public record NavigationAction(string Title, Action Action);
App.razor

Нам нужно раскрыть навигацию от Blazor в нативное приложение.

@inject NavigationManager NavManager@inject IMvvmNavigationManager MvvmNavManager@implements MvvmSampleBlazor.Wpf.States.INavigation<Router AppAssembly="@typeof(Core.Root).Assembly">    <Found Context="routeData">        <RouteView RouteData="@routeData" DefaultLayout="@typeof(NewMainLayout)" />        <FocusOnNavigate RouteData="@routeData" Selector="h1" />    </Found>    <NotFound>        <PageTitle>Не найдено</PageTitle>        <LayoutView Layout="@typeof(NewMainLayout)">            <p role="alert">Извините, по этому адресу ничего нет.</p>        </LayoutView>    </NotFound></Router>@code{    protected override void OnInitialized()    {        AppState.Navigation = this;        base.OnInitialized();        // принудительное обновление, чтобы преодолеть неинициализированную веб-навигацию для гибридных приложений        MvvmNavManager.ForceNavigationManagerUpdate(NavManager);    }    public void NavigateTo(string page)        => NavManager.NavigateTo(page);    public void NavigateTo<TViewModel>() where TViewModel : IViewModelBase        => MvvmNavManager.NavigateTo<TViewModel>(new NavigationOptions());}

Примечание: Из-за особенностей элемента управления BlazorWebView и навигации с использованием IOC с помощью MvvmNavigationManager, возникнет следующее исключение:

System.InvalidOperationException: ''WebViewNavigationManager' не инициализирован.'

Для преодоления этой проблемы нам нужно обновить внутреннюю ссылку NavigationManager в классе MvvmNavigationManager. Я использую отражение для этого:

public static class NavigationManagerExtensions{    public static void ForceNavigationManagerUpdate(        this IMvvmNavigationManager mvvmNavManager, NavigationManager navManager)    {        FieldInfo? prop = mvvmNavManager.GetType().GetField("_navigationManager",            BindingFlags.NonPublic | BindingFlags.Instance);        prop!.SetValue(mvvmNavManager, navManager);    }}
App.xaml.cs

Наконец, нам нужно все это соединить:

public partial class App{    public App()    {       HostApplicationBuilder builder = Host.CreateApplicationBuilder();        IServiceCollection services = builder.Services;        services.AddWpfBlazorWebView();#if DEBUG        builder.Services.AddBlazorWebViewDeveloperTools();#endif        services            .AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))            .AddViewModels()            .AddServicesWpf()            .AddMvvmNavigation(options =>            {                 options.HostingModel = BlazorHostingModel.Hybrid;            });#if DEBUG        builder.Logging.SetMinimumLevel(LogLevel.Trace);#endif        services.AddScoped<MainWindow>();        Resources.Add("services", services.BuildServiceProvider());        // will throw an error        //MainWindow = provider.GetRequiredService<MainWindow>();        //MainWindow.Show();    }}

Приложение Avalonia (только для Windows) Blazor Hybrid

Для Avalonia нам понадобится обертка для контрола BlazorWebView. К счастью, есть сторонний класс: Baksteen.Avalonia.Blazor - репозиторий GitHub. Я включил этот класс, так как нам нужно обновить его для поддержки последних изменений в библиотеках поддержки.

MainWindow.xaml

Работает так же, как версия WPF, но мы используем обертку Baksteen для контрола BlazorWebView.

<Window    x:Class="MvvmSampleBlazor.Avalonia.MainWindow"    xmlns="https://github.com/avaloniaui"    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"    xmlns:blazor="clr-namespace:Baksteen.Avalonia.Blazor;assembly=Baksteen.Avalonia.Blazor"    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"    xmlns:vm="clr-namespace:MvvmSampleBlazor.Avalonia.ViewModels"    Height="800" Width="1200"  d:DesignHeight="500" d:DesignWidth="800"    x:DataType="vm:MainWindowViewModel"    Title="Avalonia MVVM Blazor Hybrid Пример Приложения" Background="DarkGray"    CanResize="True" SizeToContent="Manual" mc:Ignorable="d">    <Design.DataContext>        <vm:MainWindowViewModel />    </Design.DataContext>    <Grid>        <Grid.RowDefinitions>            <RowDefinition />            <RowDefinition Height="Auto"/>        </Grid.RowDefinitions>        <Grid.ColumnDefinitions>            <ColumnDefinition Width="Auto"/>            <ColumnDefinition/>        </Grid.ColumnDefinitions>        <ItemsControl x:Name="ButtonsList"                      Grid.Column="0" Grid.Row="0" Padding="20"                      ItemsSource="{Binding NavigationActions}">            <ItemsControl.ItemTemplate>                <DataTemplate>                    <Button Content="{Binding Value.Title}"                            Padding="10 5" Margin="0 0 0 10"                            HorizontalAlignment="Stretch"                             HorizontalContentAlignment="Center"                            Command="{Binding ElementName=ButtonsList,                                      Path=DataContext.NavigateToCommand}"                            CommandParameter="{Binding Key}"/>                </DataTemplate>            </ItemsControl.ItemTemplate>        </ItemsControl>        <blazor:BlazorWebView Grid.Column="1" Grid.Row="0"                              HostPage="index.html"                              RootComponents="{DynamicResource rootComponents}"                              Services="{DynamicResource services}" />        <Label Grid.Row="1"  Grid.ColumnSpan="2"               HorizontalAlignment="Center"               Padding="0 10"               Foreground="Black"               FontWeight="Bold"               Content="Нажмите на контрол BlazorWebView, затем CTRL-SHIFT-I или                        F12, чтобы открыть окно инструментов разработчика в браузере.." />    </Grid></Window>
MainWindow.Axaml.cs

Теперь мы можем подключить контрол в коде:

public partial class MainWindow : Window{    public MainWindow()    {        IServiceProvider? services = (Application.Current as App)?.Services;        RootComponentsCollection rootComponents =             new() { new("#app", typeof(HybridApp), null) };        Resources.Add("services", services);        Resources.Add("rootComponents", rootComponents);        InitializeComponent();    }}
HybridApp.razor

Нам нужно открыть навигацию из Blazor в native приложение.

Примечание: Мы используем другое имя для app.razor для решения проблем с путями/папками и именованием.

@inject NavigationManager NavManager@inject IMvvmNavigationManager MvvmNavManager@implements MvvmSampleBlazor.Wpf.States.INavigation<Router AppAssembly="@typeof(Core.Root).Assembly">    <Found Context="routeData">        <RouteView RouteData="@routeData" DefaultLayout="@typeof(NewMainLayout)" />        <FocusOnNavigate RouteData="@routeData" Selector="h1" />    </Found>    <NotFound>        <PageTitle>Not found</PageTitle>        <LayoutView Layout="@typeof(NewMainLayout)">            <p role="alert">Sorry, there's nothing at this address.</p>        </LayoutView>    </NotFound></Router>@code{    protected override void OnInitialized()    {        AppState.Navigation = this;        base.OnInitialized();        // force refresh to overcome Hybrid app not initializing WebNavigation        MvvmNavManager.ForceNavigationManagerUpdate(NavManager);    }    public void NavigateTo(string page)        => NavManager.NavigateTo(page);    public void NavigateTo<TViewModel>() where TViewModel : IViewModelBase        => MvvmNavManager.NavigateTo<TViewModel>(new NavigationOptions());}

Примечание: Avalonia имеет ту же особенность, что и WPF, поэтому используется та же затычка.

Program.cs

Наконец, нам нужно все это связать:

internal class Program{    [STAThread]    public static void Main(string[] args)    {        HostApplicationBuilder appBuilder = Host.CreateApplicationBuilder(args);        appBuilder.Logging.AddDebug();                appBuilder.Services.AddWindowsFormsBlazorWebView();#if DEBUG        appBuilder.Services.AddBlazorWebViewDeveloperTools();#endif        appBuilder.Services            .AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))            .AddViewModels()            .AddServicesWpf()            .AddMvvmNavigation(options =>            {                 options.HostingModel = BlazorHostingModel.Hybrid;            });        using IHost host = appBuilder.Build();        host.Start();        try        {            BuildAvaloniaApp(host.Services)                .StartWithClassicDesktopLifetime(args);        }        finally        {            Task.Run(async () => await host.StopAsync()).Wait();        }    }    private static AppBuilder BuildAvaloniaApp(IServiceProvider serviceProvider)        => AppBuilder.Configure(() => new App(serviceProvider))            .UsePlatformDetect()            .LogToTrace();}

Бонусы Blazor Components (Controls)

При создании образца Blazor-приложения мне понадобились компоненты TabControl и ListBox (управляющие элементы) для Blazor. Так что я написал свои собственные. Эти компоненты можно найти в их собственных проектах решения и использовать в своем собственном проекте. Оба компонента поддерживают навигацию с помощью клавиатуры.

Использование TabControl

<TabControl>    <Panels>        <TabPanel Title="Интерактивный пример">            <div class="posts__container">                <SubredditWidget />                <PostWidget />            </div>        </TabPanel>        <TabPanel Title="Razor">            @StaticStrings.RedditBrowser.sample1Razor.MarkDownToMarkUp()        </TabPanel>        <TabPanel Title="C#">            @StaticStrings.RedditBrowser.sample1csharp.MarkDownToMarkUp()        </TabPanel>    </Panels></TabControl>

Выше приведен код примера браузера Reddit.

Использование элемента управления ListBox

<ListBox TItem=Post ItemSource="ViewModel!.Posts"         [email protected]         SelectionChanged="@(e => InvokeAsync(() => ViewModel.SelectedPost = e.Item))">    <ItemTemplate Context="post">        <div class="list-post">            <h3 class="list-post__title">@post.Title</h3>            @if (post.Thumbnail is not null && post.Thumbnail != "self")            {                <img src="@post.Thumbnail"                     onerror="this.onerror=null; this.style='display:none';"                     alt="@post.Title" class="list-post__image" />            }        </div>    </ItemTemplate></ListBox>

Свойства и события:

  • TItem - тип каждого элемента. Установка этого типа позволяет использовать сильно типизированный ItemTemplate
  • ItemSource - указывает на коллекцию типа TItem
  • SelectedItem - устанавливает начальный элемент TItem
  • SelectionChanged - событие, которое возникает при выборе элемента

Вышеуказанный код является частью компонента SubredditWidget, который отображает список заголовков и изображений (если они существуют) для определенного subreddit'а.

Литература

Резюме

У нас есть простая в использовании библиотека Blazor MVVM - Blazing.MVVM, которая поддерживает все необходимые функции, включая поддержку генератора исходного кода. Мы также исследовали преобразование существующего приложения-примера из Xamarin Community Toolkit в приложение Blazor WASM, а также в гибридные приложения WPF и Avalonia. Если вы уже используете Mvvm Community Toolkit, то использование его в Blazor очевидно. Если вы уже знакомы с MVVM, то использование Blazing.MVVM в своем проекте не вызывает трудностей. Если вы используете Blazor, но не знакомы с MVVM, но хотите попробовать, вы можете использовать уже существующую документацию, блоги, Quick Answers Code Project, поддержку на StackOverflow и т. д., чтобы изучить опыт разработки с использованием других фреймворков и применить его в Blazor с использованием библиотеки Blazing.MVVM.

История версий

  • 30 июля 2023 г. - v1.0 - Первый релиз
  • 9 октября 2023 г. - v1.1 - Добавлен MvvmLayoutComponentBase для поддержки MVVM в MainLayout.razor с обновленным примером проекта
  • 1 ноября 2023 г. - v1.2 - Добавлена поддержка .NET 7.0+ Blazor Server App; добавлена поддержка новой модели размещения; предварительный выпуск .NET 8.0 RC2 (Auto) Blazor WebApp;
  • 21 ноября 2023 г. - v1.4 - Обновлено до .NET 8.0 + образец проекта Blazor Web App, поддерживающий автоматический режим; обновлен раздел Начало работы для Blazor Web Apps .NET 8.0

Leave a Reply

Your email address will not be published. Required fields are marked *