Done is better than perfect

0%

Unity协程

为什么要使用协程

为什么要使用协程?,先来看段代码:

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
using System.Collections;
using UnityEngine;

public class TestCoroutine : MonoBehaviour
{
void Start()
{
StartCoroutine(CoroutineFunc());
}

IEnumerator CoroutineFunc()
{
var wfeof = new WaitForEndOfFrame()
//1000次循环大概1s
for(int i = 0; i < 1000; i++)
{
//假设做大量的工作,大概CPU耗时:0.1s
DoManyWork();
yield return wfeof;
}
}

void DoManyWork()
{
Debug.Log("DoManyWork is called.");
}
}

上面的CoroutineFunc函数里面消耗了大量的CPU时间,在没有协程的开发环境中,比如像C/C++这样的开发语言,一般会创建一个线程来执行这种耗时的操作,多线程开发也会牵扯到各种数据和资源的互斥访问和线程同步的问题,这也导致在其他线程访问Unity引擎相关的组件会出现问题,因为我们没法控制其他的线程在访问操作一个对象时Unity引擎不去访问它,所以在Unity中其他线程一旦访问Unity引擎相关的组件就可能回出错。因此为让Unity的主线程不被这种耗时的操作给卡住,就只有将这种耗时的操作分到多帧里去执行,所以就出现了这种伪线程即协程。 上面实例代码中for循环的控制变量i,在下次调用的时候是如何保证能正常迭代的?为什么函数签名中的返回值必须是IEnumerator,可以是其他的吗?协程函数又是谁在调?WaitForEndOfFrame又是什么,可以有其他的吗,可以自定义吗?yield return又是什么呢?

协程实现原理

yield return生成器功能

yield return是NET 2.0框架允许C#引入一个提供生成器功能的迭代器,主要是构建类似于python中的yield的功能。使用yield return,下面的这个函数将自动的保存迭代中的状态。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这个方法将传入一个数组
// 并且返回所有even的数字
namespace DotnetExample
{
class Program
{
public static IEnumerable<int> GetEven(int[] numbers)
{
for(int i=0; i < numbers.Length; i++)
{
if (i % 2 == 0)
yield return numbers[i];
}
}
static void Main(string[] args)
{
}
}
}
这里也有一个yield break声明,此语句用于无条件返回到调用者。在每个生成器方法的最后都会有一个隐式的yield break。前面我们提到了几个陌生的名词:生成器功能,迭代器和生成器方法。先不管这些晦涩的术语,我们先来看哈上面这段代码对应的IL代码,删除了一些不影响我们理解功能的代码,删除后的结果如下:
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// 生成了一个名为:<GetEven>d__0的类,并继承了IEnumerable和IEnumerator接口
.class nested private auto ansi sealed beforefieldinit <GetEven>d__0 extends [System.Runtime]System.Object implements [System.Runtime]System.Collections.Generic.IEnumerable`1<System.Int32>, [System.Runtime]System.Collections.IEnumerable, [System.Runtime]System.Collections.Generic.IEnumerator`1<System.Int32>, [System.Runtime]System.Collections.IEnumerator, [System.Runtime]System.IDisposable
{
.field private int32 '<>1__state' //用于保存迭代状态(光标位置)
.field private int32 '<>2__current' //记录当前的迭代值
.field private int32 '<>l__initialThreadId' //记录执行的线程ID
.field private int32[] numbers //记录的传入的形参numbers
.field public int32[] '<>3__numbers' //记录的传入的形参numbers,给外部访问的
.field private int32 '<i>5__1' //记录的循环控制变量i

//每迭代一次调用一下,将光标下移一个
.method private hidebysig newslot virtual final instance default bool MoveNext() cil managed
{
.maxstack 3
.locals init(int32 V_0, bool V_1, int32 V_2, bool V_3)
IL_0000: ldarg.0
IL_0001: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<>1__state'
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: brfalse.s IL_0012
IL_000a: br.s IL_000c
IL_000c: ldloc.0
IL_000d: ldc.i4.1
IL_000e: beq.s IL_0014
IL_0010: br.s IL_0016
IL_0012: br.s IL_0018
IL_0014: br.s IL_004e
IL_0016: ldc.i4.0
IL_0017: ret
IL_0018: ldarg.0
IL_0019: ldc.i4.m1
IL_001a: stfld int32 DotnetExample.Program/<GetEven>d__0::'<>1__state'
IL_001f: nop
IL_0020: ldarg.0
IL_0021: ldc.i4.0
IL_0022: stfld int32 DotnetExample.Program/<GetEven>d__0::'<i>5__1'
IL_0027: br.s IL_0066
IL_0029: nop
IL_002a: ldarg.0
IL_002b: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<i>5__1'
IL_0030: ldc.i4.2
IL_0031: rem
IL_0032: ldc.i4.0
IL_0033: ceq
IL_0035: stloc.1
IL_0036: ldloc.1
IL_0037: brfalse.s IL_0055
IL_0039: ldarg.0
IL_003a: ldarg.0
IL_003b: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<i>5__1'
IL_0040: stfld int32 DotnetExample.Program/<GetEven>d__0::'<>2__current'
IL_0045: ldarg.0
IL_0046: ldc.i4.1
IL_0047: stfld int32 DotnetExample.Program/<GetEven>d__0::'<>1__state'
IL_004c: ldc.i4.1
IL_004d: ret
IL_004e: ldarg.0
IL_004f: ldc.i4.m1
IL_0050: stfld int32 DotnetExample.Program/<GetEven>d__0::'<>1__state'
IL_0055: nop
IL_0056: ldarg.0
IL_0057: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<i>5__1'
IL_005c: stloc.2
IL_005d: ldarg.0
IL_005e: ldloc.2
IL_005f: ldc.i4.1
IL_0060: add
IL_0061: stfld int32 DotnetExample.Program/<GetEven>d__0::'<i>5__1'
IL_0066: ldarg.0
IL_0067: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<i>5__1'
IL_006c: ldarg.0
IL_006d: ldfld int32[] DotnetExample.Program/<GetEven>d__0::numbers
IL_0072: ldlen
IL_0073: conv.i4
IL_0074: clt
IL_0076: stloc.3
IL_0077: ldloc.3
IL_0078: brtrue.s IL_0029
IL_007a: ldc.i4.0
IL_007b: ret
} // End of method System.Boolean DotnetExample.Program/<GetEven>d__0::MoveNext()

//获取当前的迭代值,内部函数,外部调用Current(),Current函数调用的就是这个函数
.method private hidebysig newslot virtual specialname final instance default int32 System.Collections.Generic.IEnumerator<System.Int32>.get_Current() cil managed
{
.custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // ....
// Method begins at Relative Virtual Address (RVA) 0x2114
// Code size 7 (0x7)
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<>2__current'
IL_0006: ret
} // End of method System.Int32 DotnetExample.Program/<GetEven>d__0::System.Collections.Generic.IEnumerator<System.Int32>.get_Current()

//实现IEnumerable的GetEnumerator(),用于获取Enumerator对象,并初始化了迭代状态<>1__state和设置numbers形参数据
.method private hidebysig newslot virtual final instance default [System.Runtime]System.Collections.Generic.IEnumerator`1<System.Int32> System.Collections.Generic.IEnumerable<System.Int32>.GetEnumerator() cil managed
{
.maxstack 2
.locals init(class DotnetExample.Program/<GetEven>d__0 V_0)
IL_0000: ldarg.0
IL_0001: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<>1__state'
IL_0006: ldc.i4.s -2
IL_0008: bne.un.s IL_0022
IL_000a: ldarg.0
IL_000b: ldfld int32 DotnetExample.Program/<GetEven>d__0::'<>l__initialThreadId'
IL_0010: call int32 class [System.Runtime]System.Environment::get_CurrentManagedThreadId()
IL_0015: bne.un.s IL_0022
IL_0017: ldarg.0
IL_0018: ldc.i4.0
IL_0019: stfld int32 DotnetExample.Program/<GetEven>d__0::'<>1__state'
IL_001e: ldarg.0
IL_001f: stloc.0
IL_0020: br.s IL_0029
IL_0022: ldc.i4.0
IL_0023: newobj instance void class DotnetExample.Program/<GetEven>d__0::.ctor(int32)
IL_0028: stloc.0
IL_0029: ldloc.0
IL_002a: ldarg.0
IL_002b: ldfld int32[] DotnetExample.Program/<GetEven>d__0::'<>3__numbers'
IL_0030: stfld int32[] DotnetExample.Program/<GetEven>d__0::numbers
IL_0035: ldloc.0
IL_0036: ret
} // End of method System.Collections.Generic.IEnumerator`1<System.Int32> DotnetExample.Program/<GetEven>d__0::System.Collections.Generic.IEnumerable<System.Int32>.GetEnumerator()

// 获取当前的迭代对象
.property instance int32 System.Collections.Generic.IEnumerator<System.Int32>.Current ()
{
.get instance default int32 DotnetExample.Program/<GetEven>d__0::System.Collections.Generic.IEnumerator<System.Int32>.get_Current ()
} // End of property System.Int32 DotnetExample.Program/<GetEven>d__0::System.Collections.Generic.IEnumerator<System.Int32>.Current()
} // End of class DotnetExample.Program/<GetEven>d__0
从IL代码我们可以看出,当我们使用yield return关键字时,C#编译器会为我们生成一个实现了IEnumerable和IEnumerator接口的类,这就是生成器,生成的类中又保存了迭代状态,所以也具有迭代功能,整个函数就是生成器方法。那么我们现在就可以可以使用foreach进行遍历了,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
static void Main(string[] args)
{
var iterator = GetEven(new int[4] { 1, 2, 3, 4 });
foreach (var item in iterator)
{
Console.WriteLine($"Item:{item}");
}
}

//将输出
Item:1
Item:3

IEnumerator有什么呢?

IEnumerable和IEnumerator的区别

还是先来看一个实例,如下:

1
2
3
4
5
6
7
8
public static IEnumerator<int> GetEven(int[] numbers)
{
for (int i = 0; i < numbers.Length; i++)
{
if (i % 2 == 0)
yield return numbers[i];
}
}
这实例代码跟上个实例的代码除了将IEnumerable改变成了IEnumerator以外其他的没有任何差别,那么来看哈IL有什么差别,删除了函数体的IL代码如下:
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接口,所有没有GetEnumerator函数
.class nested private auto ansi sealed beforefieldinit <GetEven>d__0 extends [System.Runtime]System.Object implements [System.Runtime]System.Collections.Generic.IEnumerator`1<System.Int32>, [System.Runtime]System.Collections.IEnumerator, [System.Runtime]System.IDisposable
{
.field private int32 '<>1__state' //用于保存迭代状态(光标位置)
.field private int32 '<>2__current' //记录当前的迭代值
.field public int32[] numbers //记录的传入的形参numbers
.field private int32 '<i>5__1' //记录的循环控制变量i
.method public hidebysig specialname rtspecialname instance default void .ctor(int32 <>1__state) cil managed
{
}

.method private hidebysig newslot virtual final instance default bool MoveNext() cil managed
{
}

.method private hidebysig newslot virtual specialname final instance default int32 System.Collections.Generic.IEnumerator<System.Int32>.get_Current() cil managed
{
}

.property instance int32 System.Collections.Generic.IEnumerator<System.Int32>.Current ()
{
.get instance default int32 DotnetExample.Program/<GetEven>d__0::System.Collections.Generic.IEnumerator<System.Int32>.get_Current ()
}
}
那么返回IEnumerator的迭代器,将如何遍历呢?尝试用foreach遍历但编译器提示: “ CS1579: foreach statement cannot operate on variables of type 'IEnumerator' because 'IEnumerator' does not contain a public instance or extension definition for 'GetEnumerator'” 所以编译限定了能使用foreach遍历的对象必须有GetEnumerator函数,因为IEnumerator没有实现IEnumerable接口的GetEnumerator方法,所有foreach是不能遍历。但是foreach内部也是通过IEnumerator提供的接口来进行遍历的,那么我们尝试手动调用IEnumerator提供的函数来进行遍历,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
static void Main(string[] args)
{
var iterator = GetEven(new int[4] { 1, 2, 3, 4 });
while (iterator.MoveNext())
{
Console.WriteLine($"Item:{iterator.Current}");
}
}

//将输出
Item:1
Item:3

IEnumerable和IEnumerator都会生成IEnumerator里面的接口函数, 也就是说IEnumerable能够实现IEnumerator接口定义的全部功能,而IEnumerable只有一个GetEnumerator函数,这个函数只是为了给foreach提供支持,foreach对应的IL代码也会调用GetEnumerator函数来获取一个初始化了状态的Enumerator对象,最终的迭代器也是由Enumerator接口提供的支持。

从上面的分析来看,在Unity里面定义协时,我们也可定义个返回IEnumerable的函数来实现协程的功能,代码如下:

1
2
3
4
5
IEnumerable CoroutineFunc()
{
//TODO:功能
yield return
}
在启动协程的时候我们手动调动GetEnumerator()函数来获取一个Enumerator对象,代码如下:
1
StartCoroutine(CoroutineFunc().GetEnumerator());
为了验证我们的假设才这样调用的,项目中不要这样去调用,底层的IL代码会多一些。经过测试这样的调用方式也是没有问题。

谁在调用

前面我们手动调用了Enumerator接口的MoveNext来进行迭代,那在Unity的协程中又是谁在负责调用MoveNext呢?下面聊聊协程的生命周期。先来看哈Unity的脚本函数生命周期图,如下: 脚本生命周期图

在上图中,我们可以看出所有类型的协程都是在对象的生命周期的不同阶段执行的,从图中可以看出,主要在3个阶段执行: 1. 在物理更新完成调用注册的yield WaitForFixedUpdate协程。 2. 在游戏逻辑更新中调用注册的yield null, yield WaitForSeconds,yield WWW, yield StartCoroutine和自定义的其他的协程。 3. 在一帧结束时调用注册的yield WaitForEndOfFrame

当Unity引擎在执行每个对象对应的生命周期阶段时,都会调用生成的协程类的MoveNext函数进行下一步,如此反复直到MoveNext返回false,此时这个协程的生命周期也就结束了。如果我们想提前结束一个协程的生命周期呢?我们可以调用StopCoroutine函数来注销正在执行的协程,注销后就会从协程队列里面移除,在下次生命周期到来时便不会被执行了。 像WWW,WaitForSecondsRealtime, WaitUntil,WaitWhile这些继承至CustomYieldInstruction的Enumerator对象都是在游戏逻辑更新中检查执行的,这些“自定义”类型的协程对象不会直接调用MoveNext而是先检查keepWaiting是否为false如果为false则在这次的生命周期中调用MoveNext函数。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
Update()
{
List<Enumerator> coroutineList = ...
foreach(var cr in coroutineList)
{
var cyi = cr as CustomYieldInstruction
if(cyi && !cyi.keepWaiting){
cyi.MoveNext()
}
}
}

理解了Unity调用协程机制后,我们来自定义个每间隔指定帧数的yield对象。代码如下:

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
public class IntervalFrame : CustomYieldInstruction
{
private int _curFrameCount = 0;
private int _frameCount = 0;
public IntervalFrame(int count)
{
_frameCount = count;
}

public override bool keepWaiting
{
get
{
_curFrameCount++;
if (_curFrameCount < _frameCount)
{
return true;
}
else
{
_curFrameCount = 0;
return false;
}
}
}
}

在写自定义yield对象时,注意重置自己内部状态,这样在使用时就不用每次都new一个新的对象了。Unity内建的yield类型都是重置了状态的。上面这个实例在keepWaiting返回false的时候重置了_curFrameCount内部字段的。

参考