观察者模式:设备软件里报警、状态、日志为什么都靠它解耦 📅 发布时间:2026/9/4 8:31:06 👁 浏览次数: 这篇解决一个问题设备软件里一个事件发生了多个模块都要响应怎么写才不让事件源和所有响应者搅在一起。从最直觉的写法开始一步步演进到观察者模式再落到设备软件的真实场景。一、先从一个生活例子说起你在公司上班老板说「项目有进展就通知大家」。怎么通知方式一老板挨个跑。 老板跑到你工位说一句跑到同事 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 { vectorIAlarmListener* 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。加新响应者实现IAlarmListenerAddListener传进去Actor不改。删响应者不AddListener就行Actor不改。换顺序AddListener的顺序换一下Actor不改。这就是观察者模式。事件源和响应者之间通过接口解耦互不认识。四、观察者模式的标准结构角色例子Subject事件源Actor维护订阅者列表发事件时通知所有订阅者Observer观察者接口IAlarmListener定义OnAlarm接口ConcreteObserver具体观察者Logger/Beeper/AlarmDB各自实现响应逻辑Client装配方初始化时AddListener把观察者挂到事件源SubjectActor │ 维护 listObserver* │ 事件发生时遍历调 OnEvent ▼ ObserverIAlarmListener ▲ 实现 ConcreteObserverLogger / Beeper / DB核心Subject 只认 Observer 接口不认具体类。Observer 只认事件数据不认 Subject。五、观察者模式解决了什么问题不用观察者用观察者耦合事件源 include 所有响应者事件源只认接口加响应者改事件源代码AddListener不改事件源删响应者注释事件源代码不 AddListener不改事件源测试要 mock 所有响应者mock 一个 Observer 就行顺序写死在代码里由 AddListener 顺序决定一句话观察者模式把「事件发生了」和「谁来响应」分开。事件源只管发响应者自己决定要不要听。六、设备软件里的真实场景场景一报警系统——最典型的观察者设备里任何模块都可能报警报警要通知日志、蜂鸣、UI、数据库、SECS。// 事件源 class Actor { public: // 信号可以挂多个槽 static Signal3string, 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都不用加。加短信通知再加一行ConnectActor不改。场景二状态变更通知手臂状态变了空闲→取料中→搬运中→放料中UI 要刷新、总控要记录、日志要留痕。class Arm { public: Signal1ArmState 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: Signal1double 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改一处碰一片。