原文:http://www.stevevermeulen.com/index.php/2017/09/using-async-await-in-unity3d-2017/
在Unity中使用协同程序通常是解决某些问题的好方法,但它也有一些缺点:
-
1.协同程序无法返回值。这鼓励程序员创建巨大的单片协程,而不是用许多小方法编写它们。存在一些变通方法,例如将Action <>类型的回调参数传递给协同程序,或者在协程完成后转换从协同程序产生的最终无类型值,但这些方法使用起来很容易并且容易出错。
-
2.协同程序使错误处理变得困难。您不能将yield放在try-catch中,因此无法处理异常。此外,当异常确实发生时,堆栈跟踪仅告诉您抛出异常的协同程序,因此您必须猜测它可能从哪个协程调用。
随着Unity 2017的发布,现在可以使用名为async-await的新C#功能代替我们的异步方法。与协同程序相比,它具有许多不错的功能。
要启用此功能,您只需打开播放器设置(File - >Build Settings - >Player Settings.. ->Other Settings )并将“Scripting Runtime Version“改为(.NET 4.x)”。
我们来看一个简单的例子。鉴于以下协程:
public class AsyncExample : MonoBehaviour
{
IEnumerator Start()
{
Debug.Log("Waiting 1 second...");
yield return new WaitForSeconds(1.0f);
Debug.Log("Done!");
}
}
使用async-await执行此操作的等效方法如下:
public class AsyncExample : MonoBehaviour
{
async void Start()
{
Debug.Log("Waiting 1 second...");
await Task.Delay(TimeSpan.FromSeconds(1));
Debug.Log("Done!");
}
}
在这两种情况下,有点意识到引擎盖下发生了什么是有帮助的。
简而言之,Unity协程是使用C#对迭代器块的内置支持实现的。您提供给StartCoroutine方法的IEnumerator迭代器对象由Unity保存,每个帧此迭代器对象向前推进以获取由您的协同程序产生的新值。然后,Unity会读取“返回”的不同值以触发特殊情况行为,例如执行嵌套协程(返回另一个IEnumerator时),延迟一些秒(当返回WaitForSeconds类型的实例时),或者只是等到下一帧(返回null时)。
不幸的是,由于async-await在Unity中是一个非常新的事实,如上所述的这种对协同程序的内置支持并不像async-await那样以类似的方式存在。这意味着我们必须自己添加很多这种支持。
Unity确实为我们提供了一个重要的部分。正如您在上面的示例中所看到的,默认情况下,我们的异步方法将在主Unity线程上运行。在非统一C#应用程序中,异步方法通常自动在不同的线程上运行,这在Unity中是一个很大的问题,因为在这些情况下我们无法始终与Unity API进行交互。如果没有Unity引擎的支持,我们在异步方法中对Unity方法/对象的调用有时会失败,因为它们将在一个单独的线程上执行。它的工作原理是这样,因为Unity提供了一个名为UnitySynchronizationContext的默认SynchronizationContext,它自动收集每帧排队的任何异步代码,并继续在主统一线程上运行它们。
然而,事实证明,这足以让我们开始使用async-await!我们只需要一些辅助代码就可以让我们做一些有趣的事情,而不仅仅是简单的时间延迟。
定制Awaiters
目前,我们无法编写很多有趣的异步代码。我们可以调用其他异步方法,我们可以使用Task.Delay,就像上面的例子一样,但不是很多。
举个简单的例子,让我们添加直接'等待'在TimeSpan上的能力,而不是每次都像上面的例子一样调用Task.Delay。像这样:
public class AsyncExample : MonoBehaviour
{
async void Start()
{
await TimeSpan.FromSeconds(1);
}
}
我们需要做的就是只需向TimeSpan类添加一个自定义GetAwaiter扩展方法:
public static class AwaitExtensions
{
public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
{
return Task.Delay(timeSpan).GetAwaiter();
}
}
这是有效的,因为为了支持在新版本的C#中“等待”给定对象,所需要的只是该对象有一个名为GetAwaiter的方法,它返回一个Awaiter对象。这很好,因为它允许我们通过使用上面的扩展方法等待我们想要的任何东西,而无需更改实际的TimeSpan类。
我们也可以使用相同的方法来支持等待其他类型的对象,包括Unity用于协程指令的所有类!我们可以使WaitForSeconds,WaitForFixedUpdate,WWW等等所有等待它们在协同程序中可以获得的方式相同。我们还可以向IEnumerator添加一个GetAwaiter方法,以支持等待协同程序,以允许与旧的IEnumerator代码交换异步代码。
实现所有这些的代码可以从资产商店或github 仓库的发布部分下载。这允许您执行以下操作:
public class AsyncExample : MonoBehaviour
{
public async void Start()
{
// Wait one second
await new WaitForSeconds(1.0f);
// Wait for IEnumerator to complete
await CustomCoroutineAsync();
await LoadModelAsync();
// You can also get the final yielded value from the coroutine
var value = (string)(await CustomCoroutineWithReturnValue());
// value is equal to "asdf" here
// Open notepad and wait for the user to exit
var returnCode = await Process.Start("notepad.exe");
// Load another scene and wait for it to finish loading
await SceneManager.LoadSceneAsync("scene2");
}
async Task LoadModelAsync()
{
var assetBundle = await GetAssetBundle("www.my-server.com/myfile");
var prefab = await assetBundle.LoadAssetAsync("myasset");
GameObject.Instantiate(prefab);
assetBundle.Unload(false);
}
async Task GetAssetBundle(string url)
{
return (await new WWW(url)).assetBundle
}
IEnumerator CustomCoroutineAsync()
{
yield return new WaitForSeconds(1.0f);
}
IEnumerator CustomCoroutineWithReturnValue()
{
yield return new WaitForSeconds(1.0f);
yield return "asdf";
}
}
正如您所看到的,使用异步等待可能非常强大,尤其是当您开始像上面的LoadModelAsync方法一样组合多个异步方法时。
请注意,对于返回值的异步方法,我们使用Task的泛型版本并将返回类型作为泛型参数传递,就像上面的GetAssetBundle一样。
另请注意,在大多数情况下,使用上面的WaitForSeconds实际上比我们的TimeSpan扩展方法更可取,因为WaitForSeconds将使用Unity游戏时间,而我们的TimeSpan扩展方法将始终使用实时(因此它不会受到Time.timeScale更改的影响)
触发异步代码和异常处理
您可能已经注意到我们上面的代码有一件事是,某些方法被定义为'async void',而有些方法被定义为'async Task'。那么什么时候应该使用另一个呢?
这里的主要区别是,其他异步方法无法等待定义为“async void”的方法。这表明我们应该总是更喜欢用返回类型Task定义我们的异步方法,以便我们可以“等待”它们。
此规则的唯一例外是当您要从非异步代码调用异步方法时。请看以下示例:
public class AsyncExample : MonoBehaviour
{
public void OnGUI()
{
if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
{
RunTaskAsync();
}
}
async Task RunTaskAsync()
{
Debug.Log("Started task...");
await new WaitForSeconds(1.0f);
throw new Exception();
}
}
在此示例中,当用户单击按钮时,我们要启动异步方法。此代码将编译并运行,但是它存在一个主要问题。如果在RunTaskAsync方法中发生任何异常,它们将以静默方式发生。该异常将不会记录到统一控制台。
这是因为当异步方法返回Task时发生异常时,它们将被返回的Task对象捕获,而不是被Unity抛出和处理。此行为存在的原因很充分:允许异步代码与try-catch块一起正常工作。以下面的代码为例:
async Task DoSomethingAsync()
{
var task = DoSomethingElseAsync();
try
{
await task;
}
catch (Exception e)
{
// do something
}
}
async Task DoSomethingElseAsync()
{
throw new Exception();
}
这里,异常由DoSomethingElseAsync方法返回的Task捕获,并且仅在“等待”时才重新抛出。如您所见,调用异步方法与等待它们不同,这就是为什么必须让Task对象捕获异常。
因此,在上面的OnGUI示例中,当在RunTaskAsync方法中抛出异常时,它会被返回的Task对象捕获,并且由于此Task上没有任何内容,因此异常不会冒泡到Unity,因此永远不会记录到安慰。
但这让我们想到在这些情况下我们想要从非异步代码调用异步方法的问题。在上面的示例中,我们希望从OnGUI方法内部启动RunTaskAsync异步方法,我们不关心等待它完成,因此我们不希望只添加await以便可以记录异常。
要记住的经验法则是:
永远不要在没有等待返回的Task的情况下调用async Task
方法。如果您不想等待异步行为完成,则应该调用async void
方法。
所以我们的例子变成:
public class AsyncExample : MonoBehaviour
{
public void OnGUI()
{
if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
{
RunTask();
}
}
async void RunTask()
{
await RunTaskAsync();
}
async Task RunTaskAsync()
{
Debug.Log("Started task...");
await new WaitForSeconds(1.0f);
throw new Exception();
}
}
如果再次运行此代码,您现在应该看到记录了异常。这是因为当在RunTask方法中的await期间抛出异常时,它会冒泡到Unity并记录到控制台,因为在这种情况下没有任何Task对象可以捕获它。
标记为“async void”的方法表示某些异步行为的根级别“入口点”。考虑它们的一个好方法是,它们是“发射并忘记”的任务,在任何调用代码立即继续的情况下,在后台执行某些操作。
顺便说一句,这也是遵循总是在返回Task的异步方法上使用后缀“Async”的惯例的一个很好的理由。这是大多数使用async-await的代码库的标准做法。它有助于传达这样一个事实:该方法应始终以'await'开头,但也允许您为不包含后缀的方法创建一个async void
对应物。
另外值得一提的是,如果您在Visual Studio中编译代码,那么当您尝试在没有相关等待的情况下调用“异步任务”方法时,您应该会收到警告,这是避免此错误的好方法。
作为创建自己的“async void”方法的替代方法,您还可以使用辅助方法(包含在与本文相关的源代码中)来执行等待。在这种情况下,我们的例子将成为:
public class AsyncExample : MonoBehaviour
{
public void OnGUI()
{
if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
{
RunTaskAsync().WrapErrors();
}
}
async Task RunTaskAsync()
{
Debug.Log("Started task...");
await new WaitForSeconds(1.0f);
throw new Exception();
}
}
WrapErrors()方法只是确保等待任务的通用方法,因此Unity将始终接收任何抛出的异常。它只是做了等待,就是这样:
public static async void WrapErrors(this Task task)
{
await task;
}
从协同程序调用异步
对于某些代码库,从协程迁移到使用async-await似乎是一项艰巨的任务。我们可以通过逐步采用async-await来简化这个过程。但是,为了做到这一点,我们不仅需要能够从异步代码调用IEnumerator代码,而且我们还需要能够从IEnumerator代码调用异步代码。值得庆幸的是,我们可以使用另一种扩展方法轻松添加:
public static class TaskExtensions
{
public static IEnumerator AsIEnumerator(this Task task)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception;
}
}
}
现在我们可以从coroutines调用异步方法,如下所示:
public class AsyncExample : MonoBehaviour
{
public void Start()
{
StartCoroutine(RunTask());
}
IEnumerator RunTask()
{
yield return RunTaskAsync().AsIEnumerator();
}
async Task RunTaskAsync()
{
// run async code
}
}
多线程
我们也可以使用async-await来执行多个线程。你可以用两种方式做到这一点。第一种方法是使用ConfigureAwait方法,如下所示:
public class AsyncExample : MonoBehaviour
{
async void Start()
{
// Here we are on the unity thread
await Task.Delay(TimeSpan.FromSeconds(1.0f)).ConfigureAwait(false);
// Here we may or may not be on the unity thread depending on how the task that we
// execute before the ConfigureAwait is implemented
}
}
如上所述,Unity提供了一种称为默认SynchronizationContext的东西,默认情况下它将在主Unity线程上执行异步代码。ConfigureAwait方法允许我们覆盖此行为,因此结果将是await下面的代码将不再保证在主Unity线程上运行,而是从我们正在执行的任务继承上下文,有些情况可能是我们想要的。
如果要在后台线程上显式执行代码,还可以执行以下操作:
public class AsyncExample : MonoBehaviour
{
async void Start()
{
// We are on the unity thread here
await new WaitForBackgroundThread();
// We are now on a background thread
// NOTE: Do not call any unity objects here or anything in the unity api!
}
}
WaitForBackgroundThread是这篇文章的源代码中包含的一个类,它将完成启动新线程的工作,并确保重写Unity的默认SynchronizationContext行为。
那么回到Unity线程呢?
您只需等待我们在上面创建的任何Unity特定对象即可。例如:
public class AsyncExample : MonoBehaviour
{
async void Start()
{
// Unity thread
await new WaitForBackgroundThread();
// Background thread
await new WaitForSeconds(1.0f);
// Unity thread again
}
}
包含的源代码还提供了一个WaitForUpdate()类,如果您只想在没有任何延迟的情况下返回Unity线程,可以使用它:
public class AsyncExample : MonoBehaviour
{
async void Start()
{
// Unity thread
await new WaitForBackgroundThread();
// Background thread
await new WaitForUpdate();
// Unity thread again
}
}
当然,如果您使用后台线程,则需要非常小心以避免并发问题。但是,在很多情况下,提高性能是值得的。
陷阱和最佳实践
- 避免async void支持异步任务,除了你想要从非异步代码启动异步代码的'fire and forget'情况
- 将后缀“Async”附加到返回Task的所有异步方法。这有助于传达它应该始终以'await'开头的事实,并允许在不冲突的情况下轻松添加异步void对应
- 在visual studio中使用断点调试异步方法尚不可行。然而,正如此处所示,“VS Unity for Unity”团队表示正在开展工作
UniRx
另一种做异步逻辑的方法是使用像UniRx这样的库进行反应式编程。就个人而言,我是这种编码的忠实粉丝,并且在我参与的许多项目中广泛使用它。幸运的是,与async-await和另一个自定义awaiter一起使用非常容易。例如:
public class AsyncExample : MonoBehaviour
{
public Button TestButton;
async void Start()
{
await TestButton.OnClickAsObservable();
Debug.Log("Clicked Button!");
}
}
我发现UniRx observable与长期运行的异步方法/协同程序有不同的用途,所以它们自然适合使用async-await的工作流程,如上例所示。我不会在这里详细介绍,因为UniRx和反应式编程本身就是一个单独的主题,但是我会说,一旦你对UniRx“流”中的数据流感到满意,就不会有回头路了。 。
源代码
您可以从资产商店或github repo的版本部分下载包含async-await支持的源代码。