LiveCharts 实时温度曲线
这是「从零搭建灌装监控系统」系列第17篇。数字只能告诉你此刻的温度,曲线才能告诉你温度在往哪走。这篇用 LiveCharts 画实时温度,重点不是“怎么画出来”,而是怎么在几百毫秒一次的刷新下不卡、不崩、不无限膨胀。

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




