在需要实现界面美观、交互高效、业务逻辑清晰并便于单元测试的 C# 桌面及跨平台应用场景中,MVVM(Model–View–ViewModel)已成为主流架构模式。
MVVM 模式明确分离了表示层(View)、业务逻辑与状态(ViewModel)以及数据与领域模型(Model)三者的职责:View 负责 UI 显示和用户交互,几乎不涉业务逻辑;ViewModel 承担状态管理与业务流程,并为界面提供可绑定属性与命令,独立于具体 UI 框架,便于测试和维护;Model 则聚焦于领域数据和核心业务逻辑。View 与 ViewModel 之间通过数据绑定机制(Data Binding)进行解耦通信,实现界面与业务状态的同步和指令传递。

核心目标:把 UI 的“看起来与交互”与“业务的对与错”分离,彼此通过“数据绑定与命令”沟通。
在 WPF、WinUI 或 MAUI 这些 C# 桌面和跨平台 UI 框架里,我们经常会遇到“数据绑定”这个词。其实,数据绑定就像是给前台和后厨之间装了一条传送带。
我们需要告诉界面(View)应该和哪个“后厨”(ViewModel)打交道,这个指定的过程就是设置 DataContext(在 WPF/WinUI 里)或者 BindingContext(在 MAUI 里)。
一旦 DataContext 设好了,XAML 里的控件就能通过 {Binding 属性名} 这种语法,自动去 ViewModel 里找对应的属性。
比如说,我们在 ViewModel 里有个叫 UserName 的属性,只要在 XAML 里写上 {Binding UserName},界面上的文本框就能自动显示和更新这个名字。
更神奇的是,绑定不仅能单向读取数据,还能实现“双向绑定”(TwoWay),也就是说,用户在界面上改了内容,ViewModel 里的数据也会跟着变。 这就像前台点单员把顾客的要求实时传给后厨,后厨做出新菜品后,前台也能第一时间展示出来。整个过程不需要我们手动搬运数据,绑定机制会帮我们自动完成。
|<!-- WPF MainWindow.xaml --> <Window x:Class="Demo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Todo" Height="300" Width="400"> <StackPanel Margin="12"> <TextBox Text="{Binding NewText, UpdateSourceTrigger=PropertyChanged}"/> <Button Content="添加" Command="{Binding AddCommand}"/> <ListBox ItemsSource="{Binding Items}"> <ListBox.ItemTemplate> <DataTemplate> <CheckBox IsChecked="{Binding Completed}" Content="{Binding Text}"/> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </StackPanel> </Window>
在实际开发中,我们需要让界面(View)知道要和哪个“后厨”(ViewModel)打交道。这个过程就像是给前台安排一位专属的后厨师傅。我们通常会在代码隐藏文件(比如 MainWindow.xaml.cs)里,或者通过依赖注入(DI)容器,把 ViewModel 实例分配给界面的 DataContext 属性。
举个例子:假如我们有一个“待办事项”应用,MainWindow 负责展示界面,TodoViewModel 负责处理业务逻辑。我们可以在 MainWindow 的构造函数里,把 TodoViewModel 实例分配给 DataContext,这样界面上的控件就能自动和 ViewModel 里的属性、命令建立联系。
这个设置过程就像是把前台和后厨之间的“传菜口”打通,让数据和命令可以自由流动。无论是手动 new 一个 ViewModel,还是用 DI 容器自动注入,最终目的都是让 DataContext 指向正确的 ViewModel,这样数据绑定才能顺利工作。
|// MainWindow.xaml.cs(示例,实际项目推荐通过 DI 设置) public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); DataContext = new TodoViewModel(); } }
要让数据绑定真正“活”起来,我们还需要解决一个关键问题:当 ViewModel 里的数据发生变化时,界面(UI)怎么才能第一时间知道并自动更新呢?这就像我们在厨房里炒了一道新菜,前台服务员得及时收到通知,才能把新菜端给顾客。 否则,后厨做了半天,前台还蒙在鼓里,顾客也吃不到新鲜的菜。
在 WPF 这样的 MVVM 框架里,这个“通知机制”主要靠一个叫 INotifyPropertyChanged 的接口来实现。
只要我们的 ViewModel 实现了这个接口,每当某个属性的值发生变化时,我们就可以主动“广播”一个消息,告诉界面:“嘿,这个数据变啦!”这样,UI 就会自动刷新显示最新的内容。
所以,想让绑定生效,ViewModel 里的属性每次变化时都要记得通知 UI。最常见、最标准的做法,就是让 ViewModel 实现 INotifyPropertyChanged,并在属性发生变化时触发相应的事件。
|using System.ComponentModel; using System.Runtime.CompilerServices; public abstract class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected bool SetProperty<T>(ref T field, T value, [CallerMemberName]
不要忘了在属性 setter 里调用 SetProperty(或手动触发 PropertyChanged),否则 UI 看不到变化。
在 MVVM 模式下,我们经常会遇到需要在界面上动态展示一组数据,比如待办事项列表、书签集合等等。这个时候,如果我们只是用普通的 List<T>,当我们往列表里添加、删除或者清空项目时,界面是不会自动刷新的。
就像我们在黑板上写字,只有自己能看到,别人却毫无察觉。
为了解决这个问题,.NET 给我们准备了一个非常贴心的集合类型,叫做 ObservableCollection<T>。它的名字里有个“Observable”,意思就是“可观察的”。
只要我们用它来存储数据,每当我们对集合进行添加(Add)、删除(Remove)或者清空(Clear)等操作时,它都会自动“广播”一个通知,告诉界面:“嘿,列表变啦!”这样,UI 就会立刻刷新,把最新的内容展示出来。
|using System.Collections.ObjectModel; public class TodoItem { public string Text { get; set; } = string.Empty; public bool Completed { get; set; } } public class TodoViewModel : ViewModelBase { private string
在 MVVM 模式下,我们的按钮其实并不是直接去调用后台的某个方法,而是通过绑定到一个实现了 ICommand 接口的命令对象来间接完成操作。
这样做的好处是,界面和逻辑彻底分离,按钮只关心“我该不该能点、点了要做什么”,而不需要知道具体怎么做。
我们通常会写一个叫做 RelayCommand 的小工具类,把需要执行的代码和判断按钮是否可用的条件都封装进去。
比如说,添加待办事项的按钮,只有当输入框里有内容时才允许点击,这个判断逻辑就可以写在 RelayCommand 里。
就像我们在银行排队叫号,窗口工作人员不会直接处理每个人的业务,而是等叫号系统通知“轮到你了”,再去办理。
ICommand 就像这个叫号系统,界面(窗口)和业务(工作人员)之间通过它来沟通,既高效又清晰。
|using System; using System.Windows.Input; public class RelayCommand : ICommand { private readonly Action _execute; private readonly Func<bool>? _canExecute; public RelayCommand(Action execute, Func<bool>? canExecute =
我们在 XAML 里给按钮加上 Command="{Binding AddCommand}" 这个绑定,其实就像给按钮插上了一根“遥控线”,让它和 ViewModel 里的命令对象连在了一起。
这样一来,按钮的可用状态(能不能点)就完全由 AddCommand 里的 CanExecute 方法说了算。
比如说,我们输入框里没写内容的时候,CanExecute 返回的是 false,按钮就会自动变成灰色,点也点不了;
一旦我们输入了内容,CanExecute 变成 true,按钮立刻恢复可用。整个过程完全自动,不需要我们手动去控制按钮的状态。
这就像银行的叫号机,只有轮到我们的时候,窗口才会亮灯让我们过去办理业务。按钮的“亮”与“灰”,全靠命令对象背后的逻辑来决定。
值转换器(IValueConverter)其实就像我们厨房里的厨师。想象一下,我们有一篮子新鲜的蔬菜(原始数据),但直接端上桌可能不太合适,得经过厨师的巧手加工,变成一道色香味俱全的菜肴(界面上展示的数据)。 在 MVVM 里,IValueConverter 就扮演着这个“厨师”的角色。它负责把 ViewModel 里的“原料”数据,按照界面的需要,加工成合适的“菜肴”展示出来。
比如说,我们有个布尔值,true/false,直接显示在界面上可能不太友好,但通过值转换器,我们可以让 true 变成“已完成”的样式,false 变成“未完成”的样式,就像厨师根据食材做出不同的菜一样。 这样,数据和界面就能各司其职,互不干扰,却又配合得天衣无缝。
|using System; using System.Globalization; using System.Windows.Data; public class BoolToDecorationConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => (value is
|<Window.Resources> <local:BoolToDecorationConverter x:Key="Bool2Dec"/> </Window.Resources> <CheckBox IsChecked="{Binding Completed}" Content="{Binding Text}"> <CheckBox.Resources> <Style TargetType="TextBlock"> <Setter Property="TextBlock.TextDecorations" Value="{Binding Completed, Converter={StaticResource Bool2Dec}}"/>
在 MVVM 模式下,数据验证是我们经常会遇到的需求。比如说,用户填写表单时,我们希望能及时告诉他哪些内容填写得不对,哪些还没填完整。 这个时候,WPF 给我们准备了两套“验证接口”工具,分别叫做 IDataErrorInfo 和 INotifyDataErrorInfo。
我们可以把 IDataErrorInfo 想象成“老式的红笔老师”,每次我们输入内容,老师都会帮我们检查一遍,如果有错就立刻在旁边批注出来。 它的实现方式比较简单,适合一些基础的同步验证场景。
而 INotifyDataErrorInfo 更像是“现代的智能批改系统”,不仅能同步检查,还能异步校验,比如说我们要去服务器查重、或者做一些耗时的校验操作。它还能一次性返回多个错误信息,适合复杂的表单和高级需求。
在实际开发中,如果只是简单地判断某个字段有没有填、格式对不对,用 IDataErrorInfo 就够了; 如果需要更灵活、强大的验证体验,比如异步校验、多个错误提示,那就推荐用 INotifyDataErrorInfo。
下面是 INotifyDataErrorInfo 的实现示例:
|using System.Collections; using System.ComponentModel; public class ProfileViewModel : ViewModelBase, INotifyDataErrorInfo { private readonly Dictionary<string, List<string>> _errors = new(); private string _name = string.Empty; public string
在 MVVM 模式下,我们经常会遇到需要执行一些耗时操作的情况,比如从服务器加载数据、进行复杂的计算、或者执行一些异步任务。
这个时候,我们通常会用一个叫做 AsyncRelayCommand 的小工具类来封装这些异步操作。
|public class AsyncRelayCommand : ICommand { private readonly Func<Task> _run; private bool _busy; public AsyncRelayCommand(Func<Task> run) => _run = run; public bool CanExecute(object? p) => !_busy;
在 ViewModel 里使用:LoadCommand = new AsyncRelayCommand(LoadAsync);,绑定到按钮即可。
4. 实现 ViewModelBase 的 SetProperty 练习
实现 ViewModelBase 类的 SetProperty 方法:
SetProperty 方法用于在属性值变化时更新字段并触发 PropertyChanged 事件protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null)false(不触发通知)PropertyChanged 事件,返回 true[CallerMemberName] 自动获取属性名|using System.ComponentModel; using System.Runtime.CompilerServices; public abstract class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected bool SetProperty<T>(ref T field, T value, [CallerMemberName
5. RelayCommand 的 CanExecute 练习
实现 RelayCommand,让 AddCommand 仅在 NewText 非空白时可执行,并在 NewText 变化时刷新可用性:
RelayCommand 类,实现 ICommand 接口CanExecute 方法:当 NewText 非空白时返回 true,否则返回 falseNewText 属性变化时,调用 RaiseCanExecuteChanged() 刷新命令可用性TodoViewModel 中使用 RelayCommand 创建 AddCommand|using System; using System.Windows.Input; public class RelayCommand : ICommand { private readonly Action _execute; private readonly Func<bool>? _canExecute; public RelayCommand(Action execute, Func<bool>? canExecute
6. TwoWay 绑定练习
实现双向绑定,让 TextBox 改变时实时更新 ViewModel 属性:
TextBox 的 Text 属性绑定到 ViewModel 的 Name 属性Mode=TwoWay 实现双向绑定UpdateSourceTrigger=PropertyChanged 让输入时实时更新(而不是失去焦点时)Name 属性,使用 SetProperty 触发通知|<!-- MainWindow.xaml --> <Window x:Class="Demo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="双向绑定示例" Height="200" Width="300"> <StackPanel Margin="12"> <TextBlock Text="姓名:" Margin="0,0,0,5"/> <
7. ObservableCollection 的优势练习
解释为什么 List<T> 不适合直接绑定到会动态变化的列表:
List<T> 绑定到 UI 列表,当添加或删除元素时,UI 不会自动更新List<T> 没有实现 INotifyCollectionChanged 接口ObservableCollection<T>,它实现了 INotifyCollectionChanged 接口List<T> 和 ObservableCollection<T> 的行为差异|using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; // 错误示例:使用 List<T> public class BadViewModel : INotifyPropertyChanged { private List<string> _items = new List<string
8. INotifyDataErrorInfo 的使用练习
实现 INotifyDataErrorInfo 接口,让 Name 为空时产生错误,并触发 ErrorsChanged 事件:
INotifyDataErrorInfo 接口的三个成员:
HasErrors 属性:是否有错误GetErrors 方法:获取指定属性的错误列表ErrorsChanged 事件:错误变化时触发Name 属性的 setter 中,如果值为空或空白,设置错误;否则清除错误ErrorsChanged 事件|using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; using System.Linq; public class ProfileViewModel : ViewModelBase, INotifyDataErrorInfo { private readonly Dictionary<string,
|// SetProperty 方法会在属性值变化时自动触发 PropertyChanged 事件 // UI 会收到通知并自动更新显示
说明:
SetProperty 方法封装了属性更新的通用逻辑EqualityComparer<T>.Default.Equals 比较值,避免不必要的更新[CallerMemberName] 特性自动获取调用属性的名称,无需手动传递bool 表示是否真的发生了更新,可用于后续逻辑判断|添加: 测试任务
说明:
RelayCommand 封装了命令的执行逻辑和可用性判断CanExecute 方法在按钮绑定时会自动调用,决定按钮是否可用NewText 变化时,调用 RaiseCanExecuteChanged() 通知UI刷新按钮状态|// MainWindow.xaml.cs public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); DataContext = new MainViewModel(); } } // MainViewModel.cs public class MainViewModel : ViewModelBase { private string _name = string.Empty; public string Name { get => _name; set => SetProperty(ref _name, value); } }
|// 当用户在 TextBox 中输入时,Name 属性会实时更新 // 下方的 TextBlock 会实时显示输入的内容
说明:
Mode=TwoWay:实现双向绑定,UI 变化会更新 ViewModel,ViewModel 变化会更新 UIUpdateSourceTrigger=PropertyChanged:每次输入字符时立即更新,而不是失去焦点时LostFocus(失去焦点时)、Explicit(手动触发)INotifyPropertyChanged,属性变化时触发 PropertyChanged 事件|List<T> 的问题: - 添加/删除元素时,UI 不会自动更新 - 必须手动触发 PropertyChanged,但只通知集合本身的变化 - 集合内部元素的变化不会触发通知 ObservableCollection<T> 的优势: - 实现了 INotifyCollectionChanged 接口 - 添加/删除/清空元素时,自动触发 CollectionChanged 事件 - UI 会自动收到通知并更新显示 - 适合绑定到会动态变化的列表
说明:
List<T> 的问题:
INotifyCollectionChanged 接口PropertyChanged,也只通知集合本身的变化,不通知内部元素的变化ObservableCollection<T> 的优势:
INotifyCollectionChanged 接口CollectionChanged 事件|设置 Name 为空: 属性 Name 的错误已更新 错误: 姓名不能为空 设置 Name 为有效值: 属性 Name 的错误已更新 错误:
说明:
INotifyDataErrorInfo 用于数据验证,比 IDataErrorInfo 更强大HasErrors 属性表示是否有任何错误GetErrors 方法返回指定属性的错误列表(可以是多个错误)ErrorsChanged 事件在错误变化时触发,通知 UI 更新错误显示ErrorsChanged 事件HasErrors 和 GetErrors,自动显示验证错误