はじめに
本記事は、QualiArts Advent Calendar 2022 7日目の記事です。
本記事では、ILPostProcessorとasmrefを使って、本来どうにも処理を変えることができないような外部Packageのstaticメソッドの処理を好きなように書き換える方法を示します。
メソッドの書き換えによって、外部Packageの機能が拡張性の面やパフォーマンスの面などでプロジェクトにとって都合が悪い場合でも、都合の良いように改変することができるようになります。
書き換えるメソッドはprivateでも問題ありませんが、書き換える中身についてはいくつかの制約があり、これについては後述しています。
メソッドの書き換えは、対象の外部Packageの内部実装が変わらない保証はどこにもないので、Packageの更新によって想定外の事態が起きる可能性が高く危険な行為です。
その危険性を理解した上で自己責任での利用をお願いします。
ILPostProcessorについて
ILPostProcessorは、Unityが生成するILに対して手を加えることができる仕組みです。
ECSやBurst、VContainerなどで利用されています。
ILPostProcessorの利用方法について、おそらく公式ドキュメントは存在せず、日本語による解説もほとんど存在しません。
数少ないQiita上でのILPostProcessorの利用事例としては下記の記事があります。
ILPostProcessorの実装はECSの実装を参考にするというのが鉄板のようで、今回の実装もそれに従い、ECS(com.unity.entites@1.0.0-pre.15)の実装から必要な部分をコピーして利用しています。
コピーした中でも特に、ICompiledAssemblyからAssemblyDefinitionを取得する部分、処理を加えたAssemblyDefinitionからILPostProcessResultを得る部分に関しては自分はブラックボックスとして利用しています。
この辺りが気軽に使えるような機能をUnityから公式に提供してもらえれば、ILPostProcessorはもっと普及するのではないかと思います。
ILPostProcessorは本当に幅広いことが実現可能な仕組みですが、本記事ではメソッド書き換えに絞って紹介します。
ILPostProcessor活用の1つの事例の紹介という立ち位置で捉えて頂ければと思います。
asmrefについて
asmrefは既存のasmdefで生成されるDLLに自分のソースコードを追加できる機能です。
これを使うことで、外部Packageのinternalな機能をこじ開けるようなことが可能になります(内部でinternalなメソッドを呼び出すpublicメソッドをasmref内に追加する)。
asmrefについての解説は下記の記事がとても参考になります。
asmrefを使うと通常はできないようなことがいくつか可能になりますが、本記事で行おうとしている外部Package内のメソッドの内部実装を変えるようなことはできません。
そこはILPostProcessorの力が必要になります。
内部実装の書き換えに関して、asmrefは必須ではないのですが使うとかなり楽になるため、本記事ではILPostProcessorとasmrefを組み合わせた手法を紹介します。
手法
それでは本題であるメソッド書き換えの手法を説明します。
一口にメソッド書き換えと言っても、色々なパターンがあると思います。
今回は、自分で用意した処理に丸ごと置き換えるパターンのみを紹介します。
それ以外の、一部処理を追加するパターンや処理を削除するパターンなどにも、ILPostProcessorなら気合さえあれば対応できるはずです。
手順は以下の通りとなります。
- 対象のメソッドを含むasmdefに対するasmrefを作成する
- そのasmrefの中に、丸ごと置き換える先のメソッドとして、対象のメソッドと同じ引数・返り値を持つメソッドを用意する
- ILPostProcessorのためのasmdefや必要コードを用意する(先述したように、ECSの実装等を参考にする)
- ILPostProcessorの中で、対象のメソッドを探し、そのメソッドの中で2.で用意したメソッドを呼び出す
この手法のポイントの1つは、置き換え先のメソッドはC#で書けるという点です。
ILPostProcessorでできることはあくまでILの書き換えなのですが、ILの書き換えはかなり大変な作業です。
ですので、ILの書き換えは最小限にし、書き慣れたC#で処理を記述できるのは大きなメリットになります。
置き換え先のメソッドをasmref内に実装することによって、対象のメソッドからリフレクションなどを使わずに素直に呼び出せるようになっています。
またこの手法では、メソッドの呼び出し元には何も変更を加える必要がありません。
ILPostProcessorが処理する対象のDLLが増えるとコンパイル時間の増加に繋がるため、対象を1つに絞ることができるこの手法はコンパイル時間の増加を最小限にすることができます。
この手法の制約
上記のメソッド書き換えの手法ですが、置き換え先のメソッドはあくまで別のクラスに実装するため、いくつか対応できないことがあります。
まず、置き換え先のメソッドで、書き換え対象のクラスのインスタンスのフィールドやメソッド等にアクセスすることができません。
またstaticであっても、privateなフィールドやメソッド等にアクセスすることもできません。
このような制約がありますが、うまくこの制約をクリアできる条件ではかなりシンプルにメソッドを書き換える事が可能です。
またこれらの制約に関しても、リフレクションを使うなどのテクニックである程度解決できる可能性もありそうです。
例
例として、実際にメソッド書き換えを行ってみます。
書き換え対象としては、例示のために作ったサンプルのPackage、メソッドとします。
今回の手法を用いることで、Unity公式のPackageやGitHub等で公開されている有志のPackageのメソッドも書き換えられますが、繰り返しになりますが利用は自己責任でお願いします。
今回の実装はGitHub上で公開しているので、そちらもご参照ください。
書き換え対象
書き換え対象のPackageは下の画像のようにTestPackageという名前で用意しました。
SomeComponentの実装は以下のとおりです。
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class SomeComponent : MonoBehaviour
{
private void Start()
{
Hello();
var result = Sum(new List<int>
{
1, 2, 3, 4, 5
});
Debug.Log($"Sum={result}");
}
private static void Hello()
{
Debug.Log("Hello");
}
private static int Sum(List<int> v)
{
return v.Sum();
}
}
SomeComponentクラスは、Startの中でHelloの実行とSumを実行した結果の出力を行います。
今回の例では、このクラスのHelloとSumの書き換えを行います。
利用側
SomeComponentを利用する側として、Assetsフォルダ内にシーンを用意し、その中でSomeComponentを付けたGameObjectを配置します。
これでPlayすると、Consoleに以下のように Hello
と Sum=15
が出力されます。
asmref
続いて、メソッド書き換えの下準備となるasmrefの準備です。
asmrefの中に、置き換え先のメソッドを定義します。
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public static class ChangeUtility
{
public static void Hello()
{
Debug.Log("Hello World");
}
public static int Sum(List<int> v)
{
return v.Select(x => x * x).Sum();
}
}
ChangeUtilityというクラスに、HelloとSumというメソッドを改めて定義しています。
これはSomeComponentにあるものと同じ引数、返り値を持ちますが、中身は自由に実装できます。
Helloはログ出力の中身を Hello World
に変えており、Sumは各数値を二乗してから和を取るように変えています。
ILPostProcessor
最後に、メソッド書き換えの本体となるILPostProcessorを実装します。
画像のように、Unity.MethodChange.CodeGenというasmdefを作り、その中にクラスを3つ用意しています。
Assembly ReferencesにMono.CecilのDLLが指定されていますが、これらを利用可能にするため、manifest.jsonにcom.unity.nuget.mono-cecil
を追加しています。
{
"dependencies": {
... 略 ...
"com.unity.nuget.mono-cecil": "1.11.4"
}
}
MethodChangeILPostProcessorが本体で、実装は下記のとおりです。
(2023/4/18追記:MethodReferenceの取得方法に問題があったので修正しました。)
using System.Collections.Generic;
using System.Reflection;
using Mono.Cecil;
using Mono.Cecil.Cil;
using Unity.CompilationPipeline.Common.Diagnostics;
using Unity.CompilationPipeline.Common.ILPostProcessing;
public class MethodChangeILPostProcessor : ILPostProcessor
{
public override ILPostProcessor GetInstance() => this;
public override bool WillProcess(ICompiledAssembly compiledAssembly)
{
// TestPackageのみを対象とする
return compiledAssembly.Name == "TestPackage";
}
public override ILPostProcessResult Process(ICompiledAssembly compiledAssembly)
{
if (!WillProcess(compiledAssembly)) return null;
// コンパイル時のログを追加できるが、今回は使わない
var diagnostics = new List<DiagnosticMessage>();
// AssemblyDefinitionロード
using var assemblyDefinition
= ILPostProcessUtility.AssemblyDefinitionFor(compiledAssembly);
foreach (var typeDefinition in assemblyDefinition.MainModule.Types)
{
// SomeComponentクラスのみが対象
if (typeDefinition.Name != "SomeComponent") continue;
foreach (var methodDefinition in typeDefinition.Methods)
{
// HelloメソッドとSumメソッドを見つけたら、それに対応するMethodDefinitionを取得する
var methodName = methodDefinition.Name;
if (methodName == "Hello" || methodName == "Sum")
{
var replaceMethod = assemblyDefinition.MainModule
.GetType(typeof(ChangeUtility).FullName).Methods
.First(x => x.Name == methodName);
ReplaceMethod(methodDefinition, replaceMethod);
}
}
}
return ILPostProcessUtility.GetResult(assemblyDefinition, diagnostics);
}
/// <summary>
/// メソッドの中で別のメソッドを呼ぶことでメソッドの実装を置き換える
/// </summary>
private static void ReplaceMethod(MethodDefinition methodDefinition,
MethodDefinition replaceMethod)
{
var processor = methodDefinition.Body.GetILProcessor();
// メソッドの実装のILの先頭を取得
var first = processor.Body.Instructions[0];
// 引数ロードのILコードの追加
for (var i = 0; i < replaceMethod.Parameters.Count; i++)
{
var argNum = i;
if (replaceMethod.IsStatic == false)
{
// instanceメソッドではarg_0にthisが入るのでその分をずらす
argNum++;
}
var code = argNum switch
{
0 => OpCodes.Ldarg_0,
1 => OpCodes.Ldarg_1,
2 => OpCodes.Ldarg_2,
3 => OpCodes.Ldarg_3,
_ => OpCodes.Ldarg_S
};
var instruction = code == OpCodes.Ldarg_S
? processor.Create(code, methodDefinition.Parameters[i].Resolve())
: processor.Create(code);
processor.InsertBefore(first, instruction);
}
// メソッド呼び出しのILコードの追加
processor.InsertBefore(first, processor.Create(OpCodes.Call, replaceMethod));
// returnのILコードの追加
processor.InsertBefore(first, processor.Create(OpCodes.Ret));
}
}
ILPostProcessUtility.AssemblyDefinitionForはICompiledAssemblyからAssemblyDefinitionを取得するためのメソッドで、先述したようにECSの実装を参考にしつつ用意しています。
ReplaceMethodメソッドで、ILを編集することでメソッドの置き換えを行っています。
どのようにILを書き換えたら良いかについては、 SharpLabやRiderのIL Viewerを眺めつつなんとなく把握しました。
元のメソッドの引数で与えられた値を置き換え先のメソッドにそのまま渡すためのロードの処理を追加したあと、置き換え先のメソッドを呼び出し、すぐにreturnしています。
結果
上記のILPostProcessorの実装を加えた上で、改めてSceneをPlayすると、ログ出力の結果は以下のようになります。
出力の内容がChangeUtilityで定義したものに変化しており、メソッド書き換えがうまく機能していることが分かります。
ちなみに、UnityのEditorログを見ると、下の画像のようにコンパイル時にMethodChangeILPostProcessorが動いていることが確認できます。
おわりに
本記事では、ILPostProcessorとasmrefを使って、外部Packageのメソッドを書き換える方法を示しました。
ILPostProcessorは工夫次第で色々なことに活用でき、用法用量を守ることができれば有用な選択肢だと思います。
ただしILPostProcessorを使わなくて良いならそれに越したことはないので、色々な代替案を検討した上で利用するのが良いかと思います。