四轴运动控制系统 --- 博客三: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 + 数据解析 + 界面更新
- 博客五:轴控制命令(启动/停止/回零)按钮实现