🛠️

【Unity】データ一覧を簡単に確認できるエディタ拡張機能を作ってみた

に公開

概要

Unityで開発をしていると、プレイ中に現在のデータの中身をサクッと確認したい場面が多々あります。
この記事では、そんなときに使えるエディタ拡張でデータを表形式で確認するツールを作ってみたので紹介します。

機能

このエディタ拡張には、以下のような機能があります:

  • プレイモード中にだけ表示されるツールウィンドウ
    (サンプルコードのままならTools→SampleDataCheckerで表示)
  • データを横並びの表形式で表示
  • 列単位でのソート機能
  • 検索機能
    (指定した文字列が含まれる行だけを表示)
  • カラムはデータのプロパティに応じて自動生成されるので、データの型は自由

実際に使うとこんな感じです。

コード

以下が実際のコードです。
Editorフォルダ配下に配置してください。

サンプルコード
SampleDataChecker.cs
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Unity.VisualScripting;
using UnityEditor;
using UnityEditor.IMGUI.Controls;
using UnityEngine;

public class SampleDataChecker : EditorWindow
{
    [MenuItem("Tools/SampleDataChecker")]
    public static void OpenWindow()
    {
        var instance = GetWindow<SampleDataChecker>("SampleDataChecker");
        instance.Init();
    }

    private TreeViewState _treeViewState;
    private SampleDataTreeView _dataTreeView;
    private SearchField _searchField;

    private void Init()
    {
        _treeViewState = new TreeViewState();
    }

    private void OnGUI()
    {
        // TODO: ゲームプレイ中でなくてもデータが取得できるのであれば、この判定は不要です。
        if (!Application.isPlaying)
        {
            EditorGUILayout.HelpBox("プレイモードでのみ有効な機能です", MessageType.Warning);
            return;
        }

        if (GUILayout.Button("データを取得"))
        {
            GetData();
        }
        EditorGUILayout.Space(10);

        if (_dataTreeView == null)
        {
            return;
        }

        // 検索
        var searchRect = EditorGUILayout.GetControlRect(false, GUILayout.ExpandWidth(true), GUILayout.Height(EditorGUIUtility.singleLineHeight));
        _searchField ??= new SearchField();
        _dataTreeView.searchString = _searchField.OnGUI(searchRect, _dataTreeView.searchString);

        if (!_dataTreeView.GetRows().Any())
        {
            EditorGUILayout.LabelField("結果が0件でした!");
        }

        var treeViewRect = EditorGUILayout.GetControlRect(false, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
        _dataTreeView.OnGUI(treeViewRect);
    }

    private void GetData()
    {
        // TODO: データの取得処理はプロジェクトに応じたものに変えてください。
        // データの取得
        var data = new []
        {
            new { Id = 1, Name = "Enemy01", Description = "説明文その1", Hp = 101 },
            new { Id = 2, Name = "Enemy02", Description = "説明文その2", Hp = 102 },
            new { Id = 3, Name = "Enemy03", Description = "説明文その3", Hp = 103 },
            new { Id = 4, Name = "Enemy04", Description = "説明文その4", Hp = 104 },
            new { Id = 5, Name = "Enemy05", Description = "説明文その5", Hp = 105 },
            new { Id = 6, Name = "Enemy06", Description = "説明文その6", Hp = 106 },
            new { Id = 7, Name = "Enemy07", Description = "説明文その7", Hp = 107 },
        };

        // カラム設定
        var dataPropertyInfos = data.GetType().GetElementType().GetProperties().ToArray();
        var columns = new List<MultiColumnHeaderState.Column>();
        foreach (var dataPropertyInfo in dataPropertyInfos)
        {
            var column = new MultiColumnHeaderState.Column { headerContent = new GUIContent(dataPropertyInfo.Name) };
            if (dataPropertyInfo.PropertyType == typeof(string))
            {
                // 文字列のカラムの初期幅は長めに調整
                column.width = 150;
            }
            columns.Add(column);
        }
        var state = new MultiColumnHeaderState(columns.ToArray());
        var multiColumnHeader = new MultiColumnHeader(state);
        _dataTreeView = new SampleDataTreeView(_treeViewState, multiColumnHeader, dataPropertyInfos, data);
    }

    private class SampleDataTreeView : TreeView
    {
        private PropertyInfo[] _dataPropertyInfos;
        private object[] _datas;

        public float RowHeight
        {
            get { return rowHeight; }
            set { rowHeight = value; }
        }

        public SampleDataTreeView(TreeViewState state, MultiColumnHeader multiColumnHeader, PropertyInfo[] dataPropertyInfos, object[] datas)
            : base(state, multiColumnHeader)
        {
            _dataPropertyInfos = dataPropertyInfos;
            _datas = datas;

            showAlternatingRowBackgrounds = true;
            useScrollView = true;
            multiColumnHeader.sortingChanged += OnSortingChanged;

            Reload();
        }

        protected override TreeViewItem BuildRoot()
        {
            var root = new TreeViewItem { id = 0, depth = -1, displayName = "Root" };
            var id = 1;
            var items = _datas.Select(x => (TreeViewItem)new SampleDataTreeViewItem
            {
                id = id++,
                depth = 0,
                data = x,
            }).ToList();
            SetupParentsAndChildrenFromDepths(root, items);

            return root;
        }

        protected override void RowGUI(RowGUIArgs args)
        {
            if (args.item is SampleDataTreeViewItem dataTreeViewItem)
            {
                for (var i = 0; i < args.GetNumVisibleColumns(); i++)
                {
                    var rect = args.GetCellRect(i);
                    var columnIndex = args.GetColumn(i);
                    var dataPropertyInfo = _dataPropertyInfos[columnIndex];
                    var dataPropertyValue = dataPropertyInfo.GetValue(dataTreeViewItem.data);
                    EditorGUI.LabelField(rect, dataPropertyValue.ToString(), EditorStyles.wordWrappedLabel);
                }
            }
            else
            {
                base.RowGUI(args);
            }
        }

        protected override bool DoesItemMatchSearch(TreeViewItem item, string search)
        {
            var dataTreeViewItem = item as SampleDataTreeViewItem;
            if (dataTreeViewItem == null)
            {
                return false;
            }

            // いずれかのカラムに指定した文字列が含まれているか判定
            foreach (var dataPropertyInfo in _dataPropertyInfos)
            {
                var value = dataPropertyInfo.GetValue(dataTreeViewItem.data).ToString();
                if (value.Contains(search))
                {
                    return true;
                }
            }

            return false;
        }

        private void OnSortingChanged(MultiColumnHeader multiColumnHeader)
        {
            if (GetRows().Count <= 1)
            {
                return;
            }

            Sort();
        }

        private void Sort()
        {
            if (multiColumnHeader.sortedColumnIndex == -1 || !multiColumnHeader.state.sortedColumns.Any())
            {
                return;
            }

            var sortedColumn = multiColumnHeader.state.sortedColumns.First();
            var isAscending = multiColumnHeader.IsSortedAscending(sortedColumn);
            var dataTreeViewItems = rootItem.children.Cast<SampleDataTreeViewItem>();
            rootItem.children = isAscending
                ? dataTreeViewItems.OrderBy(x => _dataPropertyInfos[sortedColumn].GetValue(x.data)).Cast<TreeViewItem>().ToList()
                : dataTreeViewItems.OrderByDescending(x => _dataPropertyInfos[sortedColumn].GetValue(x.data)).Cast<TreeViewItem>().ToList();

            var rows = GetRows();
            rows.Clear();
            rows.AddRange(rootItem.children);
            Repaint();
        }
    }

    private class SampleDataTreeViewItem : TreeViewItem
    {
        public object data { get; set; }
    }
}

解説

主にTreeViewを活用してデータの表示、検索、ソートを実装しています。
TreeViewに関しては以下の記事を参考にさせていただきました。
https://qiita.com/yuyu0127/items/f6e1299e1fcb443d9d8b

大まかな処理の流れは以下の通りです。

  1. データを取得する
    (GetData()// データの取得~あたり)
  2. データの型に応じてカラムを設定する
    (GetData()// カラム設定~あたり)
  3. TreeViewにデータを入れる
    (SampleDataTreeViewのコンストラクタ、SampleDataTreeView.BuildRoot()あたり)
  4. 画面にデータの各行を表示する
    (SampleDataTreeView.RowGUI()あたり)

ちなみに検索周りの処理はSampleDataTreeView.DoesItemMatchSearch()あたり、ソート周りの処理はSampleDataTreeView.Sort()あたりでやっています。
もし検索やソートが不要であれば削除していただいても大丈夫です。

使い方

基本的には以下のデータ取得処理をプロジェクトに応じたものに修正すれば使えるはずです。

// TODO: データの取得処理はプロジェクトに応じたものに変えてください。
// データの取得
var data = new []
{
    new { Id = 1, Name = "Enemy01", Description = "説明文その1", Hp = 101 },
    new { Id = 2, Name = "Enemy02", Description = "説明文その2", Hp = 102 },
    new { Id = 3, Name = "Enemy03", Description = "説明文その3", Hp = 103 },
    new { Id = 4, Name = "Enemy04", Description = "説明文その4", Hp = 104 },
    new { Id = 5, Name = "Enemy05", Description = "説明文その5", Hp = 105 },
    new { Id = 6, Name = "Enemy06", Description = "説明文その6", Hp = 106 },
    new { Id = 7, Name = "Enemy07", Description = "説明文その7", Hp = 107 },
};

例えばdataにセットするのは、ScriptableObjectGameObjectから取得した情報でも良いですし、データを取得する静的メソッドみたいなものを使っているのであればそれでも大丈夫です。

おわりに

業務で似たようなものを作ったのですが、結構便利だったので紹介させていただきました。
その業務ではUnity側にキャッシュしたマスタデータやユーザーデータを表示するのに使っていましたが、構造を選ばず応用できる作りになっているので、どんなデータでも活用できると思います。
よろしければぜひ試してみてください!

Discussion