ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# WinForms自定义控件实战:GDI+绘制多状态循环泵监控界面

C# WinForms自定义控件实战:GDI+绘制多状态循环泵监控界面 做上位机监控界面的同行应该都有同感设备状态可视化这件事看着简单做起来全是细节。前阵子我接了一个循环水系统监控项目现场要求把每台循环泵的状态在C# WinForms上位机界面上直观地体现出来——停止、运行、故障、正转、反转最好叶轮还有动态旋转效果。我翻遍手头的控件库没有现成的多状态循环泵自定义控件网上找的方案也多是静态图片切换或依赖重型仪表库都不太贴合需求。于是干脆自己动手写一个。这个决定后来被证明是对的。这篇文章先记录第一阶段的成果状态模型怎么设计、GDI绘制怎么实现、设计期体验怎么打磨。搞上位机开发、设备监控界面或者经常和WinForms自定义控件打交道的朋友可以参考我的思路少走一些弯路。1. 为什么我会在WinForms里自己画一个泵而不是贴图或买现成仪表库先聊一个最容易被问的问题一个泵的图标而已网上找个PNG用PictureBox切图不就完事了吗为什么非要写代码画我在前几个项目里确实用过贴图方案踩过的坑可以列一长串。最典型的是尺寸缩放问题界面设计阶段控件大小是120×120到了现场客户觉得太小要拉到180×180静态图片一放大就糊锯齿感非常明显工控大屏上特别难看。如果准备多套分辨率图片资源和代码量就开始失控。更麻烦的是状态数量一多图片资源几乎成倍增长停止一张、运行一张、故障一张、正转一张、反转一张如果还要区分运行中警告这种组合状态那得多少张图而且静态图片表达不了旋转动画除非准备一整组角度帧序列那工作量我光是想想就头疼。第三方仪表控件库我也评估过。功能确实强大但为了一个水泵图形引入几百KB甚至几MB的依赖对一个只做设备监控的项目来说有点重而且商业仪表控件的设计语言偏向仪器仪表盘泵这种管道机械语义匹配并不好。更现实的问题是如果后续项目换了UI框架或者客户要求整体换肤第三方控件的定制成本会变得非常不可控。自绘GDI这条路本质上是把渲染逻辑写进代码。它的优势在设备监控场景里非常明显图片是死的代码是活的控件尺寸随便拉都不变形因为没有像素资源参与全部图形都是按控件当前大小实时计算出来的。旋转效果只需要一个角度变量不断累加再重绘状态切换只需要改变一个枚举值颜色定制只需要暴露几个属性。磁盘上不增加任何资源文件发布程序集体积几乎不变。代价是前期开发量确实比贴图大一点但一旦做成通用控件后面每个项目都能复用这笔账怎么算都划算。三种方案我整理成了一张对比表方便大家做技术选型参考维度静态贴图切换第三方仪表库自绘控件任意缩放清晰度差图片拉伸模糊取决于控件实现优按尺寸实时计算动态旋转效果差需要帧序列图部分支持优角度变量驱动状态扩展成本高每状态多张图受库模型限制低加枚举和颜色依赖与体积低高极低语义贴合度一般偏向仪表盘完全自定义跨项目复用每次重做跟库走一个程序集通用选型结论很明确只要是上位机、组态、设备监控这类需要设备状态动态效果跨项目复用的场景自绘控件是长期最优解。贴图适合快速原型验证第三方库适合有预算且需求标准化的商业项目自绘控件适合想要掌控力的团队和个人开发者。2. 先设计状态模型和对外属性再谈画图的事很多人写自定义控件容易犯一个顺序错误上来就写OnPaint画图图画得差不多了才想起来加状态结果每个状态的新需求都要重新改一遍绘制逻辑。正确顺序应该反过来——先把状态空间和属性接口定义清楚再考虑每个状态长什么样。因为绘制逻辑只是状态到视觉的映射状态模型定了绘制代码反而简单。2.1 状态枚举与运行模式枚举循环泵这个设备最基本的状态就是停止、运行、故障做循环水系统监控的时候往往还要加一个警告/备用状态用来表示水温偏高但还没有触发停机保护。运行方向在泵选型时可能就固定了但我做这个控件时把正转和反转也放进了运行模式里因为客户的监控系统里既有循环泵也有备用水泵双向泵的预冷循环用得上。public enum PumpRunState { Stopped 0, Running 1, Warning 2, Fault 3 } public enum PumpRunMode { Forward 0, Reverse 1 }这里有个小建议枚举成员的数值尽量显式指定而不是依赖默认的0、1、2自动编号尤其是将来要和PLC点位、Modbus寄存器通信协议映射的场景。现场设备传来的状态字可能是3表示故障、5表示运行中到时候直接强转枚举就能对上不需要额外写一堆if-else做翻译。我吃过这个亏早期图省事没显式写结果协议调整后全部状态映射要重写教训深刻。2.2 状态到视觉表现的映射设计状态模型定下来之后需要画一张映射表把每个状态对应的颜色、动画、指示逻辑写清楚。这是整个控件开发的规划图后面所有绘制代码都围绕这张表展开。状态泵体主色叶轮旋转指示灯流向箭头停止灰色不转灰色显示但淡化运行绿色系按方向旋转绿色常亮亮色显示警告黄色系按方向旋转黄色常亮亮色显示故障红色系不转红色常亮隐藏或闪烁一开始我把故障状态设计成红色叶轮继续转因为想着设备故障了可能还在惯性转动但客户看了样机后明确反对故障状态的第一优先级是快速识别异常如果叶轮还在转操作员扫一眼容易误判为运行中。最后改成故障时叶轮锁止信息传达反而更清晰。这个细节也说明一个道理状态视觉设计的优先级是可快速区分的差异性而不是物理真实感。2.3 核心属性的实现细节属性设计是整个控件的门面怎么让属性在属性窗口里好用、怎么让设计器能正确序列化直接决定这个控件专业不专业。下面是我这个PumpStateControl的核心属性代码[DefaultEvent(nameof(StateChanged))] public class PumpStateControl : Control { private PumpRunState _runState PumpRunState.Stopped; private PumpRunMode _runMode PumpRunMode.Forward; private Color _bodyColor Color.FromArgb(82, 130, 165); private Color _runningColor Color.FromArgb(62, 155, 92); private Color _faultColor Color.FromArgb(198, 72, 72); private Color _warningColor Color.FromArgb(218, 165, 48); private Color _stopColor Color.FromArgb(142, 142, 142); private bool _showStatusText true; private bool _showFlowArrow true; public event EventHandler? StateChanged; [Category(PumpState)] [Description(泵当前运行状态控制控件的颜色、动画与指示灯)] [DefaultValue(PumpRunState.Stopped)] public PumpRunState RunState { get _runState; set { if (_runState value) return; _runState value; UpdateTimerByState(); StateChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } } [Category(PumpState)] [Description(泵的转向模式Forward为顺时针Reverse为逆时针)] [DefaultValue(PumpRunMode.Forward)] public PumpRunMode RunMode { get _runMode; set { if (_runMode value) return; _runMode value; Invalidate(); } } [Category(PumpState)] [Description(泵体主色只在停止状态以外的状态下参与显示)] public Color BodyColor { get _bodyColor; set { _bodyColor value; Invalidate(); } } [Category(PumpState)] [Description(运行状态主体颜色)] public Color RunningColor { get _runningColor; set { _runningColor value; Invalidate(); } } // 其他状态色属性类似不再重复 }这里有一个很有用的属性特性组合Category让属性在PropertyGrid中分到自定义的PumpState分类里而不是默认的Misc杂项DefaultValue让属性窗口在值等于默认值时显示正常、不被加粗也告诉设计器这个值不需要序列化到InitializeComponent。对于枚举和基本数值类型DefaultValue很好用但对于Color这类结构体DefaultValue不太好用我通常改用ShouldSerializeXxx方法控制序列化private bool ShouldSerializeBodyColor() _bodyColor ! Color.FromArgb(82, 130, 165); private bool ShouldSerializeRunningColor() _runningColor ! Color.FromArgb(62, 155, 92); private bool ShouldSerializeFaultColor() _faultColor ! Color.FromArgb(198, 72, 72); private bool ShouldSerializeWarningColor() _warningColor ! Color.FromArgb(218, 165, 48);这种方式的意思是只有当属性值跟初始默认值不一致时设计器才把赋值语句生成到窗体代码里否则就用构造函数里的默认值。这样窗体设计器代码干净很多不会出现一大串无意义的颜色赋值。这是WinForms自定义控件里非常经典的一个细节。2.4 事件通知与上位机跨线程更新的坑StateChanged事件看起来简单但设计时有一个很实际的问题上位机场景里PLC数据回调、Modbus轮询线程、OPC消息往往都在后台线程如果直接把收到的状态值赋给RunState属性控件是在非UI线程被修改的运行时会抛InvalidOperationException跨线程异常或者偶发界面更新错乱。正确的做法是在赋值前用Control.BeginInvoke回到UI线程// 假设这是Modbus收到后的回调方法 private void OnPlcDataReceived(int pumpId, int rawState) { if (_pumpControl.IsHandleCreated) { _pumpControl.BeginInvoke(new Action(() { _pumpControl.RunState (PumpRunState)rawState; })); } }注意用BeginInvoke而不是Invoke因为回调线程不应该被UI卡住异步投递状态更新即可。这个习惯我在所有上位机控件里都保持所以在控件设计上把RunState的setter设计成幂等值相同直接返回还减少了很多重复的无效重绘。3. 从零画泵泵体轮廓、叶轮旋转动画的GDI实现状态模型设计好接下来才是重头戏绘制。这一章我会把绘制代码逐步拆解包含布局思路、泵体画法、叶轮旋转的核心原理以及动画刷新策略。这些代码是我第一版里反复调整过的可以直接抄。3.1 几何布局用百分比计算永远不要写死像素自绘控件最容易犯的错误之一是把坐标写死比如g.DrawEllipse(pen, 20, 20, 80, 80)。设计器里拖到130×130就乱了各种元素比例完全失调。我在控件里坚持一个原则所有几何元素都基于控件当前ClientSize按比例计算这样在任何尺寸下都能自动适配。核心是一个存放当前绘制几何参数的私有方法private const float PADDING_RATIO 0.06f; private const float TEXT_HEIGHT_RATIO 0.16f; private void GetLayout(out RectangleF body, out RectangleF textRect, out PointF center, out float bodyRadius) { var padding Math.Min(Width, Height) * PADDING_RATIO; var scale DeviceDpi / 96f; textRect new RectangleF(padding, Height - Height * TEXT_HEIGHT_RATIO padding / 2f, Width - padding * 2f, Height * TEXT_HEIGHT_RATIO); body new RectangleF(padding, padding, Width - padding * 2f, Height - Height * TEXT_HEIGHT_RATIO); center new PointF(body.Left body.Width / 2f, body.Top body.Height / 2f); bodyRadius Math.Min(body.Width, body.Height) / 2f - 4f * scale; }这个布局把整个控件分成上下两部分上方是泵体圆形区底部留出一小条状态文字区。泵体圆的半径取了宽度和高度的较小值保证椭圆变成长方形时泵体不会变形。底部文字区用单独的比例控制文字不会被泵体压住。3.2 泵体、进出水口和状态指示灯的绘制循环泵的辨识度主要来自一个圆形泵体左右两侧进出水口。如果只画一个圆和电机、风机之类的图标区分度不高。进出水口的画法是这样的泵体左右各伸出一段短管管口稍微内收上面有法兰的装饰线。private void DrawPumpShell(Graphics g, RectangleF body, PointF center, float bodyRadius) { var stateColor GetStateColor(); var shellRect new RectangleF(center.X - bodyRadius, center.Y - bodyRadius, bodyRadius * 2f, bodyRadius * 2f); using var shellPen new Pen(stateColor, Math.Max(2f, bodyRadius * 0.14f)); using var shellFill new SolidBrush(Lighten(stateColor, 0.15f)); using var outLinePen new Pen(Color.FromArgb(60, 0, 0, 0), Math.Max(1f, bodyRadius * 0.03f)); // 泵体底圆填充 g.FillEllipse(shellFill, shellRect); g.DrawEllipse(outLinePen, shellRect); // 泵体外圈加强环 g.DrawEllipse(shellPen, shellRect); }进出水口的绘制稍微麻烦一点需要根据泵体半径和中心点动态算出左右伸出矩形的坐标。我用的方法是从中心点往左右各延伸bodyRadius为管道中线管道宽度约为bodyRadius * 0.45。法兰用两个小圆弧表示private void DrawInletOutlet(Graphics g, RectangleF body, PointF center, float bodyRadius) { var pipeColor GetStateColor(); var pipeWidth bodyRadius * 0.45f; var pipeLength body.Width * 0.28f; using var pipeBrush new SolidBrush(Lighten(pipeColor, 0.25f)); using var pipePen new Pen(Color.FromArgb(70, 0, 0, 0), Math.Max(1f, bodyRadius * 0.04f)); // 左侧进水口 var leftRect new RectangleF(body.Left, center.Y - pipeWidth / 2f, pipeLength, pipeWidth); // 右侧出水口 var rightRect new RectangleF(body.Right - pipeLength, center.Y - pipeWidth / 2f, pipeLength, pipeWidth); g.FillRectangle(pipeBrush, leftRect); g.FillRectangle(pipeBrush, rightRect); // 法兰装饰线 g.DrawLine(pipePen, body.Left pipeLength * 0.45f, center.Y - pipeWidth / 2f, body.Left pipeLength * 0.45f, center.Y pipeWidth / 2f); g.DrawLine(pipePen, body.Right - pipeLength * 0.45f, center.Y - pipeWidth / 2f, body.Right - pipeLength * 0.45f, center.Y pipeWidth / 2f); }如果想表现流向可以在管道中间画一个三角箭头并根据RunMode的方向改变三角形的指向。这里的代码先不展开在第二篇里我会把流向箭头的动画和水流经过的效果一起补上。状态指示灯放在泵体右上角一个外圈略深、内部亮色的小圆点视觉上像设备面板上的LED灯private void DrawIndicator(Graphics g, PointF center, float bodyRadius) { var indicatorSize bodyRadius * 0.22f; var indicatorPos new PointF(center.X bodyRadius * 0.78f - indicatorSize / 2f, center.Y - bodyRadius * 0.78f - indicatorSize / 2f); using var outerBrush new SolidBrush(Color.FromArgb(130, 0, 0, 0)); using var innerBrush new SolidBrush(GetStateColor()); using var glowPen new Pen(Color.FromArgb(70, GetStateColor()), Math.Max(1f, bodyRadius * 0.06f)); g.FillEllipse(outerBrush, indicatorPos.X - 2, indicatorPos.Y - 2, indicatorSize 4, indicatorSize 4); g.FillEllipse(innerBrush, indicatorPos.X, indicatorPos.Y, indicatorSize, indicatorSize); g.DrawEllipse(glowPen, indicatorPos.X, indicatorPos.Y, indicatorSize, indicatorSize); }3.3 叶轮旋转动画核心的坐标变换技巧叶轮是整个控件最有动态表现力、也最容易画砸的部分。我的实现思路是把坐标原点平移到泵体中心然后旋转一个角度变量在旋转后的坐标系里画叶轮叶片最后恢复坐标系。这样每次重绘只需要改角度叶片形状代码完全不用动。private void DrawImpeller(Graphics g, PointF center, float bodyRadius) { var scale DeviceDpi / 96f; var state g.Save(); g.TranslateTransform(center.X, center.Y); g.RotateTransform((float)_rotationAngle); var leafCount 3; var leafLength bodyRadius * 0.68f; var hubRadius bodyRadius * 0.16f; using var bladePen new Pen(Color.FromArgb(220, 0, 0, 0), Math.Max(1.2f, bodyRadius * 0.05f)); for (int i 0; i leafCount; i) { var baseAngle i * 360f / leafCount; using var bladePath new GraphicsPath(); var outerRect new RectangleF(-leafLength, -leafLength, leafLength * 2f, leafLength * 2f); bladePath.AddArc(outerRect, baseAngle, 70f); bladePath.AddArc(new RectangleF(-hubRadius * 2.4f, -hubRadius * 2.4f, hubRadius * 4.8f, hubRadius * 4.8f), baseAngle 70f, -60f); bladePath.CloseFigure(); g.FillPath(new SolidBrush(Color.FromArgb(70, 110, 110)), bladePath); g.DrawPath(bladePen, bladePath); } // 中心轴帽 using var hubBrush new SolidBrush(Color.FromArgb(70, 70, 70)); g.FillEllipse(hubBrush, -hubRadius, -hubRadius, hubRadius * 2f, hubRadius * 2f); g.Restore(state); }这段代码里的关键是叶片路径绘制。我使用了两段Arc组成一个类似螺旋桨叶的闭合图形第一段是从baseAngle开始扫过70度的外弧第二段是从外弧结束角度往回扫60度的内弧CloseFigure把两段弧的端点连起来形成一个封闭叶片。这个形状比单纯画几个填充扇形看起来更像泵叶轮我在实际测试中调过好几版这个参数组合出来的视觉效果最接近离心泵叶片的意象。_rotationAngle的推进由Timer驱动Forward时不断增加角度Reverse时不断减小private void OnAnimTick(object? sender, EventArgs e) { var step 3.0; if (_runMode PumpRunMode.Reverse) { _rotationAngle - step; if (_rotationAngle 0) _rotationAngle 360; } else { _rotationAngle step; if (_rotationAngle 360) _rotationAngle - 360; } Invalidate(); }为什么用TranslateTransform/RotateTransform而不是自己算三角函数坐标因为Graphics提供的坐标变换会把整个绘制管线都平移旋转掉你在内部坐标系里画什么图形都是相对叶轮中心贴上去的旋转动画只需要改一个角度值代码可读性和维护性都好得多。自己算坐标虽然也能实现但每加一个叶片就要重新计算一套三角函数容易出边界错位得不偿失。3.4 双缓冲、目标帧率和GDI资源释放经验WinForms控件做动画最怕的就是闪烁。闪烁的根源是控件的每次重绘都要经历擦除背景→绘制内容两步两步之间有时间差人眼就看到了闪白。解决办法是在构造函数里设置双缓冲样式SetStyle( ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);这四个标志作用分别是AllPaintingInWmPaint告诉系统WM_PAINT消息里已经包含所有绘制工作跳过背景擦除消息UserPaint让控件完全自定义绘制OptimizedDoubleBuffer启用系统级双缓冲把绘制先画到一个后台缓冲区再一次性拷贝到屏幕ResizeRedraw让控件在大小变化时触发完整重绘。很多教程里说的双缓冲是需要手动创建Bitmap再DrawImage到前端那是控件开发之外的通用技巧。WinForms的Control自带优化双缓冲开启这几个标志就够了不需要在OnPaint里多此一举效果是一样的还少写很多代码。Timer的帧率我选了33ms换算下来大约30fps。对这个面积一般不超过200×200的小控件来说30fps已经足够流畅了再高也看不出区别纯白耗CPU。如果是组态页面上几十上百个泵同时动画帧率建议降到50ms甚至66ms否则CPU占用会让你测试的时候以为程序卡死了。GDI资源的释放是我在初版里吃过亏的地方。在OnPaint里每帧都创建Pen、Brush、GraphicsPath如果不用using释放句柄数会持续上涨跑上几小时之后程序直接崩溃。现在我的习惯是所有GDI对象全部using var声明或者把不随状态变化的静态对象缓存成字段。叶片绘制里每次迭代都new一个SolidBrush其实是有点浪费的可以提前创建好引用但为了代码可读性先这样写性能瓶颈不在这种小对象上。4. 设计期体验工具箱图标、属性分组和默认事件一个都不能少自定义控件写完了能编译运行只完成了一半工作。一个让开发者用起来舒服的控件设计期体验必须过关。下面这几个细节是很多教程不会写清楚、但实际使用频率极高的点。4.1 工具箱图标和默认事件WinForms工具箱里的图标默认是齿轮一堆自定义控件放一起根本分不清谁是谁。给控件类加上ToolboxBitmap特性可以指定图标。最省事的做法是复用系统自带图标比如把泵控件暂时用Button图标顶着正经做法是准备一张16×16的位图作为嵌入资源[ToolboxBitmap(typeof(PumpStateControl), PumpStateControl.bmp)]使用第一个参数是指定查找资源的锚点类型第二个参数是资源名。前提是这张bmp要设置成嵌入式资源并且Build Action是Embedded Resource。早期我偷懒用系统Button图标工具箱里一堆Button图标看着确实痛苦后来花十分钟做了一张小泵的位图体验立刻就不一样了。DefaultEvent特性也很容易被忽略。它指定了双击控件时设计器自动生成的事件处理函数。我给PumpStateControl声明了StateChanged事件并加上这个特性后开发者在窗体上双击控件就直接生成pumpStateControl1_StateChanged方法非常符合WinForms的使用直觉。4.2 属性窗口的有效呈现前面提到Category和Description特性这里展开讲为什么重要。WinForms的属性窗口默认是有分类视图的不指定Category所有属性会乱糟糟堆在Misc里。我给所有状态相关属性都归到了PumpState分类下开发者一眼就能看到控制状态行为的属性都在哪而不是在几百个继承自Control的属性里大海捞针。Browsable(false)也是常用的遮丑手段。Control基类有一堆对这个控件毫无意义的属性比如Text、TabIndex、TabStop这些出现在属性窗口只会让人困惑。把它们屏蔽掉[Browsable(false)] public override string Text { get base.Text; set base.Text value; } [Browsable(false)] public new bool TabStop { get base.TabStop; set base.TabStop value; }这个细节让我在把控件分发给团队其他成员用的时候少解释了很多问题。以前总有人问我为什么这个控件的Text属性没效果其实只是我没屏蔽它让使用者误以为它控制泵的名称显示。屏蔽之后连问都不会有人问。4.3 设计器序列化与默认值的博弈上一章说了ShouldSerialize方法控制Color类型序列化的问题这是自定义控件设计期体验里非常核心的一环。很多人只给属性加DefaultValue却发现设计器依然生成了一层子成员初始化代码原因就是Color这种结构体没法用简单的标量默认值描述DefaultValue特性对它的判断经常失效。我现在的做法是枚举和数值类型用DefaultValue结构和引用类型用ShouldSerialize。两者配合使用窗体设计器生成的InitializeComponent代码非常干净——默认值一律不生成一旦开发者改过某个颜色属性才在那个地方生成一行赋值代码。这样代码review的时候也能很清楚地看到哪些控件的属性被定制过。还有一个容易踩的坑是属性变更后设计器界面不刷新。只在setter里给字段赋了新值但忘了调用Invalidate()设计器里改属性后控件的画面纹丝不动。所有视觉相关的属性setter最后务必调用Invalidate()。如果有事件通知需求再加事件触发。这个我在第一版里漏过调了半天以为是设计器缓存问题最后发现就是少了重绘。4.4 Timer生命周期与Dispose的兜底动画Timer是控件自己new的要负责自己的生命周期。如果不在控件销毁时一并释放轻则资源泄漏重则窗体关闭了Timer还在触发Invalidate导致ObjectDisposedException异常。控件重写Dispose(bool)是标准做法protected override void Dispose(bool disposing) { if (disposing) { _animTimer.Dispose(); } base.Dispose(disposing); }另外Timer在控件不可见时仍然Tick是没有意义的。我处理的方式是在OnVisibleChanged里跟着状态的开关走一遍不可见时停掉Timerprotected override void OnVisibleChanged(EventArgs e) { base.OnVisibleChanged(e); UpdateTimerByState(); }当控件不可见时不画动画回到可见状态再恢复对CPU占用和页面切换时的卡顿都有很小但实际的改善。真实项目里十几个泵控件同时不可见时省下的CPU还是可感知的。5. 这一版做到什么程度以及第二期会补什么写这篇文章的时候我的PumpStateControl已经完整支持四种状态切换、正反转、叶轮旋转动画、设计期属性定制和状态事件通知核心绘制代码大概三百行出头。作为一个上位机通用控件这个投入产出比非常高。目前还没做的部分是故障状态的红灯呼吸闪烁效果、管道内部流量流动的动画、状态颜色渐变过渡以及和PLC实时变量绑定的完整示例。这些内容我准备放在第二篇里解决其中故障闪烁在客户现场被提了两次优先级最高下一篇会首先做掉。在我的实际使用体会里这类控件的价值并不在于代码量多少而在于你愿不愿意把状态-颜色-动画这套对应关系设计得足够统一。状态模型一旦清晰后面接传感器、接PLC点位、接告警联动都只是属性赋值和事件响应的事整个监控画面的可维护性会提升一大截。如果你也正在做类似的上位机设备监控界面建议先按这篇的思路把基座搭起来遇到具体绘制问题欢迎随时交流。
返回列表