WPF 四轴上机位开发笔记:限值参数、JSON 持久化、XAML 绑定与校验
基于 .NET 10 WPF / MVVM / NModbus4 的四轴运动控制项目
一、今日目标
- 为 4 个轴添加速度/加速度/减速度/力矩的上下限配置
- 限值参数持久化到 JSON 文件,重启后自动加载
- 在写入 PLC 前进行限值校验,确保参数不越界
- 修复 XAML 绑定大小写不匹配导致的静默失败
二、AxisParam 模型:限值属性定义
在 Models\AxisParam.cs 中新增 8 个限值属性(全部为 float 非空类型,区别于运动参数的 float?):
csharp
// ============== 参数的上限下限 ============
private float _velUpLimit;
private float _velLowerLimit;
private float _accelUpLimit;
private float _accelLowerLimit;
private float _decelUpLimit;
private float _decelLowerLimit;
private float _torqueUpLimit;
private float _torqueLowerLimit;
// 公开属性(XAML 绑定目标)
public float VelUpLimit { get => _velUpLimit; set { _velUpLimit = value; OnPropertyChanged(); } }
public float VelLowerLimit { get => _velLowerLimit; set { _velLowerLimit = value; OnPropertyChanged(); } }
public float AccelUpLimit { get => _accelUpLimit; set { _accelUpLimit = value; OnPropertyChanged(); } }
public float AccelLowerLimit { get => _accelLowerLimit; set { _accelLowerLimit = value; OnPropertyChanged(); } }
public float DecelUpLimit { get => _decelUpLimit; set { _decelUpLimit = value; OnPropertyChanged(); } }
public float DecelLowerLimit { get => _decelLowerLimit; set { _decelLowerLimit = value; OnPropertyChanged(); } }
public float TorqueUpLimit { get => _torqueUpLimit; set { _torqueUpLimit = value; OnPropertyChanged(); } }
public float TorqueLowerLimit { get => _torqueLowerLimit; set { _torqueLowerLimit = value; OnPropertyChanged(); } }
语法要点
| 语法 | 说明 |
|---|---|
float vs float? |
限值用 float(默认 0,永远有值);运动参数用 float?(用户可选填) |
OnPropertyChanged() |
依赖 [CallerMemberName] 编译器自动填入属性名,无需写字符串 |
{ get; set; } 完整写法 |
因为需要后接 OnPropertyChanged 通知,必须写完整属性体 |
三、JSON 序列化与反序列化(关键!)
3.1 静态文件路径
在 MainViewModel 类顶部定义统一的路径常量,确保读写始终指向同一个文件:
csharp
private static readonly string LimitConfigPath =
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:
csharp
private void SaveLimitsToJson()
{
var data = 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 同上...
};
var json = System.Text.Json.JsonSerializer.Serialize(data,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(LimitConfigPath, json);
}
生成的 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 手动解析:
csharp
private void LoadLimitFromJson()
{
if (!File.Exists(LimitConfigPath)) return; // 首次运行没有文件,直接跳过
var json = File.ReadAllText(LimitConfigPath);
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
var axes = new[] { Axis1Data, Axis2Data, Axis3Data, Axis4Data };
string[] axisNames = { "Axis1", "Axis2", "Axis3", "Axis4" };
for (int i = 0; i < 4; i++)
{
var el = 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 构造函数中调用一次,保证程序启动时限值恢复到上次保存的值:
csharp
public MainViewModel()
{
LoadLimitFromJson(); // ← 启动时加载
ConnectionCommand = new RelayCommand(Connect);
// ... 其他命令绑定
}
在 ExecuteLimitParam(进入限值设置页面前)再调一次,保证看到最新数据:
csharp
private void ExecuteLimitParam()
{
LoadLimitFromJson(); // ← 进页面前重新加载(防外部修改)
var page = new LimitAxesPage();
page.DataContext = this;
NavigateToPage?.Invoke(page);
}
四、XAML 绑定大坑:大小写敏感
问题描述
XAML 中写的是:
xml
<TextBox Text="{Binding Axis1Data.Data.accelUpLimit}" />
但 C# 属性定义是:
csharp
public float AccelUpLimit { ... } // 大写 A
WPF 绑定是大小写敏感的! 绑定失败时没有报错,不会编译失败,只在输出窗口有警告。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 按钮时调用:
csharp
private void ExecuteAxisLimit(int index)
{
if (_service == null || !IsConnected) return;
var axes = new[] { Axis1Data, Axis2Data, Axis3Data, Axis4Data };
var data = axes[index].Data;
var check = new (float up, float low, string name)[]
{
(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) in check)
{
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)验证运动参数是否超出限值:
csharp
public bool ValidateAxisParam(int index, out string msg, string actionMode)
{
msg = "";
var axes = new[] { Axis1Data, Axis2Data, Axis3Data, Axis4Data };
var data = axes[index].Data;
// 根据模式选择要检查的字段组
(float? v, string n)[] fields;
if (actionMode == "Rel")
fields = new (float? v, string n)[] {
(data.RelPos, "RelPos"), (data.RelVel, "RelVel"),
(data.RelAccel, "RelAccel"), (data.RelDecel, "RelDecel") };
else if (actionMode == "Abso")
fields = new (float? v, string n)[] {
(data.AbsoPos, "AbsoPos"), (data.AbsoVel, "AbsoVel"),
(data.AbsoAccel, "AbsoAccel"), (data.AbsoDecel, "AbsoDecel") };
else
{ msg = "未知模式"; return false; }
foreach (var (v, n) in fields)
{
// 第一步:检查空值和零值
if (v == null || v == 0f)
{ msg = $"Axis{index + 1}的{n}无效(为空或0)"; return false; }
// 第二步:根据字段名匹配对应的限值
if (n.Contains("Vel"))
{
if (v < data.VelLowerLimit || v > data.VelUpLimit)
{ msg = $"Axis{index + 1}的{n}超出速度限值({data.VelLowerLimit}~{data.VelUpLimit})"; return false; }
}
else if (n.Contains("Accel"))
{
if (v < data.AccelLowerLimit || v > data.AccelUpLimit)
{ msg = $"Axis{index + 1}的{n}超出加速度限值({data.AccelLowerLimit}~{data.AccelUpLimit})"; return false; }
}
else if (n.Contains("Decel"))
{
if (v < data.DecelLowerLimit || v > data.DecelUpLimit)
{ msg = $"Axis{index + 1}的{n}超出减速度限值({data.DecelLowerLimit}~{data.DecelUpLimit})"; return false; }
}
// RelPos / AbsoPos 没有对应的限值,跳过
}
return true;
}
语法要点:
| 语法 | 说明 |
|---|---|
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(),防止轮询覆盖用户输入的参数