現如今Unity中的協程(Coroutine)機制已略顯陳舊,隨著Unitask等非同步方案的嶄露頭角,諸如協程異常等問題也迎刃而解
並且Unity官方也在開發一套非同步方案,但對於仍使用協程的專案,依舊需要在這個方案上繼續琢磨。
眾所周知Unity協程中無法輸出完整的棧跟蹤,因為協程編譯後會轉換為IL編碼的狀態機,中間存在棧回到堆的過程,因此
假如在有多幹yield函式巢狀的協程中出現報錯,看到的棧資訊會是缺失的:
public class TestClass : MonoBehaviour { private void Start() { StartCoroutine(A()); } private IEnumerator A() { yield return B(); } private IEnumerator B() { yield return C(); yield return null; } private IEnumerator C() { yield return null; Debug.Log("C"); } }
輸出(棧資訊丟失):
C UnityEngine.Debug:Log (object) TestClass/<C>d__3:MoveNext () (at Assets/TestClass.cs:31) UnityEngine.SetupCoroutine:InvokeMoveNext (System.Collections.IEnumerator,intptr)
若要比較好的解決這個問題,是不是隻能拿到MoveNext()重新封裝或採用Unitask。
不過那樣就太重了,經過摸索後發現,還是存在一些可行的途徑。
1.StackTrace類列印棧跟蹤
使用StackTrace類可以得到當前執行棧的相關資訊,透過介面GetFrame可以得到當前哪一層呼叫的相關資訊:
public class TestClass : MonoBehaviour { private void Start() { Method1(); } private void Method1() { Method2(); } private void Method2() { var st = new System.Diagnostics.StackTrace(true); var sf = st.GetFrame(0); Debug.Log(sf.GetMethod().Name); sf = st.GetFrame(1); Debug.Log(sf.GetMethod().Name); sf = st.GetFrame(2); Debug.Log(sf.GetMethod().Name); //Print: //Method2 //Method1 //Start } }
但是之前提到,協程會在編譯後轉換為狀態機,所以此處的程式碼就得不到棧資訊:
public class TestClass : MonoBehaviour { private void Start() { StartCoroutine(A()); } private IEnumerator A() { yield return null; yield return B(); } private IEnumerator B() { yield return null; Debug.Log("Hello"); } }
列印:
Hello UnityEngine.Debug:Log (object) TestClass/<B>d__2:MoveNext () (Assets/TestClass.cs:14) UnityEngine.SetupCoroutine:InvokeMoveNext (System.Collections.IEnumerator,intptr)
抖個機靈,如果在非yield語句中進行常規程式碼的呼叫或函式呼叫,則可正常拿到類名和程式碼行數:
1 public class TestClass : MonoBehaviour 2 { 3 private StringBuilder mStb = new StringBuilder(1024); 4 5 private void Start() { 6 StartCoroutine(A()); 7 } 8 private IEnumerator A() { 9 StackTrace st = new StackTrace(true); 10 mStb.AppendLine(st.GetFrame(0).GetFileLineNumber().ToString()); 11 yield return B(); 12 } 13 private IEnumerator B() { 14 StackTrace st = new StackTrace(true); 15 mStb.AppendLine(st.GetFrame(0).GetFileLineNumber().ToString()); 16 yield return C(); 17 } 18 private IEnumerator C() { 19 StackTrace st = new StackTrace(true); 20 mStb.AppendLine(st.GetFrame(0).GetFileLineNumber().ToString()); 21 yield return null; 22 UnityEngine.Debug.Log(mStb.ToString()); 23 } 24 }
列印:
14
19
24
下面將基於這個思路繼續擴充套件。
2.StackTrace封裝
2.1 Begin/End 語句塊
下一步,建立一個類CoroutineHelper存放協程的相關擴充套件,先在類中新增一個棧物件,儲存每一步的棧跟蹤資訊:
public static class CoroutineHelper { private static StackTrace[] sStackTraceStack; private static int sStackTraceStackNum; static CoroutineHelper() { sStackTraceStack = new StackTrace[64]; sStackTraceStackNum = 0; } public static void BeginStackTraceStabDot() { sStackTraceStack[sStackTraceStackNum] = new StackTrace(true); ++sStackTraceStackNum; } public static void EndStackTraceStabDot() { sStackTraceStack[sStackTraceStackNum-1] = null; --sStackTraceStackNum; } }
注意這裡沒有直接用C#自己的Stack,是因為無法逆序遍歷不方便輸出棧日誌,因此直接採用陣列實現。
若這樣的話,每一步協程函式跳轉都要用Begin、End語句包裝又太醜。
private void Start() { StartCoroutine(A()); } private IEnumerator A() { CoroutineHelper.BeginStackTraceStabDot(); yield return B(); CoroutineHelper.EndStackTraceStabDot(); }
2.2 使用擴充套件方法與using語法糖最佳化
實際上非yield語句,普通函式呼叫也是可以的,編譯後不會被轉換。因此可用擴充套件方法進行最佳化:
public static class CoroutineHelper { //加入了這個函式: public static IEnumerator StackTrace(this IEnumerator enumerator) { BeginStackTraceStabDot(); return enumerator; } }
這樣呼叫時就舒服多了,對原始程式碼的改動也最小:
private void Start() { StartCoroutine(A()); } private IEnumerator A() { yield return B().StackTrace(); } private IEnumerator B() { yield return C().StackTrace(); }
不過還需要處理函式結束時呼叫Pop方法,這個可以結合using語法糖使用:
//加入該結構體 public struct CoroutineStabDotAutoDispose : IDisposable { public void Dispose() { CoroutineHelper.EndStackTraceStabDot(); } } public static class CoroutineHelper { //加入該函式 public static CoroutineStabDotAutoDispose StackTracePop() { return new CoroutineStabDotAutoDispose(); } }
加入Pop處理後呼叫時如下:
private void Start() { StartCoroutine(A()); } private IEnumerator A() { using var _ = CoroutineHelper.StackTracePop(); yield return B().StackTrace(); //... } private IEnumerator B() { using var _ = CoroutineHelper.StackTracePop();
yield return C().StackTrace(); //... }
2.3 不使用Using語法糖
後來我想到StackTrace可以拿到某一呼叫級的Method,可以透過比較之前記錄的StackTrace檢視有沒有重複Method來確認
是否退出棧,因此可以最佳化掉Using語法糖的Pop操作。
修改函式如下:
public static void StackTraceStabDot() { var currentTrack = new StackTrace(true); var currentTrackSf = currentTrack.GetFrame(2); for (int i = sStackTraceStackNum - 1; i >= 0; --i) { var sf = sStackTraceStack[i].GetFrame(2); if (sf.GetMethod().GetHashCode() == currentTrackSf.GetMethod().GetHashCode()) { for (int j = i; j < sStackTraceStackNum; ++j) sStackTraceStack[j] = null; sStackTraceStackNum = i; break; } } sStackTraceStack[sStackTraceStackNum] = currentTrack; ++sStackTraceStackNum; }
這樣也是最簡潔的(沒測試過複雜情形,可能存在Bug):
private void Start() { StartCoroutine(A()); } private IEnumerator A() { yield return B().StackTrace(); } private IEnumerator B() { yield return C().StackTrace(); }
3.列印輸出
在拿到完整棧資訊後,還需要列印輸出,
我們可以加入Unity編輯器下IDE連結的語法,這樣列印日誌直接具有超連結效果:
public static void PrintStackTrace() { var stb = new StringBuilder(4096); stb.AppendLine(" --- Coroutine Helper StackTrace --- "); for (int i = 0; i < sStackTraceStackNum; ++i) { var sf = sStackTraceStack[i].GetFrame(2); stb.AppendFormat("- {0} (at <a href=\"{1}\" line=\"{2}\">{1}:{2}</a>)\n", sf.GetMethod().Name, sf.GetFileName(), sf.GetFileLineNumber()); } stb.AppendLine(" --- Coroutine Helper StackTrace --- "); UnityEngine.Debug.Log(stb.ToString()); }
最終效果如下:
4.原始碼
最後提供下這部分功能原始碼。
需要手動觸發Pop函式,穩定版:
using System; using System.Collections; using System.Diagnostics; using System.Text; public struct CoroutineStabDotAutoDispose : IDisposable { public void Dispose() { CoroutineHelper.EndStackTraceStabDot(); } } public static class CoroutineHelper { private static StackTrace[] sStackTraceStack; private static int sStackTraceStackNum; static CoroutineHelper() { sStackTraceStack = new StackTrace[64]; sStackTraceStackNum = 0; } public static CoroutineStabDotAutoDispose StackTracePop() { return new CoroutineStabDotAutoDispose(); } public static IEnumerator StackTrace(this IEnumerator enumerator) { BeginStackTraceStabDot(); return enumerator; } public static void BeginStackTraceStabDot() { sStackTraceStack[sStackTraceStackNum] = new StackTrace(true); ++sStackTraceStackNum; } public static void EndStackTraceStabDot() { sStackTraceStack[sStackTraceStackNum - 1] = null; --sStackTraceStackNum; } public static void PrintStackTrace() { var stb = new StringBuilder(4096); stb.AppendLine(" --- Coroutine Helper StackTrace --- "); for (int i = 0; i < sStackTraceStackNum; ++i) { var sf = sStackTraceStack[i].GetFrame(2); stb.AppendFormat("- {0} (at <a href=\"{1}\" line=\"{2}\">{1}:{2}</a>)\n", sf.GetMethod().Name, sf.GetFileName(), sf.GetFileLineNumber()); } stb.AppendLine(" --- Coroutine Helper StackTrace --- "); UnityEngine.Debug.Log(stb.ToString()); } }
比較之前記錄的StackTrace,無需手動觸發Pop函式,可能有bug版:
using System; using System.Collections; using System.Diagnostics; using System.Text; public static class CoroutineHelper { private static StackTrace[] sStackTraceStack; private static int sStackTraceStackNum; static CoroutineHelper() { sStackTraceStack = new StackTrace[64]; sStackTraceStackNum = 0; } public static IEnumerator StackTrace(this IEnumerator enumerator) { StackTraceStabDot(); return enumerator; } public static void StackTraceStabDot() { var currentTrack = new StackTrace(true); var currentTrackSf = currentTrack.GetFrame(2); for (int i = sStackTraceStackNum - 1; i >= 0; --i) { var sf = sStackTraceStack[i].GetFrame(2); if (sf.GetMethod().GetHashCode() == currentTrackSf.GetMethod().GetHashCode()) { for (int j = i; j < sStackTraceStackNum; ++j) sStackTraceStack[j] = null; sStackTraceStackNum = i; break; } } sStackTraceStack[sStackTraceStackNum] = currentTrack; ++sStackTraceStackNum; } public static void PrintStackTrace() { var stb = new StringBuilder(4096); stb.AppendLine(" --- Coroutine Helper StackTrace --- "); for (int i = 0; i < sStackTraceStackNum; ++i) { var sf = sStackTraceStack[i].GetFrame(2); stb.AppendFormat("- {0} (at <a href=\"{1}\" line=\"{2}\">{1}:{2}</a>)\n", sf.GetMethod().Name, sf.GetFileName(), sf.GetFileLineNumber()); } stb.AppendLine(" --- Coroutine Helper StackTrace --- "); UnityEngine.Debug.Log(stb.ToString()); } }
5.異常捕獲+完整棧跟蹤
知乎上找了一個協程異常捕獲的擴充套件:
https://zhuanlan.zhihu.com/p/319551938
然後就可以實現協程異常捕獲+完整棧跟蹤:
public class TestClass : MonoBehaviour { private void Start() { StartCoroutine(new CatchableEnumerator(A(), () => { CoroutineHelper.PrintStackTrace(); })); } private IEnumerator A() { yield return B().StackTrace(); } private IEnumerator B() { yield return C().StackTrace(); } private IEnumerator C() { yield return null;throw new System.Exception(); } }