Перейти к основному содержимому

Совместимость с IL2CPP

Valkarn Tasks спроектирован для корректной работы под IL2CPP с самого начала. На этой странице описаны все принятые меры и то, что вам нужно делать — или не делать — для безопасного выпуска на платформах IL2CPP, таких как iOS, WebGL и консоли.


Почему IL2CPP требует особого внимания

IL2CPP преобразует C# IL в исходный код C++, а затем компилирует его с помощью нативного компилятора. Два аспекта этого конвейера актуальны для асинхронной библиотеки:

  1. Обрезка кода. Обрезчик управляемого кода Unity (использующий компоновщик IL2CPP) удаляет типы, методы и поля, на которые нет ссылок в статически анализируемом графе вызовов. Типы, доступные только через диспетчеризацию интерфейсов, разделяемые обобщения или рефлексию — в том числе пулируемые классы promise и реализации ISource — могут быть молча удалены.

  2. Разделение обобщений. IL2CPP не генерирует отдельный нативный бинарный файл для каждого экземпляра обобщения. Вместо этого он разделяет код между ссылочными типами и использует конкретные экземпляры для типов-значений. Это может скрывать ошибки при разработке (Mono), которые проявляются только в сборках IL2CPP.


Файл link.xml

Основная защита от обрезки — файл link.xml, расположенный по адресу:

Runtime/link.xml

Его содержимое:

<linker>
<!-- Preserve all types in the Valkarn.Tasks runtime assembly.
IL2CPP code stripping can remove internal types accessed only via
interface dispatch, generic sharing, or reflection (e.g. promise
classes, pooled runners, ISource implementations). -->
<assembly fullname="UnaPartidaMas.Valkarn.Tasks" preserve="all"/>
<assembly fullname="UnaPartidaMas.Valkarn.Tasks.Burst" preserve="all"/>
<assembly fullname="UnaPartidaMas.Valkarn.Tasks.ECS" preserve="all"/>
</linker>

preserve="all" указывает компоновщику сохранить каждый тип, метод, поле и конструктор в этих сборках независимо от того, что находит статический анализ. Это наиболее безопасная настройка для библиотеки, внутренние типы которой доступны через обобщённые параметры, которые обрезчик не может отследить.

Unity автоматически обнаруживает и применяет файлы link.xml, когда они помещены в папку пакета, импортированного в проект. Никаких ручных шагов не требуется.

Если вы форкаете или встраиваете исходный код вместо использования пакета, скопируйте link.xml в папку рядом с Resources или разместите его в любом месте, где Unity его найдёт, согласно документации Unity по обрезке управляемого кода.


Внутренние типы, которые были бы удалены без link.xml

Следующие категории внутренних типов представляют основной риск обрезки:

Пулируемые классы promise (реализации ISource)

Каждый комбинатор и тип задержки создаёт пулируемый класс promise, реализующий ValkarnTask.ISource или ValkarnTask.ISource<T>:

  • AsyncValkarnTaskRunner<TStateMachine> — пулируемый запускатель конечного автомата, по одному на каждый async-метод (специализированный по TStateMachine)
  • WhenAllPromise<T1, T2>, WhenAllArrayPromise<T>, WhenAllVoidPromise2, WhenAllVoidPromise3, WhenAllVoidArrayPromise
  • DeltaTimeDelayPromise, UnscaledDeltaTimeDelayPromise, RealtimeDelayPromise
  • CanceledSource, CanceledSource<T>, ExceptionSource, ExceptionSource<T>, NeverSource
  • Внутренние компоненты каналов: BoundedChannel<T>, UnboundedChannel<T> и их типы читателей/писателей

Эти типы создаются через обобщённые фабричные методы (ValkarnTaskPool<T>.GetOrCreate). Граф статических вызовов начинается с вызова обобщённого метода и не может надёжно отследить все конкретные экземпляры T, поэтому без link.xml любой из этих типов может быть удалён.

ValkarnTaskPool<T>

internal sealed class ValkarnTaskPool<T> : IPoolInfo where T : class, IPoolNode<T>

Пул является обобщённым по своему типу элемента. Каждый класс promise имеет своё собственное статическое поле пула. Если данный тип promise не используется в сцене, и пул, и класс promise могут быть удалены вместе.

ValkarnTaskCompletionCore<T>

Внутреннее ядро завершения — это тип-значение, используемый внутри каждого promise. Оно хранит коллбэк продолжения, токен и состояние завершения. На него никогда нет ссылки по имени извне библиотеки.


Ограничения обобщённых типов и IL2CPP

Построитель пользовательского async-метода является обобщённым по типу конечного автомата:

public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine

А запускатель является обобщённым по конечному автомату:

internal sealed class AsyncValkarnTaskRunner<TStateMachine>
: IStateMachineRunnerPromise, IPoolNode<AsyncValkarnTaskRunner<TStateMachine>>
where TStateMachine : IAsyncStateMachine

В IL2CPP для каждого отдельного TStateMachine генерируется новый конкретный тип (поскольку конечные автоматы являются структурами, требующими полной специализации обобщений). Это означает:

  • Каждый async ValkarnTask-метод в вашем проекте порождает отдельный нативный тип AsyncValkarnTaskRunner<TYourStateMachine>.
  • Если обрезчик удалит AsyncValkarnTaskRunner<T> прежде, чем увидит все экземпляры, некоторые async-методы могут вызвать сбой во время выполнения.
  • preserve="all" в link.xml предотвращает это.

Уровень обрезки управляемого кода

Уровень обрезки Unity устанавливается в Player Settings → Other Settings → Managed Stripping Level.

УровеньСтатус с Valkarn Tasks
DisabledБезопасно. Обрезка не происходит.
LowБезопасно. Удаляются только явно неиспользуемые сборки.
MediumБезопасно с link.xml. Включённый link.xml сохраняет все типы среды выполнения.
HighБезопасно с link.xml. То же покрытие; link.xml является защитным слоем.

Все уровни обрезки вплоть до High включительно безопасны при наличии файла link.xml и его применении. Не удаляйте и не изменяйте link.xml без понимания того, какие внутренние типы вы подвергаете обрезке.


Использование атрибута [Preserve]

Поиск по исходному коду Runtime не находит атрибута [UnityEngine.Scripting.Preserve], применённого к отдельным членам. Выбранный подход — сохранение на уровне сборки через link.xml, а не атрибуты для отдельных членов. Это намеренно:

  • Атрибут [Preserve] для каждого члена требует аннотирования каждого пулируемого класса по отдельности, включая все будущие добавления.
  • preserve="all" на уровне сборки в link.xml проще, менее подвержен ошибкам и гарантированно охватывает типы, добавленные в будущих версиях.

Если вам нужно интегрировать Valkarn Tasks в больший файл link.xml вместо использования поставляемого с пакетом, эквивалентная директива:

<assembly fullname="UnaPartidaMas.Valkarn.Tasks" preserve="all"/>

Дизайн пула с учётом IL2CPP

Объектный пул (ValkarnTaskPool<T>) содержит явное примечание в своём комментарии документации:

IL2CPP-first: main thread operations use zero atomics.

Пул использует два пути доступа:

  • Быстрый путь (основной поток): одиночное поле fastItem читается/записывается простым доступом к полю — без операций Volatile или Interlocked. Это позволяет избежать накладных расходов атомарных операций в основном потоке, где IL2CPP не всегда может их оптимизировать.
  • Путь переполнения (любой поток): безблокировочный стек Трайбера с Interlocked.CompareExchange для корректности при параллельном доступе из фоновых потоков (например, ValkarnTask.RunOnThreadPool).

Идентификатор основного потока хранится в поле volatile int в ValkarnTaskPoolShared.MainThreadId, публикуемом один раз при запуске через RuntimeInitializeOnLoadMethod. IL2CPP корректно обрабатывает поля volatile.


Особенности консольных платформ

Консольные платформы (PlayStation, Xbox, Nintendo Switch) используют IL2CPP исключительно. Применяется следующее:

  • Покрытие link.xml то же, что и для других целей IL2CPP. Дополнительного сохранения, специфичного для консолей, не требуется.
  • [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] корректно срабатывает на всех платформах, поддерживаемых Unity для сертификации консолей.
  • Операции Interlocked и Volatile поддерживаются на всех консольных целях IL2CPP. Стек Трайбера в пуле безопасен.
  • Разделение обобщений для экземпляров TStateMachine-структур применяется на консолях. Каждый async ValkarnTask-метод генерирует свой нативный тип — это ожидаемое поведение, обрабатываемое корректно.
  • Если консольная платформа применяет дополнительные требования AOT, убедитесь, что ваш link.xml корректно подхвачен, проверив лог сборки на наличие «Stripping assembly: UnaPartidaMas.Valkarn.Tasks». Если сборка обрезана, link.xml не был обнаружен.

Проверка того, что ничего не было удалено

После сборки для цели IL2CPP:

  1. Проверьте лог сборки. Unity выводит решения об обрезке. Найдите UnaPartidaMas.Valkarn.Tasks. Если вы видите сообщения Stripping class для внутренних типов Valkarn, link.xml не был применён.

  2. Запустите дымовой тест. Минимальный тест, выполняющий ValkarnTask.Delay, WhenAll и пользовательский ValkarnTaskCompletionSource, создаст экземпляры наиболее часто удаляемых типов. Если эти три сценария переживают сборку, ядро цело.

  3. Включайте «Strip Engine Code» выборочно. Если необходимо использовать High-обрезку и нельзя использовать поставляемый link.xml, включайте обрезку постепенно и запускайте тесты после каждого увеличения уровня.

  4. Сборка IL2CPP с включённым Development Build. Development-сборки включают дополнительную диагностику. Если тип отсутствует, среда выполнения сообщит о TypeInitializationException или нулевой ссылке на месте вызова отсутствующего типа. Сопоставьте трассировку стека с типами, перечисленными в разделе «Внутренние типы» выше.


Итоговый чеклист

  • Runtime/link.xml присутствует в пакете — убедитесь, что он не был случайно удалён.
  • Уровень обрезки управляемого кода можно устанавливать любым; High поддерживается.
  • Никаких атрибутов [Preserve] в код приложения добавлять не нужно.
  • Никаких подсказок AOT или вызовов регистрации кода не требуется.
  • Консольные сборки используют тот же link.xml; платформоспецифичных добавлений не требуется.
  • Если вы форкаете исходный код, перенесите с ним link.xml.