本文包含兩個部分,前半部分是通俗解釋一下Unity中的協程,后半部分講講C#的IEnumerator迭代器
協程是什么,能干什么?
為了能通俗的解釋,我們先用一個簡單的例子來看看協程可以干什么
首先,我突發奇想,要實現一個倒計時器,我可能是這樣寫的:
public class CoroutineTest : MonoBehaviour
{
public float sumtime = 3;
void Update()//Update是每幀調用的
{
{
sumtime -= Time.deltaTime;
if (sumtime <= 0)
Debug.Log("Done!");
}
}
}
我們知道,寫進 Update() 里的代碼會被每幀調用一次,
所以,讓總時間sumtime在Update()中每一幀減去一個增量時間Time.deltaTime(可以理解成幀與幀的間隔時間)就能實現一個簡單的倒計時器
但是,當我們需要多個獨立的計時器時,用同樣的思路,我們的代碼可能就會寫成這樣:
public class CoroutineTest : MonoBehaviour
{
public float sumtime1 = 3;
public float sumtime2 = 2;
public float sumtime3 = 1;
void Update()
{
sumtime1 -= Time.deltaTime;
if (sumtime1 <= 0)
Debug.Log("timer1 Done!");
sumtime2 -= Time.deltaTime;
if (sumtime2 <= 0)
Debug.Log("timer2 Done!");
sumtime3 -= Time.deltaTime;
if (sumtime3 <= 0)
Debug.Log("timer3 Done!");
}
}
重復度很高,計時器越多看的越麻煩
然后有朋友可能會提到,我們是不是可以用一個循環來解決這個問題
for (float sumtime = 3; sumtime >= 0; sumtime -= Time.deltaTime)
{
//nothing
}
Debug.Log("This happens after 5 seconds");
現在每一個計時器變量都成為for循環的一部分了,這看上去好多了,而且我不需要去單獨設置每一個跌倒變量。
但是
但是
但是
我們知道Update() 是每幀調用一次的,我們不能把這個循環直接寫進Update() 里,更不能寫一個方法在Update() 里調用,因為這相當於每幀開啟一個獨立的循環
那么有沒有辦法,再Update()這個主線程之外再開一個單獨的線程,幫我們管理這個計時呢?
好了,你可能知道我想說什么了,我們正好可以用協程來干這個
先來看一段簡單的協程代碼
public class CoroutineTest : MonoBehaviour
{
void Start()
{
StartCoroutine(Count3sec());
}
IEnumerator Count3sec()
{
for (float sumtime = 3; sumtime >= 0; sumtime -= Time.deltaTime)
yield return 0;
Debug.Log("This happens after 3 seconds");
}
}
你很可能看不懂上面的幾個關鍵字,但不急,我們一個個解釋上面的代碼干了什么
StartCoroutine(Count3sec());
這一句用來開始我們的Count3sec方法
然后你可能想問的是
- IEnumerator 是什么?返回值是什么?
- For循環中的yield return是什么?
理解以下的話稍有難度,但暫時理解不了問題也不大
詳細的講:
IEnumerator 是C#的一個迭代器,你可以把它當成指向一個序列的某個節點的指針,C#提供了兩個重要的接口,分別是Current(返回當前指向的元素)和 MoveNext()(將指針向前移動一個單位,如果移動成功,則返回true)
通常,如果你想實現一個接口,你可以寫一個類,實現成員,等等。迭代器塊(iterator block) 是一個方便的方式實現IEnumerator,你只需要遵循一些規則,並實現IEnumerator由編譯器自動生成。
一個迭代器塊具備如下特征:
- 返回IEnumerator
- 使用yield關鍵字
那么yield關鍵字是干嘛的?它用來聲明序列中的下一個值,或者一個無意義的值。如果使用yield x(x是指一個具體的對象或數值)的話,那么movenext返回為true並且current被賦值為x,如果使用yield break使得movenext()返回false(停止整個協程)
看不太懂?問題不大
簡單來說:
你現在只需要理解,上面代碼中,IEnumerator類型的方法Count3sec就是一個協程,並且可以通過yield關鍵字控制協程的運行
一個協程的執行,可以在任何地方用yield語句來暫停,yield return的值決定了什么時候協程恢復執行。通俗點講,當你“yield”一個方法時,你相當於對這個程序說:“現在停止這個方法,然后在下一幀中,從這里重新開始!”
yield return 0;
然后你可能會問,yield return后面的數字表示什么?比如yield return 10,是不是表示延緩10幀再處理?
並不!
並不!
並不!
yield return 0表示暫緩一幀,也就是讓你的程序等待一幀,再繼續運行。(不一定是一幀,下面會講到如何控制等待時間)就算你把這個0換成任意的int類型的值,都是都是表示暫停一幀,從下一幀開始執行
它的效果類似於主線程單獨出了一個子線程來處理一些問題,而且性能開銷較小
現在你大致學會了怎么開啟協程,怎么寫協程了,來看看我們還能干點什么:
IEnumerator count5times()
{
yield return 0;
Debug.Log("1");
yield return 0;
Debug.Log("2");
yield return 0;
Debug.Log("3");
yield return 0;
Debug.Log("4");
yield return 0;
Debug.Log("5");
}
在這個協程中,我們每隔一幀輸出了一次Hello,當然你也可以改成一個循環
IEnumerator count5times()
{
for (int i = 0; i < 5; i++)
{
Debug.Log("i+1");
yield return 0;
}
}
重點來了,有意思的是,你可以在這里加一個記錄始末狀態的變量:
public class CoroutineTest : MonoBehaviour
{
bool isDone = false;
IEnumerator count5times()
{
Debug.Log(isDone);
for (int i = 0; i < 5; i++)
{
Debug.Log("i+1");
yield return 0;
}
isDone = true;
Debug.Log(isDone);
}
void Start()
{
StartCoroutine(count5times());
}
}
很容易看得出上面的代碼實現了什么,也就就是我們一開始的需求,計時器
這個協程方法突出了協程一個“非常有用的,和Update()不同的地方:方法的狀態能被存儲,這使得方法中定義的這些變量(比如isUpdate)都會保存它們的值,即使是在不同的幀中
再修改一下,就是一個簡單的協程計時器了
public class CoroutineTest : MonoBehaviour
{
IEnumerator countdown(int count, float frequency)
{
Debug.Log("countdown START!");
for (int i = 0; i < count; i++)
{
for (float timer = 0; timer < frequency; timer += Time.deltaTime)
yield return 0;
}
Debug.Log("countdown DONE!");
}
void Start()
{
StartCoroutine(countdown(5, 1.0f));
}
}
在上面的例子我們也能看出,和普通方法一樣,協程方法也可以帶參數

你甚至可以通過yield一個WaitForSeconds()更方便簡潔地實現倒計時
協程計時器
public class CoroutineTest : MonoBehaviour
{
IEnumerator countdown(float sec)//參數為倒計時時間
{
Debug.Log("countdown START!");
yield return new WaitForSeconds(sec);
Debug.Log("countdown DONE!");
}
void Start()
{
StartCoroutine(countdown(5.0f));
}
}
好了,可能你已經注意到了,yield的用法還是很多的
在此之前,我們之前的代碼yield的時候總是用0(或者可以用null),這僅僅告訴程序在繼續執行前等待下一幀。現在你又學會了用yield return new WaitForSeconds(sec)來控制等待時間,你已經可以做更多的騷操作了!
協程另外強大的一個功能就是,你甚至可以yeild另一個協程,也就是說,你可以通過使用yield語句來相互嵌套協程,
public class CoroutineTest : MonoBehaviour
{
IEnumerator SaySomeThings()
{
Debug.Log("The routine has started");
yield return StartCoroutine(Wait(1.0f));
Debug.Log("1 second has passed since the last message");
yield return StartCoroutine(Wait(2.5f));
Debug.Log("2.5 seconds have passed since the last message");
}
IEnumerator Wait(float waitsec)
{
for (float timer = 0; timer < waitsec; timer += Time.deltaTime)
yield return 0;
}
void Start()
{
StartCoroutine(SaySomeThings());
}
}
yield return StartCoroutine(Wait(1.0f));
這里的Wait指的是另一個協程,這相當於是說,“暫停執行本程序,等到直到Wait協程結束”
協程控制對象行為
根據我們上面講的特性,協程還能像創建計時器一樣方便的控制對象行為,比如物體運動到某一個位置
IEnumerator MoveToPosition(Vector3 target)
{
while (transform.position != target)
{
transform.position = Vector3.MoveTowards(transform.position, target, moveSpeed * Time.deltaTime);
yield return 0;
}
}
我們還可以讓上面的程序做更多,不僅僅是一個指定位置,還可以通過數組來給它指定更多的位置,然后通過MoveToPosition() ,可以讓它在這些點之間持續運動。
我們還可以再加入一個bool變量,控制在對象運動到最后一個點時是否要進行循環
再把上文的Wait()方法加進來,這樣就能讓我們的對象在某個點就可以選擇是否暫停下來,停多久,就像一個正在巡邏的守衛一樣 (這里沒有實現,各位讀者可以嘗試自己寫一個)
public class CoroutineTest : MonoBehaviour
{
public Vector3[] path;
public float moveSpeed;
void Start()
{
StartCoroutine(MoveOnPath(true));
}
IEnumerator MoveOnPath(bool loop)
{
do
{
foreach (var point in path)
yield return StartCoroutine(MoveToPosition(point));
}
while (loop);
}
IEnumerator MoveToPosition(Vector3 target)
{
while (transform.position != target)
{
transform.position = Vector3.MoveTowards(transform.position, target, moveSpeed * Time.deltaTime);
yield return 0;
}
}
IEnumerator Wait(float waitsec)
{
for (float timer = 0; timer < waitsec; timer += Time.deltaTime)
yield return 0;
}
}
yield其他
這里列舉了yield后面可以有的表達式
-
null,0,1,...... 暫緩一幀,下一幀繼續執行
-
WaitForEndOfFrame - the coroutine executes on the frame, after all of the rendering and GUI is complete 等待幀結束
-
WaitForFixedUpdate - causes this coroutine to execute at the next physics step, after all physics is calculated 等待一個固定幀
-
WaitForSeconds - causes the coroutine not to execute for a given game time period
-
WWW - waits for a web request to complete (resumes as if WaitForSeconds or null)
-
StartCoroutine(Another coroutine) - in which case the new coroutine will run to completion before the yielder is resumed 等待另一個協程暫停
值得注意的是 WaitForSeconds()受Time.timeScale影響,當Time.timeScale = 0f 時,yield return new WaitForSecond(x) 將不會滿足
停止協程
- StopCoroutine(string methodName);
- StopAllCoroutine();
- 設置gameobject的active為false時可以終止協同程序,但是再次設置為true后協程不會再啟動。
總結一下
協程就是:你可以寫一段順序代碼,然后標明哪里需要暫停,然后在指定在下一幀或者任意間后,系統會繼續執行這段代碼
當然,協程不是真多線程,而是在一個線程中實現的
通過協程我們可以方便的做出一個計時器,甚至利用協程控制游戲物體平滑運動
如果你剛接觸協程,我希望這篇博客能幫助你了解它們是如何工作的,以及如何來使用它們
深入講講IEnumerator
基礎迭代器IEnumerator
迭代器是C#中一個普通的接口類,類似於C++ iterator的概念,基礎迭代器是為了實現類似for循環 對指定數組或者對象 的 子元素 逐個的訪問而產生的。
public interface IEnumerator { object Current { get; } bool MoveNext(); void Reset(); }
以上是IEnumerator的定義
Current() 的實現應該是返回調用者需要的指定類型的指定對象。
MoveNext() 的實現應該是讓迭代器前進。
Reset() 的實現應該是讓迭代器重置未開始位置
就像上文提到的,C#提供了兩個重要的接口,分別是Current(返回當前指向的元素)和 MoveNext()(將指針向前移動一個單位,如果移動成功,則返回true)當然IEnumerator是一個interface接口,你不用擔心的具體實現
注意以上用的都是“應該是”,也就是說我們可以任意實現一個派生自” IEnumerator”類的3個函數的功能,但是如果不按設定的功能去寫,可能會造成被調用過程出錯,無限循環
一個簡單的例子,遍歷並打印一個字符串數組:
public string[] m_StrArray = new string[4];
就可以派生一個迭代器接口的子類
public class StringPrintEnumerator : IEnumerator
{
private int m_CurPt = -1;
private string[] m_StrArray;
public StringPrintEnumerator(string[] StrArray)
{
m_StrArray = StrArray;
}
///實現
public object Current
{
get
{
return m_StrArray[m_CurPt];
}
}
public bool MoveNext()
{
m_CurPt++;
if (m_CurPt == m_StrArray.Length)
return false;
return true;
}
public void Reset()
{
m_CurPt = -1;
}
///實現END
public static void Run()
{
string[] StrArray = new string[4];
StrArray[0] = "A";
StrArray[1] = "B";
StrArray[2] = "C";
StrArray[3] = "D";
StringPrintEnumerator StrEnum = new StringPrintEnumerator(StrArray);
while (StrEnum.MoveNext())
{
(string)ObjI = (string)StrEnum.Current;
Debug.Log(ObjI);
}
}
}
運行會依次輸出A B C D
但是如果:
不正確的實現Current(返回null,數組下表越界)執行到Debug.Log時候會報錯。
不正確地MoveNext(),可能會出現無限循環(當然如果邏輯上正需要這樣,也是正確的)
不正確地Reset(),下次再用同一個迭代器時候不能正確工作
所以這三個方法如何才是正確的實現,完全要根據由上層的調用者約定來寫
迭代器擴展應用foreach,IEnumerable
C#使用foreach語句取代了每次手寫while(StrEnum.MoveNext())進行遍歷
同時新定了一個接口類來包裝迭代器IEnumerator,也就是IEnumerable,定義為:
public interface IEnumerable { IEnumerator GetEnumerator(); }
IEnumerable和IEnumerator的區別
可以看到IEnumerable接口非常的簡單,只包含一個抽象的方法GetEnumerator(),它返回一個可用於循環訪問集合的IEnumerator對象。
IEnumerable的作用僅僅是需要派生類寫一個返回指定迭代器的實現方法,也就是說IEnumerable僅僅是IEnumerator的一個包裝而已。
那么返回的IEnumerator對象呢?它是一個真正的集合訪問器,沒有它,就不能使用foreach語句遍歷集合或數組,因為只有IEnumerator對象才能訪問集合中的項,才能進行集合的循環遍歷。
那么我們回到foreach
foreach
就像上面提到的,foreach需要的是一個定義了IEnumerator GetEnumerator()方法的對象,當然如果他是派生自IEnumerable對象那就更好了。
我們繼續寫上文的StringPrintEnumerator類
這里新定義他的IEnumerable派生類MyEnumerable
