使用 Shadcn UI 构建 C# 桌面应用

多年来,.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 代码。

我们使用 ProtobufgRPC 来处理前端和后端之间的通信。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 视图、无需本地服务器加载静态文件,以及使前端和后端顺畅通信。

相关推荐
CodeCraft Studio4 小时前
CADSoftTools发布两款重要更新:CAD VCL Multiplatform 16.2 与 CAD .NET 16全新发布
.net·cad vcl·cad .net·cad文件格式解析·cad文件编辑·cad文件查看器
uhakadotcom4 小时前
如何从阿里云的sls日志中清洗出有价值的信息?
后端·面试·github
李昊哲小课4 小时前
spring 中 HttpStatus 与 ResponseEntity
spring boot·后端·spring·http·spring cloud·restful
尘世中一位迷途小书童5 小时前
Vuetify Admin 后台管理系统
前端·前端框架·开源
参宿75 小时前
图解Vue3 响应式,手动实现核心原理
前端·javascript·vue.js
间彧5 小时前
Java 堆、栈、方法区详解与项目实战
后端
间彧5 小时前
Java内存区域详解与项目实战
后端
SimonKing5 小时前
【开发者必备】Spring Boot 2.7.x:WebMvcConfigurer配置手册来了(三)!
java·后端·程序员
2301_801252225 小时前
前端框架Vue(Vue 的挂载点与 data 数据对象)
java·前端·javascript·vue.js·前端框架