架构解析

对象池 · 架构解析

覆盖:Assets\Revolution\Runtime\ObjectPool\(Core\ + Facade\ + Support\)

本篇只讲"为什么":为什么这么设计、为什么这么写代码、每个决策换来了什么、代价是什么、边界画在哪。
想学怎么用 → 看同目录的《使用说明》,那里是手把手,本篇不重复。
面向小白:用大白话讲"不这么做会怎样",不复述代码。

源设计起源 · 为什么要做这个模块

一句话 子弹、特效、飘字、列表项这些对象,一局游戏要反复"造了毁、毁了造",既费性能又造成卡顿。对象池让它们"不用了先收起来,下次接着用"。

本篇只讲为什么;怎么用见同目录《使用说明》。

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,取出来就是 nulllazy sweep 跳过空壳并计 Lost;prefab 被卸载会自动重载
ClearPool只 poolDic.Clear() → 池里的 GameObject 全成孤儿Clear 销毁空闲;DestroyPool 连池节点 + prefab 引用一起放掉
说明:代码量比早期版本多(实际代码 1118 行 vs 早期版本 646 行),因为补上了早期版本完全没有的能力: LIFO 复用、容量上限、延迟回收、统计、池自愈、资源系统集成、编辑器可视化。 功能变多、分工变清楚,是这次重构的目的。

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 是最不可靠的键:

新版用 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)作 Keyprefab 实例 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

3.4 延迟回收

RevPool.Return(bullet, delayFrames: 5);      // 特效还要播 5 帧

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 在 AB 包里,首次加载要走异步;WebGL(含微信、QQ 小游戏)同步加载根本不可用; 编辑器直读工程时可以同步 —— 所以"编辑器能跑、真机不行"是这类系统的经典错觉。

异步返回的是 prefab 的 RevResHandle,用法与资源系统的 LoadAsync 一致(想看失败原因就接住它)。 池已就绪时会立即回调(与资源系统"缓存命中直接回调"的行为一致)。

4.4 归还与延迟回收

RevPool.Return(bullet);                       // 立即归还
RevPool.Return(bullet, delayFrames: 5);       // 等 5 帧再回收(特效播完 / 飘字消失)
RevPool.Return(bulletComponent);              // 传组件也行(等价于归还它所在的 GameObject)

返回值表示是否真的被池收下:

全局默认延迟: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;            // 清引用,防止池替别人"续命"
    }
}
这一步是池化能不能用好的关键:对象不会被销毁,字段也不会被重置 —— "上次的数据还在"不是池的错,是归还时没清理。

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();

几个边界行为(都已实现并有断言覆盖):

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 警告

其中几条最值得关注的断言(都是"写的时候看不错、跑起来才知道"的类型):

编译阶段抓到 1 个真错误:RevGameObjectPools.cs 里 Object.Destroy(...) 在同时 using System; 与 using UnityEngine; 的文件中是歧义引用(CS0104)—— 已修正为 UnityEngine.Object.Destroy。这正是"纯逻辑抽出来 + 用 Unity 的 csproj 复编一遍"的价值。

8关键文件索引

文件职责
Runtime\ObjectPool\Facade\RevPool.csUnity 对象门面:Get / GetAsync / Return / 清理 / 配置 / 诊断
Runtime\ObjectPool\Facade\RevRefPool.csC# 引用对象门面:Get<T> / Return<T> / 容量 / 统计
Runtime\ObjectPool\Core\RevPoolCore.cs池引擎(纯 C#):LIFO + 容量 + 统计 + 延迟回收 + 重复归还拦截
Runtime\ObjectPool\Core\RevRefPools.csC# 引用池注册表(纯 C#)
Runtime\ObjectPool\Core\RevGameObjectPool.cs一条 prefab 池:资源句柄 / 池节点 / 实例创建与回调
Runtime\ObjectPool\Core\RevGameObjectPools.csGameObject 池注册表:路径解析 / 异步 / 分组清理 / 自愈
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、层级可视化)。