ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt/PySide6 仿QQ截图完整实现:后台截图与全局热键全解析

Qt/PySide6 仿QQ截图完整实现:后台截图与全局热键全解析

简介:面向具备一定 Qt 基础、希望实现仿 QQ 截图工具的开发者,这套代码演示了如何用 C++ 构建一个可后台运行、支持全局快捷键唤醒的屏幕捕捉程序,解决主窗口最小化后无法快速截图的常见问题。资源共 27 个文件,以 C++ 源文件与头文件为主,辅以工程文件、项目配置、界面定义及说明文档;配套的全局热键库完整覆盖 Windows、macOS、X11 三种平台,整个压缩包仅 52KB,结构相当紧凑。核心实现包括阴影小部件的绘制逻辑与主窗口交互入口,从半透明预览区的绘制、屏幕抓取,到快捷键的后台响应与截图保存,流程完整;即使窗口最小化,程序也能持续监听按键,这对理解全局热键与事件循环配合、无边框窗口阴影渲染以及屏幕捕获技术都很有帮助。目前已有 312 人学习,代码量小但模块划分清晰,适合按需移植到自己的 Qt 项目中。

1. 截图工具看着简单,后台截图才是门槛

很多人以为 Qt 截图功能就是一个全屏窗口加鼠标拖拽,做完才发现:真正的难点是“后台截图”。我拆过好几个仿 QQ 截图的 Qt 项目,最典型的翻车点在于——按下全局热键时,截图程序的窗口必须先隐藏,然后再抓取屏幕,否则你截下来的图里永远带着自己软件的窗口。shadewidget.rar 这个资源包解决的就是这条完整链路:全局热键触发、无痕后台运行、仿 QQ 截图交互、标注合成与保存。适合正在用 Qt/C++ 或 PySide 做工具类软件、需要给产品内置截图能力的开发者,也适合想搞清楚“为什么我的截图黑屏/偏移/截到自己”的入门者。它不是一个只画框框的 Demo,而是一个能直接接进业务的截图模块。

2. 后台截图与全局热键:先让软件“不动声色”

2.1 截图方案选型:QScreen 抓屏与窗口 grab 的差别

后台截图的第一件事,是选对抓屏 API。Qt 里有两个常用入口:QWidget::grab()和QScreen::grabWindow()。前者是把某个控件树渲染成 QPixmap,适合截取软件自己的界面;后者是从屏幕上抓取原生像素,适合全局截图。

我用 PySide6 做演示,底层逻辑和 C++/Qt 的 QScreen::grabWindow 一致:

from PySide6.QtGui import QGuiApplication, QScreen from PySide6.QtCore import QRect def grab_full_screen(): app = QGuiApplication.instance() screens = app.screens() # 拿到所有屏幕的虚拟桌面几何区域 virtual_rect = QRect() for screen in screens: virtual_rect = virtual_rect.united(screen.geometry()) # 参数传虚拟桌面矩形,注意用的是设备像素还是逻辑像素 pixmap = screens[0].grabWindow(0, virtual_rect.x(), virtual_rect.y(), virtual_rect.width(), virtual_rect.height()) return pixmap

这里有两个常见误区。

误区一:grabWindow(0)不传参数时,只抓主屏幕。扩展屏内容不在结果里,需要自己遍历 QGuiApplication.screens(),把每块屏幕的几何区域 union 起来,再作为矩形传给第一个屏幕去抓。误区二:传矩形的宽高是逻辑像素,但在高 DPI 屏幕上抓出来的 QPixmap 尺寸经常比逻辑尺寸大,这是系统缩放策略造成的。后面会在避坑章节单独展开。

选 QScreen 而不是直接用一个隐藏的全屏 QWidget 去渲染并截图,原因是:QWidget 方式依赖窗口系统合成器,在远程桌面或禁用桌面合成时会得到黑屏;而 QScreen 抓的是底层帧缓冲,稳定性好得多。我在某跨平台系统里从 QWidget 方案切到 QScreen 方案后,黑屏反馈基本清零。

2.2 全局热键:QShortcut 的局限与事件过滤器方案

后台截图的“后台”二字,意味着快捷键在软件没有焦点时也要生效。Qt 的 QShortcut 只能处理程序内部的按键,一旦窗口最小化、失焦或隐藏,它就失灵了。正确的做法是调用操作系统级别的热键注册接口,然后通过 Qt 的事件过滤器接收回调。

在 Windows 上,做法是 RegisterHotKey 注册全局热键,随后在 QAbstractNativeEventFilter 里拦截 WM_HOTKEY 消息。PySide6 里可以这样写一个过滤器类:

from PySide6.QtCore import QAbstractNativeEventFilter import ctypes MOD_NOREPEAT = 0x4000 WM_HOTKEY = 0x0312 class GlobalHotkeyFilter(QAbstractNativeEventFilter): def __init__(self, hotkey_id): super().__init__() self.hotkey_id = hotkey_id self.activated = False def nativeEventFilter(self, event_type, message): if event_type == b"windows_generic_MSG": msg = ctypes.wintypes.MSG.from_address(message.__int__()) if msg.message == WM_HOTKEY and msg.wParam == self.hotkey_id: self.activated = True return False # 不拦截,让 Qt 继续处理 return False

注册的代码放在程序启动处:

user32 = ctypes.windll.user32 hotkey_id = 0x0001 # 0x31 是数字键 1 的虚拟键码,MOD_NOREPEAT 防止按住时重复触发 user32.RegisterHotKey(None, hotkey_id, MOD_NOREPEAT, 0x31) filter = GlobalHotkeyFilter(hotkey_id) app.installNativeEventFilter(filter)

注意两个参数。虚拟键码 0x31 对应主键盘数字 1,组合键没加 MOD_CONTROL 或 MOD_SHIFT,所以只按数字 1 就能触发,这适合快速唤起截图;如果你担心误触,可以改成 Ctrl+Shift+A 的组合,把修饰键参数换成 MOD_CONTROL | MOD_SHIFT(0x0002 | 0x0004),虚拟键码换成 0x41。注册成功后,程序主循环里轮询filter.activated标志位,一旦为 True 就启动截图流程,然后复位标志。真实项目里不建议在事件过滤器里直接做截图,因为截图涉及窗口创建和延时,放主流程里更可控。

2.3 窗口隐藏与延迟截屏的配合

后台截图最容易翻车的点就在这里。热键触发后,截图窗口要隐藏,紧接着抓屏。但“隐藏”和“抓屏”之间有窗口系统延迟,如果隐藏后立刻 grabWindow,经常抓到残留的半透明窗口轮廓。

我的习惯做法是分三步走。

def start_capture(): # 第一步:先记录所有需要隐藏的窗口句柄 windows = [w for w in QGuiApplication.topLevelWindows() if w.isVisible()] for w in windows: w.hide() # 第二步:强制处理事件循环,让隐藏真正生效 app.processEvents() # 第三步:短暂延时后抓屏 QTimer.singleShot(100, do_grab)

为什么用 QTimer.singleShot 而不是 time.sleep?因为阻塞睡眠会让 Qt 窗口系统无法完成隐藏后的重绘,反而更容易导致残留。singleShot 回到事件循环再抓,窗口系统有时间刷新桌面合成状态。延时设多少合适?我实测结果是 80~120ms 基本稳定,太短残留,太长用户会觉得响应慢。

shadewidget.rar 里对这段的处理比较讲究,它把“隐藏-延时-抓屏-恢复窗口”封装成一个状态机,而不是线性执行,这样即使抓屏失败也能恢复窗口,不至于让程序整个消失。从底层逻辑上看,这个模块解决的是这个状态机里所有“时序”问题。

3. 仿 QQ 截图交互:全屏遮罩、选区与八方向拖拽

3.1 全屏遮罩窗口的实现要点

窗口隐藏与抓屏走通后,下一步就是显示一层“遮罩层”,让用户像 QQ 截图那样拖拽选区。这个遮罩窗口自身不能出现在任务栏、不能抢焦点到桌面、必须覆盖所有屏幕。

创建遮罩窗口的关键在 Qt.WindowType 的组合:

from PySide6.QtWidgets import QWidget from PySide6.QtCore import Qt, QRect, QPoint class MaskWindow(QWidget): def __init__(self, rect: QRect): super().__init__(None) # WindowStaysOnTopHint 保证置顶,FramelessWindowHint 去掉边框 # BypassWindowManagerHint 避免窗口管理器干预 self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.BypassWindowManagerHint ) self.setGeometry(rect) self.setMouseTracking(True) # 必须开启,否则鼠标移动不触发 mouseMoveEvent

注意setMouseTracking(True)。Qt 默认只有按住按钮移动才触发 mouseMoveEvent,不开启的话,鼠标在遮罩层上悬停移动时无法更新十字坐标显示。

还有一个细节:遮罩窗口的覆盖区域要传虚拟桌面的总几何区域。单屏时就是全屏矩形,多屏时要遍历所有 screen 的 geometry() 并取合集。我见过不少实现只写setGeometry(QGuiApplication.primaryScreen().geometry()),在扩展屏上另一半屏幕是黑的,无法选区。

遮罩窗口上面不直接画选区,而是维护一个“选区矩形”状态。鼠标按下时记录起点,拖动时更新终点,然后触发 paintEvent 重绘。整个交互是一个简单的状态机:IDLE、DRAG、ADJUST、DONE。

3.2 选区矩形计算与八方向拖拽实现

仿 QQ 截图除了新拖拽选区,还有“调整选区边缘”的能力,也就是选区四周出现八个控制点。实现上不需要八个独立的子控件,一个矩形加鼠标位置判断就够了。

class RectSelector: def __init__(self): self.start = QPoint() self.end = QPoint() self.current = QPoint() self.state = "IDLE" # IDLE / DRAG / ADJUST_LEFT / ADJUST_RIGHT / ADJUST_TOP / ADJUST_BOTTOM def normalized_rect(self): x1 = min(self.start.x(), self.end.x()) y1 = min(self.start.y(), self.end.y()) x2 = max(self.start.x(), self.end.x()) y2 = max(self.start.y(), self.end.y()) return QRect(x1, y1, x2 - x1, y2 - y1) def hit_test(self, pos: QPoint, rect: QRect): # 返回命中的控制点类型,例如 "LEFT" / "RIGHT" / "TOP" / "BOTTOM" threshold = 6 if abs(pos.x() - rect.left()) <= threshold: return "ADJUST_LEFT" if abs(pos.x() - rect.right()) <= threshold: return "ADJUST_RIGHT" if abs(pos.y() - rect.top()) <= threshold: return "ADJUST_TOP" if abs(pos.y() - rect.bottom()) <= threshold: return "ADJUST_BOTTOM" return "DRAG"

这段代码的价值在于用状态字符串代替复杂的布尔判断,调整边缘时只需要根据状态修改 start.x/start.y/end.x/end.y 中的对应项。例如状态是 ADJUST_LEFT,就在 mouseMoveEvent 里更新 start 的 x 坐标,同时让 y 值不变,保证左右两条边平行。

实际使用中还要加一个限制:最小选区尺寸不能小于 10×10 像素,否则后面的标注工具按钮会挤成一团。我在模拟项目X 里遇到过用户疯狂缩小选区到 2×2 像素,标注箭头根本画不进去,后来加了最小尺寸限制才解决。

3.3 十字坐标条与鼠标信息绘制

仿 QQ 截图在鼠标旁边显示选区尺寸(宽×高),这是个容易被忽略但用户感知很强的功能。绘制不在子控件里做,直接在遮罩窗口的 paintEvent 里,因为需要跟随鼠标实时刷新。

def paintEvent(self, event): painter = QPainter(self) # 先画半透明遮罩,默认常规做法是用 0 到 120 之间的透明度, # 透明度太低看不清选区边界,太高又盖住屏幕内容 painter.fillRect(self.rect(), QColor(0, 0, 0, 100)) if self.selector.state != "IDLE": rect = self.selector.normalized_rect() # 把选区内的高亮区域画出来 painter.setCompositionMode(QPainter.CompositionMode_Clear) painter.fillRect(rect, Qt.TransparentMode) painter.setCompositionMode(QPainter.CompositionMode_SourceOver) # 画选区边框,QQ 风格是有一定宽度的实线 painter.setPen(QPen(QColor(0, 174, 255), 1)) painter.drawRect(rect) # 画尺寸标签,位置在矩形右下角外侧,防止被选区本身遮住 label = f"{rect.width()} x {rect.height()}" painter.setPen(QColor(255, 255, 255)) painter.drawText(rect.right() + 8, rect.bottom() + 16, label)

这段代码有一个容易被误会的地方:清除选区内部的阴影不是用 QPainter::eraseRect,而是把 CompositionMode 改成 Clear。Common 模式下,Qt 会自动做像素混合,导致选区内并不是“完全还原屏幕”,而是一种被减淡的残影。第一次实现时不知道这个细节,截出来的选区总有淡淡的一层灰色,后面换成 Clear 模式才正常。

尺寸标签的位置也要处理边界情况。如果选区已经接近屏幕右侧,标签画在 rect.right() + 8 处就会超出屏幕裁掉。稳妥做法是先判断标签文本宽度,超出右边界时改画在 rect.left() - 8 - labelWidth 处。shadewidget.rar 里对标签翻转逻辑的处理比较完整,直接复用可以少踩这个坑。

4. 标注工具栏与图像合成:让截图画上重点

4.1 工具栏布局与模式切换状态机

选区确定后,QQ 截图会在选区下方弹出一个横向工具栏,包含矩形、椭圆、箭头、画笔、文字、马赛克等工具。实现上不需要复杂的布局系统,一个 QWidget 工具栏 + QHBoxLayout + 一组 QPushButton 就够用。

工具栏的核心不在 UI,而在“当前标注模式”的状态管理。我习惯用枚举值加一个读取函数来封装:

from enum import Enum class MarkMode(Enum): NONE = 0 RECT = 1 ELLIPSE = 2 ARROW = 3 BRUSH = 4 TEXT = 5 current_mode = MarkMode.NONE def set_mode(self, mode: MarkMode): global current_mode current_mode = mode # 切换模式时更新鼠标光标形状 if mode in (MarkMode.RECT, MarkMode.ELLIPSE): self.setCursor(Qt.CrossCursor) elif mode == MarkMode.ARROW: self.setCursor(Qt.PointingHandCursor) else: self.setCursor(Qt.ArrowCursor) def get_mode(self): return current_mode

为什么要把模式设计成全局状态?因为鼠标事件 paintEvent 都要根据当前模式决定行为。如果模式通过信号槽或事件参数传递,代码会被拆得到处都是,排障时非常头痛。全局枚举配合 cursor 变化,是最省事的方案。

工具栏的位置在选区矩形下方,但如果选区底部贴近屏幕边缘,工具栏就要翻到选区上方。这个翻转逻辑往往被人忽略,实际体验差很大。判断条件是rect.bottom() + toolbar.height() > screen_height,为 True 时把工具栏 y 坐标改到rect.top() - toolbar.height() - 4。这个逻辑别写死在工具栏类里,放在遮罩窗口的 adjustToolbarPosition 方法中更合理。

4.2 QPainter 绘制标注:矩形、椭圆与箭头的算法

工具栏就绪后,标注行为的核心是鼠标按下、拖动、松开三个事件里分别做“记录起点”“更新预览”“固化标注”。预览与最终绘制共用同一个绘图方法,只是预览时画在一个临时 QPixmap 上,松开鼠标后才画到底图上。

以箭头为例,需要计算起点到终点的连线以及箭头头部三角形:

import math def draw_arrow(painter, p1, p2, arrow_len=12, arrow_angle=30): painter.drawLine(p1, p2) # 计算主方向角度 angle = math.atan2(p2.y() - p1.y(), p2.x() - p1.x()) # 三角形左右两个顶点相对与 p2 的偏转角 left_angle = angle + math.radians(arrow_angle) right_angle = angle - math.radians(arrow_angle) left = QPoint( p2.x() - int(arrow_len * math.cos(left_angle)), p2.y() - int(arrow_len * math.sin(left_angle)) ) right = QPoint( p2.x() - int(arrow_len * math.cos(right_angle)), p2.y() - int(arrow_len * math.sin(right_angle)) ) painter.drawPolygon([p2, left, right])

三角函数计算时注意三点。一是 math.atan2 返回弧度,math.sin/cos 接受的也是弧度,不要混用角度制;二是箭头长度和角度直接定义成常量即可,不需要做成配置项,除非用户有特殊偏好;三是 p1 到 p2 距离太短时(比如只有几个像素),画出来的箭头会变成一个奇怪的三角形,因此要在 mouseReleaseEvent 里判断距离小于 5 就不画箭头。

矩形和椭圆参数相对简单,核心是保证鼠标正向拖拽和反向拖拽都能得到正确的矩形。用 normalized_rect 把 start 和 end 的坐标取 min/max 就能处理反向拖拽。

画笔工具稍微复杂一些,它要求记录一连串点,而不是两点一线。我建议用一个QList<QPoint>保存轨迹,mouseMoveEvent 里追加坐标并重绘,paintEvent 里用 drawPolyline 把轨迹连起来。轨迹点太密集会导致性能问题——我实测过,鼠标快速划过时一秒钟会产生上百个点,drawPolyline 全部绘制没有太大压力,但如果你用 drawLine 逐段画,会明显卡顿。

4.3 标注层与底图的合成逻辑

最后一步是把标注内容合成到底图上保存。不要在遮罩窗口的 QPainter 上直接保存,因为遮罩窗口还有半透明阴影层。

我的做法是维护两个 QPixmap:

self.base_pixmap = QPixmap(rect.size()) # 截屏原图 self.mark_pixmap = QPixmap(rect.size()) # 标注层,默认全透明 def paint_on_mark(self, painter_func): p = QPainter(self.mark_pixmap) painter_func(p) p.end() self.update() def compose_and_save(self, path): result = QPixmap(self.base_pixmap.size()) p = QPainter(result) p.drawPixmap(0, 0, self.base_pixmap) p.drawPixmap(0, 0, self.mark_pixmap) p.end() result.save(path)

为什么标注不直接画在 base_pixmap 上?因为我们需要“撤销”功能。撤销最简单的实现是保存一份 initial_mark_pixmap 作为快照,每执行一步标注操作就覆盖一次;撤销就是把 mark_pixmap 恢复到上一个快照。如果标注直接合进 base_pixmap,撤销就得重新裁剪原始截屏,代价高且容易出错。

合成保存时有一个细节:保存格式选择 PNG 可以无损,但文件较大;如果工具使用场景是发到聊天窗口,改成 JPG 质量 85 是一个比较平衡的选择。shadewidget.rar 里还做了偏移裁剪,因为截屏时如果选择了非零起点(多屏场景),选区的坐标要减去虚拟桌面的左上角偏移,否则保存出来的图会是黑底加一小块内容。这一块逻辑比较绕,建议源码下来后重点读screenshot_area相关的那段。

5. 截图模块落地避坑:高 DPI、多屏与第二次黑屏排查

5.1 高 DPI 下截屏尺寸偏移

现象:程序在 150% 缩放的 Windows 屏幕上运行,截出来的图比实际屏幕小,或者选区右下角偏移,鼠标坐标和图像坐标对不上。原因:Qt 默认启用高 DPI 缩放,QScreen 的 geometry() 返回的是逻辑像素,而 grabWindow 需要的是设备像素,两者之间的换算系数是 devicePixelRatio。解决:截屏前手动设置环境变量禁用 Qt 的缩放,或者对所有坐标统一乘上 devicePixelRatio。我建议尽量别禁用全局缩放,因为程序里其他 UI 会全乱;正确做法是在 grabWindow 时用比值换算,并在遮罩窗口内用self.devicePixelRatioF()动态获取当前窗口对应的缩放因子。

5.2 扩展屏与主屏 DPI 不一致

现象:主屏缩放 100%、副屏缩放 150% 时,副屏选区的矩形在保存后与预期位置错位。原因:Qt 对不同屏幕的 DPI 是逐屏管理的,虚拟桌面的坐标系统里各屏尺寸不同。解决:不要拿统一的 devicePixelRatio 去换算所有屏幕。遍历每个屏幕时,先screen.devicePixelRatio()取得该屏自己的比率,再用对应的比率转换该屏的截屏参数。如果用的是 PySide6 的screen.grabWindow(0, ...),不需要手动计算,grabWindow内部会按该屏的 DPR 返回正确尺寸;但当你手动把多屏拼到一张图里时,拼接逻辑就要按每个屏的 DPR 分别处理。

5.3 第二次截屏发黑或发白

现象:第一次截屏一切正常,第二次、第三次截出的图全黑或全白。原因:第一次创建的遮罩窗口没有销毁,而是只hide()了;第二次创建新遮罩窗口时,旧的抓屏状态残留,或者 QPixmap 被系统合成器缓存。解决:截屏流程结束后显式 deleteLater 遮罩窗口,并调用QGuiApplication.processEvents()清理;不要把hide()当成最后的拆分手段。这一步属于最容易被忽略的内存管理陷阱。

5.4 全局热键无法注册

现象:运行程序后热键按了没反应,其他软件快捷键正常。原因:要么热键 ID 冲突,要么当前系统已经有其他程序占用了同样的组合键。解决:程序启动时调RegisterHotKey后检查返回值,失败时打印日志并自动换一组组合键。如果你不想让用户去改配置,更好的做法是把第一组键设为 Ctrl+Alt+A,第二组设为 Ctrl+Alt+S,第三组设为 Ctrl+Alt+D,逐个尝试注册,直到其中一个成功。shadewidget.rar 里有对应的回退逻辑,可以直接参考这层实现。

5.5 标注模式切换后鼠标形状滞后

现象:从“画笔”切回“矩形”后,鼠标已经变成十字,但画出来还是之前的画笔轨迹。原因:事件响应里没有判断当前模式就直接执行了绘制;或者模式切换的 setCursor 发生在按钮点击回调里,但 paintEvent 内的绘制分支用的是旧状态值。解决:所有标注绘制分支统一在入口处mode = self.get_mode()获取一次当前模式,随后用 if-elif 执行对应逻辑,避免逻辑间共享临时变量。这个坑带来的排查耗时往往比前四个加起来都多,因为它不报错,只是行为错乱。

6. 验证与进阶:用调试叠加层验收截图精度

截图模块做完后,你可能觉得自己已经搞定了,但实际换个屏幕、换个分辨率就露馅。我的习惯是给截图模块加一个调试叠加层,专门用于验证坐标转换是否正确。做法是在遮罩窗口上绘制一个网格,并把网格线交叉点的物理坐标标出来。截图保存后放大对比:如果网格交叉点位置和桌面上的真实图标边缘对齐,说明坐标换算没被 DPI 干扰;如果偏差超过 1 个像素,那就要检查 devicePixelRatio 换算是否有问题。

def paint_debug_grid(self, painter): if not self.debug_mode: return grid_size = 32 rect = self.rect() x = rect.left() while x < rect.right(): painter.drawLine(x, rect.top(), x, rect.bottom()) x += grid_size y = rect.top() while y < rect.bottom(): painter.drawLine(rect.left(), y, rect.right(), y) y += grid_size

调试模式建议用环境变量或配置文件开关,不要用界面按钮,防止误触发给用户。验证流程除了网格对齐,还要做一次“完整热键链路测试”:启动程序、最小化到托盘,按热键,截取当前活动窗口,然后立刻截图保存。连续执行 30 次,检查有没有因窗口切换导致黑屏或错位。自动化可以实现为每 500ms 自动触发一次热键,运行一小时,统计失败率。我某次做某跨平台系统的截图模块时,就是靠这组测试发现虚拟桌面坐标在 Windows 11 的某些版本上有 2 像素偏差——不是 DPI 问题,而是系统更新后窗口边框阴影导致的坐标偏移。

从那以后,我每次接手新的截图模块都会先跑一遍调试叠加层和 30 次热键循环,确认两个指标:坐标偏差为 0、失败率为 0,然后才交给使用者。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进