源设计起源 · 为什么要做这个模块
- 没它会怎样:频繁新建 / 销毁会带来 GC 压力和帧内卡顿;销毁之后还会留下 GC 尖峰 —— 表现就是"打起来一卡一卡的"。
- 池化不是免费的:它一上手就带来三个风险:① 归还时忘了清状态 → 对象带着上次的数据被复用(最难查的一类 bug);② 取一次还两次、或还错池 → 两个使用者拿到同一个实例互相踩;③ 池变成"内存黑洞" → 忘记清理、无限囤积。
- 早期做法在这三点上全都不设防:重复归还直接往栈里压,池里出现两份、之后两次取出的是同一实例;池里没空闲时,它会把一个"正在被别人使用"的对象抢过来,而原来那个使用者毫无通知;清空池只清字典,池里的对象既不销毁也不回收。
- 换来了什么:把"归还清理"做成写类时躲不开的必填接口、把"重复归还"当场拒绝并报错、把"池该多大"变成上限加命中率统计 —— 原来靠自觉和运气的事,现在躲不开也看得见。
本篇只讲为什么;怎么用见同目录《使用说明》。
0三句话速览(完整用法见《使用说明》)
Unity 对象(GameObject / 组件)
① 取 GameObject bullet = RevPool.Get(RevResPath.Battle_Bullet, "Blue", firePoint, RevResGroup.Battle); Bullet b = RevPool.Get<Bullet>(RevResPath.Battle_Bullet, "Blue", firePoint); // 直接拿组件 ② 还 RevPool.Return(bullet); // 或 RevPool.Return(b) ③ 重置 预制体上的脚本实现 IRevPoolable,取出/归还时自动回调 —— 不用改预制体结构
C# 引用对象(配置 DTO、消息体、计算中间结果…)
① 取 var msg = RevRefPool.Get<ChatMessage>(); ② 还 RevRefPool.Return(msg); // msg.OnPoolReturn() 里必须把字段清干净
就这三步。剩下的都是这三步的细节。
1对象池解决什么问题
游戏里大量对象频繁创建销毁:
子弹、特效、飘字、列表项、UI 元素
→ Instantiate / Destroy 频繁调用
→ GC 压力大、卡顿(Destroy 之后还有 GC 尖峰)
对象池的思路:不用了不销毁、放回池里;再需要时从池里取出来复用,从而减少 Instantiate/Destroy 次数。
| 风险 | 后果 | 框架怎么应对 |
|---|---|---|
| 归还时忘了清状态 | 对象带着上次的数据被复用(最难查的一类 bug) | IRevPoolable 把"归还清理"做成必填接口,写这个类时躲不开 |
| 取一次还两次 / 还错池 | 两个使用者拿到同一个实例,互相踩 | 引用相等集合 + 实例身份组件,当场拒绝并报错 |
| 池变成"内存黑洞" | 忘记清理、无限囤积 | 空闲上限 + Trim/Clear/ClearGroup + 命中率统计 |
2当前实现 vs 早期版本
2.1 总览
| 维度 | 早期版本(GameObject对象池\ + 引用对象池\) | 新版(Runtime\ObjectPool\) |
|---|---|---|
| 代码规模 | 1018 非空行 / 4 文件(含 215 行示例代码;注释 372 行) | 1664 非空行 / 10 文件,分 Core / Facade / Support 三层(注释 546 行 ≈ 三成) |
| 公开方法 | 30 个(GOPoolMgr.cs 12 + ReferencePoolMgr.cs 18) | 40 个(RevPool 25 + RevRefPool 15),业务日常只认 4 个(两个门面的 Get / Return) |
| 池的 Key | 对象名字符串(obj.name) | 「根目录 + 资源名」两段的缓存键(另有 prefab 引用入口,并校验引用身份) |
| 复用顺序 | 栈(LIFO),但池空时会抢正在使用的对象 | 严格 LIFO,池空只创建,绝不碰在用对象 |
| 资源加载 | Resources.Load + ABResMgr 各写一套(3 处 + 3 处) | 统一走资源系统 RevResManager,池端着 RevResHandle 引用 |
| 重复归还 | 直接 stack.Push → 池里出现两份,之后两次取出是同一实例 | 引用相等集合拦截 + 报错,不可能出现两份 |
| 引用池复用顺序 | LRU 双向链表,每次归还 new LRUNode<T> | List 当栈 + 引用相等集合,0 分配 |
| 容量上限 | maxNum 语义混乱(既是"同时存在上限"又是"池容量"),且要靠预制体挂 PoolObj 组件 | 只有空闲上限一个概念:RevPool.MaxIdlePerPool(默认 64) |
| 延迟回收 | 无 | 有(Return(item, delayFrames),由驱动每帧推进) |
| 统计 | 无(只有 Count / UsedCount) | 创建/销毁/丢失/命中/未命中/命中率/在用/空闲,DumpStats() 一次打全 |
| 编辑器可视化 | 只有 isOpenLayout 开关 | [RevObjectPool] 根节点 → Pool-<prefab> 子节点,空闲对象一目了然 |
| 对象被外部销毁 | 池里留下 null,取出来就是 null | lazy sweep 跳过空壳并计 Lost;prefab 被卸载会自动重载 |
ClearPool | 只 poolDic.Clear() → 池里的 GameObject 全成孤儿 | Clear 销毁空闲;DestroyPool 连池节点 + prefab 引用一起放掉 |
2.2 用"对象名字符串"当池 Key
早期版本取对象是 poolDic[obj.name](回收时也一样):
// 早期版本 GOPoolMgr.PushObj public void PushObj(GameObject obj, Action<GameObject> callback = null) { if (obj == null) return; poolDic[obj.name].Push(obj); // ★ 名字对不上 → 直接 KeyNotFoundException }
obj.name 是最不可靠的键:
- Unity 实例名默认带
"(Clone)",业务还会随手改名; - 两个不同路径下的同名 prefab(
UI/Icon/Red和Effect/Red)会共用一条池,取出来的是另一个东西; - 池被
ClearPool()清过之后,poolDic[name]不存在 → 回收时直接抛异常。
新版用 prefab 实例 ID 当 Key,并且额外校验引用身份:
// Runtime\ObjectPool\Core\RevGameObjectPools.cs int prefabId = prefab.GetInstanceID(); // ★ 除了对 ID,还要对引用:Unity 的实例 ID 会被回收再利用, // 只比 ID 有可能命中一条"上一个已销毁对象"遗留的池 if (_byPrefabId.TryGetValue(prefabId, out RevGameObjectPool pooled) && pooled.Prefab == prefab) return pooled.Get(parent);
三张索引表(_byKey / _byPrefabId / _byPoolId)指向同一批池,分别服务三种找池路径:
业务传"根目录 + 资源名"、业务传 prefab 引用、归还时从实例身上读 PoolId。
其中 _byKey 的键就是资源系统的缓存键(RevResPathUtil.ComputeKey(rootPath, resName))—— 命中已有池时连字符串都不拼。
2.3 重复归还:早期版本会静默压两份
早期版本归还就是一句 dataStack.Push(obj),没有任何校验。取一次还两次的后果是:
池里出现同一个实例的两份
→ 两个使用者各自"取到"它
→ 一人改位置、一人改状态 —— 互相踩(而且完全看不出是谁干的)
新版用一个引用相等的集合记录空闲对象,重复归还当场拒绝:
// Runtime\ObjectPool\Core\RevPoolCore.cs private bool Push(T item) { // ★ 重复归还:同一个对象已经在池里了 if (!_idleSet.Add(item)) { RevPoolLog.Error($"池 \"{Name}\":这个对象已经在池里了,重复归还已忽略。..."); return false; } ... }
Equals(值语义),用默认比较器会把
"两个内容相同但不同的实例"误判成重复,静默丢对象。所以框架自带一个用
RuntimeHelpers.GetHashCode + ReferenceEquals 的比较器。
工程外断言里有一条专门验它:还两次 → 第二次被拒 + 报错 + 之后两次取出不是同一实例。
2.4 池空时"抢正在使用的对象"
早期版本 PoolData.Pop() 有一个非常危险的分支:
// 早期版本 PoolData.Pop(节选) if (Count > 0) { obj = dataStack.Pop(); usedList.Add(obj); } else { obj = usedList[0]; // ★ 把"最早在用的"对象抢过来复用 usedList.RemoveAt(0); usedList.Add(obj); }
新版把这条路径彻底删掉:池里没有就 create(受容量约束),永远不碰在用对象。
"同时存在上限"这个概念也不再需要 —— 它本来就是这套混乱逻辑的产物。
2.5 绕开资源系统自己加载
早期版本同时存在两条加载路径(Resources.Load 3 处、ABResMgr 3 处),而且三个 GetObj 重载
语义各不相同(一个走 Resources、一个走 AB 回调、一个走 prefab 引用),Key 也各不相同
(name / abName + resName / gameObject.name)—— 同一个 prefab 用不同重载取,
会得到不同的池,回收时还可能进错池。
新版只有一条加载路径,且就是资源系统:
// Runtime\ObjectPool\Core\RevGameObjectPools.cs(同步取对象) RevResHandle handle = RevResManager.LoadHandle<GameObject>(rootPath, resName, group); if (!handle.IsLoaded) { /* 报错并提示用 GetAsync / WebGL 不支持同步 */ return null; } GameObject prefab = handle.Content as GameObject;
路径参数是资源系统的"根目录段 + 资源名"两段(和 RevResManager.Load 完全同一套):
目录段用生成的 RevResPath 常量(有编译期保护),资源名单独一个参数。
池的键就是这两段算出的缓存键,所以命中已有池时不拼字符串、零分配。
2.6 引用池的 LRU 双向链表:为了省 GC 反而制造 GC
早期版本引用池用 LRU(双向链表 + 哈希表)管理空闲对象,每次归还都 new LRUNode<T>(obj),
取出时再把它扔掉 —— 对象池本来是为了减少 GC,结果每次归还都在制造 GC。
而且 LRU 在这里没有意义:池里放的都是空闲对象,"谁先用谁后用"对复用没有差别 (要的是"最后一个放进去的先取",即 LIFO)。
新版:List 当栈 + 引用相等集合,每条路径 O(1)、零分配。
工程外实测 Get/Return 连续 20000 轮 GC 分配 0 字节。
2.7 ClearPool 只清字典,池里的对象变孤儿
// 早期版本 GOPoolMgr.ClearPool public void ClearPool() { poolDic.Clear(); // ★ 池里的 GameObject 一个都没销毁、也没被回收 poolObj = null; poolObjectDic.Clear(); }
池对象还挂在场景里(失活),但没有任何引用能再找到它们 —— 既不是"在用",也不是"池里",只能等场景卸载。 新版把"清空"和"销毁池"分成两个明确的动作:
| 方法 | 做什么 |
|---|---|
RevPool.Clear(rootPath, resName) / ClearAll() | 销毁空闲实例,池保留(之后还能继续取) |
RevPool.ClearGroup(group) | 清某个资源分组下的所有池(和 RevResBootstrap.Shutdown(group) 配对) |
RevPool.DestroyPool(rootPath, resName) / DestroyAll() | 销毁整条池:空闲实例 + 池节点 + 还掉 prefab 的引用 |
3运用了参考实现的哪些设计哲学
对照《03-对象池.md》(参考实现 CGameObjectPool 分析)里的设计要点,逐条说落地情况:
3.1 八条对照
| 参考实现的做法 | 这里的落地 |
|---|---|
| ① 用原型(Prefab)作 Key | prefab 实例 ID(+ 引用身份校验),三种入口(路径 / prefab 引用 / 实例上的 PoolId)汇到同一批池 |
| ② 后进先出(LIFO)提高命中 | List 当栈,从尾部取;"最后归还的最新鲜(缓存还热)" |
| ③ 双层追踪(池里 + 在用) | 改成推导:ActiveCount = Created - Destroyed - Lost - IdleCount(见 3.3) |
| ④ 延迟回收避免抖动 | Return(item, delayFrames);延迟期间对象不会被取到,由 RevPoolDriver 每帧推进 |
| ⑤ 容量限制防止内存黑洞 | RevPool.MaxIdlePerPool(默认 64,0 = 不限);超出上限的归还对象直接销毁 |
| ⑥ 统计上报 | RevPoolStats + 命中率;RevPool.DumpStats() 一次打全(参考实现上报灯塔,这里给一个可打印快照) |
| ⑦ 编辑器可视化 | [RevObjectPool] 根节点 → Pool-<prefab> 子节点 → 空闲实例;点开层级就能看出哪条池囤了多少 |
| ⑧ 调试代码零开销 | RevPoolLog.Debug 用 [Conditional("UNITY_EDITOR")]:正式包里连参数求值都不发生(参考实现用 #if ENABLE_EDITOR_STATISTIC) |
3.2 LIFO:为什么从尾巴上取
先放回去的对象,可能已经从内存里"冷却"(缓存被挤出去过)
后放回去的对象,更可能是刚用完、还在缓存里
→ 取的时候优先取最近放回的(LIFO)→ 缓存命中率更高
实现上 _idle 用 List 当栈(归还 Add、取出 RemoveAt(Count-1)),
两边都是 O(1),都不分配。
3.3 "双层追踪"改成推导(比参考实现更省)
参考实现维护了三张表互相校验:池里、在用、全部。好处是随时能查到"谁被谁拿着",代价是
每次取出/归还都要在"在用表"里做一次 O(n) 增删(List.Remove),实例一多就是热点。
新版用推导:
ActiveCount = Created - Destroyed - Lost - IdleCount
Created累计创建数;Destroyed被池销毁数;Lost被外部销毁数;IdleCount当前空闲数;- 每次操作都是 O(1),也不用担心"三张表不同步";
- 精度一样:断言里验过"取两个没还 → 在用数 = 2"。
3.4 延迟回收
RevPool.Return(bullet, delayFrames: 5); // 特效还要播 5 帧
- 立刻失活(业务马上看不到它),但要等够帧数才真正回到空闲列表;
- 延迟期间它不属于空闲池 —— 这时取对象会新建一个(这正是"避免抖动"的取舍);
Tick由RevPoolDriver(一个常驻 MonoBehaviour)每帧驱动,不用协程;- 工程外测试可以手动调
Tick()精确验证帧数(断言里验的是"延迟 3 帧 = 第 3 次 Tick 才进池")。
3.5 容量限制
RevPool.MaxIdlePerPool = 128; // 一个开关同时管 GameObject 池和引用池 RevRefPool.SetCapacity<ChatMessage>(256); // 也可以只调某一个类型 RevPool.TrimAll(64); // 每条池最多留 64 个空闲(超出销毁)
改上限会立即对已有池生效(超出部分销毁),不是只影响以后新建的池。
3.6 统计与命中率
RevPoolStats 是一份值类型快照(拿到后不受后续操作影响),字段:
Created / Destroyed / Lost / GetCount / Hit / Miss / ReturnCount / IdleCount / ActiveCount / Recycling / Capacity,
外加 HitRate。
命中率是判断"池开得合不合适"的唯一指标:
| 命中率 | 说明 | 该怎么办 |
|---|---|---|
| 接近 100% | 池大小合适(甚至偏小) | 可以试着调大上限 |
| 明显偏低 | 池白占内存 | 调小上限,或者这个对象根本不该池化 |
Debug.Log(RevPool.DumpStats()); // 所有池一行一条:创建/销毁/丢失/命中率/在用/空闲
3.7 编辑器可视化 + 调试零开销
Hierarchy └── [RevObjectPool] ← DontDestroyOnLoad,切场景不丢 ├── Pool-BlueBullet ← 一条池一个节点 │ ├── BlueBullet(Clone) ← 空闲实例(失活) │ └── ... └── Pool-HitEffect
池里囤了多少、有没有异常增长,看一眼层级就知道。同时 RevPoolLog.Debug(...) 的调用在正式包里被
[Conditional] 整段移除,不会为了调试付运行时成本。
3.8 我和参考实现不一样的地方
| 差异 | 参考实现做法 | 新版做法 | 为什么 |
|---|---|---|---|
| 归还后父节点 | 正式包不挂池节点(省一次 SetParent) | 始终挂到池节点下 | 一次 SetParent 相对 Instantiate 可忽略;换来的是"已归还对象不再挂在业务节点下"(否则 UI 布局/射线可能扫到它),并且切场景不会连带销毁 |
| 丢失对象的检测 | 维护 m_allPooledGameObjectScriptMap 全量表 | lazy sweep:取用时发现空壳就丢掉并计 Lost | 不额外维护全量表(省内存、省每帧成本);代价是 Lost 计数略滞后(下次取到它时才更新) |
| 池键类型 | Prefab 路径哈希(ulong) | prefab 实例 ID(int)+ 引用校验 | int 比较零成本;引用校验挡住"实例 ID 被回收再利用"的误命中 |
| 资源管理层 | 参考实现自带整套资源管理 | 直接基于本框架 RevResourceSystem:RevResHandle 引用 + RevResGroup 分组 + 异步接口 | 不重复造资源管理层 |
4怎么用
4.1 一行取出(Unity 对象)
// 同步:池里有 → 直接复用;池里没有 → 通过资源系统加载 prefab 再实例化 GameObject bullet = RevPool.Get(RevResPath.Battle_Bullet, "Blue", firePoint, RevResGroup.Battle);
| 参数 | 说明 |
|---|---|
rootPath | 根目录段,和 RevResManager.Load 用的是同一套;直接用生成的 RevResPath 常量(资源就在资源根目录下时传 "") |
resName | 资源名(不带扩展名;也可以再带子目录) |
parent | 取出来挂到哪个节点下(可空) |
group | 资源分组(RevResGroup);决定它会不会被分组卸载点名到 |
前两段会被拼成逻辑路径 Battle/Bullet/Blue——拼这一步业务不用管,与 RevResManager.Load 的两参数完全同源。
4.2 直接拿组件
Bullet b = RevPool.Get<Bullet>(RevResPath.Battle_Bullet, "Blue", firePoint); // 省掉 GetComponent
预制体上没有该组件时报错、把对象还回池里、返回 null(不会静默给你一个 null 组件)。
4.3 异步(真机首次 / WebGL)
RevPool.GetAsync(RevResPath.Battle_Bullet, "Blue", go => { if (go == null) return; // 失败:回调收到 null go.transform.position = firePoint.position; }, firePoint, RevResGroup.Battle); // 直接拿组件 RevPool.GetAsync<Bullet>(RevResPath.Battle_Bullet, "Blue", b => { ... }, firePoint, RevResGroup.Battle);
异步返回的是 prefab 的 RevResHandle,用法与资源系统的 LoadAsync 一致(想看失败原因就接住它)。
池已就绪时会立即回调(与资源系统"缓存命中直接回调"的行为一致)。
4.4 归还与延迟回收
RevPool.Return(bullet); // 立即归还 RevPool.Return(bullet, delayFrames: 5); // 等 5 帧再回收(特效播完 / 飘字消失) RevPool.Return(bulletComponent); // 传组件也行(等价于归还它所在的 GameObject)
返回值表示是否真的被池收下:
false= 这个对象不是池里出来的(没有身份组件)/ 池已被销毁 / 重复归还 —— 日志里会说明原因,不会静默吞掉;- 如果对象所属的池已经被
DestroyPool了,框架会兜底销毁它,不留孤儿。
全局默认延迟:RevPool.DelayRecycleFrames = 2;(不传 delayFrames 时用它)。
4.5 让预制体自己重置状态(IRevPoolable)
// 预制体上的脚本(比如子弹) public sealed class Bullet : MonoBehaviour, IRevPoolable { private float _life; public void OnPoolGet() // 取出时:唤醒 { _life = 3f; } public void OnPoolReturn() // 归还时:★ 必须把状态清干净 { _life = 0f; _target = null; // 清引用,防止池替别人"续命" } }
- 框架在实例创建时查找一次并缓存(取出/归还路径上零查找、零分配);
- 同一个实例上的多个
IRevPoolable组件都会被回调; - 某个组件回调抛异常会被隔离(报错但不影响其它组件,也不让池卡住);
- 不想写两个方法就继承
RevPoolableBase(默认什么都不做)。
4.6 C# 引用对象(RevRefPool)
public sealed class ChatMessage : RevPoolableBase { public string Text; public int SenderId; public override void OnPoolReturn() { Text = null; // ★ 清引用 SenderId = 0; } } // 一行取出、一行归还 var msg = RevRefPool.Get<ChatMessage>(); msg.Text = "hello"; RevRefPool.Return(msg);
| 方法 | 说明 |
|---|---|
RevRefPool.Get<T>(variant = null) | 池里有就复用,没有就 new(要求 IRevPoolable + 公开无参构造) |
RevRefPool.Return<T>(item, variant = null) | 返回是否收下;取用必须同一个类型 + 同一个 variant |
RevRefPool.SetCapacity<T>(n) | 单独调某个类型的空闲上限 |
RevRefPool.DefaultCapacity | 所有引用池的默认上限(默认 64) |
RevRefPool.Clear<T>() / ClearAll() / DestroyAll() / TrimAll(keep) | 清理 |
RevRefPool.GetStats<T>() / GetGlobalStats() / GetPoolNames() | 统计 / 诊断 |
variant 是"同一个类要两条互不干扰的池"的后门(早期实现的 nameSpace 参数)。
更推荐派生一个子类(类型即语义),免得半年后没人记得 variant = "battle" 是干什么的。
4.7 和资源系统配对(分组卸载)
池端着 prefab 的引用,所以池在,prefab 就不会被卸载(这正是池里实例的贴图/材质安全的原因)。 反过来说:资源那边清账时,池这边也要放掉实例,否则会留下"实例还在、贴图没了"的怪状态。
// 退出战斗 RevResBootstrap.Instance.Shutdown(RevResGroup.Battle); // 资源系统:卸掉这个分组的资源 RevPool.ClearGroup(RevResGroup.Battle); // ★ 对象池:清掉这个分组下池里的空闲实例 // 想更彻底(连池记录一起销毁): // RevPool.DestroyAll();
几个边界行为(都已实现并有断言覆盖):
- prefab 被外部卸载/销毁 → 池自愈:下次
Get重新走资源系统加载并Rebind(旧实例清掉重建),而不是一直报错; - 池销毁时还引用,只在"句柄还在资源缓存里"时才还(若资源系统已经清过账,再还一次就成了多还,会把别人那份引用一起减掉);
- 用
RevPool.Get(prefab, parent)(传 prefab 引用)建的池不持有资源引用,prefab 生命周期由调用方保证。
4.8 配置
RevPool.MaxIdlePerPool = 128; // 空闲上限(默认 64,0 = 不限);同时作用于两类池 RevPool.DelayRecycleFrames = 2; // 归还时的默认延迟帧数(默认 0 = 立即) RevPool.Log = msg => Debug.LogWarning(msg); // 日志出口(Unity 下自动接)
无参归还时的延迟计算:delayFrames < 0(默认)→ 用 DelayRecycleFrames;显式传值则用传的值。
4.9 诊断
RevPool.GetStats(RevResPath.Battle_Bullet, "Blue"); // 某条池的快照 RevPool.GetGlobalStats(); // 所有池(含引用池)的合计 RevPool.DumpStats(); // 一坨文本,直接打日志 RevPool.ResetStats(rootPath, resName); / ResetStatsAll(); // 清请求计数(想看某一段时间的命中率) RevRefPool.GetPoolNames(); // 引用池名清单(排序,便于对比两次快照找泄漏)
ResetStats 只清"请求类"计数(请求/命中/未命中/归还);创建/销毁/丢失是账目,不清
—— 在用数靠它们推导。
找泄漏的姿势:跑一段前后各打一次 DumpStats(),看哪条池的 在用 数只涨不落(那就是有地方取出来没还)。
5必须知道的六个坑
5.1 归还时忘了清字段
池化的头号事故。框架能做的是把 OnPoolReturn 做成接口必填,但清什么只有你知道:
字符串/引用置 null、计时归零、事件解绑、父子关系复位。
5.2 把池里的实例另存成预制体
实例身上的 RevPooledMember.PoolId 故意不序列化。如果另存成预制体,新实例的 PoolId 会是 0
→ 归还时被明确拒绝并报错(而不是被送到别的池里去、被当成别的 prefab 的实例发出去)。
5.3 WebGL / 小游戏不能用同步 Get
同步加载在 WebGL 上不可用(单线程、不能阻塞、路径是 URL)。这类平台(以及真机首次加载)一律用 RevPool.GetAsync。
5.4 分组卸载没配 ClearGroup
RevResBootstrap.Shutdown(group) 之后不调 RevPool.ClearGroup(group),池里会留着指向已卸载资源的实例。
切场景的读条阶段把两句写在一起就行。
5.5 用 prefab 引用建的池不持有资源引用
RevPool.Get(prefab, parent) 适合"调用方本来就持有 prefab"的场景(省路径哈希)。但这类池没有 RevResHandle,
prefab 被卸载后池不会自愈 —— 要么自己保证它不被卸载,要么改用路径入口。
5.6 延迟回收期间对象不可用
Return(item, delayFrames: 5) 之后,这个对象不属于你也不属于池:立刻失活、5 帧后才进池。
这期间再 Return 它会被当成重复归还拒绝;这期间取对象会新建一个(不是拿到它)。
6API 速查
Unity 对象 —— RevPool
| 分类 | 方法 |
|---|---|
| 取 | Get(rootPath, resName, parent = null, group = Unknown)、Get<T>(rootPath, resName, …)、Get(prefab, parent = null)、Get<T>(prefab, …) |
| 异步取 | GetAsync(rootPath, resName, onFinished, parent, group)、GetAsync<T>(rootPath, resName, onFinished, …) |
| 还 | Return(gameObjectOrComponent, delayFrames = -1) |
| 清理 | Clear(rootPath, resName)、ClearAll()、ClearGroup(group)、TrimAll(keep)、DestroyPool(rootPath, resName | prefab)、DestroyAll() |
| 配置 | MaxIdlePerPool、DelayRecycleFrames、Log、ApplyCapacityToAll(n) |
| 诊断 | Tick()、GetStats(rootPath, resName)、GetGlobalStats()、DumpStats()、ResetStats(rootPath, resName)、ResetStatsAll() |
C# 引用对象 —— RevRefPool
| 分类 | 方法 |
|---|---|
| 取 / 还 | Get<T>(variant = null)、Return<T>(item, variant = null) |
| 容量 | SetCapacity<T>(n, variant = null)、DefaultCapacity、ApplyCapacityToAll(n) |
| 清理 | Clear<T>()、ClearAll()、DestroyAll()、TrimAll(keep) |
| 诊断 | GetStats<T>()、GetGlobalStats()、ResetStats<T>()、ResetStatsAll()、GetPoolNames()、PoolCount |
池化对象契约:IRevPoolable(OnPoolGet / OnPoolReturn)或 RevPoolableBase。
7验证结果
池的核心(RevPoolDefines / RevPoolCore / RevRefPools / RevRefPool)是
纯 C#、不引用 UnityEngine,所以能在工程外直接跑断言 —— 验的就是要提交的那份源码:
| 验证项 | 结果 |
|---|---|
| 行为断言(LIFO、重复归还、容量上限、Trim/Clear、延迟回收、变体隔离、丢失检测、统计…) | 52 项全部通过 |
RevRefPool 的 Get/Return 连续 20000 轮 | GC 分配 0 字节(早期版本每次归还都要 new LRUNode<T>) |
LangVersion 9.0(对齐 Unity 2022.3) | 编译通过 |
整个 Revolution.Runtime 程序集(Unity 真实引用 + UNITY_EDITOR) | 0 错误 0 警告 |
其中几条最值得关注的断言(都是"写的时候看不错、跑起来才知道"的类型):
- LIFO:最后归还的先被取出(连续两次取出分别拿到后还的、先还的);
- 重复归还:第二次被拒 + 报错,且之后两次取出不是同一实例;
- 容量:还 10 个、上限 3 → 池里只留 3 个,另外 7 个被销毁;
- 延迟回收:延迟 3 帧时,前 2 次
Tick后仍未进池,第 3 次才进池;延迟期间取对象会新建; - 丢失检测:空闲对象被外部"销毁"后,取用时跳过空壳(
Lost+1)并拿到还活着的那个; - 错误用法:归还到不存在的池 / variant 不一致 → 拒绝 + 错误日志(早期实现是 Warning 后静默 return)。
RevGameObjectPools.cs 里 Object.Destroy(...) 在同时
using System; 与 using UnityEngine; 的文件中是歧义引用(CS0104)——
已修正为 UnityEngine.Object.Destroy。这正是"纯逻辑抽出来 + 用 Unity 的 csproj 复编一遍"的价值。
8关键文件索引
| 文件 | 职责 |
|---|---|
Runtime\ObjectPool\Facade\RevPool.cs | Unity 对象门面:Get / GetAsync / Return / 清理 / 配置 / 诊断 |
Runtime\ObjectPool\Facade\RevRefPool.cs | C# 引用对象门面:Get<T> / Return<T> / 容量 / 统计 |
Runtime\ObjectPool\Core\RevPoolCore.cs | 池引擎(纯 C#):LIFO + 容量 + 统计 + 延迟回收 + 重复归还拦截 |
Runtime\ObjectPool\Core\RevRefPools.cs | C# 引用池注册表(纯 C#) |
Runtime\ObjectPool\Core\RevGameObjectPool.cs | 一条 prefab 池:资源句柄 / 池节点 / 实例创建与回调 |
Runtime\ObjectPool\Core\RevGameObjectPools.cs | GameObject 池注册表:路径解析 / 异步 / 分组清理 / 自愈 |
Runtime\ObjectPool\Core\RevPooledMember.cs | 实例身份组件(PoolId + 缓存 IRevPoolable) |
Runtime\ObjectPool\Core\RevPoolDefines.cs | 契约与数据:IRevPoolable / RevPoolStats / 池键 / 日志出口 |
Runtime\ObjectPool\Support\RevPoolDriver.cs | 每帧推进延迟回收 + [RevObjectPool] 池根节点 |
Runtime\ObjectPool\Support\RevPoolUnityHooks.cs | 日志出口 + 进 Play / 域重载清理 |
三方对照(想深挖设计取舍的话):
| 参考资料 | 位置 |
|---|---|
| 资源加载系统(本框架池化 prefab 的来源) | Runtime\RevResourceSystem\ + Revolution.Document\资源加载系统\ |
9本期没做的(可后续扩展)
| 项 | 说明 |
|---|---|
| 池的编辑器窗口 | 现在只能看层级([RevObjectPool])和 DumpStats() 文本;可以做一个窗口列每条池的空闲/在用/命中率,并支持一键 Trim |
| 预制体级预加载(进战斗前预热池) | 现在第一次取对象才创建;可以配合 RevResPreloader + 循环 Get/Return 预热 |
| 跨帧批量创建 / 分帧归还 | 大量归还(比如一屏子弹同时消失)时可以分帧做,避免单帧开销尖峰 |
| 池的自动回收策略 | 例如"闲置 N 秒后自动 Trim(0)",现在需要业务在切场景时显式调 |
DumpStats、层级可视化)。