🧭 WPF MVVM入门系列教程
- 一、MVVM模式介绍
- 二、依赖属性
- 三、数据绑定
- 四、ViewModel
- 五、命令和用户输入
- 六、ViewModel案例演示
WPF中的命令模型
在WPF中,我们可以使用事件来响应鼠标和键盘动作。
但使用事件会具备一定的局限性,例如:我想通过键盘快捷键触发事件、或者在某个时刻禁用事件。
如果使用代码去编写这些控制逻辑,会变得非常枯燥。因此WPF提供了命令模型
。
命令具有多个用途。
第一个用途是分隔语义和从执行命令的逻辑调用命令的对象。
这可使多个不同的源调用同一命令逻辑,并且可针对不同目标自定义命令逻辑。
例如,许多应用程序中均有的编辑操作“复制”、“剪切”和“粘贴”若通过使用命令来实现,那么可通过使用不同的用户操作来调用它们。
应用程序可允许用户通过单击按钮、选择菜单中的项或使用组合键(例如 Ctrl+X)来剪切所选对象或文本。
通过使用命令,可将每种类型的用户操作绑定到相同逻辑。
命令的另一用途是指示操作是否可用。
继续以剪切对象或文本为例,此操作只有在选择了内容时才会发生作用。
如果用户在未选择任何内容的情况下尝试剪切对象或文本,则不会发生任何操作。
为了向用户指示这一点,许多应用程序通过禁用按钮和菜单项来告知用户是否可以执行某操作。
命令可以通过实现CanExecute
方法来指示操作是否可行。 按钮可以订阅 CanExecuteChanged
事件,如果CanExecute
返回 false
则禁用,如果CanExecute
返回 true
则启用。
通俗点来说,命令模型就是事件的“升级版本”,
它可以让多个不同的源调用同一个逻辑
例如我一有个打印功能,我们将它封装成PrintDocument
,当在菜单选择时
、按钮点击时
、快捷键按下时
,我们都去执行这个功能。
它还可以控制这个功能是否可以被执行,例如,我当前未选中要打印的文档,我设置CanExecute
方法返回false
,打印功能是无法被执行的。当选中了要打印的文档,设置CanExecute
方法返回true
,这时候,打印功能又可以被执行了。
WPF 命令中的四个主要概念
WPF中的命令模型可分解为四个主要概念:命令、命令源、命令目标和命令绑定:
-
命令
:要执行的操作。 -
命令源
:调用命令的对象。 -
命令目标
:在其上执行命令的对象。 -
命令绑定
:将命令逻辑映射到命令的对象。
命令
命令表示应用程序任务,并且跟踪任务是否能够被执行。然而,命令实际上不包含执行应用程序任务的代码。
命令绑定
每个命令绑定针对用户界面的具体区域,将命令连接到相关的应用程序逻辑。这种分解的设计是非常重要的,因为单个命令可用于应用程序中的多个地方,并且在每个地方具有不同的意义。为处理这一问题,需要将同一命令与不同的命令绑定。
命令源
命令源触发命令。例如,Menultem
和 Button
都是命令源。单击它们都会执行绑定命令。
命令目标
命令目标是在其中执行命令的元素。例如,Paste命令可在TextBox控件中插入文本,而OpenFile命令
可在 DocumentViewer
中打开文档。根据命令的本质,目标可能很重要,也可能不重要。
注意:如果对于这些基础概念理解起来有困难,可以先暂时跳过,直接学习后面的部分。等掌握以后,再回头来看这些基础概念。
在MVVM中使用命令的快速示例
在前面的文章中,我们学习了数据绑定,可以在DataContext
中,取到界面上的值。如果我们需要在DataContext(ViewModel层)
去响应控件的事件,就需要用到Command
。
假设有如下界面,我们想在点击按钮后,弹框输出文本框的值。
1 <StackPanel> 2 <Label Content="输入"></Label> 3 <TextBox Name="tbox"></TextBox> 4 5 <Button Content="获取输入"></Button> 6 </StackPanel>
在WPF的基于事件模式的开发中,一般会响应按钮的Click事件
1 <Button Content="获取输入" Click="Button_Click"></Button>
然后在后台代码中对事件进行处理
1 private void Button_Click(object sender, RoutedEventArgs e) 2 { 3 MessageBox.Show(this.tbox.Text); 4 }
在MVVM模式开发中,我们会直接绑定到一个命令
1 <StackPanel> 2 <Label Content="输入"></Label> 3 <TextBox Text="{Binding InputText}"></TextBox> 4 5 <Button Content="获取输入" Command="{Binding GetInputCommand}"></Button> 6 </StackPanel>
在ViewModel
中对命令进行处理
1 public class MainWindowViewModel 2 {3 public ICommand GetInputCommand { get; private set; }4 5 public MainWindowViewModel()6 {7 GetInputCommand = new RelayCommand(GetInput);8 }9 10 public void GetInput() 11 { 12 MessageBox.Show(InputText); 13 } 14 }
运行效果
使用MVVM模式
进行开发时,从View层
到ViewModel层
获取用户输入是MVVM开发的核心知识点之一。
接下来我们会详细介绍这一点。首先我们需要了解在MVVM中如何自定义命令。
ICommand接口
WPF命令模型的核心是System.Windows.Input.ICommand
接口,该接口定义了命令的工作原理。
定义如下:
它包含了两个方法和一个事件
1 //2 // 摘要: 3 // 定义一个命令4 public interface ICommand5 {6 //7 // 摘要:8 // 当命令执行条件发生更改时触发9 event EventHandler? CanExecuteChanged; 10 11 12 // 13 // 摘要: 14 // 定义确定命令是否可以在其当前状态下执行的方法。 15 // 16 // 参数: 17 // parameter: 18 // 命令使用的数据。如果命令不需要传递数据,可为空 19 // 20 // 返回结果: 21 // true - 命令能被执行 false-命令不能被执行 22 // 23 bool CanExecute(object? parameter); 24 25 // 摘要: 26 // 定义调用命令时要调用的方法。 27 // 28 // 参数: 29 // parameter: 30 // 31 // 命令使用的数据。如果命令不需要传递数据,则可以将此对象设置为null。 32 void Execute(object? parameter); 33 }
以我们前面的GetInputCommand
逻辑为为例,
Execute()方法
将包含弹出文本框文本的逻辑。
CanExecute()方法
返回命令的状态-如果命令可用,就返回true;如果不可用,就返回False。
Execute
和CanExecute方法
都接受一个附加的参数对象,可以使用该对象传递所需要的任何附加信息。
当命令状态改变时引发CanExecuteChanged事件
。对于使用命令的任何控件,这是指示信号,
表示它们应当调用CanExecute()方法
检查命令的状态。
通过使用该事件,当命令可用时,命令源(如 Button
或 Menultem
)可自动启用自身;当命令不可用时,禁用自身。
RelayCommand
在MVVM模式中使用命令时,我们需要自定义命令类RelayCommand
,该类实例了ICommand
接口。
定义如下
1 /// <summary>2 /// 一种命令,其唯一目的是通过调用委托将其功能传递给其他对象。3 /// CanExecute方法的默认返回值为"true"。4 /// 此类不允许在Execute和CanExecute回调方法中接受命令参数。5 /// 目前只用于演示,所以不增加支持传递参数的版本6 /// 正式使用时,会使用Prism/CommunityToolkit.MVVM等包7 /// </summary>8 public class RelayCommand : ICommand9 { 10 /// <summary> 11 /// 命令绑定的回调 12 /// </summary> 13 private readonly Action _execute; 14 15 /// <summary> 16 /// 命令是否可以被执行绑定的回调 17 /// </summary> 18 private readonly Func<bool> _canExecute; 19 20 /// <summary> 21 /// 当命令执行条件发生更改时触发 22 /// </summary> 23 public event EventHandler CanExecuteChanged 24 { 25 add 26 { 27 if (_canExecute != null) 28 { 29 CommandManager.RequerySuggested += value; 30 } 31 } 32 remove 33 { 34 if (_canExecute != null) 35 { 36 CommandManager.RequerySuggested -= value; 37 } 38 } 39 } 40 41 /// <summary> 42 /// 实例化RelayCommand 43 /// </summary> 44 /// <param name="execute"></param> 45 public RelayCommand(Action execute) 46 : this(execute, null) 47 { 48 } 49 50 /// <summary> 51 /// 实例化RelayCommand 52 /// </summary> 53 /// <param name="execute">命令绑定的回调</param> 54 /// <param name="canExecute">命令是否可以被执行的回调</param> 55 public RelayCommand(Action execute, Func<bool> canExecute) 56 { 57 if (execute == null) 58 { 59 throw new ArgumentNullException("execute"); 60 } 61 62 _execute = execute; 63 _canExecute = canExecute; 64 } 65 66 /// <summary> 67 /// 触发CanExecuteChanged事件. 68 /// </summary> 69 public void RaiseCanExecuteChanged() 70 { 71 CommandManager.InvalidateRequerySuggested(); 72 } 73 74 /// <summary> 75 /// 定义确定命令是否可以在其当前状态下执行的方法。 76 /// </summary> 77 /// <param name="parameter"></param> 78 /// <returns></returns> 79 public bool CanExecute(object parameter) 80 { 81 return _canExecute == null || _canExecute(); 82 } 83 84 /// <summary> 85 /// 定义调用命令时要调用的方法。 86 /// </summary> 87 /// <param name="parameter"></param> 88 public void Execute(object parameter) 89 { 90 _execute(); 91 } 92 }
在类的内部我们定义了两个委托: Action _execute
和Func _canExecute
,并通过构造函数传递,_execute
在命令被执行时调用,_canExecute
在判断命令是否可以被执行时调用。
使用RelayCommand
我们在界面上放置一个按钮,并绑定GetTimeCommand
MainWindow.xaml
1 <Window x:Class="UseRelayCommand.MainWindow" 2 xmlns:local="clr-namespace:UseRelayCommand" 3 mc:Ignorable="d" 4 Title="MainWindow" Height="450" Width="800"> 5 <StackPanel> 6 <Button Content="获取当前时间" Command="{Binding GetTimeCommand}"></Button> 7 </StackPanel> 8 </Window>
创建ViewModel
MainWindowViewModel.cs
1 public class MainWindowViewModel2 {3 /// <summary>4 /// 获取时间5 /// </summary>6 public ICommand GetTimeCommand { get; private set; }7 8 public MainWindowViewModel()9 { 10 GetTimeCommand = new RelayCommand(GetTime); 11 } 12 13 private void GetTime() 14 { 15 MessageBox.Show(DateTime.Now.ToString()); 16 } 17 }
将ViewModel绑定到DataContext
1 public partial class MainWindow : Window 2 { 3 public MainWindow() 4 { 5 InitializeComponent(); 6 this.DataContext = new MainWindowViewModel(); 7 } 8 }
运行后,点击按钮,就可以看到消息框显示当前时间。
我们将这个例子进行升级,再增加一个复选框,只有界面钩选了复选框,才能执行GetTimeCommand
。
在创建GetTimeCommand
时,我们传递一个Func类型
的回调,而这个回调就是返回界面上复选框绑定的值。
MainWindow.xaml
1 <Window x:Class="UseRelayCommandCanExecute.MainWindow" 2 mc:Ignorable="d" 3 Title="MainWindow" Height="450" Width="800"> 4 <StackPanel> 5 <CheckBox Content="是否允许获取时间" IsChecked="{Binding CanGetTime}"></CheckBox> 6 <Button Content="获取当前时间" Command="{Binding GetTimeCommand}"></Button> 7 </StackPanel> 8 </Window>
MainWindowViewModel
1 public class MainWindowViewModel : INotifyPropertyChanged2 {3 private bool canGetTime;4 5 public bool CanGetTime6 {7 get => canGetTime;8 set9 { 10 canGetTime = value; 11 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("CanGetTime")); 12 } 13 } 14 15 /// <summary> 16 /// 获取时间 17 /// </summary> 18 public ICommand GetTimeCommand { get; private set; } 19 20 public MainWindowViewModel() 21 { 22 GetTimeCommand = new RelayCommand(GetTime, CanGetTimeExecute); 23 } 24 25 /// <summary> 26 /// GetTimeCommand是否可以被执行的回调函数 27 /// </summary> 28 /// <returns></returns> 29 public bool CanGetTimeExecute() 30 { 31 //返回CanGetTime变量,该变量绑定到界面上的CheckBox 32 return CanGetTime; 33 } 34 35 public event PropertyChangedEventHandler PropertyChanged; 36 37 private void GetTime() 38 { 39 MessageBox.Show(DateTime.Now.ToString()); 40 } 41 }
运行效果如下:
使用CommunityToolkit.MVVM包中的命令
在前面的示例中,我们自己封装了一个RelayCommand
,在正式场景中,一般还是推荐使用三方MVVM包中带的命令。
本文以CommunityToolkit.MVVM包
中的RelayCommand
进行演示。
首先我们看一下这个包里的RelayCommand
是如何封装的
跟前面的写法基本差不多,因为这里最核心的还是ICommand
接口。
1 public sealed class RelayCommand : IRelayCommand, ICommand2 {3 4 private readonly Action execute;5 6 private readonly Func<bool>? canExecute;7 8 public event EventHandler? CanExecuteChanged;9 10 public RelayCommand(Action execute) 11 { 12 ArgumentNullException.ThrowIfNull(execute, "execute"); 13 this.execute = execute; 14 } 15 16 17 public RelayCommand(Action execute, Func<bool> canExecute) 18 { 19 ArgumentNullException.ThrowIfNull(execute, "execute"); 20 ArgumentNullException.ThrowIfNull(canExecute, "canExecute"); 21 this.execute = execute; 22 this.canExecute = canExecute; 23 } 24 25 public void NotifyCanExecuteChanged() 26 { 27 this.CanExecuteChanged?.Invoke(this, EventArgs.Empty); 28 } 29 30 [MethodImpl(MethodImplOptions.AggressiveInlining)] 31 public bool CanExecute(object? parameter) 32 { 33 return canExecute?.Invoke() ?? true; 34 } 35 36 public void Execute(object? parameter) 37 { 38 execute(); 39 } 40 }
使用方法跟前面自己封装的RelayCommand也是一样的。
MainWindow.xaml
1 <Window x:Class="UseRelayCommand.MainWindow" 2 Title="MainWindow" Height="450" Width="800"> 3 <StackPanel> 4 <Button Content="获取当前时间" Command="{Binding GetTimeCommand}"></Button> 5 </StackPanel> 6 </Window>
MainWindowViewModel.cs
1 public class MainWindowViewModel2 {3 /// <summary>4 /// 获取时间5 /// </summary>6 public ICommand GetTimeCommand { get; private set; }7 8 public MainWindowViewModel()9 { 10 GetTimeCommand = new RelayCommand(GetTime); 11 } 12 13 private void GetTime() 14 { 15 MessageBox.Show(DateTime.Now.ToString()); 16 } 17 }
绑定数据上下文
1 public partial class MainWindow : Window 2 { 3 public MainWindow() 4 { 5 InitializeComponent(); 6 this.DataContext = new MainWindowViewModel(); 7 } 8 }
运行效果
传递命令参数
在前面我们自己封装RelayCommand
时,只提供了基础的命令功能,并不具备参数传递的功能。
在很多场景下,我们需要将参数传递到命令里。所以我们需要一个带参数的泛型RelayCommand
版本。这个泛型就是我们要传递的参数。
这里就不自行封装了,我们直接使用CommunityToolkit.MVVM包
中的RelayCommand
版本。感兴趣的小伙伴可以访问以下链接阅读源码:
dotnet/src/CommunityToolkit.Mvvm/Input/RelayCommand{T}.cs at main · CommunityToolkit/dotnet · GitHub
在使用RelayCommand
命令时,我们可以根据需要传递的参数类型,使用对应的泛型参数。同时,命令绑定的回调函数也需要传递对应类型的参数。
例如我想传递一个string类型:
1 RelayCommand<string> MyCommand {get;set;}
它绑定的回调函数也需要增加string类型的参数
1 void MyFunction(string parameter) 2 { 3 4 }
下面我们演示一下如何向命令中传递参数。
我们在界面上放置3个按钮,分别设置为按钮1、2、3
。
然后这三个按钮都绑定到ShowMessageCommand
,并通过CommandParameter
属性将按参数传递到ShowMessageCommand
。
MainWindow.xaml
1 <Window x:Class="PassParameterToCommand.MainWindow"2 mc:Ignorable="d"3 Title="MainWindow" Height="450" Width="800">4 <StackPanel Orientation="Horizontal">5 <!--可以直接指定命令参数-->6 <Button Content="按钮1" Command="{Binding ShowMessageCommand}" CommandParameter="按钮1" VerticalAlignment="Center" Width="128" Height="28" Margin="10"></Button>7 <!--也可以绑定自身-->8 <Button Content="按钮2" Command="{Binding ShowMessageCommand}" CommandParameter="{Binding RelativeSource={RelativeSource Mode=Self},Path=Content}" VerticalAlignment="Center" Width="128" Height="28" Margin="10"></Button>9 <!--也可以绑定到指定控件的指定属性--> 10 <Button Content="按钮3" Name="btn3" Command="{Binding ShowMessageCommand}" CommandParameter="{Binding ElementName=btn3,Path=Content}" VerticalAlignment="Center" Width="128" Height="28" Margin="10"></Button> 11 </StackPanel> 12 </Window>
MainWindowViewModel
1 public class MainWindowViewModel : ObservableObject2 {3 public RelayCommand<string> ShowMessageCommand { get; set; }4 5 public MainWindowViewModel()6 {7 ShowMessageCommand = new RelayCommand<string>(ShowMessage);8 }9 10 private void ShowMessage(string? obj) 11 { 12 MessageBox.Show("消息来自-" + obj); 13 } 14 }
绑定到数据上下文
1 public partial class MainWindow : Window 2 { 3 public MainWindow() 4 { 5 InitializeComponent(); 6 this.DataContext = new MainWindowViewModel(); 7 } 8 }
运行效果:
将任意事件绑定到命令
在前面的示例中,我们大量使用了Button
的Command
属性来进行命令绑定,Button.Command属性
的作用是获取或设置按下此按钮时要调用的命令。
在WPF中,具备Command
属性的的还有MenuItem
控件。
但是对于没有Command
属性的控件应该如何处理呢,又或者我想处理控件的其它事件呢,如选中项切换?
例如我有一个ListBox,我想在ListBox.SelectionChanged事件触发的时候调用一个命令。
这里我们可以借助Microsoft XAML Behaviors包
里面的EventTrigger
和InvokeCommandAction
来实现。
EventTrigger
是一种监听源上指定事件并在事件触发时触发的触发器,而InvokeCommandAction
是在触发器触发时执行绑定命令的一种动作。
关于Microsoft XAML Behaviors
的详细使用可以参考我前面写的文章:WPF中的Microsoft XAML Behaviors包功能详解 - zhaotianff - 博客园
创建一个WPF工程,并使用nuget安装Microsoft.Xaml.Behaviors.Wpf包和CommunityToolkit.Mvvm包
然后我们在界面上放置一个ListBox,将使用EventTrigger
和InvokeCommandAction
将SelectionChanged事件
绑定到OnSelectionChangedCommand
命令。
MainWindow.xaml
1 <Window x:Class="BindingEventToCommand.MainWindow"2 xmlns:local="clr-namespace:BindingEventToCommand"3 xmlns:i="http://schemas.microsoft.com/xaml/behaviors"4 mc:Ignorable="d"5 Title="MainWindow" Height="450" Width="800">6 <Grid>7 <ListBox Name="listbox">8 <i:Interaction.Triggers>9 <i:EventTrigger EventName="SelectionChanged"> 10 <i:InvokeCommandAction Command="{Binding OnSelectionChangedCommand}" CommandParameter="{Binding ElementName=listbox,Path=SelectedValue}"></i:InvokeCommandAction> 11 </i:EventTrigger> 12 </i:Interaction.Triggers> 13 <ListBoxItem>1234</ListBoxItem> 14 <ListBoxItem>5678</ListBoxItem> 15 <ListBoxItem>9112</ListBoxItem> 16 </ListBox> 17 </Grid> 18 </Window>
MainWindowViewModel.cs
1 public class MainWindowViewModel : ObservableObject2 {3 public RelayCommand<object> OnSelectionChangedCommand { get; set; }4 5 public MainWindowViewModel()6 {7 OnSelectionChangedCommand = new RelayCommand<object>(OnSelectionChanged);8 }9 10 private void OnSelectionChanged(object? obj) 11 { 12 var listboxItem = obj as ListBoxItem; 13 14 if(listboxItem != null) 15 { 16 MessageBox.Show(listboxItem.Content.ToString()); 17 } 18 } 19 }
绑定到数据上下文
1 public partial class MainWindow : Window 2 { 3 public MainWindow() 4 { 5 InitializeComponent(); 6 this.DataContext = new MainWindowViewModel(); 7 } 8 }
运行效果
参考资料:
命令概述 - WPF .NET Framework | Microsoft Learn
示例代码
WPF-MVVM-Beginner/5_CommandAndUserInput at main · zhaotianff/WPF-MVVM-Beginner · GitHub