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 就流畅。我总结了几条实战技巧:
- 预热缓存:在界面切换动画开始前,提前把即将用到的字形解码进缓存。比如列表滑动前,预解码可见区域前后各 10 个字符。
- 分组缓存:把常用字符(数字、标点、英文字母)和非常用字符(生僻汉字)分开缓存。常用字符给大缓存,非常用字符给小块缓存甚至不缓存。
- 避免缓存污染:一次性加载大量不重复的文本(比如日志),会迅速填满缓存,把热点字形挤出去。这时候可以考虑临时禁用缓存,或者用 FIFO 策略。
- 监控命中率:TouchGFX 提供了
FontCache::getCacheHitRate()接口。我在项目中会定期打印这个值,如果低于 60%,就说明缓存策略或大小有问题。
我曾经踩过的坑:在某个智能家居面板项目中,我用了 LRU 缓存,但每次切换页面时,旧页面的字形把新页面的热点字形全挤出去了。后来我在页面切换时主动调用 FontCache::clear(),再预热新页面的常用字符,命中率从 40% 直接飙到 85%。
3.4 缓存策略选择流程图
下面这张图是我自己项目里总结的决策流程,帮你快速选对策略:
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% 就要排查原因。可能是缓存太小,也可能是策略不对。
- 避免缓存污染,一次性大量不重复文本会冲掉热点字形。必要时临时禁用缓存或切换策略。