關於 web service, unity, blogger 等軟體工程筆記

State vs Coroutine in Unity

Edit icon 沒有留言
Unity

GameJam 中看到組員的程式碼,有感寫下這篇記錄,提供另一種撰寫主流程狀態機的方式。使用 Unity5.5.0f1 版本測試。

自動販賣機範例

考慮一個簡單自動販賣機的流程,一開始停留在待機狀態,如果使用者 投入硬幣時,則進入到投幣狀態。

等待使用者投入足夠的金額後,則進入選擇狀態,該選擇狀態等待使用者按下欲購買的飲料後,進入結算狀態。

該結算狀態將使用者選擇的飲料發出,並且結算退出結算零錢,最後回到待機狀態,等待使用者下次購買。

要注意的是,投幣狀態以及選擇狀態,使用者都可以選擇取消,將所投入的硬幣退還。

狀態機實作

一個簡單的狀態機實做,不建立額外的 Class,單純建立 enumeration,Update 時根據目前狀態執行對應的流程:

using UnityEngine;

public partial class VendingMachine: MonoBehaviour {

   public enum State {
      Idle,          // 待機
      WaitCoin,      // 投幣中
      WaitSelection, // 選擇中
      Disposing,     // 配送飲料
      CoinReturn,    // 退幣
   }

   State state = State.Idle;

   void Update() {
      switch (this.state) {
      case State.Idle:
         if (this.IsAnyCoin()) {
            // 有任何零錢
            this.state = State.WaitCoin;
         }
         break;

      case State.WaitCoin:
         if (this.HasEnoughCoin()) {
            // 顯示可選擇的飲料 (投入零錢足夠購買)
            this.DisplaySelection();
            this.state = State.WaitSelection;
         } else if (this.IsCancel()) {
            // 取消交易,退幣
            this.DoCoinOut() : this.state = State.CoinReturn;
         }
         break;

      case State.WaitSelection:
         if (this.IsAnySelect()) {
            // 結帳,配送飲料
            this.Checkout();
            this.DisposeSelected();
            this.state = State.Disposing;
         } else if (this.IsCancel()) {
            // 取消交易,退幣
            this.DoCoinOut() : this.state = State.CoinReturn;
         } else {
            // 更新可選擇的飲料 (使用者可能持續投入零錢)
            this.DisplaySelection();
         }
         break;

      case State.Disposing:
         // 等待機器配送飲料完畢
         if (!this.Disposing()) {
            // 如果還有零錢,則退幣否則回到待機
            if (this.AnyCoins()) {
               this.DoCoinOut();
               this.state = State.CoinReturn;
            } else {
               this.state = State.Idle;
            }
         }
         break;

      case State.CoinReturn:
         // 等待機器零錢退幣完畢
         if (!this.CoinReturning()) {
            this.state = State.Idle;
         }
         break;
      }
   }
}

使用 Coroutine 實作

改用 Coroutine 實作:

using System.Collections;
using UnityEngine;

public partial class VendingMachine: MonoBehaviour {

   void Start() {
      this.StartCoroutine(this.MainCoroutine());
   }

   IEnumerator MainCoroutine() {
      while (true) {
         // 待機
         while (!this.IsAnyCoin()) {
            yield  return null; 
         }

         // 投幣中
         var cancel = false;
         while (!this.HasEnoughCoin()) {
            yield  return null; 
            if (this.IsCancel()) {
               // 取消交易
               cancel = true;
               break;
            }
         }

         if (!cancel) {
            // 選擇中
            while (!this.IsAnySelect()) {
               // 更新可選擇的飲料 (使用者可能持續投入零錢)
               this.DisplaySelection();
               yield  return null; 
               if (this.IsCancel()) {
                  // 取消交易
                  cancel = true;
                  break;
               }
            }
         }

         // 結帳,配送飲料,等待機器配送完畢
         if (!cancel) {
            this.Checkout();
            this.DisposeSelected();
            while (!this.Disposing()) {
               yield  return null; 
            }
         }

         // 檢查是否需要退幣
         if (this.AnyCoins()) {
            // 退幣,並等待機器零錢退幣完畢
            this.DoCoinOut();
            while (!this.CoinReturning()) {
               yield  return null; 
            }
         }
      }
   }
}

另一個使用 Goto 更簡單版本,對於某些人來說,Goto 是禁用詞彙,但用得好的話是可以更簡潔些:

using System.Collections;
using UnityEngine;

public partial class VendingMachine: MonoBehaviour {

   void Start() {
      this.StartCoroutine(this.MainCoroutine());
   }

   IEnumerator MainCoroutine() {
      while (true) {
         // 待機
         while (!this.IsAnyCoin()) {
            yield  return null;
         }

         // 投幣中
         while (!this.HasEnoughCoin()) {
            yield  return null;
            if (this.IsCancel()) {
               // 取消交易
               goto CANCEL;
            }
         }

         // 選擇中
         while (!this.IsAnySelect()) {
            // 更新可選擇的飲料 (使用者可能持續投入零錢)
            this.DisplaySelection();
            yield  return null;
            if (this.IsCancel()) {
               // 取消交易
               goto CANCEL;
            }
         }

         // 結帳,配送飲料,等待機器配送完畢
         this.Checkout();
         this.DisposeSelected();
         while (!this.Disposing()) {
            yield  return null;
         }

         CANCEL: if (this.AnyCoins()) {
            // 退幣,並等待機器零錢退幣完畢
            this.DoCoinOut();
            while (!this.CoinReturning()) {
               yield  return null;
            }
         }
      }
   }
}

Coroutine 優點與缺點

優點 Pros

  • Clean code,相較於第一個實作,不必建立 State 定義,不需要記憶多個狀態切換,由上往下讀就能明瞭

缺點 Cons

基本上,只要用得好的話,這些缺點都是可以迴避或是減少影響,甚至是優點帶來的好處可以蓋過於缺點。但如何用得好,這需要一段時間學習累積才行。

  • GameObject inactive 或者 MonoBevhavior disable 會狀態遺失,設計要避免以上這兩件事情發生

  • StartCoroutine 由於實作關係,會吃一點資源 (allocate heap),但大量濫用 StartCoroutine 會造成大量記憶體配置,導致過多 GC。另外使用 Unity 提供的搭配 Coroutine 方式不當,例如 yield return new WaitForSeconds(5),也會要求些記憶體配置。但這些都可以修改使用方法,來減少或是避免多餘的記憶體配置要求。

    • 誇張的 Coroutines 範例,GC 120 bytes per frame
      using System.Collections;
      using UnityEngine;
      
      public class Example: MonoBehaviour {
      
         void Start() {
            this.StartCoroutine(this.MainCoroutine());
         }
      
         IEnumerator MainCoroutine() {
            while (true) {
               yield return this.StartCoroutine(this.NestedACoroutine());
            }
         }
      
         IEnumerator NestedACoroutine() {
            yield return this.StartCoroutine(this.NestedBCoroutine());
         }
      
         IEnumerator NestedBCoroutine() {
            yield  return null;
            // DO B
         }
      }
      
    • 調整第一版本,GC 80 bytes per frame
      using System.Collections;
      using UnityEngine;
      
      public partial class Example2: MonoBehaviour {
      
         void Start() {
            this.StartCoroutine(this.MainCoroutine());
         }
      
         IEnumerator MainCoroutine() {
            while (true) {
               yield return this.NestedACoroutine();
            }
         }
      
         IEnumerator NestedACoroutine() {
            var itr = this.NestedBCoroutine();
            while (itr.MoveNext()) {
               yield  return null;
            }
         }
      
         IEnumerator NestedBCoroutine() {
            yield  return null;
            // DO B
         }
      }
      
    • 調整第二版本,GC 0 bytes per frame
      using System.Collections;
      using UnityEngine;
      
      public partial class Example3: MonoBehaviour {
      
         void Start() {
            this.StartCoroutine(this.MainCoroutine());
         }
      
         IEnumerator MainCoroutine() {
            while (true) {
               yield return null;
               // DO B
            }
         }
      }
      
    • 使用 WaitForSeconds,GC 20 bytes when WaitForSeconds..ctor()

      IEnumerator MainCoroutine() {
         while (true) {
            yield return new WaitForSeconds(3);
            Debug.Log(Time.time.ToString());
         }
      }
      
    • 調整先 Cache WaitForSeconds
      var wait = new WaitForSeconds(1);
         while (true) {
            yield return wait;
            Debug.Log(Time.time.ToString());
         }
      }
      
    • 使用 WaitForSecondsRealtime,GC 20 bytes when WaitForSecondsRealtime..ctor()
      IEnumerator MainCoroutine() {
         while (true) {
            yield return new WaitForSecondsRealtime(3);
            Debug.Log(Time.realtimeSinceStartup.ToString());
         }
      }
      
    • 調整後,WaitForSecondsRealtime 沒辦法向前範例那樣先 Cache,因此改用其他方式處理
      IEnumerator MainCoroutine() {
         while (true) {
            var waitTime = Time.realtimeSinceStartup + 3f;
            while (Time.realtimeSinceStartup < waitTime) {
               yield
               return null;
            }
            Debug.Log(Time.realtimeSinceStartup.ToString());
         }
      }
      

沒有留言: