简介:基于QT与WINAPI混合编程实现Win7特效无边框窗口的完整示例工程,面向有一定QT基础、希望在Windows下打造高定制化界面的桌面应用开发者。资源以纯C++/QT源码为核心,完整演示了图标显示、窗口缩放、鼠标拖拽、AERO阴影与模糊背景,以及自定义最大最小化按钮的实现,并涉及WM_NCHITTEST、DwmExtendFrameIntoClientArea、DwmSetWindowAttribute等关键API用法,兼顾了不同系统版本下的兼容处理。压缩包共48个文件,包含13个头文件与12个C++源文件,辅以UI布局、QSS样式、多张PNG图标、工程配置与QtWinMigrate原生活口封装,结构清晰,可直接编译学习。已有1266人学习下载。通过学习源码,既能掌握无边框窗口下系统消息处理与DWM接口调用的完整思路,也能理解QT事件循环如何与WinAPI消息协同,适合希望提升混合编程与自定义窗口能力的中高级开发者。
1. 无边框窗口吊诡的地方:一个 Flag 带出的六连问
Qt 里开一个无边框窗口,一行setWindowFlags(Qt::FramelessWindowHint)就能把系统标题栏摘掉。可一旦接着做下去,问题就接踵而来:窗口怎么拖动?边缘怎么缩放?任务栏上的最大最小化还能不能点?阴影为什么没了?AERO 玻璃效果是不是必须放弃?这些问题没有一个能从 Qt 官方 API 直接拿到答案,因为它们都发生在 Windows 的系统窗口管理器那一层。想把这个标题里的功能一次做全,必须把 WinAPI 的窗口样式位、命中测试机制和 DWM 合成路径串起来理解,缺一条都会在运行时翻车。这篇文章按「原理 → 最小工程 → 逐项落地 → 高频踩坑 → 进阶收尾」的顺序,把每条线的代码和参数完整给出来。
2. 先搞懂无边框窗口在 Windows 层的三条线:样式位、非客户区与 DWM 绘制
2.1 样式位三件套:WS_THICKFRAME、WS_CAPTION 与两个系统菜单按钮
Windows 的窗口类型不是由 Qt 的 Flag 决定的,最终生效的是GWL_STYLE里那 32 位二进制样式位。无边框窗口最常见的一个误区是「无边框 = 把样式清干净」。恰恰相反,你把WS_CAPTION和WS_THICKFRAME一起去掉之后,窗口会失去三个能力:系统级缩放、阴影、最大化最小化的状态切换。没有WS_THICKFRAME,你在边缘做得再细也只是自己模拟缩放,系统根本没有把这块区域当作可调整边框来看待。
我一般的做法是保留WS_THICKFRAME,只去掉WS_CAPTION。WS_CAPTION控制的是标题栏和非客户区的绘制,去掉它之后标题栏消失,但WS_THICKFRAME还在,窗口边缘仍然是一个系统认识的 resize 边界。再加上WS_MINIMIZEBOX | WS_MAXIMIZEBOX,任务栏右键菜单里的「最大化 / 最小化」命令也能正常出现。这套组合在 Win10 和 Win11 上都能让 DWM 自动绘制外阴影,而不会真的画出标题栏。
// 在拿到 HWND 之后调整样式位 HWND hwnd = reinterpret_cast<HWND>(this->winId()); LONG_PTR style = GetWindowLongPtr(hwnd, GWL_STYLE); style |= WS_THICKFRAME; // 保留系统缩放边框和阴影来源 style &= ~WS_CAPTION; // 去掉标题栏区域,保证无边框视觉 style |= WS_MINIMIZEBOX | WS_MAXIMIZEBOX; // 让系统菜单里出现最大最小化 SetWindowLongPtr(hwnd, GWL_STYLE, style); // 关键一步:通知系统样式已变化,强制重建窗口帧 SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);SetWindowPos里的SWP_FRAMECHANGED不是可选项。只调SetWindowLongPtr改样式,很多情况下系统不会立刻按新样式重算非客户区,阴影和边框不会刷新。加上这个标志之后,DWM 才会重新计算窗口的帧区域。这也是新手最常漏掉的一步,漏了之后表现就是「样式改了但窗口没反应」。
2.2 WM_NCHITTEST:系统对鼠标位置的第一道裁决
窗口上每一个鼠标位置,系统在把消息交给应用之前会先发一条WM_NCHITTEST,让窗口自己回答「这个点属于哪个区域」。返回HTCLIENT表示客户区,事件正常进入 Qt;返回HTLEFT、HTRIGHT、HTTOP这些值,系统就会进入对应的边框调整逻辑,鼠标光标自动变成拉伸箭头,后续的拖拽由系统接管。返回HTCAPTION,系统会认为你在按住标题栏,于是可以移动窗口,还会附带双击最大化的行为。
这解释了一个现象:只用 Qt 事件模拟拖拽和缩放,光标反馈永远做不到跟原生窗口一致。因为 Qt 的鼠标事件只有进入客户区才会触发,而你想要的「悬停在边缘 5 像素处出现拉伸箭头」这件事,发生在消息进入QWidget::mouseMoveEvent之前。唯一的正路是在nativeEvent里拦截WM_NCHITTEST并返回正确的区域代码。
LRESULT handleNCHitTest(WPARAM wParam, LPARAM lParam) { POINT pt; pt.x = GET_X_LPARAM(lParam); // 屏幕物理坐标 pt.y = GET_Y_LPARAM(lParam); RECT rc; GetWindowRect(m_hwnd, &rc); int x = pt.x - rc.left; // 转换为窗口内坐标 int y = pt.y - rc.top; int w = rc.right - rc.left; int h = rc.bottom - rc.top; const int kBorder = 5; // 命中宽度,单位是物理像素 if (IsZoomed(m_hwnd)) { return HTCLIENT; // 最大化时不响应边缘缩放 } if (x <= kBorder && y <= kBorder) return HTTOPLEFT; if (x >= w - kBorder && y <= kBorder) return HTTOPRIGHT; if (x <= kBorder && y >= h - kBorder) return HTBOTTOMLEFT; if (x >= w - kBorder && y >= h - kBorder) return HTBOTTOMRIGHT; if (y <= kBorder) return HTTOP; if (y >= h - kBorder) return HTBOTTOM; if (x <= kBorder) return HTLEFT; if (x >= w - kBorder) return HTRIGHT; // 顶部 36 像素物理高度作为拖拽热区 if (y <= 36) return HTCAPTION; return HTCLIENT; }GET_X_LPARAM必须来自<windowsx.h>,不要用LOWORD/HIWORD,因为多显示器场景下屏幕坐标可能是负值,LOWORD会拆出无符号 16 位,坐标一负就全错了。另外这里完全用物理像素计算,是为了避开 Qt 逻辑坐标和物理坐标之间的换算,这一点在后面的避坑章节还会展开。
2.3 DWM:阴影、圆角、毛玻璃都在这条路径上
Windows 8 之后 AERO 的玻璃效果不再由窗口自己绘制,全部由 DWM 合成器接管。无边框窗口能不能获得阴影,取决于 DWM 认不认这个窗口有「可绘制帧」。一个完全没有WS_THICKFRAME的窗口,DWM 不会为它画外阴影;而一个设置了WS_THICKFRAME但没有WS_CAPTION的窗口,DWM 会画阴影并给一个 1px 左右的边框过渡。
DwmExtendFrameIntoClientArea的作用是把窗口的玻璃边框向内扩展,让客户区和系统帧区融为一体。参数MARGINS的四个值表示向客户区扩展多少像素。值为1时,DWM 会保留一条 1px 的玻璃边缘,同时外阴影生效;值为-1时,整个客户区都被声明为玻璃区域,如果窗口背景不透明,就会出现黑色或白雾一样的异常区域。
// 在样式位设置完成后调用 MARGINS margins = { 1, 1, 1, 1 }; DwmExtendFrameIntoClientArea(m_hwnd, &margins);Win11 上还要考虑圆角。DwmSetWindowAttribute配合DWMWA_WINDOW_CORNER_PREFERENCE可以直接把窗口四角切成圆角,这在无边框窗口里比自己去paintEvent画圆角更省事,而且系统阴影会跟着窗口形状走。这里给的是预览版本,完整调用会放在第 6 章和避坑清单里一起讲。
3. 从 QWidget 到能收到 WinAPI 消息的 HWND:最小工程搭建
3.1 工程配置:.pro 文件、头文件顺序与 NOMINMAX 陷阱
第一步先把编译环境弄干净。Qt 项目默认已经链接了user32.dll和kernel32.dll,但dwmapi.dll需要显式链接。windows.h和 Qt 头文件混用时的头号坑是min和max宏,它们会污染 Qt 的全局命名空间,导致QWidget相关模板编译报错。标准做法是在.pro里定义NOMINMAX。
QT += core gui widgets TARGET = FramelessDemo TEMPLATE = app CONFIG += c++11 DEFINES += NOMINMAX SOURCES += main.cpp framelesswindow.cpp HEADERS += framelesswindow.h LIBS += -ldwmapi代码里包含顺序也有讲究。先包含 Qt 头文件,再包含 Windows 头文件,最后包含windowsx.h拿GET_X_LPARAM。如果反了,windows.h会先定义一堆宏和类型,部分 Qt 头文件在 Windows 平台会出现重定义冲突。实际经验里,只要NOMINMAX定义到位,顺序影响不大,但保持「Qt 在前、Windows 在后」能少踩几个版本相关的怪问题。
#include <QWidget> #include <windows.h> #include <windowsx.h> #include <dwmapi.h>3.2 用 winId() 拿 HWND,重写 nativeEvent 接收系统消息
Qt 的窗口在真正显示之前,底层 HWND 是不存在的。winId()会强制创建底层窗口并返回 HWND,这个调用必须在setWindowFlags之后,最好在首次show()之前完成。拿到 HWND 之后要立刻缓存,不要每次winId()都重取,因为某些平台实现里重复调用可能导致句柄变化。
nativeEvent是 Qt 给应用开的后门,所有 Windows 消息在进入 Qt 事件系统之前会经过这里。参数eventType在 Windows 平台是"windows_generic_MSG",message实际指向MSG结构。返回true表示消息已被处理,Qt 不再往下分发。
bool FramelessWindow::nativeEvent(const QByteArray &eventType, void *message, long *result) { if (eventType == "windows_generic_MSG") { MSG *msg = static_cast<MSG *>(message); if (msg->hwnd == m_hwnd) { switch (msg->message) { case WM_NCHITTEST: *result = handleNCHitTest(msg->wParam, msg->lParam); return true; case WM_GETMINMAXINFO: *result = handleGetMinMaxInfo(msg->lParam); return true; default: break; } } } return QWidget::nativeEvent(eventType, message, result); }这里有个签名细节:Qt 5 里最后一个参数是long *result,Qt 6 改成了qintptr *result。如果你在 Qt 6 下编译,函数声明和实现里的long都要换成qintptr,否则会报覆盖不一致。qintptr在 Windows 上是long long,底层值传递不影响我们已有的逻辑。
3.3 初始化三连:样式修正、DWM 边框扩展、窗口帧重建
初始化动作集中放在一个函数里,在showEvent中调用。原因很简单:showEvent时窗口已经创建,HWND 一定有效,而且此时做样式修正能避免窗口创建早期暴露的错误外观。构造函数里也可以调winId(),但如果你在构造函数里调用setWindowFlags之后再调winId(),某些 Qt 版本可能还会触发一次窗口重建,多一次闪白。
void FramelessWindow::showEvent(QShowEvent *event) { QWidget::showEvent(event); if (m_initialized) return; m_initialized = true; m_hwnd = reinterpret_cast<HWND>(this->winId()); // 第一步:修正样式位 LONG_PTR style = GetWindowLongPtr(m_hwnd, GWL_STYLE); style |= WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; style &= ~WS_CAPTION; SetWindowLongPtr(m_hwnd, GWL_STYLE, style); // 第二步:告诉系统样式已变化 SetWindowPos(m_hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); // 第三步:DWM 扩展 1px 玻璃边框,激活阴影 MARGINS margins = { 1, 1, 1, 1 }; DwmExtendFrameIntoClientArea(m_hwnd, &margins); }这三步的顺序不能乱。先改样式位,再触发帧重建,最后做 DWM 扩展。反过来,DWM 扩展会基于旧样式计算,阴影可能不生效。全部做完之后,一个能拖动、能缩放、带系统阴影的无边框窗口骨架就成立了。
4. 逐项落地:拖拽、缩放、图标、最大最小化、阴影的代码与参数
4.1 拖拽:推荐 HTCAPTION 返回法,备选 ReleaseCapture 方案
拖拽实现有两种主流路径。第一种,也是最推荐的:在WM_NCHITTEST里把目标区域返回成HTCAPTION。这样做的好处是移动过程完全由系统调度,不用处理mouseMoveEvent,也不会在拖动时出现内容闪烁。代价是HTCAPTION区域不会收到鼠标点击事件,所以那个区域里的按钮要单独处理;另外双击HTCAPTION会自动触发最大化/还原,这是系统行为。
// 在 handleNCHitTest 中,边缘判断之后追加: if (y <= kTitleBarHeight) { return HTCAPTION; }kTitleBarHeight一般取 32 到 40 物理像素。如果你的窗口顶部有子控件做成标题栏的样子,就取子控件底部边界往上一点的坐标值。这个值宜大不宜小,太小了用户点标题栏边缘容易点到客户区,导致拖不动。
第二种方案是用 Qt 鼠标事件配合SendMessage:
void FramelessWindow::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { ReleaseCapture(); SendMessage(m_hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0); event->accept(); return; } QWidget::mousePressEvent(event); }这种方案适合只有部分区域可拖动、且该区域内有交互控件的情况。ReleaseCapture是关键,如果不释放鼠标捕获,SendMessage之后系统无法接管拖动,鼠标一动就断。它的问题在于子控件的事件会挡住这个处理,所以通常还得给子控件装eventFilter。能走WM_NCHITTEST就尽量别绕。
4.2 缩放:四角四边 + 一个常量,完整命中表
缩放核心就是WM_NCHITTEST针对边和角返回对应代码。前面已经给了实现代码,这里把返回值和系统行为的对应关系列清楚,方便调参时查。
| 返回代码 | 位置 | 系统行为 |
|---|---|---|
| HTLEFT | 左边 | 水平拉伸,光标为左右箭头 |
| HTRIGHT | 右边 | 水平拉伸,光标为左右箭头 |
| HTTOP | 上边 | 垂直拉伸,光标为上下箭头 |
| HTBOTTOM | 下边 | 垂直拉伸,光标为上下箭头 |
| HTTOPLEFT | 左上角 | 双向拉伸,光标为左上右下箭头 |
| HTTOPRIGHT | 右上角 | 双向拉伸,光标为右上左下箭头 |
| HTBOTTOMLEFT | 左下角 | 双向拉伸,光标为右上左下箭头 |
| HTBOTTOMRIGHT | 右下角 | 双向拉伸,光标为左上右下箭头 |
边界常量kBorder = 5在桌面端手感合适。如果目标用户是触屏设备,建议提到 8 到 10 物理像素,否则手指很难精准命中边缘。要注意这个值在nativeEvent里是物理像素,和 Qt 的devicePixelRatio没有关系,不需要手动乘缩放系数。
最大化状态下的处理已经在代码里做了:IsZoomed(m_hwnd)为真时直接返回HTCLIENT。否则鼠标在最大化窗口边缘会触发范围之外的缩放,窗口会从最大化状态被拖成普通尺寸,用户会觉得很奇怪。
4.3 图标、最大最小化按钮与 AERO 阴影的参数清单
图标这块,Qt 的setWindowIcon已经会向系统发送WM_SETICON,无边框窗口不需要额外处理。真正会出问题的是「先设置无边框、后设置图标」的顺序。如果setWindowIcon在窗口创建前调用,而窗口样式后面又被修改,任务栏图标可能不会刷新。稳住的做法是在初始化完成后重新调用一次即可。
// showEvent 初始化完成后 setWindowIcon(QIcon(QStringLiteral(":/app.ico")));最大最小化依靠两个层面配合:样式位 + Qt 状态切换。样式位里有了WS_MINIMIZEBOX | WS_MAXIMIZEBOX,任务栏右键菜单就能出现对应命令。代码里调用showMinimized()、showMaximized()、showNormal()即可。注意不要直接调SetWindowPos去模拟最大化,那会破坏系统对工作区的认知,拖拽到屏幕边缘的 Aero Snap 会失效。
阴影部分的参数组合是整套实现里最容易让人迷惑的。我实践下来最稳定的组合是:
| 参数 | 值 | 作用 |
|---|---|---|
| GWL_STYLE | WS_THICKFRAME保留,WS_CAPTION去掉 | 让 DWM 认为这是可缩放窗口 |
| MARGINS | { 1, 1, 1, 1 } | 保留 1px 玻璃边缘,外阴影生效 |
| SWP_FRAMECHANGED | 必须设置 | 强制系统重算帧区域 |
MARGINS四个值如果设成{ -1, -1, -1, -1 },阴影会消失,取而代之的是整窗玻璃化,四角容易出现黑灰色块。如果你不需要毛玻璃,永远别用-1。
5. 无边框窗口避坑清单:5 个高频实坑与解决方案
5.1 最大化盖住任务栏:WM_GETMINMAXINFO 没处理
现象:点击最大化之后,窗口底部完全覆盖任务栏,任务栏被压在窗口下面。
原因:没有WS_THICKFRAME的窗口在调用showMaximized()时,系统用默认的全屏尺寸替代工作区尺寸;即便有WS_THICKFRAME,如果应用没有处理WM_GETMINMAXINFO,系统也只会按主显示器的工作区计算,多显示器场景下照样出错。
解决:实现WM_GETMINMAXINFO,并用MonitorFromWindow获取当前窗口所在显示器的工作区。
LRESULT FramelessWindow::handleGetMinMaxInfo(LPARAM lParam) { LPMINMAXINFO lpmmi = reinterpret_cast<LPMINMAXINFO>(lParam); HMONITOR hMonitor = MonitorFromWindow(m_hwnd, MONITOR_DEFAULTTONEAREST); MONITORINFO mi; mi.cbSize = sizeof(mi); if (GetMonitorInfo(hMonitor, &mi)) { RECT work = mi.rcWork; lpmmi->ptMaxPosition.x = work.left; lpmmi->ptMaxPosition.y = work.top; lpmmi->ptMaxSize.x = work.right - work.left; lpmmi->ptMaxSize.y = work.bottom - work.top; } return 0; }注意千万别用SystemParametersInfo(SPI_GETWORKAREA),那个只返回主显示器的工作区。窗口拖到副屏再最大化,就会跑到主屏尺寸上,属于换了显示器反而出错的情况。
5.2 边缘缩放有光标没动作:命中测试坐标用了逻辑值
现象:鼠标移到窗口右边,光标变成了左右箭头,但按下拖动时窗口纹丝不动,或者跳了一下。
原因:WM_NCHITTEST的坐标是物理像素,而拿 Qt 的pos()或geometry()做判断时是逻辑像素。缩放比例为 125% 的机器上,两者相差 25%,边缘命中区域被算到了窗口外面。
解决:在nativeEvent里只用GET_X_LPARAM和GetWindowRect,全程物理像素,不跟 Qt 坐标混用。拖动热区的坐标也一样,如果自定义拖拽区域是用QRect存的,记得转换成物理像素再比较。这也是我建议直接用顶部固定像素高度做拖拽区的原因,省去一套坐标换算。
5.3 阴影丢了或出现黑块:样式位和 MARGINS 设置错位
现象:窗口一点阴影都没有,或者窗口四周出现黑色/深色边缘。
原因:阴影丢失通常是三种情况之一。没有设置WS_THICKFRAME;设置了样式但没调SWP_FRAMECHANGED;或者在WM_NCCALCSIZE里直接返回 0 把整个非客户区干掉了。黑块则是DwmExtendFrameIntoClientArea的MARGINS用了-1,而窗口背景完全不透明,DWM 的玻璃区域没有内容可显示。
解决:先确认样式位组合是WS_THICKFRAME加去掉WS_CAPTION,然后补上SWP_FRAMECHANGED。如果自己处理了WM_NCCALCSIZE,不要返回 0,交还给系统默认逻辑。MARGINS从-1改回{ 1, 1, 1, 1 },黑块会立刻消失,阴影恢复。
5.4 拖拽时窗口闪烁卡顿:客户区拖拽替代了系统拖拽
现象:拖动顶部区域时,窗口内容明显闪烁,甚至出现拖影,CPU 占用很高。
原因:没有用HTCAPTION,而是在mousePressEvent里自己记录偏移,然后在mouseMoveEvent里反复调用move()。每一次move()都会触发整个窗口的重绘和合成,连续高频调用时 DWM 合成压力陡然上升。
解决:能返回HTCAPTION的地方不要走 Qt 鼠标事件。如果因为子控件必须用SendMessage方案,也只在mousePressEvent点一次,之后整个移动过程交给系统,不要在mouseMoveEvent里做任何窗口位置更新。实测用HTCAPTION拖动时,CPU 占用几乎为零,而客户区方案在 4K 屏上能把单核跑满。
5.5 双屏不同 DPI 下窗口偏移:物理坐标和逻辑坐标的账没算清
现象:一个在 100% 缩放屏幕上的窗口,拖到 150% 缩放屏幕后,标题栏拖拽热区偏移,缩放边缘不灵敏。
原因:窗口跨显示器时,Windows 会对窗口内容做一次 DPI 缩放。GetWindowRect返回的是物理像素值,而WM_NCHITTEST消息里的坐标也是物理像素。但如果拖拽热区是用 Qt 的QRect存的,QRect里的值是逻辑像素,两者在 DPI 变化后不再一致。
解决:处理WM_DPICHANGED消息。Windows 会携带新 DPI 和建议的窗口矩形,Qt 在多数情况下能自己处理好,但如果你在nativeEvent中做了缓存,需要在 DPI 变化后清理并重新计算热区。经验是:所有命中测试相关常量全部走物理像素,不要在nativeEvent里引用任何 Qt 坐标值。
6. 进阶:让无边框窗口真正长出 AERO 毛玻璃和圆角收尾技巧
6.1 用 SetWindowCompositionAttribute 接上毛玻璃
SetWindowCompositionAttribute没有公开的头文件和导入库,需要自己声明函数指针再运行时解析。这条 API 从 Win10 1607 之后就在系统里,Win11 依然兼容,是做毛玻璃最直接的路径。
void FramelessWindow::enableAcrylic() { typedef BOOL(WINAPI * SetWindowCompositionAttributeFn)(HWND, PVOID); HMODULE user32 = GetModuleHandleW(L"user32.dll"); auto fn = reinterpret_cast<SetWindowCompositionAttributeFn>( GetProcAddress(user32, "SetWindowCompositionAttribute")); if (!fn) return; struct ACCENT_POLICY { int nAccentState; int nFlags; DWORD nColor; int nAnimationId; }; struct WINDOWCOMPOSITIONATTRIBDATA { int Attrib; PVOID pvData; SIZE_T cbData; }; ACCENT_POLICY policy; policy.nAccentState = 4; // ACRYLIC 亚克力 policy.nFlags = 2; // 允许颜色混合 policy.nColor = 0xCC1E1E1E; // ARGB 背景色调 policy.nAnimationId = 0; WINDOWCOMPOSITIONATTRIBDATA data; data.Attrib = 19; // WCA_ACCENT_POLICY data.pvData = &policy; data.cbData = sizeof(policy); fn(m_hwnd, &data); }用毛玻璃时,窗口需要支持透明背景,setAttribute(Qt::WA_TranslucentBackground)必须打开。同时DwmExtendFrameIntoClientArea的MARGINS要改回{ -1, -1, -1, -1 },让整个客户区变成玻璃区域。画布上用半透明色画内容,底层会被 DWM 模糊并混入色调。如果不开透明背景,毛玻璃会退化成一块灰底。
6.2 动态圆角与最大化的状态同步
Win11 上无边框窗口默认带圆角,但最大化时圆角应该消失,否则窗口四角会露出桌面背景,显得很割裂。监听changeEvent判断isMaximized(),动态切换圆角开关。
void FramelessWindow::changeEvent(QEvent *event) { QWidget::changeEvent(event); if (event->type() != QEvent::WindowStateChange) return; DWM_WINDOW_CORNER_PREFERENCE pref; if (isMaximized()) { pref = DWMWCP_DONOTROUND; } else { pref = DWMWCP_ROUND; } DwmSetWindowAttribute(m_hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)); }这个函数在 Win10 上不会生效,系统会直接忽略,不影响逻辑。Win11 上每次状态切换都调用一次,成本极低,但手感提升明显。有一个经验是:设置圆角后如果发现窗口边缘出现 1px 的白线,多半是DwmExtendFrameIntoClientArea的MARGINS值和圆角半径不匹配,把MARGINS调回{ 1, 1, 1, 1 }能解决大部分情况。
我自己的习惯是保留一个reapplyWindowFrame()方法,把样式位、SWP_FRAMECHANGED、DWM margins 全部重新走一遍,任何阴影或边框异常时先调它,十次有八次能救回来。这套方案从 Win7 到 Win11 我都跑过,Win7 上毛玻璃只能退回DwmEnableBlurBehindWindow,其他功能行为一致。无边框窗口的坑多数不在 Qt 而在 Windows 消息的细节里,把WM_NCHITTEST和WM_GETMINMAXINFO吃透,你就已经超越了大多数现成方案。希望帮到你。
本文还有配套的精品资源,点击获取