#前述
Unity中的函数类型可以分为 :
- 事件响应函数 ,比如 IPointerEnter() , IPointerExit,IDragHandler,IBeginDragHandler,IEndDragHandler等等 , 它们的执行依靠于事件的触发 ;
- Unity预留的自执行函数 , 比如Awake() , Start() ,Update() , LateUpdate() , FixedUdpate()等等
- 用户自定义的函数
前两类函数的调用都是帧相关的 。 比如事件处理函数 ,依靠的是帧流程中的事件监测 , 从而被触发执行 。Unity预留的自执行函数是由Unity在每帧中自动调用的 ,比如Update()和LateUpdate()每帧调用一次,FixedUpdate()根据一定的规则在每帧中调用>=0次 ,而Awake(),Start()则只在第一帧调用一次 ;而用户自定义的函数 ,只能被前两类函数所调用 ,所以我们可以把其看作前两类函数的计算任务 ,只不过被封装成了函数而已 ,所以我们可以把第三类函数也看成是帧相关的 。
#协程
通常意义上 ,帧相关的函数在一帧中被触发执行 , 那么在它返回前 ,它的计算任务会被全部执行完毕 ;所以 ,不能在函数中试图去定义一段连续性的动画变换 。例如 ,考虑下列的示例代码,其尝试把一个游戏对象的透明度值——通过循环——逐渐递减——直至完全不可见 :
void Fade()
{
for( float ft = 1f ; ft >= 0 ; ft -= 0.1f )
{
Color c = renderer.material.color ;
c.a = ft ;
renderer.material.color = c ;
}
最终的效果会如何 ? Fade函数产生的效果并不会如你所愿 ! 为什么呢 ? 这就要考虑帧处理的细节了 。我们知道 , Rendering部分位于帧处理的末尾 , 它渲染的是帧处理前部分的最终计算结果 ,注意是最终计算结果 !对于Fade函数中的计算任务来说 ,最终计算结果为 : c.a = 0 => renderer.material.color = c => renderer.material.color.a = 0 ; 所以帧处理的前面计算处理最终结果 ,那么后部分的Rendering流程便将最终结果渲染了出来 ,这就是为何你不会看到渐变效果的原因 ——试图在一帧中处理全部的渐变计算 。
如果你想要看到渐变的效果 ,那么你需要把alpha值从1到0的变化 ,贯穿到一系列的帧中 ,在每一帧中进行0.1f的单步递减 ,从而也在每一帧中把这种单步递减的结果渲染出来 ,所以你就可以看到渐变的效果了 。
要实现这种每帧的单步递减 ,你可以把相应的计算任务封装成函数 ,并且在Update()里面调用 ; 不过更便捷的方式是将这种任务通过协程来运行 ;
什么是协程呢 ? 协程很像函数 ,但是它有一个独一无二的特性 ,就是它可以在某一帧暂停执行 , 并且将控制转移给Unity ,但是之后却可以在接下来的帧中 ,在停下来的位置继续执行 , 也就是说协程的生命周期使跨帧的,而不像普通的函数一般——生命周期只维持在一帧 ;在C#里 ,协程(coroutine)的声明形式如下 :
IEnumerator Fade()
{
for(float ft=1f ; ft>=0;ft-=0.1f)
{
Color c = renderer.material.color ;
c.a = ft ;
renderer.material.color = c ;
yield return null ;
}
}
本质上 ,这就是一个函数 ,它的返回类型为IEnumerator , 并且在函数体某处会出现声明语句yield return ; yield return null 语句是这段协程代码的关键点 ,它意味着在当前帧——代码将会被暂停执行,并且将会在下一帧从暂停的地方恢复执行 !为了使协程运行起来 ,你需要使用StartCoroutine函数 :
void Update()
{
if( Input.GetKeyDown("f") )
{
StartCoroutine("Fade");
}
}
运行上述代码 ,你会发现Fade函数里的For循环体在协程的生命周期里始终维持着计数变量ft正确的值 。事实上 ,在yield之间 ,任何变量或参数都会始终保持着它们正确的值
一般情况下 , 通过yields return null 就可以将协程在当前帧暂停 ,并且在下一帧继续执行 。不过 ,我们依然可以通过WaitForSeconds来设置一个暂停时长 :
IEnumerator Fade()
{
Color c = renderer.material.color ;
for(float alpha = 1f ; alpha >=0 ; alpha -= 0.1f)
{
c.a = alpha ;
renderer.material.color = c ;
yield return new WaitForSeconds( .1f );
}
}
在上述代码中 , 通过设置暂停时长 ,可以使渐变效果以一种比连续帧更离散的方式在一段时间内发生 ,同时,这也是一种十分有用的程序优化方式 ;游戏中的许多计算任务需要定期去执行 ,我们可以通过把计算任务放在Update()里去实现这种定期性 。毫无疑问——Update()每秒会被执行多次 。所以当我们的计算任务不需要执行的这么频繁时 , 我们可以将其置于协程里 ,以便定期去执行 ,而不是频繁的每帧去执行 。例如,我们的游戏中通常有这么一个功能 ,当敌人靠近时,便向玩家发出警告 ,实现其的代码通常是这样的 :
// 在Update()中每帧调用
bool ProximityCheck()
{
for(int i = 0;i < enemies.Length ; i++)
{
if( Vector3.Distance(transform.position , enimes[i].transform.position ) < dangerDistance )
{
return true ;
}
}
return false ;
}
如果敌人很多的话 ,每帧都要执行上述的for循环体 ,这将带来显著的性能开销 ;所以 , 我们可以使用协程每 0.1s 调用上述的代码一次 :
IEnumerator DoCheck()
{
for(;;)
{
if( ProxImityCheck() )
{
// Perform some action here
}
yield return new WaitForSeconds( .1f ) ;
}
}
这将大大减少距离检测的次数 , 同时也不会给游戏的运行表现带来明显的影响 ;
#协程的运行机制
协程之所以能够在满足yield return的条件后 ,紧接着 yield return语句继续执行 ,并且能够维持局部变量的值不变 ,是因为C#编译器自动为协程生成了一个类实例 ,这种类是专门为记录枚举器(IEnumerator)的状态而设计的 ,所以这个类实例——或者说对象追踪记录了协程生命周期内的状态信息(从哪里结束等)和对局部变量的引用 ; 正因如此 ,我们才可以在协程的整个生命周期里——跨yield return而维持局部变量不被销毁 ,才可以使协程从哪里结束——满足yield return条件后——又从哪里开始 。
也正因为如此 ,启动协程造成的内存压力 = 固定内存消耗 + 协程内部的局部变量 ;
那么这个对象是从何时被实例化出来的呢 ?StartCoroutine启动协程时便会自动构造生成这个对象 。
StartCoroutine开启了这个协程 ,同时构造并唤起了这个追踪对象 ;然后这个对象全程跟踪了协程生命周期内第一段代码的执行——即从协程函数体开头到第一次执行yield return之间的代码 ,记录了这期间协程的状态信息 ;其余的代码执行——从第一次yield return之后 ,到协程执行结束 —— 都是受到Unity的DelayedCallManager的管理 ,也就是说DelayedCallManager对这部分代码进行调度执行 ,联系到Unity的脚本生命周期,可以知晓——DelayedCallManager也是处于脚本生命周期的主循环中 .
DelayedCallManager的对这部分代码的调度执行也要依靠那个对象 。第一次yield return的条件满足之后 ,DelayedCallManager重新唤起这个追踪对象 。直到代码又执行到下一个yield return ,然后重复上述步骤 。
由于经常在一个协程中唤起另外一个协程 ,所以这使它们的运行总开销分为两处产生