我入行嵌入式这几年,最有感触的一个瞬间,是用观察者模式重构完一个被 if-else 塞满的串口协议解析模块之后,原本动不动就“卡住”的通信链路一下子变得极其丝滑。硬件通信领域本来就充满各种时序约束、中断优先级、缓冲区溢出问题,观察者模式能成为很多老工程师口中的 yyds,不是因为它听起来高级,而是它真正解决了嵌入式通信里最顽固的两个问题:同步阻塞带来的响应延迟,以及模块之间高耦合带来的修改恐惧。
这篇文章我把整个思路完整梳理一遍:从“为什么通信会卡”的根源讲起,到观察者模式在 MCU 平台上的工作原理,再到一个可以抄作业的轻量级 C 语言实现,最后用 UART 命令解析的实战案例做前后对比。如果你正在写裸机程序、跑 RTOS,或者刚把一块传感器数据接到主控上,这篇内容应该能帮你少走不少弯路。
1. 通信“卡顿”的根源:同步等待与回调耦合
1.1 阻塞轮询为什么让人头疼
很多新手写串口收发,第一个版本长这样:
uint8_t get_response(uint8_t cmd) { // 假设串口已经初始化 send_cmd(cmd); uint8_t resp = 0; while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET) { // 死等接收完成 } resp = USART_ReceiveData(USART1); return resp; }这段代码功能上完全正确,但它把 CPU 锁死在等待循环里。假如外设响应需要 50ms,这 50ms 里你什么都干不了:LED 不能闪、按键不能扫、另一路总线上的传感器数据也没人处理。更麻烦的是,如果对方协议里有一条“不应答”的分支,while 循环直接变成死循环,整机只能靠看门狗复位。
阻塞轮询的本质问题是:通信时序占用 CPU,而不是 CPU 占用通信时序。硬件通信本应是异步的,数据到了自动触发,没有数据就去做别的事,结果你把它写成了同步等待,原本属于硬件的能力全被浪费了。
1.2 中断回调算不算观察者模式
为了不阻塞,大家普遍会用中断加回调函数:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); handle_uart_byte(data); // 直接调用某个全局处理函数 } }handle_uart_byte()里可能做了很多事:解析协议帧、更新状态机、写日志、计算校验和、回复应答。这在功能上能用,但很快你就会发现三个问题:
第一,handle_uart_byte和中断处理函数强耦合。以后要加一个“串口收到数据后同时驱动蜂鸣器”的功能,你得跑进这个中断函数里改代码,或者祈祷 Global callbacks 的设置别冲突。第二,中断处理函数里做耗时运算会拉高中断优先级占用时间。如果后面又接了 SPI Flash、CAN 总线、ADC,每个外设都往中断里塞逻辑,系统响应时间会变得越来越不可控。第三,如果两个模块都对同一份 UART 数据感兴趣,你只能在handle_uart_byte里挨个调用,调用顺序写死,想动态增删模块基本不可能。
你发现没有,中断回调本质上是观察者模式的雏形:外设是被观察的主题,中断函数是事件源,handle_uart_byte是订阅者。只不过它缺少了最关键的“注册”“注销”和“多对多分发”机制,导致每个回调都是一个孤岛,扩展全靠人肉维护。
1.3 一张表看清三种方式的差距
| 实现方式 | 阻塞情况 | 代码扩展性 | 中断占用时间 | 适合场景 |
|---|---|---|---|---|
| 阻塞轮询 | CPU 完全被锁 | 差,新增功能要改主流程 | 无中断,但CPU利用率低 | 极简单、无实时性要求的指令交互 |
| 普通中断回调 | 不阻塞,但回调内可能耗时 | 差,所有功能堆在一个函数里 | 高,取决于回调逻辑复杂度 | 功能单一的单外设处理 |
| 观察者模式 | 不阻塞,事件异步分发 | 好,新模块订阅即可,不动旧代码 | 低,中断里只做入队或快分派 | 多模块共享数据、协议栈扩展、多外设混合系统 |
所以观察者模式的真正价值不在于“不用 while 了”,而在于它让事件分发变成了一个可管理的订阅系统。中断里只负责标记“事件发生了”,到底谁来处理这个事件、处理到什么程度,交给各个订阅者自己决定。
2. 观察者模式到底在解决硬件通信里的哪几个问题
2.1 主题、观察者、事件:三个角色的分工
观察者模式在嵌入式通信上下文里,角色非常清晰。
主题(Subject)是数据产生者,比如 UART 外设、SPI 从设备、CAN 控制器的接收中断。它不关心数据最终被谁使用,只负责在事件发生时向订阅表广播。
观察者(Observer)是数据消费者,比如命令解析器、日志模块、状态指示模块、传感器融合算法。每个观察者告诉主题“我对哪类事件感兴趣”,然后等主题通知。
事件(Event)是通信过程中产生的信号,比如“收到一个字节”“收到一帧完整数据”“发送完成”“校验失败”。事件可以带负载,也就是我们常说的数据指针或数据拷贝。
一个经典的场景:一块主控板上的 UART1 连接着 GPS 模块,同一份数据需要被导航算法、数据记录器、LED 指示灯三个模块使用。如果不用观察者模式,你大概率会在串口中断里写三行调用:
navigation_update(gps_data); log_store(gps_data); led_indicate(gps_data);看起来问题不大,但哪天你要临时屏蔽日志功能,就要改中断函数;哪天想加一个远程遥测模块,又得改中断函数。用观察者模式,GPS 这个主题根本不关心谁在订阅,三个模块各自注册,哪天不想看日志了,注销一行代码就行,中断处理函数一行都不用动。
2.2 观察者模式与普通回调的边界在哪里
很多文章说观察者模式就是“函数指针 + 注册表”,我不太认同。函数指针没错,注册表也没错,但观察者模式还隐含了三个管理维度:
- 事件类型管理:不是所有事件都发给所有订阅者,观察者要能选择自己关心的事件类型;
- 生命周期管理:订阅者可以随时注册、注销,系统启动时静态注册也可以,运行时动态增减也可以;
- 分发策略管理:是同步分发还是异步分发,是在中断上下文分发还是投递到任务上下文分发,这决定了整个系统的实时性表现。
这三个维度才是观察者模式区别于“简单回调”的关键。简单回调是“谁写了就调谁”,观察者模式是“谁注册了才通知谁”。前者面向实现,后者面向接口,这也是它能降低模块耦合的根源。
2.3 一个比喻:食堂取餐和电话订餐
把观察者模式讲给硬件同事听,我常用食堂做比喻。轮询方式就像你站在食堂窗口前死等,菜没好就一直站着,后面人全被你堵住;普通中断回调就像一个窗口做好菜后,喊一声所有号码牌拿菜,但喊话的人得认识每一个来取餐的人,不然就把菜给错人;观察者模式则是食堂建立一个订餐档案,谁订了什么菜,菜好了就按档案通知谁。窗口这个“主题”只需要维护一份档案,不需要认识每个人。
硬件通信中,UART 中断频率往往很高,如果每个字节都触发一次完整分发流程,就要求分发代码足够轻量。所以观察者的落地实现,一定要围绕“轻量”和“可控速度”来设计,这也是下一章要写的核心。
3. 一个最小可用的观察者分发模块(C 语言实现)
3.1 数据结构设计:事件类型与订阅节点
在单片机上做观察者模式,我最常用的方式是基于静态数组的订阅表,不用动态内存分配。每个事件类型对应一个订阅链表,节点里保存回调函数指针和可选的上下文参数。
typedef enum { EVT_UART_RX_BYTE = 0, EVT_UART_RX_FRAME, EVT_UART_TX_DONE, EVT_CAN_RX_FRAME, EVT_TIMER_TICK, EVT_COUNT, } event_id_t; typedef struct observer_node { void (*callback)(void* ctx, const void* data, uint16_t len); void* ctx; struct observer_node* next; } observer_node_t; static observer_node_t* subscriber_head[EVT_COUNT];这里事件类型用 enum,好处是编译期检查类型,避免把事件 id 写成魔法数字。订阅表用数组加链表,EVT_COUNT 就是事件总数,每个事件维护一张链表。有人会问:为什么不用二维数组固定容量?因为不同的模块可以被同一个事件触发多次,比如两个显示器都想看 CAN 数据,链表的灵活度更高。
3.2 注册与注销接口:给观察者一个“身份”
注册接口不复杂,核心只是把节点挂到对应事件链表的尾部:
int observer_subscribe(event_id_t event, observer_node_t* node) { if (event >= EVT_COUNT || node == NULL || node->callback == NULL) { return -1; } node->next = NULL; if (subscriber_head[event] == NULL) { subscriber_head[event] = node; } else { observer_node_t* cur = subscriber_head[event]; while (cur->next != NULL) { cur = cur->next; } cur->next = node; } return 0; }注销接口稍微麻烦一点,需要遍历链表找到目标节点并摘除:
int observer_unsubscribe(event_id_t event, observer_node_t* node) { if (event >= EVT_COUNT || node == NULL) { return -1; } observer_node_t** pp = &subscriber_head[event]; while (*pp != NULL) { if (*pp == node) { *pp = node->next; node->next = NULL; return 0; } pp = &(*pp)->next; } return -1; }提示:静态数组 + 链表的实现里,节点内存是调用方自己的静态变量或结构体成员。不要在函数栈上定义节点然后注册,如果函数退出,这个订阅节点就悬空了,后面事件分发必然踩内存。
这个设计最实用的一点是同一个观察者节点可以同时订阅多个事件吗?可以的,只要这个节点结构体里的next字段只属于一条链表。如果你要让一个节点出现在多条链表里,需要给每个事件单独分配一个节点。实际项目中我一般这么用:一个模块结构体里内嵌多个 observer_node,分别对应不同的事件。
3.3 事件分发:中断上下文里的安全通告
事件分发是整个框架的心脏,也是最容易出事的地方。基本思路是遍历订阅链表,依次调用回调:
void observer_notify(event_id_t event, const void* data, uint16_t len) { if (event >= EVT_COUNT) { return; } observer_node_t* node = subscriber_head[event]; while (node != NULL) { observer_node_t* next = node->next; // 先保存next,防止回调中注销自己 if (node->callback) { node->callback(node->ctx, data, len); } node = next; } }那段“先保存 next”的代码是踩过坑才加上的。假设一个观察者收到“UART 收到帧”事件后,觉得不再需要订阅了,直接调用 observer_unsubscribe,把当前节点从链表中摘除并释放。如果分发函数还继续用 node->next 去遍历,轻则漏掉后面的观察者,重则访问悬空指针。先保存 next,再执行回调,就能保证即使回调里注销了自己,遍历指针仍然有效。
如果这个分发函数要从中断里调用,还有一个关键问题:注册表操作和遍历分发可能并发访问链表。裸机环境下,注册动作往往发生在 main 上下文,分发动作发生在中断上下文。这时候需要在操作链表时做临界区保护。
static uint32_t irq_lock_save(void) { uint32_t primask; __asm volatile("MRS %0, PRIMASK" : "=r"(primask)); __asm volatile("CPSID i"); return primask; } static void irq_lock_restore(uint32_t primask) { __asm volatile("MSR PRIMASK, %0" : : "r"(primask)); }在 Cortex-M 上,处理这种短暂链表操作最合适的做法就是关闭全局中断那么几个周期,然后把注册、注销操作包进去。注意,不要在临界区里执行观察者回调函数。回调可能跑到数毫秒甚至更久,关闭中断这么久会破坏整个系统的实时性。应该只保护链表操作的瞬间,遍历过程里如果需要动态修改链表,就采用先保存 next 的方法,同时约定“回调里允许注销自己但不允许向同一事件注册新节点”,这样并发风险完全可控。
3.4 内存和实时性的权衡
看到这里你可能会问:每个事件都要保存一份 head 指针,才几KB的 RAM 会不会不够?在主流 MCU 上,这个开销通常小到可以忽略:假如有 8 个事件,head 数组才 8 个指针,32 位平台上就是 32 字节,订阅节点一般也就几十个字节一个。真正要关心的是分发延迟。
分发延迟取决于两个因素:链表长度和回调耗时。链表越长,遍历越慢;回调越重,每个观察者的处理时间越长。硬件通信事件尤其是中断里触发的,我建议分发函数只负责“快通知”,把真正耗时的处理放到主循环或者 RTOS 队列里。这里给一个经验值:中断处理函数总耗时应该尽量控制在单次中断周期的一半以下。比如 UART 在 115200 波特率下,一个字节中断间隔约 86 微秒,那在你的分发流程里面最好不要超过 30 微秒。
3.5 扩展:异步分发和优先级机制
如果你的系统里观察者数量很多,或者存在某个观察者处理特别慢,还有一种改进是“延迟分发”。做法是在中断里不调用回调,只是把事件信息丢到一个无锁环形队列,主循环或专用处理任务再统一取出并分发。这样中断函数耗时极短,所有业务逻辑都在低优先级上下文执行。
typedef struct { event_id_t event; const void* data; uint16_t len; } pending_event_t;环形队列和消费者配合,本质上就是生产者-消费者模型,它能解耦“事件产生速度”和“事件处理速度”之间的不匹配。对高速 DMA 接收尤其有效,后面第五章我再展开。
4. 实战案例:用观察者模式重构 UART 命令解析模块
4.1 原始代码:嵌套 if-else 和 12 个模块的纠缠
之前接过某设备的一个固件,里面 UART 命令解析函数是我见过最典型的“意大利面条代码”。一个handle_frame()函数,里面大概有十几个 if-else 分支,每个分支都对应一种命令类型。看起来是不是还可以?但问题是,每个分支内部还直接调用了底层硬件操作、日志模块、状态修改、反馈发送,正反五行夹杂着读写寄存器,出问题时根本不知道是谁改了状态。
这类代码最让人绝望的地方不是能跑,而是“不能改”。你想加一个新命令,必须找到那个大函数,然后在最后的 else 后面继续堆 if-else;你想删掉一个旧命令,又必须在十几个分支里精确找到相关的隐式调用,稍不留神就会影响另一个分支的共享变量。
4.2 重构目标:命令注册、协议解析、业务处理三层分离
要把这块拆开,我第一件事是把命令处理函数抽成统一的回调签名:
typedef struct { uint8_t cmd_id; int (*handler)(void* ctx, const uint8_t* payload, uint16_t len); void* ctx; } cmd_handler_t;然后定义一组事件,每种命令 ID 就是一个事件类型。协议解析层只负责“从字节流里识别出一帧完整命令”,识别成功的帧被封装成事件,通过观察者框架分发出去。至于哪个命令由谁来处理,协议解析层完全不知道。
static observer_process_t uart_rx_byte_node; static const uint8_t* frame_ptr = NULL; static uint16_t frame_len = 0;实际项目中,我会把完整帧数据放到一个帧缓冲里,然后调用 observer_notify(EVT_UART_RX_FRAME, frame_ptr, frame_len)。每个命令处理器在系统启动时注册自己关心的事件。比如温控模块注册EVT_CMD_SET_TEMP,电机模块注册EVT_CMD_MOTOR_CTRL,版本查询模块注册EVT_CMD_GET_VERSION。
4.3 重构后的代码长什么样
系统初始化阶段是这样:
static observer_node_t temp_cmd_node = { .callback = temp_cmd_handler, .ctx = &temp_state, }; static observer_node_t motor_cmd_node = { .callback = motor_cmd_handler, .ctx = &motor_state, }; static observer_node_t version_cmd_node = { .callback = version_cmd_handler, .ctx = NULL, }; void app_init(void) { observer_subscribe(EVT_CMD_SET_TEMP, &temp_cmd_node); observer_subscribe(EVT_CMD_MOTOR_CTRL, &motor_cmd_node); observer_subscribe(EVT_CMD_GET_VERSION, &version_cmd_node); }协议解析部分变成简洁的框架代码:
void uart_frame_dispatch(const uint8_t* frame, uint16_t len) { if (len < 2) { return; } uint8_t cmd_id = frame[0]; uint16_t payload_len = (frame[1] << 8) | frame[2]; const uint8_t* payload = &frame[3]; if (cmd_id == CMD_SET_TEMP) { observer_notify(EVT_CMD_SET_TEMP, payload, payload_len); } else if (cmd_id == CMD_MOTOR_CTRL) { observer_notify(EVT_CMD_MOTOR_CTRL, payload, payload_len); } else if (cmd_id == CMD_GET_VERSION) { observer_notify(EVT_CMD_GET_VERSION, payload, payload_len); } }可能有较真的读者说:这不是还有 if-else 吗?没错,解析层仍然需要做“命令 ID 到事件 ID”的映射,这是必要的协议分派,但注意,它和业务逻辑已经彻底分开了。以后再新增命令,协议解析层的 if-else 里加一行映射,同时新增一个模块注册自己的观察者即可。相比原来在同一个函数里既做映射又做处理,改动面小了一个量级。
4.4 实测效果:代码量和响应时间的变化
重构完我做了个小统计:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| 处理函数代码总行数 | 约 1200 行 | 约 450 行(含框架和所有命令回调) |
| 新增命令耗时 | 约 1 小时改大函数,易出错 | 约 20 分钟,新增一个模块即可 |
| 单帧平均处理耗时 | 3.2ms | 1.1ms |
| 中断中耗时占比 | 40%(因为解析逻辑太多) | 8%(中断里只做字节入队分发) |
响应时间提升很明显,但更值钱的其实是维护性的改善。之后我加了一个远程升级命令,没去碰任何既有模块的回调,就实现了版本确认、固件写入、重启校验三个过程的解耦。
5. 中断、RTOS 和 DMA 混用时的三个大坑
5.1 坑一:中断上下文直接执行慢回调,毁掉整个实时性
观察者模式最容易让人产生误解的地方,是以为“把逻辑都放回调里就完事”。如果分发函数在 UART 中断里被调用,所有订阅者回调也会在中断上下文中执行。一个日志模块的订阅者把 32 字节写到 SPI Flash,写 Flash 可能要 10ms 到几十毫秒,这段时间里其他中断优先级低于 UART 的外设全部被饿死,高优先级的中断如果嵌套又会带来新的重入问题。
解决思路是分层:中断里只通知“有数据到达”这个轻量事件,真正的协议解析和业务处理放到主循环或者专用任务里。我常用的一种做法是给观察者框架配置“上下文标志”:
typedef enum { OBSERVER_CTX_ISR = 0, OBSERVER_CTX_TASK = 1, } observer_ctx_t;分发函数带一个 ctx 参数,观察者如果发现自己是在 ISR 上下文被调用,就不做耗时操作,只是把数据放入队列然后返回。主循环再调用一次专门的observer_dispatch_pending()来处理队列。
5.2 坑二:RTOS 下直接操作共享链表,不加锁就是找死
跑 RTOS 后,除了中断,另一个并发源是多个任务同时访问同一个订阅表。比如任务 A 注册一个观察者,任务 B 同时触发同一个事件,链表操作就可能出现交叉写,轻则丢事件,重则链表循环,系统直接跑飞。
我的做法是给订阅库加上一层互斥锁。如果是 FreeRTOS,中断里用taskENTER_CRITICAL_FROM_ISR,任务里用taskENTER_CRITICAL,但注意它们的实现不同。更通用一点的做法,是在发布接口里区分调用环境:
void observer_notify_from_isr(event_id_t event, const void* data, uint16_t len) { // 遍历时只处理注册表,不做锁 // 所有需要修改注册表的操作都必须在任务上下文做 }实际工作中,最稳的策略其实是:注册表只能被一个固定上下文修改,通常是初始化阶段一次性注册完,运行期间不做动态注册。如果确实需要动态订阅,就在主循环里做,不要放在中断里做。把集权机制设计在架构层,远比靠锁来救场更可靠。
5.3 坑三:用了 DMA 之后,“收到数据”事件不再是逐字节的
很多工程师第一次上手 DMA 接收,容易把观察者模式照搬过去,结果发现中断频率从每字节一次变成了一帧一次,回调触发频率一下子低了,但每次数据量大了。这种情况下,观察者事件需要改变语义:原来EVT_UART_RX_BYTE是逐字节的细粒度事件,现在应该改成EVT_UART_RX_DMA_HALF、EVT_UART_RX_DMA_FULL这类粗粒度事件。
为什么?因为 DMA 中断只会在半满、全满或者传输完成时触发,如果想把“新的字节到了”这种事件精确地通知出去,就必须自己维护一个环形缓冲区,并且每次都要处理“上次读到哪里”的游标。这个条件下,线程安全的环形缓冲区比观察者事件本身更重要,观察者只是消费这个缓冲区的通知口而已。
一个可行组合是:DMA 接收 + 无锁环形队列 + 观察者事件。DMA 中断负责把数据搬运到环形队列,中断安全地更新写指针;主循环读取观察者事件,收到EVT_UART_RX_BLOCK_READY后把环形队列数据取出来做协议解析。注意,环形队列的读写指针如果是各自上下文独立更新的,可以做到无锁。比如写指针在中断里更新,读指针在主循环里更新,不交叉操作同一个变量,数据同步靠 volatile 和内存屏障保证,这在 Cortex-M 上通常问题不大。
6. 观察者模式并不是银弹:适用边界与我踩过的教训
6.1 不适合的场景:高频控制回路和极端资源受限
观察者模式能解决解耦问题,但引入的是间接调用。对于 CPU 万分紧张的控制内核,比如电机 FOC 电流环跑在 20kHz,你要用观察者模式去分发每一个电流采样点事件,一次函数指针调用多出的开销可能就几个周期,看似不多,但如果订阅链表里有 5 个观察者,一次采样就多了 5 次间接跳转、参数传递、返回恢复,累积起来对硬实时的杀伤力不可忽视。这种场景我建议用回调数组,或者干脆直接展开写,不要为了设计模式牺牲确定性时序。
你要是跑在只有 2KB RAM 的 8 位单片机上,且通信频率也不高,上个简单的中断回调就够了,强行引一套观察者框架只会让代码比业务逻辑还复杂。
6.2 调试技巧:给订阅表加一套“可视化”
观察者模式最难调试的情况是“明明订阅了,但事件没触发”。常见原因有几个:注册的节点内存被覆盖、节点在错误上下文被注册、事件 ID 映射写错。我后来在框架里加了两样东西,大大缓解了这些问题。
第一是订阅计数。每次 register / unregister 都更新一个全局计数,调试时读一下,就知道系统启动后哪些事件被哪些模块订阅了。第二是事件触发次数统计。分发函数的每次调用都累加对应事件的 trigger count,如果协议层明明发了事件,trigger count 却没变,问题一定出在之前的上层调用;如果 trigger count 在涨但某个回调没执行,那大概率是订阅链表断裂或者节点悬挂。
我还会在注册和注销接口里放断言,检查 node 是否在合理的 RAM 范围内,避免传入野指针。嵌入式系统里内存越界是家常便饭,有断言和没有断言排查时间差好几倍。
6.3 我个人总结出的三个选型判断标准
经历多了之后,我每次设计通信模块都会先问自己三个问题。
一是“这个事件有几个消费者?”只有一个消费者,用普通中断回调就够;两个以上,考虑观察者模式;未来可能增加到三个以上,直接上观察者模式。
二是“这些消费者是否会动态增删?”运行期间要开关日志、插拔外设模块,那就是必须用观察者模式;如果整个生命周期里消费者列表固定不变,静态注册甚至直接函数表都够用。
三是“事件处理耗时是否差异巨大?”如果有一个观察者极慢,其他观察者都极快,就需要设计异步分发或者给慢观察者单独起任务;如果所有观察者都很轻量,同步分发是好选择,因为代码更简单。
有一次我把一个温度传感器的事件分发设计成 ISR 同步执行,三个观察者分别做显示刷新、数据记录、状态检测。后来记录模块升级成写 SD 卡,一次写卡时间远超显示刷新的允许时长,整个显示都跟着卡顿。后来我把记录模块改成内部队列,SD 卡写操作放后台执行,显示模块的响应立即恢复。这个案例让我理解了,观察者模式本身只解决“通知谁”的问题,解决不了“通知之后回调里花多少时间”的问题。时间预算仍然需要在系统设计时单独规划。
6.4 最后一个建议:从最痛苦的那段代码开始尝试
如果你现在还不太确定观察者模式到底能在自己项目里带来多少收益,我的建议是从最让你痛苦的那个通信模块开始试水:也许是那个每次新增协议命令都要改半天的串口解析器,也许是那个被多个模块共用的外设数据分发器。用两三天时间把那段代码按观察者模式重写一版,先不追求完美,只追求“新增功能时不需要改动已有代码”这一条原则。跑通后你自然会体会到,硬件通信丝滑感的背后不是某个神奇的循环或中断参数,而是事件流动的方向变得清晰了、模块之间的牵绊变少了。这份感觉,才是观察者模式真正让人上瘾的地方。