WPF 四轴上机位开发笔记:限值参数、JSON 持久化、XAML 绑定与校验
基于 .NET 10 WPF / MVVM / NModbus4 的四轴运动控制项目
一、今日目标
- 为 4 个轴添加速度/加速度/减速度/力矩的上下限配置
- 限值参数持久化到 JSON 文件,重启后自动加载
- 在写入 PLC 前进行限值校验,确保参数不越界
- 修复 XAML 绑定大小写不匹配导致的静默失败
二、AxisParam 模型:限值属性定义
在Models\AxisParam.cs中新增 8 个限值属性(全部为float非空类型,区别于运动参数的float?):
// ============== 参数的上限下限 ============privatefloat_velUpLimit;privatefloat_velLowerLimit;privatefloat_accelUpLimit;privatefloat_accelLowerLimit;privatefloat_decelUpLimit;privatefloat_decelLowerLimit;privatefloat_torqueUpLimit;privatefloat_torqueLowerLimit;// 公开属性(XAML 绑定目标)publicfloatVelUpLimit{get=>_velUpLimit;set{_velUpLimit=value;OnPropertyChanged();}}publicfloatVelLowerLimit{get=>_velLowerLimit;set{_velLowerLimit=value;OnPropertyChanged();}}publicfloatAccelUpLimit{get=>_accelUpLimit;set{_accelUpLimit=value;OnPropertyChanged();}}publicfloatAccelLowerLimit{get=>_accelLowerLimit;set{_accelLowerLimit=value;OnPropertyChanged();}}publicfloatDecelUpLimit{get=>_decelUpLimit;set{_decelUpLimit=value;OnPropertyChanged();}}publicfloatDecelLowerLimit{get=>_decelLowerLimit;set{_decelLowerLimit=value;OnPropertyChanged();}}publicfloatTorqueUpLimit{get=>_torqueUpLimit;set{_torqueUpLimit=value;OnPropertyChanged();}}publicfloatTorqueLowerLimit{get=>_torqueLowerLimit;set{_torqueLowerLimit=value;OnPropertyChanged();}}语法要点
| 语法 | 说明 |
|---|---|
floatvsfloat? | 限值用float(默认 0,永远有值);运动参数用float?(用户可选填) |
OnPropertyChanged() | 依赖[CallerMemberName]编译器自动填入属性名,无需写字符串 |
{ get; set; }完整写法 | 因为需要后接OnPropertyChanged通知,必须写完整属性体 |
三、JSON 序列化与反序列化(关键!)
3.1 静态文件路径
在MainViewModel类顶部定义统一的路径常量,确保读写始终指向同一个文件:
privatestaticreadonlystringLimitConfigPath=Path.Combine(AppDomain.CurrentDomain.BaseDirectory,"LimitConfig.json");为什么必须用AppDomain.CurrentDomain.BaseDirectory?
- VS 调试时当前工作目录是项目根目录,双击 exe 运行时是 exe 所在目录
BaseDirectory永远返回 exe 所在目录(bin\Debug\net10.0-windows\),保证行为一致
3.2 SaveLimitsToJson — 序列化
使用匿名类型 +System.Text.Json将 4 个轴的限值写入 JSON:
privatevoidSaveLimitsToJson(){vardata=new{Axis1=new{VelUp=Axis1Data.Data.VelUpLimit,VelLow=Axis1Data.Data.VelLowerLimit,AccelUp=Axis1Data.Data.AccelUpLimit,AccelLow=Axis1Data.Data.AccelLowerLimit,DecelUp=Axis1Data.Data.DecelUpLimit,DecelLow=Axis1Data.Data.DecelLowerLimit,TorqueUp=Axis1Data.Data.TorqueUpLimit,TorqueLow=Axis1Data.Data.TorqueLowerLimit,},// Axis2~Axis4 同上...};varjson=System.Text.Json.JsonSerializer.Serialize(data,newSystem.Text.Json.JsonSerializerOptions{WriteIndented=true});File.WriteAllText(LimitConfigPath,json);}生成的 JSON 格式:
{"Axis1":{"VelUp":1000,"VelLow":100,"AccelUp":500,"AccelLow":50,"DecelUp":500,"DecelLow":50,"TorqueUp":100,"TorqueLow":10},...}语法要点:
- 匿名类型
new { Axis1 = new { ... } }— 不需要定义专门的 DTO 类 File.WriteAllText— 覆盖写入,不是追加。每次保存都是完整写出全部 4 轴数据WriteIndented = true— 格式化 JSON,方便人工查看
3.3 LoadLimitFromJson — 反序列化
由于保存时用了匿名类型,反序列化时无法直接用泛型方法,需要用JsonDocument手动解析:
privatevoidLoadLimitFromJson(){if(!File.Exists(LimitConfigPath))return;// 首次运行没有文件,直接跳过varjson=File.ReadAllText(LimitConfigPath);usingvardoc=System.Text.Json.JsonDocument.Parse(json);varroot=doc.RootElement;varaxes=new[]{Axis1Data,Axis2Data,Axis3Data,Axis4Data};string[]axisNames={"Axis1","Axis2","Axis3","Axis4"};for(inti=0;i<4;i++){varel=root.GetProperty(axisNames[i]);axes[i].Data.VelUpLimit=el.GetProperty("VelUp").GetSingle();axes[i].Data.VelLowerLimit=el.GetProperty("VelLow").GetSingle();axes[i].Data.AccelUpLimit=el.GetProperty("AccelUp").GetSingle();axes[i].Data.AccelLowerLimit=el.GetProperty("AccelLow").GetSingle();axes[i].Data.DecelUpLimit=el.GetProperty("DecelUp").GetSingle();axes[i].Data.DecelLowerLimit=el.GetProperty("DecelLow").GetSingle();axes[i].Data.TorqueUpLimit=el.GetProperty("TorqueUp").GetSingle();axes[i].Data.TorqueLowerLimit=el.GetProperty("TorqueLow").GetSingle();}}语法要点:
| 语法 | 说明 |
|---|---|
JsonDocument.Parse(json) | 解析 JSON 字符串为可查询的文档对象 |
using var doc | 确保JsonDocument使用完后释放非托管内存 |
root.GetProperty("Axis1") | 获取 JSON 对象中的指定属性 |
el.GetProperty("VelUp").GetSingle() | 获取属性值并转换为float |
if (!File.Exists(...)) return; | 首次运行没有 JSON 文件时优雅退出 |
3.4 加载时机
在MainViewModel构造函数中调用一次,保证程序启动时限值恢复到上次保存的值:
publicMainViewModel(){LoadLimitFromJson();// ← 启动时加载ConnectionCommand=newRelayCommand(Connect);// ... 其他命令绑定}在ExecuteLimitParam(进入限值设置页面前)再调一次,保证看到最新数据:
privatevoidExecuteLimitParam(){LoadLimitFromJson();// ← 进页面前重新加载(防外部修改)varpage=newLimitAxesPage();page.DataContext=this;NavigateToPage?.Invoke(page);}四、XAML 绑定大坑:大小写敏感
问题描述
XAML 中写的是:
<TextBoxText="{Binding Axis1Data.Data.accelUpLimit}"/>但 C# 属性定义是:
publicfloatAccelUpLimit{...}// 大写 AWPF 绑定是大小写敏感的!绑定失败时没有报错,不会编译失败,只在输出窗口有警告。TextBox 的值永远是 0,导致限值校验永远认为「下限为 0」而报错。
修复
XAML 所有绑定路径必须与 C# 属性名完全一致:
| 错误(小写 a) | 正确(大写 A) |
|---|---|
Data.accelUpLimit | Data.AccelUpLimit |
Data.accelLowerLimit | Data.AccelLowerLimit |
共 4 个轴 × 2 个属性 = 8 处。
教训
- C# 属性命名建议统一大写开头(PascalCase)
- XAML 绑定写完后可以用 Snoop / 输出窗口检查 Binding 是否成功
- 或者先用
FallbackValue测试绑定链是否连通
五、限值校验逻辑
5.1 ExecuteAxisLimit — 保存前的验证
在 LimitAxesPage 点击 Confirm 按钮时调用:
privatevoidExecuteAxisLimit(intindex){if(_service==null||!IsConnected)return;varaxes=new[]{Axis1Data,Axis2Data,Axis3Data,Axis4Data};vardata=axes[index].Data;varcheck=new(floatup,floatlow,stringname)[]{(data.VelUpLimit,data.VelLowerLimit,"VelLimit"),(data.AccelUpLimit,data.AccelLowerLimit,"AccelLimit"),(data.DecelUpLimit,data.DecelLowerLimit,"DecelLimit"),(data.TorqueUpLimit,data.TorqueLowerLimit,"TorqueLimit"),};foreach(var(up,low,name)incheck){if(low<=0||up<=low){System.Windows.MessageBox.Show($"轴{index+1}的{name}限值无效(下限>0,上限>下限)");return;}}SaveLimitsToJson();System.Windows.MessageBox.Show($"轴{index+1}限值已保存");}语法要点:
(float up, float low, string name)[]— C# 7.0+ 值元组数组,比定义类更轻量foreach (var (up, low, name) in check)— 元组解构,直接取元组元素- 验证条件
low <= 0 || up <= low— 下限必须 > 0,上限必须 > 下限
5.2 ValidateAxisParam — 写入前的参数值校验
在写入 PLC 前(Apply / Confirm)验证运动参数是否超出限值:
publicboolValidateAxisParam(intindex,outstringmsg,stringactionMode){msg="";varaxes=new[]{Axis1Data,Axis2Data,Axis3Data,Axis4Data};vardata=axes[index].Data;// 根据模式选择要检查的字段组(float?v,stringn)[]fields;if(actionMode=="Rel")fields=new(float?v,stringn)[]{(data.RelPos,"RelPos"),(data.RelVel,"RelVel"),(data.RelAccel,"RelAccel"),(data.RelDecel,"RelDecel")};elseif(actionMode=="Abso")fields=new(float?v,stringn)[]{(data.AbsoPos,"AbsoPos"),(data.AbsoVel,"AbsoVel"),(data.AbsoAccel,"AbsoAccel"),(data.AbsoDecel,"AbsoDecel")};else{msg="未知模式";returnfalse;}foreach(var(v,n)infields){// 第一步:检查空值和零值if(v==null||v==0f){msg=$"Axis{index+1}的{n}无效(为空或0)";returnfalse;}// 第二步:根据字段名匹配对应的限值if(n.Contains("Vel")){if(v<data.VelLowerLimit||v>data.VelUpLimit){msg=$"Axis{index+1}的{n}超出速度限值({data.VelLowerLimit}~{data.VelUpLimit})";returnfalse;}}elseif(n.Contains("Accel")){if(v<data.AccelLowerLimit||v>data.AccelUpLimit){msg=$"Axis{index+1}的{n}超出加速度限值({data.AccelLowerLimit}~{data.AccelUpLimit})";returnfalse;}}elseif(n.Contains("Decel")){if(v<data.DecelLowerLimit||v>data.DecelUpLimit){msg=$"Axis{index+1}的{n}超出减速度限值({data.DecelLowerLimit}~{data.DecelUpLimit})";returnfalse;}}// RelPos / AbsoPos 没有对应的限值,跳过}returntrue;}语法要点:
| 语法 | 说明 |
|---|---|
out string msg | 输出参数,方法内部赋值,调用方直接获取错误信息 |
(float? v, string n) | 值元组,同时携带值和名称,方便错误消息拼接 |
n.Contains("Vel") | 用字段名模糊匹配来确定对应限值,同时覆盖 RelVel 和 AbsoVel |
v < data.VelLowerLimit | 编译时float?与float可隐式比较,但赋值给float时必须用.Value |
5.3 校验流程图
用户输入值 → 点击按钮 ↓ ValidateAxisParam 检查 null / 0 ↓ (通过) 检查字段名是否包含 "Vel"/"Accel"/"Decel" ↓ 获取对应的 upLimit / lowerLimit ↓ v < lowerLimit 或 v > upLimit ? ├─ 是 → 弹窗报错,不写入 └─ 否 → 写入 PLC六、今日踩坑总结
坑 1:XAML 绑定大小写
- WPF 绑定路径区分大小写(
accelUpLimit≠AccelUpLimit) - 绑定失败不抛异常,只在 VS 输出窗口有 BindingWarning
- 解决方案:写绑定前确认 C# 属性名,或先用 FallbackValue 测试
坑 2:JSON 读写路径不一致
- 保存用
LimitConfigPath(指向 exe 目录),加载用"LimitConfig.json"(指向工作目录) - VS F5 调试时工作目录 ≠ exe 目录,导致保存和加载去了不同位置
- 解决方案:统一使用
AppDomain.CurrentDomain.BaseDirectory拼接路径
坑 3:限值默认值为 0
float类型默认值为 0- 如果用户没设置限值就去 Apply/Confirm,验证
v > 0永远不成立 - 建议:限值校验只在限值 > 0 时才生效,或引导用户先配置限值
坑 4:ValidateAxisParam验证限值的前提
- 限值必须已经由用户设置并通过
ExecuteAxisLimit保存 LoadLimitFromJson必须在构造函数调用,保证AccelUpLimit等不是 0- 如果限值文件不存在,所有限值 = 0,Vel/Accel/Decel 的非零值都会报超限
七、相关文件路径
| 文件 | 说明 |
|---|---|
Models\AxisParam.cs | 限值属性定义(AccelUpLimit等 8 个) |
ViewModels\MainViewModel.cs | 序列化/反序列化/校验逻辑 |
View\LimitAxesPage.xaml | 限值编辑页面(8 个 TextBox × 4 轴) |
View\AxisParamSettingsPage.xaml | 绝对参数设置页面 |
View\ManualAdjustPage.xaml | 手动参数设置页面 |
Services\ModbusServiceBase.cs | Modbus 读写服务 |
Helpers\ModbusHelper.cs | 浮点数大端转换 |
bin\Debug\net10.0-windows\LimitConfig.json | 限值持久化文件 |
八、完整调用链路
启动 App └→ MainViewModel 构造函数 ├→ LoadLimitFromJson() ← 从磁盘恢复限值 └→ 绑定所有 RelayCommand 用户点击 "Limit Axes Param" 按钮 └→ ExecuteLimitParam() ├→ LoadLimitFromJson() ← 刷新限值 └→ NavigateToPage(LimitAxesPage) ← 跳转编辑页 用户设置 VelUpLimit=1000, VelLowerLimit=100 ... 用户点击 "Axis1 Confirm" └→ ExecuteAxisLimit(0) ├→ 校验 low>0 && up>low ├→ SaveLimitsToJson() ← 序列化到文件 └→ MessageBox("已保存") 用户回到主页面,点击 "轴参数设置" → 输入 AbsoVel=500 用户点击 "Apply Settings" └→ ExecuteSettingAbso() ├→ ValidateAxisParam(i, "Abso") │ ├→ 检查 null/0 │ ├→ "AbsoVel".Contains("Vel") → 检查 500 > VelLowerLimit=100 && < VelUpLimit=1000 ✓ │ └→ 通过 └→ WriteMultipleRegisters() ← 写入 PLC九、性能与注意事项
- 限值只用在上位机— 限值不会写入 PLC,只用于上位机前端校验(防止用户误操作)
- JSON 文件很小— 4 轴 × 8 个 float ≈ 128 字节,读写无性能问题
using var doc— 尽早释放JsonDocument占用的内存(每次 Load 都要创建新实例)- 不要混淆 Rel/Abso 模式—
ValidateAxisParam的actionMode参数决定校验哪些字段 - 写入前停轮询— 所有导航方法都调
_pollingTimer?.Stop(),防止轮询覆盖用户输入的参数