🔎

[Unity]Hierarchyウィンドウで参照切れを発見できるようにすると便利

2024/07/30に公開

Hierarchyウィンドウで参照切れを発見できるようにする方法を紹介します。

基本的に以下の記事の実装を参考にさせていただきました。
https://qiita.com/masamin/items/78ba7238dae7aacdc28c

元記事からの変更点

元記事と基本的に同じですが、以下の点が異なります。

  • Required属性がついているフィールドがnullの場合もエラーアイコンを表示する。(上記スクショのMain)
  • 子オブジェクトが展開されていない場合に、子オブジェクトのどこかに参照切れがある場合は警告アイコンを表示する。(上記スクショのBattleField)
  • エラーメッセージを表示しない。

順に説明します。

Required属性がついているフィールドがnullの場合もエラーアイコンを表示する

Required属性はNaughtyAttributesの機能の一つで、対象がnullの場合にInspectorに表示してくれる機能です。

public sealed class PauseButtonView : UIBehaviour
{
        [SerializeField, Required] Button _button = default!;
}

のように書いておくと、対象のフィールドがnullの場合に、Inspectorでエラーを表示してくれます。

本記事の実装では、Required属性が付与されていて、かつ、nullになっているフィールドは、参照切れとして扱うように変更しています。

子オブジェクトが展開されていない場合でも、子オブジェクトのどこかに参照切れがある場合は、警告アイコンを表示する

本記事の実装では、Hierarchyに表示されているオブジェクトだけでなく、その子孫オブジェクトのコンポーネントも検査対象にしています。こうすることで、シーン内のどこに参照切れがあってもすぐに発見できます。

ただし、オブジェクト数が多くなると判定処理に時間がかかってしまうため、1秒毎にキャッシュを行うようにしています。なお、本実装を適用したプロジェクトはまだ規模が小さく、シーン内に配置するオブジェクトの数もまだあまり多くないため問題にはなっていませんが、シーン内のオブジェクト数によっては非常に重くなってしまう可能性があり、別の工夫が必要になるかもしれません。

エラーメッセージを表示しない

Hierarchyで参照切れを発見できるため、エラーメッセージは表示しないようにしました。

備考

参照切れを防ぐために、シーンやプレハブの保存時、PR作成時、ビルド時などにチェックスクリプトを走らせる方法がありますが、これらを代替するものではなく併用する想定です。

本方法によるチェックは開いているシーンに限定されるため、網羅的な対応はできませんが、視覚的にわかりやすいため、早い段階で発見することが可能です。例えば、新機能を開発している際、アタッチを忘れて実行してしまった場合に、素早く参照切れを発見できるという利点があります。

ソースコード

Unity 2022.3.15f1での動作を確認しています。

ソースコードはコチラ
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NaughtyAttributes;
using UnityEditor;
using UnityEngine;
using UnityEngine.Pool;

// 参考: https://qiita.com/masamin/items/78ba7238dae7aacdc28c
/// <summary>
/// Hierarchyウィンドウにコンポーネントのアイコンを表示する拡張機能
/// </summary>
public static class HierarchyIconDrawer
{
    struct InstanceInfo
    {
        public bool Error { get; set; }
        public double CreatedAt { get; set; }
    }

    const int IconSize = 16;

    const string MenuPath = "Tools/Hierarchy Icon Drawer";

    const string ErrorIconName = "console.erroricon.sml";
    const string WarningIconName = "console.warnicon.sml";

    const string PropertyNameOfFieldId = "m_FileID";

    static Texture _errorIcon = default!;
    static Texture _warningIcon = default!;

    static bool _enabled = true;

    static PropertyInfo _lastInteractedHierarchyWindowProperty = default!;
    static MethodInfo _getExpandedIDsMethod = default!;

    static readonly Dictionary<int, InstanceInfo> HasErrorChildCache = new Dictionary<int, InstanceInfo>(256);
    static readonly double CacheUpdateInterval = 1.0;

    /// <summary>
    /// 初期化する。
    /// </summary>
    [InitializeOnLoadMethod]
    static void Initialize()
    {
        _errorIcon = EditorGUIUtility.IconContent(ErrorIconName).image;
        _warningIcon = EditorGUIUtility.IconContent(WarningIconName).image;

        var sceneHierarchyWindowType = typeof(EditorWindow).Assembly.GetType("UnityEditor.SceneHierarchyWindow");
        _lastInteractedHierarchyWindowProperty = sceneHierarchyWindowType.GetProperty("lastInteractedHierarchyWindow", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!;
        _getExpandedIDsMethod = sceneHierarchyWindowType.GetMethod("GetExpandedIDs", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!;

        RegisterCallback();
    }

    /// <summary>
    /// 機能の有効/無効を切り替える。
    /// </summary>
    [MenuItem(MenuPath, false)]
    static void ToggleEnabled()
    {
        _enabled = !_enabled;
        RegisterCallback();
    }

    /// <summary>
    /// アイコン描画用のコールバックを登録する。
    /// </summary>
    static void RegisterCallback()
    {
        EditorApplication.hierarchyWindowItemOnGUI -= DrawIconsOfGameObject;
        if (_enabled)
        {
            EditorApplication.hierarchyWindowItemOnGUI += DrawIconsOfGameObject;
        }
    }

    /// <summary>
    /// gameObjectがHierarchy上で展開されている場合にtrueを返す。
    /// </summary>
    static bool IsExpanded(GameObject gameObject)
    {
        var lastInteractedHierarchyWindow = _lastInteractedHierarchyWindowProperty.GetValue(null);
        if (lastInteractedHierarchyWindow == null)
        {
            return false;
        }

        var expandedIDs = (int[])_getExpandedIDsMethod.Invoke(lastInteractedHierarchyWindow, null);
        return expandedIDs.Contains(gameObject.GetInstanceID());
    }

    /// <summary>
    /// instanceIdで指定されたGameObjectが持つコンポーネントのアイコンを描画する。
    /// </summary>
    static void DrawIconsOfGameObject(int instanceId, Rect selectionRect)
    {
        // instanceIDをオブジェクト参照に変換
        if (!(EditorUtility.InstanceIDToObject(instanceId) is GameObject gameObject)) return;

        var pos = selectionRect;
        pos.x = pos.xMax - IconSize;
        pos.width = IconSize;
        pos.height = IconSize;

        using var tempComponentListHandle = ListPool<Component>.Get(out var tempComponentList);
        gameObject.GetComponents(tempComponentList);
        foreach (var component in tempComponentList)
        {
            if (component == null)
            {
                DrawIcon(ref pos, _errorIcon);
                return;
            }

            // ParticleSystemRendererはParticleSystemをつけると勝手に付いてくる不可視のコンポーネント。他にもあるかもしれない。
            if (component is Transform or ParticleSystemRenderer)
            {
                continue;
            }

            // SerializeFieldsにMissingなものが存在する場合
            if (HasMissingField(component))
            {
                DrawIcon(ref pos, _errorIcon);
                return;
            }
        }

        // 子のGameObjectにMissingなものが存在する場合は警告アイコンを表示する。
        if (!IsExpanded(gameObject) && HasErrorChildFromCache(gameObject))
        {
            DrawIcon(ref pos, _warningIcon);
        }
    }

    /// <summary>
    /// gameObjectの子GameObjectのいずれかにMissing Referenceがある場合にtrueを返す。(一定時間毎にキャッシュする)
    /// </summary>
    static bool HasErrorChildFromCache(GameObject gameObject)
    {
        var instanceId = gameObject.GetInstanceID();
        var currentTime = Time.realtimeSinceStartupAsDouble;
        if (HasErrorChildCache.TryGetValue(instanceId, out var instanceInfo))
        {
            if (currentTime - instanceInfo.CreatedAt < CacheUpdateInterval)
            {
                return instanceInfo.Error;
            }
        }

        var hasErrorChild = HasErrorChild(gameObject);
        HasErrorChildCache[instanceId] = new InstanceInfo
        {
            Error = hasErrorChild,
            CreatedAt = currentTime,
        };
        return hasErrorChild;
    }

    /// <summary>
    /// gameObjectの子GameObjectのいずれかにMissing Referenceがある場合にtrueを返す。
    /// </summary>
    static bool HasErrorChild(GameObject gameObject)
    {
        using var tempComponentListHandle = ListPool<Component>.Get(out var tempComponentList);
        var transform = gameObject.transform;
        for (var i = 0; i < transform.childCount; i++)
        {
            var child = transform.GetChild(i).gameObject;
            child.GetComponentsInChildren(true, tempComponentList);
            foreach (var component in tempComponentList)
            {
                if (component == null || HasMissingField(component))
                {
                    return true;
                }
            }
        }

        return false;
    }

    /// <summary>
    /// コンポーネントの設定値にMissingなものが存在する場合にtrueを返す。
    /// </summary>
    static bool HasMissingField(Component component)
    {
        var serializedProperty = new SerializedObject(component).GetIterator();
        while (serializedProperty.NextVisible(true))
        {
            if (serializedProperty.propertyType != SerializedPropertyType.ObjectReference) continue;
            if (serializedProperty.objectReferenceValue != null) continue;

            var fileId = serializedProperty.FindPropertyRelative(PropertyNameOfFieldId);
            if (fileId == null) continue;
            if (fileId.intValue == 0)
            {
                if (HasRequiredAttribute(serializedProperty))
                {
                    // nullが設定されていてRequiredAttributeが付いている場合はMissing
                    return true;
                }

                continue;
            }

            return true;
        }

        return false;
    }

    /// <summary>
    /// propertyがRequiredAttributeを持つ場合にtrueを返す。
    /// </summary>
    static bool HasRequiredAttribute(SerializedProperty property)
    {
        if (property.propertyPath.Contains('.'))
        {
            // トップレベル以外のフィールドにRequiredAttributeを付けても表示されない。
            return false;
        }

        var parentType = property.serializedObject.targetObject.GetType();
        var fieldInfo = GetFieldInfo(parentType, property.propertyPath);
        return fieldInfo != null && fieldInfo.GetCustomAttribute<RequiredAttribute>() != null;
    }

    /// <summary>
    /// typeのfieldNameに対応するFieldInfoを取得する。
    /// </summary>
    static FieldInfo? GetFieldInfo(Type type, string fieldName)
    {
        var currentType = type;
        while (currentType != null)
        {
            var fieldInfo = currentType.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
            if (fieldInfo != null)
            {
                return fieldInfo;
            }

            currentType = currentType.BaseType;
        }

        return null;
    }

    /// <summary>
    /// posの位置にアイコンを描画する。描画後にposを左に移動する。
    /// </summary>
    static void DrawIcon(ref Rect pos, Texture image, Color? color = null)
    {
        Color? defaultColor = null;
        if (color.HasValue)
        {
            defaultColor = GUI.color;
            GUI.color = color.Value;
        }

        GUI.DrawTexture(pos, image, ScaleMode.ScaleToFit);
        pos.x -= pos.width;

        if (defaultColor.HasValue)
        {
            GUI.color = defaultColor.Value;
        }
    }
}
Happy Elements

Discussion