如何避免WPF中事件的递归触发?

7
我有两个 WPF(标准集)小部件 A 和 B。当我更改 A 的某些属性时,应该在 B 上设置它,当 B 中的属性更改时,应该在 A 上设置它。
现在我有这个丑陋的递归 --> 我更改 A,所以代码更改 B,但由于 B 已更改,它又更改了 A,因此它又更改了 B... 你明白了。
如何避免这种递归是最“标准”的方法?天真地删除和添加事件处理程序无效,并且检查新值是否与旧值相同在这里不适用(由于计算的波动 - 我没有将相同的值设置为 A 和 B,而是转换)。
背景
我总是尝试尽可能少地提供有关问题的信息,以避免混淆。然而,这可能会有所帮助
- 我没有编写那些小部件,我只处理事件,仅此而已 - 尽管标题为“递归触发”,但处理程序是按顺序调用的,因此您有入口-退出-入口-退出-入口-退出的序列,而不是入口-入口-入口-退出-退出-退出 - 最后一个,可能是最不重要的,但仍然存在,在这种特殊情况下,我有一个公共处理程序用于 A 和 B
在这种情况下,A和B是滚动查看器,我试图保持它们的位置成比例地相同。Karin Huber的项目在这里:http://www.codeproject.com/KB/WPF/ScrollSynchronization.aspx 事件触发
阻止事件的想法非常流行,我添加了一系列触发事件的步骤,如下所示:
- 我改变A - 调用A处理程序 - 禁用A的处理程序 - 我更改B(存储但不触发) - 启用A的处理程序 - 现在从队列中获取事件 - 调用B处理程序 - 禁用B的处理程序 - 我更改A - ...
正如您所看到的,这是徒劳的。
7个回答

3
首先,我会考虑设计问题,因为这些循环依赖关系通常是糟糕设计的标志。
然而,在某些情况下,这种依赖关系可能是唯一的选择。在这种情况下,我建议使用私有标志来指示B中的更改是否由A中的更改引起。类似于这样(已更新):
public class A
{
    private bool m_ignoreChangesInB = false;

    private void B_ChangeOccurred(object sender, EventArgs e)
    {
        if (!m_ignoreChangesInB)
        {
            // handle the changes...
        }
    }

    private void SomeMethodThatChangesB()
    {
        m_ignoreChangesInB = true;
        // perform changes in B...
        m_ignoreChangesInB = false;
    }
}

在B类中应该采用相同的方法。但是,这种方法不能处理来自多个线程的更改。如果A或B可能同时从多个线程进行更改,则必须使用适当的技术来避免属性更改丢失。


我强调了这些是WPF小部件,不是我的 - 也就是说,它们来自标准集合,而我只是使用它们。只有事件处理程序是我的,其余部分都不是定制的WPF。但它不起作用,因为递归触发只是我们的幻觉 - 事件是按顺序处理的。 - greenoldman
这并不重要。最终,问题出现在你的代码改变属性时。也许,我的代码示例并不完全符合这种情况。但是,使用一个标志来指示值更改是由你的代码发起的想法仍然适用。 - gehho
我不起作用 - 请查看事件处理的“跟踪”。如果事件在更改后立即被处理,它将非常有效。但是它们没有被立即处理,您的标志被恢复,然后再次调用处理程序。 - greenoldman
好的,现在我明白了。你知道为什么处理程序不会立即调用吗?是属性更改在另一个线程上执行吗? - gehho
是的,这些属性是WPF小部件的属性,这是特定的线程。顺便说一句,作为解决方法,可以做类似的事情——存储应该被阻止的事件信息(与WPF无关)。因此,当A更改B时,它应将B添加到“忽略列表”中,当B处理程序执行时,它应从该列表中删除自己。因此,调用者始终添加自己,但删除所有其他人。 - greenoldman
是的,我考虑过类似的事情。然而,这对我来说并不是一个清晰的解决方案。它似乎很快就会变得有问题... - gehho

2

不要触发事件,重构你的代码,使得A和B的事件处理程序调用另一个方法来完成实际工作。

private void EventHandlerA(object sender, EventArgs e)
{
    ChangeA();
    ChangeB();
}

private void EventHandlerB(object sender, EventArgs e)
{
    ChangeB();
    ChangeA();
}

您可以扩展/更改这些方法,如果直接更改A或通过B进行更改,则需要执行略有不同的操作。
更新:考虑到您不能更改/无法访问代码,这不是解决方案。

这是不可行的 - 这是WPF,A和B是小部件,而小部件在内部触发事件。 - greenoldman
但是事件被挂钩到事件处理程序上,你肯定可以影响那些处理程序内部发生的事情,不是吗? - Christian
克里斯汀,没问题,但我只能调用小部件设计者公开的内容。所以一旦我改变B,它就会触发事件,然后这将触发我的处理程序......我们就有了“递归”(也许我应该把这放在引号中以强调,这并不是真正的递归)。 - greenoldman

0

ChrisFs的解决方案可能是正确的方法,但有时我们会移除事件,进行更改,然后重新添加事件处理程序:

想象一个DataContext,其中包含DataContextChanged事件:

DataContextChanged -= OnDataContextChanged;
DataContext = new object();
DataContextChanged += OnDataContextChanged;

这样,您就可以确信您的事件处理程序不会“失控”。但是,事件仍然会触发;)


1
我已经写了 - 这不起作用。事件是按顺序触发的,递归只是一种幻觉,因为事件被排队。 - greenoldman
为什么这不起作用?如果你知道 B 会调用 A,你可以在 B 中取消挂钩 A,做你的事情,然后重新挂钩 A。 - Arcturus
因为事件处理程序不可重入。它们按顺序处理--我现在在帖子中添加了示例。 - greenoldman
我并没有建议你在处理程序A中删除处理程序A的处理程序。我建议在处理程序B的处理程序中删除处理程序A:

我更改A

调用A处理程序

我禁用B的处理程序

我更改B(这被存储,但不会触发)

我启用B的处理程序

希望不会调用B处理程序。

否则,请考虑在后台工作器中进行更改。
- Arcturus
事实上,我做得更多——我删除了所有处理程序 :-) 但它没有起作用(在跟踪调用后,我知道原因)。 - greenoldman

0
检查新值是否与旧值相同在这里不适用(由于计算的波动 - 我没有将相同的值设置为A和B,而是进行了转换)。
所以你的意思是会出现类似这样的情况:
1. A.Foo 被设置为 x。 2. A_FooChanged 将 B.Bar 设置为 f(x)。 3. B_BarChanged 将 A.Foo 设置为 g(f(x)),它不是 x。 4. A_FooChanged 将 B.Bar 设置为 f(g(f(x)))。
等等。这是正确的吗?因为如果 g(f(x)) 确实等于 x,则解决方案很简单: 如果 A.Foo != g(f(x)), 那么 B_BarChanged 只应该设置 A.Foo。

假设这个递归没有可计算的结束状态,那么事件处理程序需要一些方式来知道它们处理的事件所处的上下文。你无法从正常的事件协议中获取这些信息,因为事件被设计成解耦此设计正在耦合的操作。

听起来你需要一种超出带外的方式让这些控件相互信号。可以简单地使用Window属性的HashSet<EventHandler>。我会考虑类似这样的东西:

private void A_FooChanged(object sender, EventArgs e)
{
   if (!SignalSet.Contains(B_BarChanged))
   {
      SignalSet.Add(A_FooChanged);
      B.Bar = f(A.Foo);
      SignalSet.Remove(A_FooChanged);
   }
}

如果A设置了B.Bar,并且B设置了C.Baz,C设置了A.Foo,那么这就会出问题,尽管我怀疑要求本身是否会出问题。在这种情况下,您可能需要查看堆栈跟踪。这不太好看,但是这个问题的任何方面都不好看。


谢谢你的想法!关于函数,是的,g(f(x))不一定是x(它可以是,但我不能假设它)。我考虑过对象和事件的字典,但我放弃了这种方式——问题在于当g(f(x)) IS x时(我无法预测它,至少不是以清晰的方式)。当新值与旧值不同时,事件会被触发——使用这个字典,我将等待事件到来,但它永远不会到来,因为这些值是相等的。 - greenoldman
我不理解最后一句话。在我描述的情况中没有“等待事件”的概念:B_BarChanged 检查 HashSet。如果 A_FooChangedHashSet 中,那么 B_BarChanged 就知道它是在 A.Foo 被更改的上下文中被调用的,并且它不应该更改 A.Foo - Robert Rossney
你看,当你触发 A 时,你会更新 B -- 所以你将 B 设置为在下一次被忽略。但是你不知道的是,B 不会被更新,因为旧值等于新值。过了一段时间,由于用户交互,B 将被触发,但现在它将被忽略,因为这是你第一次从 B 获取事件。 - greenoldman
检查是否 f(A.Foo) == B.Bar,只有在不相等的情况下才进行更新。 - Robert Rossney

0
一个稍微有些hackish的解决方案是在黑屏时间段内设置一个忽略后续事件处理程序触发的机制。如果这样做,请务必使用DateTime.UtcNow而不是DateTime.Now,以避免夏令时边界条件。
如果您不想依赖于时间(事件可能合法地快速更新),您可以使用类似的阻塞类型,尽管这更加hackish:
        private int _handlerCounter;
        private void Handler()
        {
            //The logic of this handler will trigger an 'asynchronously reentrant' callback, so we ignore the next (and only the next) callback
            //Note that this breaks down if the callback is not triggered, so we need to make certain the reentrancy will occur
            //If we can't ensure that, we need to at least detect that it won't occur and manually decrement the counter
            if (Interlocked.CompareExchange(ref _handlerCounter, 0, 1) == 0)
            {
                //Call set on A/B, which triggers callback of this same handler for B/A
            }
            else
            {
                Interlocked.Decrement(ref _handlerCounter);
            }
        }

谢谢你,但我不会走那条路——这会带来很多麻烦。无论电脑速度快慢,它都会崩溃。 - greenoldman

0

由于在ScrollViewers之间位置被缩放或改变,因此您不能使用简单的绑定,但是转换器可以起作用吗?

<Window x:Class="Application1.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:SurfaceApplication21"
    Title="SurfaceApplication21"
    >
    <Window.Resources>
        <local:InvertDoubleConverter x:Key="idc" />
    </Window.Resources>
  <Grid>
        <StackPanel>
            <Slider Minimum="-100" Maximum="100" Name="a" Height="23" HorizontalAlignment="Left" Margin="30,12,0,0" VerticalAlignment="Top" Width="100" />
            <Slider Minimum="-100" Maximum="100" Value="{Binding ElementName=a, Path=Value, Converter={StaticResource idc}}" Name="b" Height="23" HorizontalAlignment="Left" Margin="30,12,0,0" VerticalAlignment="Top" Width="100" />
        </StackPanel>
    </Grid>
</Window>

代码中,您可以实现所需的任何数学来在两个视图之间进行缩放。

[ValueConversion(typeof(double), typeof(double))]
public class InvertDoubleConverter : IValueConverter
{

    public object Convert(object value, Type targetType,
        object parameter, System.Globalization.CultureInfo ci)
    {
        return -(double)value;
    }

    public object ConvertBack(object value, Type targetType,
        object parameter, System.Globalization.CultureInfo ci)
    {
        return -(double)value;
    }
}

我没有尝试过使用ScrollViewers,但由于ScrollViewer模板中的滚动条和滑块都是从RangeBase派生而来的,所以类似这样的东西应该可以工作,但您可能需要重新定义模板和/或子类化您的ScrollViewers。


但这实际上并没有改变什么。转换器会改变输出值,而输出值再次触发变化 - 这一次是回来的。你展示了可逆函数(数学取反)的例子,这就是为什么它有效,而不是因为转换器。要看到不可逆函数在每次转换中的影响,请添加一个随机数(-10,+10)。 - greenoldman

0

我不熟悉WPF控件,所以以下解决方案可能不适用:

  • 仅在新值与旧值不同时更新。您已经说明这不适用于您的情况,因为值始终略有不同。
  • 通过在通知时保持布尔标志来防止递归,从而进行黑客攻击。缺点可能是会错过一些通知(即客户端未更新),并且如果通知是发布的(而不是直接调用),则此标志无效。
  • Windows控件区分引发事件的用户操作和以编程方式设置数据的操作。最后一类不会通知。
  • 使用观察者(或中介者)模式,在其中控件不会直接更新彼此

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接