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

相关推荐
wuk9981 天前
实现ROS系统的Websocket传输,向Web应用推送sensor_msgs::Image数据
前端·websocket·网络协议
hweiyu001 天前
Go Fiber 简介
开发语言·后端·golang
合作小小程序员小小店1 天前
web网页开发,在线%考试管理%系统,基于Idea,vscode,html,css,vue,java,maven,springboot,mysql
java·前端·系统架构·vue·intellij-idea·springboot
你的人类朋友1 天前
😎 Node.js 应用多阶段构建 Dockerfile 详解
后端·docker·容器
天天进步20151 天前
CSS Grid与Flexbox:2025年响应式布局终极指南
前端·css
小坏讲微服务1 天前
Spring Boot整合Redis注解,实战Redis注解使用
spring boot·redis·分布式·后端·spring cloud·微服务·mybatis
Boop_wu1 天前
[Java EE] 计算机基础
java·服务器·前端
橘子海全栈攻城狮1 天前
【源码+文档+调试讲解】基于Spring Boot的考务管理系统设计与实现 085
java·spring boot·后端·spring
追逐时光者1 天前
一个基于 .NET 8 + DDD 搭建的模块化微服务框架
后端·.net
William_cl1 天前
C# ASP.NET MVC 数据验证实战:View 层双保险(Html.ValidationMessageFor + jQuery Validate)
后端·c#·asp.net·mvc