深入剖析Unity协程
开胃菜:从IEnumerator/IEnumerable
到Yield
c#语言中,迭代器这个特性大家不会陌生,最常见的莫过于foreach
了。foreach
能够对一个实现了IEnumerable
接口的对象dataSource
进行遍历访问其中的元素。
foreach (var item in dataSource)
{
Console.WriteLine(item.ToString());
}
foreach
的遍历过程可以拆解为:
IEnumerator iterator = dataSource.GetEnumerator();
while (iterator.MoveNext())
{
Console.WriteLine(iterator.ToString());
}
细心的读者会发现,为什么迭代器要涉及IEnumerable
和 IEnumerator
两个接口而不是直接在dataSource
中实现MoveNext
和Current
?
这正是迭代器模式的要点。这个模式将存储数据和遍历数据的职责分离,在c#中对应为IEnumerable
和 IEnumerator
两个接口(其实还有两个泛型接口:IEnumerable\
如何利用IEnumerable
和 IEnumerator
,自定义一个支持foreach
遍历的类呢?
在c#1.0中,你只能这样做:
public class DataSource : IEnumerable
{
public IEnumerator GetEnumerator()
{
Enumerator enumerator = new Enumerator(0);
return enumerator;
}
public class Enumerator : IEnumerator, IDisposable
{
private int state;
private object current;
public Enumerator(int state)
{
this.state = state;
}
public bool MoveNext()
{
switch (state)
{
case 0:
current = "Hello";
state = 1;
return true;
case 1:
current = "World";
state = 2;
return true;
case 2:
break;
}
return false;
}
public void Reset()
{
throw new NotSupportedException();
}
public object Current
{
get { return current; }
}
public void Dispose()
{
}
}
}
class Program
{
static void Main(string[] args)
{
DataSource dataSource = new DataSource();
foreach (string s in dataSource)
{
Console.WriteLine(s);
}
}
}
可以看到,需要在DataSource
类中实现一个Enumerator
类,其采用状态机的方式实现MoveNext
的逻辑,稍有不慎就会产生差错。
c#2.0后引入了语法糖yield
,较完美的解决了迭代器模式的易用性这个问题。同样的功能,只需寥寥几行便可实现。
public class DataSource : IEnumerable
{
public IEnumerator GetEnumerator()
{
yield return "Hello";
yield return "World";
}
}
从这个例子中,可以猜想到yield return
是如何实现等价效果的:对于一个位于返回值为IEnumerator
的函数里的yield,做如下处理:
- 静默创建了一个
IEnumerator
对象 - 立刻调用了这个对象的
MoveNext()
方法,其执行了第一个yield之前的逻辑 - 遇到第一个yield时,将
Current
赋值为yield return
后面的值),保存当前状态并挂起。 - 下次调用
MoveNext()
时,从刚才的yield之后的语句开始执行。直到最后一个yield return
语句时,MoveNext()
返回false。
在实践中,发现yield在返回值为IEnumerable
的函数中也能起作用,和IEnumerator
似乎是一样的。这是为何?俗话说“好人做到底,送佛送到西”,本文既然说深入剖析,这个疑点自然不能放过。
首先我们知道,yield
只是语法糖,那么能不能看到编译器将其展开后的结果呢?笔者将测试代码编译成DLL后,放在ILSpy 2.4.0版中看反编译后的c#,终于发现了使用IEnumerator
和IEnumerable
的不同。
从上图可以发现,IEnumerable
会创建一个线程ID,并且初始状态为 -2(表明 GetEnumerator()
还没有被调用)。如果另一个线程在迭代中途调用了GetEnumerator()
,则会新建立一个该类对象。这里介绍的是比较简单的情况,当有参数传递时,要去维护线程安全就比较复杂了。总之,用 IEnumerable 是线程安全的。
不过语法糖终究是语法糖,yield
的使用是有限制的,比如用于异常处理。
yield return
不能用于try-catch
中,只能用在try-finally
的try中。
yield break
可以用于try-catch
,但不能用在finally
块中。《C#参考》
更多这方面的讨论推荐《C# in depth》作者写的这篇博客,以及这篇。
主菜:Unity协程的实现
前一节告诉我们:在返回值为IEnumerator/IEnumerable
的函数中,yield return [value]
可以被展开,实现迭代器的效果。[value]
是本次MoveNext()
的返回值Current
,可以是object
类型。下次调用MoveNext()
时,从刚才的yield之后的语句开始执行。
Unity利用这个特性实现了协程。协程本篇就不介绍了,这方面已有不少笔墨,亦超出本篇的讨论范围。继续刚才话题,在Unity的协程中,返回值Current并不能直接被使用者获得,而是内部进行了处理。
对于不同类型的返回值,效果亦不相同。比如yield return null;
就是挂起协程,回到主函数逻辑,下一帧从挂起的位置继续。yield return new WaitForSeconds(1f);
就是挂起1秒后再继续。这是怎么实现的?先看看反编译后的代码。
- yield return null 的结果:
- yield return new WaitForSeconds(1f) 的结果
看完后觉得信息量不大。Unity到底用了什么魔法?网上有篇博客给出了自己的猜想(看完源码后发现这位仁兄猜的真准……)
When you make a call to
StartCoroutine(IEnumerator)
you are handing the resulting IEnumerator to the underlying unity engine.
StartCoroutine()
builds aCoroutine
object, runs the first step of the IEnumerator and gets the first yielded value. That will be one of a few things, either "break", someYieldInstruction
like"Coroutine", "WaitForSeconds", "WaitForEndOfFrame", "WWW"
, or something else unity doesn't know about. The Coroutine is stored somewhere for the engine to look at later.... At various points in the frame, Unity goes through the stored Coroutines and checks the Current value in their IEnumerators.
WWW
- after Updates happen for all game objects; check the isDone flag. If true, call the IEnumerator's MoveNext() function;
WaitForSeconds
- after Updates happen for all game objects; check if the time has elapsed, if it has, call MoveNext();null
or some unknown value - after Updates happen for all game objects; Call MoveNext()WaitForEndOfFrame
- after Render happens for all cameras; Call MoveNext
MoveNext
returns false if the last thing yielded was "break" of the end of the function that returned the IEnumerator was reach. If this is the case, unity removes the IEnumerator from the coroutines list.
由于上面已经概括了Unity实现协程的思想,这里稍作补充,源码就不贴了:
StartCoroutine
创建了Coroutine
对象coroutine
,该对象保存了yield return
展开后的IEnumerator对象指针、MoveNext
和Current
的函数指针、结束后应当唤醒的协程的指针、指向调用者Monobehaviour
的指针等等,并将该对象coroutine
保存到该Monobehaviour
的活跃协程列表中。然后立即调用了coroutine.Run()
。coroutine.Run()
首先尝试调用InvokeMoveNext
,若发现当前协程执行完成,则会尝试调用应当唤醒的协程,否则才真正执行MoveNext
,获得返回值monoWait
。- 根据返回值
monoWait
的类型,进行不同的处理。通常是传递不同的参数给CallDelayed
函数。对于返回值是Coroutine
类型(c#那边用了协程嵌套),会将这个返回值的结束后应唤醒的协程的指针指向当前的coroutine
。笔者这里发现了一种不太常见的用法:当返回值为IEnumerator
类型(c#那边没有用StartCoroutine去开启嵌套协程,而是直接在yield return 后调用)时,Unity会自动为其创建一个Coroutine
对象并初始化,效果同样。 CallDelayed
函数传入了运行协程对象的方法、qingli协程对象的方法、清理条件等。函数内部创建了一个Callback
对象,加到了全局的DelayedCallManager
的列表中。游戏主循环会在每一帧调用DelayedCallManager.Update
,在满足一定条件时(比如对应的Monobehaviour对象还没被销毁等)调用Callback
对象的方法。
甜点: 带返回值的协程
看到这里,似乎意犹未尽。有些读者可能会问,除了知道如何用yield
实现自己的可迭代的类,以及Unity利用yield
实现协程的原理外,对日常代码有什么立竿见影的作用?这里就介绍一个小技巧:自定义可返回值的协程。
上一节我们知道,Unity内部将yield return
的结果进行了处理,但常常我们也想去访问协程的结果,比如玩家发起坐下请求。结果是超时了?还是坐下失败了?如果能用协程实现下面的写法,不仅省去了回调函数定义的冗余,还可以使上下文更连贯。
//期望的用法
SitDownCoroutine cd = new SitDownCoroutine(requestSitDown(seatID));
yield return cd.coroutine;
Debug.Log("result is " + cd.result); // 'success' or 'fail' or 'timeout'
为了实现上述效果,笔者在经历的项目中见过一些复杂的写法,其思想是自己模拟Unity的这套运行协程的机制。不过受限于开发量,往往只能支持简化的功能。这篇帖子介绍了一种相对简单有效的方法,有兴趣可以看看。笔者这里提供相当简练的方法,利用Unity5.3以后提供的 CustomYieldInstruction
功能。当Unity遇到返回值为CustomYieldInstruction
类型时,会检查keepWaiting
的值,直到该值为false
才会结束协程。
public class SitDownCoroutine : CustomYieldInstruction
{
public RetCode result { get; private set; }
private bool _finished = false;
public SitDownCoroutine(Action<long> request)
{
//将onResponse加入对应的回包监听
}
private void onResponse(RetCode retCode)
{
result = retCode;
_finished = true;
}
public override bool keepWaiting
{
get { return !_finished; }
}
}
篇幅有限,更多关于Unity协程的实用技巧将收录进下篇。如果本篇没有令读者满足的话,请恕笔者厨艺有限,招待不周。
おそまつ~