MyFramework:Unity ListScope 如何减少临时 List 的 GC

MyFramework:Unity ListScope 如何减少临时 List 的 GC
项目地址https://github.com/ZHOURUIH/MyFrameworkUnity 项目里ListT是使用频率非常高的容器。很多时候我们只是临时需要一个列表临时收集对象 临时保存回调 临时保存路径 临时过滤结果 临时做一次遍历缓存这种列表生命周期很短。函数执行完就不再需要。如果每次都直接写Listint tempList new();在高频逻辑里就会不断产生临时 GC。MyFramework 里的ListScopeT解决的就是这个问题。它让临时ListT从ListPool中申请并在using结束时自动归还。一、代码ListScopeT的代码如下using System; using System.Collections.Generic; using static FrameBaseHotFix; using static FrameUtility; using static StringUtility; // 用于自动从对象池中获取一个ListT,不再使用时会自动释放,需要搭配using来使用,比如using(new ListScopeT(out var list)) public struct ListScopeT : IDisposable { private ListT mList; // 分配的对象 public ListScope(out ListT list) { if (GameEntryBase.getInstance() null || mListPool null) { list new(); mList null; return; } string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT; mList list; } public ListScope(out ListT list, ListT initList) { if (GameEntryBase.getInstance() null || mListPool null) { list new(); mList null; return; } string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT; mList list; mList.addRange(initList); } public ListScope(out ListT list, T[] initList) { if (GameEntryBase.getInstance() null || mListPool null) { list new(); mList null; return; } string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT; mList list; mList.addRange(initList); } public void Dispose() { mListPool?.destroyList(ref mList, typeof(T)); } }核心逻辑很简单构造函数里从 ListPool 申请 ListT Dispose 里把 ListT 归还给 ListPool使用方式using var a new ListScopeint(out var tempList); tempList.Add(1); tempList.Add(2); tempList.Add(3);离开作用域后Dispose()自动执行。二、为什么不用 new List临时ListT看起来很便宜。但在框架代码里它可能出现在很多高频位置每帧 Update 事件分发 资源回调 UI 刷新 对象过滤 临时排序 路径收集如果这些地方不断new ListT()就会产生大量短生命周期对象。这些对象本身可能不大但数量多了以后会增加 GC 压力。ListScopeT的思路是不要每次创建新的 ListT 而是从 ListPool 取一个空列表 用完 Clear 后放回池中这样可以减少临时列表反复分配。三、using 管生命周期ListScopeT是结构体并实现了IDisposable。所以它可以用using管理生命周期using var a new ListScopestring(out var paths); // 使用 paths编译后作用域结束时会调用a.Dispose();Dispose()中执行mListPool?.destroyList(ref mList, typeof(T));也就是说临时列表不需要手动归还。只要写在using作用域里结束时就会自动还回去。这和ClassScopeT的思路一致ClassScopeT 管临时 ClassObject ListScopeT 管临时 ListT四、ListPoolListScopeT背后使用的是ListPool。申请列表时list mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT;ListPool内部有几个核心容器protected DictionaryType, HashSetIList mPersistentInuseList new(); // 持久使用的列表对象,为了提高运行时效率,仅在编辑器下使用 protected DictionaryType, HashSetIList mInusedList new(); // 仅当前栈帧中使用的列表对象,为了提高运行时效率,仅在编辑器下使用 protected DictionaryType, QueueIList mUnusedList new(); // 未使用列表 protected DictionaryIList, string mObjectStack new(); // 对象分配的堆栈信息列表其中最关键的是mUnusedList 已经回收、可以复用的 List mInusedList 当前正在使用的临时 List mPersistentInuseList 当前正在使用的持久 ListListScopeT申请的是临时列表所以传入true也就是onlyOnce true。五、申请流程ListPool.newList()的逻辑是public IList newList(Type elementType, Type listType, string stackTrace, bool onlyOnce true) { if (mHasDestroy) { return null; } if (isEditor() !isMainThread()) { Debug.LogError(只能在主线程使用ListPool,子线程请使用ListPoolThread代替); return null; } bool isNew false; IList list; // 先从未使用的列表中查找是否有可用的对象 if (mUnusedList.TryGetValue(elementType, out var valueList) valueList.Count 0) { list valueList.Dequeue(); } // 未使用列表中没有,创建一个新的 else { list createInstanceIList(listType); isNew true; } if (isEditor()) { // 标记为已使用 mObjectStack.Add(list, stackTrace); addInuse(list, elementType, onlyOnce); if (isNew) { int totalCount 0; totalCount mInusedList.get(elementType)?.Count ?? 0; totalCount mPersistentInuseList.get(elementType)?.Count ?? 0; if (totalCount % 1000 0) { Debug.Log(创建的List总数量已经达到: totalCount 个,type: elementType); } } } return list; }优先从mUnusedList取。池里没有时才创建新的ListT。所以第一次可能会创建。后面重复使用时就可以复用池里的列表。六、回收流程ListScopeT结束时调用mListPool?.destroyList(ref mList, typeof(T));destroyList()的逻辑是public void destroyListT(ref ListT list, Type elementType) { if (mHasDestroy || list null) { return; } if (isEditor() !isMainThread()) { Debug.LogError(只能在主线程使用ListPool,子线程请使用ListPoolThread代替); return; } list.Clear(); addUnuse(list, elementType); if (isEditor()) { removeInuse(list, elementType); mObjectStack.Remove(list); } list null; }这里做了几件事清空 List 加入未使用队列 从使用列表移除 移除堆栈记录 外部引用置空回收时会先Clear()。所以下次从池中取出来时是一个空列表。这一步很重要。临时列表不能把上一次的数据带到下一次使用。七、编辑器泄漏检查ListScopeT的一个重要价值是配合ListPool的泄漏检查。ListPool.update()中会检查临时列表是否还在使用public override void update(float elapsedTime) { base.update(elapsedTime); if (isEditor()) { foreach (var item in mInusedList) { foreach (IList itemList in item.Value) { string stack mObjectStack.get(itemList); if (stack.isEmpty()) { stack 当前未开启对象池的堆栈追踪,可在对象分配前使用F4键开启堆栈追踪,然后就可以在此错误提示中看到对象分配时所在的堆栈\n; } else { stack create stack:\n stack \n; } logError(有临时对象正在使用中,是否在申请后忘记回收到池中! \n stack); break; } } } }ListScopeT申请列表时传入onlyOnce true。所以这个列表会被记录到mInusedList。如果它没有在当前使用周期内归还编辑器下就会报错。这能提前发现申请了临时 List 但没有释放使用using后这种问题会少很多。八、堆栈追踪ListScopeT申请列表时会记录堆栈string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY;是否记录由参数控制mEnablePoolStackTrace开启后如果某个临时列表忘记归还报错里可以看到创建堆栈。这对排查对象池泄漏很有用。不开启时也会提示当前未开启对象池的堆栈追踪 可在对象分配前使用 F4 键开启堆栈追踪这说明ListPool不只是一个复用容器。它也承担了运行时检查工具的职责。九、初始化列表ListScopeT还有两个构造函数。可以从已有列表初始化public ListScope(out ListT list, ListT initList) { if (GameEntryBase.getInstance() null || mListPool null) { list new(); mList null; return; } string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT; mList list; mList.addRange(initList); }也可以从数组初始化public ListScope(out ListT list, T[] initList) { if (GameEntryBase.getInstance() null || mListPool null) { list new(); mList null; return; } string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT; mList list; mList.addRange(initList); }也就是using var a new ListScopeint(out var tempList, sourceList);或者using var a new ListScopeint(out var tempList, sourceArray);这样可以在不分配新ListT的情况下得到一个临时副本。十、多个列表MyFramework 里还有ListScope2Tpublic struct ListScope2T : IDisposable { private ListT mList0; // 分配的对象 private ListT mList1; // 分配的对象 public ListScope2(out ListT list0, out ListT list1) { if (GameEntryBase.getInstance() null || mListPool null) { list0 new(); list1 new(); mList0 null; mList1 null; return; } string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list0 mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT; list1 mListPool.newList(typeof(T), typeof(ListT), stackTrace, true) as ListT; mList0 list0; mList1 list1; } public void Dispose() { Type type typeof(T); mListPool?.destroyList(ref mList0, type); mListPool?.destroyList(ref mList1, type); } }它一次申请两个同类型列表。还有ListScope2TT0, T1public struct ListScope2TT0, T1 : IDisposable { private ListT0 mList0; // 分配的对象 private ListT1 mList1; // 分配的对象 public ListScope2T(out ListT0 list0, out ListT1 list1) { if (GameEntryBase.getInstance() null || mListPool null) { list0 new(); list1 new(); mList0 null; mList1 null; return; } string stackTrace GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list0 mListPool.newList(typeof(T0), typeof(ListT0), stackTrace, true) as ListT0; list1 mListPool.newList(typeof(T1), typeof(ListT1), stackTrace, true) as ListT1; mList0 list0; mList1 list1; } public void Dispose() { mListPool?.destroyList(ref mList0, typeof(T0)); mListPool?.destroyList(ref mList1, typeof(T1)); } }它一次申请两个不同类型的列表。例如资源回调中就会用到类似结构using var a new ListScope2TAssetLoadCallback, string(out var callbacks, out var paths);一个列表保存回调。一个列表保存路径。两者生命周期一致所以可以用同一个 scope 管理。十一、为什么需要 ListScope2T有些逻辑不是只需要一个临时列表。例如异步加载回调执行前需要把两组数据同时转移出来回调列表 加载路径列表它们必须保持下标对应关系。如果分别申请两个ListScopeusing var a new ListScopeAssetLoadCallback(out var callbacks); using var b new ListScopestring(out var paths);也可以工作。但写法更长。ListScope2TT0, T1把这件事收成一个作用域using var a new ListScope2TAssetLoadCallback, string(out var callbacks, out var paths);两个临时列表一起申请。作用域结束后一起归还。十二、主线程限制ListPool是主线程列表池。代码里有明确检查if (isEditor() !isMainThread()) { Debug.LogError(只能在主线程使用ListPool,子线程请使用ListPoolThread代替); return null; }这说明ListScopeT适合主线程逻辑。子线程不能使用普通ListPool。子线程需要使用ListPoolThread。这个边界很重要。因为ListPool内部没有为了多线程访问而加锁。它的设计目标是 Unity 主线程运行时系统。十三、和 safe() 的区别safe()也和列表有关。但它们解决的问题不同。safe()解决的是遍历时原列表可能是 null例如foreach (var item in list.safe()) { }它返回一个共享空列表避免空判断。ListScopeT解决的是我需要一个临时 List 但不想频繁 new 也不想手动回收例如using var a new ListScopeint(out var tempList);一个偏读取安全。一个偏临时容器复用。十四、和 SafeList 的区别SafeListT解决的是遍历中修改正在遍历 同时可能新增或删除元素所以它内部维护mMainList mUpdateList mModifyListListScopeT没有这种复杂逻辑。它只是一个临时列表生命周期工具。ListScopeT 从池中取一个 List 用完自动还回去 SafeListT 管理一个长期存在的安全列表它们名字相似但目的不同。十五、使用边界ListScopeT只适合临时列表。不适合把列表长期保存。下面这种写法是错误的using var a new ListScopeint(out var tempList); mCacheList tempList;using结束后tempList会被清空并回收到池中。mCacheList持有的就是一个已经失效的列表。所以使用边界很明确只能在当前作用域内使用 不能保存到成员变量 不能跨帧使用 不能交给异步回调后继续使用 不能在子线程使用普通 ListScope长期列表应该自己持有。临时列表才应该使用ListScopeT。十六、设计价值ListScopeT的价值不是代码复杂。它的价值在于把高频临时列表的生命周期固定下来using 开始 从 ListPool 取列表 using 结束 Clear 回到 ListPool它带来的效果是减少临时 List 分配 减少忘记回收 编辑器下能检查泄漏 支持堆栈追踪 支持多个临时列表一起管理对于 Unity 框架来说这种小工具非常实用。因为临时列表使用太频繁了。如果每个地方都手动管理代码会变啰嗦也容易漏。总结ListScopeT本质上是一个列表作用域管理工具。它通过using和IDisposable把临时ListT的生命周期限制在当前作用域内。using var a new ListScopeint(out var tempList);构造时从ListPool取列表。结束时自动调用mListPool?.destroyList(ref mList, typeof(T));destroyList()会清空列表、放回未使用队列并在编辑器下移除使用记录。这样可以减少临时ListT带来的 GC也能避免忘记手动归还。在 MyFramework 中ListScopeT、ListScope2T、ListScope2TT0, T1共同承担了临时列表生命周期管理的职责。它们和ClassScopeT一样都是把“手动申请、手动释放”的对象池使用方式收敛成更稳定的作用域生命周期。