winform两个控件数据双向绑定时需要注意的坑

简单例子

  我们还是以简单需求举例。现在需要我们做一个圆形按钮的自定义控件,和一个拖动条,当我们拖动拖动条时,圆形按钮的直径大小会随着拖动条的指针位置变化,当我们在圆形按钮控件上鼠标左键点击,圆形按钮直径变大,右键点击,直径变小,同时拖动条的指针也会移动到对应的位置。

思路解析

  首先,圆形按钮的自定义控件我们已经做过了,可以直接拿来用,具体可以直接看这篇文章。拖动条我们可以直接拿工具箱自带的trackbar。首先我们来看下圆形按钮控件的代码,为了专门说明本篇文章的主题,所以我特意做了一些改变。

public partial class CircleButton : Button
    {
        public delegate void OnDiameterChanged(int value);
        public event OnDiameterChanged DiameterChanged;

        private int diameter = 100;
        public int Diameter
        {
            get { return diameter; }
            set
            {
                diameter = value;
                SetDiameter();
                DiameterChanged(diameter);
            }
        }
        public CircleButton()
        {
            InitializeComponent();
            ClientSize = new Size(200, 200);
            FlatStyle = FlatStyle.Flat;
            FlatAppearance.BorderSize = 0;
            BackColor = Color.Red;
            SetDiameter();
        }
        private void SetDiameter()
        {
            GraphicsPath grPath = new GraphicsPath();
            grPath.AddEllipse(0, 0, diameter, diameter);
            Region = new Region(grPath);
        }
    }

 

  可以看到,圆形按钮向外暴露了一个直径改变的事件,所以在主窗体中,我们可以给直径改变的事件订阅一个方法,这个方法里的逻辑就是根据直径大小改变trackbar的指针位置;然后对于trackbar,给它的valueChanged事件订阅一个方法,去改变圆形按钮的直径大小,这样,我们就实现了两个控件的双向绑定。那个鼠标左右键点击完全是为了验证是否是双向绑定。以下是实现代码:

public partial class Form1 : Form
    {
        private CircleButton cb;
        private TrackBar trackBar;

        public Form1()
        {
            InitializeComponent();
            //先把圆形按钮和trackbar都添加到窗体中
            cb = new CircleButton();
            cb.Location = new Point(130, 130);
            this.Controls.Add(cb);
            trackBar = new TrackBar();
            trackBar.Minimum = 0;
            trackBar.Maximum = 200;
            trackBar.Orientation = Orientation.Horizontal;
            trackBar.TickFrequency = 1;
            trackBar.Location = new Point(500, 150);
            trackBar.Width = 300;
            trackBar.Value = cb.Diameter;
            this.Controls.Add(trackBar);
            //直接使用lambda(匿名函数)订阅事件,
            //如果这个函数只被调用一次,那么lambda是非常好的选择
            cb.DiameterChanged += (value) =>
            {
                trackBar.Value = value;
            };

            trackBar.ValueChanged += (sender, e) =>
            {
                cb.Diameter = trackBar.Value;
            };

            cb.MouseDown += (sender, e) =>
            {
                if (e.Button == MouseButtons.Left)
                {
                    cb.Diameter += 1;
                }
                else if (e.Button == MouseButtons.Right)
                {
                    cb.Diameter -= 1;
                }
            };
        }

    }

   如果你把这以上两段代码整理放入到一个winform程序中,看起来似乎效果已经达到了,但是,实际上这里面是有问题的,你可以猜猜trackBar.ValueChanged事件触发了几次,以及cb.DiameterChanged事件触发了几次。

  我第一次分析代码时是这样的:以鼠标左键点击驱动事件为例,当鼠标左键点击时,cb.Diameter属性的set访问器被触发,假设此时传给set的value为101,在set访问器中我们没有做任何限制,所以会直接发布DiameterChanged(diameter)事件,订阅了该事件的方法会被执行,然后我们可以在Form1类中可以看到,由于我们写的订阅方法是匿名方法,所以直接执行方法体里的代码即trackBar.Value = value,这行代码会再发布trackBar的ValueChanged事件,继而执行订阅方法cb.Diameter = trackBar.Value,这又会触发一次DiameterChanged事件,也就是说,开启了新的一轮的事件触发循环。

  可是,事实真是如此吗,我们在Form1中定义两个私有变量,分别用来统计两个订阅方法分别执行了几次,并修改订阅的匿名方法。代码如下:

cb.DiameterChanged += (value) =>
            {
                trackBar.Value = value;
                System.Diagnostics.Debug.WriteLine($"DiameterChanged第{++cnt1}次执行");
            };

            trackBar.ValueChanged += (sender, e) =>
            {
                cb.Diameter = trackBar.Value;
                System.Diagnostics.Debug.WriteLine($"ValueChanged第{++cnt2}次执行");
            };

在输出中,我们可以看到结果:

DiameterChanged第1次执行
ValueChanged第1次执行
DiameterChanged第2次执行

  这是为什么呢?难道不应该是无限循环输出吗?我一开始看到这个结果时也是疑惑的,后来,我猜想是因为trackBar的Value属性set访问器里,先判定了传进来的参数value是否跟当前Value相等,如果相等则不发布事件,事实上,我们能看到C#反编译后的代码,里面也确实是这么做的。源代码(.NET framework 4.7.2)如下:

public int Value
        {
            get
            {
                GetTrackBarValue();
                return value;
            }
            set
            {
                if (this.value != value)
                {
                    if (!initializing && (value < minimum || value > maximum))
                    {
                        throw new ArgumentOutOfRangeException("Value", SR.GetString("InvalidBoundArgument", "Value", value.ToString(CultureInfo.CurrentCulture), "'Minimum'", "'Maximum'"));
                    }

                    this.value = value;
                    SetTrackBarPosition();
                    OnValueChanged(EventArgs.Empty);
                }
            }
        }

  当第二次发布DiameterChanged事件时,其订阅方法里,给trackbar赋值value时,此时赋的value和当前trackbar.Value相等,所以事件传播到此结束。

  其实本来这篇博客我是想用两个自定义控件来做演示的,但再写一个自定义控件我怕大家没耐心看,所以干脆就拿trackbar做演示。不过看到这儿大家应该知道我想说明双向绑定时要注意的地方了,如果这个trackbar其Value属性的set访问器内部没有做判断限制的话,事件触发就会不停循环下去,为了避免这个问题,在必须在set访问器中做传入value和当前value的等值判断,如果不相等才去发布事件。两个控件双向数据绑定时,需要它们各自的绑定属性的set访问器中做等值判断,这样两个事件分别只发布一次。

  这也是为什么我把CircleButton改了下的原因,现在我们把CircleButton的Diameter属性set访问器加上等值判断,然后看下两个事件是否分别只触发一次。

 public partial class CircleButton : Button
    {
        public delegate void OnDiameterChanged(int value);
        public event OnDiameterChanged DiameterChanged;

        private int diameter = 100;
        public int Diameter
        {
            get { return diameter; }
            set
            {
                if (diameter != value)
                {
                    diameter = value;
                    SetDiameter();
                    DiameterChanged(diameter);
                }
            }
        }
//...
}

输出结果:

ValueChanged第1次执行
DiameterChanged第1次执行

posted @ 2022-12-12 23:28  hlz2516  阅读(560)  评论(0)    收藏  举报