WPF MVVM与ScottPlot实时数据学习笔记
WPF MVVM 与 ScottPlot 实时数据学习笔记:巡检数据监控台
这份笔记根据本次目录编写,主题是把 MVVM 的分层、集合、命令,与 ScottPlot 的图表数据源和实时刷新连成一条完整链路。
配套案例是一个“巡检数据监控台”。它不追求堆叠控件,而是用五个页签分别观察:
- 项目如何分层。
- 集合数据怎样接到列表。
- 列表中的每一行怎样发出命令。
- 数组数据怎样变成 ScottPlot 曲线。
- 定时采样怎样持续更新图表。
适合谁
本笔记面向刚接触 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 | Service 数据 |
推荐学习顺序
严格按目录顺序学习:
- 先看清项目结构与依赖方向。
- 再把服务数据接成可观察集合。
- 再给集合增加命令,尤其是行级删除命令。
- 再把同一份数据投影成数组,创建 ScottPlot 系列。
- 最后用定时器持续追加数据,观察刷新链路。
不建议直接跳到第 5 章。实时刷新只是把前四章的数据流重复执行,如果集合和图表的基础链路没有看懂,直接抄定时器代码很难定位问题。
示例项目路径
1 | C:\Users\North\Desktop\WPF学习笔记\MVVM\示例代码\InspectionScottPlotMvvmDemo |
项目附带本地 NuGet.Config 和 .nuget\packages 离线缓存。执行还原时使用项目内的包源,不需要访问网络。
通过命令行运行
打开 PowerShell,依次执行:
1 | Set-Location "C:\Users\North\Desktop\WPF学习笔记\MVVM\示例代码\InspectionScottPlotMvvmDemo" |
打开指定页签,页签编号是 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 中打开和运行
- 打开 Visual Studio。
- 选择“打开项目或解决方案”。
- 选择
InspectionScottPlotMvvmDemo.csproj。 - 等待“解决方案资源管理器”完成还原。
- 在“生成”菜单中选择“生成解决方案”。
- 按
F5启动调试,或按Ctrl+F5不附加调试器启动。
如果 Visual Studio 首次还原时提示包源,请确认项目目录中的 NuGet.Config 仍在,并且 .nuget\packages 文件夹没有被删除。
如何创建自己的跟做项目
可以按下面的顺序创建一个同名或类似名称的 WPF 项目:
1 | dotnet new wpf -n InspectionScottPlotMvvmDemo |
然后逐步完成:
- 添加
ScottPlot.WPF 5.1.58包引用。 - 建立
Models、Services、ViewModels、Views和Infrastructure目录。 - 先实现
InspectionPoint与内存服务。 - 再实现
ObservableObject和RelayCommand。 - 先完成第 1、2 页,再添加命令页。
- 最后加入两个
WpfPlot页面。
跟做时建议每完成一章就编译一次,不要等到五章全部写完才第一次运行。
跟做完成后的项目结构
1 | InspectionScottPlotMvvmDemo/ |
笔记中的原理图与截图位于上一级的 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.csServices\IInspectionTelemetryService.csServices\InMemoryInspectionTelemetryService.csViewModels\MainViewModel.csViewModels\ProjectStructureViewModel.csViews\ProjectStructureView.xamlApp.xaml中的统一字体和颜色资源
示例启动后会创建 12 条初始巡检数据,服务实例由 MainViewModel 创建,并传给五个页面 ViewModel。
第一步操作
先让一条采样拥有明确字段,而不是到处传几个互相没有关联的字符串。
1 | public sealed class InspectionPoint : INotifyPropertyChanged |
这里先展示最小结构。实际项目中的完整 InspectionPoint 还实现了属性变更通知,原因会在第 2 章解释。
关键点:
Id用于让集合与服务定位同一条数据。Timestamp保存时间。Station保存站点。Temperature和Vibration是两个可绘制指标。TimeText与AlarmText是只读展示属性。
第二步操作
接口只暴露业务用例,不暴露 WPF 类型:
1 | public interface IInspectionTelemetryService |
内存实现先准备 12 条数据:
1 | public sealed class InMemoryInspectionTelemetryService |
GetHistory() 返回副本,调用者不能直接拿到内部 List 随意修改,这样可以避免绕过业务方法。
第三步操作
示例让五个页面共享同一个服务实例:
1 | public MainViewModel(int selectedTabIndex = 0) |
这样做的教学价值是:在第 3 页新增一条采样,切换到其他页面时仍能观察到同一份业务数据的变化。
项目结构的核心不是“一个类只做一件事”这句话,而是每个对象只承担自己应该承担的变化原因。
关键代码逐段解释
第一段是统一资源:
1 | <Style TargetType="Window"> |
这段代码解决三个界面基础问题:
- 统一中文字体,避免不同控件显示风格不一致。
- 统一文字颜色。
- 统一背景色。
第二段是页面绑定:
1 | <TabControl SelectedIndex="{Binding SelectedTabIndex, Mode=TwoWay}"> |
SelectedIndex 双向绑定让启动参数可以指定页签,也允许用户点击切换页签后回写 ViewModel。
第三段是分层关系图:
图 2:项目分层与依赖方向。结构的目标是让数据逻辑可测试,让界面只负责显示与转发。
图中箭头表示允许的主要调用方向。View 可以绑定 ViewModel;ViewModel 可以调用 Service;Service 操作 Model。不要让 Model 反过来引用窗口或 ScottPlot。
运行后应该看到什么
运行项目,默认打开“1 项目结构”页。你应该看到:
- 左侧四行:View、ViewModel、Service、Model。
- 每行有“查看职责”按钮。
- 右侧深色卡片显示当前层的说明。
- 点击“读取服务快照”后,右侧文字变成“服务层当前保存 12 条巡检采样”。
- 底部状态栏出现本次操作提示。
图 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.csViews\CollectionBindingView.xamlInfrastructure\ObservableObject.csInfrastructure\RelayCommand.cs
第一步操作
1 | public sealed class CollectionBindingViewModel |
这一段做了两件事:
- 用服务的历史数据初始化集合。
- 保存服务引用,后续刷新时可以重新读取。
注意:集合本身是 ViewModel 的公开属性,但它不直接暴露服务的内部 List。
第二步操作
筛选结果使用计算属性:
1 | public IEnumerable<InspectionPoint> VisiblePoints => |
选中项使用普通属性,并在 setter 中更新提示:
1 | public InspectionPoint? SelectedPoint |
这里使用 SetProperty,它会在值真正变化时发出 PropertyChanged。
第三步操作
这是本章最容易漏掉的一步:
1 | public string FilterStation |
VisiblePoints 没有自己的 setter,WPF 不知道它的结果变了。所以筛选条件变化后,必须通知这两个计算属性。
关键代码逐段解释
第一段是列表绑定:
1 | <ListBox ItemsSource="{Binding VisiblePoints}" |
三个关键设置:
ItemsSource决定列表有哪些行。SelectedItem双向绑定让用户选中行时回写 ViewModel。HorizontalContentAlignment="Stretch"让自定义行模板填满可用宽度。
第二段是筛选控件:
1 | <ComboBox ItemsSource="{Binding Stations}" |
Stations 提供候选项,FilterStation 保存当前选项。
第三段是集合数据流图:
图 4:集合数据流。列表显示与选中项是两条绑定路径,修改筛选条件后必须发出属性通知。
第四段是集合重新载入:
1 | private void Refresh() |
ObservableCollection 会自动通知列表“增加了一行”,但 VisiblePoints 是计算属性,所以还要主动通知它。
运行后应该看到什么
打开“2 集合对接”页:
- 列表显示 12 条采样。
- 下拉框默认是“全部站点”。
- 当前可见数量显示 12 条。
- 选择“A 区”后,列表只保留 A 区数据,可见数量变成 4。
- 点击“选中第一条”,右侧出现选中项的温度与时间。
- 点击“刷新集合”,列表重新载入并自动选中第一条。
图 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、集合绑定中的命令处理
本节目标
掌握集合页面中的两类命令:
- 页面级命令:新增整张表单。
- 行级命令:删除用户点击的那一行。
学完后,你应该能解释为什么删除按钮必须传 CommandParameter,以及为什么命令的 CanExecute 不能只依赖“当前选中项”。
通俗原理说明
命令可以理解为“界面上的动作按钮”和“ViewModel 中的业务动作”之间的合同。
一个命令包含两件事:
| 成员 | 作用 | 示例 |
|---|---|---|
Execute(parameter) |
真正执行动作 | 新增采样、删除指定行 |
CanExecute(parameter) |
判断当前能否执行 | 输入有效、参数是行对象 |
在 ListBox 的 DataTemplate 中,按钮的 DataContext 是当前行对象,而命令通常属于整个页面 ViewModel。因此需要:
- 用
RelativeSource找到外层UserControl。 - 用
DataContext.DeleteSelectedCommand找到页面命令。 - 用
CommandParameter="{Binding}"把当前行对象传给命令。
“不用参数会怎样?”
命令只能读取 SelectedPoint。如果用户直接点击某一行右侧的“删除此行”,但这一行没有被选中,删除目标就可能不是用户以为的那一行。
需要准备的文件、数据或环境
本章需要:
ViewModels\CollectionCommandViewModel.csViews\CollectionCommandView.xamlInfrastructure\RelayCommand.cs- 已完成的 Service 与
ObservableCollection数据。
第一步操作
1 | public CollectionCommandViewModel( |
这里要特别注意 DeleteSelectedCommand:
DeletePoint接收参数。CanExecute检查参数是不是InspectionPoint。- 它不再依赖“页面必须先选中某一行”。
第二步操作
1 | public void DeletePoint(object? parameter) |
这段代码按顺序解决四件事:
- 从参数中取出行对象。
- 用
Id让 Service 删除同一份业务数据。 - 从
ObservableCollection删除同一个对象,界面自动少一行。 - 如果被删除的正是当前选中项,则清空选中项。
第三步操作
1 | <Button Content="删除此行" |
绑定路径拆开看:
| 片段 | 含义 |
|---|---|
DataContext.DeleteSelectedCommand |
找到页面 ViewModel 的命令 |
RelativeSource AncestorType=UserControl |
从行模板向上找到页面控件 |
CommandParameter="{Binding}" |
把当前行对象作为参数 |
关键代码逐段解释
第一段是新增输入状态:
1 | public bool CanAddPoint => |
这段代码把“能否新增”变成 ViewModel 的可测试规则。按钮不自己判断文本,命令也不依赖控件。
第二段是输入变化后刷新命令状态:
1 | public string NewTemperature |
如果只更新 CanAddPoint 的通知,不调用 RaiseCanExecuteChanged(),按钮可能仍保留旧的可用状态。
第三段是命令参数图:
图 6:集合命令参数链。命令通过参数识别目标行,避免依赖控件名称或隐式选中状态。
运行后应该看到什么
打开“3 集合命令”页:
- 左侧表单默认显示 A 区、68.5、2.75。
- 点击“新增采样”,右侧列表多一行,并自动选中新行。
- 点击任意一行右侧的“删除此行”,该行立即消失。
- 左侧结果文本显示删除的采样编号。
- “集合命令执行次数”每次新增或删除后增加。
- 点击“取消选择”后,右侧顶部显示“未选择行”。
图 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 | ViewModel 提供 double[] X/Y |
需要准备的文件、数据或环境
本章需要:
ViewModels\PlotBindingViewModel.csViews\PlotBindingView.xamlViews\PlotBindingView.xaml.csScottPlot.WPF 5.1.58
项目中只通过 NuGet 引用 ScottPlot.WPF,不要同时手动添加其他版本的 ScottPlot DLL。
第一步操作
1 | public double[] GetXValues() |
X 数组是采样序号,Y 数组根据当前指标在温度和振动之间切换。
两个数组必须等长。否则 Add.Scatter 无法形成一一对应的坐标点。
第二步操作
先添加命名空间:
1 | xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" |
再放置控件:
1 | <Border Style="{StaticResource Card}" Padding="8"> |
这一步只负责给图表一个显示位置,不负责创建数据。
第三步操作
1 | private void RenderPlot() |
这段代码的执行顺序很重要:
- 清空旧图形模型。
- 设置中文字体。
- 设置标题和坐标轴文字。
- 创建新的散点折线系列。
- 设置 X 轴范围。
- 显示图例。
- 请求 WPF 控件重绘。
关键代码逐段解释
第一段是首次加载和属性变化:
1 | public PlotBindingView() |
ViewModel 不直接调用控件,但它通过 SeriesVersion 发布“有一版新数据源”的信号,View 收到后重绘。
第二段是发布新版本:
1 | public void PublishSeries() |
版本号是刷新信号,不是图表数据。它的作用只是让 View 知道数据源需要重新读取。
第三段是对象关系图:
图 8:WpfPlot、Plot 与 Scatter 的关系。当前项目使用 ScottPlot 5 的 Add.Scatter API。
运行后应该看到什么
打开“4 ScottPlot 绑定”页:
- 右侧显示一条温度趋势曲线。
- 左侧显示当前指标为“温度”。
- 数据源版本初始为 0。
- 点击“振动”,曲线切换为振动数据,Y 轴标题同步改变。
- 点击“发布数据源”,版本号增加,提示文本更新。
- 点击“追加采样”,图表多一个点,版本号增加。
图 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 | DispatcherTimer 到点 |
“不控制数据量会怎样?”
如果集合无限增长,每次刷新都要处理越来越多点,界面会越来越慢。本示例保留最多 60 条,并只显示最近 45 个 X 范围。
需要准备的文件、数据或环境
本章需要:
ViewModels\RealtimePlotViewModel.csViews\RealtimePlotView.xamlViews\RealtimePlotView.xaml.cs- 已完成的 ScottPlot 基础页面。
第一步操作
1 | _timer = new DispatcherTimer( |
使用 DispatcherTimer 是因为它会在 WPF 的 UI 线程上触发,更新集合和控件时不需要额外切回界面线程。
第二步操作
1 | public void AddSample() |
这段代码做了四件事:
- 用时间序号生成有波动但可重复观察的温度。
- 把采样交给 Service。
- 把同一个对象追加到集合。
- 删除最早的数据,保持集合窗口不超过 60 条。
第三步操作
1 | private void OnDataContextChanged( |
CollectionChanged 是集合层的通知。View 收到后不直接修改集合,而是重新读取数据并绘制。
关键代码逐段解释
第一段是数组投影:
1 | var points = _viewModel.Points.ToList(); |
这里每次渲染都会重新生成数组。这样写不追求极致的零分配,但逻辑直观,适合初学者观察“集合变化之后,图表拿到的是新数组”。
第二段是坐标轴窗口:
1 | if (xs.Length > 0) |
固定 Y 轴可以避免温度小幅波动时曲线视觉上乱跳;X 轴只显示最近一段,曲线不会越挤越密。
第三段是实时链路图:
图 11:实时更新链路。定时器只负责产生数据,绘图仍然走同一条“集合 → 数组 → Scatter”路径。
第四段是刷新时机:
图 12:刷新时机对照。判断标准是数据源或图形配置是否变化,而不是界面上是否出现了任意一次点击。
运行后应该看到什么
打开“5 实时更新”页:
- 初始显示历史温度曲线。
- 点击“开始”,状态变为“实时采集运行中 · 650 ms”。
- 右侧最新温度、振动和采样时间持续变化。
- 曲线尾部不断向右延伸。
- 样本数增加,但集合不会无限增长。
- 点击“停止”,数据不再追加。
- 点击“加一个”,可以手动追加一个采样。
- 点击“重置历史数据”,恢复初始 12 条数据。
图 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 |
自测覆盖:
- 项目分层与职责说明。
- 集合筛选与选中项。
- 集合命令新增与删除。
- ScottPlot 数据源数组。
- 实时采样追加与窗口截断。
自测结果文件:
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。
后续可以继续练习的方向
如果希望继续扩展,优先围绕本次目录做小幅改动:
- 给集合页增加按温度范围筛选。
- 给命令页增加“编辑当前行”命令。
- 给 ScottPlot 页增加第二条温度系列。
- 给实时页增加暂停/继续。
- 把内存 Service 换成 CSV 或数据库实现,但保持 ViewModel 接口不变。
这些扩展都建立在同一条数据流上,不需要推翻本笔记的结构。














