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

学习WPF的命令机制,我认为最重要的是能够找到一种在MVVM模式中使用它的最佳工程实践方式。

绑定和命令是MVVM模式的核心,后面介绍MVVM模式基本是顺水推舟了。

与路由事件的关系

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

(1)路由事件提供了一种灵活的事件传递机制,主要用于处理界面状态的变化;

(2)命令主要用于封装业务逻辑,从而保证代码复用性。

(3)路由事件和命令是出发点不同的两套机制,不是相互替换的关系。

(4)在大型软件系统中,通常需要同时使用路由事件和命令来协作完成某些复杂功能。

使用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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<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>
/// MainWindow.xaml 的交互逻辑
/// </summary>
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()逻辑,每个命令不是相对独立的个体,与“高内聚”的软件设计思想有所违背,不是推荐用法。

为控件事件绑定命令

某些场景下,我们希望给控件的某一事件绑定一个命令,这时可以使用System.Windows.Interactivity.WPF库实现。

较好的工程实践

回归命令机制的初衷,开发者不希望在窗体的事件处理函数中写业务逻辑,更倾向于将每个业务逻辑写成普通函数,然后包装成命令供界面使用。

那么,如何将业务处理逻辑包装成命令呢?我认为以下方式是比较好的工程做法。

(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
public class MyCommand : ICommand {
private Action<object> execute;
private Func<object, bool> canExecute;

public MyCommand(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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestViewModel : NotifyPropertyChanged {
private MyCommand _command1;
public MyCommand Command1 {
get {
return _command1;
}
set {
_command1 = value;
RaisePropertyChanged();
}
}

public TestViewModel() {
_command1 = new MyCommand(ShowMessage, SetEnbale);
}

#region 业务处理逻辑,程序的核心
private void ShowMessage(object parameter) {
var para = parameter as Window;
if (para != null)
MessageBox.Show($"窗体宽度为:{para.Width}");
}

private bool SetEnbale(object parameter) {
var para = parameter as Window;
if (para != null && para.Width > 300)
return true;
return false;
}
#endregion
}
<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}"
CommandParameter="{Binding ElementName=myWin}"/>
</StackPanel>
</Window>

以上代码有几个关键点:

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

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

评论