当前位置: 首页 > news >正文

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

项目地址:

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.KeysDictionary.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; }

LToSULToS也有类似逻辑。

这样 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 / ValueCollectionforeach (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 来源:

委托增删 字典 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、字符串、集合、序列化、物理检测等模块里长期积累出来的一套写法。

http://www.gsyq.cn/news/1600761.html

相关文章:

  • 从钓鱼邮件到APT攻击:基于网络杀伤链的威胁狩猎与纵深防御实战解析
  • 基于HarmonyOS 7.0 跨端开发的小说人物关系图谱页面实战
  • Ubuntu20.04下PX4与Mavros的通信配置及XTDrone仿真环境排错指南
  • 机考环境不适应?3类典型崩溃场景,7天模拟训练方案全公开
  • HarmonyOS应用开发实战:SM4国密算法加解密完整实现指南
  • 建筑物混凝土墙面脱落剥落裂缝识别分割数据集labelme格式1576张2类别
  • UVa 617 Nonstop Travel
  • Go定时任务库全景解析:从Cron到JobRunner,如何为你的项目精准选型?
  • Bili2text:3分钟将B站视频转为可编辑文字稿的终极方案
  • AI贺卡的伦理困境:当祝福变成可调度的API
  • SRC漏洞挖掘入门:从零构建合规高效的安全测试工作流
  • 终极指南:如何在Blender中免费导入导出MMD模型与动作数据
  • RL78 MCU上FreeRTOS移植与Blinky Demo实战解析
  • FakeLocation:3步实现Android应用级位置模拟的完整实战指南
  • 空洞骑士模组管理器Scarab:2024终极安装与管理完全指南
  • VoiceFixer语音修复工具终极指南:如何一站式解决音频噪声、失真和低质量语音问题?
  • 华为防火墙双机热备实战:从VRRP到VGMP的平滑演进
  • MoE架构揭秘:1.8万亿参数与2%稀疏激活的工程真相
  • DLSS Swapper完整指南:一键智能切换DLSS版本,轻松提升游戏性能
  • 影刀RPA新手教程:多账号并发自动化完全指南——线程管理、资源隔离与异常恢复
  • 如何快速掌握BetterJoy:Switch控制器在PC上的终极解决方案
  • 四十六、QT应用开发之MVC架构实战:从解耦到多线程的完整实现
  • Diffie-Hellman密钥交换:从离散对数原理到Java工程实现
  • 基于Docker容器化部署Jira 9.12.0:从环境准备到生产级配置实战
  • 3分钟解密网易云音乐:ncmdump让你的NCM文件重获自由播放权
  • 无线实现分部AP通过总部AC NAT公网地址注册
  • Nginx与SpringBoot TLS安全加固实战:从等保测评失败到A+评级
  • CPAL脚本自动化测试 ———— 文件操作实战:从读写到配置管理的完整流程
  • 多模态AI如何模仿人脑实现跨模态对齐与具身推理
  • 解密抖音直播数据采集:从逆向工程到实时分析的技术突破