Blazing.Mvvm——使用Mvvm社区工具包的Blazor Server、WebAssembly和Hybrid

目录

概述

下载

第1部分——Blazing.Mvvm库

开始

MVVM的工作原理

MVVM导航的工作原理

MvvmNavigationManager类

MvvmNavLink组件

测试

第2部分——转换现有应用程序

对适用于Blazor的Xamarin示例所做的更改

组件

WASM+新的WPF和Avalonia Blazor Hybrid示例应用程序

Blazor Wasm示例应用

Program.cs

App.razor

Blazor Hybrid应用

AppState类

Wpf Blazor Hybrid应用

MainWindow.Xaml

MainWindowViewModel类

App.razor

App.xaml.cs

Avalonia(仅限Windows)Blazor Hybrid应用

MainWindow.xaml

MainWindow.Axaml.cs

HybridApp.razor

Program.cs

附加组件Blazor组件(控件)

TabControl用法

ListBox控件用法

引用

总结


概述

开发Blazor应用不需要MVVM。绑定系统比其他应用程序框架(如WinFormsWPF)更简单。

但是,MVVM模式有很多好处,例如逻辑与视图的分离、可测试性。降低风险和协作。

Blazor有多个库尝试支持MVVM设计模式,但这些库并不是最容易使用的。同时,还有支持WPFXamarinMAUI应用程序框架的CommunityToolkit.Mvvm

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

下载

源代码(通过GitHub

** 如果你觉得这个库有用,请给 Github存储库一个star

Nuget

1部分——Blazing.Mvvm

这是Kelly Adamsblazor-mvvm存储库的扩展,它通过CommunityToolkit.Mvvm实现完整的MVVM支持。为了防止跨线程异常,进行了一些小的更改,添加了额外的基类类型、Mvvm样式的导航,并转换为可用的库。

开始

1、将Blazing.Mvvm Nuget包添加到项目中。

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

  • Blazor Server App:

builder.Services.AddMvvmNavigation(options =>
{ 
    options.HostingModel = BlazorHostingModel.Server;
}); 

  • Blazor WebAssembly App:

builder.Services.AddMvvmNavigation();

  • Blazor Web 应用(.NET 8.0新增功能)Blazor Web App (new to .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、创建一个继承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>Weather forecast</PageTitle>

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (!ViewModel.WeatherForecasts.Any())
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</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> Fetch data
    </MvvmNavLink>
</div>

现在运行应用程序。

通过使用来自代码MvvmNavigationManagerViewModel进行导航,将类注入到页面或ViewModel中,然后使用以下NavigateTo方法:

mvvmNavigationManager.NavigateTo<FetchDataViewModel>();

NavigateTo方法的工作方式与标准Blazor NavigationManager相同,还支持传递相对URL/QueryString

如果你喜欢抽象,那么你也可以按界面导航:

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()
    {
        // Cause changes to the ViewModel to make Blazor re-render
        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事件时,当ViewModelBase实现上的属性发生更改或NotifyStateChanged被调用时,MvvmComponentBase会自动处理UI的刷新。

EditForm还支持验证和消息传递。有关如何用于大多数用例的示例,请参阅示例代码。

MVVM导航的工作原理

不再有魔法字符串!现在可以进行强类型导航。如果页面URI发生更改,则不再需要搜索源代码进行更改。它在运行时自动神奇地为您解析!

MvvmNavigationManager

MvvmNavigationManagerIOC容器初始化为Singleton时,该类将检查所有程序集,并在内部缓存所有ViewModel(类和接口)及其关联的页面。然后,当需要导航时,将完成快速查找,然后使用Blazor NavigationManager导航到正确的页面。如果通过NavigateTo方法调用传入了任何相对Uri &/or QueryString,则也会传递。

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

/// <summary>
/// Provides an abstraction for querying and managing navigation via 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>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine the
    ///  URI to navigate to.</typeparam>
    /// <param name="forceLoad">If true, bypasses client-side routing 
    /// and forces the browser to load
    ///  the new page from the server, whether or not the URI would normally 
    /// be handled by the client-side router.</param>
    /// <param name="replace">If true, replaces the current entry in the history stack.
    /// If false,
    ///  appends the new entry to the history stack.</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)} has no associated page");

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
            to uri '{uri}'");

        _navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> to use to
    ///  determine the URI to navigate to.</typeparam>
    /// <param name="options">Provides additional <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)} has no associated page");

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
                             to uri '{uri}'");

        _navigationManager.NavigateTo(uri, options);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine
    ///  the URI to navigate to.</typeparam>
    /// <param name="relativeUri">relative URI &/or QueryString appended to 
    ///  the navigation Uri
    ///  .</param>
    /// <param name="forceLoad">If true, bypasses client-side routing and 
    /// forces the browser to load
    ///  the new page from the server, whether or not the URI would normally 
    ///  be handled by the client-side router.</param> 
    /// <param name="replace">If true, replaces the current entry 
    /// in the history stack. If false,
    ///  appends the new entry to the history stack.</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)} has no associated page");

        uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
                             to uri '{uri}'");

        _navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine
    ///  the URI to navigate to.</typeparam>
    /// <param name="relativeUri">relative URI &/or QueryString appended 
    /// to the navigation Uri.</param>
    /// <param name="options">Provides additional 
    /// <see cref="NavigationOptions"/>.</param>
    public void NavigateTo<TViewModel>(string relativeUri, NavigationOptions options)
        where TViewModel : IViewModelBase
    {
        if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
            throw new ArgumentException($"{typeof(TViewModel)} has no associated page");

        uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
                             to uri '{uri}'");

        _navigationManager.NavigateTo(uri, options);
    }

    /// <summary>
    /// Get the <see cref="IViewModelBase"/> associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    ///  to use to determine the URI to navigate to.</typeparam>
    /// <returns>A relative URI path.</returns>
    /// <exception cref="ArgumentException"></exception>
    public string GetUri<TViewModel>()
        where TViewModel : IViewModelBase
    {
        if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
            throw new ArgumentException($"{typeof(TViewModel)} has no associated page");

        return uri;
    }

    #region Internals

    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 GenerateReferenceCache()
    {
        Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug("Starting generation of a new Reference Cache");

        foreach (Assembly assembly in assemblies)
        {
            List<(Type Type, Type? Argument)> items;

            try
            {
                items = assembly
                    .GetTypes()
                    .Select(GetViewArgumentType)
                    .Where(t => t.Argument is not null)
                    .ToList();
            }
            catch (Exception)
            {
                // avoid issue with unit tests
                continue;
            }

            // does the assembly contain the required types?
            if (!items.Any())
                continue;

            foreach ((Type Type, Type? Argument) item in items)
            {
                Attribute? attribute = item.Type.GetCustomAttributes()
                                           .FirstOrDefault(a => a is RouteAttribute);

                // is this a page or a component?
                if (attribute is null)
                    continue;

                // we have a page, let's reference it!
                string uri = ((RouteAttribute)attribute).Template;
                _references.Add(item.Argument!, uri);

                if (_logger.IsEnabled(LogLevel.Debug))
                    _logger.LogDebug($"Caching navigation reference 
                                     '{item.Argument!}' with
                                      uri '{uri}' for '{item.Type.FullName}'");
            }
        }

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug("Completed generating the Reference Cache");
    }

    private static (Type Type, Type? Argument) GetViewArgumentType(Type type)
    {
        Type viewInterfaceType = typeof(IView<>);
        Type viewModelType = typeof(IViewModelBase);
        Type ComponentBaseGenericType = typeof(MvvmComponentBase<>);
        Type? ComponentBaseType = null;
        Type? typeArgument = null;

        // Find the generic type definition for MvvmComponentBase<> 
        // with the correct type argument
        foreach (Type interfaceType in type.GetInterfaces())
        {
            if (!interfaceType.IsGenericType ||
                interfaceType.GetGenericTypeDefinition() != viewInterfaceType)
                continue;

            typeArgument = interfaceType.GetGenericArguments()[0];
            ComponentBaseType = ComponentBaseGenericType.MakeGenericType(typeArgument);
            break;
        }

        if (ComponentBaseType == null)
            return default;

        // Check if the type constraint is a subtype of MvvmComponentBase<>
        if (!ComponentBaseType.IsAssignableFrom(type))
            return default;

        // get all interfaces
        Type[] interfaces = ComponentBaseType
            .GetGenericArguments()[0]
            .GetInterfaces();

        // Check if the type argument of IView<> implements IViewModel
        if (interfaces.FirstOrDefault(i => i.Name == $"{viewModelType.Name}") is null)
            return default;

        // all checks passed, so return the type with the argument type declared 
        return (type, typeArgument);
    }

    #endregion
}/// <summary>
/// Provides an abstraction for querying and managing navigation via 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>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine the
    ///  URI to navigate to.</typeparam>
    /// <param name="forceLoad">If true, bypasses client-side routing 
    /// and forces the browser to load
    ///  the new page from the server, whether or not the URI would normally 
    /// be handled by the client-side router.</param>
    /// <param name="replace">If true, replaces the current entry in the history stack.
    /// If false,
    ///  appends the new entry to the history stack.</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)} has no associated page");

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
            to uri '{uri}'");

        _navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> to use to
    ///  determine the URI to navigate to.</typeparam>
    /// <param name="options">Provides additional <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)} has no associated page");

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
                             to uri '{uri}'");

        _navigationManager.NavigateTo(uri, options);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine
    ///  the URI to navigate to.</typeparam>
    /// <param name="relativeUri">relative URI &/or QueryString appended to 
    ///  the navigation Uri
    ///  .</param>
    /// <param name="forceLoad">If true, bypasses client-side routing and 
    /// forces the browser to load
    ///  the new page from the server, whether or not the URI would normally 
    ///  be handled by the client-side router.</param> 
    /// <param name="replace">If true, replaces the current entry 
    /// in the history stack. If false,
    ///  appends the new entry to the history stack.</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)} has no associated page");

        uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
                             to uri '{uri}'");

        _navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine
    ///  the URI to navigate to.</typeparam>
    /// <param name="relativeUri">relative URI &/or QueryString appended 
    /// to the navigation Uri.</param>
    /// <param name="options">Provides additional 
    /// <see cref="NavigationOptions"/>.</param>
    public void NavigateTo<TViewModel>(string relativeUri, NavigationOptions options)
        where TViewModel : IViewModelBase
    {
        if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
            throw new ArgumentException($"{typeof(TViewModel)} has no associated page");

        uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}' 
                             to uri '{uri}'");

        _navigationManager.NavigateTo(uri, options);
    }

    /// <summary>
    /// Get the <see cref="IViewModelBase"/> associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    ///  to use to determine the URI to navigate to.</typeparam>
    /// <returns>A relative URI path.</returns>
    /// <exception cref="ArgumentException"></exception>
    public string GetUri<TViewModel>()
        where TViewModel : IViewModelBase
    {
        if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
            throw new ArgumentException($"{typeof(TViewModel)} has no associated page");

        return uri;
    }

    #region Internals

    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 GenerateReferenceCache()
    {
        Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug("Starting generation of a new Reference Cache");

        foreach (Assembly assembly in assemblies)
        {
            List<(Type Type, Type? Argument)> items;

            try
            {
                items = assembly
                    .GetTypes()
                    .Select(GetViewArgumentType)
                    .Where(t => t.Argument is not null)
                    .ToList();
            }
            catch (Exception)
            {
                // avoid issue with unit tests
                continue;
            }

            // does the assembly contain the required types?
            if (!items.Any())
                continue;

            foreach ((Type Type, Type? Argument) item in items)
            {
                Attribute? attribute = item.Type.GetCustomAttributes()
                                           .FirstOrDefault(a => a is RouteAttribute);

                // is this a page or a component?
                if (attribute is null)
                    continue;

                // we have a page, let's reference it!
                string uri = ((RouteAttribute)attribute).Template;
                _references.Add(item.Argument!, uri);

                if (_logger.IsEnabled(LogLevel.Debug))
                    _logger.LogDebug($"Caching navigation reference 
                                     '{item.Argument!}' with
                                      uri '{uri}' for '{item.Type.FullName}'");
            }
        }

        if (_logger.IsEnabled(LogLevel.Debug))
            _logger.LogDebug("Completed generating the Reference Cache");
    }

    private static (Type Type, Type? Argument) GetViewArgumentType(Type type)
    {
        Type viewInterfaceType = typeof(IView<>);
        Type viewModelType = typeof(IViewModelBase);
        Type ComponentBaseGenericType = typeof(MvvmComponentBase<>);
        Type? ComponentBaseType = null;
        Type? typeArgument = null;

        // Find the generic type definition for MvvmComponentBase<> 
        // with the correct type argument
        foreach (Type interfaceType in type.GetInterfaces())
        {
            if (!interfaceType.IsGenericType ||
                interfaceType.GetGenericTypeDefinition() != viewInterfaceType)
                continue;

            typeArgument = interfaceType.GetGenericArguments()[0];
            ComponentBaseType = ComponentBaseGenericType.MakeGenericType(typeArgument);
            break;
        }

        if (ComponentBaseType == null)
            return default;

        // Check if the type constraint is a subtype of MvvmComponentBase<>
        if (!ComponentBaseType.IsAssignableFrom(type))
            return default;

        // get all interfaces
        Type[] interfaces = ComponentBaseType
            .GetGenericArguments()[0]
            .GetInterfaces();

        // Check if the type argument of IView<> implements IViewModel
        if (interfaces.FirstOrDefault(i => i.Name == $"{viewModelType.Name}") is null)
            return default;

        // all checks passed, so return the type with the argument type declared 
        return (type, typeArgument);
    }

    #endregion
}

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

dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
      Starting generation of a new Reference Cache
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
      Caching navigation reference 
      'Blazing.Mvvm.Sample.Wasm.ViewModels.FetchDataViewModel'
       with uri '/fetchdata' for 'Blazing.Mvvm.Sample.Wasm.Pages.FetchData'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
      Caching navigation reference 
      'Blazing.Mvvm.Sample.Wasm.ViewModels.EditContactViewModel'
       with uri '/form' for 'Blazing.Mvvm.Sample.Wasm.Pages.Form'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
      Caching navigation reference 
      'Blazing.Mvvm.Sample.Wasm.ViewModels.HexTranslateViewModel'
       with uri '/hextranslate' for 'Blazing.Mvvm.Sample.Wasm.Pages.HexTranslate'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
      Caching navigation reference 
      'Blazing.Mvvm.Sample.Wasm.ViewModels.ITestNavigationViewModel'
       with uri '/test' for 'Blazing.Mvvm.Sample.Wasm.Pages.TestNavigation'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
      Completed generating the Reference Cache 

MvvmNavLink组件

MvvmNavLink组件基于Blazor Navlink组件,并具有额外的TViewModelRelativeUri属性。在内部,使用MvvmNavigationManager进行导航。

/// <summary>
/// A component that renders an anchor tag, automatically toggling its 'active'
/// class based on whether its 'href' matches the current URI. Navigation is based on
/// ViewModel (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>
    /// Gets or sets the CSS class name applied to the NavLink when the
    /// current route matches the NavLink href.
    /// </summary>
    [Parameter]
    public string? ActiveClass { get; set; }

    /// <summary>
    /// Gets or sets a collection of additional attributes 
    /// that will be added to the generated
    /// <c>a</c> element.
    /// </summary>
    [Parameter(CaptureUnmatchedValues = true)]
    public IDictionary<string, object>? AdditionalAttributes { get; set; }

    /// <summary>
    /// Gets or sets the computed CSS class based on whether or not the link is active.
    /// </summary>
    protected string? CssClass { get; set; }

    /// <summary>
    /// Gets or sets the child content of the component.
    /// </summary>
    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    /// <summary>
    /// Gets or sets a value representing the URL matching behavior.
    /// </summary>
    [Parameter]
    public NavLinkMatch Match { get; set; }

    /// <summary>
    ///Relative URI &/or QueryString appended to the associate URI.
    /// </summary>
    [Parameter]
    public string? RelativeUri { get; set; }

    /// <inheritdoc />
    protected override void OnInitialized()
    {
        // We'll consider re-rendering on each location change
        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()
    {
        // To avoid leaking memory, it's important to detach 
        // any event handlers in 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)
    {
        // We could just re-render always, but for this component we know the
        // only relevant state change is to the _isActive property.
        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)
        {
            // Special case: highlight links to http://host/path/ even if you're
            // at http://host/path (with no trailing slash)
            //
            // This is because the router accepts an absolute URI value of "same
            // as base URI but without trailing slash" as equivalent to "base URI",
            // which in turn is because it's common for servers to return the same page
            // for http://host/vdir as they do for host://host/vdir/ as it's no
            // good to display a blank page in that case.
            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)
                && (
                    // Only match when there's a separator character 
                    // either at the end of the
                    // prefix or right after it.
                    // Example: "/abc" is treated as a prefix of "/abc/def" 
                    // but not "/abcdef"
                    // Example: "/abc/" is treated as a prefix of "/abc/def" 
                    // but not "/abcdef"
                    prefixLength == 0
                    || !char.IsLetterOrDigit(prefix[prefixLength - 1])
                    || !char.IsLetterOrDigit(value[prefixLength])
                );
        }

        return false;
    }
}

测试

包括导航和消息传递的测试。

2部分——转换现有应用程序

虽然存储库包含一个演示如何使用库的基本示例项目,但我希望包含一个示例,该示例将现有项目用于不同的应用程序类型,并且只需进行最少的更改,即可使其适用于Blazor。因此,我采用了Microsoft Xamarin示例项目并将其转换为Blazor

对适用于BlazorXamarin示例所做的更改

MvvmSample.Core项目基本保持不变,我已将基类添加到ViewModel以启用Blazor绑定更新。

因此,举个例子,SamplePageViewModel的改变从:

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

到:

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

ViewModelBase包装ObservableObject类。无需进行其他更改。

对于Xamarin Pages,连接DataContext是这样完成的:

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

使用Blazing.MVVM,只需:

@inherits MvvmComponentBase<MyPageViewModel>

最后,我已将示例应用程序中使用的所有文档从特定于Xamarin的文档更新为Blazor。如果我错过了任何更改,请告诉我,我会更新。

组件

Xamarin附带了一组丰富的控件。相比之下,Blazo是精益的。为了保持这个项目的精简,我包括了我自己的ListBox控件和Tab控件——尽情享受吧!当我有时间时,我将努力完成并发布Blazor的控件库。

WASM+新的WPFAvalonia Blazor Hybrid示例应用程序

我添加了新的WPF/Avalonia Hybrid应用,以演示如何使用MVVMWPF/Avalonia调用Blazor。为此,我有:

  • 将核心共享部件从BlazorSample应用程序移动到新的RCL(Razor类库)
  • 已将资源移动到标准内容文件夹,因为无法再访问wwwrootBlazorWebView主机控件使用的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 DEBUG
builder.Logging.SetMinimumLevel(LogLevel.Trace);
#endif

await 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">Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

最后,我们将连接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="Navigation menu" 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> Introduction
            </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 Commands
            </MvvmNavLink>
        </div>
        <div class="nav-item px-3">
            <MvvmNavLink class="nav-link" TViewModel=AsyncRelayCommandPageViewModel>
                <i class="bi bi-flag" aria-hidden="true"></i> Async Commands
            </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> Sending Messages
            </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> 
                 Request Messages
            </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> 
                 Inversion of Control
            </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 Setup
            </MvvmNavLink>
        </div>
        <div class="nav-item px-3">
            <MvvmNavLink class="nav-link" TViewModel=ISettingsServicePageViewModel>
                <i class="bi bi-wrench" aria-hidden="true"></i> Settings Service
            </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 Service
            </MvvmNavLink>
        </div>
        <div class="nav-item px-3">
            <MvvmNavLink class="nav-link" TViewModel=IBuildingTheUIPageViewModel>
                <i class="bi bi-rulers" aria-hidden="true"></i> Building the UI
            </MvvmNavLink>
        </div>
        <div class="nav-item px-3">
            <MvvmNavLink class="nav-link" TViewModel=IRedditBrowserPageViewModel>
        <i class="bi bi-reddit" aria-hidden="true"></i> The Final Result
            </MvvmNavLink>
        </div>
    </nav>
</div>

@code {
    private bool collapseNavMenu = true;

    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

Blazor Hybrid应用

我们可以将Blazor应用嵌入到标准桌面或MAUI应用程序中。下面我们将看两个示例——WPFAvalonia。同样的原则也适用于WinFormsMAUI

AppState

对于Blazor Hybrid应用,我们需要一种在两个应用程序框架之间进行通信的方法。此类充当本机应用和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 Hybrid应用

如果我们想在本机Windows应用程序(Hybrid Blazor应用程序)中托管Blazor应用,该怎么办?也许我们想将本机WPF控件与Blazor内容一起使用。以下示例应用程序将演示如何完成此操作。

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="Click on the BlazorWebView control, then CTRL-SHIFT-I or
                         F12 to open the Browser DevTools window..." />
    </Grid>
</Window>

MainWindowViewModel

此类通过该AppState类定义和管理命令导航。执行命令时,将进行快速查找并执行相关操作——无需switchif ... else需要逻辑。

internal class 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();

        // 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());
}

注意:由于BlazorWebView控件和IOC导航使用MvvmNavigationManager时出现异常,因此会出现以下异常:

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(仅限WindowsBlazor Hybrid应用

对于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 Hybrid Sample Application" 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="Click on the BlazorWebView control, then CTRL-SHIFT-I or
                        F12 to open the Browser DevTools window.." />
    </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中的导航公开给本机应用。

注意:我们正在为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());
}

注意AvaloniaWPF具有相同的怪癖,因此使用相同的解决方法。

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组件(控件)

在构建Blazor示例应用时,我需要 BlazorTabControlListBox组件(控件)。所以我自己卷了。这些组件可以在解决方案中自己的项目中找到,也可以在你自己的项目中使用。有一个通用代码的支持库。这两个组件都支持键盘导航。

TabControl用法

<TabControl>
    <Panels>
        <TabPanel Title="Interactive Sample">
            <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"
         SelectedItem=@ViewModel.SelectedPost
         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指向TItem类型的Collection
  • SelectedItem设置初始TItem
  • SelectionChanged选择项时引发事件。

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

引用

总结

我们有一个简单易用的Blazor MVVM库,称为 Blazing.MVVM,它支持所有功能,例如源生成器支持。我们还探讨了将现有的 Xamarin Community Toolkit示例应用程序转换为Blazor WASM应用,以及WPFAvalonia Hybrid应用程序。如果已在使用Mvvm社区工具包,则在Blazor中使用它是一件轻而易举的事。如果您已经熟悉MVVM,那么在您自己的项目中使用Blazing.MVVM应该很简单。如果使用的是Blazor而不是MVVM,但想要使用,则可以使用现有文档、博客文章、代码项目的快速答案StackOverflow支持等从其他应用程序框架中学习,并使用 Blazing.MVVM 库应用于Blazor

https://www.codeproject.com/Articles/5365754/Blazing-Mvvm-Blazor-Server-WebAssembly-Hybrid-usin

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值