基于 Angular UI 的 C# 桌面应用

现代用户体验已成为桌面应用的基础要求,采用 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 组件库,但在该架构下,任何类似的组件库都可以使用。

将网页界面嵌入桌面壳层时,需克服以下障碍:

  1. 需采用符合现代网页标准的可靠网页视图组件。
  2. 需实现无服务器环境下的网页内容加载,确保应用自包含性。
  3. 需建立 JavaScript 与 .NET 之间的双向通信机制。

后续章节将依次说明:窗口与网页视图的配置方案、无服务器加载 Angular UI 的实现方法,以及通过 OpenAPI 规范定义的接口连接 JavaScript 与 .NET 的具体方案。

应用窗口与网页视图

对于桌面壳层,我们使用 Avalonia 创建原生窗口和布局,并通过 DotNetBrowser(基于 Chromium 的跨平台网页视图组件)将其嵌入其中:

MainWindow.axaml

xml 复制代码
<Window Width="1200" Height="800" Title="Angular Demo">
    <Grid>
        <app:BrowserView x:Name="BrowserView" />
    </Grid>
</Window>

在后端代码中,窗口会监听生命周期事件,并在打开时初始化浏览器组件:

MainWindow.axaml.cs

csharp 复制代码
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 应用完全由包内部提供:

EngineService.cs

ini 复制代码
var builder = new EngineOptions.Builder
{
    RenderingMode = RenderingMode.HardwareAccelerated
};
builder.Schemes.Add(Scheme.Create("dnb"), handler);

ResourceRequestHandler.cs

lua 复制代码
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# 类:

csharp 复制代码
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 中映射其结构:

typescript 复制代码
declare class PrefsService {
  setAccountEmail(email: string): void;
}

declare const prefsService: PrefsService;
// ...
prefsService.setAccountEmail('john.doe@example.com');

最后,将 C# 对象注入 JavaScript 环境:

csharp 复制代码
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 演进过程中始终保持同步。

Angular UI 与 .NET 处理程序的通信

处理 API 请求时,我们采用与加载网页相同的 dnb:/ 方案拦截器。此设计使应用保持自包含特性,避免暴露用户可在普通浏览器中访问的 localhost 端口或远程端点。

客户端方面,生成的 TypeScript 服务通过常规 fetch() API 进行通信。这确保 TypeScript 代码具备可移植性,并与常规浏览器环境兼容。

OpenAPI 契约概览

以下是我们用于演示项目的 OpenAPI 定义精简版:

yaml 复制代码
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 客户端:

typescript 复制代码
const account = await DefaultService.getAccount();

const next: Account = {
  ...account,
  email: 'john.doe@example.com',
};

await DefaultService.putAccount(next);

在服务器端,相同的 OpenAPI 定义支持拦截器处理 dnb://internal.host/api/* 请求,并将它们路由到业务逻辑类:

ResourceRequestHandler.cs

typescript 复制代码
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,而无需额外启动服务器。

相关推荐
稀饭5215 小时前
用changeset来管理你的npm包版本
前端·npm
PPPHUANG15 小时前
一次 CompletableFuture 误用,如何耗尽 IO 线程池并拖垮整个系统
java·后端·代码规范
Komorebi゛15 小时前
【CSS】斜角流光样式
前端·css
Irene199115 小时前
CSS 废弃属性分类总结
前端·css
用户83562907805115 小时前
用Python轻松管理Word页脚:批量处理与多节文档技巧
后端·python
青莲84315 小时前
Android 事件分发机制 - 事件流向详解
android·前端·面试
musashi15 小时前
用 Electron 写了一个 macOS 版本的 wallpaper(附源码、下载地址)
前端·vue.js·electron
满天星辰15 小时前
Typescript之类型总结大全
前端·typescript
JFChen15 小时前
Web 仔用 Node 像 Java 一样写后端服务
前端