简介这份资源是面向MFC Windows程序设计初学者与进阶学习者的实战型取色器项目源码基于Visual Studio工程组织围绕颜色选择、调色板弹出、自定义按钮与静态控件等常见界面需求展开帮助读者理解MFC消息映射、对话框与自绘控件的配合方式避免在零散示例中反复摸索。压缩包共80个文件约57.61MB包含16个h头文件、15个cpp源文件以及obj、tlog、pdb等编译中间产物另有rc资源脚本、ico与bmp图像素材、sln与vcxproj工程文件完整保留了从源码到可执行程序的构建链路。目前已有173人学习关注。读者可借助其中的颜色对话框、调色板窗口、颜色单元格对象及自定义控件类梳理取色器各模块的职责划分与调用关系对照调试输出与资源定义掌握MFC界面控件的封装思路与工程配置方法为后续开发类似工具打下基础。1. 新款取色器从屏幕任意位置抓一个像素到底难在哪做 Windows 桌面工具的人迟早会碰到一个需求让用户从屏幕上任意位置取一个颜色然后把这个颜色用到自己的程序里。听起来简单到不值一提但真动手写的时候你会发现坑比想象中多。屏幕上有多个显示器、不同 DPI 缩放、窗口遮挡、全屏独占程序甚至还有硬件加速渲染的窗口你截到的像素可能根本不是你以为的那个颜色。MFC 做取色器核心就三件事抓屏拿到像素数据、把屏幕坐标映射到像素坐标、把颜色值回填到界面。但每一件事都有细节。比如多显示器下GetDC(NULL)拿到的是主屏还是虚拟桌面高 DPI 下鼠标坐标和像素坐标差了一个缩放系数不处理就会偏。再比如你想做实时预览——鼠标移到哪颜色框就显示哪的颜色——那就不能每次移动都全屏截一次性能扛不住。这篇笔记面向的是已经会用 MFC 写基本对话框程序、想做一个能实际用的取色器工具的开发者。我会从抓屏方案选型讲到坐标映射再到实时预览的性能优化最后给出一套可复现的实现路径和几个我踩过的坑。读完你至少能做出一个支持多显示器、高 DPI、带放大镜预览的取色器而不是一个只能在主屏 100% 缩放下勉强能用的小玩具。2. 抓屏方案怎么选BitBlt、PrintWindow 还是 Desktop Duplication2.1 三种抓屏路径的适用边界在 Windows 上抓屏常见做法有三条路。第一条是 GDI 的BitBlt从屏幕 DC 拷贝到内存 DC。这是最老也最简单的方式兼容性极好从 Windows 2000 到 Windows 11 都能跑。缺点是它抓的是 GDI 合成后的画面对于某些用 DirectX 独占全屏的程序可能抓到黑屏或者旧帧。第二条是PrintWindow它向目标窗口发送重绘消息让窗口把自己画到指定 DC 上。这个方式适合抓特定窗口但对屏幕取色来说不适用因为你要的是屏幕像素不是某个窗口的内容。第三条是 Desktop Duplication API基于 DXGI能拿到桌面合成后的纹理性能好、支持全屏独占程序但代码复杂度高而且需要处理 DXGI 设备丢失、纹理映射等细节。对于取色器这个场景我一般会选BitBlt作为主路径因为它足够简单覆盖 95% 的使用场景。只有在用户反馈“某些游戏里取色不准”时才考虑加一个 Desktop Duplication 的备选路径。但那是进阶需求第一版没必要上。2.2 用 BitBlt 抓取全屏像素的最小实现下面这段代码演示了如何用BitBlt把整个虚拟桌面抓到一个内存位图中并读取指定坐标的像素颜色。注意这里用的是虚拟桌面的概念它覆盖所有显示器的总区域。// 抓取虚拟桌面指定坐标的像素颜色 COLORREF GetScreenPixel(int x, int y) { // 获取虚拟桌面的边界 int vx GetSystemMetrics(SM_XVIRTUALSCREEN); int vy GetSystemMetrics(SM_YVIRTUALSCREEN); int vw GetSystemMetrics(SM_CXVIRTUALSCREEN); int vh GetSystemMetrics(SM_CYVIRTUALSCREEN); // 创建屏幕 DC 和内存 DC HDC hScreenDC ::GetDC(NULL); HDC hMemDC ::CreateCompatibleDC(hScreenDC); HBITMAP hBitmap ::CreateCompatibleBitmap(hScreenDC, vw, vh); HBITMAP hOldBitmap (HBITMAP)::SelectObject(hMemDC, hBitmap); // 把整个虚拟桌面拷贝到内存 DC ::BitBlt(hMemDC, 0, 0, vw, vh, hScreenDC, vx, vy, SRCCOPY); // 读取指定像素 // 注意x, y 是屏幕坐标需要减去虚拟桌面原点 COLORREF color ::GetPixel(hMemDC, x - vx, y - vy); // 清理资源 ::SelectObject(hMemDC, hOldBitmap); ::DeleteObject(hBitmap); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); return color; }这段代码的逻辑很直接先拿到虚拟桌面的范围然后一次性把整个桌面拷到内存位图最后用GetPixel读点。参数方面SM_XVIRTUALSCREEN和SM_YVIRTUALSCREEN返回虚拟桌面左上角的坐标通常是负值如果主屏不在最左边。SM_CXVIRTUALSCREEN和SM_CYVIRTUALSCREEN返回虚拟桌面的宽高。BitBlt的源坐标用vx, vy目标坐标用0, 0这样内存位图的左上角就对应虚拟桌面的左上角。读像素时要把屏幕坐标转成位图内坐标所以减去了vx和vy。注意GetPixel在内存 DC 上调用是安全的但如果在屏幕 DC 上直接调用某些显卡驱动下会非常慢。所以先BitBlt到内存再读是性能更好的做法。2.3 多显示器与虚拟桌面的坐标换算多显示器下最容易翻车的地方是坐标系统。Windows 的屏幕坐标原点在主显示器的左上角副显示器如果在主屏左边它的 x 坐标就是负的。虚拟桌面则是把所有显示器包进去的最小矩形它的左上角可能不在原点。所以当你从鼠标消息里拿到一个坐标时那个坐标是屏幕坐标而BitBlt抓下来的位图是以虚拟桌面左上角为原点的。两者之间差了一个偏移量就是vx和vy。我见过有人直接用鼠标坐标去GetPixel在主屏上没问题一到副屏就取到错误颜色就是因为没做这个偏移。另外如果你用GetCursorPos拿鼠标位置它返回的也是屏幕坐标同样需要转换。换算公式很简单bitmapX screenX - vxbitmapY screenY - vy。但前提是你抓的是整个虚拟桌面。如果你只抓了主屏那副屏上的坐标就完全对不上了。还有一个细节CreateCompatibleBitmap创建的位图尺寸不能超过显卡限制一般虚拟桌面再大也就几千像素宽没问题。但如果用户接了四个 4K 显示器虚拟桌面宽度可能超过 15000 像素这时候创建位图会占用大量内存15000 x 8000 x 4 字节约 480MB每次取色都这么干显然不现实。所以实时预览时不能每次全抓后面会讲优化。3. 实时预览与放大镜怎么让取色器跟手又不卡3.1 鼠标移动时只抓局部区域取色器最核心的体验是“鼠标移到哪颜色就显示哪”。如果每次WM_MOUSEMOVE都全屏抓一次在 4K 屏上单次BitBlt就要几十毫秒鼠标移动会明显卡顿。我的做法是只在鼠标周围抓一小块区域比如 64x64 像素这样BitBlt的数据量极小耗时可忽略。但这里有个问题鼠标移动消息是连续的如果每次移动都抓一次虽然单次快但频率高了也会累积。所以我会加一个简单的节流用GetTickCount判断距离上次抓取是否超过 16 毫秒约 60 帧没超过就跳过。这样既跟手又不浪费。// 在鼠标移动处理中抓取鼠标周围区域 void CColorPickerDlg::OnMouseMove(UINT nFlags, CPoint point) { // 节流16ms 内只抓一次 DWORD now ::GetTickCount(); if (now - m_dwLastCapture 16) { CDialogEx::OnMouseMove(nFlags, point); return; } m_dwLastCapture now; // 把客户区坐标转成屏幕坐标 CPoint screenPt point; ClientToScreen(screenPt); // 抓取鼠标周围 64x64 区域 const int CAPTURE_SIZE 64; int half CAPTURE_SIZE / 2; HDC hScreenDC ::GetDC(NULL); HDC hMemDC ::CreateCompatibleDC(hScreenDC); HBITMAP hBitmap ::CreateCompatibleBitmap(hScreenDC, CAPTURE_SIZE, CAPTURE_SIZE); HBITMAP hOldBitmap (HBITMAP)::SelectObject(hMemDC, hBitmap); // 从屏幕拷贝鼠标周围区域 ::BitBlt(hMemDC, 0, 0, CAPTURE_SIZE, CAPTURE_SIZE, hScreenDC, screenPt.x - half, screenPt.y - half, SRCCOPY); // 读取中心像素颜色 COLORREF centerColor ::GetPixel(hMemDC, half, half); // 更新界面上的颜色预览和放大镜 UpdateColorPreview(centerColor, hMemDC); ::SelectObject(hMemDC, hOldBitmap); ::DeleteObject(hBitmap); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); CDialogEx::OnMouseMove(nFlags, point); }这段代码的关键参数是CAPTURE_SIZE我设成 64意味着抓鼠标周围 64x64 的区域。half是 32所以BitBlt的源坐标是screenPt.x - 32和screenPt.y - 32。读中心像素时用half, half正好是抓取区域的中心。m_dwLastCapture是成员变量记录上次抓取的时间戳用来做节流。注意如果鼠标靠近屏幕边缘screenPt.x - half可能超出虚拟桌面范围BitBlt会失败或者抓到黑边。稳妥的做法是先判断边界如果超出就调整抓取区域或者用GetSystemMetrics拿到虚拟桌面边界后做裁剪。3.2 放大镜的绘制把 64x64 区域放大到 128x128放大镜的作用是让用户看清鼠标周围几个像素的细节方便精确取色。实现方式很简单把刚才抓到的 64x64 位图用StretchBlt放大两倍画到界面的一个 Picture Control 上。但要注意放大时不要用平滑插值否则颜色会糊掉取色就不准了。StretchBlt默认的模式是BLACKONWHITE会做颜色混合应该用SetStretchBltMode设成COLORONCOLOR这样是直接复制像素不插值。// 把抓到的位图放大两倍画到 Picture Control 上 void CColorPickerDlg::DrawMagnifier(HDC hCaptureDC) { // 获取 Picture Control 的 DC CWnd* pPic GetDlgItem(IDC_MAGNIFIER); CDC* pDC pPic-GetDC(); CRect rect; pPic-GetClientRect(rect); // 设置拉伸模式为 COLORONCOLOR避免颜色插值 int oldMode pDC-SetStretchBltMode(COLORONCOLOR); // 把 64x64 放大到控件大小 ::StretchBlt(pDC-GetSafeHdc(), 0, 0, rect.Width(), rect.Height(), hCaptureDC, 0, 0, 64, 64, SRCCOPY); // 恢复原来的拉伸模式 pDC-SetStretchBltMode(oldMode); pPic-ReleaseDC(pDC); }SetStretchBltMode的参数COLORONCOLOR是关键它告诉 GDI 直接复制像素不做颜色混合。如果用默认模式放大后的图像会模糊用户看到的颜色和实际像素颜色有偏差。StretchBlt的目标尺寸用控件的实际大小源尺寸固定 64x64这样不管控件多大都是把同一块区域放大。3.3 高 DPI 下的坐标修正高 DPI 是取色器最容易翻车的地方。在 150% 缩放下鼠标消息里的坐标是逻辑坐标而BitBlt用的是物理像素坐标。如果你不处理取到的颜色会偏移。比如鼠标在逻辑坐标 (100, 100)实际物理坐标可能是 (150, 150)你按 (100, 100) 去抓抓到的就是左上角偏了的像素。处理方式有两种。第一种是在程序启动时调用SetProcessDPIAware让程序自己处理 DPI这样鼠标消息里的坐标就是物理坐标。第二种是用GetDpiForWindow拿到当前窗口的 DPI然后手动换算。我一般用第一种因为简单而且 MFC 程序在 manifest 里加一行dpiAwaretrue/dpiAware就行。但要注意设了 DPI aware 之后界面上的字体和控件不会自动缩放需要自己调整。如果不想改界面那就用第二种在坐标转换时乘上dpi / 96.0。// 在 InitInstance 中设置 DPI 感知 BOOL CColorPickerApp::InitInstance() { // 设置进程为 DPI 感知这样鼠标坐标就是物理像素 ::SetProcessDPIAware(); CWinApp::InitInstance(); // ... 其他初始化 return TRUE; }SetProcessDPIAware必须在任何窗口创建之前调用否则不生效。调用之后GetCursorPos和鼠标消息返回的都是物理像素坐标和BitBlt的坐标系统一致。但界面的缩放需要自己处理比如对话框资源是按 96 DPI 设计的在 150% 下会显得很小。如果不想处理界面缩放可以改用SetProcessDpiAwarenessContext并传入DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2这样每个显示器独立缩放但代码更复杂。对于取色器这种小工具SetProcessDPIAware够用了。4. 避坑与排查取色器最容易翻车的五个地方4.1 取到的颜色和实际看到的差一点现象用户取一个纯色区域显示的颜色和肉眼看到的略有偏差比如纯红取出来是 (254, 0, 0) 而不是 (255, 0, 0)。原因这种情况通常是颜色管理或者显卡驱动做了色彩校正。Windows 在某些显示器上会应用 ICC 配置文件BitBlt抓到的可能是校正后的颜色。另外如果程序没有 DPI 感知坐标偏移也可能导致取到相邻像素。解决先确认坐标是否正确用放大镜看中心像素是不是目标像素。如果坐标没问题那就是色彩管理的问题。可以在抓屏前调用SetICMMode(hScreenDC, ICM_OFF)关闭屏幕 DC 的 ICM但效果因驱动而异。更稳妥的做法是接受这个偏差因为取色器的主要用途是取相对颜色不是做色彩测量。4.2 副屏上取色偏到主屏现象在主屏取色正常鼠标移到副屏取到的颜色是主屏对应位置的颜色。原因GetDC(NULL)拿到的是主屏 DCBitBlt从主屏拷贝副屏区域是黑的或者旧内容。虚拟桌面的概念没有用上。解决确保BitBlt的源坐标是虚拟桌面坐标并且抓取范围覆盖整个虚拟桌面。用SM_XVIRTUALSCREEN等参数拿到虚拟桌面边界BitBlt的源 DC 用GetDC(NULL)是可以抓到整个虚拟桌面的但源坐标要传虚拟桌面原点。如果只抓局部源坐标也要用虚拟桌面坐标不能直接用屏幕坐标。4.3 鼠标移动时界面闪烁现象鼠标在窗口上移动放大镜区域不停闪烁颜色预览也跳。原因每次鼠标移动都重绘整个 Picture Control而且没有用双缓冲。GDI 直接画到控件 DC 上擦除和绘制交替进行就闪了。解决用内存 DC 做双缓冲。先在内存 DC 上画好放大镜和颜色块再一次性BitBlt到控件 DC。或者更简单把 Picture Control 的WS_CLIPCHILDREN样式去掉减少重绘区域。我一般用双缓冲代码多几行但效果稳定。4.4 全屏游戏里取色失败现象在窗口化游戏里取色正常切到全屏独占模式取到的颜色是黑的或者旧帧。原因全屏独占程序直接访问显卡前台缓冲区GDI 的BitBlt抓不到它的内容。这是BitBlt的固有局限。解决如果必须支持全屏独占只能用 Desktop Duplication API。但那个方案代码量大而且需要处理 DXGI 设备丢失。对于取色器这种工具我一般建议用户用窗口化模式或者接受这个限制。如果非要支持可以加一个备选路径检测到BitBlt抓到黑屏时切换到 Desktop Duplication。4.5 取色后颜色值回填到编辑框格式不对现象取到颜色后编辑框里显示的是0x00FF0000这种 BGR 格式但用户期望的是#FF0000这种 RGB 格式。原因COLORREF的存储格式是0x00BBGGRR蓝色在低位红色在高位。直接格式化输出就会得到 BGR 顺序。解决用GetRValue、GetGValue、GetBValue宏分别取出三个分量然后按 RGB 顺序拼接。比如CString str; str.Format(_T(#%02X%02X%02X), GetRValue(color), GetGValue(color), GetBValue(color));。这个坑很基础但新手经常搞混。5. 从取色到调色板一个能落地的完整工作流5.1 把取到的颜色存成历史记录取色器如果只能取一个颜色用完就丢那价值有限。我习惯加一个历史记录列表用户取过的颜色按顺序存下来可以点击复用。实现上用一个CListCtrl或者自绘的列表控件每个项存一个COLORREF和对应的十六进制字符串。取色时把新颜色插到最前面超过 20 个就删掉最后一个。这样用户可以在几个颜色之间快速切换做配色的时候很方便。存储格式我一般用std::vectorCOLORREF配合一个CListCtrl显示。插入时用InsertItem(0, ...)插到顶部删除时用DeleteItem(19)删掉第 20 个。注意CListCtrl的索引是动态的插入后原来的索引会后移所以删除要用固定位置。5.2 导出调色板为常用格式取了一堆颜色之后用户可能想导出到别的工具里用。常见的格式有.palJASC 格式、.acoPhotoshop 色板、.gplGIMP 色板。我一般支持.gpl和纯文本十六进制列表因为格式简单用记事本就能看。.gpl的格式是GIMP Palette Name: My Palette Columns: 4 # 255 0 0 Red 0 255 0 Green 0 0 255 Blue导出时遍历历史记录按 RGB 分量写进去就行。注意.gpl的 RGB 之间用空格或制表符分隔每行末尾可以跟一个颜色名不写也行。这个格式 GIMP、Inkscape、Krita 都能直接导入通用性不错。5.3 用键盘快捷键提升取色效率鼠标取色虽然直观但精确到像素时鼠标移动很难控制。我一般加一组键盘快捷键方向键微调鼠标位置每次移动一个像素空格键确认取色Esc 取消。这样用户可以把鼠标移到大致位置然后用方向键精确调整避免手抖。实现上在PreTranslateMessage里拦截WM_KEYDOWN判断是方向键就调用SetCursorPos移动鼠标一个像素然后手动触发一次取色更新。注意SetCursorPos用的是屏幕坐标移动后要同步更新界面上的坐标显示。空格键触发取色时把当前颜色存到历史记录然后可以继续取下一个。5.4 一个我常用的调试习惯取色器这种工具最怕的是“看起来能用但某个场景下取到的颜色不对”。我养成了一个习惯在程序里加一个隐藏的调试模式按CtrlShiftD切换。开启后界面上会显示当前鼠标的屏幕坐标、虚拟桌面坐标、抓取区域的左上角坐标以及中心像素的 RGB 值。这样一旦用户反馈取色不准让他开调试模式截个图我就能立刻判断是坐标偏移还是抓屏失败。这个调试信息不用多复杂一个静态文本控件每次鼠标移动时更新就行。但就是这几行代码帮我省了很多来回沟通的时间。取色器本身不复杂复杂的是各种环境差异把环境信息暴露出来排查效率会高很多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?