简介:这份资源是面向MFC Windows程序设计初学者与进阶学习者的实战型取色器项目源码,围绕对话框程序、自定义控件与颜色选择交互展开,适合正在啃MFC框架、想通过完整案例理解消息映射与控件封装的开发者研究。压缩包共80个文件,约57.61MB,包含16个h头文件与15个cpp源文件,另有rc资源脚本、ico与bmp位图、cur光标等界面素材,以及sln、vcxproj、dsp等工程配置文件和编译产物,结构完整可直接打开运行。已有173人学习关注。项目涵盖颜色按钮、调色板窗口、弹出式取色面板、颜色单元格对象及自定义静态控件等多个模块,读者可从中掌握自绘控件、颜色对话框封装、资源加载与工程组织方式,配合博客讲解能少走弯路,快速把取色器思路迁移到自己的MFC项目中。
1. 新款取色器到底新在哪:从屏幕抓色到 MFC 落地的完整链路
很多人第一次写取色器,思路都很朴素:鼠标一动,读一个像素,把 RGB 填进编辑框,收工。真放到 MFC Windows 程序设计里跑起来,问题立刻冒出来——鼠标移出窗口就收不到消息、高 DPI 屏上坐标对不上、取到的颜色和设计软件里差一个色号、放大镜卡成幻灯片。所谓“新款取色器”,新就新在它不再是单点取色的小工具,而是一套带全局钩子、屏幕放大预览、多色彩空间换算和剪贴板联动的完整链路。它解决的是“所见即所得地拿到屏幕上任意位置的真实颜色”,适合做 UI 走查、图像处理 Demo、配色工具、自动化测试取色的开发者。下面按“先立原理、再动手、最后避坑”的顺序,把这条链路拆开讲清楚,新手能照着复现,熟手能直接看参数边界。
2. 全局取色的底层机制:为什么 GetPixel 一定会翻车
2.1 屏幕坐标、窗口坐标与 DPI 缩放的三层换算
取色器第一个绕不开的坑,是坐标系。Windows 里至少有三套坐标在打架:物理屏幕像素坐标、逻辑坐标(受 DPI 缩放影响)、以及窗口客户区坐标。你在 150% 缩放的屏幕上用GetCursorPos拿到的是逻辑坐标,而GetPixel要的是物理像素,两者差一个缩放因子,取出来的颜色自然偏。
常见做法是用GetDpiForWindow拿到当前窗口 DPI,再算缩放比:
// 获取当前窗口 DPI 与缩放比例 UINT dpi = GetDpiForWindow(m_hWnd); // Win10 1607+ 可用 float scale = dpi / 96.0f; // 96 DPI 为 100% 基准 // 逻辑坐标转物理像素坐标 CPoint ptLogical; GetCursorPos(&ptLogical); // 拿到的是逻辑坐标 CPoint ptPhysical; ptPhysical.x = static_cast<LONG>(ptLogical.x * scale); ptPhysical.y = static_cast<LONG>(ptLogical.y * scale);GetDpiForWindow返回窗口所在显示器的 DPI,96 是 100% 缩放基准,scale 就是缩放倍数。注意GetCursorPos在进程声明为 DPI 感知后返回的才是物理坐标,如果清单文件里没声明 DPI 感知,系统会帮你做虚拟化,坐标和像素就对不上了。这一步不做,后面全错。
2.2 用全局钩子替代定时器轮询鼠标
新手最爱用SetTimer每 50ms 读一次鼠标位置,结果就是快速移动时丢点、CPU 空转、放大镜一顿一顿。正确做法是装一个低级鼠标钩子WH_MOUSE_LL,它在鼠标事件进入消息队列前就回调,响应及时且不占 CPU。
// 全局低级鼠标钩子,捕获全屏鼠标移动 HHOOK g_hMouseHook = nullptr; LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode == HC_OK && wParam == WM_MOUSEMOVE) { MSLLHOOKSTRUCT* pInfo = reinterpret_cast<MSLLHOOKSTRUCT*>(lParam); // pInfo->pt 是物理屏幕坐标,直接可用 // 这里通过自定义消息把坐标抛回主窗口,避免跨线程直接操作 UI ::PostMessage(g_hMainWnd, WM_APP_PICK_COLOR, 0, MAKELPARAM(pInfo->pt.x, pInfo->pt.y)); } return CallNextHookEx(g_hMouseHook, nCode, wParam, lParam); }WH_MOUSE_LL是全局钩子,回调在安装钩子的线程里执行,所以别在回调里做重活,用PostMessage把坐标丢回主窗口处理。MSLLHOOKSTRUCT里的pt已经是物理屏幕坐标,省去一次换算。钩子用完必须UnhookWindowsHookEx,否则进程退出后钩子残留,系统会变卡——这是血泪经验。
2.3 屏幕 DC 抓像素与 BitBlt 的取舍
拿到坐标后取色,最直接的是GetPixel,但它慢,而且对某些硬件加速渲染的窗口(比如播放器、部分浏览器)取不到真实颜色,返回黑或白。稳妥方案是用屏幕 DC 做BitBlt抓一小块位图再读像素。
// 抓取以 (x,y) 为中心、size x size 的屏幕区域 COLORREF PickColorByBitBlt(int x, int y, int size) { HDC hScreenDC = ::GetDC(nullptr); // 整个屏幕的 DC HDC hMemDC = ::CreateCompatibleDC(hScreenDC); HBITMAP hBmp = ::CreateCompatibleBitmap(hScreenDC, size, size); HBITMAP hOld = (HBITMAP)::SelectObject(hMemDC, hBmp); // 把屏幕区域拷到内存 DC,CAPTUREBLT 保证抓到分层窗口 ::BitBlt(hMemDC, 0, 0, size, size, hScreenDC, x - size / 2, y - size / 2, SRCCOPY | CAPTUREBLT); // 读中心像素 COLORREF cr = ::GetPixel(hMemDC, size / 2, size / 2); ::SelectObject(hMemDC, hOld); ::DeleteObject(hBmp); ::DeleteDC(hMemDC); ::ReleaseDC(nullptr, hScreenDC); return cr; }CAPTUREBLT标志很关键,没有它抓不到带透明或分层合成的窗口。size一般取 1 就够取单点,但做放大镜预览时取 15 到 21,一次抓一块,比逐像素GetPixel快一个数量级。注意GetDC(nullptr)拿的是整个屏幕,用完必须ReleaseDC,否则 DC 泄漏,跑久了系统句柄耗尽。
3. 放大镜预览与实时刷新:把取色体验做顺
3.1 放大镜位图的缩放算法与刷新频率
取色器好不好用,一半看放大镜。用户要能看清鼠标下那几个像素,才能精准取到想要的颜色。做法是把 2.2 里抓到的size x size小位图,用StretchBlt放大到预览窗口,配合SetStretchBltMode选插值方式。
// 把抓到的屏幕小图放大绘制到预览窗口 void DrawMagnifier(HDC hdc, HBITMAP hSrcBmp, int srcSize, int dstSize) { HDC hMemDC = ::CreateCompatibleDC(hdc); HBITMAP hOld = (HBITMAP)::SelectObject(hMemDC, hSrcBmp); // HALFTONE 让放大后平滑,COLORONCOLOR 更快但锯齿明显 ::SetStretchBltMode(hdc, HALFTONE); ::SetBrushOrgEx(hdc, 0, 0, nullptr); // HALFTONE 必须重设刷原点 ::StretchBlt(hdc, 0, 0, dstSize, dstSize, hMemDC, 0, 0, srcSize, srcSize, SRCCOPY); ::SelectObject(hMemDC, hOld); ::DeleteDC(hMemDC); }HALFTONE模式放大后平滑,但每次都要SetBrushOrgEx重置刷原点,否则图案会错位——这个坑很多人踩过。刷新频率别跟着鼠标每个消息都重绘,用InvalidateRect标记区域,让系统合并重绘,或者用 30ms 的定时器节流,不然快速移动时 CPU 飙高。
3.2 十字准星与中心像素高亮
放大镜里要标出当前取的是哪个像素,否则用户不知道中心点在哪。常见做法是在预览窗口正中画一个十字准星,并把中心像素单独框出来。
// 在预览窗口上叠加十字准星和中心像素框 void DrawCrosshair(HDC hdc, int dstSize, int cellSize) { int center = dstSize / 2; HPEN hPen = ::CreatePen(PS_SOLID, 1, RGB(255, 0, 0)); HPEN hOld = (HPEN)::SelectObject(hdc, hPen); ::MoveToEx(hdc, center, 0, nullptr); ::LineTo(hdc, center, dstSize); ::MoveToEx(hdc, 0, center, nullptr); ::LineTo(hdc, dstSize, center); // 中心像素用矩形框出来,cellSize 是单个像素放大后的边长 ::Rectangle(hdc, center - cellSize / 2, center - cellSize / 2, center + cellSize / 2, center + cellSize / 2); ::SelectObject(hdc, hOld); ::DeleteObject(hPen); }cellSize = dstSize / srcSize,比如预览 200 像素、源 20 像素,每个源像素放大 10 倍。准星用红色 1 像素实线,中心框用矩形,视觉上最清楚。别用虚线,放大后虚线看不清。
3.3 取色确认与剪贴板联动
用户按下快捷键(一般是空格或鼠标中键)确认取色,这时要把颜色写进编辑框、色块预览,并支持一键复制。复制格式建议同时给 HEX 和 RGB,方便贴到不同工具里。
// 把颜色以多种格式写入剪贴板 void CopyColorToClipboard(HWND hWnd, COLORREF cr) { CString strHex, strRgb; strHex.Format(_T("#%02X%02X%02X"), GetRValue(cr), GetGValue(cr), GetBValue(cr)); strRgb.Format(_T("rgb(%d, %d, %d)"), GetRValue(cr), GetGValue(cr), GetBValue(cr)); CString strAll = strHex + _T("\r\n") + strRgb; if (::OpenClipboard(hWnd)) { ::EmptyClipboard(); int len = (strAll.GetLength() + 1) * sizeof(TCHAR); HGLOBAL hMem = ::GlobalAlloc(GMEM_MOVEABLE, len); if (hMem) { memcpy(::GlobalLock(hMem), strAll, len); ::GlobalUnlock(hMem); ::SetClipboardData(CF_UNICODETEXT, hMem); // 系统接管内存,不要自己释放 } ::CloseClipboard(); } }SetClipboardData成功后内存归系统管,千万别再GlobalFree,否则剪贴板数据损坏——这是经典后悔药场景。OpenClipboard可能被别的进程占用而失败,要加返回值判断和重试。
4. 色彩空间换算:为什么取到的颜色和设计稿对不上
4.1 sRGB、HEX、HSV 的换算关系
取色器取到的是设备相关的 RGB,而设计稿常用 sRGB 下的 HEX,两者在普通屏幕上基本一致,但在广色域屏上会有偏差。另外做调色面板时,HSV 比 RGB 直观,需要互转。
// RGB 转 HSV,h 范围 0-360,s/v 范围 0-1 void RgbToHsv(COLORREF cr, float& h, float& s, float& v) { float r = GetRValue(cr) / 255.0f; float g = GetGValue(cr) / 255.0f; float b = GetBValue(cr) / 255.0f; float maxC = max(r, max(g, b)); float minC = min(r, min(g, b)); float delta = maxC - minC; v = maxC; s = (maxC == 0.0f) ? 0.0f : delta / maxC; if (delta == 0.0f) h = 0.0f; else if (maxC == r) h = 60.0f * fmod((g - b) / delta, 6.0f); else if (maxC == g) h = 60.0f * ((b - r) / delta + 2.0f); else h = 60.0f * ((r - g) / delta + 4.0f); if (h < 0.0f) h += 360.0f; }fmod处理红色段的角度回绕,h为负时加 360 归正。HSV 转 RGB 是逆过程,按h所在区间分段计算即可。做调色面板时,色相条用h,饱和度和明度用两个滑块,比直接调 RGB 顺手得多。
4.2 广色域屏上的颜色偏差与应对
广色域屏(比如 P3 色域)上,同样的 RGB 值显示出来比 sRGB 屏更艳,取色器读到的原始值如果直接当 sRGB 用,贴到设计稿里就偏了。严格做法是做色彩管理,用GetICMProfile拿显示器 ICC 配置文件,做 RGB 到 sRGB 的转换。但多数取色场景对精度要求没那么高,常见做法是提供一个“按 sRGB 显示”的开关,把取到的值做一次近似钳制,或者干脆在界面上标注“设备 RGB”,让用户自己判断。
提示:如果取色器是给设计走查用,建议默认显示 HEX 并标注是否经过色彩管理,避免团队里因为色差扯皮。
4.3 多显示器不同 DPI 下的坐标修正
多显示器且缩放不一致时,GetCursorPos返回的坐标是虚拟屏幕坐标,跨屏时 DPI 会变。要取当前鼠标所在显示器的 DPI,用MonitorFromPoint加GetDpiForMonitor。
// 获取鼠标所在显示器的 DPI HMONITOR hMon = ::MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); UINT dpiX = 96, dpiY = 96; ::GetDpiForMonitor(hMon, MDT_EFFECTIVE_DPI, &dpiX, &dpiY); float scaleX = dpiX / 96.0f; float scaleY = dpiY / 96.0f;MDT_EFFECTIVE_DPI拿的是实际生效的 DPI,跨屏时每次都要重新取,不能缓存一个全局值。这一步不做,双屏不同缩放下取色位置会整体偏移。
5. 避坑与排查:取色器最容易翻车的五个地方
5.1 取到的颜色总是黑或白
现象:在某些窗口上取色,返回RGB(0,0,0)或RGB(255,255,255)。原因是这些窗口用硬件加速或分层渲染,GetPixel读的是后台缓冲,拿不到前台内容。解决:改用BitBlt加CAPTUREBLT抓屏,如 2.3 所示。如果还不行,检查是否在受保护内容上取色,这类内容系统会主动屏蔽抓取,属于预期行为。
5.2 高 DPI 下取色位置偏移
现象:125% 或 150% 缩放下,取色点比鼠标实际位置偏右下。原因是坐标没做 DPI 换算,或者进程没声明 DPI 感知。解决:在清单文件里声明PerMonitorV2DPI 感知,所有坐标按 2.1 和 4.3 换算。声明后系统不再帮你虚拟化,坐标和像素一一对应。
5.3 钩子装完系统变卡
现象:取色器运行一段时间后,整个系统鼠标响应变慢。原因是钩子回调里做了耗时操作,或者钩子没正确卸载。解决:回调里只PostMessage,重活丢主线程;退出时确保UnhookWindowsHookEx。低级钩子回调有超时限制,超时系统会静默摘掉钩子,所以回调必须快。
5.4 放大镜刷新闪烁
现象:放大镜快速移动时闪烁严重。原因是每次重绘都擦背景再画,双缓冲没做。解决:用内存 DC 双缓冲,先在内存位图上画好整帧,再一次性BitBlt到窗口。MFC 里可以重写OnEraseBkgnd返回 TRUE 屏蔽背景擦除,在OnPaint里做双缓冲。
5.5 剪贴板复制失败
现象:点复制没反应,或者粘贴出来是乱码。原因是OpenClipboard被占用失败没处理,或者SetClipboardData后错误释放了内存。解决:OpenClipboard加失败重试,SetClipboardData成功后不要GlobalFree,用CF_UNICODETEXT保证中文和特殊字符不乱码。
6. 进阶技巧:把取色器做成可复用的取色组件
做到这里,一个能用的取色器已经跑通了。但如果你想让它在多个项目里复用,建议把它抽成一个独立的取色组件类,对外只暴露“开始取色 / 结束取色 / 取色完成回调”三个接口,内部把钩子、抓屏、放大镜、色彩换算全封装起来。这样 UI 走查工具、图像处理 Demo、自动化测试脚本都能直接挂上去用。
一个我常用的验证方法是:写一个自动化脚本,让鼠标按预设轨迹移动,每到一个坐标就调用取色接口,把结果和预先用参考工具取的值比对。偏差超过阈值就报警。这套自测能覆盖 DPI 换算、抓屏、色彩转换三条链路,比手动点靠谱得多。
// 取色组件对外接口示意 class CColorPicker { public: // 开始取色,hNotifyWnd 接收取色完成消息 BOOL StartPick(HWND hNotifyWnd, UINT uMsg); // 结束取色,释放钩子和资源 void StopPick(); // 取色完成回调,参数为最终颜色 virtual void OnColorPicked(COLORREF cr) = 0; };接口设计上,StartPick负责装钩子和创建放大镜窗口,StopPick负责卸载钩子和销毁资源,回调把最终颜色抛给调用方。这样调用方不用关心底层是GetPixel还是BitBlt,也不用管 DPI 换算,换平台或换抓屏方式时只改组件内部。
参数上我一般会暴露三个可调项:放大镜源区域大小(默认 21 像素)、预览窗口尺寸(默认 200 像素)、刷新节流间隔(默认 30ms)。源区域越大,取色越稳但抓屏越慢;预览越大越清楚但占屏幕;节流间隔太小 CPU 高,太大跟手性差。这三个值按实际屏幕和机器性能调,没有万能值。
最后说个习惯:每次改完取色逻辑,我一定在 100%、125%、150% 三种缩放下各跑一遍,再在双屏不同 DPI 下跑一遍。DPI 这块的坑,靠看代码是看不出来的,只有实际跑才能暴露。希望帮到你。
本文还有配套的精品资源,点击获取