现代用户体验已成为桌面应用的基础要求,采用 Web 技术能帮助团队更快交付软件。
本文将展示基于 C# 的桌面应用架构,其用户界面采用 Angular 实现。该应用支持离线运行且无需本地服务器。
为什么在桌面应用中使用 Web UI?
Web UI 技术栈拥有庞大且经过充分验证的组件生态系统。采用 Angular 构建桌面UI,可借助熟悉的工具处理布局、主题和交互逻辑,避免在原生 UI 框架中重复实现相同控件。
这种方式同时简化了代码复用流程。同一个 Angular 应用既能在浏览器中运行,也能嵌入桌面应用宿主程序,大幅减少代码冗余,实现 UI 变更的集中管理。原生窗口与系统集成的工作由独立模块负责,Web 层保持可移植性,桌面应用则能与各个操作系统实现无缝集成。
C# 桌面应用中的 Web UI
本文将构建一个 跨平台的 Avalonia 桌面应用,其 UI 是一个基于 Angular 的简单偏好设置界面。Angular Web 应用被打包进桌面程序中,而 .NET 端负责将设置保存到持久化存储中。

运行在 Avalonia 窗口中的 Angular UI。
UI 使用了 PrimeNG 作为现成的 Angular 组件库,但在该架构下,任何类似的组件库都可以使用。
将网页界面嵌入桌面壳层时,需克服以下障碍:
- 需采用符合现代网页标准的可靠网页视图组件。
- 需实现无服务器环境下的网页内容加载,确保应用自包含性。
- 需建立 JavaScript 与 .NET 之间的双向通信机制。
后续章节将依次说明:窗口与网页视图的配置方案、无服务器加载 Angular UI 的实现方法,以及通过 OpenAPI 规范定义的接口连接 JavaScript 与 .NET 的具体方案。
应用窗口与网页视图
对于桌面壳层,我们使用 Avalonia 创建原生窗口和布局,并通过 DotNetBrowser(基于 Chromium 的跨平台网页视图组件)将其嵌入其中:
<Window Width="1200" Height="800" Title="Angular Demo">
<Grid>
<app:BrowserView x:Name="BrowserView" />
</Grid>
</Window>在后端代码中,窗口会监听生命周期事件,并在打开时初始化浏览器组件:
public partial class MainWindow : Window
{
private const string Url = ResourceRequestHandler.Domain;
public IBrowser? Browser { get; set; }
public IServiceProvider? ServiceProvider { get; set; }
public MainWindow()
{
InitializeComponent();
Opened += OnOpened;
Closed += OnClosed;
}
private async void OnOpened(object? sender, EventArgs e)
{
Browser = ServiceProvider
.GetRequiredService<IEngineService>()
.CreateBrowser();
BrowserView.InitializeFrom(Browser);
await Browser.Navigation.LoadUrl(Url);
}
private void OnClosed(object? sender, EventArgs e)
{
Browser?.Dispose();
}
}Engine 本身由 IEngineService 管理,因此窗口只需在打开时请求新的浏览器实例,并在关闭时释放它。这样可以将 Engine 的生命周期和配置集中管理。
在桌面应用中加载 Web UI
将 Angular 应用移入嵌入式浏览器会改变其加载方式。
在生产环境中,编译后的压缩文件会被打包到桌面应用中,而非通过常规浏览器标签页的 http://localhost 地址提供服务。
不过,在在开发阶段,您仍希望获得开发服务器的快速反馈和热重载功能。
在开发模式下,可保持常规 Angular 工作流:启动 ng serve 并加载开发 URL,这样热重载功才能得以保留。
生产环境中的 Web UI 加载
在生产环境中,我们不希望依赖开发服务器,甚至不希望使用任何 HTTP 服务器。相反,嵌入式浏览器会加载类似 dnb://internal.host/ 这样的自定义协议 URL。
DotNetBrowser 可拦截该协议请求,并通过嵌入资源响应数据而非网络请求。配置此协议处理器后,Angular 应用完全由包内部提供:
var builder = new EngineOptions.Builder
{
RenderingMode = RenderingMode.HardwareAccelerated
};
builder.Schemes.Add(Scheme.Create("dnb"), handler);private string ConvertToResourcePath(string url)
{
string path = url.Replace(Domain, string.Empty, StringComparison.Ordinal);
if (string.IsNullOrWhiteSpace(path) || path == "/")
{
path = "browser/index.html";
}
if (!path.StartsWith("browser/", StringComparison.OrdinalIgnoreCase))
{
path = $"browser/{path}";
}
return prefix + path.TrimStart('/').Replace("/", ".");
}完成资源加载配置后,剩下的关键步骤是让 Angular 与 .NET 之间进行信息交换。
Web 应用与 .NET 之间的通信
与任何 Web UI 一样,Angular 应用也需要与后端进行通信。这便是应用程序的 .NET 部分,负责处理业务逻辑,而 Web UI 则负责用户交互。关键问题在于如何将这两部分连接起来,同时确保在应用程序演进过程中保持可维护性。
JavaScript 与 .NET 的直接桥接
最直接的方式是将 .NET 方法直接暴露给 JavaScript。DotNetBrowser 支持这种方式。首先,定义一个 C# 类:
public class PrefsService
{
private readonly PreferencesStore store;
public PrefsService(PreferencesStore store)
{
this.store = store;
}
public void SetAccountEmail(string email)
{
var account = store.GetAccount();
account.Email = email;
store.SetAccount(account);
}
}
其次,在 TypeScript 中映射其结构:
declare class PrefsService {
setAccountEmail(email: string): void;
}
declare const prefsService: PrefsService;
// ...
prefsService.setAccountEmail('john.doe@example.com');
最后,将 C# 对象注入 JavaScript 环境:
browser.InjectJsHandler = new Handler<InjectJsParameters>(p =>
{
dynamic window = p.Frame.ExecuteJavaScript("window").Result;
if (window != null)
{
window.prefsService = new PrefsService(store);
}
});
对于小型项目来说,这种方式简单直观,也容易理解。但当 API 增长到不止几个方法时,就会在 C# 和 TypeScript 中重复定义同样的契约,并依赖人工保持同步。
为避免这种问题,该应用将桥接层视为一个 小型 HTTP API,并使用工具自动生成客户端,具体做法将在 OpenAPI 章节中介绍。
OpenAPI
在这种架构中,Web 与 .NET 之间的契约通过 OpenAPI 进行一次性描述。基于同一份规范,可以生成 强类型的 C# 和 TypeScript 模型与客户端,从而确保两端在 API 演进过程中始终保持同步。
通信协议通过 OpenAPI 定义,无需服务器支持。
处理 API 请求时,我们采用与加载网页相同的 dnb:/ 方案拦截器。此设计使应用保持自包含特性,避免暴露用户可在普通浏览器中访问的 localhost 端口或远程端点。
客户端方面,生成的 TypeScript 服务通过常规 fetch() API 进行通信。这确保 TypeScript 代码具备可移植性,并与常规浏览器环境兼容。
OpenAPI 契约概览
以下是我们用于演示项目的 OpenAPI 定义精简版:
paths:
/account:
get:
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Account'
put:
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Account'
components:
schemas:
Account:
type: object
properties:
email:
type: string
fullName:
type: string
twoFactorAuthentication:
type: string
biometricAuthentication:
type: boolean
以下是 TypeScript 代码如何使用自动生成的 API 客户端:
const account = await DefaultService.getAccount();
const next: Account = {
...account,
email: 'john.doe@example.com',
};
await DefaultService.putAccount(next);
在服务器端,相同的 OpenAPI 定义支持拦截器处理 dnb://internal.host/api/* 请求,并将它们路由到业务逻辑类:
private InterceptRequestResponse HandleApiRequest(
InterceptRequestParameters parameters,
string path)
{
return (method, trimmedPath) switch
{
("GET", "account") => JsonResponse(parameters, store.GetAccount()),
("PUT", "account") =>
HandlePut<Account>(parameters, a => store.SetAccount(a)),
("GET", "profile-picture") =>
JsonResponse(parameters, store.GetProfilePicture()),
("PUT", "profile-picture") =>
HandlePut<ProfilePicture>(
parameters,
pic => store.SetProfilePicture(pic)),
_ => CreateResponse(parameters, HttpStatusCode.NotFound)
};
}该架构确保协议类型安全、支持离线操作,并保持与常规浏览器环境的兼容性,因为它依赖标准 HTTP 语义。
总结
DotNetBrowser 与 Avalonia 提供了原生、跨平台的桌面外壳,而 Angular 与 PrimeNG 则带来了现代化的 Web UI。通过自定义协议加载 UI,可以保持应用完全自包含;而基于 OpenAPI 的契约,则允许 JavaScript 通过 fetch 调用 .NET,而无需额外启动服务器。
发送中。。。
您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。
