对于 WPF 的线程模型,Dispatcher 对象相信各位大伙伴已经不陌生,尤其是跨线程更新UI的时候,都会用它来调度消息。与 Dispatcher 对象有关的,还有一个叫 DispatcherFrame 的东东,开发文档是这么说的:Represents an execution loop in the Dispatcher,这样描述肯定是让人看不明白的,老周也不明白。
那咋办呢?根据老周十几年来积累下来的一些不要脸的经验,遇到这些很是抽象的玩意儿,可以有两种途径去了解:1、看.net 源代码,看看它能干吗;2、自己写几行代码试一试,许多时候,一试便能明了。
常看老周的垃圾博客的朋友一定很了解老周,老周向来重视示例,所以,下面咱们用示例来慢慢寻找真相,千万不要急,急的人都是浮躁。
好,首先,我们在窗口中放一个矩形,一个按钮。
<Grid> <Grid.RowDefinitions> <RowDefinition /> <RowDefinition Height="auto"/> </Grid.RowDefinitions> <Rectangle Height="350" Fill="Red" Name="rect" Width="0"/> <Button Grid.Row="1" Content="Click Me" Click="OnClick"/> </Grid>
当按钮被单击后,会引发事件,然后,我在事件处理代码中,不断地让矩形的宽度增加,即增加 Width 属性。
我们先来第一种情形,把代码放到一个Task上,并用 Dispatcher 来更新矩形宽度,这种做法很常用。
Task taskrun = new Task(() => { double i = 0d; for (i = 0d; i < 800d; i++) { Task.Delay(5).Wait(); Action act = () => rect.Width++; Dispatcher.BeginInvoke(act, DispatcherPriority.Background); } }); taskrun.Start();
因为 Task 会在另一个线程上干活,所以修改UI要借助 Dispatcher 大叔来调度消息。DispatcherPriority.Background表示更新宽度的代码在后台进行。其实你可以更为这样:
Dispatcher.BeginInvoke(act, DispatcherPriority.Render);
这样优先级更高,优先照顾UI呈现,可以尽可能地及时更新矩形的宽度。
于是,你能看到这样的效果。
好,下面我们做第二轮试验,刚才的代码中,for 循环是在一个Task中执行的,所以它不占用UI线程,只是在修改矩形宽度时才会与UI线程交互。这次我们把所有代码都在UI线程上执行。
Action invkAct = () => { rect.Width++; }; for (double i = 0d; i< 800d; i++) { Task.Delay(5).Wait(); Dispatcher.BeginInvoke(invkAct, DispatcherPriority.Render); }
然后一运行,你就发现,卡住了。就算你把 DispatcherPriority 改为 Background 也一样卡住。因为for在UI线程上执行,UI更新的消息没有被及时处理(只有一条车道,塞车是肯定的),于是,更新矩形宽度的消息一直在消息队列中排队,只有等For 循环执行完了,才能更新矩形宽度,显然,这样不符合我们的预期了。
要么,你就像最前面的例子那样,把for循环写到Task上,以减轻UI线程的负担。如果你真的不想用新的线程去执行代码,而希望全在UI线程上执行,便真的要用上 DispatcherFrame 了。
DispatcherFrame 类有个很TMD重要的属性——Continue,为什么重要?先放着,待会儿回过头来再说。
要把一个 DispatcherFrame 插入到Dispatcher 中,就要调用 PushFrame 方法,这是个静态方法,Frame 会被插入到当前线程的 Dispatcher 对象中。在.net 源代码中,我发现 PushFrame 方法的实现代码,现贴出来,请各位与老周一同鉴赏。
public static void PushFrame(DispatcherFrame frame) { …… Dispatcher dispatcher = Dispatcher.CurrentDispatcher; …… // 下面这个方法才是实现代码 dispatcher.PushFrameImpl(frame); }
private void PushFrameImpl(DispatcherFrame frame) { // 用来切换线程的上下文 SynchronizationContext oldSyncContext = null; SynchronizationContext newSyncContext = null; // Windows 消息 MSG msg = new MSG(); // _frameDepth 变量用来统计,代码执行前加1,代码执行后会减1 _frameDepth++; try { // 临时更改线程上下文 oldSyncContext = SynchronizationContext.Current; newSyncContext = new DispatcherSynchronizationContext(this); SynchronizationContext.SetSynchronizationContext(newSyncContext); // 注意,下面代码是重点,开启一个消息循环 try { // 循环条件正是 Continue 属性 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 { // 还原线程上下文 SynchronizationContext.SetSynchronizationContext(oldSyncContext); } } finally { _frameDepth--; // 注意这里减1 if(_frameDepth == 0) { // We have exited all frames. _exitAllFrames = false; } } }
代码看起来很长,看不懂不要紧,重点看那个 while 循环。
while(frame.Continue) { if (!GetMessage(ref msg, IntPtr.Zero, 0, 0)) break; TranslateAndDispatchMessage(ref msg); }
现在,你明白 DispatcherFrame.Continue 属性的作用了吧。是的,如果它为真,那么这个循环会执行,GetMessage才会获取消息,并交由 TranslateAndDispatchMessage 函数进行处理。如果为假,那循环自然不会执行了。
由于 DispatcherFrame 会开启一个消息循环来提取未处理的消息,于是你一定想到了,在 for 的每一轮循环中向 Dispatcher 对象插入一个 DispatcherFrame 实例。但是,以下两种用法都无法及时更新UI。
方案A:无效。
Dispatcher.PushFrame(new DispatcherFrame()); for (double n = 0d; n < 800d; n++) { Task.Delay(5).Wait(); rect.Width++; }
方案B:依然无效。
for (double n = 0d; n < 800d; n++) { Task.Delay(5).Wait(); rect.Width++; ProcessMsgs(); } private void ProcessMsgs() { DispatcherFrame frame = new DispatcherFrame(); Dispatcher.PushFrame(frame); frame.Continue = false; }
以上两方案,一个是在循环之前开启一个新的循环,另一个则是在每一轮for循环中插入一个DispatcherFrame对象,并把Continue属性改为False,用以及时退出消息循环。
然而,想象很美好,实战很残酷。以上两个方案虽然让界面不卡了,但,矩形的宽度依旧不会实时更新。为什么会这样呢,根据老周的低见,应该是与优先级有关,毕竟 for 循环正在密集地执行,插入到消息队列的消息只能在厕所排长龙。
因为代码都在一个线程上执行,其实UI是做不到真正的实时更新的,但可以稍稍延迟更新,至少眼睛是看不出来的。
可以把代码改成这样:
private void ProcessMsgs() { DispatcherFrame frame = new DispatcherFrame(); Action<object> cb = obj => { DispatcherFrame f = obj as DispatcherFrame; f.Continue = false; }; Dispatcher.BeginInvoke(cb, DispatcherPriority.Background, frame); Dispatcher.PushFrame(frame); }
之所以要在BeginInvoke的委托中修改 Continue 属性,最重要的是可以设置优先级,除了Background,还可以使用 ApplicationIdle、SystemIdle等值,但不能用 Render、Send 这些优先级较高的值,因为这样也会被主消息循环阻塞,只能使用空闲或后台优先级。
这样改了之后,就可以达到预期效果了。
这类似于WinForm 中的 DoEvents 方法,MSDN上有这个示例。
总结一下,DispatcherFrame 的用途就是激活一新的消息循环,并以 Continue 属性作为退出新消息循环的标志。