MVVM 解决的真正问题
代码后置(code-behind)在界面简单时很好用:按钮点击直接写逻辑,一目了然。但随着业务变复杂,事件处理器会迅速膨胀成上千行,界面元素与业务规则缠在一起,既无法单元测试,也不敢轻易改动。MVVM 的做法是把「视图状态与行为」抽到视图模型里,视图只负责把状态画出来、把操作转发出去。
MVVM 的价值不在分层好看,而在于让业务逻辑摆脱界面框架——视图模型可以像普通类一样被构造、被断言。
- Model数据与业务规则,不依赖任何界面类型。
- ViewModel暴露可绑定属性与命令,承载页面的状态与行为。
- ViewXAML 声明式描述结构,只在必要时写极少量代码后置。
数据绑定与通知机制
绑定把界面属性与视图模型属性连起来,源发生变化时目标自动更新。要让单向绑定真正生效,视图模型必须实现属性变更通知接口;需要双向同步的输入控件再额外支持目标到源的更新。集合则要使用可观察类型,增删元素时界面才会跟随变化。
public sealed class ArticleViewModel : INotifyPropertyChanged
{
private string _keyword = string.Empty;
public string Keyword
{
get => _keyword;
set
{
if (_keyword == value) return;
_keyword = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Filtered));
}
}
public IEnumerable<Article> Filtered =>
All.Where(a => a.Title.Contains(Keyword, StringComparison.Ordinal));
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
这段代码的关键点是:设置属性时先比较再赋值,避免无意义的通知造成界面反复刷新;派生属性在依赖项变化时手动通知,否则筛选结果不会更新。这两处细节往往就是「界面卡」与「数据不刷新」两类问题在代码里的根源。
命令:把用户操作变成可测逻辑
命令把「点击按钮」抽象为「执行一个操作」,并附带「当前能否执行」的状态。视图绑定命令后,按钮的可用状态由命令自动驱动,不需要在业务代码里逐个去改控件的启用属性。命令的另一个价值是可测试:它只是一个方法,在单元测试里直接调用即可验证行为。
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
异步操作要额外注意:命令在执行期间应当把可执行状态置为 false,防止用户连点造成重复提交;同时要在内部捕获异常并反馈到界面,因为命令执行路径上的异常不会被自动呈现给用户。
控件模板与样式复用
WPF 的样式系统分离了「外观」与「结构」:样式设置属性,控件模板重写视觉树,数据模板决定数据如何呈现。这种分离让同一个控件可以在不同场景换一套外观而不改逻辑,也是做出与系统控件不同视觉风格的正规途径,比继承控件重写绘制要轻量得多。
- 层级分明基础色与尺寸放最底层,控件样式次之,页面样式最后覆盖。
- 动态资源主题相关的画笔用动态资源引用,支持运行时换肤。
- 模板精简自定义模板要保留触发器与状态视觉,否则控件会失去悬停反馈。
- 可访问性保留键盘焦点视觉,不要为了美观把焦点框去掉。
依赖注入与模块划分
视图模型之间会互相协作:列表页需要打开详情页,主窗口需要协调多个页面的状态。把服务与视图模型注册进依赖注入容器,由容器负责创建与生命周期管理,可以避免到处写单例与静态变量——后者在测试时几乎无法替换,长期会让代码变得难以验证。
模块划分建议按业务域而不是按类型:把订单相关的模型、服务与视图模型放在同一目录,删功能时整块移除;公共能力如日志、配置、网络客户端放在基础设施目录并被业务模块依赖,反向依赖要坚决禁止,否则模块会逐渐退化成一张互相引用的网。
实战中的常见陷阱
第一类陷阱在绑定路径上:绑定的属性名写错不会抛异常,只会在输出窗口留下一条不起眼的提示,界面则一片空白。排查时应当先看输出窗口的绑定错误日志,再核对数据上下文是否传递到位——DataContext 没有继承到子元素,是「模板里绑定不生效」最常见的原因。
第二类陷阱是虚拟化被破坏。在列表项模板里嵌套一个高度无限的容器,或者给列表设置自动高度并放进滚动视图,都会让虚拟化失效,一次性创建全部元素。表现是长列表打开就卡、滚动掉帧,而代码看起来毫无问题。
第三类陷阱是跨线程更新界面。绑定机制会自动把通知切回界面线程,但直接操作控件属性不会,此时会抛出线程所有权异常。稳妥做法是把耗时计算放在后台,把结果通过绑定或调度器切回界面线程再更新状态。
小结与延伸阅读
MVVM 落地只需要三步:视图模型实现通知接口、用户操作改成命令、视图只保留绑定与必要的最小代码后置。
- 需要现代化的界面方案,读《WinUI 3 桌面应用开发入门》。
- 语言与集合基础,见《C# 语言核心:.NET 开发基石》。
- 服务与运行时细节,读《.NET 8 新特性与应用开发》。