当一个页面只有两个按钮时,把逻辑写进界面事件似乎最省事;等到加载、验证、保存、错误提示和导航同时出现,控件与业务状态就会纠缠在一起。MVVM 的意义不是增加三个文件夹,而是让界面只负责展示,让交互状态可以脱离 UI 独立运行和测试。
本章用“专注任务板”贯穿全部概念:输入任务、添加到列表、筛选完成状态、异步保存、显示错误并打开详情。示例只依赖通用 C# 契约;DataContext 与 BindingContext 等框架差异会明确放在边界处。

完成本章后,你应该能够:
INotifyPropertyChanged 和 ObservableCollection<T> 发出精确通知。ICommand、同步/异步 RelayCommand 表达交互意图。路线是:先建立职责图,再接通绑定通知,随后处理集合与命令,最后把异步服务、验证和测试组合成完整页面。
本节自测
判断一段代码放哪里,不要只看它“和页面有关”,而要看它因为什么变化:
任务实体保持领域含义:
public sealed record TodoItem(Guid Id, string Title, bool Completed);
public interface ITodoService
{
Task<IReadOnlyList<TodoItem>> LoadAsync(CancellationToken token);
Task SaveAsync(IReadOnlyList<TodoItem> items, CancellationToken token);
}“标题不能为空”既可能是领域不变量,也可能需要即时输入反馈。领域层负责最终拒绝无效数据;ViewModel 可以提前暴露友好的错误状态,但不能替代领域检查。
本节自测
绑定上下文是 View 查找属性和命令的根。WPF、WinUI 常称 DataContext,MAUI 常称 BindingContext;命名不同,作用相同。
// 框架组合根中建立 View 与 ViewModel 的关系。
view.DataContext = todoViewModel; // 一类桌面框架
view.BindingContext = todoViewModel; // 一类跨平台框架绑定模式决定值的传播方向:
输入框还要决定更新时机:每次键入、失去焦点或显式提交。即时验证适合每次键入;昂贵搜索应加防抖,而不是每个字符都发请求。
本节自测
绑定引擎不会持续轮询。ViewModel 通过 INotifyPropertyChanged 告诉 View 哪个属性失效:
using System.ComponentModel;
using System.Runtime.CompilerServices;
public abstract class ObservableViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(
ref T field,
T value,
[CallerMemberName
SetProperty 的顺序很重要:比较、赋值、通知。相同值不通知,能避免多余刷新和命令状态重算。
private string _newTitle = "";
public string NewTitle
{
get => _newTitle;
set
{
if (SetProperty(ref _newTitle, value))
{
OnPropertyChanged(nameof(CanAdd));
}
}
}
public bool CanAdd => !string.IsNullOrWhiteSpace(NewTitle);上例说明“派生属性也要通知”。基类提供受保护的 OnPropertyChanged,让子类不必也不能直接触发基类事件。

本节自测
普通 List<T> 不会在增删时通知 UI。ObservableCollection<T> 会发送 CollectionChanged:
public ObservableCollection<TodoItemViewModel> Items { get; } = [];
Items.Add(new TodoItemViewModel(item));
Items.Remove(selected);它只负责集合结构。若某个元素的 Completed 改变,元素自身仍要实现属性通知。替换整个集合时,也要对集合属性发送 PropertyChanged。
集合通常与 UI 线程有亲和性。后台加载完成后,不要假定能从任意线程直接批量修改已绑定集合;应通过框架调度器回到 UI 线程,或先在后台构造普通列表,再在边界一次更新。
大量数据还要考虑分页、虚拟化和批量通知。逐条向数万项集合 Add,即使逻辑正确,也可能让界面频繁重排。
本节自测
ICommand 统一表达“能否执行”与“执行什么”:
public interface ICommand
{
event EventHandler? CanExecuteChanged;
bool CanExecute(object? parameter);
void Execute(object? parameter);
}教学版 RelayCommand:
public sealed class RelayCommand(Action execute, Func<bool>? canExecute = null)
: ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? p) => canExecute?.Invoke() ?? true;
public
命令依赖 NewTitle 时,该属性变化后必须刷新 CanExecute。View 只绑定命令,不再复制“空文本不可添加”的判断。
AddCommand = new RelayCommand(
() =>
{
Items.Add(new TodoItemViewModel(NewTitle));
NewTitle = "";
},
() => !string.IsNullOrWhiteSpace(NewTitle));
本节自测
异步交互必须显式建模,而不是把 async void 塞进同步命令:
private bool _isBusy;
private string? _errorMessage;
public bool IsBusy
{
get => _isBusy;
private set => SetProperty(ref _isBusy, value);
}
public string? ErrorMessage
{
get => _errorMessage;
private set => SetProperty(ref _errorMessage, value);
private async Task SaveAsync(CancellationToken token)
{
if (IsBusy) return;
try
{
IsBusy = true;
ErrorMessage = null;
await _todoService.SaveAsync(Items.Select(x => x.Model).ToList(), token);
}
catch (OperationCanceledException) when
命令应在忙碌时禁用或选择明确的并发策略;取消令牌需要一路传到服务。异常应变成可绑定状态或交给统一错误边界,不能静默吞掉。
本节自测
Toolkit 用基类、命令和源生成器实现同一套通知协议:
public partial class TodoBoardViewModel : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string newTitle = "";
public ObservableCollection<TodoItemViewModel> Items { get; } = [];
[RelayCommand(CanExecute = nameof(CanAdd))]
private
源生成器创建公开属性、PropertyChanged 通知与 AddCommand。特性不会自动决定架构边界;服务仍应注入,异常仍要处理,命令是否允许并发仍要设计。

本节自测
布尔状态到可见性、颜色或图标的转换属于表现层。简单框架可用转换器,支持表达式的框架也可直接声明:
public sealed class InverseBooleanConverter
{
public object Convert(object value) => value is bool b && !b;
}不要把业务判断藏在转换器里。IsOverdue 可以由 ViewModel 或领域对象表达;“过期时显示橙色”由 View 决定。日期、货币等文化格式也优先放绑定格式或本地化服务,而不是把预格式化字符串扩散到领域模型。
Value converter 应保持无副作用、快速、确定。复杂异步操作绝不能放在转换器中,因为绑定引擎可能多次调用它。
本节自测
ViewModel 可以暴露错误集合和总体有效性:
private string? _titleError;
public string? TitleError
{
get => _titleError;
private set => SetProperty(ref _titleError, value);
}
private void ValidateTitle()
{
TitleError = string.IsNullOrWhiteSpace(NewTitle)
? "请输入任务标题"
: NewTitle.Length > 80
?
验证发生在字段变化、失焦还是提交时,取决于体验要求。字段级即时反馈要避免用户刚打开页面就看到满屏错误;提交时仍需做完整校验。服务端拒绝、并发冲突等不能被本地验证取代。
本节自测
ViewModel 不应 new DetailsPage() 或直接调用窗口对象。用窄接口表达意图:
public interface INavigationService
{
Task OpenTodoAsync(Guid todoId, CancellationToken token = default);
}
public sealed class TodoBoardViewModel(
ITodoService todos,
INavigationService navigation) : ObservableViewModel
{
private async Task OpenAsync(TodoItemViewModel item) =>
await
接口由应用组合根提供框架实现。相同方法在测试中可替换为记录参数的 Fake。对话框、剪贴板、时钟和文件选择器也适用,但不要为每一行代码制造接口;只隔离会变化、难测试或真正属于平台的边界。
本节自测
测试无需启动 UI。注入 Fake 服务,执行属性或命令,然后断言:
var service = new FakeTodoService(
[
new(Guid.NewGuid(), "整理需求", false),
new(Guid.NewGuid(), "补单元测试", true)
]);
var navigation = new SpyNavigationService();
var vm = new TodoBoardViewModel(service, navigation);
await vm.LoadCommand.ExecuteAsync(null
还要覆盖空输入、命令状态变化、服务异常、取消、重复点击与导航参数。属性通知本身可通过订阅 PropertyChanged 验证,但应优先测试用户可观察行为,避免测试过度绑定实现细节。

本节自测
评审时依次问:状态谁拥有?变化如何通知?命令何时可执行?异步能否取消与恢复?外部依赖能否替换?纯视觉行为是否误入 ViewModel?
本节自测
实现下面的完整数据流:
LoadCommand,加载期间显示忙碌状态。AddCommand.CanExecute。SaveCommand 防重复执行,支持取消并暴露错误。OpenCommand 只把任务 ID 交给导航服务。public partial class TodoBoardViewModel(
ITodoService service,
INavigationService navigation) : ObservableObject
{
public ObservableCollection<TodoItemViewModel> Items { get; } = [];
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string newTitle = "";
[
实践任务:为集合变化刷新 SaveCommand;实现 LoadAsync;让元素完成状态更新未完成计数;写出成功、错误、取消、空输入和导航五组测试。
本节自测
MVVM 的可靠性来自清晰的数据流:View 声明绑定,ViewModel 暴露状态和命令,Model/服务守住业务与外部副作用。INotifyPropertyChanged 处理属性,ObservableCollection 处理集合结构,ICommand 处理交互意图;异步流程还要显式管理忙碌、取消和错误。
Toolkit 能减少样板,却不能代替边界设计。判断架构是否健康的最好方式,是看 ViewModel 能否在没有窗口、页面和真实网络的情况下被完整验证。
本节自测