StartCoroutine /收益率返回模式在Unity中如何真正起作用?


134

我了解协程的原理。我知道如何使标准StartCoroutine/ yield return模式在Unity的C#中工作,例如,调用IEnumerator通过返回的方法,StartCoroutine然后在该方法中执行某项操作yield return new WaitForSeconds(1);,等待一秒钟,然后执行其他操作。

我的问题是:幕后到底发生了什么?什么是StartCoroutine真的?什么IEnumeratorWaitForSeconds恢复?如何StartCoroutine将控制权返回给被调用方法的“其他”部分?所有这些如何与Unity的并发模型(其中不使用协程同时进行很多事情)进行交互?


3
C#编译器转换返回IEnumerator/ IEnumerable(或通用等效项)并且包含yield关键字的方法。查找迭代器。
Damien_The_Unbeliever

4
迭代器是“状态机”的一种非常方便的抽象。首先了解这一点,您还将获得Unity协程。 zh.wikipedia.org/wiki/State_machine
汉斯·

2
unity标签由Microsoft Unity保留。请不要滥用它。
Lex Li

11
我发现这篇文章很有启发性:详细的Unity3D协程

5
@凯-我希望我能给你买啤酒。那篇文章正是我所需要的。我开始质疑自己的理智,因为似乎我的问题甚至都没有道理,但是这篇文章直接回答了我的问题,超出了我的想象。也许您可以为此链接添加一个我可以接受的答案,以便将来的SO用户受益?
Ghopper21

Answers:


109

经常引用的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必须是关键字,并且Unity的C#支持是vanilla C#3.5,因此它必须是vanilla C#3.5关键字。确实,这是在MSDN中 –在谈论一种叫做“迭代器块”的东西。发生什么了?

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

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

迭代器块是一个常规函数,该函数(a)返回IEnumerator,并且(b)使用yield关键字。那么yield关键字实际上是做什么的呢?它声明序列中的下一个值是-或没有更多值。代码遇到收益率返回X或收益率中断的点是IEnumerator.MoveNext()应该停止的点;收益率返回X导致MoveNext()返回true,而Current被赋值为X,而收益率中断导致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<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,协程本身可以变得更具表现力–收益回报new WaitForSignal(“ GameOver”)比while(!Signals.HasFired(“ GameOver”))收益回报null,如果您问我,除了在引擎中执行此操作可能比在脚本中执行操作更快。

一些非显而易见的后果关于这一切,有一些有用的事情,人们有时会错过,我想我应该指出。

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

YieldInstruction y;

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

yield return y;

特定行产生收益返回新的WaitForSeconds(),产生收益返回新的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,也许您也会发现以相同的方式利用它们很有用。


2
谢谢您在这里复制。它很棒,对我有很大帮助。
Naikrovek '18年

96

下面的第一个标题是对该问题的直接答案。后面的两个标题对于日常程序员来说更有用。

可能会浪费协程的实现细节

协程在Wikipedia和其他地方进行了解释。在这里,我仅从实际角度提供一些细节。IEnumeratoryield等是C#语言功能,这些功能在Unity中用于某些不同的目的。

简而言之,一个IEnumerator声明拥有一组值,您可以像一样逐个地请求它们List。在C#中,带有签名以返回的IEnumerator函数不必实际创建并返回一个,而可以让C#提供隐式IEnumerator。然后,该函数可以IEnumerator通过yield return语句以懒惰的方式提供将来返回的内容。每次调用者从该隐式请求另一个值时IEnumerator,该函数将执行到下一个yield return提供下一个值的语句。作为此方法的副产品,函数将暂停直到请求下一个值。

在Unity中,我们不使用这些值来提供将来的值,而是利用函数暂停的事实。由于这种利用,Unity中有关协程的很多事情都没有意义(什么IEnumerator与什么有关系?什么是yield?为什么new WaitForSeconds(3)?等等)。“幕后”发生的事情是,您通过IEnumerator提供的值用于StartCoroutine()确定何时要求下一个值,该值决定了协程何时将再次暂停。

您的Unity游戏是单线程(*)

协程不是线程。有一个Unity主循环,并且您编写的所有这些函数均被同一主线程依次调用。您可以通过放置一个while(true);在任何函数或协程中。它将冻结整个内容,甚至冻结Unity编辑器。这证明一切都在一个主线程中运行。凯在上面的评论中提到的链接也是一个很好的资源。

(*)Unity从一个线程调用您的函数。因此,除非您自己创建线程,否则您编写的代码是单线程的。当然,Unity确实使用其他线程,并且您可以根据需要自己创建线程。

面向游戏程序员的协程的实用描述

基本上,当你调用StartCoroutine(MyCoroutine()),这也正是像一个普通的函数调用MyCoroutine(),直到第一个yield return X,在那里X是一样的东西nullnew WaitForSeconds(3)StartCoroutine(AnotherCoroutine())break,等,这是当它开始从功能不同。Unity会在该yield return X行“暂停”该功能,然后继续处理其他事务,并通过一些帧,然后当该再次出现时,Unity在该行之后立即恢复该功能。它记住函数中所有局部变量的值。这样,您可以有一个for每两秒钟循环一次的循环。

Unity何时恢复您的协同程序取决于您的程序X中的内容yield return X。例如,如果您使用yield return new WaitForSeconds(3);,它将在3秒后恢复。如果使用yield return StartCoroutine(AnotherCoroutine()),它将在AnotherCoroutine()完全完成后恢复,这使您可以及时嵌套行为。如果您只是使用yield return null;,它将在下一帧继续播放。


2
太糟糕了,UnityGems到现在似乎已经崩溃了一段时间。Reddit上的某些人设法获得了档案的最新版本:web.archive.org/web/20140702051454/http
//unitygems.com/…– ForceMagic 2015年

3
这是非常模糊的,并且有可能出错。这是代码实际编译的方式以及工作原理。另外,这也无法回答问题。stackoverflow.com/questions/3438670/...
路易港

是的,我想我从游戏程序员的角度解释了“协程如何工作”。真正的问题是询问引擎盖下发生了什么。如果您指出我回答的不正确部分,我们将很乐意解决。
Gazihan Alankus,2015年

4
我同意收益率假为假,我添加了它是因为有人批评我的回答没有,并且我急于检查它是否甚至有用,只添加了链接。我现在将其删除。但是,我认为Unity并不是单线程的,协程如何适应于每个人也不是很清楚。我交谈过的许多初学者Unity程序员对整个事情都有很模糊的理解,并从这种解释中受益。我编辑了答案,以提供该问题的准确答案。欢迎提出建议。
济汗·阿兰库斯

2
Unity 不是单线程的。它有一个运行MonoBehaviour生命周期方法的主线程-但它还有其他线程。您甚至可以自由创建自己的线程。
benthehutt

10

它再简单不过了:

Unity(以及所有游戏引擎)都是基于框架的

整个问题,即Unity的全部存在点,都在于它基于框架。引擎为您完成“每个框架”的工作。 (动画,渲染对象,进行物理处理,等等。)

您可能会问..“哦,太好了。如果我希望引擎在每个帧中为我做些什么?我如何告诉引擎在一个帧中做某某事?”

答案是 ...

这正是“协程”的目的。

就这么简单。

考虑一下...

您知道“更新”功能。简而言之,您放入其中的所有内容都会每一帧中完成。它与协程产量语法完全相同,没有任何区别。

void Update()
 {
 this happens every frame,
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 yield return null;
 }

绝对没有区别。

脚注:正如每个人都指出的那样,Unity根本没有线程。Unity或任何游戏引擎中的“框架”完全不以任何方式与线程建立连接。

协程/产量只是您访问Unity中框架的方式。而已。(实际上,它与Unity提供的Update()函数绝对相同。)就是这么简单。


谢谢!但是您的答案说明了如何使用协程,而不是它们在幕后的工作方式。
Ghopper21 '16

1
我很高兴,谢谢。我明白您的意思-对于那些总是问到底协程是什么的初学者来说,这可能是一个很好的答案。干杯!
Fattie

1
实际上-没有一个答案,即使只是很小的答案,也无法解释“幕后”的情况。(这就是它被堆积到调度程序中的IEnumerator。)
Fattie

您说“绝对没有区别。” 那为什么当Unity已经有了一个可以正常工作的实现时,他们又为什么创建了协程Update()呢?我的意思是,这两个实现和它们的用例之间应该至少有一点明显的区别。
Leandro Gecozo

嘿@LeandroGecozo-我还要说,“更新”只是他们添加的一种(“傻”)简化。(许多人从不使用它,只使用协程!)我认为您的问题没有很好的答案,这只是Unity的方式。
Fattie

5

最近对此进行了深入研究,在这里写了一篇文章-http ://eppz.eu/blog/understanding-ienumerator-in-unity-3d/--阐明了内部结构(带有密集的代码示例),底层IEnumerator接口,以及如何用于协程

为此,使用集合枚举器似乎仍然有些怪异。这与枚举器的设计意图相反。枚举数的点是每次访问的返回值,而协程的数点是返回值之间的代码。在这种情况下,实际的返回值毫无意义。


0

在Unity中,您自动获得的基本函数是Start()函数和Update()函数,因此,协程实际上是与Start()和Update()函数一样的函数。任何旧函数func()都可以像调用协程一样被调用。显然,Unity为协程设置了某些界限,使它们不同于常规功能。区别之一是

  void func()

你写

  IEnumerator func()

用于协程。同样,您也可以使用以下代码行来控制普通功能中的时间

  Time.deltaTime

协程在控制时间的方式上有特定的处理方式。

  yield return new WaitForSeconds();

尽管这不是在IEnumerator /协程内部唯一可能做的事情,但这是协程用于的有用的事情之一。您将必须研究Unity的脚本API来学习协程的其他特定用法。


0

StartCoroutine是一种调用IEnumerator函数的方法。它类似于仅调用一个简单的void函数,不同之处在于您在IEnumerator函数上使用了它。这种类型的函数是唯一的,因为它可以允许您使用特殊的yield函数,请注意您必须返回一些东西。就我所知。在这里,我统一编写了一个简单的闪烁游戏

    public IEnumerator GameOver()
{
    while (true)
    {
        _gameOver.text = "GAME OVER";
        yield return new WaitForSeconds(Random.Range(1.0f, 3.5f));
        _gameOver.text = "";
        yield return new WaitForSeconds(Random.Range(0.1f, 0.8f));
    }
}

然后我从IEnumerator本身中调用它

    public void UpdateLives(int currentlives)
{
    if (currentlives < 1)
    {
        _gameOver.gameObject.SetActive(true);
        StartCoroutine(GameOver());
    }
}

如您所见,我如何使用StartCoroutine()方法。希望我能有所帮助。我本人就是一个初学者,因此,如果您纠正我或赞赏我,那么任何形式的反馈都将是很棒的。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.