Unity3D 进阶教程 - 05 自定义编辑器扩展

概述

Unity 编辑器本身就是一个强大的工具,但重复性操作——创建资源、配置组件、批量处理——如果每次都手动做,效率低下且容易出错。

自定义编辑器扩展 就是利用 Unity Editor API 来编写工具脚本,将重复劳动自动化。所有扩展代码都放在 Editor/ 目录下,不会影响运行时逻辑。

1. 编辑器脚本基础

1.1 目录规则

1
2
3
Assets/
Editor/ # 编辑器脚本放在这里,不会被编译到运行时
Scripts/ # 运行时脚本

编辑器脚本必须引用 UnityEditor 命名空间:

1
2
using UnityEditor;
using UnityEngine;

1.2 关键特性标记

特性 作用
[CustomEditor(typeof(T))] 自定义 Inspector 面板
[CustomPropertyDrawer(typeof(T))] 自定义属性绘制
[MenuItem(“Tools/xxx”)] 添加菜单项
[InitializeOnLoadMethod] 编辑器启动或脚本编译后自动执行

2. 自定义 Inspector

默认的 Inspector 显示所有 public 和 [SerializeField] 字段。通过自定义 Inspector,可以控制布局、分组、可视化预览。

2.1 基础自定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Weapon : MonoBehaviour
{
public string WeaponName;
public int Damage;
public float AttackRange;
public GameObject ModelPrefab;
}

[CustomEditor(typeof(Weapon))]
public class WeaponEditor : Editor
{
public override void OnInspectorGUI()
{
var weapon = (Weapon)target;

EditorGUILayout.LabelField("=== 武器配置 ===", EditorStyles.boldLabel);

weapon.WeaponName = EditorGUILayout.TextField("武器名称", weapon.WeaponName);
weapon.Damage = EditorGUILayout.IntSlider("伤害", weapon.Damage, 1, 999);
weapon.AttackRange = EditorGUILayout.Slider("攻击范围", weapon.AttackRange, 0f, 100f);
weapon.ModelPrefab = EditorGUILayout.ObjectField("模型", weapon.ModelPrefab, typeof(GameObject), false) as GameObject;

EditorGUILayout.Space();
if (GUILayout.Button("预览武器"))
{
weapon.GetComponent<Renderer>().enabled = true;
}
}
}

3. 自定义 PropertyDrawer

PropertyDrawer 控制 单个属性 在 Inspector 中的绘制方式,无需写整个 Editor 类。

3.1 范围滑条

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class MinMaxSliderAttribute : PropertyAttribute
{
public float Min;
public float Max;

public MinMaxSliderAttribute(float min, float max)
{
Min = min;
Max = max;
}
}

[CustomPropertyDrawer(typeof(MinMaxSliderAttribute))]
public class MinMaxSliderDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
if (property.propertyType == SerializedPropertyType.Vector2)
{
var attr = attribute as MinMaxSliderAttribute;
EditorGUI.BeginProperty(position, label, property);

var value = property.vector2Value;
EditorGUI.MinMaxSlider(position, label, ref value.x, ref value.y, attr.Min, attr.Max);
property.vector2Value = value;

EditorGUI.EndProperty();
}
else
{
EditorGUI.LabelField(position, label.text, "请将 MinMaxSlider 用于 Vector2");
}
}
}

使用:

1
2
3
4
5
6
7
8
public class Player : MonoBehaviour
{
[MinMaxSlider(0f, 100f)]
public Vector2 AttackRange = new(10f, 50f);

[MinMaxSlider(0f, 10f)]
public Vector2 SpeedRange = new(1f, 5f);
}

3.2 只读属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ReadOnlyAttribute : PropertyAttribute { }

[CustomPropertyDrawer(typeof(ReadOnlyAttribute))]
public class ReadOnlyDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
GUI.enabled = false;
EditorGUI.PropertyField(position, property, label);
GUI.enabled = true;
}
}

// 使用
public class Player : MonoBehaviour
{
[ReadOnly]
public string PlayerId = System.Guid.NewGuid().ToString();
}

4. EditorWindow 工具窗口

当需要独立工具窗口(而非 Inspector 面板)时,继承 EditorWindow。

4.1 批量重命名工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class BatchRenameWindow : EditorWindow
{
private string _prefix = "";
private string _suffix = "";
private int _startNumber = 1;

[MenuItem("Tools/Batch Rename")]
public static void ShowWindow()
{
GetWindow<BatchRenameWindow>("批量重命名");
}

private void OnGUI()
{
EditorGUILayout.LabelField("批量重命名工具", EditorStyles.boldLabel);
EditorGUILayout.Space();

_prefix = EditorGUILayout.TextField("前缀", _prefix);
_suffix = EditorGUILayout.TextField("后缀", _suffix);
_startNumber = EditorGUILayout.IntField("起始编号", _startNumber);

EditorGUILayout.Space();
if (GUILayout.Button("重命名选中物体"))
{
RenameSelected();
}
}

private void RenameSelected()
{
var selected = Selection.gameObjects;
if (selected.Length == 0)
{
EditorUtility.DisplayDialog("提示", "请先在场景中选择要重命名的物体", "确定");
return;
}

Undo.RecordObjects(selected, "Batch Rename");

for (int i = 0; i < selected.Length; i++)
{
selected[i].name = _prefix + (_startNumber + i) + _suffix;
}

Debug.Log("已重命名 " + selected.Length + " 个物体");
}
}

4.2 资源检查工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class AssetCheckerWindow : EditorWindow
{
[MenuItem("Tools/Asset Checker")]
public static void ShowWindow()
{
GetWindow<AssetCheckerWindow>("资源检查");
}

private Vector2 _scrollPos;

private void OnGUI()
{
EditorGUILayout.LabelField("资源检查工具", EditorStyles.boldLabel);
EditorGUILayout.Space();

if (GUILayout.Button("检查缺失引用的预制体"))
{
CheckMissingReferences();
}
}

private void CheckMissingReferences()
{
var guids = AssetDatabase.FindAssets("t:Prefab");
var missingCount = 0;

_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);

foreach (var guid in guids)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
var components = prefab.GetComponentsInChildren<Component>(true);

foreach (var component in components)
{
if (component == null)
{
EditorGUILayout.LabelField("缺失组件: " + path);
missingCount++;
continue;
}

var so = new SerializedObject(component);
var prop = so.GetIterator();

while (prop.NextVisible(true))
{
if (prop.propertyType == SerializedPropertyType.ObjectReference && prop.objectReferenceValue == null)
{
if (prop.name != "m_Script")
{
EditorGUILayout.LabelField(path + "/" + component.name + " -> " + prop.displayName + " 为空");
missingCount++;
}
}
}
}
}

EditorGUILayout.EndScrollView();

if (missingCount == 0)
{
EditorGUILayout.LabelField("未发现缺失引用");
}
}
}

5. 编辑器工具开发建议

5.1 性能注意事项

  • OnGUI 每帧调用多次,避免在里面做 AssetDatabase 等开销大的操作
  • 使用 EditorApplication.delayCall 延迟执行非 UI 操作
  • 长时间任务使用 EditorUtility.DisplayProgressBar 显示进度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void BatchProcess()
{
var assets = GetAssets();
for (int i = 0; i < assets.Length; i++)
{
if (EditorUtility.DisplayCancelableProgressBar(
"处理中", "正在处理 " + assets[i].name + " (" + (i + 1) + "/" + assets.Length + ")",
(float)i / assets.Length))
{
break;
}
// 处理逻辑...
}
EditorUtility.ClearProgressBar();
}

5.2 Undo 支持

任何修改对象的操作都应该支持 Undo:

1
2
3
Undo.RecordObject(target, "修改描述");
// 修改属性...
PrefabUtility.RecordPrefabInstancePropertyModifications(target);

总结

  • Custom Editor:控制 MonoBehaviour 的 Inspector 显示
  • PropertyDrawer:控制单个属性的绘制方式,复用性高
  • EditorWindow:独立工具窗口,适合批处理工具
  • MenuItem:注册菜单入口

自定义编辑器扩展是 Unity 高级开发的分水岭——能从”用工具的人”变成”造工具的人”。

下一篇将探讨 进阶对象池与 Addressables 资源管理,解决内存和加载效率问题。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: