1. 背景与核心概念很多嵌入式开发者在入门 ESP32 LVGL 时最先接触的是lv_label、lv_btn这些控件。但一旦界面里的元素超过三五个就会发现一个非常现实的问题控件的位置全部要靠手动计算坐标。在 LVGL 的早期使用阶段我们通常是这样的写法lv_obj_t *btn1 lv_btn_create(lv_scr_act()); lv_obj_set_pos(btn1, 20, 30); lv_obj_t *btn2 lv_btn_create(lv_scr_act()); lv_obj_set_pos(btn2, 20, 90);如果是两个三个按钮这种写法还可以接受。但实际项目里一个页面通常有状态栏、数据区域、控制区域、底栏导航控件数量少则十几个多则几十个。此时靠lv_obj_set_pos逐个摆放坐标会面临以下问题屏幕尺寸调整后所有坐标都要重新计算。增加一个控件后面的控件位置全部受影响。不同分辨率屏幕的适配极其痛苦。代码里全是“魔法数字”后期维护成本高。LVGL 其实很早就提供了解决这类问题的机制也就是本文要讲的容器Container与布局Layout。1.1 什么是 LVGL 容器LVGL 中所有控件本质上都继承自lv_obj基础对象。而“容器”并不是一个特殊的控件类型它其实就是一个普通的lv_obj对象只不过我们把其他子控件放进它里面让它承担“容器”的角色。最常见的创建方式lv_obj_t *cont lv_obj_create(lv_scr_act());创建出来之后你可以设置它的大小、背景颜色、圆角、边框、内边距等。它最核心的能力是作为一个父对象承载子控件并且通过布局规则自动安排子控件的位置。如果把整个屏幕比作一块画板那么容器就是画板上的一个个“相框”。你不需要精确告诉相框里的照片放在相框的哪个像素点只需要告诉它“照片要横着排间距 10 像素”剩下的由布局引擎来完成。1.2 什么是 LVGL 布局布局Layout是 LVGL 提供的一套自动排列子控件的规则。它解决的问题是在容器内部子控件如何排列、如何对齐、间距多大。LVGL 把布局分为两大类型布局类型含义适用场景Flex 布局子控件沿一个方向依次排列可自动换行列表、工具栏、状态栏、菜单Grid 布局子控件按行和列的网格排列仪表盘、键盘、参数表格这两种布局方式在不同的 GUI 框架中都有对应概念比如 Flutter 的 Flex 和 Grid、QML 的 Row/Column 和 GridView、网页 CSS 的 Flexbox 和 Grid。如果你有这些框架的使用经验理解 LVGL 的布局会非常快。1.3 为什么嵌入式 UI 需要布局嵌入式设备屏幕通常较小分辨率常见的有 240×320、320×480、480×800 等。小屏幕意味着可用空间寸土寸金而产品需求又往往要求同一个界面适配不同屏幕尺寸、字体大小或语言文案长度。如果在代码里硬编码坐标当语言从中文切换到英文后同一段文字宽度会变长原本排列整齐的界面可能就会重叠。而使用布局之后子控件会根据内容自适应换行、对齐和重新排布大大降低了这类问题的发生概率。可以说容器 布局是 LVGL 从“能用”走向“好用”的关键一步。本文将基于 ESP32 实际开发环境从创建容器、布局 API、Flex、Grid、嵌套滚动、内存优化、常见坑点这几个维度完整讲解 LVGL 容器与布局的用法。2. 环境准备与版本说明在设计一个正式案例之前先把开发环境梳理清楚。2.1 硬件环境本文演示使用的硬件以常见的 ESP32-WROOM-32 系列模组为例主控芯片ESP32-D0WD双核 240MHz屏幕常见 SPI 接口 IPS 屏分辨率 320×240驱动 IC 为 ST7789触摸电容式触摸可选本文示例不依赖触摸内存外部 PSRAM 建议选配对复杂界面渲染有帮助具体芯片型号、屏幕驱动请以你手头的开发板为准。LVGL 是平台无关的图形库换用 ESP32-S3或者换成 ILI9341 驱动芯片代码逻辑基本不变只需要修改底层的lv_port_disp适配代码。2.2 软件环境LVGL 在 ESP32 上主要有两种集成方式ESP-IDF 原生组件方式和 Arduino 框架方式。如果你使用ESP-IDF v5.x可以通过 IDF 组件管理器直接添加 LVGL 依赖。如果你使用Arduino IDE 或 PlatformIO Arduino 框架可以通过库管理器安装lvgl库。本文代码示例以PlatformIO Arduino 框架为例这也是很多 ESP32 LVGL 新手最常用的组合。核心软件版本组件版本建议PlatformIO Core6.1.x 及以上Arduino-ESP32 内核2.0.17 或 3.x推荐使用 2.0.x 稳定版LVGL8.3.x兼容性最好TFT_eSPI8.5.xSPI 屏驱动库注意LVGL 9.x 已经发布部分 API 命名与 8.x 不同。本文所有代码基于 LVGL 8.3.x 编写。如果你使用 LVGL 9.x需要注意lv_obj_set_layout等 API 的差异但本文的核心思想和 API 设计逻辑仍然通用。2.3 工程结构建议按以下结构组织工程文件project/ ├── platformio.ini ├── include/ │ └── lv_conf.h ├── lib/ │ ├── lvgl/ │ └── TFT_eSPI/ └── src/ └── main.cpplv_conf.h是 LVGL 的核心配置文件需要从lvgl库中复制出来并取消对应宏的注释。下面这些宏和本次案例相关#define LV_COLOR_DEPTH 16 #define LV_MEM_CUSTOM 1 #define LV_USE_FLEX 1 #define LV_USE_GRID 1 #define LV_USE_FONT_MONTSERRAT_14 1 #define LV_FONT_MONTSERRAT_14 1其中LV_USE_FLEX和LV_USE_GRID特别重要如果这两个宏为 0编译时会直接报错找不到 flex 相关的函数。2.4 显示驱动初始化在开始容器布局之前先确保屏幕能够正常点亮。以 TFT_eSPI 为例初始化流程如下// 文件路径src/main.cpp #include lvgl.h #include TFT_eSPI.h TFT_eSPI tft TFT_eSPI(); static lv_disp_draw_buf_t draw_buf; static lv_color_t buf[TFT_WIDTH * 10]; void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) { uint32_t w (area-x2 - area-x1 1); uint32_t h (area-y2 - area-y1 1); tft.startWrite(); tft.setAddrWindow(area-x1, area-y1, w, h); tft.pushColors((uint16_t *)color_p-full, w * h, true); tft.endWrite(); lv_disp_flush_ready(disp); } void lvgl_init() { lv_init(); lv_disp_draw_buf_init(draw_buf, buf, NULL, TFT_WIDTH * 10); static lv_disp_drv_t disp_drv; lv_disp_drv_init(disp_drv); disp_drv.hor_res TFT_WIDTH; disp_drv.ver_res TFT_HEIGHT; disp_drv.flush_cb my_disp_flush; disp_drv.draw_buf draw_buf; lv_disp_drv_register(disp_drv); } void setup() { Serial.begin(115200); tft.begin(); tft.setRotation(1); lvgl_init(); ui_demo(); // 业务界面代码稍后实现 } void loop() { lv_timer_handler(); delay(5); }这段代码是 LVGL 跑起来的基础。后面的所有容器布局案例都是在ui_demo()函数中完成的。3. 核心配置与原理拆解在编写完整案例之前先讲解容器和布局的核心 API。理解这些 API才能在实际项目中灵活组合。3.1 创建容器lv_obj_t *lv_obj_create(lv_obj_t *parent);参数parent是父对象。如果传入NULL会创建在默认屏幕上等价于传入lv_scr_act()。容器创建后默认自带一些样式和区域属性。创建一个带背景的容器示例lv_obj_t *cont lv_obj_create(lv_scr_act()); lv_obj_set_size(cont, 280, 180); lv_obj_center(cont); lv_obj_set_style_bg_color(cont, lv_color_hex(0x1E1E2E), LV_PART_MAIN); lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(cont, 10, LV_PART_MAIN); lv_obj_set_style_pad_all(cont, 10, LV_PART_MAIN);这里涉及的几个样式属性后面会用到LV_PART_MAIN对象的主区域。lv_obj_set_style_bg_color设置背景色。lv_obj_set_style_radius设置圆角。lv_obj_set_style_pad_all设置四周内边距即子控件与容器边缘的距离。容器的核心特性在于所有子控件的位置会受容器内边距和布局规则影响。3.2 容器内边距与子控件的关系容器创建之后即使不设置任何布局子控件默认也是放在容器的左上角内边距位置。比如lv_obj_t *label lv_label_create(cont); lv_label_set_text(label, Hello);这个 label 会出现在容器左上角距离容器顶边和左边都是pad_all指定的距离。如果你在一个容器里创建多个子控件而不设置布局它们会重叠在一起因为 LVGL 默认不会自动移动现有子控件的位置。这也是很多新手容易踩的坑容器不是一个自动流式布局盒默认情况下它只是一个普通对象。3.3 布局 API 总览LVGL 8.3 提供的布局相关核心 APIAPI 函数作用lv_obj_set_layout(obj, LV_LAYOUT_FLEX)将容器设置为 Flex 布局lv_obj_set_layout(obj, LV_LAYOUT_GRID)将容器设置为 Grid 布局lv_obj_set_layout(obj, LV_LAYOUT_NONE)取消布局使用绝对坐标lv_obj_set_flex_flow(obj, flex_flow)设置 Flex 排列方向lv_obj_set_flex_align(obj, main_place, cross_place, track_cross_place)设置 Flex 对齐方式lv_obj_set_flex_grow(obj, grow)设置子控件在 Flex 中的放大比例lv_obj_set_grid_dsc_array(obj, col_dsc, row_dsc)设置 Grid 的列宽和行高描述lv_obj_set_grid_cell(obj, column_align, column_pos, row_align, row_pos)设置子控件在 Grid 中的位置设置布局时要注意一个逻辑布局是“父对象”的属性不是子对象的属性。也就是说你要在容器上调用lv_obj_set_layout而不是在子控件上调用。子控件只需要负责“我要放在第几列第几行”或“我要占多少比例”。3.4 Flex 布局的参数细节Flex 布局也叫弹性布局。核心思想是让子控件沿着“主轴”方向排队主轴方向可以由用户指定。lv_obj_set_flex_flow(cont, LV_FLEX_ROW);LV_FLEX_ROW表示主轴为水平方向子控件从左到右排列。常用的 flow 值Flex 方向值排列方向LV_FLEX_ROW水平从左到右LV_FLEX_ROW_REVERSE水平从右到左LV_FLEX_COLUMN垂直从上到下LV_FLEX_COLUMN_REVERSE垂直从下到上LV_FLEX_ROW_WRAP水平超出自动换行LV_FLEX_COLUMN_WRAP垂直超出自动换列对齐方式则通过lv_obj_set_flex_align设置lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);三个参数分别表示main_place主轴方向的对齐方式。例如主轴是水平那么控制水平方向的排列起始位置。cross_place交叉轴方向的对齐方式。例如主轴是水平交叉轴就是垂直方向。track_cross_place多行多轨道时的对齐方式。常用的对齐值有LV_FLEX_ALIGN_START起点对齐LV_FLEX_ALIGN_END终点对齐LV_FLEX_ALIGN_CENTER居中对齐LV_FLEX_ALIGN_SPACE_EVENLY均匀分布首尾也有间距LV_FLEX_ALIGN_SPACE_AROUND均匀分布首尾间距是中间的一半LV_FLEX_ALIGN_SPACE_BETWEEN两端对齐中间均匀分布lv_obj_set_flex_grow是个比较特殊的函数它的作用是让子控件“抢占”剩余空间。比如水平布局下第一个 label 内容宽度是 50px容器宽度是 200px给第二个按钮设置flex_grow 1那么第二个按钮会填满剩下的 150px 空间。这个特性在做自适应的表单控件时非常实用。3.5 Grid 布局的参数细节Grid 布局把容器划分成一张“表格”。表格的列宽和行高由lv_obj_set_grid_dsc_array决定。static lv_coord_t col_dsc[] {80, LV_GRID_FR(1), 80, LV_GRID_TEMPLATE_LAST}; static lv_coord_t row_dsc[] {40, 40, LV_GRID_TEMPLATE_LAST}; lv_obj_set_grid_dsc_array(cont, col_dsc, row_dsc);这里需要理解几个关键概念col_dsc数组的每一个元素表示一列的宽度单位是像素。row_dsc数组的每一个元素表示一行的高度。LV_GRID_FR(1)表示弹性比例单位fraction unit它会按比例瓜分剩余空间。数组必须以LV_GRID_TEMPLATE_LAST结尾。子控件放入网格lv_obj_set_grid_cell(btn, LV_GRID_ALIGN_STRETCH, 0, LV_GRID_ALIGN_STRETCH, 0);参数含义分别是列对齐方式通常用LV_GRID_ALIGN_STRETCH拉伸填满。列索引从 0 开始。行对齐方式通常也用LV_GRID_ALIGN_STRETCH。行索引从 0 开始。LV_GRID_ALIGN_STRETCH的作用是让控件自动拉伸到所在单元格的完整尺寸适合做按钮、卡片等需要填满单元格的场景。3.6 容器透明与滚动默认创建的容器会有一层灰色背景和边框在设计上层界面时经常需要把容器设置为“透明容器”只作为布局容器使用。方法如下lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(cont, 0, 0); lv_obj_set_style_pad_all(cont, 0, 0);至于滚动LVGL 的容器默认是允许滚动的。如果子控件内容超出了容器大小LVGL 会自动允许滚动。滚动相关的设置项lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_OFF); // 隐藏滚动条 lv_obj_set_scroll_dir(cont, LV_DIR_VER); // 只允许垂直滚动在很多数据展示页面中关闭滚动条会让界面更干净但这不影响实际滚动功能。4. 完整实战案例制作嵌入式监控仪表盘下面通过一个实际项目来串联前面讲的所有知识点。这个案例的场景是在 ESP32 开发板上实现一个 320×240 分辨率的简易环境监控仪表盘界面。界面分为三个区域顶部状态栏显示标题和连接状态使用 Flex 行布局。中部数据区以网格形式展示温度、湿度、空气质量三项数据使用 Grid 布局。底部控制区两个按钮和一个提示标签使用 Flex 行布局。这个界面拥有真实项目中最常见的布局需求非常适合用来练手。4.1 创建页面整体结构// 文件路径src/main.cpp // 在 lvgl_init() 之后调用 ui_demo() static void ui_demo() { // 根容器铺满整个屏幕垂直方向排列 lv_obj_t *root lv_obj_create(lv_scr_act()); lv_obj_set_size(root, 320, 240); lv_obj_set_pos(root, 0, 0); lv_obj_set_style_bg_color(root, lv_color_hex(0x16161E), LV_PART_MAIN); lv_obj_set_style_bg_opa(root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_all(root, 0, LV_PART_MAIN); lv_obj_set_style_border_width(root, 0, LV_PART_MAIN); // 根容器使用 Flex 列布局从上到下排列三个子容器 lv_obj_set_layout(root, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(root, LV_FLEX_COLUMN); lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(root, 8, LV_PART_MAIN); // 在根容器中创建三个区域 lv_obj_t *header create_header(root); lv_obj_t *data_grid create_data_grid(root); lv_obj_t *footer create_footer(root); }这里有一个很关键的技巧整个页面本身也可以是一个容器然后设置成 Flex 列布局。这样页面从上到下自动排列三个区域即使将来要从三个区域改成四个区域也只需要再创建一个容器加进来不需要调整任何人的坐标。lv_obj_set_style_pad_row是 Flex 列布局下的专属属性它控制行与行之间的间距实际项目中非常常用。4.2 顶部状态栏Flex 行布局static lv_obj_t *create_header(lv_obj_t *parent) { // 状态栏容器宽度占满高度 40 lv_obj_t *header lv_obj_create(parent); lv_obj_set_width(header, lv_pct(100)); lv_obj_set_height(header, 40); lv_obj_set_style_bg_color(header, lv_color_hex(0x2A2A3C), LV_PART_MAIN); lv_obj_set_style_radius(header, 8, LV_PART_MAIN); lv_obj_set_style_pad_left(header, 12, LV_PART_MAIN); lv_obj_set_style_pad_right(header, 12, LV_PART_MAIN); lv_obj_set_style_pad_top(header, 0, LV_PART_MAIN); lv_obj_set_style_pad_bottom(header, 0, LV_PART_MAIN); lv_obj_set_style_border_width(header, 0, LV_PART_MAIN); // 状态栏内部使用 Flex 行布局 lv_obj_set_layout(header, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(header, LV_FLEX_ROW); lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // 左侧标题 lv_obj_t *title lv_label_create(header); lv_label_set_text(title, Device Monitor); lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_text_font(title, lv_font_montserrat_14, LV_PART_MAIN); // 右侧状态标签 lv_obj_t *status lv_label_create(header); lv_label_set_text(status, ONLINE); lv_obj_set_style_text_color(status, lv_color_hex(0x4CD964), LV_PART_MAIN); lv_obj_set_style_text_font(status, lv_font_montserrat_14, LV_PART_MAIN); return header; }这段代码中值得注意的点是lv_obj_set_width(header, lv_pct(100))。lv_pct(100)表示父容器宽度的 100%这样做的好处是当父容器宽度变化时子容器会自动适应不需要重新计算像素值。在 Flex 行布局中我把main_place设置为LV_FLEX_ALIGN_SPACE_BETWEEN。这样标题在左、状态在右中间自动留出间距完美实现了一个两端对齐的状态栏。4.3 中部数据区Grid 布局static lv_obj_t *create_data_grid(lv_obj_t *parent) { // 数据区容器 lv_obj_t *grid lv_obj_create(parent); lv_obj_set_width(grid, lv_pct(100)); lv_obj_set_height(grid, 130); lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(grid, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(grid, 4, LV_PART_MAIN); // 定义 3 列每列弹性比例 1 static lv_coord_t col_dsc[] {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST}; // 定义 1 行高度占满 static lv_coord_t row_dsc[] {LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST}; lv_obj_set_grid_dsc_array(grid, col_dsc, row_dsc); lv_obj_set_layout(grid, LV_LAYOUT_GRID); // 依次创建三张数据卡片放入第 0 列、第 1 列、第 2 列行都是第 0 行 create_metric_card(grid, 0, 0, TEMP, 26.5, °C); create_metric_card(grid, 0, 1, HUMI, 58.2, %); create_metric_card(grid, 0, 2, PM2.5, 12, μg/m³); return grid; } static void create_metric_card(lv_obj_t *parent, lv_coord_t row, lv_coord_t col, const char *name, const char *value, const char *unit) { // 卡片容器 lv_obj_t *card lv_obj_create(parent); lv_obj_set_style_bg_color(card, lv_color_hex(0x2A2A3C), LV_PART_MAIN); lv_obj_set_style_radius(card, 10, LV_PART_MAIN); lv_obj_set_style_border_width(card, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 8, LV_PART_MAIN); // 卡片内部使用 Flex 列布局 lv_obj_set_layout(card, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(card, LV_FLEX_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // 指标名称 lv_obj_t *name_label lv_label_create(card); lv_label_set_text(name_label, name); lv_obj_set_style_text_color(name_label, lv_color_hex(0x8A8A9E), LV_PART_MAIN); lv_obj_set_style_text_font(name_label, lv_font_montserrat_14, LV_PART_MAIN); // 数值和单位 lv_obj_t *value_label lv_label_create(card); lv_label_set_text_fmt(value_label, %s %s, value, unit); lv_obj_set_style_text_color(value_label, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_text_font(value_label, lv_font_montserrat_14, LV_PART_MAIN); // 将卡片放入 Grid 的指定单元格 lv_obj_set_grid_cell(card, LV_GRID_ALIGN_STRETCH, col, LV_GRID_ALIGN_STRETCH, row); }Grid 布局的核心在于“描述表格”和“放入单元格”两步。LV_GRID_FR(1)让三列等分父容器宽度这样无论屏幕宽度是 240、320 还是 480三张卡片的宽度都会自动适配。特别注意create_metric_card的参数顺序我定义的是(parent, row, col, ...)而在调用lv_obj_set_grid_cell时顺序是(obj, col_align, col, row_align, row)。如果这里传反了控件就会错位。建议在自己封装函数时统一参数顺序并写清楚是“先列后行”。4.4 底部控制区Flex 与滚动static lv_obj_t *create_footer(lv_obj_t *parent) { // 底部容器宽度占满高度 50 lv_obj_t *footer lv_obj_create(parent); lv_obj_set_width(footer, lv_pct(100)); lv_obj_set_height(footer, 50); lv_obj_set_style_bg_opa(footer, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(footer, 4, LV_PART_MAIN); // 底部使用 Flex 行布局 lv_obj_set_layout(footer, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(footer, LV_FLEX_ROW); lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // 两个按钮设置 flex_grow 让它们均分剩余空间 lv_obj_t *btn1 lv_btn_create(footer); lv_obj_set_height(btn1, 40); lv_obj_set_flex_grow(btn1, 1); lv_obj_t *btn1_label lv_label_create(btn1); lv_label_set_text(btn1_label, Refresh); lv_obj_center(btn1_label); lv_obj_t *btn2 lv_btn_create(footer); lv_obj_set_height(btn2, 40); lv_obj_set_flex_grow(btn2, 1); lv_obj_t *btn2_label lv_label_create(btn2); lv_label_set_text(btn2_label, Config); lv_obj_center(btn2_label); return footer; }这里的亮点是用lv_obj_set_flex_grow实现按钮均分宽度。两个按钮的flex_grow都是 1最终它们会各占剩余空间的一半而且中间自动间隔不需要手动计算。4.5 给数据卡片添加刷新逻辑布局写完后可以再模拟一个数据刷新的场景。创建一个定时器每隔 2 秒更新数据卡片的数值。static lv_obj_t *temp_value_label; static lv_obj_t *humi_value_label; static lv_obj_t *pm25_value_label; static void timer_cb(lv_timer_t *timer) { // 模拟传感器数据变化 float temp 26.0 (rand() % 20) / 10.0; float humi 55.0 (rand() % 60) / 10.0; int pm25 10 rand() % 30; lv_label_set_text_fmt(temp_value_label, %.1f °C, temp); lv_label_set_text_fmt(humi_value_label, %.1f %%, humi); lv_label_set_text_fmt(pm25_value_label, %d μg/m³, pm25); } static void ui_demo() { // ... 前面的创建代码保持不变 ... // 把需要更新的 label 保存到全局变量 // 在创建卡片时把 value_label 赋值给全局变量 // 创建定时器每 2 秒更新一次 lv_timer_create(timer_cb, 2000, NULL); }这种“布局一次数据更新只改 label 文本”的做法是嵌入式 GUI 开发的标准套路。因为布局结构已经由容器和布局规则确定数据刷新时完全不需要关心控件坐标。4.6 容器嵌套与复杂滚动页面实际产品中一个页面的数据可能不止三张卡片。比如我们要做一个历史记录列表可能有十几条数据。如果全部显示小屏幕放不下此时就需要容器嵌套 滚动。// 外层页面容器Flex 列布局 lv_obj_t *page lv_obj_create(lv_scr_act()); lv_obj_set_size(page, 320, 240); lv_obj_set_style_pad_all(page, 0, LV_PART_MAIN); lv_obj_set_layout(page, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(page, LV_FLEX_COLUMN); // 标题固定不滚动 lv_obj_t *title lv_label_create(page); lv_label_set_text(title, History Records); lv_obj_set_height(title, 30); // 中间滚动容器占有剩余空间 lv_obj_t *scroll_cont lv_obj_create(page); lv_obj_set_width(scroll_cont, lv_pct(100)); lv_obj_set_flex_grow(scroll_cont, 1); lv_obj_set_style_bg_opa(scroll_cont, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(scroll_cont, 0, LV_PART_MAIN); lv_obj_set_scrollbar_mode(scroll_cont, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_scroll_dir(scroll_cont, LV_DIR_VER); // 滚动容器内部使用 Flex 列布局每一条记录是一个子对象 lv_obj_set_layout(scroll_cont, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(scroll_cont, LV_FLEX_COLUMN); lv_obj_set_style_pad_row(scroll_cont, 6, LV_PART_MAIN); for (int i 0; i 20; i) { lv_obj_t *item lv_obj_create(scroll_cont); lv_obj_set_width(item, lv_pct(100)); lv_obj_set_height(item, 36); lv_obj_set_style_bg_color(item, lv_color_hex(0x2A2A3C), LV_PART_MAIN); lv_obj_set_style_radius(item, 6, LV_PART_MAIN); lv_obj_set_style_border_width(item, 0, LV_PART_MAIN); lv_obj_t *item_label lv_label_create(item); lv_label_set_text_fmt(item_label, Record #%d, i 1); lv_obj_center(item_label); }这里的关键点有两个外层页面和滚动容器都是 Flex 列布局。滚动容器设置flex_grow 1表示它占满标题下方剩余的所有空间。这样不管屏幕是 240 高还是 480 高滚动区域都能自动适配。滚动容器的子项也是通过 flex 排列的。当子项数量超过容器高度时LVGL 自动启用滚动不需要额外处理坐标。如果你需要在滚动容器里放很多动态数据注意lv_obj_set_flex_grow和固定高度不能同时乱用否则子项可能不按预期排列。5. 常见问题与排查思路LVGL 的容器和布局使用门槛不高但在实际项目中容易出现各种“看起来没生效”的问题。下面整理几个高频问题。问题现象常见原因解决思路设置了布局但子控件没有变化父对象没有调用lv_obj_set_layout确认布局设置在父容器上而不是子控件容器内子控件重叠没有设置布局或布局类型设置失败检查LV_USE_FLEX、LV_USE_GRID宏是否开启Flex 换行不生效没使用LV_FLEX_ROW_WRAP或LV_FLEX_COLUMN_WRAP换行需要使用带WRAP后缀的 flow 模式Grid 中控件跑到错误位置行列索引传反检查lv_obj_set_grid_cell参数顺序列在行前容器背景把按钮盖住未设置背景透明使用lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0)滚动不生效或内容不能滚动容器未设置滚动方向或子项未超出范围设置lv_obj_set_scroll_dir并确认子项总高度大于容器布局后按钮宽度异常flex_grow与 padding 冲突检查容器pad_all和子项 margin 设置控件位置错乱且出现段错误LVGL 内存不足或 lv_conf.h 未正确配置检查LV_MEM_SIZE必要时开启 PSRAM下面挑几个重点展开说明。5.1 设置了布局但没有任何效果这种情况最常见的原因是lv_conf.h中没有打开 Flex 和 Grid 开关。LVGL 为了节省内存默认会把很多特性开关关闭。如果LV_USE_FLEX为 0调用lv_obj_set_layout(cont, LV_LAYOUT_FLEX)时LVGL 内部的布局函数实际上是空操作界面自然不会变化。排查步骤在lv_conf.h中搜索LV_USE_FLEX确认值为 1。搜索LV_USE_GRID确认值为 1。重新编译并烧录。这样基础的开关问题通常 1 分钟就能定位。5.2 子控件全部重叠在左上角如果容器已经调用了lv_obj_set_layout但子控件仍然叠在左上角需要看一下是不是布局函数的调用顺序出了问题。正确的顺序是lv_obj_t *cont lv_obj_create(parent); lv_obj_set_layout(cont, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(cont, LV_FLEX_ROW); lv_obj_t *child1 lv_label_create(cont); lv_obj_t *child2 lv_label_create(cont);注意创建子控件之前或者之后设置布局都可以但要在lv_timer_handler开始运行之前完成。如果在界面显示一段时间后再改布局LVGL 会重新计算但可能出现闪烁。另外如果子控件是手动设置过lv_obj_set_pos的布局系统默认不会覆盖用户手动设置的位置需要先把子控件的位置重置一次lv_obj_set_pos(child, 0, 0);5.3 使用lv_pct后控件大小异常lv_pct(100)表示父容器比例 100%但这个百分比是相对于父对象的内容区而言的不是父对象的整个边框区域。举个例子父容器宽度 200pxpad_left 10pad_right 10那么子控件设置lv_pct(100)时实际宽度是 200 - 10 - 10 180px。如果子控件嵌套多层每一层都设置 100%最终宽度会逐层缩小。排查这类问题时可以先把父容器的 padding 临时设为 0看看大小是否符合预期。5.4 滚动容器里内容被截断且不滚动这种问题一般出现在 Flex 布局中。滚动容器本身高度是固定的但容器里的子项总高度超过了容器高度此时 LVGL 默认是允许滚动的。如果发现不滚动检查三件事滚动方向是否设置正确lv_obj_set_scroll_dir(cont, LV_DIR_VER)。滚动条是否被隐藏了让你误以为不能滚动。外层父容器是否拦截了滚动事件。在实际项目中滚动容器外面如果还有一层 Flex 容器且外层容器高度不够会导致内容在外层就被截断了。可以在调试时给每层容器设置不同的背景色逐层确认高度占用情况。5.5 布局与动画配合时的卡顿问题在 ESP32 这样的嵌入式设备上布局计算本身不耗时但频繁修改布局相关属性会触发子对象重绘消耗比较多 CPU。如果界面上有动画效果又要频繁更新布局需要把动画与布局分开处理。更常见的做法是布局只负责初始排列动态变化时只修改子控件的文本、颜色、透明度和位置不修改容器本身的布局规则。例如数据刷新只改 label 文本不用重新调用布局函数。6. 最佳实践与工程建议6.1 规划好容器层级容器布局虽然在代码层面只是嵌套关系但在设计阶段一定要先画清楚层级图。比如下面的设计思路屏幕根容器Flex 列顶部状态栏Flex 行内容区Grid 两列三行底部导航栏Flex 行画清楚层级之后代码自然就是一层一层的lv_obj_createlv_obj_set_layout阅读和维护都更轻松。一个比较实用的建议是把每个容器封装成一个独立函数。比如create_header、create_data_grid、create_footer。这样主流程看起来一目了然。6.2 尽量使用百分比和弹性单位在嵌入式开发中不同批次的产品可能使用不同分辨率的屏幕。如果代码里写死像素坐标换一个屏幕可能就要重写整个界面。建议遵循以下优先级优先使用lv_pct()百分比表示容器宽度。多列等分时优先使用LV_GRID_FR()弹性单位。Flex 中希望子项填满剩余空间时优先使用lv_obj_set_flex_grow。只有固定不变的元素才使用像素值比如按钮高度、图标大小。6.3 注意 LVGL 内存占用LVGL 在运行时需要为每个对象分配内存。ESP32 的内部 SRAM 有限默认的 LVGL 内存池通常只有 8KB 到 32KB。如果创建了大量容器和子控件很容易内存不足。几个建议打开 PSRAM 支持LVGL 的 draw buffer 可以放到 PSRAM 中。在lv_conf.h中合理设置LV_MEM_SIZE。及时销毁不再使用的对象lv_obj_del(obj)。尽量复用对象而不是反复创建删除。如果出现绘制异常或段错误可以先检查一下内存剩余情况Serial.printf(LVGL mem free: %d bytes\r\n, lv_mem_get_free());6.4 设计数据与界面分离容器布局本身只是界面框架真正实用的项目需要把数据获取和界面刷新解耦。例如传感器数据采集放在独立任务中通过队列或全局变量更新。UI 层只负责读取最新数据并调用lv_label_set_text_fmt更新显示。布局结构只在初始化时创建一次不随数据刷新而重建。这样可以避免在 UI 线程中做耗时操作保证界面流畅。6.5 使用语义化命名给容器和控件起名字时避免cont1、cont2、label1这种无意义命名。建议使用功能相关的名字比如header、data_grid、temp_card、refresh_btn。这对团队协作和后续维护非常重要。6.6 配置文件的维护lv_conf.h是 LVGL 的全局配置核心建议在项目早期就确定配置并纳入版本管理。升级 LVGL 版本时lv_conf.h中的宏可能发生变化需要逐一核对官方升级文档。如果采用 PlatformIO可以在platformio.ini中通过 build flag 动态覆盖部分宏但更推荐直接维护一份完整的lv_conf.h方便查找和调整。[env:esp32dev] platform espressif32 board esp32dev framework arduino lib_deps lvgl/lvgl^8.3.11 bodmer/TFT_eSPI^8.5.7 build_flags -DLV_CONF_INCLUDE_SIMPLE -Iinclude7. 总结与学习路线本文从概念入手完整讲解了 LVGL 容器与布局的核心 API并通过一个 ESP32 环境监控仪表盘案例演示了 Flex 布局、Grid 布局、容器嵌套和滚动场景的用法。回顾全文需要掌握的关键点包括容器本质上是一个普通lv_obj布局是容器上的一项属性。Flex 布局适合线性排列Grid 布局适合网格排列。LV_GRID_FR()和lv_pct()是实现自适应的关键。lv_obj_set_flex_grow能实现按钮、卡片等控件的自适应均分。滚动容器是嵌入式小屏设备展示长列表的核心手段。lv_conf.h中的LV_USE_FLEX和LV_USE_GRID必须开启。接下来可以继续学习的方向包括LVGL 样式与主题系统、动画实现、事件处理与回调逻辑、中文字体接入与 UTF-8 解码、多页面切换框架设计以及 LVGL 9.x 的新特性迁移。在实际项目中建议先从本文的仪表盘案例开始动手修改把“状态栏 数据区 底栏”这个模板理解透彻再慢慢扩展成自己的复杂界面。如果你在移植或开发过程中遇到问题欢迎在评论区留言交流。