简介:围绕 Qt 与 Windows API 混合编程的完整示例工程,面向已掌握 Qt 基础、希望在桌面上复刻 Win7 AERO 视觉与交互效果的开发者。内容聚焦无边框窗口的一整套实现:借助 WM_NCHITTEST、WM_NCLBUTTONDOWN 等消息恢复拖拽与边缘缩放,用 DwmExtendFrameIntoClientArea、DwmEnableBlurBehindWindow 打开模糊背景与投影,并通过 WM_GETMINMAXINFO、WM_NCCALCSIZE 处理最大化、最小化与客户区计算,标题栏图标则结合 QIcon 与 WM_SETICON 完成。压缩包共 48 个文件、约 73KB,以 13 个 C++ 头文件和 12 个 cpp 源文件为主体,配合 9 张 png 按钮贴图、ui 界面文件、qrc 资源与 css 样式表、pro 与 pri 工程配置,另附 md 说明与 ico 图标,可直接编译调试。已有 1267 人学习,适合对照源码理解消息钩子与 DWM 接口的配合方式,并快速移植到自研客户端。
1. 无边框窗口的“最后一公里”:为什么我只推荐 WINAPI 子类化这条路线
用 Qt 做过无边框窗口的开发者,大概都经历过同一个阶段:先setWindowFlags(Qt::FramelessWindowHint)加自绘标题栏,跑起来挺像回事;等到要拖拽缩放、要 AERO 风格的 Snap 分屏、要窗口阴影的时候,才发现每加一个功能都在给前面的坑填土。这个项目走的是另一条路线——QT 把窗口过程交给 WINAPI 子类化,用WM_NCCALCSIZE吃掉非客户区、用DwmExtendFrameIntoClientArea借系统画阴影,图标、缩放、拖拽、最大最小化、Snap 全部由系统接管。适合已经能跑起来 Qt 界面、但被无边框细节折磨过的桌面开发者,也适合想搞清楚 Windows 窗口管理器到底怎么工作的新手。下面按“路线选型 → 核心机制 → 命中测试 → 避坑 → 进阶”的顺序拆。
2. 三条实现路线的能力边界:Qt 自绘、原生子类化与混合方案
Qt 做无边框窗口不是只有一条路,不同路线决定了后面能不能拿到系统级行为。选型错了,代码写得再漂亮也只是在填补系统能力的缺口。
2.1 纯 Qt 的 FramelessWindowHint 到底缺了什么
Qt::FramelessWindowHint的本质,是把窗口的WS_CAPTION和WS_THICKFRAME两个 Win32 样式去掉。去掉WS_CAPTION之后标题栏没了,去掉WS_THICKFRAME之后系统不再把窗口当成可缩放对象。视觉上确实“无边框”了,但窗口管理器对它的态度也变了:没有系统阴影、没有边缘缩放光标、没有 Aero Snap 分屏预览、没有双击标题栏最大化,Alt+Space菜单里也少了最小化/最大化项。这些不是 Qt 的 bug,是 Windows 的窗口管理逻辑本来就挂在这两个样式上。
不少人的第一反应是在mousePressEvent、mouseMoveEvent里自己算位移实现拖拽,再在eventFilter里抓边缘做缩放。能跑,但副作用一串:拖拽时没有 Snap 预览、缩放时窗口闪烁、最大化后要用定时器修正位置。我见过一版这样的实现,代码里光“修正最大化位置”的QTimer::singleShot就有三处,每次加新功能都得重新测一遍窗口边缘。更麻烦的是,这类修补逻辑之间会互相影响,今天修好了拖拽,明天缩放时的命中区又偏了,属于典型的玄学调试。
所以这条路线只适合“只要外观无边框”的场景,比如嵌入式全屏界面、固定尺寸的启动器窗口。一旦用户有“把窗口拖到屏幕左边自动占半屏”这种系统级预期,纯 Qt 就不够用了。
2.2 WINAPI 子类化的最小可行结构
这个项目的思路反过来:保留系统需要的窗口样式,但用WM_NCCALCSIZE把非客户区压成 0,让系统“以为”有标题栏、“看到”的却是没有。Qt 提供nativeEvent虚函数,可以在不替换窗口过程的前提下拦截原始MSG,这是最轻量的接入点——不用SetWindowLongPtr去换WndProc,也不会和 Qt 自己的消息循环打架。结构大概是这样:
// framelesswindow.h #pragma once #include <QWidget> #include <windows.h> class FramelessWindow : public QWidget { Q_OBJECT public: explicit FramelessWindow(QWidget *parent = nullptr); protected: bool nativeEvent(const QByteArray &eventType, void *message, long *result) override; void showEvent(QShowEvent *e) override; private: void enableNativeFrame(); // 把系统样式加回来 void extendDwmShadow(); // 让 DWM 画阴影 bool isOverCaptionButton(const POINT &clientPt) const; bool m_nativeReady = false; };类本身还是QWidget,没有换成QMainWindow或QWindow。原因很实际:nativeEvent在QWidget上就能拿到windows_generic_MSG,而QMainWindow的中央部件、停靠区会引入额外的布局层级,定位按钮坐标时反而更麻烦。成员函数拆成“启用原生框架”和“扩展 DWM 阴影”两件事,是为了在showEvent里按顺序调用——顺序错了阴影会丢,后面 5.2 会展开。
// framelesswindow.cpp #include "framelesswindow.h" #include <dwmapi.h> #pragma comment(lib, "dwmapi.lib") FramelessWindow::FramelessWindow(QWidget *parent) : QWidget(parent) { setWindowFlags(Qt::Window | Qt::FramelessWindowHint); // 不要开 WA_TranslucentBackground,后面会解释原因 setAttribute(Qt::WA_TranslucentBackground, false); resize(1080, 720); setWindowTitle(QStringLiteral("Frameless Demo")); setWindowIcon(QIcon(QStringLiteral(":/app.ico"))); }WA_TranslucentBackground必须关掉。开了之后 DWM 会把整个客户区当成透明层,DwmExtendFrameIntoClientArea的阴影区域会跟自绘背景打架,典型表现是窗口边缘出现一圈黑边,或者阴影只在右上角出现。这是麒麟臂级别的问题——代码看起来完全正确,但画面就是不对。
2.3 什么时候不该上原生
原生子类化不是没有代价。第一,代码绑死 Windows,如果产品要出 macOS 或 Linux 版,这段逻辑必须用#ifdef Q_OS_WIN隔开,并且要保证在非 Windows 平台编译时不会引用windows.h。第二,nativeEvent里任何一次返回值错误都可能让窗口行为异常,排查成本比纯 Qt 高——Qt 的FramelessWindowHint出问题最多是拖不动,原生WM_NCHITTEST返回错值可能让窗口完全无法交互。第三,加WS_THICKFRAME会改变窗口在系统里的“身份”,某些窗口管理工具或录屏软件对带这个样式的窗口处理方式不同,用户可能会反馈“窗口在某个工具里录出来少一圈”。
判断标准很简单:如果只需要一个无边框外观、不需要系统级 Snap 和阴影,纯 Qt 方案足够;如果需要 AERO 风格、需要用户拖到屏幕边缘就能分屏,那原生这条路绕不开。这个项目选的是后者,目标场景是桌面端工具软件,用户对窗口手感和系统一致性有预期,窗口本身就是产品体验的一部分。
3. 把非客户区压成 0:WM_NCCALCSIZE 与 DWM 阴影的配合
这一章是整个项目的核心。WM_NCCALCSIZE决定客户区从哪里算起,DwmExtendFrameIntoClientArea决定阴影怎么画,两者配合不好,要么没阴影,要么有阴影但内容被裁。按顺序拆。
3.1 窗口样式的“偷梁换柱”
因为构造时用了Qt::FramelessWindowHint,窗口的WS_CAPTION和WS_THICKFRAME已经被摘掉了。但 Snap 和缩放又依赖这两个样式,所以要在窗口显示之后把它们悄悄加回来——视觉上仍然看不到标题栏,因为下一步WM_NCCALCSIZE会把非客户区压成 0。
void FramelessWindow::enableNativeFrame() { HWND hwnd = reinterpret_cast<HWND>(winId()); LONG_PTR style = GetWindowLongPtr(hwnd, GWL_STYLE); // WS_THICKFRAME 是可缩放、Snap 的前提 // WS_CAPTION 是双击最大化、系统菜单的前提 // WS_SYSMENU 是 Alt+Space 菜单的前提 style |= WS_THICKFRAME | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; SetWindowLongPtr(hwnd, GWL_STYLE, style); // 通知系统框架样式已变,触发一次 WM_NCCALCSIZE SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); }SWP_FRAMECHANGED是关键。如果不加这个标志,SetWindowLongPtr改完样式系统不会重新计算非客户区,WM_NCCALCSIZE不会被触发,窗口会呈现一种“样式加了但好像没生效”的诡异状态。很多人在这一步翻车,以为是WM_NCCALCSIZE写错了,其实是少了一个标志位。
3.2 WM_NCCALCSIZE 的返回值怎么算
WM_NCCALCSIZE在wParam == TRUE时,lParam指向一个NCCALCSIZE_PARAMS,里面rgrc[0]是系统建议的客户区,rgrc[1]是窗口矩形(屏幕坐标),rgrc[2]是客户区矩形。默认处理会从窗口矩形里扣掉边框和标题栏,得到客户区。我们要做的正好相反——把客户区设成整个窗口矩形。
bool FramelessWindow::nativeEvent(const QByteArray &eventType, void *message, long *result) { if (eventType != "windows_generic_MSG") return QWidget::nativeEvent(eventType, message, result); MSG *msg = static_cast<MSG *>(message); switch (msg->message) { case WM_NCCALCSIZE: { if (msg->wParam == FALSE) return false; // 窗口创建初期的计算交给默认处理 auto *p = reinterpret_cast<NCCALCSIZE_PARAMS *>(msg->lParam); // 客户区吞掉整个窗口矩形:标题栏和边框“消失” p->rgrc[0] = p->rgrc[1]; *result = 0; return true; } } return QWidget::nativeEvent(eventType, message, result); }这段逻辑只有一句有效代码,但有一个隐含取舍:p->rgrc[0] = p->rgrc[1]意味着最大化时客户区也吃满整个屏幕矩形。后面 4.3 会用WM_GETMINMAXINFO把最大化尺寸限制到工作区,否则窗口会盖住任务栏。另一种常见写法是在这里判断IsZoomed(hwnd),最大化时各边内缩SM_CXSIZEFRAME + SM_CXPADDEDBORDER,好处是最大化时还能保留一圈阴影,代价是客户区少几个像素。两种方案都能用,这个项目选了前一种,理由是内容区尺寸更干净。
提示:
SM_CXPADDEDBORDER在 Windows 10 之后才有,老系统上直接取SM_CXSIZEFRAME即可,用GetSystemMetrics前要判断系统版本,或者直接走工作区方案绕开。
3.3 DwmExtendFrameIntoClientArea 的正确参数
阴影不是 Qt 画的,是 DWM 画的。DwmExtendFrameIntoClientArea的作用是把窗口框架的玻璃区域往客户区里延伸,延伸出去的那部分会由 DWM 接管绘制,阴影也就跟着系统走了。
void FramelessWindow::extendDwmShadow() { HWND hwnd = reinterpret_cast<HWND>(winId()); // 四边各延伸 1px,让 DWM 认为这个窗口有原生边框 // 只要这个值大于 0,DWM 就会给窗口画系统阴影 MARGINS margins = { 1, 1, 1, 1 }; DwmExtendFrameIntoClientArea(hwnd, &margins); // 可选:跟随系统深浅色,让边框和阴影颜色协调 BOOL dark = TRUE; DwmSetWindowAttribute(hwnd, 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */, &dark, sizeof(dark)); // 重新触发一次非客户区计算 SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); }参数说明:MARGINS的四个值分别是左、右、上、下延伸的像素数。常见值是{1,1,1,1},也就是所有边都延伸 1 像素。不要用{-1,-1,-1,-1},那是 Vista 时代“整个客户区都是玻璃”的写法,在 Win10 之后会和自绘背景冲突,表现为背景变成一片模糊。另外DWMWA_USE_IMMERSIVE_DARK_MODE的属性号在不同 Windows 版本里是 19 或 20,写死 20 在较老的 Win10 上会返回错误但不影响功能,可以忽略返回值。
showEvent里把两个调用串起来:
void FramelessWindow::showEvent(QShowEvent *e) { QWidget::showEvent(e); if (m_nativeReady) return; m_nativeReady = true; enableNativeFrame(); // 先加样式 extendDwmShadow(); // 再让 DWM 接管阴影 }顺序不能反。如果先DwmExtendFrameIntoClientArea再加WS_THICKFRAME,DWM 拿到的窗口样式还是无边框的,阴影不会出现;反过来先加样式再扩展,DWM 会在下一次框架重算时正确识别。
3.4 用 Spy++ 验证样式和 DWM 属性
改完样式后怎么确认生效?两个办法。第一,用 Visual Studio 自带的 Spy++ 抓窗口,看Style里有没有WS_THICKFRAME、WS_CAPTION、WS_SYSMENU,这三个都在说明样式加成功了。第二,在WM_NCCALCSIZE里下断点或打日志,看wParam是不是TRUE、p->rgrc[0]和p->rgrc[1]是否相等。如果WM_NCCALCSIZE根本没进来,八成是SWP_FRAMECHANGED没写,回到 3.1 检查。
还有一个容易忽略的点:DwmExtendFrameIntoClientArea的返回值是HRESULT,在窗口还没创建完成时调用会返回E_HANDLE。所以必须在showEvent之后调用,不能在构造函数里调,否则阴影静默失败,没有任何报错。
4. 命中测试与窗口行为:让 Snap、缩放、拖拽、双击最大化都回来
样式和阴影搞定后,窗口看起来是对了,但鼠标放上去很可能发现“能看不能动”——因为系统不知道鼠标落在窗口的哪个逻辑区域。WM_NCHITTEST就是回答这个问题的。
4.1 WM_NCHITTEST 的边界判定
WM_NCHITTEST的lParam是屏幕坐标,必须先用ScreenToClient转成客户区坐标,再跟窗口矩形比较。这一步写错,表现是缩放区域偏移或者完全点不中。
case WM_NCHITTEST: { POINT pt = { GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam) }; ScreenToClient(msg->hwnd, &pt); RECT rc; GetClientRect(msg->hwnd, &rc); const int border = m_resizeBorder; // 8px,随 DPI 缩放 const int titleH = m_titleBarHeight; // 自绘标题栏高度 const bool left = pt.x < border; const bool right = pt.x >= rc.right - border; const bool top = pt.y < border; const bool bottom = pt.y >= rc.bottom - border; if (top && left) { *result = HTTOPLEFT; return true; } if (top && right) { *result = HTTOPRIGHT; return true; } if (bottom && left) { *result = HTBOTTOMLEFT; return true; } if (bottom && right) { *result = HTBOTTOMRIGHT; return true; } if (left) { *result = HTLEFT; return true; } if (right) { *result = HTRIGHT; return true; } if (top) { *result = HTTOP; return true; } if (bottom) { *result = HTBOTTOM; return true; } // 标题栏区域、且不在按钮上 → 拖动窗口 if (pt.y < titleH && !isOverCaptionButton(pt)) { *result = HTCAPTION; return true; } return false; }返回HTLEFT、HTRIGHT这些值之后,系统会自动处理缩放,不需要自己写mouseMoveEvent。返回HTCAPTION之后,系统会自动处理拖拽、双击最大化、拖到屏幕边缘触发 Snap 分屏。这就是原生方案的价值——你只需要告诉系统“这块区域是什么”,具体行为它自己会做,而且做得跟资源管理器一致。
参数border不能写死 8。在 150% DPI 缩放下,8 个物理像素只有 5 个逻辑像素宽,用户会觉得“边缘特别难点中”。正确做法是用GetDpiForWindow拿到当前 DPI,再按dpi / 96.0换算,或者直接用 Qt 的devicePixelRatioF()。
4.2 标题栏按钮的避让
返回HTCAPTION的区域如果包含最大化、最小化、关闭按钮,点击按钮时会变成拖拽窗口。血泪经验:这个问题在开发阶段很难发现,因为按钮的clicked信号通常还能触发,但窗口会跟着鼠标动一下,用户感知就是“按钮手感很怪”。
避让逻辑要把按钮的窗口坐标转成客户区坐标再判断:
bool FramelessWindow::isOverCaptionButton(const POINT &clientPt) const { const QPoint p(clientPt.x, clientPt.y); const auto hit = [this, &p](QWidget *w) { if (!w || !w->isVisible()) return false; const QPoint topLeft = w->mapTo(this, QPoint(0, 0)); return QRect(topLeft, w->size()).contains(p); }; return hit(m_btnMin) || hit(m_btnMax) || hit(m_btnClose); }mapTo(this, QPoint(0,0))拿到的是按钮左上角在窗口客户区里的位置,因为无边框窗口没有非客户区偏移,所以和WM_NCHITTEST里的客户区坐标是同一套。如果用了QMainWindow或者多层嵌套布局,要把this换成实际承载按钮的父控件,否则坐标会差一个偏移量。
注意:按钮的
size()是逻辑像素,WM_NCHITTEST拿到的POINT也是逻辑像素(Qt 已经处理过 DPI),两者能直接比较。不要在这里再乘一次devicePixelRatio,会放大一倍。
4.3 WM_GETMINMAXINFO 与任务栏
WM_NCCALCSIZE把客户区设成整个窗口矩形之后,最大化时窗口会占满整个屏幕,把任务栏盖住。修复方式是拦截WM_GETMINMAXINFO,把最大化尺寸限制到显示器工作区。
case WM_GETMINMAXINFO: { auto *mmi = reinterpret_cast<MINMAXINFO *>(msg->lParam); HMONITOR mon = MonitorFromWindow(msg->hwnd, MONITOR_DEFAULTTONEAREST); MONITORINFO mi{ sizeof(MONITORINFO) }; if (GetMonitorInfo(mon, &mi)) { // 最大化位置:相对显示器左上角的偏移 mmi->ptMaxPosition.x = mi.rcWork.left - mi.rcMonitor.left; mmi->ptMaxPosition.y = mi.rcWork.top - mi.rcMonitor.top; // 最大化尺寸:工作区大小,扣掉任务栏 mmi->ptMaxSize.x = mi.rcWork.right - mi.rcWork.left; mmi->ptMaxSize.y = mi.rcWork.bottom - mi.rcWork.top; // 最小尺寸,防止拖到 0×0 mmi->ptMinTrackSize.x = 640; mmi->ptMinTrackSize.y = 400; } *result = 0; return true; }rcWork是显示器去掉任务栏之后的工作区,rcMonitor是整个显示器。用ptMaxPosition的差值而不是直接赋值rcWork,是因为ptMaxPosition是相对坐标,多显示器环境下如果写成绝对坐标,窗口会跑到主屏上去。这个细节在单屏开发时完全看不出来,一插外接显示器就翻车。
4.4 系统菜单、图标与最小最大化动画
无边框窗口右键标题栏默认没有系统菜单,因为标题栏本身不存在。但用户对“右键标题栏弹出最小化/最大化/关闭”有肌肉记忆,所以要补一个WM_NCRBUTTONUP:
case WM_NCRBUTTONUP: { if (msg->wParam == HTCAPTION) { HMENU sysMenu = GetSystemMenu(msg->hwnd, FALSE <p> <a href="https://download.csdn.net/download/qq_31215157/9922310" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>