IL2CPP vs Mono в Unity: что выбрать и где какие подводные камни
Каждый раз перед релизом игры встаёт банальный вопрос: оставлять Mono или переключать на IL2CPP. На мобиле выбора нет (Apple требует IL2CPP), на ПК выбор есть, и многие выбирают по привычке. Я поработал с обоими бэкендами, и расскажу честно, где IL2CPP реально окупается, где обходится дороже, и какие баги ты получишь после переключения.
Опираюсь на Unity 6 (6000.x), IL2CPP с .NET 8 backend, Mono на legacy 2.x. Если у тебя ещё .NET Standard 2.0 — половина приёмов будет та же, но детали могут отличаться.
Чем они отличаются на пальцах
Mono — JIT-компилятор. Твой C# превращается в IL (Intermediate Language), и при запуске игры Mono на лету компилирует IL в нативный код. Быстрая сборка, но runtime overhead на старте и периодически в горячих местах.
IL2CPP — AOT-компилятор. Твой C# через IL переводится в C++, потом C++ компилируется через clang/MSVC в нативный бинарник. Медленный билд, быстрый рантайм. Никаких runtime-сюрпризов, всё статично.
Главное следствие: IL2CPP не поддерживает динамическую загрузку assembly (Assembly.Load), reflection-emitted code, и dynamic-типы. Всё, что собирается в рантайме — не работает.
Когда IL2CPP — единственный вариант
- iOS — Apple запрещает JIT на App Store. Только AOT, только IL2CPP.
- Консоли — все требуют AOT по политикам платформы.
- Web (WebAssembly) — Unity использует IL2CPP под капотом.
Если твой target включает iOS или консоль, выбор сделан за тебя. Дальше решаешь только, где собирать в IL2CPP — параллельно везде или только на тех платформах, где требуется.
Когда выбор есть: Android, Windows, Mac, Linux
Android — обычно IL2CPP. Mono работает, но больше памяти (Mono runtime на устройстве — десятки мегабайт), медленнее, и Google уже несколько лет требует 64-bit нативный код, что в Mono — головная боль.
На Steam (Windows/Mac/Linux) — спорно. По умолчанию я ставлю IL2CPP, особенно если игра тяжёлая. Аргументы:
- Performance: на Mono есть JIT-jitter (паузы на компиляцию горячих методов в первые секунды), на IL2CPP — нет.
- Memory: меньше overhead.
- Anti-tamper: IL2CPP делает декомпиляцию сложнее. Не криптография, но порог войны для читеров повышается.
- Поведение consistent с iOS/консольными билдами. Меньше «у нас на ПК работает, на консоли — нет».
Аргументы за Mono:
- Сборка в разы быстрее. Полный билд игры на IL2CPP — десятки минут, на Mono — пара минут.
- Modding: моды чаще писаны под Mono (через Cecil, Harmony). На IL2CPP modding возможен, но сложнее.
- Hot-reload: Mono поддерживает relod assembly через editor (с натяжкой). IL2CPP — нет.
- Дебаг проще: stack trace'ы понятнее, exception'ы детальнее.
Если игра — modder-friendly (Stardew Valley, BepInEx-friendly), часто разработчики оставляют Mono на Steam, чтобы не закрывать сообщество.
Производительность: что даёт IL2CPP
Цифры, которые я мерил:
- На average game logic: разница 5–15%. IL2CPP быстрее за счёт лучших оптимизаций C++ компилятора.
- На горячих циклах: разница 20–40%. Опять же, плюс к Mono нет JIT-warmup на первом запуске.
- На allocation-heavy коде: примерно одинаково. GC одинаковый и там, и там.
- Startup: IL2CPP-билд стартует на 10–30% быстрее, потому что нет JIT-фазы.
Если ты используешь Burst, IL2CPP даёт меньше выигрыша на тех конкретных Job'ах (Burst и так нативно компилируется). Но остальной код (UI, gameplay) всё равно ускорится.
Сборка и build time
IL2CPP-билд медленный. На моём проекте (среднем) сборка под Android занимала 18 минут, под Windows IL2CPP — 12 минут, Windows Mono — 1.5 минуты. iOS IL2CPP с подписью и Xcode-build'ом — 25 минут.
Что помогает:
- Incremental build. Unity и IL2CPP кэшируют C++-код. Если ты не менял код, второй билд быстрее. Не очищай Library без необходимости.
- Build target switch. Переключение между Android и Windows перетряхивает Library. На двух машинах с разными targets экономишь.
- Cache server. Unity Accelerator кэширует импортированные ассеты. Не ускоряет IL2CPP, но ускоряет общий build.
- Параллельная компиляция. В Player Settings → Other Settings → IL2CPP Code Generation поставь Faster (smaller) builds в dev, Faster runtime в release.
Reflection и stripping: главная боль
IL2CPP делает code stripping — удаляет неиспользуемый код. Если у тебя где-то reflection (Activator.CreateInstance, GetMethod) — IL2CPP может выкинуть нужный класс или метод, и в рантайме будет «MissingMethodException».
Решения:
link.xml
Файл в проекте, который говорит IL2CPP не стрипить определённые классы.
<linker>
<assembly fullname="MyGame">
<type fullname="MyGame.Save.SaveData" preserve="all"/>
<type fullname="MyGame.Save.PlayerData" preserve="fields"/>
</assembly>
<assembly fullname="Newtonsoft.Json" preserve="all"/>
</linker>
Или атрибут на сам тип:
[UnityEngine.Scripting.Preserve]
public class SaveData { ... }
Newtonsoft.Json и AOT
Newtonsoft.Json при сериализации использует reflection. На IL2CPP без подсказок он может упасть на сложных типах (Dictionary с custom keys, generic'и). Решения:
- Помечать типы
[JsonObject], поля —[JsonProperty]. Помогает Newtonsoft найти их без полной reflection-итерации. - В крайних случаях — кастомные конвертеры на каждый тип.
System.Text.Json и Source Generators
В .NET 8 — современная альтернатива. Source generators генерируют сериализационный код на этапе компиляции, и reflection не используется.
[JsonSerializable(typeof(SaveData))]
[JsonSerializable(typeof(PlayerData))]
public partial class GameJsonContext : JsonSerializerContext { }
// в коде
var json = JsonSerializer.Serialize(saveData, GameJsonContext.Default.SaveData);
На IL2CPP это работает безотказно. Минус — нужно явно перечислять все типы.
Обработка исключений
В IL2CPP исключения дороже, чем в Mono. Каждый throw — полная разворачивание стэка с дополнительной работой. Если в горячем коде ты использовал throw как control flow — IL2CPP сделает это заметно медленнее.
Совет: в hot loop не throw'ай. Возвращай Result<T>, bool TryDo, что угодно — но не exception.
Generic'и и code bloat
IL2CPP инстанциирует generic-типы статически. Каждое List<float>, List<Vector3>, List<Enemy> — отдельный сгенерированный класс. Если ты перебарщиваешь с generic'ами — размер бинарника растёт.
Особенно болезненно с многократно вложенными generic'ами: Dictionary<int, List<Tuple<A, B>>>. На больших проектах ты замечаешь это в exe-size — он может уйти за десятки мегабайт без ассетов.
P/Invoke и native plugins
В IL2CPP вызов native-плагинов тоньше, чем в Mono. Если у тебя статически линкуемый плагин (__Internal как entrypoint) — на iOS работает только так. На Android — динамические .so, как в Mono. На Windows IL2CPP P/Invoke с .dll работает так же, как в Mono.
Для общих плагинов (Steam, FMOD, Discord) — современные SDK обычно поддерживают и IL2CPP, и Mono. Старые могут падать. Смотри changelog SDK перед обновлением Unity-версии.
Дебаг IL2CPP-сборки
Дебажить IL2CPP сложнее. На устройстве — через Visual Studio Code или Visual Studio с Unity Tools. Stack trace'ы менее читаемые: видишь C++-функции, иногда без mapping'а в C#-код.
Что помогает:
- В Player Settings включить Development Build и Script Debugging. Это включает символы в IL2CPP.
- Использовать il2cpp.exe --debugger-info в кастомных билд-скриптах для подготовки более полных символов.
- На iOS — Xcode-debugger для нативной части и Unity-debugger для managed.
Если у тебя баг только в IL2CPP-сборке (а в Mono нет) — обычно это reflection или stripping. Начни с link.xml, потом смотри изменения в коде, связанные с runtime-генерацией.
API Compatibility Level
В Player Settings есть API Compatibility Level: .NET Standard 2.1 или .NET Framework (старый профиль) или .NET 8 в Unity 6.
Нюансы:
- В Unity 6 предпочтителен .NET 8. Современные API, source generators, System.Text.Json, async/await первого класса.
- На .NET Standard 2.1 ассет-стора больше совместимостей, но устаревает.
- На .NET Framework — только legacy.
Я ставлю .NET 8 на новых проектах. Если приходится работать со старым проектом — .NET Standard 2.1.
Stripping Level
В Player Settings → Optimization → Managed Stripping Level:
- Disabled — никакого стрипа. Бинарь жирный, но всё работает.
- Low — стрипит unused assembly, не трогает ваш код.
- Medium — стрипит unused типы и методы.
- High — агрессивный стрип. Может сломать reflection-зависимый код.
Я ставлю Low по умолчанию, в release под мобилку — Medium с link.xml. High нужен только для очень жёсткой оптимизации размера, и почти всегда ломает что-то.
Чек-лист переключения с Mono на IL2CPP
- Сделай билд, проверь, что вообще собирается.
- Запусти на target-устройстве, проверь startup.
- Прогоняй основные сценарии. Особое внимание — серилизация (save/load), сетевой код, integration с native плагинами.
- Если есть exceptions — смотри stack trace'ы внимательно, иногда AOT падает там, где Mono простил бы.
- Добавь link.xml для типов, которые используются через reflection.
- Прогоняй performance тесты. На IL2CPP может быть и быстрее, и медленнее в специфических местах — измеряй.
Что я бы выбрал в 2026
На любой новый проект, нацеленный на мобилки или консоли — IL2CPP сразу. Mono даже как dev-backend для итерации иногда не успеваю настроить — IL2CPP-билд на CI-сервере покрывает.
На Steam-only проект с modding-сообществом — Mono. Сборки быстрее, modder'ам проще.
На любом другом ПК-проекте — IL2CPP по умолчанию. Performance, security, consistency.
Что почитать
Unity Manual — отдельный раздел по IL2CPP, включая внутренности code generation. Блог Unity — старые посты Йосефа Мантура (один из создателей IL2CPP) описывают мотивацию и архитектуру. Стоит понять, как и почему этот бэкенд устроен — это спасает от мистических багов.
Главное правило, которое я для себя выработал: переключай бэкенд рано в проекте, не за неделю до релиза. Чем раньше ловишь IL2CPP-специфичные баги, тем дешевле они стоят. Откладывание превращает 5 мелких багов в один пожар.