自定义控件之间数据联动的进阶方法
简单例子
需求:在一张化工制作XX工艺的流程图上,有一个气压泵图标,图标上有两个参数分别是当前气压和阀门开合度,点击该图标时,弹出一个控制面板(即一个自定义窗体),控制面板里有两个文本框,第一个文本框表示当前气压,仅作显示不能编辑,弹出控制面板时该文本框内值与图标内的气压值一致,第二个文本框表示阀门开合度,既作显示也能编辑,弹出控制面板时该文本框内值与图标内的阀门开合度值一致。图标与控制面板的数据是联动的,也就是说,图标的当前气压或者阀门开合度改变,控制面板里的两个数据都跟着变;控制面板里的阀门开合度改变,图标的阀门开合度改变。
解决思路一(自定义事件)
给图标定义气压改变事件和阀门开合度改变事件,当图标内气压改变或者阀门开合度改变时,触发订阅的方法,通过这种机制让图标去同步控制面板里的数据。同理对于控制面板,但只需定义一个阀门开合度改变事件,因为控制面板里只能编辑阀门开合度,气压是不能人为更改的,所以在控制面板内部只需在阀门开合度改变时将该事件触发。放上一张简图说明:

对于这种思路,在我之前的两个控件间如何进行数据双向绑定的那篇文章中已经讲到过了,所以这里不再重复实现。但是我要讲下这种解决方案的缺点:
1. 数据的冗余定义。
这种解决方案,我们会想当然地在控制面板类里也定义两个表示气压和阀门的属性,并且给这两个属性设置set访问器,方便属性被设置后直接动态地改变UI状态。这种做法好像没问题,但其实你仔细想想,控制面板真的有必要定义这两个数据吗,其实只有图标里的数据才是源数据,控制面板里的数据集是完全参考图标里的,那么,是不是没有必要在控制面板里再定义一套与图标一模一样的数据集呢?
我认为没有必要,控制面板里定义的数据集是冗余的,除了可以通过set访问器动态改变UI状态外,没有其他的意义。而动态改变UI,可以通过封装在方法里解决,并且写在方法里有个好处就是方便外部订阅与取消订阅(第二点会详细讲述)。既然控制面板里的数据集是完全参考图标里的,为什么我们不直接在构造控制面板时直接传递一个图标的引用进来呢?这样我们就不需要重复声明同样的数据了,直接拿图标引用里的数据用就好。
2. 无法动态取消UI上的状态联动
当图标里的事件需要动态订阅和取消订阅一个方法来改变一个控制面板的UI状态时,按照目前的解决思路,你把改变UI的代码写在属性里就出问题了。这样说有点晦涩,我拿当前例子说明,假设当前控制面板类里我还是定义的两个属性代表气压(PV)和阀门开合度(MV),比如PV的属性里是这么写的:
private int pv;
public int PV
{
get { return pv; }
set
{
if(pv != value)
{
pv = value;
textbox_pv.Text = pv.ToString(); //动态改变UI状态
}
}
}
现在,我想在点击图标时,动态创建一个控制面板,并且在这个时机,让图标里的PV实时同步给控制面板里的PV(订阅),当控制面板销毁时,去掉这个实时同步(取消订阅)。那么,怎么做呢?我的想法是,在图标的点击事件里,我们去new一个控制面板,然后给图标的PV改变事件订阅一个匿名方法,这个匿名方法里我们设置控制面板实例的PV为图标的PV值,然后,在控制面板的销毁事件中,我们取消订阅之前的匿名方法,伪代码如下:
private void tubiao_Click(object sender, EventArgs e)
{
var panel = new ControlPanel(); //动态创建控制面板
tubiao.pvchanged += (pvval)=>{panel.PV = pvval;};//当图标的PV改变时,把图标的PV赋给控制面板的PV属性
panel.disposed += (obj,e)=>
{
tubiao.pvchanged -= ? //这里我们是想在控制面板销毁时取消订阅上面的匿名方法,但。。匿名方法是不能(很难,可以认为是不能)取消订阅的
}
}
可以看到,在取消订阅时出问题了,如果你不信大可以试试,在订阅一个匿名方法后尝试一下取消订阅它,我也挺像知道怎么做的。为了避免这种情况,我还是更推荐将改变UI状态的代码封装成方法而不是写在set里,写成方法后因为方法名是明确的,所以就不会出现上述问题了。另外提一嘴,为什么要取消订阅呢?因为有些需求里,这个图标里的数据(PV)是实时变化的,每次变化都会触发PV改变事件,执行所有订阅了该事件的方法们,如果其中有一个方法,它的实例已经被销毁了,那么在执行这个方法时就会报错,所以要取消订阅。
解决思路二(事件+接口)
首先,针对缺点一,我一开始的想法是,定义一个控制面板的有参构造函数,形参为图标类,在构造控制面板时调用该构造函数传入图标实例,然后在控制面板内部保存住这个实例即可。但后面想想,万一以后有不同的图标类,然后也具有PV和MV的数据,也想调出这个控制面板,该怎么办?所以我参考了OOP设计原则中的依赖倒置原则,将参数图标类改为一个数据集接口,这个数据集接口内部很简单,就是声明了PV和MV属性,也就是说,只要你一个类继承了这个数据集接口,那么你这个类的实例就可以作为实参传入到这个构造函数里,控制面板里也只能拿到你这个实例里的PV和MV做事情,其他东西是访问不到的。说的有点多,分步骤来讲就是:
1. 定义一个数据集接口
public interface IData { int PV { get; set; } int MV { get; set; } }
2. 让图标类继承自该接口
public partial class Icon : UserControl,IData { private int pv; public int PV { get { return pv; } set { pv = value; } } private int mv; public int MV { get { return mv; } set { mv = value; } } public Icon() { InitializeComponent(); } }
3. 给控制面板类添加一个有参构造函数,这个形参是这个数据集接口,并且定义一个接口变量用于保存该接口实例
public partial class ControlPanel : Form { private IData dataSource; public ControlPanel() { InitializeComponent(); } public ControlPanel(IData dataSource) { InitializeComponent(); this.dataSource = dataSource; } }
确实,这样我们就解决了数据冗余的问题,可是,数据还没有建立联动,当控制面板里的阀门开合度改变时,我们怎么去通知对应的图标并且让它也改变呢?
其实通过传入接口实例,控制面板里我们已经能通过接口访问到PV和MV,甚至,我们可以对它们进行赋值操作,一旦赋值就会触发对应的set访问器里的代码,你可以在图标类的PV的set访问器里Debug.WriteLine一下,你会发现,每一次控制面板里修改dataSource.PV的值,都会触发图标的PV的set访问器。因为图标类继承了这个数据集接口,并实现了set和get,所以每次调用接口实例的set或get时都会触发具体类的set和get,这就是接口的神奇之处。
4. 利用接口这个特性,我们继续给图标类和控制面板类添砖加瓦
public partial class ControlPanel : Form { private IData dataSource; public ControlPanel() { InitializeComponent(); } public ControlPanel(IData dataSource) { InitializeComponent(); this.dataSource = dataSource; } private void txt_mv_KeyUp(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { int mvVal; if (int.TryParse(txt_mv.Text, out mvVal)) { dataSource.MV = mvVal; } else { MessageBox.Show("请输入整数", "提示"); txt_mv.Text = ""; } } } }
public partial class Icon : UserControl,IData { private int pv; public int PV { get { return pv; } set { if (pv != value) { pv = value; lbl_PV_Val.Text = pv.ToString(); } } } private int mv; public int MV { get { return mv; } set { if (mv != value) { mv = value; lbl_MV_Val.Text = mv.ToString(); } } } public Icon() { InitializeComponent(); } }
这就实现了从控制面板到图标的数据同步,那么,如何实现从图标到控制面板的同步呢?这个同步其实就是利用我们的老方法——事件,同时为了解决上述说到的第二点的问题(动态订阅和取消订阅),我们需要在控制面板里把设置PV和MV的方法封装出来供事件订阅使用。
5. 利用事件实现图标到控制面板的同步
public partial class ControlPanel : Form { private IData dataSource; public ControlPanel() { InitializeComponent(); } public ControlPanel(IData dataSource) { InitializeComponent(); this.dataSource = dataSource; } private void txt_mv_KeyUp(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { int mvVal; if (int.TryParse(txt_mv.Text, out mvVal)) { dataSource.MV = mvVal; } else { MessageBox.Show("请输入整数", "提示"); txt_mv.Text = ""; } } } public void SetPV(int pv) { txt_pv.Text = pv.ToString(); } public void SetMV(int mv) { txt_mv.Text = mv.ToString(); } }
public partial class Icon : UserControl,IData { public delegate void ValChangedEventHandler(int value); public event ValChangedEventHandler PVChanged; public event ValChangedEventHandler MVChanged; private int pv; public int PV { get { return pv; } set { if (pv != value) { pv = value; lbl_PV_Val.Text = pv.ToString(); PVChanged?.Invoke(pv); } } } private int mv; public int MV { get { return mv; } set { if (mv != value) { mv = value; lbl_MV_Val.Text = mv.ToString(); MVChanged?.Invoke(mv); } } } public Icon() { InitializeComponent(); } }
6. 至此,所谓的数据联动的基础工作就做完了,接下来我们在窗体上放置一个icon并且给它的点击事件添加代码,来看看效果。放置Icon前,需要先生成一下项目,点击菜单栏中的项目--刷新项目工具箱项,打开工具箱,搜索找到Icon控件,将其拖拽至主窗体中,效果如下:

中间的黑色控件就是自定义icon,现在给它添加一个点击事件,然后在该点击事件里添加如下代码:
private void icon1_Click(object sender, EventArgs e) { var panel = new ControlPanel(icon1); icon1.PVChanged += panel.SetPV; icon1.MVChanged += panel.SetMV; panel.Disposed += (obj, eve) => { icon1.PVChanged -= panel.SetPV; icon1.MVChanged -= panel.SetMV; }; panel.Show(); }
至此,我们完成了开头的需求实现,这篇文章里的所有内容均来源于我对当前公司项目的代码优化和思考,抽象成一个非常简单的需求供大家参考,并且例子的参考代码已上传至gitee仓库,各位朋友可以边参考代码边理解我上述所说的内容。
最后还想说明一个很重要的点:解决思路一适用于两种数据集不同的自定义控件,它们之间的数据存在一些关联;解决思路二适用于多个(子)控件的数据均参考自另外一个(主)控件,子控件对主控件的数据的所有操作均会反映在主控件上,对于不同的应用场景我们要选择合适的解决方案。
浙公网安备 33010602011771号