对Unity中Coroutines的理解

相信从Cocos2d-x转到Unity的开发者会对Unity中的Coroutines(协程)和关键字yield产生困惑,我也一样。

在阅读了Unity官方文档UnityGems后,我加深了对Coroutines的理解。本文将用一种尽可能简单直白的方式来阐述我对Coroutines的理解。

yield

想要理解Coroutines,则必须先理解yield[ji:ld]语句。

在Unity中,yield语句其实是C#的语法。在C#中,yield语句有以下两种用法:

yield return <expression>;
yield break;

yield是一个多义英文单词,有放弃、投降、 生产、 获利等诸多意思。那么yield究竟在C#的语法中是什么意思呢?

我们先来看一个简单的foreach语句:

static void Main()  
{  
    IEnumerable<int> numbers = SomeNumbers();  
    foreach (int number in numbers)  
    {  
        Console.Write(number.ToString() + " ");  
    }  
    // 代码执行完毕后将会输出: 3 5 8  
}  

public static System.Collections.IEnumerable SomeNumbers()  
{  
    yield return 3;  
    yield return 5;  
    yield return 8;  
} 

SomeNumbers是一个简单的迭代器方法,里面分别用到了三次yield return语句。

SomeNumbers被调用的时候,其函数本体并不会执行,而是给变量numbers赋值了一个IEnumerable<int>类型。

每一次的foreach循环执行的时候,numbersMoveNext方法会被调用到,MoveNext方法会执行SomeNumbers方法内的语句,直到碰到yield return语句。

当碰到yield return语句时,SomeNumbers方法将终止执行,并将yield return的值赋给number。但这并不代表SomeNumbers方法已经全部执行完毕,等到下一次foreach循环执行时,代码会从上一次返回的yield return语句之后开始继续执行,直到函数结束或者遇到下一个yield return语句为止。

简言之,yield return的意义是返回一个值给foreach的每次迭代,然后终止迭代器方法的执行。等到下一次迭代时,迭代器方法会从原来的位置继续往下运行。

可以理解为迭代器方法SomeNumbers为一个生产者,foreach循环的每次循环MoveNext是一个消费者。每一次循环往下移动一格,消费者便要向迭代器方法要一个值。所以,我想yield在其中的意思应该是产出的意思,而且是按需生产的意思。

yield break则可以视为终止产出的意思,即结束迭代器的迭代。

什么是Coroutines?

Coroutines翻译过来就是协程,和线程(Threads)是两个完全不同的概念。

线程是完全异步的,在多核CPU上可以做到真正的并行。正是由于完全并行的自由,导致线程的编程特别麻烦。

比如:A线程在读取一个变量的值时,B线程可能正在给这个变量重新赋值,导致A线程读到的值并不准确。为了保证一个变量同一时刻只有一个线程访问,需要给变量加上锁,当线程访问完毕后又需要记得解锁。

协程则是在线程中执行的一段代码,它并不能做到真正异步。协程的代码可以只执行其中一部分,然后挂起,等到未来一个恰当时机再从原来挂起的地方继续向下执行

看到协程的功能,是不是想到了之前的yield return语句?只执行一部分的代码,直到下一个yield return语句,然后挂起,直到恢复时接着之前的代码继续向下执行。

没错,Unity中的协程就用到了yield return语句,只不过跟上述的简单foreach循环不同,Unity是在游戏的每一帧(frame)中去检查是否需要从挂起恢复继续向下执行。

yield return null

以下是一个最简单的协程示例,我建议屏幕前的你打开Unity,输入以下代码自己执行一遍。

    void Start () 
    {
        StartCoroutine(ReturnNull());
        print("Start Ends");
    }

    void Update () 
    {
        print("Update");
    }

    IEnumerator ReturnNull() 
    {
        print("ReturnNull Invoked");
        while(true)
        {
            print("before yield return null");
            yield return null;
            print("after yield return null");
        }
    }

以上代码的输出为:

ReturnNull Invoked
before yield return null
Start Ends
Update
Update
after yield return null
before yield return null
Update
after yield return null
before yield return null
Update
after yield return null
before yield return null
Update
after ...

StartCoroutine函数启动了一个协程方法ReturnNull

可以看到ReturnNull的函数体在StartCoroutine后立刻被执行到,于是输出了ReturnNull Invokedbefore yield return null。之后便执行到了yield return null;语句,然后便输出Start Ends,并没有输出after yield return null,说明代码在此中断。根据yield的用法,我们知道这只是一次暂时的挂起,在未来某个时候after yield return null将会输出。

紧接着输出了Update,这是我们的第一帧。后续的输出则构成了一个三句话的无限循环:

Update
after yield return null
before yield return null

为什么会是这样的输出呢?要理解这个之前,我们首先需要知道Unity在一帧中都干了些什么,而且它们是以怎样的顺序来执行的。

Unity的运行时序

Unity帧时序图 以上是Unity一帧内做的事情的时序图,源自于Execution Order of Event Functions。可以看到yield return null是在Update之后执行。

现在我们来试着理解以上代码的输出过程。

首先,第一帧的Update输出了一个Update,因为协程是在Start函数里开始的,所以要在下一帧才能从挂起状态恢复。

第二帧,从时序图可以看到,Mono的Update函数先执行,所以会先输出一个Update,然后协程被恢复运行。因为上一次协程是在yield return null被挂起的,所以协程会从它的下一句恢复,也就是print("after yield return null");,因为代码处在一个无限循环中,所以print("before yield return null");会紧接着被执行到。接下来又执行到yield return null,此时协程会再次被挂起,直到下一帧Update之后再恢复。

我们稍微总结一下:yield return null是协程的挂起点,即协程内的代码执行到这里将被挂起。它同时也是一个恢复点,即在下一帧Update之后,将会执行它的下一句代码,直到函数结束或者遇到下一个yield return null

其他的用法

yield break

yield break表示退出协程,即协程将不会从挂起点被恢复。在yield break之后的协程内的代码将都不会被执行到。

我们可以用这个方式来彻底终止协程的运行。

StartCoroutine & StopCoroutine

StartCoroutine表示开始一段协程,它有两种开始方式:

public Coroutine StartCoroutine(IEnumerator routine);
public Coroutine StartCoroutine(string methodName, object value = null);

一种是用IEnumerator对象启动,例如上例的ReturnNull()其实就是一个IEnumerator对象。

另一种是以协程名启动,例如上例的协程启动可以写成StartCoroutine("ReturnNull");

StopCoroutine表示彻底终止一段协程,两种不同的启动方式,但必须与终止方式一一对应着:

public void StopCoroutine(string methodName);
public void StopCoroutine(IEnumerator routine);

如果之前用的是字符串的开始方式,则结束协程也要用这种方式;同理对IEnumerator亦然。

因为StopCoroutine(string methodName)的方式的性能开销(字符串查找)会比StopCoroutine(IEnumerator routine)更高一些,且前者的开始方式只能给协程传递一个参数,所以我们一般会倾向于使用StartCoroutine(IEnumerator routine)来开启协程。

不过,为了能使用StopCoroutine(IEnumerator routine)来终止协程运行,我们需要将开始协程时的IEnumerator对象暂存起来。

yield return xxx

上述所说的yield return null是最简单的协程类型,即在每一帧Update之后恢复。

在Unity中,还支持了其他的一些类型,举例如下:

yield return new WaitForSeconds(1.5f);,表示在1.5秒之后将协程恢复,从时序图中可以看到它的恢复也将在Update之后执行。

yield return new WaitForEndOfFrame();,表示在一帧的最后阶段将协程恢复,从时序图可以看到它的恢复将在一帧的最后执行,此时物理逻辑,游戏逻辑和渲染逻辑都已执行完毕。

yield return new WaitForFixedUpdate();,表示在物理引擎这一帧运算完毕后将协程恢复,从时序图可以看到它的恢复在物理运算的最后一步,在FixedUpdate之后执行。

yield return new WWW("https://wuzhiwei.net/photo/photo1.jpg");,表示通过WWW访问网址https://wuzhiwei.net/photo/photo1.jpg,将照片下载完毕时时将协程恢复。

yield return StartCoroutine(routine),这是一种比较特殊的方式,即组合协程。 即这个协程的恢复条件是routine这个协程的运行已经彻底终止

例如:

    IEnumerator Start()
    {
        Debug.Log("Start Method Starts");
        yield return StartCoroutine(TestCoroutine());
        Debug.Log("Start Method Ends");
    }

    IEnumerator TestCoroutine()
    {
        Debug.Log("TestCoroutine Starts");
        int i = 0;
        while(i < 3)
        {
            Debug.Log("Before "+i);
            yield return null;
            Debug.Log("After "+i);
            ++i;
        }
    }

输出如下:

Start Method Starts
TestCoroutine Starts
Before 0
After 0
Before 1
After 1
Before 2
After 2
Start Method Ends

只有当TestCoroutine中的i == 3时,TestCoroutine这个协程将彻底终止,此时Start将被恢复,才输出了Start Method Ends

什么时候用协程?

我们了解了协程的原理和用法,那么协程的意义是什么,该什么时候使用它呢?

一个典型的使用场景是延时执行一段代码:

IEnumerator WaitForDoSomeThing() 
{
    yield return new WaitForSeconds(1.5f);
    DoSomeThing();
}

StartCoroutine(WaitForDoSomeThing());开启协程后,再过1.5秒DoSomeThing()方法将被执行到。因为遇到yield return,协程会被挂起,1.5秒后,将会从挂起点恢复,向下执行DoSomeThing()方法。

另外一个使用场景便是做进度的更新,比如下载一张图片:

    IEnumerator WaitForDownload()
    {
        WWW www = new WWW("https://edmullen.net/test/rc.jpg");
        while (!www.isDone)
        {
            print(www.progress);
            yield return null;
        }
        print("photo ready!");
    }

我们在图片下载完成之前,我们可以在每一帧更新图片下载的进度,从而可以更新Loading条之类的进度指示器来提示玩家。

我们当然也可以在Update方法中来做这件事,这样可能更明显一点,但是有以下两个缺陷:

  1. Update每帧都会执行到,也就意味着www.isDone的条件判断会贯穿于整个游戏的生存周期,这样在图片下载完毕后会带来没有必要的性能开销
  2. 代码的聚合性不强,Update里的逻辑会越来越多,而使用协程则可以将逻辑内聚到协程内部

总结及注意点

  • 协程不是线程,不能做到真正异步。协程是运行在Unity主线程中的一段代码,它能被挂起,然后在未来的某个时刻从挂起的位置恢复运行
  • yield return <expression>格式,表示协程的挂起点和未来的恢复点,究竟具体是什么样的未来(一帧之后或n秒后或者其它等等),取决于<expression>具体是什么
  • yield break格式,表示彻底退出协程,且其之后的协程代码将不会被执行到
  • WaitForSeconds会受到Time.timeScale的影响,当Time.timeScale为0时,协程将永远不会被恢复。如果一定要用真实时间可以用WaitForSecondsRealtime,它不会受到Time.timeScale的影响

参考资料

关注微信公众号:timind

One response

发表回复

您的电子邮箱地址不会被公开。