四轴运动控制第一篇 - WPF MVVM 基础框架搭建
本文基于 .NET 10 + WPF + MVVM 架构,从零搭建一个四轴运动控制系统的 UI 外壳。包含项目结构、全局资源、ViewModel 绑定、DataTrigger 状态切换等核心知识点,逐行注释,适合 WPF 入门学习参考。
目录
- 一、项目背景与架构
- 二、项目结构总览
- [三、App.xaml - 全局资源定义](#三、App.xaml - 全局资源定义)
- [四、MainViewModel.cs - 数据驱动核心](#四、MainViewModel.cs - 数据驱动核心)
- [五、MainWindow.xaml.cs - 绑定 ViewModel](#五、MainWindow.xaml.cs - 绑定 ViewModel)
- [六、MainWindow.xaml - 界面布局详解](#六、MainWindow.xaml - 界面布局详解)
- [6.1 整体 Grid 三行布局](#6.1 整体 Grid 三行布局)
- [6.2 顶栏 - 左侧标题区域](#6.2 顶栏 - 左侧标题区域)
- [6.3 顶栏 - 右侧 IP + 连接状态](#6.3 顶栏 - 右侧 IP + 连接状态)
- [6.4 中间 Frame - 页面容器](#6.4 中间 Frame - 页面容器)
- [6.5 底栏 - 四轴状态显示](#6.5 底栏 - 四轴状态显示)
- 七、核心机制详解
- [7.1 DataContext - 绑定的「目标对象」](#7.1 DataContext - 绑定的「目标对象」)
- [7.2 Binding - 声明式数据连接](#7.2 Binding - 声明式数据连接)
- [7.3 INotifyPropertyChanged + CallerMemberName](#7.3 INotifyPropertyChanged + CallerMemberName)
- [7.4 DataTrigger - 条件触发覆盖](#7.4 DataTrigger - 条件触发覆盖)
- 八、启动与运行流程
- 九、总结与下一步
一、项目背景与架构
本项目的目标是开发一套四轴运动控制系统的上位机软件,采用 WPF + MVVM 设计模式,与 PLC 通过 Modbus TCP 协议通信,实现四个运动轴的实时监控与控制。
技术栈
| 技术 | 用途 |
|---|---|
| .NET 10 + WPF | 桌面 UI 框架 |
| MVVM 模式 | 界面与数据逻辑分离 |
| INotifyPropertyChanged | 数据变更通知 UI |
| DataTrigger / Binding | 声明式 UI 数据驱动 |
| Frame + Page 导航 | 多页面切换 |
设计目标
- 顶栏:显示 IP 地址 + 连接状态指示灯
- 中间区域:Frame 容器,用于加载不同的功能页面
- 底栏:显示四个轴当前的运行状态
二、项目结构总览
UpperMachine/
├── App.xaml # 全局资源(颜色画刷)
├── App.xaml.cs # 应用入口
├── MainWindow.xaml # 主窗口 UI 布局
├── MainWindow.xaml.cs # 主窗口绑定 ViewModel
├── ViewModels/
│ └── MainViewModel.cs # 数据模型(IP、连接状态、轴状态)
├── Models/ # 后续存放数据模型
├── View/ # 后续存放功能页面
└── UpperMachine.csproj # 项目文件
三、App.xaml - 全局资源定义
作用:定义整个应用程序中所有窗口和控件都可以引用的全局资源。在这里我们定义了 14 个颜色画刷,实现统一的暗色主题风格。
xml
<Application x:Class="UpperMachine.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- 背景色 - 深蓝黑 -->
<SolidColorBrush x:Key="BackgroundBrush" Color="#1B1B2F"/>
<!-- 面板色 - 深蓝 -->
<SolidColorBrush x:Key="PanelBrush" Color="#162447"/>
<!-- 卡片色 - 中蓝 -->
<SolidColorBrush x:Key="CardBrush" Color="#1F4068"/>
<!-- 顶栏/底栏色 - 最暗 -->
<SolidColorBrush x:Key="HeaderBrush" Color="#0F0F23"/>
<!-- 边框色 -->
<SolidColorBrush x:Key="BorderBrush" Color="#2D2D44"/>
<!-- 强调色 - 青色 -->
<SolidColorBrush x:Key="AccentBrush" Color="#00D2FF"/>
<!-- 主要文字色 - 浅灰 -->
<SolidColorBrush x:Key="TextBrush" Color="#E0E0E0"/>
<!-- 标签文字色 - 灰蓝 -->
<SolidColorBrush x:Key="LabelBrush" Color="#90A4AE"/>
<!-- 绿色 - 连接成功/运行中 -->
<SolidColorBrush x:Key="GreenBrush" Color="#00E676"/>
<!-- 红色 - 断开/报警 -->
<SolidColorBrush x:Key="RedBrush" Color="#FF1744"/>
<!-- 橙色 - 警告 -->
<SolidColorBrush x:Key="OrangeBrush" Color="#FF9100"/>
<!-- 黄色 - 注意 -->
<SolidColorBrush x:Key="YellowBrush" Color="#FFD740"/>
<!-- 蓝色 - 信息 -->
<SolidColorBrush x:Key="BlueBrush" Color="#448AFF"/>
<!-- 纯白 - 特殊强调 -->
<SolidColorBrush x:Key="WhiteBrush" Color="#FFFFFF"/>
</Application.Resources>
</Application>
重点理解
x:Key:每个资源都有一个唯一的键名,XAML 中通过{StaticResource 键名}引用。StaticResource:编译时解析的资源引用,资源必须在引用前定义。比DynamicResource性能更好。StartupUri="MainWindow.xaml":应用启动后自动打开 MainWindow。
xml
<!-- 在控件中使用:-->
Background="{StaticResource BackgroundBrush}"
Foreground="{StaticResource TextBrush}"
Fill="{StaticResource GreenBrush}"
四、MainViewModel.cs - 数据驱动核心
作用:作为 View 和 Model 之间的桥梁,负责存储 UI 需要显示的所有数据,并在数据变化时通知 WPF 刷新界面。
csharp
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace UpperMachine.ViewModels
{
// 实现 INotifyPropertyChanged 接口,让 WPF 能监听属性变化
class MainViewModel : INotifyPropertyChanged
{
// ===== 私有字段(实际存储数据的地方) =====
private string _ipAddress = "192.168.0.10:502"; // PLC IP 地址
private bool _isConnected = true; // 连接状态
private string _axis1Status = "Axis1 : StandBy"; // 轴1 状态
private string _axis2Status = "Axis2 : StandBy"; // 轴2 状态
private string _axis3Status = "Axis3 : StandBy"; // 轴3 状态
private string _axis4Status = "Axis4 : StandBy"; // 轴4 状态
// ===== 公有属性(XAML 绑定目标) =====
// getter 返回字段值,setter 更新字段 + 触发通知
public string IpAddress
{
get => _ipAddress;
set { _ipAddress = value; OnPropertyChanged(); }
}
public bool IsConnected
{
get => _isConnected;
set { _isConnected = value; OnPropertyChanged(); }
}
public string Axis1Status
{
get => _axis1Status;
set { _axis1Status = value; OnPropertyChanged(); }
}
public string Axis2Status
{
get => _axis2Status;
set { _axis2Status = value; OnPropertyChanged(); }
}
public string Axis3Status
{
get => _axis3Status;
set { _axis3Status = value; OnPropertyChanged(); }
}
public string Axis4Status
{
get => _axis4Status;
set { _axis4Status = value; OnPropertyChanged(); }
}
// ===== INotifyPropertyChanged 接口实现 =====
// 事件:WPF 内部会订阅这个事件
public event PropertyChangedEventHandler? PropertyChanged;
// 通知方法:属性值变化时调用,WPF 收到通知后刷新 UI
// [CallerMemberName] 自动填入调用者的属性名
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
核心设计思路
ViewModel 只负责数据,不关心 UI 长什么样。UI 只负责展示,不关心数据从哪来。
例如:
csharp
// 后续写 PLC 通信代码时,只需改属性值:
IsConnected = true; // → UI 自动变绿 + 显示 "Connected"
Axis1Status = "Axis1 : Running"; // → UI 自动更新轴状态文字
[CallerMemberName] 的作用
编译器自动将调用者的方法名作为参数传入。例如:
csharp
set { _ipAddress = value; OnPropertyChanged(); }
// 编译器自动翻译为:
set { _ipAddress = value; OnPropertyChanged("IpAddress"); }
好处:不改写死字符串,重构时不会出现拼写错误。
?.Invoke 的含义
csharp
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
?:如果PropertyChanged为 null(没有监听者),则不执行this:告诉 WPF 是当前 ViewModel 实例的属性变了PropertyChangedEventArgs(name):告诉 WPF 哪个属性变了
五、MainWindow.xaml.cs - 绑定 ViewModel
作用 :在 MainWindow 初始化时创建 MainViewModel 实例,并赋值给
DataContext。DataContext是 WPF 绑定的核心------所有子控件的{Binding}都从这个对象上查找属性。
csharp
using System.Windows;
namespace UpperMachine
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
// 创建 ViewModel 实例并设为 DataContext
// 从此,所有子控件的 {Binding} 都指向这个 MainViewModel 对象
DataContext = new ViewModels.MainViewModel();
}
}
}
为什么只有这一行代码就够了?
因为所有逻辑(属性定义、通知机制)都已经写在 MainViewModel.cs 里了。下面这个图清晰展示了数据流:
用户操作 / PLC 数据
↓
ViewModel.IsConnected = true
↓
OnPropertyChanged("IsConnected")
↓
WPF 收到通知 → DataTrigger 检测到 true
↓
Ellipse.Fill = GreenBrush (UI 自动更新)
TextBlock.Text = "Connected"(UI 自动更新)
六、MainWindow.xaml - 界面布局详解
6.1 整体 Grid 三行布局
xml
<Window x:Class="UpperMachine.MainWindow"
Title="四轴运动控制系统" Height="850" Width="1280"
MinHeight="700" MinWidth="1024"
WindowStartupLocation="CenterScreen"
Background="{StaticResource BackgroundBrush}"
FontFamily="Microsoft Himalaya">
<!-- 根 Grid,分成三行 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="60"/> <!-- 顶栏:固定 60px -->
<RowDefinition Height="*"/> <!-- 中间:自动撑满 -->
<RowDefinition Height="50"/> <!-- 底栏:固定 50px -->
</Grid.RowDefinitions>
<!-- 顶栏(Row 0)-->
<!-- 中间 Frame(Row 1)-->
<!-- 底栏(Row 2)-->
</Grid>
</Window>
| 行 | 高度 | 用途 |
|---|---|---|
| Row 0 | 60px | 顶栏:标题 + IP + 连接状态 |
| Row 1 | *(剩余) |
Frame 容器:加载功能页面 |
| Row 2 | 50px | 底栏:四轴状态信息 |
6.2 顶栏 - 左侧标题区域
xml
<!-- 顶栏 Border -->
<Border Grid.Row="0"
Background="{StaticResource HeaderBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,0,0,1">
<Grid Margin="20,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <!-- 左侧:标题 -->
<ColumnDefinition Width="*"/> <!-- 中间:弹性空间 -->
<ColumnDefinition Width="Auto"/> <!-- 右侧:IP + 状态 -->
</Grid.ColumnDefinitions>
<!-- 左侧:青色圆点 + 系统标题 -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="14" Height="14"
Fill="{StaticResource AccentBrush}"
Margin="0,-5,12,0"/>
<TextBlock Text="4-Axis Motion System" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource TextBrush}"/>
</StackPanel>
<!-- 右侧:IP + 连接状态(见 6.3)-->
</Grid>
</Border>
布局技巧 :Grid.ColumnDefinitions 使用 Auto / * / Auto 三列布局(左中右),这是 WPF 实现左右对齐的经典模式。
6.3 顶栏 - 右侧 IP + 连接状态
这部分是最复杂的绑定逻辑,包含了属性绑定、样式、触发器。
xml
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- 1. IP 地址 -->
<!-- {Binding IpAddress} → 从 DataContext 的 IpAddress 属性读值 -->
<TextBlock Text="{Binding IpAddress}"
Foreground="{StaticResource LabelBrush}"
FontSize="30" Margin="0,0,15,0"
VerticalAlignment="Center"/>
<!-- 2. 连接指示灯 -->
<Ellipse Width="10" Height="10" Margin="0,-10,8,0" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<!-- 默认填充红色(断开状态) -->
<Setter Property="Fill" Value="Red"/>
<Style.Triggers>
<!-- 当 IsConnected=True 时,覆盖为绿色 -->
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="{StaticResource GreenBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- 3. 连接状态文字 -->
<TextBlock VerticalAlignment="Center" FontSize="25"
Foreground="{StaticResource TextBrush}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<!-- 默认显示 "DisConnect" -->
<Setter Property="Text" Value="DisConnect"/>
<Style.Triggers>
<!-- 当 IsConnected=True 时,显示 "Connected" -->
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Text" Value="Connected"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
为什么不直接用绑定?
xml
<!-- 这样写不行------Fill 需要 Brush 类型,IsConnected 是 bool 类型 -->
<Ellipse Fill="{Binding IsConnected}" />
因为类型不匹配 :Fill 期望一个 Brush 对象(如 Red、GreenBrush),而 IsConnected 是 bool。DataTrigger 的作用就是条件判断:
| IsConnected 值 | DataTrigger | 指示灯颜色 | 文字 |
|---|---|---|---|
false(默认) |
不触发 | 🔴 Red | DisConnect |
true |
触发覆盖 | 🟢 GreenBrush | Connected |
DataTrigger 的自动回退机制 :当 true 变回 false 时,DataTrigger 不再匹配,自动移除覆盖效果,恢复默认值(Red + "DisConnect")。
6.4 中间 Frame - 页面容器
xml
<!-- Row 1:Frame 页面容器 -->
<Frame x:Name="MainFrame" Grid.Row="1"
NavigationUIVisibility="Hidden" Focusable="False"/>
后续加载页面:
csharp
MainFrame.Navigate(new SomePage());
6.5 底栏 - 四轴状态显示
xml
<!-- Row 2:底栏 -->
<Border Grid.Row="2"
Background="{StaticResource HeaderBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,1,0,0"
Padding="15,0">
<Grid HorizontalAlignment="Center"> <!-- 整组水平居中 -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <!-- Axis1: 文字 -->
<ColumnDefinition Width="Auto"/> <!-- Axis1: 指示灯 -->
<ColumnDefinition Width="Auto"/> <!-- Axis2: 文字 -->
<ColumnDefinition Width="Auto"/> <!-- Axis2: 指示灯 -->
<ColumnDefinition Width="Auto"/> <!-- Axis3: 文字 -->
<ColumnDefinition Width="Auto"/> <!-- Axis3: 指示灯 -->
<ColumnDefinition Width="Auto"/> <!-- Axis4: 文字 -->
<ColumnDefinition Width="Auto"/> <!-- Axis4: 指示灯 -->
</Grid.ColumnDefinitions>
<!-- Axis 1 -->
<TextBlock Grid.Column="0" Text="{Binding Axis1Status}"
FontSize="28" VerticalAlignment="Center"
Foreground="{StaticResource LabelBrush}"/>
<Ellipse Grid.Column="1" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,80,0" VerticalAlignment="Center"/>
<!-- Axis 2 (同 Axis 1 结构) -->
<TextBlock Grid.Column="2" Text="{Binding Axis2Status}"
FontSize="28" VerticalAlignment="Center"
Foreground="{StaticResource LabelBrush}"/>
<Ellipse Grid.Column="3" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,80,0" VerticalAlignment="Center"/>
<!-- Axis 3 -->
<TextBlock Grid.Column="4" Text="{Binding Axis3Status}"
FontSize="28" VerticalAlignment="Center"
Foreground="{StaticResource LabelBrush}"/>
<Ellipse Grid.Column="5" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,80,0" VerticalAlignment="Center"/>
<!-- Axis 4 (最后一个不加右 Margin) -->
<TextBlock Grid.Column="6" Text="{Binding Axis4Status}"
FontSize="28" VerticalAlignment="Center"
Foreground="{StaticResource LabelBrush}"/>
<Ellipse Grid.Column="7" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,8,0" VerticalAlignment="Center"/>
</Grid>
</Border>
HorizontalAlignment="Center" 为什么加在 Grid 上?
底栏的 Grid 有 8 列,全部是 Width="Auto"(宽度=内容宽度)。如果 Grid 默认 Stretch(填满整行),则 8 列内容会贴在左侧。
默认 Stretch: 加了 HorizontalAlignment="Center":
[Axis1][●] [Axis2][●] ... [Axis1][●] [Axis2][●] ...
^ 左对齐 ^^^^^^^ 整组居中 ^^^^^^^
关键理解 :Auto 列的子元素没有多余空间可以「水平居中」------列宽就等于内容宽度。要控制整组位置,必须加在容器(Grid)上。
七、核心机制详解
7.1 DataContext - 绑定的「目标对象」
csharp
// MainWindow.xaml.cs
public MainWindow()
{
InitializeComponent();
DataContext = new ViewModels.MainViewModel();
}
-
DataContext是DependencyProperty,会沿视觉树自动向下传递 -
所有子控件的
{Binding}都从这个对象查找属性 -
一个窗口只能有一个
DataContext(但不同区域可以覆盖)Window.DataContext = MainViewModel
├── Border (继承 DataContext = MainViewModel)
│ ├── StackPanel (继承 DataContext = MainViewModel)
│ │ ├── TextBlock {Binding IpAddress} → 去 MainViewModel.IpAddress 读值
│ │ ├── Ellipse {Binding IsConnected} → 去 MainViewModel.IsConnected 读值
│ │ └── ...
│ └── ...
├── Frame (继承 DataContext = MainViewModel)
└── Border (继承 DataContext = MainViewModel)
└── Grid (继承 DataContext = MainViewModel)
├── TextBlock {Binding Axis1Status} → 去 MainViewModel.Axis1Status 读值
└── ...
7.2 Binding - 声明式数据连接
xml
<TextBlock Text="{Binding IpAddress}" />
等价于:
csharp
// TextBlock 的 Text 属性 = DataContext.IpAddress 的当前值
// 如果 IpAddress 变了 → OnPropertyChanged 通知 → WPF 刷新 TextBlock
7.3 INotifyPropertyChanged + CallerMemberName
完整的数据通知链条:
csharp
// 1. ViewModel 实现 INotifyPropertyChanged
class MainViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
// 2. 通知方法,[CallerMemberName] 自动填入属性名
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
// 3. 属性 setter 中调用通知
private string _ipAddress = "192.168.0.10:502";
public string IpAddress
{
get => _ipAddress;
set
{
_ipAddress = value;
OnPropertyChanged(); // 编译器自动补为 OnPropertyChanged("IpAddress")
}
}
}
执行流程:
属性值变化 → setter → OnPropertyChanged("IpAddress")
↓
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("IpAddress"))
↓
WPF 收到通知 → 找到绑定了 "IpAddress" 的所有控件
↓
重新读取 IpAddress 属性的 getter
↓
更新 TextBlock.Text 显示新值
7.4 DataTrigger - 条件触发覆盖
xml
<Ellipse Width="10" Height="10">
<Ellipse.Style>
<Style TargetType="Ellipse">
<!-- 默认值(始终生效的兜底) -->
<Setter Property="Fill" Value="Red"/>
<Style.Triggers>
<!-- 当 IsConnected = true 时,覆盖 Fill -->
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="{StaticResource GreenBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
重点理解:
-
默认值 :
<Setter Property="Fill" Value="Red"/>是「兜底」,条件不匹配时使用 -
只匹配 true :DataTrigger 只有匹配条件,没有
else。true时覆盖,false时自动恢复默认 -
动态回退 :
true → false时,DataTrigger 自动解除,恢复为 Red时间线:
false ───── true ───── false
红色 绿色 红色
(默认) (触发覆盖) (自动恢复默认)
八、启动与运行流程
1. App.xaml → StartupUri="MainWindow.xaml"
↓
2. MainWindow 构造函数
├── InitializeComponent() ← 解析 XAML 创建控件树
│ ├── 解析 Grid 三行布局
│ ├── 解析顶栏 Border → 标题 / IP / 状态
│ ├── 解析 Frame
│ └── 解析底栏 Border → 四轴状态
│
└── DataContext = new MainViewModel()
│
├── WPF 发现 {Binding IpAddress}
│ → 自动读取 MainViewModel.IpAddress = "192.168.0.10:502"
│ → 显示在 TextBlock 上
│
├── WPF 发现 {Binding IsConnected} 的 DataTrigger
│ → 读取 IsConnected = true
│ → DataTrigger 匹配 → 指示灯变绿 + "Connected"
│
└── WPF 发现 {Binding Axis1Status~Axis4Status}
→ 自动读取 ViewModel 对应的属性值
→ 显示在底栏四个 TextBlock 上
↓
3. 窗口显示:顶栏(标题 | IP + 绿灯 + Connected) | Frame(空白) | 底栏(四轴状态)
九、总结与下一步
本博客已完成的内容
| 模块 | 功能 | 涉及技术 |
|---|---|---|
| App.xaml | 全局 14 色画刷 | StaticResource |
| MainViewModel | IP、连接状态、4 轴状态 | INotifyPropertyChanged、[CallerMemberName] |
| MainWindow.xaml.cs | 绑定 ViewModel | DataContext |
| MainWindow.xaml - 顶栏 | 标题 + IP + 指示 | Binding、DataTrigger、Style |
| MainWindow.xaml - 底栏 | 四轴状态 | Grid 居中、Auto 列布局 |
下一步计划
| 阶段 | 内容 |
|---|---|
| 第二篇 | Models 数据层 + PLC 通信接口设计 |
| 第三篇 | Page 功能页面编写 + Frame 导航 |
| 第四篇 | 串口/NModbus4 通信集成 |
| 第五篇 | 数据绑定深化 + 多轴实时监控 |
本文基于 .NET 10 + WPF 编写,所有代码均已通过
dotnet build && dotnet run验证。如果有任何问题或建议,欢迎交流讨论。