多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C#上位机新窗口Chart实时波形移动显示实现指南

C#上位机新窗口Chart实时波形移动显示实现指南 做C#上位机的朋友应该都有这种体会程序写到最后最难的不是采集数据而是把数据明明白白地画给用户看。这次这个项目——系列第8个小案例解决的就是“在新窗口中使用Chart控件做波形移动显示”。场景非常常见比如串口温度采集、PLC数据监测、传感器实时曲线主窗体上挤满了按钮、状态栏和参数配置区留给图形的空间少得可怜。这时候把Chart控件放到一个独立弹出的窗口里数据一到就实时绘制画满之后波形往前滚动主窗体该干嘛干嘛整套程序一下子就清爽了。这个案例适合谁看如果你已经会基本窗体拖控件但对Chart控件的Series绑定、坐标轴设置、实时刷新比较模糊或者遇到过“新窗口打开后图表不动”“数据多了狂卡”“跨线程报错”这类问题这篇文章可以直接给你一套能抄作业的方案。我会把思路、配置、完整代码、踩坑记录都摊开讲确保你看完能自己复现一个带实时波形的新窗口。1. 先理清需求新窗口、Chart控件、波形移动这三件事怎么拼起来1.1 为什么非要单独开一个窗口我在做数据采集软件的时候第一次的版本把所有功能都塞在主窗体里左侧是参数面板、右侧是状态监控、下面是一条波形图。结果图被压缩成一条窄缝客户看数据还要歪着脖子。后来改成“主窗体管业务逻辑波形图单独弹窗”体验立刻不一样。放在新窗口有几个实实在在的好处显示空间大子窗体可以最大化、全屏甚至拖到第二块显示器上对监控类软件非常友好。逻辑解耦主窗体只负责启动采集、暂停、停止子窗体只管接收数据、画图。两边通过事件或队列通信代码结构干净得多。生命周期独立主窗体还在跑业务用户可以随时打开多个波形窗口同时查看不同通道的数据关闭某个窗口不影响整体程序运行。界面响应更流畅把高频的绘制操作隔离在子窗体即使绘图卡顿也不会阻塞主窗体的按钮点击和参数修改。所以这个项目表面上是“在窗口里放Chart”本质上是在设计一个生产者与消费者模型主窗体是生产者产生实时数据新窗口是消费者负责把数据可视化。1.2 数据从哪来、送到哪去整体数据流设计开发前先画清楚数据流向能省掉后面一大半麻烦。这个项目的标准数据流是这样主窗体启动一个数据源可能是System.Windows.Forms.Timer按固定周期触发也可能是后台线程从串口、TCP、PLC中读取数据。每产生一个点主窗体把数据封装为一个结构体包含时间戳、数值值通过事件或共享队列发给子窗体。子窗体订阅事件或从队列中取出数据调用Chart.Series[0].Points.AddXY()追加新点。如果点数超过显示窗口上限就移除最早的点或者通过固定坐标轴范围制造“滚动”效果。用户关闭子窗体时解除事件订阅停止内部刷新逻辑释放资源。这个设计非常解耦主窗体不需要知道Chart控件内部怎么画线子窗体也不需要关心数据是从串口来还是仿真生成。后面对代码做修改时只动一边也不会影响到另一边。1.3 方案选型为什么还是用 WinForms 自带的 Chart有人会问现在图表库那么多LiveCharts、OxyPlot、ScottPlot都挺火为什么还要用微软自带的Chart我的回答是够用、稳定、不用装包。System.Windows.Forms.DataVisualization.Charting是WinForms自带的图表控件从.NET Framework 3.5就有了一直保留到现在的.NET 6/8。它支持绝大多数基础图表曲线图、柱状图、饼图都行实时刷新的性能虽不是顶级但控制好点数后完全够上位机用。WPF环境我确实更推荐LiveCharts或ScottPlot选型原因是WPF自带的Chart没有WinForms这套成熟。但项目既然定了WinFormsChart控件是最不折腾的选择。对比来看方案学习成本性能打包体积适用场景WinForms自带Chart低中等适合点数小/中规模无需额外库工业监控、教学演示LiveCharts中高动画流畅需NuGet引用WPF项目、UI要求高OxyPlot中高需NuGet引用跨平台、科学计算ScottPlot中很高需NuGet引用大数据量、科研绘图我在这个项目里就用自带的Chart因为它的核心需求是“实时波形移动显示”数据量控制在几千个点以内自带Chart完全能扛住。而且项目一编译就能跑不用面对一堆NuGet版本冲突。2. Chart 控件基础Series 绑定、坐标轴和波形滚动的三种实现2.1 Series 数据绑定的 3 种正确姿势Chart控件里最重要的概念是Series数据系列。一个图表可以放多个Series每个Series就代表一条曲线。很多新手在这里栽跟头因为数据绑定姿势不对图表要么不显示要么显示得杂乱无章。我整理出常用的3种正确姿势对应不同场景。第一种设计器里直接配置。简单适合静态数据。在窗体设计器中选中Chart打开“Series”集合编辑器添加一个Series设置ChartType Line再在Points集合里手动添加几个点。这种方式仅适合测试图表样式实际项目中基本不用。第二种代码中提前准备数据一次性绑定。适合历史曲线、报表展示。用Series.Points.DataBindXY()方法把X轴数组和Y轴数组传进去一次性绘制整条线。比如chart1.Series.Clear(); Series series new Series(温度曲线); series.ChartType SeriesChartType.Line; series.BorderWidth 2; double[] xData { 1, 2, 3, 4, 5 }; double[] yData { 20.1, 21.5, 19.8, 22.3, 23.0 }; series.Points.DataBindXY(xData, yData); chart1.Series.Add(series);这种方式胜在速度快适合更新一批已经算好的数据。第三种实时追加单个点。这是本项目核心场景。用Series.Points.AddXY()每次加一个点加到一定数量后移除旧点。这种方式能精确控制波形的滚动节奏代码最为直观series.Points.AddXY(currentTime, currentValue); if (series.Points.Count maxPointCount) { series.Points.RemoveAt(0); }第三种方法我强烈推荐用于实时波形显示。它的好处是你可以控制每一个点的进出逻辑清晰后续要做滤波、求峰谷值也方便在追加之前处理。2.2 波形“移动”到底是怎么实现的波形移动显示说白了就是让新数据出现在右侧老数据从左侧消失看起来像整条曲线在“滚动”。实现的原理不复杂无非是控制窗口内数据的可见范围。常见的有三种实现思路。思路一固定X轴范围靠坐标轴滚动。给X轴设置一个固定宽度比如10秒。每来一个新点就把X轴的最大值往前挪一点最小值跟着挪这样整条曲线就会从右向左“跑动”。核心是操作ChartArea.AxisX.Maximum和AxisX.Minimumint displayRange 10; // 显示最近10个点 chart1.ChartAreas[0].AxisX.Minimum currentIndex - displayRange; chart1.ChartAreas[0].AxisX.Maximum currentIndex;这种思路比较适合数值很大、无限增长的场景。但要注意X轴的范围变化会频繁触发重绘如果点很多性能一般。而且曲线本身还在增长图形上的点是“挤”到左边去的观感并不是特别自然。思路二滑动窗口满屏后整体左移。用一个缓冲区保存固定数量的点比如500个。新点追加到末尾当超过500个时把第一个点移除其他点整体向左平移形成自然的滚动效果。代码实现最简单就是数组的“先删头、后加尾”和上面提到的Points.RemoveAt(0)同一个思路private void AppendPoint(Series series, double x, double y, int maxPoints) { while (series.Points.Count maxPoints) { series.Points.RemoveAt(0); } series.Points.AddXY(x, y); }这种方式的优点是数据点数量恒定内存稳定刷新性能可控。缺点是无法显示超过缓冲区的数据适合展示最近一段时间的动态波形比如示波器效果。思路三大数据量 抽样显示。如果每秒产生几百个点但屏幕只能显示几千个点就不能全装进去。此时要把接收到的数据先放进大缓存再按显示分辨率抽样把“代表点”画到Chart上。这种做法适合高速采集但实现复杂度高这个项目不需要。最后给个选型建议项目只是监控实时波形比如每秒10-100个点直接采用思路二用滑动窗口简单稳定。如果做的是长时间历史回放再去研究抽样显示。2.3 坐标轴范围与采集参数的换算波形滚动显示还有一个容易忽略的细节X轴范围和采样频率之间的关系。很多人设置不好导致波形要么挤成一团要么拉得太开。比如说采样周期是100ms也就是说每秒产生10个点想显示最近10秒的波形窗口内应该有100个点。此时滑动窗口上限设为100~120即可。采样周期变了窗口上限也要跟着变。公式很简单显示点数 采样频率 × 显示时长 窗口保留点数 显示点数 × 1.2留出余量比如采样率是50Hz想显示20秒那么显示点数是1000个窗口保留1200个点。不要卡得太死留点余量可以避免波形的最后一个点顶到Chart控件的边缘。ChartArea.AxisX.Interval也可以配合坐标范围设置。如果X轴显示的是时间建议设置间隔为TimeSpan.FromSeconds(2)之类的整数值否则坐标轴上的刻度标签会非常难看一会儿2.000123一会儿3.000456看得人头皮发麻。3. 实操在主窗体采集数据在新窗口里把波形画出来3.1 搭建项目与界面我用的开发环境是Visual Studio 2022.NET 8项目模板选择“Windows Forms应用”。如果你用的是.NET Framework 4.7.2或者4.8做法完全一样只是目标框架选择不同。项目结构建议分两个窗体MainForm.cs主窗体放一个“打开波形窗口”按钮、一个“启动采集”按钮和一个“停止采集”按钮。WaveFormChart.cs子窗体也就是新窗口放一个Chart控件。创建子窗体的Chart控件时记得在窗体设计器中给它设置Dock Fill这样当窗体拉大时图表会自动填满整个窗口不用处理任何布局代码。另外要在新窗口的构造函数里做一次初始化public WaveFormChart() { InitializeComponent(); // 配置图表区域 chart1.ChartAreas.Clear(); ChartArea area new ChartArea(MainArea); area.AxisX.Title 时间; area.AxisY.Title 数值; area.AxisX.MajorGrid.Enabled true; area.AxisY.MajorGrid.Enabled true; chart1.ChartAreas.Add(area); // 配置数据系列 chart1.Series.Clear(); Series series new Series(实时数据); series.ChartType SeriesChartType.Line; series.BorderWidth 2; series.XValueType ChartValueType.DateTime; // X轴按时间显示 chart1.Series.Add(series); }这里有个容易被忽略的小细节series.XValueType ChartValueType.DateTime。如果X轴传的是DateTime类型的数据必须设置这个属性否则Chart控件会把它当数字处理坐标轴显示的是时间戳的数值而不是“13:05:22”这类人类可读的时间格式。3.2 子窗体的 Chart 初始化和波形绘制核心代码为了简单起见我在子窗体里放一个公共方法叫AppendData(DateTime time, double value)。这个方法是外界向图表喂数据的唯一入口。public void AppendData(DateTime time, double value) { if (chart1.IsDisposed) return; Series series chart1.Series[实时数据]; int maxPoints 200; // 用锁保护避免多个线程同时写入导致异常 lock (dataLock) { // 如果点数超过上限先删掉最早的点再追加新点 if (series.Points.Count maxPoints) { series.Points.RemoveAt(0); } series.Points.AddXY(time.ToOADate(), value); AdjustAxis(time); } } private void AdjustAxis(DateTime currentTime) { // 让X轴跟随最新时间滚动默认显示最近约20秒 ChartArea area chart1.ChartAreas[MainArea]; DateTime minTime currentTime.AddSeconds(-20); area.AxisX.Minimum minTime.ToOADate(); area.AxisX.Maximum currentTime.ToOADate(); }这里有两个重点。第一个是lock (dataLock)。波形显示的典型场景是多线程环境采集线程往队列里扔数据UI线程的Timer负责刷新界面两个线程同时访问series.Points集合就可能抛异常。加锁是成本最低、最不容易出错的线程安全保护方案。第二个是坐标轴用ToOADate()转换。DateTime本质上是数字转成OLE Automation日期格式后Chart控件才能正确计算坐标轴范围。这个方法把时间变成以1900年为基准的浮点数Chart内部拿这个数计算展示位置刚刚好。3.3 父窗体如何把数据安全地传给子窗体数据通信是这个项目里最容易踩坑的地方。很多新手直接在主窗体里写waveFormChart.AppendData()然后在Timer事件里疯狂调用结果程序跑起来要么界面上什么都没有要么卡死。原因就在于跨线程操作控件。正确做法分两步第一步主窗体创建子窗体并控制其生命周期。private WaveFormChart waveChart; private void btnOpenWave_Click(object sender, EventArgs e) { if (waveChart null || waveChart.IsDisposed) { waveChart new WaveFormChart(); waveChart.Show(); waveChart.FormClosed (s, args) waveChart null; } else { waveChart.Activate(); } }这里判断了IsDisposed避免用户关闭窗口后再次点击“打开波形窗口”却对一个已经销毁的窗口调用方法。第二步在采集数据的事件里向子窗体发送数据。我在主窗体里放了一个定时器模拟实时数据private Timer dataTimer; private Random random new Random(); private double value 30; private void btnStart_Click(object sender, EventArgs e) { dataTimer new Timer(); dataTimer.Interval 100; // 每100ms采集一次 dataTimer.Tick (s, e) { value (random.NextDouble() - 0.5) * 4; DateTime now DateTime.Now; waveChart?.AppendData(now, value); }; dataTimer.Start(); }在主窗体的按钮里通过waveChart?.AppendData(...)把数据喂给子窗体。?.这个空条件运算符很关键当窗口还没打开时不会抛异常。这里有个面试里常问的点也是很多老手在实际项目中坚持的原则不要在子窗体里做任何数据采集逻辑。子窗体只负责“展示”数据源永远在主窗体或其他后台服务中。这样开发的程序后续如果要把数据保存到数据库或者发给Web前端改造起来非常方便。如果你觉得事件订阅的方式太绕还有一种更简单的方案定义一个静态数据缓冲类主窗体和子窗体都访问这个类。比如用ConcurrentQueuedouble[]存放数据子窗体每100ms取一次队列数据并刷新页面。这种方式适合数据量不大、逻辑不复杂的场景但要注意写队列和读队列的并发问题。用ConcurrentQueue可以避免锁的烦恼public static class DataBuffer { public static ConcurrentQueue(DateTime Time, double Value) Queue new(); }主窗体往里Enqueue子窗体的Timer里TryDequeue取出数据后绘制。这个方案和事件方案相比API更直观代码更容易测试适合快速开发。3.4 窗口关闭后的资源清理新窗口关闭时如果主窗体的Timer还在运行还会继续向子窗体发送数据就会触发著名的ObjectDisposedException。这个问题如果不处理你会看到程序时不时弹一个“无法访问已释放的对象”异常非常烦人。我的处理方式是protected override void OnFormClosing(FormClosingEventArgs e) { // 移除所有图表数据释放内存 chart1.Series.Clear(); base.OnFormClosing(e); }同时主窗体在FormClosed事件里把waveChart置空这样后续按钮事件里waveChart?.AppendData(...)就会自动跳过不会访问到已经销毁的窗口。另外还要提醒一句如果你的采集逻辑用到后台线程子窗体关闭后记得把线程停掉。很多上位机卡死的根因不是画图慢而是窗口关了一堆后台线程还在跑CPU被白白吃空。4. 踩坑记录跨线程、卡顿、闪屏和“什么都不显示”4.1 跨线程更新 Chart 控件的坑我在调试这个项目时故意把主窗体的Timer换成一个后台线程模拟串口采集的高频率结果运行到series.Points.AddXY()时直接抛出InvalidOperationException提示“线程间操作无效从不是创建控件的线程访问它”。这是因为WinForms控件的线程模型限制了UI控件只能在创建它的线程中访问。解决这个问题有两种标准做法。第一种用Control.Invoke/Control.BeginInvoke把更新操作切回UI线程private void AppendDataFromThread(DateTime time, double value) { if (chart1.InvokeRequired) { chart1.BeginInvoke(new Action(() AppendData(time, value))); return; } AppendData(time, value); }BeginInvoke是异步的不会阻塞当前采集线程既保证了数据能及时显示又避免跨线程崩溃。第二种给WinForms控件设置CheckForIllegalCrossThreadCalls false。这能关掉跨线程检查但我强烈不建议在生产代码中这么写。它只是把报错隐藏了底层数据竞争问题依然存在一旦Chart控件内部状态不一致程序会时不时闪退而且很难排查。我建议老老实实用Invoke。4.2 波形卡顿与内存增长我遇到过一版程序跑了十几分钟后内存从几十MB涨到500多MB波形刷新也开始卡。排查后发现罪魁祸首是series.Points无限制增长。我的Chart控件里只放了200个点但每100ms来一个点一天就是86400个点内存当然扛不住。解决办法就是前面提到的滑动窗口在追加新点时检查数量超过上限就删掉最早的。这里有个性能技巧不要逐个RemoveAt(0)当数据量很大时RemoveAt(0)的时间复杂度是O(n)每删一个点后面的所有点都要移动。所以可以定期清理比如每满500点一次性移除前100个点或者直接调用series.Points.RemoveAt(0)但把窗口上限调大一些减少清理频率。另外Chart控件刷新时默认会做很多反锯齿和阴影渲染性能开销很大。如果帧率要求高可以关掉这些效果chart1.AntiAliasing AntiAliasingStyles.None; chart1.TextAntiAliasingQuality TextAntiAliasingQuality.Normal;实测这样设置后在同样点数下刷新速率提升非常明显。4.3 Chart 不显示内容的几个常见原因“图表开了但一条线都没有”是新手最容易遇到的问题。我总结了一下常见原因有三个。第一Series没有添加到Chart里。仅仅在代码里创建了new Series(实时数据)不代表图表就会显示它必须执行chart1.Series.Add(series)。第二ChartType还是默认的柱状图或折线图以外的类型。Chart控件默认的Series类型是Column如果忘了设成SeriesChartType.Line第一批数据会画成柱状图视觉上可能看不到。第三X轴的数据格式问题。如果X轴是DateTime但代码里AddXY(时间字符串, 数值)传的是字符串Chart控件会解析失败或者把所有点按同一条竖线排列显示效果就是图形空白。快速排查方法调试时在代码里打个断点确认series.Points.Count是否在增长如果增长但页面没显示再检查Coordinate轴范围是否设置错误比如AxisX.Minimum大于AxisX.MaximumChart控件会把整个图形区域压缩掉。4.4 显示效果优化和避坑速查表做波形显示不能光“能画就行”还要考虑操作体验。我在项目中做了几个优化效果立竿见影。用chart1.SuspendLayout()和chart1.ResumeLayout()包裹大批量数据更新操作可以避免图表频繁重绘。开启ChartArea.AxisY.IsStartedFromZero false让Y轴从数据最小值附近开始而不是从0开始波形在数值区间小的情况下不会被压成一条直线。多个Series同时显示时给每条线设置不同颜色和宽度同时把Legend控件的Docking属性设置好否则图例会遮挡图表区域。显示实时数据时把鼠标滚轮放大功能加上方便用户查看细节。这个通过Chart控件的鼠标事件和AxisX.ScaleView实现。再给一个快速定位问题的速查表现象可能原因解决办法图表空白无数据Series未Add到Chart确认chart1.Series.Add(series)显示柱状图而不是曲线ChartType未设置设置SeriesChartType.Line横坐标是数字不是时间XValueType未设置设置series.XValueType ChartValueType.DateTime曲线挤成竖直线X轴数据未递增检查X轴传参用递增的时间戳数据增长后卡顿点数无上限加滑动窗口定时删除旧点关窗口后报错子窗体已释放但还在被调用主窗体在FormClosed事件里清除引用跨线程异常非UI线程更新控件用Invoke/BeginInvoke5. 我实际操作下来的一些额外心得代码写到这一步整个项目已经能跑起来了。但真正拿到正式环境里去用的时候还有几个细节值得再补充。首先是数据更新的节奏。我用的是100ms更新一次也就是每秒10帧。实际项目里要根据数值变化的剧烈程度来调整。温度变化慢1秒一次足矣还能大幅降低CPU占用振动信号变化快那就要20ms甚至10ms一帧这时候必须控制点数上限否则Chart控件会卡成PPT。其次是子窗体的标题。建议动态显示当前采样值和通道名比如“实时波形 - 温度通道 - 当前值 25.3℃”。这个信息对用户来说比坐标轴更直观操作体验提升非常明显。最后是Chart控件在窗体缩放时的表现。由于我把Chart的Dock设置为Fill窗口拖大后图形会自动铺满整个区域但这时候坐标轴刻度和网格线可能不够美观。如果你对细节有要求可以在窗体的Resize事件里调整一下AxisX.Interval的计算逻辑让网格线数量保持在合理范围内。根据我个人的开发习惯这种项目我还会做一个小工具类把Chart的初始化、追加数据、坐标轴调整封装起来这样主窗体和子窗体代码都很简洁。等做第二个、第三个上位机项目时直接拷贝工具类就能用节约了不少时间。这个第8个小案例讲到这里算是完整了。如果还有绕不过去的坎欢迎在评论区把现象发出来我按经验给你指一个排查方向。
返回列表