はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】指定したフォルダ以下のファイルで使用されている文字を列挙するエディタ拡張を作ってみた

はじめに

先日このようなエディタ拡張を作成しました。

これを作った目的は、TextMeshProのテキストを使用するものだけにして容量削減するというものになります。

www.hanachiru-blog.com

使い方はTwitterの動画をみていただければすぐに分かると思うので、今回の記事ではコードを公開しようと思います。

コード

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;

namespace UsedCharEnumerator
{
    public sealed class UsedCharEnumerator : EditorWindow
    {
        private const string ASSETS = "Assets";

        private FileExtensions _targets;
        private DefaultAsset _searchFolder;
        private string _path;
        private string _output;

        [MenuItem("Tools/UsedCharEnumerator")]
        private static void OpenWindow()
        {
            var window = GetWindow<UsedCharEnumerator>("UsedCharEnumerator");
            window.minSize = new Vector2(500, 500);
            window._targets = new FileExtensions();
        }   

        private void OnGUI()
        {
            // 検索するフォルダ.
            var searchFolder = (DefaultAsset)EditorGUILayout.ObjectField("検索フォルダ", _searchFolder, typeof(DefaultAsset), false);

            if (searchFolder == null) EditorGUILayout.HelpBox("中で使用されている文字列を知りたいフォルダを選択してください", MessageType.Info);
            else if (_searchFolder != searchFolder) UpdateSerchFile(searchFolder);

            // 検索対象を選択するボタン
            foreach (var item in FileExtensions.Kinds)
                _targets[item] = EditorGUILayout.Toggle(item, _targets[item]);

            // 検索ボタン
            if (GUILayout.Button("Serch", GUILayout.Height(16)))
                if (_searchFolder != null)
                    _output = EnumerateChars(_path);

            // 結果を出力するテキスト
            GUIStyle style = new GUIStyle(GUI.skin.textArea);
            style.wordWrap = true;
            EditorGUILayout.TextArea(_output, style, GUILayout.MaxHeight(float.MaxValue));
        }

        private void UpdateSerchFile(DefaultAsset asset)
        {
            _searchFolder = asset;
            _path = AssetDatabase.GetAssetOrScenePath(_searchFolder);
            string[] folderList = _path.Split('/');
            if (folderList[folderList.Length - 1].Contains("."))
            {
                _searchFolder = null;
                _path = null;
            }
        }

        private string EnumerateChars(string path)
        {
            EditorUtility.DisplayProgressBar("検索中","フォルダ内のファイルを走査中……", 0);

            // 対象のフォルダの絶対パスを生成
            var absolutePath = Application.dataPath.Remove(Application.dataPath.LastIndexOf(ASSETS), ASSETS.Length) + path;

            // ファイル読み込み
            var info = FileReader.GetFiles(absolutePath, _targets).ToArray();

            // 文字抽出
            var length = info.Length;
            var current = 0;
            var charSet = new HashSet<char>();
            
            foreach (var file in info)
            {
                EditorUtility.DisplayProgressBar("読み込み中", $"{file.Name}を読み込み中……", current++ / (float)length);
                foreach (var line in FileReader.Read(file))
                    foreach (var c in line) charSet.Add(c);
            }

            EditorUtility.ClearProgressBar();

            return new string(charSet.ToArray());
        }
    }

    internal static class FileReader
    {
        public static IEnumerable<FileInfo> GetFiles(string absolutePath, FileExtensions target)
        {
            var dir = new DirectoryInfo(absolutePath);

            foreach (var item in target.Targets)
            {
                if (item.Value == false) continue;
                var files = dir.GetFiles(item.Key, SearchOption.AllDirectories);
                foreach (var file in files)
                    yield return file;
            }
        }

        public static IEnumerable<string> Read(FileInfo file)
        {
            using (var reader = new StreamReader(file.FullName, Encoding.UTF8))
            {
                while (!reader.EndOfStream)
                    yield return reader.ReadLine();
            }
        }
    }

    internal class FileExtensions
    {
        public static readonly string[] Kinds = new string[] { "*.cs", "*.asset", "*.txt", "*.json", "*.xml" };
        private readonly Dictionary<string, bool> _targets;
        public IReadOnlyDictionary<string, bool> Targets => _targets;

        public FileExtensions()
        {
            _targets = new Dictionary<string, bool>();
            foreach (var item in Kinds)
                _targets[item] = true;
        }

        public bool this[string key]
        {
            get => _targets[key];
            set
            {
                if (!Kinds.Contains(key)) return;
                _targets[key] = value;
            }
        }
    }
}

これをどこかのEditorフォルダに入れれば、Tools -> UsedCharEnumeratorという欄が出てくるはずです。

仕組み

仕組みはいたってシンプルで以下のようなステップで文字を列挙します。

  1. 指定したフォルダ以下の対象ファイルを取得
  2. StreamReaderを使ってファイルを読み込む
  3. 文字をHashSetに溜める
  4. 全て読み込み終わったら出力

こだわりポイントとしては,ファイル読み込みのときに遅延評価を利用してメモリの使用を抑えた箇所と進捗バーを出したところでしょうか。

ただ進歩バーを出したかったがためにToArrayをした箇所が一つありますが、さほどメモリに影響はないと信じたいです。(色んな種類のファイルがめちゃくちゃあれば少し怪しそう?)

後はキャンセルも実装したかったのですが、今回実装しませんでした。もしかしたら追加するかもしれません。

要注意な箇所

ScriptableObjectに書かれた文字も列挙したいとの思いで、ファイル選択の箇所に.assetがあります。

ただ日本語がエンコード?されたみたく数字の羅列になってしまっているので,StreamReaderでテキストとして読み込んだときに反映されない可能性が非常に高いです。

m_EditorClassIdentifier: 
  _gnosicon:
  - _word: "\u3069\u3053"
    _gnosemes:
    - place
  - _word: "\u5929\u6C17"
    _gnosemes:
    - weather
  - _word: "\u6559\u3048\u308B"
    _gnosemes:
    - tell

現状の実装では.assetはほぼ使えないと思ってもらっても良いかもしれません。

さいごに

めんどくさいのでコードをブログに貼っちゃいましたが、需要があるようならGitHubに乗せたりを考えようと思います。

もし何かあればコメント等で教えていただけると幸いです。

ではまた。