WPF MVVM设计模式学习笔记
这份笔记由本次目录直接推导而来,示例项目叫 InspectionMvvmDemo(巡检任务看板) 。它不是一份“只背三个字母”的 MVVM 概念清单,而是从控件事件式写法出发,逐步搭出一套初学者能跟做、能运行、能观察状态变化的 WPF 结构。
写在前面
适合谁
这份笔记适合已经写过一点 WPF 窗口、知道 XAML、Button、TextBox、ListBox 的基本用法,但还不清楚以下问题的读者:
- 为什么按钮事件写在
MainWindow.xaml.cs里越来越乱。 - MVVM 的 View、ViewModel、Model 到底怎么分。
ICommand是什么,为什么按钮要绑定命令。- 改了 ViewModel 属性,界面为什么不更新。
- 按钮为什么一直是灰的,或者任务完成后按钮为什么还能点。
不需要事先学过 Prism、CommunityToolkit.Mvvm 或其他 MVVM 框架。本文只使用 WPF 自带类型和一个自己写的极简 RelayCommand,把底层机制讲透。示例为了让 8 个页签共享同一份内存任务,由 MainViewModel 创建一次服务实例,再注入各个页面 ViewModel;换成数据库或其他服务时,应把实例创建集中到启动入口。
当前环境与固定版本
本次编写和验证日期为 2026 年 10 月 23 日。以下是实际检查过的环境,不是凭印象写的版本号:
| 项目 | 实际环境或固定版本 |
|---|---|
| 操作系统 | Windows 10,版本 10.0.19045 |
| .NET SDK | 9.0.308 |
| .NET 运行时 | 9.0.11 |
| WPF 目标框架 | net9.0-windows |
| UI 框架 | WPF,UseWPF=true |
| 第三方包 | 无 |
| 示例项目 | InspectionMvvmDemo |
| 构建验证 | dotnet build,0 警告、0 错误 |
| 自测验证 | 8 项全部通过 |
本文固定使用这一套已验证版本。换到其他 .NET 大版本时,C# 语法和 WPF 基础 API 多数仍适用,但请在新建项目时明确选择自己的目标框架,不要把本文的 net9.0-windows 当成所有机器都必须使用的版本。
目录
1、理解MVVM设计模式
2、开发思维的转化
3、基于控件的逻辑处理问题实操
4、MVVM模式下的逻辑分层架构
5、分层架构下的基本命令使用
6、分层架构下的属性更新与通知
7、命令对象的状态变更
8、命令对接状态检查的调用时机
正文中的章节标题和编号严格对应以上目录,没有改名、合并或增加新的正文章节。
这份笔记解决什么问题
它解决的是一个非常具体的问题:当“新增巡检任务”“完成巡检任务”“保存巡检备注”这类动作越来越多时,如何让窗口代码不继续膨胀,如何让业务动作能被单独测试,如何让界面可靠地反映状态。
读完并跟做之后,你应该能独立完成下面这条链路:
- 输入框把文本写入 ViewModel 属性。
- ViewModel 属性变化时发出
PropertyChanged。 - Button 通过
Command调用 ViewModel 中的命令对象。 - 命令的
CanExecute根据输入、忙碌状态或任务状态决定按钮是否可用。 - 状态变化后主动通知命令重新检查,界面立即更新。
- Service 完成业务操作,Model 保存任务数据,View 不直接参与业务判断。
推荐学习顺序
建议严格按目录顺序学习,但每一章都打开示例程序的对应页签,边看边操作。
| 顺序 | 打开页签 | 学习重点 | 建议操作 |
|---|---|---|---|
| 1 | 1 理解 MVVM |
认识三块职责 | 刷新列表、发布任务,观察输出文本 |
| 2 | 2 思维转化 |
从“改控件”转为“改状态” | 输入草稿并提交 |
| 3 | 3 控件逻辑 |
看清事件直写的代价 | 分别点击左右两个新增按钮 |
| 4 | 4 分层架构 |
一条任务穿过四层 | 筛选区域、完成所选任务 |
| 5 | 5 基本命令 |
无参数命令与带参数命令 | 新增任务、删除任意一行 |
| 6 | 6 属性通知 |
INotifyPropertyChanged |
修改温度,观察计算属性刷新 |
| 7 | 7 命令状态 |
CanExecute 与忙碌状态 |
清空备注、填入备注、模拟忙碌 |
| 8 | 8 检查时机 |
何时重新检查命令状态 | 完成第一条任务,再点状态检查按钮 |
如果时间有限,建议至少完成第 2、4、5、6、7、8 章的跟做操作。第 1 章帮助建立边界,第 3 章是反例,不能跳过太久。
示例项目路径
项目在本笔记目录下的位置:
1 | 示例代码\InspectionMvvmDemo |
从本笔记根目录进入项目:
1 | Set-Location .\示例代码\InspectionMvvmDemo |
如何通过命令行运行示例
先恢复依赖并构建:
1 | dotnet restore .\InspectionMvvmDemo.csproj |
正常启动:
1 | dotnet run --project .\InspectionMvvmDemo.csproj |
直接打开第 5 个页签:
1 | dotnet run --project .\InspectionMvvmDemo.csproj -- --start-tab=5 |
运行自测:
1 | dotnet run --project .\InspectionMvvmDemo.csproj -- --self-test |
自测结果写入:
1 | 示例代码\InspectionMvvmDemo\artifacts\self-test.txt |
异常日志写入:
1 | 示例代码\InspectionMvvmDemo\artifacts\logs\application.log |
重新生成 8 张真实运行截图:
1 | dotnet run --project .\InspectionMvvmDemo.csproj -- --capture-tabs=C:\Users\North\Desktop\WPF学习笔记\MVVM\images |
这个参数会让程序依次切换 8 个页签,真实渲染窗口内容并写出 PNG,然后自动关闭。它不是拼贴的示意图,而是运行中的 WPF 界面。
如何在 Visual Studio 中打开和运行
- 打开 Visual Studio。
- 选择“打开项目或解决方案”。
- 定位到
示例代码\InspectionMvvmDemo\InspectionMvvmDemo.csproj。 - 在解决方案资源管理器中确认目标框架显示为
net9.0-windows。 - 将
InspectionMvvmDemo设为启动项目。 - 按
F5启动调试,或按Ctrl+F5不调试启动。 - 如果 Visual Studio 提示缺少 .NET 9 桌面开发组件,请安装对应工作负载;本机已验证命令行构建成功,但这不等于所有 Visual Studio 安装都默认带有该组件。
本项目没有 NuGet 依赖,因此第一次打开时只需要正常的 .NET SDK 和 WPF 桌面开发支持,不需要额外安装 MVVM 框架包。
如何创建自己的跟做项目
不要直接在示例项目里改出一堆实验代码。建议复制一份自己的练习目录:
1 | Set-Location C:\Users\North\Desktop\WPF学习笔记\MVVM\示例代码 |
然后按下面的顺序创建目录:
1 | Infrastructure |
先只做一个窗口、一个 ViewModel、一个 RelayCommand。确认“输入能写入属性、按钮能执行命令”之后,再拆出 Service 和 Model。不要一上来就复制完整项目,否则出错时很难判断问题在哪一层。
跟做完成后的项目结构
1 | 示例代码\InspectionMvvmDemo\ |
知识地图
图注:前两章建立认知,第三章暴露控件事件式写法的问题,第四、五章进入分层和命令,第六至第八章处理状态通知与命令状态时机。整条路线围绕同一个巡检任务看板展开。
1、理解MVVM设计模式
本节目标
能用自己的话说明 View、ViewModel、Model 各自保存什么、负责什么,并能在这个示例里指出“任务列表”“当前选中项”“发布结果”分别属于哪一层。学完后不是背定义,而是能判断一段代码该放哪里。
通俗原理说明
先不用记“Model-View-ViewModel”的完整英文展开,先记一个生活类比:
- Model 像巡检记录本上的真实数据:任务编号、区域、优先级、状态、备注。
- ViewModel 像值班调度员:他知道当前选中哪条任务,知道草稿是否完整,知道点击发布后该调用谁,也知道什么时候通知看板刷新。
- View 像墙上的显示屏:只负责把调度员给出的内容画出来,并把用户点击转成命令。
不用 MVVM 时,显示屏自己既查记录本、又做业务判断、又改自己的文字,最后很难换一块屏,也很难单独测试规则。MVVM 的目标不是让代码变多,而是让变化发生时只有一个明确的入口。
需要准备的文件、数据或环境
本章需要:
| 文件或环境 | 作用 |
|---|---|
MainWindow.xaml |
提供 8 个页签和页面切换 |
Views\OverviewView.xaml |
第 1 章界面 |
ViewModels\OverviewViewModel.cs |
任务列表、选中项、发布结果 |
Models\InspectionTask.cs |
任务实体 |
Services\InMemoryInspectionTaskService.cs |
内存任务数据源 |
打开示例后,先切到 1 理解 MVVM 页签。
第一步操作:观察三块职责
在页面上找到三个区域:
- 左侧“巡检任务数据”列表。
- 右侧“ViewModel 输出”深色卡片。
- 右下“三条边界”说明卡。
先点击列表中的“消防通道清点”。观察右侧输出暂时没有变化,因为只改变选中项,还没有执行发布命令。这一步是在区分“状态”和“动作”。
第二步操作:执行刷新命令
点击“刷新列表”。页面会重新读取服务层数据,并显示“已刷新 4 条任务;这是第 1 次界面通知”。再点一次,通知次数变成 2。
这里有三件事同时发生:
- 命令对象执行
RefreshTasks()。 Tasks集合重新填充。PublishResult和NotificationCount发出属性变化通知。
第三步操作:发布所选任务
保持“消防通道清点”选中,点击“发布所选任务”。右侧显示“已发布「消防通道清点」到值班看板”,通知次数增加。
此时可以切到别的页签再切回来,页面状态仍然存在。原因不是界面控件自己记住了文本,而是 OverviewViewModel 实例一直保存在 MainViewModel 中。
关键代码逐段解释
先看最小 Model。真实项目里 Model 可以更复杂,但这里只保留本案例需要的字段:
1 | public sealed class InspectionTask |
InspectionTask 没有任何 WPF 控件类型。它不知道 ListBox、TextBox 或 Window,所以同一份任务数据未来可以给控制台测试、Web 服务或其他界面使用。
再看 ViewModel 中的关键部分:
1 | public sealed class OverviewViewModel : DemoViewModelBase |
这段代码说明三件事:
- ViewModel 通过构造函数接收服务接口,不自己
new一个窗口。 Tasks是可观察集合,界面能跟随集合增删刷新。- 两个公开命令对象可以被 XAML 的 Button 直接绑定。
View 只做绑定:
1 | <ListBox ItemsSource="{Binding Tasks}" |
ItemsSource 表示列表数据从哪里来,SelectedIndex 表示选中项回到 ViewModel,Command 表示点击后执行哪个命令对象。XAML 里没有“如果标题为空就…”之类的业务判断。
运行后应该看到什么
图注:左侧任务列表来自 Model 集合,右侧发布结果来自 ViewModel 属性。点击刷新和发布按钮时,只改变 ViewModel 状态,界面通过绑定显示新值。
预期状态:
- 初始有 4 条任务。
- 第一条“配电柜温度巡检”处于选中状态。
- 右侧初始显示“尚未发布变更”。
- 点击“刷新列表”后通知次数从 0 变为 1。
- 点击“发布所选任务”后能看到所选任务标题。
跟做检查点
- 我能指出 Model 文件是哪一个。
- 我能指出 ViewModel 保存了哪些状态。
- 我知道 View 没有直接调用
InMemoryInspectionTaskService。 - 我能解释为什么切回页签后状态仍在。
- 我能解释
Tasks为什么不用普通List<T>直接绑定并增删。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
View 的 DataContext 没设置 |
页面空白,按钮没有反应 | 在 MainWindow.xaml 中把 Overview 绑定到 OverviewView.DataContext |
绑定了普通 List<T> |
新增后界面不刷新 | 使用 ObservableCollection<T>,或重新赋值并通知集合属性 |
在 ViewModel 里写 MessageBox |
ViewModel 依赖 WPF 窗口,测试困难 | 把结果放到 PublishResult 属性,由 View 展示 |
把 SelectedItem 和 SelectedIndex 同时双向绑定到不同字段 |
选择状态互相覆盖 | 本示例只绑定 SelectedIndex,选中项由计算属性得到 |
小练习
把 OverviewViewModel 的 PublishResult 初始文本改成“等待值班员发布”,重新运行,确认页面立即显示新文本。再切到另一个页签并切回,观察文本仍然存在。
本章小结
MVVM 不是让 View 消失,而是让 View 只做显示与输入转发。Model 保存数据,ViewModel 保存界面状态与动作,View 通过绑定连接两者。下一章要解决的是:写代码时怎样从“我该改哪个控件”转成“我该改哪个状态”。
2、开发思维的转化
本节目标
面对“新增巡检任务”需求,能先问“状态是什么、动作是什么、谁负责校验”,而不是立刻打开 .xaml.cs 找控件名。能写出一个可绑定属性加一个可执行命令的最小闭环。
通俗原理说明
控件事件式思考通常是:
1 | 用户点击 → 找到 TextBox → 读 Text → 判断空不空 → 调服务 → 改界面 |
MVVM 式思考则是:
1 | 用户输入 → 写入属性 → 属性变化通知 → 命令检查状态 → 执行动作 → 更新状态 |
两者都能完成需求。区别在于第二种把“输入值”和“业务动作”放进了可以单独测试的类,而不是绑定在具体控件上。以后把输入框换成别的控件,或者把页面换成另一种布局,ViewModel 不需要跟着改。
图注:左边是事件处理里依次读控件、校验、调服务、改界面;右边是属性保存输入、命令表达动作、通知驱动界面。迁移的关键是把“控件”从业务规则里拿出去。
需要准备的文件、数据或环境
| 文件 | 关注内容 |
|---|---|
Views\ThinkingShiftView.xaml |
标题、区域输入框和提交按钮 |
ViewModels\ThinkingShiftViewModel.cs |
草稿属性、提交命令、清空命令 |
Infrastructure\RelayCommand.cs |
把普通方法包装成 ICommand |
第一步操作:切换路线
打开 2 思维转化 页签。右侧有“视图事件路线”和“ViewModel 路线”两个按钮,初始显示“视图事件”。
先点“视图事件路线”,阅读对照卡;再点“ViewModel 路线”。这一步只是让你明确当前讨论的是哪种开发思维,不会改变业务数据。
第二步操作:输入完整草稿
在“标题”中输入“冷却塔水位复核”,在“区域”中输入“E 区 · 冷却塔”。按钮“通过 ViewModel 提交”会随着输入从不可用变为可用。
注意按钮可用性不是后台代码里手动 button.IsEnabled = ... 设置的,而是命令的 CanExecute 根据 CanSubmit 计算出来的。
第三步操作:提交并清空
点击“通过 ViewModel 提交”。下方结果框显示新增任务的编号和标题。然后点击“清空草稿”,两个输入框变空,提交按钮变灰。
切换到第 5 页签,可以看到刚新增的任务已经出现在任务集合中,这说明第 2 章提交的不是一段临时界面文本,而是真正进入了共享服务数据。
关键代码逐段解释
最小属性只需要“字段 + 公开属性”。如果属性要在变化后通知界面,就让它继承可观察基类:
1 | public string DraftTitle |
这里做了两件事:
SetProperty比较旧值和新值,只有真正变化时才更新字段并发出通知。- 标题会影响“能否提交”,所以还要通知
CanSubmit并让命令重新检查。
最小命令的写法:
1 | public ICommand SubmitCommand { get; } |
RelayCommand 的第一个参数是执行方法,第二个参数是“能不能执行”的判断方法。XAML 不需要知道这两个方法的名字,只需要绑定 SubmitCommand。
运行后应该看到什么
图注:两个输入框绑定到 ViewModel 的草稿属性,提交按钮绑定到命令。清空输入后,提交按钮会根据 CanExecute 自动禁用。
预期状态:
- 初始标题和区域都有示例文本。
- 右侧当前路线可以切换。
- 清空任一字段后,提交按钮禁用。
- 重新填完整后,提交按钮恢复。
- 提交结果包含新增任务编号。
跟做检查点
- 我能解释“改控件”和“改状态”的差别。
- 我知道输入框绑定的是 ViewModel 属性,不是控件文本本身。
- 我知道提交按钮为什么能自动禁用。
- 我知道
ClearCommand和SubmitCommand都只改 ViewModel 状态。 - 我能在 XAML 中找到
UpdateSourceTrigger=PropertyChanged。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
TextBox 没写 UpdateSourceTrigger=PropertyChanged |
输入过程中按钮不变化,失焦后才变 | 在需要即时反馈的 TextBox 绑定中加上该设置 |
命令字段只声明了 get 没有初始化 |
绑定为 null,按钮无反应 | 在构造函数中赋值,或使用 = new RelayCommand(...) |
CanExecute 永远返回 true |
空输入也能提交 | 让判断方法真正检查 DraftTitle 和 DraftArea |
| 提交后只改界面文本,不调用服务 | 切到其他页面看不到新增任务 | 把新增动作放进 ViewModel,调用共享服务 |
小练习
给“区域”加一个简单限制:如果区域为空,结果文本显示“区域不能为空”,并且提交命令不可用。不要修改 View 的后台代码,只修改 ViewModel 的 CanSubmit 与结果文本。
本章小结
思维转化的核心是:先定义状态,再定义动作,最后让界面绑定它们。下一章会用同一个新增任务需求,故意展示一种控件事件直写的反例,帮助你识别什么时候又走回了老路。
3、基于控件的逻辑处理问题实操
本节目标
能识别控件事件处理里“读控件、做校验、调服务、改界面”混在一起的问题,并能把它拆成 ViewModel 可执行的命令。理解 Click 事件本身没有错,错在业务规则没有边界。
通俗原理说明
控件事件式写法最常见的样子是:
1 | private void AddButton_Click(object sender, RoutedEventArgs e) |
这段代码能运行,但窗口同时知道:
- 输入控件叫什么。
- 标题怎么校验。
- 服务怎么调用。
- 集合怎么增删。
- 结果文本怎么显示。
只要其中一项变化,例如换成 UserControl、改成批量导入、给测试写一个无界面调用,窗口代码就要跟着改。真正的问题不是“写了 Click”,而是“所有责任都塞进了 Click”。
图注:一个按钮事件如果同时读取控件、做业务校验、调用服务、更新界面,就会把四类责任绑在同一个视图方法里。
需要准备的文件、数据或环境
| 文件 | 作用 |
|---|---|
Views\CodeBehindView.xaml |
左右两个对照区域 |
Views\CodeBehindView.xaml.cs |
左侧按钮的 Click 事件 |
ViewModels\CodeBehindViewModel.cs |
两种路径的状态和命令 |
这个页面故意保留了一个真实的 Click 事件,便于比较。它不用于证明“事件绝不能写”,而是说明事件里应该只保留界面转发职责。
第一步操作:在左侧输入
打开 3 控件逻辑 页签。左侧“反例:控件事件直写”区域有一个“新任务标题”输入框。输入“仓库门磁测试”。
右侧输入框和左侧绑定同一个 NewTaskTitle 属性,因此两边文字同步。这是绑定带来的效果,不是两个 TextBox 互相复制文本。
第二步操作:点击左侧事件按钮
点击“事件直接新增”。页面提示“控件事件直接修改了界面集合,新增 #5”,并说明业务规则散落在视图中。#
打开 Views\CodeBehindView.xaml.cs,可以看到事件处理本身很短:
1 | private void AddByCodeBehind_Click(object sender, RoutedEventArgs e) |
示例为了让你看到对比,仍然让事件转发给 ViewModel;如果完全照反例写,事件里会直接出现 TextBox.Text、服务调用和 Tasks.Add。你可以把这段方法改成那样,观察代码边界如何变化。
第三步操作:点击右侧命令按钮
点击“ViewModel 命令新增”。这次按钮没有 Click 事件,而是通过 Command 执行 ViewModel 中的 AddByViewModelCommand。页面提示“ViewModel 处理了新增逻辑”,右侧结果与左侧不同。
两种入口都新增了任务,但右侧的入口只有一个业务动作对象,便于替换、测试和复用。
关键代码逐段解释
控件事件式写法的关键问题可以拆成下面四段:
1 | var title = TitleTextBox.Text.Trim(); // 读取控件 |
这不是“语法错误”,而是“职责没有分开”。第一行让业务依赖控件,第二行让规则依赖窗口,第三行让窗口知道服务,第四行让窗口知道集合类型。
在示例中,业务动作集中到:
1 | private void AddByViewModel() |
这里 ViewModel 仍然保存集合,但不再知道按钮叫什么、TextBox 在哪里。View 只需要绑定输入和命令。
运行后应该看到什么
图注:左侧是保留的控件事件反例入口,右侧是命令入口;下方两块说明可测性差异。两条路径都能新增任务,但代码边界不同。
预期状态:
- 左右输入框共享同一个标题状态。
- 左侧点击后出现“控件事件直接修改了界面集合”的提示。
- 右侧点击后出现“ViewModel 处理了新增逻辑”的提示。
- 下方说明卡不再被遮挡,能完整阅读。
跟做检查点
- 我能说出 Click 事件里最不该出现的三类代码。
- 我知道
DataContext is CodeBehindViewModel的作用。 - 我能解释为什么左侧按钮不需要 Command 也能工作。
- 我能指出右侧命令入口为什么更容易测试。
- 我不会把所有事件都删掉,而是保留界面转发职责。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
在事件里直接写 TitleTextBox.Text |
单元测试必须创建窗口和控件 | 把输入绑定到 ViewModel 属性 |
在事件里直接 new InMemoryInspectionTaskService() |
数据源无法替换 | 通过构造函数注入服务接口 |
把 Click 事件绑定到不存在的同名方法 |
编译报 XAML 事件处理程序找不到 | 检查 .xaml.cs 方法名与 XAML 是否一致 |
在 ViewModel 里使用 MessageBox |
ViewModel 依赖 WPF 界面 | 用属性暴露消息,让 View 决定如何展示 |
小练习
把 CodeBehindView.xaml.cs 的 Click 事件临时改成直接读取 CodeBehindInput.Text,再调用 viewModel.SimulateCodeBehindAdd()。运行后思考:如果明天要把这个页面改成无界面批量导入,这段代码需要改哪里?
本章小结
控件事件不是敌人,问题是把业务规则放进事件。第 3 章的反例说明了为什么会越来越难改;第 4 章开始建立明确分层,把服务、模型和 ViewModel 的职责固定下来。
4、MVVM模式下的逻辑分层架构
本节目标
能把一个“完成巡检任务”的动作拆成 View → ViewModel → Service → Model 四段,并说明每一层返回什么、修改什么。能看懂接口注入、集合更新和状态通知在分层中的位置。
通俗原理说明
分层不是把文件多建几个目录,而是规定调用方向:
1 | View → ViewModel → Service → Model |
箭头表示“谁调用谁”,不表示“谁可以反向依赖谁”。View 不直接调用 Service,Service 不引用 WPF,Model 不负责弹窗。
图注:View 观察并绑定 ViewModel;ViewModel 编排动作;Service 完成业务用例;Model 保存任务实体。调用方向保持单向,才能让业务层独立于界面。
需要准备的文件、数据或环境
| 文件 | 职责 |
|---|---|
Models\InspectionTask.cs |
任务字段与状态枚举 |
Services\IInspectionTaskService.cs |
业务接口 |
Services\InMemoryInspectionTaskService.cs |
内存实现 |
ViewModels\LayeredArchitectureViewModel.cs |
筛选、选择、完成命令 |
Views\LayeredArchitectureView.xaml |
列表、筛选器、按钮 |
第一步操作:观察服务接口
打开 IInspectionTaskService.cs:
1 | public interface IInspectionTaskService |
接口只描述业务能力,不描述按钮和窗口。ViewModel 依赖这个接口,未来把内存实现替换成数据库实现时,ViewModel 的调用代码不需要重写。
第二步操作:筛选与选择任务
运行程序并打开 4 分层架构 页签。在右上角下拉框选择“C 区 · 泵房”。列表只显示该区域的任务,LayerMessage 显示筛选器已改变。
这不是在 XAML 里写 LINQ 过滤,而是 ViewModel 的计算属性:
1 | public IEnumerable<InspectionTask> VisibleTasks => |
AreaFilter 的 setter 发出 VisibleTasks 的属性变化通知,界面才会重新读取筛选结果。
第三步操作:完成所选任务
选择“配电柜温度巡检”,点击“完成所选任务”。按钮调用 CompleteCommand,命令检查 CanExecute,然后执行 CompleteSelected()。服务返回成功,任务状态变成“已完成”,深色卡片显示当前职责链。
图注:按钮只发起命令;ViewModel 校验并调用服务;Service 修改 Model;Model 状态变化后再由 ViewModel 通知界面刷新。
关键代码逐段解释
ViewModel 的构造函数接收接口:
1 | public LayeredArchitectureViewModel(IInspectionTaskService service) |
这行 _service = service 是依赖注入的最小形式。ViewModel 不需要知道实现类的名字,只需要知道接口能做什么。
完成动作的调用顺序:
1 | public void CompleteSelected() |
顺序很重要:先让服务执行,服务返回成功后再更新 ViewModel 的展示状态。不要先改界面“看起来完成”,再假设服务一定成功。
Service 只操作 Model:
1 | public bool CompleteTask(int id, string note) |
它没有 MessageBox、没有 Dispatcher、没有 Button。因此这个方法可以在控制台或测试里直接调用。
运行后应该看到什么
图注:左侧是筛选后的任务列表,右侧显示当前职责链。完成动作后,任务状态文字更新,按钮会根据 CanExecute 变为不可用。
预期状态:
- 切换区域筛选时,列表只显示对应区域任务。
- 选中任务后,右侧显示当前选择。
- 完成一条待巡检任务后,状态变为“已完成”。
- 已完成任务再被选中时,“完成所选任务”按钮不可用。
跟做检查点
- 我能画出 View → ViewModel → Service → Model 的箭头。
- 我知道接口放在哪一层,为什么 ViewModel 不直接 new 实现类。
- 我能解释筛选属性改变后为什么要通知
VisibleTasks。 - 我知道服务返回成功后才更新界面状态。
- 我能指出本页哪个文件不引用 WPF 控件。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
ViewModel 里直接 new MainWindow() |
形成循环依赖,测试困难 | 只依赖服务接口和普通对象 |
Service 引用 System.Windows |
业务层无法脱离 WPF | 把消息、弹窗交给 ViewModel 或 View |
| 改了筛选条件但列表不刷新 | 计算属性没有通知 | 在 AreaFilter setter 中通知 VisibleTasks |
| 服务返回 false 仍更新界面 | 界面显示成功但数据没变 | 以服务返回值为准,成功后再改 ViewModel 状态 |
小练习
在 IInspectionTaskService 中增加一个 GetPendingCount() 方法,在 ViewModel 中显示“待巡检数量”。注意:计数要在完成动作后刷新,思考应该通知哪个计算属性。
本章小结
分层的关键不是文件数量,而是调用方向与责任边界。下一章会在这个结构上引入命令对象,让按钮不再直接调用后台方法,而是通过 ICommand 的契约发起动作。
5、分层架构下的基本命令使用
本节目标
能自己实现一个最小 ICommand,把无参数方法和带参数方法都绑定到 Button。能解释 Execute、CanExecute、CommandParameter 的关系,并能从运行结果判断命令是否真的执行。
通俗原理说明
Button 的 Command 属性类型是 ICommand,不是任意方法。WPF 在点击按钮时会:
- 调用
CanExecute(parameter),返回 false 就不执行。 - 返回 true 时调用
Execute(parameter)。 - 当命令状态变化时,通过
CanExecuteChanged通知按钮重新检查。
图注:Button 只依赖 ICommand 的三个成员;RelayCommand 把普通委托包装成命令对象。无参数命令适合表单动作,带参数命令适合列表行操作。
需要准备的文件、数据或环境
| 文件 | 作用 |
|---|---|
Infrastructure\RelayCommand.cs |
无参数与带参数命令实现 |
ViewModels\BasicCommandViewModel.cs |
新增、删除、重置命令 |
Views\BasicCommandView.xaml |
输入区、任务列表、删除按钮 |
第一步操作:执行无参数命令
打开 5 基本命令 页签。标题、区域和优先级已经填入示例数据。点击“执行 AddTaskCommand”。
结果区显示“命令执行第 1 次:新增任务 #5”。再次点击,执行次数增加,列表也多一行。#ExecuteCount 是命令真正执行后才更新的,因此它能作为运行结果的证据。
第二步操作:使用带参数命令删除一行
在右侧列表中找到“消防通道清点”,点击它所在行的“删除”按钮。命令收到 CommandParameter,删除的是这一行任务,而不是固定的第一条。
XAML 的关键是:
1 | <Button Content="删除" |
CommandParameter="{Binding}" 表示把当前行的任务对象传给命令;RelativeSource 用来从行数据跳到外层 ViewModel 的命令。
第三步操作:重置并填入示例
点击“重置集合”,任务列表恢复为初始数据。再点击“填入示例”,输入区恢复默认文本。两个按钮分别演示“集合状态重置”和“输入状态重置”,都不需要后台代码直接操作控件。
关键代码逐段解释
最小 RelayCommand 的核心只有三个成员:
1 | public sealed class RelayCommand : ICommand |
_canExecute 为 null 时默认允许执行;RaiseCanExecuteChanged 不是 ICommand 接口强制要求的成员,而是我们额外加的公开方法,方便 ViewModel 主动通知界面。
带参数版本的关键在类型转换:
1 | private static T? ConvertParameter(object? parameter) |
如果参数类型不匹配,就返回默认值。真实项目里可以在这里记录日志,避免把错误悄悄吞掉。
运行后应该看到什么
图注:左侧输入区执行无参数新增命令,右侧每一行的删除按钮执行带参数命令。结果区显示 Execute 次数,能确认命令不是只改了界面文字。
预期状态:
- 初始列表 4 条任务。
- 点击新增后列表增加,结果区显示执行次数。
- 点击任意删除后对应行消失。
- 点击重置后列表恢复 4 条,执行次数归零。
跟做检查点
- 我能说出
CanExecute和Execute的调用顺序。 - 我知道
CommandParameter传入了什么对象。 - 我能解释为什么删除命令需要
RelativeSource。 - 我能解释
CanExecuteChanged为什么不是普通的属性通知。 - 我知道
RelayCommand只是最小实现,不是 WPF 内置的官方命令类。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
Command 绑定为 null |
按钮可点但无反应,或直接禁用 | 检查 ViewModel 是否初始化命令属性 |
| 删除按钮绑定到了行内任务 | 找不到 DeleteTaskCommand |
使用 RelativeSource 找到外层 ViewModel |
CommandParameter 没传 |
带参数命令收到 null | 明确写 CommandParameter="{Binding}" |
| 命令执行后状态没变 | 按钮显示旧可用性 | 调用 RaiseCanExecuteChanged() |
把 Action 当 ICommand 直接绑定 |
XAML 编译或运行绑定失败 | 用 RelayCommand 包装 |
小练习
增加一个“删除最后一条”按钮,绑定新的无参数命令。不要复用 DeleteTaskCommand 的 UI 参数路径,而是在 ViewModel 里新建一个命令,检查列表为空时 CanExecute 返回 false。
本章小结
命令把“动作”变成可绑定对象。无参数命令适合当前表单,带参数命令适合列表行。下一章要处理另一个基础问题:ViewModel 属性变了,界面凭什么知道要刷新。
6、分层架构下的属性更新与通知
本节目标
能实现 INotifyPropertyChanged,理解字段、公开属性、setter、通知事件之间的关系。能处理“源属性改变后,计算属性不刷新”这一最常见的绑定问题。
通俗原理说明
绑定不是魔法。第一次绑定时,WPF 读取 ViewModel 属性的值;之后只有收到 PropertyChanged 事件,它才知道要重新读取。
如果只写:
1 | public int Temperature { get; set; } |
你在代码里改 Temperature,WPF 不知道。必须让属性在 setter 中发出通知,或者显式调用通知方法。
图注:TextBox 双向绑定写入 Temperature;setter 发出 PropertyChanged;绑定系统重新读取;计算属性 TemperatureLevel 和 TemperatureDisplay 还需要单独通知。
需要准备的文件、数据或环境
| 文件 | 作用 |
|---|---|
Infrastructure\ObservableObject.cs |
通知基类 |
ViewModels\PropertyNotificationViewModel.cs |
温度、巡检人、备注、计算属性 |
Views\PropertyNotificationView.xaml |
表单、通知观察窗 |
第一步操作:修改巡检人
打开 6 属性通知 页签。在“巡检人”输入框里把“林工”改成“周工”。右侧通知观察窗立即显示“第 1 次通知:巡检人变为「周工」”。
通知次数增加说明 setter 真的执行了。若界面文本不更新,先检查绑定路径和 INotifyPropertyChanged,不要先怀疑 WPF 本身。
第二步操作:修改温度
把温度从 36 改成 46,或者点击两次“+2 ℃”。温度显示从“36 ℃ · 正常”变成“46 ℃ · 高温预警”。
这里有两个属性同时更新:
Temperature是源属性。TemperatureDisplay是依赖它的计算属性。
源属性 setter 里显式通知了 TemperatureLevel 和 TemperatureDisplay,所以两行显示同时刷新。
第三步操作:生成摘要并关闭通知
修改“巡检备注”,点击“生成计算摘要”。下方摘要使用当前巡检人、温度和备注重新计算。
然后取消勾选“启用通知日志”,再次修改温度。值仍然变化,但观察窗提示“通知开关关闭”。这个开关只影响日志观察,不改变 SetProperty 发出的属性通知;它帮助你区分“值更新”和“界面通知”两个概念。
关键代码逐段解释
基类保存了 PropertyChanged 事件:
1 | public abstract class ObservableObject : INotifyPropertyChanged |
CallerMemberName 让 OnPropertyChanged() 自动拿到调用它的属性名,避免手写字符串拼错。相等时直接返回 false,是为了避免无意义的通知。
计算属性的通知:
1 | public int Temperature |
TemperatureDisplay 没有 setter,不能靠自身变化发通知;它依赖 Temperature,所以必须由 Temperature 的 setter 通知它。
运行后应该看到什么
图注:左侧是绑定表单,右侧是通知观察窗。修改温度后,温度显示与等级同时刷新;生成摘要时,计算属性也得到显式通知。
预期状态:
- 修改巡检人后通知次数增加。
- 温度超过 38 显示“偏高”,超过 45 显示“高温预警”。
TemperatureDisplay与TemperatureLevel同步变化。- 点击生成摘要后,摘要文本包含最新温度和备注。
跟做检查点
- 我知道
PropertyChanged是事件,不是属性。 - 我知道
SetProperty什么时候返回 true。 - 我能解释为什么计算属性需要额外通知。
- 我知道
CallerMemberName解决什么问题。 - 我能区分“字段值变了”和“界面收到通知”。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
ViewModel 没实现 INotifyPropertyChanged |
属性值改了界面不动 | 继承 ObservableObject 或手动实现接口 |
| setter 里直接改字段但不通知 | 日志显示值变了,界面仍旧 | 用 SetProperty 或调用 OnPropertyChanged |
| 计算属性没通知 | 源值变了,计算值不刷新 | 在源 setter 中通知计算属性名 |
| 属性名写错字符串 | 绑定不更新,调试困难 | 使用 nameof 或 CallerMemberName |
| 只通知了集合属性,没通知集合内容 | 新增项不显示 | 使用 ObservableCollection<T> 或重新赋值集合 |
小练习
增加一个 InspectorLabel 计算属性,返回“巡检人:周工”。修改 Inspector 时通知它,并在 XAML 中绑定显示。再故意删除通知,观察界面是否停在旧值。
本章小结
属性通知解决“值改变后界面怎么知道”。下一章会把这个机制和命令状态结合起来:命令的 CanExecute 不是一个固定值,它会随着输入和业务状态变化。
7、命令对象的状态变更
本节目标
理解命令对象也有状态。能让 CanExecute 根据输入完整性、忙碌状态和业务状态返回不同结果,并让按钮的启用状态随这些状态变化。
通俗原理说明
“命令可用”不是按钮的永久属性。对于“保存草稿”按钮,至少存在三种状态:
- 标题或备注为空:不可保存。
- 字段完整且空闲:可保存。
- 正在保存:不可重复保存。
这些判断应该写在命令的 CanExecute 中,而不是在多个事件里散落 button.IsEnabled = true/false。
图注:输入完整让命令从禁用变为可用;开始保存后进入执行中,命令再次禁用;完成或清空字段后又回到对应状态。
需要准备的文件、数据或环境
| 文件 | 作用 |
|---|---|
ViewModels\CommandStateViewModel.cs |
草稿、忙碌状态、保存命令 |
Views\CommandStateView.xaml |
保存、清空、模拟忙碌按钮 |
Infrastructure\RelayCommand.cs |
提供 RaiseCanExecuteChanged |
第一步操作:观察初始禁用
打开 7 命令状态 页签。标题已有“屋顶排风机复核”,备注为空,因此“保存草稿”按钮是灰色的。状态卡显示“命令不可用:请填写标题和备注”。
这说明按钮的灰色不是样式写死,而是 CanExecute 返回 false。
第二步操作:填写备注
在备注中输入“排风机运转正常”。DraftNote 的 setter 调用 UpdateCommandState(),命令重新检查状态,按钮变亮。
此时点击“保存草稿”,任务集合新增一条任务,备注被清空,保存按钮再次变灰。这个闭环同时演示了输入状态和业务动作结果。
第三步操作:模拟忙碌
再次填写备注,然后点击“模拟忙碌”。状态卡显示“命令执行中,按钮禁用”,保存按钮变灰;即使字段完整也不能重复保存。
再点一次“模拟忙碌”恢复空闲,命令重新检查状态。这里模拟的是真实项目中“正在保存”或“正在调用接口”的短暂状态。
关键代码逐段解释
命令状态的定义:
1 | public bool CanSave => |
判断条件全部来自 ViewModel 属性,不读取任何控件。这样测试可以直接设置属性并检查 CanSave。
状态变化时主动通知:
1 | private void UpdateCommandState() |
RaiseCanExecuteChanged() 让 WPF 重新询问命令“现在能不能执行”;CommandState 只是给用户看的解释文本。两者都要更新,但职责不同。
运行后应该看到什么
图注:初始备注为空,保存按钮禁用,状态卡与按钮一致。填写备注后按钮可用;模拟忙碌后再次禁用。
预期状态:
- 初始“保存草稿”按钮灰色。
- 备注为空时状态卡提示不可用。
- 填入备注后按钮变亮。
- 点击保存后任务增加,备注清空,按钮再次变灰。
- 模拟忙碌时,即使字段完整也不可保存。
跟做检查点
- 我知道按钮禁用不等于业务失败。
- 我能说出
CanSave依赖哪三个状态。 - 我知道忙碌状态也要参与
CanExecute。 - 我能解释为什么保存后要清空备注。
- 我不会在 View 里手写
button.IsEnabled。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
只改 CanSave 不调用 RaiseCanExecuteChanged |
状态值变了,按钮外观没变 | 在 setter 或统一更新方法中通知命令 |
| 忙碌时仍允许保存 | 重复提交、重复新增任务 | 在 CanExecute 中加入 !IsBusy |
| 保存后不清空草稿 | 用户可能重复保存同一内容 | 保存成功后重置输入状态 |
| 把状态文字当成命令状态 | 文字说可用,按钮仍灰 | 以 CanExecute 的结果为准,文字只做解释 |
| 在多个事件里分别更新按钮 | 状态一多就漏更新 | 集中到 UpdateCommandState() |
小练习
给 CanSave 增加一个“标题至少 4 个字符”的条件,并同步更新状态文字。测试输入“屋顶”时按钮是否禁用,输入“屋顶排风机复核”时是否恢复。
本章小结
命令对象保存业务规则和状态。CanExecute 负责“现在能不能执行”,Execute 负责“执行什么”。最后一章要回答一个容易混淆的问题:状态改变后,到底什么时候重新检查 CanExecute。
8、命令对接状态检查的调用时机
本节目标
能说清 CanExecute 不会在任意状态变化时自动重算,知道主动通知和 CommandManager 统一查询两条路径,并能在代码中选择正确的检查时机。
通俗原理说明
很多人会以为:只要 CanExecute 方法里的判断条件变了,按钮就会立刻更新。实际并不是这样。CanExecute 是一个方法,它需要被某个时机调用。
常见触发点:
- 输入变化后,属性 setter 更新状态。
- 列表选择变化后,选中项 setter 更新状态。
- 命令执行完成后,业务状态发生变化。
CommandManager在输入事件后统一触发RequerySuggested。
前三种通常需要你主动调用 RaiseCanExecuteChanged();第四种依赖 WPF 的 CommandManager 机制,适合按钮较少、状态变化来自常规输入事件的场景。不要假设它会在任意后台线程或任意业务状态变化后立即执行。
图注:输入变化、选择变化、业务执行后是三个关键检查点;主动通知更可控,CommandManager 统一查询更省代码但时机不完全由业务控制。
需要准备的文件、数据或环境
| 文件 | 作用 |
|---|---|
ViewModels\StateCheckTimingViewModel.cs |
选中项、完成命令、检查计数 |
Views\StateCheckTimingView.xaml |
任务列表、完成按钮、检查按钮 |
第一步操作:选择任务并观察按钮
打开 8 检查时机 页签。初始没有选中任务,点击“选择第一条”,SelectedIndex 变化,CompleteByCommand 重新检查状态。
选中一条待巡检任务后,“完成所选(命令)”可用;选中“消防通道清点”这条已完成任务时,按钮不可用。
第二步操作:执行完成命令
选择第一条“配电柜温度巡检”,点击“完成所选(命令)”。命令调用服务完成任务,任务状态变为“已完成”,然后立即调用 RaiseCanExecuteChanged(),按钮变灰。
如果没有最后这次通知,按钮可能仍保持可用。你可以临时注释 CompleteByCommand.RaiseCanExecuteChanged(),重新运行并观察这个差异,再恢复。
第三步操作:比较两种检查入口
点击“主动检查状态”,ViewModel 手动调用 RaiseCanExecuteChanged(),检查次数增加,右侧显示当前 CanExecute 结果。
点击“事件入口检查”,后台事件调用 ViewModel 的 CheckInView() 并给出判断结果。这个入口是反例:虽然判断仍在 ViewModel 中,但触发点绑在视图事件上,只覆盖这一个入口,其他状态变化仍然可能漏掉。实际项目中应把状态检查放回 ViewModel 的统一状态变化入口。
关键代码逐段解释
选中项变化时检查:
1 | public int SelectedIndex |
选中项是命令状态的一部分,所以它变化后必须通知命令。只通知 SelectedTask 不会自动让按钮重新检查。
命令执行后检查:
1 | private void CompleteSelected() |
业务状态改变后,按钮的可用性可能改变,因此执行完成后也要检查一次。
统一入口的写法:
1 | private void UpdateCommandState() |
当多个属性都影响同一个命令时,把所有影响点集中到一个方法里,比在每处零散写通知更容易维护。
运行后应该看到什么
图注:左侧选择任务,右侧显示检查结果。完成待巡检任务后按钮禁用;主动检查和事件入口检查会分别增加检查计数。
预期状态:
- 未选中任务时,完成命令不可用。
- 选择待巡检任务后,完成命令可用。
- 完成后再选择同一条任务,完成命令不可用。
- 点击“主动检查状态”和“事件入口检查”,检查次数和右侧消息同步变化。
跟做检查点
- 我知道
CanExecute需要被调用才会重新计算结果。 - 我能说出三个关键检查时机。
- 我知道
RaiseCanExecuteChanged和CommandManager.RequerySuggested的区别。 - 我不会只在某个视图事件里检查状态。
- 我能把状态通知集中到统一方法。
常见错误与解决方法
| 常见错误 | 现象 | 解决方法 |
|---|---|---|
| 状态变了但不通知命令 | 按钮可用性不更新 | 在 setter 或动作后调用 RaiseCanExecuteChanged |
| 只在点击按钮时检查 | 用户看到旧状态,直到点击才更新 | 在状态变化入口通知 |
| 在后台线程直接改绑定集合 | 抛跨线程异常或界面不更新 | 通过 Dispatcher 回到 UI 线程,或使用合适的上报机制 |
把 CommandManager 当成万能自动刷新 |
某些业务变化后按钮仍旧 | 关键业务状态变化后主动通知 |
| 检查逻辑写在多个 Click 事件里 | 漏掉某个入口,行为不一致 | 把检查集中到 ViewModel 状态更新方法 |
小练习
给 StateCheckTimingViewModel 增加一个 IsReadOnly 属性。为 true 时,完成命令不可用;切换该属性时调用统一状态检查方法。分别在页面初始化和按钮点击后测试按钮状态。
本章小结
命令状态检查不是“设置一次就完事”。输入、选择、执行结果和忙碌状态变化后,都要在合适的入口重新通知命令。优先把检查集中到 ViewModel 的统一状态更新方法,视图只负责显示结果。
跟做后的验收清单
完成全部章节后,逐项检查:
- 8 个页签都能打开,
--start-tab=1到--start-tab=8都能定位到对应页面。 - 第 1 章的刷新、发布按钮都有可见结果。
- 第 2 章提交和清空会改变按钮可用性。
- 第 3 章左右两个入口都能新增任务,且提示文字不同。
- 第 4 章筛选、选择、完成动作都能观察状态变化。
- 第 5 章无参数命令和带参数命令都能执行。
- 第 6 章修改温度后计算属性同步刷新。
- 第 7 章保存按钮会随备注和忙碌状态变化。
- 第 8 章完成命令后按钮立即禁用。
-
--self-test退出码为 0,自测文件显示 8 项通过。
常见问题总表
| 问题 | 先检查什么 | 对应章节 |
|---|---|---|
| 页面空白 | DataContext 是否设置,绑定路径是否存在 |
1、2 |
| 输入后按钮不变 | TextBox 是否 UpdateSourceTrigger=PropertyChanged |
2、7 |
| 属性改了界面不刷新 | 是否实现 INotifyPropertyChanged,setter 是否发通知 |
6 |
| 计算属性不刷新 | 源属性 setter 是否通知计算属性名 | 6 |
| 按钮一直灰 | CanExecute 条件是否过严,是否收到状态通知 |
7、8 |
| 按钮该灰却不灰 | 状态变化后是否调用 RaiseCanExecuteChanged |
8 |
| 命令点击无反应 | Command 是否为 null,参数类型是否匹配 |
5 |
| 列表新增不显示 | 是否使用 ObservableCollection<T> |
1、5 |
| 服务调用成功但界面旧 | 服务成功后是否更新 ViewModel 状态并通知 | 4 |
| 截图内容为空 | 是否在窗口完成布局后渲染,路径是否存在 | 验证 |
最终验证记录
本次交付实际执行了以下验证:
dotnet build .\InspectionMvvmDemo.csproj:成功,0 警告,0 错误。--self-test:8 项通过,0 项失败,退出码 0。--capture-tabs:真实渲染 8 个页签,生成 8 张运行截图。- 9 张原理图全部生成,并逐张检查中文、连线、留白和遮挡。
- 自测结果写入
artifacts\self-test.txt,异常日志写入artifacts\logs\application.log。
自测结果文件的实际内容摘要:
1 | PASS 第 1 章:刷新任务并通知界面 |
学习完成后的下一步
如果已经能独立解释本笔记的 8 个章节,可以继续做三件事:
- 把
InMemoryInspectionTaskService换成读取本地 JSON 的实现,但保持接口不变。 - 给 ViewModel 写一个真正的测试项目,不启动 WPF 窗口,只测试属性和命令。
- 把 8 个页面拆成独立导航页面,观察 ViewModel 复用是否仍然成立。
这三项都建立在本笔记的分层与命令边界之上,不需要引入第三方 MVVM 框架也能完成。
















