リファクタリングによるPrefabの影響範囲を可視化するUnityエディタ拡張をつくってみた

サイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する子会社QualiArtsのゲームタイトル運用プロジェクトでUnityエンジニアをしている中辻です。

本記事はQualiArtsの定期ブログ「QualiArts Tech Note」第3弾の記事となります。QualiArtsでは会社で使われている様々な技術の知見をブログで紹介しています。興味のある方は、QualiArtsとタグの付いている他の記事もチェックしてみてください。
QualiArts Tech Note

運用に入っているUnityプロジェクトだと怖いのが既存のクラスの変更によるエンバグです。これをどれだけ防げるかでクライアント起因のバグの量はかなり違ってくると思います。プログラム的には関数の参照やクラスの継承関係を調べればエディタで検索すればどうにかなります。

ただしUnityの場合はPrefabの問題があります。クラスに変更を加えたとして、どこのPrefabで使っているか検索する術がありません。運用が長いとプロジェクトも大きくなり、作った人もプロジェクトからいなくなったりするのでどんどん把握が困難になり既存で汎用でよく使うクラスに変更を加えることは困難になります。

それを解決するためにEditor拡張で特定のクラスがどのPrefabで使われているかを検索するツールを作りました。状況を絞れるようにするためPrefab内のクラスのフィールドがどのような値になっているかも検索できるようにしています。

▼作ったツールのスクリーンショット

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

1:アセンブリ内のクラスをリスト化
2:Editor拡張UI部分でクラス名やフィールドの条件を入力
3:mdfindによって対象のprefabを絞って候補をリスト化
4:Prefabをロードして条件チェック
5:ヒットしたものをリストで表示

1:アセンブリ内のクラスをリスト化する

GameObjectにアタッチできるクラスをprefabから検索するにはまず検索できる対象のクラス一覧(System.Type)をリスト化してもっておく必要があります。

Project内から見えるアセンブリの一覧は以下で取得できます。

var asmList = System.AppDomain.CurrentDomain.GetAssemblies ();

そしてそれぞれのアセンブリに対して以下でクラス(System.Type)の一覧が取得できます。

var classList = asm.GetTypes ();

そしてprefab内にシリアライズされるクラスは全てComponentをベースクラスとして持つので、以下の条件に当てはまるものに絞ります。

type.IsSubclassOf (typeof(Component))

注釈:今回は話さないですがシリアライズオブジェクトも検索できるツールを作る場合はScriptableObjectをベースクラスに持つものを取っておくとよいです。

これで検索対象になりうるクラスの一覧は取得できました。
また3:の項目で必要になるUnityEngineのアセンブリのクラスも把握しておく必要があるので以下で取っておきます。

var defaultAsm = Assembly.GetAssembly (typeof(Component));

2:Editor拡張UI部分でクラス名やフィールドの条件を入力

泥臭くUnityエディタ拡張でUIを書くところなのでほとんど割愛しますが、シリアライズされた変数の条件を選ぶために変数一覧が必要になるのでその取得方法だけ説明しておきます。変数の情報はFieldInfoというクラスとして扱えます。FieldInfoは対象のクラスに対して以下で取得できます。

FieldInfo[] fs = type.GetFields (BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy);

この中にはシリアライズできないものも含まれているので、FieldInfoに対して以下のいずれかの条件に当てはまるものだけに絞ります。

1.プリミティブなデータ型や特定のUnityビルトイン型(boolean,float,string,Vector3,etc..)であること
2.System.Enumをベースクラスとすること(作ったenum系)
3.targetField.FieldType.IsArray == false && targetField.FieldType.IsGenericType == false && targetField.FieldType.IsClass() (シリアライズとして持てるクラス)

注:Array型や構造体は検索条件として指定する状況が少ないと考えたので、対応しませんでした。

3:mdfindによって対象のprefabを絞って候補をリスト化

2:で条件が指定されたとしてここから検索のロジックが始まります。
全てのprefabに対して条件マッチしていたら時間がかかるので処理を軽くするためにmdfindで検索したいクラスが関わっているprefabを絞っています。

ただしmdfindはmacでしか使えないため、windowsも想定するなら短縮は諦めて
$ var prefabList = UnityEditor.AssetDatabase.FindAssets (“t:prefab”);
として全prefabを持ってくるとよいです。

対象クラスをmdfindでヒットさせる文字列を説明します。
metaファイルの中身のguidというものがそのクラスのシリアライズされたprefab内での識別IDになっています。

metaの中身

fileFormatVersion: 2
guid: d5c5ca47aa5c01740810b7c66662099f
MonoImporter:
  serializedVersion: 2
  defaultReferences: []
  executionOrder: 0
  icon: {instanceID: 0}
  userData: 

Prefabの中身を見るとこうなっていてm_ScriptのguidのIDがアタッチしているスプリプトの.metaファイルのguidと一致しています。

Prefabの中身の一部

--- !u!114 &114429527729279624

MonoBehaviour:

  m_ObjectHideFlags: 1

  m_PrefabParentObject: {fileID: 0}

  m_PrefabInternal: {fileID: 100100000}

  m_GameObject: {fileID: 1601549996466440}

  m_Enabled: 1

  m_EditorHideFlags: 0

  m_Script: {fileID: 11500000, guid: d5c5ca47aa5c01740810b7c66662099f, type: 3}

  m_Name:

  m_EditorClassIdentifier:

ただし自作のアタッチするクラスはMonoBehaviourを継承しますが、Unity標準のそうでないクラスは以下のようにクラス名がそのまま書かれています。

prefab中身の一部

--- !u!82 &82834502235008184

AudioSource:

  m_ObjectHideFlags: 1

  m_PrefabParentObject: {fileID: 0}

  m_PrefabInternal: {fileID: 100100000}

  m_GameObject: {fileID: 1601549996466440}

  m_Enabled: 1

  serializedVersion: 4

  OutputAudioMixerGroup: {fileID: 0}

  m_audioClip: {fileID: 0}

つまりUntiyEngineのアセンブリに含まれるクラスはクラス名、それ以外はmetaファイルのguidで検索すればヒットすることがわかります。

Guidはクラス名から以下のように抽出します。(クラスがクラス名.csファイルで作られてる前提。そうでないとUnityのaddComponentの挙動もおかしくなるので当然ではありますが)

//targetClassAssetが対象のクラスのアセット
//targetClassGuidが対象のクラスのGUID
var classGuids = AssetDatabase.FindAssets (targetType.Name);
for (int i = 0; i < classGuids.Length; i++) {
	var classPath = AssetDatabase.GUIDToAssetPath (classGuids [i]);
	if (classPath == null) {
		continue;
	}
	if (classPath.Contains (".cs") == false) {
		continue;
	}
	targetClassAsset = AssetDatabase.LoadAssetAtPath (classPath, typeof(Object));
	if (targetClassAsset == null) {
		continue;
	}
	if (targetClassAsset.name == targetType.Name) {
		//確定
		targetClassGuid = classGuids [i];
		break;
	}
}

あとはC#からシェルコマンドを呼び出し、以下のようにすればヒットしたprefabのパス一覧が手に入ります。

List fileList = new List (); //ここにパス一覧が入る
ProcessStartInfo psi = new ProcessStartInfo ();
psi.UseShellExecute = false;
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
psi.RedirectStandardInput = true;
psi.CreateNoWindow = true;
psi.FileName = "mdfind";
psi.Arguments = targetTypeName + " -onlyin ."; //targetTypeNameが検索文字列
psi.WorkingDirectory = Application.dataPath;
using (Process process = Process.Start (psi)) {
	process.StandardInput.Close ();
	StreamReader sOut = process.StandardOutput;
	while (true) {
		var s = sOut.ReadLine ();
		if (string.IsNullOrEmpty (s)) {
			break;
		}
		fileList.Add (s);
	}
	sOut.Close ();
	process.WaitForExit ();
}

注釈:クラスの変更影響を調べるなら継承されたクラスも検索しないといけないので以下で取得できるクラス一覧のGUIDも一緒にパスを取得した方がいいです。

var subClass = AllTypeList.FindAll ((cls) => cls.IsSubclassOf (targetType));

4:prefabをロードして条件チェック

この時点でクラスが関係するファイルのパスの一覧が確保できているので以下でprefabのアセットを取得できます。

var path = "Assets" + mdFindPath.Substring (Application.dataPath.Length);
var asset = AssetDatabase.LoadAssetAtPath

次に以下でprefab内で使われている対象のクラスのコンポネント一覧を取得できます。

var obj = asset as GameObject;
targetComponents = obj.GetComponentsInChildren (searchInfo.classList [0].TargetType, true);

シリアライズした変数条件がないならここで必要条件を満たしたので完了です。
変数に条件を付けたい場合は

FieldInfo[] fieldList = targetType.GetFields (BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy);

で取得したフィールドから任意のものを以下で取得できます。

field = targetField.GetValue (targetComponent);

あとは変数のタイプごとにTostringしたりキャストして値が指定した条件に一致しているかを評価すれば良いです。

UnityEngine内のシリアライズされる変数の中には上記のフィールドで取得できないものがありプロパティも候補に入れる必要があるので、以下で取得して比較すればよいです。

targetType.GetProperties ();

5:ヒットしたものをリストで表示

あとは4で一致したPrefabのAssetsのリストをEditorGUILayout.ObjectFieldなどで表示してあげればクリックでProjectウィンドウでで選択できるようになるので、すぐに対象のprefabを確認できて便利です。

一連のプロセスの説明は以上です。
実際にはもっと便利にするためにHierarchy上で選択中のGameObject内のクラスを検索できるようにしたり、Sceneファイルも検索できるようにしたり、参照型のFieldがnullであることの検索やMissingであることを検索をできるようにしたり、シリアライズオブジェクトも検索できるようにしたりしてますが長くなるのでこのぐらいで。

自己流で試行錯誤で作ったものなので他にもっと効率的なやり方もあるかもしれませんが、私のプロジェクトでは重宝しています。

UnityにおいてPrefabの中で特定のクラスが使われているかを調べるのはバグを防ぐためには避けては通れないことだと思うのでそういったツールを作ることの参考になれば幸いです。

LINEで送る
Pocket

おすすめ記事