1. 项目概述与核心痛点
最近在升级一个老Unity项目到2022 LTS版本时,遇到了一个让人头疼的问题:项目里上百个预制体(Prefab)的UI文本(Text/TextMeshPro)组件,字体设置全是Arial。众所周知,Unity 2022移除了内置的Arial字体,这直接导致所有使用Arial的UI在运行时要么显示成丑陋的备用字体,要么直接不显示中文,变成一堆“口口口”。手动去Prefab里一个个改?那简直是噩梦。这个项目标题“告别Arial!手把手教你用Editor脚本批量修改Unity预制体字体(含中文支持)”,就是为解决这个在Unity版本升级、项目迁移或统一视觉规范时,普遍存在的“字体批量替换”痛点而生的。
简单来说,我们要写一个运行在Unity编辑器(Editor)环境下的脚本,它能自动扫描你指定的文件夹(比如整个Assets目录或某个UI预制体目录),找到里面所有的预制体,然后将其Text或TextMeshPro - Text (TMP)组件上使用的Arial字体,批量、安全地替换成你指定的、支持中文的新字体(如思源黑体、Noto Sans SC等)。这不仅能解决因Arial缺失导致的显示问题,更是项目资产管理规范化、提升团队协作效率的必备技能。无论你是独立开发者还是团队中的TA(技术美术)或主程,掌握这项技能都能让你从繁琐的重复劳动中解放出来。
2. 核心思路与方案设计
2.1 为什么必须用Editor脚本?
面对成百上千的预制体,手动操作的弊端显而易见:效率低下、极易出错(漏改、改错)、且无法形成可复用的流程。Unity Editor脚本的核心价值在于自动化和批处理。它允许我们将重复、有规则的操作封装成一个编辑器工具,一键执行。对于字体替换这种典型的“查找-判断-修改”任务,Editor脚本是最佳选择。它直接操作Asset数据,无需运行游戏,安全且高效。
2.2 方案选型:Text vs TextMeshPro (TMP)
Unity的UI文本系统主要有两套:传统的uGUI Text组件和更现代的TextMeshPro。我们的脚本需要同时支持两者,因为老项目可能混用,而新项目普遍推荐TMP。
- 传统Text组件:对应
UnityEngine.UI.Text类。其字体属性是Font类型。Arial在这里通常表现为一个名为“Arial”的Font资源引用。替换的本质是找到这个引用,并将其替换为另一个Font资源。 - TextMeshPro组件:对应
TMPro.TextMeshProUGUI类(用于UI)或TMPro.TextMeshPro(用于3D世界)。其字体属性是TMP_FontAsset类型。虽然TMP字体资源本身更复杂(包含图集、材质等),但替换组件上的字体引用逻辑是相似的。
我们的脚本需要能智能识别这两种组件类型,并分别进行处理。一个健壮的方案是先尝试获取TMP组件,因为TMP更常见;如果没有,再尝试获取传统Text组件。
2.3 字体资源准备:中文支持的关键
替换掉Arial,我们得找一个靠谱的“接班人”。选择新字体时,必须确保其完整支持中文(GB2312/GBK或更全的字符集)。这里有几个常见选择:
- 思源黑体 (Source Han Sans):Google和Adobe联合开发,开源免费,字重齐全,对简体中文(SC)支持极好。可以从GitHub或Adobe官网下载OTF文件。
- Noto Sans SC:Google的“Noto”字体家族中的简体中文无衬线体,同样开源免费,旨在消除所有“豆腐块”(口口口)。
- 微软雅黑:Windows系统自带,中文字形优美,但需注意版权。在Windows平台开发且目标用户为Windows用户时,可以考虑,但跨平台需谨慎。
- 其他商用字体:根据项目美术风格和版权许可选择。
准备工作:
- 将下载的
.ttf或.otf字体文件直接拖入Unity项目的Assets目录下(例如Assets/Fonts/)。 - Unity会自动将其导入为
Font资源(对于传统Text)或需要你手动创建TMP_FontAsset(对于TMP)。 - 对于TMP:在Project窗口右键点击导入的字体文件 ->
Create -> TextMeshPro -> Font Asset。这个过程会生成一个.asset文件,这就是TMP组件真正使用的字体资源。务必为你的中文字体执行此操作,否则TMP无法使用。
脚本中,我们需要提供两个字段,让使用者可以分别指定替换用的Font和TMP_FontAsset。
3. Editor脚本核心实现解析
下面我们来一步步拆解这个Editor脚本的核心代码。我会创建一个名为BatchReplaceFontTool的脚本,放在项目的Assets/Editor/文件夹下(Editor文件夹下的脚本只在编辑器中运行)。
3.1 工具界面与变量定义
首先,我们需要创建一个继承自EditorWindow的类,来绘制一个简单的工具窗口。
using UnityEngine; using UnityEditor; using TMPro; using System.IO; using System.Collections.Generic; public class BatchReplaceFontTool : EditorWindow { // 替换目标:旧字体名称(通常是"Arial") private string oldFontName = "Arial"; // 替换为的新字体资源 private Font replacementFont; // 用于传统Text组件 private TMP_FontAsset replacementTMPFont; // 用于TMP组件 // 搜索路径 private string searchPath = "Assets"; // 是否包含子文件夹 private bool includeSubdirectories = true; // 进度显示 private string progressInfo = ""; // 添加窗口菜单项 [MenuItem("Tools/批量替换字体工具")] static void Init() { BatchReplaceFontTool window = (BatchReplaceFontTool)EditorWindow.GetWindow(typeof(BatchReplaceFontTool)); window.titleContent = new GUIContent("批量替换字体"); window.Show(); } void OnGUI() { GUILayout.Label("字体批量替换工具", EditorStyles.boldLabel); EditorGUILayout.Space(); // 输入旧字体名称 oldFontName = EditorGUILayout.TextField("旧字体名称:", oldFontName); // 选择新字体资源 replacementFont = (Font)EditorGUILayout.ObjectField("新字体 (For Text):", replacementFont, typeof(Font), false); replacementTMPFont = (TMP_FontAsset)EditorGUILayout.ObjectField("新字体 (For TMP):", replacementTMPFont, typeof(TMP_FontAsset), false); EditorGUILayout.Space(); // 搜索设置 searchPath = EditorGUILayout.TextField("搜索路径:", searchPath); includeSubdirectories = EditorGUILayout.Toggle("包含子目录:", includeSubdirectories); EditorGUILayout.Space(); if (GUILayout.Button("开始扫描并替换", GUILayout.Height(40))) { if (replacementFont == null && replacementTMPFont == null) { EditorUtility.DisplayDialog("错误", "请至少指定一种新字体资源(Text或TMP)。", "确定"); return; } StartBatchReplace(); } // 显示进度或结果信息 if (!string.IsNullOrEmpty(progressInfo)) { EditorGUILayout.HelpBox(progressInfo, MessageType.Info); } } }关键点解析:
[MenuItem("Tools/批量替换字体工具")]:这行代码在Unity编辑器的菜单栏Tools下添加了一个菜单项,点击即可打开我们的工具窗口。- 我们定义了新旧字体的配置项。注意,
oldFontName是字符串,因为我们是通过字体资源的名称来匹配需要替换的旧字体(Arial)。而新字体是通过资源引用直接赋值。 - 提供了搜索路径,默认是
Assets,你可以修改为Assets/Resources/Prefabs等更具体的路径,提高扫描效率。
3.2 核心替换逻辑实现
接下来是核心的StartBatchReplace方法。它的工作流程是:查找所有预制体 -> 加载每个预制体 -> 检查其GameObject上的文本组件 -> 匹配并替换字体 -> 保存预制体。
private void StartBatchReplace() { // 1. 获取所有预制体文件路径 string[] prefabPaths = Directory.GetFiles(searchPath, "*.prefab", includeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); if (prefabPaths.Length == 0) { progressInfo = $"在路径 '{searchPath}' 下未找到.prefab文件。"; return; } int totalPrefabs = prefabPaths.Length; int processedCount = 0; int modifiedCount = 0; // 2. 遍历每个预制体 for (int i = 0; i < prefabPaths.Length; i++) { string path = prefabPaths[i]; // 更新进度条 if (EditorUtility.DisplayCancelableProgressBar("批量替换字体", $"正在处理: {Path.GetFileName(path)} ({i+1}/{totalPrefabs})", (float)i / totalPrefabs)) { // 用户取消了操作 progressInfo = "操作被用户取消。"; EditorUtility.ClearProgressBar(); return; } // 加载预制体 GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); if (prefab == null) continue; bool isPrefabModified = false; // 3. 获取预制体根对象及所有子对象(包括未激活的) // 注意:预制体可能嵌套,需要递归查找。这里使用简单情况,处理根预制体直接引用的组件。 // 更严谨的做法是遍历prefab.GetComponentsInChildren<Component>(true),但这里为清晰起见,先处理直接挂在预制体根对象上的组件。 // 实际脚本中应使用递归或GetComponentsInChildren。 // 模拟处理逻辑:这里我们假设需要处理预制体实例及其所有后代 // 由于在Asset模式下不能直接实例化,我们需要以“编辑Asset”的模式进行,这涉及到PrefabUtility。 // 更安全通用的方法是:打开Prefab编辑模式或使用SerializedObject。 // 下面是一种通过PrefabUtility打开预制体进行编辑的方法: // 打开预制体进行编辑(返回一个GameObject实例,代表预制体根) GameObject prefabInstanceRoot = PrefabUtility.LoadPrefabContents(path); // 查找所有文本组件 Text[] textComponents = prefabInstanceRoot.GetComponentsInChildren<Text>(true); TMPro.TextMeshProUGUI[] tmpComponents = prefabInstanceRoot.GetComponentsInChildren<TMPro.TextMeshProUGUI>(true); // 处理传统Text组件 foreach (Text textComp in textComponents) { if (textComp.font != null && textComp.font.name.Contains(oldFontName)) { textComp.font = replacementFont; isPrefabModified = true; Debug.Log($"在预制体 {path} 的 {textComp.gameObject.name} 上替换了Text字体。"); } } // 处理TMP组件 foreach (TMPro.TextMeshProUGUI tmpComp in tmpComponents) { if (tmpComp.font != null && tmpComp.font.name.Contains(oldFontName)) { tmpComp.font = replacementTMPFont; isPrefabModified = true; Debug.Log($"在预制体 {path} 的 {tmpComp.gameObject.name} 上替换了TMP字体。"); } } // 4. 如果预制体被修改,则保存 if (isPrefabModified) { PrefabUtility.SaveAsPrefabAsset(prefabInstanceRoot, path); modifiedCount++; } // 5. 清理临时加载的预制体实例 PrefabUtility.UnloadPrefabContents(prefabInstanceRoot); processedCount++; } // 清理进度条 EditorUtility.ClearProgressBar(); // 显示结果 progressInfo = $"处理完成!\n扫描预制体总数: {totalPrefabs}\n成功处理: {processedCount}\n实际修改了: {modifiedCount}"; // 强制刷新Asset数据库,让编辑器立即显示更改 AssetDatabase.Refresh(); Debug.Log($"批量替换字体完成。修改了 {modifiedCount} 个预制体。"); }关键点与避坑指南:
PrefabUtility.LoadPrefabContents与SaveAsPrefabAsset:这是Unity 2018.3后推荐的编辑预制体Asset的方法。它加载一个可编辑的预制体实例到内存,我们修改这个实例后,再保存回原路径。切记最后一定要调用PrefabUtility.UnloadPrefabContents来卸载,否则会造成资源泄露。- 字体匹配逻辑:
textComp.font.name.Contains(oldFontName)是一种宽松匹配。有时Arial字体资源在Unity中的名字可能带有后缀(如“Arial (TTF)”)。使用Contains比严格相等 (==) 更安全。你也可以根据font.name或font.ToString()来调整匹配策略。 - 递归查找组件:
GetComponentsInChildren<Text>(true)中的参数true表示包含未激活(Inactive)的GameObject。这非常重要,因为UI元素可能被默认禁用。 - 进度反馈:使用
EditorUtility.DisplayCancelableProgressBar显示进度条,并允许用户取消长时间的操作,这是编写友好编辑器工具的好习惯。 - AssetDatabase.Refresh():保存修改后调用,确保Project窗口立即更新,看到修改后的预制体。
3.3 增强功能:处理字体回退(Fallback)和材质
对于TMP,直接替换font属性有时可能不够。如果旧字体使用了特定的材质或材质属性(如SDF缩放、描边效果),直接替换字体Asset可能会导致这些效果丢失,因为新的TMP_FontAsset自带其默认材质。
更健壮的TMP替换策略:
- 替换字体Asset。
- 检查并尝试保持原有的材质实例或材质属性覆盖。一个常见的做法是,在替换字体后,如果旧组件有自定义的字体材质(
fontMaterial或fontSharedMaterial),我们可以尝试将新字体的默认材质属性(如_FaceColor,_OutlineWidth等)复制到旧材质上,或者直接应用旧材质(如果兼容)。但这涉及材质参数拷贝,比较复杂。 - 对于大多数“单纯换字体”的需求,直接替换
font属性是可行的。但如果你的项目中原Arial TMP字体有特殊的材质设置,你可能需要手动检查替换后的效果,或编写额外的逻辑来迁移材质属性。
一个简单的增强是,在工具界面增加一个选项:“为TMP创建并使用新的材质实例”,这样替换后会生成一个基于新字体默认材质的新材质实例,避免多个预制体共享材质导致意外联动修改。
4. 完整脚本与使用步骤
将上述代码块整合,形成一个完整的BatchReplaceFontTool.cs文件,放入Assets/Editor/文件夹。下面给出清晰的实操步骤。
4.1 前置准备
- 导入新字体:将支持中文的
.ttf/.otf文件(如“SourceHanSansSC-Regular.otf”)拖入Assets/Fonts/文件夹。 - 创建TMP字体资源(如果使用TMP):在Project窗口右键点击该字体文件 ->
Create -> TextMeshPro -> Font Asset。将其保存在合适位置,如Assets/Fonts/TMP/。 - 备份项目:在进行任何批量操作前,务必使用版本控制系统(如Git)提交,或手动复制备份整个项目。这是铁律。
4.2 使用工具
- 在Unity编辑器顶部菜单栏,点击
Tools -> 批量替换字体工具。 - 在打开的窗口中:
- 旧字体名称:输入
Arial(如果你的旧字体名是别的,就输入对应的名字)。 - 新字体 (For Text):从Project窗口拖入你为传统Text组件准备的
Font资源(例如SourceHanSansSC-Regular)。 - 新字体 (For TMP):从Project窗口拖入你创建的
TMP_FontAsset资源。 - 搜索路径:默认是
Assets,会扫描整个项目。如果你确定预制体都在某个文件夹下,可以输入更具体的路径如Assets/_Prefabs/UI,以大幅提升速度。 - 包含子目录:通常勾选。
- 旧字体名称:输入
- 点击“开始扫描并替换”。
- 等待进度条完成。处理过程中可以在Console窗口看到详细的替换日志。
- 完成后,工具窗口会显示处理统计信息。
4.3 验证结果
- 随机打开几个被修改的预制体,检查其Text或TMP组件的
Font/Font Asset属性,确认已更改为新字体。 - 在场景中实例化这些预制体,或在游戏运行时检查UI,确认中文显示正常,没有“口口口”。
- 检查是否有预制体因为嵌套引用、字体名称不完全匹配等原因未被成功替换。我们的脚本日志会输出每个修改操作,可以根据日志进行复查。
5. 常见问题与排查技巧
在实际操作中,你可能会遇到以下问题。这里记录了我踩过的坑和解决方案。
5.1 字体替换了但显示没变或变方块
- 问题:脚本执行成功,预制体资源也显示字体已更改,但运行游戏或预览时,文字没变或变成了方块。
- 排查:
- 字体资源本身不支持中文:确认你导入的字体文件包含中文字形。对于TMP,尤其要检查创建的
TMP_FontAsset的字符集(Atlas)。默认创建时可能只包含ASCII字符。你需要:- 选中你的
TMP_FontAsset。 - 在Inspector窗口,找到
Character Set,从下拉框中选择Custom Set或CJK Characters(如果选项存在)。 - 更可靠的方法是点击
Update Atlas Texture按钮,并确保Character List包含了你的项目需要用到的所有中文(可以粘贴一段中文文本进去)。
- 选中你的
- 材质/Shader问题:TMP字体与材质、Shader绑定。如果新字体的默认材质Shader与旧的不同(例如旧的是SDF,新的是Bitmap),会导致渲染异常。确保替换后的TMP组件使用的材质和Shader是合适的。
- 字体回退(Fallback)设置:TMP组件有
Fallback Font Assets列表。如果主字体缺失某些字符,会尝试用回退字体显示。检查这里是否还残留着对旧Arial字体Asset的引用,如果有,移除或替换它。
- 字体资源本身不支持中文:确认你导入的字体文件包含中文字形。对于TMP,尤其要检查创建的
5.2 脚本报错或无法运行
- 问题:打开工具窗口时报编译错误,或点击按钮后报空引用、序列化错误。
- 排查:
- 脚本位置:确保脚本放在
Assets/Editor/或其子目录下。只有放在Editor文件夹中的脚本才能使用EditorWindow、EditorUtility、PrefabUtility等编辑器API。 - TextMeshPro命名空间:脚本开头需要
using TMPro;。如果报错,检查你的项目是否已正确导入TextMeshPro包(Window -> TextMeshPro -> Import TMP Essential Resources)。 - 预制体加载失败:某些预制体可能已损坏或依赖缺失。脚本中的
AssetDatabase.LoadAssetAtPath返回null时会跳过。查看Console窗口是否有加载错误。 - 权限与锁定:极少数情况下,如果预制体文件被其他进程(如文件浏览器、Git客户端)锁定,可能导致保存失败。关闭可能占用文件的程序。
- 脚本位置:确保脚本放在
5.3 性能优化与大规模项目处理
- 问题:项目有数千个预制体,扫描过程非常慢,甚至导致编辑器卡顿或无响应。
- 优化技巧:
- 缩小搜索范围:充分利用
searchPath参数,只扫描UI预制体所在的特定文件夹,而不是整个Assets。 - 分批次处理:修改脚本,每次处理一定数量(如50个)预制体后,调用
EditorUtility.UnloadUnusedAssets()和Resources.UnloadUnusedAssets()(在编辑器模式下谨慎使用)来释放内存,然后继续下一批。可以给进度条添加“暂停/继续”功能。 - 异步处理:对于极其庞大的项目,可以考虑使用
EditorApplication.update回调或async/await(需注意Unity编辑器的主线程限制)来将任务分散到多帧执行,避免阻塞主线程。 - 缓存与过滤:首次扫描时可以构建一个预制体路径列表并缓存,后续操作如果路径没变,可以直接使用缓存列表。或者先通过AssetDatabase的标签、类型进行初步过滤。
- 缩小搜索范围:充分利用
5.4 处理嵌套预制体(Prefab Variants)与场景引用
- 问题:我们的脚本主要处理独立的
.prefab文件。但对于嵌套预制体(一个预制体引用另一个预制体)或预制体变体(Prefab Variant),直接修改根预制体可能无法覆盖其内部引用的子预制体的字体。 - 解决方案:
- 脚本中的
GetComponentsInChildren(true)会深入到嵌套的子GameObject,但如果子对象本身是一个预制体实例(在Hierarchy中显示为蓝色),那么修改的是这个实例的覆盖(Override),而不是其源预制体Asset。要修改源预制体,需要递归地找到这些嵌套预制体引用,并单独加载它们的源预制体路径进行处理。这增加了复杂度。 - 一个实用的建议是:先运行一遍当前脚本,它能够修改所有直接挂在预制体对象上的字体引用(包括嵌套实例的覆盖)。然后,手动检查那些重要的、作为基础模块的子预制体,对它们单独再运行一次脚本。对于预制体变体,修改其基体(Base)预制体的字体会自动被变体继承(除非变体自己有覆盖)。
- 脚本中的
5.5 操作安全与回滚
- 重中之重:执行前必须备份。使用Git的话,确保所有更改已提交,或者至少stash起来。
- 小范围测试:第一次使用时,先在
searchPath中指定一个只包含几个测试预制体的文件夹,验证脚本行为符合预期后,再全量运行。 - 记录更改:脚本中的
Debug.Log会输出修改记录。你也可以修改脚本,将更改记录(预制体路径、组件名、旧字体、新字体)写入一个文本文件,方便后续审计和回滚。 - 回滚方法:如果不幸改错了,而你又没有备份,唯一的希望是依赖版本控制的历史记录。如果连版本控制都没有,那就很难完美恢复了。这再次强调了备份的重要性。
这个工具脚本的核心逻辑是通用的,你可以根据自己项目的具体需求进行扩展,例如增加对SpriteRenderer组件上TextMesh(旧版3D文本)的支持,或者增加字体大小、颜色等属性的批量调整功能。掌握了Editor脚本的编写思路,你就拥有了自动化处理Unity项目中各类批量任务的钥匙。