這一期的話題有點深奧,不過按照老周一向的作風,儘量講一些人鬼都能懂的知識。 咱們先來整個小活開開胃,這個小活其實老周在 N 年前寫過水文的,常閱讀老周水文的伙伴可能還記得。通常,咱們按照正常思路構建的應用程式,第一個啟動的線程為主線程,而且還是 UI 線程(當然,WPF 預設會創建輔助線程。這都是運 ...
這一期的話題有點深奧,不過按照老周一向的作風,儘量講一些人鬼都能懂的知識。
咱們先來整個小活開開胃,這個小活其實老周在 N 年前寫過水文的,常閱讀老周水文的伙伴可能還記得。通常,咱們按照正常思路構建的應用程式,第一個啟動的線程為主線程,而且還是 UI 線程(當然,WPF 預設會創建輔助線程。這都是運行庫自動乾的活,我們不必管它)。也就是說,程式至少會有一個專門調度前臺界面的線程。
咱們在主視窗中放一個按鈕,居中對齊。
<Window x:Class="就是6" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:MakeUIOnNewThread" mc:Ignorable="d" Title="太陽粒子" Height="400" Width="600"> <Border> <Button HorizontalAlignment="Center" VerticalAlignment="Center" Padding="15,7" Content="再來一個視窗" Click="OnClick" /> </Border> </Window>
處理按鈕的單擊事件。
private void OnClick(object sender, RoutedEventArgs e) { Thread th = new(RunSomeWork); // 必須是STA th.SetApartmentState(ApartmentState.STA); th.Start(); } /*************** 被新線程調用的方法 ******************/ void RunSomeWork() { Window newWindow = new() { Title = "月亮粒子", Width = 400, Height = 350, // 弄點別的背景色 Background = new SolidColorBrush(Colors.Green), // 打開視窗時位於父視窗的中央 WindowStartupLocation = WindowStartupLocation.CenterOwner }; // 顯示視窗 newWindow.Show(); }
在一個線程上創建可視化資源,要求是 STA 模式。這便得 UI 對象只能在創建它的線程上直接訪問,若跨線程訪問,就得進行封送(指針)。UI 線程都需要這種規則。
這個例子相當好懂吧,就是在一個新線程上實例化新的視窗,並顯示它。當你運行後,點擊按鈕,發現視窗沒出現,並且還發生了異常。其實視窗是成功創建的了,但由於新的線程上沒有消息迴圈,線程執行完了,資源就釋放了。Dispatcher 類(位於 System.Windows.Threading 命名空間)的功能就是線上程上創建消息迴圈,有了消息迴圈,就可以處理各種事件,視窗就不會一啟動就結束了。因為應用程式會不斷從消息隊列中取出並處理消息,同時會一直等待新的消息,形成一個 Die 迴圈。
在 Dispatcher 類中,是通過投放“幀”的方式來啟動消息迴圈的。在初始化好視窗後,有了消息迴圈,視窗就可以響應各種事件——如重繪、鍵盤輸入、滑鼠點擊等。於是整台機器就能運轉起來。
調度程式中的“幀”用 DispatcherFrame 類表示。和許多 WPF 類一樣,它有個基類叫 DispatcherObject。從名字可知,這樣的類型內部會引用一個屬於當前線程的 Dispatcher 對象,並且公開了 CheckAccess 方法,用來檢查能否訪問相關的對象。其內部實際調用了 Dispatcher 類的 CheckAccess 方法。該方法的實現不複雜,就是判斷一下當前代碼所在的線程是否與被訪問的 Dispatcher / DispatcherObject 對象處於同一線程中,如果是,就允許訪問;否則不能訪問。
DispatcherFrame 類有一個屬性叫 Continue,記住,這個屬性很重要,高考要考的喲!它是布爾類型,表示這個“幀”是否【持續】。什麼意思?不懂?沒事,咱們先放下這個,待會兒回過頭來看就懂,總之你一定要記住這個屬性。
一個調度“幀”是怎麼啟動消息迴圈的?看,Dispatcher 類有個方法很可疑,它叫 PushFrame —— 看這名字,好像是投放幀的啊。嗯,猜對了,就是它!但是,PushFrame 方法並沒有直接進入迴圈,而是內部用一個叫 PushFrameImpl 的私有方法封裝了一層。下麵源代碼是亮點,千萬別眨眼,能否理解 Dispatcher 的工作原理,這段代碼是關鍵。
// 這個結構體很眼熟吧,是的,Windows 消息體 MSG msg = new MSG(); // 這個變數是個計數器,計錄“幀”被套了多少層 _frameDepth++; try { // 此處省略1900字 try { // 重點來了!!! while(frame.Continue) { // 看到沒? if (!GetMessage(ref msg, IntPtr.Zero, 0, 0)) break; // 是不是很熟悉配方? TranslateAndDispatchMessage(ref msg); } // If this was the last frame to exit after a quit, we // can now dispose the dispatcher. // 當一個幀結束,嵌套深度就減一層 if(_frameDepth == 1) { if(_hasShutdownStarted) { ShutdownImpl(); // 準備退出整個迴圈 } } } finally { // 這裡是切換線程上下文的代碼,先省略 } } finally { // 這裡依舊省略 }
剛纔不是叫各位記住 DispatcherFrame 類的 Continue 屬性嗎,你看,這不就用上了。在看到上面代碼之前,不知道你會不會產生誤解:以為一個幀代表一條消息。其實不然,一個幀居然表示的是一層消息迴圈。也就是說,你 Push 一幀進去就出了一個消息迴圈,你再 Push 一幀進去就會在上一個迴圈中內嵌一個子迴圈。你要還 Push 的話,就會產生孫子迴圈,再 Push 就是重孫子迴圈……子子孫孫無窮盡也。
Continue 屬性的作用就是:是否繼續迴圈。只要它變成了 flase,那消息迴圈就能退了。
回到咱們前面的示例,現在你應該知道怎樣讓視窗不自動關閉了。
Window newWindow = new() { …… }; // 顯示視窗 newWindow.Show(); // 迴圈調度器里推一幀 DispatcherFrame frame = new(); Dispatcher.PushFrame(frame);
看看,看看,就是這樣。
不過,你會發現,當你把所有視窗都關閉後,我 Kao,程式為啥不會退出?因為你剛 push 的迴圈還在打千秋呢,怎麼捨得退出?那為什麼應用程式預設啟動的主視窗可以?因為它有後臺—— Application 類,應用程式類在進入迴圈前(調用 Run 方法)會監聽一些相關事件,如果視窗都關閉了,它會調用 Dispatcher 的 CriticalInvokeShutdown 方法,告訴調度器:下班了,該回家了,伙計。遺憾的是這個方法是沒有公開的,咱們調用不了。但,我們是有法子辦它的。咱們可以從 Window 類派生個子類。
public class XiaoXiaoWindow : Window { protected override void OnClosed(EventArgs e) { base.OnClosed(e); Dispatcher.ExitAllFrames(); } }
ExitAllFrames 方法會請求所有幀即將退出,並且會讓各幀的 Continue 屬性返回 false。然後,創建新視窗的代碼稍稍改一下。
Window newWindow = new XiaoXiaoWindow() { …… };
這時候,再次運行,當最後一個視窗關閉後,程式就能退出了。
聰明如你,你一定發現問題了:調用 ExitAllFrames 方法不是讓當前 Dispatcher 所線上程的所有迴圈都退出嗎,為什麼還能 ExitAllFrames 多次?因為這個示例有 bug 唄,你看看,每點擊一次按鈕,是不是就創建了一個新線程,併在新線程上創建了一個視窗。所以,調用一次 ExitAllFrames 方法只結束了一個線程的迴圈。要是創建了四個線程,那就得相應地調用四次 ExitAllFrames 方法。所以,正確的做法應該定義個視窗集合管理類,當打開的視窗數量為0時,只調用一次 ExitAllFrames 方法即可。
想偷懶的話,可以用一個簡單計數變數。
public class XiaoXiaoWindow : Window { /// <summary> /// 計數器 /// </summary> static int WindowCount { get; set; } = 0; public XiaoXiaoWindow() { // 增加計數 WindowCount++; } protected override void OnClosed(EventArgs e) { base.OnClosed(e); // 遞減 WindowCount--; if (WindowCount == 0) { Dispatcher.ExitAllFrames(); } } }
這時候,咱們改改思路,在一個線程上創建三個視窗。
void RunSomeWork() { Window[] wlist = new XiaoXiaoWindow[] { new XiaoXiaoWindow(){Title = "月球粒子1"}, new XiaoXiaoWindow() {Title = "月球粒子2"}, new XiaoXiaoWindow(){ Title = "月球粒子3"} }; // 顯示視窗 foreach (Window window in wlist) { window.Show(); } // 迴圈調度器里推一幀 DispatcherFrame frame = new(); Dispatcher.PushFrame(frame); }
其實,對於第一個推進去的幀(首迴圈),我們是不需要調用 PushFrame 方法的,而是直接用 Run 方法即可。這個方法內部就是調用了 PushFrame 方法。
public static void Run() { PushFrame(new DispatcherFrame()); }
Dispatcher 類沒有公開咱們可以調用的構造函數,我們可以通過三種方法獲取到與當前線程關聯的 Dispatcher 實例。
1、Dispatcher.CurrentDispatcher 靜態屬性,可以直接返回 Dispatcher 實例,如果沒有會自動創建;
2、Dispatcher.FromThread() 方法,通過當前線程(可以用 Thread.CurrentThread 屬性獲取)實例可以獲取相關聯的 Dispatcher 實例;
3、如果已創建了 WPF 對象,可以直接通過 Dispatcher 屬性獲得(畢竟大部分 WPF 對象都派生自 DispatcherObject 類)。
-----------------------------------------------------------------------------------------------------------
下麵咱們瞭解一下另一個重要對象——DispatcherOperation,以及它的隊列。
Dispatcher 類使用 RegisterWindowMessage 函數向系統註冊了一個自定義消息,用來處理隊列中的 DispatcherOperation 對象。
_msgProcessQueue = UnsafeNativeMethods.RegisterWindowMessage("DispatcherProcessQueue");
在 WndProcHook 方法(用來處理消息的方法,作用類似於 Win32 API 中的 WndProc 回調函數)中,如果收到此自定義消息,就調用 ProcessQueue 方法來處理相關的操作。
WindowMessage message = (WindowMessage)msg; …… if(message == WindowMessage.WM_DESTROY) { if(!_hasShutdownStarted && !_hasShutdownFinished) // Dispatcher thread - no lock needed for read { // Aack! We are being torn down rudely! Try to // shut the dispatcher down as nicely as we can. ShutdownImpl(); } } else if(message == _msgProcessQueue) { ProcessQueue(); } else if(message == WindowMessage.WM_TIMER && (int) wParam == TIMERID_BACKGROUND) { // This timer is just used to process background operations. // Stop the timer so that it doesn't fire again. SafeNativeMethods.KillTimer(new HandleRef(this, hwnd), TIMERID_BACKGROUND); ProcessQueue(); }
DispatcherOperation 對象又是怎麼產生的?幹嗎用的?DispatcherOperation 類其實是封裝我們傳給 Dispatcher 對象的委托引用的,即調用像 Invoke、BeginInvoke 等方法時會傳入一個委托實例,這個委托實例就被封裝到 DispatcherOperation 對象中,再添加到隊列中。當然,如果在調用 Invoke 等方法時,指定的優先順序是 Send(這個可是最高級別),就不會放到隊列中等待,而是直接執行相關的委托實例。
上面提到的 ProcessQueue 方法由自定義消息觸發,並從隊列中取出一個 DispatcherOperation 對象來運行。
private void ProcessQueue() { …… lock(_instanceLock) { …… if(maxPriority != DispatcherPriority.Invalid && // Nothing. NOTE: should be Priority.Invalid maxPriority != DispatcherPriority.Inactive) // Not processed. // NOTE: should be Priority.Min { if(_foregroundPriorityRange.Contains(maxPriority) || backgroundProcessingOK) { op = _queue.Dequeue(); hooks = _hooks; } } …… // 觸發處理後面的 Operation RequestProcessing(); } …… }
DispatcherOperation 就算沒有鍵盤、滑鼠等動作也可以觸發,因為隊列運轉用的是定時器。
----------------------------------------------------------------------------------------------
許多時候,我們在處理一些耗時操作都會想到用多線程,如果把耗時操作寫在 UI 線程,會導致用戶界面“卡死”。卡死的原因就是這些需要長時間運行的代碼使用消息迴圈停下來了,Dispatcher 調度不到新的消息,視窗自然就無法響應用戶的操作了。
但是,如果耗時操作的過程是可以拆分出 N 多個小段,這些小段時間很短。然後我在每小段代碼執行前或執行後讓消息迴圈動一下。那視窗就不會卡死了吧?例如,我們在下載一個大文件,但是,下載的過程並不是一下子就讀取完所有位元組的,一般我們是讀一個緩衝的,然後寫入文件,再讀下一個緩衝。在這空隙間讓消息迴圈走一波。由於這時間很短,視窗不會卡太久,只是響應稍稍慢一些。
根據咱們前面的分析,要讓消息迴圈轉動,就要向調度代碼插入一幀,同時也要用 Invoke 等方法插入一個委托。這是因為更新界面不能只靠系統消息,例如要更改進度條的進度,這個就得咱們自己寫代碼的。
於是,有了下麵的示例。
<Window ……> <Grid> <StackPanel Margin="13" Orientation="Vertical"> <ProgressBar x:Name="pb" Maximum="100" Minimum="0" Value="0" Height="36"/> <Button Margin="0,25,0,5" Content="試試看" Click="OnClick" /> </StackPanel> </Grid> </Window>
private void OnClick(object sender, RoutedEventArgs e) { int current = 0; while (current < 100) { Thread.Sleep(300); current++; // 添加一個委托操作 this.Dispatcher.BeginInvoke(() => { pb.Value = current; }); // 插入一幀 DispatcherFrame frame = new DispatcherFrame() { // 註意這裡 Continue = false }; Dispatcher.PushFrame(frame); } }
前面咱們說了,一個幀它就是嵌套迴圈,這裡把 Continue 屬性設置為 false 是正確的,不然你插入一幀就等於多了一層死迴圈,那消息迴圈更加堵死了。
但是,你運行上面代碼後,發現視窗依然卡死了。這為什麼呢?我們不妨回憶一下前面 PushFrame 方法的源碼。
while(frame.Continue) { if (!GetMessage(ref msg, IntPtr.Zero, 0, 0)) break; TranslateAndDispatchMessage(ref msg); }
問題就出在這裡了,你都讓 Continue 為 false 了,那 GetMessage 方法還執行個毛線。這等於說消息迴圈還是轉不動。所以,咱們必須想辦法,讓消息迴圈至少能轉一圈。不用急著將 Continue 屬性設為 false,可以先讓它為真,但可以傳遞進委托里,在委托里把它 false 掉就可以了。這樣既能讓迴圈動一下,又不會導致死迴圈。
while (current < 100) { Thread.Sleep(80); current++; // 插入一幀 DispatcherFrame frame = new DispatcherFrame(); // 添加一個委托操作 this.Dispatcher.BeginInvoke((object arg) => { pb.Value = current; // 結束迴圈 ((DispatcherFrame)arg).Continue = false; }, DispatcherPriority.Background, frame); Dispatcher.PushFrame(frame); }
官方給的 DoEvents 例子其實就是這個原理。
為什麼迴圈會動呢?調用 BeginInvoke 方法添加委托到 Operation 隊列後,消息迴圈還沒動;到了 PushFrame 方法一執行 GetMessage 方法就能調用了,消息被提取並處理,這樣咱們添加的委托就能運行了。然後在委托中我們把 Continue 屬性變為 false。這樣就退出了最新嵌套的迴圈。
好了,今天就水到這裡了。今天幾個項目上的碼農朋友晚上搞個聚會,所以老周也準備出發,吃大鍋飯了,場面可能比較熱鬧。