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

使用进度报告创建 MVVM 后台任务

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (33投票s)

2011年9月9日

CPOL

18分钟阅读

viewsIcon

96576

downloadIcon

6179

本文介绍了如何在 MVVM 中实现耗时任务的后台处理和并行处理,以及如何为这些任务创建带取消功能的进度对话框。

引言

本文介绍了如何在 WPF 应用程序中执行后台任务,并提供进度报告。下面介绍的技术可以应用于任何涉及对列表中项目进行重复处理的耗时任务。例如,可能需要对指定文件夹中的每个文件执行相同的处理,或者打印大量文档。

关于在 MVVM 应用程序上下文中进行后台和并行处理的文章并不多,本文旨在弥补这一不足。后台和并行处理基于 .NET 4.0 引入的任务并行库 (TPL),它极大地简化了 .NET 应用程序中这两种处理方式。

本文的演示应用程序围绕 MVVM 模式构建,并演示了我组织 MVVM 应用程序的方法,包括命令和服务的用法。如果您不熟悉 MVVM,或者我的 MVVM 实现方式不熟悉,我建议您阅读《MVVM 和 WPF DataGrid》,其中更详细地讨论了我的通用方法。

背景

像许多人一样,我尽可能长时间地回避 .NET 的后台处理。它似乎困难且晦涩难懂,只要能绕过它,我就会这么做。然后,我开始处理一个需要一次处理 1000 个文件的应用程序。深入研究该应用程序后,我清楚地意识到该应用程序必须在后台进行大量繁重的工作。此外,考虑到多核处理器的普及,无法证明不提供并行处理支持来编写该应用程序是合理的。

我当时正在编写一个为延时摄影图像添加可见时间戳的应用程序。时间戳操作非常耗时,特别是当一次处理 1000 张图像时。该应用程序显然会从后台和并行处理中受益,并且显然需要一个进度对话框,该对话框可以根据请求取消处理。因此,我吞了两片阿司匹林,开始琢磨如何将所有这些功能添加到我原以为会是一个简单的程序中。

在网上研究这个问题时,我发现了 .NET 4 中的任务并行库 (TPL)。它是一个非常出色的库,但我找不到有关使用该库的端到端教程,尤其是在 MVVM 上下文中。本文的其余部分将在本文附带的演示项目的上下文中,展示我为解决该问题而开发的解决方案。

演示项目

演示项目被组织成一个 MVVM 应用程序。应用程序中的每个视图元素(主窗口和进度对话框)都有自己的视图模型。与其他一些 MVVM 应用程序不同,视图不创建其视图模型,视图模型也不创建其视图。相反,视图和视图模型都由一个比两者都高级的协调器创建。例如,主窗口由应用程序拥有,因此主窗口及其视图模型都由 App 对象在 OnStartup() 事件处理程序的覆盖中创建。

protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);

    // Set up the main window and its view model
    var mainWindow = new MainWindow();
    var mainWindowViewModel = new MainWindowViewModel();
    mainWindow.DataContext = mainWindowViewModel;
    mainWindow.Show();
}

演示的后台工作由操作服务类(在 .NET 解决方案的 *Operations > Services* 文件夹中)执行。演示项目不执行任何实际工作。相反,我们使用 .NET 的 Thread.Sleep() 方法来模拟耗时任务。

// Simulate a time-consuming task
Thread.Sleep(300);

演示应用程序基于我最近编写的一个生产应用程序,用于为延时摄影照片添加时间戳。该应用程序中的处理包括两个步骤:

  • 为延时摄影系列中的每张照片应用时间戳;
  • 将文件属性“创建日期”和“上次修改日期”从原始延时摄影照片复制到带有时间戳的副本。

在我的生产应用程序中,第一步比第二步耗时要长得多,这给进度跟踪带来了一些额外的挑战。我通过让第一个后台任务为每个工作项调用 Thread.Sleep() 300 毫秒,并让第二个后台任务为每个工作项调用该方法 100 毫秒来在演示应用程序中保留了这种不平衡。我们将在下面看到如何补偿这种不平衡,以便进度条在两个过程中都能平滑地递增。

演示应用程序的主窗口非常简单——它只包含一个“执行演示工作”按钮。此按钮绑定到 MainWindowViewModel 中的 DoDemoWork 命令。

<Button Content="Do Demo Work" Command="{Binding DoDemoWork}" ... />

视图模型的 DoDemoWork 命令在视图模型的 Initialize() 方法(在视图模型构造函数中调用)中初始化为 ICommand 对象。

private void Initialize()
{
    // Initialize command properties
    this.DoDemoWork = new DoDemoWorkCommand(this);

    ...
}

请注意,DoDemoWorkCommand 对象通过构造函数注入接收到对视图模型的引用。将命令封装在单独的 ICommand 对象中可以将代码从视图模型中移出。在 MVVM 应用程序中,命令通常充当视图模型与应用程序的业务层(应用程序提供的模型和服务)之间的桥梁。在我的 MVVM 应用程序中,命令通常充当协调器;它们将大部分繁重的工作委托给单独的服务类。这使得命令类更轻量,并专注于其封装应用程序命令的工作。

DoDemoWorkCommand(位于应用程序的 *Operations > Commands* 文件夹中)派生自 ICommand,因此其大部分代码都在 Execute() 方法中。让我们看一下命令执行的每个步骤。

步骤 1:初始化工作列表

该命令初始化一个工作列表。通常,此工作列表由应用程序的业务需求驱动。我的图像处理应用程序处理指定文件夹中的所有文件,因此工作列表是要处理的图像的文件路径列表。每个文件夹通常包含 999 张延时摄影图像。为了模拟相同范围的问题,DoDemoWork 命令为工作列表生成一个简单的 999 个整数列表。

// Initialize 
...
var workList = Enumerable.Range(0, 999).ToArray();

步骤 2:创建取消令牌源

进度对话框包含一个“取消”按钮,可用于取消正在进行的 ooperatio。要做到这一点,应用程序将需要一个 System.Threading.CancellationTokenSource 对象。我们将在下面更详细地讨论该对象。操作服务类和 Cancel 命令都需要此令牌源,因此该命令创建一个并将其传递给进度对话框视图模型。

步骤 3:设置 ProgressMax 值

该命令设置其控制的操作的最大进度值。在这种情况下,操作由两个任务组成,第一个任务的完成时间是第二个任务的三倍。为了使进度条平滑流动,我们需要为第一个任务处理的每个项目推进进度计数器三“次”,为第二个任务处理的每个项目推进一次。结果将是进度条在第一个任务完成后显示 75% 的完成度,在第二个任务完成后显示 100% 的完成度。为了实现这一点,我们将整个操作的最大进度值设置为工作列表长度的四倍。

// Set the maximum progress value
progressDialogViewModel.ProgressMax = workList.Length * 4;

在演示应用程序中,我们知道第一个任务的耗时是第二个任务的三倍,因为我们就是这样设置任务的。在生产应用程序中,您需要试验“点击”设置,以获得为进度条提供最平滑流程的组合。ProgressMax 值将始终设置为工作列表长度的倍数;乘数将等于分配给所有后台任务的点击总数。在演示应用程序中,乘数为四;第一个任务三,第二个任务一。

步骤 4:宣布工作开始

这个第四步是我对 MVVM 实现方式与其他一些实现方式不同之处。我们都同意,当代码从代码隐藏移到视图模型时,MVVM 的效果最好。但是,一些开发人员遵循一个规则,即 MVVM 窗口不应有任何代码隐藏。其他开发人员遵循一个规则,即视图不应有任何代码,即使是在单独的视图服务类中。我不遵循任何一个规则,因为我认为它们违反了关注点分离原则。

原因如下:视图通常负责与用户交互,而对话框(如进度对话框)的呈现是这些关注点之一。因此,视图需要拥有一个进度对话框,并且应该完全控制其打开和关闭的时间。在视图中添加一些代码来管理对话框的打开和关闭是不可避免的。

因此,我遵循一个合理的规则,而不是“视图中无代码”的规则:打开和关闭对话框的代码放在视图中;所有其他与对话框状态和行为相关的代码都放在对话框的视图模型中。在演示应用程序中,这就是 ProgressDialogViewModel

请注意,进度对话框由其所有者(主窗口)实例化,而进度对话框视图模型由其所有者(主窗口视图模型在其 Initialize() 方法中)实例化。当主窗口实例化进度对话框时,它从主窗口视图模型获取对话框的视图模型,并将进度对话框附加到对话框的视图模型。

请注意此结构中依赖关系运行的方向。

视图包含对其视图模型的引用,视图在设置 DataContext 属性时接收该引用。该引用使视图依赖于视图模型——我们无法更改视图模型而不更改视图。但是,视图模型不包含对其正在使用的视图的引用。这意味着视图模型不依赖于特定的视图,因此我们可以更改视图,或将其完全替换为不同的视图,而无需打开视图模型并重新编译它,只要新视图或修改后的视图符合视图模型所体现的 API。

这是我组织 MVVM 应用程序的核心规则:视图模型必须完全不知道它所支持的视图。这种方法使 UI 层完全独立于应用程序的其他层,从而实现了清晰的层次分离,使应用程序更易于测试和维护。我可以将视图替换为一组单元测试,视图模型对此一无所知。

这一切都很好,但这确实会产生一个棘手的问题。如果主窗口拥有对话框并控制对话框的打开和关闭,那么 DoDemoWork 命令如何告诉视图显示对话框?该命令与主窗口视图模型一样,不知道应用程序使用的特定视图。换句话说,DoDemoWork 命令没有主窗口的引用,因此无法调用窗口来显示对话框。

答案是该命令不调用主窗口。调用该命令的能力将创建视图模型对视图的依赖,从而破坏 MVVM 设计旨在促进的清晰的分层。因此,该命令只是调用一个视图模型方法,向整个应用程序宣布耗时任务已开始。稍后,它将调用相应的方法来引发宣布耗时工作已结束的事件。这是宣布工作开始的命令代码。

// Announce that work is starting
m_ViewModel.RaiseWorkStartedEvent();

宣布耗时工作完成的代码略有不同,我将在下面讨论。

这是视图模型中的事件声明,以及引发 WorkStartedWorkEnded 事件的方法。

#region Events

public event EventHandler WorkStarted;
public event 
EventHandler WorkEnded;

#endregion

...

#region Event Invokers

internal void RaiseWorkStartedEvent()
{
    // Exit if no subscribers
    if (WorkStarted == null) return;

    // Raise event
    WorkStarted(this, new EventArgs());
}

internal void RaiseWorkEndedEvent()
{
    // Exit if no subscribers
    if (WorkEnded == null) return;

    // Raise event
    WorkEnded(this, new EventArgs());
}

#endregion

主窗口在 MainWindowViewModel 设置为窗口的数据上下文时触发的事件处理程序中订阅这些事件。

private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
    var viewModel = (MainWindowViewModel)e.NewValue;
    viewModel.WorkStarted += OnWorkStarting;
    viewModel.WorkEnded += OnWorkEnding;
}

此事件订阅代码位于主窗口的代码隐藏中,但也可以放在视图服务类中。由于它直接与进度对话框的打开和关闭相关,因此符合 MVVM 设计原则。

作为事件订阅的结果,当主窗口附加到其视图模型时,它会立即订阅视图模型事件,这些事件将在耗时任务开始和结束时通知它。打开和关闭进度对话框的实际工作由上述事件订阅中引用的事件处理程序执行。

void OnWorkEnding(object sender, EventArgs e)
{
    ViewServices.CloseProgressDialog();
}

void OnWorkStarting(object sender, EventArgs e)
{
    var mainWindowViewModel = (MainWindowViewModel)this.DataContext;
    var progressDialogViewModel = mainWindowViewModel.ProgressDialogViewModel;
    ViewServices.ShowProgressDialog(this, progressDialogViewModel);
}

当工作开始时,命令会调用 WorkStarted 事件,这会导致主窗口实例化进度对话框并将其附加到对话框的视图模型。当工作结束时,命令会调用 WorkEnded 事件,这会导致主窗口关闭对话框。

步骤 5:执行后台任务

让我们回到 DoDemoWork 命令。下一步是执行应用程序后台任务的核心。这是执行这些任务的命令代码。

// Launch first background task
var taskOne = Task.Factory.StartNew(() => ServiceOne.DoWork(workList, progressDialogViewModel));

// Launch second background task
var taskTwo = taskOne.ContinueWith(t => ServiceTwo.DoWork(workList, progressDialogViewModel));

该命令使用 .NET 4.0 任务并行库来执行这些后台任务。正如我们稍后将看到的,TPL 包含强大的方法,可以大大简化将重复性任务分区以分配给多核处理器内核的问题。但 TPL 还包含其他方法,这些方法也简化了在后台线程上执行任务的工作。

TPL 包含一个 Task 对象,该对象包含用于生成新后台任务的工厂方法。在 TPL 的上下文中,Task 本质上是方法的一个包装器,它允许该方法在后台线程上执行。在上面的第一个后台任务调用中,我们调用 Task.Factory.StartNew(),将一个 Action 委托以 lambda 表达式的形式传递给它。第一个任务的委托是操作服务类 ServiceOneDoWork() 方法。该方法接收命令的工作列表,以及对进度对话框视图模型的引用。我们将在稍后更详细地研究服务方法。现在,请注意第一个 Task 被赋值给一个名为 taskOne 的变量。

我的图像处理应用程序最初会引发访问冲突异常,而只有采用对工作列表进行两步处理才能防止这些异常。第二步只能在第一步完成后才能开始。换句话说,我必须将两个后台任务按顺序链接起来。

Task.ContinueWith() 方法提供了此功能。我通过在 taskOne 上调用 ContinueWith() 来创建一个名为 taskTwo 的第二个 Task 对象。我将一个 Action 委托传递给 taskTwo,该委托是我们第二个操作服务类 ServiceTwoDoWork() 方法。结果,taskTwo 将在 taskOne 完成后立即开始。

步骤 6:宣布工作完成

DoDemoWork 命令执行的最后一步应该很简单——它向整个应用程序宣布已完成开始的耗时工作。正如我们上面所见,此声明将导致主窗口关闭进度对话框。

工作完成的声明不能在第二个后台任务完成之前做出。这意味着我们有第三个后台任务,它以与 taskTwo 链接到 taskOne 相同的方式链接到 taskTwo

taskTwo.ContinueWith(t => m_ViewModel.RaiseWorkEndedEvent(), 
   TaskScheduler.FromCurrentSynchronizationContext());

请注意,我们使用了 ContinueWith() 的另一个重载,它接受一个 TaskScheduler 对象作为参数。我们传递给 ContinueWith()Action 委托是一个视图模型方法,它在主线程上运行。由于我们处于后台线程,因此无法直接调用该方法。传递 TaskScheduler.FromCurrentSynchronizationContext() 会导致 ContinueWith() 将调用传递给应用程序 Dispatcher 对象,该对象将其放入消息队列。这与从后台线程调用 Dispatcher.Invoke() 非常相似。

服务类

解决方案的 *Operations > Services* 文件夹中的两个操作服务类基本相同,因此我将一起讨论它们。在生产应用程序中,这些类不会相同,因为它们会提供不同的服务。例如,在我的图像处理应用程序中,第一个服务为工作列表中的照片添加时间戳,第二个服务将文件属性“创建日期”和“上次修改日期”从原始照片复制到其对应的时间戳副本。尽管如此,两个服务类都遵循一个通用模式:公开一个公共服务方法来管理整个任务,以及一个私有方法来处理每个单独项目的处理。由于演示应用程序尽可能接近模拟生产条件,我设置了两个服务类,每个类处理一个项目所需的时间不同,这与我的图像处理应用程序类似。

每个服务类都公开一个 DoWork() 方法,该方法对工作列表进行处理设置。DoWork() 使用 System.Threading.Tasks.Parallel.ForEach() 方法将工作列表分区并将工作项分发到本地处理器的所有可用内核。

首先,该方法从进度对话框视图模型获取 CancellationTokenSource,并使用令牌源生成一个取消令牌。它将此令牌包装在 ParallelOptions 对象中。然后,它调用 Parallel.ForEach(),将工作列表、并行选项对象和 Action 委托传递给该方法。.NET 会以高效的方式处理工作列表分区并将其分发到可用内核的所有工作。

// Process work items in parallel
try
{
    Parallel.ForEach(workList, loopOptions, t => ProcessWorkItem(viewModel));
}
catch (OperationCanceledException)
{
    Pvar ShowCancellationMessage = new Action(viewModel.ShowCancellationMessage);
    PApplication.Current.Dispatcher.Invoke(DispatcherPriority.Normal, ShowCancellationMessage);
}

请注意,Parallel.ForEach() 调用被包装在 try-catch 块中。如果此操作被取消,Parallel.ForEach() 将引发 OperationCanceledException。我们在此处捕获该异常并使用它来显示取消消息。我将在下面进一步讨论该消息。

在这种情况下,传递给 Parallel.ForEach()Action 委托是同一服务类中的 ProcessWorkItem() 方法。该方法执行两个步骤。首先,它执行涉及的任何工作。在我的图像处理应用程序中,这包括为图像添加时间戳或复制文件属性。在演示应用程序中,它仅涉及睡眠 300 毫秒(ServiceOne 类)或 100 毫秒(ServiceTwo 类)。

在处理完工作项后,ProcessWorkItem() 方法会推进进度计数器。进度对话框视图模型包含一个名为 IncrementProgressCounter() 的方法,该方法接受一个整数参数。该整数指定应推进计数器的点击次数。ServiceOne 类每次处理一项时推进计数器三次,而 ServiceTwo 类每次处理一项时推进一次。

由于服务类方法在后台线程上运行,因此它们不能直接调用视图模型方法(该方法在主线程上运行)。我们上面在最后一个 ContinueWith() 调用中看到了这一点。在这种情况下,我们使用 Dispatcher.Invoke() 方法从后台任务调用视图模型。

// Increment progress counter
var IncrementProgressCounter = 
    new Action<int>(viewModel.IncrementProgressCounter);
Application.Current.Dispatcher.Invoke(DispatcherPriority.Normal, 
                               IncrementProgressCounter, 3);

我们创建一个指向我们要调用的视图模型方法的 Action 委托。然后,我们将该委托传递给应用程序 Dispatcher 对象,该对象控制应用程序的主线程。Dispatcher 将调用添加到其消息队列,以便在适当的时候执行。

显示进度

进度对话框以百分比和进度条的形式显示进度。

进度显示由进度对话框视图模型中的三个属性管理:ProgressProgressMaxProgressMessageIncrementProgressCounter() 方法与这些属性配合使用,以便在工作项完成时推进显示。

调用 IncrementProgressCounter() 方法时,它会向 Progress 属性添加指定数量的“点击”。点击次数由调用者确定。正如我们上面所见,ServiceOne 对每个项目推进计数器三次,ServiceTwo 对每个项目推进一次。在推进计数器后,该方法会更新新计数的进度消息,并将结果存储在 ProgressMessage 属性中。这些视图模型属性的更改会立即反映在进度对话框中。

取消正在进行的 ooperatio

进度对话框的“取消”按钮绑定到进度对话框视图模型中的 Cancel 命令。回想一下,DoDemoWork 命令创建了一个 CancellationTokenSource 对象并将其传递给进度对话框视图模型。两个操作服务类使用这些令牌源生成取消令牌,将它们包装在 ParallelOptions 对象中并传递给各自的 Parallel.ForEach() 方法。当用户单击进度对话框上的“取消”按钮时,我们将使用这些取消令牌。

TPL 使得取消正在进行的 ooperatio 变得如此容易,这真是一件了不起的事情。只需在令牌源上调用 Cancel(),.NET 就会处理取消已提供来自同一源的取消令牌的任何任务。因此,取消命令只需要做这些。

// Cancel all pending background tasks
m_ViewModel.TokenSource.Cancel();

在令牌源上调用 Cancel() 后,从此源生成的所有令牌都将被取消,并且任何已提供这些令牌的 Parallel.ForEach() 循环都将引发 OperationCanceledException。正如我上面提到的,操作服务类会捕获此异常并使用它来向进度对话框视图模型发布取消消息。

var ShowCancellationMessage = new Action(viewModel.ShowCancellationMessage);
Application.Current.Dispatcher.Invoke(DispatcherPriority.Normal, ShowCancellationMessage);

我们通过声明一个 Action 委托并将其传递给应用程序 Dispatcher 对象来调用视图模型的 ShowCancellationMessage() 方法,就像我们调用 IncrementProgressCounter() 方法一样。

结论

希望通过对演示项目的这次相当长的游览,能帮助您构建自己的 MVVM 应用程序,这些应用程序可以在不冻结 UI 的情况下执行耗时任务。演示应用程序中使用的技术可以适应各种任务,如打印多个文档或批量处理一千张图像。

一如既往,我乐于接受有关如何改进本文的反馈和建议。在 CodeProject 上发布的好处之一是它产生的同行评审质量。我计划不时更新本文,并且,当然,我会注明任何被纳入更新的建议的作者。请在本文下方的评论和讨论部分留下任何一般性问题、评论或建议。

历史

  • 2011/09/08:初始版本完成。
© . All rights reserved.