Unity中协程的理解
文章简述
本文章是我在使用游戏开发过程中对Unity中协程(协调程序,Coroutine)的体会,以及查看许多大佬和官方文档后的协程总结。
协程引入
在Unity中,一般情况下调用函数时,函数将直接运行到函数结束。这也就意味着在函数中发生的任何动作都将在一帧内发生。
那么问题来了,如果你需要执行一个需要时间流动才能推进的动作,比如,我们需要逐渐减少对象的 Alpha(不透明度)值,直至对象变得完全不可见。
void Fade()
{
for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
{
Color c = renderer.material.color;
c.a = alpha;
renderer.material.color = c;
}
}
显然,如果调用这个 Fade 函数,我们不会看到对象渐渐消失的效果,因为只有在 Fade 函数退出,即对象 Alpha 值完全降为0时,游戏画面才会渲染一次, alpha 值渐变的效果对玩家是不可见的。
为了使淡入淡出过程可见,必须通过一系列帧显示 Fade 函数执行过程中的中间值。
那么,很容易想到两种方法:向 Update 函数添加代码(此代码逐帧执行淡入淡出),或者使用协程。使用协程来执行此类任务通常会更方便。
协程简单介绍
协程就像一个函数,在调用协程后,函数正常执行,当遇到 yield 后,能够暂停执行并将控制权返还给 Unity,然后在下一帧继续执行。
在 C# 中,声明协程的方式如下:
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 * 行是暂停执行并随后在下一帧恢复的点。
调用协程的简单方法由以下两种:
使用协程名字符串调用协程:
void Update()
{
if (Input.GetKeyDown("f"))
{
StartCoroutine("Fade");
}
}
或者使用协程名()方法调用协程,这种方法可以调用有形参的协程:
void Update()
{
if (Input.GetKeyDown("f"))
{
StartCoroutine(Fade());
}
}
如果协程出现次数比较多而且名字很长或者不想重复为协程中的参数赋相同的值,我们可以使用 IEnumerator 类型的变量代替协程来启动它:
public class ExampleClass : MonoBehaviour
{
private IEnumerator coroutine;
void Start()
{
print("Starting " + Time.time);
// 使用IEnumerator类型变量开始协程。
coroutine = WaitAndPrint(2.0f);
StartCoroutine(coroutine);
print("Before WaitAndPrint Finishes " + Time.time);
}
// 每两秒输出一行内容。
private IEnumerator WaitAndPrint(float waitTime)
{
while (true)
{
yield return new WaitForSeconds(waitTime);
print("WaitAndPrint " + Time.time);
}
}
}
注意,在调用协程时,可以选择接收返回值,这个返回值的作用将在本文“协程的结束”部分说明:
Coroutine a = StartCoroutine(coroutineA());
协程用于游戏逻辑的优化
如果使用 yield return null 暂停协程,那么协程将 yield 后的帧恢复执行,但也可以使用 WaitForSeconds 来控制协程合适恢复执行。
可将这种方法作为一种有用的游戏优化手段。游戏中的许多任务需要定期执行,最容易想到的方法是将任务包含在 Update 函数中。但是,这将导致每帧执行这个任务,也就是说每秒可能会执行30次(帧率30)、60次(帧率60)甚至更多。
但通常我们不需要任务以这样高得到频率执行,一般每0.1秒执行一次,甚至更低的运行频率也不会让玩家感到异常。
不需要以这样的频繁程度重复任务时,可以将其放在协程中来进行定期更新,而不是每一帧都更新。这方面的一个示例可能是在附近有敌人时向玩家发出的警报。此代码可能如下所示:
void ProximityCheck()
{
for (int i = 0; i < enemies.Length; i++)
{
if (Vector3.Distance(transform.position, enemies[i].transform.position) < dangerDistance) {
return true;
}
}
return false;
}
如果有很多敌人,那么每帧都调用此函数可能会带来很大开销。但是,可以使用协程,每十分之一秒调用一次:
IEnumerator DoCheck()
{
for(;;)
{
ProximityCheck();
yield return new WaitForSeconds(.1f);
}
}
这将大大减少所进行的检查次数,而不会对游戏运行过程产生任何明显影响。
协程的中止
引入何时需要手动中止协程
对于一些简单协程,例如协程主体是有结束条件的for函数的协程,我们不需要关注如何结束这个协程,因为它会在for函数结束并运行到协程最后一行后结束。
但有些时候,我们可能不得不中止原本应当继续执行的协程,比如,一个会自动回血(每0.5秒恢复10点,持续3秒)的敌人正在回血,但玩家在这时给予了这个敌人致命一击,虽然还没有到达预定的3秒,但我们必须停止回血协程的运行,不然,你会看到一个尸体的血条在不断增长。啊,这场景真的诡异!
中止协程的错误方法
通过在 MonoBehaviour 实例上将 enabled 设置为 false 来禁用 MonoBehaviour 时,协程不会停止。
即将调用某协程的C#脚本组件 enabled 设置为 false ,或者在窗口中把此脚本组件左上角的对勾去掉,并不能中止这个协程的执行。因为协程在 StartCoroutine 时被注册到的GameObject上,它的生命周期受限于 GameObject 的生命周期,因此受 GameObject 的 active 属性的影响,而不受组件的 enable 属性的影响。
中止协程的方法
方法一:StopCoroutine。
此方法有三个重载:
public void StopCoroutine (string methodName);
public void StopCoroutine (IEnumerator routine);
public void StopCoroutine (Coroutine routine);
StopCoroutine 接受以下三个参数(用于指定要停止的协同程序)之一:
- 一个字符串函数,用于停止通过命名激活的协同程序。
- 之前用于创建该协同程序的 IEnumerator 变量。
- 一个 Coroutine 类型的变量,值为激活目标协程的StartCoroutine()函数的返回值。
注意:不要混合使用这三个参数。如果在StartCoroutine中使用字符串作为参数,则在 StopCoroutine 中使用该字符串。同样,如果在 StartCoroutine 中使用 IEnumerator 作为参数,则在 StopCoroutine 中使用该 IEnumerator 。最后,如果要使用 Coroutine ,则应该在调用 StartCoroutine 时接收 StartCoroutine 的返回值。
方法二:StopAllCoroutines
StopAllCoroutines 暂停的是当前脚本下的所有协程。注意,不是所有此 GameObject 上的协程,只会停止因此脚本调用而开始的协程。
方法三:gameObject.active = false
此方法可以停止该对象上全部协程的执行。
方法四:调用 Destroy(example)(其中 example 是一个 MonoBehaviour 实例)
此方法会立即触发 OnDisable,并会处理协程,从而有效地停止协程。
方法五:yield break
这个方法应该算协程自动停止。在协程中加入 if 进行条件判断,如果需要中断,则运行 yield break 。
协程中的 yield 指令
分为YieldInstruction和CustomYieldInstruction两种:
YieldInstruction
所有 yield 指令的基类。
null
在下一帧所有的Update()函数调用过之后执行。
break
结束协程。
WaitForSeconds
等待指定秒数,受Time.timeScale影响,实际暂停时间等于给定时间除以 Time.timeScale。当Time.timeScale = 0f 时,yield return new WaitForSecond(x) 将不会满足。
WaitForSecondsRealtime
等待指定秒数,不受Time.timeScale影响。
WaitForFixedUpdate
等待一个固定帧,即等待物理周期循环结束后执行
WaitForEndOfFrame
等待,直到该帧结束,在 Unity 渲染每一个摄像机和 GUI 之后,在屏幕上显示该帧之前。
可以用它将显示内容读取到纹理中,将其编码为图像文件(请参阅 Texture2D.ReadPixels 和 Texture2D.Texture2D),并将其存储在设备上。
从 Game 视图切换到 Scene 视图将导致 WaitForEndOfFrame 冻结。它只在应用程序切换回 Game 视图时才会继续。只有当应用程序在 Unity 编辑器中运行时才会发生这种情况。
StartCoroutine
等待一个新协程暂停。
WWW
等待一个加载完成,等待www的网络请求完成后,isDone=true后执行。
注意:此类已被标记为过时。WWW 已被 UnityWebRequest 取代。
DownloadHandler
WWW的新版本。
指令的执行时机
CustomYieldInstruction
基类,用于暂停协程的自定义 yield 指令。虽说是用于自定义指令的基类,Unity 也在其中提供了两个 yield 指令。
WaitWhile
暂停协程执行,直到提供的委托评估为 /false/。在 MonoBehaviour.Update 之后 MonoBehaviour.LateUpdate 之前执行。
public class WaitWhileExample : MonoBehaviour
{
public int frame;
void Start()
{
StartCoroutine(Example());
}
IEnumerator Example()
{
Debug.Log("等待王子/公主来救我...");
// 开始等待,并在 frame < 10 时,一直等待。
yield return new WaitWhile(() => frame < 10);
Debug.Log("终于我获救了!");
}
void Update()
{
if (frame <= 10)
{
Debug.Log("Frame: " + frame);
frame++;
}
}
}
WaitUntil
暂停协程执行,直到提供的委托评估为 /true/。在 MonoBehaviour.Update 之后 MonoBehaviour.LateUpdate 之前执行。
public class WaitUntilExample : MonoBehaviour
{
public int frame;
void Start()
{
StartCoroutine(Example());
}
IEnumerator Example()
{
Debug.Log("等待公主获救...");
// 开始等待,等到 frame >= 10 时,停止等待。
yield return new WaitUntil(() => frame >= 10);
Debug.Log("公主获救了!");
}
void Update()
{
if (frame <= 10)
{
Debug.Log("Frame: " + frame);
frame++;
}
}
}
自定义 yield 指令
CustomYieldInstruction 可使您实现自定义 yield 指令, 以暂停执行协同程序,直至发生事件为止。在后台,自定义 yield 指令只是另一个正在运行的协同程序。要实现该指令,应继承 CustomYieldInstruction 类,然后重写 keepWaiting 属性。要使 协同程序保持暂停,则返回 /true/。要使协同程序继续执行,则返回 /false/。在 MonoBehaviour.Update 后以及 MonoBehaviour.LateUpdate 前的每一帧均查询 keepWaiting 属性。
为获得更多控制并实现更复杂的 yield 指令,您可以直接继承 System.Collections.IEnumerator 类。在这种情况下,按照您实现 keepWaiting 属性的相同方式实现 MoveNext() 方法。此外,您还可以在 Current 属性中返回一个对象,在执行了 MoveNext() 方法后,Unity 的协同程序计划程序将处理该对象。因此,例如,如果 Current 返回了继承 IEnumerator 的另一个对象,则当前枚举器将暂停,直到返回的对象已完成为止。
协程和线程的区别
线程是利用多核达到真正的并行计算,缺点是会有锁、切换、等待的问题,而协程是非抢占式,需要用户自己释放使用权来切换到其他协程, 因此同一时间其实只有一个协程拥有运行权, 相当于单线程的能力。
协程是 C# 线程的替代品, 是 Unity 不使用线程的解决方案。
使用协程不用考虑同步和锁的问题。
多个协程可以同时运行,它们会根据各自的启动顺序来更新。
协程的优劣
性能:
在性能上和一般函数差不多,因为不是多线程。
协程的好处:
让原来要使用异步 + 回调方式写的非人类代码, 可以用看似同步的方式写出来。
能够分步做一个比较耗时的事情,如果需要大量的计算,将计算放到一个随时间进行的协程来处理,能分散计算压力。
协程的坏处:
协程本质是迭代器,且是基于unity生命周期的,大量开启协程会引起gc。
如果同时激活的协程较多,就可能会出现多个高开销的协程挤在同一帧执行导致的卡帧。
协程的性能优化
下面是一个在每帧结束时执行的协程的例子:
void Start()
{
StartCoroutine(OnEndOfFrame());
}
IEnumerator OnEndOfFrame()
{
yield return null;
while (true)
{
yield return new WaitForEndOfFrame();
}
}
上面的代码会导致 WaitForEndOfFrame 对象的每帧创建应该新的,旧的被丢弃,给 GC 增加负担。假设游戏运行在 60 fps,那么每秒钟 GC 就要回收 60 个 WaitForEndOfFrame 对象。
我们可以简单地通过复用一个全局的 WaitForEndOfFrame 对象来优化掉这个开销:
static WaitForEndOfFrame _endOfFrame = new WaitForEndOfFrame();
在合适的地方创建一个全局共享的 _endOfFrame 之后,只需要把上面的代码改为
...
yield return _endOfFrame;
...
上面的 GC 开销就被完全避免了,而逻辑上与优化前完全没有任何区别。
实际上,所有继承自 YieldInstruction 的用于挂起协程的指令类型,都可以使用全局缓存来避免不必要的 GC 负担。常见的有:
- WaitForSeconds
- WaitForFixedUpdate
- WaitForEndOfFrame
对于 WaitForSeconds 这种参数值不确定的对象,我们可以建立字典,或者使用局部变量保存。
...
static Dictionary<float, WaitForSeconds> _waitForSecondsYielders = new Dictionary<float, WaitForSeconds>();
...
其他注意点:
- IEnumerator 类型的方法不能带 ref 或者 out 型的参数,但可以带被传递的引用
- 在函数 Update 和 FixedUpdate 中不能使用 yield 语句,否则会报错, 但是可以启动协程
- 在一个协程中,StartCoroutine()和 yield return StartCoroutine()是不一样的。
前者仅仅是开始一个新的Coroutine,这个新的Coroutine和现有Coroutine并行执行。
后者是返回一个新的Coroutine,是一个中断指令,当这个新的Coroutine执行完毕后,才继承执行现有Coroutine。
本文参考的文章
从 各种点 理解Unity中的协程
Unity 协程运行时的监控和优化