首先明确一点,编辑器开发的知识还是很多的,正确的学习方式还是从实践入手,用查工具一样的方式去学习使用。

1. Unity编辑器开发前置知识

1.1 什么是注解(Attribute)?

注解(Attribute) 是 C# 提供的一种元数据(Metadata)机制,用于在类、方法、字段、属性等上附加额外的信息。

在 Unity 编辑器开发中,注解常用于告诉 Unity 或自定义的编辑器脚本如何处理某个对象或方法。

[Serializable]
public class PlayerData
{
    public int health;
    public int score;
}

[SerializeField] private int health = 100;

[Range(0, 10)] public float speed = 5f;

[MenuItem("Tools/Run Custom Action")]  
public static void Run() { ... }

这里的 [Serializable] 告诉 Unity 这个类可以被序列化,从而能在 Inspector 中显示。

1.2 如何给类与类的方法加注解

你可以在类、方法、字段或属性前加上方括号 [] 来声明注解。

// 给类加注解
[MyCustom("Player Class")]
public class Player
{
    // 给字段加注解
    [Range(0, 100)]
    public int health;

    // 给方法加注解
    [MyCustom("Init Method")]
    public void Initialize()
    {
        Debug.Log("Player initialized");
    }
}

// 自定义一个注解类
[System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method)]
public class MyCustomAttribute : System.Attribute
{
    public string Description { get; }

    public MyCustomAttribute(string description)
    {
        Description = description;
    }
}

上例中:

  • MyCustomAttribute 是一个自定义注解;
  • [MyCustom("xxx")] 表示在类或方法上附加额外说明信息。
  • 注意这里的命名时可以省略 Attribute,使用时也可以省略 Attribute,前提是无歧义。

1.3 如何读取注解

读取注解通常通过 反射(Reflection) 实现。

using System;
using System.Reflection;
using UnityEngine;

public class AttributeReader
{
    [MyCustom("Example Method")]
    public void Example() { }

    [ContextMenu("Test Read Attributes")]
    void TestRead()
    {
        // 获取类型
        Type type = typeof(AttributeReader);

        // 遍历方法
        foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
        {
            // 获取自定义注解
            MyCustomAttribute attr = method.GetCustomAttribute<MyCustomAttribute>();
            if (attr != null)
            {
                Debug.Log($"方法 {method.Name} 的描述为: {attr.Description}");
            }
        }
    }
}

运行后会在 Unity 控制台输出注解内容。

这在编辑器工具中非常常见,比如扫描项目中所有打了某个标签的类并自动注册。

2. 自定义 Inspector

2.1 利用 Attribute 直接控制

利用 Attribute 可以直接来设置 Inspector 而不用单独编写整个界面,比如下面的代码:

using UnityEngine;

public class TheAttributeSample : MonoBehaviour
{
    [Header("基础显示")]
    [SerializeField, Tooltip("移动速度 (0~10)")]
    [Range(0, 10)]
    private float moveSpeed = 5f;

    [Space]
    [Header("多行文本")]
    [TextArea(2, 5)]
    public string description = "说明文字";

    [ContextMenu("重置参数")]
    private void ResetValues()
    {
        moveSpeed = 5f;
        description = "说明文字";
        Debug.Log("已重置参数");
    }

    [Space]
    [Header("上下文按钮")]
    [ContextMenuItem("随机化数值", "RandomizeValue")]
    public int score = 10;
    private void RandomizeValue() => score = Random.Range(0, 100);

    [Space]
    [Header("颜色与枚举")]
    public Color colorValue = Color.green;
    public Mode currentMode = Mode.Attack;
    public enum Mode { Idle, Attack, Defend }

    [Space]
    [Header("引用与数组")]
    public Transform target;
    public GameObject[] enemyList;
}

在修改后:

  • Header 添加一个标题
  • Space 是空行
  • [Range(0, 100)][TextArea()] 是具体的界面样式调整。
  • [Tooltip("xxx")] 是鼠标移动上去的提示。

attribute sample 1

ContextMenu 重置参数按钮控制的是组件那个地方。

attribute sample 2

ContextMenuItem 是在属性的右键菜单中。

attribute sample 3

2.2 CustomEditor / 自定义界面的方法

前面的 Attribute 与逻辑代码还是贴在一起的,很方便但是自定义程度还不够。

我们可以专门为一个 Component 来编写它的 Inspector,下面就来介绍这种方法。

首先要确保我们的代码的 .cs 文件需要放在任意的名为 Editor 的文件夹下面,然后使用 CustomEditor 来实现自定义绘制。

using System;
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(TheAttributeSample))] // 关联脚本类型
public class TheAttributeSampleEditor : Editor
{
    SerializedProperty moveSpeed;
    
    private void OnEnable()
    {
        moveSpeed = serializedObject.FindProperty("moveSpeed");
    }

    public override void OnInspectorGUI()
    {
        // 1. 更新序列化对象
        serializedObject.Update();

        // 2. 绘制默认属性
        DrawDefaultInspector();

        // 3. 自定义按钮
        if (GUILayout.Button("打印移动速度"))
        {
            Debug.Log("MoveSpeed = " + moveSpeed.floatValue);
        }

        // 4. 应用修改
        serializedObject.ApplyModifiedProperties();
    }
}
  • [CustomEditor(typeof(TheAttributeSample))] 表示关联的类是 TheAttributeSample
  • OnInspectorGUI 会在显示和输入更改时调用。
  • OnInspectorGUIoverride 意味着可以通过 base.OnInspectorGUI() 来表示默认界面。

Custom Editor Inspector

这里还有个稍微复杂点的用法,就是可以有参数:

public CustomEditor(System.Type inspectedType, bool editorForChildClasses)

public bool isFallback { get; set; }

// 使用下面这行可替换全局 MonoBehaviour 的 Inspector
[CustomEditor(typeof(MonoBehaviour), true, isFallback = true)]

这样可以直接替换掉全局的 MonoBehaviour,但是 isFallback = true 保证了只有没任何其他可用项的时候才会使用当前的这个。

2.3 生命周期

在运行时(Play Mode)我们有:

Awake → OnEnable → Start → Update → OnDisable → OnDestroy

在编辑器扩展(Editor Mode)中也有类似的体系,只不过发生在编辑器层面:

OnEnable → OnInspectorGUI / OnSceneGUI (重复执行) → OnDisable

这些回调运行在 UnityEditor 命名空间下;Unity 会在编辑器状态变动(比如脚本重编译、选中物体、打开 Inspector 等)时自动触发。

Editor 生命周期完整流程(按时间顺序):

阶段回调方法调用时机常见用途
初始化阶段OnEnable()当 Inspector 打开 / 选中物体 / 脚本重编译时缓存 SerializedProperty、注册事件、初始化资源
激活阶段OnInspectorGUI()每次 Inspector 重绘时调用(非常频繁)绘制 UI、检测用户输入、修改数据
场景绘制阶段OnSceneGUI()当对象在 Scene 视图中被选中或 Scene 视图重绘时绘制 Gizmo、Handles、交互操作
预览阶段HasPreviewGUI() / OnPreviewGUI()在 Inspector 底部绘制预览(模型、音频等)自定义资源预览窗口
更新阶段OnInspectorUpdate()编辑器定期刷新(大约 10 FPS)定时刷新状态、检测异步任务
销毁阶段OnDisable()当 Inspector 被关闭、切换选中对象或 Domain Reload 时注销事件、释放内存、清理引用

2.4 Editor.serializedObject

在 Editor 脚本中,serializedObject 是一个特殊的对象,它是对你正在编辑的脚本(即 target)的一个 序列化包装(封装层)。

如果你在 Editor 里直接写:

((MyScript)target).speed = 5;

它不支持:

  • Undo
  • Prefab 标记覆盖
  • 多选对象 target 时无法统一修改

Unity 的序列化系统解决了这些问题,所以提供了 serializedObject

  • serializedObject = 当前目标对象(target)的序列化版本
  • SerializedProperty = 该对象中的一个字段的序列化包装

比如:

SerializedProperty moveSpeed;

void OnEnable()
{
    moveSpeed = serializedObject.FindProperty("moveSpeed");
}

public override void OnInspectorGUI()
{
    serializedObject.Update();
    EditorGUILayout.PropertyField(speed);
    serializedObject.ApplyModifiedProperties();
}

上面代码可以这么理解:Update 是把 target 的数据同步到 serializedObject,EditorGUILayout.PropertyField 是自动根据类型显示在操作界面,修改的时候会同步回传。而 ApplyModifiedProperties 是把 serializedObject 的修改,传递回 target。

2.5 GUILayout / IMGUI

GUILayout 属于 IMGUI(Immediate Mode GUI)系统,它是最基础的 GUI 绘制类,能在:

  • MonoBehaviour.OnGUI()(游戏运行时)
  • Editor.OnInspectorGUI()(编辑器自定义 Inspector)
  • EditorWindow.OnGUI()(自定义编辑器窗口)

中使用。

在 Editor 里使用时,一般我们这样写:

public override void OnInspectorGUI()
{
    GUILayout.Label("标题");
}

简记:Label / Text / Toggle / Slider / Button / Layout

一个综合的例子:

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(TheAttributeSample))]
public class TheAttributeSampleEditor : Editor
{
    private bool toggleValue = true;
    private float sliderValue = 5f;
    private string inputText = "初始文本";
    private string multiLine = "多行文本\n支持换行";
    private Vector2 scrollPos;

    public override void OnInspectorGUI()
    {
        // 标题
        GUILayout.Label("👋 自定义 Inspector - GUILayout 控件演示", EditorStyles.boldLabel);

        GUILayout.Space(10);

        // 输入框
        GUILayout.Label("单行文本输入:");
        inputText = GUILayout.TextField(inputText);

        GUILayout.Space(5);

        // 多行输入框
        GUILayout.Label("多行文本输入:");
        multiLine = GUILayout.TextArea(multiLine, GUILayout.Height(60));

        GUILayout.Space(5);

        // 开关
        toggleValue = GUILayout.Toggle(toggleValue, "启用某个功能");

        GUILayout.Space(5);

        // 滑条
        GUILayout.Label($"移动速度: {sliderValue:F2}");
        sliderValue = GUILayout.HorizontalSlider(sliderValue, 0, 10);

        GUILayout.Space(10);

        // 按钮
        if (GUILayout.Button("打印当前状态"))
        {
            Debug.Log($"输入: {inputText}, 开关: {toggleValue}, 速度: {sliderValue}");
        }

        GUILayout.Space(10);
        GUILayout.Box("", GUILayout.ExpandWidth(true), GUILayout.Height(2)); // 分隔线

        // 滚动区域
        GUILayout.Label("滚动区域示例:");
        scrollPos = GUILayout.BeginScrollView(scrollPos, GUILayout.Height(100));
        for (int i = 0; i < 20; i++)
        {
            GUILayout.Label("项目 " + i);
        }
        GUILayout.EndScrollView();

        GUILayout.Space(10);

        GUILayout.Label("布局控制:");
        GUILayout.BeginHorizontal();
        if (GUILayout.Button("左按钮")) Debug.Log("左");
        if (GUILayout.Button("右按钮")) Debug.Log("右");
        GUILayout.EndHorizontal();

        GUILayout.Space(5);

        GUILayout.Label("示例结束 🎯");
    }
}

样式:

the attribute sample

2.6 EditorGUILayout

EditorGUILayout 是 Unity 编辑器脚本里的一个工具类,它可以帮你在 Inspector 或 自定义窗口里显示各种控件,比如按钮、滑条、颜色选择器等。

自动支持 Undo、Prefab 覆盖、多对象编辑

和 GUILayout 的区别是,它支持多对象,写起来更简洁,样式统一。

常用控件:

控件用途示例
LabelField显示文本EditorGUILayout.LabelField("标题")
TextField输入单行文字EditorGUILayout.TextField("名字", name)
TextArea多行文字EditorGUILayout.TextArea(description)
Toggle开关EditorGUILayout.Toggle("是否启用", isOn)
Slider滑条EditorGUILayout.Slider("速度", speed, 0, 10)
IntField整数输入EditorGUILayout.IntField("生命", hp)
FloatField浮点输入EditorGUILayout.FloatField("速度", speed)
Vector3Field三维向量EditorGUILayout.Vector3Field("位置", pos)
ColorField颜色选择EditorGUILayout.ColorField("颜色", color)
ObjectField拖拽对象EditorGUILayout.ObjectField("目标", obj, typeof(GameObject), true)
EnumPopup下拉选择枚举EditorGUILayout.EnumPopup("类型", playerClass)
Foldout折叠面板fold = EditorGUILayout.Foldout(fold, "高级")
PropertyField自动绘制 SerializedPropertyEditorGUILayout.PropertyField(serializedProp)

一个案例:

using UnityEngine;

public class PlayerSettings : MonoBehaviour
{
    [SerializeField, Range(0, 10)]
    private float moveSpeed = 5f;

    [SerializeField]
    private string playerName = "Hero";

    [SerializeField]
    private Color playerColor = Color.cyan;

    [SerializeField]
    private bool isGodMode = false;
}

Editor 部分:

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(PlayerSettings))]
public class PlayerSettingsEditor : Editor
{
    // 定义 SerializedProperty 对象
    SerializedProperty moveSpeed;
    SerializedProperty playerName;
    SerializedProperty playerColor;
    SerializedProperty isGodMode;

    // 初始化:在编辑器加载时绑定字段
    void OnEnable()
    {
        moveSpeed = serializedObject.FindProperty("moveSpeed");
        playerName = serializedObject.FindProperty("playerName");
        playerColor = serializedObject.FindProperty("playerColor");
        isGodMode = serializedObject.FindProperty("isGodMode");
    }

    // 自定义 Inspector 界面
    public override void OnInspectorGUI()
    {
        // 同步目标对象的最新数据
        serializedObject.Update();

        GUILayout.Label("🎮 Player Settings", EditorStyles.boldLabel);
        EditorGUILayout.Space();

        // 绘制属性字段(自动支持撤销 / Prefab)
        EditorGUILayout.PropertyField(playerName, new GUIContent("Player Name"));
        EditorGUILayout.PropertyField(moveSpeed, new GUIContent("Move Speed"));
        EditorGUILayout.PropertyField(playerColor, new GUIContent("Player Color"));
        EditorGUILayout.PropertyField(isGodMode, new GUIContent("God Mode"));

        EditorGUILayout.Space(10);

        // 添加一个自定义按钮
        if (GUILayout.Button("打印玩家信息"))
        {
            var player = (PlayerSettings)target;
            Debug.Log($"👤 {playerName.stringValue} - 速度: {moveSpeed.floatValue}, 无敌: {isGodMode.boolValue}");
        }

        // 提交修改回目标对象
        serializedObject.ApplyModifiedProperties();
    }
}

效果:

EditorGUILayout Demo 1

完全可以理解为是一个抽象了一个中间层的 IMGUI,避免手动绑定一些写法。如果复杂点写,还可以实现下面的效果:

EditorGUILayout Demo 2

2.6.1 展开折叠的例子

这个例子虽然简单,但是由于太过常用,这里专门写一下。

展开折叠的例子:

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(PlayerSettings))]
public class PlayerSettingsEditor : Editor
{
    // SerializedProperty 对象
    SerializedProperty moveSpeed;
    SerializedProperty playerName;
    SerializedProperty playerColor;
    SerializedProperty isGodMode;

    // 折叠状态变量
    bool showBasicSettings = true;
    bool showAdvancedSettings = false;

    void OnEnable()
    {
        moveSpeed = serializedObject.FindProperty("moveSpeed");
        playerName = serializedObject.FindProperty("playerName");
        playerColor = serializedObject.FindProperty("playerColor");
        isGodMode = serializedObject.FindProperty("isGodMode");
    }

    public override void OnInspectorGUI()
    {
        serializedObject.Update();

        // 折叠面板:基础设置
        showBasicSettings = EditorGUILayout.Foldout(showBasicSettings, "基础设置");
        if (showBasicSettings)
        {
            EditorGUILayout.PropertyField(playerName);
            EditorGUILayout.PropertyField(moveSpeed);
        }

        EditorGUILayout.Space();

        // 折叠面板:高级设置
        showAdvancedSettings = EditorGUILayout.Foldout(showAdvancedSettings, "高级设置");
        if (showAdvancedSettings)
        {
            EditorGUILayout.PropertyField(playerColor);
            EditorGUILayout.PropertyField(isGodMode);
        }

        EditorGUILayout.Space();

        // 打印按钮
        if (GUILayout.Button("打印玩家信息"))
        {
            Debug.Log($"玩家 {playerName.stringValue}, 速度 {moveSpeed.floatValue}, " +
                      $"颜色 {playerColor.colorValue}, 无敌: {isGodMode.boolValue}");
        }

        serializedObject.ApplyModifiedProperties();
    }
}

效果实现:

Inspector Fold Demo

2.7 Handles / OnSceneGUI

OnSceneGUI 是 Unity 自定义编辑器里的一个回调函数,允许你在 Scene 视图中绘制和交互,每次 Scene 视图刷新时自动调用。

Handles 是 Unity 提供的一组 Scene 视图可视化控件和绘图工具:

方法用途
Handles.PositionHandle可拖拽控制位置
Handles.RotationHandle可旋转控制
Handles.ScaleHandle可缩放控制
Handles.Label显示文本标签
Handles.SphereHandleCap绘制球体
Handles.DrawLine绘制线条
Handles.DrawWireCube绘制空心方块

我们直接上使用方式。首先给 PlayerSettings 增加一个三维坐标,后面会用 Handles 来可视化操作这个三维坐标。

public class PlayerSettings : MonoBehaviour
{
    // ...
    //
    public Vector3 scenePosition = Vector3.zero;
}

然后在 OnSceneGUI 中通过 Handles 来实现操作 scenePosition 的可视化修改。

using UnityEditor;
using UnityEditor.TerrainTools;
using UnityEngine;

[CustomEditor(typeof(PlayerSettings))]
public class PlayerSettingsEditor : Editor
{
    private void OnSceneGUI()
    {
        PlayerSettings player = (PlayerSettings)target;

        // 开始监测修改
        EditorGUI.BeginChangeCheck();

        // 拖拽 PositionHandle 控制 scenePosition
        Vector3 newPos = Handles.PositionHandle(player.scenePosition, Quaternion.identity);

        if (EditorGUI.EndChangeCheck())
        {
            Undo.RecordObject(player, "Move Scene Position");  // 支持 Undo
            player.scenePosition = newPos;                     // 更新位置
            EditorUtility.SetDirty(player);                   // 标记对象已修改
        }

        // 绘制一个球体标记
        Handles.color = Color.aliceBlue;
        Handles.SphereHandleCap(0, player.scenePosition, Quaternion.identity, 0.5f, EventType.Repaint);

        // 绘制名字标签
        Handles.Label(player.scenePosition + Vector3.up * 0.6f, "Scene Position");
    }
}

效果:

  • 注意,这里必须要选择这个物体,才会在 Scene 中进行绘制,和下面要介绍的 Gizmo 是有区别的。

Handles Demo

这个坐标还是可以在 Inspector 中直接编辑的,不影响这一点。

Handles Demo Inspector

2.8 Gizmos / OnDrawGizmos

Handles 只能在 Scene 中进行绘制,而且必须要选中。

Gizmos 可以同时在 Scene 和 Game 中绘制并显示,准确来说这个知识是不算是 Editor 开发里面的,这里只是对比讲解下 Handles。

方法用途
Gizmos.DrawLine(start, end)绘制直线
Gizmos.DrawRay(origin, direction)绘制射线
Gizmos.DrawSphere(center, radius)绘制球体
Gizmos.DrawCube(center, size)绘制方块
Gizmos.DrawWireCube(center, size)绘制空心方块
Gizmos.color设置颜色
Gizmos.DrawIcon(position, iconName, allowScaling)绘制图标

还是回到 PlayerSettings,我们的目标是用 Gizmos 显示它:

using UnityEngine;

public class PlayerSettings : MonoBehaviour
{
    [SerializeField, Range(0, 10)]
    private float moveSpeed = 5f;

    [SerializeField]
    private string playerName = "Hero";

    [SerializeField]
    private Color playerColor = Color.cyan;

    [SerializeField]
    private bool isGodMode = false;

    public Vector3 scenePosition = Vector3.zero;
    
    private void OnDrawGizmos()
    {
        Gizmos.color = playerColor;

        // 绘制球体
        Gizmos.DrawSphere(scenePosition, 0.5f);

        // 绘制一条向上的线
        Gizmos.DrawLine(scenePosition, scenePosition + Vector3.up);

        // 绘制名字标签(Scene 中仅显示)
        UnityEditor.Handles.Label(scenePosition + Vector3.up * 0.6f, playerName);
    }
    
}

可以在 Game 中也看到,注意要把右上角的 Gizmos 给启用才能看到:

Gizmos Demo


3. 工具类

3.1 EditorUtility

EditorUtility 是 Unity 编辑器提供的 实用工具类,用来完成一些通用编辑器操作,比如弹窗、标记对象修改、保存等。

下面的 SetDirty 经常用到。

方法功能
EditorUtility.SetDirty(Object obj)标记对象已修改,确保编辑器保存更改
EditorUtility.DisplayDialog(title, message, ok, cancel)弹出对话框
EditorUtility.DisplayProgressBar(title, info, progress)显示进度条
EditorUtility.ClearProgressBar()关闭进度条
EditorUtility.FocusProjectWindow()聚焦 Project 窗口
EditorUtility.CopySerialized(Object src, Object dst)复制对象数据
using UnityEditor;
using UnityEngine;

public class EditorUtilityExample
{
    [MenuItem("Tools/Mark Dirty Example")]
    static void MarkDirtyExample()
    {
        GameObject go = Selection.activeGameObject;
        if (go != null)
        {
            Undo.RecordObject(go, "Modify GameObject");
            go.transform.position = Vector3.one;
            EditorUtility.SetDirty(go); // 标记修改
            Debug.Log("GameObject position changed and marked dirty.");
        }
    }
}

3.2 AssetDatabase

AssetDatabase 是 Unity 的资源管理接口,用于操作项目中的资源(创建、删除、导入、查找等)。

方法功能
AssetDatabase.CreateAsset(Object asset, string path)创建资源文件
AssetDatabase.DeleteAsset(string path)删除资源文件
AssetDatabase.MoveAsset(string oldPath, string newPath)移动/重命名资源
AssetDatabase.LoadAssetAtPath<T>(string path)加载资源
AssetDatabase.Refresh()刷新资源数据库
AssetDatabase.GetAssetPath(Object obj)获取资源路径
using UnityEditor;
using UnityEngine;

public class AssetDatabaseExample
{
    [MenuItem("Tools/Create Material")]
    static void CreateMaterial()
    {
        Material mat = new Material(Shader.Find("Standard"));
        AssetDatabase.CreateAsset(mat, "Assets/NewMaterial.mat");
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
        Debug.Log("Material created in Assets folder.");
    }
}

3.3 Selection

Selection 用来 获取或设置编辑器当前选中的对象,常用在编辑器工具中操作用户选择的对象。

属性 / 方法功能
Selection.activeObject当前选中的对象(单选)
Selection.activeGameObject当前选中的 GameObject(单选)
Selection.objects当前选中的所有对象(多选)
Selection.gameObjects当前选中的所有 GameObject(多选)
Selection.activeTransform当前选中对象的 Transform
Selection.activeInstanceID当前选中对象的实例 ID
using UnityEditor;
using UnityEngine;

public class SelectionExample
{
    [MenuItem("Tools/Print Selected Objects")]
    static void PrintSelectedObjects()
    {
        foreach (var obj in Selection.objects)
        {
            Debug.Log("Selected object: " + obj.name);
        }
    }
}

3.4 EditorWindow.OnGUI

每当 Unity 需要绘制界面(Inspector、EditorWindow、Game GUI等)时,就会自动调用 OnGUI(),让你用代码即时画出 UI 控件。

上下文触发方式用途
MonoBehaviour.OnGUI()游戏运行时调用绘制简单 HUD、调试 GUI
EditorWindow.OnGUI()编辑器窗口绘制自定义工具窗口
Editor.OnInspectorGUI()Inspector 面板自定义组件的 Inspector
EditorApplication.projectWindowItemOnGUIProject 窗口绘制绘制资源背景/图标
EditorApplication.projectWindowItemInstanceOnGUIProject 窗口绘制绘制资源背景/图标,可以处理子资产
EditorApplication.hierarchyWindowItemOnGUIHierarchy 绘制修改 GameObject 显示效果

利用 EditorWindow 的 GUI 创建一个窗口:

using UnityEditor;
using UnityEngine;

public class SimpleWindow : EditorWindow
{
    [MenuItem("Window/Simple GUI")]
    public static void ShowWindow()
    {
        GetWindow<SimpleWindow>("Simple GUI");
    }

    void OnGUI()
    {
        GUILayout.Label("Hello Unity IMGUI!", EditorStyles.boldLabel);
        if (GUILayout.Button("Click Me"))
        {
            Debug.Log("Button clicked!");
        }
    }
}

实现效果:

simple gui

3.5 projectWindowItemOnGUI

projectWindowItemOnGUI 是 Unity Editor 提供的一个 静态回调事件,用于在 Project 窗口的每个资源条目上绘制自定义 GUI。

注意这里要注销一下上次注册的,这是一个比较好的习惯。

using UnityEngine;
using UnityEditor;

[InitializeOnLoad]
public static class ProjectWindowDecorator
{
    static ProjectWindowDecorator()
    {
        EditorApplication.projectWindowItemOnGUI -= OnProjectWindowItemGUI;
        EditorApplication.projectWindowItemOnGUI += OnProjectWindowItemGUI;
    }

    private static void OnProjectWindowItemGUI(string guid, Rect rect)
    {
        string path = AssetDatabase.GUIDToAssetPath(guid);

        // 只对 png 文件生效
        if (path.EndsWith(".png"))
        {
            // 在资源右上角画一个红色小圆点
            Rect dotRect = new Rect(rect.xMax - 10, rect.y + 2, 8, 8);
            EditorGUI.DrawRect(dotRect, Color.red);
        }
    }
}

效果:

Add Red Spot In Project Window

还可以实现给每个文件上加一个按钮的功能:

Every File Add Test Button In Project Window

3.6 EditorPrefs

EditorPrefs 是 Unity 编辑器专用的一个类,用来在本地保存编辑器级别的配置数据(类似于“编辑器偏好设置”)。

它的功能类似于 PlayerPrefs(游戏运行时存储),但用于编辑器工具或插件的设置。

它不会随着 “项目移动” 而迁移,也就是仅仅是你本机电脑保存的。

通常不用这个,如果有需要的话,应该是写成一个可以保存的 JSON 之类的,跟着项目可以同步。

using UnityEditor;
using UnityEngine;

public class EditorPrefsExample
{
    [MenuItem("Tools/EditorPrefs 示例")]
    public static void Example()
    {
        // 保存数据
        EditorPrefs.SetString("MyTool_LastFilePath", "D:/MyProject/Config.txt");
        EditorPrefs.SetInt("MyTool_UseDarkTheme", 1);
        EditorPrefs.SetBool("MyTool_ShowTips", true);
        EditorPrefs.SetFloat("MyTool_Volume", 0.8f);

        // 读取数据
        string path = EditorPrefs.GetString("MyTool_LastFilePath", "未设置");
        bool showTips = EditorPrefs.GetBool("MyTool_ShowTips", false);
        float volume = EditorPrefs.GetFloat("MyTool_Volume", 1.0f);

        Debug.Log($"路径: {path}, 是否显示提示: {showTips}, 音量: {volume}");

        // 删除数据
        EditorPrefs.DeleteKey("MyTool_LastFilePath");

        // 检查是否存在
        if (EditorPrefs.HasKey("MyTool_UseDarkTheme"))
            Debug.Log("暗色主题设置存在");
    }
}

3.7 MonoBehaviour.OnGUI

严格来说这个也不算 Editor 开发,但是这个很适合制作一些测试用只在 Editor 运行游戏时使用的按钮,比如下面的代码:

using UnityEngine;

public class TestMonoOnGUI : MonoBehaviour
{
#if UNITY_EDITOR
    void OnGUI()
    {
        if (GUI.Button(new Rect(10, 10, 100, 30), "Click Me"))
        {
            Debug.Log("按钮被点击了!");
        }
    }
#endif
}

运行后能出现一个按钮,并且这个按钮因为宏限制在了只能用于编辑器运行的情况,不会影响正式游戏。

MonoBehaviour OnGUI

4 MenuItem

4.1 顶部菜单栏

MenuItem 是 Unity 提供的一个 编辑器注解,用于在菜单栏或右键菜单中添加自定义功能。

如果路径是不以 Assets/GameObject/ 开头,Unity 会把它放在顶部菜单栏。

using UnityEditor;
using UnityEngine;

public class MenuExample
{
    [MenuItem("Tools/Print Message")]
    public static void PrintMessage()
    {
        Debug.Log("Hello from Tools menu!");
    }

    [MenuItem("Tools/Print Message", true)] // 验证函数(用于启用/禁用菜单项)
    public static bool ValidatePrintMessage()
    {
        return Application.isPlaying == false; // 仅在非运行状态可用
    }
}

在上方出现:

Top Menu Demo

说明:

  • [MenuItem(“Tools/Print Message”)] 会在 Unity 菜单栏的 Tools 下添加一个 “Print Message” 菜单项。
  • [MenuItem("路径", true)] 带有 true 参数的表示同名方法是验证函数,用来控制菜单是否可用,如果返回 false 那么会是灰色 不可点击状态
  • 方法必须是 static。

第三项是 优先级 ,控制菜单在同级菜单中的排序,数字越小越靠上。

[MenuItem("Tools/Print Debug", false, 10)]

4.2 右键菜单栏

Assets/ 开头可以在 Project 窗口中右键出现,而 GameObject/ 开头可以在 Hierarchy 里面出现。

这里实现下面一个功能:

  • Project 窗口右键文件夹时显示菜单,多选时也能生效,并对文件夹名字加上 test- 前缀(如果没有的话)
using UnityEngine;
using UnityEditor;

public class AddTestPrefix
{
    // 右键菜单显示在 Project 窗口文件夹上
    [MenuItem("Assets/Add Test Prefix", true)]
    private static bool ValidateAddTestPrefix()
    {
        // 只允许选中文件夹
        foreach (var obj in Selection.objects)
        {
            string path = AssetDatabase.GetAssetPath(obj);
            if (!AssetDatabase.IsValidFolder(path))
                return false;
        }
        return Selection.objects.Length > 0;
    }

    [MenuItem("Assets/Add Test Prefix")]
    private static void AddPrefix()
    {
        foreach (var obj in Selection.objects)
        {
            string path = AssetDatabase.GetAssetPath(obj);
            if (!AssetDatabase.IsValidFolder(path))
                continue;

            string folderName = System.IO.Path.GetFileName(path);
            if (!folderName.StartsWith("test"))
            {
                string newFolderName = "test-" + folderName;
                string parentPath = System.IO.Path.GetDirectoryName(path).Replace("\\", "/");
                string newPath = parentPath + "/" + newFolderName;

                AssetDatabase.RenameAsset(path, newFolderName);
                Debug.Log($"Renamed folder: {folderName} -> {newFolderName}");
            }
        }

        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
    }
}

演示:

Right Menu Demo


5. 风格美化

5.1 True/False 变开关按钮

如果我们期望实现一个开关按钮,而不是勾选,怎么做呢?

Button Styles

包括水平布局,更改按钮的背景颜色,

代码:

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(PlayerSettings))]
public class PlayerSettingsEditor : Editor
{
    private SerializedProperty moveSpeed;
    private SerializedProperty playerName;
    private SerializedProperty playerColor;
    private SerializedProperty isGodMode;

    private void OnEnable()
    {
        moveSpeed = serializedObject.FindProperty("moveSpeed");
        playerName = serializedObject.FindProperty("playerName");
        playerColor = serializedObject.FindProperty("playerColor");
        isGodMode = serializedObject.FindProperty("isGodMode");
    }

    public override void OnInspectorGUI()
    {
        serializedObject.Update();

        // 基础属性
        EditorGUILayout.PropertyField(playerName);
        EditorGUILayout.PropertyField(moveSpeed);
        EditorGUILayout.PropertyField(playerColor);

        EditorGUILayout.Space();

        // ----------------- 按钮靠右 -----------------
        EditorGUILayout.BeginHorizontal();

        // 左侧标签
        EditorGUILayout.LabelField("God Mode");

        // 中间占位,使按钮靠右
        GUILayout.FlexibleSpace();

        // 右侧按钮
        GUI.backgroundColor = isGodMode.boolValue ? Color.green : Color.red;

        if (GUILayout.Button(isGodMode.boolValue ? "ON" : "OFF", GUILayout.Width(50)))
        {
            isGodMode.boolValue = !isGodMode.boolValue;
            EditorUtility.SetDirty(target);
        }

        EditorGUILayout.EndHorizontal();

        serializedObject.ApplyModifiedProperties();
    }
}

5.2 全局样式替换

也许你会想说,那么能不能把所有地方的 bool 都替换了?

Editor 确实给了我们这种接口:

代码:

using UnityEditor;
using UnityEngine;

// ✅ 作用于所有 bool 字段
[CustomPropertyDrawer(typeof(bool))]
public class GlobalBoolButtonDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        // 只处理 bool
        if (property.propertyType != SerializedPropertyType.Boolean)
        {
            EditorGUI.PropertyField(position, property, label, true);
            return;
        }

        // ---------------- 绘制布局 ----------------
        var labelRect = new Rect(position.x, position.y, EditorGUIUtility.labelWidth, position.height);
        var buttonRect = new Rect(position.x + EditorGUIUtility.labelWidth + 5, position.y, 50, position.height);

        // 标签
        EditorGUI.LabelField(labelRect, label);

        // 按钮颜色
        var oldColor = GUI.backgroundColor;
        GUI.backgroundColor = property.boolValue ? Color.green : Color.red;

        // 按钮样式
        if (GUI.Button(buttonRect, property.boolValue ? "ON" : "OFF"))
        {
            property.boolValue = !property.boolValue;
        }

        GUI.backgroundColor = oldColor;
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
    }
}

回到界面中,可以看到,连 Event System 这种地方,也直接改了:

Global bool GUI

5.3 Hierarchy 美化

利用 EditorApplication.hierarchyWindowItemOnGUI 可以实现绘制 Hierarchy 的时候进行一些自定义操作。

例如实现下面的变色功能:

Hierarchy Color Set

配合持久化实现改变色彩功能。

代码:

using UnityEditor;
using UnityEngine;

[InitializeOnLoad]
public class HierarchyCustomDrawer
{
    private const string PrefixName = ":";

    // EditorPrefs Key
    private const string BackgroundColorKey = "HierarchyBackgroundColor";
    private const string TextColorKey = "HierarchyTextColor";

    // 默认颜色
    public static Color BackgroundColor = Color.magenta;
    public static Color TextColor = Color.white;

    static HierarchyCustomDrawer()
    {
        LoadColors();
        EditorApplication.hierarchyWindowItemOnGUI -= OnHierarchyGUI;
        EditorApplication.hierarchyWindowItemOnGUI += OnHierarchyGUI;
    }

    private static void LoadColors()
    {
        // 读取背景色
        string bgStr = EditorPrefs.GetString(BackgroundColorKey, "FF00FF"); // 默认紫色
        if (!ColorUtility.TryParseHtmlString("#" + bgStr, out BackgroundColor))
        {
            BackgroundColor = Color.magenta;
        }

        // 读取文字色
        string textStr = EditorPrefs.GetString(TextColorKey, "FFFFFF"); // 默认白色
        if (!ColorUtility.TryParseHtmlString("#" + textStr, out TextColor))
        {
            TextColor = Color.white;
        }
    }

    static void OnHierarchyGUI(int instanceID, Rect selectionRect)
    {
        GameObject obj = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
        if (obj == null) return;

        if (obj.name.StartsWith(PrefixName))
        {
            // 绘制背景
            Rect backgroundRect = new Rect(0, selectionRect.y, Screen.width, selectionRect.height);
            EditorGUI.DrawRect(backgroundRect, BackgroundColor);

            // 绘制文字
            string name = obj.name.Substring(PrefixName.Length).Trim();
            GUIStyle style = new GUIStyle(EditorStyles.label)
            {
                alignment = TextAnchor.MiddleCenter,
                fontStyle = FontStyle.Bold,
                wordWrap = true,
                normal = { textColor = TextColor }
            };
            EditorGUI.LabelField(backgroundRect, name, style);
        }
    }

    // 菜单打开窗口
    [MenuItem("Tools/Hierarchy 高亮设置")]
    public static void OpenColorSettings()
    {
        HierarchyColorWindow.ShowWindow();
    }
}

public class HierarchyColorWindow : EditorWindow
{
    private Color backgroundColor;
    private Color textColor;

    public static void ShowWindow()
    {
        var window = GetWindow<HierarchyColorWindow>("Hierarchy 高亮设置");
        window.minSize = new Vector2(250, 100);
        window.LoadCurrentColors();
    }

    private void LoadCurrentColors()
    {
        backgroundColor = HierarchyCustomDrawer.BackgroundColor;
        textColor = HierarchyCustomDrawer.TextColor;
    }

    private void OnGUI()
    {
        GUILayout.Label("自定义 Hierarchy 高亮颜色", EditorStyles.boldLabel);

        backgroundColor = EditorGUILayout.ColorField("背景色", backgroundColor);
        textColor = EditorGUILayout.ColorField("文字色", textColor);

        if (GUILayout.Button("应用"))
        {
            // 保存到 EditorPrefs(字符串)
            EditorPrefs.SetString("HierarchyBackgroundColor", ColorUtility.ToHtmlStringRGBA(backgroundColor));
            EditorPrefs.SetString("HierarchyTextColor", ColorUtility.ToHtmlStringRGBA(textColor));

            // 更新静态变量
            HierarchyCustomDrawer.BackgroundColor = backgroundColor;
            HierarchyCustomDrawer.TextColor = textColor;

            // 刷新 Hierarchy
            EditorApplication.RepaintHierarchyWindow();
        }
    }
}

5.4 设置组件图标

使用 EditorGUIUtility.SetIconForObject 来给一个资产设置图标,但是这个是对 C# 是不行的,重新打开就没了。

正确的方法是用 MonoImporter.SetIconmonoImporter.SaveAndReimport 来持久化,这样同时组件图标也改了。

SetIcon Project Window

组件中样式:

SetIcon Inspector Window

代码:

using UnityEngine;
using UnityEditor;

public class AssetIconSelectorWindow : EditorWindow
{
    private Texture2D selectedIcon;

    [MenuItem("Tools/Set Asset Icon Window")]
    public static void ShowWindow()
    {
        GetWindow<AssetIconSelectorWindow>("Set Asset Icon");
    }

    private void OnGUI()
    {
        GUILayout.Label("批量设置资产图标", EditorStyles.boldLabel);

        // 获取当前选中的所有资产
        Object[] selectedAssets = Selection.objects;

        // 显示选中的资产列表
        if (selectedAssets.Length == 0)
        {
            EditorGUILayout.LabelField("未选择资产");
        }
        else
        {
            EditorGUILayout.LabelField($"选中 {selectedAssets.Length} 个资产:");
            foreach (Object asset in selectedAssets)
            {
                EditorGUILayout.LabelField(" - " + asset.name);
            }
        }

        // 选择图标
        selectedIcon = (Texture2D)EditorGUILayout.ObjectField("图标", selectedIcon, typeof(Texture2D), false);

        GUILayout.Space(10);

        if (GUILayout.Button("应用图标到选中资产"))
        {
            ApplyIcon(selectedAssets);
        }
    }

    private void ApplyIcon(Object[] assets)
    {
        if (assets == null || assets.Length == 0)
        {
            EditorUtility.DisplayDialog("错误", "请先在 Project 面板选择一个或多个资产", "确定");
            return;
        }
        if (selectedIcon == null)
        {
            EditorUtility.DisplayDialog("错误", "请选择一个图标", "确定");
            return;
        }

        int appliedCount = 0;

        foreach (Object asset in assets)
        {
            string path = AssetDatabase.GetAssetPath(asset);
            if (string.IsNullOrEmpty(path))
                continue;

            // 持久化设置
            var importer = AssetImporter.GetAtPath(path) as MonoImporter;
            if (importer != null)
            {
                importer.SetIcon(selectedIcon);
                importer.SaveAndReimport();
                appliedCount++;
            }
        }

        EditorUtility.DisplayDialog("完成", $"图标已应用到 {appliedCount} 个资产", "确定");
    }
}

6. 自定义类型

一个极其常见的需求:导入一个自定义的文件,例如 “.vrm” 等。这些是如何处理的呢?

这里以 “.hhh” 为例,它是一个纯文本文件,只有 2 行,第一行表示 name,第二行表示 description 。

6.1 ScriptableObject

导入的文件实际上上面的套了一个 ScriptableObject 才能显示,这里创建 2 个 ScriptableObject,将分别作为 .hhh 的主对象,和非主对象。

using UnityEngine;

[CreateAssetMenu(fileName = "NewHHHFile", menuName = "Custom/HHH File")]
public class HHHFile : ScriptableObject
{
    public string nameLine;
    public string descriptionLine;
}

public class HHHName : ScriptableObject
{
    public string nameLine;
}

6.2 AssetImporter 解析

然后在文件导入时,通过写 ScriptedImporter 可以实现解析过程。

创建了 HHHFile 实例并读取 name 和 description,然后将这个实例设置为主对象,同时设置了图标。

同时创建了一个 HHHName 实例,并设置为非主对象。

using UnityEngine;
using UnityEditor;
using UnityEditor.AssetImporters;
using System.IO;

[ScriptedImporter(1, "hhh")]
public class HHHImporter : ScriptedImporter
{
    public override void OnImportAsset(AssetImportContext ctx)
    {
        // 读取文件内容
        string[] lines = File.ReadAllLines(ctx.assetPath);

        // 1️⃣ 主对象 HHHFile
        HHHFile mainAsset = ScriptableObject.CreateInstance<HHHFile>();
        mainAsset.nameLine = lines.Length > 0 ? lines[0] : "";
        mainAsset.descriptionLine = lines.Length > 1 ? lines[1] : "";

        ctx.AddObjectToAsset("HHHFile", mainAsset);
        ctx.SetMainObject(mainAsset);

        // 设置主对象图标
        Texture2D mainIcon = AssetDatabase.LoadAssetAtPath<Texture2D>("Assets/Icons/hhh_icon.png");
        if (mainIcon != null)
        {
            EditorGUIUtility.SetIconForObject(mainAsset, mainIcon);
        }

        // 2️⃣ 非主对象 HHHName
        HHHName subAsset = ScriptableObject.CreateInstance<HHHName>();
        subAsset.nameLine = mainAsset.nameLine;

        ctx.AddObjectToAsset(subAsset.nameLine, subAsset);

        // 设置非主对象图标
        Texture2D subIcon = AssetDatabase.LoadAssetAtPath<Texture2D>("Assets/Icons/wrench.png");
        if (subIcon != null)
        {
            EditorGUIUtility.SetIconForObject(subAsset, subIcon);
        }
    }
}

导入后效果:

HHHFile Project File

能够在右侧查看 Inspector:

HHFile Inspector CustomEditor

这里添加了一个 Inspector CustomEditor,和前面介绍的美化方式是一个意思,作用只是告诉你这个 HHHFile 一样是可以自定义 Inspector 的。

using UnityEditor;

[CustomEditor(typeof(HHHFile))]
public class HHHFileInspector : Editor
{
    public override void OnInspectorGUI()
    {
        HHHFile hhh = (HHHFile)target;

        EditorGUILayout.LabelField("Name", hhh.nameLine);
        
        EditorGUILayout.Space();
        
        EditorGUILayout.LabelField("Description", hhh.descriptionLine);
    }
}

6.3 AssetPostprocessor

任何资产的变动,即导入、删除、移动,都可以触发 OnPostprocessAllAssets。通过对这个函数的编写,能够实现一些后处理,比如自动设置导入的图片为 sprite 等。

using UnityEngine;
using UnityEditor;
using System.IO;

public class HHHFilePostprocessor : AssetPostprocessor
{
    static void OnPostprocessAllAssets(
        string[] importedAssets,
        string[] deletedAssets,
        string[] movedAssets,
        string[] movedFromAssetPaths)
    {
        foreach (string path in importedAssets)
        {
            // 只处理 .hhh 文件
            if (Path.GetExtension(path) == ".hhh")
            {
                // 读取文件内容
                string[] lines = File.ReadAllLines(path);
                string nameLine = lines.Length > 0 ? lines[0] : "";
                string descLine = lines.Length > 1 ? lines[1] : "";

                // 打印日志
                Debug.Log($"[HHHFilePostprocessor] 导入: {path}\nName: {nameLine}\nDescription: {descLine}");
            }
        }
    }
}

导入的时候就会打印这行信息:

AssetPostprocessor log

7. 随机颜色

Just For Fun

给文件一个随机的颜色,根据文件名 Hash 唯一确定。

using System;
using UnityEditor;
using UnityEngine;

[InitializeOnLoad]
public static class ColorProjectWindowDecorator
{
    static ColorProjectWindowDecorator()
    {
        EditorApplication.projectWindowItemOnGUI -= OnProjectWindowItemGUI;
        EditorApplication.projectWindowItemOnGUI += OnProjectWindowItemGUI;
    }

    private static void OnProjectWindowItemGUI(string guid, Rect rect)
    {
        string path = AssetDatabase.GUIDToAssetPath(guid);

        // 只针对 .cs 文件
        if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
            return;

        // 提取文件名
        string fileName = System.IO.Path.GetFileNameWithoutExtension(path);

        // 计算一个确定性的哈希值
        int hash = fileName.GetHashCode();

        // 将哈希映射到颜色(HSV -> RGB)
        float hue = Mathf.Abs(hash % 360) / 360f; // 色相
        float saturation = 0.6f + (Mathf.Abs(hash) % 40) / 100f; // 0.6~1.0
        float value = 0.8f; // 亮度固定
        Color color = Color.HSVToRGB(hue, saturation, value);

        float size = Math.Min(rect.width, rect.height) * 0.7f;
        Rect dotRect = new Rect(
            rect.x + rect.width / 2f - size / 2f,
            rect.y + rect.height / 2f - size / 2f,
            size,
            size
        );
        
        DrawCircle(dotRect, color);
    }

    private static void DrawCircle(Rect rect, Color color)
    {
        // 用 Handles 绘制圆形(比方形更柔和)
        Handles.BeginGUI();
        Color oldColor = Handles.color;
        Handles.color = color;
        Handles.DrawSolidDisc(rect.center, Vector3.forward, rect.width / 2);
        Handles.color = oldColor;
        Handles.EndGUI();
    }
}

color folder