在Unity中使用协程
在最开始做游戏的时候,我们经常会遇到这种需求:玩家死亡之后玩家应该逐渐消失而非瞬间消失。玩家死亡很简单,我们按照最开始初学者的思路,死亡直接调用Destroy(gameObject); 就行了,但是当gameobject被销毁之后,我们已经无法去更改其挂载的组件。所以实际上我们需要先执行消失的逻辑。
下面是消失一个错误的实现
void Fade()
{
float alpha = 1.0f;
while(alpha > 0)
{
alpha -= Time.deltaTime;
Color c = renderer.material.color;
c.a = alpha;
renderer.material.color = c;
}
}上面代码的思路很简单,让所渲染对象的Alpha通道从1开始不断递减直至0。但是,函数被调用之后将运行到完成状态然后返回,函数内的一切逻辑都会在同一帧内完成。物体会瞬间消失,而不会淡出。
将函数改写为协程就可以修正这个错误。
IEnumerator Fade()
{
float alpha = 1.0f;
while(alpha > 0)
{
alpha -= Time.deltaTime;
Color c = renderer.material.color;
c.a = alpha;
renderer.material.color = c;
yield return null;//协程会在这里被暂停,直到下一帧被唤醒。
}
}
//Fade不能直接调用,需要使用StartCoroutine(方法名(参数列表))的形式进行调用。
StartCoroutine(Fade());这种以IEnumerator为返回值的函数,在C#里面被称之为迭代器函数,在Unity里面也可以被称为协程函数。 当协程函数运行到yield return null语句时,协程会被暂停,Unity继续执行其它逻辑,并在下一帧唤醒协程。
Unity官方对于协程的定义是这样的:
A coroutine is like a function that has the ability to pause execution and return control to Unity but then to continue where it left off on the following frame. By default, a coroutine is resumed on the frame after it yields but it is also possible to introduce a time delay using WaitForSeconds
协程是一种特殊的函数,它可以主动的请求暂停自身并提交一个唤醒条件,Unity会在唤醒条件满足的时候去重新唤醒协程。
MonoBehaviour.StartCoroutine()方法可以开启一个协程,这个协程会挂在该MonoBehaviour下。
在MonoBehaviour生命周期的Update和LateUpdate之间,会检查这个MonoBehaviour下挂载的所有协程,并唤醒其中满足唤醒条件的协程。
要想使用协程,只需要以IEnumerator为返回值,并且在函数体里面用yield return语句来暂停协程并提交一个唤醒条件。然后使用StartCoroutine来开启协程。
IEnumerator CoroutineA(int arg1, string arg2)
{
Debug.Log("协程A被开启");
yield return null;
Debug.Log("暂停了一帧");
yield return new WaitForSeconds(1.0f);
Debug.Log("暂停了一秒");
yield return StartCoroutine(CoroutineB(arg1, arg2));
Debug.Log("CoroutineB运行结束后协程A才被唤醒");
yield return new WaitForEndOfFrame();
Debug.Log("在这一帧的最后,协程被唤醒");
Debug.Log("协程A运行结束");
}
IEnumerator CoroutineB(int arg1, string arg2)
{
Debug.Log($"协程B被开启了,可以传参数,arg1={arg1}, arg2={arg2}");
yield return new WaitForSeconds(3.0f);
Debug.Log("协程B运行结束");
}注意:
- 协程是挂在
MonoBehaviour上的,必须要通过一个MonoBehaviour才能开启协程。 MonoBehaviour被Disable的时候协程会继续执行,只有MonoBehaviour被销毁的时候协程才会被销毁。- 本质上协程还是运行在主线程上的,协程更类似于
Update()方法,Unity会每一帧去检测协程需不需要被唤醒。一旦你在协程中执行了一个耗时操作,很可能会堵塞主线程。
Unity协程的底层原理
协程分为两部分,协程与协程调度器:协程仅仅是一个能够中间暂停返回的函数,而协程调度是在MonoBehaviour的生命周期中实现的。 准确的说,Unity只实现了协程调度部分,而协程本身其实就是用了C#原生的”迭代器方法“。
IEnumerator是什么
public interface IEnumerator
{
object Current { get; }//获取集合当前迭代位置的元素
bool MoveNext();//将当前迭代位置推进到下一个位置,如果成功推进到下一个位置则返回true,否则已经推进到集合的末尾返回false
void Reset();//将当前迭代位置设置为初始位置(该位置位于集合中第一个元素之前,所以当调用Reset方法后,再调用MoveNext方法,Current值则为集合的第一个元素)
}枚举器接口提供了一种通用的遍历集合的方法,任意一个集合只要实现自己的IEnumerator,它的使用者就可以通过IEnumerator迭代集合中的元素,而不用针对不同的集合采用不同的迭代方式。
foreach只是C#提供的语法糖,本质上也是采用IEnumerator来遍历集合的。在编译时编译器会将的foreach循环进行下面的代码转换:
foreach (var item in collection)
{
Console.WriteLine(item.ToString());
}
//转换后的代码
{
var enumerator = collection.GetEnumerator();
try
{
while (enumerator.MoveNext()) // 判断是否成功推进到下一个元素(可理解为集合中是否还有可供迭代的元素)
{
var item = enumerator.Current;
Console.WriteLine(item.ToString());
}
} finally
{
// dispose of enumerator.
}
}yield
yield是C#的关键字,是快速定义迭代器的语法糖。只要是yield出现在其中的方法就会被编译器自动编译成一个迭代器,对于这样的函数可以称之为迭代器函数。迭代器函数的返回值就是自动生成的迭代器类的一个对象。
这个类它会记录当前迭代的状态,包括本地变量的值与迭代器在集合中的位置;它会把yield return x翻译成“将Current设为x,记录当前的状态,并暂停执行”这一系列操作。
//eg.Form Microsoft
var numbers = ProduceEvenNumbers(5);
Console.WriteLine("Caller: about to iterate.");
foreach (int i in numbers)
{
Console.WriteLine($"Caller: {i}");
}
IEnumerable<int> ProduceEvenNumbers(int upto)
{
Console.WriteLine("Iterator: start.");
for (int i = 0; i <= upto; i += 2)
{
Console.WriteLine($"Iterator: about to yield {i}");
yield return i;
Console.WriteLine($"Iterator: yielded {i}");
}
Console.WriteLine("Iterator: end.");
}
// Output:
// Caller: about to iterate.
// Iterator: start.
// Iterator: about to yield 0
// Caller: 0
// Iterator: yielded 0
// Iterator: about to yield 2
// Caller: 2
// Iterator: yielded 2
// Iterator: about to yield 4
// Caller: 4
// Iterator: yielded 4
// Iterator: end.Unity中的协程利用了迭代器的机制实现了**“执行一系列操作”而非“遍历一个集合”的功能。关键点在于:在这里一个协程中的“集合”**就是所有yield return的返回值,比如{null, new WaitForSeconds(5)}就是下方协程的集合。当我们在遍历这个集合时,所有真正有用的语句都会一并被执行。
IEnumerator MyCoroutine()
{
//真正有用的语句
yield return null;
//真正有用的语句
yield return new WaitForSeconds(5);
}yield return语句可以返回一个值,表示迭代得到的当前元素,yield break语句可以用来终止迭代,表示当前没有可被迭代的元素了。
以能“停住”的地方为分界线,编译器会为不同分区的语句按照功能逻辑生成一个个对应的代码块。yield语句就是这条分界线,想要代码“停住”,就不执行后面语句对应的代码块,想要代码恢复,就接着执行后面语句对应的代码块。而调度上下文的保存,是通过将需要保存的变量都定义成成员变量来实现的。
协程本体:C#的迭代器函数
C#中的迭代器方法其实就是一个协程,你可以使用yield来暂停,使用MoveNext()来继续执行。 当一个方法的返回值写成了IEnumerator类型,他就会自动被解析成迭代器方法*(后文直接称之为协程)*,你调用此方法的时候不会真的运行,而是会返回一个迭代器,需要用MoveNext()来真正的运行。
static void Main(string[] args)
{
IEnumerator it = Test();//仅仅返回一个指向Test的迭代器,不会真的执行。
Console.ReadKey();
it.MoveNext();//执行Test直到遇到第一个yield
System.Console.WriteLine(it.Current);//输出1
Console.ReadKey();
it.MoveNext();//执行Test直到遇到第二个yield
System.Console.WriteLine(it.Current);//输出2
Console.ReadKey();
it.MoveNext();//执行Test直到遇到第三个yield
System.Console.WriteLine(it.Current);//输出test3
Console.ReadKey();
}
static IEnumerator Test()
{
System.Console.WriteLine("第一次执行");
yield return 1;
System.Console.WriteLine("第二次执行");
yield return 2;
System.Console.WriteLine("第三次执行");
yield return "test3";
}- 执行
Test()不会运行函数,会直接返回一个IEnumerator - 调用
IEnumerator的MoveNext()成员,会执行协程直到遇到第一个yield return或者执行完毕。 - 调用
IEnumerator的Current成员,可以获得yield return后面接的返回值,该返回值可以是任何类型的对象
那么我们都知道yield return new WaitForSeconds(5)会停5秒,yield return null会停一帧,那只有写yield return null才会延迟到下一帧执行吗?
下面这段伪代码展示了StartCoroutine()的内部原理:
void MyStartCoroutine()
{
Update//每一帧都执行
{
MoveNext();
if(Current is WaitForSeconds)
{
Stop for x seconds;//具体逻辑可能就是我们写的一个简单的计时器,timer=time+=Time.dealtTime这样
}
else if(Current is WaitFixedUpdate)
{
Stop for fixedupdate;
}
else
//···
}
}所以我们如果在yield return后面写一个new GameObject();编译器也不会报错,这些奇奇怪怪的返回类型根本在StartCoroutine()里面没有对应的情况,所以它不会执行特定的操作,依然默认间隔一帧,然后执行MoveNext(),甚至写new GameObject();,确实会把Current的值设置为新实例化一个GameObject,场景里面真的会出现新的GameObject。所以写yield return null只是为了不发生奇奇怪怪的事,并非它是特定的延迟一帧。
IEnumerator中的yield return可以返回任意类型的对象,事实上它还有泛型版本IEnumerator<T>,泛型类型的迭代器中只能返回T类型的对象。Unity原生协程使用普通版本的IEnumerator。函数调用的本质是压栈,协程的唤醒也一样,调用
IEnumerator.MoveNext()时会把协程方法体压入当前的函数调用栈中执行,运行到yield return后再弹栈。
协程调度:MonoBehaviour生命周期中实现
- C#层调用
StartCoroutine方法,将IEnumerator对象(或者是用于创建IEnumerator对象的方法名字符串)传入C++层。 - 通过mono的反射功能,找到
IEnumerator上的moveNext、current两个方法,然后创建出一个对应的Coroutine对象,把两个方法传递给这个Coroutine对象。【创建好之后这个Coroutine对象会保存在MonoBehaviour一个成员变量List中,这样使得MonoBehaviour具备StopCoroutine功能,StopCoroutine能够找到对应Coroutine并停止。】 - 调用这个
Coroutine对象的Run方法 - 在
Coroutine.Run中,然后调用一次MoveNext。①如果MoveNext返回false,表示Coroutine执行结束,进入清理流程;②如果返回true,表示Coroutine执行到了一句yield return处,这时就需要调用invocation(m_Current).Invoke取出yield return返回的对象monoWait,再根据monoWait的具体类型(null、WaitForSeconds、WaitForFixedUpdate等),将Coroutine对象保存到DelayedCallManager的callback列表m_CallObjects中。 - 至此,
Coroutine在当前帧的执行即结束。 - 之后游戏运行过程中,游戏主循环的
PlayerLoop方法会在每帧的不同时间点以不同的modeMask调用DelayedCallManager.Update方法,Update方法中会遍历callback列表中的Coroutine对象,如果某个Coroutine对象的monoWait的执行条件满足,则将其从callback列表中取出,执行这个Coroutine对象的Run方法,回到之前的执行流程中。
可以模仿Unity自己实现一个简单的协程调度:
void YieldWaitForSeconds()
{
//定义一个移除列表,当一个协程执行完毕或者唤醒条件的类型改变时,应该从当前协程列表中移除。
List<WaitForSeconds> removeList = new List<WaitForSeconds>();
foreach(IEnumerator w in m_WaitForSeconds) //遍历所有唤醒条件为WaitForSeconds的协程
{
if(Time.time >= w.beginTime() + w.interval) //检查是否满足了唤醒条件
{
//尝试唤醒协程,如果唤醒失败,则证明协程已经执行完毕
if(it.MoveNext();)
{
//应用新的唤醒条件
if(!(it.Current is WaitForSeconds))
{
removeList.Add(it);
//在这里写一些代码,将it移到其它的协程队列里面去
}
}
else
{
removeList.Add(it);
}
}
}
m_WaitForSeconds.RemoveAll(removeList);
}Unity协程只是个伪异步,内部的死循环依旧会导致应用卡死。
Unity之外的协程
一个进程可以有多个线程,一个线程可以有多个协程
进程其实就是程序运行的实例:程序本身只是存储在外存上的冷冰冰的二进制流,计算机将这些二进制流读进内存并解析成指令和数据然后执行,程序便成为了进程。
每一个进程都独立拥有自己的指令和数据,所以称为资源分配的基本单位。其中数据又分布在内存的不同区域,一个运行中的进程所占有的内存大体可以分为四个区域:栈区、堆区、数据区、代码区。其中代码区存储指令,另外三个区存储数据。
线程是处理器调度和执行的基本单位,一个线程往往和一个函数调用栈绑定,一个进程有多个线程,每个线程拥有自己的函数调用栈,同时共用进程的堆区,数据区,代码区。操作系统会不停地在不同线程之间切换来营造出一个并行的效果,这个策略称为时间片轮转法。线程是并行的,多个线程可以同时运行,而多线程共享资源,所以访问全局变量的时候要加锁。
一切用户自己实现的,类似于线程的轮子,都可以称之为是协程。
协程有什么样的行为,完全由实现协程的程序员来决定。
参考与引用: