924 lines
20 KiB
Markdown
924 lines
20 KiB
Markdown
|
|
# 高性能对象池系统设计文档
|
|||
|
|
|
|||
|
|
## 1. 设计目标
|
|||
|
|
|
|||
|
|
本文设计一个面向 Unity 大型项目的对象池系统,目标不是“能用”,而是同时满足以下要求:
|
|||
|
|
|
|||
|
|
- 高性能:热点路径稳定 O(1) 或近似 O(1),避免全表扫描进入业务帧。
|
|||
|
|
- 低内存:控制元数据膨胀,支持容量上限、分层配额、预热与回收策略。
|
|||
|
|
- 0GC:运行时热点路径不产生托管垃圾,调试和统计默认不侵入业务路径。
|
|||
|
|
- 高扩展:支持 GameObject、纯 C# 对象、句柄对象、资源实例、复合对象、分片对象。
|
|||
|
|
- 易调试:运行时诊断与编辑器面板分离,支持快照、采样、事件流和错误定位。
|
|||
|
|
- 适合大型项目:支持多域、多场景、多模块、多团队协作,不依赖单一对象模型。
|
|||
|
|
- 支持复杂池子:支持多 Key、多状态、多生命周期、多租户、异步预热、延迟销毁。
|
|||
|
|
|
|||
|
|
这套系统默认服务“小众但大型”的 Unity 项目,即:
|
|||
|
|
|
|||
|
|
- 代码量大,模块边界复杂。
|
|||
|
|
- 有长期演进需求,不能接受一次性写死。
|
|||
|
|
- 对线上性能和可观测性要求高。
|
|||
|
|
- 池子类型多,不只是子弹和特效。
|
|||
|
|
|
|||
|
|
## 2. 非目标
|
|||
|
|
|
|||
|
|
以下能力不作为第一阶段核心目标:
|
|||
|
|
|
|||
|
|
- 透明跨线程直接复用 UnityEngine.Object。
|
|||
|
|
- 自动把任意未池化代码转换成池化逻辑。
|
|||
|
|
- 用一套 API 完全覆盖所有业务差异。
|
|||
|
|
- 在所有对象类型上强行追求完全一致的内部实现。
|
|||
|
|
|
|||
|
|
原因很简单:对象池的统一接口可以统一,但底层策略不能假装一样。GameObject、原生资源句柄、纯托管对象、本地缓存块,本质差异很大,强行统一通常会换来性能和可维护性同时变差。
|
|||
|
|
|
|||
|
|
## 3. 总体架构
|
|||
|
|
|
|||
|
|
系统分为 6 层:
|
|||
|
|
|
|||
|
|
1. `PoolService`
|
|||
|
|
2. `PoolDomain`
|
|||
|
|
3. `PoolRegistry`
|
|||
|
|
4. `PoolCore<TItem, TKey>`
|
|||
|
|
5. `PoolPolicy`
|
|||
|
|
6. `PoolDiagnostics`
|
|||
|
|
|
|||
|
|
职责如下:
|
|||
|
|
|
|||
|
|
- `PoolService`:全局入口,负责生命周期、配置装载、域管理、更新调度。
|
|||
|
|
- `PoolDomain`:逻辑域隔离。比如 `UI`、`FX`、`Gameplay`、`Net`、`Addressable` 各自独立。
|
|||
|
|
- `PoolRegistry`:注册池定义、类型工厂、键类型、扩展策略。
|
|||
|
|
- `PoolCore<TItem, TKey>`:真正的池实现,处理获取、归还、预热、扩容、淘汰。
|
|||
|
|
- `PoolPolicy`:定义创建、重置、校验、回收、容量、淘汰和调试采样策略。
|
|||
|
|
- `PoolDiagnostics`:只读观测层,负责统计快照、事件记录、调试视图和导出。
|
|||
|
|
|
|||
|
|
核心原则:
|
|||
|
|
|
|||
|
|
- 业务代码不直接碰底层容器。
|
|||
|
|
- 池核心不直接依赖编辑器。
|
|||
|
|
- 统计系统不阻塞业务路径。
|
|||
|
|
- 高级功能通过策略和扩展挂上去,而不是污染主流程。
|
|||
|
|
|
|||
|
|
## 4. 模块拆分
|
|||
|
|
|
|||
|
|
建议目录:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
Runtime/
|
|||
|
|
Pool/
|
|||
|
|
Core/
|
|||
|
|
Handle/
|
|||
|
|
Policy/
|
|||
|
|
Registry/
|
|||
|
|
Domain/
|
|||
|
|
Diagnostics/
|
|||
|
|
Adapters/
|
|||
|
|
Unsafe/
|
|||
|
|
Editor/
|
|||
|
|
Pool/
|
|||
|
|
Window/
|
|||
|
|
Inspector/
|
|||
|
|
Profiler/
|
|||
|
|
Tests/
|
|||
|
|
Runtime/
|
|||
|
|
Editor/
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.1 Core
|
|||
|
|
|
|||
|
|
核心只保留高频逻辑:
|
|||
|
|
|
|||
|
|
- `Rent`
|
|||
|
|
- `Return`
|
|||
|
|
- `TryRent`
|
|||
|
|
- `Prewarm`
|
|||
|
|
- `Trim`
|
|||
|
|
- `Shutdown`
|
|||
|
|
|
|||
|
|
### 4.2 Policy
|
|||
|
|
|
|||
|
|
策略层负责差异化:
|
|||
|
|
|
|||
|
|
- 创建策略
|
|||
|
|
- 重置策略
|
|||
|
|
- 释放策略
|
|||
|
|
- 容量策略
|
|||
|
|
- 淘汰策略
|
|||
|
|
- 健康检查策略
|
|||
|
|
- 调试命名策略
|
|||
|
|
|
|||
|
|
### 4.3 Diagnostics
|
|||
|
|
|
|||
|
|
诊断层只订阅事件,不进入关键路径的复杂计算。
|
|||
|
|
|
|||
|
|
### 4.4 Adapters
|
|||
|
|
|
|||
|
|
适配层处理:
|
|||
|
|
|
|||
|
|
- `GameObject`
|
|||
|
|
- `Component`
|
|||
|
|
- `ScriptableObject`
|
|||
|
|
- `NativeArray` 包装
|
|||
|
|
- `Addressables` 实例
|
|||
|
|
- 自定义资源句柄
|
|||
|
|
|
|||
|
|
## 5. 核心对象模型
|
|||
|
|
|
|||
|
|
我不会让业务对象强制继承一个 `ObjectBase`。继承式对象池扩展性很差,会把“池系统”反向侵入所有业务对象。
|
|||
|
|
|
|||
|
|
我会采用“组合 + 句柄”模型。
|
|||
|
|
|
|||
|
|
### 5.1 核心接口
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
public interface IPool
|
|||
|
|
{
|
|||
|
|
PoolId Id { get; }
|
|||
|
|
PoolRuntimeInfo RuntimeInfo { get; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public interface IPool<T> : IPool
|
|||
|
|
{
|
|||
|
|
PoolHandle<T> Rent();
|
|||
|
|
bool TryRent(out PoolHandle<T> handle);
|
|||
|
|
void Return(in PoolHandle<T> handle);
|
|||
|
|
void Prewarm(int count);
|
|||
|
|
void Trim(int count);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public interface IPoolFactory<T>
|
|||
|
|
{
|
|||
|
|
T Create();
|
|||
|
|
void Destroy(T item);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public interface IPoolLifecycle<T>
|
|||
|
|
{
|
|||
|
|
void OnCreate(T item);
|
|||
|
|
void OnRent(T item);
|
|||
|
|
void OnReturn(T item);
|
|||
|
|
void OnReset(T item);
|
|||
|
|
bool CanRecycle(T item);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 句柄设计
|
|||
|
|
|
|||
|
|
`PoolHandle<T>` 是值类型句柄,不直接暴露池内部节点引用:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
public readonly struct PoolHandle<T>
|
|||
|
|
{
|
|||
|
|
public readonly T Value;
|
|||
|
|
internal readonly int SlotIndex;
|
|||
|
|
internal readonly ushort Version;
|
|||
|
|
internal readonly PoolId PoolId;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
作用:
|
|||
|
|
|
|||
|
|
- 防止重复归还。
|
|||
|
|
- 防止把 A 池的对象还到 B 池。
|
|||
|
|
- 支持失效检测。
|
|||
|
|
- 允许调试系统用句柄定位历史事件。
|
|||
|
|
|
|||
|
|
这个设计比“直接传对象归还”更适合大型项目,因为大型项目里误还、重还、跨池还、跨域还都很常见。
|
|||
|
|
|
|||
|
|
## 6. 数据结构设计
|
|||
|
|
|
|||
|
|
### 6.1 主存储结构
|
|||
|
|
|
|||
|
|
每个池维护以下固定结构:
|
|||
|
|
|
|||
|
|
- `Slot[] _slots`
|
|||
|
|
- `int _freeHead`
|
|||
|
|
- `int _activeCount`
|
|||
|
|
- `int _inactiveCount`
|
|||
|
|
- `FastQueue<int> _inactiveQueue`
|
|||
|
|
- `FastMap<TKey, Bucket>` 或 `Dictionary<TKey, Bucket>`
|
|||
|
|
- `BitSet _inUseBits`
|
|||
|
|
- `BitSet _dirtyBits`
|
|||
|
|
|
|||
|
|
其中 `Slot` 包含:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
internal struct Slot<T>
|
|||
|
|
{
|
|||
|
|
public T Item;
|
|||
|
|
public int NextFree;
|
|||
|
|
public int NextInBucket;
|
|||
|
|
public int PrevInBucket;
|
|||
|
|
public int StateFlags;
|
|||
|
|
public int LastRentTick;
|
|||
|
|
public int LastReturnTick;
|
|||
|
|
public ushort Version;
|
|||
|
|
public int RentCount;
|
|||
|
|
public int OwnerThreadId;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
设计意图:
|
|||
|
|
|
|||
|
|
- 不用链表节点对象,避免额外分配和引用跳转。
|
|||
|
|
- 尽量数组化,提升缓存命中率。
|
|||
|
|
- 用索引串联空闲链和桶链,避免托管容器层层嵌套。
|
|||
|
|
|
|||
|
|
### 6.2 Why not LinkedList / MultiDictionary
|
|||
|
|
|
|||
|
|
我不会把“按名字多值管理”建立在 `LinkedListNode<T>` 和泛型多值字典上,原因是:
|
|||
|
|
|
|||
|
|
- 节点对象本身有额外内存占用。
|
|||
|
|
- 指针跳转多,缓存局部性差。
|
|||
|
|
- 按名字查桶后仍可能线性扫完整桶。
|
|||
|
|
- 热点路径结构太泛化,不够专门化。
|
|||
|
|
|
|||
|
|
更好的做法是:
|
|||
|
|
|
|||
|
|
- 预先把每个对象映射到 `slotIndex`。
|
|||
|
|
- 每个 Key 有自己的空闲链和活跃链。
|
|||
|
|
- 对“可租用集合”和“所有对象集合”分开维护。
|
|||
|
|
|
|||
|
|
### 6.3 双索引模型
|
|||
|
|
|
|||
|
|
对于复杂项目,我会同时维护两种索引:
|
|||
|
|
|
|||
|
|
- `Item -> SlotIndex`:归还、校验、调试定位。
|
|||
|
|
- `Key -> AvailableHead`:按键快速获取可用对象。
|
|||
|
|
|
|||
|
|
这样 `Rent(key)` 不需要扫描整个桶,只需要从该 Key 的可用链表头部直接拿一个元素。
|
|||
|
|
|
|||
|
|
这比“遍历名字桶,找到第一个没在用的对象”要稳定得多。
|
|||
|
|
|
|||
|
|
## 7. 核心状态机
|
|||
|
|
|
|||
|
|
单个池内对象状态定义:
|
|||
|
|
|
|||
|
|
- `Uninitialized`
|
|||
|
|
- `Available`
|
|||
|
|
- `Reserved`
|
|||
|
|
- `InUse`
|
|||
|
|
- `Returning`
|
|||
|
|
- `Disposed`
|
|||
|
|
|
|||
|
|
### 7.1 状态迁移
|
|||
|
|
|
|||
|
|
正常路径:
|
|||
|
|
|
|||
|
|
- Create -> Available
|
|||
|
|
- Available -> Reserved
|
|||
|
|
- Reserved -> InUse
|
|||
|
|
- InUse -> Returning
|
|||
|
|
- Returning -> Available
|
|||
|
|
- Available -> Disposed
|
|||
|
|
|
|||
|
|
异常保护:
|
|||
|
|
|
|||
|
|
- 已归还句柄再次归还,直接拒绝。
|
|||
|
|
- 版本不匹配,直接判定句柄失效。
|
|||
|
|
- 正在释放或已销毁对象,拒绝租用。
|
|||
|
|
|
|||
|
|
`Reserved` 这个状态不是多余的,它是给以下场景准备的:
|
|||
|
|
|
|||
|
|
- 异步加载后的延迟激活
|
|||
|
|
- 跨系统交接
|
|||
|
|
- 带初始化流水线的对象
|
|||
|
|
- 需要分两阶段构建的复杂实例
|
|||
|
|
|
|||
|
|
## 8. 0GC 设计原则
|
|||
|
|
|
|||
|
|
0GC 不是口号,必须明确哪些路径严格零分配。
|
|||
|
|
|
|||
|
|
### 8.1 严格 0GC 的路径
|
|||
|
|
|
|||
|
|
- `Rent`
|
|||
|
|
- `TryRent`
|
|||
|
|
- `Return`
|
|||
|
|
- `Fast Validate`
|
|||
|
|
- `按 Key 查询可用数量`
|
|||
|
|
- `池内状态位更新`
|
|||
|
|
|
|||
|
|
### 8.2 允许分配的路径
|
|||
|
|
|
|||
|
|
- 首次创建池
|
|||
|
|
- 扩容
|
|||
|
|
- 预热
|
|||
|
|
- 编辑器快照
|
|||
|
|
- 导出 CSV / JSON
|
|||
|
|
- 显式调试采样
|
|||
|
|
|
|||
|
|
### 8.3 实现要求
|
|||
|
|
|
|||
|
|
- 不在热点路径创建委托、闭包、枚举器类。
|
|||
|
|
- 不返回 `IEnumerable<T>` 给业务热路径。
|
|||
|
|
- 不在热点路径 new `List<>`。
|
|||
|
|
- 不依赖反射创建对象。
|
|||
|
|
- 不在热点路径拼接字符串。
|
|||
|
|
- 不在运行时默认收集完整调用栈。
|
|||
|
|
|
|||
|
|
### 8.4 时间戳设计
|
|||
|
|
|
|||
|
|
我不会用 `DateTime.UtcNow` 记录热点使用时间。
|
|||
|
|
|
|||
|
|
我会统一使用:
|
|||
|
|
|
|||
|
|
- `int tick`
|
|||
|
|
- `uint frameIndex`
|
|||
|
|
- `double realtimeSinceStartup`
|
|||
|
|
|
|||
|
|
其中热路径记录 `frameIndex` 或逻辑 `tick`,只有调试导出时再转换成人类可读时间。
|
|||
|
|
|
|||
|
|
原因:
|
|||
|
|
|
|||
|
|
- 更轻
|
|||
|
|
- 更稳定
|
|||
|
|
- 更容易做排序和过期比较
|
|||
|
|
- 更适合回放与录制
|
|||
|
|
|
|||
|
|
## 9. 获取与归还算法
|
|||
|
|
|
|||
|
|
### 9.1 Rent
|
|||
|
|
|
|||
|
|
目标复杂度:
|
|||
|
|
|
|||
|
|
- `Rent()`:O(1)
|
|||
|
|
- `Rent(key)`:O(1)
|
|||
|
|
|
|||
|
|
基本步骤:
|
|||
|
|
|
|||
|
|
1. 从 `key available head` 拿 `slotIndex`
|
|||
|
|
2. 校验 `slot` 状态和版本
|
|||
|
|
3. 从可用链移除
|
|||
|
|
4. 标记 `InUse`
|
|||
|
|
5. 更新计数、tick、统计
|
|||
|
|
6. 调用 `OnRent`
|
|||
|
|
7. 返回值类型句柄
|
|||
|
|
|
|||
|
|
### 9.2 Return
|
|||
|
|
|
|||
|
|
目标复杂度:
|
|||
|
|
|
|||
|
|
- `Return(handle)`:O(1)
|
|||
|
|
|
|||
|
|
基本步骤:
|
|||
|
|
|
|||
|
|
1. 校验池 ID
|
|||
|
|
2. 校验版本号
|
|||
|
|
3. 校验状态必须为 `InUse`
|
|||
|
|
4. 调用 `CanRecycle`
|
|||
|
|
5. 调用 `OnReturn`
|
|||
|
|
6. 调用 `OnReset`
|
|||
|
|
7. 放回 `key available chain`
|
|||
|
|
8. 更新统计
|
|||
|
|
|
|||
|
|
### 9.3 扩容
|
|||
|
|
|
|||
|
|
扩容不是每次 `+1`,而是分级策略:
|
|||
|
|
|
|||
|
|
- 小池:2 倍扩容
|
|||
|
|
- 中池:1.5 倍扩容
|
|||
|
|
- 大池:固定块增长
|
|||
|
|
|
|||
|
|
原因:
|
|||
|
|
|
|||
|
|
- 小池要减少扩容次数
|
|||
|
|
- 大池要控制峰值浪费
|
|||
|
|
|
|||
|
|
## 10. 容量与回收策略
|
|||
|
|
|
|||
|
|
大型项目不能只有一个 `Capacity`。
|
|||
|
|
|
|||
|
|
我会支持以下层级:
|
|||
|
|
|
|||
|
|
- `MinCapacity`
|
|||
|
|
- `SoftCapacity`
|
|||
|
|
- `HardCapacity`
|
|||
|
|
- `PrewarmCapacity`
|
|||
|
|
- `PerKeySoftCapacity`
|
|||
|
|
- `PerDomainBudget`
|
|||
|
|
|
|||
|
|
### 10.1 容量语义
|
|||
|
|
|
|||
|
|
- `MinCapacity`:至少保留,不主动裁剪。
|
|||
|
|
- `SoftCapacity`:超过后进入延迟裁剪候选。
|
|||
|
|
- `HardCapacity`:超过后禁止继续扩容,走降级策略。
|
|||
|
|
- `PerDomainBudget`:整个域共享内存预算,防止单池失控。
|
|||
|
|
|
|||
|
|
### 10.2 回收策略
|
|||
|
|
|
|||
|
|
不做“每次超容立刻全量扫描”,而是做三段式:
|
|||
|
|
|
|||
|
|
1. 标记超容
|
|||
|
|
2. 放入裁剪队列
|
|||
|
|
3. 在预算帧内分批处理
|
|||
|
|
|
|||
|
|
这很关键。大型项目最怕把维护性工作同步砸进战斗帧。
|
|||
|
|
|
|||
|
|
### 10.3 裁剪候选组织
|
|||
|
|
|
|||
|
|
每个池维护一个“可裁剪最小堆”或分层队列,排序因子可配置:
|
|||
|
|
|
|||
|
|
- 最久未使用
|
|||
|
|
- 最低优先级
|
|||
|
|
- 冷热分层
|
|||
|
|
- Key 局部配额
|
|||
|
|
- 是否锁定
|
|||
|
|
|
|||
|
|
这样 `Trim` 可以渐进式处理,而不是每次遍历全池。
|
|||
|
|
|
|||
|
|
## 11. 多 Key / 复杂池支持
|
|||
|
|
|
|||
|
|
一个现代对象池不能只支持“按 name 拿对象”。
|
|||
|
|
|
|||
|
|
我会支持以下 Key 模型:
|
|||
|
|
|
|||
|
|
- `PoolKey.None`
|
|||
|
|
- `int`
|
|||
|
|
- `FixedString64`
|
|||
|
|
- 复合 Key
|
|||
|
|
- 结构化 Key
|
|||
|
|
|
|||
|
|
例如:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
public struct FxPoolKey
|
|||
|
|
{
|
|||
|
|
public int FxId;
|
|||
|
|
public byte QualityLevel;
|
|||
|
|
public byte Theme;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
应用场景:
|
|||
|
|
|
|||
|
|
- 同一特效不同质量等级
|
|||
|
|
- 同一 UI Cell 不同模板
|
|||
|
|
- 同一角色不同皮肤部件
|
|||
|
|
- 同一网络包对象不同协议版本
|
|||
|
|
|
|||
|
|
### 11.1 多态池
|
|||
|
|
|
|||
|
|
支持一个池管理多个派生类型,但不是默认开启。
|
|||
|
|
|
|||
|
|
理由:
|
|||
|
|
|
|||
|
|
- 多态池灵活,但查找和重置逻辑更复杂。
|
|||
|
|
- 默认建议一池一类型,特殊场景再用联合池。
|
|||
|
|
|
|||
|
|
联合池通过注册表完成:
|
|||
|
|
|
|||
|
|
- `BaseTypePool`
|
|||
|
|
- 子类型工厂映射
|
|||
|
|
- 子类型生命周期映射
|
|||
|
|
|
|||
|
|
## 12. 特殊类型池设计
|
|||
|
|
|
|||
|
|
### 12.1 GameObject 池
|
|||
|
|
|
|||
|
|
特点:
|
|||
|
|
|
|||
|
|
- 需要处理激活状态
|
|||
|
|
- 需要处理父节点
|
|||
|
|
- 需要处理场景归属
|
|||
|
|
- 需要处理组件重置成本
|
|||
|
|
|
|||
|
|
建议:
|
|||
|
|
|
|||
|
|
- 池内对象挂 `PooledInstanceTag`
|
|||
|
|
- 禁止业务直接 `Destroy`
|
|||
|
|
- 使用 `PoolHandle<GameObject>`
|
|||
|
|
- `OnReset` 里只做必要重置,不做全组件反射扫描
|
|||
|
|
|
|||
|
|
### 12.2 Component 池
|
|||
|
|
|
|||
|
|
对外返回 `TComponent`,内部仍以宿主实例为核心管理,避免组件和对象分离失控。
|
|||
|
|
|
|||
|
|
### 12.3 纯托管对象池
|
|||
|
|
|
|||
|
|
适合:
|
|||
|
|
|
|||
|
|
- 临时上下文
|
|||
|
|
- 行为树节点运行数据
|
|||
|
|
- 网络解析缓存对象
|
|||
|
|
- UI 虚拟列表单元数据容器
|
|||
|
|
|
|||
|
|
优先使用泛型池,不依赖 Unity 生命周期。
|
|||
|
|
|
|||
|
|
### 12.4 集合池
|
|||
|
|
|
|||
|
|
单独提供高频集合池:
|
|||
|
|
|
|||
|
|
- `ListPool<T>`
|
|||
|
|
- `DictionaryPool<TKey, TValue>`
|
|||
|
|
- `HashSetPool<T>`
|
|||
|
|
- `StringBuilderPool`
|
|||
|
|
|
|||
|
|
但集合池必须分离于主对象池系统,不要共用复杂的池元数据。
|
|||
|
|
|
|||
|
|
原因:
|
|||
|
|
|
|||
|
|
- 集合池需求简单
|
|||
|
|
- 热点极多
|
|||
|
|
- 追求极低开销
|
|||
|
|
|
|||
|
|
### 12.5 原生内存包装池
|
|||
|
|
|
|||
|
|
支持包装:
|
|||
|
|
|
|||
|
|
- `NativeArray`
|
|||
|
|
- `NativeList`
|
|||
|
|
- `UnsafeList`
|
|||
|
|
|
|||
|
|
但要求:
|
|||
|
|
|
|||
|
|
- 主线程与 Job 边界清晰
|
|||
|
|
- 回收前必须完成 Job 依赖
|
|||
|
|
- 诊断系统记录 owner 和 frame fence
|
|||
|
|
|
|||
|
|
### 12.6 异步资源实例池
|
|||
|
|
|
|||
|
|
例如 Addressables:
|
|||
|
|
|
|||
|
|
- `Create` 可能异步
|
|||
|
|
- `Return` 后不能立刻销毁
|
|||
|
|
- 实例可能依赖引用计数
|
|||
|
|
|
|||
|
|
对此引入 `AsyncPoolAdapter<T>`:
|
|||
|
|
|
|||
|
|
- 支持 `ReserveAsync`
|
|||
|
|
- 支持加载中状态
|
|||
|
|
- 支持失败回滚
|
|||
|
|
- 支持 warmup pipeline
|
|||
|
|
|
|||
|
|
## 13. 扩展点设计
|
|||
|
|
|
|||
|
|
### 13.1 Policy 驱动
|
|||
|
|
|
|||
|
|
每个池通过配置组合:
|
|||
|
|
|
|||
|
|
- `Factory`
|
|||
|
|
- `Lifecycle`
|
|||
|
|
- `CapacityPolicy`
|
|||
|
|
- `TrimPolicy`
|
|||
|
|
- `ValidationPolicy`
|
|||
|
|
- `DebugNameProvider`
|
|||
|
|
|
|||
|
|
### 13.2 Feature Flag
|
|||
|
|
|
|||
|
|
不是所有池都启用所有能力。
|
|||
|
|
|
|||
|
|
例如按位配置:
|
|||
|
|
|
|||
|
|
- `TrackStackTrace`
|
|||
|
|
- `TrackOwner`
|
|||
|
|
- `EnableDoubleReturnCheck`
|
|||
|
|
- `EnableLeakDetection`
|
|||
|
|
- `EnablePerItemMetrics`
|
|||
|
|
- `EnableAsyncReserve`
|
|||
|
|
|
|||
|
|
低端池只开基础能力,高风险池再开强诊断。
|
|||
|
|
|
|||
|
|
### 13.3 自定义验证器
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
public interface IPoolValidator<T>
|
|||
|
|
{
|
|||
|
|
PoolValidationResult ValidateOnRent(T item);
|
|||
|
|
PoolValidationResult ValidateOnReturn(T item);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
用来做:
|
|||
|
|
|
|||
|
|
- GameObject 是否被外部销毁
|
|||
|
|
- 资源引用是否断裂
|
|||
|
|
- Component 缓存是否失效
|
|||
|
|
- Native 容器是否已释放
|
|||
|
|
|
|||
|
|
## 14. 调试与观测设计
|
|||
|
|
|
|||
|
|
调试要“非常轻松方便”,但不能靠在 Inspector 每帧遍历整个池。
|
|||
|
|
|
|||
|
|
我的做法是“事件流 + 快照缓存 + 编辑器订阅”。
|
|||
|
|
|
|||
|
|
### 14.1 运行时观测层
|
|||
|
|
|
|||
|
|
运行时只维护固定大小统计:
|
|||
|
|
|
|||
|
|
- 总租用次数
|
|||
|
|
- 总归还次数
|
|||
|
|
- 当前活跃数
|
|||
|
|
- 当前空闲数
|
|||
|
|
- 峰值活跃数
|
|||
|
|
- 创建次数
|
|||
|
|
- 销毁次数
|
|||
|
|
- 扩容次数
|
|||
|
|
- 裁剪次数
|
|||
|
|
- 失败租用次数
|
|||
|
|
|
|||
|
|
这些都存在 struct 里,常量开销更新。
|
|||
|
|
|
|||
|
|
### 14.2 环形事件缓冲
|
|||
|
|
|
|||
|
|
仅在开启诊断时记录事件到无锁环形缓冲:
|
|||
|
|
|
|||
|
|
- Rent
|
|||
|
|
- Return
|
|||
|
|
- Grow
|
|||
|
|
- Trim
|
|||
|
|
- LeakWarning
|
|||
|
|
- InvalidReturn
|
|||
|
|
- DestroyOutsidePool
|
|||
|
|
|
|||
|
|
每条事件固定大小,不分配字符串正文,只记录:
|
|||
|
|
|
|||
|
|
- `PoolId`
|
|||
|
|
- `SlotIndex`
|
|||
|
|
- `Frame`
|
|||
|
|
- `EventType`
|
|||
|
|
- `Arg0`
|
|||
|
|
- `Arg1`
|
|||
|
|
|
|||
|
|
UI 读取时再解码。
|
|||
|
|
|
|||
|
|
### 14.3 快照机制
|
|||
|
|
|
|||
|
|
编辑器面板不直接扫运行时容器,而是请求一个“快照构建器”:
|
|||
|
|
|
|||
|
|
- 默认每 500ms 构建一次
|
|||
|
|
- 只在窗口可见时工作
|
|||
|
|
- 可按域、池名、类型过滤
|
|||
|
|
- 可显示 Top N 热池
|
|||
|
|
|
|||
|
|
快照属于调试层分配,不污染业务路径。
|
|||
|
|
|
|||
|
|
### 14.4 泄漏检测
|
|||
|
|
|
|||
|
|
泄漏检测分等级:
|
|||
|
|
|
|||
|
|
- Level 0:只统计未归还数量
|
|||
|
|
- Level 1:记录最近 owner id
|
|||
|
|
- Level 2:记录采样调用栈
|
|||
|
|
|
|||
|
|
调用栈采样不能默认全开,只能:
|
|||
|
|
|
|||
|
|
- 仅编辑器
|
|||
|
|
- 仅开发构建
|
|||
|
|
- 仅采样部分对象
|
|||
|
|
- 仅指定池启用
|
|||
|
|
|
|||
|
|
### 14.5 Debug 面板能力
|
|||
|
|
|
|||
|
|
我会做以下视图:
|
|||
|
|
|
|||
|
|
- 域总览
|
|||
|
|
- 池列表
|
|||
|
|
- 单池详情
|
|||
|
|
- Key 分布
|
|||
|
|
- 活跃对象列表
|
|||
|
|
- 最近错误事件
|
|||
|
|
- 租用热点排行
|
|||
|
|
- 裁剪压力排行
|
|||
|
|
- 扩容时间线
|
|||
|
|
- 泄漏嫌疑列表
|
|||
|
|
|
|||
|
|
并支持:
|
|||
|
|
|
|||
|
|
- 搜索
|
|||
|
|
- 排序
|
|||
|
|
- 冻结快照
|
|||
|
|
- 导出报告
|
|||
|
|
- 点击对象定位场景实例
|
|||
|
|
|
|||
|
|
## 15. 线程模型
|
|||
|
|
|
|||
|
|
Unity 项目里线程问题不能模糊。
|
|||
|
|
|
|||
|
|
### 15.1 主线程池
|
|||
|
|
|
|||
|
|
默认所有 Unity 对象池都归主线程管理。
|
|||
|
|
|
|||
|
|
### 15.2 Worker 池
|
|||
|
|
|
|||
|
|
纯托管对象池可选线程安全版本。
|
|||
|
|
|
|||
|
|
线程安全不是给所有池统一加锁,而是分实现:
|
|||
|
|
|
|||
|
|
- `MainThreadPool<T>`
|
|||
|
|
- `ConcurrentPool<T>`
|
|||
|
|
|
|||
|
|
原因:
|
|||
|
|
|
|||
|
|
- 主线程池要极致轻量
|
|||
|
|
- 并发池要接受额外同步成本
|
|||
|
|
|
|||
|
|
### 15.3 Job 边界
|
|||
|
|
|
|||
|
|
如果池对象会跨 Job 使用,必须通过显式借用协议:
|
|||
|
|
|
|||
|
|
- 主线程 `Rent`
|
|||
|
|
- 写入 job token
|
|||
|
|
- job 完成后 `Return`
|
|||
|
|
|
|||
|
|
不允许在 Job 内直接操作托管池。
|
|||
|
|
|
|||
|
|
## 16. 错误防护
|
|||
|
|
|
|||
|
|
大型项目里最值钱的是“错误早点炸,而且能查到是谁干的”。
|
|||
|
|
|
|||
|
|
系统必须捕获:
|
|||
|
|
|
|||
|
|
- 双重归还
|
|||
|
|
- 跨池归还
|
|||
|
|
- 归还已失效句柄
|
|||
|
|
- 外部销毁池对象
|
|||
|
|
- 未初始化对象进入可用链
|
|||
|
|
- 锁定对象被裁剪
|
|||
|
|
- 异步加载失败后错误入池
|
|||
|
|
|
|||
|
|
### 16.1 处理策略
|
|||
|
|
|
|||
|
|
不同构建等级不同处理:
|
|||
|
|
|
|||
|
|
- `Editor/Dev`:强校验 + 报错 + 事件记录
|
|||
|
|
- `Release`:快速失败 + 计数器上报 + 可选降级
|
|||
|
|
|
|||
|
|
### 16.2 降级策略
|
|||
|
|
|
|||
|
|
当池进入异常状态,可选:
|
|||
|
|
|
|||
|
|
- 拒绝继续租用
|
|||
|
|
- 回退到直创建直销毁
|
|||
|
|
- 标记池熔断
|
|||
|
|
- 上报监控并隔离该域
|
|||
|
|
|
|||
|
|
## 17. 配置系统
|
|||
|
|
|
|||
|
|
大型项目不能全靠代码硬编码。
|
|||
|
|
|
|||
|
|
建议配置对象:
|
|||
|
|
|
|||
|
|
- `PoolDomainConfig`
|
|||
|
|
- `PoolDefinitionAsset`
|
|||
|
|
- `PoolRuntimeOverride`
|
|||
|
|
|
|||
|
|
配置字段包括:
|
|||
|
|
|
|||
|
|
- PoolId
|
|||
|
|
- 类型
|
|||
|
|
- Key 类型
|
|||
|
|
- 预热数量
|
|||
|
|
- 容量阈值
|
|||
|
|
- 扩容策略
|
|||
|
|
- 裁剪策略
|
|||
|
|
- 调试等级
|
|||
|
|
- 泄漏检测等级
|
|||
|
|
- 场景切换策略
|
|||
|
|
- 低内存回调策略
|
|||
|
|
|
|||
|
|
### 17.1 配置分层
|
|||
|
|
|
|||
|
|
- 默认配置
|
|||
|
|
- 平台配置
|
|||
|
|
- 场景配置
|
|||
|
|
- 运行时覆盖
|
|||
|
|
|
|||
|
|
例如移动端和 PC 不应该共享完全相同的池容量。
|
|||
|
|
|
|||
|
|
## 18. 生命周期与场景切换
|
|||
|
|
|
|||
|
|
对象池需要明确域生命周期:
|
|||
|
|
|
|||
|
|
- `Global`
|
|||
|
|
- `Scene`
|
|||
|
|
- `Match`
|
|||
|
|
- `Feature`
|
|||
|
|
- `Temporary`
|
|||
|
|
|
|||
|
|
### 18.1 场景池
|
|||
|
|
|
|||
|
|
进入场景时预热,退出场景时可批量回收或转移。
|
|||
|
|
|
|||
|
|
### 18.2 Global 池
|
|||
|
|
|
|||
|
|
如 UI 和通用 FX,不随场景直接销毁,但需要做场景引用清洗。
|
|||
|
|
|
|||
|
|
### 18.3 Temporary 池
|
|||
|
|
|
|||
|
|
例如一次性事件活动模块,生命周期结束直接销毁整个域。
|
|||
|
|
|
|||
|
|
## 19. 性能预算建议
|
|||
|
|
|
|||
|
|
建议在设计时就定义预算:
|
|||
|
|
|
|||
|
|
- `Rent/Return` 平均 < 0.5us
|
|||
|
|
- `Trim` 支持分帧预算,如每帧最多处理 16 或 32 个候选
|
|||
|
|
- Debug 关闭时每池统计更新为常量成本
|
|||
|
|
- 快照构建不进入游戏主热帧
|
|||
|
|
|
|||
|
|
### 19.1 大池优化建议
|
|||
|
|
|
|||
|
|
当池数量 > 100 或对象总量 > 100000 时:
|
|||
|
|
|
|||
|
|
- 禁止全池排序式裁剪
|
|||
|
|
- 禁止调试全量对象枚举默认开启
|
|||
|
|
- 使用域级调度队列
|
|||
|
|
- 使用 Top-K 热点缓存代替实时全排序
|
|||
|
|
|
|||
|
|
## 20. 测试策略
|
|||
|
|
|
|||
|
|
必须覆盖:
|
|||
|
|
|
|||
|
|
- 单线程正确性
|
|||
|
|
- 高频 Rent/Return 压测
|
|||
|
|
- 大容量扩缩容
|
|||
|
|
- Key 分桶正确性
|
|||
|
|
- 双重归还检测
|
|||
|
|
- 无效句柄检测
|
|||
|
|
- 异步加载失败回滚
|
|||
|
|
- 场景切换清理
|
|||
|
|
- 泄漏检测
|
|||
|
|
- 调试快照一致性
|
|||
|
|
|
|||
|
|
### 20.1 Benchmark
|
|||
|
|
|
|||
|
|
基准要对比:
|
|||
|
|
|
|||
|
|
- 当前实现
|
|||
|
|
- 新实现
|
|||
|
|
- Unity 内置 `ObjectPool<T>`
|
|||
|
|
- 直创建直销毁
|
|||
|
|
|
|||
|
|
指标:
|
|||
|
|
|
|||
|
|
- CPU
|
|||
|
|
- GC Alloc
|
|||
|
|
- Peak Memory
|
|||
|
|
- P99 延迟
|
|||
|
|
- Trim 尖峰
|
|||
|
|
|
|||
|
|
## 21. 推荐的最小落地版本
|
|||
|
|
|
|||
|
|
如果让我先做第一版,不会一次塞满所有高级功能,而是按下面顺序:
|
|||
|
|
|
|||
|
|
### Phase 1
|
|||
|
|
|
|||
|
|
- `MainThreadPool<T>`
|
|||
|
|
- 值类型句柄
|
|||
|
|
- 固定数组槽位
|
|||
|
|
- O(1) Rent/Return
|
|||
|
|
- 基础预热和扩容
|
|||
|
|
- 基础统计
|
|||
|
|
- Dev 下双重归还检测
|
|||
|
|
|
|||
|
|
### Phase 2
|
|||
|
|
|
|||
|
|
- 多 Key 支持
|
|||
|
|
- 分帧 Trim
|
|||
|
|
- 域与预算
|
|||
|
|
- GameObject / Component 适配器
|
|||
|
|
- 编辑器快照面板
|
|||
|
|
|
|||
|
|
### Phase 3
|
|||
|
|
|
|||
|
|
- 异步资源实例池
|
|||
|
|
- 泄漏采样
|
|||
|
|
- Native 包装池
|
|||
|
|
- 并发池
|
|||
|
|
- 报表导出和线上遥测
|
|||
|
|
|
|||
|
|
## 22. 我会避免的设计
|
|||
|
|
|
|||
|
|
以下方案我不会采用:
|
|||
|
|
|
|||
|
|
- 强制所有池对象继承统一基类
|
|||
|
|
- 用字符串当唯一主 Key 贯穿所有热路径
|
|||
|
|
- 超容后立刻全表扫描清理
|
|||
|
|
- 每帧在 Inspector 里直接遍历完整池状态
|
|||
|
|
- 在热路径用 `DateTime.UtcNow`
|
|||
|
|
- 在热路径构造 `List<>`、`ToArray()`、LINQ
|
|||
|
|
- 为了“统一”而让所有池都加线程锁
|
|||
|
|
- 调试信息和业务数据共用同一套重型结构
|
|||
|
|
|
|||
|
|
## 23. 总结
|
|||
|
|
|
|||
|
|
如果要同时做到高性能、低内存、0GC、高扩展、易调试,我的核心思路是:
|
|||
|
|
|
|||
|
|
- 用数组槽位和索引链代替托管链表对象。
|
|||
|
|
- 用句柄和版本号解决大型项目里的误用问题。
|
|||
|
|
- 用多索引结构保证 `Rent/Return` 真正稳定。
|
|||
|
|
- 用分帧预算和候选队列替代同步全表释放。
|
|||
|
|
- 让调试成为旁路系统,而不是主路径负担。
|
|||
|
|
- 不强迫所有类型共用一种对象模型,而是统一接口、分离适配器。
|
|||
|
|
|
|||
|
|
这套设计的关键不是“对象池”,而是“把对象池当成一套运行时基础设施”来做。只有这样,它才扛得住大型项目、复杂类型、长期演进和多人协作。
|
|||
|
|
|
|||
|
|
## 24. 后续实现建议
|
|||
|
|
|
|||
|
|
如果继续往下落地,我建议按这个顺序开发:
|
|||
|
|
|
|||
|
|
1. 先实现纯 C# 泛型池核心,完成句柄、槽位、版本、O(1) 可用链。
|
|||
|
|
2. 再实现 GameObject 适配器,不要一开始就把 Unity 特性揉进核心。
|
|||
|
|
3. 接着做域、预算和分帧裁剪,否则大项目上线后一定出现维护尖峰。
|
|||
|
|
4. 最后做调试窗口和快照系统,确保调试能力强但不反噬运行时。
|
|||
|
|
|
|||
|
|
如果你愿意,我下一步可以直接基于这份文档,在 `Packages/com.alicizax.unity.framework/Runtime/ABase/ObjectPool` 旁边给你起一版可落地的代码骨架。
|