SpaceVIL 框架。支持 .NET 和 JVM 的跨平台 GUI





5.00/5 (7投票s)
SpaceVIL 是一个跨平台、多语言的框架,用于为 .NET Framework、.NET Core 和 JVM 创建 GUI 客户端应用程序。本文将介绍 SpaceVIL 框架、其功能以及其创建的简要历程。
目录
前言
SpaceVIL 框架可在 spvessel.com 找到。
使用示例可在 GitHub 找到。
入门指南也可在 spvessel.com 和 GitHub 找到。
引言
我将向您介绍 SpaceVIL 框架、它的功能以及它创建的简要历程。
SpaceVIL (Space of visual item layouts) 是一个基于 OpenGL 技术,并使用 GLFW 库 创建窗口的跨平台、多语言框架。使用该框架,您可以为 Linux、Mac OS X 和 Windows 操作系统创建 GUI 应用程序。
背景
当我决定创建 SpaceVIL 时,我已经学习(或刚熟悉)了许多 UI 框架。它们都彼此大相径庭。每次开始使用新框架时,我都发现自己无法运用在其他 UI 框架上的大部分知识,因为每个框架都与其他框架不同,并且都有自己的规则和核心思想,例如 Qt Widgets、Qt Quick、HTML、CSS、WPF、Windows Forms、Borland VCL、JavaAWT、JavaSwing、JavaFX。了解其中一个对其他框架没有用处。如果您知道如何在六个不同的框架中完成某件事,那么在第八成的情况下,您仍然需要阅读手册才能找出如何在第七个框架中完成“这件事”。不幸的是,UI 系统并不具备跨直觉性。
这也是创建通用多语言 UI 框架的原因之一。当我更改编程语言时,我希望沉浸在其特性中,而不是新的(对我来说)UI 系统(在我之前的工作中,为应用程序创建 UI 非常重要),因为之前的 UI 系统依赖于语言、平台或操作系统。创建这样的框架并不复杂。GUI 应用程序已经存在很长时间了,我们可以认为跨平台、易于使用的多语言框架应该存在。但实际上并非如此。
Qt 为 C++ 做了类似的事情,但说实话,Qt 是一个基于 C++ 的大型框架,它实现了标准类型库,并提供了比纯 C++ 更多的功能。使用 Qt 后,很难再回到纯 C++。Qt 包含用于处理图像、图形、字体等的类,而这些在标准 C++ 中并不包含。如果您想回到 C++,您必须从开源项目中收集所有碎片,才能拥有类似 Qt 的东西。而且,如果所有碎片都使用 LGPL 许可,那就更好了。
编写我自己的 UI 系统的其他原因在于,现有系统过于复杂,工作方式不同,存在许多例外情况,并且是封闭的,不能提供足够的自由来控制渲染过程以及您可能想要的所有 UI 功能。
系统应该易于使用,并且在实现开发者的计划方面具有灵活性。任何事情都不应阻碍开发者发挥他们的创造力。事实上,提供灵活性和功能性并不难,因为与用户交互的所有可能方式都严格受到操作系统和输入方法的限制。
在我的工作中,我经常使用 OpenGL 渲染技术。使用这项技术,我成功地解决了所有问题。然后,当任务转向用户交互时,我开始觉得这一切看起来都像一个 GUI 系统。我不是 OpenGL 专家,但我的知识足以在自己的 UI 构建系统中使用它们。
作为一个有趣的爱好项目,我决定制作一些原型。它们大多基于各种开源的 OpenGL 包装器,并内置了窗口创建和管理系统。与此同时,我正在努力提高我的 C# 技能,所以我在原型中使用了依赖于平台的模块。平台通常是 Windows。每个原型都是为了解决特定问题和测试平台功能而制作的。当原型足够成熟时,我开始分析未来的项目,建立严格的规则并设定目标。原型是 C# 编写的,所以我决定包含 .NET Core 平台以使其成为跨平台项目(此外,当时还无法为 .NET Core 创建 UI 应用程序)。我的队友使用 Java,所以我们决定将 JVM 平台添加到项目中。总而言之,这应该为我们带来以下前景:
- 支持以下编程语言
- .NET 平台:C#、Visual Basic、C++ CLI
- JVM 平台:Java、Scala、Kotlin
- 支持以下操作系统
- Windows、Linux、Mac OS X
- 能够将系统移植到移动平台
这相当不错。只有一个 UI 构建系统,适用于 3 种操作系统和 6 种编程语言。技术栈并非随意选择——面向对象编程 (OOP) 和 OpenGL 的使用应该会简化框架向另一种语言+平台(C++、Python 等)的移植。
为了使系统灵活且易于移植和开发,其模块被严格分离,以便在必要时可以轻松替换它们,而不会损害整个系统。这些独立的模块成为系统的核心。这些模块是:配方和规则模块(算法、接口、抽象类)、通用布局模块和三个基本控件的抽象类。可视化分为渲染引擎(带有服务静态类,用于部分渲染管理、样式设置等)和窗口创建/管理系统。所有系统都根据严格的规则相互交互。
这就是 SpaceVIL 框架的本质。这就是系统所需的一切,从此刻起,它将展现其真正的灵活性。
控件系统
控件(Items)是框架中的主要数据包类型。该类型贯穿所有系统,是框架的基本构建材料。实际上,我可以移除框架中所有已有的控件(约 54 个),只留下三个基本控件。这不会影响可操作性。这可能难以理解,但所有 54 个控件只不过是系统功能的一种演示。它们只是配方和指令的实现。任何框架用户都可以使用框架规则来创建自己的配方(控件)。没有限制,因为令人难以置信的机会之厨房就在您面前。
只有三个主要控件——IBaseItem
、Primitive
和 Prototype
。第一个是创建自己配方(控件)的基本模板,第二个是用于简单非交互式控件的配方实现,第三个是用于交互式控件(接收事件,它们也是前两种类型的容器)的 IBaseItem
实现。
让我们来看看系统的演变。我将向您展示如何创建自己的控件,并以按钮为例,这是最简单的例子之一。
要创建我们自己的控件,我们需要继承三个基本控件之一。对于按钮,这是 Prototype
,因为我们需要接收事件并与控件进行交互的能力。之后,我们需要直接对按钮的形状进行样式设置(或者我们可以使用样式系统)。这样,按钮就差不多准备好了。它唯一还需要的是上面的文本。按钮通常有一些文本。
所以我们需要文本。文本是一个非交互式控件,因此对于其实现,我们可以选择 Primitive
作为基类。根据框架的规则,许多控件可以有自己的接口,让框架知道如何处理这些配方(就像在厨房里一样——主菜是第一个,饮料配着但不是必须的,甜点在最后,等等)。对于文本,我们可以使用 ITextContainer
接口。有了 Primitive 和 ITextContainer
,我们只需要以任何方便的方法和任何我们想要的库来实现一个文本类型的控件。
假设文本控件已准备好。现在我们需要将其放入按钮中。这很容易。由于按钮继承了 Prototype
,它是一个包含所有 IBaseItem
类型控件的容器,并且内部有一个强大的布局系统。因此,我们将使用一个配方规则——InitElements()
的实现。稍后,我将介绍所有主要规则。在该方法内部,我们将调用 AddItem(text)
方法,其中 text 是 ITextContainer
的一个实例,我们之前已经创建了它。
然后,我们可以为 button
类添加一些有用的函数来更改文本,例如字体、位置等。
这样我们就创建了系统中的第一个控件,可以使用了。
下一个控件将是带有切换状态(切换按钮)的 button
。因为它已经基本完成,我们可以直接继承 button
并添加一些逻辑。作为一种选择,我们可以使用一个布尔变量来定义 button
的开/关状态。此外,我们需要根据变量更改视觉状态。我们将使用两个颜色变量——一个用于 ON 状态,另一个用于 OFF 状态。何时更改状态?当我们在按钮上单击时。为此,我们需要在重写的 InitElements()
方法中为 EventMouseClick
事件添加一个操作。该事件将切换按钮的颜色或只是混合它们(您还记得服务 static
类吗?)。就这样,切换按钮就准备好了。
我们已经有了两个控件。我们还能做什么?使用这些控件,我们可以创建一个 CheckBox
。
创建起来也很容易。我们继承 Prototype
并将 CheckBox
用作我们的切换按钮和文本的容器。在这种情况下,我们需要在 CheckBox
中接收 EventMouseClick
事件,并将其重定向到切换按钮中的同一事件。另外,在重写的 InitElements()
方法中,我们需要添加两个控件——切换按钮和文本控件,并将它们的位置设置在 CheckBox
内部。
我们已经有了三个复杂的控件。使用它们,特别是最后一个,我们可以创建一个 RadioButton
控件。本质上,RadioButton
与 CheckBox
类似,但容器中只有一个 RadioButton
可以处于 ON 状态。这可以通过创建一个 UncheckOthers()
方法轻松实现,如果其中一个 RadioButton
被打开,它将关闭所有其他的 RadioButton
。这里有一个示例算法:使用 GetParent()
方法获取 RudioButton
的容器,获取容器控件列表——GetItems()
方法——然后关闭所有 RadioButton
。
这相当容易。
使用这种方法,您可以创建自己的控件库,因为该框架的主要特点不是控件,而是具有规则和配方的“厨房”。使用规则和配方,您可以创建任何复杂度和任何目的的控件。
现在让我们来谈谈赋予我们如此多灵活性和可变性的规则和配方。
主要规则
主要规则是向容器添加控件的规则。几乎任何框架控件都可以添加到另一个控件中(如果它是 Prototype
子类),但必须遵循一定的顺序。
每个控件可以处于两种状态之一:已创建或已创建并由系统初始化。这意味着每个控件都经历两个状态:创建和初始化。需要注意的是,控件的基本功能只有在初始化状态之后才可用。
创建:调用控件的构造函数,并传入初始可视化参数。在构造函数中,可以调用内部控件的构造函数。创建后,控件尚未完全构建(未初始化),因此 AddItem()
/RemoveItem()
等方法不可用。无法将控件添加到未初始化的容器中。
初始化:这是框架初始化控件的过程,然后将其添加到控件的全局存储中。当控件被添加到另一个已初始化的控件中时,该控件将被初始化。第一个被初始化的控件是程序本身的可视窗口。让我们通过代码更好地理解这一点。
有效示例(以下代码是窗口类的一部分,InitComponents()
方法)
ButtonCore btn = new ButtonCore(); // item creation stage, the item is NOT initialized yet
ImageItem img = new ImageItem(<any image>); // item creation stage,
// the item is NOT initialized yet
AddItem(btn); // the btn item is added to the program window,
// btn is now initialized and its functions are fully available
btn.AddItem(img); // the img item added into the btn item, img is also initialized
无效示例(以下代码是窗口类的一部分,InitComponents()
方法)
ButtonCore btn = new ButtonCore(); // item creation stage, the item is NOT initialized yet
ImageItem img = new ImageItem(<any image>); // item creation stage,
// the item is NOT initialized yet
btn.AddItem(img); // trying to add the img item into the btn item.
// The button has not yet been initialized and attempt throws
// a runtime exception
AddItem(btn); // the program will not reach this line
此规则非常严格,有时可能难以或不方便遵守。有两种方法可以“绕过”此规则(实际上,系统始终遵守此规则)。
第一种方法:将控件包装在更高级别的控件中。使用前面的示例,我们可以使用 ButtonCore
和 ImageItem
来创建一个更高级别的控件——ImagedButton
。
让我们看看实现
public class ImagedButton : ButtonCore
{
private ImageItem _img = null;
public ImagedButton(String text, Bitmap picture)
{
SetText(text);
_img = new ImageItem(picture);
}
public override void InitElements()
{
base.InitElements(); // item (ImagedButton) initialization stage
AddItem(_img); // the _img item is added to the button, ImageItem is now initialized
}
}
主代码将这样更改
ImagedButton btn = new ImagedButton("", <some image>); // item creation stage,
// the item is NOT initialized yet
AddItem(btn); // the btn item is added to the program window,
// btn is now initialized and its functions are available
我们遵守了规则,但现在有一种更简单的方法将图像添加到按钮中。
第二种方法:覆盖 AddItem()
方法以延迟内部初始化
public class MyButton : ButtonCore
{
private List<IBaseItem> _list = new List<IBaseItem>(); //prepare a list
public MyButton(String text) : base(text) { }
// override the AddItem method.
// Now it will add items to the container only after its initialization
public override void AddItem(IBaseItem item)
{
if(item == null) return;
// we can know about the item initialization in another way,
// but using a flag is easier to understand
if(_init)
base.AddItem(item);
else
_list.Add(item); // until an item (MyButton) is not initialized,
// all internal items are added to the list
}
private bool _init = false;
public override void InitElements()
{
base.InitElements(); // item (MyButton) initialization stage
foreach(var item in _list)
base.AddItem(item); // the item has been initialized, and now we can initialize
// all the internal items stored in the list
_list = null; // we no longer need this list (or we can save it for some other reasons)
_init = true; // set the initialization flag as true
}
}
现在以下代码(之前是无效的并抛出异常)将起作用
ButtonCore btn = new MyButton("My Button"); // item creation stage,
// the item is NOT initialized yet
ImageItem img = new ImageItem(<any image>); // item creation stage,
// the item is NOT initialized yet
btn.AddItem(img); // it's ok, the img item is added to the list and will be initialized
// later after the btn item is initialized
AddItem(btn); // item initialization (InitElements method in MyButton),
// now all internal items from the list _list will be added and initialized
因此,我们“绕过了”主要规则,实际上,我们只是以不同的方式遵守了它。SpaceVIL 有这样的元素。例如,ComboBox
控件——它的构造函数可以接收任意数量的 MenuItem
控件,并且在 ComboBox
初始化后,所有这些控件都将被初始化。
现在让我们继续讨论特殊控件的规则。
如何创建特殊控件
要创建一个具有特殊行为的控件,您必须选择并遵循以下规则。
忽略主布局的带特殊布局的容器
接口
IHorizontalLayout
- 用于实现我们自己的带有基本垂直布局的水平布局示例
HorizontalStack
HorizontalScrollBar
CheckBox
RadioButton
IVerticalLayout
- 用于实现我们自己的带有基本水平布局的垂直布局示例
VerticalStack
ListBox
树视图
VerticalScrollBar
IFreeLayout
- 用于实现我们自己的垂直和水平布局。用户必须设置所有布局规则示例
Grid
WrapGrid
FreeArea
RadialMenu
使用规则
- 实现其中一个接口。
UpdateLayout()
方法声明了控件布局规则(算法)。 - 根据目的,重写以下一些方法:
SetX
/SetY
、SetWidth
/SetHeight
、AddItem
/RemoveItem
(显然,这些方法应该更新控件布局)。像这样重写此方法:
public override void SetWidth(int value)
{
base.SetWidth(value); // not just override but improve
UpdateLayout(); // call the method to update the layout of items
}
规则很简单,但结果可能令人印象深刻。例如,Grid
布局与 WrapGrid
不同,FreeArea
和 RadialMenu
完全不同。FreeArea
中的控件是独立的,它们可以重叠或隐藏在容器外部。RadialMenu
以圆形排列控件,并具有滚动功能。
浮动的独立控件
接口
IFloatingItem
使用规则
- 实现接口。
- 在类构造函数内部或
InitElements()
方法内部,将浮动控件添加到全局浮动控件存储中(控件是独立的,没有可以添加它们的容器控件)。
ItemsLayoutBox.AddItem(handler, this, LayoutType.Floating);
规则更少,但借助它们,您可以做有趣的事情。例如,ComboBox
、ContextMenu
、SideArea
和所有类型的对话框窗口。
通常,任何新控件都不只属于一种类型。类型是混合的。例如,ContextMenu
和 RadialMenu
是容器和浮动控件的混合体。因此,可以创建任何复杂度和任何目的的控件。
可拖动的、接收 EventMouseDrag 事件的控件
接口
IDraggable
使用规则
- 实现接口。
- 该接口是一个标记。系统会将
EventMouseDrag
事件发送到标记有此接口的类。
与前两个类似,此类型非常有用,有助于创建许多控件,例如 Slider
、ScrollBar
以及任何需要按住并拖动的控件。以下是一些好的用例:SideArea
(您可以扩展可见区域)、RadialMenu
(按住鼠标按钮并移动鼠标时控件会滚动)、FreeArea
(您可以移动可见区域)。
窗口拖动控件
接口
IWindowAnchor
使用规则
- 实现接口。
- 与前一个类似,此接口是一个标记。系统会以特殊方式处理标记有此接口的类。如果您按住鼠标按钮在此类控件上,窗口的位置将对应于鼠标的移动。
核心实现:TitleBar
和 WindowAnchor
图像控件
接口
IImageItem
使用规则
- 只需实现接口。
使用此接口(而不是框架中提供的标准实现 ImageItem
)的主要优点是改进处理算法、并行化、支持稀有格式等。
文本控件、包含文本的控件、处理文本的控件等。
接口
ITextContainer
ITextShortcuts
使用规则
- 实现一个或所有接口
ITextContainer
用于将文本渲染到纹理。ITextShortcuts
是一个额外的标记,用于系统进行特殊处理。此接口包含实现标准文本快捷键的方法:copy
(复制)、paste
(粘贴)、cut
(剪切)、select all
(全选)、undo
(撤销)、redo
(重做)。
在 SpaceVIL 中使用 OpenGL 技术
接口
IOpenGLLayer
使用规则
- 只需实现接口。
有三个有用的方法:Initialize()
、Draw()
和 Free()
。Initialize()
用于准备 OpenGL 资源(如果需要),例如 FBO、VBO、着色器等。Draw()
用于渲染场景,Free()
用于在控件删除时释放资源。
这些规则旨在创建任何复杂度的独特控件。通过结合这些规则,您可以创建实现您任何想法的控件,从文本编辑器到图形编辑器。
还有其他规则,用于配置或管理控件或系统,而不是创建控件。例如,窗口管理、控件样式设置、应用程序样式主题的创建/编辑/添加、特殊效果的创建和管理、控件视觉状态的管理、矢量形状创建规则、用于实现开发者想法的许多服务类、事件处理系统规则、缓存和渲染优化规则、控件焦点控制规则、双层渲染规则等。您不需要深入了解系统即可使用这些功能中的大多数,因为它们是直观的,并且按照您的预期工作。
上面描述的内容看起来很多,但请记住,您不需要学习系统的大多数功能。我试图制作一个“快速入门”框架。您不需要深入的知识就可以开始使用该系统。只需查看框架的内容、其方法和控件,您就会明白它是如何工作的。为了使创建新控件变得有趣,该框架包含 54 多个不同的控件,可以进行改进、继承、编辑,并且您还可以知道可以使用 SpaceVIL 创建这些控件。您必须记住的主要事情是:系统需要选择配方并遵循规则,图形引擎将根据通用规则绘制所有控件,没有任何陷阱。
应用程序样式主题
现在让我们看看控件的样式是如何工作的。样式模块包含三个部分——主题、样式、状态。将它们结合使用,您可以有效地控制控件的视觉交互性。
样式主题是应用程序中使用的每个控件的样式集合。如果新创建的控件存在于当前主题中,系统将“自动”使用该样式。
但是为了更清楚,我们首先考虑准备框架工作的主要阶段。以下是完成此操作的四个常见步骤:
- 在程序入口点(
Main
方法)通过Common.CommonService.InitSpaceVILComponents()
初始化框架组件。在此阶段,将检查操作系统、库的可用性以及操作系统依赖性。将初始化系统的基本状态,包括框架中所有控件的基础主题。 - 以
ActiveWindow
为基础,创建并初始化窗口类(InitWindow()
方法)。这通常是自定义窗口和放置控件的步骤。 - 在程序入口点(
Main
方法)创建一个窗口实例 - 调用窗口的
Show()
方法,可以是直接调用,也可以是使用窗口管理器(WindowManager
),或者使用全局窗口存储(WindowsBox
)。
开发者配置 SpaceVIL 的所有操作必须在第一和第二阶段之间进行。例如,用开发者样式主题替换主要的 SpaceVIL 样式主题,更改或替换当前主题中的基本样式,更改默认的 SpaceVIL 全局设置等。
现在考虑开发者仅使用框架内置控件或它们的组合的情况(通常,如果创建了包装控件,则不为它创建单独的样式)。假设开发者对按钮控件的基本样式不满意(在基本样式中,它具有蓝色、尖角且无边框),并且开发者希望稍微更改样式,例如,按钮的颜色、大小并添加圆角。由于更改不大,开发者可以使用当前主题中的样式更改方法来实现此目的。
// change the color of the button, after that, all instances of the button class
// will be created with gray color
DefaultsService.GetDefaultStyle(typeof(SpaceVIL.ButtonCore)).Background = Color.Gray;
// now all buttons will be created with the specified size
DefaultsService.GetDefaultStyle(typeof(SpaceVIL.ButtonCore)).SetSize(100, 35);
// all buttons will be created with round corners
DefaultsService.GetDefaultStyle
(typeof(SpaceVIL.ButtonCore)).BorderRadius = new CornerRadius(8);
但是,如果更改太多怎么办?或者您甚至需要更改内部样式?那么最好创建自己的样式,并按如下方式在基本主题中替换:
- 创建一个返回新样式的方法
public static Style GetButtonStyle() { // there is no need to create a style from scratch, // but we can significantly change it Style style = Style.GetButtonCoreStyle(); style.Background = Color.FromArgb(255, 13, 176, 255); //background color style.Foreground = Color.Black; // text color style.BorderRadius = new CornerRadius(6); // corner border radius // take the default font and set it a new style and size style.Font = DefaultsService.GetDefaultFont(FontStyle.Regular, 18); // a button will occupy all available space style.SetSizePolicy(SizePolicy.Expand, SizePolicy.Expand); // set button alignment to center style.SetAlignment(ItemAlignment.HCenter,ItemAlignment.VCenter); // set text alignment in the button to center style.SetTextAlignment(ItemAlignment.HCenter,ItemAlignment.VCenter); // change the state of a button on hover style.ItemStates.Add (ItemStateType.Hovered, new ItemState(Color.FromArgb(60, 255, 255, 255))); return style; }
- 用我们自己的样式替换默认样式主题中的按钮样式
DefaultsService.GetDefaultTheme().ReplaceDefaultItemStyle( typeof(SpaceVIL.ButtonCore), GetButtonStyle());
完成,样式已替换。所有新创建的按钮都将获得新外观(如果由于某种原因,在替换样式之前创建了按钮,它们将保留旧样式)。
让我们仔细看看行 "Style style = Style.GetButtonCoreStyle();
"。我为什么没有使用 "new Style();
"?事实上,这很简单,Style
类非常庞大,您需要很好地记住它,因为该类的某些属性是严格必需的,如果我从头开始创建样式,那么只有对于全新的控件,而且由于我只需要更改按钮的外观(实际上,修改现有的基本样式),那么这是最经济的选择。它更简单,花费的时间更少,并且排除了在样式中出错的可能性(基本样式始终正确填充)。
当然,您可以在应用程序中创建、修改、替换和应用自己的样式主题。例如,如果您想要不同操作系统有不同的主题,或者根据用户的要求。
为自己的控件在当前主题中注册样式也很简单。您需要做的就是创建一个样式,将此样式添加到基本主题中,应用样式(我建议在构造函数末尾应用样式),并且(如果您创建了一个复杂的控件)覆盖 SetStyle()
方法。
控件的状态系统和用户与控件的交互方式
用户通常与交互式控件的交互方式不多,通常只有六种
ItemStateType.Base
(基本静态空闲状态)ItemStateType.Hovered
(悬停状态)ItemStateType.Pressed
(按下状态)ItemStateType.Toggled
(切换状态(开/关))ItemStateType.Focused
(当控件接收键盘事件时的焦点状态)ItemStateType.Disabled
(禁用状态,当控件忽略所有事件时)
状态系统仅适用于交互式控件,开发者可以完全忽略它。开发者有权实现自己的状态系统。
使用此类系统非常简单,对于每个交互式控件,都有以下基本方法:
- 通过
AddItemState(ItemStateType.Hovered, state)
添加新state
,其中state
是ItemState
类的实例(与Style
类相比已大大简化) - 通过
RemoveItemState(ItemStateType.Hovered
) 移除state
如前所述,框架具有相当简单的规则,因此通过 XML 和 JSON 等文件添加标记系统(未来计划)将是一项简单而繁琐的任务。
结论
SpaceVIL 是一个强大、灵活且易于使用的 UI 框架,可以覆盖 80-90% 的桌面程序类型。由两名程序员开发。
如果您尝试我们的框架并分享您的看法,我们将不胜感激。
SpaceVIL 应用截图
点击此处查看更多截图。
历史
- 2020 年 1 月 30 日:初始版本