现代用户体验已成为桌面应用的基础要求,采用 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

运行在 Avalonia 窗口中的 Angular UI。

UI 使用了 PrimeNG 作为现成的 Angular 组件库,但在该架构下,任何类似的组件库都可以使用。

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

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

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

应用窗口与网页视图 

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

MainWindow.axaml
<Window Width="1200" Height="800" Title="Angular Demo">
    <Grid>
        <app:BrowserView x:Name="BrowserView" />
    </Grid>
</Window>

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

MainWindow.axaml.cs
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
var builder = new EngineOptions.Builder
{
    RenderingMode = RenderingMode.HardwareAccelerated
};
builder.Schemes.Add(Scheme.Create("dnb"), handler);
ResourceRequestHandler.cs
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 演进过程中始终保持同步。

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

通信协议通过 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/* 请求,并将它们路由到业务逻辑类:

ResourceRequestHandler.cs
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,而无需额外启动服务器。

Spinner

发送中。。。

抱歉,发送中断

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

阅读并同意条款以继续。

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