翼度科技»论坛 编程开发 .net 查看内容

【WPF】Dispatcher 与消息循环

5

主题

5

帖子

15

积分

新手上路

Rank: 1

积分
15
这一期的话题有点深奥,不过按照老周一向的作风,尽量讲一些人鬼都能懂的知识。
咱们先来整个小活开开胃,这个小活其实老周在 N 年前写过水文的,常阅读老周水文的伙伴可能还记得。通常,咱们按照正常思路构建的应用程序,第一个启动的线程为主线程,而且还是 UI 线程(当然,WPF 默认会创建辅助线程。这都是运行库自动干的活,我们不必管它)。也就是说,程序至少会有一个专门调度前台界面的线程。
咱们在主窗口中放一个按钮,居中对齐。
  1. <Window x:Class="就是6"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  5.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  6.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  7.         mc:Ignorable="d"
  8.         Title="太阳粒子" Height="400" Width="600">
  9.     <Border>
  10.         <Button HorizontalAlignment="Center"
  11.                 VerticalAlignment="Center"
  12.                 Padding="15,7"
  13.                 Content="再来一个窗口"
  14.                 Click="OnClick" />
  15.     </Border>
  16. </Window>
复制代码
处理按钮的单击事件。
  1. private void OnClick(object sender, RoutedEventArgs e)
  2. {
  3.     Thread th = new(RunSomeWork);
  4.     // 必须是STA
  5.     th.SetApartmentState(ApartmentState.STA);
  6.     th.Start();
  7. }
  8. /*************** 被新线程调用的方法 ******************/
  9. void RunSomeWork()
  10. {
  11.     Window newWindow = new()
  12.     {
  13.         Title = "月亮粒子",
  14.         Width = 400,
  15.         Height = 350,
  16.         // 弄点别的背景色
  17.         Background = new SolidColorBrush(Colors.Green),
  18.         // 打开窗口时位于父窗口的中央
  19.         WindowStartupLocation = WindowStartupLocation.CenterOwner
  20.     };
  21.     // 显示窗口
  22.     newWindow.Show();
  23. }
复制代码
在一个线程上创建可视化资源,要求是 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 的工作原理,这段代码是关键。
  1. // 这个结构体很眼熟吧,是的,Windows 消息体MSG msg = new MSG();// 这个变量是个计数器,计录“帧”被套了多少层_frameDepth++;try{    // 此处省略1900字    try    {        // 重点来了!!!        while(frame.Continue)        {            // 看到没?            if (!GetMessage(ref msg, IntPtr.Zero, 0, 0))<Window x:Class="就是6"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  5.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  6.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  7.         mc:Ignorable="d"
  8.         Title="太阳粒子" Height="400" Width="600">
  9.     <Border>
  10.         <Button HorizontalAlignment="Center"
  11.                 VerticalAlignment="Center"
  12.                 Padding="15,7"
  13.                 Content="再来一个窗口"
  14.                 Click="OnClick" />
  15.     </Border>
  16. </Window>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)            {<Window x:Class="就是6"
  17.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  18.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  19.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  20.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  21.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  22.         mc:Ignorable="d"
  23.         Title="太阳粒子" Height="400" Width="600">
  24.     <Border>
  25.         <Button HorizontalAlignment="Center"
  26.                 VerticalAlignment="Center"
  27.                 Padding="15,7"
  28.                 Content="再来一个窗口"
  29.                 Click="OnClick" />
  30.     </Border>
  31. </Window>ShutdownImpl();       // 准备退出整个循环            }        }    }    finally    {        // 这里是切换线程上下文的代码,先省略    }}finally{    // 这里依旧省略}
复制代码
刚才不是叫各位记住 DispatcherFrame 类的 Continue 属性吗,你看,这不就用上了。在看到上面代码之前,不知道你会不会产生误解:以为一个帧代表一条消息。其实不然,一个帧居然表示的是一层消息循环。也就是说,你 Push 一帧进去就出了一个消息循环,你再 Push 一帧进去就会在上一个循环中内嵌一个子循环。你要还 Push 的话,就会产生孙子循环,再 Push 就是重孙子循环……子子孙孙无穷尽也。
Continue 属性的作用就是:是否继续循环。只要它变成了 flase,那消息循环就能退了。
回到咱们前面的示例,现在你应该知道怎样让窗口不自动关闭了。
  1. Window newWindow = new()
  2. {
  3.     ……
  4. };
  5. // 显示窗口
  6. newWindow.Show();
  7. // 循环调度器里推一帧
  8. DispatcherFrame frame = new();
  9. Dispatcher.PushFrame(frame);
复制代码
看看,看看,就是这样。

 不过,你会发现,当你把所有窗口都关闭后,我 Kao,程序为啥不会退出?因为你刚 push 的循环还在打千秋呢,怎么舍得退出?那为什么应用程序默认启动的主窗口可以?因为它有后台—— Application 类,应用程序类在进入循环前(调用 Run 方法)会监听一些相关事件,如果窗口都关闭了,它会调用 Dispatcher 的 CriticalInvokeShutdown 方法,告诉调度器:下班了,该回家了,伙计。遗憾的是这个方法是没有公开的,咱们调用不了。但,我们是有法子办它的。咱们可以从 Window 类派生个子类。
  1. public class XiaoXiaoWindow : Window
  2. {
  3.     protected override void OnClosed(EventArgs e)
  4.     {
  5.         base.OnClosed(e);
  6.         Dispatcher.ExitAllFrames();
  7.     }
  8. }
复制代码
ExitAllFrames 方法会请求所有帧即将退出,并且会让各帧的 Continue 属性返回 false。然后,创建新窗口的代码稍稍改一下。
  1. Window newWindow = new XiaoXiaoWindow()
  2. {
  3.     ……
  4. };
复制代码
这时候,再次运行,当最后一个窗口关闭后,程序就能退出了。
聪明如你,你一定发现问题了:调用 ExitAllFrames 方法不是让当前 Dispatcher 所在线程的所有循环都退出吗,为什么还能 ExitAllFrames 多次?因为这个示例有 bug 呗,你看看,每点击一次按钮,是不是就创建了一个新线程,并在新线程上创建了一个窗口。所以,调用一次 ExitAllFrames 方法只结束了一个线程的循环。要是创建了四个线程,那就得相应地调用四次 ExitAllFrames 方法。所以,正确的做法应该定义个窗口集合管理类,当打开的窗口数量为0时,只调用一次 ExitAllFrames 方法即可。
想偷懒的话,可以用一个简单计数变量。
  1. public class XiaoXiaoWindow : Window
  2. {
  3.     /// <summary>
  4.     /// 计数器
  5.     /// </summary>
  6.     static int WindowCount { get; set; } = 0;
  7.     public XiaoXiaoWindow()
  8.     {
  9.         // 增加计数
  10.         WindowCount++;
  11.     }
  12.     protected override void OnClosed(EventArgs e)
  13.     {
  14.         base.OnClosed(e);
  15.         // 递减
  16.         WindowCount--;
  17.         if (WindowCount == 0)
  18.         {
  19.             Dispatcher.ExitAllFrames();
  20.         }
  21.     }
  22. }
复制代码
这时候,咱们改改思路,在一个线程上创建三个窗口。
  1. void RunSomeWork()
  2. {
  3.     Window[] wlist = new XiaoXiaoWindow[]
  4.     {
  5.         new XiaoXiaoWindow(){Title = "月球粒子1"},
  6.         new XiaoXiaoWindow() {Title = "月球粒子2"},
  7.         new XiaoXiaoWindow(){ Title = "月球粒子3"}
  8.     };
  9.     // 显示窗口
  10.     foreach (Window window in wlist)
  11.     {
  12.         window.Show();
  13.     }
  14.     // 循环调度器里推一帧
  15.     DispatcherFrame frame = new();
  16.     Dispatcher.PushFrame(frame);
  17. }
复制代码
其实,对于第一个推进去的帧(首循环),我们是不需要调用 PushFrame 方法的,而是直接用 Run 方法即可。这个方法内部就是调用了 PushFrame 方法。
  1. public static void Run()
  2. {
  3.     PushFrame(new DispatcherFrame());
  4. }
复制代码
Dispatcher 类没有公开咱们可以调用的构造函数,我们可以通过三种方法获取到与当前线程关联的 Dispatcher 实例。
1、Dispatcher.CurrentDispatcher 静态属性,可以直接返回 Dispatcher 实例,如果没有会自动创建;
2、Dispatcher.FromThread() 方法,通过当前线程(可以用 Thread.CurrentThread 属性获取)实例可以获取相关联的 Dispatcher 实例;
3、如果已创建了 WPF 对象,可以直接通过 Dispatcher 属性获得(毕竟大部分 WPF 对象都派生自 DispatcherObject 类)。
 
-----------------------------------------------------------------------------------------------------------
下面咱们了解一下另一个重要对象——DispatcherOperation,以及它的队列。
Dispatcher 类使用 RegisterWindowMessage 函数向系统注册了一个自定义消息,用来处理队列中的 DispatcherOperation 对象。
  1. _msgProcessQueue = UnsafeNativeMethods.RegisterWindowMessage("DispatcherProcessQueue");
复制代码
在 WndProcHook 方法(用来处理消息的方法,作用类似于 Win32 API 中的 WndProc 回调函数)中,如果收到此自定义消息,就调用 ProcessQueue 方法来处理相关的操作。
  1. WindowMessage message = (WindowMessage)msg;
  2. ……
  3. if(message == WindowMessage.WM_DESTROY)
  4. {
  5.     if(!_hasShutdownStarted && !_hasShutdownFinished) // Dispatcher thread - no lock needed for read
  6.     {
  7.         // Aack!  We are being torn down rudely!  Try to
  8.         // shut the dispatcher down as nicely as we can.
  9.         ShutdownImpl();
  10.     }
  11. }
  12. else if(message == _msgProcessQueue)
  13. {
  14.     ProcessQueue();
  15. }
  16. else if(message == WindowMessage.WM_TIMER && (int) wParam == TIMERID_BACKGROUND)
  17. {
  18.     // This timer is just used to process background operations.
  19.     // Stop the timer so that it doesn't fire again.
  20.     SafeNativeMethods.KillTimer(new HandleRef(this, hwnd), TIMERID_BACKGROUND);
  21.     ProcessQueue();
  22. }
复制代码
 
DispatcherOperation 对象又是怎么产生的?干吗用的?DispatcherOperation 类其实是封装我们传给 Dispatcher 对象的委托引用的,即调用像 Invoke、BeginInvoke 等方法时会传入一个委托实例,这个委托实例就被封装到 DispatcherOperation  对象中,再添加到队列中。当然,如果在调用 Invoke 等方法时,指定的优先级是 Send(这个可是最高级别),就不会放到队列中等待,而是直接执行相关的委托实例。
上面提到的 ProcessQueue 方法由自定义消息触发,并从队列中取出一个 DispatcherOperation  对象来运行。
  1. 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)            {<Window x:Class="就是6"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  5.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  6.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  7.         mc:Ignorable="d"
  8.         Title="太阳粒子" Height="400" Width="600">
  9.     <Border>
  10.         <Button HorizontalAlignment="Center"
  11.                 VerticalAlignment="Center"
  12.                 Padding="15,7"
  13.                 Content="再来一个窗口"
  14.                 Click="OnClick" />
  15.     </Border>
  16. </Window> op = _queue.Dequeue();<Window x:Class="就是6"
  17.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  18.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  19.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  20.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  21.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  22.         mc:Ignorable="d"
  23.         Title="太阳粒子" Height="400" Width="600">
  24.     <Border>
  25.         <Button HorizontalAlignment="Center"
  26.                 VerticalAlignment="Center"
  27.                 Padding="15,7"
  28.                 Content="再来一个窗口"
  29.                 Click="OnClick" />
  30.     </Border>
  31. </Window> hooks = _hooks;            }        }       ……        // 触发处理后面的 Operation        RequestProcessing();    }    ……}
复制代码
DispatcherOperation 就算没有键盘、鼠标等动作也可以触发,因为队列运转用的是定时器。
----------------------------------------------------------------------------------------------
许多时候,我们在处理一些耗时操作都会想到用多线程,如果把耗时操作写在 UI 线程,会导致用户界面“卡死”。卡死的原因就是这些需要长时间运行的代码使用消息循环停下来了,Dispatcher 调度不到新的消息,窗口自然就无法响应用户的操作了。
但是,如果耗时操作的过程是可以拆分出 N 多个小段,这些小段时间很短。然后我在每小段代码执行前或执行后让消息循环动一下。那窗口就不会卡死了吧?例如,我们在下载一个大文件,但是,下载的过程并不是一下子就读取完所有字节的,一般我们是读一个缓冲的,然后写入文件,再读下一个缓冲。在这空隙间让消息循环走一波。由于这时间很短,窗口不会卡太久,只是响应稍稍慢一些。
根据咱们前面的分析,要让消息循环转动,就要向调度代码插入一帧,同时也要用 Invoke 等方法插入一个委托。这是因为更新界面不能只靠系统消息,例如要更改进度条的进度,这个就得咱们自己写代码的。
于是,有了下面的示例。
  1. <Window x:Class="就是6"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  5.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  6.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  7.         mc:Ignorable="d"
  8.         Title="太阳粒子" Height="400" Width="600">
  9.     <Border>
  10.         <Button HorizontalAlignment="Center"
  11.                 VerticalAlignment="Center"
  12.                 Padding="15,7"
  13.                 Content="再来一个窗口"
  14.                 Click="OnClick" />
  15.     </Border>
  16. </Window><Window x:Class="就是6"
  17.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  18.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  19.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  20.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  21.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  22.         mc:Ignorable="d"
  23.         Title="太阳粒子" Height="400" Width="600">
  24.     <Border>
  25.         <Button HorizontalAlignment="Center"
  26.                 VerticalAlignment="Center"
  27.                 Padding="15,7"
  28.                 Content="再来一个窗口"
  29.                 Click="OnClick" />
  30.     </Border>
  31. </Window><Window x:Class="就是6"
  32.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  33.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  34.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  35.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  36.         xmlns:local="clr-namespace:MakeUIOnNewThread"
  37.         mc:Ignorable="d"
  38.         Title="太阳粒子" Height="400" Width="600">
  39.     <Border>
  40.         <Button HorizontalAlignment="Center"
  41.                 VerticalAlignment="Center"
  42.                 Padding="15,7"
  43.                 Content="再来一个窗口"
  44.                 Click="OnClick" />
  45.     </Border>
  46. </Window>
复制代码
  1. private void OnClick(object sender, RoutedEventArgs e)
  2. {
  3.     int current = 0;
  4.     while (current < 100)
  5.     {
  6.         Thread.Sleep(300);
  7.         current++;
  8.         // 添加一个委托操作
  9.         this.Dispatcher.BeginInvoke(() =>
  10.         {
  11.             pb.Value = current;
  12.         });
  13.         // 插入一帧
  14.         DispatcherFrame frame = new DispatcherFrame()
  15.         {
  16.             // 注意这里
  17.             Continue = false
  18.         };
  19.         Dispatcher.PushFrame(frame);
  20.     }
  21. }
复制代码
前面咱们说了,一个帧它就是嵌套循环,这里把 Continue 属性设置为 false 是正确的,不然你插入一帧就等于多了一层死循环,那消息循环更加堵死了。
但是,你运行上面代码后,发现窗口依然卡死了。这为什么呢?我们不妨回忆一下前面 PushFrame 方法的源码。
  1. while(frame.Continue)
  2. {
  3.     if (!GetMessage(ref msg, IntPtr.Zero, 0, 0))
  4.         break;
  5.     TranslateAndDispatchMessage(ref msg);
  6. }
复制代码
问题就出在这里了,你都让 Continue 为 false 了,那 GetMessage 方法还执行个毛线。这等于说消息循环还是转不动。所以,咱们必须想办法,让消息循环至少能转一圈。不用急着将 Continue 属性设为 false,可以先让它为真,但可以传递进委托里,在委托里把它 false 掉就可以了。这样既能让循环动一下,又不会导致死循环。
  1. while (current < 100)
  2. {
  3.     Thread.Sleep(80);
  4.     current++;
  5.     // 插入一帧
  6.     DispatcherFrame frame = new DispatcherFrame();
  7.     // 添加一个委托操作
  8.     this.Dispatcher.BeginInvoke((object arg) =>
  9.     {
  10.         pb.Value = current;
  11.         // 结束循环
  12.         ((DispatcherFrame)arg).Continue = false;
  13.     }, DispatcherPriority.Background, frame);
  14.     Dispatcher.PushFrame(frame);
  15. }
复制代码
官方给的 DoEvents 例子其实就是这个原理。
为什么循环会动呢?调用 BeginInvoke 方法添加委托到 Operation 队列后,消息循环还没动;到了 PushFrame 方法一执行 GetMessage 方法就能调用了,消息被提取并处理,这样咱们添加的委托就能运行了。然后在委托中我们把 Continue 属性变为 false。这样就退出了最新嵌套的循环。
好了,今天就水到这里了。今天几个项目上的码农朋友晚上搞个聚会,所以老周也准备出发,吃大锅饭了,场面可能比较热闹。
 

来源:https://www.cnblogs.com/tcjiaan/p/18233254
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

举报 回复 使用道具