开发回放系统(第 51 部分):事情变得复杂(三)·进阶篇
📘

开发回放系统(第 51 部分):事情变得复杂(三)·进阶篇

第 2/3 篇

◍ 用矩形标签拼出滑块轨道

在 MT5 里做自定义控件,滑块的底轨通常用 OBJ_RECTANGLE_LABEL 画出来,而不是去碰图表上的价格对象。上面这段 CreteBarSlider 函数就是典型做法:先建一个长条矩形当轨道,再建一个短矩形当滑块块。 轨道的几何参数很直白——XDISTANCE 用传入的 x 加左偏移,YDISTANCE 取滑块基准位减 4 像素,XSIZE 由 size 参数决定,YSIZE 写死 9。颜色用 clrLightSkyBlue 做底、clrBlack 做边,边框宽度 3、类型 BORDER_FLAT,视觉上就是一条细扁的浅蓝槽。 注意第 073 行紧接着又 ObjectCreate 了一个 szBarSliderBlock,说明轨道和滑块块是分开两个对象管理的。你在 EA 里复刻时,若 size 传 200、x 传 0,就能在图表左上区域看到一条 200×9 的浅蓝轨道,外汇和贵金属图表都适用,但这类 overlay 控件密集时可能拖慢老机器渲染,属高风险环境下的性能隐患。

MQL5 / C++
ObjectCreate(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJ_RECTANGLE_LABEL, class="num">0, class="num">0, class="num">0);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_XDISTANCE, def_PosXObjects + x);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_YDISTANCE, m_Slider.posY - class="num">4);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_XSIZE, size);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_YSIZE, class="num">9);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_BGCOLOR, clrLightSkyBlue);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_BORDER_COLOR, clrBlack);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_WIDTH, class="num">3);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_BORDER_TYPE, BORDER_FLAT);
ObjectCreate(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJ_RECTANGLE_LABEL, class="num">0, class="num">0, class="num">0);

滑块控件与播放键的底层摆位

在 MT5 面板里做自定义滑块,核心是用 ObjectSetInteger 逐个钉死坐标与外观属性。下面这段把滑块挡块放在距左边界 def_PosXObjects + x 像素、距顶 m_Slider.posY - 9 的位置,高度锁死 19 像素,底色用 clrRosyBrown,边框类型 BORDER_RAISED 做出凸起感。 播放/暂停按钮则靠 CreateObjectBitMap 画位图,再用 OBJPROP_STATE 切状态;当 m_bWait 为真时贴等待图,否则贴暂停或播放图。这种写法让你在 EA 面板里实时控回放进度,不依赖系统控件。 CreteCtrlSlider 里先给四个子对象拼名字:Bar、Bar Block、BtnL、BtnR,全部挂在 def_NameObjectsSlider 前缀下。外汇与贵金属图表加载这类面板时波动剧烈,对象重绘可能掉帧,建议在模拟盘先验证坐标偏移量。

MQL5 / C++
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_XDISTANCE, def_PosXObjects + x);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_YDISTANCE, m_Slider.posY - class="num">9);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_YSIZE, class="num">19);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_BGCOLOR, clrRosyBrown);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_BORDER_TYPE, BORDER_RAISED);

class="type">void CreateBtnPlayPause(class="type">bool state)
{
   m_szBtnPlay = def_PrefixObjectName + "Play";
   CreateObjectBitMap(class="num">0, class="num">25, m_szBtnPlay, (m_bWait ? def_ButtonWait : def_ButtonPause), (m_bWait ? def_ButtonWait : def_ButtonPlay));
   ObjectSetInteger(GetInfoTerminal().ID, m_szBtnPlay, OBJPROP_STATE, state);
}

class="type">void CreteCtrlSlider(class="type">void)
{
   u_Interprocess Info;
   m_Slider.szBarSlider      = def_NameObjectsSlider + " Bar";
   m_Slider.szBarSliderBlock = def_NameObjectsSlider + " Bar Block";
   m_Slider.szBtnLeft        = def_NameObjectsSlider + " BtnL";
   m_Slider.szBtnRight       = def_NameObjectsSlider + " BtnR";

「滑杆控件的创建与回收细节」

这段初始化逻辑在 MT5 自定义回放面板里搭出一个水平滑杆:滑块锚点固定在 Y=40,轨道与两侧按钮分别落在 X=77~436、左键位 X=47、右键位 X=511 的坐标上,拖拽钮位图尺寸 25×25 且锚点设为居中。 第 103 行先查全局变量 def_GlobalVariableReplay,若存在就取回放进度值,否则归零;第 104~105 行把滑杆下限锁到 Info.s_Infos.iPosShift 并立刻把拖钮摆到该位置,避免首次绘制时钮跑偏。 RemoveCtrlSlider 在删除前先把 CHART_EVENT_OBJECT_DELETE 关掉,ObjectsDeleteAll 清掉 def_NameObjectsSlider 前缀的所有图元,再恢复事件开关——不这样做,批量删对象会触发一堆误删回调。 PositionPinSlider 里那句三元把传入位置夹在 minimal 与 def_MaxPosSlider 之间,越界值直接被削平,实盘里改 def_MaxPosSlider 就能拉长回放窗口。

MQL5 / C++
m_Slider.szBtnPin      = def_NameObjectsSlider + " BtnP";
m_Slider.posY = class="num">40;
CreteBarSlider(class="num">77, class="num">436);
CreateObjectBitMap(class="num">47, class="num">25, m_Slider.szBtnLeft, def_ButtonLeft, def_ButtonLeftBlock);
CreateObjectBitMap(class="num">511, class="num">25, m_Slider.szBtnRight, def_ButtonRight, def_ButtonRightBlock);
CreateObjectBitMap(class="num">0, m_Slider.posY, m_Slider.szBtnPin, def_ButtonPin);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBtnPin, OBJPROP_ANCHOR, ANCHOR_CENTER);
if (GlobalVariableCheck(def_GlobalVariableReplay)) Info.df_Value = GlobalVariableGet(def_GlobalVariableReplay); else Info.df_Value = class="num">0;
m_Slider.Minimal = Info.s_Infos.iPosShift;
PositionPinSlider(Info.s_Infos.iPosShift);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void RemoveCtrlSlider(class="type">void)
   {
   ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, false);
   ObjectsDeleteAll(GetInfoTerminal().ID, def_NameObjectsSlider);
   ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, true);
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void PositionPinSlider(class="type">int p, class="kw">const class="type">int minimal = class="num">0)
   {
   m_Slider.posPinSlider = (p < minimal ? minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));

◍ 滑块位置钳制与按钮状态同步的坑

在自定义滑条控件里,传入参数 p 必须先做边界钳制,否则拖出范围会令界面对象越界。第118行用三元表达式把 p 限制在 m_Slider.Minimal 与 def_MaxPosSlider 之间,写进 m_Slider.posPinSlider,这一步决定后面所有 ObjectSetInteger 的基准坐标。 第119行把图钉按钮的水平距离设为 posPinSlider + def_PosXObjects + 95,这里的 95 是图钉图标相对滑条块的固定偏移量,调 UI 时若发现图钉和滑条脱节,先核这个数。 左右箭头按钮的可用状态靠 OBJPROP_STATE 控制:左键在 posPinSlider 不等于 Minimal 时才亮(120、121行存在重复赋值,后者覆盖前者,实际以 m_Slider.Minimal 为准);右键在 posPinSlider 小于 def_MaxPosSlider 时亮(122行)。 滑条遮罩块宽度在123、124行也被重复设为 minimal+2 与 m_Slider.Minimal+2,同样后者生效。每轮改完调 ChartRedraw 重绘(125行),缺这行拖动会卡帧。 构造函数里把按钮与滑条对象指针先置 NULL(136–139行),是防止 IndicatorCheckPass 失败后仍误访问野指针。外汇贵金属面板高频重绘,这类控件若状态机写错,可能拖累 MT5 主图响应,实盘前务必在策略测试器里拖满边界验证。

MQL5 / C++
m_Slider.posPinSlider = (p < m_Slider.Minimal ? m_Slider.Minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBtnPin, OBJPROP_XDISTANCE, m_Slider.posPinSlider + def_PosXObjects + class="num">95);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBtnLeft, OBJPROP_STATE, m_Slider.posPinSlider != minimal);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBtnLeft, OBJPROP_STATE, m_Slider.posPinSlider != m_Slider.Minimal);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBtnRight, OBJPROP_STATE, m_Slider.posPinSlider < def_MaxPosSlider);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_XSIZE, minimal + class="num">2);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_XSIZE, m_Slider.Minimal + class="num">2);
ChartRedraw(GetInfoTerminal().ID);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">public :
class=class="str">"cmt">//+------------------------------------------------------------------+
C_Controls(class="kw">const class="type">long Arg0, class="kw">const class="type">class="kw">string szShortName, C_Mouse *MousePtr)
   :C_Terminal(Arg0),
    m_bWait(false),
    m_MousePtr(MousePtr)
   {
   if (!IndicatorCheckPass(szShortName)) SetUserError(C_Terminal::ERR_Unknown);
   m_szBtnPlay       = NULL;
   m_Slider.szBarSlider = NULL;
   m_Slider.szBtnPin   = NULL;
   m_Slider.szBtnLeft  = NULL;

控件析构与消息派发的底层接法

回放面板的 C_Controls 类在析构时做了两件关键事:先关掉图表的对象删除事件监听,再按统一前缀清掉所有自建图形对象。这样切图表或移除 EA 时不会留下孤儿按钮,也不会误删用户手动画的线。 具体看析构函数,ChartSetInteger 把 CHART_EVENT_OBJECT_DELETE 设回 false,ObjectsDeleteAll 只删 def_PrefixObjectName 前缀的物件,第 146 行这个前缀隔离是避免误伤的核心。 Init 方法根据 state 决定建播放键还是建滑条:state 为真只建按钮并注册临时全局变量 def_GlobalVariableReplay,为假才 CreteCtrlSlider(注意原文拼写缺了 a),最后 ChartRedraw 强制重绘一次。 DispatchMessage 用 switch 接 CHARTEVENT_CUSTOM+ev_WaitOn,第 163–164 行对 lparam==0 直接 break,说明子进程没传有效句柄时主线程不往下走,避免空指针刷图表。 开 MT5 把这段粘进类里,故意把 def_PrefixObjectName 改成你自己 EA 的缩写,切周期看物件是否残留,就能验证前缀隔离是否生效。外汇与贵金属图表物件交互属高风险操作,误删可能打断你的手动止损线。

MQL5 / C++
m_Slider.szBtnRight  = NULL;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
   ~C_Controls()
     {
       ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, false);
       ObjectsDeleteAll(GetInfoTerminal().ID, def_PrefixObjectName);
     }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void Init(class="kw">const class="type">bool state)
     {
       CreateBtnPlayPause(state);
       GlobalVariableTemp(def_GlobalVariableReplay);
       if (!state) CreteCtrlSlider();
       ChartRedraw(GetInfoTerminal().ID);
     }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void DispatchMessage(class="kw">const class="type">int id, class="kw">const class="type">long &lparam, class="kw">const class="type">class="kw">double &dparam, class="kw">const class="type">class="kw">string &sparam)
     {
       u_Interprocess Info;

       class="kw">switch (id)
       {
         case (CHARTEVENT_CUSTOM + C_Controls::ev_WaitOn):
           if (lparam == class="num">0) class="kw">break;

「自定义事件里的暂停与滑块回收」

在图表事件分支里,自定义事件 CHARTEVENT_CUSTOM 加上控件偏移量 ev_WaitOn / ev_WaitOff 用来切换回放暂停状态。收到 WaitOn 时把 m_bWait 置 true 并刷新播放按钮为暂停态,WaitOff 则要求 lparam 非 0 才生效,同时把 dparam 写回 Info.df_Value 作为当前帧值。 对象删除事件要防误删:只用前缀匹配 def_PrefixObjectName 的才归本控件管,进一步判断是否是 def_NameObjectsSlider 前缀的滑块对象。命中后先 RemoveCtrlSlider() 解绑旧滑块,紧接 CreteCtrlSlider() 重建一套,保证拖拽控件被手动删掉也能自愈。 这段逻辑在 MT5 上可直接粘进 OnChartEvent 的 switch 里验证:手动删掉滑块图形,控件会在下一帧重新画出来,回放不会卡死。外汇与贵金属图表加载此类自绘控件时波动剧烈,重建过程可能带来 1~2 帧闪烁,属正常概率现象。

MQL5 / C++
m_bWait = true;
CreateBtnPlayPause(true);
class="kw">break;
case (CHARTEVENT_CUSTOM + C_Controls::ev_WaitOff):
   if (lparam == class="num">0) class="kw">break;
   m_bWait = false;
   Info.df_Value = dparam;
   CreateBtnPlayPause(Info.s_Infos.isPlay);
   class="kw">break;
case CHARTEVENT_OBJECT_DELETE:
   if (StringSubstr(sparam, class="num">0, StringLen(def_PrefixObjectName)) == def_PrefixObjectName)
   {
      if (StringSubstr(sparam, class="num">0, StringLen(def_NameObjectsSlider)) == def_NameObjectsSlider)
      {
         RemoveCtrlSlider();
         CreteCtrlSlider();

常见问题

矩形标签按像素整数坐标绘制,相邻标签宽度非整数时会留 1px 间隙。把轨道拆成整块背景+上层覆盖滑块,别用多段拼。
控件 Z 轴顺序错了,滑块盖住了按钮热区。先创建轨道背景,再建滑块,最后建按钮并置顶,保证消息命中按钮。
可以。把界面结构描述发给小布,它能指出 Z 序冲突、热区被遮及回收遗漏点,省去手动排查。
不正常,是位置钳制后没发状态同步消息。滑块越界钳回时手动触发按钮重绘与可用态刷新即可。
析构时只删了对象没派发销毁消息。要在消息派发层显式调用控件析构并通知父容器移除引用。