3. 字体缓存机制:缓存策略、大小与命中率优化

字体缓存,说白了就是一块内存区域。TouchGFX 把解码后的字形临时存进去,下次再用就不用重新解码了。你想想看,如果每个字符都要从字库文件里解压出来,那性能得多糟糕?

我刚开始接触 TouchGFX 时,觉得缓存嘛,不就是开一块内存存东西吗?后来被项目狠狠教育了一顿。嗯,这里面的门道其实不少。

3.1 两种主流缓存策略:LRU 与 FIFO

TouchGFX 的字体缓存核心就两个策略:LRU(最近最少使用)和FIFO(先进先出)。它们决定了缓存满了之后,该踢谁出去。

策略 核心逻辑 适用场景 我踩过的坑
LRU 淘汰最长时间没被访问的 局部性强的 UI(菜单、列表) 翻页时旧页面字形被优先淘汰
FIFO 淘汰最早进入缓存的 顺序滚动、日志显示 循环滚动时热点字形被意外踢出

LRU 的工作原理:每次访问一个字形,就把它移到链表头部。缓存满了,从尾部淘汰。这很符合直觉——你最近用过的,大概率马上还会用。

FIFO 的工作原理:维护一个队列,新来的排后面,满了就踢队头的。简单粗暴,开销小。但有个致命问题:一个高频使用的字形,如果它排在队头,照样会被踢出去。

我的建议:绝大多数 UI 场景,无脑选 LRU。FIFO 只在你有明确顺序访问模式时才考虑,比如逐行扫描的文本阅读器。

3.2 缓存大小对性能的影响

缓存大小不是越大越好,也不是越小越好。这里有个平衡点。

我记得有个项目,客户要求 UI 丝滑流畅,我直接把缓存开到 128KB。结果呢?内存占用飙升,系统频繁触发内存整理,反而卡顿了。后来我缩到 32KB,配合 LRU,效果反而更好。

为什么?因为缓存太大,LRU 的链表遍历开销也大。而且嵌入式系统的内存带宽有限,大缓存意味着更多的 cache miss 和内存碎片。

缓存大小 命中率 内存开销 性能表现
过小(< 4KB) 低(< 30%) 低 频繁解码,CPU 飙升
适中(16KB ~ 64KB) 高(70% ~ 90%) 可接受 流畅,解码次数大幅减少
过大(> 128KB) 极高(> 95%) 高 管理开销增加,边际收益递减

经验公式:缓存大小 ≈ 一屏最多显示的字符数 × 平均字形大小 × 1.5。比如一屏显示 200 个字符,平均字形 80 字节,那缓存开 200 × 80 × 1.5 ≈ 24KB 就够用了。

3.3 缓存命中率优化技巧

命中率是缓存的核心指标。命中率高,解码次数少,UI 就流畅。我总结了几条实战技巧:

  1. 预热缓存:在界面切换动画开始前,提前把即将用到的字形解码进缓存。比如列表滑动前,预解码可见区域前后各 10 个字符。
  2. 分组缓存:把常用字符(数字、标点、英文字母)和非常用字符(生僻汉字)分开缓存。常用字符给大缓存,非常用字符给小块缓存甚至不缓存。
  3. 避免缓存污染:一次性加载大量不重复的文本(比如日志),会迅速填满缓存,把热点字形挤出去。这时候可以考虑临时禁用缓存,或者用 FIFO 策略。
  4. 监控命中率:TouchGFX 提供了 FontCache::getCacheHitRate() 接口。我在项目中会定期打印这个值,如果低于 60%,就说明缓存策略或大小有问题。

我曾经踩过的坑:在某个智能家居面板项目中,我用了 LRU 缓存,但每次切换页面时,旧页面的字形把新页面的热点字形全挤出去了。后来我在页面切换时主动调用 FontCache::clear(),再预热新页面的常用字符,命中率从 40% 直接飙到 85%。

3.4 缓存策略选择流程图

下面这张图是我自己项目里总结的决策流程,帮你快速选对策略:

字体缓存策略选择流程图 开始选择缓存策略 UI 访问模式是否有局部性? 是 否 推荐 LRU 策略 推荐 FIFO 策略 缓存大小是否适中? 是否顺序访问? 是 否 是 否 LRU + 预热 调整缓存大小 FIFO + 大缓存 改用 LRU 💡 核心原则 局部性强 → LRU;顺序访问 → FIFO;都不确定 → 先试 LRU

3.5 实战:配置 TouchGFX 字体缓存

在 TouchGFX 中配置缓存其实不复杂。我一般直接在 TouchGFXConfiguration.cpp 里动手:

// 在应用程序初始化时配置字体缓存
void setupFontCache()
{
    // 1. 分配缓存内存(建议放在外部 SRAM 或 SDRAM)
    const uint32_t CACHE_SIZE = 32 * 1024; // 32KB
    static uint8_t cacheBuffer[CACHE_SIZE];

    // 2. 初始化 LRU 缓存
    FontCache::setCacheStrategy(FontCache::LRU);
    FontCache::setCacheBuffer(cacheBuffer, CACHE_SIZE);

    // 3. 预热常用字符(数字 + 英文字母 + 常用标点)
    const Unicode::UnicodeChar preloadChars[] = {
        '0','1','2','3','4','5','6','7','8','9',
        'A','B','C','D','E','F','G','H','I','J',
        'K','L','M','N','O','P','Q','R','S','T',
        'U','V','W','X','Y','Z',
        'a','b','c','d','e','f','g','h','i','j',
        'k','l','m','n','o','p','q','r','s','t',
        'u','v','w','x','y','z',
        '.',',','!','?',' ','\n','\0'
    };

    for (int i = 0; preloadChars[i] != '\0'; i++) {
        FontCache::preloadChar(preloadChars[i]);
    }

    // 4. 开启命中率监控(调试阶段使用)
    #ifdef DEBUG
    FontCache::enableHitRateMonitor(true);
    #endif
}

小技巧:预热时不要一次性加载太多字符,否则预热本身就会造成卡顿。我习惯在启动动画或 splash 页面期间分批预热,每帧预热 10~20 个字符,分散压力。

3.6 缓存命中率监控与调优

光配置不行,你得看效果。我个人习惯在 UI 的调试信息里加一个实时命中率显示:

// 在屏幕的 tick 事件中打印缓存状态
void MyScreen::handleTickEvent()
{
    static int tickCount = 0;
    tickCount++;

    // 每 100 帧打印一次
    if (tickCount % 100 == 0) {
        float hitRate = FontCache::getCacheHitRate();
        uint32_t usedSize = FontCache::getUsedCacheSize();
        uint32_t totalSize = FontCache::getTotalCacheSize();

        // 输出到调试串口
        printf("[FontCache] HitRate: %.1f%%, Used: %d/%d bytes\n",
               hitRate * 100.0f, usedSize, totalSize);

        // 如果命中率低于 60%,自动调整缓存大小
        if (hitRate < 0.6f) {
            uint32_t newSize = totalSize * 2;
            if (newSize <= MAX_CACHE_SIZE) {
                FontCache::resize(newSize);
                printf("[FontCache] Resized to %d bytes\n", newSize);
            }
        }
    }
}

嗯,这里要注意:自动调整缓存大小是个双刃剑。频繁 resize 会导致内存碎片。我一般只在调试阶段用自动调整,找到合适的值后,就硬编码固定下来。

我曾经踩过的坑:在某个产品中,我开了自动调整缓存,结果用户快速滑动列表时,缓存大小在 16KB 和 64KB 之间反复横跳,每次 resize 都触发内存拷贝,导致界面明显卡顿。后来我改成固定 32KB + LRU + 预热,问题解决。

3.7 总结一下核心要点

  • LRU 是默认首选,适合绝大多数 UI 场景。FIFO 只在顺序访问模式时考虑。
  • 缓存大小不是越大越好。16KB ~ 64KB 是黄金区间,具体根据一屏字符数计算。
  • 预热缓存能显著提升首次进入界面的流畅度。分批预热,别一股脑全塞进去。
  • 监控命中率,低于 60% 就要排查原因。可能是缓存太小,也可能是策略不对。
  • 避免缓存污染,一次性大量不重复文本会冲掉热点字形。必要时临时禁用缓存或切换策略。