Article

C# 实战课 05:对象池:用接口约定把 Instantiate/Destroy 变成可控成本

前四章解决了“看得见、可配置、可解耦、可调度”,这一章解决“跑得稳”:

  • 高频创建子弹、特效、掉落物会触发大量 Instantiate/Destroy
  • 每次销毁都会给 GC 和 CPU 带来额外负担。
  • 规模一上来,帧率抖动会非常明显。

对象池的价值不是“写一个容器”,而是把对象生命周期变成可控协议。

这章的目标

交付一个可以直接放进 Unity 项目的对象池方案,具备:

  1. 统一接口约定:对象拿出与回收时有明确回调。
  2. 预热能力:开局就把高频对象准备好。
  3. 回收安全:防止重复回收、跨池回收。
  4. 监控指标:知道池命中率和运行时扩容次数。

接口先行:定义生命周期协议

先定义两个接口,让“池化对象”可预测。

public interface IPoolable
{
    void OnSpawned();
    void OnDespawned();
}

public interface IPoolIdentity
{
    int PoolId { get; set; }
}

为什么要 PoolId

  • 防止 A 池的对象被回收到 B 池。
  • 防止重复回收导致状态错乱。

最小对象池实现(泛型)

这部分逻辑已沉淀到 Foundation 小库:

  • foundation/Runtime/Pooling/ObjectPool.cs

核心 API:

  • Get():从池里拿对象,没有则创建。
  • Release(T):回收到池,执行重置。
  • CountInactive:当前可用对象数量。

配合回调约定写法:

var pool = new ObjectPool<Bullet>(
    factory: CreateBullet,
    onGet: OnBulletGet,
    onRelease: OnBulletRelease,
    initialCapacity: 64);

在 Unity 场景里接入

先写一个 BulletPoolService,把池逻辑与业务解耦。

using UnityEngine;
using U3DC.Foundation.Pooling;
using U3DC.Foundation.Logging;

public sealed class BulletPoolService : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private Transform poolRoot;
    [SerializeField] private int warmupCount = 64;

    private ObjectPool<Bullet> _pool;
    private int _created;
    private int _spawned;

    private void Awake()
    {
        _pool = new ObjectPool<Bullet>(Create, OnGet, OnRelease, warmupCount);
        FoundationLog.Info("pool", "bullet pool ready inactive=" + _pool.CountInactive);
    }

    public Bullet Spawn(Vector3 position, Quaternion rotation)
    {
        var bullet = _pool.Get();
        bullet.transform.SetPositionAndRotation(position, rotation);
        _spawned++;
        return bullet;
    }

    public void Despawn(Bullet bullet)
    {
        _pool.Release(bullet);
    }

    private Bullet Create()
    {
        var instance = Instantiate(bulletPrefab, poolRoot);
        instance.gameObject.SetActive(false);
        _created++;
        return instance;
    }

    private static void OnGet(Bullet bullet)
    {
        bullet.gameObject.SetActive(true);
        var poolable = bullet as IPoolable;
        if (poolable != null) poolable.OnSpawned();
    }

    private static void OnRelease(Bullet bullet)
    {
        var poolable = bullet as IPoolable;
        if (poolable != null) poolable.OnDespawned();
        bullet.gameObject.SetActive(false);
    }
}

这里的重点不是语法,而是边界:

  • Create 只做创建。
  • OnGet/OnRelease 只做状态切换。
  • 业务逻辑(比如伤害结算)不写进池内部。

预热策略怎么选

给你一个可直接落地的经验值:

  • 子弹类:按“峰值并发 * 1.2”预热。
  • 特效类:按“单位时间触发上限 * 特效持续时间”估算。
  • UI 列表项:按“最大可见数量 + 缓冲”预热。

不要盲目拉大预热,预热太高会把启动时间拖慢。

回收安全(必须做)

对象池最常见线上问题:重复回收。

建议在调试期加入状态位:

public sealed class Bullet : MonoBehaviour, IPoolable
{
    private bool _activeInScene;

    public void OnSpawned()
    {
        _activeInScene = true;
    }

    public void OnDespawned()
    {
        _activeInScene = false;
        // 重置速度、生命周期、碰撞状态
    }

    public bool IsActiveInScene { get { return _activeInScene; } }
}

回收前做断言:

FoundationAssert.IsTrue(bullet.IsActiveInScene, "pool", "duplicate release detected");

性能观测(最小指标)

对象池不做指标,后续优化基本靠猜。

建议每 10 秒输出一次:

  • created:实际创建数量
  • inactive:池中空闲对象
  • spawned:累计取出次数
  • hitRate:命中率(1 - created/spawned

这能快速判断“池太小”还是“预热过度”。

本章验收

  1. 高并发场景下,Instantiate/Destroy 次数显著下降。
  2. 对象重用后状态正确(位置、速度、碰撞、特效开关都已重置)。
  3. 池命中率可观测,扩容行为可追踪。

与 Foundation 的衔接

本章与前四章代码约定已经串起来:

  • 日志:foundation/Runtime/Logging/FoundationLog.cs
  • 断言:foundation/Runtime/Diagnostics/FoundationAssert.cs
  • 事件:foundation/Runtime/Events/EventBus.cs
  • 调度:foundation/Runtime/Timing/Scheduler.cs
  • 对象池:foundation/Runtime/Pooling/ObjectPool.cs

后续 Unity 章节会直接复用这些模块,不再重复造基础设施。

下一章预告

下一章做“存档系统”:

  • 版本升级
  • 数据校验
  • 损坏恢复

目标是把“本地存档可演进”做成标准能力。