这篇解决一个问题:设备软件里一个事件发生了,多个模块都要响应,怎么写才不让事件源和所有响应者搅在一起。从最直觉的写法开始,一步步演进到观察者模式,再落到设备软件的真实场景。
一、先从一个生活例子说起
你在公司上班,老板说「项目有进展就通知大家」。怎么通知?
方式一:老板挨个跑。 老板跑到你工位说一句,跑到同事 A 工位说一句,跑到同事 B 工位说一句。老板累,而且每次有新人加入项目,老板要多跑一个工位。
方式二:建一个群。 老板在群里发消息,谁关心谁看。老板不用知道群里有多少人,有新人了拉进群就行,老板发消息的方式不变。
方式二就是观察者模式。
关键点:
- 老板不认识每个接收者,只管「往群里发」。
- 接收者自己决定要不要看,不看的退群,新来的拉群。
- 加人不改老板的行为,老板还是发一条消息。
观察者模式的本质:事件源不认识所有响应者,只管发事件;响应者自己订阅,订阅了就收到,不订阅就收不到。
二、回到代码:不用观察者模式会怎样
设备软件里最常见的场景:报警发生时,多个模块要响应。
- 日志模块要写日志。
- 蜂鸣模块要响蜂鸣。
- UI 模块要弹窗。
- 数据库模块要存记录。
- SECS 模块要上报。
最直觉的写法:直接调
void Actor::Alarm(string msg) { Logger::Write(msg); // 写日志 Beeper::Beep(); // 响蜂鸣 pDlg->ShowAlarm(msg); // 弹窗 db->WriteAlarm(msg); // 存库 secs->Report(msg); // 上报 }能跑,但问题一堆:
问题一:耦合爆炸。Actor要#include日志、蜂鸣、UI、数据库、SECS 的头文件。五个模块的全貌都暴露给了Actor。
问题二:加响应者要改 Actor。 明天加「短信通知」,Actor::Alarm里要加一行sms->Send(msg)。后天加「邮件通知」,又加一行。Actor越改越长。
问题三:删响应者也要改 Actor。 某个项目不需要 SECS 上报,要注释掉secs->Report(msg)。但注释了别的项目又要,改来改去。
问题四:没法独立测试。 测Actor::Alarm要同时有日志、蜂鸣、UI、数据库、SECS。缺一个就跑不了。
问题五:响应顺序写死。 先日志再蜂鸣再弹窗,写在代码里。想调顺序要改代码。
根本问题:事件源(Actor)知道了所有响应者是谁、怎么调、什么顺序。这不该是事件源管的事。
三、一步步演进到观察者模式
第一步:把响应者抽成接口
先定义「能响应报警的东西」长什么样:
class IAlarmListener { public: virtual void OnAlarm(string msg) = 0; virtual ~IAlarmListener() = default; };所有要响应报警的模块都实现这个接口:
class Logger : public IAlarmListener { public: void OnAlarm(string msg) override { Write(msg); } }; class Beeper : public IAlarmListener { public: void OnAlarm(string msg) override { Beep(); } }; class AlarmDB : public IAlarmListener { public: void OnAlarm(string msg) override { WriteRecord(msg); } };第二步:事件源维护一个订阅者列表
class Actor { vector<IAlarmListener*> m_listeners; // 订阅者列表 public: // 订阅 void AddListener(IAlarmListener* l) { m_listeners.push_back(l); } // 发报警:通知所有订阅者 void Alarm(string msg) { for (auto* l : m_listeners) { l->OnAlarm(msg); } } };第三步:使用时各自订阅
Logger logger; Beeper beeper; AlarmDB db; Actor actor; actor.AddListener(&logger); actor.AddListener(&beeper); actor.AddListener(&db); // 运行时 actor.Alarm("真空失败"); // logger.OnAlarm → 写日志 // beeper.OnAlarm → 响蜂鸣 // db.OnAlarm → 存库关键变化:
Actor不知道有Logger、Beeper、AlarmDB这些具体类。它只认IAlarmListener。- 加新响应者?实现
IAlarmListener,AddListener传进去,Actor不改。 - 删响应者?不
AddListener就行,Actor不改。 - 换顺序?
AddListener的顺序换一下,Actor不改。
这就是观察者模式。事件源和响应者之间通过接口解耦,互不认识。
四、观察者模式的标准结构
| 角色 | 例子 |
|---|---|
Subject(事件源) |
|
Observer(观察者接口) |
|
ConcreteObserver(具体观察者) |
|
Client(装配方) | 初始化时 |
Subject(Actor) │ 维护 list<Observer*> │ 事件发生时遍历调 OnEvent ▼ Observer(IAlarmListener) ▲ 实现 ConcreteObserver(Logger / Beeper / DB)核心:Subject 只认 Observer 接口,不认具体类。Observer 只认事件数据,不认 Subject。
五、观察者模式解决了什么
| 问题 | 不用观察者 | 用观察者 |
|---|---|---|
耦合 | 事件源 include 所有响应者 | 事件源只认接口 |
加响应者 | 改事件源代码 | AddListener,不改事件源 |
删响应者 | 注释事件源代码 | 不 AddListener,不改事件源 |
测试 | 要 mock 所有响应者 | mock 一个 Observer 就行 |
顺序 | 写死在代码里 | 由 AddListener 顺序决定 |
一句话:观察者模式把「事件发生了」和「谁来响应」分开。事件源只管发,响应者自己决定要不要听。
六、设备软件里的真实场景
场景一:报警系统——最典型的观察者
设备里任何模块都可能报警,报警要通知日志、蜂鸣、UI、数据库、SECS。
// 事件源 class Actor { public: // 信号:可以挂多个槽 static Signal3<string, string, string> m_alarmSignal; void Alarm(string msg) { m_alarmSignal.Emit(time, code, msg); // 只管发 } }; // 各响应者自己订阅 Actor::m_alarmSignal.Connect([](string t, string c, string m) { Logger::Instance().Write(m); // 日志 }); Actor::m_alarmSignal.Connect([](string t, string c, string m) { Beeper::Instance().Beep(); // 蜂鸣 }); Actor::m_alarmSignal.Connect([](string t, string c, string m) { pDlg->ShowAlarm(m); // UI }); Actor::m_alarmSignal.Connect([](string t, string c, string m) { AlarmDB::Instance().WriteRecord(c, t, m); // 数据库 }); Actor::m_alarmSignal.Connect([](string t, string c, string m) { Secs::Instance().Report(m); // SECS });Actor一个#include都不用加。加短信通知?再加一行Connect,Actor不改。
场景二:状态变更通知
手臂状态变了(空闲→取料中→搬运中→放料中),UI 要刷新、总控要记录、日志要留痕。
class Arm { public: Signal1<ArmState> m_stateSignal; // 状态变更信号 void SetState(ArmState s) { m_state = s; m_stateSignal.Emit(s); // 通知所有订阅者 } }; // UI 订阅 arm->m_stateSignal.Connect([](ArmState s) { pDlg->UpdateArmStatus(s); }); // 总控订阅 arm->m_stateSignal.Connect([](ArmState s) { controller->OnArmStateChange(s); }); // 日志订阅 arm->m_stateSignal.Connect([](ArmState s) { Logger::Instance().Write("ArmState: " + ToString(s)); });手臂不知道 UI 长什么样、总控怎么处理、日志怎么写。它只管「状态变了,发个信号」。
场景三:到位通知
轴运动到位后,可能要通知多个对象:状态机推进、UI 刷新位置、日志记录。
class Axis { public: Signal1<double> m_arriveSignal; // 到位信号,带位置数据 void MoveTo(double pos) { // ... 运动 m_arriveSignal.Emit(pos); // 到位了,发信号 } }; // 状态机订阅 axis->m_arriveSignal.Connect([](double pos) { arm->OnArrive(pos); // 推进状态机 }); // UI 订阅 axis->m_arriveSignal.Connect([](double pos) { pDlg->UpdateAxisPos(pos); });场景四:结批通知
一个模块结批完成后,要通知下游模块也开始结批。前面讲过的ConnectEndLotAfter就是观察者模式的应用。
// 定义结批关系:A 结批后通知 B Actor::ConnectEndLotAfter({ armTray }, { buffer1, buffer2, buffer3 }); // armTray 结批时 armTray->EndLot(); // → buffer1.EndLot() // → buffer2.EndLot() // → buffer3.EndLot()armTray不知道下游有谁,结批关系在初始化时配好。加一个下游模块?ConnectEndLotAfter加一个,armTray不改。
七、观察者模式 vs 回调函数 vs 信号槽
这三个概念容易混,简单理清:
| 回调函数 | 观察者模式 | 信号槽 | |
|---|---|---|---|
数量 | 一对一 | 一对多 | 一对多 |
注册 | 调用时传参 | 事先 subscribe | 事先 connect |
触发 | 被调方调 cb | 事件源 notify | 事件源 emit |
关系 | 临时的一次性 | 持续订阅 | 持续订阅 |
本质 | 函数指针 | 接口+列表 | 观察者的封装 |
关系:信号槽是观察者模式的具体实现(把订阅列表和通知逻辑封装成 Signal 类)。回调是观察者的退化版(只有一个订阅者)。
一句话:观察者模式是「一对多的事件通知」的设计思想,信号槽是它的工程化实现,回调是它的简化版。
八、观察者模式的坑
坑一:同步通知导致死锁
void Notify(string msg) { for (auto* o : m_observers) o->OnEvent(msg); // 同步调,如果观察者里又回来操作 Subject → 死锁 }事件源发通知时是同步调用,观察者里如果又去操作事件源(加锁、改状态),容易重入死锁。
对策:观察者里不要回调事件源;或者发通知时先拷贝一份观察者列表再遍历,避免遍历中列表被改。
坑二:观察者对象销毁了但没取消订阅
auto* ui = new UIDialog(); actor.AddListener(ui); // ... delete ui; // ui 销毁了,但 actor 的列表里还有 ui 的指针 actor.Alarm("xxx"); // 调 ui->OnAlarm → 访问已释放内存 → 崩对策:观察者销毁前调RemoveListener取消订阅;或用weak_ptr让事件源自动检测观察者是否还活着。
坑三:通知顺序不可控
观察者 A 依赖观察者 B 先执行,但AddListener顺序不对,A 先于 B 被通知。
对策:如果观察者之间有依赖,用优先级或分组管理顺序;或者干脆拆成多个信号,按顺序发。
坑四:通知里做重活导致事件源卡住
void Alarm(string msg) { for (auto* o : m_observers) o->OnAlarm(msg); // 如果某个观察者里弹模态对话框 → 事件源线程卡住 }对策:观察者里只做轻量操作(入队、标记),重活(弹窗、写库、网络)异步到别的线程。
九、可复用结论
- 观察者模式的本质:事件源不认识所有响应者,只管发事件;响应者自己订阅,订阅了就收到。
- 三个角色:Subject(事件源+订阅列表)、Observer(响应接口)、ConcreteObserver(具体响应者)。
- 设备软件最典型应用:报警通知(日志+蜂鸣+UI+数据库+SECS)、状态变更通知、到位通知、结批通知。
- 核心价值:加响应者不改事件源、删响应者不改事件源、事件源不依赖任何具体响应者。
- 和回调/信号槽的关系:信号槽是观察者的工程化实现,回调是只有一个订阅者的观察者。
- 坑:同步通知别重入、观察者销毁要取消订阅、通知里别做重活、顺序有依赖要管理。
观察者模式在设备软件里不是「设计模式课本第几章」,而是「报警怎么不耦合、状态怎么通知 UI、结批怎么链式传播」的实打实解法。用对了,加一个响应者只是一行Connect;用错了,事件源里堆满#include,改一处碰一片。