从Dispatcher.PushFrame()说起

  写在前面:本文实际上是在开发过程中解决特殊问题的一个总结。由于我并非MS员工,因此可能有讲解得不尽正确的地方,望您指出。为了您阅读方便,请对照.net源码进行阅读(源码获取方式已列出)。

  相信您在使用WPF的过程中也遇到过这种问题:如果UI线程执行了非常耗时的计算并尝试在执行过程中更改UI组成中的内容,WPF界面并不会立即发生更改。对于需要给出即时信息的用户需求而言,WPF的这种延迟绘制功能反而给软件开发人员带来了极大的不便。当然,从根本上解决该问题的方法就是将该耗时计算单独置于工作线程中。只是这种解决方案常常由于某些限制无法施行:对于某些遗留代码来说,将耗时计算单独抽离是一件较为复杂的事情;而对于一个即将交付的产品而言,执行大范围改动是一种不明智的举动。在这种情况下,MSDN中所介绍的对函数Application.DoEvents()的模拟派上了用场:

 1 public void DoEvents()
2 {
3 DispatcherFrame frame = new DispatcherFrame();
4 Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background,
5 new DispatcherOperationCallback(ExitFrame), frame);
6 Dispatcher.PushFrame(frame);
7 }
8
9 public object ExitFrames(object f)
10 {
11 ((DispatcherFrame)f).Continue = false;
12
13 return null;
14 }

  我想您在第一次看到这段代码时和我当时一样疑惑。它偷偷地做了什么?为什么所有该绘制的控件仅仅因为DoEvents()函数的调用就得到了刷新?

 

一.Dispatcher.PushFrame()

  在网络上寻觅了半天,也没有发现详细介绍该段代码所包含奥妙的文章。没有办法,我们只好从.net源代码着手研究了。

  首先要做的是获得相关部分的源代码。一般情况下,我都会使用Reflector将整个程序集导出为项目。导出的步骤如下:

  1. 打开.NET Reflector(我的是7.3),右键点击需要导出的程序集,在弹出菜单中选择“Export Assembly Source Code…”。这里我们选择WindowsBase.dll,因为Dispatcher.PushFrame()就是定义于其中的。

  

  2. 在弹出的对话框中选择需要导出的路径,并点击“Start”开始导出。导出的过程需要持续一段时间,在这段时间中,您可以休息一下。

  3. 导出完毕后,一个完整的项目就存在于您指定的目录下了:

  

  打开WindowsBase.csproj并搜索PushFrame,我们就可以找到该函数的实现:

 1 private void PushFrameImpl(DispatcherFrame frame)
2 {
3 MSG msg = new MSG();
4 this._frameDepth++;
5 try
6 {
7 while (frame.Continue)
8 {
9 if (!this.GetMessage(ref msg, IntPtr.Zero, 0, 0))
10 break;
11 this.TranslateAndDispatchMessage(ref msg);
12 }
13 if ((this._frameDepth == 1) && this._hasShutdownStarted)
14 this.ShutdownImpl();
15 }
16 finally
17 {
18 this._frameDepth--;
19 if (this._frameDepth == 0)
20 this._exitAllFrames = false;
21 }
22 }

  这里我对该段代码进行了适当的删减,以便于讲解。

  首先应当引起注意的就是成员变量_frameDepth。该变量在每次执行PushFrameImp()函数时自增,在离开之前自减,并在函数体中作为判断的依据。这种使用方式非常明显地表示PushFrameImp()函数可以以重入的方式使用。为了了解该函数是如何被重入使用的,我们需要查找它在.net中的调用方式。幸运的是,对该函数进行调用的逻辑非常简单。我们很快找到Dispatcher.Run()函数也会调用Dispatcher.PushFrameImp()函数。

  沿着代码向下读,我们就可以看到一个消息循环:

1 while (frame.Continue)
2 {
3 if (!this.GetMessage(ref msg, IntPtr.Zero, 0, 0))
4 break;
5 this.TranslateAndDispatchMessage(ref msg);
6 }

  首先,该消息循环会以DispatcherFrame的Continue属性值作为是否结束消息循环的依据。在DoEvents()函数模拟中,ExitFrames()将Continue设置为false,也便在当前消息处理完毕以后结束了消息循环的执行,进而在不久完成对PushFrameImp()的重入调用。

  接下来,该消息循环会通过TranslateAndDispatchMessage()函数完成对消息的处理。该函数实际通过Win32 API将消息发送到对应窗口中以待处理。

  现在我们就可以解释PushFrame()函数是如何模拟Application.DoEvent()函数的了。首先,Dispatcher.Run()函数会随着线程的启用而被调用,从而压入一个DispatcherFrame实例。该实例内部会通过上文所述的消息循环完成对消息的转化及分发。此时一旦一个消息需要长时间运行,那么其它消息将无法被处理,从而导致绘制消息无法及时被处理,从用户的角度看来就是界面无法及时更新。

  一旦我们通过PushFrame()函数再次压入一个DispatcherFrame的实例,那么消息循环将由于PushFrameImp()的再次执行而被启动,直到这个新压入的DispatcherFrame实例的Continue属性值为false为止。由于压入的事件优先级(仅仅为Background)低于绘制操作的Render优先级,因此在新压入DispatcherFrame所导致的消息循环结束运行之前,绘制操作即能够完成。

 

二.消息处理

  从上面所推导出的PushFrame()函数执行逻辑上看来,其不仅仅完成了绘制,更完成了对其它消息的处理。对于我们所希望的刷新界面这一任务而言,该方法做了太多不该做的事情。是否有一种方法可以仅仅执行对界面的绘制呢?

  为了解决这个问题,我们需要了解WPF是如何对界面进行绘制的。既然通过PushFrame()函数所导致的GetMessage()、TranslateMessage()以及DispatchMessage()的调用就能完成对界面的更新,那么解决这个问题的第一步就是找到实际触发了绘制的消息。

  现在,我们不再调用PushFrame()函数,而是模拟PushFrame()函数的内部执行逻辑创建一个消息循环并在需要对事件进行分发时启用该消息循环。在分发过程中,我们可以通过Trace等方法查看被处理的消息ID:

 1 while (TRUE)
2 {
3 MSG msg;
4 if (GetMessage(&msg, NULL, 0, 0))
5 {
6 TRACE("Get MSG: %x\n", msg.message);
7
8 ::TranslateMessage(&msg);
9 ::DispatchMessage(&msg);
10 }
11 else
12 break;
13 }

  在我的电脑上,这些消息的ID分别为0xC25A以及0xC262。根据Win32对消息范围的划分,我们可以知道它们是通过RegisterWindowMessage()函数定义的字符串消息。在.net源代码中,RegisterWindowMessage()函数被多处调用。那么0xC25A及0xC262分别代表哪个消息呢?这里我使用了GetClipboardFormatName()函数。在MSDN中并没有提到其可以获得自定义消息所对应的字符串这一功能,所以只能说,It works。。。

  在试验程序中,我添加了如下代码以获得0xC25A及0xC262所对应的的字符串:

1 TCHAR szMsgBuf[MAX_PATH];
2 GetClipboardFormatName(0xC25A, szMsgBuf, MAX_PATH);

  通过该段代码得到的0xC25A及0xC262所对应的字符串分别为“DispatcherProcessQueue”以及“MilChannelNotify”。

  那么,是否是这两个消息直接导致了UI界面的刷新呢?在.net源代码中搜索字符串“DispatcherProcessQueue”以及“MilChannelNotify”,可以发现.net源代码的确使用了这两个消息。DispatcherProcessQueue消息由Dispatcher.ProcessQueue()函数处理,而MilChannelNotify消息最终由MediaContext.NotifyChannelMessage()函数处理。

  首先来看看对MilChannelNotify消息的处理。该消息的处理函数通过如下调用堆栈向Dispatcher中插入了绘制任务:

  MediaContext.NotifyChannelMessage()

      MediaContext.NotifyPresented()

          MediaContext.ScheduleNextRenderOp()

              Dispatcher.BeginInvoke()

  在阅读函数ScheduleNextRenderOp()的内部实现时,您可能会对其中两个地方产生疑惑:对BeginInvoke()函数的调用为什么有不同优先级?为什么BeginInvoke()函数所传入的委托的名称_animRenderMessage像是对动画功能进行管理的一个委托?

  首先,对BeginInvoke()函数进行调用的同时也启用了几个计时器:_promoteRenderOpToInput及_promoteRenderOpToRender。这些计时器定义了从BeginInvoke()函数调用优先级升级到相应的Input及Render所需要的时间。在到达该时间时,这些计时器的回调函数会提高BeginInvoke()所传入任务_currentRenderOp的优先级至相应级别。实际上,这种自动提高优先级的处理方法在某些系统中非常常见,如Windows系统中线程的PriorityBoost机制。

  至于委托的名称为什么是_animRenderMessage,我想应该是对委托的重用。在.net源码中搜索该委托,就能看到该委托的处理函数AnimatedRenderMessageHandler()。该函数最终沿如下调用堆栈调用各个界面组成的Render()函数:

  MediaContext.AnimatedRenderMessageHandler()

      MediaContext.RenderMessageHandlerCore()

          MediaContext.Render()

              ICompositionTarget.Render()

  ICompositionTarget接口被多个类型实现,如HwndTarget等。该调用堆栈与_renderMessage委托的消息处理函数RenderMessageHandler()所导致的调用堆栈几乎相同,因此可以说,委托_animRenderMessage的功能就是用来实现UI界面的刷新。

  实际上,在接触到ICompositionTarget.Render()函数之后,我们已经非常接近WPF的底层实现了。CompositionTarget类实现了ICompositionTarget.Render()接口,而在接口中对Visual类的RenderRecursive()函数调用已经让我们看到了对视觉树的绘制,而DUCE类则看起来更像是WPF各个托管组件和milcore进行交互的组成。

  我们知道,Visual类实例用来包含一系列绘制指令以及如何绘制这些指令的数据。并是WPF托管API以及DirectX的非托管包装milcore两个子系统的连接点。对Visual的绘制通过DrawingContext来完成。其使用一种栈式的操作方法压入及弹出一些特性,并同时提供了一系列用于绘制的函数。这些调用最终会转化为一系列对绘制指令的存储,而不是将内容直接绘制在屏幕上。这种存储绘制指令供以后使用的模式则被称为保留模式系统,也是WPF绘制系统的特色之一。而直接绘制的方式则被称为即时模式图形系统。

  仔细研究.net代码后就可以发现,绝大部分绘制功能的实现都集中在DrawingContext的派生类RenderDataDrawingContext中。UIElement等基础类型的RenderOpen()函数实际上返回的是RenderDataDrawingContext类型的实例,而该实例将最终传入OnRender()函数中,以定义各个界面组成的绘制方法。在RenderDataDrawingContext类所提供的众多函数中,我们都可以看到对成员_renderData的写入操作。

  好了,扯得太远了。我们回到WPF是如何绘制UI界面组成的这一话题上来。

  在前面的讲解中,我们忽略了一个事情,那就是我们一直假设BeginInvoke()函数所插入的委托函数都可以自行运行。现在回头看一下这个问题:谁在触发对BeginInvoke()函数所插入委托的处理?先说出答案:DispatcherProcessQueue消息。现在我们来看看该消息的处理函数的实现。

  在DispatcherProcessQueue消息到达的时候,Dispatcher.ProcessQueue()函数即被调用。该函数将从优先级队列_queue中出队一个优先级不为Invalid以及Inactive的操作并调用操作的Invoke()函数。这样,由BeginInvoke()函数调用插入优先级队列的操作即被执行。其中需要注意的一行代码就是对Dispatcher.RequestProcessing()的调用。该函数会在优先级队列中拥有等于或高于DispatcherPriority.Loaded级别操作时通过TryPostMessage()函数再次发送DispatcherProcessQueue消息,以处理其它存在于_queue中的操作。通过这种方式,DispatcherProcessQueue消息就能将优先级高于或等于DispatcherPriority.Loaded的操作全部执行完毕。

  在证明了自定义消息“DispatcherProcessQueue”及“MilChannelNotify”可以用来触发UI的重绘之后,我们就可以通过从消息泵中提取并分发这两个消息强制WPF对界面进行重绘。

  需要注意的是,由RegisterWindowMessage()函数所注册的自定义消息并不具有固定的ID值,甚至在同一台电脑上的不同会话中都有可能变化,因此您不能直接使用我这里列出的数值0xC25A及0xC262作为函数调用GetClipboardFormatName()的参数。但是在程序中,我们需要知道自定义消息“DispatcherProcessQueue”以及“MilChannelNotify”所对应的ID以便对其进行捕获和处理。获得它们所对应ID的方法则是以这些自定义消息所对应的字符串作为参数调用RegisterWindowMessage()函数:

static UINT uProcessQueue = RegisterWindowMessage(_T("DispatcherProcessQueue"));

  综上所述,PushFrame()中真正起作用的组成应如下所示:

 1 static UINT uProcessQueue = RegisterWindowMessage(_T("DispatcherProcessQueue"));
2 static UINT uChannelNotify = RegisterWindowMessage(_T("MilChannelNotify"));
3
4 while (TRUE)
5 {
6 MSG msg;
7 if (::PeekMessage(&msg, NULL, uProcessQueue, uProcessQueue, PM_REMOVE | PM_NOYIELD)
8 || ::PeekMessage(&msg, NULL, uChannelNotify, uChannelNotify, PM_REMOVE | PM_NOYIELD))
9 {
10 ::TranslateMessage(&msg);
11 ::DispatchMessage(&msg);
12 }
13 else
14 break;
15 }

 

转载请注明原文地址:http://www.cnblogs.com/loveis715/admin/EditPosts.aspx?postid=2319976

商业转载请事先与我联系:silverfox715@sina.com

posted @ 2012-01-11 22:54  loveis715  阅读(6831)  评论(12编辑  收藏  举报