Unity TMP字体AssetBundle优化:从全量常驻到按需加载的实践
2026/9/15 17:13:20 网站建设 项目流程

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 / 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,再加载预制体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 + 分层子集方案与原始全局字体方案对比:

指标优化前优化后变化
首包字体相关体积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 name="xxx")不参与字形生成;数字和标点必须保留在Common字体子集里。

如果团队里UI组件是动态创建的,扫描不到运行时对象,可以在代码里集中维护一个DynamicTextRegistry,或者至少把已知的动态字符串前缀加入扫描白名单。这个坑让我们后来补做了好几个版本的白名单维护,提前考虑到能省很多事。

5.4 真机测试与Profiler性能验证

最后强调真机测试。字体加载耗时、内存占用在Editor和真机上的表现差异很大,老板们看到的截图也永远是真机效果。Android端建议在Profiler里打开Memory ProfilerGPU Usage,查看Texture2D数量与大小分布;iOS端同样用Xcode Instruments的Metal调试工具观察纹理上传。

自己踩过一次坑:Editor中字体加载只花10ms,但真机上同样的操作耗了80ms,原因是图集纹理首次上传到GPU需要时间。后来我们在进入需要字体密集展示的场景前,先做一次小字体采样预加载,把GPU上传的尖峰消耗掉,再显示UI,实测切换画面会更平滑。

6. 写在最后的个人体会

字体优化这件事,表面是包体和内存的算术题,实际是团队工程规范的照妖镜。如果你发现字体Bundle反复变大,大概率不是TMP本身的问题,而是美术和程序之间缺少强制性的资源引用约定。把字体的生成、扫描、打包全部脚本化之后,我们后续开发反而轻松了,因为同一套机制对任何UI资源都适用。

另外提醒一句,TMP的资源管理可扩展很多细节,比如影子、描边、表情包,每个特性背后都有对应的资源开销。不要试图一次性全部优化完,先关注体量最大的那块。对我们来说,字体图集和重复加载问题解决后,UI卡顿的优化基本完成了80%。剩下的很多是具体业务层面的微调了。

如果你在按这个方案改造过程中遇到异常,可以重点检查三处:一是AssetBundleName是否设置成多级路径(会导致查找依赖失败),二是字体Asset是否被预制体直接引用(会导致重复打包),三是动态字体是否意外开启了清理开关(会导致运行时字符丢失)。这三处我们全部踩过,希望你能少走弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询