Unity中非常重要的异步功能就是协程。讲协程和线程的区别以及它们的用法的文章很多,讲Unity执行协程的底层原理的也很多,这篇文章旨在讨论二者中间的部分,就是协程是怎么来的,它为什么要有固定格式。
迭代器
很多人都知道协程是基于C#迭代器实现的,因此为了了解协程,我们先来了解迭代器。
如何实现foreach
首先假设我们没有foreach。当我们想遍历一个List时,就只能用for循环或while循环(二者本质都一样,以下都用for循环讲解)实现,循环变量为List的下标。这样一段代码就很自然地写出来了。
List<int> integerList = new List<int>() { 1, 2, 3 };
for (int i = 0; i < integerList.Count; i++)
{
Debug.Log(i);
}
接下来我们打算遍历一个Dictionary。Dictionary同样能获取Count,但是此时我们发现没有哪个循环变量能索引到Dictionary中的值,因为它靠的是key来索引而不是下标。也就是我们无法知道Dictionary的内部结构,就无法从外部一一遍历它的内容。那有什么方法解决这个问题呢?答案就是迭代器模式。
迭代器在外部暴露一个MoveNext的接口用于往下遍历,当遍历结束时返回false。然后提供Current属性获取当前遍历到的值。至于MoveNext是如何遍历内部结构的,外部是不用管的。C#的迭代器接口如下:
public interface IEnumerator
{
object Current { get; }
bool MoveNext();
void Reset();
}
而Dictionary实现了IEnumerable接口,它要求实现一个GetEnumerator函数返回迭代器供外部使用。这时候我们就能用循环实现迭代Dictionary了。
Dictionary<int, string> intStringDic = new Dictionary<int, string>();
for (var iter = intStringDic.GetEnumerator(); iter.MoveNext();)
{
Debug.Log(iter.Current.Value);
}
注意在迭代器模式下MoveNext实现了往下遍历和判断遍历是否结束两个功能,因此这里将MoveNext放在判断遍历是否结束的地方就好。
这样的迭代器模式写法非常常见,因此C#把这种写法用语法糖包了一层,变成了原生的语法支持,那就是foreach。
手写一个迭代器
接下来我们手写一个迭代器来实现foreach遍历输出周一到周日的英文缩写。
class WeekEnumerator : IEnumerator
{
string[] weekdays = { "Mon", "Tues", "Wed", "Thurs", "Fri", "Sat", "Sun" };
int index = -1;
public object Current => weekdays[index];
public bool MoveNext()
{
index++;
return index < weekdays.Length;
}
public void Reset()
{
index = -1;
}
}
上述代码继承了IEnumerator并实现了其声明的函数。注意index初始是-1,因为foreach会先执行一次MoveNext再获取Current。有迭代器还不够因为foreach调用的是GetEnumerator()方法,而上述代码被我写在Mono脚本里作为内部类,所以我们在Mono脚本里写一个GetEnumerator方法。
public IEnumerator GetEnumerator()
{
return new WeekEnumerator();
}
这时候我们可以在Start方法中这样调用foreach来实现迭代:
void Start()
{
foreach (string weekday in this) //in this。正确且有趣的写法。
{
Debug.Log(weekday);
}
}
一个注意点是foreach不要求必须实现IEnumerable,只需要有GetEnumerator方法就够了。
迭代器就此写完了。但是上一节讲过类似的迭代器非常常见,每一个我们都要实现IEnumerator,太麻烦了。C#也这么认为,因此它提供了一个简洁到不能再简洁的语法方便我们写迭代器。
yield return
上面的一大堆代码,通过C#提供的yield return语法可以这么实现:
public IEnumerator GetEnumerator()
{
yield return "Mon";
yield return "Tues";
yield return "Wed";
yield return "Thurs";
yield return "Fri";
yield return "Sat";
yield return "Sun";
}
需要非常注意的是yield return和return是两个相差很大的关键字,他们俩的关系就跟javascript和java的关系一样。
C#在遇到带有yield return关键字的函数时,会在底层包装成一个迭代器返回。该迭代器每次调用MoveNext的时候都会执行到下一个yield return,并将Current设置为yield return的值。
看上述的代码,有没有觉得似曾相识,没错,它就是一个协程函数。
协程状态机
经过上面的过程,我们对协程函数已经有了一个本质的了解。而Unity实现协程是通过内置的协程状态机实现的,它参与Unity的生命周期,并对协程进行状态管理。
当我们使用StartCoroutine时,Unity会把协程函数返回的迭代器放进协程状态机里,依据Current的不同在特定的时间再次调用迭代器。比如协程函数执行到“yield return new WaitForEndOfFrame();”,说明当前迭代器的Current是WaitForEndOfFrame类型的实例,那么该迭代器会在LateUpdate之后由协程状态机执行一次MoveNext()。我们可以用下面的代码模拟一个协程函数的执行。
using System.Collections;
using UnityEngine;
public class CoroutineSimulator : MonoBehaviour
{
void Start()
{
//开启协程
CoroutineSimulate(WaitLog());
}
//模拟等待
class MyWaitForSeconds
{
public bool KeepWaiting { get => _seconds > 0; }
private float _seconds;
public MyWaitForSeconds(float seconds)
{
_seconds = seconds;
}
public void Tick(float tick)
{
_seconds -= tick;
}
}
private IEnumerator cEnumerator;
//在Update中模拟管理协程
void Update()
{
if (cEnumerator != null)
{
if (cEnumerator.Current is MyWaitForSeconds)
{
MyWaitForSeconds myWait = cEnumerator.Current as MyWaitForSeconds;
if (myWait.KeepWaiting)
{
myWait.Tick(Time.deltaTime);
}
else
{
cEnumerator.MoveNext();
}
}
else
{
cEnumerator.MoveNext();
}
}
}
//模拟开启协程
void CoroutineSimulate(IEnumerator enumerator)
{
cEnumerator = enumerator;
}
//一般的协程函数
IEnumerator WaitLog()
{
Debug.Log("Current is " + Time.time);
yield return new MyWaitForSeconds(1);
Debug.Log("Wait 1s and Now is " + Time.time);
yield return new MyWaitForSeconds(2);
Debug.Log("Wait 2s and Now is " + Time.time);
}
}
执行结果如图
注意上述代码不代表Unity的协程状态机真实执行逻辑,它要复杂得多。MyWaitForSeconds也跟WaitForSeconds的实现不一致。这里只是简单实现类似功能而已。