WPF + MVVM 实战系列02-告别 INPC 手写时代,做个体面的现代 WPF 人

by 雪隐_上班了 · 掘金主页

欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权。


一句话省流版

CommunityToolkit.Mvvm 8.4.2 用源生成器 把"手写 INotifyPropertyChanged + ICommand"这套脏活累活全包了。你只需要写字段 + 贴 attribute,编译器加班帮你生成一切------你摸鱼,代码自己卷自己


装包

bash 复制代码
dotnet add package CommunityToolkit.Mvvm --version 8.4.2

就这一个包,搞定。它依赖:

  • Microsoft.Bcl.AsyncInterfaces(源生成器要用的接口,不用你管)
  • 不依赖 WPF,纯类库项目也能用(但咱们是 WPF 系列,就不跑题了)

装完就能用,不用配任何东西,比装个微信还简单


一、[ObservableProperty]:告别 INPC 腱鞘炎

传统写法(手写 INPC,写到手抽筋)

csharp 复制代码
public class MainViewModel : INotifyPropertyChanged
{
    private string _name = string.Empty;
    public string Name
    {
        get => _name;
        set
        {
            if (_name != value)
            {
                _name = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
            }
        }
    }
    public event PropertyChangedEventHandler? PropertyChanged;
}

就一个字符串字段,写了 17 行模板代码。你想想,一个 ViewModel 少说十几个属性,那不得写到手抽筋、眼发花、腰肌劳损?

现代写法(2 行,不能再多了)

csharp 复制代码
public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private string _name = string.Empty;
}

就两行。真的就两行。

[ObservableProperty] 是源生成器的 attribute,编译时自动给你生成:

csharp 复制代码
public string Name
{
    get => _name;
    set
    {
        if (!EqualityComparer<string>.Default.Equals(_name, value))
        {
            _name = value;
            OnPropertyChanged(nameof(Name));  // 基类帮你干了活
        }
    }
}

三个关键点,记牢了:

  • 类必须 partial(源生成器要往里头塞代码,你不 partial 它进不来)
  • 继承 ObservableObject(基类给你 OnPropertyChangedSetProperty 工具方法)
  • 字段命名约定:_camelCase → 属性 PascalCase。写了 _firstName 就生成 FirstName,写了 _lastName 就生成 LastName别自己瞎起名,按规矩来。

联动通知:一个变,全家都变

csharp 复制代码
public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(DisplayName))]
    private string _firstName = string.Empty;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(DisplayName))]
    private string _lastName = string.Empty;

    public string DisplayName => $"{FirstName} {LastName}".Trim();
}

XAML 里三个控件各绑各的:

xaml 复制代码
<TextBox Text="{Binding FirstName, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Text="{Binding LastName, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding DisplayName}" />

你改 FirstNameDisplayName 自动刷新;你改 LastNameDisplayName 也跟着刷新。一个人干活,三个人响应,比当代打工人还能卷。


二、[RelayCommand]:命令系统终于能看了

传统 ICommand 写法(还是手抽筋)

csharp 复制代码
public class MainViewModel : INotifyPropertyChanged
{
    private ICommand? _saveCommand;
    public ICommand SaveCommand => _saveCommand ??= new RelayCommand(
        execute: () => { /* 业务逻辑 */ },
        canExecute: () => !string.IsNullOrEmpty(Name));
}

每来一个命令,就得写一个字段 + 一个属性 + 一个 lambda,代码量堪比写小作文

现代写法(1 行,真的就 1 行)

csharp 复制代码
public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private string _name = string.Empty;

    [RelayCommand(CanExecute = nameof(CanSave))]
    private void Save()
    {
        // 业务逻辑
    }

    private bool CanSave() => !string.IsNullOrEmpty(Name);
}

源生成器自动给你生成:

  • public IRelayCommand SaveCommand { get; }
  • CanSave 返回值变化时,自动调用 NotifyCanExecuteChanged()

XAML 绑定:

xaml 复制代码
<Button Content="保存" Command="{Binding SaveCommand}" />

按钮自动根据 Name 是否为空来启用/禁用,完全不用你手动刷新命令状态

异步命令:线程不阻塞 UI 是基本素养

csharp 复制代码
[RelayCommand]
private async Task LoadAsync()
{
    IsLoading = true;
    try
    {
        await Task.Delay(2000);  // 模拟网络请求
    }
    finally
    {
        IsLoading = false;
    }
}

生成的是 IAsyncRelayCommand,XAML 绑定方式完全一样:

xaml 复制代码
<Button Content="加载" Command="{Binding LoadCommand}" />

点一下,按钮自动禁用(防止重复点击),加载完自动启用。连 Loading 状态都不用你手动管,它自己知道。

参数化命令:传参是个小坑

csharp 复制代码
[RelayCommand]
private void SelectItem(int id)
{
    SelectedId = id;
}

XAML:

xaml 复制代码
<Button Content="选 1" Command="{Binding SelectItemCommand}" CommandParameter="1" />

⚠️ 注意坑: CommandParameter 传过来的是字符串 "1",WPF 不会自动给你转成 int。方法参数是 int 的话,它会直接报错。

解决方案:参数用 string,自己解析:

csharp 复制代码
[RelayCommand]
private void SelectTool(string kindName)
{
    if (Enum.TryParse<AnnotationKind>(kindName, out var kind))
        CurrentTool = kind;
}

记住一条铁律:CommandParameter 传过来全是 object,实际值是字符串,别指望 WPF 帮你做类型转换。 你指望它,它就会让你失望。


三、WeakReferenceMessenger:跨 VM 通信,谁也别认识谁

有些场景里,两个 ViewModel 需要沟通,但谁都不该直接 new 对方------否则两个 VM 紧耦合,测试起来想死。

经典场景:左栏选了个相册 → 中栏加载对应图片列表。

不用 Messenger(紧耦合,想死)

csharp 复制代码
public class AlbumListViewModel
{
    private ImageViewModel _imageVm = new ImageViewModel();  // 硬耦合!

    public void OnAlbumSelected(int albumId)
    {
        _imageVm.LoadAlbumImagesAsync(albumId);
    }
}

你测 AlbumListViewModel 的时候,还得顺便带个 ImageViewModel 的 mock,一个单元测试写着写着变成了集成测试

用 Messenger(解耦,各过各的)

第一步:定义消息(就是个 record,一行)

csharp 复制代码
public record AlbumSelectedMessage(int AlbumId);

第二步:发送方只管发,不管谁收

csharp 复制代码
public class AlbumListViewModel : ObservableObject
{
    [RelayCommand]
    private void SelectAlbum(Album album)
    {
        WeakReferenceMessenger.Default.Send(new AlbumSelectedMessage(album.Id));
    }
}

第三步:接收方自己注册,自己处理

csharp 复制代码
public class ImageViewModel : ObservableObject
{
    public ImageViewModel()
    {
        WeakReferenceMessenger.Default.Register<AlbumSelectedMessage>(this, (r, m) =>
        {
            if (r is ImageViewModel vm)
                _ = vm.LoadAlbumImagesAsync(m.AlbumId);
        });
    }
}

两个 VM 互相不知道对方存在。发消息的不知道谁在听,听消息的不知道谁发的。 像极了广播电台和收音机的关系------你听你的,我播我的。

关键:WeakReference 防内存泄漏

普通 C# event 订阅会让发布者持有订阅者引用,订阅者不小心就会死不了(内存泄漏)。

WeakReferenceMessenger 内部用弱引用,订阅者只要没有被其他地方强引用,GC 来了就能收走,不拖泥带水。

不过有个小习惯建议养成 :ViewModel 关闭时调用 WeakReferenceMessenger.Default.UnregisterAll(this),虽然弱引用已经不太会泄漏了,但及时清理是成年人的体面


四、实战小例:Counter(30 行从 0 到跑)

ViewModel(20 行)

csharp 复制代码
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace CounterApp;

public partial class CounterViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(StatusText))]
    private int _count;

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(IncrementCommand))]
    [NotifyCanExecuteChangedFor(nameof(DecrementCommand))]
    private int _maxCount = 10;

    public string StatusText => Count >= MaxCount
        ? "已达上限"
        : $"还能加 {MaxCount - Count} 次";

    [RelayCommand(CanExecute = nameof(CanIncrement))]
    private void Increment() => Count++;

    [RelayCommand(CanExecute = nameof(CanDecrement))]
    private void Decrement() => Count--;

    private bool CanIncrement() => Count < MaxCount;
    private bool CanDecrement() => Count > 0;
}

View(10 行 XAML)

xaml 复制代码
<Window x:Class="CounterApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Counter" Height="200" Width="300">
    <StackPanel VerticalAlignment="Center" Margin="20">
        <TextBlock Text="{Binding Count}" FontSize="48" HorizontalAlignment="Center" />
        <TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center" Margin="0,0,0,10" />
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
            <Button Content="-" Width="60" Margin="5"
                    Command="{Binding DecrementCommand}" />
            <Button Content="+" Width="60" Margin="5"
                    Command="{Binding IncrementCommand}" />
        </StackPanel>
    </StackPanel>
</Window>

App.xaml.cs(5 行)

csharp 复制代码
public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        var window = new MainWindow { DataContext = new CounterViewModel() };
        window.Show();
    }
}

跑起来效果:

  • Count=0 时,- 按钮灰的(不能减到负数)
  • Count=10 时,+ 按钮灰的(达到上限)
  • 中间状态正常加减,状态文字实时更新

30 行 实现了"双向 binding + 命令启用/禁用 + 计算属性联动"。手写 INPC + ICommand 至少 80 行起步。这叫降维打击。


五、什么时候不要用三件套(别硬套)

三件套虽好,但不是万能膏药,贴哪都行。这几个场景别硬塞到 ViewModel 里

  1. 纯 UI 状态(焦点、鼠标位置、动画进度)→ 放 View 自己管,别污染 ViewModel。你把鼠标坐标塞 ViewModel 里,测试怎么测?毫无意义。
  2. 高频事件(鼠标 Move、Drag 等)→ 别用 Command,直接事件处理 + View 内部搞定。Command 是为"用户动作触发业务逻辑"设计的,不是为"鼠标每动一次都刷新 UI"设计的。
  3. 业务逻辑可单测的部分→ 放 ViewModel,不要塞 code-behind。这是 MVVM 的底线。

具体怎么判断?下一篇我专门讲 4 问判别法,帮你一招分清"这个逻辑该放哪"。


最后说句心里话CommunityToolkit.Mvvm 这套工具我用了三天就觉得"以前手写 INPC 的我到底在干嘛"------技术选型选对了,相当于上班带了个实习生帮你干杂活。希望这篇能让你少走 3 天弯路。我们下篇见,记得装好 NuGet 包。📦☕️

相关推荐
肉肉不吃 肉1 小时前
无渲染组件和组合式函数
前端·javascript·vue.js
霸道流氓气质1 小时前
SpringBoot中使用OAuth2 认证与 JWT Token — 概念、原理与实践
java·spring boot·后端
未来智慧谷1 小时前
从 Atlas 关停复盘:AI 应用的三种载体形态怎么选(独立应用 / 浏览器扩展 / 桌面宿主)
前端·人工智能·ai·架构·浏览器
dogstarhuang1 小时前
Kimi K3 本地部署实战:从 1.56TB 权重到推理服务的完整成本分析
java·人工智能·后端·ai·开源·接口·程序员创富
sugar__salt1 小时前
前端路由技术详解:从传统多页到 React SPA 的完整进化之路
前端·javascript·react.js·前端框架
星栈1 小时前
我以为 TS7.0 只是换个版本号,结果编译快了 9 倍,也踩了 5 个坑
后端·typescript·node.js
Csvn1 小时前
📊 SQL 入门 Day 15:日期与字符串函数 — SQL 中的数据处理瑞士军刀
后端·sql
Canace1 小时前
Harness Engineering 到底在做什么
前端·人工智能·ai编程
用户84298142418101 小时前
JS代码压缩实测:可减小体积、提高执行效率!
前端·javascript·后端