ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LVGL lv_menu实战:嵌入式多级动态菜单与事件处理

LVGL lv_menu实战:嵌入式多级动态菜单与事件处理 最近在做一个基于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会带你进入对应部门pagepage 里面还能继续挂新的 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 相关代码时我会在文档注释里把“菜单树”结构画出来比如/* 菜单树结构 * ├── 主页面 * │ ├── 传感器数据 - 传感器页面 * │ │ └── 刷新按钮 * │ ├── 关于系统 - 关于页面 * │ └── 动态菜单项 - 动态页面 */这样过几个月再回来看代码不用重新梳理绑定关系直接就能定位问题。这个习惯帮我省了不少时间也推荐给你。
返回列表