事件驱动编程基础:事件循环、队列、分发器与回调
各位好,欢迎来到第二章。上一章我们聊了嵌入式GUI的基本概念,今天要啃的这块骨头,是整个事件采集系统的灵魂——事件驱动编程。
说白了,事件驱动就是“有事才干活,没事就歇着”。你想想看,嵌入式设备大部分时间都在等用户操作——等触摸、等按键、等定时器。如果CPU一直轮询,那功耗和效率都扛不住。事件驱动就是解决这个问题的。
核心思想:系统不主动询问“你有没有事?”,而是等外部或内部事件来了,再响应处理。这就像你坐在工位上,不是每分钟问一遍老板有没有任务,而是等老板喊你才动。
事件循环机制
事件循环,英文叫 Event Loop。它是整个GUI系统的心脏,一个永不退出的 while 循环。
我刚开始做嵌入式GUI时,犯过一个低级错误——在事件循环里加了延时函数,结果整个界面卡死了。嗯,这里要注意:事件循环绝对不能阻塞。
一个典型的事件循环长这样:
while (1) {
Event evt;
// 1. 从队列取事件
if (event_queue_get(&evt) == 0) {
// 2. 分发事件
event_dispatcher(&evt);
}
// 3. 处理空闲任务(可选)
idle_task();
// 4. 进入低功耗模式(可选)
// WFI(); // 等待中断
}
看到没?循环里就干四件事:取事件、分发事件、干点杂活、睡觉。其中“睡觉”这一步很关键——没有事件时让CPU进入低功耗模式,靠中断唤醒。我在一个电池供电的智能家居面板项目里,靠这个技巧把待机功耗从50mA降到了2mA。
我的经验:事件循环的周期决定了GUI的响应速度。一般要求触摸到反馈不超过50ms,所以循环里不能有超过10ms的阻塞操作。如果遇到耗时任务,拆成多步或者扔到后台线程去。
事件队列
事件队列,说白了就是一个先进先出(FIFO)的缓冲区。所有产生的事件都先排队,事件循环再一个个取出来处理。
为什么要用队列?直接处理不行吗?
你想想看,如果用户在快速滑动屏幕,触摸控制器可能1ms就产生一个事件。如果事件循环正在处理一个耗时任务,新事件就丢了。队列就是用来解耦事件产生和事件处理的。
我常用的队列实现有两种:
| 类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 环形缓冲区 | 无动态内存分配,速度快 | 大小固定,可能溢出 | 资源紧张的MCU |
| 链表队列 | 可动态扩展 | 需要内存管理,速度稍慢 | 内存充足的系统 |
我个人习惯在资源受限的MCU上用环形缓冲区。下面是一个简化版实现:
#define QUEUE_SIZE 16
typedef struct {
Event buffer[QUEUE_SIZE];
uint8_t head; // 写位置
uint8_t tail; // 读位置
uint8_t count; // 当前数量
} EventQueue;
int event_queue_put(EventQueue *q, const Event *evt) {
if (q->count >= QUEUE_SIZE) {
return -1; // 队列满了
}
q->buffer[q->head] = *evt;
q->head = (q->head + 1) % QUEUE_SIZE;
q->count++;
return 0;
}
int event_queue_get(EventQueue *q, Event *evt) {
if (q->count == 0) {
return -1; // 队列空了
}
*evt = q->buffer[q->tail];
q->tail = (q->tail + 1) % QUEUE_SIZE;
q->count--;
return 0;
}
避坑指南:我曾经在一个项目中把队列大小设成8,结果快速滑动时事件溢出,导致触摸轨迹断断续续。后来改成32才解决。建议队列大小至少是最大预期事件率的3倍以上。
事件分发器
事件分发器,就是那个根据事件类型,找到对应处理函数的中间人。它不干活,只负责“派活”。
最简单的分发器就是一个 switch-case:
void event_dispatcher(const Event *evt) {
switch (evt->type) {
case EVENT_TOUCH_DOWN:
touch_down_handler(evt->x, evt->y);
break;
case EVENT_TOUCH_MOVE:
touch_move_handler(evt->x, evt->y);
break;
case EVENT_TOUCH_UP:
touch_up_handler(evt->x, evt->y);
break;
case EVENT_KEY_PRESS:
key_press_handler(evt->key_code);
break;
case EVENT_TIMER:
timer_handler(evt->timer_id);
break;
default:
// 未处理的事件
break;
}
}
这种方式简单直观,但事件类型多了以后,switch-case会变得又臭又长。我见过一个项目,事件类型超过50种,那个switch-case写了800多行……
更优雅的方式是用函数指针表:
typedef void (*EventHandler)(const Event *evt);
EventHandler handlers[EVENT_MAX] = {
[EVENT_TOUCH_DOWN] = touch_down_handler,
[EVENT_TOUCH_MOVE] = touch_move_handler,
[EVENT_TOUCH_UP] = touch_up_handler,
[EVENT_KEY_PRESS] = key_press_handler,
[EVENT_TIMER] = timer_handler,
};
void event_dispatcher(const Event *evt) {
if (evt->type < EVENT_MAX && handlers[evt->type] != NULL) {
handlers[evt->type](evt);
}
}
这样扩展起来就方便多了——加一个新事件类型,只需要在数组里注册一个函数指针。
回调函数
回调函数,是事件驱动里最灵活的机制。说白了就是“你告诉我怎么做,到时候我喊你”。
在嵌入式GUI里,回调函数最常见的用法是控件事件通知。比如你按下一个按钮,按钮库不知道你要干啥,它通过回调函数通知你:
// 按钮控件结构体
typedef struct {
int x, y, w, h;
const char *label;
// 回调函数指针
void (*on_click)(void *user_data);
void *user_data;
} Button;
// 按钮按下时的处理
void button_press(Button *btn) {
// 绘制按下状态...
// 触发回调
if (btn->on_click != NULL) {
btn->on_click(btn->user_data);
}
}
// 用户代码:创建一个按钮并注册回调
void my_button_click_handler(void *data) {
// 用户自己的处理逻辑
printf("按钮被按下了!\n");
}
Button btn = {
.x = 10, .y = 10, .w = 100, .h = 40,
.label = "确定",
.on_click = my_button_click_handler,
.user_data = NULL,
};
你看,按钮库不需要知道用户要干什么,它只负责在事件发生时调用回调函数。这就是解耦的魅力。
我的习惯:回调函数里不要做耗时操作。如果必须做,用定时器或者工作队列延迟处理。否则整个事件循环会被卡住,界面就“冻”住了。
整体架构图
下面这张图把今天讲的四个概念串起来了。我建议你仔细看看,理解数据是怎么流动的:
总结一下
今天这四个概念,我建议你记住一个比喻:
- 事件循环 = 餐厅的服务员,一直在转悠
- 事件队列 = 服务员手里的点单本,先来后到
- 事件分发器 = 服务员把单子递给不同的厨师
- 回调函数 = 厨师按菜单做菜,做完喊“好了”