Bob Gunderson
MSDN技术组
作于:1992年11月11日
Creamdog
译于:2002年3月13日
译者的话
该文重点讲述了Windows处理事件、消息的具体过程和步骤。尤其是在系系处理鼠标键盘事件的过程上做了详解。通过这篇文章,你将对Windows的消息处理机制有一个较全面的了解。
概念
这篇文章解释了GetMessage和PeekMessage的内部运作方式,同时也是一类与“消息及消息在16位 MS-DOS®/Microsoft® Windows™环境之下的影响”相关文章的基础。我们将讨论下面这些主题:
·系统和应用程序队列(译者注:以下简称为“程序队列”)
·GetMessage和PeekMessage函数
·消息过滤
·WM_QUIT消息
·让步和休眠
·让步的问题
·WaitMessage
16位MS-DOS/Windows环境和32位Win32™/Windows NT™环境有些很重要的不同之处。虽然这些不同之处在这儿无法被忽视,但我们还是把它们做为遗留问题,由以后的文章去解释吧。
队列
要理解GetMessage和PeekMessage的运作,必须首先明白Microsoft® Windows™操作系统是如何储存事件和消息的。在Windows中有两种类型的队列为此目的工作,它们分别是系统队列和消息队列。
硬件输入:系统队列
Windows有一些驱动程序,它们负责响应来自于键盘和鼠标等硬件的中断服务。在中断时间中,键盘和鼠标驱动程序会调用USER.EXE中指定的一些入口点去报告一个事件的发生。在Windows中服务于光笔计算的光笔驱动程序,同样会在原始的光笔事件中调用这些入口点。
在Windows3.1中,系统队列是一个有着120个入口空间的定长的队列。在一般情形下这些“小房间”是足够了,但如果应用程序挂起了或者在一段长的时间里没有及时处理任何消息就可能导致系统队列被填满。如果真的发生了,任何尝试添加到系统队列的新事件都将会引起系统蜂鸣。(译者注:在DOS中,如果一个程序在一段时间内占用了所有的系统资源,使机器无法响应,这时如果你按住一个键不放,你就会听到机箱喇叭嘀嘀作响)
发送的消息和程序队列
当一个应用程序开始时,一个队列将会因此而被创建。程序队列(有时会称为任务队列)常常用于储存“正在被发往应用程序的一个窗口” 的消息。唯一常驻程序队列的消息是那些由PostMessage或PostAppMessage明确发送的消息。(SendMessage从不使用系统队列)PostQuitMessage函数不会发送一个消息到程序队列。(WM_QUIT消息将在下文中论讨)
默认的,每个程序队列可以保持八个消息。一般情况下这是相当足够的,因为PostMessage极少被使用。但是如果一个应用程序试图强制调用很多的PostMessage到某个应用程序时,那么这类应用程序将会用使用SetMessageQueue函数来增加消息队列的长度。你必须小心的使用SetMessageQueue函数,因为它无论何时都会先删掉当前的程序队列,并创建一个预期大小的新队列,此时任何在旧队列中的消息都会被销毁。因此,它必须在你的WinMain例程中在所有其它的应用程序编程接口(API)之前调用或在应用程序用PeekMessage明确的清除队列之后调用。
GetMessage和PeekMessage是怎样工作的
在Windows的内部,GetMessage和PeekMessage执行着相同的代码。而两者最大的不同之处则体现在没有任何消息返回到应用程序的情况下。在此种情况下,PeekMessage会返回一个空值到应用程序,GetMessage会在此时让应用程序休眠。在它们之间还有一些其它的不同,我们将会在下面讨论,但它们相当次要。
GetMessage和PeekMessage逻辑
下面一步步的讲述了在Windows3.1版的GetMessage和PeekMessage公用代码。
提示:下面所示步骤按照消息类型的优先权进行排序。举个例子,发送的消息总在键盘和鼠标消息之前被返回,而键盘和鼠标的消息又会在绘图(paint)消息之前反回,以此类推。
1. 检视在为“活动中任务”服务的程序队列中是否有消息的存在。如果是,首先在队首删除此消息并将其返回到应用程序。然后,应用程序中的GetMessage和PeekMessage会调用一些代码,用以从程序队列中接收此消息,这些代码是由该应用程序调用的动态链接库(DLL)生成的。记住,只有由PostMessage发送的消息会常驻于此队列中。
2. 与所有消息和窗体句柄过滤器进行对照,核查此消息。如果此消息不匹配指定的过滤器,就会把此消息留在程序队列中。如果队列中在此消息的后面还有其它消息,则会转向对下一个消息的处理。
3. 如果在程序队列中没有消息了,就扫描系统队列中的事件。这个过程相当复杂,并且我们将在下面的“扫描系统队列”小节中XX。一般来讲,在系统队列首部的事件是供这个应用程序所使用的,系统会将其转化为消息,并将消息返回到这个应用程序中(它不会首先被置于应用队列中)。注意,这个扫描系统队列的过程可能导致当前活动的应用程序将控制权让给其它的应用程序。
4. 如果在系统队列中没有等待处理的事件,则核查所有与当前应用程序(任务)相关的窗体以确定更新区域。当一个窗体的一部分需要被重绘时,一个更新区域就被创建在那个窗体部分之上。这个区域将与此窗体中现存的所有更新区域相结合,并储存在内部窗体结构体中。如果GetMessage或PeekMessage在这个任务中发现某些窗体有一些未处理的更新区域,将产生一个WM_PAINT消息,并为那个窗体返回到应用程序中。WM_PAINT从不驻留在任何队列中。此时,一个应用程序将为某个窗体不断的接收WM_PATIN消息,直到更新区域由BeginPaint/EndPaint,ValidateRect,或ValidateRgn所清除。
5. 如果这个任务中没有任何窗体需要被更新,GetMessage和PeekMessage就会在这一点让出控制权,除非PeekMessage调用被设置为PM_NOYIELD属性。
6. 当让步返回时,检视在当前任务中是否有计时器到期。如果是,创建一个WM_TIMER消息并返回。它不但发生在“返回一个WM_TIMER消息到窗体”的计时器上,同样也发生在“调用一个计时器处理过程”的计时器上。如要了解更多信息,请看在微软开发者网络(MSDN)光盘(包括技术文章、Windows文章、核心和驱动程序文章)中的文章“Timers and Timing in Microsoft Windows”(译者注:如果读者能够认可我的工作,我会不遗余力地翻译这篇关于计时器的文章)。
7. 如果这个应用程序没有计时器事件服务,并且一个应用程序正在被终止,代码将尝试去缩小图形设备界面(GDI)的本地内存堆。一些应用程序,比如绘图应用程序(像Paintbrush™),为GDI分配了大量的堆内存。当应用程序终止时释放这些对象时,会使GDI本地内存堆被空闲空间填满而膨胀。为了恢复这些空闲的空间, 在GetMessage/PeekMessage处理中,LocalShrink将在这一点被调用于GDI的内存堆。这个被完成一次,(每次)一个应用程序将终止。
8. 在这一时刻,代码将分叉为两条路,一是代码任意的返回一个有效的消息,另一个是完全没有这个应用程序去处理的消息、事件,而代码最终会走哪条路决定于PeekMessage和GetMessage中的哪一个被调用。
·PeekMessage. 如果PeekMessage被调用,并设置了PM_NOYIELD标记,PeekMessage在此刻返回一个空值,这个空返回值指出已经没有要处理的消息了。如果没有设置PM_NOYIELD标记,PeekMessage就在此刻让出控制权。它不会休眠,但会单一的交给其它已准备好的应用程序一个执行的机会。(请参阅下面的“让步与休眠的不同)当让步返回,PeekMessage直接将控制权返回到应用程序,并返回一个空值,它指出这个应用程序没有要处理的消息了。
•GetMessage. 在此刻,GetMessage会让应用程序休眠、等待,直到一些事件发生需要唤醒应用程序。控制权不会返回到调用GetMessage的应用程序,直到有应用程序必须去处理的消息出现。一旦这个应用程序从被置入休眠状态中醍来,GetMessage内部的循环将回到最开始(步骤1)。
WH_GETMESSAGE钩子
在GetMessage和PeekMessage将一个消息返回到调用的应用程序之前,会做一个验证是否存在一个WH_GETMESSAGE钩子的测试。如果有一个已经被安装了,那这个钩子会被调用。如果PeekMessage没有发现可用的消息并返回一个空值时,这个钩子将不会被调用。在钩子处理过程中,你不可能得知是到底是GetMessage被调用还是PeekMessage被调用。
扫描系统队列
综上所述,在系统队列中的事件仅仅是硬件事件的记录。那些代码扫描系统队列的主要任务是,从这些事件中创建消息,并确定哪一个窗体将接收这个消息。
代码第一次在系统队列首部找到事件时,并不会马上将其删除。因为鼠标和键盘事件只是队列中的两种事件,而代码会分枝(译者注:类似于C语言中的switch语句)并单独处理每一种类型的事件。
处理系统队列中的鼠标事件
下面是处理鼠标事件的步骤。
1. 首先,将计算该事件屏幕坐标的相应窗体。此计算(调用窗体点击测试)以桌面窗体开始,从头至尾的扫描细统中的每一个窗体(包括子窗体),直到找到一个包含这个鼠标坐标点的窗体,并且这个窗体没有任何同样包含这个坐标点的子窗体。
图2. 鼠标事件的窗体点击测试
例如:如果图2中的箭头代表当前的鼠标位置,任何的鼠标行为,像单击鼠标键,将生成一个会在B窗体中产生消息的事件。
2. 如果一个窗体使用SetCapture捕获鼠标,那么“系统队列扫描”代码将通过普通的点击测试,并将所有的鼠标消息返回到捕获的窗体。例如:如果在图2 中的A窗体调用了SetCapture,则在箭头所指位置的所有鼠标行为,将产生窗体A中的消息,而不是窗体B。
3. 如果这个被处理的事件是一个“鼠标键按下”事件(任何一个鼠标键),代码会检测这个事件是否会转化为双击事件。你可以在微软开发者网络(译者注:MSDN)CD(技术文章,Ask Dr. GUI)中的“Ask Dr. GUI #5”中找到关于双击转化的描述。实质上,如果在两次鼠标键按下事件中,时间和距离的增量在允许的范围之中,该事件将会生成一个双击消息,否则它将生成一个标准的“按下”事件。所有的鼠标事件都将生成标准的鼠标消息,而双击测试只在鼠标事件指定的,包含CS_DBLCLKS类型的窗体中进行。
4. 一个消息从鼠标事件中构造出来。
5. 如果鼠标点击测试确定该事件发生在一个窗体的非客户区,如边框或标题栏,那么该构造出的消息映射到它相应的非客户区消息中。例如:一个WM_MOUSEMOVE事件会被映谢为WM_NCMOUSEMOVE消息。
6. 与所有指定的消息过滤器进行对照,核查此消息。(请参阅下面的“消息范围过滤和窗体句柄过滤”)如果该消息不匹配过滤器,则重新从头开始“系统队列扫描”代码,查看队列中的下一个消息。
7. 如果鼠标消息需要前往与当前任务不同的另一个任务的相关窗体,事件会被留在系统队列中,并且如果那个将会处理这个消息的任务在休眠之中,会被唤醒。这个新近被唤醒的任务不会在此刻立即运行,只会标记为准备运行。如果消息前往了其它任务,并且在系统队列中没有要处理的事件被发现,“系统队列扫描”会代码返回到GetMessage/PeekMessage主代码。请参阅下面的“让步与休眠的不同”以获得更多的信息。
8. 如果安装了鼠标钩子,它将在此刻被调用。如果鼠标钩子返回了一个非零值,那么该鼠标事件被忽略,并从系统队列中被删除,然后重新从头开始“系统队列扫描”代码。如果钩子返回零,则继续处理。
9. 如果消息是一个“鼠标键按下”消息,“系统队列扫描”则会在返回此消息之前,按照下面的方法激活窗体。
•它沿着父链一直向上寻找该窗体的“最终顶层父窗体”,直到相遇。
•它用SendMessage向该窗体的“最终顶层父窗体”发送一个WM_MOUSEACTIVATE消息。
•从WM_MOUSEACTVATE返回的值将在下面被测试:
a) 如果返回的值为空、MA_ACTIVATE或者MA_ACTIVATEANDEAT,ActivateWindow函数将被调用去激活那个“最终顶层父窗体”。
b) 如果返回的值是MA_NOACTIVATE或者MA_NOACTIVATEANDEAT,窗体则不被激活。
注意:MA_ACTIVATEANDEAT和MA_NOACTIVATEANDEAT会导致“鼠标键按下”事件从系统队列中被删除,而不会生成一个鼠标按下消息。
c) 最终,一个WM_SETCURSOR消息被发送到窗体,充许窗体设置指针的轮廓。
10. 如果鼠标钩子被调用,并且当前的鼠标事件从系统队列中被删除了,则检查“基于计算机训练”(CBT)的钩子。如果安装有有一个CBT钩子,将会携带HCBT_CLICKSKIPPED钩子码代调用它。
11. 按键状态表包含了三个用于跟踪鼠标按键状态的入口。这些按键被分配予虚拟键代码(VK_LBUTTON,VK_RUTTON和VC_MBUTTON),它们和GetKeyState一起始用去确事实上鼠标键是弹起还是按下。在返回鼠标消息之前,“系统队列扫描”代码会(为弹起或按下消息)设置按键状态表并且从系统队列中删除消息。如果PeekMessage被调用时携带PM_NOREMOVE,则按键状态表不会被修改。
处理系统队列中的键盘事件
1. 检查是否Ctrl键被按下和当前的事件是否ESC键按。如果是,该用户——直接窗体——会显示任务管理器窗体。一个WM_SYSCOMMAND消息将被发送到激活的窗体,并且参数wParam为SC_TASKLIT。然后键盘按下事件从系统队列中被删除,“系统队列扫描”代码又将重新从头开始。如果此激活的窗体是一个系统模块或者是一个被显示出来的“硬”系统模块消息框(比如一个“INT”24小时系统错误消息框,或一个使用MB_ICONHAND和MB_SYSTEMMODAL参数的MessageBox函数)的事件,将会被抛弃。
2. 下一步,试着去查看当前的事件是不是一个Print Screen键的按下事件。如果是,任意一个激活的窗体或整个桌面将被做为一个位图快照,保存到剪贴板中。如果Alt键被按下,一幅激活窗体的图像被复制到剪贴板中;如果没有,则是整个桌面被复制。然后Print Screen键按下事件从系统队列中被删除,“系统队列扫描”代码又将重新从头开始。如果显示了一个“硬”系统模块消息框,则此操作被忽略。
3. 下一步检测热键。使用程序管理器,用户可以定义用来运行一个应用程序的击键事件。这些击键被称为热键。如果当前的事件是一个按键事件,将会被测试是否与定义过的热键匹配。如果发现匹配,一个WM_SYSCOMMAND消息将被发送到激活的窗体,并且参数wParam为SC_HOTKEY。然后键盘按下事件从系统队列中被删除,“系统队列扫描”代码又将重新从头开始。如果此激活的窗体是一个系统模块或者是一个被显示出来的“硬”系统模块消息框,该测试被跳过。
4. 一般情况下,所有的键盘消息(如WM_KEYDOWN、WM_CHAR等等)前往具有输入焦点的窗体。如果这个具有输入焦点的窗体与另一个当前执行的任务相关联,那么该事件会被留在系统队列中,并且那个拥有“有焦点的窗体”的任务会被唤醒(如果休眠了)。“系统队列扫描”代码会像没要发现任何要处理的事件一样,返回到主GetMessage/PeekMessage代码。请参阅下面的“让步与休眠的不同”和“应用程序如何被唤醒”以获得更多的信息。
5. 如果遇到了没有任何一个窗体具有输入焦点的情形,键盘消息会直接前往当前激活的窗体,而不会被翻译成为系统键消息(如WM_SYSKEYDOW,WM_SYSCHAR,等等)。
6. 与所有指定的消息过滤器进行对照,核查此消息。(请参阅下面的“消息范围过滤和窗体句柄过滤”)如果该消息不匹配过滤器,则重新从头开始“系统队列扫描”代码,查看队列中的下一个消息。
7. 如果事件被返到了当前的任务,它将从系统队列中被删除掉,除非PeekMessage被指定为PM_NOREMOVE标记。请参阅下面的“PeekMessage的PM_NOREMOVE标记”以了解更多的关于不从队列中删除事件的信息。
8. 如果安装有键盘钩子,将在此刻被调用。如果事件从系统队列中被删除了,钩子的调用将伴随HC_ACTION属性;如果事件未被从系统队列中删除,钩子的调用将具有HC_NOREM属性。
9. 如果键盘钩子被调用,并且当前的按键事件从系统队列中被删除了,则检查现存的CBT钩子。如果安装有CBT钩子,将调用它并携带HCBT_KEYSKIPPED钩子码。
10. 最后,消息被返加到主GetMessage/PeekMessage代码。
PeekMessage与PM_NOREMOVE
默认情况下,每一个消息被返回到应用程序后,PeekMessage和 GetMessage都会把消息和事件从系统队列中删除。然而有些时候,某个应用程序可能需要扫描队列中现存的消息而并不删除它们。例如,某个应用程序在做一些处理过程,这些处理过程期望“一但发现有可用的消息,就尽快终止”。