☰
LVGL 9新控件lv_scale实战:从零打造动态仪表盘
2026/10/2 1:20:55 网站建设 项目流程

我在LVGL 8时代做过一个车速表,为了让刻度线均匀地围成一个圆,我用lv_canvas一条一条画刻度,循环里套三角函数,调坐标调了半个下午。后来LVGL v9发布了lv_scale控件,这个问题直接从“手工活”变成了“填参数”。今天这篇就围绕lv_scale,用最少的代码把动态仪表盘跑起来,然后我会把刻度数量的计算、指针角度换算、动画联动这些容易踩坑的点逐个拆开讲清楚。适合正在用LVGL 9做HMI、传感器读数面板,或者从8.x往9.x迁移的朋友,模拟器和STM32、ESP32上都能用。

1. 认识lv_scale:一个天生会画刻度的控件

1.1 为什么v9会加入lv_scale

如果你从LVGL 8时代过来,一定对“画仪表盘”这件事有心理阴影。当时的常见做法有四种,各有各的难受:

实现方案基本思路换量程代码量维护性
底图图片美工出好表盘图,lv_img显示重新出图最少最差,改一个刻度就要换图
lv_canvas画线循环用三角函数算坐标,canvas上一条条画要重新执行绘制逻辑多一般,侧面看还行,放大就糊
lv_line堆叠每根刻度线都创建一个line对象再旋转每条线重摆很多差,对象数量爆炸
lv_scale(v9)配置总量、主刻度间隔、范围、角度改几个参数很少最好,绘制在控件内部完成

lv_scale就是v9把“刻度”这件事收敛成了独立控件,你要做的只是告诉它:画多少根刻度线、每隔几根强调一次、数值范围是多少、是横着排还是围着圆弧排。计算位置、标签、不同角度下的缩放全交给内部绘图逻辑。

1.2 两种绘制模式:直线标尺与圆弧仪表盘

lv_scale有两种大模式,全部通过lv_scale_set_mode切换:

  • 直线模式:LV_SCALE_MODE_HORIZONTAL_TOP、LV_SCALE_MODE_HORIZONTAL_BOTTOM,适合做标尺、温度条、音量条。
  • 圆弧模式:LV_SCALE_MODE_ROUND_INNER、LV_SCALE_MODE_ROUND_OUTER,适合做仪表盘。ROUND_INNER表示刻度线朝圆弧内侧,ROUND_OUTER朝外侧,做车速表、压力表、百分比表非常顺手。

动态仪表盘肯定选圆弧模式。这里要提一个容易忽略的点:圆弧模式下,控件的宽高最好相等,否则刻度圆弧会被压成椭圆,所以创建控件时我都会强制lv_obj_set_size(scale, 280, 280)这样的正方形尺寸。

1.3 核心配置项速查

lv_scale的关键配置函数不多,先给一张速查表,后面代码里会反复用到:

函数作用典型值
lv_scale_set_total_tick_count(obj, n)设置刻度线总根数51
lv_scale_set_major_tick_every(obj, n)每隔n根刻度线画一个主刻度5
lv_scale_set_range(obj, min, max)设置刻度对应的数值范围0 ~ 100
lv_scale_set_angle_range(obj, deg)圆弧模式下刻度覆盖的角度270
lv_scale_set_label_show(obj, true)是否在主刻度处显示数值标签true
lv_scale_set_mode(obj, mode)切换直线/圆弧模式LV_SCALE_MODE_ROUND_INNER
lv_scale_set_post_draw(obj, true)刻度是否在子对象之后绘制默认false即可

这里尤其要注意total_tick_count和major_tick_every的关系。很多人第一次用直接设total_tick_count = 10,结果发现标签只有0和100,那是因为主刻度没算对。两者的关系不是“想标几个数就填几”。

2. 仪表盘方案选型:参数这样定才好看

2.1 整体结构拆解

动态仪表盘我习惯拆成三层:

  1. 刻度层:它就是lv_scale本身,负责表盘背景刻度和数字标签。
  2. 指针层:用lv_line或不带透明底的lv_img,负责指示当前值。
  3. 数值显示层:一个居中的lv_label,负责实时显示数字。

把三者做成父子关系,指针和label都放进scale这个父对象里,好处是移动整个仪表盘时它们会一起跟着走,不用维护多套坐标。创建顺序直接决定层叠关系:先创建的在后创建的下面,所以优先创建指针,再创建居中的数值文本,指针就不会被文字盖住。

2.2 刻度数量计算:先算间隔,再填参数

我见过最多的问题就是把total_tick_count当成“标签个数”来填。实际上它是总共要画多少根刻度线。正确的思考路径是先确定每个刻度代表多少数值,再反推总根数。

以0~100量程、270°表盘为例,我想要主刻度每隔10显示一次数字,每个主刻度之间再放4根细刻度,也就是每个刻度代表2个数:

int tick_step = 2; // 相邻刻度线的数值间隔 int total_ticks = (100 - 0) / tick_step + 1; // 51根 int major_every = 5; // 每5根刻度线取1根作为主刻度,正好10一个标签

所以核心配置为lv_scale_set_total_tick_count(scale, 51)、lv_scale_set_major_tick_every(scale, 5)。这样0到100之间一共有51根刻度线,每隔5根标一次数字,标签就是0、10、20……100共11个。这个配比在280x280的控件上疏密非常合适,是实测比较舒服的默认值。

2.3 指针怎么实现:lv_line + transform_angle

指针实现我推荐用lv_line加旋转,不推荐用图片。原因有三个:

  1. 不用准备多张角度素材图,一套代码跑所有量程。
  2. 颜色、线宽、是否圆头都可以用样式直接控制。
  3. 配合lv_obj_set_style_transform_angle旋转时,LVGL会自动重绘,省去手动算端点的麻烦。

两个关键点是一定要设置旋转中心pivot,否则指针会绕着左上角转;另外lv_obj_set_style_transform_angle传入的角度单位是0.1度,不是度。比如转90度要传900,这个单位坑了我很久。

2.4 数值到角度换算:从量程到极坐标

lv_scale在圆弧模式下,默认从135°开始顺时针覆盖设定的角度范围。这里“135°”是LVGL屏幕坐标系的角度,0°在正右方,顺时针增加,所以135°在左下方。我的0值指针就从左下开始,扫描270°后停在右下45°的位置,正好留出底部缺口,像汽车仪表盘。

换算函数非常直白:

static int32_t value_to_angle(int32_t value) { int32_t deg = 135 + (int32_t)(((int64_t)(value - 0) * 270) / (100 - 0)); deg %= 360; return deg * 10; // LVGL角度单位是0.1度 }

这里做了两件事:先把当前值线性映射到135°~405°区间,再取模360°,最后乘以10转成LVGL的0.1度单位。只要你的量程和角度范围不变,这段代码可以原样复用。

3. 完整代码与跑通流程

3.1 环境准备:版本与配置项

首先确认你用的一定是LVGL v9.x,因为lv_scale是v9新增控件,8.x编译会直接报错lv_scale_create未定义。然后在lv_conf.h里确认两个开关:

#define LV_USE_SCALE 1 #define LV_FONT_MONTSERRAT_24 1

LV_USE_SCALE控制控件本体,LV_FONT_MONTSERRAT_24是后面中心数值文本要用的字体。在模拟器里改完配置后重新构建即可,在STM32上也是同样的逻辑,lv_scale不依赖平台,FreeRTOS、ESP-IDF、模拟器API完全一致。

3.2 初始化刻度盘

创建刻度盘的代码就一段,所有参数都是前面算好的:

lv_obj_t * scale = lv_scale_create(parent); lv_obj_set_size(scale, 280, 280); lv_obj_center(scale); lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_total_tick_count(scale, 51); lv_scale_set_major_tick_every(scale, 5); lv_scale_set_range(scale, 0, 100); lv_scale_set_angle_range(scale, 270); lv_scale_set_label_show(scale, true);

设置完成之后默认刻度盘就可以显示了,但主次刻度风格相同,不太好看。lv_scale通过样式选择器区分主刻度、次刻度和标签:LV_PART_MAIN管主刻度和标签,LV_PART_INDICATOR管次要刻度。我用两组样式把粗细和颜色分开:

lv_obj_set_style_line_color(scale, lv_color_hex(0xCCCCCC), LV_PART_MAIN); lv_obj_set_style_line_width(scale, 2, LV_PART_MAIN); lv_obj_set_style_text_color(scale, lv_color_hex(0xCCCCCC), LV_PART_MAIN); lv_obj_set_style_text_font(scale, &lv_font_montserrat_12, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0x666666), LV_PART_INDICATOR); lv_obj_set_style_line_width(scale, 1, LV_PART_INDICATOR);

这样主刻度亮白粗一些,次刻度灰暗细一些,层次感立刻就有了。

3.3 添加指针和中心数值文本

指针我选lv_line。这里的关键是把线条的初始方向画成水平向右,也就是极坐标0°方向,这样后面设置transform_angle时,角度值直接就是绝对方向角,不用再算差值。

static lv_obj_t * needle; static lv_point_t needle_points[2] = {{0, 0}, {0, 0}}; int32_t cx = 140, cy = 140, r = 110; needle_points[0].x = cx; needle_points[0].y = cy; needle_points[1].x = cx + r; needle_points[1].y = cy; needle = lv_line_create(scale); lv_line_set_points(needle, needle_points, 2); lv_obj_set_style_line_color(needle, lv_color_hex(0xFF4000), LV_PART_MAIN); lv_obj_set_style_line_width(needle, 3, LV_PART_MAIN); lv_obj_set_style_line_rounded(needle, true, LV_PART_MAIN); lv_obj_set_style_transform_pivot_x(needle, cx, LV_PART_MAIN); lv_obj_set_style_transform_pivot_y(needle, cy, LV_PART_MAIN); lv_obj_set_style_transform_angle(needle, value_to_angle(0), LV_PART_MAIN);

再创建一个居中的数值标签:

static lv_obj_t * value_label; value_label = lv_label_create(scale); lv_obj_set_style_text_font(value_label, &lv_font_montserrat_24, LV_PART_MAIN); lv_obj_set_style_text_color(value_label, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_center(value_label); lv_label_set_text(value_label, "0");

注意needle_points一定要定义为static或全局变量,因为lv_line_set_points只保存指针,不会拷贝数据。如果定义成局部数组,函数一返回指针就失效,是新手最常见的黑屏加崩溃原因。

3.4 动态更新:定时器加平滑动画

仪表盘不能是静态的。我用lv_timer定时产生新数值,再用lv_anim让指针平滑转过去。动画回调里同时更新指针角度和中心文本:

static int32_t current_value = 0; static void anim_value_cb(void * var, int32_t v) { current_value = v; lv_obj_set_style_transform_angle(needle, value_to_angle(v), LV_PART_MAIN); lv_label_set_text_fmt(value_label, "%d", v); } static void set_meter_value(int32_t new_value) { if(new_value < 0) new_value = 0; if(new_value > 100) new_value = 100; lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, &current_value); lv_anim_set_exec_cb(&a, anim_value_cb); lv_anim_set_values(&a, current_value, new_value); lv_anim_set_time(&a, 300); lv_anim_set_path_cb(&a, lv_anim_path_ease_out); lv_anim_start(&a); } static void sim_timer_cb(lv_timer_t * t) { set_meter_value(lv_rand(20, 80)); }

这里lv_anim_set_var绑定的是current_value这个变量的地址,动画执行时LVGL会连续修改这个变量并回调anim_value_cb,所以指针角度和文本内容能同步更新。

3.5 demo完整代码

把所有片段拼起来,就是一份可直接运行的仪表盘demo。我习惯把入口函数命名为dashboard_demo_create,模拟器main函数里调用它即可:

#include "lvgl.h" #define SCALE_SIZE 280 #define RANGE_MIN 0 #define RANGE_MAX 100 #define ANGLE_START 135 #define ANGLE_SPAN 270 static lv_obj_t * needle; static lv_obj_t * value_label; static int32_t current_value = 0; static lv_point_t needle_points[2] = {{0, 0}, {0, 0}}; static int32_t value_to_angle(int32_t value) { int32_t deg = ANGLE_START + (int32_t)(((int64_t)(value - RANGE_MIN) * ANGLE_SPAN) / (RANGE_MAX - RANGE_MIN)); deg %= 360; return deg * 10; } static void anim_value_cb(void * var, int32_t v) { current_value = v; lv_obj_set_style_transform_angle(needle, value_to_angle(v), LV_PART_MAIN); lv_label_set_text_fmt(value_label, "%d", v); } static void set_meter_value(int32_t new_value) { if(new_value < RANGE_MIN) new_value = RANGE_MIN; if(new_value > RANGE_MAX) new_value = RANGE_MAX; lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, &current_value); lv_anim_set_exec_cb(&a, anim_value_cb); lv_anim_set_values(&a, current_value, new_value); lv_anim_set_time(&a, 300); lv_anim_set_path_cb(&a, lv_anim_path_ease_out); lv_anim_start(&a); } static void sim_timer_cb(lv_timer_t * t) { set_meter_value(lv_rand(20, 80)); } void dashboard_demo_create(lv_obj_t * parent) { lv_obj_t * scale = lv_scale_create(parent); lv_obj_set_size(scale, SCALE_SIZE, SCALE_SIZE); lv_obj_center(scale); lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_total_tick_count(scale, 51); lv_scale_set_major_tick_every(scale, 5); lv_scale_set_range(scale, RANGE_MIN, RANGE_MAX); lv_scale_set_angle_range(scale, ANGLE_SPAN); lv_scale_set_label_show(scale, true); lv_obj_set_style_line_color(scale, lv_color_hex(0xCCCCCC), LV_PART_MAIN); lv_obj_set_style_line_width(scale, 2, LV_PART_MAIN); lv_obj_set_style_text_color(scale, lv_color_hex(0xCCCCCC), LV_PART_MAIN); lv_obj_set_style_text_font(scale, &lv_font_montserrat_12, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0x666666), LV_PART_INDICATOR); lv_obj_set_style_line_width(scale, 1, LV_PART_INDICATOR); int32_t cx = SCALE_SIZE / 2; int32_t cy = SCALE_SIZE / 2; int32_t r = cx - 30; needle_points[0].x = cx; needle_points[0].y = cy; needle_points[1].x = cx + r; needle_points[1].y = cy; needle = lv_line_create(scale); lv_line_set_points(needle, needle_points, 2); lv_obj_set_style_line_color(needle, lv_color_hex(0xFF4000), LV_PART_MAIN); lv_obj_set_style_line_width(needle, 3, LV_PART_MAIN); lv_obj_set_style_line_rounded(needle, true, LV_PART_MAIN); lv_obj_set_style_transform_pivot_x(needle, cx, LV_PART_MAIN); lv_obj_set_style_transform_pivot_y(needle, cy, LV_PART_MAIN); lv_obj_set_style_transform_angle(needle, value_to_angle(0), LV_PART_MAIN); value_label = lv_label_create(scale); lv_obj_set_style_text_font(value_label, &lv_font_montserrat_24, LV_PART_MAIN); lv_obj_set_style_text_color(value_label, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_center(value_label); lv_label_set_text(value_label, "0"); set_meter_value(35); lv_timer_create(sim_timer_cb, 1500, NULL); }

这段代码在LVGL模拟器里直接编译运行就能看到表盘指针每1.5秒摆动一次。放到STM32上时,只要确保屏的驱动和色深配置正常,效果一样。

4. 微调与进阶玩法

4.1 不同量程的参数速查表

实际项目里的量程五花八门,下面几张推荐配置可以直接抄:

量程每个刻度代表值total_tick_countmajor_tick_every标签间隔
0 ~ 100251510
0 ~ 200541420
0 ~ 5001051550
0 ~ 100025414100
0 ~ 5015155

核心规律:先用“你想要的最小读数单位”决定每格数值,再用总量程除以每格值得出间隔数,加1就是total_tick_count。major_tick_every一般是4或5,这样主刻度之间的次刻度数量统一,视觉上最整齐。

4.2 样式定制:主次刻度、标签字体与颜色

仪表盘好不好看,七成靠样式。lv_scale把主刻度、次刻度、标签分别用不同PART暴露出来,调起来很灵活。

我在实际项目里喜欢做一套深色表盘:背景用纯黑或深灰,主刻度用偏白的高对比色,次刻度用深灰,标签用浅灰,指针用橙色或红色。这样在暗色HMI界面上非常醒目。如果你的屏幕支持抗锯齿,lv_obj_set_style_line_width画出来的刻度边缘会更平滑,看起来和图片底图效果差不多。

如果你觉得lv_scale自动生成的数字标签不够醒目,也可以不显示标签,自己在指定位置添加lv_label,但那样做又回到了手动摆坐标的老路。我的建议是先开label_show看看效果,绝大多数场景默认方案就够用。

4.3 自定义刻度文本:lv_scale_set_text_src

有些表盘不显示数字,而是显示档位文字。比如电机转速表可能要写“LOW / MID / HIGH”,功率表可能要写“ECO / SPORT”。lv_scale提供了一个lv_scale_set_text_src函数,可以完全替换刻度标签的文本内容:

static const char * tick_texts[] = {"0", "50", "100", "150", "200", "250", "300", "350", "400", "450", "500"}; lv_scale_set_text_src(scale, tick_texts);

注意两点:一是数组长度必须和主刻度数量一致,二是顺序按最小到最大排列,否则标签会对不上刻度。这个功能很适合产品原型阶段快速切换表盘语义,不用改量程逻辑。

4.4 横向标尺模式也能用同一套逻辑

lv_scale不只是圆表盘专用。做音量条、温度条、光照强度标尺时,改成直线模式:

lv_scale_set_mode(scale, LV_SCALE_MODE_HORIZONTAL_TOP); lv_obj_set_size(scale, 240, 60);

其它配置比如total_tick_count、major_tick_every、range、label_show的写法完全一样。这意味着你学会了圆表盘,横向标尺就是改两行参数的事,一套知识可以复用到很多控件场景。

5. 常见问题与踩坑记录

5.1 编译报错:找不到lv_scale_create

先确认版本。LVGL v8.x没有lv_scale,必须升到9.x或拉取最新master分支。如果版本已经是9.x,检查lv_conf.h里的LV_USE_SCALE,有些精简配置会关掉非核心控件,置为1即可。

我在模拟器里遇到过改完lv_conf.h但没重新构建的情况,导致报错找半天。改配置文件后一定要 clean build,尤其LVGL的配置是编译期宏,不会增量生效。

5.2 指针旋转中心错位

指针不绕圆心转,而是飘到别的地方,十有八九是pivot没设对。lv_obj_set_style_transform_pivot_x和lv_obj_set_style_transform_pivot_y必须设置成圆心坐标,并且selector要传LV_PART_MAIN,和指针line对象保持同一个part。

另一个隐蔽坑是:point数组用局部变量定义,lv_line_set_points只存指针不拷贝,函数返回后数据就没了。这一点务必牢记,全局变量或static是底线。

5.3 主次刻度颜色不生效

如果你设置了LV_PART_INDICATOR的样式但次刻度没变化,先别怀疑代码,去看LVGL小版本。lv_scale在9.0到9.2之间调整过内部绘制逻辑,部分版本对PART的处理有差异。实测在9.2以上版本中,主刻度用LV_PART_MAIN、次刻度用LV_PART_INDICATOR是稳定的。如果版本较旧,建议升级或者直接统一用LV_PART_MAIN的线宽做区分。

5.4 刻度或标签太密,糊成一团

刻度太密通常是total_tick_count设太大。280x280的控件上61根以上刻度就会有点挤,标签更是容易互相叠。遇到这种情况,优先调大major_tick_every,比如从5改成10,标签数量直接减半,表盘立刻清爽。

5.5 动画卡顿,指针一顿一顿

lv_line加transform本质上每次重绘都要重新计算线条角度,在低主频MCU上会占用不少CPU。我的建议有三条:

  1. 动画时间不要设太长,300ms以内观感最好,超过500ms会显得拖沓。
  2. 如果CPU吃紧,直接把lv_anim改成单次跳变,lv_obj_set_style_transform_angle直接设目标角度,省掉中间插值。
  3. 中心label的文本不要每帧都刷新,可以只在动画结束回调里更新一次,文字刷新也是开销大头。

如果你的平台有2D加速引擎,比如T113这类带G2D的芯片,开启LVGL的硬件加速后transform重绘会轻松很多。没有硬件加速时,减少变化频率比优化绘制函数更有效。

5.6 刻度被指针或背景覆盖

LVGL子对象默认绘制在父对象内容之上,所以指针作为scale的子对象会盖住刻度线。这通常没问题。但如果你在scale里再放一个填充背景的lv_arc,那它会把先画好的刻度盖掉。这时候可以给lv_scale开启lv_scale_set_post_draw(scale, true),让刻度最后绘制,保证刻度永远在上层。

不过要注意,post_draw开了之后指针也会被刻度盖住,因为它也是在draw阶段的后半程才画出来的。所以我的经验是:背景填充用post_draw控制,指针还是单独放在scale外面或者用其他对象承载,避免层叠逻辑打架。

我个人在实际操作里的体会

lv_scale把“画刻度”这个最烦的活拆掉之后,仪表盘开发真正剩下的核心就是指针角度映射和动画节奏。做产品原型时,我会把量程、角度范围、主刻度间隔抽成一个结构体,一套代码通过配置出N种表盘,改需求时只动数据不动逻辑。对于想继续折腾的朋友,可以试试在表盘中心加一个lv_arc做彩色速度区间,或者叠加两套lv_scale做成双表盘,思路顺着这篇文章走很快就能接上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询