‘Blazing.Mvvm – 使用 Mvvm 社区工具包的 Blazor 服务器、WebAssembly 和混合模式’

通过使用微软社区工具包中的Blazing.Mvvm库,简化MVVM

目录

概述

MVVM对于开发Blazor应用程序是非必需的。绑定系统比其他应用程序框架(如WinForms和WPF)更简单。

然而,MVVM模式具有许多好处,如逻辑与视图的分离、可测试性、降低风险和协作。

有许多支持Blazor的MVVM设计模式的库,它们并不是最简单的使用方法。与此同时,CommunityToolkit.Mvvm支持WPF、Xamarin和MAUI应用程序框架。

为什么不是Blazor?本文介绍了一个适用于Blazor的MVVM实现,它使用了通过Blazing.MVVM库的CommunityToolkit.Mvvm。如果您熟悉CommunityToolkit.Mvvm,那么您已经知道如何使用此实现。

Image 1

下载

源代码(通过GitHub)

** 如果您发现该库有用,请在Github库上给一个星。

Nuget:

第1部分 – Blazing.Mvvm库

这是一个扩展的blazor-mvvm库,由Kelly Adams实现了完整的MVVM支持,通过CommunityToolkit.Mvvm。进行了一些较小的更改,以避免跨线程异常,添加了额外的基类类型,Mvvm风格的导航,并转换为可用库。

入门

  1. Blazing.Mvvm Nuget包添加到您的项目中。

  2. 在Program.cs文件中启用MvvmNavigation支持:

    • Blazor服务器应用程序:

      builder.Services.AddMvvmNavigation(options =>{     options.HostingModel = BlazorHostingModel.Server;}); 
    • Blazor WebAssembly应用程序:

      builder.Services.AddMvvmNavigation();
    • Blazor Web应用程序(.NET 8.0中的新功能)

      builder.Services.AddMvvmNavigation(options =>{     options.HostingModel = BlazorHostingModel.WebApp;});  
    • Blazor混合应用程序(WinForm,WPF,Avalonia,MAUI):

      builder.Services.AddMvvmNavigation(options =>{     options.HostingModel = BlazorHostingModel.Hybrid;});  
  3. 创建一个继承ViewModelBase类的ViewModel
    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 =    {        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm",         "Balmy", "Hot", "Sweltering", "Scorching"    };    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. 在Program.cs文件中注册ViewModel
    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>温度(摄氏度)</th>                <th>温度(华氏度)</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>

现在运行应用程序。

通过在代码中使用ViewModelMvvmNavigationManager进行导航,将该类注入到页面或ViewModel中,然后使用NavigateTo方法:

mvvmNavigationManager.NavigateTo<FetchDataViewModel>();

NavigateTo方法与标准的Blazor NavigationManager相同,并且还支持相对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>测试    </MvvmNavLink></div><div class="nav-item px-3">    <MvvmNavLink class="nav-link"                 TViewModel=ITestNavigationViewModel                 RelativeUri="这是一个MvvmNavLink测试"                 Match="NavLinkMatch.All">        <span class="oi oi-calculator" aria-hidden="true"></span>测试 + 参数    </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>测试 + 查询字符串    </MvvmNavLink></div><div class="nav-item px-3">    <MvvmNavLink class="nav-link"                 TViewModel=ITestNavigationViewModel                 RelativeUri="这是一个MvvmNvLink测试/                     test=this%20is%20a%20MvvmNavLink%20querystring%20test"                 Match="NavLinkMatch.All">        <span class="oi oi-calculator" aria-hidden="true"></span>测试 + 参数和查询字符串    </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();}

这里是包装ObservableObjectViewModelBase类:

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监听ViewModelBase实现中的PropertyChanged事件,当ViewModel按调用了NotifyStateChanged时,MvvmComponentBase会自动刷新UI。

EditForm验证和消息处理也被支持。请参考示例代码,了解如何在大多数用例中使用。

MVVM导航的工作原理

不再需要魔术字符串!现在可以使用强类型导航。如果页面URI发生更改,就无需在源代码中寻找更改位置。它会在运行时自动解析!

MvvmNavigationManager类

MvvmNavigationManager以Singleton模式由IOC容器初始化时,该类会检查所有程序集并在内部缓存所有ViewModel(类和接口)及其关联的页面。然后,在导航时进行快速查找,并使用Blazor的NavigationManager导航到正确的页面。如果通过NavigateTo方法传递了任何相对URI和/或QueryString,也会一同传递。

注意MvvmNavigationManager类不是Blazor NavigationManager类的完全替代品,仅实现了对MVVM的支持。对于标准的”魔术字符串”导航,请使用NavigationManager类。

/// <summary>/// 通过ViewModel (class/interface)提供查询和管理导航的抽象。/// </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">要用于确定要导航到的    ///  URI的类型<see cref="IViewModelBase"/>。</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">要用于确定要导航到的    ///  URI的类型<see cref="IViewModelBase"/>。</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">要用于确定要导航到的URI的类型<see cref="IViewModelBase"/>。</typeparam>    /// <param name="relativeUri">追加到导航URI的相对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 : IViewModelBase    {        if (!_references.TryGetValue(typeof(TViewModel), out string? uri))            throw new ArgumentException($"{typeof(TViewModel)}没有关联的页面");        uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);        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">要用于确定要导航到的URI的类型    /// <see cref="IViewModelBase"/>。</typeparam>    /// <param name="relativeUri">追加到导航URI的相对URI和/或查询字符串。

注意:如果启用Debug级别的日志记录,MvvmNavigationManager将在构建缓存时输出生成的关联。例如:

dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]      开始生成新的引用缓存dbug: 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组件基于Blazor的Navlink组件,并具有额外的TViewModelRelativeUri属性。内部使用MvvmNavigationManager进行导航。

/// <summary>/// 渲染锚点标记的组件,根据当前URI的匹配情况自动切换其'active'/// class。导航基于视图模型(class/interface)。/// </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>    /// 获取或设置NavLink在当前路由与NavLink href匹配时应用的CSS类名。    /// </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)        {            // 特殊情况:即使当前URL没有尾部斜杠,也会突出显示到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);        builder.AddContent(3, ChildContent);        builder.CloseElement();    }    private static string CombineWithSpace(string? str1, string str2)        => str1 == null ? str2 : $"{str1} {str2}";    private static bool IsStrictlyPrefixWithSeparator(string value, string prefix)    {        int prefixLength = prefix.Length;        if (value.Length > prefixLength)        {            return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)                && (                    // 仅当在前缀结尾处或紧随其后有分隔符字符时才匹配。                    // 示例:“/abc”将作为“/abc/def”的前缀匹配,但不是“/abcdef”的前缀匹配。                    // 示例:“/abc/”将作为“/abc/def”的前缀匹配,但不是“/abcdef”的前缀匹配。                    prefixLength == 0                    || !char.IsLetterOrDigit(prefix[prefixLength - 1])                    || !char.IsLetterOrDigit(value[prefixLength])                );        }        return false;    }}

测试

测试包括导航和消息。

第二部分 - 转换现有应用程序

虽然repo包含一个基本示例项目,展示如何使用该库,但我想包含一个示例,它需要一个不同类型的现有项目,并通过最小的更改使其适用于Blazor。因此,我已经取了微软的Xamarin示例项目,并将其转换为Blazor。

针对Blazor对Xamarin示例所做的更改

MvvmSample.Core项目基本上保持不变,我添加了一些基类到ViewModel,以启用Blazor绑定的更新。

所以,举个例子,SamplePageViewModel改变为:

public class MyPageViewModel : ObservableObject{    // code goes here}

到:

public class MyPageViewModel : ViewModelBase{    // code goes here}

ViewModelBase包装了ObservableObject类。不需要做其他更改。

对于Xamarin页面,将DataContext连接起来的方法如下:

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

而使用Blazing.MVVM,就是:

@inherits MvvmComponentBase<MyPageViewModel>

最后,我已经更新了示例应用程序中使用的所有文档,从Xamarin特定的到Blazor。如果我漏掉了任何更改,请告诉我,我会进行更新。

组件

Xamarin带有一套丰富的控件。Blazor相比之下则较为精简。为了保持这个项目的精简,我包含了自己的ListBoxTab控件 - 尽情享用!在有时间的时候,我会努力完成并发布一个适用于Blazor的控件库。

WASM + 新的WPF & Avalonia Blazor混合示例应用程序

我添加了新的WPF/Avalonia混合应用程序,用于演示如何从WPF/Avalonia调用Blazor,使用MVVM。为此,我做了以下操作:

  • 将核心共享部分从BlazorSample应用程序移到一个新的RCL(Razor类库)中
  • 将资源移到标准的Content文件夹中,因为wwwroot不再可访问。 BlazorWebView主控件使用无效的IP地址0.0.0.0,因为这对于httpClient是无效的。
  • 为WPF/Avalonia应用程序添加了一个新的FileService类,用于使用File类而不是HttpClient
  • 为WPF/Avalonia应用程序添加一个新的App.Razor,用于自定义Blazor布局,并连接共享状态以处理来自WPF/Avalonia的导航请求。
  • 为了使调用Blazor应用程序变得可能,我使用了一个static状态类来保存对NavigationManagerMvvvmNavigationManager类的引用。

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>找不到</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 示例</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> 可观察对象            </MvvmNavLink>        </div>        <div class="nav-item px-3">            <MvvmNavLink class="nav-link" TViewModel=RelayCommandPageViewModel>                <i class="bi bi-layer-backward" aria-hidden="true"></i> 延迟命令            </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> 消息传递器            </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> 视图模型设置            </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

我们可以将 Blazor 应用程序嵌入到标准的桌面应用程序或 MAUI 应用程序中。接下来我们将看两个例子 - WPF 和 Avalonia。相同的原则适用于 WinForms 和 MAUI。

AppState 类

对于混合应用的 Blazor,我们需要一种在两个应用程序框架之间进行通信的方法。这个类作为原生应用程序与 Blazor 应用程序之间的链接。它公开了页面导航。

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

页面导航操作的合同定义:

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

WPF Blazor 混合应用

如果我们想在原生 Windows 应用程序中托管 Blazor 应用程序,即混合型 Blazor 应用程序。也许我们想要在 Blazor 内容中使用原生的 WPF 控件。下面的示例应用程序将展示如何实现这一点。

MainWindow.Xaml

现在我们可以使用 BlazorWebView 控件来托管 Blazor 应用程序。对于导航,我使用 WPF 的 Button 控件。我将 Button 绑定到 MainWindowViewModel 中保存的 Dictionary 条目。

<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="点击 BlazorWebView 控件,然后按 CTRL-SHIFT-I 或 F12 打开浏览器的开发者工具窗口..." />    </Grid></Window>
MainWindowViewModel 类

这个类通过 AppState 类定义和管理命令导航。当执行命令时,将进行快速查找并执行关联的操作 - 不需要 switchif ... 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>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();        // 强制刷新以克服 Hybrid app 未初始化 WebNavigation        MvvmNavManager.ForceNavigationManagerUpdate(NavManager);    }    public void NavigateTo(string page)        => NavManager.NavigateTo(page);    public void NavigateTo<TViewModel>() where TViewModel : IViewModelBase        => MvvmNavManager.NavigateTo<TViewModel>(new NavigationOptions());}

注意:由于 BlazorWebView 控制和使用 MvvmNavigationManager 进行 IOC 导航的限制,会抛出以下异常:

System.InvalidOperationException: ''WebViewNavigationManager' has not been initialized.'

为了克服这个问题,我们需要刷新 MvvmNavigationManager 类中内部的 NavigationManager 引用。我使用反射来实现这个目的:

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混合应用程序

对于Avalonia,我们将需要一个包装器来包装BlazorWebView控件。幸运的是,有一个第三方类:Baksteen.Avalonia.Blazor - Github Repo。我已经包含了这个类,因为我们需要为最新的支持库破坏性变化进行更新。

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混合示例应用程序" 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中的Navigation暴露给本机应用程序。

注意:我们使用了不同的名称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>未找到</PageTitle>        <LayoutView Layout="@typeof(NewMainLayout)">            <p role="alert">对不起,请检查地址是否正确。</p>        </LayoutView>    </NotFound></Router>@code{    protected override void OnInitialized()    {        AppState.Navigation = this;        base.OnInitialized();        // 强制刷新以克服混合应用程序未初始化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();}

Bonuses Blazor组件(控件)

在构建Blazor示例应用程序时,我需要一个TabControl和一个ListBox组件(控件)。所以我自己开发了这些组件。您可以在解决方案中的它们自己的项目中找到并在您自己的项目中使用它们。有一个支持库用于通用代码。这两个组件都支持键盘导航。

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具有强类型的Context
  • ItemSource指向类型为TItemCollection
  • SelectedItem用于设置初始TItem
  • SelectionChanged事件在选择项目时触发。

上述代码是用于显示特定subreddit的标题和图像(如果存在)的SubredditWidget组件的一部分。

参考资料

摘要

我们有一个简单易用的Blazor MVVM库,称为Blazing.MVVM,支持所有功能,包括源代码生成器支持。我们还探讨了将现有的Xamarin Community Toolkit示例应用程序转换为Blazor WASM应用程序,以及WPF和Avalonia混合应用程序。如果你已经在使用Mvvm Community Toolkit,那么在Blazor中使用它是很明显的选择。如果你已经熟悉MVVM,那么在你自己的项目中使用Blazing.MVVM应该很简单。如果你正在使用Blazor,但还没有使用MVVM,但想要使用,可以使用现有的文档、博客文章、Code Project的快速答案StackOverflow支持等学习其他应用程序框架,并将其应用到使用Blazing.MVVM库的Blazor中。

历史

  • 2023年7月30日 - v1.0 - 初始版本发布
  • 2023年10月9日 - v1.1 - 添加了MvvmLayoutComponentBase以支持在MainLayout.razor中使用MVVM的更新示例项目
  • 2023年11月1日 - v1.2 - 添加了.NET 7.0+ Blazor Server App支持;新增了托管模型配置支持;.NET 8.0 RC2的预发布版本(Auto) Blazor WebApp;
  • 2023年11月21日 - v1.4 - 更新为.NET 8.0 + 支持自动模式的示例Blazor Web App项目;更新了入门部分,适用于.NET 8.0 Blazor Web Apps

Leave a Reply

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