WPF/MVVM系列(5)——命令

绑定和命令是MVVM模式的核心,也是实际MVVM工程中应用最多的技术点。

与路由事件的关系

在深入了解WPF的命令之前,有必要澄清它和路由事件的关系:

  • 路由事件提供了一种灵活的事件传递机制,主要用于处理界面状态的变化。
  • 命令主要用于封装业务逻辑,从而保证代码复用性。
  • 路由事件和命令是出发点不同的两套机制,不是相互替换的关系。
  • 在大型软件系统中,通常需要同时使用路由事件和命令来协作完成某些复杂功能。

使用RoutedCommand

在WPF中,按钮控件有Command属性,设置这个属性就可以为按钮绑定命令。

一种方式是使用RoutedCommand类,这是标准库提供的实现了ICommand接口的命令类,但是它没有实现具体的CanExecute()和Executed()逻辑,需要通过命令关联的方式添加这两个逻辑。

(1)以下窗体中有一个按钮btn1和一个文本框tb1。当文本框内的内容为空时,按钮不可用;否则可用,且单击按钮时文本框里的内容会被清空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Window x:Class="TestMVVM01.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TestMVVM01"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
mc:Ignorable="d"
SizeToContent="WidthAndHeight"
Title="MainWindow">

<StackPanel Orientation="Vertical" x:Name="sp1">
<StackPanel Orientation="Vertical" x:Name="sp2">
<Button x:Name="btn1" Content="Send Command" Margin="5"/>
<TextBox x:Name="tb1" Margin="5,0" Height="100"/>
</StackPanel>
</StackPanel>
</Window>

(2)后台代码通过CommandBinding为该命令设置CanExecute()和Executed()逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using System;
using System.Windows;
using System.Windows.Input;

namespace TestMVVM01 {
public partial class MainWindow : Window {
public MainWindow() {
InitializeComponent();
// 初始化命令绑定
InitializeCommand();
}

private RoutedCommand clearCmd = new RoutedCommand("Clear", typeof(MainWindow));

private void InitializeCommand() {
this.clearCmd.InputGestures.Add(new KeyGesture(Key.C, ModifierKeys.Alt));

// 为按钮绑定命令
this.btn1.Command = clearCmd;
this.btn1.CommandTarget = this.tb1;

// 使用命令绑定指定CanExecute()和Executed()的具体逻辑
CommandBinding binding = new CommandBinding();
binding.Command = this.clearCmd;
binding.CanExecute += new CanExecuteRoutedEventHandler(cb_CanExecute);
binding.Executed += new ExecutedRoutedEventHandler(cb_Executed);
// 将命令绑定添加到布局组件上
this.sp1.CommandBindings.Add(binding);
}

private void cb_Executed(object sender, ExecutedRoutedEventArgs e) {
this.tb1.Clear();
e.Handled = true;
}

private void cb_CanExecute(object sender, CanExecuteRoutedEventArgs e) {
if (String.IsNullOrEmpty(this.tb1.Text))
e.CanExecute = false;
else
e.CanExecute = true;

e.Handled = true;
}
}
}

当然,也可以在XAML中定义,以下使用WPF内置命令New,用户指定该命令的CanExecute()和Executed()逻辑。大多数内置命令是静态类的成员,需要实现个性化操作时通过传参实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<Window x:Class="TestMVVM01.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TestMVVM01"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
mc:Ignorable="d"
Height="240"
Width="360"
WindowStyle="ToolWindow"
Background="LightBlue"
Title="Command Parameter">

<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="24"/>
<RowDefinition Height="4"/>
<RowDefinition Height="24"/>
<RowDefinition Height="4"/>
<RowDefinition Height="24"/>
<RowDefinition Height="4"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>

<TextBlock Text="Name:" VerticalAlignment="Center" HorizontalAlignment="Left" Grid.Row="0"/>
<TextBox x:Name="nameTextBox" Margin="60,0,0,0" Grid.Row="0"/>
<Button Content="New Teacher" Command="New" CommandParameter="Teacher" Grid.Row="2"/>
<Button Content="New Student" Command="New" CommandParameter="Student" Grid.Row="4"/>
<ListBox x:Name="listBoxItems" Grid.Row="6"/>
</Grid>

<Window.CommandBindings>
<CommandBinding Command="New" CanExecute="CommandBinding_CanExecute"
Executed="CommandBinding_Executed"/>
</Window.CommandBindings>
</Window>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public partial class MainWindow : Window {
public MainWindow() {
InitializeComponent();
}

private void CommandBinding_CanExecute(object sender, CanExecuteRoutedEventArgs e) {
if (string.IsNullOrEmpty(this.nameTextBox.Text))
e.CanExecute = false;
else
e.CanExecute = true;
}

private void CommandBinding_Executed(object sender, ExecutedRoutedEventArgs e) {
string name = this.nameTextBox.Text;
if (e.Parameter.ToString() == "Teacher")
this.listBoxItems.Items.Add($"New Teacher: {name}, 学而不厌、诲人不倦。");
if (e.Parameter.ToString() == "Student")
this.listBoxItems.Items.Add($"New Student: {name}, 好好学习、天天向上。");
}
}

以上方式需要在代码中集中指定命令的CanExecute()和Executed()逻辑,每个命令不是相对独立的个体,与“高内聚”的软件设计思想有所违背,不是推荐用法。

自定义命令类

回归命令机制的初衷,开发者不希望在窗体的事件处理函数中写业务逻辑,更倾向于将每个业务逻辑写成普通函数,然后包装成命令供界面使用。我认为以下方式是比较好的工程做法。

(1)自定义以下命令类,通过构造函数传入实际的Execute()和CanExecute()执行逻辑,开发者可显式调用RaiseCanExecuteChange()函数,从而触发CanExecute()的执行来更新控件的可用状态(一般在窗体控件事件中触发)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 自定义命令类
public class RelayCommand : ICommand
{
private Action<object> execute;
private Func<object, bool> canExecute;

public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
{
this.execute = execute;
this.canExecute = canExecute;
}

public event EventHandler CanExecuteChanged;

public bool CanExecute(object parameter)
{
if (canExecute != null)
return canExecute(parameter);
return true;
}

public void Execute(object parameter)
{
execute?.Invoke(parameter);
}

public void RaiseCanExecuteChange()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}

(2)在ViewModel类中定义命令,并包装成可读可写属性供界面绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ViewModel
{
#region 命令定义
private RelayCommand _showMessageCommand;
public RelayCommand ShowMessageCommand => _showMessageCommand ?? (_showMessageCommand = new RelayCommand(ShowMessage));

private RelayCommand _modifyMessageCommand;
public RelayCommand ModifyMessageCommand => _modifyMessageCommand ?? (_modifyMessageCommand = new RelayCommand(ModifyMessage));
#endregion


#region 业务逻辑
public void ShowMessage(object parameter)
{
MessageBox.Show(Name);
}

public void ModifyMessage(object parameter)
{
Name = "xiaoming";
}
#endregion
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Window x:Class="TestMvvm.Views.Test"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TestMvvm.Views"
mc:Ignorable="d"
x:Name="myWin"
SizeChanged="myWin_SizeChanged"
Title="Test" SizeToContent="WidthAndHeight">

<StackPanel Orientation="Vertical">
<Button x:Name="btn1" Content="按钮1" Command="{Binding Command1}"/>
</StackPanel>
</Window>

以上代码有几个关键点:

① 给命令传参:在XAML中通过绑定将窗体作为参数传递给命令,CanExecute()和Execute()都能捕获这个参数。

② 更新按钮的可用状态:本实例在窗体宽度大于300时设置按钮可见,点击按钮后消息框显示窗体的宽度。为了更新按钮状态,必须在窗体尺寸变化事件处理器中调用RaiseCanExecuteChange()函数来间接触发CanExecute()函数;不直接调用CanExecute()函数,是因为开发者没有办法显式向其传递参数。

为控件事件绑定命令

某些场景下,我们想给控件的某一事件绑定一个命令,这时可以通过Microsoft.Xaml.Behaviors.Wpf库实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Window x:Class="MVVMTest.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:MVVMTest"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors">

<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<i:InvokeCommandAction Command="{Binding WindowLoadedCommand}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
</Window>

评论