四轴运动控制系统 — 博客三:ViewModel + XAML 前后端交互

四轴运动控制系统 --- 博客三:ViewModel + XAML 前后端交互

本篇覆盖 RelayCommand、MainViewModel、MainWindow.xaml 连接面板,以完整的连接流程为例讲解 WPF MVVM 前后端交互。


一、整体数据流

复制代码
用户操作                  ViewModel                    Service              PLC
─────────              ─────────────              ────────────          ──────
点击 Connect 按钮 ──→  ConnectionCommand ──→  _service.Connect() ──→  建立连接
                      (RelayCommand)             (TCP/RTU)

按钮内容切换 ──→      IsConnected = true ──→  (属性通知) ──→ UI 更新
文本"Connected"
圆点变绿色

方向总结:用户操作 XAML → 绑定触发 ICommand → ViewModel 逻辑 → Service 通信 → 结果写回属性 → 属性通知 → UI 刷新。


二、RelayCommand.cs --- 按钮绑定桥接

csharp 复制代码
using System.Windows.Input;

namespace UpperMachine.ViewModels
{
    /// <summary>
    /// 简易 ICommand 实现
    /// 将按钮的点击事件桥接到 ViewModel 的方法
    /// 
    /// WPF 按钮的 Command 属性需要一个 ICommand 对象,
    /// 我们不能直接把方法传给 Command,所以需要这个包装类
    /// </summary>
    internal class RelayCommand : ICommand
    {
        /// <summary>存储要执行的方法(如 MainViewModel 的 Connect())</summary>
        private readonly Action _execute;

        /// <summary>
        /// 事件:当 CanExecute 的返回值变化时触发
        /// WPF 用它来决定按钮是否可用
        /// 我们所有按钮始终可用,所以不会触发此事件
        /// </summary>
#pragma warning disable CS0067  // 禁用"事件从未使用"的警告
        public event EventHandler? CanExecuteChanged;
#pragma warning restore CS0067

        /// <summary>
        /// 构造函数,传入要执行的方法
        /// </summary>
        /// <param name="execute">要执行的无参方法,如 Connect()</param>
        public RelayCommand(Action execute) => _execute = execute;

        /// <summary>
        /// 判断按钮是否可用
        /// 返回 true 表示按钮始终可点击
        /// </summary>
        public bool CanExecute(object? parameter) => true;

        /// <summary>
        /// 按钮点击时执行的方法
        /// </summary>
        public void Execute(object? parameter) => _execute();
    }
}

在 ViewModel 中的使用

csharp 复制代码
// 1. 定义属性
public ICommand ConnectionCommand { get; }

// 2. 构造函数中绑定到方法
public MainViewModel()
{
    ConnectionCommand = new RelayCommand(Connect); // Connect 是方法名
}

// 3. XAML 中绑定
<Button Command="{Binding ConnectionCommand}" />

new RelayCommand(Connect) 相当于 "当按钮被点击时,调用 Connect() 方法"。

对比事件处理(传统方式 vs MVVM)

csharp 复制代码
// ❌ 传统 WinForms 方式(紧耦合)
button.Click += Button_Click;
void Button_Click(object sender, EventArgs e) { ... }

// ✅ MVVM 方式(松耦合)
public ICommand Cmd { get; } = new RelayCommand(DoSomething);
<Button Command="{Binding Cmd}" />  // XAML 与 ViewModel 不直接引用

三、MainViewModel.cs --- 核心业务逻辑(逐行注释)

csharp 复制代码
using System;                       // 基础类型
using System.ComponentModel;        // INotifyPropertyChanged
using System.IO.Ports;              // Parity / StopBits 枚举
using System.Runtime.CompilerServices; // CallerMemberName
using System.Threading.Tasks;       // Task.Run(异步连接)
using System.Windows.Input;         // ICommand
using UpperMachine.Models;          // CommConfig, AxisData
using UpperMachine.Services;        // ModbusServiceBase, ModbusTcpService, ModbusRtuService

namespace UpperMachine.ViewModels
{
    /// <summary>
    /// 主 ViewModel:管理连接 + 4 轴数据
    /// 实现 INotifyPropertyChanged 让 UI 感知属性变化
    /// </summary>
    class MainViewModel : INotifyPropertyChanged
    {
        // ========== 私有字段 ==========

        /// <summary>当前选中的连接类型("TCP" / "RTU")</summary>
        private string _connectionType = "TCP";

        /// <summary>
        /// 当前活动的 Modbus 服务实例
        /// Connect() 时创建(TCP/RTU),Disconnect() 时释放
        /// 问号表示可能为 null(未连接时)
        /// </summary>
        private ModbusServiceBase? _service;

        /// <summary>IP:端口,默认值供测试使用</summary>
        private string _ipAddress = "192.168.0.1:502";

        /// <summary>是否已连接(默认 false)</summary>
        private bool _isConnected = false;

        /// <summary>串口号</summary>
        private string _comPort = "COM1";

        /// <summary>波特率</summary>
        private int _baudRate = 9600;

        /// <summary>数据位</summary>
        private int _dataBits = 8;

        /// <summary>校验位(枚举)</summary>
        private Parity _parity = Parity.None;

        /// <summary>停止位(枚举)</summary>
        private StopBits _stopBits = StopBits.One;

        // ========== 公开属性(UI 绑定用) ==========

        /// <summary>ComboBox 下拉选项:"TCP" 和 "RTU"</summary>
        public string[] ConnectionTypes { get; } = ["TCP", "RTU"];

        /// <summary>
        /// 连接类型属性
        /// 修改时同时通知三个属性变化:
        /// - ConnectionType(ComboBox 选中项)
        /// - IsTcpMode(TCP 输入框显隐)
        /// - IsRtuMode(RTU 输入框显隐)
        /// </summary>
        public string ConnectionType
        {
            get => _connectionType;
            set
            {
                _connectionType = value;
                OnPropertyChanged();
                OnPropertyChanged(nameof(IsTcpMode));  // 通知 TCP/RTU 面板切换可见性
                OnPropertyChanged(nameof(IsRtuMode));
            }
        }

        /// <summary>连接状态,UI 状态灯和文字绑定这个</summary>
        public bool IsConnected
        {
            get => _isConnected;
            set { _isConnected = value; OnPropertyChanged(); }
        }

        /// <summary>IP 地址输入框</summary>
        public string IpAddress
        {
            get => _ipAddress;
            set { _ipAddress = value; OnPropertyChanged(); }
        }

        /// <summary>COM 口输入框</summary>
        public string ComPort
        {
            get => _comPort;
            set { _comPort = value; OnPropertyChanged(); }
        }

        /// <summary>波特率输入框</summary>
        public int BaudRate
        {
            get => _baudRate;
            set { _baudRate = value; OnPropertyChanged(); }
        }

        /// <summary>数据位</summary>
        public int DataBits
        {
            get => _dataBits;
            set { _dataBits = value; OnPropertyChanged(); }
        }

        /// <summary>校验位</summary>
        public Parity Parity
        {
            get => _parity;
            set { _parity = value; OnPropertyChanged(); }
        }

        /// <summary>停止位</summary>
        public StopBits StopBits
        {
            get => _stopBits;
            set { _stopBits = value; OnPropertyChanged(); }
        }

        // ========== 计算属性(只读,用于 XAML 条件显隐) ==========

        /// <summary>当前是 TCP 模式吗?XAML 用 DataTrigger 判断此值</summary>
        public bool IsTcpMode => ConnectionType == "TCP";

        /// <summary>当前是 RTU 模式吗?</summary>
        public bool IsRtuMode => ConnectionType == "RTU";

        // ========== ICommand(按钮绑定) ==========

        /// <summary>Connect/DisConnect 按钮绑定的命令</summary>
        public ICommand ConnectionCommand { get; }

        // ========== 4 轴数据(供 HomePage 绑定) ==========

        /// <summary>轴 1 的状态 + 参数</summary>
        public AxisData Axis1Data { get; set; } = new AxisData();
        public AxisData Axis2Data { get; set; } = new AxisData();
        public AxisData Axis3Data { get; set; } = new AxisData();
        public AxisData Axis4Data { get; set; } = new AxisData();

        // ========== INotifyPropertyChanged(属性通知) ==========

        /// <summary>属性变更事件(WPF 绑定引擎监听此事件)</summary>
        public event PropertyChangedEventHandler? PropertyChanged;

        /// <summary>
        /// 触发属性变更通知
        /// [CallerMemberName] 编译器自动填入调用属性名
        /// 例:IpAddress setter 中调用 OnPropertyChanged(),
        ///      编译器自动填入 "IpAddress"
        /// </summary>
        protected void OnPropertyChanged([CallerMemberName] string? name = null)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

        // ========== 核心方法:Connect ==========

        /// <summary>
        /// 连接/断开切换方法
        /// async void 是"fire-and-forget"模式:
        /// - 方法签名是 void(匹配 Action 委托)
        /// - 内部可用 await(异步不阻塞 UI)
        /// - 异常会直接崩溃(所以要 try-catch)
        /// </summary>
        public async void Connect()
        {
            // ======== 情况 1:当前已连接 → 断开 ========
            if (IsConnected)
            {
                _service?.Disconnect();  // 关闭连接
                _service?.Dispose();     // 释放资源
                _service = null;         // 解除引用
                IsConnected = false;     // 更新 UI 状态
                return;                  // 断开后直接返回,不走下面的连接逻辑
            }

            // ======== 情况 2:当前未连接 → 建立连接 ========

            // 根据 ConnectionType 创建不同的服务实例
            if (ConnectionType == "TCP")
            {
                // ---- TCP 分支 ----
                var tcp = new ModbusTcpService();

                // 从 "192.168.0.1:502" 格式中分离 IP 和端口
                // Split(':') → ["192.168.0.1", "502"]
                tcp.Config.IpAddress = IpAddress.Split(':')[0];

                // ElementAtOrDefault(1):取端口号,如果没有端口则用默认 502
                int.TryParse(IpAddress.Split(':').ElementAtOrDefault(1) ?? "502", out var port);
                tcp.Config.TcpPort = port;

                _service = tcp;
            }
            else
            {
                // ---- RTU 分支 ----
                var rtu = new ModbusRtuService();

                // 从前端输入框读取参数,赋值给 Config
                rtu.Config.PortName = ComPort;     // 如 "COM1"
                rtu.Config.BaudRate = BaudRate;     // 如 9600
                rtu.Config.DataBits = DataBits;     // 如 8
                rtu.Config.Parity = Parity;         // Parity.None
                rtu.Config.StopBits = StopBits;     // StopBits.One

                _service = rtu;
            }

            // ======== 执行连接(异步!) ========
            try
            {
                // ---- Task.Run 的作用 ----
                // 将 _service.Connect() 扔到线程池执行,
                // UI 线程不会阻塞,界面保持流畅
                // 
                // await 的意思是:"等后台线程完成后再继续执行后续代码"
                // 后续代码(IsConnected = true)会自动回到 UI 线程执行
                IsConnected = await Task.Run(() => _service.Connect());
                // 如果连接成功:IsConnected = true → UI 灯变绿
                // 如果连接失败:IsConnected = false → UI 灯保持红
            }
            catch (Exception ex)
            {
                // 如果 Connect() 内部有未捕获的异常,在这里处理
                Console.WriteLine($"连接异常: {ex.Message}");
                IsConnected = false;
            }
        }

        // ========== 构造函数 ==========

        /// <summary>
        /// 构造函数:在 ViewModel 创建时执行一次
        /// 将 ConnectionCommand 绑定到 Connect() 方法
        /// </summary>
        public MainViewModel()
        {
            // new RelayCommand(Connect) → 按钮点击时执行 Connect()
            // 注意这里传的是方法名"Connect",不是调用"Connect()"
            ConnectionCommand = new RelayCommand(Connect);
        }
    }
}

异步连接的数据流

复制代码
UI 线程                             线程池线程
────────                         ──────────
Connect() 调用
    │
    ├── 创建服务实例(快,不卡)
    │
    ├── await Task.Run(() =>
    │   │                 ──────→  _service.Connect()
    │   │                           ├── new TcpClient()
    │   │                           ├── ConnectAsync().Wait(timeout)
    │   │                           ├── ModbusIpMaster.CreateIp()
    │   │                           └── return true/false
    │   │                 ←──────   
    │   └── 继续执行
    │       IsConnected = result
    │       │
    │       └── PropertyChanged
    │           → UI 更新状态灯
    │           → UI 更新按钮文字

关键await Task.Run(...) 让耗时连接操作在线程池执行,await 后面的代码自动回到 UI 线程。


四、MainWindow.xaml --- 连接面板(XAML 绑定详解)

以下只展示右侧连接配置面板部分(xaml 右侧 StackPanel),这是今天新增的配置面板。

xml 复制代码
<!--
  右侧 StackPanel 包含:
  1. ConnectionType ComboBox(选 TCP/RTU)
  2. TCP 参数:IP 地址输入框
  3. RTU 参数:COM 口 + 波特率 + 8N1
  4. Connect/DisConnect 按钮
  5. 状态灯 + 状态文字
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">

    <!-- ================================================================
         连接类型选择 ComboBox
         ItemsSource:下拉列表数据源 → MainViewModel.ConnectionTypes
         SelectedItem:当前选中项(双向绑定)→ MainViewModel.ConnectionType
         ItemContainerStyle:设置下拉列表的字体颜色(避免黑色文字)
         ================================================================ -->
    <ComboBox ItemsSource="{Binding ConnectionTypes}"
              SelectedItem="{Binding ConnectionType}"
              Width="90" Margin="0,0,8,0"
              VerticalAlignment="Center"
              FontSize="22"
              Background="Transparent"
              Foreground="{StaticResource TextBrush}"
              BorderBrush="{StaticResource BorderBrush}">
        <!-- 下拉项的样式:文字用主题色(白色),背景透明 -->
        <ComboBox.ItemContainerStyle>
            <Style TargetType="ComboBoxItem">
                <Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
                <Setter Property="Background" Value="Transparent"/>
            </Style>
        </ComboBox.ItemContainerStyle>
    </ComboBox>

    <!-- ================================================================
         TCP:IP 地址输入框
         通过 Style.DataTrigger 控制显隐:
         ConnectionType == "TCP" 时 Visible,否则 Collapsed
         UpdateSourceTrigger=PropertyChanged:每次按键立即更新绑定源
         ================================================================ -->
    <TextBox Text="{Binding IpAddress, UpdateSourceTrigger=PropertyChanged}"
             Width="200" Margin="0,0,8,0"
             VerticalAlignment="Center"
             VerticalContentAlignment="Center"
             FontSize="24"
             Background="Transparent"
             Foreground="{StaticResource LabelBrush}"
             BorderThickness="0,0,0,1"
             BorderBrush="{StaticResource BorderBrush}">
        <TextBox.Style>
            <Style TargetType="TextBox">
                <!-- 默认隐藏 -->
                <Setter Property="Visibility" Value="Collapsed"/>
                <Style.Triggers>
                    <!-- 当 ConnectionType == "TCP" 时显示 -->
                    <DataTrigger Binding="{Binding ConnectionType}" Value="TCP">
                        <Setter Property="Visibility" Value="Visible"/>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </TextBox.Style>
    </TextBox>

    <!-- ================================================================
         RTU 参数面板
         包含:COM 口输入框 / 分隔符 / 波特率输入框 / 8N1 标识
         整体显隐通过外层 StackPanel 的 Style.DataTrigger 控制
         ================================================================ -->
    <StackPanel Orientation="Horizontal" Margin="0,0,8,0">
        <!-- 整体显隐:ConnectionType == "RTU" 时显示 -->
        <StackPanel.Style>
            <Style TargetType="StackPanel">
                <Setter Property="Visibility" Value="Collapsed"/>
                <Style.Triggers>
                    <DataTrigger Binding="{Binding ConnectionType}" Value="RTU">
                        <Setter Property="Visibility" Value="Visible"/>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </StackPanel.Style>

        <!-- COM 口 -->
        <TextBox Text="{Binding ComPort, UpdateSourceTrigger=PropertyChanged}"
                 Width="80" Margin="0,0,4,0"
                 VerticalAlignment="Center"
                 VerticalContentAlignment="Center"
                 FontSize="24"
                 Background="Transparent"
                 Foreground="{StaticResource LabelBrush}"
                 BorderThickness="0,0,0,1"
                 BorderBrush="{StaticResource BorderBrush}"/>

        <!-- 分隔符 -->
        <TextBlock Text=" / " FontSize="24"
                   Foreground="{StaticResource LabelBrush}"
                   VerticalAlignment="Center" Margin="0,0,4,0"/>

        <!-- 波特率 -->
        <TextBox Text="{Binding BaudRate, UpdateSourceTrigger=PropertyChanged}"
                 Width="100" Margin="0,0,4,0"
                 VerticalAlignment="Center"
                 VerticalContentAlignment="Center"
                 FontSize="24"
                 Background="Transparent"
                 Foreground="{StaticResource LabelBrush}"
                 BorderThickness="0,0,0,1"
                 BorderBrush="{StaticResource BorderBrush}"/>

        <!-- 数据位/校验位/停止位标识(固定为 8N1,目前不可配置) -->
        <TextBlock Text=" 8N1" FontSize="20"
                   Foreground="{StaticResource LabelBrush}"
                   VerticalAlignment="Center"/>
    </StackPanel>

    <!-- ================================================================
         Connect/DisConnect 按钮
         Command:绑定到 MainViewModel.ConnectionCommand
         Style.DataTrigger 切换按钮文字:
           - IsConnected == false 时显示 "Connect"
           - IsConnected == true 时显示 "DisConnect"
         ================================================================ -->
    <Button Command="{Binding ConnectionCommand}"
            FontSize="20" Margin="0,0,8,0"
            VerticalAlignment="Center"
            Foreground="{StaticResource TextBrush}"
            Background="Transparent"
            BorderBrush="{StaticResource BorderBrush}"
            BorderThickness="1"
            Padding="12,4">
        <Button.Style>
            <Style TargetType="Button">
                <Setter Property="Content" Value="Connect"/>
                <Style.Triggers>
                    <DataTrigger Binding="{Binding IsConnected}" Value="True">
                        <Setter Property="Content" Value="DisConnect"/>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </Button.Style>
    </Button>

    <!-- ================================================================
         状态灯
         Style.DataTrigger:
           - 默认红色(未连接)
           - IsConnected == true 时绿色
         ================================================================ -->
    <Ellipse Width="12" Height="12" Margin="0,0,6,0" VerticalAlignment="Center">
        <Ellipse.Style>
            <Style TargetType="Ellipse">
                <Setter Property="Fill" Value="Red"/>
                <Style.Triggers>
                    <DataTrigger Binding="{Binding IsConnected}" Value="True">
                        <Setter Property="Fill" Value="{StaticResource GreenBrush}"/>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </Ellipse.Style>
    </Ellipse>

    <!-- ================================================================
         状态文字
         Style.DataTrigger:
           - 默认显示 "DisConnect"
           - IsConnected == true 时显示 "Connected"
         ================================================================ -->
    <TextBlock VerticalAlignment="Center" FontSize="24"
               Foreground="{StaticResource TextBrush}">
        <TextBlock.Style>
            <Style TargetType="TextBlock">
                <Setter Property="Text" Value="DisConnect"/>
                <Style.Triggers>
                    <DataTrigger Binding="{Binding IsConnected}" Value="True">
                        <Setter Property="Text" Value="Connected"/>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </TextBlock.Style>
    </TextBlock>

</StackPanel>

五、MainWindow.xaml.cs --- 启动入口

csharp 复制代码
namespace UpperMachine
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            // 创建 ViewModel 实例
            var vm = new ViewModels.MainViewModel();

            // 将 Window 的 DataContext 设为 ViewModel
            // 所有没有显式指定 DataContext 的控件会继承这个
            DataContext = vm;

            // 创建 HomePage 页面
            var homePage = new HomePage();

            // 重要:Frame 不会自动继承 Window 的 DataContext
            // 必须手动传递
            homePage.DataContext = vm;

            // 导航到 HomePage
            MainFrame.Navigate(homePage);
        }
    }
}

六、完整交互流程(串联全部知识)

TCP 连接场景

复制代码
1. 用户在 ComboBox 选择 "TCP"
   → set ConnectionType = "TCP"
   → OnPropertyChanged("ConnectionType")
   → OnPropertyChanged("IsTcpMode") → TCP 输入框 Visible

2. 用户在 TextBox 输入 "192.168.0.1:502"

3. 用户点击 "Connect" 按钮
   → 按钮的 Command 触发
   → ICommand.Execute()
   → RelayCommand.Execute()
   → MainViewModel.Connect()

4. Connect() 内部:
   a. IsConnected == false → 执行连接逻辑
   b. ConnectionType == "TCP" → 创建 ModbusTcpService
   c. 解析 IP:Port → 赋值给 Config
   d. await Task.Run(() => service.Connect())
      → 线程池执行 TCP 连接
      → TcpClient.ConnectAsync (超时 1000ms)
      → ModbusIpMaster.CreateIp
      → 返回 true/false
   e. IsConnected = true → UI 自动更新

5. UI 更新(全自动,无需额外代码):
   - 按钮文字 → "DisConnect"
   - 状态灯 → 绿色
   - 状态文字 → "Connected"

RTU 连接场景

复制代码
1. 用户在 ComboBox 选择 "RTU"
   → TCP 输入框隐藏
   → RTU 面板显示

2. 用户输入 COM 口 "COM1" 和波特率 "9600"

3. 用户点击 "Connect"
   → Connect() 检测 ConnectionType == "RTU"
   → 创建 ModbusRtuService
   → 赋值 ComPort/BaudRate/DataBits/Parity/StopBits
   → await Task.Run(() => service.Connect())
   → SerialPort.Open()
   → ModbusSerialMaster.CreateRtu
   → IsConnected = true/false

七、知识点对照表

概念 所在文件 说明
INotifyPropertyChanged AxisParam.cs, MainViewModel.cs 属性变化时通知 WPF 引擎刷新 UI
ICommand RelayCommand.cs, MainViewModel.cs 将按钮点击转换为方法调用
async/await MainViewModel.cs 异步编程,不阻塞 UI 线程
Task.Run MainViewModel.cs 把耗时操作放到线程池
Bindings MainWindow.xaml {Binding PropertyName} 声明式绑定
DataTrigger MainWindow.xaml 根据属性值切换 UI 状态
Style MainWindow.xaml 一组 Setter + Trigger,集中管理控件样式
StaticResource MainWindow.xaml 引用 App.xaml 中定义的颜色画笔
UpdateSourceTrigger MainWindow.xaml 控制输入框何时更新 ViewModel
CallerMemberName AxisParam.cs, MainViewModel.cs 编译器自动传入属性名,省去手动写字符串

三篇系列到此结束。下一篇预告(后续开发):

  • 博客四:定时轮询 PLC + 数据解析 + 界面更新
  • 博客五:轴控制命令(启动/停止/回零)按钮实现