从HTML到LVGL:Web前端开发者嵌入式UI迁移实战指南
发布时间:2026/10/3 10:50:09 锦皓数字建站

我在网页切图上摸爬滚打好几年第一次接到要拿 LVGL 做嵌入式 UI 的需求时心里是有点懵的。一个登录页HTML 里半小时能调完的样式放到 LVGL 里连水平居中都得翻半天 API。但真正把页面从 HTML 迁到 LVGL 之后我的感受变成了这两套东西底层逻辑本来就是一回事只是语言不同。那些 ComboBox、CheckBox、Slider在 Web 世界里你叫它们表单控件在 LVGL 里它们叫控件对象两者背后都是“对象树 样式表 事件回调”。这让我想认真聊一次“从 HTML 到 LVGL”的迁移。不是教你怎么写 LVGL 教程而是给那些熟悉 HTML/CSS/JS却被老板一句“这个界面你用 LVGL 搞一下”砸懵的开发者提供一套可以直接落地的思路。把 Web 端养成的设计习惯翻译成嵌入式 UI 的语言把设计稿一步步变成真机屏上能跑的界面。文章涉及的代码基于 LVGL 8.x/9.x 的通用 API个别接口在不同小版本里会有差异遇到编译报错先查官方文档。1. 先搞清楚为什么非要从 HTML 迁到 LVGL1.1 LVGL 在嵌入式 UI 里到底处在什么位置LVGL 是一个用 C 写的开源图形库专门为资源受限的嵌入式设备设计。它不依赖操作系统可以在裸机上跑也可以运行在 FreeRTOS、RT-Thread、Linux 等系统之上。你不需要 GPU不需要完整浏览器引擎只要有一块屏幕、一个显示驱动、或者通过模拟器在 PC 上跑起来就能构建出包含按钮、列表、图表、键盘、动画的完整界面。我第一次把 LVGL 跑在 MCU 上的时候最惊讶的是它的资源占用。一个带基础控件和简单动画的界面RAM 能压在几十 KB 到几百 KB 这个量级Flash 存储的字体和资源另算。这对 Web 开发者来说几乎是不可想象的。浏览器解析一个 DOM 树、跑一遍 CSS 计算的开销都比这大得多。所以 LVGL 的位置很清楚如果你要做的产品是一个需要屏幕但不需要跑 Android 或 Linux 完整图形栈的设备比如温控器、电表、小家电屏幕、工控 HMI、智能穿戴LVGL 就是那个比 Qt 轻、比裸写 framebuffer 高效得多的选择。1.2 同样是做 UI为什么不用 Qt 或者直接 Web 技术这是新接触 LVGL 的人最喜欢问的问题。我自己的判断标准其实很简单看你的硬件平台和系统环境。如果主控是一颗 Cortex-M 系列 MCU内存只有几十到几百 KB跑不了完整 Linux那基本只能在 LVGL 这类轻量级库里选。如果板子上跑的是完整 Linux内存 256MB 以上屏幕分辨率高交互复杂那 Qt 可能更合适因为它有更成熟的渲染管线和强大的 QML 生态。至于直接在嵌入式里跑 Web 页面那就更看团队功底了你需要在板子上塞 WebView这意味着对 CPU、内存和系统的要求会再上一个台阶。之前有人问“Linux 跑 Qt 还是跑 LVGL”我的建议是如果产品是一个需要快速迭代、动效炫酷、团队里有大量前端开发者的场景你可以在 Linux 上跑 LVGL 作为渲染后端这也是完全可行的LVGL 在 Linux framebuffer 或 SDL 后端下都能跑得很好。但如果你需要复杂的文字排版、PDF 级渲染、多窗口管理Qt 的优势就没法替代。LVGL 其实还有一个隐藏优势它特别适合和 Web 前端工作流互补。你完全可以一边在 PC 上用 LVGL 的模拟器开发调试界面另一边用 SquareLine Studio 这类工具直接拖拽生成 UI 代码最后把代码编译到 MCU 上。这条链路跑顺之后开发效率比想象中高很多。2. 把 Web 前端的三板斧逐句翻译成 LVGL2.1 布局模型Flexbox 和 CSS Grid 到 LVGL Flex 的翻译HTML 页面里最常见的布局手段一个是 Flexbox一个是 Grid。LVGL 从 8.x 开始也加入了原生 Flex 和 Grid 布局支持这简直是 Web 开发者的福音。你可以不再逐个计算坐标而是像写 display:flex 一样设置布局方向、对齐方式、间距。以 Flex 为例HTML 里你写.container { display: flex; flex-direction: column; gap: 16px; align-items: center; }对应的 LVGL 写法大致是这样lv_obj_t *cont lv_obj_create(NULL); lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); /* flex-direction: column */ lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(cont, 16, 0); /* gap 横向间隔 */ lv_obj_set_style_pad_column(cont, 16, 0); /* gap 纵向间隔 */这里的 LV_FLEX_FLOW_ROW、LV_FLEX_FLOW_COLUMN 就是 flex-directionLV_FLEX_ALIGN_CENTER 就是 justify-content 和 align-items 的组合。我一般在迁移一个页面之前会先在纸上把设计稿里的元素按“行”“列”“嵌套”拆出层级关系然后把每个块对应到 LVGL 的容器对象就像把 HTML 的 div 块拆成 flex 容器一样。习惯绝对定位的人刚开始会不适应因为 LVGL 默认创建对象时如果不指定坐标所有对象都堆在左上角需要手动设置 pos 或依赖布局管理器去排布。我的建议是能不用绝对定位就别用绝对定位能嵌套 Flex 就嵌套 Flex这样才能保证界面在不同分辨率下的表现一致。2.2 样式系统CSS 选择器、伪类变成 LVGL Style 和状态Web 前端里改样式最常见的是写类名、写 CSS 规则还有 hover、active 这些伪类。LVGL 的样式系统和这条思路非常像只是写法变成了 C 代码。你可以在 LVGL 里先定义一个全局样式相当于一个 CSS 类static lv_style_t style_btn; lv_style_init(style_btn); lv_style_set_bg_color(style_btn, lv_color_hex(0x2196F3)); lv_style_set_radius(style_btn, 8); lv_style_set_pad_all(style_btn, 12);然后把样式加到对象上相当于给 HTML 元素绑定 classlv_obj_t *btn lv_button_create(parent); lv_obj_add_style(btn, style_btn, 0);第三个参数是选择器扮演着类似伪类的角色。传 LV_STATE_PRESSED就是按下去那一下的样式相当于 :active传 LV_STATE_DISABLED就是禁用态相当于 :disabled。我经常用这个机制实现按钮的按压缩放效果在 pressed 状态里把 pad 调大视觉上就会有一按变大的反馈。颜色方面CSS 里写 #RRGGBBLVGL 里就写 lv_color_hex(0xRRGGBB)做法一致。CSS 里的 opacity 对应 LVGL 的 lv_obj_set_style_opa / lv_obj_set_opaCSS 里的 border-radius 对应 lv_style_set_radius。你不是在重新学 UI你只是换了一层 API 的皮。这里有一个 Web 开发者容易忽略的迭代LVGL 样式是按“局部样式 全局主题Theme”两层叠加的。如果你不想要默认主题的蓝紫色控件可以调用 lv_theme_default_init 时传 NULL 参数禁用默认主题或者自定义一个全局主题。否则你会遇到“明明设置了按钮背景色为什么变不生效”的情况大概率是被主题覆盖了。2.3 事件交互从 addEventListener 到 LVGL 事件回调Web 里监听点击、滚动、输入框变化都是 addEventListener。LVGL 的对应物是事件回调API 形式是 lv_obj_add_event_cb。以点击事件为例void btn_click_cb(lv_event_t *e) { lv_event_code_t code lv_event_get_code(e); if (code LV_EVENT_CLICKED) { /* 点击后的动作 */ } } lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, NULL);注意这里的事件是挂在具体对象上的不像 DOM 事件还能冒泡到父节点。LVGL 也有事件冒泡机制需要你在 add_event_cb 之后手动调用 lv_obj_set_event_bubbling(obj, true) 才能让事件冒泡到父对象。这一点和 Web 里事件默认冒泡是不同的刚开始写界面很容易漏掉这行代码然后发现父容器收不到任何点击通知。输入框的文本变化对应 LV_EVENT_VALUE_CHANGED不是 LV_EVENT_CLICKED这个也很容易被 Web 开发者误解。LVGL 里只要是控件值发生变化的场景比如 Slider 拖动、CheckBox 勾选、TextArea 输入都会触发 LV_EVENT_VALUE_CHANGED。我习惯在回调里先统一用 lv_event_get_code 判断事件类型再分别处理。3. 实操把一个登录页从 HTML 迁移到 LVGL 9.x 模拟器3.1 先搭一个 PC 模拟器别上来就啃板子我强烈建议所有准备从 HTML 转 LVGL 的开发者第一步先在 PC 上把模拟器跑起来而不是直接对着开发板调试。原因很简单在 PC 上你能用调试器、能边改边跑、能快速定位问题开发效率差距太大了。目前最常用的方案是用 VS Code SDL 后端GitHub 上官方有 lv_port_pc_vscode 模板仓库。操作步骤大致是这样的安装 VS Code、CMake、Ninja。安装 MSYS2 或者直接在 Linux 环境操作。git clone 官方 PC 模拟器模板仓库仓库里已包含 LVGL 子模块。编译并运行看到模拟器窗口弹出上面有一个演示界面。把要用的 GUI 设计工具生成的代码或者手写代码放到 src 目录重新编译。这一步做好后你等于拥有了一个“嵌入式浏览器”后续所有 UI 的调试都在这里完成。如果你用的是 SquareLine Studio 这类可视化设计工具那更省事。它导出的代码可以直接拷进模拟器工程再交叉编译到目标板。LVGL 9.x 之后的生态明显更偏向这种“设计器生成代码 手动修改细节”的工作流新手不要抗拒工具链能提升效率的工具都是好工具。3.2 拆解设计稿从 HTML 分层变成 LVGL 对象树假设要迁移一个很常见的登录页顶部是一个 Logo 图片中间是用户名输入框、密码输入框下面是登录按钮底部是一个“忘记密码”文本。HTML 里大概长这样div classlogin-page img srclogo.png / input placeholder用户名 / input placeholder密码 typepassword / button登录/button a href#忘记密码/a /div对应的 LVGL 思路最外层是一个全屏的 lv_obj使用 Flex Column 布局内部依次添加图片对象、两个输入框对象、一个按钮对象、一个标签对象。伪代码对象树是screen └── page container (flex column) ├── image (logo) ├── textarea (用户名) ├── textarea (密码) ├── button (登录) └── label (忘记密码)这棵树和你写 HTML 时的 DOM 结构有着一一对应关系。你在迁移时的核心工作就是把 div、img、input、a 这些标签翻译成 lv_obj_create、lv_image_create、lv_textarea_create、lv_button_create、lv_label_create然后把 CSS 里定义的间距、圆角、颜色用 lv_obj_set_style_pad_*、lv_obj_set_style_radius、lv_obj_set_style_bg_color 一个属性一个属性填进去。3.3 手写核心代码登录页落地的一个最小范例下面这段是我在实际项目中抽取出来的核心结构去掉了一些不重要的装饰保留了最关键的部分。我建议你在模拟器里跑通之后再往里面加你自己的业务逻辑。static void login_btn_cb(lv_event_t *e) { lv_event_code_t code lv_event_get_code(e); if (code LV_EVENT_CLICKED) { /* 实际项目里这里会去校验账号密码然后切换页面 */ LV_LOG_USER(login button clicked); } } static void create_login_screen(lv_obj_t *parent) { /* 最外层容器占满整个屏幕 */ lv_obj_t *cont lv_obj_create(parent); lv_obj_set_size(cont, lv_pct(100), lv_pct(100)); lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_all(cont, 24, 0); /* Logo 图片 */ lv_obj_t *logo lv_image_create(cont); lv_image_set_src(logo, S:/path/to/logo.png); /* 模拟器支持本地图片路径 */ /* 用户名输入框 */ lv_obj_t *user_ta lv_textarea_create(cont); lv_obj_set_width(user_ta, lv_pct(80)); lv_textarea_set_placeholder_text(user_ta, 用户名); lv_obj_set_style_pad_all(user_ta, 12, 0); /* 密码输入框 */ lv_obj_t *pass_ta lv_textarea_create(cont); lv_obj_set_width(pass_ta, lv_pct(80)); lv_textarea_set_placeholder_text(pass_ta, 密码); lv_textarea_set_password_mode(pass_ta, true); lv_obj_set_style_pad_all(pass_ta, 12, 0); /* 登录按钮 */ lv_obj_t *btn lv_button_create(cont); lv_obj_set_width(btn, lv_pct(60)); lv_obj_t *btn_label lv_label_create(btn); lv_label_set_text(btn_label, 登 录); lv_obj_center(btn_label); lv_obj_add_event_cb(btn, login_btn_cb, LV_EVENT_CLICKED, NULL); /* 忘记密码文本 */ lv_obj_t *forget_label lv_label_create(cont); lv_label_set_text(forget_label, 忘记密码); lv_obj_set_style_text_color(forget_label, lv_color_hex(0x888888), 0); lv_obj_set_style_text_font(forget_label, lv_font_montserrat_14, 0); }注意几件事。lv_pct(100) 相当于 CSS 的 100%用它做自适应尺寸会让布局更稳。lv_textarea_set_password_mode 对应 input 的 typepassword。按钮里的 label 必须自己创建并居中这个和 HTML 里 button 直接能放文字不一样新手最容易漏这一行漏掉之后按钮上就什么都没有。模拟器里运行这段代码你会看到一个具备基本交互的登录页。接下来要做的事情是把它移植到目标芯片上这才是真正的考验。4. 移植过程中最容易被坑住的四个细节4.1 字体中文不是白嫖的得提前规划Web 开发中字体再简单不过一个 font-family 声明系统会帮你加载好。LVGL 不一样它默认的字体只包含 ASCII 字符集你写中文“登录”最后显示成一片空白或者方框。解决路径就是使用 LVGL 官方提供的字体转换工具把 TTF/OTF 字体文件转换成 C 语言数组加入工程。转换的时候有几个关键参数字体大小比如 14、16、24。需要的字符集范围如果只是界面固定文案可以只转换几百个字是否启用抗锯齿。转换之后你需要在代码里声明 extern lv_font_t my_font; 然后把所有文本控件指定到这个字体上。别以为做了转换就万事大吉中文字体文件转换后体积可能还是不小几百个常用字就可能占几十 KB 的 Flash这在 MCU 资源紧张时是很要命的事。我的习惯是把页面文案压缩到最小集合能用图标代替的文字就用内置符号比如返回箭头用 LV_SYMBOL_LEFT设置用 LV_SYMBOL_SETTINGS。4.2 颜色RGB565 会让你怀疑人生Web 端默认是 8 位每通道的 ARGB8888 颜色。LVGL 支持的颜色深度可以在 lv_conf.h 里配置 LV_COLOR_DEPTH常见的是 16 位 RGB565 和 32 位 ARGB8888。如果板子的屏是 RGB565你精心挑选的 #F0F0F0 浅灰背景显示出来可能明显偏色渐变色也会出现肉眼可见的色带。这不算 bug是硬件限制了颜色精度。遇到偏色问题先查你的 LV_COLOR_DEPTH再看屏幕驱动支持 3 字节还是 2 字节像素格式。这个排查顺序能省掉很多折腾时间。另外LVGL 里设置透明度很自由lv_obj_set_style_bg_opa(obj, LV_OPA_30, 0) 能把背景变成 30% 透明。但这在 RGB565 屏上要小心透明混合是会丢精度的叠加次数多了颜色会发灰。如果你追求设计稿上那种高级的渐变灰“毛玻璃”效果请立刻冷静下来把预期调低。4.3 毛玻璃一类的高级特效能绕就绕热搜里有个词是“LVGL 毛玻璃”。我特别理解大家想要这种效果CSS 的 backdrop-filter: blur 一眼高级。但 LVGL 原生这套软件渲染流程里要对一个区域做高斯模糊代价极高。跑这种效果要么把负载转移给硬件加速器要么自己在渲染回调里做离屏页面叠加模糊工程量和性能开销都会让你悔不当初。我的建议是嵌入式 UI 设计的审美应该往“硬边 强对比 简洁层级”靠而不是往 Web 的弥散阴影和背景模糊靠。用大背景的深浅对比、色块、留白来拉开层次同样能做到质感。非要那种“通透感”可以在图片素材里做好半透明遮罩不要指望在目标上实时 blur。4.4 遥控器按键输入LVGL 的焦点系统如果产品不用触摸屏而是用红外遥控器、旋钮编码器或者实体按键LVGL 提供了 lv_group 来做焦点管理。不是所有控件天生就能被按键选中你首先要创建一个组然后把控件加进组里再为输入设备指定这个组。lv_group_t *g lv_group_create(); lv_group_add_obj(g, btn); lv_group_add_obj(g, user_ta); lv_indev_set_group(kp_indev, g);焦点在组内会按加入顺序在两个对象之间切换按下确认键就相当于触发点击事件。这里有三个容易踩的坑一是创建的输入设备必须是 lv_indev_create 出来的按键设备类型而不是触摸屏二是每个控件如果自己定义了点击回调焦点和触发是两码事有些控件需要设置 LV_OBJ_FLAG_CLICKABLE三是首次进入页面时屏幕默认焦点不一定在你希望的位置需要手动调用 lv_group_focus_obj 指向初始对象。我把这些情况整理成了一张速查表你迁移 UI 时可以直接对着查场景HTML/CSS/JS 习惯LVGL 做法水平居中justify-content: centerlv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, ...)元素隐藏display: nonelv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN)控件禁用disabled 属性lv_obj_add_state(obj, LV_STATE_DISABLED)点击事件addEventListener(click)lv_obj_add_event_cb(obj, cb, LV_EVENT_CLICKED, NULL)改变透明度opacity: 0.5lv_obj_set_style_opa(obj, LV_OPA_50, 0)圆角border-radius: 8pxlv_obj_set_style_radius(obj, 8, 0)文本溢出省略text-overflow: ellipsislv_label_set_long_mode(label, LV_LABEL_LONG_DOT)背景色background-color: #ffflv_obj_set_style_bg_color(obj, lv_color_hex(0xFFFFFF), 0)5. 从模拟器到板子FreeRTOS 集成与硬件加速的实际经验5.1 最小移植要动哪些文件模拟器里跑的 LVGL 代码和板子上用的 LVGL 源码是同一套区别只在于底层显示驱动和输入设备驱动的适配。从零开始在 STM32 或 ESP32 上移植 LVGL你至少要做这几件事确定屏幕驱动可能有 ILI9341、ST7789、GC9A01 等型号把 LVGL 源码文件夹加入工程配置好 lv_conf.h 里的裁剪项实现显示驱动接口主要是 lv_display_create 后设置 flush_cb 回调在回调里把 LVGL 的绘图缓冲刷到屏幕实现输入设备驱动触摸屏用一个按键用一个在定时器回调里调用 lv_tick_inc(1)在死循环或任务里调用 lv_timer_handler()。这个过程看起来繁琐但很多芯片厂商和开发板厂商已经做过了适配网上能找到现成工程。在开始移植前先确认你手里的板子是否已经有人跑通 LVGL 官方 demo这能帮你排除很多硬件层面的坑。5.2 FreeRTOS 里跑 LVGL 的正确姿势如果裸机环境下你的主循环一般长这样while (1) { lv_timer_handler(); delay_ms(5); }但在 FreeRTOS 环境里别把 lv_timer_handler 放进所有任务都要抢的那种实时性要求很高的任务里也不要让多个任务同时操作 UI 控件。LVGL 对象不是线程安全的两个任务同时改同一个按钮的文本大概率会出现内存踩踏或者显示错乱。我的做法是单独开一个 “UI Task”这个任务只做三件事接收其他任务发来的消息队列更新 LVGL 控件状态调用 lv_timer_handler。其他业务模块想改界面就往队列里丢数据所有界面变动都集中在 UI Task 里完成。这样既避免并发问题也方便排查哪个界面状态被谁改了。定时器方面你可以用 FreeRTOS 自带的软件定时器也可以在中断里调用 lv_tick_inc(1)确保 LVGL 内部的动画时间基准稳定。这个 tick 不准确动画就会忽快忽慢而且排查起来极其隐蔽。5.3 硬件加速G2D、DMA2D 到底要不要用很多应用处理器和 MCU 自带 2D 加速器比如全志 T113-S3 里的 G2D或者部分 STM32 系列里的 DMA2D。这类加速器能不能用来给 LVGL 提效我的经验是能用但别指望装上就能起飞。LVGL 本身的渲染核心是软件绘制它提交给显示驱动的是一块绘制好的 RGB 缓冲区数据。加速器能发挥作用主要体现在两种场景一是硬件完成颜色格式转换比如 ARGB8888 转 RGB565二是硬件做位块搬移和简单混合比如整块区域拷贝、旋转缩放。如果你在搞图片轮播、大面积背景切换G2D 这类加速器能明显减轻 CPU 负担。但对文字渲染、圆角裁剪这类复杂绘制硬件加速器一般帮不上太多忙还是由 LVGL 的软件渲染路径完成。所以我的建议是先把纯软件渲染跑通用性能分析工具看看 CPU 占用在哪里。如果发现 CPU 负荷高优先检查是不是屏幕刷新频率设置太高、双缓冲配置不合理、或者图像格式没按屏参来配置。这些优化完了再考虑接硬件加速。另外一个很实际的性能优化手段是减少局部刷新区域。LVGL 默认工作方式是脏矩形机制只有界面变化的小区域才会重绘所以把页面上不断闪烁的时钟、进度条之类控件控制在尽量小的区域CPU 占用就会明显下降。动画不要开太多LVGL 的动画系统再好在 MCU 上同时跑十个对象的连续动画也一样会掉帧。最后再分享一个我自己的习惯我每次接手一个需要从 HTML 迁移到 LVGL 的页面不会先去写代码而是先打开一个白板把 HTML 的结构画成一棵对象树把 CSS 的每个关键类名旁边写上 LVGL 的对应样式函数名。这个步骤看起来费时间实际上能帮你规避大多数迁移问题因为你已经提前知道哪些页面元素可以复用同一个样式哪些交互必须依赖全局主题哪些资源在目标平台上根本不存在。界面做多了之后你会发现HTML 和 LVGL 的本质都是一个树形结构加上一套样式描述机制思想完全可以互相借鉴。如果你手头也有一个正在从 Web 往嵌入式迁移的项目我的经验是先别追求样式的100%还原把功能流程跑通再把视觉细节一点点抠到位。嵌入式 UI 的主战场永远是稳定性、资源开销和交互手感把这三样守住设计迁移就已经成功了八成。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。