使用调度程序构建反应速度更快的应用程序
Shawn Wildermuth
本文讨论:
| 本文使用了以下技术: .NET Framework 3.0, WIndows Presentation Foundation |
如果您在创建一个直观、自然甚至精美的界面上花费了数月时间,但结果是用户不得不在他们的组合办公桌上敲打着手指等待程序响应,这会让人觉得丢脸。由于长 时间运行的进程导致应用程序的屏幕停滞不动,看到这样的情况是一件痛苦的事情。然而,创建响应迅速的应用程序需要进行认真的规划,这通常需要使长时间运行 的进程在其他线程中工作,以便释放出 UI 线程,使其随时跟上用户的进度。
我第一次真正体验响应速度可追溯到 Visual C++® 与 MFC 以及我曾经编写的第一个网格。当时,我正在帮助编写一个药学应用程序,该程序必须能够将每种药物显示在复杂的处方中。问题是有 30,000 种药物,因此我们决定先在 UI 线程中填充第一个满屏药物(时间大约为 50 毫秒),给人一种反应迅速的印象,然后使用后台线程完成填充不可见的药物(时间大约为 10 秒)。项目运行良好,而且我学到了非常宝贵的经验,那就是用户感知可以比现实更重要。
在创建具有吸引力的用户界面方面,Windows® Presentation Foundation (WPF) 是一项出色的技术,但这并不意味着您就不需要考虑应用程序的响应性。不管相关的长时间运行进程的类型为何(不管是从数据库获取大量结果,进行异步 Web 服务调用,还是任何数量的其他潜在密集型操作),简单的事实就是,响应更快的应用程序是让用户更满意的长期保证。但是,开始在 WPF 应用程序中使用异步编程模型之前,了解 WPF 线程模型非常重要。在本文中,我不但将会向您介绍此线程模型,还会向您展示基于调度程序的对象的工作原理,以及解释如何使用 BackgroundWorker 以便创建具有吸引力和响应性的用户界面。
线程模型
所 有 WPF 应用程序启动时都会加载两个重要的线程:一个用于呈现用户界面,另一个用于管理用户界面。呈现线程是一个在后台运行的隐藏线程,因此您通常面对的唯一线程 就是 UI 线程。WPF 要求将其大多数对象与 UI 线程进行关联。这称之为线程关联,意味着要使用一个 WPF 对象,只能在创建它的线程上使用。在其他线程上使用它会导致引发运行时异常。注意,WPF 线程模型可与基于 Win32® 的 API 进行顺畅的交互。这意味着 WPF 可以承载或承载于任何基于 HWND 的 API(Windows Forms、Visual Basic®、MFC,甚至是 Win32)。
线程关联由 Dispatcher 类处理,该类即是用于 WPF 应用程序的、按优先级排列的消息循环。通常,WPF 项目有单个 Dispatcher 对象(因此有单个 UI 线程),所有用户界面工作均以其为通道。
与 典型的消息循环不同,发送到 WPF 的每个工作项目都以特定的优先级通过 Dispatcher 进行发送。这就能够按优先级对项目排序,并延迟某种类型的工作,直到系统有时间来处理它们。(例如,有些工作项目可被延迟到系统或应用程序处于空闲状态 时。) 支持项目优先顺序使 WPF 能够让某种类型的工作拥有更多的权限,因此在线程上拥有比其他工作更多的时间。
在 本文的后面,我将会阐明,呈现引擎在更新用户界面方面比输入系统具备更高的优先级。这意味着不管用户是否正在使用鼠标、键盘或墨水打印系统,动画都将会继 续更新用户界面。这可以使用户界面看起来响应更快。例如,让我们假定您正在编写一个音乐播放应用程序(类似于 Windows Media® Player)。不管用户是否正在使用界面,您最有可能希望显示有关音乐播放的信息(包括进度条和其他信息)。对用户来说,这可以使界面看起来对他们最感兴趣的事情(在此例中为听音乐)响应更快。
除 了使用 Dispatcher 的消息循环将工作项目引导至用户界面线程之外,每个 WPF 对象也可感知对其负责的 Dispatcher(以及它由此所依赖的 UI 线程)。这意味着任何从第二个线程更新 WPF 对象的尝试均会失败。这就是 DispatcherObject 类的职责。
DispatcherObject
在 WPF 的类层次结构中,大部分都集中派生于 DispatcherObject 类(通过其他类)。如图 1 所示,您可以看到 DispatcherObject 虚拟类正好位于 Object 下方和大多数 WPF 类的层次结构之间。
图 1 DispatcherObject 派生
DispatcherObject 类有两个主要职责:提供对对象所关联的当前 Dispatcher 的访问权限,以及提供方法以检查 (CheckAccess) 和验证 (VerifyAccess) 某个线程是否有权访问对象(派生于 DispatcherObject)。CheckAccess 与 VerifyAccess 的区别在于 CheckAccess 返回一个布尔值,表示当前线程是否可以使用对象,而 VerifyAccess 则在线程无权访问对象的情况下引发异常。通过提供这些基本的功能,所有 WPF 对象都支持对是否可在特定线程(特别是 UI 线程)上使用它们加以确定。如果您正在编写您自己的 WPF 对象(诸如控件),那么您使用的所有方法都应在执行任何工作之前调用 VerifyAccess。这可确保您的对象仅在 UI 线程上使用,如图 2 所示。
Figure 2 使用 VerifyAccess 与 CheckAccess
public class MyWpfObject : DispatcherObject
{
public void DoSomething()
{
VerifyAccess();
// Do some work
}
public void DoSomethingElse()
{
if (CheckAccess())
{
// Something, only if called
// on the right thread
}
}
}
为 此,在调用 Control、Window、Panel 之类的任何 DispatcherObject 派生对象时,应注意要处在 UI 线程上。如果您从非 UI 线程调用 DispatcherObject,就会引发异常。相反,如果您正在某个非 UI 线程上工作,就需要使用 Dispatcher 来更新 DispatcherObjects。
使用调度程序
Dispatcher 类提供了到 WPF 中消息泵的通道,还提供了一种机制来路由供 UI 线程处理的工作。这对满足线程关联要求是必要的,但是对通过 Dispatcher 路由的每个工作来说,UI 线程都被阻止,因此使 Dispatcher 完成的工作小而快非常重要。最好将用户界面的大块工作拆分为较小的离散块,以便 Dispatcher 执行。任何不需要在 UI 线程上完成的工作应移到其他线程上,以便在后台进行处理。
通常,您将会使用 Dispatcher 类将工作项目发送到 UI 线程进行处理。例如,如果您想要使用 Thread 类在单独的线程上进行一些工作,那么可以创建一个 ThreadStart 委托,在新的线程上进行一些工作,如图 3 所示。
Figure 3 用非 UI 线程更新 UI——错误的方法
// The Work to perform on another thread
ThreadStart start = delegate()
{
// ...
// This will throw an exception
// (it's on the wrong thread)
statusText.Text = "From Other Thread";
};
// Create the thread and kick it started!
new Thread(start).Start();
此 代码执行失败,原因是当前没有在 UI 线程上调用对 statusText 控件(一种 TextBlock)的 Text 属性的设置。当该代码尝试设置 TextBlock 上的 Text 时,TextBlock 类会在内部调用其 VerifyAccess 方法以确保该调用来自 UI 线程。当它确定调用是来自不同的线程时,则会引发异常。那么您如何使用 Dispatcher 在 UI 线程上进行调用呢?
Dispatcher 类提供了在 UI 线程上直接调用代码的权限。图 4 展示了使用 Dispatcher 的 Invoke 方法来调用名叫 SetStatus 的方法,从而更改 TextBlock 的 Text 属性。
Figure 4 更新 UI
// The Work to perform on another thread
ThreadStart start = delegate()
{
// ...
// Sets the Text on a TextBlock Control.
// This will work as its using the dispatcher
Dispatcher.Invoke(DispatcherPriority.Normal,
new Action<string>(SetStatus),
"From Other Thread");
};
// Create the thread and kick it started!
new Thread(start).Start();
该 Invoke 调用包含三条信息:要执行的项目的优先级、说明要执行何种工作的委托,以及任何传递给第二个参数中所述委托的参数。通过调用 Invoke,它将要在 UI 线程上调用的委托排入队列。使用 Invoke 方法可确保在 UI 线程上执行工作之前保持阻止。
作 为一种异步使用 Dispatcher 的替代方法,您可以使用 Dispatcher 的 BeginInvoke 方法为 UI 线程异步排队工作项目。调用 BeginInvoke 方法会返回一个 DispatcherOperation 类的实例,其中包含有关执行工作项目的信息,包括工作项目的当前状态和执行的结果(如果工作项目已完成)。BeginInvoke 方法和 DispatcherOperation 类的使用如图 5 所示。
Figure 5 异步更新 UI
// The Work to perform on another thread
ThreadStart start = delegate()
{
// ...
// This will work as its using the dispatcher
DispatcherOperation op = Dispatcher.BeginInvoke(
DispatcherPriority.Normal,
new Action<string>(SetStatus),
"From Other Thread (Async)");
DispatcherOperationStatus status = op.Status;
while (status != DispatcherOperationStatus.Completed)
{
status = op.Wait(TimeSpan.FromMilliseconds(1000));
if (status == DispatcherOperationStatus.Aborted)
{
// Alert Someone
}
}
};
// Create the thread and kick it started!
new Thread(start).Start();
与典型的消息泵实现不同,Dispatcher 是基于优先级的工作项目队列。这就能够实现更好的响应性,因为重要性更高的工作能够在重要性较低的工作之前执行。优先顺序的本质可通过 DispatchPriority 枚举中指定的优先级加以例证(如图 6 所示)。
Figure 6 DispatchPriority 优先级别(按优先级次序)
优先级 | 说明 |
---|---|
非活动 | 工作项目已排队但未处理。 |
SystemIdle | 仅当系统空闲时才将工作项目调度到 UI 线程。这是实际得到处理的项目的最低优先级。 |
ApplicationIdle | 仅当应用程序本身空闲时才将工作项目调度到 UI 线程。 |
ContextIdle | 仅在优先级更高的工作项目得到处理后才将工作项目调度到 UI 线程。 |
后台 | 在所有布局、呈现和输入项目都得到处理后才将工作项目调度到 UI 线程。 |
输入 | 以与用户输入相同的优先级将工作项目调度到 UI 线程。 |
已加载 | 在所有布局和呈现都完成后才将工作项目调度到 UI 线程。 |
呈现 | 以与呈现引擎相同的优先级将工作项目调度到 UI 线程。 |
DataBind | 以与数据绑定相同的优先级将工作项目调度到 UI 线程。 |
正常 | 以正常优先级将工作项目调度到 UI 线程。这是调度大多数应用程序工作项目时的优先级。 |
发送 | 以最高优先级将工作项目调度到 UI 线程。 |
一 般来说,对于更新 UI 外观的工作项目(如我之前使用的示例),您应始终使用 DispatcherPriority.Normal 优先级。但也有时候应该使用不同的优先级。其中尤其令人感兴趣的是三个空闲优先级(ContextIdle、ApplicationIdle 和 SystemIdle)。通过这些优先级可以指定仅在工作负载很低的情况下执行的工作项目。
BackgroundWorker
现 在您对 Dispatcher 的工作原理已有所了解,那么如果得知在大多数情况下都不会使用它,您可能会感到惊讶。在 Windows Forms 2.0 中,Microsoft 引入了一个用于非 UI 线程处理的类来为用户界面开发人员简化开发模型。此类称为 BackgroundWorker。图 7 显示了 BackgroundWorker 类的典型用法。
Figure 7 在 WPF 中使用 BackgroundWorker
BackgroundWorker _backgroundWorker = new BackgroundWorker();
...
// Set up the Background Worker Events
_backgroundWorker.DoWork += _backgroundWorker_DoWork;
backgroundWorker.RunWorkerCompleted +=
_backgroundWorker_RunWorkerCompleted;
// Run the Background Worker
_backgroundWorker.RunWorkerAsync(5000);
...
// Worker Method
void _backgroundWorker_DoWork(object sender, DoWorkEventArgs e)
{
// Do something
}
// Completed Method
void _backgroundWorker_RunWorkerCompleted(
object sender,
RunWorkerCompletedEventArgs e)
{
if (e.Cancelled)
{
statusText.Text = "Cancelled";
}
else if (e.Error != null)
{
statusText.Text = "Exception Thrown";
}
else
{
statusText.Text = "Completed";
}
}
BackgroundWorker 组件与 WPF 的配合非常好,因为在后台它使用了 AsyncOperationManager 类,该类随之又使用 SynchronizationContext 类来处理同步。在 Windows Forms 中,AsyncOperationManager 递交从 SynchronizationContext 类派生的 WindowsFormsSynchronizationContext 类。同样,在 ASP.NET 中,它与 SynchronizationContext 的不同派生(称为 AspNetSynchronizationContext)配合使用。这些 SynchronizationContext 派生的类知道如何处理方法调用的跨线程同步。
在 WPF 中,可用 DispatcherSynchronizationContext 类来扩展此模型。通过使用 BackgroundWorker,可自动应用 Dispatcher 来调用跨线程方法调用。好消息是,由于您可能已经熟悉了这个常见的模式,因此可以继续在新的 WPF 项目中使用 BackgroundWorker。
DispatcherTimer
WPF 线程资源
- Windows Presentation Foundation 虚拟实验室
- .NET Framework 开发中心的 WPF
- WPF 基础:线程模型,MSDN 库
- “Programming the Windows Presentation Foundation”(Windows Presentation Foundation 编程),作者 Chris Sells 和 Ian Griffiths (O'Reilly, 2005)
在 Microsoft® .NET Framework 中定期执行代码是开发中的一项常见任务,但是在 .NET 中使用计时器仍令人困惑。如果您在 .NET Framework 基类库 (BCL) 中查找 Timer 类,那么至少会找到 3 种 Timer 类:System.Threading.Timer、System.Timers.Timer 和 System.Windows.Forms.Timer。每种计时器均有所不同。Alex Calvo 在《MSDN 杂志》中的文章解释了何时使用这些 Timer 类中的每个类(请参见 msdn.microsoft.com/msdnmag/issues/04/02/TimersinNET)。
对于 WPF 应用程序来说,有一种使用 Dispatcher(即 DispatcherTimer 类)的新型计时器。与其他计时器类似,DispatcherTimer 类支持指定滴答之间的间隔,以及在计时器事件触发时要运行的代码。在图 8 中可以看到一种相当常见的 DispatcherTimer 使用方法。
Figure 8 运行中的 DispatcherTimer 类
// Create a Timer with a Normal Priority
_timer = new DispatcherTimer();
// Set the Interval to 2 seconds
_timer.Interval = TimeSpan.FromMilliseconds(2000);
// Set the callback to just show the time ticking away
// NOTE: We are using a control so this has to run on
// the UI thread
_timer.Tick += new EventHandler(delegate(object s, EventArgs a)
{
statusText.Text = string.Format(
"Timer Ticked: {0}ms", Environment.TickCount);
});
// Start the timer
_timer.Start();
因 为 DispatcherTimer 类与 Dispatcher 相关联,因此还可以指定 DispatcherPriority 以及要使用的 Dispatcher。DispatcherTimer 类使用“正常”优先级作为当前 Dispatcher 的默认优先级,但是您可以覆盖这些值:
_timer = new DispatcherTimer(
DispatcherPriority.SystemIdle, form1.Dispatcher);
规划工作进程以获得响应更快的应用程序,其中的一切努力都是非常值得的。开展一些初期研究工作可以使规划更成功。我建议您在开始之前浏览一下“WPF 线程参考”侧栏中提到的一些网站以及本文章,它们会为您开发响应更快的应用程序打下良好的基础。
Shawn Wildermuth不 仅是 Microsoft MVP、MCSD.NET 和 MCT 的创始人,而且还是 Wildermuth Consulting Services 的创始人。此外,Shawn 是“Pragmatic ADO.NET”(实用 ADO.NET)(Addison-Wesley, 2002) 的作者,而且还与他人合著了四套 Microsoft 认证培训系列教材以及即将出版的“Prescriptive Data Architectures”(规定性数据体系结构)。您可以通过其网站与他联系,网址为 wildermuthconsulting.com。