编辑
2026-04-29
C#
00

目录

💀 那些年,我们踩过的界面"坑"
🎯 为什么选择ScottPlot?三个字:真香!
性能表现让人意外
API设计符合程序员直觉
🏗️ 实战项目:流量计监控系统架构揭秘
核心设计思路
数据模型设计
状态管理的小心机
🚀 性能优化:让界面飞起来的黑科技
环形缓冲区:内存杀手的克星
Signal绘图:大数据量的终极武器
UI刷新频率的黄金平衡
💡 界面设计:工业风的精髓所在
暗色主题不是为了"装X"
颜色语言:让数据"会说话"
🎮 交互体验:细节决定成败
十字光标:精确定位的神器
动态Y轴:跟随数据自动调整
🔧 数据模拟:逼真才有说服力
复合信号生成
异常检测逻辑
🎯 线程安全:多线程环境下的防坑指南
生产者-消费者模式
锁的艺术
📊 部署实战:从开发到生产的最后一公里
NuGet包依赖
性能监控建议
👨‍💻 完整代码
🍢 运行样式
🎉 总结:打造工业级可视化的三个关键
1️⃣ 性能为王
2️⃣ 体验至上
3️⃣ 架构清晰

写在前面:咱们做工业软件的,谁还没被那些丑到爆的监控界面折磨过?今天就来聊聊如何用C#打造一个让甲方爸爸都夸赞的实时数据可视化系统。

💀 那些年,我们踩过的界面"坑"

说起工业监控软件的界面...emmm,怎么说呢?

大多数时候是这样的:灰突突的背景,花花绿绿的按钮,还有那些让人眼花缭乱的数据表格。更要命的是——卡!顿!崩!溃!

我记得之前接手一个流量监控项目。客户很直接:"小张啊,你看这界面,员工都不愿意用。能不能搞得专业一点?"

专业一点?这可难倒我了。

直到遇见了ScottPlot...

🎯 为什么选择ScottPlot?三个字:真香!

性能表现让人意外

咱们先看数据:

  • 传统Chart控件:1000个数据点卡成狗
  • ScottPlot Signal:100万个数据点依然丝滑

这差距,简直是"五菱宏光"和"特斯拉"的区别。

image.png

API设计符合程序员直觉

csharp
// 传统方式(想想都头疼) chart.Series[0].Points.Clear(); foreach(var point in dataPoints) { chart.Series[0].Points.AddXY(point.X, point.Y); } chart.Invalidate(); // 还要手动刷新... // ScottPlot方式(一行搞定) plot.Add.Signal(dataArray); plot.Refresh(); // 就这么简单

看到没?这就是"人性化"设计的力量。

🏗️ 实战项目:流量计监控系统架构揭秘

核心设计思路

我们要搞的不是玩具,而是能上生产环境的"工业级"系统。

MVVM模式 + 实时数据流 + 高性能图表

这套组合拳,稳!

数据模型设计

csharp
public class FlowData { public DateTime Timestamp { get; set; } public double InstantFlow { get; set; } // 瞬时流量 public double TotalFlow { get; set; } // 累计流量 public FlowStatus Status { get; set; } // 四级状态 public double Pressure { get; set; } // 管道压力 }

简单?对,就是要简单!复杂的业务逻辑交给服务层去处理。

状态管理的小心机

csharp
public enum FlowStatus { Normal, // ✅ 正常 Warning, // ⚠️ 警告 Alarm, // 🚨 报警 Low // ⬇️ 低流量 }

四个状态,对应四种颜色,对应四种处理策略。

工业现场就是这么直接。

🚀 性能优化:让界面飞起来的黑科技

环形缓冲区:内存杀手的克星

csharp
public const int BUFFER_SIZE = 1000; private double[] InstantFlowBuffer; private int DataIndex = 0; // 环形写入,永不溢出 int writeIndex = DataIndex % BUFFER_SIZE; InstantFlowBuffer[writeIndex] = data.InstantFlow; DataIndex++;

传统做法是List无限追加,内存占用越来越大。

我们用环形缓冲区——固定内存,无限数据。

这就是"以不变应万变"的智慧。

Signal绘图:大数据量的终极武器

csharp
// 创建Signal对象(直接引用数组) _instantSignal = plt.Add.Signal(_vm.InstantFlowBuffer); _instantSignal.Color = ScottPlot.Color.FromHex("#00C853"); _instantSignal.LineWidth = 2f; // 数据更新后直接刷新(无需重绘) InstantFlowPlot.Refresh();

Signal的核心思想:一次创建,持续更新

不像传统控件每次都要重新绘制所有点。

UI刷新频率的黄金平衡

csharp
// 数据采集:200ms(5Hz) _config.SampleIntervalMs = 200; // UI刷新:50ms(20Hz) _uiRefreshTimer.Interval = TimeSpan.FromMilliseconds(50);

为什么这样设计?

采集太快浪费资源,太慢响应不及时。

刷新太快眼睛跟不上,太慢感觉卡顿。

这个比例,刚好。

💡 界面设计:工业风的精髓所在

暗色主题不是为了"装X"

csharp
plt.FigureBackground.Color = new ScottPlot.Color(30, 30, 30); plt.DataBackground.Color = new ScottPlot.Color(45, 45, 48);

工业现场光线复杂,暗色主题:

  • 减少眼部疲劳
  • 突出关键信息
  • 营造专业氛围

这不是审美问题,这是功能性设计

颜色语言:让数据"会说话"

csharp
var color = _vm.FlowStatusColor switch { "#DC322F" => "🚨 报警状态", // 红色,立即处理 "#FFB900" => "⚠️ 警告状态", // 黄色,密切关注 "#42A5F5" => "⬇️ 低流量", // 蓝色,检查原因 _ => "✅ 正常运行" // 绿色,一切OK };

工业界面的颜色不是随便选的。

红色=危险,黄色=警告,蓝色=信息,绿色=正常。

这是国际通用标准

🎮 交互体验:细节决定成败

十字光标:精确定位的神器

csharp
private void SetupMouseInteraction() { // 瞬时流量图表鼠标交互 InstantFlowPlot.MouseMove += (s, e) => { var pixel = e.GetPosition(InstantFlowPlot); var location = InstantFlowPlot.Plot.GetCoordinates( (float)pixel.X, (float)pixel.Y); _instantCrosshair.Position = location; _instantCrosshair.IsVisible = true; // 标题显示当前坐标 InstantFlowPlot.Plot.Title( $"瞬时流量 | 采样点: {location.X:F0} 流量: {location.Y:F2} m³/h", size: 13); InstantFlowPlot.Refresh(); }; InstantFlowPlot.MouseLeave += (s, e) => { _instantCrosshair.IsVisible = false; InstantFlowPlot.Plot.Title("瞬时流量实时曲线", size: 13); InstantFlowPlot.Refresh(); }; // 累计流量图表鼠标交互 TotalFlowPlot.MouseMove += (s, e) => { var pixel = e.GetPosition(TotalFlowPlot); var location = TotalFlowPlot.Plot.GetCoordinates( (float)pixel.X, (float)pixel.Y); _totalCrosshair.Position = location; _totalCrosshair.IsVisible = true; TotalFlowPlot.Plot.Title( $"累计流量 | 采样点: {location.X:F0} 累计: {location.Y:F4} m³", size: 13); TotalFlowPlot.Refresh(); }; TotalFlowPlot.MouseLeave += (s, e) => { _totalCrosshair.IsVisible = false; TotalFlowPlot.Plot.Title("累计流量趋势", size: 13); TotalFlowPlot.Refresh(); }; } #endregion

鼠标悬停就能看到精确数值。

这种交互,比表格查数据爽多了。

动态Y轴:跟随数据自动调整

csharp
if (currentMax > limits.Top * 0.8) { // 当数据接近上限时,自动扩展范围 TotalFlowPlot.Plot.Axes.SetLimitsY(0, currentMax * 1.5); }

累计流量会不断增长,固定范围肯定不行。

但频繁调整又会让图表"跳动"。

我们的策略:到达80%阈值时,扩展到150%。

既保证显示效果,又避免频繁调整。

🔧 数据模拟:逼真才有说服力

复合信号生成

csharp
// 基准流量:额定值 + 正弦波 + 随机噪声 double baseFlow = _config.NominalFlow; double sineWave = 15.0 * Math.Sin(_currentTime * 0.05); double noise = (_random.NextDouble() - 0.5) * 8.0; double instantFlow = baseFlow + sineWave + noise; // 异常工况:流量突增 if (_isAnomalyMode) { instantFlow += 35.0 + _random.NextDouble() * 20.0; }

真实的工业数据不是简单的随机数。

它有周期性变化(设备运转周期)、有随机波动(测量误差)、有异常情况(故障工况)。

我们的模拟器考虑了这些因素。

异常检测逻辑

csharp
private FlowStatus GetFlowStatus(double flow) { if (flow >= _config.AlarmHigh) return FlowStatus.Alarm; if (flow >= _config.WarningHigh) return FlowStatus.Warning; if (flow < _config.FlowLow) return FlowStatus.Low; return FlowStatus.Normal; }

多级阈值判断,这是工业监控的标配。

不是简单的"正常/异常"二元判断,而是渐进式预警机制。

🎯 线程安全:多线程环境下的防坑指南

生产者-消费者模式

csharp
// 数据生成线程 private readonly ConcurrentQueue<FlowData> _dataQueue; // UI更新线程 App.Current.Dispatcher.InvokeAsync(() => { CurrentInstantFlow = data.InstantFlow; // ... 其他UI属性更新 });

数据采集在后台线程,UI更新在主线程。

ConcurrentQueue做线程安全的数据传递。

Dispatcher确保UI更新在正确线程。

这套模式,稳如泰山。

锁的艺术

csharp
private readonly object _bufferLock = new object(); lock (_bufferLock) { int writeIndex = DataIndex % BUFFER_SIZE; InstantFlowBuffer[writeIndex] = data.InstantFlow; DataIndex++; }

锁的粒度要合适:太大影响性能,太小容易死锁。

我们只锁核心数据写入部分。

读取操作不加锁(数组访问本身是原子的)。

📊 部署实战:从开发到生产的最后一公里

NuGet包依赖

xml
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageReference Include="ScottPlot.WPF" Version="5.1.57" /> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />

ScottPlot 5.x相比4.x:

  • 性能提升300%
  • API更简洁
  • 内存占用更小

MVVM Toolkit让数据绑定变得轻松。

性能监控建议

生产环境记得加上这些监控指标:

  • 内存使用量(防止内存泄漏)
  • CPU使用率(避免界面卡顿)
  • 数据丢失率(网络不稳定时)
  • 响应时间(用户体验指标)

👨‍💻 完整代码

c#
using System; using System.Windows; using System.Windows.Threading; using ScottPlot; using ScottPlot.Plottables; using AppScottPlot13.Models; using AppScottPlot13.ViewModels; namespace AppScottPlot13 { public partial class MainWindow : Window { #region ===== 私有字段 ===== private readonly FlowMonitorViewModel _vm; private readonly DispatcherTimer _uiRefreshTimer; // 瞬时流量图表对象 private Signal _instantSignal; private HorizontalLine _warningLine; private HorizontalLine _alarmLine; private HorizontalLine _lowLine; private Crosshair _instantCrosshair; // 累计流量图表对象 private Signal _totalSignal; private Crosshair _totalCrosshair; // 图表刷新控制 private bool _instantChartInitialized = false; private bool _totalChartInitialized = false; #endregion public MainWindow() { InitializeComponent(); _vm = new FlowMonitorViewModel(); DataContext = _vm; // 初始化两个图表 InitializeInstantFlowChart(); InitializeTotalFlowChart(); // 绑定鼠标交互 SetupMouseInteraction(); // UI 定时刷新(20Hz = 50ms) _uiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) }; _uiRefreshTimer.Tick += OnUiRefreshTick; _uiRefreshTimer.Start(); // 自动启动监控 _vm.StartMonitorCommand.Execute(null); } #region ===== 图表初始化 ===== /// <summary>初始化瞬时流量图表</summary> private void InitializeInstantFlowChart() { var plt = InstantFlowPlot.Plot; // ---- 工业暗色主题 ---- ApplyIndustrialTheme(plt); // ---- 创建信号曲线(存数组引用,修改数据后直接 Refresh)---- _instantSignal = plt.Add.Signal(_vm.InstantFlowBuffer); _instantSignal.Color = ScottPlot.Color.FromHex("#00C853"); _instantSignal.LineWidth = 2f; _instantSignal.LegendText = "瞬时流量 (m³/h)"; _instantSignal.MarkerSize = 0; // ---- 报警阈值线(保存引用以便动态更新)---- _alarmLine = plt.Add.HorizontalLine(_vm.Config.AlarmHigh); _alarmLine.Color = ScottPlot.Color.FromHex("#DC322F"); _alarmLine.LineWidth = 2f; _alarmLine.LinePattern = LinePattern.Solid; _alarmLine.LegendText = $"报警上限 ({_vm.Config.AlarmHigh} m³/h)"; _warningLine = plt.Add.HorizontalLine(_vm.Config.WarningHigh); _warningLine.Color = ScottPlot.Color.FromHex("#FFB900"); _warningLine.LineWidth = 1.5f; _warningLine.LinePattern = LinePattern.Dashed; _warningLine.LegendText = $"警告上限 ({_vm.Config.WarningHigh} m³/h)"; _lowLine = plt.Add.HorizontalLine(_vm.Config.FlowLow); _lowLine.Color = ScottPlot.Color.FromHex("#42A5F5"); _lowLine.LineWidth = 1.5f; _lowLine.LinePattern = LinePattern.Dashed; _lowLine.LegendText = $"低流量下限 ({_vm.Config.FlowLow} m³/h)"; // ---- 额定流量参考线 ---- var nominalLine = plt.Add.HorizontalLine(_vm.Config.NominalFlow); nominalLine.Color = ScottPlot.Color.FromHex("#505050"); nominalLine.LineWidth = 1f; nominalLine.LinePattern = LinePattern.Dotted; // ---- 十字光标 ---- _instantCrosshair = plt.Add.Crosshair(0, 0); _instantCrosshair.LineColor = ScottPlot.Color.FromHex("#C8C8C8"); _instantCrosshair.LineWidth = 1f; _instantCrosshair.LinePattern = LinePattern.Dotted; _instantCrosshair.IsVisible = false; // ---- 坐标轴配置 ---- // 固定Y轴范围(避免AutoScale计算开销) plt.Axes.SetLimitsY(-5, 180); plt.Axes.SetLimitsX(0, FlowMonitorViewModel.BUFFER_SIZE); plt.Axes.Bottom.Label.Text = "采样点"; plt.Axes.Left.Label.Text = "流量 (m³/h)"; plt.Title("瞬时流量", size: 14); // ---- Y轴精度格式化 ---- var leftGen = new ScottPlot.TickGenerators.NumericAutomatic { LabelFormatter = v => $"{v:F1}" }; plt.Axes.Left.TickGenerator = leftGen; // ---- 图例 ---- plt.Legend.IsVisible = true; plt.Legend.Alignment = Alignment.UpperRight; plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30"); plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8"); plt.Legend.OutlineColor = ScottPlot.Color.FromHex("#505050"); // ---- 关闭抗锯齿(大数据量性能优化)---- // plt.RenderManager.AntiAliasingLevel = 0; InstantFlowPlot.Refresh(); _instantChartInitialized = true; } /// <summary>初始化累计流量图表</summary> private void InitializeTotalFlowChart() { var plt = TotalFlowPlot.Plot; // ---- 工业暗色主题 ---- ApplyIndustrialTheme(plt); // ---- 创建累计流量信号曲线 ---- _totalSignal = plt.Add.Signal(_vm.TotalFlowBuffer); _totalSignal.Color = ScottPlot.Color.FromHex("#42A5F5"); _totalSignal.LineWidth = 2f; _totalSignal.LegendText = "累计流量 (m³)"; _totalSignal.MarkerSize = 0; // ---- 十字光标 ---- _totalCrosshair = plt.Add.Crosshair(0, 0); _totalCrosshair.LineColor = ScottPlot.Color.FromHex("#C8C8C8"); _totalCrosshair.LineWidth = 1f; _totalCrosshair.LinePattern = LinePattern.Dotted; _totalCrosshair.IsVisible = false; // ---- 坐标轴配置 ---- plt.Axes.SetLimitsX(0, FlowMonitorViewModel.BUFFER_SIZE); // 累计流量Y轴范围动态扩展,初始给一个合理范围 plt.Axes.SetLimitsY(0, 50); plt.Axes.Bottom.Label.Text = "采样点"; plt.Axes.Left.Label.Text = "累计流量 (m³)"; plt.Title("累计流量", size: 14); // ---- Y轴精度格式化(保留3位小数)---- var leftGen = new ScottPlot.TickGenerators.NumericAutomatic { LabelFormatter = v => $"{v:F3}" }; plt.Axes.Left.TickGenerator = leftGen; // ---- 图例 ---- plt.Legend.IsVisible = true; plt.Legend.Alignment = Alignment.UpperLeft; plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30"); plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8"); plt.Legend.OutlineColor = ScottPlot.Color.FromHex("#505050"); TotalFlowPlot.Refresh(); _totalChartInitialized = true; } private static void ApplyIndustrialTheme(Plot plt) { 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 = 1f; plt.Grid.MinorLineColor = ScottPlot.Colors.Gray.WithAlpha(40); plt.Grid.MinorLineWidth = 0.5f; // 坐标轴颜色 plt.Axes.Color(ScottPlot.Color.FromHex("#C8C8C8")); // 坐标轴框线 plt.Axes.Bottom.FrameLineStyle.Color = ScottPlot.Colors.Gray.WithAlpha(150); plt.Axes.Bottom.FrameLineStyle.Width = 1.5f; plt.Axes.Left.FrameLineStyle.Color = ScottPlot.Colors.Gray.WithAlpha(150); plt.Axes.Left.FrameLineStyle.Width = 1.5f; // 刻度标签字体大小 plt.Axes.Bottom.TickLabelStyle.FontSize = 11; plt.Axes.Left.TickLabelStyle.FontSize = 11; // 坐标轴标签样式 plt.Axes.Bottom.Label.FontSize = 12; plt.Axes.Bottom.Label.Bold = true; plt.Axes.Left.Label.FontSize = 12; plt.Axes.Left.Label.Bold = true; } #endregion #region ===== 定时刷新 ===== private void OnUiRefreshTick(object? sender, EventArgs e) { if (!_instantChartInitialized || !_totalChartInitialized) return; if (!_vm.IsRunning) return; // ---- 刷新瞬时流量图表 ---- UpdateInstantFlowChartColor(); InstantFlowPlot.Refresh(); // ---- 刷新累计流量图表 ---- // 累计流量单调递增,动态扩展Y轴上限 DynamicExpandTotalFlowYAxis(); TotalFlowPlot.Refresh(); } private void UpdateInstantFlowChartColor() { var color = _vm.FlowStatusColor switch { "#DC322F" => ScottPlot.Color.FromHex("#DC322F"), "#FFB900" => ScottPlot.Color.FromHex("#FFB900"), "#42A5F5" => ScottPlot.Color.FromHex("#42A5F5"), _ => ScottPlot.Color.FromHex("#00C853") }; _instantSignal.Color = color; } private void DynamicExpandTotalFlowYAxis() { double currentMax = _vm.CurrentTotalFlow; if (currentMax <= 0) return; // 获取当前Y轴上限 var limits = TotalFlowPlot.Plot.Axes.GetLimits(); if (currentMax > limits.Top * 0.8) { // 扩展为当前最大值的 1.5 倍 TotalFlowPlot.Plot.Axes.SetLimitsY(0, currentMax * 1.5); } } #endregion #region ===== 鼠标交互 ===== private void SetupMouseInteraction() { // 瞬时流量图表鼠标交互 InstantFlowPlot.MouseMove += (s, e) => { var pixel = e.GetPosition(InstantFlowPlot); var location = InstantFlowPlot.Plot.GetCoordinates( (float)pixel.X, (float)pixel.Y); _instantCrosshair.Position = location; _instantCrosshair.IsVisible = true; // 标题显示当前坐标 InstantFlowPlot.Plot.Title( $"瞬时流量 | 采样点: {location.X:F0} 流量: {location.Y:F2} m³/h", size: 13); InstantFlowPlot.Refresh(); }; InstantFlowPlot.MouseLeave += (s, e) => { _instantCrosshair.IsVisible = false; InstantFlowPlot.Plot.Title("瞬时流量实时曲线", size: 13); InstantFlowPlot.Refresh(); }; // 累计流量图表鼠标交互 TotalFlowPlot.MouseMove += (s, e) => { var pixel = e.GetPosition(TotalFlowPlot); var location = TotalFlowPlot.Plot.GetCoordinates( (float)pixel.X, (float)pixel.Y); _totalCrosshair.Position = location; _totalCrosshair.IsVisible = true; TotalFlowPlot.Plot.Title( $"累计流量 | 采样点: {location.X:F0} 累计: {location.Y:F4} m³", size: 13); TotalFlowPlot.Refresh(); }; TotalFlowPlot.MouseLeave += (s, e) => { _totalCrosshair.IsVisible = false; TotalFlowPlot.Plot.Title("累计流量趋势", size: 13); TotalFlowPlot.Refresh(); }; } #endregion #region ===== 窗口关闭 ===== protected override void OnClosed(EventArgs e) { _uiRefreshTimer?.Stop(); _vm?.Dispose(); base.OnClosed(e); } #endregion } }
c#
using System; using System.Collections.Generic; using System.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using AppScottPlot13.Models; using AppScottPlot13.Services; using System.Windows.Media; namespace AppScottPlot13.ViewModels { public partial class FlowMonitorViewModel : ObservableObject, IDisposable { #region ===== 属性定义 ===== [ObservableProperty] private double _currentInstantFlow; [ObservableProperty] private double _currentTotalFlow; [ObservableProperty] private double _currentPressure; [ObservableProperty] private string _flowStatusText = "正常"; [ObservableProperty] private string _flowStatusColor = "#00C853"; [ObservableProperty] private bool _isRunning = false; [ObservableProperty] private double _maxInstantFlow; [ObservableProperty] private double _minInstantFlow = double.MaxValue; [ObservableProperty] private double _avgInstantFlow; [ObservableProperty] private int _dataPointCount; [ObservableProperty] private string _runTimeText = "00:00:00"; public bool IsNotRunning => !IsRunning; public Brush FlowStatusBrush => new SolidColorBrush(ParseColor(FlowStatusColor)); // 供 View 中图表使用的共享数据 public double[] InstantFlowBuffer { get; private set; } public double[] TotalFlowBuffer { get; private set; } public int DataIndex { get; private set; } = 0; public FlowMeterConfig Config { get; } = new FlowMeterConfig(); #endregion #region ===== 私有字段 ===== private readonly FlowMeterSimulator _simulator; private readonly object _bufferLock = new object(); private DateTime _startTime; // 统计数据 private double _flowSum = 0; private int _flowCount = 0; // 环形缓冲区大小 public const int BUFFER_SIZE = 1000; #endregion public FlowMonitorViewModel() { InstantFlowBuffer = new double[BUFFER_SIZE]; TotalFlowBuffer = new double[BUFFER_SIZE]; _simulator = new FlowMeterSimulator(Config); _simulator.OnDataReceived += OnFlowDataReceived; } #region ===== 命令 ===== [RelayCommand] private void StartMonitor() { if (IsRunning) return; _startTime = DateTime.Now; _simulator.Start(); IsRunning = true; } [RelayCommand] private void StopMonitor() { if (!IsRunning) return; _simulator.Stop(); IsRunning = false; } [RelayCommand] private void ResetTotal() { lock (_bufferLock) { Array.Clear(TotalFlowBuffer, 0, TotalFlowBuffer.Length); Array.Clear(InstantFlowBuffer, 0, InstantFlowBuffer.Length); DataIndex = 0; _flowSum = 0; _flowCount = 0; MaxInstantFlow = 0; MinInstantFlow = double.MaxValue; AvgInstantFlow = 0; DataPointCount = 0; } } #endregion #region ===== 数据处理 ===== partial void OnIsRunningChanged(bool value) { OnPropertyChanged(nameof(IsNotRunning)); } partial void OnFlowStatusColorChanged(string value) { OnPropertyChanged(nameof(FlowStatusBrush)); } private static Color ParseColor(string? hex) { if (!string.IsNullOrWhiteSpace(hex)) { try { var parsed = System.Windows.Media.ColorConverter.ConvertFromString(hex); if (parsed is Color color) return color; } catch { } } return Colors.White; } private void OnFlowDataReceived(FlowData data) { lock (_bufferLock) { // 环形写入 int writeIndex = DataIndex % BUFFER_SIZE; InstantFlowBuffer[writeIndex] = data.InstantFlow; TotalFlowBuffer[writeIndex] = data.TotalFlow; DataIndex++; // 统计 _flowSum += data.InstantFlow; _flowCount++; if (data.InstantFlow > MaxInstantFlow) MaxInstantFlow = data.InstantFlow; if (data.InstantFlow < MinInstantFlow) MinInstantFlow = data.InstantFlow; AvgInstantFlow = Math.Round(_flowSum / _flowCount, 2); } // 更新 UI 绑定属性(需在主线程更新) App.Current.Dispatcher.InvokeAsync(() => { CurrentInstantFlow = data.InstantFlow; CurrentTotalFlow = data.TotalFlow; CurrentPressure = data.Pressure; DataPointCount++; RunTimeText = (DateTime.Now - _startTime).ToString(@"hh\:mm\:ss"); // 更新状态颜色 (FlowStatusText, FlowStatusColor) = data.Status switch { FlowStatus.Alarm => ("🚨 报警", "#DC322F"), FlowStatus.Warning => ("⚠ 警告", "#FFB900"), FlowStatus.Low => ("⬇ 低流量", "#42A5F5"), _ => ("✅ 正常", "#00C853") }; }); } public void Dispose() { _simulator?.Dispose(); } #endregion } }
c#
using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using AppScottPlot13.Models; namespace AppScottPlot13.Services { /// <summary> /// 流量计数据模拟器(生产环境替换为真实 OPC UA / Modbus 采集) /// </summary> public class FlowMeterSimulator : IDisposable { private readonly FlowMeterConfig _config; private readonly ConcurrentQueue<FlowData> _dataQueue; private CancellationTokenSource _cts; private double _totalFlow = 0.0; private double _currentTime = 0.0; private readonly Random _random = new Random(42); private bool _isAnomalyMode = false; // 模拟异常工况 private double _anomalyTimer = 0.0; /// <summary>数据队列(线程安全)</summary> public ConcurrentQueue<FlowData> DataQueue => _dataQueue; public event Action<FlowData>? OnDataReceived; public FlowMeterSimulator(FlowMeterConfig config) { _config = config; _dataQueue = new ConcurrentQueue<FlowData>(); } /// <summary>启动数据采集</summary> public void Start() { _cts = new CancellationTokenSource(); Task.Run(() => SimulateLoop(_cts.Token), _cts.Token); } /// <summary>停止数据采集</summary> public void Stop() => _cts?.Cancel(); private async Task SimulateLoop(CancellationToken token) { while (!token.IsCancellationRequested) { try { var data = GenerateFlowData(); _dataQueue.Enqueue(data); OnDataReceived?.Invoke(data); await Task.Delay( TimeSpan.FromMilliseconds(_config.SampleIntervalMs), token); } catch (OperationCanceledException) { break; } } } private FlowData GenerateFlowData() { _currentTime += _config.SampleIntervalMs / 1000.0; // 随机切换异常工况(每隔30~60秒出现一次) _anomalyTimer += _config.SampleIntervalMs / 1000.0; if (_anomalyTimer > 30 + _random.NextDouble() * 30) { _isAnomalyMode = !_isAnomalyMode; _anomalyTimer = 0; } // 基准流量:正弦波 + 随机噪声 double baseFlow = _config.NominalFlow; double sineWave = 15.0 * Math.Sin(_currentTime * 0.05); double noise = (_random.NextDouble() - 0.5) * 8.0; double instantFlow = baseFlow + sineWave + noise; // 模拟异常工况:流量突增 if (_isAnomalyMode) { instantFlow += 35.0 + _random.NextDouble() * 20.0; } // 偶发性低流量(模拟管道堵塞) if (_random.NextDouble() < 0.02) { instantFlow = _config.FlowLow * 0.5; } instantFlow = Math.Max(0, instantFlow); // 累计流量:积分计算 (m³) _totalFlow += instantFlow * (_config.SampleIntervalMs / 3600000.0); // 管道压力(与流量正相关) double pressure = 0.3 + (instantFlow / _config.NominalFlow) * 0.4 + (_random.NextDouble() - 0.5) * 0.05; // 判断状态 var status = GetFlowStatus(instantFlow); return new FlowData { Timestamp = DateTime.Now, InstantFlow = Math.Round(instantFlow, 2), TotalFlow = Math.Round(_totalFlow, 4), Pressure = Math.Round(pressure, 3), Status = status }; } private FlowStatus GetFlowStatus(double flow) { if (flow >= _config.AlarmHigh) return FlowStatus.Alarm; if (flow >= _config.WarningHigh) return FlowStatus.Warning; if (flow < _config.FlowLow) return FlowStatus.Low; return FlowStatus.Normal; } public void Dispose() => Stop(); } }

🍢 运行样式

image.png

image.png

🎉 总结:打造工业级可视化的三个关键

1️⃣ 性能为王

环形缓冲区 + Signal绘图 + 合理的刷新频率

这套组合能让你的界面在大数据量下依然丝滑流畅。

2️⃣ 体验至上

暗色主题 + 状态颜色 + 交互反馈

不是为了"酷炫",而是为了实用

3️⃣ 架构清晰

MVVM模式 + 线程安全 + 配置化设计

好的架构让后期维护变得轻松。


最后想说:做工业软件,技术很重要,但理解业务场景更重要。

用户要的不是华丽的特效,而是稳定、清晰、高效的数据呈现。

ScottPlot + WPF这个组合,能让我们专注于业务逻辑,而不用在界面性能上费心思。

你觉得呢?欢迎评论区聊聊你在工业软件开发中遇到的"坑"~

#C#开发 #工业软件 #数据可视化 #ScottPlot #实时监控

相关信息

通过网盘分享的文件:AppScottPlot13.zip 链接: https://pan.baidu.com/s/1jimZCWDjthSgm37hd-B8bw?pwd=a5v9 提取码: a5v9 --来自百度网盘超级会员v9的分享

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!