多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MFC屏幕截图实战:从GDI BitBlt到DPI与多显示器适配

MFC屏幕截图实战:从GDI BitBlt到DPI与多显示器适配 简介面向 MFC/C 开发者的屏幕截图示例工程基于 Visual Studio 和 MFC 框架演示如何借助 GDI、CDC、CBitmap、BitBlt 等核心 API 捕获整个屏幕或指定窗口并保存为 BMP/JPEG 文件。工程代码包含对话框界面与完整截屏实现适合初学 Windows 桌面图形编程的读者参考。压缩包共 22 个文件以 h/cpp 源码、rc 资源脚本和 vcxproj/sln 工程文件为主并附带 Debug/Release 构建记录与项目配置整体仅 136KB便于快速下载和本地编译。已有 634 人学习使用。读者可以获得一份可运行的 MFC 截屏程序源码其中覆盖获取设备上下文、创建兼容位图、SelectObject 切换对象、BitBlt 像素复制、资源释放与保存图像等关键环节工程目录结构清晰能帮助理解 MFC 对话框程序的组织方式也可在此基础上扩展定时截屏、区域截屏或窗口截屏等功能。1. 截屏在MFC项目里不是调个 API 那么简单做 MFC 桌面工具时截屏需求往往不是单独出现的要么是截图工具需要框选保存要么是远程协助要定时抓取对方屏幕要么是录屏模块需要先拿到一帧位图。很多人在 VS 里写截屏搜到的第一个答案是CWnd::GetDC加BitBlt但真跑起来才发现高 DPI 下坐标偏移、多显示器负坐标截不全、窗口被遮挡时截到别人的画面、保存成 PNG 还要另引库。这些问题都不是BitBlt本身能解决的。MFC 本身不提供专门的截屏类本质上还是走 Win32 GDI 或 DirectX 的捕获链路。区别在于 MFC 封装了CDC、CBitmap让我们能少写一些句柄释放的样板代码但也容易掩盖资源泄露和兼容性细节。本文围绕“MFC VS 截取屏幕图片”这条主线把从设备上下文到像素数据、再到保存文件的完整链路拆开给出可直接放进工程的代码同时把 DPI、多屏、黑屏这些坑点一并说清。适合正在做屏幕捕获类工具、或者需要在 MFC 对话框里集成截图功能的开发者新手能照抄老手可以对照检查自己的实现有没有漏掉边界条件。2. 选型与原理从 BitBlt 到 GetDIBitsMFC 截屏的底层链路2.1 为什么大多数 MFC 截屏方案首选 GDI屏幕截图本质是把显存里的画面拷贝到内存位图。Win32 下能做到这件事的接口并不少GDI 的BitBlt、DirectX 的IDXGIOutputDuplication、Windows 10 以后的GraphicsCapture。但在 MFC/VS 工程里GDI 仍然是默认首选原因有三个第一GDI 不依赖图形设备接口的初始化在对话框、控制台、服务程序里都能直接调用第二MFC 的CDC和CBitmap对 GDI 做了完整封装代码量最小第三对于静态画面或低频截屏每秒几帧GDI 的BitBlt性能完全足够CPU 占用也低。它的问题同样明显如果游戏或视频播放器采用硬件加速覆盖层DXGI flip modelGDI 抓到的可能是黑屏或空白。这是方案边界。所以选型时要先判断目标场景截普通桌面、Office 窗口、自绘界面GDI 足够截 DirectX 游戏画面必须换 DXGI。下面的实现和排错部分都会回到这个边界上。2.2 核心 API 调用链与内存 DC 的作用GDI 截屏的调用链是固定的GetDC(NULL)拿到屏幕设备上下文CreateCompatibleDC创建内存设备上下文CreateCompatibleBitmap按屏幕尺寸创建兼容位图然后BitBlt把像素从屏幕 DC 拷贝到内存 DC最后GetDIBits把 DDB设备相关位图转成 DIB设备无关位图拿到原始像素。整个过程的关键在“兼容”二字CreateCompatibleBitmap的位图必须与原 DC 的颜色格式一致否则BitBlt会做颜色转换轻则偏色重则花屏。// 核心调用链屏幕DC - 内存DC - 兼容位图 - 像素数据 HDC hScreenDC ::GetDC(NULL); HDC hMemDC ::CreateCompatibleDC(hScreenDC); int width ::GetSystemMetrics(SM_CXSCREEN); int height ::GetSystemMetrics(SM_CYSCREEN); HBITMAP hBitmap ::CreateCompatibleBitmap(hScreenDC, width, height); HGDIOBJ hOldBmp ::SelectObject(hMemDC, hBitmap); BOOL ok ::BitBlt(hMemDC, 0, 0, width, height, hScreenDC, 0, 0, SRCCOPY);这段代码里SelectObject之后的hOldBmp务必在函数结束前换回否则位图句柄被内存 DC 继续持有DeleteObject会失败。SRCCOPY是光栅操作码表示直接覆盖不做任何混色如果只想截某个窗口把源 DC 换成该窗口的 DC并把源坐标设为窗口客户区坐标或窗口坐标相对屏幕的偏移即可。2.3 DPI 感知与多显示器坐标的关键点很多截屏代码在普通电脑上正常到了高分屏就只截到四分之一画面根本原因是进程没声明 DPI 感知。系统默认对 GDI 做位图拉伸GetSystemMetrics(SM_CXSCREEN)返回的是虚拟化后的尺寸而BitBlt拷贝的是物理像素区域二者不一致。在 VS 的项目属性里清单工具 - 输入与输出 - DPI 感知选择Per Monitor High DPI Aware或者在代码里调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)可以解决。多显示器场景下主屏可能不是左上角原点副屏坐标可能是负数。GetSystemMetrics(SM_XVIRTUALSCREEN)和SM_YVIRTUALSCREEN取虚拟屏幕左上角SM_CXVIRTUALSCREEN、SM_CYVIRTUALSCREEN取整个跨屏矩形。用这套坐标替代SM_CXSCREEN才能准确覆盖所有显示器。方案是否支持硬件加速画面性能拖黑/白屏风险复杂度GDI BitBlt不支持DXGI flip 模型下黑屏中适合低频高低DXGI Desktop Duplication支持高适合实时低高GraphicsCaptureWin10支持中高中中表格里的“拖黑/白屏风险”指目标窗口被遮挡、最小化或使用硬件覆盖时截不到内容的情况。做 MFC 工具初期先上 GDI遇到黑屏再换 DXGI是成本最低的路径。3. MFC/VS 工程下的截屏代码实现全屏、窗口、区域3.1 从设备上下文到像素数据的完整代码3.1.1 封装一个截屏函数把上一章的 API 链封装成可复用的类放在一个ScreenshotHelper.cpp里。这里用 MFC 的CDC和CBitmap替代裸句柄方便和CWnd交互。// ScreenshotHelper.h #pragma once #include afxwin.h class CScreenshotHelper { public: // 截取虚拟屏幕全屏返回 DIB 像素数据 static BOOL CaptureVirtualScreen(CImage image); // 截取指定窗口的客户区 static BOOL CaptureWindow(HWND hWnd, CImage image); // 截取屏幕指定矩形区域坐标为物理像素 static BOOL CaptureRect(const CRect rect, CImage image); // 保存 CImage 到文件自动按扩展名选择格式 static BOOL SaveImage(const CImage image, const CString filePath); };实现文件里CaptureVirtualScreen用GetSystemMetrics取虚拟屏幕坐标确保多显示器全覆盖。注意 CImage 在 MFC 下可以直接绑定 HBITMAP省去手动GetDIBits的繁琐步骤。// ScreenshotHelper.cpp #include pch.h #include ScreenshotHelper.h BOOL CScreenshotHelper::CaptureVirtualScreen(CImage image) { int x ::GetSystemMetrics(SM_XVIRTUALSCREEN); int y ::GetSystemMetrics(SM_YVIRTUALSCREEN); int cx ::GetSystemMetrics(SM_CXVIRTUALSCREEN); int cy ::GetSystemMetrics(SM_CYVIRTUALSCREEN); HDC hScreenDC ::GetDC(NULL); if (!hScreenDC) return FALSE; HDC hMemDC ::CreateCompatibleDC(hScreenDC); HBITMAP hBitmap ::CreateCompatibleBitmap(hScreenDC, cx, cy); if (!hBitmap) { ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); return FALSE; } HGDIOBJ hOldBmp ::SelectObject(hMemDC, hBitmap); BOOL ok ::BitBlt(hMemDC, 0, 0, cx, cy, hScreenDC, x, y, SRCCOPY); // 将 HBITMAP 附加到 CImage成功后 CImage 接管所有权 image.Attach(hBitmap); ::SelectObject(hMemDC, hOldBmp); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); return ok; }逻辑说明先取虚拟屏幕覆盖范围保证副屏在主屏左边时负坐标也能正确映射。BitBlt的源坐标是(x, y)而不是(0, 0)因为屏幕 DC 的原点是主屏左上角虚拟屏幕起点可能是负值。image.Attach(hBitmap)让 CImage 自管理位图句柄之后image.Save和image.Destroy会正确处理释放。3.1.2 参数说明与调用逻辑上面函数的核心参数有四个hMemDC决定目标是内存画布hScreenDC决定源是屏幕cx/cy决定尺寸x/y决定从屏幕哪个点开始拷。对于单显示器SM_XVIRTUALSCREEN和SM_YVIRTUALSCREEN都是 0行为与SM_CXSCREEN无异对于左侧副屏SM_XVIRTUALSCREEN是负值BitBlt的源坐标必须是这个负值才能把副屏内容完整截到。调用处可以在 MFC 对话框按钮响应里直接使用void CMainDlg::OnBnClickedBtnCapture() { CImage img; if (CScreenshotHelper::CaptureVirtualScreen(img)) { CScreenshotHelper::SaveImage(img, Lscreen.png); AfxMessageBox(L截图已保存); } else { AfxMessageBox(L截图失败); } }3.2 截取指定窗口与指定区域截取指定窗口时先判断窗口是否最小化最小化状态下客户区大小为零。然后用GetWindowRect取窗口相对屏幕的坐标再根据GetClientRect换算客户区偏移。最简单的方式是直接截窗口矩形但会包含标题栏和边框要截客户区需要把窗口 DC 的源坐标调整为(0, 0)并让CreateCompatibleBitmap使用客户区尺寸。BOOL CScreenshotHelper::CaptureWindow(HWND hWnd, CImage image) { if (!::IsWindow(hWnd)) return FALSE; // 最小化时先还原否则客户区尺寸为 0 if (::IsIconic(hWnd)) { ::ShowWindow(hWnd, SW_RESTORE); ::Sleep(100); // 等待窗口重绘 } CRect rect; ::GetClientRect(hWnd, rect); // 客户区尺寸 if (rect.IsRectEmpty()) return FALSE; HDC hWndDC ::GetDC(hWnd); HDC hMemDC ::CreateCompatibleDC(hWndDC); HBITMAP hBitmap ::CreateCompatibleBitmap(hWndDC, rect.Width(), rect.Height()); HGDIOBJ hOldBmp ::SelectObject(hMemDC, hBitmap); BOOL ok ::BitBlt(hMemDC, 0, 0, rect.Width(), rect.Height(), hWndDC, 0, 0, SRCCOPY); image.Attach(hBitmap); ::SelectObject(hMemDC, hOldBmp); ::DeleteDC(hMemDC); ::ReleaseDC(hWnd, hWndDC); return ok; }注意点在于GetClientRect得到的矩形左上角永远是(0,0)所以BitBlt源坐标直接用(0,0)不需要再算标题栏高度。MFC 里如果拿的是CWnd*可以直接wnd-GetDC()但GetDC返回的CDC*需要通过GetSafeHdc()转成HDC才能给 GDI 函数用。混用 CImage 和GetDC时要留意CImage::Attach之后不要再手动删除HBITMAP否则 double-free。截取指定区域更直接把屏幕 DC 的源坐标换成目标矩形左上角即可。注意矩形必须经过 DPI 换算否则在 150% 缩放下用户框选的 100x100 像素实际对应物理 150x150结果会截偏。3.3 保存为 BMP/PNG 的两种方式CImage在 MFC 工程里自带Save方法可以直接写文件BOOL CScreenshotHelper::SaveImage(const CImage image, const CString filePath) { CString ext filePath.Right(3); ext.MakeLower(); GUID fmt Gdiplus::ImageFormatBMP; if (ext Lpng) fmt Gdiplus::ImageFormatPNG; else if (ext Ljpg || ext Ljpeg) fmt Gdiplus::ImageFormatJPEG; HRESULT hr image.Save(filePath, fmt); return SUCCEEDED(hr); }CImage::Save的第二个参数是 GDI 的编码器 GUID。ImageFormatPNG是 GDI 提供的全局常量可以直接用。需要说明的是BMP 虽然无损且不用额外编码器但文件体积大PNG 适合保存 UI 截图体积小且无损JPG 适合有渐变的画面但有损。VS 工程里默认连接 GDICImage底层已经处理好了不需要再GdiplusStartup。如果你不想依赖 CImage也可以直接用GetDIBits拿原始像素再手写 BMP 文件头。那种方式更接近底层但代码量翻倍且要自己处理 32 位 alpha 通道除非有特殊需求否则不建议在 MFC 里折腾。4. 实战优化DPI 缩放、多屏负坐标与性能4.1 在 VS 里启用 DPI 感知并修正坐标默认情况下 MFC 对话框程序不是 DPI 感知的GetSystemMetrics返回的是缩放后的逻辑尺寸。在 VS 工程里做两步设置第一步项目属性 -配置属性 - 清单工具 - 输入与输出 - DPI 感知选Per Monitor High DPI Aware第二步在CWinApp::InitInstance最前面显式声明防止某些系统策略覆盖清单。BOOL CMyApp::InitInstance() { // 必须在创建任何窗口之前设置 ::SetProcessDpiAwarenessContext( DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); // 其余初始化代码... }这一步直接影响GetSystemMetrics(SM_CXVIRTUALSCREEN)的返回值。未设置前虚拟屏幕宽度可能是 1920缩放后的值而实际物理像素是 2880150% 缩放CreateCompatibleBitmap只创建 1920 宽BitBlt却从屏幕 DC 拷贝 2880 的源导致右侧被截断。设置后所有 GDI 坐标都变成物理像素截屏结果与屏幕一一对应。4.2 多显示器负坐标的裁剪处理当主屏不在最左侧时虚拟屏幕矩形包含负坐标直接作为 BitBlt 的源坐标没问题但如果你想先拷贝到局部变量再处理或者需要把屏幕图片绘制到 MFC 窗口上负坐标会成为麻烦CImage的左上角像素索引从 0 开始负坐标需要平移。一个通用做法是记录虚拟屏幕起点在后续绘制或保存时统一偏移// 获取虚拟屏幕的全局坐标供后续裁剪使用 CRect virtualRect; virtualRect.left ::GetSystemMetrics(SM_XVIRTUALSCREEN); virtualRect.top ::GetSystemMetrics(SM_YVIRTUALSCREEN); virtualRect.right virtualRect.left ::GetSystemMetrics(SM_CXVIRTUALSCREEN); virtualRect.bottom virtualRect.top ::GetSystemMetrics(SM_CYVIRTUALSCREEN); // 用户框选区域可能是屏幕坐标 CRect userRect; // 与虚拟屏幕求交集 userRect.IntersectRect(userRect, virtualRect); // 计算在虚拟屏幕位图中的偏移 int offsetX userRect.left - virtualRect.left; int offsetY userRect.top - virtualRect.top;这样得到的offsetX/offsetY就是用户框选区域在整块截屏位图中的像素坐标。如果直接用屏幕坐标去索引位图在双屏一左一右时必然错位。4.3 定时循环截屏的内存管理做定时截屏比如每 100ms 抓一次时最容易出问题的地方是CImage多次Attach导致句柄泄漏以及位图对象反复创建销毁造成的内存碎片。建议复用一个CImage实例每次截屏前先将旧的Detach并销毁再Attach新位图。void CMonitorDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent kCaptureTimer) { CImage img; if (CScreenshotHelper::CaptureVirtualScreen(img)) { // 处理或保存当前帧 // 注意如果保存到文件不再需要 img 后调用 img.Destroy() img.Save(m_targetDir L\\frame.png, Gdiplus::ImageFormatPNG); } img.Destroy(); // 释放位图内存 } CDialogEx::OnTimer(nIDEvent); }在循环里CImage的析构会自动释放位图但如果你把img保存到成员变量里重复使用就务必先Destroy否则Attach新位图会覆盖旧句柄造成泄漏。实际测试中使用GetDC(NULL)BitBlt截取 3840x2160 屏幕单帧耗时约 20ms 到 50ms取决于显卡驱动和是否首次分配显存。持续高频截屏时建议把定时器间隔控制在 50ms 以上避免 GDI 对象耗尽导致桌面闪烁。操作耗时3840x2160i7 12700主要瓶颈CreateCompatibleBitmap1~3ms显存/内存分配BitBlt 全屏15~40ms显存拷贝与格式转换CImage::Save PNG100~300ms编码器压缩CImage::Save BMP5~10ms几乎无压缩表格数据说明保存 PNG 的耗时远高于截屏本身。如果要做连拍或者录屏应该先把位图对象缓存下来后续异步保存尽量不要在截屏线程里做Save。5. 更高效率的进阶技巧与踩坑记录5.1 用 DXGI 捕获绕过黑屏问题当目标窗口使用了硬件加速的 DXGI flip model常见于现代浏览器、视频播放器、游戏GDI 的BitBlt会返回全黑。在 MFC 里解决这个问题工程上最务实的方案是使用IDXGIOutputDuplication。核心逻辑是创建 IDXGIFactory1、枚举输出、DuplicateOutput然后每帧AcquireNextFrame拿到IDXGIResource并拷贝为共享纹理再通过Map读取像素。由于涉及 COM 初始化需要确保 MFC 工程的InitInstance里调用过CoInitializeEx。// DXGI 捕获关键步骤节点摘录 IDXGIOutputDuplication* pDeskDupl nullptr; HRESULT hr pOutput-DuplicateOutput(pDevice, pDeskDupl); DXGI_OUTDUPL_FRAME_INFO frameInfo; IDXGIResource* pDesktopResource nullptr; hr pDeskDupl-AcquireNextFrame(500, frameInfo, pDesktopResource); ID3D11Texture2D* pAcquiredTexture nullptr; pDesktopResource-QueryInterface(__uuidof(ID3D11Texture2D), (void**)pAcquiredTexture); // 将纹理拷贝到 staging texture 后读取像素这个方案通常需要 D3D11 设备和 staging texture代码量是 GDI 方案的三倍以上。但AcquireNextFrame返回的是 GPU 上的纹理DuplicateOutput内部会获取鼠标指针和脏区域支持实时截取被遮挡的窗口。做远程协助或录屏工具时这个方案是绕不开的。如果你的项目只需要静态截图BitBlt加一个检测如果BitBlt结果全黑且目标窗口是 DXGI 渲染再切换到 DXGI是最省资源的做法。5.2 截屏后直接送剪贴板很多时候用户需要把截图直接粘贴到聊天窗口或文档而不是保存成文件。MFC 里操作剪贴板的标准方式是OpenClipboard、EmptyClipboard、SetClipboardData配合CF_DIBV5格式可以让后续程序正确识别 alpha 通道。简单截图不需要 alpha用CF_DIB即可。// 将 CImage 放入剪贴板 BOOL PutImageToClipboard(CImage image) { if (!::OpenClipboard(AfxGetMainWnd()-GetSafeHwnd())) return FALSE; ::EmptyClipboard(); // 获取 DIB 数据 CImage dibImage; dibImage.Create(image.GetWidth(), image.GetHeight(), 32, CImage::createAlphaChannel ? 0 : CImage::noAlphaChannel); ::BitBlt(dibImage.GetDC(), 0, 0, dibImage.GetWidth(), dibImage.GetHeight(), image.GetDC(), 0, 0, SRCCOPY); dibImage.ReleaseDC(); HANDLE hDib dibImage.GetBits(); // 注意CImage 不直接提供 HBITMAP 转 DIB 的句柄 // 常见做法用 GetDIBits 填充 BITMAPINFO 像素数组 // 此处仅展示流程完整实现需要组装 BITMAPINFOHEADER 并分配 HGLOBAL ::SetClipboardData(CF_DIB, hDib); ::CloseClipboard(); return TRUE; }这段代码省略了小块内存细节核心是用GetDIBits把位图转成 DIB 并放入HGLOBAL。注意剪贴板数据由系统接管所有权不要手动释放hDib。CF_DIB格式不支持 alpha透明的 PNG 截图建议使用CF_DIBV5并设置BITMAPV5HEADER.bV5AlphaMask 0xFF000000。5.3 验证截屏结果的方法截屏功能写完不建议只看文件能不能打开。常见验证方法有三个第一像素级校验写一段脚本读取 BMP 中某个已知位置的颜色与屏幕实际显示对照第二边界测试在双屏且副屏在左侧的环境中截取副屏上的指定窗口检查图片尺寸与窗口尺寸是否一致第三内存检查在任务管理器中观察进程 GDI 对象数量连续截图 100 次后 GDI 对象数应保持稳定如果持续上涨说明位图或 DC 没释放。用 VS 自带诊断工具也可以跟踪HBITMAP句柄数量但 GDI 对象计数在任务管理器里看得更直观。最后补充一个容易被忽略的技巧BitBlt的SRCCOPY|CAPTUREBLT标志可以把被窗口遮挡住的层叠窗口也捕获进来。普通窗口画图时不会把隐藏区域的内容渲染到表面加了CAPTUREBLT可以强制捕获这些区域但只对 GDI 窗口有效对 DirectX 覆盖层仍然无效。在 MFC 对话框里做截图工具时这个标志值得加上它能减少很多“截出来空白”的意外。本文还有配套的精品资源点击获取
返回列表