ViewModel 与 View 交互请求





5.00/5 (3投票s)
本文介绍了一种从 ViewModel 层与用户进行交互的技术。
引言
我通过多个层(程序集)来构建我的所有 WPF 应用程序。通常,这些层包括:
Application
:GUI 实现ViewModel
:应用程序逻辑DataModel
:(数据)模型定义
采用这种架构的原因是为了抽象和隔离不同类型的组件。各层之间存在自顶向下的依赖关系,不允许循环的库引用。
这意味着顶层对象可以使用下层的对象,反之则不然。如果一个对象想要通知父对象,它必须实现一个带有委托定义的事件,父对象必须注册自己才能收到子对象的通知。这不是一个新话题,网上有很多文章解释了这种模式。
在本文中,我将介绍一种基于委托和事件设计范例的技术,以便从应用程序逻辑层提供用户交互。在这种情况下,我需要在 ViewModel 层中编写代码,以与用户进行交互。
示例
例如,一个请求文件路径的命令实现
public class OpenFileCommand : ICommand
{
--- Code omitted ---
public void Execute(object parameter)
{
OpenFileDialog openFileDialog = new OpenFileDialog();
if (openFileDialog.ShowDialog() == true)
{
this.filePath = openFileDialog.FileName;
}
}
--- Code omitted ---
}
这个例子使用了一个 GUI 组件 `OpenFileDialog` 来与用户交互,请求文件的路径。这个实现破坏了分层架构的概念!我希望引发一个事件,以便使用 `OpenFileDialog` 的实现位于应用程序层。
解决方案优势
- 健全的架构:清晰的分层,逻辑和 GUI 组件不混合
- 清晰的引用:子层没有 GUI 引用
- 可测试性:没有模态对话框,这会影响任何自动化测试
- 环境独立性:GUI 层可以被替换
背景
此解决方案的灵感来自 Microsoft Prism 库。该库是 Microsoft 复合应用程序构建模式的一部分,这是一个强大但也很庞大的库,包含许多类和模式,用于构建可以由多个单元组成的桌面应用程序(参见领域驱动设计)。
Using the Code
示例解决方案包含最简单的记事本实现。当单击“加载”或“另存为...”按钮时,交互请求模式用于请求加载或保存文本文件的文件路径。
简化组件和类图
下图显示了示例程序的 P基本结构。为了简化绘图,只显示了加载命令。在接下来的步骤中,我还会添加交互请求类。
应用程序有两个程序集
- Application:包含 GUI 元素
- ViewModel:包含业务逻辑和数据容器
Application
- MainWindow:XAML 编写的 GUI 定义
- ShowLoadFileDialogAction:此类将创建 `OpenFileDialog` 实例并显示给用户
ViewModel
MainViewModel
:包含视图的命令LoadCommand
:当用户按下“加载...”按钮时执行TextContainer
:单例实例,包含视图文本框中的文本
本文介绍了一种编程方法,可以从位于 `ViewModel` 层中的 `LoadCommand` 到达位于应用程序层中的 `ShowLoadFileDialogAction`。
此方法可以重用,因此我将为该实现创建一个库。我将此库称为“小型应用程序框架”。
小型应用程序框架
“小型应用程序框架”是一个库,我将在其中放置可重用组件。我将在本文和未来的文章中使用此库,并对其内容进行扩展和修改。
“小型应用程序框架”包含两个库
SmallApplicationFramework
:包含与 GUI 无关的核心应用程序逻辑组件SmallApplicaitonFramework.Wpf
:包含使用 WPF 的核心应用程序逻辑组件
SmallApplicationFramework
IInteractionRequest
:交互请求的接口,包含 `Raised` 事件INotification
:交互请求通知的接口InteractionRequest
:交互请求的通用实现,以通知实现作为泛型类型参数InteractionRequestEventArgs
:传递给 `Raised` 事件的交互请求事件参数CommandBase
:一个 `ICommand` 实现,处理 `CanExecuteChanged` 事件处理程序。此类用作命令的基类
SmallApplicationFramework.Wpf
InteractionRequestTrigger
:交互请求的专用事件触发器TriggerActionBase
:交互请求操作的基类
完整包图
完整包图如下所示
实现
到目前为止,已经介绍了基类。本节将解释它们如何协同工作。本次练习的目标是创建可以从命令(在本例中为 `LoadCommand`)调用的代码,该命令将在 GUI 层显示 `OpenFileDialog`。这意味着会引发一个事件,其中包含一个通知。通知包含当前任务的特定数据。第一步是定义通知。
通知
通知派生自 `INotification` 接口。在 `LoadCommand` 执行时实例化。此时,将设置将在打开文件对话框中显示的标题和筛选器。文件路径和名称是结果属性。
public class ShowLoadFileDialogNotification : INotification
{
public string Title { get; set; }
public string FileName { get; set; }
public string FilePath { get; set; }
public string Filter { get; set; }
}
交互请求定义
InteractionRequestContainer
是一个公共单例实例,包含交互请求。`ShowLoadFileDialogInteractionRequest` 是容器的属性。
public class InteractionRequestContainer
{
private static InteractionRequestContainer instance;
private InteractionRequestContainer()
{
this.ShowLoadFileDialogInteractionRequest =
new InteractionRequest<ShowLoadFileDialogNotification>();
}
public static InteractionRequestContainer Instance
{
get
{
return instance ?? (instance = new InteractionRequestContainer());
}
}
public InteractionRequest<ShowLoadFileDialogNotification>
ShowLoadFileDialogInteractionRequest { get; private set; }
}
使用交互请求
ShowLoadFileDialogInteractionRequest
在 `LoadCommand` 的 `Execute` 方法中使用。使用通知实例引发交互请求。然后测试结果值,如果成功,则打开文件并将其内容写入 `TextContainer`。这也一个单例实例,包含文本字符串和文本文件的文件路径。
请注意,在这种情况下,交互请求的执行是同步操作。控件流在 `OpenFileDialog` 关闭并将其结果值存储在通知对象后返回。
public class LoadCommand : CommandBase
{
public override void Execute(object parameter)
{
var notification = new ShowLoadFileDialogNotification
{
Title = "Select Text File",
Filter = "Text Files|*.txt|All Files|*.*"
};
InteractionRequestContainer.Instance.ShowLoadFileDialogInteractionRequest.Raise(notification);
if (string.IsNullOrEmpty(notification.FilePath) == false
&& File.Exists(notification.FilePath))
{
using (var streamReader = new StreamReader(notification.FilePath))
{
TextContainer.Instance.Text = streamReader.ReadToEnd();
}
TextContainer.Instance.FilePath = notification.FilePath;
}
}
}
MainViewModel
`MainViewModel` 不包含对交互请求的任何引用。实例已在 `InteractionRequestContainer` 中声明,它是一个公共单例实例。因此,`MainViewModel` 类仅包含 `LoadCommand` 作为属性。
public class MainViewModel
{
public MainViewModel()
{
this.LoadCommand = new LoadCommand();
}
public ICommand LoadCommand { get; private set; }
}
ShowLoadFileDialogAction 定义
此类包含与用户交互的逻辑。它派生自 `TriggerActionBase` 类,并使用 `ShowLoadFileDialogNotification` 泛型类型参数进行实例化,以便它可以接收此类通知。基类处理参数检查,并在收到有效的通知对象后调用 `abstract ExecuteAction()` 方法。此方法必须被覆盖并按如下方式实现。在此,实例化 `OpenFileDialog`,并在对话框关闭后将其结果放入通知中。
public class ShowLoadFileDialogAction : TriggerActionBase<ShowLoadFileDialogNotification>
{
protected override void ExecuteAction()
{
var openFileDialog = new OpenFileDialog();
openFileDialog.Title = this.Notification.Title;
openFileDialog.Filter = this.Notification.Filter;
if (string.IsNullOrEmpty(this.Notification.FileName) == false)
{
openFileDialog.InitialDirectory = this.Notification.FileName;
}
this.Notification.FileName = string.Empty;
if (openFileDialog.ShowDialog() == true)
{
this.Notification.FileName = openFileDialog.SafeFileName;
this.Notification.FilePath = openFileDialog.FileName;
var mainWindow = this.AssociatedObject as MainWindow;
if (mainWindow != null)
{
mainWindow.Title = string.Format("{0} - {1}",
"Simplest Notepad",
Notification.FileName);
}
}
}
}
接收交互请求
交互请求机制依赖于“Windows.System.Interactivity.dll”程序集。该程序集与 Expression Blend SDK 打包在一起,包含许多有用的功能。Interactivity 程序集作为 Nuget 包进行引用。
接下来的 XAML 定义显示了应用程序的 GUI 定义。`Interaction.Triggers` 部分保存了 `InteractionRequestTrigger` 的定义,该触发器绑定到 `InteractionRequestContainer` 中定义的 `ShowLoadFileDialogInteractionRequest`。`ShowLoadFileDialogAction` 被定义为当 `InteractionReuqestContainer` 的 `ShowLoadFileDialogInteractionRequest` 事件引发时触发的事件实例。
<Window x:Class="InteractionRequestNotepad.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:viewModel="clr-namespace:ViewModel;assembly=ViewModel"
xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
xmlns:interactionRequest="clr-namespace:SmallApplicationFramework.Wpf.InteractionRequest;
assembly=SmallApplicationFramework.Wpf"
xmlns:actions="clr-namespace:Application.Actions"
Title="Simplest Notepad" Height="350" Width="525">
<Window.DataContext>
<viewModel:MainViewModel/>
</Window.DataContext>
<i:Interaction.Triggers>
<interactionRequest:InteractionRequestTrigger
x:Name="ShowLoadFileDialog"
SourceObject="{Binding Path=ShowLoadFileDialogInteractionRequest,
Source={x:Static viewModel:InteractionRequestContainer.Instance}}">
<actions:ShowLoadFileDialogAction/>
</interactionRequest:InteractionRequestTrigger>
</i:Interaction.Triggers>
<DockPanel LastChildFill="True">
<ToolBar x:Name="MainToolBar" DockPanel.Dock="Top">
<Button x:Name="LoadButton"
Content="Load..." Command="{Binding LoadCommand}"/>
</ToolBar>
<Grid>
<TextBox x:Name="TextBox"
Text="{Binding Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap"
AcceptsReturn="True"
DataContext="{x:Static viewModel:TextContainer.Instance}"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"/>
</Grid>
</DockPanel>
</Window>
至此,所有参与者都已就绪,系统可以工作了。但它是如何工作的呢?
序列图
接下来的序列图显示了所有类以及用于显示 `OpenFileDialog` 的相关调用。
在此可以找到原始尺寸的序列图链接。
幕后实现
InteractionRequest
InteractionRequest
类以 `INotification` 对象作为其泛型类型参数。它包含一个名为 `Raised` 的事件,触发器将订阅该事件,以及 `Raise(T context)` 方法,该方法将通知实例作为参数接收,并将通知实例打包到 `InteractionRequestEventArgs` 中,然后将其传递给 `Raised` 事件。对于异步使用,`Raise` 方法可以与回调 `Action` 委托一起调用,该委托可以在操作完成后调用。
public class InteractionRequest<T> : IInteractionRequest
where T : INotification
{
public event EventHandler<InteractionRequestedEventArgs> Raised;
public void Raise(T context)
{
this.Raise(context, c => { });
}
public void Raise(T context, Action<T> callback)
{
EventHandler<InteractionRequestedEventArgs> handler = this.Raised;
if (handler != null)
{
handler(this, new InteractionRequestedEventArgs(context, () => callback(context)));
}
}
}
InteractionRequestEventArgs
包含通知和可选回调操作委托的事件参数。
public class InteractionRequestedEventArgs : EventArgs
{
public InteractionRequestedEventArgs(INotification context, Action callback)
{
this.Context = context;
this.Callback = callback;
}
public INotification Context { get; private set; }
public Action Callback { get; private set; }
}
InteractionRequestTrigger
专用的交互请求触发器。`GetEventName()` 方法返回触发器正在监听的事件的名称。在这种情况下,它是“`Raised`”事件。
public class InteractionRequestTrigger : EventTrigger
{
protected override string GetEventName()
{
return "Raised";
}
}
TriggerActionBase
所有交互请求操作的基类。它实现了 `TriggerAction` 类中定义的 `abstract Invoke(object)` 方法,该方法在事件被触发时调用。它从事件参数中检索通知对象和回调操作,并在收到有效的通知对象时调用 `ExecuteAction()` 方法。
public abstract class TriggerActionBase<T> : TriggerAction<FrameworkElement>
where T : class, INotification
{
private T notification;
private Action callback;
protected T Notification
{
get
{
return this.notification;
}
}
protected Action Callback
{
get
{
return this.callback;
}
}
protected override void Invoke(object parameter)
{
var interactionRequestedEventArgs = parameter as InteractionRequestedEventArgs;
if (interactionRequestedEventArgs != null)
{
this.notification = interactionRequestedEventArgs.Context as T;
this.callback = interactionRequestedEventArgs.Callback;
if (this.notification != null)
{
this.ExecuteAction();
}
}
}
protected abstract void ExecuteAction();
}
结论
如背景部分所述:我并没有发明这种模式。其基础是 Microsoft Prism 库的一部分。但我发现完整的库对于我的“简单”应用程序来说太大了。尽管如此,我还是喜欢这个概念,因此我在这里以所示的方式使用它。
我写这篇文章的主要原因是提高大家对 ViewModel 层调用 GUI 组件必须谨慎处理的认识。我敦促软件工程师不要实现我介绍示例中那样的代码。至少当自动化测试成为问题时,上面展示的对话框窗口将导致测试用例失败。
我明白,使用依赖注入 (DI) 和控制反转 (IoC) 可以实现相同的功能。这意味着在 `ViewModel` 层定义一个接口,该接口在 View 层实现。接口和实现都注册到 DI 机制,当 `ViewModel` 层使用(即命令调用)接口实现时,DI 机制将实例化接口实现。
我之所以不使用 DI 方法,是因为自动化应用程序测试。通常,我使用 SpecFlow 库来编写验收测试,并且我的场景会操作 ViewModel 层类,模拟应用程序层。在这种情况下,我可以订阅交互请求并在测试上下文中保留“已调用”状态。使用 DI 方法,我必须实现并注入交互请求的测试实现,如果它与“真实”实现不符,则可能会影响实际代码执行。