我把衣服丟進洗衣機裡,倒入洗衣粉,調節水量,按了速洗,啟動。皮皮豎著尾巴跟過來,我伸了個懶腰回到電腦前繼續寫文章。不久,聽到水聲譁譁譁地流,不祥的預感。我趕緊起身去看,水漫金山了。
「手欠貓!又把洗衣機的水管掏出來了!」
看了眼皮皮幼稚的圓臉,算了算了。
皮皮:「這個鐵皮怪為什麼可以一次性吐那麼多水出來?」
我一臉黑線:「這個叫洗衣機,它的功能就是洗衣服,水是從上面進水口進來的。」
皮皮舔舔自己的腳毛,仿佛在質疑洗衣機。
9.2 狀態機是什麼我拿出紙和筆,畫了洗衣機的狀態圖。我:「你可以把洗衣機看成是一個有限狀態機。」
皮皮:「什麼是有限狀態機?」
我:「有限狀態機是一種數學模型,英文全稱是Finite State Machine,縮寫FSM,簡稱狀態機,它是現實事物運行規則抽象而成的一個數學模型。」我繼續講:「看這裡,洗衣機有幾個狀態:開始、進水、漂洗、排水、脫水、結束。這些狀態由一系列事件來驅動,比如按啟動按鈕,開始進水,水位達到目標水位,進入漂洗狀態,正轉5秒,停2秒,反轉5秒,停2秒,循環執行10次,然後進入排水狀態,達到最低水位,進入脫水狀態,脫水30秒,接著又回到進水狀態,重複上述流程3次,最終結束。」
皮皮:「哇,好複雜,它也是程序控制的嗎?」
我:「是的呀,我們可以用代碼寫一個簡單的狀態機。」
9.3 使用協程實現狀態機我打開Unity,創建了一個腳本CoroutineTest.cs。CoroutineTest.cs代碼如下
using System.Collections;
using UnityEngine;
public class CoroutineTest : MonoBehaviour
{
/// <summary>
/// 當前狀態
/// </summary>
private int m_state;
void Start()
{
// 設置初始狀態
m_state = 0;
// 使用協程啟動狀態機
StartCoroutine(TestFSM());
}
/// <summary>
/// 使用協程實現一個簡單的狀態機
/// </summary>
/// <returns></returns>
private IEnumerator TestFSM()
{
Debug.Log("初始狀態:" + m_state);
while (true)
{
switch (m_state)
{
case 0:
{
// 檢測空白鍵是否按下
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("按下了空白鍵,狀態切換: 0->1");
m_state = 1;
}
}
break;
case 1:
{
// 檢測空白鍵是否按下
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("按下了空白鍵,狀態切換: 1->0");
m_state = 0;
}
}
break;
}
yield return null;
}
}
}將腳本掛到Main Camera上,點擊運行。輸出了
初始狀態:0如下
按一下空白鍵,輸出了
按下了空白鍵,狀態切換: 0->1如下
再按一下空白鍵,輸出了
按下了空白鍵,狀態切換: 1->0如下
皮皮:「上面的代碼有點看不懂,StartCoroutine、IEnumerator、yield return null是什麼?」
我:「上面用到了Unity的協程。」
皮皮:「你之前都沒教我協程,直接一上來就寫我看不懂的代碼,不厚道。」
我:「程式設計師是一個不斷學習和成長的職業,實際項目中遇到一些沒學過的東西很正常,特別是現在這個知識爆炸的時代。不懂就查,自學能力是程式設計師最重要的能力之一,不要總是依賴別人教你。」
我心想會不會有點過分,皮皮只是拔了洗衣機的水管。沒想到皮皮很認真地點了點頭,然後望著我呆呆地問:「怎麼查?」我的錯,我之前沒教過皮皮如何使用搜尋引擎。我打開CSDN,說:「以後你有問題可以在CSDN搜索,我給你註冊個帳號,實在不懂,你就訪問這個人的博客 https://blog.csdn.net/linxinfa,給他留言或者私信,他看到了會耐心回答你的問題的。」剛好,這個時候衣服洗好了,我去把衣服拿出來晾好。
我回到屋內時,皮皮轉過頭說:「查了很多文章,還是沒明白協程的準確定義。」
我:「看在你這麼認真的態度,我來講給你聽吧。要搞明白協程,需要先理解進程與線程。」
皮皮:「微信搜索公眾號 [愛上遊戲開發],回復 「資料」,免費領取 200G 學習資料!」
9.4 進程與線程9.4.1 什麼是進程進程是一個具有一定獨立功能的程序在一個數據集上的一次動態執行的過程,是作業系統進行資源分配和調度的一個獨立單位,是應用程式運行的載體。簡單來說,進程就是應用程式的啟動實例,比如我們打開Unity編輯器,其實就是啟動了一個Unity編輯器進程。我們可以在任務管理器中看到作業系統中運行的進程。推薦使用ProcessExplorer來查看進程。ProcessExplorer下載地址:https://docs.microsoft.com/zh-cn/sysinternals/downloads/process-explorer如下,在ProcessExplorer中看到了Unity.exe進程,一個進程可以啟動另一個進程,比如Unity.exe進程又啟動了UnityCrashHandle64.exe這個進程來監聽Unity.exe的崩潰。
9.4.2 什麼是線程線程是程序執行中一個單一的順序控制流程,是程序執行流的最小單元,是處理器調度和分派的基本單位。一個進程可以有一個或多個線程,各個線程之間共享程序的內存空間,也就是所在進程的內存空間。同樣使用ProcessExplorer,可以查看某個進程中的線程。右鍵Unity.exe進程,點擊菜單Properties。
9.5 Unity的協程9.5.1 Unity的協程是什麼點擊Threads標籤頁,可以看到它創建的線程,可以看到Unity.exe進程創建了97個線程。
簡單來說,協程是一個有多個返回點的函數。
協程不是多線程,協程還是在主線程裡面。進程和線程由作業系統調度,協程由程式設計師在協程的代碼裡面顯示調度。
在Unity運行時,調用協程就是開啟了一個IEnumerator(迭代器),協程開始執行,在執行到yield return之前和其他的正常的程序沒有差別,但是當遇到yield return之後會立刻返回,並將該函數暫時掛起。在下一幀遇到FixedUpdate或者Update之後判斷yield return後邊的條件是否滿足,如果滿足則向下執行。
9.5.2 Unity生命周期對協程的影響我拿出紙和筆,畫了MonoBehvaviour生命周期的一部分。
皮皮:「我記得FixedUpdate、Update和LateUpdate這三個函數,上次你講MonoBehvaviour生命周期的時候有講到。」
我:「記性不錯,本質上,Unity的協程是一個迭代器,遇到yield return的時候就掛起來,然後在MonoBehvaviour的生命周期中判斷條件是否滿足,滿足地話則迭代器執行下一步。」
9.5.3 協程的啟動使用StartCoroutine啟動協程,例:
IEnumerator TestCoroutine()
{
yield return null;
}啟動協程
// 得到迭代器
IEnumerator itor = TestCoroutine();
// 啟動協程
StartCoroutine(itor);
// 也可以直接這樣寫
// StartCoroutine(TestCoroutine());皮皮:「這個IEnumerator是什麼?」
我:「IEnumerator是一個迭代器接口,它有一個重要的方法MoveNext。」
public interface IEnumerator
{
object Current { get; }
bool MoveNext();
void Reset();
}Unity的協程遇到yield return的時候就掛起來,迭代器遊標記錄了當前運行的位置,即Current,調用MoveNext()的時候,迭代器遊標就下移一步,協程就從上一次的位置繼續運行。
皮皮:「沒有看到哪裡去調用了這個MoveNext()呀。」
我:「Unity底層幫我們調用的,就像MonoBehvaviour的Update函數一樣。」
皮皮:「那如果我把MonoBehvaviour腳本禁用,協程還會繼續執行嗎?」
我:「協程的運行是和MonoBehvaviour平行的,執行了StartCoroutine之後,禁用MonoBehvaviour腳本,不會影響協程的運行,不過如果禁用了gameObject,則協程會立即退出,即使重新激活gameObject,協程也不會繼續運行。」
9.5.4 協程的退出做個簡單的測試,CoroutineTest.cs腳本代碼如下:
using System.Collections;
using UnityEngine;
public class CoroutineTest : MonoBehaviour
{
void Start()
{
// 啟動協程
StartCoroutine(TestCoroutine());
}
IEnumerator TestCoroutine()
{
while(true)
{
Debug.Log("Coroutine is running");
yield return null;
}
}
}將CoroutineTest.cs腳本掛到一個空物體上
可以看到Console窗口輸出了日誌,輸出了Coroutine is running。
我們可以從調用堆棧中看到,第一條日誌是我們通過StartCoroutine啟動協程,內部其實是執行了一次迭代器的MoveNext方法。而後面的日誌,是通過UnityEngine.SetupCoroutine對象調用InvokeMoveNext方法,再執行了迭代器的MoveNext方法。
此時,我們把CoroutineTest腳本禁用,並不會影響協程的運行,日誌會繼續輸出。
但如果把gameObject禁用,則協程立即停止了,即使重新激活gameObject,協程也不會繼續運行了。皮皮:「上面是我們通過禁用gameObject讓協程退出,如果使用代碼的方式,如何強制退出協程呢?」
我:「有兩種方式。」方式一,啟動協程是,把迭代器對象緩存起來,
皮皮:「微信搜索公眾號 [愛上遊戲開發],回復 「資料」,免費領取 200G 學習資料!」
// 啟動協程
var itor = TestCoroutine();
StartCoroutine(itor);然後我們就可以使用StopCoroutine方法來強制退出協程了。
// 退出協程
StopCoroutine(itor);方式二,是在協程內部執行yeild break。
IEnumerator TestCoroutine()
{
while(true)
{
Debug.Log("Coroutine is running");
// yield break會直接退出協程
yield break;
}
Debug.Log("這裡永遠不會被執行到");
}
9.5.5 協程的主要應用我:「協程的方便之處就是可以使用看似同步的寫法來寫異步的邏輯,這樣可以避免大量的委託回調函數。」
皮皮:「什麼是回調函數?」
我:「舉個例子,剛剛洗衣機的狀態圖還記得嗎,進水是一個過程,需要等,站在程序的角度說,它是一個耗時的操作,當達到設定水位的時候,才進入漂洗狀態。如果不用協程,我們可能就需要申明一個委託函數,把進入漂洗狀態的函數設置給這個委託,當達到設定水位的時候,調用這個委託函數,即可進入漂洗狀態,這個委託函數就是回調函數。」類似下面這樣
using UnityEngine;
// 洗衣機
public class Washer : MonoBehaviour
{
public enum WASHER_STATE
{
/// <summary>
/// 準備
/// </summary>
INIT,
/// <summary>
/// 加水
/// </summary>
ADD_WATER,
/// <summary>
/// 漂洗
/// </summary>
POTCH
}
/// <summary>
/// 狀態
/// </summary>
private WASHER_STATE m_state;
/// <summary>
/// 飄洗的委託
/// </summary>
System.Action m_potchDelegate;
/// <summary>
/// 水位
/// </summary>
int m_waterLevel;
private void Start()
{
StartWasher();
}
void Update()
{
switch (m_state)
{
case WASHER_STATE.ADD_WATER:
{
m_waterLevel += 1;
// 判斷是否達到水位
if (m_waterLevel >= 60)
{
// 調用漂洗委託
if(null != m_potchDelegate)
{
m_potchDelegate();
}
}
}
break;
case WASHER_STATE.POTCH:
{
// TODO
break;
}
}
}
// 啟動洗衣機
void StartWasher()
{
// 把漂洗函數賦值給委託
m_potchDelegate = Potch;
m_state = WASHER_STATE.INIT;
// 加水
AddWater();
}
// 進水
void AddWater()
{
// 進入進水狀態
m_state = WASHER_STATE.ADD_WATER;
}
// 漂洗
void Potch()
{
// 進入漂洗狀態
m_state = WASHER_STATE.POTCH;
}
}如果使用協程,則代碼可以簡潔。
using System.Collections;
using UnityEngine;
// 洗衣機
public class Washer : MonoBehaviour
{
/// <summary>
/// 水位
/// </summary>
int m_waterLevel;
private void Start()
{
StartCoroutine(StartWasher());
}
// 啟動洗衣機
IEnumerator StartWasher()
{
// 加水
while (true)
{
m_waterLevel += 1;
if(m_waterLevel >= 60)
{
break;
}
yield return null;
}
// TODO 漂洗
}
}皮皮:「太酷了,看出來狀態機很適合使用協程來實現。」
我:「是的呀,現在看明白了吧。」
皮皮:「那個yield return null是不是可以看做是等一幀的意思?」
我:「是的,執行yield return null,協程就掛起了,在下一幀Update之後會執行yield null,就會執行協程迭代器的MoveNext,從而繼續執行協程。」
皮皮:「生命周期中有個yield WaitForSeconds,這個WaitForSeconds是等n秒的意思嗎?」
我:「是的,我可以使用它實現一個簡單的延時調用。」示例:
IEnumerator DelayCallTest()
{
Debug.Log("測試 WaitForSeconds");
yield return new WaitForSeconds(3);
Debug.Log("這裡會在3秒後被執行");
}皮皮:「可以了,我現在需要停下去休息一下,yield return new WaitForSeconds(9999);」
我:「我也要去休息一下了,yield break。」