3、图片解码流程:LVGL图片解码器架构、解码器注册与优先级、解码回调函数详解

图片解码,说白了就是把硬盘里的 JPEG、PNG 文件,变成 LVGL 能直接拿来刷屏的像素数据。这个过程如果出了问题,你看到的要么是花屏,要么是黑块,要么直接卡死。

我刚开始用 LVGL 做外部存储加载时,就栽在这上面。明明文件读出来了,但显示就是不对。后来才发现,是解码器的注册顺序搞错了。嗯,这节课我们就把它彻底讲透。

3.1 LVGL 图片解码器架构

LVGL 的解码器架构,其实是一个典型的「策略模式」。它不关心你的图片是存在 Flash、SD 卡还是网络,它只关心你有没有注册对应的解码器。

整个流程可以概括为三步:

  1. 文件读取:通过文件系统接口拿到原始字节流
  2. 格式识别:遍历已注册的解码器,找到能处理这个格式的那个
  3. 像素转换:把压缩数据解压成 LVGL 的 lv_img_dsc_t 结构

下面这张图,是我个人习惯用来跟团队新人讲架构的,你一看就明白:

LVGL 图片解码器架构 图片文件 (PNG/JPEG/BMP) 文件系统接口 (lv_fs) 解码器管理器 (lv_img_decoder) 内置解码器 (PNG/JPEG) 自定义解码器 外部库解码器 (libjpeg-turbo) lv_img_dsc_t 像素数据

核心要点:LVGL 不直接解码图片。它把解码工作委托给注册好的解码器。你只需要告诉它「这个格式谁来解」,剩下的它自己搞定。

3.2 解码器注册与优先级

解码器不是自动生效的。你得手动注册。而且注册顺序决定了谁先被调用。

LVGL 内部维护了一个解码器链表。每次有新图片要解码,它就从头到尾遍历这个链表,挨个问「你能处理这个文件吗?」。第一个说「能」的,就接手干活。

注册函数就一个:

lv_res_t lv_img_decoder_add(lv_img_decoder_t * decoder);

你想想看,如果你先注册了一个「万能解码器」,它什么格式都说能解,那后面的专用解码器就永远没机会了。我在项目中就遇到过这种坑——自己写了个 BMP 解码器,结果 PNG 解码器先注册了,它看到 BMP 文件头不认识,直接返回失败,但我的 BMP 解码器排在后面,根本没被问到。

所以,优先级规则其实很简单:

注册顺序 优先级 说明
先注册 高 先被遍历,先被询问
后注册 低 只有前面的都拒绝,才轮到它

个人建议:把专用解码器(比如 PNG、JPEG)注册在前面,通用或自定义解码器注册在后面。这样专用解码器能快速命中,性能更好。

3.3 解码回调函数详解

每个解码器需要实现两个回调:info_cb 和 open_cb。还有一个可选的 close_cb。

这三个回调,构成了解码器的完整生命周期。

3.3.1 info_cb:探路先锋

这个回调的作用很简单——看一眼文件头,判断这个格式我能不能解。能,返回 LV_RES_OK;不能,返回 LV_RES_INV。

原型长这样:

lv_res_t (*info_cb)(struct _lv_img_decoder_t * decoder, const void * src, lv_img_header_t * header);

注意第三个参数 header。如果你确定能解这个文件,需要在这里填上图片的宽、高、格式等信息。LVGL 会拿这些信息去分配内存。

我曾经犯过一个低级错误:在 info_cb 里只返回了 OK,忘了填 header。结果 LVGL 分配了 0 字节的缓冲区,后面一写就崩。嗯,这种问题最难查。

3.3.2 open_cb:真正干活的

这个回调负责把图片数据解压成像素。原型:

lv_res_t (*open_cb)(struct _lv_img_decoder_t * decoder, struct _lv_img_decoder_dsc_t * dsc);

这里有个关键结构体 lv_img_decoder_dsc_t,它包含了:

  • src:图片源,可能是文件路径、指针或符号
  • header:图片信息(info_cb 里填的那些)
  • img_data:解码后的像素数据,你要把结果填到这里
  • time_to_open:解码耗时,用于性能统计

实际解码时,你通常要做这几步:

  1. 从 dsc->src 读取原始数据
  2. 调用对应的解码库(比如 libjpeg、lodepng)
  3. 把解压后的 RGB 数据写入 dsc->img_data
  4. 设置 dsc->header.cf 为正确的色彩格式

注意:img_data 指向的内存由 LVGL 管理,你只管写,不要自己 malloc。否则内存泄漏等着你。

3.3.3 close_cb:打扫战场

这个回调是可选的。如果你在 open_cb 里申请了额外资源(比如临时缓冲区、文件句柄),就在这里释放。

原型:

void (*close_cb)(struct _lv_img_decoder_t * decoder, struct _lv_img_decoder_dsc_t * dsc);

大部分情况下,你不需要实现它。但如果你用了外部解码库,比如 libjpeg 的 jpeg_create_decompress,那一定要在 close_cb 里调用 jpeg_destroy_decompress。否则每解码一张图,内存就涨一块。

3.4 一个完整的自定义解码器示例

说了这么多,不如直接看代码。下面是一个最简单的 BMP 解码器骨架:

static lv_res_t decoder_info(lv_img_decoder_t * decoder, const void * src, lv_img_header_t * header)
{
    /* 检查文件头是否为 "BM" */
    const uint8_t * data = (const uint8_t *)src;
    if (data[0] != 'B' || data[1] != 'M') return LV_RES_INV;

    /* 填写图片信息 */
    header->w = *(uint16_t *)&data[18];
    header->h = *(uint16_t *)&data[22];
    header->cf = LV_IMG_CF_TRUE_COLOR;
    return LV_RES_OK;
}

static lv_res_t decoder_open(lv_img_decoder_t * decoder, lv_img_decoder_dsc_t * dsc)
{
    /* 这里做真正的解码 */
    /* 从 dsc->src 读取像素数据 */
    /* 写入 dsc->img_data */
    return LV_RES_OK;
}

void my_decoder_init(void)
{
    lv_img_decoder_t * dec = lv_mem_alloc(sizeof(lv_img_decoder_t));
    lv_img_decoder_init(dec);
    dec->info_cb = decoder_info;
    dec->open_cb = decoder_open;
    lv_img_decoder_add(dec);
}

避坑指南:我曾经在 decoder_info 里直接读取文件系统,结果导致 LVGL 在绘制图片列表时频繁打开文件,性能暴跌。正确的做法是:info_cb 只做格式识别,不要做任何 I/O 操作。I/O 留给 open_cb。

3.5 调试日志分级方法

解码出问题时,怎么定位?我习惯把日志分成三级:

级别 宏定义 输出内容
错误 LV_LOG_ERROR 解码失败、内存不足、文件不存在
警告 LV_LOG_WARN 格式不匹配、降级处理、性能异常
信息 LV_LOG_INFO 解码器注册成功、图片尺寸、耗时

在 open_cb 里,我通常会加一行:

LV_LOG_INFO("Decode image: %dx%d, format: %d", 
            dsc->header.w, dsc->header.h, dsc->header.cf);

这样一旦花屏,我就能从日志里看到「解码器到底输出了什么尺寸和格式」,跟实际显示一对比,问题就清楚了。

嗯,解码器这块内容不少,但核心就三点:架构是策略模式、注册顺序决定优先级、回调函数各司其职。把这三点吃透了,外部存储加载图片的底子就算打牢了。