ARTICLE DETAIL

建站实战干货

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

基于LVGL V8.3的嵌入式圆形表盘UI实战:从资源管理到动态刷新

2026/8/6 1:26:54 拓冰建站 浏览量
基于LVGL V8.3的嵌入式圆形表盘UI实战:从资源管理到动态刷新 1. 项目概述打造一个精致的LVGL圆形表盘最近在折腾一个基于STM32和ESP32的智能手表项目UI部分选择了LVGL这个轻量级图形库。版本迭代到V8.3后其功能和易用性都有了不小的提升。这次的目标很明确设计一个视觉上专业、功能上实用的圆形表盘界面。这不仅仅是画几个圈和显示几个数字那么简单它涉及到字体资源的精细管理、图片素材的高效加载与显示以及实时时间的动态更新。对于嵌入式开发者来说如何在资源受限的MCU上平衡美观与性能是一个既有挑战又有趣的话题。这个表盘将作为设备的主界面需要清晰显示时间时、分、秒并可能集成一些状态图标因此字体选择、图片处理和定时刷新机制是本次设计的三大核心。如果你正在将LVGL移植到STM32、ESP32或者其他微控制器上并且苦恼于界面不够“高级感”那么这篇从实战出发的分享应该能给你提供一条清晰的路径。我会从资源准备开始到核心代码编写最后分享一些我踩过的坑和优化技巧目标是让你能直接复现一个效果不错的圆形表盘。2. 核心思路与资源规划在动手写代码之前理清思路和准备好“弹药”至关重要。一个圆形表盘UI我们可以将其拆解为几个层次最底层是背景可能是纯色、渐变或图片中间层是指针或数字刻度等静态元素最上层是动态变化的文本时间和图标。LVGL V8.3的控件和样式系统足以优雅地实现这些。2.1 设计工具与资源准备虽然最终运行在MCU上但前期的设计工作最好在PC上完成。这里强烈推荐使用LVGL官方推荐的SquareLine Studio或NXP的GUI Guider。它们都是可视化的LVGL UI编辑器能让你以拖拽的方式快速搭建界面原型并直接生成C代码。这对于布局调整、控件对齐来说效率极高能避免早期反复编译下载到开发板的耗时。对于字体表盘时间通常需要一种醒目、易读的字体。系统自带的字体可能过于普通。我推荐使用Poppins、Montserrat这类现代的无衬线字体它们数字显示效果非常棒。你需要用到LVGL提供的在线字体转换工具lv_font_conv。这是一个命令行工具可以将TTF/OTF字体文件转换为LVGL可用的C数组文件。关键参数包括指定Unicode范围对于时间显示至少需要数字0-9和冒号“:”例如--range 0-9,0x3A以及设置抗锯齿--bpp 4效果较好和尺寸。注意字体文件大小随着bpp每像素比特数即抗锯齿等级和尺寸增大而急剧增加。对于嵌入式设备务必精打细算。一个36像素的Poppins字体bpp4可能就需要几十KB的Flash空间。对于图片表盘背景、刻度、图标等都需要图片资源。LVGL支持多种图片格式但在嵌入式环境中建议将图片转换为C数组或二进制bin文件。使用LVGL官方的lv_img_conv工具可以轻松完成。更高效的做法是在SquareLine Studio中导入图片它会自动帮你完成转换并生成资源声明代码。图片格式优先选择PNG带透明度转换时注意颜色格式如CF_TRUE_COLOR_ALPHA以支持透明通道。2.2 工程结构与资源整合一个清晰的工程结构能让你后续维护更轻松。建议在你的项目目录下创建专门的gui或lvgl文件夹并包含以下子目录src/: 存放主要的UI逻辑C文件如ui.c。inc/: 对应的头文件。assets/fonts/: 存放转换生成的字体C文件如ui_font_clock_36.c。assets/images/: 存放转换生成的图片C文件如img_bg.c,img_hand_hour.c。lvgl/: LVGL库本身的源码。在ui.c中你需要包含这些资源文件#include “../assets/fonts/ui_font_clock_36.h” #include “../assets/images/img_bg.h” #include “../assets/images/img_hand_hour.h” // ... 其他资源确保你的编译脚本如Makefile或Keil/IAR的工程配置正确地将这些assets目录下的C文件添加到编译列表中。3. 圆形表盘UI的详细实现步骤有了资源和规划我们就可以开始搭建UI了。整个过程就像搭积木从底层背景开始一层层向上添加。3.1 创建基础容器与背景首先我们需要一个最底层的容器所有其他元素都将放在这个容器里。在LVGL中我们通常直接使用屏幕对象lv_scr_act()或者创建一个全屏的lv_obj_t作为容器。为了便于管理和应用样式我更喜欢创建一个新的对象作为表盘容器。// 创建表盘基础容器 lv_obj_t *watchface_cont lv_obj_create(lv_scr_act()); lv_obj_set_size(watchface_cont, 240, 240); // 假设屏幕是240x240的方形便于实现圆形 lv_obj_center(watchface_cont); lv_obj_clear_flag(watchface_cont, LV_OBJ_FLAG_SCROLLABLE); // 禁止滚动 // 设置圆角使其变为圆形。半径设为宽度的一半。 lv_obj_set_style_radius(watchface_cont, 120, 0); // 0表示应用到所有状态 // 设置背景样式可以是纯色、渐变或图片 // 方案一纯色背景 lv_obj_set_style_bg_color(watchface_cont, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(watchface_cont, LV_OPA_COVER, 0); // 方案二图片背景推荐视觉效果更丰富 lv_obj_t *img_bg lv_img_create(watchface_cont); lv_img_set_src(img_bg, img_bg); // 这里的 img_bg 是之前通过工具转换并#include进来的图片变量 lv_obj_align(img_bg, LV_ALIGN_CENTER, 0, 0);这里的关键是lv_obj_set_style_radius将其设置为宽度的一半矩形就变成了圆形。背景图片需要提前处理好尺寸最好是和容器大小一致的正方形这样显示效果最完美。3.2 时间文本标签的设计与实现时间是表盘的核心。我们将创建三个标签Label对象分别显示时、分、秒并使用准备好的精美字体。// 创建小时标签 lv_obj_t *label_hour lv_label_create(watchface_cont); // 应用我们转换好的大字体 lv_obj_set_style_text_font(label_hour, ui_font_clock_36, 0); lv_obj_set_style_text_color(label_hour, lv_color_hex(0xFFFFFF), 0); lv_label_set_text(label_hour, “12”); // 初始文本 lv_obj_align(label_hour, LV_ALIGN_CENTER, -50, -30); // 粗略定位后续需精细调整 // 创建分钟标签 lv_obj_t *label_min lv_label_create(watchface_cont); lv_obj_set_style_text_font(label_min, ui_font_clock_36, 0); lv_obj_set_style_text_color(label_min, lv_color_hex(0xFFFFFF), 0); lv_label_set_text(label_min, “34”); lv_obj_align(label_min, LV_ALIGN_CENTER, 10, -30); // 创建秒标签通常字体小一些 lv_obj_t *label_sec lv_label_create(watchface_cont); lv_obj_set_style_text_font(label_sec, lv_font_montserrat_16, 0); // 使用稍小的系统字体 lv_obj_set_style_text_color(label_sec, lv_color_hex(0xFF5555), 0); // 红色突出秒 lv_label_set_text(label_sec, “56”); lv_obj_align(label_sec, LV_ALIGN_CENTER, 0, 20); // 创建冒号分隔符可以是一个静态标签也可以整合到时间更新逻辑中 lv_obj_t *label_colon lv_label_create(watchface_cont); lv_obj_set_style_text_font(label_colon, ui_font_clock_36, 0); lv_obj_set_style_text_color(label_colon, lv_color_hex(0xAAAAAA), 0); lv_label_set_text(label_colon, “:”); lv_obj_align(label_colon, LV_ALIGN_CENTER, -20, -30);字体对象的引用如ui_font_clock_36来自我们之前转换并包含的字体文件。对齐lv_obj_align的坐标需要根据你的字体尺寸和设计稿反复调试以达到最佳视觉效果。一个技巧是先在SquareLine Studio里摆好位置再导出代码参考坐标。3.3 动态时间更新机制静态显示不是我们的目标我们需要让时间“动起来”。这需要两个步骤获取当前时间并定时更新标签文本。获取时间这取决于你的硬件平台。有RTC的STM32你可以从硬件RTC如STM32的RTC外设读取日历时间。ESP32可以通过SNTP网络时间协议从互联网获取或者从内部RTC读取精度稍差。无外部时钟源的MCU可能需要依靠外部模块如DS1302、DS3231或通过其他通信方式如蓝牙从手机同步。这里以STM32的HAL库RTC为例假设你已经配置并初始化好了RTC。// 获取时间的函数 void update_time(void) { RTC_TimeTypeDef sTime; RTC_DateTypeDef sDate; char buffer[10]; // 从RTC获取时间和日期 HAL_RTC_GetTime(hrtc, sTime, RTC_FORMAT_BIN); HAL_RTC_GetDate(hrtc, sDate, RTC_FORMAT_BIN); // 格式化小时和分钟补零 snprintf(buffer, sizeof(buffer), “%02d”, sTime.Hours); lv_label_set_text(label_hour, buffer); snprintf(buffer, sizeof(buffer), “%02d”, sTime.Minutes); lv_label_set_text(label_min, buffer); // 格式化秒 snprintf(buffer, sizeof(buffer), “%02d”, sTime.Seconds); lv_label_set_text(label_sec, buffer); }定时更新我们需要创建一个LVGL定时器lv_timer_t让它每隔一段时间比如1秒调用一次update_time函数。千万不要在while(1)循环里用HAL_Delay这会阻塞LVGL的任务处理。// 定时器回调函数 static void timer_cb(lv_timer_t *timer) { update_time(); } // 在UI初始化函数中创建定时器 void ui_init(void) { // ... 创建所有UI对象容器、标签、图片等的代码 // 创建1秒周期的定时器 lv_timer_t *timer lv_timer_create(timer_cb, 1000, NULL); // 1000毫秒 1秒 lv_timer_set_repeat_count(timer, -1); // -1表示无限重复 // 立即更新一次时间避免启动后1秒的空白 update_time(); }这样一个能够每秒自动更新的数字时钟核心就完成了。LVGL的定时器与它的内部任务系统紧密结合是处理此类周期性UI更新的最佳实践。4. 高级美化与性能优化基础功能实现后我们可以追求更极致的视觉效果和更流畅的性能。4.1 图片指针式表盘实现除了数字显示传统的指针式表盘更有机械美感。我们可以用三张图片时针、分针、秒针通过旋转来实现。准备素材需要三张指向12点方向的指针图片最好是PNG格式带透明背景。图片的“轴心点”即旋转中心应该在指针的根部。在图像处理软件中确保指针根部位于画布中心或特定位置方便后续LVGL对齐。创建图片对象并设置轴心lv_obj_t *img_hour_hand lv_img_create(watchface_cont); lv_img_set_src(img_hour_hand, img_hand_hour); lv_obj_align(img_hour_hand, LV_ALIGN_CENTER, 0, 0); lv_img_set_pivot(img_hour_hand, pivot_x, pivot_y); // 关键设置旋转中心 lv_obj_t *img_min_hand lv_img_create(watchface_cont); // ... 类似创建分针和秒针这里的pivot_x和pivot_y是相对于图片自身左上角的坐标。如果你的指针图片尺寸是60x20根部在(30, 18)的位置那么轴心就应设为(30, 18)。这通常需要一些尝试来精确定位。在定时器回调中更新旋转角度static void timer_cb(lv_timer_t *timer) { RTC_TimeTypeDef sTime; HAL_RTC_GetTime(hrtc, sTime, RTC_FORMAT_BIN); // 计算角度。一圈360度。 // 小时每12小时360度加上分钟带来的偏移 (30度/60分钟) int16_t hour_angle (sTime.Hours % 12) * 30 (sTime.Minutes * 30) / 60; // 分钟每60分钟360度 int16_t min_angle sTime.Minutes * 6; // 秒每60秒360度 int16_t sec_angle sTime.Seconds * 6; // 设置旋转 lv_img_set_angle(img_hour_hand, hour_angle * 10); // LVGL角度单位是0.1度所以乘以10 lv_img_set_angle(img_min_hand, min_angle * 10); lv_img_set_angle(img_sec_hand, sec_angle * 10); // 如果同时有数字显示也更新数字标签 update_digital_time(); }4.2 样式Style的深度应用LVGL的样式系统非常强大可以统一管理控件的外观。我们可以为标签创建专门的样式而不是逐个设置属性。// 定义一个时钟数字的样式 static lv_style_t style_clock_num; lv_style_init(style_clock_num); lv_style_set_text_font(style_clock_num, ui_font_clock_36); lv_style_set_text_color(style_clock_num, lv_color_white()); lv_style_set_text_opa(style_clock_num, LV_OPA_COVER); // 可以添加阴影效果 lv_style_set_shadow_color(style_clock_num, lv_color_black()); lv_style_set_shadow_width(style_clock_num, 10); lv_style_set_shadow_ofs_x(style_clock_num, 2); lv_style_set_shadow_ofs_y(style_clock_num, 2); // 将样式应用到小时和分钟标签 lv_obj_add_style(label_hour, style_clock_num, 0); lv_obj_add_style(label_min, style_clock_num, 0);使用样式不仅使代码更整洁而且如果需要全局更换字体或颜色只需修改样式定义即可维护性大大提升。4.3 性能优化与内存管理在资源紧张的MCU上性能优化是永恒的主题。图片缓存与解码LVGL在显示图片时默认会进行实时解码对于非原始RGB格式。频繁旋转的指针图片应该使用LV_IMG_CF_TRUE_COLOR_ALPHA格式这样图片数据已经是MCU可以直接使用的格式无需解码旋转操作只是改变一个角度属性的代价极小。在转换图片时通过lv_img_conv工具指定输出格式为CF_TRUE_COLOR_ALPHA。局部刷新与无效化区域LVGL本身支持局部刷新。但对于我们这个每秒都在变化的表盘整个圆形区域几乎都是“脏区域”。一个优化技巧是如果只有秒针在快速转动可以尝试只将秒针上一秒和当前秒所在的两个扇形区域标记为无效lv_obj_invalidate_area但这实现起来较复杂。对于大多数应用让LVGL自动处理刷新区域即可。确保你的lv_tick_inc()函数被正确调用通常在SysTick中断中并且给LVGL的任务处理lv_timer_handler()留出足够的CPU时间。字体子集化这是节省Flash空间最有效的手段。使用lv_font_conv时--range参数只包含你确实需要的字符。对于时钟就是0-9和:。这比嵌入整个字体文件要小得多。使用LVGL的内存池如果频繁创建和删除对象虽然表盘UI一般不这样务必正确配置LV_MEM_SIZE并考虑使用内存池。对于静态表盘对象在初始化时创建后就不再变动内存占用是稳定的。5. 实战问题排查与经验心得在实际移植和调试过程中你肯定会遇到各种各样的问题。下面是我总结的一些常见坑点和解决思路。5.1 字体或图片显示异常现象字体显示为乱码或方框图片不显示或花屏。排查步骤检查资源是否被链接首先确认你转换生成的.c字体/图片文件是否被添加到了编译工程中。最简单的方法是在IDE中搜索生成的字体变量名如ui_font_clock_36看是否能找到定义。检查声明与引用在使用的.c文件中是否用extern或#include “.h”正确声明了这些资源变量.h文件里是否有类似LV_ATTRIBUTE_EXTERN_DATA extern lv_font_t ui_font_clock_36;的语句检查转换参数对于字体确认--range参数包含了你要显示的字符的Unicode码点。对于中文范围很大务必子集化。对于图片确认转换时的颜色深度--format与LVGL的配置LV_COLOR_DEPTH匹配。通常CF_TRUE_COLOR_ALPHA对应32位ARGB8888。检查内存过大的字体或图片可能导致内存不足。尝试减小字体尺寸或bpp降低图片分辨率。实操心得遇到图片花屏我首先会用一个纯色、小尺寸的测试图片替换如果正常显示那问题就出在原图尺寸或格式上。LVGL的lv_img_buf_get_info函数可以在运行时检查图片头信息是调试利器。5.2 时间更新延迟或卡顿现象秒针跳秒不流畅或者偶尔停顿。排查步骤检查定时器优先级确保调用lv_timer_handler()的线程或主循环优先级足够高且执行频率稳定通常放在主while(1)循环中不加阻塞延迟。它必须被频繁调用至少每秒10次以上。检查lv_tick_inc()这个函数提供了LVGL内部的时间基准。它必须在一个稳定的定时器中断如1ms的SysTick中被调用。如果它不准确所有动画和定时器都会出问题。检查RTC读取速度通过调试器或日志测量HAL_RTC_GetTime函数的执行时间。如果异常慢检查RTC时钟源配置通常使用LSE外部低速晶振更准确且省电。简化UI测试注释掉所有图片旋转和复杂的样式只更新一个简单的文本标签看是否还卡顿。如果不卡说明性能瓶颈在图形渲染。可以尝试降低屏幕刷新率LV_DISP_DEF_REFR_PERIOD或减少颜色深度。5.3 触摸或按键事件干扰现象在操作其他界面时表盘的时间更新受到影响。解决方案LVGL的定时器是全局的。确保你的表盘定时器在离开表盘界面时被正确删除lv_timer_del(timer)在进入时重新创建。否则后台的定时器会持续运行并可能访问已经销毁的UI对象如标签导致内存错误。更好的架构是将表盘的所有UI对象和定时器封装在一个结构体里界面切换时统一创建和销毁。5.4 移植到不同平台如ESP32、RT-Thread的要点显示驱动LVGL的显示驱动接口lv_disp_drv_t是标准化的。你需要根据你的屏幕如SPI OLED、并口LCD、RGB接口实现flush_cb回调函数在这个函数里将LVGL提供的颜色缓冲区数据写入屏幕。网上有大量STM32和ESP32的驱动示例可供参考。任务调度如果在RTOS如FreeRTOS、RT-Thread上运行最佳实践是创建一个专有的LVGL任务线程在这个任务中循环调用lv_timer_handler()并提供一个合理的延时如lv_timer_handler之后延时5-10ms。同时将lv_tick_inc()放在一个高优先级的硬件定时器中断中。输入设备触摸、按键的回调也应在对应的中断或任务中通过lv_indev_read触发。配置文件lv_conf.h这是LVGL的“大脑”。根据你的MCU资源仔细调整。关键参数包括LV_MEM_SIZE内存池大小、LV_DISP_DEF_REFR_PERIOD刷新周期、LV_COLOR_DEPTH颜色深度16位足以满足大多数嵌入式场景。务必启用你需要的功能如LV_USE_IMGLV_USE_FONT关闭不需要的以节省空间。最后调试LVGL时善用其内置的监视器LV_USE_MONITOR和内存诊断工具它们能直观地告诉你帧率、内存使用和CPU负载情况对于定位性能瓶颈有奇效。从一个简单的“Hello World”标签开始逐步添加功能每步都确保工作正常这是最稳妥的开发流程。圆形表盘UI看似复杂但拆解成背景、静态元素、动态文本和定时刷新这几个模块后在LVGL强大的对象模型支持下实现起来并没有想象中那么困难。