β

对Unity中Coroutines的理解

Tim's Blog 47 阅读

相信从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 循环执行的时候, numbers MoveNext 方法会被调用到, 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 Invoked before 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("http://wuzhiwei.net/photo/photo1.jpg"); ,表示通过WWW访问网址 http://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里的逻辑会越来越多,而使用协程则可以将逻辑内聚到协程内部

总结及注意点

参考资料

作者:Tim's Blog
Thinking, Coding && Writing
原文地址:对Unity中Coroutines的理解, 感谢原作者分享。

发表评论