Windows窗口 相对置顶 技术简析
本文最后更新于:2024年6月16日 晚上
前情提要
众所周知,Windows
= Window
* n
可见Window(窗口)
这一概念在Windows
操作系统中的核心地位
每个进程可以拥有多个窗口,而窗口间又可构成父子关系
如何管理图形用户界面(GUI)中这些纷繁复杂的窗口成了Windows
绕不开的难题
管中窥豹,可见一斑
本文将讨论一个与窗口层级(Z-order)相关的经典问题:置顶
置顶
这是一个很常见的需求,置顶通常意味着将窗口Z序提升到最高
例如:
mini模式网易云:
搜狗输入法:
Windows
任务栏:
他们通常不会轻易被其他窗口覆盖,除非对方也是同样置顶的窗口(此时后来居上)
Z-order
其实,置顶并不仅仅是将Z序提升那么简单
从Windows API
的视角来看,Z序可以大致分为两种:
non-topmost
:非最顶层窗口(普通窗口)topmost
:最顶层窗口,The topmost window receives the highest rank and is the first window in the Z order.
这两类层级存在阶级的隔阂,就算是普通窗口中的TOP
,也永远在TOPMOST
之下
当一个窗口获得焦点,成为活动窗口,例如用鼠标点击,那么该窗口会被设置为TOP
(普通窗口中的最高层)
当你再次点击另一个窗口,原先的窗口就会被覆盖
而我们平时说的置顶
,指的就是TOPMOST
,这样可以确保在所有普通窗口之上,不被覆盖
SetWindowPos 函数
为了改变窗口Z序,我们需要用到SetWindowPos
函数
1 |
|
该函数可以修改窗口的位置、大小和Z序
1 |
|
由于我们只想改变Z序,所以需要在最后一个flags
参数写上SWP_NOMOVE | SWP_NOSIZE
,表示忽略X Y cx cy
四个参数
第二个参数hWndInsertAfter
是我们要关注的重点,取值如下:
- HWND_BOTTOM:将窗口放置在 Z 序的底部
- HWND_NOTOPMOST:将窗口放置在所有非最顶层窗口上方(即所有最顶层窗口后面),如果窗口已经是非最顶层窗口,则此标志无效
- HWND_TOP:将窗口放置在 Z 序的顶部 (指普通窗口)
- HWND_TOPMOST:将窗口放置在所有非最顶层窗口之上(高于TOP)
注意:
HWND_NOTOPMOST
比较特殊,他的作用貌似是把TOPMOST
窗口拉下神坛,从贵族打为平民,不过瘦死的骆驼比马大,仍然是在普通窗口的TOP层级(但是可以被覆盖)由于
TOPMOST
高人一等,所以有且只有HWND_NOTOPMOST
能取消他们的特权层级,HWND_TOP
和HWND_BOTTOM
对TOPMOST
窗口都不起作用还有一点就是:
SetWindowPos
修改Z序不会对最小化的窗口生效,即便加上SWP_SHOWWINDOW
也无效
置顶 vs 焦点
刚刚说到,窗口被点击,获得焦点之后,会被设置为TOP
,置顶于普通窗口之上
但是反过来,置顶并不意味着获取焦点,即便是TOPMOST
同时,如果一个窗口没有焦点(非活动窗口),就无法被设置为HWND_TOP
或HWND_BOTTOM
官方文档中写道:
若要使用 SetWindowPos 将窗口置于顶部,拥有该窗口的进程必须具有 SetForegroundWindow 权限
那么,SetForegroundWindow
权限又是什么呢,我们可以查看官方文档:
是不是眼花缭乱,总之呢,Windows
严格限制了将窗口置顶或设为前台窗口(获取焦点)的权利
这是为了防止打扰用户的正常工作
经过实验,结论大致如下:
- 当调用进程是前台进程时,可以使用
SetForegroundWindow
将其他窗口设置为前台窗口,即便前台锁没有过期(200s) - 如果调用线程不是前台进程,大概率失败
- 当调用线程是前台进程,一般也不能使用
SetWindowPos
将窗口A设置为TOP
,除非窗口A是活动窗口,类似于BringWindowToTop
函数 TOPMOST
比较特殊,一般不需要特殊条件就可以设置(不愧是贵族)
注意:文档中写道,即使进程满足这些条件,也有可能拒绝设置前台窗口的权利 \哭
临时置顶
如果我们想要将一个窗口临时置顶,提升到用户面前,一般情况下SetForegroundWindow
和HWND_TOP
都不好使
此时我们可以采用一点trick
:
1 |
|
将窗口设置为HWND_TOPMOST
再HWND_NOTOPMOST
,完美实现了TOP
的效果,hhhh,置顶之后也能被其他窗口覆盖
这里需要注意一点,窗口的Z序只有在焦点窗口改变时才会变化
也就是说:如果焦点在A窗口上,然后对B窗口采用trick
(置顶并取消),此时B并没有获取焦点,所以无论如何点击A窗口,B还是在A之上,因为焦点没有变化,Z序也不会变化
不过只要你点击另外一个窗口,改变焦点,再点击A,B就会正常被A覆盖
检测TOPMOST状态
可以通过检测窗口的拓展样式中是否包含WS_EX_TOPMOST
1 |
|
设置前台窗口(置顶 + 焦点)
刚刚说到,使用SetForegroundWindow
设置前台窗口有很多限制,那么如何绕过这个限制,强行将某窗口(例如自身)设为前台窗口,然后吓用户一跳呢?
最简单的办法莫过于:先最小化再显示
1 |
|
但是这种方法的视觉体验不是很好(会突然消失再出现),不过可以作为保底(肯定能成)
SwitchToThisWindow
1 |
|
这个方法有时候能成 有时候不能 很玄学只能说
如果自身拥有焦点是可以easy转移出去的
SetForegroundWindow + AttachThreadInput
那么有没有更好的办法呢,那必然是有的,让我们再次把目光向SetForegroundWindow
看齐
虽然在当前进程不是前台进程时,无法使用SetForegroundWindow
但是通过使用 AttachThreadInput
函数,线程可以与另一个线程共享其输入状态 (例如键盘状态,当前焦点窗口)
例如将自身线程附加在前台线程上,假装自己也获取了焦点,就可以使用 SetForegroundWindow
辣,嘿嘿
1 |
|
大功告成
嘛,我只能说,
Windows API
之事,没有绝对逻辑,只有诶、啊、这、嘶、嘛
相对置顶
其实说了这么多,都是前置知识hhh,现在才是正片呜
说完了置顶(全局),我们来说说相对置顶
所谓相对置顶,就是仅对于某个窗口保持置顶状态,而能被其他窗口正常覆盖
例如:窗口A始终在窗口B之上,但是窗口A和其他窗口的层级关系是正常的,不存在TOPMOST
关系
有点像是:父窗口中的弹窗(子窗口)的那种感觉,或者是右键菜单
// 什么,你问我相对置顶有什么用,试想一下你对着一张参考图在blender
里建模,或是参考用户需求在coding
对于从属同一个进程的子窗口来说,这固然很容易实现
那么对于跨进程的窗口,怎么办呢?
有人要说了:用SetParent
函数将两个窗口建立父子关系不就好了
SetParent
想法是好的,但是SetParent
跨进程设置父子关系貌似并不简单,直接调用没有任何效果
即便成功了之后,也会因为消息循环同步等问题出现各种bug
参见:
c语言跨进程回调函数,使用 SetParent 跨进程设置父子窗口时的一些问题(小心卡死)…-CSDN博客
所以最终并没有采用这种方案
Dynamic Z-order
那么我们只能在保持窗口独立性的情况下,动态地去更新Z序了
这里有两种方案:
假定想要让自身窗口(me)相对置顶于目标窗口(target),设其他窗口为 others
方案一:
- 若前台窗口是
target
,则让me
临时置顶 - 若前台窗口是
others
或me
,无需任何操作,因为临时置顶后会恢复为普通层级
这种方案存在一些问题,例如target
最大化和取消最大化时,以及其他一些操作时,可能导致Z序变化,导致me
被target
覆盖
pass
方案二:
- 若前台窗口是
target
,则让me
全局置顶(TOPMOST
) - 若前台窗口是
others
,则让me
取消置顶(NOTOPMOST
),使其可以正常被others
覆盖;
注意,由于检测时机的问题,取消置顶之后me
被设置为TOP
,仍然在others
之上;
此时需要设置others
的层级为TOP
(others
是活动窗口,所以可以成功) - 若前台窗口是
me
,不执行任何操作
当然,更进一步,我们需要监视target
的最小化、显示、移动、销毁等事件来执行me
的相应操作,使其更像一个子窗口
Hook
好的,Z序调整的算法解决了,那么如何检测各种窗口的状态呢?例如,前台窗口的变化
Windows
是基于消息的操作系统,我们可以使用Hook
技术监听或拦截消息(事件)
常用的有:SetWindowsHookEx
和 SetWinEventHook
区别大致在于:
SetWindowsHookEx
更为底层,功能更强大(例如拦截或修改消息),但可能需要dll
参见我的另一篇blog:Windows Hook 技术浅析 - MrBeanC-Blog (cls.ink)SetWinEventHook
更高级,监听的是事件(不是消息),而且不能修改,但无需dll
所以为了方便起见,我们采用SetWinEventHook
SetWinEventHook
1 |
|
1 |
|
用法非常简单,一行Set + 一个回调函数即可
同时多个EventHook
可以指向同一个回调函数(内部判断event即可)
具体事件枚举可以查看事件常量 (Winuser.h) - Win32 apps | Microsoft Learn
1 |
|
我们只需要关注以下事件并做出相应操作即可:
EVENT_SYSTEM_FOREGROUND
:前台窗口改变EVENT_SYSTEM_MINIMIZESTART
&EVENT_SYSTEM_MINIMIZEEND
:最小化和恢复EVENT_OBJECT_DESTROY
:窗口关闭(此时应取消监听)EVENT_OBJECT_LOCATIONCHANGE
:窗口移动
对于窗口移动的监听要注意一点,窗口最小化也会触发移动,坐标为:(-32000 -32000)
,需要通过IsIconic
过滤
1 |
|
好的,其他的部分我相信大家都dddd了,きっと大丈夫ね
源码实现可以参考:MrBeanCpp/ImageViewer2.0: 旨在以最简洁、轻量的方式呈现图像 (github.com)
Peace
特别鸣谢
创意来源:PureRef
Ref
AttachThreadInput 函数 (winuser.h) - Win32 apps | Microsoft Learn
SetForegroundWindow的正确用法 - 子坞 - 博客园 (cnblogs.com)
SetParent 函数 (winuser.h) - Win32 apps | Microsoft Learn
c语言跨进程回调函数,使用 SetParent 跨进程设置父子窗口时的一些问题(小心卡死)…-CSDN博客
使用 SetParent 跨进程设置父子窗口时的一些问题(小心卡死)_vb中setparent作用死机-CSDN博客
SetWindowPos 使用注意事项 - 小步慢跑 - C++博客 (cppblog.com)
SetWindowPos 函数 (winuser.h) - Win32 apps | Microsoft Learn
SetForegroundWindow 函数 (winuser.h) - Win32 apps | Microsoft Learn
c# - SetWindowsHookEx 与 SetWinHookEventEx - 堆栈溢出 (stackoverflow.com)
SetWinEventHook和SetWindowsHookEx的异同_delphi setwineventhook-CSDN博客