1. 为什么TMP字体AssetBundle会拖垮你的UI
1.1 从一次线上包体告警说起
做Unity客户端的朋友应该都遇到过这种场景:UI界面卡顿,包体越打越大,用户反馈加载时间变长。我们团队在Unity 2022 LTS版本上做了一款多语言工具类App,功能不复杂,但安装包愣是冲到了200MB以上。排查到最后,元凶之一就是TextMeshPro字体资源被粗暴地打进了多个AssetBundle里。
TextMeshPro(以下简称TMP)现在是Unity UI文本的首选方案,它通过SDF(Signed Distance Field,有向距离场)技术渲染文字,放大缩小都清晰。但正因为SDF,每个字符都要生成对应的纹理区域,一套中文字体动辄几千个常用字,图集尺寸和资源体量会迅速膨胀。如果团队一开始为了省事,把主要字体做成全局静态字体Asset,一股脑丢进公共Bundle,那后续每一次界面改动,都可能把整套字体重复带入不同Bundle中。
这篇内容就是基于我们项目在Unity 2022上的实测整理出来的。适合正在做UI性能优化、包体治理,或者刚接触TMP和AssetBundle的开发者。我会把拆包思路、构建配置、运行时加载代码、以及我们踩过的坑全部写出来,看完可以直接抄作业。
1.2 TMP字体资源膨胀的底层原因
要解决问题,先得搞清楚TMP的字体资源到底由什么构成。以Unity 2022的TMP 3.x为例,字体Asset主要有以下几部分:
| 资源构成 | 作用 | 体积特征 |
|---|---|---|
| Font Asset(.asset) | 描述字体文件、字形索引、图集引用等 | 通常几十KB到几百KB |
| Atlas Texture(图集纹理) | 保存实际SDF字形数据 | 最占空间,常见1024x1024或2048x2048 |
| Material(材质) | 控制SDF渲染效果、描边、阴影等 | 几KB,但每个变体可能产生新材质 |
| Fallback Font Assets(回退字体链) | 支持生僻字或多语言 | 会额外引入更多图集 |
很多人以为优化字体就是改大图集、把字符压进一张图里。实际上,TMP图集一旦达到2048x2048,单个Atlas就能占掉16MB显存和包体空间。如果做了多套语言、多套字体、多字号变体,每个组合都是独立Font Asset,那资源量会成倍增加。
更麻烦的是AssetBundle的依赖机制。如果你把UI预制体打进Bundle时,渲染组件上引用了TMP字体,Unity构建系统会自动把该字体及其依赖的材质、纹理一并打进去,哪怕你已经单独打了一个公共字体Bundle。这就是常见的“重复打包”问题,也是包体告警的核心来源。
1.3 优化目标与适用场景界定
我们的优化目标很明确:不改变UI视觉效果的前提下,将首包体积降下来,同时减少运行时字体资源的重复加载和内存占用。具体指标有三个方向:
- 首包AssetBundle体积:删除全局字体Bundle中冗余字符和重复引用。
- 运行时内存:避免同一个字体图集被多次加载到内存。
- UI切换流畅度:减少字体加载导致的掉帧和卡顿。
但需要说明的是,并非所有项目都适合做激进的字体内嵌。如果你的游戏只有几百KB字体、UI数量极少,那优化空间很小,强行拆包反而增加复杂度。适合做这类优化的是中大型项目、多语言应用、或者UI界面多且频繁迭代的App。我们项目属于后者,UI界面超过50个,语言包含中英日韩,所以这套方案收益非常明显。
2. 优化方案设计:从“全量常驻”到“按需加载”
2.1 静态字体子集化思路
第一步是把字体Asset从“全量”变成“按需”。中文字体常用字约3500个,GB2312一级字库就包含3755个汉字,加上标点和数字,通常一套字体要覆盖4000到6000个字符。但一个具体UI界面可能只需要其中几百个字。
静态字体子集化的做法是:在UI开发阶段,周期性扫描所有预制体和代码里用到的字符串,生成一份“字符清单”,然后用这份清单重新生成TMP Font Asset。这样每个UI模块对应一个只包含必要字符的小图集。
实际操作中,我们按业务模块做了划分。登录、首页、设置、弹窗各自维护一份子集字体。例如弹窗系统只需要“确定、取消、提示、成功、失败、加载中”这些高频词,字符量非常小,图集可能只有256x256甚至128x128。
这里要特别强调:扫描字符串需要包含富文本标签。TMP支持类似<sprite>、<color>这类标签,这些标签本身的字符也要纳入子集,否则运行时会出现字符丢失。我们曾因为忘记扫描样式标签,导致某些按钮文字全部变成方块。
2.2 动态字体与图集控制
如果你觉得维护多份静态子集字体工作量太大,动态字体是另一种选择。TMP动态字体允许在运行时遇到新字符时自动添加字形到图集,类似于位图字体动态扩展。好处是开发者无需手动维护字符清单,坏处是运行时首次遇到新字符会有额外开销,严重时会造成瞬时卡顿。
Unity 2022的TMP Settings里提供了动态字体的全局配置,可以限制Atlas尺寸和动态生成上限。我的建议是只对弹窗、提示这类低频文本使用动态字体,高频UI主界面继续用子集静态字体。两者组合能兼顾包体和运行时性能。
动态字体图集控制有几个关键参数:
| 参数名 | 推荐值 | 说明 |
|---|---|---|
| Atlas Width / Height | 1024或2048 | 越大能容纳更多字符,但占用显存更高 |
| Multi Atlas Textures | 开启 | 允许动态扩展多张图集,避免频繁重建 |
| Clear Dynamic Data On Build | 开启 | 打包时清空动态生成的字符,避免包体膨胀 |
我们实测发现,开启Multi Atlas Textures后,动态字体首次生成新图集的耗时约10ms级别,视觉上基本无感。但如果你在Update里频繁改文本,每帧都触发新字形生成,那卡顿就很明显了。所以动态字体的使用场景一定要克制。
2.3 AssetBundle分层拆包策略
资源拆包策略是整个优化最核心的环节。我们最终采用了三层结构:
第一层是全局基础Bundle,只放公共字体所需的文本组件、TMP Settings、默认UI材质。这一层体积很小,启动时随首屏一起加载。
第二层是按UI模块划分的字体Bundle,每个模块内部包含该模块用到的子集字体、对应材质和Atlas。只有进入对应界面时才加载。
第三层是UI预制体Bundle,通过依赖统一指向第二层的字体Bundle,严格禁止预制体直接嵌入字体资源。
这样设计的核心在于AssetBundle的依赖共享机制。构建时Unity会为每个Bundle生成Manifest文件,记录依赖关系。只要字体Bundle独立存在,预制体Bundle就不会重复打包字体纹理。联合加载时,先加载字体Bundle,再加载预制体Bundle,Unity会自动解析依赖。
有些团队为了统一管理,直接把所有UI资源打进同一个Bundle,省事但每次更新UI都要下载整个包体。分层拆包虽然构建脚本复杂一点,但后续热更新、按需加载的收益非常高。按我们的统计,首包体积下降了约40%,其中字体相关资源占了大头。
2.4 为什么不用Addressables?
标题写的是AssetBundle,可能有朋友问:Unity 2022不是推荐用Addressables吗?我们确实评估过。Addressables在资源管理、依赖分析、引用计数上更完善,但它本质上还是底层使用AssetBundle,并且会引入额外一套资源分组和管理框架。
我们项目短时间内没法把所有资源管理切到Addressables,线上版本还在用原生AssetBundle体系。如果你是从零开始的新项目,我建议直接用Addressables,它的构建流程和运行时API更规范。但如果是老项目改造,像我们这样手动拆分字体Bundle反而是成本最低的方案。
手动方案最大的好处是可控性高。你能清楚看到每个Bundle里有什么,依赖是什么,构建日志可以直接验证。Addressables把依赖关系自动化处理了,出现问题时排查链路更长。所以不要迷信工具,先看项目现状再选方案。
3. 实操:TMP字体AssetBundle打包的完整配置
3.1 环境准备与工程规范
我们在Unity 2022.3 LTS上操作,项目使用URP管线,TMP版本是3.2.0-pre.4(随2022.3发布)。开始前需要确认TMP Essential Resources已导入,否则运行时会提示缺少TMP Settings。
工程规范上,我们定了两条硬性规定:一是所有UI字体Asset统一放在Assets/UI/Fonts/目录下,按模块建子文件夹;二是所有预制体禁止在Inspector里直接拖拽全局字体文件,必须通过运行时脚本赋值。这两条规范能避免后续构建时误引用,也让代码加载路径清晰。
目录结构示例:
Assets/UI/Fonts/ Common/ CommonFont.asset CommonFont SDF.asset Login/ LoginTitleFont.asset LoginButtonFont.asset Home/ HomeTitleFont.asset ...构建输出目录我单独放在AssetBundles/下,与工程代码隔离。
3.2 字体资产预处理:图集参数与字符集
字体Asset生成时,需要在Import Settings里确认Source Font File已经设置,然后在TMP Font Asset Creator窗口操作。关键参数我直接分享:
- Sampling Point Size(采样点大小):静态子集字体用40到60,太小时SDF边缘细节不够,太大会增加图集面积。
- Padding(内边距):默认9,用于SDF计算,建议不要低于5,否则文字边缘会发虚。
- Atlas Resolution:先设512x512做测试,如果字符放不下再加大到1024。
- Character Set:选择“Characters from File”,加载业务扫描出的字符集TXT文件。
- Render Mode:SDFAA,这是TMP标准模式。
生成完字体Asset后,还要检查Material的Shader是否使用了TextMeshPro/Distance Field。如果用了自定义Shader,要确保Shader也被纳入Bundle依赖,否则在真机上字体可能直接变紫色。
扫描字符集的脚本我这里给一个简化版思路:用FindObjectsOfType<TextMeshProUGUI>()遍历所有激活预制体,把text属性里的字符存入HashSet,再配合AssetDatabase.FindAssets扫所有场景和Prefab文件。代码不长,但注意要过滤掉动态拼接字符串的占位符。
3.3 AssetBundle构建脚本实现
构建脚本我直接用Unity Editor脚本写,挂在菜单栏方便出包。核心逻辑是给字体Asset设置AssetBundleName和Variant,再调用BuildPipeline.BuildAssetBundles。
关键代码如下:
using System.IO; using UnityEditor; using UnityEngine; public static class FontBundleBuilder { [MenuItem("Assets/Build Font AssetBundles")] public static void BuildFontBundles() { string root = "Assets/UI/Fonts"; string[] guids = AssetDatabase.FindAssets("t:FontAsset", new[] { root }); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); AssetImporter importer = AssetImporter.GetAtPath(path); importer.assetBundleName = Path.GetFileNameWithoutExtension(path) + ".fontbundle"; importer.assetBundleVariant = "ab"; } Directory.CreateDirectory("AssetBundles"); BuildPipeline.BuildAssetBundles( "AssetBundles", BuildAssetBundleOptions.None, BuildTarget.Android ); AssetDatabase.RemoveUnusedAssetBundleNames(); } }注意两点:一是assetBundleName中不能包含空格或中文,否则真机上可能解析失败;二是设置名称后一定要重新导入一次资源,再执行BuildAssetBundles,否则Unity可能没有刷新AssetBundle的缓存标记。
我们打Android包时使用了BuildAssetBundleOptions.None,没有启用LZ4压缩。原因是字体Atlas本身已经是纹理压缩格式,再压缩收益不大,反而增加加载解压耗时。如果是iOS,推荐用LZ4,系统内存和磁盘占用更平衡。
3.4 运行时加载与UI字体替换代码
运行时加载采用两层缓存机制:先查全局字典,再加载AssetBundle并缓存引用。这样同一个字体Bundle多次进入界面时不会反复从磁盘读取。
加载代码示例:
using System.Collections.Generic; using TMPro; using UnityEngine; public class FontLoader { private static Dictionary<string, FontAssetBundleCache> _cache = new(); public static TextMeshProFont LoadFont(string bundleName, string assetName) { if (_cache.TryGetValue(bundleName, out var cached)) { if (cached.Font != null) return cached.Font; } string path = Path.Combine(Application.streamingAssetsPath, bundleName); AssetBundle bundle = AssetBundle.LoadFromFile(path); TextMeshProFont font = bundle.LoadAsset<TextMeshProFont>(assetName); _cache[bundleName] = new FontAssetBundleCache(bundle, font); return font; } } public class FontAssetBundleCache { public AssetBundle Bundle; public TextMeshProFont Font; public FontAssetBundleCache(AssetBundle bundle, TextMeshProFont font) { Bundle = bundle; Font = font; } }然后在UI基类的初始化方法中,通过脚本给TMP组件赋值字体:
TextMeshProUGUI text = GetComponent<TextMeshProUGUI>(); text.font = FontLoader.LoadFont("login_font.ab", "LoginTitleFont"); text.raycastTarget = false; // 不需要点击的文本可以关掉赋值字体后,还需要调用text.UpdateFontAsset()或强制重建顶点,否则Mesh不会即时更新。Unity 2022中如果只是改font属性,部分情况下不会自动刷新,我踩过这个坑。
关于卸载:字体Bundle是全局缓存,不在UI界面退出时卸载。因为字体资源体积大且使用频繁,卸载后再次使用会产生加载卡顿。老设备上如果内存吃紧,可以再增加LRU策略,把超过N分钟未使用的字体Bundle卸载掉。我们最终没有做自动卸载,而是在切语言或重启时手动清理缓存。
4. 常见问题与性能实测数据
4.1 字体加载后发虚变糊的问题
这是我们遇到最多的问题。子集字体生成时采样点数太低,或者Padding设置过小,会导致SDF信息不足,UI在缩放时边缘发虚。尤其是同一字体用到多个字号,比如标题字号40、正文16,如果只生成一份40px的SDF字图,缩到16时字形边缘会发虚到没法看。
解决办法有两个方向:一是为差异很大的字号分别生成子集字体,比如标题一套、正文一套;二是统一使用best fit模式,让TMP自动缩放,同时保证原始采样点数足够高。我推荐前者,更可控,而且能进一步缩小单张图集体积。
另一个发虚原因是图集被压缩成ASTC或ETC2后精度下降。TMP默认的图集格式是RGBA32,打进AssetBundle时如果压缩设置不当,SDF距离场会被压坏,表现为文字边缘出现锯齿或模糊。建议在Prefab/Asset导入设置里把Atlas纹理的压缩格式改为High Quality或直接不压缩,仅保留在Bundle外,运行时加载后再压缩到显存。
4.2 重复加载、内存泄漏排查
AssetBundle最经典的坑是重复加载。我们的UI框架原来有个全局加载函数,每次打开界面都执行AssetBundle.LoadFromFile,没有判断Bundle是否已加载。结果日志里看到同一张Atlas纹理加载了十几份,内存占用直接爆掉。
排查方法很简单:在Profiler里搜索Texture2D,按大小排序,如果同一张纹理出现多个实例,基本就是重复加载。修复方案就是我上一节写的缓存字典机制。同时注意,AssetBundle.LoadFromFile在Android上尽管路径相同,每次调用都会返回新的Bundle对象,除非内存中已有相同路径引用。所以缓存键名必须统一。
还有一个隐藏坑:AssetBundle依赖的材质如果动态生成变体,可能产生不可回收的GC Alloc。我们UI中大量使用了描边和阴影效果,TMP材料的SetShaderProperty操作会创建新Material实例。优化方案是预烘焙常用样式到材质库,运行时只做引用替换,不动态create。
4.3 不同字号、多语言场景下的资源管理
支持多语言时,字体选择不能写死。日文有假名和汉字,韩文有谚文,阿拉伯文有复杂字形结合规则,TMP对这些都有基础支持,但字体Asset要分别生成。
我们项目维护了一份LocalizationFontMap配置表,key是语言ID,value是字体Asset在Bundle中的路径。切换语言时,遍历当前界面中所有TMP组件,重新赋值字体并刷新文本。
语言切换后,建议立即调用Resources.UnloadUnusedAssets(),把旧语言字体图集释放掉。但要注意,UnloadUnusedAssets是异步的,且不能手动指定卸载对象,所以如果旧语言字体缓存还在字典里,释放无效。这时需要先清掉缓存引用,再调用卸载。
我在测试中发现,日语字体和中文共用大量汉字,如果分别打包会有重复字形。可以用同一份汉字图集作为基础,日文假名单独做成Fallback字体,这样Bundle体积能减少不少。这个方案需要TMP的Fallback机制配合,设置路径在Font Asset Inspector的Fallback Font Assets列表里。
4.4 实测数据对比
以下是我们项目在Unity 2022上的一组实测数据,测试设备为Redmi K50(Android 12,骁龙870),测试场景为主界面到设置界面来回切换10次。原生AssetBundle + 分层子集方案与原始全局字体方案对比:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 首包字体相关体积 | 82MB | 38MB | 下降约54% |
| 首屏启动加载耗时 | 1.32s | 0.74s | 下降约44% |
| 主界面进入耗时 | 285ms | 120ms | 下降约58% |
| 设置界面切换耗时 | 220ms | 105ms | 下降约52% |
| 内存常驻字体图集 | 112MB | 41MB | 下降约63% |
| 10次切换GC峰值 | 18MB | 6.4MB | 下降约64% |
这里说明一下,我们优化前把字体都打进了首包Bundle,所以启动时必须全量加载。优化后首包只加载Common字体子集,主界面和设置界面各用单独Bundle,进入时按需加载,所以耗时降低明显。
GC下降主要来自两方面:一是AssetBundle重复加载被缓存机制消除,二是不再动态创建Material实例。GC降低最直接的表现就是界面切换时不再出现可感知的卡顿,UI界面卡顿问题基本解决。
完整优化过程大概花了两周,其中字符集扫描和字体重建用了一周,AssetBundle拆分和代码改造用了一周。后续新UI接入只需要走同一套规范,没有额外维护成本。
5. 一些值得再挖的细节
5.1 字体Bundle的Variant处理与热更新
如果你有热更新需求,字体Bundle可以走整包替换。我们给字体Bundle单独做了版本号,线上如果只改了UI文字,不需要重新下载整个UI模块,只需要拉取新的字体Bundle并刷新缓存。这样后端可以精确控制差分更新包体大小。
但要注意AssetBundle下载工具没有内置Hash校验,热更后一定要比对远端Manifest里记录的CRC和本地加载的Bundle是否一致。我们之前的教训是,字体Bundle更新了,但UI预制体里还引用旧字体,导致文字全变方块。这是因为预制体Bundle的依赖并没有自动更新到新版本。解决方法是把字体Bundle和UI预制体Bundle的更新作为一个版本组同时下发,并在代码里加一个构建版本号校验。
5.2 TMP默认Shader的变体裁剪
在做Android包时,Shader变体数量会影响包体和加载时间。TMP内置Shader包含多平台变体,如果你的项目只用Android,可以尝试使用TextMeshPro/Mobile/Distance Field这类移动端专用Shader。它们去掉了部分PC端特效,变体少,运行开销也更低。
但要注意,Mobile版Shader对描边和阴影的支持略弱,如果UI设计稿里大量使用复杂字体效果,可能还是得用完整版。我们项目最终分两套:普通文本用Mobile Shader,需要描边的标题用完整版。两套Shader共用同一份字体图集,只是Material不同,包体增加的量可以忽略。
5.3 字符集扫描工具的自动化扩展
前面提到扫描预制体字符集,实际上我们把它做成了CI流程的一部分。每次UI提交代码后,自动运行扫描脚本,生成最新字符集TXT,再触发字体重建。这样新UI接入时不需要人工去记要包含哪些字符。
工具逻辑上要注意:字符串中包含转义符、换行符都应忽略;富文本标签里的参数(比如sprite name="xxx")不参与字形生成;数字和标点必须保留在Common字体子集里。
如果团队里UI组件是动态创建的,扫描不到运行时对象,可以在代码里集中维护一个DynamicTextRegistry,或者至少把已知的动态字符串前缀加入扫描白名单。这个坑让我们后来补做了好几个版本的白名单维护,提前考虑到能省很多事。
5.4 真机测试与Profiler性能验证
最后强调真机测试。字体加载耗时、内存占用在Editor和真机上的表现差异很大,老板们看到的截图也永远是真机效果。Android端建议在Profiler里打开Memory Profiler和GPU Usage,查看Texture2D数量与大小分布;iOS端同样用Xcode Instruments的Metal调试工具观察纹理上传。
自己踩过一次坑:Editor中字体加载只花10ms,但真机上同样的操作耗了80ms,原因是图集纹理首次上传到GPU需要时间。后来我们在进入需要字体密集展示的场景前,先做一次小字体采样预加载,把GPU上传的尖峰消耗掉,再显示UI,实测切换画面会更平滑。
6. 写在最后的个人体会
字体优化这件事,表面是包体和内存的算术题,实际是团队工程规范的照妖镜。如果你发现字体Bundle反复变大,大概率不是TMP本身的问题,而是美术和程序之间缺少强制性的资源引用约定。把字体的生成、扫描、打包全部脚本化之后,我们后续开发反而轻松了,因为同一套机制对任何UI资源都适用。
另外提醒一句,TMP的资源管理可扩展很多细节,比如影子、描边、表情包,每个特性背后都有对应的资源开销。不要试图一次性全部优化完,先关注体量最大的那块。对我们来说,字体图集和重复加载问题解决后,UI卡顿的优化基本完成了80%。剩下的很多是具体业务层面的微调了。
如果你在按这个方案改造过程中遇到异常,可以重点检查三处:一是AssetBundleName是否设置成多级路径(会导致查找依赖失败),二是字体Asset是否被预制体直接引用(会导致重复打包),三是动态字体是否意外开启了清理开关(会导致运行时字符丢失)。这三处我们全部踩过,希望你能少走弯路。