首页 / 资讯中心 / 文章详情

Qt无边框窗口找回AERO:一套WINAPI消息循环完整方案

Qt无边框窗口找回AERO:一套WINAPI消息循环完整方案 ★ FEATURED ARTICLE
简介面向需要在Windows平台打造高级无边框窗口的Qt开发者这份资源演示了将Qt与WinAPI结合实现图标显示、任意拖拽、边缘缩放、AERO阴影与模糊背景、最大最小化及DWM特效并给出WM_NCHITTEST、WM_GETMINMAXINFO、DwmExtendFrameIntoClientArea等关键消息与接口的处理思路也覆盖DwmEnableBlurBehindWindow、WM_NCCALCSIZE等易错细节。压缩包共48个文件约73KB以13个h头文件、12个cpp实现、2个ui界面、qrc资源与pro工程文件为主另有png/svg图标、MD说明、ini配置等辅助内容其中h文件负责类与接口声明cpp实现无边框窗口核心逻辑ui完成界面布局qrc与png支撑图标和视觉资源整体按窗口框架、浏览器页面等模块分层组织便于提取复用。资源已有1266人学习内含一套可直接编译验证的完整工程源码附有README与说明文档代码对无边框窗口常见坑点做了注释处理可帮助读者快速搭建出具备系统原生质感的应用窗口。1. 无边框不是放弃系统能力一套 QtWINAPI 方案把 AERO 还给窗口很多人在 Qt 里第一次做无边框窗口都是在setWindowFlags(Qt::FramelessWindowHint)之后才意识到代价标题栏没了可阴影、边缘缩放、AERO 玻璃、任务栏图标和 snap 拖动也一起没了窗口变成一块「裸板」。这套基于 WINAPI 的 Qt 无边框窗口实现解决的问题就是把这些系统能力逐个找回来——不是退回去用系统标题栏而是让 Qt 和 win32 共享消息循环在WM_NCHITTEST、WM_NCLBUTTONDOWN、DWM 边距这些底层通道上重新定义窗口边界。它适合做自定义皮肤客户端、直播工具、仪表盘这类既想要自由绘制又不想彻底抛弃系统交互的桌面应用。2. 先让 win32 和 Qt 共享消息循环两个入口决定无边框的底子2.1 QAbstractNativeEventFilter让 Qt 进程能先看到系统消息无边框窗口里用户点到的每一个像素其实都先归 Windows 管然后才轮到 Qt。系统会把鼠标移动、非客户区命中、尺寸变化这些消息拿给窗口过程处理如果直接把 Qt 的 widget 当黑匣子win32 层完全不知道哪些区域是标题栏、哪些是缩放边。所以第一步是给 Qt 装一个「系统消息前置过滤器」。Qt 5 和 Qt 6 都保留了QAbstractNativeEventFilter在 Windows 上eventType为windows_generic_MSG拿到的message就是原生MSG*。先写一个过滤器骨架class NativeWindowFilter : public QAbstractNativeEventFilter { public: NativeWindowFilter(HWND hwnd, QWidget *host) : hwnd_(hwnd), host_(host) {} bool nativeEventFilter(const QByteArray eventType, void *message, qintptr *result) override; private: HWND hwnd_; QWidget *host_; }; bool NativeWindowFilter::nativeEventFilter(const QByteArray eventType, void *message, qintptr *result) { if (eventType ! windows_generic_MSG) return false; // 不是 Windows 消息直接放行 MSG *msg static_castMSG *(message); if (msg-hwnd ! hwnd_) return false; // 只处理自己窗口的消息 switch (msg-message) { // 后面几章的处理逻辑都往这里填 default: break; } return false; }这段代码的逻辑很直白先筛事件类型再筛窗口句柄最后按消息 ID 分发。这里有一个容易混淆的点——Qt 的事件过滤器和系统的nativeEventFilter是两套东西前者滤 Qt 事件后者滤原生消息无边框窗口的关键状态如命中测试、尺寸限制只能在原生消息里改。安装方式是在主函数里int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; HWND hwnd reinterpret_castHWND(w.winId()); NativeWindowFilter *filter new NativeWindowFilter(hwnd, w); app.installNativeEventFilter(filter); w.show(); return app.exec(); }winId()必须等在窗口对象创建之后调用它会触发系统窗口的创建如果放在构造函数早期调用拿到的句柄可能不是最终窗口。installNativeEventFilter是QCoreApplication层的接口对整个进程生效所以过滤器内部一定要做msg-hwnd ! hwnd_的判断否则系统里所有窗口的消息都会涌进这个函数。2.2 WM_NCHITTEST你返回什么热区系统就把什么能力交给你WM_NCHITTEST是整条链路的起点。Windows 在鼠标移动、按下、双击之前都会先问窗口「这个坐标属于窗口的哪一部分」窗口过程返回HTLEFT、HTTOP、HTCAPTION、HTCLIENT这些值。系统拿到HTCAPTION就自动把拖动、双击最大化、Aero Snap 全都接管拿到HTLEFT就用系统逻辑处理横向缩放。无边框窗口的默认返回是HTCLIENT也就是整块窗口都被当成客户区系统能力自然全部失效。处理思路是如果光标在边缘 6 像素内返回对应缩放热区如果在自定义标题栏区域内返回HTCAPTION其余全部返回HTCLIENT。核心代码LRESULT handleHitTest(QWidget *host, HWND hwnd, LPARAM lParam) { const long px GET_X_LPARAM(lParam); const long py GET_Y_LPARAM(lParam); qreal dpr host-devicePixelRatioF(); QPoint local host-mapFromGlobal( QPoint(qRound(px / dpr), qRound(py / dpr))); QRect clientRect host-rect(); if (!clientRect.contains(local)) return HTNOWHERE; const int border 6; const bool left local.x() border; const bool right local.x() clientRect.width() - border; const bool top local.y() border; const bool bottom local.y() clientRect.height() - border; 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; int titleBarHeight host-property(titleBarHeight).toInt(); if (local.y() titleBarHeight) return HTCAPTION; return HTCLIENT; }两个参数值得单独说。border是缩放热区的像素宽度太小了边缘几乎抓不住太大了窗口中央区域的按钮容易被误判6 像素在 100% 缩放下手感接近系统在 200% 缩放下用dpr换算之后依然准确。titleBarHeight用 Qt 动态属性传进过滤器改成自定义标题栏的高度即可不需要在过滤器里写死。还要注意lParam里的坐标是物理像素而 Qt 在开启高 DPI 感知后走逻辑坐标直接mapFromGlobal(px, py)会在 150%、200% 缩放下明显偏位。先按devicePixelRatioF回到逻辑坐标再映射是这块最常见的隐性 bug。把handleHitTest接到上一节的switchcase WM_NCHITTEST: *result handleHitTest(host_, hwnd_, msg-lParam); return true;从这之后拖拽、缩放、snap 这些能力就不再依赖setWindowFlags的运气而是由我们明确告知系统窗口的每一块边界是什么。为什么不直接调用DefWindowProc呢因为标准边框下它会根据样式自动返回命中区去掉WS_CAPTION后它默认整窗HTCLIENT等于什么都没给所以这条消息必须自己接管。3. 把拖拽、缩放、阴影一项项焊回去完整消息处理链与参数3.1 标题栏拖拽让系统做拖动而不是自己追鼠标上一章里WM_NCHITTEST返回HTCAPTIONWindows 就已经把标题栏拖拽接管了。用户按住标题栏移动系统内部走的是WM_NCLBUTTONDOWN HTCAPTION进入一个模态循环窗口位置随鼠标实时变化还会自动触发 Aero Snap鼠标拖到屏幕边缘出现半屏或最大化的布局预览。这是无边框窗口里最「省心」的一层能力只要你返回了HTCAPTION双击标题栏最大化/还原、拖到顶部最大化全都是系统行为不需要你写一行逻辑。很多初版实现会自己写拖拽在mousePressEvent里记录偏移在mouseMoveEvent里move()。短距离拖动看起来能用但有两个硬伤一是拖动循环里 Qt 事件被系统挤占窗口会感觉「跟不上鼠标」二是move()是逐帧设置窗口位置DWM 合成器会丢动画快速拖动时明显掉帧。所以正确姿势是放弃自己移动把整个拖动还给HTCAPTION。在自定义标题栏内部交互按钮要单独处理。标题栏整体返回HTCAPTION后Qt 收不到这块区域的鼠标消息按钮点击、右键菜单都必须另想办法。我常用的做法是标题栏里放一个QWidget作为按钮容器返回热区时跳过按钮矩形QRect maxBtn host-property(maxButtonRect).toRect(); QRect closeBtn host-property(closeButtonRect).toRect(); if (local.y() titleBarHeight) { // 按钮区域返回 HTCLIENT让 Qt 正常收到点击 if (maxBtn.contains(local) || closeBtn.contains(local)) return HTCLIENT; return HTCAPTION; }maxButtonRect和closeButtonRect用property传进来好处是换了皮肤不用动 C 逻辑。按钮自己收到的点击里再做最大最小化和关闭的调用这部分放到第 4 章。实际项目里标题栏右侧一般还会有最小化和菜单按钮都可以按同一套办法处理需要动 Qt 逻辑的区域返回HTCLIENT需要系统行为拖动的区域返回HTCAPTION。3.2 八方向缩放与最小尺寸WM_GETMINMAXINFO 是保护伞边缘缩放的热区在第 2 章已经全部分配好系统会自动处理后续的尺寸变化。但有两个点必须自己兜底最小尺寸以及最大化时窗口落点。窗口如果允许被拖到 0 尺寸用户很容易把窗口拖丢而 maximized 状态下如果ptMaxSize没设置好窗口不会贴着工作区边缘底部会露出一条桌面。处理这两个问题都在WM_GETMINMAXINFO它会在窗口尺寸即将变化前询问允许的最大最小范围case WM_GETMINMAXINFO: { MINMAXINFO *mmi reinterpret_castMINMAXINFO *(msg-lParam); mmi-ptMinTrackSize.x 360; mmi-ptMinTrackSize.y 240; HMONITOR monitor MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST); MONITORINFO mi { sizeof(mi) }; GetMonitorInfo(monitor, mi); RECT work mi.rcWork; // 工作区去掉任务栏 mmi-ptMaxPosition.x work.left; mmi-ptMaxPosition.y work.top; mmi-ptMaxSize.x work.right - work.left; mmi-ptMaxSize.y work.bottom - work.top; return true; }ptMinTrackSize是逻辑像素上的最小窗口尺寸ptMaxPosition和ptMaxSize决定最大化后窗口的位置与大小。这里用MonitorFromWindow而不是直接拿主屏参数是为了多显示器下最大化时不跑到副屏外面。注意ptMinTrackSize的单位是系统屏幕坐标跟 QWidget 的minimumSize是两套两者的最小值取最大才能同时拦住用户拖拽和代码设置的边界只设一边另一边会漏。还有一个关于最大化时客户区铺满的问题。标准窗口最大化时边框会被 DWM 计算成添加了边界导致客户区比工作区小一圈通常是 7 或 8 像素。无边框窗口里这个偏差非常显眼表现为最大化后四周一圈白边。处理方式是接管WM_NCCALCSIZE通知系统客户区不需要再让位给边框case WM_NCCALCSIZE: { if (IsZoomed(hwnd_)) { // 只在最大化时铺满 *result 0; return true; } break; }这个返回 0 的含义是「客户区直接使用整个窗口矩形」。注意这里只建议在最大化时返回 0还原状态下如果也返回 0窗口客户区会直接压到 DWM 的阴影边界上阴影和玻璃的过渡会变得生硬对大多数应用来说普通状态交给DefWindowProc计算客户区更省事不影响第 2 章的手动命中测试。3.3 AERO 阴影DWM 边距怎么设阴影就怎么来无边框窗口的阴影其实不是窗口画的是 DWM桌面窗口管理器根据窗口的非客户区边界帮我们绘制的。去掉标题栏后DWM 默认不认为这个窗口有边框阴影自然消失。要让阴影回来常见做法是用DwmExtendFrameIntoClientArea把 DWM 的帧画到客户区里MARGINS margins; margins.cxLeftWidth margins.cxRightWidth -1; margins.cyTopHeight margins.cyBottomHeight -1; DwmExtendFrameIntoClientArea(hwnd_, margins);四个值都是 -1 表示让 DWM 把整个窗口当成一块「延伸的非客户区」这时 AERO 阴影和玻璃材质会重新出现。与之搭配的几个坑是不要再设置CS_DROPSHADOW。经典阴影样式只在无 DWM 的传统主题下有效在 Windows 10/11 上会跟 AERO 阴影重复叠加出现两层难看的描边。不要用DwmSetWindowAttribute把DWMWA_NCRENDERING_POLICY设为DWMNCRP_DISABLED那会直接关掉 DWM 对非客户区的渲染阴影和玻璃一起没。阴影的「大小」不是这个 API 直接控制的DWMWA_EXTENDED_FRAME_BOUNDS只修改 DWM 判断窗口边界的矩形进而改变阴影的扩散范围适合用来微调阴影感但并不是真正的阴影宽度参数。阴影这块很多人觉得像玄学明明按文档设了 MARGINS 还是没阴影。我见过最多的原因是创建窗口时把窗口样式里的WS_THICKFRAME去掉了。无边框窗口不一定必须保留这个样式但阴影和缩放热区都依赖 DWM 认为「这是一个可调整大小的顶级窗口」。如果真要去掉WS_THICKFRAME则要用DwmSetWindowAttribute配合DWMWA_EXTENDED_FRAME_BOUNDS重新告诉 DWM 窗口边界——这会让代码复杂不少不必要的话还是保留默认样式更省事。4. 图标、最大最小化与 DWM 玻璃观感分水岭的收尾实现4.1 WM_SETICON把图标同时喂给任务栏和 Alt-Tab无边框窗口创建后任务栏图标往往是个「空壳」标题和图标都不对。原因是 Qt 的setWindowIcon只影响 Qt 层面对图标的管理而任务栏、Alt-Tab 用的是系统窗口图标必须通过WM_SETICON直接设置。HMODULE module GetModuleHandleW(nullptr); HICON hIcon static_castHICON( LoadImageW(module, Lapp.ico, IMAGE_ICON, 0, 0, LR_LOADFROMFILE)); if (hIcon) { SendMessageW(hwnd_, WM_SETICON, ICON_BIG, reinterpret_castLPARAM(hIcon)); SendMessageW(hwnd_, WM_SETICON, ICON_SMALL, reinterpret_castLPARAM(hIcon)); } SetWindowTextW(hwnd_, L无边框窗口示例);ICON_BIG用于 Alt-Tab、任务栏的大图标ICON_SMALL用于标题栏和小尺寸显示。无边框没有标题栏但ICON_SMALL依然影响任务栏右键菜单和某些系统对话里的显示两个都要设。SetWindowTextW设置的是系统层窗口标题影响任务栏悬停文字、Alt-Tab 列表和无障碍读屏就算界面上没有标题栏这个值也不能省。如果app.ico是 Qt 资源里的文件LoadImageW没法直接加载 qrc 路径。常见做法是使用QIcon先拿到QPixmap再从QImage生成HICON。Qt 6 里QImage自带toHICON不必再引入QtWinExtras需要多尺寸图标时提供 256 和 32 两张图任务栏在不同缩放下不会模糊。4.2 自绘控制按钮返回 HTMAXBUTTON 还是发 WM_SYSCOMMAND无边框窗口里三个系统按钮一般自绘但自绘之后按钮的行为要重新「接回系统」。两种做法差别很大。第一种做法按钮区域返回HTCLIENT鼠标事件完全走 Qt。按钮点击里自己发系统命令case WM_SYSCOMMAND: { if (wParam SC_MAXIMIZE) { // 当前若最大化则还原否则最大化 ShowWindow(hwnd_, IsZoomed(hwnd_) ? SW_RESTORE : SW_MAXIMIZE); return true; } break; }这种方式的优点是完全可控按钮的 hover、按压状态自己画不需要系统干预缺点是 Aero Snap 和 Windows 11 的 snap 布局按钮不会出现在最大化按钮旁边因为系统不知道这个按钮是「最大化按钮」。对于只追求视觉统一的应用第一种就够用。第二种做法在WM_NCHITTEST里把最大化按钮区域返回HTMAXBUTTON关闭按钮返回HTCLOSE。系统会认为那是真实的窗口按钮自动处理 hover 提示、Snap Layouts 悬停、双击还原连系统右键菜单都会包含「最大化/还原」这些项。代价是这些区域的鼠标事件不再进 Qt按钮样式没法用 QSS 那一套 hover 状态得靠WM_NCHITTEST的旁路消息自己做反馈。这块没有标准答案要看产品要的是「系统一致性」还是「视觉自由度」。无论是哪种窗口样式里必须存在WS_MAXIMIZEBOX/WS_MINIMIZEBOX/WS_CLOSE相关位自绘按钮发WM_SYSCOMMAND才有效。Qt 的FramelessWindowHint并不会保留这些样式位我一般在showEvent里用SetWindowLongPtr把WS_OVERLAPPEDWINDOW的位补回去再配合第 3 章的最大化判断让非客户区在最大化时不占客户区空间。为了还原窗口务必保留WS_THICKFRAME。4.3 DWM 玻璃与圆角让无边框窗口在深浅色主题下都体面AERO 玻璃效果在 Qt 无边框窗口里的实现用的还是 3.3 节那个MARGINS负边距。设置 -1 之后整个客户区会变成 DWM 的玻璃画布窗口背景的黑色区域会显示玻璃模糊效果。用 QSS 设置半透明背景与玻璃叠加就能得到磨砂感setAttribute(Qt::WA_TranslucentBackground); setStyleSheet(background: rgba(255, 255, 255, 0.75););这里有个容易翻车的顺序WA_TranslucentBackground必须在窗口显示前设置好否则透明背景不生效玻璃直接盖在默认不透明底上。Windows 10/11 上玻璃效果还受系统「透明效果」开关控制开关关掉时rgba半透明会退化成一整块纯色所以业务文案不能直接压在玻璃上要有足够的对比度。圆角处理视系统版本不同而不同。Windows 11 22H2 之后可以用DwmSetWindowAttribute的DWMWA_WINDOW_CORNER_PREFERENCE设置圆角Windows 10 上这个属性不生效只能自己做圆角裁剪QPainterPath path; path.addRoundedRect(rect(), 8, 8); setMask(path.toFillPolygon().toPolygon());setMask的代价是抗锯齿较弱圆角边缘能看到锯齿用WA_TranslucentBackground时则要在paintEvent里自己画圆角矩形比setMask好得多。这块不需要每个工程都做到完美但要认识到「系统版本不同圆角实现路径不同」不要把 Windows 11 的经验直接套到 Windows 10 上。5. 无边框窗口避坑排查五个实际翻车现场5.1 阴影只剩一条黑边现象窗口四周的 AERO 阴影不见了取而代之的是 1 像素硬黑边深色主题下特别扎眼。原因DWM 在无边框窗口上判断的是MARGINS四值决定它把多少外框画进客户区。更多时候是设置了DwmSetWindowAttribute把非客户区渲染策略关成了禁用或者窗口样式里没有WS_THICKFRAMEDWM 直接认为窗口不可调整大小阴影功能被旁路。解决确认MARGINS四值全部为 -1 后调用DwmExtendFrameIntoClientArea不要动DWMWA_NCRENDERING_POLICY保留WS_THICKFRAME样式让 DWM 把它当成普通顶层窗口。改完看任务栏切换主题深浅色下阴影应同步变化。5.2 最大化后底部露出一条桌面现象点击最大化后窗口没有覆盖整个工作区底部或右侧露出约 7 像素的桌面背景切换主题时尤其明显。原因标准窗口最大化时DWM 会保留一圈不可见边框用于绘制阴影客户区不会铺满工作区。无边框窗口如果不处理WM_NCCALCSIZE这个保留区域就一直存在。解决在WM_NCCALCSIZE里判断IsZoomed后返回 0让客户区直接使用窗口矩形并在WM_GETMINMAXINFO里把ptMaxPosition设为对应显示器工作区原点、ptMaxSize设为工作区尺寸。两个配合才完整只改一个都会留下偏差。5.3 拖动窗口时跑位松开后状态错乱现象按住标题栏拖动窗口移动比鼠标慢半拍某些双屏场景下松开后窗口整体偏移了几十像素。原因用了mouseMoveEvent里move()的方案或者WM_NCHITTEST的坐标换算没有按devicePixelRatioF回退。系统拖动循环里 QWidget 收不到足够的moveEvent而坐标直接用物理像素喂给了逻辑坐标函数。解决拖拽整体交还HTCAPTION不做手动move所有鼠标坐标先除以 DPR 再进mapFromGlobal。如果必须做自定义拖拽比如可拖动区域内嵌控件也要统一在原生消息层完成不要在 Qt 事件里追鼠标。5.4 Windows 11 的 snap 布局按钮不出现现象鼠标悬停在自绘最大化按钮上没有弹出 Windows 11 的 snap 布局面板拖到屏幕边缘时半屏预览有时也不出现。原因按钮区域返回了HTCLIENT系统不认为它是最大化按钮snap 布局按钮依赖系统对按钮热区的识别。解决在WM_NCHITTEST里让最大化按钮区域返回HTMAXBUTTON关闭按钮返回HTCLOSE并确保窗口样式带WS_MAXIMIZEBOX。这样悬停预览、双击还原都由系统接管。自绘按钮想要自己的 hover 反馈可以在WM_NCHITTEST里额外做一次消息旁路处理而不是退回HTCLIENT。5.5 任务栏图标是黑的或右键菜单缺项现象窗口启动后任务栏图标空白或全黑右键菜单里没有「最大化/还原」等常用项。原因只设了ICON_BIG没设ICON_SMALL或图标文件本身只有 32x32 分辨率放大后系统用了默认图标右键菜单缺项是因为窗口样式里WS_MAXIMIZEBOX、WS_MINIMIZEBOX位被FramelessWindowHint清掉了。解决WM_SETICON同时设置两种图标图标源里放入 256 和 32 两档用SetWindowLongPtr把WS_OVERLAPPEDWINDOW需要的样式位补回来配合WM_NCCALCSIZE让边框不显示。改完后重启应用任务栏图标和右键菜单应恢复正常。6. 把 snap 与边缘吸附做成肌肉记忆一张验证清单和一个进阶钩子系统自带的 Aero Snap 只覆盖「按住标题栏拖动」的场景程序里setGeometry或WM_GETMINMAXINFO变更位置时窗口不会自己吸附到屏幕边缘。如果你经常做多窗口排列可以让WM_WINDOWPOSCHANGING顺手做边缘吸附——这是程序化布局时唯一还能触发对齐的钩子case WM_WINDOWPOSCHANGING: { WINDOWPOS *wp reinterpret_castWINDOWPOS *(msg-lParam); if (wp-flags SWP_NOMOVE) break; MONITORINFO mi { sizeof(mi) }; HMONITOR hm MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST); if (GetMonitorInfo(hm, mi)) { RECT work mi.rcWork; const int edge 8; if (std::abs(wp-x - work.left) edge) wp-x work.left; if (std::abs(wp-y - work.top) edge) wp-y work.top; if (std::abs((wp-x wp-cx) - work.right) edge) wp-cx work.right - wp-x; if (std::abs((wp-y wp-cy) - work.bottom) edge) wp-cy work.bottom - wp-y; } break; }这段的edge是吸附阈值8 像素在多数屏幕上都不突兀。注意它接管的是位置变化「之前」的最终决定权所以只在SWP_NOMOVE未设置时生效避免每次show都被误吸。我把这套无边框窗口的经验整理成了一张固定验证清单每次写完 UI 都强制走一遍拖动是否跟手、双击标题栏是否在最大化/还原间切换、拖到屏幕左右边缘是否出现半屏预览、最大化后底部有没有白边、边缘 6 像素能否流畅缩放、深浅色主题下阴影是否一致、任务栏右键菜单是否包含系统项、Alt-Tab 图标是否清晰、200% 缩放下窗口是否不再跑位。任何一项不通过都能顺着第 5 章的分类快速定位到具体消息循环。从那以后我每接一个无边框窗口需求第一天就把这套验证清单跑一遍再动手改样式省掉的返工时间比写代码本身还多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站