项目地址:
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.Keys 和 Dictionary.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;
}
LToS、ULToS 也有类似逻辑。
这样 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 | 缓存 mAtlasName、mTextureName、mSpriteName |
结构体不实现 IEquatable<T> |
比较时可能走 Equals(object),导致装箱 |
struct 实现 IEquatable<T>,重写 Equals 和 GetHashCode |
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、字符串、集合、序列化、物理检测等模块里长期积累出来的一套写法。