如果你跟我一样在项目中期接手一个历史包袱比较重的 Unity 工程,大概能明白那种感觉:预制体命名全是 Cube (1)、贴图导入参数五花八门、想找一个挂在场景里的脚本得靠肉眼一个个翻。那段时间我每天晚上都在做同一件事——机器般重复的编辑器操作。后来我决定把这些高频动作收拢成一个自用工具包,也就是 Dan_Tools。它不是那种动辄几百个功能的商业插件,而是我按自己项目节奏一点点堆出来的编辑器功能集合。这篇文章会把 Dan_Tools 涉及的 Editor Toolkit 函数按功能模块拆开讲清楚,包括每个工具解决什么问题、底层用了哪些编辑器 API、以及我在实际迭代中踩过的坑。
内容面向正在做 Unity 编辑器工具、或者打算开始写内部工具的开发者,也适合刚接触 MenuItem、AssetPostprocessor、SerializedObject 这些概念但不知道怎么组合成完整功能的同学。读完你可以直接把这些思路搬进自己的项目,甚至复刻一版 Dan_Tools。
1. Dan_Tools 的设计初衷与核心边界
1.1 编辑器工具和游戏运行时逻辑是两套思维
很多初学者会下意识用写玩法逻辑的方式去写编辑器工具,结果做出来的东西在编辑器里跑得动,但要么卡界面,要么污染工程文件。我最早犯的错误就是把全部工具逻辑都塞进一个静态类,通过菜单入口直接调用。后来重构 Dan_Tools 时才意识到,编辑器工具代码运行在 Unity 编辑器进程内,它的生命周期、数据访问方式和运行时脚本完全不同。
举个例子,运行时你直接访问 GameObject.GetComponent 没问题,但编辑器工具里你经常需要同时处理几十上百个对象,这时候就要用到 Selection.objects 拿到数组再遍历。而在遍历过程中修改对象,必须通过 Undo.RecordObject 记录下来,否则用户 Ctrl+Z 点下去直接傻眼。Dan_Tools 里所有的批处理函数,第一条规则就是:不要绕过 Undo 系统。
另外,编辑器工具最忌讳的是"越权"。工具是帮助人高效工作的,不是替人做所有决定的。我在 Dan_Tools 里做了一个很刻意的设计:批处理操作默认不强制执行,而是先把当前选中对象的统计信息打出来,打印一条 Debug.Log 说明"即将修改 N 个对象",再弹确认框。这个习惯后来帮了我大忙,好几次因为多选时误选了不想动的资源而避免了批量污染事故。
1.2 以"高频操作"为优先级的功能筛选标准
Dan_Tools 不是一开始就有模块化规划的,它最初只是零散的几个函数。我给工具加功能的筛选标准非常简单:这个操作我每周是否至少重复三次以上? 不满足就直接拒绝。
按这个标准筛下来,第一批进入 Dan_Tools 的只有四类函数:
- 场景对象批量重命名与编号补全
- 资源导入后的自动参数设置(Texture、AudioClip 等)
- 被选中对象的依赖关系查询
- 自定义 ScriptableObject 配置资产的快速创建与定位
你看名单里没有那些花哨的地形绘制、场景截图等低频功能。我见过不少开发者写工具时喜欢一口气铺二十个入口,最后真正用到的只有两三个,剩下全是维护负担。工具的维护成本是真实存在的,尤其在引擎大版本升级时,API 过时警告能刷屏。
后来我还给每个工具函数写了一个一行注释,标注"适用场景"和"不适用场景"。这属于给自己的备忘,但当我把 Dan_Tools 开放给团队其他人使用时,这些注释直接变成了使用文档,省了一大笔沟通成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具包中的四类高频函数拆解
2.1 场景批处理类:批量重命名与引用替换
场景里最常见的问题就是命名混乱。新同事从建模软件拉进来一堆 FBX,Unity 自动给它们命名为 Model_Wall, Model_Wall (1), 类似的情况每天都在发生。Dan_Tools 里我写了一个批量重命名窗口,支持前缀、后缀、序号起始值、位数补零四个核心参数。
实现上其实不复杂,核心是利用 MenuItem 挂到窗口入口,然后用 EditorWindow.GetWindow 显示一个浮窗,遍历 Selection.gameObjects 逐个改名。但里面有几个细节值得注意。
第一个坑是:改名必须考虑场景的 Transform 层级重名问题。Unity 只允许同级节点下名字唯一,所以你批量改名的顺序很重要。我的做法是先按层级深度排序,深的先改,浅的后改,这样才能避免"改完一个又跟父级撞名"的尴尬。
第二个坑是对 Prefab 的处理。如果你选中的对象里有预制体实例,直接改 gameObject.name 会连带把源预制体也改了,这在团队协作里是事故级别的操作。我在实现里加了一个过滤逻辑:如果对象是预制体实例,会先 PrefabUtility.GetCorrespondingObjectFromSource 判断源资产,并在改名时弹提示是否只改实例。
批量替换脚本引用也是一个高频需求。当我把一个旧组件重构拆成两个新组件时,所有挂旧组件的预制体都得手动替换。这个工具的实现关键是绕开 SerializedObject 直接操作 serializedProperty,而不是遍历 GetComponent。
我当时用到的核心代码大概是这样的:
csharp复制[MenuItem("Dan_Tools/Replace Component References")]
static void ReplaceComponentRefs()
{
var selected = Selection.gameObjects;
if (selected.Length == 0) return;
Undo.RegisterCompleteObjectUndo(selected, "Replace Component References");
foreach (var go in selected)
{
var so = new SerializedObject(go);
var props = so.GetIterator();
while (props.Next(true))
{
if (props.propertyType == SerializedPropertyType.ObjectReference)
{
var refValue = props.objectReferenceValue;
if (refValue != null && refValue.GetType() == typeof(OldComponent))
{
props.objectReferenceValue = <新组件实例>;
}
}
}
so.ApplyModifiedProperties();
}
AssetDatabase.SaveAssets();
}
其中 Undo.RegisterCompleteObjectUndo 是重点,它保证了批量替换后可以整体撤销。没有这一步,几百个预制体改完后想回退就变成人间惨剧。另外 AssetDatabase.SaveAssets() 后记得调用 AssetDatabase.Refresh(),否则有些资源引用不会立刻刷新显示。
2.2 资源导入与检查类:自动化设置 AssetImporter
团队项目里经常遇到美术资源导入参数不统一的情况。同一个图集,有人导入成 Sprite 有人导入成 Texture,分辨率压缩率也各不相同。运行期看不出来,但包体和加载内存就会莫名其妙地膨胀。Dan_Tools 的另一半核心价值,就是用 AssetPostprocessor 在资源导入时自动纠正这些参数。
我在这块做了两个层面的功能:
第一层是常规的自动设置。比如所有以 UI_ 前缀开头的贴图,默认导入为 Sprite 类型,并且 Pixels Per Unit 设为 100,Alpha Is Transparency 勾选。音频文件统一设置为 Decompress On Load 还是 Compressed In Memory,我会根据文件名后缀判断用途。
第二层是检查类函数。有些资源是历史遗留的,不会触发新的导入回调,所以我在菜单里加了一个手动检查入口,扫描整个 Assets 目录下的所有资源,列出不符合规范的项,生成一份报告并自动定位到第一个问题资源。
这里最需要注意的性能陷阱是:不要在每一个 OnPostprocessAllAssets 回调里都对全项目做扫描。我见过有人直接在导入回调里遍历所有贴图设置参数,结果每次导入都慢到爆炸。正确做法是把全局规则集中放一份静态配置类里,导入时只处理当前变更的那几个资源。
csharp复制public class DanAssetPostprocessor : AssetPostprocessor
{
private void OnPreprocessTexture()
{
var importer = (TextureImporter)assetImporter;
if (assetPath.StartsWith("Assets/Art/UI/"))
{
importer.textureType = TextureImporterType.Sprite;
importer.spritePixelsPerUnit = 100;
importer.alphaIsTransparency = true;
}
}
}
注意我检查的是 assetPath 的前缀匹配,而不是遍历所有路径。这是一种低成本、高收益的做法。当初把一份完整的图标文件夹从 2K 分辨率降到了 512 分辨率,包体整整降了 80MB,就是靠这套自动导入规则跑出来的。
2.3 层级与定位类:依赖查询与对象快速导航
写工具时间长了你会发现,"帮用户找到东西"比"帮用户改东西"更提效。Dan_Tools 里最受欢迎的函数不是批量操作,而是依赖查询。
这个函数的场景是:资源准备删除前,你想知道谁引用了它;或者美术改了贴图名之后,你想知道哪些材质需要重新调整参数。我写了一个 FindAssetDependencies 菜单项,选中任意资源后,工具会递归扫描同目录和整个预制体库,找出所有引用该资源的对象,并生成一份带路径清单的窗口。
这里我用的是 AssetDatabase.GetDependencies 和 AssetDatabase.GetAssetPath 的组合,虽然引擎自带依赖查询,但它只告诉你间接依赖的最终结果,不告诉你中间链路。我要的是完整的"引用树"。于是我还写了一个反向索引:扫描所有 .prefab 和 .mat 文件的 YAML 内容,通过 GUID 匹配出引用关系。这个实现思路其实很简单,本质上就是文本检索加 GUID 映射。
反向索引的实现要注意一个问题:直接用 File.ReadAllText 读取 YAML 做字符串包含判断,对中文文件名和路径很敏感,最好用 AssetDatabase.GUIDToAssetPath 转一次路径再做比较。
另一类定位函数是给美术和策划用的。他们经常在 Inspector 里看到一个脚本引用,想知道这个脚本在工程哪个目录。Dan_Tools 里我实现了在 Project 窗口中自动选中并展开当前选中资产的功能,核心就一行:
csharp复制[MenuItem("Dan_Tools/Select In Project")]
static void SelectInProject()
{
var active = Selection.activeObject;
if (active == null) return;
EditorUtility.RevealInFinder(AssetDatabase.GetAssetPath(active));
}
别看实现简单,实际对非技术同事的日常效率提升非常明显。很多美术同事不熟悉 Project 窗口的目录结构,这个功能让他们不再需要在海量文件夹里翻找。
2.4 数据读写类:ScriptableObject 配置面板与序列化辅助
编辑器工具做到后期,几乎都会涉及"给工具留配置入口"这件事。Dan_Tools 里所有批处理的规则参数,我都允许使用者自行调整,而不是写死。例如重命名工具的默认前缀、自动导入规则的开关、检查报告的忽略目录列表,全部存放在一个 DanToolsSettings 的 ScriptableObject 资产里。
这个设计比用静态配置类好很多,因为美术或 TA 可以自己打开 Assets/DanTools/Settings.asset 在 Inspector 上修改参数,不用求着程序改代码。而我自己在写工具时,也只需要通过一行 AssetDatabase.LoadAssetAtPath 就能取到配置实例。
关于 ScriptableObject 的序列化辅助,我有一个值得分享的经验。引擎自带的 SerializedObject 在 Inspector 面板上非常好用,因为所有字段自动显示、自动保存。但在写窗口工具时,我们经常不想依赖 Inspector 控件,而是要在 EditorWindow.OnGUI 里手动绘制 UI。
这时候两个做法:
- 直接用
EditorGUILayout.PropertyField绘制SerializedProperty,自动获得所有内置类型支持。 - 用
EditorGUILayout.TextField手动接收输入,再通过Undo.RecordObject+EditorUtility.SetDirty保存。
我强烈推荐第一种。手动绘制的坑在于它是"非序列化"的,窗口一刷新输入内容就可能丢。一份配置资产如果被多人同时编辑,手动绘制方案极其容易互相覆盖。
csharp复制var so = new SerializedObject(settings);
so.Update();
EditorGUILayout.PropertyField(so.FindProperty("renameprefix"));
EditorGUILayout.PropertyField(so.FindProperty("autoscanFolders"));
so.ApplyModifiedProperties();
这样 Unity 内置帮你做了脏标记和撤销,几十行代码界面的配置窗口就完成了。Dan_Tools 所有窗口类只要涉及配置修改,都遵循同一套模式。
3. 核心函数实现的底层逻辑
3.1 为什么大多数工具都挂在 MenuItem 和 Selection 上
我经常被问到一个问题:Dan_Tools 的函数入口为什么几乎清一色是 [MenuItem] + Selection,而不是做一个大而全的悬浮窗?原因有两点。
第一是 MenuItem 带快捷键绑定能力。在菜单项字符串里写 %#K 这种占位符,就可以实现 Ctrl+Shift+K 这种快捷触发。批量重命名这种高频操作,鼠标多点两下都嫌多,快捷键的效率提升是实打实的。
第二是 MenuItem 天然携带"上下文"概念,配合 Selection API 可以直接获取当前用户选中了什么。绝大多数批处理工具的语义就是"对选中对象进行处理",不需要用户去窗口里手动拖对象进去。比如我想对某个文件夹下的所有预制体执行引用替换,只需要让用户在 Project 窗口选中该文件夹,然后点菜单项,工具内部用 AssetDatabase.FindAssets 拿到该路径下全部预制体路径即可。
在实现时有一点需要留意:MenuItem 的回调函数是静态的,内部不要缓存任何对象引用。编辑器域的静态变量生命周期和脚本域重载行为很容易让人困惑。如果用户触发了"Domain Reload",你缓存的引用可能已经失效。Dan_Tools 里所有工具入口都设计成"无状态",数据要么从 Selection 拿,要么从 AssetDatabase 重新查,这样能避免大量诡异 bug。
3.2 AssetPostprocessor 里的自动处理不要在导入期做重活
不少教程会把 AssetPostprocessor 当作万能入口,任何资源一进来就在里面做处理。但这里有个硬性限制:回调执行时机是在导入流程中,此时做任何耗时操作都会拖慢整个导入管线,严重时还会导致导入死循环。
我踩过最惨的一次教训是在贴图导入回调里调用 AssetDatabase.CreateAsset 生成一个配套的材质文件。Unity 检测到新增资产后又触发一次 AssetDatabase 刷新,然后又进入导入回调,于是陷入了无限循环。最后不得不强制关掉编辑器,手动清理生成的临时目录。
如果你确实需要在导入后做资产间联动,比如贴图导入后自动检查同目录是否存在材质文件,不要直接在 OnPostprocessAllAssets 里创建资源,而是把待处理路径写进一个队列,等导入流程结束后,在下一帧通过 EditorApplication.delayCall 再执行具体逻辑。这相当于把重活推到了空闲时段。
延迟执行模式在 Dan_Tools 里也用于资源批量替换后的刷新。直接调用 AssetDatabase.Refresh() 是同步阻塞的,遇到几百个资源时编辑器会卡住。用 delayCall 延迟到当前帧渲染结束再做刷新,体验会顺滑很多。
3.3 用反射和序列化 API 之前必须清楚的三个限制
写编辑器工具难免要碰反射。Unity 内部很多属性没暴露公开 API,比如 TextMeshPro 的某些私有字段、CustomEditor 的一些隐藏属性。反射虽然万能,但代价很大,我在 Dan_Tools 里给反射类函数定了三条铁律。
第一,反射结果不要缓存 FieldInfo 过长时间。Unity 的 C++ 对象背后有 native 生命周期,域重载后 FieldInfo 可能指向已失效的对象,轻则 NullReferenceException,重则编辑器崩溃。每次操作时重新 GetField 成本其实也不高。
第二,对 SerializedObject 不要做反射操作后直接 ApplyModifiedProperties。反射改的是内存里的托管对象,序列化系统可能没有同步追到底层 serializedObject 的改动记录。正确做法是通过 SerializedProperty 的 API 去写,或者改完反射对象以后额外 EditorUtility.SetDirty 标记资产。
第三,反射遍历嵌套私有字段时,一定要注意继承层级。父类声明的 private 字段,子类实例用 GetField(fieldName, BindingFlags.NonPublic) 是拿不到的,必须从继承链上逐层往上找。我写过一次泛化的反射遍历工具,把 BindingFlags.FlattenHierarchy 配合逐层 BaseType 循环才最终解决。
4. 接入 Dan_Tools 后踩过的坑
4.1 批量替换引用时指向丢失的问题
做一个批量替换工具最容易被忽略的就是 objectReferenceValue 的赋值类型匹配。假设一个字段声明类型是 Material,你替换时赋了个 Texture 过去,Unity 会在运行时帮你做隐式转换吗?不会。代码直接抛异常。
这个错误在单个替换时很容易发现,但批量处理几十个预制体时,错误信息被淹没在日志里,难定位。我之后加了一个防御逻辑:赋值前先检查 props.type 字符串里是否包含目标类型名,或者直接通过 props.objectReferenceValue 的运行时类型与字段声明类型做 IsAssignableFrom 判断。
另一个更隐蔽的问题是:当你通过 SerializedObject 同时遍历多层子对象时,props.Next(true) 会深入嵌套结构。这时你对某个 ObjectReference 赋值,如果这个属性在预制体覆盖和默认值之间处于"混合状态"(比如一个预制体实例缺省覆盖了该字段),赋值后可能出现 Apply 时部分丢失的情况。Dan_Tools 里我用 props.prefabOverride 属性做了一次过滤,遇到处于混合状态的字段会额外记录并提示,而不是直接覆盖。
4.2 批处理没有 Undo 导致的项目崩溃级事故
我必须专门强调一次 Undo 的重要性。前文提到过用 Undo.RegisterCompleteObjectUndo,但我最早写批量重命名时抱侥幸心理,觉得改个名字而已,撤销不做也行。结果有次美术同事批量重命名了八十多个场景物件,发现命名规则写错想回退,按 Ctrl+Z 什么反应都没有。最后只能手动逐个改回来,那感觉就像在游戏里没存档打了两小时副本。
Unity 的 Undo 系统不仅覆盖场景对象,还覆盖资源资产。批量操作时有两种注册方式:
Undo.RecordObject(target, name):记录单个对象的状态快照。Undo.RegisterCompleteObjectUndo(targets, name):完整记录一组对象的全部状态。
两者的区别在于 RecordObject 只记录在调用时刻的改动,如果修改发生在同一个帧内多次,第二次修改基于第一次修改后的状态,撤销可能不干净。而 RegisterCompleteObjectUndo 会保留完整的快照,推荐批处理使用。
还有一个隐蔽细节:场景对象用 Undo.RegisterFullObjectHierarchyUndo 会更安全,因为改名操作可能影响到父子层级关系。对于涉及 Transform 父子关系变化的操作,只记单个对象是不够的。Dan_Tools 里的批量重命名我用的是完整层级撤销,确保无论改动多深都能一键回到操作前。
4.3 平台宏导致编辑器下报错的问题
Unity 的编辑器工具代码需要同时考虑"编辑器可用"和"运行时编译"两种状态。最常见的问题是工具类被误放在非 Editor 文件夹下,导致打包时 UnityEditor 命名空间找不到编译错误。
Dan_Tools 早期没有严格要求目录结构,Editor 脚本和运行时脚本混在一起,偶尔有同事在构建时定位到编译问题。后来我做了统一规范:凡是 using UnityEditor 的文件必须放在 Assets/DanTools/Editor/ 目录下,凡是运行时工具类必须用 #if UNITY_EDITOR 进行包裹(如果它引用了编辑器 API)。
另一个和平台宏相关的问题是快捷键冲突。MenuItem 的快捷键如果设置成和引擎默认快捷键冲突,编辑器会弹出按键冲突警告,严重的会直接让快捷键失效。我在调试 Dan_Tools 时遇到过和 Window/General/Inspector 抢 %#I 的情况,最后通过查 ShortcutManager 的占用列表才定位到。
遇到这类问题,一个务实解法是:工具快捷键尽量用带 Shift 或 Alt 的组合,避免单键裸奔。%#R、%#F 这种双修饰组合和默认冲突的概率相对较小。
5. 对内对外两用:让工具包同时服务自己也服务团队
5.1 编辑器菜单的组织与入口层级设计
工具一旦超过十个菜单项,直接铺一层就会变得混乱。我给 Dan_Tools 设计了三级菜单结构。顶层叫 Dan_Tools,第二层按用途分为 Scene、Asset、Prefab、Utils 四大类,第三层才是具体操作。
这个组织方式参考了引擎自带的菜单分区习惯,让团队成员找功能时能凭直觉定位。另外我在菜单文案上花了心思:不使用"工具 1"这种名字,而是尽量用动词短语,比如 Batch Rename Selected、Check Import Settings、Find Dependencies。这样即使英文不好的同事,看图标也能大致明白功能。
菜单位置选择上,我也区分了全局菜单和右键菜单。全局菜单放低频但必须存在的工具,右键菜单放贴合上下文的高频工具。比如在 Scene 窗口右键点击对象,只弹出一个 Dan_Tools/Reveal In Hierarchy,用于快速在层级树中定位被选对象。这样右键菜单不会太长,核心高频功能触手可及。
5.2 配置项暴露与团队规范检查
把工具给团队使用最大的阻力不是功能不好用,而是操作边界不透明。我后来在 Dan_Tools 里加了"今日操作日志"面板,把工具执行过的所有批量修改记录成文件,包括操作人、操作类型、影响对象数量、时间戳。这个日志在团队协作里意外地管用。
有一次美术反馈某批贴图导入参数被改错了,我们通过日志定位到是前一天一个同事手动执行了"Check Import Settings"时勾选了强制覆盖。这类问题在没有日志前基本只能靠猜。现在日志会告诉你是谁、什么时候、改了什么规则。
另外,工具包的配置项我全部收敛到 DanToolsSettings 资产里,不让团队成员各自去改代码。这样规范检查的功能才有意义——大家的工具行为是一致的。一旦有人通过代码硬编码绕过配置,工具的自动检查报告就会标红提示"检测到与项目规范不一致的配置"。
给团队用,最重要的是做"可灰度"设计。Dan_Tools 里所有批量修改类工具,默认只作用于当前选中对象,且每次执行都在日志面板产生一条等待确认的记录。如果团队里有人误操作,我能第一时间从日志中看到并进行回滚引导。
5.3 后续扩展的方向
工具集做到现在,我体会到一件事:编辑器工具最有价值的部分不是那几行 API 调用,而是你积累的"操作上下文"。比如批量重命名时要不要管预制体源,依赖查询时要不要跨场景搜,这些决策逻辑才是别人难以复制的。
我下一步的计划是把 Dan_Tools 的规则检查部分抽出来,接进项目的 CI 流程。这样不只在编辑器里能查资源规范,提交代码时自动跑一遍也能拦截明显问题。核心实现思路其实很直接:把 Check Import Settings 这类检查逻辑从 MenuItem 回调里解耦出来,变成普通静态方法,再写一个命令行入口调用同一套检查函数,输出报告。编辑器工具和 CI 脚本共用同一份规则配置,维护成本就能控制在可接受范围内。
6. 一点使用体会
如果你也想维护一套自己的编辑器工具包,我的建议是从"最痛的那个点"开始写,不要上来就规划几十个功能。Dan_Tools 的第一版其实只有一个批量重命名窗口,后续每个函数都是真实工作流里反复被摩擦出来的。过程中你会不断加深对 MenuItem、Selection、AssetDatabase 这些 API 的理解,这是看文档学不来的。
还有一个小技巧是,工具函数命名要带"意图"。我见过很多人的工具类方法名叫 Tool1()、Helper(),维护两个月后自己都看不懂。我给自己定的习惯是方法名直接说明行为,比如 NormalizeSelectedPrefabNames 而不是 DoWork。这个方法名本身就成了代码注释,重构时也能快速判断工具是否还需要保留。
最后提醒一句,编辑器工具代码也是代码,同样需要 review、需要版本管理、需要文档。别因为它在 Editor 文件夹下就放松标准,否则有一天它会变成另一个让你头疼的"历史包袱"。
