☰
Unity一键批量替换UI字体:从UGUI到TMP的编辑器工具实战
2026/10/17 1:24:30 网站建设 项目流程

做Unity项目的都知道,UI字体这东西平时没人关注,一旦要换,就是一场灾难。前阵子接了个维护项目,场景加起来快三十个,预制体堆了几个文件夹,里面UI文本组件什么类型的都有。甲方提的需求很简单:全局换一套字体。我一开始天真地想,一个个改也就几个小时。结果打开场景一看,光TextMeshPro的文本组件就几百个,还没算老式的UGUI Text。

这种需求其实很常见,尤其是中大型项目走到后期,品牌调整、多语言适配、版权规避,都可能要整体换字体。手动替换不光慢,还会漏,场景里那些默认隐藏的弹窗、预制体里的组件、甚至没放进Hierarchy的模板,稍不留神就漏掉。所以我在那次之后专门写了一个Unity编辑器小工具,选中新字体,一键扫描场景和预制体,自动把所有UGUI Text和TextMeshPro的字体引用全部换掉。今天就把这个工具的完整思路和代码分享出来。

1. 换字体这件事为什么成了团队里的麻烦事

1.1 手动替换从半小时变成一整天的历程

手动换字体的流程听起来很简单:选中一个Text组件,把Font字段拖成新字体,完事。单个组件操作确实不超过十秒,但问题在于数量。一个中等规模的项目,三十个场景,每个场景平均五六十个文本,算下来就是一千五百个左右组件。还没算预制体,还没算那些放在动态加载列表里的UI模板。按每个组件十秒来算,光动手改就是四个小时打底,中间还要打开场景、保存、提交版本库,实际消耗远超预期。

更折磨人的是检查遗漏。手动替换没有“统计”概念,你不知道到底改了多少个、还剩多少个。往往以为自己改完了,结果一运行,某个常驻界面里的弹窗文字还是老字体。这类弹窗通常是默认隐藏的,得专门打开目录找出来,这时候已经接近崩溃了。我见过有人为了这种事加班到凌晨,就为了排查那十来个漏网的文本组件。

所以这类需求本质上不是技术难度问题,而是机械劳动量问题。人的注意力有限,重复操作越多越容易出错,而脚本恰恰不怕重复,数量越多优势越明显。

1.2 UGUI、TMP两套体系,坑也是双份

Unity里的文本组件从来就不是一套。老项目用Unity自带的UGUI Text,用的是Font类型资源,一般是ttf或者otf;新项目或者老项目后期改造过,主力是TextMeshPro,也就是TMP,字体引用则变成TMP_FontAsset,那是经过SDF点阵烘焙的一套独立资产。

两套体系并存意味着换字体时要准备两份字体资源。一份Font给Text用,一份TMP_FontAsset给TMP组件用。很多人栽在这里:把ttf拖给TMP的字体字段,Unity根本不认;或者手动把TMP字体字段清空,指望它自动回落,结果整个文本变成不可见。

还有更隐性的问题。TMP字体资产自带Atlas贴图、材质参数、Fallback列表,只替换主字体并不代表万事大吉。如果新字体烘焙的字符集不完整,或者Fallback没配置,屏幕上就会出现一堆空心方块,那效果比不换还难看。这些情况用纯手动作业很难系统地处理,必须靠脚本统一做强化检查。

1.3 为什么“运行时替换”不是好方案

有人可能会说,既然手动改这么多,那我写个运行时脚本,启动时遍历所有Text组件,把font字段改成新字体不就行了?这个思路我最初也考虑过,甚至见过一些项目真就这么干。但运行时替换存在几个绕不开的问题。

第一,运行时改的是内存对象,不改变场景文件和预制体的序列化数据。你每次进游戏都要重新执行一遍脚本,明显是给正式包留了一颗定时炸弹。一旦场景加载顺序调整,或者对象被复用,没跑脚本的界面就露馅。第二,运行时替换会给所有UI对象增加额外的依赖和初始化逻辑,遇到AssetBundle加载的资源还得额外处理,否则字体引用依然指向包里的旧资产。第三,帧率影响虽然小,但确实存在,尤其在低端机上,启动阶段多遍历一遍所有UI组件,时间不可忽略。

真正干净的方案是在编辑器阶段就把源文件里的引用改掉。脚本一次性扫描全部场景和预制体,把字体字段直接写进序列化数据,运行时的项目代码完全不加任何逻辑。这也是我给它定位成“编辑器工具”而不是“运行时脚本”的根本原因。

2. 一键替换的方案选型:为什么我选了编辑器脚本

2.1 核心API组合:遍历、操作、保存三板斧

写这个工具之前,先把要用的Unity编辑器API理清楚。核心是四组能力的配合:遍历资源、持有引用、修改组件、保存状态。

遍历方面,场景对象用Resources.FindObjectsOfTypeAll,它跟平时用的FindObjectsOfType最大的区别是能拿到未激活对象,这对扫描隐藏弹窗至关重要。预制体文件用AssetDatabase.FindAssets("t:Prefab")拿到所有预制体的GUID,再通过AssetDatabase.GUIDToAssetPath转成资产路径。操作方面,预制体不能像场景对象那样直接拿组件改,需要用PrefabUtility.LoadPrefabContents把预制体内容加载到一个临时GameObject上,改完再PrefabUtility.SaveAsPrefabAsset写回文件。保存方面,Undo.RecordObject负责让场景里的改动可撤销,AssetDatabase.SaveAssets负责把资产修改落盘。

这四个API单独拎出来都不复杂,但把它们串成一条流水线,就是完整的一键替换逻辑。整个工具的本质,可以理解成“找出所有文本组件——判断类型——改写字体字段——保存资源”。

2.2 一条脚本的工作流程设计

工具的执行流程设计成三个阶段:参数准备、场景替换、预制体替换。参数准备阶段,在EditorWindow的OnGUI里让用户拖入新的Font和TMP_FontAsset,同时决定是否替换UGUI、是否替换TMP、是否包含未激活对象、是否处理预制体。

场景替换阶段,只处理当前Hierarchy里挂着的场景对象。这里有个容易踩的细节:Resources.FindObjectsOfTypeAll会把Assets里的预制体资源也带出来,所以必须通过gameObject.scene.IsValid()过滤一遍,只留当前场景对象。预制体替换阶段,遍历项目里所有Prefab文件,逐个打开、替换、保存。两个阶段分开,可以避免在编辑Prefab时污染场景,也让进度展示和统计更清晰。

整个流程跑完,打印替换总数和耗时。脚本第一次在这个项目里跑通时,统计结果是三百多个UGUI组件和两百多个TMP组件,总共耗时不到三秒。换成手动改,我估算至少得两三个小时。

2.3 安全设计:备份、Undo和进度反馈

批量修改是高风险操作,尤其预制体一旦保存就很难撤销。所以工具从设计上就要做几层安全防护。

场景里的改动通过Undo.RecordObject登记,发现改错了可以直接Ctrl+Z撤回来。预制体的改动没法走Undo,只能在执行前先确认版本库当前分支是干净的,必要时开一个独立的改动分支,万一跑出问题直接丢弃分支。进度反馈用EditorUtility.DisplayProgressBar,遍历预制体时显示当前处理的路径和百分比,避免大批量执行时工具看起来像卡死。取消操作不能直接杀进程,用户点取消,代码里要终止后续循环并清除进度条。

我实际跑项目之前有个习惯:先拿一个测试场景跑一遍,看看替换数量是否符合预期,再全量执行。工具本身再可靠,也不可能猜到项目里有没有特例,先小范围验证永远是稳妥的。

3. 手把手写一个一键换字体工具

3.1 准备工具脚本,划分三个功能模块

创建脚本文件前,先确认项目里装了TextMeshPro的包。如果没装,代码里using TMPro会直接编译报错,需要去Package Manager安装。这部分完成后,在项目里建一个Editor文件夹,把脚本放进去,这样它只参与编辑器编译,不会被打进正式包。

整个脚本按功能拆成三块:窗口界面、场景替换逻辑、预制体替换逻辑。窗口界面负责做参数配置,替换逻辑各负责一块执行域,互不掺和。这样的划分让后续扩展很方便,比如某天想加个黑白名单,只需要在窗口界面加字段,在替换循环里加判断,不会动到其他部分。

脚本整体是一个EditorWindow子类,通过MenuItem菜单打开。这个结构不算复杂,但把工具挂在菜单栏里,团队其他人也方便调用,不需要谁去翻脚本位置。

3.2 场景对象替换:别漏掉未激活的UI

场景替换部分的代码是核心。我直接给出可用的版本,然后逐段解释关键决策。

private int ReplaceInScene() { int count = 0; if (replaceUGUI && newFont != null) { var allTexts = Resources.FindObjectsOfTypeAll<Text>(); foreach (var text in allTexts) { if (!text.gameObject.scene.IsValid()) continue; if (!text.gameObject.activeInHierarchy && !includeInactive) continue; Undo.RecordObject(text, "Replace UGUI Font"); text.font = newFont; count++; } } if (replaceTMP && newTmpFont != null) { var allTmps = Resources.FindObjectsOfTypeAll<TMP_Text>(); foreach (var tmp in allTmps) { if (!tmp.gameObject.scene.IsValid()) continue; if (!tmp.gameObject.activeInHierarchy && !includeInactive) continue; Undo.RecordObject(tmp, "Replace TMP Font"); tmp.font = newTmpFont; count++; } } return count; }

这里有几个地方我特意处理过。第一,用Resources.FindObjectsOfTypeAll而不是FindObjectsOfType,就是为了把未激活对象也纳入扫描范围。第二,过滤条件里的scene.IsValid()非常关键,它能挡住Prefab资源那些不属于当前场景的对象,不然会出现场景替换和预制体替换重复修改同一个对象的情况。第三,Undo.RecordObject必须在赋值之前调用,顺序反了的话Undo记录不到旧值,撤销会失效。

3.3 预制体替换:改了文件记得保存

预制体的处理跟场景完全不一样。场景对象改完字段,通过Undo.RecordObject就能留在内存里,保存场景时自然落盘。预制体文件则必须用官方推荐的一套流程:LoadPrefabContents加载、修改、SaveAsPrefabAsset保存、UnloadPrefabContents释放。

private int ReplaceInPrefabs() { int count = 0; string[] guids = AssetDatabase.FindAssets("t:Prefab"); for (int i = 0; i < guids.Length; i++) { string path = AssetDatabase.GUIDToAssetPath(guids[i]); EditorUtility.DisplayProgressBar("处理预制体", path, (float)i / guids.Length); GameObject root = PrefabUtility.LoadPrefabContents(path); bool dirty = false; if (replaceUGUI && newFont != null) { var texts = root.GetComponentsInChildren<Text>(true); foreach (var text in texts) { text.font = newFont; dirty = true; count++; } } if (replaceTMP && newTmpFont != null) { var tmps = root.GetComponentsInChildren<TMP_Text>(true); foreach (var tmp in tmps) { tmp.font = newTmpFont; dirty = true; count++; } } if (dirty) { PrefabUtility.SaveAsPrefabAsset(root, path); } PrefabUtility.UnloadPrefabContents(root); } EditorUtility.ClearProgressBar(); return count; }

这段代码在预制体上用GetComponentsInChildren而不是Resources.FindObjectsOfTypeAll,是因为LoadPrefabContents拿到的临时GameObject本来就是一个独立的实例,直接查子物体就够了,不需要全局扫描,逻辑也直观。

还有个细节:dirty标记不能省。如果一个预制体里一个文本组件都没有,还硬要执行SaveAsPrefabAsset,会无端产生一次无意义的写入,拖慢整体速度。加了dirty判断后,所有没有文本组件的预制体可以零成本跳过。

3.4 界面与主入口:把三个模块串起来

界面部分用EditorGUILayout把参数画出来就行,不需要复杂布局。主入口的ReplaceAll方法负责把场景替换和预制体替换按顺序执行,最后把统计结果打到控制台。

[MenuItem("Tools/一键字体替换")] public static void OpenWindow() { GetWindow<FontReplacerWindow>("一键字体替换"); } private void OnGUI() { newFont = (Font)EditorGUILayout.ObjectField("UGUI字体", newFont, typeof(Font), false); newTmpFont = (TMP_FontAsset)EditorGUILayout.ObjectField("TMP字体", newTmpFont, typeof(TMP_FontAsset), false); replaceUGUI = EditorGUILayout.Toggle("替换UGUI Text", replaceUGUI); replaceTMP = EditorGUILayout.Toggle("替换TextMeshPro", replaceTMP); includeInactive = EditorGUILayout.Toggle("包含未激活对象", includeInactive); includePrefabs = EditorGUILayout.Toggle("包含预制体", includePrefabs); if (GUILayout.Button("开始替换")) { if ((replaceUGUI && newFont == null) || (replaceTMP && newTmpFont == null)) { EditorUtility.DisplayDialog("提示", "请先设置需要替换的字体", "确定"); return; } ReplaceAll(); } } private void ReplaceAll() { int sceneCount = 0; int prefabCount = 0; if (includePrefabs) { prefabCount = ReplaceInPrefabs(); } sceneCount = ReplaceInScene(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($"[一键字体替换] 完成,场景替换 {sceneCount} 个,预制体替换 {prefabCount} 个"); }

这里把预制体放在场景前面处理,是因为预制体遍历过程中会频繁保存资产,如果先改了场景再去做预制体,一旦预制体那边编译刷新,场景里的Undo记录可能会丢失。反过来先处理预制体,再处理场景,最后统一SaveAssets,顺序上更稳。

3.5 提升体验的两个优化点

第一,预制体数量多的时候,每一次SaveAsPrefabAsset都会触发资源刷新,整体过程会明显变慢。可以用AssetDatabase.StartAssetEditing()在循环开始前挂起导入,全部处理完再StopAssetEditing(),保存效率能提升不少。

第二,替换完字体后,建议顺手调用一次AssetDatabase.Refresh(),让引擎重新加载字体资产的最新导入设置。有些字体文件导入配置里勾选了字符集范围,改完之后不刷新可能不会立刻生效,导致显示出来的还是旧字形。

4. 实测踩坑:这些Bug我替你先踩了

4.1 未激活对象没被替换

第一次跑完工具,统计数量看着挺正常,结果一打开某个常用弹窗,里面文字还是老字体。排查了半天才发现,弹窗默认是隐藏的,而当时的扫描用的是常规的FindObjectsOfType,默认忽略未激活对象。后来统一换成Resources.FindObjectsOfTypeAll,同时保留了对includeInactive开关的支持,才把这个坑填平。

这也是为什么工具里专门加了一个“包含未激活对象”的开关。有的团队希望只替换当前活跃界面,避免误改那些还在开发中的隐藏页,这时把它关掉就行。多数情况下建议保持开启,一次性改干净,免得后期漏网。

4.2 TMP字体替换后缺字、材质异常

TMP是大户,也是最容易出幺蛾子的地方。替换完字体,有些界面开始出现空心方块,排查发现是新的TMP_FontAsset烘焙字符集不够,某些生僻字在Atlas里根本不存在。这个不是工具能自动解决的,需要去字体资产上配置Fallback Font Assets列表,把备用中文字体加进去,确保主字体缺字时有下落。

还有个问题是材质。项目里有些TMP组件被美术手动指定过材质,替换font字段后材质不会自动跟着变,出来的效果还是旧字体的贴图。这种情况检查器里选择Reset或者手动重新指定材质,才能看到新字体的渲染效果。我在工具注释里特别记了一句:替换TMP之后要跑一遍UI检查,重点盯材质和Fallback,别以为改完字段就万事大吉。

4.3 预制体保存时机问题

预制体的坑主要集中在保存时机。有人图方便,直接用AssetDatabase.LoadAssetAtPath拿到预制体资产再改组件,改完不标记SetDirty,结果资源文件没有任何变化,看起来成功了实际上什么都没保存。另一些人用LoadPrefabContents改了,但忘记调SaveAsPrefabAsset,直接UnloadPrefabContents,改动就丢了。

正确序列必须完整走一遍:Load -> 修改 -> Save -> Unload。尤其注意LoadPrefabContents返回的是一个临时对象,它不是项目原资产,不Save就不会写回文件。这个顺序一旦弄错,工具表现就是“运行了但等于白运行”,而且因为没报错,特别难排查。

4.4 常见问题速查表

问题现象常见原因解决办法
替换后某些界面字体没变对象是未激活状态,扫描时被跳过勾选“包含未激活对象”,用Resources.FindObjectsOfTypeAll
TMP界面出现空心方块新字体Atlas字符集不足给TMP_FontAsset配置Fallback Font Assets
替换后渲染效果还是旧字体组件上手动指定过材质检查器Reset材质引用,或重新指定
预制体改了但文件没变化只Load没Save,或没调用SaveAsPrefabAsset严格执行Load->修改->Save->Unload流程
替换耗时特别长保存每个Prefab都触发资源刷新用StartAssetEditing/StopAssetEditing批量处理

5. 从“换字体”到“UI换肤”:工具还能这么扩展

5.1 给工具加上黑白名单

不是什么界面都得无脑替换。比如商店页、活动页可能故意用装饰字体突出氛围,全局一换反而破坏设计。扩展方式很简单:在界面加一个开关,指定某些路径或场景名不参与替换。实现时在替换循环里加一个判断,检测路径是否包含黑名单关键字,命中就跳过。

反过来也可以做白名单模式,只处理指定文件夹下的预制体和场景。这个在实际项目里很实用,尤其是大团队分模块开发,UI资源分散在不同目录,按目录精准替换能避免影响其他人正在做的内容。

5.2 按语言自动适配字体

多语言项目里,中英文往往用两套字体。工具可以扩展成一张字体映射表,语言和字体一一对应。比如根据当前版本号或语言标识,自动选择对应的Font和TMP_FontAsset去替换。以前每次换语言都要重新走一遍手动流程,现在只需要改映射表的资源引用,再点一次“一键替换”,剩下的事情交给脚本。

这个扩展的核心不是技术,而是把“字体选择逻辑”从人脑里搬到代码里。我在多个项目里验证过,这种思路特别适合那些一个月要出好几版地区包的项目,省掉的不只是时间,还有每次人工校对的心力。

5.3 与打包流程集成的思路

一键替换工具本身是手动触发,但完全可以变成打包链路上的一环。比如接到CI脚本里,每次出包前自动执行一遍替换,保证任何分支打出来的包都用的是目标字体。实现方式不复杂:把核心的替换方法封装成一个静态方法,不依赖EditorWindow界面,CI里通过命令行参数调用即可。

这样做还有一个好处:替换逻辑和打包逻辑集中管控,团队成员在本地手动跑或者CI自动跑,用的是同一套代码,不会出现“本地换了字体,CI打包又给覆盖回去”的割裂情况。

5.4 自动化验证脚本

替换完了不代表工作结束,还要验证有没有漏网之鱼。我后来在工具里加了一个“检查”模式,不修改任何组件,只全局扫描一遍,统计还有多少组件的字体引用指向旧字体。如果检查结果不是零,日志里列出所有未替换对象的具体路径和场景名。

这个检查模式还可以配合提交钩子用,比如有人在某个界面手动把字体拖回了旧字体,提交前自动跑一遍检查就能拦下来。它本质上把“人工找漏”变成了“自动化防漏”,长期价值比替换功能本身还大。

我个人实际跑下来,第一次写这个脚本花了半天,但之后每次换字体基本就是十几秒。后来在团队里把这个工具沉淀成公共工具,大家都会用。有一点要提醒:这种批量修改工具在跑全项目之前,一定要先在版本库分支上操作,确保能回滚。另外,替换完字体之后全局跑一遍UI测试,重点看看TMP场景的文字显示是否正常,比任何代码检查都更直观。希望这个工具能帮你也省下那些本来要熬夜改字体的时间。

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

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

立即咨询