基于 MQL 的程序是否需要图形窗口界面? 对此缺乏共识。 一种观点,交易者的梦想是用最简单的方式与交易机器人进行沟通 — 一键启用交易并开始神奇地“铸币”。 另一种观点,因为这只是一个梦想,所以它与实际情况相去甚远,因为在系统开始工作之前,您通常需经很长时间才能艰苦地选定整个设置:然而,即使在此之后,您也需要控制它,并在必要时对其进行手动校正。 对于完全手动交易者,想必我不用说 — 若是这种情况,选择一个直观舒服的交易面板是成功的一半。 一般来讲,可以这么概括窗口界面,选择一或几种形式,早用比不用要好。
为了构建图形界面,MetaTrader 提供了一些需求很高的控件元素,既可以作为独立对象放置在图表上,也可以作为标准库“控件”的包装控件,可将其组织为单个交互式窗口。 还有一些用于构造 GUI 的替代解决方案。 不过,函数库里的所有这些很少涉及元素的布局,即某种程度上的界面设计自动化。
当然,在极少数情况下,有人会想到在图表上绘制一个与 MetaTrader 本身相等的窗口。然而,即使看似简单的交易面板也可能包含数十个“控件”,这些来自 MQL 的控件其实很单调。
布局是描述界面元素排布和属性的统一方法,在此基础上,我们可以确保自动创建窗口,并将其与控制代码链接。
我们记住在 MQL 的标准实例中是如何创建界面的。
bool CPanelDialog::Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2) { if(!CAppDialog::Create(chart, name, subwin, x1, y1, x2, y2)) return(false); // create dependent controls if(!CreateEdit()) return(false); if(!CreateButton1()) return(false); if(!CreateButton2()) return(false); if(!CreateButton3()) return(false); ... if(!CreateListView()) return(false); return(true); } bool CPanelDialog::CreateButton2(void) { // coordinates int x1 = ClientAreaWidth() - (INDENT_RIGHT + BUTTON_WIDTH); int y1 = INDENT_TOP + BUTTON_HEIGHT + CONTROLS_GAP_Y; int x2 = x1 + BUTTON_WIDTH; int y2 = y1 + BUTTON_HEIGHT; if(!m_button2.Create(m_chart_id, m_name + "Button2", m_subwin, x1, y1, x2, y2)) return(false); if(!m_button2.Text("Button2")) return(false); if(!Add(m_button2)) return(false); m_button2.Alignment(WND_ALIGN_RIGHT, 0, 0, INDENT_RIGHT, 0); return(true); } ...
一切都以命令样式完成,用许多相同类型的调用。 MQL 代码很长且效率很低,若每个元素都重复执行 一次,则每种情况都用到自己的常量(所谓的“魔幻数字”,而这被认为是潜在的错误源)。 编写这样的代码是一件毫无压力的任务(尤其是在开发人员中众所周知的 复制&黏贴错误),且在您需要插入新元素并偏移顺序之处,您很可能必须手工操作来重新计算并修改许多“魔幻数字”。
下面是对话框类中所见的界面元素说明。
CEdit m_edit; // the display field object CButton m_button1; // the button object CButton m_button2; // the button object CButton m_button3; // the fixed button object CSpinEdit m_spin_edit; // the up-down object CDatePicker m_date; // the datepicker object CListView m_list_view; // the list object CComboBox m_combo_box; // the dropdown list object CRadioGroup m_radio_group; // the radio buttons group object CCheckGroup m_check_group; // the check box group object
“控件”的平铺列表可能很长,如果不提供布局来直观“提示”,则很难感知和维护它。
在其他编程语言中,界面设计通常与编码分离。 声明性语言(例如 XML 或 JSON)用于描述元素的布局。
特别是,有关安卓项目界面元素的基本原理阐述,能在文档或教程中找到。 若要了解其要旨,您只需具备 XML 的一般概念。 在此类文件中,层次结构很明显,容器元素(如 LinearLayout 或 RelativeLayout)以及单个“控件”(如 ImageView,TextView 或 CheckBox)均已定义,并会自动调整内容的大小,如 match_parent 或 wrap_content ,并在设置中定义了指向中心样式说明的链接,且可选指定的事件处理器,所有元素均可毫无疑问地进行额外调整,且可能其从可执行代码里加载额外的事件处理器。
如果我们还记得 .Net 平台,它们也用到与 XAML 相似的声明式界面描述。 即使那些从未用过 C#,或任何其他托管代码底层架构语言进行编程的人(事实上,其概念与 MetaTrader 平台及其“托管” MQL 非常相似), 也能在此看到核心元素,例如“控件”、容器、属性,以及对用户操作的响应,就恰如多合一。
为什么将布局与代码分离?并用特殊的语言描述? 此处就是这种方式的基本益处。
对于 MQL 环境,仅针对性地解决了其中很少一些问题。 特别是,在如何设计和构造对象类一文中讲述了可视化对话框设计器。 它基于 MasterWindows 函数库工作。 只是,布局的方式和所支持的元素类型列表会受到很大限制。
在 GUI 控件里使用布局和容器:CBox 类和 CGrid 类一文里,提出了一种更高级的布局系统,尽管没有视觉设计器。 它支持从CWndObj 或 CWndContainer 继承的所有标准控制元素,和其他控制元素,但仍为用户保留了创建和排布组件的例行编码。
从概念上讲,这种使用容器的方法非常先进(在几乎所有标记语言中都会提到这一点就足以说明其热门程度)。 因此,我们会看重这一点。 在我早前的文章(在交易中应用 OLAP(第二部分):可视化交互式多维数据分析结果)中,我提出了一个修订版的容器 CBox 和 CGrid,以及一些支持“橡胶”属性的控件元素。 下面,我们将利用这些扩展和改进解决自动排列元素的问题,例如标准库的对象。
图形界面编辑器的主要功能是按照用户的命令动态地创建窗口中元素,并设置其属性。 建议利用输入字段选择属性;为令它们起作用,您应该知道每个类的属性列表及其类型。 因此,每个“控件”必须含有两个相互关联的版本:所谓的运行时版本(标准操作),和设计时版本(交互式设计您的界面)。 默认情况下,“控件”首先的一个 — 它是在窗口中运行的类。 第二个版本是“控件”的包装,用于查看和更改其可用属性。 为元素的每种类型都编写这样的包装将是一项艰巨的工程。 所以,希望该过程能够自动化。 从理论上讲,为此目的,您可以借助题为运用 MQL 实现 MQL 解析一文中讲述的 MQL 解析器。 在许多编程语言中,属性的概念都放在语言语法中,并结合对象某个内部字段的 “setter” 和 “getter”。 MQL 到目前为止还没有这个功能,但是在标准库的窗口类中运用了类似的原理:若要设置和读取相同的字段,会调用一对同名的“镜像”方法 — 其一取特定类型的值,另一种则返回它。 举例来说,这是为 CEdit 输入字段定义“只读”属性的方式:
bool ReadOnly(void) const; bool ReadOnly(const bool flag);
这就是如何启用操做 CSpinEdit 的上限:
int MaxValue(void) const; void MaxValue(const int value);
利用 MQL 解析器,您可以在每个类中找到这些方法对,然后考虑继承层次结构,将它们包含在常规列表中,之后您可以生成包装器类,以交互方式设置和读取找到的属性。 对于每个“控件”类,您只需要这样做一次(前提是该类不会更改其公共属性)。
一个可实施的项目,即便规模很大。 解决它之前,您应该考虑其所有利弊。
我们要强调两个核心设计目标:辨别元素的层次依赖性,及其属性。 如果找到其他替代途径来实现它们,我们可以省略可视化编辑器。
通过有意识的反思,很明显所有元素的基本属性都是标准的,即类型、大小、对齐方式、文本和样式(颜色)。 您还可以在 MQL 代码中设置特定的属性。 值得庆幸的是,这些只是通常与业务逻辑关联的单一操作。 至于类型、大小和对齐方式,它们是由对象层次结构本身隐式设置的。
由此,我们得出的结论是,在大多数情况下,用一种便捷的方法来描述界面元素的层次结构就足够了,而不是利用功能强大的编辑器。
想象一下,对话框类中的所有控件元素和容器不是由连续列表描述的,而是带有缩进的、模拟嵌套/依赖树结构的缩进。
CBox m_main; // main client window CBox m_edit_row; // top level container/group CEdit m_edit; // control CBox m_button_row; // top level container/group CButton m_button1; // control CButton m_button2; // control CButton m_button3; // control CBox m_spin_date_row; // top level container/group SpinEdit m_spin_edit; // control CDatePicker m_date; // control CBox m_lists_row; // top level container/group CBox m_lists_column1; // nested container/group ComboBox m_combo_box; // control CRadioGroup m_radio_group; // control CCheckGroup m_check_group; // control CBox m_lists_column2; // nested container/group CListView m_list_view; // control
以此方式,该结构更加直观。 不过,格式更改当然不能以任何方式影响到程序按特殊方式解释这些对象的能力。
理想情况下,我们希望有一种描述界面的方法,根据该方法,控件元素能根据定义的层次结构自行创建,在屏幕上找到合适的位置,并计算合适的尺寸。
故此,我们必须开发一种标记语言,描述窗口界面的一般结构,及其各个元素的属性。 在此,我们可以依据广泛使用的 XML 格式,并保留一组相关标签。 我们甚至可以从上述其他框架中借用它们。 但随后我们将不得不解析 XML,然后将其解释为 MQL,将其转换为创建和调整对象的操作。 甚至,由于不再需要可视化编辑器,因此“外部”标记语言也无需作为编辑器与运行时环境之间的通信方式。
在这种条件下,就会生出一个想法:MQL 本身可以用作标记语言吗? 它当然可以。
层次结构在最初既已被纳入 MQL 之中。 立即想到的就是类的彼此继承。 但是类描述的是在执行代码之前形成的静态层次结构。 然而,我们需要一个可以在执行 MQL 代码时解释的层次结构。 在其他一些编程语言中,出于此目的,即从程序本身分析类的层次和内部结构,有一个嵌入式工具,即所谓的运行时类型信息,RTTI,也称为反射。 但是 MQL 没有这样的工具。
不过,与大多数编程语言一样,MQL 拥有另一个层次结构:执行代码片段的上下文层次结构。 函数/方法中的每对大括号(即,排除用于描述类和结构的大括号)形成一个上下文,即局部变量的生存区域。 由于单元嵌套不受限制,因此我们可以利用它们来描述随机层次结构。
MQL 中已采用了类似的方法,特别是实现了用于测量代码执行速度的自制探查器(请参见 MQL 的 OOP 注意:针对静态和自动对象的自制探查器)。 它的操作原理很简单。 如果连同解决问题的操作一起,我们需在代码单元中声明一个局部变量:
{
ProfilerObject obj;
... // code lines of your actual algorithm
}
然后它将在进入单位后立即创建,并在退出之前删除。 任何类的对象都是如此,包括那些能够被探究的行为。 特别是,您可在构造函数和析构函数中留意这些指令的时间,从而计算所采用算法的持续时间。 自然地,为了累积这些测量值,需要另一个更高级的对象,即探查器本身。 不过,它们之间的交换设备在此不是很重要(请参阅博客中的更多详细信息)。 问题在于应用相同的原理来描述布局。 换言之,它将如下所示:
container<Dialog> dialog(&this); { container<classA> main; // create classA internal object 1 { container<classB> top_level(name, property, ...); // create classB internal object 2 { container<classC> next_level_1(name, property, ...); // create classC internal object 3 { control<classX> ctrl1(object4, name, property, ...); // create classX object 4 control<classX> ctrl2(object5, name, property, ...); // create classX object 5 } // register objects 4&5 in object 3 (via ctrl1, ctrl2 in next_level_1) } // register object 3 in object 2 (via next_level_1 in top_level) { container<classC> next_level2(name, property, ...); // create classC internal object 6 { control<classY> ctrl3(object7, name, property, ...); // create classY object 7 control<classY> ctrl4(object8, name, property, ...); // create classY object 8 } // register objects 7&8 in object 6 (via ctrl3, ctrl4 in next_level_2) } // register object 6 in object 2 (via next_level_2 in top_level) } // register object 2 in object 1 (via top_level in main) } // register object 1 (main) in the dialog (this)
在执行此代码时,将创建某些类的对象(名义上称为“容器”),其中的模板参数定义了要在对话框中生成的特定 GUI 元素的类。 所有容器对象都按堆栈模式放置在一个特殊的数组之中:每层嵌套级别都会向该数组添加一个容器,当前上下文单元位于堆栈顶部,而窗口始终位于最底部,即编号一。 关闭每个单元时,在其中创建的所有子元素将自动绑定到直系父级(恰好在堆栈的顶部)。
必须通过“容器”和“控制”类的内部来确保所有这些“魔幻”。 实际上,这是相同的类,即“布局”,但为了更佳的可视性,在上图中强调了容器和控件之间的区别。 现实中,差异仅取决于模板参数指定的类。 故此,以上示例中的 Dialog、classA、classB 和 classC 类必须是窗口容器,即支持在其中存储“控件”。
我们应该区分短期的布局辅助对象(它们在上面被称为 main、top_level、next_level_1、ctrl1、ctrl2、next_level2、ctrl3 和 ctrl4)和受控于它们的界面类对象(object1 ...object8),它们将彼此绑定并与窗口绑定。 所有这些代码将作为对话框方法执行(类似于创建方法)。 因此,对话框对象可以用作 “this”。
对于某些布局对象,我们将 GUI 对象作为类变量(object4、5、7、8)发送,而对于其中一些对象,则不发送(指定名称和属性)。 无论如何,GUI 对象必须存在,但我们并不一定明确需要它。 如果所用“控件”随后要与算法进行交互,则可方便地将其与该算法链接。 容器通常与程序逻辑无关,仅履行放置“控件”的功能,所以,它们是在布局系统内部非显式地创建的。
我们将开发记录属性的特定语法,并在稍后列出它们。
我们来编写能够实现界面元素层次结构形式的类。 潜在地,这种方式可以应用于任意“控件”库。 故此,我们将类的集合划分为两部分:抽象类(含通用功能),以及与标准控件元素(CWnd 衍生类)之特定库之特定方面有关的应用类。 我们将在标准对话框上验证该概念的可行性,并希望能将其应用到抽象层引导的其他函数库。
LayoutData 类是核心。
class LayoutData { protected: static RubbArray<LayoutData *> stack; static string rootId; int _x1, _y1, _x2, _y2; string _id; public: LayoutData() { _x1 = _y1 = _x2 = _y2 = 0; _id = NULL; } };
其内存储任何布局元素固有的最少信息量:独有的名称 _id 和坐标。 仅供您参考,字段 _id 在抽象级别定义,并且 GUI 可以“显示”自己在每个特定库中 “control” 属性。 特别是在标准库中,此字段名为 m_name,可利用公开方法 CWnd::Name 来获取。 两个对象的名称不能冲突。 在 CWnd 中,还定义了类型为 “long” 的 m_id 字段 — 它用于消息分派。 当我们进入应用实现时,不要将其与我们的 _id 混淆。
此外,LayoutData 类提供了堆栈式静态存储实例,和窗口实例标识符(rootId)。 最后两个静态成员不是问题,因为每个 MQL 程序都是在单线程中执行。 即使其中有若干个窗口,一次也只能创建其中之一。 一旦绘制了其中一个窗口,则堆栈将清空,且准备与另一个窗口一同操作。 Windwo 标识符 rootId 已知在标准库中的类 CAppDialog 中为字段 m_instance_id。 对于其他函数库,必须有类似的内容(不一定是字符串,但必须是唯一的,可简化为字符串),否则窗口可能会有冲突。 我们稍后会再次定位此问题。
LayoutData 类将从 LayoutBase 类继承。 它是生成界面元素的布局类原型,包括在 MQL 代码的大括号单元里。
template<typename P,typename C> class LayoutBase: public LayoutData { ...
它的两个模板参数 P 和 C 与用作容器和“控件”的元素类有关。
容器在设计上包括“控件”,和/或其他容器,而“控件”则被视为一个整体,且不能再包含任何其他控件。 在此需要特别指出的是,“控件”是指界面的逻辑单体,它实际上可以由许多辅助对象组成。 特别是,标准库的类 CListView 或 CComboBox 是“控件”,但它们在内部是由多个对象实现。 这些是实现的技术,而相似类型的控件元素可以在其他函数库中作为单独外形廓实现,并在上面绘制按钮和文本。 在抽象布局类的上下文中,我们无需深入挖掘它,破坏封装的原理,但是为特定函数库所用的实现设计当然必须考虑这一细微差别(并将实际容器与复合“控件”区分开来)。
对于标准库,作为模板参数 P 和 C 的最佳候选者是 CWndContainer 和 CWnd。 继续向前,我们应注意,CWndObj 不能用作“控件”类,因为许多“控件”是从 CWndContainer 继承的。 例如,这些包括 CComboBox、CListView、CSpinEdit、CDatePicker、等等。 然而,作为参数 C,我们应该选择最接近所有“控件”的通用类,而对于标准库来说 CWnd 就是。 如我们所见,一个容器类,例如 CWndContainer,实际上可以满足简单元素;因此,我们将来必须确保更准确地检查特定实例是否为容器。 与此类似,必须选择所有容器中最接近的通用类作为参数 P。在标准库中,窗口类为 CDialog,它是 CWndContainer 的后代。 然而,与此同时,我们将用 CBox 分支类,在对话框中为元素分组,并且它源自 CWndClient,而后者又源自 CWndContainer。 因此,最接近的通用祖先是 CWndContainer。
LayoutBase 类的字段将存储布局对象生成的界面元素的指针。
protected: P *container; // not null if container (can be used as flag) C *object; C *array[]; public: LayoutBase(): container(NULL), object(NULL) {}
在此,容器和对象指向同一事物;但若容器不为 NULL,则所提供元素确为容器,。
该数组允许用一个布局对象来创建一组相同类型的元素,譬如按钮。 在此情况下,容器和对象指针将等于 NULL。 对于所有成员,均有一些简单的 “getter” 方法,我们无需全部讲述它们。 举例来说,利用方法 get() 很容易获取对象的链接。
接下来的三个方法所声明的绑定元素上的抽象操作,必须能够执行布局对象。
protected: virtual bool setContainer(C *control) = 0; virtual string create(C *object, const string id = NULL) = 0; virtual void add(C *object) = 0;
setContainer 方法能够区分传递参数中的容器与常规“控件”。 在该方法中,我们建议填写容器字段。 如果不为 NULL,则返回 true。
方法 create 会初始化元素(在标准库所有类中都有类似的方法 Create;但是,我认为其他库(例如 EasyAndFastGUI)也包含类似的方法;但在 EasyAndFastGUI 的情况下,出于某种原因它们的命名方式不同,故它们是不同的类;所以,为了那些喜欢将其提供的布局机制连接起来的人,我们将不得不编写适配器类,从而统一不同“控件”的程序接口;但依然还有更多: 最重要的事情是为 EasyAndFastGUI 编写类似于 CBox 和 CGrid 的类)。 您可将期望的元素标识符传递给方法,但在某些情况下这并非必要,则是因为执行算法不一定会全部或部分顾及该需求(特别是可以添加 instance_id)。 所以,您可以从要返回的字符串中了解真实标识符。
方法 “add” 将一个元素添加到父容器元素(在标准库中,此操作由方法 Add 执行;而在 EasyAndFastGUI 当中,显然则由 MainPointer 执行)。
现在我们来看看这三种方法在抽象级别上是如何介入的。 我们将界面的每个元素绑定到布局对象,这需要经历两个阶段:创建(在代码单元中初始化局部变量)和删除(从代码单元退出,并调用局部变量的析构函数)。 对于第一阶段,我们将编写方法 init,该方法将从衍生类的构造函数中调用。
template<typename T> void init(T *ref, const string id = NULL, const int x1 = 0, const int y1 = 0, const int x2 = 0, const int y2 = 0) { object = ref; setContainer(ref); _x1 = x1; _y1 = y1; _x2 = x2; _y2 = y2; if(stack.size() > 0) { if(_x1 == 0 && _y1 == 0 && _x2 == 0 && _y2 == 0) { _x1 = stack.top()._x1; _y1 = stack.top()._y1; _x2 = stack.top()._x2; _y2 = stack.top()._y2; } _id = rootId + (id == NULL ? typename(T) + StringFormat("%d", object) : id); } else { _id = (id == NULL ? typename(T) + StringFormat("%d", object) : id); } string newId = create(object, _id); if(stack.size() == 0) { rootId = newId; } if(container) { stack << &this; } }
第一个参数是指向相关元素类的指针。 在此,我们仅限于考虑从外部传递元素的情况。 但在上面的布局语法草案中,我们有一些隐式元素(仅为其指定名称)。 稍后我们将回到该操作规化。
该方法将指向元素的指针存储到对象中,利用 setContainer 检查其是否为容器(建议,如果是,则填写容器字段),并从输入或从可选的父容器获取指定的坐标,前提是它已经在堆栈中。 调用 “create” 初创界面元素。 如果堆栈仍然为空,则将标识符存储在 rootId 之中(对于标准库,它将是 instance_id),因为堆栈上的第一个元素始终是最前面的容器,即容纳所有降序元素的窗口(在标准库中为 CDialog 类或其派生类)。 最后,如果当前元素是一个容器,我们将其放入堆栈中(stack << &this)。
方法 init 是一个模板。 这能够按类型自动生成“控件”的名称;甚至,我们将很快添加其他类似的方法 init。 其中一个将在内部生成元素,而不是从外部准备它们,在这种情况下,我们需要特定的类型。 另一个版本的 init 旨在一次性在布局中注册若干个相同类型的元素(记住 array[] 成员),而数组是由链接传递,而链接不支持类型转换(取决于代码结构,可能是“参数转换不允许”,“没有适合的重载可供函数调用”),因此我们需要再次通过模板参数指向特定类型。 因此,所有的方法初始化将具有相同的“模板”约定,即运用的规则。
最有趣的事情发生在析构函数 LayoutBase 当中。
~LayoutBase() { if(container) { stack.pop(); } if(object) { LayoutBase *up = stack.size() > 0 ? stack.top() : NULL; if(up != NULL) { up.add(object); } } } };
如果当前绑定的元素是一个容器,我们将其从堆栈中删除,因为我们正从相关的封闭单元退出(容器已结束)。 问题在于,在每个单元内部,它在包含容器的堆栈里位于最高的顶部,在该容器添加单元内部占位的元素(实际上已经添加了),这些元素即可以是“控件”,亦或更小的容器。 然后,利用 “add” 方法将当前元素添加到容器当中,按顺序直到堆顶。
我们来看看更具体的内容 — 实现标准库界面元素布局的类。 以 CWndContainer 和 CWnd 类作为模板参数,我们来定义中间类 StdLayoutBase。
class StdLayoutBase: public LayoutBase<CWndContainer,CWnd> { public: virtual bool setContainer(CWnd *control) override { CDialog *dialog = dynamic_cast<CDialog *>(control); CBox *box = dynamic_cast<CBox *>(control); if(dialog != NULL) { container = dialog; } else if(box != NULL) { container = box; } return true; }
setContainer 方法利用动态强制转换来辨别元素 CWnd 是从 CDialog 还是CBox 派生而来,如果是,则它是一个容器。
virtual string create(CWnd *child, const string id = NULL) override { child.Create(ChartID(), id != NULL ? id : _id, 0, _x1, _y1, _x2, _y2); return child.Name(); }
方法 “create” 初创元素并返回其名称。 请注意,我们仅在当前图表(ChartID())和主窗口中操作(该项目中未考虑子窗口,但是您可以根据需要调整代码)。
virtual void add(CWnd *child) override { CDialog *dlg = dynamic_cast<CDialog *>(container); if(dlg != NULL) { dlg.Add(child); } else { CWndContainer *ptr = dynamic_cast<CWndContainer *>(container); if(ptr != NULL) { ptr.Add(child); } else { Print("Can't add ", child.Name(), " to ", container.Name()); } } } };
方法 “add” 将一个子元素添加到父元素之中,初步令其尽可能“上抛”,因为标准库中的 Add 方法不是虚拟的(从技术上讲,我们可以在标准库中进行相关修改,但我们可以稍后再讨论修改)。
基于 StdLayoutBase 类,我们创建工作类 _layout,该类将与 MQL 中的布局描述一起出现在代码中。 名称开头为下划线,从而引起人们的关注:该类对象为非标准用途。 我们研究该类的简化版本。 稍后我们将为其添加更多功能。 实际上,所有活动都由构造函数启动,在构造函数当中,从 LayoutBase 调用一个方法 init 或另一个方法。
template<typename T> class _layout: public StdLayoutBase { public: _layout(T &ref, const string id, const int dx, const int dy) { init(&ref, id, 0, 0, dx, dy); } _layout(T *ptr, const string id, const int dx, const int dy) { init(ptr, id, 0, 0, dx, dy); } _layout(T &ref, const string id, const int x1, const int y1, const int x2, const int y2) { init(&ref, id, x1, y1, x2, y2); } _layout(T *ptr, const string id, const int x1, const int y1, const int x2, const int y2) { init(ptr, id, x1, y1, x2, y2); } _layout(T &refs[], const string id, const int x1, const int y1, const int x2, const int y2) { init(refs, id, x1, y1, x2, y2); } };
您可以利用下面的类图浏览整体画面。 我们必须了解其上的一些内容,但大多数类对我们来说都是熟悉的。
GUI 布局类示意图
现在,我们可以实施对象描述的检查,例如 _layout<CButton> button(m_button, 100,20),如何在对话框中初创,并注册对象 m_button,前提是要在外部单元中进行描述:_layout<CAppDialog> dialog(this, name, x1, y1, x2, y2)。 但是,元素拥有许多其他属性,除尺寸以外。 某些属性,譬如边缘对齐,对布局的重要性不低于坐标。 实际上,如果元素具有水平对齐方式,就标准库“对齐方式”而言,则它将在父容器区域的整个宽度上拉伸,减去左右两侧的预定义区域。 因此,对齐优先于坐标。 甚至,在 CBox 类容器中,取向(方向)很重要,在其中会按照水平(默认情况下)或垂直顺序放置子元素。 这一点对于支持影响外部呈现的其他属性也是正确的,譬如字体大小或颜色,以及操作模式,譬如只读,“粘性”按钮等。
在窗口类中描述 GUI 对象,并将其传递给布局,我们可以利用设置属性的“本地”方法,例如 edit.Text(“text”)。 布局系统支持这一旧式技术,但它即不是单一的,也不是最佳的。 在许多情况下,创建对象应能很方便将其分配给布局系统,然后它们将无法直接从窗口取用。 故此,有必要以某种方式扩展_layout 类的能力,令其能够调整元素的。
由于属性很多,因此建议不要在同一个类里操作,而应在它和特殊的协助类之间分担责任。 同时,_layout 仍然是注册元素的起点,但是它把所有详细设置委托给新类。 这对于布局技术尽可能独立于特定控件库尤为重要。
在抽象级别,属性集由它们的数值类型来划分。 我们支持 MQL 的基本嵌入式类型,以及其他一些稍后将讨论的类型。 从语法上讲,遵照已知范式 builder 的调用链来分配属性会更便洁:
_layout<CBox> column(...); column.style(LAYOUT_STYLE_VERTICAL).color(clrGray).margin(5);
然而,这种语法意味着在一个类中要有很累赘的一组方法,后一个必须是布局类,因为逆引用操作符(点)不能被重载。 在 _layout 类中,可以保留一个方法来为属性返回协助对象的实例,如下所示:
_layout<CBox> column(...); column.properties().style(LAYOUT_STYLE_VERTICAL).color(clrGray).margin(5);
但是定义许多代理类并不合适 — 每个代理类都有自己的元素类型,从而在编译阶段验证所分配属性的正确性。 这会令项目复杂化,但对于第一个测试实现,我们希望尽可能的简单。 好了,现在该方式需要进一步扩展。
还应注意,“builder” 模板中的方法名称在某种意义上是多余的,因为诸如 LAYOUT_STYLE_VERTICAL 或 clrGray 之类的值是不言自明的,而其他类型通常不需要任何详细说明 — 因此,对于 CEdit “控件”,布尔类型值通常表示“只读”标志,而它是 CButton 的“贴合”符号。 结果就是,仅用重载运算符来分配值就很诱人。 不过,奇怪的是,赋值运算符不适合我们,因为它不允许调用链的排线。
_layout<CBox> column(...); column = LAYOUT_STYLE_VERTICAL = clrGray = 5; // 'clrGray' - l-value required ...
单行赋值运算符从右到左执行,即并非从引入了重载分配的对象开始。 它的操作方式如下:
((column = LAYOUT_STYLE_VERTICAL) = clrGray) = 5;
但这看起来有点麻烦。
版本:
column = LAYOUT_STYLE_VERTICAL; // orientation column = clrGray; // color column = 5; // margin
这也太长了。 因此,我们决定重载运算符 <= 并按如下方式使用:
column <= LAYOUT_STYLE_VERTICAL <= clrGray <= 5.0;
为此目的,在 LayoutBase 类中有一个存根:
template<typename V> LayoutBase<P,C> *operator<=(const V value) // template function cannot be virtual { Print("Please, override " , __FUNCSIG__, " in your concrete Layout class"); return &this; }
它的双重目的是声明运算符重载,并提醒您在派生类中重写该方法。 理论上,中介类对象必须与以下接口一起使用(未显示全部)。
template<typename T> class ControlProperties { protected: T *object; string context; public: ControlProperties(): object(NULL), context(NULL) {} ControlProperties(T *ptr): object(ptr), context(NULL) {} void assign(T *ptr) { object = ptr; } T *get(void) { return object; } virtual ControlProperties<T> *operator[](const string property) { context = property; StringToLower(context); return &this; }; virtual T *operator<=(const bool b) = 0; virtual T *operator<=(const ENUM_ALIGN_MODE align) = 0; virtual T *operator<=(const color c) = 0; virtual T *operator<=(const string s) = 0; virtual T *operator<=(const int i) = 0; virtual T *operator<=(const long l) = 0; virtual T *operator<=(const double d) = 0; virtual T *operator<=(const float f) = 0; virtual T *operator<=(const datetime d) = 0; };
如我们所见,需要设置的元素(对象)的链接存储在中介类中。 绑定是在构造函数中执行的,或者是利用 assign 方法执行的。 如果我们假设已编写了特殊的中介类 MyControlProperties:
template<typename T> class MyControlProperties: public ControlProperties<T> { ... };
然后,在 _layout 类中,我们可以根据以下规化使用它的对象(代码和方法已添加了注释):
template<typename T> class _layout: public StdLayoutBase { protected: C *object; C *array[]; MyControlProperties helper; // + public: ... _layout(T *ptr, const string id, const int dx, const int dy) { init(ptr, id, 0, 0, dx, dy); // this will save ptr in the 'object' helper.assign(ptr); // + } ... // non-virtual function override // + template<typename V> // + _layout<T> *operator<=(const V value) // + { if(object != NULL) { helper <= value; } else { for(int i = 0; i < ArraySize(array); i++) { helper.assign(array[i]); helper <= value; } } return &this; }
由于 _layout 中的运算符 <= 是一个模板,因此它将自动从 ControlProperties 的接口生成正确参数类型的调用(当然,它不是有关接口的抽象方法,而是关于在派生类 MyControlProperties 中实现它们的方法;我们将很快为特定的窗口库编写一个)。
在某些情况下,相同的数据类型可定义若干个不同的属性。 例如,在 CWnd 中,当设置元素的可见性和活动状态的标志时,使用相同的布尔值,如上述的“只读”模式(对于 CEdit)和“贴合”(对于 CButton)。 为了能够显式指定属性名称,接口 ControlProperties 中提供了带有字符串类型参数的运算符 []。 它设置 “context” 字段,基于该字段派生的类能够修改所需的特征。
对于输入类型和元素类的各种组合,将其中一个属性(最常用的属性)视为默认属性(如上面所示 CEdit 和 CButton 的示例)。 其他属性需要指定上下文。
例如,对于 CButton,它将如下所示:
button1 <= true; button2["visible"] <= false;
在第一段代码中,未指定上下文; 所以,意味着“锁定”属性(两个位置的按钮)。 在第二个按钮中,按钮初创为不可见,这通常很少见。
我们研究为标准元素库实现中介 StdControlProperties 的基本细节。 完整的代码可以在本文所附的文件中找到。 在开始时,您会看到如何为“布尔”类型重载运算符 “<=”。
template<typename T> class StdControlProperties: public ControlProperties<T> { public: StdControlProperties(): ControlProperties() {} StdControlProperties(T *ptr): ControlProperties(ptr) {} // we need dynamic_cast throughout below, because control classes // in the standard library does not provide a set of common virtual methods // to assign specific properties for all of them (for example, readonly // is available for edit field only) virtual T *operator<=(const bool b) override { if(StringFind(context, "enable") > -1) { if(b) object.Enable(); else object.Disable(); } else if(StringFind(context, "visible") > -1) { object.Visible(b); } else { CEdit *edit = dynamic_cast<CEdit *>(object); if(edit != NULL) edit.ReadOnly(b); CButton *button = dynamic_cast<CButton *>(object); if(button != NULL) button.Locking(b); } return object; }
以下规则适用于字符串:如果只是未指定“字体”,它意味着字体名称,则任何文本都会放入“控件”标题:
virtual T *operator<=(const string s) override { CWndObj *ctrl = dynamic_cast<CWndObj *>(object); if(ctrl != NULL) { if(StringFind(context, "font") > -1) { ctrl.Font(s); } else // default { ctrl.Text(s); } } return object; }
在 StdControlProperties 类中,我们还为仅在标准库里固有的类型引入了 <== 重写。 特别是,它可以取枚举 ENUM_WND_ALIGN_FLAGS 值来描述对齐方式。 请注意,在此枚举中,除了四条边(左、右、上和下)外,并没有描述所有组合,而只是描述了最常用的组合,例如宽度对齐(WND_ALIGN_WIDTH = WND_ALIGN_LEFT | WND_ALIGN_RIGHT),或整个客户区域(WND_ALIGN_CLIENT = WND_ALIGN_WIDTH | WND_ALIGN_HEIGHT)。 不过,如果需要按宽度和上边缘对齐元素,则这种标志组合不再是枚举的一部分。 所以,我们必须明确为其指定类型转换 ((ENUM_WND_ALIGN_FLAGS)(WND_ALIGN_WIDTH|WND_ALIGN_TOP))。 否则,逐位 “或(OR)”运算将产生 int 类型,且会错误地调用设置整数型属性的重载。 替代方案是指定“对齐”上下文。
毫不奇怪,重写 int 类型最费力。 特别是,可以设置属性,例如宽度、高度、边距、字号,等等。 为了简化这种情况,可以直接在布局对象的构造函数中指定尺寸,同时可用双精度型数字,或名为 PackedRect 的特殊包装来指定边距。 当然,还为此增加了操作符重载。 在需要非对称边距的地方用它会很方便:
button <= PackedRect(5, 100, 5, 100); // left, top, right, bottom
因为只用一个双精度型的数值指定等边区域比较容易:
button <= 5.0;
不过,用户可以选择一个替代方案,即“边距”上下文;那么您就不需要双精度型,等效记录如下:
button["margin"] <= 5;
关于边距和缩进,您只需要注意一个警告。 标准库中有对齐项,其中包括要在“控件”周围自动添加的边距。 同时,在 CBox 类中,实现了它们自己的填充机制,该机制表示容器的外部边界与子级“控件”(内容)之间的缝隙。 因此,区域对于“控件”而言,和缩进对于容器,意思基本相同。 不幸的是,由于两个定位算法没有相互参照,因此同时使用边距和缩进可能会引起问题(其中最明显的是元素偏移,而这可能不能满足您的期望)。 通常建议是将缩进保持为零,并用边距进行操作。 不过,在必要时,您也可以尝试包括缩进,特别在针对特定容器,而非常规设置的情况下。
本论文是概念验证(POC)研究,并未提供现成的解决方案。 其目的是针对标准库类和编写时可用的容器尝试提议的技术,同时对所有这些组件进行最少的修改。 理想情况下,必须将容器(不一定是 CBox 容器)编写为 GUI 元素库的集成部分,并考虑到所有可能模式组合的情况下进行操作。
下表是可支持的属性和元素。 CWnd 类表示属性适用于所有元素,而 CWndObj 类则用于简单的“控件”(两个都有,如表中提供的 CEdit 和 CButton)。 类 CWndClient 涵盖“控件”(CCheckGroup、CRadioGroup 和 CListView),且它是容器 CBox/CGrid 的父类。
按数据类型和元素类别排列的可支持属性表
type/control | CWnd | CWndObj | CWndClient | CEdit | CButton | CSpinEdit | CDatePicker | CBox/CGrid |
bool | visible enable | visible enable | visible enable | (readonly) visible enable | (locking) visible enable | visible enable | visible enable | visible enable |
color | (text) background border | (background) border | (text) background border | (text) background border | (background) border | |||
string | (text) font | (text) font | (text) font | |||||
int | width height margin left top right bottom align | width height margin left top right bottom align fontsize | width height margin left top right bottom align | width height margin left top right bottom align fontsize | width height margin left top right bottom align fontsize | (value) width height margin left top right bottom align min max | width height margin left top right bottom align | width height margin left top right bottom align |
long | (id) | (id) zorder | (id) | (id) zorder | (id) zorder | (id) | (id) | (id) |
double | (margin) | (margin) | (margin) | (margin) | (margin) | (margin) | (margin) | (margin) |
float | (padding) left * top * right * bottom * | |||||||
datetime | (value) | |||||||
PackedRect | (margin[4]) | (margin[4]) | (margin[4]) | (margin[4]) | (margin[4]) | (margin[4]) | (margin[4]) | (margin[4]) |
ENUM_ALIGN_MODE | (text align) | |||||||
ENUM_WND_ALIGN_FLAGS | (alignment) | (alignment) | (alignment) | (alignment) | (alignment) | (alignment) | (alignment) | (alignment) |
LAYOUT_STYLE | (style) | |||||||
VERTICAL_ALIGN | (vertical align) | |||||||
HORIZONTAL_ALIGN | (horizonal align) |
类 StdControlProperties 的完整源代码附带于后,可确保变换布局元素的属性,并调用标准组件库的方法。
我们来尝试测试布局类。 我们终于可以开始实测,从简单到复杂。 自从已发表的两篇有关使用容器进行 GUI 布局的原创文章,根据开发传统,我们来适应新的技术,滑动拼图(SlidingPuzzle4),和操控“控件”的标准演示(ControlsDialog4)。 索引对应于这些项目的阶段更新。 在文章中,索引 3 代表相同的程序,而您可以根据需要比较源代码。 可以在文件夹 MQL5/Experts/Examples/Layouts/ 中找到示例。
CSlidingPuzzleDialog 主要形式的公开接口中唯一可考虑修改的是新方法 CreateLayout。 应从 OnInit 处理程序中调用它,而不是常规的 Create。 两个方法拥有相同的参数列表。 由于对话框本身是一个布局对象(最外层),并且其方法 Create 将由新框架自动调用(由方法 StdLayoutBase::create 完成,我们已在上面进行了研究),因此需要进行替换。 窗体框架及其内容的所有信息,都利用基于 MQL 的标记语言在 CreateLayout 方法中专门定义。 这是方法本身:
bool CSlidingPuzzleDialog::CreateLayout(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2) { { _layout<CSlidingPuzzleDialog> dialog(this, name, x1, y1, x2, y2); { _layout<CGridTkEx> clientArea(m_main, NULL, 0, 0, ClientAreaWidth(), ClientAreaHeight()); { SimpleSequenceGenerator<long> IDs; SimpleSequenceGenerator<string> Captions("0", 15); _layout<CButton> block(m_buttons, "block"); block["background"] <= clrCyan <= IDs <= Captions; _layout<CButton> start(m_button_new, "New"); start["background;font"] <= clrYellow <= "Arial Black"; _layout<CEdit> label(m_label); label <= "click new" <= true <= ALIGN_CENTER; } m_main.Init(5, 4, 2, 2); m_main.SetGridConstraints(m_button_new, 4, 0, 1, 2); m_main.SetGridConstraints(m_label, 4, 2, 1, 2); m_main.Pack(); } } m_empty_cell = &m_buttons[15]; SelfAdjustment(); return true; }
在此,两个嵌套容器是连续形成,每个容器都由其自己的布局对象控制:
然后,在客户区中,初始化按钮集合 CButton m_buttons[16],绑定到单个布局对象、区块、以及游戏开始按钮(“start” 对象中的 CButton m_button_new),和消息标签(CEdit m_label,对象“label”)。 所有局部变量,即 dialog、clientArea、block、start 和 label,确保在执行代码时自动为界面元素调用 Create,并为它们分配所定义的附加参数(参数将在后面讨论); 删除时(即,当超出下一个闭合模块的可视范围时),将绑定的界面元素,注册到更高层的容器中。 因此,m_main 客户区将包含在 “this” 窗口中,而所有“控件”都将位于客户区中。 但在这种情况下,它以相反的顺序执行,因为区块的开始靠近其嵌套元素。 但这不是那么重要。 使用传统的对话框创建方法时,实际上会发生相同的情况:较大的界面组会创建较小,而后者又会创建更小的,直至独立“控件”级别,然后开始按相反的顺序(升序)添加 初始化的元素:首先,将“控件”添加到中等块中,然后将中等控件添加到较大的块中。
对于对话框和客户区,所有参数都是通过构造函数参数传递的(就像标准的 Create 方法一样)。 我们不需要将大小传递给“控件”,因为 GridTkEx 类会自动正确分配它们,而其他参数则用运算符 <= 传递。
初始化一个由 16 个按钮组成的块,而无需任何可见的循环(它现在隐藏在布局对象中)。 所有按钮的背景色由字符串 block[“background”] <= clrCyan 定义。 然后,将我们尚不知道的辅助对象传递给同一布局对象(SimpleSequenceGenerator)。
形成用户界面时,通常需要生成几个相同类型的元素,并用一些已知数据以批处理模式填充它们。 为此目的,最方便的方式是利用所谓的生成器。
在循环中调用 Generator 类所含方法,从特定列表中获取下一个元素。
template<typename T> class Generator { public: virtual T operator++() = 0; };
通常,生成器必须知道所需元素的数量,并且它会存储一个游标(当前元素的索引)。 特别是,如果您需要创建某些嵌入类型数值的序列(例如整数型或字符串型),则以下 SimpleSequenceGenerator 的简单实现应该适合您。
template<typename T> class SimpleSequenceGenerator: public Generator<T> { protected: T current; int max; int count; public: SimpleSequenceGenerator(const T start = NULL, const int _max = 0): current(start), max(_max), count(0) {} virtual T operator++() override { ulong ul = (ulong)current; ul++; count++; if(count > max) return NULL; current = (T)ul; return current; } };
添加生成器能方便地进行批处理操作(文件 Generators.mqh),而针对布局类中的生成器,重写了运算符 <=。 这令我们可以在一行中填写 16 个带有标识符和标题的按钮。
在如下方法 CreateLayout 的清单中,创建 m_button_new 按钮。
_layout<CButton> start(m_button_new, "New"); start["background;font"] <= clrYellow <= "Arial Black";
字符串 “New” 既是标识符又是标题。 如果我们需要分配另一个标题,则可以如下进行操作:start <= "Caption"。 通常,没必要定义标识符,亦或(如果我们不需要)。 系统将自行生成。
在第二段清单中,定义了上下文,该上下文一次包含两个工具提示:背景和字体。 需要前者才能正确解释颜色 clrYellow。 由于按钮是 CWndObj 的后代,因此 “unnamed” 颜色表示其文本颜色。 第二个工具提示确保按字符串 “Arial Black” 更改使用的字体(无需任何上下文中,字符串就可更改标题)。 如果您愿意,您可以编写更多详细信息:
start["background"] <= clrYellow; start["font"] <= "Arial Black";
当然,按钮仍然拥有其方法,即,您可以像以前一样编写:
m_button_new.ColorBackground(clrYellow); m_button_new.Font("Arial Black");
然而,要做到这一点,就必须有一个按钮对象,情况并非总是如此 — 稍后,我们将提出一种方案,其中布局系统将负责一切,包括构造和存储元素。
为了设置标签,请使用以下代码:
_layout<CEdit> label(m_label); label <= "click new" <= true <= ALIGN_CENTER;
在此处创建含有自动标识符的对象(如果在图表上打开列出对象的窗口,则将看到实例的唯一编号)。 在第二段代码中,我们定义标签文本,“只读”属性和文本的中心对齐。
然后遵循代码调整 CGridTKEx 类 的m_main 对象:
m_main.Init(5, 4, 2, 2); m_main.SetGridConstraints(m_button_new, 4, 0, 1, 2); m_main.SetGridConstraints(m_label, 4, 2, 1, 2); m_main.Pack();
CGridTKEx 是稍微改进的 CGridTk(对比前面的文章)。 在 CGridTkEx 中,我们用新方法 SetGridConstraints 实现了为子“控件”定义限制的方法。 在 GridTk 中,只能在方法 Grid 中完成同时添加元素的操作。 本质上这做法不妥,因为它在一种方法中混合了两种本质上不同的操作:在对象之间建立关系,并调整属性。 甚至,事实证明,您不应该使用 Add 将元素添加到网格中,但您必须用该方法(因为这是定义限制的唯一方法,没有 GridTk,GridTk 将无法工作)。 这违反了函数库的常规方式,其中始终用 Add 完成此目的。 自动标记系统的操作又与之相关。 在 CGridTkEx 类中,我们将 2 个操作分离 — 现在它们均有自己的方法。
需要提醒的是,对于 CBox/CGridTk 类的主容器(包括整个窗口),调用方法 Pack 是很重要的 — 以这种方法执行布局,必要时在嵌套容器中调用 Pack。
如果我们比较 SlidingPuzzle3.mqh 和 SlidingPuzzle4.mqh 的源代码,我们很容易能注意到源代码已变得更加紧凑。 方法 Create、CreateMain、CreateButton、CreateButtonNew 和 CreateLabel 已从该类“离开”。 唯一的 CreateLayout 现在可以代替所有。
启动该程序后,我们可以看到已创建元素,并按预期工作。
好了,我们仍然可以在清单中声明该类的所有“控件”和容器。 随着程序变得越来越复杂,且组件数量增加,在窗口类和布局中复制它们的描述越来越不方便。 可以用布局完成所有操作吗?这很容易猜到:它可以。 只是,这将在第二部分中讨论。
本论文阐述了图形界面标记语言的理论基础和目标。 我们以 MQL 开发了标记语言概念的实现,并研究了体现该思想的核心类。 但是,还有更多、更复杂和更具建设性的示例。
本社区仅针对特定人员开放
查看需注册登录并通过风险意识测评
5秒后跳转登录页面...
移动端课程