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