LVGL 界面UI库

1.快速了解

Light and Versatile Graphics Library(轻量级多功能图形库),简称 LVGL,是一个免费开源的嵌入式 GUI 库。它用 C 语言编写,允许你在任何 MCU、MPU 和显示器上,创建出类似手机应用般精美流畅的图形界面。简单来说,可以把 LVGL 想象成一套强大的乐高积木,让你用代码"拼"出按钮、图表、滑块等丰富的界面元素。

1.四个特点:为什么选择LVGL?

对于初学者,了解LVGL的几个核心特点,能让你明白它为何如此受欢迎:

  • 轻量级但功能强 :专为资源受限的嵌入式设备设计,在保证低内存和低Flash占用的同时,提供了完整的GUI解决方案,最低只需要 64kB Flash16kB RAM 就能运行。

  • 跨平台能力 :不仅可以在各种单片机(如STM32、ESP32)上运行,还能在Windows、Linux、macOS等桌面操作系统上通过模拟器运行,这为前期的学习、开发和调试带来了极大的便利。

  • 强大的图形能力 :支持动画、抗锯齿、透明度、平滑滚动、图层混合等高级图形效果,让你的产品界面告别单调,更加现代化和富有动感。

  • 灵活的布局和交互:支持多种输入设备(触摸屏、按键、编码器等)和灵活的布局方式(如Flex、Grid),让你可以轻松设计出适应不同屏幕尺寸和交互方式的界面。

2.工作原理:它如何与你的硬件对话

了解LVGL的基本工作流程,有助于你理解代码是如何一步步变成屏幕上的图像的。

  1. 初始化与配置 :首先,你需要初始化你MCU的底层硬件(如时钟、GPIO、SPI等),然后调用**lv_init()**来初始化LVGL库本身。

  2. 注册硬件驱动 :LVGL通过"回调函数"的方式与你的屏幕和输入设备(如触摸屏)进行交互。你需要编写一个"刷新"回调函数,告诉LVGL如何把一个像素点画到你的屏幕上;并编写一个"读取"回调函数,告诉LVGL如何从你的触摸屏获取坐标。

  3. 构建界面:这是核心的创作环节,你可以调用LVGL提供的API来创建各种对象(如按钮、标签),并设置它们的位置、大小、样式,以及添加事件响应(如点击)。

  4. 运行心跳 :在主循环中,你需要不断地调用**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 目录下。

  1. 下载库文件:从 GitHub 克隆或下载 LVGL 源码到你的项目文件夹。

  2. 配置文件 :复制 lv_conf_template.hlv_conf.h,并开启其内容(将 #if 0 改为 #if 1),此文件用于配置库的核心参数。

  3. 包含头文件 :在需要使用 LVGL 功能的源文件中包含 lvgl.h

  4. 提供时钟 :在你的系统定时器中断中,周期性地调用 lv_tick_inc(x) 函数,为 LVGL 提供内部计时基准。

  5. 移植显示与输入:这是最关键的一步。

    • 显示驱动 :调用 lv_display_create() 创建显示设备,并通过 lv_display_set_flush_cb() 注册一个"刷新回调函数"。LVGL 完成绘图后,会调用你注册的这个函数,你需要在此函数内将图像数据发送到显示屏。

    • 输入设备驱动 :如果有触摸屏或键盘等输入设备,需要创建输入设备 (lv_indev_t),并实现读取输入状态的回调函数。

  6. 设计 UI:调用 LVGL 的 API 创建屏幕、控件、设置样式和事件等。

  7. 主循环 :在 main() 函数的主循环中,无限循环地调用 lv_timer_handler() 并添加适当延时,以维持系统运行。

2.工具

1.提升效率的利器:可视化设计工具 SquareLine Studio

如果想进一步缩短开发周期,官方提供的 可视化设计工具 SquareLine Studio 会是很好的帮手。它提供所见即所得的拖拽式界面,无需深入代码即可完成 UI 布局,一键生成标准的 C 代码,直接集成到你的项目中。

2.全球最大的可定制动画库

LottieFiles

可用于嵌入式单片机界面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() 获取的当前活动屏幕,就是这棵树的根。

  • 父对象:一个容器,它的子对象会跟随它移动、缩放,甚至被裁剪。

  • 子对象 :依附于父对象,其坐标是相对于父对象左上角的,而不是屏幕。

🧩 为什么需要对象树?

对象树带来三个核心好处:

  1. 相对定位:你移动一个窗口,里面的所有按钮、文字都会跟着一起动,无需逐个修改坐标。

  2. 视觉裁剪:默认情况下,子对象超出父对象边界的内容会被自动裁剪,不显示出来。这对于实现滚动列表、弹出菜单至关重要。

  3. 事件冒泡:事件(如点击)会沿着对象树向上传递,让你可以用父容器统一处理多个子控件的交互。

📐 坐标系统与对象树

对象树直接决定了控件的位置计算方式。

坐标类型 含义 获取/设置函数
相对坐标 相对于父对象左上角的坐标 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)
🔧 核心操作:增删改查
  1. 创建与添加

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);
  1. 删除

c

复制代码
// 删除一个对象,它的所有子对象会被递归地全部删除
lv_obj_del(obj);

注意:删除父对象前,不需要手动删除子对象,LVGL 会自动清理整棵子树,避免内存泄漏。

  1. 遍历与查找

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);
  1. 改变层级(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); // 内部所有控件自动销毁,内存安全
⚠️ 常见陷阱
  1. 循环引用:不能把 A 设为 B 的父对象,又把 B 设为 A 的父对象。LVGL 会检测并阻止这种操作。

  2. 屏幕对象不要随意删除lv_scr_act() 返回的屏幕是根节点,删除它会导致整个 GUI 崩溃。正确的做法是调用 lv_scr_load(new_scr) 切换屏幕。

  3. 删除对象后不要继续使用 :删除对象后,指向它的指针就变成了"悬垂指针"。继续使用会导致程序崩溃。一个好的习惯是删除后立即将指针置为 NULL

💎 总结

对象树是 LVGL 的组织骨架,它让你的 UI 代码逻辑清晰、易于维护:

  • 用父子关系实现相对布局和整体移动。

  • 用容器(基础对象)划分界面模块。

  • 利用事件冒泡简化交互逻辑。

  • 通过删除父容器一键回收整页内存。

掌握对象树,你就能像搭积木一样,高效、稳定地构建出复杂的嵌入式图形界面。

4.核心函数

1. lv_init() ------ 初始化函数

c

复制代码
lv_init();

这是 LVGL 库的初始化函数。在调用任何其他 LVGL 函数之前,你必须先调用它。

它具体干了什么?

  • 初始化 LVGL 内部的所有全局变量、链表、互斥锁(如果有 RTOS)。

  • 准备好内存管理系统。

  • 重要 :它不涉及任何硬件(屏幕、触摸),那些是后续 lv_disp_drv_registerlv_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() 的工作主要包括以下几个方面:

  1. 管理定时任务:它会检查所有已注册的软件定时器,如果有定时器到达预设时间,就立即执行其对应的回调函数。

  2. 驱动界面刷新:它会检查屏幕上是否有区域被标记为"需要重绘",并执行实际的绘制操作。

  3. 运行动画效果:LVGL 的所有动画(如控件的缓动、切换)也是基于定时器实现的,因此同样由其驱动。

  4. 处理用户输入:它会周期性地读取输入设备(如触摸屏、按键)的状态,并将其转化为 LVGL 能理解的事件。

  5. 提供精确延时信息 :执行完一次任务后,它会返回一个值,告诉你距离下一个定时任务触发还有多少毫秒(ms)。这个值非常有用,可以用来实现更高效的调度,避免 CPU 空转。

⚙️ 它是如何工作的?

  1. 节拍心跳 (lv_tick_inc())lv_timer_handler() 依赖一个全局时间基准(节拍/Tick)。你需要在定时器中断中调用 lv_tick_inc(x),每次告知 LVGL 过去了 x 毫秒。

  2. 任务轮询 (lv_timer_handler()) :在主循环中被周期性调用。它会根据 lv_tick_inc 累积的时间,检查哪些任务(定时器、刷新等)该执行了,并按顺序逐一处理。

  3. 非抢占式执行 :任务按顺序执行,一个任务的回调必须完全执行完毕,才会轮到下一个。这种机制保证了代码的简洁和安全。

🧩 如何调用它?(集成模式)

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())); // 使用返回值精确休眠
        }
    }

⚠️ 常见陷阱与注意事项

  1. 阻塞式延时绝对不要在定时器或事件回调函数中使用 while 循环或长时间的 delay() 。这会阻塞 lv_timer_handler 的执行,导致整个界面假死。

  2. 调用频率 :建议调用间隔不超过 5ms,以保证界面交互的流畅性。

  3. 防止重入lv_timer_handler 本身有防重入机制。如果在多线程(RTOS)环境下,你需要使用互斥锁来保护所有 LVGL API 的调用,而不仅仅是 lv_timer_handler

  4. 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);

不要混淆:对象事件回调是用户交互驱动 ,定时器回调是时间驱动

📝 总结:上手流程

  1. 写一个回调函数 ,原型 static void my_cb(lv_event_t * e)

  2. 函数内用 lv_event_get_target(e)lv_event_get_user_data(e) 拿到需要的控件指针。

  3. 调用 lv_obj_add_event_cb(obj, my_cb, 事件类型, 额外数据) 完成绑定。

  4. 如果需要传递多个数据,可以把它们打包成一个结构体,传递结构体指针。

回调函数是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))

⚠️ 小白避坑指南

  1. 界面是静止的、不动的?

    • 99% 的可能性 :你的 lv_tick_inc() 没被调用 ,或者 SysTick 中断没配置对。检查一下中断是否真的进去了。
  2. 动画一卡一卡的,像慢动作?

    • 可能原因 :你的 lv_timer_handler() 调用间隔太长了(比如 50ms 才调一次),或者你在主循环里做了 delay_ms(1000) 这种阻塞式延时
  3. 触摸屏点了没反应?

    • 可能原因lv_timer_handler 里会读取触摸数据,如果你在主循环里执行了一个很耗时的计算(比如 printf 打印大量日志),导致 lv_timer_handler 很久才执行一次,它就漏掉了触摸信号。

💎 总结:三个一工程

为了让 LVGL 跑起来,你只需要记住:

  • 一个初始化lv_init()

  • 一个中断心跳 :在 1ms 中断里放 lv_tick_inc(1)

  • 一个循环任务 :在 while(1) 里放 lv_timer_handler()

只要这三个点配置无误,你的屏幕就一定能亮起来,动画就一定能动起来。剩下的,就是尽情调用 lv_btn_createlv_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)上运行了。

  1. 硬件准备:你需要一个STM32开发板(如STM32F103/F4系列),以及一个SPI接口的TFT彩屏。

  2. 软件准备 :使用STM32CubeMX来生成一个基础工程,配置好时钟、SPI接口和屏幕的背光、复位等GPIO引脚。

  3. 集成LVGL源码:将LVGL的源码文件夹(lvgl)复制到你的工程目录中。

  4. 配置工程 :在IDE中添加LVGL的头文件路径。然后,复制 lvgl/lv_conf_template.h 文件到项目目录并重命名为 lv_conf.h,打开它并将开头的 #if 0 改为 #if 1 以启用配置。

  5. 对接底层驱动:这是移植中最关键的一步。

    • 显示驱动:编写一个"刷新"函数,实现将LVGL绘制好的像素块,通过SPI或LTDC接口发送到你的屏幕上。

    • 触摸驱动:如果你的屏幕带有触摸功能,需要编写一个"读取"函数,返回触摸点的坐标和状态。

  6. 提供心跳时钟 :在定时器中断服务函数(如SysTick_Handler)中,周期性地调用 lv_tick_inc(1),为LVGL提供时间基准。

  7. 主循环调用 :在主函数的 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.特殊模块

相关推荐
久爱物联网5 小时前
【WinForm UI控件系列】Breadcrumb 面包屑控件,支持三种样式
ui·breadcrumb·面包屑控件·winformui·csharpui控件·桌面ui控件
久爱物联网5 小时前
【WinForm UI控件系列】PieChart饼状图控件
ui·winformui控件·c#控件ui·桌面应用ui控件·gdi绘制控件
久爱物联网6 小时前
【WinForm UI控件系列】Blower 鼓风机控件
ui·ui控件·桌面应用控件·鼓风机控件·winfrom控件
久爱物联网7 小时前
【WinForm UI控件系列】Battery 电池电量控件
ui·winformui控件·桌面应用控件·c#控件ui·ui控件gdi
ZC跨境爬虫7 小时前
UI前端美化技能提升日志day5:从布局优化到CSS继承原理深度解析
前端·css·ui·html·状态模式
久爱物联网7 小时前
【WinForm UI控件系列】AlarmLight 报警灯\声光报警灯控件
ui·winformui控件·桌面应用控件·c# ui控件·gdi控件 net控件
Wenzar_8 小时前
# 发散创新:SwiftUI 中状态管理的深度实践与重构艺术 在 SwiftUI 的世界里,**状态驱动 UI 是核心哲学**。但随
java·python·ui·重构·swiftui
Ulyanov8 小时前
《PySide6 GUI开发指南:QML核心与实践》 第五篇:Python与QML深度融合——数据绑定与交互
开发语言·python·qt·ui·交互·雷达电子战系统仿真
Ulyanov1 天前
《玩转QT Designer Studio:从设计到实战》 QT Designer Studio组件化开发与UI组件库构建
开发语言·python·qt·ui·雷达电子战系统仿真