【WPF】Dispatcher 與消息迴圈

来源:https://www.cnblogs.com/tcjiaan/p/18233254
-Advertisement-
Play Games

這一期的話題有點深奧,不過按照老周一向的作風,儘量講一些人鬼都能懂的知識。 咱們先來整個小活開開胃,這個小活其實老周在 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。這樣就退出了最新嵌套的迴圈。

好了,今天就水到這裡了。今天幾個項目上的碼農朋友晚上搞個聚會,所以老周也準備出發,吃大鍋飯了,場面可能比較熱鬧。

 


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • C-11.資料庫的設計規範 1.為什麼需要資料庫設計 我們在設計數據表的時候,要考慮很多問題。比如: 用戶需要什麼數據?需要在數據表中保存那些數據? 如何保證數據表中數據的正確性,當插入,刪除,更新的時候該進行怎樣的約束檢查? 如何降低數據表的數據冗餘度,保證數據表不會因為用戶量的增長而迅速擴張? ...
  • 在 ListNode dummy(0);ListNode* cur = &dummy; 中,& 是取地址運算符,用來獲取變數 dummy 的地址。具體如下: ListNode dummy(0);:創建了一個類型為 ListNode 的對象 dummy,其值初始化為 0。 &dummy:取 dummy ...
  • 最近又開始準備LLM 應用開發,要用到api key,才發現過我之前免費發放的額度沒了!我都沒咋用過,痛心! 現在 OpenAI 有要求必須充值 5 刀才能使用,問就是沒錢! 想著能不能在網上白嫖到共用api key。終於發現GitHub聖地的項目,拿來就用! 項目地址 直接點擊訪問。 ...
  • NumPy的通用函數(ufunc)提供高性能的逐元素運算,支持向量化操作和廣播機制,能應用於數組的數學、邏輯和比較運算。ufunc可提高計算速度,避免低效的迴圈,並允許自定義函數以滿足特定需求。例如,ufunc實現加法比迴圈更高效。通過`frompyfunc`可創建自定義ufunc。判斷函數是否為u... ...
  • 用udp協議傳輸文件 目錄用udp協議傳輸文件頭文件/巨集定義客戶端伺服器 頭文件/巨集定義 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> #include <netinet/in.h> ...
  • 垃圾收集器 簡述 Java 垃圾回收機制 在 java 中,程式員是不需要顯示的去釋放一個對象的記憶體的,而是由虛擬機自行執行。在 JVM 中,有一個垃圾回收線程,它是低優先順序的,在正常情況下是不會執行的,只有在虛擬機空閑或 者當前堆記憶體不足時,才會觸發執行,掃面那些沒有被任何引用的對象,並將它們添加 ...
  • 隨著軟體項目進入“維護模式”,對可讀性和編碼標準的要求很容易落空(甚至從一開始就沒有建立過那些標準)。然而,在代碼庫中保持一致的代碼風格和測試標準能夠顯著減輕維護的壓力,也能確保新的開發者能夠快速瞭解項目的情況,同時能更好地全程保持應用程式的質量。 使用外部庫來檢查代碼的質量不失為保護項目未來可維護 ...
  • 使用前,需要對你的項目勾選輸出api文檔文件。 引用Wesky.Net.OpenTools包,保持1.0.11版本或以上。 為了方便,我直接在昨天的演示基礎上,繼續給實體類添加註釋。 昨天的演示文章可參考: C#/.NET一行代碼把實體類類型轉換為Json數據字元串 https://mp.weixi ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...