1. 项目概述为什么我们需要自定义编辑器窗体在Unity开发中无论是独立开发者还是团队协作与编辑器打交道的时间可能比实际运行游戏还要多。Unity自带的Inspector、Project窗口等虽然功能强大但面对特定项目需求时常常显得不够用。比如你需要一个批量重命名场景中所有灯光预设的工具或者一个可视化的角色技能配置面板又或者是一个快速生成特定数据结构如对话树、关卡数据的向导。这时候原生的编辑器界面就捉襟见肘了。自定义编辑器窗体EditorWindow和ScriptableWizard就是Unity提供给我们的“瑞士军刀”让我们能像搭积木一样为编辑器扩展出专属的工作台。这不仅仅是提升效率更是将复杂、重复的工作流程化、自动化、可视化的关键。我见过很多团队核心玩法代码写得漂亮但工具链一塌糊涂导致策划和美术同学配置一个功能要经过七八个步骤还容易出错。一个设计良好的自定义编辑器能极大降低沟通成本提升内容生产的质量和速度。简单来说EditorWindow让你可以创建一个独立的、可停靠的编辑器窗口功能几乎不受限而ScriptableWizard则是一个更轻量、更聚焦的向导式对话框适合执行一次性的、有明确步骤的任务。掌握它们你就能从“使用编辑器的人”变成“塑造编辑器的人”。接下来我会结合我多年踩坑和实战的经验带你彻底搞懂这两种工具从原理到细节从创建到优化让你也能打造出专业级的开发工具。2. 核心工具解析EditorWindow 与 ScriptableWizard 的定位与选择在动手之前我们必须先厘清这两个核心类的区别和适用场景。选错了工具就像用螺丝刀去敲钉子事倍功半。2.1 EditorWindow你的专属工作站EditorWindow是Unity编辑器扩展的基石之一。你可以把它想象成一个空白的画布可以在上面绘制任何GUI控件并处理复杂的交互逻辑。它的生命周期与编辑器窗口绑定可以最小化、最大化、停靠并且状态可以持久化比如记住窗口大小和位置。它的核心特点是功能全面支持完整的IMGUIImmediate Mode GUI体系可以创建按钮、滑块、文本框、滚动视图等复杂界面。状态持久通过EditorPrefs或序列化字段可以轻松保存窗口的布局、用户输入等状态。交互复杂适合需要持续操作、多步骤流程、或需要可视化展示数据的工具。例如关卡编辑器资源批量处理工具如纹理压缩、模型导入设置检查游戏数据配置表如Excel导入、角色属性编辑实时调试与监控面板创建与显示一个基本的EditorWindowusing UnityEditor; using UnityEngine; // 1. 创建一个继承自EditorWindow的类 public class MyToolWindow : EditorWindow { private string someText Hello Editor; // 2. 添加一个菜单项来打开窗口 [MenuItem(Tools/My Custom Tool)] public static void ShowWindow() { // 获取现有窗口如果没有则创建一个 var window GetWindowMyToolWindow(); window.titleContent new GUIContent(我的工具); window.Show(); } // 3. 在OnGUI中绘制界面 void OnGUI() { GUILayout.Label(基础设置, EditorStyles.boldLabel); someText EditorGUILayout.TextField(输入内容, someText); if (GUILayout.Button(执行操作)) { Debug.Log($你输入了{someText}); // 在这里执行你的业务逻辑 } } }注意OnGUI方法每帧都会被调用当窗口需要重绘时因此里面的逻辑要尽量高效避免在OnGUI中进行昂贵的计算或对象创建。2.2 ScriptableWizard一步到位的任务向导ScriptableWizard则是一个更简单、更专注的类。它用于创建模态对话框即弹出后必须处理完才能操作其他窗口。它的界面通常很简洁用于收集少量参数然后执行一个特定的任务。它的核心特点是轻量快捷自动生成“创建”(Create)和“取消”(Cancel)按钮内置了标准的向导布局。任务导向完美适用于“选择一些东西然后执行一个操作”的场景。例如根据选中的资源创建预制体为场景中选中的所有对象添加某个组件快速生成一个特定类型的配置文件ScriptableObject创建与显示一个基本的ScriptableWizardusing UnityEditor; using UnityEngine; public class CreateCubeWizard : ScriptableWizard { public string cubeName New Cube; public Vector3 scale Vector3.one; // 菜单项并且限定在Hierarchy窗口有选中对象时可用 [MenuItem(GameObject/Create Cube Wizard, true)] static bool ValidateCreateWizard() { // 这里可以添加验证逻辑例如至少选中一个对象 return Selection.activeGameObject ! null; } [MenuItem(GameObject/Create Cube Wizard)] static void CreateWizard() { // 显示向导窗口第二个参数是“创建”按钮的文本 DisplayWizardCreateCubeWizard(创建立方体, 创建); } // 当窗口中的字段发生变化时调用可用于实时更新帮助信息或验证 void OnWizardUpdate() { helpString $将在 {Selection.activeGameObject.name} 下创建一个名为 {cubeName} 的立方体。; // 可以在这里控制创建按钮是否可点击 // isValid !string.IsNullOrEmpty(cubeName); } // 当用户点击“创建”按钮时调用 void OnWizardCreate() { GameObject cube GameObject.CreatePrimitive(PrimitiveType.Cube); cube.name cubeName; cube.transform.localScale scale; cube.transform.SetParent(Selection.activeGameObject.transform, false); Debug.Log($立方体 {cubeName} 已创建); } }实操心得ScriptableWizard的OnWizardUpdate方法非常有用你可以在这里根据用户输入实时更新helpString窗口底部的帮助文本或设置isValid布尔值来控制创建按钮的可用状态提供即时反馈。如何选择简单决策流如果你的工具需要复杂的UI、持续交互、或展示大量数据用EditorWindow。如果你的工具只是执行一个简单的、参数明确的特定任务用ScriptableWizard。从ScriptableWizard开始如果发现界面不够用了可以平滑地重构升级为EditorWindow。3. EditorWindow 深度开发从零搭建一个资源检查器理论说再多不如动手做一遍。让我们来实战开发一个中等复杂度的EditorWindow工具一个“资源依赖关系检查器”。这个工具可以分析选中的资源如预制体、材质球列出它引用的所有其他资源并可以快速定位到这些资源对于排查资源冗余和理清项目结构非常有用。3.1 窗口布局与核心数据结构设计首先我们规划窗口布局。左侧是一个资源列表支持多选右侧是一个详细视图展示当前选中资源所引用的所有资源。我们还需要一个刷新按钮和一个搜索过滤框。using System.Collections.Generic; using UnityEditor; using UnityEngine; public class DependencyCheckerWindow : EditorWindow { // 存储选中的资源对象列表 private ListUnityEngine.Object selectedAssets new ListUnityEngine.Object(); // 存储当前选中资源的依赖项列表 private ListDependencyInfo currentDependencies new ListDependencyInfo(); // 搜索过滤关键字 private string searchFilter ; // 滚动位置 private Vector2 leftScrollPos; private Vector2 rightScrollPos; [MenuItem(Window/Analysis/资源依赖检查器)] public static void ShowWindow() { var window GetWindowDependencyCheckerWindow(); window.titleContent new GUIContent(依赖检查器); window.minSize new Vector2(800, 500); window.Show(); } void OnGUI() { EditorGUILayout.BeginHorizontal(); { // 左侧面板资源选择区 DrawLeftPanel(); // 右侧面板依赖详情区 DrawRightPanel(); } EditorGUILayout.EndHorizontal(); } }我们需要定义一个DependencyInfo类来存储依赖资源的详细信息[System.Serializable] public class DependencyInfo { public UnityEngine.Object asset; // 依赖的资源对象 public string path; // 资源路径 public string type; // 资源类型 public DependencyInfo(UnityEngine.Object asset, string path) { this.asset asset; this.path path; this.type asset.GetType().Name; } }3.2 绘制左侧资源选择面板左侧面板需要显示从Project窗口拖拽过来的资源或者通过按钮添加的资源。private void DrawLeftPanel() { EditorGUILayout.BeginVertical(GUILayout.Width(300), GUILayout.ExpandHeight(true)); EditorGUILayout.LabelField(待检查资源, EditorStyles.boldLabel); // 区域1操作按钮 EditorGUILayout.BeginHorizontal(); if (GUILayout.Button(从选中添加)) { AddSelectedAssets(); } if (GUILayout.Button(清空列表)) { selectedAssets.Clear(); currentDependencies.Clear(); GUIUtility.ExitGUI(); // 防止后续GUI报错 } EditorGUILayout.EndHorizontal(); // 区域2拖拽接收区 GUIStyle dragBoxStyle new GUIStyle(GUI.skin.box); dragBoxStyle.alignment TextAnchor.MiddleCenter; dragBoxStyle.normal.textColor Color.gray; Rect dragRect GUILayoutUtility.GetRect(0, 50, GUILayout.ExpandWidth(true)); GUI.Box(dragRect, 拖拽资源到此区域, dragBoxStyle); HandleDragAndDrop(dragRect); // 区域3资源列表 EditorGUILayout.Space(); EditorGUILayout.LabelField(资源列表); leftScrollPos EditorGUILayout.BeginScrollView(leftScrollPos, GUILayout.ExpandHeight(true)); for (int i 0; i selectedAssets.Count; i) { EditorGUILayout.BeginHorizontal(); { // 显示一个删除按钮 if (GUILayout.Button(X, GUILayout.Width(20))) { selectedAssets.RemoveAt(i); // 如果删除的是当前正在查看的资源清空依赖列表 if (currentDependencies.Count 0 i 0) // 简单处理假设第一个是当前查看的 { currentDependencies.Clear(); } GUIUtility.ExitGUI(); return; } // 显示资源对象字段并使其不可编辑只用于显示 EditorGUI.BeginDisabledGroup(true); selectedAssets[i] EditorGUILayout.ObjectField(selectedAssets[i], typeof(UnityEngine.Object), false); EditorGUI.EndDisabledGroup(); } EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); } private void HandleDragAndDrop(Rect rect) { Event evt Event.current; switch (evt.type) { case EventType.DragUpdated: case EventType.DragPerform: if (!rect.Contains(evt.mousePosition)) return; DragAndDrop.visualMode DragAndDropVisualMode.Copy; if (evt.type EventType.DragPerform) { DragAndDrop.AcceptDrag(); foreach (UnityEngine.Object obj in DragAndDrop.objectReferences) { // 避免重复添加 if (obj ! null !selectedAssets.Contains(obj)) { selectedAssets.Add(obj); } } // 如果有资源被添加默认分析第一个 if (selectedAssets.Count 0) { AnalyzeDependencies(selectedAssets[0]); } evt.Use(); } break; } } private void AddSelectedAssets() { // 获取Project窗口中选中的资源 foreach (var obj in Selection.objects) { // 检查是否是项目内的资源排除场景对象等 string path AssetDatabase.GetAssetPath(obj); if (!string.IsNullOrEmpty(path) !selectedAssets.Contains(obj)) { selectedAssets.Add(obj); } } if (selectedAssets.Count 0) { AnalyzeDependencies(selectedAssets[0]); } }注意事项GUIUtility.ExitGUI()在删除列表元素后立即调用是为了防止Unity GUI系统因为循环索引变化而报错。这是一个处理列表动态修改时的常用技巧。3.3 实现核心依赖分析逻辑这是工具的核心功能。我们将使用AssetDatabase.GetDependenciesAPI来获取资源的直接依赖。private void AnalyzeDependencies(UnityEngine.Object targetAsset) { if (targetAsset null) return; currentDependencies.Clear(); string assetPath AssetDatabase.GetAssetPath(targetAsset); if (string.IsNullOrEmpty(assetPath)) { Debug.LogWarning($无法获取资源路径{targetAsset.name}); return; } // 获取所有直接依赖的资源路径 string[] dependencyPaths AssetDatabase.GetDependencies(assetPath, false); // false表示不包含递归依赖 foreach (string depPath in dependencyPaths) { // 跳过自己 if (depPath assetPath) continue; // 加载依赖资源 UnityEngine.Object depAsset AssetDatabase.LoadAssetAtPathUnityEngine.Object(depPath); if (depAsset ! null) { currentDependencies.Add(new DependencyInfo(depAsset, depPath)); } } // 按资源类型排序方便查看 currentDependencies.Sort((a, b) a.type.CompareTo(b.type)); Repaint(); // 手动触发窗口重绘更新右侧面板 }3.4 绘制右侧依赖详情与交互面板右侧面板需要展示分析结果并支持搜索过滤和快速定位Ping资源。private void DrawRightPanel() { EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); if (selectedAssets.Count 0) { EditorGUILayout.HelpBox(请在左侧添加需要分析的资源。, MessageType.Info); EditorGUILayout.EndVertical(); return; } // 显示当前正在分析的资源 EditorGUILayout.LabelField($当前分析{selectedAssets[0].name}, EditorStyles.boldLabel); EditorGUILayout.Space(); // 搜索框 EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label(过滤, GUILayout.ExpandWidth(false)); searchFilter EditorGUILayout.TextField(searchFilter, EditorStyles.toolbarSearchField); EditorGUILayout.EndHorizontal(); // 依赖列表 EditorGUILayout.Space(); EditorGUILayout.LabelField($直接依赖 ({currentDependencies.Count}), EditorStyles.boldLabel); rightScrollPos EditorGUILayout.BeginScrollView(rightScrollPos); bool hasMatch false; foreach (var dep in currentDependencies) { // 应用搜索过滤按路径或类型 if (!string.IsNullOrEmpty(searchFilter) !dep.path.ToLower().Contains(searchFilter.ToLower()) !dep.type.ToLower().Contains(searchFilter.ToLower())) { continue; } hasMatch true; EditorGUILayout.BeginHorizontal(); { // 显示一个可以点击Ping的标签 if (GUILayout.Button(dep.asset.name, EditorStyles.label)) // 使用Label样式但可点击 { EditorGUIUtility.PingObject(dep.asset); // 在Project窗口高亮显示该资源 } GUILayout.FlexibleSpace(); // 占位符将类型推到右侧 EditorGUILayout.LabelField(dep.type, GUILayout.Width(80)); // 显示一个更明显的定位按钮 if (GUILayout.Button(定位, GUILayout.Width(40))) { EditorGUIUtility.PingObject(dep.asset); Selection.activeObject dep.asset; // 同时选中它 } } EditorGUILayout.EndHorizontal(); // 显示资源路径小字 EditorGUILayout.LabelField($ {dep.path}, EditorStyles.miniLabel); EditorGUILayout.Space(2); } if (!string.IsNullOrEmpty(searchFilter) !hasMatch) { EditorGUILayout.HelpBox($未找到包含“{searchFilter}”的依赖项。, MessageType.Warning); } else if (currentDependencies.Count 0) { EditorGUILayout.HelpBox(此资源没有检测到直接依赖。, MessageType.Info); } EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); }实操心得EditorGUIUtility.PingObject是编辑器扩展中非常实用的一个功能它可以在Project窗口或Hierarchy窗口中高亮并滚动到指定对象给用户即时的视觉反馈。结合Selection.activeObject可以提供更好的交互体验。至此一个功能相对完整的资源依赖检查器就完成了。你可以通过拖拽或按钮添加资源查看其直接依赖并通过搜索过滤还能一键定位到依赖资源。这个工具本身就是一个很好的EditorWindow学习范例涵盖了布局、事件处理、API调用和用户交互等核心知识点。4. ScriptableWizard 实战创建批量重命名工具如果说EditorWindow是重型工作站那么ScriptableWizard就是精巧的瑞士军刀。我们用它来制作一个“批量重命名工具”这是一个在团队中极受欢迎的效率工具常用于批量处理场景中的游戏对象命名使其符合规范。4.1 设计向导界面与参数我们希望这个工具能实现以下功能选中Hierarchy中的多个对象按照指定规则如前缀、后缀、序号进行重命名。向导界面需要收集这些参数。using System.Text.RegularExpressions; using UnityEditor; using UnityEngine; public class BatchRenameWizard : ScriptableWizard { public string baseName GameObject; // 基础名称 public string prefix ; // 前缀 public string suffix ; // 后缀 public int startNumber 1; // 起始序号 public int increment 1; // 序号增量 public bool keepOriginalNumber false; // 是否尝试保留原名中的数字 [MenuItem(GameObject/批量重命名 %#r)] // %#r 对应 CtrlShiftR static void CreateWizard() { // 检查是否有对象被选中 if (Selection.gameObjects.Length 0) { EditorUtility.DisplayDialog(无选中对象, 请在Hierarchy中选中至少一个游戏对象。, 确定); return; } DisplayWizardBatchRenameWizard(批量重命名, 重命名); } void OnWizardUpdate() { // 动态更新帮助信息 helpString $已选中 {Selection.gameObjects.Length} 个对象。\n; if (Selection.gameObjects.Length 0) { helpString $示例{GenerateExampleName(0)}; if (Selection.gameObjects.Length 1) { helpString $, {GenerateExampleName(1)}...; } } // 基础名称不能为空 isValid !string.IsNullOrEmpty(baseName); } }这里我们添加了一个GenerateExampleName方法来实时生成预览让用户直观地看到重命名效果。private string GenerateExampleName(int index) { int calculatedNumber startNumber (index * increment); string numberPart keepOriginalNumber ? [原数字] : calculatedNumber.ToString(); // 构建名称 System.Text.StringBuilder sb new System.Text.StringBuilder(); if (!string.IsNullOrEmpty(prefix)) sb.Append(prefix); sb.Append(baseName); if (!string.IsNullOrEmpty(suffix)) sb.Append(suffix); sb.Append(_); sb.Append(numberPart.ToString(D2)); // D2表示至少两位不足补零 return sb.ToString(); }4.2 实现核心重命名逻辑核心逻辑在OnWizardCreate中实现。我们需要遍历所有选中的游戏对象应用命名规则。void OnWizardCreate() { if (Selection.gameObjects.Length 0) { Debug.LogWarning(没有选中任何对象。); return; } // 记录操作以便撤销 Undo.RecordObjects(Selection.gameObjects, Batch Rename); // 对选中的对象进行排序按在Hierarchy中的顺序这是一个简化版 // 更严谨的做法是使用Transform.GetSiblingIndex()进行排序 GameObject[] sortedSelection new GameObject[Selection.gameObjects.Length]; System.Array.Copy(Selection.gameObjects, sortedSelection, Selection.gameObjects.Length); System.Array.Sort(sortedSelection, (a, b) a.transform.GetSiblingIndex().CompareTo(b.transform.GetSiblingIndex())); int currentNumber startNumber; for (int i 0; i sortedSelection.Length; i) { GameObject go sortedSelection[i]; string newName ; // 1. 处理前缀和后缀 if (!string.IsNullOrEmpty(prefix)) newName prefix; newName baseName; if (!string.IsNullOrEmpty(suffix)) newName suffix; // 2. 处理数字部分 if (keepOriginalNumber) { // 尝试从原名中提取数字 Match match Regex.Match(go.name, \d); if (match.Success int.TryParse(match.Value, out int originalNum)) { newName _ originalNum.ToString(D2); } else { // 如果没找到数字使用当前序号 newName _ currentNumber.ToString(D2); currentNumber increment; } } else { newName _ currentNumber.ToString(D2); currentNumber increment; } go.name newName; EditorUtility.SetDirty(go); // 标记对象为“脏”确保更改被保存 } Debug.Log($成功重命名了 {sortedSelection.Length} 个对象。); // 重命名后可以刷新一下界面虽然ScriptableWizard会自动关闭 // 如果需要可以在这里保存场景 if (!EditorApplication.isPlaying) { UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene); } }注意事项Undo.RecordObjects和EditorUtility.SetDirty是编辑器脚本中非常重要的两个调用。前者允许用户通过CtrlZ撤销批量重命名操作后者确保对游戏对象名称的修改在保存场景时能被正确记录。缺少它们可能会导致无法撤销或更改丢失。4.3 增强功能添加命名规则预设与保存一个专业的工具应该考虑用户体验的延续性。我们可以添加保存和加载命名规则预设的功能。// 在类中添加字段 public string presetName Default; private static string PresetKeyPrefix BatchRenamePreset_; // 在OnGUI中需要重写OnGUI来自定义界面布局 protected override bool DrawWizardGUI() { // 调用基类绘制默认字段 bool result base.DrawWizardGUI(); EditorGUILayout.Space(10); EditorGUILayout.LabelField(预设管理, EditorStyles.boldLabel); EditorGUILayout.BeginHorizontal(); presetName EditorGUILayout.TextField(预设名称, presetName); if (GUILayout.Button(保存, GUILayout.Width(60))) { SavePreset(); } if (GUILayout.Button(加载, GUILayout.Width(60))) { LoadPreset(); } EditorGUILayout.EndHorizontal(); return result; } private void SavePreset() { string key PresetKeyPrefix presetName; string data JsonUtility.ToJson(new PresetData(this)); EditorPrefs.SetString(key, data); Debug.Log($预设 {presetName} 已保存。); } private void LoadPreset() { string key PresetKeyPrefix presetName; if (EditorPrefs.HasKey(key)) { string data EditorPrefs.GetString(key); JsonUtility.FromJsonOverwrite(data, this); Debug.Log($预设 {presetName} 已加载。); } else { Debug.LogWarning($未找到预设 {presetName}。); } } // 需要一个可序列化的类来存储字段 [System.Serializable] private class PresetData { public string baseName; public string prefix; public string suffix; public int startNumber; public int increment; public bool keepOriginalNumber; public PresetData(BatchRenameWizard wizard) { baseName wizard.baseName; prefix wizard.prefix; suffix wizard.suffix; startNumber wizard.startNumber; increment wizard.increment; keepOriginalNumber wizard.keepOriginalNumber; } }实操心得重写DrawWizardGUI方法而不是只依赖自动生成的字段可以让你完全控制ScriptableWizard的界面布局插入自定义的UI区块如这里的预设管理。EditorPrefs是存储编辑器工具用户设置的常用位置简单易用但注意不要存储过大的数据。现在这个批量重命名工具不仅功能强大还能记住用户常用的命名规则实用性大大增强。通过这个案例你可以掌握ScriptableWizard从简单到进阶的完整开发流程。5. 高级技巧与性能优化当你开始开发更复杂的编辑器工具时性能和用户体验就变得至关重要。以下是一些我总结的高级技巧和避坑指南。5.1 处理大量数据的虚拟列表与延迟渲染在EditorWindow中如果你需要显示成百上千个条目比如所有场景中的灯光列表直接在OnGUI循环中渲染所有GUILayout控件会导致编辑器严重卡顿。解决方案是使用ListViewUnity 2021 LTS或手动实现基于GUI的滚动视图与裁剪。手动实现简单虚拟列表的思路计算显示范围根据滚动位置scrollPos和窗口可视区域高度计算出当前需要渲染的条目起始和结束索引。只渲染可见项在循环中只对可见索引范围内的条目调用GUI绘制代码。使用GUI代替GUILayoutGUILayout方便但开销大。对于大量重复项使用GUI方法如GUI.Label并手动计算Rect位置性能会好很多。// 伪代码示例 void DrawLargeList(ListMyData allData) { int itemHeight 20; int visibleHeight (int)position.height; int visibleItemCount Mathf.CeilToInt((float)visibleHeight / itemHeight); int startIndex Mathf.FloorToInt(scrollPos.y / itemHeight); int endIndex Mathf.Min(startIndex visibleItemCount 2, allData.Count); // 2 作为缓冲 Rect viewRect new Rect(0, 0, position.width - 20, allData.Count * itemHeight); //滚动区域总大小 scrollPos GUI.BeginScrollView(new Rect(0, 0, position.width, visibleHeight), scrollPos, viewRect); for (int i startIndex; i endIndex; i) { Rect itemRect new Rect(0, i * itemHeight, viewRect.width, itemHeight); // 使用GUI方法绘制每一项 GUI.Label(new Rect(itemRect.x 5, itemRect.y, 200, itemHeight), allData[i].name); // ... 绘制其他内容 } GUI.EndScrollView(); }5.2 使用 SerializedObject 与 SerializedProperty 进行优雅的序列化数据编辑当你需要编辑一个ScriptableObject或MonoBehaviour的多个字段时直接使用EditorGUILayout.PropertyField可以自动处理撤销、预设覆盖和多种字段类型如数组、枚举比手动为每个字段写编辑器代码更健壮。public class MyDataEditorWindow : EditorWindow { private SerializedObject serializedObject; private SerializedProperty nameProperty; private SerializedProperty levelProperty; private SerializedProperty tagsProperty; void OnEnable() { // 假设我们有一个MyData ScriptableObject的实例 MyData data /* 获取你的数据对象 */; if (data ! null) { serializedObject new SerializedObject(data); nameProperty serializedObject.FindProperty(characterName); levelProperty serializedObject.FindProperty(level); tagsProperty serializedObject.FindProperty(tags); } } void OnGUI() { if (serializedObject null) return; serializedObject.Update(); // 开始更新 EditorGUILayout.PropertyField(nameProperty); EditorGUILayout.PropertyField(levelProperty); EditorGUILayout.PropertyField(tagsProperty); // 如果是数组会自动显示可折叠的列表 // 如果任何属性被修改ApplyModifiedProperties会标记对象为Dirty并处理撤销 if (serializedObject.ApplyModifiedProperties()) { Debug.Log(数据已修改并保存。); } } }5.3 编辑器协程与进度条执行耗时操作如遍历所有资源、处理大量文件时必须提供反馈否则编辑器会看起来像卡死。使用EditorUtility.DisplayProgressBar和EditorCoroutine或简单的EditorApplication.update委托来保持响应。IEnumerator ProcessAssetsCoroutine(Liststring assetPaths) { int total assetPaths.Count; for (int i 0; i total; i) { string path assetPaths[i]; // 更新进度条 if (EditorUtility.DisplayCancelableProgressBar(处理中, $正在处理 {path}, (float)i / total)) { // 用户点击了取消 break; } // 执行你的耗时操作... yield return null; // 下一帧继续保持编辑器响应 // 或者对于非协程方法可以在循环内调用 // EditorUtility.DisplayProgressBar(...); // 执行操作 // yield return null; } EditorUtility.ClearProgressBar(); // 处理完成或取消后必须清除进度条 } // 在某个按钮点击事件中启动 void OnProcessButtonClicked() { // 使用EditorCoroutineUtility.StartCoroutineOwnerless在编辑器下运行协程 EditorCoroutineUtility.StartCoroutineOwnerless(ProcessAssetsCoroutine(assetList)); }重要警告DisplayProgressBar必须和ClearProgressBar成对调用。如果在显示进度条时发生异常进度条可能不会自动关闭导致编辑器界面被“锁死”。务必使用try...finally确保其被清除。5.4 自定义样式与编辑器皮肤为了让你的工具看起来更专业或者与项目风格统一可以自定义GUI样式。你可以获取内置样式如EditorStyles.boldLabel也可以创建自己的GUIStyle。private GUIStyle customButtonStyle; private GUIStyle headerStyle; void OnEnable() { // 创建自定义按钮样式 customButtonStyle new GUIStyle(GUI.skin.button); customButtonStyle.normal.textColor Color.white; customButtonStyle.hover.textColor Color.yellow; customButtonStyle.fontStyle FontStyle.Bold; // 可以在这里加载一个背景图 // customButtonStyle.normal.background (Texture2D)EditorGUIUtility.Load(...); // 创建标题样式 headerStyle new GUIStyle(EditorStyles.largeLabel); headerStyle.fontSize 18; headerStyle.alignment TextAnchor.MiddleCenter; } void OnGUI() { GUILayout.Label(我的专业工具, headerStyle); // ... 其他内容 if (GUILayout.Button(执行操作, customButtonStyle)) { // ... } }注意过度自定义样式可能会与Unity编辑器主题不协调。通常保持与编辑器默认风格一致是最安全的选择。6. 常见问题排查与调试技巧即使经验丰富开发编辑器扩展时也难免遇到各种诡异问题。这里记录了一些高频问题和解决方法。6.1 窗口不显示或菜单项丢失问题代码编译无误但菜单项没出现或者点击后窗口不弹出。排查脚本编译状态检查Unity编辑器右下角是否还在转圈编译。等待编译完成。类名与文件名确保脚本文件名与类名完全一致包括大小写。MyWindow.cs里必须是public class MyWindow。命名空间如果你的类在命名空间内MenuItem的路径需要特别注意。菜单项是基于类全路径含命名空间注册的。一个常见的做法是将编辑器脚本放在Editor文件夹下但类本身不放在命名空间里或者使用明确的[MenuItem(...)]。编辑器脚本位置所有使用了UnityEditor命名空间且包含MenuItem、EditorWindow、Editor等特性的脚本必须放在名为Editor的文件夹中可以在Assets下任何层级的Editor文件夹。否则在项目发布构建时会导致错误。静态方法MenuItem关联的方法必须是static的。6.2 OnGUI 性能问题与界面卡顿问题自定义窗口操作时感觉卡顿尤其是列表很长时。排查与解决避免在OnGUI中做昂贵操作不要在OnGUI里进行资源加载Resources.Load、数据库查询、复杂的字符串处理等。这些数据应该在OnEnable或按钮事件中提前加载好。使用GUILayout还是GUIGUILayout系统自动布局方便但性能开销稍大。对于固定布局或大量重复元素使用GUI并手动指定Rect。虚拟化列表如前所述对于超长列表只渲染可见部分。减少控件数量评估是否每个条目都需要交互控件。有时用只读的标签代替可编辑的文本框能显著提升性能。使用EditorGUIUtility.SetWantsMouseJumping对于需要频繁刷新的窗口如实时显示数据的监控窗口在OnEnable中调用EditorGUIUtility.SetWantsMouseJumping(1)可以改善滚动体验但需在OnDisable中设回0。6.3 序列化数据丢失或重置问题EditorWindow中定义的public字段在编辑器播放停止或重新编译后值被清空了。解决使用Serializable属性确保需要持久化的类标记了[System.Serializable]。使用ScriptableObject存储数据对于复杂的配置数据最佳实践是创建一个ScriptableObject来存储然后在EditorWindow中引用它。ScriptableObject是资源文件数据会持久保存在项目中。使用EditorPrefs或SessionState对于简单的键值对可以使用EditorPrefs持久化到系统注册表/配置文件或SessionState仅在当前编辑器会话中有效。SessionState对于存储窗口布局、临时过滤条件非常有用且不会污染项目设置。// 存储 SessionState.SetString(MyWindow_LastFilter, filterString); // 读取 filterString SessionState.GetString(MyWindow_LastFilter, default);实现ISerializationCallbackReceiver如果你的类包含Unity无法直接序列化的类型如字典可以实现这个接口来自定义序列化过程。6.4 处理选区Selection变化问题工具的行为依赖于当前选中的对象但当用户在Project或Hierarchy窗口中点击其他东西时你的窗口内容没有自动更新。解决订阅Selection.selectionChanged事件在OnEnable中订阅在OnDisable中取消订阅。void OnEnable() { Selection.selectionChanged OnSelectionChanged; } void OnDisable() { Selection.selectionChanged - OnSelectionChanged; } void OnSelectionChanged() { // 更新你的UI逻辑 Repaint(); // 重要请求重绘窗口 }注意性能selectionChanged触发非常频繁。在事件处理函数中避免进行重型操作如遍历所有资源。可以添加一个小的延迟或标记一个“脏”标志在OnGUI中再处理。6.5 调试编辑器脚本使用Debug.Log最基础但有效。日志会输出到Unity控制台。使用断点在Visual Studio或Rider中你可以像调试游戏代码一样为编辑器脚本打上断点。关键步骤在Unity编辑器菜单栏选择Debug - Attach to Unity Editor或类似选项然后触发你的工具代码调试器就会中断。使用EditorWindow的FocusedWindow在调试时可以在代码中访问EditorWindow.focusedWindow来查看当前获得焦点的窗口。隔离测试将复杂功能拆分成小的、可独立测试的静态方法。创建一个简单的MenuItem来调用这些方法验证其逻辑是否正确。开发自定义编辑器工具是一个迭代的过程从满足一个简单需求开始逐步添加功能、优化体验、处理边界情况。最大的成就感莫过于看到团队其他成员因为你的工具而提升了工作效率。记住好的工具是“活”的它会随着项目需求不断进化。