做游戏界面开发的人应该都有这种体验项目做到中后期美术或者产品突然说“整体换一套字体”听起来好像就是改个设置的事但实际一动手才发现场景里、Prefab里、甚至已经打好的包里到处都是一个一个手动指定过的字体引用。真要一个个去点开Text组件替换几十个场景几百个界面点一晚上都不一定点得完。我最早遇到这个需求是在一个UI改动特别频繁的项目里当时硬着头皮手动换了两个多小时还漏了一堆Prefab里的引用后来实在受不了了才下定决心写了一个Unity换字体的批处理工具。这篇文章就把这个工具从需求分析、代码实现到落地避坑完整拆开讲一遍。核心思路一句话写一个编辑器扩展扫描当前项目里的场景和Prefab把所有指向旧字体的Text组件一次性替换成新字体。不管你是维护老项目的开发还是要做字体改版的独立开发者这套方案都能直接抄作业。我会把完整代码、放置位置、使用步骤和踩过的坑都写清楚照着做基本就能告别手动换字体的噩梦。1. 需求场景什么时候你会迫切需要一个批量换字体工具1.1 三类高频场景先说结论批量换字体不是一个小众需求遇到这些情况时你会发现手动操作远比你想象中痛苦。第一类是界面全量改版。项目做到中期UI风格大改整套字体从黑体换成圆体或者从思源系列换成其他商用字体。这种改动通常是全局性的不管是什么界面所有Text组件的字体都要换。看起来每个Text就是下拉列表里选一下但几十个场景、成百上千个Text组件操作量就非常可怕了。第二类是字体版权风险问题。项目上线前法务突然通知当前用的某个字体不能商用必须全部替换。这种时候时间紧、压力大一晚上就要改完所有界面手动点根本来不及而且非常容易漏。漏一个意味着打包后的游戏里还带着侵权字体风险不可控。第三类是多语言和自定义字体方案调整。有些项目为了兼容多语言会在某些界面动态切换字体还有的项目会用动态字体或者TextMeshPro切换时引用关系更复杂。这类需求虽然不一定是全局换但因为涉及的范围往往跨多个预制体和场景手动维护也很容易混乱。1.2 手动一个个替换为什么不可持续很多人觉得手动替换也没什么反正就是选中Text组件然后改一个字段而已。但只要真正试过就会发现这个操作有几个隐藏的大坑。首先是“防不胜防的遗漏”。Unity里Text组件的字体引用是序列化在场景或Prefab文件里的你没法像搜索脚本字符串一样全局搜到。手动改只能一个一个场景地打开、检查、替换改完当前场景以为结束了切到另一个场景发现又有一堆而且那种嵌套在预制体里的界面不点开根本看不到。这种“我以为改完了但其实没有”的状态是最让人崩溃的。其次是这样改很容易误操作其他属性。手动替换时需要在Inspector里找到Font字段滚动、下拉、选择。如果界面复杂你可能会不小心动到旁边的富文本、对齐方式、字体大小等参数改完还不一定记得。批处理脚本就不会有这个风险因为它只做一件事把A字体换成B字体。再有就是项目历史包袱。老项目里字体引用往往不是统一的一半用默认的LegacyRuntime.ttf一半用的某个资源字体。手动清理这种历史债务基本是体力活而且换完之后也没法快速统计还有多少遗漏。所以这类需求的最佳解法就是写一个能在编辑器里一键执行替换的批处理脚本。这也是我接下来要展开的重点。2. 方案选型编辑器扩展是这一类需求的正解2.1 编辑器批处理与运行时换字体的边界一说到“换字体”有些人第一反应是写运行时脚本在游戏初始化时把所有Text字体改成目标字体。这个方案在演示Demo里跑得通但在真实项目里基本不可取原因有二。运行时替换意味着每次启动游戏都要做一次遍历和赋值如果界面很多启动时会有可见的性能损耗更关键的是你不能保证所有界面在初始化时都已经被实例化出来动态加载的Prefab、后加载的场景都不受控。而且运行时维护一套替换逻辑等同于给项目长期背着一个隐藏包袱任何新界面如果忘了接入这套字体替换逻辑就会显示成旧字体。编辑器扩展恰好绕开了这些问题。它是直接在资源文件层面改写引用改完保存之后字体就已经固定成新字体不依赖任何运行时代码。这对“全量替换字体”这个需求来说是最直接、最彻底的方案。那什么时候才考虑运行时方案呢一般是“字体跟随账号设置变化”“多语言切换字体”“玩家自定义字体”这类动态需求那属于另一套架构问题不在本文讨论范围。2.2 核心难点拆解引用查找与批量改写既然要做编辑器扩展核心其实就两个技术点找到所有引用旧字体的Text组件然后把它们改成新字体。先说查找。Unity编辑器里想要拿到场景里所有Text组件最常用的API是FindObjectsOfTypeText()。但这里有个坑——这个接口默认只返回激活状态的对象禁用状态的GameObject以及预制体里的对象根本拿不到。如果要覆盖包括未激活对象在内的所有组件就得用Resources.FindObjectsOfTypeAllText()。它会返回包括场景和项目资源中所有已加载的Text组件包括那些隐藏的、未激活的。这个细节极其重要我第一次写脚本时就因为用错了API漏掉了所有被禁用的对象。再说改写。找到组件之后只要判断组件当前的font是不是旧字体如果是就赋值成新字体然后通过EditorUtility.SetDirty标记该对象需要保存。这里要注意在脚本里写“判断当前字体是否等于旧字体”而不是无脑赋值。为什么要判断因为有些Text组件可能已经换过字体了、或者是项目里有意保留的其他字体无脑赋值会把不需要动的字体也改掉破坏原有的定制逻辑。这两个核心点解决之后剩下的就是工程化的事怎么让这个功能好用、覆盖全、能复用到整个项目。3. 完整实现一个可落地的换字体编辑器工具3.1 先解决“选字体”的问题写一个可视化窗口最直接的实现方式当然是写一个MenuItem菜单点一下弹个对话框但这种方式需要把旧字体和新字体提前写死在代码里不够灵活。更好的做法是做一个简单的EditorWindow用ObjectField让使用者自己拖拽两个字体资源然后点击按钮执行替换。这样换不同字体组合时就不用改脚本。在项目中新建一个Editor目录如果没有的话然后创建一个C#脚本名字可以叫FontReplacerWindow.cs。注意一定要放在Editor文件夹下否则会打进发布包。using UnityEditor; using UnityEngine; public class FontReplacerWindow : EditorWindow { private Font oldFont; private Font newFont; [MenuItem(Tools/Font Tools/Font Replacer)] public static void OpenWindow() { FontReplacerWindow window GetWindowFontReplacerWindow(); window.titleContent new GUIContent(Font Replacer); window.minSize new Vector2(380, 240); window.Show(); } private void OnGUI() { EditorGUILayout.Space(); EditorGUILayout.LabelField(字体批量替换工具, EditorStyles.boldLabel); EditorGUILayout.Space(); oldFont (Font)EditorGUILayout.ObjectField(旧字体, oldFont, typeof(Font), false); newFont (Font)EditorGUILayout.ObjectField(新字体, newFont, typeof(Font), false); EditorGUILayout.Space(); if (GUILayout.Button(替换当前场景)) { ReplaceFontInActiveScene(); } if (GUILayout.Button(替换构建列表中的所有场景)) { ReplaceFontInAllBuildScenes(); } if (GUILayout.Button(替换项目中所有Prefab)) { ReplaceFontInAllPrefabs(); } if (oldFont null || newFont null) { EditorGUILayout.HelpBox(请先指定旧字体和新字体, MessageType.Warning); } } }这里把三个入口都放进了同一个窗口替换当前场景、替换构建列表场景、替换项目中所有Prefab。窗口用ObjectField接收字体资源操作上完全不需要打开脚本改代码很直观。注意字体类型这里用的是Legacy字体Font。如果你的项目用的是TextMeshPro那需要把类型换成TMP_FontAsset组件换成TextMeshProUGUI后面我会单独讲扩展。3.2 当前场景批量替换主循环与脏标记第一个核心功能是替换当前打开的场景。逻辑很直接拿到场景里所有Text组件逐一判断字体引用匹配旧字体就改成新字体最后标记场景为脏提醒保存。using UnityEditor.SceneManagement; using UnityEngine.SceneManagement; using UnityEngine.UI; private void ReplaceFontInActiveScene() { if (!CheckFonts()) return; Text[] allTexts Resources.FindObjectsOfTypeAllText(); int count 0; foreach (Text text in allTexts) { if (text.gameObject.scene ! SceneManager.GetActiveScene()) continue; if (text.font oldFont) { text.font newFont; EditorUtility.SetDirty(text); count; } } EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene()); AssetDatabase.SaveAssets(); EditorUtility.DisplayDialog(完成, $当前场景已替换 {count} 个Text组件, 确定); }这里有三个容易出错的地方我重点解释一下。为什么要用Resources.FindObjectsOfTypeAllText()而不是FindObjectsOfTypeText()因为前者能把场景里禁用状态的GameObject上的Text也找出来。很多界面在初始状态下是隐藏的用后者就会漏掉它们。为什么要判断text.gameObject.scene ! SceneManager.GetActiveScene()因为Resources.FindObjectsOfTypeAll会把项目中已加载的所有资源里的Text都返回包括那些没有在场景里的预制体。如果不做这个过滤你可能会把某些不该动的Prefab也改了。通过判断对象所属的场景文件路径可以确保我们只处理当前场景里的组件。为什么要对每个Text调用EditorUtility.SetDirty因为Unity的资源系统只有在对象被标记为dirty之后保存时才会真正把改动写回文件。如果只改内存里的值不标记保存场景时这些改动会丢失。这个细节特别容易踩坑尤其是场景里嵌套Prefab实例时不标记可能会导致改动不生效。3.3 把替换范围扩展到整个构建场景列表单场景替换解决了打开一个场景改一个场景的问题但全项目换字体就需要能一次性处理所有场景。Unity的编辑器接口支持遍历EditorBuildSettings.scenes逐个打开、替换、保存循环处理。private void ReplaceFontInAllBuildScenes() { if (!CheckFonts()) return; int totalCount 0; EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); foreach (EditorBuildSettingsScene scene in EditorBuildSettings.scenes) { if (scene null || string.IsNullOrEmpty(scene.path)) continue; Scene openedScene EditorSceneManager.OpenScene(scene.path, OpenSceneMode.Single); Text[] allTexts Resources.FindObjectsOfTypeAllText(); int sceneCount 0; foreach (Text text in allTexts) { if (text.gameObject.scene ! openedScene) continue; if (text.font oldFont) { text.font newFont; EditorUtility.SetDirty(text); sceneCount; } } EditorSceneManager.MarkSceneDirty(openedScene); EditorSceneManager.SaveScene(openedScene); totalCount sceneCount; Debug.Log($场景 {scene.path} 替换完成{sceneCount} 个Text组件); } AssetDatabase.SaveAssets(); EditorUtility.DisplayDialog(完成, $共替换 {totalCount} 个Text组件, 确定); }这一步和单场景的核心逻辑一样区别在于多了一个遍历场景列表、自动保存场景的流程。有一个重要的安全措施是在处理之前先调用SaveCurrentModifiedScenesIfUserWantsTo()它会弹出系统对话框询问是否保存当前未保存的场景。这一步必须做否则一旦脚本开始打开其他场景当前场景未保存的内容就会直接丢失那就真的事故现场了。这里我还加了一个日志输出每处理一个场景都打印一次替换数量。原因很现实几十个场景批量处理时如果中间某个场景有异常日志能帮你快速定位到底是哪个场景出了问题而不是跑完之后只知道一个总数。3.4 从资源层面扫清Prefab里的漏网之鱼场景处理完不代表万事大吉。项目里大量UI是做成Prefab的这些Prefab被多个场景引用。如果只改场景不改Prefab重新实例化时字体又会变回旧字体。所以需要一个遍历项目所有Prefab的逻辑。private void ReplaceFontInAllPrefabs() { if (!CheckFonts()) return; string[] guids AssetDatabase.FindAssets(t:Prefab); int prefabChangedCount 0; int totalCount 0; foreach (string guid in guids) { string assetPath AssetDatabase.GUIDToAssetPath(guid); GameObject prefab AssetDatabase.LoadAssetAtPathGameObject(assetPath); if (prefab null) continue; Text[] texts prefab.GetComponentsInChildrenText(true); bool modified false; foreach (Text text in texts) { if (text.font oldFont) { text.font newFont; EditorUtility.SetDirty(prefab); modified true; totalCount; } } if (modified) { prefabChangedCount; } } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); EditorUtility.DisplayDialog(完成, $已处理 {prefabChangedCount} 个Prefab共替换 {totalCount} 个Text组件, 确定); }这里用AssetDatabase.FindAssets(t:Prefab)拿到项目中所有Prefab的GUID然后逐个通过GUIDToAssetPath转换成资源路径加载Prefab后获取其所有子物体上的Text组件。注意GetComponentsInChildrenText(true)里的参数true它表示包括未激活的子物体这个不带的话又会漏掉默认隐藏的UI节点。处理Prefab时有一个和场景不同的重要细节不能直接保存场景而是要用EditorUtility.SetDirty(prefab)标记这个Prefab资源最后再统一调用AssetDatabase.SaveAssets()把所有改动的资源写回磁盘。很多新手在改资源时会直接AssetDatabase.SaveAssets()不调用SetDirty结果保存后改动还是丢了。其实逻辑很简单改Prefab就标记Prefab改场景就标记场景最后保存。4. 实操复盘从新建脚本到一键完成的完整流程4.1 脚本放置与窗口初始化新建脚本时最容易犯的错就是放错目录。Editor脚本必须放在名为Editor的文件夹下可以是根目录下的Assets/Editor也可以是任意子目录下的Editor文件夹但后缀必须是Editor才会被识别为编辑器专用代码否则会编译进发布包甚至报错。脚本创建好之后Unity会自动刷新编译。等编译结束顶部菜单栏就会出现Tools/Font Tools/Font Replacer。点击之后一个可拖拽字体资源的窗口就会弹出来。这个窗口可以用CommandShiftF快捷调出Windows下是CtrlShiftF会与全局搜索冲突建议直接用菜单启动也可以拖到Editor窗口边缘停靠像Inspector一样常驻用起来更顺手。我在项目里一般会把这个窗口放在Inspector旁边因为换字体可能需要反复操作几次先把A字体换成B字体做一轮检查之后又把B换成C。窗口常驻会让整个操作节奏快很多。4.2 实际执行步骤与效果验证以一次完整的字体改版为例我建议的操作顺序是第一步在窗口里分别拖入旧字体和新字体。如果旧字体是项目自带的LegacyRuntime.ttf可以直接从Packages里拖进来也可以用Assets里的自定义字体文件。第二步先执行“替换当前场景”验证效果。打开一个有代表性的界面场景点击按钮看提示框里的替换数量是否符合预期。随便选中几个Text组件确认Font字段已经指向新字体。第三步验证无误后执行“替换构建列表中的所有场景”。这里会等一段时间场景越多耗时越长。期间Unity会逐场景打开、替换、保存所以不要在替换过程中手动点击其他界面容易造成保存冲突。第四步执行“替换项目中所有Prefab”处理场景外的预制体引用。第五步打开版本管理工具检查改动范围。我习惯在Git里看一遍确认改动的都是.unity场景文件和.prefab资源文件没有意外的其他资源被改动。如果发现某些Prefab被无理由改动了那可能是替换逻辑误改了本来不需要动的资源需要回退部分文件。4.3 换字体后的必要检查项字体换完之后如果直接去游戏里看效果很容易看到一片乱码或者缺字。这不是替换失败了而是有几个容易忽略的后续事项。第一个是检查字体资源的导入设置。不同字体文件在Unity里的导入参数不一样比如字形大小、字符集、是否开启动态字体等。如果你的新字体导入设置和旧字体不同界面排版可能变形甚至某些字符直接显示不出来。所以我一般会先把新字体在Unity里导入确认Font Asset在Inspector里的预览正常再进行批量替换。第二个是处理Dynamic Font的纹理缓存问题。如果项目用动态字体替换后同一时间显示的字符过多字体会触发重建纹理。这在Profiler里能看到明显的开销峰值。理想做法是在替换前就设计好字体大小区间或者换成字符集充足的静态字体方案但这属于项目级决策不在工具范围内。第三个是运行时字体引用的残留。部分脚本可能在运行时通过Resources.Load或者其他方式动态给Text设置字体。这类代码引用不会被编辑器批处理扫描到。替换后需要全局搜索代码里是否还写了旧字体的名字如果有就一并改掉。补一个简单的检查方法在项目里搜索旧字体的资源名看除了字体文件本身之外还有哪些代码或Prefab引用了它逐项排查。5. 常见问题与排查记录5.1 问题速查表我把实际使用这个工具过程中遇到的高频问题整理成了一张表方便遇到问题直接对号入座。现象可能原因处理方法替换按钮点了之后替换数量为0旧字体选择错误或者当前场景确实没有引用旧字体的Text检查窗口里的旧字体是否正确再在场景里随机选几个Text看字体引用替换后部分Text的字体没变这些Text可能在Prefab里不在当前场景内执行“替换项目中所有Prefab”替换后字体变了但保存后重新打开又变了回去改的是Prefab实例但只保存了场景没有保存Prefab执行Prefab替换并调用AssetDatabase.SaveAssets()替换数量巨大远超过预期Resources.FindObjectsOfTypeAll把项目Assets里的Prefab也统计了但代码里已经过滤了场景检查过滤条件是否生效确认统计范围是期望值替换过程中Unity卡死或长时间无响应场景数量多单个场景Text组件数量大处理耗时耐心等待不要强杀进程如果经常处理超大规模项目可以考虑按场景分批执行替换后界面排版错乱新字体字形宽度和旧字体不同部分界面没有做自适应检查字体导入设置如果有需要可以同步调整Text的fontSize或者字体资产5.2 我踩过的几个深坑第一个坑是“替换了等于没替换”。第一次写完脚本我高高兴兴地点了执行按钮看到替换数量是几千以为搞定了。结果重新打开一个场景发现字体还是旧的。排查了很久才发现问题出在EditorUtility.SetDirty上。场景里有些Text是Prefab实例只标记场景为dirty是不够的Prefab本身的改动没有标记保存场景时只写了场景级别的覆盖Prefab资源本身还是旧字体。后来我同时标记场景和Prefab资源再统一SaveAssets问题才彻底解决。第二个坑是“误改了不该改的字体”。项目里有某个界面特意用了另一种字体做视觉区分我写脚本的时候图省事判断条件写成了只要是Text就赋值结果把那个特意定制的界面字体也全覆盖了。后来在判断里加了一个条件只有当text.font等于旧字体时才替换同时把旧字体引用不匹配的资源过滤掉。这个教训就是批处理脚本的逻辑越保守越安全宁可漏一些不要误伤。第三个坑是“没提前保存当前场景”。当时项目里有一个场景改了一堆东西还没保存我直接跑了批量处理场景的功能Unity弹出提示问我要不要保存我习惯性点了“Don‘t Save”。结果就是那个场景的所有工作白费了。从那以后我脚本里就一直保留着SaveCurrentModifiedScenesIfUserWantsTo()这个调用宁可多一个确认步骤也不冒这个险。第四个坑比较隐蔽动态字体和静态字体的引用替换顺序。如果你从一个Dynamic字体换到一个非Dynamic字体新字体没导入字体资产预览批量替换后所有Text会显示为方块或空白。所以执行替换前一定先确认新字体在Inspector里能正常预览最好直接拖一个Text手动试一次。最后再分享一个小技巧这个工具的思路完全可以平移到其他批量替换需求上。把Text改成Image把Font改成Sprite脚本骨架几乎不用动就能变成一个批量替换图片资源的工具。我在项目里还做过批量替换材质、批量修改Text组件的字体大小、批量清空所有Text的富文本标签原理都是一样的用Resources.FindObjectsOfTypeAll找对象判断旧引用赋新引用标记资源保存。掌握了这套套路Unity里大部分“批量改某种资源引用”的活你都能几分钟写出来。
阅读完成 · 觉得有帮助?