【转】Unity异步等待

原文: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<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";
    }
}

正如您所看到的,使用异步等待可能非常强大,尤其是当您开始像上面的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支持的源代码。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容