StartCoroutine/yield 返回模式在 Unity 中到底如何工作?

2023-11-24

我了解了协程的原理。我知道如何达到标准StartCoroutine / yield return在 Unity 中的 C# 中工作的模式,例如调用一个方法返回IEnumerator via StartCoroutine并在该方法中做某事,做yield return new WaitForSeconds(1);等一下,然后做其他事情。

我的问题是:幕后到底发生了什么?什么是StartCoroutine真的吗?什么IEnumerator is WaitForSeconds返回?如何StartCoroutine将控制权返回给被调用方法的“其他内容”部分?所有这些如何与 Unity 的并发模型交互(其中许多事情在不使用协程的情况下同时发生)?


经常引用的Unity3D协程详解链接已失效。由于评论和答案中提到了它,我将在这里发布文章的内容。此内容来自这面镜子.


Unity3D协程详解

游戏中的许多过程都是在多个帧的过程中发生的。你有“密集”的过程,比如寻路,每个帧都努力工作,但会分成多个帧,以免对帧速率产生太大影响。你有“稀疏”的进程,比如游戏触发器,它们在大多数帧中不执行任何操作,但偶尔会被要求执行关键工作。两者之间有各种各样的流程。

每当您创建一个将在多个帧上进行的进程(无需多线程)时,您需要找到某种方法将工作分解为可以每帧运行一个的块。对于任何具有中心循环的算法,这是相当明显的:例如,可以构造 A* 探路者,使其半永久地维护其节点列表,每帧仅处理打开列表中的少数节点,而不是尝试一口气完成所有工作。需要进行一些平衡来管理延迟 - 毕竟,如果您将帧速率锁定在每秒 60 或 30 帧,那么您的过程每秒只会执行 60 或 30 个步骤,这可能会导致该过程仅执行整体太长了。一个简洁的设计可能会在一个级别上提供尽可能最小的工作单元——例如处理单个 A* 节点 – 并在顶部分层一种将工作分组为更大块的方法 – 例如继续处理 A* 节点 X 毫秒。 (有些人称之为“时间切片”,但我不这么认为)。

尽管如此,允许以这种方式分解工作意味着您必须将状态从一帧转移到下一帧。如果您要分解迭代算法,那么您必须保留迭代之间共享的所有状态,以及跟踪下一步要执行哪个迭代的方法。这通常不算太糟糕——“A*探路者类”的设计相当明显——但也有其他情况,不太令人愉快。有时,您将面临长时间的计算,这些计算在帧与帧之间执行不同类型的工作;捕获其状态的对象最终可能会得到一大堆半有用的“局部变量”,这些“局部变量”用于将数据从一帧传递到下一帧。如果您正在处理稀疏进程,您通常最终不得不实现一个小型状态机来跟踪工作何时应该完成。

如果您不必在多个帧中显式跟踪所有这些状态,也不必使用多线程并管理同步和锁定等,而只需将函数编写为单个代码块,那么这不是很简洁吗?标记函数应“暂停”并稍后继续的特定位置?

Unity 以及许多其他环境和语言以协程的形式提供了这一点。

他们看起来怎么样? 在“Unityscript”(Javascript)中:

function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
}

In C#:

IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
}

它们如何工作? 我只想说,我不为 Unity Technologies 工作。我还没看过Unity源代码。我从未见过 Unity 协程引擎的内部结构。然而,如果他们的实现方式与我将要描述的完全不同,那么我会感到非常惊讶。如果来自 UT 的任何人想要插话并谈论它的实际工作原理,那就太好了。

重要线索在 C# 版本中。首先,请注意该函数的返回类型是 IEnumerator。其次,请注意其中一个语句是yield 返回。这意味着yield 必须是一个关键字,并且由于 Unity 的 C# 支持是普通 C# 3.5,因此它必须是普通 C# 3.5 关键字。的确,这是 MSDN 中的– 谈论一种叫做“迭代器块”的东西。那么发生了什么?

首先,有 IEnumerator 类型。 IEnumerator 类型的作用类似于序列上的光标,提供两个重要成员:Current,它是一个属性,为您提供光标当前所在的元素;MoveNext(),一个移动到序列中下一个元素的函数。因为 IEnumerator 是一个接口,所以它没有具体指定这些成员是如何实现的; MoveNext() 可以只向 Current 添加一个,或者可以从文件加载新值,或者可以从 Internet 下载图像并对其进行哈希处理,然后将新哈希值存储在 Current 中……或者它甚至可以首先做一件事序列中的元素,而第二个元素则完全不同。如果您愿意,您甚至可以使用它来生成无限序列。 MoveNext() 计算序列中的下一个值(如果没有更多值,则返回 false),Current 检索它计算的值。

通常,如果你想实现一个接口,你必须编写一个类,实现成员,等等。迭代器块是实现 IEnumerator 的一种便捷方法,没有那么多麻烦 - 您只需遵循一些规则,IEnumerator 实现就会由编译器自动生成。

迭代器块是一个常规函数,它 (a) 返回 IEnumerator,并且 (b) 使用yield 关键字。那么yield关键字实际上是做什么的呢?它声明序列中的下一个值是什么——或者没有更多的值。代码遇到yield的点 return X 或 Yield Break 是 IEnumerator.MoveNext() 应该停止的点; yield 返回 X 导致 MoveNext() 返回 true 并为 Current 分配值 X,而yield Break 导致 MoveNext() 返回 false。

现在,这就是窍门。序列返回的实际值是什么并不重要。您可以重复调用MoveNext(),并忽略Current;计算仍将被执行。每次调用 MoveNext() 时,迭代器块都会运行到下一个“yield”语句,无论它实际生成什么表达式。所以你可以写这样的东西:

IEnumerator TellMeASecret()
{
  PlayAnimation("LeanInConspiratorially");
  while(playingAnimation)
    yield return null;

  Say("I stole the cookie from the cookie jar!");
  while(speaking)
    yield return null;

  PlayAnimation("LeanOutRelieved");
  while(playingAnimation)
    yield return null;
}

您实际编写的是一个迭代器块,它生成一长串空值,但重要的是它计算空值的工作的副作用。您可以使用如下简单循环来运行此协程:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

或者,更有用的是,您可以将其与其他工作混合在一起:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

一切尽在时机 正如您所看到的,每个yield return语句必须提供一个表达式(例如null),以便迭代器块有一些东西可以实际分配给IEnumerator.Current。一长串空值并不完全有用,但我们对副作用更感兴趣。我们不是吗?

实际上,我们可以用这个表达式做一些方便的事情。如果,而不是仅仅产生 null 呢? 忽略它,我们得到了一些表明我们预计何时需要做更多工作的东西?当然,我们通常需要直接继续下一帧,但并非总是如此:很多时候我们希望在动画或声音播放完毕后,或者在经过特定时间后继续进行。那些时候(玩动画) 产量返回空;构造有点乏味,你不觉得吗?

Unity 声明了 YieldInstruction 基类型,并提供了一些具体的派生类型来指示特定类型的等待。你有 WaitForSeconds,它会在指定的时间过后恢复协程。你有 WaitForEndOfFrame,它可以在同一帧稍后的特定点恢复协程。你已经有了协程类型本身,当协程 A 产生协程 B 时,它会暂停协程 A,直到协程 B 完成。

从运行时的角度来看,这是什么样的?正如我所说,我不为 Unity 工作,所以我从未见过他们的代码;但我想它可能看起来有点像这样:

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;

foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }

    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}

unblockedCoroutines = shouldRunNextFrame;

不难想象如何添加更多的 YieldInstruction 子类型来处理其他情况 - 例如,可以添加对信号的引擎级支持,并使用 WaitForSignal("SignalName")YieldInstruction 支持它。通过添加更多的 YieldInstructions,协程本身可以变得更具表现力——yield return new WaitForSignal("GameOver") 比 while(!Signals.HasFired("GameOver")) 更容易阅读 如果你问我,yield return null,更不用说在引擎中执行它可能比在脚本中执行它更快这一事实。

一些不明显的后果 关于这一切,有一些有用的事情人们有时会错过,我认为我应该指出这些。

首先,yield return 只是产生一个表达式——任何表达式——而 YieldInstruction 是一个常规类型。这意味着您可以执行以下操作:

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

具体行yield return new WaitForSeconds(),yield return new WaitForEndOfFrame() 等很常见,但它们本身并不是特殊形式。

其次,因为这些协程只是迭代器块,所以如果您愿意,您可以自己迭代它们 - 不必让引擎为您做这件事。我之前用它来向协程添加中断条件:

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

第三,您可以在其他协程上让出这一事实可以让您实现自己的 YieldInstructions,尽管性能不如引擎实现的那样。例如:

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

然而,我真的不推荐这样做——启动一个协程的成本对我来说有点沉重。

结论 我希望这能够澄清您在 Unity 中使用协程时实际发生的一些情况。 C# 的迭代器块是一个绝妙的小构造,即使您不使用 Unity,也许您会发现以同样的方式利用它们很有用。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

StartCoroutine/yield 返回模式在 Unity 中到底如何工作? 的相关文章

随机推荐