我们把界面里的业务代码彻底抽离了:Form 里只剩下控件和绑定,所有验证、计算、保存逻辑都搬到 ViewModel,单元测试能跑,维护也方便多了。

这不是空话。改造后的一个真实项目里,原本每个按钮点击事件里堆着验证、数据拼接、调用数据库的方法,全都挪到独立的类里。界面只用 BindingSource 绑定 ViewModel,ErrorProvider 做校验提示,窗体几乎不再含业务判断。运行时的表现也直观:输入改了,控件马上更新;依赖计算字段会同步刷新;保存失败时,错误信息由 ViewModel 返回,窗体只负责显示。开发体验从“改一处要看十处”变成“改 ViewModel 就够了”。
把结果放在前面说明清楚后,再说回过程:这件事是怎么发生的,为什么要改,以及具体怎么改的。说白了,是由于传统 WinForm 项目里 UI 和业务混在一起,项目维护和测试都很痛。接下来按时间倒序把每步细节讲清楚,方便照着做。

最终运行的形态很简单:Form 里有个 BindingSource,把它的 DataSource 指向某个 ViewModel 实例。控件通过 DataBindings 绑定到 ViewModel 的属性。ViewModel 使用 Fody 自动实现属性变更通知,依赖计算用 [DependsOn] 标注。输入校验通过实现 IDataErrorInfo(或自己返回错误信息)配合 ErrorProvider 展示。按钮的可用状态通过绑定到 ViewModel 的布尔属性控制,点击时调用 ViewModel 的方法或命令。这样,界面代码量少,测试方便,团队协作顺畅。
在把界面压缩到这种形态之前,开发过程中的关键改动如下:
部署了 Fody 的 PropertyChanged 插件,省去大量 INotifyPropertyChanged 的样板代码。具体做法是给项目添加 NuGet 包 PropertyChanged.Fody(和 Fody),并在代码里按需要使用属性依赖特性。Fody 在编译时改写 IL,自动在属性的 set 里插入 PropertyChanged 的触发逻辑。举个常见的写法:有两个字段 FirstName、LastName,然后有个 FullName 是组合字段,就在 FullName 上标注依赖,把依赖关系告知 Fody,设置 FirstName 或 LastName 时,FullName 的更新会自动触发通知,界面随之刷新。这一点解决了许多手动触发通知的麻烦。
为了让 WinForm 的绑定能工作,具体绑定步骤是:在 Form 上放一个 BindingSource,把它的 DataSource 指定为 ViewModel(bindingSource.DataSource = viewModel;)。每个控件通过 DataBindings.Add 绑定到对应属性,例如 textBox.DataBindings.Add(“Text”, bindingSource, “FirstName”, true,
DataSourceUpdateMode.OnPropertyChanged); 这里的
DataSourceUpdateMode.OnPropertyChanged 能在输入时即时同步到 ViewModel。计算属性或只读属性会在依赖属性变化后刷新,前提是 PropertyChanged 事件正常触发,Fody 帮忙做到这点。
输入验证细节也要交代清楚。WinForm 常用的做法是让 ViewModel 实现 IDataErrorInfo 接口:当某个属性不合法时,this[“PropertyName”] 返回对应的错误字符串。界面端连接 ErrorProvider,把它的 DataSource 指向 BindingSource,ErrorProvider 会根据 IDataErrorInfo 的返回显示小红图标。具体实现上,验证规则可以放在属性的 set 中,也可以在单独的校验方法里聚焦执行,ViewModel 负责判断并返回错误。这样表单验证和提示完全在 ViewModel 中,窗体只做展示。
命令和按钮交互也是一个常见问题。WinForm 没有像 WPF 那样的 ICommand 机制,一般有两种简洁的应对方式:一是把按钮的 Click 事件设为很薄的一层,只调用 viewModel 的方法;二是自己实现一个简单的命令类(包含 Execute/CanExecute),在窗体里把按钮的 Enabled 绑定到 viewModel 的 CanSave 布尔属性,Click 时调用 Execute。要做到按钮状态随 ViewModel 改变自动更新,CanSave 的变化必须触发 PropertyChanged,Fody 帮你把这一步省了。要是喜爱更完整的模式,也可以在窗体里订阅 ViewModel 的事件来控制按钮启停。
改造过程从哪些地方下手?我把项目改成 MVVM 风格时,按这个倒序思路逐步推进:
最后验收阶段:运行应用,检查绑定是否生效,校验提示是否正确,依赖字段是否自动刷新,保存流程是否能被单元测试覆盖。这个阶段要对比改造前后的行为,确保功能没有退化。
中间迁移阶段:先把复杂的事件处理器拆成小方法,逐步搬移到 ViewModel。一开始不会一步到位,先把数据校验和本地状态迁移,再把数据库调用和长流程迁移。为了不影响现有功能,窗体层先保留桥接代码(例如 Button.Click 里只是调用 viewModel.Save()),等 ViewModel 完全稳定后再把这些桥接代码删掉。
最开始的识别阶段:找出哪个 Form 里有最多耦合的代码。一般是注册或编辑类窗体,那里会有许多控件和各类验证逻辑。把这些事件处理器列出来,标记出哪些是纯界面更新(可以留在窗体),哪些是业务逻辑(必须移到 ViewModel)。这个工作需要团队成员一起看一遍,避免遗漏。
为了让迁移更顺利,具体有一套实操技巧和注意点,讲清楚以免踩雷:
– Fody 和 PropertyChanged 的配置:项目里添加 Fody 和 PropertyChanged.Fody 两个包,根目录放一个 FodyWeavers.xml 文件来启用插件。装好后,代码里不必再写 INotifyPropertyChanged。对某些特殊类想关闭自动通知可以用 DoNotNotify 标记。依赖特性来自 PropertyChanged.Fody 的 DependsOn,可以在计算属性上注明依赖字段。
– 绑定时要注意 DataSourceUpdateMode:默认的同步时机可能不是你想要的。对于输入框,选择 OnPropertyChanged 比较合适,能在属性 set 时及时触发验证和依赖计算。
– 计算属性的更新:如果一个属性是由多个基础属性计算出来的,标注依赖是最稳妥的办法。否则可能出现界面不刷新的问题,或者你得手动调用 ResetBindings。
– 校验逻辑聚焦化:把规则写在 ViewModel,别在窗体里逐个控件写判断。像长度、必填、格式这些都放在 IDataErrorInfo 的实现里。ErrorProvider 和 BindingSource 会帮你把错误显示出来。不用在每个控件的 Validating 里重复判断。
– 单元测试切入点:把所有业务逻辑放到 ViewModel 或服务层,窗体只做绑定和简单事件转发。单测直接针对 ViewModel 编写,模拟注入的服务(列如数据访问层接口),验证校验逻辑和保存流程。这样测试速度快,容易定位问题。
– 数据访问和依赖注入:在企业项目里,把数据访问抽成接口(例如 IUserRepository),通过构造函数注入到 ViewModel。启动时用一个简单的容器(像
Microsoft.Extensions.DependencyInjection)组装依赖。窗体只从容器里 Resolve ViewModel,这样 ViewModel 可以直接被单测用 mock 替换。
实现中的一个细节例子:如果要把姓名合并成显示用的 FullName 字段,你在 ViewModel 里写两个普通的可读写属性 FirstName、LastName,再写一个只读的 FullName 属性并标注依赖。Fody 会在设置 FirstName 和 LastName 时自动触发 FullName 的 PropertyChanged,界面绑定 FullName 的控件会立刻刷新。另一个例子是计算保存按钮是否可用:有个 IsDirty 或 CanSave 布尔属性,任何修改后更新这个属性,按钮的 Enabled 绑定到它,就能自动控制可用性。
迁移时的常见问题也整理下,便于现场快速处理:有时候绑定不刷新,先看 PropertyChanged 有没有被触发;有时候 ErrorProvider 不显示错误,确认 BindingSource 的 DataSource 是否正确设置并且 ViewModel 是否实现了 IDataErrorInfo;有时候 computed property 没更新,检查 DependsOn 是否写对了字段名。都按这几步检查,一般能很快定位。
最后,给出一些可直接复制的关键代码片段(伪代码、便于理解):
– ViewModel 基本结构(示意):
public class UserViewModel
{
// FirstName、LastName 为自动属性,Fody 会在 set 时注入通知
public string FirstName { get; set; }
public string LastName { get; set; }
// 依赖声明,Fody 会在 FirstName 或 LastName 变时触发 FullName 的通知
[DependsOn(nameof(FirstName), nameof(LastName))]
public string FullName => $”{FirstName} {LastName}”;
// IDataErrorInfo 实现示例:针对属性返回错误信息
public string this[string columnName]
{
get
{
if (columnName == nameof(FirstName) && string.IsNullOrWhiteSpace(FirstName))
return “名字不能为空”;
// 其他规则…
return null;
}
}
public string Error => null;
// 保存方法,包含业务校验和调用仓储
public async Task SaveAsync()
{
// 可能抛出异常,或者返回失败码,视具体实现
}
}
– 窗体绑定要点(示意):
var vm = new UserViewModel();
bindingSource1.DataSource = vm;
textBoxFirst.DataBindings.Add(“Text”, bindingSource1, “FirstName”, true, DataSourceUpdateMode.OnPropertyChanged);
textBoxLast.DataBindings.Add(“Text”, bindingSource1, “LastName”, true, DataSourceUpdateMode.OnPropertyChanged);
errorProvider1.DataSource = bindingSource1;
// 保存按钮
buttonSave.Enabled = vm.CanSave;
buttonSave.Click += async (s,e) => await vm.SaveAsync();
接下来把这套步骤在你的项目里按部就班地执行,按上面那些检查点排查问题,就能把 WinForm 的界面逻辑和业务逻辑分离开来,代码的可维护性会明显改善。