经常提到的Unity3D协程详解链接已经失效。由于它在评论和答案中被提及,我将在此发布文章内容。这篇文章内容来自这个镜像。
Unity3D协程详解
游戏中的许多过程需要在多个帧中进行。你有“密集”的进程,比如寻路,它每帧都会努力工作,但会分成多个帧,以避免对帧率产生太大影响。你有“稀疏”的进程,比如游戏触发器,在大多数帧上什么也不做,但偶尔需要执行关键任务。还有两者之间的各种进程。
无论何时,当你创建一个将在多个帧中进行的进程时 - 没有多线程 - 你需要找到一种方式将工作分成可以每帧运行一次的块。对于任何具有中心循环的算法,这是相当明显的:例如,A *路径查找器可以被构造为保持其节点列表半永久性,每帧只处理来自开放列表的少量节点,而不是试图一次性完成所有工作。需要进行一些平衡以管理延迟 - 毕竟,如果您将帧速率锁定在每秒60或30帧,则您的进程每秒只会进行60或30步,这可能导致进程总时间太长。一个巧妙的设计可能在一个级别上提供最小的工作单位 - 例如,处理单个A *节点 - 并在其上添加一种将工作分组成较大块的方式 - 例如,保持处理A *节点X毫秒。 (有些人称之为“时间片”,但我不这么认为)。
尽管如此,允许以这种方式分解工作意味着您必须在一帧和下一帧之间传输状态。如果您正在打破迭代算法,则必须保留所有迭代共享的状态,以及跟踪下一个要执行的迭代的方法。这通常不太糟糕 - “A *路径查找器类”的设计相当明显 - 但也有其他情况,这些情况不太愉快。有时您将面临长时间的计算,这些计算从一帧到另一帧执行不同类型的工作;捕获其状态的对象可能会遇到大量半有用的“局部变量”,用于从一帧传递数据到下一帧。如果您正在处理稀疏进程,则通常必须实现一个小型状态机,以跟踪何时应该执行工作。
如果你能够将所有这些状态明确地跨多个帧进行跟踪,而不是必须进行多线程和管理同步、锁定等操作,那么写一个单一的代码块,并标记函数应该“暂停”并在稍后继续的特定位置,不是很好吗?
Unity - 以及许多其他环境和语言 - 提供了这种形式的协程。
它们是什么样子的?
在“Unityscript”(Javascript)中:
function LongComputation()
{
while(someCondition)
{
yield;
}
}
在C#中:
IEnumerator LongComputation()
{
while(someCondition)
{
yield return null;
}
}
它们是如何工作的?
让我简单地说一下,我不为Unity Technologies工作。我没有看过Unity源代码。我从未见过Unity协程引擎的内部结构。然而,如果他们实现的方式与我即将描述的方式截然不同,那么我会感到非常惊讶。如果任何UT的人想加入并谈论它的实际工作原理,那就太好了。
大线索在于C#版本。首先,请注意函数的返回类型为IEnumerator。其次,请注意其中一个语句是yield return。这意味着yield必须是一个关键字,并且由于Unity的C#支持是普通的C# 3.5,因此它必须是普通的C# 3.5关键字。确实,在MSDN中有
这里——谈论一些称为“迭代器块”的东西。那么到底发生了什么呢?
首先,有这个IEnumerator类型。IEnumerator类型就像是一个序列上的光标,提供了两个重要的成员:Current,它是一个属性,给出光标当前所在的元素;和MoveNext(),它是一个函数,用于移动到序列中的下一个元素。因为IEnumerator是一个接口,它不指定这些成员的具体实现方式;MoveNext()可能只是将Current加1,或者它可能从文件中加载新值,或者它可能从互联网下载图像并对其进行哈希,并将新哈希存储在Current中...或者它甚至可以为序列中的第一个元素执行一件事,而对于第二个元素则完全不同。你甚至可以使用它来生成一个无限序列,如果你愿意的话。MoveNext()计算序列中的下一个值(如果没有更多的值,则返回false),而Current检索它计算出的值。
通常情况下,如果你想要实现一个接口,你需要编写一个类,实现成员等等。迭代器块是一种方便的方法,可以在不需要所有这些麻烦的情况下实现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(Input.GetKeyDown(KeyCode.Escape)) { break; }
}
“时间非常重要。正如您所见,每个yield return语句都必须提供一个表达式(例如null),以便迭代器块实际上有东西可以分配给IEnumerator.Current。一长串的null并不是非常有用,但我们更感兴趣的是副作用。不是吗?实际上,我们可以利用这个表达式做一些方便的事情。如果我们不仅仅是产生null并忽略它,而是产生一些指示我们需要何时做更多工作的东西会怎样呢?通常我们需要在下一帧继续进行,当然,但并不总是:在动画或声音播放完成后,或者经过一定的时间后,我们将有很多时候想要继续进行。那些while(playingAnimation)yield return null;构造有点乏味,不是吗?Unity声明了YieldInstruction基类型,并提供了一些具体的派生类型,表示特定种类的等待。你有WaitForSeconds,它在指定的时间后恢复协程。你有WaitForEndOfFrame,在同一帧的稍后某个点恢复协程。你还有协程类型本身,当协程A yield协程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())
continue;
if(!coroutine.Current is YieldInstruction)
{
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
}
unblockedCoroutines = shouldRunNextFrame;
"It's not difficult to imagine how more YieldInstruction subtypes could be added to handle other cases. For example, engine-level support for signals could be added, with a WaitForSignal('SignalName') YieldInstruction supporting it. By adding more YieldInstructions, the coroutines themselves can become more expressive. 'yield return new WaitForSignal('GameOver')' is nicer to read than 'while(!Signals.HasFired('GameOver')) yield return null', if you ask me. Additionally, doing it in the engine could be faster than doing it in script.
There are a couple of useful things about all this that people sometimes miss. Firstly, yield return is just yielding an expression - any expression - and YieldInstruction is a regular type. This means you can do things like:"
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();
}
}
第三,你可以在其他协程上使用yield,这样就可以实现自己的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,也可能会发现以同样的方式利用它们很有用。
IEnumerator
/IEnumerable
(或其泛型版本)且包含yield
关键字的方法。请查询迭代器相关内容。 - Damien_The_Unbeliever