【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
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...