ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ESP32+LVGL动画实战:从基础原理到完整示例

2026/8/31 4:33:30 拓冰建站 浏览量
ESP32+LVGL动画实战:从基础原理到完整示例 在嵌入式 GUI 开发中LVGL 已经成为很多开发者绕不开的图形库。最近在给一块 ESP32 驱动的小屏做交互界面时我需要让画面“动”起来菜单切换、加载进度、通知弹窗、状态指示灯。如果全部靠手动重绘不仅代码量爆炸刷新抖动也很难看。LVGL 把动画抽象成了定时器 插值机制只需配置几个参数就能完成平移动画、透明度动画、缓冲曲线等效果。本文围绕“ESP32 LVGL 动画”展开先介绍 LVGL 动画的基础概念再从环境搭建讲起最后给出一个完整的 ESP32 动画示例包含小球回弹、圆弧进度、文字呼吸等效果。如果你刚开始接触嵌入式 UI或者准备在自己的项目上加入动画可以参考这套流程。1. LVGL 动画是什么为什么要有动画1.1 LVGL 在嵌入式 UI 中的角色LVGL 全称 LittlevGL / Light and Versatile Graphics Library是一个专门为嵌入式系统设计的轻量级图形库。和 PC 上的 Qt、Flutter 不同LVGL 运行在内存只有几百 KB、CPU 主频只有几十到几百 MHz 的 MCU 上因此它不能把整个页面当成一张位图去重绘而是基于“对象 局部刷新”的思路来绘制界面。一个 LVGL 界面由很多lv_obj_t对象组成例如标签、按钮、圆弧、列表、图表等。每个对象都有自己的位置、大小、样式、事件回调。我们做的“界面开发”本质上是创建这些对象并控制它们在合适的时间显示、隐藏、移动或改变外观。1.2 动画解决的核心痛点嵌入式屏幕界面很容易显得“死板”。如果按钮按下后没有任何反馈用户会怀疑触控有没有生效如果进度条直接从 0% 跳到 100%用户会觉得数据加载是假的如果菜单切换是瞬间闪现人眼需要额外时间理解画面关系。动画要解决的就是对象属性从 A 值平滑变化到 B 值的过程。LVGL 官方把动画描述为在指定时间内按照某种插值曲线定时修改对象的某个属性。动画本质上是一个定时器驱动的状态机不需要开发者手动管理每一帧也不需要为每个对象单独写刷新逻辑。1.3 常见动画应用场景LVGL 动画在嵌入式项目中的典型场景包括页面切换过渡菜单从右侧滑入图片淡入淡出。仪表指针转动车速、温度、湿度等数值变化时指针平滑偏转。加载动画进度条、加载圈、弧形进度指示器。状态提示WiFi 断开闪烁、通知弹窗从底部升起。交互反馈按钮按下时缩放、变暗、弹出一个气泡。这些效果不仅提升观感还能给用户明确的系统状态反馈。尤其是在小尺寸触摸屏上动画是弥补交互反馈不足的重要手段。2. 环境准备ESP32 LVGL 工程怎么搭2.1 硬件选型建议编写本示例时我使用的是 ESP32 开发板 SPI 接口的 TFT 彩屏。ESP32 芯片自带大量 GPIO 和内存资源用 LVGL 做中型界面并不吃力。常见驱动芯片有ST7789常见于 1.3 寸、1.54 寸、1.69 寸屏幕。ILI9341常见于 2.4 寸、3.5 寸屏幕。ST7735常见于 0.96 寸、1.8 寸屏幕。本文代码使用 TFT_eSPI 库驱动屏幕它支持上述多种驱动芯片。接线方式取决于屏幕模块通常包括 CS、DC、RST、MOSI、SCK、LED、VCC、GND。下面是一份常见接线示例TFT 屏幕引脚ESP32 引脚VCC3V3GNDGNDCSGPIO5RSTGPIO4DCGPIO2MOSIGPIO23SCKGPIO18LED3V3 或 GPIO32不同模块排针顺序可能完全不同接线前务必查看屏幕模块的数据手册或丝印标注。TFT_eSPI 库的引脚配置统一写在User_Setup.h中改好引脚后重新编译即可。2.2 软件环境选择LVGL 在 ESP32 上可以配合多种软件框架运行主要有三种Arduino TFT_eSPI上手快代码简单适合原型验证和小型项目。PlatformIO Arduino工程化更好依赖管理方便适合正式项目。ESP-IDF lvgl官方深度集成适合量产级项目但配置门槛稍高。本文以 PlatformIO Arduino 环境为主原因是依赖声明清晰代码放进去就能编译后续切换到 ESP-IDF 时LVGL 动画 API 的写法是完全一致的。2.3 创建 PlatformIO 工程在 PlatformIO 中新建一个esp32dev项目然后修改platformio.ini[env:esp32dev] platform espressif32 board esp32dev framework arduino monitor_speed 115200 build_flags -DLV_CONF_INCLUDE_SIMPLE lib_deps lvgl/lvgl^8.3.0 bodmer/TFT_eSPI^2.5.0这里有两个重点LV_CONF_INCLUDE_SIMPLE让 LVGL 源码以#include lv_conf.h的方式找到配置文件。LVGL 需要一份lv_conf.h配置文件文件里控制哪些功能启用、颜色深度、内存池大小、动画开关等。2.4 准备 lv_conf.h去 LVGL 源码库中复制lv_conf_template.h重命名为lv_conf.h放到项目的include目录下。由于头文件搜索路径默认包含include加上LV_CONF_INCLUDE_SIMPLE编译选项LVGL 就能自动找到它。推荐打开以下几个关键宏#define LV_COLOR_DEPTH 16 #define LV_TICK_CUSTOM 1 #define LV_TICK_CUSTOM_INCLUDE Arduino.h #define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) #define LV_USE_ANIMATION 1 #define LV_FONT_MONTSERRAT_20 1LV_COLOR_DEPTH屏幕颜色深度TFT 屏一般用 16 位 RGB565。LV_TICK_CUSTOM让 LVGL 使用 Arduino 的millis()作为系统毫秒时基。开启后不需要手动调用lv_tick_inc()对新手更友好。LV_USE_ANIMATIONLVGL 动画功能总开关如果为 0本章所有 API 都不会生效。LV_FONT_MONTSERRAT_20加载蒙塞拉特 20 号字体示例中会用到。如果使用 Arduino IDE做法也类似把lvgl库放到libraries目录并确保lv_conf.h能被项目源码找到。2.5 最小驱动框架LVGL 运行需要两个基础条件一个“心跳”提供时间基准一个显示刷新回调把 LVGL 绘制出来的像素推送到屏幕。下面是标准的最小工程骨架后面所有动画代码都嵌在setup()里// 文件路径src/main.cpp #include Arduino.h #include lvgl.h #include TFT_eSPI.h static TFT_eSPI tft; static lv_disp_draw_buf_t draw_buf; static lv_disp_drv_t disp_drv; #define TFT_HOR_RES 240 #define TFT_VER_RES 240 static lv_color_t buf[TFT_HOR_RES * 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 setup() { Serial.begin(115200); lv_init(); tft.begin(); tft.setRotation(0); lv_disp_draw_buf_init(draw_buf, buf, NULL, TFT_HOR_RES * 10); lv_disp_drv_init(disp_drv); disp_drv.hor_res TFT_HOR_RES; disp_drv.ver_res TFT_VER_RES; disp_drv.flush_cb my_disp_flush; disp_drv.draw_buf draw_buf; lv_disp_drv_register(disp_drv); // 这里放动画、界面创建代码 } void loop() { lv_timer_handler(); delay(5); }屏幕分辨率请根据自己的屏幕修改。绘制缓冲区buf是整个工程内存消耗较大的地方如果屏幕分辨率高建议用多段刷新或分块缓冲区避免占用过多 PSRAM。3. LVGL 动画核心原理与 API 拆解3.1 动画的本质对象属性插值LVGL 动画的运行逻辑可以这样理解动画系统根据当前时间计算出一个中间值然后把中间值传给一个回调函数。回调函数负责把值应用到对象属性上。一个完整动画由以下部分构成动画对象哪个对象参与动画。执行回调每帧调用哪个函数去设置属性。起始值/结束值插值变化的范围。持续时间从起点到终点需要多少毫秒。路径回调每一段时间点映射为何种值变化曲线。播放控制是否有回放、重复次数、延迟。LVGL 用一个lv_anim_t结构体描述动画。经典用法是先初始化结构体再通过lv_anim_set_xxx()函数配置最后lv_anim_start()启动。3.2 最小动画示例下面是一个最基础的左右移动动画// 关键代码片段不是完整工程 lv_obj_t *obj lv_obj_create(lv_scr_act()); lv_obj_set_size(obj, 40, 40); lv_obj_set_style_bg_color(obj, lv_color_hex(0x2196F3), 0); lv_obj_align(obj, LV_ALIGN_TOP_LEFT, 20, 20); lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, obj); lv_anim_set_exec_cb(a, (lv_anim_exec_xcb_t)lv_obj_set_x); lv_anim_set_values(a, 20, 200); lv_anim_set_time(a, 1000); lv_anim_start(a);执行过程是动画系统在 1000 毫秒内把从 20 到 200 这些值依次取出来调用lv_obj_set_x(obj, v)来修改对象 X 坐标。由于每帧都刷新屏幕人眼看到的就是对象从左侧平滑移动到右侧。这里的强制类型转换是因为lv_anim_exec_cb_t回调签名是void (*)(void *, int32_t)而lv_obj_set_x的第二个参数是lv_coord_t。两者在底层都是整数所以可以转换。3.3 动画路径决定运动节奏同样是“从 20 移动到 200”直勾勾地移动并不好看。LVGL 提供了多种路径回调控制插值变化率路径回调效果lv_anim_path_linear线性匀速运动lv_anim_path_ease_in先慢后快lv_anim_path_ease_out先快后慢lv_anim_path_ease_in_out两头慢中间快最自然lv_anim_path_overshoot超过目标值后回弹适合弹窗lv_anim_path_bounce像皮球落地一样来回弹跳lv_anim_path_step不插值直接跳到目标值使用方式是在lv_anim_start()之前设置lv_anim_set_path_cb(a, lv_anim_path_bounce);如果内置路径不能满足需求也可以自定义路径回调。LVGL 8 的路径回调签名如下typedef int32_t (*lv_anim_path_cb_t)(const lv_anim_t *a);写一个自定义路径可以这样实现static int32_t my_path_cb(const lv_anim_t *a) { /* 线性基础上叠加一个正弦波动 */ int32_t step lv_map(a-act_time, 0, a-time, 0, 1024); int32_t val (step * a-end_value) / a-start_value; return val; }实际项目中我建议优先使用内置路径尤其是lv_anim_path_ease_in_out和lv_anim_path_overshoot它们已经覆盖了绝大多数 UI 动效需求。自定义路径主要用于仪表、游戏特效等特殊场景。3.4 动画播放控制LVGL 动画不只是“从 A 到 B 播放一次”还支持延迟、回放、重复等控制参数lv_anim_set_delay(a, 300); // 延迟 300ms 后开始 lv_anim_set_playback_time(a, 800); // 反向播放时长 lv_anim_set_playback_delay(a, 100); // 反向播放前的延迟 lv_anim_set_repeat_count(a, LV_ANIM_REPEAT_INFINITE); // 无限循环 lv_anim_set_repeat_delay(a, 200); // 每次循环间延迟这里的“回放”指的是从结束值回到起始值。举例来说动画值从 0 到 100playback_time控制 100 回到 0 的时间。配合repeat_count就能做呼吸灯、循环提醒等效果。动画启动后也可以通过lv_anim_del(var, callback)删除某个动画例如lv_anim_del(obj, NULL); // 删除对象上的所有动画如果对象被删除绑定在对象上的动画也会自动清理不需要手动逐个删除。3.5 动画执行回调的常见形式除了lv_obj_set_xLVGL 所有属性几乎都能做动画常见的有lv_obj_set_yY 轴移动。lv_obj_set_width/lv_obj_set_height尺寸变化。lv_obj_set_style_opa透明度变化。lv_obj_set_style_transform_angle旋转。lv_obj_set_style_transform_scale_x水平缩放。但需要注意很多样式接口的第二个参数不是int32_t直接传lv_obj_set_style_opa给动画回调会编译失败。这时需要一个自定义回调中转一下static void opacity_cb(void *var, int32_t v) { lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); }这个封装在后续实战中会大量使用。4. 完整实战ESP32 上的 LVGL 动画效果4.1 功能设计为了演示几种不同类型的动画我设计了一个综合界面包含三种效果一个小球在屏幕上左右移动来回弹跳。一个圆弧从 0 加载到 100模拟仪表进度。一个文字标签做透明度呼吸闪烁。这三个动画分别覆盖了坐标动画、数值动画、样式属性动画。把它们放在同一个屏幕能直接看出不同动画路径之间的差异。4.2 完整工程代码在之前最小驱动框架的setup()中调用一个动画创建函数。完整代码如下// 文件路径src/main.cpp #include Arduino.h #include lvgl.h #include TFT_eSPI.h static TFT_eSPI tft; static lv_disp_draw_buf_t draw_buf; static lv_disp_drv_t disp_drv; #define TFT_HOR_RES 240 #define TFT_VER_RES 240 static lv_color_t buf[TFT_HOR_RES * 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); } /* 圆弧数值动画回调 */ static void arc_value_cb(void *var, int32_t v) { lv_arc_set_value((lv_obj_t *)var, (int16_t)v); } /* 透明度动画回调 */ static void opacity_cb(void *var, int32_t v) { lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); } static void create_animations() { lv_obj_t *scr lv_scr_act(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x101418), 0); /* 1. 左右来回运动的小球 */ lv_obj_t *ball lv_obj_create(scr); lv_obj_remove_style_all(ball); lv_obj_set_size(ball, 20, 20); lv_obj_set_style_bg_color(ball, lv_color_hex(0x22A7F0), 0); lv_obj_set_style_radius(ball, LV_RADIUS_CIRCLE, 0); lv_obj_align(ball, LV_ALIGN_TOP_LEFT, 20, 30); lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, ball); lv_anim_set_exec_cb(a, (lv_anim_exec_xcb_t)lv_obj_set_x); lv_anim_set_values(a, 20, TFT_HOR_RES - 40); lv_anim_set_time(a, 1200); lv_anim_set_path_cb(a, lv_anim_path_ease_in_out); lv_anim_set_playback_time(a, 1200); lv_anim_set_playback_delay(a, 50); lv_anim_set_repeat_count(a, LV_ANIM_REPEAT_INFINITE); lv_anim_start(a); /* 2. 圆弧从 0 加载到 100 */ lv_obj_t *arc lv_arc_create(scr); lv_obj_set_size(arc, 120, 120); lv_obj_align(arc, LV_ALIGN_CENTER, 0, 20); lv_arc_set_rotation(arc, 270); lv_arc_set_bg_angles(arc, 0, 360); lv_arc_set_range(arc, 0, 100); lv_arc_set_value(arc, 0); lv_obj_set_style_arc_color(arc, lv_color_hex(0xFF9800), LV_PART_INDICATOR); lv_obj_set_style_arc_width(arc, 8, LV_PART_INDICATOR); lv_obj_set_style_arc_width(arc, 6, LV_PART_MAIN); lv_obj_remove_style(arc, NULL, LV_PART_KNOB); lv_anim_t a2; lv_anim_init(a2); lv_anim_set_var(a2, arc); lv_anim_set_exec_cb(a2, arc_value_cb); lv_anim_set_values(a2, 0, 100); lv_anim_set_time(a2, 3000); lv_anim_set_delay(a2, 500); lv_anim_set_path_cb(a2, lv_anim_path_overshoot); lv_anim_set_repeat_count(a2, LV_ANIM_REPEAT_INFINITE); lv_anim_set_repeat_delay(a2, 1000); lv_anim_start(a2); /* 3. 文字呼吸效果 */ lv_obj_t *label lv_label_create(scr); lv_label_set_text(label, LVGL Anim); lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(label, lv_font_montserrat_20, 0); lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 10); lv_anim_t a3; lv_anim_init(a3); lv_anim_set_var(a3, label); lv_anim_set_exec_cb(a3, opacity_cb); lv_anim_set_values(a3, LV_OPA_TRANSP, LV_OPA_COVER); lv_anim_set_time(a3, 900); lv_anim_set_path_cb(a3, lv_anim_path_ease_in_out); lv_anim_set_playback_time(a3, 900); lv_anim_set_repeat_count(a3, LV_ANIM_REPEAT_INFINITE); lv_anim_start(a3); } void setup() { Serial.begin(115200); Serial.println(LVGL Animation Demo); lv_init(); tft.begin(); tft.setRotation(0); lv_disp_draw_buf_init(draw_buf, buf, NULL, TFT_HOR_RES * 10); lv_disp_drv_init(disp_drv); disp_drv.hor_res TFT_HOR_RES; disp_drv.ver_res TFT_VER_RES; disp_drv.flush_cb my_disp_flush; disp_drv.draw_buf draw_buf; lv_disp_drv_register(disp_drv); create_animations(); } void loop() { lv_timer_handler(); delay(5); }4.3 代码逐段解释上面的代码虽然不长但包含的信息量很大我挑几个关键点详细说明。小球动画坐标动画 回放lv_obj_set_x是专门修改对象 X 坐标的接口签名是void lv_obj_set_x(lv_obj_t *obj, lv_coord_t x)。lv_anim_exec_xcb_t要求回调接收void *和int32_t这里用(lv_anim_exec_xcb_t)lv_obj_set_x强制转换实际上是把 LVGL 底层普遍存在的类型问题做了一个编译器层面的“承诺”。这种写法在 LVGL 官方示例中很常见。设置playback_time后动画会从终点值反向回到起点值再配合LV_ANIM_REPEAT_INFINITE就能实现无限来回。注意ease_in_out路径让往返过程更自然不会突然加速或急停。圆弧动画数值插值lv_arc_set_value接收int16_t数值而动画回调给出的是int32_t所以我单独封装了arc_value_cb。这个函数的作用就是把动画系统计算出的中间值直接写入圆弧控件。lv_arc_set_bg_angles(arc, 0, 360)表示背景是完整一圈lv_arc_set_rotation(arc, 270)把起始点从 3 点钟方向转到 12 点钟方向。指示器部分是橙色圆弧宽度 8 像素主轴是灰色宽度 6 像素。lv_obj_remove_style(arc, NULL, LV_PART_KNOB)用于去掉圆弧旋钮这样它看起来更像进度环而不是调节器。overshoot路径很有意思它会让数值先冲过 100再回弹到 100。这个效果非常适合“加载完成”的交互暗示。文字呼吸动画样式属性动画透明度动画需要操作样式属性直接使用lv_obj_set_style_opa会面临参数类型不匹配的问题所以封装了opacity_cb。数值范围使用LV_OPA_TRANSP和LV_OPA_COVER分别对应 0 和 255。这里同样设置了回放动画从透明到不透明再从不透明回到透明形成呼吸节奏。4.4 运行与验证编译并烧录到 ESP32 后打开串口监视器应该看到输出LVGL Animation Demo Setup done.屏幕上的效果为左上角一个蓝色小球在水平方向左右往返运动边界处有轻微的减速效果。屏幕中央有一个橙色圆弧从 0 缓慢增加到 100然后短暂停止再重复。屏幕顶部文字从透明变为白色再变回透明。如果屏幕没有显示任何内容优先检查接线和User_Setup.h中的引脚配置如果出现花屏或颜色偏色检查LV_COLOR_DEPTH是否与屏幕驱动匹配。5. 常见问题与排查思路5.1 动画不启动或卡在起始值动画没有效果常见原因有以下几种LV_USE_ANIMATION被设置为 0功能被裁剪。没有调用lv_anim_start()只配置了动画参数。对象在当前屏幕上不可见或者被其他对象遮挡。系统时间基准没有提供。使用LV_TICK_CUSTOM时确认LV_TICK_CUSTOM_INCLUDE和LV_TICK_CUSTOM_SYS_TIME_EXPR配置正确不用自定义心跳时必须定时调用lv_tick_inc()。排查时可以先用最简单的lv_anim_path_linear测试单次动画排除路径和播放控制参数导致的干扰。5.2 动画闪烁、画面撕裂小屏幕出现横纹或残影通常是因为刷新缓冲区太小或者刷新回调没有正确等待屏幕芯片完成工作。建议从三个方面优化