【Unity】Missingチェックを行うEditor拡張

GameObjectに付与したスクリプトや、インスペクタウィンドウで設定したマテリアルや他のGameObjectへの参照がリンク切れになって「Missing」という表示になってしまうことってありますよね。
プレイ中に思ったように動かなくて調べたらMissingになっていたということがあるので、少しでも早く気付けるようにEditor拡張を作成しました。

バージョン2020.3.5f1で動作確認。

![動作イメージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/e6fe6a22-5a4c-5e28-9869-8dcced9673fc.png)


# 仕様
- ヒエラルキーウィンドウに表示しているGameObjectに以下のものが存在する場合、対象GameObjectの行にワーニングアイコンを表示する。
  - 付与されているコンポーネントにMissingなものが存在する場合。
  - SerializeField設定項目の中にMissingなものが存在する場合。
- 同時にコンソールにエラーメッセージを表示する。
  - GameObjectにMissingなコンポーネントがアタッチされているということを知らせる。
  - コンポーネント単位で最初に発見したリンク切れ情報を表示する。

# ポイント解説
下記の投稿と重複する部分は省きます。

https://qiita.com/masamin/items/3c34c85cb3872ff10924

## コンポーネントへの参照がリンク切れになっているかを確認する方法

`EditorApplication.hierarchyWindowItemOnGUI` イベントに登録したイベントハンドラ内で、GameObjectに対して `GetComponents<Component>()` を行ってnullのものが存在する場合、そのコンポーネントはリンク切れとなっています。

```cs:コンポーネントへの参照がリンク切れになっているかを確認
private static void CheckMissingReference(int instanceID, Rect selectionRect)
{
    // instanceIDをオブジェクト参照に変換
    if (!(EditorUtility.InstanceIDToObject(instanceID) is GameObject gameObject)) return;

    // オブジェクトが所持しているコンポーネント一覧を取得
    var components = gameObject.GetComponents<Component>();

    // Missingなコンポーネントが存在する場合はエラー表示
    var existsMissing = components.Any(x => x == null);
    if (existsMissing)
    {
        UnityEngine.Debug.LogError(
            gameObject.name + "のコンポーネントにMissingのものが存在します。");
    }
}
```

## `SerializeField` への参照がリンク切れになっているかを確認する方法
`SerializeField` が以下の条件に当て嵌まる場合はリンク切れと見なすことができます。

:::note warn
SerializedPropertyのobjectReferenceValueがnull（=参照先が存在しない）なのに、FileIDは設定されている（=参照すべきものは設定されている）もの
:::

```cs:SerializeFieldへの参照がリンク切れになっているかを確認
var serializedProp = new SerializedObject(component).GetIterator();

while (serializedProp.NextVisible(true))
{
    if (serializedProp.propertyType != SerializedPropertyType.ObjectReference) continue;
    if (serializedProp.objectReferenceValue != null) continue;

    var fileId = serializedProp.FindPropertyRelative("m_FileID");
    if (fileId == null || fileId.intValue == 0) continue;

    UnityEngine.Debug.LogError(
         component.name + "の" + component.GetType().Name + "のフィールド" + serializedProp.propertyPath + "がMissingとなっています。");
}
```

ただし過去にUnityのバージョンアップで仕様が変更になったことがあるので、バージョンによっては他の方法が必要となるかもしれません。

## ワーニングアイコン表示
ワーニングアイコンは以下の形で取得することができました。
アイコン名は[こちら](https://unitylist.com/p/5c3/Unity-editor-icons)を参考にしましたが、Unityのバージョンによって変わるかもしれません。

```cs:ワーニングアイコン取得
Texture warningIcon = EditorGUIUtility.IconContent("console.warnicon").image;
```

## エラーメッセージ表示頻度
`EditorApplication.hierarchyWindowItemOnGUI` イベントでエラーメッセージの表示を行なっているので、ヒエラルキービューの再描画処理が走る都度新しくエラーが出力されます。
これがかなりの頻度なので、ヒエラルキービューを操作しているとすぐにこんな状態になってしまいます。
![エラーメッセージ出過ぎ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/69453d98-d1a2-b90a-eaff-45eaa3657abe.png)

もう少し控えめでも良いのですが、これだけ煩かったらすぐに解消したくなるからまあいいかな、と自分を納得させています。


# コード全文

```cs:Missingチェックを行うEditor拡張
#nullable enable
using System.Linq;
using UnityEditor;
using UnityEngine;

/// <summary>
/// リンク切れ情報を表示する拡張機能
/// </summary>
/// <remarks>
/// <para>Unity2020.3.5f1で動作確認。</para>
/// <para>
/// <list type="bullet">
/// <item><description>コンポーネントがMissingとなっている場合は！のアイコンを表示し、エラーログも表示。</description></item>
/// <item><description>
/// コンポーネント内の参照項目にMissingとなっているものが存在する場合は！のアイコンを表示し、エラーログも表示。
///（ログはコンポーネント単位で最初に見つかったもののみ表示）
/// </description></item>
/// </list>
/// </para>
/// </remarks>
public static class MissingReferenceChecker
{
    private const int IconSize = 16;

    private const string WarningIconName = "console.warnicon";

    private const string PropertyNameOfFieldId = "m_FileID";

    private static Texture? warningIcon;

    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        Enable();

        /*
         * ビルトインアイコンの呼び出し方は以下を参考にした
         * https://qiita.com/Rijicho_nl/items/88e71b5c5930fc7a2af1
         * https://unitylist.com/p/5c3/Unity-editor-icons
         */
#pragma warning disable UNT0023 // Coalescing assignment on Unity objects
        warningIcon ??= EditorGUIUtility.IconContent(WarningIconName).image;
#pragma warning restore UNT0023 // Coalescing assignment on Unity objects
    }

    private static void Enable()
    {
        EditorApplication.hierarchyWindowItemOnGUI -= CheckMissingReference;
        EditorApplication.hierarchyWindowItemOnGUI += CheckMissingReference;
    }

    private static void CheckMissingReference(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;

        // オブジェクトが所持しているコンポーネント一覧を取得
        var components = gameObject.GetComponents<Component>().ToList();

        // Missingなコンポーネントが存在する場合はWarningアイコン表示
        var existsMissing = components.RemoveAll(x => x == null) > 0;
        if (existsMissing)
        {
            UnityEngine.Debug.LogError(gameObject.name + "のコンポーネントにMissingのものが存在します。");
            DrawIcon(pos, warningIcon!);
        } 
        else 
        {
            foreach (var component in components)
            {
                // SerializeFieldsにMissingなものが存在する場合はWarningアイコン表示
                var existsMissingField = ExistsMissingField(component);
                if (existsMissingField)
                {
                    DrawIcon(pos, warningIcon!);
                }
            }
        }
    }

    /// <summary>
    /// コンポーネントの設定値にMissingなものが存在するかどうかを確認する
    /// </summary>
    /// <param name="component">確認対象のコンポーネント</param>
    /// <returns>MissingなSerializedFieldが存在するかどうか</returns>
    /// <remarks>
    /// 以下の条件を満たす場合はMissingと見なす。Unityのバージョンが変わると変更になる可能性有。
    /// <list type="bullet">
    /// <item><description><see cref="SerializedProperty.propertyType"/>が<see cref="SerializedPropertyType.ObjectReference"/></description></item>
    /// <item><description><see cref="SerializedProperty.objectReferenceInstanceIDValue"/>がnull</description></item>
    /// <item><description>fileIDが0ではない</description></item>
    /// </list>
    /// </remarks>
    private static bool ExistsMissingField(Component component)
    {
        var ret = false;
        var serializedProp = new SerializedObject(component).GetIterator();

        while (!ret && serializedProp.NextVisible(true))
        {
            if (serializedProp.propertyType != SerializedPropertyType.ObjectReference) continue;
            if (serializedProp.objectReferenceValue != null) continue;

            var fileId = serializedProp.FindPropertyRelative(PropertyNameOfFieldId);
            if (fileId == null || fileId.intValue == 0) continue;

            UnityEngine.Debug.LogError(
                component.name + "の" + component.GetType().Name + "のフィールド" + serializedProp.propertyPath + "がMissingとなっています。");
            ret = true;
        }

        return ret;
    }

    private static void DrawIcon(Rect pos, Texture image)
    {
        GUI.DrawTexture(pos, image, ScaleMode.ScaleToFit);
    }
}
```

# コンポーネントアイコン表示機能と併用
こちらに記載した、ヒエラルキーウィンドウにコンポーネントのアイコンを表示するEditor拡張と合わせると、こんな風になります。

https://qiita.com/masamin/items/3c34c85cb3872ff10924

![コンポーネントアイコン表示機能と併用時の動作イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/a4dc8444-62cc-aa54-e618-e94635e0b4bd.png)

```cs:コンポーネントアイコン表示機能と併用
#nullable enable
using System.Linq;
using UnityEditor;
using UnityEngine;

/// <summary>
/// Hierarchyウィンドウにコンポーネントのアイコンを表示する拡張機能
/// </summary>
/// <remarks>
/// <para>Unity2020.3.5f1で動作確認。</para>
/// <para>
/// <list type="bullet">
/// <item><description>Transform以外のコンポーネントのアイコン表示。</description></item>
/// <item><description>スクリプトのアイコンは複数付与されていても1つのみ表示。</description></item>
/// <item><description>コンポーネントが無効になっている場合はアイコン色が半透明になっている。</description></item>
/// <item><description>コンポーネントがMissingとなっている場合は！のアイコンを表示し、エラーログも表示。</description></item>
/// <item><description>
/// コンポーネント内の参照項目にMissingとなっているものが存在する場合は！のアイコンを表示し、エラーログも表示。
///（ログはコンポーネント単位で最初に見つかったもののみ表示）
/// </description></item>
/// <item><description>ヒエラルキーウィンドウで右クリックで表示されるメニュー「コンポーネントアイコン表示切替」の選択で表示/非表示の切替可能。</description></item>
/// </list>
/// </para>
/// </remarks>
public static class ComponentIconDrawerInHierarchy
{
    private const int IconSize = 16;

    private const string MenuPath = "GameObject/コンポーネントアイコン表示切替";

    private const string ScriptIconName = "cs Script Icon";

    private const string WarningIconName = "console.warnicon";

    private const string PropertyNameOfFieldId = "m_FileID";

    private static readonly Color colorWhenDisabled = new Color(1.0f, 1.0f, 1.0f, 0.5f);

    private static Texture? scriptIcon;

    private static Texture? warningIcon;

    private static bool enabled = true;

    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        UpdateEnabled();

        /*
         * ビルトインアイコンの呼び出し方は以下を参考にした
         * https://qiita.com/Rijicho_nl/items/88e71b5c5930fc7a2af1
         * https://unitylist.com/p/5c3/Unity-editor-icons
         */
#pragma warning disable UNT0023 // Coalescing assignment on Unity objects
        scriptIcon ??= EditorGUIUtility.IconContent(ScriptIconName).image;
        warningIcon ??= EditorGUIUtility.IconContent(WarningIconName).image;
#pragma warning restore UNT0023 // Coalescing assignment on Unity objects
    }

    [MenuItem(MenuPath, false, 20)]
    private static void ToggleEnabled()
    {
        enabled = !enabled;
        UpdateEnabled();
    }

    private static void UpdateEnabled()
    {
        EditorApplication.hierarchyWindowItemOnGUI -= DisplayIcons;
        if (enabled)
            EditorApplication.hierarchyWindowItemOnGUI += DisplayIcons;
    }

    private static void DisplayIcons(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;

        // オブジェクトが所持しているコンポーネント一覧を取得
        var components
            = gameObject
                .GetComponents<Component>()
                .Where(x => !(x is Transform || x is ParticleSystemRenderer))
                .Reverse()
                .ToList();

        // Missingなコンポーネントが存在する場合はWarningアイコン表示
        var existsMissing = components.RemoveAll(x => x == null) > 0;
        if (existsMissing)
        {
            UnityEngine.Debug.LogError(gameObject.name + "のコンポーネントにMissingのものが存在します。");
            DrawIcon(ref pos, warningIcon!);
        }

        var existsScriptIcon = false;
        foreach (var component in components)
        {
            // SerializeFieldsにMissingなものが存在する場合はWarningアイコン表示
            var existsMissingField = ExistsMissingField(component);
            if (existsMissingField)
                DrawIcon(ref pos, warningIcon!);

            Texture image = AssetPreview.GetMiniThumbnail(component);
            if (image == null) continue;

            // Scriptのアイコンは1つのみ表示
            if (image == scriptIcon)
            {
                if (existsScriptIcon) continue;
                existsScriptIcon = true;
            }

            // アイコン描画
            DrawIcon(ref pos, image, component.IsEnabled() ? Color.white : colorWhenDisabled);
        }
    }

    /// <summary>
    /// コンポーネントの設定値にMissingなものが存在するかどうかを確認する
    /// </summary>
    /// <param name="component">確認対象のコンポーネント</param>
    /// <returns>MissingなSerializedFieldが存在するかどうか</returns>
    /// <remarks>
    /// 以下の条件を満たす場合はMissingと見なす。Unityのバージョンが変わると変更になる可能性有。
    /// <list type="bullet">
    /// <item><description><see cref="SerializedProperty.propertyType"/>が<see cref="SerializedPropertyType.ObjectReference"/></description></item>
    /// <item><description><see cref="SerializedProperty.objectReferenceInstanceIDValue"/>がnull</description></item>
    /// <item><description>fileIDが0ではない</description></item>
    /// </list>
    /// </remarks>
    private static bool ExistsMissingField(Component component)
    {
        var ret = false;
        var serializedProp = new SerializedObject(component).GetIterator();

        while (!ret && serializedProp.NextVisible(true))
        {
            if (serializedProp.propertyType != SerializedPropertyType.ObjectReference) continue;
            if (serializedProp.objectReferenceValue != null) continue;

            var fileId = serializedProp.FindPropertyRelative(PropertyNameOfFieldId);
            if (fileId == null || fileId.intValue == 0) continue;

            UnityEngine.Debug.LogError(
                component.name + "の" + component.GetType().Name + "のフィールド" + serializedProp.propertyPath + "がMissingとなっています。");
            ret = true;
        }

        return ret;
    }

    private 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;
    }

    /// <summary>
    /// コンポーネントが有効かどうかを確認する拡張メソッド
    /// </summary>
    /// <param name="this">拡張対象</param>
    /// <returns>コンポーネントが有効となっているかどうか</returns>
    private static bool IsEnabled(this Component @this)
    {
        var property = @this.GetType().GetProperty( "enabled", typeof(bool));
        return (bool)(property?.GetValue(@this, null) ?? true);
    }
}
```

# パッケージ
少し改良を加えたものを UPMパッケージとして公開したので、使えそうと感じられたらぜひご活用ください。

GitHub:

https://github.com/lilytech-lab/ComponentIconInHierarchy
 
OpenUPM:

https://openupm.com/packages/com.lilytech-lab.component-icon-in-hierarchy/