從Dispatcher.PushFrame()說起


  寫在前面:本文實際上是在開發過程中解決特殊問題的一個總結。由於我並非MS員工,因此可能有講解得不盡正確的地方,望您指出。為了您閱讀方便,請對照.net源碼進行閱讀(源碼獲取方式已列出)。

  相信您在使用WPF的過程中也遇到過這種問題:如果UI線程執行了非常耗時的計算並嘗試在執行過程中更改UI組成中的內容,WPF界面並不會立即發生更改。對於需要給出即時信息的用戶需求而言,WPF的這種延遲繪制功能反而給軟件開發人員帶來了極大的不便。當然,從根本上解決該問題的方法就是將該耗時計算單獨置於工作線程中。只是這種解決方案常常由於某些限制無法施行:對於某些遺留代碼來說,將耗時計算單獨抽離是一件較為復雜的事情;而對於一個即將交付的產品而言,執行大范圍改動是一種不明智的舉動。在這種情況下,MSDN中所介紹的對函數Application.DoEvents()的模擬派上了用場:

 1 public void DoEvents()
2 {
3 DispatcherFrame frame = new DispatcherFrame();
4 Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background,
5 new DispatcherOperationCallback(ExitFrame), frame);
6 Dispatcher.PushFrame(frame);
7 }
8
9 public object ExitFrames(object f)
10 {
11 ((DispatcherFrame)f).Continue = false;
12
13 return null;
14 }

  我想您在第一次看到這段代碼時和我當時一樣疑惑。它偷偷地做了什么?為什么所有該繪制的控件僅僅因為DoEvents()函數的調用就得到了刷新?

 

一.Dispatcher.PushFrame()

  在網絡上尋覓了半天,也沒有發現詳細介紹該段代碼所包含奧妙的文章。沒有辦法,我們只好從.net源代碼着手研究了。

  首先要做的是獲得相關部分的源代碼。一般情況下,我都會使用Reflector將整個程序集導出為項目。導出的步驟如下:

  1. 打開.NET Reflector(我的是7.3),右鍵點擊需要導出的程序集,在彈出菜單中選擇“Export Assembly Source Code…”。這里我們選擇WindowsBase.dll,因為Dispatcher.PushFrame()就是定義於其中的。

  

  2. 在彈出的對話框中選擇需要導出的路徑,並點擊“Start”開始導出。導出的過程需要持續一段時間,在這段時間中,您可以休息一下。

  3. 導出完畢后,一個完整的項目就存在於您指定的目錄下了:

  

  打開WindowsBase.csproj並搜索PushFrame,我們就可以找到該函數的實現:

 1 private void PushFrameImpl(DispatcherFrame frame)
2 {
3 MSG msg = new MSG();
4 this._frameDepth++;
5 try
6 {
7 while (frame.Continue)
8 {
9 if (!this.GetMessage(ref msg, IntPtr.Zero, 0, 0))
10 break;
11 this.TranslateAndDispatchMessage(ref msg);
12 }
13 if ((this._frameDepth == 1) && this._hasShutdownStarted)
14 this.ShutdownImpl();
15 }
16 finally
17 {
18 this._frameDepth--;
19 if (this._frameDepth == 0)
20 this._exitAllFrames = false;
21 }
22 }

  這里我對該段代碼進行了適當的刪減,以便於講解。

  首先應當引起注意的就是成員變量_frameDepth。該變量在每次執行PushFrameImp()函數時自增,在離開之前自減,並在函數體中作為判斷的依據。這種使用方式非常明顯地表示PushFrameImp()函數可以以重入的方式使用。為了了解該函數是如何被重入使用的,我們需要查找它在.net中的調用方式。幸運的是,對該函數進行調用的邏輯非常簡單。我們很快找到Dispatcher.Run()函數也會調用Dispatcher.PushFrameImp()函數。

  沿着代碼向下讀,我們就可以看到一個消息循環:

1 while (frame.Continue)
2 {
3 if (!this.GetMessage(ref msg, IntPtr.Zero, 0, 0))
4 break;
5 this.TranslateAndDispatchMessage(ref msg);
6 }

  首先,該消息循環會以DispatcherFrame的Continue屬性值作為是否結束消息循環的依據。在DoEvents()函數模擬中,ExitFrames()將Continue設置為false,也便在當前消息處理完畢以后結束了消息循環的執行,進而在不久完成對PushFrameImp()的重入調用。

  接下來,該消息循環會通過TranslateAndDispatchMessage()函數完成對消息的處理。該函數實際通過Win32 API將消息發送到對應窗口中以待處理。

  現在我們就可以解釋PushFrame()函數是如何模擬Application.DoEvent()函數的了。首先,Dispatcher.Run()函數會隨着線程的啟用而被調用,從而壓入一個DispatcherFrame實例。該實例內部會通過上文所述的消息循環完成對消息的轉化及分發。此時一旦一個消息需要長時間運行,那么其它消息將無法被處理,從而導致繪制消息無法及時被處理,從用戶的角度看來就是界面無法及時更新。

  一旦我們通過PushFrame()函數再次壓入一個DispatcherFrame的實例,那么消息循環將由於PushFrameImp()的再次執行而被啟動,直到這個新壓入的DispatcherFrame實例的Continue屬性值為false為止。由於壓入的事件優先級(僅僅為Background)低於繪制操作的Render優先級,因此在新壓入DispatcherFrame所導致的消息循環結束運行之前,繪制操作即能夠完成。

 

二.消息處理

  從上面所推導出的PushFrame()函數執行邏輯上看來,其不僅僅完成了繪制,更完成了對其它消息的處理。對於我們所希望的刷新界面這一任務而言,該方法做了太多不該做的事情。是否有一種方法可以僅僅執行對界面的繪制呢?

  為了解決這個問題,我們需要了解WPF是如何對界面進行繪制的。既然通過PushFrame()函數所導致的GetMessage()、TranslateMessage()以及DispatchMessage()的調用就能完成對界面的更新,那么解決這個問題的第一步就是找到實際觸發了繪制的消息。

  現在,我們不再調用PushFrame()函數,而是模擬PushFrame()函數的內部執行邏輯創建一個消息循環並在需要對事件進行分發時啟用該消息循環。在分發過程中,我們可以通過Trace等方法查看被處理的消息ID:

 1 while (TRUE)
2 {
3 MSG msg;
4 if (GetMessage(&msg, NULL, 0, 0))
5 {
6 TRACE("Get MSG: %x\n", msg.message);
7
8 ::TranslateMessage(&msg);
9 ::DispatchMessage(&msg);
10 }
11 else
12 break;
13 }

  在我的電腦上,這些消息的ID分別為0xC25A以及0xC262。根據Win32對消息范圍的划分,我們可以知道它們是通過RegisterWindowMessage()函數定義的字符串消息。在.net源代碼中,RegisterWindowMessage()函數被多處調用。那么0xC25A及0xC262分別代表哪個消息呢?這里我使用了GetClipboardFormatName()函數。在MSDN中並沒有提到其可以獲得自定義消息所對應的字符串這一功能,所以只能說,It works。。。

  在試驗程序中,我添加了如下代碼以獲得0xC25A及0xC262所對應的的字符串:

1 TCHAR szMsgBuf[MAX_PATH];
2 GetClipboardFormatName(0xC25A, szMsgBuf, MAX_PATH);

  通過該段代碼得到的0xC25A及0xC262所對應的字符串分別為“DispatcherProcessQueue”以及“MilChannelNotify”。

  那么,是否是這兩個消息直接導致了UI界面的刷新呢?在.net源代碼中搜索字符串“DispatcherProcessQueue”以及“MilChannelNotify”,可以發現.net源代碼的確使用了這兩個消息。DispatcherProcessQueue消息由Dispatcher.ProcessQueue()函數處理,而MilChannelNotify消息最終由MediaContext.NotifyChannelMessage()函數處理。

  首先來看看對MilChannelNotify消息的處理。該消息的處理函數通過如下調用堆棧向Dispatcher中插入了繪制任務:

  MediaContext.NotifyChannelMessage()

      MediaContext.NotifyPresented()

          MediaContext.ScheduleNextRenderOp()

              Dispatcher.BeginInvoke()

  在閱讀函數ScheduleNextRenderOp()的內部實現時,您可能會對其中兩個地方產生疑惑:對BeginInvoke()函數的調用為什么有不同優先級?為什么BeginInvoke()函數所傳入的委托的名稱_animRenderMessage像是對動畫功能進行管理的一個委托?

  首先,對BeginInvoke()函數進行調用的同時也啟用了幾個計時器:_promoteRenderOpToInput及_promoteRenderOpToRender。這些計時器定義了從BeginInvoke()函數調用優先級升級到相應的Input及Render所需要的時間。在到達該時間時,這些計時器的回調函數會提高BeginInvoke()所傳入任務_currentRenderOp的優先級至相應級別。實際上,這種自動提高優先級的處理方法在某些系統中非常常見,如Windows系統中線程的PriorityBoost機制。

  至於委托的名稱為什么是_animRenderMessage,我想應該是對委托的重用。在.net源碼中搜索該委托,就能看到該委托的處理函數AnimatedRenderMessageHandler()。該函數最終沿如下調用堆棧調用各個界面組成的Render()函數:

  MediaContext.AnimatedRenderMessageHandler()

      MediaContext.RenderMessageHandlerCore()

          MediaContext.Render()

              ICompositionTarget.Render()

  ICompositionTarget接口被多個類型實現,如HwndTarget等。該調用堆棧與_renderMessage委托的消息處理函數RenderMessageHandler()所導致的調用堆棧幾乎相同,因此可以說,委托_animRenderMessage的功能就是用來實現UI界面的刷新。

  實際上,在接觸到ICompositionTarget.Render()函數之后,我們已經非常接近WPF的底層實現了。CompositionTarget類實現了ICompositionTarget.Render()接口,而在接口中對Visual類的RenderRecursive()函數調用已經讓我們看到了對視覺樹的繪制,而DUCE類則看起來更像是WPF各個托管組件和milcore進行交互的組成。

  我們知道,Visual類實例用來包含一系列繪制指令以及如何繪制這些指令的數據。並是WPF托管API以及DirectX的非托管包裝milcore兩個子系統的連接點。對Visual的繪制通過DrawingContext來完成。其使用一種棧式的操作方法壓入及彈出一些特性,並同時提供了一系列用於繪制的函數。這些調用最終會轉化為一系列對繪制指令的存儲,而不是將內容直接繪制在屏幕上。這種存儲繪制指令供以后使用的模式則被稱為保留模式系統,也是WPF繪制系統的特色之一。而直接繪制的方式則被稱為即時模式圖形系統。

  仔細研究.net代碼后就可以發現,絕大部分繪制功能的實現都集中在DrawingContext的派生類RenderDataDrawingContext中。UIElement等基礎類型的RenderOpen()函數實際上返回的是RenderDataDrawingContext類型的實例,而該實例將最終傳入OnRender()函數中,以定義各個界面組成的繪制方法。在RenderDataDrawingContext類所提供的眾多函數中,我們都可以看到對成員_renderData的寫入操作。

  好了,扯得太遠了。我們回到WPF是如何繪制UI界面組成的這一話題上來。

  在前面的講解中,我們忽略了一個事情,那就是我們一直假設BeginInvoke()函數所插入的委托函數都可以自行運行。現在回頭看一下這個問題:誰在觸發對BeginInvoke()函數所插入委托的處理?先說出答案:DispatcherProcessQueue消息。現在我們來看看該消息的處理函數的實現。

  在DispatcherProcessQueue消息到達的時候,Dispatcher.ProcessQueue()函數即被調用。該函數將從優先級隊列_queue中出隊一個優先級不為Invalid以及Inactive的操作並調用操作的Invoke()函數。這樣,由BeginInvoke()函數調用插入優先級隊列的操作即被執行。其中需要注意的一行代碼就是對Dispatcher.RequestProcessing()的調用。該函數會在優先級隊列中擁有等於或高於DispatcherPriority.Loaded級別操作時通過TryPostMessage()函數再次發送DispatcherProcessQueue消息,以處理其它存在於_queue中的操作。通過這種方式,DispatcherProcessQueue消息就能將優先級高於或等於DispatcherPriority.Loaded的操作全部執行完畢。

  在證明了自定義消息“DispatcherProcessQueue”及“MilChannelNotify”可以用來觸發UI的重繪之后,我們就可以通過從消息泵中提取並分發這兩個消息強制WPF對界面進行重繪。

  需要注意的是,由RegisterWindowMessage()函數所注冊的自定義消息並不具有固定的ID值,甚至在同一台電腦上的不同會話中都有可能變化,因此您不能直接使用我這里列出的數值0xC25A及0xC262作為函數調用GetClipboardFormatName()的參數。但是在程序中,我們需要知道自定義消息“DispatcherProcessQueue”以及“MilChannelNotify”所對應的ID以便對其進行捕獲和處理。獲得它們所對應ID的方法則是以這些自定義消息所對應的字符串作為參數調用RegisterWindowMessage()函數:

static UINT uProcessQueue = RegisterWindowMessage(_T("DispatcherProcessQueue"));

  綜上所述,PushFrame()中真正起作用的組成應如下所示:

 1 static UINT uProcessQueue = RegisterWindowMessage(_T("DispatcherProcessQueue"));
2 static UINT uChannelNotify = RegisterWindowMessage(_T("MilChannelNotify"));
3
4 while (TRUE)
5 {
6 MSG msg;
7 if (::PeekMessage(&msg, NULL, uProcessQueue, uProcessQueue, PM_REMOVE | PM_NOYIELD)
8 || ::PeekMessage(&msg, NULL, uChannelNotify, uChannelNotify, PM_REMOVE | PM_NOYIELD))
9 {
10 ::TranslateMessage(&msg);
11 ::DispatchMessage(&msg);
12 }
13 else
14 break;
15 }

 

轉載請注明原文地址:http://www.cnblogs.com/loveis715/admin/EditPosts.aspx?postid=2319976

商業轉載請事先與我聯系:silverfox715@sina.com


免責聲明!

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



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