2、LVGL字体架构:深入理解字体对象与渲染流程
上一章我们搭好了环境,也看到了字体的基本用法。但说实话,那时候的字体对我们来说还是个「黑盒子」。这一章,我们就把它拆开看看。
LVGL的字体系统,说白了就是一套「怎么把字符变成屏幕上像素」的规则。我刚开始接触时也觉得这东西很底层,后来发现,搞懂它反而能帮我们省下不少内存和性能。你想想看,嵌入式设备上每一KB都很珍贵,对吧?
2.1 lv_font_t:字体对象的核心结构
在LVGL里,所有字体都用一个结构体表示——lv_font_t。它就像字体的「身份证」,记录了字体的一切关键信息。
typedef struct _lv_font_t {
uint32_t dsc; // 字体描述符,用于标识字体
uint16_t line_height; // 行高(像素)
uint16_t base_line; // 基线位置(从顶部算起)
uint8_t line_height_fixed : 1; // 行高是否固定
int8_t letter_space; // 字符间距
int8_t line_space; // 行间距
lv_coord_t underline_position; // 下划线位置
lv_coord_t underline_thickness; // 下划线粗细
const lv_font_glyph_dsc_t *glyph_dsc; // 字形描述符数组
const uint8_t *glyph_bitmap; // 字形位图数据
lv_font_get_glyph_dsc_cb_t get_glyph_dsc; // 获取字形描述的回调
lv_font_get_glyph_bitmap_cb_t get_glyph_bitmap; // 获取字形位图的回调
lv_font_t *next; // 指向下一个字体(用于字体回退链)
} lv_font_t;
这里有几个字段,我特别想强调一下:
- line_height:行高。我遇到过有人把行高设得太小,结果上下两行文字叠在一起,根本没法看。一般建议设为字体大小的1.2倍左右。
- base_line:基线。它决定了文字在行中的垂直位置。不同字体基线的位置可能不一样,混用字体时要注意。
- glyph_dsc 和 glyph_bitmap:这两个是核心。一个描述字符的宽高、偏移量,另一个存实际的像素数据。
- next:这个字段很有意思。它指向下一个字体,形成一条「字体回退链」。什么意思呢?如果一个字符在当前字体里找不到,LVGL就会顺着这个链往下找。
核心理解:lv_font_t 本质上是一个「接口」——它不关心字体数据存在哪里(内部数组、外部Flash、SD卡都行),只关心你能不能通过回调函数拿到字形数据。这种设计让LVGL的字体系统非常灵活。
2.2 字体引擎的渲染流程
当一个字符需要显示时,LVGL内部会走一套完整的流程。我把它画成了流程图,方便你理解:
这个流程看起来简单,但有几个关键点值得注意:
- 先找描述,再拿数据:LVGL不会一次性把所有字符的位图都加载进来。它先查描述信息(宽高、偏移),确认有这个字符后,才去拿位图数据。这种「按需加载」的设计,能省不少内存。
- 字体回退机制:如果当前字体找不到某个字符(比如中文字体里没有特殊符号),LVGL会沿着
next指针找下一个字体。我建议你给每个字体都配一个回退字体,比如用lv_font_montserrat_14作为英文字体的回退,这样就不会出现「缺字」的情况。 - 回调函数是关键:
get_glyph_dsc和get_glyph_bitmap这两个回调,决定了字体数据从哪里来。内置字体直接返回数组地址,外部字体可能需要从Flash或SD卡读取。
我的经验:调试字体问题时,我经常在这两个回调函数里加打印。如果某个字符显示不出来,先看 get_glyph_dsc 有没有被调用,再看它返回了什么。80%的问题都能在这里找到线索。
2.3 内置字体与外部字体
LVGL把字体分成两大类:内置字体和外部字体。它们的区别,说白了就是「数据放哪」和「怎么加载」的问题。
| 对比项 | 内置字体 | 外部字体 |
|---|---|---|
| 数据存储位置 | 编译到固件中(数组形式) | 外部存储(Flash、SD卡、文件系统) |
| 加载方式 | 直接使用,无需额外操作 | 需要注册回调函数,按需读取 |
| 内存占用 | 全部加载到RAM(小字体还行) | 按需加载,节省RAM |
| 灵活性 | 固定,改字体要重新编译 | 可动态更换,支持更多字体 |
| 适用场景 | 少量字符、小尺寸字体 | 大字符集、大尺寸字体、多语言 |
2.3.1 内置字体
LVGL自带了一些内置字体,比如 lv_font_montserrat_14、lv_font_montserrat_20 等。它们以C语言数组的形式存在,编译时直接链接到固件里。
// 使用内置字体
lv_obj_t *label = lv_label_create(lv_scr_act());
lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0);
lv_label_set_text(label, "Hello LVGL!");
内置字体的好处是「开箱即用」,不需要任何额外配置。但缺点也很明显:
- 体积问题:一个完整的16x16中文字体,GB2312编码大概有6000多个字符,位图数据轻松超过1MB。你想想看,很多MCU的Flash总共才几MB,光一个字体就占掉一大半。
- 灵活性差:想换个字体?得重新运行字体转换工具,重新编译整个固件。
注意:我曾经在一个项目里直接用了内置的中文字体,结果固件体积从200KB暴涨到1.2MB,差点塞不进Flash。从那以后,只要涉及中文,我优先考虑外部字体方案。
2.3.2 外部字体
外部字体把数据放在外部存储里,通过回调函数按需读取。LVGL官方提供了 lv_font_fmt_txt_t 格式,专门用于外部字体。
// 外部字体的典型使用方式
lv_font_t my_ext_font;
// 初始化外部字体
lv_font_fmt_txt_dsc_init(&my_ext_font_dsc);
my_ext_font_dsc.get_bitmap = my_get_bitmap_cb; // 注册读取位图的回调
my_ext_font_dsc.get_dsc = my_get_dsc_cb; // 注册读取描述的回调
my_ext_font.dsc = &my_ext_font_dsc;
my_ext_font.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt;
my_ext_font.get_glyph_bitmap = lv_font_get_glyph_bitmap_fmt_txt;
// 使用外部字体
lv_obj_set_style_text_font(label, &my_ext_font, 0);
外部字体的核心在于那两个回调函数。你需要自己实现:
- my_get_dsc_cb:根据字符编码,从外部存储读取字形描述信息(宽高、偏移等)。
- my_get_bitmap_cb:根据字形索引,从外部存储读取实际的位图数据。
我个人习惯把字体数据放在外部Flash里,用SPI接口读取。这样既省了内部Flash空间,又保留了更换字体的灵活性。
选型建议:如果你的项目只需要显示少量英文和数字(比如仪表盘、菜单),内置字体完全够用。但如果涉及中文、日文、韩文等大字符集,或者需要多种字体风格,外部字体是更明智的选择。
2.4 字体回退链的实际应用
前面提到了 next 指针,这里展开说说。字体回退链是LVGL一个很实用的特性,它允许你组合多个字体,形成一个「字体链」。
// 创建字体回退链
lv_font_t *font_chain = &lv_font_montserrat_14; // 主字体(英文)
font_chain->next = &my_chinese_font; // 回退字体(中文)
my_chinese_font.next = &emoji_font; // 再回退(表情符号)
// 使用字体链
lv_obj_set_style_text_font(label, font_chain, 0);
lv_label_set_text(label, "Hello 世界 😊"); // 三种字符都能显示
这个机制的好处是:
- 按需加载:主字体只包含常用字符,回退字体处理特殊情况。
- 节省空间:不用把所有字符塞进一个字体文件。
- 灵活组合:可以自由搭配不同风格的字体。
避坑指南:我曾经把回退链搞成了循环链表——A指向B,B指向A。结果LVGL在查找字符时陷入了死循环。所以,一定要确保回退链是单向的,不要形成环。