(個人Qiitaは)初投稿です
初めに
Unityを利用して開発する人であれば大抵は通るであろうと思います、ContextMenu。
ただこの子はちょっと癖アリで
引数があるメソッドなんかにそのままでは上手に扱いずらいんですよね。
なので今回引数や戻り値に対して有効な使いやすいContextMenuをリメイクしてみます。
環境
Unity 6000.3.0f1で動作確認済み
推定動作環境 Unity6以降(機能を削ることでそれ以前の一部バージョンでも動作可能)
Unity 2022.3.62f3環境でテストしたところ
SerializeReferenceを使ってシリアライズしたフィールドに
Genericな型のインスタンスを動的に生成して代入する箇所でエラーが発生します
このバージョンから6000.3.0f1までのどこかでシリアライズに対応したようです
対応開始したバージョンは詳しく調査していません。
2026/01/01追記
https://x.com/caitsithware/status/2006199023062127011?s=20
Unity6からの対応になっているとのことです・
Twitterで教えていただきました、感謝!
ウィンドウで引数を編集する機能をなくすことでエラーに対処も可能です…が
今回紹介するコードではその対応は行っていません。
リメイクの仮定で試したこと
結果の実装だけ見たい人は飛ばしていただければ。
実装の足跡
ContextMenuItem
機能については他の方々の記事がより詳しく説明されていると思うので省略しますが、
プロパティに紐づくような実装のメソッドをGUIで紐づけられてスッキリするイイ子。
ただ、この子も引数付きのメソッドに有効な手段を持たず、う~んとなるときもしばしば。
ContextMenuにできないことをこの子が解決してくれる場合ってそんなにないです。
…多分ないです。知らないだけかも。
ありがち(だと思っている)ユースケース
簡単な例として、TextMeshProのテキストに対し
String型を引数に表示する文字を割り当てるケースを想定してみます。
[SerializeField] private TMP_Text m_text;
private void SetText(string source)
{
m_text.SetText(source);
}
この場合ContextMenu、ContextMenuItemをそのまま使っては解決できません。
そこで大抵仮のフィールドを用意してセットしてみるなんてことになると思います。
[SerializeField] private TMP_Text m_text;
private void SetText(string source)
{
m_text.SetText(source);
}
// ContextMenuならこんな感じ?
[SerializeField] private string m_hogeContextMenu;
[ContextMenu(nameof(HogeContextMenu))]
private void HogeContextMenu()
{
SetText(m_hogeContextMenu);
}
// ContextMenuItemならこんな感じ?
[ContextMenuItem(nameof(HogeContextMenuItem), nameof(HogeContextMenuItem))]
[SerializeField] private string m_hogeContextMenuItem;
private void HogeContextMenuItem()
{
SetText(m_hogeContextMenuItem);
}
ありがちコードじゃないでしょうか。
ただ、実際にはこれよりも複雑に副作用が生じるコードになると思いますし
ContextMenuを使いたい大半の状況で
開発中にその場でさっと試したいだけでそこまでコードは書きたくない…って感じます。
でもこれ以外に特に方法はない。…ないはず。
デバッグ機能としてエディタ上やランタイムでも関数を呼べるようにしてる場合
それで済んでいるならそもそもContextMenu属性に頼らないですし…
IDE機能
Unityに対する副作用のない純粋なものなどであれば、
IDEによっては実行することができるようです。
MonoBehaviourやUnity上に関係する副作用を操作できるものがあるのかについては
軽く調べた感じ、Riderが結構強そうでTestRunnerなんかを連携できるみたいです。
筆者がRiderに疎いので、間違っていたら訂正、追記しようと思います。
Editor上で起きる副作用が必要な場合を考えると、
Unity社がUnity専用IDEを作らない限りは
ContextMenuのようにEditorから実行する作業は根本的に変わらない印象でした。
Odin Inspectorやエディタ拡張アセット
エディタ拡張アセットを購入すればすぐ使えるし
ガラパゴス拡張コードが生まれなくて平和ですよね。えぇ。
買える人、使える人はこの記事を見ていない想定をしているので多分問題ないです。
…冗談はさておき、非常に強力なアセット群は積極的に使うべきです。
ですが、今回は下記の問題について考慮しました。
尚、下記の問題は個人の主観100%となっています。
有料エディタ拡張アセット等のライセンスは、
使用する可能性のあるUnityライセンスにつき
一つアセットライセンスを購入する必要があります。
会社に帰属する1プログラマーが考える問題でもないのですが、
デザイナーがUnityを使用するケースや、
デバッグ用アセットを入れている場合は
使用する可能性があるともないとも言えない曖昧な線引きが生まれるものです。
実際、ライセンス購入の線引き等は会社によって基準が様々あると思います。
お金を払うものですから、事情も理解できます。
特段騒いで問題提起したいとは思わないですが、個人的にもやもやが残ります。
じゃあどうやって解決するかと言ったら、作ってしまえばいいじゃない。
という個人の主観を多大に孕みながら作る方向にしました。
拡張を書いてみる ~Menuから追加編~
筆記次点ではContextMenuに対して動的に要素を追加するAPIが公開されていません。
ですが、MenuItemであれば
こちらのUnityのエディタ拡張で動的にメニューを追加・削除するを参考に
internalなメソッドを呼び出して動的に追加が可能です。
MenuItem属性は、
"CONTEXT/{対象クラス名}/{メニュー上の表示名}"のフォーマットにすることで
ContextMenuに対して要素を追加が可能なので、
こちらの機能で実装できないか試してみます。
色々試した概要コード
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class MyContextMenuAttribute : Attribute
{
public string ItemName { get; set; } = string.Empty;
public bool IsValidateFunction { get; set; } = false;
public int Priority { get; set; } = 1000000;
public readonly object[] Arguments;
public MyContextMenuAttribute()
{
Arguments = null;
}
public MyContextMenuAttribute(params object[] arguments)
{
Arguments = arguments;
}
}
public static class MyContextMenu
{
[InitializeOnLoadMethod]
private static void InitializeOnLoad()
{
// NOTE: EditorApplication.delayCallで呼び出しても変わらなかった
AddContextMenu();
}
public class MyContextMenuData
{
private static List<MyContextMenuData> m_caches = new();
private string m_path;
private int m_priority;
private MethodInfo m_method;
public static void AddMyContextMenu(string path, MyContextMenuAttribute attribute, MethodInfo method)
{
var _data = new MyContextMenuData
{
m_path = path,
m_priority = attribute.Priority,
m_method = method,
};
// NOTE: エディタの内部にデータが残っている?のかエラーになる場合があるので一度Removeしてから追加
MenuHelper.RemoveMenuItem(path);
MenuHelper.Update();
MenuHelper.AddMenuItem(path, null, false, _data.m_priority, _data.Do, null);
m_caches.Add(_data);
}
private void Do()
{
// NOTE: 一度ContextMenuから呼び出すと以降の呼び出し時に以下の文言でエラーが発生
// Failed to call static function because an object was provided
// 全てのメニューを追加しなおすとエラーは発生しなかった
foreach (var cache in m_caches)
{
MenuHelper.RemoveMenuItem(cache.m_path);
}
MenuHelper.Update();
foreach (var cache in m_caches)
{
MenuHelper.AddMenuItem(cache.m_path, null, false, cache.m_priority, cache.Do, null);
}
// NOTE: 正常に動作していたらMethodInfoを呼び出すためのコードを書くところ
}
}
private static void AddContextMenu()
{
var _methods = TypeCache.GetMethodsWithAttribute<MyContextMenuAttribute>();
foreach (var method in _methods)
{
var _attribute = method.GetCustomAttribute<MyContextMenuAttribute>();
var _path = GetPath(method, _attribute);
MyContextMenuData.AddMyContextMenu(_path, _attribute, method);
}
MenuHelper.Update();
}
private static string GetPath(MethodInfo methodInfo, MyContextMenuAttribute attribute)
{
// NOTE: CONTEXT/始まりにすることでContextMenuに表示できる
var _sb = new StringBuilder("CONTEXT/");
// NOTE: {CONTEXT/型名/アイテムの表示名} になるので型名を割り当てる
var _type = methodInfo.DeclaringType;
_sb.Append(_type.Name);
_sb.Append('/');
// NOTE: アイテムの表示名を割り当てる
if (string.IsNullOrWhiteSpace(attribute.ItemName))
{
_sb.Append(methodInfo.Name);
}
else
{
_sb.Append(attribute.ItemName);
}
// NOTE: 引数の数を表示する
var _arguments = methodInfo.GetParameters();
if (_arguments != null && _arguments.Length > 0)
{
_sb.Append(' ');
_sb.Append(_arguments.Length);
}
return _sb.ToString();
}
}
結果から記載すると、このアプローチはダメでした。
大きな理由として二つです。
1. エラーが頻発する
属性からメソッドを収集し、ContextMenuとして表示する事までは問題なくできます。
ですが、以下の状況等でエラーが無限に発生しました。
- 独自に追加したアイテム以外のアイテムを実行してから独自アイテムを実行
- 対象ComponentをD&DやContextMenuからMoveUp/Downしたのち独自アイテムを実行
etc...
エラーの内容としては以下の文言のみです。
Failed to call static function because an object was provided
このエラーはStackTraceも存在せず、エラーの発生個所は分かりません。
原因の推察として、
ContextMenu属性等から追加されたものでない為
Actionとして渡された引数を内部で実行する前に
内部で事前に必要な処理の呼び出しが欠けて参照エラーを起こす…
要は正規ルートでContextMenuに追加しないから内部エラーになっているのかなと。
記事として書くために、
それなりにTry&ErrorをしてContextMenuから最低限実行できるまでは行けたのですが
ある手順を踏むと登録したコールバックが呼ばれる前に内部エラーで止まってしまい、
そのある手順が基本的な操作を含めるかなり多くの操作に該当するため
ContextMenuを代替できる機能として作るにはあまりに大きな欠陥。
内部エラーは公式のリファレンスから読み解けないので
相当な頑張りがあれば解決できるかもしれないしできないかもしれない。
2. 実行対象のContextを収集する術がない
ContextMenuが普段何気なくやっていることとして、
実行対象を適切に判別して対象のメソッドを呼び出しています。
何を言っているのかというと…
public class TestClass : MonoBehaviour
{
[ContextMenu("Hoge")]
private void TestMethod()
{
Debug.Log(this);
}
}
こんなコードを実行すると、例としてこんな感じで呼んでいるわけです。
// ContextMenuを開いた対象のUnityEditor上のインスタンスを取得
TestClass _test = Get();
// 実行
_test.TestMethod();
アクセス権限を無視していることや、実際の実装とは間違いなく違うでしょうが
言いたいこととしては対象を収集しているよということです。
また、MenuItem属性のつくメソッドではMenuCommandから対象を収集できます。
public static class TestStaticClass
{
[MenuItem("CONTEXT/TestClass/HogeHoge")]
private static void Do(MenuCommand comamnd)
{
Debug.Log(command.context)
}
}
つまり、MenuItemも内部的にContextMenuを開いた対象を取得することはできるはずです。
…なんですが、Menu.AddMenuItemにはActionしか登録できません。
なので、Selection.objectsやactiveContext等で
呼び出された側から取得しに行こうとしてみます。
別のInternalなクラスEditorToolManagerからactiveToolContext等
Contextとつくものやコールバックイベントなどを手あたり次第試しました。
…無理でした。
GameObjectまでは(当然ながら)Selection.objectsで得られますが、
コンポーネントに関する情報は一切得られませんでした。
試していないパターンやタイミングがあって取得できる可能性はゼロではないので
取得できた方は上位ContextMenu拡張ライブラリを出してほしいです。
ひとまず、Menuから追加をする実装においては、
「全て内部で完結している都合、対象をどこかに保存してくれていることはない」
と結論付けて諦めて別のアプローチに移ります。
ちなみに、MenuCommandのコンストラクタを呼び出しているメソッドを探しましたが
公開されているコードの中ではContextMenuUtilityクラスのみとなっていて、
このクラスは
ユーザーが作ったDropdownMenuに既存のエディタのContextMenuの内容をコピーできる
...というメソッドが大半を占めるものでした。
今回の用途には役に立たず…
MenuItemScriptCommandという
MenuItemの内部でいかにも使われてそうなクラスもあります。
が、勿論参照を追っても途中で途切れ…
仮に多大なエラーを解決して安定的に実行することができても、
何を対象に呼び出せばいいかわからないメソッドがあるだけになり、
やれてstatic methodだけ呼べる…というMenuItemの下位互換…
また、ついでに紹介しておくと
呼び出される側からContextを参照するのは難しい結果になりましたが
DisplayObjectContextPopupMenuというメソッド等を呼べば
任意のContextを渡してContextMenuを表示することはできます。
ちなみに当然の権利のようにInternalなメソッドです。
ContextMenuの表示側は手厚いのに要素側の扱いがひどいのはなぜなんでしょうね。
Mac等ではOSのメニュー等に直に表示されたりするから
下手にユーザー操作させすぎると悪意のある拡張を作られる危険性がある…とか?
実装について
実装コード
全文はこちらのGitHubに置きました。
InternalAPIEditorBridgeを使う必要があるので、
.asmdef衝突を考慮してPackagesで落とせる形にしていません。
Unity公式がInternalAPIEditorBridgeからEditorHeaderItemを開放するまでに
良い衝突対策があればadd git urlで落とせる形式にしておきます。
HeaderFunctionフォルダを丸々使いたいプロジェクトに放り込めば動作するかと思います。
成果物と動作
以下のサンプルコードを配置した場合の例を挙げてみます。
使用サンプルコード
using NotBura.Packages;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
public class Hoge : MonoBehaviour
{
// いつも通り
[HeaderFunction]
private void LogName()
{
Debug.Log(gameObject.name);
}
// 引数に対応
[HeaderFunction(10)]
private void LogValue(int value)
{
Debug.Log(value);
}
// 型に対して不正な値は編集用のウィンドウとエラー文言を出す
[HeaderFunction(null)]
private void LogValueNull(int value)
{
Debug.Log(value);
}
// 値が正しくてもEditableを指定すれば値を編集して実行できる
[HeaderFunction(404, Editable = true)]
private void LogValueEditable(int value)
{
Debug.Log(value);
}
// 戻り値があっても戻り値をDebug.Logで出力するので結果が分かる
// (ハンドラ差し替えできるようにはしたが現状説明してないのでコードを読んでやりたいひとは変えてもらう形で…)
[HeaderFunction]
private float ReturnValue()
{
return Random.Range(0.0f, 1000.0f);
}
// asyncなvoidも対応してる
[HeaderFunction()]
private async void LogAsyncVoid()
{
Debug.Log("void await before log");
await Task.Delay(1000);
Debug.Log("void await after log");
}
// もちろんTaskにも対応している UniTaskとかも問題なく動く
[HeaderFunction()]
private async Task LogAsyncTask()
{
Debug.Log("task await before log");
await Task.Delay(1000);
Debug.Log("task await after log");
}
// 戻り値のあるTask系統も大丈夫 GetResultの型をそのままDebug.Logの対象にする
[HeaderFunction(2000)]
private async Task<int> LogAsyncTaskValue(int value)
{
await Task.Delay(1000);
return value + 25;
}
// UnityEngine.Objectも編集ウィンドウから割り当てられる
[HeaderFunction()]
private void LogObjectName(Image image)
{
Debug.Log(image.gameObject.name);
}
// メソッド引数てんこもりでも大丈夫 属性の引数に指定しておけるのはC#の使用上定数のみだけど…
[HeaderFunction]
private void AnyArgument(int a, float b, MonoBehaviour c, string d, LayerMask e, Vector2 f)
{
Debug.Log(a);
Debug.Log(b);
Debug.Log(c);
Debug.Log(d);
Debug.Log(e);
Debug.Log(f);
}
}
HeaderFunction属性のつくメソッドが存在するMonoBehaviourやScriptableObjectで
以下のようなプレイボタンが表示されるようになります。
押下すると対象のメソッド一覧がGenericMenuで表示されるので、
実行したいメソッドを選択します。
今回はAnyArgumentを選択しました。
もし引数がないメソッド等は即時実行してくれます。
選択時に対象のメソッドに引数が必要な場合以下の状況等で編集ウィンドウを表示します。
- 引数が必要なのに属性の引数指定がない
- 必要な引数に対して属性の引数が不正な値である
- 編集可能であることを指定している
このウィンドウで値を編集して閉じることで実行されます。
内容解説
使う方がいた時のために挙動について軽く書いておきます。
まずstaticメソッドは属性がついていても弾かれます。
…ContextMenuで実行したいものだけが当初の目的だったので
メンドクサイから省きました、スミマセン。
他にも作りたいものがあるので、落ち着いたらこの子も育てていきます。
きっとやります。
戻り値のあるメソッドはその結果をデフォルトでDebug.Logに放り込みます。
一応HeaderFunctionHandler.ResultHandlerという形で切り出しておいてあって、
HeaderFunctionUtility.SetResultHandler(Action<object> handler)を置いてます。
拡張とか触りたくねーヨって方はUtilityからセットしてあげてください。
ごく一部のニーズに変更後デフォルトのハンドラーに戻したいとかあるかもしれないので
そのうち対応します。
属性の引数に書けるものはC#の都合上定数に限られます。
ほんとは属性の引数にフィールド変数とか入れられたらいいんですけどね。
一応構想としてHeaderFunctionReference属性を入れて
参照できるようにしようかと思ってます。
属性の引数からその参照の指定をどうするかが問題なので、うまくいくかは謎です。
CustomEditor的な感じでCustomHeaderFunctionDrawrAttributeなるものも入れてます。
ほぼテストしてないですが、ウィンドウ上の描画やフィールドの持ち方なんかを
好きに定義したものに差し替えられます。
ノリで作って自分で使うタイミングがあんま分からなくなって調整してないです。
// Serializableは書く なくても動くけどWarningが出て鬱陶しい
[CustomHeaderFunctionDrawer(typeof(任意の型))]
[Serializable]
public class TestDrawer : HeaderFunctionDrawer
{
// 煮るなり焼くなりラジバンダリー
}
以下に実装で使った知識や得た知見についても軽く書いておきます。
InternalAPIEditorBridge
mob-sakaiさんのツイートで知ったので貼っておきます。
https://x.com/mob_sakai/status/1827979775346098234?s=20
今回ContextMenuを代替できるような機能として、
実行対象のObjectが分からないと難しいこと。
対象のメソッドを持つ実行対象のEditorに
ContextMenuと同様の紐づく形のUIの配置が必要なこと。
様々要件を考えると普通に解決するのが結構手間だったんですが、
EditorHeaderItemAttributeを使うことで全部解決しました。
一つデメリットとして、
Internal属性クラスなのでReflectionで解決するのが難しい。
なので事実上InternalAPIEditorBridgeを使用することが必須です。
EditorHeaderItemAttribute
簡単に言えば、CustomEditor属性みたいな感じで
任意のEditorのHeaderにフックしてGUI要素を書いたり書かなかったりできます。
EditorHeaderItemAttributeの中には以下のメソッドが定義されています。
[RequiredSignature]
static bool SignatureBool(Rect rectangle, UnityEngine.Object[] targetObjets)
{
throw new InvalidOperationException();
}
使用するにもこのメソッドと同じ戻り値と引数が必要なので、
察するにRequiredSignatureAttributeがつくメソッドは
使用するためのフォーマットを表してくれているものかと。
もしバージョンによってフォーマットが変わったらこちらを見に行くのがよさそうです。
豆知識として他のInternal属性クラスも基本的に同じで
使用する対象のフォーマットが各クラス内に定義されているので
情報が少ないものでも使い方だけは知れたりします。
Taskの戻り値
現代の開発において必須と言っていいほど使うasyncですが、
async Taskのメソッドの戻り値を判定する時に気づきを得ました。
async voidやasync UniTaskしたAwaiterのGetResultの戻り値は
基本的にtypeof(Void)と同じ、つまりSystem.Void型です。
それに対してasync Taskの戻り値はSystem.Threading.Tasks.VoidTaskResult型でした。
正直、誰?って感じで…
調べた感じ、基本ユーザー操作のコードにはでてこない内部だけで使う型で
この盤面以外会うことはなさそうです。
async Voidやユーザー定義のawaitable型もGetResultをvoidで定義したら
System.Void型で扱われるので、歴史的経緯か別の原因でこうなっているのかも?
詳しい人がいれば教えてください。
気になってもやもやしてきた...
終わりに
思いつきで作った割にはいい感じにContextMenuの代替になりそうで良き。
年を越すまでに記事を出したいなと思っていたので間に合って一安心ですが
改善点は考えたらキリがないので年が明けたら練っていこうと思います。
もし気になって使ってみて改善はよと思った方は
鞭打ちにいいねを押してくださったら来年の私が頑張ります。
お付き合いいただきありがとうございました、良いお年を!


