有段时间设备通信老是断查了半天发现是McuCmdService被创建了两次。页面A里new McuCmdService()页面B里又new McuCmdService()。两个实例各管各的页面A连了设备页面B不知道又去连一次。设备端只允许一个连接直接把前面的踢掉了。这种Bug特别隐蔽——程序不报错就是莫名其妙断连。你盯着日志看半天发现两个McuCmdService实例的 hashcode 不一样才恍然大悟。那个bug我查了两天。第一天怀疑是网络问题换了网线没用。第二天怀疑是设备固件问题联系厂商排查也没用。第三天在日志里看到两个不同的实例ID才意识到是代码里 new 了两次。问题的根源谁该负责创建服务不用IOC的时候看看不用IOC怎么写publicpartialclassMainWindowViewModel:ObservableObject{privateMcuCmdService_mcuService;privateDatabaseService_dbService;privatePermissionService_permService;publicMainWindowViewModel(){_mcuServicenewMcuCmdService();_dbServicenewDatabaseService(connection_string);_permServicenewPermissionService(_dbService);}}这么写有三个问题。谁都能 new没人管你创建几个实例开头的Bug就是这么来的。McuCmdService加了个构造函数参数所有new McuCmdService()的地方全得改。测试时想用一个假的服务不发网络请求得改一堆代码。第三个问题最头疼。我们项目后期要加单元测试发现根本没法测——McuCmdService构造函数里直接建立了TCP连接你 new 它的时候它就去连设备了。测试环境没有设备直接超时报错。IOC容器你只管要我负责给IOC控制反转的核心就一句话你不再自己 new 对象告诉容器我需要什么容器给你造好传过来。项目用的是 Prism 框架自带的 DryIoc 容器。先看注册publicpartialclassApp:PrismApplication{protectedoverridevoidRegisterTypes(IContainerRegistrycontainerRegistry){containerRegistry.RegisterSingletonIMcuCmdService,McuCmdService();containerRegistry.RegisterSingletonIDatabaseService,DatabaseService();containerRegistry.RegisterSingletonPermissionService();containerRegistry.RegisterSingletonMainWindowViewModel();}}然后在 ViewModel 构造函数里直接要publicpartialclassMainWindowViewModel:ObservableObject{privatereadonlyIMcuCmdService_mcuCmdService;privatereadonlyPermissionService_permissionService;publicMainWindowViewModel(IMcuCmdServicemcuCmdService,PermissionServicepermissionService){_mcuCmdServicemcuCmdService;_permissionServicepermissionService;}}容器看到MainWindowViewModel需要IMcuCmdService就去自己注册表里查找到对应的McuCmdService实例传给构造函数。你不用管它怎么来的它就在那儿。第一次接触IOC的人可能会觉得多此一举——我不new让容器new有什么区别区别在于容器知道全局该创建几个实例你不知道。三种生命周期注册服务时有三种选择Singleton单例——全局唯一第一次请求时创建之后所有人拿到的都是同一个实例。设备通信服务、数据库连接、配置管理必须用这个。containerRegistry.RegisterSingletonIMcuCmdService,McuCmdService();Transient瞬态——每次请求都创建新实例。适合轻量级、无状态的对象比如格式化工具类用完就扔。containerRegistry.RegisterIMcuCmdService,McuCmdService();Scoped作用域——同一作用域内共享。DryIoc 里用得少WPF 项目一般不需要。ASP.NET Core 里用得多每个请求一个作用域。工业控制软件全局就一个进程Scoped 基本用不上。工厂注册需要依赖的服务有些服务的构造函数依赖其他服务。比如GasFlowCollectorService需要McuCmdService和GasFlowDataServicecontainerRegistry.RegisterSingletonGasFlowCollectorService(provider{varmcuServiceprovider.ResolveIMcuCmdService()asMcuCmdService;vardataServiceprovider.ResolveGasFlowDataService();returnnewGasFlowCollectorService(mcuService,dataService);});这种写法叫工厂注册——你给容器一个 lambda容器在创建实例时调用它。好处是你可以手动组装依赖关系。坏处是依赖关系写在了注册代码里不在构造函数签名上体现不太直观。真实项目里的注册表App.xaml.cs里的RegisterTypes方法注册了大概30多个服务。截一段protectedoverridevoidRegisterTypes(IContainerRegistrycontainerRegistry){containerRegistry.RegisterIDialogHostService,DialogHostService();containerRegistry.RegisterDialogLoginView,LoginViewModel();containerRegistry.RegisterSingletonIUserAuthService,UserAuthService();containerRegistry.RegisterSingletonPermissionService();containerRegistry.RegisterSingletonAuthAuditService();containerRegistry.RegisterSingletonIMcuCmdService,McuCmdService();containerRegistry.RegisterSingletonIDatabaseService,DatabaseService();containerRegistry.RegisterSingletonIAlarmRecordService,AlarmRecordService();containerRegistry.RegisterSingletonGasFlowDataService(provider{vardbServiceprovider.ResolveIDatabaseService()asDatabaseService;returnnewGasFlowDataService(dbService?.GetDbClient());});containerRegistry.RegisterSingletonCommands.Executors.IRecipeExecutor(providernewRecipeExecutor(provider.ResolveICommandRegistry(),provider.ResolveIMcuCmdService(),provider.ResolveIRecipeConfigRegistry(),provider.ResolveHDPS100SubsystemStatus()));containerRegistry.RegisterSingletonMainWindowViewModel();containerRegistry.RegisterSingletonModbusDICoilsPanelViewModel();containerRegistry.RegisterSingletonModbusDOCoilsPanelViewModel();}几个设计决策值得说一下。服务全用单例。工业控制软件跟Web应用不一样Web应用每个请求独立用Scoped合理。但桌面软件全局就一个进程服务状态必须全局共享。McuCmdService管着设备连接必须唯一。AlarmRecordService管着报警状态也必须唯一。要是new了两个报警就记不全。ViewModel 也注册为单例。因为多个页面可能引用同一个 ViewModel如果每次都new状态就丢了。比如McuCmdPanelViewModel管着设备控制面板的状态用户切到别的页面再切回来面板状态应该保持不变。接口注册优先。RegisterSingletonIMcuCmdService, McuCmdService()注册接口而不是具体类。好处是测试时可以换实现——注册一个MockMcuCmdService不发网络请求所有ViewModel自动用假的。ViewModelLocatorView和ViewModel怎么配对Prism 有个 ViewModelLocator自动给 View 找 ViewModel。默认按命名约定Views.MainWindow→ViewModels.MainWindowViewModel。但项目用了 .NET Reactor 混淆混淆后类名变了命名约定失效。所以改成显式注册protectedoverridevoidConfigureViewModelLocator(){base.ConfigureViewModelLocator();ViewModelLocationProvider.RegisterMainWindow,MainWindowViewModel();ViewModelLocationProvider.RegisterMcuCmdPanel,McuCmdPanelViewModel();ViewModelLocationProvider.RegisterGasFlowChartView,GasFlowChartViewModel();ViewModelLocationProvider.RegisterCommandTestView,CommandTestViewModel();ViewModelLocationProvider.RegisterLogView,LogViewModel();ViewModelLocationProvider.RegisterModbusAnalogIOPanel,ModbusAnalogIOPanelViewModel();ViewModelLocationProvider.RegisterModbusDICoilsPanel,ModbusDICoilsPanelViewModel();}每对 View-ViewModel 关系都写明白不靠命名猜。虽然代码多了几行但混淆后不会出问题。这个坑踩过一次——Release版运行起来界面全是空白Debug版好好的查了半天才发现是混淆把类名改了ViewModelLocator按命名找不到。从容器拿实例的快捷方式有时候不在构造函数里注入需要直接从容器拿。比如在非ViewModel代码里publicstaticTGetInstanceT(){if(CurrentisAppapp){returnapp.Container.ResolveT();}thrownewInvalidOperationException(容器不可用);}用法varmcuServiceApp.GetInstanceIMcuCmdService();varmainVMApp.GetInstanceMainWindowViewModel();这种方式要谨慎用。能构造函数注入就注入直接 Resolve 容易导致依赖关系不清晰——光看构造函数签名不知道这个类依赖什么得翻代码才知道它偷偷从容器拿了什么。我项目里只有少数无法注入的场景才用这个比如在静态方法里。回到开头的Bug用了IOC之后McuCmdService注册为单例全局只有一个实例不可能被new两次。所有ViewModel通过构造函数拿到的是同一个IMcuCmdService状态共享。测试时可以注册一个假的IMcuCmdService不发网络请求所有ViewModel自动用假的。那个两个实例互相踢连接的Bug从架构层面就消失了。不是修了bug是bug存在的条件没了。写在最后这篇讲的东西可能不如命令系统那么直观——命令系统写错了界面会出问题IOC用错了程序也能跑就是会出一些莫名其妙的Bug。两个实例互相踢连接、测试时连不上设备、混淆后界面空白……这些问题单看代码很难发现但用IOC规范起来从根上就不会发生。IOC的学习成本不高核心就那么几个API。难的是设计决策——哪些用单例、哪些用瞬态、哪些用工厂注册、接口怎么设计。这些没有标准答案跟项目规模和业务特点有关。我项目里全用单例是因为工业控制软件的特殊性Web项目就不能这么干。下一篇进入硬件通信——Modbus协议是什么NModbus库怎么用怎么封装一个可靠的设备连接服务。