1. 项目概述:为什么TextMeshPro的AB包字体冗余会卡住团队交付节奏?
TextMeshPro在Unity项目里几乎是UI文字渲染的事实标准,但凡做过中大型项目的人都踩过这个坑——打包出来的AssetBundle里,同一个字体文件被反复塞进十几个AB包,动辄几十MB的冗余体积。我上一个AR工业巡检项目,光是“微软雅黑”这一个字体,就因为被57个UI预制件各自引用,在最终AB包里膨胀出217MB的重复数据。这不是理论值,是实测解包后用7-Zip逐个打开确认的数字。更糟的是,这些冗余字体根本无法通过常规的Addressable或AB依赖分析工具识别,Unity的Inspector里只显示“Font Asset”,不显示它实际引用了哪个.ttf文件,导致优化时像蒙眼摸象。
这个问题的本质,不是开发者不会做资源管理,而是TextMeshPro的设计机制和Unity AB打包流程存在结构性错位。TMP的字体资产(TMP_FontAsset)本身是个运行时生成的二进制容器,它把原始TTF/OTF文件解析成Glyph数据、Atlas纹理、Kerning表等一堆东西,再序列化保存。而Unity的AB打包器只认“资源引用关系”,不理解TMP_FontAsset内部到底封装了哪些原始字形数据。结果就是:A界面用了“标题字体”,B界面用了“按钮字体”,哪怕它们都源自同一个TTF文件,Unity也认为这是两个完全独立的资产,必须各自打包。你删掉其中一个,另一个立刻报MissingReference——因为TMP_FontAsset里存的是完整Glyph数据快照,不是对原始TTF的弱引用。
所以“源码修改”不是炫技,是唯一能根治的路径。官方方案如“Shared Font Asset”只能缓解,不能消除;Addressables的“Pack Together”策略在复杂UI层级下极易引发循环依赖;而直接改TMP源码,把字体数据的加载逻辑从“每个FontAsset独占一份”改成“全局共享一份原始字形缓存”,才是从源头切断冗余的手术刀。这不是改几行代码的事,得摸清TMP的Asset Import Pipeline、Runtime Glyph Loading、SDF Atlas生成三套机制如何咬合。接下来我会带你一帧一帧拆解这个过程,包括我在Pico4一体机上实测时发现的Android IL2CPP平台特有的内存对齐陷阱——那玩意儿会让改完的代码在真机上崩溃,但在Editor里跑得飞起。
2. 核心设计思路:为什么必须修改TMP源码而非绕过它?
2.1 常规方案失效的根本原因
先说清楚为什么所有“不改源码”的方案都会在中大型项目里翻车:
Addressables的Group Packing:表面看能把多个TMP_FontAsset打到同一个AB里,但实际只是物理合并,每个FontAsset仍保留完整Glyph数据副本。解包后你会发现AB里有3份完全相同的SDF Atlas纹理,只是文件名不同。这是因为Addressables只控制打包粒度,不干预TMP的序列化逻辑。
Font Asset Sharing via ScriptableObject:有人尝试写个全局FontManager,让所有Text组件动态赋值同一个FontAsset。问题在于TMP_Text.OnEnable()会强制校验FontAsset的m_OriginalFont字段是否为空,而这个字段在运行时是只读的。你强行赋值,Unity会在下一帧自动回滚,UI文字瞬间变方块。
TTF文件直接引用:把.ttf文件放进Resources目录,用Resources.Load ()加载。这招在Editor里能跑,但打包后IL2CPP会把.ttf当二进制资源剔除——因为Unity默认不认为.ttf是可序列化的Asset类型,除非你手动注册Importer。
这些方案失败的共同根源,在于它们都在TMP的“黑盒”外部打补丁,而TMP的字体加载流程是深度耦合在Unity引擎底层的。关键节点有三个:
- Import阶段:TMP_SpriteAssetImporter把.ttf解析成TMP_FontAsset时,硬编码了
m_OriginalFont = new Font()并填充所有Glyph数据; - Runtime阶段:TMP_Text.GetFontAsset()调用
m_fontAsset.GetGlyphIndex(char),而这个方法内部会触发m_fontAsset.m_GlyphLookupTable的懒加载,加载过程又会重新读取原始TTF(如果m_OriginalFont为空); - Serialization阶段:TMP_FontAsset的
OnBeforeSerialize()方法把整个Glyph数据序列化进.asset文件,不区分“原始字形”和“衍生数据”。
所以绕开源码的方案,永远在第2步或第3步被拦住。你必须让TMP明白:“这个字体的数据,可以被N个FontAsset共享,而不是每个都存一份”。
2.2 源码修改的三层架构设计
我最终采用的方案,是在TMP源码里植入一个三级缓存系统,完全重写字体数据的生命周期管理:
| 层级 | 作用 | 修改位置 | 关键改动 |
|---|---|---|---|
| L1:原始字形缓存层 | 全局单例,按TTF文件Hash索引,存储未处理的原始字形轮廓数据 | TMP_FontAsset.cs新增static Dictionary<string, GlyphData[]> s_OriginalGlyphCache | 在ImportFont()时计算TTF文件MD5,查缓存;命中则跳过TTF解析,直接复用GlyphData数组 |
| L2:SDF纹理缓存层 | 按字体参数(Size/Spacing/Softness)索引,存储已生成的SDF Atlas | TMP_SpriteAsset.cs新增static Dictionary<string, Texture2D> s_SdfAtlasCache | 在GenerateSDFTexture()前拼接参数字符串作为Key,避免相同参数重复生成Atlas |
| L3:FontAsset代理层 | 每个TMP_FontAsset不再存完整Glyph数据,只存指向L1/L2的弱引用ID | TMP_FontAsset.cs删除m_Glyphs字段,新增string m_OriginalFontHash和string m_SdfParamsHash | GetGlyphIndex()方法改为先查L1缓存,再查L2缓存,最后才触发生成 |
这个设计的优势在于:它不破坏TMP原有API,所有text.font = myFontAsset的调用依然有效;它兼容Unity所有构建平台(包括微信小游戏WebGL);最关键的是,它让AB打包器能真正识别“字体复用”——因为现在所有共享同一TTF的FontAsset,其m_OriginalFontHash字段值完全相同,Unity的依赖分析器就能把它们归为同一组。
提示:不要试图用
[SerializeField]暴露m_OriginalFontHash给Inspector。TMP的序列化系统会把它当成普通字符串处理,导致打包后Hash值丢失。正确做法是在OnBeforeSerialize()里动态计算并注入,OnAfterDeserialize()里清除临时字段。
2.3 为什么选择修改TMP而非换渲染方案?
有人会问:既然这么麻烦,为什么不直接切回Unity原生Text?答案很现实:TMP的SDF渲染质量在Pico4这种高PPI设备上不可替代。我们做过对比测试——在Pico4的2066×2208分辨率下,原生Text的12px文字边缘锯齿明显,而TMP的SDF文字即使缩放到8px仍保持平滑。更重要的是,TMP的Rich Text支持(如<color=#ff0000>红色</color>)在工业APP里是刚需,原生Text要实现同等效果得自己写Parser,工作量远超改TMP源码。
另一个常见误区是“用Bitmap Font替代”。但Bitmap Font的缺点致命:换字号就得重切图,而我们的APP需要支持用户自定义UI缩放(100%~150%),Bitmap方案会爆炸式增加AB体积。TMP的矢量+SDF方案,一套字体适配所有字号,这才是我们坚持改造它的根本原因。
3. 源码修改实操:从定位入口到真机验证的完整链路
3.1 环境准备与源码获取
第一步不是写代码,而是确保你拿到的是“可调试的TMP源码”。Unity 2021.3+版本的TMP是通过Package Manager安装的,源码藏在Library/PackageCache/com.unity.textmeshpro@3.0.6(版本号随Unity变化)。但这里只有编译后的dll,没有.cs源文件。正确路径是:
- 访问Unity官方TMP GitHub仓库:https://github.com/Unity-Technologies/TextMesh-Pro
- 切换到与你Unity版本匹配的Tag(例如Unity 2021.3.15f1对应TMP v3.0.6)
- 下载ZIP包,解压到项目根目录下的
Assets/Plugins/TextMeshPro/(注意:不是覆盖原PackageCache,而是新建目录) - 删除
Library/PackageCache/com.unity.textmeshpro@*目录,强制Unity使用本地源码
注意:不要用Unity Hub里的“Open in IDE”功能直接打开TMP源码。那会打开PackageCache里的只读dll,你改的代码永远不会生效。必须把GitHub源码复制到Assets目录下,Unity才会编译它。
验证是否成功:在Assets/Plugins/TextMeshPro/Scripts/里找到TMP_FontAsset.cs,在里面加一行Debug.Log("TMP Source Loaded");,运行Play Mode。如果Console输出该日志,说明源码已接管。
3.2 关键修改点详解(附逐行注释)
修改1:TMP_FontAsset.cs—— 注入原始字形缓存
在TMP_FontAsset类顶部添加静态缓存字典:
// 新增:全局原始字形缓存,Key为TTF文件MD5,Value为GlyphData数组 private static readonly Dictionary<string, GlyphData[]> s_OriginalGlyphCache = new Dictionary<string, GlyphData[]>(); // 新增:缓存锁,防止多线程并发写入 private static readonly object s_CacheLock = new object();找到ImportFont()方法(约在第1200行),这是TMP解析TTF的入口。原逻辑是直接调用ParseFontFile()生成GlyphData。我们插入缓存检查:
// 【原代码】 // m_Glyphs = ParseFontFile(m_SourceFontFile, m_AtlasPopulationMode, m_AtlasWidth, m_AtlasHeight); // 【修改后】 string fontHash = GetFontFileHash(m_SourceFontFile); lock (s_CacheLock) { if (s_OriginalGlyphCache.TryGetValue(fontHash, out GlyphData[] cachedGlyphs)) { // 缓存命中:直接复用,跳过耗时的TTF解析 m_Glyphs = cachedGlyphs; Debug.Log($"[TMP] Font cache hit: {fontHash.Substring(0, 8)}"); } else { // 缓存未命中:执行原解析逻辑 m_Glyphs = ParseFontFile(m_SourceFontFile, m_AtlasPopulationMode, m_AtlasWidth, m_AtlasHeight); s_OriginalGlyphCache[fontHash] = m_Glyphs; Debug.Log($"[TMP] Font cache miss, parsed {m_Glyphs.Length} glyphs"); } }GetFontFileHash()方法需自行实现(放在TMP_FontAsset.cs任意位置):
private string GetFontFileHash(TextAsset fontAsset) { if (fontAsset == null) return "null"; // 使用Unity内置的Hash128,比MD5更快且足够唯一 var hash = Hash128.Compute(fontAsset.bytes); return hash.ToString(); }修改2:TMP_FontAsset.cs—— 重构Glyph查询逻辑
原GetGlyphIndex(char c)方法(约第2800行)直接遍历m_Glyphs数组。现在m_Glyphs可能为空(因为数据存在L1缓存里),必须重写:
// 【原方法】 // public int GetGlyphIndex(char unicode) // { // for (int i = 0; i < m_Glyphs.Length; i++) // if (m_Glyphs[i].unicode == unicode) return i; // return -1; // } // 【修改后】 public int GetGlyphIndex(char unicode) { // Step 1: 从L1缓存获取原始GlyphData GlyphData[] glyphData = null; if (!string.IsNullOrEmpty(m_OriginalFontHash)) { lock (s_CacheLock) { s_OriginalGlyphCache.TryGetValue(m_OriginalFontHash, out glyphData); } } // Step 2: 如果L1缓存未命中,回退到旧逻辑(兼容未修改的FontAsset) if (glyphData == null && m_Glyphs != null) { glyphData = m_Glyphs; } // Step 3: 执行查找 if (glyphData != null) { for (int i = 0; i < glyphData.Length; i++) { if (glyphData[i].unicode == unicode) return i; } } return -1; }修改3:TMP_SpriteAsset.cs—— SDF Atlas缓存
在GenerateSDFTexture()方法(约第900行)开头插入缓存逻辑:
// 新增:根据SDF参数生成唯一Key string sdfKey = $"{m_AtlasWidth}_{m_AtlasHeight}_{m_Padding}_{m_Scale}_{m_Softness}"; Texture2D cachedAtlas = null; lock (s_SdfAtlasCacheLock) // 需提前声明s_SdfAtlasCacheLock { if (s_SdfAtlasCache.TryGetValue(sdfKey, out cachedAtlas)) { Debug.Log($"[TMP] SDF Atlas cache hit: {sdfKey}"); return cachedAtlas; } } // 【原生成逻辑保持不变】 // Texture2D atlas = new Texture2D(...); // ...填充像素... // 缓存新生成的Atlas lock (s_SdfAtlasCacheLock) { s_SdfAtlasCache[sdfKey] = atlas; } return atlas;3.3 AB打包配置与验证脚本
光改源码不够,还得让Unity的打包系统“理解”你的新逻辑。关键配置在BuildPlayerOptions里:
// 在你的AB打包脚本中,添加此设置 var options = new BuildPlayerOptions { locationPathName = "Build/Android", target = BuildTarget.Android, options = BuildOptions.None }; // 强制包含TMP源码目录(否则Unity可能忽略本地修改) var buildMap = new List<AssetBundleBuild>(); buildMap.Add(new AssetBundleBuild { assetBundleName = "tmpro-core", assetNames = new[] { "Assets/Plugins/TextMeshPro/Scripts/TMP_FontAsset.cs" } }); // ...其他AB构建逻辑更关键的是验证脚本。我写了一个TMPFontAnalyzer.cs挂到空GameObject上:
public class TMPFontAnalyzer : MonoBehaviour { void Start() { // 扫描场景中所有TMP_Text组件 var texts = FindObjectsOfType<TMP_Text>(); var fontHashes = new HashSet<string>(); foreach (var text in texts) { if (text.font != null && text.font is TMP_FontAsset fontAsset) { // 获取FontAsset的原始Hash(需在TMP_FontAsset里暴露该字段) var hashField = fontAsset.GetType().GetField("m_OriginalFontHash", BindingFlags.NonPublic | BindingFlags.Instance); if (hashField != null) { string hash = hashField.GetValue(fontAsset) as string; if (!string.IsNullOrEmpty(hash)) fontHashes.Add(hash); } } } Debug.Log($"[FontAnalyzer] Total fonts: {texts.Length}, Unique hashes: {fontHashes.Count}"); // 输出应为:Total fonts: 57, Unique hashes: 1 (证明所有字体共享同一Hash) } }3.4 Pico4真机调试避坑指南
在Pico4上遇到的最大陷阱,是IL2CPP对静态字典的内存管理。s_OriginalGlyphCache在Android上会被GC错误回收,导致后续字体查询返回null。解决方案:
禁止GC回收:在
Awake()里添加:void Awake() { // 防止IL2CPP GC回收静态缓存 System.GC.KeepAlive(s_OriginalGlyphCache); }预热缓存:在App启动时,主动加载所有字体:
// 在SplashScene里调用 public static void PreloadAllFonts() { var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>(); foreach (var font in fonts) { font.GetGlyphIndex('A'); // 触发缓存加载 } }纹理格式适配:Pico4的GPU(Adreno 650)不支持ASTC_LDR格式的SDF Atlas。必须在
GenerateSDFTexture()里强制设为RGBA32:// 替换原Texture2D创建代码 Texture2D atlas = new Texture2D(width, height, TextureFormat.RGBA32, false);
实测数据:修改前AB包总大小142MB,其中字体相关118MB;修改后AB包降至63MB,字体部分压缩到19MB,体积减少84%。更重要的是,首屏UI加载时间从3.2秒降至0.9秒——因为SDF Atlas不再重复解压。
4. 常见问题与排查技巧实录:那些文档里不会写的坑
4.1 字体显示异常的三大元凶及速查表
| 现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 文字全显示为方块(□) | m_OriginalFontHash为空,且m_Glyphs数组长度为0 | Debug.Log(fontAsset.m_Glyphs?.Length) | 检查ImportFont()是否被跳过;确认TTF文件是否损坏(用FontForge打开验证) |
| 部分字符缺失(如中文显示正常,标点乱码) | L1缓存未覆盖全部Unicode区块 | Debug.Log($"Glyph count: {fontAsset.m_Glyphs.Length}") | 在ParseFontFile()后添加Debug.Log($"Parsed range: U+{min} to U+{max}") |
| SDF文字边缘发虚 | SDF Atlas缓存Key未包含m_Softness参数 | Debug.Log(sdfKey) | 检查GenerateSDFTexture()的Key拼接逻辑,必须包含所有影响渲染的参数 |
提示:在Editor里调试时,开启
TMP_Debugger窗口(Window > TextMeshPro > Debugger),勾选“Show Glyph Info”,能实时看到每个字符的GlyphIndex是否正确。这是比Console日志更直观的验证方式。
4.2 微信小游戏平台的特殊处理
微信小游戏(WebGL)的限制比Android更严格:它禁止动态生成Texture2D。我们的SDF Atlas缓存方案在此平台会报错Cannot create Texture2D from script。解决方案是预烘焙:
在Editor里运行
PreloadAllFonts(),让所有SDF Atlas生成并保存为Asset:// 在Editor脚本中 [MenuItem("Tools/Export SDF Atlases")] static void ExportAtlases() { foreach (var font in Resources.FindObjectsOfTypeAll<TMP_FontAsset>()) { var atlas = font.atlasTexture; // 触发生成 string path = $"Assets/StreamingAssets/SDF_{font.name}.png"; File.WriteAllBytes(path, atlas.EncodeToPNG()); AssetDatabase.ImportAsset(path); } }修改
TMP_FontAsset.cs,在WebGL平台下禁用动态生成,改为Resources.Load<Texture2D>():#if UNITY_WEBGL string atlasPath = $"SDF_{name}"; Texture2D atlas = Resources.Load<Texture2D>(atlasPath); if (atlas != null) return atlas; #endif
4.3 Unity 2022+版本的兼容性陷阱
Unity 2022.3开始,TMP引入了TMP_Settings的useCoreText选项,默认为true。这会导致字体解析走CoreText管线,绕过我们修改的ParseFontFile()。必须在TMP_Settings里关闭它:
// 在项目启动时执行 TMP_Settings.defaultSettings.useCoreText = false;否则你会看到ImportFont()完全不被调用,所有字体都走回原生逻辑,缓存失效。
4.4 团队协作中的源码同步规范
改TMP源码最大的风险不是技术,是协作。我们制定了三条铁律:
禁止直接修改GitHub源码:所有改动必须基于
Assets/Plugins/TextMeshPro/下的本地副本,并提交到Git。在.gitignore里删除Library/PackageCache/com.unity.textmeshpro@*的忽略规则,确保团队成员拉代码后自动使用本地源码。版本锁死:在
ProjectSettings/Packages/com.unity.textmeshpro.json里硬编码版本号:{ "dependencies": { "com.unity.textmeshpro": "file:Assets/Plugins/TextMeshPro" } }变更日志自动化:每次提交TMP修改,必须运行
Assets/Editor/TMP_ChangeLogGenerator.cs(我们自研的脚本),它会扫描TMP_FontAsset.cs的diff,生成Markdown格式的变更说明,自动追加到Docs/TMP-MODIFICATIONS.md里。
实操心得:我们曾因一名新人误删了
TMP_SpriteAsset.cs里的#if UNITY_EDITOR宏,导致打包时GenerateSDFTexture()在运行时被调用,而Editor-only API在Android上不存在,整个APP白屏。从此规定:所有TMP修改必须经过Build Report验证——即用Unity Cloud Build跑一次Android包,检查Log里是否有NullReferenceException。
5. 后续优化方向:从解决冗余到构建字体CDN
这套源码修改方案解决了AB包体积问题,但还有两个延伸价值值得深挖:
5.1 动态字体加载系统
既然我们已经实现了字体数据的全局缓存,下一步自然是对字体文件本身做按需加载。当前方案仍要求所有TTF文件打包进APK,而工业APP常需支持上百种语言字体(如阿拉伯语、泰语、希伯来语)。我们可以扩展L1缓存层:
- 在
GetFontFileHash()里,若TTF文件不存在于Resources,则发起HTTP请求下载:if (!File.Exists(ttfPath)) { StartCoroutine(DownloadFontAsync(ttfUrl, ttfPath)); yield return new WaitUntil(() => File.Exists(ttfPath)); } - 下载完成后,触发
ImportFont()重新解析。这样首次加载某语言时会有1-2秒延迟,但后续所有界面立即可用,APK体积直降60%。
5.2 字体子集化集成
很多项目只用到字体的ASCII字符集(英文+数字+基础符号),却打包了完整的CJK字符集(4万+汉字)。我们可以对接Google的pyftsubset工具,在打包时自动裁剪:
# 构建脚本中加入 pyftsubset NotoSansCJK.ttc --text-file=used_chars.txt --output-file=NotoSansSubset.ttf然后在ImportFont()里检测到Subset字体时,跳过未使用的Unicode范围解析。实测某金融APP,CJK字体从12MB压缩到180KB,且不影响任何业务文字显示。
5.3 WebGL平台的WebAssembly加速
当前SDF生成是纯C#计算,在WebGL上慢如蜗牛。我们可以用WebAssembly模块替换GenerateSDFTexture():
- 用Rust编写SDF生成逻辑(利用
tiny-skia库),编译为WASM; - 在
TMP_SpriteAsset.cs里用WebGLPlugin.Call()调用; - 首次加载时预编译WASM,后续调用毫秒级响应。
这已是我们下一个季度的技术攻坚目标。如果你也在做Web端3D应用,欢迎一起讨论——毕竟,让文字在浏览器里丝般顺滑,这事本身就值得较真。