2
2

Unity C# ピュアクラスのファイル名と行数を特定して、一発で開きたい【hako 生活の休日】

Last updated at Posted at 2024-09-06

SerializeReference大好きシリーズ。
よろしければ前回の記事もご覧ください。

さて、SerializeReferenceを使うと、ピュアクラスが大量に発生しますね。
インスペクターで表示中のピュアクラスを編集したくなった時、一発でピュアクラスが定義してあるコードを開きたい!という記事です。

SerializeReferenceのよくある使い方

よくあるパターンとしては、イベントシステムでの活用です。

  1. イベントの親クラスを作る
  2. 子クラスのイベントを大量に作る
  3. 実行部分を作り、インスペクタで好きに切り替え可能にする

以下のように非同期系の仕組み(例えばUniTask)と合わせると特に便利です。

C# BaseEvent.cs
[System.Serializable]
public abstract class BaseEvent
{
    public abstract UniTask EventAsync(EventConext context);
}

public class EventConext
{
    //各イベントが自由にアクセスできる変数群(変更してもイベントの子クラスに引数が増えずに済むため)
}

LifeEvents.cs
//このコードにライフ関連のピュアクラスをまとめたい。
[System.Serializable]
public class HealMaxEvent : BaseEvent
{
    public override async UniTask EventAsync(EventConext context)
    {
        //ライフ回復のコード
        // await 演出やアニメーション等
    }
}

[System.Serializable]
public class ExtendsLifeEvent : BaseEvent
{
    public override async UniTask EventAsync(EventConext context)
    {
        //ライフ拡張のコード
        // await 演出やアニメーション等
    }
}

[System.Serializable]
public class AppendLifeEvent : BaseEvent
{
    public override async UniTask EventAsync(EventConext context)
    {
        //ライフ追加のコード
        // await 演出やアニメーション等
    }
}

//実装は以下に続く。
.
.
.


C# EventScript.cs
//実行部分。
//GameObjectにアタッチするクラス。実際のイベントはインスペクタで好きなイベントを設定できる。
public class EventScript : Monobehaviour
{
    //抽象クラスやインターフェースを扱える。
    [SerializeReference] List<BaseEvent> events = new();

    public void Play()
    {
        EventAsync().Forget();
    }
    
    public override async UniTask EventAsync()
    {
        EventConext context = new();
        //ここで前提となる変数を初期化。
        context.xxx = xxx;
        foreach(var e in events){
            await e.EventAsync(context);
        }
    }
}

といった感じでしょうか。

ピュアクラス増えすぎ問題

便利にすればするほどピュアクラスは増えていきます。
上記のLifeEvents.csの例のように、ある程度同じジャンルのピュアクラスは、一つの同じファイルにまとめてしまいたいです。
UnityのMonoBehaviourのように、1ファイルにつきひとつの.csファイルで実装していたら本当にきりがないですね。

ピュアクラスどこ行った?問題

Unityのインスペクタ上でSerializeReferenceの内容をいじっていると、即座に定義元のピュアクラスを修正したくなることがよくあります。ただ、Monobehaviourと違ってピュアクラスは自身のコードの位置や情報を持っていないのです。困りましたね。

特定のファイルを開く機能

スクリプトから、特定のファイルパスの特定の行数を外部エディタ上で開くのは割と簡単です。
OpenFileAtLineExternalを使用します。

    UnityEditorInternal.InternalEditorUtility.
        OpenFileAtLineExternal("任意のファイルパス.cs", 52 /*開くファイルの行数*/);

ピュアクラスのファイル名(と行数を特定するには?

Typeクラスやリフレクション等を調べたのですが、C#のピュアクラス自身は、自身のファイルパスやファイル行を保持していないようです。とても困りました。

なんか惜しい方法

[CallerFilePath]、[CallerLineNumber]という、かなり特殊なアトリビュートがあります。
System.Runtime.CompilerServices名前空間で定義されているものです。

[CallerFilePath]、[CallerLineNumber]を付けた引数を定義すると、呼び出し元のファイルパスと行数を受け取ることができます。

public void DoProcessing()
{
    TraceMessage("Something happened.");
}

public void TraceMessage(string message,
        [CallerMemberName] string memberName = "",
        [CallerFilePath] string sourceFilePath = "",
        [CallerLineNumber] int sourceLineNumber = 0)
{
    Trace.WriteLine("message: " + message);
    Trace.WriteLine("member name: " + memberName);
    Trace.WriteLine("source file path: " + sourceFilePath);
    Trace.WriteLine("source line number: " + sourceLineNumber);
}

// Sample Output:
//  message: Something happened.
//  member name: DoProcessing
//  source file path: c:\Visual Studio Projects\CallerInfoCS\CallerInfoCS\Form1.cs
//  source line number: 31

StackTraceというクラスもありますが、できることはほぼ同じです。指定した階層だけ呼び出し元をさかのぼれるっぽい。
https://learn.microsoft.com/ja-jp/dotnet/api/system.diagnostics.stacktrace?view=net-8.0

ちょっと面倒なパターン

[CallerFilePath]、[CallerLineNumber]を呼び出すインターフェースを実装して、すべての子オブジェクトに定義すれば解決…
ですが、これでもまだ面倒です。

C# RuntimeScript.cs

//ファイル名とファイル行を特定する汎用クラス
public static class FileOpenUtil
{

    public static void FileOpen([CallerFilePath] string sourceFilePath = "",[CallerLineNumber] int sourceLineNumber = 0)
    {
        //ファイル元を呼び出す
        UnityEditorInternalInternalEditorUtility.
        OpenFileAtLineExternal(sourceFilePath, sourceLineNumber);
    }
}

//ファイルを開けることを示すインターフェース
interface IFileOpenable
{
    //実装用
    public void FileOpen();
}

//実装部分
[System.Serializable]
public class ExtendsLifeEvent : BaseEvent, IFileOpenable // I/Fを必ず実装する。面倒かも…。
{
    // I/Fの実装(先頭に用意する。面倒かも…。)
    public void FileOpen() => FileOpenUtil.FileOpen();

    public override async UniTask EventAsync(EventConext context)
    {
        //ライフ拡張のコード
        // await 演出やアニメーション等
    }
}

C# なんかのDrawer.cs
   BaseEvent eve;
   if(eve is IFileOpenable )
   {
       if(ボタンの処理)
       {
         IFileOpener opener = eve as IFileOpener
         opner.FileOpen();
       }
   }

悪くないですが、大量に発生するピュアクラスの実装にメスを入れるのはちょっとナンセンスですね。また、外部エディタが開く行の位置が、FileOpen()を実装した位置に依存するのもナンセンスです。
[CallerFilePath] は誰かが呼んであげないと、ファイルパスを特定できないのがかなりネックですね。もっと、実装部分に干渉せずスパッとクラスの先頭位置を特定する方法はないでしょうか。

もっといい方法

ふと、クラス用のカスタムアトリビュートのコンストラクタが使えるかも?と思いつきました。
試してみると、コンストラクタでも[CallerFilePath]と[CallerLineNumber]の引数は使用できるようです!

C# FileOpenableAttribute.cs
using System;
using System.Runtime.CompilerServices;
using UnityEngine;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class FileOpenableAttribute : PropertyAttribute
{
	public string source;
	public int line;
	public FileOpenableAttribute([CallerFilePath] string source = "", [CallerLineNumber] int line = 0)
	{
		this.source = source;
		this.line = line;
	}
}
//このアトリビュートだけでコードのファイル名と行数を特定できる
[FileOpenable]
public class ExtendsLifeEvent : BaseEvent
{
    public override async UniTask EventAsync(EventConext context)
    {
        //ライフ拡張のコード
        // await 演出やアニメーション等
    }
}

あとはドロワー側で呼び出してあげるだけです。

C# なんかのDrawer.cs
        if (EditorGUILayout.LinkButton("Open"))
        {
            var attr = this.attribute as FileOpenableAttribute;
            UnityEditorInternal.InternalEditorUtility.
                OpenFileAtLineExternal(attr.source, attr.line);
        }

なお、アトリビュートのコンストラクターはインスペクターの表示タイミングで呼び出されるようです。(たぶん)

さらに活用する

SubclassSelectorを使用してSerializeReferenceの候補を日本語化できる記事があります。
このNameAttributeでファイル名と行数を取ってしまえば、実装側は無意識に、ピュアクラスのファイルを開く機能を付与することができます。

C# NameAttribute.cs
public class NameAttribute : Attribute
{	
    public string Source { get; };
	public int Line { get; };
    public string Name { get; }
    public NameAttribute(string name,[CallerFilePath] string source = "", [CallerLineNumber] int line = 0)
    {
        Name = name;
		Source = source;
		Line = line;
    }
}

あとはSUbclassSelectorでOpenFileAtLineExternalできるようにしてあげればよいです。(面倒なのでサンプルは書きません…)

さらにさらに便利に使う

Odin Inspectorを活用したSubclassSelectorの記事を、以前書きました。

こちらの拡張も最後に記載しておきます。だいぶ機能モリモリです。

前回の記事のコードに色指定できる機能も導入しています。

POSubclassLabel.cs

using System;
using System.Runtime.CompilerServices;
using UnityEditor;
using UnityEngine;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class POSubclassLabelAttribute : PropertyAttribute
{
	public string displayName = "";
    public Texture2D icon;
	public Color color = Color.white;
	public int order = int.MaxValue;
    public string sourceFile;
	public int    sourceLine;

	public POSubclassLabelAttribute(string name, int order = int.MaxValue, [CallerFilePath] string source = "", [CallerLineNumber] int line = 0)
	{
		this.displayName = name;
		this.order = order;
		this.sourceFile = source;
		this.sourceLine = line;
	}

	public POSubclassLabelAttribute(string name, float r, float g, float b, int order = int.MaxValue, [CallerFilePath] string source = "", [CallerLineNumber] int line = 0)
	{
		this.displayName = name;
		this.color = new Color(r,g,b);
		this.order = order;
		this.sourceFile = source;
		this.sourceLine = line;
	}

	public POSubclassLabelAttribute(string name, string iconPath, int order = int.MaxValue, [CallerFilePath] string source = "", [CallerLineNumber] int line = 0)
	{
		this.displayName = name;
#if UNITY_EDITOR
		this.icon = EditorGUIUtility.Load(iconPath) as Texture2D;
#endif
		this.order = order;
		this.sourceFile = source;
		this.sourceLine = line;
	}

	public POSubclassLabelAttribute(string name, string iconPath, float r , float g, float b, int order = int.MaxValue, [CallerFilePath] string source = "", [CallerLineNumber] int line = 0)
	{
		this.displayName = name;
#if UNITY_EDITOR
		this.icon = EditorGUIUtility.Load(iconPath) as Texture2D;
#endif
		this.color = new Color(r, g, b);
		this.order = order;
		this.sourceFile = source;
		this.sourceLine = line;
	}
}

前回の記事のコードにTypeCacheも導入して、処理負荷の軽減を行いました。

C# POdinSubclassSelectorDrawer.cs
#if ODIN_INSPECTOR
#if UNITY_2019_3_OR_NEWER

using Sirenix.OdinInspector.Editor;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
[DrawerPriority(0.0, 0.0, 2001.0)]
public class POdinSubclassSelectorDrawer : OdinAttributeDrawer<POSubclassAttribute>
{
    //前回の記事で導入していなかったタイプキャッシュも追加
    public static Dictionary<Type, List<TypeLabel>> typeTables = new Dictionary<Type, List<TypeLabel>>(new FastTypeComparer());
    int currentTypeIndex;
    int nextIndex;
    Type baseType = null;
    public class TypeLabel
    {
        public int order = 0x0000;
        public const string NULL_LABEL = "<null>";
        public Type inheritedType;
        public string dipslayName;
        public string typeFullName;
        public Texture icon;
        public Color color;
        //ファイルパスと行数
        public string file;
        public int line;

        public static TypeLabel Null = new TypeLabel()
        {
            inheritedType = null,
            dipslayName = NULL_LABEL,
            typeFullName = "",
        };
    }

    protected override void Initialize()
    {
        base.Initialize();
        var prop = Property.Tree.UnitySerializedObject.FindProperty(Property.UnityPropertyPath);
        if (prop.isArray) { return; }
        POSubclassAttribute utility = this.Attribute;
        baseType = Property.Info.TypeOfValue;
        GetAllInheritedTypes(baseType, utility.IsIncludeMono());
        GetCurrentTypeIndex(prop.managedReferenceFullTypename);
        nextIndex = currentTypeIndex;
    }
    public override bool CanDrawTypeFilter(Type type)
    {
        return !type.IsArray && !type.IsAbstract && !type.IsGenericType;
    }

    protected override void DrawPropertyLayout(GUIContent label)
    {
        const float LINE_SPACE_X = 15;
        const float LINE_PADDING_X = -4;
        var prop = Property.Tree.UnitySerializedObject.FindProperty(Property.UnityPropertyPath);
        if (prop == null || prop.isArray || currentTypeIndex >= typeTables[baseType].Count || currentTypeIndex < 0) {
            CallNextDrawer(label);
            return;
        }
        TypeLabel typeLabel;
        if (typeTables.TryGetValue(baseType, out var typeLabels))
        {
            typeLabel = typeLabels[currentTypeIndex];
        }
        else
        {
            return;
        }
        var innerRect = Property.LastDrawnValueRect;
        innerRect.xMin  += EditorGUI.indentLevel * LINE_SPACE_X + LINE_PADDING_X;
        EditorGUI.DrawRect(innerRect, typeLabel.color * 0.05f);
        Rect labelRect = default;
        Rect buttonRect = default;
        EditorGUILayout.BeginHorizontal();
        {
            labelRect = (Rect)EditorGUILayout.BeginVertical();
            {
                if (label != null)
                {
                    GUI.color = Color.white;
                    GUI.contentColor = typeLabel.color;
                    label.image = typeLabel.icon;
                    EditorGUILayout.PrefixLabel(label);
                }
                else
                {

                }
            }
            EditorGUILayout.EndVertical();
            GUI.backgroundColor = typeLabel.color * 0.7f;
            buttonRect = (Rect)EditorGUILayout.BeginVertical();
            {
                if (EditorGUILayout.DropdownButton(new GUIContent(typeLabel.dipslayName), FocusType.Keyboard))
                {
                    GUI.color = Color.white;
                    GUI.backgroundColor = Color.white;
                    GUI.contentColor = Color.white;
                    var selector = new SubTypeSelector(typeTables[baseType]);
                    selector.SelectionConfirmed += col =>
                    {
                        col = selector.GetCurrentSelection();
                        int? check = col.LastOrDefault();
                        nextIndex = check.HasValue ? check.Value : nextIndex;
                    };
                    Vector2 popupPos = buttonRect.position;
                    popupPos.y += EditorGUIUtility.singleLineHeight;
                    selector.ShowInPopup(popupPos, buttonRect.width);
                }
            }
            EditorGUILayout.EndVertical();
            GUI.color = Color.white;
            GUI.backgroundColor = Color.white;
            GUI.contentColor = Color.white;
            var newStyle = new GUIStyle(EditorStyles.miniButtonRight);
            var icon = EditorGUIUtility.IconContent("d__Menu@2x");
            //ここでピュアクラスをコードエディタで開くボタンを追加
            if (GUILayout.Button(icon, GUILayout.Width(20), GUILayout.Height(20)))
            {
                UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(typeLabel.file, typeLabel.line + 2);
            }

        }
        EditorGUILayout.EndHorizontal();
        EditorGUI.indentLevel++;
        UpdatePropertyToSelectedTypeIndex(prop, nextIndex);
        bool drawn = CallNextDrawer(null);
        var rect = Property.LastDrawnValueRect;
        EditorGUI.indentLevel--;
        if (drawn)
        {
            var start = new Vector2(rect.xMin + EditorGUI.indentLevel * LINE_SPACE_X + LINE_PADDING_X + 8, buttonRect.position.y + EditorGUIUtility.singleLineHeight);
            var end = new Vector2(rect.xMin + EditorGUI.indentLevel * LINE_SPACE_X + LINE_PADDING_X + 8, rect.yMax);
            Rect lineRect = new Rect(start, end - start + Vector2.right * 2);
            EditorGUI.DrawRect(lineRect, typeLabel.color * 0.7f);
        }
    }
    

    private void GetCurrentTypeIndex(string typeFullName)
    {
        currentTypeIndex = typeTables[baseType].FindIndex(r => r.typeFullName == typeFullName);
    }

    void GetAllInheritedTypes(Type baseType, bool includeMono)
    {
        if (typeTables.ContainsKey(baseType))
        {
            return;
        }
        typeTables.Add(baseType, new List<TypeLabel>(20));
        Type monoType = typeof(MonoBehaviour);
        var inheritedTypes = AppDomain.CurrentDomain.GetAssemblies()
            .SelectMany(s => s.GetTypes())
            .Where(p => baseType.IsAssignableFrom(p) && p.IsClass && (!monoType.IsAssignableFrom(p) || includeMono) && !p.IsAbstract)
            .Prepend(null)
            .ToArray();
        foreach (var type in inheritedTypes)
        {
            if (type == null)
            {
                typeTables[baseType].Add(TypeLabel.Null);
            }
            else
            {
                bool hasLabel = TryGetLabelAttribute(type, out var label);
                typeTables[baseType].Add(
                    new TypeLabel()
                    {
                        inheritedType = type,
                        dipslayName = hasLabel ? label.displayName : type.Name,
                        typeFullName = string.Format("{0} {1}", type.Assembly.ToString().Split(',')[0], type.FullName),
                        icon = hasLabel ? label.icon : null,
                        color = hasLabel ? label.color : Color.white,
                        order = hasLabel ? label.order : int.MaxValue,
                        file = label.sourceFile,
                        line = label.sourceLine

                    });
            }
        }
        typeTables[baseType].Sort(new LabelComparer());
    }

    public class LabelComparer : IComparer<TypeLabel>
    {
        public int Compare(TypeLabel x, TypeLabel y)
        {

            return 
                x.order > y.order ?  1 :
                x.order < y.order ? -1 :
                x.dipslayName.CompareTo(y.dipslayName);
        }
    }



    private static bool TryGetLabelAttribute(Type type, out POSubclassLabelAttribute subclassLabelAttribute)
    {
        subclassLabelAttribute = type.GetCustomAttribute<POSubclassLabelAttribute>();
        return subclassLabelAttribute != null;
    }


    public void UpdatePropertyToSelectedTypeIndex(SerializedProperty property, int selectedTypeIndex)
    {
        
        if (currentTypeIndex == selectedTypeIndex)
        {
            return;
        }
        currentTypeIndex = selectedTypeIndex;
        Type selectedType = typeTables[baseType][selectedTypeIndex].inheritedType;
        property.managedReferenceValue =
            selectedType == null ? null : Activator.CreateInstance(selectedType);
    }

    public class SubTypeSelector : OdinSelector<int?>
    {
        List<TypeLabel> labels;
        public SubTypeSelector(List<TypeLabel> labels)
        {
            this.labels = labels;
        }

        protected override void BuildSelectionTree(OdinMenuTree tree)
        {
            tree.Config.AutoFocusSearchBar            = true;
            tree.Config.ConfirmSelectionOnDoubleClick = true;
            tree.Config.DrawScrollView                = true;
            tree.Config.UseCachedExpandedStates       = false;
            tree.Selection.SupportsMultiSelect        = false;
            tree.Config.SelectMenuItemsOnMouseDown    = true;
            int labelCount                            = labels.Count;
            for (int i = 0; i < labelCount; i++)
            {
                tree.Add(labels[i].dipslayName, i , labels[i].icon);
                if (labels[i].color != Color.white)
                {
                    var item                       = tree.GetMenuItem(labels[i].dipslayName);
                    var newStyle                   = new GUIStyle();
                    newStyle.normal.textColor      = labels[i].color;
                    var newOdinStyle               = item.Style.Clone();
                    newOdinStyle.DefaultLabelStyle = newStyle;
                    item.Style                     = newOdinStyle;
                }
            }
        }
    }
}
#endif
#endif

あとは、ピュアクラスに[POSubclassLabel]を定義するだけで自動的にファイルが直編集できるボタンが付与されます。

SubclassSelectAndFileOpener.gif
今後の制作がか~~~な~~~り改善されそう。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2