Edited at

【Unity Editor拡張】Projectビュー上でフォルダを選択・表示させる

More than 1 year has passed since last update.

この記事はIS17er Advent Calendar 2017用に作成されたものです。

多分このAdvent CalenderでUnityの話してるの私だけだと思います。


はじめに

UnityのEditor拡張、便利ですね。

私はノンリニアというゲーム創作サークルでいくつかのプロジェクトに携わっていますが、

やはり複数人開発をする上で、プログラマ以外の方にもUnityを操作してもらうことがあります。

特にデータ入力。変数名が羅列されているだけの無改造Inspectorビューにそのまま入力するのは、なかなか大変です。

そこでEditor拡張で使いやすいUIに改造するわけですが、ここで疑問。

"using UnityEditor" すれば、EditorGUIのどこでも改造できてしまうのでしょうか?

Inspectorビューはかなり自由度が高いですが……

実は、場所によってはかなり制約が厳しかったりします。

この記事では、ユーザに全く素顔を見せてくれない区画の一つ、Projectビューを攻略していきます。

具体的には、指定したアセットパスのフォルダをProjectビュー上で選択・表示するメソッドを作っていきます。


きっかけ

プロジェクトが大きくなっていくと、当然ながらProjectビューに表示されるフォルダ、ファイルはどんどん増えていきます。

当然、目当てのファイルを見つけるのにかかる時間も増えていきます。

検索やブックマーク機能があるので、普通はこれで対応していくわけですが……

いろいろな事情から、Scriptから特定のフォルダをProjectビュー上で開きたい! という状況になったわけです。

何かしらEditor拡張APIがあるだろう、と思ってリファレンスを読む私。

ええ、ないんですねこれが。

そもそもProjectビューの取得すら不可能。これは困った。

ProjectWindowUtilなる公開APIが存在しますが、公式リファレンスが空白だったり(そもそも最新リファレンスには項目がない)、メモリリーク報告が上がっていたりと使い物になりません。フォルダ選択機能なかったし。

作りかけで放置されたクラスなんでしょうかね。

しかしこれで諦めていたらこの記事は存在しません。


方法1:ダミーファイルを置いてPingを飛ばす

Inspector上で登録されているファイルをクリックすると、Projectビュー上で該当ファイルのディレクトリが表示され、該当ファイルが黄色くハイライトされます。

(このハイライト操作のことを、UnityではPingと呼びます)

これを利用して、表示したいフォルダに予めダミーファイルを入れておくことで、「フォルダを開く」ことができそうです。

PingのためのAPIは用意されているので、危ないことをせずに済みます。


Pingを飛ばす

//using UnityEditor

//指定したパスのファイルにPingを飛ばす。パスは "Assets/..." で拡張子まで。
void Ping(string path)
{
//対象アセットをロード
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);

//存在すればPing
if (obj)
EditorGUIUtility.PingObject(obj);
}


簡単ですね。

しかし、ダミーファイルをわざわざ作って置いておくのは面倒ですし、スマートじゃない。

Pingを使っているせいでどうでもいいファイルが無駄にハイライトされるのも美しくないですね。

ただ純粋に、「フォルダを選択する」ことはできないのでしょうか。


方法2:System.Reflectionでごにょごにょする

本題です。

Projectビューの左カラム上でフォルダをクリックすれば、フォルダの中身が右カラムに表示されます。

ここで呼ばれているメソッドは、公開されているAPIにはないだけで、必ず内部に存在しているはずですね。

これを無理やり呼び出しにいきます。

以降、using System.Reflection;が必要になります。

この時点で危なさ爆発です。

また、内部APIはUnityのバージョンアップによって予告なく仕様が変更される可能性があります。

本記事のコードを使うことで発生したいかなる不利益に関しても責任は負いかねます。

Unity2017.2で動作確認済みですが、ご利用は自己責任でどうぞ。


1. UnityEditorの内部を見る

適当なアセンブリブラウザでUnityEditor.dllを見ましょう。

なお非公式ですが、以下のページでも全部見ることができます。

https://github.com/MattRix/UnityDecompiled/tree/master/UnityEditor

この記事では、必要に応じてこの中の各ページにリンクを張ったり引用したりします。

引用の際は行数を付記しておくので参考にどうぞ。

2018/8/8追記:

公式に怒られたっぽいですね。

公式が公開してくれたこっちを見ましょう。

https://github.com/Unity-Technologies/UnityCsReference

なお同じコードが載っているかどうかは未確認です。


2. Projectビューを取得する

ウィンドウの取得にはGetWindowメソッドが用意されていますが、引数としてウィンドウの型が必要です。

Projectビューのクラス定義を見てみましょう。

クラス名はProjectBrowserですが、internalで宣言されているため、通常の方法ではアクセス不可能です。

Reflectionで無理やり引っ張り出しましょう。


Projectビューの取得

 Type projectwindowtype = Assembly.Load("UnityEditor").GetType("UnityEditor.ProjectBrowser");

EditorWindow projectwindow = EditorWindow.GetWindow(projectwindowtype, false, "Project", false);

はい、これだけ。

Focus()等の基本的なEditorWindow関連APIであれば、もう使えます。

というか、ここまではggれば出てくるんですよね。問題はここからです。


3. フォルダ選択メソッドを見つける

それっぽいワードでProjectBrowserクラス内を検索すれば、メソッド本体は見つかります。


ProjectBrowser.cs#L1328

internal void SetFolderSelection(int[] selectedInstanceIDs, bool revealSelectionAndFrameLastSelected)

{
this.m_FolderTree.SetSelection(selectedInstanceIDs, revealSelectionAndFrameLastSelected);
this.SetFoldersInSearchFilter(selectedInstanceIDs);
this.FolderTreeSelectionChanged(true);
}


第一引数 int[] selectedInstanceIDs

対象フォルダのパスではなく、IDを引数に取っているようです。配列なのは複数選択のためでしょう。

Unityでメタ情報のIDと言えばGUIDですが、これはintに収まるわけがないので違いますね。

まあ、Asset関連情報ならAssetDatabaseでしょう――と思ってAssetDatabaseの公式リファレンスを見ても、int型のIDを取得するようなメソッドは見つかりません。

一体IDとは何なのか? この謎を解き明かすべく、我々はアマゾンの奥地へと向かった。

ProjectBrowserクラス内をうろついていると、IDを取得してそうな場所を発見。


ProjectBrowser.cs#L2320

private static int[] GetFolderInstanceIDs(string[] folders)

{
int[] array = new int[folders.Length];
for (int i = 0; i < folders.Length; i++)
{
array[i] = AssetDatabase.GetMainAssetInstanceID(folders[i]);
}
return array;
}

やっぱりAssetDatabaseじゃないか。

このGetFolderInstanceIDs()にパスの配列を渡せばよさそうですが、せっかくなのでAssetDatabaseの中身も見に行きましょう。


AssetDatabase.cs#L280

[GeneratedByOldBindingsGenerator]

[MethodImpl(MethodImplOptions.InternalCall)]
internal static extern int GetMainAssetInstanceID(string assetPath);

こわちか。深入りするのはやめておきます。

今後Asset単体のIDを取得する必要が出てきたときはこれを使えばよさそうですね。


第二引数 bool revealSelectionAndFrameLastSelected

分かりそうで分からない第二引数。直近の選択と枠を明らかにする、とはどういうことでしょうか。

これをtrueにした場合の処理を追っていくと、インターフェースを経由して数々のソースファイルを巡り、最終的に以下のメソッドにたどり着きます。

UnityEditor.IMGUI.Controls.TreeViewDataSource.SetExpanded()

恐らくフォルダツリーの表示開閉に関わる処理だと思うのですが、

実験してみたところ、trueでもfalseでも見た目上特に何も変わらないという結果が得られました。

この記事ではとりあえずfalseにしておきます。

何かしらの意味はあるはずなので、興味を持たれた方はぜひ解明してご教示ください。


4. Projectビューには1カラム表示がある

私はProjectBrowserクラスを読んでいて初めて知りました。

Projectビューの右上のメニューで"One Column Layout"を選択すると切り替えることができますが、正直使いにくいと思います。

ProjectBrowserクラスを読んでみると、SetFolderSelection()は必ず2カラム表示の場合にのみ呼ばれるような処理の流れになっています。

1カラム表示の場合は呼ばないか、2カラム表示に切り替えてから呼ぶようにするべきでしょう。

今回は、2カラム表示に切り替える方針で実装します。


ProjectBrowser.cs#L805

private void InitViewMode(ProjectBrowser.ViewMode viewMode)

{
...
}

表示切り替えのためのメソッドはこれのようです。

引数は列挙体なので、こちらも型を取得する必要がありますね。


ProjectBrowser.cs#L22

private enum ViewMode

{
OneColumn,
TwoColumns
}

これ、boolでいいんじゃないですかね。将来的に別の表示モードが増えたりするんでしょうか。

ともかく、これで準備は整いました。


5. 実装する

ここまでに挙げてきたメソッドや型をReflectionで引っ張り出し、Projectビューの取得と合わせて処理を記述します。

列挙体の型フルネームの形式に注意しましょう。クラス内部で宣言されている型は、接続部が"+"になります。

また、Reflectionで取得したメソッドをInvokeする際、引数の渡し方に気をつける必要があります。


  • 第一引数 object


    • そのメソッドを実行するインスタンス。

    • staticメソッドの場合はnull。



  • 第二引数 object[]


    • メソッドに渡す引数。


    • params object[]ではない点に注意。



返り値はobjectです。


6. できた

というわけで、最終的に出来上がったものが以下のメソッドになります。


フォルダ選択メソッド

/// <summary>

/// 指定した全てのパスのフォルダをProjectビュー上で選択・表示する
/// </summary>
/// <param name="paths">"Assets/.../*"</param>
void OpenFolders(params string[] paths)
{
//メソッド検索オプションを設定
var flag = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;

//UnityEditor.dllを取得
var asm = Assembly.Load("UnityEditor");

//ProjectBrowserクラスを取得
var projectwindowtype = asm.GetType("UnityEditor.ProjectBrowser");

//列挙体 ProjectBrowser.ViewMode を取得
var viewmodetype = asm.GetType("UnityEditor.ProjectBrowser+ViewMode");

//フォルダIDを取得するメソッドを取得
var GetFolderInstanceIDs = projectwindowtype.GetMethod("GetFolderInstanceIDs", flag);

//任意IDのフォルダを選択するメソッドを取得
var SetFolderSelection = projectwindowtype.GetMethod("SetFolderSelection", flag);

//ビューモードを設定するメソッドを取得
var InitViewMode = projectwindowtype.GetMethod("InitViewMode", flag);

//プロジェクトウィンドウを取得
var projectwindow = EditorWindow.GetWindow(projectwindowtype, false, "Project", false);

//プロジェクトウィンドウにフォーカス
projectwindow.Focus();

//プロジェクトウィンドウを2カラム表示に変更
InitViewMode.Invoke(projectwindow, new[] { Enum.GetValues(viewmodetype).GetValue(1) });

//渡されたパスのフォルダIDを取得
int[] folderids = (int[])GetFolderInstanceIDs.Invoke(null, new[] { paths });

//取得したIDのフォルダを選択(第二引数はとりあえずfalse)
SetFolderSelection.Invoke(projectwindow, new object[] { folderids, false });
}


必要に応じて例外処理等を加えてください。

なおReflection関連は非常に重い処理なので、パフォーマンスが要求される場面ではご注意を。


おわりに

以上、なせば大抵なんとかなります。

Reflectionは何でもできる反面、扱いを間違えると非常に危険です。

さらっと「これを使いましょう」などと挙げているメソッド類がほぼ全てprivateだったことにお気づきでしょうか。

用法・用量を守って正しくSystem.Reflection。