1. 需求场景与方案取舍为什么非要用 GDI 不可1.1 哪个项目的需求逼我上了自绘这条路先说背景。我之前做一个工业设备的管理软件界面左侧是一整排设备状态卡片每个卡片上都有三个信号灯式的状态指示运行、告警、停机。需求刚出来的时候我第一反应是直接放控件——某某自带的状态灯控件、或者网上找一套红绿灯图片切换图片就行。但做到一半问题来了。设备状态不是只有三种而是有“未初始化”“运行中”“待机”“告警”“故障停机”“远程锁定”六种状态而且每种状态的颜色、闪烁方式、是否显示文字说明都不一样。图片方案要么需要准备几十张素材要么运行时切换图片会非常生硬。至于控件方案多数第三方控件要么不支持动态改色、要么在缩放窗口时糊成一团。这时候我决定彻底换思路不依赖任何现成素材直接用 VC 里的 GDI 在窗口上逐像素画出来。这个方案的好处是信号灯的颜色、高光、边框、文字全部由代码控制改一个参数整组灯跟着变。代码写完之后后续维护只需要调函数参数不需要再去 Photoshop 里改素材图。1.2 GDI 和普通 GDI 自绘相比赢在哪有些老派程序员可能会问自绘为什么不上 GDI诚然GDI 画矩形、画圆、填充纯色块也完全没问题但 GDI 有两个天然痛点一是没有抗锯齿画出来的圆形边缘在深色背景上锯齿感特别明显二是渐变填充、透明度混合、路径柔边这些高级效果做起来非常痛苦得自己写大段的像素操作。GDI 在这两点上是降维打击。它内置了抗锯齿模式SmoothingModeAntiAlias、线性渐变画刷LinearGradientBrush、透明度混合Alpha 混合、还有StringFormat 文本绘制。最直观的一个体验画一个带渐变高光的圆GDI 需要手动计算一圈一圈的环形渐变GDI 只需要定义一个渐变画刷填充一个图形路径出来的效果就是平滑自然的立体验。我把三种方案拉了一张对比表方便你判断对比项普通 GDI 自绘GDI 自绘图片资源切换抗锯齿无锯齿明显内置平滑取决于素材质量动态改色需要手动混色传参即可需要整套素材透明通道需 AlphaBlend 处理原生支持看图片格式缩放适配容易变形矢量绘制可自适应需多套分辨率开发成本周期长复杂效果难适中代码量可控依赖美术资源提示如果你的目标平台还停留在 Windows XP 且没有安装过系统更新GDI 需要额外分发动态库不过现在主流的嵌入式工控上位机基本都是 Win7 起步系统原生自带不需要额外部署。还有一个容易被忽略的成本问题图片方案短平快但等到需求变成“告警状态下灯光要由黄变红然后以 1 秒周期闪烁”时你手上那几十张静态图就完全不够用了。而 GDI 方案下这就只是接口里多一个状态枚举、绘制代码里多一个颜色插值函数的事。2. 动手前必须想清楚的三个问题状态模型、坐标系与防闪烁策略2.1 状态模型别只盯着“红黄绿”三个灯很多人一听到信号灯脑子里就是红黄绿三色做出来的类也就三个枚举值。但真实项目中信号灯只是视觉载体背后往往是复杂的业务状态。我这次的项目最终定义了六种状态ST_OFF未上电/未初始化灰色半透明ST_RUNNING运行中绿色常亮ST_STANDBY待机黄色常亮ST_WARNING参数越限告警黄色闪烁ST_ALARM严重故障红色闪烁ST_LOCKED远程锁定红色常亮但亮度压低避免与告警混淆这里有个设计心得状态的值域一定要比现在需要的大。宁可先预留几个空状态枚举位也不要等上线后才发现还要加一种“维护中”状态然后到处改 switch-case。我一开始只定义了三个状态后来需求方加了一个“远程锁定”我不得不把所有关联的状态判断点翻出来逐个排查非常被动。除此之外我建议把“灯色”和“灯效”拆成两个维度而不是混在一个枚举里。比如黄色闪烁可以表达待机也可以表达参数越限红色常亮和红色闪烁的含义也完全不同。将颜色与闪烁方式解耦后绘制代码逻辑会简单得多颜色决定画什么效果决定怎么画。2.2 绘制坐标系所有元素都用 0~1 相对坐标布局第二件要想清楚的事是坐标系设计。傻瓜做法是直接用控件当前的像素宽高去计算圆形的圆心坐标和半径比如在 31×31 的控件里画一个半径 12 的圆。这样做的直接后果是控件一旦被调整尺寸所有元素要么溢出边界要么间距失衡。用相对坐标就稳得多。我设定了一个 0~1 的虚拟坐标系之后所有的圆盘、高光、文字框位置都以这个坐标系里的归一化值表示真正绘制时再乘以控件实际宽高。这个习惯救过我很多次——客户要求在 4K 分辨率屏幕上放大界面窗口整体从 800×600 变成 1920×1080信号灯控件被拉伸了三倍但因为绘制代码跑在相对坐标系下最终画面依旧比例协调没有出现大圆套小圈这类畸形比例。相对坐标还有一个额外的好处高分辨率屏幕下依然清晰。GDI 是矢量绘制圆边是否平滑取决于抗锯齿级别而不是像素密度。只要你在缩放时调用 Control 的尺寸变化事件让它重绘画面就会在任意尺寸下保持同样比例。2.3 双缓冲与透明刷新让画面不闪不黑的关键UI 自绘最大的坑之一就是闪烁。闪烁的根源是系统先擦除背景WM_ERASEBKGND 触发背景被刷成默认色然后你的绘制代码才逐像素画出内容。这个“先擦后画”的过程在屏幕上快速反复执行时人眼就会看到闪烁。GDI 环境下最稳妥的做法是双缓冲先创建一块内存中的位图把所有元素画在位图上最后一次性 BitBlt 或 DrawImage 到屏幕上。但 GDI 的双缓冲有一个细节很多人用的是 CreateCompatibleDC 和 CreateCompatibleBitmap 这两件套在 GDI 里画之前又 GetDC 又 SelectObject最后发现绘制的坐标全错了、颜色也不对。我实际用下来最省心的是直接在 GDI 里创建 Bitmap 作为缓冲画布Bitmap bmp(drawWidth, drawHeight, PixelFormat32bppARGB); Graphics gBuf(bmp); gBuf.SetSmoothingMode(SmoothingModeAntiAlias); // 所有绘制操作都丢给 gBuf Graphics gTarget(hdc); gTarget.DrawImage(bmp, 0, 0, drawWidth, drawHeight);这个方案的代码量更少而且 32 位 ARGB 位图天然支持透明通道可以保留圆角之外的透明区域。另外一定要在处理 WM_ERASEBKGND 时直接返回 TRUE彻底关掉系统擦背景动作否则双缓冲也会白做——系统擦背景的那一下闪烁依然存在。注意绘制结束后记得将所有 GDI 对象 Dispose尤其是画笔Pen、画刷Brush、渐变对象否则长时间运行后句柄泄漏界面会越用越卡。GDI 的托管感知不强这一点全靠自己自觉。3. 实操落地信号灯控件从无到有的编码过程3.1 三步初始化 GDI忘了这步画面直接黑屏GDI 不是系统默认启用的使用前必须先调用 GdiplusStartup 完成初始化。这个步骤经常被新手漏掉漏掉后的表现非常统一窗口能创建、消息能响应但绘制区域一片漆黑或者什么也不显示调试时又不会报错非常气人。初始化顺序分三步第一步包含头文件并链接库第二步定义 GdiplusStartupInput 结构体第三步调用 GdiplusStartup 获取令牌。这个令牌ULONG_PTR 类型必须在整个 GDI 生命周期内保存最简单的做法是做成单例的 RAII 类构造时初始化、析构时关闭。#include windows.h #include gdiplus.h #pragma comment(lib, gdiplus.lib) class GdiPlusModule { private: ULONG_PTR m_gdiplusToken; public: GdiPlusModule() : m_gdiplusToken(0) { Gdiplus::GdiplusStartupInput input; input.GdiplusVersion 1; input.DebugEventCallback NULL; input.SuppressBackgroundThread FALSE; input.SuppressExternalCodecs FALSE; Gdiplus::GdiplusStartup(m_gdiplusToken, input, NULL); } ~GdiPlusModule() { if (m_gdiplusToken) { Gdiplus::GdiplusShutdown(m_gdiplusToken); } } };在我实际开发中遇到过因为初始化顺序错误导致画面错乱的情况——控件窗口先创建了主程序里 GdiplusStartup 才调用结果绘制时部分渐变效果不稳定。所以建议在程序入口比如 CWinApp::InitInstance里最先做这件事确保任何窗口创建之前 GDI 环境已经就绪。3.2 控件基类与注册一个 CWnd 足够信号灯控件不承载交互逻辑不需要高大上的 MFC 控件基类一个 CWnd 子类就够了。关键点在 PreCreateWindow 里注册窗口类时要给它一个独立的类名并指定合适的样式否则会出现刷新区域异常、背景怪色等问题。class CSignalLight : public CWnd { public: enum SignalState { ST_OFF 0, ST_RUNNING, ST_STANDBY, ST_WARNING, ST_ALARM, ST_LOCKED }; BOOL Create(CWnd* parent, const CRect rc, UINT id) { static CString className; if (className.IsEmpty()) { className _T(MY_SIGNAL_LIGHT_CLASS); WNDCLASS wc {0}; wc.style CS_HREDRAW | CS_VREDRAW; // 尺寸变化自动重绘 wc.lpfnWndProc ::DefWindowProc; wc.hInstance AfxGetInstanceHandle(); wc.lpszClassName className; AfxRegisterClass(wc); } return CWnd::Create(className, _T(), WS_CHILD | WS_VISIBLE, rc, parent, id); } virtual void OnPaint() { CPaintDC dc(this); OnDrawSignalLight(dc); } virtual BOOL OnEraseBkgnd(CDC*) { return TRUE; // 禁用背景擦除配合双缓冲 } protected: SignalState m_state ST_OFF; void OnDrawSignalLight(CDC dc); };3.3 核心绘制函数拆解底色、高光、灯体、文字整个控件绘制流程拆开看是四个图层外圈底座阴影与凸缘、灯体色彩主区域、高光模拟玻璃球体折射、状态文字。四个图层按顺序一层层叠上去画面就有立体感了。先看最核心的上色逻辑。我为了让灯体四层颜色之间的过渡更自然做了一个简单的颜色插值函数用的是 ARGB 通道分离的方式COLORREF InterpolateColor(COLORREF c1, COLORREF c2, double t) { int a1 (c1 24) 0xFF, r1 (c1 16) 0xFF; int g1 (c1 8) 0xFF, b1 c1 0xFF; int a2 (c2 24) 0xFF, r2 (c2 16) 0xFF; int g2 (c2 8) 0xFF, b2 c2 0xFF; return RGB( (int)(r1 (r2 - r1) * t), (int)(g1 (g2 - g1) * t), (int)(b1 (b2 - b1) * t) ) | ((int)(a1 (a2 - a1) * t) 24); }上色时的核心代码我贴一段关键部分注意里面每个元素都使用归一化坐标void CSignalLight::OnDrawSignalLight(CDC dc) { CRect rc; GetClientRect(rc); int w rc.Width(), h rc.Height(); // 1. 用双缓冲位图作为画布 Bitmap bmp(w, h, PixelFormat32bppARGB); Graphics g(bmp); g.SetSmoothingMode(SmoothingModeAntiAlias); g.SetPixelOffsetMode(PixelOffsetModeHalf); // 2. 计算圆盘核心参数归一化坐标 float cx w / 2.0f; float cy h / 2.0f; float baseRadius (float)(min(w, h) * 0.38f); // 外盘半径 float lampRadius baseRadius * 0.78f; // 灯体半径 // 3. 外圈底座深灰色圆环 内阴影 SolidBrush darkBrush(Color(255, 60, 60, 60)); g.FillEllipse(darkBrush, cx - baseRadius, cy - baseRadius, baseRadius * 2, baseRadius * 2); // 4. 灯体主体根据状态取色 COLORREF lampColor GetStateColor(m_state); Color baseColor( GetAValue(lampColor) , GetRValue(lampColor), GetGValue(lampColor), GetBValue(lampColor) ); SolidBrush lampBrush(baseColor); g.FillEllipse(lampBrush, cx - lampRadius, cy - lampRadius, lampRadius * 2, lampRadius * 2); // 5. 高光右上角的白色渐变椭圆模拟玻璃反光 GraphicsPath hlPath; float hlW lampRadius * 1.0f, hlH lampRadius * 0.6f; float hlX cx - lampRadius * 0.55f; float hlY cy - lampRadius * 0.55f; hlPath.AddEllipse(hlX, hlY, hlW, hlH); PathGradientBrush hlBrush(hlPath); hlBrush.SetCenterColor(Color(180, 255, 255, 255)); hlBrush.SetSurroundColors(Color(0, 255, 255, 255)); g.FillPath(hlBrush, hlPath); // 6. 状态文字如需显示 // ... // 7. 一次性绘制到窗口 Graphics gScreen(dc.GetSafeHdc()); gScreen.DrawImage(bmp, 0, 0, w, h); }关于高光补充一个经验很多人画高光喜欢直接用实心白色椭圆往上面一盖结果画面像贴了一张白纸非常假。真正模拟玻璃球体的做法是用 PathGradientBrush让高光中心的颜色从半透明白Alpha 约 180向边缘渐变到完全透明Alpha 0只有一个方向性柔边这才能营造出“光从右上方打到球体表面”的立体感。3.4 坐标换算与高光比例的计算方法有人会问上面这些比例数字0.38、0.78、0.55是怎么来的其实是我在各种尺寸下反复试出来的经验值你可以直接抄底座半径占控件最短边的 38%这样四周留白足够不会贴边灯体半径占底座的 78%留出外侧一条 22% 宽度的深色环模拟灯壳厚度高光椭圆宽度约为灯体直径的 70% 多一点高度约为直径的 42%中心落在灯体右上方的黄金位置如果你追求更精细的自适应效果可以把这些比例值提取成成员变量然后在 SetSize 或 OnSize 里统一计算。我用过一段时间后觉得静态比例就已足够动态比例反而容易在不同屏幕尺寸下出现形变。有一件小事千万别忽略GDI 的 Graphics 默认有 1 像素的坐标偏移绘制 1 像素宽的线条时如果坐标是整数线条会渲染在相邻两个像素之间看起来发虚。解决办法是设置 PixelOffsetModeHalf或者给坐标多加 0.5f。这是个非常典型的“看起来没毛病但总像蒙了层雾”的坑。4. 状态切换与动画插值让信号灯学会“呼吸”4.1 定时器驱动的颜色插值状态切换如果只是瞬间变颜色会显得非常廉价。我想要的效果是旧颜色平滑过渡到新颜色这个过程大约持续 200~300 毫秒。实现方式是引入一个 30ms 定时器每次触发时计算当前颜色到目标颜色的插值比例重绘一次。void CSignalLight::AnimateTo(COLORREF targetColor) { m_targetColor targetColor; m_animStartTime GetTickCount64(); m_animDuration 240; // 毫秒 SetTimer(ANIM_TIMER_ID, 30, NULL); } void CSignalLight::OnTimer(UINT_PTR id) { if (id ! ANIM_TIMER_ID) return; DWORD64 now GetTickCount64(); double t (double)(now - m_animStartTime) / m_animDuration; if (t 1.0) { t 1.0; KillTimer(ANIM_TIMER_ID); } COLORREF curColor InterpolateColor(m_currentColor, m_targetColor, t); m_currentColor curColor; Invalidate(FALSE); }插值用的比例 t 如果直接是线性值动效会有一点生硬头尾都不带缓动。我建议加一个缓动函数比如先快后慢的平滑过渡double SmoothStep(double t) { return t * t * (3 - 2 * t); }把上面的 t 替换成 SmoothStep(t) 之后颜色切换在视觉上会自然很多。类似的技巧也可以用在闪烁渐变上——闪烁过程中透明度的建立和消退都采用这种缓动曲线灯光的呼吸感一下就出来了。4.2 闪烁与常亮组合的几种实战用法几个状态映射里闪烁其实有讲究。简单做法是每 500ms 把灯色在“目标色”和“暗色”之间来回切换但这种生硬的两态切换在视觉上很突兀。我采用的是正弦波形的亮度变化。用一个累计相位让 Alpha 值按正弦函数周期性起伏闪烁不再是跳变而是一明一暗的自然呼吸void CSignalLight::UpdateFlashingState() { if (!m_isFlashing) return; double phase (double)(GetTickCount64() % 1000) / 1000.0; // 0~1 double brightness 0.5 0.5 * sin(phase * 2 * 3.1415926); // 用 brightness 调整灯光 Alpha/明暗程度 Invalidate(FALSE); }在工控界面里告警灯如果只是“红色常亮”人的注意力很快就会习惯化反而捕捉不到变化。闪烁频率 1~2Hz 是眼动敏感性比较高的区间太慢显得呆滞太快给人压迫感。我实测下来闪烁周期设定在 800~1000ms 比较舒服。4.3 对外接口设计SetState 之后内部发生了什么接口设计上外界只需要调用一个 SetState所有内部逻辑自动完成。好处是业务代码不用关心绘制细节将来替换成别的视觉方案也只改这个类内部。void CSignalLight::SetState(SignalState state) { if (m_state state) return; m_state state; // 根据状态确定颜色与闪烁需求 COLORREF target; bool flash false; switch (state) { case ST_OFF: target RGB(120, 120, 120); flash false; break; case ST_RUNNING: target RGB(0, 200, 80); flash false; break; case ST_STANDBY: target RGB(255, 200, 0); flash false; break; case ST_WARNING: target RGB(255, 160, 0); flash true; break; case ST_ALARM: target RGB(230, 30, 30); flash true; break; case ST_LOCKED: target RGB(160, 20, 20); flash false; break; } m_isFlashing flash; AnimateTo(target); if (m_isFlashing) { SetTimer(FLASH_TIMER_ID, 100, NULL); // 10Hz 刷新闪烁相位 } else { KillTimer(FLASH_TIMER_ID); } }这段代码在 OnTimer 里同时处理动画和闪烁相位但在实践中要注意当动画和闪烁同时发生时比如从绿色运行态切到红色告警态先让颜色插值动画跑完再启动闪烁节奏否则闪烁会和颜色渐变打架画面看起来像抽搐。控制方法很简单AnimateTo 结束后再设置闪烁标志。5. 实战中踩过的坑GDI 绘图的常见问题速查5.1 初始化与生命周期相关只调了 GdiplusStartup 忘了匹配 Shutdown。桌面软件可能看不出来但你要是把这个控件塞进一个频繁创建销毁的窗口里每次创建都初始化、销毁时却不关闭句柄和内存会持续累积程序跑七八个小时后界面越来越卡最后直接黑屏。初始化顺序问题。GDI 环境要在第一个 GDI 控件创建前就绪。有人把 GdiplusStartup 放在某个模块的静态构造函数里结果模块加载顺序和控件创建顺序冲突绘制函数进入时 GDI 还没准备好导致绘制静默失败。建议初始化放在主程序最早执行的位置。多线程绘制。GDI 的 Graphics 对象基本上只能由创建它的线程使用跨线程释放或者绘制会崩溃或者产生随机花屏。如果你自己做了一个后台工作线程来刷新信号灯切记最终 Invalidate 时 PostMessage 到 UI 线程不要在后台线程里直接创建 Graphics 丢到窗口。5.2 绘制性能与质量问题现象原因解决方式圆边有锯齿未开启抗锯齿Graphics::SetSmoothingMode(AntiAlias)线条发虚坐标落点在像素间隙SetPixelOffsetMode(Half) 或坐标 0.5f颜色带明显灰边Alpha 通道未参与插值插值函数里同时处理 A 通道高光过曝刺眼高光 Alpha 过高中心 Alpha 控制在 160~190整体发灰32 位位图背景未清透明先 Graphics::Clear 成完全透明5.3 消息处理与窗口行为WM_ERASEBKGND 的处理。这一步不能漏否则即使做了双缓冲切换状态时仍能看到瞬间的白色闪动。在 MFC 里面就是重写 OnEraseBkgnd 且返回 TRUE。控件尺寸变为 0 或负数。客户如果把窗口缩到极小或者布局代码设置了一个空的矩形绘制函数里计算坐标会得到负数半径FillEllipse 的行为就会异常。稳妥做法是绘制函数入口判断宽高是否小于某个阈值比如 8 像素直接跳过绘制。DLL 版本冲突。如果软件里还引用了其他依赖 GDI 的第三方库可能因为系统 GDI 版本差异产生兼容问题。我遇到过一次装在某台老机器上信号灯控件偶尔画出来是空心的排查许久发现是该机器 GDI 版本过旧对 PathGradientBrush 的 SurrooundColors 支持不完整。解决方式是升级系统补丁或者在高版本不可用时退化为两段式渐变。5.4 一个容易忽略的透明窗口环境如果你的信号灯不是放在普通对话框上而是叠加在透明或分层窗口WS_EX_LAYERED上直接 GDI 绘制会出现黑色底块。这是因为分层窗口更新需要指定透明色键或者使用 UpdateLayeredWindow。我试过在分层窗口里放自绘控件最后是改成用 UpdateLayeredWindow 整幅更新整个窗口内容而不是让控件单独自绘才把黑色底块问题解决。如果业务场景不是特别需要分层窗口尽量别往这个方向走。6. 写在最后一点私货与扩展建议代码能跑是一回事跑得稳是另一回事。这里想分享我后来在这个控件上面做的一些扩展也算给你留几条后续方向。第一个扩展是“点击反馈”。我把信号灯控件处理了 WM_LBUTTONDOWN按下时高光位置瞬间改变、亮度短暂降低松开恢复用来模拟物理按钮的按压感。这在“手动确认告警”这类交互里特别有用用户会感觉自己在操作一个真实存在的灯而不是一张图片。第二个扩展是“状态文字气泡”。只画灯不加文字长时间盯屏容易混淆但直接在灯旁边画文字又会破坏灯的圆润感。我后来实现了一个悬浮文本层鼠标悬停 500ms 后浮出一个小气泡显示状态名称和最近一次切换的时间。这个功能做起来不难但用户反馈特别好。第三个扩展是“亮度曲线预置”。对闪烁的亮度变化曲线做成了可配置项——正弦、三角波、矩形波、随机跳变。告警时随机跳变比正弦更刺眼待机时三角波比正弦更有节奏感。一条曲线函数不到十行代码换来的是各种业务场景下都能调到合适的警示强度。最后再说一句真心话GDI 自绘确实比贴图方案初期多花点时间但一旦把绘制核心写完后续任何需求变动都是在已有框架上做加法而且整个过程完全不依赖美术资源。这种“代码即素材”的开发方式在需求频繁变化的中小型项目里是最稳妥、后遗症最少的路线。如果你也在做类似的设备监控界面建议从一个小控件开始试跑通完整链路之后就再也回不去贴图方案了。