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 天试用许可证。
从模板创建一个 Blazor 混合应用程序,并将您的许可证密钥作为参数传递:
dotnet new dotnetbrowser.blazor.avalonia.app -o Blazor.AvaloniaUi -li <your_license_key>
然后运行应用程序:
dotnet run --project Blazor.AvaloniaUi
在 Linux 上的 Avalonia UI 上运行 Blazor 混合应用程序
实现
在混合环境中,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 环境。