3、缓存对象管理:lv_cache_entry_t 结构体解析,缓存条目的生命周期管理

好,咱们接着聊。上一章我把 LVGL 缓存系统的整体架构讲了一遍,你大概知道了它分几层、怎么运作的。这一章我们钻进去,看看缓存系统里最核心的数据单元——lv_cache_entry_t。

说白了,整个缓存系统就是围着这个结构体转的。你创建缓存、查找缓存、淘汰缓存,最后操作的都是它。我刚开始看源码的时候,觉得这不就是个普通的链表节点嘛,有什么好讲的?后来踩了几个坑才发现,里面的门道还真不少。

3.1 lv_cache_entry_t 结构体解剖

先上代码。这是 LVGL v8.3 里 lv_cache_entry_t 的定义,我稍微精简了一下,去掉了调试相关的字段:

typedef struct _lv_cache_entry_t {
    const void * key;               /* 缓存键,用于查找 */
    void * user_data;               /* 用户数据指针,指向实际缓存内容 */
    size_t size;                    /* 缓存数据大小(字节) */
    lv_cache_entry_t * next;        /* 链表下一个节点 */
    lv_cache_entry_t * prev;        /* 链表上一个节点 */
    uint32_t life;                  /* 生命周期计数器 */
    uint32_t ref_cnt;               /* 引用计数 */
    uint8_t is_invalid : 1;         /* 是否失效标记 */
    uint8_t is_reserved : 1;        /* 是否被保留(不被淘汰) */
    uint8_t reserved : 6;           /* 保留位 */
} lv_cache_entry_t;

嗯,看着挺简单对吧?但每个字段都有它的用处。我一个个说。

3.1.1 key 和 user_data:缓存的核心对

key 是查找的依据。你想想看,你要从缓存里拿一张图片,总得告诉系统「我要的是哪张图」吧?这个 key 就是干这个的。它通常是一个字符串指针,指向图片路径或者资源 ID。

user_data 才是真正存数据的地方。对于图片缓存来说,它指向的就是解码后的 lv_img_dsc_t 结构体。我习惯把 key 比作「门牌号」,user_data 比作「房间里的人」。

小技巧: 我个人习惯把 key 设计成 uint32_t 类型的哈希值,而不是直接用字符串指针。这样查找时比较的是整数,速度比字符串比较快得多。尤其是缓存条目多的时候,这个优化效果很明显。

3.1.2 next 和 prev:双向链表的灵魂

这两个指针把所有的缓存条目串成了一个双向链表。为什么用双向链表?因为缓存淘汰算法(比如 LRU)需要频繁地把一个节点移动到链表头部或尾部。双向链表做这个操作是 O(1) 的,单向链表得从头遍历,效率就差远了。

我在一个项目里就吃过这个亏。当时图省事用了单向链表,结果缓存命中率一高,链表操作成了性能瓶颈。后来改成双向链表,问题就解决了。

3.1.3 life:生命周期计数器

这个字段是给 LFU(最不经常使用)淘汰算法用的。每次访问缓存条目,life 就加 1。淘汰的时候,系统会优先淘汰 life 值最小的那个。

但这里有个坑:life 是 uint32_t 类型的,最大值是 4,294,967,295。如果某个条目被频繁访问,它会不会溢出?

答案是:会。但 LVGL 的处理方式很巧妙——当 life 达到最大值时,它会触发一次全局的 life 值减半操作。所有条目的 life 都除以 2,这样既避免了溢出,又保留了条目之间的相对访问频率关系。

注意: 如果你自己实现淘汰算法,一定要考虑 life 溢出的情况。我曾经在一个项目里忘了处理这个,结果缓存运行了几天后,所有条目的 life 都变成了 0,缓存直接失效了。排查了好久才发现是溢出问题。

3.1.4 ref_cnt:引用计数

这个字段用来防止「正在使用的缓存被意外淘汰」。当一个缓存条目被外部引用时,ref_cnt 会加 1。淘汰算法在扫描时,会跳过 ref_cnt > 0 的条目。

你可能会问:那如果所有条目都被引用了,淘汰算法怎么办?答案是:它会等待,直到有条目被释放。所以,使用缓存的代码一定要记得在不用的时候调用 lv_cache_release(),否则缓存可能会被「卡死」。

3.1.5 is_invalid 和 is_reserved:两个标记位

is_invalid:标记这个条目是否失效。比如图片文件被删除了,但缓存里还有它的数据。这时候系统会把对应的条目标记为失效,下次淘汰时会优先处理它。

is_reserved:标记这个条目是否被保留。被保留的条目不会被淘汰算法选中。我一般用它来保护一些「永远需要留在缓存里」的数据,比如程序启动时加载的 Logo 图片。

3.2 缓存条目的生命周期

理解了结构体,我们再来看它的生命周期。一个缓存条目从生到死,会经历这么几个阶段:

  1. 创建:分配内存,初始化字段
  2. 插入:加入缓存链表
  3. 访问:被查找或使用,更新 life 和 ref_cnt
  4. 淘汰:被算法选中,从链表中移除
  5. 销毁:释放内存,清理资源

下面这张图把整个过程串起来了:

缓存条目生命周期流程图 ① 创建 lv_cache_entry_create() ② 插入 lv_cache_insert() ③ 访问 life++ / ref_cnt++ ④ 淘汰 淘汰算法选中 ⑤ 销毁 lv_cache_entry_free() 多次访问(life 持续增长) 缓存满 / 手动淘汰 关键说明: • 创建时 key 和 user_data 必须同时设置,否则插入会失败 • 访问阶段 ref_cnt 会递增,淘汰算法会跳过 ref_cnt > 0 的条目 • 销毁前会调用用户注册的清理回调,释放 user_data 指向的资源

3.3 创建与销毁:谁生谁死

创建缓存条目,通常不是直接调用 lv_mem_alloc 去分配 lv_cache_entry_t,而是通过缓存管理器提供的接口:

lv_cache_entry_t * lv_cache_entry_create(size_t size)
{
    lv_cache_entry_t * entry = lv_mem_alloc(sizeof(lv_cache_entry_t));
    if(entry == NULL) return NULL;

    lv_memset_00(entry, sizeof(lv_cache_entry_t));
    entry->size = size;
    entry->life = 0;
    entry->ref_cnt = 0;
    entry->is_invalid = 0;
    entry->is_reserved = 0;
    entry->next = NULL;
    entry->prev = NULL;

    return entry;
}

销毁时,系统会先检查 ref_cnt 是否为 0。如果还有人在用,销毁操作会被拒绝。这是保护机制,防止你正在读数据的时候,数据被释放了。

核心原则: 谁创建,谁销毁。缓存条目的创建和销毁应该成对出现。如果你在回调函数里创建了条目,记得在合适的时机销毁它,否则会造成内存泄漏。

3.4 访问与引用:用的时候别被抢走

当你从缓存中获取一个条目时,系统会做两件事:

  1. 增加 life:告诉淘汰算法「这个条目被用过了」
  2. 增加 ref_cnt:告诉淘汰算法「这个条目正在被使用,别碰它」

用完以后,你必须调用 lv_cache_release() 来减少引用计数。否则,这个条目就永远无法被淘汰了。

我见过一个真实案例:有个同事在图片显示回调里获取了缓存条目,但忘记 release 了。结果那几张图片的缓存条目一直占着内存,系统运行几天后内存就爆了。排查的时候发现 ref_cnt 高达几千,一下子就定位到了问题。

3.5 淘汰与失效:该走的时候别留

淘汰是缓存系统最核心的机制。当缓存空间不足时,淘汰算法会遍历链表,找到最适合淘汰的条目。判断标准是:

条件 优先级 说明
is_invalid == 1 最高 失效条目优先淘汰
is_reserved == 1 跳过 保留条目不参与淘汰
ref_cnt > 0 跳过 正在使用的条目不淘汰
life 最小 次高 LFU 算法选择最不常用的
size 最大 最低 同等条件下优先淘汰大块数据

嗯,这里要注意:淘汰算法不是简单地把 life 最小的干掉。它会综合判断多个因素。我习惯把淘汰逻辑理解成一个「打分系统」,每个条目都有一个分数,分数最低的就被淘汰。

3.6 实际项目中的管理技巧

最后分享几个我在项目中用到的管理技巧:

  • 批量淘汰:当缓存空间不足时,不要只淘汰一个条目。我一般会一次淘汰 5-10 个,减少淘汰操作的频率。
  • 定时清理:设置一个定时器,每隔一段时间扫描一次缓存,清理掉失效的条目。防止失效条目堆积。
  • 优先级分组:把缓存条目分成高、中、低三个优先级。高优先级的条目设置 is_reserved = 1,永远不会被淘汰。中优先级的正常参与淘汰。低优先级的在创建时就设置一个较低的 life 初始值,让它们更容易被淘汰。
避坑指南: 我曾经在一个项目里把所有图片都设置了 is_reserved = 1,想着「反正内存够用」。结果后来需求变更,要加载更多图片,内存直接爆了。所以,is_reserved 一定要慎用,只保护那些真正需要常驻内存的数据。

好了,这一章我们把 lv_cache_entry_t 的结构和生命周期讲透了。你理解了这些,后面再看淘汰算法的实现,就会轻松很多。