对Unity中Coroutines的理解

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

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

yield

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

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

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

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

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,输入以下代码自己执行一遍。

以上代码的输出为:

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

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

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

为什么会是这样的输出呢?要理解这个之前,我们首先需要知道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表示开始一段协程,它有两种开始方式:

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

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

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

如果之前用的是字符串的开始方式,则结束协程也要用这种方式;同理对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("http://wuzhiwei.net/photo/photo1.jpg");,表示通过WWW访问网址http://wuzhiwei.net/photo/photo1.jpg,将照片下载完毕时时将协程恢复。

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

例如:

输出如下:

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

什么时候用协程?

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

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

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

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

我们在图片下载完成之前,我们可以在每一帧更新图片下载的进度,从而可以更新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的影响

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注