# WPF-MVVM-Learning
**Repository Path**: tendercool/wpf-mvvm-learning
## Basic Information
- **Project Name**: WPF-MVVM-Learning
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-08-05
- **Last Updated**: 2026-08-17
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# WPF 与 MVVM 学习路线
本文用于在已有 C# 基础上学习 WPF 和 MVVM。
最终项目以 `DailyNotes` 为主线,将控制台版本逐步迁移为桌面应用。
## 学习原则
不要一开始就追求完整架构,推荐按照以下顺序学习:
1. 先用 XAML 和代码后置完成界面;
2. 掌握控件、布局和事件;
3. 使用数据绑定代替直接操作控件;
4. 手动实现属性通知和 Command;
5. 将代码后置版重构为 MVVM;
6. 增加 Repository、Service、JSON 和异步;
7. 最后再使用 MVVM Toolkit 或 Prism 减少模板代码。
推荐演进路线:
代码后置
→ Data Binding
→ INotifyPropertyChanged
→ ObservableCollection
→ ICommand
→ ViewModel
→ Service
→ Repository
→ JSON 异步存储
→ 依赖注入
---
# 学习目标
完成后应掌握:
- WPF 项目结构和启动过程
- XAML 基础语法
- 常用控件和布局容器
- 事件和代码后置
- DataContext
- Data Binding
- Binding Mode
- DataTemplate
- INotifyPropertyChanged
- ObservableCollection
- SelectedItem
- ICommand
- MVVM 分层
- 搜索、过滤和排序
- ICollectionView
- Converter 和 DataTrigger
- Style 和 ResourceDictionary
- 输入验证
- Repository 和 Service
- JSON 异步加载与保存
- UI 线程和 Dispatcher
- 对话框服务
- 依赖注入
- ViewModel 单元测试
---
# 第 0 组:WPF 项目准备
## 0.1 创建 WPF 项目
目标:能够创建并运行 WPF 程序。
项目文件应包含:
WinExe
net9.0-windows
true
enable
enable
需要理解:
- `WinExe` 和 `Exe` 的区别;
- `net9.0` 和 `net9.0-windows` 的区别;
- `UseWPF` 的作用;
- WPF 为什么只能运行在 Windows 上。
## 0.2 理解项目结构
重点文件:
App.xaml
App.xaml.cs
MainWindow.xaml
MainWindow.xaml.cs
DailyNotes.csproj
需要理解:
- `App.xaml` 是应用入口和应用级资源;
- `StartupUri` 决定初始窗口;
- XAML 用于描述界面;
- `.xaml.cs` 是代码后置;
- `x:Class` 如何关联 C# 类;
- `InitializeComponent()` 的作用。
---
# 第 1 组:XAML 和基础控件
## 1. TextBlock、TextBox 和 Button
需求:
- 使用 `TextBlock` 显示标签;
- 使用 `TextBox` 输入标题;
- 使用 `Button` 提交;
- 点击按钮后用 `MessageBox` 显示输入内容。
需要复习:
- `x:Name`
- 控件属性
- `Click` 事件
- `RoutedEventArgs`
- XAML 与代码后置的关系
这一阶段允许使用代码后置。
## 2. 登录界面
需求:
- 输入用户名和密码;
- 用户名为 `admin`、密码为 `123456` 时提示成功;
- 否则提示失败;
- 输入为空时给出提示。
建议控件:
TextBlock
TextBox
PasswordBox
Button
需要理解:
- `PasswordBox.Password`
- 输入验证
- `MessageBox`
- 为什么 PasswordBox 的绑定比较特殊
---
# 第 2 组:WPF 布局
## 3. StackPanel
需求:
- 垂直排列标签和输入框;
- 水平排列保存和取消按钮;
- 调整 Margin 和对齐方式。
需要复习:
- `Orientation`
- `Margin`
- `Padding`
- `HorizontalAlignment`
- `VerticalAlignment`
## 4. Grid
需求:
- 创建两列表单;
- 第一列放标签;
- 第二列放输入框;
- 创建标题、内容和按钮三行。
需要理解:
- `Grid.Row`
- `Grid.Column`
- `RowDefinition`
- `ColumnDefinition`
- 固定尺寸
- `Auto`
- `*`
## 5. DailyNotes 主界面布局
目标界面:
┌──────────────────────────────────────┐
│ 搜索框、新增、删除、保存 │
├──────────────┬───────────────────────┤
│ Note 列表 │ Note 编辑区域 │
│ │ 标题 │
│ │ 内容 │
└──────────────┴───────────────────────┘
建议使用:
- Grid
- Border
- GridSplitter
- StackPanel
- DockPanel
---
# 第 3 组:集合控件
## 6. ListBox 显示字符串
需求:
- 创建字符串集合;
- 绑定或赋值给 ListBox;
- 点击列表项后显示选中内容。
需要复习:
- `ItemsSource`
- `SelectedItem`
- `SelectionChanged`
## 7. ListBox 显示 Note
Note 包含:
- Id
- Title
- Content
- CreatedAt
需求:
- ListBox 显示 Note 集合;
- 列表只显示标题;
- 选中 Note 后显示详情。
先使用:
DisplayMemberPath="Title"
需要复习:
- 对象集合
- `DisplayMemberPath`
- `SelectedItem`
- 可空引用类型
## 8. DataTemplate
将 `DisplayMemberPath` 替换为 `ItemTemplate`。
列表项显示:
- 标题
- 创建时间
- 内容摘要
需要理解:
- `DataTemplate`
- 模板中的 DataContext
- `{Binding Title}`
- 一个数据对象如何生成一项 UI
---
# 第 4 组:Data Binding
## 9. DataContext
创建 MainViewModel,并设置到窗口的 DataContext。
需求:
- ViewModel 提供 Title 属性;
- TextBox 绑定 Title;
- 不再通过控件名称读取标题。
需要理解:
- Binding 从 DataContext 查找属性;
- DataContext 会沿视觉树或逻辑树继承;
- View 不需要知道 ViewModel 属性的具体存储方式。
## 10. Binding Mode
分别练习:
- OneWay
- TwoWay
- OneTime
- OneWayToSource
同时测试:
- `UpdateSourceTrigger=LostFocus`
- `UpdateSourceTrigger=PropertyChanged`
需要回答:
- TextBox 内容何时写回 ViewModel?
- ViewModel 属性变化后 UI 是否更新?
- Binding 写错时在哪里查看错误?
---
# 第 5 组:属性变化通知
## 11. INotifyPropertyChanged
手动实现一次属性通知。
需要理解:
- 普通 C# 属性改变后,UI 为什么不一定更新;
- `PropertyChanged` 事件的作用;
- `CallerMemberName` 的作用;
- setter 中为什么要比较新旧值。
## 12. ViewModelBase
将通知逻辑提取为基类:
ViewModelBase
├── PropertyChanged
├── OnPropertyChanged
└── SetProperty
需要复习:
- 泛型
- `ref`
- 基类复用
- `EqualityComparer`
---
# 第 6 组:ObservableCollection
## 13. List 与 ObservableCollection
先使用 `List`,再改成 `ObservableCollection`。
测试:
- 新增 Note 后列表是否刷新;
- 删除 Note 后列表是否刷新;
- 修改 Note.Title 后列表项是否刷新。
需要区分:
INotifyPropertyChanged
→ 对象属性发生改变
ObservableCollection
→ 集合新增、删除或重置
注意:ObservableCollection 不会自动替集合元素实现属性通知。
## 14. SelectedNote
ViewModel 提供可空的 SelectedNote 属性。
需求:
- 左侧 ListBox 双向绑定 SelectedItem;
- 右侧显示 SelectedNote 的标题和内容;
- 没有选择时禁用编辑区;
- 删除后清空选择。
---
# 第 7 组:ICommand
## 15. 从 Click 迁移到 Command
演进顺序:
Button Click
→ ICommand
→ RelayCommand
→ ViewModel Command
需要手动实现一次:
- Execute
- CanExecute
- CanExecuteChanged
- RaiseCanExecuteChanged
需要理解:
- Command 和 Click 事件的区别;
- 为什么 CanExecute 为 false 时按钮会禁用;
- 哪些状态变化后需要重新计算 CanExecute。
## 16. AddNoteCommand
新增命令负责:
- 验证标题;
- 创建 Note;
- 添加到 ObservableCollection;
- 自动选中新 Note;
- 清空新增输入。
## 17. DeleteNoteCommand
删除命令负责:
- 未选择 Note 时不可执行;
- 删除前确认;
- 调用 Service 或 Repository;
- 删除后清空 SelectedNote;
- 删除失败时显示错误。
学习阶段可以暂时直接使用 MessageBox,之后再抽象对话框服务。
---
# 第 8 组:MVVM 分层
这一组要把前面散落在 `MainWindow.xaml.cs` 中的逻辑拆出来,但不要一次性追求“完美架构”。推荐顺序仍然和 C# 基础 README 一样:
1. 先让界面能跑;
2. 再把重复逻辑抽成方法;
3. 再把页面状态抽成 ViewModel;
4. 最后才加入 Service、Repository、JSON 和异步。
## 18. Model
Model 负责:
- 数据结构;
- 领域状态;
- 必要的领域规则。
Model 不应包含:
- Button
- TextBox
- MessageBox
- 页面导航
- Window 引用
## 19. View
View 负责:
- XAML
- 布局
- Style
- DataTemplate
- 纯 UI 行为
View 不应负责:
- ID 生成
- JSON 序列化
- 搜索算法
- 删除业务规则
## 20. ViewModel
ViewModel 负责:
- 页面状态
- 页面数据
- SelectedNote
- SearchText
- IsBusy
- ErrorMessage
- Commands
- 调用 Service
ViewModel 不应直接操作:
- TextBox
- ListBox
- 控件名称
- 具体文件格式
## 21. Service 和 Repository
Service 负责:
- 业务验证
- ID 生成
- 新增和删除规则
- 调用 Repository
Repository 负责:
- 数据存取
- 内存集合
- 文件
- 数据库
- 数据源相关查询
典型调用关系:
View
→ MainViewModel
→ NoteService
→ INoteRepository
→ JsonNoteRepository
---
# 关键代码段:手写 MVVM 基础设施
这些代码段不是要求一开始全部写完,而是在对应阶段逐步引入。学习重点是理解它们分别解决什么问题。
## Note Model
```csharp
public class Note
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.Now;
public string Summary
{
get
{
if (Content.Length <= 40)
{
return Content;
}
return Content[..40] + "...";
}
}
}
```
Model 只描述数据和少量领域规则,不引用 WPF 控件。
## ViewModelBase
```csharp
using System.ComponentModel;
using System.Runtime.CompilerServices;
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetProperty(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
```
它解决的是“ViewModel 属性变了以后,Binding 如何知道要刷新 UI”。
## RelayCommand
```csharp
using System.Windows.Input;
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func? _canExecute;
public RelayCommand(Action execute, Func? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke() ?? true;
}
public void Execute(object? parameter)
{
_execute();
}
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
```
它解决的是“按钮点击后如何调用 ViewModel,并且按钮什么时候应该禁用”。
## MainViewModel 最小版本
```csharp
using System.Collections.ObjectModel;
using System.Windows.Input;
public class MainViewModel : ViewModelBase
{
private string _newTitle = "";
private string _newContent = "";
private Note? _selectedNote;
private int _nextId = 1;
public MainViewModel()
{
AddNoteCommand = new RelayCommand(AddNote, CanAddNote);
DeleteNoteCommand = new RelayCommand(DeleteNote, CanDeleteNote);
}
public ObservableCollection Notes { get; } = new();
public string NewTitle
{
get => _newTitle;
set
{
if (SetProperty(ref _newTitle, value))
{
((RelayCommand)AddNoteCommand).RaiseCanExecuteChanged();
}
}
}
public string NewContent
{
get => _newContent;
set => SetProperty(ref _newContent, value);
}
public Note? SelectedNote
{
get => _selectedNote;
set
{
if (SetProperty(ref _selectedNote, value))
{
((RelayCommand)DeleteNoteCommand).RaiseCanExecuteChanged();
}
}
}
public ICommand AddNoteCommand { get; }
public ICommand DeleteNoteCommand { get; }
private bool CanAddNote()
{
return !string.IsNullOrWhiteSpace(NewTitle);
}
private void AddNote()
{
Note note = new()
{
Id = _nextId++,
Title = NewTitle.Trim(),
Content = NewContent.Trim(),
CreatedAt = DateTime.Now
};
Notes.Add(note);
SelectedNote = note;
NewTitle = "";
NewContent = "";
}
private bool CanDeleteNote()
{
return SelectedNote is not null;
}
private void DeleteNote()
{
if (SelectedNote is null)
{
return;
}
Notes.Remove(SelectedNote);
SelectedNote = null;
}
}
```
这是 MVVM 的第一版,不需要 Repository,也不需要 DI。先用它验证 Binding、ObservableCollection、SelectedItem 和 Command。
## MainWindow.xaml 绑定示例
```xml
```
窗口构造函数中先手动设置 DataContext:
```csharp
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
```
等这一版跑通后,再把 `AddNote`、`DeleteNote` 中的业务逻辑移动到 `NoteService`。
## Repository 和 Service 示例
```csharp
public interface INoteRepository
{
Task> GetAllAsync();
Task SaveAllAsync(IEnumerable notes);
}
```
```csharp
using System.Text.Json;
public class JsonNoteRepository : INoteRepository
{
private readonly string _filePath;
public JsonNoteRepository(string filePath)
{
_filePath = filePath;
}
public async Task> GetAllAsync()
{
if (!File.Exists(_filePath))
{
return [];
}
string json = await File.ReadAllTextAsync(_filePath);
return JsonSerializer.Deserialize>(json) ?? [];
}
public async Task SaveAllAsync(IEnumerable notes)
{
JsonSerializerOptions options = new()
{
WriteIndented = true
};
string json = JsonSerializer.Serialize(notes, options);
await File.WriteAllTextAsync(_filePath, json);
}
}
```
```csharp
public class NoteService
{
private readonly INoteRepository _repository;
public NoteService(INoteRepository repository)
{
_repository = repository;
}
public async Task> LoadAsync()
{
return await _repository.GetAllAsync();
}
public async Task SaveAsync(IEnumerable notes)
{
await _repository.SaveAllAsync(notes);
}
}
```
这一步的判断标准是:如果以后从 JSON 换成数据库,ViewModel 不需要知道文件格式变化。
# 第 9 组:搜索与排序
## 22. SearchText
需求:
- 输入关键字后实时搜索;
- 同时搜索标题和正文;
- 忽略大小写;
- 清空搜索框后显示全部内容。
需要复习:
- `UpdateSourceTrigger=PropertyChanged`
- LINQ Where
- `StringComparison.OrdinalIgnoreCase`
## 23. ICollectionView
使用集合视图进行 UI 过滤和排序。
需要理解:
- 原始 ObservableCollection 保存完整数据;
- ICollectionView 是 UI 的观察视图;
- Filter 不需要删除原始数据;
- Refresh 用于重新执行过滤。
## 24. 排序
需求:
- 最新创建优先;
- 最早创建优先;
- 通过 ComboBox 切换排序方式。
需要复习:
- SortDescription
- ListSortDirection
- `nameof`
- UI 排序和 Repository 查询排序的区别
---
# 第 10 组:Converter、Trigger 和 Style
## 25. Converter
练习:
- BooleanToVisibilityConverter
- NullToBooleanConverter
- 日期显示格式
优先考虑 Binding 的 `StringFormat`,简单格式不要过度创建 Converter。
## 26. DataTrigger
需求:
- IsBusy 时禁用操作区;
- 未选择 Note 时隐藏详情;
- 内容为空时显示警告;
- 选中项改变样式。
## 27. Style
先在 Window.Resources 定义,再移动到 App.xaml。
需要复习:
- StaticResource
- 带 Key 的 Style
- 隐式 Style
- Setter
- Trigger
- 资源查找范围
## 28. ResourceDictionary
建议结构:
Resources/
Colors.xaml
ButtonStyles.xaml
TextBoxStyles.xaml
需要理解:
- MergedDictionaries
- Source URI
- 资源覆盖顺序
- 应用级主题
---
# 第 11 组:输入验证
## 29. 基础验证
要求:
- 标题不能为空;
- 标题长度有限制;
- 内容长度有限制;
- 验证失败时不保存;
- 显示 ErrorMessage。
## 30. INotifyDataErrorInfo
进阶练习:
- 输入改变时自动验证;
- 在输入框旁显示错误;
- HasErrors 为 true 时禁用保存;
- 使用 Validation ErrorTemplate。
---
# 第 12 组:Repository 和 JSON
## 31. INoteRepository
建议提供:
- GetAllAsync
- AddAsync
- UpdateAsync
- DeleteAsync
- SaveAsync
需要理解:
- Repository 不应直接暴露内部可变集合;
- 异步方法以 Async 结尾;
- ViewModel 依赖接口而不是具体实现。
## 32. MemoryNoteRepository
先实现内存版本。
目标:
- 验证 ViewModel 和 Service 逻辑;
- 不依赖磁盘文件;
- 便于单元测试;
- 后续可替换为 JSON Repository。
## 33. JsonNoteRepository
需求:
- 文件不存在时返回空集合;
- JSON 为空时返回空集合;
- JSON 损坏时显示友好错误;
- 使用异步文件 API;
- 保存时格式化 JSON。
重点异常:
- JsonException
- IOException
- UnauthorizedAccessException
---
# 第 13 组:异步和 UI 状态
## 34. Async Command
需求:
- 异步加载;
- 异步保存;
- 执行期间 IsBusy 为 true;
- 防止重复点击;
- 显示错误信息;
- finally 中恢复 IsBusy。
需要理解:
- 业务异步方法优先返回 Task;
- 不随意使用 async void;
- I/O 异步不需要 Task.Run;
- UI 不应被文件读写阻塞。
## 35. Dispatcher
需要理解:
- WPF 控件属于 UI 线程;
- 后台线程不能直接修改控件;
- await 后通常会回到 UI 上下文;
- 什么情况下才需要 Dispatcher;
- 不要为了“异步”随意创建线程。
---
# 第 14 组:对话框和多 View
## 36. DialogService
抽象:
IDialogService
├── Confirm
└── ShowMessage
目标:
- ViewModel 不直接依赖 MessageBox;
- 删除操作可以确认;
- ViewModel 更容易测试。
## 37. UserControl
将主界面拆分:
Views/
NoteListView.xaml
NoteEditorView.xaml
需要注意:
- UserControl 通常不应在构造函数中覆盖外部 DataContext;
- 子 View 可以继承父级 DataContext;
- 也可以显式绑定子 ViewModel。
## 38. 编辑窗口
需求:
- 新增和编辑使用独立窗口;
- 保存后返回成功结果;
- 取消时不修改原始 Note;
- 使用临时编辑副本。
需要复习:
- Show
- ShowDialog
- DialogResult
- Owner
---
# 第 15 组:依赖注入
## 39. 手动依赖注入
先在 App.xaml.cs 中手动组装:
JsonNoteRepository
→ NoteService
→ MainViewModel
→ MainWindow
需要理解:
- 构造函数注入;
- Composition Root;
- ViewModel 不应自己创建 Repository。
## 40. DI 容器
理解手动注入后,再使用依赖注入容器。
需要复习:
- Register
- Resolve
- Singleton
- Transient
- 服务生命周期
---
# 第 16 组:ViewModel 测试
## 41. 单元测试
建议测试:
- 标题为空时不能新增;
- 新增后集合数量增加;
- 新增后自动选中新 Note;
- 未选择时不能删除;
- 删除失败时显示错误;
- 搜索条件改变时刷新结果;
- 加载失败后 IsBusy 恢复;
- 保存命令调用 Repository。
测试目标是 ViewModel 状态和业务结果,不是控件外观。
---
# 最终项目:WPF DailyNotes
## 功能要求
- Note 列表
- Note 详情
- 新增
- 编辑
- 删除
- 搜索
- 排序
- JSON 加载
- JSON 保存
- 关闭前保存
- 删除确认
- 空数据状态
- IsBusy 状态
- 错误信息
## MVVM 要求
- View 不包含业务逻辑;
- ViewModel 不直接访问控件;
- 按钮使用 Command;
- 属性实现变化通知;
- 集合使用 ObservableCollection;
- Repository 负责存取;
- Service 负责业务规则;
- 文件操作使用异步 API;
- 依赖通过构造函数注入。
## 推荐结构
DailyNotes/
App.xaml
App.xaml.cs
Models/
Note.cs
Views/
MainWindow.xaml
NoteListView.xaml
NoteEditorView.xaml
ViewModels/
ViewModelBase.cs
MainViewModel.cs
NoteEditorViewModel.cs
Commands/
RelayCommand.cs
AsyncRelayCommand.cs
Interfaces/
INoteRepository.cs
INoteService.cs
IDialogService.cs
Repositories/
MemoryNoteRepository.cs
JsonNoteRepository.cs
Services/
NoteService.cs
DialogService.cs
Converters/
NullToBooleanConverter.cs
Resources/
Colors.xaml
Styles.xaml
---
# 每一步的最小示例代码
本节按前面的步骤编号提供最小代码。实际练习时不要一次全部复制,做到哪一步就只看哪一步。
## 0.1 创建 WPF 项目
```powershell
dotnet new wpf -n DailyNotes -f net9.0-windows
dotnet run --project .\DailyNotes\DailyNotes.csproj
```
```xml
WinExe
net9.0-windows
true
enable
enable
```
## 0.2 理解项目结构
```xml
```
```csharp
// MainWindow.xaml.cs
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
```
## 1. TextBlock、TextBox 和 Button
```xml
```
```csharp
private void ShowButton_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show(TitleTextBox.Text);
}
```
## 2. 登录界面
```xml
```
```csharp
private void LoginButton_Click(object sender, RoutedEventArgs e)
{
bool success = UsernameTextBox.Text == "admin"
&& PasswordInput.Password == "123456";
MessageBox.Show(success ? "Login success" : "Login failed");
}
```
## 3. StackPanel
```xml
```
## 4. Grid
```xml
```
## 5. DailyNotes 主界面布局
```xml
```
## 6. ListBox 显示字符串
```xml
```
```csharp
public MainWindow()
{
InitializeComponent();
NotesListBox.ItemsSource = new List { "Learn WPF", "Practice Binding" };
}
private void NotesListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
MessageBox.Show(NotesListBox.SelectedItem?.ToString());
}
```
## 7. ListBox 显示 Note
```csharp
public class Note
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.Now;
}
```
```xml
```
## 8. DataTemplate
```xml
```
## 9. DataContext
```csharp
public class MainViewModel
{
public string Title { get; set; } = "DailyNotes";
}
```
```csharp
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
```
```xml
```
## 10. Binding Mode
```xml
```
## 11. INotifyPropertyChanged
```csharp
public class MainViewModel : INotifyPropertyChanged
{
private string _title = "";
public event PropertyChangedEventHandler? PropertyChanged;
public string Title
{
get => _title;
set
{
if (_title == value)
{
return;
}
_title = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Title)));
}
}
}
```
## 12. ViewModelBase
```csharp
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer.Default.Equals(field, value))
{
return false;
}
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
return true;
}
}
```
## 13. List 与 ObservableCollection
```csharp
public class MainViewModel : ViewModelBase
{
public ObservableCollection Notes { get; } = new();
public void AddSample()
{
Notes.Add(new Note { Id = 1, Title = "New note" });
}
}
```
```xml
```
## 14. SelectedNote
```csharp
private Note? _selectedNote;
public Note? SelectedNote
{
get => _selectedNote;
set => SetProperty(ref _selectedNote, value);
}
```
```xml
```
## 15. 从 Click 迁移到 Command
```csharp
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func? _canExecute;
public RelayCommand(Action execute, Func? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
```
```xml
```
## 16. AddNoteCommand
```csharp
public ICommand AddNoteCommand { get; }
private string _newTitle = "";
public string NewTitle
{
get => _newTitle;
set => SetProperty(ref _newTitle, value);
}
private void AddNote()
{
Notes.Add(new Note { Id = _nextId++, Title = NewTitle.Trim() });
NewTitle = "";
}
private bool CanAddNote()
{
return !string.IsNullOrWhiteSpace(NewTitle);
}
```
## 17. DeleteNoteCommand
```csharp
public ICommand DeleteNoteCommand { get; }
private void DeleteNote()
{
if (SelectedNote is null)
{
return;
}
Notes.Remove(SelectedNote);
SelectedNote = null;
}
private bool CanDeleteNote()
{
return SelectedNote is not null;
}
```
## 18. Model
```csharp
public class Note
{
public int Id { get; init; }
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public DateTime CreatedAt { get; init; } = DateTime.Now;
public bool HasTitle()
{
return !string.IsNullOrWhiteSpace(Title);
}
}
```
## 19. View
```xml
```
View 只声明控件和绑定,不生成 ID、不读写 JSON。
## 20. ViewModel
```csharp
public class MainViewModel : ViewModelBase
{
private readonly INoteService _noteService;
public MainViewModel(INoteService noteService)
{
_noteService = noteService;
}
public ObservableCollection Notes { get; } = new();
public string ErrorMessage { get; private set; } = "";
}
```
## 21. Service 和 Repository
```csharp
public interface INoteService
{
Note Create(string title, string content);
}
public class NoteService : INoteService
{
private int _nextId = 1;
public Note Create(string title, string content)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("Title is required.");
}
return new Note { Id = _nextId++, Title = title.Trim(), Content = content.Trim() };
}
}
```
## 22. SearchText
```csharp
private string _searchText = "";
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value))
{
RefreshFilter();
}
}
}
private IEnumerable Search()
{
return Notes.Where(note =>
note.Title.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ||
note.Content.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
}
```
```xml
```
## 23. ICollectionView
```csharp
public ICollectionView NotesView { get; }
public MainViewModel()
{
NotesView = CollectionViewSource.GetDefaultView(Notes);
NotesView.Filter = item => item is Note note && MatchSearch(note);
}
private void RefreshFilter()
{
NotesView.Refresh();
}
```
```xml
```
## 24. 排序
```csharp
public void SortNewestFirst()
{
NotesView.SortDescriptions.Clear();
NotesView.SortDescriptions.Add(
new SortDescription(nameof(Note.CreatedAt), ListSortDirection.Descending));
}
```
```xml
```
## 25. Converter
```csharp
public class NullToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is null ? Visibility.Collapsed : Visibility.Visible;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
```
```xml
```
## 26. DataTrigger
```xml
```
## 27. Style
```xml
```
## 28. ResourceDictionary
```xml
```
```xml
```
## 29. 基础验证
```csharp
private string _errorMessage = "";
public string ErrorMessage
{
get => _errorMessage;
set => SetProperty(ref _errorMessage, value);
}
private bool Validate()
{
ErrorMessage = string.IsNullOrWhiteSpace(NewTitle) ? "Title is required." : "";
return ErrorMessage == "";
}
```
## 30. INotifyDataErrorInfo
```csharp
public class NoteEditorViewModel : ViewModelBase, INotifyDataErrorInfo
{
private readonly Dictionary> _errors = new();
public bool HasErrors => _errors.Count > 0;
public event EventHandler? ErrorsChanged;
public IEnumerable GetErrors(string? propertyName)
{
return propertyName is not null && _errors.TryGetValue(propertyName, out List? errors)
? errors
: Enumerable.Empty();
}
private void SetErrors(string propertyName, List errors)
{
if (errors.Count == 0)
{
_errors.Remove(propertyName);
}
else
{
_errors[propertyName] = errors;
}
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
}
}
```
## 31. INoteRepository
```csharp
public interface INoteRepository
{
Task> GetAllAsync();
Task SaveAllAsync(IEnumerable notes);
}
```
第一版 Repository 可以只负责“读全部、写全部”。等这个版本稳定后,再扩展 `AddAsync`、`UpdateAsync`、`DeleteAsync`。
## 32. MemoryNoteRepository
```csharp
public class MemoryNoteRepository : INoteRepository
{
private readonly List _notes = new();
public Task> GetAllAsync()
{
return Task.FromResult>(_notes.ToList());
}
public Task SaveAllAsync(IEnumerable notes)
{
_notes.Clear();
_notes.AddRange(notes);
return Task.CompletedTask;
}
}
```
## 33. JsonNoteRepository
```csharp
public class JsonNoteRepository : INoteRepository
{
private readonly string _path;
public JsonNoteRepository(string path)
{
_path = path;
}
public async Task> GetAllAsync()
{
if (!File.Exists(_path))
{
return [];
}
string json = await File.ReadAllTextAsync(_path);
return JsonSerializer.Deserialize>(json) ?? [];
}
public async Task SaveAllAsync(IEnumerable notes)
{
string json = JsonSerializer.Serialize(notes, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(_path, json);
}
}
```
## 34. Async Command
```csharp
private bool _isBusy;
public bool IsBusy
{
get => _isBusy;
set => SetProperty(ref _isBusy, value);
}
private async Task LoadNotesAsync()
{
try
{
IsBusy = true;
IReadOnlyList notes = await _noteService.LoadAsync();
Notes.Clear();
foreach (Note note in notes)
{
Notes.Add(note);
}
}
finally
{
IsBusy = false;
}
}
```
## 35. Dispatcher
```csharp
private async Task LoadOnBackgroundThreadAsync()
{
List notes = await Task.Run(() => SlowLoad());
await Application.Current.Dispatcher.InvokeAsync(() =>
{
Notes.Clear();
foreach (Note note in notes)
{
Notes.Add(note);
}
});
}
```
多数文件 I/O 场景优先使用真正的异步 API,不需要 `Task.Run`。
## 36. DialogService
```csharp
public interface IDialogService
{
bool Confirm(string message);
void ShowMessage(string message);
}
public class MessageBoxDialogService : IDialogService
{
public bool Confirm(string message)
{
return MessageBox.Show(message, "Confirm", MessageBoxButton.YesNo) == MessageBoxResult.Yes;
}
public void ShowMessage(string message)
{
MessageBox.Show(message);
}
}
```
## 37. UserControl
```xml
```
```xml
```
## 38. 编辑窗口
```csharp
private void EditSelectedNote()
{
if (SelectedNote is null)
{
return;
}
Note draft = new()
{
Id = SelectedNote.Id,
Title = SelectedNote.Title,
Content = SelectedNote.Content,
CreatedAt = SelectedNote.CreatedAt
};
NoteEditorWindow window = new(draft);
if (window.ShowDialog() == true)
{
SelectedNote.Title = draft.Title;
SelectedNote.Content = draft.Content;
}
}
```
## 39. 手动依赖注入
```csharp
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
INoteRepository repository = new JsonNoteRepository("notes.json");
INoteService service = new NoteService(repository);
MainViewModel viewModel = new(service);
MainWindow window = new() { DataContext = viewModel };
window.Show();
}
}
```
## 40. DI 容器
```csharp
ServiceCollection services = new();
services.AddSingleton(_ => new JsonNoteRepository("notes.json"));
services.AddTransient();
services.AddTransient();
services.AddTransient();
ServiceProvider provider = services.BuildServiceProvider();
MainWindow window = provider.GetRequiredService();
window.Show();
```
## 41. 单元测试
```csharp
[Fact]
public void AddNote_WithTitle_AddsNote()
{
MemoryNoteRepository repository = new();
NoteService service = new(repository);
MainViewModel viewModel = new(service);
viewModel.NewTitle = "Test";
viewModel.AddNoteCommand.Execute(null);
Assert.Single(viewModel.Notes);
Assert.Equal("Test", viewModel.SelectedNote?.Title);
}
```
测试重点是 ViewModel 状态,不需要启动窗口。
---
# 推荐练习项目
01-WpfHello
02-WpfControls
03-WpfLogin
04-WpfLayout
05-WpfListBox
06-WpfDataTemplate
07-WpfDataBinding
08-WpfBindingMode
09-WpfPropertyChanged
10-WpfObservableCollection
11-WpfSelectedItem
12-WpfCommand
13-WpfDailyNotesCodeBehind
14-WpfDailyNotesMvvm
15-WpfSearch
16-WpfCollectionView
17-WpfValidation
18-WpfStyles
19-WpfJsonRepository
20-WpfAsyncCommands
21-WpfDialogService
22-WpfDependencyInjection
Final-WpfDailyNotes
---
# 推荐节奏
| 时间 | 内容 |
|---|---|
| 第 1 天 | WPF 项目、XAML、控件 |
| 第 2 天 | StackPanel、Grid、主界面布局 |
| 第 3 天 | ListBox、ItemsSource、DataTemplate |
| 第 4 天 | DataContext、Binding、Binding Mode |
| 第 5 天 | INotifyPropertyChanged、ObservableCollection |
| 第 6 天 | SelectedItem、ICommand、RelayCommand |
| 第 7 天 | 代码后置版重构为 MVVM |
| 第 8 天 | 搜索、排序、验证和样式 |
| 第 9 天 | Repository、JSON 和异步 |
| 第 10 天 | DI、DialogService 和最终整合 |
---
# 每次练习后的自查
- 当前控件的 DataContext 是谁?
- Binding Path 对应哪个属性?
- 属性是否需要通知 UI?
- 集合新增后为什么会或不会刷新?
- 按钮为什么启用或禁用?
- 这个行为应该使用事件还是 Command?
- 这段逻辑属于 View、ViewModel、Service 还是 Repository?
- ViewModel 是否引用了具体控件?
- 文件操作是否阻塞 UI?
- 异步出错后 IsBusy 是否恢复?
- 如果将 JSON 替换为数据库,哪些类需要修改?
- ViewModel 能否脱离窗口进行测试?
---
# 最重要的原则
1. 先完成代码后置版本,再重构为 MVVM。
2. INotifyPropertyChanged 和 ICommand 至少手写一次。
3. 不要把所有逻辑从 MainWindow.xaml.cs 原样搬进 MainViewModel。
4. 不要为了追求零代码后置而增加不必要的复杂度。
5. 先完成最小可运行功能,再进行目录和架构重构。
6. 每一步都要能够解释数据如何从 View 流向 ViewModel,再流向 Service 和 Repository。