Unity Editor脚本开发全攻略:从MenuItem到SceneView交互

📅 2026/8/6 2:21:14
Unity Editor脚本开发全攻略:从MenuItem到SceneView交互
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 GetWindowMyCustomWindow(); 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 定制InspectorEditor与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 { // 获取当前选中的所有GameObjectHierarchy和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 ListGameObject objectsToProcess; private int currentIndex 0; private bool isProcessing false; [MenuItem(“MyTools/打开批量处理器”)] private static void ShowWindow() { GetWindowBatchProcessor(“批量处理器”).Show(); } private void OnGUI() { if (GUILayout.Button(“选择并处理物体”)) { objectsToProcess new ListGameObject(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.GetComponentProcessedMarker() null) { Undo.RecordObject(go, “Add ProcessedMarker”); go.AddComponentProcessedMarker(); } 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 ListItemData items new ListItemData(); } [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.AddComponentMyComponent(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工具是一个从提升个人效率到赋能整个团队的过程。最开始可能只是为了省去重复点击后来会逐渐发展为构建复杂的资产管线、自动化测试工具甚至是连接外部数据的中台。其核心价值在于将你对项目和工作流的深刻理解固化为可重复执行的工具从而释放创造力专注于更本质的问题。