首页 / 资讯中心 / 文章详情

MFC C++屏幕截图:GDI BitBlt完整实现与常见问题

MFC C++屏幕截图:GDI BitBlt完整实现与常见问题 ★ FEATURED ARTICLE
简介这份资源面向使用MFC与Visual Studio从事C桌面开发的程序员提供了一个可直接运行的屏幕截图示例工程解决了在Windows环境下捕获全屏或指定窗口并保存为BMP/JPEG等格式的核心需求。压缩包共包含22个文件大小约136KB其中6个h头文件与3个cpp源文件构成主要代码框架rc和ico用于界面与图标资源另有vcxproj、sln等工程配置以及完整的Debug/Release编译记录便于直接打开项目查看或二次扩展。借助这段示例读者能深入理解GDI设备上下文、CDC、CBitmap、BitBlt等关键API的配合方式并掌握从获取屏幕DC、创建兼容位图、复制像素到释放资源的完整链路。资源已有636人学习浏览尤其适合刚接触MFC图形编程、希望以最小成本掌握截屏原理的初学者也可作为定时截屏、指定窗口截屏等进阶功能的改造起点。1. MFC 加 C 的程序要截屏这份资源解决的是“从哪下手”MFC 加 C 的桌面程序跑着跑着就要留一张现场图这个需求看起来小真动手又挺烦。你可以在 VS 里新建一个对话框工程从GetDC、CreateCompatibleDC、CreateCompatibleBitmap、BitBlt一路写下来每一步都可能踩坑尤其是“截出来是黑屏”和“文件打不开”。这份资源包里的 MFCApplication1 工程就是一条已经走通的截屏路径把整个屏幕抓成位图再落盘成 BMP 或 JPEG。适合两类人用一类是在老 MFC 系统里加截屏功能的一线开发一类是把 GDI 设备上下文当入门题的 C 学习者。它不是个只能跑的黑匣子工程结构、消息函数、保存函数都可以按着自己的需求改。2. 为什么用 GDI 而不是直接读像素先弄懂设备上下文2.1 屏幕不是一块内存而是一串“设备上下文”Windows 下绘制界面默认走 GDIGraphics Device InterfaceMFC 对 GDI 做了一层包装。屏幕、窗口、打印机、内存画布在 GDI 眼里都叫“设备上下文”Device Context简称 DC。它不是一块能让你直接memcpy的内存而是一个绘图表面的句柄所有基于 GDI 的绘制、复制、重绘都得通过这些 DC 来排队。MFC 里的CDC类就是 GDI 设备上下文的 C 封装CBitmap则封装了HBITMAP位图句柄。很多人误以为“截屏”就是把屏幕像素读到一个数组里实际上流程是先借一个屏幕 DC再建一个内存 DC再建一张和屏幕格式兼容的位图最后用BitBlt把屏幕 DC 上的像素复制到内存 DC 关联的位图里。这里有个非常关键的细节CreateCompatibleDC创建出来的内存 DC默认只带一张 1x1 像素的单色位图。你必须用SelectObject把一张真正够大的HBITMAP选进去之后BitBlt才有地方落笔。否则就算代码看着没报错截出来的图也大概率是黑块或者只有左上角一个小点。2.2 从 GetDC 到 BitBlt五个函数把屏幕搬进位图截屏最核心的调用序列是固定的先写出来// 1. 拿到整个屏幕的设备上下文NULL 表示桌面 HDC hScreenDC ::GetDC(NULL); // 2. 主屏宽高注意这里拿的是主屏不是虚拟桌面 int cx ::GetSystemMetrics(SM_CXSCREEN); int cy ::GetSystemMetrics(SM_CYSCREEN); // 3. 创建和屏幕 DC 兼容的内存 DC HDC hMemDC ::CreateCompatibleDC(hScreenDC); // 4. 创建和屏幕 DC 兼容的位图像素格式会自动跟着屏幕走 HBITMAP hBmp ::CreateCompatibleBitmap(hScreenDC, cx, cy); // 5. 把位图选进内存 DC替换掉内存 DC 默认的 1x1 位图 HGDIOBJ hOldBmp ::SelectObject(hMemDC, hBmp); // 6. 从屏幕 DC 左上角复制 cx * cy 像素到内存 DC BOOL bCopied ::BitBlt(hMemDC, 0, 0, cx, cy, hScreenDC, 0, 0, SRCCOPY); // 清理顺序不要乱先还原旧对象再删位图再删内存 DC最后归还屏幕 DC ::SelectObject(hMemDC, hOldBmp); ::DeleteObject(hBmp); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC);BitBlt的参数看着多其实分成四组前两个是目标 DC 的起始坐标中间两个是复制区域的宽高第五个是源 DC再后面两个是源 DC 的起始坐标最后一个SRCCOPY是光栅操作码表示直接把源像素原样复制过来不做任何位运算。这里的“兼容”指的是颜色深度和像素格式一致所以CreateCompatibleBitmap必须用屏幕 DC 来创建不能拿内存 DC 去创建否则颜色格式可能错位。提示GetDC(NULL)借来的 DC 要归还用ReleaseDC(NULL, hScreenDC)CreateCompatibleDC创建出来的 DC 是自己拥有的用DeleteDC销毁。借来的还回去自己建的销毁这个配对习惯能省掉后面 80% 的 GDI 泄漏排查。3. 落地实现把截屏代码接进 MFCApplication1 的对话框工程3.1 从工程结构里找到接线点资源包里是一个完整的 VS 解决方案MFCApplication1.sln是解决方案入口MFCApplication1Dlg.cpp和MFCApplication1Dlg.h是对话框主窗口实现resource.h管控件 IDMFCApplication1.rc管对话框资源和按钮布局。打开工程后先在资源编辑器里找到主对话框往上面拖一个按钮把按钮 ID 改成IDC_BTN_CAPTURE标题写成“截屏”。然后右键按钮添加BN_CLICKED消息处理函数VS 会自动在MFCApplication1Dlg.cpp里生成一个OnBnClickedBtnCapture函数截屏代码就写在这个入口里。如果你拿到的版本已经带了截屏按钮直接把下面的函数体替换进去就行。我一般会先在OnInitDialog里加一句SetWindowText(_T(MFC 截屏 Demo))确认界面能起来再点按钮看结果。不要一上来就跑一遍完整逻辑因为截屏失败往往分不清是设备上下文问题还是保存文件问题。3.2 完整截屏函数GetDC 到 BitBlt 的代码与参数说明下面这段是对话框类里的成员函数输入参数是保存路径和保存格式BOOL CMFCApplication1Dlg::CaptureScreenToFile(LPCTSTR szPath, BOOL bSaveJpeg) { // 整个屏幕的设备上下文 HDC hScreenDC ::GetDC(NULL); if (hScreenDC NULL) return FALSE; // 主屏像素宽高若想抓多屏这里要换虚拟桌面尺寸 int nW ::GetSystemMetrics(SM_CXSCREEN); int nH ::GetSystemMetrics(SM_CYSCREEN); // 内存 DC 和兼容位图 HDC hMemDC ::CreateCompatibleDC(hScreenDC); HBITMAP hBmp ::CreateCompatibleBitmap(hScreenDC, nW, nH); if (hBmp NULL) { ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); return FALSE; } // 重要必须先选入位图BitBlt 才有真正的像素存储目标 HGDIOBJ hOldBmp ::SelectObject(hMemDC, hBmp); // 复制屏幕像素 BOOL bCopied ::BitBlt(hMemDC, 0, 0, nW, nH, hScreenDC, 0, 0, SRCCOPY); // 清理资源顺序不能倒 ::SelectObject(hMemDC, hOldBmp); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); if (!bCopied) { ::DeleteObject(hBmp); return FALSE; } // 保存位图 BOOL bSaved SaveBitmapToFile(hBmp, szPath, bSaveJpeg); // 位图是 CreateCompatibleBitmap 创建的最终由 DeleteObject 销毁 ::DeleteObject(hBmp); return bSaved; }这段代码的关键点是错误检查GetDC失败返回 NULLCreateCompatibleBitmap也可能失败。很多人写截屏只检查BitBlt返回值但内存 DC 或位图创建失败时BitBlt根本不会被正常调用程序会在后面SelectObject时崩溃。还有一点hBmp在SelectObject(hMemDC, hOldBmp)之后才删是因为位图还处于选中状态时DeleteObject可能失败造成 GDI 对象泄漏。bSaveJpeg这个参数不是必须的我看你习惯用扩展名判断也行。工程里最稳的做法是让保存函数根据szPath的后缀决定走 BMP 还是 JPEG避免传参和扩展名不一致。3.3 位图落盘BMP/JPEG 保存的两个落地方案保存位图最容易翻车因为HBITMAP在内存里是 GDI 对象不是直接裸像素数组。最省事的方案是用CImage它能把HBITMAP通过Attach接管过来再用Save按扩展名写文件#include atlimage.h BOOL SaveBitmapToFile(HBITMAP hBmp, LPCTSTR szPath, BOOL bSaveJpeg) { CImage img; img.Attach(hBmp); // 把 HBITMAP 交给 CImage 处理 HRESULT hr img.Save(szPath); // 扩展名 .bmp 走 BMP.jpg 走 JPEG img.Detach(); // 之后 HBITMAP 仍由调用方负责删除 return SUCCEEDED(hr); }CImage::Save的返回值是HRESULT不能只判断 TRUE/FALSE。如果目标机器用的是精简版系统或者你的工程没启用 ATL/GDI这招可能失灵那就退回手动写 BMP 文件。手动保存 BMP 的核心是补齐文件头否则图片查看器打开直接报损坏BOOL SaveBitmapToBmp(HBITMAP hBmp, LPCTSTR szPath) { BITMAP bm {0}; if (!::GetObject(hBmp, sizeof(BITMAP), bm)) return FALSE; // 每行像素按 4 字节对齐 DWORD cbRow ((bm.bmWidth * bm.bmBitsPixel 31) / 32) * 4; DWORD cbPixels cbRow * bm.bmHeight; BITMAPINFOHEADER bih {0}; bih.biSize sizeof(BITMAPINFOHEADER); bih.biWidth bm.bmWidth; bih.biHeight bm.bmHeight; // 正数表示自下而上存储 bih.biPlanes 1; bih.biBitCount bm.bmBitsPixel; bih.biCompression BI_RGB; bih.biSizeImage cbPixels; std::vectorBYTE vecBits(cbPixels); HDC hDC ::GetDC(NULL); int nRows ::GetDIBits(hDC, hBmp, 0, bm.bmHeight, vecBits.data(), (BITMAPINFO*)bih, DIB_RGB_COLORS); ::ReleaseDC(NULL, hDC); if (nRows ! bm.bmHeight) return FALSE; BITMAPFILEHEADER bfh {0}; bfh.bfType 0x4D42; // BM bfh.bfSize sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER) cbPixels; bfh.bfOffBits sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER); try { CFile file; if (!file.Open(szPath, CFile::modeCreate | CFile::modeWrite)) return FALSE; file.Write(bfh, sizeof(bfh)); file.Write(bih, sizeof(bih)); file.Write(vecBits.data(), cbPixels); file.Close(); } catch (CFileException* e) { e-Delete(); return FALSE; } return TRUE; }这段代码里最容易漏的是BITMAPFILEHEADER.bfType 0x4D42这个字节序是固定的写反了文件头会变成乱码。GetDIBits负责把 GDI 位图转成 DIB 字节流它的hDC参数也必须是和位图兼容的 DC我习惯用GetDC(NULL)用完了立刻还回去。如果你保存的是 16 位高色或带调色板的位图bih.biBitCount要按实际位数填同时考虑调色板我在工程里通常强制转 24 位省得后面读图代码要处理各种颜色表。4. 避坑与排查截屏最常见的 5 个翻车现场4.1 截出来是黑屏兼容位图根本没选进内存 DC现象程序能跑保存的 BMP 打开后全黑没有一点界面内容。原因最常见的是BitBlt执行前没做SelectObject(hMemDC, hBmp)内存 DC 还停留在默认的 1x1 单色位图上。另一种情况是CreateCompatibleBitmap用的是内存 DC 而不是屏幕 DC导致位图格式和屏幕不兼容复制结果异常。解决严格按“屏幕 DC → 内存 DC → 兼容位图 → 选入 → BitBlt → 还原 → 清理”的顺序执行。代码写成HBITMAP hBmp ::CreateCompatibleBitmap(hScreenDC, nW, nH);不要用hMemDC作为参数。如果远程桌面或锁屏状态下截图黑屏那是会话层限制和代码无关需要改用PrintWindow或者从目标进程内部抓取窗口内容。4.2 保存出来的文件打不开只存了像素没写文件头现象文件生成了但图片查看器提示“文件已损坏”或“无法识别的格式”。原因手动保存 BMP 时漏掉了BITMAPFILEHEADER或者bfType字段没写成0x4D42。还有一种情况是调用CImage::Save时扩展名和格式不一致比如路径写.bmp但函数内部强制用 JPEG 编码器文件头就变成了 JPEG 头预览器会识别不了。解决手动写 BMP 时先写BITMAPFILEHEADER再写BITMAPINFOHEADER最后写像素数据。用CImage::Save时让文件扩展名决定格式或者显式传Gdiplus::ImageFormatBMP/Gdiplus::ImageFormatJPEG。我一般在保存失败后会第一时间打开文件看前两个字节BMP 文件必须是42 4DJPEG 文件应该是FF D8。4.3 高 DPI 和多显示器截图范围不是你以为的 1920x1080现象在 150% 缩放的笔记本上截出来的图只有屏幕左上角一块或者副屏内容完全没截到。原因GetSystemMetrics(SM_CXSCREEN)只返回主屏宽度。在 DPI 缩放场景下如果进程没有声明 DPI 感知系统会先做虚拟化导致GetDC(NULL)拿到的 DC 尺寸是逻辑像素而BitBlt的角度看到的却是物理像素两边一错位截图范围就缩水了。多显示器时副屏在虚拟桌面里可能有负坐标直接从 0,0 开始复制副屏内容就丢了。解决抓整个虚拟桌面时用SM_XVIRTUALSCREEN、SM_YVIRTUALSCREEN、SM_CXVIRTUALSCREEN、SM_CYVIRTUALSCREEN四个值组合。高 DPI 环境里还得让程序明确声明 DPI 感知工程属性里开 PerMonitorV2或者在main函数启动时调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)。我一般会写个辅助函数输出当前逻辑屏幕和虚拟屏幕的宽高先打印出来再调BitBlt。4.4 内存泄漏DC 和 GDI 对象到底谁负责释放现象程序长时间运行任务管理器里 GDI 对象数一直涨最终界面卡死或截图变成花屏。原因GetDC获取的 DC 不是新建的用完必须ReleaseDC归还CreateCompatibleDC创建的 DC 归自己所有要用DeleteDC销毁CreateCompatibleBitmap创建的HBITMAP要用DeleteObject删除。这三套对象漏掉任何一个都会造成 GDI 句柄泄漏。最隐蔽的是SelectObject的旧对象没还原位图在选中状态下删除失败句柄直接泄漏。解决把函数里的清理逻辑固定成“后获取的先释放”顺序。我在调试时会在关键位置调用GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)打印对象数如果每次截屏后数值不回落到基线就说明哪里泄漏了。你可以在OnTimer或按钮处理函数里前后打印两次用二分法定位泄漏函数。4.5 定时截屏卡死 UI别把循环抓图塞进按钮消息现象加了定时截屏后窗口拖不动按钮点了没反应最后整个程序显示“未响应”。原因BitBlt和CImage::Save都是耗时的同步调用如果一个while(1)循环在按钮消息里不停截屏存文件主线程的消息队列就被堵死了。解决不要用while(1)做截屏循环用SetTimer定时触发或者把截屏和保存扔到工作线程。我在工程里通常用SetTimer(1, 3000, NULL)三秒一次已经很快了文件落盘用异步线程池会更稳。定时条件下文件命名里必须带时间戳否则上一帧还没写完下一帧已经覆盖文件就是一个正在写入的残缺文件。5. 进阶用法把全屏截屏改成“指定窗口 定时”的小工具5.1 指定窗口从 HWND 到客户区很多场景不需要整屏只需要截某个对话框或某个外部窗口。核心思路是把目标窗口的HWND拿过来用GetClientRect拿客户区再用ClientToScreen把客户区左上角转成屏幕坐标最后用BitBlt按这个矩形从屏幕 DC 复制HWND hWnd ::FindWindow(NULL, _T(目标窗口标题)); if (hWnd NULL) return; RECT rcClient {0}; ::GetClientRect(hWnd, rcClient); POINT ptLeftTop {0, 0}; ::ClientToScreen(hWnd, ptLeftTop); int cx rcClient.right - rcClient.left; int cy rcClient.bottom - rcClient.top; HDC hScreenDC ::GetDC(NULL); HDC hMemDC ::CreateCompatibleDC(hScreenDC); HBITMAP hBmp ::CreateCompatibleBitmap(hScreenDC, cx, cy); HGDIOBJ hOldBmp ::SelectObject(hMemDC, hBmp); ::BitBlt(hMemDC, 0, 0, cx, cy, hScreenDC, ptLeftTop.x, ptLeftTop.y, SRCCOPY); // 后续保存和清理同上FindWindow按标题找窗口不够稳标题一变就找不到了。我一般用EnumWindows遍历所有顶层窗口再用窗口类名和 PID 双重过滤这样即使标题带版本号或动态后缀也能命中。如果目标窗口有分层属性或异形区域BitBlt可能截不到内容此时要调用PrintWindow并传入PW_RENDERFULLCONTENT这是处理“窗口是黑块”时最后的手段。5.2 定时器驱动的循环截屏和文件命名定时截屏的代码我会放进OnTimer而不是按钮消息里。先SetTimer(1, 3000, NULL)然后在OnTimer里做三件事生成时间戳文件名、调截屏函数、检查保存结果。void CMFCApplication1Dlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { CTime t CTime::GetCurrentTime(); CString strPath; strPath.Format(_T(Screen_%04d%02d%02d_%02d%02d%02d.bmp), t.GetYear(), t.GetMonth(), t.GetDay(), t.GetHour(), t.GetMinute(), t.GetSecond()); BOOL bOk CaptureScreenToFile(strPath, FALSE); // 打印或记录 bOk方便无人值守时排查 } CDialogEx::OnTimer(nIDEvent); }文件名里带毫秒更可靠同一秒内截多帧不会互相覆盖。注意CaptureScreenToFile里的保存动作会阻塞调用线程如果保存文件慢就把文件 I/O 拆到独立线程主线程只负责BitBlt和传参。5.3 验证结果不靠肉眼也能自查截图落盘后我习惯马上做一次“头校验”打开文件读到前两个字节确认是BMBMP或FF D8JPEG同时读BITMAPINFOHEADER.biWidth和biHeight对比期望尺寸。这样能自动发现黑屏、截到 0 高度、文件只写了一半这类问题。BOOL ValidateImageFile(LPCTSTR szPath, int nExpectWidth, int nExpectHeight) { CFile file; if (!file.Open(szPath, CFile::modeRead)) return FALSE; BYTE byHead[2] {0}; file.Read(byHead, 2); if (byHead[0] ! B || byHead[1] ! M) { file.Close(); return FALSE; } file.Seek(18, CFile::begin); // biWidth 在文件头偏移 18 DWORD dwW 0, dwH 0; file.Read(dwW, 4); file.Read(dwH, 4); file.Close(); return (int)dwW nExpectWidth (int)dwH nExpectHeight; }这套验证逻辑看起来多写几步但在无人值守的定时任务里特别值钱。从那以后我每次给老工程加截屏功能都会强制走一遍“全屏截图 → 指定窗口截图 → 双屏/缩放环境截图 → 文件头校验”的流程哪怕只改了一个参数也要重跑。希望你接手的 MFC 截屏工程也能少踩几个我之前踩过的坑。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站