Unity提供了协程来实现一定程度上的“伪并发”。

众所周知,UnityAPI只能在主线程中调用,如果我们希望通过多线程来实现多个过程的并发处理,那么如果涉及对UnityAPI的操作,则必须转到主线程去执行,比较繁琐。 MonoBehaviour.StartCoroutine可以创建一个协程(Coroutine), 在协程中可以通过yield语句暂停程序执行或者退出协程,从游戏运行的宏观角度看,它们似乎是在并行一样。

注意

协程中运行的代码仍然是在主线程中运行,所以我们仍然可以使用UnityAPI。

IEnumerator接口

要了解协程的执行,首先要了解StartCoroutine方法需要的参数IEnumerator。它是.Net层面提供的接口类型(https://docs.microsoft.com/zh-cn/dotnet/api/system.collections.ienumerator?view=net-6.0)而非Unity层面,它提供了一种简单的枚举非泛型集合的方法(也许这也是为什么没有泛型协程)。

要实现IEnumerator接口,需要实现两个方法MoveNextReset,以及一个属性Currentget访问器。其中MoveNextReset用于移动和重置指向的元素,Current用于获取当前元素。

按照约定,初始时,Current应该指向不可用元素,需要调用一次MoveNext才会指向第一个可用元素。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class NatualNumberEnumrator : IEnumerator
{
    private int _number = -1;

    public object Current => _number;

    public bool MoveNext()
    {
        ++_number;
        return _number < 100;//返回值表示Current是否合法,这里假设只数到99;
    }

    public void Reset()
    {
        _number = -1;
    }
}

void Start()
{
    NatualNumberEnumrator natualNumberEnumrator = new NatualNumberEnumrator();
    while (natualNumberEnumrator.MoveNext())
        Debug.Log(natualNumberEnumrator.Current);//打印0到99
}

可以看到[0, 99]这些数字并不是一开始就有,而是在我们不断执行MoveNext过程中产生的,有点像生成器(Generator)的概念。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class FibonacciEnumrator : IEnumerator
{
    private int _pre = 1;

    private int _cur = 0;

    public object Current => _cur;

    public bool MoveNext()
    {
        int temp = _cur;
        _cur += _pre;
        _pre = temp;
        return true;
    }

    public void Reset()
    {
        _pre = 1;
        _cur = 0;
    }
}

void Start()
{
    FibonacciEnumrator fibonacciEnumrator = new FibonacciEnumrator();
    int count = 0;
    while (fibonacciEnumrator.MoveNext())
    {
        Debug.Log(fibonacciEnumrator.Current);
        count++;
        if (count == 10)
            break;
    }
}

yield语句

yield是C#提供的关键字(https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/yield), 使用yield后,方法只能返回一个IEnumerator接口,且方法中的代码只能通过对IEnumerator接口执行MoveNext的方式来执行。

yield语句由两种形式:

  • yield return <expression>;用于更新Current值。
  • yield break;用于中途停止迭代。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
IEnumerator Fibonacci()
{
    Debug.Log("Fibonacci start");
    int pre = 1;
    int cur = 0;
    while (true)
    {
        int temp = cur;
        cur += pre;
        pre = temp;
        yield return cur;
    }
}

void Start()
{
    IEnumerator fibonacciEnumrator = Fibonacci();
    Debug.Log("Create fibonacc");
    for (int idx = 0; idx < 10; ++idx)
    {
        if (fibonacciEnumrator.MoveNext())
            Debug.Log(fibonacciEnumrator.Current);
    }
    //Create fibonacc
    //Fibonacci start
    //1
    //1
    //2
    //...
}

可以看到Fibonacci方法在调用时并没有执行,而是在执行MoveNext才被执行,直到遇到yield语句。使用yield语句,一个方法的执行过程 可以被“分段执行”。

注意

IEnumrator接口和yield语句是Unity协程的基石。

StartCoroutine

把Fibonacci的例子稍作修改,使用StartCoroutine执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
IEnumerator Fibonacci()
{
    Debug.LogFormat("Fibonacci start, Frame {0}", Time.frameCount);
    int pre = 1;
    int cur = 0;
    int cnt = 0;
    while (true)
    {
        int temp = cur;
        cur += pre;
        pre = temp;
        Debug.LogFormat("{0} Frame:{1}", cur, Time.frameCount);
        yield return null;
        ++cnt;
        if (cnt == 10)
            yield break;
    }
}

void Start()
{
    IEnumerator fibonacciEnumrator = Fibonacci();
    Debug.LogFormat("Create fibonacc, Frame:{0}", Time.frameCount);
    StartCoroutine(fibonacciEnumrator);
    //Create fibonacc, Frame:1
    //Fibonacci start, Frame 1
    //1 Frame:1
    //1 Frame:2
    //2 Frame:3
    //...
}

注意观察Fibonacci第一项被输出的时候是第一帧,也就是说当StartCoroutine被执行的时候,会在当前帧执行一次MoveNext而不是隔一帧再处理。

另外我们把输出Fibonacci项的工作放在了Fibonacci方法内,因为使用StartCoroutineFibonacci产生的IEnumrator接口由Unity去处理, yield语句对于我们使用者来说已经没有什么意义了。

不过这对于Unity来说有意义吖!

Unity会通过协程yield return返回的内容(本质上就是每次MoveNext之后读取一下Current)来决定暂停多久,分为以下几种情况:

  • YieldInstruction对象,如WaitForSecondsWaitForEndOfFrame等,由Unity决定暂停到何时。
  • CustomYieldInstruction子类对象,通过重写keepWaitingget访问器决定暂停到什么时候。
  • Coroutine或者IEnumrator,也就是协程嵌套,这种情况程序会执行新协程,直到执行完再继续原来的协程。
  • 其他情况,暂停到下一帧。

遇到协程嵌套的情况,协程至少会等待一帧:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
IEnumerator outer()
{
    Debug.LogFormat("outer start, Frame:{0}", Time.frameCount);
    for (int idx = 0; idx < 3; ++idx)
    {
        //yield return inner();
        yield return StartCoroutine(inner());
    }
}

IEnumerator inner()
{
    Debug.LogFormat("inner Frame:{0}", Time.frameCount);
    yield break;
}

void Start()
{
    StartCoroutine(outer());
    //outer start, Frame:1
    //inner Frame:1
    //inner Frame:2
    //inner Frame:3
}

如果不希望这样,可以手动执行被嵌套的协程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
IEnumerator outer()
{
    Debug.LogFormat("outer start, Frame:{0}", Time.frameCount);
    for (int idx = 0; idx < 3; ++idx)
    {
        IEnumerator innerCoroutine = inner();
        while (innerCoroutine.MoveNext())
            yield return innerCoroutine.Current;
    }
}

IEnumerator inner()
{
    Debug.LogFormat("inner Frame:{0}", Time.frameCount);
    yield break;
}

void Start()
{
    StartCoroutine(outer());
    //outer start, Frame:1
    //inner Frame:1
    //inner Frame:1
    //inner Frame:1
}

BGM