如果你已经写了半年以上 Unity 业务代码,大概率迟早会碰到这样一种需求:想给编辑器加一个按钮,一键完成某个反复操作;想批量修改一堆同类型资源,又不想一个个点 Inspector;或者策划跟你提了一百遍“能不能给我做个配置工具”。这时候你真正需要掌握的,就是 Unity 编辑器扩展这套东西。
这篇文章围绕 Unity 编辑器扩展的常用方法展开,覆盖菜单、Inspector 自绘、独立窗口、资源管线与撤销栈几个核心面。它解决的问题很直接:把零散、重复、容易出错的编辑器操作沉淀成可复用的工具,让程序和策划都能在编辑器里更高效地工作。适合已经写过基本 MonoBehaviour 脚本、想往工具开发方向深入,或者正在为项目做内部效率工具的开发者。
需要先说明一点:编辑器扩展再花哨,本质还是 C# 与 Unity 内部 API 的调用,它只服务于开发期和内容生产期,不会直接影响最终玩家包逻辑。把这一点想清楚,后面看代码就不会发怵,也不会出现打包时因为误引入 UnityEditor 而翻车的事故。
1. 编辑器扩展值得学吗:先看三个真实场景
1.1 批量资源处理
有一回,项目里所有 UI 预制体都要替换字体,一共两百多个 Prefab。手动改的话,一个至少一分钟,人还会疲劳漏改。写一个编辑器菜单项,遍历选中目录下的 Prefab,用代码替换字体组件,两分钟跑完。这就是编辑器扩展最大的价值——把“人肉重复”变成“机器批处理”。
这种批处理场景在项目中期特别常见。贴图压缩格式要调整、所有模型需要重新生成 LOD、场景里某类组件的参数要统一加偏移,都是编辑器扩展的主场。写的时候不用考虑性能有多极致,只要保证逻辑正确、能一次跑完、出错时能明确提示是哪条数据出了问题,就已经比手动操作强一个量级。
1.2 策划向的工具需求
配表工具有时候比游戏内容本身更影响开发效率。策划需要批量创建关卡配置、批量预览角色模型、快速调整地编资源。用 EditorWindow 做一块独立面板,把底层数据序列化成 ScriptableObject,剩下的事情就是编辑器里的几个按钮。这类工具不需要多精美,能稳定执行就是成功。
我见过不少项目,程序花三小时写了个临时窗口,结果策划用了一年。起因通常就是某个配置流程太繁琐、太容易出错,而编辑器扩展正好补齐了这个缺口。给策划做工具时,核心设计原则就一条:把会出错的地方藏起来,把常用操作放在最显眼的位置。
1.3 开发期的数据校验与便捷操作
美术可能漏掉贴图压缩设置,程序可能在场景里留下空引用。这些不该等运行时才暴露。写一个自定义 Inspector,让组件在被选中的时候就显示校验结果;或者写一个菜单一键扫描场景,把问题全部输出到 Log。这是低成本高回报的扩展方向。
这类工具写起来不难,但收益非常直接。一条扫描命令跑完,输出十个警告,对应九个真实问题。难怪很多团队在项目稳定期,办公效率上最明显的提升都来自这些“检查器”。它能让你在问题发生之前就把隐患按死在编辑器里。
1.4 编辑器扩展的运行边界
要理解编辑器扩展,关键是记住几点:相关代码必须放在名为 Editor 的文件夹下,或者类文件用#if UNITY_EDITOR包住;编辑器代码只参与编辑器进程,不会编译进玩家包;编辑器扩展的本质是调用 UnityEditor 命名空间下的 API。不用害怕这个命名空间,常用的类就那么几十个。
提示:放在任何位置但被
#if UNITY_EDITOR包裹的类,一样会被编辑器编译。只是团队规范里我更推荐直接建 Editor 文件夹,结构一眼可见,也不容易误用。
编辑器扩展的常用入口按使用场景划分,大致有这么几类:
| 入口 | 适用场景 | 典型 API |
|---|---|---|
| 菜单扩展 | 给编辑器顶部菜单或右键菜单加命令 | MenuItem、ValidateMenuItem |
| 组件右键命令 | 挂在 MonoBehaviour 上,右键组件快速操作 | ContextMenu、ContextMenuItem |
| 自定义 Inspector | 重写组件在 Inspector 上的显示 | CustomEditor、OnInspectorGUI |
| 属性绘制器 | 让自定义数据结构在 Inspector 上可读可改 | CustomPropertyDrawer |
| 独立窗口 | 多个控件组合,形成完整工具界面 | EditorWindow、EditorGUILayout |
| 编辑器生命周期 | 启动时初始化、脚本重编译后回调 | InitializeOnLoad、EditorApplication |
这张表也是这篇文章的行文顺序。先认识这些入口,再逐个击破,编辑器扩展的功底就打下了大半。
2. 菜单类扩展:MenuItem、右键命令和快捷键的正确姿势
2.1 MenuItem 的基础写法
MenuItem 大概是接触频率最高、也最容易让人产生“原来扩展这么简单”错觉的入口。写法非常直接:
using UnityEditor; using UnityEngine; public static class MenuTools { [MenuItem("Tools/Batch/Replace Texture")] public static void ReplaceTexture() { // 具体逻辑... } }点击编辑器顶部新增的 Tools 菜单,找到 Batch 下的 Replace Texture,就会执行对应静态方法。菜单路径里用斜杠分隔层级,这很直觉。但有三个关键约定必须遵守:
第一,目标方法必须是静态方法,参数为空或者只有一个 bool 参数(配合校验逻辑用)。第二,类本身不一定要继承什么,静态工具类即可。第三,菜单路径不能与 Unity 内置菜单冲突,像GameObject/...这类路径要谨慎使用,改不好会污染原有菜单。
2.2 菜单的启用状态与优先级
实际项目里很多菜单项是有前置条件的:没选中物体时,点“替换材质”毫无意义,最好直接置灰。这时就要用到 ValidateMenuItem:
[MenuItem("Tools/Batch/Replace Texture", true)] public static bool ValidateReplaceTexture() { return Selection.activeGameObject != null; }注意校验方法与原方法同名,且返回值是 bool。Unity 规定校验方法也可以多一个 true 参数版本,但我个人习惯用同名 bool 返回值,语义更清楚。当校验方法返回 false,菜单项会自动置灰,点击无效。
菜单的顺序优先级靠数字指定,数字越小越靠前:
[MenuItem("Tools/My Tool", false, 10)]数字可以省略,省略时默认按字母序排列。常用的组内间距用 10 或 50 都比较舒服,太密的数字会给后续插入新菜单项留不了余量。团队协作时,如果多个工具脚本都由不同人维护,建议先约定一段数字范围,比如这个模块的菜单项都占 100~199,避免互相覆盖。
2.3 快捷键:从点击到一键
菜单项支持快捷键,格式比想象中简单:% 代表 Ctrl(macOS 上是 Cmd),# 代表 Shift,& 代表 Alt,_ 是单个按键。写在菜单路径末尾,用空格分隔:
[MenuItem("Tools/Quick Bake %#b")] public static void QuickBake() { // Ctrl+Shift+B }注意快捷键是全局的,只要编辑器聚焦即可触发。团队项目里尽量先查一下项目约定,避免和已有快捷键冲突。一旦冲突,可能出现菜单里显示正常但按下快捷键后触发的是别的命令这种诡异现象。用_加单个字母时,例如"Tools/My Tool _m",只按下 M 键就会触发,这种写法适合高频工具,但同样要做好冲突检查。
2.4 在组件上直接挂右键命令
除了顶栏菜单,很多扩展需求发生在组件上下文。给 MonoBehaviour 类加上 ContextMenu 特性即可:
[ContextMenu("随机生成血量")] public void RandomizeHealth() { health = Random.Range(50, 100); EditorUtility.SetDirty(this); }这样在 Inspector 右上角三个点菜单里就能看到一个临时命令。如果只是针对某个字段提供快捷操作,可以在字段上使用 ContextMenuItem:
[ContextMenuItem("重置为最大值", nameof(ResetMax))] public float maxHealth = 100f; private void ResetMax() { maxHealth = 9999f; }右键字段就会弹出“重置为最大值”的入口。要注意,同一类型的 MonoBehaviour 在很多项目里会被大量使用,右键命令命名尽量带上操作对象语义,不要叫“测试”这种无意义的名字,否则过段时间自己都会分不清。菜单类扩展先说到这里,下面进入修改组件 Inspector 的核心部分,这是绝大多数工具开发里最常反复打磨的区域。
3. 自定义 Inspector:让组件面板符合你的真实工作流
3.1 什么时候值得自定义 Inspector
默认 Inspector 已经很好用了,字段多时会自动折叠、可显示引用。那为什么还要重写?理由通常是这三个:
组件字段语义不直观。比如一个表示“有效时间范围”的 Vector2,默认编辑器让你手动点两个数值,你得靠标签猜含义。但如果能画成一条可拖拽的范围条,人类理解的成本瞬间降低。
需要联动与校验。字段 A 变了,字段 B 要跟着变化;数值超出合理范围,应该在面板上立刻变红或给出提示框,而不是等运行时才发现。
需要一键操作。面板上加点“重置”“自动归一化”“复制到其他组件”之类的按钮,省去跨窗口操作。
3.2 自定义 Inspector 的标准骨架
自定义 Inspector 用到 CustomEditor 特性加 Editor 子类。这里最关键的,是永远不要直接改原始对象的 public 字段,而是通过 serializedObject 和 SerializedProperty 操作。骨架如下:
using UnityEditor; using UnityEngine; [CustomEditor(typeof(HealthController))] public class HealthControllerEditor : Editor { private SerializedProperty healthProp; private SerializedProperty maxHealthProp; private void OnEnable() { healthProp = serializedObject.FindProperty("health"); maxHealthProp = serializedObject.FindProperty("maxHealth"); } public override void OnInspectorGUI() { serializedObject.Update(); EditorGUILayout.PropertyField(healthProp); EditorGUILayout.PropertyField(maxHealthProp); serializedObject.ApplyModifiedProperties(); } }初写时最容易犯的错是漏掉 Update 和 ApplyModifiedProperties 的配对。Update 从 Unity 的序列化系统中拉取最新数据到 SerializedObject;ApplyModifiedProperties 则把界面上的修改写回对象并产生撤销记录。没有这套配对,会出现修改在界面上一闪而过、资产没被标记为修改过的问题,保存场景时改动直接丢掉。
3.3 用基础控件和控制流把面板做“活”
EditorGUILayout 提供了大量控件方法,常用的有:
- EditorGUILayout.Slider 画滑动条
- EditorGUILayout.Toggle / ToggleLeft 画开关
- EditorGUILayout.ObjectField 画对象拖拽框
- EditorGUILayout.HelpBox 画提示框
- EditorGUILayout.BeginHorizontal / EndHorizontal 水平排版
- EditorGUILayout.Space 手动间距
拿常见的血量组件举例,我想在面板上展示一个颜色会变的血条:
public override void OnInspectorGUI() { serializedObject.Update(); EditorGUILayout.PropertyField(healthProp); EditorGUILayout.PropertyField(maxHealthProp); float health = healthProp.floatValue; float maxHealth = maxHealthProp.floatValue; float ratio = maxHealth > 0 ? health / maxHealth : 0f; Color barColor = ratio > 0.5f ? Color.green : ratio > 0.2f ? Color.yellow : Color.red; EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 18f), barColor); serializedObject.ApplyModifiedProperties(); }这里的核心逻辑是:先把序列化属性读出来,画完标准字段,再做一层可视化增强。你不需要把 GUI 写得多花哨,稳定、直观、不遮挡默认字段是第一原则。用 EditorGUI.DrawRect 画条状图是成本最低的做法,比引入 IMGUI 的 GUIStyle 简单得多。
3.4 可复用的 PropertyDrawer 到底解决了什么
PropertyDrawer 与 CustomEditor 不同,它作用于某个自定义类型,而不是某个组件。举个例子,如果项目里有大量伤害类数据,希望它在任何组件面板上都显示成一个带颜色标签的格式,就可以定义一个结构体:
[System.Serializable] public struct DamageInfo { public float min; public float max; public DamageType type; }再给它配一个绘制器:
[CustomPropertyDrawer(typeof(DamageInfo))] public class DamageInfoDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { var minProp = property.FindPropertyRelative("min"); var maxProp = property.FindPropertyRelative("max"); var typeProp = property.FindPropertyRelative("type"); position.height = EditorGUIUtility.singleLineHeight; EditorGUI.PropertyField(position, typeProp); position.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; EditorGUI.PropertyField(position, minProp); position.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; EditorGUI.PropertyField(position, maxProp); } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { return EditorGUIUtility.singleLineHeight * 3f + EditorGUIUtility.standardVerticalSpacing * 2f; } }PropertyDrawer 必须实现 OnGUI 和 GetPropertyHeight,这两者决定了一个属性在面板上的绘制内容和占用高度。一旦写好,任何脚本里出现 DamageInfo 字段的地方都会自动套用这个画法,不需要每个组件再单独写 Editor。简短总结一下:CustomEditor 是“按组件定制”,PropertyDrawer 是“按类型定制”。后者收益更高,因为一次编写到处生效,是很多成熟项目首选的扩展方式。
4. 用 EditorWindow 搭出独立工具面板:从窗口生命周期到数据落地
4.1 什么时候需要 EditorWindow
当你的扩展需要同时展示多个操作按钮、需要维护一段状态、或者需要给不太熟悉代码的人用,就应该从菜单项升级为 EditorWindow。菜单适合一次性动作,窗口适合“持续的交互工具”。
比方说,你要做一个批量创建关卡配置的工具。窗口上需要选择根目录、输入命名前缀、选择预制体、显示创建结果列表。这些控件堆在一起,用一个窗口管理明显比散落的菜单命令清晰得多。
4.2 最小可用的 EditorWindow 结构
using UnityEditor; using UnityEngine; public class PrefabBatchCreatorWindow : EditorWindow { [MenuItem("Tools/Prefab Batch Creator")] public static void OpenWindow() { var window = GetWindow<PrefabBatchCreatorWindow>(); window.titleContent = new GUIContent("批量预制体创建"); window.minSize = new Vector2(380f, 260f); window.Show(); } private string namePrefix = "Prop_"; private int count = 10; private GameObject prefabTemplate; private void OnGUI() { namePrefix = EditorGUILayout.TextField("命名前缀", namePrefix); count = EditorGUILayout.IntField("创建数量", count); prefabTemplate = (GameObject)EditorGUILayout.ObjectField("模板预制体", prefabTemplate, typeof(GameObject), true); EditorGUILayout.Space(); if (GUILayout.Button("批量创建")) { CreatePrefabs(); } } private void CreatePrefabs() { // 核心创建逻辑 } }窗口内容的绘制同样放在 OnGUI 里,它每帧都可能被调用,所以不要在 OnGUI 里做耗时操作。常见的错是在 OnGUI 里直接 AssetDatabase.LoadAssetAtPath 加载几百个资源,界面会卡到怀疑人生。正确做法是把资源引用暴露成字段,让 Unity 在刷新窗口时自动保留引用,真正加载和批处理都放到按钮回调里执行。
4.3 生命周期回调与刷新时机
EditorWindow 有 OnEnable、OnDisable、OnFocus、OnLostFocus 等回调。OnEnable 常用来初始化状态、订阅事件;OnDisable 常用来反订阅、保存临时数据。
新手容易踩的一个点:在 OnGUI 里直接调用 Close() 关闭窗口。Close() 之后 OnGUI 可能还会被调用若干帧,所以关闭后要立刻用 return 防止后面的代码往已销毁的窗口写数据。
还有一个刷新相关的常识:编辑器窗口什么时候会重绘。鼠标移动、输入事件、Repaint 事件都会触发 OnGUI。如果你在窗口里改了某个外部资产,希望其他窗口同步刷新,可以在修改后主动调用Repaint()或EditorApplication.QueuePlayerLoopUpdate()来请求重绘,避免出现“改了半天界面没反应”的错觉。
4.4 窗口状态与 EditorPrefs:关掉编辑器再打开,数据还在吗
EditorWindow 类里的字段,Unity 在域重载时会尽量序列化一部分,但这并不可靠,尤其是 List、Dictionary 等复杂容器。真正稳妥的做法是使用 EditorPrefs,它类似 PlayerPrefs,但只存在于编辑器环境,可以存字符串、浮点数、整数和 bool。
private void OnDisable() { EditorPrefs.SetFloat("PrefabBatchCreator_Count", count); EditorPrefs.SetString("PrefabBatchCreator_Prefix", namePrefix); } private void OnEnable() { count = (int)EditorPrefs.GetFloat("PrefabBatchCreator_Count", 10); namePrefix = EditorPrefs.GetString("PrefabBatchCreator_Prefix", "Prop_"); }窗口尺寸、上次选择路径、最近使用的几个选项,都适合用 EditorPrefs 保存。要是工具配置项很多,可以落到 ScriptableObject 资产里再配合默认路径加载,这是更工程化的方案。
不过要注意,脚本重编译时 Unity 会重建程序集,EditorWindow 实例会被销毁重建。如果工具持有一些编辑器运行时才能获得的引用,比如某个场景对象,尽量不要直接存窗口字段,建议用完立刻记录路径,重编译后按路径重新加载,这也是很多高阶工具“丢引用”问题的根因。
5. 与资源管线打交道:AssetDatabase、Undo 与序列化修改
5.1 用 AssetDatabase 找到并修改资源
菜单、Inspector、窗口这些界面只是表现层,真正让工具干活的是资源操作 API。AssetDatabase 是编辑器环境下操作项目资产的核心类,常用方法:
- AssetDatabase.GetAssetPath(Object) 获取对象在项目中的路径
- AssetDatabase.LoadAssetAtPath(string, Type) 按路径加载资源
- AssetDatabase.GetDependencies(string) 获取依赖列表
- AssetDatabase.CreateAsset(Object, string) 创建资产
- AssetDatabase.SaveAssets() 保存修改
- AssetDatabase.Refresh() 刷新,让新生成的文件在项目视图可见
一个实际例子:批量把选中贴图的 Alpha Is Transparency 打开。
[MenuItem("Tools/Texture/开启 Alpha Is Transparency")] public static void EnableAlphaTransparency() { foreach (Object obj in Selection.objects) { string path = AssetDatabase.GetAssetPath(obj); if (string.IsNullOrEmpty(path) || !path.EndsWith(".png")) { continue; } TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer == null) { continue; } importer.alphaIsTransparency = true; AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); }这个模式在资源工具开发里极其通用:拿路径,取 Importer,改属性,重新导入。不管是 TextureImporter、ModelImporter 还是 AudioImporter,流程都是同一套。AssetDatabase.ImportAsset是让改动的导入设置真正生效的关键,很多人忘了这一行,在面板上看到了新值,但最终打包和预览用的还是旧设置,排查时会浪费很长时间。
5.2 修改场景或预制体:Undo 优先
凡是会改变场景对象、组件等持久化数据的编辑器代码,都应该考虑支持撤销。Unity 内置撤销系统的 API 集中在 Undo 类:
Undo.RecordObject(targetObject, "修改血量上限"); targetObject.health = 999; EditorUtility.SetDirty(targetObject);RecordObject 会在修改前记录对象状态,Ctrl+Z 时可回退。创建新对象时用 RegisterCreatedObjectUndo,删除对象时用 DestroyObjectImmediate 加 Undo 记录也是有意义的。
项目里有个不成文的规矩:任何批量修改资源的工具,最好先问一句“如果我误操作了,能不能撤销”。很多内部工具翻车,就是因为缺少这一步。对于无法走 Undo 的资产导入设置,比如贴图导入属性,那就在操作前打印一批修改清单,或者做一个“操作前备份到独立文件”的安全网。
5.3 编辑器生命周期:启动即初始化,重编译不慌张
有些工具需要在编辑器启动、脚本编译完成这些时间点自动执行任务。初始化入口的典型写法:
[InitializeOnLoad] public class ProjectToolInitializer { static ProjectToolInitializer() { EditorApplication.delayCall += () => { // 在这里做启动后的初始化,例如检查上次构建是否产生了缓存 }; } }InitializeOnLoad 特性的类只要放在 Editor 目录下,静态构造会在程序集加载后执行。delayCall 委托则保证等一帧再执行,适合处理那些需要确保其他子系统已经就绪的操作。
如果你写了这类启动逻辑,请永远记得:Unity 每次脚本重编译都会触发静态构造。如果你的初始化逻辑会重复注册事件、重复生成文件,一定要加防重逻辑,比如静态标记字段判断是否已执行。否则项目一改脚本,工具就悄悄多生成一堆垃圾文件,这类问题在多人协作时尤其隐蔽。
6. 编辑器扩展最容易翻车的三个位置,以及一个推荐的项目结构
6.1 翻车位置一:在 OnGUI 里做了重活
OnGUI 在两帧之间可能执行多次,Unity 内部的输入、布局、重绘事件都会触发它。把资源加载、AssetDatabase 扫描、文件读写放到 OnGUI 里,等于让编辑器每帧都在做体力活,整个编辑器都会卡。
规范做法:按钮回调里执行重活,结果缓存到字段,OnGUI 只做展示。真需要实时刷新时,用EditorApplication.QueuePlayerLoopUpdate控制刷新频率,不要盲目每帧全量重算。
6.2 翻车位置二:改了序列化数据却忘了说一声
前面反复强调 Update 和 ApplyModifiedProperties 的原因就在这里。编辑器扩展直接改写组件字段时,如果没有走这套序列化流程,Unity 的修改标记和撤销记录都不会生成。表现是:运行游戏时字段是新的,但保存场景或关掉编辑器再打开,改动没了。
还有一个常见连带问题:忘记调用EditorUtility.SetDirty。当你不通过 SerializedProperty,而是直接修改了一些非序列化脚本行为的数据时,需要 SetDirty 告知 Unity 该对象发生了变化。
6.3 翻车位置三:工具与业务代码混淆,打包时报错
我再强调一遍:编辑器代码的典型存放位置是 Editor 文件夹或#if UNITY_EDITOR包裹。放在 Assets 下普通文件夹里的编辑器类,在打包时如果没有正确排除,就会出现编译错误或运行时引用 UnityEditor 的异常。规范做法是目录结构清晰,工具代码和运行时代码严格隔离。
一个我比较推荐的中型工具目录结构:
Assets/ Editor/ Tools/ BatchTools.cs TextureTools.cs Inspectors/ HealthControllerEditor.cs Windows/ PrefabBatchCreatorWindow.cs PropertyDrawers/ DamageInfoDrawer.cs Scripts/ Runtime/ HealthController.csEditor 下再按 Tools、Inspectors、Windows、PropertyDrawers 分文件夹。不是所有项目都这么分,但一个几百行以上的工具集,不分区会很快乱掉。命名空间也建议统一,比如 namespace ProjectTools,避免多个工具类之间的类名冲突。
6.4 让工具自己“可被检查”:一个建议
最后分享一个小经验:好的工具应该自带体检能力。在我自己维护的扩展集里,每个工具在启动时会注册到一个静态检查表,Inspector 里能看到哪些工具可用、上次执行时间、执行结果。调试和交接都方便很多。坚持一段时间后你会明显感觉到,工具开发最难的从来不是写第一个按钮,而是后续几个月里,项目变了、需求变了,你的工具还能稳定用下去。
我个人在实际操作中最深的体会是:编辑器扩展写得好不好,不取决于你会多少冷门 API,而取决于你踩过多少坑以后,把“稳定”放在了“炫技”前面。先把这些常用方法用熟,再谈架构和花活。你迟早会发现,这是全 Unity 开发里性价比最高的投资之一。