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

この記事は[IS17er Advent Calendar 2017](https://adventar.org/calendars/2583)用に作成されたものです。
<font color=gray>多分このAdvent CalenderでUnityの話してるの私だけだと思います。</font>

# はじめに
UnityのEditor拡張、便利ですね。
私は[ノンリニア](http://n-linear.org/)というゲーム創作サークルでいくつかのプロジェクトに携わっていますが、
やはり複数人開発をする上で、プログラマ以外の方にもUnityを操作してもらうことがあります。
特にデータ入力。変数名が羅列されているだけの無改造Inspectorビューにそのまま入力するのは、なかなか大変です。

そこでEditor拡張で使いやすいUIに改造するわけですが、ここで疑問。
"using UnityEditor" すれば、EditorGUIのどこでも改造できてしまうのでしょうか？
Inspectorビューはかなり自由度が高いですが……

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

この記事では、ユーザに全く素顔を見せてくれない区画の一つ、**Projectビュー**を攻略していきます。
具体的には、**指定したアセットパスのフォルダをProjectビュー上で選択・表示するメソッド**を作っていきます。

# きっかけ
プロジェクトが大きくなっていくと、当然ながらProjectビューに表示されるフォルダ、ファイルはどんどん増えていきます。
当然、目当てのファイルを見つけるのにかかる時間も増えていきます。
検索やブックマーク機能があるので、普通はこれで対応していくわけですが……
いろいろな事情から、**Scriptから特定のフォルダをProjectビュー上で開きたい！**　という状況になったわけです。

何かしらEditor拡張APIがあるだろう、と思ってリファレンスを読む私。
ええ、**ないんですねこれが。**
そもそもProjectビューの取得すら不可能。これは困った。

ProjectWindowUtilなる公開APIが存在しますが、公式リファレンスが空白だったり（そもそも最新リファレンスには項目がない）、メモリリーク報告が上がっていたりと使い物になりません。フォルダ選択機能なかったし。
作りかけで放置されたクラスなんでしょうかね。

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

# 方法１：ダミーファイルを置いてPingを飛ばす
Inspector上で登録されているファイルをクリックすると、Projectビュー上で該当ファイルのディレクトリが表示され、該当ファイルが黄色くハイライトされます。
（このハイライト操作のことを、Unityでは**Ping**と呼びます）

これを利用して、表示したいフォルダに予めダミーファイルを入れておくことで、「フォルダを開く」ことができそうです。
PingのためのAPIは用意されているので、危ないことをせずに済みます。

```C#:Pingを飛ばす
//using UnityEditor

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

    //存在すればPing
    if (obj)
        EditorGUIUtility.PingObject(obj);
}
```
簡単ですね。
しかし、ダミーファイルをわざわざ作って置いておくのは面倒ですし、スマートじゃない。
Pingを使っているせいでどうでもいいファイルが無駄にハイライトされるのも美しくないですね。

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

# 方法２：System.Reflectionでごにょごにょする
本題です。

Projectビューの左カラム上でフォルダをクリックすれば、フォルダの中身が右カラムに表示されます。
ここで呼ばれているメソッドは、**公開されているAPIにはないだけで、必ず内部に存在している**はずですね。
これを無理やり呼び出しにいきます。

以降、`using System.Reflection;`が必要になります。
この時点で危なさ爆発です。
<font color="Red">また、内部APIはUnityのバージョンアップによって予告なく仕様が変更される可能性があります。
本記事のコードを使うことで発生したいかなる不利益に関しても責任は負いかねます。
Unity2017.2で動作確認済みですが、ご利用は自己責任でどうぞ。</font>

## 1. UnityEditorの内部を見る
適当なアセンブリブラウザでUnityEditor.dllを見ましょう。

~~なお非公式ですが、以下のページでも全部見ることができます。~~
~~https://github.com/MattRix/UnityDecompiled/tree/master/UnityEditor~~
~~この記事では、必要に応じてこの中の各ページにリンクを張ったり引用したりします。~~
~~引用の際は行数を付記しておくので参考にどうぞ。~~

2018/8/8追記：
公式に怒られたっぽいですね。
公式が公開してくれたこっちを見ましょう。
https://github.com/Unity-Technologies/UnityCsReference
なお同じコードが載っているかどうかは未確認です。

## 2. Projectビューを取得する
ウィンドウの取得には[GetWindowメソッド](https://docs.unity3d.com/ScriptReference/EditorWindow.GetWindow.html)が用意されていますが、引数としてウィンドウの型が必要です。

[Projectビューのクラス定義を見てみましょう。](https://github.com/MattRix/UnityDecompiled/blob/master/UnityEditor/UnityEditor/ProjectBrowser.cs)
クラス名はProjectBrowserですが、**internal**で宣言されているため、通常の方法ではアクセス不可能です。
Reflectionで無理やり引っ張り出しましょう。

```C#:Projectビューの取得
 Type projectwindowtype = Assembly.Load("UnityEditor").GetType("UnityEditor.ProjectBrowser");
 EditorWindow projectwindow = EditorWindow.GetWindow(projectwindowtype, false, "Project", false);
```
はい、これだけ。
Focus()等の基本的なEditorWindow関連APIであれば、もう使えます。

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

## 3. フォルダ選択メソッドを見つける
それっぽいワードでProjectBrowserクラス内を検索すれば、メソッド本体は見つかります。

```C#: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の公式リファレンス](https://docs.unity3d.com/ScriptReference/AssetDatabase.html)を見ても、int型のIDを取得するようなメソッドは見つかりません。

一体IDとは何なのか？　~~この謎を解き明かすべく、我々はアマゾンの奥地へと向かった。~~
ProjectBrowserクラス内をうろついていると、IDを取得してそうな場所を発見。

```C#: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の中身](https://github.com/MattRix/UnityDecompiled/blob/master/UnityEditor/UnityEditor/AssetDatabase.cs)も見に行きましょう。

```C#: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ビューには１カラム表示がある
私はProjectBrowserクラスを読んでいて初めて知りました。
Projectビューの右上のメニューで"One Column Layout"を選択すると切り替えることができますが、正直使いにくいと思います。

ProjectBrowserクラスを読んでみると、`SetFolderSelection()`は必ず２カラム表示の場合にのみ呼ばれるような処理の流れになっています。
１カラム表示の場合は呼ばないか、２カラム表示に切り替えてから呼ぶようにするべきでしょう。
今回は、２カラム表示に切り替える方針で実装します。

```C#:ProjectBrowser.cs#L805
private void InitViewMode(ProjectBrowser.ViewMode viewMode)
{
    ...
}
```
表示切り替えのためのメソッドはこれのようです。
引数は列挙体なので、こちらも型を取得する必要がありますね。

```C#:ProjectBrowser.cs#L22
private enum ViewMode
{
    OneColumn,
    TwoColumns
}
```
これ、boolでいいんじゃないですかね。将来的に別の表示モードが増えたりするんでしょうか。

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

## 5. 実装する
ここまでに挙げてきたメソッドや型をReflectionで引っ張り出し、Projectビューの取得と合わせて処理を記述します。
列挙体の型フルネームの形式に注意しましょう。クラス内部で宣言されている型は、接続部が"+"になります。

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

+ 第一引数　`object`
 + そのメソッドを実行するインスタンス。
 + staticメソッドの場合はnull。
+ 第二引数　`object[]`
 + メソッドに渡す引数。
 + `params object[]`ではない点に注意。

返り値は`object`です。

## 6. できた

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

```C#:フォルダ選択メソッド
/// <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();
        
    //プロジェクトウィンドウを２カラム表示に変更
    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は何でもできる反面、扱いを間違えると非常に危険です。
さらっと「これを使いましょう」などと挙げているメソッド類がほぼ全て<font color=red>**private**</font>だったことにお気づきでしょうか。
用法・用量を守って正しくSystem.Reflection。