【WPF】Dispatcher 與訊息迴圈

东邪独孤發表於2024-06-06

這一期的話題有點深奧,不過按照老週一向的作風,儘量講一些人鬼都能懂的知識。

咱們先來整個小活開開胃,這個小活其實老周在 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。這樣就退出了最新巢狀的迴圈。

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

相關文章