3、字体资源优化:字体文件结构分析、子集化技术、动态字体加载与缓存
字体资源,在 TouchGFX 项目里往往是最容易被忽视的“内存杀手”。我见过不少团队,UI 做得很漂亮,一编译发现字体占了 2MB 以上的 Flash,然后开始疯狂砍功能。其实问题不在字体本身,而在你根本不知道字体文件里装了什么。
这一章,我们就来把字体资源彻底拆开看看。我会从文件结构讲起,然后带你做子集化,最后聊动态加载和缓存。嗯,这三板斧用好了,字体占用的资源至少能砍掉 60%。
3.1 字体文件结构:你用的不是“一个字”,是一堆字
TouchGFX 使用的字体文件,本质上是一个字形索引表。它不像 PC 上的 TTF 那样包含完整的矢量轮廓,而是把每个字符的像素数据打包好,按顺序排列。
一个典型的 TouchGFX 字体文件(.bin 或 .ttf 转换后)包含这几部分:
| 结构段 | 说明 | 占用比例(典型值) |
|---|---|---|
| 字体头信息 | 字号、行高、基线偏移、字符总数 | 约 0.1% |
| 字形索引表 | 每个字符的编码 → 数据偏移量映射 | 约 5% |
| 字形像素数据 | 每个字符的位图或抗锯齿灰度数据 | 约 85% |
| Kerning 表 | 字符间距微调信息(可选) | 约 10% |
看到没?字形像素数据占了绝对大头。你想想看,一个 24px 的汉字,在 4 级抗锯齿下,每个字大约需要 24×24×2 = 1152 字节。如果你的界面用了 3000 个汉字,光这部分就是 3.3MB。这还没算英文字母和数字。
核心结论:字体优化的本质,就是减少“需要存储的字形数量”和“每个字形的数据量”。
3.2 子集化技术:只带你要用的字
子集化,说白了就是从完整的字体文件里,只提取你界面真正用到的字符。我在项目中遇到过最夸张的情况:客户用了一款思源黑体,完整 TTF 有 15MB,但界面实际只用到了 200 多个汉字和 50 个标点符号。子集化之后,字体文件从 15MB 降到了 180KB。
怎么做?我推荐两种方式:
3.2.1 手动指定字符范围
在 TouchGFX Designer 的字体设置里,你可以直接输入需要的字符。比如你的界面只有“开始、停止、设置、确认、取消”这几个词,那就只输入这些字。
// 在 .ttf 转 .bin 时,通过 --glyph-range 参数指定
// 只包含:开始停止设置确认取消0123456789:.
fontconverter --font=SourceHanSansSC-Regular.otf \
--output=my_font.bin \
--size=24 \
--glyph-range=0x4E00-0x4E00,0x59CB,0x505C,0x6B62,0x8BBE,0x7F6E,0x786E,0x8BA4,0x53D6,0x6D88 \
--glyph-range=0x30-0x39,0x3A,0x2E
这种方式最精确,但需要你提前整理好所有用到的字符。我个人的习惯是:在项目初期就建一个 used_chars.txt 文件,每次新增文本就往里加,最后统一生成。
3.2.2 自动扫描文本资源
如果你的项目已经写好了所有界面文本,可以用脚本自动扫描。我写过一个 Python 小工具,遍历所有 Texts.xml 和 *.cpp 文件,提取所有中文字符和符号,然后生成子集化命令。
# 伪代码示意
import re, os
def extract_chars(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
text = f.read()
# 提取所有中文字符和常用符号
chars = set(re.findall(r'[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef0-9a-zA-Z]', text))
return chars
# 合并所有文件中的字符
all_chars = set()
for root, dirs, files in os.walk('./src'):
for f in files:
if f.endswith('.cpp') or f.endswith('.hpp'):
all_chars |= extract_chars(os.path.join(root, f))
# 输出为 glyph-range 参数
ranges = sorted([hex(ord(c)) for c in all_chars])
print('--glyph-range=' + ','.join(ranges))
小技巧:子集化时,别忘了把空格、换行符、冒号、句号这些标点也加进去。我曾经漏了“:”符号,结果界面上所有时间显示都变成了“12 30”,排查了半天。
3.3 动态字体加载:用的时候再读
子集化能解决“字太多”的问题,但有些场景下,你不得不支持大量字符——比如一个多语言界面,需要同时显示中文、日文、韩文、阿拉伯文。全部子集化到同一个文件里,体积还是会很大。
这时候就需要动态字体加载了。说白了,就是按需加载:界面显示哪个语言,就加载哪个语言的字体文件。
3.3.1 实现思路
- 把每种语言的字体做成独立的 .bin 文件(比如
font_zh.bin、font_ja.bin、font_en.bin) - 把这些文件存储在外部 Flash 或文件系统中
- 在 TouchGFX 中,通过
FontManager动态切换字体
// 动态加载字体示例
#include <touchgfx/FontManager.hpp>
// 假设字体文件存储在外部 Flash 的 0x90000000 地址
void switchLanguage(const char* lang) {
if (strcmp(lang, "zh") == 0) {
// 加载中文字体
FontManager::getInstance().setFont(
TypedText::getTypedText(T_MAIN_FONT),
FontManager::loadFontFromAddress(0x90000000)
);
} else if (strcmp(lang, "en") == 0) {
// 加载英文字体
FontManager::getInstance().setFont(
TypedText::getTypedText(T_MAIN_FONT),
FontManager::loadFontFromAddress(0x90020000)
);
}
// 刷新界面
application().invalidate();
}
注意:动态加载时,要确保目标地址的字体数据是按 4 字节对齐存储的。TouchGFX 内部使用 32 位指针访问字体数据,不对齐会导致 HardFault。我曾经在这个坑里摔过一次,排查了整整两天。
3.4 字体缓存:别反复加载同一份数据
动态加载虽然省了 Flash,但如果频繁切换语言,每次都从外部 Flash 读数据,速度会很慢。这时候就需要缓存了。
TouchGFX 的字体缓存机制其实很简单:把最近用过的字体数据放在 RAM 里,下次再用时直接读 RAM,不用再去 Flash 读。
3.4.1 缓存策略对比
| 策略 | 实现方式 | RAM 占用 | 切换速度 | 适用场景 |
|---|---|---|---|---|
| 无缓存 | 每次从 Flash 读取 | 0 | 慢(约 50ms) | 极少切换语言 |
| 全量缓存 | 所有字体常驻 RAM | 大(几百 KB) | 极快(<1ms) | RAM 充裕的设备 |
| LRU 缓存 | 保留最近使用的 2-3 种字体 | 中等(几十 KB) | 快(<5ms) | 多语言频繁切换 |
我个人比较推荐 LRU 缓存。你想想看,用户不可能在 1 秒内切换 5 次语言,保留最近 2-3 种字体就足够了。实现起来也不复杂:
// 简单的 LRU 字体缓存实现
class FontCache {
struct CacheEntry {
uint32_t address;
const touchgfx::Font* font;
uint32_t lastAccess;
};
static const int MAX_ENTRIES = 3;
CacheEntry entries[MAX_ENTRIES];
uint32_t accessCounter;
public:
const touchgfx::Font* getFont(uint32_t address) {
// 查找缓存
for (int i = 0; i < MAX_ENTRIES; i++) {
if (entries[i].address == address) {
entries[i].lastAccess = ++accessCounter;
return entries[i].font;
}
}
// 未命中,加载并替换最久未使用的
int oldest = 0;
for (int i = 1; i < MAX_ENTRIES; i++) {
if (entries[i].lastAccess < entries[oldest].lastAccess) {
oldest = i;
}
}
entries[oldest].address = address;
entries[oldest].font = FontManager::loadFontFromAddress(address);
entries[oldest].lastAccess = ++accessCounter;
return entries[oldest].font;
}
};
3.5 本章知识体系总览
下面这张图,把字体优化的三个核心环节串起来了。你可以把它当作一个检查清单:做字体优化时,按这个流程走一遍,基本不会漏掉什么。
3.6 避坑指南:我踩过的几个坑
做字体优化这么多年,有些坑我是一路踩过来的。分享几个典型的,你遇到了能少走弯路:
- 子集化后某些字符显示为方框——这通常是因为你漏掉了那个字符。我建议在子集化完成后,用 TouchGFX 的
FontCache工具把所有字符渲染一遍,检查是否有缺失。 - 动态加载时界面闪烁——这是因为字体切换后,所有文本控件都重新渲染了。解决办法是在切换语言时,先隐藏界面,加载完字体再显示。用
application().setTransition(0)可以做到无闪烁切换。 - 缓存命中率低——如果你发现 LRU 缓存总是 miss,检查一下是不是每次切换都用了不同的字体指针地址。有时候同一个字体文件,从不同地址加载,TouchGFX 会认为是两个不同的字体。
最后说一句:字体优化不是一锤子买卖。项目开发过程中,界面文本会不断增删,字体文件也要跟着更新。我个人的习惯是:每次发布前,都跑一遍字符扫描脚本,重新生成子集化字体。这样既能保证字体最小,又不会漏掉新加的字符。