Version: Unity 6.0 (6000.0)
语言 : 中文
Split tasks across frames with coroutines
Analyzing coroutines

跨帧拆分任务

协程允许您将任务分散到多个帧中。协程是一种方法,能够暂停执行并将控制权返还给 Unity,然后在下一帧继续执行。

在大多数情况下,当调用一个方法时,它会一直运行到完成,然后将控制权返回给调用方法,同时会返回任何可选的返回值。这意味着在方法内发生的任何操作都必须在单个帧更新内完成。

如果要使用方法调用来纳入程序化动画或随着时间发生的一系列事件,则可以使用协程。

注意:必须注意的是,协程不是线程。在协程内运行的同步操作仍然是在主线程上执行。如果要减少主线程上花费的 CPU 时间,与任何其他脚本代码中一样,在协程中避免阻塞操作同样很重要。如果要在 Unity 中使用多线程代码,则可以选择:

在需处理长时间异步操作(例如等待 HTTP 传输、资源加载或文件 I/O 完成)时,最适合使用协程。

协程示例

例如,假设需要逐渐减少对象的 Alpha(不透明度)值,直至对象变得不可见:

void Fade()
{
    Color c = renderer.material.color;
    for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
    {
        c.a = alpha;
        renderer.material.color = c;
    }
}

在本示例中,淡化 (Fade) 方法不会产生期望的效果。要使淡化效果可见,必须减小一系列帧中淡化的 Alpha 值以显示 Unity 渲染的中间值。但是,此示例方法完全在单个帧更新中执行。中间值永远都不会显示,对象会立即消失。

要解决这种情况,可以向逐帧执行淡化的 Update 函数添加代码。但是,使用协程来执行此类任务通常会更加方便。

在 C# 中,可以声明如下所示协程:

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 null;
    }
}

协程是使用 IEnumerator 返回类型并在正文中包含 yield 返回语句来声明的方法。yield return null 行是执行暂停并在下一帧中恢复执行的点。要将协程设置为运行状态,必须使用 StartCoroutine 函数:

void Update()
{
    if (Input.GetKeyDown("f"))
    {
        StartCoroutine(Fade());
    }
}

Fade 函数中的循环计数器能够在协程的生命周期内保持正确值,且 yield 语句之间的任何变量或参数都保留了。

协程时间延迟

默认情况下,Unity 会在 yield 语句之后恢复帧上的协程。如果要引入时间延迟,请使用 WaitForSeconds

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);
    }
}

您可以使用 WaitForSeconds 在一段时间内扩展效果,也可以将其用作将任务纳入 Update 方法的替代方法。Unity 每秒会多次调用 Update 方法,因此如果您不需要经常重复某项任务,则可以将其放入协程中以实现定期更新,而不是每一帧都更新。

例如,可以在应用程序中设置一个警报,当附近有敌人时向玩家发出警报,代码如下:

bool ProximityCheck()
{
    for (int i = 0; i < enemies.Length; i++)
    {
        if (Vector3.Distance(transform.position, enemies[i].transform.position) < dangerDistance) {
                return true;
        }
    }

    return false;
}

如果有很多敌人,那么每帧都调用此函数可能会带来很大开销。但是,可以使用协程,每十分之一秒调用一次:

IEnumerator DoCheck()
{
    for(;;)
    {
        if (ProximityCheck())
        {
            // Perform some action here
        }
        yield return new WaitForSeconds(.1f);
    }
}

这会减少 Unity 所进行的检查次数,而不会对游戏运行过程产生任何明显影响。

停止协程

要停止协程,请使用 StopCoroutineStopAllCoroutines。如果将 SetActive 设置为 false 以禁用协程所附加的游戏对象,协程也会停止。调用 Destroy(example)(其中 exampleMonoBehaviour 实例)会立即触发 OnDisable,Unity 会处理协程,从而有效地停止协程。最后,在帧的末尾调用 OnDestroy

注意:如果通过将 enabled 设置为 false 来禁用 MonoBehaviour,Unity 则不会停止协程。

分析协程

协程的执行方式与其他脚本代码不同。Unity 中的大多数脚本代码显示在性能跟踪内的位于特定回调调用下的某个位置。但是,协程的 CPU 代码总是出现在跟踪内的两个位置。

每当 Unity 启动协程时,协程中的所有初始代码(从协程方法的开始一直到第一个 yield 语句)都将出现在跟踪过程中。初始代码通常在调用 StartCoroutine 方法时出现。从 Unity 回调(例如返回 IEnumeratorStart 回调)生成的协程首先出现在各自的 Unity 回调中。

协程代码的其余部分(从第一次恢复一直到完成执行)将显示在 Unity 主循环内的 DelayedCallManager 行中。

这是由于 Unity 的协程执行方式所致。C# 编译器会自动生成一个支持协程的类的实例。然后,Unity 会将此对象用于跟踪单个方法的多次调用之间的协程状态。因为协程中的局部作用域变量必须在 yield 调用中保持一致,所以 Unity 会将这些局部作用域变量保存到生成的类中,而这些变量在协程的存活期内会保留在堆上的地址分配。该对象还会跟踪协程的内部状态:它会记住协程暂停后必须从代码中的哪一点恢复。

因此,启动协程产生的内存压力等于固定开销分配加上其局部作用域变量的消耗。

启动协程的代码将构造并调用对象,然后 Unity 的 DelayedCallManager 在每当满足协程的 yield 条件时会再次调用此对象。由于协程通常在其他协程之外启动,因此这会将它们的执行成本分担到 yield 调用和 DelayedCallManager

您可以使用 Unity 性能分析器 (Unity Profiler) 来检查和了解 Unity 在应用程序中执行协程的位置。为此,请在启用深度性能分析 (Deep Profiling) 的情况下对应用程序进行性能分析,这会对脚本代码的每个部分进行性能分析并记录所有函数调用。然后,您可以使用 CPU 使用率性能分析器 (CPU Usage Profiler) 模块来调查应用程序中的协程。

在 DelayedCall 中使用协程的性能分析器 (Profiler) 会话
在 DelayedCall 中使用协程的性能分析器 (Profiler) 会话

最佳做法是尽可能将一系列操作压缩到最少数量的协程中。嵌套的协程对确保代码的条理性和进行维护非常有用,但协程跟踪对象本身会导致产生更高的内存开销。

如果一个协程每帧都运行并且在长时间运行操作中不会 yield,那么用 UpdateLateUpdate 回调来替换它会更加高效。如果有长时间运行或无限循环的协程,这会很有用。

其他资源

Split tasks across frames with coroutines
Analyzing coroutines