|
一、【Unity3D】协程Coroutine的运用
首先,在Unity3D的Update()的函数是不能被打断的,也就是说如下代码,如果绑定在任何一个对象上面,你的游戏将会被卡死,只能Ctrl+Alt+Delete立即结束了:
using UnityEngine;using System.Collections; public class MyCoroutine : MonoBehaviour{ private int a; void Start() { a = 0; } void Update() { Debug.Log("0"); while (a == 0) { //去做些事情,然后做完让a!=0。 } Debug.Log("1"); } }
本来我是打断,Update()函数你等我一下,然后处理一些事情,你读下面的代码,而我做完这些事情的标志就是让a不等于0。
可惜事与愿违,且不说Update()函数,每帧都被读取,也就说时刻在执行里面的代码,这个机制。单单是Unity3d是要读完Update()函数的代码,才会给你刷新一帧这个机制,已经足以让这游戏瞬间崩溃。因此,也启发了我,Update()尽可能地不要扔些循环给它做,里面顶多就放些条件判断好了,这样你的游戏才会流畅,才是所谓的“优化好”。
那么,爷确实有些比较耗时的任务,这怎办?那就通通开个子线程——协程Coroutine,别都写在主线程,Update()函数。
1.延迟执行某段代码
using UnityEngine;using System.Collections; public class MyCoroutine : MonoBehaviour{ void Start() { Debug.Log("0"); Debug.Log("1"); StartCoroutine(Thread1()); } void Update() { } IEnumerator Thread1() { yield return new WaitForSeconds(3.0f); Debug.Log("2"); }}
yield return new WaitForSeconds(3.0f);这一句就是中断这线程3秒的意思,也就是在这行停3秒。并且,中断线程的语句,只能写在IEnumerator Thread1(){}这些协程里面,而不能写在Update()里面,因为Update()这个主线程根本不能被中断。
而开子线程Thread1,或者按照Unity3d的术语,应该说是 开协程Thread1的语句StartCoroutine(Thread1());应该放在只在开始执行一次的Start()里面,不然在Update()每帧都执行一次,子线程Thread1里面的程度,得开多少次啊?
另外,IEnumerator Thread1(){}在读完所有代码,自动死亡,会被系统的线程回收机制自动回收,我们自管开线程就行,其余的不用管!
2.每隔几秒执行某段代码
如果我不想每帧都执行某些代码,而是比如想每1秒i+1,初始=0的i,i++到10即停止,这又怎么办呢?你可以这样写:
using UnityEngine;using System.Collections; public class MyCoroutine : MonoBehaviour{ private int i; void Start() { i = 0; StartCoroutine(Thread1()); } void Update() { } IEnumerator Thread1() { while (true) { Debug.Log("i=" + i); i++; if (i > 10) { break; } yield return new WaitForSeconds(1.0f); } }}
这一段也很好理解,就是在Thread1中上个死循环,但死循环里面的代码并不是这么好读,读到 yield return new WaitForSeconds(1.0f);就要停顿1秒。读其余代码的时间可以忽略不计,因此,协程Coroutine配合一个有条件break的死循环,可以做到每隔几秒执行某段代码的效果。
但还是那句话,这一切通通都只能写到协程IEnumerator Thread1()里面,因为Update()不能停顿,游戏和动画一样,都是每一帧不停被刷新的页面。
3.同步
比如我想执行完某段代码,立即执行另一段代码,做到回调的效果,那该怎么办呢?
当然最简单就是在写完一段代码,在下一行写另一段代码。可是,如果这些代码不是立即完成的,需要等待,就要用到协程的同步。
比如,协程1需要耗时X秒,我并不知道,而协程2则需要在协程1之后马上执行,这又该怎么办呢?你可以这样写:
using UnityEngine;using System.Collections; public class MyCoroutine : MonoBehaviour{ private int i; void Start() { i = 0; StartCoroutine(Thread1()); } void Update() { } IEnumerator Thread1() { while (true) { Debug.Log("i=" + i); i++; if (i > 3) { break; } yield return new WaitForSeconds(1.0f); } Debug.Log("线程1已经完成了"); StartCoroutine(Thread2()); } IEnumerator Thread2() { Debug.Log("线程2开始"); yield return null;//这句必须有,C#要求每个协程都要有yield return //,虽然这句话看起来并没有什么卵用,但你就是要写-_-! }}二、对yield return的理解
下面来看看两段显示人物对话的代码(对话随便复制了一段内容),功能是一样的,但是方法不一样:
1 using UnityEngine; 2 using System.Collections; 3 4 public class dialog_easy : MonoBehaviour { 5 public string dialogStr = "yield return的作用是在执行到这行代码之后,将控制权立即交还给外部。yield return之后的代码会在外部代码再次调用MoveNext时才会执行,直到下一个yield return——或是迭代结束。虽然上面的代码看似有个死循环,但事实上在循环内部我们始终会把控制权交还给外部,这就由外部来决定何时中止这次迭代。有了yield之后,我们便可以利用“死循环”,我们可以写出含义明确的“无限的”斐波那契数列。"; 6 public float speed = 5.0f; 7 8 private float timeSum = 0.0f; 9 private bool isShowing = false;10 // Use this for initialization11 void Start () {12 ShowDialog();13 }14 15 // Update is called once per frame16 void Update () {17 if(isShowing){18 timeSum += speed * Time.deltaTime;19 guiText.text = dialogStr.Substring(0, System.Convert.ToInt32(timeSum));20 21 if(guiText.text.Length == dialogStr.Length)22 isShowing = false;23 }24 }25 26 void ShowDialog(){27 isShowing = true;28 timeSum = 0.0f;29 }30 }
这段代码实现了在GUIText中逐渐显示一个字符串的功能,速度为每秒5个字,这也是新手常用的方式。如果只是简单的在GUIText中显示一段文字,ShowDialog()函数可以做的很好;但是如果要让字一个一个蹦出来,就需要借助游戏的循环了,最简单的方式就是在Update()中更新GUIText。
从功能角度看,这段代码完全没有问题;但是从代码封装性的角度来看,这是一段很恶心的代码,因为本应由ShowDialog()完成的功能放到了Update()中,并且在类中还有两个private变量为这个功能服务。如果将来要修改或者删除这个功能,需要在ShowDialog()和Update()中修改,并且还可能修改那两个private变量。现在代码比较简单,感觉还不算太坏,一旦Update()中再来两个类似的的功能,估计写完代码一段时间之后自己修改都费劲。
如果通过yield return null实现帧与帧之间的同步,则代码优雅了很多:
1 using UnityEngine; 2 using System.Collections; 3 4 public class dialog_easy : MonoBehaviour { 5 public string dialogStr = "yield return的作用是在执行到这行代码之后,将控制权立即交还给外部。yield return之后的代码会在外部代码再次调用MoveNext时才会执行,直到下一个yield return——或是迭代结束。虽然上面的代码看似有个死循环,但事实上在循环内部我们始终会把控制权交还给外部,这就由外部来决定何时中止这次迭代。有了yield之后,我们便可以利用“死循环”,我们可以写出含义明确的“无限的”斐波那契数列。"; 6 public float speed = 5.0f; 7 8 // Use this for initialization 9 void Start () {10 StartCoroutine(ShowDialog());11 }12 13 // Update is called once per frame14 void Update () {15 }16 17 IEnumerator ShowDialog(){18 float timeSum = 0.0f;19 while(guiText.text.Length < dialogStr.Length){20 timeSum += speed * Time.deltaTime;21 guiText.text = dialogStr.Substring(0, System.Convert.ToInt32(timeSum));22 yield return null;23 }24 }25 }
相关代码都被封装到了ShowDialog()中,这么一来,不论是要增加、修改或删除功能,都变得容易了很多。根据官网手册的描述,yield return null可以让这段代码在下一帧继续执行。在ShowDialog()中,每次更新文字以后yield return null,直到这段文字被完整显示。看到这里,可能有童鞋不解:
为什么在协程中也可以用Time.deltaTime?协程中的Time.deltaTime和Update()中的一样吗?这样使用协程,会不会出现与主线程访问共享资源冲突的问题?(线程的同步与互斥问题)yield return null太神奇了,为什么会在下一帧继续执行这个函数?这段代码是不是相当于为ShowDialog()构造了一个自己的Update()?
参考Unity协程(Coroutine)原理深入剖析协程不是线程,也不是异步执行的。协程和 MonoBehaviour 的 Update函数一样也是在MainThread中执行的。使用协程你不用考虑同步和锁的问题。
协程其实就是一个IEnumerator(迭代器),IEnumerator 接口有两个方法 Current 和 MoveNext() ,迭代器方法运行到 yield return 语句时,会返回一个expression表达式并保留当前在代码中的位置。 当下次调用迭代器函数时执行从该位置重新启动。unity3d在每帧做的工作就是:调用协程(迭代器)MoveNext() 方法,如果返回 true ,就从当前位置继续往下执行。 协程和Update()一样更新,自然可以使用Time.deltaTime了,而且这个Time.deltaTime和在Update()当中使用是一样的效果(使用yield return null的情况下)协程并不是多线程,它和Update()一样是在主线程中执行的,所以不需要处理线程的同步与互斥问题yield return null其实没什么神奇的,只是unity3d封装以后,这个协程在下一帧就被自动调用了可以理解为ShowDialog()构造了一个自己的Update(),因为yield return null让这个函数每帧都被调用了
三、Unity StartCoroutine 和 yield return 深入研究
public class MainTest : MonoBehaviour{ // Start is called before the first frame update void Start() { Debug.Log("start1"); StartCoroutine(Test()); Debug.Log("start2"); } IEnumerator Test() { Debug.Log("test1"); yield return null; Debug.Log("test2"); }
运行结果是:
start1test1start2test2
当StartCoroutine刚调用的时候,可以理解为正常的函数调用,然后接着看调用的函数里面。当被调用函数执行到yield return null;(暂停协程,等待下一帧继续执行)时,根据Unity解释协同程序就会被暂停,其实我个人认为他这个解释不够精确,先返回开始协程的地方,然后再暂停协程。也就是先通知调用处,“你先走吧,不用管我”,然后再暂停协程。。这里如果把yeild return null改为yield return new WaitForSeconds(3);就可以看到test2是3秒之后才打印出来的。
四、Unity协程(一):彻底了解yield return null 和 yield return new WaitForSeconds
WaitForEndOfFrame,顾名思义是在等到本帧的帧末进行在进行处理
yield return null表示暂缓一帧,在下一帧接着往下处理,也有人习惯写成yield return 0或者yield return 1,于是误区就随之而来了,很多同学误认为yield return后面的数字表示的是帧率,比如yield return 10,表示的是延缓10帧再处理,实则不然,yield return num;的写法其实后面的数字是不起作用的,不管为多少,表示都是在下一帧接着处理。
yield return new WaitForSeconds,这个要注意的是1·实际时间等于给定的时间乘以Time.timeScale的值。2·触发间隔一定大等于1中计算出的实际时间,而且误差的大小取决于帧率,因为它是在每帧处理协程的时候去计算时间间隔是否满足条件,如果满足则继续执行。例如,当帧率为5的情况下,一帧的时间为200ms,这时即使时间参数再小,最快也要200ms之后才能继续执行剩余部分。
image.png
这是一张关于MonoBehaviour的执行顺序图关于协程的部分,由图可见,yield 是在yield WaitForSeconds之前处理的,再结合上段的分析可以得出一个结论:在同一帧里执行的两个协程,不论先后关系如何,不论WaitForSeconds给定的值为多少,yield return null所在的协程都要比yield return new WaitForSeconds的协程更先执行。同类型的协程则跟其开启的先后顺序相关
最后再提个点,yield return null和yield return new WaitForSeconds协程最好别一起混着用,特别是同时开启的这两个协程还有相互依赖的关系,因为帧率是不稳定的,所以有可能引起某些非必现的bug。
五、Unity 协程原理探究与实现
IEnumerator TestCoroutine(){ yield return null; //返回内容为null yield return 1; //返回内容为1 yield return "sss"; //返回内容为"sss" yield break; //跳出,类似普通函数中的return语句 yield return 999; //由于break语句,该内容无法返回}void Start(){ IEnumerator e = TestCoroutine(); while (e.MoveNext()) { Debug.Log(e.Current); //依次输出枚举接口返回的值 }}/* 枚举接口的定义public interface IEnumerator{ object Current { get; } bool MoveNext(); void Reset();}*//*运行结果:Null1sss*/
首先注意注释部分枚举接口的定义
Current属性为只读属性,返回枚举序列中的当前位的内容
MoveNext()把枚举器的位置前进到下一项,返回布尔值,新的位置若是有效的,返回true;否则返回false
Reset()将位置重置为原始状态
再看下Start函数中的代码,就是将yield return 语句中返回的值依次输出。
第一次MoveNext()后,Current位置指向了yield return 返回的null,该位置是有效的(这里注意区分位置有效和结果有效,位置有效是指当前位置是否有返回值,即使返回值是null;而结果有效是指返回值的结果是否为null,显然此处返回结果是无意义的)所以MoveNext()返回值是true;
第二次MoveNext()后,Current新位置指向了yield return 返回的1,该位置是有效的,MoveNext()返回true
第三次MoveNext()后,Current新位置指向了yield return 返回的"sss",该位置也是有效的,MoveNext()返回true
第四次MoveNext()后,Current新位置指向了yield break,无返回值,即位置无效,MoveNext()返回false,至此循环结束
先来回顾下Unity的协程具体有些功能:
将协程代码中由yield return语句分割的部分分配到每一帧去执行。yield return 后的值是等待类(WaitForSeconds、WaitForFixedUpdate)时需要等待相应时间。yield return 后的值还是协程(Coroutine)时需要等待嵌套部分协程执行完毕才能执行接下来内容。
1.分帧
实现分帧执行之前,先将上述迭代器的代码简单修改下,看下输出结果
IEnumerator TestCoroutine(){ Debug.Log("TestCoroutine 1"); yield return null; Debug.Log("TestCoroutine 2"); yield return 1;}void Start(){ IEnumerator e = TestCoroutine(); while (e.MoveNext()) { Debug.Log(e.Current); //依次输出枚举接口返回的值 }}/*运行结果TestCoroutine 1NullTestCoroutine 21*/
前面有说过,每次MoveNext()后会返回yield return后的内容,那yield return之前的语句怎么办呢?
当然也执行啊,遇到yield return语句之前的内容都会在MoveNext()时执行的。
到这里应该很清楚了,只要把MoveNext()移到每一帧去执行,不就实现分帧执行几段代码了么!
既然要分配在每一帧去执行,那当然就是Update和LateUpdate了。这里我个人喜欢将实现代码放在LateUpdate之中,为什么呢?因为Unity中协程的调用顺序是在Update之后,LateUpdate之前,所以这两个接口都不够准确;但在LateUpdate中处理,至少能保证协程是在所有脚本的Update执行完毕之后再去执行。
image.png
IEnumerator e = null;void Start(){ e = TestCoroutine();}void LateUpdate(){ if (e != null) { if (!e.MoveNext()) { e = null; } }}IEnumerator TestCoroutine(){ Log("Test 1"); yield return null; //返回内容为null Log("Test 2"); yield return 1; //返回内容为1 Log("Test 3"); yield return "sss"; //返回内容为"sss" Log("Test 4"); yield break; //跳出,类似普通函数中的return语句 Log("Test 5"); yield return 999; //由于break语句,该内容无法返回}void Log(object msg){ Debug.LogFormat("<color=yellow>[{0}]</color>{1}", Time.frameCount, msg.ToString());}
image.png
再来看看运行结果,黄色中括号括起来的数字表示当前在第几帧,很明显我们的协程完成了每一帧执行一段代码的功能。
2.延时等待
要是完全理解了case1的内容,相信你自己就能完成“延时等待”这一功能,其实就是加了个计时器的判断嘛!
既然要识别自己的等待类,那当然要获取Current值根据其类型去判定是否需要等待。假如Current值是需要等待类型,那就延时到倒计时结束;而Current值是非等待类型,那就不需要等待,直接MoveNext()执行后续的代码即可。
这里着重说下“延时到倒计时结束”。既然知道Current值是需要等待的类型,那此时肯定不能在执行MoveNext()了,否则等待就没用了;接下来当等待时间到了,就可以继续MoveNext()了。可以简单的加个标志位去做这一判断,同时驱动MoveNext()的执行。
private void OnGUI(){ if (GUILayout.Button("Test")) //注意:这里是点击触发,没有放在start里,为什么? { enumerator = TestCoroutine(); }}void LateUpdate(){ if (enumerator != null) { bool isNoNeedWait = true, isMoveOver = true; var current = enumerator.Current; if (current is MyWaitForSeconds) { MyWaitForSeconds waitable = current as MyWaitForSeconds; isNoNeedWait = waitable.IsOver(Time.deltaTime); } if (isNoNeedWait) { isMoveOver = enumerator.MoveNext(); } if (!isMoveOver) { enumerator = null; } }}IEnumerator TestCoroutine(){ Log("Test 1"); yield return null; //返回内容为null Log("Test 2"); yield return 1; //返回内容为1 Log("Test 3"); yield return new MyWaitForSeconds(2f); //等待两秒 Log("Test 4");}
image.png
运行结果里黄色表示当前帧,青色是当前时间,很明显等待了2秒(虽然有少许误差但总体不影响)。
上述代码中,把函数触发放在了Button点击中而不是Start函数中?
这是因为我是用Time.deltaTime去做计时,假如放在了Start函数中,Time.deltaTime会受Awake这一帧执行时间影响,时间还不短(我测试时有0.1s左右),导致运行结果有很大误差,不到2秒就结束了,有兴趣的可以自己试一下~
六、从 各种点 理解Unity中的协程
什么是协同程序?什么是协程?
unity协程是一个能够暂停协程执行,暂停后立即返回主函数,执行主函数剩余的部分,直到中断指令完成后,从中断指令的下一行继续执行协程剩余的函数。函数体全部执行完成,协程结束。
由于中断指令的出现,使得可以将一个函数分割到多个帧里去执行。
性能:
在性能上相比于一般函数没有更多的开销
协程的好处:
让原来要使用异步 + 回调方式写的非人类代码, 可以用看似同步的方式写出来。
能够分步做一个比较耗时的事情,如果需要大量的计算,将计算放到一个随时间进行的协程来处理,能分散计算压力
协程的坏处:
协程本质是迭代器,且是基于unity生命周期的,大量开启协程会引起gc
如果同时激活的协程较多,就可能会出现多个高开销的协程挤在同一帧执行导致的卡帧
协程书写时的性能优化:
常见的问题是直接new 一个中断指令,带来不必要的 GC 负担,可以复用一个全局的中断指令对象,优化掉开销;在 Yielders.cs 这个文件里,已经集中地创建了上面这些类型的静态对象
这个链接分析了一下https://blog.csdn.net/liujunjie612/article/details/70623943
协程是在什么地方执行?
协程不是线程,不是异步执行;协程和monobehaviour的update函数一样也是在主线程中执行
unity在每一帧都会处理对象上的协程,也就是说,协程跟update一样都是unity每帧会去处理的函数
经过测试,协程至少是每帧的lateUpdate后运行的。
协程怎么结束?
方法一:StopCoroutine(string methodName);
方法二:stopAllCoroutines暂停的是当前脚本下的所有协程
方法三:gameObject.active = false 可以停止该对象上全部协程的执行,即使再次激活,也不能继续执行。但注意MonoBehaviour enabled = false 不能停止协程;对比 update却是可以在MonoBehaviour enabled = false 就中止
原因:由于协程在StartCoroutine时被注册到的GameObject上,他的生命期受限于GameObject的生命期,因此受GameObject是否active的影响。
结论:协程虽然是在MonoBehvaviour启动的(StartCoroutine)但是协程函数的地位完全是跟MonoBehaviour是一个层次的,不受MonoBehaviour的状态影响。
协程结束的标志是什么?
如果最后一个 yield return 的 IEnumerator 已经迭代到最后一个是,MoveNext 就会 返回 false 。这时,Unity就会将这个 IEnumerator 从 cortoutines list 中移除。
只有当这个对象的 MoveNext() 返回 false 时,即该 IEnumertator 的 Current 已经迭代到最后一个元素了,才会执行 yield return 后面的语句。
中断函数类型:
null 在下一帧所有的Update()函数调用过之后执行
WaitForSeconds() 等待指定秒数,在该帧(延迟过后的那一帧)所有update()函数调用完后执行。即等待给定时间周期, 受Time.timeScale影响,当Time.timeScale = 0f 时,yield return new WaitForSecond(x) 将不会满足。
WaitForFixedUpdate 等待一个固定帧,即等待物理周期循环结束后执行
WaitForEndOfFrame 等待帧结束,即等待渲染周期循环结束后执行
StartCoroutine 等待一个新协程暂停
WWW 等待一个加载完成,等待www的网络请求完成后,isDone=true后执行
协程的执行顺序:
开始协程->执行协程->遇到中断指令中断协程->返回上层函数继续执行上层函数的下一行代码->中断指令结束后,继续执行中断指令之后的代码->协程结束
协程可以嵌套协程吗?
可以,yield return StartCoroutine就是,执行顺序是:
子协程中断后,会返回父协程,父协程暂停,返回父协程的上级函数。
决定父协程结束的标志是子协程是否结束,当子协程结束后返回父协程执行其后的代码才算结束。
同一时刻同一脚本实例中能有多少个运行的协程?
在一个MonoBehaviour提供的主线程里只能有一个处于运行状态的协程。因为协程不是线程,不是并行的。同一时刻、一个脚本实例中可以有多个暂停的协程,但只有一个运行着的协程
协程和线程的区别?
线程是利用多核达到真正的并行计算,缺点是会有大量的锁、切换、等待的问题,而协程是非抢占式,需要用户自己释放使用权来切换到其他协程, 因此同一时间其实只有一个协程拥有运行权, 相当于单线程的能力。
协程是 C# 线程的替代品, 是 Unity 不使用线程的解决方案。
使用协程不用考虑同步和锁的问题
多个协程可以同时运行,它们会根据各自的启动顺序来更新
其他注意点:
1、IEnumerator 类型的方法不能带 ref 或者 out 型的参数,但可以带被传递的引用
2、在函数 Update 和 FixedUpdate 中不能使用 yield 语句,否则会报错, 但是可以启动协程
3、在一个协程中,StartCoroutine()和 yield return StartCoroutine()是不一样的。
前者仅仅是开始一个新的Coroutine,这个新的Coroutine和现有Coroutine并行执行。
后者是返回一个新的Coroutine,是一个中断指令,当这个新的Coroutine执行完毕后,才继承执行现有Coroutine。
七、实现自己的WaitForSeconds
在Unity中StartCoroutine/yield return这个模式到底是怎么应用的?其中的原理是什么?
Coroutine,你究竟干了什么?
Coroutine,你究竟干了什么?(小续)
WaitForSeconds本身是一个普通的类型,但是在StartCoroutine中,其被特殊对待了,一般而言,StartCoroutine就是简单的对某个IEnumerator 进行MoveNext()操作,但如果他发现IEnumerator其实是一个WaitForSeconds类型的话,那么他就会进行特殊等待,一直等到WaitForSeconds延时结束了,才进行正常的MoveNext调用,而至于WWW或者WaitForFixedUpdate等类型,StartCoroutine也是同样的特殊处理,如果用代码表示一下的话,大概是这个样子:
foreach(IEnumerator coroutine in coroutines){ if(!coroutine.MoveNext()) // This coroutine has finished continue; if(!coroutine.Current is YieldInstruction) { // This coroutine yielded null, or some other value we don't understand; run it next frame. continue; } if(coroutine.Current is WaitForSeconds) { // update WaitForSeconds time value } else if(coroutine.Current is WaitForEndOfFrame) { // this iterator will MoveNext() at the end of the frame } else /* similar stuff for other YieldInstruction subtypes or WWW etc. */}2.嵌套
IEnumerator UnityCoroutine() { Debug.Log("Unity coroutine begin at time : " + Time.time); yield return new WaitForSeconds(2); yield return StartCoroutine(InnerUnityCoroutine()); Debug.Log("Unity coroutine end at time : " + Time.time); } IEnumerator InnerUnityCoroutine() { Debug.Log("Inner Unity coroutine begin at time : " + Time.time); yield return new WaitForSeconds(2); Debug.Log("Inner Unity coroutine end at time : " + Time.time); } void Start() { StartCoroutine(UnityCoroutine()); }
image.png
“外层”的UnityCoroutine只有在“内层”的InnerUnityCoroutine“执行”完毕之后才会继续“执行” |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|