ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity UI优化:用AssetBundle拆分TMP字体资源,解决内存与卡顿

Unity UI优化:用AssetBundle拆分TMP字体资源,解决内存与卡顿 年初做性能专项打开游戏UI那一瞬间内存直接往上跳了大几十MB排查了半天罪魁祸首居然不在模型、也不在图集而是TextMeshPro的字体资源。做Unity UI优化的同学对TMP肯定不陌生但字体这个环节一旦处理不当内存和加载开销比想象中狠得多。这篇文章基于Unity 2022.3 LTS实测聊聊我是怎么用AssetBundle把TMP字体单独打成资源包让UI从“卡顿吃内存”变成“秒开稳占用”的。完整的打包流程、参数细节和踩坑记录都放出来了适合正在为UI性能和包体发愁的Unity开发者参考。不管你是刚接触TMP的初级开发还是已经在做中大型项目的技术负责人这轮优化思路都可以直接落地。核心就一句话把字体从UI预制体里拆出来让所有界面共享一份资源再通过AssetBundle做按需加载和热更替换。1. 先说结论字体为什么会拖垮你的UI1.1 动态字体省事背后的内存陷阱TextMeshPro有两种字体模式动态字体在Inspector里勾一下就能用新遇到的字符会自动生成字形放进图集。听起来特别省事但代价是运行时的失控。动态字体在运行时碰到没有的字符会走一次RebuildAtlas流程算位置、生成SDF数据、写入图集、刷新材质。这个过程是在主线程上完成的聊天框连续输入中文的时候每一帧都在触发rebuild掉帧是必然的。我去过的一些项目里打开战绩面板、分享弹窗这些界面字体图集每次都在动态扩展UI卡顿的根源往往不是粒子特效而是这里。更麻烦的是动态图集只增不减。Unity不会因为你关掉界面就把生成的字形清掉长期开着游戏图集可能从1张涨到3张甚至4张。2048x2048的Alpha8图集一张约4MB看着不多但如果项目里有10个动态字体资产就是10份图集各自膨胀几十MB的内存就这么没了。1.2 静态字体字符集与图集膨胀的矛盾静态字体是在编辑器阶段就把所有需要的字符烘焙成SDF图集运行时不再生成字形。它能解决卡顿问题但带来了另一个矛盾字符集小了容易缺字字符集大了图集数量就会失控。做中文本地化的时候这个问题特别明显。常用汉字3500个加上全角标点、数字、英文一张2048的图集用9号Padding其实差不多能塞下但如果你既要正文又要粗体、既要中文又要日文韩文每个字体资产都需要自己的一套图集。很多项目的UI系统会为不同语言、不同样式各自建立一个字体资产图集数量瞬间就上去了内存翻倍是常事。这里有个常见误区不少人认为“静态字体就是万能解药”实际上如果每个界面预制体都直接引用一份静态字体那么每个预制体依赖里都挂着一个完整的字体资产打AssetBundle时Unity会把同一份字体复制进多个Bundle包体和运行时内存双双恶化。静态字体只是解决了“生成字形”的卡顿没有解决“资源重复和加载”的问题。2. 为什么选AssetBundle而不是直接挂引用2.1 直接引用和Resources目录的隐藏成本很多项目从第一天起就把字体放在Resources目录里或者直接在场景里拖引用。对小项目来说没问题一旦项目变大问题就来了。Resources目录下的资源无论如何都会打进安装包Unity对所有Resources目录做全量管理你没有按需加载的控制权。更麻烦的是它默认是全量打包一个字体文件哪怕只有一个界面用到也会跟随安装包永远存在。想换字体只能发整包更新。直接把字体拖到Prefab引用也有问题。假设你有20个UI预制体其中8个用了标题字体构建Bundle的时候如果这8个预制体没有被正确拆分依赖Unity很可能会把同一个字体资产重复打进8份Bundle里。首包装包体积飙升而且运行时要等Unity去加载每一份副本内存里同时存在多个相同字体的实例纯属浪费。2.2 独立字体包到底优化了什么AssetBundle的核心价值是把“资源”和“使用方”彻底解耦。把字体单独打成一个Bundle之后所有UI预制体的Bundle都不再包含字体实体而只是记录一个依赖关系。运行时Unity会先加载字体Bundle其他Bundle在解析依赖时会自动复用同一份字体资源。这个方案带来的收益很直接包体不再因为重复引用而膨胀字体只保留一份。字体Bundle只在需要时加载比如主界面初始化时加载其他非核心界面用到字体时再按需加载。多语言切换和字体热更变得可行只要替换字体Bundle不用动任何UI预制体。配合引用计数管理可以精确控制字体资源何时驻留、何时释放。说白了AssetBundle让我们把“字体”当成了一个独立依赖项去管理而不是藏在某个界面资源里的附属品。这个思路同样适用于图集、公共材质、通用Shader配置只是字体因为图集大、引用点多优化的收益最明显。3. Unity 2022实测字体AssetBundle打包手动全流程3.1 第一步用Font Asset Creator烘焙静态字体打包之前先把字体资产准备好。推荐的做法是直接用Font Asset Creator把字体烘焙成静态SDF图集运行时不再动态生成字形。打开菜单Window TextMeshPro Font Asset Creator。Source Font File选择你项目里的TTF或OTF文件。如果项目是从旧版TextMeshNGUI那边转过来的要确保导入的是新版TMP兼容的字体格式。Character Set这里很多教程会让你选Custom Characters然后手动贴字符但我更推荐用Characters from File把你项目里所有UI文案导出到一个txt每一行放一句或一个字符然后让Creator读取这个文件。这样烘焙出来的字符集不会漏字后续设计师新增文案后重新导出再烘焙即可。关键参数上我的移动端推荐配置是Atals Resolution2048 x 2048。Point Size正文90到120标题字体可以到140。Padding普通文本用5到8如果材质加了Outline或Shadow至少9以上否则描边笔画会串到相邻字形上。Render ModeSDFAA或SDF8都行移动端用SDFAA兼容性更好。Multi Atlas Textures保持勾选万一字符集超过一张图集可以自动扩展。烘焙完会生成一个TMP_FontAsset同时自动创建一个Material和一个Texture。这三个资源是绑定在一起的后面打包时最好一起处理。提示如果之前用的动态字体想转静态在Font Asset Creator里直接基于当前动态字体Asset创建静态版本也行但要注意它只保留当前图集里已有的字形没生成过的字符不会包含进去。3.2 第二步设置AssetBundle名称与依赖在Project窗口里选中最核心的字体资产在Inspector底部找到AssetBundle下拉框点击New输入font/main。这里的命名前缀没有强制要求但强烈建议统一用category/bundleName的格式后面写构建脚本和加载管理时会方便很多。这个字体资产配套的Material和Texture也要确认它们的AssetBundle名。选中Material和Texture分别设置成同一个Bundle名。因为TMP_FontAsset的Material通常是以子资产形式存在的但当你手动给Material设置Bundle名时本质上是在告诉Unity这些资源是显式分配到某个Bundle里的。如果漏了这步Unity构建时会把这个Material当作依赖自动塞进引用它的UI Bundle里字体还是会被复制进多个包前面做的拆分就白费了。接着处理你的UI预制体。每个场景的UI根节点对应的预制体可以设置成ui/mainmenu、ui/battle这类名字。预制体里所有TextMeshProUGUI组件如果引用的是font/main这个字体资产构建时Unity会自动记录依赖关系不会再把字体实体复制进ui/mainmenu。怎么验证依赖关系对不对在Unity 2022里可以直接打开Window Asset Management AssetBundle Browser然后选中某个UI Bundle查看它的Dependencies列表。如果Dependencies里出现了字体资产说明这个Bundle确实依赖于它这是正常的。但如果Dependencies里出现了Texture和Material同时这些资源在别的Bundle里也出现了就要检查是不是显式分配失误了。3.3 第三步写构建脚本并选择压缩选项手动画完Bundle名之后写一个构建脚本以后打版本一键跑就行。下面是我项目里在用的精简版基于Unity 2022.3using System.IO; using UnityEditor; using UnityEngine; public static class BundleBuilder { [MenuItem(Tools/Build AssetBundles/Android)] public static void BuildAndroid() { BuildForTarget(BuildTarget.Android); } [MenuItem(Tools/Build AssetBundles/WebGL)] public static void BuildWebGL() { BuildForTarget(BuildTarget.WebGL); } private static void BuildForTarget(BuildTarget target) { string outputDir $Assets/StreamingAssets/ab/{target}; if (!Directory.Exists(outputDir)) Directory.CreateDirectory(outputDir); var options BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.DeterministicAssetBundle; var manifest BuildPipeline.BuildAssetBundles(outputDir, options, target); if (manifest null) { Debug.LogError(AssetBundle构建失败); return; } AssetDatabase.Refresh(); Debug.Log($AssetBundle构建完成: {outputDir}); } }这里有两个关键选项。ChunkBasedCompression对应LZ4压缩加载时可以按需读取不需要像LZMA那样整包解压再载入内存移动端上启动速度和首次OpenUI的响应都会好很多。DeterministicAssetBundle保证相同内容构建出的Bundle哈希稳定对增量更新和热更比对很重要。如果用LZMA压缩包体还能再小一点但运行时Unity会把整包解压成LZ4缓存首次加载会有明显延迟。我的建议是StreamingAssets本机互动的Bundle统一用LZ4只有放在远程下载、需要极限压缩的Bundle才用LZMA下载完成后在本地再解压存成LZ4。3.4 第四步运行时异步加载与缓存字体Bundle加载要避免一个坑不要在每次打开界面时都LoadFromFileAsync一次。AssetBundle加载有引用计数重复加载同一路径会创建多个Bundle实例内存占用直线上升不说卸载还容易出错。我的做法是做一个常驻字体管理器用字典缓存已加载的Bundle和字体资产using System.Collections.Generic; using System.IO; using Cysharp.Threading.Tasks; using TMPro; using UnityEngine; public class TMPFontBundleLoader : MonoBehaviour { public static TMPFontBundleLoader Instance { get; private set; } private readonly Dictionarystring, AssetBundle _bundleCache new Dictionarystring, AssetBundle(); private readonly Dictionarystring, TMP_FontAsset _fontCache new Dictionarystring, TMP_FontAsset(); private void Awake() { if (Instance null) Instance this; } public async UniTaskTMP_FontAsset GetFontAsync(string bundleName, string assetName) { if (_fontCache.TryGetValue(assetName, out var cached)) return cached; AssetBundle bundle null; if (!_bundleCache.TryGetValue(bundleName, out bundle) || bundle null) { #if UNITY_WEBGL // WebGL没有本地文件IO改用UnityWebRequestAssetBundle见第5节 #else string path Path.Combine(Application.streamingAssetsPath, ab, bundleName); var request await AssetBundle.LoadFromFileAsync(path).ToUniTask(); bundle request; #endif _bundleCache[bundleName] bundle; } var font bundle.LoadAssetTMP_FontAsset(assetName); _fontCache[assetName] font; return font; } }如果项目里没装UniTask可以改成协程或者async/await思路是一样的字体加载完成前需要用到这个字体的UI界面可以先显示Loading或默认字体等回调再刷新Text。注意LoadAssetTMP_FontAsset方法返回的字体资产直接赋给TextMeshProUGUI.font即可生效。但要注意TMP的文本组件在赋值font之后需要重新调用SetAllDirty()或者更新一次文本否则部分情况下面板上的字形不会立刻刷新。4. 运行时字体管理与切换的正确姿势4.1 启动预加载与回退策略字体Bundle最好在启动早期加载但不用一进游戏就把所有语言和所有字重全部加载进来。我的习惯是启动阶段只预加载主字体和弹窗通用字体其他装饰性字体等实际用到时再异步加载。如果启动时字体还没加载完成UI会先显示默认字体。这里要给UI系统做一个字体替换回调TextMeshProUGUI组件在初始化时会读取当前全局字体Key如果字体Key对应的字体资产还没加载完先回退到内置默认字体等加载完成后再统一替换成目标字体并强制刷新所有正在显示的文本。这个回调机制建议做到一个单独的UI工具类里而不是让每个Text组件自己处理否则几十个界面改起来会疯掉。我项目里是一个UIFontManager单例维护一个Dictionarystring, TMP_FontAssetUI组件通过GetFont()访问加载完成后触发事件所有订阅的Text刷新。4.2 Fallback链与多语言切换TMP本身支持Fallback Font Assets也就是当前字体缺少某个字符时自动去fallback列表里找。这个机制很适合多语言项目主体用主字体日文假名或韩文谚文放fallback字体这样不用每套语言都烘焙一套全量字符的字体。在TMP_FontAsset的Inspector面板里可以给字体资产添加多级Fallback。我的建议是Fallback层级最多两层太深了字符查找的性能开销反而会盖过多语言的收益。另外要注意如果主字体是动态字体fallback也建议用静态字体否则运行时还是可能rebuild卡顿。多语言切换时不要遍历所有Text组件去改font那样不仅慢还容易漏。正确做法是统一替换TMP_Settings的默认字体Asset或者让UIFontManager持有当前语言对应的字体配置表Text组件初始化时直接读这张配置表切换语言后向所有Text发布一次刷新事件。4.3 什么时候卸载、怎么卸载字体Bundle不像场景资源不值得频繁卸载。我的经验是如果项目没有热更换字体需求字体Bundle在整个游戏生命周期内常驻尽量不要卸载。反过来如果涉及到多语言包切换或者版本热更需要卸载旧字体包一定要小心引用状态。卸载时用bundle.Unload(false)不要直接Unload(true)。false表示从Bundle对象上释放原生资源但不对已加载的Asset做强制释放正在显示的TMP文本不会立刻变粉或变消失。等所有引用旧字体的UI对象都销毁之后再调一次Resources.UnloadUnusedAssets()做延迟清理。如果场景切换时旧UI还没销毁完新UI已经需要新字体这时候最容易出现“字体突然消失”或者“UI变粉”。解决办法是在旧字体卸载前先把所有还活着的Text组件切换到新字体或默认字体做一次引用转移。这一步必须在卸载线程里同步完成不能等异步。5. 打包和运行中常见的坑我都给你踩过了5.1 方框字 / 缺字静态字体最怕的就是字符集不全。打开界面发现一堆方块立刻检查两个地方一是字符是否在烘焙的txt文件里二是当前字体Asset的Fallback列表是否为空。排查时可以先在Font Asset Creator里把整段出问题的文案粘贴进去点Generate看是否提示缺字。如果缺字要么把新字符加入字符集重新烘焙要么给这个字体加一个包含这些字符的fallback字体。注意重新烘焙后要重新打Bundle并确保客户端加载的是新包。还有一类情况是UI文本用了动态字体但动态字体内部也用了一个缺少某些字符的静态fallback日志里会疯狂刷“Missing character in dynamic font”警告。这类警告不能忽略它说明TMP在尝试rebuild字形只是rebuild没有成功或者找不到合适的字形。5.2 加载后内存翻倍 / 不释放字体Bundle加载后内存翻倍最常见的原因是同一个Bundle路径被LoadFromFileAsync多次Unity没有复用之前的AssetBundle实例。检查方式在Profiler里搜索AssetBundle如果看到多个相同路径的记录就是重复加载了。另一个隐蔽原因是字体纹理被当成普通纹理再次加载了一份。TMP字体Asset和它的Texture在Bundle里是分开的资源如果加载字体Asset之后又手动加载了Texture而Texture本身也被其他Bundle引用就有可能出现两份纹理拷贝。解决办法始终通过TMP_FontAsset加载不要直接操作它内部的Texture也不要手动给字体纹理设置Bundle名之外的第二个Bundle引用。还有一点如果用AssetBundle.LoadFromMemoryAsync加载字体Bundle内存会比LoadFromFileAsync高一截因为要先把字节流拷贝进托管堆再解析。除非是加密Bundle需要自定义解密流程否则一律用LoadFromFileAsync。5.3 WebGL、Android平台的特有问题WebGL上最容易踩的坑是Application.streamingAssetsPath路径在浏览器里不可用。Unity WebGL没有本地文件系统StreamingAssets实际存放在远程服务器上直接用LoadFromFileAsync会加载失败并伴随类似IndexedDB写入失败的报错。正确做法是用UnityWebRequestAssetBundle.GetAssetBundle加载using UnityEngine; using UnityEngine.Networking; public class WebGLBundleLoader { public static async void LoadBundle(string bundleUrl) { using var request UnityWebRequestAssetBundle.GetAssetBundle(bundleUrl); await request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { var bundle DownloadHandlerAssetBundle.GetContent(request); var font bundle.LoadAssetTMP_FontAsset(mainfont); // 使用后不要马上Dispose保留Bundle引用 } } }WebGL下还有浏览器索引数据库存储限制的问题。如果字体Bundle比较大又没有提前申请足够配额加载到一半会失败。解决办法是给AssetBundle请求增加一个最大缓存大小提示让浏览器主动扩展存储配额如果还是失败可以提示玩家清理浏览器缓存。Android端的坑主要在纹理压缩上。TMP的字体纹理默认是Alpha8格式这本身是正确的它保存的是SDF距离场数据不是普通颜色。但如果构建时Unity把字体纹理自动压缩成了ETC2或ASTC距离场数据会被破坏字体会明显发虚、发糊甚至出现明显毛边。解决办法在纹理导入设置里把字体纹理的格式设为不压缩或者在构建脚本里排除字体纹理的压缩。PICO4这类XR设备上我遇到过字体图集在双屏渲染时显存占用翻倍的问题。这属于VR渲染管线本身的行为但更稳妥的做法是尽量让SDF图集小一点2048优先避免用到4096大图集否则单眼一张、双眼两张显存压力会非常明显。5.4 常见问题速查表现象可能原因处理方式UI显示方块或问号字符不在字体atlas中重新烘焙字符集或添加fallback字体日志刷Missing character警告动态字体rebuild失败改用静态字体或检查fallback链加载Bundle后台内存翻倍同一Bundle重复LoadFromFile用字典缓存Bundle实例字体发虚、毛边字体纹理被压缩纹理设置为Uncompressed排除自动压缩WebGL加载Bundle失败不支持本地IO路径改用UnityWebRequestAssetBundleAndroid偶发加载慢IO线程延迟首次启动拷到persistentDataPath再加载卸载后UI变粉Unload(true)强制释放改用Unload(false)延迟清理6. 优化前后对比与一点个人心得6.1 实测数据变化这是我最近一个Unity 2022项目做字体优化前后的实测数据硬件是小米13仅供参考项目优化前优化后首包内字体相关资源约280MB多Bundle重复引用约18MB独立字体Bundle运行时字体纹理内存约96MB10个动态字体各存图集约8MB2个静态字体图集打开新UI的卡顿次数频繁每进一个新界面都可能rebuild基本没有卡顿字体热更支持不支持改字体要发整包支持只替换字体Bundle多语言切换耗时需要重新加载场景秒切只调字体接口第一行“首包内字体相关资源”差距巨大是因为优化前多个UI预制体各自把字体当成自己的依赖打进了不同BundleUnity没法去重。优化后字体只占一个独立包其他Bundle全部通过依赖引用它包体立刻瘦身。运行时字体纹理内存从96MB降到8MB主要靠静态字体和字符集控制。少做一个动态字体往往比多压缩一张图集还有效。6.2 这套方案的使用边界AssetBundle不是银弹它解决的是“资源可拆分、可替换、按需加载”的问题。如果你的项目没有热更、没有多语言、字体也就两三个那么只需要做好一件事确保所有UI预制体引用同一个字体资产不要被Unity重复打进多个Bundle即可。这时候甚至不需要单独打字体Bundle直接让字体作为整个包体的共享依赖就行。反过来如果你的项目要做多语言、要做热更、要控制首包体积那独立字体Bundle就是刚需。它不只是“省内存”更重要的是把字体的生命周期、加载时机、替换路径都掌握在自己手里。决定用动态字体还是静态字体也要看场景。聊天室这种内容完全不可控的输入场景动态字体天然适合但要做预热和字符过滤。常规的UI界面、活动运营页、菜单弹窗文案都是可预期的全部走静态字体更稳。6.3 最后一个小技巧如果打包后发现Bundle里莫名其妙多了一个“LiberationSans SDF”之类的内置字体多半是TMP Essentials资源残留。TMP第一次导入时会生成Essential Resources里面包含默认的LiberationSans SDF字体和TMP Settings即使你项目里没用到这个字体它也可能被打进包体。解决方法是打开TMP Settings把Default Font Asset改成你自己打包的字体资产或者直接在构建脚本里排除TMP内置字体资源目录。这个操作能再省下几MB到十几MB具体取决于你用的是哪个TMP版本和内置字体的尺寸。另外字体图集的Padding值建议调成9尤其在开了Outline的SDF材质下。Padding太小会导致相邻字形互相污染字体边缘出现不规则的杂色。这个问题在AssetBundle打包后尤其隐蔽因为编辑器和Bundle里的材质表现可能不一样调试时用真机同平台测试才能暴露出来。
返回列表