简介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 为什么不用 ElectrondesktopCapturer 解决不了交互细节很多截图工具用 Electron 实现因为 desktopCapturer 可以拿到屏幕流配上 HTML 的选区很容易做原型。但做到“1:1 高仿”这个程度就不够了原版截图时遮罩层是半透明的选区外压暗、选区内保持原亮度鼠标拖拽时选区边框跟随像素级刷新标注箭头有抗锯齿马赛克是块状填充而不是高斯模糊。Electron 的渲染进程和桌面层隔着一层 IPC窗口穿透、全局热键、剪贴板行为都要走原生模块交互延迟很难压到 Qt Widgets 那么低。这个项目选的是 C17 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 数据流一个截图请求从按键到进入剪贴板的完整链路完整流程是这样走的HotkeyManager 在启动时调用RegisterHotKey注册全局截图键。用户按下快捷键系统把 WM_HOTKEY 消息投递给消息队列。主窗口收到消息后隐藏自身等待约 100 毫秒让桌面重绘。ScreenCapturer 调用grabWindow(0)抓取整块虚拟桌面。MaskWindow 带着抓屏结果全屏显示进入选区模式。用户拖出选区ToolbarWindow 出现在选区右下角。用户选择标注类型并完成编辑按回车或点击“完成”。裁剪选区内图像交给 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 与平台工具链拿到源码包后不要急着编译先核对环境。这个工程要求 C17Qt 6.5 以上版本CMake 3.21 以上。Windows 上我建议用 MSVC 编译器而不是 MinGW原因只有一个全局热键和 DPI 相关 API 在 MSVC 下行为更稳定MinGW 在配合 Qt 6 时偶尔会出现符号链接问题。安装 Qt 时勾选 Qt Base 和 Qt Widgets 两个组件就够了不需要装 Qt Creator。命令行工具链需要用到 CMake 和 NinjaNinja 可选但它比默认的 Makefile 快不少。如果之前没配过 Qt 环境最典型的报错是Could not find a package configuration file named Qt6Widgets这说明 CMake 找不到 Qt 的安装路径。4.2 构建与启动CMake 命令逐条解释构建命令如下cmake -S . -B build \ -DCMAKE_PREFIX_PATHD:/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 里增加返回值检查失败时做两件事弹一次性提示告知快捷键冲突把默认快捷键自动更换为 AltF1 并写入配置文件。我建议调试时先关掉原版软件再启动这个高仿工程验证热键。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属性这样它不会抢占任务栏也不会在 AltTab 切换时出现第二贴图窗口上的关闭按钮要用Qt::WindowCloseButtonHint且放在左上角贴合截图工具的操作习惯第三如果需要在贴图上继续标注就得复用 annotation 模块把现有的 ToolbarWindow 挂到贴图窗口上。这三件事验证完贴图功能基本就稳了。我自己的习惯是每加一个新功能先跑通主流程再检查异常路径。比如加完 OCR 识别按钮优先验证的不是识别精度而是 OCR 失败时程序会不会卡死。这个工程的标注历史记录目前只支持单步撤销你要做多步撤销的话建议在AnnotationLayer里维护一个QUndoStack而不是自己写 vector 回退。QUndoStack 的合并机制对连续画箭头这个场景非常合适按下撤销键能连续回退到上一步操作。从那以后我每次往截图工程里加新类型都强制自己走一遍“绘制分发—历史记录—撤销栈”三个地方再没让某个新箭头变成黑匣子。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?