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 观察者模式的核心流程图

观察者模式核心流程 主题 (Subject) 维护观察者列表 状态变化时通知 观察者列表 回调函数链表 动态增删 观察者1 (LCD) 观察者2 (SD卡) 观察者3 (串口) 遍历通知 订阅 / 取消订阅 事件触发 核心流程:事件触发 → 主题遍历观察者列表 → 依次调用回调函数 观察者可以动态订阅或取消订阅,实现完全解耦

6.7 性能考量与优化

在嵌入式系统里,观察者模式不是银弹。用不好,性能会崩。

  • 回调执行时间:每个观察者的回调要尽量短。如果某个回调需要耗时操作(比如写Flash),建议用消息队列异步处理。
  • 遍历开销:观察者太多时,遍历链表会消耗CPU。我建议控制在20个以内。超过这个数,考虑用哈希表或优先级分组。
  • 递归风险:不要在回调里再次触发同一个主题的通知,否则会递归调用,栈溢出。我见过有人这么干,调试了一整天。
  • 内存碎片:频繁注册注销观察者会导致内存碎片。如果观察者数量固定,可以用静态数组+位图管理,避免动态分配。

重要提醒:观察者模式虽然解耦,但也会让代码流程变得「隐式」。你很难一眼看出某个事件发生后会触发哪些操作。调试时建议加日志,打印每次通知的观察者数量和ID。我曾经在一个50个观察者的系统里找bug,没有日志简直寸步难行。

6.8 总结

观察者模式是嵌入式事件驱动架构的基石。它让模块之间松耦合,每个模块只关心自己该做的事。回调函数列表是实现它的核心手段,订阅发布模型是它的高级形态。

记住:模式是工具,不是目的。别为了用模式而用模式。如果你的系统只有两个模块在通信,直接函数调用比观察者模式更简单高效。

我的建议:从最简单的回调函数列表开始,等系统复杂度上来了,再逐步引入消息中心、事件队列等机制。一步到位往往过设计,反而增加维护成本。


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