本文将探讨 Unity 中异步编程的主要方式:讨论优缺点,展示代码示例,并展示每个方法的实际应用场景。对于新手来说,这是一份绝佳的入门指南,对于经验丰富的工程师来说,它也是一份可靠的资源!
游戏开发中的一些任务不是同步的,而是异步的。这意味着它们不是在游戏代码中线性执行的。一些异步任务可能需要相当长的时间才能完成,而另一些则与密集计算相关。
一些最常见的异步游戏任务如下:
-
执行网络请求
-
加载场景、资源和其他资产
-
读取和写入文件
-
人工智能决策
-
长动画序列
-
处理大量数据
-
路径查找
现在,至关重要的是,由于所有 Unity 代码都在一个线程中运行,因此任何像上面提到的任务一样,如果它们是同步执行的,会导致主线程被阻塞,从而导致帧速率下降。
在这篇文章中,我们将讨论如何避免此类问题。我们将推荐异步编程技术,以便在单独的线程中执行这些任务,从而使主线程能够执行其他任务。这将有助于确保游戏流畅、响应迅速,并(希望)让玩家满意。
协程
首先,让我们谈谈 协程。它们是在 2011 年引入 Unity 的,甚至早于 async/await 出现在 .NET 中。在 Unity 中,协程允许我们在多个帧上执行一组指令,而不是一次性执行它们。它们类似于线程,但它们很轻量级,并且集成到 Unity 的更新循环中,非常适合游戏开发。
(顺便说一下,从历史上看,协程是 Unity 中执行异步操作的第一种方式,因此互联网上大多数关于此主题的文章都是关于它们的。)
要创建协程,你需要声明一个返回值类型为 IEnumerator
的函数。此函数可以包含你希望协程执行的任何逻辑。
要启动协程,你需要在 MonoBehaviour
实例上调用 StartCoroutine
方法,并将协程函数作为参数传递:
public class Example : MonoBehaviour
{
void Start()
{
StartCoroutine(MyCoroutine());
}
IEnumerator MyCoroutine()
{
Debug.Log("Starting coroutine");
yield return null;
Debug.Log("Executing coroutine");
yield return null;
Debug.Log("Finishing coroutine");
}
}
Unity 中提供了几个可用的 yield 指令,例如 WaitForSeconds
、WaitForEndOfFrame
、WaitForFixedUpdate
、WaitForSecondsRealtime
、WaitUntil
以及其他一些。重要的是要记住,使用它们会导致内存分配,因此应尽可能地重用它们。
例如,考虑文档中的这个方法:
IEnumerator Fade()
{
Color c = renderer.material.color;
for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
{
c.a = alpha;
renderer.material.color = c;
yield return new WaitForSeconds(.1f);
}
}
在循环的每次迭代中,都会创建一个新的 new WaitForSeconds(.1f)
实例。相反,我们可以将创建操作移到循环之外,以避免分配:
IEnumerator Fade()
{
Color c = renderer.material.color;
var waitForSeconds = new WaitForSeconds(0.2f);
for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
{
c.a = alpha;
renderer.material.color = c;
yield return waitForSeconds;
}
}
另一个值得注意的重要属性是,yield return
可以与 Unity 提供的所有 Async
方法一起使用,因为 AsyncOperation
是 YieldInstruction
的后代:
yield return SceneManager.LoadSceneAsync("path/to/scene.unity");
协程的一些潜在陷阱
尽管如此,协程也有一些缺点需要注意:
-
无法返回长时间操作的结果。你仍然需要回调,这些回调将传递给协程,并在协程完成后调用,以从中提取任何数据。
-
协程严格绑定到启动它的
MonoBehaviour
。如果GameObject
被关闭或销毁,协程将停止处理。 -
由于存在 yield 语法,因此无法使用
try-catch-finally
结构。 -
在
yield return
之后,至少会经过一帧,下一段代码才会开始执行。 -
lambda 和协程本身的内存分配
Promise
Promise 是一种用于组织和使异步操作更易读的模式。由于它们在许多第三方 JavaScript 库中的使用而变得流行起来,并且自 ES6 以来,它们已在原生环境中实现。
使用 Promise 时,我们会立即从异步函数中返回一个对象。这允许调用者等待操作的解析(或错误)。
本质上,这使得异步方法可以返回值,并“表现得”像同步方法一样:它们不会立即返回最终值,而是会给出“承诺”,承诺它们会在将来的某个时间返回一个值。
Unity 有几个 Promise 实现:
-
C-Sharp-Promise
(https://github.com/Real-Serious-Games/C-Sharp-Promise) -
UnityFx.Async
(https://github.com/Arvtesh/UnityFx.Async) -
C# Promises
(https://github.com/AgeOfLearning/promises) -
uPromise
(https://assetstore.unity.com/packages/tools/upromise-15604)
与 Promise 交互的主要方式是通过 回调函数。
你可以定义一个回调函数,该函数将在 Promise 解析时调用,以及另一个回调函数,该函数将在 Promise 被拒绝时调用。这些回调以异步操作的结果作为参数接收,然后可以使用这些参数来执行进一步的操作。
根据 Promises/A+(https://promisesaplus.com/) 组织的这些 规范(https://github.com/promises-aplus),Promise 可以处于三种状态之一:
-
Pending
:初始状态,这意味着异步操作仍在进行中,操作的结果尚不清楚。 -
Fulfilled
(Resolved
):解析状态伴随着一个值,该值代表操作的结果。 -
Rejected
:如果异步操作由于任何原因失败,则 Promise 被称为“被拒绝”。拒绝状态伴随着失败的原因。
关于 Promise 的更多信息
此外,Promise 可以链接在一起,以便一个 Promise 的结果可以用来确定另一个 Promise 的结果。
例如,你可以创建一个 Promise 来从服务器获取一些数据,然后使用这些数据来创建一个另一个 Promise,该 Promise 执行一些计算和其他操作:
var promise = MakeRequest("<https://some.api>")
.Then(response => Parse(response))
.Then(result => OnRequestSuccess(result))
.Then(() => PlaySomeAnimation())
.Catch(exception => OnRequestFailed(exception));
以下是如何组织执行异步操作的方法的示例:
public IPromise<string> MakeRequest(string url)
{
// 创建一个新的 promise 对象
var promise = new Promise<string>();
// 创建一个新的 web 客户端
using var client = new WebClient();
// 添加一个 DownloadStringCompleted 事件的处理程序
client.DownloadStringCompleted += (sender, eventArgs) =>
{
// 如果发生错误,拒绝 promise
if (eventArgs.Error != null)
{
promise.Reject(eventArgs.Error);
}
// 否则,使用结果解析 promise
else
{
promise.Resolve(eventArgs.Result);
}
};
// 异步启动下载
client.DownloadStringAsync(new Uri(url), null);
// 返回 promise
return promise;
}
我们也可以将协程包装在 Promise
中:
void Start()
{
// 加载场景,然后显示开场动画
LoadScene("path/to/scene.unity")
.Then(() => ShowIntroAnimation())
.Then( ... );
}
// 加载场景并返回一个 promise
Promise LoadScene(string sceneName)
{
// 创建一个新的 promise
var promise = new Promise();
// 启动一个协程来加载场景
StartCoroutine(LoadSceneRoutine(promise, sceneName));
// 返回 promise
return promise;
}
IEnumerator LoadSceneRoutine(Promise promise, string sceneName)
{
// 异步加载场景
yield return SceneManager.LoadSceneAsync(sceneName);
// 场景加载完成后解析 promise
promise.Resolve();
}
当然,你可以使用 ThenAll
/ Promise.All
和 ThenRace
/ Promise.Race
来组织任何 Promise 执行顺序的组合:
// 按顺序执行以下两个 promise
Promise.Sequence(
() => Promise.All( // 并行执行这两个 promise
RunAnimation("Foo"),
PlaySound("Bar")
),
() => Promise.Race( // 以竞赛方式执行这两个 promise
RunAnimation("One"),
PlaySound("Two")
)
);
Promise 的“不靠谱”之处
尽管使用起来很方便,但 Promise 也有一些缺点:
-
开销: 创建 Promise 与使用其他异步编程方法(如协程)相比,会带来额外的开销。在某些情况下,这会导致性能下降。
-
调试: 调试 Promise 可能比调试其他异步编程模式更困难。跟踪执行流程和识别错误源可能很困难。
-
异常处理: 与其他异步编程模式相比,Promise 的异常处理可能更复杂。管理 Promise 链中发生的错误和异常可能很困难。
Async/Await 任务
async/await 功能自 C# 5.0(2012 年)以来一直是 C# 的一部分,它是在 Unity 2017 中引入的,并随着 .NET 4.x 运行时的实现而引入。
在 .NET 的历史上,可以区分以下阶段:
-
EAP(基于事件的异步模式):这种方法基于在操作完成后触发的事件,以及调用此操作的常规方法。
-
APM(异步编程模型):这种方法基于两种方法。
BeginSmth
方法返回IAsyncResult
接口。EndSmth
方法接受IAsyncResult
;如果在调用EndSmth
时操作尚未完成,则线程将被阻塞。 -
TAP(基于任务的异步模式):这个概念通过引入 async/await 以及
Task
和Task
类型得到了改进。
由于最后一种方法的成功,以前的方法已经过时了。
要创建一个异步方法,该方法必须用 async
关键字标记,包含一个 await
,并且返回值必须是 Task
、Task<T>
或 void
(不推荐)。
public async Task Example()
{
SyncMethodA();
await Task.Delay(1000); // 第一个异步操作
SyncMethodB();
await Task.Delay(2000); // 第二个异步操作
SyncMethodC();
await Task.Delay(3000); // 第三个异步操作
}
在这个例子中,执行将按以下方式进行:
-
首先,将执行第一个异步操作 (
SyncMethodA
) 之前的代码。 -
启动第一个异步操作
await Task.Delay(1000)
,并预计它将被执行。同时,将保存异步操作完成后要调用的代码(“延续”)。 -
第一个异步操作完成后,“延续”——直到下一个异步操作 (
SyncMethodB
) 的代码将开始执行。 -
启动第二个异步操作 (
await Task.Delay(2000)
),并预计它将被执行。同时,将保存第二个异步操作 (SyncMethodC
) 之后要执行的代码。 -
第二个异步操作完成后,将执行
SyncMethodC
,然后执行并等待第三个异步操作await Task.Delay(3000)
。
这是一个简化的解释,因为实际上 async/await 是语法糖,允许方便地调用异步方法并等待它们完成。
你还可以使用 WhenAll
和 WhenAny
来组织任何执行顺序的组合:
var allTasks = Task.WhenAll(
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ })
);
allTasks.ContinueWith(t =>
{
Console.WriteLine("所有任务都已完成");
});
var anyTask = Task.WhenAny(
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ })
);
anyTask.ContinueWith(t =>
{
Console.WriteLine("其中一个任务已完成");
});
IAsyncStateMachine
C# 编译器将 async/await 调用转换为一个 IAsyncStateMachine
状态机,它是一组必须执行以完成异步操作的顺序操作。
每次调用 await 操作时,状态机都会完成其工作,并等待该操作完成,之后它会继续执行下一个操作。这允许在后台执行异步操作,而不会阻塞主线程,并且还使异步方法调用更简单、更易读。
因此,Example
方法被转换为使用 [AsyncStateMachine(typeof(ExampleStateMachine))]
注释创建和初始化一个状态机,状态机本身具有与 await 调用数量相等的状态数。
-
转换后的方法
Example
的示例
[AsyncStateMachine(typeof(ExampleStateMachine))]
public /*async*/ Task Example()
{
// 创建 ExampleStateMachine 类的新实例
ExampleStateMachine stateMachine = new ExampleStateMachine();
// 创建一个新的 AsyncTaskMethodBuilder,并将其分配给 stateMachine 实例的 taskMethodBuilder 属性
stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create();
// 将 stateMachine 实例的 currentState 属性设置为 -1
stateMachine.currentState = -1;
// 启动 stateMachine 实例
stateMachine.taskMethodBuilder.Start(ref stateMachine);
// 返回 taskMethodBuilder 的 Task 属性
return stateMachine.taskMethodBuilder.Task;
}
-
生成的状态机
ExampleStateMachine
的示例
[CompilerGenerated]
private sealed class ExampleStateMachine : IAsyncStateMachine
{
public int currentState;
public AsyncTaskMethodBuilder taskMethodBuilder;
private TaskAwaiter taskAwaiter;
public int paramInt;
private int localInt;
void IAsyncStateMachine.MoveNext()
{
int num = currentState;
try
{
TaskAwaiter awaiter3;
TaskAwaiter awaiter2;
TaskAwaiter awaiter;
switch (num)
{
default:
localInt = paramInt;
// 调用第一个同步方法
SyncMethodA();
// 为延迟 1000 毫秒创建一个任务等待器
awaiter3 = Task.Delay(1000).GetAwaiter();
// 如果任务未完成,将当前状态设置为 0 并存储等待器
if (!awaiter3.IsCompleted)
{
currentState = 0;
taskAwaiter = awaiter3;
// 存储当前状态机
ExampleStateMachine stateMachine = this;
// 等待任务并传递状态机
taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine);
return;
}
// 如果任务已完成,跳转到第一个 await 之后的标签
goto Il_AfterFirstAwait;
case 0:
// 从 taskAwaiter 字段中检索等待器
awaiter3 = taskAwaiter;
// 重置 taskAwaiter 字段
taskAwaiter = default(TaskAwaiter);
currentState = -1;
// 跳转到第一个 await 之后的标签
goto Il_AfterFirstAwait;
case 1:
// 从 taskAwaiter 字段中检索等待器
awaiter2 = taskAwaiter;
// 重置 taskAwaiter 字段
taskAwaiter = default(TaskAwaiter);
currentState = -1;
// 跳转到第二个 await 之后的标签
goto Il_AfterSecondAwait;
case 2:
// 从 taskAwaiter 字段中检索等待器
awaiter = taskAwaiter;
// 重置 taskAwaiter 字段
taskAwaiter = default(TaskAwaiter);
currentState = -1;
break;
Il_AfterFirstAwait:
awaiter3.GetResult();
// 调用第二个同步方法
SyncMethodB();
// 为延迟 2000 毫秒创建一个任务等待器
awaiter2 = Task.Delay(2000).GetAwaiter();
// 如果任务未完成,将当前状态设置为 1 并存储等待器
if (!awaiter2.IsCompleted)
{
currentState = 1;
taskAwaiter = awaiter2;
// 存储当前状态机
ExampleStateMachine stateMachine = this;
// 等待任务并传递状态机
taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine);
return;
}
// 如果任务已完成,跳转到第二个 await 之后的标签
goto Il_AfterSecondAwait;
Il_AfterSecondAwait:
// 获取第二个等待器的结果
awaiter2.GetResult();
// 调用 SyncMethodC
SyncMethodC();
// 使用延迟 3000 毫秒创建一个新的等待器
awaiter = Task.Delay(3000).GetAwaiter();
// 如果等待器未完成,将当前状态设置为 2 并存储等待器
if (!awaiter.IsCompleted)
{
currentState = 2;
taskAwaiter = awaiter;
// 将 stateMachine 设置为 this
ExampleStateMachine stateMachine = this;
// 等待任务并传递状态机
taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
}
break;
}
// 获取等待器的结果
awaiter.GetResult();
}
catch (Exception exception)
{
currentState = -2;
taskMethodBuilder.SetException(exception);
return;
}
currentState = -2;
taskMethodBuilder.SetResult();
}
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { /*...*/ }
}
SynchronizationContext
在 AwaitUnsafeOnCompleted
调用中,将获取当前同步上下文 SynchronizationContext
。SynchronizationContext 是 C# 中的一个概念,用于表示控制一组异步操作执行的上下文。它用于协调跨多个线程的代码执行,并确保代码按特定顺序执行。SynchronizationContext 的主要目的是提供一种方法来控制多线程环境中异步操作的调度和执行。
在不同的环境中,SynchronizationContext
有不同的实现。例如,在 .NET 中,有:
-
WPF:
System.Windows.Threading.DispatcherSynchronizationContext
-
WinForms:
System.Windows.Forms.WindowsFormsSynchronizationContext
-
WinRT:
System.Threading.WinRTSynchronizationContext
-
ASP.NET:
System.Web.AspNetSynchronizationContext
Unity 也有自己的同步上下文 UnitySynchronizationContext
,它使我们能够使用异步操作并绑定到 PlayerLoop API。以下代码示例展示了如何使用 Task.Yield()
在每一帧中旋转一个对象:
private async void Start()
{
while (true)
{
transform.Rotate(0, Time.deltaTime * 50, 0);
await Task.Yield();
}
}
另一个在 Unity 中使用 async/await 来发出网络请求的示例:
using UnityEngine;
using System.Net.Http;
using System.Threading.Tasks;
public class NetworkRequestExample : MonoBehaviour
{
private async void Start()
{
string response = await GetDataFromAPI();
Debug.Log("API 响应:" + response);
}
private async Task<string> GetDataFromAPI()
{
using (var client = new HttpClient())
{
var response = await client.GetStringAsync("https://api.example.com/data");
return response;
}
}
}
由于 UnitySynchronizationContext
,我们可以在异步操作完成后安全地使用 UnityEngine
方法(例如 Debug.Log()
),因为此代码的执行将继续在 Unity 主线程中进行。
TaskCompletitionSource
此类允许你管理一个 Task
对象。它被创建是为了将旧的异步方法适应 TAP,但当我们想要将一个 Task
包装在某个长时间运行的操作周围时,它也非常有用,该操作在某个事件发生后会完成。
在以下示例中,taskCompletionSource
内部的 Task
对象将在启动后 3 秒后完成,我们将在 Update
方法中获取其结果:
using System.Threading.Tasks;
using UnityEngine;
public class Example : MonoBehaviour
{
private TaskCompletionSource<int> taskCompletionSource;
private void Start()
{
// 创建一个新的 TaskCompletionSource
taskCompletionSource = new TaskCompletionSource<int>();
// 启动一个协程,等待 3 秒
// 然后设置 TaskCompletionSource 的结果
StartCoroutine(WaitAndComplete());
}
private IEnumerator WaitAndComplete()
{
yield return new WaitForSeconds(3);
// 设置 TaskCompletionSource 的结果
taskCompletionSource.SetResult(10);
}
private async void Update()
{
// 等待 TaskCompletionSource 的结果
int result = await taskCompletionSource.Task;
// 将结果记录到控制台
Debug.Log("结果:" + result);
}
}
取消令牌
取消令牌 在 C# 中用于发出信号,指示应该取消任务或操作。令牌被传递给任务或操作,任务或操作中的代码可以定期检查令牌以确定是否应该停止任务或操作。这允许干净、优雅地取消任务或操作,而不是突然终止它。
取消令牌通常用于以下情况:长时间运行的任务可以被用户取消,或者如果不再需要任务(例如用户界面中的取消按钮)。
总体模式类似于使用 TaskCompletionSource
。首先,创建一个 CancellationTokenSource
,然后将其 Token
传递给异步操作:
public class ExampleMonoBehaviour : MonoBehaviour
{
private CancellationTokenSource _cancellationTokenSource;
private async void Start()
{
// 创建一个新的 CancellationTokenSource
_cancellationTokenSource = new CancellationTokenSource();
// 从 CancellationTokenSource 获取令牌
CancellationToken token = _cancellationTokenSource.Token;
try
{
// 启动一个新的 Task 并传递令牌
await Task.Run(() => DoSomething(token), token);
}
catch (OperationCanceledException)
{
Debug.Log("任务已取消");
}
}
private void DoSomething(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
// 检查令牌是否已被取消
if (token.IsCancellationRequested)
{
// 如果令牌已被取消,则返回
return;
}
Debug.Log("正在执行某些操作...");
// 休眠 1 秒
Thread.Sleep(1000);
}
}
private void OnDestroy()
{
// 对象被销毁时取消令牌
_cancellationTokenSource.Cancel();
}
}
当操作被取消时,将抛出 OperationCanceledException
,并且 Task.IsCanceled
属性将被设置为 true
。
Unity 2022.2 中的新异步功能
需要注意的是,Task
对象由 .NET 运行时管理,而不是由 Unity 管理,如果执行任务的对象被销毁(或者如果游戏在编辑器中退出播放模式),任务将继续运行,因为 Unity 没有办法取消它。
你始终需要将 await Task
与相应的 CancellationToken
结合使用。这会导致一些代码冗余,在 Unity 2022.2 中,出现了 MonoBehaviour
级别和整个 Application
级别的内置令牌。
让我们看看在使用 MonoBehaviour
对象的 destroyCancellationToken
时,前面的示例是如何变化的:
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class ExampleMonoBehaviour : MonoBehaviour
{
private async void Start()
{
// 从 MonoBehaviour 获取取消令牌
CancellationToken token = this.destroyCancellationToken;
try
{
// 启动一个新的 Task 并传递令牌
await Task.Run(() => DoSomething(token), token);
}
catch (OperationCanceledException)
{
Debug.Log("任务已取消");
}
}
private void DoSomething(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
// 检查令牌是否已被取消
if (token.IsCancellationRequested)
{
// 如果令牌已被取消,则返回
return;
}
Debug.Log("正在执行某些操作...");
// 休眠 1 秒
Thread.Sleep(1000);
}
}
}
我们不再需要手动创建一个 CancellationTokenSource
并在 OnDestroy
方法中完成任务。对于与特定 MonoBehaviour
无关的任务,我们可以使用 UnityEngine.Application.exitCancellationToken
。这将在退出播放模式(在编辑器中)或退出应用程序时终止任务。
UniTask
尽管使用 .NET 任务很方便,并且它们提供了功能,但它们在 Unity 中使用时存在重大缺点:
-
Task
对象过于笨重,会导致很多分配。 -
Task
与 Unity 线程(单线程)不匹配。
UniTask(https://github.com/Cysharp/UniTask) 库绕过了这些限制,而无需使用线程或 SynchronizationContext
。它通过使用基于结构的 UniTask<T>
类型来实现没有分配。
UniTask 需要 .NET 4.x 脚本运行时版本,Unity 2018.4.13f1 是官方最低支持版本。
你也可以使用扩展方法将所有 AsyncOperations
转换为 UnitTask
:
using UnityEngine;
using UniTask;
public class AssetLoader : MonoBehaviour
{
public async void LoadAsset(string assetName)
{
var loadRequest = Resources.LoadAsync<GameObject>(assetName);
await loadRequest.AsUniTask();
var asset = loadRequest.asset as GameObject;
if (asset != null)
{
// 对加载的资产执行某些操作
}
}
}
在这个例子中,LoadAsset
方法使用 Resources.LoadAsync
异步加载资产。然后使用 AsUniTask
方法将 LoadAsync
返回的 AsyncOperation
转换为 UniTask
,该 UniTask
可以被等待。
与之前一样,你可以使用 UniTask.WhenAll
和 UniTask.WhenAny
来组织任何执行顺序的组合:
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
public class Example : MonoBehaviour
{
private async void Start()
{
// 启动两个 Task 并等待它们都完成
await UniTask.WhenAll(Task1(), Task2());
// 启动两个 Task 并等待其中一个完成
await UniTask.WhenAny(Task1(), Task2());
}
private async UniTask Task1()
{
// 执行某些操作
}
private async UniTask Task2()
{
// 执行某些操作
}
}
在 UniTask 中,还有另一个 SynchronizationContext
实现,称为 UniTaskSynchronizationContext
,它可以用来替换 UnitySynchronizationContext
以获得更好的性能。
Awaitable API
在 Unity 2023.1 中,引入了 Awaitable
类。可等待的协程是与 async/await 兼容的任务类型,旨在在 Unity 中运行。与 .NET 任务不同,它们由引擎管理,而不是由运行时管理。
private async Awaitable DoSomethingAsync()
{
// 等待内置事件
await Awaitable.EndOfFrameAsync();
await Awaitable.WaitForSecondsAsync();
// 等待 .NET 任务
await Task.Delay(2000, destroyCancellationToken);
await Task.Yield();
// 等待 AsyncOperations
await SceneManager.LoadSceneAsync("path/to/scene.unity");
// ...
}
它们可以被等待,并用作异步方法的返回值类型。与 System.Threading.Tasks
相比,它们不太复杂,但利用了基于 Unity 特定假设的性能增强捷径。
以下是与 .NET 任务的主要区别:
-
Awaitable
对象只能被等待一次;它不能被多个异步函数等待。 -
Awaiter.GetResults()
不会阻塞,直到完成。在操作完成之前调用它会导致未定义的行为。 -
永远不要捕获
ExecutionContext
。出于安全原因,.NET 任务在等待时会捕获执行上下文,以便在异步调用之间传播模拟上下文。 -
永远不要捕获
SynchronizationContext
。协程延续是从引发完成的代码同步执行的。在大多数情况下,这将来自 Unity 主帧。 -
可等待对象是池化对象,以防止过度分配。它们是引用类型,因此它们可以在不同的堆栈之间被引用,可以有效地复制等等。
ObjectPool
已经过改进,以避免在 async 状态机生成的典型获取/释放序列中进行Stack
边界检查。
要获取长时间操作的结果,可以使用 Awaitable<T>
类型。你可以使用 AwaitableCompletionSource
和 AwaitableCompletionSource<T>
,类似于 TaskCompletitionSource
来管理 Awaitable
的完成:
using UnityEngine;
using Cysharp.Threading.Tasks;
public class ExampleBehaviour : MonoBehaviour
{
private AwaitableCompletionSource<bool> _completionSource;
private async void Start()
{
// 创建一个新的 AwaitableCompletionSource
_completionSource = new AwaitableCompletionSource<bool>();
// 启动一个协程,等待 3 秒
// 然后设置 AwaitableCompletionSource 的结果
StartCoroutine(WaitAndComplete());
// 等待 AwaitableCompletionSource 的结果
bool result = await _completionSource.Awaitable;
// 将结果记录到控制台
Debug.Log("结果:" + result);
}
private IEnumerator WaitAndComplete()
{
yield return new WaitForSeconds(3);
// 设置 AwaitableCompletionSource 的结果
_completionSource.SetResult(true);
}
}
有时需要执行可能导致游戏冻结的大量计算。为此,最好使用可等待的方法:BackgroundThreadAsync()
和 MainThreadAsync()
。它们允许你退出主线程并返回到主线程。
private async Awaitable DoCalculationsAsync()
{
// 等待在 ThreadPool 后台线程上执行。
await Awaitable.BackgroundThreadAsync();
var result = PerformSomeHeavyCalculations();
// 等待在 Unity 主线程上执行。
await Awaitable.MainThreadAsync();
// 在主线程中使用结果
Debug.Log(result);
}
这样,可等待对象消除了使用 .NET 任务的缺点,并且还允许等待 PlayerLoop 事件和 AsyncOperations。
结论
正如我们所看到的,随着 Unity 的发展,组织异步操作的工具越来越多:
我们已经考虑了 Unity 中异步编程的所有主要方式。根据你的任务的复杂性和你使用的 Unity 版本,你可以使用从协程和 Promise 到 Task 和可等待对象等各种技术,以确保你的游戏中流畅、无缝的游戏体验。
想了解更多游戏开发知识,可以扫描下方二维码,免费领取游戏开发4天训练营课程