简介:《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 SetProperty<T>(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}" Width="200" Height="30"/>这个方案有几个细节要注意。一是扫码枪输入的字符可能不是一次全部到达,尤其是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线程的处理能力,消息队列就会积压,界面自然越来越卡。
先说结论:数据采集快和界面画得快是两码事。业务数据必须有一层缓冲,界面的刷新频率应该远低于数据采集频率。一个常见的合理方案是:采集线程只管把数据放进一个线程安全的队列或Channel;UI线程上挂一个定时器,每50到100毫秒批量取一次数据,一口气更新图表和标签。这样即使设备端每秒上报1000条数据,UI线程也只刷新10次,压力完全可控。
3.2 批量刷新方案:队列加定时器是标准答案
我给出一个非常典型的实现思路。后台采集端用System.Threading.Channels,它比BlockingCollection更现代,也支持异步读写。UI刷新端用DispatcherTimer,60毫秒一次批量取出所有待显示数据,更新到ObservableCollection里,同时更新最新值。
// 采集线程(模拟数据) private readonly Channel<double> _dataChannel = Channel.CreateUnbounded<double>(); 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}" ZoomMode="X"> <lvc:CartesianChart.XAxes> <lvc:Axis Title="时间" Labeler="{Binding IndexLabeler}"/> </lvc:CartesianChart.XAxes> <lvc:CartesianChart.YAxes> <lvc:Axis Title="数值" MinLimit="0" MaxLimit="100"/> </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 EventHandler<string>? MessageReceived; }这里要特别强调:连接一次后必须有一个独立任务去持续接收数据,不能每收到一条数据就创建一次连接。ReceiveLoopAsync里用ReadAsync,线程不会阻塞,也省了ManualResetEvent这类同步原语。断线重连的话,可以在异常捕获里做指数退避重连,比如第一次等1秒,第二次等2秒,最多等10秒,防止设备还没恢复就拼命重连把CPU打满。
4.2 定时任务和时间刷新:选对Timer很关键
“wpf 定时任务”和“c# 更新本地时间”这类需求,核心其实是选对Timer。WPF里有三种常见Timer:DispatcherTimer、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线程定时器里批量更新 |
| 界面卡顿甚至无响应 | 采集线程高频Invoke,UI消息积压 | 引入线程安全队列,在UI线程批量取数据,限制刷新频率 |
| TextBox输入后焦点自动跳到下一个控件 | 扫码枪回车触发默认行为 | 在KeyDown事件里设置e.Handled = true,或把AcceptsReturn设为False |
| C#截取字符串出现乱码或索引越界 | 中英文字符长度理解错误 | 使用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分层、数据缓冲、异步通讯这三件事练熟,后面接真实设备、做多窗口协同都会顺很多。
本文还有配套的精品资源,点击获取