ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity Editor扩展开发:从MenuItem到批量工具,提升开发效率

Unity Editor扩展开发:从MenuItem到批量工具,提升开发效率

1. 从零开始:为什么我们需要自定义Editor工具

在Unity项目开发的中后期,尤其是当项目规模膨胀到一定程度时,你可能会发现一个现象:每天花在编辑器界面上的重复性操作时间,甚至可能超过了写核心逻辑的时间。比如,为一个新角色配置几十个动画状态机参数,为一批道具逐一填写相同的脚本引用,或者手动调整上百个Prefab的某个公共属性。这些工作枯燥、机械,且极易出错。

这就是Unity Editor扩展的用武之地。它远不止是“美化Inspector面板”那么简单,而是一套能让你将工作流自动化、标准化、甚至智能化的强大武器。一个设计良好的自定义Editor工具,可以将原本需要数小时的手动操作,压缩到一次点击和几秒钟内完成。它不仅仅是提升效率,更是解放了开发者的创造力,让我们能将精力集中在更核心的游戏逻辑和玩法设计上。

网络上关于Editor编程的教程很多,但往往比较零散,要么是教你画一个简单的按钮,要么是深入某个复杂特性(如PropertyDrawer)。对于已经入门、希望系统掌握常用“兵器库”的开发者来说,缺少一份能串联起常用方法、讲清使用场景和避坑指南的汇总。今天,我就结合自己多年的踩坑经验,把那些最常用、最实用、也最容易出问题的Editor API和方法梳理一遍,目标是让你看完就能上手,快速武装自己的编辑器。

2. 基石篇:MenuItem与EditorWindow

任何自定义工具的人口,都离不开这两个核心类。MenuItem是你的工具菜单,EditorWindow是你的工具界面。

2.1 MenuItem:为工具安上启动按钮

MenuItem是一个特性(Attribute),用于在Unity编辑器顶部菜单栏添加自定义项。它的基本语法很简单,但细节决定成败。

using UnityEditor; using UnityEngine; public class MenuItemExamples { // 基础用法:在"Tools"菜单下添加一个选项 [MenuItem("Tools/My Tool/Do Something")] static void DoSomething() { Debug.Log("Something is done!"); } // 添加分隔符和优先级 [MenuItem("Tools/My Tool/_Advanced Action", false, 11)] static void AdvancedAction() { // 高级操作 } // 带验证的菜单项(灰色不可点击) [MenuItem("Tools/My Tool/Require Selection", true)] static bool ValidateRequireSelection() { // 只有当有物体被选中时,菜单项才可用 return Selection.activeGameObject != null; } [MenuItem("Tools/My Tool/Require Selection", false, 12)] static void ExecuteRequireSelection() { Debug.Log($"Operating on: {Selection.activeGameObject.name}"); } }

核心要点与避坑:

  1. 路径规划”Tools/My Tool/Do Something”。建议将你的所有工具都归类到”Tools””Window”菜单下,并建立清晰的子菜单层级,避免污染主菜单。”Tools”通常用于操作类工具,”Window”用于打开面板类工具。
  2. 优先级(Priority)MenuItem的第三个参数。数字越小,在菜单中的位置越靠上。你可以利用这个特性来给菜单项分组(例如,0-10是一组基础工具,11-20是一组高级工具)。注意:如果多个菜单项的路径和优先级完全相同,它们的显示顺序是不确定的。
  3. 验证函数(Validation Function):这是MenuItem最强大的特性之一。通过添加一个同名、同路径但返回bool的静态方法,并将MenuItem的第二个参数设为true,你可以动态控制菜单项是否可用(灰色)。验证函数会在每次菜单刷新时被调用。常见坑点:验证函数和執行函数必须严格匹配路径和优先级,否则关联会失效。
  4. 热键(Hotkey):可以在菜单项文本中使用%(Ctrl/Cmd),#(Shift),&(Alt) 等符号定义快捷键,如”Tools/My Tool/Do Something _%#d”代表 Ctrl/Cmd+Shift+D。注意:热键冲突是常事,尽量使用不常用的组合,并在团队内同步。

2.2 EditorWindow:打造专属工具面板

当你的工具需要复杂的交互或持续的状态显示时,一个独立的窗口就必不可少了。EditorWindow是所有自定义编辑器窗口的基类。

创建与显示一个基础窗口:

using UnityEditor; using UnityEngine; public class MyToolWindow : EditorWindow { // 添加菜单项来打开窗口 [MenuItem("Window/My Tool Panel")] static void ShowWindow() { // 获取现有窗口,如果没有则创建一个 var window = GetWindow<MyToolWindow>(); window.titleContent = new GUIContent("我的工具面板"); window.Show(); } // 窗口的主GUI绘制逻辑 void OnGUI() { GUILayout.Label("这是一个基础工具面板", EditorStyles.boldLabel); if (GUILayout.Button("执行操作")) { Debug.Log("按钮被点击!"); } } }

深入使用与性能优化:

  1. GetWindowvsCreateWindowGetWindow<T>()会尝试查找已存在的同类型窗口,如果找到则聚焦它,否则创建新实例。这通常是你想要的,避免重复打开多个相同面板。EditorWindow.CreateWindow<T>()则总是创建新实例。
  2. titleContent:不仅设置窗口标题,还可以设置图标。例如:new GUIContent(“My Tool”, EditorGUIUtility.IconContent(“d_SceneViewTools”).image)
  3. OnGUI的调用频率OnGUI在每一帧都会被调用,类似于MonoBehaviour的Update这是性能陷阱的高发区!务必避免在OnGUI中进行昂贵的计算或频繁的FindGetComponent操作。复杂的计算结果应该缓存起来。
  4. 序列化与数据持久化EditorWindow继承自ScriptableObject,因此你可以使用[SerializeField]来保存窗口的状态(如用户输入的文本、选择的选项等)。这些数据会在编辑器会话之间保持。
  5. 窗口生命周期:除了OnGUI,还有OnEnable(窗口打开或获得焦点时)、OnDisable(窗口关闭或失去焦点时)、OnDestroy(窗口被销毁时)。OnEnable是初始化数据和订阅事件的好地方,OnDisable则用于清理和取消订阅,防止内存泄漏。

注意:在EditorWindow中,不能直接使用GameObject.Find或依赖于场景中运行时才存在的对象。你需要通过SelectionAssetDatabase或序列化引用来获取数据。

3. 核心武器库:GUILayout与EditorGUILayout

这是你在OnGUI中构建界面的两大主力。GUILayout提供自动布局的基础控件,EditorGUILayout在其基础上提供了大量为编辑器量身定制的、功能更丰富的控件。

3.1 GUILayout:灵活自动布局的基础

GUILayout的核心思想是“自动布局”。你只需要声明控件,系统会自动为你排列,无需手动计算位置和大小。这对于快速构建原型和自适应窗口非常友好。

void OnGUI() { // 垂直布局块:控件从上到下排列 GUILayout.BeginVertical("Box"); // “Box”样式会添加一个背景框 { GUILayout.Label("基本信息"); _name = GUILayout.TextField(_name); // 文本输入框 _age = GUILayout.IntField(_age); // 整数输入框 _toggle = GUILayout.Toggle(_toggle, "启用选项"); } GUILayout.EndVertical(); // 水平布局块:控件从左到右排列 GUILayout.BeginHorizontal(); { if (GUILayout.Button("保存")) { SaveData(); } if (GUILayout.Button("重置")) { ResetData(); } // GUILayout.FlexibleSpace(); // 弹性空间,将后面的控件推到右边 if (GUILayout.Button("高级...", GUILayout.Width(80))) // 固定宽度 { ShowAdvancedOptions(); } } GUILayout.EndHorizontal(); }

布局技巧与常见问题:

  • 嵌套布局BeginVertical/EndVerticalBeginHorizontal/EndHorizontal可以任意嵌套,构建复杂界面。
  • GUILayout.FlexibleSpace():这是一个非常重要的布局元素。它会填充所有可用的弹性空间,常用于实现右对齐或居中。例如,一个水平布局中,先放一个按钮,然后一个FlexibleSpace,再放一个按钮,那么第二个按钮就会被推到最右边。
  • GUILayout.Width/Height/MinWidth/MaxWidth:这些GUILayoutOption参数可以覆盖自动布局计算的大小,实现固定尺寸或限制范围。过度使用固定尺寸会破坏自动布局的适应性
  • GUILayout.ExpandWidth/ExpandHeight:让控件在布局方向上扩展以填充可用空间。

3.2 EditorGUILayout:为编辑器而生的增强控件

EditorGUILayout继承了GUILayout的自动布局,并提供了大量专门用于编辑对象、属性、资源的控件。这是与Unity原生Inspector体验保持一致的关键。

public GameObject targetObject; public int selectedIndex = 0; public float rangeValue = 0.5f; public Color objectColor = Color.white; public Vector3 startPosition; void OnGUI() { // 对象字段:可以直接拖拽场景或项目中的GameObject/Prefab等资源 targetObject = (GameObject)EditorGUILayout.ObjectField("目标物体", targetObject, typeof(GameObject), true); // 弹出式菜单(Popup) string[] options = new string[] { "选项A", "选项B", "选项C" }; selectedIndex = EditorGUILayout.Popup("选择模式", selectedIndex, options); // 滑块(Slider) rangeValue = EditorGUILayout.Slider("强度", rangeValue, 0f, 1f); // 颜色字段(ColorField) objectColor = EditorGUILayout.ColorField("物体颜色", objectColor); // 向量字段(Vector3Field) startPosition = EditorGUILayout.Vector3Field("起始位置", startPosition); // 属性字段(PropertyField) - 与SerializedObject配合使用,后文详述 // EditorGUILayout.PropertyField(serializedObject.FindProperty("someProperty")); }

为什么优先使用EditorGUILayout?

  1. 风格统一:这些控件的外观、行为与Unity原生Inspector完全一致,用户无需学习成本。
  2. 功能强大ObjectField支持拖拽、点击选择;PopupEnumPopup能优雅地处理枚举;Slider有拖动条和数字输入;ColorField内置颜色选择器。
  3. 与序列化系统集成EditorGUILayout.PropertyField是自定义Inspector的基石,它能自动根据属性的类型(float, int, Vector3, 数组,甚至其他自定义类)绘制出合适的UI,并自动处理撤销(Undo)、预设(Prefab)覆盖和多对象编辑。

一个经典组合:EditorGUILayout+SerializedObject这是编写自定义Inspector(Editor类)的标准模式,同样适用于EditorWindow中编辑脚本化对象(ScriptableObject)。

private SerializedObject _serializedObject; private SerializedProperty _nameProperty; private SerializedProperty _levelProperty; void OnEnable() { // 假设target是某个ScriptableObject实例 _serializedObject = new SerializedObject(target); _nameProperty = _serializedObject.FindProperty("characterName"); _levelProperty = _serializedObject.FindProperty("level"); } void OnGUI() { // 更新序列化对象,确保显示最新数据 _serializedObject.Update(); EditorGUILayout.PropertyField(_nameProperty); EditorGUILayout.PropertyField(_levelProperty); // 应用修改,并自动注册撤销操作 _serializedObject.ApplyModifiedProperties(); }

使用SerializedPropertyPropertyField的最大好处是自动处理了撤销/重做(Undo/Redo)。用户任何通过这个界面做出的修改,都可以通过Ctrl+Z撤销,这是专业工具的基本要求。

4. 实战精要:常用模式与高阶技巧

掌握了基础控件,我们来看看如何将它们组合成实用的工具模式,并探讨一些提升工具稳定性和用户体验的高阶技巧。

4.1 自定义Inspector:增强组件编辑体验

当你为自己编写的MonoBehaviour脚本设计一个更友好的编辑界面时,就需要创建自定义的Editor类。

// MyComponent.cs using UnityEngine; public class MyComponent : MonoBehaviour { public string displayName; public int health; public float[] coefficients; } // MyComponentEditor.cs using UnityEditor; [CustomEditor(typeof(MyComponent))] public class MyComponentEditor : Editor { private SerializedProperty _displayNameProp; private SerializedProperty _healthProp; private SerializedProperty _coefficientsProp; void OnEnable() { _displayNameProp = serializedObject.FindProperty("displayName"); _healthProp = serializedObject.FindProperty("health"); _coefficientsProp = serializedObject.FindProperty("coefficients"); } public override void OnInspectorGUI() { // 绘制默认Inspector的另一种方式,通常用于在自定义UI前后保留一些原生属性 // DrawDefaultInspector(); serializedObject.Update(); EditorGUILayout.PropertyField(_displayNameProp); EditorGUILayout.IntSlider(_healthProp, 0, 100); // 使用Slider代替默认的IntField // 绘制数组 EditorGUILayout.PropertyField(_coefficientsProp, true); // 第二个参数true表示包含子级(即展开数组) // 添加自定义按钮 if (GUILayout.Button("自动计算系数")) { // 这里可以访问到target,并进行操作 MyComponent comp = (MyComponent)target; // ... 执行计算逻辑 // 注意:直接修改target后,需要调用serializedObject.Update()来同步序列化数据 } serializedObject.ApplyModifiedProperties(); } }

关键点:

  • [CustomEditor(typeof(MyComponent))]这个特性将编辑器类与组件关联。
  • Editor基类提供了serializedObjecttarget属性。对于修改属性值,永远优先通过serializedObjectSerializedProperty,以保证撤销功能和Prefab覆盖正常工作。
  • DrawDefaultInspector()方法会绘制该组件所有序列化字段的默认UI。你可以在它前后添加自己的GUI代码,来“包裹”默认的Inspector。
  • 直接操作target(转为你的组件类型)是可行的,但通常用于触发方法或计算,而不是直接修改序列化字段。如果修改了,务必调用serializedObject.Update()来刷新序列化数据。

4.2 EditorUtility:非GUI的实用工具集

EditorUtility类包含大量与编辑器交互、显示对话框、进度条等相关的静态方法,是提升工具用户体验的利器。

// 1. 显示进度条 EditorUtility.DisplayProgressBar("处理中", "正在批量修改Prefab...", 0.5f); // ... 执行耗时操作 EditorUtility.ClearProgressBar(); // 完成后必须清除! // 2. 保存/加载文件对话框 string path = EditorUtility.OpenFilePanel("选择配置文件", "Assets/", "json"); string savePath = EditorUtility.SaveFilePanel("保存数据", "Assets/", "NewData", "asset"); // 3. 系统对话框 if (EditorUtility.DisplayDialog("确认", "此操作不可逆,确定继续?", "是", "否")) { // 用户点击了“是” } int choice = EditorUtility.DisplayDialogComplex("复杂选择", "请选择操作", "覆盖", "跳过", "取消"); // 4. 设置对象脏标记(标记为已修改,需要保存) EditorUtility.SetDirty(targetObject);

使用进度条的注意事项:在长时间运行的操作中(如遍历成百上千个资源),使用DisplayProgressBar至关重要。但有一个大坑:这个进度条是阻塞主线程的模态对话框。如果你的操作本身就在主线程进行(绝大多数Editor脚本都是),那么没问题。但如果你在异步操作中更新进度条,需要确保更新UI的代码在主线程执行(可以通过EditorApplication.delayCall或主线程上下文调度)。更重要的铁律:无论操作成功还是失败(异常),都必须在finally块或确保执行的地方调用ClearProgressBar(),否则进度条会一直卡在那里,导致编辑器无法正常使用。

4.3 AssetDatabase与PrefabUtility:资源操作的左膀右臂

自动化工具经常需要与项目资源打交道。

// AssetDatabase: 管理Assets文件夹下的资源 // 查找资源 string[] guids = AssetDatabase.FindAssets("t:Prefab MyCharacter_"); // 查找所有包含‘MyCharacter_’的Prefab foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); // 处理prefab... } // 创建资源 ScriptableObject newConfig = ScriptableObject.CreateInstance<GameConfig>(); AssetDatabase.CreateAsset(newConfig, "Assets/Resources/NewGameConfig.asset"); AssetDatabase.SaveAssets(); // 重要:保存到磁盘 // 导入/刷新资源 AssetDatabase.ImportAsset("Assets/Models/NewModel.fbx", ImportAssetOptions.ForceUpdate); // PrefabUtility: 专门处理Prefab GameObject instanceInScene = ...; GameObject originalPrefab = PrefabUtility.GetCorrespondingObjectFromSource(instanceInScene); // 获取实例对应的原始Prefab // 检查Prefab状态 PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(instanceInScene); if (status == PrefabInstanceStatus.Connected) // 实例与Prefab关联 { // 应用实例的修改到Prefab PrefabUtility.ApplyPrefabInstance(instanceInScene, InteractionMode.UserAction); // 或,将实例重置为Prefab状态 // PrefabUtility.RevertPrefabInstance(instanceInScene, InteractionMode.UserAction); } // 断开或创建Prefab连接 // PrefabUtility.UnpackPrefabInstance(instanceInScene, PrefabUnpackMode.OutermostRoot, InteractionMode.UserAction); // PrefabUtility.SaveAsPrefabAsset(instanceInScene, "Assets/Prefabs/NewPrefab.prefab");

重要经验:

  • AssetDatabase的很多操作(如CreateAsset,SaveAssets)需要在主线程执行。
  • AssetDatabase.Refresh()会触发Unity重新导入所有脏资源,这是一个相对昂贵的操作。在批量修改资源后调用一次即可,避免在循环中调用。
  • PrefabUtility在处理Prefab实例和资源之间的关联时非常强大,但逻辑也相对复杂。务必清楚Apply(实例->资源)、Revert(资源->实例)、Unpack(断开连接)之间的区别。InteractionMode.UserAction参数意味着这个操作会被记录到撤销历史中。

4.4 Undo与EditorGUI.BeginChangeCheck:保障操作可逆与响应

Undo(撤销):让用户安心尝试的保障。任何会修改场景或资源数据的操作,都应该考虑注册撤销。

// 在修改对象前,注册一个撤销点 Undo.RecordObject(targetGameObject, "Change GameObject Position"); targetGameObject.transform.position = new Vector3(1, 2, 3); // 对于多个对象或更复杂的操作,可以使用Undo.RegisterCompleteObjectUndo Undo.RegisterCompleteObjectUndo(new UnityEngine.Object[] { obj1, obj2 }, "Batch Modify Objects"); // ... 修改obj1和obj2的属性

EditorGUI.BeginChangeCheck():这是一个用于检测GUI输入何时发生变化的模式。当你需要在值改变时立即触发某些逻辑(如实时预览效果),而不是等按钮点击时,它就非常有用。

EditorGUI.BeginChangeCheck(); float newValue = EditorGUILayout.Slider("强度", _currentValue, 0, 1); if (EditorGUI.EndChangeCheck()) { // 只有当滑块的值真正被用户改变时,才会进入这里 _currentValue = newValue; OnValueChanged(); // 立即执行更新预览等操作 } // 如果用户只是点击了但没有拖动,EndChangeCheck()会返回false。

这个模式在制作材质编辑器、动画曲线编辑器等需要实时反馈的工具时必不可少。

5. 避坑指南与性能优化

Editor工具开发中遇到的坑,很多都与生命周期、线程和性能有关。

5.1 生命周期与事件订阅

  • OnEnablevsOnDisable:这是EditorWindowEditor类的初始化与清理场所。在OnEnable中订阅事件(如EditorApplication.update,Selection.selectionChanged),必须在OnDisable中取消订阅。否则,当窗口关闭或编辑器重编译时,订阅者不会被自动释放,导致内存泄漏,甚至引发错误(因为回调试图访问已销毁的窗口实例)。
public class MyToolWindow : EditorWindow { void OnEnable() { EditorApplication.update += OnEditorUpdate; Selection.selectionChanged += OnSelectionChanged; } void OnDisable() { EditorApplication.update -= OnEditorUpdate; Selection.selectionChanged -= OnSelectionChanged; } void OnEditorUpdate() { /* 每帧执行,慎用! */ } void OnSelectionChanged() { /* 选择改变时触发 */ } }
  • EditorApplication.update:这是一个每帧触发的事件。除非必要,否则不要订阅它。如果确实需要(如实现一个实时刷新的监视器),确保在OnDisable中取消订阅,并且回调函数内的逻辑要尽可能轻量。

5.2 性能陷阱

  1. OnGUI中的昂贵操作:前文已强调,OnGUI调用频繁。避免在其中进行:

    • 复杂的字符串处理(如频繁的String.Format或正则表达式)。
    • 遍历大型数据结构(如场景中所有物体)。
    • 频繁调用AssetDatabase.LoadAssetAtPathResources.Load解决方案:在OnEnable或响应事件时计算并缓存结果,在OnGUI中只进行绘制。
  2. FindGetComponent:在Editor脚本中,GameObject.FindFindObjectsOfType以及GetComponent同样昂贵,尤其是在OnGUI中。对于需要频繁访问的对象,考虑在OnEnable或选择变化时缓存引用。

  3. 过度重绘:如果你的窗口内容非常复杂,可以考虑使用EditorGUIUtility.IsDisplayChange或自定义的脏标记(dirty flag)来减少不必要的OnGUI计算。只有当数据真正改变时才触发重绘逻辑。

5.3 多对象编辑与撤销的复杂性

当你编写自定义Inspector (Editor)时,target实际上可能是一个对象数组(用户多选了同类型组件)。你的编辑器需要能处理这种情况。

public override void OnInspectorGUI() { // 多对象编辑时,serializedObject代表所有选中对象的合并视图。 serializedObject.Update(); SerializedProperty commonProp = serializedObject.FindProperty("commonField"); EditorGUILayout.PropertyField(commonProp); // 对于多对象,直接修改targets中的每个对象是危险的,可能破坏撤销或Prefab链接。 // 最好的做法是始终通过serializedObject和SerializedProperty来修改值。 // 如果必须调用方法,可以这样: if (GUILayout.Button("对所有选中对象执行操作") && targets.Length > 1) { Undo.RecordObjects(targets, "Batch Operation"); foreach (var t in targets) { MyComponent comp = (MyComponent)t; comp.DoSomething(); } } serializedObject.ApplyModifiedProperties(); }

核心原则:对于序列化字段的修改,坚持使用SerializedProperty。对于非序列化操作或方法调用,使用Undo.RecordObject(s)显式注册撤销,并遍历targets数组。

5.4 脚本重编译与数据持久化

当你在编辑器中修改并保存了脚本,Unity会触发重编译。此时,所有EditorWindowEditor的实例都会被销毁然后重新创建。如果你在窗口中有未保存的临时状态(比如用户输入了一半的文本),就会丢失。

解决方案:使用ScriptableObject作为数据容器,或者将状态序列化到EditorPrefs或一个临时文件中。

[System.Serializable] public class ToolState : ScriptableObject { public string lastSearch; public int selectedTab; } public class MyPersistentWindow : EditorWindow { private ToolState _state; void OnEnable() { // 从EditorPrefs加载或创建新的状态对象 string stateJson = EditorPrefs.GetString("MyToolState"); if (!string.IsNullOrEmpty(stateJson)) { _state = ScriptableObject.CreateInstance<ToolState>(); JsonUtility.FromJsonOverwrite(stateJson, _state); } if (_state == null) _state = ScriptableObject.CreateInstance<ToolState>(); } void OnDisable() { // 将状态保存到EditorPrefs string stateJson = JsonUtility.ToJson(_state); EditorPrefs.SetString("MyToolState", stateJson); DestroyImmediate(_state); // 销毁创建的ScriptableObject实例 } void OnGUI() { // 使用_state中的数据 _state.lastSearch = EditorGUILayout.TextField("搜索", _state.lastSearch); // ... } }

这种方法可以保证即使在脚本重编译后,用户的输入也不会丢失。当然,对于更复杂的数据,你可能需要直接序列化到项目内的一个ScriptableObject资产中。

6. 综合案例:一个简单的批量重命名工具

让我们将以上知识融会贯通,创建一个实用的批量重命名工具窗口。这个工具允许用户选择多个场景中的GameObject,并按照指定规则(如添加前缀、后缀、替换文本、序列号)进行重命名。

using UnityEditor; using UnityEngine; using System.Text.RegularExpressions; public class BatchRenameWindow : EditorWindow { private string _prefix = ""; private string _suffix = ""; private string _find = ""; private string _replace = ""; private bool _useNumbering = false; private int _startNumber = 1; private int _step = 1; private string _numberFormat = "00"; // 如 "01", "02" [MenuItem("Tools/Utilities/Batch Rename")] static void Init() { var window = GetWindow<BatchRenameWindow>(); window.titleContent = new GUIContent("批量重命名"); window.Show(); } void OnGUI() { GUILayout.Label("批量重命名工具", EditorStyles.boldLabel); EditorGUILayout.HelpBox("选中场景中的多个GameObject,然后在此设置规则。", MessageType.Info); // 输入规则 _prefix = EditorGUILayout.TextField("前缀", _prefix); _suffix = EditorGUILayout.TextField("后缀", _suffix); EditorGUILayout.Space(); GUILayout.Label("查找与替换", EditorStyles.miniBoldLabel); _find = EditorGUILayout.TextField("查找", _find); _replace = EditorGUILayout.TextField("替换为", _replace); EditorGUILayout.Space(); _useNumbering = EditorGUILayout.BeginToggleGroup("添加序列号", _useNumbering); _startNumber = EditorGUILayout.IntField("起始数字", _startNumber); _step = EditorGUILayout.IntField("步长", _step); _numberFormat = EditorGUILayout.TextField("格式 (如 00)", _numberFormat); EditorGUILayout.EndToggleGroup(); EditorGUILayout.Space(10); // 显示当前选中对象 GameObject[] selected = Selection.gameObjects; GUILayout.Label($"当前选中对象: {selected.Length} 个", EditorStyles.boldLabel); if (selected.Length == 0) { EditorGUILayout.HelpBox("请在场景中至少选择一个GameObject。", MessageType.Warning); } else { EditorGUILayout.BeginVertical("Box"); for (int i = 0; i < Mathf.Min(selected.Length, 10); i++) // 最多显示10个预览 { string newName = GenerateNewName(selected[i].name, i); EditorGUILayout.LabelField($"{selected[i].name} -> {newName}"); } if (selected.Length > 10) { EditorGUILayout.LabelField("... 以及更多", EditorStyles.centeredGreyMiniLabel); } EditorGUILayout.EndVertical(); } EditorGUILayout.Space(); using (new EditorGUI.DisabledScope(selected.Length == 0)) { if (GUILayout.Button("应用重命名", GUILayout.Height(30))) { ApplyRename(selected); } } } void OnSelectionChange() { // 当场景中的选择发生变化时,重绘窗口以更新预览 Repaint(); } private string GenerateNewName(string originalName, int index) { string newName = originalName; // 查找与替换 if (!string.IsNullOrEmpty(_find)) { newName = Regex.Replace(newName, _find, _replace); } // 添加序列号 if (_useNumbering) { int number = _startNumber + index * _step; string numberStr = number.ToString(_numberFormat); newName = numberStr + " " + newName; // 将序号放在前面,通常更清晰 } // 添加前缀和后缀 newName = _prefix + newName + _suffix; return newName.Trim(); // 去除可能的首尾空格 } private void ApplyRename(GameObject[] objects) { // 注册一个组合撤销操作 Undo.RecordObjects(objects, "Batch Rename"); for (int i = 0; i < objects.Length; i++) { objects[i].name = GenerateNewName(objects[i].name, i); } // 提示用户 EditorUtility.DisplayDialog("完成", $"已成功重命名 {objects.Length} 个对象。", "确定"); // 强制刷新Inspector和场景视图,显示新名字 EditorApplication.RepaintHierarchyWindow(); EditorApplication.RepaintProjectWindow(); } }

这个案例体现了以下关键点:

  1. 完整的EditorWindow生命周期OnEnable(本例中未使用,可用于加载保存的状态)、OnGUIOnSelectionChange(响应选择变化)。
  2. 丰富的EditorGUILayout控件TextFieldToggleGroupIntFieldHelpBoxLabelFieldDisabledScope(用于禁用按钮)。
  3. 与编辑器交互:通过Selection.gameObjects获取选中的对象;通过Undo.RecordObjects注册撤销;通过EditorUtility.DisplayDialog给用户反馈;通过Repaint方法手动触发窗口重绘。
  4. 用户体验:实时预览重命名效果;当没有选中对象时禁用按钮并给出提示;使用HelpBox提供指引。
  5. 健壮性:使用Regex.Replace进行灵活的查找替换;使用Mathf.Min限制预览列表长度;使用string.Trim清理结果。

通过这样一个工具,你可以瞬间完成以前需要手动逐个修改的繁琐工作。这正是Unity Editor编程的魅力所在——将重复劳动自动化,让你专注于更有创造性的部分。

返回列表