Unityでマルチプラットフォーム(iOS、Android、WebGL、コンソールなど)向けにビルドを行う際、描画パフォーマンスやセキュリティの観点から IL2CPP (Intermediate Language to C++) スクリプティングバックエンドの使用が推奨、あるいは必須となっています。IL2CPPはC#コードを中間言語(IL)経由で高度に最適化されたC++コードへとコンパイルするため、実行速度が向上し、逆コンパイルも困難になります。

しかし、エディタ上では何の問題もなく動作していたプロジェクトが、実機用のIL2CPPビルドを行った途端、特定の処理(JsonUtilityやNewtonsoft.Jsonによるシリアライズ、DIコンテナの使用、インターフェースを介したジェネリックメソッド呼び出しなど)の実行時に 「ExecutionEngineException: Attempting to call method... for which no ahead of time (AOT) code was generated」 というエラーで強制終了(クラッシュ)する致命的なトラブルが多発します。

本記事では、このIL2CPP環境特有のAOT(Ahead-Of-Time)コンパイルおよびコードストリッピングの仕組みをコードレベルで解明し、link.xml によるアセンブリ保護と、値型に対するAOTヒントコードを用いた事前生成による根本解決手法を解説します。

1. 根本原因:Mono(JIT)とIL2CPP(AOT)のコード生成の違い

UnityエディタやPC向けのスタンドアロンビルド(Monoモード時)では、JIT (Just-In-Time) コンパイル が採用されています。JITコンパイルでは、実行時にプログラムが必要とした時点でコードがその場でコンパイルされます。そのため、実行時に動的にリフレクションで型を検索したり、初めて呼び出されるジェネリック型のインスタンスを作成したりすることが可能です。

一方、IL2CPP環境では AOT (Ahead-Of-Time) コンパイル が使用されます。AOTコンパイルは、ビルド時にすべてのC#コードを事前にC++に変換・コンパイルします。したがって、実行時に新しいコードや新しいジェネリック型をその場で生成することは不可能です。

【Mono (JITコンパイル)】
  C# ILコード ──(実行時コンパイル)──> マシン語 (いつでも新しい型やメソッドを生成可能)

【IL2CPP (AOTコンパイル)】
  C# ILコード ──(ビルド時コンパイル)──> C++コード ──> 静的バイナリ (実行時はコンパイル不可)
  ※ビルド時に「必要」と認識されなかったジェネリック引数の組み合わせは生成されず、実行時に呼ぶとクラッシュ!
  

図:JITコンパイルとAOTコンパイルにおけるコード生成タイミングの違い

これにより、以下の2つのAOT特有の問題が発生します。

  1. Managed Code Stripping (マネージドコードストリッピング): ビルドサイズ削減のため、Unityは静的解析を行い「一度も呼び出されていない」と判断したコード(クラス、メソッド、プロパティ)を削除します。リフレクションを介して文字列で呼び出すコードや、外部JSONのパース時に暗黙的に呼び出される引数なしコンストラクタなどは、コンパイラが「未使用」と判定して削除(ストリップ)してしまいます。
  2. AOTジェネリックコードの不足: 一般メソッド(例:void Process<T>(T data))やジェネリッククラスは、型パラメータ T に応じてC++コードがコンパイル時に生成される必要があります。参照型(クラス)の場合は、ポインタのメモリサイズが同一であるためコードが共有されます。しかし、値型(構造体やプリミティブ型)は型ごとにメモリサイズやレイアウトが異なるため、型ごとに個別のC++コード生成(実体化)が必要です。コンパイル時にコード上に静的な呼び出しが存在しない値型の組み合わせはC++コードが作られず、実行時に呼び出すと ExecutionEngineException が発生します。

2. 対策1:link.xmlによるコードストリッピングの防止

リフレクションやJSONライブラリ、外部アセットなどが暗黙的に使用するクラスやコンストラクタが削除されるのを防ぐには、プロジェクトの Assets フォルダ(あるいは任意の asmdef フォルダ)の直下に link.xml ファイルを配置します。これにより、指定したアセンブリや型がビルド時にストリップされるのを明示的に保護できます。

link.xml の構成例

<linker>
  <!-- 1. アセンブリ全体をストリップから保護する -->
  <assembly fullname="MyCustomLibrary" preserve="all" />

  <!-- 2. 特定のネームスペースやクラスのみを詳細に保護する -->
  <assembly fullname="Assembly-CSharp">
    <!-- クラスとそのすべてのメンバーを保護 -->
    <type fullname="MyProject.Network.PacketData" preserve="all" />
    
    <!-- 型のみを保護し、使用されていないメソッドはストリップを許可 -->
    <type fullname="MyProject.UI.DynamicWindow" preserve="nothing">
      <!-- コンストラクタだけはリフレクション用に残す -->
      <method signature="System.Void .ctor()" />
    </type>
  </assembly>
</linker>

JSONシリアライザでデシリアライズ対象となるDTO(Data Transfer Object)クラスは、C#コード上で直接コンストラクタが呼ばれないためストリップ対象になりやすいです。それらのDTOクラスが属するネームスペースや型を link.xml に登録することで、実行時のNullReferenceExceptionやJsonUtilityのパース失敗を防ぐことができます。

3. 対策2:値型ジェネリックのためのAOTヒントコード(事前宣言)

link.xml はコードの「ストリッピング」を防ぐだけであり、値型に対するジェネリックメソッドのC++コード(実体)を新しく生成させることはできません。
例えば、インターフェースやオブジェクトを経由して、実行時に動的に自作構造体 MyStructProcess<T>() に渡そうとする場合、AOTコンパイラが事前に Process<MyStruct>() 用のC++コードを作っておく必要があります。これがないと、どれだけ link.xml に書いていても ExecutionEngineException でクラッシュします。

これを解決するためには、「AOTヒント」と呼ばれる、コンパイラに実体化を強制するためのダミーコードをプロジェクト内のどこかに記述します。

AOTヒントコードの実装例

using UnityEngine;

// ダミーコードを置くための静的クラスを用意(呼び出す必要はありません)
public static class AOTPreserveHelper
{
    // AOTコンパイラにコードの生成を強制するためのダミーメソッド
    private static void EnsureAOTInstances()
    {
        // 実際には実行されない条件で囲むことで、実行時の負荷をゼロにする
        if (neverTrueCondition())
        {
            var processor = new DataProcessor();
            
            // 自作の構造体(値型)に対してジェネリック呼び出しを記述しておく
            // これにより、IL2CPPビルド時にこの組み合わせのC++コードがコンパイルされます
            processor.Process<MyCustomStruct>(default(MyCustomStruct));
            processor.Process<Vector2Int>(default(Vector2Int));
            processor.Process<int>(default(int));
        }
    }

    private static bool neverTrueCondition()
    {
        // 常にfalseを返すが、コンパイラの静的解析に「デッドコード」として
        // 完全に最適化・消去されないようにする設計
        return Random.value == -1f;
    }
}

// 対象となる構造体とクラスの定義
public struct MyCustomStruct
{
    public int Id;
    public float Weight;
}

public class DataProcessor
{
    public void Process<T>(T data) where T : struct
    {
        Debug.Log($"Processing type {typeof(T)}: {data}");
    }
}

このように、「ダミーの静的メソッド内で、対象の値型を引数に指定してジェネリックメソッドをダミー呼び出しする」ことで、AOTコンパイラに対してコンパイル時に必要な実体化コードを出力させることができます。

4. その他の予防策と設計アプローチ

(1) Newtonsoft.Json などの外部アセットの選定

リフレクションを多用するライブラリはIL2CPPと非常に相性が悪いです。例えば Newtonsoft.Json (Json.NET) を使用する場合、Unity向けにフォークされてAOT対応が行われている Json.NET for Unity や、Unity公式 of JsonUtility、あるいはC#の最新機能(Source Generators)を利用してビルド時に静的にシリアライズコードを生成するライブラリ(例: MemoryPackMessagePack for C#)への切り替えを検討してください。これらはAOTアロケーションフリーであり、パフォーマンス的にも非常に優れています。

(2) 開発初期からの定期的な実機ビルド

IL2CPPのAOTエラーは、エディタ上で動作確認しているだけでは絶対に検知できません。「ビルドパイプラインが通るか」および「実機でクラッシュしないか」を、開発の初期フェーズからCI/CD環境などを通じて定期的に(毎日またはマイルストーンごとに)確認する体制を構築することが、最も効果的な予防策です。

5. まとめ

  1. IL2CPPは実行時の動的なコード生成ができない: ビルド時にすべてのコードがC++化されるAOT方式のため、リフレクションやジェネリックが制限されます。
  2. link.xmlで未使用コードのストリップを防ぐ: リフレクションで呼び出されるクラスやコンストラクタがコンパイラに消されないよう保護します。
  3. 値型ジェネリックはAOTヒントで事前実体化する: link.xml では解決できない「値型」を型引数とするジェネリックコードの不足には、ダミーコードを書いてビルド時に実体化を強制します。
  4. AOTフレンドリーなライブラリやSource Generatorへの移行: リフレクション依存から静的ジェネレータ(MemoryPack等)への移行が、長期的なバグ防止と速度向上の両面でベストプラクティスです。