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

Spectre 框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (21投票s)

2012年12月10日

Apache

17分钟阅读

viewsIcon

37110

Spectre 框架试图将 HTML5 作为一等公民 UI 语言引入到基于 CLR 的应用程序中。

引言

Spectre 框架旨在将 Web 和桌面应用程序的所有属性结合到一个程序中。该框架基于 .NET Framework 和 Chromium 浏览器,使我们能够使用纯 HTML5 在其原生环境中创建 UI,就像创建网页一样,同时允许我们使用托管代码扩展 HTML/JavaScript 运行时,从而收获 CLR Runtime 的所有积极方面。

在本文中,我希望分享当前的进展并将我的发现公之于众,希望能获得一些反馈。目前,整个项目仅仅是我好奇心的产物,仍有许多悬而未决的问题需要解决,但它在 Windows 平台上的稳定性足以让您尝试一下。

从 GitHub 下载源代码和依赖项 

背景与动机

正如大多数行业一样,竞争和 rivalry 在 IT 行业中并非陌生人。
大公司花费巨资嘲讽彼此及其产品和技术。
因此,观察不同公司通过达到一个中立但共同的目标来竞争,这令人耳目一新。我当然在谈论 HTML,特别是其最新的草案 #5。

Google、Apple、Microsoft 等主要公司不断努力提高其产品的标准,以符合 HTML5 标准设定的指导方针。
当然,也存在一些冲突和争论点,例如 video 标签,但我有信心,这些问题最终会得到解决。

随着几乎整个 IT 行业都拥抱一项技术,我敢说,它最终将超越市场上的任何竞争产品,如果还没有的话。此外,随着它的发展和成熟,它甚至可能进入意想不到的领域,这是本文和底层程序的一个方面。

就在几年前,在 HTML5 草案之前,当 Flash 仍然是互联网的闪耀之星时,也许没有人敢于设想将 html 用作桌面应用程序的前端,其功能充其量是有限的。然而,今天,尽管草案尚未完成,但已经涌现出各种工具和技术,使我们能够迈出下一步。

我并非第一个发表此评论的人,可能也不会是最后一个,甚至可能有点迟了,因为我们最近目睹了一款允许创建 HTML5 桌面应用程序的操作系统正式发布,即 Windows 8。大多数人,我知道并非所有人,可能会同意我的看法,即使用 HTML 和 CSS 进行 UI 开发是一种愉快的体验,与大多数桌面小部件或控件库相比。然而,一个应用程序不仅仅包含一个漂亮的 UI,通常还附带非同寻常的复杂业务逻辑。不幸的是,城里唯一的真正“HTML 脚本语言”是 Javascript,虽然 Javascript 在与 HTML 交互方面做得非常出色,但对于创建健壮、可维护、复杂、中到大型应用程序来说,它是一个糟糕的选择,这引出了本文的第二个方面。

由于我非常喜欢 CLR,我决定用 .NET Framework 本身来替换 Javascript 作为业务应用程序层的驱动程序。

因此,一方面,我们有 HTML5 作为一种出色的 UI 开发技术;另一方面,我们有 CLR,无论其实现如何,无论是 .NET 还是 Mono,都完全有资格成为高度复杂、健壮且可维护的应用程序设计的基础。

该项目致力于将这两个世界结合起来,不是通过创建某种不兼容的混合体,而是通过建立一座桥梁,从而最大限度地保留双方的所有方面。

概述

在深入代码之前,我想先介绍一个应用程序的小型横截面,这将使事情更容易理解,甚至可能提前回答一些重要问题,这些问题可能会在以后出现。

由于我是一个人,每天平均只有 24 小时,我没有时间重新发明轮子。尽管 Spectre 框架有一个或两个自己的类,但它主要由外部应用程序和库组成。

Chromium

可能最大、最重要的组件是实际的 HTML 渲染器,即 Chromium 浏览器本身。选择很简单,因为它开源、极快、可靠、跨平台,拥有庞大的社区,并且在支持 HTML5 方面无与伦比。

这个选择比任何其他选择都更能塑造应用程序,因为我们因此继承了浏览器的许多特性,有些是故意的,有些是非故意的。然而,影响最严重的是浏览器的内在多进程架构,顺便说一句,这属于非故意的范畴。

图 1

如图 1 所示,基本的 Chromium 架构由一个浏览器进程和一个或多个渲染进程组成。架构的详细描述可以在关于 多进程架构的文章中找到。虽然它是一个复杂的构造,但您只需要记住一个细节。您的代码将在两个不同的进程中运行,如果您希望跨越这些边界进行通信,您将需要使用 Chromium 提供的 IPC 通道。我们稍后将通过一个示例来说明这个过程。

这算是一份祝福还是诅咒,由您决定,因为它确实增加了应用程序的复杂性,以换取稳定性和性能。

此外,我们几乎免费获得了以下功能:

我说“几乎免费”,因为我们在大小方面付出了代价。即使您的项目没有贡献一行代码,Chromium 及其所有依赖项也将占用 50MB 的固定大小,并且所有功能都完整。
通过放弃一些功能,例如调试工具和编解码器,可以减小此大小,但减小幅度不大,最多可以减到 40MB。

Chromium 嵌入式框架

尽管 Chromium 带来了所有积极的属性,但宏伟的设计中缺少了一个非常重要的部分。Chromium 从未打算被嵌入,……它没有 API。

幸运的是,Chromium 嵌入式框架通过 C 语言 API 暴露了 Chromium 的内部机制,解决了这个问题。我不会在这里详细介绍,因为尽管 CEF 是一个出色且半复杂的项目,但它的目的无需进一步解释。

CLR Runtime

我们需要最后一个外部成分来完成我们的菜肴,那就是 CLR Runtime,无论是 .NET Framework 还是 Mono Project,都没关系,两者都可以正常工作,我就说到这里。

Spectre 框架

Spectre 框架就坐落在这之上。它的目的是以一种符合托管类库设计指南的方式公开所有本机组件的访问权限,从而给人一种使用完全托管框架进行编码的印象。第二个目的是隐藏原生代码带来的大部分职责,例如 P/Invoke 或手动引用计数。
该框架目前共有三个输出库

  • Crystalbyte.Spectre.Framework.dll
  • Crystalbyte.Spectre.Projections.dll
  • Crystalbyte.Spectre.Razor.dll
Projections 库包含原始的 P/Invoke 绑定声明,例如结构体、委托和工厂类,而 Framework 程序集将这些组织成面向对象的、.NET 风格的结构。
Razor 程序集是可选的,它只是为应用程序添加 Razor 支持,因此依赖于 libs 目录中的 System.Web.Razor.dll,如果您需要或想要的话。

使用框架

这个项目的全部目的是创建一个框架,它允许使用通用、高效且最先进的工具快速轻松地开发桌面应用程序。
首先需要引用 Crystalbyte.Spectre.Framework.dll 程序集。Razor 程序集稍后将包含进来,Projections 程序集将作为依赖项一同引入,不需要直接引用。

入门

为了开始,我们将需要两个类和一个接口,我们将从 `Crystalbyte.Spectre` 命名空间中的 Bootstrapper 开始。

如果您打开 Windows 平台的示例目录,您会找到几个项目,演示了框架的各种功能。

Bootstrapper 类

图 2

Bootstrapper 是一个抽象类,其唯一目的是将所有引导调用按正确的顺序排列。如图 2 所示,引导程序提供了一些可重写的方法来配置和扩展启动例程。
有一个抽象方法,名为 `CreateViewports`,需要实现。
虽然 `Viewport` 本身在这里并不重要,但它的构造函数参数很重要。这些参数的类型是 `IRenderTarget` 和 `BrowserDelegate`。以下代码片段显示了 `Bootstrapper` 的最小实现。

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
        protected override IEnumerable<Viewport> CreateViewports() {
            yield return new Viewport(
                new Window {StartupUri = new Uri("spectre:///Views/index.html")},
                new BrowserDelegate());
        }
    } 
}  

您可能已经注意到,启动 URI 有些特别。
该框架能够使用所有常见的方案,如 file、http、https 等,然而,这些方案附带所有常规的安全限制。为避免干扰已建立的实现,我创建了一个新方案,它的行为类似于文件方案,但解除了一些限制以达到目标。因此,桌面应用程序页面的每个基本 URI 都需要以 `"spectre:///"` 开头。

IRenderTarget 接口

您想要渲染到的任何控件都必须实现 `IRenderTarget` 接口。
它是一个接口,因此可以应用于任何控件、窗口或小部件,无论其技术如何,例如 Winforms、Gtk 或 WPF。它暴露了一个句柄和几个窗口事件,请参见图 3。

Interface: IRenderTarget

图 3

以下代码片段显示了在所有示例中使用的 WinForms 窗体的 `IRenderTarget` 实现。`Handle` 属性缺失,因为它已由基类公开,除此之外,没有什么神秘之处。

namespace Crystalbyte.Spectre.Samples {
    public partial class Window : Form, IRenderTarget {
        public Window() {
            InitializeComponent();
        }
 
        #region IRenderTarget Members
 
        public Uri StartupUri { get; set; }
 
        public event EventHandler<SizeChangedEventArgs> TargetSizeChanged;
 
        public void NotifySizeChanged(Size size) {
            var handler = TargetSizeChanged;
            if (handler != null) {
                handler(this, new SizeChangedEventArgs(size));
            }
        }
 
        public event EventHandler TargetClosed;
 
        public void NotifyTargetClosed() {
            var handler = TargetClosed;
            if (handler != null) {
                handler(this, EventArgs.Empty);
            }
        }
 
        public event EventHandler TargetClosing;
 
        public void NotifyTargetClosing() {
            var handler = TargetClosing;
            if (handler != null) {
                handler(this, EventArgs.Empty);
            }
        }
 
        public new Size Size {
            get { return new Size(ClientRectangle.Width, ClientRectangle.Height); }
        }
 
        #endregion
 
        protected override void OnClosing(CancelEventArgs e) {
            NotifyTargetClosing();
            base.OnClosing(e);
        }
 
        protected override void OnClosed(EventArgs e) {
            NotifyTargetClosed();
            base.OnClosed(e);
        }
 
        protected override void OnSizeChanged(EventArgs e) {
            NotifySizeChanged(Size);
            base.OnSizeChanged(e);
        }
    }
}  

BrowserDelegate 类

browser delegate 是一个抽象类,所有浏览器事件都将委托给它。

图 4

如图 4 所示,该类与 WinForms 中的代码隐藏类用途相似。虽然目的微不足道,但有一个重要事实需要了解。在此实例中执行的任何代码都将在浏览器进程中执行,因此得名。

最后一步是运行引导程序,框架将从那里接管。

 namespace Crystalbyte.Spectre.Samples {
    internal static class Program {
        /// <summary>
        ///   The main entry point for the application.
        /// </summary>
        [STAThread]
        private static void Main() {
            var bootstrapper = new WinformsBootstrapper();
            bootstrapper.Run();
        }
    }
} 

根据启动文件,我们将看到类似这样的内容。截图显示了播放“Big Bug Bunny”预告片的视频示例。

如上所述,我们不局限于渲染到窗口,如 MultiView 示例所示,我们可以渲染到用户控件。请参见以下截图。

扩展 Javascript Runtime

虽然我们现在可以在原生窗口中渲染任意 HTML 代码,但有趣的部分还在后面。为了创建桥梁,我们需要一种简单的方式来进行 JSR 和 CLR 之间的相互调用。大多数示例都利用了此功能,然而,Extensions 项目使用同步和异步方法提供了更详细的实现。

Extension 类

由于 JavaScript 是一种无类语言,实现新功能的唯一方法是扩展已有的对象,在我们的例子中是 `window` 对象。在本教程中,我们将通过一个简单的乘法方法 `mult` 来扩展运行时,该方法接受两个整数并返回它们的乘积。由于直接向 `window` 对象添加方法是不好的做法,我们将函数存储在一个名为 `extensions` 的容器中。

为此,该框架提供了一个 `Extension` 类,我们需要继承并实现它。
以下代码显示了一种可能的实现

namespace Crystalbyte.Spectre.Samples.Extensions {
    public sealed class MultExtension : Extension {
        public override string RegistrationCode {
            get { return RegistrationCodes.Synthesize("extensions", "mult", "first", "second"); }
        }
 
        protected override void OnExecuted(ExecutedEventArgs e) {
            var first = e.Arguments[0].ToInteger();
            var second = e.Arguments[1].ToInteger();
            e.Result = new JavascriptObject(first * second);
            e.IsHandled = true;
        }
    }
}  

`RegistrationCode` 方法返回一段代码,告诉运行时我们想要添加一个扩展。`RegistrationCodes.Synthesize` 方法接受容器、函数、参数的名称,并从中合成一个有效的注册代码,细节不重要。`OnExecuted` 方法应该是不言自明的,每当我们从 JS 调用 `extensions.mult(x, y)` 时,它都会被调用。显然,异步版本涉及的代码稍多一些,但与其他环境中的代码不多不少。示例项目“Extensions”展示了一个可行的实现。 

注册扩展

下一步是向运行时注册此扩展,引导程序有一个可重写的方法可以做到这一点。以下代码片段说明了用法。

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {

        //...

        protected override IList<Extension> RegisterScriptingExtensions() {
            var extensions = base.RegisterScriptingExtensions();
            extensions.Add(new MultExtension());
            return extensions;
        }

        //...

    }
} 
我们成功地向 DOM 添加了一个新函数,现在可以从任何 Javascript 函数调用它。
        function() {
            var product = window.extensions.mult(4, 5);
        }   

还有一件事我需要提及。在扩展中执行的所有代码都在渲染进程中进行,这意味着我们无法在扩展和运行在浏览器进程中的代码(例如浏览器委托)之间直接通信。下一个教程将向我们展示如何克服这个障碍。

使用多进程架构

我们现在已经能够启动一个简单的应用程序并创建一个扩展,以便从 Javascript 调用托管代码。问题是,扩展运行在与应用程序不同的进程中,为了通信,我们需要跨越进程边界。幸运的是,此功能已经实现,但在开始之前,我们需要先看一下 **Browser** 类。

Browser 类

图 5

`browser` 类允许访问当前的 HTML/Javascript 环境,该环境被分割成 Frames。
虽然 Frame 是一个非常重要的类,因为它允许与环境进行大多数交互,例如导航、搜索、执行 Javascript 等……但对于这个示例,我们对此不感兴趣。

发送 IPC 消息

然而,有趣的是 `SendIpcMessage` 方法,我们将使用它将数据流从渲染进程发送到浏览器进程。

用法很简单,以下代码片段说明了它在扩展的 execute 方法中的用法。

namespace Crystalbyte.Spectre.Samples.Extensions {
    internal class ChangeWindowTitleExtension : Extension {
        public override string RegistrationCode {
            get { return RegistrationCodes.Synthesize("commands", "changeWindowTitle", "title"); }
        }
        protected override void OnExecuted(ExecutedEventArgs e) {
            var title = e.Arguments.First().ToString();
            if (string.IsNullOrWhiteSpace(title)) {
                // Chromium does not allow to send nothing over the wire, so we send the termination symbol instead.
                title = "\0";
            }
            var browser = ScriptingContext.Current.Browser;
            browser.SendIpcMessage(ProcessType.Browser, new IpcMessage("change-window-title") {
                Payload = title.ToUtf8Stream()
            });
            e.IsHandled = true;
        }
    }
}
 

第一个参数设置所需的目标,由于我们在 Renderer 中运行,我们希望将数据发送到 Browser。第二个参数是 Message 本身,它接受一个任意名称用于标识,以及一个序列化流形式的有效负载。由于我们现在能够发送消息,我们需要知道它们被发送到哪里。作为到浏览器进程的任何调用,它都会路由到 `BrowserDelegate`。如果您向上滚动并查看图 4,您会发现一个名为 `OnIpcMessageReceived` 的小事件处理程序,它将在浏览器线程上调用。

从消息处理程序内部,我们现在可以安全地访问浏览器进程中的任何代码。此项目附带的示例实现了一种通过在 html 输入控件中键入来更改窗口标题的方法。

namespace Crystalbyte.Spectre.Samples {
    internal class IpcBrowserDelegate : BrowserDelegate {
        private readonly Window _window;
        public IpcBrowserDelegate(Window window) {
            _window = window;
        }
        protected override void OnIpcMessageReceived(IpcMessageReceivedEventArgs e) {
            base.OnIpcMessageReceived(e);
            if (!e.Message.IsValid) {
                return;
            }
            var title = e.Message.Payload.ToUtf8String();
            if (_window.InvokeRequired) {
                _window.BeginInvoke(new Action(() => _window.Text = title));
            }
            else {
                _window.Text = title;
            }
        }
    }
}  

如果我们需要改变方向,即从浏览器发送消息到渲染器,我们可以用同样的方式做到,即通过访问在浏览器进程中运行的应用程序的当前浏览器。对渲染器端的 `BrowserDelegate` 而言,有一个对应项,即 `RenderDelegate`,这应该不会让任何人感到惊讶。从浏览器发送到渲染器的任何 IPC 消息都将委托给它。我们可以通过重写 Bootstrapper 中的 `CreateRenderDelegate` 方法来为生成的进程提供自定义 `RenderDelegate`。

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
        //...
 
        protected override RenderDelegate CreateRenderDelegate() {
            return new MyCustomRenderDelegate();
        }
 
        //...
    }
}   

正如您可能注意到的,没有直接向单个渲染器发送消息的方法,它本质上是对所有渲染器的广播;此外,渲染器之间无法直接通信,所有通信都必须通过浏览器进程。

Razor

通过扮演桌面上的客户端和服务器角色,托管页面的负担就落在了我们身上。如今,托管静态 HTML 已不再常见,网络上的大多数页面都是动态生成的,所以我想为桌面保留此属性。

在偶然发现 Rick Strahl 的一篇博客文章后,您可以在 这里阅读全文,我选择在项目中包含一个简单的 Razor 解析器。基于 Rick 的项目,并在他的祝福下,我将他的代码纳入了框架,使我们能够使用 Razor 语法动态创建 HTML 页面。

由于我选择使其成为一项可选功能,因此我们需要引用之前提到的 Crystalbyte.Spectre.Razor.dll 程序集到我们的代码中。

此时,我认为重要的是要注意,我选择不将整个 MVC 堆栈包含在项目中。我想保持应用程序尽可能简单,因此只实现了一个基本版本的 MVC 工作流。

如果您熟悉 MVC 应用程序,您会注意到以下区别。

  • 您必须手动注册控制器。
  • 每个控制器只有一个入口点和路由。
现在,为了开始,我们需要在框架中注册 razor 数据提供程序,这可以在 Bootstrapper 的 `RegisterSchemeHandlerFactories` 重写中完成。
namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
        //...
        protected override IList<ISchemeHandlerFactoryDescriptor> RegisterSchemeHandlerFactories() {
            var descriptors = base.RegisterSchemeHandlerFactories();
            var spectre = (SpectreSchemeHandlerFactoryDescriptor)
                          descriptors.First(x => x is SpectreSchemeHandlerFactoryDescriptor);
            spectre.Register(typeof (RazorDataProvider));
            return descriptors;
        }
        //...
    }
}  

这将使框架在给定路径无法解析资源文件时扩展搜索范围,这使我们能够注册一个 `Controller` 及其路由。

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
    //...
    protected override OnStarting(object sender, EventArgs e) {
            ControllerRegistrar.Register(typeof (HomeController));
            base.OnStarting(sender, e);
    
        }
    //... 
    }
}  

`Controller` 本身实现起来很简单,因为它缺乏其 MVC 对等项的大部分功能。

namespace Crystalbyte.Spectre.Samples.Controllers {
    public sealed class HomeController : Controller {
        //...

        public override ActionResult Execute() {
            return View(new HomeModel());
        }

        //...
    }
} 

由于我只想快速实现,因此在使用方面有几个硬编码的方面。

  1. 所有控制器都必须位于“Controllers”目录中。
  2. 所有路由的形式都是且仅是:“spectre:///Controllers/<name>”(“spectre:///Controllers/Home”)
  3. 所有视图必须与控制器同名(HomeController => HomeView)
  4. 所有视图都必须位于 Views 目录中。
  5. 目前不支持部分视图。
所有这些限制都归因于我时间不足,没有任何东西可以阻止任何人通过扩展或完全重写代码来解除它们。

示例中的视图相当简单,但足以说明其工作原理。

@using Crystalbyte.Spectre.Samples.Support
@inherits  Crystalbyte.Spectre.Razor.Templates.RazorFolderHostTemplate
               
@{
    var model = (Crystalbyte.Spectre.Samples.Models.HomeModel) Context;
}
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>Spectre - Razor Sample</title>
        @foreach (var style in model.Styles) {
            @Style.Link(style)
        }
    </head>
    <body>
        <div class="content" >
            <div>
                @Html.Header(model.Header)
            </div>
            <div>
                @Html.Span(model.Description)
            </div>
            <footer>
                <span>created on: @model.CreationDate.ToShortDateString()</span>
            </footer>
        </div>
        @foreach (var script in model.Scripts) {
            @Script.Reference(script)
        }
    </body>
</html>  

如果我们现在导航到控制器,我们应该能看到 Razor 生成的页面。

跨平台支持

库和工具的选择并非完全是任意的。始终打算将框架提供给所有重要平台,即 Windows、Linux 和 OS X。虽然 OS X 还不支持,但使用 Mono 而不是 .NET Framework 的 Linux 上已经有一个原型正在运行,然而,它极不稳定。不幸的是,我的时间有限,坦率地说,我对 UNIX 平台的了解也有限。如果有人有任何有用的建议,请随时发表。

部署项目

虽然托管部分很容易构建,但它依赖于几个原生库。虽然从头开始编译它们是推荐的方式,但我认识到在 5 分钟内完成 Chromium 的编译是不可能的。对于所有不想从头开始编译 Chromium/CEF 的人,在 lib 目录中有一个名为 **spectre_redist_x86_windows_release.zip** 的 zip 文件,其中包含所有依赖项的二进制构建。

不幸的是,可再发行包太大,无法在此处托管,因此请记住,您从 codeproject 下载的源代码不完整,它将编译但无法运行,您需要直接从 GitHub 获取缺失的二进制文件或自己构建 Chromium。 

如果您构建 Chromium,您需要创建一个名为 `CHROMIUM_SRC` 的环境变量,指向 chromium 源目录,示例项目的 Visual Studio 后期生成脚本将自动从 chromium 输出目录获取所有必需文件。

如果您决定只部署二进制文件,您需要将它们手动复制到输出目录并注释掉复制脚本。

最后,您的输出目录应该看起来像这样,其中突出显示的项目构成了本机依赖项。

对于初次使用的用户来说,部署过程可能有点繁琐,如果一开始有什么不工作的地方,请告诉我。

结论

虽然这很艰难,要让所有这些看似不兼容的东西协同工作,我相信它表明,利用 HTML 的功能来创建感觉和行为都像桌面应用程序的桌面应用程序是完全可能的。我不确定这需要多长时间,或者是否会发生,但我相信 Web 和桌面世界将在某个时候融合,形成某种混合操作系统,介于 Windows 和 Chromium-OS 之间。

我希望阅读起来比敲击像素使其看起来好更有趣 眨眼 | <img src=。期待您的任何评论。

© . All rights reserved.