WPF C#上位机Demo实战:MVVM、实时曲线与扫码枪处理 简介《WPF专业编程指南》一书的配套C#演示代码集合面向刚接触WPF或希望系统掌握桌面应用开发的新手开发者。资源共746个文件压缩包约5.79MB以228个cs源码文件和76个xaml界面文件为核心同时包含38个sln解决方案、39个csproj项目文件以及exe、baml、resx、settings等辅助文件基本覆盖书中各章节的示例工程便于边读边练、对照运行。从文件构成可以清晰看出cs、xaml为源代码与界面布局sln、csproj负责工程组织与编译配置exe、baml为构建产物另有bmp、jpg、wmv等资源素材整体结构层次分明很适合逆向阅读和模仿练习。目前已有200人学习下载。书中作者站在实践者角度讲解WPF编程问题这份代码能帮助新人省去手敲示例的时间把注意力放在XAML布局、数据绑定、控件模板、路由事件等核心知识点上对刚读完前几章、想验证书中示例的读者尤其友好按章节目录打开对应解决方案即可运行查看遇到不理解的细节也能通过cs与xaml的对应关系快速定位入门阶段反复查阅价值很高。1. 先想清楚Demo要解决什么问题整体设计与技术选型“WPF c#DEMO”这组关键词我估计很多搜进来的朋友都和我前几年状态差不多手里有一台设备或者采集卡上位机界面还是WinForm拖控件甚至干脆在控制台里做实验然后被一句“做个漂亮点的界面”逼着开始搜WPF。我当初从WinForm转到WPF把数据采集、设备通信、界面联动这些事摸了一遍踩了不少坑所以这篇博文我想用一个贴近实际的上位机Demo把WPF C#开发里最关键也最容易被忽略的几个点串起来讲清楚包括MVVM怎么落地、实时数据怎么刷不卡、扫码枪怎么触发、图表和属性表格怎么接。适合正在入门WPF、想在工控上位机场景试水的朋友也适合准备用WPF重写老界面的朋友。1.1 为什么选WPF C#做上位机Demo先说结论在Windows桌面开发里WPF是目前做上位机界面最划算的选择没有之一。WinForm拖控件虽然快但做到后期你会发现界面一旦复杂起来事件满天飞按钮状态、数据刷新、控件可见性全都靠代码手动控制逻辑越堆越乱。WPF的核心优势是数据和界面分离界面用XAML描述运行时的状态变化通过绑定自动同步UI逻辑大大简化。C#的生态也很关键。工控圈子里常见的视觉软件、运动控制卡、串口/网口设备SDK大部分都提供C#接口。比如热词里有人问“海康相机软件VisionMaster与C#上位机软件通讯使用什么协议比较好”这种问题的前提就是你已经选了C#做上位机语言而WPF能把界面做得接近现代软件的质感同时不牺牲C#在硬件通讯上的便利性。所以整套组合的意义在于硬件交互有成熟的SDK可用界面层有WPF扛着开发效率和数据展现能力都能兼顾。1.2 技术栈选型的取舍Demo虽然小但技术选型还是要有点讲究。MVVM模式建议一上来就学即使Demo不用Prism这么重的框架也要保持View和ViewModel分层的习惯。Prism提供了模块化、导航、依赖注入和Region管理适合大型项目但对于一个演示性质的上位机Demo手写一个RelayCommand和ViewModel基类就够了等业务真的膨胀到需要插件化架构时再上Prism也不迟。我在自己Demo里没用完整Prism但文件夹结构和绑定方式都是照着Prism的思路来这样以后迁移也方便。实时曲线这块热词里出现了LiveCharts2和OxyPlot。我实测下来LiveCharts2更现代化动画平滑文档也比较全适合做仪表盘、趋势图OxyPlot更轻量稳定适合做离线分析和工业报表。Demo里我选LiveCharts2因为它的实时刷新性能在数据量不高的情况下表现很好。还有个PropertyGrid需求WPF没有自带PropertyGrid控件很多人的第一反应是去NuGet找个第三方库但Demo阶段我建议直接用DataGrid加反射自己列属性后面我会讲具体思路避免为了一个属性面板引入一堆依赖。1.3 Demo的功能边界明确了技术方向接下来定清楚Demo到底要做什么。我一直强调一个观点Demo的功能边界越清晰学习效果越好。这个Demo我设计了四个核心场景基本覆盖上位机软件的高频需求模拟数据采集并实时显示曲线、扫码枪输入触发设备查找、设备状态通过自定义模板和转换器呈现、日志记录与定时任务刷新时间。其中模拟数据采集是最重要的一环因为真实的PLC、传感器数据流本质上就是一个持续产生的数值序列用后台线程生成随机数模拟采集就能把“UI刷新卡顿”这个老大难问题完整复现出来并解决。扫码枪触发事件是很多现场工程师问得比较多的问题其实扫码枪大多是HID键盘设备本质上是“快速键盘输入”处理方式很特殊。标题虽然是“WPF c#DEMO”但把这些场景吃透了你等于拥有了一个上位机软件的半成品底座后面接真实设备时只需要替换数据源而已。2. MVVM落地从数据绑定到命令、转换器2.1 先写一个ViewModel基类后面所有页面都靠它MVVM的第一步不是建窗口而是写一个ViewModel基类。你别嫌基础我见过太多朋友一上来就写MainWindow.xaml.cs在Code-Behind里面定义一堆public变量结果数据一多就乱。正确的姿势是先建一个类实现INotifyPropertyChanged接口这样ViewModel里的属性一旦变化界面就能自动刷新。using System.ComponentModel; using System.Runtime.CompilerServices; namespace WpfDemo.ViewModels { public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected bool SetPropertyT(ref T storage, T value, [CallerMemberName] string? propertyName null) { if (Equals(storage, value)) return false; storage value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); return true; } protected void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } }这个基类里最核心的是SetProperty方法它先判断新旧值是否相等相等就不通知避免界面无意义刷新。属性名用CallerMemberName自动获取省得手写字符串也降低了写错属性名的概率。后面所有ViewModel都继承这个基类属性就按下面这样写private double _temperature; public double Temperature { get _temperature; set SetProperty(ref _temperature, value); }这套写法一旦习惯你会觉得WinForm里手动给TextBox赋值再刷新的方式实在太原始。数据绑定的本质是让界面“订阅”数据变化而不是靠你记得给每个控件赋值。2.2 扫码枪触发事件怎么处理从事件到命令的桥接很多人问“C#扫码枪触发事件”实际上WPF里并不能直接把硬件事件绑定到Command因为扫码枪在系统里被识别为键盘设备它扫描后做的事情就是快速输入一串字符然后通常以回车结尾。所以你的核心逻辑是在某个TextBox上监听键盘输入当检测到以回车结尾的连续输入时把这个字符串作为条码值触发业务处理。MVVM模式下我们不想在Code-Behind里写业务逻辑通常用两种方式。第一种是使用System.Windows.Interactivity的行为库在XAML里给TextBox挂一个KeyDown事件触发命令第二种是写一个附加属性用起来更轻量。我这里给出附加属性的思路public static class ScanBehavior { public static readonly DependencyProperty ScanCommandProperty DependencyProperty.RegisterAttached( ScanCommand, typeof(ICommand), typeof(ScanBehavior), new PropertyMetadata(null, OnScanCommandChanged)); public static void SetScanCommand(DependencyObject obj, ICommand value) obj.SetValue(ScanCommandProperty, value); public static ICommand GetScanCommand(DependencyObject obj) (ICommand)obj.GetValue(ScanCommandProperty); private static void OnScanCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is TextBox textBox) { textBox.KeyDown - TextBox_KeyDown; if (e.NewValue is ICommand) textBox.KeyDown TextBox_KeyDown; } } private static void TextBox_KeyDown(object sender, KeyEventArgs e) { if (e.Key Key.Enter) { var textBox (TextBox)sender; var command GetScanCommand(textBox); if (command?.CanExecute(textBox.Text) true) command.Execute(textBox.Text); e.Handled true; } } }XAML里这样用TextBox local:ScanBehavior.ScanCommand{Binding ScanCodeCommand} Width200 Height30/这个方案有几个细节要注意。一是扫码枪输入的字符可能不是一次全部到达尤其是USB接口老一点的设备偶尔会出现字符拆包严谨的做法是在ScanCodeCommand里做条码长度校验和超时拼接但Demo阶段先不过度设计。二是如果扫码枪回车后还会触发其他控件的默认行为记得把e.Handled设为true否则界面会莫名其妙跳焦点。2.3 转换器和自定义模板让界面不再“一眼假”热词里“wpf 转换器”和“wpf 自定义模板”都是高频搜索。转换器解决的是“数据如何显示”的问题比如设备温度超过80度要显示红色我们需要把double类型转换为Brush。自定义模板解决的是“控件长什么样”的问题比如树形控件的节点默认只有图标和文本你要做设备树、工艺菜单树就得自己定义TreeView的ItemTemplate。转换器非常简单无非是继承IValueConverter接口然后实现Convert和ConvertBack。ConvertBack在OneWay绑定时可以抛异常或者返回默认值不用太纠结。实际中我最常用的是状态转颜色和布尔转可见性布尔转可见性WPF内置了BooleanToVisibilityConverter直接用就行。状态转颜色的核心逻辑是在Converter里写switch判断返回不同画刷。public class StatusToBrushConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { string status value as string ?? Unknown; return status switch { Running Brushes.Green, Stopped Brushes.Red, Warning Brushes.Orange, _ Brushes.Gray }; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) throw new NotSupportedException(); }自定义模板里我重点说TreeView。上位机里经常要做一个设备树比如“产线1 - 工位1 - 温度传感器 / 压力传感器”这种层级结构天然适合TreeView。默认的TreeView只能显示字符串如果你想显示每个节点的状态颜色、是否在线就要用HierarchicalDataTemplate在模板里绑定子节点集合和状态属性。关键是ItemsSource属性它指定子节点从哪个属性获取。做完这一步树形控件在做界面设计时就会变成一把利刃而不是默认的那个简陋控件。3. 实时数据采集与UI刷新怎么刷都不卡3.1 卡顿根源UI线程没有你想的那么闲热词榜上有个经典问题“C#循环数据采集和UI刷新卡顿”。我刚转WPF那年也被这个问题折磨过现象非常典型后台线程每10毫秒读取一次数据然后更新界面上的Label结果界面先是越来越迟钝最后直接无响应。原因是WPF的UI操作必须发生在UI线程也就是Dispatcher线程上而后台采集线程每来一条数据就通过Dispatcher.Invoke把更新操作“塞”给UI线程。如果采集频率高于UI线程的处理能力消息队列就会积压界面自然越来越卡。先说结论数据采集快和界面画得快是两码事。业务数据必须有一层缓冲界面的刷新频率应该远低于数据采集频率。一个常见的合理方案是采集线程只管把数据放进一个线程安全的队列或ChannelUI线程上挂一个定时器每50到100毫秒批量取一次数据一口气更新图表和标签。这样即使设备端每秒上报1000条数据UI线程也只刷新10次压力完全可控。3.2 批量刷新方案队列加定时器是标准答案我给出一个非常典型的实现思路。后台采集端用System.Threading.Channels它比BlockingCollection更现代也支持异步读写。UI刷新端用DispatcherTimer60毫秒一次批量取出所有待显示数据更新到ObservableCollection里同时更新最新值。// 采集线程模拟数据 private readonly Channeldouble _dataChannel Channel.CreateUnboundeddouble(); private async Task SimulateSensorDataAsync(CancellationToken ct) { var rnd new Random(); while (!ct.IsCancellationRequested) { await _dataChannel.Writer.WriteAsync(rnd.NextDouble() * 100, ct); await Task.Delay(10, ct); } } // UI刷新定时器 private DispatcherTimer _refreshTimer; private void StartUiRefresh() { _refreshTimer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(60) }; _refreshTimer.Tick (s, e) { while (_dataChannel.Reader.TryRead(out double value)) { SensorData.Add(value); } if (SensorData.Count 1000) { for (int i 0; i SensorData.Count - 1000; i) SensorData.RemoveAt(0); } LatestValue SensorData.LastOrDefault(); }; _refreshTimer.Start(); }这个方案里有一个关键的细节ObservableCollection每Add一次都会触发界面更新所以需要控制集合大小。我的做法是设置一个最大点数比如1000超出后从头部剔除旧数据保证图表一直显示最近的一段时间。还有一个更优的做法是一次性New一个List再赋给ObservableCollection但那样会丢失增量动画对曲线图有一定影响。实测下来每秒刷新16次左右UI线程占用非常低曲线也足够平滑。3.3 LiveCharts2实时曲线和PropertyGrid的轻量实现图表这块用LiveCharts2写折线图比老版本LiveCharts简单不少。先在NuGet安装LiveChartsCore.SkiaSharpView.WPF然后在XAML里放CartesianChart在ViewModel里定义Series和Axis。lvc:CartesianChart Series{Binding Series} ZoomModeX lvc:CartesianChart.XAxes lvc:Axis Title时间 Labeler{Binding IndexLabeler}/ /lvc:CartesianChart.XAxes lvc:CartesianChart.YAxes lvc:Axis Title数值 MinLimit0 MaxLimit100/ /lvc:CartesianChart.YAxes /lvc:CartesianChart对应的ViewModel里Series要绑定一个ISeries数组实时刷新时直接操作LineSeries.Values这个集合本身是支持增删的。注意点在于LiveCharts2的坐标轴刻度是自动计算的高频更新时会频繁重绘所以上节说的定时批量刷新在图表中同样重要。PropertyGrid的轻量实现也不复杂。定义一个属性条目类包含PropertyName、PropertyValue、PropertyType然后用反射读取目标对象的公开属性并填充列表DataGrid的AutoGenerateColumns设置为true直接绑定这个列表就能得到一个“类属性面板”。如果后续需要分组、只读控制再额外加字段就行。这个方案的扩展性很好而且你能完全控制显示逻辑不会被第三方控件束缚。4. Demo的周边模块通信、定时任务、与旧系统协同4.1 封装一个异步TCP通信类别再开线程死循环热词里“c# socket”“websocket连接 wpf”“c# 工业级网口通讯助手”出现频率都很高。上位机免不了要和设备走TCP/IP我之前看到很多初学者用TcpClient.Receive加死循环UI线程被拖垮不说断线重连也很难写。正确做法是用async/await加NetworkStream.ReadAsync配合CancellationToken实现超时和优雅关闭。我贴一个核心逻辑public class TcpDeviceClient { private TcpClient? _client; private NetworkStream? _stream; private CancellationTokenSource? _cts; public async Task ConnectAsync(string ip, int port) { _client new TcpClient(); await _client.ConnectAsync(ip, port); _stream _client.GetStream(); _cts new CancellationTokenSource(); _ Task.Run(() ReceiveLoopAsync(_cts.Token)); } private async Task ReceiveLoopAsync(CancellationToken ct) { var buffer new byte[4096]; while (!ct.IsCancellationRequested) { int read await _stream!.ReadAsync(buffer, ct); if (read 0) { string message Encoding.UTF8.GetString(buffer, 0, read); MessageReceived?.Invoke(this, message); } } } public event EventHandlerstring? MessageReceived; }这里要特别强调连接一次后必须有一个独立任务去持续接收数据不能每收到一条数据就创建一次连接。ReceiveLoopAsync里用ReadAsync线程不会阻塞也省了ManualResetEvent这类同步原语。断线重连的话可以在异常捕获里做指数退避重连比如第一次等1秒第二次等2秒最多等10秒防止设备还没恢复就拼命重连把CPU打满。4.2 定时任务和时间刷新选对Timer很关键“wpf 定时任务”和“c# 更新本地时间”这类需求核心其实是选对Timer。WPF里有三种常见TimerDispatcherTimer、System.Timers.Timer、System.Threading.Timer很多人选错导致界面卡顿或者事件不触发。我的经验是如果定时任务只更新UI元素用DispatcherTimer因为它的Tick事件本身就在UI线程上执行不需要考虑跨线程访问控件的异常如果定时任务要做重活比如写数据库、调用设备接口用System.Timers.Timer但回调里如果需要更新UI再通过Dispatcher.Invoke转回UI线程。一个典型的“本地时间每秒更新”的写法private DispatcherTimer _clockTimer new DispatcherTimer { Interval TimeSpan.FromSeconds(1) }; // 构造函数里注册事件 _clockTimer.Tick (s, e) CurrentTime DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss); _clockTimer.Start();CurrentTime是ViewModel里的属性绑定到TextBlock上界面自动刷新。至于后台的轮询型任务我建议优先使用System.Timers.Timer并把AutoReset设为true同时在回调里做好异常捕获因为Timer回调里的异常一旦抛出会导致进程崩溃这个坑很隐蔽。4.3 WPF嵌套WinForm和.NET 8调用旧库的问题热词里有一条很特别的“.net 8.0 调用 winform .net framework 4.6库”。这是在项目升级时很常见的痛。如果你要把一个.NET Framework 4.6的WinForm控件或类库迁到WPF里可以用WindowsFormsHost嵌套WinForm控件但要注意空气空间问题WinForm控件会盖在WPF元素之上导致Z轴顺序错乱解决方案是尽量避免在同一区域混排WPF和WinForm内容要么全用WinForm容器要么只在独立区域放WinForm内容。如果你要在.NET 8项目里直接引用.NET Framework 4.6类库首先要区分类库程序集是否真的兼容。很多老库只用了基础类型通常能被兼容但用了AppDomian、Remoting之类老技术的库迁移就很困难。最稳妥的方案是给老库建一个.NETstandard2.0的中间层把需要的方法重新封装一遍再让新项目引用这个中间层。WPF项目还可以通过RuntimeIdentifier和UseWindowsForms设置来启用WinForm兼容但Demo阶段遇到编译报错先检查是否有无法解析的依赖比瞎改项目配置更高效。4.4 和视觉软件联动协议选择的一点经验入行时我也纠结过“海康VisionMaster和C#上位机用什么协议通信”这种问题。实际开发中优先看视觉软件是否提供SDK如果提供C#的SDK直接引用DLL是最省事的方案因为视觉软件的内部变量、图像数据、结果消息都能通过SDK拿到数据格式也最完整。如果对方只提供网络通讯接口那就优先走TCP协议自己定义一套简单的指令格式比如命令头命令长度JSON数据校验位。只有设备本身只支持Modbus时才考虑Modbus因为Modbus的数据吞吐量和灵活性相比TCP弱一些。5. 常见问题与避坑清单这个章节我整理一下在开发和现场调试中踩过、以及群里朋友反复问过的坑做成一个速查表方便直接对照排查。现象原因排查思路与解决方案后台线程更新界面抛异常跨线程访问UI元素使用Dispatcher.Invoke/InvokeAsync或直接在UI线程定时器里批量更新界面卡顿甚至无响应采集线程高频InvokeUI消息积压引入线程安全队列在UI线程批量取数据限制刷新频率TextBox输入后焦点自动跳到下一个控件扫码枪回车触发默认行为在KeyDown事件里设置e.Handled true或把AcceptsReturn设为FalseC#截取字符串出现乱码或索引越界中英文字符长度理解错误使用Substring前先确认字符位置最好用IndexOf定位分隔符再截取ObservableCollection频繁增删导致列表闪烁每次变动都触发CollectionChanged批量更新一次循环添加后在集合末尾触发一次Reset通知或用List临时承载再赋值TreeView自定义模板后子节点不显示没有设置HierarchicalDataTemplate的ItemsSource检查模板里的ItemsSource是否绑定子节点集合并正确设置绑定路径引用第三方UI库后样式被全局覆盖资源字典合并顺序和隐式样式冲突检查App.xaml里资源字典的顺序或使用x:Key避免隐式全局样式.NET 8项目引用.NET Framework库编译报错程序集目标框架和API不兼容处理方式见4.3优先做封装层解决依赖Timer回调中修改UI抛异常System.Timers.Timer回调在后台线程回调里通过Dispatcher转回UI线程或改用DispatcherTimer扫码枪偶发只扫描出部分字符USB设备拆包或输入法状态干扰在命令里做数量校验必要时用字符串末尾字符或超时判断一次扫描结束再补两个调试技巧。第一用Stopwatch给采集、序列化、界面刷新分别计时很多时候你一测就会发现卡顿不在界面而在某个设备的同步读取上。第二在Visual Studio的诊断工具里看CPU和内存占用能直观看到UI线程的负担。如果你发现某个方法一调用UI占用率就飙升优先去看是不是无意中做了同步等待或者大量字符串拼接改用StringBuilder往往立竿见影。最后说一句我从实际项目里练出来的体会WPF这套东西最难的不是某个控件会不会用而是习惯“数据驱动界面”的思维方式。刚开始你可能会觉得绑定拐弯抹角不如WinForm直接赋值方便但等你的程序里数据量涨上来、窗口多起来你就明白绑定的意义了。这个Demo做完之后我的建议是先别急着加各种花哨功能踏踏实实把MVVM分层、数据缓冲、异步通讯这三件事练熟后面接真实设备、做多窗口协同都会顺很多。本文还有配套的精品资源点击获取