在前面两篇文章,我们了解了C#迭代器的基础知识,分析了延迟执行的本质,并实现了两个LINQ常用扩展,在这一篇里,我将解析Unity中的协程功能并实现一个自己的协程功能
前置知识回顾:
C#迭代器的实现和应用(一)——基础篇
C#迭代器的实现和应用(二)——延迟执行、流式处理与两个基本LINQ扩展的实现
我在github上存放了一份完整的项目,有需要的也可以研究查看,欢迎各路大佬朋友指正。
git链接
协程是Unity中非常好用和常用的功能之一,它利用C#的迭代器,在主线程中实现了一个并发的效果,虽然Unity的协程和一些其他语言中提供的协程在使用上存在比较大的差别(但又哪有两个完全一样的协程呢,lua和golang这两个语言原生的协程使用也存在区别)。
我们这里编写几个最简单的协程。
public class CoroutineTest : MonoBehaviour {
void Start () {
StartCoroutine(TestNull());
StartCoroutine(TestWaitUntil());
}
bool condition = false;
IEnumerator TestNull()
{
Debug.Log("[" + Time.time + "]\t" + "Test Null 1");
yield return null;
Debug.Log("[" + Time.time + "]\t" + "Test Null 2");
yield return TestWaitForSeconds();
}
IEnumerator TestWaitForSeconds()
{
Debug.Log("[" + Time.time + "]\t" + "TestWaitForSeconds : start WaitForSeconds");
yield return new WaitForSeconds(5);
condition = true;
Debug.Log("[" + Time.time + "]\t" + "TestWaitForSeconds : stop WaitForSeconds");
yield return 2;
}
IEnumerator TestWaitUntil()
{
Debug.Log("[" + Time.time + "]\t" + "start WaitUntil");
yield return new WaitUntil(() => condition);
Debug.Log("[" + Time.time + "]\t" + "stop WaitUntil");
}
TestNull
方法中使用yield return null
中断了一次,然后嵌套了TestWaitForSeconds
方法TestWaitForSeconds
方法中创建了一个WaitForSeconds
的协程,等待五秒,完成之后再将成员变量condition
的值变为true,最后再使用yield return 2
返回一个2TestWaitUntil
方法创建了一个WaitUntil
协程,当condition
为true时进入下一步Start
方法是Unity自带的“魔法函数”,在脚本初始化完成后被调用,在这个方法中使用StartCoroutine
方法开启了两个协程。很容易看到,WaitForSeconds启动后,等待了五秒(注意start WaitForSeconds和stop WaitForSeconds的时间差异),之后condition
被置为true,WaitUntil也测试通过了。但这个过程并不会导致程序卡住,如果你在update
方法中同样进行log,会发现update
方法中的log和协程中的log是同步的,整体的表现得就像是异步进行了这些操作一样——但这些操作的写法又是同步的,甚至连运行都是在主线程进行的。
如果对Unity的协程不熟悉,想了解关于Unity协程的更具体内容和使用方法,可以查看官方手册:协程;
应该几乎所有的unity开发者都会使用unity的协程,但它是如何实现的呢?
其实很简单——两个关键知识点:C#迭代器和游戏循环
下面我将逐个拆解实现原理。
因为这是关于C#迭代器的系列文章,所以其实在前两篇文章中我就已经对C#的迭代器特点及应用作出了一些讲解,而Unity协程的实现就依赖了在前两篇文章中所讲的知识:延迟处理和yiled 简化迭代器编写。
Unity的协程有两种形式,一种是以IEnumerator
为返回值的方法,另一种是继承了IEnumerator
的类型,IEnumerator
这个接口我们已经很很熟悉了,说白了,Unity的“协程”本质上就是一个迭代器。
游戏循环是几乎所有游戏都存在的核心组件,与普通应用不同的是,在整个游戏过程中,游戏循环是重中之重。
在Robert Nystrom写的《游戏编程模式》一书中就专门有一个讲游戏循环的章节,里面有这样一段话:
假如有哪个模式是本书最无法山羊的,那么非游戏循环模式莫属。游戏循环模式是游戏编程模式中的精髓。几乎所有的游戏中都包含着它,无一雷同,相比而言那些非游戏程序中却难见它的身影。
同样,在Jason Gregory著的《游戏引擎架构》一书中,也专门留出了篇幅对游戏循环进行讲解。
不过吹了这么多,游戏循环到底是个什么东西?
简单来说,就是在游戏启动后的Main函数中运行的一个死循环,每一帧都进行一定时间的暂停,防止进程卡死,类似于下面这样:
while (true)
{
ProcessInput();//检测输入
Update();//更新画面
Render();//渲染画面
Thread.Sleep(17);
}
而我们所有的代码,都在这个循环中反复运行。
机智的小伙伴这时候一定意识到了,这个Update
其实就是Unity里MonoBehaviour
类中的Update
。
当然,实际的游戏循环要更加复杂。下面这是Unity的MonoBehaviour
生命周期图中的一部分 (完整周期点击查看),其中Input events
、Game logic
、Scene rendering
、Gizmo rendering
等等都是属于游戏循环中的一部分。
如果对游戏循环感兴趣,推荐阅读我前面提到过的两本书:
《游戏编程模式》《游戏引擎架构》
那么这个游戏循环跟协程的实现有什么关系呢?
我们把游戏循环和迭代器合在一起看:
yield
来简化封装操作有了这些条件,我们就可以做到使用游戏循环来驱动迭代器 , 使用yield来编写函数以自动封装操作,也可以直接编写具体的迭代器,通过传入操作来筛选迭代器的结束条件。
理论到前面为止,下面我们可以开始实现自己的协程了——如果还没有消化,记得再返回去了解一下迭代器的功能特点哦!
关于协程是如何运行的,在前面已经解析了,那么我们来设计一下协程的具体运行方式。
IEnumerator
接口为核心,WaitUntil
和WaitForSeconds
两个类均实现接口IEnumerator
作为协程进行调用;Coroutine
和CorotineEngine
是我们的核心工具类, 其中Coroutine
是协程的包装,CorotineEngine
是驱动Coroutine
的工具类;CorotineEngine
中包含一个StartCoroutine
方法,传入一个迭代器,在这个方法中将使用Coroutine
对迭代器进行包装,创建一个Corotutine
类型的变量并保存在coroutines
中;每一次CoroutineUpdate
方法被调用,都将驱动Coroutine
运行;Coroutine
中一个返回值为bool的MoveNext
成员函数,当MoveNext
的返回值为false时,说明这个协程已经运行结束。下面是简单的类图
Coroutine
和CorotineEngine
,CorotineEngine
接近于Unity中MonoBehaviour
的协程部分,它包含开始协程运行的StartCoroutine
函数,并返回一个Coroutine
实例;CorotineEngine
的Update
被调用时,所有coroutines
中的包装器都会被驱动运行一次MoveNext
,当MoveNext
函数的返回值为false,那么说明这个协程已经运行结束,这个协程将会被从列表中移除;Coroutine
类通过传入一个迭代器进行创建,每一个Coroutine
实例都包含一个用于保存当前运行的迭代器的栈,在迭代器被驱动运行时有以下几种情况:
MoveNext
返回false
MoveNext
返回false
,那么表明此迭代器已迭代结束,此时将这个迭代器从栈中弹出,再返回第一个判断MoveNext
返回true
,并且Current
也是一个迭代器,那么将把这个迭代器压入栈,再返回第一个判断,并且此协程的MoveNext
返回true
MoveNext
返回true
,并且Current
不是一个迭代器,那么此协程的MoveNext
返回true
WaitUntil
类MoveNext
时运行一次这个lamda表达式,需要注意的是,因为MoveNext为false时表示运行结束,所以要对并返回这个值进行取反。WaitForSeconds
类DateTime
进行计时;WaitUntil
思路很简单,在创建时传入一个以秒为单位的时间,同时使用DateTime.Now.AddSeconds
来计算结束时间,之后在MoveNext
中将当前时间与结束时间对比,如果当前时间超过了结束时间,那么MoveNext返回false,表示迭代结束;解析到此结束,后面就是代码啦。
为了方便查看时间,设计了Debug类用于Log
class Debug
{
public static void Log(string source, string format = "", params object[] args)
{
var f = string.Format("[{0}]\t[{1}] : {2}", DateTime.Now.ToString("HH:mm:ss fff"), source, format);
Console.WriteLine(f, args);
}
}
我们的简单引擎,有用于保存协程的List、用于更新的Update方法、用于驱动协程的UpdateCoroutine方法、添加协程的StartCoroutine方法,简单来说,可以把这个类看成MonoBehaviour中关于协程的那一部分。
class CoroutineEngine
{
public CoroutineEngine()
{
Debug.Log("CoroutineEngine", "setup");
}
List<Coroutine> coroutines = new List<Coroutine>();
public void Update()
{
//Debug.Log("CoroutineEngine", "Update");
}
public void CoroutineUpdate()
{
for (int i = 0; i < coroutines.Count; i++)
{
if (coroutines[i].MoveNext())
{
//Debug.Log("CoroutineEngine", "CoroutineUpdate");
}
else
{
Debug.Log("CoroutineEngine", "remove corotine : " + coroutines[i].Name);
coroutines.RemoveAt(i);
i--;
}
}
}
public void StartCoroutine(IEnumerator coroutine)
{
coroutines.Add(new Coroutine(coroutine));
}
}
协程类,每一个被创建的协程都会使用一个Coroutine包装起来,只要反复调用Coroutine中的MoveNext方法就可以驱动迭代器进行迭代,这个类的MoveNext逻辑相对有一点复杂,我使用递归方式来实现,如果一下不理解,可以再回到前面查看它的运行图。
class Coroutine
{
public string Name { get; private set; }
public Coroutine(IEnumerator enumerator)
{
Name = enumerator.GetType().Name;
enumerators.Push(enumerator);
}
Stack<IEnumerator> enumerators = new Stack<IEnumerator>();
public bool MoveNext()
{
if (enumerators.Count == 0) return false;
return MoveNext(enumerators.Peek());
}
private bool MoveNext(IEnumerator it)
{
if(it.MoveNext())
{
if(it.Current is IEnumerator)
{
var next = it.Current as IEnumerator;
enumerators.Push(next);
MoveNext(next);
}
return true;
}
else
{
enumerators.Pop();
if (enumerators.Count == 0) return false;
return false || MoveNext(enumerators.Peek());
}
}
}
class WaitForSeconds : IEnumerator
{
private long targetTicks;
public WaitForSeconds(float seconds)
{
this.targetTicks = DateTime.Now.AddSeconds(seconds).Ticks;
}
public object Current => null;
public bool MoveNext()
{
return this.targetTicks > DateTime.Now.Ticks;
}
public void Reset()
{
}
}
class WaitUntil : IEnumerator
{
Func<bool> condition;
public WaitUntil(Func<bool> condition)
{
if (null == condition) throw new ArgumentNullException("WaitUntil condition is null");
this.condition = condition;
}
public object Current => null;
public bool MoveNext()
{
return !condition();
}
public void Reset()
{
}
}
class Program
{
static void Main(string[] args)
{
var engine = new CoroutineEngine();
engine.StartCoroutine(TestNull());
engine.StartCoroutine(TestWaitUntil());
while (true)//主循环
{
engine.Update();
engine.CoroutineUpdate();
Thread.Sleep(33);
}
}
static bool condition = false;
static IEnumerator TestNull()
{
Debug.Log("Test Null", "1");
yield return null;
Debug.Log("Test Null", "2");
yield return TestWaitForSeconds();
}
static IEnumerator TestWaitForSeconds()
{
Debug.Log("TestWaitForSeconds", "start WaitForSeconds");
yield return new WaitForSeconds(5);
condition = true;
Debug.Log("TestWaitForSeconds", "stop WaitForSeconds");
yield return 2;
}
static IEnumerator TestWaitUntil()
{
Debug.Log("TestWaitUntil", "start WaitUntil");
yield return new WaitUntil(() => condition);
Debug.Log("TestWaitUntil", "stop WaitUntil");
}
}
以上就是关于C#迭代器的扩展的所有内容了,这一系列文章写了我很久,主要还是因为准备不足,也对工作量没有概念,好在有番茄工作法的帮助,一点一点完成了这三篇文章,以后还是要继续努力。