MVVM

MVVM 应用程序中的多线程与调度

Laurent Bugnion

下载代码示例

大约一年前,我开始为 MSDN 杂志网站撰写有关 Model-View-ViewModel (MVVM) 模式的一系列文章(您可以在 is.gd/mvvmmsdn 上访问所有这些文章)。这些文章展示了如何按照此模式使用 MVVM Light Toolkit 的组件构建松散耦合的应用程序。我探究了依存关系注入 (DI) 和控制反转 (IOC) 容器模式(包括 MVVM Light SimpleIoc),介绍了 Messenger 并且讨论了 View 服务(例如导航、对话框等)。我还展示了如何创建设计时数据来最大程度利用可视化设计器,例如 Blend;此外,还谈论了 RelayCommand 和 EventToCommand 组件,这些组件替代事件处理程序,可实现 View 与其 ViewModel 之间更加分离的关系。

在本文中,我要深入探究新型客户端应用程序中的另一种常见情况 — 处理多线程以及帮助它们互相通信。在 Windows 8、Windows Phone、Windows Presentation Foundation (WPF)、Silverlight 等新型应用程序和框架中,多线程是一个日益重要的话题。在上述的每一个、即使是最弱的系统上也需要启动多个后台线程并对其进行管理。实际上,对在计算能力较低的小型平台提供更好的 UX 而言,这更显得重要。

Windows Phone 平台是一个很好的示例。在第一个版本 (Windows Phone 7) 中,在长列表中实现顺畅滚动非常难,尤其是在项目模板包含图像时。但在之后的版本中,图像以及一些动画的解码传递给了专用后台线程。因此当加载图像时,不会再影响主线程,并且滚动很顺畅。

该示例强调了我将在本文中探讨的一些重要概念。我将首先回顾多线程在基于 XAML 的应用程序中的一般工作方式。

简言之,线程可被视为应用程序的一个较小执行单位。每个应用程序都至少拥有一个线程,这称为主线程。这是在启动时调用应用程序的主方法时由操作系统启动的线程。注意,在所有支持的平台上或多或少都会发生这种情况,在强大计算机上运行的 WPF 上与在计算能力有限且基于 Windows Phone 的设备上同样频繁。

当调用方法时,该操作将添加到一个队列中。每个操作均按照将它们添加到队列中的顺序连续执行(不过可通过为这些操作指定优先级来影响执行顺序)。负责管理此队列的对象称之为线程调度程序。此对象是 WPF、Silverlight 和 Windows Phone 中 Dispatcher 类的一个实例。在 Windows 8 中,该调度程序对象名为 CoreDispatcher,其使用的 API 略有不同。

根据应用程序的要求,新线程可在代码中显式启动,由某些库隐式启动,或者由操作系统启动。大多数情况下,启动新线程的主要目的是执行操作(或等待某个操作的结果),而不会导致应用程序的其余部分被阻塞。计算密集型操作、I/O 操作等便是这种情况。这就是为什么新型应用程序日益多线程化的原因,因为 UX 需求也在增加。当应用程序变得更加复杂时,它们启动的线程数也开始增加。这一趋势的一个典型示例是 Windows 应用商店的应用中使用的 Windows 运行时框架。在这些新型客户端应用程序中,异步操作(在后台线程上运行的操作)已经是家常便饭了。例如,Windows 8 中的每个文件访问现在都是异步操作。以下是在 WPF 中(同步)读取文件的方式:

public string ReadFile(FileInfo file)
{
  using (var reader = new StreamReader(file.FullName))
  {
    return reader.ReadToEnd();
  }
}

以下是 Windows 8 中的等同(异步)操作:

public async Task<string> ReadFile(IStorageFile file)
{
  var content = await FileIO.ReadTextAsync(file);
  return content;
}

注意,在 Windows 8 版本中出现了 await 和 async 关键字。它们的作用是为了避免在异步操作中使用回调并提高代码的可读性。此处需要它们是因为文件操作是异步的。相反,WPF 版本是同步的,如果正在读取的文件较长,则这可能会阻塞主线程。这可能导致动画不流畅,或者 UI 未更新,结果使 UX 变得糟糕。

同样,如果应用程序中的长操作可能使 UI 变得不流畅,则应转到后台线程进行处理。例如,在 WPF、Silverlight 和 Windows Phone 中,图 1 中的代码启动一个运行长循环的后台操作。在每个循环中都会使该线程短暂处于睡眠状态,以便使其他线程有时间处理自己的操作。

图 1:Microsoft .NET Framework 中的异步操作

public void DoSomethingAsynchronous()
{
  var loopIndex = 0;
  ThreadPool.QueueUserWorkItem(
    o =>
    {
      // This is a background operation!
      while (_condition)
      {
        // Do something
        // ...
        // Sleep for a while
        Thread.Sleep(500);
      }
  });
}

线程间能够通信

当一个线程需要与另一个线程通信时,需要采取一些防范措施。例如,我将修改图 1 中的代码,以便每次循环向用户显示状态消息。为此,我在 while 循环中添加了一行代码,设置 XAML 中的 StatusTextBlock 控件的 Text 属性:

while (_condition)
{
  // Do something
  // Notify user
  StatusTextBlock.Text = 
    string.Format("Loop # {0}", loopIndex++);
  // Sleep for a while
  Thread.Sleep(500);
}

本文附带的名为 SimpleMultiThreading 的应用程序显示了此示例。如果您使用标记为“Start (crashes the app)”的按钮运行该应用程序,该应用程序实际上会崩溃。那这是怎么回事呢?在创建对象时,该操作发生在调用构造函数方法所在的线程。对于 UI 元素,在加载 XAML 文档时,XAML 分析器会创建对象。所有这一切都在主线程上进行。因此,所有这些 UI 元素都属于主线程,这也通常称为 UI 线程。当先前代码中的后台线程尝试修改 StatusTextBlock 的 Text 属性时,则会导致非法的跨线程访问。因此会引发异常。在调试器中运行此代码可展示这一点。图 2 显示了异常对话框。注意“Additional information”消息,表明此问题的根源。

Cross-Thread Exception Dialog
图 2:跨线程异常对话框

要使该代码正常运行,后台线程需要通过联系主线程的调度程序,以将操作加入主线程上的队列。令人欣慰的是,每个 FrameworkElement 也是一个 DispatcherObject,如图 3 中的 .NET 类层次结构所示。每个 DispatcherObject 均公开 Dispatcher 属性,该属性提供对其所有者调度程序的访问。因此,该代码可按照图 4 中所示的方式加以修改。

Window Class Hierarchy
图 3:Window 类层次结构

图 4:调度对 UI 线程的调用

while (_condition)
{
  // Do something
  Dispatcher.BeginInvoke(
    (Action)(() =>
    {
      // Notify user
      StatusTextBlock.Text = 
        string.Format("Loop # {0}", loopIndex++);
    }));
  // Sleep for a while
  Thread.Sleep(500);
}

MVVM 应用程序中的调度

当从 ViewModel 执行后台操作时,情况略有不同。通常,ViewModel 不从 DispatcherObject 继承。它们是执行 INotifyPropertyChanged 接口的 Plain Old CLR Objects (POCO)。例如,图 5 显示了派生自 MVVM Light ViewModelBase 类的 ViewModel。在真正 MVVM 方式中,我添加了一个引发 PropertyChanged 事件的名为 Status 的可观察属性。然后从后台线程代码中,我尝试用信息消息设置此属性。

图 5:更新 ViewModel 中的 Bound 属性

public class MainViewModel : ViewModelBase
{
  public const string StatusPropertyName = "Status";
  private bool _condition = true;
  private RelayCommand _startSuccessCommand;
  private string _status;
  public RelayCommand StartSuccessCommand
  {
    get
    {
      return _startSuccessCommand
        ?? (_startSuccessCommand = new RelayCommand(
          () =>
          {
            var loopIndex = 0;
            ThreadPool.QueueUserWorkItem(
              o =>
              {
                // This is a background operation!
                while (_condition)
                {
                  // Do something
                  DispatcherHelper.CheckBeginInvokeOnUI(
                    () =>
                    {
                      // Dispatch back to the main thread
                      Status = string.Format("Loop # {0}", 
                         loopIndex++);
                    });
                  // Sleep for a while
                  Thread.Sleep(500);
                }
              });
          }));
    }
  }
  public string Status
  {
    get
    {
      return _status;
    }
    set
    {
      Set(StatusPropertyName, ref _status, value);
    }
  }
}

在我尝试将 Status 属性数据绑定到 XAML 前端中的 TextBlock 前,在 Windows Phone 或 Silverlight 中运行此代码很顺利。再次运行此操作会使应用程序崩溃。就像之前一样,此后台线程一尝试访问属于另一个线程的元素便会引发异常。即使通过数据绑定来进行此访问也会出现这种情况。

注意,在 WPF 中情况不同,即使在 Status 属性被数据绑定到 TextBlock 时,图 5 中所示的代码也正常运行。这是因为与其他所有 XAML 框架不同,WPF 会自动将 PropertyChanged 事件调度到主线程。在其他所有框架中都需要调度解决方案。实际上,真正需要的是仅在必要时才对此调用进行调度的系统。为了在 WPF 与其他框架之间共享 ViewModel 代码,最好不必关心调度问题,而是有一个能自动执行此操作的对象。

因为 ViewModel 是一个 POCO,它不能访问 Dispatcher 属性,因此我需要通过另一种方式来访问主线程,以将操作加入队列中。这是 MVVM Light DispatcherHelper 组件的作用。实际上,该类所做的是将主线程的调度程序保存在静态属性中,并公开一些实用工具方法,以便通过便捷且一致的方式访问。为了实现正常功能,需要在主线程上初始化该类。最好应在应用程序生命周期的初期进行此操作,使应用程序一开始便能够访问这些功能。通常,在 MVVM Light 应用程序中,DispatcherHelper 在 App.xaml.cs 中进行初始化,App.xaml.cs 是定义应用程序启动类的文件。在 Windows Phone 中,在应用程序的主框架刚刚创建之后,在 InitializePhoneApplication 方法中调用 Dispatcher­Helper.Initialize。在 WPF 中,该类是在 App 构造函数中进行初始化的。在 Windows 8 中,在窗口激活之后便立刻在 OnLaunched 中调用 Initialize 方法。

完成了对 DispatcherHelper.Initialize 方法的调用后,DispatcherHelper 类的 UIDispatcher 属性包含对主线程的调度程序的引用。相对而言很少直接使用该属性,但如果需要可以这样做。但最好使用 CheckBeginInvokeOnUi 方法。此方法将委托视为参数。通常使用图 6 中所示的 lambda 表达式,但也可以使用一个命名方法。

图 6:使用 DispatcherHelper 来避免崩溃

while (_condition)
{
  // Do something
  DispatcherHelper.CheckBeginInvokeOnUI(
    () =>
    {
      // Dispatch back to the main thread
      Status = string.Format("Loop # {0}", loopIndex++);
    });
  // Sleep for a while
  Thread.Sleep(500);
}

顾名思义,此方法首先执行检查。如果此方法的调用方已经在主线程上运行,则无需进行调度。在这种情况下会直接在主线程上立即执行委托。但如果此调用方是在后台线程上,则执行调度。

由于此方法是在调度之前进行检查,因此该调用方可依赖该代码将始终使用最佳调用这一事实。这对于编写跨平台代码时尤其有用,在这种情况下,多线程在不同平台上工作时可能略有不同。在这种情况下,总是可以共享图 6 中所示的 ViewModel 代码,无需修改设置 Status 属性的代码行。

此外,DispatcherHelper 还使调度程序 API 在 XAML 平台间的区别变得抽象化。在 Windows 8 中,CoreDispatcher 的主要成员是 RunAsync 方法和 HasThreadAccess 属性。但在其他 XAML 框架中,分别使用 BeginInvoke 和 CheckAccess 方法。通过使用 DispatcherHelper,您不必担心这些区别,并且可轻松共享此代码。

真实调度:传感器

我将通过构建指南针传感器 Windows Phone 应用程序来阐述 DispatcherHelper 的使用。

本文随附的示例代码包含一个初步完成的应用程序,名为 CompassSample - Start。在 Visual Studio 中打开该应用程序时,从 MainViewModel 对该指南针传感器的访问被封装在名为 SensorService 的服务中,该服务是 ISensorService 接口的实现。可在 Model 文件夹中找到这两个元素。

MainViewModel 在 ISensorService 的构造函数中获得对 ISensorService 的引用,并且对于每个指南针更改均使用 SensorService RegisterForHeading 方法进行注册。此方法需要回调,该传感器每次报告基于 Windows Phone 设备方向的更改时都将执行回调。在 MainViewModel 中,将默认构造函数替换成以下代码:

sensorService.RegisterForHeading(
  heading =>
  {
    Heading = string.Format("{0:N1}°", heading);
    Debug.WriteLine(Heading);
  });

不幸的是,无法在 Windows Phone 仿真器中模拟此设备指南针。要测试此代码,您将需要在物理设备上运行该应用程序。连接开发者设备,通过单击 F5 在调试模式下运行此代码。观察 Visual Studio 中的输出控制台。您将看到列出了指南针的输出。如果您移动该设备,您将能够找到北方,观察该值是如何不断更新的。

下一步,我会将 XAML 中的 TextBlock 绑定到 MainViewModel 中的 Heading 属性。打开 MainPage.xaml,找到位于 ContentPanel 中的 TextBlock。将 Text 属性中的“Nothing yet”替换成“{Binding Heading}”。如果您在调试模式下再次运行该应用程序,则您将看到崩溃,并且显示与先前类似的一条错误消息。这又是跨线程异常。

引发此错误是因为该指南针传感器是在后台线程上运行的。当调用回调代码时,其也在后台线程上运行,就像 Heading 属性的 setter 一样。由于 TextBlock 属于主线程,因此引发异常。在这里,您也需要创建一个“安全区域”,负责将操作调度到主线程。要执行此操作,打开 SensorService 类。CurrentValueChanged 事件是由名为 CompassCurrentValueChanged 的方法处理的;这是执行回调方法的地方。将此代码更换成以下内容,这里使用 DispatcherHelper:

void CompassCurrentValueChanged(
  object sender,
  SensorReadingEventArgs<CompassReading> e)
{
  if (_orientationCallback != null)
  {
    DispatcherHelper.CheckBeginInvokeOnUI(
      () => _orientationCallback(e.SensorReading.TrueHeading));
  }
}

现在必须初始化 DispatcherHelper。要执行此操作,打开 App.xaml.cs,找到名为 InitializePhoneApplication 的方法。在此方法的最后,添加 DispatcherHelper.Initialize();。现在运行此代码会产生预计结果,正确显示基于 Windows Phone 的设备方向。

注意,并非 Windows Phone 中的所有传感器都在后台线程上引发它们的事件。例如,为了方便,用于获取电话定位的 GeoCoordinateWatcher 传感器已经返回到主线程上。通过使用 DispatcherHelper,您不必担心这一点,您可以始终以相同方式调用主线程的回调。

总结

我讨论了 Microsoft .NET Framework 如何处理线程,以及在后台线程想要修改主线程(也称为 UI 线程)创建的对象时需要采取哪些防范措施。您了解到导致崩溃的原因,以及为避免此崩溃,应使用主线程的调度程序正确处理此操作。

然后,我将这一知识转变成了 MVVM 应用程序,并且介绍了 MVVM Light Toolkit 的 DispatcherHelper 组件。我展示了如何使用该组件避免从后台线程进行通信时遇到的问题,以及该组件如何优化此访问,并使 WPF 与基于 XAML 的其他框架之间的区别抽象化。这样,它可实现 ViewModel 代码的轻松共享,并且使您的工作更加轻松。

最后,我通过真实示例演示了如何在 Windows Phone 应用程序中通过 Dispatcher­Helper 来避免在使用某些会在后台线程上引发事件的传感器时遇到的问题。

在下一篇文章中,我将进一步深入探究 MVVM Light 的 Messenger 组件,并展示如何使用该组件以真正分离的方式、在双方不需要知道对方的情况下实现对象间的轻松通信。

Laurent Bugnion 是 IdentityMine Inc. 的高级主管,目前在瑞士苏黎世工作。该公司是从事 Windows Presentation Foundation、Silverlight、Pixelsense、Kinect、Windows 8、Windows Phone 和用户体验等技术开发工作的。Microsoft 合作伙伴公司。他还是 Microsoft MVP 和 Microsoft 区域主管。

衷心感谢以下技术专家对本文的审阅:Thomas Petchel (Microsoft)