State vs Coroutine in 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()); } }
- 誇張的 Coroutines 範例,GC 120 bytes per frame
沒有留言: