2022 年 5 月,Alexandre Mutel 和 Kristyna Hougaard 在他们的帖子“Unity 和 .NET,下一步是什么?”中宣布。 Unity 计划采用更多 .NET 的功能,包括使用 async-await 的便利性。而且,Unity 似乎正在兑现其承诺。在Unity 2023.1 alpha版本中,引入了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 停止游戏时间。为了进行实验,将在 RunGameplay() 方法中使用 Coroutine 在 5 秒后恢复其流程。然后,我们启动两个一秒等待方法。一个使用 Awaitable.WaitForSecondsAsync(),另一个使用 Task.Delay()。一秒钟后,我们将在控制台中收到一条消息“Waiting WaitWithTaskDelay() ended”。 5 秒后,将出现消息“Waiting WaitWithTaskDelay() ended”。
还添加了其他方便的方法,让您在 Unity 的基本播放器循环中更加灵活。它们的用途从名称中就很清楚,并且对应于它们在使用 Coroutines 时的类比:
如果您是协程的新手,我建议您自己试验一下,以便更好地理解。
还添加了一种方法 Awaitable.FromAsyncOperation() 以向后兼容旧 API AsyncOperation。
使用协程的便利之一是,如果组件被删除或禁用,它们会自动停止。在 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 秒,然后应该输出消息“That message won't be logged”。因为对象被立即删除,所以 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。
在 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); } } }
在此示例中,切换到播放模式后,将向控制台输出消息“此消息每秒记录一次”。即使松开播放按钮,它也会继续输出。在这个例子中,使用 Task.Delay() 而不是 Awaitable.WaitForSecondsAsync(),因为在这里,为了显示动作,延迟不是游戏时间而是实时需要的。
类似于 destroyCancellationToken,我们可以使用 Application.exitCancellationToken,它会在退出播放模式时中断异步方法的执行。让我们修复脚本。
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 功能都可以再次使用了。例如,在这种情况下,禁用一个游戏对象,如果没有主线程是不可能做到的。
总之,Unity 2023.1 中引入的新 Awaitable 类为开发人员提供了更多编写异步代码的机会,从而更容易创建响应式和高性能的游戏。 Awaitable 类包括多种等待方法,例如 WaitForSecondsAsync()、EndOfFrameAsync()、FixedUpdateAsync() 和 NextFrameAsync(),这些方法在 Unity 的基本播放器循环中提供了更大的灵活性。 destroyCancellationToken 和 Application.exitCancellationToken 属性还提供了一种在对象删除或退出播放模式时停止异步执行的便捷方法。
需要注意的是,虽然 Awaitable 类提供了一种在 Unity 中编写异步代码的新方法,但它应该与 Coroutines 和 InvokeRepeating 等其他 Unity 工具结合使用才能达到最佳效果。此外,了解 async-await 的基础知识及其可为游戏开发过程带来的好处也很重要,例如提高性能和响应能力。
总而言之,Awaitable 类是 Unity 开发人员的强大工具,但应谨慎使用,并与其他 Unity 工具和概念结合使用才能达到最佳效果。重要的是要对其进行试验以更好地了解其功能和局限性。