资讯中心

LVGL v9 lv_scale 仪表盘开发实战:从刻度定制到性能优化

📅 2026/9/28 6:33:23
LVGL v9 lv_scale 仪表盘开发实战:从刻度定制到性能优化
1. 为什么仪表盘项目值得单独折腾 lv_scale做嵌入式 GUI 时间长了你会发现一个规律凡是带仪表两个字的项目客户或者产品经理的期望值都会自动拔高一个档次。普通的按钮列表、参数设置页做得中规中矩没人挑刺但只要屏幕上出现一个圆盘指针所有人都会盯着它看指针抖一下、刻度歪一点、刷新卡一帧都会被立刻指出来。这也是为什么我建议把仪表盘当成一个独立课题来对待而不是随便找个 arc 控件拼一拼就交差。LVGL 在 v9 版本里正式把lv_scale从一个附属部件提升为独立控件这件事对做仪表盘的开发者来说意义很大。在 v8 时代想要画一个带刻度的表盘主流做法是用lv_meter它确实能干活但内部把刻度、指针、弧线全揉在一起定制自由度有限而且内存占用偏大。lv_scale的思路完全不同它只负责刻度这一件事——主刻度、次刻度、标签、扇形范围全部由你控制指针、表盘背景、数值显示这些可以自由组合其他控件来实现。这种拆分式设计让整个仪表盘的层次变得非常清晰也更容易做出高颜值的视觉效果。这篇文章面向的是已经跑通 LVGL 基础移植、能点亮屏幕、会创建基本控件的开发者。如果你还在纠结怎么把 LVGL 弄到 STM32 或者 PC 模拟器上跑起来建议先把移植这一步搞定再回来看因为仪表盘的调试非常依赖实时预览。我会从lv_scale的核心 API 讲起把刻度配置、标签格式化、指针联动、动画平滑、性能优化这几块拆开讲透最后给一份可以直接编译运行的完整代码。中间会穿插一些我在实际项目里踩过的坑比如刻度标签重叠、指针刷新撕裂、动态量程切换时残留旧刻度这些问题这些在官方文档里基本不会提但实际做项目一定会遇到。2. lv_scale 的核心机制与它和 lv_meter 的本质区别2.1 lv_scale 到底在画什么理解lv_scale最快的方式是把它想象成一把可以弯成圆弧的尺子。它的本质工作是在给定的角度范围内按照设定的步长均匀地画出若干条刻度线并在指定的刻度位置放置文本标签。它不画指针不画背景圆不画数值框这些统统不管。你给它一个角度区间比如 135 度到 405 度也就是常见的 270 度表盘它就在这个区间里把刻度铺满。这个设计带来的直接好处是组合自由度极高。你可以用lv_arc画一个彩色进度弧作为背景用lv_scale叠加刻度再用一个lv_line或者旋转的lv_image做指针最后用lv_label显示当前数值。每个部件各司其职想改哪一层就改哪一层不会牵一发动全身。lv_scale的几个关键属性需要先建立概念angle_range刻度覆盖的角度范围用lv_scale_set_angle_range(scale, 270)设置默认从 135 度开始顺时针铺开。rotation整个刻度的旋转偏移用来调整起始角度。total_tick_count主刻度的总数注意这是总数不是间隔数容易搞混。major_tick_every每隔几个刻度画一条主刻度线其余为次刻度。label_enabled是否显示标签配合lv_scale_set_text_src或者格式化回调使用。2.2 和 lv_meter 的取舍很多人会问既然 v8 的lv_meter也能做仪表盘v9 为什么还要用lv_scale我整理了一张对比表这是我在两个实际项目里分别用两种方案后的真实感受对比维度lv_meterlv_scale设计定位一体化仪表盘控件纯刻度绘制控件指针支持内置多指针需自行组合实现刻度定制有限受内部结构约束完全自由逐条可控内存占用偏高内部维护多套结构较低只维护刻度数据视觉自由度中等高可叠加任意背景学习曲线上手快深入难上手稍慢深入顺畅适合场景快速出原型追求视觉品质的正式产品结论很明确如果只是做个内部调试用的简易表盘lv_meter够用但如果是面向终端用户的产品界面尤其是对颜值有要求的lv_scale是更值得投入的选择。它的笨恰恰是它的优势——因为笨所以可控。2.3 坐标系与角度约定lv_scale的角度约定和数学课本不一样这点必须提前说清楚否则调半天都对不上。它采用屏幕坐标系0 度指向右侧三点钟方向角度顺时针增加。默认的起始角度是 135 度也就是左下方顺时针转到 405 度等价于 45 度结束正好形成一个 270 度的开口朝下的表盘。这个默认值其实挺符合仪表盘的视觉习惯大多数情况下不用改。如果你想要一个开口朝上的表盘就需要把 rotation 设成 180 度让起始点转到右下方。这个调整在代码里就是一行lv_scale_set_rotation(scale, 180)但视觉上差别巨大建议先在模拟器里试一下再定。3. 从零搭一个 270 度速度表盘的完整过程3.1 环境准备与最小可运行框架我假设你已经有一个能跑的 LVGL v9 工程不管是 PC 模拟器还是 STM32 上的实际屏幕。PC 模拟器调试仪表盘效率最高因为改一行代码就能立刻看到效果不用反复烧录。如果你用的是 STM32 FreeRTOS 的组合建议也先在 PC 上把界面调好再移植过去能省下大量时间。最小框架需要包含一个屏幕对象、一个容器作为表盘底座、lv_scale实例、指针、中心数值标签。下面这段是骨架代码先让刻度显示出来其他部件后面逐步加lv_obj_t *scr lv_screen_active(); lv_obj_t *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_angle_range(scale, 270); lv_scale_set_total_tick_count(scale, 61); lv_scale_set_major_tick_every(scale, 10); lv_scale_set_label_show(scale, true);跑起来你会看到一个圆环内侧铺满了刻度每 10 条有一个主刻度。这时候还没有标签因为标签需要额外配置文本源。3.2 刻度数量与间隔的计算逻辑total_tick_count和major_tick_every这两个参数的关系是新手最容易搞错的地方。我见过不少人设了 total60、every10以为会得到 60 个刻度、每 10 个一个主刻度结果发现主刻度数量不对。正确的理解是total_tick_count是刻度的总条数major_tick_every表示每隔几条出现一个主刻度。如果 total61、every10那么主刻度出现在第 0、10、20、30、40、50、60 条一共 7 个主刻度。为什么用 61 而不是 60因为 0 到 60 是 61 个点对应 0 到 60 的数值范围每个主刻度正好代表 10 个单位标签就是 0、10、20、30、40、50、60非常整齐。如果你想要 0 到 100 的范围主刻度每 20 一个那就设 total51、every10主刻度落在 0、10、20、30、40、50对应数值 0、20、40、60、80、100。这个换算关系建议拿张纸算一遍比在代码里试错快得多。3.3 标签文本的两种配置方式标签文本有两种给法。第一种是静态数组适合刻度值固定的场景static const char *scale_labels[] { 0, 10, 20, 30, 40, 50, 60, }; lv_scale_set_text_src(scale, scale_labels);注意数组末尾要留一个空字符串作为结束标志否则会越界读取。这个坑我在第一次用的时候踩过表现是标签乱码或者程序跑飞排查了半天才发现是数组没封口。第二种是格式化回调适合刻度值需要动态计算或者带单位的场景static void scale_label_cb(lv_event_t *e) { lv_obj_t *scale lv_event_get_target(e); int32_t val lv_scale_get_label_value(scale); lv_label_set_text_fmt(lv_event_get_user_data(e), %d km/h, val); }回调方式更灵活但要注意回调触发频率如果刻度很多每帧都格式化字符串会有性能开销。我的做法是只在数值真正变化时才更新标签而不是每帧都刷。3.4 指针的实现旋转图片还是画线指针这块LVGL 没有现成的指针控件需要自己造。两种主流方案方案一旋转图片。准备一张指针的 PNG用lv_image加载然后通过lv_image_set_rotation或者lv_image_set_angle旋转。优点是视觉效果好可以做渐变、阴影、金属质感缺点是每张图占内存旋转有插值开销。方案二画线。用lv_line从圆心画一条到边缘的线通过改变终点坐标实现旋转。优点是零额外内存刷新快缺点是只能画纯色线做不出复杂质感。我一般用方案一因为仪表盘的指针是视觉焦点值得为它花点内存。但如果你跑在资源紧张的 STM32 上方案二更稳妥。下面是指针旋转的核心逻辑void set_needle_angle(lv_obj_t *needle, int32_t value, int32_t min, int32_t max) { int32_t angle 135 (value - min) * 270 / (max - min); lv_image_set_rotation(needle, angle * 10); }注意lv_image_set_rotation的参数单位是 0.1 度所以要乘以 10。这个细节文档里写得不显眼我第一次用的时候传了角度值结果指针转得飞快完全对不上。4. 让仪表盘活起来动画、联动与动态量程4.1 数值变化的平滑过渡仪表盘最忌讳的就是指针跳变。真实世界的仪表指针是带着惯性摆动的直接赋值会让界面显得很廉价。LVGL 的lv_anim可以解决这个问题但用法有讲究。不要对指针的角度直接做动画因为角度和数值之间是线性映射直接动画角度在量程切换时会出问题。正确做法是对数值做动画在动画回调里把数值转换成角度再设置指针。这样无论量程怎么变指针始终跟着数值走。static void needle_anim_cb(void *var, int32_t value) { lv_obj_t *needle (lv_obj_t *)var; int32_t angle 135 (value - g_min) * 270 / (g_max - g_min); lv_image_set_rotation(needle, angle * 10); } void animate_needle(lv_obj_t *needle, int32_t target) { lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, needle); lv_anim_set_exec_cb(a, needle_anim_cb); lv_anim_set_values(a, g_current, target); lv_anim_set_duration(a, 400); lv_anim_set_path_cb(a, lv_anim_path_ease_out); lv_anim_start(a); g_current target; }lv_anim_path_ease_out这个缓动曲线很关键它让指针先快后慢模拟真实仪表的阻尼感。如果你想要更弹的效果可以用lv_anim_path_overshoot指针会稍微冲过目标值再回弹很有机械感。4.2 中心数值与刻度的同步指针转的时候中心的大号数值也要跟着变。这里有个性能陷阱如果每帧都调用lv_label_set_text_fmt在低端 MCU 上会明显掉帧。我的优化做法是缓存上一次显示的整数值只有整数部分变化时才更新标签static int32_t last_shown -1; static void update_center_label(int32_t value) { if (value last_shown) return; last_shown value; lv_label_set_text_fmt(center_label, %d, value); }这个优化看起来不起眼但在 60 帧的动画里能省下大量字符串格式化操作实测在 STM32F4 上帧率能提升 15% 左右。4.3 动态量程切换的坑有些项目需要根据工况切换量程比如低速模式 0-60、高速模式 0-200。这时候如果直接改g_max然后重绘刻度会出现旧刻度残留的问题。原因是lv_scale的刻度是增量绘制的改参数后需要显式触发重绘。正确流程是先更新全局量程变量再调用lv_scale_set_total_tick_count和lv_scale_set_major_tick_every重新配置最后调用lv_obj_invalidate(scale)强制重绘。少任何一步都会出问题。我建议把量程切换封装成一个函数内部按顺序执行这三步避免遗漏。5. 视觉打磨配色、层次与那些让界面高级的细节5.1 刻度线的粗细与颜色分层默认的刻度线是统一粗细和颜色的看起来很平。想要高级感必须做分层主刻度粗且深次刻度细且浅甚至可以给主刻度加一点圆角。LVGL 的lv_scale支持通过样式分别设置主刻度和次刻度的线宽和颜色lv_obj_set_style_line_width(scale, 3, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0x2C3E50), LV_PART_MAIN); lv_obj_set_style_line_width(scale, 1, LV_PART_INDICATOR); lv_obj_set_style_line_color(scale, lv_color_hex(0x95A5A6), LV_PART_INDICATOR);这里LV_PART_MAIN对应主刻度LV_PART_INDICATOR对应次刻度。颜色上我习惯用深蓝灰做主刻度、浅灰做次刻度对比度适中长时间看眼睛不累。5.2 背景弧与刻度的层次关系一个好看的仪表盘通常有三层最底层是背景圆或背景弧中间是刻度最上层是指针和数值。背景弧用lv_arc实现可以做成渐变色的进度条随数值变化填充。关键点是层级顺序。LVGL 里后创建的对象默认在上层所以创建顺序应该是背景圆 → 背景弧 → 刻度 → 指针 → 中心标签。如果顺序错了指针会被刻度盖住或者刻度被背景弧遮住视觉上会很别扭。5.3 字体选择与标签可读性刻度标签的字体大小要适中。太小看不清太大又和刻度线打架。我的经验值是表盘直径 240px 时标签字体用 14px直径 320px 时用 16px。字体建议用等宽或者数字宽度一致的字体否则 1 和 8 的宽度差异会让标签看起来参差不齐。如果标签出现重叠说明刻度太密或者字体太大。解决办法有两个一是减少主刻度数量二是把标签旋转成径向排列。LVGL 的lv_scale支持标签跟随刻度角度旋转开启后标签会沿着半径方向排列能有效缓解重叠。6. 性能优化与常见问题排查6.1 刷新率与 CPU 占用的平衡仪表盘是持续刷新的控件如果处理不好会吃掉大量 CPU。几个实测有效的优化点指针动画的持续时间不要低于 200ms太短会显得生硬而且刷新压力大。刻度本身是静态的不需要每帧重绘确保没有在循环里调用lv_obj_invalidate(scale)。如果屏幕支持局部刷新确保仪表盘区域被正确标记为脏区避免全屏重绘。在 FreeRTOS 环境下把 LVGL 的lv_timer_handler放在独立任务里优先级不要设太高避免抢占其他关键任务。我在一个 STM32F407 的项目里优化前仪表盘刷新占用约 35% CPU优化后降到 12% 左右主要就是靠减少无效重绘和缓存标签文本。6.2 常见问题速查表现象可能原因解决方向刻度标签乱码文本数组未封口末尾加空字符串指针角度对不上旋转单位搞错角度乘以 10刻度残留未触发重绘调用 invalidate指针抖动动画时长过短调到 300ms 以上标签重叠刻度太密减少主刻度或旋转标签刷新卡顿每帧格式化字符串缓存上次数值内存不足指针图片过大压缩图片或改画线6.3 移植到实际硬件时的注意事项PC 模拟器上跑得再顺移植到 STM32 上还是可能出问题。最常见的是颜色格式不匹配模拟器用 ARGB8888实际屏幕可能是 RGB565指针图片的颜色会失真。解决办法是准备两套图片资源或者用纯色指针避免渐变。另一个坑是 FreeRTOS 任务栈大小。LVGL 的渲染函数递归深度不低如果任务栈给小了跑仪表盘动画时容易栈溢出表现是随机死机。我一般给 LVGL 任务留 4KB 以上的栈空间复杂界面给到 8KB。7. 完整代码与工程组织建议7.1 完整可运行代码下面这份代码整合了前面所有要点可以直接放进你的工程里跑。假设你已经初始化好 LVGL 和一个 240x240 的屏幕#include lvgl.h static lv_obj_t *scale; static lv_obj_t *needle; static lv_obj_t *center_label; static lv_obj_t *bg_arc; static int32_t g_min 0; static int32_t g_max 60; static int32_t g_current 0; static int32_t last_shown -1; static const char *scale_labels[] { 0, 10, 20, 30, 40, 50, 60, }; static void needle_anim_cb(void *var, int32_t value) { lv_obj_t *n (lv_obj_t *)var; int32_t angle 135 (value - g_min) * 270 / (g_max - g_min); lv_image_set_rotation(n, angle * 10); if (value ! last_shown) { last_shown value; lv_label_set_text_fmt(center_label, %d, value); } } void dashboard_set_value(int32_t value) { if (value g_min) value g_min; if (value g_max) value g_max; lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, needle); lv_anim_set_exec_cb(a, needle_anim_cb); lv_anim_set_values(a, g_current, value); lv_anim_set_duration(a, 400); lv_anim_set_path_cb(a, lv_anim_path_ease_out); lv_anim_start(a); lv_arc_set_value(bg_arc, value); g_current value; } void dashboard_create(void) { lv_obj_t *scr lv_screen_active(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x1A1A2E), 0); lv_obj_t *base lv_obj_create(scr); lv_obj_set_size(base, 240, 240); lv_obj_center(base); lv_obj_set_style_radius(base, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(base, lv_color_hex(0x16213E), 0); lv_obj_set_style_border_width(base, 0, 0); lv_obj_clear_flag(base, LV_OBJ_FLAG_SCROLLABLE); bg_arc lv_arc_create(base); lv_obj_set_size(bg_arc, 200, 200); lv_obj_center(bg_arc); lv_arc_set_rotation(bg_arc, 135); lv_arc_set_bg_angles(bg_arc, 0, 270); lv_arc_set_range(bg_arc, g_min, g_max); lv_obj_remove_style(bg_arc, NULL, LV_PART_KNOB); lv_obj_clear_flag(bg_arc, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_width(bg_arc, 8, LV_PART_MAIN); lv_obj_set_style_arc_color(bg_arc, lv_color_hex(0x0F3460), LV_PART_MAIN); lv_obj_set_style_arc_width(bg_arc, 8, LV_PART_INDICATOR); lv_obj_set_style_arc_color(bg_arc, lv_color_hex(0xE94560), LV_PART_INDICATOR); scale lv_scale_create(base); lv_obj_set_size(scale, 200, 200); lv_obj_center(scale); lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_angle_range(scale, 270); lv_scale_set_total_tick_count(scale, 61); lv_scale_set_major_tick_every(scale, 10); lv_scale_set_label_show(scale, true); lv_scale_set_text_src(scale, scale_labels); lv_obj_set_style_line_width(scale, 3, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0xE0E0E0), LV_PART_MAIN); lv_obj_set_style_line_width(scale, 1, LV_PART_INDICATOR); lv_obj_set_style_line_color(scale, lv_color_hex(0x808080), LV_PART_INDICATOR); lv_obj_set_style_text_color(scale, lv_color_hex(0xE0E0E0), LV_PART_MAIN); needle lv_image_create(base); lv_obj_center(needle); lv_image_set_src(needle, needle_img_dsc); lv_image_set_pivot(needle, 8, 80); lv_image_set_rotation(needle, 1350); center_label lv_label_create(base); lv_obj_align(center_label, LV_ALIGN_CENTER, 0, 40); lv_obj_set_style_text_color(center_label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(center_label, lv_font_montserrat_24, 0); lv_label_set_text(center_label, 0); }指针图片needle_img_dsc需要你自己准备建议做成 16x80 的竖条底部对齐圆心。lv_image_set_pivot设置旋转轴心这里设在图片底部中心偏上一点让指针绕圆心转。7.2 工程文件组织仪表盘代码建议单独放一个文件比如ui_dashboard.c和ui_dashboard.h对外只暴露dashboard_create和dashboard_set_value两个接口。这样业务逻辑和界面代码解耦后面换皮肤或者改布局都不会影响主流程。头文件里声明接口源文件里放所有静态变量和内部函数。全局变量尽量用static限制作用域避免和其他模块命名冲突。这个习惯在多人协作的项目里特别重要我见过太多因为全局变量重名导致的诡异 bug。7.3 后续扩展方向这套框架搭好之后扩展起来很顺手。想加第二个指针比如同时显示目标值和当前值复制一份指针创建逻辑用不同的颜色区分即可。想加报警区域可以在背景弧上叠加一段红色弧当数值进入危险区间时显示。想做多表盘切换把整个 base 容器做成可隐藏的切换时显示对应的容器就行。我个人在实际项目里最常用的扩展是峰值保持功能——用一个细线指针记录历史最大值主指针回落时它停在峰值位置按一下复位。这个功能在工业仪表里很实用实现起来也就是多一个指针加一个变量的事。最后分享一个调试技巧在 PC 模拟器上跑的时候可以临时加一个滑块控件拖动它来驱动dashboard_set_value这样能快速测试各种数值下的指针表现和标签显示比反复改代码烧录快得多。等界面调满意了再把滑块去掉换成真实的数据源。这个习惯帮我省下了大量调试时间推荐你也试试。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案