从零打造专属控件: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 元素时,别再说“这肯定很难做”,而是问自己:

“我能把它拆解成事件 + 数据 + 绘图三部分吗?”
“它的状态机是怎么流转的?”
“我能不能下周就让它跑在我的板子上?”

一旦你开始这样思考,恭喜你——你已经入门了 🚀。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐