Unity MyFramework: 框架中的那些非常实用的 GC 处理技巧

项目地址:

GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub

Unity 项目里的 GC,很多时候不是来自某个很大的对象分配。

更常见的是一些"看起来很正常"的写法,在高频路径里不断产生小分配。

比如:

bash 复制代码
Delegate += callback
Dictionary.Keys / Values
UnityEngine.Object.name
new List / new Dictionary / new byte[]
字符串拼接
params 参数
LINQ
闭包
接口容器
结构体未实现 IEquatable<T>
Physics.OverlapXXX
yield return new WaitForEndOfFrame()

这些写法单独看都很普通。

但如果出现在事件分发、UI 刷新、资源回调、网络解析、战斗逻辑、Update 里,就会变成持续 GCAlloc。

MyFramework 里减少 GC 的做法,不是简单地禁止 new,而是对这些常见分配点做统一规避。


一、不用 Delegate.Add 管理高频回调

C# 里最常见的回调注册写法是:

bash 复制代码
callback += onCallback;
callback -= onCallback;

或者:

bash 复制代码
someEvent += onEvent;

这类写法的隐藏问题是:委托是不可变对象。

每次 +=-=,本质上都会生成新的委托对象。

多播委托还会维护新的调用列表。

所以在高频注册、取消的地方,直接使用 Delegate.Add 并不适合。

MyFramework 里很多地方没有用多播委托来保存动态回调,而是使用 List<Action> 或专门的注册信息列表。

例如命令对象里:

bash 复制代码
protected List<CommandCallback> mStartCallback = new(); // 命令开始执行时的回调函数
protected List<CommandCallback> mEndCallback = new();   // 命令执行完毕时的回调函数

添加回调时:

bash 复制代码
public void addEndCommandCallback(CommandCallback cmdCallback)
{
	mEndCallback.addNotNull(cmdCallback);
}

public void addStartCommandCallback(CommandCallback cmdCallback)
{
	mStartCallback.addNotNull(cmdCallback);
}

执行后清理:

bash 复制代码
public void invokeEndCallBack()
{
	mEndCallback.For(callback => callback(this));
	mEndCallback.Clear();
}

这里不是用:

bash 复制代码
mEndCallback += callback;

而是用列表保存回调。

这样可以避免每次增删回调都生成新的委托调用列表。

事件系统也是类似思路。

事件注册不是简单地把所有回调拼成一个多播委托,而是封装成 GameEventRegisteInfo

bash 复制代码
public class GameEventRegisteInfo : ClassObject
{
	public int mEventTypeID;          // 事件类型ID
	public long mCharacterID;         // 事件所属的玩家ID
	public IEventListener mListener;  // 监听者
	public Action mBaseCallback;      // 不带参数的回调
}

事件表里保存的是注册信息列表:

bash 复制代码
protected Dictionary<int, SafeList0<GameEventRegisteInfo>> mGlobalListenerEventList = new();

这样事件系统可以按事件类型查找、按监听者反向清理,也避免了频繁 Delegate.Combine / Delegate.Remove


二、Dictionary.Keys / Values 不直接在高频路径使用

很多人会写:

bash 复制代码
foreach (var key in dic.Keys)
{
}

或者:

bash 复制代码
foreach (var value in dic.Values)
{
}

Dictionary.KeysDictionary.Values 第一次访问时会创建对应的集合对象。

如果再写成:

bash 复制代码
List<int> keys = new(dic.Keys);

那又会额外创建一个 List 并复制一份 key。

MyFramework 里为了避免这种写法,在扩展函数里提供了直接遍历字典项的方法。

例如:

bash 复制代码
public static void forKey<TKey, TValue>(this Dictionary<TKey, TValue> list, Action<TKey> action)
{
	if (list.isEmpty())
	{
		return;
	}
	foreach (var item in list)
	{
		action(item.Key);
	}
}

以及:

bash 复制代码
public static void forValue<TKey, TValue>(this Dictionary<TKey, TValue> list, Action<TValue> action)
{
	if (list.isEmpty())
	{
		return;
	}
	foreach (var item in list)
	{
		action(item.Value);
	}
}

如果确实需要把 key 或 value 拷贝到列表里,也不是直接 new List(dic.Keys)

而是把结果写入一个已经存在的列表:

bash 复制代码
public static List<TKey> setRangeKeys<TKey, TValue>(this List<TKey> list, Dictionary<TKey, TValue> dic)
{
	list.Clear();
	if (dic.isEmpty())
	{
		return list;
	}
	foreach (var item in dic)
	{
		list.add(item.Key);
	}
	return list;
}

value 也一样:

bash 复制代码
public static List<TValue> setRangeValues<TKey, TValue>(this List<TValue> list, Dictionary<TKey, TValue> dic)
{
	list.Clear();
	if (dic.isEmpty())
	{
		return list;
	}
	foreach (var item in dic)
	{
		list.add(item.Value);
	}
	return list;
}

这种写法的目的很明确:

bash 复制代码
不直接依赖 Dictionary.Keys / Values
不临时 new List(dic.Keys)
复用已有 List
通过遍历 KeyValuePair 取 Key / Value

三、UnityEngine.Object.name 要缓存

Unity 里访问 UnityEngine.Object.name 是一个很容易忽略的 GC 来源。

比如:

bash 复制代码
string name = sprite.name;

或者:

bash 复制代码
if (texture.name == targetName)
{
}

UnityEngine.Object.name 每次访问都会从 Unity native 对象生成一个 managed string。

所以如果在高频逻辑里反复访问 .name,就会持续产生字符串分配。

MyFramework 里对这种情况会缓存名字。

例如 AtlasTP

bash 复制代码
protected Texture2D mTexture;     // 图集对象
protected string mTextureName;    // 由于直接访问.name每次都会有GC,所以使用一个变量存储

public override string getName()
{
	return mTextureName;
}

public void setAtlas(Texture2D atlas)
{
	mTexture = atlas;
	mTextureName = mTexture.name;
}

AtlasUGUI 也是一样:

bash 复制代码
protected SpriteAtlas mSpriteAtlas; // 图集对象
protected string mAtlasName;        // 由于直接访问.name每次都会有GC,所以使用一个变量存储

public override string getName()
{
	return mAtlasName;
}

public void setAtlas(SpriteAtlas atlas)
{
	mSpriteAtlas = atlas;
	mAtlasName = mSpriteAtlas.name;
}

SpriteRef 里也缓存了 Sprite 名字:

bash 复制代码
private Sprite mSprite;      // 引用的图片
private string mSpriteName;  // 图片的名字,避免访问name而产生GC

public void setSprite(Sprite sprite, AtlasRef atlas)
{
	mSprite = sprite;
	mSpriteName = null;
	if (mSprite == null)
	{
		logError("sprite is null");
		return;
	}
	mSpriteName = sprite.name;
	mAtlas = atlas;
}

public string getSpriteName()
{
	return mSpriteName;
}

这里的原则是:

bash 复制代码
对象刚设置时可以访问一次 .name
运行时反复使用时读缓存字段

尤其是 Sprite、Texture、Atlas 这种资源对象,名字经常作为索引或调试信息使用,不应该在运行时频繁访问 Unity 的 .name 属性。


四、结构体实现 IEquatable

结构体如果经常作为列表元素、字典 Key、HashSet 元素,比较逻辑就很重要。

普通写法可能只写字段,不实现 IEquatable<T>

bash 复制代码
public struct TileKey
{
	public int mX;
	public int mY;
}

然后在高频逻辑里使用:

bash 复制代码
mTileSet.Contains(key);
mTileList.Contains(key);
mTileDictionary.TryGetValue(key, out var value);

如果结构体没有实现 IEquatable<T>,某些比较路径可能会走到 Equals(object)

这会带来装箱,也可能走默认的 ValueType.Equals 比较逻辑。

在高频容器查询里,这种开销很容易被忽略。

更合适的写法是让结构体实现 IEquatable<T>

bash 复制代码
public struct TileKey : IEquatable<TileKey>
{
	public int mX;
	public int mY;

	public bool Equals(TileKey other)
	{
		return mX == other.mX && mY == other.mY;
	}

	public override bool Equals(object obj)
	{
		return obj is TileKey other && Equals(other);
	}

	public override int GetHashCode()
	{
		return mX * 397 ^ mY;
	}
}

这样 Dictionary<TileKey, TValue>HashSet<TileKey>List<TileKey>.Contains() 在使用默认比较器时,可以优先走强类型的 Equals(TileKey other)

它的价值是:

bash 复制代码
避免结构体比较时走 object 参数
减少装箱
减少默认反射式字段比较
让 HashSet / Dictionary 查找更稳定

这类优化经常出现在坐标、格子、范围、索引、二元组、三元组这类结构体上。

这些结构体本身很小,但使用频率可能非常高。


五、接口容器要避免装箱和隐式分配

一些容器接口在使用不当时会带来额外开销。

尤其是非泛型接口,或者把值类型通过接口传递时,容易触发装箱。

常见风险包括:

bash 复制代码
ICollection
IList
IDictionary
IEnumerable
object 参数
非泛型 Contains / Remove / Add

例如值类型被当成 object 传入接口方法时,就会发生装箱。

如果这种代码出现在高频路径里,就会产生 GCAlloc。

MyFramework 里的热路径更倾向于使用具体类型:

bash 复制代码
List<T>
Dictionary<TKey, TValue>
HashSet<T>
Span<T>

而不是统一写成:

bash 复制代码
ICollection<T>
IEnumerable<T>
IList<T>

框架里确实仍然有一些通用接口参数,比如工具函数、低频封装、批量归还接口。

但在高频路径里,更常见的是具体容器和具体循环。

比如 DictionaryExtension 在热更新层有针对 Dictionary<TKey,TValue> 的扩展,而不是只依赖 IDictionary<TKey,TValue>

这样做不是为了代码形式好看,而是为了避免在高频逻辑里出现接口分发、装箱和不确定的枚举分配。


六、用 Span 和 stackalloc 代替小数组

很多正常写法会创建临时数组:

bash 复制代码
Vector3[] corners = new Vector3[4];
int[] values = new int[2];
byte[] temp = new byte[4];

如果这些代码在 UI 几何计算、序列化、曲线计算、网络解析里频繁执行,就会产生大量小数组 GC。

MyFramework 里大量使用:

bash 复制代码
Span<T>
stackalloc

例如 UI 边界计算里:

bash 复制代码
Span<Vector3> tempCorners = stackalloc Vector3[4];
tempCorners[0] = new(-size.x * 0.5f, -size.y * 0.5f);
tempCorners[1] = new(-size.x * 0.5f, size.y * 0.5f);
tempCorners[2] = new(size.x * 0.5f, size.y * 0.5f);
tempCorners[3] = new(size.x * 0.5f, -size.y * 0.5f);
cornerToSide(tempCorners, sides);

序列化里也会使用:

bash 复制代码
writer.write(stackalloc int[2]{ mItemID, mItemCount }, needWriteSign);

曲线计算里:

bash 复制代码
Span<Vector3> tempControlPoint = stackalloc Vector3[4];

AssetBundle 配置读取里:

bash 复制代码
Span<byte> tempStringBuffer = stackalloc byte[256];

这种写法适合小数组、短生命周期、当前函数内使用的临时数据。

它的优势是:

bash 复制代码
不产生托管堆数组
作用域结束自动失效
适合固定长度的小临时缓冲

但它也有边界:

bash 复制代码
不能跨函数长期保存
不能放到字段里
不能异步使用
数组太大不适合 stackalloc

所以框架里通常在小型临时缓冲上使用 Span + stackalloc


七、数组和 byte\[\] 有专门对象池

不是所有临时数组都适合 stackalloc

如果数组比较大,或者需要跨多个函数使用,就不能放在栈上。

这种情况 MyFramework 里会走数组池:

bash 复制代码
public static void ARRAY<T>(out T[] array, int count)
{
	if (mArrayPool == null)
	{
		array = new T[count];
		return;
	}
	array = mArrayPool.newArray<T>(count, true);
}

byte 数组也有单独池:

bash 复制代码
public static void ARRAY_BYTE(out byte[] array, int count)
{
	if (mByteArrayPool == null)
	{
		array = new byte[count];
	}
	else
	{
		array = mByteArrayPool.newArray(count, true);
	}
}

回收时:

bash 复制代码
public static void UN_ARRAY<T>(ref T[] array, bool destroyReally = false)
{
	mArrayPool?.destroyArray(ref array, destroyReally);
}

public static void UN_ARRAY_BYTE(ref byte[] array, bool destroyReally = false)
{
	mByteArrayPool?.destroyArray(ref array, destroyReally);
}

这里的处理逻辑是:

bash 复制代码
小而固定的临时数组:
    stackalloc + Span

较大或需要普通数组 API 的临时数组:
    ArrayPool / ByteArrayPool

这样避免运行时反复 new byte[]new int[]new Vector3[]


八、Unity Physics 使用 NonAlloc API

Unity 物理查询有两类 API。

会分配数组的写法:

bash 复制代码
Collider[] hits = Physics.OverlapSphere(pos, radius);

不分配数组的写法:

bash 复制代码
int count = Physics.OverlapSphereNonAlloc(pos, radius, results, layer);

MyFramework 里的工具函数使用 NonAlloc 版本。

例如:

bash 复制代码
public static int overlapAllSphere(SphereCollider collider, Collider[] results, int layer = -1)
{
	Transform transform = collider.transform;
	Vector3 colliderWorldPos = localToWorld(transform, collider.center);
	int hitCount = Physics.OverlapSphereNonAlloc(colliderWorldPos, collider.radius, results, layer);
	return results.removeValue(hitCount, collider);
}

2D 也一样:

bash 复制代码
public static int overlapAllSphere(CircleCollider2D collider, Collider2D[] results, int layer = -1)
{
	Transform transform = collider.transform;
	Vector2 colliderWorldPos = localToWorld(transform, collider.offset);
	int hitCount = Physics2D.OverlapCircleNonAlloc(colliderWorldPos, collider.radius, results, layer);
	return results.removeValue(hitCount, collider);
}

Raycast 也使用 NonAlloc:

bash 复制代码
return Physics.RaycastNonAlloc(ray, result, maxDistance, layer);

这样调用方提供结果数组,框架只返回命中数量。

不会每次物理检测都创建新的数组。


九、yield 指令缓存

协程里常见写法:

bash 复制代码
yield return new WaitForEndOfFrame();

这会创建新的等待对象。

如果这类代码频繁执行,也会产生 GC。

MyFramework 在 AssetBundle 加载器里缓存了等待对象:

bash 复制代码
protected WaitForEndOfFrame mWaitForEndOfFrame = new(); // 用于避免GC

使用时:

bash 复制代码
yield return mWaitForEndOfFrame;

这类对象没有必要每次都 new。

如果等待条件固定,就可以缓存起来复用。


十、字符串拼接不直接依赖 + 和 params

字符串是 Unity GC 的大头之一。

常见写法:

bash 复制代码
string info = "id:" + id + ", name:" + name + ", count:" + count;

这会产生中间字符串。

另一种写法:

bash 复制代码
string.Concat(args);

如果使用 params string[],还可能额外创建参数数组。

MyFramework 里封装了 MyStringBuilder,并且配合对象池使用:

bash 复制代码
using var a = new MyStringBuilderScope(out var builder);
builder.add("cmd is invalid, type:");
builder.add(cmd.GetType().ToString());
builder.add(", id:");
builder.add(LToS(cmd.getAssignID()));
logError(builder.ToString());

MyStringBuilder 本身是池化对象:

bash 复制代码
public class MyStringBuilder : ClassObject
{
	protected StringBuilder mBuilder = new(128);

	public override void resetProperty()
	{
		base.resetProperty();
		mBuilder.Clear();
	}
}

字符串工具里还提供了固定参数数量的 strcat 重载。

注释里也写得很清楚:

bash 复制代码
// 字符串拼接,当拼接小于等于4个字符串时,直接使用+号最快,GC与StringBuilder一致

超过一定数量后,会走池化的 MyStringBuilder

bash 复制代码
public static string strcat(string str0, string str1, string str2, string str3, string str4)
{
	if (isMainThread())
	{
		using var a = new MyStringBuilderScope(out var builder);
		return builder.add(str0, str1, str2, str3, str4).ToString();
	}
	else
	{
		using var a = new ClassThreadScope<MyStringBuilder>(out var builder);
		return builder.add(str0, str1, str2, str3, str4).ToString();
	}
}

这里有两个重点:

bash 复制代码
不用 params string[],避免参数数组分配
StringBuilder 从对象池取,用完归还

最终 ToString() 仍然会产生结果字符串。

但中间拼接过程不会创建一堆临时字符串。


十一、数字转字符串做缓存

运行时经常需要把数字转成字符串。

比如 UI 显示数量、时间、等级、战力、货币。

普通写法:

bash 复制代码
value.ToString()

每次都会产生字符串。

MyFramework 里对常用整数做了缓存:

bash 复制代码
private static string[] mIntToString; // 用于快速获取整数转换后的字符串
private static Dictionary<string, int> mStringToInt;

初始化时:

bash 复制代码
protected static void initIntToString()
{
	mIntToString = new string[10240];
	mStringToInt = new();
	for (int i = 0; i < mIntToString.Length; ++i)
	{
		string iStr = i.ToString();
		mStringToInt.Add(iStr, i);
		mIntToString[i] = iStr;
	}
}

转换时先查表:

bash 复制代码
public static string IToS(int value, int minLength = 0)
{
	if (mIntToString == null)
	{
		initIntToString();
	}
	string retString;
	if (value >= 0 && value < mIntToString.Length)
	{
		retString = mIntToString[value];
	}
	else
	{
		retString = value.ToString();
	}
	...
	return retString;
}

LToSULToS 也有类似逻辑。

这样 0 到 10239 之间的整数转字符串时,直接复用缓存字符串。

常见 UI 数值可以减少大量 ToString() 分配。


十二、字符串解析提供 NonAlloc 版本

配置表和字符串参数解析里,经常会把字符串转成数组或列表。

普通写法可能是:

bash 复制代码
List<int> values = new();
foreach (string item in str.Split(','))
{
	values.Add(int.Parse(item));
}

这里会有:

bash 复制代码
Split 生成 string[]
new List
字符串转数字

MyFramework 里提供了 NonAlloc 版本。

例如:

bash 复制代码
private static List<int> mTempIntList = new();     // 避免GC
private static List<float> mTempFloatList = new(); // 避免GC
private static List<string> mTempStringList = new();

转换函数:

bash 复制代码
public static List<int> SToIsNonAlloc(string str, char separate = ',')
{
	SToIs(str, mTempIntList, separate);
	return mTempIntList;
}

float、long、byte 也有类似函数:

bash 复制代码
public static List<float> SToFsNonAlloc(string str, char separate = ',')
public static List<long> SToLsNonAlloc(string str, char separate = ',')
public static List<byte> SToBsNonAlloc(string str, char separate = ',')

这类函数的限制也很明确:

bash 复制代码
返回的是静态临时列表
使用期间不能再次调用同类 NonAlloc 函数
不能长期保存返回值

这种写法适合临时解析,不适合长期持有。


十三、文件查找也提供 NonAlloc 版本

框架里的文件工具也有 NonAlloc 版本。

例如:

bash 复制代码
public static List<string> findResourcesFilesNonAlloc(string path, string pattern, bool recursive = true, bool keepAbsolutePath = false)
{
	mTempPatternList.Clear();
	mTempPatternList.addNotEmpty(pattern);
	mTempFileList.Clear();
	findResourcesFiles(path, mTempFileList, mTempPatternList, recursive, keepAbsolutePath);
	return mTempFileList;
}

还有:

bash 复制代码
public static List<string> findFilesNonAlloc(string path, string pattern, bool recursive = true)
{
	mTempPatternList.Clear();
	mTempPatternList.addNotEmpty(pattern);
	mTempFileList1.Clear();
	findFilesInternal(path, mTempFileList1, mTempPatternList, null, recursive);
	return mTempFileList1;
}

普通版本由调用方传入列表:

bash 复制代码
findResourcesFiles(path, fileList, patterns, recursive);

NonAlloc 版本则复用框架内部临时列表。

这类函数通常用于编辑器或初始化流程,但设计思路是一致的:

bash 复制代码
要么调用方提供容器
要么框架复用临时容器
不要每次都创建新 List

十四、safe() 共享空集合

为了避免 null 判断,很多代码会写:

bash 复制代码
foreach (var item in list ?? new List<int>())
{
}

这样遇到 null 时会创建临时空列表。

MyFramework 里使用共享空集合:

bash 复制代码
public class EmptyList<T>
{
	public static List<T> mList;

	public static List<T> getEmptyList()
	{
		mList ??= new();
		return mList;
	}
}

然后:

bash 复制代码
public static List<T> safe<T>(this List<T> original)
{
	return original ?? EmptyList<T>.getEmptyList();
}

数组、HashSet、Dictionary 都有类似设计:

bash 复制代码
EmptyArray<T>
EmptyHashSet<T>
EmptyDictionary<TKey, TValue>

这样遍历时可以写:

bash 复制代码
foreach (var item in list.safe())
{
}

不会为了 null 集合临时创建空容器。

这里的边界也很清楚:

bash 复制代码
safe() 返回值只适合读取和遍历
不要把 safe() 返回的空集合当成写入入口

十五、对象、容器、数组都有池化入口

框架中不是只池化 List。

它把几类常见运行时对象都收进了池体系。

对象:

bash 复制代码
CLASS<T>()
CLASS_ONCE<T>()
UN_CLASS(ref obj)

List:

bash 复制代码
LIST<T>()
LIST_PERSIST<T>()
UN_LIST(ref list)

HashSet:

bash 复制代码
SET_PERSIST<T>()
UN_SET(ref set)

Dictionary:

bash 复制代码
DIC_PERSIST<K, V>()
UN_DIC(ref dic)

数组:

bash 复制代码
ARRAY<T>(out array, count)
ARRAY_BYTE(out array, count)
UN_ARRAY(ref array)
UN_ARRAY_BYTE(ref array)

还有线程版本:

bash 复制代码
CLASS_THREAD<T>()
ARRAY_THREAD<T>()
ARRAY_BYTE_THREAD(...)

再配合作用域结构:

bash 复制代码
ClassScope
ListScope
HashSetScope
DicScope
ArrayScope
ByteArrayScope
MyStringBuilderScope

这些不是为了让代码里到处都有 Scope。

它们的作用是把临时对象的申请和释放变成统一模式。

高频路径里需要临时对象时,优先走池。


十六、事件对象和命令对象复用

事件和命令都是框架高频路径。

事件派发如果每次都 new EventXXX,会产生 GC。

MyFramework 中事件对象继承 ClassObject,可以通过对象池创建。

例如:

bash 复制代码
public void pushEvent<T>() where T : GameEvent, new()
{
	using var a = new ClassScope<T>(out var param);
	pushEvent(param);
}

事件对象基类:

bash 复制代码
public class GameEvent : ClassObject
{
	public long mCharacterGUID;

	public override void resetProperty()
	{
		base.resetProperty();
		mCharacterGUID = 0;
	}
}

命令对象也一样。

命令执行完成后统一回收:

bash 复制代码
protected void destroyCmd(Command cmd)
{
	if (cmd == null)
	{
		return;
	}
	if (cmd.isThreadCommand())
	{
		mClassPoolThread?.destroyClass(ref cmd);
	}
	else
	{
		mClassPool?.destroyClass(ref cmd);
	}
}

对象池不是简单地把对象塞回队列。

回收时会调用 resetProperty(),清理字段状态。

这样可以避免对象复用时残留旧数据。


十七、回调列表先转移再执行

资源异步加载完成后,通常要执行一批回调。

普通写法可能是:

bash 复制代码
foreach (var callback in mCallback)
{
	callback();
}
mCallback.Clear();

问题是回调执行过程中可能继续添加回调。

如果直接遍历原列表,可能出现:

bash 复制代码
遍历过程中列表被修改
Clear 时把新加入的回调也清掉

MyFramework 的做法是先转移当前批次:

bash 复制代码
public void callbackAll()
{
	using var a = new ListScope2T<AssetLoadCallback, string>(out var callbacks, out var paths);

	mCallback.moveTo(callbacks);
	mLoadPath.moveTo(paths);

	int callbackCount = callbacks.Count;
	for (int i = 0; i < callbackCount; ++i)
	{
		callbacks[i](mSubAssets.get(0), mSubAssets, null, paths[i]);
	}
}

重点不是 ListScope2T 这个类本身。

重点是:

bash 复制代码
原始回调列表先清空
当前批次转移到临时列表
本轮只执行当前批次
新加入回调留到下一轮

这样同时解决了流程安全和临时分配问题。


十八、SafeList / SafeList0 避免遍历中修改产生额外复制

很多系统需要一边遍历一边修改列表。

普通 List<T> 在遍历中修改容易出问题。

一种做法是每次遍历前复制一份:

bash 复制代码
var temp = new List<T>(list);
foreach (var item in temp)
{
}

这种写法会产生临时列表。

MyFramework 里有 SafeList<T>SafeList0<T>

SafeList<T> 使用主列表、遍历列表、修改列表来处理遍历中增删。

SafeList0<T> 则在遍历中删除时先把元素标记为 default,等最外层遍历结束后再压缩。

这类结构的目标是:

bash 复制代码
遍历中允许增删
不需要每次都 new 一个临时副本
修改行为可控

事件系统里的监听列表就使用了 SafeList0<GameEventRegisteInfo>

因为事件回调中可能取消监听,也可能新增监听。


十九、TypeID 替代字符串事件名

字符串事件名也会带来问题。

普通事件系统可能写:

bash 复制代码
listenEvent("ItemChanged", callback);
pushEvent("ItemChanged");

字符串本身容易写错,也让事件系统运行时依赖字符串。

MyFramework 用 TypeID<T>.ID 把类型转换为 int:

bash 复制代码
static public class TypeID<T>
{
	public static readonly int ID = Interlocked.Increment(ref TypeID.mGlobalCounter);
}

事件注册时保存的是 int:

bash 复制代码
info.mEventTypeID = TypeID<T>.ID;

事件表也是:

bash 复制代码
Dictionary<int, SafeList0<GameEventRegisteInfo>>

这样调用层写类型:

bash 复制代码
listenEvent<EventItemChanged>(callback, listener);

内部查表走 int。

这不是单纯为了 GC。

但它避免了字符串事件名,也让事件表索引更直接。


二十、UI 绑定不在运行时反复查找

UI 里如果到处写:

bash 复制代码
getObject("ButtonClose");
getObject("TextTitle");

字符串路径会散落在业务逻辑里。

运行时还会反复查找节点。

MyFramework 的 UI 代码生成会把节点绑定集中到初始化阶段。

例如:

bash 复制代码
protected myUGUIButton mButtonClose;
protected myUGUIText   mTextTitle;

绑定:

bash 复制代码
newObject(out mButtonClose, "ButtonClose");
newObject(out mTextTitle, "TextTitle");

业务逻辑后续直接访问成员变量:

bash 复制代码
mButtonClose.setClickCallback(onCloseClick);
mTextTitle.setText(title);

这样可以避免:

bash 复制代码
运行时反复字符串查找
业务代码散落节点路径
节点查找产生额外临时对象

这属于 GC、性能和工程结构一起处理。


二十一、少数明确时机主动 GC

框架不希望运行时随机发生不可控 GC。

所以在一些大生命周期边界上,会主动清理。

例如 GameScene.exit()

bash 复制代码
public virtual void exit()
{
	changeProcedure(mExitProcedure);
	mCurProcedure?.exit();
	mCurProcedure = null;
	GC.Collect();
}

还有 SQLiteManager 销毁时:

bash 复制代码
SqliteConnection.ClearAllPools();
GC.Collect();
GC.WaitForPendingFinalizers();

这种做法不是让业务逻辑到处手动 GC。

而是在明确的大资源生命周期边界上,把可能积累的无用对象集中处理。

比如逻辑场景切换、数据库关闭、资源阶段退出。

这些位置本来就可能有卡顿遮罩或加载流程,更适合放主动清理。


二十二、哪些写法在框架里会特别注意

可以把 MyFramework 里的 GC 处理总结成下面这张表。

正常写法 可能的 GCAlloc 来源 框架里的处理
callback += func 新委托对象 / 新调用列表 List<Action>、注册信息对象、SafeList 保存回调
dic.Keys / dic.Values 第一次访问创建 KeyCollection / ValueCollection foreach (var item in dic),或 setRangeKeys/setRangeValues 写入已有容器
new List(dic.Keys) 新 List + 拷贝 用已有 List + setRangeKeys
asset.name / sprite.name 每次 native string 转 managed string 缓存 mAtlasNamemTextureNamemSpriteName
结构体不实现 IEquatable<T> 比较时可能走 Equals(object),导致装箱 struct 实现 IEquatable<T>,重写 EqualsGetHashCode
ICollection / IList / IEnumerable 接口调用、object 参数、值类型装箱、不确定枚举分配 热路径使用具体泛型容器
new int[2] / new Vector3[4] 小数组分配 Span<T> + stackalloc
byte[] / T[] 数组分配 ArrayPool / ByteArrayPool
Physics.OverlapSphere 返回新数组 OverlapSphereNonAlloc
yield return new WaitForEndOfFrame() 等待对象分配 缓存 WaitForEndOfFrame
字符串连续 + 中间字符串 MyStringBuilderScope / strcat 固定参数重载
params string[] 参数数组分配 多个固定参数重载
value.ToString() 新字符串 IToS/LToS 常用整数缓存
string.Split 后转列表 string\[\] + List 分配 SToIsNonAlloc 等静态临时容器版本
list ?? new List<T>() 空列表分配 safe() + EmptyList<T>
new EventXXX() 事件对象分配 GameEvent 池化
new CommandXXX() 命令对象分配 Command 池化
遍历中复制列表 临时 List 分配 SafeList / SafeList0
LINQ / 闭包 迭代器、闭包、临时集合 热路径使用 for / foreach 具体容器
运行时反复查 UI 节点 字符串路径、查找过程临时对象 UI 代码生成,初始化阶段绑定成员

总结

MyFramework 里减少 GC 的处理不是单点工具,而是一套运行时编码规则。

它关注的不是"代码里不能出现 new"。

而是这些常见 GCAlloc 来源:

bash 复制代码
委托增删
字典 Keys / Values
UnityEngine.Object.name
结构体比较
接口容器装箱
小数组
大 byte[]
字符串拼接
数字 ToString
字符串 Split
物理查询返回数组
协程等待对象
空集合临时创建
事件对象
命令对象
遍历中复制列表
运行时字符串查找 UI 节点

框架里的对应处理是:

bash 复制代码
回调列表化
字典 Key/Value 手动写入复用容器
Unity Object 名字缓存
struct 实现 IEquatable<T>
热路径避免接口容器和 object 参数
Span + stackalloc
数组池和 byte 数组池
MyStringBuilder 池化
整数转字符串缓存
字符串解析 NonAlloc
Physics NonAlloc API
等待对象缓存
safe() 共享空集合
事件和命令对象池化
SafeList / SafeList0
UI 自动绑定成员变量

这些做法的共同目标是:

bash 复制代码
把高频路径里的临时分配变成可控的生命周期管理

减少 GC 不是靠某一个类完成的。

它是框架在事件、命令、资源、UI、字符串、集合、序列化、物理检测等模块里长期积累出来的一套写法。

相关推荐
SmalBox8 小时前
【节点】[Whirl节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox1 天前
【节点】[Truchet节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_2 天前
MyFramework:Unity GameScene 和 SceneProcedure 的划分准则
unity3d
SmalBox2 天前
【节点】[Tile节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_3 天前
MyFramework:Unity 自动生成 UI 代码怎么避免覆盖手写逻辑
unity3d
SmalBox3 天前
【节点】[Taiji节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox4 天前
【节点】[Stripes节点]原理解析与实际应用
unity3d·游戏开发·图形学
qiqizizzz5 天前
Unity引擎底层 | ”Fake Null“ 假空现象
unity3d
_zhourui_h_5 天前
MyFramework:Unity TypeID 如何替代字符串和反射
unity3d