调用函数时,函数将运行到完成状态,然后返回。这实际上意味着在函数中发生的任何动作都必须在单帧更新内发生;函数调用不能用于包含程序性动画或随时间推移的一系列事件。例如,假设需要逐渐减少对象的 Alpha(不透明度)值,直至对象变得完全不可见。
错误做法:函数将在单个帧更新中执行。这种情况下,永远不会看到中间值,对象会立即消失。
void Fade()
{Color c = renderer.material.color;for (float alpha = 1f; alpha >= 0; alpha -= 0.1f){c.a = alpha;renderer.material.color = c;}
}
可以通过向 Update 函数添加代码(此代码逐帧执行淡入淡出)来处理此类情况。但是,使用协程来执行此类任务通常会更方便。
ps:协程能做的Update都能做,那为什么我们需要协程呢? 答:使用协程,我们可以把一个跨越多帧的操作封装到一个方法内部,代码会更清晰。
正确做法:
协程就像一个函数,能够暂停执行并将控制权返还给 Unity,然后在下一帧继续执行。在 C# 中,声明协程的方式如下:
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 null;}
}
此协程本质上是一个用返回类型 IEnumerator 声明的函数,并在主体中的某个位置包含 yield return 语句。yield return null 行是暂停执行并随后在下一帧恢复的点。要将协程设置为运行状态,必须使用 StartCoroutine 函数:
void Update()
{if (Input.GetKeyDown("f")){StartCoroutine(Fade());}
}
如何使用
MonoBehaviour.StartCoroutine()
方法可以开启一个协程,这个协程会挂在该MonoBehaviour
下。
在MonoBehaviour
生命周期的Update
和LateUpdate
之间,会检查这个MonoBehaviour
下挂载的所有协程,并唤醒其中满足唤醒条件的协程。
要想使用协程,只需要以IEnumerator
为返回值,并且在函数体里面用yield return
语句来暂停协程并提交一个唤醒条件。然后使用StartCoroutine
来开启协程。
IEnumerator CoroutineA(int arg1, string arg2)
{Debug.Log($"协程A被开启了");yield return null;Debug.Log("刚刚协程被暂停了一帧");yield return new WaitForSeconds(1.0f);Debug.Log("刚刚协程被暂停了一秒");yield return StartCoroutine(CoroutineB(arg1, arg2));Debug.Log("CoroutineB运行结束后协程A才被唤醒");yield return new WaitForEndOfFrame();Debug.Log("在这一帧的最后,协程被唤醒");Debug.Log("协程A运行结束");
}
IEnumerator CoroutineB(int arg1, string arg2)
{Debug.Log($"协程B被开启了,可以传参数,arg1={arg1}, arg2={arg2}");yield return new WaitForSeconds(3.0f);Debug.Log("协程B运行结束");
}
默认情况下,协程将在执行 yield 后的帧上恢复,但也可以使用 WaitForSeconds 来引入时间延迟
应用
创建补间动画
补间动画指的是在给定若干个关键帧中插值来实现的动画。 如:给定两个时间点的Alpha值,可以插值出一个淡入淡出的动画效果。
创建补间动画更常用的做法是使用Dotween插件。
打字机效果
很多游戏的人物对话界面中,文字并不是一开始就显示在对话框中的,而是一个一个显示出来的。这种将文本一个一个字的显示出来的效果称之为打字机(Typewriter)。
使用协程,你可以每显示一个字符后等待若干时间,从而实现打字机效果。 b站上有一个基于协程的打字机效果的简单实现(https://www.bilibili.com/video/BV1cJ411Y7F6)
异步加载资源
资源加载指的是通过IO操作,将磁盘或服务器上的数据加载成内存中的对象。资源加载一般是一个比较耗时的操作,如果直接放在主线程中会导致游戏卡顿,通常会放到异步线程中去执行。
举个例子,当你需要从服务器上加载一个图片并显示给用户,你需要做两件事情:
- 通过IO操作从服务器上加载图片数据到内存中。
- 当加载完成后,将图片显示在屏幕上。
其中,2操作必须等待1操作执行完毕后才能开始执行。
在传统的互联网应用中,一般会使用回调函数来实现类似功能:
//伪代码
//提供给用户的接口
void ShowImageFromUrl(string url)
{LoadImageAsync(url, Callback); //开启一个异步线程来加载图像,加载完成后会自动调用回调函数
}
//回调函数
void Callback(Image image)
{Show(image);
}
我们也可以改写成协程的形式:
//伪代码
IEnumerator ShowImageFromUrl(string url)
{Image image = null;yield return LoadImageAsync(url, image); //异步加载图像,加载完成后唤醒协程Show(image);
}
使用协程来进行异步加载在Unity中是一个很常用的写法。
定时器操作
当你需要延时执行一个方法或者是每隔一段时间就执行某项操作时,可以使用协程。不过对于这种情况,也可以考虑写一个TickManager
来管理定时操作。Electricity项目中的定时器
注意事项
- 协程是挂在MonoBehaviour上的,必须要通过一个MonoBehaviour才能开启协程。
- MonoBehaviour被Disable的时候协程会继续执行,只有MonoBehaviour被销毁的时候协程才会被销毁。
- 协程看起来有点像是轻量级线程,但是本质上协程还是运行在主线程上的,协程不是线程,更不是进程,也不是什么所谓的轻量级的线程或者进程。“协程”更像是Unity专门为某类特殊函数定义的一种「 执行规则 」,它是使用状态机并配合迭代器来模拟“并发”的效果,是一种水平高超的障眼法。协程更类似于
Update()
方法,Unity会每一帧去检测协程需不需要被唤醒。一旦你在协程中执行了一个耗时操作,很可能会堵塞主线程。这里提供两个解决思路:(1) 在耗时算法的循环体中加入yield return null
来将算法分到很多帧里面执行;(2) 如果耗时操作里面没有使用Unity API,那么可以考虑在异步线程中执行耗时操作,完成后唤醒主线程中的协程。
协程的底层原理
unity利用了C#原本的yield(书签机制),当调用迭代器函数时,编译器在背后自动声明的一个嵌套可迭代类(实现了IEnumerable接口)中的枚举器的MoveNext函数,如果返回值为true,枚举器执行一直执行函数内的逻辑直到遇到yield return语句,此时给此处打一个标签(记录暂停位置)枚举器将current设置为当前遍历值,暂停当前函数执行,并返回给调用者current值,此时主线程重新回到调用者手中,对于unity来说是StartCorotine(),对于C#来讲,是回到Foreach的语句
此时Unity开始参与进来
IEnumerator的重点对于Unity看重的不是其遍历函数这一点,而是能暂停一个函数的执行跳转到另一个函数中,在迭代器内部的for循环我们可以删掉“获取上次数组返回的位置,movenext等逻辑”,将其替换为我们自己的功能代码,返回值我们也不需要返回数组元素,返回null就可以。这样就将迭代器的遍历数组功能变成一个副产物
由于DLL格式我们无法看到源代码,通过实验我们发现无论返回我们发现无论我们返回new GameObject、null、999这些未定义类型,unity的处理方式都是视而不见,代码依旧是下一帧调用movenext,和C#自带的没什么区别。Unity.StartCorroutine内部的伪代码应该是
用if语句判断返回值类型,如果返回了Waitforsecond等unity规定的几个返回值,就执行对应
if语句中的逻辑,而未定义的类的处理逻辑放入了else里,而对应的处理就是执行后什么也不做,等待下一帧继续Movenext()。stop for x second 可能是內部维护了一个timer
所以我们知道协程并不是多线程而是单线程,只是利用迭代器的暂停函数执行,并允许我们插入一些代码并执行的性质让我们意味其是一个多线程操作
我们也得到一些启发:可以利用完全不相干的事物的本质去实现另一个事物,迭代器遍历数组其本质是暂停函数执行其他函数的代码,利用这一点Unity做了协程
迭代器 = 状态机 + 枚举器 C# IEnumerator,IEnumerable ,Iterator-CSDN博客
迭代器函数返回值是IEnumerator是给调用者的,这样调用者才能通过迭代器函数使用MoveNext,获取Current。
协程分为两部分,协程与协程调度器:协程仅仅是一个能够中间暂停返回的函数,而协程调度是在MonoBehaviour的生命周期中实现的。 准确的说,Unity只实现了协程调度部分,而协程本身其实就是用了C#原生的”迭代器方法“
扩展Unity协程
扩展协程有两个思路,一种是自己另外写一套,可以有高度的定制性;另一种是对Unity现有的协程系统进行封装,可以兼容Unity现有的WaitForXXX。完整代码 CCoroutineMgr.cs
进程线程协程
进程是操作系统资源分配的基本单位 线程是处理器调度与执行的基本单位
1. 进程(Process)
- 定义:进程是运行中的程序实例,每一个进程都独立拥有自己的指令和数据,所以称为资源分配的基本单位。其中数据又分布在内存的不同区域。一个运行中的进程所占有的内存大体可以分为四个区域:栈区、堆区、数据区、代码区。其中代码区存储指令,另外三个区存储数据。
- 特点:
- 进程之间的资源相对独立,互不干扰。
- 因为进程拥有独立的内存空间,因此进程间的通信(IPC)相对复杂。
- 进程的创建和销毁以及上下文切换开销较大。
2. 线程(Thread)
- 定义:线程是进程中的执行单元,通常被称为轻量级进程。一个进程可以包含多个线程,这些线程共享进程的资源。线程是处理器调度和执行的基本单位,一个线程往往和一个函数调用栈绑定,一个进程有多个线程,每个线程拥有自己的函数调用栈,同时共用进程的堆区,数据区,代码区。操作系统会不停地在不同线程之间切换来营造出一个并行的效果,这个策略称为时间片轮转法。
- 特点:
- 线程之间的切换开销比进程小。
- 同一进程内的线程可以共享内存、文件描述符等资源。
- 多线程可以提高程序的并发性和性能,适合处理I/O密集型和计算密集型任务。
3. 协程(Coroutine)
- 定义: 一切用户自己实现的,类似于线程的轮子,都可以称之为是协程。C#中的迭代器方法是协程; Unity在迭代器的基础上扩展出来的协程模块是协程;
- 特点:
- 协程不是由操作系统调度,而是由程序控制,因此切换非常高效且开销小。
- 协程能够在执行过程中自己保存状态,可以在某个点暂停执行,并在稍后恢复。
- 通常用于需要高并发的场景,如网络编程和游戏开发。
4. 关系
-
层级关系:
- 进程是最上层的抽象,它包含一个或多个线程。
- 线程是进程内的执行单元,多个线程共享同一进程的资源。
- 协程则是一种更高层次的抽象,用于高效地管理线程内的多个执行任务。
-
应用场景:
- 进程适合需要严格资源隔离的任务,如大型服务或独立的应用程序。
- 线程适合 CPU 密集型或 I/O 密集型的任务,可以通过多线程提高并发性。
- 协程更适合需要大量并发但对性能要求极高的场景,尤其是在处理 I/O 操作时。
Reference
漫画秒懂 Unity 的协程与迭代器(Coroutine 与 Enumerator) - 知乎
Unity协程的原理与应用 - 知乎
Unity官方的异步加载场景的示例 倩女幽魂手游中的资源加载与更新方案
SC102课程