Skip to content

从对象池开始

为什么用对象池?

csharp-unity-why-object-pool

标准答案

用对象池是为了减少运行时频繁 Instantiate / Destroy 带来的 CPU 峰值、GC Alloc 和卡顿

在 Unity 里,像子弹、特效、飘字、怪物、UI Item 这种对象,可能短时间大量创建和销毁。如果每次都 Instantiate,每次用完都 Destroy,就容易造成:

  1. 创建对象开销高。
  2. 销毁对象不是立刻完全释放。
  3. 托管对象分配增加 GC 压力。
  4. 某一帧突然创建很多对象,会出现卡顿尖峰。

所以对象池的思路是:提前创建一批对象,用的时候取出来,不用的时候重置状态放回去,下次继续复用。

底层原理

对象池本质是用内存换性能稳定性

不用对象池:

创建 -> 使用 -> 销毁 -> 再创建 -> 再销毁

使用对象池:

预热 -> 取出 -> 使用 -> 重置 -> 放回 -> 再取出

这样做不是让对象数量凭空减少,而是减少运行时反复分配和销毁。

Unity 项目里常见场景

子弹池:大量发射和回收。

特效池:爆炸、受击、技能特效频繁播放。

飘字池:伤害数字大量出现。

UI Item 池:背包、排行榜、邮件列表。

怪物池:刷怪、回收、再刷。

代码例子

c
using System.Collections.Generic; // 引入 Queue 和 HashSet,用来管理池中对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、MonoBehaviour。

public class SimpleBulletPool : MonoBehaviour // 定义一个简单子弹对象池。
{ // 类开始。
    [SerializeField] private GameObject bulletPrefab; // 子弹预制体,由 Inspector 绑定。
    [SerializeField] private int preloadCount = 30; // 预热数量,避免战斗中第一次创建卡顿。

    private readonly Queue<GameObject> _pool = new Queue<GameObject>(); // 保存可复用的空闲子弹。
    private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录哪些对象已经在池中,防止重复回收。

    private void Awake() // Unity 初始化时调用。
    { // 方法开始。
        for (int i = 0; i < preloadCount; i++) // 按预热数量提前创建子弹。
        { // 循环开始。
            GameObject bullet = CreateBullet(); // 创建一个新的子弹对象。
            Return(bullet); // 创建后立刻放回池中等待复用。
        } // 循环结束。
    } // 方法结束。

    public GameObject Get(Vector3 position, Quaternion rotation) // 从池中取出一个子弹。
    { // 方法开始。
        GameObject bullet = _pool.Count > 0 ? _pool.Dequeue() : CreateBullet(); // 有空闲对象就复用,没有就扩容创建。
        _inPool.Remove(bullet); // 从池中记录移除,表示它现在正在使用。
        bullet.transform.SetPositionAndRotation(position, rotation); // 设置子弹出生位置和方向。
        bullet.SetActive(true); // 激活子弹,让它开始显示和运行逻辑。
        return bullet; // 返回取出的子弹对象。
    } // 方法结束。

    public void Return(GameObject bullet) // 把子弹归还到对象池。
    { // 方法开始。
        if (bullet == null) // 如果传进来的对象为空。
        { // 判断开始。
            return; // 直接返回,避免空引用。
        } // 判断结束。

        if (_inPool.Contains(bullet)) // 如果这个子弹已经在池中。
        { // 判断开始。
            return; // 直接返回,避免重复回收导致一个对象被取出多次。
        } // 判断结束。

        bullet.SetActive(false); // 关闭对象,停止显示和大部分 Unity 回调。
        bullet.transform.SetParent(transform); // 放到池节点下面,方便层级管理。
        _pool.Enqueue(bullet); // 加入空闲队列,等待下次复用。
        _inPool.Add(bullet); // 标记这个对象已经在池中。
    } // 方法结束。

    private GameObject CreateBullet() // 创建一个新的子弹对象。
    { // 方法开始。
        GameObject bullet = Instantiate(bulletPrefab, transform); // 实例化子弹,并挂到池节点下。
        bullet.SetActive(false); // 新创建的对象先关闭,避免马上参与逻辑。
        return bullet; // 返回新创建的对象。
    } // 方法结束。
} // 类结束。

面试加分点

对象池不是万能优化。它适合频繁创建、频繁销毁、生命周期短、数量多的对象。

它的代价是:对象会常驻一部分内存。如果池子过大,反而会浪费内存。所以项目里要考虑预热数量、最大容量、扩容策略、切场景时是否清空。

一句话总结:对象池是用内存换 CPU 和 GC 稳定性,重点不是“缓存对象”,而是“取出初始化、归还重置、防止重复回收、合适时机释放”。

如何预热?

csharp-unity-object-pool-prewarm

标准答案

对象池预热就是:在战斗开始前、Loading 阶段、进入场景时,提前创建一批对象并放进池里,这样运行时需要子弹、特效、飘字、UI Item 时,就可以直接取,不用在关键帧里突然 Instantiate

简单说:

预热前:战斗中突然创建 -> 卡顿风险
预热后:Loading 时提前创建 -> 战斗中直接复用

预热怎么做

  1. 先估算数量 比如子弹同屏最多 80 发,就可以预热 80 个左右。特效、飘字、怪物也是看峰值并发数量。
  2. 选择安全时机 一般放在 Loading、进入战斗前、波次开始前,不要放在玩家释放技能或怪物生成的瞬间。
  3. 分帧创建 如果一次预热 500 个对象,也可能把 Loading 卡住,所以可以每帧创建 10 个、20 个。
  4. 创建后关闭并入池Instantiate 出来后 SetActive(false),重置状态,然后放到队列或栈里。
  5. 运行时不够要兜底 池子不够时可以扩容,也可以丢弃或等待,取决于对象类型。子弹可以扩容,飘字可以丢弃低优先级。

代码例子

c
using System.Collections; // 引入 IEnumerator,用来写分帧预热协程。
using System.Collections.Generic; // 引入 Queue,用来保存空闲对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 MonoBehaviour。

public class BulletPoolPrewarmer : MonoBehaviour // 定义一个子弹池预热脚本。
{ // 类开始。
    [SerializeField] private GameObject bulletPrefab; // 子弹预制体,由 Inspector 绑定。
    [SerializeField] private int prewarmCount = 80; // 预热数量,根据同屏峰值估算。
    [SerializeField] private int createPerFrame = 10; // 每帧创建数量,避免预热阶段卡顿。

    private readonly Queue<GameObject> _pool = new Queue<GameObject>(); // 保存空闲子弹对象。
    private int _createdCount; // 记录已经创建过的对象数量。

    public IEnumerator Prewarm() // 定义分帧预热协程。
    { // 协程开始。
        int needCreate = prewarmCount - _createdCount; // 计算还需要创建多少个对象。

        for (int i = 0; i < needCreate; i++) // 循环创建缺少的对象。
        { // 循环开始。
            GameObject bullet = CreateOne(); // 创建一个新的子弹对象。
            ReturnToPool(bullet); // 创建后立即放回池中等待复用。

            if ((i + 1) % createPerFrame == 0) // 每创建一批对象就暂停一帧。
            { // 判断开始。
                yield return null; // 把控制权还给 Unity,下一帧继续预热。
            } // 判断结束。
        } // 循环结束。
    } // 协程结束。

    public GameObject Get(Vector3 position, Quaternion rotation) // 从池里取出一个子弹。
    { // 方法开始。
        GameObject bullet = _pool.Count > 0 ? _pool.Dequeue() : CreateOne(); // 池里有就取,没有就扩容创建。
        bullet.transform.SetPositionAndRotation(position, rotation); // 设置子弹出生位置和方向。
        bullet.SetActive(true); // 激活子弹,让它开始参与逻辑。
        return bullet; // 返回可用子弹。
    } // 方法结束。

    public void ReturnToPool(GameObject bullet) // 把子弹放回池中。
    { // 方法开始。
        if (bullet == null) // 如果传入对象为空。
        { // 判断开始。
            return; // 直接返回,避免空引用。
        } // 判断结束。

        bullet.SetActive(false); // 关闭对象,避免继续显示和执行逻辑。
        bullet.transform.SetParent(transform); // 挂到对象池节点下面,方便层级管理。
        _pool.Enqueue(bullet); // 放入空闲队列,等待下次复用。
    } // 方法结束。

    private GameObject CreateOne() // 创建一个新子弹对象。
    { // 方法开始。
        GameObject bullet = Instantiate(bulletPrefab, transform); // 实例化子弹并挂到池节点下。
        _createdCount++; // 增加已创建数量。
        return bullet; // 返回新创建的子弹对象。
    } // 方法结束。
} // 类结束。

面试加分点

预热不是越多越好。预热太少,运行时还是会临时扩容卡顿;预热太多,会浪费内存。比较好的做法是:先按峰值估算,再用 Profiler 或日志记录池子的最大使用量,之后调整预热数量。

一句话总结:预热就是把运行时创建对象的尖峰,提前搬到 Loading 或进场阶段,并且最好分帧完成。

如何扩容?

csharp-unity-object-pool-expand

标准答案

对象池扩容就是:当池子里的空闲对象不够用时,按规则补充新对象。但不能池空就无限 Instantiate,否则对象池会变成“内存持续上涨”的问题来源。

我一般会这样设计:

  1. 池空时才触发扩容
  2. 每次扩一批,而不是永远只扩 1 个
  3. 设置最大容量 maxCount
  4. 超过上限时做兜底:丢弃、等待、复用旧对象
  5. 记录最大活跃数量,反过来调整预热数量

底层思路

对象池扩容本质是在处理一个问题:预热数量低于运行时峰值

比如预热了 50 个子弹,但某一瞬间需要 80 个,那池子就会空。这个时候可以临时创建 10 个或 20 个补进去。

但是扩容有代价:Instantiate 仍然会产生 CPU 开销,GameObject、Transform、Renderer、Collider 等组件也会占内存。所以扩容一定要有上限。

代码例子

c
using System.Collections.Generic; // 引入 Stack 和 HashSet,用来管理对象池。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、Mathf。

public class ExpandableObjectPool : MonoBehaviour // 定义一个支持扩容的对象池。
{ // 类开始。
    [SerializeField] private GameObject prefab; // 池子要创建的预制体。
    [SerializeField] private int preloadCount = 30; // 初始预热数量。
    [SerializeField] private int expandStep = 10; // 每次扩容数量。
    [SerializeField] private int maxCount = 100; // 池子允许创建的最大对象数量。

    private readonly Stack<GameObject> _pool = new Stack<GameObject>(); // 保存空闲对象。
    private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象,防止重复回收。
    private int _totalCount; // 记录池子总共创建过多少对象。
    private int _activeCount; // 记录当前正在使用的对象数量。
    private int _maxActiveCount; // 记录历史最大活跃数量,方便后续调参。

    private void Awake() // Unity 初始化时调用。
    { // 方法开始。
        Expand(preloadCount); // 启动时先预热一批对象。
    } // 方法结束。

    public GameObject Get(Vector3 position, Quaternion rotation) // 从池子里取出对象。
    { // 方法开始。
        if (_pool.Count == 0) // 如果当前没有空闲对象。
        { // 判断开始。
            Expand(expandStep); // 按固定步长扩容一批对象。
        } // 判断结束。

        if (_pool.Count == 0) // 如果扩容后还是没有对象,说明达到上限。
        { // 判断开始。
            return null; // 返回空,让上层决定丢弃、等待或降级。
        } // 判断结束。

        GameObject obj = _pool.Pop(); // 从空闲栈中取出一个对象。
        _inPool.Remove(obj); // 标记这个对象已经不在池中。
        _activeCount++; // 活跃对象数量加一。
        _maxActiveCount = Mathf.Max(_maxActiveCount, _activeCount); // 更新历史最大活跃数量。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象的位置和旋转。
        obj.SetActive(true); // 激活对象,让它参与显示和逻辑。
        return obj; // 返回可用对象。
    } // 方法结束。

    public void Return(GameObject obj) // 把对象归还到池子。
    { // 方法开始。
        if (obj == null) // 如果传入对象为空。
        { // 判断开始。
            return; // 直接返回,避免空引用。
        } // 判断结束。

        if (_inPool.Contains(obj)) // 如果对象已经在池中。
        { // 判断开始。
            return; // 直接返回,避免重复回收。
        } // 判断结束。

        ResetObject(obj); // 重置对象状态,避免下次取出时残留旧数据。
        obj.SetActive(false); // 关闭对象。
        obj.transform.SetParent(transform); // 放回对象池节点下面。
        _pool.Push(obj); // 放入空闲栈。
        _inPool.Add(obj); // 标记对象已经在池中。
        _activeCount = Mathf.Max(0, _activeCount - 1); // 活跃数量减一,并避免变成负数。
    } // 方法结束。

    private void Expand(int count) // 执行扩容。
    { // 方法开始。
        int canCreate = Mathf.Min(count, maxCount - _totalCount); // 计算本次最多还能创建多少个。

        for (int i = 0; i < canCreate; i++) // 按允许数量创建对象。
        { // 循环开始。
            GameObject obj = Instantiate(prefab, transform); // 创建新对象,Unity 对象创建通常要在主线程。
            _totalCount++; // 总创建数量加一。
            ResetObject(obj); // 初始化对象状态。
            obj.SetActive(false); // 创建后先关闭,等待取出使用。
            _pool.Push(obj); // 放入空闲栈。
            _inPool.Add(obj); // 标记对象已经在池中。
        } // 循环结束。
    } // 方法结束。

    private void ResetObject(GameObject obj) // 重置对象状态。
    { // 方法开始。
        obj.transform.SetParent(transform); // 重置父节点,避免留在旧逻辑节点下。
        obj.transform.localScale = Vector3.one; // 重置缩放,避免上次使用残留。
    } // 方法结束。
} // 类结束。

工程建议

低频对象可以每次扩 1 个,高频对象建议一次扩一批,比如子弹、飘字、特效。扩容最好不要无限制,maxCount 很重要。

超过上限时要看对象类型处理:普通飘字、低优先级特效可以丢弃;关键子弹或怪物可以等待、复用最老对象,或者提前把预热数量调大。

面试加分点

我会说:扩容次数、最大活跃数量、池空次数都应该打点。比如发现某个特效池经常扩容,说明预热数量偏小;如果创建了很多但长期不用,说明池子过大,需要回收或降低上限。

如何回收?

csharp-unity-object-pool-recycle

标准答案

对象池扩容就是:当池子里的空闲对象不够用时,按规则补充新对象。但不能池空就无限 Instantiate,否则对象池会变成“内存持续上涨”的问题来源。

我一般会这样设计:

  1. 池空时才触发扩容
  2. 每次扩一批,而不是永远只扩 1 个
  3. 设置最大容量 maxCount
  4. 超过上限时做兜底:丢弃、等待、复用旧对象
  5. 记录最大活跃数量,反过来调整预热数量

底层思路

对象池扩容本质是在处理一个问题:预热数量低于运行时峰值

比如预热了 50 个子弹,但某一瞬间需要 80 个,那池子就会空。这个时候可以临时创建 10 个或 20 个补进去。

但是扩容有代价:Instantiate 仍然会产生 CPU 开销,GameObject、Transform、Renderer、Collider 等组件也会占内存。所以扩容一定要有上限。

代码例子

c
using System.Collections.Generic; // 引入 Stack 和 HashSet,用来管理对象池。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、Mathf。

public class ExpandableObjectPool : MonoBehaviour // 定义一个支持扩容的对象池。
{ // 类开始。
    [SerializeField] private GameObject prefab; // 池子要创建的预制体。
    [SerializeField] private int preloadCount = 30; // 初始预热数量。
    [SerializeField] private int expandStep = 10; // 每次扩容数量。
    [SerializeField] private int maxCount = 100; // 池子允许创建的最大对象数量。

    private readonly Stack<GameObject> _pool = new Stack<GameObject>(); // 保存空闲对象。
    private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象,防止重复回收。
    private int _totalCount; // 记录池子总共创建过多少对象。
    private int _activeCount; // 记录当前正在使用的对象数量。
    private int _maxActiveCount; // 记录历史最大活跃数量,方便后续调参。

    private void Awake() // Unity 初始化时调用。
    { // 方法开始。
        Expand(preloadCount); // 启动时先预热一批对象。
    } // 方法结束。

    public GameObject Get(Vector3 position, Quaternion rotation) // 从池子里取出对象。
    { // 方法开始。
        if (_pool.Count == 0) // 如果当前没有空闲对象。
        { // 判断开始。
            Expand(expandStep); // 按固定步长扩容一批对象。
        } // 判断结束。

        if (_pool.Count == 0) // 如果扩容后还是没有对象,说明达到上限。
        { // 判断开始。
            return null; // 返回空,让上层决定丢弃、等待或降级。
        } // 判断结束。

        GameObject obj = _pool.Pop(); // 从空闲栈中取出一个对象。
        _inPool.Remove(obj); // 标记这个对象已经不在池中。
        _activeCount++; // 活跃对象数量加一。
        _maxActiveCount = Mathf.Max(_maxActiveCount, _activeCount); // 更新历史最大活跃数量。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象的位置和旋转。
        obj.SetActive(true); // 激活对象,让它参与显示和逻辑。
        return obj; // 返回可用对象。
    } // 方法结束。

    public void Return(GameObject obj) // 把对象归还到池子。
    { // 方法开始。
        if (obj == null) // 如果传入对象为空。
        { // 判断开始。
            return; // 直接返回,避免空引用。
        } // 判断结束。

        if (_inPool.Contains(obj)) // 如果对象已经在池中。
        { // 判断开始。
            return; // 直接返回,避免重复回收。
        } // 判断结束。

        ResetObject(obj); // 重置对象状态,避免下次取出时残留旧数据。
        obj.SetActive(false); // 关闭对象。
        obj.transform.SetParent(transform); // 放回对象池节点下面。
        _pool.Push(obj); // 放入空闲栈。
        _inPool.Add(obj); // 标记对象已经在池中。
        _activeCount = Mathf.Max(0, _activeCount - 1); // 活跃数量减一,并避免变成负数。
    } // 方法结束。

    private void Expand(int count) // 执行扩容。
    { // 方法开始。
        int canCreate = Mathf.Min(count, maxCount - _totalCount); // 计算本次最多还能创建多少个。

        for (int i = 0; i < canCreate; i++) // 按允许数量创建对象。
        { // 循环开始。
            GameObject obj = Instantiate(prefab, transform); // 创建新对象,Unity 对象创建通常要在主线程。
            _totalCount++; // 总创建数量加一。
            ResetObject(obj); // 初始化对象状态。
            obj.SetActive(false); // 创建后先关闭,等待取出使用。
            _pool.Push(obj); // 放入空闲栈。
            _inPool.Add(obj); // 标记对象已经在池中。
        } // 循环结束。
    } // 方法结束。

    private void ResetObject(GameObject obj) // 重置对象状态。
    { // 方法开始。
        obj.transform.SetParent(transform); // 重置父节点,避免留在旧逻辑节点下。
        obj.transform.localScale = Vector3.one; // 重置缩放,避免上次使用残留。
    } // 方法结束。
} // 类结束。

工程建议

低频对象可以每次扩 1 个,高频对象建议一次扩一批,比如子弹、飘字、特效。扩容最好不要无限制,maxCount 很重要。

超过上限时要看对象类型处理:普通飘字、低优先级特效可以丢弃;关键子弹或怪物可以等待、复用最老对象,或者提前把预热数量调大。

面试加分点

我会说:扩容次数、最大活跃数量、池空次数都应该打点。比如发现某个特效池经常扩容,说明预热数量偏小;如果创建了很多但长期不用,说明池子过大,需要回收或降低上限。

如何防重复回收?

csharp-unity-object-pool-prevent-double-return

标准答案

防重复回收的核心是:Return 方法变成幂等操作。也就是同一个对象就算被回收多次,也最多只会真正放回池里一次。

重复回收很常见,比如子弹命中敌人时回收,同时生命周期超时也回收;或者 UI 关闭时统一回收,Item 自己也触发了一次回收。如果不防,池里可能出现两个相同对象,后面两个系统会同时取到同一个实例,Bug 会非常难查。

常用做法

  1. HashSet 记录哪些对象已经在池里。
  2. 或者给对象加一个 isInPool 状态位。
  3. Return 开头先判断,已经在池里就直接返回。
  4. 入池时立刻标记 inPool = true
  5. 取出时立刻标记 inPool = false
  6. 开发期重复回收可以打 Warning,方便定位来源。

代码例子

c
using System.Collections.Generic; // 引入 Queue 和 HashSet,用来管理对象池。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 MonoBehaviour。

public class SafeObjectPool : MonoBehaviour // 定义一个防重复回收的对象池。
{ // 类开始。
    [SerializeField] private GameObject prefab; // 需要池化的预制体。
    private readonly Queue<GameObject> _pool = new Queue<GameObject>(); // 保存空闲对象。
    private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象。
    private readonly HashSet<GameObject> _createdByThisPool = new HashSet<GameObject>(); // 记录属于这个池创建的对象。

    public GameObject Get(Vector3 position, Quaternion rotation) // 从池中取出对象。
    { // 方法开始。
        GameObject obj = _pool.Count > 0 ? _pool.Dequeue() : CreateOne(); // 池中有对象就取出,没有就创建。
        _inPool.Remove(obj); // 标记对象不在池中,表示正在使用。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转。
        obj.SetActive(true); // 激活对象。
        return obj; // 返回对象给调用方。
    } // 方法结束。

    public void Return(GameObject obj) // 把对象归还到池中。
    { // 方法开始。
        if (obj == null) // 如果对象为空。
        { // 判断开始。
            return; // 直接返回,避免空引用。
        } // 判断结束。

        if (!_createdByThisPool.Contains(obj)) // 如果对象不是这个池创建的。
        { // 判断开始。
            Debug.LogWarning("非法对象不能回收到这个池"); // 开发期提示对象归属错误。
            return; // 直接返回,避免把外部对象塞进池里。
        } // 判断结束。

        if (_inPool.Contains(obj)) // 如果对象已经在池中。
        { // 判断开始。
            Debug.LogWarning("对象被重复回收,已忽略"); // 开发期提示重复回收问题。
            return; // 直接返回,保证同一个对象不会入池两次。
        } // 判断结束。

        ResetObject(obj); // 回收前重置对象状态。
        obj.SetActive(false); // 关闭对象。
        obj.transform.SetParent(transform); // 把对象挂回池节点下面。
        _pool.Enqueue(obj); // 把对象放回空闲队列。
        _inPool.Add(obj); // 标记对象已经在池中。
    } // 方法结束。

    private GameObject CreateOne() // 创建一个新对象。
    { // 方法开始。
        GameObject obj = Instantiate(prefab, transform); // 实例化预制体。
        _createdByThisPool.Add(obj); // 记录这个对象属于当前池。
        obj.SetActive(false); // 新对象先关闭。
        return obj; // 返回新对象。
    } // 方法结束。

    private void ResetObject(GameObject obj) // 重置对象状态。
    { // 方法开始。
        obj.transform.localScale = Vector3.one; // 重置缩放。
    } // 方法结束。
} // 类结束。

更高级的坑:旧回调

有时候对象已经回收并再次取出,但上一次使用留下的延迟协程、计时器、网络回调又触发了 Return。这时只靠 HashSet 还不够,最好给对象加一个 versiontoken

思路是:每次取出对象时 version 加一,延迟回调触发时先检查 version 是否还是当时那一版。不一致就说明这是旧回调,直接丢弃。

面试加分点

不要只用 obj.activeSelf 判断是否在池中,因为对象可能只是被临时隐藏,但仍然属于业务使用中。SetActive 状态不是池状态。池状态应该由对象池自己维护。

一句话总结:防重复回收就是让对象池自己兜底,不能完全相信调用方;核心手段是 HashSet、状态位、owner 校验和 version。

如何重置对象?

csharp-unity-object-pool-reset-object

标准答案

对象池重置对象的核心是:把上一次使用留下的所有状态清干净,让下次取出来时像新对象一样可靠。

不能只写一句 SetActive(false)。因为对象虽然隐藏了,但它身上的速度、目标引用、事件监听、粒子残留、拖尾、动画状态、计时器都可能还在。

一般要重置哪些东西

  1. 通用状态:父节点、位置、旋转、缩放、Layer、Tag。
  2. 物理状态Rigidbody.velocityangularVelocity、碰撞开关。
  3. 表现状态TrailRenderer.Clear()、粒子停止、Animator 回到初始状态。
  4. 业务状态:目标引用、伤害值、生命周期计时、是否命中、事件监听。
  5. 异步状态:协程、计时器、延迟回调、旧 version。

推荐设计

通用对象池只负责生命周期:取出、归还、队列管理。

具体对象自己实现重置接口,比如 IPoolable。因为子弹、特效、UI Item 要清理的东西不一样,不能全部写死在对象池里。

代码例子

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Rigidbody、TrailRenderer 等类型。

public interface IPoolable // 定义一个池化对象接口。
{ // 接口开始。
    void OnSpawnFromPool(); // 对象从池中取出时调用,用来初始化本次使用状态。
    void OnReturnToPool(); // 对象归还到池中时调用,用来清理上次使用状态。
} // 接口结束。

public class PooledBullet : MonoBehaviour, IPoolable // 定义一个可池化子弹。
{ // 类开始。
    private Rigidbody _rigidbody; // 缓存 Rigidbody 组件。
    private TrailRenderer _trail; // 缓存 TrailRenderer 组件。
    private Transform _target; // 保存当前追踪目标。
    private float _lifeTimer; // 保存生命周期计时。
    private bool _hasHit; // 保存是否已经命中过目标。

    private void Awake() // Unity 初始化时调用。
    { // 方法开始。
        _rigidbody = GetComponent<Rigidbody>(); // 获取 Rigidbody 组件。
        _trail = GetComponent<TrailRenderer>(); // 获取 TrailRenderer 组件。
    } // 方法结束。

    public void Init(Transform target) // 子弹取出后设置本次使用参数。
    { // 方法开始。
        _target = target; // 设置本次追踪目标。
        _lifeTimer = 0f; // 重置生命周期计时。
        _hasHit = false; // 重置命中状态。
    } // 方法结束。

    public void OnSpawnFromPool() // 从对象池取出时调用。
    { // 方法开始。
        gameObject.SetActive(true); // 激活对象。
        _lifeTimer = 0f; // 重置生命周期计时。
        _hasHit = false; // 重置命中状态。
        if (_trail != null) // 如果存在拖尾组件。
        { // 判断开始。
            _trail.Clear(); // 清空旧拖尾。
        } // 判断结束。
    } // 方法结束。

    public void OnReturnToPool() // 归还到对象池时调用。
    { // 方法开始。
        _target = null; // 清空目标引用,避免引用旧敌人。
        _lifeTimer = 0f; // 清空生命周期计时。
        _hasHit = false; // 清空命中状态。

        if (_rigidbody != null) // 如果存在 Rigidbody。
        { // 判断开始。
            _rigidbody.velocity = Vector3.zero; // 清空线速度。
            _rigidbody.angularVelocity = Vector3.zero; // 清空角速度。
        } // 判断结束。

        if (_trail != null) // 如果存在拖尾组件。
        { // 判断开始。
            _trail.Clear(); // 清空旧拖尾。
        } // 判断结束。

        transform.SetParent(null); // 根据项目需求清理父节点,也可以改成挂回池节点。
        gameObject.SetActive(false); // 最后关闭对象。
    } // 方法结束。
} // 类结束。

面试加分点

我会强调:取出初始化和归还清理要分开

OnSpawnFromPool 负责设置本次使用需要的数据,比如位置、目标、伤害。

OnReturnToPool 负责清理上次使用留下的数据,比如速度、特效、事件、协程、引用。

一句话总结:通用池只管理生命周期,具体对象自己实现 ResetForPool;谁有状态,谁负责清理。

如何处理不同 Prefab?

csharp-unity-object-pool-multiple-prefabs

标准答案

不同 Prefab 不能混在同一个对象池里。正确做法是:一个 Prefab 对应一个子池,外层用 PoolManager 根据 key 找到对应的子池

比如:

c
bullet_basic -> BulletPool
vfx_explode  -> VfxPool
damage_text  -> TextPool

取对象时按 key 取,归还时按对象身上记录的 PoolKeyOwnerPool 回到原来的池。

为什么不能混池

因为不同 Prefab 的组件、重置逻辑、生命周期都不一样。子弹要重置速度、目标、碰撞;特效要重置粒子和拖尾;UI 飘字要重置文本、颜色、动画。如果混在一个池里,很容易拿到错误类型,或者旧状态残留。

代码例子

c
using System.Collections.Generic; // 引入 Dictionary、Queue、HashSet。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、Vector3、Quaternion。

public class PoolIdentity : MonoBehaviour // 给池中对象挂一个身份组件。
{ // 类开始。
    public string PoolKey { get; set; } // 记录这个对象属于哪个 Prefab 池。
} // 类结束。

public class MultiPrefabPoolManager : MonoBehaviour // 多 Prefab 对象池管理器。
{ // 类开始。
    private readonly Dictionary<string, GameObject> _prefabs = new Dictionary<string, GameObject>(); // key 到 Prefab 的映射。
    private readonly Dictionary<string, Queue<GameObject>> _pools = new Dictionary<string, Queue<GameObject>>(); // key 到空闲队列的映射。
    private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象,防止重复回收。

    public void Register(string key, GameObject prefab, int prewarmCount) // 注册一种 Prefab。
    { // 方法开始。
        _prefabs[key] = prefab; // 保存 key 和 Prefab 的对应关系。
        _pools[key] = new Queue<GameObject>(); // 为这个 Prefab 创建一个独立队列。

        for (int i = 0; i < prewarmCount; i++) // 按预热数量创建对象。
        { // 循环开始。
            GameObject obj = CreateObject(key); // 创建属于这个 key 的对象。
            Return(obj); // 创建后立刻放回对应池中。
        } // 循环结束。
    } // 方法结束。

    public GameObject Get(string key, Vector3 position, Quaternion rotation) // 根据 key 取出对象。
    { // 方法开始。
        if (!_prefabs.ContainsKey(key)) // 如果没有注册这个 key。
        { // 判断开始。
            return null; // 返回空,避免取到错误对象。
        } // 判断结束。

        Queue<GameObject> pool = _pools[key]; // 找到这个 key 对应的子池。
        GameObject obj = pool.Count > 0 ? pool.Dequeue() : CreateObject(key); // 有空闲对象就取,没有就扩容创建。
        _inPool.Remove(obj); // 标记对象已经离开池子。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置本次使用的位置和旋转。
        obj.SetActive(true); // 激活对象。
        return obj; // 返回对象。
    } // 方法结束。

    public void Return(GameObject obj) // 归还对象。
    { // 方法开始。
        if (obj == null) // 如果对象为空。
        { // 判断开始。
            return; // 直接返回。
        } // 判断结束。

        PoolIdentity identity = obj.GetComponent<PoolIdentity>(); // 获取对象身份组件。
        if (identity == null || !_pools.ContainsKey(identity.PoolKey)) // 如果对象不属于任何已知池。
        { // 判断开始。
            Destroy(obj); // 非法对象不入池,直接销毁或报警。
            return; // 结束归还流程。
        } // 判断结束。

        if (_inPool.Contains(obj)) // 如果对象已经在池中。
        { // 判断开始。
            return; // 防止重复回收。
        } // 判断结束。

        obj.SetActive(false); // 关闭对象。
        obj.transform.SetParent(transform); // 放回池管理节点下面。
        _pools[identity.PoolKey].Enqueue(obj); // 按 PoolKey 回到自己的子池。
        _inPool.Add(obj); // 标记对象已经在池中。
    } // 方法结束。

    private GameObject CreateObject(string key) // 创建指定 key 的对象。
    { // 方法开始。
        GameObject obj = Instantiate(_prefabs[key], transform); // 根据 key 找到 Prefab 并实例化。
        PoolIdentity identity = obj.GetComponent<PoolIdentity>(); // 尝试获取身份组件。
        if (identity == null) // 如果对象没有身份组件。
        { // 判断开始。
            identity = obj.AddComponent<PoolIdentity>(); // 动态添加身份组件。
        } // 判断结束。
        identity.PoolKey = key; // 记录对象属于哪个子池。
        obj.SetActive(false); // 新对象默认关闭。
        return obj; // 返回创建好的对象。
    } // 方法结束。
} // 类结束。

工程建议

key 不建议只用 Prefab 名字,因为可能重名。项目里更推荐用配置表 ID、Addressables key、资源路径或枚举 ID。

如果是 Prefab Variant,也建议作为不同 key 管理,除非它们组件结构、重置逻辑、生命周期完全一样。

面试加分点

我会补一句:多 Prefab 对象池本质是 PoolManager 加多个 SubPoolPoolManager 负责根据 key 路由,SubPool 负责具体实例的预热、取出、归还、扩容和清空。归还时必须校验 owner,避免对象回错池。

如何切场景清理?

csharp-unity-object-pool-scene-cleanup

标准答案

切场景清理对象池,我会按这个顺序做:

  1. 先封入口:设置 isCleaning = true,切场景期间不允许继续 Get 新对象。
  2. 回收活跃对象:把正在飞的子弹、播放中的特效、飘字、怪物临时对象统一停止和重置。
  3. 区分池类型:场景级池直接销毁清空;全局池只回收活跃对象,并裁剪多余空闲对象。
  4. 清引用:清掉对旧场景 Transform、怪物、UI、特效目标的引用。
  5. 释放资源:如果 Prefab 来自 Addressables 或 AssetBundle,要在实例销毁后释放资源句柄。

代码例子

c
using System.Collections.Generic; // 引入集合类型,用来保存活跃对象和空闲对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、MonoBehaviour、Vector3、Quaternion。
using UnityEngine.SceneManagement; // 引入场景管理,用来监听场景卸载。
public class PoolObject : MonoBehaviour // 定义池对象基类,让具体对象自己清理状态。
{ // 类开始。
    public virtual void OnReturnToPool() // 对象回收前调用。
    { // 方法开始。
    } // 方法结束。
} // 类结束。
public class SceneBoundPool : MonoBehaviour // 定义一个场景级对象池。
{ // 类开始。
    private readonly List<GameObject> _active = new List<GameObject>(); // 保存正在使用的对象。
    private readonly Queue<GameObject> _idle = new Queue<GameObject>(); // 保存空闲可复用的对象。
    private bool _isCleaning; // 标记当前是否正在切场景清理。
    private void OnEnable() // 对象启用时调用。
    { // 方法开始。
        SceneManager.sceneUnloaded += OnSceneUnloaded; // 监听场景卸载事件。
    } // 方法结束。
    private void OnDisable() // 对象禁用时调用。
    { // 方法开始。
        SceneManager.sceneUnloaded -= OnSceneUnloaded; // 取消监听,避免事件残留。
    } // 方法结束。
    public GameObject Get(GameObject prefab, Vector3 position, Quaternion rotation) // 从池中取对象。
    { // 方法开始。
        if (_isCleaning) // 如果正在清理。
        { // 判断开始。
            return null; // 直接拒绝生成新对象。
        } // 判断结束。
        GameObject obj = _idle.Count > 0 ? _idle.Dequeue() : Instantiate(prefab); // 优先复用空闲对象,没有就创建。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转。
        obj.SetActive(true); // 激活对象。
        _active.Add(obj); // 记录为活跃对象。
        return obj; // 返回对象。
    } // 方法结束。
    public void Return(GameObject obj) // 正常回收单个对象。
    { // 方法开始。
        if (obj == null) // 如果对象为空。
        { // 判断开始。
            return; // 直接返回。
        } // 判断结束。
        _active.Remove(obj); // 从活跃列表移除。
        if (obj.TryGetComponent<PoolObject>(out PoolObject poolObject)) // 如果对象有池化组件。
        { // 判断开始。
            poolObject.OnReturnToPool(); // 让对象自己清理速度、事件、特效、引用。
        } // 判断结束。
        obj.SetActive(false); // 关闭对象。
        obj.transform.SetParent(transform); // 挂回池节点下面。
        _idle.Enqueue(obj); // 放回空闲队列。
    } // 方法结束。
    private void OnSceneUnloaded(Scene scene) // 场景卸载时触发。
    { // 方法开始。
        ClearForSceneSwitch(); // 执行切场景清理。
    } // 方法结束。
    public void ClearForSceneSwitch() // 清理场景级对象池。
    { // 方法开始。
        _isCleaning = true; // 封住入口,避免清理时继续生成对象。
        for (int i = _active.Count - 1; i >= 0; i--) // 倒序遍历所有活跃对象。
        { // 循环开始。
            GameObject obj = _active[i]; // 取出当前活跃对象。
            if (obj == null) // 如果对象已经被销毁。
            { // 判断开始。
                continue; // 跳过这个对象。
            } // 判断结束。
            if (obj.TryGetComponent<PoolObject>(out PoolObject poolObject)) // 如果对象有池化组件。
            { // 判断开始。
                poolObject.OnReturnToPool(); // 清理对象内部状态和引用。
            } // 判断结束。
            Destroy(obj); // 场景级池切场景时直接销毁活跃对象。
        } // 循环结束。
        _active.Clear(); // 清空活跃列表。
        while (_idle.Count > 0) // 遍历所有空闲对象。
        { // 循环开始。
            GameObject obj = _idle.Dequeue(); // 取出一个空闲对象。
            if (obj != null) // 如果对象还存在。
            { // 判断开始。
                Destroy(obj); // 销毁空闲对象。
            } // 判断结束。
        } // 循环结束。
        _isCleaning = false; // 清理完成后恢复状态。
    } // 方法结束。
} // 类结束。

面试加分点

我会特别强调:如果对象池管理器用了 DontDestroyOnLoad,它不会自动随着场景销毁,所以必须主动清掉旧场景引用。否则池里可能还拿着旧敌人、旧 UI、旧 Transform,导致切场景后内存不下降,甚至出现 Missing Reference。

UnloadUnusedAssets() 可以辅助释放不用资源,但它本身很重,最好放在 Loading 阶段做,不要在战斗中突然调用。

如何统计命中率?

csharp-unity-object-pool-hit-rate

标准答案

这里的命中率指对象池的命中率:调用 Get 时,如果能直接从空闲池取到对象,就是命中;如果池空了,需要扩容、创建或失败兜底,就是未命中。

公式是:

命中率 = 命中次数 / Get 请求总次数 * 100%

但只看命中率还不够,最好一起统计:

请求次数 requestCount
命中次数 hitCount
未命中次数 missCount
扩容次数 expandCount
创建次数 createCount
失败次数 failCount
最大活跃数 maxActiveCount
最大空闲数 maxIdleCount

代码例子

c
using System.Collections.Generic; // 引入 Queue,用来保存对象池空闲对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 MonoBehaviour。

public class PoolStats // 定义对象池统计数据类。
{ // 类开始。
    public int RequestCount; // Get 请求总次数。
    public int HitCount; // 直接从空闲池取到对象的次数。
    public int MissCount; // 池空导致未命中的次数。
    public int ExpandCount; // 触发扩容的次数。
    public int CreateCount; // 实际创建对象的次数。
    public int FailCount; // 达到上限后无法返回对象的次数。
    public int ActiveCount; // 当前活跃对象数量。
    public int MaxActiveCount; // 历史最大活跃对象数量。

    public float HitRate // 计算命中率。
    { // 属性开始。
        get // 获取命中率。
        { // get 开始。
            if (RequestCount == 0) // 如果没有请求。
            { // 判断开始。
                return 1f; // 没有请求时可以认为没有压力,返回 100%。
            } // 判断结束。
            return (float)HitCount / RequestCount; // 返回命中次数除以请求总数。
        } // get 结束。
    } // 属性结束。
} // 类结束。

public class StatObjectPool : MonoBehaviour // 定义一个带统计的对象池。
{ // 类开始。
    [SerializeField] private GameObject prefab; // 需要池化的预制体。
    [SerializeField] private int maxCount = 100; // 池子最大创建数量。
    private readonly Queue<GameObject> _idle = new Queue<GameObject>(); // 保存空闲对象。
    private readonly PoolStats _stats = new PoolStats(); // 保存当前池的统计数据。
    private int _totalCount; // 保存总共创建过的对象数量。

    public GameObject Get() // 从池里取对象。
    { // 方法开始。
        _stats.RequestCount++; // 每次 Get 都记录一次请求。

        if (_idle.Count > 0) // 如果空闲池里有对象。
        { // 判断开始。
            _stats.HitCount++; // 记录一次命中。
            GameObject obj = _idle.Dequeue(); // 从空闲队列取出对象。
            Activate(obj); // 激活对象并更新活跃数量。
            return obj; // 返回对象。
        } // 判断结束。

        _stats.MissCount++; // 空闲池为空,记录一次未命中。

        if (_totalCount < maxCount) // 如果还没有达到最大容量。
        { // 判断开始。
            _stats.ExpandCount++; // 记录一次扩容。
            GameObject obj = Instantiate(prefab, transform); // 创建新对象。
            _totalCount++; // 总创建数量加一。
            _stats.CreateCount++; // 记录创建次数。
            Activate(obj); // 激活对象并更新活跃数量。
            return obj; // 返回新对象。
        } // 判断结束。

        _stats.FailCount++; // 达到上限后无法提供对象,记录失败次数。
        return null; // 返回空,让上层选择丢弃、等待或降级。
    } // 方法结束。

    public void Return(GameObject obj) // 归还对象。
    { // 方法开始。
        if (obj == null) // 如果对象为空。
        { // 判断开始。
            return; // 直接返回。
        } // 判断结束。

        obj.SetActive(false); // 关闭对象。
        obj.transform.SetParent(transform); // 挂回池节点下面。
        _idle.Enqueue(obj); // 放回空闲队列。
        _stats.ActiveCount = Mathf.Max(0, _stats.ActiveCount - 1); // 活跃数量减一,并防止小于零。
    } // 方法结束。

    private void Activate(GameObject obj) // 激活对象。
    { // 方法开始。
        obj.SetActive(true); // 打开对象。
        _stats.ActiveCount++; // 活跃数量加一。
        _stats.MaxActiveCount = Mathf.Max(_stats.MaxActiveCount, _stats.ActiveCount); // 更新历史最大活跃数量。
    } // 方法结束。

    public void PrintStats() // 打印统计信息。
    { // 方法开始。
        Debug.Log($"命中率:{_stats.HitRate:P1}, 请求:{_stats.RequestCount}, 未命中:{_stats.MissCount}, 扩容:{_stats.ExpandCount}, 最大活跃:{_stats.MaxActiveCount}"); // 输出关键指标。
    } // 方法结束。
} // 类结束。

工程判断

如果命中率低,说明预热数量可能太少,或者对象回收太慢。

如果命中率很高,但空闲对象长期很多,说明池子可能过大,常驻内存浪费。

如果扩容次数很多,说明预热策略不合理。

如果失败次数很多,说明最大容量太小,或者玩法瞬时峰值超出预期。

面试加分点

不要只看全局命中率,要按 Prefab key 分开统计。比如全局命中率 95%,但某个技能特效池只有 60%,这个池仍然会造成卡顿尖峰。

一句话总结:命中率看池子是否够用,最大活跃数看预热该调多少,空闲峰值看是否浪费内存。

如何证明优化有效?

csharp-unity-prove-optimization-effective

标准答案

证明优化有效,不能只说“感觉不卡了”,要拿同条件 Before / After 数据说话。

我一般按这个流程:

  1. 先确认问题:是 CPU 卡、GPU 卡、GC、内存、加载慢,还是网络慢。
  2. 建立优化前基线:用 Profiler、Memory Profiler、Frame Debugger、日志记录优化前数据。
  3. 控制测试条件:同一设备、同一场景、同一账号、同一操作路径、同一画质设置。
  4. 只改一个主要变量:避免多个改动混在一起,不知道是哪一个生效。
  5. 复测并对比数据:看平均值、峰值、p95、p99,而不是只看 FPS。
  6. 防止回退:把关键指标接入日志、调试面板或自动化测试。

例子

如果我优化的是对象池,我会这样证明:

c
优化前:
Main Thread p95:24 ms
GC Alloc / frame:3 KB
Instantiate 次数:1000
卡顿尖峰:12/ 分钟

优化后:
Main Thread p95:13 ms
GC Alloc / frame:0 B
Instantiate 次数:30
卡顿尖峰:1/ 分钟

这样面试官会觉得你的优化是有证据的,不是凭感觉。

代码例子

c
using UnityEngine; // 引入 UnityEngine,用来使用 MonoBehaviour 和 Time。
using UnityEngine.Profiling; // 引入 Profiler,用来添加自定义采样点。

public class OptimizationMeasureDemo : MonoBehaviour // 定义一个简单的优化测量脚本。
{ // 类开始。
    private int _frameCount; // 记录累计帧数。
    private float _totalFrameMs; // 记录累计帧耗时,单位毫秒。
    private float _maxFrameMs; // 记录最大单帧耗时,单位毫秒。

    private void Update() // Unity 每帧调用 Update。
    { // 方法开始。
        Profiler.BeginSample("MySystem.UpdateCost"); // 在 Profiler 里标记当前系统的耗时范围。
        SimulateWork(); // 执行需要观察的逻辑。
        Profiler.EndSample(); // 结束 Profiler 标记。

        float frameMs = Time.deltaTime * 1000f; // 把当前帧耗时从秒转换为毫秒。
        _frameCount++; // 累计帧数加一。
        _totalFrameMs += frameMs; // 累加帧耗时。
        _maxFrameMs = Mathf.Max(_maxFrameMs, frameMs); // 更新最大帧耗时。

        if (_frameCount == 300) // 每 300 帧输出一次统计。
        { // 判断开始。
            Debug.Log($"平均帧耗时:{_totalFrameMs / _frameCount:F2}ms, 最大帧耗时:{_maxFrameMs:F2}ms"); // 输出统计结果。
            _frameCount = 0; // 重置帧数。
            _totalFrameMs = 0f; // 重置累计耗时。
            _maxFrameMs = 0f; // 重置最大耗时。
        } // 判断结束。
    } // 方法结束。

    private void SimulateWork() // 模拟需要测量的业务逻辑。
    { // 方法开始。
    } // 方法结束。
} // 类结束。

面试加分点

我会补一句:FPS 是结果,不是原因。真正证明优化有效,要看 Profiler 里的具体指标,比如 Main ThreadGC AllocCanvas.BuildBatchCamera.RenderDrawCallSetPass、峰值内存、加载耗时。

一句话总结:优化要能定位、能量化、能复现、能对比、能防回退。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0