简介:这份《LVGL开发指南》中文V1.3版是正点原子面向STM32嵌入式开发者编写的图形界面开发教程,适合已具备单片机基础、希望在资源受限的微控制器上实现触摸UI的工程师与学生。全书按基础篇展开,涵盖LVGL的组件体系与移植条件、源码获取、无操作系统移植时的工程文件添加与配置修改、官方例程验证,以及基于FreeRTOS的带系统移植、PC模拟器使用和初始化流程等进阶内容,兼顾入门与实战。整个压缩包仅含1个PDF文档,约9.54MB,单文件即完整教程,便于离线阅读与随查随用。目前已有766人学习下载,在嵌入式GUI学习群体中有一定认可度。读者可从中获得移植步骤、配置项说明与例程验证方法,并沿着目录顺序完成从裸机到RTOS环境的界面搭建,少走配置与调试的弯路。
1. 一块 320×240 的屏,为什么值得专门啃一套图形库
做嵌入式这行的,几乎都经历过同一个阶段:手里一块 STM32 开发板,屏幕点亮了,纯色能刷了,兴致勃勃想做个设置菜单,结果一头扎进for循环和坐标计算里出不来——按钮要重绘、按下要高亮、滑条要跟手、页面切换要有过渡,每加一个需求就得把画图函数再改一遍。LVGL 就是在这个节点上接手的东西:一套跑在 MCU 上的开源图形库,把控件、样式、布局、事件、动画、字体这些桌面端早就成熟的概念,压缩进几十 KB 内存里。这份 V1.3 版的中文开发指南,走的是从零到能用的一条直线:先讲裸机和 RTOS 两种移植,再讲对象模型和配置项,最后落到按钮、圆弧、进度条这类具体部件。刚上手 MCU 界面、需要照着一步步跑通的人适合读前半本,打算把现有裸机绘图代码换成可维护 UI 框架的人,重点在后半本的样式与事件部分。
2. 移植前置:硬件门槛与工程目录怎么落地
2.1 屏幕、显存和接口带宽的三笔账
LVGL 本身不驱动任何硬件,它对底层只有两个要求:一块能按矩形区域写像素的显示设备,一条能读出触摸或按键状态的输入通道。剩下的事全在驱动层。所以真到选型阶段,需要算的是内存和带宽。
一屏 320×240、RGB565 的完整帧缓冲是 320×240×2 = 150KB。F103 这个级别的芯片拿不出这么一块连续内存,F407 也要精打细算。LVGL 的设计从一开始就允许「部分缓冲区」:你只给一小块,比如 320×40 行,库画满一块就调一次刷新回调把这块推出去,再画下一块。这解决了内存问题,但也决定了刷新率的上限。
接口带宽是第二笔账。SPI 屏在 40MHz 下推 150KB 大概在 30ms 量级,理论上限三四十帧,扣掉绘制开销实际会更低。8080 并口或者带 DMA2D 的型号宽裕得多,DMA 版本还能把刷屏和绘制并行起来。这不是玄学,是把目标帧率倒推回接口选型的算术。
还有一点容易漏:触摸和屏幕是不是共用一条 SPI 总线。共用的话,读触摸时要切片选、等时序,没处理好就会出现「屏幕闪一下」或者触摸偶发丢点。选型时看一眼原理图能省掉后面半天调试。
2.2 源码树和工程目录:先看清哪些文件必须进工程
LVGL 8.x 的源码树顶层结构大致如下,手册配套的例程也是这个分支:
| 路径 | 作用 | 是否必须加入工程 |
|---|---|---|
lvgl/src/core | 对象、事件、样式、定时器内核 | 必须 |
lvgl/src/draw | 绘制算法、矩形填充、图层合成 | 必须 |
lvgl/src/font | 内置字模与字体解析 | 必须(除非全部外挂字库) |
lvgl/src/widgets | 按钮、滑条、圆弧、进度条等部件 | 建议全加 |
lvgl/src/extra | 布局、主题、文件系统、图片解码 | 按需 |
lvgl/examples/porting | 移植模板,参考用 | 不编进工程 |
lv_conf.h | 全局配置,全工程只能有一份 | 必须 |
常见做法是把lvgl整个目录拷到工程根目录下,再在 IDE 里按功能建分组。我一般分三组:lvgl(库本体)、porting(lv_port_disp.c、lv_port_indev.c、lv_port_fs.c这些自己写的对接层)、app(业务界面)。分组清晰不只是好看,后面裁功能、加模块、排查编译错误都靠它定位。
需要特别提醒:lv_conf.h只能存在一份。有些人为了「方便」在库目录和工程目录各放一份,结果改了哪份都不生效,编译器用的永远是头文件搜索路径里靠前的那一份。血泪经验,别问我是怎么知道的。
2.3 把 lv_conf.h 里最先要改的几个宏定下来
配置文件里几百个宏,一开始不用全看,但这几个必须在编译第一版之前就定死,否则跑起来就是花屏或者直接 HardFault:
/* lv_conf.h —— 移植阶段最先确认的几项 */ #ifndef LV_CONF_H #define LV_CONF_H /* ① 颜色深度:屏幕是 RGB565 就写 16,写 32 会白白多占一倍内存 */ #define LV_COLOR_DEPTH 16 /* ② SPI 屏高低字节反了会出现花屏/偏色,改成 1 试试,这是最快的排查手段 */ #define LV_COLOR_16_SWAP 0 /* ③ 0 = 用 LVGL 自带的静态内存池;1 = 自己接 malloc */ #define LV_MEM_CUSTOM 0 /* 内存池大小,芯片 RAM 紧张就先给 32K,跑起来再调 */ #define LV_MEM_SIZE (48U * 1024U) /* ④ 刷新周期,单位 ms。30 约等于 33fps 的理论上限 */ #define LV_DISP_DEF_REFR_PERIOD 30 /* ⑤ 输入扫描周期,触摸响应慢就往小了调 */ #define LV_INDEV_DEF_READ_PERIOD 30 /* ⑥ 日志与断言:移植阶段建议全开,虽然占点 Flash,但出错时能救命 */ #define LV_USE_LOG 1 #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN #define LV_USE_ASSERT_NULL 1 #endif /* LV_CONF_H */参数背后的逻辑值得说清楚。LV_COLOR_DEPTH不是越大越好,它直接决定每个像素占几个字节,也决定绘制缓冲区大小,屏幕是 16 位色就老老实实填 16。LV_MEM_CUSTOM填 0 时,LVGL 会从一段静态数组里切内存,好处是不依赖堆、不产生碎片,坏处是池子满了创建控件会失败且不容易察觉,所以移植阶段把LV_USE_ASSERT_NULL打开,lv_obj_create返回NULL时能立刻断下来。刷新周期和输入周期这两个值,先按默认跑通,再根据实际帧率调。
2.4 工程配置:头文件路径与编译选项
把lvgl目录加进头文件搜索路径就够了,lvgl.h会自动展开src下的所有子模块。容易出问题的是编译选项:
- 必须开 C99。部分编译器默认 C89,
//注释和混合声明会直接报错,命令行参数一般是-std=gnu99,MDK 里勾选 C99 Mode。 - 优化等级别一上来就
-O0。-O0下绘制函数慢得离谱,界面会明显拖影,换成-Os或-O2后帧率能有肉眼可见的提升。 - 栈要留够。LVGL 的绘制函数递归层次不浅,主栈给到 2KB 以上比较稳妥,RTOS 下给 GUI 任务的栈同理。
编译第一版建议先只加最小集合(core、draw、font、widgets),把官方那个lv_example_get_started_1跑通,再加extra里的布局和主题。一次性全加进去,出问题的时候你根本分不清是哪个模块的锅。
3. 三大驱动接口:显示、输入、时基怎么接
3.1 显示驱动:flush_cb 是整个移植的核心
移植 LVGL,说到底就是实现一个回调:LVGL 画好一块矩形区域的像素,交给你,你负责把它推到屏幕上。只要这个函数通了,界面就能出来。
/* lv_port_disp.c —— 最朴素的阻塞式刷新 */ static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint32_t w = lv_area_get_width(area); uint32_t h = lv_area_get_height(area); /* ① 告诉屏幕控制器:接下来要写的是这块矩形 */ LCD_SetWindow(area->x1, area->y1, area->x2, area->y2); /* ② 把 LVGL 画好的像素整块写进去,color_p 就是绘制缓冲区首地址 */ LCD_WritePixels((uint16_t *)color_p, w * h); /* ③ 必须调用,否则 LVGL 会一直等,后面再也不会给你下一块 */ lv_disp_flush_ready(drv); }三个步骤缺一不可。lv_area_get_width和lv_area_get_height拿到的是这一块区域的宽高,不是整个屏幕的尺寸,因为 LVGL 是分块刷的。LCD_SetWindow对应的是屏幕控制器里的「设置显示窗口」命令,不同驱动 IC 寄存器不一样,但语义一致。最后那句lv_disp_flush_ready是最容易漏的一行,漏了的表现是界面只出现第一帧就再也不动——因为 LVGL 内部有个flushing标志没被清掉。
注册驱动的代码同样不长,但参数要对得上:
void lv_port_disp_init(void) { /* 绘制缓冲区:320 宽 × 40 行,约 25KB */ static lv_color_t buf1[320 * 40]; static lv_disp_draw_buf_t draw_buf; lv_disp_draw_buf_init(&draw_buf, buf1, NULL, 320 * 40); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 320; /* 必须和实际屏幕一致 */ disp_drv.ver_res = 240; disp_drv.flush_cb = disp_flush; disp_drv.draw_buf = &draw_buf; /* 屏幕竖装就靠这两行,省掉后面所有手写坐标换算 */ disp_drv.sw_rotate = 1; disp_drv.rotated = LV_DISP_ROT_90; lv_disp_drv_register(&disp_drv); }hor_res和ver_res填错是最常见的「只显示左上角一块」的根因,因为 LVGL 按这个尺寸算坐标,写出去的区域和屏幕实际可视区对不上。sw_rotate打开后,旋转由软件在绘制阶段完成,代价是每帧多一次坐标变换,好处是触摸和显示都按你期望的方向走,不用在业务代码里到处加偏置。
3.2 缓冲区怎么选:单缓冲、双缓冲、还是 DMA
这块的取舍直接决定手感,我给个实打实的对比:
| 方案 | 内存占用(320×240 RGB565) | 帧率表现 | 适用场景 |
|---|---|---|---|
| 单缓冲,1/6 屏 | 约 25KB | 一般,绘制与刷新串行 | RAM 紧张的 F103 |
| 双缓冲,各 1/6 屏 | 约 50KB | 较好,一块刷屏时另一块在绘制 | F407 及以上 |
| 全屏单缓冲 | 150KB | 好,但占满 SRAM | 带外部 SRAM 或 SDRAM |
双缓冲的写法是在lv_disp_draw_buf_init里传两个缓冲区指针,LVGL 会在一块被推出去的同时画另一块。前提是驱动层能接受「上一块还没传完就来了下一块」,也就是必须用 DMA。
/* DMA 版本:启动传输后立刻返回,但 ready 一定要等传输完成再调 */ static void disp_flush_dma(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { LCD_SetWindow(area->x1, area->y1, area->x2, area->y2); /* 埋一个上下文,DMA 中断里要用它回调 lv_disp_flush_ready */ s_disp_drv = drv; LCD_DMA_Start((uint16_t *)color_p, lv_area_get_size(area)); /* 这里千万不要调 lv_disp_flush_ready */ } /* DMA 传输完成中断 */ void LCD_DMA_Complete_IRQHandler(void) { lv_disp_flush_ready(s_disp_drv); }翻车点就在注释那两行。有人在disp_flush_dma末尾顺手写了lv_disp_flush_ready,结果是 DMA 还在读缓冲区,LVGL 已经把这块内存拿去画下一帧了,屏幕上出现的就是撕裂和残影。判断依据很简单:出现「一半新画面一半旧画面」的横向撕裂,基本就是提前调了 ready。
3.3 输入设备:read_cb 里那个「松手要保留坐标」的细节
触摸对接比显示简单,只有一个回调,但有一个非常经典的坑:
static void indev_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { static lv_coord_t last_x = 0, last_y = 0; uint16_t x, y; if (TP_Read(&x, &y) == 0) { /* 读到有效触点 */ >/* 四点校准后得到的线性系数,提前算好存成常量 */ #define TOUCH_X_MIN 380 #define TOUCH_X_MAX 3720 #define TOUCH_Y_MIN 420 #define TOUCH_Y_MAX 3680 x = (uint16_t)((uint32_t)(x - TOUCH_X_MIN) * 320 / (TOUCH_X_MAX - TOUCH_X_MIN)); y = (uint16_t)((uint32_t)(y - TOUCH_Y_MIN) * 240 / (TOUCH_Y_MAX - TOUCH_Y_MIN));注意屏幕如果开了sw_rotate,这里的映射要放在旋转之前,或者干脆把映射结果按旋转后的坐标系重新算一遍,两边对齐了就不会有偏移。
3.4 时基与任务处理:裸机和 RTOS 两种接法
LVGL 内部所有跟时间有关的东西——动画、定时器、刷新周期——都依赖一个毫秒级时基。裸机下最常见,也最容易写错:
/* 推荐做法:SysTick 回调里喂时基,主循环里跑任务 */ void SysTick_Handler(void) { HAL_IncTick(); lv_tick_inc(1); /* 每 1ms 递增,绝对不能漏 */ } int main(void) { HAL_Init(); LCD_Init(); TP_Init(); lv_init(); lv_port_disp_init(); lv_port_indev_init(); while (1) { lv_task_handler(); /* 刷新、输入扫描、动画、定时器全在这一句里 */ HAL_Delay(5); /* 5ms 一轮,别写 100,界面会明显迟钝 */ } }lv_tick_inc放在主循环里调是错的,因为主循环里任何一次长延时都会让时基漂移,动画看着就一卡一卡的。放 SysTick 里才是稳定方案。lv_task_handler的调用周期也别太长,5ms 到 10ms 比较合适,太大的话触摸扫描间隔变长,滑动会掉点。
带 RTOS 的时候把lv_task_handler单独开一个任务:
void lvgl_task(void *pv) { lv_init(); lv_port_disp_init(); lv_port_indev_init(); while (1) { lv_task_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } } /* 创建任务时栈别抠,给 4096 字节以上 */ xTaskCreate(lvgl_task, "lvgl", 4096, NULL, 4, NULL);有一个必须记住的约束:LVGL 8.x 不是线程安全的。别的任务里直接调lv_label_set_text这类 API,运气好是花屏,运气差就是 HardFault。正确做法是用lv_msg消息机制,或者自己做一个队列,统一到 GUI 任务里执行。
4. UI 骨架:对象树、样式、事件和动画
4.1 对象树与坐标:父子关系决定了你所有位置计算
LVGL 里所有控件都建立在lv_obj之上,组织方式是一棵树。屏幕是根,控件挂到父对象下面,子对象坐标相对父对象计算:
lv_obj_t *scr = lv_scr_act(); /* 当前活动屏幕,树的根 */ lv_obj_t *card = lv_obj_create(scr); /* 卡片,父对象是屏幕 */ lv_obj_set_size(card, 200, 120); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); /* 相对父对象居中 */ lv_obj_t *label = lv_label_create(card); /* 标签挂在卡片下面 */ lv_label_set_text(label, "Temp: 26C"); lv_obj_align(label, LV_ALIGN_TOP_LEFT, 8, 6); /* 这个 8,6 是相对 card 的 */理解这一点之后,页面布局会简单很多:你只要把一组控件塞进一个容器,容器整体挪位置,里面的东西全跟着走,不需要挨个改坐标。删除父对象时子对象会被一起回收,也不会漏内存。
LV_ALIGN_CENTER这类对齐方式比手写x = (320 - w) / 2靠谱得多,因为屏幕分辨率改了不用动代码。对齐的第二个参数是相对于父对象,不是相对于屏幕,这一点在嵌套容器里经常被搞混。
4.2 样式与状态:为什么你的按压高亮不生效
LVGL 的样式体系是把「外观」抽成独立对象,再挂到控件的某个部分、某个状态上。按钮按下变深色,本质是给LV_STATE_PRESSED挂了一份不同的背景色:
static lv_style_t style_btn; /* 注意:必须是 static 或全局 */ lv_style_init(&style_btn); lv_style_set_bg_color(&style_btn, lv_color_hex(0x2D7FF9)); lv_style_set_radius(&style_btn, 8); lv_style_set_border_width(&style_btn, 0); static lv_style_t style_btn_pressed; lv_style_init(&style_btn_pressed); lv_style_set_bg_color(&style_btn_pressed, lv_color_hex(0x1B5FBF)); lv_obj_t *btn = lv_btn_create(lv_scr_act()); lv_obj_set_size(btn, 120, 48); lv_obj_add_style(btn, &style_btn, LV_PART_MAIN); /* 关键:状态位必须显式带上,按下时才生效 */ lv_obj_add_style(btn, &style_btn_pressed, LV_PART_MAIN | LV_STATE_PRESSED);两个坑。第一,样式变量写成局部变量,函数一返回这块栈内存就被覆盖了,表现是界面随机花掉或者颜色乱跳,编译器还不一定报错。第二,状态位忘了带LV_STATE_PRESSED,那么这份样式永远匹配不到按下状态,效果就是「怎么点都没变化」。
LV_PART_MAIN也是一样,滑条、圆弧这类复合部件有主体、指示器、旋钮三个部分,样式挂错部分就没反应。圆弧控件改颜色要挂LV_PART_INDICATOR,改旋钮颜色要挂LV_PART_KNOB。
4.3 事件回调:user_data 是唯一干净的传参通道
控件的交互都走事件回调,签名固定,想往回调里带业务数据只能用user_data:
static void btn_event_cb(lv_event_t *e) { lv_obj_t *btn = lv_event_get_target(e); /* 触发事件的对象 */ uint32_t *ctx = lv_event_get_user_data(e); /* 注册时传进来的上下文 */ lv_obj_t *lbl = lv_obj_get_child(btn, 0); /* 取第一个子对象 */ if (lv_event_get_code(e) == LV_EVENT_CLICKED) { lv_label_set_text_fmt(lbl, "count=%lu", (unsigned long)(++(*ctx))); } } static uint32_t click_count = 0; /* 生命周期要长于控件 */ lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, &click_count);lv_event_get_target拿到的是真正触发事件的对象,lv_event_get_current_target拿到的是事件注册的那个对象,两者在冒泡场景下不一样——子控件的事件冒泡到父容器时,前者是子控件,后者是父容器。分不清这两个,做列表点击的时候就会拿到错误的行号。
user_data指向的变量生命周期必须比控件长。传一个局部变量地址进去,回调触发时那块栈早被复用了,读出来的就是随机值。
4.4 动画和定时器:别用定时器做过渡
LVGL 里动画和定时器是两套东西,但经常被混用。动画由lv_task_handler驱动,按时间插值,适合位置、透明度、数值这类连续变化:
static void anim_x_cb(void *var, int32_t v) { lv_obj_set_x((lv_obj_t *)var, v); } lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, card); /* 动画作用的对象 */ lv_anim_set_values(&a, -200, 0); /* 起点和终点 */ lv_anim_set_time(&a, 300); /* 时长 ms */ lv_anim_set_exec_cb(&a, anim_x_cb); /* 每帧干什么 */ lv_anim_set_path_cb(&a, lv_anim_path_ease_out); /* 缓动曲线,入场更好看 */ lv_anim_start(&a);定时器则是周期性的业务逻辑,比如每 500ms 读一次传感器:
static void timer_cb(lv_timer_t *t) { lv_label_set_text_fmt(label, "Temp: %dC", sensor_read()); } lv_timer_create(timer_cb, 500, NULL); /* 周期 ms,用户数据 */用一个定时器去逐帧改坐标来做过渡,代码能跑,但一旦定时器周期和刷新周期不同步,动作就会抖。动画模块内部就是按lv_tick_inc的时基做插值的,交给它更稳。
4.5 中文显示:内置符号、C 数组字库和外挂字库
中文乱码是新手最容易卡住的一关。先说清楚 LVGL 的字体分层:内置的lv_font_montserrat_xx只覆盖 ASCII,LV_SYMBOL_OK这类内置符号是一套打包在库里的图标字体,不需要额外资源:
/* 内置符号直接用,不占额外 Flash */ lv_obj_t *icon = lv_label_create(scr); lv_label_set_text(icon, LV_SYMBOL_OK); /* 中文必须挂自定义字库 */ LV_FONT_DECLARE(my_font_20); /* 由字体转换工具生成的 C 数组 */ lv_obj_set_style_text_font(label, &my_font_20, 0); lv_label_set_text(label, "温度正常");前提是源文件存成 UTF-8 编码,IDE 默认 GBK 的话,中文会变成编译不过的乱码字符。这一点在部分老版本 IDE 上要手动改,改完之后再写中文,否则前面所有中文都得重打一遍。
字库来源有两种方式,差别很大:
| 方式 | 占用 | 加载速度 | 适用 |
|---|---|---|---|
| C 数组字库(内嵌 Flash) | 存在 Flash,按字号增长 | 最快,直接寻址 | 字符集固定、Flash 宽裕 |
| 文件系统字库(外部存储) | 存 SD 卡或 SPI Flash,不占代码区 | 首次加载有 IO 开销 | 字符集较大、多字号切换 |
裁剪是必须做的。生成 C 数组时只勾选项目里真正出现的字符,别图省事勾了全字库——一个 20 号字的完整中文字库能到几 MB,Flash 直接被吃干净。常见做法是先用脚本把界面文案里的所有汉字去重导出成一个字符串,再拿去生成字库。
5. 避坑与排查:第一次点亮屏幕最容易踩的五个坑
5.1 显示相关
坑一:花屏、偏色,颜色看着像换了通道
现象是纯红刷出来是纯蓝,或者界面全是噪点色块。原因是 RGB565 的高字节和低字节顺序反了,SPI 屏和并口屏在这块处理不一样。解决办法是把lv_conf.h里的LV_COLOR_16_SWAP从 0 改成 1 重新编译。如果改完还是不对,再查LCD_WritePixels里送的数据是不是按 16 位传的,有没有被当成两个 8 位拆开。
坑二:只显示左上角一块,右边和下边是残留
现象是界面能出来,但只占屏幕四分之一。原因是disp_drv.hor_res或ver_res填的是别的分辨率,LVGL 按这个尺寸规划绘制区域,超出部分屏幕根本没刷到。解决是把这两个值改成屏幕实际分辨率,同时确认LCD_SetWindow里的坐标边界没有写死。还有一个变种:绘制缓冲区按「像素个数」算的时候算成了「字节数」,缓冲区实际大小只有预期的一半,表现是刷到一半就停住。
5.2 触摸相关
坑三:触摸点整体偏移,越靠边越明显
现象是点按钮中心没反应,要偏一点才中。原因是屏幕做了 90 度旋转,但触摸坐标没跟着转,两个坐标系错位。解决办法是在read_cb里按旋转方向做一次坐标映射,旋转 90 度时一般是x_new = y_old、y_new = 屏幕宽度 - x_old,具体看屏幕排线方向。另一个可能是校准系数不对,重新做一次四点校准。
坑四:按钮按下去有高亮,松手不触发点击
现象是按压状态样式能出来,但LV_EVENT_CLICKED从来不进。原因是松手时>/* app_page.h —— 页面注册表,新增页面只改这个数组 */ typedef struct { const char *name; void (*create)(lv_obj_t *scr); /* 页面构建函数 */ } page_t; static lv_obj_t *page_open(const page_t *p) { lv_obj_t *scr = lv_obj_create(NULL); /* 每个页面一块独立屏幕 */ lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE); p->create(scr); /* 业务代码只关心往 scr 上挂控件 */ return scr; } /* 切页统一走这个入口,动画参数改一处全局生效 */ void page_switch(const page_t *p) { lv_obj_t *scr = page_open(p); lv_scr_load_anim(scr, LV_SCR_LOAD_ANIM_FADE_ON, 200, 0, true); }
这样划分的好处很直接:每个页面的构建逻辑单独一个.c文件,main里只留一个初始页;控件查找和事件注册都封装在页面内部,改样式不会波及其他页面;切页动画只在page_switch里定义一次,产品经理要改过渡效果时只改一行。
配套的验证方法我一般分两步走。第一步在 PC 模拟器里把逻辑跑通,模拟器编译快、能直接断点调试,界面布局调整效率比烧板子高一个数量级。第二步才是上板,这时候只验证驱动层的东西——刷新有没有撕裂、触摸准不准、内存够不够。把两件事分开验证,出问题时排查范围能砍掉一半。
再补一个小技巧:给每个页面加一个LV_EVENT_DELETE回调,在里面把页面内部持有的大块资源(图片缓存、文件句柄)释放掉。LVGL 默认会回收控件本身,但你自己malloc的东西不会自动回收,页面反复进出几次之后内存就被啃光了。
从那以后我每次接手一个新的板子,都是先在模拟器里把页面骨架搭出来,再回头接驱动,最后才在板子上调刷新和触摸——顺序反过来的话,你永远分不清是界面代码写错了还是驱动时序有问题。希望帮到你。
本文还有配套的精品资源,点击获取