Unity 2023.1 представляет класс Awaitable

Unity 2023.1 представляет класс Awaitable

28 января 2023 г.

В мае 2022 года Александр Мютель и Кристина Хогаард объявили в своем посте «Unity и .NET, что дальше?» что Unity планирует использовать больше функций .NET, включая удобство использования async-await. И похоже, что Unity выполняет свое обещание. В альфа-версии Unity 2023.1 был представлен класс Awaitable, дающий больше возможностей для написания асинхронного кода.

Методы ожидания

В этом разделе я не буду слишком углубляться в методы, которые, на мой взгляд, имеют достаточное описание в официальной документации Unity. Все они связаны с асинхронным ожиданием.

Awaitable.WaitForSecondsAsync() позволяет вам ждать указанное количество игрового времени. В отличие от Task.Delay(), которая выполняет ожидание в режиме реального времени. Чтобы прояснить разницу, я приведу небольшой пример позже в блоке кода.

private void Start()
{
    Time.timeScale = 0;

    StartCoroutine(RunGameplay());

    Task.WhenAll(
        WaitWithWaitForSecondsAsync(),
        WaitWithTaskDelay());
}

private IEnumerator RunGameplay()
{
    yield return new WaitForSecondsRealtime(5);
    Time.timeScale = 1;
}

private async Task WaitWithWaitForSecondsAsync()
{
    await Awaitable.WaitForSecondsAsync(1);
    Debug.Log("Waiting WithWaitForSecondsAsync() ended.");
}

private async Task WaitWithTaskDelay()
{
    await Task.Delay(1);
    Debug.Log("Waiting WaitWithTaskDelay() ended.");
}

В этом примере в начале метода Start() игровое время останавливается с помощью Time.timeScale. Ради эксперимента будет использоваться сопрограмма для возобновления своего потока через 5 секунд в методе RunGameplay(). Затем мы запускаем два односекундных метода ожидания. Один использует Awaitable.WaitForSecondsAsync(), а другой использует Task.Delay(). Через одну секунду мы получим сообщение в консоли «Ожидание WaitWithTaskDelay() завершено». А через 5 секунд появится сообщение "Ожидание WaitWithTaskDelay() завершено".

Также были добавлены другие удобные методы, чтобы дать вам больше гибкости в базовом цикле игрока Unity. Их назначение понятно из названия и соответствует их аналогии при использовании сопрограмм:

* EndOfFrameAsync() * Фиксированное обновление асинхронного обновления () * Некстфреймасинк()

Если вы новичок в работе с сопрограммами, я рекомендую поэкспериментировать с ними самостоятельно, чтобы лучше понять их суть.

Также был добавлен метод Awaitable.FromAsyncOperation() для обратной совместимости со старым API AsyncOperation.

Использование свойства destroyCancellationToken

Одним из преимуществ использования сопрограмм является то, что они автоматически останавливаются, если компонент удаляется или отключается. В Unity 2022.2 в MonoBehaviour было добавлено свойство destroyCancellationToken, позволяющее останавливать асинхронное выполнение в момент удаления объекта. Важно помнить, что остановка задачи с помощью отмены CancellationToken вызывает исключение OperationCanceledException. Если вызывающий метод не возвращает Task или Awaitable, это исключение должно быть перехвачено.

private async void Awake()
{
   try
   {
       await DoAwaitAsync();
   }
   catch (OperationCanceledException) { }
}

private async Awaitable DoAwaitAsync()
{
   await Awaitable.WaitForSecondsAsync(1, destroyCancellationToken);
   Debug.Log("That message won't be logged.");
}

private void Start()
{
   Destroy(this);
}

В этом примере объект сразу уничтожается в Start(), но перед этим Awake() успевает запустить выполнение DoAwaitAsync(). Команда Awaitable.WaitForSecondsAsync(1, destroyCancellationToken) ждет 1 секунду, а затем должна вывести сообщение «Это сообщение не будет зарегистрировано». Поскольку объект немедленно удаляется, destroyCancellationToken останавливает выполнение всей цепочки, вызывая исключение OperationCanceledException. Таким образом, destroyCancellationToken избавляет нас от необходимости вручную создавать CancellationToken.

Но мы все еще можем сделать это, например, чтобы остановить выполнение в момент деактивации объекта. Я приведу пример.

using System;
using System.Threading;
using UnityEngine;

public class Example : MonoBehaviour
{
    private CancellationTokenSource _tokenSource;

    private async void OnEnable()
    {
        _tokenSource = new CancellationTokenSource();

        try
        {
            await DoAwaitAsync(_tokenSource.Token);
        }
        catch (OperationCanceledException) { }

    }

    private void OnDisable()
    {
        _tokenSource.Cancel();
        _tokenSource.Dispose();
    }

    private static async Awaitable DoAwaitAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            await Awaitable.WaitForSecondsAsync(1, token);
            Debug.Log("This message is logged every second.");
        }
    }
}

В такой форме сообщение «Это сообщение регистрируется каждую секунду» будет отправляться до тех пор, пока включен объект, на котором висит это MonoBehaviour. Объект можно выключить и снова включить.

Этот код может показаться избыточным. Unity уже содержит множество удобных инструментов, таких как Coroutines и InvokeRepeating(), которые значительно упрощают выполнение подобных задач. Но это всего лишь пример использования. Здесь мы имеем дело только с Awaitable.

Использование свойства Application.exitCancellationToken

В Unity выполнение асинхронного метода не останавливается само по себе даже после выхода из режима воспроизведения в редакторе. Добавим аналогичный скрипт в проект.

using System.Threading.Tasks;
using UnityEngine;

public static class Boot
{
    [RuntimeInitializeOnLoadMethod]
    public static async Awaitable LogAsync()
    {
        while (true)
        {
            Debug.Log("This message is logged every second.");
            await Task.Delay(1000);
        }
    }
}

В этом примере после переключения в режим воспроизведения на консоль будет выводиться сообщение «Это сообщение регистрируется каждую секунду». Он продолжает выводиться даже после того, как кнопка Play будет отпущена. В данном примере вместо Awaitable.WaitForSecondsAsync() используется Task.Delay(), так как здесь для показа действия нужна задержка не в игровом, а в реальном времени.

По аналогии с destroyCancellationToken мы можем использовать Application.exitCancellationToken, который прерывает выполнение асинхронных методов при выходе из Play Mode. Давайте исправим скрипт.

using System.Threading.Tasks;
using UnityEngine;

public static class Boot
{
    [RuntimeInitializeOnLoadMethod]
    public static async Awaitable LogAsync()
    {
        var cancellationToken = Application.exitCancellationToken;

        while (!cancellationToken.IsCancellationRequested)
        {
            Debug.Log("This message is logged every second.");
            await Task.Delay(1000, cancellationToken);
        }
    }
}

Теперь скрипт будет выполняться, как задумано.

Использование с функциями событий

В Unity некоторые функции событий могут быть сопрограммами, например Start, OnCollisionEnter или OnCollisionExit. Но, начиная с Unity 2023.1, все они могут быть Awaitable, включая Update(), LateUpdate и даже OnDestroy().

Их следует использовать с осторожностью, так как нет ожидания их асинхронного выполнения. Например, для следующего кода:

private async Awaitable Awake()
{
   Debug.Log("Awake() started");
   await Awaitable.NextFrameAsync();
   Debug.Log("Awake() finished");
}

private void OnEnable()
{
   Debug.Log("OnEnable()");
}

private void Start()
{
   Debug.Log("Start()");
}

В консоли получим следующий результат:

Awake() started
OnEnable()
Start()
Awake() finished

Также стоит помнить, что сам MonoBehaviour или даже игровой объект могут перестать существовать, пока асинхронный код все еще выполняется. В такой ситуации:

private async Awaitable Awake()
{
    Debug.Log(this != null);
    await Awaitable.NextFrameAsync();
    Debug.Log(this != null);
}

private void Start()
{
    Destroy(this);
}

В следующем кадре MonoBehaviour считается удаленным. В консоли получим следующий результат:

True
Flase

Это также относится к методу OnDestroy(). Если вы делаете метод асинхронным, вы должны учитывать, что после оператора await MonoBehaviour уже считается удаленным. При удалении самого объекта работа многих расположенных на нем MonoBehaviours в этот момент может работать некорректно.

Стоит отметить, что при работе с функциями событий важно помнить о порядке выполнения. Асинхронный код может выполняться не в том порядке, в котором вы ожидаете, и важно помнить об этом при разработке сценариев.

Функции ожидаемых событий перехватывают все типы исключений

Стоит отметить, что функции ожидаемых событий перехватывают все типы исключений, которые могут быть неожиданными. Я ожидал, что они поймают только OperationCanceledExceptions, что имело бы больше смысла. Но перехват всех типов исключений делает их непригодными для использования в настоящее время. Вместо этого вы можете запускать асинхронные методы и вручную перехватывать необходимые сообщения, как показано в предыдущем примере.

private async void Awake()
{
   try
   {
       await DoAwaitAsync();
   }
   catch (OperationCanceledException) { }
}

private async Awaitable DoAwaitAsync()
{
   await Awaitable.WaitForSecondsAsync(1, destroyCancellationToken);
   Debug.Log("That message won't be logged");
}

private void Start()
{
   Destroy(this);
}

Поскольку компонент удаляется сразу при запуске, выполнение DoAwaitAsync() будет прервано. Сообщение «Это сообщение не будет зарегистрировано» не появится в консоли. Перехватывается только OperationCanceledException(), все остальные исключения могут быть сгенерированы.

Я надеюсь, что этот подход будет исправлен в будущем. На данный момент использование функций ожидающих событий небезопасно.

Свободное перемещение по темам

Как известно, все операции с игровыми объектами и MonoBehaviours разрешены только в основном потоке. Иногда приходится производить массовые вычисления, которые могут привести к зависанию игры. Их лучше выполнять вне основного потока. Awaitable предлагает два метода: BackgroundThreadAsync() и MainThreadAsync(), которые позволяют уйти от основного потока и вернуться к нему. Я приведу пример.

private async Awaitable DoAwaitAsync(CancellationToken token)
{
   await Awaitable.BackgroundThreadAsync();
   Debug.Log($"Thread: {Thread.CurrentThread.ManagedThreadId}");
   Thread.Sleep(10000);

   await Awaitable.MainThreadAsync();

   if (token.IsCancellationRequested)
   {
       return;
   }

   Debug.Log($"Thread: {Thread.CurrentThread.ManagedThreadId}");
   gameObject.SetActive(false);
   await Awaitable.BackgroundThreadAsync();
   Debug.Log($"Thread: {Thread.CurrentThread.ManagedThreadId}");
}

Здесь при запуске метода он переключится на дополнительный поток. Здесь я вывожу в консоль id этого дополнительного потока. Он не будет равен 1, потому что 1 — это основной поток.

Затем поток замораживается на 10 секунд (Thread.Sleep(10000)), имитируя массивные вычисления. Если вы сделаете это в основном потоке, игра будет зависать на время ее выполнения. Но и в этой ситуации все продолжает стабильно работать. Вы также можете использовать CancellationToken в этих вычислениях, чтобы остановить ненужную операцию.

После этого переключаемся обратно в основной поток. И вот теперь нам снова доступны все функции Unity. Например, как в данном случае, отключение игрового объекта, что было невозможно сделать без основного потока.

Заключение

В заключение отметим, что новый класс Awaitable, представленный в Unity 2023.1, предоставляет разработчикам больше возможностей для написания асинхронного кода, упрощая создание отзывчивых и производительных игр. Класс Awaitable включает множество методов ожидания, таких как WaitForSecondsAsync(), EndOfFrameAsync(), FixedUpdateAsync() и NextFrameAsync(), которые обеспечивают большую гибкость базового цикла Player в Unity. Свойства destroyCancellationToken и Application.exitCancellationToken также предоставляют удобный способ остановить асинхронное выполнение во время удаления объекта или выхода из режима воспроизведения.

Важно отметить, что хотя класс Awaitable предоставляет новый способ написания асинхронного кода в Unity, для достижения наилучших результатов его следует использовать в сочетании с другими инструментами Unity, такими как Coroutines и InvokeRepeating. Кроме того, важно понимать основы асинхронного ожидания и преимущества, которые он может принести процессу разработки игр, например повысить производительность и скорость отклика.

Таким образом, класс Awaitable — это мощный инструмент для разработчиков Unity, но его следует использовать с осторожностью и в сочетании с другими инструментами и концепциями Unity для достижения наилучших результатов. Важно поэкспериментировать с ним, чтобы лучше понять его возможности и ограничения.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE