界面里那些原本堆成一团的点击处理、校验逻辑和数据库调用,被抽离到ViewModel之后,Form只剩下数据绑定和少量事件接入,代码一下子好维护多了。单元测试可以直接跑ViewModel,不再需要跑界面,团队都能更快定位问题,开发效率提高了。

把结果说完,倒回去讲过程。最后的项目分成几层:UI层只负责控件绑定和少许交互,ViewModel承载状态、验证和命令,Model/Repository处理数据访问,依赖注入把这些串起来。关键点是用Fody把INotifyPropertyChanged的样板代码自动织入、用DependsOn标记维护计算属性的依赖、用简单的校验接口配合WinForm的ErrorProvider展示错误信息。这样一来,原来在Form里出现的字符串拼接、校验重复、数据库直接写在事件里的问题都消失了。
回到实现细节。先说准备工作:项目里加几个NuGet包,Fody 和 PropertyChanged.Fody是核心,另外加个依赖注入库(列如
Microsoft.Extensions.DependencyInjection),还有测试会用的Mock库。安装好后,要在项目根目录放一份FodyWeavers.xml,声明使用PropertyChanged。Fody会在编译阶段把INotifyPropertyChanged的实现和属性变更通知的发射代码自动插入,不用你手工写INotifyPropertyChanged接口和OnPropertyChanged方法。

ViewModel的写法很简单:把需要通知变化的字段当作普通自动属性写就行,Fody会替你把set里的通知织进去。对有计算逻辑的属性,标记DependsOn,告知Fody哪些基础属性变化时要触发计算属性的通知。举个常见场景:FirstName、LastName两个普通属性,FullName是组合属性。给FullName加上DependsOn(nameof(FirstName), nameof(LastName)),这样改FirstName或LastName时,界面绑定FullName也能自动刷新。写法不是很复杂,但要注意:如果你在属性里写了自定义的set逻辑,就得小心和Fody的织入冲突,许多时候更稳妥的做法是把复杂逻辑放到方法里调用,而不是在set里做太多事。
数据绑定在WinForm里的实践也要讲清楚。不要把DataSource直接设置成Model对象,一般用BindingSource包装ViewModel。Form的初始化里做的事情是:bindingSource.DataSource = viewModel;然后各个控件用DataBindings.Add把控件属性和ViewModel属性绑定,DataSourceUpdateMode设为OnPropertyChanged可以让输入即时同步到ViewModel。ErrorProvider用起来也挺直观:在控件的Validating事件里,去查ViewModel的校验结果(列如用IDataErrorInfo或者自定义的Validate方法),把错误信息传给ErrorProvider.SetError(control, errorMsg)。这样显示上和以前没差,但逻辑上一切都在ViewModel里,界面只负责展示。
关于校验,WinForm没有像WPF那样内置的校验管道,所以选择一种容易配合的方式更实用。我常用的做法是让ViewModel实现IDataErrorInfo或者提供一个GetErrors的方法。具体做法是把校验规则写在ViewModel内部:类型检查、必填校验、跨字段校验都在这里做。保存时再统一触发一次整体校验,如果有错误就返回并把错误信息回写给界面。由于校验逻辑和格式化都在ViewModel,测试用例可以直接断言各种输入下的错误信息,不用去启动窗体。
命令(Command)在WinForm里不是天然存在,所以需要一点适配。可以实现一个简单的DelegateCommand,包含CanExecute和Execute,然后在Form里把按钮的Click事件绑到一个通用方法,这个方法去调用
viewModel.SaveCommand.Execute(null)前会先检查CanExecute。另一种方式是在初始化阶段把按钮的Enabled绑定到viewModel的某个布尔属性,这样按钮状态会随着ViewModel变化自动改变。关键是尽量把业务判断放到ViewModel里,表面上的事件处理只负责把控件的动作转发过去。
再说依赖注入和数据层。不要把Repository硬编码在ViewModel里,通过构造函数注入接口更容易测试。启动时在Program.cs里用ServiceCollection注册:把Repository、数据上下文、ViewModel都注册进容器,然后解析出主窗体的依赖并运行。这样写的好处是,测试时用Mock替换Repository就能把网络或数据库调用完全隔离开来,只测试ViewModel逻辑。
回过头谈谈原先的问题。许多团队的WinForm项目犯的老毛病是:所有逻辑都往Form里塞,按钮点击里既有输入转换、校验、网络调用、还有UI刷新。结果是代码像一团线,改一个地方常常打乱别处。单元测试基本做不了,某些逻辑要改就得开界面跑一圈。维护成本高,交接也难。把这些责任拆开后来,事情就清楚了——界面只做展示,ViewModel做状态和规则,Repository负责数据。Fody在这件事里起到减负的作用:不再需要反复写样板代码来触发PropertyChanged,依赖关系标注也省了大量手工维护工作。
在实战里有几个坑要注意。第一,Fody的版本敏感,升级时会有兼容问题,最好在项目里固定版本并在CI里跑一次全量编译。第二,自动属性和手写属性混用时要小心,有些需要在set里干的副作用要换成方法调用或者事件订阅方式实现,否则会出现通知漏发或重复执行的情况。第三,WinForm的绑定机制对类型转换比较挑剔,绑定时要注意DataSourceUpdateMode和格式化选项,数值和日期类型最好在ViewModel里统一做转换,别把字符串解析放在Form里。
测试写法也有讲究。既然业务逻辑都搬到ViewModel,就直接写针对ViewModel的单元测试:修改属性后断言PropertyChanged触发和相关计算属性变化;模拟Repository返回不同结果,断言SaveCommand的可用性和调用次数;验证校验逻辑在各种异常输入下返回的错误消息。这样的测试不需要启动窗体,跑得快,覆盖面也容易做到。
开发流程上我一般这么安排:把一个功能拆成界面、ViewModel、Repository三块;先写好ViewModel和对应的单元测试,保证逻辑正确;再写Repository的接口和简单实现;最后做Form绑定和展示,把界面跑起来验收。这样流程倒着走一次,问题能在最早阶段被发现,后续改动也更安全。团队配合上也好办,界面开发和后台逻辑可以并行推进,互不阻塞。
说点小感想吧:把WinForm从“事件地狱”改成“数据驱动”的模式后,代码看着舒服多了,像把桌面项目从散乱的文件夹整理成有序的抽屉。用Fody省的那些重复劳动,让人能把精力放在真正的业务上。不过,这套方法不是神丹妙药,得按规矩来用,尤其是版本控制和团队约定要统一,才能长期受益。
最后提一句实用的小技巧:在调试绑定问题时,把BindingSource的Current属性输出一下,确认它的确 指向了你期望的ViewModel实例;在校验显示上,ErrorProvider配合控件的Validating事件最好不要写成一锅粥,拆成每个控件针对性的检查,用户体验会更好。