擴充套件實現Unity協程的完整棧跟蹤

HONT發表於2024-05-12

現如今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函式,穩定版:

擴充套件實現Unity協程的完整棧跟蹤
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());
    }
}
View Code

比較之前記錄的StackTrace,無需手動觸發Pop函式,可能有bug版:

擴充套件實現Unity協程的完整棧跟蹤
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());
    }
}
View Code

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();
    }
}

相關文章