65.9K
CodeProject 正在变化。 阅读更多。
Home

ViewModel 与 View 交互请求

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2015 年 3 月 30 日

CPOL

8分钟阅读

viewsIcon

24287

downloadIcon

469

本文介绍了一种从 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 方法,我必须实现并注入交互请求的测试实现,如果它与“真实”实现不符,则可能会影响实际代码执行。

© . All rights reserved.