MVVM # 第四集






4.89/5 (26投票s)
将扩展的 MVVM 模式用于实际的 LOB 应用程序:第四部分
本系列其他文章
第一部分
第二部分
第三部分
第四部分
引言
在这篇文章系列的第一部分中,我介绍了我的 MVVM 模式观点,并讨论了我认为在某些实现以及模式本身中存在的一些不足。
在第二部分中,我介绍了我在实现中使用的一些基类和接口,我称之为 MVVM#(为图方便起的名称)。
在第三部分中,我提供了足够应用程序代码,让我们能够启动和运行,并在窗体中显示客户选择视图,同时包含运行时和设计时数据。
在本部分系列文章中,我将在该基本应用程序的基础上进行扩展,展示实际功能。
过滤
我们过滤需求的技术规范非常简单。允许用户输入一个州代码,然后过滤列表,只包含在该州的客户。(我是一个住在澳大利亚的英国人——所以我在这里使用澳大利亚的州代码——如果你感兴趣的话,完整的列表是 QLD、NSW、SA、WA、TAS、NT、ACT、VIC。)(实际上,无论你是否感兴趣,这都是完整的列表)
通常,解决这个问题有无数种方法,但我的设计师希望将其实现为“即时搜索”。
实现这一点的第一步实际上已经在我们的 CustomerSelectionView
的 XAML 中。
Text="{Binding Path=StateFilter, UpdateSourceTrigger=PropertyChanged}"></TextBox>
这行代码将州文本框绑定到 StateFilter
属性,而 UpdateSourceTrigger
属性告诉 WPF 在属性更改时立即更新该属性。
在此处提醒一下可能很重要;此属性虽然是 Observable
属性,但它是*功能*的一部分,而不是正在处理的数据的一部分——因此该属性驻留在 ViewModel
中,而不是 ViewData
对象中。
因此,将会发生的是,用户在 TextBox
中输入内容,该操作会设置 ViewModel
上的属性,然后 ViewModel
会要求 Controller
提供一个新过滤后的数据集。由于该数据已绑定到 View
,因此 View
中的 DataGrid
将会更新。
但等等!如果我们这样实现,一旦输入一个字母,列表就会变为空,因为没有州的代码是单个字母。我们可以将过滤设置为“以…开头”的过滤器——但那样输入“N”代表 NT 会将所有 NSW 的客户都找出来。好吧,这其实也不是什么大问题,但可能会出现获取过滤结果所花费时间的问题——所以我们不想在用户按下按键时不断刷新结果。
因此,我想引入一个延迟。在用户输入内容后,大约半秒钟内不会进行任何过滤——然后半秒钟后,列表将使用 TextBox
中的内容进行过滤,除非按下另一个按键,在这种情况下,半秒钟的倒计时会重新开始。
所以,我们需要一个 Timer
——我使用的是 DispatcherTimer
,因此需要在 ViewModels
项目中添加对 WindowsBase
的引用。
然后,我们可以添加一个新的 private
字段。
DispatcherTimer stateFilterTimer;
我们还需要添加我们的 ObservableProperty StateFilter
。
private string stateFilter;
public string StateFilter
{
get
{
return stateFilter;
}
set
{
if (value != stateFilter)
{
stateFilterTimer.Stop();
stateFilter = value;
RaisePropertyChanged("StateFilter");
stateFilterTimer.Start();
}
}
}
在构造函数中,我们需要实例化计时器,将其时间间隔设置为半秒,并为其提供一个事件处理程序方法,以便在计时器归零时处理事件。
/// <summary>
/// Use the base class to store the controller and set the Data Context of the view (view)
/// Initialise any data that needs initialising
/// </summary>
/// <param name="controller"></param>
/// <param name="view"></param>
public CustomerSelectionViewModel(ICustomerController controller,
IView view, string stateFilter = "")
: base(controller, view)
{
controller.Messenger.Register(MessageTypes.MSG_CUSTOMER_SAVED,
new Action<Message>(RefreshList));
// Leave it for half a second before filtering on State
stateFilterTimer = new DispatcherTimer()
{
Interval = new TimeSpan(0, 0, 0, 0, 500)
};
stateFilterTimer.Tick += StateFilterTimerTick;
StateFilter = stateFilter;
RefreshList();
}
当然,然后我们需要编写那个事件处理程序……
/// <summary>
/// Event handler for the timer used for 'filter as you type' on the State filter.
/// When the timer triggers, filter the list with the existing filter.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
void StateFilterTimerTick(object sender, EventArgs e)
{
stateFilterTimer.Stop();
RefreshList();
}
然后,所有需要做的就是更改 RefreshList
方法,以便我们传递 StateFilter
属性,而不是一个空的 string
。
/// <summary>
/// Ask for an updated list of customers based on the filter
/// </summary>
private void RefreshList()
{
ViewData = CustomerController.GetCustomerSelectionViewData(StateFilter);
}
这就差不多了——运行一下。请记住,在我的测试数据中,我只使用了两个州——Qld 和 NSW。

编辑
好吧,这个应用程序的全部意义在于能够编辑客户详细信息,所以让我们开始创建我们的 CustomerEditViewModel
。
using System;
using System.Windows.Input;
using Messengers;
namespace ViewModels
{
/// <summary>
/// A ViewModel for a view that allows a Customer to be modified
/// </summary>
public class CustomerEditViewModel : BaseViewModel
{
#region Private Fields
#endregion
#region Properties
/// <summary>
/// Just to save us casting the base class's IController
/// to ICustomerController all the time...
/// </summary>
private ICustomerController CustomerController
{
get
{
return (ICustomerController)Controller;
}
}
#region Observable Properties
#endregion
#endregion
#region Commands
#region Command Relays
private RelayCommand<IView> cancelledCommand;
private RelayCommand<IView> saveCommand;
public ICommand CancelledCommand
{
get
{
return cancelledCommand ?? (cancelledCommand =
new RelayCommand<IView>(param => ObeyCancelledCommand(param),
param => CanObeyCancelledCommand(param)));
}
}
public ICommand SaveCommand
{
get
{
return saveCommand ?? (saveCommand = new RelayCommand<IView>
(param => ObeySaveCommand(param), param => CanObeySaveCommand(param)));
}
}
#endregion // Command Relays
#region Command Handlers
/// <summary>
/// </summary>
/// <returns></returns>
private bool CanObeyCancelledCommand(IView view)
{
return true;
}
private void ObeyCancelledCommand(IView view)
{
CloseViewModel(false);
}
private bool CanObeySaveCommand(IView view)
{
return true;
}
private void ObeySaveCommand(IView view)
{
CustomerController.UpdateCustomer((CustomerEditViewData)ViewData);
CloseViewModel(true);
}
#endregion // Command Handlers
#endregion // Commands
#region Constructor
public CustomerEditViewModel(ICustomerController controller)
: this(controller, null)
{
}
public CustomerEditViewModel(ICustomerController controller, IView view)
: base(controller, view)
{
controller.Messenger.Register(MessageTypes.MSG_CUSTOMER_SELECTED_FOR_EDIT,
new Action<Message>(HandleCustomerSelectedForEditMessage));
}
#endregion
/// <summary>
/// If somewhere someone selects a customer for editing and this
/// Edit ViewModel is already
/// Editing that customer, then abort the message, and make the View active
/// </summary>
/// <param name="message"></param>
private void HandleCustomerSelectedForEditMessage(Message message)
{
CustomerListItemViewData customer =
message.Payload as CustomerListItemViewData;
if (customer != null && customer.CustomerId ==
((CustomerEditViewData)ViewData).CustomerId)
{
message.HandledStatus = MessageHandledStatus.HandledCompleted;
ActivateViewModel();
}
}
}
}
花一分钟时间浏览一下这段代码,确保我们理解了各个部分。
同样,我定义了一个类型为 ICustomerController
的 private
属性,用于返回 BaseViewModel
中定义的 IController
,这样可以省去每次使用它时进行强制类型转换。
没有 ObservableProperties
。我们正在编辑的 Customer
数据的 ObservableProperties
在 CustomerViewData
中——ViewModel
中缺少 ObservableProperties
表明此视图没有额外的绑定功能。
我们定义了两个类型为 IView
的 RelayCommands
(cancelledCommand
和 saveCommand
),它们在需要的关联属性 Getters 中实例化。
CanObeyCancelledCommand
方法始终返回 true
——因此用户随时都可以取消。
CanObeySaveCommand
也始终返回 true
。当然,在实际应用中,你可能只想在当前的 CustomerEditViewData
是“脏”的——也就是说,用户进行了更改——时才返回 true
——但本系列文章已经足够长了,无需添加额外的变更跟踪复杂性!
ObeyCancelledCommand
方法(当用户取消时调用的方法)使用带有 False
参数的 CloseViewModel
方法。此参数决定,如果视图以对话框形式显示,dialogresult
是 true
还是 false
。
ObeySaveCommand
要求 CustomerController
保存我们 ViewData
中的数据(该数据绑定到用户正在使用的控件,并反映了这些更改)。然后它使用 CloseViewModel
方法,传递“True
”,这样,如果视图以对话框形式显示,DialogResult
将为 true
。
在构造函数中,你会看到 CustomerEditViewModel
注册接收类型为‘MSG_CUSTOMER_SELECTED_FOR_EDIT
’的消息。这样做的原因是,如果我们愿意,可以打开多个 CustomerEditViewModels
,每个都编辑自己的 Customer
——但我们真的不希望同一个客户在两个 View
中被编辑——所以每次选择一个 Customer
进行编辑时,CustomerEditViewModel
都会检查消息,如果选择的 customer
与它当前正在编辑的 customer
匹配,它会将消息状态设置为 HandledCompleted
并“激活”自身,这实际上意味着它会激活 View
——所以对用户的影响将是包含 View
的窗口将成为活动窗口。)
这一切都在 HandleCustomerSelectedForEditMessage
方法中实现。Message
对象有一个 Payload
属性,对于这种消息类型,它是一个 CustomerListItemViewData
。(顺便说一句,在此版本中,我没有实现任何东西来强制确保此消息类型的 payload
对象是正确的类型,也没有尝试自动化此类型转换。这样做并不难,如果你想做的话——但你在这方面走得越远,你就越接近一个复杂的 Framework——而这正是我想要避免的。)
好了——这就是 ViewModel
——让我们为此创建一个 View——然后我们可以把它交给设计师来让它看起来漂亮。
CustomerEditView.xaml
<views:BaseView x:Class="Views.CustomerEditView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:views="clr-namespace:Views"
mc:Ignorable="d"
d:DesignHeight="243"
d:DesignWidth="346"
d:DataContext="{d:DesignInstance
Type=views:DesignTimeCustomerEditViewModel,
IsDesignTimeCreatable=true}">
<StackPanel Margin="10">
<Grid >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Name"
Grid.Column="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Name}"
Grid.Column="1"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Address"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Address}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Suburb"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Suburb}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="State"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.State}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="PostCode"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.PostCode}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Phone"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Phone}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="eMail"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Email}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<StackPanel Orientation="Horizontal"
FlowDirection="RightToLeft"
Height="35">
<Button Content="Save"
Command="{Binding Path=SaveCommand, Mode=OneTime}"
Height="23"
Width="75"
Margin="5,5,25,2" />
<Button Content="Cancel"
Command="{Binding Path=CancelledCommand, Mode=OneTime}"
Height="23"
Width="75"
Margin="5,5,25,2" />
</StackPanel>
</StackPanel>
</views:BaseView>
编辑视图中没什么特别的。成对的 Text Blocks 和 TextBox
es。TextBox
es 绑定到 CustomerEditViewData
的属性。
有两个按钮——一个用于取消,一个用于保存,每个都绑定到相应的 Command。就这些了!
所以,如果我们回到 CustomerController_ViewManagement.cs,GetCustomerEditView
可以被取消注释,因为我们现在有一个 CustomerEditView
,它会编译。同样在 CustomerController.cs 源代码中,editCustomer
方法的内容也可以被取消注释。
在继续之前,请记住我们的目标之一是 Blendability——能够将我们的 View
发送给设计师进行美化?在 CustomerEditView
XAML 中,我们有
d:DataContext="{d:DesignInstance Type=views:DesignTimeCustomerEditViewModel,
IsDesignTimeCreatable=true}">
这告诉设计师实例化一个 DesignTimeCustomerEditViewModel
实例,以便我们的设计师可以看到一些数据。所以,我们最好创建一个。
DesignTimeCustomerEidtViewModel.cs
using ViewModels;
namespace Views
{
class DesignTimeCustomerEditViewModel : CustomerEditViewModel
{
public DesignTimeCustomerEditViewModel()
{
ViewData = new CustomerEditViewData()
{
Address = "23 Netherington on Wallop Street",
CustomerId = 123,
Email = "Oldhag@GeeMail.Com",
Name = "Betty Boop",
Phone = "0414 4142424",
PostCode = "4540",
State = "QLD",
Suburb = "Indooroopilly"
};
}
}
}
Blendability - 题外话
看看下面的三个截图。它们是 VS2010、Expression Blend 4 和运行时中客户窗体(添加了简单的按钮下拉阴影效果)的对比(此效果不包含在此处和下载的版本中)。

有趣的是,Blend 将按钮和阴影对齐到左侧,而 VS2010(以及运行时)则将它们都对齐到右侧!这类事情往往会惹恼设计师,但至少他们可以通过看到数据而不是空白输入字段来安抚。
继续表演
现在,运行程序。你应该能够选择一个 customer
,点击按钮进行编辑,这将打开一个窗口,你可以在其中更改 customer
。该窗口不是模态的,所以你可以返回并选择另一个 customer
,这将打开另一个窗口。
关闭选择窗口,所有编辑窗口也会随之关闭。
更改选择列表中的一个字段,保存 customer
,然后选择列表刷新以显示修改后的详细信息。
更改同一个字段并取消,选择列表中不会显示任何更改。
两次选择同一个 customer
,第一次打开的窗口会获得焦点。
我认为这很好地符合了规范(如果你还记得第一部分的话)。
变更
当然,我们都知道生活并不那么简单!一旦我们的项目赞助商看到这个应用程序,他就想要改变。他不希望能够打开多个编辑窗口——这太混乱了。
好的,让我们为他做出更改。
进入 CustomerController
源代码,找到 EditCustomer
方法。这是我们进行编辑客户所需操作的地方——所以我们在这里需要将 view.ShowInWindow
的第一个参数从 false
改为 true
。
工作完成。
结论
我们已经通过四个章节走了很长的路,但我希望这对某些人有所帮助。
正如我一直强调的,这不是一个 Framework,而只是我如何将可工作的组件组合在一起,以构建一个 MVVM WPF 应用程序的示例,该应用程序对我来说,在解决其他解决方案的一些不足之处方面,有所改进。
这可能不适合你。你可能想使用现有的框架之一——Cinch、MVVM Light 或其他数以百万计的框架——或者你想自己开发。或者,像我一样,你可能想用自己的方式去做,在你自己的环境中实现你认为最有效的东西。
无论你做什么,我都非常感谢你的反馈。我相信我在过程中犯过错误,并且一直乐于向他人学习来改进我自己的东西。
再次感谢我所站立的巨人的肩膀——尤其是 Pete O'Hanlon。