项目地址GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHubUnity 项目里的 GC很多时候不是来自某个很大的对象分配。更常见的是一些“看起来很正常”的写法在高频路径里不断产生小分配。比如Delegate callback Dictionary.Keys / Values UnityEngine.Object.name new List / new Dictionary / new byte[] 字符串拼接 params 参数 LINQ 闭包 接口容器 结构体未实现 IEquatableT Physics.OverlapXXX yield return new WaitForEndOfFrame()这些写法单独看都很普通。但如果出现在事件分发、UI 刷新、资源回调、网络解析、战斗逻辑、Update 里就会变成持续 GCAlloc。MyFramework 里减少 GC 的做法不是简单地禁止new而是对这些常见分配点做统一规避。一、不用 Delegate.Add 管理高频回调C# 里最常见的回调注册写法是callback onCallback; callback - onCallback;或者someEvent onEvent;这类写法的隐藏问题是委托是不可变对象。每次或-本质上都会生成新的委托对象。多播委托还会维护新的调用列表。所以在高频注册、取消的地方直接使用Delegate.Add并不适合。MyFramework 里很多地方没有用多播委托来保存动态回调而是使用ListAction或专门的注册信息列表。例如命令对象里protected ListCommandCallback mStartCallback new(); // 命令开始执行时的回调函数 protected ListCommandCallback 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;而是用列表保存回调。这样可以避免每次增删回调都生成新的委托调用列表。事件系统也是类似思路。事件注册不是简单地把所有回调拼成一个多播委托而是封装成GameEventRegisteInfopublic class GameEventRegisteInfo : ClassObject { public int mEventTypeID; // 事件类型ID public long mCharacterID; // 事件所属的玩家ID public IEventListener mListener; // 监听者 public Action mBaseCallback; // 不带参数的回调 }事件表里保存的是注册信息列表protected Dictionaryint, SafeList0GameEventRegisteInfo 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第一次访问时会创建对应的集合对象。如果再写成Listint keys new(dic.Keys);那又会额外创建一个List并复制一份 key。MyFramework 里为了避免这种写法在扩展函数里提供了直接遍历字典项的方法。例如public static void forKeyTKey, TValue(this DictionaryTKey, TValue list, ActionTKey action) { if (list.isEmpty()) { return; } foreach (var item in list) { action(item.Key); } }以及public static void forValueTKey, TValue(this DictionaryTKey, TValue list, ActionTValue action) { if (list.isEmpty()) { return; } foreach (var item in list) { action(item.Value); } }如果确实需要把 key 或 value 拷贝到列表里也不是直接new List(dic.Keys)。而是把结果写入一个已经存在的列表public static ListTKey setRangeKeysTKey, TValue(this ListTKey list, DictionaryTKey, TValue dic) { list.Clear(); if (dic.isEmpty()) { return list; } foreach (var item in dic) { list.add(item.Key); } return list; }value 也一样public static ListTValue setRangeValuesTKey, TValue(this ListTValue list, DictionaryTKey, 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 里对这种情况会缓存名字。例如AtlasTPprotected 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 元素比较逻辑就很重要。普通写法可能只写字段不实现IEquatableTpublic struct TileKey { public int mX; public int mY; }然后在高频逻辑里使用mTileSet.Contains(key); mTileList.Contains(key); mTileDictionary.TryGetValue(key, out var value);如果结构体没有实现IEquatableT某些比较路径可能会走到Equals(object)。这会带来装箱也可能走默认的ValueType.Equals比较逻辑。在高频容器查询里这种开销很容易被忽略。更合适的写法是让结构体实现IEquatableTpublic struct TileKey : IEquatableTileKey { 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; } }这样DictionaryTileKey, TValue、HashSetTileKey、ListTileKey.Contains()在使用默认比较器时可以优先走强类型的Equals(TileKey other)。它的价值是避免结构体比较时走 object 参数 减少装箱 减少默认反射式字段比较 让 HashSet / Dictionary 查找更稳定这类优化经常出现在坐标、格子、范围、索引、二元组、三元组这类结构体上。这些结构体本身很小但使用频率可能非常高。五、接口容器要避免装箱和隐式分配一些容器接口在使用不当时会带来额外开销。尤其是非泛型接口或者把值类型通过接口传递时容易触发装箱。常见风险包括ICollection IList IDictionary IEnumerable object 参数 非泛型 Contains / Remove / Add例如值类型被当成object传入接口方法时就会发生装箱。如果这种代码出现在高频路径里就会产生 GCAlloc。MyFramework 里的热路径更倾向于使用具体类型ListT DictionaryTKey, TValue HashSetT SpanT而不是统一写成ICollectionT IEnumerableT IListT框架里确实仍然有一些通用接口参数比如工具函数、低频封装、批量归还接口。但在高频路径里更常见的是具体容器和具体循环。比如DictionaryExtension在热更新层有针对DictionaryTKey,TValue的扩展而不是只依赖IDictionaryTKey,TValue。这样做不是为了代码形式好看而是为了避免在高频逻辑里出现接口分发、装箱和不确定的枚举分配。六、用 Span 和 stackalloc 代替小数组很多正常写法会创建临时数组Vector3[] corners new Vector3[4]; int[] values new int[2]; byte[] temp new byte[4];如果这些代码在 UI 几何计算、序列化、曲线计算、网络解析里频繁执行就会产生大量小数组 GC。MyFramework 里大量使用SpanT stackalloc例如 UI 边界计算里SpanVector3 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);曲线计算里SpanVector3 tempControlPoint stackalloc Vector3[4];AssetBundle 配置读取里Spanbyte tempStringBuffer stackalloc byte[256];这种写法适合小数组、短生命周期、当前函数内使用的临时数据。它的优势是不产生托管堆数组 作用域结束自动失效 适合固定长度的小临时缓冲但它也有边界不能跨函数长期保存 不能放到字段里 不能异步使用 数组太大不适合 stackalloc所以框架里通常在小型临时缓冲上使用Span stackalloc。七、数组和 byte[] 有专门对象池不是所有临时数组都适合stackalloc。如果数组比较大或者需要跨多个函数使用就不能放在栈上。这种情况 MyFramework 里会走数组池public static void ARRAYT(out T[] array, int count) { if (mArrayPool null) { array new T[count]; return; } array mArrayPool.newArrayT(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_ARRAYT(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 APIUnity 物理查询有两类 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 也使用 NonAllocreturn 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一致超过一定数量后会走池化的MyStringBuilderpublic 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 ClassThreadScopeMyStringBuilder(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 Dictionarystring, 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 版本配置表和字符串参数解析里经常会把字符串转成数组或列表。普通写法可能是Listint values new(); foreach (string item in str.Split(,)) { values.Add(int.Parse(item)); }这里会有Split 生成 string[] new List 字符串转数字MyFramework 里提供了 NonAlloc 版本。例如private static Listint mTempIntList new(); // 避免GC private static Listfloat mTempFloatList new(); // 避免GC private static Liststring mTempStringList new();转换函数public static Listint SToIsNonAlloc(string str, char separate ,) { SToIs(str, mTempIntList, separate); return mTempIntList; }float、long、byte 也有类似函数public static Listfloat SToFsNonAlloc(string str, char separate ,) public static Listlong SToLsNonAlloc(string str, char separate ,) public static Listbyte SToBsNonAlloc(string str, char separate ,)这类函数的限制也很明确返回的是静态临时列表 使用期间不能再次调用同类 NonAlloc 函数 不能长期保存返回值这种写法适合临时解析不适合长期持有。十三、文件查找也提供 NonAlloc 版本框架里的文件工具也有 NonAlloc 版本。例如public static Liststring 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 Liststring 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 Listint()) { }这样遇到 null 时会创建临时空列表。MyFramework 里使用共享空集合public class EmptyListT { public static ListT mList; public static ListT getEmptyList() { mList ?? new(); return mList; } }然后public static ListT safeT(this ListT original) { return original ?? EmptyListT.getEmptyList(); }数组、HashSet、Dictionary 都有类似设计EmptyArrayT EmptyHashSetT EmptyDictionaryTKey, TValue这样遍历时可以写foreach (var item in list.safe()) { }不会为了 null 集合临时创建空容器。这里的边界也很清楚safe() 返回值只适合读取和遍历 不要把 safe() 返回的空集合当成写入入口十五、对象、容器、数组都有池化入口框架中不是只池化 List。它把几类常见运行时对象都收进了池体系。对象CLASST() CLASS_ONCET() UN_CLASS(ref obj)ListLISTT() LIST_PERSISTT() UN_LIST(ref list)HashSetSET_PERSISTT() UN_SET(ref set)DictionaryDIC_PERSISTK, V() UN_DIC(ref dic)数组ARRAYT(out array, count) ARRAY_BYTE(out array, count) UN_ARRAY(ref array) UN_ARRAY_BYTE(ref array)还有线程版本CLASS_THREADT() ARRAY_THREADT() ARRAY_BYTE_THREAD(...)再配合作用域结构ClassScope ListScope HashSetScope DicScope ArrayScope ByteArrayScope MyStringBuilderScope这些不是为了让代码里到处都有 Scope。它们的作用是把临时对象的申请和释放变成统一模式。高频路径里需要临时对象时优先走池。十六、事件对象和命令对象复用事件和命令都是框架高频路径。事件派发如果每次都new EventXXX会产生 GC。MyFramework 中事件对象继承ClassObject可以通过对象池创建。例如public void pushEventT() where T : GameEvent, new() { using var a new ClassScopeT(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 ListScope2TAssetLoadCallback, 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 避免遍历中修改产生额外复制很多系统需要一边遍历一边修改列表。普通ListT在遍历中修改容易出问题。一种做法是每次遍历前复制一份var temp new ListT(list); foreach (var item in temp) { }这种写法会产生临时列表。MyFramework 里有SafeListT和SafeList0T。SafeListT使用主列表、遍历列表、修改列表来处理遍历中增删。SafeList0T则在遍历中删除时先把元素标记为default等最外层遍历结束后再压缩。这类结构的目标是遍历中允许增删 不需要每次都 new 一个临时副本 修改行为可控事件系统里的监听列表就使用了SafeList0GameEventRegisteInfo。因为事件回调中可能取消监听也可能新增监听。十九、TypeID 替代字符串事件名字符串事件名也会带来问题。普通事件系统可能写listenEvent(ItemChanged, callback); pushEvent(ItemChanged);字符串本身容易写错也让事件系统运行时依赖字符串。MyFramework 用TypeIDT.ID把类型转换为 intstatic public class TypeIDT { public static readonly int ID Interlocked.Increment(ref TypeID.mGlobalCounter); }事件注册时保存的是 intinfo.mEventTypeID TypeIDT.ID;事件表也是Dictionaryint, SafeList0GameEventRegisteInfo这样调用层写类型listenEventEventItemChanged(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新委托对象 / 新调用列表用ListAction、注册信息对象、SafeList 保存回调dic.Keys/dic.Values第一次访问创建 KeyCollection / ValueCollection用foreach (var item in dic)或setRangeKeys/setRangeValues写入已有容器new List(dic.Keys)新 List 拷贝用已有 List setRangeKeysasset.name/sprite.name每次 native string 转 managed string缓存mAtlasName、mTextureName、mSpriteName结构体不实现IEquatableT比较时可能走Equals(object)导致装箱struct 实现IEquatableT重写Equals和GetHashCodeICollection/IList/IEnumerable接口调用、object 参数、值类型装箱、不确定枚举分配热路径使用具体泛型容器new int[2]/new Vector3[4]小数组分配SpanT stackalloc大byte[]/T[]数组分配ArrayPool/ByteArrayPoolPhysics.OverlapSphere返回新数组OverlapSphereNonAllocyield return new WaitForEndOfFrame()等待对象分配缓存WaitForEndOfFrame字符串连续中间字符串MyStringBuilderScope/strcat固定参数重载params string[]参数数组分配多个固定参数重载value.ToString()新字符串IToS/LToS常用整数缓存string.Split后转列表string[] List 分配SToIsNonAlloc等静态临时容器版本list ?? new ListT()空列表分配safe()EmptyListTnew EventXXX()事件对象分配GameEvent池化new CommandXXX()命令对象分配Command池化遍历中复制列表临时 List 分配SafeList/SafeList0LINQ / 闭包迭代器、闭包、临时集合热路径使用 for / foreach 具体容器运行时反复查 UI 节点字符串路径、查找过程临时对象UI 代码生成初始化阶段绑定成员总结MyFramework 里减少 GC 的处理不是单点工具而是一套运行时编码规则。它关注的不是“代码里不能出现 new”。而是这些常见 GCAlloc 来源委托增删 字典 Keys / Values UnityEngine.Object.name 结构体比较 接口容器装箱 小数组 大 byte[] 字符串拼接 数字 ToString 字符串 Split 物理查询返回数组 协程等待对象 空集合临时创建 事件对象 命令对象 遍历中复制列表 运行时字符串查找 UI 节点框架里的对应处理是回调列表化 字典 Key/Value 手动写入复用容器 Unity Object 名字缓存 struct 实现 IEquatableT 热路径避免接口容器和 object 参数 Span stackalloc 数组池和 byte 数组池 MyStringBuilder 池化 整数转字符串缓存 字符串解析 NonAlloc Physics NonAlloc API 等待对象缓存 safe() 共享空集合 事件和命令对象池化 SafeList / SafeList0 UI 自动绑定成员变量这些做法的共同目标是把高频路径里的临时分配变成可控的生命周期管理减少 GC 不是靠某一个类完成的。它是框架在事件、命令、资源、UI、字符串、集合、序列化、物理检测等模块里长期积累出来的一套写法。