Unity開發不可避免的要用到協程(Coroutine),協程同步代碼做異步任務的特性使程式員擺脫了曾經異步操作加回呼的編碼方式,使代碼邏輯更加連貫易讀,然而在驚訝于協程的好用與神奇的同時,因為不清楚協程背后的實作原理,所以總是感覺無法完全掌握協程,比如:
MonoBehaviour.StartCoroutine接收的引數為什么是IEnumerator,IEnumerator和協程有什么關系?- 既然協程函式回傳值宣告是
IEnumerator,為什么函式內yield return的又是不同型別的回傳值? yield是什么,常見的yield return,yield break是什么意思,又有什么區別?- 為什么使用了
yield return就可以使代碼“停”在那里,達到某種條件后又可以從“停住”的地方繼續執行? - 具體的,
yield return new WaitForSeconds(3),yield return webRequest.SendWebRequest(),為什么可以實作等待指定時間或是等待請求完成再接著執行后面的代碼?
如果你和我一樣也有上面的疑問,不妨閱讀下本文,相信一定可以解答你的疑惑,
IEnumerator是什么
根據微軟官方檔案的描述,IEnumerator是所有非泛型列舉器的基介面,換而言之就是IEnumerator定義了一種適用于任意集合的迭代方式,任意一個集合只要實作自己的IEnumerator,它的使用者就可以通過IEnumerator迭代集合中的元素,而不用針對不同的集合采用不同的迭代方式,
IEnumerator的定義如下所示
public interface IEnumerator
{
object Current { get; }
bool MoveNext();
void Reset();
}
IEnumerator介面由一個屬性和兩個方法組成
- Current屬性可以獲取集合中當前迭代位置的元素
- MoveNext方法將當前迭代位置推進到下一個位置,如果成功推進到下一個位置則回傳true,否則已經推進到集合的末尾回傳false
- Reset方法可以將當前迭代位置設定為初始位置(該位置位于集合中第一個元素之前,所以當呼叫Reset方法后,再呼叫MoveNext方法,Curren值則為集合的第一個元素)
比如我們經常會使用的foreach關鍵字遍歷集合,其實foreach只是C#提供的語法糖而已
foreach (var item in collection)
{
Console.WriteLine(item.ToString());
}
本質上foreach回圈也是采用IEnumerator來遍歷集合的,在編譯時編譯器會將上面的foreach回圈轉換為類似于下面的代碼
{
var enumerator = collection.GetEnumerator();
try
{
while (enumerator.MoveNext()) // 判斷是否成功推進到下一個元素(可理解為集合中是否還有可供迭代的元素)
{
var item = enumerator.Current;
Console.WriteLine(item.ToString());
}
} finally
{
// dispose of enumerator.
}
}
yield和IEnumerator什么關系
yield是C#的關鍵字,其實就是快速定義迭代器的語法糖,只要是yield出現在其中的方法就會被編譯器自動編譯成一個迭代器,對于這樣的函式可以稱之為迭代器函式,迭代器函式的回傳值就是自動生成的迭代器類的一個物件
試試想象如果沒有yield關鍵字,我們每定義一個迭代器,就要創建一個類,實作IEnumerator介面,介面包含的屬性與方法都要正確的實作,是不是很麻煩?而利用yield關鍵字,只需要下面簡單的幾行代碼,就可以快速定義一個迭代器,諸如迭代器類的創建,IEnumerator介面的實作作業編譯器通通幫你做了
// 由迭代器函式定義的迭代器
IEnumerator Test()
{
yield return 1;
Debug.Log("Surprise");
yield return 3;
yield break;
yield return 4;
}
yield return陳述句可以回傳一個值,表示迭代得到的當前元素yield break陳述句可以用來終止迭代,表示當前沒有可被迭代的元素了
如下所示,可以通過上面代碼定義的迭代器遍歷元素
IEnumerator enumerator = Test(); // 直接呼叫迭代器函式不會執行方法的主體,而是回傳迭代器物件
bool ret = enumerator.MoveNext();
Debug.Log(ret + " " + enumerator.Current); // (1)列印:True 1
ret = enumerator.MoveNext();
// (2)列印:Surprise
Debug.Log(ret + " " + enumerator.Current); // (3)列印:True 3
ret = enumerator.MoveNext();
Debug.Log(ret + " " + enumerator.Current); // (4)列印:False 3
(1)(3)(4)處的列印都沒有什么問題,(1)(3)正確列印出了回傳的值,(4)是因為迭代被yield break終止了,所以MoveNext回傳了false
重點關注(2)列印的位置,是在第二次呼叫MoveNext函式之后觸發的,也就是說如果不呼叫第二次的MoveNext,(2)列印將不會被觸發,也意味著Debug.Log("Surprise")這句代碼不會被執行,表現上來看yield return 1好像把代碼“停住”了,當再次呼叫MoveNext方法后,代碼又從“停住”的地方繼續執行了
yield return為什么能“停住”代碼
想要搞清楚代碼“停住”又原位恢復的原理,就要去IL中找答案了,但是編譯生成的IL是類似于匯編語言的中間語言,比較底層且晦澀難懂,所以我利用了Unity的IL2CPP,它會將C#編譯生成的IL再轉換成C++語言,可以通過C++代碼的實作來曲線研究yield return的實作原理
比如下面的C#類,為了便于定位函式內的變數,所以變數名就起的復雜點
public class Test
{
public IEnumerator GetSingleDigitNumbers()
{
int m_tag_index = 0;
int m_tag_value = https://www.cnblogs.com/iwiniwin/archive/2021/07/05/0;
while (m_tag_index < 10)
{
m_tag_value += 456;
yield return m_tag_index++;
}
}
}
生成的類在Test.cpp檔案中,由于檔案比較長,所以只截取部分重要的片段(有刪減,完整的檔案可以查看這里)
// Test/<GetSingleDigitNumbers>d__0
struct U3CGetSingleDigitNumbersU3Ed__0_t9371C0E193B6B7701AD95F88620C6D6C93705F1A : public RuntimeObject
{
public:
// System.Int32 Test/<GetSingleDigitNumbers>d__0::<>1__state
int32_t ___U3CU3E1__state_0;
// System.Object Test/<GetSingleDigitNumbers>d__0::<>2__current
RuntimeObject * ___U3CU3E2__current_1;
// Test Test/<GetSingleDigitNumbers>d__0::<>4__this
Test_tD0155F04059CC04891C1AAC25562964CCC2712E3 * ___U3CU3E4__this_2;
// System.Int32 Test/<GetSingleDigitNumbers>d__0::<m_tag_index>5__1
int32_t ___U3Cm_tag_indexU3E5__1_3;
// System.Int32 Test/<GetSingleDigitNumbers>d__0::<m_tag_value>5__2
int32_t ___U3Cm_tag_valueU3E5__2_4;
public:
inline int32_t get_U3CU3E1__state_0() const { return ___U3CU3E1__state_0; }
inline void set_U3CU3E1__state_0(int32_t value)
{
___U3CU3E1__state_0 = value;
}
inline RuntimeObject * get_U3CU3E2__current_1() const { return ___U3CU3E2__current_1; }
inline void set_U3CU3E2__current_1(RuntimeObject * value)
{
___U3CU3E2__current_1 = value;
Il2CppCodeGenWriteBarrier((void**)(&___U3CU3E2__current_1), (void*)value);
}
inline int32_t get_U3Cm_tag_indexU3E5__1_3() const { return ___U3Cm_tag_indexU3E5__1_3; }
inline void set_U3Cm_tag_indexU3E5__1_3(int32_t value)
{
___U3Cm_tag_indexU3E5__1_3 = value;
}
inline int32_t get_U3Cm_tag_valueU3E5__2_4() const { return ___U3Cm_tag_valueU3E5__2_4; }
inline void set_U3Cm_tag_valueU3E5__2_4(int32_t value)
{
___U3Cm_tag_valueU3E5__2_4 = value;
}
};
可以看到GetSingleDigitNumbers函式確實被定義成了一個類U3CGetSingleDigitNumbersU3Ed__0_t9371C0E193B6B7701AD95F88620C6D6C93705F1A,而區域變數m_tag_index和m_tag_value都分別被定義成了這個類的成員變數___U3Cm_tag_indexU3E5__1_3和___U3Cm_tag_valueU3E5__2_4,并且為它們生成了對應的get和set方法,___U3CU3E2__current_1成員變數對應IEnumerator的Current屬性,這里再關注下額外生成的___U3CU3E1__state_0成員變數,可以理解為一個狀態機,通過它表示的不同狀態值,決定了整個函式邏輯應該如何執行,后面會看到它是如何起作用的,
// System.Boolean Test/<GetSingleDigitNumbers>d__0::MoveNext()
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR bool U3CGetSingleDigitNumbersU3Ed__0_MoveNext_mED8994A78E174FF0A8BE28DF873D247A3F648CFB (U3CGetSingleDigitNumbersU3Ed__0_t9371C0E193B6B7701AD95F88620C6D6C93705F1A * __this, const RuntimeMethod* method)
{
static bool s_Il2CppMethodInitialized;
if (!s_Il2CppMethodInitialized)
{
il2cpp_codegen_initialize_method (U3CGetSingleDigitNumbersU3Ed__0_MoveNext_mED8994A78E174FF0A8BE28DF873D247A3F648CFB_MetadataUsageId);
s_Il2CppMethodInitialized = true;
}
int32_t V_0 = 0;
int32_t V_1 = 0;
bool V_2 = false;
{
int32_t L_0 = __this->get_U3CU3E1__state_0();
V_0 = L_0;
int32_t L_1 = V_0;
if (!L_1)
{
goto IL_0012;
}
}
{
goto IL_000c;
}
IL_000c:
{
int32_t L_2 = V_0;
if ((((int32_t)L_2) == ((int32_t)1)))
{
goto IL_0014;
}
}
{
goto IL_0016;
}
IL_0012:
{
goto IL_0018;
}
IL_0014:
{
goto IL_0068;
}
IL_0016:
{
return (bool)0;
}
IL_0018:
{
__this->set_U3CU3E1__state_0((-1));
// int m_tag_index = 0;
__this->set_U3Cm_tag_indexU3E5__1_3(0);
// int m_tag_value = https://www.cnblogs.com/iwiniwin/archive/2021/07/05/0;
__this->set_U3Cm_tag_valueU3E5__2_4(0);
goto IL_0070;
}
IL_0030:
{
// m_tag_value += 456;
int32_t L_3 = __this->get_U3Cm_tag_valueU3E5__2_4();
__this->set_U3Cm_tag_valueU3E5__2_4(((int32_t)il2cpp_codegen_add((int32_t)L_3, (int32_t)((int32_t)456))));
// yield return m_tag_index++;
int32_t L_4 = __this->get_U3Cm_tag_indexU3E5__1_3();
V_1 = L_4;
int32_t L_5 = V_1;
__this->set_U3Cm_tag_indexU3E5__1_3(((int32_t)il2cpp_codegen_add((int32_t)L_5, (int32_t)1)));
int32_t L_6 = V_1;
int32_t L_7 = L_6;
RuntimeObject * L_8 = Box(Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var, &L_7);
__this->set_U3CU3E2__current_1(L_8);
__this->set_U3CU3E1__state_0(1);
return (bool)1;
}
IL_0068:
{
__this->set_U3CU3E1__state_0((-1));
}
IL_0070:
{
// while (m_tag_index < 10)
int32_t L_9 = __this->get_U3Cm_tag_indexU3E5__1_3();
V_2 = (bool)((((int32_t)L_9) < ((int32_t)((int32_t)10)))? 1 : 0);
bool L_10 = V_2;
if (L_10)
{
goto IL_0030;
}
}
{
// }
return (bool)0;
}
}
而U3CGetSingleDigitNumbersU3Ed__0_MoveNext_mED8994A78E174FF0A8BE28DF873D247A3F648CFB 成員方法對應了IEnumerator的MoveText方法,它的實作利用了goto陳述句,而這個方法正是代碼“停住”與恢復的關鍵所在
我們一步步來看,按照c#代碼的邏輯,第一次呼叫moveNext函式時,應該執行以下代碼
int m_tag_index = 0;
int m_tag_value = https://www.cnblogs.com/iwiniwin/archive/2021/07/05/0;
if (m_tag_index < 10)
{
m_tag_value += 456;
return m_tag_index++;
}
對應執行的c++代碼如下所示,執行完畢IL_0030完畢后,將回傳true,表示還有元素,此時的state為1
// 初始時,___U3CU3E1__state_0值為0
goto IL_0012;
goto IL_0018; // IL_0018內部初始化m_tag_index和m_tag_value為0. 同時設定___U3CU3E1__state_0值為-1
goto IL_0070; // 判斷m_tag_index是否小于10
goto IL_0030; // IL_0030內部將m_tag_index值加1,并將m_tag_index的值設定為current值,并將___U3CU3E1__state_0值設定為1
第二次呼叫moveNext函式,對應C#代碼為
if (m_tag_index < 10)
{
m_tag_value += 456;
return m_tag_index++;
}
對應的c++代碼為
// 此時___U3CU3E1__state_0值為1,根據判斷進入IL_000c
goto IL_000c;
goto IL_0014;
goto IL_0068; // 設定___U3CU3E1__state_0為-1
IL_0070 // 判斷m_tag_index是否小于10
goto IL_0030; // 回傳1,表示true,還有可迭代元素
當第11次呼叫moveNext函式時,m_tag_index的值已經是10,此時函式應該結束,回傳值應該是false,表示沒有再能回傳的元素了,
所以對應的C++代碼為
// ___U3CU3E1__state_0值是1
goto IL_000c;
goto IL_0014;
goto IL_0068
IL_0070 // 判斷m_tag_index是不小于10的,所以不會進入IL_0030
{
// }
return (bool)0;
}
到這里,我想代碼“停住”與恢復的神秘面紗終于被揭開了,總結下來就是,以能“停住”的地方為分界線,編譯器會為不同磁區的陳述句按照功能邏輯生成一個個對應的代碼塊,yield陳述句就是這條分界線,想要代碼“停住”,就不執行后面陳述句對應的代碼塊,想要代碼恢復,就接著執行后面陳述句對應的代碼塊,而調度背景關系的保存,是通過將需要保存的變數都定義成成員變數來實作的,
Unity協程機制的實作原理
現在我們可以討論下yield return與協程的關系了,或者說IEnumerator與協程的關系
協程是一種比執行緒更輕量級的存在,協程可完全由用戶程式控制調度,協程可以通過yield方式進行調度轉移執行權,調度時要能夠保存背景關系,在調度回來的時候要能夠恢復,這是不是和上面“停住”代碼然后又原位恢復的執行效果很像?沒錯,Unity實作協程的原理,就是通過yield return生成的IEnumerator再配合控制何時觸發MoveNext來實作了執行權的調度
具體而言,Unity每通過MonoBehaviour.StartCoroutine啟動一個協程,就會獲得一個IEnumerator(StartCoroutine的引數就是IEnumerator,引數是方法名的多載版本也會通過反射拿到該方法對應的IEnumerator),并在它的游戲回圈中,根據條件判斷是否要執行MoveNext方法,而這個條件就是根據IEnumerator的Current屬性獲得的,即yield return回傳的值,
在啟動一個協程時,Unity會先呼叫得到的IEnumerator的MoveNext一次,以拿到IEnumerator的Current值,所以每啟動一個協程,協程函式會立即執行到第一個yield return處然后“停住”,
對于不同的Current型別(一般是YieldInstruction的子類),Unity已做好了一些默認處理,比如:
-
如果
Current是null,就相當于什么也不做,在下一次游戲回圈中,就會呼叫MoveNext,所以yield return null就起到了等待一幀的作用 -
如果
Current是WaitForSeconds型別,Unity會獲取它的等待時間,每次游戲回圈中都會判斷時間是否到了,只有時間到了才會呼叫MoveNext,所以yield return WaitForSeconds就起到了等待指定時間的作用 -
如果
Current是UnityWebRequestAsyncOperation型別,它是AsyncOperation的子類,而AsyncOperation有isDone屬性,表示操作是否完成,只有isDone為true時,Unity才會呼叫MoveNext,對于UnityWebRequestAsyncOperation而言,只有請求完成了,才會將isDone屬性設定為true,也因此我們才可以使用下面的同步代碼,完成本來是異步的網路請求操作,
using(UnityWebRequest webRequest = UnityWebRequest.Get("https://www.cnblogs.com/iwiniwin/p/13705456.html")) { yield return webRequest.SendWebRequest(); if(webRequest.isNetworkError) { Debug.Log("Error " + webRequest.error); } else { Debug.Log("Received " + webRequest.downloadHandler.text); } }
實作自己的Coroutine
Unity的協程是和MonoBehavior進行了系結的,只能通過MonoBehavior.StartCoroutine開啟協程,而在開發中,有些不是繼承MonoBehavior的類就無法使用協程了,在這種情況下我們可以自己封裝一套協程,在搞清楚Unity協程的實作原理后,想必實作自己的協程也不是難事了,感興趣的同學趕快行動起來吧,
這里有一份Remote File Explorer內已經封裝好的實作,被用于制作Editor工具時無法使用MonoBehavior又想使用協程的情況下,Remote File Explorer是一個跨平臺的遠程檔案瀏覽器,使用戶通過Unity Editor就能操作應用所運行平臺上的目錄檔案,其內部訊息通訊部分大量使用了協程,是了解協程同步代碼實作異步任務特性的不錯的例子
當然Unity Editor下使用協程,Unity也提供了相關的包,可以參考Editor Coroutines
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/288938.html
標籤:其他
下一篇:Python模塊注入-SSTI
