简介:围绕Qt与WinAPI混合编程的无边框窗口示例工程,面向希望实现Win7 AERO视觉与完整系统交互的桌面开发者,也适合作为Qt事件处理、窗口消息机制的进阶练习。工程重点解决去除系统边框后仍保留图标显示、窗口缩放、拖拽、阴影、最大最小化及贴边(Snap)等行为的问题,并将DWM非客户区渲染、模糊背景与自定义标题栏按钮整合到统一窗口框架中。压缩包共48个文件,约73KB,包含13个头文件和12个cpp源码,以及2个ui界面文件、qrc资源表、png/ico/svg图标素材和pro/pri工程配置,另附css样式、ini配置与说明文档,便于直接编译并对照消息处理与界面资源组织。已有1267人学习下载,适合中高级Qt开发者参照。读者可从中获得无边框窗口的完整实现骨架,包括命中测试、非客户区计算、图标设置、DWM阴影与模糊调用等关键代码,也能借鉴自定义最大化/最小化、窗口拖动和样式资源分离的组织方式,用于排查实际项目中的窗口交互与AERO效果问题。
1. 先别急着 setWindowFlags:QT 无边框窗口在 WINAPI 里到底缺了哪几块拼图
很多人第一次做 QT 无边框窗口,都是从一行setWindowFlags(Qt::FramelessWindowHint)起步的,接着三件事同时出问题:窗口拖到屏幕边缘不触发 snap 分屏,最大化后四边被任务栏压住,任务栏图标和最小化动画还时不时丢。这不是 QT 的问题,FramelessWindowHint只做了一件事——把WS_CAPTION从窗口样式里摘掉,剩下的 AERO 风格行为全落在 Win32 窗口过程里:阴影归 DWM 管,贴边归WM_NCHITTEST管,最大化的几何归WM_NCCALCSIZE管。QT 负责画,WINAPI 负责窗口行为,两层必须在 HWND 上重新对接一次。这套方案覆盖图标、缩放、拖拽、阴影、最大最小化和 AERO 风格,适合已经能用 QT 摆布局、但被无边框窗口的边角料磨过一轮的桌面端开发者。
2. 窗口骨架:从 QWidget 到 HWND 的三个对接点
2.1 三个必须最先关掉的默认行为
在写任何 Win32 消息处理之前,有三件事必须先定下来,否则后面所有调试都是在错误的地基上打补丁。
第一,窗口类型只留Qt::Window,别用Qt::Tool或Qt::Popup。Qt::Tool在 Windows 上会被当作工具窗口,任务栏不显示图标,最小化动画也没有,等到你发现任务栏里找不到程序时已经改了一堆代码。第二,不要打开Qt::WA_TranslucentBackground。很多人开它就是为了自己画圆角和阴影,结果是窗口变成分层窗口(layered window),DWM 不再给你画系统阴影,DwmExtendFrameIntoClientArea的效果也会被吃掉。阴影这件事交给 DWM,比自己用 QPainter 画一圈模糊便宜得多,也真实得多。第三,别在构造函数里就急着读winId()去做 DWM 设置——那时窗口还没真正创建,句柄可能变。稳妥的做法是打开WA_NativeWindow强制提前创建,然后在showEvent之后再做 DWM 相关调用。
// mainwindow.cpp —— 窗口初始化,顺序很关键 #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include <windows.h> #include <dwmapi.h> #pragma comment(lib, "dwmapi.lib") MainWindow::MainWindow(QWidget *parent) : QWidget(parent) { // 1) 只保留 Window + Frameless,别混入 Tool / Popup setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowMinMaxButtonsHint); // 2) 强制提前创建原生窗口,后面拿 HWND 才稳定 setAttribute(Qt::WA_NativeWindow); // 3) 不要开半透明背景,阴影交给 DWM setAttribute(Qt::WA_TranslucentBackground, false); // 4) 减少重绘闪烁 setAttribute(Qt::WA_OpaquePaintEvent, true); }这段代码里Qt::WindowMinMaxButtonsHint看起来对无边框窗口没意义,但它决定了系统菜单里“最大化/最小化”项是否存在,也影响WM_SYSCOMMAND的行为,建议保留。WA_OpaquePaintEvent是减少重绘闪烁的常用手段,代价是你得保证paintEvent里把整个矩形填满,否则会留下残影——这一点在自绘标题栏和内容区背景色不一致时特别容易翻车。
真正的对接点在nativeEvent。QT 会把它收到的每一条 Windows 消息原样转发过来,你在这里处理WM_NCCALCSIZE、WM_NCHITTEST、WM_NCACTIVATE、WM_GETMINMAXINFO这几条就够了,其余的交给 QT 默认逻辑。注意函数签名在 QT5 和 QT6 之间有差异:QT5 是long *result,QT6 是qintptr *result,跨版本编译时这是第一个会报错的地方。
2.2 WM_NCCALCSIZE:把整块非客户区“吃掉”
无边框窗口最核心的一条消息就是WM_NCCALCSIZE。它决定客户区(client area)在窗口矩形里占多大。默认情况下 Windows 会把标题栏、边框这些非客户区扣掉,客户区是剩下的中间那块;我们要做的是让客户区等于整个窗口矩形,边框和标题栏全部由 QT 自己画。
这条消息有个容易忽略的细节:wParam == TRUE时,lParam指向的是NCCALCSIZE_PARAMS结构,里面rgrc[0]是建议的新窗口矩形,rgrc[1]是旧窗口矩形,rgrc[2]是旧客户区;wParam == FALSE时lParam只是一个RECT*,表示客户区。我们只关心TRUE的分支。
bool MainWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result) { Q_UNUSED(eventType); MSG *msg = static_cast<MSG *>(message); switch (msg->message) { case WM_NCCALCSIZE: { if (msg->wParam != TRUE) break; // FALSE 分支交给默认处理 NCCALCSIZE_PARAMS *p = reinterpret_cast<NCCALCSIZE_PARAMS *>(msg->lParam); if (IsZoomed(msg->hwnd)) { // 最大化:客户区对齐到显示器工作区,避开任务栏 MONITORINFO mi = { sizeof(MONITORINFO) }; GetMonitorInfo(MonitorFromWindow(msg->hwnd, MONITOR_DEFAULTTONEAREST), &mi); p->rgrc[0] = mi.rcWork; } // 还原态:不改 rgrc[0],客户区就等于整个窗口矩形 *result = 0; // 0 表示客户区已由我们算好 return true; } } return QWidget::nativeEvent(eventType, message, result); }还原态下“不改rgrc[0]”这句话是整段的关键。rgrc[0]进入时已经是窗口矩形,原样返回、*result = 0,Windows 就认为客户区铺满整个窗口,非客户区宽度为零。如果你手贱把它设成GetWindowRect拿到的值,在缩放过程中会因为消息时序问题拿到旧矩形,窗口边缘会出现一条撕不开的缝。
最大化分支则必须换成mi.rcWork。默认的最大化矩形是显示器全尺寸rcMonitor,会盖住任务栏;用工作区矩形才能让窗口“贴着任务栏上沿”。这里还有一个 Win10 之后的老坑:最大化时 Windows 会额外给窗口加一圈约 8 像素的不可见边框,如果不做这一步修正,用户看到的就是窗口四边各少一截,或者任务栏被压住一半。
2.3 DwmExtendFrameIntoClientArea 与阴影预留的 1 像素
非客户区被吃干净之后,DWM 就不知道该怎么给你画阴影了——它默认只给有标准边框的窗口投影。解决办法是调用DwmExtendFrameIntoClientArea,把窗口框架向客户区“延伸”一点点,DWM 就会沿着这条延伸线画系统阴影。
参数是MARGINS结构,网上流传的写法五花八门,{1,1,1,1}、{-1,-1,-1,-1}、{0,0,0,0}都有人用,但行为完全不同。{-1,-1,-1,-1}是 Aero 玻璃时代的“整窗玻璃”,Win8 之后基本失效;{0,0,0,0}等于关闭延伸,阴影也没了;{1,1,1,1}是现在最稳的写法——四周各延伸 1 像素,既让 DWM 画出阴影,又不会在客户区里留下可见的边框线。
void MainWindow::setupDwmShadow() { HWND hwnd = reinterpret_cast<HWND>(winId()); // 四周各延伸 1 像素,DWM 沿这条线画系统阴影 MARGINS margins = { 1, 1, 1, 1 }; DwmExtendFrameIntoClientArea(hwnd, &margins); // 通知 DWM 重新计算非客户区,否则要等下一次 resize 才生效 SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); // Win11 圆角:不设的话无边框窗口是直角,和系统风格不搭 enum { DWMWA_WINDOW_CORNER_PREFERENCE_ = 33 }; enum { DWMWCP_ROUND_ = 2 }; DWORD corner = DWMWCP_ROUND_; DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE_, &corner, sizeof(corner)); }SWP_FRAMECHANGED这一句容易被漏掉。DwmExtendFrameIntoClientArea只是改了一个属性,窗口不会立刻重算非客户区,表现是“代码明明调了,阴影就是不出现,拖动一下窗口才出来”。加上SWP_FRAMECHANGED强制触发一次WM_NCCALCSIZE就好了。
Win11 的圆角枚举在 SDK 头文件里叫DWMWA_WINDOW_CORNER_PREFERENCE,如果你的编译环境 SDK 版本偏旧、头文件里没有这个符号,就按上面代码那样自己定义常量值,比升级 SDK 省事。注意圆角只影响视觉,命中测试的矩形还是方的,圆角附近的点击会有几像素的“死角”,这个后面在排查章里还会提到。
3. Aero Snap 与拖拽缩放:让 WM_NCHITTEST 说实话
3.1 命中测试的返回码与边框宽度的 DPI 换算
窗口能不能拖、能不能缩放、能不能贴边,全看WM_NCHITTEST返回什么。Windows 在鼠标移动时(非客户区跟踪模式下)会不断发这条消息问你:“这个坐标点落在窗口的哪个部位?”你返回部位代码,系统就执行对应行为——返回HTLEFT系统自己去改宽度,返回HTCAPTION系统自己去拖窗口。这就是无边框窗口能保留原生行为的关键:不是你去模拟,而是你告诉系统“这里还是标题栏”。
| 返回码 | 覆盖区域 | 系统行为 |
|---|---|---|
HTLEFT/HTRIGHT | 左右边框条 | 水平缩放,光标变双向箭头 |
HTTOP/HTBOTTOM | 上下边框条 | 垂直缩放 |
HTTOPLEFT/HTBOTTOMRIGHT等 | 四个角 | 双向等比缩放 |
HTCAPTION | 标题栏空白区 | 拖拽、双击最大化、贴边 snap |
HTCLIENT | 内容区、按钮 | 消息正常派发给 QT |
HTNOWHERE | 不该命中的地方 | 鼠标事件穿透 |
判断顺序很重要:按钮区域必须先判,再判边框,最后判标题栏。如果反了,落在标题栏上的按钮会被当成HTCAPTION,点下去变成拖窗口,按钮永远点不动——这个坑我在第一次做自绘标题栏时踩了整整一个下午。
边框宽度不能写死成 6 像素。GetWindowRect和lParam里的坐标都是物理像素,而你的设计稿是按逻辑像素给的,在 150% 缩放的屏幕上直接写 6 会让可拖拽区域缩水到 4 个物理像素,手感极差。
case WM_NCHITTEST: { const POINT pt = { GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam) }; RECT rc; GetWindowRect(msg->hwnd, &rc); // 1) 自绘按钮优先:落到按钮上就交还给 QT const QPoint gpt(pt.x, pt.y); if (m_titleBar && m_titleBar->hitTestButtons(gpt)) { *result = HTCLIENT; return true; } // 2) 边框命中:最大化状态下不提供缩放 if (!IsZoomed(msg->hwnd)) { const int bw = qRound(kResizeBorder * devicePixelRatioF()); const bool left = pt.x < rc.left + bw; const bool right = pt.x >= rc.right - bw; const bool top = pt.y < rc.top + bw; const bool bottom = pt.y >= rc.bottom - bw; if (top && left) { *result = HTTOPLEFT; return true; } if (top && right) { *result = HTTOPRIGHT; return true; } if (bottom && left) { *result = HTBOTTOMLEFT; return true; } if (bottom && right) { *result = HTBOTTOMRIGHT; return true; } if (left) { *result = HTLEFT; return true; } if (right) { *result = HTRIGHT; return true; } if (top) { *result = HTTOP; return true; } if (bottom) { *result = HTBOTTOM; return true; } } // 3) 标题栏区域 const int titleH = qRound(m_titleBar->height() * devicePixelRatioF()); if (pt.y < rc.top + titleH) { *result = HTCAPTION; return true; } *result = HTCLIENT; return true; }devicePixelRatioF()取的是窗口所在屏幕的缩放比。这里有个细节:devicePixelRatioF()是 QT 给的主屏比例,窗口跨屏拖动时它未必跟着变,所以更严谨的写法是用GetDpiForWindow(msg->hwnd) / 96.0现算。两种写法在单屏场景下没差别,多屏混合 DPI 场景下后者才准,第 5 章会专门说这个。
3.2 为什么标题栏必须返回 HTCAPTION 而不是自己 mouseMove
这是整篇最关键的一条经验。网上大量 QT 无边框教程教你重写mousePressEvent/mouseMoveEvent,记录按下点和窗口位置,然后move()。这种写法能拖动,但会同时丢掉四样东西:Aero Snap 贴边分屏、拖到屏幕顶端最大化、双击标题栏最大化/还原、以及窗口最大化状态下拖拽还原并跟随鼠标。你可以在代码里把这四样全部手写一遍,但每一样都有边界情况——贴边分屏还要判断多显示器边界,拖拽还原还要算鼠标在标题栏里的相对位置比例,写完之后大概三百行,还容易在缩放比例变化时错位。
返回HTCAPTION就全部免费。系统收到这个返回码后,会接管鼠标,走的是和资源管理器窗口完全相同的代码路径:拖到边缘出半屏预览,拖到顶部出最大化提示,双击等于按标题栏,最大化状态下按住标题栏往下拖会自动还原并让鼠标保持在标题栏的相对位置。这套行为是操作系统的原生实现,你的程序天然和系统里其他窗口一致。
代价是标题栏区域里的 QT 控件收不到鼠标事件——这也是为什么按钮区域必须在WM_NCHITTEST里提前排除、返回HTCLIENT。我的做法是给标题栏控件加一个hitTestButtons(QPoint)方法,内部用mapToGlobal(rect())把每个按钮的全局矩形算出来做包含判断,把所有按钮合并成一个QRegion一次判断,鼠标移动时开销可以忽略。
还有一个隐蔽的坑:如果你的标题栏容器本身设了setMouseTracking(true),QT 会持续跟踪鼠标,但WM_NCHITTEST在HTCAPTION区域已经把事件拦走了,QT 层的跟踪其实是空的,别指望靠它实现悬停高亮,老老实实把按钮拆成独立子控件。
3.3 WM_GETMINMAXINFO 与最大化几何修正
无边框窗口的“最小尺寸”在 QT 层用setMinimumSize()设,理论上就够了,但实际会失效——因为WM_GETMINMAXINFO走的是 Windows 自己的计算路径,DefWindowProc会用窗口样式推一个默认最小值,某些 DPI 下比你在 QT 里设的还大,表现为“明明设了 600×400,拖到 800 就拖不动了”。
case WM_GETMINMAXINFO: { MINMAXINFO *mmi = reinterpret_cast<MINMAXINFO *>(msg->lParam); const qreal dpr = devicePixelRatioF(); mmi->ptMinTrackSize.x = qRound(minimumWidth() * dpr); mmi->ptMinTrackSize.y = qRound(minimumHeight() * dpr); *result = 0; return true; }这里同样要用物理像素。ptMinTrackSize的单位是物理像素,直接塞minimumWidth()在 200% 缩放下会让最小值变成设计值的一半。
最大化几何在第 2 章的WM_NCCALCSIZE里已经用mi.rcWork修过一次,但还有第二种情况:窗口从最大化还原时,Windows 会把窗口恢复到最大化之前的矩形,如果那个矩形是在另一块屏幕上记录的、或者那块屏幕已经被拔掉了,还原后窗口会跑到屏幕外面。稳妥的补充处理是在WM_SYSCOMMAND里拦截SC_RESTORE,还原前检查一次目标矩形是否和任一显示器工作区有交集,没有就居中到主屏。这段逻辑我一般放在 QT 层用QGuiApplication::screens()遍历判断,比在 Win32 里写更清楚。
4. 自绘标题栏:图标、最小化按钮与 AERO 视觉状态的联动
4.1 图标、按钮的布局与 DPI 对齐
标题栏做成一个独立的QWidget,高度按逻辑像素给 40,内部用QHBoxLayout排:左边图标 + 标题文字,右边最小化/最大化/关闭三个按钮。按钮不要用QPushButton,用QToolButton加setAutoRaise(true),默认样式接近系统 caption 按钮,改起来也方便。
图标这一块要注意两件事。第一,窗口图标要同时设两处:setWindowIcon()管 QT 内部(比如任务栏预览、Alt+Tab 列表),WM_SETICON管标题栏和任务栏的位图缓存。只设前者的话,任务栏图标有时是空的,特别是程序启动后第一次最小化再恢复的时候——这是 Windows 的图标缓存机制导致的,补一条WM_SETICON就稳定了。第二,图标尺寸要按 DPI 准备多套,Windows 在 125%/150% 下会找 20/24 像素的图标,只给 16 和 32 会触发位图缩放,边缘发虚。
void MainWindow::applyWindowIcon(const QIcon &icon) { setWindowIcon(icon); // QT 侧:Alt+Tab、任务栏预览 HWND hwnd = reinterpret_cast<HWND>(winId()); const int dpi = GetDpiForWindow(hwnd); // 需要 Win10 1607+ const int cx = MulDiv(16, dpi, 96); // 按 DPI 换算图标边长 HICON hi = icon.pixmap(cx, cx).toImage().toHICON(); SendMessage(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(hi)); SendMessage(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(hi)); }toHICON()是 QT5.15 之后才有的便捷方法,早期版本得用QImage手动构造HBITMAP再CreateIconIndirect,麻烦且容易泄漏,建议按最低 QT 版本决定要不要写这一层。图标句柄的所有权在 Windows 侧,重复调用WM_SETICON时旧句柄不会自动释放,频繁换图标的场景(比如根据主题切换)要自己DestroyIcon,否则每次切换漏一个 GDI 对象,跑一天能到几万个。
4.2 激活态与非激活态:WM_NCACTIVATE 的状态同步
系统标题栏在窗口失焦时会变成灰色,自绘标题栏也得跟着变,否则用户切到别的窗口,你的标题栏还亮着,视觉上很别扭。判断依据是WM_NCACTIVATE:wParam == TRUE表示获得焦点,FALSE表示失去焦点。
这条消息有个默认行为要改:DefWindowProc处理WM_NCACTIVATE时会重绘非客户区,在无边框窗口上会引发整条标题栏闪一下,尤其是快速 Alt+Tab 切换时特别明显。标准做法是返回TRUE但不让它重绘。
case WM_NCACTIVATE: { const bool active = (msg->wParam == TRUE); if (m_titleBar) m_titleBar->setActive(active); // 切换标题栏配色 *result = TRUE; // 告诉系统"我已处理",阻止默认重绘 return true; }*result = TRUE和return true要一起给。只return true不给 result,系统拿到的返回码是未初始化的栈值,行为随机——有时候标题栏不刷新,有时候整个窗口重绘。
激活态配色我一般做两套:激活时文字用#1a1a1a、按钮悬停背景用半透明黑;失活时文字降到#8a8a8a、按钮悬停几乎不变色。这套颜色跟随系统深浅色主题的话,可以用QGuiApplication::styleHints()->colorScheme()判断,再在WM_SETTINGCHANGE里监听ImmersiveColorSet变化重新取色。不放宽这块也行,深色主题下浅色标题栏确实会显得突兀,属于体验上的最后 10%。
4.3 系统菜单、右键菜单与任务栏图标的补充处理
Qt::WindowSystemMenuHint加了之后,在标题栏上按 Alt+空格、或者右键标题栏,系统期望弹出窗口系统菜单。但因为我们把非客户区吃掉了,WM_NCRBUTTONUP根本不会发到标题栏上——事件走的是HTCLIENT路径。
处理办法是在标题栏控件的contextMenuEvent里主动发一次:
void TitleBar::contextMenuEvent(QContextMenuEvent *e) { // 手动拉起系统菜单,锚点用全局坐标 HWND hwnd = reinterpret_cast<HWND>(window()->winId()); const QPoint g = e->globalPos(); SendMessage(hwnd, WM_SYSCOMMAND, SC_MOUSEMENU, MAKELPARAM(g.x(), g.y())); e->accept(); }SC_MOUSEMENU会让系统在指定坐标弹出标准的还原/移动/大小/最小化/最大化/关闭菜单,项的状态跟着窗口当前状态走,不用自己维护。用SC_KEYMENU就是 Alt+空格的效果,坐标随便填。
任务栏图标还有一处要补:如果程序有多个顶层窗口,Windows 默认可能把每个都当成独立任务栏项,或者合并成一组但图标取第一个窗口的。要想固定用主窗口图标做分组图标,得设AppUserModelID,在main()里SetCurrentProcessExplicitAppUserModelID(L"YourApp.Id")一次即可。这个 ID 是字符串,随便取但要全进程唯一,改了就相当于换了一个新的任务栏分组,用户原来固定的快捷方式会失效,所以第一次发布前就要定下来。
5. 排查清单:黑边、闪白、Snap 失灵的五个现场
5.1 最大化后四边被任务栏压住或多出黑边
现象:点最大化按钮,窗口铺满整块屏幕,底部任务栏被盖住,或者反过来,窗口四边各露出一条和内容区颜色不一致的窄边。
原因有两个层次。第一层是WM_NCCALCSIZE里没处理最大化分支,客户区还是按窗口矩形算,而窗口矩形是rcMonitor全屏,自然压任务栏。第二层更隐蔽:即使改成了rcWork,如果WM_NCCALCSIZE的wParam判断写成了if (msg->wParam)而不是if (msg->wParam == TRUE),FALSE分支(lParam是RECT*)也会进到同一段代码里被当成NCCALCSIZE_PARAMS解析,读到的是垃圾指针,行为完全随机。
解决:严格按wParam == TRUE分流,最大化分支里用MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)取当前显示器,再GetMonitorInfo拿rcWork。多显示器场景下千万不要用主屏的rcWork,窗口在副屏最大化时会跑到主屏去。
5.2 窗口创建瞬间闪白、拖动时撕裂
现象:程序启动的零点几秒里,窗口先闪一个白色矩形再变成正常内容;或者拖动窗口时边框处有一条白线闪烁。
原因:QT 的默认背景填充和 Windows 的窗口擦除在打架。窗口刚创建时系统会先发一次WM_ERASEBKGND,用默认画刷填白,然后 QT 才跟上画内容。拖动时撕裂则是WS_EX_COMPOSITED没开,DWM 合成和 QT 重绘之间有一个时间窗。
解决:nativeEvent里拦截WM_ERASEBKGND直接返回 1(表示“我已经擦过了”),同时在 QT 侧保证paintEvent覆盖整个窗口矩形。这一步对性能影响不大,因为 DWM 本来就会合成,重复擦除纯属浪费。
case WM_ERASEBKGND: *result = 1; // 告诉系统:擦除已处理,跳过默认白底 return true;如果闪白还在,检查是不是构造函数里设了setStyleSheet且带背景色——样式表在窗口显示后异步应用,第一帧还是白底。改成在paintEvent里手动fillRect一次,或者用setAutoFillBackground(true)配QPalette提前把背景色定死。
5.3 Snap 贴边失效、双击标题栏不还原
现象:窗口能拖,但拖到屏幕边缘没有半屏预览;双击标题栏没反应,或者双击之后窗口变大了但没贴到工作区。
原因八成是标题栏区域没有返回HTCAPTION,而是走了mouseMoveEvent里自己move()。系统根本没收到“这里是标题栏”的信号,自然不会有 snap 和双击行为。另一种可能是返回了HTCAPTION,但WM_NCCALCSIZE把客户区算错了,导致rc.top + titleH这个判断阈值落到了窗口外面,整个标题栏都被判成HTCLIENT。
解决:先确认WM_NCHITTEST里能走到HTCAPTION分支——在分支里打一行qDebug()看鼠标移到标题栏时有没有输出。如果有输出但 snap 还是没有,检查是不是在别处调用了SetWindowLongPtr(GWL_STYLE, ...)把WS_THICKFRAME去掉了;WS_THICKFRAME是无边框窗口保留 snap 和缩放能力的必要条件,Qt::FramelessWindowHint不会动它,但有人为了让窗口“更干净”手动清了样式,之后所有原生行为一起消失。
5.4 拖到第二块屏幕后缩放边距全部错位
现象:主屏 150% 缩放、副屏 100% 缩放,窗口从主屏拖到副屏,边框的可拖拽区域突然变窄一半,或者变宽到 9 像素,手感完全不对。
原因:devicePixelRatioF()返回的是窗口所属屏幕的比例,但 QT 在跨屏拖动时不会立即更新这个值,存在一到两帧的滞后;另外WM_NCHITTEST是高频消息,每次都调devicePixelRatioF()也会拿到过期的缓存。
解决:在WM_NCHITTEST内部改用GetDpiForWindow(msg->hwnd) / 96.0现算,这个 API 直接问 Windows 当前窗口所在显示器的 DPI,没有缓存滞后。GetDpiForWindow需要 Win10 1607 以上,更老的系统用GetDpiForMonitor配MonitorFromWindow兜底。
static double dpiScaleOf(HWND hwnd) { const UINT dpi = GetDpiForWindow(hwnd); // Win10 1607+ return dpi ? dpi / 96.0 : 1.0; }顺带提一句,如果程序声明了 per-monitor DPI aware(manifest 里的dpiAwareness设PerMonitorV2),Windows 会在窗口跨屏时发WM_DPICHANGED,你可以在那里同步更新标题栏高度、按钮尺寸和图标。没声明的话系统会做位图拉伸,缩放边距虽然对了,但整个界面是模糊的,属于另一种翻车。
5.5 任务栏图标丢失或最小化动画消失
现象:程序跑起来任务栏里没有图标,只有一个空白占位;或者点最小化,窗口“啪”地消失,没有那个缩到任务栏的动画。
原因:任务栏图标丢失通常是WM_SETICON没设或者设的HICON尺寸不对(比如给了 32×32 却标成ICON_SMALL);最小化动画消失则多半是窗口样式里混进了WS_EX_TOOLWINDOW——QT 的Qt::Tool会自动带这个扩展样式,一旦带上,系统就把它当工具窗口,不给任务栏项也不给最小化动画。
解决:确认窗口 flags 里没有Qt::Tool或Qt::Popup,然后检查GetWindowLongPtr(hwnd, GWL_EXSTYLE)的结果里有没有WS_EX_TOOLWINDOW,有就SetWindowLongPtr清掉再SetWindowPos(SWP_FRAMECHANGED)。图标方面按 4.1 的做法,ICON_SMALL和ICON_BIG都设,尺寸按 DPI 换算,别图省事两个都用 32。
6. 收进一个 NativeFrameHelper:把消息收敛成可复用类
上面这些逻辑如果全堆在MainWindow::nativeEvent里,第二个窗口要用的时候就得复制一遍,改一处漏一处是迟早的事。我一般的做法是抽一个NativeFrameHelper,构造时传宿主QWidget*,内部装一个事件过滤器接管nativeEvent,把边框宽度、标题栏高度、按钮命中回调做成可配置项。
class NativeFrameHelper : public QObject { Q_OBJECT public: explicit NativeFrameHelper(QWidget *host, QObject *parent = nullptr) : QObject(parent), m_host(host) { m_host->setAttribute(Qt::WA_NativeWindow); m_host->installEventFilter(this); } void setResizeBorder(int logicalPx) { m_border = logicalPx; } void setTitleBarHeight(int logicalPx) { m_titleH = logicalPx; } // 返回 true 表示该全局坐标落在自绘按钮上,交还给 QT std::function<bool(const QPoint &)> buttonHitTest; protected: bool eventFilter(QObject *obj, QEvent *ev) override { if (ev->type() == QEvent::WinEventAct) { /* 占位 */ } return QObject::eventFilter(obj, ev); } bool nativeEvent(const QByteArray &, void *message, qintptr *result); private: QWidget *m_host = nullptr; int m_border = 6; int m_titleH = 40; };真正干活的是nativeEvent里那个switch,和前面几章写的完全一样,只是把m_titleBar->hitTestButtons(gpt)换成了buttonHitTest(gpt)回调,把minimumWidth()换成了宿主控件的查询。这样主窗口、设置窗口、关于窗口都能复用,区别只在按钮区和标题栏高度。
接入之后别急着收工,按下面这张清单过一遍,每一条都对应前面提过的一个坑:
| 验证项 | 操作 | 期望结果 |
|---|---|---|
| 贴边分屏 | 拖标题栏到屏幕左边缘 | 出现半屏预览,松手占满左半 |
| 顶部最大化 | 拖标题栏到屏幕顶端 | 出现最大化提示,松手最大化 |
| 双击标题栏 | 双击标题栏空白处 | 最大化,再双击还原 |
| 最大化还原拖拽 | 最大化后按住标题栏往下拖 | 自动还原且鼠标保持相对位置 |
| 四边缩放 | 从四条边和四个角拖拽 | 光标变双向箭头,窗口跟随 |
| 最小化动画 | 点最小化按钮 | 有缩到任务栏的动画 |
| 任务栏图标 | 查看任务栏 | 图标清晰,无空白占位 |
| 多屏 DPI | 拖到不同缩放比的屏幕 | 边框手感一致,界面不模糊 |
| 阴影 | 观察窗口四周 | 有系统投影,无黑边 |
| 失焦配色 | 点击其他窗口 | 标题栏变灰,无闪烁 |
这套东西我从最早的mouseMove拖动版一路改到现在的HTCAPTION版,中间翻过的车基本都在第 5 章里了。最血泪的一次是WM_NCCALCSIZE里用GetWindowRect拿到旧矩形,在 4K 屏上表现为窗口右边缘怎么都拖不动,查了两个晚上才定位到是消息时序问题。从那以后我每次写完nativeEvent,都强制按上面那张表从头到尾走一遍,尤其是多屏 DPI 那一条——单屏开发环境永远测不出来,等用户反馈的时候已经发版了。希望帮到你。
本文还有配套的精品资源,点击获取