630:接口等

接口


1. 抽象功能,子类实现 ------ 语法层的「契约约束」

这是接口最基础的语法作用,核心是定规矩、统一规范

  • 本质:接口只描述「必须具备什么功能」,完全不写「功能具体怎么实现」;所有继承接口的子类,都必须按接口约定的方法名、参数、返回值来写实现,否则编译不通过。
  • 项目实例
    定义 IAxisController 接口,约定必须有 MoveToAsyncEmergencyStopGetPosition 三个方法;
    真实硬件类 EtherCatAxis 实现接口,内部调用EtherCAT驱动下发运动指令;
    模拟调试类 SimulatedAxis 实现同一个接口,内部用软件模拟走位、返回假坐标。
  • 核心价值
    强制同类型组件写法完全统一,避免多人协作时出现一人一个方法名的混乱;同时为多态打下基础------同一个接口可以有任意多个实现类,对外表现一致。

这是接口的"地基":没有这层契约约束,后面的解耦、依赖注入都无从谈起。


2. 接口对象 + 解耦 ------ 设计层的「上下隔离」

这是接口最核心的设计价值,也是工业上位机必须用接口的根本原因。

  • 本质 :上层业务代码只用接口类型声明变量、调用方法,完全不依赖、不感知具体的实现类;把「业务逻辑怎么用」和「底层功能怎么做」彻底拆成两层,中间靠接口做"隔离墙"。
  • 项目实例
    扫描业务里只写 IAxisController _axis,永远调用 _axis.MoveToAsync()
    业务层完全不知道底层是EtherCAT真实轴、还是模拟轴,也不需要知道------只要接口约定的方法能用就行。
    之前学的 DbRepositoryFactory.Create() 返回 IDetectionRepository 接口对象,就是最典型的落地:工厂负责选实现,业务只管拿接口用。
  • 核心价值
    1. 改底层不碰业务:换硬件、换数据库只改实现类+工厂/注册,核心检测逻辑零改动,风险极低;
    2. 无硬件也能开发调试:开发阶段用模拟实现类,不用接真实X光机、电机,就能跑通完整检测流程;
    3. 职责清晰:业务层只关心"做什么",底层实现才关心"怎么做",代码分层一目了然。

这是接口的"灵魂":不用接口也能写代码,但只有用接口才能真正做到上下层解耦,支撑工业软件长期迭代维护。


3. 面向接口编程 + 依赖注入 ------ 工程层的「自动托管」

这是接口解耦思想的工业化落地,把"手动创建对象"升级为"框架自动托管",是中大型项目的标准做法。

  • 本质
    配合.NET DI依赖注入容器,提前注册「接口→实现类」的映射关系(比如 AddTransient<IAxisController, EtherCatAxis>());
    上层业务只需要在构造函数里声明接口类型,容器就会自动创建对应的实现类实例并注入进去,全程不用手动 new

  • 项目实例

    csharp 复制代码
    // 注册映射(App.xaml.cs)
    services.AddTransient<IDetectionRepository, SqliteDetectionRepository>();
    
    // ViewModel 直接注入使用,完全不用手动new仓储
    public ScanViewModel(IDetectionRepository repo)
    {
        _repo = repo;
    }
  • 核心价值

    1. 依赖自动传递:如果实现类还依赖其他服务(比如日志、配置),容器会递归自动注入,不用层层手动传参;
    2. 生命周期统一管控:Transient/Singleton/Scoped 三种生命周期在注册时一键配置,对象创建、销毁全由容器管理,避免手动管理的内存泄漏、重复实例问题;
    3. 切换实现零侵入:从真实版切换到模拟版,只需要修改注册的那一行代码,所有业务调用处完全不用动,比手动写工厂类更简洁、更统一。

这是接口的"放大器":DI容器把接口的解耦价值从"设计层面"落地成了"工程层面"的自动化管理,是大型项目的标配。


三者的递进关系

  1. 第1点是语法基础:没有契约约束,就没有统一的调用规范;
  2. 第2点是核心思想:接口的本质价值就是解耦,这也是工业软件架构的核心诉求;
  3. 第3点是工程落地:依赖注入把接口解耦从"手动写工厂"升级成了框架级的自动化方案。

也正因为如此,才有了之前的结论:ViewModel、纯数据类通常不需要接口------它们没有多种实现、不需要替换,既用不到第1点的多态契约,也发挥不了第2点的解耦价值,强行写接口属于过度设计。

ILogger接口

ILogger 接口完整讲解(适配X光工控上位机,结合DI容器、分层架构)

一、核心定位

ILogger.NET 官方内置的日志抽象接口 (命名空间 Microsoft.Extensions.Logging),和你学的 IDetectionRepositoryITemperatureController 完全是同一套设计思想:

  • 接口只约定「能写日志」的标准方法;
  • 底层可以自由切换实现(控制台日志、文件日志、数据库日志、第三方日志框架);
  • 业务代码只依赖 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) =>
{
    // 事件触发后执行的业务逻辑
};
  1. +=事件订阅运算符 ,只有事件(event)能使用 += 绑定处理方法,对应之前学的 public event Action
  2. (s, e):Lambda 表达式的形参列表 ,仅用于.NET原生 EventHandler 类型事件(按钮点击、窗口加载等控件路由事件)
    • s = sender,事件发送者对象(比如点击的ToggleButton、触发事件的控件)
    • e = EventArgs,事件附带的参数(鼠标位置、触发状态等系统内置数据)
  3. =>: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注册成全局单例:

  1. 第一次打开检测窗口,加载100条IGBT检测记录到DataGrid
  2. 关闭窗口,ViewModel实例仍被容器保留;
  3. 第二次打开窗口,DataGrid 直接显示上次残留的100条旧数据,筛选条件、分页、空洞率参数全部保留;
  4. 多窗口同时打开时,一个窗口修改参数会同步污染所有页面,造成检测数据混淆、质量追溯出错。

Transient 瞬时模式的核心优势

  1. 天然数据隔离
    每打开一次窗口 = 全新ViewModel,列表、输入框、状态标记全部重置,无需手动写Clear()清空集合/属性,从根源避免旧数据残留。
  2. 多窗口互不干扰
    同时打开2份IGBT检测对比窗口,两个ViewModel完全独立,修改其中一个的筛选条件不会影响另一个页面。
  3. 生命周期与视图同步
    窗口关闭后,ViewModel无强引用,自动被GC回收,长期运行软件不会堆积内存。
  4. 依赖自动隔离
    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

  1. 临时弹窗:登录窗口、配方编辑弹窗、单条IGBT复检详情页;
  2. 独立子窗口:检测记录查询窗口、参数配置面板;
  3. 一次性操作视图:单次扫描参数临时面板,关闭后无需保留状态。

❌ 不适合Transient的对象(改用Scoped/Singleton)

  1. IDetectionRepositoryDbContext:频繁瞬时创建会反复打开/关闭SQLite连接,损耗磁盘IO,统一用Scoped;
  2. IAxisController运动轴、Modbus温控硬件服务:硬件总线全局唯一,注册Singleton;
  3. 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异常,单个窗口异步报错不会污染其他页面。


六、串联你之前学过的知识点

  1. DAL仓储Scoped注入:每个瞬时ViewModel都会注入独立仓储上下文,数据库读写隔离,避免实体追踪冲突;
  2. 接口对象解耦 :Transient VM构造仅依赖IDetectionRepositoryIAxisController抽象接口,真实/模拟实现可一键切换;
  3. 异步未观察异常:每个瞬时VM的异步任务独立,一个窗口数据库读写失败不会导致全软件闪退;
  4. event Action广播事件:每个VM独立订阅硬件告警事件,窗口销毁释放订阅,减少内存堆积;
  5. INotifyPropertyChanged:每个瞬时VM拥有独立属性变更通知,多窗口界面刷新互不干扰。

一句话总结

AddTransient<ViewModel>() 就是让每个窗口都拿到全新干净、互不干扰的视图模型,从生命周期层面杜绝界面数据残留、多窗口状态串扰,是WPF工业上位机MVVM标准注册方案。