🍠 WPF MVVM进阶系列教程
- 一、对话框
在前面的文章中,我们介绍了MVVM开发
的一些基础知识。
对于日常开发来说,基本已经足够应付大部分场景。
从这里开始,介绍的都是在MVVM模式
开发中,提升程序可维护性、灵活性、健壮性等方面的技巧。
包括对话框
、单元测试
、数据验证
、Ioc
、数据访问
及三方MVVM框架
使用等。
可以根据自身学习情况阅读。
Dialog
在WPF中,我们经常会用到对话框,包括非模态(Show())
和模态(ShowDialog())
两种。
在基于Code-Behind
模式的开发中,我们一般会直接在逻辑代码中,直接操作对话框。
类似下面这样
消息框
1 private void Button_Click(object sender, RoutedEventArgs e) 2 { 3 System.Windows.MessageBox.Show("HelloWorld"); 4 }
自定义对话框
1 private void Button_Click(object sender, RoutedEventArgs e) 2 { 3 MyDialogWindow dialog = new MyDialogWindow(); 4 MyDialogWindow.DataContext = new MyDialogWindowViewModel(); 5 MyDialogWindow.Owner = Application.Current.MainWindow; 6 dialog.Show(); 7 }
但在MVVM模式
中,不建议使用这种直接去操作对话框的方式。
主要考虑以下几个因素
1、直接调用 Show() 或 ShowDialog() 需要ViewModel引用System.Windows。这就打破了 MVVM 所期望的关注点分离,使测试代码等工作变得更加困难。
2、另一个问题与对话框的所有权(Owner)有关,因为我们需要设置对话框的父窗口,但显然在ViewModel中无法做到。即使我们直接从ViewModel中显示对话框,也无法直接从ViewModel中设置所有者,除非我们从View中引用ViewModel。
3、不利于模块化和代码重用,如果将对话框做成独立的模块,可以更方便移植。
4、不利于单元测试。
接下来我们就来看看如何使用DialogService来规避这些问题
使用DialogService
DialogService(对话框服务)
是一种使用抽象层来显示对话框的方法。
ViewModel
将显示对话框的责任委托给DialogService
,只需向服务提供显示所需的数据即可。
DialogService
拥有显示对话框的职责,因此我们可以将ViewModel
与 System.Windows
解耦,避免从View
到ViewModel
之间的引用。
在单元测试中,我们可以注入一个虚假的对话框服务实例,而不是显示实际的对话框,并使用我们的虚假对象进行预留和模拟。
说明:因为目前我们还没有使用Ioc容器,所以需要手动创建DialogService实例,并声明为单例模式,且不能通过注入的形式进行调用。
在后面会介绍如何在MVVM模式中使用Ioc。使用Ioc容器后,容器会帮我们完成这个操作。
不过使用Ioc容器不是必须的,手动操作也可以达到同样的功能。
使用DialogService的MessageBox示例
1、定义DialogService的接口
IDialogService.cs
在这个对话框服务的接口中,我们定义了一个显示消息的接口。
1 public interface IDialogService 2 { 3 MessageBoxResult ShowMessage(string title, string content); 4 }
说明:为了方便演示,该示例中还是使用了System.WIndows.MessageBox及相关类型。
2、定义DialogService的实现
因为我们目前还没有使用Ioc容器,所以我们将DialogService
类型定义成单例模式。
DialogService.cs
1 public class DialogService : IDialogService2 {3 private static volatile DialogService instance;4 private static object obj = new object();5 6 /// <summary>7 /// 单例模式8 /// </summary>9 public static DialogService Instance 10 { 11 get 12 { 13 if(instance == null) 14 { 15 lock(obj) 16 { 17 if (instance == null) 18 instance = new DialogService(); 19 } 20 } 21 22 return instance; 23 } 24 } 25 26 /// <summary> 27 /// ShowMessage接口实现 28 /// </summary> 29 /// <param name="title"></param> 30 /// <param name="content"></param> 31 /// <returns></returns> 32 public MessageBoxResult ShowMessage(string title, string content) 33 { 34 return MessageBox.Show(title, content); 35 } 36 }
3、定义主界面
在界面上放置一个按钮,当按钮点击时,弹出对话框
MainWindow.xaml
1 <Window x:Class="_1_DialogService.MainWindow" 2 Title="MainWindow" Height="450" Width="800"> 3 <Grid> 4 <Button Content="ShowMessage" Width="88" Height="28" Command="{Binding ShowMessageCommand}"></Button> 5 </Grid> 6 </Window>
4、定义主界面ViewModel并绑定到DataContext
MainWindowViewModel.cs
1 public class MainWindowViewModel2 {3 /// <summary>4 /// DialogService实例5 /// </summary>6 private IDialogService dialogService;7 8 public ICommand ShowMessageCommand { get; set; }9 10 public MainWindowViewModel() 11 { 12 ShowMessageCommand = new RelayCommand(ShowMessage); 13 14 //如果通过注入的形式,可以我们从构造函数取得IDialogService的实例 15 //这里我们手动获取 16 this.dialogService = DialogService.DialogService.GetInstance(); 17 } 18 19 private void ShowMessage() 20 { 21 var result = this.dialogService.ShowMessage("标题", "内容"); 22 } 23 }
运行效果如下:
这样我们就拥有了一个基于DialogService
的最简单的实践示例。
这种情况对于普通消息框都可以应付。
基于DialogService的复杂对话框示例
前面的示例中,我们演示了使用DialogService
对普通 的消息框进行操作。
但是如果我们需要显示一个复杂的数据对话框,应该如何去操作呢?
这里就需要借助数据模板的相关功能。
在《TabControl绑定到列表并单独指定每一页内容》,文章中,介绍过如何通过数据模板功能将ViewModel和View绑定起来。
假设我们有一个Student列表,Student具备Id、Name、Age三个属性,当在界面选择列表项后,单击显示详情按钮,使用对话框显示Student的详细信息。
1、定义StudentViewModel
StudentViewModel.cs
StudentViewModel
内部定义了Id
、Name
、Age
三个属性。
日常使用时,它内部可能会有更复杂的逻辑,这里我们只定义简单的数据进行演示。
1 public class StudentViewModel : INotifyPropertyChanged2 {3 public event PropertyChangedEventHandler? PropertyChanged;4 5 private int id;6 7 private string name;8 9 private string age; 10 11 public int Id 12 { 13 get => id; 14 set 15 { 16 id = value; 17 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Id")); 18 } 19 } 20 21 public string Name 22 { 23 get => name; 24 set 25 { 26 name = value; 27 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Name")); 28 } 29 } 30 31 public string Age 32 { 33 get => age; 34 set 35 { 36 age = value; 37 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Age")); 38 } 39 } 40 }
2、定义IDialogService接口
IDialogService.cs
1 public interface IDialogService 2 { 3 void ShowStudentDetail(StudentViewModel student); 4 }
这里我们暂时先不做接口的实现,等后面的准备工作都完成了再去实现这个接口。
3、定义对话框窗口
因为我们要将ViewModel
和View
绑定,所以这个对话框窗口并不是实际要显示的内容,它只是一个“壳”。
在窗口里放置一个ContentControl
,并使用自动绑定,用于内容显示
DialogView.xaml
1 <Window x:Class="_2_DialogServiceShowDetail.Views.DialogView" 2 mc:Ignorable="d" 3 Title="DialogView" Height="450" Width="800"> 4 <Grid> 5 <ContentControl Content="{Binding}"></ContentControl> 6 </Grid> 7 </Window>
4、定义数据展示界面
有了前面的DialogView
窗口后,我们可以增加一个UserControl
,用于实际数据显示
StudentView.xaml
StudentView
内部绑定到StudentViewModel
对应的属性
1 <UserControl x:Class="_2_DialogServiceShowDetail.Views.StudentView"2 d:DesignHeight="450" d:DesignWidth="800">3 <Grid>4 <Grid.ColumnDefinitions>5 <ColumnDefinition/>6 <ColumnDefinition/>7 </Grid.ColumnDefinitions>8 9 <Grid.RowDefinitions> 10 <RowDefinition/> 11 <RowDefinition/> 12 <RowDefinition/> 13 </Grid.RowDefinitions> 14 15 <Label Content="Id" HorizontalAlignment="Left" Margin="10,0,0,0" VerticalAlignment="Center"/> 16 <TextBox Grid.Column="1" Text="{Binding Id}" VerticalAlignment="Center" Margin="10,0"></TextBox> 17 18 <Label Grid.Row="1" Content="Name" HorizontalAlignment="Left" Margin="10,0,0,0" VerticalAlignment="Center"/> 19 <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Name}" VerticalAlignment="Center" Margin="10,0"></TextBox> 20 21 <Label Grid.Row="2" Content="Age" HorizontalAlignment="Left" Margin="10,0,0,0" VerticalAlignment="Center"/> 22 <TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Age}" VerticalAlignment="Center" Margin="10,0"></TextBox> 23 </Grid> 24 </UserControl>
大概效果如下
4、定义View和ViewModel的映射
这里我们借助数据模板功能,实现StudentViewModel
和StudentView的映射
增加一个资源字典
DialogTemplate.xaml
1 <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 2 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 3 xmlns:vm="clr-namespace:_2_DialogServiceShowDetail.ViewModels" 4 xmlns:view="clr-namespace:_2_DialogServiceShowDetail.Views"> 5 <DataTemplate DataType="{x:Type vm:StudentViewModel}"> 6 <view:StudentView /> 7 </DataTemplate> 8 </ResourceDictionary>
5、在App.xaml中使用资源字典
1 <Application.Resources> 2 <ResourceDictionary> 3 <ResourceDictionary.MergedDictionaries> 4 <ResourceDictionary Source="DialogTemplate.xaml"></ResourceDictionary> 5 </ResourceDictionary.MergedDictionaries> 6 </ResourceDictionary> 7 </Application.Resources>
说明:我们可以直接将这个数据模板定义在App.xaml里面,DialogTemplate.xaml这个资源字典不是必须的。
新建资源字典文件再引入的原因是使项目结构更清晰,更容易查找 。
6、实现IDialogService接口
在这里我们就可以实现IDialogService
接口了,在显示对话框时,只需要将StudentViewModel
传递到DialogView
的数据上下文,DialogView
就会自动加载StudentView
到DialogView
中显示。
DialogService.cs
1 public class DialogService : IDialogService2 {3 private static volatile DialogService instance;4 private static object obj = new object();5 6 /// <summary>7 /// 单例模式8 /// </summary>9 public static DialogService GetInstance() 10 { 11 if (instance == null) 12 { 13 lock (obj) 14 { 15 if (instance == null) 16 instance = new DialogService(); 17 } 18 } 19 20 return instance; 21 } 22 23 //显示对话框 24 public void ShowStudentDetail(StudentViewModel studentViewModel) 25 { 26 //设置StudentViewModel到DialogView的数据上下文 27 //DialogView会自动加载StudentView 28 var dialog = new DialogView() { DataContext = studentViewModel }; 29 dialog.Owner = Application.Current.MainWindow; 30 dialog.ShowInTaskbar = false; 31 dialog.ShowDialog(); 32 33 } 34 }
7、定义主界面
在主界面中放置一个ListBox
和Button
,当点击Button
时,弹窗显示选中项的详情。
MainWindow.xaml
1 <Window x:Class="_2_DialogServiceShowDetail.MainWindow"2 Title="MainWindow" TitleVisibility="Collapsed" Height="400" Width="300" MinimizeVisibility="Collapsed" MaximizeVisibility="Collapsed" WindowStartupLocation="CenterScreen">3 <tianxia:BlurWindow.Background>4 <SolidColorBrush Color="White" Opacity=".9"></SolidColorBrush>5 </tianxia:BlurWindow.Background>6 <Grid>7 <Grid.RowDefinitions>8 <RowDefinition/>9 <RowDefinition Height="35"/> 10 </Grid.RowDefinitions> 11 12 <ListBox ItemsSource="{Binding StudentList}" SelectedIndex="{Binding StudentListSelectedIndex}" BorderThickness="0" DisplayMemberPath="Name"></ListBox> 13 14 <Button Content="显示详情" Width="88" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="1" Command="{Binding ShowStudentDetailCommand}"></Button> 15 </Grid> 16 </Window>
8、定义主界面ViewModel并绑定到DataContext
MainWindowViewModel.cs
MainWindowViewModel
中定义描述如下:
StudentList
:用于绑定到列表显示。
StudentListSelectedIndex
:用于绑定到列表选中索引。
ShowStudentDetailCommand
:显示详情命令,绑定到显示详情按钮上
IDialogService
:对话框服务接口,通过DialogService单例
获取实例。
1 public class MainWindowViewModel : INotifyPropertyChanged2 {3 private IDialogService dialogService;4 5 private ObservableCollection<StudentViewModel> studentList = new ObservableCollection<StudentViewModel>();6 7 public ObservableCollection<StudentViewModel> StudentList8 {9 get => studentList; 10 set 11 { 12 studentList = value; 13 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("StudentList")); 14 } 15 } 16 17 private int studentListSelectedIndex = -1; 18 19 public int StudentListSelectedIndex 20 { 21 get => studentListSelectedIndex; 22 set 23 { 24 studentListSelectedIndex = value; 25 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("StudentListSelectedIndex")); 26 } 27 } 28 29 30 public ICommand ShowStudentDetailCommand { get; private set; } 31 32 public event PropertyChangedEventHandler? PropertyChanged; 33 34 public MainWindowViewModel() 35 { 36 dialogService = DialogService.DialogService.GetInstance(); 37 38 ShowStudentDetailCommand = new RelayCommand(ShowStudentDetail); 39 40 StudentList.Add(new StudentViewModel() { Id = 1,Name = "测试1",Age = "17"}); 41 StudentList.Add(new StudentViewModel() { Id = 2, Name = "测试2", Age = "18" }); 42 StudentList.Add(new StudentViewModel() { Id = 3, Name = "测试3", Age = "19" }); 43 } 44 45 private void ShowStudentDetail() 46 { 47 dialogService.ShowStudentDetail(StudentList[StudentListSelectedIndex]); 48 } 49 }
运行效果如下:
手动关闭对话框并获取对话框结果
通过上述两个示例,我们对DialogService
已经有了较为深入的认识。
但是现在还存在一个关键问题没有解决,就是如何关闭对话框,并获取对话框结果。
接下来我们讲解一下如何在DialogService
的基础上,实现关闭对话框并获取对话框结果。
在过去,我们一般会使用类似下面的代码结构来获取对话框的结果
1 var dialogResult = System.Windows.MessageBox.Show("是否确认", "标题", System.Windows.MessageBoxButton.YesNoCancel, System.Windows.MessageBoxImage.Information);2 3 if(dialogResult == System.Windows.MessageBoxResult.Yes)4 {5 //是6 }7 else if(dialogResult == System.Windows.MessageBoxResult.No)8 {9 //否 10 } 11 else 12 { 13 //取消 14 }
但是使用了DialogService
后,我们不会直接操作对话框窗口,应该如何实现呢?
先说一下大概实现思路
1、给DialogViewModel
里增加一个事件,当在Dialog
上点击相应的按钮时,引发这个事件
2、将界面点击的结果以参数形式传到事件,例如点击确认时传递Ok
3、在DialogService
内部创建DialogView
的ViewModel
时,为这个事件添加处理程序
4、事件处理程序内部负责关闭对话框,并获取结果供下一步调用。
5、封装DialogService
时,传入一个回调,当对话框关闭时,将获取的对话框结果通过这个回调传出去。
简单点来说,就是在DialogService
里创建对话框时,创建一个回调,让对话框通过回调的形式来操作对话框的关闭并返回结果。
实现步骤
1、定义对话框结果枚举
DialogResult.cs
1 public enum DialogResult 2 { 3 Ok, 4 Cancel 5 }
2、创建封装DialogViewModel内部事件的接口
IMyDialog.cs
1 public interface IMyDialog 2 { 3 event Action<DialogResult> RequestClose; 4 }
3、创建DialogView
这个是用于显示对话框的壳
DialogView.xaml
1 <Window x:Class="_3_DialogResult.Views.DialogView" 2 Title="DialogView" Height="280" Width="350" WindowStartupLocation="CenterScreen"> 3 <Grid> 4 <ContentControl Content="{Binding}"></ContentControl> 5 </Grid> 6 </Window>
4、创建对话框内容
NotificationDialog.xaml
这里我们放置了两个按钮,Ok
和Cancel
当点击Ok
时执行OkCommand
当点击Cancel
时执行CancelCommand
1 <UserControl x:Class="_3_DialogResult.Views.NotificationDialog"2 d:DesignHeight="450" d:DesignWidth="800">3 <Grid x:Name="LayoutRoot" Margin="5">4 <Grid.RowDefinitions>5 <RowDefinition />6 <RowDefinition Height="Auto" />7 </Grid.RowDefinitions>8 9 <TextBlock Text="Hello World" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="50" Grid.Row="0" TextWrapping="Wrap" /> 10 <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0" Grid.Row="1" > 11 <Button Command="{Binding OkCommand}" Content="OK" Width="75" Height="25" IsDefault="True" /> 12 <Button Command="{Binding CancelCommand}" Content="Cancel" Width="75" Height="25" Margin="10,0,0,0" IsCancel="True" /> 13 </StackPanel> 14 </Grid> 15 </UserControl>
5、创建对话框ViewModel
当点击Ok/Cancel
按钮时,引发IMyDialog
里的RequestClose
事件。
NotificationDialogViewModel.cs
1 public class NotificationDialogViewModel : IMyDialog2 {3 public ICommand OkCommand { get; private set; }4 5 public ICommand CancelCommand { get; private set; }6 7 public NotificationDialogViewModel()8 {9 OkCommand = new RelayCommand(Ok); 10 CancelCommand = new RelayCommand(Cancel); 11 } 12 13 public event Action<DialogResult> RequestClose; 14 15 private void Cancel() 16 { 17 RaiseRequestClose(DialogResult.Cancel); 18 } 19 20 private void Ok() 21 { 22 RaiseRequestClose(DialogResult.Ok); 23 } 24 25 private void RaiseRequestClose(DialogResult dialogResult) 26 { 27 RequestClose?.Invoke(dialogResult); 28 } 29 }
6、创建IDialogService
在显示对应框时,传入一个回调Action<DialogResult> resultCallback
,这个回调
会在对话框关闭时调用,并返回对话框结果
1 public interface IDialogService 2 { 3 void ShowNotificationDialog(Action<DialogResult> resultCallback); 4 }
7、创建DialogService
这里我们可以看到,在创建NotificationDialogViewModel
时,我们为IMyDialog.RequestClose
事件增加了一个事件处理程序。
这个事件处理程序里有如下逻辑:
1、获取了对话框结果
2、然后关闭对话框
3、调用IDialogService创建对话框时传进来的回调,通知外部对话框已经关闭,并传回对话框结果
1 public class DialogService : IDialogService2 {3 4 public void ShowNotificationDialog(Action<DialogResult> resultCallback)5 {6 DialogResult dialogResult;7 var dialog = new DialogView();8 9 NotificationDialogViewModel notificationDialogViewModel = new NotificationDialogViewModel(); 10 notificationDialogViewModel.RequestClose += (x)=> 11 { 12 dialogResult = x; 13 dialog.Close(); 14 resultCallback?.Invoke(dialogResult); 15 }; 16 17 dialog.ShowInTaskbar = false; 18 dialog.DataContext = notificationDialogViewModel; 19 dialog.ShowDialog(); 20 } 21 }
其它部分的代码暂时就省略了,可以到示例代码中进行查看。
运行效果如下:
说明:这里只是展示原理,正式使用时,可以根据实际使用情况进行优化。
示例代码
https://github.com/zhaotianff/WPF-MVVM-Beginner/tree/main/7_Dialog