前言

最近在做一个 PyQt6 桌面客户端时,我遇到了一个非常典型但又很容易被低估的问题:

我想自定义标题栏,但又不想丢掉 Windows 原生窗口体验。

一开始以为这只是一个 UI 问题:把系统标题栏隐藏掉,然后自己画三个按钮不就行了吗?

后来发现完全不是这么回事。

当窗口改成自绘标题栏后,陆续出现了这些问题:

  • 鼠标悬停最大化按钮时,Windows 11 的 Snap Layout 分屏面板不出现;
  • 鼠标移动到窗口边缘,没有系统双箭头缩放光标;
  • 最大化和还原时偶发黑边闪烁;
  • 最大化后顶部偶尔冒出第二套 Windows 原生按钮;
  • 自动隐藏任务栏有时无法从屏幕边缘唤出;
  • 使用 showFullScreen() 模拟最大化后,任务栏、贴边吸附、窗口动画都变得不稳定。

最终复盘下来,核心结论只有一句话:

PyQt6 自绘标题栏不能等同于纯 Qt 无边框窗口。标题栏可以自己画,但窗口管理必须尽量交还给 Windows。

本文记录这次修复过程中的经验,尤其适合正在做 PyQt6 / PySide6 自定义标题栏、无边框窗口、Windows 11 Snap Layout 适配的开发者。


1. 先明确目标:不是“无边框”,而是“自绘标题栏 + 原生窗口管理”

很多人做自定义标题栏时,第一反应是:

self.setWindowFlags(Qt.WindowType.FramelessWindowHint)

然后自己处理鼠标拖动:

def mouseMoveEvent(self, event):
    self.move(...)

这类方案看起来能用,但它本质上是“纯 Qt 无边框窗口”。问题是,Windows 不再把你的窗口当作一个标准的可管理窗口区域来看待。

于是你会慢慢丢掉这些系统能力:

  • 原生边框缩放;
  • 贴边吸附;
  • Win11 最大化按钮悬停分屏面板;
  • 双击标题栏最大化;
  • 最大化时自动避让任务栏;
  • DWM 原生阴影、圆角、动画;
  • 任务栏自动隐藏边缘唤出。

所以本项目最终采用的目标不是:

Qt 自己模拟一个窗口

而是:

Qt 负责画标题栏
Windows 继续负责窗口管理

这句话非常重要。


2. 最终采用的混合方案

Windows 下最终采用的是一个混合方案:

Qt.Window | Qt.FramelessWindowHint
    用来阻止 Windows 原生标题栏绘制

Win32 Window Style
    用来保留正常窗口管理能力

nativeEvent / Win32 消息处理
    用来处理 WM_NCCALCSIZE、WM_NCHITTEST、WM_GETMINMAXINFO

这不是纯 Qt 无边框窗口,也不是纯 Windows 原生标题栏窗口。

更准确地说,它是:

Qt 隐藏系统标题栏绘制,Win32 保留窗口管理语义。


3. 为什么需要 FramelessWindowHint?

一开始我走过一个弯路:认为 Windows 下不要使用 FramelessWindowHint,只要保留 WS_CAPTIONWS_THICKFRAME 等 Win32 style,再通过 WM_NCCALCSIZE 隐藏标题栏即可。

结果出现了很典型的问题:

  • 顶部或者右侧出现黑边;
  • 最大化之后,系统原生按钮又冒出来;
  • 自绘按钮和原生按钮叠在一起;
  • 最大化/还原动画期间出现闪烁。

后面才发现,问题在于:

如果不使用 FramelessWindowHint 阻止系统标题栏绘制,Windows 原生标题栏和 Qt 自绘标题栏可能会同时存在。

所以更稳的做法是:

self.setWindowFlags(
    Qt.WindowType.Window |
    Qt.WindowType.FramelessWindowHint
)

它的作用不是让 Qt 完全接管窗口管理,而是先阻止 Windows 把原生标题栏画出来。

然后我们再通过 Win32 API 把窗口管理能力补回来。


4. Win32 Style:隐藏绘制,但保留能力

窗口显示之后,需要对 HWND 的 style 做一次修复。

核心是:

  • 清除 WS_POPUP
  • 保留或补回 WS_CAPTION
  • 保留或补回 WS_THICKFRAME
  • 保留或补回 WS_SYSMENU
  • 保留或补回 WS_MINIMIZEBOX
  • 保留或补回 WS_MAXIMIZEBOX
  • 调用一次 SetWindowPos(..., SWP_FRAMECHANGED)

示例代码如下:

import ctypes
from ctypes import wintypes

user32 = ctypes.windll.user32

GWL_STYLE = -16

WS_POPUP = 0x80000000
WS_CAPTION = 0x00C00000
WS_SYSMENU = 0x00080000
WS_THICKFRAME = 0x00040000
WS_MINIMIZEBOX = 0x00020000
WS_MAXIMIZEBOX = 0x00010000

SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
SWP_NOZORDER = 0x0004
SWP_NOACTIVATE = 0x0010
SWP_FRAMECHANGED = 0x0020

LONG_PTR = ctypes.c_longlong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_long

GetWindowLongPtr = (
    user32.GetWindowLongPtrW
    if ctypes.sizeof(ctypes.c_void_p) == 8
    else user32.GetWindowLongW
)

SetWindowLongPtr = (
    user32.SetWindowLongPtrW
    if ctypes.sizeof(ctypes.c_void_p) == 8
    else user32.SetWindowLongW
)


def apply_windows_native_style(hwnd: int):
    style = GetWindowLongPtr(hwnd, GWL_STYLE)

    # FramelessWindowHint 之后窗口可能带 WS_POPUP,这里要清掉。
    style &= ~WS_POPUP

    # 补回 Windows 原生窗口管理能力。
    style |= (
        WS_CAPTION
        | WS_SYSMENU
        | WS_THICKFRAME
        | WS_MINIMIZEBOX
        | WS_MAXIMIZEBOX
    )

    SetWindowLongPtr(hwnd, GWL_STYLE, style)

    user32.SetWindowPos(
        hwnd,
        None,
        0,
        0,
        0,
        0,
        SWP_NOMOVE
        | SWP_NOSIZE
        | SWP_NOZORDER
        | SWP_NOACTIVATE
        | SWP_FRAMECHANGED,
    )

注意一个关键点:

这段 style 修复只能在窗口创建后执行一次,不要在最大化和还原过程中反复执行。

反复调用 SetWindowLongPtrSetWindowPos(..., SWP_FRAMECHANGED) 很容易造成:

  • 黑边闪烁;
  • 窗口重建;
  • 任务栏状态异常;
  • 原生标题栏按钮重新绘制出来。

5. WM_NCCALCSIZE:隐藏原生非客户区

WM_NCCALCSIZE 的职责是告诉 Windows:

原生标题栏和非客户区不用画了,整个窗口区域交给 Qt 绘制。

在 PyQt6 中可以通过 nativeEvent() 处理:

WM_NCCALCSIZE = 0x0083


def nativeEvent(self, eventType, message):
    msg = MSG.from_address(int(message))

    if msg.message == WM_NCCALCSIZE and msg.wParam:
        return True, 0

    return super().nativeEvent(eventType, message)

这里不要做得太复杂。

不要在 WM_NCCALCSIZE 里同时处理最大化工作区、任务栏避让、窗口边界等逻辑。

职责要拆开:

WM_NCCALCSIZE
    只负责隐藏原生非客户区

WM_GETMINMAXINFO
    负责最大化尺寸、任务栏工作区、最小窗口尺寸

这样后期排查问题会清晰很多。


6. WM_NCHITTEST:整个方案的核心

真正决定窗口是否有原生体验的,是 WM_NCHITTEST

Windows 会通过这个消息询问:

当前鼠标所在位置,到底是标题栏、边框、最大化按钮,还是普通客户区?

如果返回值正确,Windows 就能继续接管窗口行为。

常见返回值包括:

区域 返回值 效果
标题栏空白区 HTCAPTION 原生拖动、贴边吸附、双击最大化
最大化按钮 HTMAXBUTTON 触发 Win11 Snap Layout
左边缘 HTLEFT 左边缘缩放
右边缘 HTRIGHT 右边缘缩放
上边缘 HTTOP 上边缘缩放
下边缘 HTBOTTOM 下边缘缩放
四角 HTTOPLEFT 斜向缩放
普通内容区 HTCLIENT 正常 Qt 控件交互

7. 不要用 QCursor.pos() 做 hit-test

这是一个非常容易踩的坑。

一开始可能会这样写:

pos = self.mapFromGlobal(QCursor.pos())

普通情况下看起来没问题,但在这些场景下容易出错:

  • 高 DPI;
  • 多显示器;
  • 屏幕缩放不是 100%;
  • 窗口最大化/还原动画期间;
  • 鼠标消息和 Qt 事件循环存在时序差异。

更稳的方式是使用 WM_NCHITTEST 消息里的 lParam

lParam 自带鼠标屏幕坐标,然后通过 ScreenToClient 转为客户区坐标。

示例:

def get_x_lparam(lparam: int) -> int:
    return ctypes.c_short(lparam & 0xFFFF).value


def get_y_lparam(lparam: int) -> int:
    return ctypes.c_short((lparam >> 16) & 0xFFFF).value


def screen_to_client(hwnd, lparam: int):
    screen_x = get_x_lparam(lparam)
    screen_y = get_y_lparam(lparam)

    pt = wintypes.POINT(screen_x, screen_y)
    user32.ScreenToClient(hwnd, ctypes.byref(pt))

    return int(pt.x), int(pt.y)

这一步修完之后,最大化按钮区域和边框区域的命中会稳定很多。


8. 命中顺序非常重要

WM_NCHITTEST 的判断顺序不能乱。

最终稳定的顺序是:

1. 最大化按钮
2. 最小化按钮和关闭按钮
3. 四边和四角缩放区
4. 标题栏空白区
5. 普通客户区

为什么最大化按钮要放在边框前面?

因为最大化按钮通常位于标题栏顶部区域,而顶部区域也可能同时属于 HTTOP 缩放边框。

如果先判断顶部边框,就会出现这种情况:

鼠标明明停在最大化按钮上
但 hit-test 先返回了 HTTOP
Windows 认为你在窗口上边缘
于是 Snap Layout 不出现

所以最大化按钮必须优先判断:

if in_max_button:
    return HTMAXBUTTON

# 后面才判断 HTTOP、HTLEFT、HTRIGHT 等边框区域

9. 最大化按钮必须返回 HTMAXBUTTON

Windows 11 的 Snap Layout 分屏面板依赖最大化按钮语义。

也就是说,鼠标悬停最大化按钮时,如果你只是让 Qt 按钮 hover:

btn_max.clicked.connect(...)

Windows 并不知道这个区域是最大化按钮。

正确做法是:

if in_max_button:
    return HTMAXBUTTON

这样 Windows 才知道:

当前鼠标悬停在最大化按钮区域。

然后 Win11 才有机会弹出 Snap Layout 面板。


10. 最小化和关闭按钮不要返回 HTMINBUTTON / HTCLOSE

这个点也很关键。

理论上,最小化按钮可以返回 HTMINBUTTON,关闭按钮可以返回 HTCLOSE

但在自绘标题栏场景下,这样容易引发一个问题:

Windows 可能会绘制原生按钮按下态,导致顶部冒出第二套系统按钮。

所以最终更稳的规则是:

最大化按钮:返回 HTMAXBUTTON
最小化按钮:返回 HTCLIENT,继续由 Qt clicked 处理
关闭按钮:返回 HTCLIENT,继续由 Qt clicked 处理

示例:

if in_max_button:
    return HTMAXBUTTON

if in_min_button:
    return HTCLIENT

if in_close_button:
    return HTCLIENT

最大化按钮之所以特殊,是因为它要触发 Windows 11 Snap Layout。

最小化和关闭按钮没有这个需求,所以继续交给 Qt 处理更稳定。


11. 边框缩放:返回 HTLEFT / HTRIGHT / HTTOP / HTBOTTOM

要让窗口边缘出现系统双箭头,并且能原生缩放,必须在边缘区域返回对应的 hit-test 值。

示例:

HTLEFT = 10
HTRIGHT = 11
HTTOP = 12
HTTOPLEFT = 13
HTTOPRIGHT = 14
HTBOTTOM = 15
HTBOTTOMLEFT = 16
HTBOTTOMRIGHT = 17


def hit_test_resize_border(x, y, width, height, border):
    left = x < border
    right = x >= width - border
    top = y < border
    bottom = y >= height - border

    if top and left:
        return HTTOPLEFT

    if top and right:
        return HTTOPRIGHT

    if bottom and left:
        return HTBOTTOMLEFT

    if bottom and right:
        return HTBOTTOMRIGHT

    if left:
        return HTLEFT

    if right:
        return HTRIGHT

    if top:
        return HTTOP

    if bottom:
        return HTBOTTOM

    return None

需要注意:

窗口最大化时,边框区域不应该继续返回缩放命中。

所以需要加判断:

if not self.isMaximized() and not self.isFullScreen():
    resize_hit = hit_test_resize_border(...)
    if resize_hit is not None:
        return resize_hit

12. 标题栏空白区返回 HTCAPTION

标题栏空白区域应该返回 HTCAPTION

这样 Windows 会原生处理:

  • 拖动窗口;
  • 拖到屏幕边缘贴边吸附;
  • 双击标题栏最大化;
  • 从最大化状态向下拖动还原;
  • 与系统窗口动画保持一致。

示例:

if in_title_bar_empty_area:
    return HTCAPTION

不要自己用鼠标事件模拟拖动窗口。

也就是说,不推荐这样做:

def mouseMoveEvent(self, event):
    self.move(...)

这种方式能拖,但不是 Windows 原生拖动,很多系统体验都会丢。


13. 最大化不是全屏

这是本次踩坑中非常重要的一点。

主窗口最大化应该使用:

self.showMaximized()
self.showNormal()

不要使用:

self.showFullScreen()

也不要用:

self.setGeometry(screen.availableGeometry())

原因是:

  • showFullScreen() 是真正全屏,通常用于视频、图片预览、演示等场景;
  • 主窗口最大化应该保留任务栏、工作区、Snap Layout、系统动画语义;
  • setGeometry() 模拟最大化会绕过窗口管理器,状态非常容易错乱。

正确写法:

def toggle_max_restore(self):
    if self.isMaximized():
        self.showNormal()
    else:
        self.showMaximized()

14. 处理最大化按钮点击

由于最大化按钮区域返回了 HTMAXBUTTON,Qt 的普通 clicked 信号不一定总是稳定触发。

所以需要在 native event 里兜底处理:

WM_NCLBUTTONDOWN = 0x00A1
WM_NCLBUTTONUP = 0x00A2

HTMAXBUTTON = 9


def nativeEvent(self, eventType, message):
    msg = MSG.from_address(int(message))

    if msg.message == WM_NCLBUTTONDOWN and msg.wParam == HTMAXBUTTON:
        # 吞掉原生按下态,避免 Windows 绘制第二套按钮
        return True, 0

    if msg.message == WM_NCLBUTTONUP and msg.wParam == HTMAXBUTTON:
        self.toggle_max_restore()
        return True, 0

    return super().nativeEvent(eventType, message)

这样既能保留最大化按钮的系统语义,又能避免 Windows 原生按钮绘制出来。


15. WM_GETMINMAXINFO:最大化不要盖住任务栏

如果隐藏了原生非客户区,最大化尺寸也需要自己约束。

否则可能出现:

  • 最大化盖住任务栏;
  • 自动隐藏任务栏无法从边缘唤出;
  • 多显示器切换时最大化区域异常;
  • 最小窗口尺寸太小导致布局挤压。

这部分通常在 WM_GETMINMAXINFO 中处理。

它的职责是:

告诉 Windows:
窗口最大化时应该占据哪个工作区
最小能缩到多大
最大追踪尺寸是多少

尤其要注意任务栏自动隐藏场景,窗口不要完全压死屏幕边缘,否则任务栏不容易被唤出。


16. 黑边闪烁常见原因

最大化或还原时出现黑边,常见原因有几个。

16.1 反复修改 Win32 style

错误做法:

def toggle_max_restore(self):
    self.apply_windows_native_style()
    self.showMaximized()

正确做法:

窗口创建后修一次 style
最大化/还原时不要再动 style

16.2 使用透明背景

尽量避免:

self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)

透明背景、阴影容器、伪圆角在最大化状态下很容易出现黑边或残影。

16.3 最大化状态下仍然保留外层 margin

例如:

layout.setContentsMargins(8, 8, 8, 8)

如果最大化后外层 margin 仍然存在,就可能露出黑边。

最大化时应该确保外层边距为 0。

16.4 用 showFullScreen 模拟最大化

这个前面已经说过,不要这么做。

主窗口最大化就是 showMaximized()


17. 一份简化版 nativeEvent 结构

下面是一份简化结构,仅展示核心逻辑。

实际项目中还需要处理 DPI、按钮区域映射、最小窗口尺寸、多屏幕等细节。

def nativeEvent(self, eventType, message):
    msg = MSG.from_address(int(message))

    if msg.message == WM_NCCALCSIZE and msg.wParam:
        return True, 0

    if msg.message == WM_GETMINMAXINFO:
        self._handle_get_min_max_info(msg)
        return True, 0

    if msg.message == WM_NCHITTEST:
        hit = self._win32_hit_test(msg)
        return True, hit

    if msg.message == WM_NCLBUTTONDOWN and msg.wParam == HTMAXBUTTON:
        return True, 0

    if msg.message == WM_NCLBUTTONUP and msg.wParam == HTMAXBUTTON:
        self.toggle_max_restore()
        return True, 0

    if msg.message == WM_NCLBUTTONDBLCLK and msg.wParam == HTCAPTION:
        self.toggle_max_restore()
        return True, 0

    return super().nativeEvent(eventType, message)

_win32_hit_test() 的大致结构:

def _win32_hit_test(self, msg):
    hwnd = int(self.winId())

    x, y = self._screen_to_client_from_lparam(hwnd, msg.lParam)

    # 1. 最大化按钮优先
    if self._is_in_max_button(x, y):
        return HTMAXBUTTON

    # 2. 最小化和关闭继续交给 Qt
    if self._is_in_min_button(x, y):
        return HTCLIENT

    if self._is_in_close_button(x, y):
        return HTCLIENT

    # 3. 边框缩放
    if not self.isMaximized() and not self.isFullScreen():
        resize_hit = self._hit_test_resize_border(x, y)
        if resize_hit is not None:
            return resize_hit

    # 4. 标题栏空白区域
    if self._is_in_title_bar(x, y):
        return HTCAPTION

    # 5. 普通内容区
    return HTCLIENT

18. 调试时一定要打日志

这类问题不能只靠肉眼猜。

建议在调试期间打印这些日志:

[chrome] style=0x... WS_POPUP=False WS_THICKFRAME=True WS_MAXIMIZEBOX=True
[chrome] WM_NCCALCSIZE consumed
[chrome] hit=HTMAXBUTTON x=... y=...
[chrome] hit=HTRIGHT x=... y=...
[chrome] hit=HTCAPTION x=... y=...
[chrome] WM_GETMINMAXINFO workArea=...

如果鼠标放在最大化按钮上,没有打印 HTMAXBUTTON,那 Snap Layout 肯定不会出现。

如果鼠标放在窗口边缘,没有打印 HTLEFT / HTRIGHT / HTTOP / HTBOTTOM,那系统双箭头缩放肯定不会出现。

如果 style 日志里 WS_THICKFRAME=False,那即使 hit-test 返回边缘命中,也可能无法原生缩放。


19. 问题排查表

现象 优先排查
最大化按钮悬停不出现 Snap Layout 最大化按钮是否返回 HTMAXBUTTON
边缘没有双箭头缩放 是否返回 HTLEFT / HTRIGHT 等;是否有 WS_THICKFRAME
最大化后出现第二套系统按钮 是否漏用 FramelessWindowHintWM_NCCALCSIZE 是否稳定返回 0
最大化或还原黑边闪烁 是否反复修改 Win32 style;是否存在透明背景、阴影、外层 margin
自动隐藏任务栏无法唤出 WM_GETMINMAXINFO 是否正确处理工作区和边缘
点击最大化变成真正全屏 是否误用了 showFullScreen()
标题栏能拖但不能贴边吸附 是否自己用 mouseMoveEvent 模拟拖动,而不是返回 HTCAPTION
高 DPI 下命中错位 是否用了 QCursor.pos(),而不是 msg.lParam + ScreenToClient

20. 最终验收清单

修完之后,不要只跑单元测试,还必须在真实 Windows 桌面环境手工验收。

建议检查:

  1. 鼠标悬停最大化按钮,Windows 11 Snap Layout 正常出现;
  2. 鼠标悬停窗口四边和四角,出现系统双箭头缩放光标;
  3. 拖动标题栏空白区域,窗口可以移动;
  4. 拖动标题栏到屏幕边缘,系统贴边吸附正常;
  5. 双击标题栏空白区域,窗口最大化或还原;
  6. 最大化不会盖住任务栏;
  7. 自动隐藏任务栏仍能从屏幕边缘唤出;
  8. 最大化和还原时不出现黑边闪烁;
  9. 最大化后不会冒出第二套 Windows 原生按钮;
  10. 高 DPI、多显示器环境下按钮和边框命中不偏移。

21. 本次踩坑总结

这次最大的教训是:

自绘标题栏不是普通页面样式问题,而是平台窗口适配问题。

Qt 负责的是界面绘制,Windows 负责的是窗口管理。

如果把两者职责混在一起,就会出现各种奇怪问题:

  • Qt 自己拖动窗口,Windows 贴边吸附丢失;
  • Qt 自己模拟最大化,任务栏避让异常;
  • Windows 原生标题栏没彻底隐藏,第二套按钮冒出来;
  • 最大化按钮没有返回 HTMAXBUTTON,Snap Layout 不出现;
  • 边框没有返回 HTLEFT 等命中值,系统缩放丢失。

最终稳定方案可以概括成一句话:

Qt FramelessWindowHint 隐藏系统标题栏绘制;
Win32 style 保留窗口管理能力;
WM_NCCALCSIZE 隐藏非客户区;
WM_NCHITTEST 返回正确窗口语义;
WM_GETMINMAXINFO 处理最大化工作区。

也就是:

标题栏自己画,但窗口管理仍然交给 Windows。

这才是 PyQt6 在 Windows 下实现“自绘标题栏 + 原生窗口体验”的关键。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐