
我在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)设置刻度线总根数51lv_scale_set_major_tick_every(obj, n)每隔n根刻度线画一个主刻度5lv_scale_set_range(obj, min, max)设置刻度对应的数值范围0 ~ 100lv_scale_set_angle_range(obj, deg)圆弧模式下刻度覆盖的角度270lv_scale_set_label_show(obj, true)是否在主刻度处显示数值标签truelv_scale_set_mode(obj, mode)切换直线/圆弧模式LV_SCALE_MODE_ROUND_INNERlv_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, 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 ~ 1002515100 ~ 2005414200 ~ 50010515500 ~ 1000254141000 ~ 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。我的建议有三条动画时间不要设太长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做成双表盘思路顺着这篇文章走很快就能接上。