1.快速了解
Light and Versatile Graphics Library(轻量级多功能图形库),简称 LVGL,是一个免费开源的嵌入式 GUI 库。它用 C 语言编写,允许你在任何 MCU、MPU 和显示器上,创建出类似手机应用般精美流畅的图形界面。简单来说,可以把 LVGL 想象成一套强大的乐高积木,让你用代码"拼"出按钮、图表、滑块等丰富的界面元素。
1.四个特点:为什么选择LVGL?
对于初学者,了解LVGL的几个核心特点,能让你明白它为何如此受欢迎:
-
轻量级但功能强 :专为资源受限的嵌入式设备设计,在保证低内存和低Flash占用的同时,提供了完整的GUI解决方案,最低只需要 64kB Flash 和 16kB RAM 就能运行。
-
跨平台能力 :不仅可以在各种单片机(如STM32、ESP32)上运行,还能在Windows、Linux、macOS等桌面操作系统上通过模拟器运行,这为前期的学习、开发和调试带来了极大的便利。
-
强大的图形能力 :支持动画、抗锯齿、透明度、平滑滚动、图层混合等高级图形效果,让你的产品界面告别单调,更加现代化和富有动感。
-
灵活的布局和交互:支持多种输入设备(触摸屏、按键、编码器等)和灵活的布局方式(如Flex、Grid),让你可以轻松设计出适应不同屏幕尺寸和交互方式的界面。
2.工作原理:它如何与你的硬件对话
了解LVGL的基本工作流程,有助于你理解代码是如何一步步变成屏幕上的图像的。
-
初始化与配置 :首先,你需要初始化你MCU的底层硬件(如时钟、GPIO、SPI等),然后调用**
lv_init()**来初始化LVGL库本身。 -
注册硬件驱动 :LVGL通过"回调函数"的方式与你的屏幕和输入设备(如触摸屏)进行交互。你需要编写一个"刷新"回调函数,告诉LVGL如何把一个像素点画到你的屏幕上;并编写一个"读取"回调函数,告诉LVGL如何从你的触摸屏获取坐标。
-
构建界面:这是核心的创作环节,你可以调用LVGL提供的API来创建各种对象(如按钮、标签),并设置它们的位置、大小、样式,以及添加事件响应(如点击)。
-
运行心跳 :在主循环中,你需要不断地调用**
lv_timer_handler()**。LVGL会在这个函数里处理所有后台任务,包括刷新界面、响应用户输入、运行动画等。
LVGL 的工作原理,可以简单理解为"创建对象,事件驱动,定时刷新 ",其核心是 "一切皆对象" 。你通过创建 lv_obj_t 对象来构建界面的每个元素,并为其设置样式、属性和事件响应函数。在 while(1) 主循环中,你需要持续调用 lv_timer_handler() 函数,它像一个心脏起搏器,负责处理动画、响应输入并刷新显示。
整个流程的示意图如下,希望能帮助你建立直观印象:
flowchart TD
A[硬件初始化] --> B[调用 lv_init]
B --> C[创建显示驱动<br>设置Flush刷新回调]
B --> D[创建输入设备驱动<br>设置Read回调]
C --> E[创建界面控件]
D --> E
E --> F[主循环]
F --> G[调用 lv_timer_handler]
G --> H[LVGL内部处理]
H --> I[需要刷新显示]
H --> J[检测到输入]
H --> K[运行动画/计时器]
I --> L[调用Flush回调<br>刷新屏幕]
J --> M[调用Read回调<br>获取输入数据]
L --> N[等待下一次循环]
M --> N
K --> N
N --> F
2.小白教学
1.PC模拟器搭建
VSCode + CMake + MinGW (推荐喜欢轻量级工具的用户)
此方案配置稍复杂,但工具链非常现代、灵活。
-
前置条件 :需要提前安装好 VSCode、CMake、MinGW (GCC) 和 SDL2 库 ,并将它们的
bin目录添加到系统环境变量中。 -
步骤 :克隆官方提供的
lv_port_pc_vscode仓库,用VSCode打开,选择正确的编译工具链(Kit),然后编译并运行。 -
常见问题 :运行时如果提示"找不到SDL2.dll",你需要手动将SDL2库中的
SDL2.dll文件复制到生成的bin目录下。
-
下载库文件:从 GitHub 克隆或下载 LVGL 源码到你的项目文件夹。
-
配置文件 :复制
lv_conf_template.h为lv_conf.h,并开启其内容(将#if 0改为#if 1),此文件用于配置库的核心参数。 -
包含头文件 :在需要使用 LVGL 功能的源文件中包含
lvgl.h。 -
提供时钟 :在你的系统定时器中断中,周期性地调用
lv_tick_inc(x)函数,为 LVGL 提供内部计时基准。 -
移植显示与输入:这是最关键的一步。
-
显示驱动 :调用
lv_display_create()创建显示设备,并通过lv_display_set_flush_cb()注册一个"刷新回调函数"。LVGL 完成绘图后,会调用你注册的这个函数,你需要在此函数内将图像数据发送到显示屏。 -
输入设备驱动 :如果有触摸屏或键盘等输入设备,需要创建输入设备 (
lv_indev_t),并实现读取输入状态的回调函数。
-
-
设计 UI:调用 LVGL 的 API 创建屏幕、控件、设置样式和事件等。
-
主循环 :在
main()函数的主循环中,无限循环地调用lv_timer_handler()并添加适当延时,以维持系统运行。
2.工具
1.提升效率的利器:可视化设计工具 SquareLine Studio
如果想进一步缩短开发周期,官方提供的 可视化设计工具 SquareLine Studio 会是很好的帮手。它提供所见即所得的拖拽式界面,无需深入代码即可完成 UI 布局,一键生成标准的 C 代码,直接集成到你的项目中。
2.全球最大的可定制动画库
可用于嵌入式单片机界面UI绘制
3.核心概念:一切皆是对象
在LVGL的世界里,所有的界面元素,比如一个按钮、一段文字、一张图片,都被称为对象(Object)。每个对象都是一个独立的实体,拥有自己的属性(位置、大小、样式等)和行为。
1.基础对象 lv_obj:万物的起点
lv_obj 是LVGL中最基本的对象类型,你可以把它看作一个透明的"容器"或"空白画布"。其他所有的复杂控件(如按钮、滑动条),都是基于这个基础对象扩展而来的。
创建一个基础对象的代码很简单:
c
// 获取当前的活动屏幕作为父对象
lv_obj_t * scr = lv_scr_act();
// 创建一个基础对象,它的父对象是屏幕
lv_obj_t * obj = lv_obj_create(scr);
2.父子关系:构建界面的骨架
LVGL的对象可以形成父子关系。一个对象可以有多个子对象,但只能有一个父对象。这种关系非常实用:
-
相对位置:当你移动一个父对象时,它的所有子对象都会跟着一起移动。这为我们创建可复用的UI模块提供了可能。
-
视觉裁剪:默认情况下,子对象超出父对象区域的部分会被裁剪掉,不会显示出来。
3.对象树
LVGL 中的对象树,你可以把它理解为界面所有元素的"家谱"。每一个控件,比如按钮、标签、滑块,都是这棵大树上的一个节点。理解对象树,是你从"会用单个控件"升级到"能搭建复杂、灵活界面"的关键一步。
🌳 什么是对象树?
在 LVGL 中,所有控件(Object)都可以形成父子关系。一个对象可以有多个子对象,但只能有一个父对象。这种层级结构就构成了一棵"树"。
-
根节点 :是屏幕 。通过
lv_scr_act()获取的当前活动屏幕,就是这棵树的根。 -
父对象:一个容器,它的子对象会跟随它移动、缩放,甚至被裁剪。
-
子对象 :依附于父对象,其坐标是相对于父对象左上角的,而不是屏幕。
🧩 为什么需要对象树?
对象树带来三个核心好处:
-
相对定位:你移动一个窗口,里面的所有按钮、文字都会跟着一起动,无需逐个修改坐标。
-
视觉裁剪:默认情况下,子对象超出父对象边界的内容会被自动裁剪,不显示出来。这对于实现滚动列表、弹出菜单至关重要。
-
事件冒泡:事件(如点击)会沿着对象树向上传递,让你可以用父容器统一处理多个子控件的交互。
📐 坐标系统与对象树
对象树直接决定了控件的位置计算方式。
| 坐标类型 | 含义 | 获取/设置函数 |
|---|---|---|
| 相对坐标 | 相对于父对象左上角的坐标 | lv_obj_set_x(obj, 10) |
| 绝对坐标 | 相对于屏幕左上角的坐标 | lv_obj_get_x(obj) |
当你调用 lv_obj_get_x(obj) 时,LVGL 会递归地将所有父对象的坐标累加起来,返回最终的屏幕绝对坐标。
示例:
c
lv_obj_t * parent = lv_obj_create(lv_scr_act()); // 父对象在屏幕(50,50)
lv_obj_set_pos(parent, 50, 50);
lv_obj_t * child = lv_obj_create(parent); // 子对象在父对象内(20,20)
lv_obj_set_pos(child, 20, 20);
// child 的屏幕绝对坐标 = (50+20, 50+20) = (70, 70)
🔧 核心操作:增删改查
- 创建与添加
c
// 创建一个对象,并指定父对象
lv_obj_t * btn = lv_btn_create(parent);
// 也可以创建后,再改变父子关系
lv_obj_t * label = lv_label_create(lv_scr_act());
lv_obj_set_parent(label, new_parent);
- 删除
c
// 删除一个对象,它的所有子对象会被递归地全部删除
lv_obj_del(obj);
注意:删除父对象前,不需要手动删除子对象,LVGL 会自动清理整棵子树,避免内存泄漏。
- 遍历与查找
c
// 获取第一个子对象 / 下一个兄弟对象
lv_obj_t * child = lv_obj_get_child(parent, 0);
lv_obj_t * sibling = lv_obj_get_sibling_next(child);
// 获取父对象
lv_obj_t * parent = lv_obj_get_parent(obj);
// 获取屏幕(根节点)
lv_obj_t * scr = lv_obj_get_screen(obj);
- 改变层级(Z序)
子对象按创建顺序堆叠,后创建的显示在上层。你可以调整顺序:
c
// 把 obj 移动到所有兄弟对象的最前面(最上层)
lv_obj_move_foreground(obj);
// 把 obj 移动到最后面(最下层)
lv_obj_move_background(obj);
// 获取子对象数量
uint32_t cnt = lv_obj_get_child_cnt(parent);
📨 事件冒泡:对象树的隐藏通道
这是对象树最巧妙的应用之一。当一个对象触发事件时(例如被点击),如果它自己没有消费这个事件,事件会沿着父对象链一直向上传递,直到被处理或到达屏幕根节点。
经典场景:点击空白区域关闭弹窗
c
// 创建一个全屏半透明的背景遮罩
lv_obj_t * bg = lv_obj_create(lv_scr_act());
lv_obj_set_size(bg, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_opa(bg, LV_OPA_50, 0);
// 在背景上创建一个弹窗
lv_obj_t * popup = lv_obj_create(bg);
lv_obj_set_size(popup, 200, 150);
lv_obj_center(popup);
// 只需给背景添加点击事件回调
lv_obj_add_event_cb(bg, bg_click_cb, LV_EVENT_CLICKED, popup);
回调函数中判断点击的是背景自身还是弹窗:
c
static void bg_click_cb(lv_event_t * e)
{
lv_obj_t * target = lv_event_get_target(e);
lv_obj_t * bg = lv_event_get_current_target(e);
// 只有当点击的目标就是背景本身时,才关闭弹窗
if(target == bg) {
lv_obj_del(bg); // 删除背景,连带删除弹窗
}
}
当点击弹窗上的按钮时,事件会冒泡到 bg,但由于 target != bg,所以不会误删窗口。
🎯 内存管理:自动回收
对象树与 LVGL 的内存管理紧密结合。当你删除一个父对象时,它所有的子对象以及子对象的子对象都会被自动删除,占用的内存会被释放。
最佳实践:设计界面时,尽量将功能相关的控件放在一个公共的父容器下。要切换页面或关闭弹窗时,只需删除这个父容器,就能干净利落地释放所有资源,无需担心内存泄漏。
c
// 创建一个页面容器
lv_obj_t * page1 = lv_obj_create(lv_scr_act());
lv_obj_set_size(page1, LV_PCT(100), LV_PCT(100));
// ... 在 page1 内创建大量控件
// 切换到另一个页面时,直接删除 page1
lv_obj_del(page1); // 内部所有控件自动销毁,内存安全
⚠️ 常见陷阱
-
循环引用:不能把 A 设为 B 的父对象,又把 B 设为 A 的父对象。LVGL 会检测并阻止这种操作。
-
屏幕对象不要随意删除 :
lv_scr_act()返回的屏幕是根节点,删除它会导致整个 GUI 崩溃。正确的做法是调用lv_scr_load(new_scr)切换屏幕。 -
删除对象后不要继续使用 :删除对象后,指向它的指针就变成了"悬垂指针"。继续使用会导致程序崩溃。一个好的习惯是删除后立即将指针置为
NULL。
💎 总结
对象树是 LVGL 的组织骨架,它让你的 UI 代码逻辑清晰、易于维护:
-
用父子关系实现相对布局和整体移动。
-
用容器(基础对象)划分界面模块。
-
利用事件冒泡简化交互逻辑。
-
通过删除父容器一键回收整页内存。
掌握对象树,你就能像搭积木一样,高效、稳定地构建出复杂的嵌入式图形界面。
4.核心函数
1. lv_init() ------ 初始化函数
c
lv_init();
这是 LVGL 库的初始化函数。在调用任何其他 LVGL 函数之前,你必须先调用它。
它具体干了什么?
-
初始化 LVGL 内部的所有全局变量、链表、互斥锁(如果有 RTOS)。
-
准备好内存管理系统。
-
重要 :它不涉及任何硬件(屏幕、触摸),那些是后续
lv_disp_drv_register和lv_indev_drv_register要做的事。
2. lv_tick_inc(period_ms) ------ 计时:永不间断的节拍器
这是 LVGL 里最特殊的一个函数 ,因为它是唯一一个绝对不能在主循环里被阻塞调用 的函数。它必须在一个硬件中断里运行。
c
// 假设你的 MCU 定时器每 1ms 产生一次中断
void SysTick_Handler(void) {
lv_tick_inc(1); // 告诉 LVGL:刚刚过去了 1 毫秒
}
为什么它如此重要?
-
LVGL 所有的计时(动画速度、定时器周期、长按判定)都依赖这个累加起来的全局毫秒计数器。
-
如果这个函数不运行,或者运行延迟了,整个界面就会"时间静止"------动画不动、按钮没反应。
小白常犯错误:
-
❌ 把
lv_tick_inc(1)放在while(1)循环里。 -
✅ 它必须放在 SysTick 中断 或 硬件定时器中断 里,保证即便主程序卡死了,它依然在精准计时。
3. lv_timer_handler() ------ LVGL 图形库的"心脏"
lv_timer_handler() 是 LVGL 图形库的"心脏"。简单来说,它就像一个"超级管家",在主循环中被周期性调用,负责检查并执行 LVGL 系统中所有待处理的任务,比如屏幕刷新、动画运行、定时器回调和用户输入处理。如果 UI 出现了卡顿或无响应,通常问题就出在这个"管家"没有被及时、充分地调用。
🔄 它具体做了什么?(核心功能)
lv_timer_handler() 的工作主要包括以下几个方面:
-
管理定时任务:它会检查所有已注册的软件定时器,如果有定时器到达预设时间,就立即执行其对应的回调函数。
-
驱动界面刷新:它会检查屏幕上是否有区域被标记为"需要重绘",并执行实际的绘制操作。
-
运行动画效果:LVGL 的所有动画(如控件的缓动、切换)也是基于定时器实现的,因此同样由其驱动。
-
处理用户输入:它会周期性地读取输入设备(如触摸屏、按键)的状态,并将其转化为 LVGL 能理解的事件。
-
提供精确延时信息 :执行完一次任务后,它会返回一个值,告诉你距离下一个定时任务触发还有多少毫秒(ms)。这个值非常有用,可以用来实现更高效的调度,避免 CPU 空转。
⚙️ 它是如何工作的?
-
节拍心跳 (
lv_tick_inc()) :lv_timer_handler()依赖一个全局时间基准(节拍/Tick)。你需要在定时器中断中调用lv_tick_inc(x),每次告知 LVGL 过去了x毫秒。 -
任务轮询 (
lv_timer_handler()) :在主循环中被周期性调用。它会根据lv_tick_inc累积的时间,检查哪些任务(定时器、刷新等)该执行了,并按顺序逐一处理。 -
非抢占式执行 :任务按顺序执行,一个任务的回调必须完全执行完毕,才会轮到下一个。这种机制保证了代码的简洁和安全。
🧩 如何调用它?(集成模式)
LVGL 提供了几种调用 lv_timer_handler() 的模式,适应不同的场景:
-
裸机环境 (Super-loop) :最简单,在主循环的
while(1)中直接调用。c
// 裸机简单示例 while(1) { lv_timer_handler(); // 处理 LVGL 任务 usleep(5000); // 简单延时 5ms } -
优化模式 (精确延时):利用返回值实现精确延时,节省 CPU 资源。
c
while(1) { uint32_t time_till_next = lv_timer_handler(); // 如果返回 LV_NO_TIMER_READY,则延时一个默认周期 (如 5ms) if(time_till_next == LV_NO_TIMER_READY) { time_till_next = 5; } usleep(time_till_next * 1000); // 休眠直至下一个任务就绪 } -
便捷模式 (
lv_timer_handler_run_in_period()):如果你只需要固定的调用频率,可以使用这个辅助函数。c
while(1) { // ... 其他任务 lv_timer_handler_run_in_period(5); // 保证每 5ms 调用一次 lv_timer_handler() // ... 其他任务 } -
操作系统 (OS/RTOS) 环境:通常将其作为一个独立任务运行。
c
void lvgl_task(void *pvParameters) { while(1) { vTaskDelay(pdMS_TO_TICKS(lv_timer_handler())); // 使用返回值精确休眠 } }
⚠️ 常见陷阱与注意事项
-
阻塞式延时 :绝对不要在定时器或事件回调函数中使用
while循环或长时间的delay()。这会阻塞lv_timer_handler的执行,导致整个界面假死。 -
调用频率 :建议调用间隔不超过 5ms,以保证界面交互的流畅性。
-
防止重入 :
lv_timer_handler本身有防重入机制。如果在多线程(RTOS)环境下,你需要使用互斥锁来保护所有 LVGL API 的调用,而不仅仅是lv_timer_handler。 -
LV_NO_TIMER_READY状态 :如果lv_timer_handler()返回LV_NO_TIMER_READY(最大值),表示当前没有任何活动的定时器。这是正常的休眠信号,此时 LVGL 会暂停刷新,直到有新任务被创建。
🤝 与其他函数的协作关系
-
与
lv_tick_inc():lv_timer_handler是执行者,lv_tick_inc是它的时间来源。后者驱动前者,前者依赖后者。 -
与
lv_timer_ready():此函数可强制一个定时器在下一次lv_timer_handler调用时立即执行。它只是修改时间戳,并不直接调用回调。 -
与
lv_timer_get_idle():此函数用于获取lv_timer_handler的空闲时间百分比,可用于评估系统负载。
💎 总结
可以把 lv_timer_handler() 视为 LVGL 世界的引擎和时钟。它驱动着整个 GUI 系统的运转。理解它如何工作、如何正确调用以及如何与其他函数协作,是从"会用 LVGL"进阶到"精通 LVGL"的关键一步。
4. lv_timer_create(cb, period, user_data) ------ 预约定时任务
这是你用来让 LVGL 自动执行重复任务的工具。
c
// 定义一个任务:每 500ms 让 LED 闪烁一下
void my_timer_cb(lv_timer_t * timer) {
static bool led_state = false;
led_state = !led_state;
set_led(led_state);
}
// 在主函数里“预约”这个任务
lv_timer_create(my_timer_cb, 500, NULL);
需要注意什么?
-
不要在这个回调里做耗时操作 (比如
delay)。因为它是在lv_timer_handler内部被调用的,如果卡住,整个界面都会卡死。 -
与对象事件回调的区别:
-
lv_timer_create→ 基于时间触发。 -
lv_obj_add_event_cb→ 基于交互触发(如点击)。
-
5.lv_obj_add_event_cb()
回调函数是LVGL让界面"活起来"的核心机制。没有它,你创建的按钮、滑块就只是静态的装饰画;有了它,用户每次点击、滑动都能触发你自定义的动作。
🔄 回调函数是什么?
回调函数本质上是一个你写好、但不由你亲自调用的函数。你把它"注册"给LVGL,告诉LVGL:"当某个事件发生时,请帮我调用这个函数"。
打个比方:你把手机号码(回调函数)留给快递员(LVGL),告诉他"包裹到了请给我打电话"(注册事件)。之后你不需要时刻盯着窗外,快递员会在合适的时间主动打给你(调用回调),你接电话处理包裹(执行函数体)。
🧩 核心API:注册与删除回调
与回调函数打交道的最重要函数是这两个:
c
// 为对象添加一个事件回调
lv_obj_add_event_cb(lv_obj_t * obj, lv_event_cb_t event_cb, lv_event_code_t filter, void * user_data);
// 删除对象的某个回调(需要提供注册时返回的句柄,也可删除所有)
bool lv_obj_remove_event_cb(lv_obj_t * obj, lv_event_cb_t event_cb);
-
obj:要监听的对象,比如一个按钮。 -
event_cb:你编写的函数指针,格式固定为static void my_callback(lv_event_t * e)。 -
filter:关心的事件类型,比如LV_EVENT_CLICKED(点击)、LV_EVENT_VALUE_CHANGED(值改变)。 -
user_data:一个万能指针,你可以传递任何数据给回调函数,在回调内部通过lv_event_get_user_data(e)取出来用。这个参数极为常用。
📨 事件参数 lv_event_t:从回调中获取一切信息
每个回调函数都接收一个 lv_event_t * e 参数,它就是LVGL在事件发生时打包给你的"快递包裹"。通过一系列 lv_event_get_xxx(e) 函数,你可以拆开包裹拿到里面的信息:
| 你要获取的信息 | 使用的函数 | 说明 |
|---|---|---|
| 是哪个对象触发了事件? | lv_event_get_target(e) |
返回触发事件的对象,例如被点击的按钮。 |
| 当前正在处理哪个对象? | lv_event_get_current_target(e) |
在事件冒泡时可能不同于 target,初学先记前者。 |
| 事件码是什么? | lv_event_get_code(e) |
返回 LV_EVENT_CLICKED 等常量。 |
我注册时传的 user_data 呢? |
lv_event_get_user_data(e) |
取出万能指针,常用来传递关联的控件。 |
| 事件携带的额外参数? | lv_event_get_param(e) |
不同事件附带不同数据,比如按键事件会附带按键值。 |
🎬 典型场景实战
1️⃣ 最简单的按钮点击
c
// 回调函数实现
static void btn_click_cb(lv_event_t * e)
{
// 获取被点击的按钮对象(其实也可以直接用user_data传进来)
lv_obj_t * btn = lv_event_get_target(e);
// 改变按钮上的文字
lv_obj_t * label = lv_obj_get_child(btn, 0);
lv_label_set_text(label, "Clicked!");
}
// 注册回调
lv_obj_t * btn = lv_btn_create(lv_scr_act());
lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, NULL);
2️⃣ 利用 user_data 联动多个控件
这是最灵活的模式:注册时把另一个控件的指针作为 user_data 传入,回调里拿到它进行操作。
c
// 回调:当滑块值变化时,更新标签
static void slider_update_label_cb(lv_event_t * e)
{
lv_obj_t * slider = lv_event_get_target(e); // 滑块自己
lv_obj_t * label = lv_event_get_user_data(e); // 我们关联的标签
char buf[16];
lv_snprintf(buf, sizeof(buf), "%d%%", (int)lv_slider_get_value(slider));
lv_label_set_text(label, buf);
lv_obj_align_to(label, slider, LV_ALIGN_OUT_TOP_MID, 0, -10);
}
// 创建滑块和标签
lv_obj_t * slider = lv_slider_create(lv_scr_act());
lv_obj_t * label = lv_label_create(lv_scr_act());
// 注册回调,并将 label 作为 user_data 传进去
lv_obj_add_event_cb(slider, slider_update_label_cb, LV_EVENT_VALUE_CHANGED, label);
3️⃣ 处理多种事件
同一个对象可以注册多个回调,也可以在一个回调里处理多种事件(通过 lv_event_get_code(e) 判断)。
c
static void btn_multi_event_cb(lv_event_t * e)
{
lv_event_code_t code = lv_event_get_code(e);
if(code == LV_EVENT_PRESSED) {
// 按下时的效果
lv_obj_set_style_bg_color(lv_event_get_target(e), lv_palette_main(LV_PALETTE_RED), 0);
}
else if(code == LV_EVENT_RELEASED) {
// 松开时恢复
lv_obj_set_style_bg_color(lv_event_get_target(e), lv_palette_main(LV_PALETTE_BLUE), 0);
}
else if(code == LV_EVENT_CLICKED) {
// 真正的点击动作
// ...
}
}
🌐 事件冒泡:对象树的传递
LVGL事件还有一个高级特性------事件冒泡。当一个对象触发事件后,如果它自己没有处理(或者即使处理了),事件会像水泡一样沿着父对象链向上传递,直到被"阻止冒泡"或到达顶层屏幕。
这对于实现类似"点击背景关闭弹窗"的效果非常有用。你只需要在背景层(父对象)监听点击事件,子控件触发的事件会冒泡上来,但你可以通过判断 target 是不是背景自身来决定是否关闭。
c
static void bg_click_cb(lv_event_t * e)
{
// 只有直接点击背景(target == 背景自己)才关闭窗口
if(lv_event_get_target(e) == lv_event_get_current_target(e)) {
lv_obj_del(lv_event_get_current_target(e)); // 删除这个背景窗口
}
}
⏱️ 特殊事件 LV_EVENT_DELETE
当一个对象即将被销毁时,会发送 LV_EVENT_DELETE 事件。如果你的回调里使用了动态分配的内存,或者需要做清理工作,这是最佳时机。
🧰 与定时器回调的区别
除了对象事件回调,LVGL还有一个独立的定时器(Timer) 系统,用于执行周期性任务,比如每100ms更新一次时钟。定时器回调的注册方式不同:
c
lv_timer_t * timer = lv_timer_create(my_timer_cb, 100, user_data);
不要混淆:对象事件回调是用户交互驱动 ,定时器回调是时间驱动。
📝 总结:上手流程
-
写一个回调函数 ,原型
static void my_cb(lv_event_t * e)。 -
函数内用
lv_event_get_target(e)或lv_event_get_user_data(e)拿到需要的控件指针。 -
调用
lv_obj_add_event_cb(obj, my_cb, 事件类型, 额外数据)完成绑定。 -
如果需要传递多个数据,可以把它们打包成一个结构体,传递结构体指针。
回调函数是LVGL交互设计的基石,掌握它,你就真正掌握了让界面响应每个触控动作的能力。
疑问
lv_timer_handler 的调用频率 ≠ 屏幕刷新率(30/60帧)
lv_timer_handler 的调用频率 ≠ 屏幕刷新率(30/60帧)。它们是两个相互关联但各自独立的时钟系统。
LVGL 的设计哲学是 "按需刷新" :屏幕不是机械地每秒重绘 60 次,而是只有界面上真正发生了变化(比如动画进行中、手指滑动、进度条更新)时,才会去执行昂贵的绘制操作。
为了帮你彻底理清这两者的关系,下面从三个维度来拆解。
1. 心跳节拍 lv_tick_inc:LVGL 的"秒针"
这是所有逻辑的基础。你在硬件定时器中断(如 SysTick)中每 1ms 调用一次 lv_tick_inc(1),告诉 LVGL 时间过去了 1 毫秒。
-
作用 :这是 LVGL 内部所有定时器、动画进度的时间标尺。
-
与帧率的关系:无直接关系。它只是用来计算"这个动画现在应该移动到什么位置了"。
2. lv_timer_handler 的调用:LVGL 的"检查和执行"
这是你在主循环中调用(或交给 RTOS 调度)的函数。
-
它的职责:检查时间线。比如:"有一个定时 10ms 的动画到了吗?","触摸屏有新数据吗?"。
-
它的结果 :如果检查到有任务需要处理(比如动画到了下一帧的位置),它会计算新界面的数据 ,然后标记对应的屏幕区域为"脏区" 。最后,如果存在"脏区",它会调用底层驱动(
flush_cb)把这一小块区域发给屏幕硬件。 -
调用频率建议 :建议 5ms - 10ms 调用一次。这决定了输入响应延迟 和动画开始的最小延迟。如果你 50ms 才调用一次,即使屏幕是 60Hz,你也会觉得操作非常卡顿。
3. 30帧 / 60帧:屏幕硬件的"最终输出"
这是指显示驱动芯片(如 ILI9341、ST7789)通过 SPI/RGB 接口向 LCD 玻璃面板物理刷新像素点的频率。
-
理想情况(有硬件同步) :如果你开启了屏幕的 TE(撕裂效应)信号,LVGL 的
flush_cb会在屏幕硬件准备好接收下一帧数据时才发送。这时,屏幕物理刷新率 = LVGL 发送数据的最大上限。比如屏幕是 60Hz,LVGL 即使算得再快,数据也是一秒最多发 60 次。 -
常见情况(无硬件同步) :大部分嵌入式 SPI 屏没有同步信号。LVGL 只要算出脏区,就立刻通过 SPI 发过去。此时屏幕的"帧率"实际上是变化的:
-
静止画面 :0 帧(LVGL 完全不调用
flush_cb)。 -
缓慢动画:可能 10-20 帧(取决于动画每帧变化需要的时间)。
-
全屏滑动:可能冲到 60-100 帧(受限于 SPI 传输速度)。
-
4. 实战关系解析:为什么有时候动画不流畅?
| 你的操作 | LVGL 内部行为 | 屏幕表现 | 影响因素 |
|---|---|---|---|
lv_timer_handler 调用太慢 |
检查时间线滞后,本该 10ms 触发的动画 20ms 才触发 | 肉眼可见的掉帧、卡顿 | 软件调度问题 |
flush_cb 传输太慢 |
LVGL 算好了,但 SPI 发送数据堵车 | 画面撕裂、更新缓慢 | 硬件带宽瓶颈 |
| 没有开 DMA 传输 | CPU 死等 SPI 发完数据 | lv_timer_handler 被长时间阻塞,触摸失灵 |
CPU 资源耗尽 |
5. LVGL 如何设置"目标帧率"?
虽然 LVGL 没有全局的"设置 60 帧"开关,但你可以通过两个宏来控制它的最大刷新请求频率:
A. 控制屏幕刷新周期(lv_conf.h)
c
// 默认是 30ms,即 LVGL 最快每 30ms 才会发起一次屏幕刷新请求(约 33 帧上限)
#define LV_DISP_DEF_REFR_PERIOD 30
如果设置为 16,则理论上支持到 62.5 帧上限。
B. 控制输入设备读取周期
c
// 默认 30ms 读取一次触摸屏
#define LV_INDEV_DEF_READ_PERIOD 30
6. 最佳实践总结表
| 想要达到的效果 | 应该如何配置 |
|---|---|
| 极致的 60FPS 流畅动画 | 1. LV_DISP_DEF_REFR_PERIOD 设为 16 。 2. lv_timer_handler 保证每 5-8ms 调用一次。 3. 使用 DMA 传输 SPI 数据。 |
| 平衡性能与功耗 | 1. LV_DISP_DEF_REFR_PERIOD 设为 30 (默认 33 帧)。 2. lv_timer_handler 利用返回值 sleep 等待,每 10-20ms 唤醒一次。 |
| 完全静止的仪表盘界面 | 1. 不需要高频调用 lv_timer_handler。 2. 利用 lv_obj_invalidate() 仅在数据变化时主动刷新。 |
总结 :lv_timer_handler 是"导演",它决定何时排练 ;屏幕是"演员",它负责上台表演。导演喊"开始"的节奏(心跳)决定了排练的效率,但演员实际在台上展现了多少次(帧率),取决于舞台设备(硬件带宽)和导演有没有安排新动作(界面是否变化)。
关系
🔗 核心函数协作流程图
为了让你一目了然,我用文字模拟一遍开机后的运行轨迹:
text
[MCU 启动]
↓
1. 硬件初始化 (GPIO, SPI, 屏幕背光...)
↓
2. 调用 lv_init() [导演就位]
↓
3. 注册显示驱动、触摸驱动
↓
4. 创建界面控件 (按钮、标签...)
↓
5. 启动 SysTick 定时器中断 (1ms) [节拍器开始打拍子]
↓
┌─────────────────────────────────────────┐
│ 主循环 while(1) │
├─────────────────────────────────────────┤
│ 6. 调用 lv_timer_handler() │ [舞台监督看表]
│ - 内部检查:有定时器到期吗? │
│ - 内部检查:触摸屏有数据吗? │
│ - 如果需要,刷新屏幕 │
│ │
│ 7. 短暂延时 (根据返回值决定休眠时间) │ [等下一场]
└─────────────────────────────────────────┘
↑ ↓
└──── SysTick 中断触发 ───────┘
(在后台默默调用 lv_tick_inc(1))
⚠️ 小白避坑指南
-
界面是静止的、不动的?
- 99% 的可能性 :你的
lv_tick_inc()没被调用 ,或者 SysTick 中断没配置对。检查一下中断是否真的进去了。
- 99% 的可能性 :你的
-
动画一卡一卡的,像慢动作?
- 可能原因 :你的
lv_timer_handler()调用间隔太长了(比如 50ms 才调一次),或者你在主循环里做了delay_ms(1000)这种阻塞式延时。
- 可能原因 :你的
-
触摸屏点了没反应?
- 可能原因 :
lv_timer_handler里会读取触摸数据,如果你在主循环里执行了一个很耗时的计算(比如printf打印大量日志),导致lv_timer_handler很久才执行一次,它就漏掉了触摸信号。
- 可能原因 :
💎 总结:三个一工程
为了让 LVGL 跑起来,你只需要记住:
-
一个初始化 :
lv_init()。 -
一个中断心跳 :在 1ms 中断里放
lv_tick_inc(1)。 -
一个循环任务 :在
while(1)里放lv_timer_handler()。
只要这三个点配置无误,你的屏幕就一定能亮起来,动画就一定能动起来。剩下的,就是尽情调用 lv_btn_create、lv_label_create 去创造你的界面了。
5.组件
🧩 常用控件
基于基础对象,LVGL提供了许多功能丰富的控件(Widgets)。下面表格中介绍几个最常用的:
| 控件名称 | API 函数前缀 | 一句话描述 |
|---|---|---|
| 标签 (Label) | lv_label_ |
用于在屏幕上显示单行或多行文本。 |
| 按钮 (Button) | lv_btn_ |
一个可点击的控件,用于触发某个动作。 |
| 开关 (Switch) | lv_switch_ |
一个有两种状态(开/关)的切换控件。 |
| 滑块 (Slider) | lv_slider_ |
一个可以在一个范围内连续调节数值的控件。 |
| 进度条 (Bar) | lv_bar_ |
用于可视化地显示一个任务的进度。 |
下面是一段综合示例,展示了如何创建标签、按钮和滑块,并让它们相互配合:
c
/* 1. 创建一个滑块 */
lv_obj_t * slider = lv_slider_create(lv_scr_act());
lv_obj_set_size(slider, 200, 10); // 设置大小
lv_obj_center(slider); // 放在屏幕中心
/* 2. 创建一个标签,放在滑块上方 */
lv_obj_t * label = lv_label_create(lv_scr_act());
lv_label_set_text(label, "0%"); // 设置初始文本
lv_obj_align_to(label, slider, LV_ALIGN_OUT_TOP_MID, 0, -10); // 对齐到滑块上方
/* 3. 为滑块添加事件回调,当值改变时,更新标签 */
lv_obj_add_event_cb(slider, slider_event_cb, LV_EVENT_VALUE_CHANGED, label);
这里的关键点是,lv_obj_add_event_cb 函数的最后一个参数 label 是一个"用户数据",它会被传递到事件回调函数中。这个技巧稍后会在事件处理部分详细说明。
🎨 样式与主题
如果把对象比作房子的"毛坯",那么样式(Style) 就是"精装修"。通过样式,你可以控制控件的背景色、边框、文字字体、透明度等各种视觉属性。
-
共享与复用:你可以创建一个样式对象,然后把它共享给多个控件使用,这不仅方便管理,还能节省内存。
-
局部与状态:你可以为控件不同的部分(如滑块的"手柄"和"轨道")和不同的状态(如"按下"、"选中")设置不同的样式。
以下代码演示了如何创建一个红色背景的样式,并应用给一个按钮:
c
/* 1. 创建一个样式 */
static lv_style_t style_red;
lv_style_init(&style_red);
lv_style_set_bg_color(&style_red, lv_palette_main(LV_PALETTE_RED));
lv_style_set_bg_opa(&style_red, LV_OPA_COVER); // 设置完全不透明
/* 2. 创建一个按钮,并应用样式 */
lv_obj_t * btn = lv_btn_create(lv_scr_act());
lv_obj_add_style(btn, &style_red, 0); // 0 表示默认状态
主题(Theme) 则是一整套预定义的样式集合。应用一个主题,可以让你界面上的所有控件瞬间拥有统一、专业的视觉风格,省去了逐个设置样式的麻烦。
🎯 事件与交互
界面有了,还需要让它"活"起来,这就靠事件(Event) 。事件是用户与界面交互时产生的信号,例如点击(LV_EVENT_CLICKED) 、按下(LV_EVENT_PRESSED) 、值改变(LV_EVENT_VALUE_CHANGED) 等。通过为对象注册回调函数,就可以在这些事件发生时执行自定义的代码。
还记得前面的综合示例中,我们为滑块注册了一个事件回调吗?下面是这个回调函数的实现:
c
/* 滑块值改变时的事件回调函数 */
static void slider_event_cb(lv_event_t * e)
{
lv_obj_t * slider = lv_event_get_target(e); // 获取触发事件的对象(滑块)
lv_obj_t * label = lv_event_get_user_data(e); // 获取我们传递的用户数据(标签)
char buf[8];
lv_snprintf(buf, sizeof(buf), "%d%%", (int)lv_slider_get_value(slider));
lv_label_set_text(label, buf); // 更新标签的文本
lv_obj_align_to(label, slider, LV_ALIGN_OUT_TOP_MID, 0, -10); // 调整位置
}
✨ 动画
动画能让你的界面过渡更自然、更流畅。在LVGL中,几乎任何数值类型的属性都可以被"动画化"。比如,你可以让一个按钮的大小从50x50平滑地增长到100x100,整个过程非常流畅。
c
`lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_x);
lv_anim_set_values(&a, 0, 100);
lv_anim_set_time(&a, 1000);
lv_anim_set_var(&a, btn);
lv_anim_start(&a);`
📐 布局
当界面上的控件越来越多,手动用 x, y 坐标去摆放每个控件会变得非常繁琐。LVGL提供了布局(Layout) 系统来解决这个问题,它类似于Web开发中的CSS Flexbox和Grid,可以让你轻松创建出响应式、自适应的界面。
6.移植
🚀 进阶实战:将LVGL移植到STM32
当你通过模拟器熟悉了LVGL开发后,就可以尝试将它移植到真正的单片机(如STM32)上运行了。
-
硬件准备:你需要一个STM32开发板(如STM32F103/F4系列),以及一个SPI接口的TFT彩屏。
-
软件准备 :使用STM32CubeMX来生成一个基础工程,配置好时钟、SPI接口和屏幕的背光、复位等GPIO引脚。
-
集成LVGL源码:将LVGL的源码文件夹(lvgl)复制到你的工程目录中。
-
配置工程 :在IDE中添加LVGL的头文件路径。然后,复制
lvgl/lv_conf_template.h文件到项目目录并重命名为lv_conf.h,打开它并将开头的#if 0改为#if 1以启用配置。 -
对接底层驱动:这是移植中最关键的一步。
-
显示驱动:编写一个"刷新"函数,实现将LVGL绘制好的像素块,通过SPI或LTDC接口发送到你的屏幕上。
-
触摸驱动:如果你的屏幕带有触摸功能,需要编写一个"读取"函数,返回触摸点的坐标和状态。
-
-
提供心跳时钟 :在定时器中断服务函数(如SysTick_Handler)中,周期性地调用
lv_tick_inc(1),为LVGL提供时间基准。 -
主循环调用 :在主函数的
while(1)循环中,定期调用lv_timer_handler(),驱动整个GUI系统运行。
📚 学习资源与下一步建议
-
官方文档 :最权威的学习资料,地址是 https://docs.lvgl.io/。建议将 "Quick overview" 和 "Learn the Basics" 作为你的入门必读。
-
视频教程:B站、论坛上有大量社区贡献的中文视频教程,搜索"LVGL 教程"即可找到。例如,论坛上有一套广受好评的零基础入门系列教程,无需开发板,基于PC模拟器学习。
-
社区与论坛:遇到问题时,可以到LVGL的官方论坛提问,这里的社区非常活跃。
-
最后的小建议 :你的学习路径可以这样规划:搭建PC模拟器 → 运行并修改官方示例 → 学习核心对象和样式 → 尝试自己组合控件完成一个小界面 → 最后再挑战移植到硬件。不要试图一下吃透所有,循序渐进才能走得更稳。
希望这份教程能为你打开LVGL的大门。编程的乐趣在于创造,现在,开始动手创造你的第一个界面吧!
附录.
视频选集
01_LVGL基础之模拟开发和移植
12:16
02_安装浏览器的沉浸式翻译插件
06:38
03_LVGL适用场景介绍
16:23
04_LVGL引言特征介绍
15:49
05_LVGL模拟配置的工具链介绍
11:56
06_LVGL构建环境上
18:40
07_LVGL完成模拟环境的搭建
06:22
08_LVGL的核心流程
29:50
09_LVGL_创建基础的屏幕
17:04
10_LVGL_创建便于移植的文件
09:50
11_LVGL组件特性_通用特性展示
17:50
12._LVGL组件特性_父级子级关系展示
08:38
13_LVGL组件特性_图层关系
13:40
14_LVGL组件特性_位置和大小
20:54
15_LVGL组件特性_部件和特性
19:04
16_LVGL组件特性_颜色介绍
10:28
17_LVGL组件特性_样式介绍
21:03
18_LVGL组件特性_过渡效果
12:18
19_LVGL组件特性_主题
06:01
20_LVGL组件特性_基础按钮事件
12:14
21_LVGL组件特性_数值变化事件
17:42
22_LVGL组件特性_标志符以及自定义组件介绍
18:09
23_LVGL组件特性_自定义组件实现
14:13
24_LVGL组件特性_弹性布局的分布和对齐介绍
31:22
25_LVGL组件特性_弹性布局的其他内容
10:31
26_LVGL组件特性_网格布局介绍
17:58
27_LVGL组件特性_网格的其他内容
10:03
28_LVGL组件特性_滚动的简单示例
17:39
29_LVGL组件特性_滚动的标志符
08:40
30_LVGL组件特性_滚动吸附功能
12:36
31_LVGL组件特性_手动滚动函数
08:55
32_LVGL组件展示_折线
16:24
33_LVGL组件展示_条形图和led灯
07:06
34_LVGL组件展示_label文本展示
13:50
35_LVGL组件展示_label额外功能
16:39
36_LVGL组件展示_画布富文本弧形标签
04:27
37_LVGL组件展示_动画效果
22:57
38_LVGL组件展示_动画控制
18:33
39_LVGL组件展示_基础的矢量动画
13:23
40_LVGL组件展示_自定义动画显示
06:56
41_LVGL组件展示_阳历和农历的日历展示
17:10
42_LVGL组件展示_日历的点击功能
07:31
43_LVGL组件展示_table展示
10:48
44_LVGL组件展示_基础折线图展示
23:46
45_LVGL组件展示_带有刻度的柱状图
19:44
46_LVGL组件展示_标签视图
13:38
47_LVGL组件展示_瓦片视图和窗口
12:43
48_LVGL组件展示_开关和弧形滑块
16:31
49_LVGL组件展示_消息框
10:20
50_LVGL组件展示_下拉菜单和图片按钮
15:43
51_LVGL组件展示_复选框和滚动条
12:06
52_LVGL组件展示_旋转框
16:16
53_LVGL组件展示_列表和菜单
10:51
54_LVGL组件展示_拼音输入法
19:11
55_LVGL组件展示_修改拼音输入法的字典
09:35
56_LVGL组件展示_添加新字体
18:04
57_LVGL组件展示_展示图片和动图
26:44
58_LVGL组件展示_动画图像和3D纹理
04:02
59_LVGL特殊模块_观察者模式
12:22
60_LVGL特殊模块_观察者模式基础展示
20:26
61_LVGL特殊模块_完善观察者模式
09:31
62_LVGL特殊模块_翻译功能基础流程
15:18
63_LVGL特殊模块_实现动态翻译语言
12:40
64_LVGL移植_基础流程介绍
06:59
65_LVGL移植_STM32开发软件选择
05:41
66_LVGL移植_keil_mdk安装
08:20
67_LVGL移植_keil_mdk配置
05:08
68_LVGL移植_keil注册机使用
02:20
69_LVGL移植_stm32cubemx安装
12:58
70_LVGL移植_构建hal项目
14:34
71_LVGL移植_添加移植文件到项目中
06:51
72_LVGL移植_完成ST7789屏幕驱动兼容
17:51
73_LVGL移植_完成触摸屏驱动兼容
09:19
74_LVGL移植_裁剪conf文件
08:25
75_LVGL移植_编写main方法逻辑
08:38
76_LVGL移植_完成移植展示
05:34
77_LVGL移植_移植自定义的页面
11:36
78_LVGL课程总结
06:32
1.LVGL官网基本组件




2.特殊模块
