LVGL 自定义控件开发教程
本文深入讲解如何在LVGL中从零实现一个可复用的触摸式环形旋钮控件,涵盖结构体继承、事件处理、绘图回调与性能优化等核心技巧,帮助嵌入式开发者掌握高阶GUI开发能力。
从零打造专属控件:LVGL 高阶实战之路
你有没有遇到过这样的场景?项目里需要一个环形音量旋钮,或者带刻度的仪表盘,又或者是那种可以“弹动”的触摸滑块。翻遍 LVGL 的官方控件列表——按钮、滑块、开关……嗯,都不太对味儿。
这时候你就得问自己一句: 能不能自己造一个?
好消息是:能!而且不难。
坏消息是:如果没人带你走一遍,那坑可真不少 😅。
今天我们就来干票大的——手把手教你如何在 LVGL 中开发一个完整的自定义控件。不是那种“Hello World”级别的玩具,而是一个真正能在产品中用起来、可复用、性能还不错的专业级组件。
我们以“触摸式环形旋钮(Knob)”为例,贯穿整个实现过程。但别被例子局限了思路,这套方法论适用于任何你想做的控件:旋转编码器界面、动态波形图、手势识别区域……只要你敢想,就能做。
一切从 lv_obj_t 开始:LVGL 的“类”系统长什么样?
在 C 这种没有原生面向对象的语言里搞 GUI 框架,听起来像是某种“魔法”。但实际上,LVGL 的做法非常聪明又极其务实: 用结构体内存布局模拟继承 。
核心就是这个结构体:
typedef struct _lv_obj_t {
// 内部字段省略...
} lv_obj_t;
它是所有控件的“祖宗”。无论是 lv_btn_create() 创建的按钮,还是你自己写的旋钮,底层都是基于 lv_obj_t 扩展出来的。
结构体怎么“继承”?
想象一下你在写 C++:
class MyKnob : public lv_obj_t { ... };
C 没有这语法,但我们可以手动“拼”出来:
typedef struct {
lv_obj_t obj; // 必须放在第一位!
int16_t value;
int16_t min_value;
int16_t max_value;
lv_point_t drag_start;
} my_knob_t;
关键点来了: 只要把父类结构体作为子类的第一个成员,编译器就能保证它们的内存地址一致 。也就是说:
my_knob_t * knob = malloc(sizeof(my_knob_t));
lv_obj_t * base = &knob->obj;
// 这两个指针指向同一块内存起始位置
assert((void*)knob == (void*)base);
这就意味着,所有接受 lv_obj_t* 的函数(比如 lv_obj_set_size() 、 lv_obj_add_event_cb() ),都可以直接操作你的自定义控件!
是不是有点像“类型擦除”?没错,这就是 LVGL 实现多态的核心机制。
那我能不能直接这么定义?
理论上可以,但实际开发中我们更推荐另一种方式: 使用扩展属性(ext attribute)动态分配数据 。
为什么?
因为如果你每个控件都定义一个大结构体,会导致:
- 控件创建时必须知道完整大小;
- 不同控件之间难以统一管理;
- 内存碎片风险增加;
所以 LVGL 提供了一个 API:
void * lv_obj_allocate_ext_attr(lv_obj_t * obj, uint32_t size);
它会为指定对象分配一块额外内存,并返回指针。你可以把它当成“私有数据区”。
于是我们的旋钮数据结构就可以这样用了:
typedef struct {
int16_t value;
int16_t min;
int16_t max;
lv_point_t drag_start;
} my_knob_ext_t;
然后在创建时动态挂上去:
my_knob_ext_t * ext = lv_obj_allocate_ext_attr(obj, sizeof(my_knob_ext_t));
ext->value = 50;
ext->min = 0;
ext->max = 100;
这样一来,控件本身的类型依然是标准的 lv_obj_t ,但我们可以通过 lv_obj_get_ext_attr(obj) 拿到自己的“小房间”,存放私货。
💡 小贴士: lv_obj_get_ext_attr() 是线程安全的吗?
目前是的,在单任务环境下没问题。但如果你在多线程或带 RTOS 的系统中使用,请确保事件处理和 UI 更新都在主线程(GUI 线程)执行,避免竞态条件。
创建控件:不只是 malloc ,而是“工厂模式”的艺术
现在我们知道数据存在哪儿了,接下来的问题是: 怎么让用户像调用 lv_btn_create() 一样,简单一行代码就生成我们的旋钮?
答案是:封装一个创建函数。
最简单的开始
lv_obj_t * my_knob_create(lv_obj_t * parent) {
lv_obj_t * obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj); // 清空默认样式,干净起步
lv_obj_set_size(obj, 100, 100);
lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); // 背景透明
// 分配扩展数据
my_knob_ext_t * ext = lv_obj_allocate_ext_attr(obj, sizeof(my_knob_ext_t));
ext->value = 0;
ext->min = 0;
ext->max = 100;
// 注册事件处理器
lv_obj_add_event_cb(obj, my_knob_event_handler, LV_EVENT_ALL, NULL);
// 设置绘图回调
lv_obj_set_draw_part_cb(obj, my_knob_draw_handler);
return obj;
}
就这么几行,我们就有了一个“看得见摸得着”的旋钮雏形。
但等等,这里有几个细节值得深挖:
为什么要 remove_style_all ?
因为默认的 lv_obj_create() 会带上一些基础样式(比如灰色背景、边框等)。对于我们要做的旋钮来说,这些全是干扰项。
清空后从零开始,反而更容易控制视觉表现。
draw_part_cb 是什么鬼?
这是 LVGL 5.3+ 引入的关键特性: 绘制阶段回调(Draw Part Callback) 。
它允许你在控件渲染的不同阶段插入自定义绘图逻辑,而不影响原有流程。
比如你想画个圆弧进度条、加个指针箭头、甚至叠加文字标签,都可以在这里完成。
它的触发时机是在 LV_EVENT_DRAW_PART_BEGIN 和 LV_EVENT_DRAW_PART_END 之间,由内核自动调度。
事件驱动的世界:让控件“活”起来
GUI 的灵魂是什么?不是长得好看,而是 响应交互 。
用户点了、滑了、拖了、松了……这些动作都要被捕获并转化为有意义的行为。
LVGL 使用的是典型的观察者模式:你注册一个回调函数,框架在合适的时候通知你。
我们的旋钮要响应哪些事件?
| 事件 | 行为 |
|---|---|
LV_EVENT_PRESSED |
记录初始触摸点 |
LV_EVENT_DRAG |
根据移动轨迹计算角度,更新值 |
LV_EVENT_RELEASED |
可选:播放释放动画或触发声效 |
LV_EVENT_VALUE_CHANGED |
值变化时广播出去,供外部监听 |
来看具体实现:
static void my_knob_event_handler(lv_event_t * e) {
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t * obj = lv_event_get_target(e); // 获取触发事件的对象
my_knob_ext_t * ext = lv_obj_get_ext_attr(obj);
switch(code) {
case LV_EVENT_PRESSED: {
lv_point_t p;
lv_indev_get_point(&p);
ext->drag_start = p;
break;
}
case LV_EVENT_DRAG: {
lv_point_t cur;
lv_indev_get_point(&cur);
// 计算相对于中心的角度(单位:度)
int16_t angle = get_angle_between_points(obj, &ext->drag_start, &cur);
int16_t new_value = map_angle_to_value(angle, ext->min, ext->max);
if(new_value != ext->value) {
ext->value = new_value;
lv_obj_send_event(obj, LV_EVENT_VALUE_CHANGED, NULL); // 广播变更
lv_obj_invalidate(obj); // 请求重绘
}
break;
}
default:
break;
}
}
这里面有两个关键函数还没写: get_angle_between_points 和 map_angle_to_value 。
先看角度计算:
static int16_t get_angle_between_points(lv_obj_t * obj, const lv_point_t * start, const lv_point_t * current) {
lv_area_t * area = lv_obj_get_content_area(obj);
lv_point_t center = {
.x = area->x1 + lv_area_get_width(area) / 2,
.y = area->y1 + lv_area_get_height(area) / 2
};
// 相对坐标
int32_t dx_start = start->x - center.x;
int32_t dy_start = start->y - center.y;
int32_t dx_cur = current->x - center.x;
int32_t dy_cur = current->y - center.y;
// 使用 atan2 计算夹角(返回弧度)
float ang_start = atan2f(dy_start, dx_start);
float ang_cur = atan2f(dy_cur, dx_cur);
// 转换为角度,并归一化到 [0, 360)
int16_t a1 = (int16_t)(ang_start * 180 / PI);
int16_t a2 = (int16_t)(ang_cur * 180 / PI);
// 处理跨象限问题(如从 350° 到 10°)
if(a2 < a1) a2 += 360;
return a2 - a1; // 返回相对增量
}
这个函数其实挺 tricky 的。特别是当用户手指绕了一圈回来时,角度可能突然跳变。所以我们不能只看绝对角度,还得跟踪累计变化。
不过为了简化示例,我们先假设用户不会连续转好几圈 😄。
再来看映射函数:
static int16_t map_angle_to_value(int16_t angle, int16_t min, int16_t max) {
// 假设有效旋转范围是 270°(比如从 -135° 到 +135°)
int16_t range_deg = 270;
int16_t v = ((int32_t)angle * (max - min)) / range_deg + min;
return constrain(v, min, max);
}
// 工具宏
#define constrain(x, low, high) (((x) < (low)) ? (low) : (((x) > (high)) ? (high) : (x)))
这里的 270° 是设计选择。你可以根据实际 UI 设计调整起始/结束角度。
绘图的艺术:不只是“画出来”,更要“画得好”
到现在为止,我们的旋钮已经有行为逻辑了,但它还“看不见”。
是时候让它美起来了 ✨!
LVGL 提供了丰富的绘图 API:画线、矩形、圆、弧、文本、图像……全都支持抗锯齿、渐变色、局部刷新。
我们通过 draw_part_cb 回调介入绘制流程:
static void my_knob_draw_handler(lv_event_t * e) {
lv_obj_draw_part_dsc_t * dsc = lv_event_get_param(e);
// 只处理主部分的绘制
if(dsc->part != LV_PART_MAIN) return;
// 获取中心点
lv_area_t * area = dsc->draw_area;
lv_point_t center = {
.x = area->x1 + lv_area_get_width(area) / 2,
.y = area->y1 + lv_area_get_height(area) / 2
};
// 底层灰弧(表示范围)
lv_draw_arc_dsc_t arc_dsc;
lv_draw_arc_dsc_init(&arc_dsc);
arc_dsc.radius = 40;
arc_dsc.center = center;
arc_dsc.start_angle = 135; // -135°
arc_dsc.end_angle = 405; // +135° = 360 + 45
arc_dsc.color = lv_color_gray();
arc_dsc.width = 8;
arc_dsc.rounded = 1;
lv_draw_arc(dsc->draw_ctx, &arc_dsc);
// 前景蓝弧(表示当前值)
my_knob_ext_t * ext = lv_obj_get_ext_attr(dsc->obj);
int16_t value_angle = 135 + ((ext->value - ext->min) * 270) / (ext->max - ext->min);
arc_dsc.end_angle = value_angle;
arc_dsc.color = lv_color_make(0, 150, 255);
lv_draw_arc(dsc->draw_ctx, &arc_dsc);
}
🎨 效果预览:
- 外圈是一段 270° 的灰色粗弧;
- 内部蓝色弧随值增长而延长;
- 圆角端点让视觉更柔和;
- 整体像个现代音响上的物理旋钮。
💡 性能提示:每次 lv_obj_invalidate() 都会触发重绘。但 LVGL 支持 局部刷新 ,只会重画脏区域(dirty area)。所以你不需要担心整屏闪烁。
实战中的那些“坑”与最佳实践
理论讲完,咱们聊聊真实项目里的经验教训。
1. 别滥用 float —— MCU 上的隐形杀手
你可能会想:“反正现在 Cortex-M4/M7 都带 FPU,用浮点算角度不是更准?”
话是没错,但在嵌入式世界里, 每一次 printf("%f", x) 或隐式 double 转换,都会链接进庞大的 math 库 ,轻松吃掉几十 KB Flash。
建议:
- 尽量用定点数或查表法;
- 如果非要用 float,确保编译器开启了 -ffast-math 和硬件 FPU 支持;
- 关键路径避免频繁 sin/cos/atan2 ,考虑预先生成 LUT(Look-Up Table);
例如:
// 预计算 0~360° 的 sin/cos 表(每度一项)
static const int16_t sin_lut[360] = { /* ... */ };
2. 内存别往栈上堆
曾经有个同事写了这么一段代码:
void my_knob_draw_handler(...) {
lv_point_t points[100]; // 局部数组,100 个点 → 800 字节!
// ...
}
结果在递归调用或其他中断上下文中触发栈溢出……
记住: GUI 回调属于中断上下文边缘地带 ,栈空间有限。大型临时变量务必放在静态区或堆上。
正确姿势:
static lv_point_t s_temp_points[100]; // 静态缓冲区
或者用 lv_mem_alloc() 动态申请(记得释放)。
3. 支持动画?当然要!
LVGL 的动画系统 ( lv_anim_t ) 是一大亮点。让你的旋钮支持平滑过渡,只需要几行代码:
void my_knob_animate_to_value(lv_obj_t * obj, int16_t target, uint32_t time) {
my_knob_ext_t * ext = lv_obj_get_ext_attr(obj);
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, obj);
lv_anim_set_values(&a, ext->value, target);
lv_anim_set_time(&a, time);
lv_anim_set_exec_cb(&a, [](void * var, int32_t v) {
my_knob_ext_t * ext = lv_obj_get_ext_attr((lv_obj_t*)var);
ext->value = v;
lv_obj_invalidate((lv_obj_t*)var);
});
lv_anim_set_path_cb(&a, lv_anim_path_ease_out); // 缓出效果
lv_anim_start(&a);
}
现在你可以这样调用:
my_knob_animate_to_value(knob, 80, 500); // 0.5秒内平滑转到80
瞬间就有了高级感 🎉。
4. 主题兼容性:别让自己变成“异类”
如果你的控件完全脱离了 LVGL 的样式系统,那它就失去了灵活性。
理想情况是: 你的控件能自动适配当前主题的颜色、字体、圆角风格等 。
怎么做?
- 使用
lv_theme_get_color_primary()获取主色调; - 使用
LV_FONT_DEFAULT而不是硬编码字体; - 支持 RTL 布局(虽然旋钮可能不涉及,但通用组件要考虑);
- 提供
set_style_xxx接口,允许外部定制;
例如:
void my_knob_set_indicator_color(lv_obj_t * obj, lv_color_t color) {
my_knob_ext_t * ext = lv_obj_get_ext_attr(obj);
ext->ind_color = color; // 存起来,在 draw 中使用
lv_obj_invalidate(obj);
}
5. 用户该怎样使用你的控件?
优秀的控件应该“开箱即用”。想想你是怎么用 lv_slider_create() 的:
lv_obj_t * slider = lv_slider_create(parent);
lv_slider_set_value(slider, 50, LV_ANIM_ON);
我们也应该提供类似的简洁接口:
lv_obj_t * knob = my_knob_create(parent);
my_knob_set_range(knob, 0, 100);
my_knob_set_value(knob, 75);
my_knob_add_event_cb(knob, on_value_changed, LV_EVENT_VALUE_CHANGED, NULL);
为此你需要补充几个辅助函数:
void my_knob_set_value(lv_obj_t * obj, int16_t val) {
my_knob_ext_t * ext = lv_obj_get_ext_attr(obj);
if(val == ext->value) return;
ext->value = constrain(val, ext->min, ext->max);
lv_obj_send_event(obj, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_invalidate(obj);
}
int16_t my_knob_get_value(lv_obj_t * obj) {
my_knob_ext_t * ext = lv_obj_get_ext_attr(obj);
return ext->value;
}
void my_knob_set_range(lv_obj_t * obj, int16_t min, int16_t max) {
my_knob_ext_t * ext = lv_obj_get_ext_attr(obj);
ext->min = min;
ext->max = max;
// 注意:可能需要重新映射当前值
ext->value = constrain(ext->value, min, max);
lv_obj_invalidate(obj);
}
这样别人用起来才舒服,也更容易集成到现有项目中。
架构视角:它到底处在系统的哪一层?
在一个典型的嵌入式 HMI 架构中,我们的自定义控件处于这样一个位置:
┌─────────────────────┐
│ Application Logic │ ← 页面控制器,业务逻辑
├─────────────────────┤
│ Custom Widget Layer │ ← my_knob.c/h,独立模块
├─────────────────────┤
│ LVGL Core │ ← 对象管理、事件派发、渲染引擎
├─────────────────────┤
│ Display Driver (LCD)│ ← 如 ST7789、ILI9341 驱动
├─────────────────────┤
│ Input Driver (Touch)│ ← XPT2046、GT911 等
└─────────────────────┘
它的职责很明确:
- 向上:提供简单易用的 API;
- 向下:依赖 LVGL 内核服务完成渲染与事件处理;
- 对外:像原生控件一样被组合、嵌套、布局;
更重要的是: 它可以被打包成 .c/.h 文件,扔进别的项目直接复用 。
这才是“造轮子”的真正意义——不是重复劳动,而是积累资产 💼。
它解决了什么问题?为什么不用图片切换?
以前的做法可能是:
“做个旋钮?简单!准备 100 张 PNG 图片,按角度切换显示。”
听起来可行,实则灾难:
| 问题 | 后果 |
|---|---|
| 图片资源占用大 | 一张 100x100 ARGB8888 图 ≈ 40KB,100张就是 4MB!Flash 直接爆掉 🔥 |
| 切换卡顿 | 加载纹理耗时,尤其 SPI Flash 上更慢 |
| 缩放失真 | 换个屏幕分辨率就得重新出图 |
| 修改麻烦 | UI 改个颜色,美术又要加班 |
而我们的矢量方案:
- 占用近乎为零的存储空间;
- 渲染基于数学计算,无限缩放不失真;
- 改颜色?改一行代码就行;
- 支持运行时主题切换;
效率高下立判。
更进一步:你能拓展到什么程度?
掌握了这套方法,你的想象力才是唯一限制。
试试这些方向:
✅ 添加“咔哒”手感(Tactile Feedback)
配合震动马达,在特定角度(如每 15°)产生轻微震动,模拟物理编码器的“档位感”。
if(angle % 15 == 0) trigger_haptic_feedback();
✅ 动态刻度标记
在弧线上每隔一定角度画一个小短线或数字标签:
for(int i = 0; i <= 10; i++) {
int16_t tick_angle = 135 + i * 27; // 10等分
draw_tick_line(dsc->draw_ctx, center, 40, tick_angle, lv_color_white());
}
✅ 支持多种外观风格
通过参数切换不同视觉模式:
typedef enum {
MY_KNOB_STYLE_ARC, // 圆弧填充
MY_KNOB_STYLE_DOT_TRACE, // 小圆点轨迹
MY_KNOB_STYLE_NEEDLE // 指针式
} my_knob_style_t;
void my_knob_set_style(lv_obj_t * obj, my_knob_style_t style);
✅ 与音频系统联动
绑定到真实的音量控制:
static void on_value_changed(lv_event_t * e) {
int16_t val = my_knob_get_value(lv_event_get_target(e));
audio_set_volume(val); // 调用底层驱动
}
写在最后:为什么说这是嵌入式工程师的“成人礼”?
当你第一次成功做出一个能用、好用、还能给别人用的自定义控件时,你会有一种特别的感觉:
“原来我不是只会调 API 的搬运工,我真的能‘创造’东西。”
这种能力,远不止于做一个旋钮。
它代表你已经理解了:
- 框架的设计哲学;
- 内存与性能的权衡;
- 模块化与接口抽象;
- 用户体验的细节打磨;
而这,正是优秀嵌入式软件工程师的核心竞争力。
下次当你看到某个酷炫的 UI 元素时,别再说“这肯定很难做”,而是问自己:
“我能把它拆解成事件 + 数据 + 绘图三部分吗?”
“它的状态机是怎么流转的?”
“我能不能下周就让它跑在我的板子上?”
一旦你开始这样思考,恭喜你——你已经入门了 🚀。
更多推荐
所有评论(0)