文章目录
进程、线程、协程的关系
线程和协程都是进程的子集,一个进程可以有多个协程,一线程也可以有多个协程,进程基于程序主体。
IO密集型一般使用多线程或多进程。CPU密集型一般使用多进程。强调非阻塞异步并发的一般都用协程。
进程、线程、协程关系图
进程
进程是系统分配资源和调度资源的一个独立单位,每个进程都有自己的独立内存空间,不同进程间可以进行进程间通信。进程重量级比较大,占据独立内存,上下文进程间的切换开销(栈寄存器、虚拟内存、文件句柄)比较大,但相对稳定安全。进程的上级为操作系统,有自己固定的堆栈。
进程间通信(IPC)
进程间通信通常有以下几种方式:
- 管道(Pipe):管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。其本质是内核中固定大小的缓冲区。
- 命名管道(Named Pipes):“命名管道”又名“命名管线”(Named Pipes),命名管道支持可靠的、单向或双向的数据通信。不同于匿名管道的是:命名管道可以在不相关的进程之间和不同计算机之间使用,服务器建立命名管道时给它指定一个名字,任何进程都可以通过该名字打开管道的另一端,根据给定的权限和服务器进程通信。
- 消息队列(MQ,Message Quene):消息队列用于在进程间通信的过程中将消息按照队列存储起来,常见的MQ有ActiveMQ、RocketMQ、RabbitMQ、Kafka等。
- 信号量(Semaphore):有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量。
- 共享内存(Share Memory):共享内存是三个IPC机制中的一个。它允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在进行的进程之间传递数据的一种非常有效的方式。
- 套接字(Socket):就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。
对于游戏开发者来说,最为常用的无疑是Socket,这是长连接网络游戏的核心。
进程状态图
线程
线程也被称为轻量级进程,是操作系统调度(CPU调度)执行的最小单位,是进程的子集。
线程本身基本不拥有资源,而是访问隶属于进程的资源,一个进程拥有至少一个或多个线程,线程间共享进程的地址空间。
由于线程是阻塞式的,如果想要同步执行IO,每个IO都必须开启一个新线程,多线程开销较大,适合多任务处理,进程崩溃不影响其他进程,而线程只是一个进程的不同执行路线。
线程有自己的堆栈,却没有单独的地址空间,进程死就等于所有线程死,所以多进程要比多线程健壮。但在进程切换时,消耗资源较大,效率较差。
线程是并发的,且是阻塞式同步的,一旦资源死锁,线程将陷入混乱。在同步线程的执行过程中,线程的执行切换是由CPU轮转时间片的分配来决定的。
线程状态图
线程开销
线程开销包括以下几个方面:
- 线程内核对象(Thread kernel object):包含一组对线程进行描述的属性。该数据结构中还包括线程上下文(Thread context)。上下文是一个内存块,其中包含了CPU的寄存器集合。
- 线程环境块(Thread environment block):用户模式中分配和初始化的一个内存块,应用程序代码能够快速访问的地址空间。
- 用户模式栈:用户存储传给方法的局部变量和实参,此外还包含一个地址,指出当前方法返回时,线程接着应该从什么地方开始执行。默认情况下,windows为每个线程的用户模式栈分配1MB的内存。(对于本地程序而言,这只是一个虚拟地址,并没有对应真正的物理内存空间。但是对于CLR线程,CLR强制分配1MB的屋里内存空间给线程)。
- 内核模式栈:用于存储内核模式的方法的局部变量和实参,同时也包含方法的返回地址。
- DLL线程加载和线程分离通知:任何时候在进程中创建一个线程,都会调用该进程中加载的所有DLL的DllMain方法,并向该方法传递一个DLL_THREAD_ATTACH标志。类似的,任何时候一个线程终止,都会调用该进程中所有的DLL的DllMain方法,并向该方法传递一个DLL_THREAD_DETACH标志。(托管的DLL不会接收到这两个通知)。
上下文切换
上下文切换过程如下:
- 将CPU寄存器中的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中。
- 从现有线程集合中选出一个线程供调度(这个线程要切换到的线程)。如果该线程由另一个进程拥有,Windows在开始执行代码或者接触任何数据之前,还必须切换CPU“看见”的虚拟地址空间。
- 将所选上下文结构中的值加载到CPU的寄存器中。
何时使用线程
- 需要大量计算的任务:
- 物理模拟:物理模拟可能需要处理大量的碰撞检测、力学计算等,这些计算往往非常耗时,可以通过多线程来分担主线程的压力。
- AI计算:游戏中的AI系统需要进行复杂的决策和路径规划,这些计算也可以通过多线程来加速。
- 复杂的数据处理:如大规模数据分析、图像处理等,这些任务同样适合放在多线程中执行。
- 异步加载资源:
游戏中经常需要加载大量的资源,如纹理、模型、声音等。使用多线程可以在后台加载这些资源,避免阻塞主线程,从而保持游戏的流畅性。 - 实时网络通信:
在多人在线游戏中,网络通信是必不可少的。多线程可以用于处理网络通信,允许游戏同时接收和发送数据,确保玩家之间的实时互动。 - I/O操作:
游戏中的文件读写、网络请求等I/O操作也适合使用多线程来处理,因为这些操作通常比较耗时,且不需要占用CPU的密集计算能力。
协程
协程是比线程更轻量级的存在,协程不由操作系统内核所管理,而是完全由程序所控制(也就是在用户态执行)。
协程的好处是性能大幅提升,不会像线程切换那样消耗资源。同一时间只能执行某个协程,开辟多个协程开销不大。适合对任务进行分时处理。
协程有自己的寄存器和上下文栈。协程调度切换时,将寄存器和上下文栈保存到其他地方,并在协程切换回来时恢复之前保存的寄存器和上下文栈。由于直接对栈进行操作,基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文切换非常快。
一个线程可以有多个协程,一个进程也可以单独拥有多个协程。线程和进程都是同步机制,而协程是异步机制,无需阻塞。协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用时的状态。多协程间对CPU的使用是依次进行的,每次只有一个协程工作,而其他协程处于休眠状态。
实际上多个协程是在一个线程中的,只不过每个协程对CPU进行分时。协程可以访问和使用Unity的所有方法和Component。函数(子程序)的调用是通过栈实现的,一个线程就是执行一个函数,函数调用总是一个入口,一个返回,调用顺序是明确的,而协程在函数内部是可以中断的,然后转而执行其他函数,在适当的时候再返回来继续执行。函数(子程序)的切换不是由线程切换,而是程序自身控制,因此没有线程切换开销。和多线程相比,线程越多,协程的性能优势就越明显,切协程因为依次执行,不存在线程安全问题,变量访问不会冲突,共享资源也无需加锁,只需要判断状态即可,所以执行效率比线程高很多。
协程的语法
yield:暂停,通常用 yield return null 来暂停协程。
StartCoroutine(方法名()):恢复执行。
WaitForSeconds:引入时间延迟,默认情况下,协程将在 yield 后的帧上恢复。使用 yield return new WaitForSecond(.1f) 后,将延迟0.1秒后执行协程。
关于协程的使用,其实有点像 ES7 的 Async / await 以用来改变执行顺序,由于不需要切换上下文,所以执行效率相对较高。比如,执行一个网络请求,用 yield return WWW; 就能实现异步回调,而U3D的生命周期本身就提供了很多可以使用的 yield节点,这样就能方便的使用异步了。
例如:
IEnumerator Start()
{
string url = "https://xxxx.xxxx.xxxx/xxxx.jpg";
WWW www = new WWW(url);
yield return WWW;
renderer.material.mainTexture = www.texture;
}
当程序执行到 yield return WWW; 时就不会直接往下执行了,而是等到网络请求结束后的第一帧的WWW协程节点触发时才继续执行,也就是说,当网络请求结束后,纹理才会被替换。
协程的内核:迭代器【重点】
从程序的角度讲,协程的核心就是迭代器。想要定义一个协程方法有两个因素,第一:方法的返回值为 IEnumerator 。第二,方法中有 yield关键字。当代码满足以上两个条件时,此方法的执行就具有了迭代器的特质,其核心就是 MoveNext方法。方法内的内容将会被分成两部分:yield 之前的代码和 yield 之后的代码。yield之前的代码会在第一次执行MoveNext时执行, yield之后的代码会在第二次执行MoveNext方法时执行。而在Unity中,MoveNext的执行时机是以帧为单位的,无论你是设置了延迟时间,还是通过按钮调用MoveNext,亦或是根本没有设置执行条件,Unity都会在每一帧的生命周期中判断当前帧是否满足当前协程所定义的条件,一旦满足,当前帧就会抽出CPU时间执行你所定义的协程迭代器的MoveNext。注意,只要方法中有yield语句,那么方法的返回值就必须是 IEnumerator ,不然无法通过编译。
前面的讲解可能会比较晦涩,我们可以结合代码来看:
private IEnumerator Fun1()
{
for (int i = 0; i < 5; i++)
{
print(i + "---" + Time.frameCount);
yield return null;
}
}
当我们定义一个这样的方法,我们的协程就诞生了。那么如何访问这个方法呢?你可能会说,xxx.Fun1(); 不就行了吗?实际上,如果这样调用,方法将不会被执行。那到底怎么调用呢?请看下面的代码:
private IEnumerator iterator;
private void OnGUI()
{
if (GUILayout.Button("启动"))
{
// Fun1(); 直接这样调用是没有用的,需要先获取迭代器
// 获取迭代器,此时for循环仍然不会执行,必须要调用了MoveNext方法后才会执行。
iterator = Fun1();
}
// 每当我们点击一次这个按钮,for循环就会执行一次,实际上是只执行到yield之前就停下了。
if (GUILayout.Button("调用MoveNext"))
{
// 每调用一次MoveNext,Fun1中的代码就会开始执行,直到执行到看到yield为止。
iterator.MoveNext();
}
}
此时程序的运行结果是,每当我们点击一次 “调用MoveNext” 按钮,print就会执行一次,直到for循环执行完毕为止。
从上面的代码我们看到,调用协程方法需要先获取迭代器,然后执行MoveNext方法,此时需要注意的是,每次点击按钮后代码执行的分水岭不再是方法中发for循环,而是yield。代码的执行顺序是从Fun1方法的第一行开始执行,直到第一次执行到yield停止。再次调用MoveNext方法后,程序会描你前面所执行的yield指定的条件是否已经满足,假如满足了条件,就会从上一次yield的下一行开始执行,直到再次遇到yield后再停止。也就是说,第二次的执行从yield的后半段开始,再到下一次yield的前半段结束。直到方法执行结束也没有遇到下一个yield,这个协程才真的算是结束了。下面总结几点:
- 协程方法的调用看的是迭代器,必须要获取到这个迭代器,然后将索引移动到下一步(也就是执行MoveNext方法)才会真的开始执行方法内容。
- 代码的停止是以yield为节点的,看到yield就停,满足yield条件就继续执行,直到看不到yield为止。
了解了MoveNext的执行时机后,我们是不是每次都要手动的去调用MoveNext呢?其实大部分情况下我们都不需要手动调用MoveNext,而是使用StartCoroutine() 方法,具体使用方法如下:
private IEnumerator iterator;
private void OnGUI()
{
if (GUILayout.Button("启动"))
{
// 获取迭代器,此时for循环仍然不会执行,必须要调用了MoveNext方法后才会执行。
iterator = Fun1();
}
if (GUILayout.Button("开启协程"))
{
// 当我们调用了这个StartCoroutine方法,就等于把这个迭代器iterator的执行交给了程序端。
// 程序会逐帧扫描上次yield指定的条件是否已经满足,如果满足,就继续执行下一次。
StartCoroutine(iterator);
}
}
private IEnumerator Fun1()
{
for (int i = 0; i < 5; i++)
{
print(i + "---" + Time.frameCount);
yield return null;
}
}
此时程序运行的结果是连续五帧,每一帧执行一次print。
这次我们不再手动调用MoveNext方法,而是使用StartCoroutine方法。当我们调用了这个StartCoroutine方法,就等于把这个迭代器iterator的MoveNext执行交给了程序端。程序会逐帧扫描上次yield指定的条件是否已经满足,如果满足,就继续执行下一次。yield暂停后下一次执行的时机并不是准确的时间节点执行,而是在某一帧的生命周期中判断当前协程是否到达了执行时机(即满足yield执行条件),如果不满足就继续进行下一帧,如果满足就继续执行yield后面的内容。
这样每一帧执行一次好像用处不大啊,怎么办?这时候就用到了yield的条件,通过这个条件就可以控制执行时机,并且我们还可以更简单的使用StartCoroutine方法,代码如下:
private IEnumerator iterator;
private void OnGUI()
{
if (GUILayout.Button("一键开启协程"))
{
// 不用在单独获取迭代器对象,直接将协程方法的执行结果返回给StartCoroutine方法,此时协程就开始执行了。
StartCoroutine(Fun1());
}
}
private IEnumerator Fun1()
{
for (int i = 0; i < 5; i++)
{
print(i + "---" + Time.frameCount);
yield return new WaitForSeconds(1);// 一秒钟执行一次yield。
}
}
此时程序的运行结果是每秒(大概为一秒,实际上并不是准确的一秒)执行一次print。
我们注意到代码中有两处改动,第一处是调用StartCoroutine时,不再需要单独将迭代器对象拿到再传参,而是直接将Fun1方法的执行结果作为参数传入StartCoroutine方法中,这是StartCoroutine方法的常用调用方式。关键的改动在于第二处,yield语句后面加入了执行条件(等待一秒的条件)。在每一帧的生命周期中,有很多个针对不同协程条件的判断节点,而WaitForSeconds的判断节点是在Update之后,LateUpdate之前。也就是说,如果代码中指定的条件是WaitForSeconds,当Update执行结束,就会判断yield指定的条件是否已经满足了,如果满足,就会在此时完成这个协程的下一步操作(也就是执行到下一次yield为止)。具体Unity生命周期的细节,可以参看另一篇文章:【Unity】Unity生命周期
yield WaitForSeconds所在的生命周期节点如图:
StartCoroutine是否只做了MoveNext的事呢?实际上StartCoroutine并不只是用于将迭代器的指针移到了下一步,StartCoroutine方法的作用是管理了一次协程执行的全过程,它包含了开启协程、移动指针、自然结束协程、初始化协程等一系列操作。也就是说,每当我们调用一次StartCoroutine方法,一个协程就被开启了一次。
与之对应的,有Start就会有Stop,Unity在MonoBehaviour中为我们提供了多个协程方法,以便我们调用,例如StopCoroutine、StopAllCoroutines等。
Unity的协程是作用在游戏对象上的,协程开启后,简单的禁用脚本组件是不会停止协程的,只有当前物体本身被禁用才会终止协程。
另外,由于协程的启动(StartCoroutine)会有一定的内存消耗,而yield不会有后续消耗,所以尽量不要频繁的调用StartCoroutine方法来开启协程。当然,在与进程、线程比较时,协程的消耗无疑是最小的。所以如果需要用到异步操作,请尽情的使用协程吧。
yield return对象
可以被yield return的对象有:
- null或数字:在Update后执行,适合分解耗时的逻辑处理。
- WaitForFixedUpdate:在FixedUpdate后执行,适合分解物理操作。
- WaitForSeconds:在指定时间后执行,适合延迟调用。
- WaitForSecondsRealtime:在指定时间后执行,适合延迟调用。不受时间缩放影响。
- WaitForEndOfFrame:在每帧结束后执行,适合相机跟随操作。
- Coroutine:在另一个协程执行完毕后再执行。
- WaitUntil:在委托返回true时执行,适合等待某一操作。
- WaitWhile:在委托返回false时执行,适合等待某一操作。
- WWW:在请求结束后执行,适合加载数据,如文件、贴图、材质等。
yield return coroutine执行顺序
public class CoroutineTest : MonoBehaviour
{
private Coroutine coroutine;
private void Start()
{
print("a : " + Time.frameCount);
coroutine = StartCoroutine(Fun1());
print("d : " + Time.frameCount);
StartCoroutine(Fun2());
print("f : " + Time.frameCount);
}
private IEnumerator Fun1()
{
for (int i = 0; i < 2; i++)
{
print(i + " : b : " + Time.frameCount);
yield return new WaitForSeconds(1);
print(i + " : c : " + Time.frameCount);
}
}
private IEnumerator Fun2()
{
for (int i = 0; i < 2; i++)
{
print(i + " : e : " + Time.frameCount);
yield return coroutine;
print(i + " : g : " + Time.frameCount);
}
}
}
执行结果如下:
由上图可以看出,a、0-b、d、0-e、f在第一帧就被执行了,而0-c、1-b则在一秒后的第90帧执行了,最后1-c、0-g、1-e、1-g在又一秒后的第292帧执行了(我本地环境并未锁帧,所以一秒钟不是稳定的60帧,实际执行间隔就是一秒,请勿在此处生疑)。由此我们可以总结几点:
- yield return Coroutine前面的代码的第一次执行是StartCoroutine后立即执行的。
- yield return Coroutine前面的代码的第二次执行是等待另一个协程完全执行过后才执行的。
- yield return Coroutine后面的代码是等待前面的协程全部执行完成后才执行的。
所以,yield return Coroutine的机制是等待指定的协程完全结束后才继续执行的,而不是与指定协程进行穿插执行。这一点一定要明确。
协程案例
协程通常有两个作用:1、延时调用;2、分解操作。
案例1
当玩家按下某个按键后触发一个渐变功能,此功能并不需要每一帧都渐变,这时就可以使用协程,按照一定的时间间隔调用。大致代码如下:
IEnumerator FadeOut()
{
Color c = renderer.material.color;
do
{
c.a -= 0.02f; // 改变颜色
yield return new WaitForSeconds(0.2f); // 延迟0.2秒执行
} while (c.a > 0);
if (c.a < 0)
{
c.a = 0;
}
}
void Update()
{
if (Input.GetKeyDown("f"))
{
StartCoroutine(FadeOut());
}
}
案例2
用协程嵌套实现寻路功能,这样做的好处有:1、让Update不再臃肿;2、让逐帧操作变成了单次调用,增加代码可读性。
将类似的需要逐帧或跳帧操作的功能都用协程封装成工具类,代码的可读性就会大大增强,且运行效率也有所提升。
/// <summary>
/// 嵌套协程实现寻路
/// </summary>
public class PathFinding : MonoBehaviour
{
public Transform[] wayPoints;
public float moveSpeed;
public IEnumerator FindPath(Transform[] wayPoints)
{
for (int i = 0; i < wayPoints.Length; i++)
{
yield return StartCoroutine(MoveToTarget(wayPoints[i].position));
}
}
private IEnumerator MoveToTarget(Vector3 position)
{
transform.LookAt(position);
while (Vector3.Distance(transform.position, position) > 0.1f)
{
transform.position = Vector3.MoveTowards(transform.position, position, moveSpeed);
yield return new WaitForFixedUpdate();
}
}
private void OnGUI()
{
if (GUILayout.Button("走你"))
{
StartCoroutine(FindPath(wayPoints));
}
}
}
案例3
给每个敌人加一个警报检测,这种功能可以放在Update中执行,但每一帧执行没有必要,这时就可以使用协程,将此功能从Update中剥离出来。
public class Enemy : MonoBehaviour
{
// 警报半径
public float radiusOfAlert = 5f;
// 是否在警报状态
public bool isInAlert = false;
// 玩家的Transform组件
private Transform playerTransform;
void Start()
{
// 假设你有一个方法来获取玩家的Transform,这里我们直接假设已经获取到了
// playerTransform = GameManager.Instance.Player.transform; 或者其他方式
// 注意:这只是一个示例,实际项目中应避免使用Find
playerTransform = GameObject.Find("Player").transform;
StartCoroutine(CheckAlert()); // 启动协程
}
IEnumerator CheckAlert()
{
// 无限循环,直到敌人被销毁
while (true)
{
// 计算玩家与敌人的距离
float distanceToPlayer = Vector3.Distance(transform.position, playerTransform.position);
// 如果玩家在警报半径内,则设置警报状态
if (distanceToPlayer <= radiusOfAlert && !isInAlert)
{
isInAlert = true;
// 在这里可以添加进入警报状态的逻辑,比如播放警报声或改变敌人的动画
Debug.Log("Enemy is in alert!");
}
// 如果玩家不在警报半径内且敌人处于警报状态,则取消警报状态
else if (distanceToPlayer > radiusOfAlert && isInAlert)
{
isInAlert = false;
// 在这里可以添加退出警报状态的逻辑
Debug.Log("Enemy is no longer in alert.");
}
// 等待一段时间再次检查
yield return new WaitForSeconds(1f); // 这里设置为每秒检查一次
}
}
}
案例4
当程序需要异步加载资源或者获取网络资源时,可以使用WWW协程。案例代码如下:
using System.Collections;
using UnityEngine;
using UnityEngine.Networking; // 注意:虽然这里导入了UnityEngine.Networking,但WWW本身不直接需要它,只是习惯上可能会与Unity的网络功能一起提及
public class WebLoader : MonoBehaviour
{
// 用于回调加载完成的结果
public delegate void OnLoadComplete(string result);
// 异步加载网络资源
public static IEnumerator LoadWebResource(string url, OnLoadComplete onComplete)
{
using (WWW www = new WWW(url))
{
// 等待异步加载完成
yield return www;
// 检查是否加载成功
if (www.isDone && !string.IsNullOrEmpty(www.error))
{
Debug.LogError("加载失败: " + www.error);
onComplete?.Invoke(null); // 调用回调并传入null作为错误处理
}
else if (www.isDone)
{
// 加载成功,调用回调并传入加载结果
onComplete?.Invoke(www.text); // 假设我们加载的是文本资源,你可以根据需求修改这部分
}
}
}
// 示例用法
void Start()
{
StartCoroutine(LoadWebResource("https://example.com/somefile.txt", HandleLoadComplete));
}
// 处理加载完成的结果
void HandleLoadComplete(string result)
{
if (!string.IsNullOrEmpty(result))
{
Debug.Log("加载成功: " + result);
}
else
{
Debug.Log("加载结果为空或发生错误");
}
}
}
WebLoader 可以专门用于在项目中异步加载资源。虽然Unity提供了WWW协程,但在Unity2017后官方推荐使用 UnityWebRequest 替代 WWW 类,所以下面再提供一个 UnityWebRequest 的案例:
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class WebRequestExample : MonoBehaviour
{
// 用于回调加载完成的结果
public delegate void OnLoadComplete(string result, UnityWebRequestError error);
// 异步加载网络资源
public static IEnumerator LoadWebResource(string url, OnLoadComplete onComplete)
{
using (UnityWebRequest webRequest = UnityWebRequest.Get(url))
{
// 发送请求并等待响应
yield return webRequest.SendWebRequest();
// 检查请求是否成功完成
if (webRequest.result != UnityWebRequest.Result.Success)
{
Debug.LogError("加载失败: " + webRequest.error);
onComplete?.Invoke(null, UnityWebRequestError.Error);
}
else
{
// 下载成功,获取下载的内容
string downloadedContent = webRequest.downloadHandler.text;
// 调用回调并传入结果
onComplete?.Invoke(downloadedContent, UnityWebRequestError.NoError);
}
}
}
// UnityWebRequestError枚举,用于表示请求的错误状态
public enum UnityWebRequestError
{
NoError,
Error
}
// 示例用法
void Start()
{
StartCoroutine(LoadWebResource("https://example.com", HandleLoadComplete));
}
// 处理加载完成的结果
void HandleLoadComplete(string result, UnityWebRequestError error)
{
if (error == UnityWebRequestError.NoError && !string.IsNullOrEmpty(result))
{
Debug.Log("加载成功: " + result.Substring(0, Mathf.Min(result.Length, 500)) + "..."); // 打印前500个字符以避免控制台溢出
}
else
{
Debug.Log("加载失败或结果为空");
}
}
}
在实际的项目中,其实一般不会这样单独写一个工具类,而是要结合热更SDK做一个相应的适配系统,统一处理所有与资源相关的功能,比如版本更新、资源异步加载等。
案例5
创建补间动画,使用协程结合Lerp代替Update实现动画补间,示例代码如下:
using System.Collections;
using UnityEngine;
public class TweenPositionCoroutine : MonoBehaviour
{
// 目标位置
public Vector3 targetPosition;
// 动画持续时间(秒)
public float duration = 1.0f;
// 调用此函数来启动补间动画
public void StartTween()
{
// 启动协程
StartCoroutine(TweenPosition());
}
// 协程函数,用于执行补间动画
private IEnumerator TweenPosition()
{
float elapsedTime = 0;
Vector3 startingPosition = transform.position; // 记录开始位置
while (elapsedTime < duration)
{
// 计算插值
float t = elapsedTime / duration;
t = Mathf.Clamp01(t); // 确保t的值在0到1之间
Vector3 newPosition = Vector3.Lerp(startingPosition, targetPosition, t);
// 更新位置
transform.position = newPosition;
// 等待下一帧
elapsedTime += Time.deltaTime;
yield return null; // 暂停协程直到下一帧
}
// 确保在结束时位置完全到达目标位置
transform.position = targetPosition;
}
// 可以在Unity编辑器中直接调用
private void OnGUI()
{
if (GUI.Button(new Rect(10, 10, 100, 30), "Tween"))
{
StartTween();
}
}
}
实际上如果为了开发更简单,通常大家都会使用DoTween来做这样的事情。DoTween虽然使用的是Update,但DoTween被设计为高效且资源优化的补间动画库。它内部通过优化算法和内存管理来减少GC(垃圾收集)调用,从而提高性能。特别是在处理大量补间动画时,DoTween的性能优势更加明显。所以在补间动画这个领域,建议还是使用DoTween来做更有优势,而且DoTween的使用也更便利。
案例6
打字机效果。有很多对话向的游戏都把对话字幕做成打字机效果,用协程就可以实现,代码示例如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI; // 确保引入了UI命名空间
public class TypewriterEffect : MonoBehaviour
{
public Text textComponent; // 绑定到你的Text组件上
public string fullMessage = "你好,这是一个用协程实现的打字机效果案例!"; // 要显示的完整信息
public float typeSpeed = 0.1f; // 打字速度,每秒打多少个字符
private string currentMessage = ""; // 当前已经显示的字符串
private bool isTyping = false; // 是否正在打字
void Start()
{
// 可以在Start中开始打字,或者在某个事件触发时开始
StartCoroutine(TypeMessage());
}
IEnumerator TypeMessage()
{
if (isTyping) yield break; // 如果已经在打字,则退出协程
isTyping = true;
foreach (char letter in fullMessage.ToCharArray())
{
currentMessage += letter; // 将当前字符添加到已显示的字符串中
textComponent.text = currentMessage; // 更新Text组件的文本
yield return new WaitForSeconds(typeSpeed); // 等待一段时间
}
// (可选)打字完成后可以添加一些额外的处理,比如播放一个音效
// AudioSource.PlayClipAtPoint(typingDoneSound, transform.position);
isTyping = false; // 打字完成
}
// 如果你需要在游戏的其他部分触发打字效果,可以添加一个公开的方法来启动协程
public void StartTyping()
{
StartCoroutine(TypeMessage());
}
// 如果你需要在打字过程中暂停或停止,可以添加额外的逻辑来控制isTyping变量
}
其实打字机的效果也是可以用DoTween来做的,下面是示例代码:
using System.Collections;
using System.Collections.Generic;
using DG.Tweening; // 引入DoTween命名空间
using UnityEngine;
using UnityEngine.UI; // 引入UI命名空间以使用Text组件
public class TypewriterEffectWithDoTween : MonoBehaviour
{
public Text textComponent; // 绑定到你的Text组件
public string fullMessage = "你好,这是一个用DOTween实现的打字机效果案例!";
public float typeSpeed = 0.1f; // 打字速度,每秒打多少个字符
private float currentCharIndex = 0; // 当前已显示的字符索引
void Start()
{
StartTyping();
}
void StartTyping()
{
// 使用DOTween的Sequence来创建一个序列动画
DOTween.Sequence()
// 使用DOValue来递增currentCharIndex,从0开始到fullMessage.Length(不包含)
.Append(DOTween.To(() => currentCharIndex, x => currentCharIndex = x, fullMessage.Length, typeSpeed * fullMessage.Length))
// 在每次currentCharIndex更新时设置Text组件的text
.OnUpdate(() =>
{
// 确保不会超出fullMessage的长度(虽然DOValue已经处理了这一点,但作为一种好的实践)
if (currentCharIndex < fullMessage.Length)
{
textComponent.text = fullMessage.Substring(0, (int)currentCharIndex);
}
else
{
// 如果已经完成打字,可以执行一些额外的操作,比如回调
// 这里我们就不做额外操作了,因为打字已经完成
}
});
}
}
案例7
定时器操作。
定义一个单例工具类:
public class TimeTools : MonoSingleton<TimeTools>
{
public void DelayToDo(float delayTime, Action callback)
{
StartCoroutine(OnDelayToDo(delayTime, callback));
}
private IEnumerator OnDelayToDo(float delay, Action callback)
{
yield return new WaitForSeconds(delay);
callback?.Invoke();
}
}
单例代码MonoSingleton<T>
请参考我的另一篇文章:【Unity】Unity开发进阶(四)Unity脚本单例工具(必备工具)
使用方法:
float delay= 3.0f;
TimeTools.Instance.DelayToDo(delay, () =>
{
Destroy(gameObject);
});
程序将会使用协程的方式在3秒钟后销毁当前游戏对象。比如我们有一个角色,我们需要这个角色的死亡动画播放结束后执行销毁,就可以用这个方法。
协程总结
在Unity的生命周期中,有很多步骤都涉及到协程,我们可以通过协程来实现在生命周期的不同步骤下执行任务,协程是依赖于迭代器原理执行的,其本身并不能够加快程序运行速度,但其功能却能实现前后台的异步、定时操作任务等,且其代码规格使其从Update中剥离出来,既简化了Update,又增加了功能代码的可读性。大型游戏甚至可以做一套协程管理器来实现功能的管理,以及代码的审核,让代码更具可维护性。
注意:启动一个协程会消耗少量的内存,在方法调用时却不会有后续消耗。如果内存消耗和垃圾回收是严重的问题,应该尝试避免产生太多短时间的协程,并避免在运行时调用太多 StartCoroutine() 。
更多内容请查看总目录【Unity】Unity学习笔记目录整理