DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 12
STEP 01 / CODE / LOGIC

先说结论

对象池用“回收后再次使用”代替高频创建和销毁。它减少分配和实例化抖动,但不会自动重置旧状态;池中对象只是暂时空闲,不是全新对象。

初始:池中 0,已创建 0
发射 A、B、C:池中 0,已创建 3
A 命中并 Release:池中 1,已创建仍为 3
第四次 Get:复用 A,池中 0,已创建仍为 3

分类:工程设计

先说结论

对象池用“回收后再次使用”代替高频创建和销毁。它减少分配和实例化抖动,但不会自动重置旧状态;池中对象只是暂时空闲,不是全新对象。

四次发射只创建三颗子弹

初始:池中 0,已创建 0
发射 A、B、C:池中 0,已创建 3
A 命中并 Release:池中 1,已创建仍为 3
第四次 Get:复用 A,池中 0,已创建仍为 3

第四次取出 A 时必须清除它上次的目标、命中标记和回调。重复 Release A 会让池中出现两个相同引用,之后两个调用者可能同时拿到同一个对象。

对象池解决什么问题

游戏里有很多对象会频繁创建和销毁:

子弹
伤害数字
飘字
命中特效
掉落物
音效播放器
UI 列表项
怪物预制体

普通写法:

GameObject bullet = Instantiate(bulletPrefab);
Destroy(bullet);

偶尔一次没问题,但如果每秒几十次,会带来:

CPU 开销
内存分配
GC 压力
卡顿
资源加载抖动

对象池的思路是:

不用了不要销毁,先放回池子。
下次要用,再从池子里拿出来复用。

跟着代码跑一次:创建、回收、再次取出

先用普通 C# 对象模拟子弹,不依赖 Unity:

using System;
using System.Collections.Generic;

sealed class Bullet
{
    private static int nextId;
    public int Id { get; } = ++nextId;
    public bool Active { get; private set; }
    public float Damage { get; private set; }

    public void Spawn(float damage)
    {
        Active = true;
        Damage = damage;
    }

    public void Despawn()
    {
        Active = false;
        Damage = 0f;
    }
}

sealed class BulletPool
{
    private readonly Stack<Bullet> available = new();

    public Bullet Rent(float damage)
    {
        Bullet bullet = available.Count > 0
            ? available.Pop()
            : new Bullet();
        bullet.Spawn(damage);
        return bullet;
    }

    public void Return(Bullet bullet)
    {
        bullet.Despawn();
        available.Push(bullet);
    }
}

按下面顺序调用:

BulletPool pool = new();
Bullet first = pool.Rent(10f);
Console.WriteLine($"第一次: Bullet #{first.Id}, Damage={first.Damage}");

pool.Return(first);

Bullet second = pool.Rent(25f);
Console.WriteLine($"第二次: Bullet #{second.Id}, Damage={second.Damage}");

两次都是 Bullet #1,但第二次伤害是 25

Rent(10) -> 池为空,创建 #1,Spawn(10)
Return   -> Despawn 清理 #1,压回池
Rent(25) -> 弹出 #1,Spawn(25)

第二步:复现重复回收

pool.Return(second);
pool.Return(second); // 错:同一个引用被压入两次

Bullet a = pool.Rent(5f);
Bullet b = pool.Rent(8f);
Console.WriteLine(ReferenceEquals(a, b)); // True,严重错误

两个调用者以为各自拿到一颗子弹,实际拿到同一个对象。加入“当前是否在池中”的集合:

private readonly HashSet<Bullet> inPool = new();

public void Return(Bullet bullet)
{
    if (!inPool.Add(bullet))
        throw new InvalidOperationException("Bullet 被重复回收");

    bullet.Despawn();
    available.Push(bullet);
}

同时在 Rent 弹出对象后执行 inPool.Remove(bullet)。这次第二个 Return 会立即报出生命周期错误,而不是几帧后表现成两发子弹互相覆盖。后面的 Unity 版本只是把 Spawn/Despawn 换成激活状态、Transform、协程和事件绑定清理,池的状态机没有变化。

核心结构

对象池常用 Stack<T> 保存空闲对象:

Stack<GameObject> pool = new();

原因是对象池通常不关心顺序:

拿哪个空闲对象都行。

Stack.PushStack.Pop 很适合:

回收时 Push
取出时 Pop

取对象

GameObject Get()
{
    if (pool.Count > 0)
    {
        GameObject obj = pool.Pop();
        obj.SetActive(true);
        return obj;
    }

    return Instantiate(prefab);
}

逻辑:

池子里有空闲对象:
    拿一个出来,激活,返回

池子里没有:
    创建一个新的

回收对象

void Release(GameObject obj)
{
    obj.SetActive(false);
    pool.Push(obj);
}

逻辑:

不用了:
    先隐藏
    放回池子

这里不是 Destroy,而是复用。

简化版对象池

public class SimpleGameObjectPool
{
    private readonly GameObject prefab;
    private readonly Stack<GameObject> pool = new();

    public SimpleGameObjectPool(GameObject prefab)
    {
        this.prefab = prefab;
    }

    public GameObject Get()
    {
        if (pool.Count > 0)
        {
            GameObject obj = pool.Pop();
            obj.SetActive(true);
            return obj;
        }

        return UnityEngine.Object.Instantiate(prefab);
    }

    public void Release(GameObject obj)
    {
        if (obj == null)
        {
            return;
        }

        obj.SetActive(false);
        pool.Push(obj);
    }
}

使用:

SimpleGameObjectPool bulletPool = new(bulletPrefab);

GameObject bullet = bulletPool.Get();

// 子弹飞完或命中后
bulletPool.Release(bullet);

坑一:取出时要重置状态

对象复用前一定要清理旧状态。

例如子弹上次的数据:

速度 = 20
伤害 = 100
目标 = enemyA
已经命中 = true

如果回收后下次直接拿出来,不重置,就可能:

新子弹一出生就处于“已经命中”
新子弹还保留旧目标
新特效还带着旧颜色、旧位置、旧回调

所以对象池通常会有:

OnSpawn:取出时初始化
OnDespawn:回收时清理

示例:

public interface IPoolable
{
    void OnSpawn();
    void OnDespawn();
}

坑二:防止重复回收

如果同一个对象被回收两次:

pool.Release(bullet);
pool.Release(bullet);

池子里会有两个相同引用。

之后两次 Get() 可能拿到同一个对象:

A 拿到 bullet
B 也拿到 bullet

这会非常难查。

可以用 HashSet<GameObject> 检测:

HashSet<GameObject> inPool = new();

回收时:

if (!inPool.Add(obj))
{
    Debug.LogWarning("重复回收");
    return;
}

obj.SetActive(false);
pool.Push(obj);

取出时:

GameObject obj = pool.Pop();
inPool.Remove(obj);
obj.SetActive(true);

这里用到了 HashSet.Add 的返回值:

false 表示已经存在,说明重复回收。

坑三:对象池不是越大越好

对象池减少创建销毁,但也会占内存。

如果池子里长期堆着大量不用的对象:

不销毁也会占内存。

成熟对象池通常会有:

初始容量
最大容量
超出最大容量时 Destroy
长时间不用时裁剪
场景退出时清空

例如:

if (pool.Count >= maxSize)
{
    UnityEngine.Object.Destroy(obj);
    return;
}

适合对象池的场景

适合:

创建销毁频繁
对象结构差不多
生命周期短
复用成本低

例如:

子弹
特效
伤害数字
UI 列表项
音效播放器

不适合:

很少创建的对象
状态极其复杂且难以重置
长期存在的唯一对象

最重要的收获

对象池用“回收复用”替代“频繁创建销毁”。

最重要的三个坑:

取出时要初始化。
回收时要清理。
防止同一个对象重复回收。

和前面知识点的连接:

Stack:保存可复用对象。
HashSet:检测重复回收。
生命周期管理:谁 Get,谁 Release。
状态重置:避免旧数据污染新对象。