ARTICLE DETAIL

资讯详情

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

LVGL菜单系统实战:从页面管理到交互优化

LVGL菜单系统实战:从页面管理到交互优化 前阵子帮人review一个基于LVGL的HMI项目功能需求不复杂无非是几个设置页、一个参数列表、一个关于页面。但打开他的main.c之后我有点上头——屏幕上一共十来个lv_btn每个按钮一个回调页面切换靠互相设置hidden标志返回键逻辑散落在三个不同的.c文件里列表滚轮和触摸又没协调好实测的时候触控一滑焦点按钮直接跑偏。这其实不是个例。很多刚接触LVGL的嵌入式工程师第一步能把控件画出来第二步就栽在菜单交互上。菜单控件看着简单真正要做得像消费级产品那样顺手背后涉及页面管理、焦点切换、事件回调、字体内存这些连环问题。这篇东西我准备直接从实战角度拆一遍把一套我在多个项目里复用的菜单框架代码拿出来讲从模拟器快速验证到真机踩坑尽量让看到这篇文章的人少走我走错过的那些路。适合正准备用LVGL做设备界面、或者已经画了几个页面但不知道怎么把交互理顺的读者。1. 菜单界面看着简单为什么还有那么多人写崩很多嵌入式产品的UI需求本质上就是一个多级菜单开机进主界面左边一列功能项选中某个功能后进入二级页二级页里可能是参数调节、开关切换、数据展示再深层可能就是确认弹窗或者输入框。这种需求在需求文档里两三行就能写完但实现起来远比画几个控件复杂。1.1 先认清一件事菜单不是控件堆叠是页面体系LVGL官方给的是控件库不是应用框架。lv_btn、lv_list、lv_label这些是零件而菜单是一个“体系”它要处理三件事页面从哪来、页面怎么切、切完之后怎么回。很多项目写崩就是把页面切换逻辑写散在各自的回调里每个按钮都觉得自己是主角结果状态一乱就成了一锅粥。我见过一个典型写法每个页面都自己new一个screen切换的时候lv_scr_load()直接整屏替换。这在小demo里没问题但一旦涉及动画、返回栈、焦点保留这种写法会让内存碎片悄然上涨而且每个页面都要重新创建状态根本留不住。更合理的做法是所有页面挂在一个统一的容器下由一套切换函数管理进出页面本身只负责“画自己”和“通知外部我有点击”不直接操作兄弟页面。1.2 不同菜单形态控件选型完全不同嵌入式菜单常见三种形态不同形态对应的LVGL控件策略完全不同你要先想清楚自己是哪一种再去写代码。菜单形态典型场景LVGL推荐方案选型理由左右分栏工控屏、仪表设置页左侧导航右侧内容lv_list 右侧自绘容器列表滚动和内容区切换解耦焦点明确全屏层级切换消费电子、小型LCD面板点进去全屏都是二级页自拼lv_obj容器 切换动画可定制性最强页面栈好控制同级Tab页带多个并列功能页的设备比如示波器、温控器lv_tabview或lv_tileview官方对左右滑动分页支持成熟省事如果界面层级超过两级或者每个页面的布局差异很大我基本不推荐用lv_tabview硬拼。lv_tabview适合“同级内容切换”不适合“有路径关系的多级跳转”。真正产品级的做法是自己维护一个页面容器用lv_obj当page容器配合动画做切换这套方案我在下面第三章展开写。1.3 lv_list不是只能当菜单它也是交互状态的载体很多人只用lv_list_add_btn往列表里塞按钮以为list就是个纵向排列的容器。其实lv_list自带了滚动、选中态、焦点处理这一整套机制尤其在配合旋钮或遥控器这种非触摸输入时list内部的焦点移动和滚动绑定是帮你省掉很多事的。我自己习惯用list做主菜单但会把list默认的边框、背景、圆角全清掉把视觉交给自定义样式。这样既拿到了list的交互能力又不被默认外观绑架。后面代码部分会展示具体怎么配样式。2. 先别上板子5分钟跑起来的秘诀是模拟器先行标题说5分钟搞定这个时间预算里根本不该包含“移植LVGL到单片机”这一步。第一次接触LVGL的人最容易掉进一个坑拿到板子就开始配屏幕驱动、调SPI时序、折腾DMA折腾一整天连一个空窗口都没点亮UI自然无从谈起。正确的做法是先在PC模拟器上把界面逻辑跑通验证完再上板。2.1 快速跑通PC模拟器的三个关键点LVGL官方有维护基于SDL的PC模拟器工程用VS Code加CMake插件就能跑。流程不复杂三件事点透就够了用git clone官方模拟器仓库注意分支和LVGL版本要对齐。LVGL 8.x和9.x的接口差异不小如果clone下来默认是9.x而你看的教程是8.x代码大概率编译不过。检查lv_conf.h里LV_COLOR_DEPTH是否和你的目标屏一致。PC模拟器随便设32bit没事但真机如果是RGB565的屏建议模拟器里也设成16避免后期颜色表现不一致。把lv_conf.h中LV_USE_DEMO系列打开先跑一个官方demo确认环境OK再删掉demo换自己的代码。模拟器最大的价值不是“看效果”而是能打断点。UI代码在板子上最难调的就是回调顺序和坐标计算在PC上按个断点、看一眼调用栈问题原因一目了然。我的习惯是UI逻辑出问题先在模拟器复现复现不了才考虑是不是硬件差异。2.2 用Keil和STM32的同学移植最卡的是这两个环节每周都有不下五个人在嵌入式群里问LVGL卡在哪个文件里其实大部分卡点不是LVGL本身而是两块没接好屏幕驱动接口和心跳时钟。LVGL要正常工作底层必须提供一个disp_flush回调你在这个回调里把显存中的像素数据写给LCD控制器。很多移植教程会让你用取模软件生成整屏数据效率极低而且一旦分辨率稍微大一点速度就惨不忍睹。正确做法是配置LVGL的draw buffer分区刷新每次flush只把一两个buffer的行数据交给DMA传给屏幕这样即使没有硬件加速也能跑出可用的帧率。另一个容易忽略的是lv_tick_inc()LVGL内部所有动画、超时、长按检测都依赖这个心跳。STM32裸机环境下一般用一个定时器中断每1ms调用一次lv_tick_inc(1)。有人移植完了界面能显示但不能点击多半就是心跳没喂或者喂的周期不对。此外对Keil用户来说如果不想手动移植现在很多开发板的BSP包已经带了LVGL工程模板直接搜“LVGL移植STM32工程”或者去官方仓库的移植示例里找对应平台的模板比自己从零开始复制粘贴文件省太多时间。2.3 我的建议模拟器走通UI板子只验证性能这里分享一个我很坚持的工作流UI布局、页面切换、交互逻辑全部在模拟器上写好调好上板之后只做三件事——验证触摸坐标对不对、验证帧率够不够、验证颜色显示正不正。这不是偷懒而是因为UI调试迭代次数极高板子每烧一次程序都要几秒到几十秒模拟器上则是秒级刷新。人被频繁打断之后思路很难保持连贯。3. 不玩花活一个真正能用的菜单系统是怎么写出来的下面这部分是核心。我会以一套精简但完整的菜单框架为例逐步拆解。这套代码用的是LVGL 8.x的API9.x用户需要注意少量接口差异但整体结构可以直接照搬。3.1 先定义菜单模型让数据和界面解耦很多入门教程把菜单项写死在事件回调里这个菜单页面一旦要加一个功能就得在多个文件里来回改。我更建议一开始就定义一套菜单模型用数据结构描述“这个页面有哪些菜单项每个菜单项点了之后去哪”界面代码只负责渲染这些结构。/* menu_model.h */ #ifndef MENU_MODEL_H #define MENU_MODEL_H #include lvgl.h typedef enum { PAGE_MAIN, PAGE_SYSTEM_SETTING, PAGE_DISPLAY_SETTING, PAGE_COMM_SETTING, PAGE_ABOUT, PAGE_BACKLIGHT_ADJUST, PAGE_FACTORY_RESET, PAGE_COMM_MODE, PAGE_MAX } page_id_t; typedef struct { const char *name; const void *icon; page_id_t target_page; } menu_item_t; typedef struct { page_id_t page_id; const char *title; const menu_item_t *items; uint8_t item_count; } page_info_t; extern const page_info_t page_table[PAGE_MAX]; #endif这里把页面ID、标题、菜单项数组集中放到了数据段里。菜单项点击之后要去哪个页面由target_page字段描述而不是写在回调里。这样将来加菜单只需要往数组里加一条数据UI层和业务层彻底分开。对应的页面数据表在menu_model.c里/* menu_model.c */ #include menu_model.h static const menu_item_t main_menu_items[] { {系统设置, NULL, PAGE_SYSTEM_SETTING}, {显示设置, NULL, PAGE_DISPLAY_SETTING}, {通信设置, NULL, PAGE_COMM_SETTING}, {关于设备, NULL, PAGE_ABOUT}, }; static const menu_item_t system_menu_items[] { {背光亮度, NULL, PAGE_BACKLIGHT_ADJUST}, {恢复出厂, NULL, PAGE_FACTORY_RESET}, {返回主菜单, NULL, PAGE_MAIN}, }; static const menu_item_t display_menu_items[] { {屏幕休眠, NULL, PAGE_MAIN}, {主题颜色, NULL, PAGE_MAIN}, {返回主菜单, NULL, PAGE_MAIN}, }; static const menu_item_t comm_menu_items[] { {通信模式, NULL, PAGE_COMM_MODE}, {返回主菜单, NULL, PAGE_MAIN}, }; static const menu_item_t about_menu_items[] { {版本号, NULL, PAGE_MAIN}, {返回主菜单, NULL, PAGE_MAIN}, }; const page_info_t page_table[PAGE_MAX] { [PAGE_MAIN] {PAGE_MAIN, 主菜单, main_menu_items, sizeof(main_menu_items) / sizeof(menu_item_t)}, [PAGE_SYSTEM_SETTING] {PAGE_SYSTEM_SETTING, 系统设置, system_menu_items, sizeof(system_menu_items) / sizeof(menu_item_t)}, [PAGE_DISPLAY_SETTING] {PAGE_DISPLAY_SETTING, 显示设置, display_menu_items, sizeof(display_menu_items) / sizeof(menu_item_t)}, [PAGE_COMM_SETTING] {PAGE_COMM_SETTING, 通信设置, comm_menu_items, sizeof(comm_menu_items) / sizeof(menu_item_t)}, [PAGE_ABOUT] {PAGE_ABOUT, 关于设备, about_menu_items, sizeof(about_menu_items) / sizeof(menu_item_t)}, /* 详情页在page_table中没有items作为特殊页面单独创建 */ };也许有人会觉得用枚举做页面ID有点笨后续项目变得更复杂时可以考虑用函数指针注册页面工厂。但对于绝大多数嵌入式菜单枚举加数据表已经足够清晰而且还有一个好处编译器能帮你检查页面ID是否越界。3.2 用lv_list构建页面通用逻辑我写了一个通用页面创建函数所有“列表型页面”都走同一个入口。传入页面对应的page_info_t函数自动创建状态栏、列表和返回键。/* ui_menu.c */ #include ui_menu.h #include menu_model.h static lv_obj_t *screen_container; static lv_obj_t *current_page; static page_id_t current_page_id; static void ui_switch_page(page_id_t target); /* 页面顶部状态栏显示当前页标题和返回键 */ static lv_obj_t *create_header(lv_obj_t *parent, const char *title, bool show_back) { lv_obj_t *header lv_obj_create(parent); lv_obj_set_size(header, LV_PCT(100), 48); lv_obj_set_pos(header, 0, 0); lv_obj_set_style_border_width(header, 0, 0); lv_obj_set_style_bg_color(header, lv_color_hex(0x2C3E50), 0); lv_obj_set_style_radius(header, 0, 0); if (show_back) { lv_obj_t *back_btn lv_btn_create(header); lv_obj_set_size(back_btn, 56, 40); lv_obj_align(back_btn, LV_ALIGN_LEFT_MID, 4, 0); lv_obj_set_style_bg_color(back_btn, lv_color_hex(0x34495E), 0); lv_obj_t *back_label lv_label_create(back_btn); lv_label_set_text(back_label, 返回); lv_obj_center(back_label); lv_obj_add_event_cb(back_btn, back_btn_event_cb, LV_EVENT_CLICKED, NULL); } lv_obj_t *title_label lv_label_create(header); lv_label_set_text(title_label, title); lv_obj_align(title_label, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_text_color(title_label, lv_color_white(), 0); return header; } /* 菜单列表页通用创建函数 */ static lv_obj_t *create_list_page(page_id_t page_id) { const page_info_t *info page_table[page_id]; lv_obj_t *page lv_obj_create(screen_container); lv_obj_set_size(page, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(page, 0, 0); lv_obj_set_style_border_width(page, 0, 0); lv_obj_set_style_pad_all(page, 0, 0); bool show_back (page_id ! PAGE_MAIN); create_header(page, info-title, show_back); lv_obj_t *list lv_list_create(page); lv_obj_set_size(list, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(list, 0, 48); lv_obj_set_style_border_width(list, 0, 0); lv_obj_set_style_pad_all(list, 8, 0); lv_obj_set_style_radius(list, 0, 0); /* 菜单项默认样式 */ static lv_style_t btn_style; lv_style_init(btn_style); lv_style_set_bg_color(btn_style, lv_color_hex(0xECF0F1)); lv_style_set_bg_color(btn_style, lv_color_hex(0x3498DB), LV_STATE_PRESSED); lv_style_set_text_color(btn_style, lv_color_hex(0x2C3E50)); lv_style_set_text_color(btn_style, lv_color_white(), LV_STATE_PRESSED); lv_style_set_radius(btn_style, 6); lv_style_set_pad_all(btn_style, 12); for (uint8_t i 0; i info-item_count; i) { const menu_item_t *item info-items[i]; lv_obj_t *btn lv_list_add_btn(list, item-icon, item-name); lv_obj_add_style(btn, btn_style, 0); lv_obj_add_style(btn, btn_style, LV_STATE_PRESSED); lv_obj_add_event_cb(btn, menu_btn_event_cb, LV_EVENT_CLICKED, (void *)(uintptr_t)item-target_page); } lv_obj_t *page_ref page; /* 用于焦点管理稍后说明 */ LV_UNUSED(page_ref); return page; }这段代码里有个关键点事件回调的参数直接把target_page作为用户数据传进去了而不是像很多人那样用lv_event_get_target(e)再去找按钮关联的页面。这样数据流非常直白点击菜单项 - 拿到目标页ID - 调切换函数。整个回调不到十行static void menu_btn_event_cb(lv_event_t *e) { page_id_t target (page_id_t)(uintptr_t)lv_event_get_user_data(e); ui_switch_page(target); } static void back_btn_event_cb(lv_event_t *e) { page_id_t parent page_table[current_page_id].page_id PAGE_MAIN ? PAGE_MAIN : get_parent_page(current_page_id); ui_switch_page(parent); }get_parent_page实现很简单可以在数据表里再加一个parent_page字段也可以在切换页面时用一个栈记录来源。我个人更推荐栈式记录因为用户可能从系统设置进入背光调节再进入一个更深的确认页此时需要逐级返回。这个后面第四章讲交互时会细说。3.3 页面切换容器动画和生命周期管理切换函数是整套框架的心脏。它的核心逻辑是把新页面放到容器里旧页面做个位移动画移出新页面移入动画结束后回收旧页面对象。static void ui_switch_page(page_id_t target) { if (target current_page_id) { return; } /* 创建新页面并移动到容器右侧准备滑入 */ lv_obj_t *new_page create_list_page(target); lv_obj_set_x(new_page, lv_obj_get_width(screen_container)); lv_obj_t *old_page current_page; /* 新页面滑入动画 */ lv_anim_t a_in; lv_anim_init(a_in); lv_anim_set_var(a_in, new_page); lv_anim_set_exec_cb(a_in, (lv_anim_exec_xcb_t)lv_obj_set_x); lv_anim_set_values(a_in, lv_obj_get_width(screen_container), 0); lv_anim_set_time(a_in, 250); lv_anim_set_path_cb(a_in, lv_anim_path_ease_out); lv_anim_start(a_in); /* 旧页面向左滑出 */ if (old_page ! NULL) { lv_anim_t a_out; lv_anim_init(a_out); lv_anim_set_var(a_out, old_page); lv_anim_set_exec_cb(a_out, (lv_anim_exec_xcb_t)lv_obj_set_x); lv_anim_set_values(a_out, 0, -lv_obj_get_width(screen_container) / 4); lv_anim_set_time(a_out, 250); lv_anim_set_path_cb(a_out, lv_anim_path_ease_out); lv_anim_set_ready_cb(a_out, old_page_del_cb); lv_anim_start(a_out); } current_page new_page; current_page_id target; } static void old_page_del_cb(lv_anim_t *a) { lv_obj_t *old_page (lv_obj_t *)a-var; lv_obj_del(old_page); }这里有三个比较容易被忽略的细节。第一新页面在创建时就已经被加到screen_container里了所以容器内同一时刻可能有两个页面对象。在动画期间LVGL会同时绘制这两个对象如果你把新页面创建在容器最前面旧页面只需移动一点点视觉上不会出现露底的情况。第二动画结束后必须删除旧页面否则每点一次菜单就多一个未销毁的页面对象跑几个小时之后内存就会耗尽。很多看起来像“越用越卡”的问题其实就是这种动画页面没有正确释放。第三lv_anim_set_ready_cb可能在你使用某个LVGL旧版本时不存在可以用lv_anim_set_deleted_cb或者在动画开始前用一个lv_timer延迟删除代替。但当前主流的8.x和9.x都是支持ready回调的问题不大。3.4 特殊页面参数调节和开关切换菜单系统里除了列表页还有一类“内容页”。比如背光亮度调节页是一个滑杆通信模式页是两个开关按钮关于页是纯文本信息。这类页面没必要套list模板可以单独写创建函数但同样要挂到容器里复用同一套切换逻辑。static lv_obj_t *create_backlight_adjust_page(void) { lv_obj_t *page lv_obj_create(screen_container); lv_obj_set_size(page, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(page, 0, 0); lv_obj_set_style_border_width(page, 0, 0); lv_obj_set_style_pad_all(page, 0, 0); create_header(page, 背光亮度, true); lv_obj_t *slider lv_slider_create(page); lv_obj_set_width(slider, 280); lv_obj_align(slider, LV_ALIGN_CENTER, 0, 0); lv_slider_set_range(slider, 0, 100); lv_slider_set_value(slider, 60, LV_ANIM_ON); lv_obj_t *value_label lv_label_create(page); lv_label_set_text_fmt(value_label, 当前亮度: %d%%, 60); lv_obj_align(value_label, LV_ALIGN_CENTER, 0, 40); lv_obj_add_event_cb(slider, slider_event_cb, LV_EVENT_VALUE_CHANGED, value_label); return page; } static void slider_event_cb(lv_event_t *e) { lv_obj_t *slider lv_event_get_target(e); lv_obj_t *label (lv_obj_t *)lv_event_get_user_data(e); int32_t val lv_slider_get_value(slider); lv_label_set_text_fmt(label, 当前亮度: %d%%, (int)val); }这个页面的创建函数也要在切换逻辑里识别。最简单的方式是在ui_switch_page里根据target做switch分支列表页走create_list_page特殊页走各自的创建函数static lv_obj_t *create_page_by_id(page_id_t page_id) { switch (page_id) { case PAGE_BACKLIGHT_ADJUST: return create_backlight_adjust_page(); case PAGE_COMM_MODE: return create_comm_mode_page(); /* 其他特殊页继续追加 */ default: return create_list_page(page_id); } }这样页面创建的入口统一了切换逻辑不用关心页面内部长什么样只管“挂到容器里、做动画、删旧页”。这套结构的扩展性很好新增页面只需要在数据表里加一项或者在switch里加一个case。4. 键盘、旋钮、触摸菜单的交互细节决定成品率菜单能显示只是第一步能不能在各种输入方式下都操作顺畅才是产品从demo走向量产的分水岭。LVGL的交互体系比很多人想象的要复杂但也比很多人想象的要规整。4.1 lv_group焦点机制为什么你按遥控器没反应LVGL的控件默认不会自动响应键盘或旋钮它有一个独立的焦点机制叫lv_group。一个group里可以加多个控件方向键或旋钮事件会在这个group内部移动焦点。很多人在模拟器里用鼠标点得好好的一上真机用按键操作发现LVGL完全不响应就是因为没建group也没把需要操作的控件加进group里。菜单框架里焦点应该跟页面走切换页面时把新页面里的list或者slider加入group退出页面时把这个页面从group里移除。一个比较省事的做法是用lv_group_set_default设一个默认group然后在创建列表页时把list加进去static lv_obj_t *create_list_page(page_id_t page_id) { /* ... 创建page和list ... */ lv_group_t *g lv_group_get_default(); if (g NULL) { g lv_group_create(); lv_group_set_default(g); } lv_group_add_obj(g, list); return page; }注意一点group对象不会自动释放页面切换时如果不把旧页面的控件移除焦点可能还在一个已经被删除的控件上导致按键失效甚至崩溃。在页面切换动画开始时应该先把旧页面的所有焦点对象从group里移除。4.2 返回键的两种设计别让用户走迷宫返回逻辑设计得好不好直接影响用户对一款嵌入式设备的好感。我见过最简单粗暴的写法返回键永远回主菜单。这在某些场景下能用但用户设置到第三级页面误触一下返回直接跳回主界面前面设的参数全丢了心态很容易崩。更稳妥的是维护一个页面栈每次进入新页面就把当前页面压栈返回时弹栈。栈的深度一般不超过四层用静态数组加栈顶指针就能实现不需要引入复杂的数据结构#define MAX_PAGE_STACK_DEPTH 8 static page_id_t page_stack[MAX_PAGE_STACK_DEPTH]; static uint8_t page_stack_depth 0; static void push_page(page_id_t page) { if (page_stack_depth MAX_PAGE_STACK_DEPTH) { page_stack[page_stack_depth] page; } } static page_id_t pop_page(void) { if (page_stack_depth 0) { return page_stack[--page_stack_depth]; } return PAGE_MAIN; }在ui_switch_page里进入新页面时先压栈返回键的回调里先弹栈拿到来源页面ID再调用ui_switch_page。这样用户不管钻到多深的页面都能一级一级退回来。这里还有一个细微的需求差异有的设备希望返回键直接回到主菜单有的希望逐级返回。我的建议是把策略做成一个宏或者配置项在产品定义阶段就能切换而不是在代码里写死。4.3 触摸滚动和按钮点击的冲突怎么处理触摸屏设备上列表滚动和列表项点击是两个天然冲突的操作手指滑动的时候系统需要判断用户是想滚动还是想点击。LVGL内置的lv_list其实已经处理了大部分这种冲突它会根据滑动的距离和速度决定是进入滚动模式还是触发点击事件。但如果你自己用lv_obj拼了一个“看起来像列表”的容器里面放了很多button触摸冲突就需要自己解决。我的经验是优先用lv_list不要自己造轮子如果非要自定义列表至少不要自己加LV_EVENT_PRESSING里的点击逻辑而是交给LVGL的事件顺序机制判断。另外一个容易被忽略的坑是触摸校准。很多设备上菜单点不准、点了没反应不是代码问题而是触摸屏没有校准。LVGL支持触摸坐标归一化在lv_indev_drv里设置read_cb时最好把坐标转换逻辑单独抽出来方便适配不同屏幕的触摸芯片。5. 跑真机前必须处理的三个坑字体、内存、刷新代码在模拟器上跑得再顺到真机上也会露出马脚。我基于实际项目经验把最常遇到、也最影响体验的三个坑提前摆出来。5.1 中文字体不只是“加个字库”那么简单LVGL默认字体是不带中文的如果你直接lv_label_set_text(label, 系统设置)屏幕上会出现一排方框。解决办法是用LVGL官方字体转换工具把ttf格式的字体转成C数组然后在代码里声明并设置。字体转换时有几个经验值值得记下来字体文件不要全量包含只勾选用到的汉字常用二级汉字大概几百个生成的C文件可以控制在几十KB以内。每个页面用到的字号尽量统一不要出现14号、15号、16号三个字体都加载的情况。字号不同等于要转换三份字体数组内存开销成倍上涨。如果只需要数字和字母保持用默认字体只有中文用自定义字体。lv_style_set_text_font可以按控件设置字体混用没问题。转换工具里开启LV_FONT_KERNING和抗锯齿设置时要谨慎抗锯齿会增大字体数据运行时也会占用更多渲染开销低端MCU上宁可不抗锯齿。代码里声明字体的方式很简单LV_FONT_DECLARE(my_font_16); lv_obj_t *label lv_label_create(parent); lv_label_set_text(label, 系统设置); lv_obj_set_style_text_font(label, my_font_16, 0);5.2 LV_MEM_SIZE和draw buffer怎么定才够用LVGL默认使用动态内存池所有控件、样式、动画对象都在这个池子里分配。lv_conf.h里的LV_MEM_SIZE定得太小界面一复杂就会出现分配失败表现是某些控件突然画不出来或者动画中途停住。以我自己的项目经验估算一个包含状态栏、列表和几个弹窗的典型菜单界面大概会创建几十个LVGL对象对象加样式加动画LV_MEM_SIZE至少需要40KB以上才够稳。如果界面复杂比如同时存在多个页面对象未销毁、使用了较大图片缓冲建议直接上到80KB甚至128KB。draw buffer的选择也很有讲究。这个buffer是LVGL渲染时用的大小直接影响刷新流畅度。比较常见的做法是设为屏幕宽度的10行到20行举例一块480x272的屏幕RGB565格式下每像素2字节如果配10行buffer就是480 * 10 * 2 9.6KB。如果想降低撕裂感可以配两个bufferLVGL在flush一个buffer的同时渲染另一个画面会平滑很多。#define LCD_WIDTH 480 #define LCD_HEIGHT 272 #define DRAW_BUF_LINES 10 static lv_color_t buf1[LCD_WIDTH * DRAW_BUF_LINES]; static lv_color_t buf2[LCD_WIDTH * DRAW_BUF_LINES]; void ui_init(void) { lv_init(); lv_disp_draw_buf_init(disp_buf, buf1, buf2, LCD_WIDTH * DRAW_BUF_LINES); lv_disp_drv_init(disp_drv); disp_drv.draw_buf disp_buf; disp_drv.flush_cb disp_flush_cb; disp_drv.hor_res LCD_WIDTH; disp_drv.ver_res LCD_HEIGHT; lv_disp_drv_register(disp_drv); }如果你的MCU内部RAM不够放buf1和buf2可以用外部PSRAM或者SDRAMLVGL的draw buffer放在外部内存是完全没问题的只是功耗和带宽上要权衡。5.3 动画卡顿与刷新问题先按这个顺序排查菜单切换动画掉帧、滑动不跟手这是真机上最常见的性能问题。我总结了一个排查顺序照着走一般能定位到根因。先看draw buffer是不是太小。如果每次刷新只配了2到3行缓冲渲染和flush交替太频繁性能一定上不去尤其是有大面积颜色渐变的页面。把行数调到10行以上再测很多时候卡顿直接消失。再看flush回调里有没有做不必要的等待。很多屏幕驱动是在flush里等待上一次DMA传输完成才返回这会导致LVGL的渲染流水线卡住。正确的做法是在flush里启动DMA之后立刻返回在DMA完成中断里调用lv_disp_flush_ready通知LVGL可以继续渲染下一块。如果屏幕驱动不支持中断就在flush里用lv_disp_flush_is_last判断最后一块再同步等待至少能减少大部分阻塞。第三看动画期间是否有大对象频繁重绘。菜单页面切换动画里我建议旧页面不要做全屏透明度的fade动画因为透明度变化会强制LVGL把新旧页面做混合渲染计算量比单纯的位移翻倍。位移动画只需要移动对象坐标大部分芯片都能扛住fade动画在低端MCU上几乎必卡。最后检查编译优化等级。这是最容易被忽略的一点。Keil里如果优化等级是-O0LVGL的渲染性能会差好几倍。把优化等级开到-O2或者-O3很多“代码没问题但卡得要死”的问题能直接解决。代价是调试时变量查看可能不准可以日常用-O1发布时再开到-O2。这套菜单框架我在好几个项目里用过最大的感受是把菜单的数据、页面、交互三者拆开之后后续维护和迭代的舒适度完全不一样。产品经理提需求说“这里要加一个页面”我只需要往menu_model.c里加一条数组数据说“返回逻辑改一下”我只需要动栈处理说“这个页面换一种布局”我只需要改对应页面的创建函数。如果你正准备开始写LVGL菜单建议直接照这个结构搭前期多花半小时建模后面能省下好几个小时的返工。
返回列表