ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MFC屏幕截图实战:GDI、BitBlt与多显示器处理

MFC屏幕截图实战:GDI、BitBlt与多显示器处理 简介这是一份基于MFC与Visual Studio环境实现的屏幕截图功能资源面向C/MFC初学者及需要在Windows桌面应用中集成截屏功能的开发者。压缩包内包含完整的MFC工程涉及GDI编程、CDC设备上下文、CBitmap位图处理及BitBlt图像复制等核心知识点可帮助读者理解从获取屏幕设备、创建兼容位图到保存图像文件的完整流程。资源共22个文件以6个h头文件、3个cpp源文件为主另有对话框资源、图标、工程配置及日志等辅助文件结构清晰便于对照学习与二次修改。压缩包整体仅136KB轻量易用。目前已有634人学习适合通过实际工程快速掌握MFC屏幕捕获与位图操作技巧。1. 屏幕截图在 MFC 里不是一句 API 那么简单在 MFC 里做屏幕截图第一反应是调用PrintWindow还是直接把桌面 DC 读出来实际工程里两条路都要补课。MFC 封装的 CDC、CBitmap 只是把 GDI 对象包了一层截屏的关键不是“按下按钮”那一下而是设备上下文与位图之间的三趟转换取屏幕 DC、建兼容内存 DC、把像素 BitBlt 过去。利用 VS 的 MFC 对话框工程可以快速做出一个能截全屏并保存 BMP/JPEG 的小工具但要处理多显示器、高 DPI、位图还原这些细节否则就会拿到全黑图或者截出来只有半屏。这套代码也适合作为界面自动化测试、教学录屏、后台监控的底座。2. 搭建 MFC 截屏工程屏幕 DC 和兼容 DC 的选型2.1 在 MFC 工程里找对挂钩文件用 VS 的 MFC 应用程序向导生成工程后名称为 MFCApplication1 的默认对话框工程里真正关心的是MFCApplication1Dlg.h和MFCApplication1Dlg.cpp这是对话框生命周期所在。截屏动作一般放在按钮点击事件里Class Wizard 会自动生成消息映射函数。pch.h是预编译头如果后面要用CImage保存 JPEG就把atlimage.h加在这个文件里避免每个 cpp 重复引入。工程还包含framework.h、resource.h、MFCApplication1.rc等资源定义文件截屏代码本身不依赖它们但.rc会影响你能不能在资源视图里找到按钮 ID。项目自带的.sln和.vcxproj已经分好了 Debug/Release 与 x64 配置建议直接沿用 Release/x64不要在 Debug 下测截屏因为调试器对 GDI 对象生命周期的介入会掩盖一部分资源泄漏问题Release 下跑出来的句柄数才接近真实情况。以对话框工程为例按钮事件函数长这样void CMFCApplication1Dlg::OnBnClickedBtnCapture() { // 截屏按钮点击后先做截屏再走保存逻辑 CaptureAndSave(); }CaptureAndSave是自定义函数把截屏和写文件拆开方便后续改成定时截屏或窗口截屏。MFC 的消息映射、控件变量声明这些由向导生成不用手工维护。2.2 获取屏幕设备上下文与创建兼容 DC截屏第一步是拿到屏幕的设备上下文。可以直接用GetDC(NULL)NULL表示整个桌面。也可以用GetDC(HWND_DESKTOP)两者在当前会话下返回的都是桌面 DC。需要注意这个 DC 是“只读”性质的不能在上面直接绘图它只是用来作为源设备上下文或者作为创建兼容位图的颜色模板。有了屏幕 DC 后创建两个对象内存 DC用CreateCompatibleDC创建它是一块与屏幕颜色格式兼容的绘图画布。兼容位图用CreateCompatibleBitmap创建真正的像素存储区。常见错误是把CreateCompatibleBitmap的模板参数写成内存 DC。内存 DC 刚刚创建时只有一个 1x1 的默认表面拿它当模板生成的位图只有 1x1 像素BitBlt 后只能截到角落一个点。正确的模板必须是屏幕 DC。HDC hScreen ::GetDC(NULL); // 获取整个屏幕的DC int cx ::GetSystemMetrics(SM_CXSCREEN); // 主屏宽度 int cy ::GetSystemMetrics(SM_CYSCREEN); // 主屏高度 CDC screenDC; screenDC.Attach(hScreen); // 把HDC包装成CDC CDC memDC; memDC.CreateCompatibleDC(screenDC); // 与屏幕兼容的内存DC CBitmap bmp; bmp.CreateCompatibleBitmap(screenDC, cx, cy); // 创建存储屏幕像素的位图 CBitmap* pOld memDC.SelectObject(bmp); // 把位图选入内存DC记住旧位图Attach把HDC交给CDC对象托管后面释放时只需要调用一次ReleaseDC(NULL, screenDC.Detach())或者直接让screenDC析构后调用GetDC对应的释放接口。这里我选择手动ReleaseDC因为 MFC 的CDC析构函数不会替你释放屏幕 DC它只负责释放自己创建的内存 DC。2.3 GetSystemMetrics 参数表截全屏还是虚拟屏幕GetSystemMetrics是截屏尺寸的来源参数不同结果不同参数含义注意事项SM_CXSCREEN主屏幕宽度多显示器时只覆盖主屏SM_CYSCREEN主屏幕高度与分辨率一致SM_XVIRTUALSCREEN虚拟屏幕左上角 X扩展屏在主屏左侧时为负数SM_YVIRTUALSCREEN虚拟屏幕左上角 Y扩展屏在主屏上方时为负数SM_CXVIRTUALSCREEN虚拟屏幕总宽度包含所有显示器SM_CYVIRTUALSCREEN虚拟屏幕总高度包含所有显示器如果只截主屏SM_CXSCREEN和SM_CYSCREEN够用。如果要多显示器一起截就要用虚拟屏幕的坐标和尺寸。注意虚拟屏幕的原点不一定在左上角BitBlt的源坐标应该用虚拟屏幕的左上角值而不是0,0。很多多显示器截屏只截到一个屏就是因为直接把源坐标写成了0,0。int vx ::GetSystemMetrics(SM_XVIRTUALSCREEN); int vy ::GetSystemMetrics(SM_YVIRTUALSCREEN); int vw ::GetSystemMetrics(SM_CXVIRTUALSCREEN); int vh ::GetSystemMetrics(SM_CYVIRTUALSCREEN);用这四个值创建位图和调用 BitBlt就能覆盖所有显示器。要注意这时候位图的大小可能非常大4K 双屏合并超过 7680x216032 位像素下内存占用是 7680 * 2160 * 4约 66MB运行时需要评估 GDI 资源压力。2.4 为什么不直接用 CClientDCMFC 里还有CClientDC和CWindowDC。CClientDC只覆盖窗口客户区工具栏、菜单、边框都截不到CWindowDC覆盖整个窗口但如果目标是桌面它和GetDC(NULL)的作用类似。对全屏截屏来说CClientDC的坐标系是相对于窗口左上角直接拿来当成屏幕 DC 使用截出来的图会偏移整个窗口客户区的位置。所以全屏截屏我一般直接用CClientDC的底层替代方案也就是GetDC(NULL)它拿到的 DC 坐标系就是整个虚拟屏幕坐标系和后续 BitBlt 的坐标最匹配。3. BitBlt 把屏幕像素搬进内存 DC3.1 BitBlt 的复制语义和光栅码BitBlt的全称是 Bit Block Transfer作用是块级位图拷贝。它的签名很长但核心是五个关键参数目标 DC、目标坐标、源 DC、源坐标、复制尺寸以及光栅操作码。光栅码决定源和目标像素如何叠加截屏时最常用的是SRCCOPY表示直接覆盖。光栅码运算含义截屏场景是否适用SRCCOPY源像素直接拷贝到目标截屏首选SRCPAINT源和目标做按位或特殊合成效果SRCAND源和目标做按位与掩码处理SRCERASE源取反后与目标按位与很少用CAPTUREBLT包含被 Z 序遮挡的窗口截取分层窗口时需要CAPTUREBLT值得仔细说。普通SRCCOPY抓不到被其他窗口完全遮挡的窗口内容只能看到遮挡结果。如果写的是一个“窗口截屏”工具想捕获被盖住的窗口光栅码要加上CAPTUREBLT写法是SRCCOPY | CAPTUREBLT。但CAPTUREBLT对某些显卡驱动会使 BitBlt 性能下降所以全屏截屏不用它。#pragma comment(lib, gdi32.lib) BOOL ok memDC.BitBlt( 0, 0, cx, cy, // 目标内存DC左上角开始宽高 screenDC, // 源DC 0, 0, // 源坐标屏幕左上角 SRCCOPY // 直接拷贝 ); if (!ok) { DWORD err ::GetLastError(); TRACE(_T(BitBlt failed, err%u\n), err); }CDC的BitBlt是 MFC 对 GDI 函数的封装参数顺序和 Win32 API 一致。目标 DC 在前源 DC 在后这个顺序写反之后返回的也是 FALSE 吗不写反时目标矩形从屏幕 DC 拷贝到屏幕 DC可能截屏不报错但保存出来是黑图因为源 DC 和目标 DC 重叠且没有正确复制内容。我在排错时第一步就是检查目标 DC 是不是memDC源 DC 是不是screenDC。3.2 全屏截取的完整流程把前面的初始化、BitBlt、还原、清理合并成一个函数void CMFCApplication1Dlg::CaptureFullScreen() { HDC hScreen ::GetDC(NULL); int cx ::GetSystemMetrics(SM_CXSCREEN); int cy ::GetSystemMetrics(SM_CYSCREEN); CDC screenDC; screenDC.Attach(hScreen); CDC memDC; memDC.CreateCompatibleDC(screenDC); CBitmap bmp; bmp.CreateCompatibleBitmap(screenDC, cx, cy); CBitmap* pOldBitmap memDC.SelectObject(bmp); memDC.BitBlt(0, 0, cx, cy, screenDC, 0, 0, SRCCOPY); // 保存逻辑放在这里传入 bmp 和路径 SaveBitmapToFile(bmp, _T(capture.bmp)); memDC.SelectObject(pOldBitmap); // 还原旧位图 memDC.DeleteDC(); // 删除内存DC ::ReleaseDC(NULL, screenDC.Detach()); // 释放屏幕DC }这段流程里有两个容易漏的步骤。SelectObject(pOldBitmap)必须在memDC.DeleteDC()之前执行如果不还原旧对象就直接DeleteDC再让bmp析构GDI 内部可能出现句柄交叉引用轻则截屏偶发黑块重则DeleteObject时报错。screenDC.Detach()也很关键CDC析构不会释放外部创建的 HDCReleaseDC的参数必须是原始 HDC而不是screenDC.m_hDC包装后的副本Detach()取出句柄后释放最安全。3.3 为什么要把旧位图 SelectObject 回去这是新手最容易忽略的一步。CreateCompatibleDC创建的内存 DC 初始时自带一个 1x1 的单色位图SelectObject返回值就是那个初始位图。把截屏位图选入后内存 DC 持有了截屏位图的句柄。如果在截屏位图生命周期结束前直接调用DeleteDC释放内存 DC内存 DC 持有的位图句柄不会被自动销毁但 GDI 对象会自动解锁之后CBitmap析构再删除同一个句柄时就可能和 DC 内部缓存产生冲突。所以规范化顺序是SelectObject(旧位图)让内存 DC 回到初始状态。DeleteDC释放 DC。CBitmap析构或显式DeleteObject。这个顺序适用于所有把位图选入 DC 的场景不只是截屏。按位图、内存画布、双缓冲绘制都应该遵守。3.4 BitBlt 返回值和错误定位BitBlt返回FALSE时先用GetLastError拿到错误码。常见的错误码是ERROR_INVALID_HANDLE说明传入的 DC 或者位图句柄已经失效。另一个情况是目标矩形超出位图范围例如位图高度和屏幕高度不一致MultiMonitor 场景下源坐标是负数但目标坐标却从 0 开始这时位图右侧或下侧是黑的。解决办法是目标矩形也用虚拟屏幕的宽高并且源坐标用SM_XVIRTUALSCREEN、SM_YVIRTUALSCREEN。拿到黑图时不要急着改保存函数先在 BitBlt 后面加一个GdiFlush()强制 GDI 刷完绘制命令再读位图内容。在 MFC 对话框里调试时TRACE宏可以把错误码输出到 VS 的“输出”窗口比AfxMessageBox更不打断流程if (!ok) { TRACE(_T(BitBlt error: %d\n), ::GetLastError()); }4. 把 CBitmap 保存成文件RAW DIB 还是 CImage4.1 DDB 与 DIB 的差异CreateCompatibleBitmap创建出来的是设备相关位图也就是 DDB它和当前显卡、屏幕的颜色格式绑定内存布局直接对应显示设备的显存格式。DDB 不能直接作为标准 BMP 文件写入磁盘因为 BMP 文件要求头部包含BITMAPFILEHEADER和BITMAPINFOHEADER像素数据也需要按照固定的行对齐规则排列。要把 DDB 变成可保存的数据必须转换成设备无关位图 DIB这个转换用GetDIBits完成。GetDIBits的参数比较多核心工作是根据BITMAPINFO中指定的格式把像素拷贝到调用者提供的缓冲区。我一般统一转成 32 位BI_RGB这样不用处理调色板也避免了 24 位位图行对齐的麻烦。4.2 手写 BMP 文件保存函数下边是经过实践验证的保存函数用 32 位 top-down 格式输出保存出来的文件能被系统自带图片查看器直接打开#include vector bool SaveBitmapToFile(CBitmap* pBmp, LPCTSTR lpszPath) { BITMAP bm{}; pBmp-GetObject(sizeof(bm), bm); // 获取位图宽高和位深 const DWORD dwBpp 32; // 统一转成32位 const DWORD dwStride (((bm.bmWidth * dwBpp) 31) / 32) * 4; const DWORD dwImageSize dwStride * bm.bmHeight; std::vectorBYTE pixels(dwImageSize); BITMAPINFO bi{}; bi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth bm.bmWidth; bi.bmiHeader.biHeight -bm.bmHeight; // 负数表示top-down正数表示bottom-up bi.bmiHeader.biPlanes 1; bi.bmiHeader.biBitCount dwBpp; bi.bmiHeader.biCompression BI_RGB; bi.bmiHeader.biSizeImage dwImageSize; HDC hDC ::GetDC(NULL); if (!hDC) return false; int lines ::GetDIBits( hDC, (HBITMAP)pBmp-GetSafeHandle(), 0, bm.bmHeight, pixels.data(), bi, DIB_RGB_COLORS ); ::ReleaseDC(NULL, hDC); if (lines ! bm.bmHeight) return false; BITMAPFILEHEADER bfh{}; bfh.bfType 0x4D42; // BM bfh.bfSize sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER) dwImageSize; bfh.bfOffBits sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER); try { CFile file; if (!file.Open(lpszPath, CFile::modeCreate | CFile::modeWrite)) { return false; } file.Write(bfh, sizeof(bfh)); file.Write(bi.bmiHeader, sizeof(BITMAPINFOHEADER)); file.Write(pixels.data(), dwImageSize); file.Close(); } catch (CFileException* e) { e-ReportError(); e-Delete(); return false; } return true; }dwStride的计算是关键BMP 每行字节数必须是 4 的倍数。bm.bmWidth * 32即每行像素的位数加上 31 后除以 32 再乘 4得到按 4 字节对齐的每行字节数。biHeight设为负数时GetDIBits返回 top-down 像素排列也就是第一行就是屏幕最上方不需要再翻转像素数据很多网上代码把高度写成正数保存出来的图片上下颠倒加大负号就能解决。GetDIBits的返回值是实际扫描行数应该等于bm.bmHeight如果小于该值说明缓冲区太小或者位图句柄有问题。这里用GetDC(NULL)获取屏幕 DC是为了向GetDIBits提供兼容 DC真彩色屏幕上即使传NULL也可能工作但保留 DC 参数对老式调色板显卡更安全。4.3 用 CImage 保存 JPEG如果目标格式是 JPEG不必继续手写VS 自带的CImage类提供了Save方法可以直接从 CBitmap 包装出图像#include atlimage.h bool SaveBitmapAsJpeg(CBitmap* pBmp, LPCTSTR lpszPath) { // 从CBitmap获取HBITMAP用FromHandle包装避免所有权转移 CImage image; image.FromHandle((HBITMAP)pBmp-GetSafeHandle()); // CImage::Save内部走GDI需要确保GDI可用 HRESULT hr image.Save(lpszPath, Gdiplus::ImageFormatJPEG); return SUCCEEDED(hr); }FromHandle不会取得位图的所有权所以CBitmap对象生命周期必须长于image.Save调用。CImage::Save内部依赖 GDI在 MFC 对话框程序里最好在CWinApp::InitInstance中调用一次GdiplusStartup否则高版本 Windows 上 Save 可能返回E_FAIL。如果不想全局初始化也可以在函数内部临时启动 GDI保存完立即关闭但频繁开关会影响性能适合低频截图。CImage 还有一个优点它可以直接从文件加载图像并绘制到内存 DC做截屏预览时可以用它替代 CBitmap。不过在截屏核心环节我还是保留 CBitmap BitBlt 的流程因为 CImage 的BitBlt是对成员句柄的操作和 MFC 原有的 CDC 对象封装配合起来偶尔会出奇奇怪怪的句柄问题。4.4 文件格式和异常处理选择保存 BMP 还是 JPEG取决于用途。需要无损截图、后续做像素比对、或者输出到 OCR就选 BMP 或 PNG只需要记录界面状态选 JPEG 可以显著缩小文件体积。CImage 支持Gdiplus::ImageFormatPNG但 PNG 是无损压缩处理速度比 JPEG 慢对纯界面截图区别不大。CFile::Open失败时除了路径不存在最常见的原因是目标目录没有写权限或者文件被其他程序锁定。捕获CFileException后可以用e-m_cause判断具体错误码m_lOsError对应系统错误号。把这两项输出到日志或TRACE中比只弹一个“保存失败”更利于定位问题。5. 从全屏到窗口DPI、定时与截屏验证5.1 只截指定窗口全屏截取跑通后截指定窗口只需替换 DC 和矩形范围。用GetWindowRect拿到窗口的屏幕坐标用GetWindowDC拿到窗口 DCHWND hWnd ::FindWindow(NULL, _T(计算器)); CRect rc; ::GetWindowRect(hWnd, rc); int w rc.Width(); int h rc.Height(); HDC hWndDC ::GetWindowDC(hWnd); CDC wndDC; wndDC.Attach(hWndDC); CDC memDC; memDC.CreateCompatibleDC(wndDC); CBitmap bmp; bmp.CreateCompatibleBitmap(wndDC, w, h); CBitmap* pOld memDC.SelectObject(bmp); memDC.BitBlt(0, 0, w, h, wndDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); ::ReleaseDC(hWnd, wndDC.Detach());GetWindowDC返回的 DC 包含窗口非客户区也就是标题栏和边框。如果要截客户区用GetDC或者在CClientDC里取。源矩形是一整个窗口而保存的位图大小正好等于窗口大小所以不需要做坐标换算。截取窗口时还要考虑窗口是否被遮挡如果窗口被其他窗口完全盖住普通SRCCOPY截不到窗口内容只能截到遮挡窗口的画面。这种情况下要么把窗口SetForegroundWindow后再截要么用PrintWindow让窗口自己重绘到 DC。5.2 高 DPI 截屏只切一半的处理在 Windows 10/11 下如果程序没有声明 DPI Aware系统会做 DPI 虚拟化GetSystemMetrics返回的是缩放后的逻辑分辨率。比如 4K 显示器的实际分辨率是 3840x2160缩放 150%GetSystemMetrics(SM_CXSCREEN)返回的可能是 2560 而不是 3840。这时创建出来的位图宽度只有 2560BitBlt 复制时也只复制了 2560 宽度的像素截出来的图只占屏幕左侧一部分。解决办法是在程序启动早期调用::SetProcessDPIAware();在 MFC 的InitInstance里放在第一行。这个 API 让进程使用系统 DPI 感知模式之后GetSystemMetrics返回真实物理像素。更推荐的方式是通过 manifest 声明 per-monitor DPI aware但SetProcessDPIAware对一个小工具来说改动最小。注意如果程序里还要适配多个显示器不同的缩放比仅靠这个 API 还不够需要用GetDpiForMonitor配合每个显示器单独处理我一般只在单显示器截图场景下用SetProcessDPIAware。5.3 定时截屏和 GDI 资源回收做监控类工具时可以加SetTimer触发定时截屏。MFC 对话框里用SetTimer(1, 3000, NULL)每 3 秒截一次注意每次截完都要完整走一遍还原、释放流程否则 GDI 句柄会持续累积。在 XP 时代GDI 句柄数上限是 10000现代 Windows 也有限制截图工具跑一天下来就是几十万个 BitBlt每一个泄漏的 DC 或位图都是定时炸弹。验证资源是否回收可以在任务管理器里加上“GDI 对象”列连续截图 100 次如果对象数稳定在一个范围就说明资源释放正常如果一直往上涨优先排查CreateCompatibleDC后是否DeleteDC、GetDC后是否ReleaseDC、CreateCompatibleBitmap后是否DeleteObject。5.4 用图片尺寸反推截屏是否正确截完图后把保存文件的宽高和GetSystemMetrics的返回值对比能快速定位多显示器和 DPI 问题。BMP 文件头本身就包含宽高信息可以读取BITMAPINFOHEADER.biWidth和biHeight与预期对比。如果截图高度是负数说明保存函数用了 bottom-up 格式图片查看器会自动翻转但 OCR 工具直接读 DIB 时可能拿到上下颠倒的像素这时把biHeight取绝对值即可。这个验证步骤比肉眼看截图更可靠因为截屏程序在虚拟屏幕范围扩大时图片内容可能只占据文件左半边但尺寸完全正常肉眼看不出异常。把坐标、尺寸、返回值组合起来打一次日志所有变量都对齐截屏功能才算真正闭环。本文还有配套的精品资源点击获取
返回列表