Unity-协程详解

Posted 卖烤麸烤饼儿

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity-协程详解相关的知识,希望对你有一定的参考价值。

1. 简介

unity的**协程(Coroutine)**只是在c#的基础上做了一层封装,其实yield是C#的关键字。

unity协程是一个能够暂停协程执行,暂停后立即返回主函数,执行主函数剩余的部分,直到中断指令完成后,从中断指令的下一行继续执行协程剩余的函数。
函数体全部执行完成,协程结束。
由于中断指令的出现,使得可以将一个函数分割到多个帧里去执行。

协程不是进程,也不是线程,它就是一个特殊的函数——可以在某个地方挂起,并且可以重新在挂起处继续运行。

协程方法与普通方法的区别:

  • 普通方法
    被调用时,原来执行的部分保留现场,停止执行,然后去执行要调用的方法,并且,被调用的方法执行完之后才能返回到调用前的状态接着往下执行。
  • 协同方法
    执行不用等协同方法执行完再执行调用之前原来方法的代码,而是两者异步执行。

协程不是多线程,它与主线程同时运行,它在主线程运行的同时开启另一段逻辑处理。
类似一个子线程单独出来处理一些问题,性能开销较小。
Unity的协程会在每帧结束之后去检测yield的条件是否满足,满足则执行yield return之后的代码。

在一个MonoBehaviour提供的主线程里只能有一个处于运行状态的协程,而其他协程处于休眠状态。
协程实际上是在一个线程中,只不过每个协程对CUP进行分时,协程可以访问和使用unity的所有方法和component。

性能:
在性能上相比于一般函数没有更多的开销

协程的好处:
让原来要使用异步 + 回调方式写的非人类代码, 可以用看似同步的方式写出来。
能够分步做一个比较耗时的事情,如果需要大量的计算,将计算放到一个随时间进行的协程来处理,能分散计算压力

协程的坏处:
协程本质是迭代器,且是基于unity生命周期的,大量开启协程会引起gc
如果同时激活的协程较多,就可能会出现多个高开销的协程挤在同一帧执行导致的卡帧

协程书写时的性能优化:
常见的问题是直接new 一个中断指令,带来不必要的 GC 负担,可以复用一个全局的中断指令对象,优化掉开销;在 Yielders.cs 这个文件里,已经集中地创建了上面这些类型的静态对象
这个链接分析了一下https://blog.csdn.net/liujunjie612/article/details/70623943

协程是在什么地方执行?
协程不是线程,不是异步执行;协程和monobehaviour的update函数一样也是在主线程中执行
unity在每一帧都会处理对象上的协程,也就是说,协程跟update一样都是unity每帧会去处理的函数
经过测试,协程至少是每帧的lateUpdate后运行的。
参照unity的生命周期图

前驱知识:

  • 设计模式——迭代器模式
  • C#中的IEnumerator、IEnumerable接口

2. 协程的实现

协程的实现需要在Unity中继承MonoBehaviour并使用C#的迭代器IEnumrator,格式如下所示:

IEnumrator 函数名(形参表)  //最多只能有一个形参 
   
    yield return xxx; //恢复执行条件
    //方法体

在IEnumerator类型的方法中写入需要执行的操作,遇到yield后会暂时挂起,等到yield return后的条件满足才继续执行yield语句后面的内容。

3. 协程的开启与中止

相关测试:Unity 协程的一些基本用法及测试

3.1 协程的开启

开启协程需要使用StartCoroutine()方法:

  • 开启无参数的协程:
    StartCoroutine(协程名());StartCoroutine("协程名");

  • 开启单参数的协程:
    StartCoroutine(协程名(参数));StartCoroutine("协程名",参数);

  • 开启多参数的协程:
    StartCoroutine(协程名(参数1,......));

    void StartCoroutine()//开启协程的函数
    
        IEnumerator coroutine = Test(5, 6);
        StartCoroutine(coroutine);
    
     public IEnumerator Test(int a, int b)//协程
     
         //等待帧画面渲染结束
         yield return new WaitForEndOfFrame();
         a=2;
         b=3;
    
    

    用“协程名”启动的方式不允许传入 一个以上的参数

3.2 协程的结束

结束协程有两种情况:

  • 当协程的方法体执行完毕将会自动结束

  • 调用StopCoroutine();方法中止协程执行

中止协程的几种情况:

  • 中止所有协程:
    StopAllCoroutines();

  • 使用对象实例中止指定协程

    Coroutine c;
    void Start()
    
        c = StartCoroutine(CountSeconds());        
    
    void Update()
    
        if (Input.GetKeyDown(KeyCode.J))
        
            StopCoroutine(c);
        
    
    
  • 使用字符串中止指定协程

    StartCoroutine("协程名");
    StopCoroutine("协程名");
    

    只有以协程名字符串启动的协程可以用此方法中止
    既:StartCoroutine(“协程名”);StartCoroutine(“协程名”,参数);

    允许使用**StopCoroutine(“协程名”);**中止协程

    不允许使用直接调用协程方法的方式中止指定协程
    既:**StopCoroutine(协程名([参数]));**不被允许

4. yield 协程回复条件语句

快查表:

yield语句功能
yield return null;下一帧再执行后续代码
yield return 0;下一帧再执行后续代码
yield return 6;(任意数字)下一帧再执行后续代码
yield break;直接结束该协程的后续操作
yield return asyncOperation;等异步操作结束后再执行后续代码
yield return StartCoroution(其它协程);调用执行其它协程后再执行后续代码
yield return WWW();等待WWW操作完成后再执行后续代码
yield return new WaitForEndOfFrame();等待帧结束,等待直到所有的摄像机和GUI被渲染完成后,在该帧显示在屏幕之前执行
yield return new WaitForSeconds(0.3f);等待0.3秒,一段指定的时间延迟之后继续执行,在所有的Update函数完成调用的那一帧之后(这里的时间会受到Time.timeScale的影响);
yield return new WaitForSecondsRealtime(0.3f);等待0.3秒,一段指定的时间延迟之后继续执行,在所有的Update函数完成调用的那一帧之后(这里的时间不受到Time.timeScale的影响);
yield return WaitForFixedUpdate();等待下一次FixedUpdate开始时再执行后续代码
yield return new WaitUntil()将协同执行直到当输入的参数(或者委托)为true的时候
yield return new WaitWhile()将协同执行直到 当输入的参数(或者委托)为false的时候

生命周期图:

4.1 yield return null;

从生命周期图中可以看到,在GameLogic部分对协程中挂起的条件进行了判断。

也就是说,协程顺序为:
(当前帧为第1帧)
第1帧在start中开启协程,执行协程(自上而下),遇到yield return null 将后面的内容挂起。
这时继续执行第1帧剩下的东西直到第1帧Update执行结束,这时对挂起的协程进行判断 是否满足return条件,
满足则在第2帧Update之后,在LateUpdate前执行协程中yield return 以后的代码;
不满足条件则继续执行第1帧的LateUpdate。
第2帧同第1帧相同。

测试如下:

using System.Collections;
using UnityEngine;
public class CorTest2 : MonoBehaviour

    int i = 0;//update中判断次数的变量
    private void Start()
    
        Debug.Log("start 1");
        //开启协程1
        StartCoroutine(Test());
        Debug.Log("start 2");
    
    private void Update()
    
        Debug.Log("第" + ++i + "帧开始");
    
    private void LateUpdate()
    
        Debug.Log("第" + i + "帧结束");
    
    IEnumerator Test()
    
        while (true)
        
            Debug.Log("协程1第一次");
            //挂起时机
            yield return null;
            Debug.Log("协程1第二次");
        
    

结果如下:

可以看到,协程运行到一半在第一帧被挂起,第二帧Update执行完后满足条件继续执行。

4.2 yield return StartCoroutine();

测试如下:

IEnumerator Test()
   
       while (true)
       
           Debug.Log("协程1第一次");
           //挂起时机
           yield return StartCoroutine(Test2());
           Debug.Log("协程1第二次");
       
   

   IEnumerator Test2()
   
       Debug.Log("协程2第一次");
       yield return null;
       Debug.Log("协程2第二次");
   

结果如下:

原理都是一样的,执行完yield return 后挂起(注意不是遇到就挂起,而是执行),在每一帧的update与lateupdate之间对挂起的内容进行判断,满足则继续执行被挂起的协程的剩余部分。

4.3 yield return new WaitUntil();

案例:

public int counter;
IEnumerator Start()

    counter=20;
    yield return new WaitUntil(TestWait);
    Debug.Log("Start执行完毕");

bool TestWait()

    return true;

  • 当方法TestWait的返回值为true的时候
    Start会一次性执行完。
  • 当方法TestWait的返回值为false的时候
    Start会一直等待着,只要返回值为false,那么Start的最后一句打印就不会执行。

可以使用lambda表达式

4.4 yield return new WaitWhile()

案例:

public int counter;
IEnumerator Start()

    counter=20;
    yield return new WaitWhile(TestWait);
    Debug.Log("Start执行完毕");

bool TestWait()

    return false;

  • 当方法TestWait的返回值为true的时候
    Start会一直等待着,只要返回值为true,那么Start的最后一句打印就不会执行。
  • 当方法TestWait的返回值为false的时候
    Start会一次性执行完。

可以使用lambda表达式

5. 协程的嵌套

利用yield return StartCoroution(其它协程);可以实现多个协程的嵌套使用。
例如:

IEnumerator SaySomeThings()   
       
    Debug.Log("The routine has started");       
    yield return StartCoroutine(RepeatMessage(1, 1f, "Hello"));       
    Debug.Log("1 second has passed since the last message");       
    yield return StartCoroutine(RepeatMessage(1, 2.5f, "Hello"));       
    Debug.Log("2.5 seconds have passed since the last message");   

执行结果:

6. 注意

  • IEnumerator 类型的方法不能带 ref 或者 out 型的参数,但可以带被传递的引用
  • 在函数 Update 和 FixedUpdate 中不能使用 yield 语句,否则会报错, 但是可以启动协程
  • 在一个协程中,StartCoroutine()和 yield return StartCoroutine()是不一样的。
    前者仅仅是开始一个新的Coroutine,这个新的Coroutine和现有Coroutine并行执行。
    后者是返回一个新的Coroutine,是一个中断指令,当这个新的Coroutine执行完毕后,才继承执行现有Coroutine。

7. 使用案例

7.1 运动到某一位置

在Inspector面板中设置目标位置和运动速度,在游戏开始时将一个物体移动到目标位置

public Vector3 targetPosition;
public float moveSpeed=5;
void Start()

    c = StartCoroutine(MoveToPosition(targetPosition));

IEnumerator MoveToPosition(Vector3 target)

    while (transform.position != target)
    
        transform.position = Vector3.MoveTowards(transform.position,target,moveSpeed*Time.deltaTime);
        yield return 0;
    

7.2 按指定路径前进

我们可以让运动到某一位置的程序做更多,不仅仅是一个指定位置,我们还可以通过数组来给它赋值更多的位置,通过MoveToPosition() ,我们可以让它在这些点之间持续运动。

public List<Vector3> path;    
IEnumerator MoveOnPath(bool loop)

    do
    
        foreach (var point in path)
            yield return StartCoroutine(MoveToPosition(point));
    
    while (loop);

7.3 倒计时

IEnumerator CountDown(int time)

        for(int t = time;t >= 0;t -= 1)
        
            print(time);
            time -= 1; 
            yield return new WaitForSecondsRealtime(1f); //WaitForSecondsRealtime不受时间缩放影响
        

Unity的协程详解

一、协程的定义

协程,即为协同程序. Unity中的协程由协程函数和协程调度器两部分构成.协程函数使用的是C#的迭代器, 协程调度器则利用了MonoBehaviour中的生命周期函数来实现. 协程函数实现了分步, 协程调度器实现了分时. 

注:因为协程分时分步执行的特性,当多个协程的耗时操作挤在同一时间执行也会造成卡顿。

二、协程的用法

using System.Collection;
using UnityEngine;

// 定义一个协程函数,返回一个迭代器接口
IEnumerator CoroutineFunc()

    Debug.Log("第一次进入");
    yield return null;
    Debug.Log("第二次进入");
    yield return null;


// 在继承自MonoBehaviour的类中调用此协程函数
void Start()

    // 获取迭代器接口
    IEnumerator enumerator = CoroutineFunc();
    // 返回的Coroutine对象保存起来可用于停止协程
    Coroutine coroutine = StartCoroutine(enumerator);
    // 相当于在外部 yield break;
    StopCoroutine(coroutine);

三、Unity规定的协程返回值的含义

含义代码
下一帧再执行后续代码yield return null;  yield retun x(x代表任意数字)
结束该协程yield break;
等待固定时间执行后续代码

yield return new WaitForSeconds(0.3f);

yield return new WaitForSecondsRealtime(0.3f); //不受timescale影响

函数执行完毕后执行后续代码yield return FunctionName();
异步执行完毕后执行后续代码yield return AsyncOperation;
协程执行完毕后执行后续代码yield return Coroutine;
帧渲染完成后执行后续代码yield return new WaitForEndOfFrame();
物理帧更新后执行后续代码yield return new WaitForFixedUpdate();
参数为true时执行后续代码yield return new WaitUntil(arg);
参数为false时执行后续代码yield return new WaitWhile(arg);

注: 为了优化性能,yield return 后面需要new的返回值应该预先创建, 而不是在协程函数中反复创建.

各个 yield return 在生命周期的位置

四、协程函数与普通函数的区别

操作协程函数普通函数
返回值可分步返回多次只能返回一次
获取返回值的方式调用后执行MoveNext(),通过Current属性获取当前返回值;调用函数;
返回顺序  根据实际情况交错返回根据调用顺序返回

注:执行协程函数返回的是一个迭代器接口而并非得到结果

五、协程与多线程的联系与区别

区别:

协程多线程
切换时机自定CPU时间片为单位的系统调度
CPU核心与主线程在同一核心根据操作系统调度不同
对主线程的影响卡顿会影响主线程卡死都不会影响主线程
线程同步问题不存在线程同步问题需要注意线程同步问题
线程开销不存在线程开销存在线程创建、销毁、切换的开销
书写方式与普通函数一致回调函数

联系:

协程与多线程都是异步操作,都是为了提高CPU的利用率存在的。

六、Unity协程的原理

using System;
using System.Collection;
using System.Collection.Gernic;
using UnityEngine;

// 感谢唐老师的指导

public class YieldInstruction

    public IEnumerator ie;
    public float executeTime;




public class CoroutineMgr : MonoBehaviour

    private List<YieldInstruction> list = new List<YieldInstruction>();

    public void StartCoroutine(IEnumerator ie)
    
        ie.MoveNext();
        if((ie.Current is null) || (ie.Current is int))
        
            list.Add(new YieldInstruction ie=ie,executeTime=0; );
        
        else if(ie.Current is WaitForSeconds)
        
            list.Add(new YieldInstruction 
                ie=ie,
                executeTime=Time.time+(ie.Currentas WaitForSeconds).second );
        
        else if (...)
        ...
    



    void Update()
    
        // 倒序遍历方便移除
        for(int i=list.Count-1; i>=0; i--)
        
            if(list[i].executeTime<=Time.time)
            
                if(list[i].ie.MoveNext())
                
                    // 如果是已定义的类型
                    if((ie.Current is null) 
                    || (ie.Current is int)) 
                    || (ie.Current is WaitForSeconds))
                    
                        // 继续指定执行时机
                    
                    else
                    
                        list.RemoveAt(i);
                    
                
                else
                
                    list.RemoveAt(i);
                
            
        
    

七、Unity协程的垃圾的来自何处

在我尚不了解协程的时候, 用协程制作了真炎幸魂的终极技能:八重火垣. 因在协程中循环使用协程, 在我顾忌协程是否有坏处的时候, 发现了这么一句话:

协程的坏处:
协程本质是迭代器,且是基于unity生命周期的,大量开启协程会引起gc

这句话对我小小的心灵造成了巨大的伤害:

"我的杰作刚出生就要宣判死刑了吗!!!!!  不要!!!!!!!   嘤嘤嘤~~~"

即使是现在的我, 依然不太能理解这句话. 一连串的问题从我脑海中冒出来了.

这GC是协程的问题还是大量的问题?

本质是迭代器是坏处? 基于unity声明周期是坏处? 大量开启是协程的问题不是使用者的问题?

同样的东西协程就比普通函数更容易GC吗?

不是只有引用类型会GC吗?

基于Unity生命周期就会GC吗?

垃圾到底在哪里?

垃圾的产生无法控制吗?

这前言不搭后语的一句话真是让人误会, 而且我还发现多处地方都引用了这句话, 却完全没人把这句话说明白.


疑惑存在, 实验开始.

1.当前版本的Unity使用协程是否会产生GC

实验代码:

public class TestMono : MonoBehaviour

    public int times = 100000;
    public bool useStr = true;

    void Start()
    
        for (int i = 0; i < times; i++)
        
            if (useStr)
                StartCoroutine("CoroutineFunc");
            else
                StartCoroutine(CoroutineFunc());
        
    

    IEnumerator CoroutineFunc()
    
        yield return null;
    

实验数据取 Unity Profiler 5秒内 GC Used Memory 最高点

项目/实验次数123456
不启动协程13.1M11.8M12.2M12.1M12.4M12.4M
传入字符串24.0M28.0M27.7M28.1M28.4M28.2M

传入IEnumerator

28.6M28.2M28.7M28.5M28.6M28.5M

小结: 使用协程的确会产生垃圾, 且两种方式产生的内存不相上下.

2. 协程的垃圾产生在迭代器还是调度器?

由于调度器调度不存在的函数会报错, 所以只能从迭代器入手进行测试.

实验代码:

public class TestMono : MonoBehaviour

    public int times = 100000;
    public bool useStr = true;


    // Start is called before the first frame update
    void Start()
    
        for (int i = 0; i < times; i++)
        
            if (useStr)
                enumerator = "CoroutineFunc";
            else
                CoroutineFunc();
        
    

    IEnumerator CoroutineFunc()
    
        yield return null;
    

项目/实验次数123456
不启动协程11.9M12.0M12.0M12.2M11.7M12.0M
传入字符串12.7M11.8M12.2M12.2M12.2M12.4M

传入IEnumerator

12.4M12.5M12.4M12.6M12.5M12.6M

小结: 通过对比一阶段的数据基本可以确定产生垃圾的主要位置在调度器. 但同时能发现迭代器对象也会造成微量的垃圾.

3. 是否只是单纯的使用调度器就会产生大量GC?

实验代码:

public class TestMono : MonoBehaviour

    public int times = 100000;
    private IEnumerator enumerator;


    // Start is called before the first frame update
    void Start()
    
        enumerator = CoroutineFunc();
        for (int i = 0; i < times; i++)
        
            StartCoroutine(enumerator);
        
    

    IEnumerator CoroutineFunc()
    
        yield return null;
    

项目/实验次数123456
不启动协程17.5M15.9M16.5M16.1M16.5M16.6M

传入IEnumerator

17.4M17.0M17.5M16.9M17.4M17.5M

小结: 只是单纯的调用StartCoroutine并不会产生大量垃圾, 那么第一轮的垃圾应该是一个可以遍历的迭代器和调度器共同作用产生的. 可惜IEnumerator禁止使用Reset, 不然可以测试的更全面.

结论

使用协程时会产生垃圾, 且此垃圾不可控制.

原因: 协程函数的调用会实例化一个接口对象, 而接口是引用类型.

         StartCoroutine对协程的调用会不可避免的产生较多垃圾.

         迭代器对象无法Reset, 想要重复执行相同逻辑只能再次创建迭代器对象.

以上是关于Unity-协程详解的主要内容,如果未能解决你的问题,请参考以下文章

Unity Coroutine详解

Unity怎么暂停协程✨Unity协程管理方案

Unity怎么暂停协程✨Unity协程管理方案

在foreach循环Unity3D C#中的协程

unity中协程函数没有错误为啥会报错

unity 协程怎么 return