C# WinForms自绘仪表控件:工控监控面板与交互实现

发布时间:2026/11/6 12:33:53
C# WinForms自绘仪表控件:工控监控面板与交互实现

简介:面向 C# WinForms 与工控组态界面开发的仪表控件示例包,围绕圆表、直表(测量标尺)、旋钮、开关和指示灯等仿真控件,提供可在窗体中直接搭建仪表盘的练习素材,适合希望理解 ActiveX 与 .NET 控件集成、工业界面设计的初中级开发者,也可用于课程设计或原型验证。压缩包内共有 18 个文件,主要由 6 个 C# 源码、2 个互操作 DLL、2 个 resx 资源文件及 1 个 OCX 控件组成,此外还配有项目与解决方案文件、可执行示例、说明文档和效果预览图,压缩包约 1.15MB。已有 1239 人学习,读者可先运行示例观察控件外观与交互,再对照源码学习实例化、属性设置、事件响应以及非托管 COM 组件的引用配置;说明文档和预览图有助于快速判断改造方向,便于将其迁移到自己的工控或组态项目中。

1. 一个设备监控面板的需求,把我推到了这套 C# 仪表控件上

去年接了个设备监控的小项目,客户要求界面上有圆表看压力、直表看流量、旋钮调给定值、开关切模式、指示灯报状态,一套工控组态面板该有的视觉元素一个都不能少。商业控件库授权费报过来直接劝退,于是翻开源代码,找到这套用 C# 写的仪表控件例子——圆表、直表(测量标尺)、旋钮、开关、指示灯全部内聚在 WinForms 自绘控件里,没有第三方依赖,双击解决方案就能跑。它解决的不是"学控件原理",而是"明天就要交一个有仪表盘的监控页面"。适合手上有一个 C# WinForms 上位机、需要快速补齐仿真仪表外观的开发者,也适合想看懂 GDI+ 自绘控件链路的人。

2. 架构选型:为什么这套仪表走的是 WinForms 自绘而不是 ActiveX 包装

先把技术栈摆清楚。这套例子的运行环境是 .NET Framework 的 WinForms,渲染层走 GDI+,控件继承自System.Windows.Forms.Control,通过重写OnPaint把刻度、指针、滑块一笔笔画出来。它没有走 ActiveX 封装,也没有用 WPF 的矢量体系——这不是偷懒,而是工程约束下的取舍:工控现场的上位机很多还在 Win7 + .NET Framework 4.x 上跑,WPF 的渲染和部署成本高,ActiveX 又要处理 COM 注册和 32/64 位冲突,最稳的反而是 WinForms 自绘,控件往窗体上一拖就能用,还能直接嵌进老组态页面。

2.1 控件继承体系:从 Control 到 GaugeBase 的抽象

整套控件不是各写各的OnPaint,而是先抽了一层公共基类。刻度起点、量程、端点样式、数值合法性校验这些东西每类仪表都要用,放到基类里能省掉一大半重复代码。常见做法是定义一个抽象的GaugeBase,让它继承Control,把"量程-数值"这条主链路统一起来,再让GaugeCircleControl、LinearGaugeControl各自去实现"怎么画"。

// 公共基类:只负责数值区间和重绘触发,不含任何绘制细节 public abstract class GaugeBase : Control { private double _minValue = 0; private double _maxValue = 100; private double _value = 0; public double MinValue { get => _minValue; set { _minValue = value; Invalidate(); } // 量程变了必须重绘 } public double MaxValue { get => _maxValue; set { _maxValue = value; Invalidate(); } } public double Value { get => _value; set { // 关键:先把值夹到量程内,再判断是否真的变了 double clamped = Math.Max(_minValue, Math.Min(_maxValue, value)); if (Math.Abs(clamped - _value) < 1e-9) return; _value = clamped; Invalidate(); // 触发重绘 OnValueChanged(EventArgs.Empty); // 抛给外部业务 } } public event EventHandler ValueChanged; protected virtual void OnValueChanged(EventArgs e) => ValueChanged?.Invoke(this, e); // 子类各自实现绘制 protected override void OnPaint(PaintEventArgs e) => base.OnPaint(e); }

这段基类有两点值得注意。第一,Value的 setter 里做了夹取(clamp),越界值不会传进绘制层,避免指针算出来飞到控件外面——这是后面避坑章节会展开的血泪经验。第二,每次赋值都调用Invalidate(),用消息循环合并重绘,而不是直接Refresh()同步刷,拖动滑块时 CPU 占用会低一个量级。子类只需要关心"给定一个 value,怎么映射成角度或像素"。

2.2 公开属性与事件的设计

控件要能被设计器识别、被属性面板改、被业务代码监听,属性就不能随机命名。这套例子里给绘图相关字段都挂了Category和Description特性,属性面板才会分组显示;数值变化通过ValueChanged事件抛出去,业务侧只订阅事件、不直接读控件内部状态。常见做法是再补一个Tag字段做业务绑定,比如把设备点位 ID 塞进Tag,事件回调里拿Tag去查数据库或推给 PLC。

[Category("仪表参数"), Description("表盘刻度数量")] public int TickCount { get; set; } = 10; [Category("外观"), Description("指针颜色")] public Color NeedleColor { get; set; } = Color.Red; // 业务侧订阅方式 circleGauge.ValueChanged += (s, e) => { var id = ((GaugeBase)s).Tag?.ToString(); // 用 id 去找对应点位,做阈值报警、写日志 };

属性的 setter 里一定要带来Invalidate(),只写字段不刷新是新手最常翻的车——属性面板里改了颜色,界面上纹丝不动,还以为是设计器坏了。

2.3 设计器支持与工具箱注册

控件拖不进工具箱是这套例子被问得最多的问题之一。WinForms 自绘控件要被 Visual Studio 的设计器认出来,至少需要三件事:类必须是public且有无参构造函数、项目要能被设计器加载、控件要出现在工具箱里。前两个靠代码规范,第三个靠工具箱手动"选择项"或加ToolboxItem。很多同学的解决方案里控件类被写在别的内部类里、或者带参数的构造函数把默认构造覆盖了,设计器反射不到就直接跳过。

// 显式声明工具箱项,避免设计器版本差异导致的不可见 [ToolboxItem(true)] public class GaugeCircleControl : GaugeBase { public GaugeCircleControl() { // 开启双缓冲,减少拖动闪烁(后面避坑章节细讲) SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true); } }

SetStyle这一行很多人抄了但没理解:AllPaintingInWmPaint把擦背景和绘制合并到 WM_PAINT,OptimizedDoubleBuffer开后备缓冲,UserPaint声明自己负责全部绘制。三个标志配合起来才能消除指针转动时的闪烁,只开其中一个效果有限。

3. 圆表与直表:刻度盘、指针、量程的绘制链路

这两类控件是整套例子的核心。圆表负责压力、温度这类单值读数,直表(测量标尺)负责流量、液位这种带线性刻度的量。它们共享"数值→几何位置"的映射思想,但坐标系完全不同:圆表是极坐标,直表是笛卡尔坐标。看懂这两套映射,其余控件都能照葫芦画瓢。

3.1 圆表:把数值映射成圆弧角度

GDI+ 的DrawArc角度有个容易踩的坑:0 度在 3 点钟方向,顺时针为正。工控圆表习惯从 7 点半方向开始、扫 270 度,换算下来起始角是 135 度、扫过角是 270 度。指针端点用三角函数算,千万不要直接用弧度混着角度算,会得到完全对不上的位置。

protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 抗锯齿,否则刻度毛刺明显 int cx = Width / 2, cy = Height / 2; int radius = Math.Min(cx, cy) - 10; // 留 10px 边距,避免贴边裁切 const float startAngle = 135f; // 起始角:7 点半方向 const float sweepAngle = 270f; // 总扫角 // 1) 外圈弧线 var rect = new Rectangle(cx - radius, cy - radius, radius * 2, radius * 2); using (var pen = new Pen(Color.Gray, 2)) g.DrawArc(pen, rect, startAngle, sweepAngle); // 2) 刻度线:把量程均分成 TickCount 份 using (var tickPen = new Pen(Color.Black, 1)) { for (int i = 0; i <= TickCount; i++) { float ratio = (float)i / TickCount; double angleDeg = startAngle + ratio * sweepAngle; double rad = angleDeg * Math.PI / 180.0; // 刻度内端点、外端点 float x1 = cx + (float)((radius - 10) * Math.Cos(rad)); float y1 = cy + (float)((radius - 10) * Math.Sin(rad)); float x2 = cx + (float)(radius * Math.Cos(rad)); float y2 = cy + (float)(radius * Math.Sin(rad)); g.DrawLine(tickPen, x1, y1, x2, y2); } } // 3) 指针:按当前 Value 落在量程里的比例换算角度 double ratioNow = (Value - MinValue) / (MaxValue - MinValue); double needleDeg = startAngle + ratioNow * sweepAngle; double needleRad = needleDeg * Math.PI / 180.0; float nx = cx + (float)((radius - 25) * Math.Cos(needleRad)); float ny = cy + (float)((radius - 25) * Math.Sin(needleRad)); using (var needlePen = new Pen(NeedleColor, 3)) g.DrawLine(needlePen, cx, cy, nx, ny); // 4) 中心轴帽 g.FillEllipse(Brushes.DarkSlateGray, cx - 5, cy - 5, 10, 10); }

逐段看一眼。外圈弧线决定了圆表的视觉边界,rect是内切圆矩形,半径减去边距是为了不让刻度贴到控件边上被裁掉。刻度循环里ratio从 0 到 1,映射成角度后再用cos/sin转成 x/y 坐标,所有刻度端点都落在同一圆弧上,这是极坐标映射的典型用法。指针部分和刻度共用同一套映射,只是半径短了 25px 留出中心帽位置。

startAngle和sweepAngle一般会做成公开属性,业务侧有的表需要从 0 度开始扫、有的要扫满 360 度,写死就失去了通用性。

3.2 直表(测量标尺):线性刻度与滑块的分离

直表跟圆表不同,它是线性映射,但工程上有个细节值得单独拎出来:刻度尺和滑块(测量标尺)视觉上应该分成两层,刻度是背景层,滑块是前景层,这样滑块拖动时只需要重绘前景,刻度不用重算。刻度线的绘制用同一个循环思路,滑块位置按比例映射到控件宽度即可。

protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; int padLeft = 20, padRight = 20; int trackWidth = Width - padLeft - padRight; int trackY = Height / 2; // 1) 轨道底线 using (var trackPen = new Pen(Color.LightGray, 6)) g.DrawLine(trackPen, padLeft, trackY, Width - padRight, trackY); // 2) 刻度:沿轨道均分刻度线,长短交替 using (var tickPen = new Pen(Color.Black, 1)) { for (int i = 0; i <= TickCount; i++) { float x = padLeft + trackWidth * (float)i / TickCount; int h = (i % 5 == 0) ? 12 : 6; // 每 5 格画长刻度 g.DrawLine(tickPen, x, trackY - h, x, trackY + h); } } // 3) 滑块:Value 映射成 X 坐标 float ratio = (float)((Value - MinValue) / (MaxValue - MinValue)); float sliderX = padLeft + trackWidth * ratio; using (var sliderBrush = new SolidBrush(SliderColor)) g.FillRectangle(sliderBrush, sliderX - 4, trackY - 14, 8, 28); }

轨道和刻度用同一个trackWidth,滑块位置也用同一个trackWidth,三者坐标体系对齐了才不会出现"刻度画到 100、滑块却停在 95"这种尴尬。i % 5 == 0是常见的主次刻度区分,实际项目里主刻度还可以加数字标签,用DrawString在刻度下方居中画。

直表还有一种变体是垂直方向的,把trackWidth换成trackHeight、x和y互换即可,映射逻辑完全一致。

3.3 重绘与双缓冲:让指针拖动不闪

仪表控件最影响体验的是闪烁。拖动值的时候如果每次Value变化都触发完整擦除再重绘,指针会像抽风一样闪。前面构造函数里的SetStyle是第一步,第二步是把所有绘制收进OnPaint、别在Invalidate前后手动调CreateGraphics画东西——手动CreateGraphics画出来的内容不受双缓冲管理,会和OnPaint打架。

// 错误示范:手动 CreateGraphics 绘制,双缓冲无效 var g = CreateGraphics(); g.DrawLine(Pens.Red, 0, 0, 100, 100); // 正确做法:只改状态,交给 OnPaint 画 Value = 42.5; // 内部 Invalidate() 触发重绘

另一个常被忽略的是ResizeRedraw。不设ControlStyles.ResizeRedraw的话,控件尺寸变化后旧内容会残留,拖动窗口边缘时会看到残影。加上这个标志后,尺寸变化自动触发全量重绘。

4. 旋钮、开关、指示灯:把鼠标事件翻译成状态机

圆表和直表是"显示型"控件,被动接收数值;旋钮、开关、指示灯是"交互型"控件,要主动响应鼠标,还得把状态回写给业务。这三类控件看上去简单,实际翻车点集中在鼠标事件和状态同步上。

4.1 旋钮:按下、拖动、释放三段式角度计算

旋钮的核心是把鼠标位置换算成旋转角度,再映射到量程。听上去简单,但关键在于抓取起始角度:如果直接用鼠标当前位置算绝对角度,第一次点击时旋钮会瞬间跳到鼠标方向,体验极差。正确做法是鼠标按下时记录"当前旋钮角度"和"鼠标相对中心的角",移动时算角度差,叠加到起始角度上。

private bool _dragging; private double _startAngleDeg; private Point _startMousePt; protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _dragging = true; _startMousePt = e.Location; _startAngleDeg = ValueToAngle(Value); // 记录当前角度作为拖动基准 } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (!_dragging) return; // 鼠标相对控件中心的极角 double mouseRad = Math.Atan2(e.Y - Height / 2, e.X - Width / 2); double startRad = Math.Atan2(_startMousePt.Y - Height / 2, _startMousePt.X - Width / 2); double deltaDeg = (mouseRad - startRad) * 180.0 / Math.PI; double newAngle = _startAngleDeg + deltaDeg; // 夹到 [MinAngle, MaxAngle] 之间,避免无限旋转 newAngle = Math.Max(MinAngle, Math.Min(MaxAngle, newAngle)); Value = AngleToValue(newAngle); // 内部会 Invalidate } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _dragging = false; }

AngleToValue和ValueToAngle是一对互逆映射,要把角度扫过的范围(比如 0~270 度)和量程(比如 0~100)对应起来。这一对函数一定要保证可逆,否则旋钮转到最大值回不到 100,或者和圆表显示的值对不上。我一般会把它们写成纯函数单独测试,避免拖动时才发现对不齐。

_startMousePt必须用e.Location,不能用MousePosition(屏幕坐标),否则控件在容器里的偏移会让角度算错。

4.2 开关:两态与三态的位运算切换

开关控件本质是状态机。两态开关就是bool,三态开关(自动/手动/停止)用枚举。工控场景里开关经常需要"闪烁确认"——点击后不立即切换,而是等待业务侧确认再变状态,防止误操作。

public enum SwitchState { Off, On, Auto } public class ToggleSwitchControl : Control { public SwitchState State { get; private set; } = SwitchState.Off; public event EventHandler<SwitchState> StateChanged; protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); // 按顺序循环切换三态;两态场景把 States 改成 new[]{Off,On} var states = new[] { SwitchState.Off, SwitchState.On, SwitchState.Auto }; int idx = System.Array.IndexOf(states, State); State = states[(idx + 1) % states.Length]; Invalidate(); StateChanged?.Invoke(this, State); } protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; var rect = new Rectangle(2, Height / 3, Width - 4, Height / 3); var bg = State == SwitchState.On ? Color.LimeGreen : State == SwitchState.Auto ? Color.Orange : Color.Gray; using (var b = new SolidBrush(bg)) e.Graphics.FillRectangle(b, rect); // 滑块位置随状态移动 int knobW = Width / 3; int knobX = State == SwitchState.Off ? 2 : Width - knobW - 2; e.Graphics.FillEllipse(Brushes.White, knobX, Height / 4, knobW, Height / 2); } }

OnMouseUp里做状态切换而不是OnMouseDown,是为了避免用户"按下才发现点错了"时已经触发业务动作。事件参数直接传SwitchState比传object更安全,订阅方不用做类型判断。

三态开关如果要在属性面板里暴露给设计器,枚举类型必须写在独立的公开类里,嵌套在控件内部的枚举在旧版设计器里偶尔识别不出来。

4.3 指示灯:颜色映射和闪烁计时器

指示灯是工控面板里最不显眼但最容易被骂的控件。业务侧的逻辑是"设备在线亮绿、故障亮红、待机亮黄",控件只要负责把颜色和闪烁状态画出来。闪烁用System.Windows.Forms.Timer驱动,间隔通常 500ms 到 1s,间隔太短看着像卡顿。

public class IndicatorLightControl : Control { private readonly Timer _blinkTimer = new Timer { Interval = 500 }; private bool _blinkOn; public Color LightColor { get; set; } = Color.LimeGreen; public bool BlinkEnabled { get; set; } public IndicatorLightControl() { SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true); _blinkTimer.Tick += (s, e) => { _blinkOn = !_blinkOn; Invalidate(); }; } protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; int d = Math.Min(Width, Height) - 8; // 闪烁时用背景色填充,形成"灭"的状态 var c = (BlinkEnabled && !_blinkOn) ? SystemColors.Control : LightColor; using (var brush = new SolidBrush(c)) e.Graphics.FillEllipse(brush, 4, 4, d, d); // 高光点,让指示灯有立体感 e.Graphics.FillEllipse(Brushes.White, d / 3, d / 4, d / 6, d / 6); } }

BlinkEnabled变化时要么重启计时器、要么在 setter 里判断,不能只在构造函数里启一次——不然业务侧一开始不闪的灯后来要闪,就永远闪不起来。_blinkTimer要随控件生命周期销毁,如果控件被频繁创建销毁(比如动态报表页),忘了Dispose会导致计时器泄漏,最后表现为窗口关不掉。

5. 常见问题排查:设计器、越界、闪烁、DPI 五类坑

这章是我这两年用这套控件最常被问到的问题清单,每一条都对应真实的失败现场。按"现象→原因→解决"的顺序写,方便对照排查。

现象一:控件拖不进工具箱。属性面板里找不到这个控件,右键"选择项"也加载不出来。原因通常是控件类所在程序集被设计器拒绝加载——可能是构造函数有参数、或者是internal类、或者项目目标框架和设计器版本不匹配。解决:确认类是public、有无参构造、Build一遍再从工具箱"选择项"里浏览 dll;实在加载不了就把控件先拖到窗体上,改成运行时new出来Controls.Add,绕开设计器。

// 运行时创建,绕过设计器加载问题 var gauge = new GaugeCircleControl { MinValue = 0, MaxValue = 100, Value = 50 }; gauge.Bounds = new Rectangle(20, 20, 200, 200); this.Controls.Add(gauge);

现象二:传到界面的值超过量程,指针飞出控件。现象是表盘上突然冒出一条长线,直到控件边外。原因是业务侧直接Value = someRawValue,rawValue 可能是每个量程范围一致但单位不同的原始值(比如 0~65535 的 AD 采样),这时候直接赋给 0~100 的圆表就会越界。解决有两个:控件内部Valuesetter 做 clamp(推荐,兜底),业务侧做单位归一化。两个都做最稳。

现象三:拖动值或改变窗口大小时界面闪烁。表现为指针或者刻度抖动。原因是没有开启双缓冲,或者绘制时在OnPaint之外用了CreateGraphics。解决:SetStyle打开OptimizedDoubleBuffer | AllPaintingInWmPaint | UserPaint,所有绘制收进OnPaint,Invalidate()代替Refresh()。

现象四:高 DPI 屏幕上刻度和文字模糊或错位。现象是在 125%、150% 缩放的笔记本上,控件边缘发虚、刻度被截掉。原因是 WinForms 默认按 96 DPI 布局,控件用像素硬编码坐标。解决:控件内部所有尺寸按Width/Height比例算,不要写死像素;必要时在app.manifest里声明 DPI 感知,或者窗体设AutoScaleMode = Dpi。我一般会把刻度半径用Math.Min(Width, Height) * 0.4这类相对值算,兼容性会好很多。

现象五:属性面板改了颜色但界面没变。现象是设计时能改、运行时改了属性面板里的颜色,控件视觉不变。原因是属性的 setter 只赋了后台字段、没有触发重绘。解决:所有影响绘制的 setter 最后补一句Invalidate()。这个坑很基础但每月都有人踩,写完自定义属性记得回头看有没有漏刷新。

6. 二次开发:照着"映射-绘制-交互"三段式加一个新控件

加新控件的套路,我总结成三段式:先定映射(数值怎么变位置),再定绘制(位置怎么画出来),最后定交互(鼠标和值怎么互相驱动)。这套例子里现有五个控件都按这个模式拆解,补一个"柱状液位计"或"半圆表"基本不用改架构。

以加一个"垂直柱状液位计"为例。第一步,映射层从GaugeBase继承,Value到高度的比例是(Value - Min) / (Max - Min)。第二步,绘制层在OnPaint里画底槽、画刻度、画填充柱,填充高度按比例乘控件高度。第三步,交互层如果需要点击定位,就重写OnMouseDown,把点击 Y 坐标反算成值,赋给Value触发重绘。

public class ColumnLevelControl : GaugeBase { protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; int pad = 6; var track = new Rectangle(pad, pad, Width - pad * 2, Height - pad * 2); // 底槽 using (var bg = new SolidBrush(Color.FromArgb(30, 0, 0, 0))) g.FillRectangle(bg, track); // 按 Value 比例填充 float ratio = (float)((Value - MinValue) / (MaxValue - MinValue)); int fillH = (int)(track.Height * ratio); var fill = new Rectangle(track.X, track.Bottom - fillH, track.Width, fillH); using (var b = new SolidBrush(Color.DeepSkyBlue)) g.FillRectangle(b, fill); // 刻度数字,每 20% 一个 for (int i = 0; i <= 5; i++) { int y = track.Bottom - track.Height * i / 5; float val = (float)(MinValue + (MaxValue - MinValue) * i / 5.0); g.DrawString(val.ToString("F0"), Font, Brushes.Black, track.Right + 2, y - 8); } } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); int pad = 6; int trackH = Height - pad * 2; float ratio = 1f - (float)(e.Y - pad) / trackH; // Y 轴向上是增大 Value = MinValue + ratio * (MaxValue - MinValue); // setter 内部已 clamp } }

代码里有两处值得注意。一是刻度数字的坐标用track.Right + 2,把标签放在柱体右侧而不是压在填充色上,可读性更好。二是OnMouseDown里的比例反算用1f - ...把屏幕坐标系(Y 向下增大)翻成数值坐标系(Y 向上增大),写漏了就会点击顶部反而变小,这种方向性的问题看代码很难发现、必须手动点一遍验证。

验证方法我一般走三步:先用new出来塞进一个临时 Form,手工拖几下看映射是否单调、刻度数值和实际值是否一致;再挂ValueChanged事件打日志,确认拖动过程中数值平滑变化、不出现跳跃;最后接一个定时器把值从 0 拉到最大值再拉回来,看边界处有没有卡死或指针越界。三步走完,这个控件才算能进业务页面。

新增控件最容易犯的错是直接抄圆表的OnPaint改改坐标,结果startAngle、sweepAngle这些语义完全不同的字段被一起抄过来,后面改值映射时怎么调都不对。我现在的习惯是:新控件先只写OnPaint画个最简单的填充块,跑通了再把刻度和交互一层层加进去——每加一层就单独验证一次映射,别一上来就把整块逻辑糊上去。后来每个加到这套项目里的控件,我都强制先跑一遍"0 → 最大 → 0"的定时器验证再合入,血泪经验告诉我这一步省不得。

希望这套拆解能帮到你,拿过去跑起来、改成自己项目需要的仪表。

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