【WPF 】MVVM 设计模式在 WPF 中的实战应用

文章目录

    • [一、MVVM 核心组件职责](#一、MVVM 核心组件职责)
    • 二、数据绑定核心机制
    • [三、命令绑定:ICommand 实战](#三、命令绑定:ICommand 实战)
      • [3.1 常见问题:如何把"事件"变成命令](#3.1 常见问题:如何把“事件”变成命令)
    • 四、现代方案:CommunityToolkit.Mvvm
      • [4.0 NuGet 与命名空间](#4.0 NuGet 与命名空间)
      • [4.1 核心特性(含异步命令)](#4.1 核心特性(含异步命令))
      • [4.2 消息系统(解耦 ViewModel/跨 VM 通信)](#4.2 消息系统(解耦 ViewModel/跨 VM 通信))
      • [4.3 集合与验证](#4.3 集合与验证)
    • 五、验证机制全景
    • [六、View/ViewModel 关联与 DI](#六、View/ViewModel 关联与 DI)
      • [6.1 DataContext 作用域与 Source](#6.1 DataContext 作用域与 Source)
      • [6.2 ViewModelLocator(自动关联 View/ViewModel)](#6.2 ViewModelLocator(自动关联 View/ViewModel))
      • [6.3 DI 生命周期与注册策略](#6.3 DI 生命周期与注册策略)
    • 七、项目结构规范
    • 八、框架选型对比
    • 九、常见避坑提醒

一、MVVM 核心组件职责

层级 职责 不做什么
View 声明式 UI(XAML),负责"展示"和"采集",通过绑定对接 ViewModel 不写业务逻辑;不直接操作 Model/数据库
ViewModel 暴露可绑定属性与命令;编排交互逻辑、状态;作为 View 的上下文(DataContext) 不直接引用 View 控件;不应存在 UI 命名空间依赖
Model 数据实体、业务规则、数据访问(Repository)、领域事件 不关心 UI 展示与绑定;不包含 ViewModel 特定逻辑

也可以引入 Service 层:业务/基础设施服务接口+实现,由 ViewModel 通过 DI 注入使用,避免 ViewModel 臃肿。

csharp 复制代码
// View:XAML 绑定示例
<TextBlock Text="{Binding Temperature, StringFormat={}{0:F1} °C}" />
<Button Content="刷新" Command="{Binding RefreshCommand}" />

最佳实践:ViewModel 实现单一接口(或仅使用社区工具包提供的基类),方便单元测试和跨框架复用。例如 CommunityToolkit.Mvvm 提供的 ObservableObjectObservableRecipient 等。该工具包由 Microsoft 维护,是官方推荐的 MVVM 实用类型集合。


二、数据绑定核心机制

  • INotifyPropertyChanged:属性变更 → 触发 UI 更新;是"响应式 UI"的基石。
  • 绑定模式选择
    • OneWay:只读展示;
    • TwoWay:可编辑输入(如 TextBox.Text 默认为 TwoWay);
    • OneTime:初始化一次,后续不再同步;
    • OneWayToSource:只把 UI 的变化写回源;
    • Default:根据目标依赖属性的默认行为来决定。
  • 更新时机(UpdateSourceTrigger)
    • PropertyChanged:每次击键都更新源;适合搜索框/实时预览。
    • LostFocus:控件失去焦点时更新源;TextBox.Text 的默认值,减少高频写入。
    • Explicit:需在代码中调用 BindingExpression.UpdateSource(),才提交到源(很少在 MVVM 里直接使用)。
  • 值转换器(IValueConverter)
    用于"类型不匹配/展示定制"的场景(如 bool↔Visibility、枚举→中文描述、颜色/图片路径转换等)。
csharp 复制代码
// 示例:布尔→可见性(与参数结合可反转)
public class BoolToVisibilityConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is bool b)
            return b ? Visibility.Visible : Visibility.Collapsed;
        return Visibility.Collapsed;
    }
    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is Visibility v)
            return v == Visibility.Visible;
        return false;
    }
}
xml 复制代码
<!-- 在资源里声明转换器 -->
<Window.Resources>
    <local:BoolToVisibilityConverter x:Key="BoolToVis"/>
</Window.Resources>
<!-- 使用 -->
<TextBlock Visibility="{Binding IsAdmin, Converter={StaticResource BoolToVis}}" Text="管理员面板"/>

小提示:转换器应尽量保持无副作用、无状态,避免在 Convert/ConvertBack 里写复杂业务,以便测试和复用。


三、命令绑定:ICommand 实战

通用 RelayCommand 实现(你给的版本可用,这里补充注释与要点):

csharp 复制代码
public class RelayCommand : ICommand
{
    private readonly Action<object> _execute;
    private readonly Func<object, bool> _canExecute;
    public event EventHandler CanExecuteChanged
    {
        // WPF 提供的全局自动重询机制,适合简单场景
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }
    public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
    {
        _execute = execute ?? throw new ArgumentNullException(nameof(execute));
        _canExecute = canExecute;
    }
    public bool CanExecute(object parameter) => _canExecute?.Invoke(parameter) ?? true;
    public void Execute(object parameter) => _execute(parameter);
}

3.1 常见问题:如何把"事件"变成命令

  • 推荐用 System.Windows.Interactivity/交互触发器 (例如 EventTrigger+InvokeCommandAction),把事件路由到 ViewModel 的命令(可第三方包,也可自己轻量实现)。示例:
xml 复制代码
<!-- 示例:ListBox 双击→执行 OpenDetailCommand(伪代码,具体类名因包而异) -->
<ListBox ItemsSource="{Binding Items}">
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="MouseDoubleClick">
            <i:InvokeCommandAction Command="{Binding OpenDetailCommand}"
                                   CommandParameter="{Binding SelectedItem}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</ListBox>

注意:不要把"非命令型逻辑"硬塞进 View 的 code-behind,尽量用命令/行为(Behavior)统一到 ViewModel 中,方便测试。


四、现代方案:CommunityToolkit.Mvvm

4.0 NuGet 与命名空间

  • 包名:CommunityToolkit.Mvvm(前身为 `Microsoft.Toolkit.Mvvm)。
  • 常用命名空间:
    • CommunityToolkit.Mvvm.ComponentModelObservableObjectObservableRecipientObservableValidator 等。
    • CommunityToolkit.Mvvm.InputRelayCommandAsyncRelayCommand 等。
    • CommunityToolkit.Mvvm.MessagingWeakReferenceMessengerIRecipient<T> 等。
  • 安装后即可按需引入对应命名空间使用。

4.1 核心特性(含异步命令)

csharp 复制代码
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
    private string _username = string.Empty;
    [ObservableProperty]
    private bool _isLoading;
    [RelayCommand(CanExecute = nameof(CanLogin))]
    private async Task LoginAsync()
    {
        IsLoading = true;
        try
        {
            await AuthService.LoginAsync(Username);
        }
        finally
        {
            IsLoading = false;
        }
    }
    private bool CanLogin() => !string.IsNullOrEmpty(Username) && !IsLoading;
}

要点:

  • [ObservableProperty] 会在编译期生成公开属性 Username,并自动实现属性变更通知。
  • AsyncRelayCommand 封装了异步方法,内部提供 IsRunningExecutionTaskCancelCommand 等能力,便于 UI 做加载/取消/异常提示。

4.2 消息系统(解耦 ViewModel/跨 VM 通信)

csharp 复制代码
// 定义消息(推荐用 record/value 类型)
public record UserLoggedInMessage(string UserName);
// 发送(WeakReferenceMessenger 是默认单例)
WeakReferenceMessenger.Default.Send(new UserLoggedInMessage(Username));
// 接收
public class HeaderViewModel : ObservableRecipient, IRecipient<UserLoggedInMessage>
{
    public HeaderViewModel()
    {
        // 激活当前对象,开始监听
        WeakReferenceMessenger.Default.Register<UserLoggedInMessage>(this);
    }
    public void Receive(UserLoggedInMessage message)
    {
        // 更新当前用户信息、UI 等逻辑
    }
}

小提示:

  • 在不需要监听时务必调用 WeakReferenceMessenger.Default.UnregisterAll(this)(或在 ViewModel 实现清理逻辑),避免"订阅后未注销"导致内存/逻辑泄漏。
  • 消息应尽量"只通知状态/事件",不要包含复杂业务逻辑。复杂流程仍然通过 Service/领域事件处理。

4.3 集合与验证

csharp 复制代码
// 集合:推荐 ObservableCollection<T>,对增删整体替换会自动触发 UI 刷新
[ObservableProperty]
private ObservableCollection<SensorData> _sensorList = new();
// 验证:继承 ObservableValidator,支持 DataAnnotations
public partial class UserModel : ObservableValidator
{
    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "用户名必填")]
    [StringLength(20, MinimumLength = 2, ErrorMessage = "长度应在2-20之间")]
    private string _userName;
}

要点:

  • [NotifyDataErrorInfo] 让工具包在属性变更时自动触发 INotifyDataErrorInfo 的错误刷新。
  • XAML 中绑定可通过 ValidatesOnNotifyDataErrors=True 开启 UI 校验提示(默认已经开启)。

五、验证机制全景

WPF 支持多种验证方式,MVVM 下推荐 INotifyDataErrorInfo:支持"多错误/异步验证/按属性查询",.NET 4.5+ 可用。

  • 接口对比:
    • IDataErrorInfo:只支持单个错误信息、同步验证。
    • INotifyDataErrorInfo:推荐;支持多错误、异步、属性级/实体级错误。
    • ValidationRule:完全在 View 侧,适合跨控件/纯 UI 校验(格式校验等)。
  • 在 XAML 中:
    • 启用:Binding 默认包含 ValidatesOnNotifyDataErrors=True;若使用 DataAnnotations 或 ObservableValidator,需要确保 Binding 能看到 INotifyDataErrorInfo 的实现。
    • 提示样式:
      • 默认:红色边框(ErrorTemplate 为默认模板)。
      • 自定义:通过 Validation.ErrorTemplate 附加属性定制提示位置/样式。
xml 复制代码
<!-- 简单示例:在控件下方显示第一条错误 -->
<TextBox Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged,
                          ValidatesOnNotifyDataErrors=True}">
    <Validation.ErrorTemplate>
        <ControlTemplate>
            <StackPanel>
                <AdornedElementPlaceholder x:Name="textBox"/>
                <ItemsControl ItemsSource="{Binding}">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding ErrorContent}" Foreground="Red"/>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </StackPanel>
        </ControlTemplate>
    </Validation.ErrorTemplate>
</TextBox>

最佳实践:

  • 验证逻辑放 Model 或 ViewModel(领域层/应用层),而不是 View。
  • 通用基础类:可以把 INotifyDataErrorInfo + DataAnnotations 封装到基类(或直接继承 ObservableValidator)。

六、View/ViewModel 关联与 DI

6.1 DataContext 作用域与 Source

  • 绑定默认沿可视树向上寻找 DataContext,直到 Window/Page 级别;也可以显式指定:
    • Source={StaticResource vm}RelativeSourceElementName 等。
  • ItemsControl.ItemTemplate 内的绑定上下文是"当前集合项",需用相对路径或指定源来访问父级 ViewModel:
xml 复制代码
<!-- 示例:列表项访问父级命令 -->
<Button Command="{Binding DataContext.DeleteCommand,
                RelativeSource={RelativeSource AncestorType=ItemsControl}}"
        CommandParameter="{Binding}" Content="删除"/>

6.2 ViewModelLocator(自动关联 View/ViewModel)

  • 概念:通过约定/注册,自动把 View 的 DataContext 设置为对应 ViewModel 的实例。例如 Prism 的 ViewModelLocator:约定 Views 放 .Views、ViewModels 放 .ViewModels,命名对应 XxxView ↔ XxxViewModel。
  • 核心要点:
    • 命名约定:View 所在程序集 相同;ViewModel 以"ViewModel"结尾,并位于 .ViewModels 命名空间。
    • DI 集成:自定义工厂函数,通过容器解析 ViewModel(含构造函数注入依赖)。

即使不使用 Prism,也可以自己实现一个简易 ViewModelLocator:在 App 中注册 View↔ViewModel 的映射,然后在 Window.Resources 或样式里把 DataContext 绑到静态实例。

6.3 DI 生命周期与注册策略

使用 Microsoft.Extensions.DependencyInjection 时,常见生命周期包括:

  • Singleton:全局唯一,生命周期等于容器/应用;注意线程安全与长期持有的大对象。
  • Transient:每次注入/解析都是新实例。
  • Scoped:每个"作用域"一个实例;在 WPF 里通常不会自动创建作用域,需要手动创建并管理生命周期。
    常见注册策略(WPF):
csharp 复制代码
public partial class App : Application
{
    private ServiceProvider _serviceProvider;
    public App()
    {
        var services = new ServiceCollection();
        // 工具/配置/持久化
        services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
        services.AddSingleton<IAuthService, AuthService>();
        services.AddSingleton<IDialogService, DialogService>();
        // ViewModel:通常注册为 Transient/Scoped,按视图实例化
        services.AddTransient<MainViewModel>();
        services.AddTransient<LoginViewModel>();
        services.AddTransient<SettingsViewModel>();
        // View:也可以注册为 Transient
        services.AddTransient<MainWindow>();
        services.AddTransient<LoginWindow>();
        _serviceProvider = services.BuildServiceProvider();
    }
    protected override void OnStartup(StartupEventArgs e)
    {
        // 推荐通过解析 View,在构造函数里注入 ViewModel(构造注入),并设置 DataContext
        var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }
}
csharp 复制代码
// MainWindow.xaml.cs:用构造注入,保持 View "薄"
public partial class MainWindow : Window
{
    public MainWindow(MainViewModel vm)
    {
        InitializeComponent();
        DataContext = vm;
    }
}

关键点:

  • View 不应该知道 ViewModel 的具体类型,只依赖其接口(可读性/测试性更佳)。
  • ViewModel 不应该引用 View(避免反向依赖和内存泄漏)。
  • 在关闭窗口/页面时,确保及时释放 ViewModel 所持有的资源(订阅/长任务/打开的连接等)。如使用 IDisposable 或提供 Cleanup() 方法。

七、项目结构规范

复制代码
MyWpfApp/
├── Models/                # 实体类/值对象/领域模型
├── ViewModels/            # 视图模型
├── Views/                 # XAML 页面/Window/UserControl
├── Services/              # 业务服务接口与实现(IAuthService, IFileService 等)
├── Converters/            # IValueConverter 实现
├── Behaviors/             # 附加行为/触发器(可选)
├── Messages/              # 消息类型定义(可合并到 ViewModels)
├── Resources/             # 样式/模板/字符串资源
└── App.xaml.cs            # DI 配置、全局异常处理、主题/语言等

八、框架选型对比

场景 推荐方案 理由(简要)
中小型项目、快速原型 CommunityToolkit.Mvvm(配合自建 DI) 轻量、无强约束、易与 DI/其他框架组合;官方维护并内置常用类型(ObservableObject/Command/Messenger/Validator)。
大型复合应用、模块化/导航 Prism 提供 Module、Region 导航、Dialog、ViewModelLocator、事件聚合器、DI 抽象等;生态成熟。
需要高度定制/学习成本敏感 自建轻量 MVVM 基础设施 手写 ObservableObjectRelayCommandIoc,或只引入工具包中的部分类型;更透明但需自己维护。

九、常见避坑提醒

  1. 属性通知丢失 :必须使用 [ObservableProperty] 或手动实现 INotifyPropertyChanged;修改字段而不是属性不会触发通知。
  2. DataContext 作用域ItemsControl.ItemTemplate 内的绑定上下文是集合项本身;需要访问父 ViewModel 时用 RelativeSource
  3. 集合项属性修改不刷新 :集合(ObservableCollection<T>)只对增删/整体替换生效;元素属性修改需要元素实现 INotifyPropertyChanged(可继承 ObservableObject)。
  4. 内存泄漏
    • 长生命周期对象(单例 ViewModel/Service)不要直接引用 View。
    • Messenger/事件订阅要及时注销(Unregister/UnregisterAll)。
    • 命令持有闭包/强引用时,在 ViewModel 销毁时置空命令或字段。
  5. 后台线程与 UI 线程
    • ViewModel 中改变属性(触发 UI 更新)要确保在 UI 线程(可通过 Application.Current.Dispatcher),否则会抛异常。
    • 异步命令的异常要在 ViewModel 中统一处理/记录,避免"静默失败"。
  6. 性能
    • 大列表使用虚拟化(VirtualizingStackPanel.IsVirtualizing="True")。
    • 避免在属性 Getter 中做重计算;复杂计算结果应缓存为属性或可观察字段。
    • 避免频繁的"全量重建集合";尽量复用对象、做增量更新。
  7. 测试友好
    • ViewModel 不依赖具体 Service,而是通过接口注入(便于 Mock)。
    • 命令/消息都可通过单元测试驱动编写;避免把 UI 特定逻辑耦合进 ViewModel。
相关推荐
FreeGo~2 小时前
java23种设计模式示例
设计模式
ximu_polaris2 小时前
设计模式(C++)-行为型模式-命令模式
c++·设计模式·命令模式
darkhorsefly2 小时前
《智能体设计模式》
设计模式
张小俊_3 小时前
WPF 跨线程 UI 更新与硬编码赋值引发的 Bug 排查
c#·bug·wpf
ximu_polaris4 小时前
设计模式(C++)-行为型模式-责任链模式
c++·设计模式·责任链模式
geovindu7 小时前
go: Visitor Pattern
开发语言·设计模式·golang·访问者模式
ximu_polaris21 小时前
设计模式(C++)-行为型模式-模版方法模式
c++·设计模式
A-Jie-Y21 小时前
JAVA设计模式-抽象工厂模式
java·设计模式
故事还在继续吗1 天前
设计模式完全指南
设计模式