Qt无边框窗口:保留WS_THICKFRAME实现Aero Snap与DWM阴影
简介面向 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_THICKFRAMEWM_NCCALCSIZE裁剪有正常原生系统处理小第一类上手最快代码最少代价是所有依赖原生非客户区的能力全部归零。第二类连系统菜单和 AltSpace 都要自己模拟。第三类的核心只有一句话样式留着视觉上藏起来。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_castHWND(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是 AltSpace 菜单的前提。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_castMSG *(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_castNCCALCSIZE_PARAMS *(msg-lParam); HWND hwnd reinterpret_castHWND(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_TOOLWINDOWDWM 组合已开启。前一条ensureNativeFrameStyle已经解决后两条是最容易踩的。void FramelessWindow::enableDwmShadow() { HWND hwnd reinterpret_castHWND(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_castHWND(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_castQAbstractButton *(w)) w w-parentWidget(); if (!qobject_castQAbstractButton *(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_castMINMAXINFO *(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_castQAbstractButton *(w)) w w-parentWidget(); // 按钮里的 label 也要能被识别成按钮用parentWidget()逐层上溯是为了兼容按钮里放了一个QLabel做图标这种布局。只判一层childAt的话点在图标上就会命中HTCAPTION按钮按下去没反应用户会以为程序卡了。标题栏高度建议用QFontMetrics::height() 上下内边距算而不是写死 32px。Windows 在 125% 缩放下的默认标题栏是 33~34 逻辑像素写死会被看出来。4.2 HTCAPTION 白送的三件事返回HTCAPTION之后有三件事不用自己写双击标题栏最大化/还原系统在拖拽循环里自己判。右键标题栏弹出系统菜单还原、移动、大小、最小化、最大化、关闭前提是WS_SYSMENU在。AltSpace 调出同一个菜单键盘用户靠它操作。如果自己去重写mouseDoubleClickEvent调showMaximized()在拖动过程中松手和双击第二次落在别的位置这些边界上很容易和系统拖拽循环打架表现出来就是双击一下窗口先最大化再还原闪一下。既然样式保住了这活儿就让系统干。拖拽到屏幕边缘触发 Snap 之后窗口会进入IsZoomed状态此时hitTest必须返回HTNOWHERE否则边框热区还生效鼠标在顶部会变成上下箭头看起来很奇怪。4.3 图标、系统菜单与任务栏同步setWindowIcon只影响 Qt 内部任务栏和 AltTab 用的是原生WM_SETICON。void FramelessWindow::applyWindowIcon(const QIcon icon) { setWindowIcon(icon); // 自绘标题栏用的 HWND hwnd reinterpret_castHWND(winId()); HICON hBig icon.pixmap(GetSystemMetrics(SM_CXICON)).toWinHICON(); HICON hSmall icon.pixmap(GetSystemMetrics(SM_CXSMICON)).toWinHICON(); SendMessage(hwnd, WM_SETICON, ICON_BIG, reinterpret_castLPARAM(hBig)); SendMessage(hwnd, WM_SETICON, ICON_SMALL, reinterpret_castLPARAM(hSmall)); // HICON 由 Qt 的 toWinHICON 创建窗口销毁前要 DestroyIcon否则泄漏 m_bigIcon hBig; m_smallIcon hSmall; }大图标给小图标分别对应 AltTab 缩略图和窗口左上角。自绘标题栏里用QIcon::pixmap(dpr * size)取高清图别直接QPixmap(icon.png)高 DPI 下会糊。另外任务栏右键菜单里的关闭窗口走的是WM_SYSCOMMAND / SC_CLOSEQt 默认会转发成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 最大化后盖住任务栏或者四周多出黑边现象最大化之后底部压着任务栏必须 AltTab 才能切回去或者反过来窗口四周留了一条几像素的缝。原因WM_NCCALCSIZE里没有按工作区修正rgrc[0]系统给的是含边框的窗口矩形。另一种情况是既在NCCALCSIZE里缩了一次又在WM_GETMINMAXINFO里缩了一次缩了两遍。解决只在一处做补偿推荐NCCALCSIZE里IsZoomed时直接赋rcWorkGETMINMAXINFO里只改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本文还有配套的精品资源点击获取