目录
协程的定义及简介
协程的用途
定时器
将复杂程序分帧执行
等待某些条件完成后执行后续
异步加载资源
协程的原理
MonoBehaviour中每一帧的游戏循环
迭代器 IEnumerator 接口
具体执行过程
协程和线程的区别
协程的缺点
无法返回值
依赖于MonoBehaviour
维护困难与回调地狱
协程的定义及简介
先来看看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.
“协程类似于一个具有暂停执行并将控制权返回给Unity的功能的函数,但随后可以在下一帧继续从中断处继续执行。”
Unity中的协程是一种特殊的函数,它允许在执行过程中在特定点暂停,并在以后的时间点(如下一帧)从暂停的地方继续执行。这使得协程成为处理延时、等待和异步操作的强大工具。同时可以避免在主线程上进行长时间的阻塞操作。
协程的用途
定时器
当你需要延时执行一个方法或者是每隔一段时间就执行某项操作时,可以使用协程。
比如 yield return new WaitForSeconds(.1f),可以使得协程
以下是官方的案例:
游戏中的许多任务需要定期执行,最容易想到的方法是将任务包含在 Update 函数中。但是,通常情况下,每秒将多次调用该函数。不需要以这样的频繁程度重复任务时,可以将其放在协程中来进行定期更新,而不是每一帧都更新。这方面的一个示例可能是在附近有敌人时向玩家发出的警报。此代码可能如下所示:
bool 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(;;)
{
if (ProximityCheck())
{
// Perform some action here
}
yield return new WaitForSeconds(.1f);
}
}
将复杂程序分帧执行
如果一个复杂的函数对于一帧的性能需求很大,我们就可以通过yield return null将步骤拆除,从而将性能压力分摊开来,最终获取一个流畅的过程,这就是一个简单的应用。
举一个案例,如果某一时刻需要使用Update读取一个列表,这样一般需要一个循环去遍历列表,这样每帧的代码执行量就比较大,就可以将这样的执行放置到协程中来处理:
//伪代码
IEnumerator ShowImageFromUrl(string url)
{
Image image = null;
yield return LoadImageAsync(url, image); //异步加载图像,加载完成后唤醒协程
Show(image);
}
等待某些条件完成后执行后续
直到变量满足某些条件
yield return new WaitUntil(()=> counter <= 0);
直到函数返回真
yield return new WaitUntil(() => true);
直到某个协程开始执行后
yield return StartCoroutine(Test());
异步加载资源
资源加载指的是通过IO操作,将磁盘或服务器上的数据加载成内存中的对象。资源加载一般是一个比较耗时的操作,如果直接放在主线程中会导致游戏卡顿,通常会放到异步线程中去执行。
举个例子,当你需要从服务器上加载一个图片并显示给用户,你需要做两件事情:
- 通过IO操作从服务器上加载图片数据到内存中。
- 当加载完成后,将图片显示在屏幕上。
其中,2操作必须等待1操作执行完毕后才能开始执行。
//伪代码
IEnumerator ShowImageFromUrl(string url)
{
Image image = null;
yield return LoadImageAsync(url, image); //异步加载图像,加载完成后唤醒协程
Show(image);
}
以下是较为进阶的内容
协程的原理
基于迭代器:Unity协程的核心是C#的迭代器(Iterator)。迭代器允许函数在执行过程中暂停,并在未来某个时刻从上次暂停的地方继续执行。
yield
关键字:在协程中,yield
关键字用于指示暂停点。当协程运行到yield
语句时,它会返回一个值,然后暂停执行,直到Unity决定继续执行这个协程。
协程的队列:Unity在内部维护了一个协程的执行队列。当你调用StartCoroutine()
时,Unity会将协程加入这个队列。
MonoBehaviour中每一帧的游戏循环
协程的执行:在每一帧的游戏循环中,Unity会检查这些协程,并根据它们的yield
返回值决定是否执行协程中的下一段代码。
在每一帧的游戏循环中可以见下表:
翻阅Unity官方文档中介绍MonoBehaviour生命周期的部分,会发现有很多yield
阶段,在这些阶段中,Unity会检查MonoBehaviour中是否挂载了可以被唤醒的协程,如果有则唤醒它。
上面那段话的英文:
If a coroutine has yielded previously but is now due to resume then execution takes place during this part of the update.
“如果一个协程之前已经暂停了,但现在应该恢复执行,那么它的执行将在更新的这一部分进行。”
迭代器 IEnumerator
接口
在C#中,迭代器是通过实现 IEnumerator
接口来实现的。
IEnumerator接口:协程方法被编译成实现了IEnumerator
接口的类。这个接口包含:
MoveNext()
方法:这个方法用于将迭代器推进到下一个元素。在协程中,每次调用 MoveNext()
会执行到下一个 yield
语句或协程结束。
Current
属性:这个属性用于获取迭代器当前位置的元素。在协程中,它返回的是当前yield
语句返回的对象。例如,考虑以下几种情况:
-
yield return null;
:这种情况下,协程将在下一帧的Update
方法之前恢复执行。Current
属性返回的值是null
,这告诉Unity协程在下一帧继续执行。 -
yield return new WaitForSeconds(n);
:在这里,yield
返回的是一个WaitForSeconds
对象。Current
属性返回的是这个对象,Unity使用它来确定协程应该在等待指定的秒数后继续执行。
Unity根据这个返回值来决定协程的下一步执行时机和方式。
具体执行过程
有了以上的基础知识后,我们就可以总结出协程的具体执行过程:
游戏循环中的执行:Unity内部维护了一个活动协程的队列。当你通过StartCoroutine
启动一个协程时,该协程就会被添加到这个队列中。在每一帧的游戏循环中,Unity会遍历协程队列,调用每个协程的MoveNext()
方法。在协程中,每次调用 MoveNext()
会执行到下一个 yield
语句,此时,协程执行yield return
语句时,它会将控制权交还给Unity引擎,并暂停协程的执行。Unity随后决定何时再次恢复协程,这取决于yield return
后的对象。
- 如果
yield return null
,协程会在下一帧继续执行。 - 如果
yield return
了一个WaitForSeconds
对象,Unity会等待指定的时间再继续执行协程。 - 其他
yield return
对象(如WaitForEndOfFrame
,WaitForFixedUpdate
)会告诉Unity在特定的游戏循环阶段执行协程。
协程和线程的区别
是否并行执行:Unity的协程在主线程上执行。它们允许你将任务分解成多个步骤或等待,但这些步骤依然是在主线程上顺序执行的。
而线程是可以并行执行的,可以多线程并行执行。
轻量级:协程通常比线程更轻量级,因为它们不需要分配额外的操作系统资源。线程需要分配内存和操作系统线程资源,而协程只需要Unity的调度器来管理。
适用场景:协程适用于处理与游戏对象、Unity的生命周期和Unity API交互相关的任务,如延迟、动画序列、协作动作等。线程更适合处理需要并行执行的计算密集型任务,如物理模拟、复杂的算法计算等。
安全性:协程在Unity中的执行是协作的,可以安全地访问和修改Unity的游戏对象和组件。线程在多线程环境中需要额外的同步措施来确保数据的安全性,容易引入竞态条件和死锁。
调度和等待
- 协程:协程可以方便地使用
yield
语句等待一段时间或等待其他协程完成,这使得状态管理和任务调度更容易。 - 线程:线程通常需要使用诸如锁、信号量等机制来进行线程同步和等待操作,这可能会更复杂。
协程的缺点
无法返回值
使用协程的时候,协程本身无法像常规函数那样直接返回值。
如果你需要在异步操作完成后获取某个计算结果,需要寻找其他方式来传递这个结果。
-
使用回调函数:可以在协程结束时调用一个回调函数,并将结果作为参数传递给这个回调函数。
-
共享变量:协程可以修改外部定义的变量,这些变量的新值可以在协程结束后被读取。
-
事件系统:通过Unity的事件系统,可以在协程完成某项操作时触发事件,并传递相关数据。
依赖于MonoBehaviour
在Unity中,协程通常是在继承自MonoBehaviour
的类中启动和运行的。这是因为StartCoroutine
和StopCoroutine
方法是MonoBehaviour
类的一部分。
依赖于 MonoBehavior 有什么不好的地方?就是我们在大型的商业项目当中,通常会自己去开发一些游戏框架。我们知道 Unity 为我们提供了脚本的一个基类叫做 MonoBehavior ,但实际上我们在商业项目开发当中很有可能根本就不从MonoBehavior 继承。
对MonoBehaviour依赖的考量
-
灵活性和控制:
MonoBehaviour
是Unity提供的一个基础类,它与Unity的生命周期方法紧密绑定(如Start
,Update
)。在某些情况下,开发者可能需要更多的控制权和灵活性,比如实现自定义的生命周期管理或更细粒度的控制。 -
性能优化:
MonoBehaviour
会随Unity的生命周期进行调用,哪怕是空的生命周期方法也会占用调用时间。在大型项目中,这可能导致性能问题。自定义的游戏框架可以更精确地控制何时和如何执行这些方法。 -
代码组织和架构:大型游戏项目往往需要更复杂的代码组织和架构。直接依赖于
MonoBehaviour
可能限制了项目架构的设计,特别是在实现模块化和解耦方面。
虽然MonoBehaviour
提供了许多方便的特性,但在某些情况下,它可能不适合所有的需求,特别是当涉及到高度定制的游戏架构和性能优化时。
维护困难与回调地狱
协程的异步性质可能使得调试变得更加困难。协程中的错误可能不会立即显现,而是在协程的执行过程中的某个点才出现,这可能导致问题的根源不明显。
回调地狱
"回调地狱"(Callback Hell)是一个编程术语,通常用于描述在异步编程中,多层嵌套的回调函数造成的代码结构复杂、难以理解和维护的情况。
在Unity中,当你过度依赖协程来处理复杂的异步逻辑时,可能陷入类似"回调地狱"的困境。具体表现为:
-
深层嵌套:一个协程等待另一个协程完成,而那个协程又等待另一个,这样层层嵌套下去,导致代码结构混乱,难以追踪协程之间的逻辑关系。
-
逻辑分散:异步逻辑分散在多个协程中,使得理解整个流程变得困难,尤其是当不同协程散布在不同的
MonoBehaviour
脚本中时。 -
维护困难:每增加一个新的异步步骤或更改现有步骤,都可能需要重新考虑整个协程链的结构,这增加了维护和调试的难度。
-
错误处理:在嵌套的协程中正确地处理错误和异常可能变得复杂。如果在协程的某个环节发生了错误或异常,那么这个错误信息需要被有效地传递回到协程调用的起点,以便可以被正确处理。在Unity中,当你使用协程处理异步任务或复杂的逻辑流时,你可能会有多个协程相互调用或嵌套。例如,一个协程等待另一个协程完成某个任务。如果在这个过程中的任何一个环节发生了错误(如一个协程中的操作失败),这个错误信息应该被捕获并传递给原始调用者,这样才能对错误做出适当的响应。
为了避免这种“回调地狱”,可以采取以下措施:
- 逻辑分解:尽量将复杂的协程逻辑分解成更小、更可管理的部分。
- 避免过深嵌套:避免无必要的嵌套协程,尽可能使用更直接的逻辑流程。
- 状态机:对于复杂的异步流程,考虑使用状态机来管理状态转换,而不是深层嵌套的协程。
- 异步/等待模式:在支持C# 6.0及以上版本的Unity项目中,考虑使用异步/等待(async/await)模式,这是一种更现代的异步编程方法,可以提供更清晰的代码结构。