使用Unity3D避免不了使用协程(Coroutine),他提供了另一种流程控制的方法,但是如果了解不够深入,会造成不知道什么时候代码会被执行,从而产生不可预知的错误。这篇文章尽可能用通俗的语言和例子解释迭代器和协程的由来和工作原理,本人能力有限,不对之处敬请指正。
迭代器是一个普通的接口类,如果写C++代码的话感觉更接近"iterator",这个概念,微软C#官方文档在介绍迭代器时用的也是"iterator"这个词,基础迭代器是为了实现类似for循环对指定数组或者对象的子元素逐个的访问而产生的。
public interface IEnumerator
{
object Current { get; }
bool MoveNext();
void Reset();
}
以上是IEnumerator的定义。
Current()方法的实现应该是返回调用者需要的指定类型的指定对象。
MoveNext()(方法的实现应该是让迭代器前进。
Reset()方法的实现应该是让迭代器重置未开始位置。
注意以上用的都是“应该是”,也就是说写程序的时候我们可以任意实现一个派生自” IEnumerator”类的3个函数的功能,但是如果不按设定的功能去写,可能会造成被调用过程出错,无限循环等问题。
例如我们要遍历打印一个字符串数组
public string[] m_StrArray = new string[4];
就可以派生一个迭代器接口的子类
public class StringPrintEnumerator : IEnumerator
{
private int m_CurPt = -1;
private string[] m_StrArray;
public StringPrintEnumerator(string[] StrArray)
{
m_StrArray = StrArray;
}
public object Current
{
get
{
return m_StrArray[m_CurPt];
}
}
public void Reset()
{
m_CurPt = -1;
}
public bool MoveNext()
{
m_CurPt++;
if (m_CurPt == m_StrArray.Length)
return false;
return true;
}
}
以下是具体打印的过程
public static void Do()
{
string[] StrArray = new string[4];
StrArray[0] = "A";
StrArray[1] = "B";
StrArray[2] = "C";
StrArray[3] = "D";
StringPrintEnumerator StrEnum = new StringPrintEnumerator(StrArray);
while (StrEnum.MoveNext())
{
(string) ObjI = (string)StrEnum.Current;
Debug.Log(ObjI);
}
}
这段代码会依次输出A,B,C,D
如果我们不正确的实现Current方法,比如返回null,或者数组下表越界,那么执行到Debug.Log时候会报错。
如果我们不正确的MoveNext,那么可能会出现无限循环(当然如果这是逻辑所需,那么有也是正确的)。
如果我们不正确的Reset()那么可能下次再用同一个迭代器的时候不会正确工作。
所以这三个方法如何才是正确的实现,完全要根据由上层的调用者约定来写。
到这里为止还没有涉及到任何C#内置隐藏东西,完全是设计理念相关的东西:既把对指定类的迭代工作交给专门的类(IEnumerator的派生类)去做。
C#使用foreach这个内置的语句取代了每次手写while (StrEnum.MoveNext())来完成遍历工作,
同时新定了一个接口类来包装迭代器(IEnumerator),他就是IEnumerable,来看看他的定义:
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
他的做用仅仅是需要派生类写一个返回指定迭代器的实现方法,也就是说IEnumerable仅仅是IEnumerator的个包装而已。
下面说说IEnumerable和IEnumerator的区别,这两个类几乎没有区别,对于一些内置的操作,如果系统需要的是IEnumerator,那么他要做的仅仅调用现在拥有的IEnumerable的GetEnumerator()方法,从而获得想要的IEnumerator,所以才是真正的核心。
下面回到foreach, foreach需要的是一个定义了IEnumerator GetEnumerator()方法的对象,当然如果他是派生自IEnumerable对象那就更好了。
我们继续使用上文的StringPrintEnumerator
新定义他的IEnumerable派生类MyEnumerable
public class MyEnumerable : IEnumerable
{
public IEnumerator GetEnumerator()
{
return new StringPrintEnumerator(m_StrArray);
}
public string[] m_StrArray = new string[4];
}
一个标准的foreach写法如下:
MyEnumerable EnumMy = new MyEnumerable();
EnumMy.m_StrArray[0]=”A”;
EnumMy.m_StrArray[1]=”B”;
foreach(string obj in MyEnumerable)
{
Debug.Log(obj);
}
我们可以用纯粹的IEnumerator和IEnumerable重写上面的foreach代码:
MyEnumerable EnumMy = new MyEnumerable();
EnumMy.m_StrArray[0]=”A”;
EnumMy.m_StrArray[1]=”B”;
IEnumerable Iter = EnumMy.GetEnumerator();
while (Iter.MoveNext())
{
(string) ObjI = (string)StrEnum.Current;
Debug.Log(ObjI);
}
从中我们可以看到foreach(var x in E)每次遍历中的x其实是每一次Current()方法的调用,而每次循环开始前条件的判断和迭代步进其实是一次MoveNext()方法的调用,并且在循环的开始,会对遍历的目标对象进行一次GetEnumerator()方法的调用从而获得IEnumerator对象。
上面引入迭代器看起来只是对遍历对象或者数组进行了有限的扩充,比如可以派生迭代器子类实现灵活的步进迭代,并没有获得更多的收获。但是C#加入了yield语句同时配合迭代器,实现了对指定函数执行的随时切入切出的流程控制,就使得传统写代码的方法获得一些新手段。
如果一个函数长成这个样子:
IEnumerator MyRun()
{
Debug.Log(“Hello world.”);
}
千万不要把他当作一般函数使用。
例如单独在某处调用这个函数:
void Do()
{
MyRun();
}
不会获得任何输出。原因是一个返回迭代器的函数(这个说法是错误的),我们叫他函数迭代器更合适,C#在编译的时候会把它当作一种特殊的对象来看待,他虽然长成了函数的样子,但其实是一个迭代器对象,我们应该这样理解他:我定义并且实例化了一个IEnumerator对象,他的变量名叫做MyRun,我们用一个函数给他赋值{ Debug.Log(“Hello world.”);}。也就是说一个函数迭代器不会在定义的时候执行,他只会在MoveNext()被调用的时候执行。
我们知道任何一个IEnumerator都要实现2个方法:Current和MoveNext,为了实现对函数执行的随时切入切出,C#加入yield return和yield break来实现Current和MoveNext的调用。
我们丰富一下上面赋值给MyRun迭代器的函数
IEnumerator MyRun()
{
Debug.Log(“Hello world 1.”);
yield return “A”;
Debug.Log(“Hello world 2.”);
yield return null;
Debug.Log(“Hello world 3.”);
yield return 2;
Debug.Log(“Hello world 4.”);
yield break;
Debug.Log(“Hello world 5.”);
yield return 6;
}
这里的区别是一个函数取代了我们之前的Current,和MoveNext的实现,我们可以把这个函数看作是一个IEnumerator的C#内建特殊的派生类,他的Current和MoveNext方法的实现是通过每次在函数里标记yield的位置来实现的。每次yield的位置就是每次MoveNext指令步进的终点,而yield return 后面的值就是Current方法返回的值。
这样一来我们在外面只需要这样调用:
while (MyRun.MoveNext())
{
ObjI = StrEnum.Current;
Debug.Log(ObjI);
}
就会获得输出
Hello world 1.
A
Hello world 2.
<错误>
这个错误是因为Current执行到yield return null处返回了null,调用者的Debug.Log();对null进行打印报错导致的。
我们在外层加入判空操作继续执行:
while (MyRun.MoveNext())
{
ObjI = StrEnum.Current;
If(ObjI != null)
Debug.Log(ObjI);
}
就会获得输出
Hello world 1.
A
Hello world 2.
Hello world 3.
2
Hello world 4.
yield break后的语句没有执行,是因为当MoveNext执行到yield break后,会返回false,这样会导致while()结束。这个例子是用一个while循环连续MoveNext得到的函数连续执行的结果。如果我们把MoveNext操作分散在其他逻辑里面,就能得到随时切入切出执行这个函数的效果了。
同理,我们也可以让一个有GetEnumerator()接口的对象返回这个函数迭代器。从而让foreach来控制他的的执行。
public class MYEnumUseForeach
{
public IEnumerator GetEnumerator()
{
return MyRun();
}
}
MYEnumUseForeach My = new MYEnumUseForeach();
Foreach(var x in My)
{
//TODO…
}
上面已经实现了函数执行暂停与切入切出。那么如果是一个函数迭代器的方法又嵌套另一个函数迭代器的方法呢,比如我们想实现下图这样的执行流程:
既主逻辑流程执行期间的同时“兼顾”方法A的执行,方法A执行到某一步后暂停执行,同时开启方法B的执行,当B执行完毕后继续执行方法A的后续。这里之所以用“兼顾”是因为不是多线程执行,而是在主循环方法的某些时机暂停主方法的执行,执行一部分其他方法。要明确这里仍然是单线程。
那么我们该如何用已经上面的东西实现这样的“子函数迭代器”呢?
首相要有2个函数迭代器一个赋予方法A,一个赋予方法B,然后对A进行MoveNext,我们标记一个yield return 的返回值(既Current)特殊值,比如返回yield return 999,来作为暂停A的MoveNext,并且开始B MoveNext的契机,然后我们在执行主方法的同时兼顾B的MoveNext,当B MoveNext返回false的时候就是B方法结束的时候,这时我们继续执行A的MoveNext。这样我们就实现了函数迭代器开启子函数迭代器,这也就是协程的实现雏形,也是Unity的协程对函数型迭代器的封装。
Unity3D的核心是由C++编写,通过Mono库提供的API实现与C#的交互(调用与被调用),
Unity3D中C#脚本的基础是MonoBehaviour类,他的Update()方法每个渲染帧都会被内核调用,协程的创建方法也是定义在MonoBehaviour中,他的定义是:
Coroutine StartCoroutine(IEnumerator routine)
他传入的参数必须是一个迭代器,通常是上文说的函数式迭代器,就是
public IEnumerator MyRun()
{
}
这个函数可以有参数,但是参数不能是ref和out类型的
他返回一个Unity3D内建的对象Coroutine
这个协程(函数式迭代器)的一切上下文都会被存储在这个Coroutine中。
StartCoroutine是一个C++内核函数,这个传入的函数式迭代器会被立刻MoveNext一次,从而协程函数被执行一步,然后这个Coroutine会被存储在MonoBehaviour中的一个容器里,从而这个协程的执行完全交给Unity3D管理。那么他的下次执行(MoveNext)时机,子协程的发起,以及合适判断执行完毕呢?这些就要依靠函数式迭代器的每次yield返回类型来判断了。
上次说到StartCoroutine首先会对一个函数式迭代器进行一次MoveNext操作,这次MoveNext如果返回true,那么同时会把函数执行到下次yield的语句,同时返回yield返回的内容。Unity3D会判断返回值的类型:
WaitForSeconds
WaitForFixedUpdate
WaitForEndOfFrame
其他
Coroutine
iEnumerator
AsyncOperation
Unity3D内核会在每次MoveNext后产生一个新的延迟执行对象来管理下次执行MoveNext的时机。
a.如果yield return的对象是WaitForSeconds对象,那么下一次的MoveNext会被Unity3D内核安排到指定的秒数后的第一帧被执行,既Unity3D本迭代器产生一个“延迟执行对象”,并标记为指定秒后执行。
b.如果yield return的对象是WaitForFixedUpdate对象,那么下一次的MoveNext会被Unity3D内核安排到下一次的Fixupdate时执行。既Unity3D本迭代器产生一个“延迟执行对象”,并标记为下一个固定时间帧后执行。
如果WaitForEndOfFrame,会被安排到GUI渲染之后,present前执行。既Unity3D本迭代器产生一个“延迟执行对象”,并标记为当前渲染帧前执行。
c.如果是除了最后三个之外的其他任何值,包括null,那么会在下一次渲染帧update时候执行。既Unity3D对本迭代器产生一个“延迟执行对象”,并标记为下一次update时执行。
下面是三个重点返回类型Coroutine,iEnumerator, AsyncOperation
d.Coroutine
如果我们在C#代码里这样写yield returnStartCoroutine(MyRun2)
这里MyRun2是另一个函数式迭代器,那么内核C++的StartCoroutine的就会通过Mono获得这种协程式的返回类型.Unity会立刻“挂起”,既Unity3D对本迭代器不再产生一个“延迟执行对象”,而是对新的协程(迭代器)执行一次,然后根据新协程的返回值产生或者不产生相应的“延迟执行对象”,但是,会把新的协程和父协程相互关联,标记父子关系,当新的协程彻底结束的时候,会执父协程一次,并根据返回结果产生或者不产生父协程的“延迟执行对象”。
上面说这些其实就是返回yield returnStartCoroutine(MyRun2)会挂起父协程,等待子协程执行完毕才会恢复父协程的执行。
e.iEnumerator
如果再C#里我们这样写yield returnMyRun2()
这里MyRun2()是一个函数式迭代器,我们会获得和上面d同样的结果,只是有两个区别,一个是内在区别StartCoroutine方法会由内核执行,而不是用户编写。另一个区别是当我们手动中断父协程(StopCoroutine)的时候,情况e连同子协程也会被中断,而情况d不会中断子协程。
f.AsyncOperation
异步加载资源(例如Resources.LoadAsync)的时候会返回这个对象,他包含一个重要的方法isDone(),如果yield return AsyncOperation,返回时这个资源被同步加载完毕,那么isDone就是true,协程会继续立即执行MoveNext,但是通常情况下资源不是同步加载完毕,这时候我们把执行下次MoveNext的权利交给了异步资源加载器(AsyncOperation),当资源加载完毕时候会发起MoveNext,使得协程函数继续前进。
转载务必在明显处注明:作者李剑鹏