简介:面向需要在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. 无边框不是放弃系统能力:一套 Qt+WINAPI 方案把 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_cast<MSG *>(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_cast<HWND>(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接到上一节的switch:
case WM_NCHITTEST: *result = handleHitTest(host_, hwnd_, msg->lParam); return true;从这之后,拖拽、缩放、snap 这些能力就不再依赖setWindowFlags的运气,而是由我们明确告知系统窗口的每一块边界是什么。为什么不直接调用DefWindowProc呢?因为标准边框下它会根据样式自动返回命中区;去掉WS_CAPTION后它默认整窗HTCLIENT,等于什么都没给,所以这条消息必须自己接管。
3. 把拖拽、缩放、阴影一项项焊回去:完整消息处理链与参数
3.1 标题栏拖拽:让系统做拖动,而不是自己追鼠标
上一章里WM_NCHITTEST返回HTCAPTION,Windows 就已经把标题栏拖拽接管了。用户按住标题栏移动,系统内部走的是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_cast<MINMAXINFO *>(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_cast<HICON>( LoadImageW(module, L"app.ico", IMAGE_ICON, 0, 0, LR_LOADFROMFILE)); if (hIcon) { SendMessageW(hwnd_, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(hIcon)); SendMessageW(hwnd_, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(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_THICKFRAME,DWM 直接认为窗口不可调整大小,阴影功能被旁路。
解决:确认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_cast<WINDOWPOS *>(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 章的分类快速定位到具体消息循环。
从那以后我每接一个无边框窗口需求,第一天就把这套验证清单跑一遍再动手改样式,省掉的返工时间比写代码本身还多。希望帮到你。
本文还有配套的精品资源,点击获取