65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Webview 的 .NET Core UI

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2019年11月30日

CPOL

11分钟阅读

viewsIcon

27593

使用操作系统原生 webview,通过 .Net Core 编写跨平台桌面应用程序。

引言

借助 .NET Core,微软发布了一个强大的跨平台 .NET 开发工具,但在 UI 方面有所欠缺。通常,对于跨平台 UI,许多人会选择 Electron(例如非常成功的 Visual Studio Code),它基于 Node.js 作为运行时,并嵌入 Chromium 作为 webview。嵌入 webview 可以确保所有平台上的外观(大部分)相同,但会占用相当大的空间。Windows 或 macOS 等现代操作系统内置了 webview,而在 Linux 上,可以使用包管理器轻松添加(如果默认未安装)。所有这些都促使我创建了一个 .NET Core 库,该库通过操作系统原生 webview 提供 UI。它被称为 SpiderEye,本文将介绍如何使用它以及一些关于其实现原理的背景信息。

背景

本节讨论该库的工作原理。如引言所述,该库使用每个操作系统内置的 webview。为了使用 webview,我还需要使用原生 API 实现一些窗口管理。自然,这涉及到大量的 P/Invoke 调用和指针。在公共 API 中,所有这些都被隐藏起来了。

通常,每个平台都有一个静态 Application 类(例如 LinuxApplication),它执行该平台所需的任何初始化,并将一个工厂类注入到处理所有平台应用程序的通用 Application 类中。然后有一个所有平台通用的 Window 类,它使用之前注入的工厂类在内部与原生对应物进行通信。webview 本身不直接暴露,但 Window 类提供了所需的功能。对于 API 消费者来说,窗口就是 webview。为了启用与 webview 的通信,Window 类提供了一个桥接对象。通过它,您可以调用 webview 端的一个处理程序(必须先用 JavaScript 注册),或者您可以注册一个处理来自 webview 的调用的对象。该对象只是一个带有方法的简单类,可以与 MVC 中的 Controller 进行比较。

要加载到 webview 中的内容(HTML、CSS 等)直接通过 webview 提供的 API 提供服务,无需额外的服务器。唯一的例外是 Windows Forms WebBrowser 控件,它没有用于此目的的 API,而是使用一个直接托管在应用程序中的简单 localhost 服务器。内容本身通常嵌入在程序集中,但也可以从其他地方加载。

Windows

Windows 是最容易实现的平台,因为我可以直接使用 Windows Forms 和 WebBrowser 控件,而无需调用任何原生 API。WebBrowser 控件基于 IE,而在 Windows 10 上,可以使用更现代的、基于 Edge 的 webview。幸运的是,也有一个 .NET API 可用,只需要一次原生调用即可检查确切的 Windows 10 版本,因为它仅从 build 17134 开始可用。在撰写本文时,新的带 Chromium webview 的 Edge 尚未完全发布,但一旦发布,将会包含。在创建窗口时,会根据操作系统和用户配置决定使用哪个 webview。

Linux

在 Linux 上,窗口处理使用 GTK 实现,webview 使用 webkit2gtk 实现。我选择 GTK 是因为它非常普及,即使在非 GTK 桌面环境中也经常已经安装。它也易于使用。例如,这是创建窗口并附加 webview 的(略有编辑的)代码。

// create the window
Handle = Gtk.Window.Create(GtkWindowType.Toplevel);

// create and add a scrolling container
IntPtr scroller = Gtk.Window.CreateScrolled(IntPtr.Zero, IntPtr.Zero);
Gtk.Widget.ContainerAdd(Handle, scroller);
// add the webview (created earlier) to the scrolling container
Gtk.Widget.ContainerAdd(scroller, webview.Handle);

Gtk.WidgetGtk.Window 是静态类,包含 P/Invoke 函数,例如 Create

macOS

macOS 使用 Cocoa 和 WKWebView,这是最难工作的平台。主要是因为大多数时候您不能直接调用原生函数,而必须使用 Objective-C 运行时,并在此基础上使用 Objective-C 语法。大多数时候,您只需要使用 objc_getClass 获取类型的指针,使用 sel_registerName 获取方法或属性的指针,然后使用各种重载的 objc_msgSend 来实际执行方法或属性。这就是窗口的创建和初始化方式。

// create a window
Handle = AppKit.Call("NSWindow", "alloc");

// get the style for the window, it's just an enum with various flags
var style = GetStyleMask(config.CanResize);

// initialize the window
ObjC.SendMessage(
    Handle,
    ObjC.RegisterName("initWithContentRect:styleMask:backing:defer:"),
    new CGRect(0, 0, config.Size.Width, config.Size.Height),
    style,
    new UIntPtr(2),
    false);

与之前的 Linux 一样,AppKitObjC 是包含 P/Invoke 调用的静态类,AppKit.Call 只是一个组合了上述三个函数的辅助方法,其用法如下。

public static IntPtr Call(string id, string sel)
{
    return ObjC.SendMessage(GetClass(id), ObjC.RegisterName(sel));
}

使用库

SpiderEye 应用程序通常由四个项目组成:一个包含通用逻辑和 Web 相关内容(如 HTML、CSS、JavaScript 等)的核心库,以及每个平台(Windows、Linux、macOS)的一个项目。

一个简单示例

最简单的示例会在核心库中包含共享的启动代码,如下所示。

namespace SpiderEye.Example.Simple.Core
{
    public abstract class ProgramBase
    {
        protected static void Run()
        {
            // this creates a new window with default values
            using (var window = new Window())
            {
                // this provides webview content from files embedded in the assembly
                Application.ContentProvider = new EmbeddedContentProvider("App");

                // runs the application and opens the window with the given page loaded
                Application.Run(window, "index.html");
            }
        }
    }
}

EmbeddedContentProvider 类简单地从嵌入到库中的文件加载 webview 请求的文件(例如 index.html)。对于此示例,我们有一个名为“App”的文件夹,其中包含这些客户端文件。要嵌入文件并保持路径名不变,您必须将此添加到 csproj 文件中。

<ItemGroup>
  <!-- The App folder is where all our html, css, js, etc. files are -->
  <EmbeddedResource Include="App\**">
    <!-- this retains the original filename of the embedded files 
         (required to located them later) -->
    <LogicalName>%(RelativeDir)%(Filename)%(Extension)</LogicalName>
  </EmbeddedResource>
</ItemGroup>

平台特定的项目只需要一个启动类来初始化平台并调用通用启动逻辑,例如,对于 Windows。

using System;
using SpiderEye.Windows;
using SpiderEye.Example.Simple.Core;

namespace SpiderEye.Example.Simple
{
    class Program : ProgramBase
    {
        [STAThread]
        public static void Main(string[] args)
        {
            // initializes Windows specific things to run the app
            WindowsApplication.Init();
            // run the app by calling the common startup logic from the core library
            Run();
        }
    }
}

完整的示例项目以及一些更复杂的项目可以在 Github 上找到。

API

SpiderEye 的公共 API 相对简单,主要围绕 ApplicationWindow 类。

Application 类

这个类是每个应用程序的起点,工作方式与 Windows Forms Application 类非常相似。

属性
static bool ExitWithLastWindow { get; set; }

使用 ExitWithLastWindow,您可以设置当最后一个窗口关闭时整个应用程序是否也关闭。通常,此设置为 true。如果您有一个后台应用程序(通常与 StatusIcon 结合使用)或者您在 macOS 上,您可能希望将其设置为 false

static IContentProvider ContentProvider { get; set; }

通过内容提供程序,您可以设置一个从某处加载 webview 文件的对象。通常,您会像上面的示例一样使用 EmbeddedContentProvider,但如果您需要,可以轻松实现自定义的。

static IUriWatcher UriWatcher { get; set; }

UriWatcher 对开发非常有用。它的作用是检查即将加载到 webview 中的 URI,并在需要时将其替换为另一个。一个用例是,如果您使用 Angular 开发服务器之类的东西,在开发时会希望将任何请求指向开发服务器,但在发布时不这样做。

static OperatingSystem OS { get; }

OS 属性简单地返回您当前运行的操作系统。

方法
static void Run()
static void Run(Window window)
static void Run(Window window, string startUrl)

调用 Run 会启动主循环,并阻塞直到应用程序退出(例如,当所有窗口都关闭或调用 Exit 时)。有重载函数可以传入一个在调用 Run 时显示的窗口,并且您可以指定一个立即加载的起始 URL。

static void Exit()

使用 Exit,您可以关闭整个应用程序,并且 Run 调用将返回。

static void Invoke(Action action)
static T Invoke<T>(Func<T> action)

使用 Invoke 在主线程上执行一些代码。如果您想从其他线程访问 UI,则需要这样做。如果您不确定当前在哪个线程上,也可以安全地从主线程调用它。

static void AddGlobalHandler(object handler)

使用 AddGlobalHandler,您可以注册一个对象来处理来自任何 webview 的调用(无论它在哪个窗口中)。这将在下面更详细地描述,此处

窗口类

通过窗口类,您可以管理窗口(显而易见)、在 webview 中加载 URL 以及与 webview 进行通信。

属性

我不会在此列出所有属性,因为大多数都很明显(例如 TitleSize)。

string BackgroundColor { get; set; }

BackgroundColor 属性设置窗口和 webview 的背景颜色(如果可能)。您应该将其设置为与网页上的颜色相同,以避免在页面加载时出现任何闪烁。使用六位十六进制代码,例如 "#FFFFFF"

bool UseBrowserTitle { get; set; }

如果将 UseBrowserTitle 设置为 true,窗口标题将反映当前加载的 HTML 页面中设置的标题。

bool EnableScriptInterface { get; set; }

使用 EnableScriptInterface,您可以设置是否允许 webview 与 .NET 端进行通信。

IWebviewBridge Bridge { get; }

这将获取桥接对象,您可以使用它与 webview 进行通信。它将在下面更详细地描述,此处

方法
void Show()
void Close()

ShowClose 非常直观,它们显示或关闭窗口。

void SetWindowState(WindowState state)

使用 SetWindowState,您可以将窗口状态更改为例如最小化或最大化。

void LoadUrl(string url)

使用 LoadUrl,您可以在 webview 中加载页面。如果提供相对 URL(例如“/index.html”),它将尝试使用 Application.ContentProvider 加载它,绝对 URL(例如“https://codeproject.org.cn”)将直接加载。

IWebviewBridge 接口

这个接口是您的 .NET 应用程序和 webview 之间的桥梁。您可以用它做两件事:在 webview 端执行某些操作,或者提供一个对象来处理来自 webview 的调用。

处理来自 Webview 的调用

使用 AddHandler 注册一个对象来处理来自单个特定窗口/webview 的调用,或者使用 AddGlobalHandler 注册一个对象来处理来自所有窗口/webview(包括稍后创建的)的调用。

void AddHandler(object handler)
void AddGlobalHandler(object handler)

您注册的对象只是一个带有方法的简单类,类似于 MVC 中的 Controller。

public class UiBridge
{
    // methods can be async and return a Task or Task<T>
    public async Task RunLongProcedureOnTask()
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
    }

    // they can return complex or simple types.
    // just keep in mind that they are converted to JSON
    public SomeDataModel GetSomeData()
    {
        return new SomeDataModel
        {
            Text = "Hello World",
            Number = 42,
        };
    }

    // they can receive one parameter from the webview.
    // if you need more than one value, use a model like here
    public double Power(PowerModel model)
    {
        return Math.Pow(model.Value, model.Power);
    }

    // any uncaught exceptions are relayed to the webview
    public void ProduceError()
    {
        throw new Exception("Intentional exception from .Net");
    }
}

在 webview 端,该对象的公共方法可以通过“ClassName.camelCaseMethodName”的路径访问。例如,使用我们的类:“UiBridge.getSomeData”。

要实际从客户端调用,建议安装“spidereye”npm 包。它包含 SpiderEye 对象,该对象提供了使桥接更易于使用的函数。如果这不可行,您可以直接使用注入的 window._spidereye 对象。请注意,此对象可能不是立即可用的,您应该首先检查它是否存在,如果不存在,则订阅 spidereye-ready 事件以在它准备好时收到通知,例如 window.addEventListener("spidereye-ready", callback)

在以下示例中,我将使用“spidereye”npm 包的语法,但您可以将“SpiderEye”替换为“window._spidereye”,它仍然应该可以工作。

因此,要在 JavaScript 中调用我们的桥接对象,请执行类似的操作。

const parameters = {
    value: 2,
    power: 6,
};

SpiderEye.invokeApi("UiBridge.power", parameters, result => {
    if (!result.success) {
        console.error(result.error);
    } else {
        console.log(result.value);
    }
});
在 Webview 中执行操作

使用 InvokeAsync 在 webview 中执行一些操作。id 参数是您在 webview 中注册处理程序时使用的相同值(参见示例),data 参数可以是您想传递的任何 JSON 可序列化值。

Task InvokeAsync(string id, object data)
Task<T> InvokeAsync<T>(string id, object data)

与之前一样,我在本示例中使用“spidereye”npm 包,之前的说明也适用于此。

首先,我们需要像这样在 JavaScript 端注册处理程序。

SpiderEye.addEventHandler("gimmeSomeData", data => {
    console.log(data);
    return "I got some data: " + data;
});

您不必在处理程序中返回任何内容,但可以这样做。在 .NET 中,如果您没有/不需要响应,请使用 InvokeAsync;如果您期望响应,请使用 InvokeAsync<T>

然后在 .NET 端,您可以像这样调用该处理程序(假设您有一个名为 windowWindow 实例)。

string result = await window.Bridge.InvokeAsync<string>("gimmeSomeData", 42);
// result should now be "I got some data: 42"

在 JavaScript 中,您也可以通过调用以下方式删除该处理程序。

SpiderEye.removeEventHandler("gimmeSomeData");

StatusIcon 类

使用 StatusIcon 类,您可以为您的应用程序添加一个带有菜单的状态图标。您可以这样使用它(相关的应用程序配置已省略)。

using (var statusIcon = new StatusIcon())
{
    var menu = new Menu();
    var exitItem = menu.MenuItems.AddLabelItem("Exit");
    exitItem.Click += (s, e) => Application.Exit();
    
    statusIcon.Icon = AppIcon.FromFile("icon", ".");
    statusIcon.Title = "My Status Icon App";
    statusIcon.Menu = menu;

    Application.ExitWithLastWindow = false;
    Application.Run();
}

AppIcon 类

此类表示应用程序的图标。它的设计方式便于在任何平台上使用。您可以从文件创建一个实例。

static AppIcon FromFile(string iconName, string directory)
static AppIcon FromFile(string iconName, string directory, bool cacheFiles)

或从嵌入在程序集中的资源创建。

static AppIcon FromResource(string iconName, string baseName)
static AppIcon FromResource(string iconName, string baseName, Assembly assembly)

例如,从文件创建图标。

var icon = AppIcon.FromFile("icon", ".");

它将在应用程序所在的目录(即可执行文件旁边)查找名为“icon”的文件。至于文件扩展名,它会在 Windows 上查找“icon.ico”,在 Linux 上查找“icon.png”,在 macOS 上查找“icon.icns”。ico 和 icns 格式可以在同一文件中包含多个分辨率,但 png 不行。因此,在 Linux 上,提供多个缩放到所需分辨率的文件(以获得更好的质量)可能很有意义。AppIcon 类将查找格式为

icon.png
icon32.png
icon-32.png
icon_32.png
icon-32x32.png
icon-32-32.png
icon-32_32.png

其中数字表示分辨率,如 Width x Height(单个数字表示图标是正方形)。

对话框

该库还包含显示对话框的类。

消息框

MessageBox.Show("Message", "Title", MessageBoxButtons.Ok)

保存文件对话框

var dialog = new SaveFileDialog
{
    Title = "My Save Dialog",
    InitialDirectory = "/Some/Directory",
    FileName = "SaveFile.png",
    OverwritePrompt = true,
};

var result = dialog.Show();
if (result == DialogResult.Ok)
{
    string selectedFile = dialog.FileName;
}

打开文件对话框

var dialog = new OpenFileDialog
{
    Title = "My Open Dialog",
    InitialDirectory = "/Some/Directory",
    FileName = "OpenFile.png",
    Multiselect = false,
};

var result = dialog.Show();
if (result == DialogResult.Ok)
{
    string selectedFile = dialog.FileName;
}

实际应用

Windows

Linux (Manjaro, KDE Plasma)

macOS

进一步说明

SpiderEye 库是开源的(Apache-2.0),可在 Github 上找到。

如果您遇到任何错误或有功能请求,请在此处提交一个 issue。

还有完整的示例、安装说明、项目模板(C#、VB.NET、F#)等。

历史

  • 2019 年 11 月 30 日 - 初始版本
© . All rights reserved.