65.9K
CodeProject 正在变化。 阅读更多。
Home

在 WPF 中将 RoutedCommands 与 ViewModel 一起使用

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (52投票s)

2008年7月24日

CPOL

8分钟阅读

viewsIcon

623480

downloadIcon

4121

介绍了一种在 MVVM 模式中使用路由命令的干净、轻量级的方法。

mvvmCommanding2.png

引言

本文探讨了一种技术,该技术可以轻松地在命令执行逻辑位于 ViewModel 对象中时使用路由命令。这种方法省去了中间环节:代码隐藏文件。它允许您的 ViewModel 对象直接接收路由命令的执行和状态查询通知。

本文假设读者已经熟悉数据绑定和模板路由命令附加属性以及模型-视图-ViewModel(MVVM)设计模式。

背景

在 WPF Disciples 论坛上,有一个关于如何将路由命令与模型-视图-ViewModel 模式结合使用的有趣讨论。经过大量讨论,以及与Bill Kempf的旁敲侧击,我开始理解核心问题所在。大多数使用 WPF 与 MVP、MVC 或 MVVM 模式的示例都涉及路由命令。这些命令伴随着指向 View 的代码隐藏中事件处理方法的 CommandBinding,而这些处理方法又会委托给与该 View 关联的 Presenter/Controller/ViewModel。WPF Disciples 论坛上的讨论围绕着寻找一种方法,让那些 RoutedCommands 直接与 ViewModel 进行通信。

好处

让 View 中的路由命令直接与 ViewModel 通信有几个明显的好处。绕过 View 的代码隐藏意味着 View 与 ViewModel 的耦合度降低了。这也意味着 ViewModel 不依赖于 View 的代码隐藏来正确处理路由命令的事件并将这些调用委托给 ViewModel 对象上的正确成员。不仅如此,它还减少了创建 View 所需的代码量,这对于在设计器-开发人员工作流中工作非常重要。

现有技术

目前已经存在许多解决此类问题的方案,例如 Dan Crevier 的CommandModel,以及 Rob Eisenberg 的Caliburn框架。我认为 CommandModel 太复杂且受限,而 Caliburn 又过于庞大和广泛。我并不是说它们是任何方面的糟糕的解决方案,只是不符合我在此特定任务中的要求。

我的解决方案

我仔细思考了这个问题,并决定解决方案是创建一个自定义的 CommandBinding 类。为此,我创建了 CommandSinkBinding 类。它有两个 CommandSink 属性:一个是普通实例属性,另一个是附加属性。实例属性为 CommandSinkBinding 提供一个对象来处理其 CanExecuteExecuted 事件。附加的 CommandSink 属性用于指定要提供给元素 CommandBindings 集合中每个 CommandSinkBinding 的命令接收器。

另一块拼图是一个名为 ICommandSink 的小型接口和一个实现该接口的类,名为 CommandSink。想要响应路由命令的 ViewModel 类必须实现该接口,或从 CommandSink 派生。

这个解决方案非常轻量级、可重用,并且完全不依赖于反射。

实际应用

在我们检查我的解决方案如何工作之前,让我们先看看演示应用程序,该应用程序可在本文顶部下载。运行演示时,它的外观如下:

screenshot1.png

UI 显示了三个人,你可以让他们说话或死亡。如果单击一个人的“说话”按钮,会弹出一个消息框,如下所示:

screenshot2.png

如果单击一个人的“死亡”按钮,他们将变得禁用并变灰,如下所示:

screenshot3.png

Window 的代码隐藏文件保持不变。其 XAML 文件的内容如下:

<Window 
  x:Class="VMCommanding.DemoWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:view="clr-namespace:VMCommanding.View"
  xmlns:vm="clr-namespace:VMCommanding.ViewModel"
  FontSize="13"
  ResizeMode="NoResize"
  SizeToContent="WidthAndHeight" 
  Title="ViewModel Commanding Demo"   
  WindowStartupLocation="CenterScreen"
  >
  <Window.DataContext>
    <vm:CommunityViewModel />
  </Window.DataContext>
  
  <Window.Content>
    <view:CommunityView />
  </Window.Content>
</Window>

显然,Window 中没有太多内容。现在,让我们将注意力转移到 Window 的内容:CommunityView 控件。这个用户控件知道如何渲染 CommunityViewModel 对象,该对象基本上是 PersonViewModel 对象的容器,以及一个允许您一举消灭所有人的命令。

我们稍后会检查 ViewModel 类,但现在,这是 CommunityView 控件。值得注意的是,CommunityViewPersonView 控件的代码隐藏文件中没有任何代码,除了调用标准的 InitializeComponent 的自动生成代码。

<UserControl 
  x:Class="VMCommanding.View.CommunityView"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:jas="clr-namespace:VMCommanding"
  xmlns:view="clr-namespace:VMCommanding.View"
  xmlns:vm="clr-namespace:VMCommanding.ViewModel"  
  jas:CommandSinkBinding.CommandSink="{Binding}"
  >  
  <UserControl.CommandBindings>
    <jas:CommandSinkBinding 
      Command="vm:CommunityViewModel.KillAllMembersCommand" />
  </UserControl.CommandBindings>
  
  <DockPanel Margin="4">
    <Button 
      DockPanel.Dock="Bottom"
      Command="vm:CommunityViewModel.KillAllMembersCommand"
      Content="Kill All"
      Margin="0,8,0,0"
      />
    <ItemsControl ItemsSource="{Binding People}">
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <view:PersonView />
        </DataTemplate>
      </ItemsControl.ItemTemplate>
    </ItemsControl>
  </DockPanel>
</UserControl>

该 XAML 中最相关部分是**粗体**。如您所见,该控件的 CommandBindings 包含一个 CommandSinkBinding,其 Command 属性引用了 CommunityViewModel 类的静态命令。Kill All 按钮也引用了该命令。请注意 UserControl 元素如何将 CommandSinkBindingCommandSink 附加属性设置为 {Binding}。这意味着它绑定到 CommunityView 控件的 DataContext,该 DataContext 是主 WindowDataContext 上设置的 CommunityViewModel 对象,如前所述。

社区中的每个 PersonViewModel 都在 ItemsControl 中渲染。该控件的 ItemsSource 绑定到 CommunityViewModel 对象(即数据上下文)的 People 属性。控件中的每个项由一个 DataTemplate 渲染,该模板发出一个 PersonView 控件。现在,让我们看看 PersonView 的 XAML 文件:

<UserControl 
  x:Class="VMCommanding.View.PersonView"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:jas="clr-namespace:VMCommanding"
  xmlns:vm="clr-namespace:VMCommanding.ViewModel"
  jas:CommandSinkBinding.CommandSink="{Binding}"
  >  
  <UserControl.CommandBindings>
    <jas:CommandSinkBinding Command="vm:PersonViewModel.DieCommand" />
    <jas:CommandSinkBinding Command="vm:PersonViewModel.SpeakCommand" />
  </UserControl.CommandBindings>
  
  <UserControl.Resources>
    <Style TargetType="{x:Type TextBlock}">
      <Setter Property="Margin" Value="0,0,6,0" />
      <Style.Triggers>
        <DataTrigger Binding="{Binding CanDie}" Value="False">
          <Setter Property="Foreground" Value="#88000000" />
        </DataTrigger>
      </Style.Triggers>
    </Style>
  </UserControl.Resources>
  
  <StackPanel Margin="2" Orientation="Horizontal">
    <TextBlock Text="Name:" FontWeight="Bold" />
    <TextBlock Text="{Binding Name}" Width="60" />
    <TextBlock Text="Age:" FontWeight="Bold" />
    <TextBlock Text="{Binding Age}" Width="40" />
    <Button 
      Command="vm:PersonViewModel.SpeakCommand"
      CommandParameter="Howdy partner!"
      Content="Speak"
      Margin="0,0,6,0"
      Width="60"
      />
    <Button
      Command="vm:PersonViewModel.DieCommand"
      Content="Die"
      Width="60"
      />
  </StackPanel>
</UserControl>

除了拥有更多的样式资源和视觉元素外,此控件与 CommunityView 控件基本相同。它在其 CommandBindings 集合中也有 CommandSinkBinding,它在其上设置了 CommandSink 附加属性,并且它包含的按钮的 Command 属性设置为 ViewModel 类的静态命令字段。当此控件中的命令执行时,它们将由 PersonViewModel 类中的逻辑处理,而不是 CommunityViewModel 类中的逻辑处理。考虑到这是 PersonViewModel 类的视图,这似乎很合理。

再次重申,值得注意的是,CommunityViewPersonView 控件的代码隐藏文件中没有任何代码,除了调用 InitializeComponent 的自动生成代码。主 Window 的代码隐藏文件也保持不变。如果您习惯于使用路由命令,这可能会同时显得奇怪和奇妙!

工作原理

现在是时候将注意力转向它是如何工作的了。您不必阅读本文的这一部分就可以使用我的解决方案,尽管我强烈建议您阅读以加深理解。

ICommandSink

首先,我们将检查 ICommandSink 接口。请记住,这是 ViewModel 类实现的接口;它由 CommandSink 类实现,并且是 CommandSinkBindingCommandSink 属性的类型。

/// <summary>
/// Represents an object that is capable of being notified of 
/// a routed command execution by a CommandSinkBinding. This
/// interface is intended to be implemented by a ViewModel class
/// that honors a set of routed commands.
/// </summary>
public interface ICommandSink
{
  bool CanExecuteCommand(ICommand command, object parameter, out bool handled);
  void ExecuteCommand(ICommand command, object parameter, out bool handled);
}

这类似于标准的 ICommand 接口签名,只是它旨在由可以处理多个命令的类来实现。我在两个方法中都包含了 handled 参数,因为我使用它来设置传递到 CommandSinkBinding 事件处理方法的事件参数的 Handled 属性。如果您已成功完成命令逻辑的执行,或者提供了执行状态,请务必将该参数设置为 true,因为它有助于在元素树很大且路由命令很多的情况下提高性能。

CommandSinkBinding

真正的核心在于 CommandSinkBinding 类。类声明和实例 CommandSink 属性如下:

/// <summary>
/// A CommandBinding subclass that will attach its
/// CanExecute and Executed events to the event handling
/// methods on the object referenced by its CommandSink property.
/// Set the attached CommandSink property on the element 
/// whose CommandBindings collection contain CommandSinkBindings.
/// If you dynamically create an instance of this class and add it 
/// to the CommandBindings of an element, you must explicitly set
/// its CommandSink property.
/// </summary>
public class CommandSinkBinding : CommandBinding
{
    ICommandSink _commandSink;

    public ICommandSink CommandSink
    {
        get { return _commandSink; }
        set
        {
            if (value == null)
                throw new ArgumentNullException("...");

            if (_commandSink != null)
                throw new InvalidOperationException("...");

            _commandSink = value;

            base.CanExecute += (s, e) =>
                {
                    bool handled;
                    e.CanExecute = _commandSink.CanExecuteCommand(
                      e.Command, e.Parameter, out handled);
                    e.Handled = handled;
                };

            base.Executed += (s, e) =>
                {
                    bool handled;
                    _commandSink.ExecuteCommand(
                      e.Command, e.Parameter, out handled);
                    e.Handled = handled;
                };
        }
    }

    // Other members omitted for clarity...
}

现在,我们将把注意力转向 CommandSinkBinding 的附加 CommandSink 属性。此属性使我们能够为多个 CommandSinkBinding 提供相同的命令接收器。

public static ICommandSink GetCommandSink(DependencyObject obj)
{
    return (ICommandSink)obj.GetValue(CommandSinkProperty);
}

public static void SetCommandSink(DependencyObject obj, ICommandSink value)
{
    obj.SetValue(CommandSinkProperty, value);
}

public static readonly DependencyProperty CommandSinkProperty =
    DependencyProperty.RegisterAttached(
    "CommandSink",
    typeof(ICommandSink),
    typeof(CommandSinkBinding),
    new UIPropertyMetadata(null, OnCommandSinkChanged));

static void OnCommandSinkChanged(
  DependencyObject depObj, DependencyPropertyChangedEventArgs e)
{
    ICommandSink commandSink = e.NewValue as ICommandSink;

    if (!ConfigureDelayedProcessing(depObj, commandSink))
        ProcessCommandSinkChanged(depObj, commandSink);
}

// This method is necessary when the CommandSink attached property 
// is set on an element in a template, or any other situation in 
// which the element's CommandBindings have not yet had a chance to be 
// created and added to its CommandBindings collection.
static bool ConfigureDelayedProcessing(DependencyObject depObj, ICommandSink sink)
{
    bool isDelayed = false;

    CommonElement elem = new CommonElement(depObj);
    if (elem.IsValid && !elem.IsLoaded)
    {
        RoutedEventHandler handler = null;
        handler = delegate
        {
            elem.Loaded -= handler;
            ProcessCommandSinkChanged(depObj, sink);
        };
        elem.Loaded += handler;
        isDelayed = true;
    }

    return isDelayed;
}

static void ProcessCommandSinkChanged(DependencyObject depObj, ICommandSink sink)
{
    CommandBindingCollection cmdBindings = GetCommandBindings(depObj);
    if (cmdBindings == null)
        throw new ArgumentException("...");

    foreach (CommandBinding cmdBinding in cmdBindings)
    {
        CommandSinkBinding csb = cmdBinding as CommandSinkBinding;
        if (csb != null && csb.CommandSink == null)
            csb.CommandSink = sink;
    }
}

static CommandBindingCollection GetCommandBindings(DependencyObject depObj)
{
    var elem = new CommonElement(depObj);
    return elem.IsValid ? elem.CommandBindings : null;
}

请注意,设置附加的 CommandSink 属性对元素只生效一次。您不能再次设置它,也不能将其设置为 null。如果您随后向该元素的 CommandBindings 添加 CommandSinkBinding,您将必须显式将该对象的 CommandSink 属性设置为 ICommandSink 对象。CommandBindingCollection 类不提供集合更改通知,因此我无法知道何时以及是否向集合添加了新项。

CommandSink

既然我们已经看到了解决方案的基本组成部分,现在让我们把注意力转向一个非常方便的类,名为 CommandSink,它实现了上面看到的 ICommandSink 接口。在我发布本文后不久,我收到一位才华横溢的Bill Kempf的建议,他提出了这个类。它提供了一种集中、可重用的方式来干净地实现使用此模式的 ViewModel 对象。它可以作为 ViewModel 对象的基类,或者,如果您已经有一个 ViewModel 基类,您可以将 CommandSink 的实例嵌入到 ViewModel 类中。我建议尽可能让您的 ViewModel 类派生自 CommandSink,因为它减少了您需要编写和维护的代码量。

这是 CommandSink 类的完整内容:

/// <summary>
/// This implementation of ICommandSink can serve as a base
/// class for a ViewModel or as an object embedded in a ViewModel.
/// It provides a means of registering commands and their callback 
/// methods, and will invoke those callbacks upon request.
/// </summary>
public class CommandSink : ICommandSink
{
    #region Data
    readonly Dictionary<ICommand, CommandCallbacks> 
       _commandToCallbacksMap = new Dictionary<ICommand, CommandCallbacks>();
    #endregion // Data

    #region Command Registration
    public void RegisterCommand(
      ICommand command, Predicate<object> canExecute, Action<object> execute)
    {
        VerifyArgument(command, "command");
        VerifyArgument(canExecute, "canExecute");
        VerifyArgument(execute, "execute");

        _commandToCallbacksMap[command] = 
            new CommandCallbacks(canExecute, execute);
    }

    public void UnregisterCommand(ICommand command)
    {
        VerifyArgument(command, "command");

        if (_commandToCallbacksMap.ContainsKey(command))
            _commandToCallbacksMap.Remove(command);
    }
    #endregion // Command Registration

    #region ICommandSink Members
    public virtual bool CanExecuteCommand(
      ICommand command, object parameter, out bool handled)
    {
        VerifyArgument(command, "command");
        if (_commandToCallbacksMap.ContainsKey(command))
        {
            handled = true;
            return _commandToCallbacksMap[command].CanExecute(parameter);
        }
        else
        {
            return (handled = false);
        }
    }

    public virtual void ExecuteCommand(
      ICommand command, object parameter, out bool handled)
    {
        VerifyArgument(command, "command");
        if (_commandToCallbacksMap.ContainsKey(command))
        {
            handled = true;
            _commandToCallbacksMap[command].Execute(parameter);
        }
        else
        {
            handled = false;
        }
    }
    #endregion // ICommandSink Members

    #region VerifyArgument
    static void VerifyArgument(object arg, string argName)
    {
        if (arg == null)
            throw new ArgumentNullException(argName);
    }
    #endregion // VerifyArgument

    #region CommandCallbacks [nested struct]
    private struct CommandCallbacks
    {
        public readonly Predicate<object> CanExecute;
        public readonly Action<object> Execute;

        public CommandCallbacks(Predicate<object> canExecute, 
                                Action<object> execute)
        {
            this.CanExecute = canExecute;
            this.Execute = execute;
        }
    }
    #endregion // CommandCallbacks [nested struct]
}

在 ViewModel 类中使用 CommandSink

最后,是时候看看如何在 ViewModel 类中使用 CommandSink 了。让我们看看 CommunityViewModel 类是如何定义的。请记住,这个类是分配给主 WindowDataContext 属性的 ViewModel 对象。

/// <summary>
/// A ViewModel class that exposes a collection of
/// PersonViewModel objects, and provides a routed
/// command that, when executed, kills the people.
/// This class derives from CommandSink, which is
/// why it does not directly implement the ICommandSink
/// interface. See PersonViewModel for an example
/// of implementing ICommandSink directly.
/// </summary>
public class CommunityViewModel : CommandSink
{
    public CommunityViewModel()
    {
        // Populate the community with some people.
        Person[] people = Person.GetPeople();

        IEnumerable<PersonViewModel> peopleView = 
          people.Select(p => new PersonViewModel(p));

        this.People = new ReadOnlyCollection<PersonViewModel>(peopleView.ToArray());

        // Register the command that kills all the people.
        base.RegisterCommand(
            KillAllMembersCommand,
            param => this.CanKillAllMembers,
            param => this.KillAllMembers());
    }

    public ReadOnlyCollection<PersonViewModel> People { get; private set; }

    public static readonly RoutedCommand KillAllMembersCommand = new RoutedCommand();

    public bool CanKillAllMembers
    {
        get { return this.People.Any(p => p.CanDie); }
    }

    public void KillAllMembers()
    {
        foreach (PersonViewModel personView in this.People)
            if (personView.CanDie)
                personView.Die();
    }
}

魔法发生在构造函数中。注意对 RegisterCommand 方法的调用,该方法由 CommandSink 基类定义。第一个参数是要注册的命令。第二个参数是 Predicate<object>,创建为 lambda 表达式,当查询命令是否可以执行时调用它。最后一个参数是 Action<object>,创建为 lambda 表达式,当命令执行时调用它。当然,lambda 表达式的使用是可选的。

现在,让我们看看 PersonViewModel 类是如何工作的。它是嵌入 CommandSink 的一个例子,而不是派生自它。由于 PersonViewModel 不派生自 CommandSink,因此它必须实现 ICommandSink 接口。

以下是 PersonViewModel 的构造函数:

readonly CommandSink _commandSink;

public PersonViewModel(Person person)
{
    _person = person;

    _commandSink = new CommandSink();

    _commandSink.RegisterCommand(
        DieCommand, 
        param => this.CanDie,
        param => this.Die());

    _commandSink.RegisterCommand(
        SpeakCommand,
        param => this.CanSpeak,
        param => this.Speak(param as string));
}

我将不显示该类的每个成员,因为它们对于本次审阅并不重要。但是,现在我们将看到 PersonViewModel 如何实现 ICommandSink 接口以及它如何使用 CommandSink 类的实例。

public bool CanExecuteCommand(ICommand command, object parameter, out bool handled)
{
    return _commandSink.CanExecuteCommand(command, parameter, out handled);
}

public void ExecuteCommand(ICommand command, object parameter, out bool handled)
{
    _commandSink.ExecuteCommand(command, parameter, out handled);
}

修订历史

  • 2008 年 7 月 26 日 - 对解决方案进行了多项改进,包括:采纳Bill Kempf的建议创建了 CommandSink 类,更新了 ViewModel 类以使用它,将 RelayCommandBinding 重命名为 CommandSinkBinding,并修复了 CommandSinkBinding 以便在模板中的元素上工作。更新了文章和源代码下载。
  • 2008 年 7 月 24 日 - 创建了本文。
© . All rights reserved.