Unity 2017中使用Async-Await替代 coroutines

作者: 归零者 分类: 未分类 发布时间: 2018-12-16 21:05
在Unity中使用协同程序通常是解决某些问题的好方法,但它也带来了一些缺陷:
 
协程不能返回值。 这鼓励程序员创建巨大的整体协同程序方法,而不是从许多较小的方法中构建它们。
协调程序使错误处理变得困难。 您不能将yield放在try-catch中,因此不可能处理异常。 另外,当异常确实发生时,堆栈跟踪只会告诉您在何处抛出异常,所以你必须猜测它可能调用了哪些其他协程。
 
随着Unity 2017的发布,现在可以使用一个称为async-await的新的C#功能来代替我们的异步方法。 与协同程序相比,它具有很多不错的功能。
要启用此功能,所有您需要做的是打开播放器设置(编辑 – >项目设置 – >播放器),并将“脚本运行时版本”更改为“实验(.NET 4.6等效)”。
我们来看一个简单的例子。 给定以下协程:
 
[C#] 纯文本查看 复制代码
1
2
3
4
5
6
7
8
public class AsyncExample : MonoBehaviour
{
    IEnumerator Start()
    {
        Debug.Log("Waiting 1 second...");
        yield return new WaitForSeconds(1.0f);
        Debug.Log("Done!");
    }
}


 
使用async-await等效的方法将是以下内容:
 
[C#] 纯文本查看 复制代码
1
2
3
4
5
6
7
8
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时)。
 
不幸的是,由于在Unity中异步等待是相当新的,如上所述,对协同程序的内置支持对于async-await不是以类似的方式存在。 这意味着我们必须添加很多这样的支持。
 
Unity确实为我们提供了一件重要的事情。 正如你在上面的例子中看到的,我们的异步方法默认情况下将在主unity线程上运行。 在非Unity的C#应用程序中,异步方法通常会在单独的线程中自动运行,这在Unity中将是一个大问题,因为在这些情况下,我们并不总是能够与Unity API进行交互。 没有Unity引擎的支持,我们对Unity方法/对象的调用有时会失败,因为它们将在单独的线程上执行。 在引擎框架下,它的工作原理是因为Unity提供了一个名为UnitySynchronizationContext的默认SynchronizationContext,它会自动收集每个帧排队的任何异步代码,并在主要的Unity线程上继续运行它们。
 
不过事实证明,这足以令我们开始。 我们只需要一些帮助代码,让我们做一些更有趣的事情,而不仅仅是简单的时间延迟。
 
自定义 Awaiters
目前,我们可以编写很多有趣的异步代码。 我们可以调用其他异步方法,我们可以使用Task.Delay,就像上面的例子中的那样,但不是很多。
 
作为一个简单的例子,让我们添加直接在TimeSpan上等待的能力,而不是每次像上面的例子一样总是要调用Task.Delay。 如下:
 
[C#] 纯文本查看 复制代码
1
2
3
4
5
6
public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        await TimeSpan.FromSeconds(1);
    }
}


 
我们需要做的只是为TimeSpan类添加一个自定义GetAwaiter扩展方法:
 
[C#] 纯文本查看 复制代码
1
2
3
4
5
6
public static class AwaitExtensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
    {
        return Task.Delay(timeSpan).GetAwaiter();
    }
}


 
这是因为为了支持在较新版本的C#中“等待”一个给定的对象,所需要的只是对象有一个名为GetAwaiter的方法返回Awaiter对象。 这是伟大的,因为它允许我们等待我们想要的任何东西,像上面的TimeSpan对象,而不需要改变实际的TimeSpan类。
 
我们可以使用同样的方法来支持等待其他类型的对象,包括Unity用于协程指令的所有类; 我们可以使WaitForSeconds,WaitForFixedUpdate,WWW等都等同于在协同程序中可以实现的方式。 我们还可以向IEnumerator添加一个GetAwaiter方法,以支持等待协程来允许使用旧的IEnumerator代码来互换异步代码。
 
使所有这些发生的代码可以从github repo的发行版中下载。 这样,您可以执行以下操作:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 void”的方法不能被其他异步方法调用。 因此,这将表明我们应该始终使用返回类型的Task或Task <T>来定义我们的异步方法,以允许它们被其他异步方法调用。
但是,这会导致一个问题,因为异常仅在标记为“async void”的方法中发生时才记录到统一控制台。 这种行为存在很好的理由 – 允许异步代码与try-catch块正常工作。 采取以下代码:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
async Task RunAsync()
{
    var task = NestedRunAsync();
 
    try
    {
        await task;
    }
    catch (Exception e)
    {
        // do something
    }
}
 
async Task NestedRunAsync()
{
    throw new Exception();
}


 
在这里,异常被NestedRunAsync方法返回的Task捕获,只有当它被等待时才被再次触发。 正如你所看到的,调用异步方法不同于等待它,这就是为什么有必要让Task对象捕获异常。
但是,这让我们有一个关于如何处理我们调用的根级异步方法的问题? 例如:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
public class AsyncExample : MonoBehaviour
{
    public void Start()
    {
        RunAsync();
    }
 
    async Task RunAsync()
    {
        throw new Exception();
    }
}


 
在这种情况下,异常将被Task对象捕获,不会被记录到unity控制台。
我建议的解决方案来避免这个问题是为了使用一个简单的扩展方法:
 
[C#] 纯文本查看 复制代码
1
2
3
4
5
6
public static class AwaitExtensions
{
    public static async void WrapErrors(this Task task)
    {
        await task;
    }
}


 
而使用情况就是这样的:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
public class AsyncExample : MonoBehaviour
{
    public void Start()
    {
        RunAsync().WrapErrors();
    }
 
    async Task RunAsync()
    {
        throw new Exception();
    }
}


 
这样做非常好,并正确输出我们的异步代码中出现的任何未处理的异常(使用完整异步链的堆栈跟踪!)。 所以我们可以采用我们期望的约定,总是为所有的异步方法返回Task或Task <T>,没有任何问题。
另外,如果您正在使用visual studio进行编译,那么在调用不返回任务的代码时,应该收到一个警告 – 记住使用WrapErrors()方法非常有帮助。
从协同程序调用异步
对于一些代码库,从协同程序迁移到使用异步等待似乎是一项艰巨的任务。 我们可以通过允许异步等待逐步采用来简化此过程。 为了做到这一点,我们不仅需要从异步代码调用IEnumerator代码的能力,而且还需要能够从IEnumerator代码调用异步代码。 幸运的是,我们可以非常容易地添加另一个扩展方法:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
public static class TaskExtensions
{
    public static IEnumerator AsIEnumerator(this Task task)
    {
        while (!task.IsCompleted)
        {
            yield return null;
        }
 
        if (task.IsFaulted)
        {
            throw task.Exception;
        }
    }
}


 
现在我们可以从这样的协程中调用异步方法:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class AsyncExample : MonoBehaviour
{
    public void Start()
    {
        StartCoroutine(Run());
    }
 
    IEnumerator Run()
    {
        yield return RunAsync().AsIEnumerator();
    }
 
    async Task RunAsync()
    {
        // run async code
    }
}


 
多线程
我们也可以使用async-await来执行多个线程。 你可以通过两种方法来实现。 第一种方法是使用ConfigureAwait方法,如下所示:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
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线程上运行,而是从我们正在执行的任务继承上下文, 有些情况可能是我们想要的。
如果要在后台线程上显式执行代码,还可以执行以下操作:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
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特定对象来做到这一点。 例如:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Unity thread
 
        await new WaitForBackgroundThread();
 
        // Background thread
 
        await new WaitForSeconds(1.0f);
 
        // Unity thread again
    }
}


 
包含的源代码还提供了一个类WaitForUpdate(),您可以使用它,如果您只想返回到unity线程没有任何延迟:
 
[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Unity thread
 
        await new WaitForBackgroundThread();
 
        // Background thread
 
        await new WaitForUpdate();
 
        // Unity thread again
    }
}


 
当然,如果你使用后台线程,你需要非常小心避免并发问题。 然而,在很多情况下,提高性能是值得的。
 
Gotchas和最佳实践
  •  
  • 避免异步无效。 始终使用异步任务或异步任务<T>。
  • 当从非异步代码调用异步代码时,始终调用WrapErrors
  • 将后缀“Async”附加到所有异步方法。 这在使用异步的大多数代码库中都是非常标准的做法,有助于传达一个事实,即它应该始终处于“等待”
  • 在visual studio中使用断点调试异步方法不起作用。 然而,“VS工具团队”团队说,他们正在努力,如这里所示


UniRx
使用异步逻辑的另一种方法是使用反应式编程与像UniRx这样的库。 就个人而言,我是这种编码的巨大粉丝,并且广泛地用于我参与的许多项目中。 而且幸运的是,与另外一个定制的等待人一起等待异步等待很容易。 例如:
[C#] 纯文本查看 复制代码
1
2
3
4
5
6
7
8
9
public class AsyncExample : MonoBehaviour
{
    public Button TestButton;
 
    async void Start()
    {
        await TestButton.OnClickAsObservable();
        Debug.Log("Clicked Button!");
    }
}


 
我发现UniRx可观察器与长时间运行的异步方法/协同程序有着不同的目的,所以在上面的例子中,它们与工作流一起使用async-await自然而然。 我不会在这里详细介绍,因为UniRx和反应性编程本身就是一个单独的主题,但是我会说,一旦你以UniRx“流”的方式思考了应用程序中的数据流,就没有回头。
 
源代码
您可以从github repo的发行版中下载包含async-await支持的源代码。
 
深入阅读:
Async and Await
Task-based Asynchronous Pattern document
Async in depth
Best Practices in Asynchronous Programming
Official MSDN documentation
Coroutines In Detail
UniRx Reactive Extensions

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!

说点什么

avatar
  Subscribe  
提醒
跳至工具栏