简介:面向 Qt 开发者的一份 Windows 桌面端进阶实践素材,聚焦在 Qt 框架下调用 WINAPI 打造 Win7 AERO 风格无边框窗口,解决去边框后丢失系统拖拽、缩放与阴影等原生交互的痛点,适合已具备 Qt 基础、希望提升窗口定制与系统集成能力的中级开发者参考。压缩包共 48 个文件、约 73KB,以 13 个 .h 头文件与 12 个 .cpp 源文件为主体承载窗口消息处理与业务逻辑,配合 2 个 .ui 界面文件、9 张 png 与 ico、svg 图标资源、qrc 资源索引、pro/pri 工程配置及 md 说明文档,构成可直接编译运行的完整工程骨架。内容围绕 WM_NCHITTEST、WM_NCCALCSIZE、WM_GETMINMAXINFO 等消息的自定义处理,以及 DwmExtendFrameIntoClientArea、DwmEnableBlurBehindWindow、DwmSetWindowAttribute 等 DWM 接口的调用展开,涵盖图标设置、客户区拖拽、边缘缩放、Aero 阴影与模糊、最大化最小化按钮自绘等环节,并附浏览器类窗口示例帮助理解多组件协作。目前已有 1267 人学习,可作为无边框窗口与系统特效整合的排错与结构参考。
1. 一个 FramelessWindowHint 把 Snap 和阴影全弄丢了
Qt 里做 AERO 风格无边框窗口,十个人里九个先写setWindowFlags(Qt::FramelessWindowHint)。跑起来边框确实没了,但窗口往屏幕顶端一拖,snap 分屏毫无反应;最大化之后内容边缘糊出屏幕两三个像素,底部还压着任务栏;阴影消失,最小化动画从顺滑变成硬切。问题不出在 Qt,而出在FramelessWindowHint顺手把WS_THICKFRAME这个原生样式一起删了——窗口阴影、Aero Snap、最大化还原动画、高 DPI 缩放,全都挂在 Windows 的非客户区上。后来我换了个思路:不删原生边框,用 WinAPI 把非客户区裁成零,再自己接管命中测试。下面按 Qt 加 WinAPI 的落地顺序,把这条链路拆成能直接抄的样子。
2. 保住 WS_THICKFRAME:无边框窗口的原生样式底盘
2.1 三条实现路线的取舍
能搜到的无边框方案大致三类,差别不在代码量,而在你把窗口外壳交给了谁。
| 做法 | DWM 阴影 | Aero Snap | 最大化动画 | 高 DPI 缩放 | 自绘工作量 |
|---|---|---|---|---|---|
FramelessWindowHint+ 自绘 | 无 | 失效 | 硬切 | 需要自己算 | 大 |
WS_POPUP全自绘 | 无 | 失效 | 硬切 | 需要自己算 | 最大 |
保留WS_THICKFRAME+WM_NCCALCSIZE裁剪 | 有 | 正常 | 原生 | 系统处理 | 小 |
第一类上手最快,代码最少,代价是所有依赖原生非客户区的能力全部归零。第二类连系统菜单和 Alt+Space 都要自己模拟。第三类的核心只有一句话:样式留着,视觉上藏起来。Qt 只管画客户区内容,窗口的外壳仍然是原生的,阴影、Snap、动画、DPI 换算系统全包。所以这条路线的关键动作不是"加",而是"别删"。
2.2 把被 Qt 清掉的样式补回去
// framelesswindow.cpp #include "framelesswindow.h" #include <windows.h> #include <dwmapi.h> void FramelessWindow::ensureNativeFrameStyle() { // 保险:Designer 或父类可能塞过 FramelessWindowHint,先清掉 setWindowFlag(Qt::FramelessWindowHint, false); // winId() 会强制创建原生窗口,必须拿到 HWND 之后再改样式 HWND hwnd = reinterpret_cast<HWND>(winId()); LONG_PTR style = GetWindowLongPtr(hwnd, GWL_STYLE); // 这五个样式就是 AERO 行为的开关 style |= WS_THICKFRAME | WS_CAPTION | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX; style &= ~WS_POPUP; SetWindowLongPtr(hwnd, GWL_STYLE, style); // 不通知系统重算非客户区,改动要等下一次 resize 才生效 SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); }WS_THICKFRAME决定窗口能不能被系统当成"可缩放窗口",DWM 的阴影和 Snap 判定都建立在它之上;WS_CAPTION让系统愿意走标题栏的双击最大化和右键系统菜单;WS_SYSMENU是 Alt+Space 菜单的前提。SWP_FRAMECHANGED这一句很容易漏,漏了的表现是"改了样式但界面没反应,拖动一下窗口又突然正常了",属于玄学级翻车。
还有个稳一点的习惯:在showEvent里再补一次。Qt 在首次 show 时可能根据 flags 重新下发一次窗口样式,构造函数里改完被覆盖掉的情况我遇到过不止一次。
void FramelessWindow::showEvent(QShowEvent *e) { QWidget::showEvent(e); if (!m_styleFixed) { // 只补一次,避免每次 show 都触发重排 ensureNativeFrameStyle(); enableDwmShadow(); m_styleFixed = true; } }2.3 WM_NCCALCSIZE 返回 0 之后发生了什么
非客户区的尺寸由系统在WM_NCCALCSIZE里算,你返回什么,系统就用什么。返回 0 表示"客户区等于整个窗口矩形",边框宽度为零。
bool FramelessWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result) { // Qt6 是 qintptr*,Qt5.15 及更早是 long*,迁移时这里会报错 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 == TRUE) { handleNcCalcSize(msg); *result = 0; return true; } break; case WM_NCHITTEST: { const LRESULT hit = hitTest(msg); if (hit != HTNOWHERE) { // HTNOWHERE 表示不接管,交回 Qt *result = hit; return true; } break; } case WM_NCACTIVATE: *result = TRUE; // 保留激活态切换,但不要系统画边框 return true; case WM_NCPAINT: *result = 0; // 明确告诉系统别画非客户区 return true; case WM_GETMINMAXINFO: handleMinMaxInfo(msg); return true; } return QWidget::nativeEvent(eventType, message, result); }wParam == TRUE这个分支只处理"窗口尺寸正在变化"的场景,FALSE时lParam是RECT*而不是NCCALCSIZE_PARAMS*,直接强转会读越界。这个细节在不少示例代码里都是错的,表现是偶发的窗口尺寸跳变。
void FramelessWindow::handleNcCalcSize(MSG *msg) { auto *params = reinterpret_cast<NCCALCSIZE_PARAMS *>(msg->lParam); HWND hwnd = reinterpret_cast<HWND>(winId()); if (IsZoomed(hwnd)) { // 最大化时客户区直接对齐显示器工作区,避免盖住任务栏 HMONITOR hMon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); MONITORINFO mi{}; mi.cbSize = sizeof(MONITORINFO); if (GetMonitorInfo(hMon, &mi)) params->rgrc[0] = mi.rcWork; } // 非最大化:rgrc[0] 就是系统给的窗口矩形,不再内缩, // 于是非客户区宽度为 0,视觉上等同无边框 }rgrc[0]是建议的新窗口矩形,rgrc[1]是变化前的客户区,rgrc[2]是变化前的窗口矩形,三个都是屏幕坐标。返回 0 时系统只认rgrc[0],其余忽略。
3. DWM 阴影与 Snap:把原生行为接回来
3.1 DwmExtendFrameIntoClientArea 与阴影生效的四个条件
阴影不是你画出来的,是 DWM 根据窗口样式自己补的。它生效要同时满足四个条件:有WS_THICKFRAME或WS_CAPTION;没有WS_EX_LAYERED;没有WS_EX_TOOLWINDOW;DWM 组合已开启。前一条ensureNativeFrameStyle已经解决,后两条是最容易踩的。
void FramelessWindow::enableDwmShadow() { HWND hwnd = reinterpret_cast<HWND>(winId()); // 四边各扩 1px 的帧,足以激活 DWM 的窗口阴影与过渡动画 MARGINS margins = {1, 1, 1, 1}; DwmExtendFrameIntoClientArea(hwnd, &margins); // Win11 上顺手开圆角,视觉更接近原生;Win10 会返回失败,忽略即可 enum { DWMWA_WINDOW_CORNER_PREFERENCE_ = 33 }; int pref = 2; // DWMWCP_ROUND DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE_, &pref, sizeof(pref)); }MARGINS传全 1 是常用取值,它只在窗口外侧撑出 1px 的扩展帧,不会污染客户区渲染。传{-1,-1,-1,-1}是另一套语义,会把整个客户区变成玻璃层,配合不透明背景会出黑块,别混用。
WS_EX_LAYERED这条要重点说:Qt 的Qt::WA_TranslucentBackground会在 Windows 上引入分层窗口,一旦引入,DWM 阴影直接消失,而且不会报任何错。想要圆角或半透明标题栏,正确做法是用 DWM 的圆角属性和自绘,而不是开WA_TranslucentBackground。
3.2 Snap 链路上最容易断的一环:HTCAPTION 什么时候不生效
Aero Snap 的判定链是:系统收到鼠标按下 → 查WM_NCHITTEST返回码 → 如果是HTCAPTION就进入系统拖拽循环 → 拖到屏幕边缘触发分屏预览。链路上任何一环返回了别的值,Snap 就静默失效。
所以标题栏必须返回HTCAPTION,而不是自己用mousePressEvent加move()模拟拖动。自绘拖动看起来能用,但系统不知道你在拖窗口,分屏预览、拖到顶部最大化、拖回还原这些全都不会发生。
分流的判断顺序是固定的:先判边框,再判标题栏,最后交回 Qt。
LRESULT FramelessWindow::hitTest(MSG *msg) { HWND hwnd = reinterpret_cast<HWND>(winId()); if (IsZoomed(hwnd)) return HTNOWHERE; // 最大化状态没有可拖拽的边框 POINT pt{ GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam) }; ScreenToClient(hwnd, &pt); // 物理像素,相对客户区左上角 RECT rc{}; GetClientRect(hwnd, &rc); // 同样是物理像素 // 用带 DPI 的度量值,150% 缩放下硬编码 8 会偏小 const UINT dpi = GetDpiForWindow(hwnd); const int frameX = GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi); const int frameY = GetSystemMetricsForDpi(SM_CYSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi); const int gripX = qMax(6, frameX); const int gripY = qMax(6, frameY); const bool left = pt.x < gripX; const bool right = pt.x >= rc.right - gripX; const bool top = pt.y < gripY; const bool bottom = pt.y >= rc.bottom - gripY; if (top && left) return HTTOPLEFT; if (top && right) return HTTOPRIGHT; if (bottom && left) return HTBOTTOMLEFT; if (bottom && right) return HTBOTTOMRIGHT; if (left) return HTLEFT; if (right) return HTRIGHT; if (top) return HTTOP; if (bottom) return HTBOTTOM; // 标题栏区域:空白处交给系统当标题栏,按钮自己吃掉点击 const QPoint globalPos(GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)); if (m_titleBar && m_titleBar->geometry().contains(mapFromGlobal(globalPos))) { QWidget *w = m_titleBar->childAt(m_titleBar->mapFromGlobal(globalPos)); while (w && w != m_titleBar && !qobject_cast<QAbstractButton *>(w)) w = w->parentWidget(); if (!qobject_cast<QAbstractButton *>(w)) return HTCAPTION; } return HTNOWHERE; }返回HTNOWHERE而不是HTCLIENT是有意为之:HTCLIENT会让系统认为你已经处理完了,某些场景下会打断 Qt 自己的鼠标事件分发。交回QWidget::nativeEvent让 Qt 走默认路径更安全。
GetSystemMetricsForDpi需要 Windows 10 1607 以上。如果要兼容更老的系统,用GetProcAddress从 user32 动态取函数指针,取不到就退回GetSystemMetrics。
3.3 最大化与还原:工作区补偿和可还原尺寸
最大化的补偿放在WM_NCCALCSIZE里做,比放在WM_GETMINMAXINFO里干净,因为前者只影响客户区,不会污染系统记录的可还原矩形。
void FramelessWindow::handleMinMaxInfo(MSG *msg) { auto *mmi = reinterpret_cast<MINMAXINFO *>(msg->lParam); // Qt 的 minimumSize 不会自动同步到原生层,这里补一条 const qreal dpr = devicePixelRatioF(); mmi->ptMinTrackSize.x = qRound(minimumWidth() * dpr); mmi->ptMinTrackSize.y = qRound(minimumHeight() * dpr); }只补最小尺寸,ptMaxPosition和ptMaxSize保持系统默认。理由很直接:一旦你在这里改了最大化尺寸,系统记录的可还原矩形会跟着变,从最大化还原回来窗口就比原来小一圈。这个坑我在两个项目上各踩过一次,排查花的时间比写字还多。
多显示器场景要额外注意:不同显示器工作区不同,任务栏可能在上、在左、在右侧。rcWork减去rcMonitor才能得到正确的偏移,而handleNcCalcSize里直接赋值rgrc[0] = mi.rcWork是屏幕绝对坐标,天然支持任意位置的显示器。副屏 DPI 不一致时,WM_DPICHANGED会导致窗口重排,Qt 自己会处理尺寸,但自绘标题栏的高度如果写死逻辑像素,切换屏幕后会出现 1~2px 的错位,用QFontMetrics或者布局算高度,别硬编码。
4. 自绘标题栏:图标、双击最大化与缩放热区
4.1 自绘标题栏的命中分流
标题栏本身是个普通QWidget,里面放图标、标题QLabel、最小化/最大化/关闭三个按钮。难点在于同一块区域要同时满足两个诉求:空白处归系统(拖拽、双击、右键菜单),按钮归 Qt。
上面hitTest里的向上查找循环就是干这个的:
QWidget *w = m_titleBar->childAt(m_titleBar->mapFromGlobal(globalPos)); while (w && w != m_titleBar && !qobject_cast<QAbstractButton *>(w)) w = w->parentWidget(); // 按钮里的 label 也要能被识别成按钮用parentWidget()逐层上溯,是为了兼容"按钮里放了一个QLabel做图标"这种布局。只判一层childAt的话,点在图标上就会命中HTCAPTION,按钮按下去没反应,用户会以为程序卡了。
标题栏高度建议用QFontMetrics::height() + 上下内边距算,而不是写死 32px。Windows 在 125% 缩放下的默认标题栏是 33~34 逻辑像素,写死会被看出来。
4.2 HTCAPTION 白送的三件事
返回HTCAPTION之后,有三件事不用自己写:
- 双击标题栏最大化/还原,系统在拖拽循环里自己判。
- 右键标题栏弹出系统菜单(还原、移动、大小、最小化、最大化、关闭),前提是
WS_SYSMENU在。 - Alt+Space 调出同一个菜单,键盘用户靠它操作。
如果自己去重写mouseDoubleClickEvent调showMaximized(),在"拖动过程中松手"和"双击第二次落在别的位置"这些边界上很容易和系统拖拽循环打架,表现出来就是双击一下窗口先最大化再还原,闪一下。既然样式保住了,这活儿就让系统干。
拖拽到屏幕边缘触发 Snap 之后,窗口会进入IsZoomed状态,此时hitTest必须返回HTNOWHERE,否则边框热区还生效,鼠标在顶部会变成上下箭头,看起来很奇怪。
4.3 图标、系统菜单与任务栏同步
setWindowIcon只影响 Qt 内部,任务栏和 Alt+Tab 用的是原生WM_SETICON。
void FramelessWindow::applyWindowIcon(const QIcon &icon) { setWindowIcon(icon); // 自绘标题栏用的 HWND hwnd = reinterpret_cast<HWND>(winId()); HICON hBig = icon.pixmap(GetSystemMetrics(SM_CXICON)).toWinHICON(); HICON hSmall = icon.pixmap(GetSystemMetrics(SM_CXSMICON)).toWinHICON(); SendMessage(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(hBig)); SendMessage(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(hSmall)); // HICON 由 Qt 的 toWinHICON 创建,窗口销毁前要 DestroyIcon,否则泄漏 m_bigIcon = hBig; m_smallIcon = hSmall; }大图标给小图标分别对应 Alt+Tab 缩略图和窗口左上角。自绘标题栏里用QIcon::pixmap(dpr * size)取高清图,别直接QPixmap("icon.png"),高 DPI 下会糊。
另外,任务栏右键菜单里的"关闭窗口"走的是WM_SYSCOMMAND / SC_CLOSE,Qt 默认会转发成closeEvent,只要WS_SYSMENU还在,这条链路是通的。有一类翻车是"点任务栏关闭没反应",多半是某处把WS_SYSMENU清掉了。
5. 排查清单:五类无边框窗口的常见翻车
5.1 阴影消失,四周一圈硬边
现象:窗口没有 DWM 阴影,边缘是直角硬切,和系统窗口一比很明显。
原因:Qt::FramelessWindowHint删了WS_THICKFRAME;或者WA_TranslucentBackground引入了WS_EX_LAYERED;或者窗口被设成了WS_EX_TOOLWINDOW(有些"隐藏任务栏图标"的写法会这么干)。
解决:先跑一遍样式检查,确认WS_THICKFRAME在、WS_EX_LAYERED和WS_EX_TOOLWINDOW都不在,再调DwmExtendFrameIntoClientArea。顺序反了没用。
5.2 最大化后盖住任务栏,或者四周多出黑边
现象:最大化之后底部压着任务栏,必须 Alt+Tab 才能切回去;或者反过来,窗口四周留了一条几像素的缝。
原因:WM_NCCALCSIZE里没有按工作区修正rgrc[0],系统给的是含边框的窗口矩形。另一种情况是既在NCCALCSIZE里缩了一次,又在WM_GETMINMAXINFO里缩了一次,缩了两遍。
解决:只在一处做补偿,推荐NCCALCSIZE里IsZoomed时直接赋rcWork,GETMINMAXINFO里只改ptMinTrackSize。
5.3 拖到屏幕边缘不触发 Snap
现象:拖到顶部能最大化,拖到左右边缘没有分屏预览。或者反过来。
原因:hitTest在最大化状态下没有返回HTNOWHERE,系统认为还在缩放状态;或者标题栏的HTCAPTION判断被更宽的边框grip抢走了——标题栏本身高度只有 33px,如果gripY设成了 12px,靠近顶部的那条带子会返回HTTOP。
解决:grip取系统度量值,别超过 10px;最大化状态直接HTNOWHERE。可以临时把grip改成 0 验证一下是不是这个原因。
5.4 高 DPI 下缩放热区偏位
现象:125% 或 150% 缩放下,鼠标要往窗口内侧移一段才变成缩放光标,边缘那几像素点不动。
原因:lParam是物理像素,GetClientRect也是物理像素,但mapFromGlobal返回的是逻辑像素。两套坐标系混在一起用,等于把 1.5 倍缩放又乘了一遍。
解决:边框判断全走物理像素,标题栏判断全走逻辑像素,中间不要交叉。上文的hitTest就是按这个原则切的,pt和rc一套,globalPos和m_titleBar->geometry()一套。
5.5 从最大化还原后窗口尺寸不对
现象:最大化再还原,窗口比原来小一圈,或者位置向右下偏了几像素。
原因:运行时改了GWL_STYLE
本文还有配套的精品资源,点击获取