把"轮询"换成"回调":每个动作背后,都藏着一个真相
核心方法论:真相只有一个——先问清楚 code、target、user_data
「事件不会凭空发生,也不会无中生有。每一个回调背后都藏着'真相':是谁发出的、作用在哪个对象上、附带什么数据。别急着写代码,先像侦探一样把这三件套问清楚。记住——按一下按钮不是一件事,而是一连串事件;真相只有一个,但它藏在链条里。」
前几章里,控件被创建、被排布、被赋予样式,但一个 UI 想要真正"活"起来,必须回应输入——点按钮、拖滑块、敲键盘。LVGL 的做法不是让你在主循环里逐个轮询"这个控件有没有被按?",而是事件驱动:控件在状态变化时发出事件,你提前注册一个回调函数,库在正确的时刻替你调用它。这就是 lv_obj_add_event_cb() 的来历。
回调的签名是类型 lv_event_cb_t,即 void (*)(lv_event_t * e)——参数只有一个 lv_event_t *,里面装着"发生了什么、发生在谁身上、附带什么数据"。事件相关的类型与枚举定义在 include/lvgl/core/lv_event.h,而对象级事件的分发入口 lv_obj_send_event() 及其回调管理实现在 src/core/lv_obj_event.c。注册函数的完整原型是 lv_event_dsc_t * lv_obj_add_event_cb(lv_obj_t * obj, lv_event_cb_t event_cb, lv_event_code_t filter, void * user_data)——第三个参数 filter 用来声明"我只关心哪类事件"。
/* 事件回调的签名:lv_event_cb_t = void (*)(lv_event_t *) */
static void btn_event_cb(lv_event_t * e)
{
LV_LOG_USER("button event!"); /* 在日志里打一个点 */
}
/* 注册:对象 + 回调 + 过滤的事件码 + 用户数据 */
lv_obj_t * btn = lv_button_create(lv_screen_active());
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);
/* 参考 examples/event/lv_example_event_click.c:点击一次,计数 +1 */
static void event_cb(lv_event_t * e)
{
static uint32_t cnt = 1;
lv_obj_t * btn = lv_event_get_target_obj(e); /* 事件最初作用的对象 */
lv_obj_t * label = lv_obj_get_child(btn, 0); /* 按钮上的第 0 个子对象 */
lv_label_set_text_fmt(label, "%" LV_PRIu32, cnt); /* 把点击数写进标签 */
cnt++;
}
| 事件核心 API | 作用 |
|---|---|
lv_obj_add_event_cb() | 注册事件回调,返回描述符 lv_event_dsc_t * |
lv_event_get_code() | 取事件码(lv_event_code_t) |
lv_event_get_target() | 取事件最初作用的对象(void *) |
lv_event_get_current_target() | 取当前正在回调的对象(冒泡时二者不同) |
lv_event_get_param() | 取事件发送时附带的参数 |
lv_event_get_user_data() | 取注册时传入的用户数据 |
事件回调运行在 LVGL 的主循环与刷新上下文里。回调里一旦做长耗时或阻塞操作(延时、大块内存操作),整个界面的刷新就会被卡住。回调应该是"快速反应",而不是"大动干戈"。
"点一下按钮"在 LVGL 里不是一件事,而是一连串事件。按下瞬间发 LV_EVENT_PRESSED;按住不松会持续发 LV_EVENT_PRESSING;按满一定时长(事件注释里的 long_press_time)发 LV_EVENT_LONG_PRESSED,之后每隔 long_press_repeat_time 再发一次 LV_EVENT_LONG_PRESSED_REPEAT;最后松手时,LV_EVENT_RELEASED 在每种情况下都会发,而 LV_EVENT_CLICKED 只在"整个过程没有滚动"时发——无论之前是否长按。至于 LV_EVENT_VALUE_CHANGED,是滑块、开关、下拉框这类"值型控件"专用的:值一变就触发。
examples/event/lv_example_event_button.c 是"一个回调区分多种事件"的最佳样本:注册时 filter 传 LV_EVENT_ALL(值为 0,含义是"匹配所有事件",而不是"什么都不匹配"),回调里用 lv_event_get_code(e) 拿到的枚举值做 switch,把"最近一次按钮事件"写进旁边的信息标签。这套"注册 ALL + switch code"的模式,是新手处理复杂交互的第一选择。
/* 参考 examples/event/lv_example_event_button.c:一个回调区分多种事件 */
static void event_cb(lv_event_t * e)
{
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t * label = (lv_obj_t *) lv_event_get_user_data(e);
switch(code) {
case LV_EVENT_PRESSED:
lv_label_set_text(label, "最后一次事件:LV_EVENT_PRESSED");
break;
case LV_EVENT_CLICKED:
lv_label_set_text(label, "最后一次事件:LV_EVENT_CLICKED");
break;
case LV_EVENT_LONG_PRESSED:
lv_label_set_text(label, "最后一次事件:LV_EVENT_LONG_PRESSED");
break;
default:
break;
}
}
lv_obj_add_event_cb(btn, event_cb, LV_EVENT_ALL, info_label); /* ALL 匹配一切 */
/* 值型控件:滑块一变就刷新标签(LV_EVENT_VALUE_CHANGED) */
static void slider_event_cb(lv_event_t * e)
{
lv_obj_t * slider = lv_event_get_target_obj(e);
lv_obj_t * label = (lv_obj_t *) lv_event_get_user_data(e);
lv_label_set_text_fmt(label, "%d", lv_slider_get_value(slider));
}
lv_obj_t * slider = lv_slider_create(lv_screen_active());
lv_obj_t * label = lv_label_create(lv_screen_active());
lv_obj_add_event_cb(slider, slider_event_cb, LV_EVENT_VALUE_CHANGED, label);
| 事件码 | 触发时机 |
|---|---|
LV_EVENT_PRESSED | 首次按下 |
LV_EVENT_PRESSING | 按住期间持续触发 |
LV_EVENT_RELEASED | 每次松手都触发 |
LV_EVENT_SHORT_CLICKED | 短按后松开(未滚动) |
LV_EVENT_CLICKED | 松开且未滚动(无论是否长按) |
LV_EVENT_LONG_PRESSED | 按住超过长按时长 |
LV_EVENT_LONG_PRESSED_REPEAT | 长按后按周期重复 |
LV_EVENT_VALUE_CHANGED | 控件值变化(滑块 / 开关 / 下拉框) |
要区分"点击"与"长按",记住一句话:CLICKED 不看时长,只看"松手时是否滚过";LONG_PRESSED 不看滚没滚,只看"是否按够时长"。二者都可能紧接着 RELEASED——所以判断动作,永远以 code 为准,不要靠事件顺序去猜。
回调拿到了 lv_event_t * e,里面到底能掏出什么?一共四样:lv_event_get_code(e) 拿事件码;lv_event_get_target(e) 拿事件最初作用的对象(返回 void *,实际开发更常用 lv_event_get_target_obj(e) 直接得 lv_obj_t *);lv_event_get_param(e) 拿事件发送时附带的参数(例如 LV_EVENT_KEY 时的按键值、绘制事件的绘制区域);lv_event_get_user_data(e) 拿注册回调时的第四参 user_data。
user_data 是让"同一个回调服务多个控件"的关键:第四参可以是任何 void *——最常见的做法是传一个 lv_obj_t *(比如回调要改写的那个标签),更复杂的场景传一个自定义结构体指针。这正是"真相只有一个"的落点:回调不再需要猜测自己在服务谁,user_data 把上下文直接递到你手里。
/* 把 info_label 作为 user_data 传给回调:同一个回调可被多个控件复用 */
static void event_cb(lv_event_t * e)
{
if(lv_event_get_code(e) == LV_EVENT_CLICKED) {
lv_obj_t * label = (lv_obj_t *) lv_event_get_user_data(e);
lv_label_set_text(label, "按钮被点击了!");
}
}
lv_obj_t * info_label = lv_label_create(lv_screen_active());
lv_obj_add_event_cb(btn, event_cb, LV_EVENT_ALL, info_label);
/* user_data 也可以是一个结构体指针:先取 target,再读写数据 */
typedef struct {
int32_t click_count;
lv_obj_t * label;
} my_data_t;
static void event_cb(lv_event_t * e)
{
my_data_t * data = (my_data_t *) lv_event_get_user_data(e);
lv_obj_t * target = lv_event_get_target_obj(e); /* 谁发的这个事件 */
data->click_count++;
lv_label_set_text_fmt(data->label, "count=%d", data->click_count);
LV_UNUSED(target);
}
| Getter | 返回 | 含义 |
|---|---|---|
lv_event_get_code(e) | lv_event_code_t | 事件码 |
lv_event_get_target(e) | void * | 事件最初作用的对象 |
lv_event_get_current_target(e) | void * | 当前正在处理回调的对象 |
lv_event_get_param(e) | void * | 事件附带参数(如按键值) |
lv_event_get_user_data(e) | void * | 注册时传入的用户数据 |
lv_event_get_target_obj(e) | lv_obj_t * | target 的 lv_obj_t 版本 |
回调可能在任意时刻触发,你传入 user_data 的指针必须在这段生命周期内保持有效。最常见的翻车现场是:传了某个栈上局部变量的地址,函数一返回指针就失效了。结构体用 static 或 malloc 持有,并在不需要时妥善释放。
默认情况下,事件只发给目标对象自己。但对象有父子关系,很多场景希望父容器"统一处理"子控件的事件——比如一个网格里摆了 30 个按钮,难道每个都注册一个回调吗?不必。给子对象打上 LV_OBJ_FLAG_EVENT_BUBBLE(或直接调用 lv_obj_set_event_bubble(obj, true)),事件就会在目标对象处理完后向上冒泡,逐级传给父对象。examples/event/lv_example_event_bubble.c 正是这个模式:一个 290×200 的 flex 容器里塞了 30 个按钮,只在容器上注册一个 LV_EVENT_CLICKED 回调,点击任意按钮都会被它捕获。
冒泡时同一个回调会在多个对象上触发,于是"我正在处理谁"成了关键问题。分辨法就在两个 getter 的名字里:lv_event_get_target_obj(e) 永远是事件最初的对象(被点的那个按钮);lv_event_get_current_target_obj(e) 是当前正在调用这个回调的对象(冒泡进行到哪一层就是哪一层)。若想在某一层叫停,调用 lv_event_stop_bubbling(e)。与冒泡方向相反的机制叫 trickle(下渗):给父对象加 LV_OBJ_FLAG_EVENT_TRICKLE(lv_obj_set_event_trickle(obj, true)),输入事件会从父穿透到子对象——lv_example_event_trickle.c 就靠它让"按下整个容器"时整组子控件一起高亮。
/* 参考 examples/event/lv_example_event_bubble.c:容器统一处理 30 个按钮 */
static void event_cb(lv_event_t * e)
{
lv_obj_t * target = lv_event_get_target_obj(e); /* 最初被点的按钮 */
lv_obj_t * cont = lv_event_get_current_target_obj(e); /* 挂回调的容器 */
if(target == cont) return; /* 点在容器上就忽略 */
lv_obj_set_style_bg_color(target, lv_palette_main(LV_PALETTE_RED), 0);
}
lv_obj_t * cont = lv_obj_create(lv_screen_active());
lv_obj_set_size(cont, 290, 200);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW_WRAP);
for(int i = 0; i < 30; i++) {
lv_obj_t * btn = lv_button_create(cont);
lv_obj_set_size(btn, 70, 50);
lv_obj_set_event_bubble(btn, true); /* 关键:让点击冒泡给容器 */
}
lv_obj_add_event_cb(cont, event_cb, LV_EVENT_CLICKED, NULL);
/* 在某一层叫停冒泡 */
static void event_cb(lv_event_t * e)
{
if(lv_event_get_code(e) == LV_EVENT_CLICKED) {
lv_event_stop_bubbling(e); /* 到此为止,不再上传给父对象 */
}
}
/* Trickle:父对象的输入事件"渗"给所有子对象 */
lv_obj_set_event_trickle(cont, true); /* 等价于 LV_OBJ_FLAG_EVENT_TRICKLE */
冒泡(BUBBLE)和 trickle(TRICKLE)是方向相反的两条路:BUBBLE 让子事件上传给父,适合"父容器统一处理一群子控件";TRICKLE 让父的输入穿透到子,适合"按整组、整组联动"。判断当前回调执行到哪一层,永远看 current_target,别只看 target。
回调不是一锤子买卖。lv_obj_remove_event_cb(obj, event_cb) 按函数指针移除回调;lv_obj_remove_event_cb_with_user_data(obj, event_cb, user_data) 更进一步——同一个函数以不同 user_data 注册了多份时,用它精确删除某一份;lv_obj_get_event_count(obj) 则查询对象当前注册了多少回调。值得注意的是,对象被删除时其上所有回调会自动随之释放,你不需要手动逐条清理。
除了内置事件,你还能自造事件。lv_event_register_id() 返回一个新的、唯一的自定义事件码;之后用 lv_obj_send_event(obj, my_code, param) 主动向对象发送事件,对象上所有 filter 匹配的回调都会被调用。这一招常被用来做"业务层 → UI 层"的解耦通知:业务逻辑只发事件、界面只管订阅,二者通过事件码对话、互不 include。调试时若想看清某个事件码是什么,用 lv_event_code_get_name(code) 把它变成可读字符串。
/* 注册一个自定义事件码,并主动发送 */
static uint32_t LV_EVENT_UPDATE = 0;
LV_EVENT_UPDATE = lv_event_register_id(); /* 返回唯一事件码 */
static void update_cb(lv_event_t * e)
{
/* LV_EVENT_UPDATE 时被调用,param 可携带数据 */
lv_obj_t * label = (lv_obj_t *) lv_event_get_user_data(e);
lv_label_set_text(label, "已更新");
}
lv_obj_add_event_cb(panel, update_cb, LV_EVENT_UPDATE, label);
lv_obj_send_event(panel, LV_EVENT_UPDATE, NULL); /* 业务侧主动触发 */
/* 回调的移除与查询 */
lv_obj_remove_event_cb(btn, event_cb); /* 按函数指针移除 */
lv_obj_remove_event_cb_with_user_data(btn, event_cb, data); /* 连同 user_data 一起匹配 */
uint32_t n = lv_obj_get_event_count(btn); /* 已注册回调数量 */
/* 调试:把事件码转成可读字符串 */
const char * name = lv_event_code_get_name(LV_EVENT_CLICKED);
LV_LOG_USER("event: %s", name);
| 生命周期 API | 作用 |
|---|---|
lv_obj_remove_event_cb() | 按回调函数指针移除 |
lv_obj_remove_event_cb_with_user_data() | 按回调 + user_data 精确移除 |
lv_obj_get_event_count() | 查询已注册回调数量 |
lv_obj_send_event() | 主动向对象发送事件 |
lv_event_register_id() | 注册自定义事件码 |
lv_event_code_get_name() | 事件码转字符串 |
自定义事件码 + user_data 是解耦的黄金组合:界面层只关心"收到了什么事件、带什么数据",业务层只关心"发什么事件、传什么数据"。两边都不需要知道对方的实现细节——真相通过事件码传递,而非通过互相 include。
创建 3 个按钮和一个标签。让同一个回调统计"总点击次数"并实时写入标签。要求:3 个按钮共用同一个回调函数,user_data 统一传入这个标签。写出完整代码。
用 static uint32_t cnt 计数;user_data 传标签,回调里用 lv_event_get_user_data(e) 取出并 lv_label_set_text_fmt 更新;3 个按钮都 lv_obj_add_event_cb(btn, event_cb, LV_EVENT_CLICKED, label)。
回调:static void event_cb(lv_event_t * e) { static uint32_t cnt = 0; if(lv_event_get_code(e) == LV_EVENT_CLICKED) { cnt++; lv_obj_t * label = (lv_obj_t *)lv_event_get_user_data(e); lv_label_set_text_fmt(label, "clicked: %u", cnt); } };3 个按钮分别 lv_obj_add_event_cb(btn1/btn2/btn3, event_cb, LV_EVENT_CLICKED, label)。所有按钮的点击都汇聚到一个计数、一个标签——回调、事件码、user_data 三者协同的典型用例。
仿照 lv_example_event_button.c:一个按钮 + 一个信息标签。用单个回调分别显示 LV_EVENT_PRESSED、LV_EVENT_RELEASED、LV_EVENT_CLICKED、LV_EVENT_LONG_PRESSED 四种事件名。注册时的 filter 应该传什么?为什么?
要收到多种事件,filter 传 LV_EVENT_ALL(值为 0,表示匹配所有);回调里用 lv_event_get_code(e) 返回的 lv_event_code_t 做 switch,分别 break。
lv_obj_add_event_cb(btn, event_cb, LV_EVENT_ALL, info_label)。传 LV_EVENT_ALL 是因为你需要同时收到按下、松开、点击、长按四种事件,而单个 filter 一次只能声明一类;LV_EVENT_ALL 的值是 0,语义是"匹配所有事件"。回调内 switch(lv_event_get_code(e)) 分四路更新标签文本。注意按住不动时会先 PRESSED 再 LONG_PRESSED,松开时依次是 RELEASED 与 CLICKED(未滚动时)。
仿照 lv_example_event_bubble.c:一个 flex 容器里放 6 个按钮,只在容器上注册一个 LV_EVENT_CLICKED 回调。要求:(1) 点击任意按钮,该按钮被标记为红色;(2) 点击容器本身不改变任何东西;(3) 在回调里说明 lv_event_get_target_obj(e) 与 lv_event_get_current_target_obj(e) 各返回什么。
每个按钮都要 lv_obj_set_event_bubble(btn, true)(或加 LV_OBJ_FLAG_EVENT_BUBBLE);回调里先 if(target == cont) return; 忽略容器自身;把 target 染红。冒泡到容器这一层时 target 是按钮、current_target 是容器。
代码要点:lv_obj_t * cont = lv_obj_create(lv_screen_active()); 设置 LV_FLEX_FLOW_ROW_WRAP;for(...) 创建 6 个按钮并 lv_obj_set_event_bubble(btn, true);lv_obj_add_event_cb(cont, event_cb, LV_EVENT_CLICKED, NULL)。回调内:lv_obj_t * target = lv_event_get_target_obj(e); lv_obj_t * cont = lv_event_get_current_target_obj(e); if(target == cont) return; lv_obj_set_style_bg_color(target, lv_palette_main(LV_PALETTE_RED), 0);。当容器回调被触发时,target = 最初被点的按钮,current_target = 容器(因为事件冒泡到了容器这一层)。
做一个"状态面板":一个按钮触发一次"更新",一个滑块调整"数值"。要求:用 lv_event_register_id() 注册一个自定义事件 LV_EVENT_UPDATE,数值变化与按钮点击都通过 lv_obj_send_event() 发这个事件;面板上唯一一个回调收到事件后,把 user_data 结构体里的计数与数值一起显示出来。
定义 static 数据 my_data_t { int32_t count; int32_t value; lv_obj_t * label; };两个"生产者"(按钮的 LV_EVENT_CLICKED、滑块的 LV_EVENT_VALUE_CHANGED)里更新字段后 lv_obj_send_event(panel, LV_EVENT_UPDATE, NULL);面板上挂 update_cb 消费并刷新标签。
核心结构:LV_EVENT_UPDATE = lv_event_register_id(); 取得自定义事件码;按钮回调里 data->count++; lv_obj_send_event(panel, LV_EVENT_UPDATE, NULL);;滑块回调里 data->value = lv_slider_get_value(slider); lv_obj_send_event(panel, LV_EVENT_UPDATE, NULL);;update_cb 收到事件后 lv_label_set_text_fmt(data->label, "count=%d value=%d", data->count, data->value)。要点:多个生产者汇聚到同一个事件,消费方只有面板一个——这正是"事件解耦"优于"直接调用"的地方。