Unity协程:解决嵌套IEnumerator导致多一帧的问题

5min read

在Unity中,我们经常会用到Coroutine。利用C#语言提供的IEnumerator特性,它提供了很多便利:

  • 方便实现延时,yield return new WaitForSeconds()
  • 方便实现异步,一个方法不限于在一帧内执行;
  • 方便实现重入,在一个方法的执行过程中可插入执行另一个方法,并等待执行结束后返回到当前方法的中断点继续执行。

在此不加赘述使用方法,可参考官方文档,并且这里有一篇不错的博文

这里我主要介绍一下我在使用的过程中遇到的一个问题,以及解决办法。

Why One More Frame

下面看一个例子:

测试代码

IEnumerator Task1()
{        
    Debug.Log(">>>Task1---1---" + Time.time);
    yield return Task2(false);

    Debug.Log(">>>Task1---2---" + Time.time);
    yield return Task2(true);

    Debug.Log(">>>Task1---3---" + Time.time);
}

IEnumerator Task2(bool skip)
{
    Debug.Log(">>>Task2---1---skip: " + skip + "  " + Time.time);
    if (!skip)
        yield return 0;
    Debug.Log(">>>Task2---2---skip: " + skip + "  " + Time.time);
}

IEnumerator Start () 
{
    yield return new WaitForSeconds(1);
    StartCoroutine(Task1());
}

Log输出

从Log中可以看出,当Task2里没有执行任何yield return时,返回到Task1时仍然等了一帧往下执行。

那么问题来了:是不是嵌套一次yield return IEnumerator就要至少花费一帧呢?我稍微改动了测试代码:

IEnumerator Task1()
{
    Debug.Log(">>>Task1---begin---" + Time.time);
    yield return Task2();
    Debug.Log(">>>Task1---end---" + Time.time);
}
IEnumerator Task2()
{
    Debug.Log(">>>Task2---1---" + Time.time);
    if (counter++ < 3)
        yield return Task2();
    Debug.Log(">>>Task2---2---" + Time.time);
}

Log输出

并没有!无论迭代嵌套多少次Task2都没有叠加一帧,而只是第一次返回调用点放在了下一帧执行。

再次改动测试代码:

IEnumerator Task1()
{
    Debug.Log(">>>Task1---begin---" + Time.time);
    while (counter2++ < 2)
    {
        counter = 0;
        Debug.Log(">>>Task1---1---" + Time.time + "---count2: " + counter2);
        yield return Task2();
        Debug.Log(">>>Task1---2---" + Time.time + "---count2: " + counter2);
    }    
    Debug.Log(">>>Task1---end---" + Time.time);
}
IEnumerator Task2()
{
    Debug.Log(">>>Task2---begin---" + Time.time);
    yield return Task3();
    Debug.Log(">>>Task2---end---" + Time.time);
}
IEnumerator Task3()
{
    Debug.Log(">>>Task3---begin---" + Time.time+ "---count: " + counter);
    if (counter++ < 1)
        yield return Task3();
    Debug.Log(">>>Task3---end---" + Time.time+ "---count: " + counter);
}

Log输出

可以看出,无论怎么深入、迭代嵌套下去,第一次返回调用点(yield return)总是在下一帧执行。这就带来了一个问题,当在最上层多次调用yield return IEnumerator,并且下层在嵌套调用时并没有真正执行yield return Coroutine/YieldInstruction/null/value时,就会多出不必要的帧,可能会导致与预期结果的偏差。我在实现Blockly Code Runner时遇到了这个问题,因为采用了协程的方式执行Block的解释方法,但是一部分Block的解释方法是没有yield return的,理应执行完后立即执行下一个Block的方法,实际上却等了一帧。

了解Coroutine执行原理的都知道,它是依赖于IEnumerator运作的,在Unity Monobehavior的一个生命周期中的某个时间点执行MoveNext(),返回false则结束。如果遇到嵌套IEnumerator调用,则应该是将其推入栈顶,先执行嵌套,等待执行完后推出。从这里可以推断出,推出后返回上一层IEnumerator后,需要在下一帧执行MoveNext()返回false才结束,因此才产生了这个问题。

Solution

鉴于以上反推出的可能原因,我修改了执行代码,在嵌套IEnumerator执行结束后立即推出并返回上一层,并且上一层立即执行MoveNext(),做到嵌套IEnumerator的推入、推出都是在一帧内连续的,只有在遇到除了IEnumerator外的yield return才等待。测试结果达到预期。

IEnumerator SimulateCoroutine(IEnumerator itorFunc)
{
    Stack<IEnumerator> stack = new Stack<IEnumerator>();
    stack.Push(itorFunc);
    while (stack.Count > 0)
    {
        IEnumerator itor = stack.Peek();
        bool finished = true;
        while (itor.MoveNext())
        {
            if (itor.Current is IEnumerator)
            {
                stack.Push((IEnumerator) itor.Current);
                finished = false;
                break;
            }

            yield return itor.Current;
        }

        if (finished)
        {
            stack.Pop();
        }
    }
}

第三个测试代码Log输出

可能我的理解和推导不完全正确,如果错误,欢迎指出~


MORE FROM THE BLOG

The UGUI Design of uBlockly...

For Chinese:...

5min read

The Interpreter and Runner of...

For Chinese:...

6min read

The Blockly Model of uBlockly...

For Chinese:...

4min read

Introduction of uBlockly - Reimplementation...

For Chinese:...

1min read