第27章 硬件输入模型和局部输入状态
这章说的是按键和鼠标事件是如何进入系统并发送给适当的窗口过程的。微软设计输入模型的一个主要目标就是为了保证一个线程的动作不要对其他线程的动作产生不好的影响。
27.1 原始输入线程
当系统初始化时,要建立一个特殊的线程,即原始输入线程(raw input thread,R I T)。此外,系统还要建立一个队列,称为系统硬件输入队列(System hardware input queue, SHIQ)。R I T和S H I Q构成系统硬件输入模型的核心。
R I T怎么才能知道要向哪一个线程的虚拟输入队列里增加硬件输入消息?对鼠标消息,R I T只是确定是哪一个窗口在鼠标光标之下。利用这个窗口, R I T调用G e t Wi n d o w T h r e a dP r o c e s s I d来确定是哪个线程建立了这个窗口。返回的线程 I D指出哪一个线程应该得到这个鼠标消息。
对按键硬件事件的处理稍有不同。在任何给定的时刻,只有一个线程同 R I T“连接”。这个线程称为前景线程(foreground thread),因为它建立了正在与用户交互的窗口,并且这个线程的窗口相对于其他线程所建立的窗口来说处在画面中的前景。
当一个用户在系统上登录时, Windows Explorer进程让一个线程建立相应的任务栏(t a s k b a r)和桌面。这个线程连接到R I T。如果你又要产生C a l c u l a t o r,那么就又有一个线程来建立一个窗口,并且这个线程变成连接到 R I T的线程。注意现在Windows Explorer的线程不再与R I T连接,因为在一个时刻只能有一个线程同R I T连接。当一个按键消息进入S H I Q时,R I T就被唤醒,将这个事件转换成适当的按键消息,并将消息放入与R I T连接的线程的虚拟输入队列。
不同的线程是如何连接到R I T的呢?我们已经说过,当产生一个进程时,这个进程的线程可以建立一个窗口。这个窗口处于前景,其建立窗口的线程同 R I T相连接。另外,R I T还要负责处理特殊的键组合,如A l t + Ta b、A l t + E s c和C t r l + A l t + D e l等。因为R I T在内部处理这些键组合,就可以保证用户总能用键盘激活窗口。应用程序不能够拦截和废弃这些键组合。当用户按动了某个特殊的键组合时,R I T激活选定的窗口,并将窗口的线程连接到R I T。Wi n d o w s也提供激活窗口的功能,使窗口的线程连接到R I T。
从上面的图中可以看到如何保护线程,避免相互影响的。如果 R I T向窗口 B 1 或窗口 B 2 发送一个消息,消息到达线程 B的虚拟输入队列。在处理消息时,线程 B在与五个内核对象同步时可能会进入死循环或死锁。如果发生这种情况,线程仍然同 R I T连接在一起,并且可能有更多的消息要增加到线程的虚拟输入队列中。
这种情况下,用户会发现窗口 B 1和B 2都没有反应,可能想切换到窗口 A 1 。为了做这种切换,用户按A l t + Ta b。因为是R I T处理A l t + Ta b按键组合,所以用户总能切换到另外的窗口,不会有什么问题。在选定窗口 A 1 之后,线程 A就连接到R I T。这个时候,用户就可以对窗口 A 1 进入输入,尽管线程及其窗口都没有响应。
27.2 局部输入状态
• 哪一个窗口有鼠标捕获。
• 鼠标光标的形状。
• 鼠标光标的可见性。
由于每个线程都有自己的输入状态变量,每个线程都有不同的焦点窗口、鼠标捕获窗口等概念。从一个线程的角度来看,或者它的某个窗口拥有键盘焦点,或者系统中没有窗口拥有键盘焦点;或者它的某个窗口拥有鼠标捕获,或者系统中没有窗口拥有鼠标捕获,等等。
27.2.1 键盘输入与焦点
R I T使用户的键盘输入流向一个线程的虚拟输入队列,而不是流向一个窗口。R I T将键盘事件放入线程的虚拟输入队列时不用涉及具体的窗口。当这个线程调用G e t M e s s a g e时,键盘事件从队列中移出并分派给当前有输入焦点的窗口。(由该线程所建立)。下图说明了这个处理过程。
线程1当前正在从R I T接收输入,用窗口A、窗口B或窗口C的句柄作参数调用S e t F o c u s会引起焦点改变。失去焦点的窗口除去它的焦点矩形或隐藏它的插入符号,获得焦点的窗口画出焦点矩形或显示它的插入符号。
假定线程1仍然从R I T接收输入,并用窗口 E的句柄作为参数调用 S e t F o c u s。这种情况下,系统阻止执行这个调用,因为想要设置焦点的窗口不使用当前连接 R I T的虚拟输入队列。在线的线程不一样,那么,对于建立失去焦点窗口的线程,要更新它的局部输入状态变量,说明它没有窗口拥有焦点。这时调用G e t F o c u s将返回N U L L,这会使线程知道当前没有窗口拥有焦点。
函数S e t A c t i v e Wi n d o w激活系统中一个最高层( t o p - l e v e l)的窗口,并对这个窗口设定焦点:
HWND WINAPI SetActiveWindow(__in HWND hWnd);
同S e t F o c u s函数一样,如果调用线程没有创建作为函数参数的窗口,则这个函数什么也不做。
与S e t A c t i v e Wi n d o w相配合的函数是G e t A c t i v e Wi n d o w函数:
HANDLE GetActiveWindow();
这个函数的功能同G e t F o c u s函数差不多,不同之处是它返回由调用线程的局部输入状态变量所指出的活动窗口的句柄。当活动窗口属于另外的线程时, G e t A c t i v e Wi n d o w返回N U L L。
其他可以改变窗口的 Z序(Z - o r d e r)、活动状态和焦点状态的函数还包括 B r i n g Wi n d o w ToTo p和S e t Wi n d o w P o s:
BOOL WINAPI BringWindowToTop(__in HWND hWnd);
BOOL WINAPI SetWindowPos(
_In_ HWND hWnd,
_In_opt_ HWND hWndInsertAfter,
_In_ int X,
_In_ int Y,
_In_ int cx,
_In_ int cy,
_In_ UINT uFlags);
这两个函数功能相同(实际上, B r i n g Wi n d o w To To p函数在内部调用 S e t Wi n d o w P o s,以H W N D _ TO P作为第二个参数)。如果调用这两个函数的线程没有连接到 R I T,则函数什么也不做。如果调用这些函数的线程同 R I T相连接,系统就会激活相应的窗口。注意即使调用线程不是建立这个窗口的线程,也同样有效。这意味着,这个窗口变成活动的,并且建立这个窗口的线程被连接到R I T。这也引起调用线程和新连接到R I T的线程的局部输入状态变量被更新。
有时候,一个线程想让它的窗口成为屏幕的前景。例如,有可能会利用 Microsoft Qutlook
安排一个会议。在会议开始前的半小时, O u t l o o k弹出一个对话框提醒用户会议将要开始。如果Q u t l o o k的线程没有连接到R I T,这个对话框就会藏在其他窗口的后面,有可能看不见它。
为了制止这种现象,微软对 S e t F o r e g r o u n d Wi n d o w函数增加了更多的智能。特别规定,仅当调用一个函数的线程已经连接到 R I T或者当前与R I T相连接的线程在一定的时间内(这个时间量由S y s t e m P a r a m e t e r s I n f o函数和S P I _ S E T F O R E G R O U N D _ L O C K T I M E O U T值来控制)没有收到任何输入,这个函数才有效。另外,如果有一个菜单是活动的,这个函数就失效。
如果不允许S e t F o r e g r o u n d Wi n d o w将窗口移到前景,它会闪烁该窗口的标题栏和任务条上该窗口的按钮。用户看到任务条按钮闪烁,就知道该窗口想得到用户的注意。用户应该手工激活这个窗口,看一看要报告什么信息。还可以用S y s t e m P a r a m e t e r s I n f o函数和S P I _ S E T F O R E G R O U N D -F L A S H C O U N T值来控制闪烁。
由于这些新的内容,系统又提供了另外一些函数。如果调用 A l l o w S e t F o r e g r o u n d Wi n d o w的线程能够成功调用S e t F o r e g r o u n d Wi n d o w,第一个函数(见下面所列)可使指定进程的一个线程成功调 用S e t F o r e g r o u n d Wi n d o w。为了使任何进程都可以在你的线程的窗口上弹出一个窗口,指定A S F W _ A N Y (定义为-1 )作为d w P r o c e s s I d参数:
此外,线程可以锁定 S e t F o r e g r o u n d Wi n d o w函数,使它总是失效的。方法是调用 L o c kS e t F o r e g r o u n d Wi n d o w。
BOOL LockSetForegroundWindow(UINT uLockCode);
对u L o c k C o d e参数可以指定L S F W _ L O C K或者L S F W _ U N L O C K。当一个菜单被激活时,系统在内部调用这个函数,这样一个试图跳到前景的窗口就不能关闭这个菜单。 Wi n d o w sE x p l o r e r在显示S t a r t菜单时,需要明确地调用这些函数,因为 S t a r t菜单不是一个内置菜单。当用户按了A l t键或者将一个窗口拉到前景时,系统自动解锁 S e t F o r e g r o u n d Wi n d o w函数。这可以防止一个程序一直对S e t F o r e g r o u n d Wi n d o w函数封锁。
关于键盘管理和局部输入状态,其他的内容是同步键状态数组。每个线程的局部输入状态变量都包含一个同步键状态数组,但所有的线程要共享一个同步键状态数组。这些数组反映了在任何给定时刻键盘所有键的状态。利用 G e t A s y n c K e y S t a t e函数可以确定用户当前是否按下了键盘上的一个键:
SHORT WINAPI GetAsyncKeyState(__in int vKey);
参数n Vi r t K e y指出要检查键的虚键代码。结果的高位指出该键当前是否被按下(是为 1,否为0)。笔者在处理一个消息时,常用这个函数来检查用户是否释放了鼠标主按钮。为函数参数赋一个虚键值V K _ L B U T TO N,并等待返回值的高位成为0。注意,如果调用函数的线程不是建立的窗口上,鼠标光标就可见了。
鼠标光标管理的另一个方面是使用C l i p C u r s o r函数将鼠标光标剪贴到一个矩形区域。
BOOL ClipCursor(CONST RECT *prc);
这个函数使鼠标被限制在一个由p r c参数指定的矩形区域内。当一个程序调用 C l i p C u r s o r函数时,系统该做些什么呢?允许剪贴鼠标光标可能会对其他线程产生不利影响,而不允许剪贴鼠标光标又会影响调用线程。微软实现了一种折衷的方案。当一个线程调用这个函数时,系统将鼠标光标剪贴到指定的矩形区域。但是,如果同步激活事件发生(当用户点击了其他程序的窗口,调用了S e t F o r e g r o u n d Wi n d o w,或按了C t r l + E s c组合键),系统停止剪贴鼠标光标的移动,允许鼠标光标在整个屏幕上自由移动。
27.3 将虚拟输入队列同局部输入状态挂接在一起
从上面的讨论我们可以看出这个输入模型是强壮的,因为每个线程都有自己的局部输入状态环境,并且在必要时每个线程可以连接到 R I T或从R I T断开。有时候,我们可能想让两个或多个线程共享一组局部输入状态变量及一个虚拟输入队列。
可以利用A t t a c h T h r e a d I n p u t函数来强制两个或多个线程共享同一个虚拟输入队列和一组局部输入状态变量:
BOOL WINAPI AttachThreadInput(
__in DWORD idAttach,
__in DWORD idAttachT);
函数的第一个参数i d A t t a c h,是一个线程的I D,该线程所包含的虚拟输入队列(以及局部输入状态变量)是你不想再使用的。第二个参数 i d A t t a c h To,是另一个线程的I D,这个线程所包含的虚拟输入队列(和局部输入状态变量)是想让两个线程共享的。第三个参数 f A t t a c h,当想让共享发生时,被设置为 T R U E,当想把两个线程的虚拟输入队列和局部输入状态变量分开时,设定为FA L S E。可以通过多次调用A t t a c h T h r e a d I n p u t函数让多个线程共享同一个虚拟输入队列和局部输入状态变量。
我们再考虑前面的例子,假定线程 A调用A t t a c h T h r e a d I n p u t,传递线程 A的I D作为第一个参数,线程B的I D作为第二个参数,T R U E作为最后一个参数:
线程 A的虚拟输入队列将不再接收输入事件,除非再一次调用A t t a c h T h r e a d I n p u t并传递FA L S E作为最后一个参数,将两个线程的输入队列分开。
当将两个线程的输入都挂接在一起时,就使线程共享单一的虚拟输入队列和同一组局部输入状态变量。但线程仍然使用自己的登记消息队列、发送消息队列、应答消息队列和唤醒标志(见第2 6章的讨论)。
如果让所有的线程都共享一个输入队列,就会严重削弱系统的强壮性。如果某一个线程接收一个按键消息并且挂起,其他的线程就不能接收任何输入了。所以应该尽量避免使用A t t a c h T h r e a d I n p u t函数。在某些情况下,系统隐式地将两个线程挂接在一起。第一种情况是当一个线程安装一个日志记录挂钩(journal record hook)或日志播放挂钩(journal playback hook)的时候。当挂钩被卸载时,系统自动恢复所有线程,这样线程就可以使用挂钩安装前它们所使用的相同输入队列。
当一个线程安装一个日志记录挂钩时,它是让系统将用户输入的所有硬件事件都通知它。这个线程通常将这些信息保存或记录在一个文件上。因用户的输入必须按进入的次序来记录,所以系统中每个线程要共享一个虚拟输入队列,使所有的输入处理同步。
还有一些情况,系统会代替你隐式地调用 A t t a c h T h r e a d I n p u t。假定你的程序建立了两个线程。第一个线程建立了一个对话框。在这个对话框建立之后,第二个线程调用 G r e a t Wi n d o w,使用W S _ C H I L D风格,并向这个子窗口的双亲传递对话框的句柄。系统用子窗口的线程调用A t t a c h T h r e a d I n p u t,让子窗口的线程使用对话框线程所使用的输入队列。这样就使对话框的所有子窗口之间对输入强制同步。