Unity字体优化:TextMeshPro与AssetBundle的包体及内存治理
1. 字体资源为什么总在半夜偷走包体体积先说我踩过的那个坑。项目做了一年半UI界面越加越多中英文双语加少量日韩字符TextMeshPro字体资产一直放在项目里直接引用。某天看构建报告整个APK从120MB涨到180MB拆开一看三类字体资产占了接近60MB。更离谱的是这些字体里有大量根本没用到的字形——韩文、日文、生僻符号全被打进了动态字体图集运行时还会根据输入继续往图集里塞字形内存峰值一次比一次高。很多团队对贴图压缩、模型面数盯得很紧唯独对字体睁一只眼闭一只眼。原因也简单字体这东西平时看不见摸不着渲染也不会卡只有等包体报告出来才吓一跳。但字体AssetBundle的优化逻辑和其他资源差别很大不能简单套用贴图压缩那套思路。TextMeshPro的字体资源由几个部分构成TTF/OTF源字体文件这个是最占体积的主儿单个中文字体动辄十几MBTMP生成出来的Font Asset内部包含SDF图集纹理、Glyph索引表、字体度量信息Fallback Font Table回退字体表负责生僻字和特殊语言兜底TMP Settings里的默认字体引用如果被误打进Bundle会连带一堆资源体积膨胀的原因主要有两个一个是源字体文件本身大另一个是SDF图集为了容纳足够多字形被撑大。SDF图集的分辨率越高、包含的字形越多纹理体积就越大。2048×2048的图集一张就是16MB四张就是64MB这还只是图集没算源文件。这里要先建立一个认知TextMeshPro和UGUI的Text不同它不直接渲染TTF而是把所有用到的字形烘焙到一张或多张SDF图集纹理上。所以优化字体包本质上就是优化两件事——图集里装什么字形以及图集本身多大。2. 打包之前先动手把字体管线从动态改成静态2.1 哪些字该进图集哪些不该进TextMeshPro创建字体资源时有一个关键选择Dynamic还是Static。动态模式Dynamic意味着图集在运行时按需生成刚启动只占很小空间但每遇到一个新字形就往图集里塞静态模式Static则在编辑期把所有需要的字形预先烘焙进图集。两种模式在AssetBundle打包里的表现完全不同。动态模式的问题不是字体文件本身而是运行时的控不住。运营活动发一条带生僻字的公告玩家输入一个怪名字这些字形就会在你毫不知情的情况下进入图集。图集满了还会自动扩容或重建内存暴涨甚至触发卡顿。而且动态模式打出的Font Asset虽然初始体积小但构建时如果把TTF也归进Bundle那TTF的十几MB还是省不掉。静态模式的优势在于可预期。你在编辑器里把项目所有用到的字符收集一遍烘焙成图集确定这个Font Asset里不会有计划外的字形打包出来的体积就是固定的不随着玩家输入膨胀。缺点也很直接如果漏了字符运行时就显示豆腐块方框。我现在的策略是所有面向玩家可见的UI文本全部走静态字体策划配置表里的文本、系统弹窗、按钮文字全部提前收集进字符集。玩家输入昵称这类不可预知的场景才用动态字体且动态字体单独拆Bundle不跟核心UI混在一起。2.2 用Font Asset Creator做一次干净的静态烘焙创建静态Font Asset的路径是Window TextMeshPro Font Asset Creator。打开以后几个参数直接影响后面的打包体量和使用体验Source Font File指定TTF/OTF。中文建议用思源黑体或HarmonyOS Sans这类开源字体版权干净字形也全Atlas Resolution图集分辨率。中英文混排的项目一般2048×2048起步纯英文1024×1024就够。这块每放大一档纹理内存翻四倍别贪Padding字形之间的间距建议保持默认9。调小会省图集空间但清晰度会掉小字号尤其明显Character Set选Custom Range或者Character List把你收集好的字符粘贴进去。这是整个打包优化的核心输入收集字符这一步我给团队写过一个Editor工具遍历场景资源和ScriptableObject配置表把所有TextMeshProUGUI的text属性、本地化配置表key的译文文本全部抽出来union成一个字符集合再映射成Unicode区间。这样每次烘焙字符集就不用手动维护新增界面后跑一次工具就同步了。有一件事必须说清楚字体Asset并不需要把整个TTF打进Bundle。静态Font Asset烘焙完成后运行时只需要加载这个Font AssetTTF只是编辑器里用来生成字形的原料。但默认情况下Unity会把Font Asset依赖的TTF也带进Bundle因为BuildPipeline认为它们是依赖关系。所以构建脚本里要手动控制要么把TTF标记为不包含在构建里要么在打包后检查依赖关系时剔除。3. AssetBundle分组决策字体Bundle不该和UI预制体住在一起3.1 为什么字体单独成Bundle能解决隐性依赖很多项目打AssetBundle是按功能模块分的MainUI一个BundleLoginUI一个Bundle字体资源顺手就放进了引用它的那个UI Bundle里。这个做法短期看不出问题等模块多起来就麻烦了。假设三个UI预制体都用到了同一个字体资产把字体放进AUI的Bundle。BUI加载时发现依赖缺失Unity会自动把AUIBundle也拉进来。还有更隐蔽的AUI.Bundle依赖着字体资源而字体资源又回退引用着别的字体这一串依赖会把一堆用不上的Bundle拽进内存。即便AssetBundle在加载的时候Unity会注册依赖你在Profiler里看到的内存占用也会比你预期的多不少。正确做法是所有字体资产独立成一个或者几个CommonFont.Bundle不挂在任何业务模块下。UI预制体只负责引用构建时Unity把依赖关系记录在每个Bundle的Manifest里运行时通过Manifest查找并加载CommonFont.Bundle作为依赖。这样每个模块启动时按需加载自己那部分UI字体这个公共依赖只加载一份内存中没有重复副本。3.2 构建脚本里如何把字体资产强制归位用BuildPipeline.BuildAssetBundles构建时AssetBundle的分组主要靠Assets目录下建立的.assetbundle后缀标记文件。给CommonFont目录创建名为CommonFont的.assetbundle标记后这个目录里的所有Font Asset会被打进同名Bundle。但依赖是不会自动分流的。你给Font Asset设置了Fallback字体表回退字体构建时会把回退字体也打进去。如果回退字体被打进了另一个业务Bundle那业务Bundle加载时又会连带出新的依赖链。所以构建脚本里最好加一道校验var manifest BuildPipeline.BuildAssetBundles(outputPath, builds, BuildAssetBundleOptions.None, BuildTarget.Android); foreach (var bundleName in manifest.GetAllAssetBundles()) { AssetBundleManifest deps manifest.GetDirectDependencies(bundleName); foreach (var dep in deps) { if (dep.Contains(font, StringComparison.OrdinalIgnoreCase) !bundleName.Contains(font, StringComparison.OrdinalIgnoreCase)) { Debug.LogWarning($字体依赖交叉{bundleName} 依赖 {dep}); } } }这个脚本不是为了打包时才跑而是每次构建后自动检查。发现字体依赖交叉说明有预制体直接引用了不该引用的Font Asset或者Fallback设置不干净。排查完再出包避免线上包带着隐性依赖链。注意一个细节静态字体Asset如果带Fallback字体表比如主字体缺生僻字时回退到“生僻字字体”那么这两个字体Asset都得打进同一个Bundle否则跨Bundle依赖会带来额外的加载时间和内存峰值。字体Fallback建议全链路设计成树形结构而不是网状交叉引用每棵树的节点都落在同一个Bundle里。4. 运行时加载策略与缓存不重复加载也别急着卸载4.1 启动预加载和按需加载的边界怎么划字体资源比较特殊UI可能在任意时刻被打开你没法像关卡贴图那样明确知道哪张字体何时需要。但这不代表不能规划。我按使用频率把字体划分成三层层级适用字体加载时机生命周期常驻层主要的UI字体、数字字体、英文启动Loading阶段永不卸载按需层生僻字回退字体、多语言小语种字体对应语言包或对应界面打开前引用计数归零可卸载动态层玩家输入、运营动态文本用字体首次输入时长时间不用可释放图集重建这个分层听着简单真正的坑在实现。启动时你不能无脑加载所有字体Bundle——万一项目有20种语言启动就把20套字体全预加载那启动时间就完了。正确做法是只加载默认语言字体比如简体中文和数字英文这是所有界面跑不掉的基底。其他语言在切换语言时加载对应Bundle并且只加载当前语言对应的那一套而不是一次性全加载。4.2 用引用计数管理字体Bundle而不是裸LoadAssetBundle加载最怕的就是重复Load。Unity的LoadFromFileAsync多次调用同一个Bundle路径返回的其实是同一份Bundle实例但每次LoadAsset的时候你拿到的引用是不同的如果不手动管理卸载时极容易把正在用的字体给unload掉。我给字体Bundle写了一个简单的引用计数管理器。核心就三件事加载时记录Bundle实例和引用数引用数从0变成1时执行加载引用数归零时延迟5秒再真正卸载防止界面上一个动画还没播完字体就被释放卸载时先处理Bundle依赖确保没有其他Bundle还引着这个字体public class FontBundleCache { private AssetBundle _bundle; private int _refCount; private float _unloadTimer; private bool _pendingUnload; public TMP_FontAsset Load(string bundlePath, string assetName) { if (_bundle null) { _bundle AssetBundle.LoadFromFile(bundlePath); } _pendingUnload false; _refCount; return _bundle.LoadAssetTMP_FontAsset(assetName); } public void Release() { _refCount--; if (_refCount 0) { _refCount 0; _unloadTimer 5f; _pendingUnload true; } } public void Update() { if (_pendingUnload) { _unloadTimer - Time.deltaTime; if (_unloadTimer 0) { _bundle.Unload(false); _bundle null; _pendingUnload false; } } } }这套逻辑的好处在于字体虽然被多个界面引用但底层只有一份Bundle实例。每个UIPanel打开时请求一次Load关闭时Release一次计数归零后才真正释放。卸载时用Unload(false)而不是Unload(true)这样已经生成的SDF图集纹理还有机会被TMP缓存复用不会每次打开界面都重新烘焙字形。另一个细节是LoadFromFile的路径选择。开发阶段可以用Application.streamingAssetsPath下的本地Bundle方便调试线上则建议把字体Bundle放在persistentDataPath或者从服务器下到本地后加载。不少项目把字体Bundle直接放StreamingAssets这样启动就能用LoadFromFile加载不走网络启动稳定。5. Unity 2022实测对比与踩坑记录5.1 优化前后数据到底差多少我这边的参考项目是2022.3 LTSAndroid平台中英日韩四语言支持UI界面两百多个。优化前的做法是最原始的四个语言的TTF直接打进StreamingAssets作为AssetBundle每个TTF里字形奇全构建时为了保险还带上了TMP Settings。看看打包出去的Bundle实际大小资源优化前动态模式整包TTF优化后静态Font Asset独立Bundle简体中文字体相关18.6MB3.2MB英文字体相关5.4MB0.8MB日文字体相关9.2MB1.1MB韩文字体相关8.7MB1.4MBTMP Settings连带依赖2.1MB0MB合计44MB6.5MB40MB的字体包压到6.5MB差距主要来自三个地方一是扔掉TTF源文件二是字符集从“覆盖全字形”变成“只覆盖实际使用到的字符”三是TMP Settings不再被误打包。运行时内存上优化前动态字体图集最大记录到峰值86MB界面全开后大概稳定在60MB出头。优化后静态Font Asset图集预烘焙完运行时没有新增字形内存稳定在18MB左右。移动端测试中端机上一轮完整界面流跑下来总内存峰值降低了约35MB。5.2 坑一静态字体漏字后显示豆腐块静态字体最怕漏字符。我的项目第一次切静态字体打包前一天发现新功能里有一个%符号没进字符集测试时所有价格为¥29.9的地方全显示方块。排查过程很典型先是怀疑字体没加载结果Log里报的是“TextMeshPro Font Asset 缺字符未找到Unicode U00A5”。定位到问题后发现是字符收集工具只扫描了预制体和配置表没扫到代码里拼出来的字符串——¥ price.ToString()。这类运行时拼接的字符串根本不会出现在静态文本里编辑器工具扫不到。解决方案是给字符收集工具加一个手工维护的Extra Character白名单文件所有策划和程序约定代码里动态拼接的文本字符要登记进白名单否则就要用动态字体。这个白名单文件连同字符集一起入版本库每次烘焙字体时自动合并。从那以后线上就再没出现过漏字符导致豆腐块的事故。5.3 坑二Fallback字体表导致依赖全量加载有一阵子我在Profiler里看到启动初期内存暴涨Track Down之后发现是字体回退表在作怪。主字体A设置了回退到字体B字体A和B本来应该都在CommonFont.Bundle里但因为某个预制体直接引用的是B构建时Unity把B单独拆到了另一个模块的Bundle里。结果就是每次加载UI界面时Unity发现需要B又要把B所在的业务Bundle整包加载连带把那个模块的几十个预制体依赖全部拉进内存。这类问题构建脚本的依赖检查能拦截一大部分但还有一条路径是我后来才注意到的TextMeshPro的TextStyleSheet和TMP Settings里也可以配Fallback。在TMP Settings里给默认字体设置回退会导致所有不显式指定FontAsset的Text组件统一走这条依赖链。排查的时候不一定第一时间想到TMP Settings而是先去翻预制体。建议每次改动Fallback后用AssetDatabase.GetDependencies看一眼最终依赖列表确保所有回退目标都在同一个Bundle里或者确认它们正是你想打进不同语言包的一部分。5.4 坑三WebGL平台下字体Bundle加载失败Unity 2022发布WebGL时如果你用了LoadFromFileAsync加载StreamingAssets里的Bundle会遇到一个问题WebGL平台下StreamingAssets处于压缩包内不能直接按路径读取必须用UnityWebRequest。我当时没注意这个平台差异本地Player跑得好好的发布到WebGL后所有字体全部丢失UI一片空白。解决办法很简单封装一个平台相关的Bundle加载函数public static AssetBundle LoadBundleFromFile(string path) { #if UNITY_WEBGL var request UnityWebRequestAssetBundle.GetAssetBundle(path); var operation request.SendWebRequest(); while (!operation.isDone) { } return DownloadHandlerAssetBundle.GetContent(request); #else return AssetBundle.LoadFromFile(path); #endif }要注意的是WebGL下AssetBundle的下载和加载另有一套内存管理逻辑Bundle加载完成后request.dispose()要记得调用不然会有内存泄漏。这类平台差异问题很容易在真机验收前一天爆发最好在项目早期就把不同平台的字加载路径分好。5.5 构建后验证脚本防止字体回归最后分享一个我觉得最值回票价的习惯构建完成后的自动验证。每次出包后跑一遍以下脚本遍历Manifest的所有Bundle统计与字体相关的Bundle大小校验CommonFont.Bundle里包含全部指定Font Asset检查所有UI预制体引用的Font Asset GUID是否都能在已构建的Bundle中找到找不到的直接输出Missing字体引用列表这套脚本由Jenkins在构建流水线末尾自动执行一旦字体引用断裂或者缺字符集构建日志直接标红。有了这层保障字体相关的回归问题基本在出包前就被拦截了。字体AssetBundle优化的核心思路还是那几个字静态化、独立Bundle、精准引用、构建校验。经历过一次线上字体缺失的事故之后我个人是把这套流程固化成标准操作了每次接入新字体或者调整语言包都会跑一遍依赖检查和字符集校验花几分钟省掉的是上线后大面积豆腐块的灾难体验。