做前端页面还原、PPT排版或者视频剪辑调色的时候取色是绕不开的动作。以前我都是截个图再放到看图工具里吸颜色后来嫌麻烦换过几个现成的取色器总有这样那样不舒服的地方——有些需要先启动再点一下才能激活有些不能以多种格式复制色值还有些干脆就是把鼠标区域放大得不够清楚。刚好那段时间想用C#练练桌面小工具的开发手感就动手写了个取色器TakeColor把屏幕任意位置的像素实时读出来显示坐标、RGB、HEX、HSV还支持CtrlAltC一键复制到剪贴板顺带做了鼠标周围的放大镜和最近取色历史。这篇文章适合两类人看一是经常和颜色打交道、想有个趁手工具的设计或开发同学二是打算用C# WinForms练手想看看一个完整小工具是怎么组织出来的初学者。我会从核心原理讲到关键代码最后把实测过程中踩过的坑也一并交代清楚源码的工程结构和编译方式放在文末照着跑就能出来一个能用的取色器。1. 为什么一个取色器值得自己造一次轮子1.1 工作流里的刚性需求我日常最密集的取色场景是前端还原设计稿。设计稿里一个按钮的浅灰色、一行文案的深灰色光靠眼睛估根本不靠谱必须拿到精确色值。一天下来几十次取色操作工具够不够顺手直接影响效率。还有就是做PPT、剪视频和写文档配图。视频软件自带的取色器只能在软件内部用全屏看片时想吸取暂停画面里某个色调就只能依赖外部工具。所谓外部工具用得最多的其实是截图软件附带的取色功能但那个必须“先截图、再吸色”等于每次取色都多了一整套动作。次数少还行次数一多这个多余步骤就会让人很烦躁。我一度用过网上流传的单文件取色器确实轻量功能却比较简单。有的把放大镜缩在界面角落里看不到鼠标周围的实际像素有的根本不做放大镜只显示一个色块取色的时候完全凭感觉还有的快捷键不能自定义和系统里其他软件的热键撞了也没办法。最让我不放心的还是来路不明的单文件工具既不知道它会不会往系统里塞东西也不确定它监听全局鼠标之后会不会上传数据。与其纠结这些不如趁周末用C#自己写一个。工具本身也不复杂属于“半天能做完但能学到不少东西”的典型项目。我给它起名TakeColor目标很简单鼠标指哪儿取哪儿色值随取随拷窗口别挡视线历史记录能回看。1.2 “先截图再吸色”为什么不够用很多人习惯用截图工具的取色器是因为它能提供一个放大镜方便精确到像素级。但这个工作流有个天然问题取色前必须先进入截图状态此时画面被蒙层遮住如果目标是窗口边缘、任务栏、甚至是其他软件弹出的Tooltip蒙层一盖目标可能就被挡住了或者蒙层本身让颜色看起来偏暗最后取出来的色值并不准确。另外截图取色器的颜色格式通常只给RGB或HEX对需要HSV做调色板的人来说不够用。上述这些痛点单独看都不大合在一起就形成了充分的“造轮子”理由。造轮子的意义不一定是“比现成的更强”更在于把需求定义清楚然后完全按照自己的习惯去做这才是这个项目最大的价值。1.3 技术栈选择WinForms 就够了这个工具本质上就是一个浮动面板加鼠标追踪界面简单没有动画、没有复杂数据绑定也不打算做跨平台。C#这边最直接的方案就是WinForms。相比WPFWinForms没有XAML没有数据绑定上下文一个Form就能搞定全部界面逻辑开发速度最快编译出来体积也小。我的目标框架选了.NET Framework 4.8。原因很简单WinForms在.NET Framework下最稳定目标机器基本都自带运行环境不需要额外装运行时。如果你想用高版本改成.NET 6以上开启UseWindowsForms也一样代码层面几乎不用动。技术选型这件事上我的经验是做小工具优先选自己最熟、最快出结果的组合。取色器不涉及跨平台需求也不涉及复杂界面动效WinForms这种“老派”方案反而最合适。2. TakeColor的核心原理屏幕像素怎么变成颜色数值2.1 从“截屏”而不是“读像素”入手第一个要解决的问题是如何拿到屏幕上指定点的颜色。很多刚接触的同学会下意识搜索“C#读取屏幕像素”然后找到GetDC加GetPixel的组合。这个方案在Windows下确实能工作原理是获取屏幕的设备上下文然后直接读取物理像素颜色。但它有两个问题一是性能并不算好不适合跟着鼠标移动高频调用二是在某些显卡驱动和远程桌面环境下返回值不稳定。更稳妥的思路是“截屏取点”从屏幕上拷贝一小块区域到内存位图再从位图里取指定坐标的颜色。因为Windows GDI的Graphics.CopyFromScreen就是为屏幕内容拷贝设计的在桌面环境下非常成熟可靠。一个取色器本质上就是三件事截块区域、取个像素、转个格式。后面的所有功能都是这三件事的包装。核心方法很简单public static Bitmap CaptureRegion(Rectangle region) { var bmp new Bitmap(region.Width, region.Height); using (var g Graphics.FromImage(bmp)) { g.CopyFromScreen(region.X, region.Y, 0, 0, region.Size); } return bmp; }然后取像素只需要调用using (var bmp CaptureRegion(new Rectangle(mousePos, new Size(1, 1)))) { Color c bmp.GetPixel(0, 0); }这段代码是取色器的地基。但实际使用时我并没有让每次鼠标移动都创建1x1的位图而是截取鼠标周围一块区域顺便用于放大镜绘制一举两得。这个优化思路我会在第3章详细讲。2.2 坐标换算鼠标位置不等于屏幕坐标有了截屏方法接下来就是坐标问题。Windows里Cursor.Position拿到的是屏幕坐标。这里有个容易踩的坑主屏幕左上角是(0,0)但如果你接了副屏并且副屏摆在主屏左侧副屏上的坐标会是负数。比如副屏分辨率1920x1080那么副屏最左侧的X坐标就是-1920。如果直接把Cursor.Position传给CopyFromScreen遇到副屏就麻烦了。CopyFromScreen的源坐标如果是负数在某些环境下会直接截出异常区域或者抛GDI错误。正确的做法是先把鼠标坐标换算到虚拟桌面坐标系里再截屏。虚拟桌面范围可以通过SystemInformation.VirtualScreen拿到它是一个Rectangle表示所有屏幕拼起来的总区域。鼠标当前位置换算成虚拟桌面内的相对坐标int relativeX Cursor.Position.X - SystemInformation.VirtualScreen.Left; int relativeY Cursor.Position.Y - SystemInformation.VirtualScreen.Top;截屏时仍然用屏幕绝对坐标但取像素时用上面的相对坐标在“全屏截图上的对应位置”去取。多显示器环境下的具体处理我会在第5章踩坑记录里再展开。2.3 RGB、HEX、HSV 的换算逻辑取到Color对象之后剩下的是格式转换。RGB是最直接的Color的R、G、B三个分量直接就是0到255的数值。HEX则是把三个分量转成两位十六进制字符串拼接成#RRGGBB。这个自己拼完全没难度public static string ToHex(Color c) $#{c.R:X2}{c.G:X2}{c.B:X2};HSV稍微绕一点。HSV的价值在于它把颜色拆成了色相Hue0到360度、饱和度Saturation0到100%、明度Value0到100%三个维度。当你需要沿着“更亮一点”“更灰一点”“往橙色偏一点”这样的方向调整颜色时直接在HSV上改远比在RGB上改直觉。这个工具支持HSV主要是为了我在做设计稿微调时可以直接看到一个颜色的明度和饱和度不用再心算。RGB转HSV的标准算法网上很多我封装成了静态方法public static (double H, double S, double V) ToHsv(Color c) { double r c.R / 255.0; double g c.G / 255.0; double b c.B / 255.0; double max Math.Max(r, Math.Max(g, b)); double min Math.Min(r, Math.Min(g, b)); double delta max - min; double h 0; if (delta ! 0) { if (max r) h (g - b) / delta; else if (max g) h 2 (b - r) / delta; else h 4 (r - g) / delta; h * 60; if (h 0) h 360; } double s max 0 ? 0 : delta / max; double v max; return (h, s * 100, v * 100); }格式都齐了界面显示和剪贴板复制就都有了素材。3. 关键代码拆解从空白项目到可用取色器3.1 鼠标位置追踪方案对比取色器必须跟着鼠标走。实现方案主要有两种SetWindowsHookEx的鼠标钩子或者Timer轮询。鼠标钩子方案能拿到全局鼠标移动事件响应很即时但SetWindowsHookEx要求回调方法必须放在独立模块里不能放在托管代码的匿名委托中否则钩子会被GC回收导致程序崩溃。这种复杂度对取色器来说没有必要。Timer轮询方案就简单得多。我开了一个15毫秒间隔的定时器每15毫秒读一次Cursor.Position。15毫秒对应约66Hz的刷新率视觉上已经是流畅的连续取色完全不卡顿也不会有钩子带来的稳定性风险。实际体验下来只要不是刻意甩鼠标颜色变化基本没有延迟感。这个环节我的建议是用具最简单的方式实现稳定的效果不要为了“实时性”引入不必要的复杂性。取色器不是电竞外设15毫秒的轮询已经足够。Timer的核心逻辑private void timerMouse_Tick(object sender, EventArgs e) { Point mousePos Cursor.Position; lblCoords.Text ${mousePos.X}, {mousePos.Y}; RefreshColorAt(mousePos); }3.2 取色核心方法与放大镜绘制RefreshColorAt是取色器的核心方法。刚才我说过不会每次只截1x1像素的位图而是截取鼠标周围一块60x60的区域这个区域既能用来取中心点颜色又能作为放大镜的数据源。private Bitmap _snapshot; private void RefreshColorAt(Point mousePos) { int size 60; var captureRect new Rectangle( mousePos.X - size / 2, mousePos.Y - size / 2, size, size); _snapshot ScreenCapturer.CaptureRegion(captureRect); // 中心点就是鼠标当前指向的像素 Color center _snapshot.GetPixel(size / 2, size / 2); lblColorHex.Text ColorUtils.ToHex(center); lblColorRgb.Text ColorUtils.ToRgb(center); var (h, s, v) ColorUtils.ToHsv(center); lblColorHsv.Text ${h:F1}°, {s:F1}%, {v:F1}%; panelColor.BackColor center; pboxMagnifier.Invalidate(); }放大镜的绘制放在PictureBox的Paint事件里。这里有个关键技术点放大不是用GetPixel把60x60个像素逐个重新画而是直接把整张位图用DrawImage缩放到目标区域。因为DrawImage是GDI内部完成的位图复制加缩放效率远高于遍历一万个像素逐个调GetPixel。private void pboxMagnifier_Paint(object sender, PaintEventArgs e) { if (_snapshot null) return; var destRect new Rectangle(0, 0, pboxMagnifier.Width, pboxMagnifier.Height); e.Graphics.InterpolationMode System.Drawing.Drawing2D.InterpolationMode.NearestNeighbor; e.Graphics.DrawImage(_snapshot, destRect); // 绘制十字准线 int cx pboxMagnifier.Width / 2; int cy pboxMagnifier.Height / 2; using (var pen new Pen(Color.FromArgb(160, 255, 255, 255))) { e.Graphics.DrawLine(pen, cx, 0, cx, pboxMagnifier.Height); e.Graphics.DrawLine(pen, 0, cy, pboxMagnifier.Width, cy); } }InterpolationMode设为NearestNeighbor是关键。如果不设置GDI会默认做平滑插值放大后的像素边缘会变模糊而取色器需要的是硬核像素风每个原始像素都变成一个大方块这样鼠标定位到哪个像素一目了然。放大镜显示的尺寸我固定在240x240也就是把60x60的截图放大了4倍。这个倍数比较合适既能看清像素边界又不至于因为放大倍率太大导致看到的目标太少。3.3 全局快捷键让取色键没有存在感取色器最大的效率提升来自全局快捷键。我设置了CtrlAltC作为“复制当前取色”快捷键。它的实现依赖Win32的RegisterHotKey函数。using System.Runtime.InteropServices; public static class HotKeyManager { public const int WM_HOTKEY 0x0312; const uint MOD_ALT 0x1; const uint MOD_CONTROL 0x2; const uint MOD_SHIFT 0x4; const uint MOD_WIN 0x8; [DllImport(user32.dll, SetLastError true)] static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); [DllImport(user32.dll, SetLastError true)] static extern bool UnregisterHotKey(IntPtr hWnd, int id); public static bool Register(IntPtr hWnd, int id, Keys key, bool ctrl false, bool alt false, bool shift false) { uint mods 0; if (ctrl) mods | MOD_CONTROL; if (alt) mods | MOD_ALT; if (shift) mods | MOD_SHIFT; return RegisterHotKey(hWnd, id, mods, (uint)key); } public static bool Unregister(IntPtr hWnd, int id) UnregisterHotKey(hWnd, id); }注册后的快捷键消息会以WM_HOTKEY的形式发送到窗体消息队列。在窗体里重写WndProc处理它protected override void WndProc(ref Message m) { if (m.Msg HotKeyManager.WM_HOTKEY m.WParam.ToInt32() 1) { CopyCurrentColor(); } base.WndProc(ref m); }CopyCurrentColor做的事情很简单把当前取到颜色的HEX格式写进剪贴板同时更新状态栏给一个微弱的视觉反馈。有的人习惯复制RGB格式我把这个设计成了可切换的选项。4. 那些让工具“像回事”的细节处理4.1 置顶浮动面板与拖动取色器属于“一直在前台”的小工具窗口必须置顶TopMost true否则一操作其他软件就看不见它了。但置顶之后窗口会挡住后面的内容所以窗口本身要尽量窄小并且支持鼠标拖动。WinForms的窗口默认带标题栏拖动没问题但标题栏占了纵向空间而且默认外观偏重。我用了FormBorderStyle.None把标题栏去掉做成紧凑面板然后在窗体上手动实现拖动逻辑private bool _dragging; private Point _dragStart; protected override void OnMouseDown(MouseEventArgs e) { if (e.Button MouseButtons.Left) { _dragging true; _dragStart e.Location; } base.OnMouseDown(e); } protected override void OnMouseMove(MouseEventArgs e) { if (_dragging) { Location Location (e.Location - _dragStart); } base.OnMouseMove(e); } protected override void OnMouseUp(MouseEventArgs e) { _dragging false; base.OnMouseUp(e); }还有一个细节默认情况下鼠标移到窗体上时屏幕上的取色区域依然跟随的是全局Cursor.Position也就是鼠标在窗体内部时取色中心是窗体下面的像素还是窗体控件上的像素我选择后者——Cursor.Position指哪儿就取哪儿包括窗体本身下方的内容。这样用户把鼠标对准目标像素时即使经过窗体也没有影响。但有个副作用当鼠标停留在TakeColor窗体自身控件上取色时颜色会取到窗体的背景色。解决方法是把窗体设计成半透明或者留出取色盲区但实际使用中由于浮动面板很小鼠标大多数时间都在面板外的屏幕上这个副作用可以接受。提示如果你真的需要“透过窗体取色”可以把窗体的Opacity设为0.5或更低或者干脆加一个“吸管模式”按钮点击后窗体暂时隐藏等下一次鼠标移动时重新显示。这是很多商业取色器的标准做法实现也不复杂。4.2 一键复制与格式切换取到颜色之后下一步通常是复制到剪贴板。不同场景需要的格式不一样写CSS用HEX做图像处理有时用RGB分析配色时可能需要HSV。我在界面上放了一个小的格式选择区用Tab键循环切换HEX / RGB / HSV三种格式当前选中格式会在状态栏里显著标识。按CtrlAltC复制时复制的内容就是当前选中的格式。复制操作本身很直接private void CopyCurrentColor() { string text _currentColorText; Clipboard.SetText(text); lblStatus.Text $已复制 {text}; }这里有个新手容易踩的坑Clipboard.SetText在极少数情况下会抛异常比如剪贴板被其他程序锁死。稳健做法是包一层try-catch失败时在状态栏提示“复制失败请重试”。我遇到过几次加了异常处理之后就没有再被这个问题打断过。4.3 历史记录与色板扩展取色器的另一个高频需求是“刚才取的那个颜色是什么来着”。很多人取完色就直接用了但做设计时经常要回头对比几个候选色。我在面板底部加了一个ListBox用于保存最近取色的历史记录每一条都显示色块和对应HEX值。ListBox的默认绘制不擅长显示色块需要设置DrawMode为OwnerDrawFixed然后重写DrawItem。绘制的思路是先填充一个色样小方块再在右边画出文本listBoxHistory.DrawMode DrawMode.OwnerDrawFixed; listBoxHistory.ItemHeight 24; private void listBoxHistory_DrawItem(object sender, DrawItemEventArgs e) { if (e.Index 0) return; e.DrawBackground(); Color color (Color)listBoxHistory.Items[e.Index]; using (var brush new SolidBrush(color)) { e.Graphics.FillRectangle(brush, 2, 2, 24, 20); } e.Graphics.DrawRectangle(Pens.Gray, 2, 2, 24, 20); e.Graphics.DrawString(ColorUtils.ToHex(color), Font, Brushes.Black, 32, 4); }历史记录的长度我限制为30条超出后移除最旧的。这样面板不会无限变长内存占用也稳定。点击历史条目时会把这条颜色重新设置到当前颜色区域方便再次复制。这个简单的历史功能已经接近“调色板”的雏形了。顺着它的思路我还随手做了一排“同色系衍生色”按钮把当前颜色按明度加减10%、20%生成5个相邻色点击就直接复制。做按钮和做设计稿的时候这一排衍生色经常能给我灵感。5. 实测踩坑记录DPI缩放、性能和多显示器5.1 高DPI下取色偏移一个像素第一次编译运行习惯性把窗口调好位置然后吸屏幕上某个图标的颜色。结果取出来的颜色怎么都对不上明明鼠标指着一个蓝色按钮取出来却是旁边的深灰色。排查之后发现问题出在DPI缩放——我的屏幕缩放是150%WinForms程序默认不感知DPI系统会按照96dpi的基准把窗口拉伸但Cursor.Position返回的却是物理像素坐标。两者一错位取色点就偏了。解决方案是在app.manifest里声明DPI感知让程序按物理像素工作application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness /windowsSettings /application这里还要配合窗体设计器的AutoScaleMode设置为Dpi确保在150%缩放下字体和控件大小正常。加了PerMonitorV2之后取色偏移的问题立刻消失。注意如果你用的是.NET Framework 4.8app.manifest本身已经存在只需要在assembly节点里加入上面的内容。如果你用了.NET 6还需要在csproj里确保ApplicationManifest指向该文件。5.2 GetPixel放大镜卡顿的根因第一版放大镜我是这么写的在60x60的截图上双重循环用GetPixel读取每个像素然后逐像素画小方块到PictureBox上。效果倒是对但鼠标移动时明显卡顿CPU占用率直接拉满。问题出在GetPixel本身是GDI的逐像素访问方法每调用一次都要做一次COM互操作级别的颜色提取调用900次就是一个很大的开销。修复方式就是我前面写的方案不要逐个GetPixel像素再画而是直接用DrawImage把整张60x60位图放大到240x240区域。DrawImage是GDI内部对位图数据的整体缩放复制底层用GPU或者高度优化的CPU路径效率比逐像素高两个数量级。改完之后Timer哪怕跑10毫秒间隔也不卡了。这个坑的本质是“把工具当成了API集”而不是去理解API内部的性能模型。GetPixel适合取单点不适合批量绘制。5.3 副屏负坐标导致截取异常接上双屏之后我把窗口移到副屏上测试调试时发现取色区域明显不对显示出来的放大镜图像和鼠标指向的位置对不上。排查后发现副屏在主屏左侧时Cursor.Position的X坐标是负数而我当时直接用这个负坐标构造了CopyFromScreen的源矩形。CopyFromScreen的源坐标如果为负在某些Windows版本上会让GDI抛“参数无效”的异常或者干脆截取失败。修复方法是在截屏前先判断鼠标所在的显示器然后按该显示器的Bounds做一次平移。代码层面最简单的方式是用Screen.FromPoint获取鼠标当前所在屏幕Screen screen Screen.FromPoint(Cursor.Position); Rectangle bounds screen.Bounds;取色区域只需要在当前屏幕的Bounds范围内截取然后把中心对准鼠标位置这样坐标就永远是正的不会再碰负坐标问题。5.4 热键冲突与兜底方案RegisterHotKey不是每次都能成功的。CtrlAltC如果已经被其他软件注册函数会返回false如果我忽略这一点用户就会以为快捷键坏了点按无反应。第一次遇到这个情况时我在调试控制台里看到返回值才发现。处理方式是在注册失败时给出明确提示同时提供一个可替换的默认热键。更完整的做法是做一个简单的热键设置界面让用户自己录入手势。我第一版没做界面直接在配置里留了一个字符串变量改配置文件就能换热键。后来发现每次换热键都要改配置重编译实在麻烦就顺手做了一个下拉框列出几个预置组合键选项注册失败时自动回退到CtrlShiftC。6. 源码结构、跑起来以及还能怎么玩6.1 项目结构总览TakeColor的工程规模不大很适合作为学习WinForms项目结构的参考。源码目录大概是这样的TakeColor/ ├─ TakeColor.sln └─ TakeColor/ ├─ TakeColor.csproj ├─ Program.cs ├─ MainForm.cs ├─ ScreenCapturer.cs ├─ HotKeyManager.cs ├─ ColorUtils.cs └─ app.manifest每个文件职责很单一Program.cs负责应用入口MainForm.cs是主窗体负责UI刷新和交互逻辑ScreenCapturer.cs封装了区域截屏HotKeyManager.cs封装了全局热键注册ColorUtils.cs集中放颜色格式转换。这种按功能划分的做法是我写小工具的习惯好处是想改哪里直接找对应文件不用在一大坨代码里翻。6.2 从零编译运行步骤跑起来很简单全程不需要第三方NuGet包打开Visual Studio新建一个.NET Framework 4.8的WinForms项目。把Program.cs、MainForm.cs等文件复制进项目目录在解决方案里“添加现有项”。在项目属性里把app.manifest设置为应用程序清单文件或者直接替换默认生成的manifest。编译运行。F5后桌面出现浮动面板鼠标移动即可实时取色CtrlAltC复制颜色到剪贴板。我在开发时用的是Release x86配置因为取色器本身不需要64位特性x86在兼容性上反而覆盖面更广。编译产物大概几百KB到1MB出头作为绿色小工具随身携带没有压力。6.3 可以继续扩展的方向TakeColor目前的功能已经能满足我日常取色的全部需求但它的扩展空间还很大。比如可以给取色器加上屏幕尺子功能按住鼠标拖拽就能量出两点间的像素距离这个对前端还原很有用也可以加入颜色命名识别取到颜色后自动输出一个接近的语义化名称比如“浅蓝”“橄榄绿”之类写文档的时候很方便。如果对性能有更高要求还可以改用前台屏幕捕获API直接从显卡缓冲里读取屏幕内容这样连Timer轮询的延迟都能进一步降低做视频取色的时候会更跟手。只是这套方案的代码复杂度会明显上升取色器这个小体量项目暂时用不到。写到这TakeColor的主体功能就齐了。回头看这个工具没有高深算法但整套流程走下来坐标转换、GDI绘图、热键注册、剪贴板交互这些桌面开发的地基都实打实过了一遍。如果你也想从零写一个顺手的小工具我建议就从取色器开始——它足够小又足够完整能让你在半天时间里获得一次完整的“造轮子”体验。