简介:针对Qt开发者实现Windows无边框窗口的难点,压缩包内提供了一套基于QT与WINAPI混合编程的完整工程。工程围绕图标显示、窗口缩放、拖拽移动、AERO风格阴影与最大最小化等需求,给出可编译的源码实现,涉及WM_NCHITTEST、DwmExtendFrameIntoClientArea、DwmSetWindowAttribute、DwmEnableBlurBehindWindow等关键API的调用方式,并借助QtWinMigrate、QWinWidget/QWinHost处理WinAPI与Qt事件循环的衔接。压缩包共48个文件,体积约73KB,以13个h头文件、12个cpp源文件为主体,另含ui界面、qrc资源、png图标和pro/pri工程配置,构建入口与常用样式、脚本一并收纳,便于直接集成或按模块拆解学习。适合有一定Qt基础、想突破原生窗口限制的桌面应用开发者,已有1266人学习;借助源码可弄清无边框窗口的命中测试、阴影开启、模糊背景以及最大最小化时的尺寸约束等细节,可作为从界面绘制到系统交互的参考实现,尤其适合研究AERO风格和系统级消息处理的进阶者使用。
1. 无边框窗口看着简单,做起来全是对抗系统:为什么改用 Qt + WINAPI 混合方案
setWindowFlags(Qt::FramelessWindowHint)一行代码能让 Qt 窗口去掉系统边框,可实际做产品时,用户的追加要求永远是同一串:图标要正常显示、四边要能缩放、标题栏要能拖拽、窗口要有阴影、最大最小化不能出岔子、最后还要一个 AERO 毛玻璃风格。把这串要求拆开看,每一项都指向同一个事实——无边框窗口不是“去边框”,而是接管系统非客户区的全部职责。Qt 对 Windows 原生消息暴露得很少,这些能力 Qt 层拿不到,只能通过 WINAPI 直接和消息循环打交道。
这套方案适合做自定义标题栏、统一客户端外观、以及明确要 AERO 风格效果的 Windows 桌面应用。本文从窗口样式、nativeEvent 入口、WM_NCHITTEST 命中表、DWM 阴影与毛玻璃一路写到避坑清单,偏重能直接复用的代码路径和参数选择。新手能跟着把窗口跑起来,熟手可以重点看第 5 章那些“看起来正常、一测就翻车”的边界情况。
2. 搭建无边框窗口的底子:窗口样式、nativeEvent 入口与 DWM 第一个集成点
2.1 窗口标志怎么设:FramelessWindowHint 会顺手拆掉的三个系统能力
把Qt::FramelessWindowHint当成唯一开关是第一个误区。它只告诉 Qt“我不要系统边框”,但 Win32 层真正的窗口样式WS_THICKFRAME、WS_MINIMIZEBOX、WS_MAXIMIZEBOX也会被连带干掉。后果很直观:窗口确实无边框了,可同时失去了边缘缩放、任务栏右键菜单里的最大化和最小化选项,以及系统提供的最大化动画。很多人在这里卡住,以为是 Qt 不支持,其实是样式没补回来。
// 构造函数: setWindowFlags(Qt::FramelessWindowHint | Qt::WindowMinMaxButtonsHint); // show() 之前,拿到原生句柄后补回 Win32 样式: HWND hwnd = reinterpret_cast<HWND>(winId()); LONG_PTR style = GetWindowLongPtr(hwnd, GWL_STYLE); style |= WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; SetWindowLongPtr(hwnd, GWL_STYLE, style); SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);逻辑说明:winId()会强制 Qt 创建真正的 Win32 窗口,返回的HWND就是后面所有 WINAPI 调用的目标。WS_THICKFRAME是边缘缩放的前提,没有这个样式,WM_NCHITTEST返回HTLEFT、HTTOP这些命中码,系统也不会启动缩放循环。WS_MINIMIZEBOX和WS_MAXIMIZEBOX负责恢复任务栏右键菜单的系统能力。
参数说明:SWP_FRAMECHANGED很关键,它通知系统重新计算非客户区,否则改完样式窗口不会立刻按新样式刷新。SetWindowPos的其余标志SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER表示只刷新样式不移动窗口。注意这段要在窗口show()之前执行,winId()调用时机越早越好,避免窗口创建后样式闪烁一次。
2.2 重写 nativeEvent:Qt5 与 Qt6 的签名差异和一个容易漏的返回值
Qt 没有把 Win32 消息循环完全藏起来,它留了一个口子给开发者——nativeEvent()。所有发往这个窗口的 Windows 消息都会先经过这里,拦截之后返回true表示“我处理了”,返回false才会继续走 Qt 默认逻辑。这是整套方案的地基,后面所有功能都在这个函数里分支。
// Qt6 签名;Qt5 里第三个参数是 long*,把 qintptr 换掉即可 bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override { // Windows 平台下 eventType 固定是 "windows_generic_MSG" MSG *msg = static_cast<MSG *>(message); switch (msg->message) { case WM_NCCALCSIZE: // 后续章节展开 *result = 0; return true; case WM_NCHITTEST: // 拖拽缩放的核心,第 3 章展开 return false; // 交给 DefWindowProc default: break; } return QWidget::nativeEvent(eventType, message, result); }逻辑说明:message实际指向 Win32 的MSG结构,msg->message就是消息编号,msg->wParam和msg->lParam是消息参数。eventType在 Windows 上永远是"windows_generic_MSG",不需要额外判断平台,因为非 Windows 平台根本编译不到这段代码。
参数说明:第三参数qintptr *result对应 Win32 窗口过程的返回值。Win32 窗口过程约定是“你处理了某条消息,就把结果写进返回值”,比如WM_NCHITTEST要返回命中码,WM_NCCALCSIZE返回 0 表示客户区占满整个窗口。nativeEvent的返回值是给 Qt 看的,true表示拦截;result是给系统看的。这个双层语义很容易漏:拦截了但忘记写*result,系统会读到垃圾值。
2.3 集成 DWM:先让系统愿意给这个窗口画阴影和毛玻璃
AERO 风格在实现层面离不开 DWM(Desktop Window Manager)。无边框窗口刚建出来时是一块“裸”客户区,系统不会自动为它绘制边框阴影。让 DWM 接管窗口边缘渲染的常见做法,是用DwmExtendFrameIntoClientArea把玻璃边框延伸到整个客户区,边距传-1时系统会把整个窗口都当作玻璃区域,并顺带生成一个和普通窗口一致的阴影。
// 需要 #include <dwmapi.h>,链接 dwmapi.lib void enableDwmShadow(HWND hwnd) { MARGINS margins = {-1, -1, -1, -1}; DwmExtendFrameIntoClientArea(hwnd, &margins); }逻辑说明:MARGINS结构体四个字段分别表示左、右、上、下的玻璃延伸宽度,全部为-1是“整个客户区都参与 DWM 合成”的约定值。DWM 会根据窗口形状生成外阴影,这是 Windows 10 / 11 上无边框窗口阴影最稳定的来源。
参数说明:这条调用要在窗口创建完成后执行,winId()拿到HWND之后就可以调。如果程序运行在 DWM 被关闭的极端环境(远程桌面兼容模式也可能出现),DwmIsCompositionEnabled会返回FALSE,此时阴影和毛玻璃都会失效。我一般会在初始化时读一次这个开关,失效时降级成纯色背景,避免窗口变成一块黑底。
3. 拖拽与缩放:WM_NCHITTEST 的命中表就是整套交互的核心
3.1 为什么不用鼠标事件模拟拖拽:系统级命中的四个优势
很多人第一反应是重写mousePressEvent/mouseMoveEvent自己搬窗口。这个方案能跑,但四边缩放、双击最大化、跨屏幕拖拽这些边界会越写越痛苦:鼠标快速移动时 Qt 事件可能丢帧,窗口拖到一半卡一下;跨 DPI 屏幕时坐标换算容易错;双击标题栏最大化是HTCAPTION的系统行为,自己模拟又要手动判断。与其在 Qt 层拼凑,不如把命中测试交还给 Win32:窗口过程返回一个“这里是什么区域”的代码,系统自己完成移动、缩放、双击最大化整套循环。这套机制在 Win32 里已经稳定运行了三十年,流畅度和边缘吸附都不是 Qt 层模拟能比的。
3.2 命中表与热区:八方向 + HTCAPTION + HTCLIENT 的完整写法
WM_NCHITTEST是整套交互的核心消息。鼠标在窗口上移动时,系统会问窗口过程“指针落在哪”,窗口过程返回命中码,系统据此决定后续行为。无边框窗口的客户区本来应该全部返回HTCLIENT,我们要做的就是把边缘一圈和标题栏区域“伪装”成系统非客户区。
case WM_NCHITTEST: { // 最大化时不允许边缘缩放,否则热区会吃掉屏幕边缘的鼠标 if (IsZoomed(hwnd)) { *result = HTCLIENT; return true; } RECT rc; GetWindowRect(hwnd, &rc); POINT pt; pt.x = GET_X_LPARAM(lParam); // lParam 低 16 位是屏幕坐标 x pt.y = GET_Y_LPARAM(lParam); // 高 16 位是屏幕坐标 y const int b = resizeBorder; // 边缘热区宽度,单位:物理像素 bool left = pt.x < rc.left + b; bool right = pt.x > rc.right - b; bool top = pt.y < rc.top + b; bool bottom = pt.y > rc.bottom - b; if (top && left) { *result = HTTOPLEFT; return true; } else if (top && right) { *result = HTTOPRIGHT; return true; } else if (bottom && left){ *result = HTBOTTOMLEFT; return true; } else if (bottom && right){*result = HTBOTTOMRIGHT; return true; } else if (top) { *result = HTTOP; return true; } else if (bottom) { *result = HTBOTTOM; return true; } else if (left) { *result = HTLEFT; return true; } else if (right) { *result = HTRIGHT; return true; } // 标题栏区域:返回 HTCAPTION 后系统自动处理拖动和双击最大化 if (pt.y < rc.top + titleBarHeight) { *result = HTCAPTION; return true; } *result = HTCLIENT; return true; }命中码与系统行为对应关系如下表,调试时对照这个表看结果最直接。
| 命中码 | 系统行为 |
|---|---|
| HTLEFT / HTRIGHT | 水平单向缩放,光标变左右箭头 |
| HTTOP / HTBOTTOM | 垂直单向缩放,光标变上下箭头 |
| HTTOPLEFT 等四角 | 对角缩放,光标变斜向箭头 |
| HTCAPTION | 按住拖动窗口;双击触发最大化/还原 |
| HTCLIENT | 普通客户区,鼠标事件交给窗口内容 |
逻辑说明:这里return true并设置*result是合法的,WM_NCHITTEST本来就是查询消息,窗口过程返回命中码即可。lParam是屏幕坐标,不是客户区坐标,必须用GetWindowRect拿到窗口在屏幕上的位置做比较。IsZoomed判断窗口是否最大化,最大化时窗口已铺满工作区,此时若还返回边缘命中码,鼠标滑到屏幕边缘会意外触发缩放。
参数说明:resizeBorder我用物理像素 8,在 100% 缩放下约 3 毫米,手感适中。titleBarHeight是自绘标题栏高度,通常和 Qt 侧标题栏 widget 的高度保持一致。这两个值建议做成成员变量,第 5 章会讲高 DPI 下它们为什么必须跟着缩放走。
3.3 WM_GETMINMAXINFO:最大化时不让窗口盖住任务栏
无边框窗口最大化经常出现两个问题:窗口盖住任务栏,或者还原后窗口位置跑偏。根源是系统默认的最大化尺寸按“带边框窗口”计算,而无边框窗口没有边框可留,多出的尺寸就溢出到工作区外。解决办法是拦截WM_GETMINMAXINFO,告诉系统“最大化时请严格贴着工作区”。
case WM_GETMINMAXINFO: { MINMAXINFO *mmi = reinterpret_cast<MINMAXINFO *>(lParam); // 找到窗口所在显示器的工作区(排除任务栏) HMONITOR hMon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); MONITORINFO mi = {}; mi.cbSize = sizeof(mi); GetMonitorInfo(hMon, &mi); RECT workArea = mi.rcWork; mmi->ptMaxPosition.x = workArea.left; mmi->ptMaxPosition.y = workArea.top; mmi->ptMaxSize.x = workArea.right - workArea.left; mmi->ptMaxSize.y = workArea.bottom - workArea.top; return true; }逻辑说明:MINMAXINFO结构里保存着系统建议的最大化位置和尺寸。默认值是按带边框窗口计算的,多出的边框宽度和高度就是“盖住任务栏”的元凶。改成显示器工作区后,最大化刚好铺满任务栏之外的区域。MONITOR_DEFAULTTONEAREST保证多显示器环境下每个屏幕上的最大化行为都正确,不会跑到主屏之外。
参数说明:rcWork是工作区,rcMonitor是整个显示器区域,两者差的就是任务栏占用的边。如果程序是自定义全屏模式(比如视频播放器),才考虑用rcMonitor。还要注意ptMaxTrackSize不是必须改的,除非你要限制窗口最大尺寸,比如限制不超过 1920×1080。
3.4 拖拽里的双击最大化与自绘标题栏按钮的互斥
返回HTCAPTION之后,系统免费赠送了“按住拖动”和“双击最大化/还原”两个行为,不需要写一行代码。但这里有个容易打架的地方:自绘标题栏上的最小化、最大化、关闭按钮。如果整个标题栏区域都返回HTCAPTION,鼠标按下时会触发系统移动窗口,Qt 侧的mousePressEvent根本收不到事件,按钮就废了。最常见的做法是给按钮区域单独划出来。
// 在 WM_NCHITTEST 的标题栏判断之前,先判断按钮区域 if (pt.y < rc.top + titleBarHeight) { // 假设按钮区从窗口右侧 150px 开始 if (pt.x > rc.right - 150) { *result = HTCLIENT; // 按钮区域交还给 Qt 处理 return true; } *result = HTCAPTION; return true; }逻辑说明:把按钮区域返回HTCLIENT后,鼠标事件会正常进入 Qt 的按钮控件,用户点击、悬停、按下的视觉效果都由 Qt 处理。按钮的点击动作里自己调用最小化和最大化逻辑,最大化判断用IsZoomed(hwnd),最大化时调用ShowWindow(hwnd, SW_RESTORE),否则ShowWindow(hwnd, SW_MAXIMIZE)。这个方案避免了“标题栏按钮点不动”这个无边框窗口最普遍的翻车现场。
参数说明:150px是按钮区宽度,实际项目里用标题栏布局控件的几何信息换算。要注意这里的坐标是物理像素,而 Qt 控件坐标是逻辑坐标,高 DPI 下两者有倍数关系,最稳妥的办法是把这个判断放进 nativeEvent 后,通过widget->mapToGlobal(buttonRect.topRight())换算到屏幕坐标再比较。
4. 图标、阴影与 AERO 毛玻璃:三个看起来小、坑起来大的细节
4.1 图标为什么不显示:窗口图标、任务栏图标和 AppUserModelID 各管一段
无边框窗口的图标问题经常表现为:窗口左上角有图标,任务栏却是空白,或者任务栏图标是默认的白色方块。原因是窗口图标和任务栏图标走的是两套机制。窗口图标由WM_SETICON控制,任务栏图标在 Win7 以上系统里更多地和进程的AppUserModelID绑定,且两处还分ICON_SMALL和ICON_BIG两个尺寸。Qt 的setWindowIcon在无边框窗口上经常只生效一半,最可控的做法是自己在 nativeEvent 里发WM_SETICON。
// 项目里放一个 .rc 资源文件,编译进 exe // IDI_MAIN ICON "app.ico" case WM_CREATE: { HICON hIconBig = static_cast<HICON>(LoadImage( GetModuleHandle(nullptr), MAKEINTRESOURCE(IDI_MAIN), IMAGE_ICON, 32, 32, LR_DEFAULTCOLOR)); HICON hIconSmall = static_cast<HICON>(LoadImage( GetModuleHandle(nullptr), MAKEINTRESOURCE(IDI_MAIN), IMAGE_ICON, 16, 16, LR_DEFAULTCOLOR)); SendMessage(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(hIconBig)); SendMessage(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(hIconSmall)); // 不拦截,让 Qt 继续处理 WM_CREATE return false; }逻辑说明:WM_SETICON的wParam用ICON_BIG或ICON_SMALL区分用途。ICON_BIG用于 Alt+Tab 切换和大尺寸窗口图标,ICON_SMALL用于标题栏和任务栏。一个 16×16、一个 32×32,系统会自动从.ico文件里取对应尺寸。用LoadImage而不是LoadIcon是因为LoadImage能指定目标尺寸,避免系统把 32×32 强行缩成 16×16 导致图标发糊。
参数说明:MAKEINTRESOURCE(IDI_MAIN)把资源 ID 转成字符串指针,这是 Win32 资源加载的标准写法。.rc 文件需要在.pro里声明:RC_FILE = app.rc。如果不想用资源文件,Qt6 下可以从QIcon的pixmap()拿QImage再转HICON,但那段转换代码接近二十行,资源文件方案最省事。
任务栏分组图标还有最后一层:如果程序整个生命周期里从没调用过SetCurrentProcessExplicitAppUserModelID,任务栏可能把多个窗口混成一个组,图标显示成 exe 默认图标。这个 API 只影响任务栏,不影响窗口左上角图标。
// 入口处调用一次,参数格式:"公司名.产品名" SetCurrentProcessExplicitAppUserModelID(L"MyTool.FramelessDemo");4.2 阴影三条路线:CS_DROPSHADOW、DWM 延伸边距、自绘阴影
| 方案 | 实现难度 | 兼容性 | 问题 |
|---|---|---|---|
| CS_DROPSHADOW 类样式 | 最低 | WinXP 起 | 阴影生硬,和 DWM 合成冲突,半透明窗口下失效 |
| DWM 延伸边距 | 低 | Win7 起 | 推荐;毛玻璃和阴影一步到位 |
| QGraphicsDropShadowEffect | 中 | 跨平台 | 性能开销大,拖拽期间阴影明显掉帧 |
CS_DROPSHADOW 的写法是SetClassLongPtr(hwnd, GCL_STYLE, GetClassLongPtr(hwnd, GCL_STYLE) | CS_DROPSHADOW),一句话就能出阴影,但那个阴影是 GDI 时代的硬边投影,和 AERO 风格不搭。更糟糕的是,窗口一旦用了WA_TranslucentBackground,这条阴影会直接消失。我建议直接走 DWM 路线,也就是第 2 章的DwmExtendFrameIntoClientArea,它在现代 Windows 上生成的阴影和系统原生窗口阴影一模一样,还自带圆角过渡。
自绘阴影只在特殊场景使用,比如窗口形状不是矩形、需要自定义发光效果。用QGraphicsDropShadowEffect挂到顶层容器上,拖拽时每帧都要重算阴影,高 DPI 下锯齿明显,性能上得不偿失。无边框窗口的阴影,DWM 是第一选择。
4.3 AERO 风格在现代 Windows 上的落地:毛玻璃、亚克力与圆角裁剪
AERO 的核心视觉是半透明毛玻璃——背景模糊、内容透明叠加。这个效果在 Win7 时代由DwmEnableBlurBehindWindow负责,函数本身在 Win10 上还能调用,但效果已经被 DWM 新机制掩盖。现代 Windows 上做类似效果,Win11 提供了DWMWA_SYSTEMBACKDROP_TYPE属性,可以切到亚克力或云母材质。
// 兼容老系统的 AERO 毛玻璃(整个窗口变玻璃,黑色部分透明) DWM_BLURBEHIND bb = {}; bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; bb.fEnable = TRUE; bb.hRgnBlur = CreateRectRgn(-1, -1, -1, -1); // 整个窗口模糊 DwmEnableBlurBehindWindow(hwnd, &bb); // Win11 上切亚克力(更现代的 AERO 风格) DWORD backdropType = 4; // DWMSBT_TRANSIENTWINDOW 暗色亚克力 DwmSetWindowAttribute(hwnd, 38, &backdropType, sizeof(backdropType)); // 38 = DWMWA_SYSTEMBACKDROP_TYPE逻辑说明:DWM_BLURBEHIND的hRgnBlur是模糊区域,CreateRectRgn(-1,-1,-1,-1)是“全窗口”的惯用写法。启用后窗口里绘制为黑色的区域会变成透明并施加背景模糊,绘制为其他颜色的区域保持不透明。这个特性很适合做背景磨砂效果,但要注意:Qt 侧必须启用setAttribute(Qt::WA_TranslucentBackground),否则黑色区域会死黑一片。
参数说明:dwFlags里DWM_BB_ENABLE是总开关,DWM_BB_BLURREGION表示启用自定义模糊区域。Win11 的DWMWA_SYSTEMBACKDROP_TYPE用数值 38,旧 SDK 头文件里没有常量名,直接按文档写数值最稳。还有一个配套项是圆角:DWMWA_WINDOW_CORNER_PREFERENCE(属性 33),值2表示圆角,1表示直角。半透明窗口和圆角组合时,阴影会不会被裁掉是个要实测的点,第 5 章展开。
5. 无边框窗口避坑指南:最大化与阴影打架、DPI 错位、半透明裁剪
5.1 现象:最大化瞬间阴影和圆角一起溢出屏幕
把窗口最大化,发现屏幕外沿残留一圈阴影或圆角白边;还原后正常。原因在于第 2 章给 DWM 设置的“整个窗口玻璃延伸”在最大化后仍然生效,DWM 把阴影画到了工作区之外,而WM_NCCALCSIZE返回 0 又让客户区铺满窗口,两者叠加就露出了一条边缘。
解决:监听WM_SIZE,在wParam == SIZE_MAXIMIZED时把MARGINS全部改成 0,关掉 DWM 延伸;还原时恢复{-1, -1, -1, -1}。关掉之后最大化窗口没有阴影是正常的,原生窗口最大化时同样没有阴影。别忘了还原时重新调用一次DwmExtendFrameIntoClientArea,否则阴影永久丢失。
5.2 现象:高 DPI 下边缘热区小得不好点,命中位置明显偏移
在 150% 缩放的屏幕上,把resizeBorder固定成 8 意味着实际只有 5.3 个物理像素,鼠标稍微偏一点就抓不到边缘。更隐蔽的是,Qt 的逻辑坐标和 Win32 的物理坐标在 DPI 缩放下不一致,WM_NCHITTEST拿到的lParam是物理像素,而 Qt 控件返回的mapToGlobal是逻辑坐标,两者相差一个缩放系数。
解决:入口处调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),让系统按物理像素处理命中,然后热区宽度用GetSystemMetrics(SM_CXFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER)动态取,或者手动乘以缩放因子。我习惯是resizeBorder = 8 * dpiScale,dpiScale从windowHandle()->screen()->devicePixelRatio()取,这样 100% 和 200% 缩放下物理手感一致。
5.3 现象:打开 WA_TranslucentBackground 后,阴影和边框同时消失
做圆角窗口时必然开WA_TranslucentBackground,开完发现 DWM 阴影不见了。原因是透明窗口走的是分层窗口合成路径,DwmExtendFrameIntoClientArea的玻璃延伸在分层窗口上不生效,阴影自然没有。
解决:透明背景窗口的阴影改用WM_NCHITTEST + UpdateLayeredWindow的时代已经过去了,我的做法是不要在窗口根上开透明,而是开一个“无阴影的子容器”。如果一定要圆角加阴影,用一个小技巧:保留根窗口不透明,只把圆角区域外的部分用 DWM 的DWMWA_WINDOW_CORNER_PREFERENCE裁圆角,阴影交给 DWM 画。Win11 上这个组合是原生效果,最省心。
5.4 现象:最小化再从任务栏恢复,窗口内容在左上角、尺寸不对
无边框窗口最小化后恢复,有时窗口跑到屏幕左上角,或者高度比原来小一截。原因是极小化期间系统重新计算窗口尺寸,而 Qt 保存的normalGeometry()和 Win32 窗口过程里WM_GETMINMAXINFO给出的值不一致,还原时走了错误的位置。
解决:在showEvent里主动记录一次窗口的正常几何位置,WM_SIZE里判断IsZoomed,还原时把记录的值写回去。更稳妥的做法是在WM_SYSCOMMAND里拦截SC_RESTORE,先SetWindowPos校正位置再放行。这个问题的根源是两套坐标系没有同步,校正一次后基本不会再犯。
5.5 现象:缩放时 Qt 坐标与 Win32 窗口差 1 像素,resizeEvent 拿旧尺寸
WM_NCCALCSIZE返回 0 之后,客户区铺满窗口,但 Qt 的resizeEvent偶尔比实际窗口尺寸小 1 像素,或者缩放过程中内容闪烁。原因:WM_NCCALCSIZE返回 0 时系统认为“没有非客户区”,但如果这个消息的处理发生在 Qt 更新几何信息之前,两边状态就错位了。
解决:WM_NCCALCSIZE返回 0 之后,立刻调用一次SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED | SWP_NOZORDER),强制系统重算非客户区。闪烁问题多半是缺少WS_CLIPCHILDREN样式,顺手加进GWL_STYLE里。Qt 拿旧尺寸的问题,可以在同一分支里调用QWidget::resize手动同步一次尺寸,等下一次系统WM_SIZE到达时就一致了。
6. 把这些能力收敛成一个 FramelessWidget:封装骨架与五分钟验证清单
6.1 封装骨架:把 nativeEvent 集中在基类,业务子类不留 Win32 代码
所有功能都在 nativeEvent 里按switch分支走,写快了代码会散。我的习惯是做一个FramelessWidget基类,把窗口样式、DWM、命中测试、最大最小化全部封装好,对外只暴露三个配置项:边缘热区宽度、标题栏高度、可拖拽区域回调。子类只关心业务,不需要接触HWND。
class FramelessWidget : public QWidget { public: FramelessWidget() { setWindowFlags(Qt::FramelessWindowHint | Qt::WindowMinMaxButtonsHint); setAttribute(Qt::WA_TranslucentBackground, false); } void setResizeBorder(int px) { resizeBorder_ = px; } void setTitleBarHeight(int h) { titleBarHeight_ = h; } protected: bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override { MSG *msg = static_cast<MSG *>(message); switch (msg->message) { case WM_NCCALCSIZE: *result = 0; return true; case WM_GETMINMAXINFO: handleMinMax(msg, result); return true; case WM_NCHITTEST: handleHitTest(msg, result); return true; case WM_SIZE: handleSizeChange(); return false; default: break; } return QWidget::nativeEvent(eventType, message, result); } private: void handleMinMax(MSG *msg, qintptr *result); // 第 3.3 节逻辑 void handleHitTest(MSG *msg, qintptr *result); // 第 3.2 节逻辑 void handleSizeChange(); // 第 5.1 节阴影切换 int resizeBorder_ = 8; int titleBarHeight_ = 40; };逻辑说明:构造函数只负责 Qt 侧标志,Win32 样式和 DWM 初始化可以放在showEvent里统一做,避免基类构造函数里winId()调用过早。handleSizeChange里读IsZoomed,切换 DWM 边距并同步阴影状态。这个封装把 Win32 细节全部下沉,子类继承后只要设置标题栏高度和热区宽度就能得到一个完整的无边框窗口。
验证时建议按下面的清单过一遍,每一项都对应本文某个章节的坑:
| 验证项 | 操作方式 | 预期结果 |
|---|---|---|
| 拖拽 | 按住标题栏拖动跨屏 | 无卡顿、无丢帧 |
| 双击最大化 | 标题栏空白处双击 | 工作区最大化,任务栏不被遮挡 |
| 八方向缩放 | 分别拖四边和四角 | 热区响应准确,方向正确 |
| 阴影 | 窗口还原状态看外沿 | 阴影圆润,无硬边 |
| 图标 | 看标题栏和任务栏 | 均为清晰图标,Alt+Tab 正常 |
| 毛玻璃 | 窗口叠在彩色背景上 | 半透明模糊效果正常 |
| 高 DPI | 100% / 150% / 200% 切换 | 热区手感一致,无错位 |
最后说一个自己的习惯:最早的版本里我把热区宽度写成固定 8,头两天在普通屏上一切正常,换到 4K 屏调试时怎么都点不中边缘,后来才发现是 DPI 缩放把热区缩没了。从那以后,这套代码里所有 Win32 坐标一律按物理像素处理,所有可调尺寸全部乘以缩放系数,再没在这个问题上花过时间。无边框窗口本身不难,难的是每个细节都在和系统默认行为作斗争,把这些斗争点收进一个基类里,后续项目直接复用,希望帮到你。
本文还有配套的精品资源,点击获取