WPF MVVM 与 ScottPlot 实时数据学习笔记:巡检数据监控台

这份笔记根据本次目录编写,主题是把 MVVM 的分层、集合、命令,与 ScottPlot 的图表数据源和实时刷新连成一条完整链路。

配套案例是一个“巡检数据监控台”。它不追求堆叠控件,而是用五个页签分别观察:

  1. 项目如何分层。
  2. 集合数据怎样接到列表。
  3. 列表中的每一行怎样发出命令。
  4. 数组数据怎样变成 ScottPlot 曲线。
  5. 定时采样怎样持续更新图表。

适合谁

本笔记面向刚接触 WPF 和 MVVM 的初学者。开始前最好已经能创建 WPF 项目,认识 XAML、控件、事件和简单的 C# 类。

不要求你事先会写 ScottPlot 图表,也不要求使用某个 MVVM 框架。示例只使用 .NET 自带接口和手写的两个基础类,便于看清每一步发生了什么。

当前环境与版本

本文写作年份为 2026 年。以下版本是本机实际还原、编译、运行和截图验证过的固定基线,不代表“所有环境的最新版本”。

项目 已验证值
操作系统 Windows 10 22H2,版本 19045
.NET SDK 9.0.308
.NET 运行时 9.0.11
.NET Framework 4.8.09037
示例目标框架 .NET Framework 4.6.2,net462
示例运行时标识 win-x64
ScottPlot.WPF 5.1.58
界面语言 简体中文

示例使用 ScottPlot.WPF 5.1.58 的 ScottPlot 5 API,例如 plot.Add.Scatter(xs, ys)、plot.Axes.SetLimitsX(...) 和 WpfPlot.Refresh()。不要把 ScottPlot 4 的旧 API 与本示例混用。

项目目标使用 net462,原因是随附的离线包缓存已经验证了 ScottPlot.WPF 5.1.58 在 .NET Framework 4.6.2 下的完整依赖链。实际运行时由本机 .NET Framework 4.8 承载。

目录

1、MVVM项目结构

2、分层架构下的集合数据对接

3、集合绑定中的命令处理

4、ScottPlot的基本绑定逻辑

5、ScottPlot的绑定数据源实时更新

这份笔记解决什么问题

很多初学者学完 MVVM 后仍会遇到这些具体问题:

  • 文件分了文件夹,但不知道谁应该调用谁。
  • List<T> 能显示,运行时新增一行却不出现。
  • 选中项绑定了,但命令拿不到当前行。
  • 图表能画一次,换数据后不知道何时重画。
  • 定时器能跑,但曲线不跟随新数据移动。
  • 代码“看起来能跑”,却没有可重复的自测和截图验证。

本笔记通过一个共享巡检数据源,把这些问题放进同一条可观察链路:

1
2
3
4
5
Service 数据
→ ViewModel 集合与状态
→ View 列表、命令和图表
→ 用户操作或定时器
→ 再回到 Service 与集合

推荐学习顺序

严格按目录顺序学习:

  1. 先看清项目结构与依赖方向。
  2. 再把服务数据接成可观察集合。
  3. 再给集合增加命令,尤其是行级删除命令。
  4. 再把同一份数据投影成数组,创建 ScottPlot 系列。
  5. 最后用定时器持续追加数据,观察刷新链路。

不建议直接跳到第 5 章。实时刷新只是把前四章的数据流重复执行,如果集合和图表的基础链路没有看懂,直接抄定时器代码很难定位问题。

示例项目路径

1
C:\Users\North\Desktop\WPF学习笔记\MVVM\示例代码\InspectionScottPlotMvvmDemo

项目附带本地 NuGet.Config 和 .nuget\packages 离线缓存。执行还原时使用项目内的包源,不需要访问网络。

通过命令行运行

打开 PowerShell,依次执行:

1
2
3
4
Set-Location "C:\Users\North\Desktop\WPF学习笔记\MVVM\示例代码\InspectionScottPlotMvvmDemo"
dotnet restore
dotnet build --no-restore
dotnet run --no-build

打开指定页签,页签编号是 1 到 5:

1
dotnet run --no-build -- --start-tab=5

执行自测:

1
dotnet run --no-build -- --self-test

自测返回码为 0 表示全部通过,结果写入:

1
artifacts\self-test.txt

运行日志写入:

1
artifacts\logs\application.log

在 Visual Studio 中打开和运行

  1. 打开 Visual Studio。
  2. 选择“打开项目或解决方案”。
  3. 选择 InspectionScottPlotMvvmDemo.csproj。
  4. 等待“解决方案资源管理器”完成还原。
  5. 在“生成”菜单中选择“生成解决方案”。
  6. 按 F5 启动调试,或按 Ctrl+F5 不附加调试器启动。

如果 Visual Studio 首次还原时提示包源,请确认项目目录中的 NuGet.Config 仍在,并且 .nuget\packages 文件夹没有被删除。

如何创建自己的跟做项目

可以按下面的顺序创建一个同名或类似名称的 WPF 项目:

1
dotnet new wpf -n InspectionScottPlotMvvmDemo

然后逐步完成:

  1. 添加 ScottPlot.WPF 5.1.58 包引用。
  2. 建立 Models、Services、ViewModels、Views 和 Infrastructure 目录。
  3. 先实现 InspectionPoint 与内存服务。
  4. 再实现 ObservableObject 和 RelayCommand。
  5. 先完成第 1、2 页,再添加命令页。
  6. 最后加入两个 WpfPlot 页面。

跟做时建议每完成一章就编译一次,不要等到五章全部写完才第一次运行。

跟做完成后的项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
InspectionScottPlotMvvmDemo/
├─ App.xaml
├─ App.xaml.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
├─ InspectionScottPlotMvvmDemo.csproj
├─ NuGet.Config
├─ README.md
├─ Infrastructure/
│ ├─ ObservableObject.cs
│ └─ RelayCommand.cs
├─ Models/
│ └─ InspectionPoint.cs
├─ Services/
│ ├─ IInspectionTelemetryService.cs
│ ├─ InMemoryInspectionTelemetryService.cs
│ ├─ AppPaths.cs
│ ├─ SelfTestRunner.cs
│ ├─ ScreenshotRunner.cs
│ └─ NativeWindowCapture.cs
├─ ViewModels/
│ ├─ MainViewModel.cs
│ ├─ DemoViewModelBase.cs
│ ├─ ProjectStructureViewModel.cs
│ ├─ CollectionBindingViewModel.cs
│ ├─ CollectionCommandViewModel.cs
│ ├─ PlotBindingViewModel.cs
│ └─ RealtimePlotViewModel.cs
├─ Views/
│ ├─ ProjectStructureView.xaml
│ ├─ CollectionBindingView.xaml
│ ├─ CollectionCommandView.xaml
│ ├─ PlotBindingView.xaml
│ └─ RealtimePlotView.xaml
├─ artifacts/
│ ├─ self-test.txt
│ └─ logs/
│ └─ application.log
└─ images/
└─ 本项目使用的真实运行截图

笔记中的原理图与截图位于上一级的 MVVM\images 目录,Markdown 使用相对路径 images/... 引用。

知识地图

学习路线图

图 1:五章知识路线。第 1、2 章建立数据入口,第 3 章补动作,第 4、5 章把数据送进图表。

学习时可以用一句话记住每章的核心:

章节 核心问题 主要结果
第 1 章 谁负责什么 固定依赖方向
第 2 章 数据怎样进列表 集合与选中项可观察
第 3 章 用户怎样操作行 命令与参数接上
第 4 章 数组怎样变曲线 完成基础图表绑定
第 5 章 新数据怎样持续进图 定时刷新链路成立

1、MVVM项目结构

本节目标

理解 MVVM 项目结构不是“多建几个文件夹”,而是让不同对象承担不同职责,并固定依赖方向。

学完后,你应该能够回答:

  • 为什么 View 不直接操作数据集合。
  • 为什么 ViewModel 不应该创建 WpfPlot。
  • 为什么 Service 不应该引用 WPF 控件。
  • 为什么 Model 只描述一条巡检采样。

通俗原理说明

可以把程序想成一家巡检站:

角色 项目中的对象 负责什么 不负责什么
前台 View 展示界面、接收输入 不保存业务历史
调度员 ViewModel 保存页面状态、命令、图表刷新状态 不创建窗口控件
业务员 Service 查询、追加、删除采样 不知道按钮叫什么
档案 Model 记录时间、站点、温度、振动 不负责显示

“不用分层会怎样?”

如果把按钮点击、校验、数据修改和界面刷新全部写在窗口后台代码中,刚开始确实很快,但后面会出现:

  • 业务逻辑无法单独测试。
  • 同一份数据在多个窗口重复维护。
  • 控件改名或界面重排会破坏业务代码。
  • 图表刷新和数据处理纠缠在一起。

MVVM 的价值不是让文件看起来整齐,而是让数据流和操作路径可追踪。

需要准备的文件、数据或环境

本章需要:

  • Models\InspectionPoint.cs
  • Services\IInspectionTelemetryService.cs
  • Services\InMemoryInspectionTelemetryService.cs
  • ViewModels\MainViewModel.cs
  • ViewModels\ProjectStructureViewModel.cs
  • Views\ProjectStructureView.xaml
  • App.xaml 中的统一字体和颜色资源

示例启动后会创建 12 条初始巡检数据,服务实例由 MainViewModel 创建,并传给五个页面 ViewModel。

第一步操作

先让一条采样拥有明确字段,而不是到处传几个互相没有关联的字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public sealed class InspectionPoint : INotifyPropertyChanged
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public string Station { get; set; } = string.Empty;
public double Temperature { get; set; }
public double Vibration { get; set; }

public string TimeText => Timestamp.ToString("HH:mm:ss");

public string AlarmText =>
Temperature >= 80 ? "高温" :
Vibration >= 4.5 ? "振动偏高" :
"正常";
}

这里先展示最小结构。实际项目中的完整 InspectionPoint 还实现了属性变更通知,原因会在第 2 章解释。

关键点:

  • Id 用于让集合与服务定位同一条数据。
  • Timestamp 保存时间。
  • Station 保存站点。
  • Temperature 和 Vibration 是两个可绘制指标。
  • TimeText 与 AlarmText 是只读展示属性。

第二步操作

接口只暴露业务用例,不暴露 WPF 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface IInspectionTelemetryService
{
IReadOnlyList<InspectionPoint> GetHistory();
InspectionPoint AppendPoint(
string station,
double temperature,
double vibration);
bool RemovePoint(int id);
bool UpdatePoint(
int id,
double temperature,
double vibration);
}

内存实现先准备 12 条数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public sealed class InMemoryInspectionTelemetryService
: IInspectionTelemetryService
{
private readonly List<InspectionPoint> _points = new();
private int _nextId = 1;

public InMemoryInspectionTelemetryService()
{
var start = DateTime.Today.AddHours(8);
for (var index = 0; index < 12; index++)
{
var station = index % 3 == 0 ? "A 区" :
index % 3 == 1 ? "B 区" : "C 区";

_points.Add(new InspectionPoint
{
Id = _nextId++,
Timestamp = start.AddMinutes(index * 10),
Station = station,
Temperature = 52 + index * 1.7,
Vibration = 2.2 + index * 0.12
});
}
}

public IReadOnlyList<InspectionPoint> GetHistory()
{
return _points.ToList();
}
}

GetHistory() 返回副本,调用者不能直接拿到内部 List 随意修改,这样可以避免绕过业务方法。

第三步操作

示例让五个页面共享同一个服务实例:

1
2
3
4
5
6
7
8
9
10
11
12
public MainViewModel(int selectedTabIndex = 0)
{
_selectedTabIndex = selectedTabIndex;

var service = new InMemoryInspectionTelemetryService();

ProjectStructure = new ProjectStructureViewModel(service);
CollectionBinding = new CollectionBindingViewModel(service);
CollectionCommand = new CollectionCommandViewModel(service);
PlotBinding = new PlotBindingViewModel(service);
RealtimePlot = new RealtimePlotViewModel(service);
}

这样做的教学价值是:在第 3 页新增一条采样,切换到其他页面时仍能观察到同一份业务数据的变化。

项目结构的核心不是“一个类只做一件事”这句话,而是每个对象只承担自己应该承担的变化原因。

关键代码逐段解释

第一段是统一资源:

1
2
3
4
5
<Style TargetType="Window">
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
<Setter Property="Foreground" Value="{StaticResource InkBrush}"/>
<Setter Property="Background" Value="{StaticResource CanvasBrush}"/>
</Style>

这段代码解决三个界面基础问题:

  • 统一中文字体,避免不同控件显示风格不一致。
  • 统一文字颜色。
  • 统一背景色。

第二段是页面绑定:

1
2
3
4
5
6
<TabControl SelectedIndex="{Binding SelectedTabIndex, Mode=TwoWay}">
<TabItem Header="1 项目结构">
<views:ProjectStructureView
DataContext="{Binding ProjectStructure}"/>
</TabItem>
</TabControl>

SelectedIndex 双向绑定让启动参数可以指定页签,也允许用户点击切换页签后回写 ViewModel。

第三段是分层关系图:

项目结构图

图 2:项目分层与依赖方向。结构的目标是让数据逻辑可测试,让界面只负责显示与转发。

图中箭头表示允许的主要调用方向。View 可以绑定 ViewModel;ViewModel 可以调用 Service;Service 操作 Model。不要让 Model 反过来引用窗口或 ScottPlot。

运行后应该看到什么

运行项目,默认打开“1 项目结构”页。你应该看到:

  • 左侧四行:View、ViewModel、Service、Model。
  • 每行有“查看职责”按钮。
  • 右侧深色卡片显示当前层的说明。
  • 点击“读取服务快照”后,右侧文字变成“服务层当前保存 12 条巡检采样”。
  • 底部状态栏出现本次操作提示。

第 1 章真实运行截图

图 3:第 1 章真实运行画面。项目结构页把分层职责直接做成可点击的观察入口。

对应配图或真实运行截图

本章使用:

  • images/scottplot-mvvm-project-structure.png:分层与依赖方向。
  • images/scottplot-mvvm-chapter-01-structure.png:项目结构页真实运行截图。

跟做检查点

  • [ ] 能指出 InspectionPoint 属于 Model。
  • [ ] 能指出 IInspectionTelemetryService 属于 Service 层。
  • [ ] 能指出 MainViewModel 保存页面级状态。
  • [ ] 能指出 View 不直接创建服务数据。
  • [ ] 能解释为什么五个页面共享一个服务实例。

常见错误与解决方法

错误一:ViewModel 中直接写 new WpfPlot()。

解决方法:ViewModel 不应持有 WPF 控件。把绘图代码放在 View 的后台代码中,由 ViewModel 发布数据或刷新状态。

错误二:Service 中引用 MessageBox。

解决方法:Service 只返回业务结果或抛出业务异常,提示由 View 或 ViewModel 决定怎样显示。

错误三:每个页面各自 new InMemoryInspectionTelemetryService()。

解决方法:共享依赖应在 MainViewModel 中创建,再通过构造函数注入。

小练习

把“读取服务快照”的文字改成同时显示:

  • 服务中的采样总数。
  • A 区采样数量。
  • 当前最高温度。

要求计算逻辑放在 ViewModel,而不是直接写在按钮事件里。

本章小结

本章先固定了职责:Model 描述数据,Service 提供业务用例,ViewModel 保存页面状态,View 负责显示。

下一章会把这个服务返回的普通数据,变成列表可以自动观察的集合。

2、分层架构下的集合数据对接

本节目标

掌握 ObservableCollection<T> 与 ListBox 的对接方式,理解集合变化通知、选中项绑定和筛选属性通知。

通俗原理说明

普通 List<T> 只保存数据,不负责通知界面。

可以把它想成一摞纸质名单:你往里面加一张纸,名单本身不会主动按铃告诉前台。ObservableCollection<T> 则会在增加、删除、清空时发出通知,WPF 的列表控件听到通知后刷新显示。

集合绑定有两个不同问题:

问题 绑定入口 典型属性
有哪些行 ItemsSource Points 或 VisiblePoints
用户选中哪一行 SelectedItem SelectedPoint

如果这两个问题混在一起,就容易出现“列表变了但选中项没变”或“选中项变了但详情没刷新”的问题。

需要准备的文件、数据或环境

本章需要:

  • 已完成第 1 章的 Model 与 Service。
  • ViewModels\CollectionBindingViewModel.cs
  • Views\CollectionBindingView.xaml
  • Infrastructure\ObservableObject.cs
  • Infrastructure\RelayCommand.cs

第一步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public sealed class CollectionBindingViewModel
: DemoViewModelBase
{
private readonly IInspectionTelemetryService _service;
private InspectionPoint? _selectedPoint;
private string _filterStation = "全部站点";

public CollectionBindingViewModel(
IInspectionTelemetryService service)
{
_service = service;
Points = new ObservableCollection<InspectionPoint>(
service.GetHistory());

RefreshCommand = new RelayCommand(Refresh);
SelectFirstCommand = new RelayCommand(SelectFirst);
}

public ObservableCollection<InspectionPoint> Points { get; }
}

这一段做了两件事:

  1. 用服务的历史数据初始化集合。
  2. 保存服务引用,后续刷新时可以重新读取。

注意:集合本身是 ViewModel 的公开属性,但它不直接暴露服务的内部 List。

第二步操作

筛选结果使用计算属性:

1
2
3
4
5
6
public IEnumerable<InspectionPoint> VisiblePoints =>
FilterStation == "全部站点"
? Points
: Points.Where(point => point.Station == FilterStation);

public int VisibleCount => VisiblePoints.Count();

选中项使用普通属性,并在 setter 中更新提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public InspectionPoint? SelectedPoint
{
get => _selectedPoint;
set
{
if (SetProperty(ref _selectedPoint, value))
{
SelectionMessage = value == null
? "当前没有选中采样点。"
: $"选中 {value.Station} {value.TimeText},"
+ $"温度 {value.Temperature:0.0} ℃。";
}
}
}

这里使用 SetProperty,它会在值真正变化时发出 PropertyChanged。

第三步操作

这是本章最容易漏掉的一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public string FilterStation
{
get => _filterStation;
set
{
if (SetProperty(ref _filterStation, value))
{
OnPropertyChanged(nameof(VisiblePoints));
OnPropertyChanged(nameof(VisibleCount));
SelectionMessage =
"筛选条件改变,界面重新读取 VisiblePoints。";
}
}
}

VisiblePoints 没有自己的 setter,WPF 不知道它的结果变了。所以筛选条件变化后,必须通知这两个计算属性。

关键代码逐段解释

第一段是列表绑定:

1
2
3
4
<ListBox ItemsSource="{Binding VisiblePoints}"
SelectedItem="{Binding SelectedPoint, Mode=TwoWay}"
HorizontalContentAlignment="Stretch">
</ListBox>

三个关键设置:

  • ItemsSource 决定列表有哪些行。
  • SelectedItem 双向绑定让用户选中行时回写 ViewModel。
  • HorizontalContentAlignment="Stretch" 让自定义行模板填满可用宽度。

第二段是筛选控件:

1
2
<ComboBox ItemsSource="{Binding Stations}"
SelectedItem="{Binding FilterStation, Mode=TwoWay}"/>

Stations 提供候选项,FilterStation 保存当前选项。

第三段是集合数据流图:

集合对接图

图 4:集合数据流。列表显示与选中项是两条绑定路径,修改筛选条件后必须发出属性通知。

第四段是集合重新载入:

1
2
3
4
5
6
7
8
9
10
11
12
private void Refresh()
{
Points.Clear();
foreach (var point in _service.GetHistory())
{
Points.Add(point);
}

OnPropertyChanged(nameof(VisiblePoints));
OnPropertyChanged(nameof(VisibleCount));
SelectFirst();
}

ObservableCollection 会自动通知列表“增加了一行”,但 VisiblePoints 是计算属性,所以还要主动通知它。

运行后应该看到什么

打开“2 集合对接”页:

  1. 列表显示 12 条采样。
  2. 下拉框默认是“全部站点”。
  3. 当前可见数量显示 12 条。
  4. 选择“A 区”后,列表只保留 A 区数据,可见数量变成 4。
  5. 点击“选中第一条”,右侧出现选中项的温度与时间。
  6. 点击“刷新集合”,列表重新载入并自动选中第一条。

第 2 章真实运行截图

图 5:第 2 章真实运行画面。列表、筛选条件、可见数量和选中项结果同时可见。

对应配图或真实运行截图

本章使用:

  • images/scottplot-mvvm-collection-flow.png:集合与选中项两条数据路径。
  • images/scottplot-mvvm-chapter-02-collection.png:集合对接页真实运行截图。

跟做检查点

  • [ ] 能解释 ObservableCollection<T> 与 List<T> 的刷新差异。
  • [ ] 能指出 ItemsSource 绑定的是 VisiblePoints。
  • [ ] 能指出 SelectedItem 绑定的是 SelectedPoint。
  • [ ] 筛选条件变化后能主动通知 VisiblePoints。
  • [ ] 选中项为空时不会强行读取它的数值。

常见错误与解决方法

错误一:绑定 List<T> 后执行 Points.Add(...),界面没有新行。

解决方法:改用 ObservableCollection<T>,或者重新给 ItemsSource 赋值并发出通知。本示例采用前者。

错误二:筛选后列表有变化,但数量文字仍是旧值。

解决方法:在 FilterStation 的 setter 中同时通知 VisiblePoints 和 VisibleCount。

错误三:SelectedPoint 不为空,但详情文字不刷新。

解决方法:在 setter 中调用 OnPropertyChanged(nameof(SelectionMessage)),或者直接更新 SelectionMessage 的属性 setter。

错误四:把 VisiblePoints 写成可写属性,但没有真正保存筛选结果。

解决方法:计算属性只读,筛选条件作为源状态,结果由源状态推导。

小练习

新增一个“只看高温”复选框,要求:

  • 复选框绑定 bool OnlyHighTemperature。
  • VisiblePoints 同时应用站点筛选和高温筛选。
  • 两个条件改变时都通知 VisiblePoints 与 VisibleCount。

本章小结

本章把服务数据接入 ObservableCollection,并区分了“列表有哪些行”和“当前选中哪一行”。

下一章会在这张列表上增加真正的用户动作:新增一条采样,以及删除被点击的那一行。

3、集合绑定中的命令处理

本节目标

掌握集合页面中的两类命令:

  1. 页面级命令:新增整张表单。
  2. 行级命令:删除用户点击的那一行。

学完后,你应该能解释为什么删除按钮必须传 CommandParameter,以及为什么命令的 CanExecute 不能只依赖“当前选中项”。

通俗原理说明

命令可以理解为“界面上的动作按钮”和“ViewModel 中的业务动作”之间的合同。

一个命令包含两件事:

成员 作用 示例
Execute(parameter) 真正执行动作 新增采样、删除指定行
CanExecute(parameter) 判断当前能否执行 输入有效、参数是行对象

在 ListBox 的 DataTemplate 中,按钮的 DataContext 是当前行对象,而命令通常属于整个页面 ViewModel。因此需要:

  • 用 RelativeSource 找到外层 UserControl。
  • 用 DataContext.DeleteSelectedCommand 找到页面命令。
  • 用 CommandParameter="{Binding}" 把当前行对象传给命令。

“不用参数会怎样?”

命令只能读取 SelectedPoint。如果用户直接点击某一行右侧的“删除此行”,但这一行没有被选中,删除目标就可能不是用户以为的那一行。

需要准备的文件、数据或环境

本章需要:

  • ViewModels\CollectionCommandViewModel.cs
  • Views\CollectionCommandView.xaml
  • Infrastructure\RelayCommand.cs
  • 已完成的 Service 与 ObservableCollection 数据。

第一步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public CollectionCommandViewModel(
IInspectionTelemetryService service)
{
_service = service;
Points = new ObservableCollection<InspectionPoint>(
service.GetHistory());

AddCommand = new RelayCommand(
AddPoint,
() => CanAddPoint);

DeleteSelectedCommand = new RelayCommand(
DeletePoint,
parameter => parameter is InspectionPoint);

ClearSelectionCommand = new RelayCommand(
() => SelectedPoint = null);
}

这里要特别注意 DeleteSelectedCommand:

  • DeletePoint 接收参数。
  • CanExecute 检查参数是不是 InspectionPoint。
  • 它不再依赖“页面必须先选中某一行”。

第二步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void DeletePoint(object? parameter)
{
if (parameter is not InspectionPoint point)
{
return;
}

if (_service.RemovePoint(point.Id))
{
Points.Remove(point);

if (ReferenceEquals(SelectedPoint, point))
{
SelectedPoint = null;
}

OperationCount++;
Result = $"删除采样 #{point.Id},集合与数据源同步变化。";
}
}

这段代码按顺序解决四件事:

  1. 从参数中取出行对象。
  2. 用 Id 让 Service 删除同一份业务数据。
  3. 从 ObservableCollection 删除同一个对象,界面自动少一行。
  4. 如果被删除的正是当前选中项,则清空选中项。

第三步操作

1
2
3
4
<Button Content="删除此行"
Command="{Binding DataContext.DeleteSelectedCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"/>

绑定路径拆开看:

片段 含义
DataContext.DeleteSelectedCommand 找到页面 ViewModel 的命令
RelativeSource AncestorType=UserControl 从行模板向上找到页面控件
CommandParameter="{Binding}" 把当前行对象作为参数

关键代码逐段解释

第一段是新增输入状态:

1
2
3
4
public bool CanAddPoint =>
!string.IsNullOrWhiteSpace(NewStation) &&
double.TryParse(NewTemperature, out _) &&
double.TryParse(NewVibration, out _);

这段代码把“能否新增”变成 ViewModel 的可测试规则。按钮不自己判断文本,命令也不依赖控件。

第二段是输入变化后刷新命令状态:

1
2
3
4
5
6
7
8
9
10
11
12
public string NewTemperature
{
get => _newTemperature;
set
{
if (SetProperty(ref _newTemperature, value))
{
OnPropertyChanged(nameof(CanAddPoint));
AddCommand.RaiseCanExecuteChanged();
}
}
}

如果只更新 CanAddPoint 的通知,不调用 RaiseCanExecuteChanged(),按钮可能仍保留旧的可用状态。

第三段是命令参数图:

集合命令参数图

图 6:集合命令参数链。命令通过参数识别目标行,避免依赖控件名称或隐式选中状态。

运行后应该看到什么

打开“3 集合命令”页:

  1. 左侧表单默认显示 A 区、68.5、2.75。
  2. 点击“新增采样”,右侧列表多一行,并自动选中新行。
  3. 点击任意一行右侧的“删除此行”,该行立即消失。
  4. 左侧结果文本显示删除的采样编号。
  5. “集合命令执行次数”每次新增或删除后增加。
  6. 点击“取消选择”后,右侧顶部显示“未选择行”。

第 3 章真实运行截图

图 7:第 3 章真实运行画面。每一行都有独立删除按钮,表单与集合命令状态同时可见。

对应配图或真实运行截图

本章使用:

  • images/scottplot-mvvm-command-flow.png:命令参数如何从行模板传到 ViewModel。
  • images/scottplot-mvvm-chapter-03-command.png:集合命令页真实运行截图。

跟做检查点

  • [ ] 能解释 CommandParameter="{Binding}" 传的是当前行对象。
  • [ ] 能解释 RelativeSource 为什么能找到页面命令。
  • [ ] 删除逻辑使用的是参数对象,而不是隐式选中项。
  • [ ] 删除后集合与 Service 数据保持同步。
  • [ ] 输入无效时新增命令不可执行。

常见错误与解决方法

错误一:在行模板里写 Command="{Binding DeleteSelectedCommand}",命令始终找不到。

解决方法:行模板的 DataContext 是 InspectionPoint,不是页面 ViewModel。使用 RelativeSource 找到外层控件,再访问它的 DataContext。

错误二:删除命令忽略参数,只删除 SelectedPoint。

解决方法:在 Execute 和 CanExecute 中都接收参数,并根据参数对象处理。

错误三:删除集合后没有删除服务数据。

解决方法:先调用 Service 的业务删除方法,成功后再更新集合;或者反过来后确保失败时回滚。本示例采用前者。

错误四:命令状态变化后按钮仍不亮。

解决方法:在属性 setter 中调用 RaiseCanExecuteChanged()。

小练习

给每一行增加一个“复制此行”按钮,要求:

  • 命令放在页面 ViewModel。
  • 通过 CommandParameter 传入当前行。
  • 复制后的新行生成新的 Id。
  • 新行追加到集合末尾并自动选中。

本章小结

本章把集合从“能显示”推进到“能操作”。页面级命令处理表单,行级命令通过参数处理目标行。

下一章不再操作列表,而是把同一份采样集合转换成数组,交给 ScottPlot。

4、ScottPlot的基本绑定逻辑

本节目标

理解 WpfPlot、Plot、Scatter 三者的分工,掌握把 X、Y 数组绑定到图表的最短流程,并知道什么时候需要重新创建系列和刷新。

通俗原理说明

ScottPlot 5 的 WPF 控件叫 WpfPlot,但真正保存标题、坐标轴和系列的是它的 Plot 属性。

可以把它们想成:

对象 角色 负责什么
WpfPlot 画框 在 WPF 界面中占位、显示和请求刷新
Plot 画布模型 保存坐标轴、字体、图例和系列
Scatter 一条曲线 保存 X、Y 数据并负责绘制点线

“不用数组绑定会怎样?”

如果每次刷新都直接操作界面元素,或者把绘图逻辑散落在按钮事件中,就会出现:

  • 数据源和图形对象混在一起。
  • 切换指标时不知道旧系列是否要清除。
  • 新增数据后曲线没有变化。
  • 图表无法脱离窗口进行数据层测试。

本示例采用一条简单、可验证的 ScottPlot 5 路线:

1
2
3
4
ViewModel 提供 double[] X/Y
→ View 调用 Plot.Add.Scatter(xs, ys)
→ 设置坐标轴
→ WpfPlot.Refresh()

需要准备的文件、数据或环境

本章需要:

  • ViewModels\PlotBindingViewModel.cs
  • Views\PlotBindingView.xaml
  • Views\PlotBindingView.xaml.cs
  • ScottPlot.WPF 5.1.58

项目中只通过 NuGet 引用 ScottPlot.WPF,不要同时手动添加其他版本的 ScottPlot DLL。

第一步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public double[] GetXValues()
{
return Points
.Select((point, index) => (double)index)
.ToArray();
}

public double[] GetYValues()
{
return Points
.Select(point =>
SelectedMetric == "振动"
? point.Vibration
: point.Temperature)
.ToArray();
}

X 数组是采样序号,Y 数组根据当前指标在温度和振动之间切换。

两个数组必须等长。否则 Add.Scatter 无法形成一一对应的坐标点。

第二步操作

先添加命名空间:

1
xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"

再放置控件:

1
2
3
<Border Style="{StaticResource Card}" Padding="8">
<ScottPlot:WpfPlot x:Name="MetricPlot"/>
</Border>

这一步只负责给图表一个显示位置,不负责创建数据。

第三步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private void RenderPlot()
{
if (DataContext is not PlotBindingViewModel viewModel)
{
return;
}

var plot = MetricPlot.Plot;
plot.Clear();
plot.Font.Set("Microsoft YaHei UI");
plot.Title(viewModel.PlotTitle);
plot.Axes.Bottom.Label.Text = "采样序号";
plot.Axes.Left.Label.Text = viewModel.AxisLabel;

var scatter = plot.Add.Scatter(
viewModel.GetXValues(),
viewModel.GetYValues());

scatter.LegendText = viewModel.SelectedMetric;
scatter.LineWidth = 2.5f;
scatter.MarkerSize = 6;
scatter.Color = Color.FromHex("#D95D39");

var xs = viewModel.GetXValues();
if (xs.Length > 0)
{
plot.Axes.SetLimitsX(
-0.5,
Math.Max(4.5, xs.Max() + 0.5));
}

plot.ShowLegend(Alignment.UpperLeft);
MetricPlot.Refresh();
}

这段代码的执行顺序很重要:

  1. 清空旧图形模型。
  2. 设置中文字体。
  3. 设置标题和坐标轴文字。
  4. 创建新的散点折线系列。
  5. 设置 X 轴范围。
  6. 显示图例。
  7. 请求 WPF 控件重绘。

关键代码逐段解释

第一段是首次加载和属性变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public PlotBindingView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
Loaded += OnLoaded;
}

private void OnViewModelPropertyChanged(
object? sender,
PropertyChangedEventArgs e)
{
if (e.PropertyName ==
nameof(PlotBindingViewModel.SeriesVersion))
{
RenderPlot();
}
}

ViewModel 不直接调用控件,但它通过 SeriesVersion 发布“有一版新数据源”的信号,View 收到后重绘。

第二段是发布新版本:

1
2
3
4
5
6
7
public void PublishSeries()
{
SeriesVersion++;
RenderMessage =
$"已发布第 {SeriesVersion} 版数据源:"
+ $"{Points.Count} 个点,指标为 {SelectedMetric}。";
}

版本号是刷新信号,不是图表数据。它的作用只是让 View 知道数据源需要重新读取。

第三段是对象关系图:

ScottPlot 对象关系图

图 8:WpfPlot、Plot 与 Scatter 的关系。当前项目使用 ScottPlot 5 的 Add.Scatter API。

运行后应该看到什么

打开“4 ScottPlot 绑定”页:

  1. 右侧显示一条温度趋势曲线。
  2. 左侧显示当前指标为“温度”。
  3. 数据源版本初始为 0。
  4. 点击“振动”,曲线切换为振动数据,Y 轴标题同步改变。
  5. 点击“发布数据源”,版本号增加,提示文本更新。
  6. 点击“追加采样”,图表多一个点,版本号增加。

第 4 章真实运行截图

图 9:第 4 章真实运行画面。左侧数据源控制与右侧真实曲线同时可见。

对应配图或真实运行截图

本章使用:

  • images/scottplot-mvvm-plot-relationship.png:WpfPlot、Plot 与 Scatter 的职责关系。
  • images/scottplot-mvvm-basic-binding.png:数组到曲线的五步流程。
  • images/scottplot-mvvm-chapter-04-plot-binding.png:基础绑定页真实运行截图。

基础绑定流程图

图 10:ScottPlot 基础绑定流程。刷新信号来自 ViewModel,图形对象操作留在 View。

跟做检查点

  • [ ] 能区分 WpfPlot 与 Plot。
  • [ ] 知道 X、Y 数组必须等长。
  • [ ] 能指出 Add.Scatter 创建的是系列。
  • [ ] 能指出 Refresh() 请求控件重绘。
  • [ ] 知道切换指标后需要重新读取 Y 数组。

常见错误与解决方法

错误一:XAML 找不到 WpfPlot。

解决方法:检查 xmlns:ScottPlot 是否指向 clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF,并确认包引用版本一致。

错误二:运行后图表是空白。

解决方法:依次检查数组长度是否大于 0、Add.Scatter 是否执行、是否调用了 Refresh()。

错误三:切换指标后曲线没有变化。

解决方法:确认切换指标后重新调用 GetYValues(),并发出 SeriesVersion 通知。

错误四:中文标题显示方框。

解决方法:调用 plot.Font.Set("Microsoft YaHei UI"),并确认系统中安装了该字体。

错误五:混用 ScottPlot 4 的 plt.PlotScatter 和 ScottPlot 5 的 plot.Add.Scatter。

解决方法:以当前项目引用的 5.1.58 API 为准,不要复制旧版本代码。

小练习

给图表增加一条水平预警线,温度阈值为 80 ℃。要求:

  • 使用 plot.Add.HorizontalLine(80)。
  • 设置红色和虚线样式。
  • 图例中显示“预警线 80 ℃”。

本章小结

本章完成了第一张真实曲线:ViewModel 提供数组,View 操作 Plot,WpfPlot 负责显示。

下一章会把“手动发布一次数据”升级为“定时持续追加数据”,并观察刷新链路。

5、ScottPlot的绑定数据源实时更新

本节目标

理解实时图表的关键不是不停重建 WpfPlot,而是持续更新数据源、重新生成数组,并请求 Plot 重绘。

学完后,你应该能说清:

  • 定时器产生什么。
  • 集合何时通知视图。
  • View 何时重建数组。
  • 为什么需要保留数据窗口。
  • 怎样避免图表越画越慢。

通俗原理说明

实时图表的“实时”来自数据不断追加,不来自控件不断重建。

可以把实时链路想成一条传送带:

1
2
3
4
5
6
7
8
DispatcherTimer 到点
→ ViewModel 生成一条新采样
→ Service 保存这条采样
→ ObservableCollection 追加新行
→ View 收到 CollectionChanged
→ 集合转换成 double[]
→ Plot.Add.Scatter 创建新系列
→ WpfPlot.Refresh()

“不控制数据量会怎样?”

如果集合无限增长,每次刷新都要处理越来越多点,界面会越来越慢。本示例保留最多 60 条,并只显示最近 45 个 X 范围。

需要准备的文件、数据或环境

本章需要:

  • ViewModels\RealtimePlotViewModel.cs
  • Views\RealtimePlotView.xaml
  • Views\RealtimePlotView.xaml.cs
  • 已完成的 ScottPlot 基础页面。

第一步操作

1
2
3
4
5
6
_timer = new DispatcherTimer(
DispatcherPriority.Background)
{
Interval = TimeSpan.FromMilliseconds(650)
};
_timer.Tick += OnTimerTick;

使用 DispatcherTimer 是因为它会在 WPF 的 UI 线程上触发,更新集合和控件时不需要额外切回界面线程。

第二步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void AddSample()
{
Tick++;
var temperature = Math.Round(
58 + Math.Sin(Tick / 2.7) * 8
+ _random.NextDouble() * 2, 1);

var vibration = Math.Round(
2.1 + Math.Cos(Tick / 3.1) * 0.45
+ _random.NextDouble() * 0.18, 2);

var point = _service.AppendPoint(
SelectedStation,
temperature,
vibration);

Points.Add(point);

while (Points.Count > 60)
{
Points.RemoveAt(0);
}

LatestTemperature = point.Temperature;
SampleCount++;
LastSampleText =
$"{point.TimeText} · {point.Station} · "
+ $"{point.Temperature:0.0} ℃";
}

这段代码做了四件事:

  1. 用时间序号生成有波动但可重复观察的温度。
  2. 把采样交给 Service。
  3. 把同一个对象追加到集合。
  4. 删除最早的数据,保持集合窗口不超过 60 条。

第三步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void OnDataContextChanged(
object sender,
DependencyPropertyChangedEventArgs e)
{
if (_viewModel != null)
{
_viewModel.Points.CollectionChanged -=
OnPointsChanged;
}

_viewModel = e.NewValue as RealtimePlotViewModel;
if (_viewModel != null)
{
_viewModel.Points.CollectionChanged +=
OnPointsChanged;
}
}

private void OnPointsChanged(
object? sender,
NotifyCollectionChangedEventArgs e)
{
RenderPlot();
}

CollectionChanged 是集合层的通知。View 收到后不直接修改集合,而是重新读取数据并绘制。

关键代码逐段解释

第一段是数组投影:

1
2
3
4
5
6
7
8
var points = _viewModel.Points.ToList();
var xs = Enumerable
.Range(0, points.Count)
.Select(index => (double)index)
.ToArray();
var ys = points
.Select(point => point.Temperature)
.ToArray();

这里每次渲染都会重新生成数组。这样写不追求极致的零分配,但逻辑直观,适合初学者观察“集合变化之后,图表拿到的是新数组”。

第二段是坐标轴窗口:

1
2
3
4
5
6
7
8
if (xs.Length > 0)
{
plot.Axes.SetLimitsX(
Math.Max(0, xs.Max() - 45),
xs.Max() + 2);

plot.Axes.SetLimitsY(40, 95);
}

固定 Y 轴可以避免温度小幅波动时曲线视觉上乱跳;X 轴只显示最近一段,曲线不会越挤越密。

第三段是实时链路图:

实时更新链路图

图 11:实时更新链路。定时器只负责产生数据,绘图仍然走同一条“集合 → 数组 → Scatter”路径。

第四段是刷新时机:

刷新时机对照图

图 12:刷新时机对照。判断标准是数据源或图形配置是否变化,而不是界面上是否出现了任意一次点击。

运行后应该看到什么

打开“5 实时更新”页:

  1. 初始显示历史温度曲线。
  2. 点击“开始”,状态变为“实时采集运行中 · 650 ms”。
  3. 右侧最新温度、振动和采样时间持续变化。
  4. 曲线尾部不断向右延伸。
  5. 样本数增加,但集合不会无限增长。
  6. 点击“停止”,数据不再追加。
  7. 点击“加一个”,可以手动追加一个采样。
  8. 点击“重置历史数据”,恢复初始 12 条数据。

第 5 章真实运行截图

图 13:第 5 章真实运行画面。截图前已手动追加采样,最新值、样本数和曲线尾部均可见。

对应配图或真实运行截图

本章使用:

  • images/scottplot-mvvm-realtime-flow.png:定时器到图表的实时链路。
  • images/scottplot-mvvm-refresh-timing.png:哪些动作需要重绘。
  • images/scottplot-mvvm-troubleshooting.png:图表不更新时的检查顺序。
  • images/scottplot-mvvm-chapter-05-realtime.png:实时更新页真实运行截图。

图表排错顺序图

图 14:图表排错顺序。数据、数组、通知、渲染、生命周期,五步能定位大多数“图表不动”问题。

跟做检查点

  • [ ] 定时器间隔与状态文字一致。
  • [ ] 新采样同时进入 Service 和 ObservableCollection。
  • [ ] 集合变化能触发图表重绘。
  • [ ] X/Y 数组在每次重绘时重新生成。
  • [ ] 集合最多保留 60 条。
  • [ ] 停止后不再继续追加数据。

常见错误与解决方法

错误一:定时器每次 new WpfPlot(),旧控件越堆越多。

解决方法:控件只创建一次,持续更新的是集合、数组和 Plot 系列。

错误二:集合有新数据,但曲线不动。

解决方法:检查是否订阅了 CollectionChanged,以及 RenderPlot() 是否执行到 Refresh()。

错误三:运行时抛出跨线程访问异常。

解决方法:使用 DispatcherTimer,或者把后台线程的数据通过 Dispatcher.Invoke 切回 UI 线程。

错误四:运行几分钟后越来越卡。

解决方法:限制集合长度,并让 X 轴只显示最近一段数据。

错误五:图表在窗口加载前就尝试渲染,出现空引用或布局异常。

解决方法:在 Loaded 事件后再首次渲染,或者在渲染入口检查 IsLoaded。

小练习

把定时器间隔改为 1000 ms,并新增一个“暂停后继续”的切换按钮。要求:

  • 暂停时定时器停止,但已有数据保留。
  • 继续时从当前样本序号继续追加。
  • 状态文字明确显示“暂停”或“运行中”。

本章小结

本章把基础图表变成可持续更新的实时图表。关键路径始终是同一条:产生数据、追加集合、重建数组、更新 Plot、请求刷新。

完整构建与自测结果

本项目最后一次完整验证结果:

检查项 结果
离线还原 成功
构建警告 0
构建错误 0
自测通过 5
自测失败 0
自测退出码 0

自测覆盖:

  1. 项目分层与职责说明。
  2. 集合筛选与选中项。
  3. 集合命令新增与删除。
  4. ScottPlot 数据源数组。
  5. 实时采样追加与窗口截断。

自测结果文件:

1
示例代码\InspectionScottPlotMvvmDemo\artifacts\self-test.txt

应用日志文件:

1
示例代码\InspectionScottPlotMvvmDemo\artifacts\logs\application.log

跟做完成后的最终检查清单

  • [ ] 五个页签标题与目录顺序一致。
  • [ ] 第 1 页能查看四层职责并读取服务快照。
  • [ ] 第 2 页筛选后可见数量和列表同步变化。
  • [ ] 第 3 页新增和按行删除都能改变集合。
  • [ ] 第 4 页能切换温度与振动并显示真实曲线。
  • [ ] 第 5 页开始后曲线持续追加,停止后不再追加。
  • [ ] 自测 5 项全部通过。
  • [ ] 构建为 0 警告、0 错误。
  • [ ] 图片使用本地相对路径,不依赖网络。
  • [ ] 不混用 ScottPlot 4 与 ScottPlot 5 API。

后续可以继续练习的方向

如果希望继续扩展,优先围绕本次目录做小幅改动:

  1. 给集合页增加按温度范围筛选。
  2. 给命令页增加“编辑当前行”命令。
  3. 给 ScottPlot 页增加第二条温度系列。
  4. 给实时页增加暂停/继续。
  5. 把内存 Service 换成 CSV 或数据库实现,但保持 ViewModel 接口不变。

这些扩展都建立在同一条数据流上,不需要推翻本笔记的结构。