前言

最近在做一个 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

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐