【Unity】ILPostProcessor でC#コンパイル後に処理を挿入する

UnityでILPostProcessor でC#コンパイル後に処理を挿入する方法です。

Unity 2022.3.28f1

ILPostProcessorとは?

.NET 環境でコードを書くと、コンパイルが走って中間言語が生成されます。
この仕組みについては以下の記事にまとめているので、必要に応じて参照してください。

light11.hatenadiary.com

ILPostProcessor を使うと、このコンパイル後の中間言語(IL)に手を加えて、C#の段階では存在しない処理を追加することができます。

本記事ではこれを使って簡単な処理を挿入する実装を行います。

なお、ILPostProcessor については私が所属している組織の技術ブログにも連載の形でまとめられていますので、詳しく知りたい方はこちらも参照してください。

blog.sge-coretech.com

処理対象のコードを書く

ではまず処理を挿入する対象のコードを作ります。

ILPostProcessor では処理するアセンブリを指定できるので、まずExample という名前の asmdef を作り、その中にスクリプトを作っていきます。
(Assembly-CSharp も処理できるので必ずしも asmdef を作る必要はありません)

スクリプトは以下の通り作成します。

using UnityEngine;

public class EntryPoint : MonoBehaviour
{
    private void Start()
    {
        var addService = new AddService();
        var subtractService = new SubtractService();
        const int source1 = 1;
        const int source2 = 2;
        var resultAdd = addService.Add(source1, source2);
        var resultSubtract = subtractService.Subtract(source1, source2);
        Debug.Log($"Add Result: {resultAdd}");
        Debug.Log($"Subtract Result: {resultSubtract}");
    }
}

public class AddService
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

public class SubtractService
{
    public int Subtract(int a, int b)
    {
        return a - b;
    }
}

これを適当な GameObject にアタッチしてシーンを再生すると、以下のログが出力されることを確認できます。

ログ

必要なパッケージをインストールする

処理対象のコードが作成できたので、次に ILPostProcessor を作成する準備をしていきます。
まず必要なパッケージをインストールします。

Window > Package Manager から Package Manager を開き、左上の+ボタン > Add package by name… を選択します。

パッケージをインストール

入力フィールドが現れるので、com.unity.nuget.mono-cecil と入力し、Addボタンを押下します。
これでインストールは完了です。

ILPostProcessor用のasmdefを作る

次に ILPostProcessor 用の asmdef を作ります。
ILPostProcessor 用のアセンブリ名は Unity. で始まり .CodeGen で終わる必要があるので、この命名規則に沿って作成します。
今回は Unity.Example.CodeGen としました。

asmdef

次に、asmdef のインスペクタから以下の通り設定します。

  • Auto Referenced のチェックを外す
  • Override Reference にチェックを入れる
  • Root Namespace に任意の名前空間名を入力(任意)
  • Assembly Referencesに以下を追加
  • PlatformsをEditorだけに設定

インポート設定

ILPostProcessorを書く

asmdef の作成が完了したら、そのフォルダに ILPostProcessor のためのスクリプトを以下の通り作成します。

using System.Collections.Generic;
using System.IO;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
using Unity.CompilationPipeline.Common.ILPostProcessing;
using UnityEngine;

namespace Example.CodeGen
{
    public sealed class UseCaseILPostProcessor : ILPostProcessor
    {
        public override ILPostProcessor GetInstance()
        {
            // 自身をnewして返す
            return new UseCaseILPostProcessor();
        }

        public override bool WillProcess(ICompiledAssembly compiledAssembly)
        {
            // 処理をするアセンブリをここで絞り込む
            // 今回は「Example」という名前のアセンブリだけを対象にする
            return compiledAssembly.Name == "Example";
        }

        public override ILPostProcessResult Process(ICompiledAssembly compiledAssembly)
        {
            // アセンブリの読み込み(ボイラープレート)
            var assemblyDefinition = ReadAssemblyDefinition(compiledAssembly);

            // アセンブリの処理
            // この ProcessAssembly 内で目的の処理を行う
            var modified = ProcessAssembly(assemblyDefinition);
            if (!modified)
            {
                return new ILPostProcessResult(null);
            }

            // アセンブリの書き込み(ボイラープレート)
            using var peStream = new MemoryStream();
            using var pdbStream = new MemoryStream();
            var writerParameters = new WriterParameters
            {
                WriteSymbols = true,
                SymbolStream = pdbStream,
                SymbolWriterProvider = new PortablePdbWriterProvider()
            };
            assemblyDefinition.Write(peStream, writerParameters);

            return new ILPostProcessResult(new InMemoryAssembly(peStream.ToArray(), pdbStream.ToArray()));
        }

        private bool ProcessAssembly(AssemblyDefinition assembly)
        {
            var modified = false;
            foreach (var module in assembly.Modules)
            {
                foreach (var type in module.Types)
                {
                    if (!type.Name.EndsWith("Service"))
                    {
                        continue;
                    }

                    foreach (var method in type.Methods)
                    {
                        // publicメソッドだけを対象にする
                        if (!method.IsPublic || method.IsConstructor || !method.HasBody || method.IsAbstract ||
                            method.IsPInvokeImpl)
                        {
                            continue;
                        }

                        // メソッドの最初に、以下の一行を挿入する
                        // UnityEngine.Debug.Log(Service executed: [クラスの型].[メソッド名]())
                        var ilProcessor = method.Body.GetILProcessor();
                        var firstInstruction = method.Body.Instructions.First();
                        var debugType = module.ImportReference(typeof(Debug));
                        var logMethodDefinition = debugType.Resolve()
                            .Methods.First(m => m.Name == "Log" && m.Parameters.Count == 1 &&
                                                m.Parameters[0].ParameterType.FullName == "System.Object");
                        var debugLogMethod = module.ImportReference(logMethodDefinition);

                        var logMessage = $"Service executed: {type.Name}.{method.Name}()";
                        var logInstruction = ilProcessor.Create(OpCodes.Ldstr, logMessage);
                        var callInstruction = ilProcessor.Create(OpCodes.Call, debugLogMethod);
                        ilProcessor.InsertBefore(firstInstruction, logInstruction);
                        ilProcessor.InsertBefore(firstInstruction, callInstruction);

                        modified = true;
                    }
                }
            }

            return modified;
        }

        private AssemblyDefinition ReadAssemblyDefinition(ICompiledAssembly compiledAssembly)
        {
            var resolver = new ILPostProcessorAssemblyResolver(compiledAssembly.References);
            var readerParameters = new ReaderParameters
            {
                SymbolStream = new MemoryStream(compiledAssembly.InMemoryAssembly.PdbData.ToArray()),
                SymbolReaderProvider = new PortablePdbReaderProvider(),
                ReadingMode = ReadingMode.Immediate,
                AssemblyResolver = resolver
            };
            var peStream = new MemoryStream(compiledAssembly.InMemoryAssembly.PeData.ToArray());
            return AssemblyDefinition.ReadAssembly(peStream, readerParameters);
        }
        
        /// <summary>
        ///     <para>UnityのILPostProcessor用のAssemblyResolver</para>
        ///     <para>与えられたパスを元にアセンブリを読み込む</para>
        /// </summary>
        public sealed class ILPostProcessorAssemblyResolver : IAssemblyResolver
        {
            private readonly string[] _assemblyReferences;
            private readonly Dictionary<string, AssemblyDefinition> _cache = new();

            public ILPostProcessorAssemblyResolver(string[] assemblyReferences)
            {
                _assemblyReferences = assemblyReferences;
            }

            #region IAssemblyResolver Members

            public AssemblyDefinition Resolve(AssemblyNameReference name)
            {
                return Resolve(name, new ReaderParameters());
            }

            public AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters)
            {
                if (_cache.TryGetValue(name.FullName, out var assembly))
                {
                    return assembly;
                }

                var assemblyPath = _assemblyReferences.FirstOrDefault(r => Path.GetFileName(r) == $"{name.Name}.dll");
                if (assemblyPath == null)
                {
                    throw new FileNotFoundException($"Assembly '{name.Name}' not found.");
                }

                parameters.AssemblyResolver = this;
                assembly = AssemblyDefinition.ReadAssembly(assemblyPath, parameters);
                _cache[name.FullName] = assembly;
                return assembly;
            }

            public void Dispose()
            {
                foreach (var assembly in _cache.Values)
                {
                    assembly.Dispose();
                }

                _cache.Clear();
            }

            #endregion
        }
    }
}

長いですが、コメントに書いてある通りほぼボイラープレートです(細かい挙動を変えたいときにはその部分も編集する必要はありますが)。

重要なのは ProcessAssembly メソッドで、ここでアセンブリの中身を書き換えています。

今回はメソッドのボディの先頭に、Debug.Log でクラス名とメソッド名を出力するコードを挿入しています。

実行確認

ILPostProcessor を作成するとコンパイルが走り、既存コードに処理が入ります。
今回の例ではAddServiceクラスとSubtractServiceのメソッドに処理が追加されます。

シーンを再生すると、以下のようにログが追加されていることを確認できます。

実行結果

関連

light11.hatenadiary.com

参考

blog.sge-coretech.com