☰
高仿聊天软件截图工具源码解析:Qt与GDI实现细节
2026/10/20 7:08:13 网站建设 项目流程

简介:QQ截图的1比1高仿开源实现,面向需要学习屏幕捕获技术或自研截图工具的开发者,可帮助理解接近原版的功能设计与交互。资源为RAR压缩包,共29个文件,以8个C++源文件、8个头文件、9个Markdown文档及Visual Studio解决方案文件为主,整体仅49KB,结构清晰。已有556人学习下载。压缩包内含完整的msvs工程与src源码,涉及屏幕捕捉、图形处理、编辑保存等模块,同时提供中英文README和开源许可,便于快速上手和二次集成。通过阅读源码,能学习窗口识别、图像缩放、用户交互等实现细节,是理解桌面截图工具工作原理的实用起点。

1. 有人把聊天软件的截图工具完整重写了一遍,我拆完发现最难的不是画图

如果你只写过 Web 页面,第一次打开这个开源高仿截图工具时,大概率会以为工程的核心是裁剪和涂鸦。真正上手后才发现,最费劲的是窗口层级、焦点切换、按键拦截和高 DPI 下那一堆像素换算。这个项目把某聊天软件内置截图工具的交互完整搬到了 Qt 桌面上,包含全屏截取、区域选择、标注工具栏、马赛克、剪贴板输出和全局快捷键,适合正在做桌面生产力工具、需要给软件塞一个截图组件的开发者。我按源码从头到尾跑了一遍,这篇文章把技术选型、目录结构、核心代码和最容易翻车的几个点一次讲清楚。

2. 技术选型与整体架构:Qt Widgets + GDI 是怎么还原原生截图手感的

2.1 为什么不用 Electron:desktopCapturer 解决不了交互细节

很多截图工具用 Electron 实现,因为 desktopCapturer 可以拿到屏幕流,配上 HTML 的选区很容易做原型。但做到“1:1 高仿”这个程度就不够了:原版截图时遮罩层是半透明的,选区外压暗、选区内保持原亮度,鼠标拖拽时选区边框跟随像素级刷新,标注箭头有抗锯齿,马赛克是块状填充而不是高斯模糊。Electron 的渲染进程和桌面层隔着一层 IPC,窗口穿透、全局热键、剪贴板行为都要走原生模块,交互延迟很难压到 Qt Widgets 那么低。

这个项目选的是 C++17 + Qt 6.5 Widgets,抓屏部分在 Windows 上走系统 GDI,不引入额外图形库。Qt Widgets 的优势是直接控制窗口绘制,每帧的 paintEvent 你都能拿到画家对象,想画什么都自己说了算。另一个实际原因是跨平台:同一套标注代码,Windows、macOS、Linux 都能编,只是抓屏底层各自实现。

我一般会建议做这类工具优先考虑 Qt,而不是去调 DXGI 或者 Metal。DXGI 桌面复制性能虽好,但设备丢失、多显卡切换这类问题调试起来很玄学;GDI 的 BitBlt 虽然“老”,却很稳。这个工程也印证了这一点,抓屏部分代码量很少,复杂度基本集中在选区交互和标注模型上。

2.2 工程目录:截图能力被拆成四个模块

整个仓库结构很清晰,我拉下来后第一件事就是画目录树:

A-Screenshot/ ├─ CMakeLists.txt ├─ src/ │ ├─ main.cpp │ ├─ capture/ │ │ ├─ ScreenCapturer.h │ │ └─ ScreenCapturer.cpp │ ├─ ui/ │ │ ├─ MaskWindow.h │ │ ├─ MaskWindow.cpp │ │ ├─ ToolbarWindow.h │ │ └─ ToolbarWindow.cpp │ ├─ annotation/ │ │ ├─ AnnotationItem.h │ │ ├─ RectItem.cpp │ │ ├─ EllipseItem.cpp │ │ ├─ ArrowItem.cpp │ │ ├─ MosaicItem.cpp │ │ ├─ TextItem.cpp │ │ └─ FreeHandItem.cpp │ └─ utils/ │ ├─ HotkeyManager.cpp │ └─ ClipboardHelper.cpp └─ resources/ ├─ screen.css └─ icons/

四个模块的职责分别是:capture只负责把屏幕变成 QPixmap,不掺任何 UI 逻辑;ui里的 MaskWindow 是全屏遮罩窗口,负责选区交互;annotation是标注模型的集合,每个标注类持有自己的绘制方法;utils放的是绕不开的系统级能力,比如全局热键和剪贴板。

这个分法最值得学的是 annotation 的设计。它没有把画矩形、画箭头的逻辑散落在窗口类里,而是定义一个AnnotationItem基类,每种标注自己实现draw(QPainter *)。窗口在 paintEvent 里只做一个循环,把所有标注对象按顺序画到画布上。后面想加新的标注类型,只需要新增一个类,不需要碰窗口代码。

2.3 数据流:一个截图请求从按键到进入剪贴板的完整链路

完整流程是这样走的:

  1. HotkeyManager 在启动时调用RegisterHotKey注册全局截图键。
  2. 用户按下快捷键,系统把 WM_HOTKEY 消息投递给消息队列。
  3. 主窗口收到消息后隐藏自身,等待约 100 毫秒让桌面重绘。
  4. ScreenCapturer 调用grabWindow(0)抓取整块虚拟桌面。
  5. MaskWindow 带着抓屏结果全屏显示,进入选区模式。
  6. 用户拖出选区,ToolbarWindow 出现在选区右下角。
  7. 用户选择标注类型并完成编辑,按回车或点击“完成”。
  8. 裁剪选区内图像,交给 ClipboardHelper 写入系统剪贴板。

第 3 步的 100 毫秒延迟很关键。如果按下快捷键后立刻抓屏,截图里往往包含自己程序的窗口残留。隐藏主窗口后桌面需要一帧时间恢复原样,直接抓会抓到黑块或者半透明的窗口轮廓。这个时序问题也是后面避坑章节会展开的。

3. 核心模块拆解:从按下快捷键到图片出图的完整链路

3.1 抓屏模块:grabWindow 的一行代码背后发生了什么

抓屏模块是整个截图流程的起点,代码量却意外地少:

// capture/ScreenCapturer.cpp #include <QScreen> #include <QGuiApplication> QPixmap ScreenCapturer::captureFullScreen() { QScreen *screen = QGuiApplication::primaryScreen(); if (!screen) { return QPixmap(); } // 抓取整块虚拟桌面;winId 传 0 表示所有显示器 QPixmap shot = screen->grabWindow(0); return shot; }

逻辑很简单:拿到主屏的 QScreen 对象,调用grabWindow(0)。参数 0 在这里不是“没有窗口”,而是“抓整个虚拟桌面”。如果你的电脑接了双屏,副屏在主屏左边,它的坐标是负数,grabWindow(0)依然能把所有显示器内容合成为一张大图。这个接口在 Windows 底层走的是 GDI 的 BitBlt,把屏幕 DC 的内容复制到位图上,同步执行,返回时数据已经可用。

使用时有两点要注意。第一,如果只需要抓某个特定窗口,把窗口的 winId 传进去,但截图工具通常不需要这个能力,因为用户可能想截任意区域,包括多个窗口叠加的区域。第二,这张 QPixmap 的尺寸是整个虚拟桌面的像素尺寸,在高 DPI 屏幕上它会比逻辑分辨率大。后续鼠标坐标换算时,需要用devicePixelRatio做转换,否则选区和背景图会错位。

3.2 遮罩窗口与选区交互:橡皮筋选区的绘制逻辑

MaskWindow 是截图时覆盖全屏的那个半透明窗口。它的核心任务是显示抓屏结果,然后用 QPainter 把选区外压暗:

void MaskWindow::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.drawPixmap(0, 0, m_background); // 底层是抓屏整图 if (m_selection.isEmpty()) { return; } QRect sel = m_selection.normalized(); QRegion dimmed = QRegion(rect()) - QRegion(sel); painter.setClipRegion(dimmed); painter.fillRect(rect(), QColor(0, 0, 0, 100)); }

这里的做法是用QRegion做差集,把“选区外”这个区域描述出来,然后只填充那一块。这样选区内部像素保持原样,外部盖上 100 透明度的黑色,视觉上就有“压暗”效果。为什么不用整个窗口叠加半透明再在选区挖洞?因为在 Qt 里挖洞需要设置WA_TranslucentBackground,在 Windows 上会造成额外的合成层开销,而且选区边缘容易发虚,不如 clipRegion 干净。

鼠标交互部分,按下时记录原点:

void MaskWindow::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_origin = event->position().toPoint(); m_selection = QRect(m_origin, m_origin); update(); } }

拖动时更新选区并重绘。有一个细节是QRect::normalized(),它保证用户从右下角向左上角拖拽时,得到的矩形坐标是正向的,不然以后裁剪图片也会出问题。选区的topLeft()和size()会被传给 ToolbarWindow,用来决定工具栏显示位置:出现在选区右下角偏右 8 像素、偏上 8 像素处,避免遮挡选区内容。

3.3 标注渲染:矩形、箭头、马赛克都是怎么画出来的

标注层的核心是AnnotationItem抽象基类:

// annotation/AnnotationItem.h class AnnotationItem { public: virtual ~AnnotationItem() = default; virtual void draw(QPainter *painter) = 0; virtual QRect bounds() const = 0; };

每种标注在 paintEvent 中各自绘制。窗口只负责遍历:

void AnnotationLayer::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.drawPixmap(0, 0, m_cropPixmap); for (AnnotationItem *item : m_items) { painter.save(); item->draw(&painter); painter.restore(); } }

save和restore是防止单个标注修改画笔颜色、线宽后影响后续绘制。矩形和椭圆就是drawRect和drawEllipse,箭头用的是QLineF加两条斜线,难点在于箭头夹角要固定 20 度左右,线的宽度随选区变大适当加大。

马赛克是标注里最容易被误解的模块。很多人以为是对选区做模糊滤镜,但原版截图工具的马赛克是像素块平铺,不是模糊。实现如下:

void MosaicItem::draw(QPainter *painter) { const int block = qMax(4, m_rect.width() / 40); QPixmap sample = m_source.copy(m_rect); // 从原始抓屏图取一小块 for (int y = m_rect.y(); y < m_rect.bottom(); y += block) { for (int x = m_rect.x(); x < m_rect.right(); x += block) { QRect cell(x, y, block, block); QColor avg = averageColor(sample, QRect(x - m_rect.x(), y - m_rect.y(), block, block)); painter->fillRect(cell, avg); } } }

核心参数是divisor = 40,它控制马赛克块的大小。选区越宽,块越大;最小时不小于 4 像素。m_source必须是指向原始抓屏图的引用,不能是已经画过标注的图层,否则马赛克会“越涂越糊”。averageColor是对 block 区域内所有像素求平均值,也可以简化为取像素中最常见的颜色。fillRect 比逐像素 setPixel 快得多,适合在 paintEvent 里直接调用。

3.4 结果输出:剪贴板写入与文件保存

用户按下回车后,所有标注已经绘制在裁剪好的 QPixmap 上,接下来要把它交给系统剪贴板:

// utils/ClipboardHelper.cpp #include <QClipboard> #include <QGuiApplication> void ClipboardHelper::copyPixmap(const QPixmap &pixmap) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setPixmap(pixmap, QClipboard::Clipboard); }

这个 API 看起来平平无奇,但有一个参数值得注意:QClipboard::Clipboard对应的是 Windows / macOS 的系统剪贴板,用户在聊天框粘贴时读的就是它。在 Linux 上还有QClipboard::Selection,对应中键粘贴的选区缓冲。如果要做跨平台,建议同时 set 两份,这样 Linux 用户中键也能粘贴。保存文件就一行pixmap.save(filePath, "PNG"),我一般建议默认导出 PNG 而不是 JPEG,因为截图里的文字需要无损压缩。

4. 运行与调试:从空目录到按下截图键

4.1 环境准备:Qt、CMake 与平台工具链

拿到源码包后不要急着编译,先核对环境。这个工程要求 C++17,Qt 6.5 以上版本,CMake 3.21 以上。Windows 上我建议用 MSVC 编译器而不是 MinGW,原因只有一个:全局热键和 DPI 相关 API 在 MSVC 下行为更稳定,MinGW 在配合 Qt 6 时偶尔会出现符号链接问题。

安装 Qt 时,勾选 Qt Base 和 Qt Widgets 两个组件就够了,不需要装 Qt Creator。命令行工具链需要用到 CMake 和 Ninja,Ninja 可选但它比默认的 Makefile 快不少。如果之前没配过 Qt 环境,最典型的报错是Could not find a package configuration file named "Qt6Widgets",这说明 CMake 找不到 Qt 的安装路径。

4.2 构建与启动:CMake 命令逐条解释

构建命令如下:

cmake -S . -B build \ -DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/msvc2019_64 cmake --build build --config Release -j8 ./build/A-Screenshot.exe

第一条命令中-S .指定源码目录,-B build指定构建目录,-DCMAKE_PREFIX_PATH是告诉 CMake 去哪里找 Qt 的 CMake 配置文件。这个路径要指向 Qt 安装目录下带编译器后缀的子目录,比如msvc2019_64或mingw_64。路径写错就会报开头说的找不到 Qt6Widgets。

第二条命令执行实际编译,--config Release生成发布版本,-j8并行编译。如果你用 Ninja,第二条命令可以改成cmake --build build -j8,速度差别在大型工程上更明显。编译完成后,直接运行生成的 exe 即可。程序启动后会用QSystemTrayIcon驻留在托盘,不弹主窗口,按下快捷键直接进入截图状态。

4.3 高 DPI 与多显示器:开发中必须开的三个开关

截图工具是最容易踩高 DPI 坑的应用类型。原版工具在 150% 缩放的屏幕上依然非常精细,靠的是三件事。第一件事是告诉 Qt 按像素点而不是逻辑点工作:

int main(int argc, char *argv[]) { // Qt6 默认启用高DPI;如果还要兼容 Qt5,这行必须放在 QApplication 构造之前 QApplication::setHighDpiScaleFactorRoundingPolicy( Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); QApplication app(argc, argv); // ... }

如果省略这一步,在 125% 缩放的屏幕上,截图区域选出来后图片边缘会出现细微错位,属于那种不对比看不出来、对比后又很恼火的细节。

第二件事是处理副屏负坐标。前面提过,主屏放在右边、副屏放在左边时,副屏的 topLeft 是负数。如果代码里只用primaryScreen()->geometry(),截图范围会漏掉整个副屏。我一般这么算总范围:

QRect virtualDesktopRect() { QRect total; for (const QScreen *screen : QGuiApplication::screens()) { total = total.united(screen->geometry()); } return total; }

第三件事是把devicePixelRatio传给选区裁剪函数。抓屏得到的 QPixmap 的宽度可能是逻辑宽度的 1.5 倍,裁剪时要先按比例缩放选区坐标,再copy。我见过不少改这个项目的人把裁剪坐标直接用,结果高分屏上总是截偏,这就是坐标基准没做换算。

5. 避坑与排查:高仿截图工具最容易翻车的 5 个细节

5.1 抓屏黑屏

现象:按下快捷键进入截图界面,背景整块是黑的,看不到桌面内容,但窗口本身正常。

原因:最常见的两种情况。一是按下快捷键后立即抓屏,此时主窗口虽然隐藏了,但残留的半透明窗口没有真正消失,GDI 抓屏时读取到了黑的合成表面。二是目标桌面有另一个程序以更高权限绘制了窗口,普通权限进程无法抓取那块内容,抓回来就是黑块。

解决:HotkeyManager 收到按键后,先隐藏主窗口,再QThread::msleep(100)等待桌面合成器更新,之后才调用grabWindow(0)。如果 100 毫秒后还黑,就把主窗口的Qt::WindowTransparentForInput属性去掉,不要让窗口处于隐藏和透明共存的状态。

5.2 选区拖动时背景重影

现象:拖拽选区时,背景图有一圈残影,松开鼠标后残影消失。

原因:paintEvent 里每次都重新调用grabWindow抓屏,而不是用启动截图时缓存好的那一张。抓屏是同步且昂贵的操作,在拖动选区这种高频重绘场景下,上一帧画面来不及清除,视觉上就是残影。

解决:把抓屏结果存成成员变量m_background,只在 MaskWindow 构造时抓一次,paintEvent 里只drawPixmap。从此拖动选区只涉及 QPainter 绘制,不再触碰任何系统 API。这是截图工具性能优化里投入产出比最高的一步。

5.3 马赛克边缘不齐

现象:马赛克区域边缘出现半块或者多余一列,看起来像贴图没贴齐。

原因:块坐标从矩形左上角开始算,但block不能整除矩形宽高,导致最后一行或一列超出或不足。很多初版实现会用m_rect.width() / block做整除,余数直接被丢弃,视觉上就是底部齐、右侧参差不齐。

解决:循环条件用x < m_rect.right()而不是x <= m_rect.right(),并且每个 cell 的几何都从原矩形内取,超出部分用intersected(m_rect)收敛。另外逻辑上要把块大小根据像素比调整,在高 DPI 屏上固定 8px 的块会显得颗粒太大,动态算 block 是更稳的做法。

5.4 全局快捷键注册失败

现象:程序运行后按 F1 没反应,托盘图标在,但进不了截图界面。

原因:原版聊天软件还在后台运行,占用了同一个快捷键;或者上一次运行的程序进程没退干净,新的RegisterHotKey返回失败。这个 API 失败时是静默的,不检查返回值的话你根本不知道注册失败了。

解决:在 HotkeyManager 里增加返回值检查,失败时做两件事:弹一次性提示告知快捷键冲突;把默认快捷键自动更换为 Alt+F1 并写入配置文件。我建议调试时先关掉原版软件,再启动这个高仿工程验证热键。

5.5 剪贴板粘贴出来是黑图

现象:截图完成后,在聊天窗口粘贴出来是一张黑色或全透明的图。

原因:保存到剪贴板的时机不对。如果写入剪贴板时 MaskWindow 还没销毁,系统会把遮罩窗口的一段残留画面一起复制进去;或者写入时用了带透明通道的 QImage 但没有composited到不透明背景上。

解决:完成操作后先执行maskWindow->hide(),调用一次QApplication::processEvents(),让窗口真正消失,再调ClipboardHelper::copyPixmap。保存文件前建议先用QImage::convertToFormat(QImage::Format_RGB32)去掉透明通道。这个坑最隐蔽,因为它不是每次都出现,和系统剪贴板处理时机有关,调试时多试几次才有规律。

6. 二次开发:把高仿截图改造成你自己的生产力工具

拿到这个工程后,最容易上手的改造是给它加“贴图置顶”。原版截图工具支持把截图钉在桌面上,方便一边看资料一边写代码。实现思路不难:完成截图后,不把结果直接写入剪贴板,而是同时创建一个无边框置顶窗口:

// 伪代码示意,完整实现请查阅源码包内示例 QWidget *pinWidget = new QWidget(nullptr, Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); pinWidget->setAttribute(Qt::WA_TranslucentBackground); QLabel *label = new QLabel(pinWidget); label->setPixmap(croppedPixmap); label->setScaledContents(false); pinWidget->resize(croppedPixmap.size()); pinWidget->show();

改造时重点验证三件事:第一,贴图窗口要设置Qt::Tool属性,这样它不会抢占任务栏,也不会在 Alt+Tab 切换时出现;第二,贴图窗口上的关闭按钮要用Qt::WindowCloseButtonHint且放在左上角,贴合截图工具的操作习惯;第三,如果需要在贴图上继续标注,就得复用 annotation 模块,把现有的 ToolbarWindow 挂到贴图窗口上。这三件事验证完,贴图功能基本就稳了。

我自己的习惯是每加一个新功能,先跑通主流程,再检查异常路径。比如加完 OCR 识别按钮,优先验证的不是识别精度,而是 OCR 失败时程序会不会卡死。这个工程的标注历史记录目前只支持单步撤销,你要做多步撤销的话,建议在AnnotationLayer里维护一个QUndoStack,而不是自己写 vector 回退。QUndoStack 的合并机制对连续画箭头这个场景非常合适,按下撤销键能连续回退到上一步操作。

从那以后,我每次往截图工程里加新类型,都强制自己走一遍“绘制分发—历史记录—撤销栈”三个地方,再没让某个新箭头变成黑匣子。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询