观察者模式:设备软件里报警、状态、日志为什么都靠它解耦
2026/9/4 8:30:58 网站建设 项目流程

这篇解决一个问题:设备软件里一个事件发生了,多个模块都要响应,怎么写才不让事件源和所有响应者搅在一起。从最直觉的写法开始,一步步演进到观察者模式,再落到设备软件的真实场景。

一、先从一个生活例子说起

你在公司上班,老板说「项目有进展就通知大家」。怎么通知?

方式一:老板挨个跑。 老板跑到你工位说一句,跑到同事 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不知道有LoggerBeeperAlarmDB这些具体类。它只认IAlarmListener
  • 加新响应者?实现IAlarmListenerAddListener传进去,Actor不改。
  • 删响应者?不AddListener就行,Actor不改。
  • 换顺序?AddListener的顺序换一下,Actor不改。

这就是观察者模式。事件源和响应者之间通过接口解耦,互不认识。

四、观察者模式的标准结构

角色例子

Subject(事件源)

Actor,维护订阅者列表,发事件时通知所有订阅者

Observer(观察者接口)

IAlarmListener,定义OnAlarm接口

ConcreteObserver(具体观察者)

Logger/Beeper/AlarmDB,各自实现响应逻辑

Client(装配方)

初始化时AddListener把观察者挂到事件源

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都不用加。加短信通知?再加一行ConnectActor不改。

场景二:状态变更通知

手臂状态变了(空闲→取料中→搬运中→放料中),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); // 如果某个观察者里弹模态对话框 → 事件源线程卡住 }

对策:观察者里只做轻量操作(入队、标记),重活(弹窗、写库、网络)异步到别的线程。


九、可复用结论

  1. 观察者模式的本质:事件源不认识所有响应者,只管发事件;响应者自己订阅,订阅了就收到。
  2. 三个角色:Subject(事件源+订阅列表)、Observer(响应接口)、ConcreteObserver(具体响应者)。
  3. 设备软件最典型应用:报警通知(日志+蜂鸣+UI+数据库+SECS)、状态变更通知、到位通知、结批通知。
  4. 核心价值:加响应者不改事件源、删响应者不改事件源、事件源不依赖任何具体响应者。
  5. 和回调/信号槽的关系:信号槽是观察者的工程化实现,回调是只有一个订阅者的观察者。
  6. 坑:同步通知别重入、观察者销毁要取消订阅、通知里别做重活、顺序有依赖要管理。

观察者模式在设备软件里不是「设计模式课本第几章」,而是「报警怎么不耦合、状态怎么通知 UI、结批怎么链式传播」的实打实解法。用对了,加一个响应者只是一行Connect;用错了,事件源里堆满#include,改一处碰一片。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询