项目地址:
GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub
Unity 项目里的 GC,很多时候不是来自某个很大的对象分配。
更常见的是一些“看起来很正常”的写法,在高频路径里不断产生小分配。
比如:
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# 里最常见的回调注册写法是:
callback += onCallback; callback -= onCallback;或者:
someEvent += onEvent;这类写法的隐藏问题是:委托是不可变对象。
每次+=或-=,本质上都会生成新的委托对象。
多播委托还会维护新的调用列表。
所以在高频注册、取消的地方,直接使用Delegate.Add并不适合。
MyFramework 里很多地方没有用多播委托来保存动态回调,而是使用List<Action>或专门的注册信息列表。
例如命令对象里:
protected List<CommandCallback> mStartCallback = new(); // 命令开始执行时的回调函数 protected List<CommandCallback> mEndCallback = new(); // 命令执行完毕时的回调函数添加回调时:
public void addEndCommandCallback(CommandCallback cmdCallback) { mEndCallback.addNotNull(cmdCallback); } public void addStartCommandCallback(CommandCallback cmdCallback) { mStartCallback.addNotNull(cmdCallback); }执行后清理:
public void invokeEndCallBack() { mEndCallback.For(callback => callback(this)); mEndCallback.Clear(); }这里不是用:
mEndCallback += callback;而是用列表保存回调。
这样可以避免每次增删回调都生成新的委托调用列表。
事件系统也是类似思路。
事件注册不是简单地把所有回调拼成一个多播委托,而是封装成GameEventRegisteInfo:
public class GameEventRegisteInfo : ClassObject { public int mEventTypeID; // 事件类型ID public long mCharacterID; // 事件所属的玩家ID public IEventListener mListener; // 监听者 public Action mBaseCallback; // 不带参数的回调 }事件表里保存的是注册信息列表:
protected Dictionary<int, SafeList0<GameEventRegisteInfo>> mGlobalListenerEventList = new();这样事件系统可以按事件类型查找、按监听者反向清理,也避免了频繁Delegate.Combine/Delegate.Remove。
二、Dictionary.Keys / Values 不直接在高频路径使用
很多人会写:
foreach (var key in dic.Keys) { }或者:
foreach (var value in dic.Values) { }Dictionary.Keys和Dictionary.Values第一次访问时会创建对应的集合对象。
如果再写成:
List<int> keys = new(dic.Keys);那又会额外创建一个List并复制一份 key。
MyFramework 里为了避免这种写法,在扩展函数里提供了直接遍历字典项的方法。
例如:
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); } }以及:
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)。
而是把结果写入一个已经存在的列表:
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 也一样:
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; }这种写法的目的很明确:
不直接依赖 Dictionary.Keys / Values 不临时 new List(dic.Keys) 复用已有 List 通过遍历 KeyValuePair 取 Key / Value三、UnityEngine.Object.name 要缓存
Unity 里访问UnityEngine.Object.name是一个很容易忽略的 GC 来源。
比如:
string name = sprite.name;或者:
if (texture.name == targetName) { }UnityEngine.Object.name每次访问都会从 Unity native 对象生成一个 managed string。
所以如果在高频逻辑里反复访问.name,就会持续产生字符串分配。
MyFramework 里对这种情况会缓存名字。
例如AtlasTP:
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也是一样:
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 名字:
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; }这里的原则是:
对象刚设置时可以访问一次 .name 运行时反复使用时读缓存字段尤其是 Sprite、Texture、Atlas 这种资源对象,名字经常作为索引或调试信息使用,不应该在运行时频繁访问 Unity 的.name属性。
四、结构体实现 IEquatable
结构体如果经常作为列表元素、字典 Key、HashSet 元素,比较逻辑就很重要。
普通写法可能只写字段,不实现IEquatable<T>:
public struct TileKey { public int mX; public int mY; }然后在高频逻辑里使用:
mTileSet.Contains(key); mTileList.Contains(key); mTileDictionary.TryGetValue(key, out var value);如果结构体没有实现IEquatable<T>,某些比较路径可能会走到Equals(object)。
这会带来装箱,也可能走默认的ValueType.Equals比较逻辑。
在高频容器查询里,这种开销很容易被忽略。
更合适的写法是让结构体实现IEquatable<T>:
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)。
它的价值是:
避免结构体比较时走 object 参数 减少装箱 减少默认反射式字段比较 让 HashSet / Dictionary 查找更稳定这类优化经常出现在坐标、格子、范围、索引、二元组、三元组这类结构体上。
这些结构体本身很小,但使用频率可能非常高。
五、接口容器要避免装箱和隐式分配
一些容器接口在使用不当时会带来额外开销。
尤其是非泛型接口,或者把值类型通过接口传递时,容易触发装箱。
常见风险包括:
ICollection IList IDictionary IEnumerable object 参数 非泛型 Contains / Remove / Add例如值类型被当成object传入接口方法时,就会发生装箱。
如果这种代码出现在高频路径里,就会产生 GCAlloc。
MyFramework 里的热路径更倾向于使用具体类型:
List<T> Dictionary<TKey, TValue> HashSet<T> Span<T>而不是统一写成:
ICollection<T> IEnumerable<T> IList<T>框架里确实仍然有一些通用接口参数,比如工具函数、低频封装、批量归还接口。
但在高频路径里,更常见的是具体容器和具体循环。
比如DictionaryExtension在热更新层有针对Dictionary<TKey,TValue>的扩展,而不是只依赖IDictionary<TKey,TValue>。
这样做不是为了代码形式好看,而是为了避免在高频逻辑里出现接口分发、装箱和不确定的枚举分配。
六、用 Span 和 stackalloc 代替小数组
很多正常写法会创建临时数组:
Vector3[] corners = new Vector3[4]; int[] values = new int[2]; byte[] temp = new byte[4];如果这些代码在 UI 几何计算、序列化、曲线计算、网络解析里频繁执行,就会产生大量小数组 GC。
MyFramework 里大量使用:
Span<T> stackalloc例如 UI 边界计算里:
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);序列化里也会使用:
writer.write(stackalloc int[2]{ mItemID, mItemCount }, needWriteSign);曲线计算里:
Span<Vector3> tempControlPoint = stackalloc Vector3[4];AssetBundle 配置读取里:
Span<byte> tempStringBuffer = stackalloc byte[256];这种写法适合小数组、短生命周期、当前函数内使用的临时数据。
它的优势是:
不产生托管堆数组 作用域结束自动失效 适合固定长度的小临时缓冲但它也有边界:
不能跨函数长期保存 不能放到字段里 不能异步使用 数组太大不适合 stackalloc所以框架里通常在小型临时缓冲上使用Span + stackalloc。
七、数组和 byte[] 有专门对象池
不是所有临时数组都适合stackalloc。
如果数组比较大,或者需要跨多个函数使用,就不能放在栈上。
这种情况 MyFramework 里会走数组池:
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 数组也有单独池:
public static void ARRAY_BYTE(out byte[] array, int count) { if (mByteArrayPool == null) { array = new byte[count]; } else { array = mByteArrayPool.newArray(count, true); } }回收时:
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); }这里的处理逻辑是:
小而固定的临时数组: stackalloc + Span 较大或需要普通数组 API 的临时数组: ArrayPool / ByteArrayPool这样避免运行时反复new byte[]、new int[]、new Vector3[]。
八、Unity Physics 使用 NonAlloc API
Unity 物理查询有两类 API。
会分配数组的写法:
Collider[] hits = Physics.OverlapSphere(pos, radius);不分配数组的写法:
int count = Physics.OverlapSphereNonAlloc(pos, radius, results, layer);MyFramework 里的工具函数使用 NonAlloc 版本。
例如:
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 也一样:
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:
return Physics.RaycastNonAlloc(ray, result, maxDistance, layer);这样调用方提供结果数组,框架只返回命中数量。
不会每次物理检测都创建新的数组。
九、yield 指令缓存
协程里常见写法:
yield return new WaitForEndOfFrame();这会创建新的等待对象。
如果这类代码频繁执行,也会产生 GC。
MyFramework 在 AssetBundle 加载器里缓存了等待对象:
protected WaitForEndOfFrame mWaitForEndOfFrame = new(); // 用于避免GC使用时:
yield return mWaitForEndOfFrame;这类对象没有必要每次都 new。
如果等待条件固定,就可以缓存起来复用。
十、字符串拼接不直接依赖 + 和 params
字符串是 Unity GC 的大头之一。
常见写法:
string info = "id:" + id + ", name:" + name + ", count:" + count;这会产生中间字符串。
另一种写法:
string.Concat(args);如果使用params string[],还可能额外创建参数数组。
MyFramework 里封装了MyStringBuilder,并且配合对象池使用:
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本身是池化对象:
public class MyStringBuilder : ClassObject { protected StringBuilder mBuilder = new(128); public override void resetProperty() { base.resetProperty(); mBuilder.Clear(); } }字符串工具里还提供了固定参数数量的strcat重载。
注释里也写得很清楚:
// 字符串拼接,当拼接小于等于4个字符串时,直接使用+号最快,GC与StringBuilder一致超过一定数量后,会走池化的MyStringBuilder:
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(); } }这里有两个重点:
不用 params string[],避免参数数组分配 StringBuilder 从对象池取,用完归还最终ToString()仍然会产生结果字符串。
但中间拼接过程不会创建一堆临时字符串。
十一、数字转字符串做缓存
运行时经常需要把数字转成字符串。
比如 UI 显示数量、时间、等级、战力、货币。
普通写法:
value.ToString()每次都会产生字符串。
MyFramework 里对常用整数做了缓存:
private static string[] mIntToString; // 用于快速获取整数转换后的字符串 private static Dictionary<string, int> mStringToInt;初始化时:
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; } }转换时先查表:
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 版本
配置表和字符串参数解析里,经常会把字符串转成数组或列表。
普通写法可能是:
List<int> values = new(); foreach (string item in str.Split(',')) { values.Add(int.Parse(item)); }这里会有:
Split 生成 string[] new List 字符串转数字MyFramework 里提供了 NonAlloc 版本。
例如:
private static List<int> mTempIntList = new(); // 避免GC private static List<float> mTempFloatList = new(); // 避免GC private static List<string> mTempStringList = new();转换函数:
public static List<int> SToIsNonAlloc(string str, char separate = ',') { SToIs(str, mTempIntList, separate); return mTempIntList; }float、long、byte 也有类似函数:
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 = ',')这类函数的限制也很明确:
返回的是静态临时列表 使用期间不能再次调用同类 NonAlloc 函数 不能长期保存返回值这种写法适合临时解析,不适合长期持有。
十三、文件查找也提供 NonAlloc 版本
框架里的文件工具也有 NonAlloc 版本。
例如:
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; }还有:
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; }普通版本由调用方传入列表:
findResourcesFiles(path, fileList, patterns, recursive);NonAlloc 版本则复用框架内部临时列表。
这类函数通常用于编辑器或初始化流程,但设计思路是一致的:
要么调用方提供容器 要么框架复用临时容器 不要每次都创建新 List十四、safe() 共享空集合
为了避免 null 判断,很多代码会写:
foreach (var item in list ?? new List<int>()) { }这样遇到 null 时会创建临时空列表。
MyFramework 里使用共享空集合:
public class EmptyList<T> { public static List<T> mList; public static List<T> getEmptyList() { mList ??= new(); return mList; } }然后:
public static List<T> safe<T>(this List<T> original) { return original ?? EmptyList<T>.getEmptyList(); }数组、HashSet、Dictionary 都有类似设计:
EmptyArray<T> EmptyHashSet<T> EmptyDictionary<TKey, TValue>这样遍历时可以写:
foreach (var item in list.safe()) { }不会为了 null 集合临时创建空容器。
这里的边界也很清楚:
safe() 返回值只适合读取和遍历 不要把 safe() 返回的空集合当成写入入口十五、对象、容器、数组都有池化入口
框架中不是只池化 List。
它把几类常见运行时对象都收进了池体系。
对象:
CLASS<T>() CLASS_ONCE<T>() UN_CLASS(ref obj)List:
LIST<T>() LIST_PERSIST<T>() UN_LIST(ref list)HashSet:
SET_PERSIST<T>() UN_SET(ref set)Dictionary:
DIC_PERSIST<K, V>() UN_DIC(ref dic)数组:
ARRAY<T>(out array, count) ARRAY_BYTE(out array, count) UN_ARRAY(ref array) UN_ARRAY_BYTE(ref array)还有线程版本:
CLASS_THREAD<T>() ARRAY_THREAD<T>() ARRAY_BYTE_THREAD(...)再配合作用域结构:
ClassScope ListScope HashSetScope DicScope ArrayScope ByteArrayScope MyStringBuilderScope这些不是为了让代码里到处都有 Scope。
它们的作用是把临时对象的申请和释放变成统一模式。
高频路径里需要临时对象时,优先走池。
十六、事件对象和命令对象复用
事件和命令都是框架高频路径。
事件派发如果每次都new EventXXX,会产生 GC。
MyFramework 中事件对象继承ClassObject,可以通过对象池创建。
例如:
public void pushEvent<T>() where T : GameEvent, new() { using var a = new ClassScope<T>(out var param); pushEvent(param); }事件对象基类:
public class GameEvent : ClassObject { public long mCharacterGUID; public override void resetProperty() { base.resetProperty(); mCharacterGUID = 0; } }命令对象也一样。
命令执行完成后统一回收:
protected void destroyCmd(Command cmd) { if (cmd == null) { return; } if (cmd.isThreadCommand()) { mClassPoolThread?.destroyClass(ref cmd); } else { mClassPool?.destroyClass(ref cmd); } }对象池不是简单地把对象塞回队列。
回收时会调用resetProperty(),清理字段状态。
这样可以避免对象复用时残留旧数据。
十七、回调列表先转移再执行
资源异步加载完成后,通常要执行一批回调。
普通写法可能是:
foreach (var callback in mCallback) { callback(); } mCallback.Clear();问题是回调执行过程中可能继续添加回调。
如果直接遍历原列表,可能出现:
遍历过程中列表被修改 Clear 时把新加入的回调也清掉MyFramework 的做法是先转移当前批次:
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这个类本身。
重点是:
原始回调列表先清空 当前批次转移到临时列表 本轮只执行当前批次 新加入回调留到下一轮这样同时解决了流程安全和临时分配问题。
十八、SafeList / SafeList0 避免遍历中修改产生额外复制
很多系统需要一边遍历一边修改列表。
普通List<T>在遍历中修改容易出问题。
一种做法是每次遍历前复制一份:
var temp = new List<T>(list); foreach (var item in temp) { }这种写法会产生临时列表。
MyFramework 里有SafeList<T>和SafeList0<T>。
SafeList<T>使用主列表、遍历列表、修改列表来处理遍历中增删。
SafeList0<T>则在遍历中删除时先把元素标记为default,等最外层遍历结束后再压缩。
这类结构的目标是:
遍历中允许增删 不需要每次都 new 一个临时副本 修改行为可控事件系统里的监听列表就使用了SafeList0<GameEventRegisteInfo>。
因为事件回调中可能取消监听,也可能新增监听。
十九、TypeID 替代字符串事件名
字符串事件名也会带来问题。
普通事件系统可能写:
listenEvent("ItemChanged", callback); pushEvent("ItemChanged");字符串本身容易写错,也让事件系统运行时依赖字符串。
MyFramework 用TypeID<T>.ID把类型转换为 int:
static public class TypeID<T> { public static readonly int ID = Interlocked.Increment(ref TypeID.mGlobalCounter); }事件注册时保存的是 int:
info.mEventTypeID = TypeID<T>.ID;事件表也是:
Dictionary<int, SafeList0<GameEventRegisteInfo>>这样调用层写类型:
listenEvent<EventItemChanged>(callback, listener);内部查表走 int。
这不是单纯为了 GC。
但它避免了字符串事件名,也让事件表索引更直接。
二十、UI 绑定不在运行时反复查找
UI 里如果到处写:
getObject("ButtonClose"); getObject("TextTitle");字符串路径会散落在业务逻辑里。
运行时还会反复查找节点。
MyFramework 的 UI 代码生成会把节点绑定集中到初始化阶段。
例如:
protected myUGUIButton mButtonClose; protected myUGUIText mTextTitle;绑定:
newObject(out mButtonClose, "ButtonClose"); newObject(out mTextTitle, "TextTitle");业务逻辑后续直接访问成员变量:
mButtonClose.setClickCallback(onCloseClick); mTextTitle.setText(title);这样可以避免:
运行时反复字符串查找 业务代码散落节点路径 节点查找产生额外临时对象这属于 GC、性能和工程结构一起处理。
二十一、少数明确时机主动 GC
框架不希望运行时随机发生不可控 GC。
所以在一些大生命周期边界上,会主动清理。
例如GameScene.exit():
public virtual void exit() { changeProcedure(mExitProcedure); mCurProcedure?.exit(); mCurProcedure = null; GC.Collect(); }还有 SQLiteManager 销毁时:
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 来源:
委托增删 字典 Keys / Values UnityEngine.Object.name 结构体比较 接口容器装箱 小数组 大 byte[] 字符串拼接 数字 ToString 字符串 Split 物理查询返回数组 协程等待对象 空集合临时创建 事件对象 命令对象 遍历中复制列表 运行时字符串查找 UI 节点框架里的对应处理是:
回调列表化 字典 Key/Value 手动写入复用容器 Unity Object 名字缓存 struct 实现 IEquatable<T> 热路径避免接口容器和 object 参数 Span + stackalloc 数组池和 byte 数组池 MyStringBuilder 池化 整数转字符串缓存 字符串解析 NonAlloc Physics NonAlloc API 等待对象缓存 safe() 共享空集合 事件和命令对象池化 SafeList / SafeList0 UI 自动绑定成员变量这些做法的共同目标是:
把高频路径里的临时分配变成可控的生命周期管理减少 GC 不是靠某一个类完成的。
它是框架在事件、命令、资源、UI、字符串、集合、序列化、物理检测等模块里长期积累出来的一套写法。