1

【WPF】Dispatcher 与消息循环 - 东邪独孤

 2 months ago
source link: https://www.cnblogs.com/tcjiaan/p/18233254
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【WPF】Dispatcher 与消息循环

这一期的话题有点深奥,不过按照老周一向的作风,尽量讲一些人鬼都能懂的知识。

咱们先来整个小活开开胃,这个小活其实老周在 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);

看看,看看,就是这样。

367389-20240605175743656-91686684.png

 不过,你会发现,当你把所有窗口都关闭后,我 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。这样就退出了最新嵌套的循环。

好了,今天就水到这里了。今天几个项目上的码农朋友晚上搞个聚会,所以老周也准备出发,吃大锅饭了,场面可能比较热闹。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK