开发回放系统(第 61 部分):玩转服务(二)·进阶篇
🛠️

开发回放系统(第 61 部分):玩转服务(二)·进阶篇

(2/3)· 服务跑起来却莫名变慢?本篇拆解图形对象误用如何拖垮回放系统

偏理论 第 2/3 篇
很多人以为图表上的图形对象只能由指标、脚本或 EA 直接操控,于是让服务强依赖图表显示。回放系统一旦这么写,性能会莫名其妙地掉下来。先理清对象访问机制,比急着改代码更省时间。

控件销毁与滑块定位的底层写法

面板退出或重建时,先关掉图表的对象删除事件回调,防止循环触发。代码里用 ChartSetInteger 把 CHART_EVENT_OBJECT_DELETE 设为 false,再逐个 delete 按钮指针并把数组槽位置 NULL,最后 ObjectsDeleteAll 按前缀清场,完事再把事件开关打开。 PositionPinSlider 负责把滑块位置夹在 Minimal 到 def_MaxPosSlider 之间,越界就吸边。注意第 110 行硬加了 95 - (def_SizeButtons/2) 的偏移量,按钮中心对齐逻辑全靠这组魔法数,改 def_SizeButtons 时这里必须同步调,否则控件会偏出可视区。 CheckPositionMouseClick 把鼠标图形坐标直接写回引用参数 x、y,遍历 ePlay 到 eNull 的控件枚举做命中判断。外汇与贵金属图表上挂这类自绘 UI,高频重绘可能引发卡顿,实盘前应在 MT5 策略测试器用真实tick跑一遍确认资源释放干净。

MQL5 / C++
ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, class="kw">false);
for (eObjectControl c0 = ePlay + class="num">1; c0 < eNull; c0++)
{
   class="kw">delete m_Section[c0].Btn;
   m_Section[c0].Btn = NULL;
}
ObjectsDeleteAll(GetInfoTerminal().ID, def_ObjectCtrlName("B"));
ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, true);

class="kw">inline class="type">void PositionPinSlider(class="type">class="kw">ushort p)
{
   class="type">int iL, iR;
   m_Section[ePin].x = (class="type">short)(p < m_Slider.Minimal ? m_Slider.Minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));
   iL = (m_Section[ePin].x != m_Slider.Minimal ? class="num">0 : class="num">1);
   iR = (m_Section[ePin].x < def_MaxPosSlider ? class="num">0 : class="num">1);
   m_Section[ePin].x += def_PosXObjects;
   m_Section[ePin].x += class="num">95 - (def_SizeButtons / class="num">2);
   for (eObjectControl c0 = ePlay + class="num">1; c0 < eNull; c0++)
      m_Section[c0].Btn.Paint(m_Section[c0].x, m_Section[c0].y, m_Section[c0].w, m_Section[c0].h, class="num">20, (c0 == eLeft ? iL : (c0 == eRight ? iR : class="num">0)));
   ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_XSIZE, m_Slider.Minimal + class="num">2);
}

class="kw">inline eObjectControl CheckPositionMouseClick(class="type">short &x, class="type">short &y)
{
   C_Mouse::st_Mouse InfoMouse;
   InfoMouse = (*m_MousePtr).GetInfoMouse();
   x = (class="type">short) InfoMouse.Position.X_Graphics;
   y = (class="type">short) InfoMouse.Position.Y_Graphics;
   for (eObjectControl c0 = ePlay; c0 < eNull; c0++)
   {

◍ 控件命中检测与构造期的坐标排布

这段逻辑先解决一个实操问题:鼠标点下去,怎么知道命中了哪个按钮。125 到 126 行用四个边界条件(x/y 下限、x+w/y+h 上限)判断坐标是否落在 m_Section[c0] 的矩形内,命中就直接返回该控件索引 c0;若遍历完都没中,129 行返回 eNull,调用方据此走“点空白”分支。

  • 到 153 行是 C_Controls 的构造函数,进场先卡两道校验:指标名不通过或鼠标指针无效就 SetUserError,139 行发现 _LastError 非 ERR_SUCCESS 直接 return,避免后续在脏状态下建对象。

建按钮前有个细节容易踩坑:140 行先关掉 CHART_EVENT_OBJECT_DELETE,141 行用 ObjectsDeleteAll 清掉旧控件对象,142 行再重新打开该事件——防止清对象时触发删除回调造成递归。 按钮布局是硬编码的:145 到 147 行把每个控件的宽高设为 def_SizeButtons、y 统一为 25、Btn 指针置 NULL;149 到 151 行给出 x 坐标,ePlay 在 def_PosXObjects,eLeft 偏移 +47,eRight 偏移 +511。想加新按钮,改这三个偏移量比改循环体更稳。

  • 到 159 行的析构里,157 行逐个 delete 按钮对象、158 行再清一次图表对象、159 行删鼠标指针——外汇/贵金属 EA 面板若频繁重加载,漏掉其中任一步都可能留下孤儿对象拖慢 MT5 终端。
MQL5 / C++
if ((m_Section[c0].Btn != NULL) && (m_Section[c0].x <= x) && (m_Section[c0].y <= y) && ((m_Section[c0].x + m_Section[c0].w) >= x) && ((m_Section[c0].y + m_Section[c0].h) >= y))
   class="kw">return c0;
   }

   class="kw">return eNull;
}
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_MousePtr(MousePtr)
   {
      if ((!IndicatorCheckPass(szShortName)) || (CheckPointer(m_MousePtr) == POINTER_INVALID)) SetUserError(C_Terminal::ERR_Unknown);
      if (_LastError != ERR_SUCCESS) class="kw">return;
      ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, class="kw">false);
      ObjectsDeleteAll(GetInfoTerminal().ID, def_ObjectCtrlName(""));
      ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, true);
      for (eObjectControl c0 = ePlay; c0 < eNull; c0++)
      {
         m_Section[c0].h = m_Section[c0].w = def_SizeButtons;
         m_Section[c0].y = class="num">25;
         m_Section[c0].Btn = NULL;
      }
      m_Section[ePlay].x = def_PosXObjects;
      m_Section[eLeft].x = m_Section[ePlay].x + class="num">47;
      m_Section[eRight].x = m_Section[ePlay].x + class="num">511;
      m_Slider.Minimal = eTriState;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
   ~C_Controls()
   {
      for (eObjectControl c0 = ePlay; c0 < eNull; c0++) class="kw">delete m_Section[c0].Btn;
      ObjectsDeleteAll(GetInfoTerminal().ID, def_ObjectCtrlName(""));
      class="kw">delete m_MousePtr;

「回放控件的缓冲区与消息派发」

这段逻辑负责把滑块控件的状态写进指标缓冲区,并响应图表自定义事件。SetBuffer 函数在每根 K 线收尾时把当前帧的控制字写入 Buff[rates_total-1],也就是说你能在数据窗口最后一列看到实时回放位置。 代码里用了一个 uCast_Double 联合体做双精度与 16 位短整数数组的 reinterpret,_16b[eCtrlPosition] 存滑块最小位置,_16b[eCtrlStatus] 存播放或暂停标记。若 m_Slider.Minimal 超过 def_MaxPosSlider,状态位会强制夹在最大值内,避免越界写坏图形。 DispatchMessage 监听 CHARTEVENT_CUSTOM+evCtrlReplayInit,先校验 info._8b[7]=='D' 且 info._8b[6]=='M' 才认作合法回放指令,否则直接 break。通过这层校验后,iPinPosX 被更新为约束后的滑块位置,并调用 SetPlay 切换播放态。 想验证的话,在 MT5 里给图表发一笔自定义事件,dparam 低字节不是 'D''M' 时控件不会动;把 def_MaxPosSlider 改小到 100,滑块拖到 200 也会卡在 100,外汇与贵金属行情跳空频繁,这类回放边界条件最好先在模拟盘跑通。

MQL5 / C++
class="type">void SetBuffer(class="kw">const class="type">int rates_total, class="type">class="kw">double &Buff[])
{
 uCast_Double info;

 info._16b[eCtrlPosition] = m_Slider.Minimal;
 info._16b[eCtrlStatus] = (class="type">class="kw">ushort)(m_Slider.Minimal > def_MaxPosSlider ? m_Slider.Minimal : (m_Section[ePlay].state ? ePlay : ePause));class=class="str">"cmt">//SHORT_MAX : SHORT_MIN);
 if (rates_total > class="num">0)
   Buff[rates_total - class="num">1] = info.dValue;
}
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)
 {
 class="type">short x, y;
 class="kw">static class="type">class="kw">ushort iPinPosX = class="num">0;
 class="kw">static class="type">short six = -class="num">1, sps;
 uCast_Double info;

 class="kw">switch (id)
  {
   case (CHARTEVENT_CUSTOM + evCtrlReplayInit):
     info.dValue = dparam;
     if ((info._8b[class="num">7] != &class="macro">#x27;D&class="macro">#x27;) || (info._8b[class="num">6] != &class="macro">#x27;M&class="macro">#x27;)) class="kw">break;
     x = (class="type">short) info._16b[eCtrlPosition];
     iPinPosX = m_Slider.Minimal = (info._16b[eCtrlPosition] > def_MaxPosSlider ? def_MaxPosSlider : (info._16b[eCtrlPosition] < iPinPosX ? iPinPosX : info._16b[eCtrlPosition]));
     SetPlay((eObjectControl)(info._16b[eCtrlStatus]) == ePlay);
     class="kw">break;
   case CHARTEVENT_OBJECT_DELETE:
     if (StringSubstr(sparam, class="num">0, StringLen(def_ObjectCtrlName(""))) == def_ObjectCtrlName(""))
      {

播放键与滑杆的交互拆解

这段事件处理代码管两件事:对象被删除时怎么清场,以及鼠标左键点下去后播放键和滑杆怎么联动。外汇与贵金属图表上挂这类自定义控件,先想清楚内存和状态不同步带来的闪退风险。 当传入参数 sparam 等于播放控件名时,先 delete 掉 m_Section[ePlay].Btn 指针并把其置为 NULL,再调用 SetPlay 按当前 state 重画;否则走 RemoveCtrlSlider 加 CreateCtrlSlider 重建滑杆。191–200 行就是这套分支,注意 delete 后不置 NULL 在 MT5 后续访问中可能触发无效指针异常。 鼠标移动事件里,若检测到左键按下,按坐标命中区域分流:点中 ePlay 就翻转 state,播放态下移除滑杆并把 m_Slider.Minimal 锁到 iPinPosX,非播放态重建滑杆(204–212 行)。点 eLeft / eRight 则把 iPinPosX 减 1 或加 1,并夹在 Minimal 与 def_MaxPosSlider 边界内(214–218 行)。 开 MT5 把这段塞进你的面板类,故意不写 194 行置空那句,跑几次拖拽滑杆大概率能复现崩溃,验证指针清理的必要性。

MQL5 / C++
if (sparam == def_ObjectCtrlName(ePlay))
            {
             class="kw">delete m_Section[ePlay].Btn;
             m_Section[ePlay].Btn = NULL;
             SetPlay(m_Section[ePlay].state);
            }else
            {
             RemoveCtrlSlider();
             CreateCtrlSlider();
            }
           }
           class="kw">break;
         case CHARTEVENT_MOUSE_MOVE:
            if ((*m_MousePtr).CheckClick(C_Mouse::eClickLeft))  class="kw">switch (CheckPositionMouseClick(x, y))
            {
            case ePlay:
               SetPlay(!m_Section[ePlay].state);
               if (m_Section[ePlay].state)
               {
                RemoveCtrlSlider();
                m_Slider.Minimal = iPinPosX;
               }else CreateCtrlSlider();
               class="kw">break;
            case eLeft:
               PositionPinSlider(iPinPosX = (iPinPosX > m_Slider.Minimal ? iPinPosX - class="num">1 : m_Slider.Minimal));
               class="kw">break;
            case eRight:
               PositionPinSlider(iPinPosX = (iPinPosX < def_MaxPosSlider ? iPinPosX + class="num">1 : def_MaxPosSlider));

◍ 拖拽滑块时如何锁住图表滚动

这段收尾逻辑处理的是图表上『图钉滑块』的鼠标拖拽:按下图钉后先在 ePin 分支里记录起始坐标 six 与 sps,同时把 CHART_MOUSE_SCROLL 设为 false,等于临时冻住图表的鼠标滚轮平移,避免拖控件时画面乱跑。 代码第 227–228 行按位移重算 iPinPosX,并用三元裁剪把它限制在 m_Slider.Minimal 到 def_MaxPosSlider 之间,再送回 PositionPinSlider 刷新位置;越界部分直接被丢掉,滑块不会跑出轨道。 松开判定落在 six>0 的 else 分支:把 six 复位成 -1,并重新打开 CHART_MOUSE_SCROLL(true),图表滚动恢复。最后 ChartRedraw 强制重绘一次,确保 MT5 主图立刻反映新位置。 想验证就开 MT5 把这段嵌进你的面板类,按住图钉拖一下,若发现滚轮还能平移说明 six 初始值没进 ePin 分支;外汇与贵金属图表多周期切换频繁,这类锁定逻辑能明显降低误触风险。

MQL5 / C++
class="num">219.                class="kw">break;
class="num">220.              case ePin:
class="num">221.                if (six == -class="num">1)
class="num">222.                {
class="num">223.                  six = x;
class="num">224.                  sps = (class="type">short)iPinPosX;
class="num">225.                  ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, class="kw">false);
class="num">226.                }
class="num">227.                iPinPosX = sps + x - six;
class="num">228.                PositionPinSlider(iPinPosX = (iPinPosX < m_Slider.Minimal ? m_Slider.Minimal : (iPinPosX > def_MaxPosSlider ? def_MaxPosSlider : iPinPosX)));
class="num">229.                class="kw">break;
class="num">230.              }else if (six > class="num">0)
class="num">231.              {
class="num">232.                six = -class="num">1;
class="num">233.                ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, true);
class="num">234.              }
class="num">235.              class="kw">break;
class="num">236.          }
class="num">237.          ChartRedraw(GetInfoTerminal().ID);
class="num">238.        }
class="num">239. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">240. };
class="num">241. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">242. class="macro">#undef def_PosXObjects
class="num">243. class="macro">#undef def_ButtonPlay
class="num">244. class="macro">#undef def_ButtonPause
class="num">245. class="macro">#undef def_ButtonLeft
class="num">246. class="macro">#undef def_ButtonRight
class="num">247. class="macro">#undef def_ButtonPin
class="num">248. class="macro">#undef def_PathBMP
class="num">249. class=class="str">"cmt">//+------------------------------------------------------------------+
把对象依赖诊断交给小布
小布盯盘的 AIGC 已内置常见 MQL5 图形对象误用模式识别,打开对应品种页即可看到服务与图表耦合的预警,你专注决策而非翻代码。

常见问题

手动添加控制模块会破坏服务与图表间的既定通信结构,导致后续改进或新功能无法实现,必须依赖服务自身创建图形对象。
不是。图形对象可在执行环境与期望环境间跨程序操作,但引入这种依赖容易引发不可预期问题,本篇重点讲清边界。
文章演示了用 MetaEditor 自带的剖析与调试视图观察对象创建频率,偏理论层面解释调用开销从哪来。
可以。小布盯盘内置的 AIGC 看板会标记服务对图表对象的异常依赖,省去你手动逐行比对的时间。
保持模块间低耦合让服务不绑定图表显示,性能下降时你能精准改局部而非重构,这也是本系列一贯的约束。