1. 从一次真实的效率崩溃说起
如果你用 Unity 做过超过半年的项目,大概率经历过这样的场景:美术同学丢过来一批 200 个预制体,要求统一检查它们的碰撞体配置;策划同学临时要改 50 个 ScriptableObject 里的数值,而 Inspector 面板一次只能显示一个;又或者你自己在调试时,需要反复在十几个场景之间切换,每次都要手动找到那个特定的对象、展开组件、点开某个折叠面板。这些操作单次可能只花十几秒,但一天下来累积的时间损耗非常可观。
Unity 编辑器扩展就是解决这类问题的钥匙。而所有编辑器扩展里,自定义面板是最基础、最常用、也最容易上手的一类。它让你可以在 Unity 编辑器里创建自己的窗口、按钮、输入框、列表、图表,把那些重复性的手工操作变成一键完成。这篇文章不讲空泛的概念,而是从零开始,把自定义面板的完整实现路径、核心 API、常见坑点和实战技巧全部拆开讲清楚。
适合谁看?如果你已经会用 C# 写 Unity 脚本,知道MonoBehaviour和ScriptableObject的基本用法,但对EditorWindow、GUILayout、SerializedObject这些编辑器相关的类还比较陌生,那这篇内容就是为你准备的。如果你已经写过一些简单的编辑器工具,但总觉得代码组织混乱、刷新时机把握不准、Undo 系统老是出问题,那这篇文章里的经验总结应该也能帮你省不少时间。
我自己的项目里,前后写过不下二十个自定义面板,从简单的批量重命名工具,到复杂的场景管理器和资源审计面板都有。踩过的坑包括但不限于:面板刷新导致编辑器卡死、Undo 操作后数据不一致、序列化对象在面板关闭后丢失引用、GUILayout 和 EditorGUILayout 混用导致布局错乱。这些问题的解决方案,我都会在下面的章节里逐一说明。
2. 自定义面板的整体设计思路
2.1 为什么选择 EditorWindow 而不是 CustomEditor
Unity 编辑器扩展有好几种入口:CustomEditor用来定制某个组件的 Inspector 显示,EditorWindow用来创建独立的浮动窗口,PropertyDrawer用来定制某个字段的绘制方式,还有Editor基类下的各种回调。自定义面板这个需求,核心诉求是“把多个对象的操作聚合到一个界面上”,所以EditorWindow是最自然的选择。
CustomEditor的局限在于它绑定在单个组件的 Inspector 上,你没法在一个面板里同时操作多个不同类型的对象。而EditorWindow是一个独立的窗口,你可以自由地往里面放任何控件,访问任何资源,调用任何编辑器 API。它的生命周期也更清晰:OnEnable初始化,OnGUI绘制,OnDisable清理。对于批量操作、资源管理、场景工具这类需求,EditorWindow是唯一合理的选择。
2.2 面板的三种典型架构模式
在实际项目里,自定义面板的架构大致可以分成三类,选择哪一种取决于你的具体需求。
第一类是“即时操作型”面板。面板上放几个按钮,点一下执行一个操作,执行完就结束。比如“批量重命名选中对象”、“导出当前场景的灯光信息”。这类面板逻辑最简单,不需要维护复杂的状态,OnGUI里直接写按钮和对应的处理函数就行。
第二类是“数据编辑型”面板。面板需要显示和编辑一批数据,数据可能来自 ScriptableObject、JSON 文件或者场景中的对象。用户可以在面板里修改数值、增删条目,修改后需要保存。这类面板需要处理序列化、Undo、数据同步等问题,复杂度明显更高。
第三类是“工具集型”面板。面板本身是一个容器,里面分多个标签页或折叠区域,每个区域负责不同的功能。比如一个“项目工具箱”面板,里面有资源检查、场景切换、批量操作、日志查看等多个模块。这类面板需要考虑模块之间的解耦和代码组织。
我个人的建议是:从第一类开始,逐步演进到第二类和第三类。不要一上来就设计一个庞大的工具集面板,那样很容易在早期陷入架构泥潭。先把一个具体的痛点解决掉,跑通了,再考虑扩展。
2.3 编辑器代码的目录结构约定
Unity 对编辑器代码有一个硬性规定:所有继承自Editor、EditorWindow、PropertyDrawer等编辑器相关的脚本,必须放在名为Editor的文件夹下。这个文件夹可以放在Assets根目录,也可以放在任何子目录下,但文件夹名字必须是Editor。否则打包时这些代码会被包含进运行时程序集,导致编译错误。
我习惯的目录结构是这样的:
Assets/ Editor/ Windows/ # 所有 EditorWindow 子类 Inspectors/ # 所有 CustomEditor Drawers/ # 所有 PropertyDrawer Utilities/ # 编辑器工具类,不继承任何基类 Resources/ # 编辑器用的图标、样式表这样分的好处是,当项目变大、编辑器脚本超过几十个时,你还能快速找到某个面板的实现文件。另外,如果某些编辑器工具只在特定模块下使用,也可以把Editor文件夹放在对应模块的子目录里,比如Assets/Modules/Inventory/Editor/,这样模块删除时编辑器代码也跟着一起删掉,不会留下孤儿脚本。
注意:
Editor文件夹下的脚本不能引用运行时程序集里不存在的类型,但运行时脚本可以引用编辑器脚本里的类型——这是单向的。如果你在运行时脚本里写了#if UNITY_EDITOR包裹的代码,那部分代码在打包时会被剔除,不会造成问题。
3. 核心 API 与实操要点拆解
3.1 EditorWindow 的生命周期与初始化
创建一个自定义面板,第一步是新建一个继承自EditorWindow的类,然后用MenuItem特性给它绑定一个菜单入口。
using UnityEditor; using UnityEngine; public class BatchRenameWindow : EditorWindow { [MenuItem("Tools/Batch Rename Window")] private static void OpenWindow() { var window = GetWindow<BatchRenameWindow>(); window.titleContent = new GUIContent("批量重命名"); window.minSize = new Vector2(400, 300); window.Show(); } private void OnEnable() { // 初始化数据,注册回调 } private void OnGUI() { // 绘制界面 } private void OnDisable() { // 清理资源,保存状态 } }这段代码里有几个细节值得展开说。
GetWindow<T>()的行为是:如果已经存在一个该类型的窗口,就返回它;如果不存在,就创建一个新的。这意味着你不需要自己管理窗口实例,Unity 帮你做了单例管理。但要注意,GetWindow创建的窗口默认是浮动窗口,如果你希望它停靠在某个位置,可以用GetWindow<T>(EditorWindow dockTo)或者GetWindow<T>(Type desiredDockNextTo)。
titleContent是窗口标题栏显示的内容。如果你不设置,Unity 会用类名作为标题,显示出来就是“BatchRenameWindow”,不太友好。设置成中文标题后,在菜单栏和窗口标签页上都会显示中文。
minSize是窗口的最小尺寸。这个设置很重要,因为你的界面布局是基于某个最小宽度设计的,如果用户把窗口拖得太小,控件会重叠或截断。我一般会根据界面里最宽的那一行控件来估算最小宽度,再加上一些余量。
OnEnable和OnDisable是成对出现的。OnEnable在窗口创建时和每次重新编译后调用,OnDisable在窗口关闭时调用。如果你在OnEnable里注册了事件监听,一定要在OnDisable里取消注册,否则重新编译后会出现重复注册的问题。
3.2 GUILayout 与 EditorGUILayout 的选择
Unity 的 IMGUI 系统有两套并行的 API:GUILayout和EditorGUILayout。前者是运行时和编辑器通用的,后者是编辑器专用的,提供了很多编辑器场景下才需要的控件,比如对象字段、层级选择、颜色字段等。
我的原则是:在编辑器面板里,优先用EditorGUILayout,只在它没有对应功能时才用GUILayout。原因很简单,EditorGUILayout的控件会自动适配编辑器的皮肤和缩放,而GUILayout的控件在某些编辑器主题下看起来会很突兀。
常用的EditorGUILayout控件包括:
| 控件方法 | 用途 | 备注 |
|---|---|---|
EditorGUILayout.ObjectField | 选择任意 Unity 对象 | 支持类型过滤和场景对象选择 |
EditorGUILayout.IntField | 整数输入 | 可设置范围 |
EditorGUILayout.FloatField | 浮点数输入 | 可设置范围 |
EditorGUILayout.TextField | 文本输入 | 支持占位符 |
EditorGUILayout.Toggle | 布尔开关 | 可带标签 |
EditorGUILayout.ColorField | 颜色选择 | 支持 HDR 和 Alpha |
EditorGUILayout.Vector3Field | 三维向量输入 | 常用于位置、旋转、缩放 |
EditorGUILayout.EnumPopup | 枚举下拉框 | 自动列出枚举值 |
EditorGUILayout.HelpBox | 提示信息框 | 支持 Info/Warning/Error 三种类型 |
EditorGUILayout.HelpBox是我用得最多的控件之一。当用户的操作条件不满足时,用它在界面上显示一条提示,比弹对话框要友好得多。比如“请先选中至少一个对象”这种提示,用HelpBox显示在按钮上方,用户一眼就能看到。
3.3 序列化对象与 Undo 系统的正确用法
这是自定义面板里最容易出问题的部分。很多新手写的面板,操作完数据后,Undo 按下去没反应,或者撤销后界面显示的数据和实际数据不一致。根本原因是没有正确使用SerializedObject和Undo系统。
先说什么情况下需要 Undo。只要你的面板修改了任何持久化的数据——场景中的对象、资源文件、ScriptableObject——就应该支持 Undo。用户按 Ctrl+Z 的时候,期望的是刚才那一步操作被撤销,而不是整个面板的状态被重置。
正确的做法是:修改数据前调用Undo.RecordObject或Undo.RegisterCompleteObjectUndo,修改后调用EditorUtility.SetDirty标记对象已修改。
// 修改单个对象 Undo.RecordObject(targetObject, "修改对象名称"); targetObject.name = newName; EditorUtility.SetDirty(targetObject); // 修改多个对象 Undo.RecordObjects(targetObjects, "批量修改"); foreach (var obj in targetObjects) { obj.name = newName; EditorUtility.SetDirty(obj); }Undo.RecordObject和Undo.RegisterCompleteObjectUndo的区别在于:前者只记录对象的序列化状态,后者记录整个对象的状态包括非序列化字段。对于大多数场景,RecordObject就够了。但如果你修改的是 ScriptableObject 的字段,而且这个字段是通过SerializedProperty访问的,那用SerializedObject的ApplyModifiedProperties会更方便,它会自动处理 Undo。
var so = new SerializedObject(targetScriptableObject); so.Update(); var prop = so.FindProperty("someField"); prop.intValue = newValue; so.ApplyModifiedProperties(); // 自动注册 Undo用SerializedObject的好处是,它自动处理了 Undo、多对象编辑、预制体覆盖等复杂情况。缺点是它只能访问序列化字段,不能访问属性或方法。所以我的建议是:能用 SerializedObject 就用 SerializedObject,不能用的时候再用 Undo.RecordObject 手动处理。
3.4 面板刷新与 Repaint 的时机控制
EditorWindow的OnGUI不是每帧都调用的,它只在以下情况被触发:鼠标在窗口内移动、用户点击了控件、窗口获得或失去焦点、以及你主动调用了Repaint()。
这意味着如果你的面板需要显示实时变化的数据——比如场景中对象的位置、某个资源的加载状态——你必须主动调用Repaint()来刷新界面。但Repaint()不能乱调,调得太频繁会导致编辑器卡顿。
我踩过的一个坑是:在OnGUI里直接调用Repaint(),结果造成了无限重绘循环,编辑器直接卡死。正确的做法是,只在数据确实发生变化时才调用Repaint(),而且最好用EditorApplication.delayCall或者EditorApplication.update来延迟刷新。
private void OnEnable() { EditorApplication.update += OnEditorUpdate; } private void OnDisable() { EditorApplication.update -= OnEditorUpdate; } private void OnEditorUpdate() { if (needRepaint) { needRepaint = false; Repaint(); } }另一个常见的刷新需求是:当用户在场景中选中了不同的对象时,面板要跟着更新。这可以通过监听Selection.selectionChanged事件来实现。
private void OnEnable() { Selection.selectionChanged += OnSelectionChanged; } private void OnDisable() { Selection.selectionChanged -= OnSelectionChanged; } private void OnSelectionChanged() { Repaint(); }提示:
Selection.selectionChanged在用户框选多个对象时会触发多次,如果你的面板在响应这个事件时做了比较重的计算,建议加一个延迟或节流,避免性能问题。
4. 完整实操:从零搭建一个批量操作面板
4.1 需求定义与界面规划
假设我们要做一个“批量对象处理器”面板,功能包括:显示当前选中的对象列表、批量修改名称(支持前缀/后缀/替换)、批量添加或移除指定组件、批量设置层级(Layer)和标签(Tag)。这个面板覆盖了自定义面板的典型场景:列表显示、文本输入、下拉选择、按钮操作、Undo 支持。
界面布局规划如下:
- 顶部:当前选中对象的数量和列表(可滚动)
- 中部:名称修改区域,包含前缀输入、后缀输入、替换查找和替换为输入
- 中部:组件操作区域,包含组件类型选择、添加按钮、移除按钮
- 底部:层级和标签设置区域,包含 Layer 下拉框、Tag 下拉框、应用按钮
- 最底部:操作日志区域,显示最近一次操作的结果
这个布局用EditorGUILayout的垂直布局就能实现,不需要复杂的自定义绘制。
4.2 数据准备与选中对象获取
面板打开时,需要获取当前选中的对象。Selection.objects返回的是UnityEngine.Object[],可能包含场景对象、资源文件、文件夹等。我们需要过滤出GameObject类型的对象。
private GameObject[] GetSelectedGameObjects() { var selected = Selection.objects; var result = new List<GameObject>(); foreach (var obj in selected) { if (obj is GameObject go) { result.Add(go); } else if (obj is Component comp) { result.Add(comp.gameObject); } } return result.ToArray(); }这里有一个细节:如果用户选中的是场景中的某个组件(比如 Transform),Selection.objects返回的是组件对象,而不是 GameObject。所以需要同时处理GameObject和Component两种情况。另外,如果选中的是预制体资源文件,Selection.objects返回的是预制体资源,不是场景中的实例。这两种情况的处理方式不同,需要根据具体需求决定是否包含预制体资源。
4.3 名称批量修改的实现
名称修改的逻辑是:对每个选中的 GameObject,先应用前缀,再应用后缀,最后执行查找替换。顺序很重要,如果先替换再添加前缀,前缀里的字符可能会被替换掉。
private void ApplyNameChanges(GameObject[] targets) { Undo.RecordObjects(targets, "批量修改名称"); foreach (var go in targets) { string newName = go.name; if (!string.IsNullOrEmpty(prefix)) { newName = prefix + newName; } if (!string.IsNullOrEmpty(suffix)) { newName = newName + suffix; } if (!string.IsNullOrEmpty(findText) && !string.IsNullOrEmpty(replaceText)) { newName = newName.Replace(findText, replaceText); } go.name = newName; EditorUtility.SetDirty(go); } }这里用Undo.RecordObjects一次性记录所有对象,比在循环里逐个调用Undo.RecordObject效率更高,而且 Undo 栈里只会出现一条记录,用户按一次 Ctrl+Z 就能撤销整个批量操作。
4.4 组件批量添加与移除
组件操作需要处理类型选择。用户从下拉框里选择一个组件类型,然后点击添加或移除。组件类型的列表可以通过反射获取所有继承自Component的类型,但那样列表会非常长。更实用的做法是提供一个搜索框,或者只列出常用的几种组件类型。
private Type[] GetComponentTypes() { // 返回所有非抽象、继承自 Component 的类型 return AppDomain.CurrentDomain.GetAssemblies() .SelectMany(asm => asm.GetTypes()) .Where(t => t.IsSubclassOf(typeof(Component)) && !t.IsAbstract) .ToArray(); }这个列表可能有几百个类型,直接显示在下拉框里体验很差。我的做法是:用一个文本输入框让用户输入类型名称,然后用Type.GetType或者遍历匹配来找到对应的类型。如果找到多个匹配,显示一个选择列表让用户确认。
添加组件的代码:
private void AddComponentToTargets(GameObject[] targets, Type componentType) { Undo.RecordObjects(targets, "批量添加组件"); foreach (var go in targets) { if (go.GetComponent(componentType) == null) { Undo.AddComponent(go, componentType); } } }注意这里用的是Undo.AddComponent而不是go.AddComponent。Undo.AddComponent会自动注册 Undo 操作,而go.AddComponent不会。如果用了后者,用户撤销时组件不会被移除。
移除组件的代码:
private void RemoveComponentFromTargets(GameObject[] targets, Type componentType) { Undo.RecordObjects(targets, "批量移除组件"); foreach (var go in targets) { var comp = go.GetComponent(componentType); if (comp != null) { Undo.DestroyObjectImmediate(comp); } } }同样,用Undo.DestroyObjectImmediate而不是Object.DestroyImmediate,确保 Undo 能正确恢复。
4.5 层级与标签的批量设置
层级和标签的设置相对简单,但有一个坑:GameObject.tag只能设置为已经存在的标签,如果标签不存在会抛异常。所以在设置之前,需要检查标签是否存在,或者用UnityEditorInternal.InternalEditorUtility.tags获取所有可用标签。
private void ApplyLayerAndTag(GameObject[] targets, int layer, string tag) { Undo.RecordObjects(targets, "批量设置层级和标签"); foreach (var go in targets) { go.layer = layer; if (!string.IsNullOrEmpty(tag)) { go.tag = tag; } EditorUtility.SetDirty(go); } }层级用LayerMask.NameToLayer获取索引,标签用UnityEditorInternal.InternalEditorUtility.tags获取列表。这两个 API 都是编辑器专用的,所以这段代码必须放在Editor文件夹下。
4.6 操作日志与用户反馈
每次操作完成后,在面板底部显示一条日志,告诉用户操作了多少个对象、是否有跳过的情况。这个反馈很重要,否则用户点完按钮不知道发生了什么。
private string lastOperationLog = ""; private void LogOperation(string message) { lastOperationLog = $"[{System.DateTime.Now:HH:mm:ss}] {message}"; Repaint(); }日志区域用EditorGUILayout.HelpBox或者EditorGUILayout.LabelField显示。如果日志内容比较长,可以用EditorGUILayout.TextArea配合GUILayout.ExpandHeight来显示多行文本。
5. 常见问题与排查技巧实录
5.1 面板打开后一片空白或报错
最常见的原因是OnGUI里抛了异常。Unity 的 IMGUI 系统在OnGUI里抛异常时,不会在控制台显示完整的堆栈,只会显示一条简短的错误信息,很容易被忽略。排查方法是:在OnGUI的开头加一个try-catch,把异常打印出来。
private void OnGUI() { try { DrawGUI(); } catch (System.Exception e) { EditorGUILayout.HelpBox($"面板绘制出错: {e.Message}", MessageType.Error); Debug.LogException(e); } }另一个常见原因是OnEnable里访问了尚未初始化的数据。比如在OnEnable里调用了Selection.objects,但此时 Unity 的 Selection 系统可能还没准备好。解决方法是把初始化逻辑延迟到第一次OnGUI时执行,或者用EditorApplication.delayCall延迟一帧。
5.2 Undo 操作后数据不一致
这个问题通常是因为修改数据后没有调用EditorUtility.SetDirty,或者 Undo 记录的范围不对。排查步骤:
- 确认修改前调用了
Undo.RecordObject或Undo.RecordObjects。 - 确认修改后调用了
EditorUtility.SetDirty。 - 如果修改的是 ScriptableObject,确认用的是
SerializedObject.ApplyModifiedProperties而不是直接改字段。 - 如果修改的是预制体实例,确认没有破坏预制体连接。
还有一个隐蔽的坑:如果在OnGUI里直接修改数据,而没有用Undo.RecordObject,Unity 会在下一次OnGUI调用时把数据重置回去,因为OnGUI可能在一帧内被调用多次(Layout 和 Repaint 各一次)。所以所有数据修改都应该放在按钮的回调里,而不是直接写在OnGUI的绘制代码中。
5.3 面板卡顿或编辑器无响应
面板卡顿通常有几个原因:在OnGUI里做了耗时计算、频繁调用Repaint()、或者绘制了大量控件。
排查方法:用Profiler或者简单的计时器测量OnGUI的执行时间。如果超过 16ms,就需要优化。优化的方向包括:把耗时计算缓存起来,只在数据变化时重新计算;减少Repaint()的调用频率;用GUILayout.BeginScrollView包裹长列表,只绘制可见部分。
另一个容易被忽略的点是:EditorGUILayout.ObjectField在绘制大量对象时性能很差。如果你需要显示一个包含几百个对象的列表,不要用ObjectField,而是用EditorGUILayout.LabelField显示对象名称,点击时再通过EditorGUIUtility.PingObject高亮对应的对象。
5.4 重新编译后面板状态丢失
Unity 在脚本重新编译后会重新加载所有编辑器窗口,OnEnable会被再次调用,但之前保存在字段里的数据会丢失。如果面板里有用户输入的数据需要保留,可以用EditorPrefs或者ScriptableObject来持久化。
private void OnEnable() { prefix = EditorPrefs.GetString("BatchRename_Prefix", ""); suffix = EditorPrefs.GetString("BatchRename_Suffix", ""); } private void OnDisable() { EditorPrefs.SetString("BatchRename_Prefix", prefix); EditorPrefs.SetString("BatchRename_Suffix", suffix); }EditorPrefs的 key 建议加上项目前缀,避免不同项目之间互相干扰。但要注意,EditorPrefs是全局的,如果同一个项目在不同机器上使用,数据不会同步。对于需要跨机器同步的配置,应该用 ScriptableObject 资源文件来保存。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 面板打开空白 | OnGUI 抛异常 | 加 try-catch 打印异常 |
| Undo 无效 | 未调用 RecordObject 或 SetDirty | 修改前 Record,修改后 SetDirty |
| 数据自动重置 | 在 OnGUI 里直接改数据 | 把修改逻辑移到按钮回调 |
| 面板卡顿 | OnGUI 耗时过长或频繁 Repaint | 缓存计算结果,减少 Repaint |
| 重新编译后数据丢失 | 字段未持久化 | 用 EditorPrefs 或 ScriptableObject |
| 组件添加后 Undo 无效 | 用了 AddComponent 而非 Undo.AddComponent | 改用 Undo.AddComponent |
| 标签设置报错 | 标签不存在 | 先检查标签是否存在 |
| 预制体修改后连接断开 | 直接改了预制体实例的字段 | 用 PrefabUtility 相关 API |
6. 进阶技巧与代码组织建议
6.1 用 UIElements 替代 IMGUI 的时机
Unity 2019 之后引入了 UIElements(现在叫 UI Toolkit),它提供了一套基于 XML 和 USS 的界面描述方式,比 IMGUI 更适合构建复杂的编辑器界面。UIElements 的优势在于:布局更灵活、样式可复用、性能更好、支持数据绑定。
但 UIElements 的学习曲线比 IMGUI 陡峭,而且一些老的编辑器 API 在 UIElements 里没有直接对应。我的建议是:简单的工具面板继续用 IMGUI,复杂的工具集面板可以考虑迁移到 UIElements。判断标准是:如果你的面板超过 5 个功能区域,或者需要动态增删大量列表项,UIElements 会更合适。
6.2 面板代码的分层组织
一个超过 500 行的EditorWindow类会变得很难维护。我的做法是把面板拆成三层:
- 窗口层:只负责窗口的打开、关闭、标题设置、以及调用绘制层。
- 绘制层:负责
OnGUI里的界面绘制,把不同功能区域拆成独立的DrawXXX方法。 - 逻辑层:负责具体的数据操作,每个操作是一个静态方法或独立的工具类。
这样拆分后,逻辑层的代码可以单独写单元测试,绘制层的代码可以单独调整布局,窗口层几乎不需要改动。
6.3 快捷键与菜单项的配合
给面板里的常用操作绑定快捷键,可以进一步提升效率。在OnGUI里检测Event.current的keyCode和type,可以实现快捷键响应。
private void HandleKeyboardShortcuts() { var e = Event.current; if (e.type == EventType.KeyDown && e.keyCode == KeyCode.Return) { ApplyNameChanges(GetSelectedGameObjects()); e.Use(); } }e.Use()的作用是阻止事件继续传播,避免快捷键被其他控件捕获。另外,菜单项的路径建议用Tools/开头,这样所有自定义工具都会集中在 Unity 菜单栏的 Tools 菜单下,方便查找。
6.4 面板的版本兼容性处理
不同 Unity 版本之间,编辑器 API 可能有差异。比如EditorGUILayout.ObjectField在某些版本里不支持泛型,Undo.RecordObjects的参数类型在不同版本里也有变化。如果你的工具需要在多个 Unity 版本上使用,建议用条件编译来处理版本差异。
#if UNITY_2020_1_OR_NEWER // 新版本 API #else // 旧版本 API #endif我自己的经验是,尽量使用那些从 Unity 5.x 就存在且没有大改的 API,比如EditorGUILayout的基础控件、Undo.RecordObject、EditorUtility.SetDirty。这些 API 稳定且兼容性好,不容易出问题。
7. 我个人在实际操作中的几点体会
写了这么多编辑器面板,最大的感受是:不要为了做工具而做工具。我见过不少项目,编辑器扩展写了一堆,但真正被团队用起来的没几个。原因往往是工具的使用成本比手动操作还高,或者工具的功能和实际工作流不匹配。
我的做法是:先观察团队里哪些操作是高频且重复的,然后只针对这些操作做工具。工具的第一版一定要简单,哪怕只有一个按钮,只要它能解决一个具体的痛点,就有价值。等这个工具被用起来了,再根据反馈逐步增加功能。
另一个体会是:编辑器工具的代码质量同样重要。很多人觉得编辑器代码只在开发期用,随便写写就行。但实际上,编辑器代码的 bug 会直接影响开发效率,而且因为编辑器代码的调试比运行时代码更麻烦,一旦出问题排查成本很高。所以该写的注释要写,该做的异常处理要做,该拆的函数要拆。
最后分享一个小技巧:在面板的OnGUI最后加一个EditorGUILayout.Space()和一条版本信息,比如EditorGUILayout.LabelField("v1.2.0", EditorStyles.miniLabel)。这样当团队里有人反馈问题时,你可以快速确认他用的版本,避免在旧版本上浪费时间排查已经修复的问题。这个习惯是从一次惨痛经历里养成的——当时一个面板的 bug 修了三天,最后发现是同事用的还是半年前的版本。