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

项目地址:

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> 本质上是一个列表作用域管理工具。

它通过 usingIDisposable,把临时 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> 一样,都是把"手动申请、手动释放"的对象池使用方式,收敛成更稳定的作用域生命周期。