
最近在折腾设备监控界面把老项目从LVGL v8迁到 v9最惊喜的就是这个新增的lv_scale控件。以前用lv_meter手动加刻度、算角度、添标签一通操作下来代码又长又难维护换到lv_scale之后一个仪表盘的静态刻度部分十几行配置就出来了配合自定义绘制回调还能轻松做出动态指针、预警色带这些效果。这篇文章不绕弯子直接以 LVGL v9 的lv_scale控件为主线带你5分钟实现一个可复用的动态仪表盘代码可以直接在 PC 模拟器上跑也能轻松移植到 STM32、ESP32 这类嵌入式平台。适合刚接触 LVGL v9、或者正打算把老项目 UI 升级到 v9 的朋友看完你至少能明白三个事lv_scale的核心 API 怎么用、动态指针怎么画、以及各种版本差异和坑在哪里。1.lv_scale能做什么为什么 v9 要专门给它一个控件1.1 从lv_meter到lv_scale仪表类控件的进化LVGL v8 时代想做个仪表盘一般用lv_meter。这个控件本身不差但用起来很啰嗦你得手动调用lv_meter_add_scale()、lv_meter_add_needle_line()、lv_meter_add_ticks()这些接口一层一层地把刻度、指针、弧线叠加出来。最麻烦的是如果你想做“0~100、每10格一个主刻度、带数值标签、270度圆弧”这种常见表盘光配置刻度就是一堆回调。到了 v9官方推出了lv_scale。你可以把它理解成一个“通用标尺”它只管一件事——沿着水平方向或者圆弧方向把刻度线和刻度标签画出来。具体刻度密不密、主刻度隔几个、量程是多少、从哪个角度转到哪个角度全部参数化配置。配上 v9 新引入的自定义绘制机制你可以在刻度绘制过程中插入自己的逻辑于是“动态指针”“警戒区变色”这些功能也就不再是lv_meter的专利。从项目实际感受来说最直观的变化是代码量。原来一个圆形仪表盘静态部分至少要四五十行现在按下面的配置走10 行内就能把主体搭出来。而且因为刻度、标签这些底层绘制都封装好了整个界面的观感一致性也更好不用自己再逐像素调间距。1.2 用一张表看清lv_scale和lv_meter的差异对比维度lv_meter老方案lv_scalev9 新方案刻度绘制需要手动添加 Scale 和 Tick设置总刻度数、主刻度间隔即可刻度标签基本要自己挂 Label内置支持自动生成圆弧角度手动逐段画弧线直接设置起始角度和结束角度自定义绘制事件回调相对零散提供统一的lv_scale_custom_draw_cb_t回调代码量中等偏多明显更少适合场景老项目维护新项目、v9 项目首选如果你在维护老项目lv_meter也不是不能继续用但新项目只要基于 v9我建议直接上lv_scale。它不是简单替换而是把仪表盘开发从“手工拼装”变成了“参数配置”。2. 环境准备5分钟搭好 LVGL v9 的试验场动手之前先解决环境。lv_scale是 v9 的新控件版本最好选择 v9.1 以上因为 v9.0 里部分 API 还不太稳定有些自定义绘制接口是后面才补齐的。我下面给的两套环境任选一套跑通就行。2.1 PC 模拟器方案最推荐上手最快PC 模拟器最大的好处是编译快、调试直观。建议用 VS Code SDL2 CMake 的组合整体流程是下载 LVGL v9 源码官方仓库的 master 分支就是最新 v9。复制lvgl/lv_conf_template.h为lv_conf.h放在工程根目录。在lv_conf.h里确认LV_USE_SCALE为 1这个默认一般是打开的但裁剪过的工程容易漏。使用lvgl/examples/porting/下的模板把lv_port_disp、lv_port_indev配置好SDL 驱动在 PC 上不用改太多。主循环里定时调用lv_timer_handler()每 5ms 或 10ms 一次同时处理 SDL 事件。参考的 CMake 关键片段include_directories(${LVGL_ROOT}) add_subdirectory(${LVGL_ROOT} lvgl) target_link_libraries(ui_demo PRIVATE lvgl SDL2 m)模拟器跑起来之后所有下面的代码都能直接贴进去看效果调角度、调刻度密度非常方便。强烈建议先在这套环境里把lv_scale的参数玩明白再上板。2.2 嵌入式方案STM32 / ESP32 移植要点嵌入式移植本质上就是把lv_conf.h和显示/输入驱动接好。如果你是从 v8 升级到 v9注意一下几点v9 的分辨率支持LV_DPI_DEF建议先确认LV_COLOR_DEPTH和你屏幕一致。如果是 STM32 FreeRTOSLVGL 官方推荐单独建一个 UI 任务不要在中断里调用 UI 函数。ESP-IDF 新版组件里直接带 LVGL v9添加组件依赖后lv_scale默认就被编译进去了省去手动拷贝源文件的麻烦。老工程如果是从 v8 的 demo 改的注意删除旧lv_meter相关调用不要混用。我实际在 STM32F407 480x272 屏上跑过下面的仪表盘代码性能足够帧率 40 帧以上没什么压力。屏幕更大、分辨率更高的话再考虑双缓冲或者 DMA2D 加速。3. 核心 API 拆解读懂lv_scale的“刻度语法”lv_scale的 API 数量不多但有几个参数特别容易理解错。这一节把最关键的接口逐一拆开讲配合参数选择逻辑你看完就知道为什么那么配置。3.1 三件套创建控件、设置范围、设置刻度创建控件本身没什么特殊的lv_obj_t * scale lv_scale_create(parent); lv_obj_set_size(scale, 400, 400); lv_obj_center(scale);真正的重点是下面三个函数lv_scale_set_range(scale, 0, 100); lv_scale_set_total_tick_count(scale, 51); lv_scale_set_major_tick_every(scale, 5);先说容易踩坑的lv_scale_set_total_tick_count()。它的参数不是“刻度线的根数”而是“刻度区间的数量”。也就是说如果你传入 51实际刻度点是 52 个因为区间数 点数 - 1。我把它理解成“把量程分成多少段”量程是 0~100分成 50 段每段就是 2配合major_tick_every5表示每 5 个区间出现一个主刻度也就是每 10 一个主刻度标签就会显示 0、10、20……这样视觉最清楚。如果误把total_tick_count理解成总刻度根数就会遇到刻度分布偏掉的问题。举个例子你以为传 101 是 101 根刻度结果是 102 个点量程范围总共就 100计算出来每一格的数值偏差标签和实际位置对不上。3.2 让表盘转起来角度范围这一个函数值回票价线性标尺不需要调用角度函数但要做仪表盘必须给lv_scale_set_angle_range()lv_scale_set_angle_range(scale, 135, 45);这里需要特别强调的是LVGL v9 中角度的定义是0 度在右侧3 点钟方向角度按顺时针增加90 度在正下方。所以start_angle135表示从左下角开始end_angle45表示绕到右下角结束这个组合实际上从 135 度顺时针转了 270 度覆盖了左上、正上、右上这段圆弧刚好留出下方缺口。这是最经典的仪表盘造型转速表、温度表都是这个布局。理解角度方向很重要因为后面画指针时要自己做三角运算如果按数学坐标系里“逆时针为正”去算指针方向就会反。3.3 样式与文本调整刻度长度、颜色和标签lv_scale提供了几个快捷设置我项目中常用的有lv_scale_set_major_tick_len(scale, 18); // 主刻度长度 lv_scale_set_minor_tick_len(scale, 10); // 次刻度长度 lv_scale_set_major_tick_color(scale, lv_color_hex(0x333333)); lv_scale_set_minor_tick_color(scale, lv_color_hex(0x888888)); lv_scale_set_label_show(scale, true);标签的字体和颜色我一般直接通过样式设置lv_obj_set_style_text_color(scale, lv_palette_main(LV_PALETTE_GREY), LV_PART_ITEMS); lv_obj_set_style_text_font(scale, lv_font_montserrat_20, LV_PART_ITEMS);主刻度和次刻度的长短差要拉开18 和 10 是个比较舒服的比例视觉上能一眼分清主次。如果两个长度接近屏小一点时看起来就是一团乱线。标签方面如果刻度太密建议把total_tick_count调小否则标签会挤在一起。3.4 自定义绘制lv_scale的真正杀手锏lv_scale之所以能替代大量自定义绘制工作是因为它提供了一个绘制回调lv_scale_add_custom_drawing()。注册之后每根刻度线绘制时都会进入你的回调你能拿到当前刻度对应的数值、坐标、区域然后自由发挥。回调原型是typedef void (*lv_scale_custom_draw_cb_t)(lv_obj_t * obj, lv_layer_t * layer, lv_scale_draw_ctx_t * draw_ctx);重点在lv_scale_draw_ctx_t里面比较关键的字段有tick_value当前刻度值、tick_area刻度线区域、tick_start_point和tick_end_point刻度线两端坐标。不同 v9 小版本的字段名可能略有差异以你本机lv_scale.h里的定义为准。这个回调我用得最多的是做“按数值区间变色”。比如 0~60 绿色、60~80 橙色、80~100 红色在回调里判断tick_value落在哪个区间重新用lv_draw_rect()绘制对应颜色的刻度矩形即可。如果配合lv_scale_set_post_draw()设置绘制时机可以确保颜色覆盖在默认刻度之上。4. 实战打造一个可复用的动态仪表盘理论讲完上代码。这里给出一个完整的动态仪表盘实现功能包括0~100 量程、270 度圆弧表盘主刻度带数值标签动态指针每 100ms 刷新一次模拟传感器数据中央数字实时显示当前值指针平滑移动4.1 完整代码/************************* * gauge_ui.c * LVGL v9 lv_scale 动态仪表盘 *************************/ #include lvgl.h #include math.h /* 仪表盘全局状态 */ typedef struct { int32_t min; int32_t max; int32_t start_angle; int32_t end_angle; int32_t needle_value; lv_obj_t * scale; lv_obj_t * label; } gauge_ctrl_t; static gauge_ctrl_t gauge { .min 0, .max 100, .start_angle 135, .end_angle 45, .needle_value 0, }; /* 数值 - 角度 */ static int32_t angle_for_value(int32_t value) { if (gauge.end_angle gauge.start_angle) { return gauge.start_angle (gauge.end_angle - gauge.start_angle) * (value - gauge.min) / (gauge.max - gauge.min); } else { int32_t span 360 - gauge.start_angle gauge.end_angle; int32_t angle gauge.start_angle span * (value - gauge.min) / (gauge.max - gauge.min); return (angle 360) ? (angle - 360) : angle; } } /* 背景/指针绘制入口 */ static void gauge_pointer_event_cb(lv_event_t * e) { if (lv_event_get_code(e) ! LV_EVENT_DRAW_MAIN_END) return; lv_obj_t * scale lv_event_get_target(e); lv_layer_t * layer lv_event_get_layer(e); lv_area_t area; lv_obj_get_content_coords(scale, area); lv_point_t center { (area.x1 area.x2) / 2, (area.y1 area.y2) / 2 }; lv_coord_t radius lv_obj_get_height(scale) / 2 - 40; /* 表盘底部灰色弧线给刻度一个底座 */ lv_draw_arc_dsc_t arc_dsc; lv_draw_arc_dsc_init(arc_dsc); arc_dsc.color lv_color_hex(0xEEEEEE); arc_dsc.width 20; arc_dsc.rounded 1; lv_draw_arc(layer, arc_dsc, center, radius, gauge.start_angle, gauge.end_angle); /* 指针 */ int32_t angle angle_for_value(gauge.needle_value); float rad angle * 3.1415926f / 180.0f; lv_point_t needle_end { center.x (lv_coord_t)(radius * cosf(rad)), center.y (lv_coord_t)(radius * sinf(rad)) }; lv_draw_line_dsc_t line_dsc; lv_draw_line_dsc_init(line_dsc); line_dsc.color lv_palette_main(LV_PALETTE_RED); line_dsc.width 6; line_dsc.round_start 1; line_dsc.round_end 1; lv_point_t line[2] { center, needle_end }; lv_draw_line(layer, line_dsc, line); /* 中心圆点 */ lv_draw_arc_dsc_init(arc_dsc); arc_dsc.color lv_palette_main(LV_PALETTE_RED); arc_dsc.width 6; arc_dsc.rounded 1; lv_draw_arc(layer, arc_dsc, center, 12, 0, 360); } /* 定时器模拟传感器数据变化 */ static void gauge_update_timer(lv_timer_t * timer) { static int32_t dir 1; gauge.needle_value dir; if (gauge.needle_value 100) dir -1; if (gauge.needle_value 0) dir 1; if (gauge.label) { lv_label_set_text_fmt(gauge.label, %d, (int)gauge.needle_value); } lv_obj_t * scale (lv_obj_t *)lv_timer_get_user_data(timer); lv_obj_invalidate(scale); } /* 创建仪表盘 */ void gauge_create(lv_obj_t * parent) { /* 1. scale 主体 */ gauge.scale lv_scale_create(parent); lv_obj_set_size(gauge.scale, 480, 480); lv_obj_center(gauge.scale); /* 2. 量程和刻度 */ lv_scale_set_range(gauge.scale, gauge.min, gauge.max); lv_scale_set_total_tick_count(gauge.scale, 51); lv_scale_set_major_tick_every(gauge.scale, 5); /* 3. 圆弧角度 */ lv_scale_set_angle_range(gauge.scale, gauge.start_angle, gauge.end_angle); /* 4. 标签 */ lv_scale_set_label_show(gauge.scale, true); lv_obj_set_style_text_color(gauge.scale, lv_palette_main(LV_PALETTE_GREY), LV_PART_ITEMS); lv_obj_set_style_text_font(gauge.scale, lv_font_montserrat_20, LV_PART_ITEMS); /* 5. 刻度样式 */ lv_scale_set_major_tick_len(gauge.scale, 18); lv_scale_set_minor_tick_len(gauge.scale, 10); lv_scale_set_major_tick_color(gauge.scale, lv_color_hex(0x333333)); lv_scale_set_minor_tick_color(gauge.scale, lv_color_hex(0x888888)); /* 6. 绘制事件背景弧线 指针 */ lv_obj_add_event_cb(gauge.scale, gauge_pointer_event_cb, LV_EVENT_DRAW_MAIN_END, NULL); /* 7. 中央数值 */ gauge.label lv_label_create(parent); lv_obj_set_style_text_font(gauge.label, lv_font_montserrat_32, 0); lv_obj_set_style_text_color(gauge.label, lv_color_black(), 0); lv_label_set_text(gauge.label, 0); lv_obj_align(gauge.label, LV_ALIGN_CENTER, 0, -20); /* 8. 启动刷新定时器 */ lv_timer_create(gauge_update_timer, 100, gauge.scale); }4.2 代码逐段说明先看量程与刻度部分。我故意把total_tick_count设为 51 而不是 100是不想让每个刻度都代表 1。51 个区间表示每段 2共有 0、2、4、……、100 共 51 个刻度点其中每 5 个区间出一个主刻度也就是 0、10、20、……、100标签数量 11 个清晰不拥挤。如果你想要更密的刻度改成 101 就行但要同时调整major_tick_every否则标签会糊在一起。再看角度。start_angle135, end_angle45是仪表盘最常用的 270 度布局。lv_scale负责把刻度、标签按这个角度范围均匀分布不需要你亲自算每个刻度的位置。但在绘制指针时我们自己要用angle_for_value()把数值映射到角度这个函数里针对end_angle start_angle的情况做了跨 0 点处理。如果你直接写成start ratio * (end - start)在 135 升到 45 这种区间里会算出 90 度中间值实际应该在 180 度指针就偏了。指针绘制我放在LV_EVENT_DRAW_MAIN_END事件里用lv_event_get_layer()拿到当前绘制图层然后用最基础的lv_draw_line()画一条从圆心到目标角度的粗线。这比依赖lv_scale内部绘制上下文的写法更通用不同 v9 小版本都不容易出问题。细节上我把指针宽度设成 6带圆头视觉上比较像真实仪表指针中心画了一个半径为 12 的圆点盖住指针起点整体更有完成度。4.3 数值动态更新定时器让指针动起来动态更新的核心在gauge_update_timer()。这里用lv_timer_create()每 100ms 触发一次模拟一个传感器数值在 0~100 之间往返变化。每次变化后做两件事更新中央数字标签lv_label_set_text_fmt()调用lv_obj_invalidate()强制重绘整个 scale 对象第二件事别漏。lv_scale本身是复合控件内部状态不会因为needle_value变了就自动重绘你必须主动让 LVGL 知道这个区域脏了。如果忘了invalidate你会看到刻度正常但指针死活不动。实际项目中定时器回调里的数据可以换成从环形队列读取的传感器值或者某个滤波算法的输出。注意不要在中断里直接改 UI最好通过消息队列把数据发给 LVGL 所在任务。4.4 进阶用自定义绘制给刻度色带加“红区”警示很多仪表盘需要在 80 以上显示红色预警区。用lv_scale_add_custom_drawing()注册回调后可以在每个刻度绘制时判断数值范围并重新上色。参考实现如下static void gauge_draw_cb(lv_obj_t * obj, lv_layer_t * layer, lv_scale_draw_ctx_t * draw_ctx) { lv_color_t color lv_palette_main(LV_PALETTE_GREEN); if (draw_ctx-tick_value 60) { color lv_palette_main(LV_PALETTE_ORANGE); } if (draw_ctx-tick_value 80) { color lv_palette_main(LV_PALETTE_RED); } lv_draw_rect_dsc_t rect_dsc; lv_draw_rect_dsc_init(rect_dsc); rect_dsc.bg_color color; rect_dsc.bg_opa LV_OPA_COVER; lv_draw_rect(layer, rect_dsc, draw_ctx-tick_area); }注册回调lv_scale_add_custom_drawing(scale, gauge_draw_cb);需要注意的是lv_scale在 v9.0 和 v9.1 中自定义绘制的函数名与回调时机有差异。如果你用的是 v9.0编译报错找不到lv_scale_add_custom_drawing可以打开lv_scale.h确认一下接口名称如果是 v9.1上面的写法没问题。另外如果你发现绘制的颜色被默认刻度盖掉检查有没有lv_scale_set_post_draw()接口有的话设置为true后重绘即可。5. 常见问题与排查技巧实录这部分是实打实踩坑换来的经验建议收藏。5.1 编译报错找不到lv_scale相关函数先确认lv_conf.h里LV_USE_SCALE是 1。LVGL v9 默认代码裁剪很激进如果是从旧工程升级上来的很容易因为宏没开导致整个lv_scale模块没有被编译。另外检查是不是混入了 v8 的旧头文件路径v8 和 v9 的头文件结构差异很大不建议在同一个工程里共存。5.2 刻度数量和我预期不一样再强调一次lv_scale_set_total_tick_count()接收的是区间数不是刻度线数。刻度点 区间数 1。比如希望 0~100 每 1 一个刻度那就传 100画出来是 101 个点希望每 2 一个刻度传 50。5.3 指针方向和预期相反LVGL 的角度体系和数学课本里的极坐标不一样0 度在右侧且顺时针增加。如果你用atan2推坐标时用了逆时针习惯结果自然是反的。这里建议做指针时角度计算函数直接照上面代码里的angle_for_value()先保证数值映射正确再微调起始角度参数。5.4 标签重叠严重标签数量和total_tick_count / major_tick_every直接相关。比如你传了 101 个区间major_tick_every1那每个点都是主刻度每个主刻度都带标签小屏上必糊。一般大屏上用major_tick_every5或10小屏上反而要减少主刻度频率和标签数量。也可以考虑把lv_scale_set_label_show(scale, false)关掉只保留刻度在外部自己放几个关键数值的 Label界面会更干净。5.5 自定义回调覆盖不掉默认刻度这个跟回调触发顺序有关。v9.1 之后如果lv_scale_set_post_draw()存在设置为true让自定义绘制在默认刻度之后执行。如果你用的版本没有这个函数最简单的替代方案是不要依赖lv_scale的默认刻度颜色而是在创建控件后把默认刻度的透明度设为 0完全用自定义回调绘制刻度。5.6 性能优化低端 MCU 跑起来卡lv_scale的刻度绘制本质是大量小矩形/线段在低主频 MCU 上如果 scale 尺寸太大、刻度数太多CPU 压力会很明显。我的经验是对象尺寸不要超过屏幕实际需要的范围400x400 的表盘在 480x272 屏上就不合适。total_tick_count尽量控制在 50 以内超过 100 密集刻度的视觉效果在高分屏上不一定更好。启用 LVGL 的双缓冲配置LV_COLOR_DEPTH与屏幕一致。像 T113-S3 这类带 G2D 的芯片G2D 主要加速整屏 flushlv_scale本身的矢量绘制还是软件渲染不要指望某个 widget 自动吃到 2D 加速。6. 进阶从单个仪表盘到产品级 UI如果只是做一个仪表盘上面的代码已经够用。但真放到产品里还有几个点值得改进。6.1 把仪表盘封装成组件实战项目里一般不会只有一个表盘。我的做法是把gauge_create()升级成一个组件函数入参包含量程、单位、颜色、指针样式等参数内部维护独立的gauge_ctrl_t实例。这样同样的代码在不同的页面里创建多个仪表盘只需要传不同参数维护成本低很多。6.2 结合 FreeRTOS 设计数据流带 FreeRTOS 的项目UI 任务单独跑一个循环调用lv_timer_handler()。传感器数据由采集任务放入 QueueUI 任务收到后更新gauge.needle_value。注意lv_timer_create()创建的回调是在 UI 任务上下文中执行的不要在采集任务里直接改 UI 对象。数据更新频率也不用太快仪表盘是人眼看的100ms 刷新一次足够太快反而浪费 CPU。6.3 让指针动得更顺滑上面的代码每次直接给needle_value赋值视觉上指针是一格一格跳的。要更顺滑可以用lv_anim做动画lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, gauge.needle_value); lv_anim_set_exec_cb(a, needle_anim_cb); lv_anim_set_values(a, gauge.needle_value, target); lv_anim_set_time(a, 300); lv_anim_set_path_cb(a, lv_anim_path_ease_out); lv_anim_start(a);needle_anim_cb里更新数值并invalidate对应的 scale 对象。这种缓动效果在转速、温度等场景下体验提升非常明显代码成本也不高。我个人在实际操作中的体会是lv_scale这个控件大大降低了仪表盘类界面的开发门槛但真正让它发挥价值的关键是把参数配置、自定义绘制、动态刷新这三层逻辑理清楚。上面这版完整代码是我在多个项目里反复调整后的稳定方案你在 PC 模拟器上跑通之后可以试着改改角度范围、刻度密度或者把指针换成弧形指针你会发现这个控件的可玩性比想象中高很多。最后分享一个小技巧调试时先把角度范围设成 0~360用完整圆验证刻度分布对不对确认无误后再切回 270 度表盘造型这样排查定位问题会快很多。