写在前面
版本说明:基于 .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 间通信
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 全家桶。