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

学习目标与实现路线
完成本章后,你应该能够:
- 划清 View、ViewModel、Model 与服务的职责。
- 选择 OneWay、TwoWay 等绑定模式,并正确设置绑定上下文。
- 用
INotifyPropertyChanged和ObservableCollection<T>发出精确通知。 - 用
ICommand、同步/异步 RelayCommand 表达交互意图。 - 使用 CommunityToolkit.Mvvm 减少样板,同时读懂生成代码的行为。
- 把验证、导航、错误与测试放到清晰边界内。
路线是:先建立职责图,再接通绑定通知,随后处理集合与命令,最后把异步服务、验证和测试组合成完整页面。
本节自测
三类角色:按变化原因拆分
判断一段代码放哪里,不要只看它“和页面有关”,而要看它因为什么变化:
任务实体保持领域含义:
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 可以提前暴露友好的错误状态,但不能替代领域检查。
本节自测
BindingContext / DataContext 与绑定模式
绑定上下文是 View 查找属性和命令的根。WPF、WinUI 常称 DataContext,MAUI 常称 BindingContext;命名不同,作用相同。
// 框架组合根中建立 View 与 ViewModel 的关系。
view.DataContext = todoViewModel; // 一类桌面框架
view.BindingContext = todoViewModel; // 一类跨平台框架绑定模式决定值的传播方向:
输入框还要决定更新时机:每次键入、失去焦点或显式提交。即时验证适合每次键入;昂贵搜索应加防抖,而不是每个字符都发请求。
本节自测
INotifyPropertyChanged:让单个状态可观察
绑定引擎不会持续轮询。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] 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(propertyName));
}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,让子类不必也不能直接触发基类事件。

本节自测
ObservableCollection:结构变化不等于元素变化
普通 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 与 RelayCommand:把手势变成意图
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 void Execute(object? p) => execute();
public void NotifyCanExecuteChanged() =>
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}命令依赖 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 (token.IsCancellationRequested)
{
// 用户取消不是业务错误。
}
catch (Exception ex)
{
ErrorMessage = ToUserMessage(ex);
}
finally
{
IsBusy = false;
}
}命令应在忙碌时禁用或选择明确的并发策略;取消令牌需要一路传到服务。异常应变成可绑定状态或交给统一错误边界,不能静默吞掉。
本节自测
CommunityToolkit.Mvvm:减少样板而不隐藏机制
Toolkit 用基类、命令和源生成器实现同一套通知协议:
public partial class TodoBoardViewModel : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string newTitle = "";
public ObservableCollection<TodoItemViewModel> Items { get; } = [];
[RelayCommand(CanExecute = nameof(CanAdd))]
private void Add()
{
Items.Add(new(NewTitle));
NewTitle = "";
}
private bool CanAdd() => !string.IsNullOrWhiteSpace(NewTitle);
}源生成器创建公开属性、PropertyChanged 通知与 AddCommand。特性不会自动决定架构边界;服务仍应注入,异常仍要处理,命令是否允许并发仍要设计。

本节自测
转换器、格式化与 View 边界
布尔状态到可见性、颜色或图标的转换属于表现层。简单框架可用转换器,支持表达式的框架也可直接声明:
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
? "标题不能超过 80 个字符"
: null;
AddCommand.NotifyCanExecuteChanged();
}验证发生在字段变化、失焦还是提交时,取决于体验要求。字段级即时反馈要避免用户刚打开页面就看到满屏错误;提交时仍需做完整校验。服务端拒绝、并发冲突等不能被本地验证取代。
本节自测
导航与服务:依赖接口,不持有页面
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 navigation.OpenTodoAsync(item.Id);
}接口由应用组合根提供框架实现。相同方法在测试中可替换为记录参数的 Fake。对话框、剪贴板、时钟和文件选择器也适用,但不要为每一行代码制造接口;只隔离会变化、难测试或真正属于平台的边界。
本节自测
ViewModel 测试:断言状态与协作
测试无需启动 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);
Assert.Equal(2, vm.Items.Count);
Assert.False(vm.IsBusy);
Assert.Null(vm.ErrorMessage);
Assert.Equal(1, service.LoadCalls);还要覆盖空输入、命令状态变化、服务异常、取消、重复点击与导航参数。属性通知本身可通过订阅 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 = "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand), nameof(SaveCommand))]
private bool isBusy;
[ObservableProperty]
private string? errorMessage;
private bool CanAdd() =>
!IsBusy && !string.IsNullOrWhiteSpace(NewTitle) && NewTitle.Length <= 80;
[RelayCommand(CanExecute = nameof(CanAdd))]
private void Add()
{
Items.Add(new TodoItemViewModel(NewTitle.Trim()));
NewTitle = "";
}
[RelayCommand(CanExecute = nameof(CanSave), IncludeCancelCommand = true)]
private async Task SaveAsync(CancellationToken token)
{
try
{
IsBusy = true;
ErrorMessage = null;
await service.SaveAsync(Items.Select(x => x.ToModel()).ToList(), token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
catch (Exception ex) { ErrorMessage = ToUserMessage(ex); }
finally { IsBusy = false; }
}
private bool CanSave() => !IsBusy && Items.Count > 0;
}实践任务:为集合变化刷新 SaveCommand;实现 LoadAsync;让元素完成状态更新未完成计数;写出成功、错误、取消、空输入和导航五组测试。
本节自测
小结:让状态、通知与副作用各归其位
MVVM 的可靠性来自清晰的数据流:View 声明绑定,ViewModel 暴露状态和命令,Model/服务守住业务与外部副作用。INotifyPropertyChanged 处理属性,ObservableCollection 处理集合结构,ICommand 处理交互意图;异步流程还要显式管理忙碌、取消和错误。
Toolkit 能减少样板,却不能代替边界设计。判断架构是否健康的最好方式,是看 ViewModel 能否在没有窗口、页面和真实网络的情况下被完整验证。
本节自测