Unity自定义Attribute实现下拉菜单场景选择

更新记录:2023.2.20 修改了一些表述,并改进了实现方法。
如果希望直接用当前最新的代码,可以直接翻到"2023.2.20更新>解决问题"处。

前几天看麦扣视频里提到一个在Inspector可以把场景选择由输字符串改变为下拉菜单的功能,挺实用的,然后就想自己写一下。

因为之前对自定义编辑器这方面内容经验不是很多,而自定义attribute更是没有涉及过,所以还是走了不少弯路的……其实最主要的还是反射啊attribute啊这些语法没有深入了解过……

需求

首先明确一下需要做到什么。

从视频里可以看到,麦扣导入了一个dll,然后就可以用一个[SceneName]的attribute来修饰string,从而让其在inspector中以下拉菜单的形式进行选择。
在这里插入图片描述
在这里插入图片描述
这样的功能主要包括两个部分的内容:自定义attribute、获取场景名称。

实现

我在实现的时候整体分为四个部分:首先是怎么自定义出attribute;然后是怎么获取所有场景的名称;最后是怎么让attribute把值反馈回字段,最后的最后是修复一些bug(实际上是完善功能)。

定义attribute

其实定义attribute还是比较简单的,而且网上也有很多资料,这里就贴一下代码吧。

代码包括两部分:定义部分和编辑器扩展部分。

using System;
using UnityEngine;

[AttributeUsage(AttributeTargets.Field)]
public class SceneName : PropertyAttribute
{
    
     }
//就这些,标签就能用了。虽然现在还不起任何作用。

然后,为了在编辑器中得到下拉菜单的效果,编写编辑器扩展部分。同时为了方便之后存储和读取数据,在SceneName类中定义selected和name两个变量,分别表示下拉菜单中选中的场景序号和场景名称。

//SceneName类
using System;
using UnityEngine;

[AttributeUsage(AttributeTargets.Field)]
public class SceneName : PropertyAttribute
{
    
    
    int _selected;
    string _name;
    public int selected {
    
     get {
    
     return _selected; } set {
    
     _selected = value; } }
    public string name {
    
     get {
    
     return _name; } set {
    
     _name = value; } }
}

//SceneNameEditor类
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(SceneName))]
public class SceneNameEditor : PropertyDrawer
{
    
    
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
    
    
        SceneName sceneName = attribute as SceneName;
        sceneName.selected = EditorGUI.Popup(position, label.text, sceneName.selected, new string[] {
    
     "a", "b" });
        sceneName.name = scenes[sceneName.selected].text;
    }
}

注意:编辑器扩展部分代码需要放在Editor文件夹内。

上边两个脚本就可以实现在inspector里以下拉菜单形式选择字符串了。

完成这部分之后,就可以开始考虑如何获取场景名称列表了。

获取场景

EditorBuildSettings.scenes这一变量的值正是build settings中的场景列表。所以用它来获取场景名称的列表是非常合适的。

但是有一个问题,它存储的是EditorBuildingSettingsScene类型的数据,而通过它只能获得path,也就是场景路径。所以现在的问题就是,将场景路径转换为场景名称。

我们只需要从路径中裁剪出场景名即可。

由于路径都是以“/”为分隔符,以“.unity”结尾的,所以可以方便地处理出场景名称。

//SceneNameEditor类
    GUIContent[] GetSceneNames()
    {
    
    
        GUIContent[] g = new GUIContent[EditorBuildSettings.scenes.Length];
        for(int i=0;i<g.Length;++i)
        {
    
    
            string[] splitResult = EditorBuildSettings.scenes[i].path.Split('/');
            string nameWithSuffix = splitResult[splitResult.Length - 1];
            g[i] = new GUIContent(nameWithSuffix.Substring(0, nameWithSuffix.Length - ".unity".Length));
        }
        return g;
    }

这里我返回了GUIContent类型的数据,因为Popup函数需要的参数为GUIContent[]类型(当然也有用string[]的,不过那是另一个重载,区别不大)。

下面是更改后的SceneNameEditor类:

//SceneNameEditor类
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(SceneName))]
public class SceneNameEditor : PropertyDrawer
{
    
    
    GUIContent[] scenes;
    GUIContent[] GetSceneNames()
    {
    
    
        GUIContent[] g = new GUIContent[EditorBuildSettings.scenes.Length];
        for(int i=0;i<g.Length;++i)
        {
    
    
            string[] splitResult = EditorBuildSettings.scenes[i].path.Split('/');
            string nameWithSuffix = splitResult[splitResult.Length - 1];
            g[i] = new GUIContent(nameWithSuffix.Substring(0, nameWithSuffix.Length - ".unity".Length));
        }
        return g;
    }
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
    
    
        SceneName sceneName = attribute as SceneName;
        scenes = GetSceneNames();
        sceneName.selected = EditorGUI.Popup(position, label, sceneName.selected, scenes);
        sceneName.name = scenes[sceneName.selected].text;
    }
}

现在就可以在inspector中得到一个可以选择场景名称的下拉菜单了。

反馈attribute存储的值

这一步我卡了最长时间……看了不少反射、attribute之类的内容,然后发现好像不太好弄……

我的想法是,总要有个可以和被修饰字段之间产生联系的变量吧,不可能没什么联系就说attribute修饰了某个字段吧。

然后就找到了attribute和fieldInfo两个变量。

它俩确实记录了和哪个对象、哪个变量产生了联系,但要获取具体是哪个对象,感觉不好办。

直到最后我才意识到OnGUI还有个property参数…… 它记录了是哪个对象用到了这个attribute。然后就比较简单了,用fieldInfo的SetValue方法即可设置数值。

//SceneNameEditor类
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(SceneName))]
public class SceneNameEditor : PropertyDrawer
{
    
    
    static GUIContent[] scenes;
    GUIContent[] GetSceneNames()
    {
    
    
        GUIContent[] g = new GUIContent[EditorBuildSettings.scenes.Length];
        for(int i=0;i<g.Length;++i)
        {
    
    
            string[] splitResult = EditorBuildSettings.scenes[i].path.Split('/');
            string nameWithSuffix = splitResult[splitResult.Length - 1];
            g[i] = new GUIContent(nameWithSuffix.Substring(0, nameWithSuffix.Length - ".unity".Length));
        }
        return g;
    }
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
    
    
        SceneName sceneName = attribute as SceneName;
        scenes = GetSceneNames();
        sceneName.selected = EditorGUI.Popup(position, label, sceneName.selected, scenes);
        sceneName.name = scenes[sceneName.selected].text;
        fieldInfo.SetValue(property.serializedObject.targetObject, sceneName.name);
    }
}

这样就可以把值反馈回用attribute修饰的字段了。

但是……

把脚本挂到物体上然后看了看效果后,突然发现这样还不行:attribute会被删除和重新创建。这样一来,每当attribute被重新创建之后,就会导致被标记的内容恢复到默认值。

于是我考虑到在OnGUI里首先用字段已有的值来更新attribute内的存储值。

//SceneNameEditor类
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(SceneName))]
public class SceneNameEditor : PropertyDrawer
{
    
    
    static GUIContent[] scenes;
    GUIContent[] GetSceneNames()
    {
    
    
        GUIContent[] g = new GUIContent[EditorBuildSettings.scenes.Length];
        for(int i=0;i<g.Length;++i)
        {
    
    
            string[] splitResult = EditorBuildSettings.scenes[i].path.Split('/');
            string nameWithSuffix = splitResult[splitResult.Length - 1];
            g[i] = new GUIContent(nameWithSuffix.Substring(0, nameWithSuffix.Length - ".unity".Length));
        }
        return g;
    }
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
    
    
        SceneName sceneName = attribute as SceneName;
        scenes = GetSceneNames();
        sceneName.selected = 0;
        sceneName.name = scenes[0].text;
        
        string cntString = (string)fieldInfo.GetValue(property.serializedObject.targetObject);
        for(int i=0;i<scenes.Length;++i)
        {
    
    
            if(scenes[i].text.Equals(cntString))
            {
    
    
                sceneName.selected = i;
                sceneName.name = cntString;
                break;
            }
        }
        //
        sceneName.selected = EditorGUI.Popup(position, label, sceneName.selected, scenes);
        sceneName.name = scenes[sceneName.selected].text;
        fieldInfo.SetValue(property.serializedObject.targetObject, sceneName.name);
    }
}

上边用“/”框起来的就是做了改动的内容。效果还可以,但是当然,时间开销会增加不少……(目前自己做的东西,还是能跑为第一追求)

结束了吗?

我当时也以为结束了。

但是在测试的时候,发现如果在inspector中修改字段值之后退出工程再重进,并不会保存。

举个例子,假如上次打开编辑器,我把一个字段从scene1改成了scene2,那么在重新打开的时候,它可能还是scene1。

多次测试+查询资料,发现是这样一回事:自定义编辑器可能不会使场景变dirty,也就是不会让场景知道自己已经做了修改但还没保存。也就是说,需要让编辑器知道自己已经dirty了,才会使修改可以被保存。

因此目前来说,最终版代码如下:

//SceneNameEditor类
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(SceneName))]
public class SceneNameEditor : PropertyDrawer
{
    
    
    static GUIContent[] scenes;
    GUIContent[] GetSceneNames()
    {
    
    
        GUIContent[] g = new GUIContent[EditorBuildSettings.scenes.Length];
        for(int i=0;i<g.Length;++i)
        {
    
    
            string[] splitResult = EditorBuildSettings.scenes[i].path.Split('/');
            string nameWithSuffix = splitResult[splitResult.Length - 1];
            g[i] = new GUIContent(nameWithSuffix.Substring(0, nameWithSuffix.Length - ".unity".Length));
        }
        return g;
    }
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
    
    
        SceneName sceneName = attribute as SceneName;
        scenes = GetSceneNames();
        string cntString = (string)fieldInfo.GetValue(property.serializedObject.targetObject);
        sceneName.selected = 0;
        sceneName.name = scenes[0].text;
        for(int i=0;i<scenes.Length;++i)
        {
    
    
            if(scenes[i].text.Equals(cntString))
            {
    
    
                sceneName.selected = i;
                sceneName.name = cntString;
                break;
            }
        }
        sceneName.selected = EditorGUI.Popup(position, label, sceneName.selected, scenes);
        sceneName.name = scenes[sceneName.selected].text;
        fieldInfo.SetValue(property.serializedObject.targetObject, sceneName.name);
        ///
        if(GUI.changed)
        {
    
    
            EditorUtility.SetDirty(property.serializedObject.targetObject);
        }
        //
    }
}

到此为止,我没有发现有其他什么bug。所以上边的就是目前的最终版代码(SceneName类开始已经写好,后来没有修改)。

在使用的时候,只需要为string类型的字段标上一个[SceneName]即可通过下拉菜单选择场景名。

如果有朋友发现最终版的代码仍然有漏洞,欢迎批评指正。


2023.2.20更新

背景

上边的写法一直用了挺长一段时间,也没有遇到什么问题。

直到最近。因为最近在整理之前的代码,所以对一些部分的实现有了新的想法。

另外还做了另一个效果:把继承自一个类的所有子类列在下拉菜单里供用户选择,就是在实现这个功能时发现了一些问题。

关于SceneName类

这一部分内容是关于SceneName相关的实现的,它其实并不是什么问题,但是现在在我看来似乎有些冗余,所以就想要改一改。

之前的写法中,SceneName类的实现如下:

//SceneName类
[AttributeUsage(AttributeTargets.Field)]
public class SceneName : PropertyAttribute
{
    
    
    int _selected;
    string _name;
    public int selected {
    
     get {
    
     return _selected; } set {
    
     _selected = value; } }
    public string name {
    
     get {
    
     return _name; } set {
    
     _name = value; } }
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
    
    
    SceneName sceneName = attribute as SceneName;
    scenes = GetSceneNames();
    string cntString = (string)fieldInfo.GetValue(property.serializedObject.targetObject);
    sceneName.selected = 0;
    sceneName.name = scenes[0].text;
    for(int i=0;i<scenes.Length;++i)
    {
    
    
        if(scenes[i].text.Equals(cntString))
        {
    
    
            sceneName.selected = i;
            sceneName.name = cntString;
            break;
        }
    }
    sceneName.selected = EditorGUI.Popup(position, label, sceneName.selected, scenes);
    sceneName.name = scenes[sceneName.selected].text;
    fieldInfo.SetValue(property.serializedObject.targetObject, sceneName.name);
    ...
}

首先,SceneName的两个属性是什么?一个是当前选中的场景名对应的编号,另一个是当前选中的场景名。

那么被修饰的字符串是什么?它本身存储的就是当前选中的场景名。

而编号呢,由于可能出现BuildSetting中场景列表变化的情况导致编号与名称对应错误,所以我们在OnGUI函数里一直都是重新计算编号。

所以实际上,SceneName的两个属性都没什么实际意义,因此直接全部删除即可。相应的,在SceneNameEditor中的一些代码需要做出调整。

这里不贴SceneName的代码了,只贴Editor的代码:

namespace CustomTool.EditorTools
{
    
    
    [CustomPropertyDrawer(typeof(SceneName))]
    public class SceneNameEditor : PropertyDrawer
    {
    
    
        static GUIContent[] scenes;
        GUIContent[] GetSceneNames()
        {
    
    
            GUIContent[] g = new GUIContent[EditorBuildSettings.scenes.Length];
            for (int i = 0; i < g.Length; ++i)
            {
    
    
                string[] splitResult = EditorBuildSettings.scenes[i].path.Split('/');
                string nameWithSuffix = splitResult[splitResult.Length - 1];
                g[i] = new GUIContent(nameWithSuffix.Substring(0, nameWithSuffix.Length - ".unity".Length));
            }
            return g;
        }
        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
    
    
            scenes = GetSceneNames();
            string cntString = (string)fieldInfo.GetValue(property.serializedObject.targetObject);
            int selected = 0;
            string targetScene;
            for (int i = 1; i < scenes.Length; ++i)
            {
    
    
                if (scenes[i].text.Equals(cntString))
                {
    
    
                    selected = i;
                    break;
                }
            }
            selected = EditorGUI.Popup(position, label, selected, scenes);
            targetScene = scenes[selected].text;
            fieldInfo.SetValue(property.serializedObject.targetObject, targetScene);
            if (GUI.changed)
            {
    
    
                EditorUtility.SetDirty(property.serializedObject.targetObject);
            }
        }
    }
}

问题

性能

首先我们看之前的代码:
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
    
    
        ...
        scenes = GetSceneNames();
        ...
    }

每当执行OnGUI,GetSceneNames都会被调用一次。如果GetSceneNames是一个比较耗时的操作,那么这显然会带来比较严重的时间消耗。

但幸运的是,GetSceneName的消耗并不算大,所以即使它被频繁调用,仍然对性能没有很大的影响。

但不幸的是,这次要实现的功能开销很大,在编辑器中会导致明显的卡顿。

报错

性能暂且不谈,毕竟退一步来说,至少能跑,再退一步讲,编辑器代码不会影响运行时的性能。

但是问题在于,上边的写法会出现一些问题。

我们创建一个类,它内部包含一个被[SceneName]修饰的string s,如下所示:

    [Serializable]
    public class C
    {
    
    
        [SceneName] public string s;
    }
    public C sceneNameInClass;

我们希望得到的效果应该是这样的:
期望效果
名为sceneNameInClass的变量里包含一个SceneName字符串,并且这个字符串能够如之前一样以下拉菜单的形式展示。

但实际上得到的效果是这样的:

实际效果

解决问题

报错

要解决问题,首先需要了解问题出在什么地方。

先看下错误提示:

在这里插入图片描述

再来到错误位置:

string cntString = (string)fieldInfo.GetValue(property.serializedObject.targetObject);

看上去是反射部分出了问题,来看一下GetValue函数的定义:
GetValue
简单来说,GetValue函数的作用是获取obj的对应field。那么传入的obj是什么呢?

在这里插入图片描述

可以看出,obj是整个MultiSelector脚本(这个脚本最开始是测试另一个自定义attribute的,后来就没改名了)。

那么这就是问题所在了,我们无法从MultiSelector中获得相应的字段,因为它根本不存在。

这里我又是卡了很久,但是一直没找到办法获得property的值。直到我突然发现property还提供了这样一系列属性:xxxValue。更令我意外的是,这些属性居然同时可读可写。

既然如此,那也就不需要费尽周折用反射来获取字段的值了,直接用property提供的这些属性来读写就行了。

那么代码就变成了如下的样子,仍然用"/"来框出修改的部分:

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
    
    
    scenes = GetSceneNames();
    ///
    string cntString = property.stringValue;
    ///
    int selected = 0;
    string targetScene;
    for (int i = 1; i < scenes.Length; ++i)
    {
    
    
        if (scenes[i].text.Equals(cntString))
        {
    
    
            selected = i;
            break;
        }
    }
    selected = EditorGUI.Popup(position, label, selected, scenes);
    targetScene = scenes[selected].text;
    ///
    property.stringValue = targetScene;
    ///
    if (GUI.changed)
    {
    
    
        EditorUtility.SetDirty(property.serializedObject.targetObject);
    }
}

目前尚未发现这样有什么问题,因此它目前是SceneName的最终版代码。

在这里插入图片描述

优化性能

再来看性能的问题。

但首先,可能比较令人失望的是,对于SceneName来说,我暂时没有优化它的方法。至于原因,还是先来看一个可行的解决方案。

对于那个新功能来说,它需要获取一个类的所有子类。因此它的初版代码和SceneName一样,每当执行一次OnGUI,就获取一次这个列表。而问题就在于这个过程相比之下更加耗时。

但是这个操作与获取全部场景的操作有一个不同:类的数目在代码编译过后就不再变了,而Build Setting里的场景数目则在编辑过程中随时可能发生变化。

因此,可以为那个类实现一个构造方法,在构造方法中获取类的列表。这个列表直到下一次编译代码之前都不会改变,而在编译代码之后,这个构造方法也会被调用。

再回到SceneName,我没有找到优化方式的原因就是无法保证BuildSetting变化后重新调用构造方法。

不过这也是目前阶段的结论,未来会不会找到一个更好的实现方式仍未可知。

猜你喜欢

转载自blog.csdn.net/m0_49792815/article/details/124356276