经常引用的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;
}
}
在 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 return。这意味着 yield 必须是关键字,并且由于 Unity 的 C# 支持是 vanilla C# 3.5,因此它必须是 vanilla C# 3.5 关键字。事实上,它在 MSDN 中——谈论称为“迭代器块”的东西。发生什么了?
首先,有这个 IEnumerator 类型。IEnumerator 类型就像一个序列上的光标,提供了两个重要的成员:Current,它是一个属性,为您提供光标当前所在的元素,以及 MoveNext(),一个移动到序列中下一个元素的函数。因为 IEnumerator 是一个接口,它并没有具体说明这些成员是如何实现的;MoveNext() 可以只添加一个 toCurrent,或者它可以从文件中加载新值,或者它可以从 Internet 下载图像并将其散列并将新散列存储在 Current 中……或者它甚至可以首先做一件事序列中的元素,而第二个元素则完全不同。如果您愿意,您甚至可以使用它来生成无限序列。MoveNext() 计算序列中的下一个值(如果没有更多值,则返回 false),
通常,如果你想实现一个接口,你必须编写一个类,实现成员,等等。迭代器块是实现 IEnumerator 的一种便捷方式,无需任何麻烦——您只需遵循一些规则,编译器会自动生成 IEnumerator 实现。
迭代器块是一个常规函数,它 (a) 返回 IEnumerator,并且 (b) 使用 yield 关键字。那么 yield 关键字实际上是做什么的呢?它声明序列中的下一个值是什么——或者没有更多的值。代码遇到 yield return X 或 yield break 的点是 IEnumerator.MoveNext() 应该停止的点;yield return 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 并忽略它,而是产生了一些表明我们何时需要做更多工作的东西怎么办?通常我们需要直接继续下一帧,当然,但并非总是如此:在动画或声音播放完毕或经过特定时间后,我们会想要继续进行很多次。那些 while(playingAnimation) 产生返回 null;构造有点乏味,你不觉得吗?
Unity 声明了 YieldInstruction 基类型,并提供了一些具体的派生类型来指示特定类型的等待。你有 WaitForSeconds,它会在指定的时间过去后恢复协程。你有 WaitForEndOfFrame,它会在同一帧稍后的特定点恢复协程。你有 Coroutine 类型本身,当协程 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 只是产生一个表达式——任何表达式——而 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,也许您会发现以同样的方式利用它们很有用。