为什么在命令源从UI中移除后仍会调用CanExecute?

7
我正在尝试理解为什么已从UI中删除的命令源上会调用CanExecute。这里是一个简化的程序示例:
<Window x:Class="WpfApplication1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Height="350" Width="525">
    <StackPanel>
        <ListBox ItemsSource="{Binding Items}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel>
                        <Button Content="{Binding Txt}" 
                                Command="{Binding Act}" />
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
        <Button Content="Remove first item" Click="Button_Click"  />
    </StackPanel>
</Window>

后台代码:

public partial class MainWindow : Window
{
    public class Foo
    {
        static int _seq = 0;
        int _txt = _seq++;
        RelayCommand _act;
        public bool Removed = false;

        public string Txt { get { return _txt.ToString(); } }

        public ICommand Act
        {
            get
            {
                if (_act == null) {
                    _act = new RelayCommand(
                        param => { },
                        param => {
                            if (Removed)
                                Console.WriteLine("Why is this happening?");
                            return true;
                        });
                }
                return _act;
            }
        }
    }

    public ObservableCollection<Foo> Items { get; set; }

    public MainWindow()
    {
        Items = new ObservableCollection<Foo>();
        Items.Add(new Foo());
        Items.Add(new Foo());
        Items.CollectionChanged += 
            new NotifyCollectionChangedEventHandler(Items_CollectionChanged);
        DataContext = this;
        InitializeComponent();
    }

    void Items_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Remove)
            foreach (Foo foo in e.OldItems) {
                foo.Removed = true;
                Console.WriteLine("Removed item marked 'Removed'");
            }
    }

    void Button_Click(object sender, RoutedEventArgs e)
    {
        Items.RemoveAt(0);
        Console.WriteLine("Item removed");
    }
}

当我点击“删除第一个项目”按钮一次时,输出如下:
Removed item marked 'Removed'
Item removed
Why is this happening?
Why is this happening?

每次我点击窗口的某个空白部分,都会打印出“为什么会发生这种情况?”。
为什么会发生这种情况?我应该怎么做才能防止在已删除的命令源上调用CanExecute?
注意:RelayCommand可以在此处找到。
Michael Edenfield问题的答案:
Q1:当从按钮中删除时调用CanExecute的调用堆栈:
WpfApplication1.exe!WpfApplication1.MainWindow.Foo.get_Act.AnonymousMethod__1(object param) 第30行 WpfApplication1.exe!WpfApplication1.RelayCommand.CanExecute(object parameter) 第41行 + 0x1a 字节 PresentationFramework.dll!MS.Internal.Commands.CommandHelpers.CanExecuteCommandSource(System.Windows.Input.ICommandSource commandSource) + 0x8a 字节 PresentationFramework.dll!System.Windows.Controls.Primitives.ButtonBase.UpdateCanExecute() + 0x18 字节 PresentationFramework.dll!System.Windows.Controls.Primitives.ButtonBase.OnCanExecuteChanged(object sender, System.EventArgs e) + 0x5 字节 PresentationCore.dll!System.Windows.Input.CommandManager.CallWeakReferenceHandlers(System.Collections.Generic.List handlers) + 0xac 字节 PresentationCore.dll!System.Windows.Input.CommandManager.RaiseRequerySuggested(object obj) + 0xf 字节 Q2: 另外,如果您从列表中删除所有按钮(而不仅仅是第一个),这是否会继续发生?

我想念RelayCommand。这是什么? - Gqqnbig
我添加了一个 RelayCommand 的实现链接。 - user610650
你尝试过在事件期间检查调用堆栈并查看是什么触发了它吗?此外,如果您从列表中删除所有按钮(而不仅仅是第一个),这是否会持续发生? - Michael Edenfield
@MichaelEdenfield:我已更新并附上答案。 - user610650
2个回答

3
问题在于命令源(即按钮)没有取消绑定的命令的CanExecuteChanged事件,因此无论何时CommandManager.RequerySuggested触发,CanExecute都会触发,即使命令源已经不存在。
为了解决这个问题,我在RelayCommand上实现了IDisposable,并添加了必要的代码,以便每当模型对象被移除且从UI中移除时,将调用所有其RelayCommand的Dispose()方法。
以下是修改后的RelayCommand(原始版本在这里):
public class RelayCommand : ICommand, IDisposable
{
    #region Fields

    List<EventHandler> _canExecuteSubscribers = new List<EventHandler>();
    readonly Action<object> _execute;
    readonly Predicate<object> _canExecute;

    #endregion // Fields

    #region Constructors

    public RelayCommand(Action<object> execute)
        : this(execute, null)
    {
    }

    public RelayCommand(Action<object> execute, Predicate<object> canExecute)
    {
        if (execute == null)
            throw new ArgumentNullException("execute");

        _execute = execute;
        _canExecute = canExecute;
    }

    #endregion // Constructors

    #region ICommand

    [DebuggerStepThrough]
    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute(parameter);
    }

    public event EventHandler CanExecuteChanged
    {
        add
        {
            CommandManager.RequerySuggested += value;
            _canExecuteSubscribers.Add(value);
        }
        remove
        {
            CommandManager.RequerySuggested -= value;
            _canExecuteSubscribers.Remove(value);
        }
    }

    public void Execute(object parameter)
    {
        _execute(parameter);
    }

    #endregion // ICommand

    #region IDisposable

    public void Dispose()
    {
        _canExecuteSubscribers.ForEach(h => CanExecuteChanged -= h);
        _canExecuteSubscribers.Clear();
    }

    #endregion // IDisposable
}

在我使用以上内容的任何地方,我都会跟踪所有已实例化的RelayCommands,以便在必要时调用Dispose()

Dictionary<string, RelayCommand> _relayCommands 
    = new Dictionary<string, RelayCommand>();

public ICommand SomeCmd
{
    get
    {
        RelayCommand command;
        string commandName = "SomeCmd";
        if (_relayCommands.TryGetValue(commandName, out command))
            return command;
        command = new RelayCommand(
            param => {},
            param => true);
        return _relayCommands[commandName] = command;
    }
}

void Dispose()
{
    foreach (string commandName in _relayCommands.Keys)
        _relayCommands[commandName].Dispose();
    _relayCommands.Clear();
}

0

使用lambda表达式和触发事件存在已知问题。我不敢称其为“错误”,因为我不了解内部细节是否意图如此,但对我来说,它显然是违反直觉的。

这里的关键指示是您调用堆栈的这一部分:

PresentationCore.dll!System.Windows.Input.CommandManager.CallWeakReferenceHandlers(
   System.Collections.Generic.List handlers) + 0xac bytes 

"弱事件"是一种连接事件的方式,它不会使目标对象保持活动状态;这里使用它是因为您正在传递一个lambda表达式作为事件处理程序,所以包含该方法的"对象"是一个内部生成的匿名对象。问题在于,传递到您事件的add处理程序中的对象与传递到remove事件中的对象不是同一个表达式实例,它只是一个功能上相同的对象,因此它没有从您的事件中取消订阅。
有几种解决方法,如下面的问题所述:

用于lambda的弱事件处理程序模型

C#中使用Lambda取消挂钩事件

使用lambda作为事件处理程序会导致内存泄漏吗?

对于您的情况,最简单的方法是将CanExecute和Execute代码移动到实际方法中:
if (_act == null) {
  _act = new RelayCommand(this.DoCommand, this.CanDoCommand);
}

private void DoCommand(object parameter)
{
}

private bool CanDoCommand(object parameter)
{
    if (Removed)
      Console.WriteLine("Why is this happening?");
    return true;
}

或者,如果您可以安排您的对象从lambda构造Action<>Func<>委托一次,并将它们存储在变量中,然后在创建您的RelayCommand时使用它们,它将强制使用相同的实例。在我看来,对于您的情况,这可能比它需要的更复杂。


创建非匿名方法并将它们作为RelayCommand构造函数的参数传递并不会改变任何内容。我进行了一些研究,似乎问题在于命令源(按钮)仍然订阅了CanExecuteChanged事件(即按钮自动挂钩到事件,但没有取消订阅)。 - user610650

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