Unityのエディタ拡張で動的にメニューを追加・削除する

こちらは[クラスター Advent Calendar 2022](https://qiita.com/advent-calendar/2022/cluster)（2ページ目）の16日目の記事です！
前日は @BlueRose_Sora さんの「[Tips：力こそパワーな3DCGモデリング](https://note.com/bluerose_sora/n/nc9c53d443ddc)」でした！て…手水舎を作る工程を一から解説されていてとても勉強になります！名刺交換も待ってます🤗

こんにちは、スワンマンです。クラスター株式会社でディレクターとかカスタマーサポートとかスワンマンをしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58380/a69799ad-7b11-a9fa-5ff4-da93bc327601.png "著者近影")
弊社では「ワールド」というメタバース空間上のデータを扱うため、僕のような非エンジニアもUnityを触ることがあります。
その中で「この作業をもっと楽にしたい！」とエディタ拡張を作ることもあるのですが、そのエディタ拡張を作る際に便利な「動的にメニューを追加・削除する方法」について今回はご紹介します。
※確認はUnity 2021.3.4f1で行っていますが、2023.1のリファレンスコードを見ても変わってないっぽいので他でもいけそうです（たぶん）

## 従来の方法

通常、Unity Editorにメニューを追加するには静的な方法しか用意されていません。
処理内容を書いたメソッドに属性として指定するので、動的に追加することは基本的にできないです。
こんな感じ。
```csharp
[MenuItem("SwanTools/Menu Item")]
private static void Execute()
{
    Debug.Log("clicked!");
}
```

## 今回の方法

…と思ってたんですが、実はMenuクラスのinternalなメソッドにAddMenuItemというものがあるので、こいつを使うと簡単に好きなだけメニューを追加できます。
以下は雑にまとめたヘルパークラスです。
```csharp
public static class MenuHelper
{
    public static void AddMenuItem(string name, string shortcut, bool isChecked, int priority, Action execute, Func<bool> validate)
    {
        var addMenuItemMethod = typeof(Menu).GetMethod("AddMenuItem", BindingFlags.Static | BindingFlags.NonPublic);
        addMenuItemMethod?.Invoke(null, new object[] { name, shortcut, isChecked, priority, execute, validate });
    }

    public static void AddSeparator(string name, int priority)
    {
        var addSeparatorMethod = typeof(Menu).GetMethod("AddSeparator", BindingFlags.Static | BindingFlags.NonPublic);
        addSeparatorMethod?.Invoke(null, new object[] { name, priority });
    }

    public static void RemoveMenuItem(string name)
    {
        var removeMenuItemMethod = typeof(Menu).GetMethod("RemoveMenuItem", BindingFlags.Static | BindingFlags.NonPublic);
        removeMenuItemMethod?.Invoke(null, new object[] { name });
    }

    public static void Update()
    {
        var internalUpdateAllMenus = typeof(EditorUtility).GetMethod("Internal_UpdateAllMenus", BindingFlags.Static | BindingFlags.NonPublic);
        internalUpdateAllMenus?.Invoke(null, null);

        var shortcutIntegrationType = Type.GetType("UnityEditor.ShortcutManagement.ShortcutIntegration, UnityEditor.CoreModule");
        var instanceProp = shortcutIntegrationType?.GetProperty("instance", BindingFlags.Static | BindingFlags.Public);
        var instance = instanceProp?.GetValue(null);
        var rebuildShortcutsMethod = instance?.GetType().GetMethod("RebuildShortcuts", BindingFlags.Instance | BindingFlags.NonPublic);
        rebuildShortcutsMethod?.Invoke(instance, null);
    }
}
```

## メニューを追加する

追加方法は下記のような感じです。最後のUpdateメソッドは反映のために呼ぶ必要があります。
（呼ばなくてもエラーにはならないですが、メニューのどこかをクリックするまで表示されなかったり、ショートカットキーが割り当てられなくなります）
```csharp
// 追加する
MenuHelper.AddMenuItem("SwanTools/First Menu", "", false, 1, () => Debug.Log("clicked!"), null);

// validateも使用可
MenuHelper.AddMenuItem("SwanTools/Second Menu", "", false, 2, () => Debug.Log("clicked!"), () => Selection.activeGameObject != null);

// 表示を反映
MenuHelper.Update();
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58380/4d898518-a63f-2551-cb59-1afa440c79a3.png)

## 区切り線を追加する

MenuItemを使用した従来の方法では区切り線を追加するためにpriorityを11以上空けてメニューを作る必要がありましたが、AddSeparatorを使えば連続したpriorityでも区切り線の挿入が可能です。
```csharp
MenuHelper.AddMenuItem("SwanTools/First Menu", "", false, 1, () => Debug.Log("clicked!"), null);
MenuHelper.AddSeparator("SwanTools/", 2);
MenuHelper.AddMenuItem("SwanTools/Second Menu", "", false, 3, () => Debug.Log("clicked!"), null);
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58380/7d796321-9eaf-e074-0370-daf3a269fb64.png)

## メニューを削除する

削除は追加時と同じ名前を指定してRemoveMenuItemを呼ぶだけ…のはずなんですが、下記のようにRemoveMenuItemを呼んでも消えてくれません。普通にバグっぽい…。
```csharp
// これだけだとNG
MenuHelper.RemoveMenuItem("SwanTools/First Menu");
// Updateしてもダメ
MenuHelper.Update();
```
とはいえ全く機能していないわけではなく、恐らく内部的には削除されているのにUpdateに反応するための更新フラグが立っていないようです。実際RemoveMenuItemしたメニューにアクセスしようとするとUnityが落ちます😇


幸いにして（？）AddMenuItemした際には更新フラグが立っているようなので、追加したものはListなどに溜めておき、更新時は一度全て削除してから再度追加するような仕組みにすればうまく動きます。
```csharp
// 例えばこんなクラスを作っておくと
public static class MenuManager
{
    private struct MenuItem
    {
        public string name;
        public string shortcut;
        public bool isChecked;
        public int priority;
        public Action execute;
        public Func<bool> validate;
    }

    private static readonly List<string> currentMenuNames = new();
    private static readonly List<MenuItem> menuItems = new();

    public static void Add(string name, string shortcut, bool isChecked, int priority, Action execute, Func<bool> validate, bool forceUpdate = true)
    {
        MenuItem item;
        item.name = name;
        item.shortcut = shortcut;
        item.isChecked = isChecked;
        item.priority = priority;
        item.execute = execute;
        item.validate = validate;
        menuItems.Add(item);
        
        if (forceUpdate)
        {
            Update();
        }
    }

    public static void Remove(string name, bool forceUpdate = true)
    {
        var idx = menuItems.FindIndex(x => x.name == name);
        if (idx >= 0)
        {
            menuItems.RemoveAt(idx);
        }

        if (forceUpdate)
        {
            Update();
        }
    }

    public static void Update()
    {
        // 一度管理下のメニューを全て消す
        currentMenuNames.ForEach(x => MenuHelper.RemoveMenuItem(x));
        currentMenuNames.Clear();

        // 新規に追加する
        menuItems.ForEach(x =>
        {
            MenuHelper.AddMenuItem(x.name, x.shortcut, x.isChecked, x.priority, x.execute, x.validate);
            currentMenuNames.Add(x.name);
        });
        MenuHelper.Update();
    }
}

// こういう感じで使える
MenuManager.Add("SwanTools/First Menu", "", false, 1, () => Debug.Log("clicked!"), null);
MenuManager.Remove("SwanTools/Second Menu");

// forceUpdateを使わない場合は最後にUpdateする
MenuManager.Add("SwanTools/First Menu", "", false, 1, () => Debug.Log("clicked!"), null, false);
MenuManager.Remove("SwanTools/Second Menu", false);
MenuManager.Update();
```


ちなみに元々あるメニュー項目を消すこともできるため、こんな風に動作を置き換えることも可能です。
```csharp
// Cubeの作成メニューを乗っ取ってSwanを生成するように変更する
MenuHelper.RemoveMenuItem("GameObject/3D Object/Cube");
MenuHelper.AddMenuItem("GameObject/3D Object/Cube", "", false, 0, () => {
    var prefab = AssetDatabase.LoadAssetAtPath("Assets/Models/swan.prefab", typeof(GameObject));
    Selection.activeGameObject = GameObject.Instantiate(prefab) as GameObject;
}, null);
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58380/57573e5f-4047-1f9b-643d-ba6b035c6887.gif" width="640" title="こんなCubeはいやだ">

## 注意点

起動時に追加するような場合は[InitializeOnLoadMethod]のタイミングだとうまく動かないため、以下のように少し遅らせて実行してやる必要があります。もっといいタイミングがあるかもしれないので有識者はぜひ教えてください！
```csharp
[InitializeOnLoadMethod]
private static void Initialize()
{
    EditorApplication.delayCall += () => BuildMenu();
}

private static void BuildMenu()
{
    MenuHelper.AddMenuItem("SwanTools/First Menu", "", false, 1, () => Debug.Log("clicked!"), null);
    MenuHelper.Update();
}
```

## 活用例

社内ではこれを使用してclusterのワールドアップロード用のアカウント切り替えツールを作ってます。結構頻繁に切り替えるので便利。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58380/583cc0dc-9bb2-417d-93ce-5ca29e3bce3a.png)

## おわり

皆さんもぜひこれを使用して便利なエディタ拡張を作ってみてください😉
明日は @tsgcpp さんの「UnityでMoqを使う (Unity2021バージョン)」です！