从零搭建灌装监控系统(十七):LiveCharts 实时温度曲线

LiveCharts 实时温度曲线

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


曲线跑到第二天就卡住了

首页加温度曲线的时候,我的实现非常朴素:每次收到数据就往 ChartValues 里 Add 一个点。

刚开始特别顺。跑了一上午,曲线像心电图一样漂亮。第二天早上同事说软件卡了------切页面要等两秒,拖动窗口一顿一顿。

原因很直白:数据点只增不减。 200ms 一次采样,一小时就是 18000 个点,一天 43 万个点。LiveCharts 每个点都要参与布局和重绘,几万个点之后,每次刷新都在做无用功------因为屏幕上根本显示不下那么多点。

还有一个更隐蔽的问题:有时候刚启动就崩,异常信息指向图表,但代码看起来完全正常。

这两个问题的解法,就是这篇的全部内容。


为什么选 LiveCharts

WPF 上画实时曲线有几个选择,我选 LiveCharts 0.9.7 的原因:

  • 纯 WPF 原生控件,不依赖浏览器或 OpenGL 上下文
  • SeriesCollection + ChartValues 的数据模型跟 MVVM 合得来
  • 支持虚线、点样式、图例这些工业界面常用的细节
  • 包体积小,部署到现场不需要额外运行时

代价是它的老版本(0.9.x)对线程比较敏感,这点后面专门讲。


两条曲线:实际值 vs 设定值

温度只看一条线是不够的。操作员真正关心的是实际值和设定值的偏差,所以画两条:

csharp 复制代码
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 点

解决无限膨胀的核心,是让集合长度恒定:

csharp 复制代码
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 是为了讲清机制,具体数值要按工艺周期调。原则是:窗口时长应该能覆盖一次完整的状态变化。

流程如下:
#mermaid-svg-MaMho4695q1ka86e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MaMho4695q1ka86e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MaMho4695q1ka86e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MaMho4695q1ka86e .error-icon{fill:#552222;}#mermaid-svg-MaMho4695q1ka86e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MaMho4695q1ka86e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MaMho4695q1ka86e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MaMho4695q1ka86e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MaMho4695q1ka86e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MaMho4695q1ka86e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MaMho4695q1ka86e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MaMho4695q1ka86e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MaMho4695q1ka86e .marker.cross{stroke:#333333;}#mermaid-svg-MaMho4695q1ka86e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MaMho4695q1ka86e p{margin:0;}#mermaid-svg-MaMho4695q1ka86e .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MaMho4695q1ka86e .cluster-label text{fill:#333;}#mermaid-svg-MaMho4695q1ka86e .cluster-label span{color:#333;}#mermaid-svg-MaMho4695q1ka86e .cluster-label span p{background-color:transparent;}#mermaid-svg-MaMho4695q1ka86e .label text,#mermaid-svg-MaMho4695q1ka86e span{fill:#333;color:#333;}#mermaid-svg-MaMho4695q1ka86e .node rect,#mermaid-svg-MaMho4695q1ka86e .node circle,#mermaid-svg-MaMho4695q1ka86e .node ellipse,#mermaid-svg-MaMho4695q1ka86e .node polygon,#mermaid-svg-MaMho4695q1ka86e .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MaMho4695q1ka86e .rough-node .label text,#mermaid-svg-MaMho4695q1ka86e .node .label text,#mermaid-svg-MaMho4695q1ka86e .image-shape .label,#mermaid-svg-MaMho4695q1ka86e .icon-shape .label{text-anchor:middle;}#mermaid-svg-MaMho4695q1ka86e .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MaMho4695q1ka86e .rough-node .label,#mermaid-svg-MaMho4695q1ka86e .node .label,#mermaid-svg-MaMho4695q1ka86e .image-shape .label,#mermaid-svg-MaMho4695q1ka86e .icon-shape .label{text-align:center;}#mermaid-svg-MaMho4695q1ka86e .node.clickable{cursor:pointer;}#mermaid-svg-MaMho4695q1ka86e .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MaMho4695q1ka86e .arrowheadPath{fill:#333333;}#mermaid-svg-MaMho4695q1ka86e .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MaMho4695q1ka86e .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MaMho4695q1ka86e .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MaMho4695q1ka86e .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MaMho4695q1ka86e .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MaMho4695q1ka86e .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MaMho4695q1ka86e .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MaMho4695q1ka86e .cluster text{fill:#333;}#mermaid-svg-MaMho4695q1ka86e .cluster span{color:#333;}#mermaid-svg-MaMho4695q1ka86e div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MaMho4695q1ka86e .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MaMho4695q1ka86e rect.text{fill:none;stroke-width:0;}#mermaid-svg-MaMho4695q1ka86e .icon-shape,#mermaid-svg-MaMho4695q1ka86e .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MaMho4695q1ka86e .icon-shape p,#mermaid-svg-MaMho4695q1ka86e .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MaMho4695q1ka86e .icon-shape rect,#mermaid-svg-MaMho4695q1ka86e .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MaMho4695q1ka86e .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MaMho4695q1ka86e .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MaMho4695q1ka86e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
轮询收到数据
Dispatcher 切回 UI 线程
实际值集合 Add
点数超过 40?
移除头部一个点
保持
设定值同样处理
图表自动重绘


必须在 UI 线程创建

这是那个"刚启动就崩"的根因。

SeriesCollection、LineSeries、ChartValues 都是 WPF 的 DependencyObject 体系里的对象,有线程亲和性。如果在非 UI 线程创建,之后 UI 线程去渲染它就会抛异常------而且异常点往往在渲染阶段,堆栈指向 LiveCharts 内部,看起来跟你的代码无关。

所以创建过程要用 Dispatcher 包起来:

csharp 复制代码
public DashBoardViewModel()
{
    PlcService.DataReceived += OnDataReceived;

    Application.Current.Dispatcher.BeginInvoke(() =>
    {
        TempLiveCharts = new SeriesCollection
        {
            // ... 两条 LineSeries
        };
    });
}

用 BeginInvoke 而不是 Invoke:构造过程不需要等图表建完,异步投递到 UI 队列就行,不会阻塞当前线程。

同样的道理,后续每次改 Values 也要在 UI 线程。DataReceived 是从轮询线程发出的,所以更新前必须切线程:

csharp 复制代码
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 篇讲过断线要清零,曲线同样适用:

csharp 复制代码
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 里非常容易踩的坑:本地值为什么会让触发器失效。

相关推荐
泛凡(Linyongui)21 小时前
基于 CW32L010 的智能厨卫调温旋钮方案--低成本 EC21 编码旋钮全解析
单片机·嵌入式硬件·c#
暴走约伯1 天前
【VFX工具链源码拆解 02】告别机械劳动(上):一键统改百个子粒子颜色与批量重命名
3d·unity·c#·材质
信息安全失业大专人员1 天前
每日了解一个智能体-WorkBuddy
开发语言·ai·c#·智能体
呆呆敲代码的小Y1 天前
【实战精选面试题】C# 面试题详细解答,按难易程度排序
android·java·游戏·c#·游戏开发·unity面试题·c#面试题
淡海水1 天前
09-01-内存-Unity资源生命周期全景
unity·c#·游戏引擎·.netcore·gc
椒颜皮皮虾྅1 天前
让 C# 读懂整张图:DeploySharp 驱动 PaddleOCR v4/v5/v6,打通 OCR 全流程
开发语言·人工智能·深度学习·学习·c#·ocr·cuda
昂好的day1 天前
C# 同步异步与多线程:Thread / Task / 线程池一次讲透(上篇)
开发语言·windows·c#
avi91111 天前
电影开源Jevdo - 源码介绍(vue版)没有android 和 没有wpf
android·wpf·框架·数据爬虫·meego·电影管理
吃一碗粉只付一碗钱2 天前
12-给SPA友好的401和403响应
c#·状态模式
caimouse2 天前
MonoDevelop 编译用户应用程序时调用的是哪个编译器
c#