1. 程式人生 > 其它 >【轉載】【精品】Unity協程原理探究與實現

【轉載】【精品】Unity協程原理探究與實現

  不得不說這個作者寫的是真的好,很透徹,先把地址放出來:

   https://www.cnblogs.com/yespi/p/9847533.html

一、介紹

  協程Coroutine在Unity中一直扮演者重要的角色。可以實現簡單的計時器、將耗時的操作拆分成幾個步驟分散在每一幀去執行等等,用起來很是方便。但是,在使用的過程中有沒有思考過協程是怎麼實現的?為什麼可以將一段程式碼分成幾段在不同幀執行?本篇文章將從實現原理上更深入的理解協程,最後肯定也要實現我們自己的協程。關於協程的用法網上有很多介紹,不清楚的話可以看下官方文件,這裡不做贅述。

二、迭代器

  在使用協程的時候,我們總是要宣告一個返回值為IEnumerator

的函式,並且函式中會包含yield return xxx或者yield break之類的語句。就像文件裡寫的這樣

1 private IEnumerator WaitAndPrint(float waitTime)
2 {
3         yield return new WaitForSeconds(waitTime);
4         print("Coroutine ended: " + Time.time + " seconds");
5 }

  想要理解IEnumerator和yield就不得不說一下迭代器。迭代器是C#中一個十分強大的功能,只要類繼承了IEnumerable介面或者實現了GetEnumerator()方法就可以使用foreach去遍歷類,遍歷輸出的結果是根據GetEnumerator()的返回值IEnumerator確定的,為了實現IEnumerator介面就不得不寫一堆繁瑣的程式碼,而yield關鍵字就是用來簡化這一過程的。是不是很繞,理解這些內容需要花些時間。

  不理解也沒關係,目前只需要明白一件事,當在IEnumerator函式中使用yield return語句時,每使用一次,迭代器中的元素內容就會增加一個。就嚮往列表中新增元素一樣,每Add一次元素內容就會多一個。先來看看下面這段簡單的程式碼

 1 IEnumerator TestCoroutine()
 2 {
 3     yield return null;              //返回內容為null
 4 
 5     yield return 1;                 //返回內容為1
 6 
 7     yield return "sss";             //
返回內容為"sss" 8 9 yield break; //跳出,類似普通函式中的return語句 10 11 yield return 999; //由於break語句,該內容無法返回 12 } 13 14 void Start() 15 { 16 IEnumerator e = TestCoroutine(); 17 while (e.MoveNext()) 18 { 19 Debug.Log(e.Current); //依次輸出列舉介面返回的值 20 } 21 } 22 /*執行結果: 23 Null 24 1 25 sss 26 */

  首先注意註釋部分列舉介面的定義
  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,至此迴圈結束

  最後輸出的執行結果跟我們分析是一致的。關於C#是如何實現迭代器的功能,有興趣的可以看下容器類原始碼中關於迭代器部分的實現就明白了

三、原理

 1 // case 1
 2 IEnumerator Coroutine1()
 3 {
 4     //do something xxx        //假如是第N幀執行該語句
 5     yield return 1;         //等一幀
 6     //do something xxx      //則第N+1幀執行該語句
 7 }
 8 
 9 // case 2
10 IEnumerator Coroutine2()
11 {
12     //do something xxx        //假如是第N秒執行該語句
13     yield return new WaitForSeconds(2f);    //等兩秒        
14     //do something xxx      //則第N+2秒執行該語句
15 }
16 
17 // case 3
18 IEnumerator Coroutine3()
19 {
20     //do something xxx
21     yield return StartCoroutine(Coroutine1());  //等協程Coroutine1執行完            
22     //do something xxx     
23 }

  好了,知道了IEnumerator函式和yield return語法之後,在看到上面幾個協程的功能,是不是對如何實現協程有點頭緒了?

  case1 : 分幀

  實現分幀執行之前,先將上述迭代器的程式碼簡單修改下,看下輸出結果

 1 IEnumerator TestCoroutine()
 2 {
 3     Debug.Log("TestCoroutine 1");
 4     yield return null;
 5     Debug.Log("TestCoroutine 2");
 6     yield return 1;
 7 }
 8 
 9 void Start()
10 {
11     IEnumerator e = TestCoroutine();
12     while (e.MoveNext())
13     {
14         Debug.Log(e.Current);       //依次輸出列舉介面返回的值
15     }
16 }
17 /*執行結果
18 TestCoroutine 1
19 Null
20 TestCoroutine 2
21 1
22 */

  前面有說過,每次MoveNext()後會返回yield return後的內容,那yield return之前的語句怎麼辦呢?
  當然也執行啊,遇到yield return語句之前的內容都會在MoveNext()時執行的。
  到這裡應該很清楚了,只要把MoveNext()移到每一幀去執行,不就實現分幀執行幾段程式碼了麼!

  既然要分配在每一幀去執行,那當然就是Update和LateUpdate了。這裡我個人喜歡將實現程式碼放在LateUpdate之中,為什麼呢?因為Unity中協程的呼叫順序是在Update之後,      LateUpdate之前,所以這兩個介面都不夠準確;但在LateUpdate中處理,至少能保證協程是在所有指令碼的Update執行完畢之後再去執行。

  

  現在可以實現最簡單的協程了

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());
}

 

  再來看看執行結果,黃色中括號括起來的數字表示當前在第幾幀,很明顯我們的協程完成了每一幀執行一段程式碼的功能。

  case2: 延時等待

  要是完全理解了case1的內容,相信你自己就能完成“延時等待”這一功能,其實就是加了個計時器的判斷嘛!
  既然要識別自己的等待類,那當然要獲取Current值根據其型別去判定是否需要等待。假如Current值是需要等待型別,那就延時到倒計時結束;而Current值是非等待型別,那就不需要等待,直接MoveNext()執行後續的程式碼即可。
  這裡著重說下“延時到倒計時結束”。既然知道Current值是需要等待的型別,那此時肯定不能在執行MoveNext()了,否則等待就沒用了;接下來當等待時間到了,就可以繼續MoveNext()了。可以簡單的加個標誌位去做這一判斷,同時驅動MoveNext()的執行。

 1 private void OnGUI()
 2 {
 3     if (GUILayout.Button("Test"))       //注意:這裡是點選觸發,沒有放在start裡,為什麼?
 4     {
 5         enumerator = TestCoroutine();
 6     }
 7 }
 8 
 9 void LateUpdate()
10 {
11     if (enumerator != null)
12     {
13         bool isNoNeedWait = true, isMoveOver = true;
14         var current = enumerator.Current;
15         if (current is MyWaitForSeconds)
16         {
17             MyWaitForSeconds waitable = current as MyWaitForSeconds;
18             isNoNeedWait = waitable.IsOver(Time.deltaTime);
19         }
20         if (isNoNeedWait)
21         {
22             isMoveOver = enumerator.MoveNext();
23         }
24         if (!isMoveOver)
25         {
26             enumerator = null;
27         }
28     }
29 }
30 
31 IEnumerator TestCoroutine()
32 {
33     Log("Test 1");
34     yield return null;              //返回內容為null
35     Log("Test 2");
36     yield return 1;                 //返回內容為1
37     Log("Test 3");
38     yield return new MyWaitForSeconds(2f);  //等待兩秒           
39     Log("Test 4");
40 }

  執行結果裡黃色表示當前幀,青色是當前時間,很明顯等待了2秒(雖然有少許誤差但總體不影響)。
  上述程式碼中,把函式觸發放在了Button點選中而不是Start函式中?
這是因為我是用Time.deltaTime去做計時,假如放在了Start函式中,Time.deltaTime會受Awake這一幀執行時間影響,時間還不短(我測試時有0.1s左右),導致執行結果有很大誤差,不到2秒就結束了,有興趣的可以自己試一下~

  case3: 協程巢狀等待

  協程巢狀等待也就是下面這種樣子,在實際情況中使用的也不少。

 1 IEnumerator Coroutine1()
 2 {
 3     //do something xxx
 4     yield return null;
 5     //do something xxx
 6     yield return StartCoroutine(Coroutine2());  //等待Coroutine2執行完畢
 7                                                 //do something xxx
 8     yield return 3;
 9 }
10 
11 
12 IEnumerator Coroutine2()
13 {
14     //do something xxx
15     yield return null;
16     //do something xxx
17     yield return 1;
18     //do something xxx
19     yield return 2;
20 }

  實現原理的話基本與延時等待完全一致,這裡我就不貼例子程式碼了,最後會放出完整工程的。
  需要注意下協程巢狀時的執行順序,先執行完內層巢狀程式碼再執行外層內容;即更新結束條件時要先更新內層協程(上例Coroutine2)在更新外層協程(上例Coroutine1)。

四、總結

  前一節只是把每塊內容的原理用例子程式碼實現了一下,實際使用中這樣肯定不行,需要更通用的介面。
  我按照Unity的介面方式把上述這些功能用相同名稱封裝了一下,並做了一些測試樣例與Unity原生介面執行結果作對比

  下圖是最後一個測試樣例的程式碼和執行結果,可以看出表現是完全一致的。

  

 1 //Hi是名稱空間
 2 private void OnGUI()
 3 {
 4     GUILayout.BeginHorizontal();
 5     if (GUILayout.Button("自己 巢狀的協程"))
 6     {
 7         Hi.CoroutineMgr.Instance.StartCoroutine(TestNesting());
 8     }
 9     GUILayout.Space(20);
10     if (GUILayout.Button("Unity 巢狀的協程"))
11     {
12         StartCoroutine(UnityNesting());
13     }
14     GUILayout.EndHorizontal();
15 }
16 
17 IEnumerator TestNesting()
18 {
19     Log("Nesting 1");
20     yield return Hi.CoroutineMgr.Instance.StartCoroutine(TestNesting__());
21     Log("Nesting 2");
22 }
23 
24 IEnumerator TestNesting__()
25 {
26     Log("Nesting__ 1");
27     yield return Hi.CoroutineMgr.Instance.StartCoroutine(TestNormalCoroutine());
28     Log("Nesting__ 2");
29     yield return Hi.CoroutineMgr.Instance.StartCoroutine(TestWaitFor());
30     Log("Nesting__ 3");
31 }
32 
33 IEnumerator UnityNesting()
34 {
35     LogWarn("UnityNesting 1");
36     yield return StartCoroutine(UnityTesting__());
37     LogWarn("UnityNesting 2");
38 }
39 
40 IEnumerator UnityTesting__()
41 {
42     LogWarn("UnityTesting__ 1");
43     yield return StartCoroutine(UnityNormalCoroutine());
44     LogWarn("UnityTesting__ 2");
45     yield return StartCoroutine(UnityWaitFor());
46     LogWarn("UnityTesting__ 3");
47 }
48 
49 void Log(string message)
50 {
51     Debug.LogFormat("<color=yellow>[{0}]</color>-<color=cyan>[{1}]</color>{2}", Time.frameCount,
52     System.DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss fff"), message);
53 }
54 
55 void LogWarn(string message)
56 {
57     Debug.LogWarningFormat("<color=yellow>[{0}]</color>-<color=cyan>[{1}]</color>{2}",
58     Time.frameCount, System.DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss fff"), message);
59 }

  最後放上工程地址GitHub。目前只是實現了常用的部分介面,足以滿足日常使用,但像停止協程介面還未實現(後續會補上),感興趣的可以自己完善。本篇文章有什麼問題歡迎大家討論、指出~~~

----------------------------------------------------------------------------------------------------

轉載地址:https://www.cnblogs.com/yespi/p/9847533.html