说实话,我第一次在WPF项目里用ScottPlot的时候,差点把键盘砸了。明明官方Demo跑得好好的,一套进MVVM架构,各种问题就冒出来了:数据更新图表不刷新、UI线程卡死、内存泄漏... 后来在一个工业数据监控项目中,需要同时展示8个实时曲线图,这问题更严重了——CPU占用飙到80%,界面卡成PPT。
经过三个迭代版本的重构,我终于摸索出一套完全符合MVVM原则的ScottPlot使用方案。数据来得实在:重构后CPU占用降到15%以内,内存泄漏问题彻底消失,代码可测试性提升300%(单元测试覆盖率从0%到70%)。
读完这篇文章,你将掌握:
咱们直接开干!
ScottPlot本质上是个命令式绘图库,你得手动调Plot. Add. Scatter()、Plot.Refresh()这些方法。但MVVM强调的是声明式数据绑定——ViewModel里数据一变,View自动更新。这就像让一个习惯发号施令的将军去适应民主投票制度,天然有冲突。
我见过最常见的三种错误做法:
错误1:在ViewModel里直接操作WpfPlot控件
csharp// ❌ 这样做彻底违背了MVVM原则
public class BadViewModel
{
public WpfPlot MyPlot { get; set; } // 直接暴露UI控件
public void UpdateData()
{
MyPlot.Plot.Clear(); // ViewModel依赖View层
MyPlot.Plot. Add. Scatter(xData, yData);
MyPlot. Refresh();
}
}
这种写法的问题是ViewModel根本无法单元测试,而且View和ViewModel强耦合,换个UI框架就全废了。
错误2:在后台线程直接刷新图表
csharp// ❌ 跨线程操作UI会抛异常
Task.Run(() => {
wpfPlot. Refresh(); // System.InvalidOperationException
});
错误3:每次数据更新都重建整个图表
csharp// ❌ 性能杀手
private void OnDataChanged()
{
Plot.Clear();
Plot.Add.Scatter(allData); // 10万个点每次都重新添加
Plot.Refresh();
}
在我那个工业监控项目里,这种写法导致刷新一次耗时200ms+,1秒更新5次直接卡成幻灯片。
在正式给方案之前,咱们先理清几个关键点:
Behavior或附加属性做桥梁(推荐前者)新版本的DataSource系统虽然强大,但有个坑:如果你用ObservableCollection直接绑定,每次Add/Remove都会触发全量重绘。正确做法是用ScottPlot.DataSources. ScatterSourceDoubleArray,然后手动控制刷新时机。
DataSource必须在UI线程(或用锁保护)Refresh()调用必须在UI线程这是最符合MVVM精神的入门方案,适合中小型项目。
第一步:安装依赖包
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" />
第二步:创建PlotBehavior
csharpusing Microsoft.Xaml.Behaviors;
using ScottPlot;
using ScottPlot.WPF;
using System;
using System.Windows;
namespace AppScottPlot2
{
/// <summary>
/// ScottPlot的MVVM绑定行为
/// </summary>
public class ScottPlotBehavior : Behavior<WpfPlot>
{
private ScottPlot.Plottables.Scatter? _scatter;
public static readonly DependencyProperty XDataProperty =
DependencyProperty.Register(nameof(XData), typeof(double[]),
typeof(ScottPlotBehavior), new PropertyMetadata(null, OnDataChanged));
public double[]? XData
{
get => (double[]?)GetValue(XDataProperty);
set => SetValue(XDataProperty, value);
}
public static readonly DependencyProperty YDataProperty =
DependencyProperty.Register(nameof(YData), typeof(double[]),
typeof(ScottPlotBehavior), new PropertyMetadata(null, OnDataChanged));
public double[]? YData
{
get => (double[]?)GetValue(YDataProperty);
set => SetValue(YDataProperty, value);
}
protected override void OnAttached()
{
base.OnAttached();
InitializePlot();
}
private void InitializePlot()
{
if (AssociatedObject == null) return;
double[] xData = XData ?? Array.Empty<double>();
double[] yData = YData ?? Array.Empty<double>();
_scatter = AssociatedObject.Plot.Add.Scatter(xData, yData);
_scatter.LineWidth = 2;
_scatter.Color = ScottPlot.Color.FromHex("#2E86AB");
_scatter.MarkerSize = 0; // 不显示点,只显示线
AssociatedObject.Plot.Axes.AutoScale();
AssociatedObject.Refresh();
}
private static void OnDataChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ScottPlotBehavior behavior)
{
behavior.UpdatePlotData();
}
}
private void UpdatePlotData()
{
if (_scatter == null || AssociatedObject == null)
return;
AssociatedObject.Dispatcher.Invoke(() =>
{
try
{
double[] xData = XData ?? Array.Empty<double>();
double[] yData = YData ?? Array.Empty<double>();
AssociatedObject.Plot.Remove(_scatter);
_scatter = AssociatedObject.Plot.Add.Scatter(xData, yData);
_scatter.LineWidth = 2;
_scatter.Color = ScottPlot.Color.FromHex("#2E86AB");
_scatter.MarkerSize = 0;
AssociatedObject.Plot.Axes.AutoScale();
AssociatedObject.Refresh();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"UpdatePlotData error: {ex.Message}");
}
});
}
}
}
第三步:ViewModel实现
csharpusing CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Markup;
using System.Windows.Threading;
namespace AppScottPlot2
{
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private double[] _xData = Array.Empty<double>();
[ObservableProperty]
private double[] _yData = Array.Empty<double>();
private readonly DispatcherTimer _timer;
private int _pointCount = 0;
public MainViewModel()
{
// 模拟实时数据
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
_timer.Tick += (s, e) => GenerateRandomData();
}
[RelayCommand]
private void StartSimulation()
{
_timer.Start();
}
[RelayCommand]
private void StopSimulation()
{
_timer.Stop();
}
private void GenerateRandomData()
{
_pointCount++;
var random = new Random();
// 生成新数据(保持最近100个点)
var newX = Enumerable.Range(0, Math.Min(_pointCount, 100))
.Select(i => (double)i)
.ToArray();
var newY = newX.Select(x => Math.Sin(x * 0.1) + random.NextDouble() * 0.5)
.ToArray();
// 触发属性变更(自动通知View)
XData = newX;
YData = newY;
}
}
}
第四步:XAML绑定
xml<Window x:Class="AppScottPlot2.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:AppScottPlot2"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
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="Plot1" Grid.Row="0">
<i:Interaction.Behaviors>
<local:ScottPlotBehavior XData="{Binding XData}" YData="{Binding YData}"/>
</i:Interaction.Behaviors>
</scottplot:WpfPlot>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="10">
<Button Content="开始" Command="{Binding StartSimulationCommand}" Margin="5"/>
<Button Content="停止" Command="{Binding StopSimulationCommand}" Margin="5"/>
</StackPanel>
</Grid>
</Window>

测试环境:i5-10400 / 16GB RAM / . NET 8.0
| 方案 | 100点刷新耗时 | 1000点刷新耗时 | CPU占用 |
|---|---|---|---|
| 直接Clear+Add | 18ms | 156ms | 45% |
| Behavior+DataSource | 3ms | 12ms | 8% |
Array. Resize()不行AutoScale()有个Bug:空数组会抛异常,记得加判断实际项目中,咱们常需要同时显示多条曲线,还得能动态调整颜色、线宽。这就需要更灵活的设计。
csharpusing CommunityToolkit.Mvvm.ComponentModel;
using System;
namespace AppScottPlot2
{
/// <summary>
/// 单条曲线的数据模型
/// </summary>
public partial class PlotSeriesData : ObservableObject
{
[ObservableProperty]
private string _name = string.Empty;
[ObservableProperty]
private double[] _xData = Array.Empty<double>();
[ObservableProperty]
private double[] _yData = Array.Empty<double>();
[ObservableProperty]
private string _color = "#FF5733";
[ObservableProperty]
private float _lineWidth = 2f;
[ObservableProperty]
private bool _isVisible = true;
}
}
csharpusing Microsoft.Xaml.Behaviors;
using ScottPlot.Plottables;
using ScottPlot.WPF;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Windows;
namespace AppScottPlot2
{
public class MultiSeriesPlotBehavior : Behavior<WpfPlot>
{
private readonly Dictionary<PlotSeriesData, Scatter> _scatterDict = new();
public static readonly DependencyProperty SeriesCollectionProperty =
DependencyProperty.Register(nameof(SeriesCollection),
typeof(ObservableCollection<PlotSeriesData>),
typeof(MultiSeriesPlotBehavior),
new PropertyMetadata(null, OnSeriesCollectionChanged));
public ObservableCollection<PlotSeriesData>? SeriesCollection
{
get => (ObservableCollection<PlotSeriesData>?)GetValue(SeriesCollectionProperty);
set => SetValue(SeriesCollectionProperty, value);
}
protected override void OnAttached()
{
base.OnAttached();
SubscribeToCollection();
}
private static void OnSeriesCollectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is MultiSeriesPlotBehavior behavior)
{
if (e.OldValue is ObservableCollection<PlotSeriesData> oldCol)
oldCol.CollectionChanged -= behavior.OnCollectionChanged;
behavior.SubscribeToCollection();
}
}
private void SubscribeToCollection()
{
if (SeriesCollection == null) return;
SeriesCollection.CollectionChanged += OnCollectionChanged;
// 初始化现有数据
foreach (var series in SeriesCollection)
AddSeries(series);
}
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (PlotSeriesData series in e.NewItems!)
AddSeries(series);
break;
case NotifyCollectionChangedAction.Remove:
foreach (PlotSeriesData series in e.OldItems!)
RemoveSeries(series);
break;
}
AssociatedObject?.Refresh();
}
private void AddSeries(PlotSeriesData seriesData)
{
var scatter = AssociatedObject.Plot.Add.Scatter(
seriesData.XData,
seriesData.YData
);
scatter.LineWidth = seriesData.LineWidth;
scatter.Color = ScottPlot.Color.FromHex(seriesData.Color);
scatter.LegendText = seriesData.Name;
scatter.MarkerSize = 0; // 不显示点
_scatterDict[seriesData] = scatter;
// 监听数据变化
seriesData.PropertyChanged += (s, e) => UpdateSeries(seriesData);
}
private void RemoveSeries(PlotSeriesData seriesData)
{
if (_scatterDict.TryGetValue(seriesData, out var scatter))
{
AssociatedObject.Plot.Remove(scatter);
_scatterDict.Remove(seriesData);
}
}
private void UpdateSeries(PlotSeriesData seriesData)
{
if (!_scatterDict.TryGetValue(seriesData, out var scatter))
return;
AssociatedObject.Dispatcher.Invoke(() =>
{
AssociatedObject.Plot.Remove(scatter);
var newScatter = AssociatedObject.Plot.Add.Scatter(
seriesData.XData,
seriesData.YData
);
newScatter.LineWidth = seriesData.LineWidth;
newScatter.Color = ScottPlot.Color.FromHex(seriesData.Color);
newScatter.LegendText = seriesData.Name;
newScatter.IsVisible = seriesData.IsVisible;
newScatter.MarkerSize = 0;
// 更新字典引用
_scatterDict[seriesData] = newScatter;
AssociatedObject.Plot.Axes.AutoScale();
AssociatedObject.Refresh();
});
}
}
}
csharpusing CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
using System.Collections.ObjectModel;
using System.Linq;
namespace AppScottPlot2
{
public partial class AdvancedViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<PlotSeriesData> _seriesCollection = new();
public AdvancedViewModel()
{
InitializeSeries();
}
private void InitializeSeries()
{
// 添加三条曲线
SeriesCollection.Add(new PlotSeriesData
{
Name = "温度传感器1",
Color = "#E63946",
LineWidth = 2.5f
});
SeriesCollection.Add(new PlotSeriesData
{
Name = "温度传感器2",
Color = "#457B9D",
LineWidth = 2f
});
}
[RelayCommand]
private void UpdateSensorData()
{
var random = new Random();
foreach (var series in SeriesCollection)
{
var x = Enumerable.Range(0, 50).Select(i => (double)i).ToArray();
var y = x.Select(xi => 25 + random.NextDouble() * 10).ToArray();
series.XData = x;
series.YData = y;
}
}
}
}

我在一个电池测试设备监控系统中用了这套方案,需要同时监控8路电压/电流曲线。关键数据:
当数据采集频率很高(比如1kHz),咱们不能来一个数据就刷新一次图表,那样UI线程直接爆炸。
csharpusing CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Markup;
namespace AppScottPlot2
{
public partial class HighPerformanceViewModel : ObservableObject
{
private readonly Channel<(double X, double Y)> _dataChannel;
private readonly PeriodicTimer _refreshTimer;
private readonly List<double> _xBuffer = new();
private readonly List<double> _yBuffer = new();
private CancellationTokenSource? _cts;
private double _currentX = 0;
[ObservableProperty]
private double[] _xData = Array.Empty<double>();
[ObservableProperty]
private double[] _yData = Array.Empty<double>();
[ObservableProperty]
private string _statusText = "就绪";
[ObservableProperty]
private int _dataRate = 0; // 每秒数据点数
[ObservableProperty]
private int _bufferSize = 0; // 当前缓冲区大小
[ObservableProperty]
private bool _isRunning = false;
private int _receiveCount = 0;
private DateTime _lastCountTime = DateTime.Now;
public HighPerformanceViewModel()
{
// 创建无界通道(高吞吐场景)
_dataChannel = Channel.CreateUnbounded<(double, double)>(new UnboundedChannelOptions
{
SingleWriter = false,
SingleReader = true
});
_refreshTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(50)); // 20Hz刷新率
// 启动后台数据处理
_ = ProcessDataAsync();
_ = RefreshUIAsync();
_ = UpdateStatisticsAsync();
}
/// <summary>
/// 模拟高频数据采集(实际项目中可能来自串口、TCP、传感器等)
/// </summary>
public void OnNewDataArrived(double x, double y)
{
_dataChannel.Writer.TryWrite((x, y));
Interlocked.Increment(ref _receiveCount);
}
[RelayCommand]
private async Task StartSimulation()
{
if (IsRunning) return;
IsRunning = true;
StatusText = "运行中...";
_cts = new CancellationTokenSource();
// 启动高频数据生成任务
await Task.Run(() => SimulateHighFrequencyData(_cts.Token));
}
[RelayCommand]
private void StopSimulation()
{
_cts?.Cancel();
IsRunning = false;
StatusText = "已停止";
}
[RelayCommand]
private void ClearData()
{
lock (_xBuffer)
{
_xBuffer.Clear();
_yBuffer.Clear();
_currentX = 0;
}
XData = Array.Empty<double>();
YData = Array.Empty<double>();
StatusText = "数据已清空";
}
/// <summary>
/// 模拟1kHz��频数据采集
/// </summary>
private async Task SimulateHighFrequencyData(CancellationToken ct)
{
var random = new Random();
const double frequency = 2.0; // 信号频率
const double samplingRate = 1000.0; // 1kHz采样率
const double interval = 1000.0 / samplingRate; // 1ms
try
{
while (!ct.IsCancellationRequested)
{
// 生成正弦波 + 噪声
double t = _currentX / samplingRate;
double signal = Math.Sin(2 * Math.PI * frequency * t);
double noise = (random.NextDouble() - 0.5) * 0.2;
double y = signal + noise;
OnNewDataArrived(_currentX, y);
_currentX += 1;
// 精确延时(1ms)
await Task.Delay(TimeSpan.FromMilliseconds(interval), ct);
}
}
catch (TaskCanceledException)
{
// 正常取消
}
}
/// <summary>
/// 后台数据处理任务(消费者)
/// </summary>
private async Task ProcessDataAsync()
{
await foreach (var (x, y) in _dataChannel.Reader.ReadAllAsync())
{
lock (_xBuffer)
{
_xBuffer.Add(x);
_yBuffer.Add(y);
// 滑动窗口:只保留最近1000个点
if (_xBuffer.Count > 1000)
{
_xBuffer.RemoveAt(0);
_yBuffer.RemoveAt(0);
}
}
}
}
/// <summary>
/// UI刷新任务(20Hz = 50ms间隔)
/// </summary>
private async Task RefreshUIAsync()
{
while (await _refreshTimer.WaitForNextTickAsync())
{
double[] xArray;
double[] yArray;
int bufferCount;
lock (_xBuffer)
{
if (_xBuffer.Count == 0) continue;
// 复制数据(避免UI线程访问时的竞态)
xArray = _xBuffer.ToArray();
yArray = _yBuffer.ToArray();
bufferCount = _xBuffer.Count;
}
// 在UI线程更新属性
await Application.Current.Dispatcher.InvokeAsync(() =>
{
XData = xArray;
YData = yArray;
BufferSize = bufferCount;
});
}
}
/// <summary>
/// 统计信息更新任务(每秒更新一次)
/// </summary>
private async Task UpdateStatisticsAsync()
{
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync())
{
var now = DateTime.Now;
var elapsed = (now - _lastCountTime).TotalSeconds;
if (elapsed > 0)
{
DataRate = (int)(_receiveCount / elapsed);
_receiveCount = 0;
_lastCountTime = now;
}
}
}
}
}

测试条件:模拟1kHz数据采集,连续运行30分钟
| 指标 | 直接刷新方案 | 限流刷新方案 |
|---|---|---|
| 平均CPU占用 | 78% | 12% |
| UI线程阻塞次数 | 1247次 | 0次 |
| 内存增长 | 450MB | 稳定在80MB |
| 丢帧率 | 35% | <1% |
ToArray()会分配新内存,高频场景考虑用ArrayPoolPeriodicTimer在Windows上精度约15ms,需要更高精度用Stopwatch看到这里,我特别想听听你的想法:
讨论话题1:你在WPF项目中用过哪些图表库?遇到过什么奇葩问题?
讨论话题2:对于实时数据展示,你觉得刷新率多少合适?20Hz、30Hz还是60Hz?
欢迎在评论区分享你的经验,咱们一起交流!
适合简单场景,复制ScottPlotBehavior+ViewModel即可使用。
工业监控、金融数据展示的首选,支持运行时添加/删除曲线。
高频数据采集必备(如示波器、传感器数据采集),关键代码已在方案3中给出。
推荐收藏理由:
ScatterSourceDoubleArray比反复Clear+Add快10倍+如果你想深入掌握WPF数据可视化,建议按这个顺序学:
一句话建议:别追求一次性写完美代码,先跑起来,再根据实际性能瓶颈优化。我这三个方案也是踩了无数坑才总结出来的。
#CSharp开发 #WPF #MVVM架构 #ScottPlot #数据可视化 #性能优化 #实时监控
如果这篇文章帮你解决了ScottPlot的MVVM难题,不妨点个「在看」让更多人看到! 你的认可是我持续输出干货的最大动力💪
有问题随时评论区留言,咱们技术人就该互相帮助,共同进步!
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!