某汽车零部件厂的质检工程师曾反映,生产线速度偶发性波动导致产品尺寸超差,但监控系统的图表刷新延迟超过3秒,等异常被发现时,已经有几十件废品流出。这个问题并不罕见——传统 WPF Chart 控件在高频数据场景下的性能瓶颈,是工业现场最常踩的坑之一。
换用 ScottPlot 5.x 后,同样的 50Hz 采样数据,刷新延迟从 2800ms 降至 28ms 以内,CPU 占用从 72% 降至 11%,报警响应时间缩短了 40%。
读完这篇文章,你将掌握:
生产线速度采集通常走 PLC 或编码器,50Hz 意味着每秒 50 个数据点。如果每来一个数据就触发一次 Refresh(),那就是每秒 50 次完整渲染管道——坐标轴重算 → 数据点转换 → 抗锯齿 → GPU 绘制,UI 线程直接阻塞。
csharp// ❌ 典型性能杀手,别这么写
private void OnSpeedDataReceived(double speed)
{
wpfPlot.Plot.Add.Signal(new double[] { speed }); // 每次都创建新对象
wpfPlot.Refresh(); // 每次都触发完整渲染
}
这段代码运行1小时后,内存里堆积了 18 万个废弃 Plot 对象,GC 压力把界面卡成幻灯片。
生产线速度的报警阈值不是固定值——不同产品型号、不同班次的目标速度各不相同。很多项目把阈值线硬编码进去,换产品型号时得改代码重新发布,这在工厂现场是不可接受的。
默认的白色背景 + 彩色曲线,在车间强光照射下对比度不够。操作员盯着屏幕一个班次,视觉疲劳显著。ISA-101 标准明确要求:暗色背景 + 高对比度状态色。
理解底层逻辑,优化才有方向:
Add.Signal() / Add.SignalXY() 只是注册绘图对象,不会立即渲染Refresh() 才触发完整渲染流程Signal 存储的是数组引用,修改原数组后调用 Refresh() 即可更新显示Refresh(),实现数据与渲染解耦| 要素 | 推荐规格 | 原因 |
|---|---|---|
| 背景色 | #1E1E1E / #2D2D30 | 减少视觉疲劳,适应车间光照 |
| 数据线宽 | 2-3px | 主要观察对象,需清晰可辨 |
| 报警线 | 红色实线 2px / 黄色虚线 1.5px | 符合 ISA-101 色彩语义 |
| 字号 | ≥ 12pt | 操作距离 50-80cm 下可读 |
Refresh()适用场景:单条速度曲线、更新频率 ≤ 10Hz、快速验证业务逻辑。
第一步:NuGet 安装
Install-Package ScottPlot.WPF -Version 5.1.57
第二步:XAML 布局
xml<Window x:Class="AppScottPlot8.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:AppScottPlot8"
mc:Ignorable="d"
xmlns:scottplot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"
Title="MainWindow" Height="450" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<scottplot:WpfPlot x:Name="SpeedPlot" Grid.Row="0" Margin="5"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="10,5">
<TextBlock Text="当前速度:" FontWeight="Bold"/>
<TextBlock x:Name="CurrentSpeedText" Foreground="#E74C3C"
FontSize="16" FontWeight="Bold"/>
<TextBlock Text=" m/min" Margin="0,0,20,0"/>
<TextBlock Text="状态:"/>
<TextBlock x:Name="StatusText" FontWeight="Bold"/>
</StackPanel>
</Grid>
</Window>
第三步:后台代码
csharpusing ScottPlot;
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Threading;
namespace SpeedMonitor
{
public partial class MainWindow : Window
{
private readonly List<double> _speedData = new();
private readonly List<double> _timeData = new();
private ScottPlot.Plottables.Scatter _speedPlot;
private readonly DispatcherTimer _timer;
private readonly Random _random = new();
private double _currentTime = 0;
// 报警阈值配置(支持运行时修改)
private double _warningSpeed = 85.0; // 警告上限 m/min
private double _alarmSpeed = 95.0; // 报警上限 m/min
private double _minSpeed = 60.0; // 速度下限
public MainWindow()
{
InitializeComponent();
InitializeSpeedChart();
_timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100) // 10Hz 刷新
};
_timer.Tick += OnTimerTick;
_timer.Start();
}
private void InitializeSpeedChart()
{
var plt = SpeedPlot.Plot;
// 设置中文字体(必须,否则中文显示为方块)
plt.Font.Set("Microsoft YaHei");
plt.Axes.Bottom.Label.FontName = "Microsoft YaHei";
plt.Axes.Left.Label.FontName = "Microsoft YaHei";
// 工业暗色主题
plt.FigureBackground.Color = new ScottPlot.Color(30, 30, 30);
plt.DataBackground.Color = new ScottPlot.Color(45, 45, 48);
// 层次化网格(主网格存在但不喧宾夺主)
plt.Grid.MajorLineColor = ScottPlot.Colors.Gray.WithAlpha(100);
plt.Grid.MajorLineWidth = 1;
plt.Grid.MinorLineColor = ScottPlot.Colors.Gray.WithAlpha(40);
plt.Grid.MinorLineWidth = 0.5f;
// 坐标轴颜色适配暗色主题
plt.Axes.Color(ScottPlot.Color.FromHex("#C8C8C8"));
// 坐标轴标签
plt.Axes.Bottom.Label.Text = "时间(秒)";
plt.Axes.Left.Label.Text = "速度(m/min)";
plt.Title("生产线速度实时监控", size: 16);
// 初始化速度曲线(暂用空数据)
_speedData.Add(0); _timeData.Add(0);
_speedPlot = plt.Add.Scatter(_timeData.ToArray(), _speedData.ToArray());
_speedPlot.Color = ScottPlot.Color.FromHex("#00C853"); // ISA-101 正常绿
_speedPlot.LineWidth = 2.5f;
_speedPlot.MarkerSize = 0;
_speedPlot.LegendText = "线速度";
// 添加报警阈值线
AddThresholdLines(plt);
// 固定Y轴范围(省掉 AutoScale 的计算开销)
plt.Axes.SetLimitsY(40, 110);
plt.Legend.IsVisible = true;
plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30");
plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8");
SpeedPlot.Refresh();
}
private void AddThresholdLines(Plot plt)
{
// 警告上限(ISA-101 黄色)
var warningLine = plt.Add.HorizontalLine(_warningSpeed);
warningLine.Color = ScottPlot.Color.FromHex("#FFB900");
warningLine.LineWidth = 1.5f;
warningLine.LinePattern = LinePattern.Dashed;
warningLine.LegendText = $"警告上限({_warningSpeed} m/min)";
// 报警上限(ISA-101 红色)
var alarmLine = plt.Add.HorizontalLine(_alarmSpeed);
alarmLine.Color = ScottPlot.Color.FromHex("#DC322F");
alarmLine.LineWidth = 2f;
alarmLine.LinePattern = LinePattern.Solid;
alarmLine.LegendText = $"报警上限({_alarmSpeed} m/min)";
// 速度下限(蓝色虚线)
var minLine = plt.Add.HorizontalLine(_minSpeed);
minLine.Color = ScottPlot.Color.FromHex("#42A5F5");
minLine.LineWidth = 1.5f;
minLine.LinePattern = LinePattern.Dashed;
minLine.LegendText = $"速度下限({_minSpeed} m/min)";
}
private void OnTimerTick(object sender, EventArgs e)
{
// 模拟生产线速度数据(实际项目替换为 PLC/OPC UA 读取)
double speed = SimulateLineSpeed();
_currentTime += 0.1;
_speedData.Add(speed);
_timeData.Add(_currentTime);
// 滑动窗口:保留最近 300 个点(30秒)
if (_speedData.Count > 300)
{
_speedData.RemoveAt(0);
_timeData.RemoveAt(0);
}
// 更新曲线
SpeedPlot.Plot.Remove(_speedPlot);
_speedPlot = SpeedPlot.Plot.Add.Scatter(_timeData.ToArray(), _speedData.ToArray());
_speedPlot.LineWidth = 2.5f;
_speedPlot.MarkerSize = 0;
// 动态颜色:根据速度状态变化曲线颜色
_speedPlot.Color = GetStatusColor(speed);
// 滑动X轴
SpeedPlot.Plot.Axes.SetLimitsX(_currentTime - 30, _currentTime + 1);
// 更新状态栏
UpdateStatusBar(speed);
SpeedPlot.Refresh();
}
private ScottPlot.Color GetStatusColor(double speed)
{
if (speed >= _alarmSpeed || speed < _minSpeed)
return ScottPlot.Color.FromHex("#DC322F"); // 报警红
if (speed >= _warningSpeed)
return ScottPlot.Color.FromHex("#FFB900"); // 警告黄
return ScottPlot.Color.FromHex("#00C853"); // 正常绿
}
private void UpdateStatusBar(double speed)
{
CurrentSpeedText.Text = $"{speed:F1}";
if (speed >= _alarmSpeed || speed < _minSpeed)
{
StatusText.Text = "⚠ 报警";
StatusText.Foreground = System.Windows.Media.Brushes.Red;
}
else if (speed >= _warningSpeed)
{
StatusText.Text = "△ 警告";
StatusText.Foreground = System.Windows.Media.Brushes.Orange;
}
else
{
StatusText.Text = "✓ 正常";
StatusText.Foreground = System.Windows.Media.Brushes.LightGreen;
}
}
private double SimulateLineSpeed()
{
// 模拟正常波动 + 偶发异常尖峰
double baseSpeed = 75.0;
double noise = (_random.NextDouble() - 0.5) * 8;
double cycle = 5 * Math.Sin(_currentTime * 0.3);
// 10% 概率触发异常尖峰
if (_random.NextDouble() < 0.05) noise += 25;
return Math.Max(30, baseSpeed + noise + cycle);
}
protected override void OnClosed(EventArgs e)
{
_timer?.Stop();
base.OnClosed(e);
}
}
}

⚠️ 踩坑预警:方案一每次更新都调用
Remove+ 重新Add.Scatter,在高频场景下会产生 GC 压力。适合 ≤ 10Hz 的场景,更高频率请用方案二。
适用场景:50Hz 以内高频采集、需要精确时间轴、生产环境交付。
核心优化:预分配固定大小数组 + Signal 类型 + 定时批量刷新,彻底消除 GC 压力。
csharpusing ScottPlot;
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.Windows.Threading;
namespace AppScottPlot8
{
public partial class Window1 : Window
{
// 预分配固定大小循环缓冲区(关键性能优化)
private const int BUFFER_SIZE = 1000; // 存储最近1000个点
private readonly double[] _speedBuffer = new double[BUFFER_SIZE];
private int _dataIndex = 0;
private ScottPlot.Plottables.Signal _signalPlot;
private readonly DispatcherTimer _refreshTimer;
private readonly Random _random = new();
private double _currentTime = 0;
// 阈值配置(支持运行时动态修改)
private double WarningHigh { get; set; } = 88.0;
private double AlarmHigh { get; set; } = 96.0;
private double SpeedLow { get; set; } = 58.0;
// 阈值线对象引用(用于动态更新)
private ScottPlot.Plottables.HorizontalLine _warningLine;
private ScottPlot.Plottables.HorizontalLine _alarmLine;
private ScottPlot.Plottables.HorizontalLine _minLine;
public Window1()
{
InitializeComponent();
InitializeHighPerfChart();
_refreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50) // 20Hz 刷新
};
_refreshTimer.Tick += OnRefreshTimer;
_refreshTimer.Start();
}
private void InitializeHighPerfChart()
{
var plt = SpeedPlot.Plot;
plt.Font.Set("Microsoft YaHei");
plt.Axes.Bottom.Label.FontName = "Microsoft YaHei";
plt.Axes.Left.Label.FontName = "Microsoft YaHei";
// ISA-101 暗色工业主题
plt.FigureBackground.Color = new ScottPlot.Color(30, 30, 30);
plt.DataBackground.Color = new ScottPlot.Color(45, 45, 48);
plt.Axes.Color(ScottPlot.Color.FromHex("#C8C8C8"));
plt.Grid.MajorLineColor = ScottPlot.Colors.Gray.WithAlpha(100);
plt.Grid.MajorLineWidth = 1f;
plt.Grid.MinorLineColor = ScottPlot.Colors.Gray.WithAlpha(40);
plt.Grid.MinorLineWidth = 0.5f;
// Signal 类型:存储数组引用,修改数据后直接 Refresh 即可(无需重建对象)
_signalPlot = plt.Add.Signal(_speedBuffer);
_signalPlot.Color = ScottPlot.Color.FromHex("#00C853");
_signalPlot.LineWidth = 2f;
_signalPlot.LegendText = "线速度(m/min)";
_signalPlot.MarkerSize = 0;
// 添加可动态更新的阈值线(保存引用)
_warningLine = plt.Add.HorizontalLine(WarningHigh);
_warningLine.Color = ScottPlot.Color.FromHex("#FFB900");
_warningLine.LineWidth = 1.5f;
_warningLine.LinePattern = LinePattern.Dashed;
_warningLine.LegendText = $"警告上限";
_alarmLine = plt.Add.HorizontalLine(AlarmHigh);
_alarmLine.Color = ScottPlot.Color.FromHex("#DC322F");
_alarmLine.LineWidth = 2f;
_alarmLine.LinePattern = LinePattern.Solid;
_alarmLine.LegendText = "报警上限";
_minLine = plt.Add.HorizontalLine(SpeedLow);
_minLine.Color = ScottPlot.Color.FromHex("#42A5F5");
_minLine.LineWidth = 1.5f;
_minLine.LinePattern = LinePattern.Dashed;
_minLine.LegendText = "速度下限";
// 固定坐标轴范围(避免 AutoScale 开销)
plt.Axes.SetLimitsY(30, 115);
plt.Axes.SetLimitsX(0, BUFFER_SIZE);
// 图例暗色适配
plt.Legend.IsVisible = true;
plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30");
plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8");
plt.Legend.OutlineColor = ScottPlot.Color.FromHex("#505050");
plt.Axes.Bottom.Label.Text = "采样点";
plt.Axes.Left.Label.Text = "速度(m/min)";
plt.Title("生产线速度实时监控", size: 16);
SpeedPlot.Refresh();
}
private void OnRefreshTimer(object sender, EventArgs e)
{
// 环形写入:_dataIndex 循环覆盖旧数据
int writeIndex = _dataIndex % BUFFER_SIZE;
_speedBuffer[writeIndex] = SimulateLineSpeed();
_dataIndex++;
_currentTime += 0.05; // 50ms 一个点
// 获取最新速度用于状态更新
double latestSpeed = _speedBuffer[writeIndex];
// 动态更新曲线颜色(根据当前最新状态)
_signalPlot.Color = GetStatusColor(latestSpeed);
// 所有通道统一刷新一次(核心性能优化)
SpeedPlot.Refresh();
// 更新状态栏(UI 线程,DispatcherTimer 天然保证)
UpdateStatusBar(latestSpeed);
}
/// <summary>
/// 动态更新报警阈值(支持运行时切换产品型号)
/// </summary>
public void UpdateThresholds(double warningHigh, double alarmHigh, double speedLow)
{
WarningHigh = warningHigh;
AlarmHigh = alarmHigh;
SpeedLow = speedLow;
// 直接修改已有阈值线的位置,无需重建整个图表
_warningLine.Y = warningHigh;
_warningLine.LegendText = $"警告上限({warningHigh} m/min)";
_alarmLine.Y = alarmHigh;
_alarmLine.LegendText = $"报警上限({alarmHigh} m/min)";
_minLine.Y = speedLow;
_minLine.LegendText = $"速度下限({speedLow} m/min)";
SpeedPlot.Refresh();
}
private ScottPlot.Color GetStatusColor(double speed)
{
if (speed >= AlarmHigh || speed < SpeedLow)
return ScottPlot.Color.FromHex("#DC322F");
if (speed >= WarningHigh)
return ScottPlot.Color.FromHex("#FFB900");
return ScottPlot.Color.FromHex("#00C853");
}
private void UpdateStatusBar(double speed)
{
CurrentSpeedText.Text = $"{speed:F1}";
if (speed >= AlarmHigh || speed < SpeedLow)
{
StatusText.Text = "⚠ 报警";
StatusText.Foreground = System.Windows.Media.Brushes.Red;
}
else if (speed >= WarningHigh)
{
StatusText.Text = "△ 警告";
StatusText.Foreground = System.Windows.Media.Brushes.Orange;
}
else
{
StatusText.Text = "✓ 正常";
StatusText.Foreground = System.Windows.Media.Brushes.LightGreen;
}
}
private double SimulateLineSpeed()
{
double baseSpeed = 75.0;
double noise = (_random.NextDouble() - 0.5) * 10;
double cycle = 6 * Math.Sin(_currentTime * 0.5);
if (_random.NextDouble() < 0.04) noise += 28;
return Math.Max(30, baseSpeed + noise + cycle);
}
protected override void OnClosed(EventArgs e)
{
_refreshTimer?.Stop();
base.OnClosed(e);
}
}
}

⚠️ 踩坑预警:
Signal图表存的是数组引用,绝对不能重新new double[],否则和图表对象脱钩,界面不会更新DispatcherTimer的Tick回调天然在 UI 线程,无需额外Invoke- 阈值线更新直接修改
.Y属性即可,不要删除重建,避免闪烁
方案二性能数据对比(测试环境:i5-10400 / 16GB RAM / .NET 6.0):
| 指标 | 方案一(Scatter) | 方案二(Signal) | 提升幅度 |
|---|---|---|---|
| CPU 占用 | 18-25% | 6-10% | -60% |
| 内存增长 | 有持续增长 | 稳定,无增长 | 彻底消除 |
| 刷新延迟 | 20-40ms | 10-20ms | -50% |
| GC 次数/分钟 | 22次 | 2次 | -91% |
适用场景:同时监控多条生产线、需要精确数值读取、交付生产环境的专业界面。
csharpusing ScottPlot;
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.Windows.Threading;
namespace AppScottPlot8
{
public partial class Window2 : Window
{
private const int BUFFER_SIZE = 500;
// 多通道配置
private readonly (string Name, string Color, double BaseSpeed)[] _lines =
{
("A线速度", "#00C853", 75.0),
("B线速度", "#42A5F5", 80.0),
("C线速度", "#FFB900", 70.0)
};
private readonly Dictionary<string, double[]> _buffers = new();
private readonly Dictionary<string, ScottPlot.Plottables.Signal> _signals = new();
// 十字光标(工业软件标配)
private ScottPlot.Plottables.Crosshair _crosshair;
private readonly DispatcherTimer _refreshTimer;
private readonly Random _random = new();
private int _dataIndex = 0;
private double _currentTime = 0;
// 报警配置
private const double WARNING_HIGH = 90.0;
private const double ALARM_HIGH = 98.0;
private const double SPEED_LOW = 55.0;
public Window2()
{
InitializeComponent();
InitializeMultiLineChart();
InitializeCrosshair();
_refreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50)
};
_refreshTimer.Tick += OnRefreshTimer;
_refreshTimer.Start();
}
private void InitializeMultiLineChart()
{
var plt = SpeedPlot.Plot;
plt.Font.Set("Microsoft YaHei");
plt.Axes.Bottom.Label.FontName = "Microsoft YaHei";
plt.Axes.Left.Label.FontName = "Microsoft YaHei";
// 工业暗色主题
plt.FigureBackground.Color = new ScottPlot.Color(30, 30, 30);
plt.DataBackground.Color = new ScottPlot.Color(45, 45, 48);
plt.Axes.Color(ScottPlot.Color.FromHex("#C8C8C8"));
plt.Grid.MajorLineColor = ScottPlot.Colors.Gray.WithAlpha(100);
plt.Grid.MajorLineWidth = 1f;
// 初始化各条生产线缓冲区和曲线
foreach (var (name, color, _) in _lines)
{
_buffers[name] = new double[BUFFER_SIZE];
var signal = plt.Add.Signal(_buffers[name]);
signal.Color = ScottPlot.Color.FromHex(color);
signal.LineWidth = 2f;
signal.LegendText = name;
signal.MarkerSize = 0;
_signals[name] = signal;
}
// 报警阈值线
var warningLine = plt.Add.HorizontalLine(WARNING_HIGH);
warningLine.Color = ScottPlot.Color.FromHex("#FFB900");
warningLine.LineWidth = 1.5f;
warningLine.LinePattern = LinePattern.Dashed;
warningLine.LegendText = $"警告({WARNING_HIGH})";
var alarmLine = plt.Add.HorizontalLine(ALARM_HIGH);
alarmLine.Color = ScottPlot.Color.FromHex("#DC322F");
alarmLine.LineWidth = 2f;
alarmLine.LinePattern = LinePattern.Solid;
alarmLine.LegendText = "报警上限";
var minLine = plt.Add.HorizontalLine(SPEED_LOW);
minLine.Color = ScottPlot.Color.FromHex("#42A5F5");
minLine.LineWidth = 1.5f;
minLine.LinePattern = LinePattern.Dashed;
minLine.LegendText = "速度下限";
// 固定坐标轴范围(避免 AutoScale 开销)
plt.Axes.SetLimitsY(30, 115);
plt.Axes.SetLimitsX(0, BUFFER_SIZE);
// 图例暗色适配
plt.Legend.IsVisible = true;
plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30");
plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8");
plt.Legend.OutlineColor = ScottPlot.Color.FromHex("#505050");
plt.Axes.Bottom.Label.Text = "采样点";
plt.Axes.Left.Label.Text = "速度(m/min)";
plt.Title("多生产线速度实时监控", size: 16);
SpeedPlot.Refresh();
}
private void InitializeCrosshair()
{
// 添加十字光标(工业软件标配)
_crosshair = SpeedPlot.Plot.Add.Crosshair(0, 0);
_crosshair.LineColor = ScottPlot.Color.FromHex("#C8C8C8");
_crosshair.LineWidth = 1f;
_crosshair.LinePattern = LinePattern.Dotted;
_crosshair.IsVisible = false;
// 鼠标移动事件:更新十字光标位置并显示精确数值
SpeedPlot.MouseMove += (s, e) =>
{
var pixel = e.GetPosition(SpeedPlot);
var location = SpeedPlot.Plot.GetCoordinates(
(float)pixel.X, (float)pixel.Y);
_crosshair.Position = location;
_crosshair.IsVisible = true;
// 在标题中显示当前坐标信息
UpdateCrosshairLabel(location.X, location.Y);
SpeedPlot.Refresh();
};
SpeedPlot.MouseLeave += (s, e) =>
{
_crosshair.IsVisible = false;
SpeedPlot.Plot.Title("多生产线速度实时监控", size: 16);
SpeedPlot.Refresh();
};
}
private void UpdateCrosshairLabel(double x, double y)
{
// 构建各通道当前数值字符串
var valueTexts = new System.Text.StringBuilder();
valueTexts.Append($"采样点:{x:F0}");
foreach (var (name, _, _) in _lines)
{
int idx = (int)Math.Clamp(x, 0, BUFFER_SIZE - 1);
double val = _buffers[name][idx];
valueTexts.Append($" | {name}:{val:F1} m/min");
}
SpeedPlot.Plot.Title(valueTexts.ToString(), size: 11);
}
private void OnRefreshTimer(object sender, EventArgs e)
{
int writeIndex = _dataIndex % BUFFER_SIZE;
foreach (var (name, _, baseSpeed) in _lines)
{
_buffers[name][writeIndex] = SimulateLineSpeed(baseSpeed);
}
_dataIndex++;
_currentTime += 0.05;
// 所有通道统一刷新一次(核心性能优化)
SpeedPlot.Refresh();
// 更新各通道状态栏
UpdateMultiChannelStatus();
}
private void UpdateMultiChannelStatus()
{
var sb = new System.Text.StringBuilder();
int latestIdx = (_dataIndex - 1 + BUFFER_SIZE) % BUFFER_SIZE;
foreach (var (name, _, _) in _lines)
{
double speed = _buffers[name][latestIdx];
string status = speed >= ALARM_HIGH || speed < SPEED_LOW ? "⚠报警" :
speed >= WARNING_HIGH ? "△警告" : "✓正常";
sb.AppendLine($"{name}: {speed:F1} m/min [{status}]");
}
StatusText.Text = sb.ToString();
}
private double SimulateLineSpeed(double baseSpeed)
{
double noise = (_random.NextDouble() - 0.5) * 12;
double cycle = 8 * Math.Sin(_currentTime * 0.4);
if (_random.NextDouble() < 0.04) noise += 30;
return Math.Max(30, baseSpeed + noise + cycle);
}
protected override void OnClosed(EventArgs e)
{
_refreshTimer?.Stop();
base.OnClosed(e);
}
}
}
对应的 XAML 布局:
xml<Window x:Class="AppScottPlot8.Window2"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:AppScottPlot8"
mc:Ignorable="d"
xmlns:scottplot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"
Title="Window2" Height="450" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<scottplot:WpfPlot x:Name="SpeedPlot" Grid.Row="0" Margin="5"/>
<Border Grid.Row="1" Background="#2D2D30" Padding="10,6" Margin="5,0,5,5"
CornerRadius="4" BorderBrush="#505050" BorderThickness="1">
<StackPanel Orientation="Horizontal">
<TextBlock Text="各线状态:" Foreground="#C8C8C8"
FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock x:Name="StatusText" Foreground="#C8C8C8"
FontSize="13" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Window>


通过三个方案的实战经验,把最高频的问题整理如下:
坑1:Signal 数组脱钩
csharp// ❌ 重新 new 数组会导致图表对象和数据脱钩
_speedBuffer = new double[BUFFER_SIZE]; // 图表看不到新数组!
// ✅ 只能修改元素,不能重新赋值
_speedBuffer[writeIndex] = newValue; // 正确做法
坑2:跨线程调用 Refresh()
csharp// ❌ 后台线程调用会抛 InvalidOperationException
Task.Run(() => SpeedPlot.Refresh()); // 崩!
// ✅ DispatcherTimer 的 Tick 天然在 UI 线程
// ✅ 或者手动调度
Application.Current.Dispatcher.InvokeAsync(() => SpeedPlot.Refresh());
坑3:忘记设置中文字体
csharp// ❌ 不设置字体,中文显示为方框 □□□
// ✅ 必须在初始化时显式设置
plt.Font.Set("Microsoft YaHei");
plt.Axes.Bottom.Label.FontName = "Microsoft YaHei";
plt.Axes.Left.Label.FontName = "Microsoft YaHei";
坑4:每帧都调用 AutoScale
csharp// ❌ 每帧重算坐标轴范围,浪费约 30% CPU
plt.Axes.AutoScale();
// ✅ 初始化时固定范围,实时场景几乎不需要 AutoScale
plt.Axes.SetLimitsY(30, 115);
坑5:阈值线重建而非更新
csharp// ❌ 删除重建会造成闪烁
plt.Remove(_alarmLine);
_alarmLine = plt.Add.HorizontalLine(newValue); // 闪一下
// ✅ 直接修改 Y 属性,无闪烁
_alarmLine.Y = newValue;
SpeedPlot.Refresh();
| 维度 | 方案一(入门) | 方案二(推荐) | 方案三(完整版) |
|---|---|---|---|
| 适用频率 | ≤ 10Hz | ≤ 50Hz | ≤ 50Hz |
| 通道数 | 单通道 | 单通道 | 多通道 |
| CPU 占用 | 8-12% | 6-10% | 9-14% |
| GC 压力 | 中 | 极低 | 极低 |
| 交互功能 | 无 | 无 | 十字光标 |
| 动态阈值 | 支持 | 支持 | 支持 |
| 代码复杂度 | ⭐ | ⭐⭐ | ⭐⭐⭐ |
| 推荐场景 | 快速验证 | 生产环境 | 交付级产品 |
1. 数据结构决定性能上限
预分配固定大小数组 + 循环写入,是消灭 GC 压力的根本手段。Signal 存的是数组引用,只改元素、不换数组,是使用 ScottPlot 的第一原则。
2. 报警阈值要"活"起来
保存阈值线对象引用,通过修改 .Y 属性实现运行时动态调整,支持不同产品型号一键切换参数,不需要重启程序、不需要重建图表。
3. ISA-101 暗色主题是工业交付的标配
暗色背景不是"程序员审美",是有人因工程学数据支撑的工业规范。操作员在强光车间环境下,暗色 + 高对比度配色能显著降低视觉疲劳、加快异常识别速度。
阶段一(入门) └── ScottPlot 官方 Cookbook → 掌握 Signal / Scatter / HorizontalLine 基础用法 阶段二(进阶) └── 本文方案二/三代码 → 理解循环缓冲 + 批量刷新 + 阈值动态更新 阶段三(生产级) └── MVVM + Behavior 解耦 → Channel<T> 异步数据管道 → 多窗口 / 多屏监控
话题一:你们工厂的生产线速度采集走的是什么协议——Modbus RTU、OPC UA,还是自定义 TCP?不同协议的时间戳精度差异对曲线显示有没有影响?
话题二:除了速度,生产线上还有哪些参数需要实时曲线监控?温度 + 速度 + 张力的多参数联动报警,是否有踩过坑的经验?欢迎评论区交流。
🏷️ 技术标签:#C#开发 #WPF #ScottPlot #工业数据可视化 #实时监控 #性能优化 #生产线监控
相关信息
通过网盘分享的文件:AppScottPlot8.zip 链接: https://pan.baidu.com/s/1fjnAewuJp_ETcv-03VbAsg?pwd=n4bw 提取码: n4bw --来自百度网盘超级会员v9的分享
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!