目录
WASM+新的WPF和Avalonia Blazor Hybrid示例应用程序
Avalonia(仅限Windows)Blazor Hybrid应用
概述
开发Blazor应用不需要MVVM。绑定系统比其他应用程序框架(如WinForms和WPF)更简单。
但是,MVVM模式有很多好处,例如逻辑与视图的分离、可测试性。降低风险和协作。
Blazor有多个库尝试支持MVVM设计模式,但这些库并不是最容易使用的。同时,还有支持WPF、Xamarin和MAUI应用程序框架的CommunityToolkit.Mvvm。
为什么不是Blazor?本文介绍Blazor的MVVM实现,该实现通过名为Blazing.MVVM的库使用CommunityToolkit.Mvvm。如果您熟悉CommunityToolkit.Mvvm,那么您已经知道如何使用此实现。
下载
源代码(通过GitHub)
** 如果你觉得这个库有用,请给 Github存储库一个star。
Nuget:
第1部分——Blazing.Mvvm库
这是Kelly Adams对blazor-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>
现在运行应用程序。
通过使用来自代码MvvmNavigationManager的ViewModel进行导航,将类注入到页面或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的工作原理
分为两部分:
- ViewModelBase
- 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();
}
这里是包装ObservableObject的ViewModelBase类:
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类
当MvvmNavigationManager由IOC容器初始化为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组件,并具有额外的TViewModel和RelativeUri属性。在内部,使用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。
对适用于Blazor的Xamarin示例所做的更改
该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+新的WPF和Avalonia Blazor Hybrid示例应用程序
我添加了新的WPF/Avalonia Hybrid应用,以演示如何使用MVVM从WPF/Avalonia调用Blazor。为此,我有:
- 将核心共享部件从BlazorSample应用程序移动到新的RCL(Razor类库)
- 已将资源移动到标准内容文件夹,因为无法再访问wwwroot。BlazorWebView主机控件使用的IP 0.0.0.0地址对httpClient无效。
- 向WPF/Avalonia应用添加了新FileService类以使用该类File,而不是HttpClient。
- 向WPF/Avalonia应用添加了一个新功能App.Razor,用于自定义Blazor布局和挂钩共享状态,以处理来自WPF/Avalonia的导航请求。
- 为了启用对 Blazor 应用的调用,我使用了一个static状态类来保存对 NavigationManager和MvvvmNavigationManager类的引用。
Blazor Wasm示例应用
由于我们已将Blazor应用的核心移动到MvvmSampleBlazor.Core共享项目,因此我们只需要添加引用。
Program.cs
我们需要引导并将应用程序绑定在一起:
WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services
.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) })
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServices()
.AddMvvmNavigation();
#if 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>
NavMenu.raz
最后,我们将连接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应用程序中。下面我们将看两个示例——WPF和Avalonia。同样的原则也适用于WinForms和MAUI。
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类定义和管理命令导航。执行命令时,将进行快速查找并执行相关操作——无需switch或if ... 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(仅限Windows)Blazor 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());
}
注意:Avalonia与WPF具有相同的怪癖,因此使用相同的解决方法。
Program.cs
最后,我们需要把它们全部连接起来:
internal class Program
{
[STAThread]
public static void Main(string[] args)
{
HostApplicationBuilder appBuilder = Host.CreateApplicationBuilder(args);
appBuilder.Logging.AddDebug();
appBuilder.Services.AddWindowsFormsBlazorWebView();
#if DEBUG
appBuilder.Services.AddBlazorWebViewDeveloperTools();
#endif
appBuilder.Services
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServicesWpf()
.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.Hybrid;
});
using IHost host = appBuilder.Build();
host.Start();
try
{
BuildAvaloniaApp(host.Services)
.StartWithClassicDesktopLifetime(args);
}
finally
{
Task.Run(async () => await host.StopAsync()).Wait();
}
}
private static AppBuilder BuildAvaloniaApp(IServiceProvider serviceProvider)
=> AppBuilder.Configure(() => new App(serviceProvider))
.UsePlatformDetect()
.LogToTrace();
}
附加组件Blazor组件(控件)
在构建Blazor示例应用时,我需要 Blazor的TabControl和ListBox组件(控件)。所以我自己卷了。这些组件可以在解决方案中自己的项目中找到,也可以在你自己的项目中使用。有一个通用代码的支持库。这两个组件都支持键盘导航。
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组件的一部分。
引用
- Kelly Adams的 blazor-mvvm——Github存储库
- Baksteen.Avalonia.Blazor——Github存储库
- Microsoft的 CommunityToolkit.Mvvm——Github Repo/CommunityToolkit.Mvvm——Nuget
- Microsoft学习——MVVM工具包简介
- Microsoft的 Xamarin示例——Github存储库
总结
我们有一个简单易用的Blazor MVVM库,称为 Blazing.MVVM,它支持所有功能,例如源生成器支持。我们还探讨了将现有的 Xamarin Community Toolkit示例应用程序转换为Blazor WASM应用,以及WPF和Avalonia 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