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.


ссылка на оригинал статьи https://habr.com/ru/post/704792/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *