▪ 前言
开始着手写这个WPF系列,这里的一站式就是力争在每一个点上能把它讲透。当然,做不到那么尽善尽美,如果有不对的地方也欢迎朋友们指正,我会逐步补充,争取把这个系列写好。
通常,WPF 应用程序从两个线程开始:一个用于处理呈现(render),一个用于管理 UI。呈现线程有效地隐藏在后台运行,而 UI 线程则接收输入、处理事件、绘制屏幕以及运行应用程序代码。
UI 线程对一个名为 Dispatcher 的对象内的工作项进行排队。 Dispatcher 基于优先级选择工作项,并运行每一个工作项直到完成。每个 UI 线程都必须至少有一个 Dispatcher,并且每个 Dispatcher 都只能在一个线程中执行工作项。
以上两段是 MSDN 上关于 WPF 线程模型 的描述。主要介绍了两个概念:
- WPF中线程一分为二:一个用于呈现(Render),一个用于管理 UI。
- 在 UI 线程中,使用了一个名为 Dispatcher 的类帮助 UI 线程处理任务。
▪ WPF 的 线程模型 和 Dispatcher
线程模型 和 Dispatcher 到底是怎样的呢,它又有什么特点,有什么优缺点呢?在正式分析之前,我先找一个插入点,希望这个插入点能为朋友们所理解。
▪ WPF的窗口建立和消息机制
作为一个 Presentation 的基架,WPF 的使命就是要编写图形化的操作界面。而在 Windows 操作系统上,图形化界面是建立在消息机制这个基础上的,那么创建一个窗口,要经历哪些步骤呢?
- 创建窗口类:WNDCLASSEX wcex; RegisterClassEx(&wcex);
- 创建窗口:CreateWindow(…); ShowWindow(…); UpdateWindow(…);
- 建立消息泵:while( GetMessage(&msg,NULL,0,0) ){ TranslateMessage(&msg); DispatchMessage(&msg); }
▪ 打个比方,我们在一个自动化的厂房里生产设备
-
基于正规,我们会首先定义好该设备的模板,这就是创建窗口类(这里 “类” 更多表示类别的意思)。
-
模板定义完毕,我们可以正式生产设备了,这就是 创建窗口,调用
CreateWindow(…)
的时候会通过字符串参数来匹配到我们定义的模板(窗口类)。 -
创建成功后,我们要让生产出来的设备动起来。就要像人一样,体内一定要有类似于血液的流传机制,把命令传达到设备的各个部分,这就是消息泵,这个泵就像我们的心脏一样,源源不断的通过
GetMessage(…)
并调用DispatchMessage(…)
来分发血液(消息)。既然我们通过消息来对设备下达指令,那么就要有消息队列来存储消息,在 Windows 中,线程为基本的调度单位,这个消息队列就在线程上,当循环使用GetMessage(…)
时,就是在当前线程的消息队列中的取出消息,然后分发到对应的窗口中去。
▪ 那么具体到WPF,它又是一个怎么样的情况,如何和老的技术兼容,又有什么新的突破呢?
WPF 引入了 Dispatcher 的概念,这个 Dispatcher 的主要功能类似于 Win32 中的消息队列,在 Dispatcher 的内部函数中仍然调用了传统的创建窗口类,创建窗口,建立消息泵等操作。
Dispatcher 本身是一个单例模式,构造函数私有,暴露了一个静态的 CurrentDispatcher()
方法用于获得当前线程的 Dispatcher
。对于线程来说,它对 Dispatcher
是一无所知的,Dispatcher 内部维护了一个静态的 List<Dispatcher> _dispatchers
, 每当使用 CurrentDispatcher(…)
方法时,它会在这个 _dispatchers
中遍历,如果没有找到,则创建一个新的 Dispatcher
对象并加入到 _dispatchers
中去。Dispatcher
内部维护了一个 Thread
的属性,创建 Dispatcher
对象时会把当前线程赋值给这个 Thread
的属性,下次遍历查找的时候就使用这个字段来匹配是否在 _dispatchers
中已经保存了当前线程的 Dispatcher
。
▪ 那么 创建窗口 和 建立消息泵 又是什么时候被调用的呢?
在 Dispatcher
内部,维护了一个 HwndWrapper
的字段,在 Dispatcher
的构造函数中,调用了 HwndWrapper
的构造函数,创建窗口类 和 创建窗口 就是在这个函数中被调用的。这里实际的类是 MessageOnlyHwndWrapper
,这个 Message-Only,是 Windows 编程中常用的伎俩,创建一个隐藏窗口,仅仅用来派发消息。
▪ 那么循环读取消息的消息泵又是什么时候建立起来的呢?
Dispatcher
对外提供了一个静态的 Run(…)
函数,顾名思义,就是启动 Dispatcher
,在其函数内部,调用了 PushFrame(…)
函数,在这个函数中,可以找到熟悉的 GetMessage(…)
, TranslateAndDispatchMessage(…)
。
▪ 那么这个 PushFrame 是怎么回事,Frame这个概念又是如何而来的呢?
这个就是 WPF 引入的一个新的概念:嵌套消息泵,就是在一个 while(GetMessage(…))
内部又启动了一个 while(GetMessage(…))
。
每调用一次 PushFrame(…)
,就会启动一个新的嵌套消息泵。每调用一次 GetMessage(…)
,就在线程的消息队列中取出一个消息,直至取出 WM_QUIT
的时候 GetMessage(…)
才返回 False
。这个 GetMessage(…)
函数 Windows 内部进行了处理,当消息队列为空时,挂起执行线程,避免死循环的发生。
关于嵌套消息泵的优缺点,我们稍后再讲,先来看看 Dispatcher
是如何处理任务的:
Windows 中定义了很多 Message,以 WM_ 开头,在注册窗口类的时候需要设置窗口过程函数,GetMessage(…)
取得的消息再分发到窗口过程函数中,整个过程为:
这个图来自于侯捷的经典书籍《深入浅出MFC》:
- 首先创建 Window 并指定窗口的过程函数 WndProc。
- 当窗口创建时一个 WM_CREATE 被放入到消息队列中。
- 消息泵通过
GetMessage(…)
取得该消息后分发到窗口,窗口过程函数处理这个 WM_CREATE 消息…
▪ 那么 WPF 的 Dispatcher
在这个过程中扮演了什么角色呢?
上面的 1,2,3 三点仍然如此,当窗口过程函数接收到消息时,它需要根据消息的类别把 Windows 消息转译成内部的 RoutedEvent 或者调用布局函数等来处理。
前面提到了 Dispatcher
主要功能类似于 Win32 中的消息队列,这个队列中存放的对象是 DispatcherOperation
,这个 DispatcherOperation
就是把每一个执行项封装成一个对象,类似:
这个队列的类型为 PriorityQueue
,是一个含有优先级的队列。WPF 定义了这个优先级 DispatcherPriority
有如下枚举:
当对这个 PriorityQueue
调用 DeQueue
时,就会取出优先级最高的任务。
▪ 那么这个队列中的任务是什么时候被添加的,又是什么时候被取出执行的呢?
Dispatcher
暴露了两个方法,Invoke
和 BeginInvoke
,这两个方法还有多个不同参数的重载。其中 Invoke
内部还是调用了 BeginInvoke
,一个典型的 BeginInvoke
参数如下:
public DispatcherOperation BeginInvoke(Delegate method, DispatcherPriority priority, params object[] args);
在这个 BeginInvoke
内部,会把执行函数 method
与参数 args
封装成 DispatcherOperation
,并按 priority
加入到 PriorityQueue
中,这个返回值就是内部创建的 DispatcherOperation
。
结论:也就是说每调用一次 Invoke
和 BeginInvoke
,就向 Dispatcher
中加入了一个任务
▪ 那么这个任务什么时候被执行呢?
DispatcherPriority
定义了很多优先级,WPF 将这些优先级主要分成两类:前台优先级和 后台优先级,其中前台包括 Loaded
~ Send
,后台包括 Background
~ Input
(参照上图中的 DispatcherPriority
枚举)。剩下的几个优先级除了 Invalid
和 Inactive
都属于空闲优先级,处理顺序同后台优先级。ProrityQueue
的来源有:
当然,这里 Hwnd 级别 Hook 到的消息最终也是调用 Dispatcher
的 Invoke/BeginInvoke
方法加入到 Dispatcher
的队列中去的。当处理这个 PriorityQueue
时,会首先取得队列中的最大优先级,如果它属于前台优先级,执行。如果属于后台优先级,那么它要去扫描线程的消息队列,看看其中是由有类似 WM_MOUSEMOVE 之类的 Input 消息。如果没有,执行。如果存在,则放弃执行,并启动一个 Timer,当 Timer 唤起时继续判断是否可以执行。
▪ 那么处理 PriorityQueue
的时机呢?
当你调用 BeginInvoke
,向队列中加入执行项的同时,也会调用处理 Queue
的判断。判断逻辑和上面类似,队列中最大优先级是 前台优先级,向隐藏窗口 PostMessage(…)
,这个消息是 Disptcher
使用 RegisterWinodwMessage(…)
注册的自定义消息。然后在调用 GetMessage(…)
的时候如果取出这个自定义消息,则处理 PriorityQueue
。如果是后台优先级,扫描线程消息队列的 Input 消息,决定是否启动 Timer 还是 PostMessage(…)
。
▪ 举个例子,在后台线程中向 UI 线程中使用 Invoke
来发送请求,经历的过程为:
-
调用
this.Dispatcher.Invoke(…)
,对传入的参数DispatcherPriority
进行判断,如果是Send
,这是个特殊的优先级,直接执行任务并返回(不放入消息队列)。如果是其他的优先级,调用BeginInvoke(…)
。 -
在
BeginInvoke(…)
中,把传入的Delegate
和参数封装成DispatcherOperation
,加入到PriorityQueue
中。 -
调用队列处理的请求函数,希望处理
PriorityQueue
。 -
如果队列中最大优先级属于前台优先级,调用
PostMessage(…)
向隐藏窗口发送自定义消息。后台处理这里省略不表。 -
在调用
GetMessage(…)
中取得消息并分发到隐藏窗口,这里使用的是常见的 SubWindow(注释一),消息通过 Hook 发送到Dispatcher
的WndProcHook(…)
函数进行处理。 -
在
WndProcHook(…)
中,如果接收到的Window消息是Dispatcher
自定义的消息,则真正处理PriorityQueue
。 -
处理
PriorityQueue
,从中取出一个任务,进行前后台优先级判断,决定是否处理还是启动 Timer 稍后处理。
▪ WPF 的嵌套消息循环
▪ 回过头来,说一说嵌套的消息循环,这个要从模态对话框说起,一个通常的模态对话框场景如下:
SomeCodeA();
bool ? result = dlg.ShowDialog();
SomeCodeB();
代码运行在UI线程中,当执行到 dlg.ShowDialog()
时,启动模态对话框,等待用户点击 Yes/No 或者 关闭对话框
对话框关闭后程序继续执行 SomeCodeB()
代码。那么程序要在 SomeCodeB()
处等待 ShowDialog()
返回后才继续执行。
当然你可以使用 WaitHandle
来同步,不过这个需要挂起当前(UI)线程,如果主窗口中有动画等UI动作,那么会停止得不到响应。这里 WPF 使用的是 PushFrame,就是在 ShowDialog()
内部又建立起了一个消息泵 while(GetMessage(…))
。一方面,可以确保 UI 线程中的消息可以被处理;另一方面,因为是 while 循环,在对话框关闭时返回,可以确保 SomeCodeB()
的执行顺序。
▪ 那么是不是这个嵌套的消息循环真的如此完美呢?
当然不是,它打开了一扇门的同时,也打开了另一扇门。一个情景,当收到 WM_SIZE 消息的时候,Layout系统 开始处理,如果在这个处理过程中,又启动了 PushFrame,那么嵌套的消息泵就会继续从消息队列中取出消息,如果下一个消息也是 WM_SIZE ,那么进行处理。
假设这个消息处理结束后这个嵌套的消息泵返回了,那么第一个 WM_SIZE 得以继续处理。这样就发生了错误,本来12的处理顺序变成了121。当然这种情况不仅仅发生在 Layout 中,所以 WPF 在 Dispatcher
中加入了一个 DisableProcessing(…)
函数,在 Layout 等关键过程中调用了这个函数,在这个过程中停止 pump消息 和 禁止PushFrame。
在WPF中,所有的 WPF 对象都派生自 DispatcherObject
,DispatcherObject
暴露了 Dispatcher
属性用来取得创建对象线程对应的 Dispatcher
。鉴于线程亲缘性,DispatcherObject
对象只能被创建它的线程所访问,其他线程修改 DispatcherObject
需要取得对应的 Dispatcher
,调用 Invoke
或者 BeginInvoke
来投入任务。一个 UI 线程至少有一个 Dispatcher
来建立消息泵处理任务,一个 Dispatcher
只能对应一个UI线程。
▪ 那么UI线程和Render线程又如何呢?
开篇提到,WPF线程一分为二,一个是UI线程,一个是Render线程。这两个被设计成分离的关系,通过Channel(event)来进行通信。两者之间的数量关系是一个WPF进程只能有一个Render线程,旦可以有大于等于一个的UI线程。通常情况下是一个UI线程,也就是一个Dispatcher,那么什么情况下需要建立多个呢?
大多情况下是不需要的,少数情况下,比如MediaElement,或者Host其他ActiveX控件,我们期望在其他线程中创建,以提高性能。可以新建线程,在新线程中创建控件,并调用Dispatcher.Run启动Dispatcher。这样主Window和新控件就处在不同线程中,两者间的通信可以使用VisualTarget连接视觉树或者使用D3DImage拷贝新控件到主Window中显示。
开篇有益,WPF没有什么全新的技术,但提出了很多新的概念。就像施了妆包装后的美人,远看很美,可是风一来手一伸,丫的,不过如此。_
▪ 注释一
SubWindow,子窗口子类化。通常情况,所有同类别Window会共用同一个消息处理函数WndProc,子Window可以调用SetWindowLong用SubWndProc替换WndProc,这个通常称为Sub-Window。