多年来,.NET 开发者一直使用 WinForms 和 WPF 等工具包构建桌面应用。这些框架虽然能够完成任务并提供原生控件,但要让界面看起来现代化却需要大量的额外工作。默认组件显得过时,而添加流畅的动画或简洁的现代风格也并非易事。

相比之下,Web 技术发展迅速。借助 React 等框架和庞大的库生态系统,开发者可以快速构建响应迅速、界面美观的应用。因此,许多流行应用(如 Slack、Notion、Microsoft Teams)即使在桌面版本中也采用了基于 Web 的用户界面。

在本文中,我们将介绍一种架构,它既能保持 Web 的灵活性和高效性,又能提供原生桌面体验。

为什么要在桌面上使用 Web UI? 

在桌面应用中使用 Web UI 具有超越外观的明显优势。现代 Web 技术可以轻松构建出简洁、响应迅速且在不同平台上表现一致的界面。

其真正的优势在于庞大的生态系统。有大量现成的组件可供使用,并且 Web 开发者众多,因此您可以减少重复造轮子的时间,将更多精力投入到实际应用的开发中。

跨平台支持也是一大亮点。借助 Avalonia 或 DotNetBrowser 等框架,您可以在 Windows、macOS 和 Linux 上运行同一套代码,而无需维护多个版本。

此外,这种架构还具有灵活性。同一个前端可以同时用于 Web 和桌面,从而简化开发流程、实现工作复用,并避免重复劳动。

带有 Web UI 的桌面应用截图

带有 Web UI 的桌面应用截图

完整源代码可在 GitHub 上获取。

挑战 

乍一看,将 Web UI 嵌入到 .NET 桌面应用中似乎很简单,但有几个挑战需要解决:

  • 安全的加载资源。 应用不应依赖本地或远程服务器加载其 UI,并且捆绑的资源不得暴露。
  • JavaScript 与 .NET 之间的通信。 Web UI 需要一种安全且结构化的方式来调用后端逻辑并接收结果。

让我们看看 Avalonia 和 DotNetBrowser 如何帮助解决这些挑战。

应用窗口与 Web 视图 

我们使用 Avalonia 创建应用窗口并处理原生集成。在窗口内部,嵌入 DotNetBrowser 的 BrowserView —— 一个基于 Chromium 的 Web 视图组件。

XAML 窗口定义:

<Window ...>
    <app:BrowserView x:Name="BrowserView" />
</Window>

后台初始化代码:

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 或其他文件时,我们会从应用资源中读取并返回:

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# 类:

public class PrefsService {

    public void SetBrightness(int percents) {
        ...
    }
}

然后,让我们在 TypeScript 中镜像它:

// 匹配的类。
declare class PrefsService { 

   SetBrightness(percents: number): void; 
}

// 托管对象的全局变量。
declare const prefService: PrefsService;
...
prefService.SetBrightness(95);

最后,当页面加载时,我们将 .NET 对象注入到 已声明的 JavaScript 变量中:

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 代码。

我们使用 ProtobufgRPC 来处理前端和后端之间的通信。Protobuf 以语言中立的方式定义共享的数据结构和服务,并为 C# 和 TypeScript 生成类型安全的代码。

结果:请求和响应在构建时即可校验,并且您无需任何额外设置即可获得完整的类型提示和自动补全功能。

通信图

JavaScript 和 .NET 之间的通信。

以下是等效的 Protobuf 定义:

service PrefsService {
 rpc SetBrightness(Brightness) returns (google.protobuf.Empty);
}

message Brightness {
 int32 percents = 1;
}

以及从 Protobuf 定义自动生成的 .NET 实现:

public override Task<Empty> SetBrightness(Brightness brightness, ServerCallContext context)
{
    int percents = brightness.percents;
    ...
    return Task.FromResult(new Empty());
}

这次,我们不需要注入对象。但我们需要将 gRPC 主机传递给前端,以便它可以连接:

browser.InjectJsHandler = new Handler<InjectJsParameters>(p =>
{
    dynamic window = p.Frame.ExecuteJavaScript("window").Result;
    if (window != null)
    {
        window.rpcAddress = "http://localost:5051";
    }
});

现在,Web 前端可以使用自动生成的代码向 .NET 后端发送请求:

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 视图、无需本地服务器加载静态文件,以及使前端和后端顺畅通信。

Spinner

发送中。。。

抱歉,发送中断

请再次尝试,如果问题仍然存在,请联系我们 info@teamdev.com.

阅读并同意条款以继续。

您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。