接口
1. 抽象功能,子类实现 ------ 语法层的「契约约束」
这是接口最基础的语法作用,核心是定规矩、统一规范。
- 本质:接口只描述「必须具备什么功能」,完全不写「功能具体怎么实现」;所有继承接口的子类,都必须按接口约定的方法名、参数、返回值来写实现,否则编译不通过。
- 项目实例 :
定义IAxisController接口,约定必须有MoveToAsync、EmergencyStop、GetPosition三个方法;
真实硬件类EtherCatAxis实现接口,内部调用EtherCAT驱动下发运动指令;
模拟调试类SimulatedAxis实现同一个接口,内部用软件模拟走位、返回假坐标。 - 核心价值 :
强制同类型组件写法完全统一,避免多人协作时出现一人一个方法名的混乱;同时为多态打下基础------同一个接口可以有任意多个实现类,对外表现一致。
这是接口的"地基":没有这层契约约束,后面的解耦、依赖注入都无从谈起。
2. 接口对象 + 解耦 ------ 设计层的「上下隔离」
这是接口最核心的设计价值,也是工业上位机必须用接口的根本原因。
- 本质 :上层业务代码只用接口类型声明变量、调用方法,完全不依赖、不感知具体的实现类;把「业务逻辑怎么用」和「底层功能怎么做」彻底拆成两层,中间靠接口做"隔离墙"。
- 项目实例 :
扫描业务里只写IAxisController _axis,永远调用_axis.MoveToAsync();
业务层完全不知道底层是EtherCAT真实轴、还是模拟轴,也不需要知道------只要接口约定的方法能用就行。
之前学的DbRepositoryFactory.Create()返回IDetectionRepository接口对象,就是最典型的落地:工厂负责选实现,业务只管拿接口用。 - 核心价值 :
- 改底层不碰业务:换硬件、换数据库只改实现类+工厂/注册,核心检测逻辑零改动,风险极低;
- 无硬件也能开发调试:开发阶段用模拟实现类,不用接真实X光机、电机,就能跑通完整检测流程;
- 职责清晰:业务层只关心"做什么",底层实现才关心"怎么做",代码分层一目了然。
这是接口的"灵魂":不用接口也能写代码,但只有用接口才能真正做到上下层解耦,支撑工业软件长期迭代维护。
3. 面向接口编程 + 依赖注入 ------ 工程层的「自动托管」
这是接口解耦思想的工业化落地,把"手动创建对象"升级为"框架自动托管",是中大型项目的标准做法。
-
本质 :
配合.NET DI依赖注入容器,提前注册「接口→实现类」的映射关系(比如AddTransient<IAxisController, EtherCatAxis>());
上层业务只需要在构造函数里声明接口类型,容器就会自动创建对应的实现类实例并注入进去,全程不用手动new。 -
项目实例 :
csharp// 注册映射(App.xaml.cs) services.AddTransient<IDetectionRepository, SqliteDetectionRepository>(); // ViewModel 直接注入使用,完全不用手动new仓储 public ScanViewModel(IDetectionRepository repo) { _repo = repo; } -
核心价值 :
- 依赖自动传递:如果实现类还依赖其他服务(比如日志、配置),容器会递归自动注入,不用层层手动传参;
- 生命周期统一管控:Transient/Singleton/Scoped 三种生命周期在注册时一键配置,对象创建、销毁全由容器管理,避免手动管理的内存泄漏、重复实例问题;
- 切换实现零侵入:从真实版切换到模拟版,只需要修改注册的那一行代码,所有业务调用处完全不用动,比手动写工厂类更简洁、更统一。
这是接口的"放大器":DI容器把接口的解耦价值从"设计层面"落地成了"工程层面"的自动化管理,是大型项目的标配。
三者的递进关系
- 第1点是语法基础:没有契约约束,就没有统一的调用规范;
- 第2点是核心思想:接口的本质价值就是解耦,这也是工业软件架构的核心诉求;
- 第3点是工程落地:依赖注入把接口解耦从"手动写工厂"升级成了框架级的自动化方案。
也正因为如此,才有了之前的结论:ViewModel、纯数据类通常不需要接口------它们没有多种实现、不需要替换,既用不到第1点的多态契约,也发挥不了第2点的解耦价值,强行写接口属于过度设计。
ILogger接口
ILogger 接口完整讲解(适配X光工控上位机,结合DI容器、分层架构)
一、核心定位
ILogger 是 .NET 官方内置的日志抽象接口 (命名空间 Microsoft.Extensions.Logging),和你学的 IDetectionRepository、ITemperatureController 完全是同一套设计思想:
- 接口只约定「能写日志」的标准方法;
- 底层可以自由切换实现(控制台日志、文件日志、数据库日志、第三方日志框架);
- 业务代码只依赖
ILogger接口,完全不感知日志具体写到哪里、用什么组件写,彻底解耦业务与日志实现。
工业项目标配作用:记录设备运行状态、硬件故障、操作记录、异常堆栈,是现场排查设备问题的核心依据。
三、6个标准日志级别(工控项目选型指南)
日志级别从低到高,生产环境可配置过滤级别,避免日志文件过大占满磁盘:
| 级别 | 用途 | 工控场景示例 | 生产环境是否开启 |
|---|---|---|---|
Trace |
最详细调试信息,原始报文、逐行流程 | Modbus原始寄存器报文、EtherCAT周期数据 | ❌ 关闭,仅本地调试 |
Debug |
调试细节,开发排障用 | 仿真模式参数、算法中间计算值 | ❌ 生产默认关闭 |
Information |
正常运行记录,关键业务节点 | 扫描开始/完成、用户登录、配方切换 | ✅ 必开 |
Warning |
警告,不影响运行但需关注 | Modbus单次通讯超时、温度接近阈值 | ✅ 必开 |
Error |
错误,功能异常,业务中断 | PLC心跳断线、数据库写入失败、电机急停 | ✅ 必开 |
Critical |
致命错误,程序崩溃、设备危险 | 高压异常、运动轴撞限位、软件闪退 | ✅ 必开 |
四、标准用法(DI注入 + 业务代码示例)
1. 推荐写法:泛型 ILogger<T>
工业项目统一用泛型版本,自动携带「所属服务类」的分类标签,排查问题时可直接筛选出扫描服务、PLC心跳各自的日志:
csharp
public class ScanService
{
private readonly ILogger<ScanService> _logger;
private readonly ITemperatureController _tempCtrl;
// 构造函数注入,DI容器自动传入日志实例
public ScanService(ILogger<ScanService> logger, ITemperatureController tempCtrl)
{
_logger = logger;
_tempCtrl = tempCtrl;
}
public async Task StartScanAsync()
{
// 记录正常运行信息
_logger.LogInformation("开始执行IGBT扫描检测");
try
{
double temp = await _tempCtrl.GetCurrentTempAsync();
if (temp > 30)
{
// 记录警告
_logger.LogWarning("水温超限:当前{Temp}℃,禁止扫描启动", temp);
throw new Exception("水温过高");
}
}
catch (Exception ex)
{
// 记录错误 + 异常堆栈
_logger.LogError(ex, "扫描启动失败,水温检测异常");
throw;
}
}
}
2. 常用方法速记
csharp
// 信息日志,支持占位符参数
_logger.LogInformation("批次{BatchNo}检测完成,空洞率{Rate:F2}%", batchNo, voidRate);
// 警告日志
_logger.LogWarning("Modbus第{Count}次重试通讯", retryCount);
// 错误日志,附带异常对象,自动记录堆栈
_logger.LogError(ex, "PLC心跳连续超时,设备急停");
// 致命错误
_logger.LogCritical(ex, "X光高压异常,程序强制退出");
五、DI容器注册方式(配合你的 App.xaml.cs)
.NET 提供官方扩展方法,一行完成注册,默认自带控制台、调试输出实现;接入第三方框架(Serilog/NLog)后,注册代码几乎不变:
csharp
var services = new ServiceCollection();
// 注册日志服务,全局单例
services.AddLogging(builder =>
{
// 开发环境:输出到控制台、调试窗口
builder.AddConsole();
builder.AddDebug();
// 生产环境:接入Serilog写本地文件,只改这里,业务代码不动
// builder.AddSerilog();
// 配置生产环境默认只显示 Information 及以上级别
builder.SetMinimumLevel(LogLevel.Information);
});
// 注册你的业务、硬件、仓储服务
services.AddSingleton<ITemperatureController, ModbusTemperatureController>();
services.AddTransient<ScanService>();
一句话总结
ILogger 是.NET标准日志接口,依托DI容器注入使用,实现业务与日志实现解耦;工业项目用来记录设备运行、故障、操作痕迹,是现场问题排查的核心工具。
+=(s,e)
一、完整语法拆分
完整代码模板:
csharp
控件.事件 += (s, e) =>
{
// 事件触发后执行的业务逻辑
};
+=:事件订阅运算符 ,只有事件(event)能使用+=绑定处理方法,对应之前学的public event Action;(s, e):Lambda 表达式的形参列表 ,仅用于.NET原生EventHandler类型事件(按钮点击、窗口加载等控件路由事件)s= sender,事件发送者对象(比如点击的ToggleButton、触发事件的控件)e= EventArgs,事件附带的参数(鼠标位置、触发状态等系统内置数据)
=>:Lambda箭头,后面是事件触发时要运行的代码块。
二、两种事件,参数格式严格区分
1. WPF控件原生事件(Click/Loaded/MouseEnter)→ 固定 (s,e)
这类事件底层是标准 EventHandler,签名强制要求2个参数,必须写 (s,e)。
实战示例(ToggleButton点击切换仿真模式):
csharp
// 按钮点击事件,标准 (s,e) 写法
SimToggle.Click += (s, e) =>
{
// s 就是触发点击的按钮控件
ToggleButton btn = s as ToggleButton;
// 切换仿真开关状态
_viewModel.IsSimMode = btn.IsChecked.Value;
};
2. 你项目自定义硬件事件(event Action PLC心跳/急停)→ 不能写 (s,e)
根据Action有无参数,分两种写法:
① 无参自定义事件 public event Action PlcDisconnectAlarm;
没有任何参数,直接写 ()=>
csharp
_plcHeart.PlcDisconnectAlarm += () =>
{
// PLC断线,执行急停
_axisCtrl.EmergencyStop();
};
② 单参数自定义事件 public event Action<string> DeviceStatus;
只有1个参数,直接写 (msg)=>
csharp
_plc.DeviceStatusChanged += (msg) =>
{
Logger.WriteLog($"设备状态:{msg}");
};
核心误区:
(s,e)只属于系统控件事件,自己写的event Action强行写(s,e)会直接编译报错。
三、两种订阅方式对比(命名方法 vs 匿名 (s,e) Lambda)
方式1:命名方法(推荐ViewModel长期订阅,无内存泄漏)
可以用 -= 在窗口关闭时取消订阅,释放资源:
csharp
// 订阅
_simBtn.Click += OnSimToggleClick;
// 独立处理方法
private void OnSimToggleClick(object s, EventArgs e)
{
var btn = s as ToggleButton;
_vm.IsSimMode = btn.IsChecked.Value;
}
// 窗口销毁时取消订阅,GC可回收ViewModel
_simBtn.Click -= OnSimToggleClick;
方式2:匿名 (s,e) Lambda(临时简易逻辑,致命缺陷)
匿名方法没有名称,无法使用 -= 取消订阅:
csharp
_simBtn.Click += (s, e) =>
{
_vm.IsSimMode = ((ToggleButton)s).IsChecked.Value;
};
⚠️ 工控软件重大隐患:瞬时ViewModel、窗口关闭后,事件依然持有ViewModel引用,GC永远无法回收对象,长期运行内存持续泄漏、软件卡顿。
四、工控项目高频场景完整示例
示例1:DataGrid单元格鼠标悬浮(原生MouseEnter事件,(s,e))
csharp
// 表格行悬浮高亮
dataGrid.MouseEnter += (s, e) =>
{
// s 是DataGrid控件
};
示例2:PLC心跳后台事件(自定义无参Action,不用s,e)
csharp
// 心跳丢失告警
_plcHeart.PlcDisconnectAlarm += () =>
{
// 后台线程必须切UI主线程才能弹窗
Application.Current.Dispatcher.Invoke(() =>
{
MessageBox.Show("PLC通讯断开,设备已急停!");
});
};
一句话总结
+= (s,e)=>{} 是WPF控件原生点击/悬浮事件的匿名订阅写法,s是触发控件、e是系统事件参数;自定义Action硬件事件不能使用该格式,且匿名Lambda无法取消订阅,极易造成工控软件内存泄漏。
Transient 瞬时生命周期
一、核心定义
1. Transient(瞬时)生命周期规则
在.NET依赖注入(DI)容器中,每次从容器获取/注入该类型时,都会创建一个全新独立的实例;容器不会缓存、复用旧对象,对象使用完毕后无强引用则由GC回收。
DI三大生命周期对比(区分关键)
| 生命周期 | 创建规则 | 适用对象 |
|---|---|---|
| Transient 瞬时 | 每次请求都 new 全新实例 | 窗口ViewModel、临时弹窗页面 |
| Scoped 作用域 | 同一个作用域内只创建1次,跨作用域新建 | DbContext、仓储Repository |
| Singleton 单例 | 全局仅创建1个,程序全程复用 | 硬件服务、日志、全局工厂 DbRepositoryFactory |
2. 注册语句含义
csharp
// App.xaml.cs DI服务注册代码
services.AddTransient<DetectionRecordViewModel>();
services.AddTransient<LoginViewModel>();
services.AddTransient<ParameterEditViewModel>();
AddTransient<T> 代表:将视图模型注册为瞬时模式,每次打开对应窗口,都生成干净无残留的全新ViewModel。
二、为什么ViewModel必须用Transient(工业软件刚需)
反面风险:注册为Singleton单例的致命问题
如果把检测记录ViewModel注册成全局单例:
- 第一次打开检测窗口,加载100条IGBT检测记录到
DataGrid; - 关闭窗口,ViewModel实例仍被容器保留;
- 第二次打开窗口,
DataGrid直接显示上次残留的100条旧数据,筛选条件、分页、空洞率参数全部保留; - 多窗口同时打开时,一个窗口修改参数会同步污染所有页面,造成检测数据混淆、质量追溯出错。
Transient 瞬时模式的核心优势
- 天然数据隔离
每打开一次窗口 = 全新ViewModel,列表、输入框、状态标记全部重置,无需手动写Clear()清空集合/属性,从根源避免旧数据残留。 - 多窗口互不干扰
同时打开2份IGBT检测对比窗口,两个ViewModel完全独立,修改其中一个的筛选条件不会影响另一个页面。 - 生命周期与视图同步
窗口关闭后,ViewModel无强引用,自动被GC回收,长期运行软件不会堆积内存。 - 依赖自动隔离
ViewModel构造注入的仓储IDetectionRepository、硬件IAxisController,每次新建ViewModel都会注入独立作用域资源,数据库、硬件状态互不串扰。
三、完整实操流程(DI容器+Transient ViewModel)
步骤1:App.xaml.cs 全局注册服务
csharp
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var serviceCollection = new ServiceCollection();
// 1. 瞬时注册所有页面ViewModel(核心)
serviceCollection.AddTransient<DetectionRecordViewModel>();
serviceCollection.AddTransient<LoginViewModel>();
serviceCollection.AddTransient<ScanParamViewModel>();
// 2. 仓储Scoped(作用域生命周期,适配EF DbContext)
serviceCollection.AddScoped<IDetectionRepository, SqliteDetectionRepository>();
serviceCollection.AddScoped<IPermissionRepository, SqlitePermissionRepo>();
// 3. 全局单例硬件、工厂
serviceCollection.AddSingleton<DbRepositoryFactory>();
serviceCollection.AddSingleton<IAxisController, EtherCatAxis>();
_serviceProvider = serviceCollection.BuildServiceProvider();
// 启动主窗口(瞬时VM)
var mainVm = _serviceProvider.GetRequiredService<MainViewModel>();
new MainWindow(mainVm).Show();
}
步骤2:窗口构造函数注入ViewModel
csharp
public partial class DetectionRecordWindow : Window
{
// DI自动传入全新Transient实例
public DetectionRecordWindow(DetectionRecordViewModel vm)
{
InitializeComponent();
this.DataContext = vm;
}
}
步骤3:业务代码打开窗口(每次获取全新VM)
csharp
// 每次调用GetRequiredService,都会新建一个ViewModel
var recordVm = _serviceProvider.GetRequiredService<DetectionRecordViewModel>();
var recordWin = new DetectionRecordWindow(recordVm);
recordWin.Show();
每次弹窗,VM都是空白初始化状态,不会携带上一次的检测记录、筛选参数。
四、适用/不适用场景
✅ 适合注册Transient的ViewModel
- 临时弹窗:登录窗口、配方编辑弹窗、单条IGBT复检详情页;
- 独立子窗口:检测记录查询窗口、参数配置面板;
- 一次性操作视图:单次扫描参数临时面板,关闭后无需保留状态。
❌ 不适合Transient的对象(改用Scoped/Singleton)
IDetectionRepository、DbContext:频繁瞬时创建会反复打开/关闭SQLite连接,损耗磁盘IO,统一用Scoped;IAxisController运动轴、Modbus温控硬件服务:硬件总线全局唯一,注册Singleton;DbRepositoryFactory数据库工厂、全局日志服务:全局单例,无需重复实例化。
五、Transient ViewModel 配套注意事项(避坑)
1. 事件订阅必须手动取消(防内存泄漏)
ViewModel中订阅硬件event Action、全局异常事件时,窗口关闭必须-=取消订阅:
csharp
// ViewModel内部订阅急停事件
_device.EmergencyStopEvent += OnEmergencyStop;
// 窗口关闭时执行释放
public void Dispose()
{
_device.EmergencyStopEvent -= OnEmergencyStop;
}
Transient对象仅在无事件订阅、无UI强引用时,才能被GC正常回收。
2. 共享全局状态不要依赖ViewModel
若多窗口需要共用设备状态(如设备就绪、当前扫描批次),不要共用ViewModel ,单独创建Singleton全局状态服务注入各个瞬时VM。
3. 构造函数轻量化
ViewModel构造不要执行重型初始化(批量加载上万条检测记录),把数据加载放到窗口加载完成异步方法中,减少瞬时创建耗时。
4. 异步操作强制使用await
瞬时VM内部数据库查询repo.SaveAsync()、硬件移动axis.MoveToAsync()必须加await,避免产生未观察Task异常,单个窗口异步报错不会污染其他页面。
六、串联你之前学过的知识点
- DAL仓储Scoped注入:每个瞬时ViewModel都会注入独立仓储上下文,数据库读写隔离,避免实体追踪冲突;
- 接口对象解耦 :Transient VM构造仅依赖
IDetectionRepository、IAxisController抽象接口,真实/模拟实现可一键切换; - 异步未观察异常:每个瞬时VM的异步任务独立,一个窗口数据库读写失败不会导致全软件闪退;
- event Action广播事件:每个VM独立订阅硬件告警事件,窗口销毁释放订阅,减少内存堆积;
- INotifyPropertyChanged:每个瞬时VM拥有独立属性变更通知,多窗口界面刷新互不干扰。
一句话总结
AddTransient<ViewModel>() 就是让每个窗口都拿到全新干净、互不干扰的视图模型,从生命周期层面杜绝界面数据残留、多窗口状态串扰,是WPF工业上位机MVVM标准注册方案。