WPF - 在子线程中显示窗口

  记得在刚刚接触WPF时,我对它所提供的一个特性印象尤为深刻:在程序运行大规模计算时,程序的界面将不会停止绘制,并能够在需要进行界面的刷新时进行正确的绘制。那么,这种绘制特性是否能在WPF执行大规模计算时对用户的输入进行响应呢?让我们来做个试验吧。

  打开示例工程并运行,您会看到控制窗口(Control Window)。点击Sychronous work所对应的开始键,以开始执行以下代码:

1 public void StartSynchronousWork(object sender, RoutedEventArgs e)
2 {
3     int counter = 0;
4     while (counter < 10000000)
5     {
6         textBlock.Text = counter.ToString();
7         counter++;
8     }
9 }

  上面的代码中,textBlock是界面中的一个元素。StartSynchronousWork()函数的执行会在循环中不停地对该界面元素的Text属性进行更新。可是您看到了什么?界面中的相应元素并没有得到刷新,而是在该函数执行完毕后才在界面上反映出该更改。

  这就是我们可能遇到的问题:在一个需要长时间运行的代码中,我们需要及时地更新当前程序运行状态,或在界面中显示一些信息。但是从上面代码的运行结果来看,WPF并不能保证这些信息及时地显示在界面上。

  当然,我在“从Dispatcher.PushFrame()说起”一文中曾经提到过Dispatcher.PushFrame()函数以及经过优化后的解决方案。但是该方案有众多缺点:发送消息将不仅仅导致重绘功能的执行,更可能导致其它WPF机制被执行;该解决方案需要自行创建一些消息泵,因此软件开发人员至少需要在.net代码中通过PInvoke混入一些Win32函数调用。

  而在本文中,我们将提出在多线程中显示WPF界面这一解决方案。

 

独立的UI线程

  在WPF中,我们需要创建一个独立的UI线程以显示WPF组成。

  首先要考虑的就是界面元素的分割。我们知道,WPF的界面元素基本上都派生于DispatcherObject,因此对其的使用被限制在创建它的线程之中。又由于WPF的各种计算常常需要传递给子元素,如布局时使用的Measure-Arrange机制,因此我们需要保证WPF中的父元素和子元素处于同一线程之中。这也便限制了我们需要将整个WPF窗口作为最基本的分割单元独立地存在于同一线程之中。

  好了。知道如何在多个线程中分割程序界面以后,我们就需要开始着手在独立的线程中显示窗口了。首先是线程的创建。为了能让WPF正确执行,我们需要将这个新线程设置为STA:

1 Thread newWindowThread = new Thread(new ThreadStart(CreateCounterWindowThread));
2 newWindowThread.SetApartmentState(ApartmentState.STA);
3 newWindowThread.Start();

  而下面是函数CreateCounterWindowThread()的实现:

1 private void CreateCounterWindowThread()
2 {
3     mCounterWindow = new CounterWindow();
4     mCounterWindow.ControlWindow = this;
5     mCounterWindow.Show();
6     mCounterWindow.Closed += (s, e) => 
7         mCounterWindow.Dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
8     Dispatcher.Run();
9 }

  让我们来逐行看看这些代码的意义。该段代码首先创建了一个窗口并通过Show()函数调用来显示新创建的窗口。而在该函数的最后,Dispatcher.Run()函数将启动一个消息泵,使当前线程拥有了处理消息的能力,从而保证所有发送到窗口的消息能够正确地分发和处理。

  那中间那行对Closed事件进行侦听并处理的代码是什么意思呢?我们可以看到,CreateCounterWindowThread()函数的最后通过Dispatcher.Run()函数启动了消息泵。该消息泵内部会通过循环对消息进行处理。在独立UI线程中显示的窗口被关闭后,我们需要向该消息泵发送关闭的通知,以终止消息泵的执行。Closed事件的处理函数中所调用的BeginInvokeShutdown()函数实际上就是在窗口被关闭后通知Dispatcher终止消息循环,使UI线程的执行点从Dispatcher.Run()函数中跳出,并进而释放UI线程所占用的资源。

 

通讯模型

  OK,您现在已经了解了怎样在独立的UI线程中创建窗口实例。但当前的解决方案只能让您在独立的UI线程中创建一个不与其它线程交互的窗口,而并不能将其它线程中的数据显示在当前界面中。当然,我会告诉您该问题的解决方法,那就是Dispatcher的Invoke()和BeginInvoke()函数。

  在一个Dispatcher实例上调用Invoke()和BeginInvoke()函数会向该Dispatcher实例中放入一个工作项,并在Dispatcher实例所对应的线程中执行该工作项。这两个函数之间的区别是:Invoke()函数所插入的工作项会同步执行,而BeginInvoke()函数所插入的工作项则是异步执行。

  对于本文开头所提到的问题,我想我们已经有了一个解决方案,那就是在独立的UI线程中创建显示信息的界面,并在主线程需要显示信息的时候通过BeginInvoke()函数将工作项分发到UI线程中。就如示例代码中的StartAsynchronousWork()函数那样:

 1 public void StartAsynchronousWork(object sender, RoutedEventArgs e)
 2 {
 3     int counter = 0;
 4     while (counter < 10000000)
 5     {
 6         mCounterWindow.Dispatcher.BeginInvoke(
 7             new Action<int>(param => 
 8                 { mCounterWindow.textBlock.Text = param.ToString(); }), 
 9             DispatcherPriority.Background, new object[] { counter });
10         counter++;
11     }
12 }

  我想您脑中可能存在着一些疑问:我们为什么要用BeginInvoke()而不是Invoke()?为什么我们选择了DispatcherPriority.Background这一优先级,而不是DispatcherPriority.Normal或是更高?对于第一个问题,答案是出于性能的考虑。Invoke()函数会在调用时阻塞当前线程的执行,而频繁地调用该函数可能导致程序性能的大幅下降。一般情况下,我们会在线程拥有较大负荷时调用BeginInvoke()函数,以避免Invoke()函数所造成的开销。而对于第二个问题,则是为了给WPF绘制线程对界面进行绘制的机会。众所周知,WPF单独使用一个绘制线程对WPF程序的界面进行绘制,而该绘制工作的优先级为DispatcherPriority.Render。如果我们一直使用较高的优先级向其发送工作项,那么UI线程也将像文章开始时所介绍的那种情况一样不能得到执行的机会。但是就BeginInvoke()及Invoke()函数调用次数不是很频繁的情况而言,您也可以选择使用较高的优先级。

  这只是进行单向调用的情况。但是事情往往不是那么简单。您可能常常需要从UI中获取主线程中的数据以辅助显示。这时我们需要在Invoke()和BeginInvoke()函数中使用哪个呢?一般情况下,我都会选择Invoke()函数。首先,创建独立UI线程所要解决的问题常常是主线程不能及时更新信息,即它常常是负荷较重的线程。因此出于性能的考虑,我们常常更偏好于在主线程中使用BeginInvoke()将任务分发给UI线程。而UI线程则常常是负载较轻的线程,因此我们可以通过同步调用Invoke()来从主线程中访问数据。就如示例中的代码所示:

 1 // ControlWindow.xaml.cs
 2 public void StartTimer(object sender, RoutedEventArgs e)
 3 {
 4     mTimer = new DispatcherTimer();
 5     mTimer.Interval = TimeSpan.FromSeconds(1);
 6     mTimer.Tick += new EventHandler(new Action<object, EventArgs>((obj, args) =>
 7     {
 8         mCounterWindow.Dispatcher.BeginInvoke(new Action(mCounterWindow.OnTimerTick));
 9     }));
10     mTimer.Start();
11 }
12 
13 public string GetTimeString()
14 {
15     return DateTime.Now.ToString();
16 }
17     
18 // CounterWindow.xaml.cs
19 public void OnTimerTick()
20 {
21     textBlock.Text = ControlWindow.Dispatcher.Invoke(
22         new Func<string>(ControlWindow.GetTimeString)) as string;
23 }

  您可能会继续问:那我是否可以在主线程及UI线程中都使用BeginInvoke()函数?答案是可以,但是那经常会给你带来不必要的麻烦。下面我们会从最简单的情况说起。

首先您要深刻理解的是,BeginInvoke()函数是一个异步调用,在工作项被执行的时候,我们所需要的数据可能已经发生了变化。举例来说,如果我们在BeginInvoke()函数中传入了一个字符串参数,并在工作项执行时从主线程中取得取子串时所需要的索引,那么取子串的操作可能导致致命的错误:主线程中字符串可能已经变更,而索引已经超过了原字符串的长度。正确的方法则是在BeginInvoke()函数中同时将字符串以及子串的索引传入工作项,UI线程则仅仅执行对子串的求值即可。

  您可能担心:此时的UI并没有反映程序执行的准确数据。是的,但是所有的软件都会在绘制界面时产生一定的延迟,就好像Windows会用WM_PAINT消息执行绘制。由于在我们所讨论的话题中,UI线程并不是一个拥有较高负载的线程,因此WPF能保证对其界面执行适时的刷新。

  在主线程执行过程中,我们可以通过BeginInvoke()函数将具有相同优先级的工作项插入UI线程的Dispatcher中,这样可以保证这些工作项按序执行。这种按序执行在一定程度上类似于我们在主线程中对界面元素的属性进行设置,不同的则是,对界面的绘制可能在属性设置到一半的时候进行,即在界面上的数据可能分为已更新和未更新两部分。只是这种不一致会很快地在下一个工作项被处理时即被更新,基本不会被用户所察觉。

  当然,我们不能忽略UI线程同主线程进行沟通的情况。如果这种沟通是由于用户操作了UI,那么我们需要考虑在UI线程和主线程中分别添加执行代码:UI线程中添加对UI状态的更改,而主线程则用来执行业务逻辑。当然您可以在这里用BeginInvoke(),也就相当于向主线程Post了一个消息。但是从主线程中取得数据有时也是无法避免的,例如界面分支逻辑众多,无法准确判断实际需要的数据这一情况。此时我们需要使用Invoke()函数取得需要的数据。

  为什么不用异步调用BeginInvoke()?调用BeginInvoke()函数时,您需要侦听该函数所返回的DispatcherOperation实例的Complete事件,并在该事件的响应函数中继续执行逻辑。这样做至少有两个缺点:代码被分割到了多个函数中,难以阅读和维护;BeginInvoke()函数返回时,UI线程可能又执行了其它BeginInvoke()函数所插入的工作项,从而使UI状态和函数调用时所使用的数据不一致。

 

Dispatcher的内部实现

  双向通讯的模型中,我们已经基本否定了两个方向都使用BeginInvoke()的情况。那么两个方向都使用Invoke()呢?在示例程序中,我已经给出了实验代码。您只需将StartTimer()函数中的BeginInvoke()函数更改为Invoke()函数调用即可:

 1 // ControlWindow.xaml.cs
 2 public void StartTimer(object sender, RoutedEventArgs e)
 3 {
 4     mTimer = new DispatcherTimer();
 5     mTimer.Interval = TimeSpan.FromSeconds(1);
 6     mTimer.Tick += new EventHandler(new Action<object, EventArgs>((obj, args) =>
 7     {
 8         mCounterWindow.Dispatcher.BeginInvoke(new Action(mCounterWindow.OnTimerTick));
 9     }));
10     mTimer.Start();
11 }

  接下来,您就可以看到结果:程序死锁了。为什么?

  我喜欢在产生疑问的时候看看WPF实现源码,一来可以很清晰地了解产生问题的原因,二来可以从这些源码中学到一些新的东西。经过扩展及整理后的Invoke()函数的源码如下:

 1 internal object Invoke(…)
 2 {
 3  4 DispatcherOperation operation = BeginInvokeImpl(priority, method, 
 5     args, isSingleParameter);
 6     if (operation != null)
 7     {
 8         operation.Wait(timeout);
 9 10     }
11     return null;
12 }

  哦,这里透露了两个重要的信息:Invoke()函数的内部实现实际上调用了BeginInvoke(),并暂停当前线程的执行,直到BeginInvoke()函数返回。这也便能解释前面线程死锁的原因了:在一个线程通过Invoke()函数调用另一个线程的时候,自身便进入休眠状态。如果被调用线程反过来想调用调用者,则会因为该线程已经处于休眠状态而不能进行响应。

  接下来我们看看BeginInvokeImp()的实现吧,也就是BeginInvoke()的实际实现:

 1 internal DispatcherOperation BeginInvokeImpl(…)
 2 {
 3  4     DispatcherHooks hooks = null;
 5     bool flag = false;
 6     lock (this._instanceLock)
 7     {
 8         DispatcherOperation data = new DispatcherOperation(…) {
 9             _item = this._queue.Enqueue(priority, data)
10         };
11         // this.RequestProcessing() 内部实际调用了TryPostMessage()函数
12 MS.Win32.UnsafeNativeMethods.TryPostMessage(new HandleRef(this, 
13 this._window.Value.Handle), _msgProcessQueue, IntPtr.Zero, IntPtr.Zero);
14 15     }
16 17 }

  上面的代码首先将需要执行的工作项插入到表示消息队列的成员_queue中。接下来,WPF通过Win32调用TryPostMessage()发送一个_msgProcessQueue消息。也就是说,我们对Invoke()及BeginInvoke()函数的调用仅仅相当于发送了一个消息,只是Invoke()函数会暂停当前线程的执行以等待该消息被处理完毕而已。而在_msgProcessQueue消息的处理函数ProcessMessage()函数中,工作项才被真正执行:

 1 // 整理后的ProcessQueue()函数
 2 private void ProcessQueue()
 3 {
 4     DispatcherOperation operation = null;
 5     lock (this._instanceLock)
 6     {
 7         DispatcherPriority invalid = this._queue.MaxPriority;
 8         if (((invalid != DispatcherPriority.Invalid) 
 9             && (invalid != DispatcherPriority.Inactive)) 
10             && _foregroundPriorityRange.Contains(invalid))
11             operation = this._queue.Dequeue();
12         this.RequestProcessing(); // 内部再次发出_msgProcessQueue消息
13     }
14     if (operation != null)
15         operation.Invoke(); // 执行工作项
16 }

 

写在后面

  本文介绍了创建独立的UI线程以显示WPF窗口的一种方法。但在使用该方法的之前,您首先需要研究一下我们是否真的需要使用它。按照一般的软件设计思路,耗时的工作都需要置于后台线程中。

  其次注意线程之间的忙碌情况。这是决定到底哪个线程调用BeginInvoke()函数而哪个线程调用Invoke()函数的最重要依据:如果一个线程较为忙碌,那么对BeginInvoke()函数的响应将会较为缓慢。所以在一般情况下,具有较低负载的线程用来接收BeginInvoke()函数,以保证程序能及时地对用户的输入进行响应。

  最后要强调的是,在该实现中不能同时用两个Invoke()函数。这是因为像上面所介绍的那样,对Invoke()函数的调用会阻止当前线程的执行。那么在另一个线程调用Invoke()函数请求当前线程执行工作项时,将因为当前线程已经被阻止,从而无法得到当前线程的响应,并最终死锁。

 

源码地址: http://download.csdn.net/detail/silverfox715/4267793

转载请注明原文地址:http://www.cnblogs.com/loveis715/archive/2012/04/30/2477356.html

商业转载请事先与我联系:silverfox715@sina.com,我只会要求添加作者名称以及博客首页链接。

posted @ 2012-04-30 23:05  loveis715  阅读(12586)  评论(8编辑  收藏  举报