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

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() 可以只向 Current 添加一个,或者可以从文件加载新值,或者可以从 Internet 下载图像并对其进行哈希处理,然后将新哈希值存储在 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 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,它可以在同一帧稍后的特定点恢复协程。您已经获得了协程类型本身,当协程 A 产生协程 B 时,它会暂停协程 A 直到协程 B 完成。

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

List unblockedCoroutines;
List shouldRunNextFrame;
List 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,也许您会发现以同样的方式利用它们很有用。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

万兴丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值