多年来,.NET 开发者一直使用 WinForms 和 WPF 等工具包构建桌面应用。这些框架虽然能够完成任务并提供原生控件,但要让界面看起来现代化却需要大量的额外工作。默认组件显得过时,而添加流畅的动画或简洁的现代风格也并非易事。
相比之下,Web 技术发展迅速。借助 React 等框架和庞大的库生态系统,开发者可以快速构建响应迅速、界面美观的应用。因此,许多流行应用(如 Slack、Notion、Microsoft Teams)即使在桌面版本中也采用了基于 Web 的用户界面。
在本文中,我们将介绍一种架构,它既能保持 Web 的灵活性和高效性,又能提供原生桌面体验。
为什么要在桌面上使用 Web UI?
在桌面应用中使用 Web UI 具有超越外观的明显优势。现代 Web 技术可以轻松构建出简洁、响应迅速且在不同平台上表现一致的界面。
其真正的优势在于庞大的生态系统。有大量现成的组件可供使用,并且 Web 开发者众多,因此您可以减少重复造轮子的时间,将更多精力投入到实际应用的开发中。
跨平台支持也是一大亮点。借助 Avalonia 或 DotNetBrowser 等框架,您可以在 Windows、macOS 和 Linux 上运行同一套代码,而无需维护多个版本。
此外,这种架构还具有灵活性。同一个前端可以同时用于 Web 和桌面,从而简化开发流程、实现工作复用,并避免重复劳动。

完整源代码可在 GitHub 上获取。
挑战
乍一看,将 Web UI 嵌入到 .NET 桌面应用中似乎很简单,但有几个挑战需要解决:
- 安全的加载资源。 应用不应依赖本地或远程服务器加载其 UI,并且捆绑的资源不得暴露。
- JavaScript 与 .NET 之间的通信。 Web UI 需要一种安全且结构化的方式来调用后端逻辑并接收结果。
让我们看看 Avalonia 和 DotNetBrowser 如何帮助解决这些挑战。
应用窗口与 Web 视图
我们使用 Avalonia 创建应用窗口并处理原生集成。在窗口内部,嵌入 DotNetBrowser 的 BrowserView ------ 一个基于 Chromium 的 Web 视图组件。
XAML 窗口定义:
xml
<Window ...>
<app:BrowserView x:Name="BrowserView" />
</Window>
后台初始化代码:
c#
private async void Window_Opened(object? sender, EventArgs e)
{
Browser = ServiceProvider.GetService<IEngineService>()?.CreateBrowser();
BrowserView.InitializeFrom(Browser);
await Browser.Navigation.LoadUrl("dnb://internal.host/");
}
在后台代码中,我们初始化 Chromium 引擎,并加载仅供内部使用、外部无法访问的 UI。
加载页面
在 Web 开发中,通过本地开发服务器运行应用并自动重新加载更改是一种常见做法------这是大多数前端开发者习以为常的工作流程。在我们的设置中,我们保留了同样的方法:Web 应用在本地运行并启用热重载,因此你可以立即看到更新,而无需每次重新构建整个桌面应用。
在生产环境中,我们不想依赖 Web 服务器。为什么呢?首先,任何能够访问服务器 URL 的人都可以窥探其资源,包括本应保密的逻辑。其次,这增加了额外的组件,使部署和维护变得更加复杂。
相反,我们将前端资源嵌入到应用包中,并使其仅在应用内部可用。
DotNetBrowser 为此提供了自定义协议处理器(Custom Scheme Handler) 。它允许我们拦截特定协议(如 my-app://)的请求,并以任意数据进行响应,而非从远程服务器获取。在示例中,当浏览器请求 index.html 或其他文件时,我们会从应用资源中读取并返回:
c#
public class ResourceRequestHandler : ISchemeHandler
{
public InterceptRequestResponse Handle(InterceptRequestParameters parameters)
{
string url = parameters.UrlRequest.Url;
// 定位并读取嵌入资源(如 index.html、JS、CSS)。
...
}
}
...
EngineOptions engineOptions = new EngineOptions.Builder
{
Schemes =
{
{ Scheme.Create("my-app"), new ResourceRequestHandler() }
}
}.Build();
IEngine engine = EngineFactory.Create(engineOptions);
通过这种设置,我们拦截所有对 my-app:// URL 方案的请求,并允许常规的 HTTP 请求通过。结果是,我们得到了一个安全、自包含的软件包,它可以离线运行并隐藏资源不被直接访问。
JavaScript 与 .NET 之间的通信
Web UI 非常适合展示,但大多数实际工作仍在其他地方进行。例如,读取或写入文件、保存设置或与操作系统交互等操作只能由 .NET 后端完成。然而,前端需要触发这些操作并获取结果。因此,我们需要一种 JavaScript 与 .NET 之间进行通信的方式。
JavaScript 与 .NET 之间的直接调用
对于小型项目,一个简单的桥接机制就足够了。大多数 WebView 组件都允许 JavaScript 与 .NET 直接通信。有的通过传递 JSON 消息实现,有的(如 DotNetBrowser)则允许在 JavaScript 中直接访问 .NET 对象。反之亦然。下面是一个简单的示例。
首先,我们在 C# 中定义一个类。然后,我们在 TypeScript 中镜像它,保持相同的接口。接着,我们配置 DotNetBrowser 将 .NET 实例注入到匹配类型的 JavaScript 对象中。
让我们考虑以下简单的 C# 类:
c#
public class PrefsService {
public void SetBrightness(int percents) {
...
}
}
然后,让我们在 TypeScript 中镜像它:
typescript
// 匹配的类。
declare class PrefsService {
SetBrightness(percents: number): void;
}
// 托管对象的全局变量。
declare const prefService: PrefsService;
...
prefService.SetBrightness(95);
最后,当页面加载时,我们将 .NET 对象注入到 已声明的 JavaScript 变量中:
c#
browser.InjectJsHandler = new Handler<InjectJsParameters>(p =>
{
dynamic window = p.Frame.ExecuteJavaScript("window").Result;
if (window != null)
{
window.prefService = new PrefsService();
}
});
这是一种简单而有效的方法------至少在开始时是这样。问题在于,它的扩展性不佳。您必须手动保持 C# 和 TypeScript 定义同步,并且随着 API 的增长和发展,不可避免的不匹配问题将成为持续的 bug 源头。
JavaScript 与 .NET 之间的 RPC 通信
与其保持两个独立的定义同步,我们可以定义一次数据,并让工具生成匹配的 C# 和 TypeScript 代码。
我们使用 Protobuf 和 gRPC 来处理前端和后端之间的通信。Protobuf 以语言中立的方式定义共享的数据结构和服务,并为 C# 和 TypeScript 生成类型安全的代码。
结果:请求和响应在构建时即可校验,并且您无需任何额外设置即可获得完整的类型提示和自动补全功能。
以下是等效的 Protobuf 定义:
proto
service PrefsService {
rpc SetBrightness(Brightness) returns (google.protobuf.Empty);
}
message Brightness {
int32 percents = 1;
}
以及从 Protobuf 定义自动生成的 .NET 实现:
c#
public override Task<Empty> SetBrightness(Brightness brightness, ServerCallContext context)
{
int percents = brightness.percents;
...
return Task.FromResult(new Empty());
}
这次,我们不需要注入对象。但我们需要将 gRPC 主机传递给前端,以便它可以连接:
c#
browser.InjectJsHandler = new Handler<InjectJsParameters>(p =>
{
dynamic window = p.Frame.ExecuteJavaScript("window").Result;
if (window != null)
{
window.rpcAddress = "http://localost:5051";
}
});
现在,Web 前端可以使用自动生成的代码向 .NET 后端发送请求:
typescript
import {createGrpcWebTransport} from "@connectrpc/connect-web";
import {createClient} from "@connectrpc/connect";
import {PreferencesService} from "@/gen/prefs_pb.ts";
const transport = createGrpcWebTransport({
baseUrl: `http://localhost:50051`,
});
const prefsClient = createClient(PrefsService, transport);
...
prefsClient.SetBrightness(...);
结论
通过 Web UI 构建桌面应用,可以大幅提升开发效率与灵活性。您可以使用现代 Web 工具,利用庞大的生态系统,打造符合当下审美标准的用户界面。
在本文中,我们结合了 Web 和桌面技术------使用 DotNetBrowser 托管基于 React 的用户界面,将前端捆绑到应用中,并建立了 .NET 和 JavaScript 之间的双向通信。
在此过程中,我们解决了一些常见的痛点:嵌入 Web 视图、无需本地服务器加载静态文件,以及使前端和后端顺畅通信。