☰
LVGL lv_menu实战:嵌入式多级动态菜单与事件处理
2026/9/28 2:01:26 网站建设 项目流程

最近在做一个基于STM32的嵌入式设备,界面这块需要在几层菜单之间反复跳转,还得支持动态增删菜单项和参数实时刷新。一开始我图省事直接用 lv_list + lv_tabview 硬拼,结果菜单一深,返回逻辑和焦点管理写得想砸键盘。后来老老实实把 lv_menu 这套组件吃透,才发现官方其实已经把菜单的树形结构、返回栈、焦点控制都封装好了,之前那些手搓的活全是重复造轮子。

这篇就专门聊聊 lv_menu 的实战用法。我会先拆解 lv_menu 的核心对象和设计逻辑,再给一套可以直接抄的初始化代码,接着重点讲动态交互(增删菜单、刷新数据、返回键处理)怎么实现,最后把我在实际调试中踩过的坑和排查思路整理成速查表。内容侧重 LVGL 8.x 版本,因为 9.x 的 lv_menu 改动很大而且文档还不全,先用稳定的 8.x 做产品更靠谱。

1. 为什么是 lv_menu,而不是 lv_list 硬造轮子

很多人在嵌入式 UI 里做菜单,第一反应就是拿 lv_list 堆列表项,然后用 lv_tabview 做页面切换。这套方案在小规模场景下确实能用,但一旦菜单层级超过两层,或者需要动态调整菜单结构,代码复杂度会指数级上升。

lv_menu 解决的核心问题有三个:

  • 它天生就是树形结构,父菜单、子页面、返回关系都封装在控件内部,不需要自己去维护“当前在第几层”这种状态机。
  • 焦点管理和按键导航是内置的,配合遥控器或者旋转编码器操作时,LVGL 会自动处理焦点在菜单项之间的移动。
  • 页面切换带了内置的滚动和加载机制,LVGL 8.x 里 lv_menu 的页面切换是支持动画的,省掉自己写过渡效果的工作量。

我自己实测下来的对比感受是:用 lv_list 硬拼三层菜单,事件回调里全是“根据当前选中项再判断要跳到哪里”的 if-else 嵌套,大概要写 300 行以上;换成 lv_menu 之后,核心跳转逻辑压缩到 100 行以内,而且以后加新菜单项只需要往树里挂节点,完全不用动跳转逻辑。

1.1 lv_menu 的三个核心对象

lv_menu 的结构理解起来其实很简单,它由三个基础组件构成:

对象类型创建函数作用类比
lv_menu_pagelv_menu_page_create()一个菜单页面容器,可以包含子菜单和普通控件相当于手机里的一个“设置界面”
lv_menu_ctrllv_menu_ctrl_create()菜单根容器,管理整个菜单树、页面切换和返回栈相当于导航控制器
lv_menu_btnlv_menu_btn_create()菜单按钮项,点击后跳转到指定子页面相当于“设置”这个入口图标

简单来说,lv_menu_ctrl是老板,lv_menu_page是部门,lv_menu_btn是部门里的员工。点击员工(btn)会带你进入对应部门(page),page 里面还能继续挂新的 btn,于是就有了无限层级的菜单树。

需要注意的一点是,lv_menu_btn 和真正的子页面是通过lv_menu_set_load_page_event()这个函数绑定起来的。这个函数会给按钮自动加上点击事件,点击时自动加载目标页面。

1.2 为什么事件处理不能沿用 lv_event 的老思路

用 lv_menu 最反直觉的地方在于:给菜单按钮添加自定义点击事件时,不能直接lv_obj_add_event_cb(btn, my_cb, LV_EVENT_CLICKED, NULL)完事,因为 lv_menu 的内部逻辑已经把这个按钮的点击事件接管了。

我当时就在这里栽过跟头。我给一个 lv_menu_btn 额外挂了一个 LV_EVENT_CLICKED 回调,想着在里面写跳转逻辑,结果页面跳了两次,而且焦点还乱了。后来看源码才发现,lv_menu 的事件处理是分层的,它自己在lv_menu_page和按钮内部做了事件传播处理。

正确的做法是:用lv_menu_set_load_page_event()设置页面跳转,然后在lv_menu_ctrl上挂LV_EVENT_VALUE_CHANGED事件,这个事件会在菜单选中项变化时触发。如果你需要在跳转后做一些额外动作(比如刷新页面里的实时数据),就应该挂在LV_EVENT_VALUE_CHANGED里。

2. 实战:从零搭建动态菜单界面

光说不练假把式,下面我用一段可以编译运行的代码,完整演示怎么在 LVGL 8.3 里创建一个二级菜单,并且实现动态增删菜单项的功能。这段代码是我在一个 480x320 的 RGB 屏上验证过的,逻辑部分和分辨率关系不大,可以直接参考。

2.1 头文件与全局对象声明

#include "lvgl.h" static lv_obj_t *menu; /* 根菜单控件 */ static lv_obj_t *menu_page_main; /* 主页面 */ static lv_obj_t *menu_page_sensor; /* 传感器子页面 */ static lv_obj_t *menu_page_about; /* 关于页面 */ static lv_obj_t *sensor_value_label; /* 子页面里的数据标签 */ static uint8_t menu_item_count = 0; /* 动态菜单项计数 */

这里我把三个页面对象声明成全局,方便在事件回调里访问。如果你的项目有独立的 UI 文件,也可以放到结构体里统一管理。

2.2 菜单初始化与页面构建

void menu_init(void) { /* 创建根菜单 */ menu = lv_menu_create(lv_scr_act()); lv_obj_set_size(menu, 480, 320); lv_obj_center(menu); /* 设置菜单主题风格,深色背景 */ lv_obj_set_style_bg_color(menu, lv_color_hex(0x1E1E2E), 0); /* 创建主页面 */ menu_page_main = lv_menu_page_create(menu); lv_obj_set_style_pad_all(menu_page_main, 10, 0); /* 创建子页面 */ menu_page_sensor = lv_menu_page_create(menu); menu_page_about = lv_menu_page_create(menu); /* 构建菜单树 */ build_main_menu(); }

这里有几个细节值得注意。lv_menu_create()创建出来的控件本身就自带一个根页面,但这个根页面默认是空的,我们需要主动创建lv_menu_page_create()来作为实际的主页面,然后用lv_menu_set_page()把它设置进去。

2.3 主菜单页面构建

static void build_main_menu(void) { lv_obj_t *btn_sensor; lv_obj_t *btn_about; lv_obj_t *btn_dynamic; /* 创建返回按钮(主页面根部) */ lv_obj_t *back_btn = lv_menu_back_button_create(menu); lv_obj_set_style_bg_color(back_btn, lv_color_hex(0x2A2A3C), 0); /* 菜单首页内容 */ lv_obj_t *section = lv_menu_section_create(menu); btn_sensor = lv_menu_btn_create(section); lv_obj_set_style_bg_color(btn_sensor, lv_color_hex(0x313244), 0); lv_obj_set_style_text_color(btn_sensor, lv_color_hex(0xCDD6F4), 0); lv_menu_btn_set_text(btn_sensor, "传感器数据"); lv_menu_set_load_page_event(menu, btn_sensor, menu_page_sensor); btn_about = lv_menu_btn_create(section); lv_menu_btn_set_text(btn_about, "关于系统"); lv_menu_set_load_page_event(menu, btn_about, menu_page_about); /* 动态示例按钮 */ btn_dynamic = lv_menu_btn_create(section); lv_menu_btn_set_text(btn_dynamic, "动态菜单项"); lv_menu_set_load_page_event(menu, btn_dynamic, create_dynamic_page()); /* 挂载主页 */ lv_menu_set_page(menu, menu_page_main); lv_menu_set_sidebar_page(menu, menu_page_main); }

有个细节要解释一下:lv_menu_set_sidebar_page()是设置侧边栏页面,在宽屏设备上,lv_menu 默认会把根页面放在左侧作为侧边栏,右侧显示当前选中的页面。如果你只想用全屏切换模式,不想要这种“左边菜单右边内容”的布局,可以不调用lv_menu_set_sidebar_page(),或者在创建根菜单时把lv_menu_set_mode_root_back_btn()设置成LV_MENU_ROOT_BACK_BTN_ENABLED。

我这边 480 宽的屏幕用侧边栏模式有点挤,所以实际项目中我用了全屏模式,关键代码是在创建菜单后执行:

lv_menu_set_mode_root_back_btn(menu, LV_MENU_ROOT_BACK_BTN_ENABLED); lv_obj_set_style_pad_left(menu, 0, 0);

这样做之后,主页面点击菜单项会直接全屏切换到子页面,返回时点左上角的返回按钮即可。

2.4 子页面构建与动态数据刷新

static void build_sensor_page(void) { lv_obj_t *label; /* 子页面标题 */ label = lv_label_create(menu_page_sensor); lv_label_set_text(label, "传感器实时数据"); lv_obj_set_style_text_font(label, &lv_font_montserrat_16, 0); lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 20); /* 数据内容标签 */ sensor_value_label = lv_label_create(menu_page_sensor); lv_label_set_text(sensor_value_label, "温度: -- 度"); lv_obj_align(sensor_value_label, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_text_color(sensor_value_label, lv_color_hex(0xA6E3A1), 0); /* 刷新数据按钮 */ lv_obj_t *btn_refresh = lv_btn_create(menu_page_sensor); lv_obj_set_size(btn_refresh, 120, 40); lv_obj_align(btn_refresh, LV_ALIGN_BOTTOM_MID, 0, -40); lv_obj_add_event_cb(btn_refresh, refresh_sensor_data_cb, LV_EVENT_CLICKED, NULL); }

这里我用的是普通的lv_btn而不是lv_menu_btn,因为子页面里不一定所有按钮都要负责跳转,也可能像这个刷新按钮一样只是执行一个动作。lv_menu 的页面容器是可以自由添加任意控件的,不要被“菜单页面”这个名字限制住。

刷新数据的回调里,直接操作sensor_value_label即可:

static void refresh_sensor_data_cb(lv_event_t *e) { int16_t temp = get_temperature(); /* 假设这个函数读取传感器 */ lv_label_set_text_fmt(sensor_value_label, "温度: %d.%d 度", temp / 10, temp % 10); }

3. 动态交互的核心实现

3.1 动态增删菜单项

lv_menu 有一个很方便的特性,就是页面里的按钮可以在运行时创建和删除,而且不需要重建整个菜单树。结合lv_menu_set_load_page_event()就能实现运行时动态增加一个新的菜单入口。

static lv_obj_t *create_dynamic_menu_page(void) { lv_obj_t *page = lv_menu_page_create(menu); lv_obj_t *label = lv_label_create(page); lv_label_set_text(label, "这是一个运行时创建的页面"); lv_obj_center(label); return page; } static void add_dynamic_menu_item(lv_obj_t *parent_section) { lv_obj_t *btn = lv_menu_btn_create(parent_section); char buf[32]; menu_item_count++; snprintf(buf, sizeof(buf), "动态入口 %d", menu_item_count); lv_menu_btn_set_text(btn, buf); lv_obj_t *new_page = create_dynamic_menu_page(); lv_menu_set_load_page_event(menu, btn, new_page); }

调用add_dynamic_menu_item()时注意传入的parent_section必须是当前页面里已经存在的 section 对象。如果你的页面还没有创建 section,直接用lv_menu_page_create()的返回值作为父对象也行。

动态删除时要注意:不要用lv_obj_del()直接删按钮完事,因为 lv_menu 内部还保存了页面索引和加载关系。正确做法是先lv_menu_set_load_page_event(menu, btn, NULL)解除绑定,再删除按钮对象。

static void remove_dynamic_menu_item(lv_obj_t *btn) { lv_menu_set_load_page_event(menu, btn, NULL); /* 先解绑 */ lv_obj_del(btn); /* 再删除 */ }

3.2 处理 LV_EVENT_VALUE_CHANGED 事件

LV_EVENT_VALUE_CHANGED是 lv_menu 在“当前选中菜单项变化”时触发的事件,类似于 tabview 切换 tab 的事件。在动态菜单场景下,这个事件能帮你做很多事情:比如根据菜单项变化更新右侧内容区域的标题栏、重置一些页面状态、记录用户操作路径等。

static void menu_event_cb(lv_event_t *e) { lv_event_code_t code = lv_event_get_code(e); if (code == LV_EVENT_VALUE_CHANGED) { lv_obj_t *menu = lv_event_get_target(e); lv_obj_t *item = lv_menu_get_cur_main_link(menu); if (item == NULL) { /* 回到了根页面 */ update_status_bar("首页"); return; } lv_obj_t *btn = lv_menu_get_cur_btn(menu); if (btn != NULL) { const char *txt = lv_menu_btn_get_text(btn); update_status_bar(txt); } } }

这里lv_menu_get_cur_main_link()返回当前页面对象,lv_menu_get_cur_btn()返回当前被激活的菜单按钮。这两个 API 在动态菜单里很实用,因为返回的是运行时状态,不用自己维护全局变量去记“用户点到哪了”。

挂载这个回调的时候,是挂在menu根对象上,不是挂在单个按钮上:

lv_obj_add_event_cb(menu, menu_event_cb, LV_EVENT_VALUE_CHANGED, NULL);

3.3 返回键处理与页面栈管理

lv_menu 自带返回栈。最开始用的时候,我以为返回逻辑要自己用数组模拟栈来写,后来发现多余了。lv_menu 内部维护了当前页面栈,点返回按钮会按照“当前页 -> 上一级页 -> 根页面”的顺序逐级回退。

你唯一需要做的是决定主页面是否要显示返回按钮。用lv_menu_set_mode_root_back_btn(menu, LV_MENU_ROOT_BACK_BTN_ENABLED)让根页面也有返回按钮,并且点击时触发LV_EVENT_CANCEL事件(如果你在根页面也想要一个“退出应用”的动作)。

static void root_cancel_cb(lv_event_t *e) { /* 用户在主页面点返回按钮,执行自定义操作 */ save_user_config(); show_exit_dialog(); } lv_obj_add_event_cb(menu, root_cancel_cb, LV_EVENT_CANCEL, NULL);

如果你的产品用的是实体按键(比如旋转编码器按下),可以在按键事件里主动调用lv_menu_back_btn_create()创建的返回按钮的模拟点击,或者更简单地调用:

lv_event_send(menu, LV_EVENT_CANCEL, NULL);

这样就能触发返回逻辑。

4. 关键参数选择与常见问题排查

4.1 字体、图标和边距的参数选择

lv_menu 在显示返回按钮时会用到箭头图标LV_SYMBOL_LEFT。如果你的自定义字体没包含这个符号,返回按钮上会显示一个方框。解决方案有两个:

  • 使用 LVGL 内置字体,比如lv_font_montserrat_14,里面自带符号表。
  • 在自定义字体中加入LV_SYMBOL_LEFT等必要图标。

我建议用第二种,因为嵌入式项目一般都会做中文字体,而中文字体通常需要自己生成。生成时记得勾选“Symbols”选项,否则菜单上各种图标字符都会变成豆腐块。

另外,菜单项的文字对齐也值得花心思调一下。lv_menu_btn 的文字默认靠左对齐,如果产品设计要的是居中,需要手动设置:

lv_obj_set_style_text_align(btn, LV_TEXT_ALIGN_CENTER, 0);

4.2 聚焦环和按键导航

如果你的硬件只有几个实体按键(上下左右确认返回),需要打开 LVGL 的 group 功能,让 lv_menu 能响应按键导航。

lv_group_t *g = lv_group_create(); lv_group_add_obj(g, menu); lv_group_set_default(g); /* 在按键事件里发送 KEY 事件 */ void key_cb(uint8_t key) { switch (key) { case KEY_UP: lv_group_send_data(g, LV_KEY_UP); break; case KEY_DOWN: lv_group_send_data(g, LV_KEY_DOWN); break; case KEY_ENTER: lv_group_send_data(g, LV_KEY_ENTER); break; case KEY_BACK: lv_group_send_data(g, LV_KEY_ESC); break; } }

注意,LV_KEY_ESC在 lv_menu 里默认会触发返回操作吗?实测在 LVGL 8.3 中,LV_KEY_ESC会触发LV_EVENT_CANCEL事件,而 lv_menu 的返回按钮点击也是触发这个事件。所以如果返回没生效,检查是不是 group 里没有正确放在 menu 对象上,焦点不在菜单控件上时按键事件是进不来的。

4.3 常见问题速查表

问题现象可能原因解决方案
返回按钮显示为方框字体缺少 LV_SYMBOL_LEFT更换或重构字体,勾选 Symbols
菜单点击无反应未调用 lv_menu_set_load_page_event给按钮绑定加载事件
点击菜单跳转两次自己额外挂了 LV_EVENT_CLICKED去掉自定义点击事件,改用 VALUE_CHANGED
根页面不显示返回按钮未开启 ROOT_BACK_BTN 模式lv_menu_set_mode_root_back_btn
动态删菜单项后崩溃直接删对象,没有解绑先 set_load_page_event 传 NULL
子页面里控件显示不全页面没有设置滚动或自动适配检查页面 padding 与控件布局
焦点不在菜单上group 未正确添加lv_group_add_obj(g, menu)

4.4 一个容易被忽略的坑:lv_menu 和 lv_group 的冲突

lv_menu 在创建时会自动创建一个内部 group,用于页面内按钮的焦点管理。如果你又额外把 menu 加入了一个自定义 group,有些版本会出现焦点被抢占或者事件冲突的问题。

实测下来,最稳妥的方案是:让 lv_menu 自己管焦点,不要把它加入外部 group。外部按键事件直接通过lv_event_send(menu, LV_EVENT_KEY, &key_data)发给 menu 对象本身,或者调用lv_group_send_data(lv_obj_get_group(menu), LV_KEY_UP)这种方式会安全很多。

lv_key_t key = LV_KEY_DOWN; lv_event_data_t ed = {0}; ed.user_data = &key; lv_event_send(menu, LV_EVENT_KEY, &ed);

注意lv_event_data_t在 LVGL 8.3 中不是这样初始化的,更常见的是用:

uint32_t key = LV_KEY_DOWN; lv_event_send(menu, LV_EVENT_KEY, &key);

我用后一种方式实测是可以的。但不同版本对 LV_EVENT_KEY 的 data 类型定义有差异,如果你在升级 LVGL 版本后按键失效,优先检查这里。

5. 让动态交互界面更顺手的几个细节

5.1 菜单页面切换动画的开与关

lv_menu 默认页面切换是有动画的,但这个动画在低主频 MCU 上可能会造成卡顿感。如果你的主控是 Cortex-M0 或者主频低于 100MHz,可以考虑关闭动画:

lv_menu_set_anim_time(menu, 0);

如果你的硬件性能充足,保留 200ms 左右的动画会让界面质感提升不少。我目前用的主控跑 240MHz,动画开 150ms 很流畅。

5.2 使用 lv_menu_section 分组,而不是一股脑塞按钮

lv_menu_section 是分组容器,同一组内的菜单按钮会挨在一起,组与组之间会有间距。产品上如果需要把“系统设置”和“关于”分成两个逻辑区块,用 section 分组的视觉效果会比手动设置间距好很多。

lv_obj_t *section1 = lv_menu_section_create(menu_page_main); lv_obj_t *section2 = lv_menu_section_create(menu_page_main); /* section1 放设备信息菜单,section2 放操作类菜单 */

5.3 利用 LV_EVENT_READY 优化操作流

lv_menu 还有一个隐藏事件LV_EVENT_READY,在菜单返回栈清空(回到主页面)时触发。这个事件很适合用来做“退出菜单回到主界面”之类的逻辑,实现类似手机按下返回键退出最顶层页面的效果。

static void menu_ready_cb(lv_event_t *e) { /* 用户从子页面返回到了根页面,这里做数据同步 */ refresh_overview_page(); } lv_obj_add_event_cb(menu, menu_ready_cb, LV_EVENT_READY, NULL);

我之前一直用 VALUE_CHANGED 判断“是否回到根”,但那种方式取到的当前链接是 NULL,不好区分是“刚启动”还是“从子页返回”。用 READY 事件更准确,毕竟它只在返回栈清空时触发一次。

6. 我的实际体验与建议

lv_menu 这套组件,在 LVGL 8.x 里算是我见过封装得最完整的菜单类控件之一,比 lv_list 的“裸奔感”强很多,也比自己写一个菜单状态机要省心得多。但也不是没有代价:

  • 文档偏少,很多细节得翻源码才能确认,尤其是事件流那一块。
  • 9.x 版本改动很大,原来的 API 基本废弃了,新项目如果想用 9.x,需要重新适配。
  • 它在小屏(比如 240x240 的圆形屏)上表现一般,更适合矩形屏幕、带明确层级结构的菜单场景。

如果你现在还在用 lv_list 硬拼菜单,又恰好要支持多级跳转,我强烈建议花半天时间把 lv_menu 啃下来。先按官方 example 跑通基础结构,再把你自己的页面往里填,最后再根据事件回调逐步加动态逻辑。整个流程比你想的简单,而且后期维护的幸福感会提升很多。

最后分享一个我自己的习惯:写 lv_menu 相关代码时,我会在文档注释里把“菜单树”结构画出来,比如:

/* 菜单树结构 * ├── 主页面 * │ ├── 传感器数据 -> 传感器页面 * │ │ └── 刷新按钮 * │ ├── 关于系统 -> 关于页面 * │ └── 动态菜单项 -> 动态页面 */

这样过几个月再回来看代码,不用重新梳理绑定关系,直接就能定位问题。这个习惯帮我省了不少时间,也推荐给你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询