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 字体渲染流程 应用层请求显示字符 调用 get_glyph_dsc 查找字形描述 是否找到字形描述? 是 调用 get_glyph_bitmap 获取位图 将位图数据渲染到显示缓冲区 渲染完成 否 顺着 next 指针 查找下一个字体

这个流程看起来简单,但有几个关键点值得注意:

  1. 先找描述,再拿数据:LVGL不会一次性把所有字符的位图都加载进来。它先查描述信息(宽高、偏移),确认有这个字符后,才去拿位图数据。这种「按需加载」的设计,能省不少内存。
  2. 字体回退机制:如果当前字体找不到某个字符(比如中文字体里没有特殊符号),LVGL会沿着 next 指针找下一个字体。我建议你给每个字体都配一个回退字体,比如用 lv_font_montserrat_14 作为英文字体的回退,这样就不会出现「缺字」的情况。
  3. 回调函数是关键: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在查找字符时陷入了死循环。所以,一定要确保回退链是单向的,不要形成环。