1. 项目概述:为什么我们需要系统化整理Editor脚本方法?
在Unity开发中,无论是制作工具、优化工作流,还是为团队定制开发环境,编写Editor扩展脚本都是提升效率的必经之路。然而,Unity的Editor API庞大且分散,很多方法藏在不同的命名空间里,官方文档虽然详尽,但缺乏场景化的串联。新手常常面对EditorWindow、MenuItem、GUILayout感到无从下手,而老手也可能因为长期依赖零散的代码片段,而忽略了一些更高效或更优雅的写法。
这个项目,就是一次对Unity Editor脚本编写中那些高频、实用方法的系统性梳理与汇总。它不是简单的API罗列,而是结合我多年在项目工具链开发、资产管线定制中的实战经验,将那些“好用但容易忘”、“强大但文档没说清”的方法,按照实际使用场景进行分类和解读。无论你是想快速创建一个自定义配置窗口,还是为组件添加一个便捷的Inspector面板按钮,或是批量处理项目中的资源,这里汇总的方法都能为你提供清晰的路径和可复用的代码块。
2. 核心模块拆解:Editor扩展的四大基石
要玩转Unity Editor开发,核心是掌握几个关键模块。它们就像乐高积木,组合起来能构建出功能强大的自定义工具。
2.1 入口与菜单:MenuItem与EditorWindow
一切自定义工具的开始,都需要一个入口。MenuItem是最直接的方式,它允许你在Unity编辑器顶部的菜单栏中添加自定义项目。
using UnityEditor; using UnityEngine; public class CustomMenuItems { // 基础菜单项,点击后执行静态方法 [MenuItem(“MyTools/快速操作/打印HelloWorld”)] private static void PrintHello() { Debug.Log(“Hello from MyTools!”); } // 带验证的菜单项,可用于条件性启用/禁用 [MenuItem(“MyTools/高级操作/处理选中物体”, true)] // 第二个参数为验证函数 private static bool ValidateProcessSelected() { // 仅当有物体被选中时,该菜单项才可用 return Selection.activeGameObject != null; } [MenuItem(“MyTools/高级操作/处理选中物体”)] private static void ProcessSelected() { // 实际的处理逻辑 Debug.Log($“正在处理:{Selection.activeGameObject.name}”); } }但简单的菜单命令往往不够,我们需要一个界面来交互。这时EditorWindow就登场了。它是你创建独立工具窗口的基类。
using UnityEditor; using UnityEngine; public class MyCustomWindow : EditorWindow { // 创建菜单项并打开窗口 [MenuItem(“MyTools/打开配置窗口”)] private static void ShowWindow() { // 获取或创建一个窗口实例 var window = GetWindow<MyCustomWindow>(); window.titleContent = new GUIContent(“我的工具窗口”); window.Show(); } // 窗口的GUI绘制逻辑在此实现 private void OnGUI() { GUILayout.Label(“这是一个自定义编辑器窗口”, EditorStyles.boldLabel); // 更多GUI元素将在后面介绍 } }注意:
MenuItem的路径可以自定义层级,用/分隔。合理的路径规划能让你的工具集显得更专业、更易用。验证函数(第二个参数为true)是一个常被忽略但极其有用的功能,它可以防止用户在无效状态下误操作。
2.2 界面构建核心:GUILayout与EditorGUILayout
有了窗口,就要在里面摆放控件。Unity提供了两套主要的GUI系统:GUILayout和EditorGUILayout。简单来说,GUILayout是自动布局系统,你只需要声明控件,它会自动排列;而EditorGUILayout是在前者基础上,提供了大量为编辑器量身定制的、样式统一的高级控件。
自动布局 vs 绝对布局: 初学者容易混淆GUILayout和GUI。GUI要求你手动指定每个控件的矩形位置(Rect),虽然灵活但非常繁琐。GUILayout解放了你,你只需关心控件的顺序和参数。
private void OnGUI() { // 使用GUILayout(自动布局) GUILayout.Label(“自动布局标签”); myString = GUILayout.TextField(myString); if (GUILayout.Button(“自动布局按钮”)) { // 点击事件 } // 使用GUI(绝对布局)- 需要计算位置 GUI.Label(new Rect(10, 50, 200, 20), “绝对布局标签”); myString = GUI.TextField(new Rect(10, 80, 200, 20), myString); if (GUI.Button(new Rect(10, 110, 200, 20), “绝对布局按钮”)) { // 点击事件 } }对于绝大多数编辑器工具,GUILayout和EditorGUILayout的组合足以应对。EditorGUILayout提供了诸如ObjectField(对象选择框)、Popup(下拉菜单)、Toggle(开关)等控件,它们的外观和行为与Unity原生Inspector保持一致,能极大提升工具的专业度和用户体验。
using UnityEngine; private GameObject targetObj; private int selectedIndex = 0; private string[] options = { “选项A”, “选项B”, “选项C” }; private bool toggleState = false; private void OnGUI() { // 对象字段:像Inspector里一样拖拽赋值 targetObj = (GameObject)EditorGUILayout.ObjectField(“目标物体”, targetObj, typeof(GameObject), true); // 下拉菜单 selectedIndex = EditorGUILayout.Popup(“选择模式”, selectedIndex, options); // 开关 toggleState = EditorGUILayout.Toggle(“启用特效”, toggleState); // 滑块 float sliderValue = EditorGUILayout.Slider(“强度”, 0.5f, 0f, 1f); // 颜色字段 Color colorValue = EditorGUILayout.ColorField(“颜色”, Color.blue); }实操心得:混合使用
GUILayout和EditorGUILayout时,注意它们不能交叉进行自动布局。通常在一个OnGUI方法中,选定一种布局方式贯穿使用,或者用GUILayout.BeginArea划定区域进行切换。EditorGUILayout的控件在获取焦点、撤销操作等方面有更好的集成。
2.3 定制Inspector:Editor与PropertyDrawer
除了创建独立窗口,另一个高频需求是增强现有组件在Inspector中的显示效果。这就需要用到自定义Editor类。
通过为你的MonoBehaviour脚本创建一个同名的Editor类(放在Editor文件夹下),你可以完全重写其在Inspector中的绘制逻辑。
// MyComponent.cs (运行时脚本) using UnityEngine; public class MyComponent : MonoBehaviour { public string displayName; public int health; public Vector3 startPosition; } // MyComponentEditor.cs (必须放在Editor文件夹内) using UnityEditor; using UnityEngine; [CustomEditor(typeof(MyComponent))] public class MyComponentEditor : Editor { public override void OnInspectorGUI() { // 1. 绘制默认Inspector(等同于不写这个Editor类时的样子) // DrawDefaultInspector(); // return; // 2. 自定义绘制 MyComponent myTarget = (MyComponent)target; // 获取当前检视的对象 EditorGUILayout.LabelField(“自定义Inspector”, EditorStyles.boldLabel); // 使用SerializedProperty进行序列化数据的绘制,支持撤销和多对象编辑 SerializedProperty nameProp = serializedObject.FindProperty(“displayName”); EditorGUILayout.PropertyField(nameProp, new GUIContent(“显示名称”)); // 也可以直接修改目标对象的字段,但这样对撤销/重做的支持不完善 // myTarget.health = EditorGUILayout.IntField(“生命值”, myTarget.health); // 使用PropertyField绘制复杂类型(如Vector3) SerializedProperty posProp = serializedObject.FindProperty(“startPosition”); EditorGUILayout.PropertyField(posProp); // 自定义按钮 if (GUILayout.Button(“重置位置”)) { myTarget.transform.position = Vector3.zero; } // 将SerializedProperty的修改应用回目标对象 serializedObject.ApplyModifiedProperties(); } }对于更细粒度的控制,比如只想定制某个特定类型字段的绘制方式(例如,一个Enum显示为按钮组,一个float显示为带单位的滑块),可以使用PropertyDrawer。它为序列化属性提供可重用的绘制器。
// 自定义属性,用于标记 public class RangeWithUnitAttribute : PropertyAttribute { public string Unit { get; private set; } public RangeWithUnitAttribute(string unit = “m”) { Unit = unit; } } // 对应的PropertyDrawer (放在Editor文件夹) using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(RangeWithUnitAttribute))] public class RangeWithUnitDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { RangeWithUnitAttribute attr = attribute as RangeWithUnitAttribute; // 绘制前缀标签 position = EditorGUI.PrefixLabel(position, label); // 计算字段和单位标签的矩形区域 Rect fieldRect = new Rect(position.x, position.y, position.width - 30, position.height); Rect unitRect = new Rect(position.x + position.width - 28, position.y, 28, position.height); // 绘制FloatField property.floatValue = EditorGUI.FloatField(fieldRect, property.floatValue); // 绘制单位标签 EditorGUI.LabelField(unitRect, attr.Unit); } } // 在MonoBehaviour中使用 public class TestComponent : MonoBehaviour { [RangeWithUnit(“km”)] public float distance; }注意事项:使用
Editor类时,务必区分target(当前编辑的对象实例)和serializedObject(该对象的序列化表示)。直接修改target的字段虽然简单,但会绕过Unity的序列化系统,可能导致撤销操作无效、预制件覆盖提示不准确等问题。最佳实践是:始终通过serializedObject.FindProperty和EditorGUILayout.PropertyField来绘制和修改序列化字段。PropertyDrawer的优势在于其可复用性,一次编写,所有使用了该属性的字段都会自动应用此绘制逻辑。
2.4 资产与项目管理:AssetDatabase与Selection
工具脚本经常需要与项目资产和场景中的对象交互。AssetDatabase是管理资产(如预制件、材质、脚本)的核心类,而Selection则用于处理当前选中的对象。
AssetDatabase常用操作:
using UnityEditor; using UnityEngine; using System.IO; public class AssetOperations { [MenuItem(“Assets/我的工具/获取选中资产路径”)] private static void LogSelectedAssetPath() { // 获取Project窗口选中的资产(第一个) Object selected = Selection.activeObject; if (selected != null) { string path = AssetDatabase.GetAssetPath(selected); Debug.Log($“资产路径:{path}”); // 获取依赖资源 string[] dependencies = AssetDatabase.GetDependencies(path); Debug.Log($“依赖资源数量:{dependencies.Length}”); } } [MenuItem(“Assets/我的工具/批量重命名选中纹理”)] private static void BatchRenameTextures() { // 获取所有选中的纹理资产 Object[] selectedTextures = Selection.GetFiltered(typeof(Texture2D), SelectionMode.Assets); int index = 1; foreach (Texture2D tex in selectedTextures) { string oldPath = AssetDatabase.GetAssetPath(tex); string dir = Path.GetDirectoryName(oldPath); string newName = $“Texture_{index++.ToString(“D3”)}.png”; // 如 Texture_001.png string newPath = Path.Combine(dir, newName); // 重命名资产 string result = AssetDatabase.RenameAsset(oldPath, newName); if (string.IsNullOrEmpty(result)) // 成功时返回空字符串 { Debug.Log($“重命名成功:{oldPath} -> {newPath}”); } else { Debug.LogError($“重命名失败:{result}”); } } // 重要:操作完成后刷新数据库,使更改在编辑器中可见 AssetDatabase.Refresh(); } // 创建资产 [MenuItem(“MyTools/创建默认材质球”)] private static void CreateDefaultMaterial() { Material newMat = new Material(Shader.Find(“Standard”)); newMat.name = “New_Material”; // 指定保存路径 string path = “Assets/Materials/New_Material.mat”; // 确保目录存在 string dir = Path.GetDirectoryName(path); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } // 创建资产 AssetDatabase.CreateAsset(newMat, path); AssetDatabase.SaveAssets(); // 保存 AssetDatabase.Refresh(); // 刷新 Debug.Log($“材质已创建:{path}”); } }Selection常用操作:
using UnityEditor; using UnityEngine; public class SelectionOperations { // 获取当前选中的所有GameObject(Hierarchy和Scene视图) [MenuItem(“GameObject/我的工具/打印选中物体名”, false, 10)] // 第三个参数是菜单优先级 private static void PrintSelectedNames() { GameObject[] selectedGOs = Selection.gameObjects; foreach (GameObject go in selectedGOs) { Debug.Log(go.name, go); // 第二个参数可点击日志跳转到对象 } Debug.Log($“共选中了 {selectedGOs.Length} 个游戏对象”); } // 操作选中物体的变换组件 [MenuItem(“GameObject/我的工具/重置选中物体的变换”, true)] // 验证函数 private static bool ValidateResetTransform() { return Selection.activeTransform != null; } [MenuItem(“GameObject/我的工具/重置选中物体的变换”)] private static void ResetTransform() { // 支持多选操作 foreach (Transform t in Selection.transforms) { Undo.RecordObject(t, “Reset Transform”); // 记录撤销操作 t.localPosition = Vector3.zero; t.localRotation = Quaternion.identity; t.localScale = Vector3.one; } } }实操心得:
AssetDatabase的任何创建、移动、删除、重命名操作,最后都必须跟上AssetDatabase.Refresh(),否则编辑器界面可能无法立即更新。对于Selection,要善用Selection.GetFiltered来按类型筛选对象,这比遍历Selection.gameObjects再判断类型更高效。在进行任何会修改场景或资产的操作前,使用Undo.RecordObject或Undo.RecordObjects来记录状态是一个好习惯,它能提供完美的撤销/重做支持,是专业工具的标志。
3. 高级技巧与实战模式
掌握了基础模块后,我们可以组合它们,实现更复杂、更实用的工具模式。
3.1 编辑器协程与进度条
在编辑器下执行耗时操作(如批量导入、处理大量数据)时,直接使用循环会阻塞主线程,导致编辑器卡死无响应。这时需要模拟协程,并给用户提供进度反馈。
Unity Editor提供了EditorApplication.update委托来模拟更新循环,结合EditorUtility.DisplayProgressBar显示进度条。
using UnityEditor; using UnityEngine; using System.Collections.Generic; public class BatchProcessor : EditorWindow { private List<GameObject> objectsToProcess; private int currentIndex = 0; private bool isProcessing = false; [MenuItem(“MyTools/打开批量处理器”)] private static void ShowWindow() { GetWindow<BatchProcessor>(“批量处理器”).Show(); } private void OnGUI() { if (GUILayout.Button(“选择并处理物体”)) { objectsToProcess = new List<GameObject>(Selection.gameObjects); if (objectsToProcess.Count > 0) { currentIndex = 0; StartProcessing(); } else { EditorUtility.DisplayDialog(“提示”, “请先在场景中选择一些游戏对象”, “确定”); } } if (isProcessing) { EditorGUILayout.HelpBox(“处理中,请勿操作编辑器...”, MessageType.Info); } } private void StartProcessing() { isProcessing = true; // 注册到更新委托,模拟协程 EditorApplication.update += ProcessCoroutine; } private void ProcessCoroutine() { if (currentIndex >= objectsToProcess.Count) { // 处理完成 FinishProcessing(); return; } GameObject go = objectsToProcess[currentIndex]; // 更新进度条 float progress = (float)currentIndex / objectsToProcess.Count; if (EditorUtility.DisplayCancelableProgressBar(“批量处理”, $“正在处理:{go.name}”, progress)) { // 用户点击了取消 EditorUtility.ClearProgressBar(); FinishProcessing(); return; } // 模拟耗时操作(例如,修改组件、添加脚本等) // 这里为了示例,只是简单地添加一个标记组件 if (go.GetComponent<ProcessedMarker>() == null) { Undo.RecordObject(go, “Add ProcessedMarker”); go.AddComponent<ProcessedMarker>(); } currentIndex++; // 强制重绘界面(非必须,但能让进度显示更及时) Repaint(); } private void FinishProcessing() { EditorApplication.update -= ProcessCoroutine; // 务必取消注册! EditorUtility.ClearProgressBar(); isProcessing = false; objectsToProcess.Clear(); Debug.Log(“批量处理完成!”); this.Repaint(); // 刷新窗口UI } // 当窗口关闭时,确保清理 private void OnDestroy() { if (isProcessing) { EditorApplication.update -= ProcessCoroutine; EditorUtility.ClearProgressBar(); } } } // 一个简单的标记组件 public class ProcessedMarker : MonoBehaviour { }注意事项:使用
EditorApplication.update模拟协程时,有两大关键点:第一,必须手动管理其注册与注销。在操作开始(StartProcessing)时注册,在操作完成或取消(FinishProcessing)以及窗口销毁(OnDestroy)时务必注销,否则会导致内存泄漏和持续执行。第二,DisplayCancelableProgressBar的第三个参数(进度)范围是0到1,务必正确计算。任何耗时操作都应放在这个“协程”中,而不是直接放在OnGUI的按钮回调里。
3.2 序列化数据与ScriptableObject工具
ScriptableObject是存储编辑器配置、游戏设计数据的绝佳容器。为ScriptableObject创建自定义的编辑工具能极大提升策划和开发效率。
// 数据容器:ItemDatabase.asset using UnityEngine; using System.Collections.Generic; [CreateAssetMenu(fileName = “ItemDatabase”, menuName = “Game Data/Item Database”)] public class ItemDatabase : ScriptableObject { public List<ItemData> items = new List<ItemData>(); } [System.Serializable] public class ItemData { public string itemID; public string itemName; public Sprite icon; public int maxStack = 99; } // 编辑器工具:ItemDatabaseEditor.cs using UnityEditor; using UnityEngine; [CustomEditor(typeof(ItemDatabase))] public class ItemDatabaseEditor : Editor { private SerializedProperty itemsProp; private Vector2 scrollPos; private void OnEnable() { // 在OnEnable中查找属性,避免每次OnGUI都查找 itemsProp = serializedObject.FindProperty(“items”); } public override void OnInspectorGUI() { serializedObject.Update(); EditorGUILayout.LabelField(“道具数据库编辑器”, EditorStyles.boldLabel); EditorGUILayout.HelpBox(“在这里管理所有游戏道具数据。”, MessageType.Info); // 添加新道具的按钮 if (GUILayout.Button(“+ 添加新道具”, GUILayout.Width(120))) { itemsProp.arraySize++; // 将新增元素的属性展开,方便编辑 SerializedProperty newItem = itemsProp.GetArrayElementAtIndex(itemsProp.arraySize - 1); // 可以在这里为新元素设置一些默认值 newItem.FindPropertyRelative(“itemID”).stringValue = “ITEM_” + (itemsProp.arraySize).ToString(“D4”); newItem.FindPropertyRelative(“itemName”).stringValue = “新道具”; newItem.FindPropertyRelative(“maxStack”).intValue = 99; } EditorGUILayout.Space(10); // 列表视图 scrollPos = EditorGUILayout.BeginScrollView(scrollPos); for (int i = 0; i < itemsProp.arraySize; i++) { EditorGUILayout.BeginVertical(EditorStyles.helpBox); SerializedProperty itemProp = itemsProp.GetArrayElementAtIndex(i); EditorGUILayout.BeginHorizontal(); // 显示序号和删除按钮 EditorGUILayout.LabelField($“道具 [{i}]”, GUILayout.Width(60)); if (GUILayout.Button(“X”, GUILayout.Width(20))) { itemsProp.DeleteArrayElementAtIndex(i); // 删除后需要立即应用并退出循环,因为数组大小已变 serializedObject.ApplyModifiedProperties(); break; } EditorGUILayout.EndHorizontal(); // 绘制道具的各个字段 EditorGUILayout.PropertyField(itemProp.FindPropertyRelative(“itemID”), new GUIContent(“道具ID”)); EditorGUILayout.PropertyField(itemProp.FindPropertyRelative(“itemName”), new GUIContent(“道具名称”)); EditorGUILayout.PropertyField(itemProp.FindPropertyRelative(“icon”), new GUIContent(“图标”)); EditorGUILayout.PropertyField(itemProp.FindPropertyRelative(“maxStack”), new GUIContent(“最大堆叠”)); EditorGUILayout.EndVertical(); EditorGUILayout.Space(5); } EditorGUILayout.EndScrollView(); serializedObject.ApplyModifiedProperties(); } }这个自定义Inspector为ItemDatabase提供了一个清晰的列表界面,支持增删改查,比默认的列表展开方式友好得多。关键在于使用SerializedProperty来操作数组元素,并通过FindPropertyRelative访问嵌套的字段。这种方式完全在Unity的序列化系统内工作,支持撤销、预制件覆盖,并且数据能正确保存。
3.3 场景视图(SceneView)交互
有时我们需要在Scene视图里进行可视化编辑,比如绘制路径点、编辑地形笔刷范围等。这需要通过SceneView.duringSceneGui事件来实现。
using UnityEditor; using UnityEngine; [InitializeOnLoad] // 确保类在编辑器启动时初始化 public class SceneViewGridDrawer { private static bool isEnabled = false; private const string MENU_NAME = “Tools/显示场景网格”; static SceneViewGridDrawer() { // 将菜单项的勾选状态与我们的静态变量同步 isEnabled = EditorPrefs.GetBool(MENU_NAME, false); Menu.SetChecked(MENU_NAME, isEnabled); // 根据状态注册或注销事件 UpdateSceneGUI(); } [MenuItem(MENU_NAME)] private static void ToggleGrid() { isEnabled = !isEnabled; Menu.SetChecked(MENU_NAME, isEnabled); EditorPrefs.SetBool(MENU_NAME, isEnabled); // 保存偏好设置 UpdateSceneGUI(); } private static void UpdateSceneGUI() { if (isEnabled) { SceneView.duringSceneGui += OnSceneGUI; } else { SceneView.duringSceneGui -= OnSceneGUI; } } private static void OnSceneGUI(SceneView sceneView) { Handles.BeginGUI(); // 开始2D GUI绘制 // 在Scene视图左上角绘制一个信息标签 GUILayout.BeginArea(new Rect(10, 10, 200, 60)); EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUILayout.Label(“网格绘制已启用”, EditorStyles.boldLabel); GUILayout.Label($“视角大小: {sceneView.camera.orthographicSize:F2}”); EditorGUILayout.EndVertical(); GUILayout.EndArea(); Handles.EndGUI(); // 结束2D GUI绘制 // 使用Handles绘制3D图形:一个网格 DrawGrid(sceneView); } private static void DrawGrid(SceneView sceneView) { float gridSize = 10f; float cellSize = 1f; int lineCount = Mathf.RoundToInt(gridSize / cellSize); float halfSize = gridSize * 0.5f; Vector3 center = Vector3.zero; // 网格中心 // 设置Handles颜色 Handles.color = new Color(0.5f, 0.5f, 0.5f, 0.3f); // 半透明的灰色 // 绘制XZ平面上的网格线 for (int i = 0; i <= lineCount; i++) { float offset = -halfSize + i * cellSize; // 平行于Z轴的线 Vector3 startX = center + new Vector3(offset, 0, -halfSize); Vector3 endX = center + new Vector3(offset, 0, halfSize); Handles.DrawLine(startX, endX); // 平行于X轴的线 Vector3 startZ = center + new Vector3(-halfSize, 0, offset); Vector3 endZ = center + new Vector3(halfSize, 0, offset); Handles.DrawLine(startZ, endZ); } // 绘制坐标轴 Handles.color = Color.red; Handles.DrawLine(center, center + Vector3.right * (halfSize + 1)); Handles.color = Color.green; Handles.DrawLine(center, center + Vector3.up * (halfSize + 1)); Handles.color = Color.blue; Handles.DrawLine(center, center + Vector3.forward * (halfSize + 1)); } }这个例子创建了一个可以在Scene视图切换显示的网格和坐标轴。关键点在于:
[InitializeOnLoad]确保静态构造函数在编辑器启动时运行,用于读取保存的偏好设置。- 通过
SceneView.duringSceneGui事件注册绘制函数。 - 在
OnSceneGUI中,可以使用Handles.BeginGUI()/EndGUI()来绘制2D UI,使用Handles.DrawLine等方法来绘制3D图形。 - 使用
EditorPrefs保存工具的开启状态,这样重启Unity后设置依然保留。 - 务必注意事件的注册与注销管理,避免内存泄漏。
4. 常见问题与调试技巧
即使掌握了方法,在实际编写中还是会遇到各种问题。这里记录一些高频问题和排查思路。
4.1 编辑器脚本不执行或报错
问题现象:菜单没出现,窗口打不开,或者代码修改后编辑器没反应。
排查步骤:
- 检查脚本位置:自定义
Editor、PropertyDrawer、EditorWindow等类必须放在名为Editor的文件夹中(可以在Assets下任何层级的Editor文件夹)。普通的MenuItem静态方法可以放在任何非Editor文件夹的脚本中。 - 检查编译错误:编辑器脚本的编译和加载依赖于项目没有编译错误。查看Console窗口是否有任何错误(即使是其他脚本的错误),这可能会阻止编辑器脚本编译。
- 检查类名与文件名:确保脚本文件名与类名一致。
- 检查菜单路径:
MenuItem的路径不能有重复。如果重复,只有第一个会生效。 - 重启Unity或重新编译:有时Unity的脚本编译缓存会出问题。尝试在代码修改后,点击Unity的
Assets -> Refresh,或者直接重启Unity编辑器。 - 检查命名空间:确保使用了
using UnityEditor;。
4.2 序列化数据丢失或显示不正确
问题现象:在自定义Inspector中修改的值没有保存,或者多选编辑时值乱套。
解决方案:
- 始终使用
SerializedProperty:这是黄金法则。通过serializedObject.FindProperty(“fieldName”)找到属性,用EditorGUILayout.PropertyField()绘制,最后调用serializedObject.ApplyModifiedProperties()。这能完美支持撤销、预制件覆盖和多对象编辑。 - 在
OnEnable中缓存属性:避免在每次OnGUI中都调用FindProperty,将其缓存在成员变量中。 - 理解
serializedObject.Update():它从目标对象拉取最新的序列化数据到SerializedObject中。通常在OnInspectorGUI开始时调用。而ApplyModifiedProperties()则是将修改写回目标对象。
4.3 编辑器性能优化
当工具需要处理大量数据(如绘制包含数百个元素的列表)时,性能可能成为问题。
优化策略:
- 使用
EditorGUIUtility.SetWantsMouseJumping:对于可滚动的长列表,在鼠标滚轮滚动时,设置此值为1可以启用“鼠标跳跃”,让滚动更平滑。scrollPos = EditorGUILayout.BeginScrollView(scrollPos); EditorGUIUtility.SetWantsMouseJumping(1); // 开始滚动时启用 // ... 绘制列表项 EditorGUIUtility.SetWantsMouseJumping(0); // 结束滚动时禁用 EditorGUILayout.EndScrollView(); - 分页或虚拟列表:对于极其庞大的列表,考虑实现分页加载,或者只绘制视口内的项(虚拟列表)。
- 避免在
OnGUI中进行昂贵计算:OnGUI每帧可能调用多次。将复杂的计算(如排序、搜索)结果缓存起来,只在数据变化时重新计算。 - 使用
EditorGUI.BeginChangeCheck和EndChangeCheck:如果你有很多控件,但只想在特定控件变化时才执行某些操作,可以用这对方法包裹,避免不必要的逻辑执行。EditorGUI.BeginChangeCheck(); someValue = EditorGUILayout.IntField(“阈值”, someValue); if (EditorGUI.EndChangeCheck()) { // 只有当阈值被修改时才重新计算 RecalculateBasedOnThreshold(); }
4.4 处理Undo(撤销)操作
专业的工具必须支持撤销。Unity提供了Undo类来记录操作。
// 记录单个对象的一个操作 Undo.RecordObject(gameObject, “Change GameObject Name”); gameObject.name = “NewName”; // 记录多个对象的操作 Undo.RecordObjects(new Object[] {obj1, obj2}, “Change Multiple Objects”); obj1.value = 10; obj2.value = 20; // 更复杂的操作组,可以折叠成一步撤销 Undo.SetCurrentGroupName(“Complex Setup”); int group = Undo.GetCurrentGroup(); Undo.RecordObject(transform, “Reset Position”); transform.position = Vector3.zero; Undo.RecordObject(renderer, “Change Material”); renderer.material = newMaterial; // 将之前的所有操作合并到一步 Undo.CollapseUndoOperations(group); // 添加或移除组件 Undo.AddComponent<MyComponent>(gameObject); Undo.DestroyObjectImmediate(component); // 用于撤销删除组件实操心得:对于通过
SerializedProperty和PropertyField进行的修改,Unity会自动处理撤销,无需手动调用Undo.RecordObject。但对于直接修改对象字段、调用AddComponent、DestroyImmediate等操作,务必手动添加Undo记录。给操作起一个清晰的名字(如“重命名所有选中物体”),能让用户在撤销历史中一目了然。
4.5 编辑器脚本的调试
调试编辑器脚本与调试游戏脚本略有不同。
Debug.Log与Object参数:Debug.Log的第二个参数可以传入一个UnityEngine.Object。这样在Console中点击该日志时,编辑器会自动Ping(高亮)该对象,非常方便。Debug.Log($“处理了物体:{go.name}”, go);- 使用
EditorWindow作为调试面板:可以创建一个简单的EditorWindow,实时显示一些内部变量或状态。 - Visual Studio / Rider 附加调试:和游戏调试一样,你可以在编辑器中设置断点,然后通过Visual Studio或Rider的“Attach to Unity Editor”功能进行调试。关键点:确保你编译的是“Development Build”的编辑器项目(在Build Settings中),并且脚本调试符号已生成。
EditorUtility.DisplayDialog用于临时确认:在不确定代码执行路径时,可以用弹窗来确认。
但注意不要在产品代码中留下这些弹窗,它们会阻塞主线程。if (EditorUtility.DisplayDialog(“确认”, “确定要执行此操作吗?”, “确定”, “取消”)) { // 执行操作 }
编写Editor工具是一个从提升个人效率到赋能整个团队的过程。最开始可能只是为了省去重复点击,后来会逐渐发展为构建复杂的资产管线、自动化测试工具,甚至是连接外部数据的中台。其核心价值在于,将你对项目和工作流的深刻理解,固化为可重复执行的工具,从而释放创造力,专注于更本质的问题。