6、观察者模式:事件驱动机制、回调函数列表、订阅发布模型
观察者模式,说白了就是「你盯着我,我变了就通知你」。
我在嵌入式项目里用过无数次这个模式。最典型的一次,是做一个多传感器数据采集系统——温度、湿度、气压三个传感器,采集完数据要同时更新LCD显示、SD卡日志、串口输出。如果每个传感器都直接调用这三个模块,代码会耦合成一团乱麻。后来我用观察者模式重构,整个世界清静了。
6.1 什么是观察者模式?
观察者模式定义了一种一对多的依赖关系。当一个对象(主题)的状态发生变化时,所有依赖它的对象(观察者)都会得到通知并自动更新。
你想想看,这像不像公众号的订阅机制?你关注了一个号,号主一发文章,你就收到推送。你不关注,就收不到。观察者模式就是干这个的。
核心三要素:
- 主题(Subject):被观察的对象,维护观察者列表,负责通知
- 观察者(Observer):订阅主题的对象,收到通知后执行自己的逻辑
- 订阅/取消订阅:动态管理观察者列表的接口
6.2 嵌入式C语言中的观察者模式实现
在C语言里,我们没有class和interface,但可以用结构体+函数指针搞定。我个人习惯把观察者设计成一个回调函数列表,每个观察者注册一个函数指针,主题变化时遍历列表依次调用。
6.2.1 基础数据结构
/* 观察者回调函数类型 */
typedef void (*observer_callback_t)(void *context, int event_id, void *data);
/* 观察者节点 */
typedef struct observer_node {
observer_callback_t callback;
void *context;
struct observer_node *next;
} observer_node_t;
/* 主题对象 */
typedef struct {
observer_node_t *head;
int event_count;
} subject_t;
这里我用了链表来管理观察者。为什么不用数组?因为嵌入式场景下,观察者的注册和注销是动态的,链表插入删除方便,而且不需要预分配大数组。我在项目里吃过数组越界的亏,后来全改成链表了。
6.2.2 核心接口实现
/* 初始化主题 */
void subject_init(subject_t *sub) {
sub->head = NULL;
sub->event_count = 0;
}
/* 注册观察者 */
int subject_attach(subject_t *sub, observer_callback_t cb, void *ctx) {
observer_node_t *node;
/* 防止重复注册 */
node = sub->head;
while (node) {
if (node->callback == cb && node->context == ctx) {
return -1; /* 已注册 */
}
node = node->next;
}
node = (observer_node_t *)malloc(sizeof(observer_node_t));
if (!node) return -2; /* 内存不足 */
node->callback = cb;
node->context = ctx;
node->next = sub->head;
sub->head = node;
return 0;
}
/* 注销观察者 */
int subject_detach(subject_t *sub, observer_callback_t cb, void *ctx) {
observer_node_t *prev = NULL, *curr = sub->head;
while (curr) {
if (curr->callback == cb && curr->context == ctx) {
if (prev)
prev->next = curr->next;
else
sub->head = curr->next;
free(curr);
return 0;
}
prev = curr;
curr = curr->next;
}
return -1; /* 未找到 */
}
/* 通知所有观察者 */
void subject_notify(subject_t *sub, int event_id, void *data) {
observer_node_t *node = sub->head;
while (node) {
node->callback(node->context, event_id, data);
node = node->next;
}
}
注意:在中断服务函数中调用 subject_notify 时,要确保回调函数是中断安全的。我曾经在一个项目中,某个观察者的回调里调用了 printf,结果在中断里跑,直接导致系统卡死。教训深刻。
6.3 事件驱动机制
观察者模式天然适合事件驱动架构。每个事件都有一个ID,观察者根据事件ID决定是否响应。
/* 事件ID定义 */
typedef enum {
EVENT_TEMPERATURE_UPDATE = 0,
EVENT_HUMIDITY_UPDATE,
EVENT_PRESSURE_UPDATE,
EVENT_ALARM_TRIGGER,
EVENT_MAX
} event_id_t;
/* 使用示例:温度传感器更新 */
void temperature_sensor_task(void *params) {
float temp;
while (1) {
temp = read_temperature();
subject_notify(&sensor_subject, EVENT_TEMPERATURE_UPDATE, &temp);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
/* LCD显示观察者 */
void lcd_display_callback(void *ctx, int event_id, void *data) {
float *value = (float *)data;
switch (event_id) {
case EVENT_TEMPERATURE_UPDATE:
lcd_show_temperature(*value);
break;
case EVENT_HUMIDITY_UPDATE:
lcd_show_humidity(*value);
break;
/* 其他事件... */
}
}
这样做的好处是,传感器模块完全不知道LCD、SD卡、串口的存在。它只管采集数据、发通知。谁想监听,自己注册去。这就是解耦。
6.4 订阅发布模型
订阅发布模型是观察者模式的升级版。它引入了一个「消息中心」作为中介,发布者和订阅者不再直接接触。
观察者模式 vs 订阅发布模型:
| 特性 | 观察者模式 | 订阅发布模型 |
|---|---|---|
| 耦合度 | 主题知道观察者 | 完全解耦 |
| 消息路由 | 直接通知 | 通过消息中心 |
| 适用场景 | 单进程、简单通知 | 多模块、复杂系统 |
| 实现复杂度 | 低 | 中 |
在嵌入式系统中,我通常这样选择:如果模块数量少(3-5个),直接用观察者模式。如果模块超过10个,或者需要跨任务通信,就用订阅发布模型。
6.5 实战:按键事件分发系统
嗯,这里我要分享一个实际案例。一个智能家居控制面板,有8个按键,每个按键按下要触发不同的动作——开灯、关窗帘、调温度、启动场景模式等等。
/* 按键事件结构 */
typedef struct {
uint8_t key_id;
uint8_t press_type; /* 0:短按, 1:长按, 2:双击 */
uint32_t timestamp;
} key_event_t;
/* 消息中心 */
typedef struct {
subject_t subjects[EVENT_MAX];
} message_center_t;
message_center_t g_msg_center;
/* 按键中断服务程序 */
void KEY_IRQHandler(void) {
key_event_t evt;
evt.key_id = get_pressed_key();
evt.press_type = detect_press_type();
evt.timestamp = get_sys_tick();
/* 发布按键事件 */
subject_notify(&g_msg_center.subjects[EVENT_KEY_PRESS],
evt.key_id, &evt);
}
/* 场景管理器订阅按键事件 */
void scene_manager_init(void) {
subject_attach(&g_msg_center.subjects[EVENT_KEY_PRESS],
scene_key_callback, NULL);
}
void scene_key_callback(void *ctx, int event_id, void *data) {
key_event_t *evt = (key_event_t *)data;
if (evt->key_id == KEY_SCENE_MODE && evt->press_type == 1) {
/* 长按场景键,切换场景 */
switch_scene();
}
}
避坑指南:我曾经在按键中断里直接调用 subject_notify,结果某个观察者的回调里操作了I2C总线,而I2C驱动依赖另一个中断。两个中断互相抢占,系统直接死锁。后来我改成在中断里只放一个标志位,在任务循环里处理通知,问题解决。
6.6 观察者模式的核心流程图
6.7 性能考量与优化
在嵌入式系统里,观察者模式不是银弹。用不好,性能会崩。
- 回调执行时间:每个观察者的回调要尽量短。如果某个回调需要耗时操作(比如写Flash),建议用消息队列异步处理。
- 遍历开销:观察者太多时,遍历链表会消耗CPU。我建议控制在20个以内。超过这个数,考虑用哈希表或优先级分组。
- 递归风险:不要在回调里再次触发同一个主题的通知,否则会递归调用,栈溢出。我见过有人这么干,调试了一整天。
- 内存碎片:频繁注册注销观察者会导致内存碎片。如果观察者数量固定,可以用静态数组+位图管理,避免动态分配。
重要提醒:观察者模式虽然解耦,但也会让代码流程变得「隐式」。你很难一眼看出某个事件发生后会触发哪些操作。调试时建议加日志,打印每次通知的观察者数量和ID。我曾经在一个50个观察者的系统里找bug,没有日志简直寸步难行。
6.8 总结
观察者模式是嵌入式事件驱动架构的基石。它让模块之间松耦合,每个模块只关心自己该做的事。回调函数列表是实现它的核心手段,订阅发布模型是它的高级形态。
记住:模式是工具,不是目的。别为了用模式而用模式。如果你的系统只有两个模块在通信,直接函数调用比观察者模式更简单高效。
我的建议:从最简单的回调函数列表开始,等系统复杂度上来了,再逐步引入消息中心、事件队列等机制。一步到位往往过设计,反而增加维护成本。
公众号:蓝海资料掘金营,微信deep3321