观察者模式:设计事件驱动接口,实现一对多的依赖关系解耦

各位同学,今天我们来聊聊观察者模式。说实话,这个模式在嵌入式系统里太常用了。你想想看,一个按键按下,要通知好几个模块——LED要闪、蜂鸣器要响、日志要记录。如果把这些逻辑全写死在按键检测函数里,那代码就成了一团乱麻。

观察者模式,说白了就是解决「一对多通知」的问题。一个事件源,多个监听者。事件源不需要知道监听者是谁,监听者也不需要关心事件源怎么工作的。这就是解耦。

从实际需求说起

我在项目中遇到过这样一个场景:一个温度传感器,每秒钟采集一次数据。数据来了之后,需要做三件事:更新显示、判断是否超温报警、记录到历史缓冲区。最开始,我直接在采集函数里依次调用这三个函数。结果呢?

  • 想加一个新功能(比如上传到云端),得改采集函数
  • 想关掉某个功能(比如暂时不报警),还得改采集函数
  • 测试的时候,每个功能都耦合在一起,没法单独测

这就是典型的紧耦合。观察者模式就是来解决这个问题的。

核心设计思路

观察者模式的核心就两个角色:主题(Subject)观察者(Observer)。主题负责维护一个观察者列表,当事件发生时,遍历列表通知所有观察者。观察者则实现一个统一的接口,接收通知并做出响应。

用C语言实现,我习惯用函数指针。每个观察者就是一个回调函数,主题里存一个函数指针数组。嗯,这里要注意:数组大小要提前规划好,或者用动态链表。

关键点:观察者模式的核心是「订阅-发布」机制。观察者先向主题注册,主题才知道要通知谁。注册和注销接口是必须的。

代码实现:一个轻量级的事件驱动框架

下面我给出一个完整的实现。这个框架我用了好几年,在多个项目里验证过,稳定可靠。

/* event_driver.h */
#ifndef EVENT_DRIVER_H
#define EVENT_DRIVER_H

#include <stdint.h>

/* 事件类型定义 */
typedef enum {
    EVENT_TEMPERATURE_UPDATE,
    EVENT_BUTTON_PRESS,
    EVENT_ALARM_TRIGGER,
    EVENT_MAX
} event_type_t;

/* 观察者回调函数类型 */
typedef void (*observer_callback_t)(void *data, uint32_t data_len);

/* 主题结构体 */
typedef struct {
    observer_callback_t callbacks[EVENT_MAX][10];  /* 每个事件最多10个观察者 */
    uint8_t count[EVENT_MAX];                      /* 每个事件的观察者数量 */
} subject_t;

/* 接口函数 */
void subject_init(subject_t *sub);
int subject_register(subject_t *sub, event_type_t event, observer_callback_t cb);
int subject_unregister(subject_t *sub, event_type_t event, observer_callback_t cb);
void subject_notify(subject_t *sub, event_type_t event, void *data, uint32_t data_len);

#endif
/* event_driver.c */
#include "event_driver.h"
#include <string.h>

void subject_init(subject_t *sub) {
    memset(sub, 0, sizeof(subject_t));
}

int subject_register(subject_t *sub, event_type_t event, observer_callback_t cb) {
    if (event >= EVENT_MAX) return -1;
    if (sub->count[event] >= 10) return -2;  /* 观察者已满 */
    
    /* 检查是否已注册 */
    for (int i = 0; i < sub->count[event]; i++) {
        if (sub->callbacks[event][i] == cb) {
            return -3;  /* 重复注册 */
        }
    }
    
    sub->callbacks[event][sub->count[event]++] = cb;
    return 0;
}

int subject_unregister(subject_t *sub, event_type_t event, observer_callback_t cb) {
    if (event >= EVENT_MAX) return -1;
    
    for (int i = 0; i < sub->count[event]; i++) {
        if (sub->callbacks[event][i] == cb) {
            /* 用最后一个覆盖当前,然后减一 */
            sub->callbacks[event][i] = sub->callbacks[event][sub->count[event] - 1];
            sub->count[event]--;
            return 0;
        }
    }
    return -2;  /* 未找到 */
}

void subject_notify(subject_t *sub, event_type_t event, void *data, uint32_t data_len) {
    if (event >= EVENT_MAX) return;
    
    for (int i = 0; i < sub->count[event]; i++) {
        if (sub->callbacks[event][i] != NULL) {
            sub->callbacks[event][i](data, data_len);
        }
    }
}

使用示例

来看看怎么用这个框架。假设我们有温度传感器、显示模块、报警模块。

/* 观察者回调函数 */
void display_update(void *data, uint32_t len) {
    float *temp = (float *)data;
    /* 更新显示逻辑 */
    printf("Display: %.2f°C\n", *temp);
}

void alarm_check(void *data, uint32_t len) {
    float *temp = (float *)data;
    if (*temp > 50.0f) {
        printf("ALARM: Temperature too high!\n");
    }
}

/* 主程序 */
subject_t g_subject;

void temperature_sensor_task(void) {
    float current_temp = read_sensor();
    
    /* 通知所有观察者 */
    subject_notify(&g_subject, EVENT_TEMPERATURE_UPDATE, 
                   ¤t_temp, sizeof(current_temp));
}

int main(void) {
    subject_init(&g_subject);
    
    /* 注册观察者 */
    subject_register(&g_subject, EVENT_TEMPERATURE_UPDATE, display_update);
    subject_register(&g_subject, EVENT_TEMPERATURE_UPDATE, alarm_check);
    
    while (1) {
        temperature_sensor_task();
        delay(1000);
    }
}

我的经验:观察者回调函数里不要做耗时操作。如果某个观察者需要处理大量数据,建议用消息队列把任务丢给后台处理。否则一个观察者卡住了,其他观察者都得等着。

架构图:观察者模式的核心流程

下面这张图展示了观察者模式的完整工作流程。从注册到通知,一目了然。

观察者模式核心流程 主题 (Subject) 维护观察者列表 register() 观察者 A 显示更新 观察者 B 报警检查 观察者 C 日志记录 notify() notify() notify() 事件源 温度传感器 / 按键 触发事件 事件源触发 → 主题遍历观察者列表 → 依次调用回调函数 关键:主题不依赖观察者具体实现,只依赖统一的回调接口

避坑指南

我曾经在一个项目里踩过一个大坑。当时观察者回调函数里直接调用了free()释放了事件数据的内存。结果另一个观察者还在用这个数据,直接野指针崩溃。调试了一整天才找到原因。

所以,关于观察者模式,有几点经验分享:

  • 数据所有权要明确:通知时传递的数据,是主题分配还是观察者分配?谁负责释放?必须在设计文档里写清楚。
  • 不要在通知循环里注册/注销:如果在某个观察者的回调里调用了register或unregister,会破坏正在遍历的数组。我一般用延迟处理,或者加一个「待处理列表」。
  • 考虑优先级:有些观察者需要优先响应(比如紧急停止),有些可以延后。可以在注册时传入优先级参数,通知时按优先级排序。
  • 注意中断上下文:如果事件来自中断,观察者回调里不能做阻塞操作。我习惯在中断里只设置标志位,在主循环里调用notify。

警告:观察者模式虽然好用,但不要滥用。如果只有一两个观察者,直接函数调用更简单。过度设计也是罪。我见过有人把两个函数之间的调用也改成观察者模式,结果代码绕来绕去,维护成本反而高了。

性能考量

观察者模式会引入一定的性能开销。主要是函数指针调用和循环遍历。在资源受限的MCU上,需要注意:

场景 建议 原因
观察者数量 < 5 直接使用数组,线性遍历 代码简单,性能足够
观察者数量 > 10 考虑用链表或哈希表 减少内存浪费,方便动态管理
事件频率 > 1kHz 用位图标记,批量通知 减少函数调用次数
中断中触发 只设置标志,主循环处理 避免在中断里做复杂操作

总结

观察者模式是嵌入式事件驱动架构的基石。它让模块之间的依赖关系从「硬编码」变成了「可配置」。你想想看,用了我上面这个框架,以后加一个新功能,只需要写一个回调函数,然后register一下就行。原来的代码一行都不用改。

这就是解耦的魅力。也是我们做嵌入式系统架构追求的目标——高内聚、低耦合。

好了,今天的分享就到这里。代码我已经在多个项目里验证过,你可以直接拿去用。如果有问题,欢迎交流。


公众号:蓝海资料掘金营,微信deep3321