Qt Windows 无边框窗口:用 WINAPI 保留 AERO Snap 与 DWM 阴影
发布时间:2026/11/4 7:22:25 锦皓数字建站

简介面向 Qt/C 桌面开发者的 Windows 无边框窗口实践工程围绕 Qt 与 WINAPI 混合编程解决去除系统边框后仍要保留图标、拖拽、缩放、阴影、最大化/最小化及 AERO 视觉效果的问题适合已掌握 Qt 基础、希望深入 Windows 窗口消息与 DWM 接口的中级开发者参考与复用。压缩包共 48 个文件约 73KB以 13 个 h 头文件和 12 个 cpp 实现文件为核心辅以 2 个 ui 界面文件、9 个 png 图标资源、qrc/ico/svg 资源及 pro、pri 构建脚本另有 css 样式、ini 配置和 md 说明体积轻量但工程结构完整便于查阅。已有 1271 人学习。读者可从中获得可运行的窗口框架源码观察无边框窗口的消息处理、非客户区渲染、阴影与模糊效果、自定义标题栏按钮和窗口缩放拖拽的实现组织与系统集成方式。配套资源与说明还可帮助理解各模块职责用于二次开发、实际项目或对照排查兼容性问题。1. 一个贴边不吸附的窗口QT 无边框窗口为什么守不住 AERO 特性去年给一个内部图像处理 Demo 换皮肤第一版直接用了setWindowFlag(Qt::FramelessWindowHint)。视觉上是干净了但用户上手三分钟就报了一堆问题窗口拖到屏幕左边缘不会自动半屏AERO snap 失效最大化以后任务栏被盖住从任务栏点击还原时动画变成生硬闪现任务栏图标还分裂成两个。翻车翻得很彻底。问题不在 Qt 本身在于FramelessWindowHint在 Windows 上会把窗口样式改成WS_POPUP系统从这一刻起就不再把你的窗口当成标准顶层窗口了。阴影、贴边、最大最小化动画、系统菜单这些能力全部挂在窗口样式和 NC 区域非客户区的判定上。后来我把实现整个翻过来用 WINAPI 保留WS_OVERLAPPEDWINDOW再通过拦截WM_NCCALCSIZE把视觉上的标题栏抹掉AERO 风格、snap、阴影一次性全部回来。这篇讲的就是这条路线怎么落地。适合正在用 Qt 写 Windows 桌面端、想要无边框外观但不想丢掉系统原生行为的人也适合已经在用无边框窗口、被上面那些问题反复咬过的同行。2. 窗口样式层用 WINAPI 换掉 FramelessWindowHint 的选型与落地无边框窗口这件事在 Windows 上有三条常见路线实现量差别不大但拿到手的能力差得很远。先把代价看清楚再选比写完再返工省事得多。2.1 三条路线的能力对照路线阴影来源AERO snap最大最小化动画高 DPIWin11 圆角Qt::FramelessWindowHint需自绘失效部分失效正常丢失透明背景 QGraphicsDropShadowEffectQt 自绘失效失效一般丢失保留WS_OVERLAPPEDWINDOWWM_NCCALCSIZE系统 DWM生效生效正常保留第三条路线的核心思路是不要告诉 Qt 你要一个无边框窗口而是告诉 Windows 我要一个标准窗口只不过我准备把非客户区的视觉全部裁掉。窗口在操作系统眼里仍然是带 caption、带 size border 的正常窗口DWM 自然按正常窗口给它画阴影、响应贴边。Qt 这层只是不再绘制那根标题栏。第二条路线经常出现在各种博客里因为它写起来最像自己掌控一切。但WA_TranslucentBackground会让窗口带上WS_EX_LAYERED分层窗口在 Windows 10 上会直接退出 snap 的判定DWM 也不再给它投阴影。你在客户区画出来的那圈阴影本质是 Qt 的QGraphicsDropShadowEffect在离屏渲染窗口一多、一缩放帧率就往下掉。这两条我都不推荐拿来做主方案。2.2 改 GWL_STYLE 的先后顺序第一步是把窗口样式拉回标准窗口。这段代码必须在 native 窗口句柄有效之后执行一般放在showEvent里做一次用成员变量保证只执行一次。#include windows.h #include windowsx.h #include dwmapi.h void FramelessWindow::initNativeStyle() { HWND hwnd reinterpret_castHWND(winId()); // 确保 native 句柄已创建 LONG_PTR style GetWindowLongPtrW(hwnd, GWL_STYLE); // 清掉 Qt 可能塞进来的 popup 位 style ~(WS_POPUP | WS_CAPTION | WS_THICKFRAME); // 保留 caption / size border 等系统识别位 style | WS_OVERLAPPEDWINDOW; SetWindowLongPtrW(hwnd, GWL_STYLE, style); LONG_PTR exStyle GetWindowLongPtrW(hwnd, GWL_EXSTYLE); exStyle ~(WS_EX_LAYERED | WS_EX_TOOLWINDOW); // 这两类会让窗口脱离正常分组 SetWindowLongPtrW(hwnd, GWL_EXSTYLE, exStyle); // 关键通知系统样式已改否则窗口不会重新计算 frame SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); }这段里几个参数值得单独说清楚。WS_CAPTION是让系统认为这个窗口有标题栏DWM 的阴影判定依赖它WS_THICKFRAME是 snap 和缩放判定的依赖项同时它给窗口保留了一圈不可见的边框WS_SYSMENU、WS_MINIMIZEBOX、WS_MAXIMIZEBOX决定任务栏右键菜单里能不能看到最大化最小化这些项被剥掉以后用户右键任务栏点不了还原。WS_EX_LAYERED必须清掉这是透明背景方案留下的后遗症不清理的话你会在后面对着一堆为什么 snap 不生效怀疑人生。WS_EX_TOOLWINDOW会让窗口从任务栏消失也不会出现在 AltTab 里非必要不要加。最后那个SetWindowPos是整个调用里最容易忘的一步。SetWindowLongPtrW只更新内部记录不会让窗口重新计算非客户区不加这一句你会发现改了样式跟没改一样。注意调用时机要在winId()有效之后。构造函数里调用winId()会强制创建 native 窗口但此时 Qt 的一些属性还没定下来建议统一挪到showEvent里用m_nativeInited标记守卫。2.3 WM_NCCALCSIZE 抹掉标题栏返回 0 还是保留 1px样式保住以后窗口会真的显示一根系统标题栏。要把它抹掉就拦WM_NCCALCSIZE。这个消息在窗口尺寸变化时被调用参数wParam TRUE时lParam指向NCCALCSIZE_PARAMS其中rgrc[0]是系统建议的客户区矩形。返回值决定客户区从哪儿开始返回 0 表示客户区等于整个窗口矩形也就是说非客户区宽度为零。bool FramelessWindow::nativeEvent(const QByteArray eventType, void *message, qintptr *result) { 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 FALSE) { break; // wParam 为 FALSE 时是 RECT* 形态交给默认处理 } if (IsZoomed(msg-hwnd)) { // 最大化时走系统默认保证客户区不会溢出到屏幕外 *result DefWindowProcW(msg-hwnd, msg-message, msg-wParam, msg-lParam); } else { // 非最大化客户区铺满整个窗口视觉上无标题栏无边框 *result 0; } return true; } default: break; } return QWidget::nativeEvent(eventType, message, result); }这段逻辑里最关键的是那个IsZoomed分支。很多人第一次写会无条件return 0结果最大化时内容左侧和顶部被切掉 8 个像素因为最大化后的窗口矩形其实比屏幕工作区大一圈多出来的就是边框厚度。把最大化的情况交还给DefWindowProc系统会把rgrc[0]收进屏幕内客户区就不会越界。代价是最大化状态下四周会少 8 像素对于绝大多数布局肉眼基本看不出来但如果你的界面有贴边控件会看到一点内缩。想彻底解决也不是不行见 3.3 的另一种做法。还有一种变体是保留 1 像素 NC 区域让 DWM 阴影贴上客户区边缘NCCALCSIZE_PARAMS *p reinterpret_castNCCALCSIZE_PARAMS *(msg-lParam); p-rgrc[0].left 1; p-rgrc[0].right - 1; p-rgrc[0].top 1; p-rgrc[0].bottom - 1; *result WVR_REDRAW; return true;这个写法的效果是窗口外圈多一圈 1 像素的透明边阴影过渡更自然代价是你要在客户区最外层加 1 像素 padding否则边框会和内容重叠。两种写法都能用先跑通return 0的版本觉得阴影边界太硬再换成 1 像素版本这是我一般推荐的推进顺序。3. 系统特性层AERO 阴影、Snap、最大化一次拿回样式和 NC 区域处理完窗口已经在系统眼里是个标准窗口了但还有几个开关没打开不打开你会看到看起来像那么回事用起来全不对的中间状态。3.1 DWM 阴影从哪里来以及别碰 WA_TranslucentBackgroundWindows 10 之后 DWM 会对带 size border 的正常顶层窗口自动投阴影这一层阴影是合成器画的不占你的渲染管线也不受窗口内容影响。你在 2.2 里保留了WS_THICKFRAME阴影其实已经回来了只是它贴着客户区边缘视觉上会显得比较薄。想让它更明显一点可以调DwmExtendFrameIntoClientArea把 DWM 的框架延伸进客户区#include dwmapi.h #pragma comment(lib, dwmapi.lib) void FramelessWindow::extendDwmFrame() { HWND hwnd reinterpret_castHWND(winId()); MARGINS margins {1, 1, 1, 1}; // 四边各延伸 1 像素 HRESULT hr DwmExtendFrameIntoClientArea(hwnd, margins); if (FAILED(hr)) { qWarning(DwmExtendFrameIntoClientArea failed: 0x%08lX, hr); } }MARGINS的四个值分别对应左、右、上、下1是最常用的配置够触发 DWM 的阴影绘制又不会让内容区被框架覆盖。这里有个必须说清的前提调用这个 API 的窗口不能带WS_EX_LAYERED也不能设Qt::WA_TranslucentBackground。一旦设了透明背景DWM 会走另一条合成路径你看到的不是阴影而是四角发灰或者整窗发黑这个坑我在三个项目里见过四次属于血泪经验级。如果DwmExtendFrameIntoClientArea返回E_INVALIDARG或者干脆没效果先查GWL_EXSTYLE里有没有残留的WS_EX_LAYERED再查 Qt 侧有没有设透明属性这两处查完基本都能解决。在 Win11 上还有额外一步圆角。系统会给标准窗口自动加圆角但前提是它认为你在用系统框架。用下面这行可以显式要求圆角// Win11 SDK 中定义为 DWMWA_WINDOW_CORNER_PREFERENCE 33 // 低版本 SDK 需要手动定义 #ifndef DWMWA_WINDOW_CORNER_PREFERENCE #define DWMWA_WINDOW_CORNER_PREFERENCE 33 #endif #ifndef DWMWCP_ROUND #define DWMWCP_ROUND 2 #endif int pref DWMWCP_ROUND; DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, pref, sizeof(pref));在 Windows 10 上调用这个会返回E_INVALIDARG属于预期行为不需要处理判断返回值只是为了别把错误写到日志里干扰排查。3.2 Snap 的触发条件让系统自己接住拖拽AERO snap 的判定发生在系统的非客户区拖拽循环里也就是说鼠标按下必须命中HTCAPTION系统才会进入那个循环才会在鼠标靠近屏幕边缘时给出贴边预览。你在 Qt 里自己算GetCursorPos再MoveWindow系统全程不知情snap 一辈子不会出现。最省事的做法是在WM_NCHITTEST里对标题栏空白区域直接返回HTCAPTIONcase WM_NCHITTEST: { if (IsZoomed(msg-hwnd)) { *result HTCLIENT; // 最大化状态不做热区判定 return true; } POINT pt{ GET_X_LPARAM(msg-lParam), GET_Y_LPARAM(msg-lParam) }; // m_dragAreaGlobal 是标题栏空白区在屏幕坐标系下的矩形 if (m_dragAreaGlobal.contains(QPoint(pt.x, pt.y))) { *result HTCAPTION; // 交给系统进入拖拽循环 return true; } break; // 其余区域走尺寸热区判断见 4.1 }返回HTCAPTION之后双击最大化、右键系统菜单、拖动贴边这些行为全部由系统负责你一行额外代码都不用写。代价是这一块区域的 Qt 鼠标事件收不到了所以标题栏上的按钮、菜单必须排除在m_dragAreaGlobal之外。如果项目里已经有了一套自绘标题栏、事件绑定比较深不想改成 hit test 驱动退而求其次的做法是在mousePressEvent里手动发起拖拽void TitleBar::mousePressEvent(QMouseEvent *e) { if (e-button() ! Qt::LeftButton) { QWidget::mousePressEvent(e); return; } HWND hwnd reinterpret_castHWND(window()-windowHandle()-winId()); ReleaseCapture(); // 释放 Qt 已建立的鼠标捕获 SendMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0); e-accept(); }ReleaseCapture()这一句不能省。Qt 在鼠标按下时会调用SetCapture不释放的话系统的拖拽循环抢不到鼠标表现就是拖一下就粘住松开鼠标窗口还在跟着跑。这个问题的另一面是SendMessage会阻塞到拖拽循环结束函数返回之前 Qt 的事件循环是停的所以在这之后别再写依赖事件分发的逻辑。3.3 最大化越界的两种修法3.3 讲两条路线选一条走就行。第一条就是 2.3 里写的最大化时把WM_NCCALCSIZE交还系统简单缺点是最大化时客户区四周内缩边框厚度。第二条是始终return 0改用WM_GETMINMAXINFO把最大化矩形纠正到屏幕工作区case WM_GETMINMAXINFO: { MINMAXINFO *mmi reinterpret_castMINMAXINFO *(msg-lParam); HMONITOR mon MonitorFromWindow(msg-hwnd, MONITOR_DEFAULTTONEAREST); MONITORINFO mi { sizeof(MONITORINFO) }; if (GetMonitorInfoW(mon, mi)) { // ptMaxPosition 是相对显示器原点的偏移 mmi-ptMaxPosition.x mi.rcWork.left - mi.rcMonitor.left; mmi-ptMaxPosition.y mi.rcWork.top - mi.rcMonitor.top; // ptMaxSize 取工作区尺寸避开任务栏 mmi-ptMaxSize.x mi.rcWork.right - mi.rcWork.left; mmi-ptMaxSize.y mi.rcWork.bottom - mi.rcWork.top; } *result 0; return true; }rcWork是不含任务栏的可用区域rcMonitor是显示器整块区域两者相减得到的就是任务栏占据的那一条。多显示器场景下MonitorFromWindow会拿到窗口当前所在的那块屏所以副屏最大化也能正确避开副屏的任务栏。ptMaxTrackSize如果被系统设过最好一并覆盖为工作区尺寸否则在某些 DPI 组合下拖动缩放会有回弹。这条路线下客户区完整铺满工作区贴边控件不会内缩代价是要自己维护最小尺寸mmi-ptMinTrackSize.x qRound(minimumWidth() * devicePixelRatioF()); mmi-ptMinTrackSize.y qRound(minimumHeight() * devicePixelRatioF());注意单位是物理像素所以乘了devicePixelRatioF()。不乘的话在 150% 缩放屏上最小尺寸会缩水三分之一用户能把你精心设计的布局拖成一条缝。4. 交互层缩放热区、控件避让和图标链路窗口能正常显示、能贴边、能最大化了接下来是用户真正天天碰的部分边缘拖动缩放、标题栏拖拽、任务栏图标。这三块各有各的坑而且坑都在细节上。4.1 WM_NCHITTEST 热区计算与 DPI 换算WS_THICKFRAME虽然还在但由于非客户区已经被裁干净系统自己算出来的本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。