WPF 学习笔记(七):MVVM 工程化与依赖注入

写在前面

版本说明:基于 .NET 8 LTS + CommunityToolkit.Mvvm 8.2+ + Microsoft.Extensions.DependencyInjection 8.0。

承接上一篇命令系统。前面学了 ICommand 和 CommunityToolkit.Mvvm 的源生成器,本篇把它们落到工程实践——怎么组织项目、怎么集成 DI、怎么处理导航和对话框这些"基础设施"问题。

本篇之后,你应该能从零搭建一个"工业级"的 WPF 项目骨架。

本文要回答:

View 和 ViewModel 怎么连?WPF 怎么集成 Microsoft.Extensions.DependencyInjection?对话框、导航、消息这些跨 View 通信怎么处理?


一、MVVM 三层职责

1.1 三层定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Model:
  - 业务实体(领域模型)
  - 数据访问(Repository / Service)
  - 纯业务逻辑,不依赖 UI

ViewModel:
  - View 的"抽象"
  - 暴露属性 + 命令(绑定用)
  - 调用 Model 层的服务
  - 不持有 View 引用

View:
  - XAML + 极少 code-behind
  - 只负责显示和绑定
  - 不写业务逻辑

1.2 数据流

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
   用户操作 UI
  View 触发绑定(Command)
   ViewModel.Execute
   Service(业务逻辑)
   Model(持久化)
   Service 返回数据
   ViewModel 更新属性(INPC)
   View 通过绑定自动更新
1
2
3
4
5
6
7
关键约束:
  - View 不知道 Model 存在
  - ViewModel 不知道 View 存在
  - Model 不知道 ViewModel / View 存在

  依赖方向:View → ViewModel → Service → Model
  反向:通过事件 / 回调 / 消息机制

1.3 与 MVC / MVP 对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
MVC(ASP.NET Core):
  Controller 接收请求 → 调 Model → 返回 View
  View 渲染 → HTML 发给客户端
  → 服务端模式(无状态)

MVP(WinForms 经典):
  View 持有 Presenter
  Presenter 监听 View 事件,更新 View
  → 双向依赖(紧耦合)

MVVM(WPF):
  ViewModel 暴露属性 + 命令
  View 通过"绑定"自动同步
  ViewModel 不持有 View
  → 单向依赖(解耦)

  WPF 绑定是 MVVM 能成立的基础
  → 没绑定,MVVM 做不到(所以 WinForms 用 MVP)

二、View ↔ ViewModel 的连接方式

2.1 方式一:XAML 设置 DataContext

1
2
3
<Window.DataContext>
    <local:MainViewModel/>
</Window.DataContext>
1
2
3
4
5
优点:简单、声明式
缺点:
  - ViewModel 必须有无参构造
  - 不能注入服务
  - 不利于测试

2.2 方式二:Code-behind 设置

1
2
3
4
5
public MainWindow()
{
    InitializeComponent();
    DataContext = new MainViewModel();
}

2.3 方式三:DataTemplate 隐式映射(推荐用于多视图)

1
2
3
4
5
6
7
8
9
<!-- App.xaml -->
<Application.Resources>
    <DataTemplate DataType="{x:Type vm:MainViewModel}">
        <views:MainView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type vm:SettingsViewModel}">
        <views:SettingsView/>
    </DataTemplate>
</Application.Resources>
1
2
<!-- 主窗口只放一个 ContentControl -->
<ContentControl Content="{Binding CurrentView}"/>
1
2
3
4
5
6
效果:
  - CurrentView 是 ViewModel 类型
  - WPF 根据 DataTemplate 自动选对应 View
  - 切换 CurrentView 就切换界面

  这是"导航"的标准模式(详见第 5 节)

2.4 方式四:ViewModelLocator(旧模式)

1
2
3
4
5
// 全局服务定位器
public class ViewModelLocator
{
    public MainViewModel Main => App.Services.GetRequiredService<MainViewModel>();
}
1
2
3
<Window.DataContext>
    <Binding Path="Main" Source="{StaticResource Locator}"/>
</Window.DataContext>
1
2
现在通常被 DI + DataTemplate 替代
了解即可

三、Microsoft.Extensions.DependencyInjection 集成

WPF 完全可以用 ASP.NET Core 同款 DI 容器。

3.1 项目结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
MyApp/
├── App.xaml
├── App.xaml.cs                  ← DI 入口
├── Views/
│   ├── MainWindow.xaml
│   ├── MainWindow.xaml.cs
│   ├── TodoItemView.xaml
│   └── ...
├── ViewModels/
│   ├── MainViewModel.cs
│   ├── TodoItemViewModel.cs
│   └── ...
├── Services/
│   ├── ITodoService.cs
│   ├── TodoService.cs
│   ├── IDialogService.cs
│   └── DialogService.cs
├── Models/
│   └── TodoItem.cs
└── ContainerConfiguration.cs    ← DI 注册

3.2 App.xaml.cs 注入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public partial class App : Application
{
    public static IServiceProvider Services { get; private set; }

    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        // 1. 配置 DI
        var services = new ServiceCollection();
        ConfigureServices(services);
        Services = services.BuildServiceProvider();

        // 2. 解析主窗口(自动注入 MainViewModel)
        var mainWindow = Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }

    private static void ConfigureServices(IServiceCollection services)
    {
        // 服务
        services.AddSingleton<ITodoService, TodoService>();
        services.AddSingleton<IDialogService, DialogService>();
        services.AddSingleton<INavigationService, NavigationService>();

        // ViewModel
        services.AddSingleton<MainViewModel>();
        services.AddTransient<TodoItemViewModel>();

        // 窗口
        services.AddSingleton<MainWindow>();
    }
}
1
2
3
4
5
6
7
关键点:
  - 注意 App.xaml 里的 StartupUri 要去掉(改为 OnStartup 手动显示)
  - 注册顺序不重要(DI 自动解析依赖)
  - 单例(Singleton)vs 瞬态(Transient)vs 作用域(Scoped)
    * Singleton:全局唯一(适合服务、主 ViewModel)
    * Transient:每次 new(适合短生命周期 ViewModel)
    * Scoped:WPF 较少用(无 HTTP 请求边界)

3.3 删除 StartupUri

1
2
3
4
5
6
7
8
9
<!-- App.xaml -->
<Application x:Class="MyApp.App"
             xmlns="..."
             xmlns:x="..."
             ><!-- ← 不要 StartupUri -->
    <Application.Resources>
        <!-- DataTemplate 隐式映射 -->
    </Application.Resources>
</Application>

3.4 MainWindow 接收 ViewModel

1
2
3
4
5
6
7
8
public partial class MainWindow : Window
{
    public MainWindow(MainViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}
1
2
3
DI 解析 MainWindow → 看到构造需要 MainViewModel → 解析 MainViewModel
→ MainViewModel 构造需要 ITodoService → 解析 TodoService
→ 全部注入完成

四、ViewModel 接收服务

4.1 构造函数注入(推荐)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public partial class MainViewModel : ObservableObject
{
    private readonly ITodoService _todoService;
    private readonly IDialogService _dialogService;

    public MainViewModel(ITodoService todoService, IDialogService dialogService)
    {
        _todoService = todoService;
        _dialogService = dialogService;
        LoadCommand = new RelayCommand(Load);
    }

    public ObservableCollection<TodoItem> Todos { get; } = new();
    public ICommand LoadCommand { get; }

    private async void Load()
    {
        var items = await _todoService.GetAllAsync();
        Todos.Clear();
        foreach (var item in items) Todos.Add(item);
    }
}
1
2
3
4
5
6
7
8
优点:
  - 依赖明确(构造签名就是依赖列表)
  - 易测试(mock 服务注入)
  - 编译期检查依赖

缺点:
  - 依赖多了构造函数参数多
  - 但这是"好"的复杂度(强迫你思考)

4.2 服务定位器(不推荐)

1
2
3
4
public MainViewModel()
{
    _todoService = App.Services.GetRequiredService<ITodoService>();
}
1
2
3
4
为什么不好:
  - 隐藏依赖
  - 难测试(依赖全局静态 Services)
  - 容易循环依赖

五、导航服务

WPF 的"导航"通常用 ContentControl + DataTemplate 切换。

5.1 接口定义

1
2
3
4
5
6
public interface INavigationService
{
    ViewModelBase CurrentViewModel { get; }
    event Action CurrentViewModelChanged;
    void NavigateTo<TViewModel>() where TViewModel : ViewModelBase;
}

5.2 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class NavigationService : INavigationService, ObservableObject
{
    private readonly IServiceProvider _services;
    private ViewModelBase _currentViewModel;

    public NavigationService(IServiceProvider services)
    {
        _services = services;
    }

    public ViewModelBase CurrentViewModel
    {
        get => _currentViewModel;
        private set => SetProperty(ref _currentViewModel, value);
    }

    public event Action CurrentViewModelChanged;

    public void NavigateTo<TViewModel>() where TViewModel : ViewModelBase
    {
        // 从 DI 解析 ViewModel
        CurrentViewModel = _services.GetRequiredService<TViewModel>();
        CurrentViewModelChanged?.Invoke();
    }
}

5.3 使用

1
2
3
4
5
6
7
8
9
<!-- App.xaml 注册 DataTemplate -->
<Application.Resources>
    <DataTemplate DataType="{x:Type vm:HomeViewModel}">
        <views:HomeView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type vm:SettingsViewModel}">
        <views:SettingsView/>
    </DataTemplate>
</Application.Resources>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- MainWindow -->
<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="200"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>

    <StackPanel Grid.Column="0">
        <Button Content="主页" Command="{Binding NavigateHomeCommand}"/>
        <Button Content="设置" Command="{Binding NavigateSettingsCommand}"/>
    </StackPanel>

    <!-- 关键:ContentControl 绑定到当前 ViewModel -->
    <ContentControl Grid.Column="1" Content="{Binding Navigation.CurrentViewModel}"/>
</Grid>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public partial class MainViewModel : ObservableObject
{
    private readonly INavigationService _navigation;

    public MainViewModel(INavigationService navigation)
    {
        _navigation = navigation;
        NavigateHomeCommand = new RelayCommand(() => _navigation.NavigateTo<HomeViewModel>());
        NavigateSettingsCommand = new RelayCommand(() => _navigation.NavigateTo<SettingsViewModel>());
    }

    public INavigationService Navigation => _navigation;
    public ICommand NavigateHomeCommand { get; }
    public ICommand NavigateSettingsCommand { get; }
}

六、对话框服务

ViewModel 不能直接 MessageBox.Show(破坏 MVVM)。

6.1 抽象接口

1
2
3
4
5
6
public interface IDialogService
{
    void ShowMessage(string message, string title = "提示");
    bool ShowConfirm(string message, string title = "确认");
    Task<string> ShowInputAsync(string prompt, string defaultValue = "");
}

6.2 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class DialogService : IDialogService
{
    public void ShowMessage(string message, string title = "提示")
        => MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);

    public bool ShowConfirm(string message, string title = "确认")
        => MessageBox.Show(message, title, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes;

    public async Task<string> ShowInputAsync(string prompt, string defaultValue = "")
    {
        // 简化:用 InputBox 风格的自定义窗口
        var dialog = new InputDialog(prompt, defaultValue);
        if (dialog.ShowDialog() == true)
            return dialog.InputValue;
        return null;
    }
}

6.3 ViewModel 使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[RelayCommand]
private async Task DeleteAsync(TodoItem item)
{
    if (item == null) return;

    if (_dialogService.ShowConfirm($"确定删除 {item.Title}?"))
    {
        await _todoService.DeleteAsync(item.Id);
        Todos.Remove(item);
        _dialogService.ShowMessage("删除成功");
    }
}
1
2
3
4
好处:
  - ViewModel 不直接依赖 MessageBox
  - 单元测试可以 mock IDialogService
  - 想换"自定义漂亮对话框",只改 DialogService

七、ViewModel 间通信

7.1 Messager 模式(CommunityToolkit.Mvvm)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class TodoDeletedMessage : ValueChangedMessage<TodoItem>
{
    public TodoDeletedMessage(TodoItem item) : base(item) { }
}

// 发送
public partial class TodoListViewModel : ObservableObject
{
    [RelayCommand]
    private void Delete(TodoItem item)
    {
        WeakReferenceMessenger.Default.Send(new TodoDeletedMessage(item));
    }
}

// 接收
public partial class SummaryViewModel : ObservableObject
{
    public SummaryViewModel()
    {
        WeakReferenceMessenger.Default.Register<TodoDeletedMessage>(this, (r, m) =>
        {
            // 处理消息
            UpdateSummary(m.Value);
        });
    }
}

7.2 弱事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
WeakReferenceMessenger 的关键:
  - 弱引用持有接收者
  - 接收者被 GC 回收,自动取消订阅
  - 不会内存泄漏

vs 普通 C# event:
  - event 强引用
  - 不取消订阅会泄漏

何时用:
  - ViewModel 间广播(多对多)
  - 跨层级事件
  - 不适合:父子 ViewModel 直接通信(直接方法调用更清晰)

7.3 事件聚合器(Prism 的等价物)

如果用 Prism,类似叫 IEventAggregator。Toolkit 的 WeakReferenceMessenger 是更轻量的实现。


八、集合的 MVVM

8.1 ObservableCollection 替代 List

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public partial class TodoListViewModel : ObservableObject
{
    public ObservableCollection<TodoItem> Todos { get; } = new();

    [RelayCommand]
    private async Task LoadAsync()
    {
        var items = await _todoService.GetAllAsync();
        Todos.Clear();
        foreach (var item in items) Todos.Add(item);
    }
}

8.2 后台线程加载数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[RelayCommand]
private async Task LoadAsync()
{
    // 后台线程获取数据
    var items = await _todoService.GetAllAsync();

    // ObservableCollection 的 Add 必须在 UI 线程
    // 但 await 之后已经在 UI 线程(SynchronizationContext)
    Todos.Clear();
    foreach (var item in items) Todos.Add(item);
}

// 或者:跨线程同步
private readonly object _lock = new();
public ObservableCollection<TodoItem> Todos { get; }

public TodoListViewModel()
{
    Todos = new ObservableCollection<TodoItem>();
    BindingOperations.EnableCollectionSynchronization(Todos, _lock);
}

8.3 增量更新 vs 整体替换

1
2
3
4
5
6
// ❌ 性能差:每次 Clear + Add 触发多次通知
Todos.Clear();
foreach (var item in items) Todos.Add(item);

// ✅ 性能好:单次 Reset
Todos = new ObservableCollection<TodoItem>(items);   // 整体替换(要 OnPropertyChanged)

九、设计时数据

让 XAML 设计器显示示例数据,而不是空白。

9.1 d:DataContext

1
2
3
<Window xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        d:DataContext="{d:DesignInstance Type=local:MainViewModel,
                                         IsDesignTimeCreatable=True}">
1
2
3
4
5
6
7
8
9
// MainViewModel 设计时构造(提供示例数据)
public MainViewModel()
{
    if (DesignerProperties.GetIsInDesignMode(new DependencyObject()))
    {
        Todos.Add(new TodoItem { Title = "示例任务", Completed = false });
        Todos.Add(new TodoItem { Title = "另一个任务", Completed = true });
    }
}

9.2 d:DesignData

1
<Window d:DataContext="{d:DesignData Source=SampleData.xaml}">
1
2
3
4
5
6
好处:
  - VS/Blend 设计器立即显示效果
  - 不需要运行就能看到 UI 布局
  - 设计师友好

实际编译时 d: 前缀的内容会被忽略

十、实战:TodoApp 完整工程

把全部组合起来。

10.1 项目结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
TodoApp/
├── App.xaml / App.xaml.cs
├── Models/TodoItem.cs
├── Services/
│   ├── ITodoService.cs
│   ├── InMemoryTodoService.cs
│   ├── IDialogService.cs
│   ├── DialogService.cs
│   └── INavigationService.cs
├── ViewModels/
│   ├── ViewModelBase.cs
│   ├── MainViewModel.cs
│   ├── HomeViewModel.cs
│   └── SettingsViewModel.cs
└── Views/
    ├── MainWindow.xaml / .cs
    ├── HomeView.xaml
    ├── SettingsView.xaml
    └── Controls (TodoItemView.xaml)

10.2 Model

1
2
3
4
5
6
7
public class TodoItem
{
    public int Id { get; set; }
    public string Title { get; set; }
    public bool Completed { get; set; }
    public DateTime CreatedAt { get; set; }
}

10.3 Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public interface ITodoService
{
    Task<List<TodoItem>> GetAllAsync();
    Task<TodoItem> AddAsync(string title);
    Task DeleteAsync(int id);
    Task ToggleAsync(int id);
}

public class InMemoryTodoService : ITodoService
{
    private readonly List<TodoItem> _items = new();
    private int _nextId = 1;

    public Task<List<TodoItem>> GetAllAsync() => Task.FromResult(_items.ToList());

    public Task<TodoItem> AddAsync(string title)
    {
        var item = new TodoItem
        {
            Id = _nextId++,
            Title = title,
            CreatedAt = DateTime.Now
        };
        _items.Add(item);
        return Task.FromResult(item);
    }

    public Task DeleteAsync(int id)
    {
        _items.RemoveAll(x => x.Id == id);
        return Task.CompletedTask;
    }

    public Task ToggleAsync(int id)
    {
        var item = _items.FirstOrDefault(x => x.Id == id);
        if (item != null) item.Completed = !item.Completed;
        return Task.CompletedTask;
    }
}

10.4 ViewModel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public abstract class ViewModelBase : ObservableObject { }

public partial class HomeViewModel : ViewModelBase
{
    private readonly ITodoService _todoService;
    private readonly IDialogService _dialogService;

    public ObservableCollection<TodoItem> Todos { get; } = new();

    [ObservableProperty]
    private string _newTodoTitle = "";

    public HomeViewModel(ITodoService todoService, IDialogService dialogService)
    {
        _todoService = todoService;
        _dialogService = dialogService;
        _ = LoadAsync();
    }

    [RelayCommand]
    private async Task LoadAsync()
    {
        var items = await _todoService.GetAllAsync();
        Todos.Clear();
        foreach (var item in items) Todos.Add(item);
    }

    [RelayCommand(CanExecute = nameof(CanAdd))]
    private async Task AddAsync()
    {
        var item = await _todoService.AddAsync(NewTodoTitle);
        Todos.Add(item);
        NewTodoTitle = "";
    }

    private bool CanAdd() => !string.IsNullOrWhiteSpace(NewTodoTitle);

    [RelayCommand]
    private async Task DeleteAsync(TodoItem item)
    {
        if (item == null) return;
        if (_dialogService.ShowConfirm($"删除 \"{item.Title}\"?"))
        {
            await _todoService.DeleteAsync(item.Id);
            Todos.Remove(item);
        }
    }

    [RelayCommand]
    private async Task ToggleAsync(TodoItem item)
    {
        if (item == null) return;
        await _todoService.ToggleAsync(item.Id);
        item.Completed = !item.Completed;
    }

    partial void OnNewTodoTitleChanged(string value)
        => AddCommand.NotifyCanExecuteChanged();
}

public partial class SettingsViewModel : ViewModelBase
{
    [ObservableProperty]
    private bool _confirmOnDelete = true;

    [ObservableProperty]
    private bool _autoSave = true;
}

public partial class MainViewModel : ViewModelBase
{
    public INavigationService Navigation { get; }

    public MainViewModel(INavigationService navigation)
    {
        Navigation = navigation;
        Navigation.NavigateTo<HomeViewModel>();
    }

    [RelayCommand] private void GoHome() => Navigation.NavigateTo<HomeViewModel>();
    [RelayCommand] private void GoSettings() => Navigation.NavigateTo<SettingsViewModel>();
}

10.5 App + DI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public partial class App : Application
{
    public static IServiceProvider Services { get; private set; }

    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        var services = new ServiceCollection();
        ConfigureServices(services);
        Services = services.BuildServiceProvider();

        var mainWindow = Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }

    private void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<ITodoService, InMemoryTodoService>();
        services.AddSingleton<IDialogService, DialogService>();
        services.AddSingleton<INavigationService, NavigationService>();

        services.AddSingleton<MainViewModel>();
        services.AddTransient<HomeViewModel>();
        services.AddTransient<SettingsViewModel>();

        services.AddSingleton<MainWindow>();
    }
}

10.6 XAML(精简)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!-- App.xaml -->
<Application.Resources>
    <DataTemplate DataType="{x:Type vm:HomeViewModel}">
        <views:HomeView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type vm:SettingsViewModel}">
        <views:SettingsView/>
    </DataTemplate>
</Application.Resources>

<!-- MainWindow.xaml -->
<Window x:Class="TodoApp.MainWindow"
        Title="TodoApp" Width="800" Height="500">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="180"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <StackPanel Grid.Column="0" Background="#F0F0F0">
            <Button Content="主页" Command="{Binding GoHomeCommand}" Margin="8"/>
            <Button Content="设置" Command="{Binding GoSettingsCommand}" Margin="8"/>
        </StackPanel>

        <ContentControl Grid.Column="1" Content="{Binding Navigation.CurrentViewModel}"/>
    </Grid>
</Window>

<!-- HomeView.xaml -->
<UserControl xmlns="...">
    <Grid Margin="12">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
            <TextBox Width="300" Text="{Binding NewTodoTitle, UpdateSourceTrigger=PropertyChanged}"/>
            <Button Content="添加" Command="{Binding AddCommand}" Margin="8,0,0,0"/>
        </StackPanel>

        <ListBox Grid.Row="1" ItemsSource="{Binding Todos}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Grid Margin="4">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="30"/>
                            <ColumnDefinition Width="*"/>
                            <ColumnDefinition Width="Auto"/>
                        </Grid.ColumnDefinitions>
                        <CheckBox Grid.Column="0" IsChecked="{Binding Completed}"/>
                        <TextBlock Grid.Column="1" Text="{Binding Title}"/>
                        <Button Grid.Column="2" Content="✕"
                                Command="{Binding DataContext.DeleteCommand,
                                                  RelativeSource={RelativeSource AncestorType=UserControl}}"
                                CommandParameter="{Binding}"/>
                    </Grid>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </Grid>
</UserControl>

10.7 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
✅ MVVM 三层(Model / ViewModel / View)
✅ DI 容器集成(Microsoft.Extensions.DependencyInjection)
✅ ViewModel 构造函数注入服务
✅ ContentControl + DataTemplate 导航
✅ INavigationService 抽象导航
✅ IDialogService 抽象对话框
✅ ObservableCollection + 异步加载
✅ CommandParameter 传参(Delete item)
✅ 相对源绑定(AncestorType=UserControl 找 ViewModel)
✅ [ObservableProperty] + [RelayCommand]
✅ partial 钩子(OnNewTodoTitleChanged 通知 CanExecute)

观察:
  - 完全无业务 code-behind
  - ViewModel 可单元测试(mock ITodoService / IDialogService)
  - 导航切换是数据驱动(改 ViewModel,UI 自动切)

十一、单元测试

MVVM 的最大收益之一。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[Fact]
public async Task AddAsync_WithValidTitle_AddsToCollection()
{
    // Arrange
    var todoService = new Mock<ITodoService>();
    todoService.Setup(s => s.AddAsync("New task"))
               .ReturnsAsync(new TodoItem { Id = 1, Title = "New task" });

    var dialogService = new Mock<IDialogService>();
    var vm = new HomeViewModel(todoService.Object, dialogService.Object);
    vm.NewTodoTitle = "New task";

    // Act
    await vm.AddCommand.ExecuteAsync(null);

    // Assert
    Assert.Single(vm.Todos);
    Assert.Equal("New task", vm.Todos[0].Title);
    Assert.Equal("", vm.NewTodoTitle);
}

[Fact]
public void AddCommand_CannotExecute_WhenTitleEmpty()
{
    var vm = new HomeViewModel(Mock.Of<ITodoService>(), Mock.Of<IDialogService>());
    vm.NewTodoTitle = "";

    Assert.False(vm.AddCommand.CanExecute(null));
}
1
2
没有 UI、没有 MessageBox、没有视觉树
完全纯 .NET 测试 → 快、稳、可 CI

十二、常见陷阱

12.1 ViewModel 持有 View 引用

1
2
3
4
5
// ❌ ViewModel 直接拿 Window
public class MainViewModel
{
    private readonly MainWindow _window;   // 反模式!
}
1
2
3
4
为什么不行:
  - ViewModel 不应该知道 View 存在
  - 一旦依赖 View,无法跨 View 复用 ViewModel
  - 单元测试要 mock View,复杂

12.2 code-behind 写业务

1
2
3
4
5
6
// ❌ Button.Click 在 code-behind 里写业务
private void SaveButton_Click(...)
{
    if (string.IsNullOrEmpty(nameBox.Text)) return;
    _service.Save(nameBox.Text);
}
1
2
3
✅ 用 Command + Binding
SaveCommand 处理业务逻辑
nameBox.Text 绑定到 ViewModel.Name

12.3 服务注册遗漏

1
2
3
services.AddSingleton<MainViewModel>();
// ❌ 忘了注册 ITodoService
// 解析 MainViewModel 时报错

12.4 Singleton ViewModel 的状态泄漏

1
2
3
4
5
6
// Singleton 的 HomeViewModel 全局唯一
// → Todos 集合在所有"主页"间共享
// → 用户切换出去再回来,状态保留(这通常是你想要的)

// 但如果要"每次进入都新状态",用 Transient
services.AddTransient<HomeViewModel>();

12.5 异步 void

1
2
3
4
5
6
// ❌ 异步 void 异常会崩溃
private async void Load() { ... }

// ✅ 异步 Task(命令内部)
[RelayCommand]
private async Task LoadAsync() { ... }

十三、与 ASP.NET Core 的对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ASP.NET Core                WPF MVVM
──────────────────────────────────────────────
Controller                  ViewModel
View (Razor)                View (XAML)
Service                     Service(同样)
Repository                  Repository(同样)
DI Container                DI Container(同样)
Middleware                  (WPF 无等价)
Route                       DataTemplate(隐式映射)
ModelState.IsValid          INotifyDataErrorInfo
HttpClient                  HttpClient(同样)
Logger                      Logger(同样)

关键差异:
  - ASP.NET Core Controller 每请求新建
  - WPF ViewModel 通常 Singleton 或 Transient
  - ASP.NET Core 的 View 在服务端渲染
  - WPF 的 View 在客户端常驻 + 绑定

后端工程师视角:
  ViewModel = Controller(一个屏幕一个)
  Service = Application Service / Domain Service
  View = Razor Page(绑定模型字段)

十四、小结

本文把 MVVM 落到工程实践:

  • MVVM 三层职责与单向依赖
  • View ↔ ViewModel 的四种连接方式
  • Microsoft.Extensions.DependencyInjection 集成 WPF
  • ViewModel 构造函数注入服务
  • 导航服务(ContentControl + DataTemplate)
  • 对话框服务(IDialogService 抽象)
  • ViewModel 间通信(WeakReferenceMessenger)
  • 设计时数据(d:DataContext)
  • 实战:TodoApp 完整工程
  • 单元测试 ViewModel
1
2
3
4
记住三句话:
  1. ViewModel 永远不知道 View 存在——这是 MVVM 的红线
  2. DI 容器在 WPF 用法和 ASP.NET Core 一样——直接搬过来
  3. ContentControl + DataTemplate 是导航的标准武器——别用 Window.Show

下一篇进入 WPF 的"换肤"机制——样式与模板。ResourceDictionary、Style、ControlTemplate、DataTemplate、Trigger 全家桶。

Licensed under CC BY-NC-SA 4.0