MQL 作为 MQL 程序图形界面的标记工具(第三部)。 窗体设计师·进阶篇
🛠️

MQL 作为 MQL 程序图形界面的标记工具(第三部)。 窗体设计师·进阶篇

(2/3)· 动态创建控件后如何高效排版与调参?本文拆解编辑器双窗口协作逻辑

实战向 第 2/3 篇
手动在代码里排坐标改属性,一次改版就重编译的痛很多人都在忍。用标记驱动的窗体设计师,拖好布局直接导出 MQL 片段,能省掉大半重复劳动。

用观察者自动锁死无效配置的按钮

在 MT5 面板开发里,最烦的是用户没填完参数就点“应用”,把脏数据塞进 PropertySet。与其在点击事件里写一堆判断,不如让控件状态自己跟着依赖属性变:依赖源一变,按钮自动 Enable/Disable。 核心是一个抽象类 EnableStateMonitorBase,它把受监控的控件指针放在 control 字段,把影响状态的变更源塞进 sources 数组。子类必须填 sources,并自己实现 isEnabled 来决定锁还是放。调用 attach 把观察者绑到具体控件时,它会一次性订阅 sources 里所有源,之后源一变就走 notify 重算状态。 实际例子是 Inspector 对话框的 Apply 按钮。若对象名没填、类型没选(等于 -1),或者名字里带了标准库给客户区预留的 "Client" 字符串,按钮就必须锁住。ApplyButtonStateMonitor 用两个源——name 和 type——在 isEnabled 里做这三道检查,满足才放行。 对话框构造函数里 new 一个 monitor,把 props.name 和 props.type 的 StdValue 引用喂进去;布局阶段 EditProperty、ComboBoxProperty 通过 _layout 的 attach 绑到对应 StdValue,观察者再绑到 Apply 按钮。用户改任一字段,NotifiableProperty 的 ON_CHANGE / ON_END_EDIT 会把值写回 PropertySet 并通知 monitor,按钮状态秒级翻转,不用手动刷。 外汇与贵金属 EA 面板属于高风险场景,这类自动锁按钮逻辑只解决交互脏数据,不替代策略风控。

MQL5 / C++
class="kw">template<class="kw">typename C>
class EnableStateMonitorBase: class="kw">public StateMonitor
{
class="kw">protected:
Publisher *sources[];
C *control;
class="kw">public:
EnableStateMonitorBase(): control(NULL) {}
class="kw">virtual class="type">void attach(C *c)
{
control = c;
for(class="type">int i = class="num">0; i < ArraySize(sources); i++)
{
if(control)
{
sources[i].subscribe(&this);
}
else
{
sources[i].unsubscribe(&this);
}
}
}
class="kw">virtual class="type">bool isEnabled(class="type">void) = class="num">0;
};
class EnableStateMonitor: class="kw">public EnableStateMonitorBase<CWnd>
{
class="kw">public:
EnableStateMonitor() {}
class="type">void notify(class="type">void *sender) class="kw">override
{
if(control)
{
if(isEnabled())
{
control.Enable();
}
else
{
control.Disable();
}
}
}
};
class ApplyButton: class="kw">public Notifiable<CButton>
{
class="kw">public:
class="kw">virtual class="type">bool onEvent(class="kw">const class="type">int event, class="type">void *parent) class="kw">override
{
if(event == ON_CLICK)
{
...
}
}
};
class ApplyButtonStateMonitor: class="kw">public EnableStateMonitor
{
class=class="str">"cmt">// what&class="macro">#x27;s required to detect Apply button state
class="kw">const class="type">int NAME;
class="kw">const class="type">int TYPE;
class="kw">public:
ApplyButtonStateMonitor(StdValue<class="type">class="kw">string> *n, StdValue<class="type">int> *t): NAME(class="num">0), TYPE(class="num">1)
{
ArrayResize(sources, class="num">2);
sources[NAME] = n;
sources[TYPE] = t;
}
class="kw">virtual class="type">bool isEnabled(class="type">void) class="kw">override
{
StdValue<class="type">class="kw">string> *name = sources[NAME];
StdValue<class="type">int> *type = sources[TYPE];
class="kw">return StringLen(~name) > class="num">0 && ~type != -class="num">1 && ~name != "Client";
}
};
class InspectorDialog: class="kw">public AppDialogResizable
{
class="kw">private:
PropertySet props;
ApplyButtonStateMonitor *applyMonitor;
class="kw">public:
InspectorDialog::InspectorDialog(class="type">void)
{
...
applyMonitor = new ApplyButtonStateMonitor(&props.name, &props.type);
}
...
_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);

◍ 用模板把控件和数值绑起来

在 MT5 面板里关掉某个开关,只需要把对应按钮的 enable 状态置为 false,再把监控对象挂上去即可。下面这段骨架演示了如何用模板类把任意 StdValue 绑进布局对象,省去每类控件写一遍绑定函数的麻烦。 核心在于 _layout 继承自 StdLayoutBase,并通过 attach 模板方法把外部传入的 StdValue 指针交给内部 object 去 bind。这样同一套布局代码可以复用于按钮、输入框等不同控件,编译期决定类型,运行期零额外开销。 实际验证时,可在 OnInit 里 new 一个 StdValue<bool> 传给 button1.get(),断点跟到 ((T *)object).bind(v) 确认地址写入;外汇与贵金属 EA 涉及界面交互时务必注意终端崩溃风险,误绑空指针可能直接卡死图表。

MQL5 / C++
button1["enable"] <= class="kw">false;
applyMonitor.attach(button1.get());
class="kw">template<class="kw">typename T>
class _layout: class="kw">public StdLayoutBase
{
...
class="kw">template<class="kw">typename V>
class="type">void attach(StdValue<V> *v)
{
((T *)object).bind(v);
}
...
};

「用间接寻址绕过线性搜索」

MQL5 标准库的 StdLayoutCache 在事件触发时,靠 lparam 里的唯一编号去遍历缓存数组做线性搜索。控件一多,这种 O(n) 扫描会拖慢 CAppDialog::Run 里的事件分发,尤其 Inspector 这类塞满属性行的窗口。 实际图表对象数远多于缓存里的“逻辑控件”:一个控件往往由框架、标题、最小化按钮等多个低级对象拼成,所以对象 Id 减去 m_instance_id 后,并不能直接当缓存下标用。 做法是给 MyStdLayoutCache 加一个 buildIndex 方法,分配大小等于窗口对象总数的索引数组,把缓存里真实控件的序号写进去。之后事件携带的 lparam 取负再减 start,就能经 index[] 做一次间接寻址直插目标,跳过遍历。 buildIndex 必须在 CAppDialog::Run 分配完唯一编号、但 OnInit 应答结束前填充。标准库 Run 不是虚函数,要在 InspectorDialog 里重写 Run,调完父类 Run 成功后再 cache.buildIndex()。 事件映射里 ON_EVENT_LAYOUT_CTRL_DLG 把 ON_END_EDIT、ON_CHANGE、ON_CLICK 等绑到 cache 的对应处理方法;ApplyButton 继承 Notifiable,用户改完属性点按钮,onEvent 里取 inspector.getProperties().flatten() 丢给 designer.inject() 并 ChartRedraw()。外汇/贵金属面板开发涉及实时交互,MT5 自定义控件高风险,建议在策略测试器外先用简单对话框验证索引逻辑。

MQL5 / C++
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) class=class="str">"cmt">// class="kw">default (stub)
  EVENT_MAP_END(AppDialogResizable)
  class="type">bool InspectorDialog::Run(class="type">void)
  {
    class="type">bool result = AppDialogResizable::Run();
    if(result)
    {
      cache.buildIndex();
    }
    class="kw">return result;
  }
  class MyStdLayoutCache: class="kw">public StdLayoutCache
  {
    class="kw">protected:
      InspectorDialog *parent;
      class=class="str">"cmt">// fast access
      class="type">int index[];
      class="type">int start;
      
    class="kw">public:
      MyStdLayoutCache(InspectorDialog *owner): parent(owner) {}
      
      class="type">void buildIndex()
      {
        start = parent.GetInstanceId();
        class="type">int stop = class="num">0;
        for(class="type">int i = class="num">0; i < cacheSize(); i++)
        {
          class="type">int id = (class="type">int)get(i).Id();
          if(id > stop) stop = id;
        }
        
        ArrayResize(index, stop - start + class="num">1);
        ArrayInitialize(index, -class="num">1);
        for(class="type">int i = class="num">0; i < cacheSize(); i++)
        {
          CWnd *wnd = get(i);
          index[(class="type">int)(wnd.Id() - start)] = i;
        }
      ...
  };
      class="kw">virtual CWnd *get(class="kw">const class="type">long m) class="kw">override
      {
        if(m < class="num">0 && ArraySize(index) > class="num">0)
        {
          class="type">int offset = (class="type">int)(-m - start);
          if(offset >= class="num">0 && offset < ArraySize(index))
          {
            class="kw">return StdLayoutCache::get(index[offset]);
          }
        }
        
        class="kw">return StdLayoutCache::get(m);
      }
  class ApplyButton: class="kw">public Notifiable<CButton>
  {
    class="kw">public:
      class="kw">virtual class="type">bool onEvent(class="kw">const class="type">int event, class="type">void *parent) class="kw">override
      {
        if(event == ON_CLICK)
        {
          Properties p = inspector.getProperties().flatten();
          designer.inject(p);
          ChartRedraw();
          class="kw">return true;
        }
        class="kw">return class="kw">false;
      };
  };
    PropertySet *getProperties(class="type">void) class="kw">const
    {
      class="kw">return (PropertySet *)&props;
    }

设计器里的新元素注入与点击选中逻辑

Designer 的 inject 方法本质是把目标容器压入布局堆栈,再按传入属性生成 GUI 元素。窗体里用鼠标点选就能切换插入上下文,同一次点击还会把所选元素属性抛给 Inspector,实现已创建对象的编辑。设计器靠元素名称和类型判断用户是想新增还是改旧:DefaultStdLayoutCache 里若已有该组合,就走编辑分支。 变量 cache 在 DesignerForm 中定义为 StdLayoutCache 派生的 DefaultStdLayoutCache 对象。StdLayoutCache.get 按名查对象,查不到就是新对象,此时算法调用 getSelected 检测用户当前选中的容器;我们实现的控件只能放进 CBox 容器,若没选中容器,就由 findParent 回溯父容器当目标。定位插入点后,外部模块建 _layout 对象并内部以字符串生成控件。 模板对象 _layout 用 MQL 静态标记描述,重载运算符 <= 来填属性,比如 CBoxH 的 HORIZONTAL_ALIGN、文本或轮转器的 ENUM_ALIGN_MODE;缩进、文本、颜色等交给 applyProperties。缓存命中时,助手 update 把 props 结构里的宽高、对齐、边距搬到 ptr 上。 窗体里的选中靠缓存对象的 onEvent 处理,StdLayoutCache 用 ON_EVENT_LAYOUT_ARRAY 宏映射图表事件,全部单击转发到派生类 onEvent。可见元素加红色边框(ColorBorder)作高亮:先清掉旧选的边框色 CONTROLS_BUTTON_COLOR_BORDER,再把新点中的指针存进 selected,发 ON_LAYOUT_SELECTION 事件,Inspector 的 OnRemoteSelection 拦截后通过标准 API 读全部属性。 为识别类型,底层 CWnd 带 _rtti 变量,所有派生类填特殊类名。Inspector 用 StdValue<string> 的反向链接 backlink() 写控件,内部改值不触发用户事件,因此还要手动写 PropertySet(props.name)。复合控件如 CCheckGroup 不适合重写虚修改方法,所以采用这种务实做法。

MQL5 / C++
class="type">void inject(Properties &props)
{
CWnd *ptr = cache.get(props.name);
if(ptr != NULL)
{
...
}
else
{
CBox *box = class="kw">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() + ((class="type">long)rand() << class="num">32));
}
}
box.Pack();
cache.select(added);
}
}
AutoPtr<StdLayoutBase> base(getPtr(props));
StdLayoutBase *getPtr(class="kw">const Properties &props)
{
class="kw">switch(props.type)
{
case _BoxH:
{
_layout<CBoxH> *temp = applyProperties(new _layout<CBoxH>(props.name, props.width, props.height), props);
temp <= (HORIZONTAL_ALIGN)props.style;
class="kw">return temp;
}
case _Button:
class="kw">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);
class="kw">return temp;
}
case _SpinEdit:
{
_layout<SpinEditResizable> *temp = applyProperties(new _layout<SpinEditResizable>(props.name, props.width, props.height), props);
temp["min"] <= class="num">0;
temp["max"] <= DUMMY_ITEM_NUMBER;
temp["value"] <= class="num">1 <= class="num">0;
class="kw">return temp;
}
...
}
}
class="kw">template<class="kw">typename T>
T *applyProperties(T *ptr, class="kw">const Properties &props)
{
class="kw">static class="kw">const class="type">class="kw">string sides[class="num">4] = {"left", "top", "right", "bottom"};
for(class="type">int i = class="num">0; i < class="num">4; i++)
{
ptr[sides[i]] <= (class="type">int)props.margins[i];
}
if(StringLen(props.text))
{
ptr <= props.text;
}
else
{
ptr <= props.name;
}
...
class="kw">return ptr;
}
class="type">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());
}
}
...
}
class="type">void update(CWnd *ptr, class="kw">const Properties &props)
{
ptr.Width(props.width);
ptr.Height(props.height);
ptr.Alignment(convert(props.align));
ptr.Margins(props.margins[class="num">0], props.margins[class="num">1], props.margins[class="num">2], props.margins[class="num">3]);
CWndObj *obj = class="kw">dynamic_cast<CWndObj *>(ptr);
if(obj)
{

◍ 用事件映射把选中控件同步进检查器

这段逻辑解决的是 GUI 设计器里「点哪个、右侧就显示哪个」的联动问题。核心在 DefaultStdLayoutCache::onEvent:当控件被点击,先取消旧高亮(CONTROLS_BUTTON_COLOR_BORDER),再沿父链找真实元素,最后用 clrRed 高亮并通过 EventChartCustom 发 ON_LAYOUT_SELECTION 自定义事件。 InspectorDialog::OnRemoteSelection 接到事件后,从 designer.getCache() 取远端选中指针。StringSubstr(ptr.Name(),5) 切掉实例 ID 前缀,把纯名写回属性框;GetTypeByRTTI 按 _rtti 匹配下拉类型,再用 ptr.Width() / ptr.Height() 回填 SpinEdit 的数值。 代码里的 dynamic_cast 分支值得注意:CBoxH 走 HorizontalAlign,CBoxV 走 VerticalAlign,CEdit 走 TextAlign,三者都调 Pack() 或 return,说明布局重排只在复合框上触发。外汇与贵金属 MT5 面板开发属高风险定制,参数错乱可能导致界面卡死,建议先在模拟账户终端验证。 EditProperty::OnSetText 覆盖了文本提交:CEdit::OnSetText() 成功才写 property,避免空串污染。整体看,事件映射 + 缓存指针的方式比全局轮询更省资源,开 MT5 把这段贴进 EA 的 dialog 模块就能跑通选中联动。

MQL5 / C++
obj.Text(props.text);
}
CBoxH *boxh = class="kw">dynamic_cast<CBoxH *>(ptr);
if(boxh)
{
boxh.HorizontalAlign((HORIZONTAL_ALIGN)props.style);
boxh.Pack();
class="kw">return;
}
CBoxV *boxv = class="kw">dynamic_cast<CBoxV *>(ptr);
if(boxv)
{
boxv.VerticalAlign((VERTICAL_ALIGN)props.style);
boxv.Pack();
class="kw">return;
}
CEdit *edit = class="kw">dynamic_cast<CEdit *>(ptr);
if(edit)
{
edit.TextAlign(LayoutConverters::style2textAlign(props.style));
class="kw">return;
}
}
EVENT_MAP_BEGIN(DesignerForm)
ON_EVENT_LAYOUT_ARRAY(ON_CLICK, cache)
...
EVENT_MAP_END(AppDialogResizable)
class DefaultStdLayoutCache: class="kw">public StdLayoutCache
{
class="kw">protected:
CWnd *selected;
class="kw">public:
CWnd *getSelected(class="type">void) class="kw">const
{
class="kw">return selected;
}
...
class="kw">virtual class="type">bool onEvent(class="kw">const class="type">int event, CWnd *control) class="kw">override
{
if(control != NULL)
{
highlight(selected, CONTROLS_BUTTON_COLOR_BORDER);
CWnd *element = control;
if(!find(element)) class=class="str">"cmt">// this is an auxiliary object, not a compound control
{
element = findParent(element); class=class="str">"cmt">// get actual GUI element
}
...
selected = element;
class="kw">const class="type">bool b = highlight(selected, clrRed);
Print(control.Name(), " -> ", element._rtti, " / ", element.Name(), " / ", element.Id());
EventChartCustom(CONTROLS_SELF_MESSAGE, ON_LAYOUT_SELECTION, class="num">0, class="num">0.0, NULL);
class="kw">return true;
}
class="kw">return class="kw">false;
}
};
EVENT_MAP_BEGIN(InspectorDialog)
...
ON_NO_ID_EVENT(ON_LAYOUT_SELECTION, OnRemoteSelection)
EVENT_MAP_END(AppDialogResizable)
class="type">bool InspectorDialog::OnRemoteSelection()
{
DefaultStdLayoutCache *remote = designer.getCache();
CWnd *ptr = remote.getSelected();
if(ptr)
{
class="type">class="kw">string purename = StringSubstr(ptr.Name(), class="num">5); class=class="str">"cmt">// cut instance id prefix
CWndObj *x = class="kw">dynamic_cast<CWndObj *>(props.name.backlink());
if(x) x.Text(purename);
props.name = purename;
class="type">int t = -class="num">1;
ComboBoxResizable *types = class="kw">dynamic_cast<ComboBoxResizable *>(props.type.backlink());
if(types)
{
t = GetTypeByRTTI(ptr._rtti);
types.Select(t);
props.type = t;
}
class=class="str">"cmt">// width and height
SpinEditResizable *w = class="kw">dynamic_cast<SpinEditResizable *>(props.width.backlink());
w.Value(ptr.Width());
props.width = ptr.Width();
SpinEditResizable *h = class="kw">dynamic_cast<SpinEditResizable *>(props.height.backlink());
h.Value(ptr.Height());
props.height = ptr.Height();
...
}
}
class EditProperty: class="kw">public NotifiableProperty<CEdit,class="type">class="kw">string>
{
class="kw">public:
...
class="kw">virtual class="type">bool OnSetText(class="type">void) class="kw">override
{
if(CEdit::OnSetText())
{
if(CheckPointer(class="kw">property) != POINTER_INVALID) class="kw">property = m_text;
class="kw">return true;
}
class="kw">return class="kw">false;
}
};
把排版导出交给小布盯盘辅助
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到界面元素结构树,方便你对照 Designer 导出的标记快速查漏。

常见问题

偏移量只是间接绝对坐标,维护成本高;CBox 用自身对齐设置自动排布子元素,改布局不用手算坐标,倾向更稳。
标准库部分类型原本不支持自适应,本篇 ControlsPlus 分支里重写了尺寸响应逻辑,确保缩放时不错位。
Inspector 填控件属性,Designer 实时渲染外观;两窗口协作产出可粘贴的 MQL 标记代码。
目前小布内置的是结构诊断与对照视图,生成标记仍建议在 Designer 完成,再把片段丢进 EA 里跑。
支持类型、名称、宽高、对齐、标题等基础项,字体等特定属性为简化 POC 暂未纳入,后续可能补。