lenec ru

← все посты

IL2CPP vs Mono в Unity: что выбрать и где какие подводные камни

14K

Каждый раз перед релизом игры встаёт банальный вопрос: оставлять 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

  1. Сделай билд, проверь, что вообще собирается.
  2. Запусти на target-устройстве, проверь startup.
  3. Прогоняй основные сценарии. Особое внимание — серилизация (save/load), сетевой код, integration с native плагинами.
  4. Если есть exceptions — смотри stack trace'ы внимательно, иногда AOT падает там, где Mono простил бы.
  5. Добавь link.xml для типов, которые используются через reflection.
  6. Прогоняй 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 мелких багов в один пожар.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.