ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

GameFramework对象池模块详解:优化Unity高频实例化与GC性能

GameFramework对象池模块详解:优化Unity高频实例化与GC性能 先说明一下标题里写的是 GameFramwork框架本名其实是 GameFramework我猜是笔误不影响学习。这篇是系列第三篇讲的是它的对象池模块ObjectPool。如果你正在做射击、动作类游戏或者经常需要高频生成和销毁物体那这个模块你应该重点研究它解决的不只是性能问题还顺带帮你规范了对象生命周期。1. 对象池解决的是性能和 GC 问题1.1 没有对象池时反复 Instantiate 和 Destroy 有多痛我之前做过一个弹幕类小游戏前期偷懒子弹全是 Instantiate 生成、碰撞后 Destroy。单发子弹在编辑器里实测耗时 0.3ms 左右看着不高对吧但弹幕游戏一波怪同时射出 60 发子弹加上弹幕轨迹拖尾特效和每一次碰撞产生的爆炸粒子一帧里可能有几十次 Instantiate。一加就是十几毫秒直接卡掉一半帧预算。更隐蔽的问题是 GC。Unity 的 Instantiate 和 Destroy 会带来内存分配和堆碎片Destroy 并不是立刻从内存里消失对象还要等到帧末才会被真正收掉。帧率看着还行但真机上过几分钟就开始周期性卡顿那是 GC 在拖后腿。用 Profiler 看一遍满屏的Instantiate和Destroy调用记录血压直接拉满。对象池就是为了解决这两件事减少创建和销毁的开销、避免频繁分配内存。说白了就是提前把对象准备好用的时候拿出来不用的时候还回去而不是每次重新做。1.2 对象池的本质是复用不是缓存很多人一开始会把对象池理解成一个 List 装着空闲对象需要用就取、不需要就存。这个理解方向是对的但不够。对象池要解决的另一个关键问题是使用权一个对象不能被两个系统同时拿过去用。你可以把对象池想成一家自行车租赁点。车就那么多辆你租走一辆别人就不能再租同一辆你还回来车才重新进入可租状态。如果还回来的车爆胎了租赁点还要负责修好—这就是对象池里的重置和清理环节。GameFramework 的对象池实现得比我见过的很多自制池子要严格它把空闲和使用中这两个状态拆得很清楚池子内部会维护对象的空闲状态、最后使用时间、锁定标记和释放顺序。后面我会细说这些状态是怎么配合的。1.3 什么场景才需要真上对象池不是所有东西都要进对象池。拿我踩过的坑来说一开始我把 UI 上的一些常驻面板也塞进对象池结果池化带来的复杂度比省下的那点性能还多。适合用对象池的典型对象子弹、弹壳、敌人、掉落物、飘字这类高频率生成销毁的东西。粒子特效、拖尾、音效播放器创建开销不一定大但量一大就难受。UI 滚动列表中反复实例化和销毁的 Item。需要复用的数据对象比如网络消息体、临时计算用的结构体。不适合硬套对象池的整个场景里就只有一两个、生命周期很长、加载一次就不动的对象。创建开销本来就极小的对象比如new Listint()这种数据容器池化反而增加代码复杂度。判断标准很简单同屏峰值数量 × 创建销毁频率是否真的影响到了帧率和 GC。如果只是偶尔用一次别折腾对象池无脑池化是另一种浪费。2. GameFramework 对象池的设计思路它只管理对象不负责创建2.1 核心三类ObjectPoolManager、ObjectPoolT、ObjectBaseGameFramework 的对象池由三部分协作ObjectPoolManager全局管理器负责创建、获取、销毁所有对象池并在主循环里驱动各个池子做释放检查。ObjectPoolT一个具体对象池实例内部维护对象链表T 必须是ObjectBase的子类。ObjectBase放进池子里所有对象的基类封装了生命周期方法。我第一次看这套设计的时候有个疑问为什么不直接池化GameObject或者 Prefab非得再包一层ObjectBase后来想明白了直接池化 GameObject 会把框架和 Unity 的运行时绑死不方便测试也不方便管理非 Unity 对象比如网络消息体、协议数据。而ObjectBase这套抽象既可以包装一个GameObject也可以包装一个纯 C# 对象灵活性高很多。换个角度理解对象池只负责管理对象的存取不关心对象内部是什么。你给它一个子弹对象它帮你管理子弹对象的借出和归还你给它一个敌人生成器它也照样管得住。2.2 Spawn / Register / Unspawn 三者的关系这是很多人第一次接触 GameFramework 对象池时最容易绕晕的地方。我直接用代码关系来展示// 从池中获取一个对象 T obj pool.Spawn(); // obj 为 null说明池里没有可用对象Spawn()只做一件事从空闲列表里挑一个对象标记为使用中返回给你。如果池子里压根没有空闲对象直接返回null它不会帮你创建新对象。所以要配合Register()一起用T obj pool.Spawn(); if (obj null) { obj new MyObject(); // 或者从资源系统创建 pool.Register(obj, true); // 注册进池子并标记为使用中 }Register(obj, true)的第二个参数是spawned表示注册后是否立刻视为已取出。如果传false对象注册进池子后仍是空闲状态你需要再调一次Spawn()才能拿到它。两种写法都行我习惯传true少一道手续。归还时用Unspawn()pool.Unspawn(obj);调用之后这个对象重新变为空闲状态可以被后续的Spawn()取出复用。这套设计里最反直觉的点是池子本身不是工厂不干活儿只负责管理。创建对象的逻辑必须由你实现。刚开始觉得麻烦实际用下来反而好因为创建逻辑你可以自己控制——可能是加载资源、可能是对象工厂、可能是从别的地方转移过来池子都不管只保证从我手上借走的对象最终必须还回来。2.3 对象池的自动释放机制是怎么回事光有存取还不够对象池还得考虑什么时候把空闲对象真销毁掉。GameFramework 的对象池里每个对象都有LastUseTime最后一次被归还的时间。池子会按帧检查所有空闲对象空闲时间超过ExpireTime的对象会被释放。池中总对象数超过Capacity时多出来的空闲对象会被释放。被标记为 Locked 的对象不会被自动释放。释放顺序会优先挑优先级更低、空闲时间更长的对象。这就是为什么建议你不要图省事把ExpireTime设成float.MaxValue那等于把自动清理废掉了。后面第四节我会单独说参数怎么定。自动释放是框架在主循环的Update里触发的不是实时立刻执行所以你Unspawn一个对象后不会马上销毁它会在池子里待一阵子。这个延迟销毁其实是特性给了对象被复用的机会避免反复创建销毁。3. 手把手写一个子弹对象池3.1 定义 BulletObject继承 ObjectBase 的正确姿势先用一个最简单的子弹池演示完整流程。假设你有一个BulletMonoBehaviour挂在子弹 Prefab 上public class Bullet : MonoBehaviour { public float speed 20f; public int damage 1; public void Launch(Vector3 pos, Vector3 dir) { transform.position pos; transform.rotation Quaternion.LookRotation(dir); gameObject.SetActive(true); } public void Hide() { gameObject.SetActive(false); } }然后定义BulletObject继承ObjectBase它负责任务包装这个 Bullet 实例处理真正创建和真正销毁的逻辑public class BulletObject : ObjectBase { private Bullet m_Bullet; public Bullet Bullet { get { return m_Bullet; } } public BulletObject(string name, Bullet bullet) : base(name) { m_Bullet bullet; } protected override void Release() { // 真正销毁对象调用 Destroy 或者回收到资源系统 if (m_Bullet ! null) { Object.Destroy(m_Bullet.gameObject); m_Bullet null; } } public override void Clear() { // 清理引用断开对外部对象的引用防止内存泄漏 m_Bullet null; } }这里有个细节必须提Release()和Clear()不是一回事。Release()是对象被池子判定可以销毁时调用的执行真正的销毁动作Clear()是框架在你主动清空池子时调用的断开引用、让对象可以被 GC 回收。如果只把引用置空不销毁资源就泄漏了如果只销毁不置空后续可能访问到空引用。两者要配合好。3.2 创建对象池容量、过期时间、优先级怎么传在你管理子弹的BulletSystem里创建对象池public class BulletSystem : MonoBehaviour { private IObjectPoolBulletObject m_BulletPool; private void Start() { m_BulletPool GameEntry.ObjectPool.CreateObjectPoolBulletObject( BulletPool, // 对象池名称 100, // 容量同时存在的最大对象数量 30f, // 过期时间空闲对象 30 秒后自动释放 100 // 优先级越大越不容易被自动释放 ); } private void OnDestroy() { if (m_BulletPool ! null) { m_BulletPool.Clear(); } } }注意GameEntry.ObjectPool是我在入口脚本里暴露的框架组件引用项目里直接这么写是不够的一般会自己封装一个GameEntry或者框架组件容器。这不影响对象池本身的理解。CreateObjectPool的泛型参数就是我们的BulletObject。如果你不传名称框架会用类型名当默认池名但我建议显式命名后面排查问题的时候一眼能看出是哪个池子。3.3 子弹的开枪和回收逻辑完整写法现在写完整的取子弹、发子弹、回收子弹流程public Bullet SpawnBullet(Vector3 pos, Vector3 dir) { BulletObject bulletObj m_BulletPool.Spawn(); if (bulletObj null) { // 池中没有空闲对象自行创建 Bullet newBullet CreateBulletInstance(); bulletObj new BulletObject(Bullet, newBullet); m_BulletPool.Register(bulletObj, true); } Bullet bullet bulletObj.Bullet; bullet.Launch(pos, dir); return bullet; } public void DespawnBullet(BulletObject bulletObj) { if (bulletObj null || bulletObj.Bullet null) { return; } bulletObj.Bullet.Hide(); m_BulletPool.Unspawn(bulletObj); } private Bullet CreateBulletInstance() { // 这里可以是 Resources.Load、Addressables 加载或者直接从场景里取 GameObject go Object.Instantiate(m_BulletPrefab); return go.GetComponentBullet(); }你可以看到Spawn()返回null的时候先自己创建再Register(bulletObj, true)这一步很重要如果Register时不传true对象会处于空闲状态你还得再调一次Spawn()才能拿到它不如一次到位。子弹本身不知道自己是池化对象也不需要知道。它只管飞行、碰撞、隐藏。碰撞检测到目标或者超出射程时由外部调用DespawnBullet(bulletObj)把它还回池子。谁借出、谁归还这个责任一定要清晰。3.4 给对象池命名什么时候有必要区分有两层命名容易搞混分开说第一层对象池的名称。同一个IObjectPoolT里你只能存T类型的对象。如果同一个类型要跑多个池子例如两种不同手感、不同性能配置的子弹可以建两个池子分别命名BulletPool_Pistol和BulletPool_Rifle。这样每个池子的容量、过期时间都能单独调互不干扰。第二层ObjectBase的 Name。这用于在同一个池子里区分不同对象。比如同一个对象池里可以同时放子弹 A和子弹 B它们的BulletObject里Name不同。Spawn()有个带name参数的重载可以按名字取。不过实际项目里我很少用到这一层用多个池子管理不同对象更清晰。命名规范我自己的习惯是模块名 对象类型 池名。排查问题看到BulletSystem_Bullet_Pool就立刻知道是哪个系统、哪种对象。4. 容量、过期时间、优先级参数怎么设才合理4.1 capacity 不是越大越好也不是越小越省容量决定池子里最多能存多少对象。超过容量的部分空闲对象会被逐步释放但使用中的对象不会被强杀。这里有一个池子总对象数 空闲数 使用中数的关系所以容量设小了可能经常出现Spawn()返回 null被迫重新创建对象池化效果就打折了。容量的估算公式很简单[ 容量 \approx 同屏最大同时存在的对象数 余量 ]举个例子假设子弹是每秒发射 20 发子弹飞行时间是 1 秒那么同屏同时存在的子弹峰值大约是 20 发。再考虑一波敌人同时开火的情况乘个 2 到 3 倍安全系数设 50 到 60 比较合理。容量不是越大越好。每个对象都要占内存池子塞了 200 个对象但实际只用 20 个这 180 个空闲对象就是白缴的内存税。你可以在 Profiler 里观察实际峰值再回来微调容量。4.2 expireTime 控制的是空闲对象多久被释放过期时间是池子自动清理空闲对象的依据。空闲超过这个时间池子就会把它释放掉。注意它是空闲时间不是存活时间。对象一直在使用中即使过了 10 分钟也不会被释放一旦归还就开始倒计时。不同场景我给的经验值对象类型建议 expireTime理由子弹/弹壳10s ~ 30s短生命周期、高频复用30 秒足够撑过战斗密集期敌人60s ~ 120s波次间隔可能较长但又不想常驻大小不固定的 UI 列表 Item300s 或 float.MaxValue避免频繁重建容量控制更关键粒子特效30s ~ 60s特效对象量大建议短一点随用随建一个典型误区是把所有池都设成float.MaxValue。比如子弹池设了无限过期时间一局游戏打了 5000 发子弹池里的对象数会一路涨到接近容量上限大部分空闲对象永远留在内存里。除非你的业务对性能有严苛要求否则不建议这种用法。4.3 priority 影响对象被释放的顺序当池子需要释放一些对象超容量或超时时优先释放哪些对象优先级就是用来排序的优先级越低越先被释放。我习惯把优先级理解成保底程度。常用配置建议反复创建开销很高的对象池比如复杂的角色模型池优先级设高一点比如 100。创建开销很低的对象池比如简单 UI 控件池设低一点比如 0 或 10。同一个池子里的对象也可以单独设置Priority属性比如稀有角色的实例优先级高于普通小怪。优先级不是越高越好。如果所有池都设成最高优先级等于大家都不释放内存压力反而转移到全局。我通常只给 2~3 个核心池设高优先级其他用默认值甚至低值。5. 实际项目里的常见坑和排查方法5.1 Spawn 拿到 null 怎么办这个是刚上手时命中率最高的坑。GameFramework 的Spawn()和很多自制对象池不一样它不负责自动创建对象。你调用Spawn()拿到的 null并不代表池子坏了而是池子确实没有空闲对象了。正确的处理姿势就是第三节写的先Spawn()拿到 null 就 new Register(spawned: true)。我这里再强调一个细节Register后如果不传true对象是空闲状态你立刻用会出问题——你以为自己拿到了使用中的对象但池子认为它是空闲的万一被别人Spawn()走了同一个对象就被两个系统同时使用。我早期出现过这种问题某次Register(bulletObj)没传true然后直接操作子弹结果下一帧另一个怪物也拿到了同一个子弹对象两套逻辑同时改同一个 Transform画面直接乱飞。排查半天才发现是spawned参数的问题。5.2 回收后对象状态没重置复用时翻车对象池复用的第一大隐患是上一个使用场景的状态残留。子弹上次飞行的速度、方向、伤害、命中特效可能全都还挂在对象身上。你Unspawn()的时候不清理下次Spawn()出来就直接用轻则表现异常重则逻辑报错。我的写法是把Launch做成完全重置语义。每次Spawn()之后调用Launch()在Launch()里把位置、旋转、速度、伤害、存活状态全部重新赋值并且先SetActive(false)再SetActive(true)强制唤醒状态。你自己掂量一下有没有把该重置的字段漏掉。另外如果对象持有事件监听、委托引用记得在Hide()或者Clear()里全部解绑。比如子弹的碰撞事件里挂了伤害数值回调不复位下次这个子弹复活时会带着上一任的回调造成重复伤害。5.3 对象被自动释放了但代码还在引用它自动释放机制是把双刃剑。你Unspawn()一个对象过了 30 秒池子觉得它空闲太久就自动Release()了。这时候如果你的代码还持有这个BulletObject引用并尝试访问它的Bullet极大概率会碰到m_Bullet null的情况。处理方式有几种在Release()里真正销毁对象并置空引用让后续访问返回 null业务层加判空。借出方和归还方严格约定Unspawn()之后任何持有方都不允许再访问该对象。用比判空更稳妥的方案让对象自己管生命周期超过射程或生命周期结束就归还外部只负责判断是否还有效。我实际项目里会在Bullet上加一个IsAlive标记Hide()的时候置 false外部逻辑取到 false 就直接忽略。这样即使偶尔出现时序问题最坏的结果也只是少处理一个对象不会崩溃。5.4 从哪里观察对象池的运行状态排查对象池问题光靠写代码猜不行得看运行时数据。GameFramework 的对象池接口提供了几个有用的属性Count池中当前对象总数。SpawnCount当前处于使用中状态的对象数量。CanReleaseCount当前可以被释放的对象数量。我习惯写一个调试面板把常用对象池的状态打出来属性含义正常判断Count池内总对象数接近容量说明池子在大量复用SpawnCount使用中的对象数稳定后不会有持续尖峰CanReleaseCount可释放对象数太大说明池子里存了很多闲置对象你还可以在ObjectPool的更新过程中挂一个 Debug.Log观察释放事件。如果释放频率异常高说明容量或过期时间设小了如果CanReleaseCount长期很大说明容量设大了白白占内存。6. 我用对象池时养成的几个习惯6.1 统一封装成一个组件而不是散落各处一开始我在各个模块里直接调GameEntry.ObjectPool.CreateObjectPool结果项目大了之后每个模块都在创建自己的池子参数各不相同池子之间互相不知道存在排查问题要翻无数个文件。后来的做法是写一个PoolService集中管理所有对象池的创建、获取、释放。比如子弹池、敌人池、特效池都在一个管理器里注册统一设置容量和过期时间。这样调参只动一个地方出了状态异常也有唯一入口去查。6.2 对象放到池里之后仍然要小心持有引用对象池只管理存取不管理引用安全。你把一个对象Unspawn()之后对象本身还在池子里内存并没有被回收它的字段也还保留着。所以仍然有内存泄漏风险比如对象持有了一次性事件、资源引用、或者一个大型 UI 面板。我会有意识地要求每个池化对象实现清理逻辑归还时把引用字段都清一遍而不是只在框架销毁时Clear()。这样虽然代码多一点但不会出现池子越跑越胖的情况。6.3 对象池不只用来管子弹还能管 UI 和音频对象池的应用范围比很多人想得广。我后来把新手引导里频繁出现的飘字、任务弹窗、加载时的转圈提示都改成了池化对象。UI 创建销毁虽然不涉及 Unity 生命周期的大开销但每次 Instantiate 都会产生 GC 压力高频场景下用对象池同样有效。音频也可以这样处理音效播放器一般是个AudioSource反复创建销毁不但有 GC还会出现播放中断的边界问题。用一个音频播放器池循环取用播放完自动归还整体性能更稳定。这个模块真正用顺了之后你会发现它带来的不只是性能提升还有一套对象状态闭环的思路创建、初始化、使用、清理、归档。把生命周期理清楚了很多偶发 bug 自己就消失了。
返回列表