![MVC 设计范式及其应用(第 2 部分):三个组件之间相互作用示意图](https://c.mql5.com/i/articles/overlay.png)
MVC 设计范式及其应用(第 2 部分):三个组件之间相互作用示意图
1. 概述
我来简单地提醒您上一篇文章的内容。 根据 MVC 范式,代码分为三个组件:模型、视图和控制器。 每个组件都可由单独的程序员、或单独的团队开发:他们可创建、支持和更新组件。 甚而,如果脚本代码由功能清晰的组件组成,那么它总是更容易理解。
我们来看看每个组件。
- 视图。 视图负责信息的直观表示。 它从模型接收数据,且不会干扰其操作。 这可以是任何可视的东西:图表、表格、图像。
- 模型。 模型处理数据。 它接收数据,根据一些内部规则进行处理,并向视图提供操作结果。 然而,模型对视图一无所知,只为其提供自身的操作结果。 模型接收来自控制器的源数据,但对其内部也是一无所知。
- 控制器。 它的主要作用是接收来自用户的数据,并与模型交互。 控制器对模型的内部结构一无所知,因为它只是向模型传递源数据。
在这篇文章中,我们将研究这三个组件之间可能的相互作用的示意图。 第一篇文章没有涉及这一方面,结果一位读者在评论区中提到了这一点。 如果交互机制没有得到充分研究或不准确,那么使用该模式的所有优势就可能被削弱。 这就是为什么这个话题应该特别关注的原因。
我们需要一个实验对象。 我们将使用一个标准指标,比如 WPR。 应为新指标创建单独的文件夹。 这个文件夹应该有三个子文件夹:View,Controller 和 Model。 由于选定的指标非常简单,我们将添加更多额外的功能,仅仅是为了展现本文的不同观点。 该指标并无实际价值,不可用于真实交易。
2. 控制器的详情
我们将从控制器开始,因为它负责与用户交互。 因此,控制器可以使用输入参数进行操作,用户通过输入参数与指标或智能交易系统进行交互。
2.1. 源数据模块
//--- input parameters input int InpWPRPeriod = 14; // Period input int dist = 20; // Distance
We have only two parameters which require much work though. 必须确保参数不包含无效值。 如果这样做了,就需要采取进一步行动。 例如,两个参数都不能小于零。 假设第一个参数被错误地设置为 -2。 可能的操作之一是通过将无效数据设置为默认值(等于 14)来修复该数据。 在任何情况下,第二个输入参数都应进行转换。 在这一步,可能是这样的:
//--- input parameters input int InpWPRPeriod = 14; // Period input int dist = 20; // Distance int iRealPeriod; double dRealDist; //+------------------------------------------------------------------+ //| Custom indicator initialization function | //+------------------------------------------------------------------+ int OnInit() { if(InpWPRPeriod < 3) { iRealPeriod = 14; Print("Incorrect InpWPRPeriod value. Indicator will use value=", iRealPeriod); } else iRealPeriod = InpWPRPeriod; int tmp = dist; if (dist <= 0) { Print("Incorrect Distance value. Indicator will use value=", dist); tmp = 14; } dRealDist = tmp * _Point; ..... return INIT_SUCCEEDED; }
我们的代码相当长,且在全局范围内有两个变量。 如果有更多参数,OnInit 处理程序就会变得一团糟。 甚而,除了输入参数的验证和转换,处理程序还要执行其它任务。 出于此原因,我们为控制器创建一个新模块,它将处理所有源数据,包括输入参数。
在 Controller 文件夹中,创建 Input.mqh 文件,并将来自 WPR.mq5 的所有输入移到其内。 在同一个文件中,我们编写 CInputParam 类来处理可用的现有参数:
class CInputParam { public: CInputParam() {} ~CInputParam() {} const int GetPeriod() const {return iWprPeriod;} const double GetDistance() const {return dDistance; } protected: int iWprPeriod; double dDistance; };
类结构理应很清晰了。 两个输入参数都保存在受保护的字段中,有两种方法可以访问它们。 从现在起,所有组件,包括视图、控制器和模型,将只与在控制器中创建的这个类对象配合工作。 因此,这些组件不能与常规输入一起工作。 视图和模型将用该对象的 GetXXX 方法访问该对象和输入参数。 参数 InpWPRPeriod 将通过 GetPeriod() 访问,以及调用 GetDistance() 方法访问 “dist”。
请注意,ddInstance 字段的类型为 double,已为操作准备就绪。 现在,这两个参数都经过了检查,确定无疑是正确的。 不过,并未在类内执行任何检查。 所有检查都在另一个类 CInputManager 中执行,我们将在同一个文件中编写该类。 这个类很简单,如下所示:
class CInputManager: public CInputParam { public: CInputManager(int minperiod, int defperiod): iMinPeriod(minperiod), iDefPeriod(defperiod) {} CInputManager() { iMinPeriod = 3; iDefPeriod = 14; } ~CInputManager() {} int Initialize(); protected: private: int iMinPeriod; int iDefPeriod; };
该方法拥有 Initialize() 方法,其实现所需的检查,并在必要时转换输入。 如果初始化失败,该方法将返回非 INIT_SUCCEEDED 的值:
int CInputManager::Initialize() { int iResult = INIT_SUCCEEDED; if(InpWPRPeriod < iMinPeriod) { iWprPeriod = iDefPeriod; Print("Incorrect InpWPRPeriod value. Indicator will use value=", iWprPeriod); } else iWprPeriod = InpWPRPeriod; if (dist <= 0) { Print("Incorrect Distance value. Indicator will use value=", dist); iResult = INIT_PARAMETERS_INCORRECT; } else dDistance = dist * _Point; return iResult;
现在,您还记得我们多久需要调用一次 SymbolInfoХХХХ(...) 类型和类似的函数? 当我们需要获取品种参数、打开窗口数据、等等时,我们都会这样做。 这是经常做的。 这些函数调用在整个文本中实现,并且可以重复。 但它们也是源数据,类似于输入数据。
假设我们需要得到 SYMBOL_BACKGROUND_COLOR 的值,然后在视图中使用它。 我们在 CInputParam 类中创建一个受保护的字段:
class CInputParam { ... const color GetBckColor() const {return clrBck; } protected: ... color clrBck; };
另外,我们还需编辑 CInputManager:
class CInputManager: public CInputParam { public: ... int Initialize(); protected: int VerifyParam(); bool GetData(); };
这个操作将切分为两个新方法:
int CInputManager::Initialize() { int iResult = VerifyParam(); if (iResult == INIT_SUCCEEDED) GetData(); return iResult; } bool CInputManager::GetData() { long tmp; bool res = SymbolInfoInteger(_Symbol, SYMBOL_BACKGROUND_COLOR, tmp); if (res) clrBck = (color)tmp; return res; } int CInputManager::VerifyParam() { int iResult = INIT_SUCCEEDED; if(InpWPRPeriod < iMinPeriod) { iWprPeriod = iDefPeriod; Print("Incorrect InpWPRPeriod value. Indicator will use value=", iWprPeriod); } else iWprPeriod = InpWPRPeriod; if (dist <= 0) { Print("Incorrect Distance value. Indicator will use value=", dist); iResult = INIT_PARAMETERS_INCORRECT; dDistance = 0; } else dDistance = dist * _Point; return iResult; }
将其切分为两种方法提供了另一种有用的可能性:能够在必要时更新某些参数。 我们添加一个公开的 Update() 方法:
class CInputManager: public CInputParam { public: ... bool Update() {return GetData(); } ... };
将用户指定的输入参数与从终端接收到的参数组合在一个类(CInputParam)中很难被视为完美的解决方案。 因为这不符合原则。 这种不一致性构成了不同程度的代码易变性。 开发人员可以频繁且轻易地更改输入:更改单独参数的名称、类型、删除参数或添加新参数。 这种操作方式就是为何应在单独的模块中实现输入参数的原因之一。 这种状况与通过函数调用 SymbolInfoХХХХ() 得到的数据不同:开发人员不太愿意在这里进行任何更改。 下一个原因是来源不同。 在第一种情况下,它是用户,而在第二种情况下,它是终端。
纠正这些标记并不困难。 为此,我们可以将所有源数据拆分到两个子模块。 其中一个将处理输入参数,另一个将处理终端数据。 如果我们需要第三个呢? 例如,使用包含 XML 或 JSON 的配置文件? 编写并添加另一个子模块。 然后,在 CInputParam 类中创建合成,同时保持 CInputManager 类原样。 当然,这会令整个代码复杂化。 因此,我们不会这样实施,因为我们的测试指标非常简单。 但对于更复杂的脚本,这种方法就很合理。
有一个时刻需要特别注意。 为什么我们需要第二个类 CInputManager? 来自该类的所有方法都可以轻松地移动到 CInputParam 基类。 然而,这种解决方案是有原因的。 您不应该允许所有组件从 CInputManager 类调用 Initialize()、Update() 和类似的方法。 这就是为什么要在控制器中创建 CInputManager 类型的对象,而其它组件将访问其 CInputParam 基类的原因。 这样做可以防止重复初始化,或意外的从其它组件调用 Update(...)。
2.2. CController 类
在控制器文件夹中创建 Controller.mqh 文件。 将该文件与源数据模块连接,并在此文件中创建 CController 类。 在类中添加私密字段:
CInputManager pInput;
现在,我们需要初始化这个模块,提供更新其内数据的可能性,并调用其它方法,尽管有些尚未实现。 至少我们需要 Release() 方法,它可以清理和释放源数据占用的一些资源。 我们现在还不需要,但以后肯定需要。
那么,我们将 Initialize() 和 Update() 更新方法添加到类中。 现在看上去如下:
class CController { public: CController(); ~CController(); int Initialize(); bool Update(); protected: private: CInputManager* pInput; }; ... int CController::Initialize() { int iResult = pInput.Initialize(); if (iResult != INIT_SUCCEEDED) return iResult; return INIT_SUCCEEDED; } bool CController::Update() { bool bResult = pInput.Update(); return bResult; }
我们在 Controller 类的 Initialize() 方法中使用源数据初始化模块。 如果结果不令人满意,则中断初始化。 显然,如果源数据出错,则无法执行进一步的操作。
更新源数据时也可能出现错误。 在这种情况下,Update() 将返回 false。
控制器的下一个任务是为其它组件提供访问其源数据模块的权限。 如果控制器拥有其它组件,即包括模型和视图,则可以轻松解决此任务:
class CController { public: ... private: CInputManager* pInput; CModel* pModel; CView* pView; } ... CController::CController() { pInput = new CInputManager(); pModel = new CModel(); pView = new CView(); }
控制器还将负责初始化、更新和维护所有组件的生存周期,如果我们将 Initialize) 和 Update() 方法(以及任何其它必要的方法)添加到模型和视图组件当中,控制器就可以轻松地做到这一点。
所以,WPR.mq5 指标的主文件将如下所示:
... CController* pController; int OnInit() { pController = new CController(); return pController.Initialize(); } ... void OnDeinit(const int reason) { if (CheckPointer(pController) != POINTER_INVALID) delete pController; }
OnInit() 处理程序创建控制器,并调用其 Initialize() 方法。 接下来,控制器调用相关的模型和视图方法。 例如,对于 OnCalculate(...) 指标处理程序,创建 Tick(...) 方法,并在 OnCalculate(...) 中调用其主指标文件的处理程序:
int OnCalculate(const int rates_total, const int prev_calculated, const datetime &time[], const double &open[], const double &high[], const double &low[], const double &close[], const long &tick_volume[], const long &volume[], const int &spread[]) { return pController.Tick(rates_total, prev_calculated, time, open, high, low, close, tick_volume, volume, spread); }
稍后我们将回到控制器的 Tick(...) 方法。 现在,请注意:
- 针对每个指标的事件处理程序,我们都可以在控制器中创建一个相关的方法:
int CController::Initialize() { if (CheckPointer(pInput) == POINTER_INVALID || CheckPointer(pModel) == POINTER_INVALID || CheckPointer(pView) == POINTER_INVALID) return INIT_FAILED; int iResult = pInput.Initialize(); if (iResult != INIT_SUCCEEDED) return iResult; iResult = pView.Initialize(GetPointer(pInput) ); if (iResult != INIT_SUCCEEDED) return iResult; iResult = pModel.Initialize(GetPointer(pInput), GetPointer(pView) ); if (iResult != INIT_SUCCEEDED) return iResult; return INIT_SUCCEEDED; } ... bool CController::Update() { bool bResult = pInput.Update(); return bResult; } ...
- WPR.mq5 的主文件能证明其非常小且简单。
3. 模型
现在,我们转到指标的主要部分,模型。 模型是做出决策的组件。 控制器给出模型数据进行计算,模型接收结果。 这包括源数据。 我们刚刚创建了一个处理这些数据的模块。 此外,这包括在 OnCalculate(...) 中接收的数据,并传递给控制器。 还可以有来自其它处理程序的数据,例如 OnTick()、OnChartEvent() 和其它处理程序(在我们的简单指标中不需要它们)。
在现有模型文件夹中,创建 Model.mqh 文件,其内包含 CModel 类和 Controller 类中的 CModel 类型的私密字段。 现在,我们应该令模型能够访问源数据。 这可以通过两种方式实现。 一种是在模型中复制所需的数据,并调用 SetXXX(...) 方法初始化数据:
#include "..\Controller\Input.mqh" class CModel { public: ... void SetPeriod(int value) {iWprPeriod = value;} ... private: int iWprPeriod; ... };
如果有很多输入数据,就会有很多 SetXXX() 函数,这不是一个优秀的解决方案。
另一个是从控制器向模型传递一个指向 CInputParam 类对象的指针:
#include "..\Controller\Input.mqh" class CModel { public: int Initialize(CInputParam* pI){ pInput = pI; return INIT_SUCCEEDED; } private: CInputParam* pInput; };
现在,模型可以利用一组 GetXXX() 函数接收源数据:
pInput.GetPeriod();
但这种方法也不是很好。 这个模型的目的是什么? 它应该做出决策。 主要计算都在这里进行。 它生成最终结果。 它应该是业务逻辑的汇集处,应该保持几乎不变。 例如,如果开发者基于两条移动平均线的交叉创建智能交易系统,模型将判断这种交叉的事实,并决定 EA 是否应该入场。 开发者可以更改输入集合、输出方法、添加/删除尾随停止、等等。 但这并会不影响模型。 两条移动平均线仍将相交。 不过,模型类的文件中有以下行:
#include "..\Controller\Input.mqh"
设置控制器模块中模型与源数据的相关性! 控制器通知模型:“我有这个源数据。 拿走吧。 如果我改变了什么,您必须考虑到这一点,改变自己”。 因此,最重要的、核心的、很少改变的元素取决于一个可以轻松频繁改变的模块。 但事实恰恰相反。 模型应指导控制器:“您执行初始化。 我需要操作数据。 把所需的数据给我”。
为了实现这个条件,我们需要从 CModel 类文件里删除包含 Input.mqh 的行(以及类似的行)。 然后,我们需要定义模型希望如何接收源数据。 为了实现此任务,在 Model 文件夹中创建一个名为 InputBase.mqh 的文件。 在此文件中,创建以下接口:
interface IInputBase { const int GetPeriod() const; };
将以下代码添加到模型类:
class CModel { public: ... int Initialize(IInputBase* pI){ pInput = pI; return INIT_SUCCEEDED; } ... private: IInputBase* pInput; };
在 CInputParam 类里进行以下修改。 它将实现新编写的接口:
class CInputParam: public IInputBase
同样,我们可以删除 CInputManage 类,并将其功能移动到 CInputParam。 但我们不会这样做,从而避免了不受控制地调用 Initialize() 和 Update()。 对于那些我们希望避免因 InputBase.mqh 定义的接口而产生依赖模块,因此,这些方法可能需要使用指向 CInputParam 的指针,来替代 IIInputBase。
- 模型中没有形成新的依赖关系。 添加的接口是模型的一部分。
- 鉴于我们所用的示例非常简单,所有 GetXXX() 方法都可以添加到此接口,包括那些与模型无关的方法(GetBckColor() 和 GetDistance())。
我们继续讨论模型实现的主要计算。 在此,基于从控制器接收到的数据,模型将计算指标值。 我们需要加入 Tick(...) 方法,就像在控制器中一样。 然后,我们将代码从原来的 WRP 指标移到这个方法,并添加辅助方法。 因此,我们的模型几乎与原始指标的 OnCalculate 处理程序代码相同。
然而,我们也遇到了一个问题:指标缓冲区。 有必要将数据直接写入缓冲区。 但是,将指标缓冲区放在模型里是不正确的,因为它应该位于视图当中。 所以,我们需再一次实现了它,就像我们之前做的那样。 在 Model 的同一文件夹中,创建 IOutputBase.mqh 文件。 在此文件中编写接口:
interface IOutputBase { void SetValue(int shift, double value); const double GetValue(int shift) const; };
第一个方法在指定索引处保存数值,而第二个方法则返回数值。 稍后,视图将实现此接口。 现在我们需要编辑模型初始化方法,以便它接收指向新接口的指针。 添加一个私密字段:
int Initialize(IInputBase* pI, IOutputBase* pO){ pInput = pI; pOutput = pO; ... } ... private: IInputBase* pInput; IOutputBase* pOutput;
在计算中,将指标缓冲区访问替换为方法调用:
pOutput.SetValue(...);
由此在模型中产生的 Tick(...) 函数如下所示(将其与原始的 OnCalculate 处理程序进行比较):
int CModel::Tick(const int rates_total,const int prev_calculated,const datetime &time[],const double &open[],const double &high[],const double &low[],const double &close[],const long &tick_volume[],const long &volume[],const int &spread[]) { if(rates_total < iLength) return(0); int i, pOutputs = prev_calculated - 1; if(pOutputs < iLength - 1) { pOutputs = iLength - 1; for(i = 0; i < pOutputs; i++) pOutput.SetValue(i, 0); } double w; for(i = pOutputs; i < rates_total && !IsStopped(); i++) { double max_high = Highest(high, iLength,i); double min_low = Lowest(low, iLength, i); //--- calculate WPR if(max_high != min_low) { w = -(max_high - close[i]) * 100 / (max_high - min_low); pOutput.SetValue(i, w); } else pOutput.SetValue(i, pOutput.GetValue(i - 1) ); } return(rates_total); }
在此,我们使用模型完成操作。
4. 视图
我们指标的最后一个组成部分是视图。 它负责呈现模型提供的数据。 与源数据模块一样,视图也是一个经常更新的组件。 所有频繁的更改(如添加缓冲区、更改样式、默认颜色等)都在视图中实现。 您应该注意的另一个方面是:视图中的更改通常源于源数据模块中的更改,反之亦然。 这便是将视图和源数据模块自模型分离出来的另一个原因。
再次重复上述步骤。 在 View 文件夹中创建 CView 类。 连接 IOutputBase.mqh 文件。 在 View 类中,创建熟悉的 Initialize(...) 方法。 注意,我们模型和视图中并未创建 Update(...) 和 Release(...) 方法。 目前我们的指标不需要它们。
我们添加一个指标缓冲区作为常规的私密字段,实现 IOutputBase 协定,并将所有指标的 IndicatorSetХХХ、PlotIndexSetХХХ 和相似的调用隐藏到 Initialize(...) 当中。 这将从主指标文件中删除大多数宏:
class CView : public IOutputBase { private: const CInputParam* pInput; double WPRlineBuffer[]; public: CView(){} ~CView(){} int Initialize(const CInputParam* pI); void SetValue(int shift, double value); const double GetValue(int shift) const {return WPRlineBuffer[shift];} }; int CView::Initialize(const CInputParam *pI) { pInput = pI; IndicatorSetString(INDICATOR_SHORTNAME, NAME ); IndicatorSetInteger(INDICATOR_DIGITS, 2 ); IndicatorSetDouble(INDICATOR_MINIMUM,-100 ); IndicatorSetDouble(INDICATOR_MAXIMUM, 0 ); IndicatorSetInteger(INDICATOR_LEVELCOLOR,clrGray ); IndicatorSetInteger(INDICATOR_LEVELWIDTH,1 ); IndicatorSetInteger(INDICATOR_LEVELSTYLE,STYLE_DOT); IndicatorSetInteger(INDICATOR_LEVELS, 2 ); IndicatorSetDouble(INDICATOR_LEVELVALUE,0, -20 ); IndicatorSetDouble(INDICATOR_LEVELVALUE,1, -80 ); SetIndexBuffer(0, WPRlineBuffer); PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_LINE ); PlotIndexSetInteger(0, PLOT_LINE_STYLE, STYLE_SOLID); PlotIndexSetInteger(0, PLOT_LINE_WIDTH, 1 ); PlotIndexSetInteger(0, PLOT_LINE_COLOR, clrRed ); PlotIndexSetString (0, PLOT_LABEL, NAME + "_View" ); return INIT_SUCCEEDED; } void CView::SetValue(int shift,double value) { WPRlineBuffer[shift] = value; }
这就是全部。 我们已经创建了一个指标,它能有效操作。 屏幕截图显示了这两个版本 — 原始 WPR 和我们的定制 WPR,可在文后的附件中找到它们:
显然,它们的读数是一样的。 现在,我们根据上面研究的规则,尝试在指标中实现其它功能。
5. 基于新指标操作
假设我们需要动态地将指标绘图样式从直线更改为柱状图。 我们来添加这个选项,看看新功能的实现能否变得更容易一些。
我们需要一种发出信号的方式。 它将是一个图形对象,点击它会将指标从直线切换到直方图,反之亦然。 我们在指标子窗口中创建一个按钮:
创建 CButtonObj 类来初始化、存储和删除 “Button” 图形对象。 这个代码类非常简单,所以我不会在此展示。 类(和按钮)将由控制器控制:该按钮是用户交互元素,由控制器直接负责。
将 OnChartEvent 处理程序添加到主程序文件当中,并将相关方法添加到控制器:
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { pController.ChartEvent(id, lparam, dparam, sparam); }
主要变化都将在视图中实现。 在此,我们需要为信号和少数方法添加一个枚举,:
enum VIEW_TYPE { LINE, HISTO }; class CView : public IOutputBase { private: ... VIEW_TYPE view_type; protected: void SwitchViewType(); public: CView() {view_type = LINE;} ... const VIEW_TYPE GetViewType() const {return view_type;} void SetNewViewType(VIEW_TYPE vt); }; void CView::SetNewViewType(VIEW_TYPE vt) { if (view_type == vt) return; view_type = vt; SwitchViewType(); } void CView::SwitchViewType() { switch (view_type) { case LINE: PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_LINE ); break; case HISTO: PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_HISTOGRAM ); break; } ChartRedraw(); }
在指标主文件的 OnChartEvent 处理程序中调用 Controller 方法的结果如下所示:
void CController::ChartEvent(const int id,const long &lparam,const double &dparam,const string &sparam) { switch (id) { case CHARTEVENT_OBJECT_CLICK: if (StringCompare(sparam, pBtn.GetName()) == 0) { if (pView.GetViewType() == LINE) pView.SetNewViewType(HISTO); else pView.SetNewViewType(LINE); } break; default: break; }//switch (id) }
该方法检查鼠标是否在正确的对象上单击,然后切换视图中的显示模式:
加入相关更改非常简单、快速。 假使我们在一年后必须进行类似的改变,也不会花太多时间。 开发者会记住脚本的结构,以及在每个组件中执行的操作。 因此,即使文档丢失、或忘记了项目原则,项目也很容易维护。
7. 代码注释
现在,我们针对所编写代码进行一些分析。
- 该模型几乎没有依赖关系。 与之对比,控制器依赖于所有其它模块:从文件开头的 #include 集合中可以清楚地看出这一点。 从形式上讲,这是真的。 当包含一个文件时,开发者会引入一个依赖项。 控制器的具体功能是创建模块、控制模块的生命周期,并将事件转发给模块。 控制器充当“引擎”,它提供动力,并实现与用户的交互。
- 所有组件都包含类似的方法:Initialize、Update、Release。 更深入的逻辑步骤是使用一组虚拟方法创建基类。 Initialize 方法的内容对于不同的组件是不同的,但是可以找到一些解决方案。
- 大概,一个更吸引人的变体(虽然更难)是让 CInputManager 返回指向接口的指针:
class CInputManager { ... public: InputBase* GetInput(); ... };
如果实现这个想法,独立组件只能访问有限的一组输入参数。 我们现在不会在此这样做。 请注意,整篇文章中对输入参数模块的关注如此之多,是因为我想展示构建其它模块的可能方法,这些模块以后可能会用到。 例如,CView 组件不必像本文中所做的那样,通过层次关系实现 IOutputBase 接口。 它可以选择一些其它构成形式。
8. 结束语
这个主题于此可认为是完整的。 在第一篇文章中,只是一般性研究了 MVC 范式。 这一次,我们深入探讨了这个主题,研究了 MVC 范式的各个组件之间可能存在的交互。 当然,这个话题不是很简单。 但是,如果应用得当,提供的信息可能会非常有用。
文章中用到的程序:
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | WPR_MVC.zip | ZIP 存档 | 修订的 WPR 指标 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/10249
![在一张图表上的多个指标(第 01 部分):理解概念](https://c.mql5.com/2/44/variety_of_indicators.png)
![学习如何设计一款布林带(Bollinger Bands)交易系统](https://c.mql5.com/2/45/why-and-how__2.png)
![DoEasy 函数库中的图形(第九十六部分):窗体对象中的图形和鼠标事件的处理](https://c.mql5.com/2/45/MQL5-avatar-doeasy-library3-2.png)
发表文章《MVC设计模式及其使用的可能性(第二部分):三个组件之间的互动方案》。
作者:Andrei Novichkov