[Unity] Unity 2017 中的 Async - Await代替协程

英文原文:

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# 功能。与协程相比,它具有许多不错的功能。

  要启用此功能,您只需打开播放器设置(Edit -> Project Settings -> Player)并将“脚本运行时版本”更改为“实验性(.NET 4.6 等效)。

让我们看一个简单的例子。给定以下协程:

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# 对iterator blocks的内置支持来实现的。您提供给 StartCoroutine 方法的 IEnumerator 迭代器对象由 Unity 保存,并且该迭代器对象的每一帧都向前推进以获取由您的协程产生的新值。然后,Unity 会读取“yield return”的不同值以触发特殊情况行为,例如执行嵌套协程(返回另一个 IEnumerator 时)、延迟几秒(返回 WaitForSeconds 类型的实例时),或者只是等到下一帧(返回 null 时)。

  不幸的是,由于 async-await 在 Unity 中是相当新的事实,因此上述对协程的内置支持并不以类似的方式存在于 async-await 中。这意味着我们必须自己添加很多这种支持。

  然而,Unity 确实为我们提供了一个重要的部分。正如您在上面的示例中所看到的,我们的异步方法将默认在主unity线程上运行。在非unity C# 应用程序中,异步方法通常在单独的线程上自动运行,这在 Unity 中将是一个大问题,因为在这些情况下我们并不总是能够与 Unity API 交互。如果没有 Unity 引擎的这种支持,我们在异步方法中对 Unity 方法/对象的调用有时会失败,因为它们将在单独的线程上执行。在底层它是这样工作的,因为 Unity 提供了一个名为 UnitySynchronizationContext 的默认 SynchronizationContext,它会自动收集每帧排队的任何异步代码并继续在主unity线程上运行它们。

  然而,事实证明,这足以让我们开始使用 async-await!我们只需要一些帮助代码来让我们做一些比简单的时间延迟更有趣的事情。

Custom Awaiters

  目前,我们可以编写的有趣的异步代码并不多。我们可以调用其他异步方法,也可以使用 Task.Delay,就像上面的示例一样,但仅此而已。

  作为一个简单的例子,让我们添加在 TimeSpan 上直接“等待”的能力,而不是像上面的例子那样每次都必须调用 Task.Delay。像这样:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        await TimeSpan.FromSeconds(1);
    }
}

  为了支持这一点,我们需要做的就是简单地将自定义 GetAwaiter 扩展方法添加到 TimeSpan 类:

public static class AwaitExtensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
    {
        return Task.Delay(timeSpan).GetAwaiter();
    }
}

  这是因为为了支持在较新版本的 C# 中“awaiting”给定对象,所需要的只是该对象有一个名为 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<GameObject>("myasset");
        GameObject.Instantiate(prefab);
        assetBundle.Unload(false);
    }
 
    async Task<AssetBundle> 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";
    }
}

  如您所见,像这样使用 async await 可能非常强大,尤其是当您开始将多个 async 方法组合在一起时,就像上面的 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 方法中发生任何异常,它们将静默发生。异常不会记录到Unity控制台。

  这是因为在返回 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,因此永远不会记录到console。

  但这给我们留下了一个问题,即在我们想要从非异步代码调用异步方法的情况下该怎么做。在上面的示例中,我们希望从 OnGUI 方法内部启动 RunTaskAsync 异步方法,并且我们不关心等待它完成,因此我们不想添加一个 await 来记录异常.

这里要记住的经验法则是:

永远不要在没有等待返回的任务的情况下调用“异步任务”方法。如果您不想等待异步行为完成,则应改为调用 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 Task”方法时应该会收到警告,这是避免此错误的好方法。

  作为创建自己的“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() 方法只是一种确保 Task 得到等待的通用方法,因此 Unity 将始终接收抛出的任何异常。它只是做了一个等待,就是这样:

public static async void WrapErrors(this Task task)
{
    await task;
}
从协程调用async

  对于某些代码库,从协程迁移到使用 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;
        }
    }
}

现在我们可以像这样从协程调用异步方法:

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()
    {
        // 在这里,我们在Unity线程上
 
        await Task.Delay(TimeSpan.FromSeconds(1.0f)).ConfigureAwait(false);
 
        // 在这里,我们可能会或可能不会在统一线程上,
        // 具体取决于我们在执行 ConfigureAwait 之前执行的任务的方式
    }
}

  如上所述,Unity 提供了一个称为默认 SynchronizationContext 的东西,默认情况下它将在 Unity 主线程上执行异步代码。 ConfigureAwait 方法允许我们覆盖此行为,因此结果将是 await 下面的代码将不再保证在 Unity 主线程上运行,而是从我们正在执行的任务中继承上下文,其中有些情况可能是我们想要的。

  如果要在后台线程上显式执行代码,也可以这样做:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // 我们在这里unity线程
 
        await new WaitForBackgroundThread();
 
        //我们现在在后台线程上
        // NOTE: 不要在这里调用任何unity对象或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,而使用async Task,除非在 "fire and forget "的情况下,你想从非async代码中启动async代码。
  • 将后缀“Async”附加到所有返回任务的异步方法。这有助于传达这样一个事实,即它应该始终以“await”开头,并允许轻松添加异步无效对应项而不会发生冲突。
  • 在 Visual Studio 中使用断点调试异步方法还不起作用。然而,“VS tools for Unity”团队表示他们正在研究它,如此处所示。
UniRx

  执行异步逻辑的另一种方法是使用响应式编程和 UniRx 之类的库。就我个人而言,我非常喜欢这种类型的编码,并在我参与的许多项目中广泛使用它。幸运的是,它很容易与 async-await 一起使用,只需另一个自定义等待器即可。例如:

public class AsyncExample : MonoBehaviour
{
    public Button TestButton;
 
    async void Start()
    {
        await TestButton.OnClickAsObservable();
        Debug.Log("Clicked Button!");
    }
}

  我发现 UniRx 可观察对象与长期运行的异步方法/协程的用途不同,因此它们自然适合使用 async-await 的工作流程,就像上面的示例一样。我不会在这里详细介绍,因为 UniRx 和响应式编程本身是一个单独的主题,但我会说,一旦你习惯于根据 UniRx “流”来考虑应用程序中的数据流,就没有回头路了。

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Unity的异步操作通常使用C#的`async/await`关键字来实现。`async/await`是一种编写异步代码的简洁方式,它允许您以同步的方式编写代码,而实际上在后台进行异步操作。 在Unity,您可以在适当的方法或函数前面加上`async`关键字来指示该方法将是一个异步方法。然后,您可以使用`await`关键字来等待一个异步操作完成,而不会阻塞主线程。 以下是一个简单的示例,展示了如何在Unity使用`async/await`: ```csharp using UnityEngine; using System.Threading.Tasks; public class AsyncExample : MonoBehaviour { private async void Start() { await DoSomethingAsync(); // 等待异步操作完成 Debug.Log("异步操作完成"); } private async Task DoSomethingAsync() { await Task.Delay(1000); // 模拟一个耗时操作 Debug.Log("异步操作完成"); } } ``` 在上面的示例,`Start`方法被标记为`async`,因此可以使用`await`关键字。`DoSomethingAsync`方法也被标记为`async`,表示它是一个异步方法。在`Start`方法,我们使用`await DoSomethingAsync()`等待`DoSomethingAsync`方法完成。 请注意,异步方法必须返回`Task`、`Task<T>`或`void`类型。如果您需要返回结果,可以使用`Task<T>`,其`T`是您要返回的类型。 这只是一个简单的示例,您可以在异步方法执行更复杂的操作,例如网络请求、文件读写等。使用`async/await`可以使您的代码更清晰、易读和易于维护。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值