Em maio de 2022, Alexandre Mutel e Kristyna Hougaard anunciaram em sua postagem "Unity e .NET, o que vem a seguir?" que a Unity planeja adotar mais recursos do .NET, incluindo a conveniência de usar async-await. E parece que o Unity está cumprindo sua promessa. Na versão alfa do Unity 2023.1, a classe Awaitable foi introduzida, oferecendo mais oportunidades para escrever código assíncrono.
Nesta seção, não vou me aprofundar muito nos métodos que, na minha opinião, têm descrição suficiente na documentação oficial do Unity. Eles estão todos relacionados à espera assíncrona.
Awaitable.WaitForSecondsAsync() permite que você aguarde um determinado período de tempo de jogo. Ao contrário de Task.Delay(), que realiza uma espera em tempo real. Para ajudar a esclarecer a diferença, fornecerei um pequeno exemplo posteriormente em um bloco de código.
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."); }
Neste exemplo, no início do método Start(), o tempo do jogo é interrompido usando Time.timeScale. Para o experimento, uma Coroutine será usada para retomar seu fluxo após 5 segundos no método RunGameplay(). Em seguida, lançamos dois métodos de espera de um segundo. Um usando Awaitable.WaitForSecondsAsync() e o outro usando Task.Delay(). Após um segundo, receberemos uma mensagem no console "Waiting WaitWithTaskDelay() encerrado". E após 5 segundos, a mensagem "Waiting WaitWithTaskDelay() terminou" aparecerá.
Outros métodos convenientes também foram adicionados para oferecer mais flexibilidade no Player Loop básico do Unity. Seu propósito é claro a partir do nome e corresponde à sua analogia ao usar Coroutines:
Se você é novo no trabalho com corrotinas, recomendo experimentá-las por conta própria para entender melhor.
Um método, Awaitable.FromAsyncOperation(), também foi adicionado para compatibilidade com a antiga API, AsyncOperation.
Uma das conveniências de usar Coroutines é que elas param automaticamente se o componente for removido ou desabilitado. No Unity 2022.2, a propriedade destroyCancellationToken foi adicionada ao MonoBehaviour, permitindo interromper a execução assíncrona no momento da exclusão do objeto. É importante lembrar que interromper a tarefa por meio do cancelamento de CancellationToken gera a OperationCanceledException. Se o método de chamada não retornar Task ou Awaitable, essa exceção deverá ser capturada.
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); }
Neste exemplo, o objeto é imediatamente destruído em Start(), mas antes disso, Awake() consegue iniciar a execução de DoAwaitAsync(). O comando Awaitable.WaitForSecondsAsync(1, destroyCancellationToken) aguarda 1 segundo e, em seguida, deve gerar a mensagem "Essa mensagem não será registrada". Como o objeto é excluído imediatamente, o destroyCancellationToken interrompe a execução de toda a cadeia lançando o OperationCanceledException. Dessa forma, destroyCancellationToken nos livra da necessidade de criar um CancellationToken manualmente.
Mas ainda podemos fazer isso, por exemplo, para interromper a execução no momento da desativação do objeto. Vou dar um exemplo.
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."); } } }
Neste formulário, a mensagem "Esta mensagem é registrada a cada segundo" será enviada enquanto o objeto no qual este MonoBehaviour trava estiver ligado. O objeto pode ser desligado e ligado novamente.
Este código pode parecer redundante. O Unity já contém muitas ferramentas convenientes, como Coroutines e InvokeRepeating(), que permitem que você execute tarefas semelhantes com muito mais facilidade. Mas este é apenas um exemplo de uso. Aqui estamos apenas lidando com Awaitable.
No Unity, a execução do método assíncrono não para sozinha, mesmo depois de sair do modo Play no editor. Vamos adicionar um script semelhante ao projeto.
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); } } }
Neste exemplo, depois de alternar para o modo Play, a mensagem "Esta mensagem é registrada a cada segundo" será enviada ao console. Ele continua a ser reproduzido mesmo após o botão Play ser liberado. Neste exemplo, Task.Delay() é usado em vez de Awaitable.WaitForSecondsAsync(), porque aqui, para mostrar a ação, é necessário um atraso não em tempo de jogo, mas em tempo real.
Analogamente para destroyCancellationToken, podemos usar Application.exitCancellationToken, que interrompe a execução de métodos assíncronos ao sair do Modo Play. Vamos corrigir o roteiro.
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); } } }
Agora o script será executado conforme o esperado.
No Unity, algumas funções de evento podem ser Coroutines, por exemplo, Start, OnCollisionEnter ou OnCollisionExit. Mas a partir do Unity 2023.1, todos eles podem ser Awaitable, incluindo Update(), LateUpdate e até OnDestroy().
Eles devem ser usados com cautela, pois não há espera para sua execução assíncrona. Por exemplo, para o seguinte código:
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()"); }
No console, teremos o seguinte resultado:
Awake() started OnEnable() Start() Awake() finished
Vale lembrar também que o próprio MonoBehaviour ou mesmo o objeto do jogo podem deixar de existir enquanto o código assíncrono ainda estiver em execução. Em tal situação:
private async Awaitable Awake() { Debug.Log(this != null); await Awaitable.NextFrameAsync(); Debug.Log(this != null); } private void Start() { Destroy(this); }
No quadro seguinte, o MonoBehaviour é considerado excluído. No console, teremos o seguinte resultado:
True Flase
Isso também se aplica ao método OnDestroy(). Se você tornar o método assíncrono, deve levar em consideração que após a instrução await, o MonoBehaviour já é considerado excluído. Quando o próprio objeto é excluído, o trabalho de muitos MonoBehaviours localizados nele pode não funcionar corretamente neste ponto.
Vale a pena notar que ao trabalhar com funções de evento, é importante estar ciente da ordem de execução. O código assíncrono pode não ser executado na ordem esperada e é essencial ter isso em mente ao criar seus scripts.
Vale a pena notar que as funções de eventos aguardáveis capturam todos os tipos de exceções, que podem ser inesperadas. Eu esperava que eles capturassem apenas OperationCanceledExceptions, o que faria mais sentido. Mas capturar todos os tipos de exceções os torna inadequados para uso neste momento. Em vez disso, você pode executar métodos assíncronos e capturar manualmente as mensagens necessárias, conforme mostrado no exemplo anterior.
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); }
Como o componente é excluído imediatamente na inicialização, a execução de DoAwaitAsync() será interrompida. A mensagem "Essa mensagem não será registrada" não aparecerá no console. Somente OperationCanceledException() é capturado, todas as outras exceções podem ser lançadas.
Espero que essa abordagem seja corrigida no futuro. No momento, o uso de Awaitable Event Functions não é seguro.
Como é sabido, todas as operações com objetos do jogo e MonoBehaviours são permitidas apenas na thread principal. Às vezes é necessário fazer cálculos massivos que podem levar ao congelamento do jogo. É melhor realizá-los fora do thread principal. Awaitable oferece dois métodos, BackgroundThreadAsync() e MainThreadAsync(), que permitem sair do thread principal e retornar a ele. Vou fornecer um exemplo.
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}"); }
Aqui, quando o método for iniciado, ele mudará para um thread adicional. Aqui eu envio o id deste thread adicional para o console. Não será igual a 1, porque 1 é o thread principal.
Em seguida, o thread é congelado por 10 segundos (Thread.Sleep(10000)), simulando cálculos massivos. Se você fizer isso no thread principal, o jogo parecerá congelar durante sua execução. Mas nesta situação, tudo continua a funcionar de forma estável. Você também pode usar um CancellationToken nesses cálculos para interromper uma operação desnecessária.
Depois disso, voltamos ao thread principal. E agora todas as funções do Unity estão disponíveis para nós novamente. Por exemplo, como neste caso, desabilitando um objeto de jogo, o que não foi possível sem o thread principal.
Em conclusão, a nova classe Awaitable introduzida no Unity 2023.1 fornece aos desenvolvedores mais oportunidades para escrever código assíncrono, facilitando a criação de jogos responsivos e de alto desempenho. A classe Awaitable inclui uma variedade de métodos de espera, como WaitForSecondsAsync(), EndOfFrameAsync(), FixedUpdateAsync() e NextFrameAsync(), que permitem mais flexibilidade no Player Loop básico do Unity. As propriedades destroyCancellationToken e Application.exitCancellationToken também fornecem uma maneira conveniente de interromper a execução assíncrona no momento da exclusão do objeto ou da saída do Modo de reprodução.
É importante observar que, embora a classe Awaitable forneça uma nova maneira de escrever código assíncrono no Unity, ela deve ser usada em conjunto com outras ferramentas do Unity, como Coroutines e InvokeRepeating, para obter os melhores resultados. Além disso, é importante entender os fundamentos do async-await e os benefícios que ele pode trazer para o processo de desenvolvimento do jogo, como melhorar o desempenho e a capacidade de resposta.
Em resumo, a classe Awaitable é uma ferramenta poderosa para desenvolvedores Unity, mas deve ser usada com cuidado e em conjunto com outras ferramentas e conceitos Unity para obter os melhores resultados. É importante experimentá-lo para entender melhor suas capacidades e limitações.