Unity 2022.2 продолжает интеграцию async await
В Unity 2022.2 был сделан ещё один небольшой шаг в сторону поддержки async-await, анонсированный еще в мае 2022 года в статье https://blog.unity.com/technology/unity-and-net-whats-next. В UnityEngine.MonoBehaviour было добавлено свойство destroyCancellationToken, которое позволяет остановить задачу в момент уничтожения объекта. В UnityEngine.Application добавлено свойство с токеном exitCancellationToken, который отменяется в момент выхода из Play Mode. Коротко вспомним отличие Coroutine от async-await и применим новые свойства.
Пример использования Coroutine
Coroutine, по сути, это простые IEnumerator-методы, которые итерируются в Player Loop всегда в главном потоке. Из Coroutine вы можете вернуть null или 4 типа объекта: WaitForSeconds, WaitForFixedUpdate, WWW или же какой-то другой Coroutine. В зависимости от типа можно точно предсказать, когда произойдет возврат в метод. Это можно посмотреть на схеме https://docs.unity3d.com/Manual/ExecutionOrder.html. Если возвращаемый объект не будет относиться ни к одному из перечисленных, то он будет воспринят как null.
Каждый Coroutine строго привязан к MonoBehaviour, которым он вызывается. Так к примеру, если game object будет выключен или будет отключен сам MonoBehaviour, то и вызов Coroutine не будет происходить. При уничтожении объекта, Coroutine вовсе перестает как либо обрабатываться.
Это дает удобство, но и вносит свои ограничения. К примеру, вы уже не можете управлять постоянным включением и выключением объекта через Coroutine самого объекта. Только через какой-то внешний объект.
Приведу пример с мигающими объектами на сцене. Создадим такой компонент.
public class BlinkingObject : MonoBehaviour
{
public float period;
public IEnumerator Start()
{
var delay = new WaitForSeconds(period);
while (true)
{
yield return delay;
gameObject.SetActive(false);
yield return delay;
gameObject.SetActive(true);
}
}
}
Выполнение этого компонента приведёт только к отключению объекта, и он никогда не включится вновь. Т.к. возврат в Coroutine на выключенном объекте не произойдёт. Классическое решение данной ситуации — это управление мигающим объектом извне.
public class BlinkingObject : MonoBehaviour
{
public float period = 1;
public GameObject target;
public IEnumerator Start()
{
var delay = new WaitForSeconds(period);
while (true)
{
yield return delay;
target.SetActive(false);
yield return delay;
target.SetActive(true);
}
}
}
Тут один объект будет управлять другим, который был указан в target. Опять же, если указать для него самого себя, то скрипт работать не будет. Хорошим тоном будет сделать проверку.
if (target == gameObject)
{
throw new Exception(
$"Specified {nameof(GameObject)} in the variable {nameof(target)} of {nameof(BlinkingObject)} " +
$"on the '{gameObject.name}' {nameof(GameObject)} is the same as the parent one.");
}
В большинстве других случаев подобная взаимосвязь Coroutine и MonoBehaviour удобна. Любое управление движением объекта через Coroutine будет остановлено, как только объект будет выключен или уничтожен.
Пример использование async await для создания мигающего объекта
В какой поток будет передано управление после await зависит от используемого SynchronizationContext. В Unity await всегда возвращает управление в основной поток, что и требуется в большинстве случаев.
Напишем компонент для управления миганием объекта через async-await.
public class BlinkingObject : MonoBehaviour
{
public int period = 1;
public async void Start()
{
// Converts seconds to milliseconds.
var delay = period * 1000;
while (true)
{
await Task.Delay(delay);
if (this == null)
{
break;
}
gameObject.SetActive(false);
await Task.Delay(delay);
if (this == null)
{
break;
}
gameObject.SetActive(true);
}
}
}
После каждого использования Task.Delay приходится делать проверку на то, был ли уничтожен объект.
if (this == null)
{
break;
}
В такой ситуации лучше использовать CancellationToken, чтобы отменять выполнение Task, сразу как только объект был уничтожен. Следующий пример будет использовать свойство MonoBehaviour.destroyCancellationToken, которое было добавлено в Unity 2022.2.
public class BlinkingObject : MonoBehaviour
{
public int period = 1;
public async void Start()
{
// Converts seconds to milliseconds.
var delay = period * 1000;
while (!destroyCancellationToken.IsCancellationRequested)
{
await Task.Delay(delay, destroyCancellationToken);
gameObject.SetActive(false);
await Task.Delay(delay, destroyCancellationToken);
gameObject.SetActive(true);
}
}
}
Однако, отмена Task вызывает TaskCanceledException, который мы и получим, если просто уничтожим объект в момент выполнения. Следует избегать async-void методов. Если в методе нет полезного результата, то он должен возвращать Task. Если, всё-таки нет возможности сделать такой метод, как в примере с void Start (), выполнение следует оборачивать в try-catch.
public class BlinkingObject : MonoBehaviour
{
public int period = 1;
public async void Start()
{
try
{
await BlinkAsync();
}
catch (TaskCanceledException) { }
}
private async Task BlinkAsync()
{
// Converts seconds to milliseconds.
var delay = period * 1000;
while (!destroyCancellationToken.IsCancellationRequested)
{
await Task.Delay(delay, destroyCancellationToken);
gameObject.SetActive(false);
await Task.Delay(delay, destroyCancellationToken);
gameObject.SetActive(true);
}
}
}
При таком подходе нет нужды беспокоиться будет ли объект включен или выключен, управление будет возвращаться в метод в любом случае, а при уничтожении выполнение остановится. При этом код получился достаточно компактным. К примеру, раньше обработку токена destroyCancellationToken приходилось бы делать вручную.
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class BlinkingObject : MonoBehaviour
{
...
private CancellationTokenSource _cancellationTokenSource;
private CancellationToken DestroyCancellationToken => _cancellationTokenSource.Token;
private async void Start()
{
_cancellationTokenSource = new CancellationTokenSource();
...
}
private void OnDestroy()
{
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
}
private async Task BlinkAsync()
{
...
}
}
Использование async-await в отрыве от MonoBehaviour
При работе с Unity мы можем сталкиваться со множеством других библиотек и пакетов, которые могут использовать async-await подход. Или когда мы создаем асинхронную задачу в отрыве от игровых объектов, например, для реализации какой-от другой игровой логики. Начиная с Unity 2022.2, можно будет использовать UnityEngine.Application.exitCancellationToken для их своевременной остановки.
Приведу гипотетический пример ранней инициализации игры.
public static class Boot
{
[RuntimeInitializeOnLoadMethod]
public static async void Initialization()
{
try
{
await StartGameAsync(Application.exitCancellationToken);
}
catch (OperationCanceledException) { }
}
private static async Task StartGameAsync(CancellationToken cancellationToken)
{
await SomeJobBeforeInitialization(cancellationToken);
await Addressables.InitializeAsync().Task;
await PreloadingSomeBundles(cancellationToken);
await Addressables.LoadSceneAsync("StartMenuScene").Task;
}
private static async Task SomeJobBeforeInitialization(CancellationToken cancellationToken)
{
await Task.Delay(1000, cancellationToken);
}
private static async Task PreloadingSomeBundles(CancellationToken cancellationToken)
{
await Task.Delay(1000, cancellationToken);
}
}
До введения Application.exitCancellationToken приходилось делать обработку этого токена вручную. Напомню, что если просто так запустить async Task, без обработки отмены, то эта задача продолжит выполняться и после остановки игры при переходе в Edit Mode.
Сравнивая Coroutine и async-await
Ожидание Task через await часто выделяют объекты в памяти, что может вызвать проблемы с производительностью, при частом использовании. Так при управлении игровыми объектам в Unity, несмотря на все удобства синтаксиса async-await, на мой взгляд, всё ещё предпочтительнее использовать Coroutine.
Одним из самых больших недостатков Coroutine — это невозможность вернуть результат. В такой ситуации уже можно обратиться к стандартному подходу в .NET с использование async-await.
Также можно создать свой собственный аналог Task более адаптированный для Unity и более производительный, который будет поддерживать внутреигровое время Time.time, Time.deltaTime и т.п. Но это — большая тема для отдельной статьи. На данный момент уже существует библиотека UniTask, которая совмещает в себе все удобства async-await и адаптированность под Unity.