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,我只會要求添加作者名稱以及博客首頁鏈接。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM