LOADING

加载过慢请开启缓存 浏览器默认开启

Unity 对象池的设计与实现:以弹幕游戏的子弹管理为例

游戏里每颗子弹、每个粒子、每次特效,背后都是一次创建和销毁。当同屏上千个对象在飞,这种频繁分配会直接拖垮帧率。对象池(Object Pool)就是为解决这个问题而生的设计模式——预先创建一批对象放在一边,需要的时候借出来,用完再还回去,而不是每次都新建、销毁。

这篇文章以我做过的一个弹幕游戏《车万空寂花》为例,把对象池这项技术从原理到完整实现拆开讲。

一、为什么需要对象池

先说清楚我们要解决的问题。在 Unity 里,你在场景中看到的一个物体就是一个 GameObject,它的模板叫 Prefab(预制体)。要用它,调用 Object.Instantiate(prefab) 复制一份到场景;不再需要了,调用 Object.Destroy(go) 销毁。

这两步看着轻巧,代价却不小:

  • Instantiate 要在托管堆上分配 C# 对象,还要在引擎底层创建对应的原生资源(Transform、渲染状态等)。频繁调用就是频繁分配。
  • Destroy 不会立刻回收内存,被销毁对象会在某一刻交给 C# 的垃圾回收(Garbage Collection,简称 GC)去清理。GC 一跑就要暂停主线程做标记-整理,表现就是一次突然而短暂的卡顿。

弹幕游戏是这个问题的放大器:一场战斗里可能有成千上万颗子弹在生成、飞行、命中、消失。如果每颗都 Instantiate / Destroy,内存分配和 GC 会反复冲击主线程,帧率跟着上上下下。

对象池的思路很直接——子弹不要真的销毁,而是藏起来备用。需要一颗新子弹时,从「备用堆」里拿一颗出来重新激活;它该消失时,关掉显示、放回备用堆,等下一颗复用。这样一来,分配只发生在第一次填满池子的时候,之后几乎不再有 Instantiate / Destroy,GC 压力随之消失。

下面要讲的,就是《车万空寂花》里这套池子的完整实现。

二、核心结构:分桶字典池

整个池子建立在两个概念上:桶(Bucket)池根(PoolRoot)

一颗子弹的模板(Prefab)对应一个桶,桶里维护一个「当前空闲、可被借出」的对象栈。多个不同的 Prefab 就对应多个桶,用一个字典串起来。代码如下:

// DemoFrameWork.Pool
public class GameObjectPool : MonoBehaviour
{
    // 一个 Prefab 对应一个桶
    private class PrefabBucket
    {
        public GameObject Prefab;
        public Transform Container;            // 空闲实例挂在这里
        public Stack<GameObject> Available = new Stack<GameObject>();
    }

    // key(Prefab 路径或名字)→ 桶
    private Dictionary<string, PrefabBucket> _buckets
        = new Dictionary<string, PrefabBucket>();

    private Transform _poolRoot;
    private bool _initialized;

    public void Initialize()
    {
        if (_initialized) return;
        _poolRoot = new GameObject("PoolRoot").transform;
        _poolRoot.SetParent(transform);
        DontDestroyOnLoad(gameObject);   // 切场景也不丢
        _initialized = true;
    }
}

这里有几个设计点值得说:

为什么用 Stack 而不是 Queue 栈是后进先出(LIFO)。一颗刚还回来的子弹,它的内存、组件状态还在 CPU 缓存里「热」着,马上再借出去命中率最高;队列是先进先出,反而容易借到很久没用的冷对象。对子弹这种高频复用、生命周期短的对象,LIFO 在缓存局部性上更友好。

为什么需要 PoolRoot + DontDestroyOnLoad 池子本身是一个 MonoBehaviour,挂在某个不销毁的节点上。所有空闲实例统一挂在 PoolRoot 下的各个桶 Container 里,既不会污染场景层级,也能在切场景时完整保留,下次进场景直接复用。

桶的 key 用什么? 可以是 Prefab 的资源路径(Resources 或资源系统路径),也可以是 Prefab 的名字。GetOrCreateBucketByPath 在找不到桶时会按需创建,并优先从游戏的资源系统加载,失败再退回 Resources.Load

private PrefabBucket GetOrCreateBucketByPath(string path)
{
    if (_buckets.TryGetValue(path, out var b)) return b;

    GameObject prefab = null;
    if (DemoGameEntry.Resource != null)
        prefab = DemoGameEntry.Resource.Load<GameObject>(path);
    if (prefab == null)
        prefab = Resources.Load<GameObject>(path);
    if (prefab == null)
    {
        Debug.LogWarning($"[GameObjectPool] Prefab not found: {path}");
        return null;
    }
    return GetOrCreateBucketByPrefab(prefab, path);
}

池子要管理对象,对象也得「认识」自己的池。所以每个被池管理的 GameObject 上都挂一个 PooledObject 组件,记下它属于哪个桶、哪个池:

public class PooledObject : MonoBehaviour
{
    public string BucketKey { get; internal set; }   // 属于哪个桶
    internal GameObjectPool Pool { get; set; }        // 属于哪个池

    public void Recycle()
    {
        if (Pool != null)
            Pool.Recycle(gameObject);
    }
}

需要借出/归还时收到生命周期回调的对象,可以实现 IPoolable 接口,池会在关键时刻通知它:

public interface IPoolable
{
    void OnSpawn();    // 借出时
    void OnRecycle();  // 归还时
}

这三个类(IPoolable / PooledObject / GameObjectPool)就是整座池的骨架。

三、借出:Spawn 是怎么工作的

借出逻辑全在 SpawnFromBucket 里。它从桶的空闲栈里弹出一个对象,能复用就复用,不能就新建:

private GameObject SpawnFromBucket(PrefabBucket bucket, Vector3 position, Quaternion rotation)
{
    // 栈里可能有「已销毁」的引用(切场景等导致)。
    // Pop 后必须用 == null 判断(Unity 重载了 ==,含已销毁情况),再访问 .activeSelf
    GameObject go = null;
    while (bucket.Available.Count > 0)
    {
        var candidate = bucket.Available.Pop();
        if (candidate != null)
        {
            go = candidate;
            break;
        }
    }

    if (go == null)
    {
        // 池空了,新建一颗,挂上 PooledObject 登记身份
        go = Object.Instantiate(bucket.Prefab, position, rotation);
        var po = go.GetComponent<PooledObject>() ?? go.AddComponent<PooledObject>();
        po.BucketKey = GetKeyForBucket(bucket);
        po.Pool = this;
    }
    else
    {
        // 栈损坏或重复 Recycle 时,弹出的对象可能仍激活、或已不在桶下。
        // 这种情况丢弃并新建,避免后续 SetParent / MoveGameObjectToScene 触发引擎断言
        if (go.activeSelf || go.transform.parent != bucket.Container)
        {
            Debug.LogWarning(
                $"[GameObjectPool] Pooled instance in bad state (active={go.activeSelf}, parentOk={go.transform.parent == bucket.Container}); destroying and creating new. Prefab={bucket.Prefab?.name}",
                go);
            Object.Destroy(go);
            go = Object.Instantiate(bucket.Prefab, position, rotation);
            var poNew = go.GetComponent<PooledObject>() ?? go.AddComponent<PooledObject>();
            poNew.BucketKey = GetKeyForBucket(bucket);
            poNew.Pool = this;
        }
        else
        {
            if (go.transform.parent != null)
                go.transform.SetParent(null, true);
            go.transform.position = position;
            go.transform.rotation = rotation;

            // 从 PoolRoot(DontDestroyOnLoad)下取出后若不重设场景,
            // 根物体会留在 DDOL,切场景也不会被卸载
            var active = SceneManager.GetActiveScene();
            if (active.IsValid() && go.scene != active)
                SceneManager.MoveGameObjectToScene(go, active);
        }
    }

    go.SetActive(true);
    foreach (var c in go.GetComponentsInChildren<IPoolable>(true))
        c.OnSpawn();
    return go;
}

这段代码里有三个坑,是对象池最容易翻车的地方:

1. Unity 里的「空引用」和你想的不一样。 在 C# 里 Destroy(go) 之后,go 这个变量还是指向那个托管对象的,普通 go == null 会是 false。但 Unity 重载了 ==!= 运算符:当一个 GameObject 的原生部分已被销毁时,go == null 会返回 true。所以 while 循环里用 candidate != null 来判断,正好能把「已经销毁、只剩空壳」的引用过滤掉,不会借到一颗幽灵子弹。这一点如果没意识到,池子用几次就会借出已死亡的对象,表现极其诡异。

2. 借出来的对象状态可能「坏」了。 理论上栈里弹出来的都该是已归还、未激活、挂在桶下的。但万一之前发生过重复归还、或外部代码乱动了父子关系,弹出来的对象可能还亮着、或父节点已经不对。这时硬把它 SetParent / 移场景,Unity 会直接抛断言。代码选择「宁可不信任,销毁重建」,用一次 Instantiate 的代价换掉一个不可信状态,比冒险复用安全得多。

3. 跨场景归属。 池里的对象挂在 PoolRoot 下,而 PoolRootDontDestroyOnLoad 的,不属于任何具体场景。从池里借出一颗子弹后,如果不把它显式移回当前活动场景,它的「根」会一直留在 DDOL 里,切场景时也卸载不掉,慢慢堆积。所以 Spawn 时检查 go.scene != active,用 SceneManager.MoveGameObjectToScene 把它放回当前场景。

最后,SetActive(true) 激活,并广播 OnSpawn() 给所有实现了 IPoolable 的子组件——这是借出方做「出场初始化」的钩子。

四、归还:Recycle 与幂等防护

借出难,归还更容易出错,因为「还」这个动作可能被重复调用。看 Recycle

public void Recycle(GameObject go)
{
    if (go == null) return;
    var po = go.GetComponent<PooledObject>();
    if (po == null || string.IsNullOrEmpty(po.BucketKey))
    {
        Object.Destroy(go);   // 来历不明,直接销毁
        return;
    }
    if (!_buckets.TryGetValue(po.BucketKey, out var bucket))
    {
        Object.Destroy(go);
        return;
    }

    // 已在该桶下且未激活:视为重复 Recycle,直接返回,避免同一实例两次 Push
    if (!go.activeSelf && go.transform.parent == bucket.Container)
        return;

    foreach (var c in go.GetComponentsInChildren<IPoolable>(true))
        c.OnRecycle();
    go.SetActive(false);
    go.transform.SetParent(bucket.Container, true);
    bucket.Available.Push(go);
}

最关键的防护是那行提前返回: if (!go.activeSelf && go.transform.parent == bucket.Container) return;。设想一颗子弹被 Kill 了,触发 Recycle,正常还回桶里(此时它未激活、父节点是桶)。如果同一帧里某个逻辑又误调了一次 Recycle,对象其实已经在桶里了——要是不拦,它会被再次 Push 进栈。于是栈里出现两份指向同一颗子弹的引用,下次 Spawn 就可能把同一颗子弹借出去两次,场景里出现「双胞胎」,再 SetParent 时引擎直接断言崩溃。

所以「已还过的,就别再还」是池子的基本纪律,而这行判断就是这道纪律的代码表达。

另一个细节:来历不明的对象直接 Destroy 如果一个 GameObject 没有 PooledObject,或者 BucketKey 对不上任何桶,说明它不是这个池管理的,池子不敢随便收,直接销毁最稳妥。这也意味着,任何想进池的对象,都得先有 PooledObject 身份。

五、预热、延迟归还与清理

光有借和还还不够,真实项目里还有几件配套的事。

预热(Prewarm)。 如果等战斗开始才第一次 Spawn,池子是空的,几千颗子弹会在一瞬间全部 Instantiate,造成开局尖峰卡顿。预热就是在加载阶段先把 N 颗建好藏进桶里:

public void Prewarm(string prefabPath, int count)
{
    var bucket = GetOrCreateBucketByPath(prefabPath);
    if (bucket == null) return;
    for (int i = 0; i < count; i++)
    {
        var go = Object.Instantiate(bucket.Prefab, bucket.Container);
        var po = go.GetComponent<PooledObject>() ?? go.AddComponent<PooledObject>();
        po.BucketKey = prefabPath;
        po.Pool = this;
        go.SetActive(false);
        bucket.Available.Push(go);
    }
}

延迟归还(RecycleDelay)。 有些对象「该消失」和「能从场景移除」不是同一时刻。比如子弹命中爆炸,你希望先播完一小段消亡动画,再把它收回池。用协程等一小会儿再 Recycle 即可:

public void RecycleDelay(GameObject go, float delay)
{
    if (go == null) return;
    StartCoroutine(RecycleDelayCoroutine(go, delay));
}

private IEnumerator RecycleDelayCoroutine(GameObject go, float delay)
{
    yield return new WaitForSeconds(delay);
    Recycle(go);
}

清理与缩容。 切场景、换关卡时,可以把空闲实例交还内存;平时池子只增不减,所以也提供按上限裁剪的能力:

public void Clear(string prefabPath)            // 清掉某个 Prefab 的空闲实例
public void ClearAll()                           // 清掉所有空闲实例
public void TrimExcessIdle(int maxIdlePerBucket) // 每桶只留 maxIdle 个空闲,其余 Destroy

这里有个要心里有数的事实:池子平时是只增不减的。 一旦某波弹幕把池子撑到很大,即使之后没那么多子弹,空闲实例也一直占着内存,直到你主动调 TrimExcessIdleClear。在暂停菜单、关卡切换这类「安全时机」调用缩容,才能把峰值内存还回去。

六、消费端:子弹管理器怎么用池

池子建好,得有人来借、来还、来驱动逻辑。这就是 BulletManager 的职责。它维护一个「当前存活子弹」的列表:

// 预分配容量,避免运行时反复扩容产生 GC
private readonly List<DanmakuBullet> _aliveBullets = new List<DanmakuBullet>(256);

生成一颗子弹时,先向池借对象,再填参数:

public DanmakuBullet SpawnBullet(Vector2 worldPos, float angleDeg, float speed,
    float hitRadius = 0.06f, int layer = 0, string prefabPath = null,
    int bulletStyleFolderIndex = -1, int bulletSpriteVariantIndex = -1)
{
    GameObject go = null;
    if (!string.IsNullOrEmpty(prefabPath))
        go = _pool != null ? _pool.Spawn(prefabPath, worldPos, Quaternion.identity)
            : Instantiate(Resources.Load<GameObject>(prefabPath), worldPos, Quaternion.identity);
    else if (_bulletPrefab != null)
        go = _pool != null ? _pool.Spawn(_bulletPrefab, worldPos, Quaternion.identity)
            : Instantiate(_bulletPrefab, worldPos, Quaternion.identity);
    else
        go = _pool != null ? _pool.Spawn(_defaultBulletPrefabPath, worldPos, Quaternion.identity)
            : Instantiate(Resources.Load<GameObject>(_defaultBulletPrefabPath), worldPos, Quaternion.identity);

    if (go == null) return null;

    var bullet = go.GetComponent<DanmakuBullet>();
    if (bullet == null) bullet = go.AddComponent<DanmakuBullet>();

    float rad = angleDeg * Mathf.Deg2Rad;
    Vector2 dir = new Vector2(Mathf.Cos(rad), Mathf.Sin(rad));
    int style = ResolveBulletStyleFolderIndex(bulletStyleFolderIndex);
    int variant = ResolveBulletSpriteVariant(style, bulletSpriteVariantIndex);

    bullet.Init(this, dir, speed, hitRadius, layer, _defaultMinLiveOutScreen, style, variant);
    _aliveBullets.Add(bullet);
    return bullet;
}

注意这里有个降级写法:优先走池(_pool.Spawn),如果池没接好就退回普通 Instantiate。这是给「还没接池」的情况留的后路,但正式运行一定走池。借到对象后,调 bullet.Init(...) 把所有运行参数一次性填进去——位置、方向、速度、碰撞半径、图层、样式——然后丢进存活列表。

每帧推进时,管理器遍历存活子弹做移动和碰撞:

public void LogicUpdate(float dt)
{
    DanmakuCircle playerHit = _player != null ? _player.GetHitCircle() : default;
    DanmakuCircle playerGraze = default;
    bool haveGraze = false;
    if (_player != null && _player.IsAlive)
    {
        float r = playerHit.Radius * _grazeRadiusMultiplier;
        playerGraze = new DanmakuCircle(playerHit.X, playerHit.Y, r);
        haveGraze = true;
    }

    int n = _aliveBullets.Count;
    for (int i = 0; i < n; i++)
    {
        var b = _aliveBullets[i];
        if (b == null || !b.IsAlive) continue;

        b.LogicUpdate(dt, _playArea);
        if (!b.IsAlive) continue;

        if (_player != null && _player.IsAlive)
        {
            var bulletC = b.GetCircle();
            if (!_player.IsInvincible && DanmakuCollision.CheckCircleToCircle(bulletC, playerHit))
            {
                b.Kill();
                _player.TakeDamage();
                if (_player == null || !_player.IsAlive) break;
                continue;
            }
            if (haveGraze && !b.GrazeConsumed &&
                DanmakuCollision.CheckBulletGraze(bulletC, playerHit, playerGraze))
            {
                b.MarkGrazeConsumed();
                _onPlayerGrazed?.Invoke();
            }
        }
    }

    CompactAliveBullets();
}

碰撞检测用的是 DanmakuCircle——一个值类型(struct)。每帧给每颗子弹、CheckCircleToCircle 传的都是一个栈上的小数组,不产生堆分配,所以这场每帧上千次的碰撞计算不会给 GC 添负担。

存活列表的维护是个性能要点。 一颗子弹命中后 IsAlivefalse,但它在列表里的位置还在。如果每死一颗就 List.RemoveAt,而 RemoveAt 会把后面所有元素往前挪,一帧死几百颗就是几百次 O(n) 搬移,整体退化成 O(n²)。改用一次正向扫描的双指针压实:

// 单遍压实列表,避免大量 RemoveAt 导致 O(n²) 移动
private void CompactAliveBullets()
{
    int w = 0;
    int c = _aliveBullets.Count;
    for (int r = 0; r < c; r++)
    {
        var b = _aliveBullets[r];
        if (b != null && b.IsAlive)
            _aliveBullets[w++] = b;   // 只搬活着的
    }
    if (w < c)
        _aliveBullets.RemoveRange(w, c - w);  // 尾部一次性截断
}

读指针 r 走全程,写指针 w 只记录「下一个该留的位置」。活着的往前搬,死了的跳过,最后把 w 之后的尾巴一次 RemoveRange 砍掉。一趟 O(n) 搞定,子弹死再多也不放大开销。

清屏(炸弹、关卡切换)和回收也很直接:

public void ClearBullets(int layer = -1)
{
    for (int i = 0; i < _aliveBullets.Count; i++)
    {
        var b = _aliveBullets[i];
        if (b == null || !b.IsAlive) continue;
        if (layer < 0 || b.Layer == layer) b.Kill();
    }
    CompactAliveBullets();
}

public void RecycleBullet(DanmakuBullet bullet)
{
    if (_pool != null) _pool.Recycle(bullet.gameObject);
    else Destroy(bullet.gameObject);
}

七、子弹本身:复用时的干净复位

借出来的对象是「旧的」,它身上可能还留着上一次飞行的速度、图层、贴图。复用最坑的地方就在这里——忘了复位某个字段,子弹就会带着上辈子的状态出场。所以每颗子弹有一个显式的 Init

public void Init(BulletManager manager, Vector2 direction, float speed, float hitRadius,
    int layer, int minLiveOutScreen, int resolvedBulletStyleFolderIndex, int resolvedSpriteVariantIndex)
{
    _manager = manager;
    Direction = direction.normalized;
    Speed = speed;
    HitRadius = hitRadius;
    Layer = layer;
    MinLiveOutScreen = minLiveOutScreen;
    IsAlive = true;
    _outOfBoundsFrames = 0;
    GrazeConsumed = false;
    _lastVisualUpDir = Vector2.zero;

    ApplyBulletSpriteFromResources(resolvedBulletStyleFolderIndex, resolvedSpriteVariantIndex);
}

每个字段都重新赋值,不留死角。这里要回答一个自然的问题:为什么子弹不用 IPoolable.OnSpawn 来复位,而要单独搞一个 Init 因为 OnSpawn() 没有参数,而子弹出场时必须带上「往哪飞、飞多快、长什么样」这些外部传入的信息。Init 把参数一路传进来,才能在一次调用里完成「复位 + 配置」。池子只负责借还,不负责知道子弹该怎么配置——配置是子弹自己的事。这也是为什么 BulletManager 借到对象后,紧接着调的是 bullet.Init(...) 而不是指望池替它初始化。

每帧更新里还有一处小优化——贴图朝向:

public void LogicUpdate(float dt, Rect playArea)
{
    if (!IsAlive) return;
    transform.position += (Vector3)(Direction * Speed * dt);

    // 旋转贴图朝向运动方向;方向没变时跳过,减轻大量同向弹的 Transform 写入
    if (Direction != Vector2.zero)
    {
        const float rotEpsilon = 1e-4f;
        if (Vector2.Dot(_lastVisualUpDir, Direction) < 1f - rotEpsilon)
        {
            transform.up = Direction;
            _lastVisualUpDir = Direction;
        }
    }

    Vector2 pos = transform.position;
    if (!playArea.Contains(pos))
    {
        _outOfBoundsFrames++;
        if (_outOfBoundsFrames > MinLiveOutScreen) Kill();
    }
    else
    {
        _outOfBoundsFrames = 0;
    }
}

长条形子弹要根据飞行方向旋转贴图。但同一波弹幕往往方向一致,如果每帧都写 transform.up,上千颗做着相同的写入纯属浪费。所以用 _lastVisualUpDir 记上次的朝向,方向没变就跳过这次 Transform 写入。一颗子弹省一次,上千颗就是上千次。

死亡也要幂等,和池的 Recycle 防护是同一套纪律:

public void Kill()
{
    if (!IsAlive) return;
    IsAlive = false;
    _manager?.RecycleBullet(this);
}

if (!IsAlive) return 保证一颗子弹不会因为重复命中而被 Kill 两次、进而被 Recycle 两次。

贴图加载也走缓存:所有样式贴图在启动时由 DanmakuBulletResourceIndex.WarmupIfNeeded 预热进一个字典,Init 里只做 TryGetSprite 查表,不重复 Resources.Load。资源加载这种重活,能提前做就提前做,运行时别碰。

八、对象池没有消灭 GC

必须说清楚一件事:对象池大幅减少了分配,但没有彻底消灭 GC。 它把「每颗子弹一次分配」压成了「池子填满后几乎零分配」,但下面这些地方依然会分配:

  • GetComponentsInChildren<IPoolable>(true) 每次调用都会 new 一个数组来装结果。借出/归还各调一次,高频下仍有小额分配。
  • 闭包与协程。 RecycleDelay 用了协程和 WaitForSeconds,本身有分配;弹幕波次的回调则预先缓存成字段委托 _stageWaveSpawnHandler,避免每波 new 一个闭包。
  • List 扩容。 所以存活列表 new List<DanmakuBullet>(256) 一开始就给足容量,运行时不再扩容。
  • 值类型帮忙。 碰撞用的 DanmakuCircle 是 struct,每帧上千次碰撞计算都在栈上完成,不进堆,这是把这块 GC 彻底拿掉的关键。

还有一个真实存在的低效点值得点名:GetKeyForBucket 为了在「桶对象」和「桶 key」之间反查,用了一个线性遍历:

private string GetKeyForBucket(PrefabBucket bucket)
{
    foreach (var kv in _buckets)
        if (kv.Value == bucket) return kv.Key;
    return bucket.Prefab.name;
}

桶数量一多,每次 Spawn 新建对象都要扫一遍字典,是 O(n)。更干净的做法是在 PrefabBucket 上直接存一份 Key,或者在 PooledObject 创建时就记好,新建路径就不再反查。这个项目当时桶少、没到瓶颈,所以留着;但要讲透,就得把这块如实摆出来。

九、自写池 vs Unity 官方方案

写到这你可能会问:Unity 自己不是有 ObjectPool<T> 之类的东西吗,为什么还要自己写?

官方方案胜在通用、省事,适合「我就要一个能借能还的容器」的场景。这个项目选择自写,主要为了三件事官方接口不一定顺手:

  1. 生命周期钩子。 OnSpawn / OnRecycle 让任何挂了 IPoolable 的子组件都能在借还时刻自动被通知,不用调用方手动记得初始化谁。
  2. 跨场景归属。 PoolRoot + DontDestroyOnLoad + MoveGameObjectToScene 这套,保证池和对象在关卡切换里行为正确,这是 GameObject 池特有的麻烦,通用容器不会管。
  3. 按 Prefab 分桶的简洁性。 子弹、粒子、敌机各自一桶,key 就是资源路径,调用方只管 Spawn(path)

如果你的需求只是「缓存一堆非 Unity 对象」,直接用官方 ObjectPool<T>;如果管的是 GameObject、还要跨场景、还要钩子,自写一套像上面这样的反而更可控。两者不冲突,看复杂度决定。

十、什么时候用,什么时候别用

对象池有它合适的场景,也有不合适的,先把这两头分清楚:

  • 适合: 大量、短命、同构的对象——子弹、粒子、敌人、掉落物、滚动列表项。这类对象反复创建销毁,池的回报最大。
  • 不适合: 数量很少、生命周期长、或者形态差异极大的对象。给三五个长命对象建池,反而增加复杂度,没收益。
  • 线程假设: 上面这套池全程假设在 Unity 主线程跑。Instantiate / Destroy / SceneManager 都是主线程 API,没有做任何加锁。如果你的逻辑在子线程,这套不能直接用,得加同步或换成线程安全的容器。

写在最后。

对象池说到底是「用空间换时间、用复用换稳定」的一招。把它讲清楚,重点不在那几行 Spawn / Recycle 代码,而在理解每一处防护为什么要存在:幽灵引用要靠 Unity 重载的 == 看穿,重复归还要靠幂等判断拦下,跨场景要靠显式移动归属,复用干净要靠显式 Init 复位。把这些边界想明白,你写的池子才不会因为一个边缘情况突然崩在玩家面前。

《车万空寂花》的这套实现就到这里。它没有跑过 Profiler 基准,所以我没有给你「帧率从 X 到 Y」的漂亮数字——但结构和当时踩到的坑都是真的。源码在 qian488/Danmaku,想看完整工程可以顺着翻。