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

在 ViewModel 中执行命令逻辑

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (54投票s)

2010年8月15日

CPOL

14分钟阅读

viewsIcon

317865

downloadIcon

2838

介绍 RoutedCommandBinding 类,这是一种在 MVVM 应用程序中直接从 XAML 调用 ViewModel 中方法的新方法。

引言

通过使用 Windows Presentation Foundation (WPF) 的高级数据绑定功能,用户界面的逻辑结构可以相对容易地与其表示分离。可绑定组件现在可以包含应用程序几乎所有的用户界面逻辑,并且在大多数情况下,可以取代经典的 Model-View-Controller (MVC) 模式中的控制器对象。因此,UI 设计的新模式出现也就不足为奇了。在 UI 设计的 Model-View-ViewModel (MVVM) 模式中,控制器已被 ViewModel 取代。

起初,似乎我们终于有了一种完全支持数据绑定(和模板化)的 Windows 表示技术,但当我们深入了解时,会发现 WPF 提供的功能与我们的需求之间存在差距。将表示层直接绑定到 ViewModel 的属性可以完美运行,但将用户输入绑定到 ViewModel 的方法则完全无效。许多开发人员通过利用 WPF 命令体系结构的功能,为解决此问题贡献了功能性解决方案。这些解决方案使用自定义命令对象作为一种手段,将 WPF 的属性绑定功能适配到 ViewModel 的方法。然而,这种方法牺牲了 WPF 命令体系结构提供的核心价值,并且要求我们在 ViewModel 中添加命令属性。这些命令属性除了作为 WPF 的 适配器外,没有其他价值。

本文介绍的 RoutedCommandBinding 类解决了这些问题。通过使用该类的特定实现 (DataContextCommandBinding),开发人员可以直接从 XAML 调用 ViewModel 中的方法,充分利用 WPF 命令体系结构,并且无需在 ViewModel 中使用命令对象。

下图展示了该方法的简单性。它说明了命令的逻辑路径

  1. Button 调用的命令
  2. DataContextCommandBinding 接收的命令
  3. 传递给 ViewModel 方法的命令

RoutedCommandBindingSample4.png

背景:理解 WPF 输入系统

WPF 在将开发人员与捕获用户输入的细节隔离开方面做得非常出色。基本上,设备输入由 WPF 输入子系统捕获,然后干净的输入事件会在我们的 WPF 元素上弹出。当这些事件发生时,有两种机制可用于调用应用程序行为:处理输入事件WPF 命令

这两种机制中更基础的一种是处理输入事件,它涉及将事件处理程序直接从代码隐藏文件附加到 UIElementContentElement 上的输入事件。这种事件处理对于简单场景有效,但在 MVVM 应用程序中会失效。要理解它为何失效,请想象一个大型数据录入屏幕,其中单击多个按钮中的任何一个或在任何控件上按 Enter 键都会导致整个屏幕保存。为了调用屏幕根 ViewModel 对象上的 Save 方法,我们需要为每种输入类型创建事件处理程序,例如 ClickMouseDownPreviewKeyDown。每个事件处理程序都需要通过向下转换正确的 UIElementContentElementDataContext 来获取指向根 ViewModel 对象的引用。然后才能调用保存逻辑。这绝对不是数据绑定,所以我们将继续介绍一种更有希望的处理输入事件的方法。

另一种输入机制是 WPF 命令,它实现了 命令模式。如果您不了解命令模式,建议您阅读相关内容,因为它对 UI 开发来说是一个非常有价值的概念。WPF 命令通过使用逻辑层次结构中的元素作为命令调用者,并将其他元素作为命令接收者来实现命令模式。继承自 RoutedCommand 类型的命令通过 RoutedEvents 沿着逻辑层次结构传播,直到找到命令接收者。然后,命令接收者通过事件处理程序调用应用程序逻辑。

任何 UIElementContentElement 都可以通过简单地将其 InputBinding 添加到其 InputBindings 集合中,并将绑定映射到输入手势和 RoutedCommand 来充当命令调用者。当在该元素上执行输入手势时,将调用 RoutedCommandInputBinding 类实现了 ICommandSource 接口,以指示它是命令调用者,尽管该接口对于命令的正常工作并非必需。一些内置控件直接实现 ICommandSource,以指示它们可以在发生许多特定输入事件中的任何一个时调用命令。您可以将其视为添加所有必需的 InputBindings 的快捷方式。Button 控件就是一个例子。命令可以直接附加到 Button 实例,并且所有鼠标单击、空格键按下、Enter 键按下等都将调用该命令。

不仅任何 UIElementContentElement 都可以充当命令源,而且它们还可以通过简单地将其 CommandBinding 添加到元素的 CommandBindings 集合中来充当命令接收者。CommandBindings 允许我们在代码隐藏文件中处理 RoutedCommands 并执行事件处理程序。尽管 CommandBindings 仍然需要我们使用代码隐藏文件,但使用 WPF 命令可以带来很多好处。它解决了许多与 ViewModel 绑定问题不直接相关的问题。要了解这些优势,我建议您阅读 Josh Smith 的博客文章:理解路由命令

那么,我们如何将表示层中的用户输入事件绑定到 ViewModel 的方法呢?

当前解决方案

大多数当前解决方案,例如 Josh Smith 的 RelayCommand 和 Prism 的 DelegateCommand,都利用了某些 WPF 控件的命令属性允许使用 RoutedCommand 以外的命令类型的这一事实。命令只需要实现 ICommand 接口。使用 RelayCommands 和 DelegateCommands 的建议方法是在 ViewModel 中添加命令属性,将命令映射到 ViewModel 中的方法,然后将 ViewModel 的命令属性绑定到 WPF 控件的命令属性。

尽管此方法确实使用了数据绑定,但存在两个主要陷阱。首先,我们无法将 ViewModel 命令属性绑定到 InputBindings 的命令属性。这是因为 InputBinding 不继承其所有者的 DataContext。存在一些使用静态资源的变通方法,但它们很麻烦。

RelayCommands 和 DelegateCommands 的第二个问题是,WPF 命令的真正有价值的功能未被使用;即,在使用这些类时,命令调用者和命令接收者之间没有分离。一些框架和应用程序试图通过在 ViewModel 中创建层次结构来解决此问题,以便命令模式可以在 ViewModel 内部使用。这种方法实际上只是重新发明了轮子,并为我们的 ViewModel 带来了很多负担。

但是,有一个当前解决方案可以与 WPF 命令体系结构协同工作,并且非常接近我们想要的东西。该解决方案是 Josh Smith 的 CommandSinkBinding 类。该类解决了主要问题,但要求 ViewModel 类实现一个特殊接口。通过此接口,ViewModel 必须监听它想要处理的命令。这似乎给 ViewModel 带来了很多负担,更重要的是,这会在 ViewModel 和 WPF 命令之间产生依赖关系。命令是 Windows 表示技术,而这正是我们应该在 ViewModel 中避免的依赖关系。那么,有什么更好的解决方案呢?

我的解决方案

当我们考虑将 WPF 命令与 MVVM 模式结合使用的所有因素时,我们会意识到遵循主流解决方案会付出过大的代价并带来过多的限制。为了让两者协同工作,我们实际上需要将 CommandBindings(而不是命令)绑定到 ViewModel 中的方法。

起初,这似乎很简单。只需继承 CommandBinding 类型并创建一个自定义实现来调用 DataContext 上的方法。尽管 CommandBinding 类不是密封的,我们可以轻松地继承它,但 Microsoft 没有为我们提供覆盖与 CommandBinding 相关的任何行为的能力。事实上,我们与 CommandBinding 相关的大部分行为实际上存在于 RoutedCommand 类内部的成员以及密封的 CommandManager 类中。CommandBindings 实际上只是其他不可访问代码可以作用的标记。我们根本无法扩展行为。因此,似乎要使用 RoutedCommands 和 ViewModel,我们被迫使用事件处理程序。不允许绑定……除非您考虑 RoutedCommands 如何使用 RoutedEvents。

基本上,RoutedCommand 会触发 CommandManager 类定义的几个 RoutedEvents,CommandManager 会监听这些事件(PreviewCanExecuteEventPreviewExecutedEventCanExecuteEventExecutedEvent)。当每个 RoutedEvent 沿着逻辑层次结构向上或向下传播时,CommandManager 会在 RoutedEvent 达到的每个 UIElementContentElement 上收到通知。每次 CommandManager 收到通知时,它都会检查当前元素的 CommandBindings 集合,如果找到与 RoutedCommand 匹配的 CommandBinding,它将执行分配给该 CommandBinding 的事件处理程序。我们问题的解决方案在于,我们可以有效地复制 CommandBindingCommandManager 类的行为。我们可以同样轻松地监听 WPF 事件系统以获取 RoutedCommand 事件,并查找我们自己的自定义 CommandBindings。我们的自定义 CommandBindings 然后可以用于调用 DataContext 对象的方法,而不是调用代码隐藏文件的事件处理程序。

RoutedCommandBinding

我提供的自定义 CommandBinding 类恰如其分地命名为 RoutedCommandBinding,它具有在命令事件发生时执行的方法。第二个类负责监听 WPF 事件系统,并命名为 RoutedCommandMonitor。该监视器类只是监听 WPF RoutedCommand 事件,并调用适当的 RoutedCommandBindings 上的相应事件处理方法。要理解这两个类之间的交互,请查看下面的序列图。它显示了如何处理 CanExecuteEvent 事件

RoutedCommandBinding1.png

序列非常直接。图中唯一不一定显而易见的是,事件参数是 CanExecuteRoutedEventArgs 类的实例,并且 RoutedCommandBinding 负责设置其 HandledCanExecute 属性。RoutedCommandBinding 类如下所示

public abstract class RoutedCommandBinding : CommandBinding
{
    public bool ViewHandledEvents { get; set; }

    public RoutedCommandBinding();

    public RoutedCommandBinding(ICommand command);

    protected internal abstract void OnPreviewCanExecute(
        object sender, CanExecuteRoutedEventArgs e);

    protected internal abstract void OnCanExecute(
        object sender, CanExecuteRoutedEventArgs e);

    protected internal abstract void OnPreviewExecuted(
        object sender, ExecutedRoutedEventArgs e);

    protected internal abstract void OnExecuted(
        object sender, ExecutedRoutedEventArgs e);
}

您首先可能会注意到该类被声明为 abstract,并且所有事件处理方法也都被声明为 abstract。这样做是因为创建 RoutedCommandBinding 类最初的根本原因是因为 WPF 的 CommandBinding 实现是不可扩展的。创建一个不可扩展的新实现将是短视的。因此,现在任何开发人员都可以创建 RoutedCommandBinding 类的新版本,以满足他们的需求。自定义 RoutedCommandBinding 类可以简单地添加到 UIElementContentElementCommandBindings 集合中,它就会起作用。因为根本问题是找到一种方法直接在 DataContext 上执行命令方法,所以需要一个具体的 RoutedCommandBinding 实现。但在我们开始之前,我们应该看看 RoutedCommandMonitor 类是如何工作的。

RoutedCommandMonitor

RoutedCommandMonitor 类之所以能够实现其功能,是因为 WPF EventManager 类提供了一个允许我们监听和处理 RoutedEvents 的方法。该方法名为 RegisterClassHandler,与 WPF 的 CommandManager 类用于处理 RoutedCommand 事件的 EventManager 方法相同。该方法的签名如下

public static void RegisterClassHandler(
    Type classType, RoutedEvent routedEvent, 
    Delegate handler, bool handledEventsToo);

我们所需要做的就是为 CommandManager 类定义的 RoutedCommand 事件注册委托,当这些事件中的任何一个发生时,我们的委托就会被调用。收到通知后,RoutedCommandMonitor 会查找当前元素的 CommandBindings 集合中任何 RoutedCommandBindings,如果找到匹配命令的,则执行 RoutedCommandBinding 上的正确方法。

此类的实现必须克服一个主要障碍,它围绕着获取 CommandBindings 的集合。本质上,我们无法访问 UIElements 和 ContentElements 的公共 CommandBindings 属性,因为该属性是延迟加载的。如果我们使用了该属性,那么整个应用程序中每个 UIElementContentElementCommandBindings 集合很快就会被实例化。这将是一个重大的资源使用问题。解决方案是访问 CommandManager 类用于获取 CommandBindings 的内部方法。该方法称为 GetCommandBindings,只能通过反射访问,而反射当然很慢。

为了解决这个问题,我们必须使用一个很多 .NET 开发者不知道的框架功能:开放实例委托(也称为无界委托)。很少有人知道该功能的原因是它只在 MSDN 上简要提及,而且从未提及过它与反射相比的性能优势。但是,有一些博客文章描述了它们的工作原理,例如 Simon Cooper 的最新文章:开放实例委托简介。我在这里不解释它们,它们在 RoutedCommandMonitor 类内部用于以高性能的方式执行 GetCommandBindings 方法。问题解决了!

DataContextCommandBinding

实际包含我们所需行为的类名为 DataContextCommandBinding,它只是 RoutedCommandBinding 类的一个实现,使用开放实例委托来执行 DataContext 上的方法。该类如下所示

public class DataContextCommandBinding : RoutedCommandBinding
{
    public new string CanExecute { get; set; }

    public new string Executed { get; set; }

    public new string PreviewCanExecute { get; set; }

    public new string PreviewExecuted { get; set; }

    public DataContextCommandBinding() { };

    public DataContextCommandBinding(ICommand command);

    protected internal override void OnPreviewCanExecute(
        object sender, CanExecuteRoutedEventArgs e);

    protected internal override void OnCanExecute(
        object sender, CanExecuteRoutedEventArgs e);

    protected internal override void OnPreviewExecuted(
        object sender, ExecutedRoutedEventArgs e);

    protected internal override void OnExecuted(
        object sender, ExecutedRoutedEventArgs e);
}

XAML 中使用它的示例如下所示

<Window
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:cmd="clr-namespace:RoutedCommandBindingSample.Commands">

    <Window.CommandBindings>
        <cmd:DataContextCommandBinding
            Command="ApplicationCommands.Close" 
            Executed="Close" CanExecute="CanClose" />
    </Window.CommandBindings>

    <Button Content="Close" Command="ApplicationCommands.Close"/>

</Window>

这个类有趣的地方在于(除了它如何执行 DataContext 上的方法之外),CommandBinding 类的事件声明被字符串属性所覆盖(而不是重载),这些字符串属性用于存储要执行的 DataContext 方法的名称。当内置的 WPF CommandManager 访问 DataContextCommandBinding 实例时,它是作为 CommandBinding 访问的,因此会使用 CommandBinding 类定义的事件。这些事件将没有附加任何处理程序,结果是 CommandManager 对它们不做任何操作。当 DataContextCommandBinding 访问其自身的成员时,它会使用字符串属性。事件以这种方式被覆盖,以便在 XAML 中实例化普通 CommandBinding 和实例化 DataContextCommandBinding 的语义之间几乎没有区别。

一旦 RoutedCommandMonitor 执行了 DataContextCommandBinding 的任何命令方法,就会动态调用 DataContext 上的相应方法。命令方法的允许签名如下所示

bool MyCanExecuteMethod();
bool MyCanExecuteMethod(object parameter);
void MyExecutedMethod();
void MyExecutedMethod(object parameter);

CommandExecutionManager

内部,DataContextCommandBinding 类使用另一个类来实际执行 DataContext 的方法。它将动态执行逻辑(相当复杂)推迟,以便其他类和 RoutedCommandBinding 的实现可以重用它。执行逻辑之所以复杂,是因为使用了开放实例委托来调用 DataContext 的方法,并且为了性能原因对它们进行了缓存。有了 CommandExecutionManager 类,我们现在可以查看由 DataContext 对象处理的 RoutedCommand 事件的完整序列图。下面是如何处理 ExecutedEvent 事件

RoutedCommandBinding2.png

奖励:DataContextCommandAdapter

我所描述的类展示了如何从 CommandBinding 直接绑定到 DataContext。但是,如果您真正想做的只是从按钮或 InputBinding 调用 ViewModel 上的方法呢?好吧,我提供了一个标记扩展来实现这一点,并使用 CommandExecutionManager 来实现。该标记扩展名为 DataContextCommandAdapter,下面是其用法示例

<Window
     xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
     xmlns:cmd="clr-namespace:RoutedCommandBindingSample.Commands">
  <Button Content="Close" 
     Command="{cmd:DataContextCommandAdapter Close, CanClose}/>
</Window>

正如您所见,它足够简单易用,并且用法与 DataContextCommandBinding 非常相似,只是参数没有命名(这是向标记扩展提供参数方式的副作用)。第一个参数是要处理 Executed 事件的方法的名称,第二个可选参数是要处理 CanExecute 事件的方法的名称。这些方法可以具有 DataContextCommandBinding 类允许的相同签名。使用此扩展有一个主要的限制;当使用 .NET 3.5 Framework 时,它在 InputBindingCommand 属性上使用时将不起作用。但在 .NET 4.0 框架中则可以正常工作。因此,我提供了两个示例解决方案,一个用于 VS 2010 和 .NET 4.0,另一个用于 VS 2008 和 .NET 3.5。

就是这样。附加的解决方案提供了此处描述的类的代码,以及一个展示其用法的简单媒体播放器应用程序。

参考文献

修订历史

  • 2010 年 8 月 15 日 - 创建文章。
  • 2010 年 8 月 16 日 - 在引言中添加了插图,并修复了拼写错误。
  • 2010 年 8 月 17 日 - 更新了项目以允许提供空的 CanExecute 值。
  • 2010 年 8 月 20 日 - 修复了小的拼写错误。
  • 2010 年 9 月 23 日 - 更新了代码以处理断开连接的 DataContext 对象。
  • 2011 年 3 月 10 日 - 应用了与断开连接的 DataContext 对象相关的其他修复。
© . All rights reserved.