Windows窗口 相对置顶 技术简析

本文最后更新于:2024年6月16日 晚上

前情提要

众所周知,Windows = Window * n

可见Window(窗口)这一概念在Windows操作系统中的核心地位

每个进程可以拥有多个窗口,而窗口间又可构成父子关系

如何管理图形用户界面(GUI)中这些纷繁复杂的窗口成了Windows绕不开的难题

管中窥豹,可见一斑

本文将讨论一个与窗口层级(Z-order)相关的经典问题:置顶

置顶

这是一个很常见的需求,置顶通常意味着将窗口Z序提升到最高

例如:

mini模式网易云:

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
2
3
4
5
6
7
8
9
BOOL SetWindowPos(
[in] HWND hWnd,
[in, optional] HWND hWndInsertAfter,
[in] int X,
[in] int Y,
[in] int cx,
[in] int cy,
[in] UINT uFlags
);

该函数可以修改窗口的位置、大小和Z序

1
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);

由于我们只想改变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_TOPHWND_BOTTOMTOPMOST窗口都不起作用

  • 还有一点就是:SetWindowPos修改Z序不会对最小化的窗口生效,即便加上SWP_SHOWWINDOW也无效

置顶 vs 焦点

刚刚说到,窗口被点击,获得焦点之后,会被设置为TOP,置顶于普通窗口之上

但是反过来,置顶并不意味着获取焦点,即便是TOPMOST

同时,如果一个窗口没有焦点(非活动窗口),就无法被设置为HWND_TOPHWND_BOTTOM

官方文档中写道:

若要使用 SetWindowPos 将窗口置于顶部,拥有该窗口的进程必须具有 SetForegroundWindow 权限

那么,SetForegroundWindow权限又是什么呢,我们可以查看官方文档:

SetForegroundWindow注解

是不是眼花缭乱,总之呢,Windows严格限制了将窗口置顶或设为前台窗口(获取焦点)的权利

这是为了防止打扰用户的正常工作

经过实验,结论大致如下:

  • 当调用进程是前台进程时,可以使用SetForegroundWindow将其他窗口设置为前台窗口,即便前台锁没有过期(200s)
  • 如果调用线程不是前台进程,大概率失败
  • 当调用线程是前台进程,一般也不能使用SetWindowPos将窗口A设置为TOP,除非窗口A是活动窗口,类似于BringWindowToTop函数
  • TOPMOST比较特殊,一般不需要特殊条件就可以设置(不愧是贵族)

注意:文档中写道,即使进程满足这些条件,也有可能拒绝设置前台窗口的权利 \哭

临时置顶

如果我们想要将一个窗口临时置顶,提升到用户面前,一般情况下SetForegroundWindowHWND_TOP都不好使

此时我们可以采用一点trick

1
2
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);

将窗口设置为HWND_TOPMOSTHWND_NOTOPMOST,完美实现了TOP的效果,hhhh,置顶之后也能被其他窗口覆盖

这里需要注意一点,窗口的Z序只有在焦点窗口改变时才会变化

也就是说:如果焦点在A窗口上,然后对B窗口采用trick(置顶并取消),此时B并没有获取焦点,所以无论如何点击A窗口,B还是在A之上,因为焦点没有变化,Z序也不会变化

不过只要你点击另外一个窗口,改变焦点,再点击A,B就会正常被A覆盖

检测TOPMOST状态

可以通过检测窗口的拓展样式中是否包含WS_EX_TOPMOST

1
2
3
4
5
6
bool isTopMost(HWND hwnd)
{
if (hwnd == nullptr) return false;
LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
return style & WS_EX_TOPMOST;
}

设置前台窗口(置顶 + 焦点)

刚刚说到,使用SetForegroundWindow设置前台窗口有很多限制,那么如何绕过这个限制,强行将某窗口(例如自身)设为前台窗口,然后吓用户一跳呢?

最简单的办法莫过于:先最小化再显示
1
2
3
4
5
void miniAndShow(HWND hwnd)
{
ShowWindow(hwnd, SW_MINIMIZE); //组合操作不要异步(ShowWindowAsync) 要等前一步完成
ShowWindow(hwnd, SW_NORMAL);
}

但是这种方法的视觉体验不是很好(会突然消失再出现),不过可以作为保底(肯定能成)

SwitchToThisWindow
1
SwitchToThisWindow(hwnd, false); //将自己的focus转移很easy 获取很困难//不能用于获取focus //该函数会造成任务栏闪烁不太好

这个方法有时候能成 有时候不能 很玄学只能说

如果自身拥有焦点是可以easy转移出去的

SetForegroundWindow + AttachThreadInput

那么有没有更好的办法呢,那必然是有的,让我们再次把目光向SetForegroundWindow看齐

虽然在当前进程不是前台进程时,无法使用SetForegroundWindow

但是通过使用 AttachThreadInput 函数,线程可以与另一个线程共享其输入状态 (例如键盘状态,当前焦点窗口)

例如将自身线程附加在前台线程上,假装自己也获取了焦点,就可以使用 SetForegroundWindow辣,嘿嘿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void getInputFocus(HWND hwnd)
{
HWND foreHwnd = GetForegroundWindow(); //获取前台窗口
DWORD foreTID = GetWindowThreadProcessId(foreHwnd, NULL); //获取线程ID
DWORD threadId = GetWindowThreadProcessId(hwnd, NULL);
if (foreHwnd == hwnd)
return;
bool res = AttachThreadInput(threadId, foreTID, TRUE); //附加
if (res == false) { //如果遇到系统窗口而失败 只能最小化再激活获取焦点
miniAndShow(hwnd); //保底
} else {
//SetForegroundWindow(foreHwnd); //刷新任务栏图标状态 防止保持焦点状态 不更新 导致点击后 最小化 而非获取焦点
SetForegroundWindow(hwnd);
SetFocus(hwnd); //冗余操作
AttachThreadInput(threadId, foreTID, FALSE); //解除附加
}
}

大功告成

嘛,我只能说,Windows API之事,没有绝对逻辑,只有诶、啊、这、嘶、嘛

相对置顶

其实说了这么多,都是前置知识hhh,现在才是正片呜

说完了置顶(全局),我们来说说相对置顶

所谓相对置顶,就是仅对于某个窗口保持置顶状态,而能被其他窗口正常覆盖

例如:窗口A始终在窗口B之上,但是窗口A和其他窗口的层级关系是正常的,不存在TOPMOST关系

有点像是:父窗口中的弹窗(子窗口)的那种感觉,或者是右键菜单

// 什么,你问我相对置顶有什么用,试想一下你对着一张参考图在blender里建模,或是参考用户需求在coding

对话框相对置顶

对于从属同一个进程的子窗口来说,这固然很容易实现

那么对于跨进程的窗口,怎么办呢?

有人要说了:用SetParent函数将两个窗口建立父子关系不就好了

SetParent

想法是好的,但是SetParent跨进程设置父子关系貌似并不简单,直接调用没有任何效果

即便成功了之后,也会因为消息循环同步等问题出现各种bug

参见:

SetParent 函数修改父窗口的误区-CSDN博客

c语言跨进程回调函数,使用 SetParent 跨进程设置父子窗口时的一些问题(小心卡死)…-CSDN博客

所以最终并没有采用这种方案

Dynamic Z-order

那么我们只能在保持窗口独立性的情况下,动态地去更新Z序了

这里有两种方案:

假定想要让自身窗口(me)相对置顶于目标窗口(target),设其他窗口为 others

方案一:

  • 若前台窗口是target,则让me临时置顶
  • 若前台窗口是othersme,无需任何操作,因为临时置顶后会恢复为普通层级

这种方案存在一些问题,例如target最大化和取消最大化时,以及其他一些操作时,可能导致Z序变化,导致metarget覆盖

pass

方案二:

  • 若前台窗口是target,则让me全局置顶TOPMOST
  • 若前台窗口是others,则让me取消置顶NOTOPMOST),使其可以正常被others覆盖;
    注意,由于检测时机的问题,取消置顶之后me被设置为TOP,仍然在others之上;
    此时需要设置others的层级为TOPothers是活动窗口,所以可以成功)
  • 若前台窗口是me,不执行任何操作

当然,更进一步,我们需要监视target的最小化、显示、移动、销毁等事件来执行me的相应操作,使其更像一个子窗口

Hook

好的,Z序调整的算法解决了,那么如何检测各种窗口的状态呢?例如,前台窗口的变化

Windows是基于消息的操作系统,我们可以使用Hook技术监听或拦截消息(事件)

常用的有:SetWindowsHookExSetWinEventHook

区别大致在于:

  • SetWindowsHookEx更为底层,功能更强大(例如拦截或修改消息),但可能需要dll
    参见我的另一篇blog:Windows Hook 技术浅析 - MrBeanC-Blog (cls.ink)
  • SetWinEventHook更高级,监听的是事件(不是消息),而且不能修改,但无需dll

所以为了方便起见,我们采用SetWinEventHook

SetWinEventHook

1
2
3
4
5
6
7
8
9
HWINEVENTHOOK SetWinEventHook(
[in] DWORD eventMin,
[in] DWORD eventMax,
[in] HMODULE hmodWinEventProc,
[in] WINEVENTPROC pfnWinEventProc,
[in] DWORD idProcess,
[in] DWORD idThread,
[in] DWORD dwFlags
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void CALLBACK WinEventProc(HWINEVENTHOOK hWinEventHook, DWORD event, HWND hwnd, LONG idObject, LONG idChild, DWORD dwEventThread, DWORD dwmsEventTime) {
if (idObject != OBJID_WINDOW) //确保对象是窗口
return;

if (event == EVENT_SYSTEM_FOREGROUND && hwnd == xxx)
...
}

// WINEVENT_OUTOFCONTEXT:表示回调函数是在调用线程的上下文中调用的,而不是在生成事件的线程的上下文中。这种方式不需要DLL模块句柄(hmodWinEventProc 设置为 NULL)
hHookFocus = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, NULL, WinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT);
// START就是最小化,END就是恢复
//hHookHideShow = SetWinEventHook(EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZEEND, NULL, WinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT);
//hHookDestory = SetWinEventHook(EVENT_OBJECT_DESTROY, EVENT_OBJECT_DESTROY, NULL, WinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT);
//hHookLocation = SetWinEventHook(EVENT_OBJECT_LOCATIONCHANGE , EVENT_OBJECT_LOCATIONCHANGE , NULL, WinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT);

用法非常简单,一行Set + 一个回调函数即可

同时多个EventHook可以指向同一个回调函数(内部判断event即可)

具体事件枚举可以查看事件常量 (Winuser.h) - Win32 apps | Microsoft Learn

1
UnhookWinEvent(hHookFocus); //用完记得卸载钩子

我们只需要关注以下事件并做出相应操作即可:

  • EVENT_SYSTEM_FOREGROUND:前台窗口改变
  • EVENT_SYSTEM_MINIMIZESTART & EVENT_SYSTEM_MINIMIZEEND:最小化和恢复
  • EVENT_OBJECT_DESTROY:窗口关闭(此时应取消监听)
  • EVENT_OBJECT_LOCATIONCHANGE:窗口移动

对于窗口移动的监听要注意一点,窗口最小化也会触发移动,坐标为:(-32000 -32000),需要通过IsIconic过滤

1
2
3
4
5
6
7
8
} else if (event == EVENT_OBJECT_LOCATIONCHANGE && hwnd == targetWindow) { // 窗口位置改变
if (!IsIconic(hwnd)) { // 最小化时 位置为(-32000 -32000),需要过滤
RECT currentRect;
GetWindowRect(hwnd, &currentRect);
auto pos = QPoint(currentRect.left, currentRect.top);
...
}
}

好的,其他的部分我相信大家都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博客


Windows窗口 相对置顶 技术简析
https://mrbeancpp.github.io/2024/06/16/Windows窗口-相对置顶-技术简析/
作者
MrBeanC
发布于
2024年6月16日
许可协议