【设计模式】用观察者模式对比事件订阅(相机举例)

📷 用观察者模式对比事件订阅(相机举例)

标签:WPF、C#、Halcon、设计模式、观察者模式、事件机制


在日常开发中,我们经常使用 事件机制(Event) 来订阅图像采集信号。然而当系统日益复杂,多个模块同时需要响应图像变化 时,事件机制常常暴露出诸多痛点:

  • 回调函数难以管理
  • 抛异常一个挂全挂 ❌(详见下文)
  • 解耦能力差,测试困难
  • 缺乏灵活扩展能力(过滤、异步、重试等)

于是我重构了图像采集模块,采用 观察者模式(Observer Pattern),让系统结构更加优雅、可控、可扩展!


🧱 传统事件订阅方式的写法

csharp 复制代码
public class Camera
{
    public event Action<HObject> ImageGrabbed;

    public void SimulateGrab()
    {
        HObject image = GetImage();
        ImageGrabbed?.Invoke(image); // 抛异常就"炸链"
    }

    private HObject GetImage()
    {
        HObject image;
        HOperatorSet.GenEmptyObj(out image);
        return image;
    }
}

👇 模拟订阅多个模块

csharp 复制代码
camera.ImageGrabbed += img => Console.WriteLine("✅ UI 模块收到:" + img);

camera.ImageGrabbed += img =>
{
    Console.WriteLine("❌ 日志模块出错了!");
    throw new Exception("磁盘已满");
};

camera.ImageGrabbed += img => Console.WriteLine("🔬 图像分析模块收到:" + img);

❗ 为什么事件中一个模块抛异常,其他模块就收不到了?

C# 中事件是多播委托(MulticastDelegate),其底层是一个同步执行的委托链:

csharp 复制代码
foreach (var handler in ImageGrabbed.GetInvocationList())
{
    handler.DynamicInvoke("图像1"); // 如果某个 handler 抛异常,后续的不会执行
}

因此,如果某个订阅者(例如日志模块)在处理事件时抛出异常,整个事件的执行链条会被中断,导致后续模块(如图像分析模块)完全无法接收到通知。

📌 这不是因为其他模块无法处理异常,而是它们根本没有被调用!


✅ 引入观察者模式(命名为 ICameraSubject)

在更实际的项目中,相机的图像采集往往是通过第三方 SDK 注册回调函数获得的。例如:

csharp 复制代码
camera.RegisterImageCallback(OnImageReceived);

private void OnImageReceived(byte[] rawBuffer)
{
    HObject image = ConvertToHObject(rawBuffer);
    Notify(image);
}

此时,CameraSubject 充当了"驱动层和业务逻辑之间的桥梁"。我们可以将采集到的图像统一分发给多个"观察者",如 UI 展示模块、日志记录模块、图像分析模块等。

🔗 接口定义

csharp 复制代码
//观察者需要实现的接口
public interface ICameraObserver
{
    void Update(HObject image);
}
//被观察者需要实现的接口
public interface ICameraSubject
{
    void Add(ICameraObserver observer);
    void Remove(ICameraObserver observer);
    void Notify(HObject image);
}

📷 被观察者实现(事件发布者)

csharp 复制代码
public class CameraSubject : ICameraSubject
{
    private readonly List<ICameraObserver> observers = new();

    public void Add(ICameraObserver observer)
    {
        observers.Add(observer);
    }

    public void Remove(ICameraObserver observer)
    {
        observers.Remove(observer);
    }

    public void Notify(HObject image)
    {
        foreach (var observer in observers)
        {
            try
            {
                observer.Update(image);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"[异常] {observer.GetType().Name} 处理图像失败: {ex.Message}");
            }
        }
    }
}

被观察者实例定义的Notify()里面会调用所有已添加过的观察者的Update()

📷 相机驱动模块实现

csharp 复制代码
public class CameraDriver
{
    private readonly ICameraSubject cameraSubject;

    public CameraDriver(ICameraSubject cameraSubject)
    {
        this.cameraSubject = cameraSubject;
    }

    // 假设由 SDK 回调触发
    public void OnImageGrabbedFromDriver(byte[] buffer)
    {
        HObject image = ConvertToHObject(buffer);
        cameraSubject.Notify(image); // 使用 Subject 通知观察者
    }

    private HObject ConvertToHObject(byte[] buffer)
    {
        HObject image;
        HOperatorSet.GenEmptyObj(out image);
        // 这里添加具体的图像转换逻辑
        return image;
    }
}

注意,相机驱动模块里会调用被观察者对象的Notify方法,就是通知所有的观察者!

因为:被观察者的Notify()里面会调用所有已添加过的观察者的Update()

🖼️ 界面模块如何接收图像?

我们创建一个 UI 模块,界面模块作为观察者,实现 ICameraObserver 接口:

csharp 复制代码
public class MainWindowObserver : ICameraObserver
{
    public void Update(HObject image)
    {
         // 例如绑定到 ImageControl 或刷新控件
         Console.WriteLine("主界面刷新图像");
    }
}

然后在界面初始化时订阅:

csharp 复制代码
cameraSubject.Add(this);
为什么是cameraSubject.Add(this)?

因为界面模块实现了接口ICameraObserver 而作为被观察者实例

cameraSubject管理全部的观察者,所以这里是:cameraSubject.Add(this); 表示界面订阅被观察者将会触发的事件!!!被cameraSubject收入麾下(观察者你需要时刻关注我啦)。

cameraSubject 通常会被作为单例注册到容器中。其他模块可以通过容器拿到被观察者的实例对象。

然后,观察者实现观察者接口,最后通过被观察者的实例对象加入自己(this)。


小结

模块 说明
ICameraObserver 观察者接口,定义 Update(HObject image) 方法,用于接收图像更新通知并处理图像数据。
ICameraSubject 被观察者接口,定义 Add, Remove, Notify 方法,用于管理观察者的注册、注销以及事件通知。
CameraSubject 实现 ICameraSubject 接口的具体类,负责维护观察者列表并通知所有已注册的观察者。
CameraDriver 相机驱动类,负责从 SDK 获取图像,并通过 CameraSubject 发布事件,触发观察者的更新方法。
ImageProcessorA 具体的观察者实现类,实现了 ICameraObserver 接口,负责执行特定的图像处理任务(如图像增强)。
ImageProcessorB 另一个具体的观察者实现类,也实现了 ICameraObserver 接口,负责执行不同的图像分析任务(如目标检测)。

然后,cameraSubject 被观察者实例,如果Add了观察者实例,那么就相当于该实例订阅了一个事件。

所以这里也可以感受到,观察者模式和事件订阅的差别。

事件订阅模式是,模块自己订阅事件。

而观察者模式是,有一个第三方的被观察者实例,把你纳入麾下,你就是订阅了(当然你还得实现观察者接口)。

总的来说:cameraSubject 被观察者实例,既存在于相机驱动模块(需要调用Notify()触发事件)又存在于处理事件模块(需要添加自己进去,以及需要实现Update方法!!!)

最后,被观察者的Notify()里面会调用所有观察者的Update()。

💡 多种 Notify() 用法示例

那观察者模式好在哪里?就体现在如下的几个方面!!!!一些功能事件订阅的方式是无法实现的。

1️⃣ 异常捕获(防止"炸链")

csharp 复制代码
public void Notify(HObject image)
{
    foreach (var observer in observers)
    {
        try
        {
            observer.Update(image);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"❌ {observer.GetType().Name} 出错:{ex.Message}");
        }
    }
}

2️⃣ 异步处理(提高响应效率)

csharp 复制代码
public async void Notify(HObject image)
{
    var tasks = observers.Select(o => Task.Run(() =>
    {
        try { o.Update(image); }
        catch (Exception ex)
        {
            Console.WriteLine($"❌ {o.GetType().Name} 异步处理失败:{ex.Message}");
        }
    }));

    await Task.WhenAll(tasks);
}

3️⃣ 条件过滤(比如只处理亮度高的图像)

csharp 复制代码
public interface IFilterableObserver : ICameraObserver
{
    bool ShouldHandle(HObject image);
}

public void Notify(HObject image)
{
    foreach (var o in observers)
    {
        if (o is IFilterableObserver f && !f.ShouldHandle(image))
            continue;

        try { o.Update(image); }
        catch (Exception ex) { Console.WriteLine($"❌ {o.GetType().Name} 出错:{ex.Message}"); }
    }
}

4️⃣ 自动重试(适合网络上传、数据库保存等)

csharp 复制代码
private void SafeUpdate(ICameraObserver observer, HObject image)
{
    int retry = 3;
    while (retry-- > 0)
    {
        try
        {
            observer.Update(image);
            return;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"⚠️ {observer.GetType().Name} 第 {3 - retry} 次失败: {ex.Message}");
            Thread.Sleep(100); // 可配置
        }
    }

    Console.WriteLine($"❌ {observer.GetType().Name} 重试失败,放弃");
}

public void Notify(HObject image)
{
    foreach (var observer in observers)
    {
        SafeUpdate(observer, image);
    }
}

✅ 实际使用演示

csharp 复制代码
var camera = new CameraSubject();

camera.Add(new UIObserver());
camera.Add(new LoggerObserver());
camera.Add(new AnalyzerObserver());

🎯 对比总结

功能/特性 event 事件 观察者模式
多模块响应图像 ✅ 支持 ✅ 支持
异常隔离 ❌ 不支持 ✅ 支持
条件过滤 ❌ 不支持 ✅ 支持
异步支持 ❌ 手工复杂 ✅ 易扩展
重试机制 ❌ 不支持 ✅ 支持
解耦性 ❌ 紧耦合 ✅ 松耦合
测试友好 ❌ 不好 mock ✅ 好测试

📌 小结

事件机制虽然语法简洁,但在复杂系统中,尤其是图像采集 + 多模块处理的系统,劣势显现明显:

  • 一旦抛异常,系统整体功能中断
  • 缺乏扩展空间
  • 不利于维护和测试

观察者模式完美解决这些问题,逻辑集中、扩展灵活、结构清晰、异常独立、安全可靠


📘 推荐命名实践

如果你希望语义清晰又不太抽象,推荐使用:

csharp 复制代码
interface ICameraSubject
interface ICameraObserver

如果你计划封装为通用框架,可以用:

csharp 复制代码
interface ISubject<T>
interface IObserver<T>

最后一问:为啥被观察者也要定义一个接口?

在观察者模式中引入**抽象被观察者接口(如SubjectISubject)**主要有以下几个原因:

1. 实现解耦与多态

接口定义了被观察者的行为契约,使得观察者只依赖于抽象接口,而非具体实现类。这实现了依赖倒置原则

  • 观察者只需知道如何注册/注销自己,以及如何接收通知(通过接口方法)。
  • 具体被观察者可以自由变化(如从WeatherData扩展为StockData),只要实现相同接口,观察者无需修改。

示例

若直接依赖WeatherData类,后续新增StockData类时,观察者代码需重新修改;而依赖ISubject接口后,新增被观察者只需实现该接口即可。

2. 支持多种被观察者实现

通过接口,可以有多个不同的被观察者实现,它们可以是:

  • 同步通知 :如示例中的直接遍历观察者列表调用Update
  • 异步通知:将通知放入队列,由单独线程处理。
  • 广播通知:通过消息中间件发布事件。

示例

csharp 复制代码
// 不同被观察者实现相同接口
class WeatherData : ISubject { /* 同步通知 */ }
class AsyncWeatherData : ISubject { /* 异步通知 */ }

3. 遵循开闭原则

接口使系统更具扩展性:

  • 新增观察者 :无需修改被观察者代码,直接实现Observer接口并注册。
  • 新增被观察者 :实现ISubject接口,现有观察者可无缝适配。

4. 便于单元测试

接口便于创建测试替身(如Mock对象):

  • 在测试观察者时,可以用Mock对象模拟被观察者的行为,隔离外部依赖。

示例(使用Moq框架):

csharp 复制代码
var mockSubject = new Mock<ISubject>();
var observer = new CurrentConditionsDisplay(mockSubject.Object);

// 验证观察者是否正确注册
mockSubject.Verify(s => s.RegisterObserver(observer), Times.Once);

5. 避免菱形继承问题

若使用继承而非接口,当一个类需要同时成为多个被观察者的子类时,会引发多重继承冲突(如C++的菱形继承问题)。接口允许多实现,规避了这一问题。

对比:无接口的实现问题

若不使用抽象接口,直接让观察者依赖具体被观察者类(如WeatherData):

  • 强耦合:观察者与特定被观察者绑定,难以复用。
  • 扩展性差:新增被观察者需修改观察者代码。
  • 违反单一职责:被观察者类既要管理状态,又要处理观察者逻辑,职责过重。

总结

抽象被观察者接口是观察者模式的核心设计,它通过抽象隔离变化,使系统更灵活、可扩展和易维护。在大型系统中,这种设计模式能显著降低模块间的耦合度,提升代码质量。

相关推荐
阑梦清川2 分钟前
C#建立与数据库连接(版本问题的解决方案)踩坑总结
开发语言·数据库·c#
听风lighting28 分钟前
1. C++ WebServer项目分享
linux·c语言·c++·设计模式·嵌入式·webserver
code_li1 小时前
C#实现语音预处理:降噪/静音检测/自动增益
开发语言·c#
军训猫猫头1 小时前
100.Complex[]同时储存实数和虚数两组double的数组 C#例子
算法·c#·信号处理
o0向阳而生0o5 小时前
71、C# Parallel.ForEach 详解
c#
上位机付工7 小时前
不会PLC,怎么学上位机?
c#·上位机·modbus·三菱·西门子·欧姆龙plc
Kookoos8 小时前
ABP VNext + MongoDB 数据存储:多模型支持与 NoSQL 扩展
后端·mongodb·c#·.net·abp vnext
甄天8 小时前
WPF数据绑定
c#·wpf
牛奶咖啡1310 小时前
C#的泛型和匿名类型
c#·泛型接口·泛型方法·泛型委托·泛型类·泛型参数约束·匿名类型