banner
YZ

周周的Wiki

种一棵树最好的时间是十年前,其次是现在。
zhihu
github
csdn

深入探索Unity協程:揭開CSharp迭代器背後的神秘面紗

image

協程是一種特殊類型的迭代器方法,允許你在多個幀之間分段執行代碼。可以用來處理時間延遲、異步操作和順序執行的任務,而不阻塞主線程。Unity 協程的實現依賴於 C# 語言提供的迭代器相關的語言特性,所以想要弄清楚 Unity 協程的底層原理,必須先了解 C# 的迭代器的基本功能。

C# 迭代器#

迭代器的基本概念#

  1. 迭代器是什麼? 迭代器是一種簡化遍歷集合或序列的工具。你可以用它來逐個訪問集合中的每個元素,而不需要自己編寫複雜的循環邏輯。迭代器通過生成一個可枚舉的序列,讓你逐個取出元素。

  2. yield 關鍵字 。在 C# 中,yield 關鍵字是迭代器的核心。它幫助你創建一個可以暫停和恢復的迭代過程。使用 yield 關鍵字,你可以逐步生成序列中的每個元素,而不是一次性生成所有元素。

    • yield return:用於返回序列中的一個元素,並暫停迭代器的執行,直到下一次請求。
    • yield break:用於結束序列的生成,不再返回更多的元素。

C# 迭代器的作用#

C# 迭代器 Enumerator 提供了一種可以通過 foreach 遍歷任何一個自定義類型的手段。對於任何一個實現了 IEnumerable 接口和 IEnumerator 接口的類型來說,都可以通過 foreach 語句來像遍歷一個集合一樣遍歷一個對象。

定義一個班級類,由若干學生組成:

public class Student
{
    public string Name { get; set; }
    
	public override string ToString()
    {
        return Name;
    }
}

public class ClassRoom : IEnumerable
{
    private List<Student> students;

    public ClassRoom()
    {
        students = new List<Student>();
    }

    public void Add(Student student)
    {
        if (!students.Contains(student))
        {
            students.Add(student);
        }
    }

    public void Remvoe(Student student)
    {
        if (students.Contains(student))
        {
            students.Remove(student);
        }
    }

    public IEnumerator GetEnumerator()
    {
        return new StudentEnumerator(students);
    }
}

 public class StudentEnumerator : IEnumerator
 {
     public StudentEnumerator(List<Student> students)
     {
         this.students = students;
     }
 
     private List<Student> students;
     private int currentIndex = -1;
 
     public object Current
     {
         get
         {
             if(0 <= currentIndex && currentIndex<students.Count)
             {
                 return students[currentIndex];
             }
             return null;
         }
     }
 
     public bool MoveNext()
     {
         currentIndex++;
         return currentIndex<students.Count;
     }
 
     public void Reset()
     {
         currentIndex = -1;
     }
 }

當需要能夠編寫代碼遍歷 ClassRoom 類中的 Student 對象,如果不借助迭代器,就只能將 ClassRoom 內部的 students 集合暴露出來供調用方使用,這樣就暴露了 ClassRoom 內部有關 Student 對象的存儲細節,以後如果 Student 對象的存儲結構變了(比如由 List 結構變成了數組或者字典等等),對應的調用方的所有代碼也得跟著變更。除了直接將 students 成員暴露出來以外,還有一種方法就是可以讓 ClassRoom 實現 IEnumerable 接口,這樣就可以通過 foreach 語句來遍歷其中的 Student 對象。

驗證代碼:

    ClassRoom c = new ClassRoom();
    c.Add(new Student() { Name = "zzz"});
    c.Add(new Student() { Name = "yyy"});

    foreach (Student s in c)
    {
        Debug.Log(s.ToString());
    }
    Debug.Log("......等價輸出........");
	//foreach的等價寫法
    IEnumerator enumerator = c.GetEnumerator();
    while (enumerator.MoveNext())
    {
        Debug.Log(((Student)(enumerator.Current)).ToString());
    }

控制台輸出:
image.png

Unity 協程#

通常情況下,我們寫的每一段代碼,都会在 Unity 的更新邏輯中在同一幀全部執行完畢。如果我們需要將某一段代碼包含的邏輯拆分到不同的幀來分段執行,除了自己手寫狀態機來實現該流程外,更簡單方便的方法就是使用 Unity 協程。總的來說,Unity 協程允許我們在保證整個應用在單線程模式不變的情況下通過編寫協程函數並調用開啟協程的方法(StartCoroutine)將一個任務分到不同的時間段異步執行。

Unity 針對開關協程均提供了三個重載方法,以下表格中的方法均是一一對應的開關協程的用法,不能混用。

開啟協程方法停止協程方法
StartCoroutine(string methodName)/StartCoroutine(string methodName, object value)StopCoroutine (string methodName) 和 StopCoroutine (Coroutine)
StartCoroutine(IEnumerator routine)/StartCoroutine(IEnumerator routine)StopCoroutine (Coroutine routine) 和 StopCoroutine (IEnumerator routine)

Yield Return 延遲函數#

Uniyt 協程中的協程函數通過 yield return 後面的 WaitForSeconds、WaitForEndOfFrame 等可以控制延遲多少秒、多少幀之後再執行,諸如此類效果是如何實現的呢?關鍵點在於 yield return 語句後面的對象類型。我們知道,Unity 協程中常見的 yield return 有這麼幾種:

  yield return new WaitForSeconds(1);

  yield return new WaitForEndOfFrame();

  yield return new WaitForFixedUpdate();

轉到上述三個函數定義源碼處,不難看出它們均繼承於YieldInstruction。Unity 就是根據yield return返回的對象類型來判斷到底應該延遲多長時間來執行下一段代碼的。

總結#

Unity 的協程的實現原理是基於 C# 語言的迭代器特性,通過定義一個協程函數(通過yield return返回),將協程函數緩存為一個IEnumerator的對象,然後根據該對象的Current(是一個 YieldInstruction 對象或者 null) 來判斷下一次執行需要間隔的時間,等到間隔時間結束後執行MoveNext執行下一階段的任務,並繼續根據新的Current確定下一次等待的時間間隔,直到MoveNext返回false標誌著協程終止。

大致可以用以下流程圖來表示 Unity 協程的執行過程:
image.png

自定義實現一個有趣的協程方法#

理解了 Unity 協程的實現原理之後,我們完全可以自己寫代碼來實現類似 Unity 中的StartCoroutine的效果。比如,我們編寫一個自己的開啟協程的方法:此方法規定能夠接受一個返回 IEnumerator 的協程函數,並且可以根據 yield return 後面返回的字符串的長度來等待相應的秒數,比如yield return "1234",那麼就等待 4 秒之後再執行後面的代碼,如果yield return "100", 那麼就等待 3 秒之後再執行後面的代碼,如果yield return後面的對象不是string,則默認等待一幀之後再執行。有了前文的基礎,我們很容易寫出如下代碼:

	/// <summary>
    /// 用來存儲創建的迭代器對象
    /// </summary>
    private IEnumerator taskEnumerator = null;
    /// <summary>
    /// 用來記錄任務是否完成的標記
    /// </summary>
    private bool isDone = false;
    private float currentDelayTime = 0f;
    private float currentPassedTime = 0f;
    private int delayFrameCount = 1;
    private bool delayFrame = false;
    private bool isCoroutineStarted = false;

    private void MyStartCoroutine(IEnumerator enumerator)
    {
        if (enumerator == null) return;
        isCoroutineStarted = true;
        taskEnumerator = enumerator;
        PushTaskToNextStep();
    }

    private void Start()
    {
        MyStartCoroutine(YieldFunction());
    }

    private IEnumerator YieldFunction()
    {
        //第一段代碼
        Debug.Log("first step......");
        yield return 1;

        //第二段代碼
        Debug.Log("second tep......");
        yield return 2;

        //第三段代碼
        Debug.Log("third step......");
        yield return 3;

        //第四段代碼
        Debug.Log("forth step......");
        yield return 4;
    }

    private void PushTaskToNextStep()
    {
        isDone = !taskEnumerator.MoveNext();
        if (!isDone)
        {
            if (taskEnumerator.Current is string)
            {
                currentDelayTime = (taskEnumerator.Current as string).Length;
                currentPassedTime = 0f;
                delayFrame = false;
            }
            else
            {
                delayFrame = true;
                delayFrameCount = 1;
            }
        }
        else
        {
            isCoroutineStarted = false;
        }
    }

    private void Update()
    {
        if (isCoroutineStarted)
        {
            if (delayFrame)
            {
                delayFrameCount--;
                if (delayFrameCount == 0)
                {
                    Debug.Log(string.Format("第{0}幀(運行數:{1})結果:階段任務已完成!", Time.frameCount, Time.time));
                    PushTaskToNextStep();
                }
            }
            else
            {
                currentPassedTime += Time.deltaTime;
                if (currentPassedTime >= currentDelayTime)
                {
                    Debug.Log(string.Format("第{0}幀(運行數:{1})結果:階段任務已完成!", Time.frameCount, Time.time));
                    PushTaskToNextStep();
                }
            }

        }
    }

控制台輸出與預期一致:
image.png

挺有趣哈!

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。