尧图精选

VC++自绘虚拟仪表控件:GDI表盘指针全面实现

🕒 发布时间:2026/9/9 14:45:41 📁 来源:尧图网络
简介一份基于VCMFC框架的虚拟仪表控件工程面向具备C基础、需要在Windows桌面应用中实现动态仪表盘界面的开发者。资源覆盖从CWnd派生控件类、OnPaint自绘、GDI绘制刻度与指针到定时器驱动的指针平滑动画、实时数据绑定及多线程更新的完整实现思路。压缩包共35个文件其中7个H头文件与5个CPP源文件构成主要工程代码另含1个可直接运行的EXE演示程序、1个ICO图标资源以及编译生成的OBJ、PDB等中间文件便于对照工程结构直观理解MFC控件封装与自绘流程。已有210人学习下载。借助该工程读者不仅能在Visual C 6.0环境中重新编译调试而且可以按需修改刻度范围、颜色主题和动画逻辑快速复用到工业监控或车载仪表等项目中是学习MFC自绘控件和GDI图形编程的实用参考。 每个做上位机、工业监控或者车载模拟项目的朋友十有八九都会碰到一个需求界面里需要一块像模像样的仪表盘指针要转刻度要准数值还要能跟着数据实时走。买现成控件吧要么License贵得要命要么样式老旧跟自己的界面风格完全搭不上。自己画吧又总觉得使不上劲。我以前也被这事卡过很久后来干脆用vc手写了一个虚拟仪表控件从表盘、刻度、指针到数值刷新全部自绘做完之后不管是塞进MFC对话框还是Win32窗口几分钟就能集成。这篇文章就把这套控件的设计思路、核心绘图逻辑和踩过的坑完整梳理一遍打算用C做自绘控件的朋友可以直接照着抄作业。这个虚拟仪表控件本质上就是一个自绘窗口它摆脱了对图片素材的依赖所有视觉元素都用GDI的画笔、画刷、字体和几何计算现场画出来。效果上可以实现石英表盘风格的圆环、均匀分布的主刻度和次刻度、带小圆点配重的指针、量程值和单位标注还可以设置报警阈值变色。处理数据的部分交给调用方控件只负责把数值映射成指针角度再按固定频率刷新。适合我们一起在实际项目里复用也适合想搞明白Windows控件绘制原理的C开发者拿来做练手项目。1. 项目背景与整体设计思路1.1 为什么要自己造一个虚拟仪表控件很多团队的常规做法是找一张画好的表盘图片当背景再把指针图片按角度旋转贴上去。这条路初期看起来很省事可真做进项目里会发现几个很烦的问题一是表盘尺寸和界面布局经常对不上背景图一拉伸就糊不拉伸又留白二是量程、单位、刻度密度这些参数一变就得重新找美术切图三是字体、配色要跟随系统的主题风格时纯图片方案完全无能为力。用VC自绘控件正好把这些痛点一次解决。所有元素都通过代码绘制控件尺寸改变时用数学计算重新布局量程上限、指针位置、刻度数量都做成公开接口外部可以随时调整。更关键的是自绘控件的性能可以做到很可控GDI在上面画一个表盘刷新一帧的开销非常小不用考虑Direct2D那种级别的人力成本。当时对比过网上一些开源仪表盘库有的依赖很重、动辄几个静态库有的只支持QtMFC环境根本接不进去。最后决定自己写一个干净、零依赖、双击就能用到现有工程里的控件这就是这个项目的起点。1.2 仪表控件的功能拆解与类的核心设计动手之前先把功能点列清楚避免写着写着把自己绕进去。这个虚拟仪表控件需要支持设定量程上下限比如0到1000转或者0到220公里/小时主刻度和次刻度数量可配置主刻度下面可以显示数值文字当前值的实时设置控件内部完成数值到角度的映射指针的平滑旋转效果数值突变时指针不快不慢地转过去表盘半径、背景色、指针颜色、报警阈值颜色等风格属性可调支持透明背景或者固定背景色方便嵌入不同风格的窗体落实到类设计上我会用一个CMyGauge类继承CWnd核心成员包括最小值m_dMinValue、最大值m_dMaxValue、当前值m_dCurrentValue、主刻度数m_nMajorTicks、次刻度数m_nMinorTicks、表盘起始角度和终止角度m_dStartAngle、m_dEndAngle。绘图句柄封装在CDC里控件尺寸用GetClientRect()实时获取。消息响应就四个OnPaint、OnEraseBkgnd、OnTimer、OnSize。这个结构很干净扩展起来也不会推倒重来。1.3 技术路线选型为什么用GDI而不是GDI或Direct2D我选择纯GDI方案是基于三个现实理由第一GDI是Windows系统最基础也最稳定的绘图接口从Windows XP到Windows 10都能跑不需要额外安装运行库这个控件的分发成本几乎为零第二仪表盘的绘制都是直线、圆弧、填充、文本这些基础图元GDI完全够用不涉及图片混合、半透明层这些GDI或Direct2D的强项第三GDI的代码写起来比较直白工程里其他人接手维护也容易看懂。GDI在字体抗锯齿上确实好看一点但引入它之后初始化、清理、资源管理的代码量会多一截对一个单片机、PLC上位机场景来说没有必要。有人会问GDI绘图会不会卡。实际测试下来一个300x300像素的仪表框加上刻度、字符、指针一次完整重绘大概在1到2毫秒的量级用定时器20毫秒刷一帧完全没压力。核心技巧是双缓冲先把整个画面画进内存DC再一次BitBlt到窗口这样既不会闪烁渲染速度也足够快。下面第三节会给出完整的代码结构。2. 核心技术点绘图、坐标换算与定时刷新2.1 GDI绘制的坐标系与双缓冲机制Windows窗口的默认坐标系是左上角为原点X轴向右Y轴向下。这和我们在数学课上学到的坐标系方向相反所以画表盘的时候如果用标准公式直接套sin、cos指针方向就会别扭甚至镜像。正确的做法是保持X轴计算从左边出发Y轴方向做一次翻转也就是把“竖直向上”的方向对应的Y坐标用cy - r * sin(弧度)来算这样逆时针角度就能正常对应到画面位置上。双缓冲的代码其实很固定每次重绘开始时创建一个兼容内存DC和位图void CMyGauge::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap memBmp; memBmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); DrawGauge(memDC, rcClient); dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); memBmp.DeleteObject(); }注意OnEraseBkgnd必须直接返回TRUE不让系统后台擦除窗口背景。否则每一帧都会先清成灰色再画新内容这就是闪烁的根源。双缓冲配合禁止擦除画面会干净利落。2.2 表盘刻度与指针位置的数学换算表盘绘制最核心的映射关系是“数值到角度”我用的表盘范围是从135度到405度也就是从左下角沿着顺时针扫过270度留出下方约90度不画。换算公式可以这样理解先把当前值减去最小值得到它在量程中的位置比例再用这个比例乘以总角度跨度最后加上起始角度。double CMyGauge::ValueToAngle(double value) const { double span m_dEndAngle - m_dStartAngle; // 270 double ratio (value - m_dMinValue) / (m_dMaxValue - m_dMinValue); double angle m_dStartAngle ratio * span; // 角度制 return angle; }拿到角度之后绘制主刻度线外圆半径是m_nRadiusOuter内圆半径是m_nRadiusInner刻度线的起点和终点分别是这两个半径上的两个点double rad angle * PI / 180.0; CPoint ptInner((int)(cx innerRadius * cos(rad)), (int)(cy - innerRadius * sin(rad))); CPoint ptOuter((int)(cx outerRadius * cos(rad)), (int)(cy - outerRadius * sin(rad))); pDC-MoveTo(ptInner); pDC-LineTo(ptOuter);指针的终点也是同样道理把当前角度算出来半径取表盘半径的0.7倍左右然后从圆心画一条粗线过去再加上一个小圆配重。整个过程就是反过来用cos和sin求点的坐标只要角度转弧度没有算错位置和视觉角度就一定是对的。2.3 定时器刷新与控件响应机制仪表要做成动态的必须有一个定期触发重绘的机制。最常见也最简单的是SetTimer配合WM_TIMER消息void CMyGauge::SetRefreshRate(int ms) { if (m_nTimerID ! 0) { KillTimer(m_nTimerID); m_nTimerID 0; } m_nTimerID SetTimer(1, ms, NULL); m_nRefreshMs ms; } void CMyGauge::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { Invalidate(FALSE); // 不擦背景直接触发 OnPaint } CWnd::OnTimer(nIDEvent); }这里要注意Invalidate(FALSE)的第二个参数传FALSE表示废除了背景擦除配合上面OnEraseBkgnd返回TRUE整个刷新链路不会闪。禁止在定时器里直接调用绘图函数绘图应该只集中在OnPaint里做定时器只负责给窗口发“该刷新了”的信号。否则很容易出现绘图和系统消息乱序、窗口拖动时画面错乱的问题。3. 实操记录在VS2017从零搭建虚拟仪表控件3.1 新建MFC对话框工程建立Windows窗体程序的基本路径很多刚接触VC的读者可能对“Visual Studio 2017中如何用vc建立windows窗体程序”有疑问这里顺便记一下。打开VS2017文件 - 新建 - 项目左侧选择“Visual C” - “MFC”选择“MFC应用程序”填好项目名称。向导里应用程序类型选“基于对话框”其他选项保持默认。这样VS会生成一个完整的对话框框架自带C**App和C**Dlg两个类编译直接就能跑出窗口。对于这个仪表控件我的做法是在对话框窗体上放一个自定义控件容器。在资源编辑器的工具箱中找到“Custom Control”拖到对话框上给它指定一个类名比如MyGauge然后VS就会在程序启动时尝试通过这个类名绑定控件。但我们代码里的类不是从系统标准控件派生出来的所以需要自己封装窗口类的注册和创建流程这一步放在下面讲。3.2 自定义控件类的编写步骤定义这个类的头文件核心结构如下// MyGauge.h class CMyGauge : public CWnd { DECLARE_DYNAMIC(CMyGauge) public: CMyGauge(); virtual ~CMyGauge(); BOOL Create(CWnd* pParentWnd, const CRect rect, UINT nID); void SetRange(double minVal, double maxVal); void SetValue(double val); void SetTicks(int major, int minor); void SetTheme(COLORREF bkColor, COLORREF tickColor, COLORREF needleColor); void SetRefreshRate(int ms); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnTimer(UINT_PTR nIDEvent); DECLARE_MESSAGE_MAP() private: double m_dMinValue, m_dMaxValue, m_dCurrentValue; int m_nMajorTicks, m_nMinorTicks; COLORREF m_clrBk, m_clrTick, m_clrNeedle, m_clrText; int m_nTimerID; double m_dStartAngle, m_dEndAngle; UINT_PTR m_nTimer; void DrawGauge(CDC* pDC, const CRect rcClient); void DrawTicks(CDC* pDC, int cx, int cy, int radius); void DrawNeedle(CDC* pDC, int cx, int cy, int radius); void DrawUnitText(CDC* pDC, const CRect rcClient); };接着在Create函数里完成窗口类的注册和窗口创建。这个步骤不能省因为MFC资源编辑器里的Custom Control只是布置了位置真正要用代码接管绘制还得重新注册一个窗口类BOOL CMyGauge::Create(CWnd* pParentWnd, const CRect rect, UINT nID) { CString strClassName AfxRegisterWndClass( CS_HREDRAW | CS_VREDRAW, ::LoadCursor(NULL, IDC_ARROW), (HBRUSH)::GetStockObject(NULL_BRUSH), NULL); return CWnd::CreateEx( 0, strClassName, _T(MyGauge), WS_CHILD | WS_VISIBLE | WS_TABSTOP, rect, pParentWnd, nID); }构造函数里把关键的成员变量初始化好量程默认0到100当前值0起始角135终止角405颜色默认黑底白刻度红色指针刷新频率默认20ms。把这些都集中在一个InitMembers()函数里以后新增配置项也方便统一管理。3.3 具体绘制流程与关键代码逐段说明DrawGauge是整个控件的核心它按照固定顺序把表盘画出来顺序很重要。第一步画实心背景圆第二步画外圆环边框第三步画刻度线和刻度值第四步画单位文字最后画指针。背景圆这一步如果控件底色是纯黑底直接用一个实心黑色画刷FloodFill或者Ellipse填充就行。如果想让表盘看起来更有质感可以先画一个大点的灰色圆再画一个次大的黑色圆形成一圈金属质感的“外圈”。示例代码void CMyGauge::DrawGauge(CDC* pDC, const CRect rcClient) { int cx rcClient.CenterPoint().x; int cy rcClient.CenterPoint().y; int radius min(rcClient.Width(), rcClient.Height()) / 2 - 4; pDC-SetBkMode(TRANSPARENT); // 外圈 CBrush brOuter(RGB(90, 90, 90)); CBrush* pOldBr pDC-SelectObject(brOuter); pDC-Ellipse(cx - radius, cy - radius, cx radius, cy radius); // 内背景 CBrush brInner(m_clrBk); pDC-SelectObject(brInner); pDC-Ellipse(cx - radius 6, cy - radius 6, cx radius - 6, cy radius - 6); pDC-SelectObject(pOldBr); // 刻度 DrawTicks(pDC, cx, cy, radius - 10); // 指针 DrawNeedle(pDC, cx, cy, radius - 20); // 数值文字和单位 DrawUnitText(pDC, rcClient); }画刻度时要分成主刻度和次刻度两轮循环。主刻度在每个大格上画粗一点的线并在外沿写上数字。次刻度则是两个主刻度之间平均分出的小线。循环变量从0到m_nMajorTicks每个主刻度对应的角度值用ValueToAngle反算这样才能保证数字和刻度位置一一对应。代码如下void CMyGauge::DrawTicks(CDC* pDC, int cx, int cy, int radius) { CPen penMinor(PS_SOLID, 1, m_clrTick); CPen penMajor(PS_SOLID, 3, m_clrTick); CPen* pOldPen pDC-SelectObject(penMinor); for (int i 0; i m_nMajorTicks * m_nMinorTicks; i) { double value m_dMinValue (m_dMaxValue - m_dMinValue) * i / (m_nMajorTicks * m_nMinorTicks); double angle ValueToAngle(value); double rad angle * 3.14159265358979 / 180.0; int lineLen (i % m_nMinorTicks 0) ? 12 : 5; int innerR radius - lineLen; int outerR radius; CPoint pt1((int)(cx innerR * cos(rad)), (int)(cy - innerR * sin(rad))); CPoint pt2((int)(cx outerR * cos(rad)), (int)(cy - outerR * sin(rad))); if (i % m_nMinorTicks 0) { pDC-SelectObject(penMajor); } else { pDC-SelectObject(penMinor); } pDC-MoveTo(pt1); pDC-LineTo(pt2); } // 主刻度数值 CFont font; font.CreatePointFont(80, _T(Arial)); CFont* pOldFont pDC-SelectObject(font); for (int i 0; i m_nMajorTicks; i) { double value m_dMinValue (m_dMaxValue - m_dMinValue) * i / m_nMajorTicks; double angle ValueToAngle(value); double rad angle * 3.14159265358979 / 180.0; int textR radius - 26; CPoint ptText((int)(cx textR * cos(rad)), (int)(cy - textR * sin(rad))); CString strValue; strValue.Format(_T(%.0f), value); CSize szText pDC-GetTextExtent(strValue); pDC-TextOut(ptText.x - szText.cx / 2, ptText.y - szText.cy / 2, strValue); } pDC-SelectObject(pOldFont); pDC-SelectObject(pOldPen); }画指针的算法前面提过从圆心画一条到目标点的粗线再加上一个小圆形轴心。为了让视觉更真实我会在指针尾部也画一段反向线做成类似飞机仪表指针的配重效果void CMyGauge::DrawNeedle(CDC* pDC, int cx, int cy, int radius) { double angle ValueToAngle(m_dCurrentValue); double rad angle * 3.14159265358979 / 180.0; int endX (int)(cx radius * 0.75 * cos(rad)); int endY (int)(cy - radius * 0.75 * sin(rad)); int tailX (int)(cx - radius * 0.15 * cos(rad)); int tailY (int)(cy radius * 0.15 * sin(rad)); CPen penNeedle(PS_SOLID, 4, m_clrNeedle); CPen* pOldPen pDC-SelectObject(penNeedle); pDC-MoveTo(cx, cy); pDC-LineTo(endX, endY); pDC-MoveTo(cx, cy); pDC-LineTo(tailX, tailY); pDC-SelectObject(pOldPen); CBrush brCenter(RGB(255, 255, 255)); CBrush* pOldBr pDC-SelectObject(brCenter); pDC-Ellipse(cx - 6, cy - 6, cx 6, cy 6); pDC-SelectObject(pOldBr); }文本部分可以在表盘中下方位置显示一个偏大的数字下面跟上单位用SetTextColor设置颜色。这里要和刻度数字区分开推荐用CreatePointFont(160, _T(Arial))这种明显大一号的字体。打印的时候用TextOut居中显示。3.4 对外接口与交互逻辑对外接口的设计直接影响控件好不好用。我常用的调用方式是在对话框初始化的时候创建控件然后按照业务需求配置量程、颜色和刷新率之后在数据回调里不断SetValue。示例代码BOOL CMyGaugeDlg::OnInitDialog() { CDialogEx::OnInitDialog(); CRect rcGauge(20, 20, 320, 320); m_gauge.Create(this, rcGauge, 1001); m_gauge.SetRange(0, 1000); m_gauge.SetTicks(10, 5); m_gauge.SetTheme(RGB(20, 20, 20), RGB(255, 255, 255), RGB(255, 77, 77)); m_gauge.SetRefreshRate(30); SetTimer(100, 100, NULL); // 模拟数据源 return TRUE; } void CMyGaugeDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 100) { static double value 0; value 3.7; if (value 1000) value 0; m_gauge.SetValue(value); } CDialogEx::OnTimer(nIDEvent); }这样外部只管数据控件内部自动完成角度映射、定时刷新和重绘耦合度很低。如果想要更顺滑的指针动画效果可以在SetValue里不要直接赋值而是引入一个“目标值”和“当前值”每次OnTimer里让当前值向目标值逼近步长取目标值和当前值差值的十分之一。这样指针就不会因为数值跳变而瞬间甩过去观感提升非常明显。4. 常见问题与排查技巧实录4.1 闪屏和指针残影闪屏是这个控件最容易被吐槽的地方。现象表现为窗口缩放、数值刷新时表盘整个白色闪烁或者指针转动时留下一条条“尾巴”。原因几乎都指向两个点OnEraseBkgnd没有返回TRUE或者Invalidate时用了默认参数导致背景被擦除。排查顺序也很简单。第一步确认OnEraseBkgnd的返回值。第二步给控件加上双缓冲也就是第三节中的memDC方案。第三步在每次重绘第一笔之前先用背景色把整个客户区填一遍防止上一帧残留的内容透过边缘漏出来。这三步全做到之后画面在任何电脑上都不会闪。如果是嵌入大窗体的控件拖拽主窗体时如果控件区域出现残影除了双缓冲之外还要留意控件创建时有没有设置CS_HREDRAW | CS_VREDRAW没有这两个样式的话窗口尺寸变化时系统不会主动重绘画面会花掉。4.2 控件在别人机器上打不开或显示为空白自绘控件如果编译成Release版本发给其他人最常遇到的问题就是运行时库缺失。这个和VC runtime repair tool经常被搜到是同一个背景。用MFC开发的程序目标机器上除了VC运行库还需要MFC动态库这是两个不同的组件。解决办法有两个方向项目属性 - C/C - 代码生成 - 运行库改成“多线程(/MT)”进行静态链接或者项目属性 - MFC的使用改成“在静态库中使用MFC”。这样发布的就是一个不依赖系统运行库的exe拷到干净机器上也能跑。另一个常见问题是对话框上放了一个Custom Control运行时那个区域是空白。这是因为自定义控件类名没有正确注册。检查一下Create函数中AfxRegisterWndClass返回的类名和资源编辑器里Custom Control属性的类名是否一致不一致自然创建不了。如果完全自己从代码创建控件就不存在这个资源绑定问题所以排错时优先怀疑资源的类名配置。4.3 坐标计算与缩放失真问题控件尺寸变化后表盘被拉伸成椭圆、刻度位置错乱是非常典型的坐标换算错误。比如刻度坐标没有用当前客户区的宽高动态计算而是写死了之前的美工像素值。我的做法是绘制函数传入的是一个CRect每次在内部用rect.Width()和rect.Height()计算圆心和半径取宽和高中较小的那一个除以2作为半径这样不管控件被拉成什么样的矩形表盘永远保持正圆四周留白自动处理。还要检查一个隐蔽问题CreatePointFont创建的字体大小和DPI缩放有关。在装有高DPI显示的机器上如果程序没有声明DPI Aware系统会把窗口逻辑尺寸放大字体和刻度位置算出来的坐标会错位。项目清单文件里加入dpiAwaretrue/dpiAware或者调用SetProcessDPIAware就能避免大部分模糊和错位问题。4.4 定时器精度与UI线程卡顿问题WM_TIMER的消息优先级低于鼠标键盘输入如果主线程正在执行比较重的计算定时器消息会被延后指针刷新看起来就会一顿一顿的。解决办法是把数据计算放到工作线程UI线程只负责SetValue和重绘。前面提到指针平滑逼近目标值的方法在数据突然变化时也能让画面保持流畅。对于要求毫秒级精确的场合建议用timeSetEvent多媒体定时器产生一个高频刷新信号把最新值压到一个临界区保护的变量里UI线程的定时器再去读这样既精准又不卡界面。还有一个很值得注意的细节SetTimer的第二个参数单位是毫秒但Windows内部定时器的最小分辨率大约是15.6毫秒你设置10毫秒和设置1毫秒实际效果差别不大。如果真的需要高刷新率别只调定时器参数要从刷新机制本身想办法比如考虑在主循环里用GetTickCount64做帧率控制。结尾这个虚拟仪表控件从最初的粗糙版本到现在前前后后改了好几轮。最深刻的体会是自绘控件遇到的大部分问题其实都不是绘图本身的问题而是和系统消息机制、资源管理、坐标换算这些基础模块纠缠在一起。只要把双缓冲、坐标翻转、窗口类注册这几件事想透彻剩下的就是按部就班地画线条、写字、转指针。这个控件目前在我的项目里稳定跑了很长时间后续我还打算往里面补一个历史曲线模式以及鼠标拖拽指针交互的功能。如果你也在用vc做类似的上位机界面完全可以拿这套代码当底子把表盘颜色、刻度风格、甚至表盘形状改成自己需要的样子改起来比调第三方控件顺手得多。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →