:LiveCharts 实时温度曲线)
LiveCharts 实时温度曲线这是「从零搭建灌装监控系统」系列第17篇。数字只能告诉你此刻的温度曲线才能告诉你温度在往哪走。这篇用 LiveCharts 画实时温度重点不是“怎么画出来”而是怎么在几百毫秒一次的刷新下不卡、不崩、不无限膨胀。曲线跑到第二天就卡住了首页加温度曲线的时候我的实现非常朴素每次收到数据就往ChartValues里Add一个点。刚开始特别顺。跑了一上午曲线像心电图一样漂亮。第二天早上同事说软件卡了——切页面要等两秒拖动窗口一顿一顿。原因很直白数据点只增不减。200ms 一次采样一小时就是 18000 个点一天 43 万个点。LiveCharts 每个点都要参与布局和重绘几万个点之后每次刷新都在做无用功——因为屏幕上根本显示不下那么多点。还有一个更隐蔽的问题有时候刚启动就崩异常信息指向图表但代码看起来完全正常。这两个问题的解法就是这篇的全部内容。为什么选 LiveChartsWPF 上画实时曲线有几个选择我选 LiveCharts 0.9.7 的原因纯 WPF 原生控件不依赖浏览器或 OpenGL 上下文SeriesCollectionChartValues的数据模型跟 MVVM 合得来支持虚线、点样式、图例这些工业界面常用的细节包体积小部署到现场不需要额外运行时代价是它的老版本0.9.x对线程比较敏感这点后面专门讲。两条曲线实际值 vs 设定值温度只看一条线是不够的。操作员真正关心的是实际值和设定值的偏差所以画两条TempLiveChartsnewSeriesCollection{newLineSeries{Title实际温度,ValuesnewChartValuesdouble(),StrokeBrushes.Cyan,StrokeThickness2,PointGeometrynull,},newLineSeries{Title设定温度,ValuesnewChartValuesdouble(),StrokeBrushes.Orange,StrokeThickness2,PointGeometrynull,StrokeDashArraynewDoubleCollection{4,2},}};三个刻意的选择实际值实线、设定值虚线。StrokeDashArray {4, 2}是 4 像素实线加 2 像素空。两条线颜色不同还不够色觉差异或者屏幕反光时颜色容易混线型不同才能一眼分开。而且设定值本来就该是“参考线”的语义虚线正好。PointGeometry null。默认每个数据点会画一个小圆点。几百个点就是几百个图形对象纯属浪费——曲线本身已经把趋势表达清楚了。关掉之后渲染量明显下降。两条线用同一个 Y 轴。实际值和设定值量纲相同共用纵轴才能直观看出偏差。如果量纲不同比如温度和压力才需要拆双轴。滚动窗口只保留最近 40 点解决无限膨胀的核心是让集合长度恒定privatevoidUpdateChart(DeviceStatesstate){if(TempLiveChartsnull||TempLiveCharts.Count0)return;TempLiveCharts[0].Values.Add(state.CurrentTemp);if(TempLiveCharts[0].Values.Count40)TempLiveCharts[0].Values.RemoveAt(0);if(TempLiveCharts.Count1){TempLiveCharts[1].Values.Add(state.SettingTemp);if(TempLiveCharts[1].Values.Count40)TempLiveCharts[1].Values.RemoveAt(0);}}加一个到尾部超长就从头部删一个。集合长度永远是 40内存和渲染开销都恒定。为什么是 40按 200ms 采样算40 个点大约覆盖 8 秒。这个窗口要跟工艺节奏匹配窗口太短曲线抖动剧烈看不出趋势窗口太长温度变化的细节被压缩报警前的爬升看不出来8 秒对灌装加热这种慢过程偏短实际项目可以放宽到 100-200 点。文章里用 40 是为了讲清机制具体数值要按工艺周期调。原则是窗口时长应该能覆盖一次完整的状态变化。流程如下是否轮询收到数据Dispatcher 切回 UI 线程实际值集合 Add点数超过 40?移除头部一个点保持设定值同样处理图表自动重绘必须在 UI 线程创建这是那个“刚启动就崩”的根因。SeriesCollection、LineSeries、ChartValues都是 WPF 的DependencyObject体系里的对象有线程亲和性。如果在非 UI 线程创建之后 UI 线程去渲染它就会抛异常——而且异常点往往在渲染阶段堆栈指向 LiveCharts 内部看起来跟你的代码无关。所以创建过程要用 Dispatcher 包起来publicDashBoardViewModel(){PlcService.DataReceivedOnDataReceived;Application.Current.Dispatcher.BeginInvoke((){TempLiveChartsnewSeriesCollection{// ... 两条 LineSeries};});}用BeginInvoke而不是Invoke构造过程不需要等图表建完异步投递到 UI 队列就行不会阻塞当前线程。同样的道理后续每次改Values也要在 UI 线程。DataReceived是从轮询线程发出的所以更新前必须切线程privatevoidOnDataReceived(object?sender,DeviceStatesstate){Application.Current.Dispatcher.Invoke((){CurrentTempstate.CurrentTemp;SettingTempstate.SettingTemp;UpdateChart(state);});}Invoke同步在这里是合适的因为更新图表和更新卡片数值要在一个批次里完成避免界面出现“数字已经是新值、曲线还是旧点”的错位。不要每帧重建集合我见过一种写法每次收到数据就TempLiveCharts new SeriesCollection {...}把整个集合换掉。看起来“数据总是最新的”实际上每次都在重建图形对象、重新绑定、重新布局。数据一快GC 压力和渲染压力一起上来。正确做法是建一次之后只改Values。ChartValues实现了集合变更通知增删元素会自动触发增量重绘这才是 LiveCharts 的设计意图。断线时把曲线也清掉第 16 篇讲过断线要清零曲线同样适用privatevoidOnConnectionChanged(object?sender,boolconnected){if(!connected){Application.Current.Dispatcher.Invoke((){CurrentTemp0;SettingTemp0;TempLiveCharts[0].Values.Clear();TempLiveCharts[1].Values.Clear();});}}保留断线前的曲线会让操作员误判——曲线停在 25.5℃看起来像“温度稳定”其实是没数据了。清空之后图表是空的配合报警提示语义才正确。踩坑记录后台线程直接改 ChartValues崩在渲染阶段堆栈在 LiveCharts 内部看不出跟自己的代码有关。所有图表对象操作都要切 UI 线程。数据点只增不减跑几小时就卡跑一天基本不能看。用滚动窗口把集合长度固定住。每次刷新重建 SeriesCollection数据越快越卡而且图表会闪。建一次之后只改Values。实际值和设定值不做区分两条同色实线叠在一起操作员分不清哪条是哪条。颜色 线型双区分。本篇小结问题做法集合无限膨胀滚动窗口超长移除头部渲染开销大PointGeometry null只画线不画点两条线分不清实际值实线、设定值虚线后台线程崩溃创建和更新都在 UI 线程频繁重建集合建一次之后只改Values断线误判清空曲线不保留旧数据图表让趋势可见了但界面上还有一堆重复的视觉元素每个页面都要一个红绿圆点、一个状态标签、一个液位指示。复制粘贴到最后改一次配色要翻十几个文件。下一篇把这些做成可复用的自定义控件。下期预告第18篇自定义控件三色灯与状态指示下一篇把三色灯和状态指示器做成独立的UserControl用依赖属性和值转换器驱动顺便讲一个 WPF 里非常容易踩的坑本地值为什么会让触发器失效。