文章目錄
-
-
- 一、Unity協程簡單回顧
- 二、Unity協程的分析
-
- 1. C#疊代器
- 2 遊戲循環
- 3. 協程實作的核心邏輯
- 三、協程的實作設計
-
- 1. 協程的實作設計
- 2. 協程類的執行邏輯
- 3. 疊代器棧在每一次MoveNext的運作流程圖
- 4. 兩個簡單類的實作思路
- 四、協程代碼實作
-
- 1. 輔助類Debug
- 2. CorotineEngine類
- 3. Coroutine類
- 4. WaitForSeconds類
- 5. WaitUntil 類
- 7. 測試結果
- 總結
-
在前面兩篇文章,我們了解了C#疊代器的基礎知識,分析了延遲執行的本質,并實作了兩個LINQ常用擴充,在這一篇裡,我将解析Unity中的協程功能并實作一個自己的協程功能
前置知識回顧:
C#疊代器的實作和應用(一)——基礎篇
C#疊代器的實作和應用(二)——延遲執行、流式處理與兩個基本LINQ擴充的實作
我在github上存放了一份完整的項目,有需要的也可以研究檢視,歡迎各路大佬朋友指正。
git連結
一、Unity協程簡單回顧
協程是Unity中非常好用和常用的功能之一,它利用C#的疊代器,在主線程中實作了一個并發的效果,雖然Unity的協程和一些其他語言中提供的協程在使用上存在比較大的差别(但又哪有兩個完全一樣的協程呢,lua和golang這兩個語言原生的協程使用也存在差別)。
我們這裡編寫幾個最簡單的協程。
public class CoroutineTest : MonoBehaviour {
void Start () {
StartCoroutine(TestNull());
StartCoroutine(TestWaitUntil());
}
bool condition = false;
IEnumerator TestNull()
{
Debug.Log("[" + Time.time + "]\t" + "Test Null 1");
yield return null;
Debug.Log("[" + Time.time + "]\t" + "Test Null 2");
yield return TestWaitForSeconds();
}
IEnumerator TestWaitForSeconds()
{
Debug.Log("[" + Time.time + "]\t" + "TestWaitForSeconds : start WaitForSeconds");
yield return new WaitForSeconds(5);
condition = true;
Debug.Log("[" + Time.time + "]\t" + "TestWaitForSeconds : stop WaitForSeconds");
yield return 2;
}
IEnumerator TestWaitUntil()
{
Debug.Log("[" + Time.time + "]\t" + "start WaitUntil");
yield return new WaitUntil(() => condition);
Debug.Log("[" + Time.time + "]\t" + "stop WaitUntil");
}
-
方法中使用TestNull
中斷了一次,然後嵌套了yield return null
方法TestWaitForSeconds
-
方法中建立了一個TestWaitForSeconds
的協程,等待五秒,完成之後再将成員變量WaitForSeconds
的值變為true,最後再使用condition
傳回一個2yield return 2
-
方法建立了一個TestWaitUntil
協程,當WaitUntil
為true時進入下一步condition
-
方法是Unity自帶的“魔法函數”,在腳本初始化完成後被調用,在這個方法中使用Start
方法開啟了兩個協程。StartCoroutine
來檢視一下輸出結果:
很容易看到,WaitForSeconds啟動後,等待了五秒(注意start WaitForSeconds和stop WaitForSeconds的時間差異),之後
condition
被置為true,WaitUntil也測試通過了。但這個過程并不會導緻程式卡住,如果你在
update
方法中同樣進行log,會發現
update
方法中的log和協程中的log是同步的,整體的表現得就像是異步進行了這些操作一樣——但這些操作的寫法又是同步的,甚至連運作都是在主線程進行的。
如果對Unity的協程不熟悉,想了解關于Unity協程的更具體内容和使用方法,可以檢視官方手冊:協程;
二、Unity協程的分析
應該幾乎所有的unity開發者都會使用unity的協程,但它是如何實作的呢?
其實很簡單——兩個關鍵知識點:C#疊代器和遊戲循環
下面我将逐個拆解實作原理。
1. C#疊代器
因為這是關于C#疊代器的系列文章,是以其實在前兩篇文章中我就已經對C#的疊代器特點及應用作出了一些講解,而Unity協程的實作就依賴了在前兩篇文章中所講的知識:延遲處理和yiled 簡化疊代器編寫。
Unity的協程有兩種形式,一種是以
IEnumerator
為傳回值的方法,另一種是繼承了
IEnumerator
的類型,
IEnumerator
這個接口我們已經很很熟悉了,說白了,Unity的“協程”本質上就是一個疊代器。
2 遊戲循環
遊戲循環是幾乎所有遊戲都存在的核心元件,與普通應用不同的是,在整個遊戲過程中,遊戲循環是重中之重。
在Robert Nystrom寫的《遊戲程式設計模式》一書中就專門有一個講遊戲循環的章節,裡面有這樣一段話:
假如有哪個模式是本書最無法山羊的,那麼非遊戲循環模式莫屬。遊戲循環模式是遊戲程式設計模式中的精髓。幾乎所有的遊戲中都包含着它,無一雷同,相比而言那些非遊戲程式中卻難見它的身影。
同樣,在Jason Gregory著的《遊戲引擎架構》一書中,也專門留出了篇幅對遊戲循環進行講解。
不過吹了這麼多,遊戲循環到底是個什麼東西?
簡單來說,就是在遊戲啟動後的Main函數中運作的一個死循環,每一幀都進行一定時間的暫停,防止程序卡死,類似于下面這樣:
while (true)
{
ProcessInput();//檢測輸入
Update();//更新畫面
Render();//渲染畫面
Thread.Sleep(17);
}
而我們所有的代碼,都在這個循環中反複運作。
機智的小夥伴這時候一定意識到了,這個
Update
其實就是Unity裡
MonoBehaviour
類中的
Update
。
當然,實際的遊戲循環要更加複雜。下面這是Unity的
MonoBehaviour
生命周期圖中的一部分 (完整周期點選檢視),其中
Input events
、
Game logic
、
Scene rendering
、
Gizmo rendering
等等都是屬于遊戲循環中的一部分。
如果對遊戲循環感興趣,推薦閱讀我前面提到過的兩本書:
《遊戲程式設計模式》《遊戲引擎架構》
3. 協程實作的核心邏輯
那麼這個遊戲循環跟協程的實作有什麼關系呢?
我們把遊戲循環和疊代器合在一起看:
- 遊戲循環是一個具有在每一幀都進行一次調用的死循環
- C#疊代器是一個需要被多次調用以驅動運作并且擷取值的集合
- C#疊代器中可以封裝具體的操作,在每次疊代時被調用
- C#疊代器可以使用
來簡化封裝操作yield
有了這些條件,我們就可以做到使用遊戲循環來驅動疊代器 , 使用yield來編寫函數以自動封裝操作,也可以直接編寫具體的疊代器,通過傳入操作來篩選疊代器的結束條件。
三、協程的實作設計
理論到前面為止,下面我們可以開始實作自己的協程了——如果還沒有消化,記得再傳回去了解一下疊代器的功能特點哦!
1. 協程的實作設計
關于協程是如何運作的,在前面已經解析了,那麼我們來設計一下協程的具體運作方式。
- 在我們的設計裡,以
接口為核心,IEnumerator
和WaitUntil
兩個類均實作接口WaitForSeconds
作為協程進行調用;IEnumerator
-
和Coroutine
是我們的核心工具類, 其中CorotineEngine
是協程的包裝,Coroutine
是驅動CorotineEngine
的工具類;Coroutine
-
中包含一個CorotineEngine
方法,傳入一個疊代器,在這個方法中将使用StartCoroutine
對疊代器進行包裝,建立一個Coroutine
類型的變量并儲存在Corotutine
中;每一次coroutines
方法被調用,都将驅動CoroutineUpdate
運作;Coroutine
-
中一個傳回值為bool的Coroutine
成員函數,當MoveNext
的傳回值為false時,說明這個協程已經運作結束。MoveNext
下面是簡單的類圖
2. 協程類的執行邏輯
- 前面有提到,我們的核心執行工具類是
和Coroutine
,CorotineEngine
接近于Unity中CorotineEngine
的協程部分,它包含開始協程運作的MonoBehaviour
函數,并傳回一個StartCoroutine
執行個體;Coroutine
- 在每一次
的CorotineEngine
被調用時,所有Update
中的包裝器都會被驅動運作一次coroutines
,當MoveNext
函數的傳回值為false,那麼說明這個協程已經運作結束,這個協程将會被從清單中移除;MoveNext
-
類通過傳入一個疊代器進行建立,每一個Coroutine
執行個體都包含一個用于儲存目前運作的疊代器的棧,在疊代器被驅動運作時有以下幾種情況:Coroutine
- 如果目前疊代器棧為空,不包含任何疊代器,那麼此疊代器已運作結束,協程的
傳回MoveNext
false
- 如果目前疊代器棧最上層的疊代器的
傳回MoveNext
,那麼表明此疊代器已疊代結束,此時将這個疊代器從棧中彈出,再傳回第一個判斷false
- 如果目前疊代器棧最上層的疊代器的
傳回MoveNext
,并且true
也是一個疊代器,那麼将把這個疊代器壓入棧,再傳回第一個判斷,并且此協程的Current
傳回MoveNext
true
- 如果目前疊代器棧最上層的疊代器的
傳回MoveNext
,并且true
不是一個疊代器,那麼此協程的Current
傳回MoveNext
true
- 如果目前疊代器棧為空,不包含任何疊代器,那麼此疊代器已運作結束,協程的
3. 疊代器棧在每一次MoveNext的運作流程圖
4. 兩個簡單類的實作思路
-
WaitUntil
類
在構造函數中傳入一個傳回值為bool的lamda表達式并儲存,每次調用
時運作一次這個lamda表達式,需要注意的是,因為MoveNext為false時表示運作結束,是以要對并傳回這個值進行取反。MoveNext
-
WaitForSeconds
類
因為直接使用控制台程式運作 ,沒有Unity自帶的Time類,是以我使用一c#的
進行計時;DateTime
思路很簡單,在建立時傳入一個以秒為機關的時間,同時使用WaitUntil
來計算結束時間,之後在DateTime.Now.AddSeconds
MoveNext
中将目前時間與結束時間對比,如果目前時間超過了結束時間,那麼MoveNext傳回false,表示疊代結束;
在疊代器中current僅用于判斷是否需要壓棧,是以随意傳回一個引用類型的值即可——建議不要傳回一個值類型,因為會造成一次拆裝箱損耗。
解析到此結束,後面就是代碼啦。
四、協程代碼實作
1. 輔助類Debug
為了友善檢視時間,設計了Debug類用于Log
class Debug
{
public static void Log(string source, string format = "", params object[] args)
{
var f = string.Format("[{0}]\t[{1}] : {2}", DateTime.Now.ToString("HH:mm:ss fff"), source, format);
Console.WriteLine(f, args);
}
}
2. CorotineEngine類
我們的簡單引擎,有用于儲存協程的List、用于更新的Update方法、用于驅動協程的UpdateCoroutine方法、添加協程的StartCoroutine方法,簡單來說,可以把這個類看成MonoBehaviour中關于協程的那一部分。
class CoroutineEngine
{
public CoroutineEngine()
{
Debug.Log("CoroutineEngine", "setup");
}
List<Coroutine> coroutines = new List<Coroutine>();
public void Update()
{
//Debug.Log("CoroutineEngine", "Update");
}
public void CoroutineUpdate()
{
for (int i = 0; i < coroutines.Count; i++)
{
if (coroutines[i].MoveNext())
{
//Debug.Log("CoroutineEngine", "CoroutineUpdate");
}
else
{
Debug.Log("CoroutineEngine", "remove corotine : " + coroutines[i].Name);
coroutines.RemoveAt(i);
i--;
}
}
}
public void StartCoroutine(IEnumerator coroutine)
{
coroutines.Add(new Coroutine(coroutine));
}
}
3. Coroutine類
協程類,每一個被建立的協程都會使用一個Coroutine包裝起來,隻要反複調用Coroutine中的MoveNext方法就可以驅動疊代器進行疊代,這個類的MoveNext邏輯相對有一點複雜,我使用遞歸方式來實作,如果一下不了解,可以再回到前面檢視它的運作圖。
class Coroutine
{
public string Name { get; private set; }
public Coroutine(IEnumerator enumerator)
{
Name = enumerator.GetType().Name;
enumerators.Push(enumerator);
}
Stack<IEnumerator> enumerators = new Stack<IEnumerator>();
public bool MoveNext()
{
if (enumerators.Count == 0) return false;
return MoveNext(enumerators.Peek());
}
private bool MoveNext(IEnumerator it)
{
if(it.MoveNext())
{
if(it.Current is IEnumerator)
{
var next = it.Current as IEnumerator;
enumerators.Push(next);
MoveNext(next);
}
return true;
}
else
{
enumerators.Pop();
if (enumerators.Count == 0) return false;
return false || MoveNext(enumerators.Peek());
}
}
}
4. WaitForSeconds類
class WaitForSeconds : IEnumerator
{
private long targetTicks;
public WaitForSeconds(float seconds)
{
this.targetTicks = DateTime.Now.AddSeconds(seconds).Ticks;
}
public object Current => null;
public bool MoveNext()
{
return this.targetTicks > DateTime.Now.Ticks;
}
public void Reset()
{
}
}
5. WaitUntil 類
class WaitUntil : IEnumerator
{
Func<bool> condition;
public WaitUntil(Func<bool> condition)
{
if (null == condition) throw new ArgumentNullException("WaitUntil condition is null");
this.condition = condition;
}
public object Current => null;
public bool MoveNext()
{
return !condition();
}
public void Reset()
{
}
}
- 測試類
class Program
{
static void Main(string[] args)
{
var engine = new CoroutineEngine();
engine.StartCoroutine(TestNull());
engine.StartCoroutine(TestWaitUntil());
while (true)//主循環
{
engine.Update();
engine.CoroutineUpdate();
Thread.Sleep(33);
}
}
static bool condition = false;
static IEnumerator TestNull()
{
Debug.Log("Test Null", "1");
yield return null;
Debug.Log("Test Null", "2");
yield return TestWaitForSeconds();
}
static IEnumerator TestWaitForSeconds()
{
Debug.Log("TestWaitForSeconds", "start WaitForSeconds");
yield return new WaitForSeconds(5);
condition = true;
Debug.Log("TestWaitForSeconds", "stop WaitForSeconds");
yield return 2;
}
static IEnumerator TestWaitUntil()
{
Debug.Log("TestWaitUntil", "start WaitUntil");
yield return new WaitUntil(() => condition);
Debug.Log("TestWaitUntil", "stop WaitUntil");
}
}
7. 測試結果
總結
以上就是關于C#疊代器的擴充的所有内容了,這一系列文章寫了我很久,主要還是因為準備不足,也對工作量沒有概念,好在有番茄工作法的幫助,一點一點完成了這三篇文章,以後還是要繼續努力。