Blazor 是一个 .NET 前端框架,用于仅使用 .NET 技术构建 Web 应用程序。2021 年,Blazor 扩展到桌面端,推出了 Blazor Hybrid(混合),使开发者可以在桌面平台上使用已有的技能。
Blazor 混合应用程序是传统的桌面应用程序,它们在一个 Web View 控件中托管实际的 Blazor Web 应用程序。虽然这些应用程序使用 .NET MAUI 作为桌面端技术,但如果不符合需求,也可以使用其他框架。
MAUI 的局限性在于它缺乏对 Linux 的支持,并且在 Windows 和 macOS 上使用不同的 Browser Engine。Microsoft Edge 和 Safari 在实现 Web 标准、执行 JavaScript 以及页面渲染方面存在差异。这些差异在高级应用程序中可能会导致 bug 并需要额外的测试。
如果 MAUI 不符合您的要求,可以考虑选择 Avalonia UI,它是一个跨平台的 UI 库,其生态系统中包含多个基于 Chromium 的 Web View。
在本文中,我们将探讨如何使用 Avalonia UI 和 DotNetBrowser 作为 Web View 来创建 Blazor 混合应用程序。
使用模板快速入门
要使用 DotNetBrowser 和 Avalonia UI 创建一个基本的 Blazor 混合应用程序,请使用我们的模板:
dotnet new install DotNetBrowser.Templates
然后,获取 DotNetBrowser 的免费 30 天试用许可证。
发送中。。。
您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。
从模板创建一个 Blazor 混合应用程序,并将您的许可证密钥作为参数传递:
dotnet new dotnetbrowser.blazor.avalonia.app -o Blazor.AvaloniaUi -li <your_license_key>
然后运行应用程序:
dotnet run --project Blazor.AvaloniaUi
实现
在混合环境中,Blazor 应用程序在其桌面壳程序的进程中运行。这个壳程序或窗口管理整个应用程序的生命周期,显示 Web View,并启动 Blazor 应用程序。我们将使用 Avalonia UI 创建这个窗口。
Blazor 应用程序的后端是 .NET 代码,前端是托管在 Web View 中的 Web 内容。 Web View 中的 Browser Engine 和 .NET 运行时之间没有直接连接。因此,为了前后端通信,Blazor 必须知道如何在它们之间交换数据。由于我们引入了一个新的 Web View,我们必须教会 Blazor 如何使用 DotNetBrowser 进行数据交换。
接下来,我们将带您了解 Blazor 与 Avalonia 和 DotNetBrowser 集成的关键部分。有关完整解决方案,请查看上面的模板。
创建窗口
为了托管 Blazor 混合应用程序,我们需要创建一个常规的 Avalonia 窗口,并添加一个 Web View 组件。
MainWindow.axaml
<Window ... Closed="Window_Closed">
<browser:BlazorBrowserView x:Name="BrowserView" ... />
...
</browser:BlazorBrowserView>
</Window>
MainWindow.axaml.cs
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
...
BrowserView.Initialize();
}
private void Window_Closed(object sender, EventArgs e)
{
BrowserView.Shutdown();
}
}
BlazorBrowserView
是我们为了封装 DotNetBrowser 而创建的一个 Avalonia 控件。稍后,我们将在这个控件中将其与 Blazor 集成。
BlazorBrowserView.axaml
<UserControl ...>
...
<avaloniaUi:BrowserView x:Name="BrowserView" IsVisible="False" ... />
</UserControl>
BlazorBrowserView.axaml.cs
public partial class BlazorBrowserView : UserControl
{
private IEngine engine;
private IBrowser browser;
public BlazorBrowserView()
{
InitializeComponent();
}
public async Task Initialize()
{
EngineOptions engineOptions = new EngineOptions.Builder
{
RenderingMode = RenderingMode.HardwareAccelerated
}.Build();
engine = await EngineFactory.CreateAsync(engineOptions);
browser = engine.CreateBrowser();
...
Dispatcher.UIThread.InvokeAsync(ShowView);
}
public void Shutdown()
{
engine?.Dispose();
}
private void ShowView()
{
BrowserView.InitializeFrom(browser);
BrowserView.IsVisible = true;
browser?.Focus();
}
}
配置 Blazor
在混合应用程序中,负责 Blazor 与环境集成的主要实体是 WebViewManager
。这是一个抽象类,因此我们需要创建自己的实现,这里我们称之为 BrowserManager
并在 BlazorBrowserView
中实例化它。
BrowserManager.cs
class BrowserManager : WebViewManager
{
private static readonly string AppHostAddress = "0.0.0.0";
private static readonly string AppOrigin = $"https://{AppHostAddress}/";
private static readonly Uri AppOriginUri = new(AppOrigin);
private IBrowser Browser { get; }
public BrowserManager(IBrowser browser, IServiceProvider provider,
Dispatcher dispatcher,
IFileProvider fileProvider,
JSComponentConfigurationStore jsComponents,
string hostPageRelativePath)
: base(provider, dispatcher, AppOriginUri, fileProvider, jsComponents,
hostPageRelativePath)
{
Browser = browser;
}
...
}
BlazorBrowserView.axaml.cs
public partial class BlazorBrowserView : UserControl
{
private IEngine engine;
private IBrowser browser;
private BrowserManager browserManager;
...
public async Task Initialize()
{
EngineOptions engineOptions = new EngineOptions.Builder
{
RenderingMode = RenderingMode.HardwareAccelerated
}.Build();
engine = await EngineFactory.CreateAsync(engineOptions);
browser = engine.CreateBrowser();
...
browserManager = new BrowserManager(browser, ...);
...
}
...
}
一个 Blazor 应用程序需要一个或多个根组件。当 Web View 正在初始化时,我们将它们添加到 WebViewManager 中。
RootComponent.cs
public class RootComponent
{
public string ComponentType { get; set; }
public IDictionary<string, object> Parameters { get; set; }
public string Selector { get; set; }
public Task AddToWebViewManagerAsync(BrowserManager browserManager)
{
ParameterView parameterView = Parameters == null
? ParameterView.Empty
: ParameterView.FromDictionary(Parameters);
return browserManager?.AddRootComponentAsync(
Type.GetType(ComponentType)!, Selector, parameterView);
}
}
BlazorBrowserView.axaml.cs
public partial class BlazorBrowserView : UserControl
{
private IEngine engine;
private IBrowser browser;
private BrowserManager browserManager;
public ObservableCollection<RootComponent> RootComponents { get; set; } = new();
...
public async Task Initialize()
{
...
engine = await EngineFactory.CreateAsync(engineOptions);
browser = engine.CreateBrowser();
browserManager = new BrowserManager(browser, ...);
foreach (RootComponent rootComponent in RootComponents)
{
await rootComponent.AddToWebViewManagerAsync(browserManager);
}
...
}
...
}
MainWindow.axaml
<Window ... Closed="Window_Closed">
<browser:BlazorBrowserView x:Name="BrowserView" ... />
<browser:BlazorBrowserView.RootComponents>
<browser:RootComponent Selector="..." ComponentType="..." />
</browser:BlazorBrowserView.RootComponents>
</browser:BlazorBrowserView>
</Window>
加载静态资源
在普通的 Web 应用程序中,Browser 通过向服务器发送 HTTP 请求来加载页面和静态资源。在 Blazor 混合应用程序中,虽然原理相似,但这里并没有传统的服务器。相反,WebViewManager
提供了一个名为 TryGetResponseContent
的方法,该方法接受一个 URL 并返回数据作为类似 HTTP 的响应。
我们通过拦截 DotNetBrowser 中的 HTTPS 流量将 HTTP 请求和响应传递到此方法并返回。
BlazorBrowserView.axaml.cs
public partial class BlazorBrowserView : UserControl
{
private IEngine engine;
private IBrowser browser;
private BrowserManager browserManager;
...
public async Task Initialize()
{
EngineOptions engineOptions = new EngineOptions.Builder
{
RenderingMode = RenderingMode.HardwareAccelerated,
Schemes =
{
{
Scheme.Https,
new Handler<InterceptRequestParameters,
InterceptRequestResponse>(OnHandleRequest)
}
}
}.Build();
engine = await EngineFactory.CreateAsync(engineOptions);
browser = engine.CreateBrowser();
browserManager = new BrowserManager(browser, ...);
...
}
public InterceptRequestResponse OnHandleRequest(
InterceptRequestParameters params) =>
browserManager?.OnHandleRequest(params);
...
}
BrowserManager.cs
internal class BrowserManager : WebViewManager
{
private static readonly string AppHostAddress = "0.0.0.0";
private static readonly string AppOrigin = $"https://{AppHostAddress}/";
private static readonly Uri AppOriginUri = new(AppOrigin);
...
public InterceptRequestResponse OnHandleRequest(InterceptRequestParameters p)
{
if (!p.UrlRequest.Url.StartsWith(AppOrigin))
{
// 如果请求不以 AppOrigin 开头,则允许它通过。
return InterceptRequestResponse.Proceed();
}
ResourceType resourceType = p.UrlRequest.ResourceType;
bool allowFallbackOnHostPage = resourceType is ResourceType.MainFrame
or ResourceType.Favicon
or ResourceType.SubResource;
if (TryGetResponseContent(p.UrlRequest.Url, allowFallbackOnHostPage,
out int statusCode, out string _,
out Stream content,
out IDictionary<string, string> headers))
{
UrlRequestJob urlRequestJob = p.Network.CreateUrlRequestJob(p.UrlRequest,
new UrlRequestJobOptions
{
HttpStatusCode = (HttpStatusCode)statusCode,
Headers = headers
.Select(pair => new HttpHeader(pair.Key, pair.Value))
.ToList()
});
Task.Run(() =>
{
using (MemoryStream memoryStream = new())
{
content.CopyTo(memoryStream);
urlRequestJob.Write(memoryStream.ToArray());
}
urlRequestJob.Complete();
});
return InterceptRequestResponse.Intercept(urlRequestJob);
}
return InterceptRequestResponse.Proceed();
}
}
导航
现在,当 Web View 可以导航到应用页面并加载静态资源时,我们可以加载索引页并教导 WebViewManager
如何执行导航操作。
BlazorBrowserView.axaml.cs
public partial class BlazorBrowserView : UserControl
{
private IEngine engine;
private IBrowser browser;
private BrowserManager browserManager;
...
public async Task Initialize()
{
...
engine = await EngineFactory.CreateAsync(engineOptions);
browser = engine.CreateBrowser();
browserManager = new BrowserManager(browser, ...);
foreach (RootComponent rootComponent in RootComponents)
{
await rootComponent.AddToWebViewManagerAsync(browserManager);
}
browserManager.Navigate("/");
...
}
...
}
BrowserManager.cs
internal class BrowserManager : WebViewManager
{
...
private IBrowser Browser { get; }
...
protected override void NavigateCore(Uri absoluteUri)
{
Browser.Navigation.LoadUrl(absoluteUri.AbsoluteUri);
}
}
数据交换
与普通的 Web 应用程序不同,Blazor Hybrid 不使用 HTTP 进行数据交换。前端和后端通过字符串消息进行通信,使用的是特殊的 .NET-JavaScript 互操作机制。在 JavaScript 中,消息通过 window.external
对象发送和接收,而在 .NET 端,则通过 WebViewManager
进行。
我们使用 DotNetBrowser 的 .NET-JavaScript 桥接功能来创建 window.external
对象并传输消息。
BrowserManager.cs
internal class BrowserManager : WebViewManager
{
...
private IBrowser Browser { get; }
private IJsFunction sendMessageToFrontEnd;
public BrowserManager(IBrowser browser, IServiceProvider provider,
Dispatcher dispatcher,
IFileProvider fileProvider,
JSComponentConfigurationStore jsComponents,
string hostPageRelativePath)
: base(provider, dispatcher, AppOriginUri, fileProvider, jsComponents,
hostPageRelativePath)
{
Browser = browser;
// 此处理程序在页面加载之后但在执行其自己的 JavaScript 之前调用。
Browser.InjectJsHandler = new Handler<InjectJsParameters>(OnInjectJs);
}
...
private void OnInjectJs(InjectJsParameters p)
{
if (!p.Frame.IsMain)
{
return;
}
dynamic window = p.Frame.ExecuteJavaScript("window").Result;
window.external = p.Frame.ParseJsonString("{}");
// 当页面调用这些方法时,DotNetBrowser 会将调用代理到 .NET 方法。
window.external.sendMessage = (Action<dynamic>)OnMessageReceived;
window.external.receiveMessage = (Action<dynamic>)SetupCallback;
}
private void OnMessageReceived(dynamic obj)
{
this.MessageReceived(new Uri(Browser.Url), obj.ToString());
}
private void SetupCallback(dynamic callbackFunction)
{
sendMessageToFrontEnd = callbackFunction as IJsFunction;
}
protected override void SendMessage(string message)
{
sendMessageToFrontEnd?.Invoke(null, message);
}
}
结论
在本文中,我们讨论了 Blazor Hybrid,这是一种用于使用 Blazor 构建桌面应用程序的 .NET 技术。
Blazor Hybrid 使用 .NET MAUI 存在两个局限性:
- 不支持 Linux。
- 在 Windows 和 macOS 上使用不同的 Browser Engine,使得相同的应用程序在不同平台上可能表现和外观不同。
我们建议使用 Avalonia UI + DotNetBrowser 作为替代方案。这种组合为 Windows、macOS 和 Linux 提供了全面支持,并确保在所有平台上都能保持一致的 Browser 环境。