WPF 桌面应用:XAML 与 MVVM 模式
WPF 桌面应用:XAML 与 MVVM 模式
WPF(Windows Presentation Foundation)是微软推出的桌面端 UI 框架,它凭借强大的数据绑定、声明式布局和清晰的分层架构,成为 .NET 平台下企业级应用开发的首选。本教程将带领你从零开始,掌握 WPF 的核心——XAML 声明式界面与 MVVM 设计模式,并通过一个完整的实例巩固所学。
为什么选择 WPF?
与传统的 Windows Forms 相比,WPF 提供了:
- 矢量图形渲染:界面无损缩放,支持丰富的动画和特效
- 数据驱动型 UI:通过绑定自动同步界面与数据,减少手动更新代码
- 样式与模板:轻松定制控件外观,实现一致的视觉风格
- MVVM 友好:天然支持模型-视图-视图模型的分离,提升可测试性与可维护性
XAML:用声明式语言描述界面
XAML(eXtensible Application Markup Language)是 WPF 中定义界面的 XML 方言。它将 UI 元素表示为树形结构的对象,让你能够直观地“画”出界面。
<Window x:Class="TodoApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="任务管理" Height="350" Width="525">
<Grid>
<Button Content="添加任务" Width="100" Height="30" />
</Grid>
</Window>
XAML 基础概念
- 命名空间:默认命名空间映射 WPF 控件,
x:前缀用于 XAML 语言特性(如x:Name、x:Class) - 标记扩展:以花括号
{}表示的特殊语法,用于动态赋值,例如{Binding}、{StaticResource} - 属性元素:当属性值无法用字符串简洁表达时,使用嵌套的
Type.PropertyName形式 - 依赖属性:WPF 属性的基础,支持绑定、动画和样式继承
常用布局容器
| 控件 | 说明 |
|---|---|
| Grid | 行列布局,最灵活 |
| StackPanel | 水平或垂直堆叠子元素 |
| DockPanel | 停靠布局(上下左右) |
| WrapPanel | 自动换行排列 |
| Canvas | 绝对定位,适合绘图 |
合理组合这些容器即可构建出响应式界面。
资源与样式
将颜色、画刷、字符串等定义为资源,可在整个应用中复用。样式则像 CSS 一样作用于控件,定义其外观与行为。
<Window.Resources>
<Style TargetType="Button">
<Setter Property="Background" Value="DodgerBlue"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Margin" Value="5"/>
</Style>
</Window.Resources>
MVVM 模式:构建可维护的应用架构
MVVM(Model-View-ViewModel)将应用拆分为三层:
- Model:业务数据与逻辑,通常包含实体类、数据库访问等
- View:纯 XAML 界面,负责展示,不包含业务逻辑
- ViewModel:视图的抽象,通过数据绑定和命令将 Model 的数据转换为 View 可直接使用的形式
View ←— 数据绑定 —→ ViewModel ←— 操作 —→ Model
这种分离带来的好处:
- View 和 ViewModel 可独立开发、测试
- ViewModel 轻量化,不依赖 UI 线程,易于单元测试
- 更换界面或技术栈时只需修改 View 层
实现 MVVM 的核心基础设施
数据绑定(Data Binding)
绑定的桥梁是 DataContext。View 会从控件的 DataContext 开始向上查找绑定源。通常将窗口或控件的 DataContext 设置为一个 ViewModel 实例。
// 在窗口的构造函数或 App.xaml.cs 中
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
XAML 中使用 {Binding Path=属性名} 来绑定 ViewModel 中的属性。
<TextBox Text="{Binding TaskTitle, UpdateSourceTrigger=PropertyChanged}" />
UpdateSourceTrigger 控制数据更新时机,PropertyChanged 表示每次输入变化即同步回 ViewModel。
INotifyPropertyChanged 接口
当 ViewModel 的属性值变化时,必须通知 View 更新界面。这就需要实现 INotifyPropertyChanged 接口,并在 setter 中触发 PropertyChanged 事件。为了简化代码,通常创建一个基类。
public class BaseViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
之后 ViewModel 继承该类,即可轻松发出通知。
命令(ICommand)
按钮等控件的交互不能直接在 ViewModel 中写事件处理方法,而是通过命令。WPF 提供了 ICommand 接口。通常需要实现一个通用的 RelayCommand(或 DelegateCommand)。
public class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Func<object, bool> _canExecute;
public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
public bool CanExecute(object parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object parameter) => _execute(parameter);
}
然后 ViewModel 暴露命令属性:
public ICommand AddTaskCommand { get; }
并在构造函数中实例化:
AddTaskCommand = new RelayCommand(_ => AddTask());
XAML 中绑定:
<Button Content="添加" Command="{Binding AddTaskCommand}" />
动手实践:构建一个简易任务管理应用
我们将创建一个极简任务列表,功能包括:添加任务、显示任务、删除已完成任务。通过这个例子,你会看到 MVVM 如何协同工作。
1. 项目结构
新建 WPF 项目,添加以下文件夹和文件:
TodoApp/
├── Models/
│ └── TaskItem.cs
├── ViewModels/
│ ├── BaseViewModel.cs
│ ├── RelayCommand.cs
│ └── MainViewModel.cs
├── Views/
│ └── MainWindow.xaml
└── App.xaml / App.xaml.cs
2. Model:任务类
// Models/TaskItem.cs
public class TaskItem
{
public string Title { get; set; }
public bool IsCompleted { get; set; }
}
3. ViewModel:主视图模型
// ViewModels/MainViewModel.cs
public class MainViewModel : BaseViewModel
{
private string _newTaskTitle;
public string NewTaskTitle
{
get => _newTaskTitle;
set => SetProperty(ref _newTaskTitle, value);
}
public ObservableCollection<TaskItem> Tasks { get; } = new ObservableCollection<TaskItem>();
public ICommand AddTaskCommand { get; }
public ICommand RemoveCompletedCommand { get; }
public MainViewModel()
{
AddTaskCommand = new RelayCommand(_ =>
{
if (!string.IsNullOrWhiteSpace(NewTaskTitle))
{
Tasks.Add(new TaskItem { Title = NewTaskTitle });
NewTaskTitle = string.Empty; // 清空输入框
}
});
RemoveCompletedCommand = new RelayCommand(_ =>
{
var completed = Tasks.Where(t => t.IsCompleted).ToList();
foreach (var item in completed)
Tasks.Remove(item);
});
}
}
注意我们使用了 ObservableCollection,它会在增删项时自动通知界面刷新列表。
4. View:XAML 界面
<!-- Views/MainWindow.xaml -->
<Window x:Class="TodoApp.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="简易任务管理" Height="400" Width="500">
<Window.DataContext>
<local:MainViewModel xmlns:local="clr-namespace:TodoApp.ViewModels" />
</Window.DataContext>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 输入区域 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
<TextBox Text="{Binding NewTaskTitle, UpdateSourceTrigger=PropertyChanged}"
Width="300" Height="25" Margin="0,0,5,0"/>
<Button Content="添加任务" Command="{Binding AddTaskCommand}"
Width="100" Height="25"/>
</StackPanel>
<!-- 任务列表 -->
<ListBox Grid.Row="1" ItemsSource="{Binding Tasks}" DisplayMemberPath="Title">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="IsSelected" Value="{Binding Path=IsCompleted, Mode=TwoWay}" />
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<!-- 删除按钮 -->
<Button Grid.Row="2" Content="清除已完成任务"
Command="{Binding RemoveCompletedCommand}"
Width="140" Height="25" HorizontalAlignment="Right" Margin="0,5,0,0"/>
</Grid>
</Window>
我们使用 ListBox.ItemContainerStyle 将每个列表项的 IsSelected 绑定到 TaskItem.IsCompleted,实现了点击即标记完成的效果。
5. 运行应用
- 编译运行,输入任务标题,点击“添加”,新任务出现在列表中。
- 单击列表中的任务,其
IsCompleted被设为true(选中状态)。 - 点击“清除已完成任务”,所有已选择的项被移除。
至此,一个遵循 MVVM 的完整 WPF 应用就完成了。
MVVM 进阶与最佳实践
依赖注入与 ViewModel 定位
手动设置 DataContext 不够灵活,实际项目中常使用依赖注入(DI)容器(如 Microsoft.Extensions.DependencyInjection)管理 ViewModel,并通过一个 ViewModelLocator 进行绑定。
<Window DataContext="{Binding Main, Source={StaticResource Locator}}" />
消息通信(Messenger)
跨 ViewModel 解耦通信通常依赖消息传递库(如 CommunityToolkit.Mvvm 中的 Messenger)。可以发送和注册消息,而无需相互引用。
单元测试 ViewModel
因为 ViewModel 不依赖 UI,可以轻松编写单元测试。注入模拟的 Model 或服务,验证命令执行后状态是否正确。
[Test]
public void AddTask_WithValidTitle_ShouldAddToCollection()
{
var vm = new MainViewModel();
vm.NewTaskTitle = "测试任务";
vm.AddTaskCommand.Execute(null);
Assert.AreEqual(1, vm.Tasks.Count);
Assert.AreEqual("测试任务", vm.Tasks[0].Title);
}
常见坑与解决方案
- 忘记实现 INotifyPropertyChanged:属性变化界面不更新,务必在 setter 中调用通知方法。
- 绑定源错误:检查 DataContext 树,确保绑定的路径存在且可访问。
- 命令 CanExecute 不会自动刷新:需要手动调用
CommandManager.InvalidateRequerySuggested()或在 RelayCommand 中监听相关变化。 - 大量数据性能问题:对于万级列表,使用虚拟化(
VirtualizingStackPanel)或增量加载。 - 线程问题:ViewModel 中如果触发 UI 更新,必须回到 UI 线程;可以将耗时操作放在后台线程,更新集合时使用
Application.Current.Dispatcher.Invoke。
总结
借助 XAML 的声明式能力和 MVVM 的分层思想,你能以极低的耦合度构建出可扩展、可测试的桌面应用。从数据绑定到命令,从简单属性通知到完整架构,每一步都体现了现代桌面开发的最佳实践。继续深入,你可以探索更高级的主题,如 Prism 框架、控件模板自定义、异步编程在 MVVM 中的应用等。
现在,打开 Visual Studio,用刚刚学到的知识去创建属于你自己的 WPF 应用吧!