【Unity】Unity协程(Coroutine)的原理与应用


前言

本文是作者在学习Unity过程中对协程相关知识的汇总,以方便以后查阅。大部分内容转载自不同文章,原文链接可在最后一部分查看,如果对文章内容有任何困惑或者疑问,建议阅读原文相关部分。


一、什么是协程

首先看一下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中的协程是一种返回值为IEnumerator的特殊函数,它可以主动的请求暂停自身并提交一个唤醒条件,Unity会在唤醒条件满足的时候去重新唤醒协程,所以协程还是运行在主线程上。

二、应用场景

1.异步加载资源

资源加载指的是通过IO操作,将磁盘或服务器上的数据加载成内存中的对象。资源加载一般是一个比较耗时的操作,如果直接放在主线程中会导致游戏卡顿,通常会放到异步线程中去执行。

举个例子,当你需要从服务器上加载一个图片并显示给用户,你需要做两件事情:

  1. 通过IO操作从服务器上加载图片数据到内存中。
  2. 当加载完成后,将图片显示在屏幕上。

其中,2操作必须等待1操作执行完毕后才能开始执行。

//伪代码
​
IEnumerator ShowImageFromUrl(string url)
{
    
    
    Image image = null;
    yield return LoadImageAsync(url, image); //异步加载图像,加载完成后唤醒协程
    Show(image);
}

使用协程来进行异步加载在Unity中是一个很常用的写法。异步资源加载是一个较为深奥的话题,有兴趣的话可以通过下面两个参考链接进行研究:
Unity官方的异步加载场景的示例
倩女幽魂手游中的资源加载与更新方案

2.将一个复杂程序分帧执行

如果一个复杂的函数对于一帧的性能需求很大,我们就可以通过yield return null将步骤拆除,从而将性能压力分摊开来,最终获取一个流畅的过程,这就是一个简单的应用。

举一个案例,如果某一时刻需要使用Update读取一个列表,这样一般需要一个循环去遍历列表,这样每帧的代码执行量就比较大,就可以将这样的执行放置到协程中来处理:

public class Test : MonoBehaviour
{
    
    
    public List<int> nums = new List<int> {
    
     1, 2, 3, 4, 5, 6 };


    private void Update()
    {
    
    
        if(Input.GetKeyDown(KeyCode.Space))
        {
    
    
            StartCoroutine(PrintNum(nums));
        }
    }
	//通过协程分帧处理
    IEnumerator PrintNum(List<int> nums)
    {
    
    
        foreach(int i in nums)
        {
    
    
            Debug.Log(i);
            yield return null;
                 
        }

    }
}

3.定时器

当你需要延时执行一个方法或者是每隔一段时间就执行某项操作时,可以使用协程。当然这种应用场景很少,如果我们需要计时器有很多其他更好用的方式,下面是官方一个案例。

游戏中的许多任务需要定期执行,最容易想到的方法是将任务包含在 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);
    }
}

这将大大减少所进行的检查次数,而不会对游戏运行过程产生任何明显影响。

三、协程的使用

MonoBehaviour.StartCoroutine()方法可以开启一个协程,这个协程会挂在该MonoBehaviour下。

要想使用协程,只需要以IEnumerator为返回值,并且在函数体里面用yield return语句来暂停协程并提交一个唤醒条件。然后使用StartCoroutine来开启协程。

下面这个实例展示了协程的用法。

IEnumerator Demo(int arg1)
{
    
    
    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运行结束");
}//在程序种调用协程
    public void Test()
    {
    
    
        //第一种与第二种调用方式,通过方法名与参数调用
        StartCoroutine("Demo", 1);

        //第三种调用方式, 通过调用方法直接调用
        StartCoroutine(Demo(1));
    }

在一个协程开始后,同样会有结束协程的方法StopCoroutineStopAllCoroutines两种方式,需要注意的是,两者的使用需要遵循一定的规则。在此之前,先介绍一下关于StopCoroutine重载:

StopCoroutine(string methodName):通过方法名(字符串)来进行
StopCoroutine(IEnumerator routine):通过方法形式来调用
StopCoroutine(Coroutine routine):通过指定的协程来关闭

刚刚说到两种结束协程方法的使用有一定的规则,那么规则是什么呢,答案是前两种结束协程方法的使用上,如果我们是使用StartCoroutine(string methodName)来开启一个协程的,那么结束协程就只能使用StopCoroutine(string methodName)StopCoroutine(Coroutine routine)来结束协程

注意事项

  1. 协程是挂在MonoBehaviour上的,必须要通过一个MonoBehaviour才能开启协程。
  2. 通过设置MonoBehaviour脚本的enabled对协程是没有影响的,但如果 gameObject.SetActive(false) 则已经启动的协程则完全停止了,即使在Inspector把gameObject 激活还是没有继续执行。也就说协程虽然是在MonoBehvaviour启动的StartCoroutine,但是协程函数的地位完全是跟MonoBehaviour是一个层次的,不受MonoBehaviour的状态影响,但跟MonoBehaviour脚本一样受gameObject 控制,也应该是和MonoBehaviour脚本一样每帧“轮询” yield 的条件是否满足。
  3. 协程看起来有点像是轻量级线程,但是本质上协程还是运行在主线程上,不是异步执行的。协程更类似于Update()方法,Unity会每一帧去检测协程需不需要被唤醒。一旦你在协程中执行了一个耗时操作,很可能会堵塞主线程。这里提供两个解决思路:(1) 在耗时算法的循环体中加入yield return null来将算法分到很多帧里面执行;(2) 如果耗时操作里面没有使用Unity API,那么可以考虑在异步线程中执行耗时操作,完成后唤醒主线程中的协程。
  4. 经过测试验证,协程至少是每帧的LateUpdate()后去运行。这里贴上实验链接和测试结果,下面补一张Monobehaviour的函数执行顺序图

在这里插入图片描述

四、Unity协程的底层原理

协程分为两部分,协程与协程调度器:协程仅仅是一个能够中间暂停返回的函数,而协程调度是在MonoBehaviour的生命周期中实现的。 准确的说,Unity只实现了协程调度部分,而协程本身其实就是用了C#原生的”迭代器方法“。

1. 协程本体:C#的迭代器函数

许多语言都有迭代器的概念,使用迭代器我们可以很轻松的遍历一个容器。 但是C#里面的迭代器要屌一点,它可以“遍历函数”。

C#中的迭代器方法其实就是一个协程,你可以使用yield来暂停,使用MoveNext()来继续执行。 当一个方法的返回值写成了IEnumerator类型,他就会自动被解析成迭代器方法(后文直接称之为协程),你调用此方法的时候不会真的运行,而是会返回一个迭代器,需要用MoveNext()来真正的运行。看例子:

static void Main(string[] args)
{
    
    
    IEnumerator it = Test();//仅仅返回一个指向Test的迭代器,不会真的执行。
    Console.ReadKey();
    it.MoveNext();//执行Test直到遇到第一个yield
    System.Console.WriteLine(it.Current);//输出1
    Console.ReadKey();
    it.MoveNext();//执行Test直到遇到第二个yield
    System.Console.WriteLine(it.Current);//输出2
    Console.ReadKey();
    it.MoveNext();//执行Test直到遇到第三个yield
    System.Console.WriteLine(it.Current);//输出test3
    Console.ReadKey();
}static IEnumerator Test()
{
    
    
    System.Console.WriteLine("第一次执行");
    yield return 1;
    System.Console.WriteLine("第二次执行");
    yield return 2;
    System.Console.WriteLine("第三次执行");
    yield return "test3";
}
  • 执行Test()不会运行函数体,会直接返回一个IEnumerator

  • 调用IEnumerator的MoveNext()成员,会执行协程直到遇到第一个yield return或者执行完毕。

  • 调用IEnumerator的Current成员,可以获得yield return后面接的返回值,该返回值可以是任何类型的对象。

这里有两个要注意的地方:

  1. IEnumerator中的yield return可以返回任意类型的对象,事实上它还有泛型版本IEnumerator,泛型类型的迭代器中只能返回T类型的对象。Unity原生协程使用普通版本的IEnumerator,但是有些项目(比如倩女幽魂)自己造的协程轮子可能会使用泛型版本的IEnumerator

  2. 函数调用的本质是压栈,协程的唤醒也一样,调用IEnumerator.MoveNext()时会把协程方法体压入当前的函数调用栈中执行,运行到yield return后再弹栈。这点和有些语言中的协程不大一样,有些语言的协程会维护一个自己的函数调用栈,在唤醒的时候会把整个函数调用栈给替换,这类协程被称为有栈协程,而像C#中这样直接在当前函数调用栈中压入栈帧的协程我们称之为无栈协程。Unity中的协程是无栈协程,它不会维护整个函数调用栈,仅仅是保存一个栈帧。

2. 协程调度:MonoBehaviour生命周期中实现

仔细翻阅Unity官方文档中介绍MonoBehaviour生命周期的部分,会发现有很多yield阶段,在这些阶段中,Unity会检查MonoBehaviour中是否挂载了可以被唤醒的协程,如果有则唤醒它。

通过对C#迭代器的了解,我们可以模仿Unity自己实现一个简单的协程调度。这里以YieldWaitForSeconds为例

// 伪代码
void YieldWaitForSeconds()
{
    
    
    //定义一个移除列表,当一个协程执行完毕或者唤醒条件的类型改变时,应该从当前协程列表中移除。
    List<WaitForSeconds> removeList = new List<WaitForSeconds>();
    foreach(IEnumerator w in m_WaitForSeconds) //遍历所有唤醒条件为WaitForSeconds的协程
    {
    
    
        if(Time.time >= w.beginTime() + w.interval) //检查是否满足了唤醒条件
        {
    
    
            //尝试唤醒协程,如果唤醒失败,则证明协程已经执行完毕
            if(it.MoveNext();)
            {
    
    
                //应用新的唤醒条件
                if(!(it.Current is WaitForSeconds))
                {
    
    
                    removeList.Add(it);
                       //在这里写一些代码,将it移到其它的协程队列里面去
                }
            }
            else 
            {
    
    
                removeList.Add(it);
            }
        }
    }
    m_WaitForSeconds.RemoveAll(removeList);
}

原文还较为详细的描述了如何扩展Unity的协程和不同框架下协程的共同点,建议去阅读一下原文,这里贴上链接:
Unity协程的原理与应用

虽然本文内容来源于对其他文章的整理,但技术类文章一般都有时效性,本人习惯不定期对自己的博文进行修正和更新,因此请访问出处以查看本文的最新版本。欢迎转载,请注明文章出处


五、参考资料

  1. 【UNITY3D 游戏开发之六】UNITY 协程COROUTINE与INVOKE
  2. Unity 协程(Coroutine)原理与用法详解
  3. Unity协程的原理与应用

猜你喜欢

转载自blog.csdn.net/hafeiyangha/article/details/125365152