📷 用观察者模式对比事件订阅(相机举例)
标签: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>
最后一问:为啥被观察者也要定义一个接口?
在观察者模式中引入**抽象被观察者接口(如Subject
或ISubject
)**主要有以下几个原因:
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
):
- 强耦合:观察者与特定被观察者绑定,难以复用。
- 扩展性差:新增被观察者需修改观察者代码。
- 违反单一职责:被观察者类既要管理状态,又要处理观察者逻辑,职责过重。
总结
抽象被观察者接口是观察者模式的核心设计,它通过抽象隔离变化,使系统更灵活、可扩展和易维护。在大型系统中,这种设计模式能显著降低模块间的耦合度,提升代码质量。