C# WinForms 工控仪表控件:GDI+绘制与ActiveX/COM接入

发布时间:2026/10/29 11:15:12
C# WinForms 工控仪表控件:GDI+绘制与ActiveX/COM接入

简介:这是一份面向C# WinForms工控界面开发者的仪表控件示例包,聚焦圆表、直表(测量标尺)、旋钮、开关与指示灯等仿真控件的落地实现,适合需要快速搭建组态式监控面板或理解ActiveX控件在.NET中调用的初中级开发者。压缩包共18个文件、约1.15MB,含6个C#源码文件、工程与解决方案配置、窗体资源文件,以及OCX控件和两个互操作DLL,另有可执行示例、预览图与使用说明文本,文件类型覆盖源码、资源、控件与文档,便于直接运行观察效果并对照源码学习控件实例化、属性设置和事件响应。已有1238人学习。源码展示了将ActiveX仪表嵌入窗体、绑定刻度与指针状态,并让开关和指示灯随交互联动更新的做法,对刻度映射、指针刷新、控件属性配置和交互逻辑有直观参考,可作为自定义仪表与工控可视化界面的工程结构参考,也为.NET与非托管COM组件互操作提供实践样例。

1. 仪表盘不是画出来就行:一份 C# 控件例子能落地到什么程度

工控组态画面里最不起眼、又最容易被挑毛病的,往往就是仪表。圆表指针抖不抖、直表刻度挤不挤、旋钮拖起来跟不跟手,直接决定这套仿真界面能不能拿上台面。这份 C# 仪表控件例子把圆表、直表(测量标尺)、旋钮、开关、指示灯五类元件放在一起,给的是能直接抄进 WinForms 工程的绘制与交互骨架,不是一张截图。它适合两类人:一类在 .NET 控件里做设备面板,想把 GDI+ 自绘这条路走通;另一类要把控件通过 ActiveX/COM 塞进组态软件,被注册和属性暴露折腾过。下面按「怎么拆、怎么写、怎么接、哪里翻车」的顺序过一遍。

2. 五类仪表的绘制骨架:圆表、直表、旋钮、开关、指示灯怎么拆

这五类控件看着差别大,底层其实是同一套东西:一个把数值映射到几何位置的函数,加一个把几何位置画出来的 OnPaint。圆表映射到角度,直表映射到像素,旋钮反过来把鼠标角度映射成数值,开关和指示灯则是布尔状态到视觉状态的映射。把这条主线抓住,后面加多少种表都是套模板。

我一般先定一个抽象基类,把 MinValue、MaxValue、Value、ValueChanged 这几样放进去,五类控件各自只实现「值怎么变成像素」和「像素怎么画」。这样圆表和直表能共用属性面板、共用钳位逻辑、共用鼠标捕获,代码量会少一大截。下面按控件逐个拆,重点讲清坐标和角度的换算,因为翻车基本都翻在这里。

2.1 圆表:极坐标、扫角与指针三角

圆表的核心是一个线性映射:把 [MinValue, MaxValue] 映射到 [StartAngle, StartAngle + SweepAngle]。GDI+ 的角度制里 0° 指向三点钟方向,顺时针为正,所以工控表盘常见的布局是起始 135°、扫过 270°,也就是从左下经上方绕到右下,留出底部一块放单位标签。

private float ValueToAngle(float value) { // 先钳位,避免值超量程时指针转出表盘 float v = Math.Max(MinValue, Math.Min(MaxValue, value)); float ratio = (v - MinValue) / (MaxValue - MinValue); // StartAngle 默认 135,SweepAngle 默认 270 return StartAngle + ratio * SweepAngle; }

这段代码有两个参数值得留意。StartAngle决定零位指向,SweepAngle决定表盘开口大小,两者一改,刻度、色区、指针必须全部走同一个ValueToAngle,否则指针和刻度对不上。我见过有人画刻度时用一套手算角度、画指针时用另一套,结果指针永远偏两度,查了半天以为是浮点误差。

刻度线用循环算角度,主刻度画长线、加数字,副刻度画短线。真正省事的是指针:不要手算旋转后的坐标,用TranslateTransform把原点挪到圆心,再RotateTransform旋转,最后按「指针朝上」的坐标画三角形。

protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; float cx = Width / 2f; float cy = Height / 2f; float radius = Math.Min(Width, Height) / 2f - 8f; // 表盘底圆 using (var bg = new SolidBrush(DialBackColor)) g.FillEllipse(bg, cx - radius, cy - radius, radius * 2, radius * 2); // 报警色区:用粗弧线画在表盘内圈 DrawColorZone(g, cx, cy, radius); // 刻度 using (var tickPen = new Pen(TickColor, 1.5f)) { for (int i = 0; i <= MajorTickCount; i++) { float angle = StartAngle + SweepAngle * i / MajorTickCount; double rad = angle * Math.PI / 180.0; float x1 = cx + (float)(Math.Cos(rad) * (radius - 14)); float y1 = cy + (float)(Math.Sin(rad) * (radius - 14)); float x2 = cx + (float)(Math.Cos(rad) * radius); float y2 = cy + (float)(Math.Sin(rad) * radius); g.DrawLine(tickPen, x1, y1, x2, y2); } } // 指针:平移到圆心再旋转,省掉手算三角函数 var state = g.Save(); g.TranslateTransform(cx, cy); g.RotateTransform(ValueToAngle(Value) + 90f); // +90 让指针初始指向十二点 using (var needle = new SolidBrush(NeedleColor)) { var pts = new[] { new PointF(-4, 6), // 尾部左 new PointF(4, 6), // 尾部右 new PointF(0, -(radius - 20)) // 针尖 }; g.FillPolygon(needle, pts); } g.Restore(state); }

+90f这个偏移是很多人第一次写圆表时卡住的地方:RotateTransform旋转的是整个坐标系,而指针三角形是按「针尖朝上」画的,朝上在 GDI+ 里是 -90°,所以要把映射出来的角度补 90° 才能对齐。三个点的坐标里,尾部两个点故意留出 8 像素宽度,是为了让指针根部有配重感,针尖长度取radius - 20,比刻度内沿短一点,避免盖住数字。Save/Restore成对出现,保证后续绘制不被旋转变换污染,这个习惯在多层绘制的表盘上尤其重要。

色区绘制用DrawArc配粗 Pen,起始角和扫角同样走ValueToAngle,但要注意 Pen 的线宽会让弧线内外各溢出一半,想精确贴合刻度得把半径往里收半个线宽。报警色区一般是两段:下限到报警下限、报警上限到量程上限,颜色用半透明红或黄,压在底圆之上、刻度之下。

2.2 直表(测量标尺):线性映射与刻度密度

直表就是横过来或竖过来的标尺,核心是值到像素的线性映射。水平直表从左往右增,垂直直表从下往上增,两个方向要分开处理,不能用一个公式硬套。

private float ValueToPixel(float value) { float v = Math.Max(MinValue, Math.Min(MaxValue, value)); float ratio = (v - MinValue) / (MaxValue - MinValue); return Orientation == Orientation.Horizontal ? Padding.Left + ratio * (Width - Padding.Left - Padding.Right) : Height - Padding.Bottom - ratio * (Height - Padding.Top - Padding.Bottom); }

Padding在这里不是装饰,是给刻度和单位文字留的边距。水平直表如果左右各留 24 像素,刻度起点就不会贴着控件边缘被裁掉;垂直直表的底部留白则用来放最小值标签。

直表真正难的不是映射,是刻度密度。量程 0 到 1000、控件宽度只有 200 像素时,如果按固定步长 10 画标签,数字会叠成一团黑。常见做法是按 1/2/5 序列动态选步长,保证相邻标签的像素间距不小于「最大标签宽度 + 8」。

private float CalcMajorStep(Graphics g, float minPixelGap) { float range = MaxValue - MinValue; float pixelPerUnit = (Orientation == Orientation.Horizontal ? Width - Padding.Left - Padding.Right : Height - Padding.Top - Padding.Bottom) / range; // 候选步长按 1/2/5 序列递增,覆盖大多数工控量程 float[] steps = { 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000 }; foreach (var s in steps) { if (s * pixelPerUnit >= minPixelGap) return s; } return steps[steps.Length - 1]; }

minPixelGap一般取g.MeasureString(MaxValue.ToString(), Font).Width + 8,用最大值的字符串宽度是因为它通常最长。算出来的步长只用于主刻度,副刻度取主步长的五分之一或二分之一,且不标数字。填充条用矩形叠在标尺轨道上,超上限时把画刷换成报警色,这个判断放在 OnPaint 里做,不要塞进 Value 的 setter。

垂直直表的文字要旋转 90° 绘制,用g.RotateTransform(-90)之后DrawString的坐标要重新算,简单做法是把文字画在轨道左侧固定列,StringFormat设成居中,别跟着刻度走。

2.3 旋钮、开关、指示灯:交互状态机与视觉反馈

旋钮是把鼠标位置反算成角度,再累加成数值。这里有个必须处理的细节:Math.Atan2的返回值在 -180° 到 180° 之间,鼠标拖过正上方或正下方时角度会突然跳 360°,如果不归一化,值会瞬间从 90 掉到 -270。

private double _lastAngle; protected override void OnMouseDown(MouseEventArgs e) { _lastAngle = AngleOf(e.Location); _dragging = true; Capture = true; // 鼠标移出控件仍能收到 Move 消息 } protected override void OnMouseMove(MouseEventArgs e) { if (!_dragging) return; double cur = AngleOf(e.Location); double delta = cur - _lastAngle; // 关键:把角度差归一化到 -180..180,避免跨零点跳变 if (delta > 180) delta -= 360; if (delta < -180) delta += 360; _lastAngle = cur; Value = (float)Math.Max(MinValue, Math.Min(MaxValue, Value + delta * UnitsPerDegree)); Invalidate(); } private double AngleOf(Point p) { double dx = p.X - Width / 2.0; double dy = p.Y - Height / 2.0; return Math.Atan2(dy, dx) * 180.0 / Math.PI; }

UnitsPerDegree是灵敏度参数,默认 1 表示转一度值变一个单位;量程 0 到 10 的旋钮要把它设成 0.05 左右,否则轻轻一拖就顶到上限。Capture一定要在 MouseUp 里置回 false,不然控件会一直抓着鼠标,别的控件点不动。

开关是布尔状态加过渡动画。直接瞬间跳变也能用,但组态画面里带 150 毫秒缓动的开关看起来更像真实设备。用一个 15 毫秒的 Timer 把滑块位置按比例逼近目标值,差小于 0.01 就停表,避免定时器空转。

private float _thumbPos = 0f; // 0 关,1 开 private void OnAnimTick(object sender, EventArgs e) { float target = Checked ? 1f : 0f; _thumbPos += (target - _thumbPos) * 0.25f; if (Math.Abs(target - _thumbPos) < 0.01f) { _thumbPos = target; _anim.Stop(); } Invalidate(); }

指示灯用状态枚举比布尔值好扩展,至少留 Off、On、Blink、Fault 四态。发光效果用PathGradientBrush,中心亮、边缘暗,点亮时中心色饱和度拉满,熄灭时整体压到 60 左右透明度。闪烁态不要自己开线程,用共享的 Timer 翻_blinkOn再 Invalidate,多个灯可以挂同一个 Timer,减少消息循环压力。

3. 在 WinForms 里把控件写出来:GDI+ 绘制流程与双缓冲

原理清楚了,落到 WinForms 还有三件事要处理:属性契约怎么定、双缓冲怎么开、鼠标和键盘怎么统一。这三件事做不好,控件能画出来但不好用,接进组态软件后甲方一拖就露馅。

3.1 控件基类与属性契约

基类继承Control而不是UserControl,因为自绘控件不需要子控件容器,继承 Control 更轻,也不会多出一层默认背景。属性用完整的 get/set,setter 里做三件事:钳位、比较、触发事件。比较这一步不能省,数据源定时刷新时如果每帧都触发 ValueChanged,订阅方会被事件风暴拖垮。

public abstract class GaugeBase : Control { private float _min = 0f, _max = 100f, _value = 0f; [Category("仪表"), Description("量程下限")] public float MinValue { get => _min; set { _min = value; if (_max <= _min) _max = _min + 1; ClampAndInvalidate(); } } [Category("仪表"), Description("量程上限")] public float MaxValue { get => _max; set { _max = value; if (_max <= _min) _min = _max - 1; ClampAndInvalidate(); } } [Category("仪表"), Description("当前值")] public float Value { get => _value; set { float v = Math.Max(_min, Math.Min(_max, value)); if (Math.Abs(v - _value) < 0.0001f) return; // 值没变就不重绘 _value = v; OnValueChanged(EventArgs.Empty); Invalidate(); } } public event EventHandler ValueChanged; protected virtual void OnValueChanged(EventArgs e) => ValueChanged?.Invoke(this, e); private void ClampAndInvalidate() { _value = Math.Max(_min, Math.Min(_max, _value)); Invalidate(); } }

Category和Description是给属性面板看的,组态软件里配点位的人不一定懂代码,属性分组清楚能省掉一半沟通。MinValue的 setter 里顺手修正MaxValue,是为了防止有人先把上限调小再调下限,导致中间状态出现Max < Min的除零。浮点比较用 0.0001 的容差,是因为数据源传过来的值经常带小数点后一串二进制误差,严格相等会漏掉该重绘的帧。

3.2 双缓冲与重绘区域

WinForms 自绘控件闪烁,九成是背景擦除和前景绘制不同步。基类构造函数里把三个样式打开,再重写OnPaintBackground留空,闪烁基本就没了。

public GaugeBase() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); DoubleBuffered = true; } protected override void OnPaintBackground(PaintEventArgs e) { // 背景由 OnPaint 自己画,交给基类会多一次擦除造成闪烁 }

AllPaintingInWmPaint把擦背景和绘制合并到同一条 WM_PAINT 消息里,OptimizedDoubleBuffer开后台缓冲,UserPaint表示自己负责全部绘制,ResizeRedraw让尺寸变化时自动重绘。四个样式缺一个都可能出现边缘残留。

重绘区域方面,圆表全量Invalidate()在 30 帧以内问题不大,指针快速摆动时想再省一点,可以只失效指针新旧位置的外接矩形。我一般的做法是先全量,用性能分析看单帧耗时,超过 8 毫秒再优化,不提前折腾。

数据源高频推送时加一层节流,把刷新和赋值解耦:

private int _lastTick; private float _pendingValue; public void UpdateValue(float v) { // 40ms 节流,约 25 帧,肉眼已经够顺 int now = Environment.TickCount; if (now - _lastTick < 40) { _pendingValue = v; return; } _lastTick = now; Value = _pendingValue = v; }

_pendingValue的作用是保证节流窗口结束时不会丢掉最后一个值,否则缓慢变化的信号会出现台阶。

3.3 鼠标拖动改值与点击切换

五类控件的交互统一放在基类:MouseDown 里Focus()加Capture = true,MouseMove 里判断拖动标志,MouseUp 里释放捕获。子类只重写ApplyMouse(Point p),圆表和直表在里面做点击定位,旋钮在里面做角度累加。

protected override void OnMouseDown(MouseEventArgs e) { if (!Enabled) return; Focus(); // 让键盘也能调值 _dragging = true; Capture = true; ApplyMouse(e.Location); } protected override void OnMouseMove(MouseEventArgs e) { if (_dragging) ApplyMouse(e.Location); } protected override void OnMouseUp(MouseEventArgs e) { _dragging = false; Capture = false; } protected virtual void ApplyMouse(Point p) { }

Focus()这行容易被忽略,但工控现场经常用键盘微调,没有焦点就收不到 KeyDown。键盘处理在基类里统一做,左右箭头按步长加减,Home/End 跳到量程两端,步长用SmallChange属性暴露出来,默认取量程的百分之一。

4. 接到组态软件上:.NET 控件走 ActiveX/COM 的注册与暴露边界

控件在本机跑通只是第一步,真正要落地往往是把它注册成 ActiveX 控件,让组态软件的画面编辑器能拖进去。.NET 控件走 COM 互操作这条路,注册命令本身不复杂,难的是属性类型和事件模型这两道边界。

4.1 COM 可见性与注册命令

每个要暴露的控件加ComVisible、Guid、ProgId三个特性,类接口用AutoDispatch,让 COM 自动生成调度接口,省掉手写 IDispatch。

[ComVisible(true)] [Guid("6F1C2A70-3B4D-4E8A-9C21-5D7E8F90A1B2")] [ProgId("SimGauge.RoundGauge")] [ClassInterface(ClassInterfaceType.AutoDispatch)] public class RoundGauge : GaugeBase { }

Guid必须唯一,同一个 DLL 重新编译后 Guid 不能变,否则组态画面里已经插入的控件会全部变成红叉。ProgId是组态软件里显示的名字,建议用「项目前缀.控件名」的格式,别用默认的命名空间全名,太长不好找。

注册用对应位数的 RegAsm,32 位宿主和 64 位宿主必须分别注册:

:: 管理员权限运行,注册 32 位版本并生成类型库 C:\Windows\Microsoft.NET\Framework\v4.0.30319\RegAsm.exe SimGauge.dll /codebase /tlb :: 64 位宿主用 Framework64 下的 RegAsm C:\Windows\Microsoft.NET\Framework64\v4.0.30319\RegAsm.exe SimGauge.dll /codebase

/codebase让注册表记录 DLL 的绝对路径,适合不想装 GAC 的场合;/tlb生成类型库,部分组态软件插入控件时依赖它。DLL 放在固定目录,别放桌面或临时文件夹,路径一移动注册就失效。卸载用/u加同样的参数。

4.2 属性、事件、方法暴露的边界

COM 只认简单类型:int、float、double、bool、string,以及底层是 int 的枚举。Color、Point、泛型集合、可空类型这些,组态软件的属性浏览器要么显示空白,要么直接实例化失败。颜色属性的常见做法是拆成 ARGB 整数暴露。

[ComVisible(true)] [Category("外观"), Description("表盘底色,ARGB 整数")] public int DialBackColorArgb { get => DialBackColor.ToArgb(); set => DialBackColor = Color.FromArgb(value); }

属性面板里显示成一串数字不好看,但能存能取,比显示空白强。有的组态软件认 OLE_COLOR,可以在类型库层面做转换,普通场景用 int 最省事。

事件是第二个坑。.NET 的EventHandler在AutoDispatch模式下不一定被宿主识别成 COM 事件,稳妥做法是同时提供一个可轮询的CurrentValue只读属性,让组态脚本定时读,而不是依赖事件回调。方法暴露同理,参数和返回值只用简单类型,别返回自定义结构体。

[Browsable(false)]用来藏掉不需要暴露的内部属性,[DefaultValue]帮设计器判断属性是否被改过、要不要序列化。控件类上再加ToolboxBitmap指定一个 16×16 的位图,不然插入画面后图标是默认的白方块,找起来费劲。

5. 避坑与排查:五个让仪表控件当场翻车的细节

这五条是我在不同项目里反复踩过的,按现象、原因、解决三段写,遇到了直接对照。

5.1 画面闪烁与指针重影

现象:定时刷新时表盘整体泛白闪一下,指针快速摆动会拖出一串残影。

原因:OnPaintBackground每帧擦一遍背景,和前景绘制不在同一帧完成;或者在 OnPaint 里每次 new Brush、new Pen,GDI+ 对象频繁分配导致绘制延迟。

解决:开双缓冲四件套,重写OnPaintBackground留空,Pen 和 Brush 提成字段在构造函数里创建、控件销毁时 Dispose。局部刷新用Invalidate(rect)包住指针新旧位置的外接矩形,别整个控件重画。

5.2 圆表指针越界与零位偏移

现象:值超过上限时指针继续往表盘外转;或者值等于下限时指针不在起始刻度上,偏个几度。

原因:映射前没钳位,ratio大于 1 直接算出超范围角度;或者画刻度和画指针用了两套角度算法,StartAngle和RotateTransform的偏移没对齐。

解决:ValueToAngle第一行就 Clamp;刻度和指针强制走同一个函数;画一条 0 值参考线做视觉校验,指针应该正好压在起始刻度上。调试时把StartAngle、SweepAngle、Value、算出的角度打到 Debug 输出里,一眼能看出偏差。

5.3 直表刻度标签挤成一团

现象:量程 0 到 1000、宽度 200 像素的直表,每个刻度都标数字,字和字叠在一起看不清。

原因:主刻度步长写死成 10 或 20,没有按控件实际像素宽度动态计算。

解决:用 1/2/5 序列按最小标签间距选步长,主刻度标数字、副刻度只画线。宽度小于 120 像素时干脆只标首尾两个值,中间用刻度线示意。字号跟着控件高度缩放,别固定 9 号字。

5.4 旋钮拖过零点角度跳变

现象:鼠标从正上方逆时针拖过正左方,数值突然从 90 跳到 -270,或者直接顶到量程一端。

原因:Atan2返回 -180 到 180,跨过 ±180 边界时两次采样的差值出现 ±360 的跳变,直接累加就飞了。

解决:差值归一化到 -180 到 180 再累加,代码就是前面那段if (delta > 180) delta -= 360。另一种做法是用累计角度而不是绝对角度,但要处理鼠标离开控件再回来的情况,归一化更简单。

5.5 注册成功但设计器里拖不出来

现象:RegAsm 提示注册成功,组态软件的工具箱里找不到控件,或者插入时报「无法创建对象」。

原因:位数不匹配是最常见的,32 位宿主读不到 64 位注册表项;其次是 ProgId 和已装控件冲突;还有控件没有公共无参构造函数,或者某个属性类型 COM 不支持,实例化时反射失败。

解决:先确认宿主进程位数,用对应的 RegAsm 重新注册;ProgId 加项目前缀保证唯一;保留公共无参构造;属性多的时候用二分法注释掉一半,重新注册试插入,定位到具体是哪个属性类型不兼容。

6. 进阶:把五类仪表做成一套可配置的组态元件

五个控件写到能跑,下一步是把外观全部做成属性,让一个 DLL 配出整面板的表。下面这张表是我一般会暴露的参数集,按控件类型分:

属性类型作用常用值
StartAnglefloat圆表零位角度135
SweepAnglefloat圆表扫角270
MajorTickCountint主刻度段数10
ColorZoneStartfloat报警色区起点量程 80%
ColorZoneEndfloat报警色区终点量程 100%
UnitsPerDegreefloat旋钮灵敏度0.05~1
LampColorint指示灯基色 ARGB按工位配色
Orientation枚举直表方向Horizontal / Vertical

把这些做成属性而不是写死,组态时改属性就能出不同规格的表,不用为每个工位编译一版 DLL。颜色统一用 ARGB 整数,前面讲过 COM 的兼容问题。

改完外观必须做渲染验证,靠肉眼盯静态截图看不出越界和闪烁。用一个定时器把值按正弦规律推一遍,5 秒内从下限扫到上限再回落,指针、色区、报警联动一起看:

var timer = new Timer { Interval = 50 }; float t = 0; timer.Tick += (s, e) => { t += 0.02f; float v = (float)(MinValue + (MaxValue - MinValue) * (0.5 + 0.5 * Math.Sin(t))); roundGauge.Value = v; barGauge.Value = v; knob.Value = v; lamp.State = v > MaxValue * 0.8f ? LampState.Fault : LampState.On; }; timer.Start();

这段代码能一次抓出三类问题:指针在量程两端是否越界、色区和指针是否同步、指示灯在阈值附近有没有频繁抖动。验证通过再把定时器删掉,别留在正式代码里。

从那以后我每做一个表盘控件,都先把 Value 的钳位和角度归一化写成两条单元测试,再动手画界面。返工次数少了,接组态软件时也敢让甲方当场拖控件试。希望帮到你。

本文还有配套的精品资源,点击获取