
簡介這份C#儀表控件示例面向工控、組態(tài)及仿真界面開發(fā)者尤其適合需要在WinForm應用中快速搭建可視化儀表界面的初中級程序員。資源以ActiveX控件技術為核心演示了圓表、直表測量標尺、旋鈕、開關和指示燈等常見儀表元素的集成與調用方式幫助解決界面缺乏真實設備操作反饋的問題。壓縮包共18個文件約1.15MB包含6個cs源碼文件、2個dll互操作庫、1個ocx控件、1個exe示例程序及說明文檔、預覽圖等覆蓋從控件引用到窗體搭建的完整流程。目前已有1236人學習下載。通過研究源碼與可執(zhí)行示例讀者可以掌握.NET與COM互操作原理、ActiveX控件在C#項目中的實例化與屬性事件設置方法并直接參考說明文檔與預覽效果進行二次開發(fā)為工控組態(tài)軟件界面設計提供可復用的實踐基礎。1. 工控上位機儀表盤從零搭建這套 C# 控件庫把圓表、直表、旋鈕、開關、指示燈全包了做過工控上位機或者組態(tài)軟件的兄弟應該都有體會界面里最煩的不是通信協議也不是數據采集而是那一堆儀表控件。圓表要畫刻度、直表要對標尺、旋鈕要處理拖拽角度、開關和指示燈還得跟 PLC 的布爾量實時聯動。用第三方 ActiveX 控件吧部署麻煩、授權貴、還經常跟 .NET 版本打架自己用 GDI 從頭畫吧一個圓表就能耗掉兩三天刻度對齊、指針緩動、雙緩沖防閃爍全是坑。這套 C# 儀表控件例子就是沖著這個場景來的——它把工控組態(tài)里最常用的五類控件圓表、直表/測量標尺、旋鈕、開關、指示燈用純 .NET 自定義控件的方式實現了一遍不依賴任何 ActiveX 或第三方商業(yè)庫直接拖進 WinForms 工程就能用。適合正在做設備監(jiān)控界面、數據采集上位機、仿真訓練系統的開發(fā)者尤其是那些被 ActiveX 部署問題折騰過的人。2. 技術選型與整體架構為什么用 GDI 自定義控件而不是 ActiveX2.1 純 .NET 自定義控件的取舍這套例子的核心思路很明確所有儀表控件都繼承自System.Windows.Forms.Control重寫OnPaint方法用 GDI 在控件表面繪制。相比 ActiveX 方案好處是部署時不需要注冊 OCX、不需要管理員權限、不挑目標機器的運行庫版本。代價是繪制性能要靠雙緩沖和臟矩形來保證動畫流暢度不如 DirectX 方案。常見做法是在構造函數里設置SetStyle開啟雙緩沖和用戶繪制public class GaugeBase : Control { public GaugeBase() { // 開啟雙緩沖消除重繪閃爍 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); // 控件背景不透明避免父容器透出 SetStyle(ControlStyles.Opaque, false); } }AllPaintingInWmPaint把擦除背景和繪制合并到一次OnPaint里OptimizedDoubleBuffer讓 GDI 先畫到內存位圖再一次性貼到屏幕。這兩個標志是工控儀表控件不閃屏的基礎少了任何一個指針一動整個控件就會抖。2.2 控件繼承關系與公共基類設計五類控件雖然外觀差異大但共享不少邏輯邊框繪制、背景色、刻度值范圍、當前值屬性、值變化事件。所以合理的做法是抽一個GaugeBase基類把公共屬性和繪制骨架放進去具體控件只重寫OnDrawScale和OnDrawPointer這類虛方法。public abstract class GaugeBase : Control { protected float _minValue 0f; protected float _maxValue 100f; protected float _currentValue 0f; public float MinValue { get _minValue; set { _minValue value; Invalidate(); } // 改范圍立即重繪 } public float CurrentValue { get _currentValue; set { // 限制在量程內防止指針畫出界 _currentValue Math.Max(_minValue, Math.Min(_maxValue, value)); Invalidate(); // 觸發(fā)重繪 } } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode SmoothingMode.AntiAlias; OnDrawBackground(e.Graphics); OnDrawScale(e.Graphics); // 子類實現刻度 OnDrawPointer(e.Graphics); // 子類實現指針 } protected abstract void OnDrawScale(Graphics g); protected abstract void OnDrawPointer(Graphics g); protected virtual void OnDrawBackground(Graphics g) { } }CurrentValue的 setter 里做了量程鉗制這是工控場景的硬需求——PLC 傳上來的原始值可能因為量程配置錯誤而超限如果不在控件層兜住指針就會畫到圓表外面去。Invalidate()每次賦值都調用配合雙緩沖視覺上就是指針平滑跟隨。2.3 圓表與直表的繪制差異圓表的刻度是極坐標分布直表測量標尺是線性分布。圓表繪制時要把角度映射到值域// 圓表把當前值映射到起始角和掃過角之間 float angleRange _sweepAngle; // 比如 270 度 float startAngle _startAngle; // 比如 135 度 float ratio (_currentValue - _minValue) / (_maxValue - _minValue); float pointerAngle startAngle ratio * angleRange; // 指針終點坐標極坐標轉笛卡爾 float rad pointerAngle * MathF.PI / 180f; float cx Width / 2f; float cy Height / 2f; float len Math.Min(cx, cy) * 0.75f; // 指針長度取半徑的 75% PointF tip new PointF(cx len * MathF.Cos(rad), cy len * MathF.Sin(rad));直表則簡單得多把值域映射到控件的水平或垂直像素區(qū)間刻度線就是一組等距的短線。旋鈕控件本質上是一個可拖拽的圓表區(qū)別在于它響應鼠標事件來改變CurrentValue而不是被動接收外部賦值。3. 核心模塊拆解圓表、直表、旋鈕、開關、指示燈逐個過3.1 圓表控件刻度、指針、中心軸的繪制順序圓表的繪制順序很講究畫錯了要么刻度被指針蓋住要么中心軸壓不住指針根部。正確順序是背景圓盤 → 刻度線 → 刻度數字 → 指針 → 中心軸帽。protected override void OnDrawScale(Graphics g) { float cx Width / 2f, cy Height / 2f; float radius Math.Min(cx, cy) - 10; // 留 10px 邊距 int tickCount 11; // 主刻度數量 for (int i 0; i tickCount; i) { float ratio i / (float)(tickCount - 1); float angle _startAngle ratio * _sweepAngle; float rad angle * MathF.PI / 180f; // 刻度線起點和終點從外向內畫 PointF outer new PointF( cx radius * MathF.Cos(rad), cy radius * MathF.Sin(rad)); PointF inner new PointF( cx (radius - 12) * MathF.Cos(rad), cy (radius - 12) * MathF.Sin(rad)); g.DrawLine(Pens.Black, outer, inner); // 刻度值文字 float val _minValue ratio * (_maxValue - _minValue); string text val.ToString(F0); SizeF sz g.MeasureString(text, Font); PointF textPos new PointF( cx (radius - 30) * MathF.Cos(rad) - sz.Width / 2, cy (radius - 30) * MathF.Sin(rad) - sz.Height / 2); g.DrawString(text, Font, Brushes.Black, textPos); } }tickCount控制主刻度數量radius - 12決定刻度線長度radius - 30決定數字離圓心的距離。這三個參數是調圓表外觀時改得最頻繁的。指針繪制時用Pen的EndCap設為LineCap.ArrowAnchor可以省去手動畫箭頭。3.2 直表測量標尺線性映射與刻度密度直表的核心是把值域線性映射到像素區(qū)間刻度密度根據控件寬度動態(tài)計算protected override void OnDrawScale(Graphics g) { int padding 20; float drawWidth Width - padding * 2; int majorTicks 10; // 主刻度數 int minorPerMajor 5; // 每個主刻度間的小刻度數 for (int i 0; i majorTicks * minorPerMajor; i) { float ratio i / (float)(majorTicks * minorPerMajor); float x padding ratio * drawWidth; bool isMajor (i % minorPerMajor 0); float tickLen isMajor ? 12 : 6; g.DrawLine(Pens.Black, x, Height - padding, x, Height - padding - tickLen); if (isMajor) { float val _minValue ratio * (_maxValue - _minValue); string text val.ToString(F0); SizeF sz g.MeasureString(text, Font); g.DrawString(text, Font, Brushes.Black, x - sz.Width / 2, Height - padding 2); } } }minorPerMajor控制小刻度密度工控現場一般 5 或 10。直表的指針通常是一條豎直的紅色游標線繪制時直接g.DrawLine從頂部畫到底部即可。3.3 旋鈕控件鼠標拖拽角度計算與值更新旋鈕的交互邏輯是這套控件里最繞的鼠標按下時記錄起始角度移動時計算角度差映射到值變化量。protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _dragging true; _lastAngle GetMouseAngle(e.Location); // 記錄按下時的角度 } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (!_dragging) return; float currentAngle GetMouseAngle(e.Location); float delta currentAngle - _lastAngle; // 處理跨越 0/360 度的跳變 if (delta 180) delta - 360; if (delta -180) delta 360; // 角度差映射到值變化 float valueDelta delta / _sweepAngle * (_maxValue - _minValue); CurrentValue valueDelta; _lastAngle currentAngle; } private float GetMouseAngle(Point p) { float cx Width / 2f, cy Height / 2f; float dx p.X - cx, dy p.Y - cy; float angle MathF.Atan2(dy, dx) * 180f / MathF.PI; return angle 0 ? angle 360 : angle; }delta 180和delta -180這兩行是處理角度環(huán)繞的關鍵少了它鼠標從 350 度移到 10 度時旋鈕會突然反轉一大截。_sweepAngle是旋鈕的有效旋轉范圍一般設 270 度。3.4 開關與指示燈布爾量綁定與狀態(tài)切換開關和指示燈邏輯簡單但工控場景下要考慮狀態(tài)顏色可配置、點擊切換、以及跟數據源的綁定。public class IndicatorLight : Control { private bool _isOn; public bool IsOn { get _isOn; set { _isOn value; Invalidate(); } } public Color OnColor { get; set; } Color.Lime; public Color OffColor { get; set; } Color.DarkGray; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode SmoothingMode.AntiAlias; float d Math.Min(Width, Height) - 4; RectangleF rect new RectangleF((Width - d) / 2, (Height - d) / 2, d, d); using (var brush new SolidBrush(_isOn ? OnColor : OffColor)) { e.Graphics.FillEllipse(brush, rect); } // 高光效果左上角畫一個半透明白色小圓 using (var hl new SolidBrush(Color.FromArgb(80, Color.White))) { e.Graphics.FillEllipse(hl, rect.X d * 0.15f, rect.Y d * 0.15f, d * 0.3f, d * 0.3f); } } }OnColor和OffColor做成屬性方便在組態(tài)時按報警級別配不同顏色。高光小圓是讓指示燈看起來有立體感的廉價技巧成本極低但視覺效果提升明顯。4. 避坑與常見問題儀表控件開發(fā)中容易翻車的地方4.1 指針閃爍雙緩沖沒開全現象指針一動整個控件閃或者父容器背景透出來。原因只設了DoubleBuffered true但沒設AllPaintingInWmPaint導致背景擦除和繪制分兩次進行。解決在構造函數里用SetStyle把AllPaintingInWmPaint、UserPaint、OptimizedDoubleBuffer三個一起開缺一不可。4.2 刻度數字重疊半徑和字體沒聯動現象圓表刻度數字擠在一起或者跑到圓盤外面。原因數字位置寫死了像素偏移沒根據控件實際尺寸和字體大小動態(tài)算。解決用g.MeasureString量文字尺寸位置按radius - 30這種比例算控件 resize 時ResizeRedraw會自動重繪。4.3 旋鈕跳變角度環(huán)繞沒處理現象鼠標拖旋鈕經過 0 度附近時值突然跳到最大或最小。原因Atan2返回的角度在 -180 到 180 之間跨過邊界時差值出現 ±360 的跳變。解決在計算 delta 后加if (delta 180) delta - 360; if (delta -180) delta 360;把差值歸一化到 -180 到 180 之間。4.4 值更新不重繪忘了調 Invalidate現象外部代碼改了CurrentValue但界面上的指針不動。原因屬性 setter 里只賦了值沒調Invalidate()。解決所有影響外觀的屬性 setter 末尾都加Invalidate()這是自定義控件最容易漏的一行。4.5 高 DPI 下模糊沒處理縮放現象在 125% 或 150% 縮放的顯示器上儀表控件邊緣發(fā)虛。原因GDI 默認按 96 DPI 繪制系統縮放后位圖被拉伸。解決在OnPaint開頭根據DeviceDpi或Graphics.DpiX算一個縮放系數所有尺寸乘以這個系數或者給項目加app.manifest聲明 DPI 感知。5. 二次開發(fā)與改造把這套控件接進你的上位機項目5.1 從示例工程到實際項目的移植步驟這套例子給的是控件源碼移植到自己的上位機項目里我一般按這個順序走先把GaugeBase和五個控件類文件拷進項目編譯通過后在工具箱里手動添加這些控件右鍵工具箱 → 選擇項 → 瀏覽到生成的 DLL然后拖到窗體上。如果不想走工具箱直接在代碼里new GaugeBase()也行但設計時看不到效果。移植后第一件事是改命名空間第二件事是檢查Font和ForeColor的繼承鏈——自定義控件默認不繼承父容器的字體和前景色需要在構造函數里顯式設置Font Parent?.Font ?? SystemFonts.DefaultFont否則拖到窗體上字會變成默認的宋體小五。5.2 數據綁定讓儀表跟著 PLC 數據動工控上位機里儀表的值通常來自 PLC 或采集卡。最直接的做法是定時器輪詢private void timer1_Tick(object sender, EventArgs e) { // 從數據緩沖區(qū)讀最新值 float flow _dataBuffer.GetLatest(FlowRate); float pressure _dataBuffer.GetLatest(Pressure); // 更新儀表控件內部會 Invalidate gaugeFlow.CurrentValue flow; gaugePressure.CurrentValue pressure; // 指示燈根據報警狀態(tài)切換 lightHighPressure.IsOn pressure 8.0f; }定時器間隔一般設 100 到 200 毫秒太快了 GDI 重繪跟不上反而卡頓。如果數據源是事件驅動的比如串口回調直接在回調里更新控件值也行但要注意跨線程問題——WinForms 控件只能在 UI 線程訪問回調里得用Invoke或BeginInvoke切回來。5.3 外觀定制改顏色、改刻度、改指針樣式這套控件的繪制參數大多做成了屬性或 protected 字段改外觀不用動繪制邏輯。比如把圓表指針改成紅色、加粗// 在子類或設計器中設置 this.PointerColor Color.Red; this.PointerWidth 3f;如果要改刻度數量或角度范圍直接改_startAngle、_sweepAngle、tickCount這幾個字段的初始值。想加報警區(qū)比如 80 以上畫紅色弧段在OnDrawScale里加一段DrawArc就行邏輯不復雜。5.4 性能優(yōu)化多儀表同屏時的重繪策略一個畫面上放十幾個儀表控件時每個控件的Invalidate()都會觸發(fā)一次重繪CPU 占用會上去。優(yōu)化思路有兩個一是只在值真正變化時才調Invalidate()在 setter 里加個判斷public float CurrentValue { get _currentValue; set { float clamped Math.Max(_minValue, Math.Min(_maxValue, value)); if (Math.Abs(clamped - _currentValue) 0.01f) return; // 變化太小不重繪 _currentValue clamped; Invalidate(); } }二是把刷新頻率統一到一個定時器里所有儀表在同一個 tick 里更新避免多個定時器交錯觸發(fā)重繪。我一般會在主窗體里放一個 100ms 的System.Windows.Forms.Timer所有儀表的值更新都掛在這個 tick 上實測十幾個儀表同屏 CPU 占用能壓在 5% 以內。從那以后我每次接工控上位機項目都先把這套控件源碼拖進去編譯一遍確認雙緩沖和 DPI 縮放沒問題再開始畫界面。儀表控件這種東西看著簡單真到現場跑起來閃爍、跳變、模糊這三個坑一個都躲不掉提前在示例工程里踩一遍比在現場調試時抓瞎強得多。希望幫到你。本文還有配套的精品資源點擊獲取