1. 为什么我最终选了 lv_scale 来做仪表盘
嵌入式 GUI 里做仪表盘这件事,说简单也简单,说坑也真不少。早几年大家用 LVGL 做仪表盘,基本是两条路:要么用lv_arc拼一个圆弧加指针,要么干脆贴一张背景图,然后在上面画一根会转的线。前者灵活但代码量大,刻度、数字、指针全得自己算;后者省事但分辨率一变就糊,改个量程还得重新出图。LVGL v9 把lv_scale这个控件正式独立出来之后,我第一时间就把手头几个项目里的仪表盘全换了一遍,实测下来代码量能砍掉一半以上,而且刻度、标签、指针的联动是原生支持的,不用再自己维护一堆坐标数组。
lv_scale本质上是一个"刻度尺"控件,它负责画主刻度线、次刻度线、刻度标签,以及可选的指针区域。你可以把它理解成一把可以弯成任意角度、可以自定义每一格代表多少数值的尺子。仪表盘只是它最典型的用法之一,实际上进度条、旋钮、音量条、转速表、温度计,都能用它来做。它解决的核心问题是:把"数值到角度/位置的映射"这件事从业务代码里抽出来,交给控件自己管。你只需要告诉它量程是多少、当前值是多少,剩下的刻度怎么排、标签怎么显示,它自己会算。
这篇文章适合两类人看。一类是刚接触 LVGL v9、想找个能直接跑起来的仪表盘例子的朋友,我会把完整代码贴出来,PC 模拟器和 STM32 上都能用;另一类是已经在用 LVGL、但还在用老办法手搓仪表盘的同行,我会重点讲清楚lv_scale的几个关键参数怎么调、指针怎么联动、性能怎么优化,这些都是我在实际项目里踩过坑之后总结出来的。代码基于 LVGL v9.x,v8 的 API 差异比较大,如果你还在 v8,建议先升级或者对照官方迁移文档改一下。
2. lv_scale 的核心机制与设计思路拆解
2.1 它到底解决了什么老问题
在没有lv_scale之前,做一个像样的仪表盘,我通常要写这么几块东西:一段代码算圆弧的起止角度,一段代码循环画刻度线(每条刻度线都要算起点终点坐标),一段代码放刻度数字(每个数字都要算位置和旋转角度),最后还要一段代码根据当前值算指针角度。这四块代码互相耦合,改一个量程,四个地方都得动。更麻烦的是,刻度数字的旋转在 LVGL 里一直是个痛点,早期版本对文字旋转支持不好,很多人干脆不旋转,数字全是正的,看起来就很别扭。
lv_scale把这四块全包了。你给它一个min_value和max_value,再给它一个angle_range(角度范围),它内部会自动把数值区间映射到角度区间,然后按你设定的total_tick_count(总刻度数)和major_tick_every(每隔几个刻度算一个主刻度)去排布刻度和标签。指针部分,v9 提供了lv_scale_set_line_needle_value和lv_scale_set_image_needle_value两个接口,前者用一条线当指针,后者用一张图片当指针,你只要传入当前值,它自己算角度。
提示:
lv_scale在 v9 里是独立控件,不再是lv_meter的附属。lv_meter在 v9 里依然存在,但官方更推荐新项目用lv_scale,因为它的 API 更清晰,扩展性也更好。
2.2 角度映射的数学逻辑
理解角度映射是调好仪表盘的关键。假设你设的angle_range是 135 到 405(这是 LVGL 里常用的一个范围,对应从左下到右下扫过 270 度),min_value是 0,max_value是 100。那么当前值v对应的角度就是:
angle = 135 + (v - 0) / (100 - 0) * (405 - 135) = 135 + v / 100 * 270当 v=0 时角度是 135 度,v=50 时是 270 度(正上方),v=100 时是 405 度(也就是 45 度,因为 405-360=45)。这个映射是线性的,lv_scale内部就是这么算的。你调angle_range的时候要注意,LVGL 的角度是顺时针增加的,0 度在三点钟方向,90 度在六点钟方向,180 度在九点钟方向,270 度在十二点钟方向。所以 135 度其实是七点半方向,405 度是四点半方向,这样扫出来正好是一个开口朝下的 270 度圆弧,符合大多数仪表盘的视觉习惯。
如果你想要一个开口朝上的仪表盘(比如某些转速表),可以把angle_range设成 45 到 315,这样扫出来是上半圆。这个没有标准答案,看你的 UI 设计稿。
2.3 刻度数量与标签的取舍
total_tick_count和major_tick_every这两个参数决定了刻度的疏密。假设total_tick_count设成 21,major_tick_every设成 5,那么总共会有 21 条刻度线,其中第 0、5、10、15、20 条是主刻度(长线),其余是次刻度(短线)。主刻度上会显示标签,次刻度不显示。
这里有个容易踩的坑:total_tick_count是刻度线的总数,不是"格数"。如果你想要 0 到 100 之间每 10 一个主刻度,那就是 11 个主刻度(0,10,20,...,100),对应total_tick_count至少是 11,major_tick_every设成 1。但这样次刻度就没有了。如果你想要每 10 一个主刻度、每 2 一个次刻度,那total_tick_count要设成 51(0 到 100 每 2 一格,共 51 个点),major_tick_every设成 5(每 5 个次刻度一个主刻度,5*2=10)。这个换算关系一定要算清楚,不然刻度会乱。
我个人的经验是,仪表盘上主刻度不要超过 11 个,否则标签会挤在一起,尤其是小尺寸屏幕上。次刻度可以密一点,但也不要超过主刻度的 5 倍,不然视觉上会显得很乱。
3. 从零搭一个仪表盘的完整实操
3.1 环境准备与基础框架
先假设你已经有一个能跑 LVGL v9 的工程,不管是 PC 模拟器(比如用 SDL 或者 Wayland 驱动)还是 STM32 上的裸机/RTOS 工程。如果你还没搭好,PC 模拟器是最快的验证方式,官方仓库里有lv_port_pc_visual_studio和lv_port_pc_eclipse两个现成的工程,拉下来改改就能用。STM32 的话,我一般用 FreeRTOS 跑一个 GUI 任务,里面定时调lv_timer_handler,这个后面会细说。
基础框架很简单,创建一个屏幕,设置背景色,然后创建一个lv_scale对象:
lv_obj_t *scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x1a1a2e), LV_PART_MAIN); lv_obj_t *scale = lv_scale_create(scr); lv_obj_set_size(scale, 240, 240); lv_obj_center(scale);这几行跑起来,你会看到一个空白的方形区域,什么都看不到,因为还没设量程和刻度。别急,接下来一步步填。
3.2 设置量程、角度与刻度
lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_range(scale, 0, 100); lv_scale_set_angle_range(scale, 135, 405); lv_scale_set_total_tick_count(scale, 51); lv_scale_set_major_tick_every(scale, 5);LV_SCALE_MODE_ROUND_INNER表示刻度画在内侧,指针也朝内。对应的还有LV_SCALE_MODE_ROUND_OUTER,刻度画在外侧。仪表盘一般用 INNER,因为指针从圆心指向外,视觉上更自然。
lv_scale_set_range设的是数值范围,lv_scale_set_angle_range设的是角度范围,这两个是核心映射参数。total_tick_count和major_tick_every按前面说的换算关系来设。
设完这些,你应该能看到一圈刻度线了,但标签可能还没出来,因为标签的样式需要单独配。
3.3 配置刻度线与标签的样式
LVGL v9 的样式系统分 part,lv_scale有几个关键的 part:
LV_PART_MAIN:主体背景LV_PART_ITEMS:刻度线LV_PART_INDICATOR:指示器(一般不用)LV_PART_TICKS:v9 里刻度线的专用 part
实际上 v9 的lv_scale刻度线样式是通过LV_PART_ITEMS和LV_PART_INDICATOR配合lv_scale_set_line_needle_value来控制的。更准确地说,主刻度和次刻度的样式分别用LV_PART_MAIN和LV_PART_ITEMS来设。我直接贴一段实测能用的配置:
// 主刻度线样式 lv_obj_set_style_line_width(scale, 3, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0x00d9ff), LV_PART_MAIN); lv_obj_set_style_line_opa(scale, LV_OPA_COVER, LV_PART_MAIN); // 次刻度线样式 lv_obj_set_style_line_width(scale, 1, LV_PART_ITEMS); lv_obj_set_style_line_color(scale, lv_color_hex(0x555555), LV_PART_ITEMS); lv_obj_set_style_line_opa(scale, LV_OPA_COVER, LV_PART_ITEMS); // 刻度标签样式 lv_obj_set_style_text_color(scale, lv_color_hex(0xffffff), LV_PART_MAIN); lv_obj_set_style_text_font(scale, &lv_font_montserrat_14, LV_PART_MAIN);这里有个细节:标签的字体要用lv_font_montserrat_14这种内置字体,或者你自己用 LVGL 的字体转换工具生成的字体。默认字体太小,仪表盘上根本看不清。如果你要显示中文标签,必须自己生成中文字体,内置的 Montserrat 不含中文。
3.4 添加指针并让它动起来
指针有两种做法。第一种是用线:
lv_scale_set_line_needle_value(scale, 80, 4, 50);第一个参数是 scale 对象,第二个是线的长度(从圆心往外伸多少像素),第三个是线宽,第四个是当前值。这行代码会在 scale 上画一条从圆心指向当前值对应角度的线。但注意,这个接口是"设置"而不是"创建",每次值变化你都要重新调一次,它会覆盖上一次的指针。
第二种是用图片:
lv_obj_t *needle_img = lv_image_create(scale); lv_image_set_src(needle_img, &needle_icon); lv_scale_set_image_needle_value(scale, needle_img, 50);图片指针的好处是可以做得很精致,比如一个带渐变和阴影的箭头。但图片要提前转成 LVGL 的 C 数组,用官方的图片转换工具就行。
我一般用线指针做原型,快速验证逻辑,等 UI 定稿了再换成图片指针。线指针的性能更好,因为不用解码图片。
3.5 用定时器驱动数值变化
仪表盘要动起来,得有个东西定时改值。在 PC 模拟器上,你可以用lv_timer_create:
static void anim_cb(lv_timer_t *t) { static int val = 0; val = (val + 3) % 101; lv_scale_set_line_needle_value(scale, 80, 4, val); } lv_timer_t *timer = lv_timer_create(anim_cb, 50, NULL);每 50ms 加 3,大概 1.7 秒走完一圈。实际项目里,这个值应该来自你的传感器或业务逻辑,比如 ADC 采到的电压、CAN 总线上的转速信号。
在 STM32 + FreeRTOS 上,lv_timer_handler要放在一个任务里定时调用,一般 5ms 一次。lv_timer_create创建的定时器会被lv_timer_handler统一调度,所以你不需要额外开任务。但要注意,lv_timer_handler的调用周期要稳定,不然动画会卡顿。我一般用vTaskDelayUntil来保证周期精确。
4. 让仪表盘更好看的几个关键细节
4.1 颜色搭配与视觉层次
仪表盘好不好看,颜色占七成。我的经验是,背景用深色(比如0x1a1a2e这种深蓝黑),刻度线用中灰,主刻度用亮色(青色或橙色),指针用高对比度的颜色(红色或白色),标签用白色或浅灰。这样层次分明,远看也能看清。
不要用纯黑背景配纯白刻度,对比度太高,看久了眼睛累。也不要用太多颜色,三种以内就够了。如果你要做多仪表盘并排,每个仪表盘的主色调可以不同,但背景色要统一。
4.2 刻度标签的旋转与对齐
LVGL v9 的lv_scale支持标签旋转,但默认是不旋转的。如果你想要标签跟着刻度方向转,需要设置:
lv_scale_set_label_show(scale, true);实际上 v9 里标签旋转是自动的,当LV_SCALE_MODE_ROUND_INNER时,标签会自动朝向圆心。如果你发现标签方向不对,检查一下 mode 设置。有些版本需要手动调lv_scale_set_text_src来指定标签文本,但大多数情况下自动生成的数字就够用了。
如果标签数字太长(比如四位数),在 240x240 的屏幕上会挤。这时候要么减少主刻度数量,要么把字体调小,要么用缩写(比如 1000 显示成 1k)。我一般会在lv_scale_set_text_src里传一个自定义的字符串数组,把长数字替换成短标签。
4.3 指针的平滑动画
直接改值指针会跳,看起来生硬。LVGL 有内置的动画系统,可以给指针加过渡:
lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, scale); lv_anim_set_values(&a, old_val, new_val); lv_anim_set_time(&a, 300); lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)anim_needle_cb); lv_anim_start(&a);anim_needle_cb里调lv_scale_set_line_needle_value。这样指针会在 300ms 内平滑地从旧值转到新值,视觉效果提升非常明显。但要注意,动画期间如果新值又来了,要先把旧动画删掉,不然会冲突。我一般用一个静态变量存当前动画指针,新动画开始前调lv_anim_delete。
5. 常见问题与排查技巧实录
5.1 刻度不显示或显示不全
最常见的原因是total_tick_count和major_tick_every设得不合理。比如total_tick_count设成 10,major_tick_every设成 5,那只有第 0 和第 5 条是主刻度,第 10 条不存在(因为总共只有 10 条,索引 0 到 9)。这时候你会觉得"怎么少了一个标签"。解决办法是把total_tick_count设成 11,这样索引 0 到 10,第 10 条就是最后一个主刻度。
另一个原因是角度范围设反了。angle_range的 start 必须小于 end,如果你写成 405 到 135,LVGL 不会报错,但什么都不画。这个坑我踩过,查了半天才发现。
5.2 指针位置不对
指针位置不对,九成是量程和角度范围的映射没对上。检查一下lv_scale_set_range的 min/max 和你传入的当前值是否在同一个数量级。比如你设 range 是 0 到 100,但传进去的值是 0 到 1000,那指针永远在最大位置附近晃。还有一种情况是lv_scale_set_line_needle_value的第二个参数(线长)设得太短,指针缩在圆心附近看不见。240x240 的 scale,线长设 70 到 90 比较合适。
5.3 性能问题与优化
在 STM32F103 这种没有硬件浮点、主频 72MHz 的片子上,lv_scale如果刻度设得太密(比如total_tick_count超过 100),刷新会明显卡顿。因为每帧都要重绘所有刻度线。优化办法有几个:一是减少刻度数量,主刻度 11 个、次刻度 50 个以内;二是把 scale 的背景和刻度缓存成图片,只让指针动,但这需要额外内存;三是降低刷新率,仪表盘不需要 60fps,20fps 就够了。
我在 F103 上实测,240x240 的 scale,51 个刻度,20fps 刷新,CPU 占用大概 15% 左右,可以接受。如果你用 F407 或更高,基本不用操心性能。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 刻度完全不显示 | angle_range 设反了 | 确保 start < end |
| 少一个标签 | total_tick_count 少 1 | 改成主刻度数 * major_tick_every + 1 |
| 指针不动 | 没调 set_line_needle_value | 每次值变化都要调一次 |
| 指针位置偏 | range 和值数量级不匹配 | 检查 range 的 min/max |
| 标签看不清 | 字体太小或颜色太暗 | 换大字体,提高对比度 |
| 刷新卡顿 | 刻度太密或刷新率太高 | 减少刻度,降到 20fps |
| 中文显示乱码 | 用了内置字体 | 生成并注册中文字体 |
6. 完整代码与移植要点
6.1 PC 模拟器完整示例
#include "lvgl.h" static lv_obj_t *scale; static int current_val = 0; static void needle_anim_cb(void *var, int32_t v) { lv_scale_set_line_needle_value(scale, 80, 4, v); } static void timer_cb(lv_timer_t *t) { int new_val = (current_val + 5) % 101; lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, scale); lv_anim_set_values(&a, current_val, new_val); lv_anim_set_time(&a, 200); lv_anim_set_exec_cb(&a, needle_anim_cb); lv_anim_start(&a); current_val = new_val; } void dashboard_create(void) { lv_obj_t *scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x1a1a2e), LV_PART_MAIN); scale = lv_scale_create(scr); lv_obj_set_size(scale, 240, 240); lv_obj_center(scale); lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_range(scale, 0, 100); lv_scale_set_angle_range(scale, 135, 405); lv_scale_set_total_tick_count(scale, 51); lv_scale_set_major_tick_every(scale, 5); lv_obj_set_style_line_width(scale, 3, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0x00d9ff), LV_PART_MAIN); lv_obj_set_style_line_width(scale, 1, LV_PART_ITEMS); lv_obj_set_style_line_color(scale, lv_color_hex(0x555555), LV_PART_ITEMS); lv_obj_set_style_text_color(scale, lv_color_hex(0xffffff), LV_PART_MAIN); lv_obj_set_style_text_font(scale, &lv_font_montserrat_14, LV_PART_MAIN); lv_scale_set_line_needle_value(scale, 80, 4, 0); lv_timer_create(timer_cb, 100, NULL); }这段代码在 PC 模拟器上直接能跑,你会看到一个深色背景的仪表盘,指针每 100ms 动一次,平滑地转圈。
6.2 STM32 + FreeRTOS 移植要点
移植到 STM32 上,核心是把lv_timer_handler放进一个 FreeRTOS 任务:
void gui_task(void *pvParameters) { dashboard_create(); TickType_t last_wake = xTaskGetTickCount(); while (1) { lv_timer_handler(); vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(5)); } }任务栈建议至少 4KB,因为 LVGL 内部有些函数递归比较深。堆的话,LVGL 自己有一套内存管理,LV_MEM_SIZE在lv_conf.h里设,240x240 的仪表盘加一个屏幕,建议至少 32KB。如果你还要跑其他 UI,64KB 更稳妥。
显示驱动和触摸驱动按你用的屏来配,SPI 屏一般用 DMA 刷,不然刷新率上不去。我用的是一块 240x240 的 ST7789 SPI 屏,DMA 刷一帧大概 8ms,20fps 很轻松。
6.3 几个移植时容易忽略的配置
lv_conf.h里有几个宏必须开:
LV_USE_SCALE必须设成 1,v9 默认可能是关的LV_FONT_MONTSERRAT_14设成 1,不然字体链接会报错LV_COLOR_DEPTH根据你的屏设,16 位色最常用LV_USE_ANIM设成 1,不然动画不生效
还有一点,v9 的lv_conf.h模板和 v8 差别很大,不要直接拿 v8 的配置改,建议用 v9 自带的模板重新配一遍。我一开始偷懒用了旧配置,编译报了一堆错,折腾了半天。
7. 我踩过的几个坑和一点心得
第一个坑是lv_scale_set_line_needle_value的线宽参数。我一开始设成 1,结果指针细得几乎看不见,后来改成 4 才正常。这个参数是像素值,不是 LVGL 的 line width 样式,别搞混了。
第二个坑是动画冲突。我一开始没删旧动画,快速连续改值时指针会乱跳,甚至卡死。后来在timer_cb里加了一句lv_anim_delete(scale, needle_anim_cb),问题就解决了。这个细节官方文档里没写,是我调试了半天才发现的。
第三个坑是字体。我想在仪表盘中间显示当前数值,用了lv_label,结果中文标签显示成方块。后来用 LVGL 的字体转换工具生成了一个只含常用汉字的字体,文件大小控制在 200KB 以内,才搞定。如果你只需要数字和英文,内置的 Montserrat 就够了,别折腾中文字体。
最后分享一个小技巧:如果你想让仪表盘看起来更精致,可以在 scale 下面垫一个圆形的lv_obj,设成深色带一点渐变,然后把 scale 放上去。这样刻度线不会直接贴在背景上,视觉上更有层次。这个做法在多个项目里都验证过,成本很低,效果很明显。
代码里的量程、角度、刻度数这些参数,你按自己的需求改就行,核心逻辑不用动。如果要做多个仪表盘并排,把dashboard_create里的对象创建部分封装成带参数的函数,传入不同的位置和量程即可。