
MQL 作为 MQL 程序图形界面的标记工具(第三部)。 窗体设计师
在前两篇文章( 1 和 2 )中,我们研究了在 MQL 中构建界面标记系统的一般概念,并为表达类的层次结构的界面元素实现了基本类的初始化,对其进行缓存,样式化,为其设置属性,以及响应事件。 动态创建所需元素能够即时修改简单对话框布局,而已创建元素的单个存储的可用性通常能用建议的 MQL 语法来创建它,随后可以将其按原样插入到需要 GUI 的 MQL 程序之中。 故此,我们已着手创建窗体的图形编辑器。 我们会在本文中密切关注此任务。
问题陈述
编辑器必须确保在窗口中布置元素,并调整其基本属性。 以下是可支持属性的常规列表,但并非所有属性均适用于所有类型的元素。
- 类型,
- 名称,
- 宽度,
- 高度,
- 内部内容对齐样式,
- 文字或标题,
- 背景颜色,
- 在父容器里的对齐方式, 以及
- 距容器边界的偏移量/区域。
此处未包括许多其他属性,例如字体名称和大小,或各种类型“控件”的特定属性(尤其是“粘贴”按钮的属性)。 如此做意在简化这个主要针对概念验证(POC)的项目。 如有必要,稍后可以在编辑器中添加其他属性的支持。
通过偏移量可以间接完成绝对坐标定位,但是不建议这样做。 建议 CBox 容器采用本身的对齐设置自动执行定位。
编辑器是为标准函数库界面元素类而设计的。 若要为其他函数库创建类似的工具,您必须依据提议的标记系统为所有抽象实体编写特定实现。 同时,您应该遵照标准库的标记类实现作为指导。
应当注意,“标准组件库”定义实际上并不正确,因为关乎之前文章的上下文,我们必须对其进行大量修改,并将其放置在并行版本分支的 ControlsPlus 文件夹中。 于此,我们将继续使用和修改它。
我们列出编辑器支持的元素类型。
- 带有水平(CBoxH)和垂直(CBoxV)方向的容器 CBox,
- CButton,
- CEdit 输入框,
- CLabel,
- SpinEditResizable,
- 日历选择器 CDatePicker,
- 下拉菜单 ComboBoxResizable,
- 列表 ListViewResizable,
- CheckGroupResizable, 和
- RadioGroupResizable.
所有类都确保能够自适应调整大小(某些标准类型在一开始就可以做到,而我们必须对其他类型进行相当大的更改)。
该程序由两个窗口组成:“Inspector” 对话框,用户在其中选择要创建的控件所需属性;在 “Designer” 中,创建这些元素,从而按设计形成图形界面的外观。
GUI MQL 设计器程序界面草案
就 MQL 而言,该程序将在各自名称的头文件中定义 2 个基类,即 InspectorDialog 和 DesignerForm。
#include "InspectorDialog.mqh" #include "DesignerForm.mqh" InspectorDialog inspector; DesignerForm designer; int OnInit() { if(!inspector.CreateLayout(0, "Inspector", 0, 20, 20, 200, 400)) return (INIT_FAILED); if(!inspector.Run()) return (INIT_FAILED); if(!designer.CreateLayout(0, "Designer", 0, 300, 50, 500, 300)) return (INIT_FAILED); if(!designer.Run()) return (INIT_FAILED); return (INIT_SUCCEEDED); }
这两个窗口都是由 MQL 标记技术形成的 AppDialogResizable(以下称为 CAppDialog)的子类。 因此,我们看到调用 CreateLayout,替代了 Create。
每个窗口都有自己的界面元素缓存。 然而,在 Inspector 当中,它从一开始就填充了“控件”,它们以相当复杂的布局(我们尝试以一般术语进行研究)进行描述,而在 Designer 里则是空的。 这很容易解释:几乎所有程序的业务逻辑都存储在 Inspector 之中,而 Designer 则是一个虚拟对象,Inspector 将根据用户的命令逐个实现新元素。
PropertySet
上面列出的每个属性都由特定类型的值表示。 例如,元素名称是一个字符串,而宽度和高度则是整数。 全部的数值集合完整定义了必须出现在 Designer 中的对象。 将集合存储在一个位置是合理的,为此引入了特殊类 PropertySet。 但是其中必须包含哪些成员变量呢?
乍一看,使用简单嵌入式类型的变量似乎是一个显而易见的解决方案。 不过,它们缺乏将来会用到的重要功能。 MQL 不支持指向简单变量的链接。 于此同时,链接在处理用户界面的算法当中又非常重要。 这通常意味着针对数值变化的复杂反应。 例如,在一个字段中输入的数值若是超界,其必然会阻塞某些依赖它的“控件”。如果这些“控件”能够通过检查存储在单一位置处的数值,并遵其指导控制自己的状态,将会很方便。 最简单的方式是利用链接“指路”到同一变量。 因此,我们将用近似如下所示的模板包装器类,替代简单的嵌入式类型,临时将其命名为 Value。
template<typename V> class Value { protected: V value; public: V operator~(void) const // getter { return value; } void operator=(V v) // setter { value = v; } };
加上单词“大约”是个好主意。 实际上,会在类中添加更多功能,下面将会进行研究。
对象包装器能够拦截重载运算符 '=' 作为赋新值,而在使用简单类型时是不可能的。 我们将需要它。
研究该类,可以大致如下描述新界面对象的属性集合。
class PropertySet { public: Value<string> name; Value<int> type; Value<int> width; Value<int> height; Value<int> style; // VERTICAL_ALIGN / HORIZONTAL_ALIGN / ENUM_ALIGN_MODE Value<string> text; Value<color> clr; Value<int> align; // ENUM_WND_ALIGN_FLAGS + WND_ALIGN_CONTENT Value<ushort> margins[4]; };
在 “Inspector” 对话框里,我们将引入该类的变量,从 Inspector 控件输入的当前设置会集中存储于此。
显然,在 Inspector 里每个属性都会用合适的控件来定义。 例如,为了选择要创建的“控件”类型,将用下拉列表 CComboBox,而将 CEdit 输入框则用于名称。 属性代表类型的单个值,例如列表中的行、数字或索引。 即使是复合属性,例如为 4 个边中的每一个分别定义的偏移量,也应独立考虑(左,上、等等),因为需要保留 4 个字段用于输入它们,因此,每个值都将连接到为其分配的控件。
因此,我们为 “Inspector” 对话框制定一条显而易见的规则 — 其中的每个控件都定义与之相关的属性,并且始终拥有给定类型的特定值。 这导致我们获得以下架构解决方案。
“控件”的特性
在之前的文章中,我们引入了一个特殊的接口 Notifiable,该接口允许为特定控件定义事件处理。
template<typename C> class Notifiable: public C { public: virtual bool onEvent(const int event, void *parent) { return false; }; };
在这里,C 是“控件”类之一,譬如 CEdit、CSpinEdit、等等。 布局缓存自动为相关元素和事件类型调用 onEvent 应答程序。 很自然,只有将正确的代码添加到事件映射中,它才会发生。 例如,在上一部分中,依此原理调整了处理 “Inject” 按钮单击的过程(它已被描述为 Notifiable <CButton> 的后代)。
如果用控件来调整预定义类型的属性,那么创建一个更专业的接口 PlainTypeNotifiable 会很令人神往。
template<typename C, typename V> class PlainTypeNotifiable: public Notifiable<C> { public: virtual V value() = 0; };
方法 value 旨在从 C 元素里返回最典型的 V-类型值。例如,对于类 CEdit,返回字符串类型值看起来很自然(在某些假设的类 ExtendedEdit 中)。
class ExtendedEdit: public PlainTypeNotifiable<CEdit, string> { public: virtual string value() override { return Text(); } };
对于每种“控件”,都有一个单一特征的数据类型,或有限范围(例如,对于整数,您可选的精度有 short、int 或 long)。 所有“控件”都有一个或另一个 “getter” 方法,可以在可重载的 “value” 方法中取值。
故此,我们进入了体系结构解决方案的要点 — 协调 Value 和 PlainTypeNotifiable 类。 它用后代类实现,PlainTypeNotifiable,该类将“控件”值从 Inspector 里移到与其链接的 Value 属性中。
template<typename C, typename V> class NotifiableProperty: public PlainTypeNotifiable<C,V> { protected: Value<V> *property; public: void bind(Value<V> *prop) { property = prop; // pointer assignment property = value(); // overloaded operator assignment for value of type V } virtual bool onEvent(const int event, void *parent) override { if(event == ON_CHANGE || event == ON_END_EDIT) { property = value(); return true; } return false; }; };
由于继承自模板类 PlainTypeNotifiable,因此新类 NotifiableProperty 表示 C “控件”类和 V-类型值的提供者。
方法 bind 能够将“值”的链接保留在“控件”内部,然后自动更改属性值(通过引用),从而响应用户对“控件”的操作。
例如,对于字符串类型的输入字段,引入了 EditProperty,类似于 ExtendedEdit 实例,但继承自 NotifiableProperty:
class EditProperty: public NotifiableProperty<CEdit,string> { public: virtual string value() override { return Text(); // Text() is a standard method of CEdit } };
对于下拉列表,类似的类定义了含有整数值的属性。
class ComboBoxProperty: public NotifiableProperty<ComboBoxResizable,int> { public: virtual int value() override { return (int)Value(); // Value() is a standard method of CComboBox } };
“控件”属性类描述了程序中所有基本类型的元素。
“可通知属性”类示意图
现在是时候摆脱“近似地”这个称呼,并了解完整的类了。
StdValue:值、监控和依赖
上面已经提到了一种标准情况,其中有必要监视某些“控件”的变化,以便检查其有效性,和其他“控件”的状态变化。 换言之,我们需要一个能够监视“控件”,并通知其他相关“控件”其变化的观察者。
为此目的,引入了接口 StateMonitor(观察者)。
class StateMonitor { public: virtual void notify(void *sender) = 0; };
方法 notify 由变化源调用,从而令观察者能够在必要时做出响应。 变化源可以通过 “sender” 参数加以识别。 当然,变化源必须预先以某种方式知道对其通知感兴趣的特定观察者。 为此,源必须实现接口 Publisher。
class Publisher { public: virtual void subscribe(StateMonitor *ptr) = 0; virtual void unsubscribe(StateMonitor *ptr) = 0; };
观察者可以利用 “subscribe” 方法将链接传递给 Publisher。 不难猜测,对于我们而言,变化的来源将是属性,因此,假设类 Value 实际上是从 Publisher 继承的,并如下所示。
template<typename V> class ValuePublisher: public Publisher { protected: V value; StateMonitor *dependencies[]; public: V operator~(void) const { return value; } void operator=(V v) { value = v; for(int i = 0; i < ArraySize(dependencies); i++) { dependencies[i].notify(&this); } } virtual void subscribe(StateMonitor *ptr) override { const int n = ArraySize(dependencies); ArrayResize(dependencies, n + 1); dependencies[n] = ptr; } ... };
任何已注册的观察者都获得“依赖关系”,且如果数值发生变化,则调用其 “notify” 方法得到通知。
由于属性是我们引入的唯一与“控件”相关联的,我们将为标准库提供保存指向“控件”链接的最终属性类,即 StdValue(它用到了所有 CWind “控件”类型)。
template<typename V> class StdValue: public ValuePublisher<V> { protected: CWnd *provider; public: void bind(CWnd *ptr) { provider = ptr; } CWnd *backlink() const { return provider; } };
该链接在以后会很有用。
这些是填充 PropertySet 的 StdValue 实例。
StdValue 通讯示意图
在上面提到的类 NotifiableProperty 里,实际上还用到了 StdValue,在方法 “bind” 中,我们将属性值绑定到“控件”(this)。
template<typename C, typename V> class NotifiableProperty: public PlainTypeNotifiable<C,V> { protected: StdValue<V> *property; public: void bind(StdValue<V> *prop) { property = prop; property.bind(&this); // + property = value(); } ... };
自动管理“控件”状态 — EnableStateMonitor
响应某些设置变化的最相关方法是阻塞/解锁其他依赖的“控件”。这种自适应的每个“控件”状态可能取决于若干设置(并非仅限于一种设置)。 为了监控它们,开发了一个特殊的抽象类 EnableStateMonitorBase。
template<typename C> class EnableStateMonitorBase: public StateMonitor { protected: Publisher *sources[]; C *control; public: EnableStateMonitorBase(): control(NULL) {} virtual void attach(C *c) { control = c; for(int i = 0; i < ArraySize(sources); i++) { if(control) { sources[i].subscribe(&this); } else { sources[i].unsubscribe(&this); } } } virtual bool isEnabled(void) = 0; };
“控件”状态由给定观察者监控,并放置在 “control” 字段中。 数组 “sources” 包含影响状态的变化源。 该数组必须在后代类中加以填充。 当我们通过调用 “attach” 将观察者连接到特定的“控件”时,观察者即可订阅所有变化源。 然后,它将通过调用其 “notify” 方法,开始接收有关源变化的通知。
isEnabled 方法将决定是否应“阻塞”或“解锁”控件,但此处将其声明为抽象,并在其后代类中实现。
对于标准库类,已知一种启用/禁用“控件”的机制。 我们用它们来实现特定的类 EnableStateMonitor。
class EnableStateMonitor: public EnableStateMonitorBase<CWnd> { public: EnableStateMonitor() {} void notify(void *sender) override { if(control) { if(isEnabled()) { control.Enable(); } else { control.Disable(); } } } };
在实践中,该类经常在程序中用到,但我们只打算研究一个示例。 若要在 Designer 中创建新对象,或使用修改后的属性,在 “Inspector” 对话框中找到 “Apply” 按钮(为此定义了从 Notifiable<CButton> 派生出的 ApplyButton 类)。
class ApplyButton: public Notifiable<CButton> { public: virtual bool onEvent(const int event, void *parent) override { if(event == ON_CLICK) { ... } }; };
如果未定义对象名称,或未选择其类型,则该按钮必须被锁定。 因此,我们通过两个变化源(“publishers”)实现 ApplyButtonStateMonitor:名称和类型。
class ApplyButtonStateMonitor: public EnableStateMonitor { // what's required to detect Apply button state const int NAME; const int TYPE; public: ApplyButtonStateMonitor(StdValue<string> *n, StdValue<int> *t): NAME(0), TYPE(1) { ArrayResize(sources, 2); sources[NAME] = n; sources[TYPE] = t; } virtual bool isEnabled(void) override { StdValue<string> *name = sources[NAME]; StdValue<int> *type = sources[TYPE]; return StringLen(~name) > 0 && ~type != -1 && ~name != "Client"; } };
类的构造函数采用两个指向相关属性的参数。 它们会被保存在 “sourcees” 数组之中。 方法 isEnabled 检查名称是否填写,以及是否选择了类型(是否不为 -1)。 如果满足条件,则按钮可以按下。 此外,还会检查名称中是否包含特殊字符串,Client,这是标准库对话框中为客户区域保留的字符串,因此不能在用户元素的名称里出现。
在 inspector 对话框类中,有一个 ApplyButtonStateMonitor 类型的变量,该变量在构造函数中通过指向存储名称和类型的 StdValue 对象链接进行初始化。
class InspectorDialog: public AppDialogResizable { private: PropertySet props; ApplyButtonStateMonitor *applyMonitor; public: InspectorDialog::InspectorDialog(void) { ... applyMonitor = new ApplyButtonStateMonitor(&props.name, &props.type); }
在对话框布局中,名称和类型的属性绑定到相关的“控件”,而观察者绑定到 “Apply” 按钮。
... _layout<EditProperty> edit("NameEdit", BUTTON_WIDTH, BUTTON_HEIGHT, ""); edit.attach(&props.name); ... _layout<ComboBoxProperty> combo("TypeCombo", BUTTON_WIDTH, BUTTON_HEIGHT); combo.attach(&props.type); ... _layout<ApplyButton> button1("Apply", BUTTON_WIDTH, BUTTON_HEIGHT); button1["enable"] <= false; applyMonitor.attach(button1.get());
我们已经知道 applyMonitor 对象中的 “attach” 方法,而 _layout 对象中的 “attach” 是新事物。 在我们的第二篇文章中已深入讨论了 _layout 类,与该版本相比,“attach” 方法是唯一的变化。 此中间方法仅对检查器对话框内 _layout 对象生成的控件调用 “bind”。
template<typename T> class _layout: public StdLayoutBase { ... template<typename V> void attach(StdValue<V> *v) { ((T *)object).bind(v); } ... };
应当注意,所有属性“控件”(包括本示例中的 EditProperty 和 ComboBoxProperty)都是 NotifiableProperty 类的后代,在该类中,存在一个 “bind” 方法,可将“控件”与存储该控件相关属性的 StdValue 变量绑定。 因此,inspector 窗口中的“控件”实际上已与相关属性绑定,而后者又由观察者 ApplyButtonStateMonitor 监视。 一旦用户更改了两个字段之一的数值,它就会显示在 PropertySet 中(记住 NotifiableProperty 中的 ON_CHANGE和ON_END_EDIT 事件应答程序 onEvent)并通知已注册的观察者,包括 ApplyButtonStateMonitor。 这样会自动更改当前按钮的状态。
在 inspector 对话框中,我们将需要若干个监视器,它们以类似的方式监视“控件”的状态。 我们将在用户手册的某个章节里讲述阻塞的特殊规则。
StateMonitor 类
好了,我们在检查器对话框中表示要创建的对象,和“控件”的所有属性的最终相关性。
- name — EditProperty, 字符串;
- type — ComboBoxProperty, 整数, 受支持元素列表中的类型编号;
- width — SpinEditPropertySize, 整数, 像素;
- height — SpinEditPropertySize, 整数, 像素;
- style — ComboBoxProperty, 等于枚举之一值(取决于元素类型)的整数:VERTICAL_ALIGN(CBoxV),HORIZONTAL_ALIGN(CBoxH)和 ENUM_ALIGN_MODE(CEdit);
- text — EditProperty, 字符串;
- background color — ComboBoxColorProperty, 列表中的颜色值;
- boundary alignment — AlignCheckGroupProperty,位掩码,独立标志组(ENUM_WND_ALIGN_FLAGS + WND_ALIGN_CONTENT); 和
- indents — 四个 SpinEditPropertyShort, 整数;
与“简单的” SpinEditProperty、ComboBoxProperty、CheckGroupProperty 等提供的基本实现相比,某些“属性”元素的类名称更加专业化,即功能扩展。 从用户手册中可以清楚地了解它们的用途。
为了准确、清晰地显示这些“控件”,对话框标记当然包括其他容器和数据标签。 完整代码可在附件中找到。
处理事件
事件映射中定义了所有“控件”的可应答事件:
EVENT_MAP_BEGIN(InspectorDialog)
ON_EVENT_LAYOUT_CTRL_DLG(ON_END_EDIT, cache, EditProperty)
ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, SpinEditProperty)
ON_EVENT_LAYOUT_CTRL_DLG(ON_CLICK, cache, SpinEditProperty)
ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, SpinEditPropertyShort)
ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, ComboBoxProperty)
ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, ComboBoxColorProperty)
ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, AlignCheckGroupProperty)
ON_EVENT_LAYOUT_CTRL_DLG(ON_CLICK, cache, ApplyButton)
...
ON_EVENT_LAYOUT_ARRAY(ON_CLICK, cache) // default (stub)
EVENT_MAP_END(AppDialogResizable)
为了在高速缓存中提高处理事件的效率,已采取了一些特殊步骤。 我们在第二篇文章中引入的宏 ON_EVENT_LAYOUT_CTRL_ANY 和 ON_EVENT_LAYOUT_CTRL_DLG,其操作是基于系统从参数 lparam 里接收到的唯一编号,据其在缓存数组里搜索“控件”。 于此同时,基本缓存实现遍历数组执行线性搜索。
为了加快此过程,将 buildIndex 方法添加到 MyStdLayoutCache 类(StdLayoutCache 的后代)之中,在 Inspector 中存储和使用该类的一个实例。 其中实现的便捷索引功能基于标准库的特殊功能,可为所有元素分配唯一编号。 在方法 CAppDialog::Run 中,我们已知里面有一个随机数,即 m_instance_id,自该数字开始窗口创建的所有图表对象都进行编号。 依此方式,我们就可知道能得到的数值范围。 减去 m_instance_id,每次随事件携带的 lparam 数值,直接变为对象的编号。 不过,该程序在图表中创建的对象要比缓存中存储的对象多得多,因为许多“控件”(以及与此相关的窗口本身,是为框架、标题、最小化按钮、等等的聚集)由多个低级对象构成。 因此,缓存中的索引永远不会与对象标识符减去m_instance_id一致。 故此,我们必须分配一个特殊的索引数组(其大小等于窗口里对象的总数),并取高速缓存中可用的“实际”控件数为其编写序号。 结果就是,根据间接寻址的原理,可立即提供访问。
仅在基本 CAppDialog::Run 实现被分配了唯一编号之后再填充数组,但要在 OnInit 应答程序完成操作之前。 为此目的,最好的解决方案是把方法 Run 设为虚拟(在标准库中不是这样),并在 InspectorDialog 中重写它,举例如下所示。
bool InspectorDialog::Run(void) { bool result = AppDialogResizable::Run(); if(result) { cache.buildIndex(); } return result; }
方法 buildIndex 本身很简单。
class MyStdLayoutCache: public StdLayoutCache { protected: InspectorDialog *parent; // fast access int index[]; int start; public: MyStdLayoutCache(InspectorDialog *owner): parent(owner) {} void buildIndex() { start = parent.GetInstanceId(); int stop = 0; for(int i = 0; i < cacheSize(); i++) { int id = (int)get(i).Id(); if(id > stop) stop = id; } ArrayResize(index, stop - start + 1); ArrayInitialize(index, -1); for(int i = 0; i < cacheSize(); i++) { CWnd *wnd = get(i); index[(int)(wnd.Id() - start)] = i; } ... };
现在,我们可以编写该方法的快速实现,从而可按编号搜索“控件”。
virtual CWnd *get(const long m) override { if(m < 0 && ArraySize(index) > 0) { int offset = (int)(-m - start); if(offset >= 0 && offset < ArraySize(index)) { return StdLayoutCache::get(index[offset]); } } return StdLayoutCache::get(m); }
但对于 Inspector 的内部结构来说已经足够了。
这是正在运行的程序里窗口的外观。
Inspector 对话框和 Form 设计器
除了属性,我们还可以在此处看到一些未知的元素。 它们都将在后面讲述。 现在我们来看一下按钮 Apply。 用户设置属性值之后,可按此按钮在 “Designer” 窗体中生成请求的对象。 派生自 Notifiable 的类,可令其自身的 onEvent 方法处理。
class ApplyButton: public Notifiable<CButton> { public: virtual bool onEvent(const int event, void *parent) override { if(event == ON_CLICK) { Properties p = inspector.getProperties().flatten(); designer.inject(p); ChartRedraw(); return true; } return false; }; };
应当注意,变量检查器和设计器是全局对象,分别带有检查器对话框和设计器窗体。 在其程序界面中,Inspector 拥有方法 getProperties,可提供当前的属性集合,PropertySet,如上所述:
PropertySet *getProperties(void) const { return (PropertySet *)&props; }
PropertySet 可将自身封包为一个平面(常规)结构,Properties,以便传递给 Designer 的方法,inject。 在此,我们去看看 “Designer” 窗口。
Designer(设计器)
除了进行额外的检查之外,方法 “inject” 的本质与第二篇文章的结尾类似:Form 将目标容器放入布局堆栈中(在第二篇文章中是静态设置的,即始终是相同的),并在其中依据所传递属性值生成元素。 在新的窗体里,可单击鼠标来选择所有元素,从而更换插入的上下文。 甚或,这种单击会启动将所选元素的属性转换至 Inspector。 因此,会出现编辑已创建对象的属性,并用相同的 “Apply” 按钮更新它们。 设计器通过比较元素的名称和类型来判断用户是要引入新元素,还是要编辑已有元素。 如果 Designer 缓存中已有此种组合,则意味着进行编辑。
如何添加一个新元素,通常就是这样的。
void inject(Properties &props) { CWnd *ptr = cache.get(props.name); if(ptr != NULL) { ... } else { CBox *box = dynamic_cast<CBox *>(cache.getSelected()); if(box == NULL) box = cache.findParent(cache.getSelected()); if(box) { CWnd *added; StdLayoutBase::setCache(cache); { _layout<CBox> injectionPanel(box, box.Name()); { AutoPtr<StdLayoutBase> base(getPtr(props)); added = (~base).get(); added.Id(rand() + ((long)rand() << 32)); } } box.Pack(); cache.select(added); } }
变量 “cache” 已在 DesignerForm 里定义,并包含一个从 StdLayoutCache 派生的 DefaultStdLayoutCache 类对象(在前面的文章中已将述)。 StdLayoutCache 能偶用方法 “get” 按名称查找对象。 如果不存在,则意味着这是一个新对象,且 Designer 尝试检测用户选择的当前容器。 为此目的,在新类 DefaultStdLayoutCache 中实现了方法 getSelected。 如何精确执行选择,我们稍后会看到。 在此必须注意,实现的新元素只能放置在一个容器里(在我们的情况下,用的是 CBox 容器)。 如果此刻没有选择一个容器,该算法将调用 findParent 来检测父容器,并将其用作目标容器。 定义插入位置后,含有嵌套模块的常规标记方案开始起作用。 在外部模块里,创建带有目标容器的对象 _layout,然后在其内部以字符串形式生成对象:
AutoPtr<StdLayoutBase> base(getPtr(props));
所有属性都传递给助手方法 getPtr。 它可以创建所有受支持类型的对象,但是为了简单起见,我们仅展示它在某些对象的样子。
StdLayoutBase *getPtr(const Properties &props) { switch(props.type) { case _BoxH: { _layout<CBoxH> *temp = applyProperties(new _layout<CBoxH>(props.name, props.width, props.height), props); temp <= (HORIZONTAL_ALIGN)props.style; return temp; } case _Button: return applyProperties(new _layout<CButton>(props.name, props.width, props.height), props); case _Edit: { _layout<CEdit> *temp = applyProperties(new _layout<CEdit>(props.name, props.width, props.height), props); temp <= (ENUM_ALIGN_MODE)LayoutConverters::style2textAlign(props.style); return temp; } case _SpinEdit: { _layout<SpinEditResizable> *temp = applyProperties(new _layout<SpinEditResizable>(props.name, props.width, props.height), props); temp["min"] <= 0; temp["max"] <= DUMMY_ITEM_NUMBER; temp["value"] <= 1 <= 0; return temp; } ... } }
模板化的对象 _layout 是由 GUI 元素的预定义类型所创建,它用到了我们所熟知的 MQL 标记静态描述。 对象 _layout 启用重载运算符 “<=” 来定义属性,实际上,这就是 CBoxH 如何填充 HORIZONTAL_ALIGN 样式,文本字段或轮转器范围的 ENUM_ALIGN_MODE 样式。 其他一些常规属性(例如缩进、文本和颜色)的设置委托给辅助方法 applyProperties(您可以在源代码中找到有关它的更多详细信息)。
template<typename T> T *applyProperties(T *ptr, const Properties &props) { static const string sides[4] = {"left", "top", "right", "bottom"}; for(int i = 0; i < 4; i++) { ptr[sides[i]] <= (int)props.margins[i]; } if(StringLen(props.text)) { ptr <= props.text; } else { ptr <= props.name; } ... return ptr; }
如果按名称在缓存里能找到对象,则会发生以下情况(以简化形式):
void inject(Properties &props) { CWnd *ptr = cache.get(props.name); if(ptr != NULL) { CWnd *sel = cache.getSelected(); if(ptr == sel) { update(ptr, props); Rebound(Rect()); } } ... }
助手方法 “update” 将属性从结构 “props” 转移到已找到的 ptr 对象之中。
void update(CWnd *ptr, const Properties &props) { ptr.Width(props.width); ptr.Height(props.height); ptr.Alignment(convert(props.align)); ptr.Margins(props.margins[0], props.margins[1], props.margins[2], props.margins[3]); CWndObj *obj = dynamic_cast<CWndObj *>(ptr); if(obj) { obj.Text(props.text); } CBoxH *boxh = dynamic_cast<CBoxH *>(ptr); if(boxh) { boxh.HorizontalAlign((HORIZONTAL_ALIGN)props.style); boxh.Pack(); return; } CBoxV *boxv = dynamic_cast<CBoxV *>(ptr); if(boxv) { boxv.VerticalAlign((VERTICAL_ALIGN)props.style); boxv.Pack(); return; } CEdit *edit = dynamic_cast<CEdit *>(ptr); if(edit) { edit.TextAlign(LayoutConverters::style2textAlign(props.style)); return; } }
现在我们回到在窗体里选择 GUI 元素的问题。 由于处理了用户发起的事件,它已由缓存对象解决。 在 StdLayoutCache 类中保留了 onEvent 应答程序,以便利用宏 ON_EVENT_LAYOUT_ARRAY 连接映射的图表事件:
EVENT_MAP_BEGIN(DesignerForm) ON_EVENT_LAYOUT_ARRAY(ON_CLICK, cache) ... EVENT_MAP_END(AppDialogResizable)
这会将所有缓存元素的鼠标点击发送到我们在派生类 DefaultStdLayoutCache 中定义的应答程序 onEvent。 指向通用窗口类型 CWnd 的 “selected” 指,CWnd,已在类中创建,它必须由 onEvent 应答程序填充。
class DefaultStdLayoutCache: public StdLayoutCache { protected: CWnd *selected; public: CWnd *getSelected(void) const { return selected; } ... virtual bool onEvent(const int event, CWnd *control) override { if(control != NULL) { highlight(selected, CONTROLS_BUTTON_COLOR_BORDER); CWnd *element = control; if(!find(element)) // this is an auxiliary object, not a compound control { element = findParent(element); // get actual GUI element } ... selected = element; const bool b = highlight(selected, clrRed); Print(control.Name(), " -> ", element._rtti, " / ", element.Name(), " / ", element.Id()); EventChartCustom(CONTROLS_SELF_MESSAGE, ON_LAYOUT_SELECTION, 0, 0.0, NULL); return true; } return false; } };
在窗体里选择的可见元素,会加上红色边框,作为简易的“高亮”方法(即 ColorBorder)。 处理器首先取消先前选择的元素(设置边框颜色 CONTROLS_BUTTON_COLOR_BORDER),然后找到与所单击图表对象对应的缓存元素,并将其指针保存在 “selected” 变量中。 最后,新选择的对象用红色边框标记,并发送 ON_LAYOUT_SELECTION 事件至图表。 它会通知 Inspector,在窗体里已选择了一个新元素,故此它应该在 Inspector 对话框中显示其属性。
在 Inspector 里,此事件在 OnRemoteSelection 应答程序中被拦截,该应答程序从 Designer 请求所选择对象的链接,并通过函数库的标准 API 读取该对象的所有属性。
EVENT_MAP_BEGIN(InspectorDialog) ... ON_NO_ID_EVENT(ON_LAYOUT_SELECTION, OnRemoteSelection) EVENT_MAP_END(AppDialogResizable)
以下是方法 OnRemoteSelection 的开头部分。
bool InspectorDialog::OnRemoteSelection() { DefaultStdLayoutCache *remote = designer.getCache(); CWnd *ptr = remote.getSelected(); if(ptr) { string purename = StringSubstr(ptr.Name(), 5); // cut instance id prefix CWndObj *x = dynamic_cast<CWndObj *>(props.name.backlink()); if(x) x.Text(purename); props.name = purename; int t = -1; ComboBoxResizable *types = dynamic_cast<ComboBoxResizable *>(props.type.backlink()); if(types) { t = GetTypeByRTTI(ptr._rtti); types.Select(t); props.type = t; } // width and height SpinEditResizable *w = dynamic_cast<SpinEditResizable *>(props.width.backlink()); w.Value(ptr.Width()); props.width = ptr.Width(); SpinEditResizable *h = dynamic_cast<SpinEditResizable *>(props.height.backlink()); h.Value(ptr.Height()); props.height = ptr.Height(); ... } }
从 Designer 缓存接收选定对象的 ptr 链接后,该算法查找其名称,并从窗口标识符中清除该名称(CAppDialog 类中的 m_instance_id 字段是所有名称的前缀,以避免不同窗口对象之间的冲突,因为我们会有 2 个),并将其写入与名称相关的“控件”之中。 您应当注意,在此我们用的是属性 StdValue<string> 名称到“控件”的反向链接(backlink())。 甚至,由于我们是从内部修改字段的,因此与其变化相关的事件(有些情况是由用户发起的更改)不会生成; 因此,它还需要将新值写入 PropertySet (props.name) 的相关属性。
从技术上讲,以 OOP 的角度来看,为属性“控件”的每种类型重写其虚拟的修改方法,并自动更新与其链接的 StdValue 实例更加正确。 在此举例,这是 CEdit 的实现方式。
class EditProperty: public NotifiableProperty<CEdit,string> { public: ... virtual bool OnSetText(void) override { if(CEdit::OnSetText()) { if(CheckPointer(property) != POINTER_INVALID) property = m_text; return true; } return false; } };
然后,使用 Text() 方法更改字段内容将导致随后调用 OnSetText,并自动更新属性。 然而,对于复合控件(例如 CCheckGroup),这样做不是很方便。 因此,我们倾向于一种更实际的实现。
与此类似,使用“控件”的反向链接,我们就能更新 Designer 里所选择对象的高度、宽度、类型和其他属性字段中的内容。
为了识别受支持的类型,我们有一个枚举,可以基于特殊变量 _rtti 来检测其元素,我们在之前的几篇文章里,将该变量添加到最底层的 CWnd 类之中,并在所有派生类中用特殊的类名填充它。
快速入门指南
“Inspector” 对话框包含各种类型的输入字段,其中包含当前对象(在 Designer 中选择),或要创建的对象属性。
强制填写的字段是名称(字符串)和类型(将在下拉列表中选择)。
宽度和高度字段允许以像素为单位定义对象大小。 只是,如果在下面指定了特定的拉伸模式,则不会考虑这些设置:例如,与左右边界绑定表示填满容器的宽度。 鼠标点击高度或宽度字段同时按住 Shift 键,可将属性重置为默认值(宽度 100,和高度 20)。
改进了所有 SpinEdit 类型的“控件”(不仅是大小属性),其方式是鼠标在“控件”内部移动(拖动但不放下)的情况下,按住鼠标键向左或向右移动能快速按比例更改 “spinner” 像素所覆盖的距离。 这样做是为了便于编辑,因为按小按钮并不是很方便。 任何程序均可更改来自 ControlsPlus 文件夹中的“控件”。
含有内容对齐样式(Style)的下拉列表仅适用于 CBoxV、CBoxH 和 CEdit 的元素(所有其他类型均被阻塞)。 对于 CBox 容器,所有对齐模式 ("center", "justify", "left/top", "right/bottom", 和 "stack") 均允许。 对于 CEdit,只有与 ENUM_ALIGN_MODE("center", "left", 和 "right")相对应的才起作用。
字段 "Text" 定义 CButton 的标题,CLabel 或 CEdit 的内容。 对于其他类型,该字段则被禁用。
下拉列表 “Color” 旨在从 Web 颜色列表中选择背景颜色。 它仅适用于 XBox、XBox、Button 和 CREdit。 其他类型的“控件”是复合控件,需要更复杂的技术来为其更新所有组件的颜色,故我们决定不支持为它们着色。 为了选择颜色,修改了 CListView 类。 在其内添加了特殊的 “color” 模式,在该模式下,列表项的值被解释为颜色代码,并且每项的背景均以相关颜色绘制。 此模式由 SetColorMode 方法启用,并在新类 ComboBoxWebColors(来自 Layouts 文件夹的 ComboBoxResizable 的特殊化)中调用。
目前无法选择函数库 GUI 的标准颜色,因为定义默认颜色存在问题。 对于我们来说,重要的是要知道每种“控件”类型的默认颜色,以便在用户未选择任何特定颜色的情况下,在列表中不要把它显示为已选颜色。 最简单的方法是创建一个特定类型的空“控件”,并在其中读取 ColorBackground 属性,但它只能在数量非常有限的“控件”时使用。 问题在于,通常不会在类构造函数中分配颜色,而是在 Create 方法中分配颜色,该方法会引发许多不必要的初始化,包括在图表中创建实际对象。 当然,我们不需要任何不必要的对象。 甚至,许多复合对象的背景色就是背景底色,而并非来自基准“控件”。由于考虑到这些细微差别的复杂性,我们决定考虑在标准库“控件”的任何类别中不选择默认颜色。这意味着它们不能包含在列表中,否则的话,用户可能会选择这种颜色,但在 Inspector 中看不到任何对其选择的确认。 Web 颜色和标准 GUI 颜色的列表在 LayoutColors.mqh 文件中提供。
若要将颜色重置为默认值(每种“控件”类型不同),应在列表中选择与 clrNONE 相关的第一个 “empty” 项。
一组独立切换器中的标志 Alignment 与枚举 ENUM_WND_ALIGN_FLAGS 两边侧的对齐方式相对应,加上特殊模式 WND_ALIGN_CONTENT,这在第二篇文章中已有过介绍,且它仅适用于容器。 如果您按下切换器时按住 Shift 键,程序将同步切换 ENUM_WND_ALIGN_FLAGS 的所有 4 个标志。 如果启用了该选项,则还应启用其他选项,反之亦然;如果禁用了该选项,则将重置其他选项。 一键切换整个组,WND_ALIGN_CONTENT 除外。
“Spinners” 边距定义元素的缩进,该缩进与该元素所在的容器矩形的边界有关。 字段顺序:左,上,右和下。 按住 Shift 键的同时单击任意字段,可将所有字段快速重置为零。 按住 Ctrl 键的同时单击含有所需值的字段,可以轻松地将所有字段设置为相等 — 这导致该值被复制到其它 3 个字段中。
我们已经知道 “Apply” 按钮 — 它应用所做的更改,这将导致在Designer中创建新的“控件”或修改现有控件。
新对象将被插入到所选容器对象,或包含所选“控件”的容器当中(如果已选择“控件”)。
若要在 Designer 中选择元素,应使用鼠标单击它。 所选元素以红色框高亮显示。 唯一的例外是 CLabel — 它不支持此功能。
插入后立即自动选择新元素。
只有容器 CBoxV 或 CBoxH 能插入到一个空对话框之中,且不必预先选择客户区。 默认情况下,第一个也是最大的容器会拉伸填满整个窗口。
重复单击已选择的元素则会删除请求。 删除仅在用户确认后才会实际发生。
两个位置的按钮 TestMode 在 Designer 的两个操作模式之间切换。 默认情况下,它是未按下的,测试模式是禁用的,Designer 界面编辑是可操作的 — 用户可以通过单击鼠标来选择元素,或将其删除。 按下时,将启用测试模式。 于此同时,对话框的操作方式与实际程序中的操作大致相同,而布局编辑和元素选择被禁用。
按钮 Export 允许将 Designer 界面的当前配置保存为 MQL 布局。 文件名以前缀 layout 开头,并包含当前的时间掩码和扩展名 txt。 如果您在按 “Export” 时按住 Shift 键,则窗体的配置将以二进制格式(而不是文本)保存到其自身格式的文件中,扩展名为 mql。 这很方便,因为您可以中断布局设计过程,然后过一会儿再恢复它。 若要加载二进制布局 mql 文件,使用同一个 “Export” 按钮,条件是元素的表单和缓存为空,这在启动程序后会立即执行。 当前版本始终尝试导入文件 layout.mql。 如果您愿意,可以在输入或 MQL 中实现文件选择。
在 “Inspector” 对话框的上部,有一个下拉列表,其中包含在 Designer 中创建的所有元素。 在列表中选择一个元素会导致在 Designer 中自动选择并高亮显示该元素。 反之亦然,选择表单中的元素会在列表中成为当前元素。
现在,在编辑中,可能会出现两种类别的错误:这些是可以通过分析 MQL 布局而更正的错误,以及更严重的。 前者包括设置组合,其中“控件”或容器超出了窗口或父容器的边界。 在这种情况下,通常会中断鼠标选择它们,且您只能用 Inspector 中的选择器启用它们。 您可以通过分析文本 MQL 标记来找出哪些属性完全是假的 — 只需按 Export 即可获取其当前状态。 在分析了标记之后,您应该更正 Inspector 中的属性,然后还原表单的正确视图。
程序的目前版本旨在验证概念,且未在源代码中检查所有参数组合,在重新计算自适应容器的大小时可能会发生这种情况。
第二类错误包括错误地将元素插入到错误容器的情况。 在此情况下,您只能删除该元素,然后将其再次添加到别的位置。
建议定期以二进制格式保存表单(按 “Export” 按钮,同时按住 Shift 键),如此即可在出现无法解决的问题时,您可以以最后的正确配置继续工作。
我们来研究一些使用该程序的示例。
示例
首先,我们尝试在 Designer 中重现 Inspector 结构。 在下面的动画图示里,您可从添加四个上部字符串和字段来设置名称、类型和宽度开始的全过程。 用到了不同类型的“控件”、对齐方式、配色方案。 包含字段名称的标签是用 CEdit 的输入字段形成的,因为 CLabel 只具有非常有限的功能(特别是它不支持文本对齐和背景颜色)。 然而,在 Inspector 中未有“只读”属性设置。 因此,将标签表示为不可编辑的唯一方法是为其分配灰色背景(这只是纯粹的视觉效果)。 在 MQL 代码里,此类 CEdit 对象必须确认要相应地调整,即,切换到“只读”模式。 这正是我们在 Inspector 本身中所做的。
编辑窗体
编辑窗体清晰地表明了标记技术的适应性,并作为一种外部表示形式,它唯与 MQL 标记绑定。 您可以随时按 Export 按钮,查看生成的 MQL 代码。
在最终版本里,我们会得到一个对话框,该对话框与 Inspector 窗口在理论上是完全对应的(某些细节除外)。
在 Designer 中还原 Inspector 对话框标记
不过,应当注意的是,在 Inspector 中,许多“控件”类都是非标准的,因为它们是从某个 x-属性继承而来的,且表示附加的算法工具。 不过,在我们的示例中,仅用到“控件”的标准类(ControlsPlus)。 换言之,结果布局始终只包含程序的外部表示形式,和“控件”的标准行为。 跟踪元素的状态,并针对其变化响应(包括可能的类自定义)编写代码是程序员的特权。 创建的系统能够像是在常规 MQL 中一样更改 MQL 标记中的工件。 也就是,您可以将 ComboBox 替换为 ComboBoxWebColors。 但是,无论如何,必须用 #include 指令将布局中提到的所有类都包含在项目中。
用 “Export” 命令将上面的对话框(Inspector 复本)保存到文本文件和二进制文件当中 — 两者都附带于此,分别名为 layout-inspector.txt 和 layout-inspector.mql。
分析完文本文件后,您无需绑定算法或数据即可感知 Inspector 标记。
基本上,将标记导出到文件后,其内容可以插入到任何项目之中,包括布局系统的头文件,和所有用到的 GUI 类。 结果就是,我们得到了一个可操作界面。 特别是,含有空 DummyForm 对话框的项目已附加到其中。 如果您愿意,可以在其中找到 CreateLayout,并将 MQL 标记插入其中,以便在 Designer 中进行初步准备。
对于 layout-inspector.txt,也可以轻松完成此操作。 我们将把这个文件的全部内容复制到剪贴板中,并插入到 CreateLayout 方法中的文件 DummyForm.mqh 当中,这里有注释 "// insert exported MQL-layout here"。
请注意,提及的对话框尺寸以布局文本表示(在本例中为 200*350),为此它之前已被创建。 因此,应在以 _layout<DummyForm> dialog(this...) 形式创建对象的代码之后,和已复制的布局之前,将以下代码插入源代码 CreateLayout 之中:
Width(200); Height(350); CSize sz = {200, 350}; SetSizeLimit(sz);
这将为所有“控件”提供足够的空间,并且不允许缩小对话框。
导出时,我们不会自动生成相关的片段,因为布局可能仅代表对话框的一部分,或者最终被其他类别的窗口和容器使用,而这些窗口和容器也许没有这些方法。
如果现在编译并运行示例,我们将获得非常类似的 Inspector 副本。 但仍存在差异。
恢复 Inspector 界面
首先,所有下拉列表都是空的,因此它们不起作用。 没有调整 “spinners”,因此它们也不起作用。 对齐标记组在视觉上是空的,因为我们尚未在布局中生成任何复选框,但是存在相关的“控件”,甚至还有 5 个隐藏的复选框,这些复选框是由标准组件库依据初始大小生成的“控件”(您可以在图表对象列表,命令“对象列表”中查看所有这些对象)。
其次,确实没有带有缩进值的 “spinners” 组:我们没有将其转移到表单中,因为它是由一个布局对象创建的,并在 Inspector 中作为数组。 我们的编辑器无法执行此类操作。 我们可以创建 4 个独立的元素,但随后我们必须在代码里如同其它一样做出调整。
按下任何“控件”,窗体都会将其名称、类和标识符输出到日志中。
我们还可以将二进制文件 layout-inspector.mql(已将其初步重命名为 layout.mql)加载到 Inspector,并继续进行编辑。 为此目的,运行主项目并按 Export 足矣,就如同早前窗体为空。
请注意,出于阐释目的,Designer 会为带有列表或分组的所有“控件”生成一定数量的数据,并设置 “spinners” 的范围。 因此,当切换到 TestMode 时,我们就可以使用元素。 伪数据的大小在 Designer 窗体中由宏 DUMMY_ITEM_NUMBER 定义,默认为 11。
现在,我们看看交易面板如何出现在 Designer 当中。
交易面板布局:彩色立方体交易面板
它并无虚伪的超级功能,但关键在于可以根据特定交易者的偏好轻松地在根本上进行修改。 此窗体如同前一种,使用着色容器来更容易地看到它们的布置。
我们应该再次有所保留,意思是仅在这里出现。 在 Designer 输出中,我们仅得到负责生成窗口和“控件”初始状态的 MQL 代码。像往常一样,所有计算算法,对用户操作的响应,防止输入错误数据,以及发送交易指令的操作都必须手动编程。
在该布局中,某些类型的“控件”应替换为更合适的内容。 故此,挂单的到期日由日历所示,且它不支持时间输入 。 所有下拉列表必须填充相关选项。 举例来说,止损价位可用不同单位输入,例如价格、以点数为单位的距离、盈/亏资金百分比、或绝对值,同时可以将交易量设置为固定、货币或可用保证金百分比,以及尾随等若干种算法之一。
此标记附带于此,分为两个 layout-color-cube-trade-panel 文件:文本文件和二进制文件。 可以将前一个插入到空窗体(例如 DummyForm)当中,并附带数据和事件处理。 后者可以加载到 Designer 当中,并进行编辑。 但是请记住,图形编辑器不是必需的。 标记也可在其文本表述中进行调整。 编辑器的唯一优点是我们可以进行设置,并即时查看变化。 然而,它仅支持最基本的功能。
结束语
在这篇论文中,我们研究了一个简单的编辑器程序,以交互方式开发基于 MQL 标记技术的图形界面。 呈现的实现仅包括基本功能,但这些功能仍足以展现该概念的可操作性,以及对其他类型“控件”的进一步扩展,对各种属性的更完整支持,GUI 组件的其他函数库,以及编辑机制。 特别是,编辑器仍然缺少取消操作、将元素插入容器中任何位置的功能(即,不光是将它们添加到已存在的“控件”列表的末尾)、分组操作、从剪贴板复制并粘贴的功能、等等。 然而,开源代码令您可以补充和调整该技术,从而满足您的需求。
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/7795
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.



我的一台笔记本电脑就遇到了这种情况。
我有个想法--试着将 Defines.mqh 补丁改为:
添加了减号。
或者另一个想法:
让我知道这两种变体是否有帮助。您好、
首先非常感谢你们的精彩文章!

我正试图将代码移植回 MQL 4,因为那里也有标准控制库。
但我在让 GroupTemplate.mqh 运行时遇到了以下错误:
是否有可能将其移植到 MQL 4,还是只有在 MQL 5 中才有调用模板类型上的方法的功能?
任何帮助都将不胜感激,
最崇高的敬意
Holger
你好
首先,非常感谢你的精彩文章!
我正试图将代码移植到 MQL 4,因为那里也有标准控制库。
但我在让 GroupTemplate.mqh 运行时遇到了以下错误:
是否有可能将其移植到 MQL 4,还是只有在 MQL 5 中才有调用模板类型方法的功能?
任何帮助都将不胜感激,
最良好的祝愿
Holger
与 MQL4 相比,MQL5 恐怕在很多方面都有了重大更新,因此这些模板在没有完全重新工作的情况下无法向后移植。
与 MQL4 相比,MQL5 恐怕在很多方面都有了很大的更新,因此如果不重新制作,这些模板是无法向后移植的。
好的,感谢您的说明!
在一次构建中发生了一些变化(显然与 MQL5 编译器优化器有关)之后,程序在发布版本中无法正常运行,尽管在调试版本中可以正常运行。
我在论坛上报告了这一情况,但没有得到 MQ 的回应。
对象的创建顺序 总是被考虑在内:较晚创建的对象被视为 "在上"--它们在点击处理中具有优先权。
现在,它以一种奇怪的方式被打破了。
如果 "智能交易系统 "在编译时未进行优化或在调试器下编译,则一切正常(和以前一样)。
如果编译时进行了优化,则会分配错误的对象(底层)。