扩展实现Unity协程的完整栈跟踪
现如今Unity中的协程(Coroutine)机制已略显陈旧,随着Unitask、ETTask等异步方案的崭露头角,诸如协程异常等问题也迎刃而解
并且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 } }
但是之前提到,协程会在编译后转换为状态机,所以此处的代码就得不到栈信息:
1 public class TestClass : MonoBehaviour { 2 private void Start() { 3 StartCoroutine(A()); 4 } 5 private IEnumerator A() { 6 yield return null; 7 yield return B(); 8 } 9 private IEnumerator B() { 10 yield return null; 11 var st = new StackTrace(true); 12 for (int i = 0; i < st.FrameCount; ++i) 13 UnityEngine.Debug.Log(st.GetFrame(i).GetFileLineNumber().ToString()); 14 //print 15 //11 16 //0 17 } 18 }
不过抖个机灵,如果在非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(); } }