
1. 为什么TMP字体AssetBundle会拖垮你的UI1.1 从一次线上包体告警说起做Unity客户端的朋友应该都遇到过这种场景UI界面卡顿包体越打越大用户反馈加载时间变长。我们团队在Unity 2022 LTS版本上做了一款多语言工具类App功能不复杂但安装包愣是冲到了200MB以上。排查到最后元凶之一就是TextMeshPro字体资源被粗暴地打进了多个AssetBundle里。TextMeshPro以下简称TMP现在是Unity UI文本的首选方案它通过SDFSigned 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到几百KBAtlas Texture图集纹理保存实际SDF字形数据最占空间常见1024x1024或2048x2048Material材质控制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 / Height1024或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再加载预制体BundleUnity会自动解析依赖。有些团队为了统一管理直接把所有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 ModeSDFAA这是TMP标准模式。生成完字体Asset后还要检查Material的Shader是否使用了TextMeshPro/Distance Field。如果用了自定义Shader要确保Shader也被纳入Bundle依赖否则在真机上字体可能直接变紫色。扫描字符集的脚本我这里给一个简化版思路用FindObjectsOfTypeTextMeshProUGUI()遍历所有激活预制体把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 Dictionarystring, 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.LoadAssetTextMeshProFont(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 GetComponentTextMeshProUGUI(); 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是语言IDvalue是字体Asset在Bundle中的路径。切换语言时遍历当前界面中所有TMP组件重新赋值字体并刷新文本。语言切换后建议立即调用Resources.UnloadUnusedAssets()把旧语言字体图集释放掉。但要注意UnloadUnusedAssets是异步的且不能手动指定卸载对象所以如果旧语言字体缓存还在字典里释放无效。这时需要先清掉缓存引用再调用卸载。我在测试中发现日语字体和中文共用大量汉字如果分别打包会有重复字形。可以用同一份汉字图集作为基础日文假名单独做成Fallback字体这样Bundle体积能减少不少。这个方案需要TMP的Fallback机制配合设置路径在Font Asset Inspector的Fallback Font Assets列表里。4.4 实测数据对比以下是我们项目在Unity 2022上的一组实测数据测试设备为Redmi K50Android 12骁龙870测试场景为主界面到设置界面来回切换10次。原生AssetBundle 分层子集方案与原始全局字体方案对比指标优化前优化后变化首包字体相关体积82MB38MB下降约54%首屏启动加载耗时1.32s0.74s下降约44%主界面进入耗时285ms120ms下降约58%设置界面切换耗时220ms105ms下降约52%内存常驻字体图集112MB41MB下降约63%10次切换GC峰值18MB6.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 namexxx不参与字形生成数字和标点必须保留在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是否被预制体直接引用会导致重复打包三是动态字体是否意外开启了清理开关会导致运行时字符丢失。这三处我们全部踩过希望你能少走弯路。