☰
MFC/VS屏幕截图完整指南:BitBlt核心实现与多显示器、DPI兼容处理
2026/10/8 7:34:41 网站建设 项目流程

简介:面向MFC/C++开发者的屏幕截图功能实现,在Visual Studio环境下演示如何捕获全屏或指定窗口并保存为位图或JPEG。这一例程围绕GDI图形设备接口展开,覆盖CDC设备上下文、CBitmap位图对象、GetDC窗口设备句柄获取、BitBlt像素块复制、SelectObject选入位图等核心API的组合用法,对学习Windows图形编程和MFC消息处理具有很好的参考价值。资源包共22个文件,包含头文件、cpp源码、资源脚本、图标以及完整工程文件(sln/vcxproj),压缩后仅136KB,代码量精炼,便于直接编译运行或迁移到现有项目。已有635人学习,适合需要快速掌握GDI/CDC/CBitmap/BitBlt等Windows图形接口的初学者。通过阅读源码可梳理截屏标准流程:获取屏幕设备上下文、创建兼容位图、BitBlt复制像素、释放GDI资源,同时也可参考其窗口截取、文件保存等扩展思路,是学习MFC图形编程和Windows屏幕捕获机制的实用例程。

1. MFC/VS 截取屏幕图片,这组词背后是桌面工具的第一道坎

MFC/VS 截取屏幕图片,这组词在桌面开发里很常见,但真做起来总有意外:全黑、只截主屏、分辨率不对,都是截屏工具最常见的翻车现场。看起来是 BitBlt 一行代码的事,背后却连着 DC 生命周期、DPI 虚拟化和多显示器三套逻辑。

这篇笔记想做的事很简单:把一个 MFC 工程里“点按钮存一张屏幕图”的完整路径拆透——怎么选截屏对象、怎么写核心代码、怎么保存成 BMP,以及多显示器和 DPI 缩放这些边界条件怎么处理。

适用对象是做截图工具、界面存档、自动化验证的桌面端开发者。新手照步骤能跑通,熟手可以参考参数边界和清理时机,少走一遍我已经走过的弯路。

2. 动手前先想清楚:全屏、窗口还是区域,决定你用的是哪套 API

2.1 三种截图对象:它们背后是两套完全不同的 API

写 MFC 截屏,最忌讳一上来就搜代码。先把自己要截的东西归类:全屏、窗口、区域。这三类在日常工具里出现频率差不多,可代码差别很大,选错了轻则多写代码,重则截出来根本不是要的内容。

全屏最简单,GetDC(NULL)拿到的就是整个虚拟桌面的 DC,直接用 BitBlt 按屏幕宽高拷像素。窗口截图不能直接对窗口 DC 做 BitBlt,因为窗口 DC 只在窗口没被遮挡时有意义,通常做法是先GetWindowRect拿窗口外框,再用PrintWindow或 WM_PRINT 让窗口自己画进一个兼容 DC。区域截图其实是全屏截图的参数化版本,把 BitBlt 的目标宽高固定成区域的宽高,源坐标从区域左上角开始。

截图对象常见实现路径典型场景最容易踩的坑
全屏截图GetDC(NULL) + BitBlt 全屏尺寸存档留证、录屏单帧多显示器下只截主屏
窗口截图GetWindowRect + PrintWindow控件快照、窗口留档被遮挡、DPI 缩放后黑图
区域截图BitBlt 限定宽高和源坐标表格区、局部校验坐标基准混乱导致偏位

为什么这么分?因为 MFC 里的 GDI 截屏,核心就是 BitBlt 一个函数:它把屏幕上指定矩形内的像素原封不动地拷贝到内存 DC。全屏只是区域等于虚拟屏大小,窗口则是让窗口自己重新绘制一遍,而不是从屏幕抓现成的像素。

有个容易混的点:区域截图用的坐标,到底是以屏幕左上角为基准,还是以客户区左上角为基准?如果坐标来自GetWindowRect,就是相对虚拟屏幕左上角的坐标;如果来自GetClientRect,必须先ClientToScreen转换。混用坐标系是截屏偏位的第一大来源。

提示:区域截图的坐标基准,要么统一用虚拟屏坐标,要么统一用客户区坐标,混用是截屏偏位最常见的原因。

2.2 在 VS 里新建 MFC 截屏工程:从向导到按钮

这里的 VS 指 Visual Studio,不是 VS Code,MFC 工程只能在 Visual Studio 里建。打开 Visual Studio,新建项目,选“MFC 应用”,应用类型选“基于对话框”,语言用 C++。创建好后,在资源视图里打开对话框模板,从工具箱拖一个按钮进去,把按钮 ID 改成IDC_BTN_CAPTURE,标题改成“截屏”。

双击这个按钮,VS 会自动生成消息处理函数框架:

void CCaptureDlg::OnBnClickedBtnCapture() { // TODO: 在此添加按钮处理代码 // 截屏代码会写在这个函数里 }

这个名字是 VS 根据按钮 ID 自动映射的。MFC 的消息映射表把IDC_BTN_CAPTURE和这个函数绑定,所以函数名不能随便改。如果希望在别处触发截屏,也可以自己在消息映射里加一行ON_BN_CLICKED(IDC_BTN_CAPTURE, &CCaptureDlg::OnBnClickedBtnCapture)。

工程配置上有两个点值得注意。第一,项目属性里的字符集建议选“使用 Unicode 字符集”,这样代码里的L"xxx.bmp"字符串不会出现转换问题。第二,如果同一个工程里加了多个 .cpp 文件,不要在每个文件里都定义同一个消息处理函数,否则链接时会报 LNK2005 符号重复定义,这个错误和截屏本身没关系,纯粹是工程组织问题。

MFC 向导默认会链接 user32 和 gdi32,截屏需要用到的那几个 Win32 API 不需要额外加库,这点比新手想的省事。

2.3 为什么在 MFC 里优先用 GDI 而不是 GDI+ 或 DXGI

MFC 工程里做静态截图,我默认用 GDI 的 BitBlt 系列,而不是 GDI+。GDI+ 不是不能用,它保存成 PNG、JPEG 确实方便,但要在 MFC 里先用GdiplusStartup初始化、退出时再GdiplusShutdown,管线比 GDI 长一截。截屏本身只是把屏幕像素拷出来,GDI 的BitBlt加GetDIBits已经覆盖整个流程,代码量更少,也不容易引入额外的状态管理。

DXGI Desktop Duplication 更适合连续采集,比如录屏或远程画面推送。它要求创建 DXGI 设备和共享资源,状态机复杂,为一个按钮截屏引入它太重。如果以后要往录屏方向走,再单独抽一个 DXGI 采集模块,和现有 GDI 截屏并存,完全来得及。

也有人会问“桌面开发用 MFC 还是 Qt”。Qt 的QScreen::grabWindow底层同样走系统抓屏,如果哪天把工具迁移到 Qt,这个需求的表现形式会变,但“屏幕 DC → 内存 DC → 位图 → 文件”这个模型是不变的。本文代码以 MFC 为主,思路可以平移。

3. 核心实现:用 GDI 的 BitBlt 把屏幕像素搬进 BMP 文件

3.1 理解屏幕 DC、内存 DC 和位图这三件套

屏幕 DC 是截屏的“源头”。GetDC(NULL)返回的屏幕 DC 代表整个虚拟桌面,所有显示器的像素都可以从它上面拷。注意这个 DC 用完后必须ReleaseDC(NULL, hdc)释放,否则每次截屏都会泄漏一个 GDI 对象,这个问题在定时连续截屏时尤其致命。

内存 DC 是中间画板。屏幕 DC 不能直接拿来当写入目标,BitBlt 需要一个兼容 DC 作为目标,CreateCompatibleDC(hScreen)创建的就是这种画板。它本身没有可画的表面,必须配合一张位图。先用CreateCompatibleBitmap(hScreen, w, h)创建一张和屏幕颜色格式一致的位图,再通过SelectObject(hMemDC, hBitmap)把它选入内存 DC。选入时SelectObject的返回值是上一个旧位图,保存好,后面清理时再选回去,否则直接删除位图容易删掉 DC 里正在用的对象,导致后续异常。

整个流程的顺序是:

GetDC(NULL)→CreateCompatibleDC→CreateCompatibleBitmap→SelectObject→BitBlt→GetDIBits→SelectObject(旧位图)→DeleteObject→DeleteDC→ReleaseDC

对象创建方式清理方式常见误用
屏幕 DCGetDC(NULL)ReleaseDC(NULL, hdc)只开不关,连续截屏内存暴涨
内存 DCCreateCompatibleDCDeleteDC和屏幕 DC 搞混
位图CreateCompatibleBitmapDeleteObject选入 DC 后直接删除

这三件套的创建和释放顺序,决定了这个函数能不能扛住长时间运行。后面第 5 章有一节专门讲连续截屏的内存泄漏,就是从这里来的。

3.2 完整代码:从屏幕 DC 到 BMP 文件

这是最常用的一个函数,输入一个屏幕矩形,输出 BMP 文件。坐标用虚拟屏坐标,单位是像素。

BOOL CaptureRectToFile(LPCTSTR lpszFileName, int nLeft, int nTop, int nRight, int nBottom) { BOOL bOk = FALSE; HDC hScreen = GetDC(NULL); // 整个虚拟桌面的DC HDC hMemDC = CreateCompatibleDC(hScreen); // 兼容内存DC int nWidth = nRight - nLeft; int nHeight = nBottom - nTop; HBITMAP hBitmap = CreateCompatibleBitmap(hScreen, nWidth, nHeight); HBITMAP hOldBitmap = (HBITMAP)SelectObject(hMemDC, hBitmap); // 把屏幕指定矩形拷进内存DC if (BitBlt(hMemDC, 0, 0, nWidth, nHeight, hScreen, nLeft, nTop, SRCCOPY)) { BITMAPINFO bmi = {0}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = nWidth; bmi.bmiHeader.biHeight = -nHeight; // 负值:从上到下存储,免翻转 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; // 32位BGRA bmi.bmiHeader.biCompression = BI_RGB; DWORD dwBitsSize = ((nWidth * 32 + 31) / 32) * 4 * nHeight; BYTE* pBits = new BYTE[dwBitsSize]; // 把位图内容读到内存缓冲区 if (GetDIBits(hMemDC, hBitmap, 0, nHeight, pBits, &bmi, DIB_RGB_COLORS)) { BITMAPFILEHEADER bfh = {0}; bfh.bfType = 0x4D42; // 'B''M' bfh.bfSize = sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER) + dwBitsSize; bfh.bfOffBits = sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER); HANDLE hFile = CreateFile(lpszFileName, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile != INVALID_HANDLE_VALUE) { DWORD dwWritten = 0; WriteFile(hFile, &bfh, sizeof(bfh), &dwWritten, NULL); WriteFile(hFile, &bmi.bmiHeader, sizeof(BITMAPINFOHEADER), &dwWritten, NULL); WriteFile(hFile, pBits, dwBitsSize, &dwWritten, NULL); CloseHandle(hFile); bOk = TRUE; } } delete[] pBits; } // 清理:先选回旧位图,再删位图、删DC、释放屏幕DC if (hOldBitmap) SelectObject(hMemDC, hOldBitmap); DeleteObject(hBitmap); DeleteDC(hMemDC); ReleaseDC(NULL, hScreen); return bOk; }

调用方式:全屏时传入虚拟屏范围,区域时传入目标矩形。

// 截全屏:虚拟屏坐标 int vx = GetSystemMetrics(SM_XVIRTUALSCREEN); int vy = GetSystemMetrics(SM_YVIRTUALSCREEN); int vw = GetSystemMetrics(SM_CXVIRTUALSCREEN); int vh = GetSystemMetrics(SM_CYVIRTUALSCREEN); CaptureRectToFile(L"fullscreen.bmp", vx, vy, vx + vw, vy + vh);

代码里有两个细节值得说明。第一,biHeight用负值,表示 BMP 像素行从上到下存储,和显卡内存排列一致,省去保存前做行翻转。第二,dwBitsSize的公式确保每行字节数按 4 字节对齐,这是 BMP 文件格式的硬性要求,宽高随意的时候也安全。

注意:GetDC 必须配 ReleaseDC,CreateCompatibleDC 必须配 DeleteDC。两条链漏一条,长时间跑就会出问题。

3.3 BitBlt 核心参数设置:尺寸、坐标、光栅操作

BitBlt 的函数签名分布在 wingdi.h 里,最常用的有六个参数:

BitBlt(HDC hdcDest, int nx, int ny, int nWidth, int nHeight, HDC hdcSrc, int xs, int ys, DWORD dwRop)

这里nx/ny是目标 DC 的左上角,通常传0, 0;nWidth/nHeight是要截的宽高,必须大于 0;xs/ys是源 DC(屏幕)里拷取的起点,这是屏幕坐标系中的位置;dwRop是光栅操作码,平常只用SRCCOPY,表示直接把源像素覆盖到目标,不做任何混合。

坐标最容易错的不是 BitBlt 本身,而是“这个坐标是哪套坐标系”。如果区域来自GetWindowRect,得到的是相对虚拟屏幕左上角的坐标,可以直接当作xs/ys用;如果来自GetClientRect,那是客户区坐标,必须先ClientToScreen转成屏幕坐标再传进来。窗口有边框、标题栏、滚动条的时候,客户区和窗口区可以差出几十个像素。

多显示器场景下还有一个负坐标问题。副屏放在主屏左边时,屏幕矩形左上角的 x 是负数,比如-1920。BitBlt 是接受负数源坐标的,只要在屏幕 DC 的有效范围内就能正常拷贝。怕就怕代码里写死了0起点。

dwRop还有一个CAPTUREBLT,很多人把它当万能钥匙。实际上默认 BitBlt 不会把分层窗口(WS_EX_LAYERED)的像素拷进来,加SRCCOPY | CAPTUREBLT能把这类窗口包含进去。但它只是解决分层窗口这一小类问题,遇到硬件加速渲染的窗口照样黑图,那种情况要交给第 4 章的方案。

4. 高频坑位排查:多显示器、DPI 缩放和窗口遮挡,让截屏集体翻车

截屏代码跑通一次很容易,换台机器就现原形。下面这三类是我在 MFC 截屏工具里踩过的真问题,按“现象 → 原因 → 解决”记录。

4.1 多显示器:为什么全屏截图只截到主屏

现象:双屏扩展模式下,截屏程序输出一张图片,只有主屏那么宽,副屏内容完全没进去;或者图片最右侧能看到一部分副屏,其余区域是黑的。单屏机器上一切正常,一接副屏就出问题。

原因:代码里全屏范围写成了GetSystemMetrics(SM_CXSCREEN)和SM_CYSCREEN,这两个值只返回主屏尺寸。GetDC(NULL)返回的却是整个虚拟桌面 DC。副屏内容在超出主屏的矩形里,BitBlt 在参数超出屏幕 DC 有效范围时,要么空白要么被裁剪。

解决:用虚拟屏那组系统指标,而不是主屏尺寸:

int vx = GetSystemMetrics(SM_XVIRTUALSCREEN); int vy = GetSystemMetrics(SM_YVIRTUALSCREEN); int vw = GetSystemMetrics(SM_CXVIRTUALSCREEN); int vh = GetSystemMetrics(SM_CYVIRTUALSCREEN); // 虚拟屏区域可能是负坐标,原样传进去即可 CaptureRectToFile(L"all_screens.bmp", vx, vy, vx + vw, vy + vh);

BitBlt 的源坐标用虚拟屏坐标,不需要额外换算。副屏在左边时,SM_XVIRTUALSCREEN返回的是负数,直接把vx传进去就行。GetWindowRect返回的坐标也是这套坐标系,所以窗口截图在多屏场景下反而不需要太多处理;只有区域截图要确认自己的坐标基准是虚拟屏还是客户区。

4.2 DPI 缩放:截出来的图变小、模糊、鼠标对不上

现象:一台 100% 缩放的机器正常,另一台 150% 或 200% 缩放的机器上,同一份代码截全屏,生成图片的分辨率只有实际物理分辨率的约三分之二。比如 3840x2160 的屏,截出来变成 2560x1440。用鼠标坐标做像素定位时,位置和图像内容对不上。窗口截出来也比真实窗口窄一圈。

原因:Windows 对没声明 DPI 感知的程序做了 DPI 虚拟化。程序里GetSystemMetrics、GetWindowRect返回的是逻辑像素,而屏幕 DC 在 BitBlt 时实际拷贝的是物理像素。用逻辑宽高去拷物理屏,结果自然是分辨率缩水。截图变模糊是后续拉伸放大造成的。

解决:在创建任何窗口之前,把进程设为 DPI 感知。MFC 工程放在InitInstance最前面:

BOOL CCaptureApp::InitInstance() { // 放在这个函数最前面,窗口创建之前 typedef HRESULT(WINAPI* PFN_SetProcessDpiAwareness)(int); HMODULE hShcore = LoadLibrary(_T("Shcore.dll")); if (hShcore) { PFN_SetProcessDpiAwareness pFn = (PFN_SetProcessDpiAwareness)GetProcAddress(hShcore, "SetProcessDpiAwareness"); if (pFn) pFn(2); // 2 = PROCESS_PER_MONITOR_DPI_AWARE } else { SetProcessDPIAware(); // Win7/Win8 老路径,让系统按系统DPI感知处理 } // ...后续原来的代码 }

设置之后,GetSystemMetrics返回物理像素,屏幕 DC 尺寸和物理分辨率一致。要注意的是,这一步会改变整个进程的 UI 布局:老工程用默认字体时,控件可能显小,对话框资源和字体大小得跟着调整。多显示器之间 DPI 不一致的环境,要进一步用SetProcessDpiAwarenessContext走 Per-Monitor V2,但通用的单分辨率缩放场景,上面的代码已经够用。

4.3 窗口遮挡与透明窗口:BitBlt 不灵,PrintWindow 也有边界

现象一:截某个窗口,目标窗口被另一个窗口盖住一半。BitBlt 截出的图片里,被盖住的区域显示的是遮挡窗口的内容或桌面背景,而不是目标窗口本身的内容。

现象二:用PrintWindow截窗口,整个区域是黑块;或者截透明窗口(WS_EX_LAYERED)时,背景区域是黑色的。

原因:BitBlt 抓的是屏幕合成后的真实像素,遮挡窗口先画在屏幕上,所以像素也被盖住了。PrintWindow 是让窗口自己往指定 DC 里重绘,如果窗口用了 DirectComposition 或硬件加速合成,普通 WM_PRINT 路径拿不到合成后的缓冲。透明窗口的绘制逻辑类似,普通路径不会把背景合成进来。

解决:BitBlt 路径在截窗口时不成立,改用PrintWindow:

BOOL CaptureWindowToFile(HWND hWnd, LPCTSTR lpszFileName) { RECT rc; GetWindowRect(hWnd, &rc); int nW = rc.right - rc.left; int nH = rc.bottom - rc.top; HDC hScr = GetDC(NULL); HDC hMem = CreateCompatibleDC(hScr); HBITMAP hBmp = CreateCompatibleBitmap(hScr, nW, nH); HBITMAP hOld = (HBITMAP)SelectObject(hMem, hBmp); // 优先带 PW_RENDERFULLCONTENT,多数窗口会把完整合成内容画进去 DWORD dwFlags = PW_RENDERFULLCONTENT; if (!PrintWindow(hWnd, hMem, dwFlags)) PrintWindow(hWnd, hMem, 0); // 老了系统回退到普通路径 // 保存 BMP 的流程复用 3.2 的 GetDIBits + WriteFile 逻辑 // 这里从 hMem 中读取位图并写入文件 if (hOld) SelectObject(hMem, hOld); DeleteObject(hBmp); DeleteDC(hMem); ReleaseDC(NULL, hScr); return TRUE; }

PrintWindow 的 DC 要用 CreateCompatibleDC 创建的兼容 DC,而且最好先把位图选进去,否则部分窗口会拒绝绘制。PW_RENDERFULLCONTENT 这个标志在 Win8.1 以上可用,Windows 10/11 下对绝大多数窗口都有效,但遇到真正走 DirectComposition 的视频窗口或浏览器硬件加速层,它可能仍然输出黑块。

对这种情况,我一般会分成两步走。第一,试试“隐藏遮挡窗口”的土办法:枚举目标窗口上方的顶层窗口,临时SetWindowPos把它们移开,截完再恢复,这一招能解决不少重叠场景。第二,真搞不定再引入 DXGI Desktop Duplication 做桌面采集,那套东西是一个完整的采集管线,适合后面要录屏、推流的场景,为单张截图专门迁移不划算。

5. 进阶做法:定时连续截屏、区域直截和 GDI 对象的内存回收

5.1 定时截屏:每 500ms 抓一张,内存却不涨

截屏不只是点一下按钮。很多时候要定期留档:每隔 500ms 抓一张,连起来就是屏幕变化的记录。MFC 里实现很简单,对话框上放一个“开始”按钮,点击后启动定时器。

void CCaptureDlg::OnBnClickedBtnStart() { // 定时器ID = 1,间隔500ms SetTimer(1, 500, NULL); } void CCaptureDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { static int nSeq = 0; CString strPath; strPath.Format(_T("capture_%04d.bmp"), ++nSeq); // 用虚拟屏范围,兼容多显示器 int vx = GetSystemMetrics(SM_XVIRTUALSCREEN); int vy = GetSystemMetrics(SM_YVIRTUALSCREEN); int vw = GetSystemMetrics(SM_CXVIRTUALSCREEN); int vh = GetSystemMetrics(SM_CYVIRTUALSCREEN); CaptureRectToFile(strPath, vx, vy, vx + vw, vy + vh); } CDialogEx::OnTimer(nIDEvent); }

OnTimer每被系统调度一次,就执行一次截屏。文件按序号命名,capture_0001.bmp这样排下去。500ms 间隔相当于 2 帧每秒,对操作留痕、状态监控已经足够。

参数上有一个需要提前算的账:一张 1080p 的 32 位 BMP 约 8.3MB,按 2fps 存一小时,大约 60MB。磁盘占用会很快上来,实际项目里一般会建议压缩成 PNG,或者只截关键区域,不然跑一晚上就是几十万个小文件。如果确实需要高帧率连续采集,GDI 截屏加 BMP 落盘这条路很快会遇到瓶颈,那时候应该先换压缩格式,再考虑 DXGI。

5.2 区域截图:直接剪裁 BitBlt 参数,而不是截全屏再裁

有些需求只关心屏幕上某个区域,比如表格里的某一行、按钮的图标、报表的一角。常见的错误做法是先截全屏,再在内存里做一次矩形裁剪,白白多一次大图的分配和拷贝。

正确做法是把区域矩形直接传给CaptureRectToFile,BitBlt 只拷贝目标区域,不会多占内存。比如要截一个控件:

// 拿控件在屏幕上的位置 RECT rcWnd; GetDlgItem(IDC_REPORT)->GetWindowRect(&rcWnd); // 相对虚拟屏坐标 // 直接截这个区域,不用先截全屏再裁剪 CaptureRectToFile(_T("area.bmp"), rcWnd.left, rcWnd.top, rcWnd.right, rcWnd.bottom);

GetWindowRect返回的是屏幕坐标,可以直接传给CaptureRectToFile。如果截的是控件的客户区,步骤是GetClientRect拿到客户区大小,再ClientToScreen把左上角转成屏幕坐标。这一步是区域截图最容易出错的点,很多人拿客户区宽高直接当屏幕坐标用,截出来的图整体向右下偏移。

为什么不建议截全屏再裁?一张 4K 全屏位图要占约 33MB 内存,裁剪时又是一次逐像素的整图拷贝。直接对目标区域 BitBlt,内存占用只有区域大小,差距非常明显,连续截屏时这个差距会直接影响帧率。

5.3 连续截屏时的一次内存泄漏事故

现象:截屏程序开着跑了一晚上,内存从 30MB 涨到 2GB 以上,最后 GDI 对象耗尽,截出来的图全黑。

原因:CaptureRectToFile里创建了屏幕 DC、内存 DC、位图,如果中途提前 return,没有走到后面的清理代码,所有对象都会被丢弃。定时器每 500ms 触发一次,每次泄漏几 MB,几个小时后内存就爆了。

我自己的惨痛经历:有一次在GetDIBits失败时直接加了 return,没走清理代码,第二天跑批发现截图全黑,任务管理器里内存已经顶到 2.7GB。从那以后,我写截屏函数第一件事就是把所有出口都统一到同一段清理逻辑上。

清理顺序是固定的:先SelectObject选回旧位图,再DeleteObject删除位图,然后DeleteDC删除内存 DC,最后ReleaseDC释放屏幕 DC。任何分支都不能漏。更稳妥的办法是写一个小的 RAII 类,让析构函数兜底:

class CScreenCaptureGuard { public: HDC hScreen; HDC hMemDC; HBITMAP hBitmap; HBITMAP hOldBmp; CScreenCaptureGuard(int nW, int nH) { hScreen = GetDC(NULL); hMemDC = CreateCompatibleDC(hScreen); hBitmap = CreateCompatibleBitmap(hScreen, nW, nH); hOldBmp = (HBITMAP)SelectObject(hMemDC, hBitmap); } ~CScreenCaptureGuard() { if (hOldBmp) SelectObject(hMemDC, hOldBmp); DeleteObject(hBitmap); DeleteDC(hMemDC); ReleaseDC(NULL, hScreen); } };

这个类把三件套的创建和释放收拢到构造和析构函数里。哪怕中间有异常、有提前 return,析构函数都会执行,不会“漏一次就泄漏一点”。实际工程里还应该在构造函数里判断每一步的返回值,创建失败就抛异常或置标志,这里为可读性省略了。

6. 验证截屏结果:用像素级对比代替“肉眼看着没问题”

6.1 一个简单的像素级校验流程

肉眼看不靠谱,尤其是截屏偶尔差一两个像素的情况。我现在的验证流程是:先用 Windows 自带截图工具截一张基准图,再让自己写的函数截同一区域,逐像素对比 RGB。差异像素占比低于 0.1%,基本可以交付。

// 比较两张32位BGRA位图,返回差异像素数量 DWORD CountDiffPixels(const BYTE* a, const BYTE* b, DWORD width, DWORD height) { DWORD diff = 0; for (DWORD y = 0; y < height; ++y) { const BYTE* pa = a + y * width * 4; const BYTE* pb = b + y * width * 4; for (DWORD x = 0; x < width; ++x) { // BGRA顺序,三通道任一个超过阈值就算差异 if (abs(pa[x * 4] - pb[x * 4]) > 3 || abs(pa[x * 4 + 1] - pb[x * 4 + 1]) > 3 || abs(pa[x * 4 + 2] - pb[x * 4 + 2]) > 3) { ++diff; } } } return diff; }

阈值 3 以内通常是肉眼不易察觉的色差,字体边缘的抗锯齿和部分渲染差异通常落在这个范围。如果差异像素大量集中在固定区域,且差值超过几十,基本可以断定是坐标偏移或者位图格式没对齐。

6.2 我留给自己的几个验证习惯

我现在常备一台双显示器 + 150% 缩放的测试机,专门用来跑截屏回归。每次改动后至少确认三件事:全屏截图的尺寸和物理分辨率一致;窗口截图的内容和窗口实际位置吻合;定时器连续运行 20 分钟,任务管理器里内存曲线平稳。

如果这次改动只动了保存逻辑,我也会至少完整跑一遍上面的流程。老实说,我见过太多“能跑就以为没问题”的做法,最后都在 DPI 缩放或遮挡问题上栽跟头。截屏工具就是这样,边界条件不测到,换台机器就是另一副面孔。

希望这套思路和代码,能让你在 MFC/VS 截屏这件事上少走一点弯路,也希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询