
简介这是一份面向C#开发者与工控组态界面设计人员的仪表控件示例资源围绕圆表、直表测量标尺、旋钮、开关和指示灯等常见仿真控件展开帮助读者在.NET平台上快速搭建具有真实感的设备操作界面。压缩包共18个文件约1.15MB包含6个cs源码文件、2个dll互操作库、2个resx资源文件、1个ocx控件、1个csproj工程文件、1个sln解决方案、1个exe示例程序及说明文档与预览图覆盖从控件引用、窗体设计到运行演示的完整链路。其中ActiveX互操作库与ocx控件展示了.NET与非托管COM组件协作的实现方式示例程序可直接运行查看仪表外观与交互效果说明文档则提供实例化控件、设置属性与响应事件的参考。目前已有1235人学习下载适合希望掌握工控仪表控件集成与自定义的中初级开发者借鉴实践。1. 仪表控件 C#例子从圆表到指示灯一套能直接抄的工控 UI 方案做过上位机的人大概都有过这种经历项目里要显示一个压力值你拿 ProgressBar 凑合甲方看了一眼说“太丑了能不能做成那种圆盘指针的”。于是你打开搜索引擎搜“仪表控件 C#例子”翻了一堆文章要么是贴一张截图不给代码要么是 WinForm 里嵌了个 Flash要么是让你去买某个商业控件库。最后你花了两天自己用 GDI 画了一个圆表指针还会抖。这个标题讲的就是这件事用 C# 实现一套常见的工业仪表控件包括圆表指针式表盘、直表测量标尺/条形刻度、旋钮、开关和指示灯。它解决的是工控上位机、设备监控界面、测试台软件里“数据要好看地显示出来”这个需求。适合两类人一是刚接手上位机项目、需要快速出效果的开发者二是想搞清楚 GDI 自定义绘制到底怎么落地的人。下面这套方案基于 WinForm 的 UserControl GDI 双缓冲绘制不依赖任何第三方控件库你新建一个类库就能跑。2. 圆表和直表怎么画GDI 绘制的核心参数与最小实现2.1 为什么选 UserControl OnPaint 而不是现成控件工控界面里的仪表控件核心诉求是“可控”。商业控件库当然省事但你会遇到几个问题授权费用、定制困难甲方说指针颜色要改成企业色、部署环境限制。自己用 GDI 画代码量其实不大一个圆表控件核心绘制逻辑大概 150 行左右。选 UserControl 而不是直接从 Control 继承是因为 UserControl 自带设计器支持你可以把它拖到窗体上在属性面板里调量程、单位、刻度数量这些参数。绘制入口统一走OnPaint配合SetStyle开启双缓冲避免指针转动时闪烁。public partial class CircularGauge : UserControl { // 量程参数默认 0-100单位可改 private float _minValue 0f; private float _maxValue 100f; private float _currentValue 0f; private string _unit MPa; public CircularGauge() { InitializeComponent(); // 关键开启双缓冲和用户绘制消除闪烁 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); // 控件尺寸变化时重绘 ResizeRedraw true; } public float CurrentValue { get _currentValue; set { // 钳位防止超量程导致指针画出界 _currentValue Math.Max(_minValue, Math.Min(_maxValue, value)); Invalidate(); // 触发重绘 } } }这段代码里三个参数值得注意。ControlStyles.OptimizedDoubleBuffer是消除闪烁的关键不加的话指针每动一次整个控件闪一下肉眼可见。ResizeRedraw true保证控件被拉伸时重新计算绘制坐标否则刻度会糊。Invalidate()而不是Refresh()前者标记区域等待系统统一重绘后者强制立即重绘频繁调用会拖慢 UI 线程。2.2 圆表绘制从外圈到指针的六层结构圆表的绘制顺序很重要后画的覆盖先画的。我一般按这个层次来外圈边框 → 刻度线 → 刻度文字 → 单位文字 → 指针 → 中心轴帽。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 抗锯齿圆和指针边缘才平滑 int w Width, h Height; int size Math.Min(w, h) - 10; // 留 5px 边距 int cx w / 2, cy h / 2; Rectangle outerRect new Rectangle(cx - size / 2, cy - size / 2, size, size); // 第一层外圈 using (Pen outerPen new Pen(Color.FromArgb(80, 80, 80), 2f)) g.DrawEllipse(outerPen, outerRect); // 第二层刻度线从 225 度开始扫过 270 度 float startAngle 225f; // 起始角度GDI 角度顺时针0 度在 3 点钟方向 float sweepAngle 270f; // 总扫过角度 int tickCount 10; // 主刻度数量 float step sweepAngle / tickCount; for (int i 0; i tickCount; i) { float angle startAngle i * step; double rad angle * Math.PI / 180.0; // 刻度线起点和终点从半径 0.85 处到 0.95 处 float r1 size * 0.42f, r2 size * 0.47f; PointF p1 new PointF(cx r1 * (float)Math.Cos(rad), cy r1 * (float)Math.Sin(rad)); PointF p2 new PointF(cx r2 * (float)Math.Cos(rad), cy r2 * (float)Math.Sin(rad)); g.DrawLine(Pens.Black, p1, p2); // 第三层刻度值文字 float value _minValue (_maxValue - _minValue) * i / tickCount; string text value.ToString(F0); SizeF textSize g.MeasureString(text, Font); float rt size * 0.36f; PointF tp new PointF(cx rt * (float)Math.Cos(rad) - textSize.Width / 2, cy rt * (float)Math.Sin(rad) - textSize.Height / 2); g.DrawString(text, Font, Brushes.Black, tp); } // 第四层单位文字画在圆心偏下位置 SizeF unitSize g.MeasureString(_unit, Font); g.DrawString(_unit, Font, Brushes.Gray, cx - unitSize.Width / 2, cy size * 0.15f); // 第五层指针根据当前值计算角度 float ratio (_currentValue - _minValue) / (_maxValue - _minValue); float needleAngle startAngle ratio * sweepAngle; double needleRad needleAngle * Math.PI / 180.0; float needleLen size * 0.38f; PointF needleTip new PointF(cx needleLen * (float)Math.Cos(needleRad), cy needleLen * (float)Math.Sin(needleRad)); using (Pen needlePen new Pen(Color.Red, 2.5f)) { needlePen.EndCap LineCap.ArrowAnchor; // 指针末端带箭头 g.DrawLine(needlePen, cx, cy, needleTip.X, needleTip.Y); } // 第六层中心轴帽 int capR size / 20; g.FillEllipse(Brushes.DarkRed, cx - capR, cy - capR, capR * 2, capR * 2); }角度计算是圆表最容易翻车的地方。GDI 的DrawLine和DrawEllipse用的是屏幕坐标系Y 轴向下角度从 X 轴正方向顺时针增加。所以 225 度对应的是左下方向270 度扫过之后到 495 度即 135 度结束正好覆盖表盘的下方开口。如果你按数学坐标系去算指针会转到完全相反的方向。刻度文字的位置用MeasureString先量文字尺寸再居中不然文字会偏。半径系数 0.36 是文字中心到圆心的距离这个值要根据你的控件实际大小微调太小文字会压到刻度线太大文字会跑出外圈。2.3 直表测量标尺横向和纵向的坐标映射直表本质上是一个带刻度的矩形区域核心是把数值映射到像素坐标。横向直表和纵向直表逻辑一样只是交换 X/Y。public class LinearGauge : UserControl { private float _minValue 0f, _maxValue 100f, _currentValue 0f; private Orientation _orientation Orientation.Horizontal; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; int margin 20; // 两端留白给刻度和文字 int tickCount 10; if (_orientation Orientation.Horizontal) { int barY Height / 2; int barLeft margin, barRight Width - margin; int barLen barRight - barLeft; // 主轴线 g.DrawLine(Pens.Gray, barLeft, barY, barRight, barY); for (int i 0; i tickCount; i) { int x barLeft barLen * i / tickCount; // 主刻度线长 8px次刻度线长 4px g.DrawLine(Pens.Black, x, barY - 8, x, barY 8); float val _minValue (_maxValue - _minValue) * i / tickCount; string t val.ToString(F0); SizeF ts g.MeasureString(t, Font); g.DrawString(t, Font, Brushes.Black, x - ts.Width / 2, barY 12); } // 当前值指示三角 float ratio (_currentValue - _minValue) / (_maxValue - _minValue); int px barLeft (int)(barLen * ratio); PointF[] tri { new PointF(px, barY - 12), new PointF(px - 6, barY - 22), new PointF(px 6, barY - 22) }; g.FillPolygon(Brushes.Red, tri); } else { // 纵向逻辑交换 X/Y从下往上映射 int barX Width / 2; int barTop margin, barBottom Height - margin; int barLen barBottom - barTop; g.DrawLine(Pens.Gray, barX, barTop, barX, barBottom); for (int i 0; i tickCount; i) { int y barBottom - barLen * i / tickCount; // 注意从下往上 g.DrawLine(Pens.Black, barX - 8, y, barX 8, y); float val _minValue (_maxValue - _minValue) * i / tickCount; g.DrawString(val.ToString(F0), Font, Brushes.Black, barX 12, y - 8); } float ratio (_currentValue - _minValue) / (_maxValue - _minValue); int py barBottom - (int)(barLen * ratio); PointF[] tri { new PointF(barX - 12, py), new PointF(barX - 22, py - 6), new PointF(barX - 22, py 6) }; g.FillPolygon(Brushes.Red, tri); } } }纵向直表最容易搞反的就是 Y 坐标方向。屏幕坐标 Y 向下增大但仪表读数是从下往上增大的所以y barBottom - barLen * i / tickCount这个减法不能省。我见过有人直接写barTop barLen * i / tickCount结果 0 刻度跑到顶部去了。margin参数控制两端留白太小的话首尾刻度文字会被裁掉。如果你的量程文字比较长比如 “1000.0”margin 要相应加大到 30 以上。3. 旋钮、开关和指示灯交互状态机与视觉反馈3.1 旋钮鼠标拖拽角度换算与边界处理旋钮和圆表在视觉上像但交互逻辑完全不同。圆表是只读显示旋钮要响应鼠标拖拽。核心是把鼠标位置转换成角度再映射到数值。public class KnobControl : UserControl { private float _minValue 0f, _maxValue 100f, _currentValue 50f; private bool _dragging false; private const float StartAngle 225f; private const float SweepAngle 270f; public KnobControl() { SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint, true); } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _dragging true; UpdateValueFromMouse(e.Location); } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_dragging) UpdateValueFromMouse(e.Location); } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _dragging false; } private void UpdateValueFromMouse(Point p) { int cx Width / 2, cy Height / 2; double dx p.X - cx, dy p.Y - cy; // Atan2 返回 -PI 到 PI转换成 0-360 度 double angle Math.Atan2(dy, dx) * 180.0 / Math.PI; if (angle 0) angle 360; // 把角度映射到量程注意处理起始角偏移 double relative angle - StartAngle; if (relative 0) relative 360; if (relative SweepAngle) { // 超出有效角度范围取最近边界 relative (relative SweepAngle (360 - SweepAngle) / 2) ? 0 : SweepAngle; } float ratio (float)(relative / SweepAngle); _currentValue _minValue ratio * (_maxValue - _minValue); Invalidate(); OnValueChanged?.Invoke(this, EventArgs.Empty); } public event EventHandler OnValueChanged; }Math.Atan2(dy, dx)返回的是弧度范围 -π 到 π转成角度后负数加 360 归正。关键在relative SweepAngle的处理旋钮的有效旋转范围是 270 度剩下 90 度是“死区”。鼠标落在死区时不能直接忽略否则用户拖到那里旋钮会跳。我的做法是判断离哪端近就取哪端这样手感是连续的。OnValueChanged事件让外部订阅数值变化比如你在主窗体里订阅这个事件去更新一个 Label 显示当前值。3.2 开关和指示灯用状态枚举驱动绘制开关和指示灯逻辑简单但状态要清晰。我一般定义一个枚举绘制时根据状态走不同分支。public enum SwitchState { Off, On, Disabled } public enum LampState { Off, Green, Red, Yellow, Blinking } public class IndicatorLamp : UserControl { private LampState _state LampState.Off; private Timer _blinkTimer; private bool _blinkOn true; public LampState State { get _state; set { _state value; // 只有闪烁状态才启动定时器其他状态停掉省资源 if (value LampState.Blinking) { if (_blinkTimer null) { _blinkTimer new Timer { Interval 500 }; _blinkTimer.Tick (s, e) { _blinkOn !_blinkOn; Invalidate(); }; } _blinkTimer.Start(); } else { _blinkTimer?.Stop(); _blinkOn true; } Invalidate(); } } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; int size Math.Min(Width, Height) - 4; int cx Width / 2, cy Height / 2; Rectangle rect new Rectangle(cx - size / 2, cy - size / 2, size, size); Color lampColor; switch (_state) { case LampState.Green: lampColor Color.LimeGreen; break; case LampState.Red: lampColor Color.Red; break; case LampState.Yellow: lampColor Color.Gold; break; case LampState.Blinking: lampColor _blinkOn ? Color.OrangeRed : Color.DarkGray; break; default: lampColor Color.DarkGray; break; } // 外圈暗色边框 g.FillEllipse(Brushes.DimGray, rect); // 内圈亮色留 2px 边框 Rectangle inner new Rectangle(rect.X 2, rect.Y 2, rect.Width - 4, rect.Height - 4); using (SolidBrush brush new SolidBrush(lampColor)) g.FillEllipse(brush, inner); // 高光点让灯看起来有立体感 int hlSize size / 4; Rectangle hlRect new Rectangle( cx - size / 4 - hlSize / 2, cy - size / 4 - hlSize / 2, hlSize, hlSize); using (SolidBrush hlBrush new SolidBrush(Color.FromArgb(120, 255, 255, 255))) g.FillEllipse(hlBrush, hlRect); } }闪烁用Timer而不是在OnPaint里计数因为OnPaint的调用时机不确定可能因为窗口遮挡、最小化恢复等原因触发计数会乱。Timer的 500ms 间隔是工控场景常用的闪烁频率太快像故障太慢像卡顿。高光点用半透明白色画在左上角这是让平面圆看起来像灯泡的廉价技巧成本极低但视觉效果提升明显。4. 避坑与排查仪表控件开发中常见的五个翻车点4.1 指针转动时整个控件闪烁现象数值刷新频率稍高比如 100ms 一次圆表控件肉眼可见地闪像老式日光灯。原因没开双缓冲每次Invalidate都直接往屏幕画绘制过程被看到了。解决构造函数里加SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)。三个标志缺一不可只加DoubleBuffer属性在 UserControl 上有时不生效。4.2 刻度文字重叠或跑出控件边界现象量程改成 0-1000 后刻度值变成四位数文字互相压在一起最边上的数字被裁掉。原因刻度数量固定为 10但文字宽度随位数增加没有动态调整。解决根据控件宽度和文字预估宽度动态计算最大刻度数。简单做法是tickCount Math.Max(4, Width / 60)保证每个刻度至少有 60px 空间。同时把绘制区域往里缩留出文字宽度的边距。4.3 旋钮拖拽时数值跳变现象鼠标从旋钮一侧拖到另一侧数值不是平滑变化而是突然从最小跳到最大。原因Atan2在角度跨越 0 度/360 度边界时relative计算结果从接近 270 突然变成接近 0映射出的数值就跳了。解决在UpdateValueFromMouse里记录上一次的角度如果本次和上次的角度差超过 180 度说明跨过了边界对本次角度做 ±360 修正后再计算差值。或者更简单限制旋钮的有效角度范围不跨越 0 度比如从 225 度到 495 度即 135 度这样relative始终在 0-270 之间不会出现负值。4.4 指示灯闪烁定时器导致内存泄漏现象窗体关闭后进程还在后台跑任务管理器里能看到。原因Timer没有随控件销毁而停止Tick事件里还持有控件的引用。解决重写Dispose方法在里面_blinkTimer?.Stop(); _blinkTimer?.Dispose();。如果控件是在设计器里拖出来的记得在Dispose(bool disposing)里处理不要只写无参Dispose。4.5 高 DPI 缩放下绘制错位现象在 4K 屏或系统缩放 150% 的环境下圆表变成椭圆指针指偏。原因GDI 默认按 96 DPI 计算系统缩放后控件实际像素尺寸和逻辑尺寸不一致。解决在app.manifest里声明 DPI 感知或者在程序入口调用Application.SetHighDpiMode(HighDpiMode.SystemAware)。然后在OnPaint里用ClientSize而不是Size计算绘制区域ClientSize已经排除了边框和滚动条的影响。5. 进阶技巧让仪表控件真正能用在项目里的三个习惯第一个习惯是把所有仪表控件的公共参数抽到一个基类里。量程、单位、刻度数、颜色方案这些属性圆表、直表、旋钮都要用。我一般写一个GaugeBase : UserControl在里面定义MinValue、MaxValue、Unit、TickCount这些属性子类只管画自己的形状。这样改一个默认颜色所有控件一起变不用一个个改。第二个习惯是给数值变化加一个平滑过渡。工控现场的数据往往有噪声直接刷新指针会抖得厉害。我一般加一个简单的低通滤波private float _displayValue 0f; private const float SmoothFactor 0.3f; // 0-1越小越平滑 public void UpdateValue(float newValue) { // 指数平滑显示值向目标值逼近 _displayValue (newValue - _displayValue) * SmoothFactor; Invalidate(); }SmoothFactor取 0.2 到 0.4 之间比较合适。太小指针反应迟钝太大起不到平滑效果。这个滤波放在控件内部做外部只管调UpdateValue不用关心平滑逻辑。第三个习惯是给控件加设计时支持。在属性上加[Category(仪表参数)]和[Description(设置量程上限)]这样在 Visual Studio 的属性面板里会分组显示甲方或同事拖控件的时候能直接看懂每个参数是干什么的。再给CurrentValue属性加[Browsable(false)]因为运行时数值不应该在设计器里手动设。[Category(仪表参数), Description(量程下限)] public float MinValue { get _minValue; set { _minValue value; Invalidate(); } } [Category(仪表参数), Description(量程上限)] public float MaxValue { get _maxValue; set { _maxValue value; Invalidate(); } } [Browsable(false)] // 运行时属性设计器里隐藏 public float CurrentValue { get _currentValue; set { _currentValue value; Invalidate(); } }还有一个实际项目里踩过的坑控件的BackColor属性。UserControl 默认背景色是Control灰色如果你在OnPaint里没有先填充背景拖动窗口时残留的旧图像会留在上面。解决办法是在OnPaint开头加g.Clear(BackColor)或者把ControlStyles.Opaque也加上。我一般直接g.Clear(BackColor)简单可靠。最后说一个验证方法写一个测试窗体放一个Timer每 50ms 给所有控件的CurrentValue赋一个随机值跑十分钟。如果内存不涨、CPU 占用稳定在 5% 以下、没有闪烁和错位这套控件基本就能上项目了。我自己的习惯是每个仪表控件写完都先过这一关比在项目里调试省事得多。希望帮到你。本文还有配套的精品资源点击获取