简介:面向 Qt/C++ 桌面开发者的无边框窗口实战工程,聚焦 Windows 平台下将 Qt 框架与 WINAPI、DWM 接口结合,解决直接使用 FramelessWindowHint 后丢失系统拖拽、缩放和阴影等问题。工程覆盖标题栏图标、边缘缩放、窗口拖拽、Aero 阴影、最大最小化及模糊背景等效果,涉及 WM_NCHITTEST、WM_NCCALCSIZE、WM_GETMINMAXINFO 等消息处理,也包含 DwmExtendFrameIntoClientArea、DwmSetWindowAttribute 等调用,并需关注系统合成开关检测与非客户区绘制,适合桌面客户端界面美化、系统集成方向的中高级开发者参考复用。压缩包共 48 个文件、约 73KB,以 13 个 h 与 12 个 cpp 承载窗口逻辑、浏览器界面及 Win 迁移封装,另有 2 个 ui、9 个 png 及 ico、qrc、pri、pro、css、md 等,兼顾源码、图标资源、工程配置与说明文档。已有 1267 人学习,可作为无边框窗口框架、Aero 风格实现和 WinAPI 混合编程的完整样例,便于对照梳理消息流程、排错思路与可复用组件。
1. 从 FramelessWindowHint 说起:做出 Aero 风格的无边框窗口要补哪些 Win32 消息
产品经理给的需求往往只有一句话:窗口要像系统自带的那样,左上角有图标,能拖能拉,贴到屏幕边自动分屏,还得有阴影和最大最小化动画。可真把Qt::FramelessWindowHint打开,标题栏一没了,剩下的东西也全都没了——Snap 吸附没了,最大化动画没了,DWM 投影没了,连拖到屏幕顶端触发 Aero 分屏预览都没了。这份代码走的是另一条路:不去覆盖 Qt 的绘制,而是保留 Win32 的窗口样式,用nativeEvent接管WM_NCCALCSIZE、WM_NCHITTEST、WM_GETMINMAXINFO这几条关键消息,把系统原生的 Snap 吸附、投影、动画原封不动借过来用。它适合已经能用 Qt Widgets 写界面、又想让桌面端观感接近原生的开发者,纯新手照着跑也能通,但最好先知道HWND是什么。
2. 技术选型与工程骨架:为什么用 nativeEvent 接管,而不是纯 Qt 自绘
2.1 三条常见路线的取舍
做无边框窗口,业内基本就三条路,选错一条后面全是补丁。
第一条是纯 Qt 路线:setWindowFlags(Qt::FramelessWindowHint),标题栏用QWidget自己拼,阴影用QGraphicsDropShadowEffect,拖拽在mouseMoveEvent里算偏移,右下角塞一个QSizeGrip。写起来最快,一天能出原型。代价是:鼠标移到窗口边缘时,光标不会变成缩放箭头,因为系统根本不知道那里有条边;拖动时窗口跟手性差,快速甩动会掉帧;最要命的是 Snap 吸附彻底失效,用户把窗口拖到屏幕顶部,什么都不会发生,他的肌肉记忆直接被破坏。
第二条是 QML / Quick 全自绘,观感可以做到极致,但 Aero 分屏预览、最大化动画这类东西属于窗口管理器(DWM)的活儿,你只能模仿个七八成,而且要多写一套窗口拖拽状态机。
第三条就是这份代码的路子:保留WS_CAPTION | WS_THICKFRAME这些原生样式,让系统以为这是个标准窗口,然后用WM_NCCALCSIZE把非客户区“吃掉”。系统仍然负责投影、吸附、动画、缩放边框的命中判定,你只负责在客户区里画自己的标题栏。观感最接近原生,坑也集中——集中在几条消息的返回值上。
我一般会优先选第三条,除非项目要跨 macOS 和 Linux 同时保持一致观感,那种情况才退回第一条。
2.2 消息流:QWidget、QWindow、HWND 三层怎么对上
很多人卡在第一步:不知道nativeEvent是从哪冒出来的。简单捋一遍。
在 Qt Widgets 里,每个顶层QWidget在第一次需要原生句柄时(创建时、或者调用winId()时)会生成一个QWidgetWindow,它继承自QWindow;QWindow在 Windows 平台上再对应一个真实的HWND。系统的消息先进到 Qt 的平台插件,插件把MSG打包后派发给QWindow,QWindow再回调到QWidget::nativeEvent。
所以这个虚函数是窗口消息的总入口,签名长这样:
// Qt 5:long *result;Qt 6:qintptr *result,移植时这里最容易编译不过 bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override;eventType常见两个值:"windows_generic_MSG"表示发给窗口本身的消息,"windows_dispatcher_MSG"表示线程级消息(比如某些 DPI 变化通知)。绝大多数窗口外观相关的消息走的是前者,所以入口第一件事就是过滤。
返回值语义要记牢:返回true表示这条消息被你处理掉了,Qt 和DefWindowProc都不会再管;返回false表示继续走默认流程。很多人在这里翻车,是因为只return true却没给*result赋值,系统拿到一个未初始化的值,行为就变得很玄学。
2.3 工程骨架与编译
项目结构我建议一开始就拆清楚,别全塞进main.cpp:
AeroFrame/ ├─ CMakeLists.txt ├─ src/ │ ├─ main.cpp │ ├─ aerowindow.h # 无边框主窗口,nativeEvent 总入口 │ ├─ aerowindow.cpp │ ├─ titlebar.h # 自绘标题栏:图标、标题、最小化/最大化/关闭 │ ├─ titlebar.cpp │ └─ win32helper.h # DWM 属性、DPI、显示器信息的小封装 └─ res/ └─ app.ico构建脚本只需要多链两个系统库,dwmapi用来设置投影和圆角,user32里的MonitorFromWindow、GetMonitorInfo用来处理最大化区域:
cmake_minimum_required(VERSION 3.16) project(AeroFrame LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) add_executable(AeroFrame src/main.cpp src/aerowindow.cpp src/aerowindow.h src/titlebar.cpp src/titlebar.h ) target_link_libraries(AeroFrame PRIVATE Qt${QT_VERSION_MAJOR}::Widgets dwmapi # DwmExtendFrameIntoClientArea / DwmSetWindowAttribute user32 # 显示器信息、系统度量 )CMAKE_AUTOMOC一定要开,Q_OBJECT宏没有 moc 处理会在链接期报一堆undefined reference to vtable,这是新手最常见的第一个失败点。编译用 MSVC 或 MinGW 都行,但要在 Windows 上跑,因为整篇代码依赖 Win32 消息,Linux 上这些消息编号根本不会出现。
3. 无边框的骨架:WM_NCCALCSIZE 与 WM_GETMINMAXINFO 的一次性改造
3.1 构造函数里必须补回来的窗口样式
Qt::FramelessWindowHint在 Windows 平台上的实际动作,是把WS_CAPTION和WS_THICKFRAME从窗口样式里摘掉。这两个样式恰恰是 DWM 投影、最大化动画、以及 Snap 吸附的前置条件——没有WS_THICKFRAME,系统压根不认为这个窗口可缩放,拖动到屏幕边缘自然不会有分屏反应。
所以构造函数里要做的是“先把样式加回去,再自己把非客户区吃掉”:
AeroWindow::AeroWindow(QWidget *parent) : QWidget(parent) { setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowMinMaxButtonsHint | Qt::WindowSystemMenuHint); setAttribute(Qt::WA_NativeWindow); // 提前拿到 HWND,避免后面反复触发重建 HWND hwnd = reinterpret_cast<HWND>(winId()); LONG_PTR style = GetWindowLongPtr(hwnd, GWL_STYLE); // 关键:把被 FramelessWindowHint 摘掉的样式补回来 // WS_THICKFRAME 负责缩放边框、DWM 投影和 Snap 吸附 // WS_CAPTION 负责最大化/最小化动画、任务栏缩略图行为 style |= WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX; SetWindowLongPtr(hwnd, GWL_STYLE, style); // 改完样式必须让窗口重新计算一次非客户区,否则新样式要等下一次尺寸变化才生效 SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); }SWP_FRAMECHANGED这个标志容易被漏掉。漏了之后你会看到窗口顶部莫名其妙多出一条白边,或者投影时有时无,因为它还在用旧的窗口框架缓存。SWP_NOMOVE | SWP_NOSIZE保证这次调用只刷新框架、不动位置和大小。
提示:
winId()会强制创建原生窗口,如果你的窗口初始化阶段有大量子控件布局,把它放到构造末尾或者showEvent里会更稳。
3.2 WM_NCCALCSIZE:把标题栏吃掉的正确姿势
WM_NCCALCSIZE是系统在问“这个窗口的客户区应该占多大”。默认实现会把标题栏、边框的高度算进去,然后返回一个缩小的客户区矩形。我们想要客户区等于整个窗口,那就得接管这条消息。
它有两个分支,靠wParam区分:wParam == TRUE时lParam是NCCALCSIZE_PARAMS*(三个矩形,分别代表建议的新窗口矩形、旧窗口矩形、旧客户区矩形);wParam == FALSE时lParam只是一个RECT*,这是窗口创建或样式变化的简化路径。
case WM_NCCALCSIZE: { if (msg->wParam == TRUE) { // 什么都不改,客户区 = 窗口矩形,标题栏被"吃掉" *result = 0; return true; // 吞掉消息,DefWindowProc 不会再补标准标题栏 } break; // wParam == FALSE 的场景交给 Qt 默认处理 }逻辑就三行,但含义要讲透:*result = 0表示客户区计算成功,return true表示不要再让DefWindowProc去调整。如果只赋值不返回true,系统还会在你这版基础上再减一次边框,结果是内容被裁掉一圈。
这里有个必须提前知道的前提:WS_THICKFRAME还在的前提下,窗口最大化时系统会把窗口矩形按边框外扩,因为默认逻辑认为客户区比窗口小一圈。现在客户区等于窗口,外扩量就变成了溢出——内容会顶到屏幕外面,并盖住任务栏。这个问题在 3.3 用WM_GETMINMAXINFO解决,两处只改一处就够。
3.3 WM_GETMINMAXINFO:最大化不能盖住任务栏
WM_GETMINMAXINFO是系统在问“这个窗口最大能多大、最小能多小”。默认答案是基于主显示器的,多屏环境下经常出错。我们在这里做两件事:把最大化目标矩形锁定到当前所在显示器的工作区,并把WS_THICKFRAME造成的外扩量补偿回去。
case WM_GETMINMAXINFO: { auto *mmi = reinterpret_cast<MINMAXINFO *>(msg->lParam); // 关键:用窗口当前所在显示器,而不是默认主显示器 HMONITOR hmon = MonitorFromWindow(msg->hwnd, MONITOR_DEFAULTTONEAREST); MONITORINFO mi{}; mi.cbSize = sizeof(MONITORINFO); if (GetMonitorInfo(hmon, &mi)) { // 最大化时窗口矩形会被按边框外扩,这里先算出外扩量 const int fx = GetSystemMetrics(SM_CXSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER); const int fy = GetSystemMetrics(SM_CYSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER); // rcWork 是排除任务栏后的工作区,rcMonitor 是整个屏幕 mmi->ptMaxPosition.x = mi.rcWork.left - mi.rcMonitor.left - fx; mmi->ptMaxPosition.y = mi.rcWork.top - mi.rcMonitor.top - fy; mmi->ptMaxSize.x = (mi.rcWork.right - mi.rcWork.left) + fx * 2; mmi->ptMaxSize.y = (mi.rcWork.bottom - mi.rcWork.top) + fy * 2; } // 最小尺寸跟着 Qt 的 minimumWidth/minimumHeight 走,避免拖到 0 宽 mmi->ptMinTrackSize.x = qMax<LONG>(mmi->ptMinTrackSize.x, minimumWidth()); mmi->ptMinTrackSize.y = qMax<LONG>(mmi->ptMinTrackSize.y, minimumHeight()); *result = 0; return true; }参数含义拆开看:ptMaxPosition是最大化后窗口左上角相对于屏幕左上角的偏移,用rcWork.left - rcMonitor.left就得到任务栏高度形成的偏移;ptMaxSize是最大化后的窗口尺寸。SM_CXPADDEDBORDER是 Win8 之后给边框额外加的一圈不可见内边距,不算进去的话最大化会差 1~2 像素,表现为边缘露出桌面。
3.4 WM_NCACTIVATE 与 DWM 投影:让阴影和圆角回来
窗口失去焦点时,系统默认会重绘一遍非客户区,把标题栏刷成灰色。我们既然已经把非客户区吃掉了,这一次重绘就变成了纯粹的闪烁。所以直接告诉系统“激活状态没变化”:
case WM_NCACTIVATE: *result = TRUE; // 不去重绘非客户区,避免切换焦点时闪一下 return true;投影和圆角交给 DWM。DwmExtendFrameIntoClientArea的作用是把系统边框“延伸”进客户区,哪怕只延伸 1 像素,DWM 也会给这个窗口配上标准的窗口阴影:
void AeroWindow::applyDwmEffect() { HWND hwnd = reinterpret_cast<HWND>(winId()); // 四周各延伸 1px,DWM 会顺带画出标准投影 MARGINS margins{1, 1, 1, 1}; DwmExtendFrameIntoClientArea(hwnd, &margins); // 告诉 DWM 非客户区仍由系统渲染,拖动时不容易出现白底残影 DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED; DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy)); // Win11 的圆角。常量值 33 在旧 SDK 里没有定义,手写数值更省事 // 1 = 不圆角,2 = 系统默认圆角,3 = 小圆角 const DWORD cornerPref = 2; DwmSetWindowAttribute(hwnd, 33 /* DWMWA_WINDOW_CORNER_PREFERENCE */, &cornerPref, sizeof(cornerPref)); }MARGINS填{1,1,1,1}而不是全 0,是有原因的:全 0 表示关闭延伸,投影也就没了。填 1 像素的代价是窗口四周会出现一条极淡的系统边框,如果你要绝对纯净的圆角,可以只在底部填 1({0,0,0,1}),投影照样会有,但高度会短一点。
注意:一旦打开了
Qt::WA_TranslucentBackground,窗口会变成分层窗口,DWM 投影会直接消失。这两个方案二选一,不能叠加。
4. 命中测试与交互:用 WM_NCHITTEST 手写八向缩放、拖拽和 Snap 吸附
4.1 WM_NCHITTEST 的坐标系与调用频率
WM_NCHITTEST是系统在问“这个坐标点上,鼠标落在了窗口的哪个部位”。返回值决定了后续行为:返回HTLEFT,系统就进入左边框拖拽缩放模式;返回HTCAPTION,系统就进入标题栏拖动模式,Snap 吸附自动生效;返回HTCLIENT,鼠标事件才会正常派发给你的控件。
这条消息有个必须记住的特性:lParam里的坐标是屏幕坐标(物理像素),不是客户区坐标,也不是 Qt 的逻辑坐标。很多人的缩放判定永远不生效,就是拿逻辑坐标去和物理像素的窗口矩形做比较。
// lParam 高 16 位是 y,低 16 位是 x,必须用 GET_X_LPARAM 拆, // 直接强转 short 在负坐标(副屏在主屏左侧)时会拿到错误值 const QPoint globalPos(GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)); const QPoint localPos = mapFromGlobal(globalPos); // 转成 Qt 逻辑坐标,方便和控件几何比较还有一点是性能:这条消息在鼠标每次移动时都会触发,如果你的判定逻辑里塞了GetWindowRect、遍历所有子控件、甚至查数据库,拖动窗口时就会明显掉帧。我一般把窗口矩形和边框宽度缓存在成员变量里,只在WM_SIZE和WM_DPICHANGED时更新。
4.2 八向缩放:边框宽度怎么算才跟手
八个方向(四条边加四个角)的判定顺序很重要:先判角,再判边。反过来的话,鼠标落在左上角时会先命中左边,缩放方向就只有一个轴,用户会觉得角上“拉不动”。
case WM_NCHITTEST: { // 最大化状态下不允许拖拽缩放,直接交回默认处理 if (isMaximized() || IsZoomed(msg->hwnd)) break; const QPoint globalPos(GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)); // 边框宽度用逻辑像素定义,乘上 DPI 缩放才是物理像素, // 否则 200% 缩放的屏幕上,8px 的判定区只剩 4px,鼠标根本点不中 const int b = qRound(m_resizeBorder * devicePixelRatioF()); POINT pt{globalPos.x(), globalPos.y()}; ScreenToClient(msg->hwnd, &pt); // 转成物理像素的客户区坐标 RECT rc; GetClientRect(msg->hwnd, &rc); const bool onLeft = pt.x < b; const bool onRight = pt.x >= rc.right - b; const bool onTop = pt.y < b; const bool onBottom = pt.y >= rc.bottom - b; // 先判四个角 if (onTop && onLeft) { *result = HTTOPLEFT; return true; } if (onTop && onRight) { *result = HTTOPRIGHT; return true; } if (onBottom && onLeft) { *result = HTBOTTOMLEFT; return true; } if (onBottom && onRight) { *result = HTBOTTOMRIGHT; return true; } // 再判四条边 if (onLeft) { *result = HTLEFT; return true; } if (onRight) { *result = HTRIGHT; return true; } if (onTop) { *result = HTTOP; return true; } if (onBottom) { *result = HTBOTTOM; return true; } break; // 其他区域交回 Qt,让子控件正常收鼠标事件 }HTLEFT、HTTOPRIGHT这些常量就是系统的缩放方向标识,返回之后光标形状、拖拽逻辑、松开鼠标后的窗口重排,全部由DefWindowProc完成,你一行都不用写。这也是这套方案最大的红利——缩放行为天然和系统一致。
4.3 标题栏拖拽:HTCAPTION 与 startSystemMove 两条路
拖拽有两条路,各有取舍。
第一条是命中测试路线:在WM_NCHITTEST里判断鼠标是否落在标题栏区域,是就返回HTCAPTION。系统接管后,拖动、Snap 吸附、双击最大化、甚至 Win11 的贴靠布局全都免费获得。但有个硬伤:一旦返回HTCAPTION,这个坐标点上的鼠标事件就不会再派发给 Qt 控件。如果你的标题栏上放了最小化、关闭按钮,按钮就永远点不动。
解决办法是让标题栏自己做命中判定,把按钮区域排除出去:
// TitleBar 里维护一份"可拖动区域",按钮所在的矩形不参与 bool TitleBar::isCaptionArea(const QPoint &localPos) const { // 遍历标题栏上的交互控件(图标、三个按钮) for (QWidget *w : m_interactiveWidgets) { if (w->geometry().contains(localPos)) return false; // 落在按钮上,交给控件自己处理 } return rect().contains(localPos); }然后WM_NCHITTEST里把全局坐标转成标题栏的局部坐标再问一次:
if (m_titleBar && !m_titleBar->isCaptionArea(m_titleBar->mapFrom(this, localPos))) { // 落在按钮上,什么都不返回,让 Qt 正常派发点击 } else if (m_titleBar && m_titleBar->geometry().contains(localPos)) { *result = HTCAPTION; return true; }第二条是事件路线,适合不想维护命中测试的场景:在标题栏的mousePressEvent里手动触发系统的移动循环。
void TitleBar::mousePressEvent(QMouseEvent *e) { if (e->button() != Qt::LeftButton) { QWidget::mousePressEvent(e); return; } // 先释放 Qt 自己的鼠标捕获,否则系统的移动循环收不到鼠标松开 ReleaseCapture(); SendMessage(reinterpret_cast<HWND>(window()->winId()), WM_NCLBUTTONDOWN, HTCAPTION, 0); }这个写法看起来更短,Snap 吸附同样可用,但SendMessage会阻塞到用户松开鼠标为止,期间 Qt 的事件循环是被占住的。如果你有定时器在跑动画,这段时间会冻住。Qt 5.15 之后还有个更干净的替代:window()->windowHandle()->startSystemMove(),跨平台可用,Windows 上同样触发系统的移动循环,但不会阻塞事件循环。我现在的项目基本都用这个。
4.4 Snap 吸附、双击最大化与 Win11 布局预览
Snap 吸附不需要额外代码,它就是HTCAPTION的副产品。用户把窗口拖到屏幕顶部,系统检测到标题栏拖动,自动弹出贴靠预览,松手后窗口占半屏——这就是标题里 Aero 风格的核心体验,用纯 Qt 自绘是模拟不出来的。
双击标题栏最大化同理,属于HTCAPTION的默认行为。如果你想自定义(比如双击后只放大不铺满),可以在标题栏的mouseDoubleClickEvent里拦截,但要注意拦截后系统行为就失效了。
Win11 还多了一个东西:鼠标悬停在最大化按钮上会弹出贴靠布局选择器。这个默认不生效,需要在WM_NCHITTEST里把按钮区域返回成HTMAXBUTTON:
// Win11 才有的常量,旧 SDK 里没有,手写值即可 #ifndef HTMAXBUTTON #define HTMAXBUTTON 9 #endif if (m_titleBar && m_titleBar->maxButtonRect().contains(localPos)) { *result = HTMAXBUTTON; return true; }返回之后,最大化按钮就不再走 Qt 的点击逻辑了,你需要额外处理WM_NCLBUTTONDOWN里wParam == HTMAXBUTTON的按下,以及WM_NCMOUSEMOVE的离开,才能在用户点击时正确最大化。如果项目对 Win11 布局预览没强需求,我建议先不做——它带来的状态机复杂度不低,容易把点击最大化搞坏。
4.5 完整的 nativeEvent 拼装
把前面几块拼起来,入口长这样:
bool AeroWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result) { // 只处理发往窗口本身的消息,线程消息交回 Qt if (eventType != "windows_generic_MSG") return QWidget::nativeEvent(eventType, message, result); auto *msg = static_cast<MSG *>(message); switch (msg->message) { case WM_NCCALCSIZE: return handleNcCalcSize(msg, result); case WM_NCACTIVATE: *result = TRUE; return true; case WM_GETMINMAXINFO: return handleGetMinMaxInfo(msg, result); case WM_NCHITTEST: return handleNcHitTest(msg, result); case WM_DPICHANGED: return handleDpiChanged(msg, result); default: break; } // 没处理的消息一定交回父类,否则 Qt 的内部状态会和自己打架 return QWidget::nativeEvent(eventType, message, result); }这里的分发结构建议一开始就按消息类型拆成独立成员函数。我见过把几百行switch全塞在nativeEvent里的版本,加一个 Win11 特性就要在中间翻半天,改错一个return整个窗口的拖拽就废了。
WM_DPICHANGED单独处理的原因:窗口被拖到不同缩放比例的显示器时,系统会发这条消息并附带一个建议矩形,Qt 自己也会处理,但我们的边框判定宽度依赖 DPI,必须在收到消息后刷新缓存的m_resizeBorder。
5. 常见问题排查:无边框窗口最容易被忽略的五个坑
5.1 布局与最大化相关的坑
坑一:最大化之后内容溢出屏幕,或者整体缩进了 8 像素。
现象是窗口最大化后,底部内容被任务栏压住看不到,或者四周出现一圈奇怪的空白。原因只有一个:WS_THICKFRAME的外扩补偿做了两次或者一次都没做。WM_NCCALCSIZE里在IsZoomed时收缩客户区、WM_GETMINMAXINFO里调整ptMaxSize,这是两种并行的解法,只能选一种。两个都写就是双重补偿,内容缩进两圈;两个都不写,内容就溢出去。排查方法很简单:在WM_GETMINMAXINFO里打个日志,看ptMaxSize是不是正好等于工作区尺寸,如果等于工作区还溢出,说明补偿量没加。
坑二:多显示器环境下,最大化跑到主屏去了。
现象是窗口放在副屏上点最大化,窗口跳回主屏并铺满。原因是用了MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY),或者干脆用了GetSystemMetrics(SM_CXSCREEN)取屏幕尺寸。这两个 API 拿到的永远是主显示器。改成MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)配合GetMonitorInfo,并注意ptMaxPosition是相对当前显示器的偏移,不是相对虚拟桌面原点。
5.2 交互与视觉相关的坑
坑三:标题栏上的按钮点不动,或者点一下窗口就跟着拖走了。
现象是点击关闭按钮没反应,但拖拽窗口是正常的。原因是WM_NCHITTEST对整个标题栏区域无脑返回了HTCAPTION,按钮的点击事件在系统层就被吃掉了,根本传不到 Qt。解决思路就是 4.3 里的isCaptionArea:先把按钮矩形排除,落在按钮上的坐标不返回HTCAPTION,让它自然落到HTCLIENT。判断顺序不能反,必须先问控件再问标题栏。
坑四:高 DPI 屏幕上,缩放边框“点不中”,或者光标变了但拖不动。
现象是在 150% 缩放的笔记本上,鼠标明明贴着窗口边缘,光标却不变成缩放箭头。原因是边框判定用了逻辑像素(比如 8),而WM_NCHITTEST拿到的是物理像素坐标,150% 缩放下 8 逻辑像素只有 12 物理像素的判定区,扣掉系统边框本身占的部分就所剩无几。解决是判定时乘devicePixelRatioF(),并且用GetSystemMetricsForDpi(SM_CXSIZEFRAME, GetDpiForWindow(hwnd))取当前 DPI 下的边框值,而不是全局的GetSystemMetrics——后者在每显示器 DPI 感知的进程里返回的是系统 DPI,不等于窗口所在显示器的 DPI。
坑五:拖动窗口时出现白底残影,或者拖动过程中窗口闪黑。
现象是拖拽时窗口内容短暂变白,松手才恢复。原因通常是打开了Qt::WA_TranslucentBackground又同时调了DwmExtendFrameIntoClientArea。分层窗口会关闭 DWM 的非客户区渲染,扩展边框的那 1 像素变成未绘制区域,拖动时合成器拿不到内容就只能给白色。解法是二选一:要么关掉透明背景,用 DWM 原生投影;要么保留透明背景,自己用QGraphicsDropShadowEffect在窗口内部留出 1 像素透明边距画阴影,同时把DwmExtendFrameIntoClientArea的MARGINS全部置 0。
注意:改动
MARGINS之后必须重新调用一次SetWindowPos带上SWP_FRAMECHANGED,否则新的框架设置不会立即应用到已存在的窗口上。
6. 二次开发:把散落的消息处理收进一个可复用基类
当第二个、第三个窗口都需要无边框时,把nativeEvent里的东西复制粘贴三份是最省事也最痛苦的决定。我的做法是抽一个AeroWidget基类,只暴露三个可配置点,其余全部封死在基类里:
| 配置点 | 类型 | 默认值 | 用途 |
|---|---|---|---|
resizeBorder | int(逻辑像素) | 8 | 缩放热区宽度,小窗口可以调到 5 |
captionProvider | 回调 / 虚函数 | 返回整条标题栏 | 告诉基类哪块区域算标题栏 |
dwmShadowEnabled | bool | true | 关掉后自动切换成内部阴影方案 |
本文还有配套的精品资源,点击获取