尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC与GDI截屏实战:从设备上下文到BitBlt完整实现

MFC与GDI截屏实战:从设备上下文到BitBlt完整实现 简介面向C/MFC初学者的屏幕截图实现工程包含完整的Visual Studio解决方案与源码演示通过GDI、CDC、CBitmap、BitBlt等API捕获整个屏幕并保存为BMP/JPEG文件的典型流程同时涵盖设备上下文获取与释放、内存兼容DC创建、SelectObject对象选入等关键环节并给出保存图像与异常处理思路可帮助学习者快速掌握MFC图形设备接口与截屏原理。压缩包共22个文件以.h头文件、.cpp源文件、.rc资源脚本及.vcxproj工程文件为主另有日志、图标等辅助文件整体约136KB结构清晰便于直接打开调试或对照学习。已有635人学习浏览适合正在做桌面工具、需要截屏功能或想深入理解CDC/CBitmap用法的开发者参考。1. MFC 与 VS 截屏为什么我最终选择让 GDI 背这个锅在做桌面工具时经常遇到「界面状态说不清」的尴尬——程序跑着跑着界面变了客户说不对、测试说复现不了截图是唯一能让所有人闭嘴的证据。按键精灵、系统自带截图工具都能用但当你需要的是一段可编程、可定时、可指定窗口的截图逻辑时最省心的路子不是去调第三方的库而是直接用 MFC 加 GDI 那套老 API 自己写。MFC 的 CDC、CBitmap 把设备上下文和位图对象包了一层核心流程说白了就是 GetDC 拿屏幕、CreateCompatibleBitmap 建位图、BitBlt 拷贝像素、再落盘成 BMP全流程不超过 50 行有效代码不需要任何外部依赖VS 里新建一个 MFC 对话框工程就能跑。这篇文章会把这份截屏工程从原理、文件结构到实际改造一点点拆开适合正在写 MFC 桌面工具的开发者也适合刚接触 GDI 编程、想弄清楚设备上下文到底是什么的初学者。2. 从屏幕像素到硬盘文件GDI 截屏的链路与工程骨架2.1 为什么非要绕一圈屏幕 DC、兼容 DC 与兼容位图的三角关系很多人第一次写截屏时会想直接用 GetDC 拿到屏幕的句柄再读像素不就行了没这么简单。屏幕 DC 是系统共享资源你不能长时间占用它更不能在它上面创建持久对象。正确做法是先通过 CreateCompatibleDC 创建一个内存 DC再通过 CreateCompatibleBitmap 创建一个与屏幕颜色格式完全一致的位图把位图选入内存 DC之后 BitBlt 把屏幕像素「复印」到内存 DC 中的位图里。这条链路的每一步都有明确的选型理由CreateCompatibleDC 创建的内存 DC 初始只有一个 1x1 单色位图必须先选入一个真正的位图才能绘图。CreateCompatibleBitmap 按照屏幕 DC 的像素格式通常是 32 位分配内存所以 BitBlt 拷贝时不需要做色彩转换这是性能关键。BitBlt 是 GDI 的位块传输函数最后一个参数 SRCCOPY 表示直接复制像素不做任何 ROP 运算如果改成 NOTSRCCOPY 可以得到反色效果但截屏场景永远用 SRCCOPY。SelectObject 将位图选入内存 DC 后必须保存返回值旧对象句柄拷贝结束再还原否则 DeleteDC 时会出现对象句柄泄漏。这个三角关系是整条截屏链路的核心骨架。你可以把它理解成屏幕 DC 是复印件原稿内存 DC 是复印机兼容位图是复印纸BitBlt 就是按一下复印键。后面所有的代码都是在维护这个三角关系。2.2 工程包里那些文件是干什么的拿到 MFCApplication1 截屏.rar 解压后第一眼会被一堆 .vcxproj、.sln、.h、.cpp 文件晃到。这些东西对视窗老手不陌生但对新接触 VS 工程结构的人值得先花三分钟对号入座。整个工程的核心文件分配如下文件作用MFCApplication1.sln / .vcxprojVS 解决方案与工程文件双双击 sln 就能打开工程MFCApplication1Dlg.h / .cpp对话框主类截屏按钮和保存逻辑实际都写在这MFCApplication1.h / .cppCWinApp 应用程序类程序入口、初始化都在这里framework.h / pch.h / pch.cpp预编译头文件把 Windows.h、afxwin.h 这些固定头文件提前编一次加速重复编译resource.h / MFCApplication1.rc资源文件对话框布局、按钮 ID、图标都定义在这里targetver.h目标平台宏定义限制程序能跑在哪些 Windows 版本上res 目录图标、版本信息等二进制资源x64 / Release 等目录VS 编译输出的中间文件与最终 exe值得注意的一点是预编译头文件里如果没包含 afxwin.h 等 MFC 核心头文件工程编译会直接报一堆 C2065 未声明标识符这是新手第一次编译翻车的高发区。framework.h 里通常已经把该含的头文件都含全了不要随便动它。2.3 对话框程序还是单文档程序怎么选截屏这种工具型应用选 MFC 对话框程序就够了。对话框程序有自己的消息循环、资源模板拖一个按钮就能干活完全不需要文档/视图架构的序列化、打印预览这些重装备。单文档程序更适合画布交互、多窗口管理的场景。顺带提一句如果你心理上在 MFC 和 Qt 之间纠结做一次性小工具用 MFC 可以直接吃系统 GDI API产物体积和控制力都在自己手里Qt 的 QScreen::grabWindow 封装确实更省事但它要拖一套运行库进来。我的习惯是项目里刚好有 MFC 工程就顺手把截屏模块塞进去没有的话换 Qt 也不亏但本文的工程是基于 VS 的 MFC 对话框模板的下面的代码都以此为基准。3. 在 MFCApplication1Dlg 里跑通截屏全流程按钮 → BitBlt → 存成 BMP3.1 先搭一个能响应的截屏入口在对话框资源编辑器里拖一个按钮ID 设为 IDC_BTN_CAPTURE双击自动生成消息处理函数。MFC 的机制是通过消息映射表把控件通知号关联到类的成员函数比 Win32 时代的 Switch 大派分发清爽不少。消息映射增加一句 ON_BN_CLICKED其中 IDC_BTN_CAPTURE 是资源文件里定义的按钮 ID函数名只要符合签名规则可以自己起BEGIN_MESSAGE_MAP(CMFCApplication1Dlg, CDialogEx) ON_BN_CLICKED(IDC_BTN_CAPTURE, CMFCApplication1Dlg::OnBnClickedBtnCapture) END_MESSAGE_MAP()按钮函数里只做三件事调截屏函数、调保存函数、用静态文本反馈结果。代码在这里承担的是「任务编排」职责真正干活的是后面两个函数void CMFCApplication1Dlg::OnBnClickedBtnCapture() { CBitmap bmp; if (!CaptureScreen(bmp)) { AfxMessageBox(_T(屏幕捕获失败)); return; } CString strSavePath _T(C:\\Temp\\capture.bmp); if (SaveBitmapToFile(bmp, strSavePath)) { SetDlgItemText(IDC_STATIC_HINT, _T(已保存: C:\\Temp\\capture.bmp)); } else { AfxMessageBox(_T(保存失败)); } }很多 MFC 教程喜欢把头像、位图资源直接贴在对话框上演示但截屏场景不需要这些花活。这里我用 SetDlgItemText 往一个静态文本控件写提示如果你想要的是状态栏那种显示效果可以换成 CMFCStatusBar不过对一个截图小工具来说静态文本已经足够。3.2 核心截屏函数从 GetDC 到 BitBlt截屏的核心函数负责把屏幕像素搬进 CBitmap。下面的写法是最典型的单人双屏方案用全局屏幕 DC 为主屏截取方式每行代码都带注释说明它存在的理由BOOL CMFCApplication1Dlg::CaptureScreen(CBitmap bmp) { // 1. 获取整块屏幕的设备上下文NULL 表示整个屏幕 HDC hScreenDC ::GetDC(NULL); if (!hScreenDC) return FALSE; // 主屏逻辑像素尺寸 int nScreenW ::GetSystemMetrics(SM_CXSCREEN); int nScreenH ::GetSystemMetrics(SM_CYSCREEN); // 2. 创建与屏幕兼容的内存 DC作为像素拷贝的中转站 HDC hMemDC ::CreateCompatibleDC(hScreenDC); if (!hMemDC) { ::ReleaseDC(NULL, hScreenDC); return FALSE; } // 3. 创建与屏幕颜色格式兼容的位图32 位桌面就是 32 位位图 HBITMAP hBitmap ::CreateCompatibleBitmap(hScreenDC, nScreenW, nScreenH); if (!hBitmap) { ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); return FALSE; } // 4. 将位图选入内存 DC并保存旧对象后续要还原 HGDIOBJ hOldBmp ::SelectObject(hMemDC, hBitmap); // 5. 从屏幕 DC 的 (0,0) 开始拷贝主屏整块区域到内存 DC BOOL bOK ::BitBlt(hMemDC, 0, 0, nScreenW, nScreenH, hScreenDC, 0, 0, SRCCOPY); // 6. 先还原旧位图再删除内存 DC 与屏幕 DC顺序不能反 ::SelectObject(hMemDC, hOldBmp); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); if (!bOK) { ::DeleteObject(hBitmap); return FALSE; } // 7. 把 HBITMAP 句柄交给 CBitmap 对象管理 bmp.Attach(hBitmap); return TRUE; }这段函数有四个容易被忽略的参数细节。第一GetDC(NULL) 与 GetDC(GetDesktopWindow()) 在这段代码里等价但 GetDC(NULL) 更直接。第二CreateCompatibleBitmap 的宽高如果与后面 BitBlt 的宽高不一致位图只拷贝一部分其余区域是未初始化内存保存出来会出现花屏。第三BitBlt 的目标坐标是内存 DC 的 (0,0)源坐标是屏幕 DC 的 (0,0)宽高必须保持一致。第四hBitmap 在 Attach 后不要手动 DeleteObject否则后续保存时句柄已经失效程序会在 GetDIBits 处崩溃——这一步是 MFC 与 Win32 API 混用最容易踩的坑。3.3 把 CBitmap 存成 BMP手动写文件头与像素数据CBitmap 只是内存里的位图对象要落盘成文件得把 DIB 数据按 BMP 文件格式写出来。BMP 文件由三部分组成14 字节的 BITMAPFILEHEADER、40 字节的 BITMAPINFOHEADER、以及按自底向上顺序排列的像素数据。24 位位图没有调色板所以信息头后面直接就是像素数组。下面这个函数用 MFC 的 CFile 写文件比 C 标准库的 fopen 多了一层异常封装BOOL CMFCApplication1Dlg::SaveBitmapToFile(CBitmap bmp, LPCTSTR lpszFilePath) { BITMAP bmInfo; bmp.GetBitmap(bmInfo); // 1. 构造 40 字节的 BITMAPINFOHEADER24 位、BI_RGB 无压缩 BITMAPINFOHEADER bih { 0 }; bih.biSize sizeof(BITMAPINFOHEADER); bih.biWidth bmInfo.bmWidth; bih.biHeight bmInfo.bmHeight; // 正值: 自底向上存储 bih.biPlanes 1; bih.biBitCount 24; bih.biCompression BI_RGB; // 2. BMP 每行必须 4 字节对齐宽 x 3 字节不等于 4 的倍数时必须补齐 DWORD dwRowSize ((bih.biWidth * 24 31) / 32) * 4; DWORD dwDataSize dwRowSize * abs(bih.biHeight); // 3. 构造 14 字节的 BITMAPFILEHEADER BITMAPFILEHEADER bfh { 0 }; bfh.bfType 0x4D42; // BM bfh.bfSize sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER) dwDataSize; bfh.bfOffBits sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER); // 4. 用 GetDIBits 把 GDI 位图转成 DIB 像素数据 HDC hDC ::CreateCompatibleDC(NULL); HBITMAP hBmp (HBITMAP)bmp.GetSafeHandle(); ::SelectObject(hDC, hBmp); BYTE* pPixels new BYTE[dwDataSize]; // 第一次调用: 传 NULL让 GetDIBits 校验并填充 bih 的字段 ::GetDIBits(hDC, hBmp, 0, abs(bih.biHeight), NULL, (LPBITMAPINFO)bih, DIB_RGB_COLORS); // 注意: 第一次调用可能改写 bih第二次前必须恢复关键字段 bih.biSize sizeof(BITMAPINFOHEADER); bih.biWidth bmInfo.bmWidth; bih.biHeight bmInfo.bmHeight; bih.biPlanes 1; bih.biBitCount 24; bih.biCompression BI_RGB; // 第二次调用: 传入缓冲区真正填充像素数据 ::GetDIBits(hDC, hBmp, 0, abs(bih.biHeight), pPixels, (LPBITMAPINFO)bih, DIB_RGB_COLORS); ::DeleteDC(hDC); // 5. 用 CFile 依次写文件头、信息头、像素数据 CFile file; if (!file.Open(lpszFilePath, CFile::modeCreate | CFile::modeWrite)) { delete[] pPixels; return FALSE; } file.Write(bfh, sizeof(BITMAPFILEHEADER)); file.Write(bih, sizeof(BITMAPINFOHEADER)); file.Write(pPixels, dwDataSize); file.Close(); delete[] pPixels; return TRUE; }GetDIBits 在这里是最容易写错的函数。第一次调用传入 NULL 缓冲区目的是让系统回填位图信息但 GDI 文档没有承诺不修改调用者传入的 BITMAPINFOHEADER所以第二次调用前把它原样重建一遍是稳妥习惯。pPixels 的大小直接取 dwDataSize如果按 width × height × 3 算而忘了行对齐文件尾部会缺字节图片下半部分错位。还有一个容易被忽视的小细节CFile 打开失败时要用delete[]释放 pPixels否则每次截屏失败都漏一块内存。资源释放是这类工具型工程最容易出问题的地方。3.4 想要 JPG/PNGCImage 的替代路径与参数限制BMP 文件虽然通用但体积大不适合长时段保存。MFC 的 CImage 类内部封装了 GDI可以一行代码完成 JPG/PNG 保存。关键用法是把 CBitmap 的句柄 Attach 给 CImage然后调 SaveCImage image; image.Attach(hBitmap); // 接管句柄后CBitmap 不要再管理它 image.Save(_T(C:\\Temp\\capture.png), Gdiplus::ImageFormatPNG); image.Detach(); // 截屏函数内部后续要 DeleteObjectCImage image; image.Attach(hBitmap); image.Save(_T(C:\\Temp\\capture.jpg), Gdiplus::ImageFormatJPEG); image.Detach();CImage::Save 的第一个坑是目录不存在时静默失败第二个坑是 JPEG 质量控制通过 Gdiplus 的 EncoderParameters 设置不传默认 75% 质量。如果工程链接时缺 Gdiplus 相关引用需要在 stdafx.h 或 pch.h 里加#include atlimage.h并在工程属性里把通用语言运行时支持关闭。第三条思路是直接用 GDI 的 Bitmap 类但代码量比 CImage 大一倍。我的建议是演示和排错用 BMP任何人都能打开正式工具用 PNG格式转换和落盘分离不要在截屏线程里做压缩否则主界面会卡。4. 实战避坑黑屏、错位、句柄泄漏与 DPI 缩放4.1 BitBlt 截出黑屏现象程序能编译能运行但截出来的图片是全黑的偶尔还有花屏。原因这个现象背后通常有两种可能。一是截屏源 DC 用错了目标比如在 OnPaint 里截屏此时屏幕 DC 正在被当前窗口的绘制操作使用GDI 的操作顺序错乱导致拷贝到空白区域。二是捕获目标是 DirectX 独占模式的游戏或硬件加速视频播放器这类表面根本不会经过 GDI 的屏幕 DCBitBlt 只能看到黑色背景。解决先确认调用场景不要在 WM_PAINT 的绘制序列中间插截屏代码截屏函数应该在按钮事件或定时器里独立调用。对 DX 独占画面可以尝试 PrintWindow 代替 BitBltPrintWindow 会向目标窗口发送 WM_PRINT 消息部分程序能配合输出内容实在不行就得走 DXGI Desktop Duplication 这条 API 路线那是另一个量级的工程复杂度。4.2 BMP 文件打不开提示文件损坏现象保存函数返回成功文件也在磁盘上但 Windows 照片查看器打不开或者图片上下颠倒、左半部有 3 个像素的彩虹条。原因BMP 的行长度有硬性约定——必须以 4 字节为界限对齐。一幅宽度为 1919 像素的 24 位位图一行的像素数据是 1919 × 3 5757 字节不是 4 的倍数必须补齐到 5760 字节。如果你按width * 3分配内存、按这个长度写入文件文件结构就坏了。另一个原因是行序问题BMP 存储自底向上如果直接用GetDIBits返回自顶向下数据而不做转换图片会上下颠倒。解决一行字节数统一用((width * 24 31) / 32) * 4计算不要手写乘法。GetDIBits 的 biHeight 传正值表示自底向上存储与 BMP 的规则一致就不需要再翻转像素行序。按这个规则分配像素缓冲区并写入 bfh.bfSize文件一定能打开。4.3 高频截屏后 GDI 对象数量持续增长现象程序每 5 秒截屏一次运行一小时后任务管理器里 GDI 对象数量几千上万最终屏幕出现绘制异常甚至报「内存不足」。原因每一次截屏循环里如果 CreateCompatibleBitmap 之后没有 DeleteObject或者 SelectObject 之后没有还原旧对象GDI 对象句柄就在进程句柄表里累积。GDI 对象的释放依赖 DeleteDC 和 DeleteObject不释放的句柄不会自动被垃圾回收机制处理MFC 的 CBitmap 析构函数只释放它自己拥有的位图对象对裸句柄无能为力。解决记住一个次序——先 SelectObject 还原旧对象再 DeleteDC 释放内存 DC最后 DeleteObject 删除位图。用完 CBitmap 后不要遗漏 Attach 的句柄CImage 里 Detach 的思想也一样。排查时可以在任务管理器里看进程的「GDI 对象」列如果稳定不涨说明资源释放做对了。4.4 高分屏 150% 缩放下截屏区域偏移现象Windows 显示缩放设为 125% 或 150% 时截出来的图片尺寸和屏幕实际物理像素对不上鼠标点击的区域偏移了半条任务栏。原因进程默认的 DPI 感知模式是系统感知GetSystemMetrics(SM_CXSCREEN) 返回的是经过缩放的逻辑像素比如 1920 物理像素在 150% 下显示为 1280而 BitBlt 拷贝的是物理像素两者混用必然错位。解决在程序启动处调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)让进程按物理像素工作。调用后 GetSystemMetrics 返回真实分辨率CreateCompatibleBitmap 与 BitBlt 宽高一致截屏区域才精准。如果你的程序需要在多个 DPI 不同的显示器间切换单次设置还不够要处理 WM_DPICHANGED 消息的重绘逻辑这个场景比较少见但知道了总比不知道强。4.5 多显示器时只截到主屏现象双屏环境中副屏上的内容在截图里永远看不到。原因GetDC(NULL) 返回的设备上下文以主屏为视觉原点BitBlt 从 (0,0) 开始的拷贝范围天然只覆盖主屏。副屏如果位于主屏左侧其坐标是负值GDI 的传统 BitBlt 处理不了负坐标区域。解决要真正截取虚拟屏幕宽高用SM_XVIRTUALSCREEN/SM_CYVIRTUALSCREEN算虚拟屏幕矩形再按多显示器逐个枚举对每块屏分别创建兼容 DC 进行 BitBlt最后把各段图像拼接到一个大位图上——这个逻辑可以放到循环里复杂度并不高。如果你的场景只是「需要截个主屏」那么现有的 GetDC(NULL) 方案不用改。5. 进阶用法定时截屏、指定窗口与可复用的验证习惯5.1 定时截屏与指定窗口定时截屏在监控类工具里很常见。在对话框的 OnInitDialog 里加一句SetTimer(1, 5000, NULL)然后重载 OnTimer 消息响应每 5 秒执行一次截屏与保存文件名用 CTime 拼接成时间戳就不会互相覆盖void CMFCApplication1Dlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { CBitmap bmp; if (CaptureScreen(bmp)) { CString strPath; CTime t CTime::GetCurrentTime(); strPath.Format(_T(C:\\Captures\\%04d%02d%02d_%02d%02d%02d.png), t.GetYear(), t.GetMonth(), t.GetDay(), t.GetHour(), t.GetMinute(), t.GetSecond()); SaveBitmapToFile(bmp, strPath); } } CDialogEx::OnTimer(nIDEvent); }指定窗口截屏则是在 CaptureScreen 基础上换了源 DC用::GetWindowDC(hWnd)代替::GetDC(NULL)宽高用GetWindowRect计算。对最小化的窗口可以先ShowWindow(SW_RESTORE)再截对某些无法被 BitBlt 捕获的窗口试 PrintWindow。三种手段按场景选择适用边界如下表所示场景推荐手段局限整屏 / 主屏BitBlt GetDC(NULL)无法捕获 DX 独占表面某个普通窗口PrintWindow(hWnd, hMemDC, PW_RENDERFULLCONTENT)部分自绘窗口不响应 WM_PRINT全屏游戏 / DX 画面DXGI Desktop Duplication代码复杂度大幅上升需按 D3D 方式处理5.2 落盘后的三个验证点保存后不要急着打开看先做三个快速校验第一文件大小应该等于bfh.bfSize差一个字节就说明行对齐算错了第二位图宽高乘积再乘以像素字节数应该在 dwDataSize 附近说明缓冲区分配正确第三用 CImage 读回文件再取像素和内存里的 CBitmap 做一次像素级哈希对比一致说明落盘过程没丢像素。这三个校验我把它们写成了一个独立的小函数每次截屏保存后自动执行这个习惯是从一次线上事故里换来的——用户反馈「截图打不开」我本机打开正常最后发现是对方系统临时目录权限问题导致 CFile 写入失败。从那以后我每次做截屏工具都强制走一遍「DPI 感知 → DC 来源 → 落盘校验」这条链路先排除自己代码的嫌疑再往外部环境查。这套方法和上面的工程代码能让你在 VS 里少花几天冤枉时间希望帮到你。本文还有配套的精品资源点击获取
返回列表