我在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 整体结构拆解
动态仪表盘我习惯拆成三层:
- 刻度层:它就是lv_scale本身,负责表盘背景刻度和数字标签。
- 指针层:用lv_line或不带透明底的lv_img,负责指示当前值。
- 数值显示层:一个居中的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加旋转,不推荐用图片。原因有三个:
- 不用准备多张角度素材图,一套代码跑所有量程。
- 颜色、线宽、是否圆头都可以用样式直接控制。
- 配合
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 1LV_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, ¤t_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, ¤t_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_count | major_tick_every | 标签间隔 |
|---|---|---|---|---|
| 0 ~ 100 | 2 | 51 | 5 | 10 |
| 0 ~ 200 | 5 | 41 | 4 | 20 |
| 0 ~ 500 | 10 | 51 | 5 | 50 |
| 0 ~ 1000 | 25 | 41 | 4 | 100 |
| 0 ~ 50 | 1 | 51 | 5 | 5 |
核心规律:先用“你想要的最小读数单位”决定每格数值,再用总量程除以每格值得出间隔数,加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。我的建议有三条:
- 动画时间不要设太长,300ms以内观感最好,超过500ms会显得拖沓。
- 如果CPU吃紧,直接把
lv_anim改成单次跳变,lv_obj_set_style_transform_angle直接设目标角度,省掉中间插值。 - 中心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做成双表盘,思路顺着这篇文章走很快就能接上。