这份笔记由本次目录直接推导而来,示例项目叫 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、命令对接状态检查的调用时机

正文中的章节标题和编号严格对应以上目录,没有改名、合并或增加新的正文章节。

这份笔记解决什么问题

它解决的是一个非常具体的问题:当“新增巡检任务”“完成巡检任务”“保存巡检备注”这类动作越来越多时,如何让窗口代码不继续膨胀,如何让业务动作能被单独测试,如何让界面可靠地反映状态。

读完并跟做之后,你应该能独立完成下面这条链路:

  1. 输入框把文本写入 ViewModel 属性。
  2. ViewModel 属性变化时发出 PropertyChanged。
  3. Button 通过 Command 调用 ViewModel 中的命令对象。
  4. 命令的 CanExecute 根据输入、忙碌状态或任务状态决定按钮是否可用。
  5. 状态变化后主动通知命令重新检查,界面立即更新。
  6. 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
2
dotnet restore .\InspectionMvvmDemo.csproj
dotnet build .\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 中打开和运行

  1. 打开 Visual Studio。
  2. 选择“打开项目或解决方案”。
  3. 定位到 示例代码\InspectionMvvmDemo\InspectionMvvmDemo.csproj。
  4. 在解决方案资源管理器中确认目标框架显示为 net9.0-windows。
  5. 将 InspectionMvvmDemo 设为启动项目。
  6. 按 F5 启动调试,或按 Ctrl+F5 不调试启动。
  7. 如果 Visual Studio 提示缺少 .NET 9 桌面开发组件,请安装对应工作负载;本机已验证命令行构建成功,但这不等于所有 Visual Studio 安装都默认带有该组件。

本项目没有 NuGet 依赖,因此第一次打开时只需要正常的 .NET SDK 和 WPF 桌面开发支持,不需要额外安装 MVVM 框架包。

如何创建自己的跟做项目

不要直接在示例项目里改出一堆实验代码。建议复制一份自己的练习目录:

1
2
3
Set-Location C:\Users\North\Desktop\WPF学习笔记\MVVM\示例代码
dotnet new wpf -n MyInspectionMvvm -f net9.0
Set-Location .\MyInspectionMvvm

然后按下面的顺序创建目录:

1
2
3
4
5
Infrastructure
Models
Services
ViewModels
Views

先只做一个窗口、一个 ViewModel、一个 RelayCommand。确认“输入能写入属性、按钮能执行命令”之后,再拆出 Service 和 Model。不要一上来就复制完整项目,否则出错时很难判断问题在哪一层。

跟做完成后的项目结构

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
示例代码\InspectionMvvmDemo\
├─ App.xaml 应用资源、全局样式
├─ App.xaml.cs 启动、自测、截图参数
├─ MainWindow.xaml 窗口框架与 8 个页签
├─ MainWindow.xaml.cs 只负责初始化窗口
├─ InspectionMvvmDemo.csproj 目标框架与项目配置
├─ Infrastructure\
│ ├─ ObservableObject.cs 属性变化通知基类
│ └─ RelayCommand.cs 最小 ICommand 实现
├─ Models\
│ └─ InspectionTask.cs 巡检任务实体与枚举
├─ Services\
│ ├─ IInspectionTaskService.cs 服务接口
│ ├─ InMemoryInspectionTaskService.cs
│ ├─ AppPaths.cs 输出目录与日志
│ ├─ SelfTestRunner.cs 8 项自测
│ └─ ScreenshotRunner.cs 真实窗口截图
├─ ViewModels\
│ ├─ MainViewModel.cs 页签与页面 ViewModel 组合
│ ├─ DemoViewModelBase.cs 页面状态消息基类
│ ├─ OverviewViewModel.cs 第 1 章
│ ├─ ThinkingShiftViewModel.cs 第 2 章
│ ├─ CodeBehindViewModel.cs 第 3 章
│ ├─ LayeredArchitectureViewModel.cs
│ ├─ BasicCommandViewModel.cs
│ ├─ PropertyNotificationViewModel.cs
│ ├─ CommandStateViewModel.cs
│ └─ StateCheckTimingViewModel.cs
└─ Views\
├─ OverviewView.xaml 第 1 章页面
├─ ThinkingShiftView.xaml 第 2 章页面
├─ CodeBehindView.xaml 第 3 章页面
├─ LayeredArchitectureView.xaml 第 4 章页面
├─ BasicCommandView.xaml 第 5 章页面
├─ PropertyNotificationView.xaml 第 6 章页面
├─ CommandStateView.xaml 第 7 章页面
└─ StateCheckTimingView.xaml 第 8 章页面

知识地图

MVVM 学习知识地图

图注:前两章建立认知,第三章暴露控件事件式写法的问题,第四、五章进入分层和命令,第六至第八章处理状态通知与命令状态时机。整条路线围绕同一个巡检任务看板展开。

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 页签。

第一步操作:观察三块职责

在页面上找到三个区域:

  1. 左侧“巡检任务数据”列表。
  2. 右侧“ViewModel 输出”深色卡片。
  3. 右下“三条边界”说明卡。

先点击列表中的“消防通道清点”。观察右侧输出暂时没有变化,因为只改变选中项,还没有执行发布命令。这一步是在区分“状态”和“动作”。

第二步操作:执行刷新命令

点击“刷新列表”。页面会重新读取服务层数据,并显示“已刷新 4 条任务;这是第 1 次界面通知”。再点一次,通知次数变成 2。

这里有三件事同时发生:

  1. 命令对象执行 RefreshTasks()。
  2. Tasks 集合重新填充。
  3. PublishResult 和 NotificationCount 发出属性变化通知。

第三步操作:发布所选任务

保持“消防通道清点”选中,点击“发布所选任务”。右侧显示“已发布「消防通道清点」到值班看板”,通知次数增加。

此时可以切到别的页签再切回来,页面状态仍然存在。原因不是界面控件自己记住了文本,而是 OverviewViewModel 实例一直保存在 MainViewModel 中。

关键代码逐段解释

先看最小 Model。真实项目里 Model 可以更复杂,但这里只保留本案例需要的字段:

1
2
3
4
5
6
7
8
9
public sealed class InspectionTask
{
public int Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Area { get; init; } = string.Empty;
public InspectionPriority Priority { get; init; }
public InspectionStatus Status { get; set; }
public string Note { get; set; } = string.Empty;
}

InspectionTask 没有任何 WPF 控件类型。它不知道 ListBox、TextBox 或 Window,所以同一份任务数据未来可以给控制台测试、Web 服务或其他界面使用。

再看 ViewModel 中的关键部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public sealed class OverviewViewModel : DemoViewModelBase
{
private readonly IInspectionTaskService _service;
private int _selectedIndex;
private string _publishResult = "尚未发布变更";

public OverviewViewModel(IInspectionTaskService service)
{
_service = service;
Tasks = new ObservableCollection<InspectionTask>(service.GetTasks());
RefreshCommand = new RelayCommand(RefreshTasks);
PublishCommand = new RelayCommand(PublishTask);
}

public ObservableCollection<InspectionTask> Tasks { get; }
public RelayCommand RefreshCommand { get; }
public RelayCommand PublishCommand { get; }
}

这段代码说明三件事:

  • ViewModel 通过构造函数接收服务接口,不自己 new 一个窗口。
  • Tasks 是可观察集合,界面能跟随集合增删刷新。
  • 两个公开命令对象可以被 XAML 的 Button 直接绑定。

View 只做绑定:

1
2
3
4
5
6
7
8
<ListBox ItemsSource="{Binding Tasks}"
SelectedIndex="{Binding SelectedIndex, Mode=TwoWay}"/>

<Button Content="刷新列表"
Command="{Binding RefreshCommand}"/>

<Button Content="发布所选任务"
Command="{Binding PublishCommand}"/>

ItemsSource 表示列表数据从哪里来,SelectedIndex 表示选中项回到 ViewModel,Command 表示点击后执行哪个命令对象。XAML 里没有“如果标题为空就…”之类的业务判断。

运行后应该看到什么

第 1 章真实运行截图

图注:左侧任务列表来自 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
2
3
4
5
6
7
8
9
10
11
12
public string DraftTitle
{
get => _draftTitle;
set
{
if (SetProperty(ref _draftTitle, value))
{
OnPropertyChanged(nameof(CanSubmit));
SubmitCommand.RaiseCanExecuteChanged();
}
}
}

这里做了两件事:

  1. SetProperty 比较旧值和新值,只有真正变化时才更新字段并发出通知。
  2. 标题会影响“能否提交”,所以还要通知 CanSubmit 并让命令重新检查。

最小命令的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public ICommand SubmitCommand { get; }

public ThinkingShiftViewModel(IInspectionTaskService service)
{
_service = service;
SubmitCommand = new RelayCommand(SubmitDraft, CanSubmitDraft);
}

private bool CanSubmitDraft()
{
return !string.IsNullOrWhiteSpace(DraftTitle)
&& !string.IsNullOrWhiteSpace(DraftArea);
}

RelayCommand 的第一个参数是执行方法,第二个参数是“能不能执行”的判断方法。XAML 不需要知道这两个方法的名字,只需要绑定 SubmitCommand。

运行后应该看到什么

第 2 章真实运行截图

图注:两个输入框绑定到 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
2
3
4
5
6
7
8
9
10
11
12
13
private void AddButton_Click(object sender, RoutedEventArgs e)
{
var title = TitleTextBox.Text.Trim();
if (title.Length == 0)
{
MessageBox.Show("标题不能为空");
return;
}

var task = service.AddTask(title);
TaskList.Items.Add(task);
ResultText.Text = "新增成功";
}

这段代码能运行,但窗口同时知道:

  • 输入控件叫什么。
  • 标题怎么校验。
  • 服务怎么调用。
  • 集合怎么增删。
  • 结果文本怎么显示。

只要其中一项变化,例如换成 UserControl、改成批量导入、给测试写一个无界面调用,窗口代码就要跟着改。真正的问题不是“写了 Click”,而是“所有责任都塞进了 Click”。

控件逻辑问题图

图注:一个按钮事件如果同时读取控件、做业务校验、调用服务、更新界面,就会把四类责任绑在同一个视图方法里。

需要准备的文件、数据或环境

文件 作用
Views\CodeBehindView.xaml 左右两个对照区域
Views\CodeBehindView.xaml.cs 左侧按钮的 Click 事件
ViewModels\CodeBehindViewModel.cs 两种路径的状态和命令

这个页面故意保留了一个真实的 Click 事件,便于比较。它不用于证明“事件绝不能写”,而是说明事件里应该只保留界面转发职责。

第一步操作:在左侧输入

打开 3 控件逻辑 页签。左侧“反例:控件事件直写”区域有一个“新任务标题”输入框。输入“仓库门磁测试”。

右侧输入框和左侧绑定同一个 NewTaskTitle 属性,因此两边文字同步。这是绑定带来的效果,不是两个 TextBox 互相复制文本。

第二步操作:点击左侧事件按钮

点击“事件直接新增”。页面提示“控件事件直接修改了界面集合,新增 #5”,并说明业务规则散落在视图中。#

打开 Views\CodeBehindView.xaml.cs,可以看到事件处理本身很短:

1
2
3
4
5
6
7
private void AddByCodeBehind_Click(object sender, RoutedEventArgs e)
{
if (DataContext is CodeBehindViewModel viewModel)
{
viewModel.SimulateCodeBehindAdd();
}
}

示例为了让你看到对比,仍然让事件转发给 ViewModel;如果完全照反例写,事件里会直接出现 TextBox.Text、服务调用和 Tasks.Add。你可以把这段方法改成那样,观察代码边界如何变化。

第三步操作:点击右侧命令按钮

点击“ViewModel 命令新增”。这次按钮没有 Click 事件,而是通过 Command 执行 ViewModel 中的 AddByViewModelCommand。页面提示“ViewModel 处理了新增逻辑”,右侧结果与左侧不同。

两种入口都新增了任务,但右侧的入口只有一个业务动作对象,便于替换、测试和复用。

关键代码逐段解释

控件事件式写法的关键问题可以拆成下面四段:

1
2
3
4
var title = TitleTextBox.Text.Trim();        // 读取控件
if (title.Length == 0) return; // 业务校验
var task = _service.AddTask(title); // 调用服务
Tasks.Add(task); // 修改界面集合

这不是“语法错误”,而是“职责没有分开”。第一行让业务依赖控件,第二行让规则依赖窗口,第三行让窗口知道服务,第四行让窗口知道集合类型。

在示例中,业务动作集中到:

1
2
3
4
5
6
7
8
9
10
private void AddByViewModel()
{
var title = string.IsNullOrWhiteSpace(NewTaskTitle)
? "ViewModel 任务"
: NewTaskTitle.Trim();

var task = _service.AddTask(title, "ViewModel 区域", InspectionPriority.Normal);
Tasks.Add(task);
VmMessage = $"ViewModel 处理了新增逻辑,新增 #{task.Id};视图只负责转发输入。";
}

这里 ViewModel 仍然保存集合,但不再知道按钮叫什么、TextBox 在哪里。View 只需要绑定输入和命令。

运行后应该看到什么

第 3 章真实运行截图

图注:左侧是保留的控件事件反例入口,右侧是命令入口;下方两块说明可测性差异。两条路径都能新增任务,但代码边界不同。

预期状态:

  • 左右输入框共享同一个标题状态。
  • 左侧点击后出现“控件事件直接修改了界面集合”的提示。
  • 右侧点击后出现“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
2
3
4
5
6
public interface IInspectionTaskService
{
IReadOnlyList<InspectionTask> GetTasks();
InspectionTask AddTask(string title, string area, InspectionPriority priority);
bool CompleteTask(int id, string note);
}

接口只描述业务能力,不描述按钮和窗口。ViewModel 依赖这个接口,未来把内存实现替换成数据库实现时,ViewModel 的调用代码不需要重写。

第二步操作:筛选与选择任务

运行程序并打开 4 分层架构 页签。在右上角下拉框选择“C 区 · 泵房”。列表只显示该区域的任务,LayerMessage 显示筛选器已改变。

这不是在 XAML 里写 LINQ 过滤,而是 ViewModel 的计算属性:

1
2
3
4
public IEnumerable<InspectionTask> VisibleTasks =>
AreaFilter == "全部区域"
? Tasks
: Tasks.Where(task => task.Area == AreaFilter);

AreaFilter 的 setter 发出 VisibleTasks 的属性变化通知,界面才会重新读取筛选结果。

第三步操作:完成所选任务

选择“配电柜温度巡检”,点击“完成所选任务”。按钮调用 CompleteCommand,命令检查 CanExecute,然后执行 CompleteSelected()。服务返回成功,任务状态变成“已完成”,深色卡片显示当前职责链。

分层调用流程图

图注:按钮只发起命令;ViewModel 校验并调用服务;Service 修改 Model;Model 状态变化后再由 ViewModel 通知界面刷新。

关键代码逐段解释

ViewModel 的构造函数接收接口:

1
2
3
4
5
6
public LayeredArchitectureViewModel(IInspectionTaskService service)
{
_service = service;
Tasks = new ObservableCollection<InspectionTask>(service.GetTasks());
CompleteCommand = new RelayCommand(CompleteSelected, CanCompleteSelected);
}

这行 _service = service 是依赖注入的最小形式。ViewModel 不需要知道实现类的名字,只需要知道接口能做什么。

完成动作的调用顺序:

1
2
3
4
5
6
7
8
9
10
11
public void CompleteSelected()
{
if (SelectedTask is null) return;

if (_service.CompleteTask(SelectedTask.Id, "由分层架构页完成。"))
{
SelectedTask.Status = InspectionStatus.Completed;
OnPropertyChanged(nameof(SelectedTask));
LayerMessage = "服务层返回成功,ViewModel 更新状态。";
}
}

顺序很重要:先让服务执行,服务返回成功后再更新 ViewModel 的展示状态。不要先改界面“看起来完成”,再假设服务一定成功。

Service 只操作 Model:

1
2
3
4
5
6
7
8
9
public bool CompleteTask(int id, string note)
{
var task = _tasks.FirstOrDefault(item => item.Id == id);
if (task is null) return false;

task.Status = InspectionStatus.Completed;
task.Note = note;
return true;
}

它没有 MessageBox、没有 Dispatcher、没有 Button。因此这个方法可以在控制台或测试里直接调用。

运行后应该看到什么

第 4 章真实运行截图

图注:左侧是筛选后的任务列表,右侧显示当前职责链。完成动作后,任务状态文字更新,按钮会根据 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 在点击按钮时会:

  1. 调用 CanExecute(parameter),返回 false 就不执行。
  2. 返回 true 时调用 Execute(parameter)。
  3. 当命令状态变化时,通过 CanExecuteChanged 通知按钮重新检查。

ICommand 使用图

图注:Button 只依赖 ICommand 的三个成员;RelayCommand 把普通委托包装成命令对象。无参数命令适合表单动作,带参数命令适合列表行操作。

需要准备的文件、数据或环境

文件 作用
Infrastructure\RelayCommand.cs 无参数与带参数命令实现
ViewModels\BasicCommandViewModel.cs 新增、删除、重置命令
Views\BasicCommandView.xaml 输入区、任务列表、删除按钮

第一步操作:执行无参数命令

打开 5 基本命令 页签。标题、区域和优先级已经填入示例数据。点击“执行 AddTaskCommand”。

结果区显示“命令执行第 1 次:新增任务 #5”。再次点击,执行次数增加,列表也多一行。#ExecuteCount 是命令真正执行后才更新的,因此它能作为运行结果的证据。

第二步操作:使用带参数命令删除一行

在右侧列表中找到“消防通道清点”,点击它所在行的“删除”按钮。命令收到 CommandParameter,删除的是这一行任务,而不是固定的第一条。

XAML 的关键是:

1
2
3
4
<Button Content="删除"
Command="{Binding DataContext.DeleteTaskCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"/>

CommandParameter="{Binding}" 表示把当前行的任务对象传给命令;RelativeSource 用来从行数据跳到外层 ViewModel 的命令。

第三步操作:重置并填入示例

点击“重置集合”,任务列表恢复为初始数据。再点击“填入示例”,输入区恢复默认文本。两个按钮分别演示“集合状态重置”和“输入状态重置”,都不需要后台代码直接操作控件。

关键代码逐段解释

最小 RelayCommand 的核心只有三个成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;

public event EventHandler? CanExecuteChanged;

public bool CanExecute(object? parameter)
=> _canExecute?.Invoke(parameter) ?? true;

public void Execute(object? parameter)
=> _execute(parameter);

public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

_canExecute 为 null 时默认允许执行;RaiseCanExecuteChanged 不是 ICommand 接口强制要求的成员,而是我们额外加的公开方法,方便 ViewModel 主动通知界面。

带参数版本的关键在类型转换:

1
2
3
4
private static T? ConvertParameter(object? parameter)
{
return parameter is T value ? value : default;
}

如果参数类型不匹配,就返回默认值。真实项目里可以在这里记录日志,避免把错误悄悄吞掉。

运行后应该看到什么

第 5 章真实运行截图

图注:左侧输入区执行无参数新增命令,右侧每一行的删除按钮执行带参数命令。结果区显示 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

protected bool SetProperty<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;

field = value;
OnPropertyChanged(propertyName);
return true;
}

protected void OnPropertyChanged(
[CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

CallerMemberName 让 OnPropertyChanged() 自动拿到调用它的属性名,避免手写字符串拼错。相等时直接返回 false,是为了避免无意义的通知。

计算属性的通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int Temperature
{
get => _temperature;
set
{
if (SetProperty(ref _temperature, value))
{
OnPropertyChanged(nameof(TemperatureLevel));
OnPropertyChanged(nameof(TemperatureDisplay));
}
}
}

public string TemperatureDisplay => $"{Temperature} ℃ · {TemperatureLevel}";

TemperatureDisplay 没有 setter,不能靠自身变化发通知;它依赖 Temperature,所以必须由 Temperature 的 setter 通知它。

运行后应该看到什么

第 6 章真实运行截图

图注:左侧是绑定表单,右侧是通知观察窗。修改温度后,温度显示与等级同时刷新;生成摘要时,计算属性也得到显式通知。

预期状态:

  • 修改巡检人后通知次数增加。
  • 温度超过 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
2
3
4
public bool CanSave =>
!IsBusy &&
!string.IsNullOrWhiteSpace(DraftTitle) &&
!string.IsNullOrWhiteSpace(DraftNote);

判断条件全部来自 ViewModel 属性,不读取任何控件。这样测试可以直接设置属性并检查 CanSave。

状态变化时主动通知:

1
2
3
4
5
6
7
8
9
private void UpdateCommandState()
{
SaveCommand.RaiseCanExecuteChanged();
CommandState = IsBusy
? "忙碌中:即使字段完整,SaveCommand 也不可用。"
: CanSave
? "命令可用:标题和备注均已填写"
: "命令不可用:请填写标题和备注";
}

RaiseCanExecuteChanged() 让 WPF 重新询问命令“现在能不能执行”;CommandState 只是给用户看的解释文本。两者都要更新,但职责不同。

运行后应该看到什么

第 7 章真实运行截图

图注:初始备注为空,保存按钮禁用,状态卡与按钮一致。填写备注后按钮可用;模拟忙碌后再次禁用。

预期状态:

  • 初始“保存草稿”按钮灰色。
  • 备注为空时状态卡提示不可用。
  • 填入备注后按钮变亮。
  • 点击保存后任务增加,备注清空,按钮再次变灰。
  • 模拟忙碌时,即使字段完整也不可保存。

跟做检查点

  • 我知道按钮禁用不等于业务失败。
  • 我能说出 CanSave 依赖哪三个状态。
  • 我知道忙碌状态也要参与 CanExecute。
  • 我能解释为什么保存后要清空备注。
  • 我不会在 View 里手写 button.IsEnabled。

常见错误与解决方法

常见错误 现象 解决方法
只改 CanSave 不调用 RaiseCanExecuteChanged 状态值变了,按钮外观没变 在 setter 或统一更新方法中通知命令
忙碌时仍允许保存 重复提交、重复新增任务 在 CanExecute 中加入 !IsBusy
保存后不清空草稿 用户可能重复保存同一内容 保存成功后重置输入状态
把状态文字当成命令状态 文字说可用,按钮仍灰 以 CanExecute 的结果为准,文字只做解释
在多个事件里分别更新按钮 状态一多就漏更新 集中到 UpdateCommandState()

小练习

给 CanSave 增加一个“标题至少 4 个字符”的条件,并同步更新状态文字。测试输入“屋顶”时按钮是否禁用,输入“屋顶排风机复核”时是否恢复。

本章小结

命令对象保存业务规则和状态。CanExecute 负责“现在能不能执行”,Execute 负责“执行什么”。最后一章要回答一个容易混淆的问题:状态改变后,到底什么时候重新检查 CanExecute。

8、命令对接状态检查的调用时机

本节目标

能说清 CanExecute 不会在任意状态变化时自动重算,知道主动通知和 CommandManager 统一查询两条路径,并能在代码中选择正确的检查时机。

通俗原理说明

很多人会以为:只要 CanExecute 方法里的判断条件变了,按钮就会立刻更新。实际并不是这样。CanExecute 是一个方法,它需要被某个时机调用。

常见触发点:

  1. 输入变化后,属性 setter 更新状态。
  2. 列表选择变化后,选中项 setter 更新状态。
  3. 命令执行完成后,业务状态发生变化。
  4. 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
2
3
4
5
6
7
8
9
10
11
12
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (SetProperty(ref _selectedIndex, value))
{
OnPropertyChanged(nameof(SelectedTask));
CompleteByCommand.RaiseCanExecuteChanged();
}
}
}

选中项是命令状态的一部分,所以它变化后必须通知命令。只通知 SelectedTask 不会自动让按钮重新检查。

命令执行后检查:

1
2
3
4
5
6
7
8
9
private void CompleteSelected()
{
if (SelectedTask is null) return;

_service.CompleteTask(SelectedTask.Id, "在第 8 页通过命令完成。");
SelectedTask.Status = InspectionStatus.Completed;
OnPropertyChanged(nameof(SelectedTask));
CompleteByCommand.RaiseCanExecuteChanged();
}

业务状态改变后,按钮的可用性可能改变,因此执行完成后也要检查一次。

统一入口的写法:

1
2
3
4
5
6
7
private void UpdateCommandState()
{
SaveCommand.RaiseCanExecuteChanged();
CommandState = IsBusy
? "忙碌中"
: CanSave ? "可用" : "不可用";
}

当多个属性都影响同一个命令时,把所有影响点集中到一个方法里,比在每处零散写通知更容易维护。

运行后应该看到什么

第 8 章真实运行截图

图注:左侧选择任务,右侧显示检查结果。完成待巡检任务后按钮禁用;主动检查和事件入口检查会分别增加检查计数。

预期状态:

  • 未选中任务时,完成命令不可用。
  • 选择待巡检任务后,完成命令可用。
  • 完成后再选择同一条任务,完成命令不可用。
  • 点击“主动检查状态”和“事件入口检查”,检查次数和右侧消息同步变化。

跟做检查点

  • 我知道 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
截图内容为空 是否在窗口完成布局后渲染,路径是否存在 验证

最终验证记录

本次交付实际执行了以下验证:

  1. dotnet build .\InspectionMvvmDemo.csproj:成功,0 警告,0 错误。
  2. --self-test:8 项通过,0 项失败,退出码 0。
  3. --capture-tabs:真实渲染 8 个页签,生成 8 张运行截图。
  4. 9 张原理图全部生成,并逐张检查中文、连线、留白和遮挡。
  5. 自测结果写入 artifacts\self-test.txt,异常日志写入 artifacts\logs\application.log。

自测结果文件的实际内容摘要:

1
2
3
4
5
6
7
8
9
10
11
PASS  第 1 章:刷新任务并通知界面
PASS 第 2 章:命令提交新增任务
PASS 第 3 章:事件路径与 ViewModel 路径均能新增
PASS 第 4 章:分层完成所选任务
PASS 第 5 章:无参数命令和带参数命令
PASS 第 6 章:计算属性随源属性刷新
PASS 第 7 章:命令状态随输入和忙碌状态变化
PASS 第 8 章:完成命令后重新检查状态
通过:8
失败:0
结论:全部通过

学习完成后的下一步

如果已经能独立解释本笔记的 8 个章节,可以继续做三件事:

  1. 把 InMemoryInspectionTaskService 换成读取本地 JSON 的实现,但保持接口不变。
  2. 给 ViewModel 写一个真正的测试项目,不启动 WPF 窗口,只测试属性和命令。
  3. 把 8 个页面拆成独立导航页面,观察 ViewModel 复用是否仍然成立。

这三项都建立在本笔记的分层与命令边界之上,不需要引入第三方 MVVM 框架也能完成。