WPF 四轴上机位开发笔记:限值参数、JSON 持久化、XAML 绑定与校验

WPF 四轴上机位开发笔记:限值参数、JSON 持久化、XAML 绑定与校验

基于 .NET 10 WPF / MVVM / NModbus4 的四轴运动控制项目


一、今日目标

  1. 为 4 个轴添加速度/加速度/减速度/力矩的上下限配置
  2. 限值参数持久化到 JSON 文件,重启后自动加载
  3. 在写入 PLC 前进行限值校验,确保参数不越界
  4. 修复 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 绑定路径区分大小写(accelUpLimitAccelUpLimit
  • 绑定失败不抛异常,只在 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

九、性能与注意事项

  1. 限值只用在上位机 --- 限值不会写入 PLC,只用于上位机前端校验(防止用户误操作)
  2. JSON 文件很小 --- 4 轴 × 8 个 float ≈ 128 字节,读写无性能问题
  3. using var doc --- 尽早释放 JsonDocument 占用的内存(每次 Load 都要创建新实例)
  4. 不要混淆 Rel/Abso 模式 --- ValidateAxisParamactionMode 参数决定校验哪些字段
  5. 写入前停轮询 --- 所有导航方法都调 _pollingTimer?.Stop(),防止轮询覆盖用户输入的参数